동시성은 결합을 없애는 전략이다. 무엇(what)을 하는지와 언제(when) 하는지를 분리하면 시스템의 처리량과 구조가 함께 좋아질 수 있다. 톰캣이나 서블릿 컨테이너가 요청마다 스레드를 붙여 처리하는 것도, 요청 처리 로직이 “언제 실행되는가”를 몰라도 되게 만든 덕분이다. 하지만 공짜가 아니다. 동시성은 성능을 항상 올려주지도 않고, 설계를 바꾸지 않고 얹을 수 있는 것도 아니며, 버그는 재현조차 어렵다. “한 번은 통과했으니 괜찮다”가 동시성에서 가장 위험한 말이다.

동시성 버그가 왜 그렇게 안 잡히는지부터 지도로 펼쳐두면, 아래 방어 원칙이 각각 어느 조건을 끊는지 보인다.

flowchart TD
    A["여러 스레드"] --> B{"공유 자료가 있는가?"}
    B -->|아니오| SAFE["안전<br/>경쟁 조건 없음"]
    B -->|예| C{"동시에 갱신하는가?"}
    C -->|아니오| SAFE
    C -->|예| RACE["경쟁 조건<br/>read-modify-write 겹침"]
    RACE --> D1["방어 1: 공유 자료 제거<br/>(사본·불변·스레드 로컬)"]
    RACE --> D2["방어 2: 임계영역 보호<br/>(synchronized 최소 범위)"]
    RACE --> D3["방어 3: 동시성 로직 격리<br/>(SRP)"]

세 방어선 중 위쪽일수록 근본적이다. 공유 자료를 아예 없애면 잠금 자체가 필요 없고, 없앨 수 없을 때만 잠금으로 내려간다.

동시성 방어 원칙

단일 책임 원칙을 지켜라

동시성 코드는 그 자체로 복잡하다. 스레드를 관리하는 코드는 다른 코드와 분리해서, 동시성 로직만 따로 개발하고 튜닝하고 테스트할 수 있게 한다. 비즈니스 로직과 스레드 관리가 한 클래스에 뒤엉키면 둘 다 이해하기 어려워진다.

// before — 업무 로직과 스레드 관리가 한 덩어리
class ReportGenerator {
    void generate(List<Order> orders) {
        ExecutorService pool = Executors.newFixedThreadPool(4);
        for (Order o : orders) {
            pool.submit(() -> {
                BigDecimal tax = o.subtotal().multiply(RATE); // 업무 규칙
                o.setTax(tax);
            });
        }
        pool.shutdown();
    }
}
 
// after — 업무 규칙(TaxRule)과 병렬 실행(ParallelRunner)을 분리
class ReportGenerator {
    private final ParallelRunner runner;   // 동시성만 담당
    private final TaxRule rule;            // 업무 규칙만 담당
 
    void generate(List<Order> orders) {
        runner.forEach(orders, rule::applyTo); // 각 관심사를 독립 테스트 가능
    }
}

판단 기준: 스레드 생성·풀 관리·동기화 코드가 도메인 로직과 같은 메서드에 있으면 SRP 위반 신호다. 분리하면 TaxRule은 단일 스레드로, ParallelRunner는 동시성만 집중해 검증할 수 있다. 함정: “지금은 작으니까”라며 섞어두면, 나중에 경쟁 조건을 디버깅할 때 업무 버그인지 스레드 버그인지 구분조차 못 하게 된다.

자료 범위를 제한하라

공유 자료는 문제의 근원이다. 임계영역은 synchronized로 보호하되, 그보다 먼저 공유하는 자료 자체를 줄이고 접근하는 코드 수를 최소로 유지한다. 보호해야 할 지점이 많아질수록 하나를 빠뜨릴 확률이 올라간다.

// before — 임계영역이 넓고, 갱신 코드가 여러 곳에 흩어짐
class Counter {
    private int count = 0;
    void tick() { count++; }                 // 보호 안 됨 → 경쟁 조건
    synchronized void report() { log(count); }
}
 
// after — 갱신 지점을 하나로 모으고 임계영역을 좁게
class Counter {
    private int count = 0;
    synchronized void tick() { count++; }    // read-modify-write를 원자화
    synchronized int snapshot() { return count; }
}

판단 기준: 같은 필드를 갱신하는 메서드가 둘 이상이면 보호가 새어나갈 자리다. 갱신 지점을 한곳으로 모으면 보호할 임계영역도 한 곳으로 준다. 함정: count++는 한 줄이지만 read-modify-write 세 연산이라 원자적이지 않다. “짧으니 괜찮겠지”가 전형적인 함정이다.

