1. CountDownLatch: 신호로 시작
멀티스레드 세계에서는 여러 스레드가 발맞춰 시작·종료하거나 함께 다음 단계로 넘어가도록 조율해야 할 때가 많습니다. 예를 들어:
경주를 떠올려 보세요. 자동차들이 스타트 라인에 서 있습니다 — 어떤 차는 이미 엔진을 예열했고, 어떤 차는 아직 타이어를 점검 중이죠. 그런데 심판이 깃발을 흔들기 전까지는 아무도 움직이지 않습니다. 이것이 바로 조율의 과제입니다.
또 다른 예: 친구들과 저녁을 준비한다고 해봅시다 — 누구는 채소를 썰고, 누구는 물을 올리고, 누구는 소금이 어디 갔는지 찾고 있죠. 중요한 건 요리를 시작하기 전에 모두가 준비를 마치는 것입니다.
이런 경우를 위해 Java는 wait()와 notify()의 고통 없이도 안전하고 이해하기 쉬운 동기화 도구를 제공합니다. 그중 가장 유용한 것 중 하나가 CountDownLatch입니다. 이는 카운터-잠금처럼 동작합니다. 값이 0이 될 때까지 “문”은 닫혀 있어 아무도 앞으로 가지 못하죠. 모두가 체크하면 latch가 열리고, 스레드들이 동시에 움직입니다.
CountDownLatch
CountDownLatch는 일회성 “밸브”로, 하나 이상의 스레드가 다른 스레드들이 정해진 횟수만큼의 작업을 마칠 때까지 기다리게 합니다.
마라톤 출발과 같습니다. 모든 주자가 출발선에서 스타트 건 소리를 기다립니다. 심판이 방아쇠를 당겨 카운트다운이 0이 되는 순간 — 모두가 뛰기 시작합니다.
작동 원리
CountDownLatch는 스레드를 위한 출발 신호와 같습니다. 생성 시 숫자를 지정합니다 — 예를 들어 3. 경주가 시작되기 전에 받아야 할 세 개의 신호와 같죠.
출발을 기다려야 하는 스레드는 await()를 호출합니다. 출발선에 서서 튀어나갈 준비는 됐지만 아직 브레이크를 밟고 있는 상태죠. 다른 스레드들은 준비를 마칠 때마다 countDown()을 호출해 “준비 완료!”라는 신호를 보냅니다.
카운터가 0이 되는 순간 — 빵! — 대기하던 모든 스레드가 동시에 출발합니다.
하지만 기억하세요: CountDownLatch는 일회용입니다. 카운터가 0이 되고 나면 다시 되돌릴 수 없습니다. 재장전 가능한 리볼버가 아니라, 한 번 터지면 끝인 폭죽에 가깝습니다.
예제: N개의 작업 완료 대기
import java.util.concurrent.CountDownLatch;
public class LatchDemo {
public static void main(String[] args) throws InterruptedException {
int workers = 3;
CountDownLatch latch = new CountDownLatch(workers);
for (int i = 1; i <= workers; i++) {
int id = i;
new Thread(() -> {
System.out.println("작업자 " + id + " 작업 시작");
try { Thread.sleep(500 + id * 200); } catch (InterruptedException ignored) {}
System.out.println("작업자 " + id + " 작업 종료");
latch.countDown(); // 카운터 감소
}).start();
}
System.out.println("메인 스레드가 모든 작업자의 완료를 기다리는 중...");
latch.await(); // 모든 작업자가 끝날 때까지 대기
System.out.println("모든 작업자가 끝났습니다! 본 작업을 계속합니다.");
}
}
출력:
메인 스레드가 모든 작업자의 완료를 기다리는 중...
작업자 1 작업 시작
작업자 2 작업 시작
작업자 3 작업 시작
작업자 1 작업 종료
작업자 2 작업 종료
작업자 3 작업 종료
모든 작업자가 끝났습니다! 본 작업을 계속합니다.
예제: ‘신호’로 동시에 시작
CountDownLatch startSignal = new CountDownLatch(1);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + " 시작 신호를 기다리는 중");
startSignal.await(); // 신호 대기
System.out.println(Thread.currentThread().getName() + " 출발!");
} catch (InterruptedException ignored) {}
}).start();
}
Thread.sleep(1000);
System.out.println("시작 신호!");
startSignal.countDown(); // 모든 스레드가 동시에 시작
2. CyclicBarrier: 반복 가능한 단계, 배리어 동작
CyclicBarrier: 모닥불 앞에서 만남
CyclicBarrier는 스레드들의 “만남의 장소”입니다. 각자 자신의 경로를 따라 일을 하다가 “배리어”에서 모두 모입니다 — 산속 모닥불 앞처럼요. 모두 모이면 배리어가 열리고, 그룹은 함께 다음으로 나아갑니다.
CountDownLatch와의 가장 큰 차이는 — 이 배리어는 계속 재사용할 수 있다는 점입니다. 매번 함께 멈춘 뒤 다시 “재장전”되어 다음 단계로 이어갈 수 있습니다.
상상해 보세요: 한 팀의 등산객이 긴 코스를 걷고 있습니다. 각자 속도가 다릅니다. 누구는 나비를 찍고, 누구는 Wi‑Fi를 찾습니다. 하지만 매 고개에서는 모닥불 앞에 모여 서로를 기다리고, 다음에 어디로 갈지 결정하죠. 이것이 바로 CyclicBarrier의 실제 모습입니다.
작동 원리
배리어를 만들 때 몇 명이 모여야 하는지 지정합니다. 예를 들어 4. 각 스레드는 체크포인트에 도달하면 await()을 호출해 다른 이들을 기다립니다. 네 명이 모두 모이면 배리어가 “찰칵” 하고 열리며 모두를 통과시킵니다.
“배리어 동작”을 지정할 수도 있습니다 — 그룹이 모였을 때 단 한 번 실행되는 작은 코드 조각입니다. 이를 통해 모닥불을 피우거나 “단계 완료, 다음으로 진행” 같은 로그를 남길 수 있죠. 생성자에 Runnable을 전달하면 됩니다.
중요: 일회성 CountDownLatch와 달리 CyclicBarrier는 재사용 가능합니다. 매번 “모임”이 끝난 뒤에도 다음 단계를 위해 다시 준비되죠 — 다시 피울 수 있는 영원의 모닥불처럼요.
예제: 단계 동기화
import java.util.concurrent.CyclicBarrier;
public class BarrierDemo {
public static void main(String[] args) {
int parties = 3;
CyclicBarrier barrier = new CyclicBarrier(parties, () -> {
System.out.println("모두 배리어에 도착했습니다! 새 단계를 시작합니다.");
});
for (int i = 1; i <= parties; i++) {
int id = i;
new Thread(() -> {
try {
System.out.println("스레드 " + id + "가 1단계에서 작업 중");
Thread.sleep(300 + id * 200);
System.out.println("스레드 " + id + "가 배리어를 기다리는 중");
barrier.await(); // 나머지 대기
System.out.println("스레드 " + id + "가 2단계에서 작업 중");
Thread.sleep(200 + id * 100);
System.out.println("스레드 " + id + "가 배리어를 기다리는 중 (2)");
barrier.await(); // 다시 대기
System.out.println("스레드 " + id + "가 작업을 마침");
} catch (Exception e) {
System.out.println("오류: " + e);
}
}).start();
}
}
}
출력:
스레드 1가 1단계에서 작업 중
스레드 2가 1단계에서 작업 중
스레드 3가 1단계에서 작업 중
스레드 1가 배리어를 기다리는 중
스레드 2가 배리어를 기다리는 중
스레드 3가 배리어를 기다리는 중
모두 배리어에 도착했습니다! 새 단계를 시작합니다.
스레드 1가 2단계에서 작업 중
...
배리어 동작
CyclicBarrier의 생성자에 동작(Runnable)을 전달하면, 모든 스레드가 배리어에 도착했을 때 한 번 실행됩니다(예: 상태 갱신, 로그 출력).
함정: 한 스레드가 실패하면?
한 스레드가 예외를 던지거나 배리어에 도달하지 못하면, 나머지는 영원히 대기하거나 BrokenBarrierException을 받게 됩니다. 배리어가 “깨지며”, 다시 생성해야 합니다.
이 절을 더 생생하고 비유적인 구어체로 — “오케스트라” 라인을 자연스럽게 잇는 느낌으로 — 다시 써보면 다음과 같습니다:
3. Phaser: 대규모 연주의 유능한 지휘자
Phaser는 일종의 “슈퍼 배리어”입니다. CountDownLatch와 CyclicBarrier의 장점을 결합하고도 훨씬 더 유연합니다. 마치 음악가들이 연주 도중 들어오거나 나갈 수 있는데도, 지휘자가 각 악장이 모두 준비됐을 때 시작되도록 맞추는 오케스트라 같습니다.
일반 배리어와 달리 Phaser는 여러 단계로 작업합니다 — 단계가 차례로 바뀌죠. 누군가는 1악장에서만 연주하고, 누군가는 나중에 합류하며, 누군가는 더 일찍 떠납니다 — 이 모든 상황을 Phaser는 무리 없이 처리합니다.
작동 원리
먼저 Phaser를 생성하며, 보통 참가자 수 — parties — 를 지정합니다. 각 스레드는 register()로 등록하고, 자신의 파트를 수행한 뒤 단계 끝에서 arriveAndAwaitAdvance()를 호출해 완료를 알리고 다른 이들을 기다립니다. 모두가 도착하면 Phaser는 다음 단계로 전환되고 과정이 반복됩니다.
더 이상 필요 없는 참가자는 arriveAndDeregister()로 깔끔하게 “인사”하고 무대에서 내려올 수 있습니다. 반대로 새로운 참가자는 연주 중에도 register()로 합류할 수 있습니다.
Phaser가 Barrier보다 나은 경우
Phaser는 프로그램이 하나의 리듬이 아닌 여러 리듬으로 움직일 때 적합합니다:
- 스레드 수가 실행 중에 바뀌며,
- 여러 단계가 있고 모든 참가자가 모든 단계에 참여할 필요는 없으며,
- 수동 동기화의 번거로움 없이 최대한의 유연성을 원할 때.
요컨대 Phaser는 지휘자입니다. 지휘봉만 흔드는 것이 아니라, 오케스트라 구성, 악장 수, 심지어 누군가가 늦거나 일찍 나가는 상황에도 적응합니다.
예제: 동적으로 변하는 스레드 수와 단계 처리
import java.util.concurrent.Phaser;
public class PhaserDemo {
public static void main(String[] args) {
Phaser phaser = new Phaser(1); // 메인 스레드
for (int i = 1; i <= 3; i++) {
phaser.register(); // 참가자 등록
int id = i;
new Thread(() -> {
for (int phase = 1; phase <= 2; phase++) {
System.out.println("스레드 " + id + "가 " + phase + "단계에서 작업 중");
try { Thread.sleep(200 + id * 100); } catch (InterruptedException ignored) {}
phaser.arriveAndAwaitAdvance(); // 다른 이들을 기다림
}
System.out.println("스레드 " + id + "가 작업을 종료");
phaser.arriveAndDeregister(); // phaser에서 탈퇴
}).start();
}
// 메인 스레드도 단계에 참여
for (int phase = 1; phase <= 2; phase++) {
phaser.arriveAndAwaitAdvance();
System.out.println("메인 스레드: " + phase + "단계 완료");
}
phaser.arriveAndDeregister();
System.out.println("모든 단계가 완료되었습니다!");
}
}
특징:
- 참가자를 실행 중에 추가/제거할 수 있습니다.
- 현재 단계 번호를 알 수 있습니다: phaser.getPhase().
- phaser를 종료시킬 수 있습니다: phaser.forceTermination().
4. Exchanger: 두 스레드 간 데이터 교환
Exchanger<T>는 두 스레드가 데이터를 교환하기 위한 동기화 장치입니다. 각 스레드가 exchange(data)를 호출하고, 두 스레드가 만나면 서로의 데이터를 교환합니다.
비유: 두 택배 기사가 교차로에서 만나 소포를 교환합니다.
동작 방식?
- 한 스레드가 exchange(data1)를 호출하면 — 두 번째 스레드를 기다립니다.
- 두 번째 스레드가 exchange(data2)를 호출하면 — 둘 모두 서로의 데이터를 받습니다.
- 두 번째 스레드가 오지 않으면 — 첫 번째는 기다립니다(타임아웃을 설정할 수 있습니다).
예제: producer와 consumer 사이 버퍼 교환
import java.util.concurrent.Exchanger;
public class ExchangerDemo {
public static void main(String[] args) {
Exchanger<String> exchanger = new Exchanger<>();
// Producer
new Thread(() -> {
String data = "producer의 데이터";
try {
System.out.println("Producer: 데이터를 전송함");
String response = exchanger.exchange(data);
System.out.println("Producer: 응답을 받음: " + response);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
// Consumer
new Thread(() -> {
try {
String received = exchanger.exchange("consumer의 응답");
System.out.println("Consumer: 데이터를 받음: " + received);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
출력:
Producer: 데이터를 전송함
Consumer: 데이터를 받음: producer의 데이터
Producer: 응답을 받음: consumer의 응답
활용 예:
- 스레드 간 버퍼 교환(예: 하나는 파일에서 읽고 다른 하나는 네트워크로 씀).
- 두 스레드 사이 단계 동기화.
5. 실습: 병렬 파이프라인 처리
과제: 게임 ‘틱’(단계)
여러 스레드가 있고, 각 스레드는 게임 세계의 일부(예: 물리, AI, 렌더링)를 담당한다고 합시다. 매 “틱”(단계)마다 모두 동기화되어야 뒤엉키지 않습니다.
해결: CyclicBarrier 또는 Phaser를 사용합니다.
import java.util.concurrent.CyclicBarrier;
public class GameTickDemo {
public static void main(String[] args) {
int subsystems = 3;
CyclicBarrier barrier = new CyclicBarrier(subsystems, () -> {
System.out.println("모든 하위 시스템이 틱을 완료했습니다. 다음을 시작합니다.");
});
for (int i = 1; i <= subsystems; i++) {
int id = i;
new Thread(() -> {
for (int tick = 1; tick <= 5; tick++) {
System.out.println("하위 시스템 " + id + "이(가) 틱 " + tick + "에서 작업 중");
try { Thread.sleep(100 + id * 50); } catch (InterruptedException ignored) {}
try {
barrier.await();
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
}
과제: 많은 워커를 위한 “밸브”
준비가 끝난 뒤에 100개의 워커 스레드가 동시에 시작해야 한다고 가정해 봅시다(예: 부하 테스트).
해결: CountDownLatch를 사용합니다.
import java.util.concurrent.CountDownLatch;
public class MassStartDemo {
public static void main(String[] args) throws InterruptedException {
int workers = 100;
CountDownLatch ready = new CountDownLatch(workers);
CountDownLatch start = new CountDownLatch(1);
for (int i = 0; i < workers; i++) {
new Thread(() -> {
System.out.println("스레드가 시작 준비 완료");
ready.countDown(); // 준비 상태 신호
try {
start.await(); // 공통 신호 대기
System.out.println("스레드 시작!");
} catch (InterruptedException ignored) {}
}).start();
}
ready.await(); // 모든 스레드가 준비될 때까지 대기
System.out.println("모두 준비 완료! 시작!");
start.countDown(); // 시작 신호 전송
}
}
6. 동기화 장치 사용 시 흔한 실수
실수 №1: CountDownLatch를 재사용 가능한 배리어로 사용함.
CountDownLatch는 일회용입니다! 0에 도달하면 ‘재장전’할 수 없습니다. 반복 단계에는 CyclicBarrier 또는 Phaser를 사용하세요.
실수 №2: 예외(InterruptedException, BrokenBarrierException)를 처리하지 않음.
await() 계열 메서드는 예외를 던질 수 있습니다 — 항상 처리하세요. 그렇지 않으면 스레드가 “멈추거나” 오류로 종료될 수 있습니다. InterruptedException과 BrokenBarrierException을 주의하세요.
실수 №3: 한 스레드가 배리어까지 도달하지 않음.
어느 한 스레드가 “죽거나” await()을 호출하지 않으면, 나머지는 영원히 기다리게 됩니다(혹은 BrokenBarrierException 발생). 모든 참가자가 배리어까지 도달하는지 확인하세요.
실수 №4: Phaser에서 deregister()를 잊음.
스레드가 작업을 끝냈는데 arriveAndDeregister()를 호출하지 않으면, Phaser는 “죽은” 참가자를 계속 기다립니다. 항상 Phaser에서 스레드를 올바르게 제거하세요.
실수 №5: Exchanger를 두 개 초과 스레드에 사용함.
Exchanger는 두 스레드 간 교환에만 동작합니다. 스레드가 더 많으면 데드락이 발생합니다.
실수 №6: 동작 원리를 이해하지 못한 채 서로 다른 동기화 장치를 섞어 사용함.
같은 스레드 그룹에 여러 배리어/래치 등을 동시에 사용하지 마세요 — 혼란과 교착 상태로 이어질 수 있습니다.
GO TO FULL VERSION