본문 바로가기

개발/클린 코드

클린 코드 - 부록 A 동시성 2

본 챕터에서는 동시성을 좀 더 자세히 설명하고 보완한다.

 

클라이언트/서버 예제

서버와 클라이언트의 단순한 소켓 프로그래밍 코드를 책에서는 보여주고 있다.

또한, 해당 테스트가 10초 내에 처리가 되는지를 확인하는 테스트코드를 구현하였다.

 

만약 테스트가 실패한다면? 이벤트 폴링 루프를 구현하면 모를까, 단일 스레드 환경에서 속도를 끌어올릴 방법은 거의 없다. 

다중 스레드를 사용하면 성능이 높아질까? 그럴지도 모르지만, 먼저 애플리케이션이 어디서 시간을 보내는지 알아야 한다. 가능성은 크게 아래의 2가지다.

 

  • I/O - 소켓 사용, 데이터베이스 연결, 가상 메모리 스와핑 기다리기 등에 시간을 보낸다.
  • 프로세서 - 수치 계산, 정규 표현식 처리, 가비지 컬렉션 등에 시간을 보낸다.

대게 시스템은 둘 다 하느라 시간을 보내지만, 특정 연산을 살펴보면 대개 하나가 지배적이다. 

 

만약 프로그램이 주로 프로세서 연산에 시간을 보낸다면, 새로운 하드웨어를 추가해 성능을 높여 테스트를 통과하는 방식이 적합하다. 프로세서 연산에 시간을 보내는 프로그램은 스레드를 늘인다고 빨라지지 않는다. CPU 사이클은 한계가 있기 때문이다.

 

반면 주로 I/O 연산에 시간을 보낸다면 동시성이 성능을 높여주기도 한다. 시스템 한쪽이 I/O를 기다리는 동안에 다른 쪽이 뭔가를 처리해 노는 CPU를 효과적으로 활용할 수 있다. 

 

스레드 추가하기

여기서는 스레드를 우선 추가한다. 

 

서버 살펴보기

  위의 서버는 테스트를 완료한다. 하지만, 단순하게 스레드를 추가하면 다소 부실하기에 새로운 문제를 일으킨다.

 

새 서버가 만드는 스레드 수는 몇 개일까? 코드에서 한계를 명시하지 않으므로 이론상으로 JVM이 허용하는 수까지 가능하다. 대다수 간단한 시스템은 그래도 괜찮다. 하지만 공용 네트워크에 연결된 수많은 사용자를 지원하는 시스템이라면 어떨까? 너무 많은 사용자가 시스템에 몰리면 동작을 멈출지도 모른다 ( 스레드 풀이 필요한 이유 ). 하지만 동작 문제는 잠시 미뤄둔다.

 

그 외에도 깨끗한 코드와 구조라는 관점에서도 문제가 있다. 서버 코드가 지는 책임이 몇 개일까?

  • 소켓 연결 관리
  • 클라이언트 처리
  • 스레드 정책
  • 서버 종료 정책

불행히도 이 모든 책임은 이 책의 예제에서는 process 함수가 진다.

