Language/Java

[클린 코드] Ch.13 - 동시성

비소_ 2022. 8. 23.

클릭하시면 네이버 Book으로 연결됩니다!

해당 서적을 참고하여 개인 공부용으로 정리한 글입니다.


동시성의 필요성

동시성은 결합도(coupling)을 없애는 전략이다.

무엇과 언제를 분리한다.

무엇과 언제를 분리하면 어플리케이션 구조와 효율이 극적으로 나아진다.

단일 스레드 프로그램은 무엇과 언제가 밀접하다.

 

서블릿 모델은 동시성을 부분적으로 관리한다.

웹 요청이 들어올 때마다 웹 서버는 비동기식으로 서블릿을 실행한다.

하지만 웹 컨테이너가 제공하는 결합분리(decoupling) 전략은 완벽과 거리가 멀다.

그럼에도 불구하고 구조적 이점은 아주 크다.

 

많은 사용자를 동시에 처리해야 하거나, 많은 정보를 분석해야할 때 동시성이 필요해진다.

동시성 오해

  • 동시성은 성능을 항상 높여주진 않는다.
    대기 시간이 길어 여러 스레드가 프로세서를 공유할 수 있거나, 여러 프로세서가 동시에 처리할 독립적인 계산이 충분히 많을때만 높아진다.
  • 동시성을 구현하면 설계가 완전히 달라진다.
  • 웹 또는 EJB 컨테이너를 사용하더라도 동시성을 이해해야 한다.

타당한 견해

  • 동시성은 다소 부하를 유발한다.
  • 동시성은 복잡하다
  • 동시성 버그는 재현하기 어렵다.
  • 근본적인 설계 전략을 재고해야 한다.

난관

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

두 스레드가 해당 인스턴스를 공유한다고 가정하자.

스레드들이 메서드를 호출하면 결과는 몇 가지 경우로 나눠진다.

 

그 이유는 스레드가 자바 코드 한 줄을 거쳐가는 경로가 수없이 많기 때문이다.

(JIT 컴파일러가 처리하는 과정이 순서에따라 조금씩 달라지기 때문)

대부분은 정상 결과를 반환하지만, 일부 경로가 잘못된 경로를 내놓는다.


동시성 방어 원칙

SRP

동시성 관련 코드를 다른 코드와 분리해야 한다.

동시성을 구현할 때는 다음 사항을 고려한다.

  • 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다.
  • 동시성 코드에는 독자적인 난관이 있다.
  • 잘못 구현한 동시성 코드는 예상치 못한 방식으로 실패한다.

자료 범위 제한

스레드가 공유 데이터를 수정하지 못하도록 임계영역을 설정하는 것도 중요하지만, 무엇보다 이 임계영역의 수와 사이즈를 최대한 줄여야 한다.

 

공유 데이터를 수정하는 위치가 많을 수록 다음 가능성이 커진다.

  • 보호할 임계영역을 빼먹는다.
  • 임계영역을 올바로 보호했는지 확인(DRY 위반)하느라 똑같은 노력과 수고를 반복한다.
  • 찾아내기 어려운 버그가 더욱 찾기 어려워짐

자료를 캡슐화하는것도 좋다.

자료 사본 사용

처음부터 공유하지 않는 것이 최선이다.

객체를 복사해서 사용하면 원본 데이터(공유 데이터)를 변경하지 않고 원하는 결과를 받을 수 있다.

객체를 복사하는 시간과 부하는 동기화를 피해 절약한 수행 시간으로 상쇄할 가능성이 크므로, 걱정하지 말 것.

독립적인 스레드로 구현

각 스레드는 클라이언트 요청 하나를 처리한다.

모든 정보는 비공유 출처에서 가져오며 로컬 변수에 저장한다.

그러면 각 스레드는 자신만의 세상에서 돌아갈 수 있다.

물론 대다수 어플리케이션은 데이터베이스 연결과 같은 자원을 공유하는 상황에 처한다.


