013. (Clean Code) 13. 동시성 - Concurrency

13. 동시성 - Concurrency

객체는 처리의 추상화다. 스레드는 일정의 추상화다.
제임스 O. 코플리엔(James O. Coplien) - Advanced C++ Programming Styles and Idioms의 저자, 힐사이드 그룹의 창립 멤버

동시성과 깔끔한 코드는 양립하기 아주 어려운 영역이다.

단순히 하나의 스레드만 실행하는 코드를 작성하는 것은 쉽다.

다중 스레드 코드라도 겉으로 멀쩡하게 보이도록 작성하는 것도 쉽다.

다만 스레드 깊숙한 곳에 문제가 있는 경우엔 시스템이 부하를 직접적으로 받기전까진 알아채기 어렵다.

본 포스팅에서는 이러한 문제점을 감안하도서라도 여러 스레드를 동시에 작동시키는 이유를 살펴보고,

최대한 깨끗한 코드를 작성하는 방법, 마지막으로 동시성의 문제점과 테스트 방법에 대해서도 알아본다.

13.1. 동시성이 필요한 이유

동시성은 결합을 없애기 위해 무엇(what)언제(when) 를 분리하는 전략이다.

스레드가 하나인 프로그램은 이 무엇언제 가 서로 밀접하기에 호출 스택만 보아도 프로그램의 상태가 바로 드러난다.

단일 스레드 프로그램의 경우 브레이크포인트 하나로 시스템 상태를 파악하고 디버깅하는 것도 가능하다.

이때 무엇언제 를 분리하면 애플리케이션의 구조와 효율이 매우 높아진다.

구조적인 관점에서 프로그램은 거대한 루프 하나가 아닌 작은 협력 프로그램 여러 개의 조합이기때문에,

분리할 수록 시스템을 이해하기도 쉽고 문제를 분리하는 것도 쉬워진다.

하지만 구조적 개선만을 위해 동시성을 채택하는 것은 아니다.

어떤 시스템 A는 응답 시간과 작업 처리량 개선이라는 요구사항으로 인해 직접적인 동시성 구현이 불가피하며,

어떤 시스템 B는 한 번에 한 명의 사용자만을 처리할 수 있는 상황에서 사용자가 늘어나는 경우의 응답속도를 개선하기 위해서도 동시성을 구현해야 한다.

마지막으로 어떤 시스템 C는 대량의 정보를 한 번에 분석하기에, 응답 속도가 너무 느린 경우 대량의 정보를 여러 컴퓨팅 장치에서 동시에 처리하도록 동시성을 구현해야할 수 도 있다.

미신과 오해

위에서 상술했듯 반드시 동시성이 필요한 상황은 존재한다.

동시성은 개념 자체도 어렵고, 구현하는 것도 어렵기에 아래와 같은 미신과 오해가 존재한다.

  • 오해 : 동시성은 항상 성능을 높여준다.
    • 동시성은 아래의 조건에서만 성능을 높여준다.
    • 대기 시간이 아주 길어 여러 스레드가 프로세서를 공유할 수 있는 경우
    • 여러 프로세서가 동시에 처리할 독립적인 계산이 충분히 많은 경우
  • 오해 : 동시성을 구현해도 설계는 변하지 않는다.
    • 단일 스레드 시스템과 다중 스레드 시스템의 설게는 굉장히 다르다.
    • 일반적으로도 무엇과 언제를 분리하는 순간 시스템 구조가 크게 달리진다.
  • 오해 : 웹 또는 EJB 컨에티너를 사용하면 동시성을 이해할 필요가 없다.
    • 실제로는 컨테이너의 동작 방식에 대한 이해, 동시 수정 및 데드락 문제 회피 방법들을 이해해야한다.
  • 사실 : 동시성은 다소 부하를 유발한다.
    • 동시성은 성능 측면에서 부하가 걸리며, 더 많은 코드를 요구한다.
  • 사실 : 동시성은 복잡하다.
    • 간단한 문제라도 동시성은 복잡하다.
  • 사실 : 일반적으로 동시성 버그는 재현하기 어렵다.
    • 진짜 결함으로 간주되는 경우보다 일시적인 현상이라 여기는 경우가 많다.
  • 사실 : 동시성을 구현하려면 근본적인 설계 전략을 재고해야 한다.

