| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 자바프로그래밍
- 자료구조
- 코딩공부
- 알고리즘
- 자바공부
- HashMap
- 가비지컬렉션
- 예외처리
- 코딩테스트팁
- 정렬
- 클린코드
- 객체지향
- Java
- 개발자취업
- 개발자팁
- 메모리관리
- 멀티스레드
- 코딩테스트준비
- 코딩테스트
- 프로그래밍기초
- 코딩인터뷰
- 백준
- 자바
- JVM
- 자바개발
- 프로그래머스
- 파이썬
- 자바기초
- 알고리즘공부
- 개발공부
- Today
- Total
코드 한 줄의 기록
Java 동시성 멀티스레드 제어: ReentrantLock, Semaphore, CountDownLatch 본문
우리가 백엔드 개발을 하면서 가장 많이 마주치는 문제 중 하나가 바로 동시성 이슈입니다. 여러 개의 스레드가 동시에 같은 자원에 접근하려고 할 때, 우리가 제대로 관리하지 않으면 예상치 못한 결과가 나타날 수 있거든요. 저도 처음에는 synchronized 키워드만 사용했지만, 실제 프로젝트에서 더 정교한 동시성 제어가 필요해졌을 때 Java의 java.util.concurrent 패키지에 있는 도구들을 알게 됐습니다.
오늘은 제가 공부하면서 정리한 ReentrantLock, Semaphore, CountDownLatch 이 세 가지 동시성 유틸에 대해 함께 알아보겠습니다. 각각의 특징, 언제 어떻게 써야 하는지, 그리고 실제 코드 예제까지 정리해봤습니다.
ReentrantLock: 명시적 락 제어의 시작
먼저 ReentrantLock부터 시작해봅시다. 이름에서도 알 수 있듯이, "재진입 가능한 락"이라는 의미입니다.
우리가 기존에 사용하던 synchronized는 이미 락을 가진 스레드가 다시 같은 방법에 진입할 때 자동으로 처리해줬어요. ReentrantLock도 마찬가지로 이를 지원합니다. 하지만 ReentrantLock의 핵심 차이점은 명시적으로 락을 획득하고 해제한다는 것입니다.
import java.util.concurrent.locks.ReentrantLock;
public class BankAccount {
private int balance;
private final ReentrantLock lock = new ReentrantLock();
public BankAccount(int initialBalance) {
this.balance = initialBalance;
}
// 출금 메서드
public boolean withdraw(int amount) {
lock.lock(); // 락 획득
try {
if (balance < amount) {
System.out.println("잔액 부족");
return false;
}
// 임계 영역 (Critical Section)
balance -= amount;
System.out.println(Thread.currentThread().getName() +
" - " + amount + "원 출금. 남은 잔액: " + balance);
return true;
} finally {
lock.unlock(); // 락 해제
}
}
// 조회 메서드
public int getBalance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
}
여기서 중요한 포인트는 finally 블록에 unlock()을 반드시 넣어야 한다는 것입니다. 예외가 발생하더라도 락을 풀어줘야 하니까요. 안 그러면 다른 스레드들이 영원히 기다릴 수도 있습니다.
ReentrantLock을 선언할 때는 보통 private final로 선언하는 것이 관례입니다. 이렇게 하면 실수로 재할당하는 실수를 방지할 수 있죠.
ReentrantLock의 장점
- 명시적 제어로 더 세밀한 동시성 관리 가능
Condition을 통해 고급 대기/신호 메커니즘 사용 가능- 타임아웃 설정 가능 (
tryLock()메서드) - 공정성(fairness) 설정 가능
예를 들어 tryLock()을 사용하면 대기하지 않고 즉시 결과를 반환받을 수 있습니다.
public boolean withdrawWithTimeout(int amount) {
try {
// 1초 동안만 기다려
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
if (balance >= amount) {
balance -= amount;
return true;
}
return false;
} finally {
lock.unlock();
}
} else {
System.out.println("락 획득 실패 - 타임아웃");
return false;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
Semaphore: 리소스 접근 개수 제한하기
이제 Semaphore를 살펴봅시다. Semaphore는 "세마포어"라고 부르는데, 신호 기반 동시성 제어 메커니즘입니다.
ReentrantLock이 "이것을 점유하는 스레드는 1개만 가능"이라면, Semaphore는 "동시에 N개까지 스레드가 접근 가능해"라고 말하는 거예요. 예를 들어, 프린터가 5개 있는 오피스에서 여러 직원이 프린트를 요청한다고 생각해봅시다.
import java.util.concurrent.Semaphore;
public class Printer {
private Semaphore semaphore;
public Printer(int numPrinters) {
this.semaphore = new Semaphore(numPrinters);
}
public void print(String document) {
try {
semaphore.acquire(); // 프린터 사용 권한 획득 (없으면 대기)
System.out.println(Thread.currentThread().getName() +
" - 프린팅 시작: " + document);
// 프린팅 진행 중 (시간이 걸린다고 가정)
Thread.sleep(2000);
System.out.println(Thread.currentThread().getName() +
" - 프린팅 완료");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release(); // 프린터 사용 권한 반환
}
}
}
위 코드를 실행해보면, 동시에 최대 5개의 스레드만 프린팅을 할 수 있게 됩니다. 6번째 스레드는 누군가 release()를 할 때까지 대기하게 되는 거죠.
실제 로직을 보면
- 스레드가
acquire()를 호출하면 허가를 받으려고 합니다. - 허가가 있으면 그대로 진행, 없으면 대기합니다.
- 작업이 끝나면
release()로 허가를 반환합니다.
Semaphore의 활용 사례
- 데이터베이스 커넥션 풀 관리
- 쿠폰 발급 시스템 (동시 접근 제한)
- API 레이트 리미팅
- 리소스 풀의 동시 사용 제한
// 쿠폰 발급 예제
public class CouponService {
private Semaphore semaphore = new Semaphore(100); // 100개 쿠폰만 발급 가능
private int issuedCoupons = 0;
public boolean issueCoupon() {
if (semaphore.tryAcquire()) { // 대기하지 않고 즉시 시도
try {
issuedCoupons++;
System.out.println("쿠폰 발급 완료. 발급 수: " + issuedCoupons);
return true;
} finally {
semaphore.release();
}
} else {
System.out.println("쿠폰이 모두 소진되었습니다.");
return false;
}
}
}
CountDownLatch: 여러 스레드의 완료를 기다리기
마지막으로 CountDownLatch를 봅시다. 이것은 정말 독특한 유틸입니다.
제 경험상, 여러 개의 비동기 작업이 모두 완료될 때까지 기다려야 하는 경우가 있습니다. 예를 들어, 데이터 처리 작업을 여러 스레드로 병렬로 처리한 후, 모든 작업이 끝나야 최종 리포트를 생성한다거나 하는 경우 말이죠. 이때 CountDownLatch가 정말 유용합니다.
import java.util.concurrent.CountDownLatch;
public class DataProcessor {
private static final int NUM_THREADS = 5;
private static final CountDownLatch latch = new CountDownLatch(NUM_THREADS);
public static void main(String[] args) {
System.out.println("데이터 처리 시작");
// 5개의 스레드 생성 및 시작
for (int i = 1; i <= NUM_THREADS; i++) {
new Thread(new WorkerTask(i)).start();
}
try {
// 모든 워커 스레드가 완료될 때까지 대기
latch.await();
System.out.println("모든 데이터 처리 완료!");
System.out.println("최종 리포트 생성 중...");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static class WorkerTask implements Runnable {
private int taskId;
public WorkerTask(int taskId) {
this.taskId = taskId;
}
@Override
public void run() {
try {
System.out.println("Task " + taskId + " 시작");
// 작업 진행
Thread.sleep((long) (Math.random() * 3000)); // 0~3초 대기
System.out.println("Task " + taskId + " 완료");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 작업 완료를 알림
latch.countDown();
}
}
}
}
위 코드를 실행하면 다음과 같은 순서로 진행됩니다.
- 메인 스레드가 5개의 워커 스레드를 생성합니다.
- 메인 스레드는
latch.await()에서 대기합니다. - 각 워커 스레드가 자신의 작업을 진행합니다.
- 워커 스레드가 작업을 완료하면
latch.countDown()을 호출합니다. - 모든 워커 스레드가
countDown()을 호출하면, 카운트가 0이 되고 메인 스레드가 깨어납니다. - 메인 스레드가 최종 리포트를 생성합니다.
CountDownLatch의 특징은
- 일회용입니다. 0이 되면 다시 사용할 수 없습니다.
- 카운트를 감소시키는 스레드와 대기하는 스레드가 다를 수 있습니다. (이것이 Semaphore와의 주요 차이점)
- 타임아웃 설정도 가능합니다.
try {
// 최대 5초만 기다려
if (latch.await(5, TimeUnit.SECONDS)) {
System.out.println("모든 작업 완료");
} else {
System.out.println("타임아웃 - 아직 완료되지 않은 작업이 있음");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
세 도구의 비교
제가 정리해본 세 도구의 핵심 차이점을 표로 만들어봤습니다.
| 특징 | ReentrantLock | Semaphore | CountDownLatch |
| 용도 | 상호배제 (1개 스레드만) | 리소스 제한 (N개 스레드) | 스레드 완료 대기 |
| 소유권 | 있음 (락을 획득한 스레드가 반드시 해제) | 없음 (다른 스레드가 해제 가능) | 없음 |
| 재사용 | 계속 재사용 가능 | 계속 재사용 가능 | 일회용 (0이 되면 끝) |
| 주요 메서드 | lock(), unlock() | acquire(), release() | await(), countDown() |
| 공정성 설정 | 가능 | 가능 | 해당 없음 |
실전: 멀티스레드 환경에서 카운터 증가시키기
이제 이 세 도구를 실제로 어떻게 선택해서 써야 하는지 보여드리겠습니다.
ReentrantLock을 사용한 버전
public class Counter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Semaphore를 사용하면? (이건 별로 좋지 않은 예이지만...)
public class CounterWithSemaphore {
private int count = 0;
private final Semaphore semaphore = new Semaphore(1); // 1개로 제한
public void increment() {
try {
semaphore.acquire();
count++;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release();
}
}
}
Semaphore도 작동하긴 하지만, 이 경우에는 ReentrantLock이 더 적절합니다. Semaphore는 정말로 여러 개의 리소스를 동시에 제어해야 할 때 사용해야 합니다.
CountDownLatch는 이 시나리오에 맞지 않습니다. CountDownLatch는 "언제 모두 끝날까?"를 기다릴 때 쓰니까요.
저도 처음에는 어떤 도구를 언제 써야 하는지 헷갈렸습니다. 하지만 시간이 지나면서 깨달은 것들이 있습니다.
- ReentrantLock: 기본적인 동시성 제어가 필요할 때.
synchronized를 쓰는 것보다 더 정교한 제어가 필요하면 이것부터 생각하세요. - Semaphore: 제한된 자원(예: DB 커넥션 10개, 프린터 5대)을 여러 스레드가 공유할 때. "동시에 최대 몇 개까지만 허용한다"는 조건이 있으면 이것입니다.
- CountDownLatch: 여러 개의 작업이 모두 완료되기를 기다릴 때. 특히 병렬 처리 후 결과를 합치는 상황에서 유용합니다.
동시성 프로그래밍은 정말 까다롭지만, 이 세 도구를 제대로 이해하면 대부분의 상황을 처리할 수 있습니다. 물론 실전에서는 더 복잡한 상황도 많겠지만, 기본기를 탄탄히 하는 것이 가장 중요합니다.
혹시 이 글을 읽으면서 "아, 내가 이런 상황에서는 뭘 써야 하지?"라는 생각이 들면, 처음부터 다시 읽어보세요. 그리고 직접 코드를 짜보면서 실습해보시길 권장합니다. 동시성 프로그래밍은 이론만으로는 절대 이해할 수 없거든요.
Java Synchronized·Volatile·원자성 완벽 이해하기
오늘은 Java를 공부하면서 정말 헷갈렸던 부분을 다루고자 합니다. 바로 Synchronized(동기화), 가시성(Visibility), 원자성(Atomicity)에 관한 내용입니다. 이 세 가지 개념은 멀티스레드 환경에서 매우 중
byteandbit.tistory.com
'JAVA' 카테고리의 다른 글
| Java Executors, Future, Callable의 개념과 활용법을 완벽히 이해하자 (0) | 2025.11.23 |
|---|---|
| Java volatile과 happens-before 관계: 멀티스레드 메모리 가시성 이해하기 (0) | 2025.11.20 |
| Java Synchronized·Volatile·원자성 완벽 이해하기 (0) | 2025.11.18 |
| Java 스레드 라이프사이클과 Runnable/Thread 완벽 이해하기 (0) | 2025.11.16 |
| Java 직렬화 완벽 가이드: 직렬화, transient 키워드, 역직렬화 보안까지 한 번에 이해하기 (0) | 2025.11.10 |