
Java 동시성 프로그래밍은 멀티코어 환경에서 성능을 극대화하기 위한 필수 역량입니다. 이 가이드에서는 가장 기본적인 synchronized 키워드부터 Java 21에서 정식 도입된 Virtual Thread까지, 동시성 프로그래밍의 모든 것을 실전 코드와 함께 다룹니다.
synchronized: 동시성의 출발점
synchronized의 동작 원리
synchronized는 Java에서 가장 기본적인 동기화 메커니즘입니다. 모든 Java 객체는 내부에 모니터(Monitor)를 가지고 있으며, synchronized는 이 모니터의 잠금(lock)을 획득하는 방식으로 동작합니다.
public class Counter {
private int count = 0;
// 메서드 레벨 synchronized - this 객체의 모니터 잠금
public synchronized void increment() {
count++; // read → modify → write (원자적이지 않은 연산)
}
// 블록 레벨 synchronized - 더 세밀한 제어 가능
public void incrementWithBlock() {
// 다른 비동기 로직 수행 가능
synchronized (this) {
count++;
}
}
public synchronized int getCount() {
return count;
}
}
synchronized의 한계
// 문제 1: 공정성(Fairness) 보장 불가
// synchronized는 어떤 스레드가 다음에 lock을 획득할지 보장하지 않음
// → 특정 스레드가 계속 대기(starvation) 가능
// 문제 2: 타임아웃 불가
// lock 획득을 기다리는 중 취소할 수 없음
synchronized (lock) {
// 이 블록에 진입하기 위해 무한 대기...
}
// 문제 3: 조건부 대기의 복잡성
// wait/notify 패턴은 사용이 어렵고 버그 발생 소지가 높음
synchronized (lock) {
while (!condition) {
lock.wait(); // spurious wakeup 가능
}
}
ReentrantLock: 진화된 동기화
ReentrantLock은 synchronized의 한계를 극복하기 위해 java.util.concurrent.locks 패키지에 도입되었습니다.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.TimeUnit;
public class AdvancedCounter {
private final ReentrantLock lock = new ReentrantLock(true); // fair=true
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private int count = 0;
private static final int MAX = 100;
// 타임아웃 지원
public boolean tryIncrement(long timeout, TimeUnit unit)
throws InterruptedException {
if (lock.tryLock(timeout, unit)) {
try {
while (count >= MAX) {
if (!notFull.await(timeout, unit)) {
return false; // 타임아웃
}
}
count++;
notEmpty.signal();
return true;
} finally {
lock.unlock(); // finally에서 반드시 해제!
}
}
return false;
}
public int decrement() throws InterruptedException {
lock.lock();
try {
while (count <= 0) {
notEmpty.await();
}
count--;
notFull.signal();
return count;
} finally {
lock.unlock();
}
}
}
ReadWriteLock: 읽기 성능 극대화
읽기가 많고 쓰기가 적은 시나리오에서는 ReadWriteLock이 효과적입니다.
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.HashMap;
import java.util.Map;
public class ThreadSafeCache<K, V> {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Map<K, V> cache = new HashMap<>();
public V get(K key) {
rwLock.readLock().lock(); // 여러 스레드 동시 읽기 가능
try {
return cache.get(key);
} finally {
rwLock.readLock().unlock();
}
}
public void put(K key, V value) {
rwLock.writeLock().lock(); // 쓰기 시 독점 잠금
try {
cache.put(key, value);
} finally {
rwLock.writeLock().unlock();
}
}
// StampedLock (Java 8+) - 낙관적 읽기로 더 높은 성능
// private final StampedLock stampedLock = new StampedLock();
}