라이브러리 이해

  • 스레드 환경에 안전한 컬렉션 사용 ex) concurrent 패키지
  • 서로 무관한 작업을 수행할 때는 executor 프레임워크 사용
  • 가능한한 스레드가 차단되지 않는 방법 사용
  • 일부 클래스 라이브러리는 스레드에 안전하지 못함을 유의

스레드 안전 컬렉션

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

동시에 읽기/쓰기를 지원하며 다중 스레드 상에서 안전하다.

이처럼 자바에는 동시성 설계를 지원하는 여러 클래스가 존재한다.

  • ReentrantLock : 한 메서드에서 잠그고 다른 메서드에서 푸는 락
  • Semaphore : 세마포어
  • CountDownLatch : 지정한 수만큼 이벤트가 발생하고 나서야 대기 중인 스레드를 모두 해제하는 락

실행 모델 이해

다중 스레드 프로그래밍에서 사용하는 실행 모델 몇가지를 살펴본다.

생산자-소비자

생산자 스레드는 큐에 빈 공간이 있어야 정보를 채운다.

소비자 스레드는 큐에 정보가 있어야 가져온다.

서로 목적을 달성하고나서 상대방에게 시그널을 보낸다.

따라서, 둘 다 진행 가능함에도 동시에 서로에게서 시그널을 기다릴 가능성이 존재한다.

읽기-쓰기

빠른 읽기를 위해 공유 자원을 사용하는데 이따금씩 쓰기 스레드가 갱신한다고 가정하자.

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

갱신을 허용하면 처리율에 영향을 미친다.

따라서 복잡한 균형잡기가 필요하다.

대개, 쓰기 스레드가 버퍼를 오랫동안 점유하는 바람에 여러 읽기 스레드의 처리율이 떨어진다.

양쪽 균형을 잡으면서 동시 갱신 문제를 피하는 해법이 필요하다.

식사하는 철학자들

유명한 이론인 만큼 주의해서 설계하지 않으면 데드락, 라이브락, 처리율/효율성 저하 등을 겪는다.

다중 스레드 문제는 위 범주 중 하나에 속한다.

기본 알고리즘과 각 해법을 이해하고 연습해본다면 실전에 도움이 될 것이다.


동기화 메서드의 의존성 이해

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

따라서 공유 객체 하나에는 메서드 하나만 사용하는 것이 좋다.

 

하지만, 여러 메서드가 필요한 상황도 생긴다.

그 경우 다음 방법을 고려해본다.

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

임계영역 최소화

락은 스레드를 지연시키고 부하를 가중시킨다.

synchronized 키워드를 남발하는 코드는 바람직하지 않다.

하지만, 임계 영역은 반드시 보호해야 하므로, 임계 영역을 최대한 줄이기 위해 노력해야 한다.


올바른 종료 코드

깔끔하게 종료하는 코드는 올바르게 구현하기 어렵다.

가장 흔한 예시가 데드락이다.

깔끔하게 종료하는 다중 스레드 코드를 짜야한다면 시간을 투자해 올바르게 구현해야 한다.

따라서 개발 초기부터 고민하고 동작하게 초기부터 구현해야 한다.

생각보다 어렵고 오래 걸리므로 이미 나온 알고리즘을 검토한다.


스레드 코드 테스트

테스가 늘 정확성을 보장하지 않지만, 충분한 테스트는 위험을 낮춘다.

하지만, 같은 코드/자원을 사용하는 스레드가 둘 이상으로 늘어나면 상황이 복잡해진다.

따라서, 문제를 노출하는 테스트 케이스를 작성하고, 설정과 부하를 바꿔가며 자주 돌려야한다.

일회성 오류가 나온다면 절대 그냥 넘어가면 안된다.

 

아래는 몇 가지 구체적인 지침이다.

일회성 문제는 없다

말이 안되는 오류, 즉, 일회성 문제를 발견하면 그냥 지나치기 쉽상이다.