13.2. 동시성 구현의 난관

동시성을 구현하기가 어려운 이유는 무엇일까?

아래 예제를 통해 이해해보자.

1
2
3
4
5
6
7
public class X {
private int lastIdUsed;

public int getNextId() {
return ++lastIdUsed;
}
}

아래 상황을 가정해보자.

클래스 X의 객체를 생성한 뒤 lastIdUsed를 42로 초기화했다.

이때 두 개의 스레드 A, B가 해당 객체에 접근하여 getNextId()를 호출한다.

결과는 아래 3개 중 하나로 나올 것이다.

  • 스레드 A는 43을 반환받고, 스레드 B는 44를 반환받는다. lastIdUsed는 44로 저장된다.
  • 스레드 A는 44를 반환받고, 스레드 B는 43을 반환받는다. lastIdUsed는 44로 저장된다.
  • 스레드 A는 43를 반환받고, 스레드 B도 43을 반환받는다. lastIdUsed는 43로 저장된다.

두 개의 스레드가 같은 변수를 동시에 참조하면 세번째 결과 처럼 의도돠는 다른 값을 반환한다.

이러한 결과는 두 개의 스레드가 자바 코드 한 줄을 거쳐가는 경로가 수없이 많기 때문이다.

참고 가능한 경로 수를 계산하려면 자바 컴파일러가 생성한 바이트 코드를 살펴보아야 한다.

간단하게 두 스레드가 getNextId() 메서드를 호출할 수 있는 잠재적인 경로는 최대 12,870개에 달한다.

이때 lastIdUsed의 타입을 int에서 long으로 변경하면 조합 가능한 경로의 수는 2,704,156개로 증가한다.

물론 대다수의 경로는 올바른 결과를 내놓지만, 문제는 잘못된 결과를 반환하는 일부 경로이다.

즉, 코드에 따라 경우의 수가 동적으로 증감하는 만큼 동시성을 대응하는 것은 어렵다고 볼 수 있다.

13.3. 동시성 방어 원칙

이번엔 동시성 코드가 일으키는 문제로부터 시스템을 방어하는 원칙과 기술에 대해서 알아보자.

동시성과 단일 책임 원칙

단일 책임 원칙은 메서드/클래스/컴포넌트를 변경할 이유가 하나여야 한다는 원칙이다.

동시성은 복잡성 하나만으로도 따로 분리해야할 이유가 충분하다.

즉 동시성 관련 코드는 다른 코드와 명확히 분리되어야 한다.

하지만 불행히도 동시성과 관련이 없는 코드에서 동시성을 바로 구현하는 사례가 너무 흔하다.

따라서 동시성을 구현할 때에는 아래와 같은 부분을 고려해야한다.

  • 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다.
  • 동시성 코드에는 독자적인 난관이 있다. 다른 코드에서 겪는 난관과는 다르며 훨씬 어렵다.
  • 잘못 구현한 동시성 코드는 별의별 방식으로 실패한다. 주변에 있는 다른 코드와의 연관성이 없더라도 동시성 하나만으로도 충분히 어렵다.

결론 동시성 코드는 다른 코드와 명백하게 분리해야 한다.

따름 정리 : 자료 범위를 제한하라

위의 예제에서 확인할 수 있듯이,

객체 하나를 공유한 후 동일 필드를 수정하던 두 개의 스레드가 서로 간섭하는 경우 의도와는 다른 결과를 반환할 수 있다.

이러한 문제를 해결하는 방안으로 공유 객체를 사용하는 코드 내에서 임계 영역을 synchronized 키워드로 보호하는 것이 권장된다.

결국 중요한 것은 이 임계 영역을 최소화하는 것이다.

