Clean Code : 13장(동시성)

13장. 동시성

이 장에서는 스레드의 동시성에 대한 내용을 간략하게 다루었습니다. 이 장에서는 운영체제 관련 지식들이 많이 나오기 때문에 제가 정리했던 자료들을 참고하셔도 좋을 것 같습니다.

참고 자료

13-1. 동시성이 필요한 이유?

동시성은 결합을 없애는 전략입니다. 즉, 무엇과 언제를 분리하는 전략입니다.

예를 들어, 매일 수많은 웹 사이트에서 정보를 가져와 요약하는 정보 수집기가 있다고 가정하겠습니다.

만약 수집기가 단일 스레드 프로그램이라면 한 사이트 정보 수집이 끝나야 다음 사이트로 넘어가게 되는데, 웹 사이트 수가 많아 지는 것에 비례해 실행 시간이 길어집니다.

하지만 다중 스레드를 이용한다면, 한 번에 한 사이트를 방문하는 대신 여러 사이트 수집이 동시에 진행되기 때문에 실행 속도를 현저히 줄일 수 있습니다.

미신과 오해

위와 같이 동시성이 반드시 필요한 상황이 존재합니다. 하지만 동시성은 매우 복잡하기 때문에 각별히 주의할 필요가 있습니다.

아래는 동시성과 관련한 일반적인 미신과 오해입니다.

:exclamation: 동시성은 항상 성능을 높여준다. :arrow_right: 여러 프로세서가 동시에 처리할 독립적인 계산이 충분히 많은 경우나, 대기 시간이 아주 길어 여러 스레드가 프로세서를 공유할 수 있는 상황일 때만 성능이 높아집니다. (두 상황 모두 일반적인 상황은 아닙니다.)

:exclamation: 동시성을 구현해도 설계는 변하지 않는다. :arrow_right: 단일 스레드 시스템과 다중 스레드 시스템은 설계가 다릅니다.

:exclamation: 웹 또는 EJB 컨테이너를 사용하면 동시성을 이해할 필요가 없다. :arrow_right: 실제로 컨테이너가 어떻게 동작하는지, 어떻게 동시 수정, 데드락 등과 같은 문제를 피할 수 있는지 알아야 합니다.)

반대로 아래는 동시성과 관련된 올바른 생각입니다.

:white_check_mark: 동시성은 다소 부하를 유발한다. :arrow_right: 성능 측면에서도 부하가 걸리며, 코드도 더 짜야 합니다.

:white_check_mark: 동시성은 복잡하다. :arrow_right: 간단한 문제라도 동시성이 엮이게 되면 복잡해집니다.

:white_check_mark: 일반적으로 동시성 버그는 재현하기 어렵다. :arrow_right: 진짜 결함으로 간주되지 않고 일회성 문제로 여겨지기 쉽습니다.

:white_check_mark: 동시성을 구현하려면 흔히 근본적인 설계 전략을 재고해야 한다.

13-2. 난관

동시성을 구현하기 어려운 이유를 예제로 알아보겠습니다.

public class X {
    private int lastIdUsed;
    
    public int getNextId() {
        return ++lastIdUsed;
    }
}

위와 같은 간단한 메서드를 두 스레드가 실행한다고 가정하겠습니다. lastIdUsed를 42로 설정한 다음, 두 스레드를 실행한다면 아래와 같은 결과들이 나올 수 있습니다.

  • 한 스레드가 43을 받음 :arrow_right: 다른 스레드는 44를 받음 :arrow_right: lastIdUsed는 44가 됨
  • 한 스레드가 44을 받음 :arrow_right: 다른 스레드는 43을 받음 :arrow_right: lastIdUsed는 44가 됨
  • 한 스레드가 43을 받음 :arrow_right: 다른 스레드는 43를 받음 :arrow_right: lastIdUsed는 43이 됨(동시에 실행 시키는 경우)

