본문 바로가기

Language/Java

[Java] CountDownLatch 완벽 정리 — 멀티스레드 동기화의 핵심

Java 동시성 프로그래밍에서 "특정 작업들이 모두 끝날 때까지 기다려야 하는" 상황, CountDownLatch 하나면 깔끔하게 해결됩니다.

 


1. CountDownLatch란?

CountDownLatch는 Java 5(java.util.concurrent)에서 도입된 동기화 보조 클래스입니다.
내부에 카운터(count) 를 가지고 있으며, 이 카운터가 0이 될 때까지 하나 이상의 스레드를 대기시키는 역할을 합니다.

  • 패키지: java.util.concurrent.CountDownLatch
  • 핵심 개념: 카운터가 0이 되는 순간 대기 중인 스레드가 일제히 진행을 재개

 

 


2. 핵심 메서드

메서드 설명
CountDownLatch(int count) 카운터 초기값을 설정하여 생성
await() 카운터가 0이 될 때까지 현재 스레드를 블로킹
await(long timeout, TimeUnit unit) 지정한 시간만큼만 대기 (타임아웃 지원)
countDown() 카운터를 1 감소. 0이 되면 대기 중인 스레드 해제
getCount() 현재 카운터 값 반환

 


3. 기본 예제 코드

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CountDownLatchExample {
    public static void main(String[] args) throws InterruptedException {
        int workerCount = 3;
        CountDownLatch latch = new CountDownLatch(workerCount);

        ExecutorService executor = Executors.newFixedThreadPool(workerCount);

        for (int i = 1; i <= workerCount; i++) {
            final int workerId = i;
            executor.submit(() -> {
                try {
                    System.out.println("워커 " + workerId + " 작업 시작");
                    Thread.sleep((long) (Math.random() * 2000)); // 작업 시뮬레이션
                    System.out.println("워커 " + workerId + " 작업 완료 → countDown()");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    latch.countDown(); // ★ 반드시 finally에서 호출!
                }
            });
        }

        System.out.println("메인 스레드: 모든 워커 대기 중...");
        latch.await(); // 카운터가 0이 될 때까지 블로킹
        System.out.println("메인 스레드: 모든 워커 완료! 후속 작업 실행");

        executor.shutdown();
    }
}

 

출력 예시 (순서는 달라질 수 있음):

메인 스레드: 모든 워커 대기 중...
워커 2 작업 시작
워커 1 작업 시작
워커 3 작업 시작
워커 2 작업 완료 → countDown()
워커 1 작업 완료 → countDown()
워커 3 작업 완료 → countDown()
메인 스레드: 모든 워커 완료! 후속 작업 실행

 


4. 동작 타임라인

위 코드의 실행 흐름을 시간 축으로 나타내면 다음과 같습니다.

 

흐름 요약:

  1. 메인 스레드가 new CountDownLatch(3) 으로 카운터를 3으로 초기화
  2. 메인 스레드는 await() 을 호출하여 대기
  3. 각 워커 스레드는 작업 완료 후 countDown() 호출 → 카운터 감소
  4. 카운터가 0이 되는 순간 메인 스레드가 깨어나 후속 작업 진행

 


5. 타임아웃 사용 예제

무한정 대기하면 안 되는 경우엔 await(timeout, unit)을 사용합니다.

import java.util.concurrent.TimeUnit;

boolean completed = latch.await(5, TimeUnit.SECONDS);

if (completed) {
    System.out.println("모든 작업 완료!");
} else {
    System.out.println("타임아웃! 일부 작업이 완료되지 않았습니다.");
    // 타임아웃 후 처리 로직
}

 


6. 실전 활용 패턴

패턴 1 — 시작 신호 (Start Gate)

여러 스레드를 동시에 시작시키고 싶을 때 사용합니다. (성능 테스트에 유용)

CountDownLatch startGate = new CountDownLatch(1); // 시작 신호용
CountDownLatch endGate   = new CountDownLatch(N); // 완료 대기용