만약 공유 자료를 수정하는 위치가 많아진다면 아래와 같은 문제들을 맞닥뜨리게 된다.

  • 임계 영역의 보호를 누락하여 공유 자료를 수정하는 모든 코드를 망가뜨린다.
  • 모든 임계 영역을 올바르게 보호했는지 확인하느라 똑같은 노력과 수고를 반복한다. (DRY : Don’t Repeat Yourself 위반)
  • 안그래도 찾기 어려운 버그가 더욱 찾기 어려워진다.

결론 자료를 캡슐화하고, 공유 자료를 최대한 줄여야 한다.

따름 정리 : 자료 사본을 사용하라

공유 자료를 줄이는 가장 좋은 방법은 처음부터 공유하지 않는 것이다.

경우에 따라 객체를 복사해서 읽기 전용으로 사용하는 방법이 가능하다.

또 다른 경우에는 각 스레드가 접근하려는 객체의 사본을 만들어 사용한 후, 다른 스레드가 해당 사본에서 결과를 가져오는 것도 가능하다.

위와 같이 어떠한 방법이든 공유 객체를 회피한다는 것은 코드가 문제를 일으킬 가능성도 낮아지는 것을 기대할 수 있다.

만약 객체를 복사하는 비용 혹은 복사 과정에서 발생하는 부하가 우려되는 경우, 실측 후 판단하는 것이 좋다.

다만 사본으로 동기화를 회피하는 것이 객체 내부의 잠금을 통해 절약한 비용을 상쇄할 가능성이 크다.

따름 정리 : 스레드는 가능한 독립적으로 구현하라

스레드를 독립적인 세상에 홀로 존재하도록 구현하는 것이 좋다.

즉 애초에 다른 스레드와 자료를 공유하지않도록 하는 것이다.

좀 더 구체적으로는 각 스레드는 클라이언트 요청을 처리하며 비공유 영역에서 자료를 가져와, 로컬 변수에 저장하도록 하여 동기화의 여지를 차단해버리는 것이다.

결론 독자적인 스레드로, 가능하면 다른 프로세서에서 구동해도 괜찮도록 자료를 독립적인 단위로 분할하라.

13.4. 라이브러리를 이해하라

자바5부터는 동시성 측면에서 좀 더 개선되었다고 볼 수 있다.

이제 스레드 코드를 구현한다면 아래와 같은 사항을 고려해야 한다.

  • 스레드 환경에 안전한 컬렉션을 사용한다.
  • 서로 무관한 작업을 수행할 때는 executor 프레임워크를 사용한다.
  • 가능하다면 스레드가 차단되지않도록 사용한다.
  • 일부 라이브러리는 스레드에 안전하지 못함을 인지하고 사용한다.

스레드 환경에 안전한 컬렉션

java.util.concurrent 패키지에서 제공되는 클래스는 다중 스레드 환경에서도 안전함이 보장되며 성능도 뛰어나다.

실제로 ConcurrentHashMap은 거의 모든 상황에서 HashMap보다 빠르다.

좀 더 복잡한 동시성 문제를 해결하기 위해 사용할 수 있는 컬렉션은 아래와 같다.

| 구분 | 명세 |
| ReentrantLock | 한 메서드에서 잠그고 다른 메서드에서 푸는 Lock이다 |
| Semaphore | 공유된 자원의 데이터 또는 임계 영역 등에 여러 스레드가 접근하는 것을 막아주는 개수가 있는 Lock이다 |
| CountDownLatch | 지정한 수만큼 이벤트가 발생하고 나서야 대기중인 스레드를 모두 해제해주는 Lock 이다 |

결론 언어가 제공하는 클래스를 검토하라.
자바에서는 java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks가 해당된다.

13.5. 실행 모델을 이해하라

다중 스레드 애플리케이션을 분류하는 방식을 여러 가지이다.

