코드
https://github.com/rkwhr0010/clean_code/tree/main/src
변경 사항은 git history 참고
객체는 처리의 추상화다. 스레드는 일정의 추상화다.
-제임스 O. 코플리엔
동시성과 깔끔한 코드는 양립하기 어렵다.
스레드 하나만 실행하는 코드는 짜기 쉽다.
겉으로 멀쩡 해보이는 실상은 문제가 숨겨진 다중 스레드 코드는 짜기 쉽다. 이런 코드의 심각한 문제는 시스템이 잘 동작하다가 부하가 발생하거나 아니면 갑자기 문제가 발생한다.
동시성이 필요한 이유?
동시성은 결합(무엇과 언제)을 없애는 전략
무엇과 언제를 분리하는 전략
스레드가 하나인 프로그램은 무엇과 언제가 밀접하다
콜스텍을 보면 쉽게 알 수 있다.
무엇과 언제를 분리하면 애플리케이션 구조와 효율이 극적으로 나아진다.
구조적인 관점에서 하나의 프로그램은 거대한 루프(단일 쓰레드)가 아닌 작은 협력 프로그램 여럿으로 보인다.
따라서 시스템을 이해하기, 문제를 분리하기 쉽다
서블릿 모델, WAS의 서블릿 컨테이너를 보면, 동시성을 부분적으로 관리한다.
웹 요청 마다 웹 서버는 비동기식으로 서블릿을 실행한다.
각 서블릿 스레드는 다른 서블릿 스레드와 무관하다.
웹 컨테이너가 제공하는 결합분리 전략은 완벽하지 않아 주의가 필요하다.
그래도 서블릿 모델이 제공하는 구조점 이점이 더욱 크다.
구조점 이점을 제외하고도, 응답시간과 작업 처리량 개선도 많이된다.
단일 쓰레드 프로그램은 병목에 취약하다.
I/O 블락, 소켓 통신 등 대기하는 시간
미신과 오해
미신과 오해
- 동시성은 항상 성능을 올려준다.
대기시간이 길고, 프로세서를 여러 스레드가 공유 가능하거나
여러 프로세서가 동시에 처리할 독립적인 계산이 많은 경우에만 성능이 높아진다.
둘 경우 전부 일반적인 상황은 아니다.
- 동시성을 구현해도 설계는 그대로다.
단일 쓰레드와 멀티 쓰레드는 설계 자체가 다르다.
무엇과 언제를 분리하면 시스템 구조가 크게 달라져야 한다.
- 웹 컨테이너를 사요하면 동시성을 이해할 필요가 없다.
컨테이너가 어떻게 동작하는지 알아야 동시 수정, 데드락 등과 같은 문제를 회피할 수 있다.
사실
- 동시성은 다소 부하를 유발한다.
성능 측면에서 부하가 걸린다. 그리고 코드량도 늘어난다 - 동시성은 복잡하다. 간단한 문제라도 복잡하다
- 일반적으로 동시성 버그를 제현하기 어렵다.
이 문제로 자주 발현되지 않은 동시성 버그를 버그로 생각하지 않고, 고치려는 시도를 하지 않는 경우가 있다. - 동시성을 구현하려면 근본적인 설계 전략을 고려해야 한다.
난관
동시성 구현이 어려운 이유
public class X { private int lastIdUsed; public void resetLastIdUsed() { lastIdUsed = 0; } public int getNextId() { return ++lastIdUsed; // lastIdUsed = lastIdUsed + 1 } } |
JIT 컴파일러가 바이트 코드를 처리하는 방식과 자바 메모리 모델이 원자로 간주하는 최소 단위를 알아야 무엇이 문제인지 파악할 수 있다.
getNextId() 메서드를 동작을 피연산자스택 기준으로 추상화하여 한글로 풀면 다음과 같다.
- lastIdUsed 읽는다.
- 상수 1을 읽는다.
- lastIdUsed, 상수 1을 더 한다.
이 단위 사이 마다 다른 쓰레드가 개입할 가능성이 있다.
원자적 연산
중간에 중단이 불가능한 연산을 원자적 연산으로 정의한다.
자바 메모리 모델에 의하면 32bit 메모리에 값을 할당하는 연산은 중단이 불가능하다.
int 에서 long으로 바꾼다면, 원자적 연산이 아니다.
JVM 명세에 따르면 64 bit 값을 할당하는 연산 두 개로 나눠진다. 단, 프로세서에 따라 원자적 연산으로 처리할 수 있다.
https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html#jvms-2.6.1
즉, 32bit 할당 후 다음 32bit 할당 사이 다른 쓰레드가 끼어들 가능성이 존재한다.
다른 쓰레드가 32bit 두 개 값 중 하나를 수정할 수 있다.
resetLastIdUsed() 메서드는 원자적 연산이다.
전후 맥락과 관계 없이 상수 0을 할당하고 있다.
피연산자 스택에서 값을 할당할 때 사용되는 정보(this, 0)는 다른 스레드에서 간섭을 할 수 없다.
추가로 데이터 형을 int -> long으로 변경해도 원자적 연산이다. 중간에 다른 쓰레드가 간섭할 경우의 수는 의미가 없다. 결국 상수 0을 할당하기 때문이다.
getNextId()는 원자적 연산이 아니다.
++ 연산자는 바이트 코드상으로 lastIdUsed = lastIdUsed + 1 연산과 같은다.
피연산자 스택에서 lastIdUsed 값을 가져오고 난 뒤 다른 쓰레드가 끼어들어 lastIdUsed + 1 수행을 하면, +1 은 소실된다.
public class Main { public static void main(String[] args) { X x = new X(); for (int i = 0; i < 100_000; i++) { createThread(() -> System.out.println(x.getNextId())); } } static void createThread(Runnable run) { new Thread(run).start(); } } |
100000 이 결과로 나와야 하지만, 동시성 문제로 최종 결과로 999981가 출력됐다.
한 쓰레드가 코드 레벨 ++lastIdUsed 작업을 수행하던 중
바이트 코드 레벨 lastIdUsed 가져오고, 상수 1을 가져오고, 둘을 더해야하는데
lastIdUsed 을 가져온 상태에서 다른 쓰레드가 끼어들어 일부 데이터가 손실된 것이다.
public synchronized int getNextId() { return ++lastIdUsed; // lastIdUsed = lastIdUsed + 1 } |
synchronized 키워드를 사용해 임계영역을 지정하면 정상적으로 동작한다.
동시성을 위해 바이트 코드를 전부 이해할 필요는 없다.
공유 객체/값이 있는곳, 동시 읽기/수정 문제를 일으킬 수 있는 코드, 동시성 문제를 방지하는 방법은 알아야 한다.
동시성 방어 원칙
단일 책임 원칙
주어진 메서드, 클래스, 컴포넌트를 변경할 이유는 하나여야 한다는 원칙
동시성은 복잡성 하나만으로도 분리할 이유가 충분하다.
동시성 관련 코드는 다른 코드와 분리해야 한다.
동시성 구현 고려사항
- 동시성 코드는 독자적인 개발, 변경, 조율 주기가 있다
- 동시성 코드는 독자적인 난관이 있다. 훨씬 어렵다.
- 잘못 구현된 동시성 코드는 추측하기 힘든 방식으로 실패한다.
동시성 관련 코드는 다른 코드와 분리해야 한다.
따름 정리: 자료 범위를 제한하라
객체 하나를 공유한 후 동일 필드를 수정하면, 쓰레드 간 간섭이 발생할 수 있다.
이 문제를 방지하려면, 공유 객체 내에 synchronized 키워드로 임계 영역으로 보호해야 한다.
임계영역은 성능에 악영향으로 그 사용 수를 최대한 줄여야 한다.
공유 자료를 수정하는 위치가 많을 수록 발생할 수 있는 경우의 수
- 임계영역을 빼먹기, 하나라도 빼먹으면 전체에 영향
- 모든 임계영역 체크로 고된 노력이 필요
- 어려운 버그 사유를 더 찾기 어려워 짐
자료를 캡슐화하고, 공유 자료를 최대한 줄여야 한다.
따름 정리: 자료 사본을 사용하라
객체를 복사해 읽기 전용으로 사용하는 방법
각 쓰레드가 객체를 복사해 사용한 후 한 쓰레드가 사본에서 결과를 가져오는 방법도 가능
일반적으로 사본을 생성하는 비용이 공유 자료 임계영역으로 인한 비용보다 적다.
- 사본 생성 비용
메모리, 메모리로 인한 GC 발생 - 임계영역 비용
동기화로 인한 락
따름 정리: 쓰레드는 가능한 독립적으로 구현하라
스레드 하나 마다 싱글 스레드 어플리케이션으로 생각하고 개발한다.
이러면 다른 스레드와 자료 공유라는 생각을 못하게 된다. 단일 쓰레드 프로그램이니까
각 쓰레드는 클리이언트 요청 하나를 처리한다.
모든 정보는 비공유 출처에서 가져와 로컬 변수에 저장한다.
이 로컬 변수만 사용한다면, 동기화 문제를 일으킬 수가 없다.
이렇게 되면 각 스레드는 마치 서로 다른 어플리케이션 처럼 동작하게 된다.
DB 같은 공유하는 외부 자원을 사용하면 문제가 생길 수 있다.
위 예시는 WAS 동작 방식이다. Request 마다 하나의 쓰레드를 생성해 동작한다.
권장
독자적인 쓰레드로, 가능하면 다른 프로세서에서, 돌려도 갠찮도록 자료를 독립적인 단위로 분할하라
라이브러리를 이해하라
자바 5부터 동시성이 많이 개선됐다.
java.util.concurrent 패키지
참고로 자바 8부터 추가된 java.util.stream 패키지에선 동시성을 훨씬 쉽게 사용할 수 있다.
자바 5 이후 버전을 사용한다면 동시성 코드 구현 시 다음을 고려한다.
- 스레드 환경에 안전한 컬렉션 사용
- 서로 무관한 작업을 수행할 때는 executor 프레임 워크를 사용한다
- 가능하면 스레드가 블락킹 되지 않는 방법을 사용한다.
- 일부 클래스 라이브러리는 스레드에 안전하지 못하다.
스레드 환경에 안전한 컬렉션
java.util.concurrent 패키지는 다중 쓰레드 환경에서 안전하다. 심지어 성능도 좋다.
ConcurrentHashMap의 경우 HashMap보다 거의 모든 면에서 빠르다.
https://stackoverflow.com/questions/1378310/performance-concurrenthashmap-vs-hashmap
동시 읽기/쓰기를 지원한다. 그리고 자주 사용하는 복합 연산을 다중 쓰레드 상에서 안전하게 만든 메서드를 제공한다.
https://codepumpkin.com/hashtable-vs-synchronizedmap-vs-concurrenthashmap/
더 복잡한 동시성 설계를 위해 자바 5에 추가된 클래스
- ReentrantLock
한 메서드에서 잠그고, 다른 메서드에서 푸른 락 - Semaphore
진입 가능한 프로세스/스레드 카운트 수 제한이 존재하는 락 - CountDownLatch
지정한 수 만큼 이벤트 발생하면 대기 중인 스레드를 모두 해제하는 락
모든 쓰레드는 동시에 공편하게 시작할 기회를 가진다.
권장
자바 네이티브 라이브러리 사용을 검토
실행 모델을 이해하라
기본 용어
- 한정된 자원(Bound Resource)
모든 컴퓨팅 자원이 아닌, 공유 자원을 의미한다.
CPU, 메모리 같은 나의 자원부터, 소켓, DB연결 등 외부 연결 등... - 상호 배제(Mutual Exclusion)
한 번에 한 쓰레드만 공유 자료나 공유 자원을 사용할 수 있는 경우를 의미한다. - 기아(Stravation)
우선순위같은 스케줄링 문제로 특정 쓰레드나 쓰레드들이 오랫동안 혹은 영원히 자원 할당을 못받는 상황을 의미한다. - 데드락(Deadlock)
여러 쓰레드가 각기 자원을 들고 있으면서 서로 간 자원을 원해 계속 대기하는 상황 - 라이브락(Livelock)
락을 거는 단계에서 각 쓰레드가 서로를 방해
락 획득과 락 헤제를 반복하면서, 오랫동안 혹은 영원히 실행하지 못하는 상황
생산자-소비자
스레드를 생산자/소비자 스레드로 분류한다.
각 역할 쓰레드는 하나일 수도 여러 쓰레드 일 수도 있다.
두 종류 쓰레드는 실행 대기열을 공유한다. 대기열을 한정된 자원
생산자 쓰레드는 대기열에 빈 공간이 있을 때 정보를 만들어 채워 넣는다.
소비자 쓰레드는 대기열에 정보가 있어야 가져온다. 정보가 채워질 때까지 대기한다.
대기열을 효과적으로 사용하기 위해, 생산자/소비자 쓰레드는 서로 통신한다.
생산자 쓰레드는 대기열에 정보를 채웠으면, 정보가 있다는 사실을 소비자 쓰레드에게 알려준다.
소비자 쓰레드는 대기열에 정보를 가져오고, 대기열에 빈공간이 존재한다는 사실을 생산자 쓰레드에게 알려준다.
순환 구조로 잘못하면, 생산자/소비자 쓰레드는 진행할 수 있는 상황에서 서로의 신호를 대기할 수도 있다.
읽기-쓰기
상황
읽기 쓰레드가 공유 자원을 사용한다. 쓰기 쓰레드가 공유 자원을 가끔씩 갱신
이 구조는 읽기 쓰레드 처리율이 중요하다.
읽기 처리율만 강조하면 쓰기 쓰레드가 기아 상태에 빠져, 정보를 갱신하지 못할 수도 있다.
갱신을 허용하면, 처리율이 떨어진다.
공유 자원을 읽는 동안, 공유 자원을 쓰지 못하게, 반대로 공유 자원을 쓰는 동안, 공유 자원을 읽지 못하게, 이 사이 균형이 중요하다.
일반적으론 쓰기 작업을 대기하느라 읽기 쓰레드들이 대기하는 상황 때문에 처리율이 떨어진다.
식사하는 철학자들
둥근 식탁에 철학자들이 앉아 있다.
각 철학자는 왼쪽에 포크가 있다.
식탁 가운데에 스파게티 한 접시가 있다.
포크를 집어야 먹을 수 있다.
철학자들은 배고프지 않으면 생각을 하고, 배고프면 포크를 집어들고 스파게티를 먹는다.
배고픈 철학자는 왼쪽/오른쪽 철학자가 포크를 사용 중이라면, 대기한다.
스파게티를 먹고, 포크를 내려놓고 다시 생각에 잠긴다.
위 상황에서 철학자는 쓰레드, 포크는 자원이다.
여러 프로세스는 한정된 자원을 얻으려 경쟁한다.
이 경쟁을 효과적으로 제어하지 못하면, 기아, 데드락, 라이브락 등과 같은 상황이 발생한다.
권장
대부분의 다중 쓰레드 문제는 큰 틀에서 보면 위 세가지 범주에 속한다.
각 알고리즘과 해법을 이해해야 한다.
동기화하는 메서드 사이에 존재하는 의존성을 이해하라
동기화하는 메서드 사이에 의존성이 존재하면 동시성 코드에 찾기 어려운 버그가 생긴다.
자바는 synchronized 키워드로 임계영역을 만들어 개별 메서드를 보호할 수 있다.
공유 클래스 하나에 동기화된 메서드가 여럿이면 구현이 올바른지 확인해야 한다.
권장
공유 객체 하나에는 메서드 하나만 사용
공유 객체 하나에 메서드 둘 이상 필요한 경우는 다음을 고려
- 클라이언트에서 잠금
클라이언트에서 첫 번째 메서드 호출 전 서버를 잠근다
마지막 메서드 호출까지 잠금을 유지 - 서버에서 잠금
클라이언트가 호출한 별도 메서드를 하나 만든다.
이 메서드는 서버를 잠그고 모든 메서드 호출 후 잠금을 해제한다. - 연결 서버
잠금을 수행하는 중간 단계를 생성
서버에서 잠금과 유사하나 원래 서버는 변경하지 않는다
메서드 사이에 존재하는 의존성을 조심하라
public class IntegerIterator implements Iterator<Integer>{ private Integer nextValue = 0; @Override public synchronized boolean hasNext() { return nextValue < 100_000; } @Override public synchronized Integer next() { if (nextValue >= 100_000) { return -1; //예외라고 가정, 비정상값 } return nextValue++; } public synchronized Integer getNextValue() { return nextValue; } } |
public class Main { public static void main(String[] args) { IntegerIterator iterator = new IntegerIterator(); while (iterator.hasNext()) { int nextValue = iterator.next(); // nextValue 로 무언가를 계산 } } } |
위 코드는 하나의 쓰레드가 실행하면 문제없지만,
둘 이상 쓰레드가 IntegerIterator 객체를 공유하면, 문제가 생긴다.
거의 문제가 없지만, 끝 지점쯤에 스레드 간 간섭으로 정상값을 가져오지 못할 수 있다.
nextValue 이 현재 99998
스레드 1, hasNext() 호출로 true를 얻었다.
스레드 1, next() 호출 전에
스레드 2, 끼어들어 hasNext() 호출로 true를 얻었다.
스레드 2, next() 호출한다. 99998 + 1 = 99999 를 반환한다. 스레드 2는 종료
nextValue은 현재 99999 이므로 false 이다.
하지만 스레드 1은 이미 true를 받아, hasNext() 호출한다. 비정상 값을 반환받는다(예외라고 가정)
위 상황은 실제 다중 스레드 환경에서 발생할 수 있는 문제다.
이 문제는 스레드 간 간섭할 때만 문제가 발생하기 때문에 훨씬 까다롭다.
해결 방안
- 실패를 용인한다
- 클라이언트를 바꿔 문제 해결
클라이언트 기반 잠금 구현 - 서버를 바꿔 문제 해결
서버 기반 잠근 구현
실패를 용인한다
실패를 전부 허용한다는 뜻이 아니라, 때로는 실패해도 괜찮도록 한다.
예를들어 가끔 발생하는 실패를 예외처리로 재상태로 돌린다. 다만, 근본적인 문제해결이 아니므로 조잡한 방식이다. 근단적인 예시로, 메모리 누수를 못잡아서 한 달에 한 번씩 재부팅하는 것과 같다.
클라이언트-기반 잠금
다중 스레드 환경에서 클라이언트 코드를 모두 다음과 같이 변경
static void threadSafe() { IntegerIterator it = new IntegerIterator(); while (true) { int nextValue; synchronized (it) { if (!it.hasNext()) { break; } nextValue = it.next(); } doSomethingWith(nextValue); } } |
각 클라이언트는 IntegerIterator 객체에 락을 건다.
안전해 졌지만 ,DRY 원칙을 위배해 중복이 많아진다.
중복도 큰 문제고. 다른 큰 문제는 모든 프로그래머가 클라이언트 쪽 코드에서 IntegerIterator 객체를 사용할 때 synchronized 키워드로 락을 걸어야 한다고 인지를 하고, 빼먹으면 안된다.
이 문제는 아주 심각하고, 빼먹은 코드를 찾기도 힘들다.
즉, 절대 클라리언트-기반 잠금을 사용해선 안된다.
다중 쓰레드 환경에 안전하지 못한 라이브러리 사용해야 하는 상황에서도 절대 쓰지 않는다.
Adapter 패턴으로 해당 라이브러리를 감싸서 사용한다.
서버-기반 잠금
IntegerIterator 를 다음과 같이 변경한다.
public class IntegerIteratorServerLocked { private Integer nextValue = 0; public synchronized Integer getNextOrNull() { if (nextValue < 100_000) { return nextValue++; } else { return null; } } } |
클라이언트 코드
private static void serverBaseThreadSafe() { IntegerIteratorServerLocked it = new IntegerIteratorServerLocked(); while (true) { Integer nextValue = it.getNextOrNull(); if (nextValue == null) { break; } // nextValue 로 무언가를 계산 } } |
API를 변경했다.
클라리언트는 hasNext() 대신 null 체크가 필요해졌다.
그래도 일반적으로 서버기반 잠금이 더 옳다.
이유
- 락 관련 코드 중복 제거
- 성능 증가(멀티 쓰레드 환경 서버를 단일 쓰레드 환경으로 수정)
- 오류 발생 가능성 감소(락 관련 코드를 기억할 필요 없음)
- 중앙 집중화된 락 코드
- 공유 변수 범위 축소
서버 코드에 손을 대지 못할 경우 예시
public class IntegerIteratorAdapter { private IntegerIterator it = new IntegerIterator(); public synchronized Integer getNextOrNull() { if (it.hasNext()) { return it.next(); } else { return null; } } } |
동기화하는 부분을 작게 만들어라
synchronized 키워드로 감싼 코드 영역은 한 번에 한 쓰레드만 실행 가능하다.
락은 스레드 지연과 HW 부하를 유발한다.
꼭 필요한 곳만 임계영역을 설정해 그 수를 최대한 줄여야 한다.
단편적으로 임계영역 수만 집중하여 전체를 임계영역으로 만들면 안된다.
임계영역 크기는 스레드 간 경쟁을 촉발시켜 성능이 저하된다.
작업 처리량 높이기
예시
URL 목록을 받아 네트워크 연결 후 각 페이지를 읽어오는 코드
각 페이지를 분석해 통계를 구함
모든 페이지 분석 후 통계 보고서 출력
//URL 하나를 받아 해당 페이지 내용을 반환한다. public class PageReader { // ... HttpClientImpl httpClient = new HttpClientImpl(); public String getPageFor(String url) { HttpMethod method = new GetMethod(url); try { httpClient.executeMethod(method); String response = method.getResponseBodyAsString(); return response; } catch (Exception e) { return handle(e); } finally { method.releaseConnection(); } } private String handle(Exception e) { return ""; } } |
public class PageIterator { private PageReader reader; private URLIterator urls; public PageIterator(PageReader reader, URLIterator urls) { this.reader = reader; this.urls = urls; } public synchronized String getNextPageOrNull() { if (urls.hasNext()) { return getPageFor(urls.next()); } else { return null; } } private String getPageFor(String url) { return reader.getPageFor(url); } } |
PageIterator 인스턴스는 여러 스레드가 공유
각 스레드는 PageIterator 인스턴스를 공유하며, Iterator를 사용한다.
위 코드에서 synchronized 키워드를 꼭 필요한 곳에 최대한 적은 범위로 적용한 것을 알 수 있다. 이 처럼 제한적으로 사용해야 한다
작업 처리량 계산 - 단일 스레드 환경
연산 속도 가정
- 페이지 읽어오는 평균 I/O시간 : 1초
- 페이지 분석하는 평균 처리 시간 : 0.5초
- 처리 CPU 100% 사용, I/O CPU 0% 사용
쓰레드 하나가 N 페이지를 처리하는 시간은 1.5초 * N
작업 처리량 계산 - 다중 스레드 환경
순서에 무관하게 페이지를 읽어, 독립적으로 처리해도 좋다면, 다중 스레드가 처리율을 높여줄 수 있다.
다중 쓰레드를 사용하면 페이지 분석과 I/O를 동시에 처리할 수 있다.
스레드가 3개, CPU를 100%를 활용한다고 가정하면,
페이지 읽기 한 번에 페이지 분석을 두 번을 겹칠 수 있다.
따라서 1초 마다 단일 쓰레드와 비교해서 3배를 처리할 수 있다.
올바른 종료 코드는 구현하기 어렵다
영구 구동 시스템과 잠시 구동하다 종료하는 시스템을 구현하는 방법은 다르다.
깔끔하게 종료하는 코드는 구현하기 어렵다.
가장 흔한 문제는 데드락으로 스레드가 오지 못하는 종료 시그널을 계속 기다린다.
가정
부모 스레드가 자식 스레드를 여러 개 만든 후 모두 끝나기를 기다렸다 자원을 해제하고 종료하는 시스템
자식 쓰레드 중 하나라도 락이 걸리면, 부모 쓰레드는 영원히 대기상태로 시스템 종료를 못한다
유사한 시스템에서 사용자에게서 종료 명령을 받았다고 가정
부모 쓰레드는 자식 쓰레드에게 종료하라고 명령을 내린다.
이때 자식 쓰레드 두 개가 생산자/소비자 관계
생산자 자식 쓰레드는 명령을 받아 종료를 했다.
소비자 자식 쓰레드는 생상자 자식 쓰레드로부터 오지 못할 명령을 대기 중이라 부모 쓰레드 종료 명령을 수행하지 못하고 영원히 종료를 못한다.
이게 끝이 아니다. 반드시 시간을 들여 올바른 종료 코드를 구현해야 한다.
권장
종료 코드를 개발 초기부터 고민하고 구현하고, 좋은 알고리즘 찾아보고 있다면 그 것을 사용하라
데드락
가정
개수가 한정된 자원 풀 두 개를 공유하는 웹 어플리케이션
- 로컬 임시 DB 연결 풀
- 중앙 저장소 MQ 연결 풀
생성과 갱신 연산 두 개를 수행
- 생성
중앙 저장소 연결 후 임시 DB 연결
중앙 저장소 통신 후 임시 DB에 작업을 저장 - 갱신
임시 DB 연결 후 중앙 저장소 연결
임시 DB에서 작업을 읽어 중앙 저장소로 전송
풀 크기보다 사용자 수가 많은 상황, 각 풀 크기는 10
- 사용자 10명이 생성 시도
중앙 저장소 연결 10개 전부 확보
임시 DB 연결 확보 전에 중단 - 사용자 10명이 갱신 시도
임시 DB 연결 10개 모두 확보
중앙 저장소 연결 확보 전에 중단 - 생성 스레드 10개는 임시 DB 연결 확보를 위해 대기한다.
갱신 스레드 10개는 중앙 저장소 연결 확보를 위해 대기한다. - 데드락 발생
이런 동시성 버그는 증상 재현이 어렵다. 증상이 어쩌다 발생하기도 하고, 원인 파악을 위해 디버깅 문을 추가하면, 디버깅 문이 추가 됨에 따라 데드락 양상이 바뀔 수도 있다.
데드락을 해결하려면 원인을 이해해야 한다.
- 상호 배제 (Mutual exclusion)
- 잠금 & 대기 (Lock & Wait)
- 선점 불가 (No Preemption)
- 순환 대기 (Circular Wait)
상호 배제 (Mutual exclusion)
여러 스레드가 한 자원을 공유하나 그 자원은 여러 스레드가 동시에 사용하지 못하며 개수가 제한적이라면 상호 배제 조건을 만족한다
예시
DB 연결, 쓰기용 파일 열기, 레코드 락, 세마포어 같은 자원
잠금 & 대기 (Lock & Wait)
스레드가 자원을 점유하면 필요한 나머지 자원까지 모두 점유해 작업을 마칠 때까지 점유한 자원을 내놓지 않는다.
선점 불가 (No Preemption)
한 스레드가 다른 스레드로부터 자원을 뺏지 못한다.
자원을 점유한 스레드가 자원을 내놓기 전까지 다른 스레드는 그 자원을 점유하지 못한다.
순환 대기 (Circular Wait, == 죽음의 포옹)
T1, T2 쓰레드 존재, R1, R2 자원 존재
T1 -> R1 점유
T2 -> R2 점유
T1 -> R2 필요
T2 -> R1 필요
위 네 가지 조건을 모두 충족하면, 데드락이 발생한다.
이 중 하나라도 해결하면, 발생하지 않는다.
상호 배제 조건 깨기
- 동시에 사용해도 괜찮은 자원을 활용
- 스레드 수 이상으로 자원 수를 늘린다.
- 자원을 점유하기 전에 필요한 자원이 모두 있는지 확인한다.
실무에선 대다수 자원은 수가 제한적이다. 그리고 첫 번째 자원을 사용하고 추가로 필요한 자원이 있을 수도 있다.
즉, 실제로 상호 배제 조건을 깨기는 쉽지 않다.
잠금 & 대기 조건 깨기
대기하지 않으면 데드락은 발생하지 않는다.
각 자원을 점유하기 전에 확인 해 자원 하나라도 점유를 못하면 지금까지 점유한 자원을 반환하고 처음부터 재시작한다.
문제점
- 기아(Starvation)
한 스레드가 필요한 자원을 점유하지 못한다.
이유는 한 번에 다 점유하기 힘든 자원 조합 - 라이브락(Livelock)
여러 스레드가 한번에 잠금 단계로 진입을 시도한다. 때문에 각 쓰레드는 자원을 점유했다가 반환했다가를 반복한다.
두 상황 다 작업 처리량을 크게 저해한다.
기아는 CPU 효율을 저하시켜 작업 처리량을 저하시킨다.
라이브락은 CPU 자원만을 쓸데없이 많이 사용한다.
선점 불가 조건 깨기
다른 쓰레드로부터 자원을 뺏을 수 있으면, 데드락은 발생하지 않는다.
필요한 자원이 잠금 상태라면, 소유자 스레드에게 잠금 해제 요청을 한다.
소유자 스레드는 현재 상태가 다른 자원을 기다리는 중이라면, 자신이 점유한 자원을 모두 해제하고 처음부터 다시 시작한다
잠금 해제 요청이 없다면, 스레드가 자원을 기다려도 된다는 이점이 있다.
따라서 처음부터 다시 시작하는 수가 줄어든다.
다만 이 요청 관리하기 어렵다.
순환 대기 조건 깨기
가장 흔한 전략
대부분 시스템이 채용
T1, T2 가 똑같은 순서로 자원을 할당하게 만들면 순환 대기는 불가능하다.
기존
T1 -> R1 점유
T2 -> R2 점유
T1 -> R2 필요
T2 -> R1 필요
해결
모든 쓰레드가 자원 점유 순서 동일하게 가져가도록 한다.
R1 점유 후 R2를 점유하도록 변경
문제점
- 자원을 할당하는 순서와 사용하는 순서가 다를 수 있다.
먼저 할당 받은 자원을 나중에 사용하게 되어 필요 이상으로 자원을 점유하게 될 수 있다 - 때론 순서에 따라 자원 할당이 어렵다
첫 자원 사용 후 둘째 자원을 얻는다면 순서대로 할당하기는 불가능하다.
결론
데드락을 피하는 전략은 많다. 각기 장단이 존재하며 어떠한 것도 쉽지 않다.
따라서 쓰레드 관련 코드를 분리해 개발을 해야한다.
그래야 쓰레드 관련 문제를 파악하기 그나마 쉬워지고, 문제 해결 방법도 쉬워진다.
스레드 코드 테스트하기
동시성 코드는 올바르다고 증명하기는 불가능하다.
그럼에도 불구하고 충분한 테스트는 위험을 낮춰준다.
권장
문제를 노출하는 테스트 케이스 작성
각종 설정과 부하 정도를 바꿔가며 반복 테스트
실패하면, 반드시 원인을 추적, 다시 돌렸을 때 통과했다고 그냥 넘어가면 안된다.
고려사항 지침
- 말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라
- 다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자
- 다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있도록 스레드 코드를 구현하라
- 다중 스레드 쓰는 코드 부분을 상황에 맞춰 조정할 수 있게 작성하라
- 프로세서 수보다 많은 스레드를 돌려보라
- 다른 플랫폼에서 돌려보라
- 코드에 보조 코드를 넣어 돌려, 강제로 실패를 일으키게 해보라
말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라
다중 쓰레드 코드는 말이 안되는 오류를 일으킬 때도 있다.
문제는 이를 개발자가 직관적으로 원인을 이해할 수 없다.
100 만 번에 1 번 문제가 발생할 수도 있어, 문제를 재현하기가 어렵다
이런 문제를 일회성 문제로 취급해선 안된다.
권장
시스템 실패를 일회성이라 치부하지 말기
다중 스레드를 고려하지 않은 순차 코드부터 제대로 돌게 만들자
스레드 환경은 배제한 코드부터 테스트해야 한다.
스레드가 별개 코드로 잘 분리되어 있다면, 그 코드가 호출하는 POJO는 스레드를 모른다.
POJO는 스레드 환경 밖
권장
스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하지 말기
먼저 스레드 환경 밖에 코드를 테스트
다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있도록 스레드 코드를 구현하라
- 한 스레드도 실행, 여러 스레드로 실행, 실행 중 스레드 수 변경
- 실제 환경과 테스트 환경에서 스레드 코드 실행
- 빨리, 천천히 다양한 속도로 테스트
- 반복 가능하도록 테스트 케이스 작성
다중 스레드 쓰는 코드 부분을 상황에 맞춰 조정할 수 있게 작성하라
적절한 스레드 개수를 한 번에 파악할 수는 없다.
스레드 개수를 조절하기 쉽게 코드를 구현한다.
런타임 중 스레드 수를 조절하는 방법도 고려한다.
처리율과 효율에 따라 스스로 스레드 개수를 조율하는 코드도 코민한다.
프로세서 수보다 많은 스레드를 돌려보라
강제로 스레드 스와핑을 유발하기 위해 프로세서 수보다 많은 스레드를 돌린다.
스와핑이 잦을 수록 임계영역을 빼먹은 코드나 데드락을 코드를 찾기 쉬워진다.
다른 플랫폼에서 돌려보라
다중 스레드 코드는 운영체제마다 스레드를 처리하는 정책에 따라 결과가 달라질 수도 있다.
코드에 보조 코드를 넣어 돌려, 강제로 실패를 일으키게 해보라
스레드 버그가 산발적이고 우발적이고 재현이 어려운 이유는 코드가 실행될 수 있는 수천, 수만 가지의 경로 중 하나에서 오류가 발생하기 때문이다.
때문에 버그를 발견해도 다시 찾기도, 재현하기도 힘들다.
드물게 발생하는 오류를 자주 일으키기 위해 보조 코드를 사용한다.
Object.wait(), sleep() 등과 같은 스레드를 조정하는 메서드를 추가해 다양한 순서로 실행한다.
코드에 보조 코드를 추가하는 방법 두 가지
- 직접 구현
- 자동화
직접 구현
public synchronized String nextUrlOrNull() { if (hasNext()) { String url = urlGenerator.next(); Thread.yield(); //보조 코드 return url; } return null; } |
yield() 메서드를 추가해 실행되는 경로에 변화를 줬다.
실패할 가능성을 더 높였다.
다만, 이 실패가 yield() 때문은 절대 아니다. 원래 실패했어야 할 것이 드러났을 뿐이다.
문제점
- 직접 보조 코드 삽입할 위치 선정
- 어떤 함수를 어디서 호출할 지
- 배포 환경에 보조 코드를 계속 남겨두는 것은 성능이 저하됨
- 보조 코드를 넣어도 실패했어야 할 코드가 실패하지 않을 확률일 훨씬 더 높다.
POJO와 쓰레드를 제어하는 클래스를 분리하면, 보조 코드를 넣기 훨씬 쉽다.
자동화
보조 코드 자동화는 AOF(Aspect-Oriented Framework, CGLIB 등과 같은 도구가 필요하다.
public class ThreadJigglePoint { public static void jiggle() { //무작위로 sleep() yield() 등과 같은 쓰레드 제어 메서드를 랜덤으로 호출한다. } } |
public synchronized String nextUrlOrNull() { if (hasNext()) { ThreadJigglePoint.jiggle(); String url = urlGenerator.next(); ThreadJigglePoint.jiggle(); updateHasNext(); ThreadJigglePoint.jiggle(); return url; } return null; } |
전용 도구를 사용하면 좋지만, 여의치 않으면 위와 같은 간단한 도구를 만들어 사용한다.
public class ThreadJigglePoint { private static Action action; static { //환경에 따라 다르게 사용하도록 구현 if(isLocal()) { action = Action.Op; } else { action = Action.NoOp; } } private static boolean isLocal() { return true; } public static void jiggle() { action.jiggle(); } enum Action { Op { void jiggle() { //무작위로 sleep() yield() 등과 같은 쓰레드 제어 메서드를 호출한다. } }, NoOp { void jiggle() { //아무것도 하지 않는다. } }; abstract void jiggle(); } } |
위와 같이 배포환경 마다 구현을 따로 가지고 있으면 더 편리하다.
코드를 흔드는 이유는 스레드를 매번 다른 순서로 실행해 오류를 들어내게 하기 위함이다.
결론
다중 스레드 코드는 올바로 구현하기 어렵다.
간단한 코드도 다중 쓰레드와 공유 자료를 사용하는 순간 복잡해진다.
SRP를 준수한다. 반드시 쓰레드 코드와 쓰레드를 모르는 POJO 코드를 분리한다.
쓰레드 코드를 테스트할 때는 쓰레드 코드만 테스트할 수 있게 된다.
동시성 오류를 일으키는 잠정적인 원인을 철저히 이해한다.
사용하는 라이브러리와 기본 알고리즘을 이해한다.
특정 라이브러리 기능이 기본 알고리즘과 유사한 문제를 어떻게 해결했는지 파악한다.
보호할 코드 영역을 찾는 방법과 코드 영역을 잠그는 방법을 이해한다.
잠긴 영역에서 다른 감긴 영역을 호출하지 않는다.
공유할 정보와 공유하지 말아야할 정보를 명확히 식별한다.
보호할 코드 영역의 수와 크기를 최대한 줄인다.
클라이언트에게 공유 상태를 관리할 책임을 넘기지 않는다.
어쩌다 발생한다고 일회성 문제라 취급하지 않는다.
보조 코드를 넣고, 무작위 설정으로 수천 번 돌려 최대한 많은 오류를 발견해야 한다.
'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글
14장 점진적인 개선 (0) | 2024.02.12 |
---|---|
12장 창발성 (0) | 2024.01.29 |
11장 시스템 (0) | 2024.01.22 |
10장 클래스 (1) | 2024.01.15 |
9장 단위 테스트 (1) | 2024.01.08 |