이렇게 두 스레드가 같은 변수를 동시에 참조하는 것 처럼 의도치 않은 결과가 간혈적으로 일어나기 때문에 동시성을 구현하기 어렵게 되는 것입니다. :arrow_right: 이렇게 간단한 메서드 조차 이런 동시성 오류가 발생하는데… 더 복잡해진다면 정말 뜬금없는 에러가 발생할 수도 있습니다.

13-3. 동시성 방어 원칙

동시성 코드가 일으키는 문제로부터 시스템을 방어하는 원칙과 기술을 소개합니다.

단일 책임 원칙 (SRP : Single Responsibility Principle)

동시성은 복잡성 하나만으로도 따로 분리할 이유가 있기 때문에 동시성 코드는 다른 코드와 분리하여야 합니다.

자료 범위를 제한하라

객체 하나를 공유한 후 동일 필드를 수정하거나 두 스레드가 서로 간섭하게 되면 예상치 못한 결과가 나오게 되기 때문에 아래와 같은 방법들이 필요합니다.

  • 임계영역synchronized키워드로 보호
  • 자료를 캡슐화
  • 공유자료를 최대한 줄임

자료 사본을 사용하라

공유 객체를 사용해야 한다면, 자료 사본을 만들어서 사용하는 것이 Lock을 통해 자료에 접근하는 것 보다 더 실용적일 수도 있습니다.

스레드는 가능한 독립적으로 구현하라

스레드는 최대한 다른 스레드와 자료를 공유하지 않도록 독립적인 단위로 분할하여 구현해야 합니다.

13-4 라이브러리를 이해하라

자바 5는 동시성 측면에서 이전 버전보다 많이 나아졌습니다. 자바 5로 스레드 코드를 구현한다면 아래를 고려하면 좋습니다.

현재 자바 버전가 차이가 크기 때문에 ‘‘이런 게 있구나’ 정도로만 알고 가는 것이 좋을 것 같습니다.

  • 스레드 환경에 안전한 컬렉션을 사용(자바 5부터 제공)
  • 서로 무관한 작업을 수행할 때는 executor 프레임워크를 사용
  • 가능하다면 스레드가 차단되지 않는 방법을 사용
  • 일부 클래스 라이브러리는 스레드에 안전하지 못함

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

더그 리Cuncurrent Programming in Java라는 책을 집필하면서, 스레드에 사용해도 안전한 컬렉션 클래스 몇 개를 구현했는데, 나중에 java.util.concurrent 패키지에 추가되었습니다.

이 패키지가 제공하는 클래스는 다중 스레드 환경에서 사용해도 안전하며, 성능도 좋습니다.

동시성 설계를 지원하는 자바 5의 다른 클래스

좀 더 복잡한 동시성 설계를 지원하고자 자바 5에는 다른 클래스도 추가되었습니다.

  • ReentrantLock : 한 메서드에서 잠그고 다른 메서드에서 푸는 락(lock)
  • Semaphore : 개수가 있는 락(lock)인, 전형적인 세마포
  • CountDownLatch : 지정한 수 만큼 이벤트가 발생하고 나서야 대기 중인 스레드를 모두 해제하는 락(lock) :arrow_right: 모든 스레드에게 동시에 공평하게 시작할 기회를 줌

저자는 자바에서 java.util.concurrent, java.util.concurrent.atomic, java.util.concurrent.locks을 익히라고 권장합니다.

13-5. 실행 모델을 이해하라

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

생산자-소비자

생산자 스레드와 소비자 스레드가 사용하는 대기열은 한정된 자원입니다.

  • 생산자 스레드 : 생산자 스레드는 대기열에 빈 공간이 있어야 정보를 채움 :arrow_right: 정보를 채운 다음 소비자 스레드에게 정보가 있다는 시그널을 보냄
  • 소비자 스레드 : 대기열에 정보가 있어야 가져옴 :arrow_right: 대기열에서 정보를 읽어들인 후, 빈 공간이 있다는 시그널을 보냄

