들어가며
멀티코어 프로세서가 보편화된 현대 환경에서 동시성 프로그래밍은 Java 개발자에게 필수 역량입니다. 단순히 Thread를 생성하는 것을 넘어, 안전하고 효율적인 동시성 코드를 작성하려면 Java가 제공하는 다양한 동기화 메커니즘을 깊이 이해해야 합니다. 이 글에서는 가장 기본적인 synchronized부터 Java 21의 Virtual Threads까지, 실무에서 반드시 알아야 할 동시성 도구들을 체계적으로 정리합니다.
1. synchronized - 가장 기본적인 동기화
synchronized는 Java의 내장 동기화 메커니즘으로, 모니터 락(Monitor Lock)을 기반으로 동작합니다. 메서드 또는 블록 단위로 적용할 수 있으며, 한 번에 하나의 스레드만 임계 영역에 진입할 수 있도록 보장합니다.
public class Counter {
private int count = 0;
// 메서드 레벨 동기화
public synchronized void increment() {
count++;
}
// 블록 레벨 동기화 - 더 세밀한 제어 가능
public void incrementWithBlock() {
synchronized (this) {
count++;
}
}
public synchronized int getCount() {
return count;
}
}
synchronized의 한계점:
- 락 획득 시 타임아웃을 설정할 수 없어 무한 대기가 발생할 수 있습니다.
- 읽기/쓰기 락을 구분할 수 없어, 읽기 작업이 많은 경우 불필요한 대기가 발생합니다.
- 공정성(fairness)을 보장하지 않아 특정 스레드가 계속 락을 획득하지 못하는 기아(starvation) 현상이 발생할 수 있습니다.
2. ReentrantLock - 유연한 명시적 락
java.util.concurrent.locks.ReentrantLock은 synchronized의 한계를 극복한 명시적 락입니다. 타임아웃, 인터럽트 가능, 공정성 설정 등 다양한 기능을 제공합니다.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.TimeUnit;
public class BoundedBuffer<T> {
private final Object[] items;
private int putIdx, takeIdx, count;
private final ReentrantLock lock = new ReentrantLock(true); // 공정성 보장
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public BoundedBuffer(int capacity) {
items = new Object[capacity];
}
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (count == items.length) {
notFull.await(); // 버퍼가 가득 차면 대기
}
items[putIdx] = item;
if (++putIdx == items.length) putIdx = 0;
count++;
notEmpty.signal(); // 소비자에게 알림
} finally {
lock.unlock(); // 반드시 finally에서 해제
}
}
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0) {
notEmpty.await(); // 버퍼가 비면 대기
}
Object item = items[takeIdx];
if (++takeIdx == items.length) takeIdx = 0;
count--;
notFull.signal(); // 생산자에게 알림
return (T) item;
} finally {
lock.unlock();
}
}
}
tryLock()을 사용하면 락 획득에 타임아웃을 설정하여 데드락을 예방할 수 있습니다.
public boolean transferMoney(Account from, Account to, long amount) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
while (true) {
if (from.lock.tryLock()) {
try {
if (to.lock.tryLock()) {
try {
from.debit(amount);
to.credit(amount);
return true;
} finally {
to.lock.unlock();
}
}
} finally {
from.lock.unlock();
}
}
if (System.nanoTime() > deadline) return false; // 타임아웃
Thread.sleep(1); // 잠시 대기 후 재시도
}
}
3. Atomic 클래스 - 락 없는 원자적 연산
java.util.concurrent.atomic 패키지는 CAS(Compare-And-Swap) 연산을 활용하여 락 없이도 원자적 연산을 수행합니다. 락 기반 동기화보다 경합이 적은 상황에서 훨씬 높은 성능을 보입니다.
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.LongAdder;
public class AtomicExamples {
// AtomicLong - 단순 카운터
private final AtomicLong requestCount = new AtomicLong(0);
public void handleRequest() {
long currentCount = requestCount.incrementAndGet();
// requestCount는 여러 스레드에서 안전하게 증가
}
// LongAdder - 높은 경합 상황에서 AtomicLong보다 우수한 성능
private final LongAdder highContentionCounter = new LongAdder();
public void highThroughputCount() {
highContentionCounter.increment(); // 내부적으로 셀 분할
long total = highContentionCounter.sum(); // 전체 합산
}
// AtomicReference - 락 없는 스택 구현
private final AtomicReference<Node<String>> top = new AtomicReference<>();
public void push(String value) {
Node<String> newHead = new Node<>(value);
Node<String> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead)); // CAS 재시도
}
static class Node<T> {
final T value;
Node<T> next;
Node(T value) { this.value = value; }
}
}
Atomic 클래스 선택 가이드: 단순 카운터에는 AtomicLong, 높은 경합 상황의 카운터에는 LongAdder, 객체 참조의 원자적 교체에는 AtomicReference를 사용하세요.
4. CompletableFuture - 비동기 프로그래밍의 핵심
CompletableFuture는 Java 8에서 도입된 강력한 비동기 프로그래밍 도구입니다. 콜백 체이닝, 조합, 예외 처리 등 복잡한 비동기 워크플로우를 선언적으로 구성할 수 있습니다.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class OrderService {
private final ExecutorService executor = Executors.newFixedThreadPool(10);
public CompletableFuture<OrderResult> processOrder(Long orderId) {
// 여러 비동기 작업을 병렬로 실행
CompletableFuture<User> userFuture = CompletableFuture
.supplyAsync(() -> userService.findById(orderId), executor);
CompletableFuture<Inventory> inventoryFuture = CompletableFuture
.supplyAsync(() -> inventoryService.check(orderId), executor);
CompletableFuture<Double> priceFuture = CompletableFuture
.supplyAsync(() -> pricingService.calculate(orderId), executor);
// 세 결과를 조합
return userFuture
.thenCombine(inventoryFuture, (user, inventory) -> {
if (!inventory.isAvailable()) {
throw new RuntimeException("재고 부족");
}
return new UserInventory(user, inventory);
})
.thenCombine(priceFuture, (ui, price) ->
new OrderResult(ui.user(), ui.inventory(), price)
)
.exceptionally(ex -> {
log.error("주문 처리 실패: {}", ex.getMessage());
return OrderResult.failed(ex.getMessage());
});
}
// 여러 API를 동시에 호출하고 가장 빠른 결과 사용
public CompletableFuture<ExchangeRate> getExchangeRate() {
return CompletableFuture.anyOf(
CompletableFuture.supplyAsync(() -> apiA.getRate()),
CompletableFuture.supplyAsync(() -> apiB.getRate()),
CompletableFuture.supplyAsync(() -> apiC.getRate())
).thenApply(result -> (ExchangeRate) result);
}
}
5. Virtual Threads (Java 21) - 경량 스레드의 혁명
Java 21에서 정식 도입된 Virtual Threads는 기존 플랫폼 스레드의 한계를 극복합니다. OS 스레드와 1:1 매핑되는 플랫폼 스레드와 달리, Virtual Thread는 JVM이 관리하는 경량 스레드로 수백만 개까지 생성할 수 있습니다.
import java.util.concurrent.Executors;
import java.util.concurrent.StructuredTaskScope;
import java.util.List;
import java.util.stream.IntStream;
public class VirtualThreadExamples {
// 기본 Virtual Thread 생성
public void basicVirtualThread() {
Thread vThread = Thread.ofVirtual()
.name("worker-", 0)
.start(() -> {
System.out.println(Thread.currentThread());
// I/O 블로킹 작업도 효율적으로 처리
});
}
// Virtual Thread 기반 ExecutorService
public void handleMassiveRequests() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 10만 개의 동시 작업도 문제없이 처리
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
String result = callExternalApi(i);
saveToDatabase(result);
return result;
})
);
} // try-with-resources로 자동 종료 대기
}
// Spring Boot에서 Virtual Threads 활성화 (application.yml)
// spring:
// threads:
// virtual:
// enabled: true
}
Virtual Threads 사용 시 주의사항:
synchronized블록 내에서 블로킹 I/O를 수행하면 캐리어 스레드가 고정(pinning)됩니다. 이 경우ReentrantLock으로 대체하세요.- CPU 집약적 작업에는 Virtual Thread의 이점이 없습니다. I/O 바운드 작업에 사용하세요.
ThreadLocal사용을 최소화하세요. 수백만 개의 Virtual Thread가 각각 ThreadLocal을 가지면 메모리 문제가 발생합니다.
6. 데드락 방지 전략
데드락은 두 개 이상의 스레드가 서로가 가진 락을 기다리며 영원히 블로킹되는 현상입니다. 실무에서 데드락을 예방하는 핵심 전략을 알아봅시다.
// 전략 1: 락 순서 고정 (Lock Ordering)
public class DeadlockPrevention {
// 항상 ID가 작은 계좌부터 락을 획득
public void transfer(Account a, Account b, long amount) {
Account first = a.getId() < b.getId() ? a : b;
Account second = a.getId() < b.getId() ? b : a;
synchronized (first) {
synchronized (second) {
a.debit(amount);
b.credit(amount);
}
}
}
}
// 전략 2: tryLock + 타임아웃
public boolean safeTransfer(Account a, Account b, long amount)
throws InterruptedException {
boolean aLocked = false, bLocked = false;
try {
aLocked = a.getLock().tryLock(1, TimeUnit.SECONDS);
bLocked = b.getLock().tryLock(1, TimeUnit.SECONDS);
if (aLocked && bLocked) {
a.debit(amount);
b.credit(amount);
return true;
}
} finally {
if (aLocked) a.getLock().unlock();
if (bLocked) b.getLock().unlock();
}
return false; // 락 획득 실패 시 재시도 또는 에러 처리
}
정리: 상황별 도구 선택 가이드
| 상황 | 추천 도구 | 이유 |
|---|---|---|
| 간단한 임계 영역 보호 | synchronized | 코드 간결, JVM 최적화 |
| 타임아웃, 공정성 필요 | ReentrantLock | 유연한 제어 가능 |
| 단순 카운터/플래그 | Atomic 클래스 | 락 없이 고성능 |
| 비동기 워크플로우 | CompletableFuture | 선언적 비동기 조합 |
| 대량 I/O 바운드 작업 | Virtual Threads | 경량, 높은 동시성 |
동시성 프로그래밍에서 가장 중요한 원칙은 가능한 한 공유 가변 상태를 줄이는 것입니다. 불변 객체를 사용하고, 상태를 공유해야 한다면 적절한 동기화 도구를 선택하세요. Java의 동시성 도구는 계속 발전하고 있으며, 프로젝트의 요구사항에 맞는 도구를 선택하는 것이 핵심입니다.
'Java' 카테고리의 다른 글
| Clean Code 실전 - 레거시 코드를 리팩토링하는 7가지 패턴 (0) | 2026.04.13 |
|---|---|
| Java 디자인 패턴 실전 - 실무에서 자주 쓰는 10가지 패턴 (0) | 2026.04.07 |
| JVM 메모리 구조와 GC 튜닝 - G1, ZGC, Shenandoah 완벽 비교 (0) | 2026.04.07 |
| Java 22~26 새 기능 총정리 - Structured Concurrency부터 Primitive Types까지 (0) | 2026.04.07 |
| Java 17~21 새 기능 총정리 - 실무에서 바로 쓸 수 있는 핵심 기능들 (0) | 2026.03.25 |