CountDownLatch란 무엇인가?
CountDownLatch는 자바의 동시성 유틸리티 중 하나로, 하나 이상의 스레드가 특정 작업을 완료할 때까지 다른 스레드(주로 메인 스레드)가 기다리도록 만드는 데 사용됩니다. 이는 내부적으로 카운터 값을 관리하여 동작합니다. 이 카운터는 CountDownLatch 객체 생성 시 초기화되며, 특정 이벤트가 발생할 때마다 카운터 값이 1씩 감소합니다. 카운터가 0이 되면, 대기 중이던 스레드들이 작업을 재개할 수 있게 됩니다.
CountDownLatch의 동작 원리
CountDownLatch는 java.util.concurrent 패키지에 포함되어 있으며, 그 이름처럼 '세고(count)' '문을 잠그는(latch)' 방식의 동기화를 제공합니다. 핵심 메서드는 다음과 같습니다:
CountDownLatch(int count): 지정된 초기 카운터 값으로CountDownLatch를 생성합니다. 이 값은 스레드가 기다려야 할 이벤트의 총 개수를 의미합니다.await(): 현재 스레드를 대기 상태로 전환합니다. 카운터가 0이 될 때까지 스레드는 블록됩니다.countDown(): 카운터 값을 1 감소시킵니다. 카운터가 0이 되면await()호출로 대기 중인 모든 스레드를 해제합니다.
CountDownLatch는 한 번 사용하고 나면 재사용할 수 없다는 중요한 특징이 있습니다. 즉, 카운터가 0이 되면 그 CountDownLatch 인스턴스는 더 이상 유효하지 않습니다.
일반적인 활용 사례
CountDownLatch는 다양한 동시성 시나리오에서 유용하게 사용될 수 있습니다. 대표적인 두 가지 유형을 살펴보겠습니다.
1. 동시성 시작 지점 조율 (Start Synchronization)
여러 스레드가 동시에 작업을 시작해야 할 때 CountDownLatch를 사용하여 모든 스레드가 준비될 때까지 기다렸다가 한 번에 작업을 시작하도록 조율할 수 있습니다. 예를 들어, 성능 테스트 시 여러 클라이언트 스레드를 동시에 시작하여 실제 동시성 환경을 시뮬레이션하는 경우에 활용할 수 있습니다.
import java.util.concurrent.CountDownLatch;
import java.util.stream.IntStream;
public class ConcurrencyStarter {
public static void main(String[] args) throws InterruptedException {
int numberOfWorkers = 5;
// 카운터가 1인 CountDownLatch를 생성하여, 한 번의 countDown()으로 모든 스레드를 해제합니다.
CountDownLatch startSignal = new CountDownLatch(1);
System.out.println("작업 스레드 준비 중...");
IntStream.range(0, numberOfWorkers).forEach(workerId -> {
new Thread(() -> {
try {
System.out.println("작업자 " + workerId + ": 시작 신호 대기 중...");
startSignal.await(); // 시작 신호가 올 때까지 대기
System.out.println("작업자 " + workerId + ": 신호 수신, 작업 시작!");
// 실제 작업 수행 로직...
Thread.sleep(100 + (long)(Math.random() * 500)); // 짧은 작업 시뮬레이션
System.out.println("작업자 " + workerId + ": 작업 완료.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("작업자 " + workerId + " 스레드 인터럽트 발생.");
}
}, "Worker-" + workerId).start(); // 스레드 이름 설정
});
// 모든 작업 스레드가 await()에 도달할 시간을 줍니다.
Thread.sleep(2000);
System.out.println("\n메인 스레드: 모든 작업자에게 시작 신호 전송!");
startSignal.countDown(); // 모든 대기 스레드 해제
// 모든 작업이 시작될 시간을 줍니다.
Thread.sleep(1000);
System.out.println("모든 작업자에게 시작 신호가 전달되었습니다. 메인 스레드 종료.");
}
}
2. 하위 작업 완료 대기 및 결과 통합 (Completion Waiting and Aggregation)
메인 스레드가 여러 하위 스레드에 작업을 분배하고, 모든 하위 스레드가 작업을 완료할 때까지 기다린 후 최종 결과를 통합하는 시나리오에 적합합니다. 예를 들어, 대규모 데이터를 병렬 처리하거나, 웹 페이지 로딩 시 여러 컴포넌트의 데이터를 비동기로 가져와 통합하여 사용자에게 보여주는 경우에 활용할 수 있습니다.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.IntStream;
public class TaskCoordinator {
public static void main(String[] args) throws InterruptedException {
int numberOfTasks = 4;
// numberOfTasks 만큼 카운터를 설정하여, 모든 하위 작업이 완료될 때까지 기다립니다.
CountDownLatch completionLatch = new CountDownLatch(numberOfTasks);
System.out.println("메인 스레드: " + numberOfTasks + "개의 하위 작업을 시작합니다.");
IntStream.range(0, numberOfTasks).forEach(taskId -> {
new Thread(() -> {
try {
System.out.println("작업 #" + taskId + ": 시작.");
// 작업별로 다른 시간 소요 시뮬레이션
Thread.sleep(500 + ThreadLocalRandom.current().nextInt(1500));
System.out.println("작업 #" + taskId + ": 완료.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("작업 #" + taskId + " 스레드 인터럽트 발생.");
} finally {
completionLatch.countDown(); // 작업 완료 시 카운터 감소
}
}, "Task-Executor-" + taskId).start();
});
System.out.println("\n메인 스레드: 모든 하위 작업 완료 대기 중...");
completionLatch.await(); // 카운터가 0이 될 때까지 메인 스레드 대기
System.out.println("메인 스레드: 모든 하위 작업이 완료되었습니다. 결과 통합을 시작합니다.");
// 모든 하위 작업이 완료된 후 수행할 추가 작업 (예: 결과 취합, 데이터 처리)
}
}
CountDownLatch의 내부 구현
CountDownLatch는 자바 동시성 API의 근간이 되는 AbstractQueuedSynchronizer (AQS)를 기반으로 구현됩니다. AQS의 state 필드를 카운터로 사용하며, 이 state 필드는 CountDownLatch 생성 시 지정된 초기값으로 설정됩니다.
countDown()메서드는 AQS의releaseShared(int arg)메서드를 호출하며,arg값은 항상 1입니다.releaseShared(int arg)는 내부적으로tryReleaseShared(int releases)를 호출하여state값을 원자적으로 감소시킵니다.tryReleaseShared메서드는state값이 0이 되는 순간true를 반환하고, 이 경우 AQS는doReleaseShared()를 호출하여await()호출로 대기 중이던 모든 스레드를 깨웁니다.
AQS의 공유 모드(Shared Mode)를 활용하여 여러 스레드가 동시에 해제될 수 있도록 합니다.
// AQS의 releaseShared 메서드 (간략화된 형태)
public final boolean releaseShared(int arg) {
// tryReleaseShared는 CountDownLatch의 내부 클래스(Sync)에서 오버라이드 됨
if (tryReleaseShared(arg)) {
// 성공적으로 state가 0이 되어 릴리즈 가능하면 대기 중인 스레드를 깨움
doReleaseShared();
return true;
}
return false;
}
// CountDownLatch 내부 Sync 클래스의 tryReleaseShared 구현 (간략화된 형태)
protected boolean tryReleaseShared(int releases) {
// state 값을 원자적으로 감소시키고, 0이 되면 true 반환
for (;;) {
int currentCount = getState(); // 현재 카운터 값 조회
if (currentCount == 0) // 이미 0이면 더 이상 감소할 수 없음
return false;
int nextCount = currentCount - 1; // 카운터 감소
// CAS (Compare And Swap) 연산을 통해 state 값 업데이트 시도
if (compareAndSetState(currentCount, nextCount))
return nextCount == 0; // 감소 후 카운터가 0이 되었는지 확인
}
}
이러한 메커니즘을 통해 CountDownLatch는 스레드 간의 정확하고 효율적인 동기화를 보장합니다.
CountDownLatch의 주요 특징 및 고려사항
- 재사용 불가능:
CountDownLatch의 카운터는 한 번 0이 되면 다시 초기화하거나 값을 변경할 수 없습니다. 따라서 여러 번 동기화가 필요한 경우에는CyclicBarrier와 같은 다른 동시성 유틸리티를 고려해야 합니다. - 유연한 대기:
await()메서드 외에도await(long timeout, TimeUnit unit)을 사용하여 타임아웃을 설정할 수 있습니다. 이는 특정 시간 내에 모든 작업이 완료되지 않으면 대기를 포기하고 다음 로직으로 진행할 때 유용합니다. Thread.join()과의 비교:CountDownLatch는 특정 스레드들이 완료될 때까지 기다린다는 점에서Thread.join()과 유사해 보일 수 있습니다. 하지만CountDownLatch는 더 유연한 제어를 제공합니다.join()은 대상 스레드의 종료 여부만 기다리는 반면,CountDownLatch는 여러 스레드가 특정 "이벤트"를 완료했을 때를 기준으로 대기할 수 있으며, 여러 스레드가 동시에 한 신호를 기다리거나, 한 스레드가 여러 신호를 기다릴 수 있는 등 다양한 동기화 패턴을 구현할 수 있습니다. 또한,join()은 스레드의 활성 상태를 지속적으로 확인하는 방식으로 동작하는 반면,CountDownLatch는 AQS의 대기 큐를 통해 효율적으로 스레드를 관리합니다.