따라서 잘못하면 생산자 스레드와 소비자 스레드가 둘 다 진행 가능함에도 불구하고 동시에 서로에게 시그널을 기다릴 가능성이 존재합니다.

읽기-쓰기

  • 읽기 스레드 : 주된 정보원으로 공유자원을 사용
  • 쓰기 스레드 : 공유 자원을 갱신

읽기 스레드와 쓰기 스레드의 요구를 적절히 만족시켜서 처리율을 적당히 높이고 기아상태도 방지하는 해법이 필요합니다.

처리율 : 쓰기 스레드가 자원을 처리하는 효율

기아상태 : 여러 프로세스가 부족한 자원을 점유하기 위해 경쟁하는 상태

식사하는 철학자들

둥근 식탁에 철학자 한 무리가 둘러앉다고 가정하는 상황입니다. 식탁 가운데는 커다란 스파게티가 한 접시가 놓여있고 철학자들은 배가 고프지 않으면 생각을, 배가 고프면 양손에 포크를 집어들고 스파게티를 먹습니다. 이 때, 양손에 포크를 쥐지 않으면 먹지 못하는데, 왼쪽 철학자나 오른쪽 철학자가 포크를 사용하고 있다면, 그 쪽 철학자가 먹고 나서 포크를 내려놓을 때까지 기다려야 합니다.

이 상황에서 철학자를 스레드로, 포크를 자원으로 바꾸어 생각해보게 되면, 여러 프로세스가 자원을 얻으려 경쟁하는 상황이 됩니다. 때문에 식사하는 철학자들이라는 이름이 붙은 것이고 이 내용의 요지는 주의해서 설계하지 않으면, 데드락, 라이브락, 처리율 저하, 효율성 저하등을 겪기 때문에 설계할 때 각별한 주의가 필요하다는 것입니다.

저자는 기본 알고리즘과 각 해법을 직접 구현해보고 이해하라고 권장합니다.

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

자바 언어는 개별 메서드를 보호하는 synchronized라는 개념을 지원하는데, 동기화하는 메서드 사이에 의존성이 존재하면 동시성 코드를 찾아내기 어렵게 됩니다.

동기화 메서드가 여럿이라면 공유 객체 하나에는 메서드 하나만 사용하라고 저자는 권장합니다.

만약 공유 객체 하나에 여러 메서드가 필요한 상황이라면, 아래의 3가지 방법을 고려합니다.

  • 클라이언트에서 잠금 : 클라이언트에서 첫 번째 메서드를 호출하기 전에 서버를 잠금 :arrow_right: 마지막 메서드를 호출할 때까지 잠금을 유지
  • 서버에서 잠금 : 서버를 잠그고 모든 메서드를 호출한 후 잠금을 해제하는 메서드를 구현 :arrow_right: 클라이언트는 이 메서드를 호출
  • 연결 서버 : 잠금을 수행하는 중간 단계를 생성 :arrow_right: 서버에서 잠금방식과 유사하지만 원래 서버는 변경하지 않음

13-7. 동기화하는 부분을 작게 만들어라

자바에서 synchronized 키워드를 사용하면 락을 설정하는데, 여기저기 락을 설정하게 되면, 부하를 가중시킵니다.

하지만 임계영역은 반드시 보호해야 하기 때문에, 코드를 구현할 때는 임계영역 수를 최대한 줄이고 동기화 하는 부분을 최대한 작게 만들어야 합니다.

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

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

깔끔하게 종료하는 코드는 스레드가 절대 오지 않을 시그널을 기다리는 데드락같은 문제가 발생하기 때문에 올바르게 구현하기가 어렵습니다. :arrow_right: 깔끔하게 종료하는 다중 스레드 코드를 구현해야 한다면, 시간을 투자해 올바르게 구현하여야 합니다.

저자는 종료 코드를 개발하는 것은 생각보다 어렵고 오래 걸리기 때문에 이미 있는 알고리즘을 검토하라고 합니다.

13-9. 스레드 코드 테스트하기