먼저 용어 정의부터 진행하자

  • 한정된 자원(Bound Resource) : 다중 스레드 환경에서 사용하는 자원으로, 크기나 숫자가 제한적이다.
    • 데이터베이스의 연결, 길이가 일정한 읽기/쓰기 버퍼 등이 이에 해당한다.
  • 상호 배제(Mutual Exclusion) : 한 번에 하나의 스레드만 공유 자료나 공유 자원을 사용하는 경우를 말한다.
  • 기아(Sarvation) : 하나 혹은 여러 스레드가 굉장히 오랫동안 혹은 영원히 자원의 할당을 기다린다.
    • 항상 실행 시간이 짧은 스레드가 우선순위를 가진다면 긴 스레드가 기아 상태에 빠진다.
  • 데드락(Deadlock) : 여러 스레드가 서로가 끝나기를 기다린다. 모든 스레드가 각기 필요한 자원을 다른 스레드에서 점유하여 어느 스레드도 더 이상 진행하지 못하는 경우를 말한다.
  • 라이브락(Livelock) : 락을 거는 단계에서 각 스레드가 서로를 방해한다. 스레드는 계속해서 동작을 수행하려하지만 공명(resonance)로 인해 굉장히 오랫동안 혹은 영원히 동작하지않는다.

이제 다중 스레드 프로그래밍에서 사용하는 실행 모델을 몇 가지 살펴보자.

생산자-소비자(Producer-Consumer)

하나 이상의 생산자 스레드가 정보를 생성해 버퍼나 대기열에 넣는다.

동시에 하나 이상의 소비자 스레드가 버퍼나 대기열에서 정보를 가져와 사용한다.

이때 생산자와 소비자 스레드가 사용하는 대기열을 한정된 자원이며, 빈 공간이 있어야 정보를 채울 수 있으므로 빈 공간이 생길때까지 기다린다.

반대로 소비는 대기열에 정보가 있어야 가져온다.

따라서 생산자와 소비자 스레드는 서로에게 시그널을 보내야 하며,

둘 다 진행할 수 있는 상황에서 서로의 시그널을 기다리는 상황이 발생할 여지가 있다.

읽기-쓰기(Readers-Writers)

읽기 스레드는 주된 정보의 출처로 공유 자원을 사용하고 있고, 쓰기 스레그가 이 공유 자원을 간헐적으로 갱신한다고 가정하자.

이때 처리율이 문제의 핵심이 된다.

처리율을 강조하면 기아 현상이 생기거나, 오래된 정보가 쌓이게 된다.

이때 쓰기 스레드에 갱신을 허용하면 처리율에 영향을 미치므로, 서로가 서로에게 영향을 줄 수 밖에 없다.

따라서 읽기 쓰레드의 요구와 쓰기 스레드의 요구를 적절히 만족히여 타협할 수 있는 지점을 파악해야 한다.

13.6. 동기화하는 메서드 사이에 존재하는 의존성을 이해하라

동기화하는 메서드 사이에 의존성이 존재하면 동시성 코드에 찾아내기 어려운 버그가 생길 수 있다.

이를 해소하기 위해 자바는 개별 메서드를 보호하는 synchronized 키워드를 제공해주고 있다.

하지만 공유 클래스 하나에 동기화된 메서드가 여럿이라면 구현이 올바르게 된건지 다시 확인해볼 필요가 있다.

결론 공유 객체 하나에는 메서드 하나만 사용하라.

물론 공유 객체 하나에 여러 메서드가 필요한 상황도 발생할 수 있다.

그럴 땐 아래의 세 가지 방법을 고려해보도록 하자.

  • 클라이언트에서 잠금 : 클라이언트에서 첫 번째 메서드를 호출하기 전에 서버를 잠근다. 이후 마지막 메서드를 호출할 때까지 잠금을 유지한다.
  • 서버에서 잠금 : 서버에서 서버를 잠그고 모든 메서드르를 호출한 후 잠금을 해제하는 메서드를 구현하고, 클라이언트가 이 메서드를 호출한다.
  • 연결 서버 : 잠금을 수행하는 중간 단계를 생성한다. 기존 서버의 변경 없이 서버에서 잠근 효과를 부여할 수 있다.

