Clean Code · Ch.13

동시성

동시성에 대한 오해와 진실

동시성은 결코 쉽지 않다. 먼저 현실을 직시하자.

⚠️ 미신

동시성은 항상 성능을 높여준다 — 대기 시간이 아주 길어 여러 스레드가 프로세서를 공유하거나, 여러 프로세서가 처리할 독립적인 계산이 충분히 많은 경우에만 성능이 향상된다.

동시성을 구현해도 설계는 변하지 않는다 — '무엇'과 '언제'를 분리하면 설계가 크게 변한다.

✅ 사실

동시성은 다소 부하를 요구한다 — 성능 오버헤드와 추가 코드가 수반된다.

🐛

동시성 버그는 재현이 어렵다 — 타이밍에 의존하므로 같은 조건에서도 다르게 동작할 수 있다.

🏗️

근본적인 설계 전략 재고 필요 — 동시성은 단순히 코드를 추가하는 문제가 아니다.

동시성 방어 원칙

동시성 문제를 예방하기 위한 설계 원칙들.

① SRP

동시성 관련 코드는 다른 코드와 분리하라. 스레드 관리 코드와 비즈니스 로직을 섞지 마라.

SRP 관심사 분리
② 임계 영역 보호

스레드 간 공유 자원에 동시 접근하면 예상치 못한 결과가 생긴다. synchronized/lock을 사용하라.

private readonly object _lock = new object();
private int _counter = 0;

public void Increment() {
    lock (_lock) { _counter++; }
}
③ 읽기 전용 복사

임계 영역을 만들기 어렵다면 차라리 객체를 복사해서 읽기 전용으로 사용하는 것도 좋다. 복사 비용이 동기화 비용보다 저렴할 수 있다.

④ 독립적인 스레드

스레드는 가능한 독립적으로 구현하라. 각자의 자원만 사용하고, 자료를 독자적으로 처리 가능하게 분리하라. 공유 자원 없음 = 경쟁 조건 없음.

Producer-Consumer 패턴

생산자는 큐에 넣고 소비자는 큐에서 꺼낸다. 두 역할의 속도 차이를 버퍼로 완충한다.

Producer
→ 작업 추가 →
Buffer Queue
→ 작업 꺼냄 →
Consumer
Producer 역할
- 작업(task)을 생성한다
- 버퍼(큐)가 가득 차면 대기
- 작업을 큐에 추가 후 신호 전송:
  "큐가 비어있지 않다"
Consumer 역할
- 큐에서 작업을 꺼낸다
- 큐가 비어있으면 대기
- 작업 처리 후 신호 전송:
  "큐가 가득 차있지 않다"
신호 체계 — 생산자와 소비자는 서로에게 신호를 보내며 조율한다. 그 전까지 둘은 신호를 기다린다.
해결 방법
🔢

Semaphore: 빈 슬롯 수와 아이템 수를 세는 세마포어 + 뮤텍스로 구현

📦

Blocking Queue: put()/take()가 내부적으로 대기/신호를 처리해주는 큐 구현체 사용

Readers-Writers 패턴

읽기는 동시에 가능하지만 쓰기는 단독으로만 가능하다.

Reader
- 읽기만 하는 스레드
- 다수가 동시에 읽기 가능
- Writer가 없을 때 자유롭게 읽음
Writer
- 쓰기를 시도하는 스레드
- 단독으로만 허용 (배타적 접근)
- 모든 Reader가 없을 때만 쓰기
⚠️

Reader 우선 정책: 처리량↑ 이지만 Writer가 기아(Starvation) 상태에 빠지고 공유 자원이 정체된 정보로 가득 찬다.

⚠️

Writer 우선 정책: 데이터는 최신으로 유지되지만 처리량이 줄어든다.

해결 방법
🔒

RW Lock: 읽기 시엔 읽기 접근은 통과, 쓰기 접근은 차단. 쓰기 시엔 모든 접근 차단.

📋

FIFO 정책: 요청 순서대로 처리해 Starvation 방지

식사하는 철학자 문제

자원 공유 패턴에서 발생하는 교착·기아·비효율 문제의 원형.

시나리오 — N명의 철학자가 원형 테이블에 앉아 있고, 인접한 두 포크를 모두 잡아야만 식사할 수 있다. 포크는 이웃과 공유된다.
Deadlock

전원이 왼쪽 포크만 잡고 서로 기다리면 영원히 멈춤. 아무도 오른쪽 포크를 가져갈 수 없다.

Livelock

서로 양보만 반복 — 포크를 집었다 놨다를 반복하지만 실제로 아무도 식사하지 못한다.

Starvation

특정 철학자는 계속 굶주린다 — 운이 나쁘면 영원히 포크를 못 잡을 수 있다.

비효율성

과도하게 보수적인 설계 — 한 번에 한 명만 식사하게 하면 안전하지만 병렬성이 낮아진다.

근본 원인 — 포크를 잡을 때 atomic하게 (한 번에) 잡지 못하기 때문이다. 왼쪽 잡고 → 오른쪽 잡기 시도 사이에 다른 스레드가 개입한다.
✅ 해결 방법
🔢

Resource Hierarchy (Ordered Locks): 포크에 번호를 매겨 항상 낮은 번호부터 잡는다. 순환 대기가 원천 차단된다.

⏱️

타임아웃 + 재시도: 일정 시간 동안 포크를 못 잡으면 포기하고 재시도. 데드락 방지.

동시성 핵심 요약

동시성을 안전하게 다루기 위한 핵심 전략

동시성은 어렵다

성능 향상이 보장되지 않고, 버그는 재현이 어렵다.

✂️

분리하라

동시성 코드와 비즈니스 로직을 분리. SRP를 적용하라.

🔒

공유를 줄여라

공유 자원과 임계 영역을 최소화하라. 읽기 전용 객체 복사도 고려하라.

🧪

테스트하라

다양한 설정에서 여러 번 테스트하라. 타이밍 버그는 한두 번 실행으로 잡히지 않는다.

3가지 수칙 — (1) 필요한 구간만 잠가라. (2) 공유 객체와 공유 구간을 최소화하라. (3) 무작위 순서로 여러 번 테스트하라.