스레드가 많아지면 고려할 상황이 기하급수적으로 증가하기 때문에 충분한 테스트를 통해 위험도를 낮추어야 합니다. 이 때, 아래의 몇 가지 구체적인 지침을 따르면 좋다고 합니다.

  • 말이 안 되는 실패는 잠정적인 스레드 문제로 취급 :arrow_right: 스레드 코드의 버그는 수천, 수백만 번에 한 번씩 드러나기도 하기 때문에 시스템 실패를 일회성으로 치부하게 된다면 잘못된 코드 위에 코드가 계속 쌓이는 꼴이 됩니다.
  • 다중 스레드를 고려하지 않은 순차 코드부터 제대로 실행되게 만들기 :arrow_right: 스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하면 안됩니다.
  • 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있도록 스레드 코드를 구현 :arrow_right: 아래 처럼 다양한 설정에서 실행할 목적으로 다른 환경에서도 쉽게 사용할 수 있는 코드를 구현해야 합니다.
    • 한 스레드로 실행, 여러 스레드로 실행, 실행 중 스레드 수를 바꾸는 경우
    • 스레드 코드를 실제 환경이나 테스트 환경에서 실행하는 경우
    • 테스트 코드를 빨리, 천천히 등 다양한 속도로 돌려보는 경우
    • 반복 테스트가 가능하도록 테스트 케이스를 작성하는 경우
  • 다중 스레드를 쓰는 코드 부분을 상황에 맞춰 조정할 수 있게 작성 :arrow_right: 스레드 개수를 조율하기 쉽게 코드를 구현해야 합니다.
  • 프로세서 수보다 많은 스레드를 돌려보기 :arrow_right: 시스템이 스레드를 스와핑할 때도 문제가 발생하기 때문에 많은 스레드를 실행해 테스트 해야합니다.
  • 다른 플랫폼에서 돌려보기 :arrow_right: 다중 스레드 코드는 플랫폼에 따라 다르게 실행될 수 있기 때문에 가능성이 있는 플랫폼 전부에서 테스트해야 합니다.
  • 코드에 보조 코드를 넣어 돌려, 강제로 실패하게 만들기 :arrow_right: 스레드 코드 오류는 찾기가 쉽지 않기 때문에 Object.wait(), Object.sleep(), Object.yield(), Object.priority() 등과 같은 메서드를 추가해 코드를 다양한 순서로 실행해 버그를 찾아야 합니다.

13-10. 결론

간단했던 코드가 여러 스레드와 공유 자료를 추가하면서 엄청나게 복잡해집니다. 때문에 다중 스레드 코드를 작성한다면 각별히 깨끗하게 코드를 구현해야 합니다.

아래는 다중 스레드 코드를 작성할 때 주의해야할 사항들 입니다.

  • 단일 책임 원칙(SRP)를 준수 :arrow_right: 스레드 코드를 테스트할 때는 스레드만 테스트해야하기 때문에 스레드 코드는 최대한 집약되고 작아야 합니다.
  • 동시성 오류를 일으키는 잠정적인 원인을 철저히 이해 :arrow_right: 여러 스레드가 공유 자료를 조작하거나 공유할 때 오류가 발생, 프로그램을 깔끔하게 종료하는 등 경계 조건의 경우가 까다로우므로 주의해야합니다.
  • 사용하는 라이브러리와 기본 알고리즘을 이해 :arrow_right: 특정 라이브러리 기능이 기본 알고리즘과 유사한 어떤 문제를 어떻게 해결하는지 파악해야합니다.
  • 보호할 코드 영역을 찾아내는 방법과 특정 코드 영역을 잠그는 방법을 이해 :arrow_right: 공유하는 정보와 공유하지 않는 정보를 제대로 이해해야합니다.
  • 어떻게든 문제는 생기기 때문에 문제를 일회성으로 치부하면 안됨 :arrow_right: 스레드 코드는 많은 플랫폼에서 많은 설정으로 반복해서 계속 테스트해야합니다.

참고 : Clean Code - 로버트 C. 마틴

댓글남기기