void process(final Socket socket) {
    if (socket == null) {
        return;
    }
    
    Runnable clientHandler = new Runnable() {
        public void run() {
            try {
                String message = MessageUtils.getMessage(socket);
                MessageUtils.sendMessage(socket, "Processed: " + message);
                closeIgnoringException(socket);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    };

    Thread clientConnection = new Thread(clientHandler);
    clientConnection.start();
}

추상화 수준도 다양하다. process 함수가 작기는 하지만 확실히 분할할 필요가 있다.

   서버 프로그램은 고칠 이유가 여럿이다. 즉, 단일 책임 원칙 SRP을 위반한다. 잘 통제된 몇 곳으로 스레드 관리를 모아야 한다. 아니, 스레드를 관리하는 코드는 스레드만 관리해야 한다.

 

public interface ClientScheduler {
   void schedule(ClientRequestProcessor requestProcessor);
}
public class ThreadPerRequestScheduler implements ClientScheduler {
   public void schedule(final ClientRequestProecessor requestprocessor) {
      Runnablel runnable = new Runnable() {
         public void run() {
            requestProcessor.process();
         }
      };
      
      Thread thread = new Thread(runnable);
      thread.start();
   }
}

스레드 관리를 한 곳으로 몰았으니 스레드를 제어하는 동시성 정책을 바꾸기도 쉬워진다. 예를 들어, 자바 5 Executor 프레임워크로 옮기려면 새 클래스를 작성해 대체하면 그만이다.

public class ExecutorClientScheduler implements ClientScheduler {
   Executor executor;
   
   public ExecutorClientScheduler(int availableThreads) {
      executor = Executors.newFixedThreadPool(availableThreads);
   }
   
   public void schedule(final ClientRequestProcessor requestProcessor) {
      Runnable runnable = new Runnable() {
         public void run() {
            requestProcessor.process();
         }
      };
      executor.execute(runnable);
   }
}

결론

동시성은 그 자체가 복잡한 문제이므로 다중 스레드 프로그램에서는 단일 책임 원칙이 특히 중요하다.

 

 

가능한 실행 경로

다음 incrementValue 메서드를 살펴보자. 루프나 분기가 없는, 한 줄짜리 메서드다.

 

public class IdGenerator {
   int lastIdUsed;
   
   public int incrementValue() {
      return ++lastIdUsed;
   }
}

스레드 하나가 IdGenerator 인스턴스 하나를 사용한다고 가정한다. 그렇다면 가능한 실행 경로는 단 하나다. 가능한 결과도 단 하나다.

  • 반환값은 lastIdUsed 값과 동일하다. 두 값 모두 메서드를 호출하기 전보다 1이 크다.

만약 IdGenarator 인스턴스는 그대로지만 스레드가 두 개라면? 각 스레드가 함수를 한 번씩 호출한다면 가능한 결과는 무엇일까? 실행 경로는? 초깃값을 93으로 가정할 때 가능한 결과는 다음과 같다.

 

  • 스레드 1이 94, 2가 95, lastIdUsed가 95가 된다. 
  • 스레드 1이 95, 2가 94, lastIdUsed가 95가 된다.
  • 스레드 1이 94, 2가 94, lastIdUsed가 94가 된다. 

놀랄지도 모르지만 마지막 결과도 가능하다. 가능한 실행 경로 수와 JVM의 동작 방식을 알아야 한다.

 

경로

  가능한 경로 수를 계산하기 위해 자바 컴파일러가 생성한 바이트 코드를 살펴보자. return ++lastIdUsed 자바 코드 한 줄은 바이크 코드 명령 8개에 해당한다. 즉, 두 스레드가 8개를 뒤섞어 실행할 가능성이 충분하다. 

  루프나 분기가 없는 명령 N개를 스레드 T개가 차례로 실행한다면 가능한 경로 수는 다음과 같다.

 

(NT)! / N!^T

 

이해하기 쉽게 단계 N=2, T=2 를 예시로 들면, A/B , 1/2라고 치자.

전체적으로 보았을 때, 1122 처럼 총 4단계가 된다. 1이 2개, 2가 2개.

우선 4단계가 전부 다르다고 치고, (N*T)개를 순열로 나열하면 (N*T)! 개가 된다. 하지만, 이렇게 나열하게 된 경우의 수를 1122와 같이 표현하면, 단계는 순서가 정해지게 되므로 중복되는 개수가 (N!)^T개 만큼 생긴다.

 

즉 위의 예제는 12,870 가지의 경로 수가 존재한다. 만약 lastIdUsed가 long이라면 읽기/쓰기 명령이 한 단계가 아니라 두 단계가 된다. 32비트씩 가져올 테니까. 

 

만약 메서드를 synchronized로 바꾼다면, 가능한 경로 수는 (스레드가 2개일 때) 2개로 줄어든다! 스레드가 N개라면 가능한 경로 수는 N! 이다. 

 

 

심층 분석

  앞선 94, 94, 94 시나리오를 분석해보자. 중단이 불가능한 연산을 원자적 연산이라고 칭한다. lastid에 0을 할당하는 연산은 원자적이다. 하지만, long으로 바꾼다면 JVM명세에 따르게 되면, 2번에 나누어 할당하게 되고 스레드의 개입이 가능해진다. 

  전처리 증가 연산자 ++ 는 중단이 가능하다. 바이트 코드를 상세히 보기 전에 다음 정의 세 개를 확인해야 한다.

 

  • 프레임 - 모든 메서드 호출에는 프레임이 필요하다. 반환 주소, 메서드로 넘어온 매개변수, 메서드가 정의하는 지역 변수를 포함한다. 호출 스택을 정의할 때 사용하는 표준 기법이다. 현대 언어는 호출 스택으로 기본 함수/메서드 호출과 재귀적 호출을 지원한다. 
  • 지역 변수 - 메서드 범위 내에 정의되는 모든 변수를 가리킨다. 정적 메서드를 제외한 모든 메서드는 this라는 지역 변수를 갖는다. 현재 스레드에서 가장 최근에 메시지를 받아 메서드를 호출한 객체를 가리킨다. 
  • 피연산자 스택 - JVM이 지원하는 명령 대다수는 매개변수를 받는다. 피연산자 스택은 이런 매개변수를 저장하는 장소다. 표준 LIFO 자료 구조다. 

이 단계가 결국 세부적으로 나뉘면, 앞선 결과가 나올수도 있다. 자세한 내용은 책에서 확인한다. ( p.418 )

 

결론

  ++ 연산은 분명히 원자적 연산이 아니다. 즉, 다음을 알아야 한다는 말이다.

 

  • 공유 객체/값이 있는 곳
  • 동시 읽기/수정 문제를 일으킬 소지가 있는 코드
  • 동시성 문제를 방지하는 방법

 

 

라이브러리를 이해하라

Executor 프레임워크

  스레드는 생성하나 스레드 풀을 사용하지 않는다면 혹은 직접 생성한 스레드 풀을 사용한다면 Executor 클래스를 고려하기 바란다. 

Executor 프레임워크는 스레드 풀을 관리하고, 풀 크기를 자동으로 조정하며, 필요하다면 스레드를 재사용한다. Future도 지원한다. 

 

Future를 사용하게 되면, get() 하는 시점에 Futuer가 끝나기를 기다린다.

 

스레드를 차단하지 않는 방법(non blocking)

  기본 int와 같은 유형을 사용하지 않고, ++ 연산자가 아니라 incrementAndGet() 이라는 메서드를 사용한다. 이는 AtomicBoolean, AtomicInteger, AtomicReference를 포함한 여러 클래스가 있다. 이는 synchronized 보다 훨씬 빠르다.

 이러한 이유는, CAS(Compare and Swap)이라 불리는 연산을 지원하기 때문이다. DB에서 optimistic locking이라는 개념과 유사하다고 한다. 동기화는 pessimistic locking이라는 개념과 유사하다. 

  synchronized 키워드는 언제나 락을 건다. 락을 거는 대가는 비싸다. 락을 거는 것보다 문제를 감지하는 쪽이 거의 항상 더 효율적이다.

 

CAS 연산은 메서드가 공유 변수를 갱신하려 든다면, 현재 변수 값이 최종으로 알려진 값인지 확인한다. 그렇다면 변수 값을 갱신하고 아니라면 다른 스레드가 끼어들었다는 뜻이므로 변수 값을 갱신하지 않는다. 메서드는 이를 확인하고 다시 시도한다.

 

다중 스레드 환경에서 안전하기 않은 클래스

본질적으로 다중 스레드 환경에서 안전하지 않는 클래스가 있다. 몇 가지 예는 다음과 같다.

  • SimpleDataFormat
  • 데이터베이스 연결
  • java.util 컨테이너 클래스
  • 서블릿

Concurrent를 지원하는 클래스를 사용하거나, synchronized를 잘 사용하자.

 

 

메서드 사이에 존재하는 의존성을 조심하라

public class IntegerIterator implements Iterator<Integer> {
   private Integer nextValue = 0;
   
   public synchronized boolean hasNext() {
      return nextValue < 100000;
   }
   
   public synchronized Integer next() {
      if (nextValue == 100000)
         throw new IteratorPastEndException();
      return nextValue++;
   }
   
   public synchronized Integer getNextValue() {
      return nextValue;
   }
}

IntegerIterator iterator = new IntegerIterator();

while(iterator.hasNext()) {
   int nextValue = iterator.next();
   // nextValue로 뭔가를 한다.
}

스레드가 2개일 때, iterator.hasNext() 를 순차적으로 통과 후 next() 가 호출된다면, 100001에 도달함에도 아래의 로직을 실행하는 스레드가 존재할 수도 있다. 

 

해결방안은 3가지가 있다. 

 

실패를 용인한다

   때로는 실패해도 괜찮도록 프로그램을 조정할 수 있다. 예를 들어, 위에서 클라이언트가 예외를 받아 처리해도 되겠다. 조잡한 방법이다. 한밤중에 시스템을 재부팅해 메모리 누수를 해결하는 방법과 비슷하다 하겠다.

 

클라이언트-기반 잠금

IntegetIterator iterator = new IntegerIterator();

while(true) {
   int nextValue;
   synchronized (iterator) {
      if(!iterator.hasNext())
         break;
      nextValue = iterator.next();
   }
   doSometingWith(nextValue);
}

서버를 사용하는 모든 프로그래머가 락을 기억해 객체에 걸었다 풀어야 하므로 다소 위험한 전략이다. 저자의 경험으로 보았을 때, 클라이언트-기반 잠금 메커니즘은 진짜 사람이 할 짓이 아니다. 

 

서버-기반 잠금

public class IntegerIteratorServerLocked {
   private Integer nextValue = 0;
   public synchronized Integer getNextOrNull() {
      if (nextValue < 100000)
         return nextValue++;
      else
         return null;
   }
}

// 클라이언트 코드도 다음과 같이 변경한다

while(true) {
   Integer nextValue = iterator.getNextorNull();
   if (next == null)
      break;
   // 뭔가를 한다.
}

마치, ConcurrentHashMap의 putIfAbsent() 와 같은 역할이다. 

 

일반적으로 서버-기반 잠금이 더 바람직하다. 

  • 코드 중복이 줄어든다. 
  • 성능이 좋아진다.
  • 오류가 발생할 가능성이 줄어든다. 잠금을 잊어버리는 바람에 오류가 발생할 위험은 프로그래머 한 명으로 제한된다.
  • 스레드 정책이 하나다. 서버 한곳에서 정책을 구현한다.
  • 공유 변수 범위가 줄어든다. 모두가 서버에 숨겨진다. 문제가 생기면 살펴볼 곳이 적다. 

서버 코드에 손대지 못한다면?

public class ThreadSafeIntegerIterator {
   private IntegerIterator iterator = new IntegerIterator();
   
   public synchronized Integer getNextOrNull() {
      if(iterator.hasNext())
         return iterator.next();
      return null;
   }
}

위와 같은 ADAPTER 패턴을 사용해 API를 변경하고 잠금을 추가한다.

 

 

작업 처리량 높이기

이 책에서는

  • 페이지를 읽어오는 평균 I/O 시간 : 1초
  • 페이지를 분석하는 평균 처리 시간 : 0.5초
  • 처리는 CPU 100% 사용, I/O는 CPU 0% 사용

이라고 가정했다. 

이때, 1개의 스레드로 N페이지를 처리한다면 총 실행 시간은 1.5 * N이다.

 

스레드를 3개 사용한다고 치면, 페이지를 분석하는 행위는 읽어오는 1초간 2번 처리할 수 있다. 

즉, 대략적으로 1초에 2개를 처리할 수 있고, 0.5초에 1개.

즉, 3배정도 빨라진다고 볼 수 있다. 

 

 

데드락

 개수가 한정된 자원 풀 두 개를 공유하는 웹 어플리케이션이 있다고 가정하자.

  • 로컬 임시 데이터베이스 연결 풀
  • 중앙 저장소 MQ 연결 풀

애플리케이션은 생성과 갱신이라는 연산 두 개를 수행한다.

  • 생성 - 중앙 저장소 연결을 확보한 후 임시 데이터베이스 연결을 얻는다. 중앙 저장소와 통신한 후 임시 데이터베이스에 작업을 저장한다.
  • 갱신 - 임시 데이터베이스 연결을 확보한 후 중앙 저장소 연결을 얻는다. 임시 데이터베이스에서 작업을 읽어 중앙 저장소로 보낸다.

만약 풀 크기보다 사용자 수가 많다면 어떤 일이 벌어질까? 두 풀 크기를 각각 10이라 가정하자.

 

생성이 10개, 갱신이 10개의 사용자가 요청하게 된다면 데드락에 빠질 수 있다. 시스템을 결코 복구되지 못한다.

 

다음 네 가지 조건을 모두 만족하면 데드락이 발생한다.

  • 상호 배제
  • 잠금 & 대기
  • 선점 불가
  • 순환 대기

상호 배제

  여러 스레드가 한 자원을 공유하나 그 자원은

  • 여러 스레드가 동시에 사용하지 못하며
  • 개수가 제한적이라면

상호 배제 조건을 만족한다. 좋은 예가 데이터베이스 연결, 쓰기용 파일 열기, 레코드 락, 세마포어 등과 같은 자원이다. 

 

잠금 & 대기

  일단 스레드가 자원을 점유하면 필요한 나머지 자원까지 모두 점유해 작업을 마칠 때까지 이미 점유한 자원을 내놓지 않는다.

 

선점 불가

  한 스레드가 다른 스레드로부터 자원을 빼앗지 못한다. 자원을 점유한 스레드가 스스로 내놓지 않는 이상 다른 스레드는 그 자원을 점유하지 못한다.

 

순환 대기

  죽음의 포옹이라고도 한다. T1, T2라는 스레드 두 개가 있으며 R1, R2라는 자원 두 개가 있다고 가정하자. T1이 R1을 점유하고, T2가 R2를 점유한다. 또한 T1은 R2가 필요하고 T2도 R2가 필요하다. 그림으로 표현하면 아래와 같다.

네 조건 모두를 충족해야 데드락이 발생한다. 네 조건 중 하나라도 깨버리면 데드락이 발생하지 않는다.

 


상호 배제 조건 깨기

  • 동시에 사용해도 괜찮은 자원을 사용한다. 예를 들어, AtomicInteger를 사용한다.
  • 스레드 수 이상으로 자원을 늘린다.
  • 자원을 점유하기 전에 필요한 자원이 모두 있는지 확인한다.

대다수 자원은 불행히도 그 수가 제한적인 데다 동시에 사용하기도 어렵다. 첫 번째 자원을 사용하고 나서야 두 번째로 필요한 자원이 밝혀지는 경우도 없지 않다.

 

잠금 & 대기 조건 깨기

대기하지 않으면 데드락이 발생하지 않는다. 각 자원을 점유하기 전에 확인한다. 만약 어느 하나라도 점유하지 못한다면 지금까지 점유한 자원을 몽땅 내놓고 처음부터 다시 시작한다.

  하지만, 이 방법은 잠재적인 문제가 몇 가지 있다.

 

  • 기아 - 하나 스레드가 계속해서 필요한 자원을 점유하지 못한다. ( 점유하려는 자원이 한꺼번에 확보하기 어려운 조합일지도 모른다 )
  • 라이브락 Livelock - 여러 스레드가 한꺼번에 잠금 단계로 진입하는 바람에 계속해서 자원을 점유했다 내놨다를 반복한다. 단순한 CPU 스케줄링 알고리즘에서 특히 쉽게 발생한다.

두 경우 모두가 자칫하면 작업 처리량을 크게 떨어뜨린다. 기아는 CPU 효율을 저하시키는 반면, 라이브락은 CPU만 많이 사용한다.

비효율적으로 보일지도 모르지만, 아무 대책이 없는 경우보다는 좋다.

 

선점 불가 조건 깨기

데드락을 피하는 또 다른 전략은 다른 스레드로부터 지원을 뺏어오는 방법이다. 일반적으로 간단한 요청 메커니즘으로 처리한다. 

필요한 자원이 잠겼다면 자원을 소유한 스레드에게 풀어달라 요청한다. 소유 스레드가 다른 자원을 기다리던 중이었다면 자신이 소유한 자원을 모두 풀어주고 처음부터 다시 시작한다. 

  앞서 언급한 전략과 비슷하지만, 스레드가 자원을 기다려도 괜찮다는 이점이 있다. 그러면 처음부터 다시 시작하는 횟수가 줄어든다. 하지만 이 모든 요청을 관리하기가 그리 간단하지 않다.

 

순환 대기 조건 깨기

 데드락을 방지하는 가장 흔한 전략이다. 대다수 시스템에서는 모든 스레드가 동의하는 간단한 규약이면 충분하다. 

  R1을 점유한 T1이 R2를 기다리고 R2를 점유한 T2가 R1을 기다리는 앞서 예제에서 T1과 T2가 자원을 똑같은 순서로 할당하게 만들면 순환 대기는 불가능해진다.

  좀 더 일반적으로 말해, 모든 스레드가 일정 순서에 동의하고 그 순서로만 자원을 할당한다면 데드락을 불가능하다. 하지만 이 전략 역시 문제를 일으킬 소지가 있다.

 

  • 자원을 할당하는 순서와 자원을 사용하는 순서가 다를지도 모른다. 그래서 맨 처음 할당한 자원을 아주 나중에야 쓸지도 모른다. 즉, 자원을 필요한 이상으로 오랫동안 점유한다.
  • 때로는 순서에 따라 자원을 할당하기 어렵다. 첫 자원을 사용한 후에야 둘째 자원 ID를 얻는다면 순서대로 할당하기란 불가능하다.

이렇게 데드락을 피하는 전략을 많다. 어떤 전략은 기아를 일으키고, 어떤 전략은 CPU를 심하게 사용해 응답도를 낮춘다. 

TANSTAAFL!!!! - There ain't no such thing as a free lunch, 공짜 점심은 없다.

 

프로그램에서 스레드 관련 코드를 분리하면 조율과 실험이 가능하므로 통찰력이 높아져 최적의 전략을 찾기 쉬워진다. ( p.438 )

 

다중 스레드 코드 테스트

다중 스레드에서의 문제는 너무 드물게 발생하는 바람에 테스트로 발견하기가 어렵다.

그렇다면 이렇게 간단한 실패를 증명할 방법은 무엇일까? 

  다음은 몇 가지 아이디어다.

 

  • 몬테 카를로 테스트 - 조율이 가능하게 유연한 테스트를 만든다. 임의로 값을 조율하면서 반복해 돌린다. 테스트가 실패한 조건은 신중하게 기록한다. 
  • 시스템을 배치할 플랫폼 전부에서 테스트를 돌린다. 반복해서 돌린다. 테스트가 실패 없이 오래 돌아갈수록 두 가지 중 하나일 확률이 높아진다. 
    • 실제 코드가 올바르다.
    • 테스트가 부족해 문제를 드러내지 못한다.
  • 부하가 변하는 장비에서 테스트를 돌린다. 실제 환경과 비슷하게 부하를 걸어 줄 수 있다면 그렇게 한다.

하지만, 가능성은 매우 낮다. 십억 번에 한 번씩만 일어나는 희귀한 문제가 가장 골치 아프다.

 

스레드 코드 테스트를 도와주는 도구

IBM은 ConTest라는 도구를 내놓았다. 스레드에 안전하지 않는 코드에 보조 코드를 더해 실패할 가능성을 높여주는 도구다.

 

결론

여기서는 다중 스레드 코드를 깨끗하게 유지하는 방법을 익혔다. 

이 장에서는 동시 갱신을 논했으며, 동시 갱신을 방지하는 깨끗한 동기화/잠금 기법을 소개했다. 스레드가 I/O 위주 시스템의 처리율을 높여주는 이유와 실제로 처리율을 높이는 방법을 살펴봤다. 데드락을 논했으며, 깔끔하게 데드락을 방지하는 기법도 열거했다. 마지막으로 보조 코드를 추가해 동시성 문제를 사전에 노출하는 전략을 소개했다.