ConcurrentHashMap: 동시성 컬렉션의 핵심
내부 구조와 동작 원리
Java 8 이후 ConcurrentHashMap은 Bucket별 세분화된 잠금(fine-grained locking)과 CAS(Compare-And-Swap) 연산을 결합합니다.
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public class WordCounter {
// 올바른 사용법: 원자적 연산 메서드 활용
private final ConcurrentHashMap<String, LongAdder> wordCounts =
new ConcurrentHashMap<>();
public void countWord(String word) {
// computeIfAbsent + increment = 원자적 + 효율적
wordCounts.computeIfAbsent(word, k -> new LongAdder()).increment();
}
// ❌ 잘못된 패턴: check-then-act는 원자적이지 않음!
public void countWordWrong(String word) {
if (!wordCounts.containsKey(word)) { // 체크
wordCounts.put(word, new LongAdder()); // 삽입 (경합 발생!)
}
wordCounts.get(word).increment();
}
// 집계
public long getTotalCount() {
return wordCounts.reduceValuesToLong(
Long.MAX_VALUE, // parallelism threshold
LongAdder::sum, // transformer
0L, // identity
Long::sum // reducer
);
}
}
ConcurrentHashMap vs Hashtable vs Collections.synchronizedMap
| 특성 | ConcurrentHashMap | Hashtable | synchronizedMap |
|---|---|---|---|
| 잠금 범위 | Bucket 단위 | 전체 Map | 전체 Map |
| null 허용 | Key/Value 모두 불가 | 모두 불가 | Key/Value 모두 가능 |
| 반복자 안전성 | Weakly Consistent | Fail-fast | Fail-fast |
| 성능 (동시 읽기) | ★★★★★ | ★★ | ★★ |
| 원자적 연산 | compute, merge 등 | 없음 | 없음 |
CompletableFuture: 비동기 프로그래밍의 핵심
CompletableFuture는 Java 8에서 도입된 비동기 프로그래밍 프레임워크로, 콜백 체이닝과 조합이 가능합니다.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class AsyncOrderService {
private final ExecutorService executor = Executors.newFixedThreadPool(10);
public CompletableFuture<OrderResult> processOrder(Order order) {
return CompletableFuture
// 1단계: 재고 확인 (비동기)
.supplyAsync(() -> checkInventory(order), executor)
// 2단계: 결제 처리
.thenComposeAsync(inventory -> {
if (!inventory.isAvailable()) {
return CompletableFuture.failedFuture(
new OutOfStockException(order.getItemId()));
}
return processPayment(order);
}, executor)
// 3단계: 배송 요청
.thenComposeAsync(payment -> requestShipping(order, payment), executor)
// 에러 처리
.exceptionally(ex -> {
log.error("주문 처리 실패: {}", order.getId(), ex);
return OrderResult.failed(ex.getMessage());
});
}
// 여러 비동기 작업 조합
public CompletableFuture<ProductDetail> getProductDetail(String productId) {
CompletableFuture<Product> productFuture =
CompletableFuture.supplyAsync(() -> getProduct(productId), executor);
CompletableFuture<List<Review>> reviewsFuture =
CompletableFuture.supplyAsync(() -> getReviews(productId), executor);
CompletableFuture<PriceInfo> priceFuture =
CompletableFuture.supplyAsync(() -> getPrice(productId), executor);
// 3개의 비동기 작업을 모두 기다린 후 합침
return productFuture.thenCombine(reviewsFuture, (product, reviews) ->
new ProductWithReviews(product, reviews)
).thenCombine(priceFuture, (productWithReviews, price) ->
new ProductDetail(productWithReviews, price)
);
}
// 타임아웃 처리 (Java 9+)
public CompletableFuture<String> fetchWithTimeout(String url) {
return CompletableFuture
.supplyAsync(() -> httpGet(url), executor)
.orTimeout(5, TimeUnit.SECONDS) // 5초 타임아웃
.completeOnTimeout("default", 3, TimeUnit.SECONDS); // 3초 후 기본값
}
}
CompletableFuture 주요 메서드 정리
| 메서드 | 설명 | 반환 타입 |
|---|---|---|
supplyAsync(Supplier) |
비동기 작업 시작 (값 반환) | CompletableFuture<T> |
thenApply(Function) |
결과 변환 | CompletableFuture<U> |
thenCompose(Function) |
결과로 새 Future 생성 (flatMap) | CompletableFuture<U> |
thenCombine(Future, BiFunction) |
두 결과 합침 | CompletableFuture<V> |
allOf(Future...) |
모든 Future 완료 대기 | CompletableFuture<Void> |
anyOf(Future...) |
가장 빠른 결과 | CompletableFuture<Object> |
exceptionally(Function) |
예외 처리 | CompletableFuture<T> |
handle(BiFunction) |
성공/실패 모두 처리 | CompletableFuture<U> |
Virtual Thread (Java 21): 동시성의 패러다임 전환
Virtual Thread는 Java 21에서 정식 도입된 경량 스레드입니다. OS 스레드(Platform Thread)와 달리 JVM이 직접 관리하며, 수백만 개를 동시에 생성할 수 있습니다.