자료 사본을 사용하라

공유를 피할 수 있으면 피한다. 객체를 복사해 읽기 전용으로 쓰거나, 각 스레드가 사본으로 작업한 뒤 한 스레드가 결과를 모으면 동기화 비용 자체가 사라진다. 잠금을 잘 거는 것보다 잠글 일을 없애는 게 언제나 낫다.

// 각 스레드는 사본으로만 작업 → 공유 갱신이 없음
List<Order> snapshot = List.copyOf(orders);   // 불변 사본
snapshot.parallelStream()
        .map(rule::applyTo)                    // 순수 계산, 부수효과 없음
        .collect(toList());                    // 결과를 마지막에 한 번만 취합

판단 기준: 스레드가 자료를 읽기만 하거나, 각자의 결과를 마지막에 합칠 수 있으면 사본이 정답이다. 함정: 사본 만드는 비용이 잠금 경합 비용보다 큰 드문 경우가 있지만, 그 판단은 추측이 아니라 측정으로 한다.

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

각 스레드가 자기 세상에 존재하는 것처럼, 클라이언트 요청 하나를 받아 로컬 변수만으로 처리하고 끝나는 구조가 이상적이다. 공유 변수 대신 스레드 로컬 데이터로 작업하면 동기화가 필요 없다.

라이브러리와 실행 모델을 알아라

java.util.concurrent 패키지의 컬렉션은 스레드 환경에서 검증돼 있다. ConcurrentHashMap은 거의 모든 상황에서 HashMap보다 낫다. 그 위에서 생산자-소비자, 읽기-쓰기, 식사하는 철학자 같은 고전 실행 모델을 이해해 두면 대부분의 동시성 문제를 기존 해법의 변형으로 풀 수 있다.

클라이언트 잠금 vs 서버 잠금

동기화하는 메서드 사이에 존재하는 의존성은 미묘한 버그를 만든다. 공유 객체 하나에는 메서드 하나만 사용하는 것을 원칙으로 하고, 여러 메서드를 순서대로 써야 한다면 잠금을 어디에 둘지가 문제가 된다.

// 클라이언트 기반 잠금 — 호출하는 쪽이 잠금 책임을 짐
synchronized (queue) {          // 모든 호출자가 이렇게 감싸야 한다
    if (!queue.isEmpty())
        process(queue.remove());
}
// → 한 명이라도 감싸는 걸 잊으면 즉시 깨진다
 
// 서버 기반 잠금 — 객체가 스스로를 보호
class SafeQueue {
    synchronized Optional<Task> pollAndProcess() { // 원자적 복합 연산 제공
        return isEmpty() ? Optional.empty() : Optional.of(remove());
    }
}
// → 호출자는 잠금을 몰라도 안전하다

판단 기준: 여러 호출자가 같은 잠금 규약을 지켜야 하는 상황이면 서버 기반 잠금으로 옮긴다. 잠금 책임을 객체 안으로 넣으면 호출자가 규약을 어길 여지가 사라진다. 함정: 클라이언트 잠금은 임계영역이 겹겹이 중첩되기 쉽고, 그중 하나만 빠뜨려도 조용히 깨진다. 임계영역은 겹겹이 만들지 말고 최대한 작게 유지한다.

스레드 코드 테스트

  • 말이 안 되는 실패도 일시적 문제로 치부하지 않는다. 스레드 버그는 수천 번에 한 번 드러난다.
  • 스레드 환경 밖에서 먼저 코드가 제대로 도는지 확인한다 — 스레드 버그와 로직 버그를 섞지 않는다.
  • 스레드 수를 조율할 수 있게 만들고, 프로세서 수보다 많은 스레드로 돌려보고, 다른 플랫폼에서도 돌려본다.
  • 코드에 보조 코드(jiggling)를 넣어 강제로 실패를 유도해 본다. Thread.yield() 하나로 숨어 있던 버그가 드러나기도 한다.

정리

  • 동시성 방어는 세 층이다: 공유 자료 제거 → 임계영역 보호 → 동시성 로직 격리. 위층일수록 근본적이라, 잠금은 최후 수단이다.
  • 동시성 코드는 SRP로 격리하고, 공유 자료는 줄이고, 임계영역은 작게. 여러 메서드를 순서대로 써야 하면 클라이언트 잠금보다 서버 잠금을 택한다.
  • 라이브러리가 이미 푼 문제(java.util.concurrent)를 다시 풀지 않는다.
  • 동시성 버그는 테스트로 “발견”하는 게 아니라 설계로 “예방”하고 테스트로 “압박”하는 것이다.

다음장으로 14장