일회성 문제란 존재하지 않는다 가정하고, 무시하지 말자.

순차 코드부터 확실히

스레드 환경 밖에서 코드가 제대로 도는지 반드시 확인해야 한다.

따라서 스레드를 모르는 POJO를 만든다.

설정을 쉽게 바꿀 수 있도록 구현

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

다양한 설정에서 코드를 돌려봄으로써 코드의 문제점을 찾을 수 있다.

조율가능한 스레드 코드

적절한 스레드 개수를 파악하려면 상당한 시행착오가 필요하다.

다양한 설정으로 성능 측정 방법을 강구하고, 개수를 쉽게 조율할 수 있도록 구현한다.

런타임 중 스레드 개수를 변경하는 방법도 고려한다. (처리율과 효율에 따라)

스와핑 테스트

시스템이 스레드를 스와핑(swapping)할 때도 문제가 발생한다.

스와핑을 일으키려면 프로세서 수보다 많은 스레드를 돌리면 된다.

스와핑이 잦을 수록 임계영역을 빼먹은 코드나 데드락을 일으키는 코드를 찾기 쉬워진다.

이식성 테스트

다중 스레드 코드는 플랫폼에 따라 다르게 돌아간다.

코드가 돌아갈 가능성이 있는 플랫폼 전부에서 테스트를 수행해야 한다.

보조 코드

스레드 버그는 발견하기 아주 어렵다.

따라서 보조 코드를 추가해 코드가 실행되는 순서를 바꿔본다. (ex. wait(), yeild(), sleep(), ...)

각 메서드는 실행되는 순서에 영향을 미친다.

 

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

직접 구현

코드에다 직접 위와 같은 보조 코드를 삽입하는 것이다.

코드가 실패한다면 보조 코드를 실패해서 그런 것이 아니다. 원래 잘못된 코드인 것이다.

하지만, 이 방법에는 여러 문제가 있다.

  • 보조 코드의 적정 위치
  • 어떤 함수를 호출할 지
  • 배포 환경에 그대로 남겨두면 프로그램 성능이 떨어짐
  • 무작위적임. 나타나지 않을 확률이 사실상 높음

따라서, 테스트 환경에서 보조 코드를 실행할 방법이 필요하다.

또한, 실행할 때마다 설정을 바꿔줄 방법도 필요하다.

자동화

AOF(Aspect-Oriented Framework), CGLIB, ASM 등과 같은 도구를 사용한다.

public class ThreadJigglePoint {
    public static void jiggle() { }
}

public synchronized String nextUrlOrNull() {
    if(hasNext()) {
        ThreadJiglePoint.jiggle();
        String url = urlGenerator.next();
        ThreadJiglePoint.jiggle();
        updateHasNext();
        ThreadJiglePoint.jiggle();
        return url;
    }
    return null;
}

ThreadJigglePoint.jiggle() 호출은 무작위로 sleep이나 yield를 호출한다. 때로는 아무 동작(nop)도 하지 않는다.

ThreadJigglePoint 클래스를 두 가지로 구현하면 편리하다.

하나는 jiggle() 메서드를 비워두고 배포 환경에서 사용하고, 하나는 보조 코드를 사용해 테스트 환경에서 수행한다.

둘째 클래스로 테스트를 수천 번 실행하면 오류가 드러날 수도 있다.

 

코드를 흔드는(jiggle)이유는 스레드를 매번 다른 순서로 실행하기 위해서다.

좋은 테스트 케이스와 흔들기 기법은 오류가 드러날 확률을 크게 높여준다.

'Language > Java' 카테고리의 다른 글

[클린 코드] Ch.15 - JUnit  (0) 2022.08.25
[클린 코드] Ch.14 - 점진적인 개선  (0) 2022.08.24
[클린 코드] Ch.12 - 창발성  (0) 2022.08.22
[클린 코드] Ch.11 - 시스템  (0) 2022.08.19
[클린 코드] Ch.10 - 클래스  (0) 2022.08.18

댓글