Virtual Thread 기본 사용법
// 방법 1: Thread.ofVirtual()
Thread vThread = Thread.ofVirtual()
.name("worker-", 0)
.start(() -> {
System.out.println(Thread.currentThread());
// VirtualThread[#21,worker-0]/runnable@ForkJoinPool-1-worker-1
});
// 방법 2: Executors.newVirtualThreadPerTaskExecutor()
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 100만 개의 동시 작업도 OK
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
futures.add(executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // blocking이지만 OK
return "done";
}));
}
// 모든 결과 수집
futures.forEach(f -> {
try { f.get(); } catch (Exception e) { /* handle */ }
});
}
// 방법 3: Structured Concurrency (Preview in Java 21)
// try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Subtask<User> userTask = scope.fork(() -> findUser(userId));
// Subtask<Order> orderTask = scope.fork(() -> findOrder(orderId));
// scope.join().throwIfFailed();
// return new UserOrder(userTask.get(), orderTask.get());
// }
Virtual Thread vs Platform Thread
| 항목 | Platform Thread | Virtual Thread |
|---|---|---|
| 스레드 생성 비용 | ~1MB 스택 메모리 | ~수 KB |
| 최대 동시 스레드 수 | 수천 개 | 수백만 개 |
| 스케줄링 | OS 커널 | JVM (ForkJoinPool) |
| Blocking I/O 시 | OS 스레드 점유 | Carrier Thread 해제 |
| 적합한 작업 | CPU 집약적 | I/O 집약적 |
synchronized 호환 |
완벽 | pinning 발생 가능 |
Virtual Thread 주의사항
// ❌ 문제: synchronized 내에서 blocking → pinning 발생
// Carrier Thread가 해제되지 않아 Virtual Thread의 장점 상실
public synchronized String fetchData() {
return httpClient.send(request); // pinning!
}
// ✅ 해결: ReentrantLock 사용
private final ReentrantLock lock = new ReentrantLock();
public String fetchData() {
lock.lock();
try {
return httpClient.send(request); // Virtual Thread가 unmount됨
} finally {
lock.unlock();
}
}
// ❌ 주의: ThreadLocal 남용 금지 (메모리 누수 위험)
// Virtual Thread는 수백만 개 → ThreadLocal도 수백만 개 복사본
private static final ThreadLocal<SimpleDateFormat> formatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// ✅ 대안: ScopedValue (Preview in Java 21)
// private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
Spring Boot에서 Virtual Thread 적용
# application.yml (Spring Boot 3.2+)
spring:
threads:
virtual:
enabled: true # Tomcat이 Virtual Thread로 요청 처리
@Configuration
public class VirtualThreadConfig {
// 비동기 작업도 Virtual Thread로
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
// @Scheduled 작업도 Virtual Thread로
@Bean
public TaskScheduler taskScheduler() {
return new SimpleAsyncTaskScheduler(); // Virtual Thread 기반
}
}
데드락 방지 전략
데드락 발생 조건 (4가지 모두 충족 시)
- 상호 배제(Mutual Exclusion): 자원은 하나의 스레드만 사용 가능
- 점유와 대기(Hold and Wait): 자원을 가진 채 다른 자원 대기
- 비선점(No Preemption): 다른 스레드의 자원을 강제 해제 불가
- 순환 대기(Circular Wait): 스레드 간 자원 대기가 원형 구조
// ❌ 데드락 발생 코드
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) { // lockA 획득
sleep(100);
synchronized (lockB) { // lockB 대기 → 데드락!
// ...
}
}
}
public void method2() {
synchronized (lockB) { // lockB 획득
sleep(100);
synchronized (lockA) { // lockA 대기 → 데드락!
// ...
}
}
}
}
// ✅ 해결 1: Lock 순서 고정 (순환 대기 방지)
public void method1() {
synchronized (lockA) {
synchronized (lockB) { /* ... */ }
}
}
public void method2() {
synchronized (lockA) { // lockA 먼저!
synchronized (lockB) { /* ... */ }
}
}
// ✅ 해결 2: tryLock으로 타임아웃 적용
public boolean transferMoney(Account from, Account to, int amount)
throws InterruptedException {
while (true) {
if (from.getLock().tryLock(1, TimeUnit.SECONDS)) {
try {
if (to.getLock().tryLock(1, TimeUnit.SECONDS)) {
try {
from.debit(amount);
to.credit(amount);
return true;
} finally {
to.getLock().unlock();
}
}
} finally {
from.getLock().unlock();
}
}
Thread.sleep(ThreadLocalRandom.current().nextInt(100)); // 백오프
}
}
데드락 탐지
# jstack으로 스레드 덤프 확인
jstack <PID> | grep -A 5 "deadlock"
# 출력 예시:
# Found one Java-level deadlock:
# =============================
# "Thread-1":
# waiting to lock monitor 0x00007f8c3c003f08 (object 0x00000000d7f45e08)
# which is held by "Thread-0"
# "Thread-0":
# waiting to lock monitor 0x00007f8c3c006008 (object 0x00000000d7f45e18)
# which is held by "Thread-1"
마무리
Java 동시성 프로그래밍은 점점 더 강력하고 사용하기 쉬운 방향으로 진화하고 있습니다.
| 시대 | 핵심 기술 | 키워드 |
|---|---|---|
| Java 1.0 | synchronized, wait/notify | 기본 동기화 |
| Java 5 | java.util.concurrent, Lock, Executor | 고수준 동시성 |
| Java 8 | CompletableFuture, Parallel Stream | 비동기/함수형 |
| Java 21 | Virtual Thread, Structured Concurrency | 경량 스레드 |
실전 가이드라인:
- 단순 동기화 →
synchronized(가장 쉽고 충분한 경우가 많음) - 세밀한 제어 필요 →
ReentrantLock+Condition - 동시성 컬렉션 →
ConcurrentHashMap,CopyOnWriteArrayList - 비동기 파이프라인 →
CompletableFuture - 대량 I/O 동시 처리 → Virtual Thread (Java 21+)
- 데드락 방지: Lock 순서 고정, tryLock 타임아웃, 최소 범위 잠금
참고 자료
- Java Concurrency in Practice - Brian Goetz
- JEP 444: Virtual Threads
- JEP 453: Structured Concurrency (Preview)
- Oracle Java Concurrency Tutorial
- Baeldung - Guide to CompletableFuture