13.7. 동기화하는 부분을 작게 만들어라

상술 했듯 자바에서는 synchronized 키워드를 사용해 락을 설정한다.

같은 락으로 감싼 모든 코드 영역은 한 번에 하나의 스레드만 실행가능하지만, 이 락은 스레드를 지연시키고 부하를 가중시킨다.

그러므로 synchronized 키워드를 남발하는 코드는 지양해야 한다.

결론 동기화하는 부분을 최대한 작게 만들어라.

13.8. 올바른 종료 코드는 구현하기 어렵다

영구적으로 돌아가는 시스템을 구현하는 방법과, 잠시 돌다가 깔끔하게 종료되는 시스템을 구현하는 방법은 다르다.

이중 후자에 해당하는 코드를 올바르게 구현하기 어려운데, 데드락이 가장 흔하게 발생할 수 있기 때문이다.

예를 들어, 부모 스레드가 자식 스레드를 여러 개 만든 후 모두가 끝나기를 기다렸다가 자원을 해제하고 종료하는 시스템이 있다고 가정하자.

부모 스레드는 영원히 기다리고, 시스템은 영원히 종료되지 않는다.

사용자가 직접 종료하도록 지시했다하더라도, 부모 스레드가 자식 스레드에 보낸 시그널이 차단되어있을 수 있다.

결론 종료 코드를 개발 초기부터 고민하고 동작하게 초기부터 구현하라.

13.9. 스레드 코드 테스트하기

코드가 올바르다고 증명하기란 현실적으로 불가능에 가까운 어려운 일이다.

테스트가 정확성을 보장해주지도 않는다.

다만 충분한 테스트를 통해 위험을 최소화하는 것은 가치있는 일이다.

같은 코드와 같은 자원을 사용하는 스레드가 둘 이상인 경우 많은 위험이 발생하므로 고려해야할 것이 많다.

결론 문제를 노출하는 테스트 케이스를 작성하고, 프로그램 설정과 시스템 설정과 부하를 변경해가며 수행해본다.
테스트가 실패하면 원인을 추적해야 한다. 다시 시도했을 때 통과한다고 하더라도 바로 넘어가서는 안된다.

고려사항을 명세한 아래 지침을 따라보도록 하자.

말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라

다중 스레드 코드는 때때로 말이 안되는 오류를 발생 시킨다.

대다수의 개발자는 스레드가 다른 코드와 교류하는 방식을 직관적으로 이해하지 못하기에, 스레드 코드내 버그는 매우 간헐적으로 한 번 씩 드러날 수 있다.

이때 실패는 매우 재현하기 어려우므로 단순한 일회성 문제로 치부하기 쉽다.

결론 시스템 실패를 일회성이라 치부하지 마라.

다중 스레드를 고려하지 않는 순차 코드부터 제대로 돌게 만들자

당연한 말이다.

스레드 외부 환경부터 제대로 동작함을 보장하는 것이 필요하다.

일반적으로는 스레드가 호출하는 POJO를 만들어, 스레드 환경 밖에서 테스트를 수행하는 것이다.

스레드보다는 POJO에 넣는 코드가 많을수록 좋다.

결론 스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하지 마라. 먼저 스레드 환경 밖에서 코드를 올바르게 동작시켜라.

다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있게 스레드 코드를 구현하라

다중 스레드를 쓰는 코드를 다양한 설정으로 실행하기 쉽게 구현하는 것이 필요하다.

예를 들어 아래와 같다.

  • 한 스레드로 실행하거나, 여러 스레드로 실행하거나, 실행 중 스레드 수를 바꾸어본다.
  • 스레드 코드를 실제 환경이나 테스트 환경에서 돌려본다.
  • 테스트 코드를 빨리, 천천히, 다양한 속도로 돌려본다.
  • 반복 테스트가 가능하도록 테스트 케이스를 작성한다.

결론 제목 그대로다. 다양한 설정에서 실행할 목적으로 다른 환경에 쉽게 끼워넣을 수 있게 코드를 구현하라.