for (int i = 0; i < N; i++) {
    new Thread(() -> {
        try {
            startGate.await(); // 신호 대기
            // ... 실제 작업 수행 ...
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            endGate.countDown();
        }
    }).start();
}

startGate.countDown(); // 모든 스레드 동시 출발!
endGate.await();       // 전부 끝날 때까지 대기

 

패턴 2 — 초기화 완료 대기

서비스 시작 전에 필요한 초기화 작업들이 모두 완료될 때까지 기다립니다.

CountDownLatch initLatch = new CountDownLatch(3);

// DB 연결, 캐시 로딩, 설정 로딩을 병렬로 실행
executor.submit(() -> { connectDB();      initLatch.countDown(); });
executor.submit(() -> { loadCache();      initLatch.countDown(); });
executor.submit(() -> { loadConfig();     initLatch.countDown(); });

initLatch.await(); // 초기화 완료까지 대기
startServer();     // 서버 시작

 


7. CountDownLatch vs CompletableFuture.join()

둘 다 "다른 작업이 끝날 때까지 현재 스레드를 블로킹"한다는 점에서 비슷하게 느껴지지만 목적이 다릅니다.

비교 항목 CountDownLatch CompletableFuture.join()
기다리는 대상 이벤트(카운트 신호) 작업의 결과값
결과값 반환 ❌ 없음 ✅ 있음
여러 작업 처리 카운터 하나로 N개 통합 allOf(...).join()으로 묶음
카운트 주체 누구든 countDown() 호출 가능 Future 객체 단위로 명확히 정의
예외 처리 InterruptedException (checked) CompletionException (unchecked)
주요 용도 완료 신호 동기화, 테스트, 시작 게이트 비동기 결과 수집 및 체이닝

선택 기준: "작업 결과를 모아서 뭔가를 해야 한다" → CompletableFuture
"N개의 완료 신호만 확인하면 된다" → CountDownLatch

 


8. CountDownLatch vs CyclicBarrier

비슷해 보이지만 용도가 다릅니다.

     
비교 항목 CountDownLatch CyclicBarrier
재사용 ❌ 불가 (1회성) ✅ 가능 (reset 후 재사용)
대기 주체 다른 스레드가 카운트다운 서로를 기다림 (상호 대기)
카운트 주체 누구든 countDown() 호출 가능 참여 스레드만 가능
용도 완료 이벤트 대기 집결점(rendezvous) 동기화
스레드 수 대기자 ≠ 카운트 주체 대기자 = 카운트 주체

선택 기준: "한 스레드가 여러 작업의 완료를 기다린다" → CountDownLatch
"여러 스레드가 서로를 기다리며 단계를 함께 진행한다" → CyclicBarrier

 


9. 주의사항 & Best Practice

반드시 finally 블록에서 countDown() 호출

예외가 발생해도 카운트다운이 누락되지 않도록 합니다.

try {
    doWork();
} finally {
    latch.countDown(); // 예외 발생 여부와 무관하게 반드시 실행
}

 

재사용이 필요하다면 CyclicBarrier 사용

CountDownLatch는 카운터가 0이 된 이후 리셋 불가입니다.

// ❌ 잘못된 사용 - 재사용 시도
latch.await();
// latch를 다시 사용하려 하면 count가 이미 0이라 즉시 통과해버림!

// ✅ 재사용이 필요하면 새로 생성하거나 CyclicBarrier 사용

 

타임아웃을 적극 활용

운영 환경에서는 특정 작업이 무한 대기에 빠질 수 있으므로, 반드시 타임아웃을 설정합니다.

 


10. 정리

CountDownLatch(N)
    │
    ├─ await()         → 카운터가 0이 될 때까지 블로킹
    ├─ countDown()     → 카운터 1 감소 (0이 되면 대기 스레드 해제)
    └─ getCount()      → 현재 카운터 값 확인

핵심 특징:
  - 1회성 (재사용 불가)
  - 카운트다운 주체와 대기 주체가 다를 수 있음
  - 여러 스레드가 동시에 await() 가능
  - 카운터가 0이 되는 순간 모든 대기 스레드 해제

 


참고 자료