다중 스레드를 쓰는 코드 부분을 상황에 맞게 조율할 수 있게 작성하라

적절한 스레드 개수를 파악하는 데는 많은 시행 착오가 필요하다.

따라서 처음부터 다양한 설정으로 프로그램의 성능 측정 방법을 강구하는 것이 좋다.

예를 들어 아래와 같은 방식을 고민해보는 것이다.

  1. 스레드 개수를 조율하기 쉽게 코드를 구현한다.
  2. 프로그램이 돌아가는 도중에 스레드 개수를 변경할 수 있게 구현한다.
  3. 프로그램의 처리율과 효율에 따라 스스로 스레드 개수를 조율하도록 구현한다.

프로세스 수보다 많은 스레드를 돌려보라

시스템이 스레드를 스와핑하는 경우에도 문제는 발생한다.

스와핑을 강제로 발생시키려면 프로세서 수보다 많은 스레드를 동작시킨다.

스와핑이 잦을수록 임계 영역내의 빼먹은 코드나 데드락을 발견하기 쉬워진다.

다른 플랫폼에서 돌려보라

운영체제마다 스레드를 처리하는 정책이 다를 수 있다.

따라서 다중 스레드 코드는 다양한 운영체제 위에서 제대로 동작하는지 확인이 필요하다.

결론 처음부터 그리고 자주 모든 목표 플랫폼에서 코드를 돌려라.

코드에 보조 코드를 넣어 돌려라. 강제로 실패를 일으키게 해보라

스레드 코드의 오류는 간단한 테스트로 드러나지 안흔ㄴ다.

스레드 버그가 산발적이고 재현이 어려운 이유는 코드가 실행되는 수천 가지 경로 중에 아주 소수만 실패하기때문이다.

이 오류를 자주 발생하게 만드려면 보조 코드를 추가해서 코드가 실행되는 순서를 바꿔주는 기법을 적용할 수 있다.

코드에 보조 코드를 추가하는 방법은 두 가지다.

  • 직접 구현하기
  • 자동화

각기 자세히 알아보자.

직접 구현하기

코드에다 직접 wait(), sleep(), yield(), priority() 함수를 추가한다.

까다로운 코드를 테스트할때 적합하다.

아래는 예시이다.

1
2
3
4
5
6
7
8
9
public synchronized String nextUrlOrNull() {
if (hasnext()) {
String url = urlGenerator.next();
Thread.yield(); // HERE
updateHasNext();
return url;
}
return null;
}

위 예제처럼 yield() 함수의 추가만으로 실행되는 경로가 바뀌게 되어 오류가 발생할 가능성을 열어준다.

다만 이 방법에는 아래와 같은 문제들이 있다.

  • 보조 코드를 삽입할 적정 위치를 직접 찾아야 한다.
  • 어떤 함수를 어디서 호출해야 적당한지 판단해야 한다.
  • 배포 환경에 보조 코드를 그대로 남겨두면 프로그램의 성능이 떨어진다.
  • 말그대로 가능성을 열어주는 것이기에, 무조건 오류가 재현된다고 보장할 수 없다.

자동화

보조 코드를 자동으로 추가하려면 AOF, CGLIB, ASM 등과 같은 도구를 사용해야 한다.

아래 예제를 보자.

1
2
3
4
5
public class ThreadJigglePoint {
public static void jiggle() {

}
}

아래 코드에서 다양한 위치에 ThreadJigglePoint.jiggle() 메서드 호출을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
public synchronized String nextUrlOrNull) { 
if (hasNext)) {
ThreadJigglePoint.jiggle();
Stringurl= urlGenerator.next();
ThreadJigglePoint.jiggle();
updateHasNext ();
ThreadJigglePoint.jiggle();
return url;
}
return null;
}

ThreadJigglePoint.jiggle() 메서드 호출은 무작위로 sleep()이나 yield()를 호출한다.

때로는 아무런 동작도 하지 않는다.

결론 흔들기 기법을 사용해 오류를 찾아내라.