new Thread()를 직접 만드는 것을 멈춰야 하는 이유 — Executor와 CompletableFuture
100개의 HTTP 요청을 병렬로 보내야 한다. new Thread()를 100개 만들까? — OS 스레드는 메모리를 1MB씩 소비하므로(스택 크기), 100개는 100MB다. 게다가 스레드 생성/소멸 비용이 매번 발생한다. ExecutorService를 쓰면 스레드 풀을 재사용하여 이 비용을 없앤다. (java.util.concurrent 패키지)
100개의 HTTP 요청을 보내야 한다. new Thread() 100개? OS 스레드는 1MB씩 잡아먹는다. 이 글은 스레드 풀로 재사용하고, CompletableFuture로 비동기를 엮고, ConcurrentHashMap으로 안전하게 공유하는 법을 풀어간다.
ExecutorService — 스레드 풀의 표준
// Java 25
import java.util.concurrent.*;
// 고정 크기 스레드 풀
ExecutorService pool = Executors.newFixedThreadPool(10);
// 작업 제출
Future<String> future = pool.submit(() -> {
Thread.sleep(1000);
return "결과";
});
// 결과 대기 (블로킹)
String result = future.get(); // 1초 후 반환
스레드 풀 종류
| 팩토리 메서드 | 특징 | 용도 |
|---|---|---|
newFixedThreadPool(n) |
고정 n개 스레드 | CPU 집약적 작업 (코어 수에 맞춤) |
newCachedThreadPool() |
필요 시 생성, 60초 유휴 시 회수 | 짧은 I/O 작업 많음 |
newSingleThreadExecutor() |
단일 스레드 (순서 보장) | 로그, 순차 처리 |
newScheduledThreadPool(n) |
지연/주기적 실행 | 스케줄러 |
Executors.newFixedThreadPool()/newCachedThreadPool()은 프로덕션에서 권장되지 않는다 — 무제한 큐(LinkedBlockingQueue)로 OOM 위험이 있거나, 무제한 스레드 생성으로 시스템 자원 고갈 위험이 있다. 프로덕션에서는ThreadPoolExecutor를 직접 구성한다. (Effective Java Item 80)
ThreadPoolExecutor 직접 구성
// Java 25 — 프로덕션용 스레드 풀
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize (최소 스레드)
8, // maximumPoolSize (최대 스레드)
60L, TimeUnit.SECONDS, // 유휴 스레드 대기 시간
new LinkedBlockingQueue<>(100), // 작업 큐 (제한된 크기!)
new ThreadPoolExecutor.CallerRunsPolicy() // 거부 정책
);
// 거부 정책(rejection policy): 큐가 가득 찼을 때
// AbortPolicy (기본): RejectedExecutionException
// CallerRunsPolicy: 제출한 스레드가 직접 실행 (백프레셔)
// DiscardPolicy: 조용히 무시
// DiscardOldestPolicy: 가장 오래된 작업 제거
Executor 종료 — shutdown vs shutdownNow
// Java 25 — 올바른 종료
executor.shutdown(); // 새 작업 거부, 기존 작업 완료 대기
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow(); // 강제 중단
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
ExecutorService를 종료하지 않으면 스레드가 계속 살아 있어 JVM이 종료되지 않는다 (데몬 스레드가 아닌 경우).try-finally또는try-with-resources(Java 19+,ExecutorService가AutoCloseable구현)로 종료한다.
CompletableFuture — 비동기 파이프라인 (Java 8+)
CompletableFuture는 비동기 작업을 체이닝하고 조합할 수 있는 도구다. (CompletableFuture API)
// Java 25 — 비동기 체이닝
CompletableFuture<String> pipeline = CompletableFuture
.supplyAsync(() -> fetchUserId()) // 공급 (비동기)
.thenApply(id -> fetchUserName(id)) // 변환 (동기)
.thenApplyAsync(name -> processName(name)) // 변환 (비동기)
.exceptionally(ex -> "fallback: " + ex.getMessage()); // 예외 처리
String result = pipeline.join(); // 결과 대기
flowchart LR
S["supplyAsync<br/>fetchUserId"] -->|thenApply| T1["fetchUserName"]
T1 -->|thenApplyAsync| T2["processName"]
T2 -->|exceptionally| F["fallback"]
여러 Future 조합
// Java 25 — 두 비동기 작업을 병렬로 실행하고 합치기
CompletableFuture<String> userFuture = CompletableFuture
.supplyAsync(() -> fetchUser());
CompletableFuture<String> orderFuture = CompletableFuture
.supplyAsync(() -> fetchOrder());
// 둘 다 완료되면 결합
CompletableFuture<String> combined = userFuture
.thenCombine(orderFuture, (user, order) -> user + " - " + order);
// 여러 Future가 모두 완료될 때까지 대기
CompletableFuture<Void> all = CompletableFuture.allOf(
CompletableFuture.supplyAsync(() -> task1()),
CompletableFuture.supplyAsync(() -> task2()),
CompletableFuture.supplyAsync(() -> task3())
);
all.join(); // 세 작업 모두 완료 대기
동시성 컬렉션 — synchronized 없이 스레드 안전
| 컬렉션 | 동시성 전략 | 용도 |
|---|---|---|
ConcurrentHashMap |
버킷 단위 잠금 (Java 8+: CAS) | 고성능 동시 맵 |
CopyOnWriteArrayList |
쓰기 시 복사 | 읽기 많고 쓰기 적음 (이벤트 리스너) |
ConcurrentLinkedQueue |
락프리(CAS) 연결 큐 | 무제한 동시 큐 |
LinkedBlockingQueue |
블로킹 큐 (생산자-소비자) | 제한된 크기 큐 |
// Java 25 — ConcurrentHashMap
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("A", 1);
// 원자적 업데이트 (compute)
map.compute("A", (key, val) -> val == null ? 1 : val + 1);
// 병렬 forEach (Java 8+)
map.forEach(1, (k, v) -> System.out.println(k + "=" + v)); // parallelismThreshold=1
ConcurrentHashMap은Hashtable이나Collections.synchronizedMap과 달리 읽기에 잠금이 없다. 버킷 단위의 세밀한 잠금으로 여러 스레드가 동시에 쓸 수 있다.null키/값을 허용하지 않는다. (ConcurrentHashMap API)
동기화 도구
CountDownLatch — N개의 완료 대기
// Java 25 — 3개의 서비스 초기화를 모두 기다림
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
final int id = i;
executor.submit(() -> {
try {
initService(id);
} finally {
latch.countDown(); // 카운트 감소
}
});
}
latch.await(); // 카운트가 0이 될 때까지 대기
System.out.println("모든 서비스 초기화 완료");
Semaphore — 동시 접근 제한
// Java 25 — 최대 5개의 동시 요청만 허용
Semaphore semaphore = new Semaphore(5);
void handleRequest() throws InterruptedException {
semaphore.acquire(); // 허가증 획득 (5개만 동시 가능)
try {
processRequest();
} finally {
semaphore.release(); // 허가증 반환
}
}
ThreadLocal — 스레드별 독립 변수
ThreadLocal은 각 스레드가 자신만의 변수 복사본을 갖는다 — 스레드 간 공유 없이 안전하게 상태를 유지한다.
// Java 25 — ThreadLocal로 스레드별 컨텍스트 관리
private static final ThreadLocal<SimpleDateFormat> formatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm"));
String formatted = formatter.get().format(new java.util.Date());
// 각 스레드마다 독립적인 SimpleDateFormat 인스턴스 (thread-safe!)
SimpleDateFormat은 thread-safe하지 않다 — 공유하면 동시성 버그.ThreadLocal로 각 스레드마다 독립 인스턴스를 주면 안전하다. 단, 스레드 풀 환경에서는ThreadLocal값을 명시적으로 제거(remove())하지 않으면 스레드 재사용 시 이전 데이터가 남는다(스레드 풀 메모리 누수).
ThreadLocal의 주요 용도
- 스레드별 트랜잭션 컨텍스트 (Spring의
TransactionSynchronizationManager) - 사용자 인증 정보 (요청 스레드별 사용자 ID)
- 스레드별 데이터베이스 연결
경고: Virtual Thread 환경에서
ThreadLocal은 수백만 개의 스레드가 각자 변수를 가질 수 있어 메모리 폭발 위험이 있다. Java 25에서 Scoped Values(JEP-506)가 정식(finalized)으로 도입되어 ThreadLocal의 대안을 제공한다.
구조적 동시성(Structured Concurrency) — Java 25 기준 여전히 Preview
구조적 동시성은 관련된 비동기 작업을 하나의 범위로 묶어, 실패 시 전체를 취소하고 결과를 예측 가능하게 만든다. (JEP-505, Java 25 5차 Preview)
// Java 25 (preview — --enable-preview 필요)
// StructuredTaskScope로 관련 작업을 묶어서 관리
// try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// var userTask = scope.fork(() -> fetchUser());
// var orderTask = scope.fork(() -> fetchOrder());
// scope.join(); // 모든 작업 완료 대기
// scope.throwIfFailed(); // 하나라도 실패하면 전체 취소
// return new Result(userTask.get(), orderTask.get());
// }
구조적 동시성의 이점:
- 한 작업이 실패하면 나머지도 자동 취소 (fail-fast)
- 부모-자식 작업의 생명주기가 같음 (자식이 완료되어야 부모가 완료)
CompletableFuture의 콜백 지옥 없이 선언적으로 비동기 처리
구조적 동시성은 Java 21에서 처음 preview로 도입됐고 Java 25에서 5차 preview다. 아직 정식(finalized)이 아니지만, 이 패턴은 이미 Rust, Swift, Kotlin에서 널리 쓰이고 있으며, Java의 동시성 프로그래밍을 근본적으로 단순화할 전망이다. Scoped Values는 Java 25에서 정식(finalized, JEP-506)으로 승격됐다.
실습 — CompletableFuture로 병렬 API 호출
// Java 25 — ParallelFetchDemo.java
import java.util.concurrent.*;
import java.util.*;
public class ParallelFetchDemo {
public static void main(String[] args) {
try (var executor = Executors.newFixedThreadPool(3)) {
// 3개의 API를 병렬로 호출
List<String> apis = List.of("users", "orders", "products");
List<CompletableFuture<String>> futures = apis.stream()
.map(api -> CompletableFuture.supplyAsync(
() -> fetchApi(api), executor))
.toList();
// 모두 완료 대기
CompletableFuture<Void> all = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0]));
all.join();
// 결과 출력
futures.forEach(f -> System.out.println(f.join()));
}
}
static String fetchApi(String endpoint) {
try { Thread.sleep(500); } catch (InterruptedException e) { }
return endpoint + " 응답 (" + Thread.currentThread().getName() + ")";
}
}
java ParallelFetchDemo.java
users 응답 (pool-1-thread-1)
orders 응답 (pool-1-thread-2)
products 응답 (pool-1-thread-3)
확인할 것: 3개의 API 호출이 병렬로 실행되어 약 0.5초 만에 완료된다 (순차 실행 시 1.5초). CompletableFuture.allOf로 모든 작업 완료를 대기한다.
요약 — 이 글의 결론
new Thread()를 직접 쓰지 않는다.ExecutorService(스레드 풀)로 스레드를 재사용한다. 생성/소멸 비용과 메모리 절약.- 프로덕션 스레드 풀은
ThreadPoolExecutor를 직접 구성한다.Executors팩토리는 무제한 큐/스레드로 OOM 위험. 제한된 큐와 적절한 거부 정책을 설정한다. CompletableFuture로 비동기 파이프라인을 구축한다.thenApply,thenCombine,allOf로 여러 비동기 작업을 체이닝하고 조합한다.- 동시성 컬렉션으로
synchronized를 대체한다.ConcurrentHashMap은 버킷 단위 잠금으로 고성능.CopyOnWriteArrayList는 읽기 많은 환경에 적합. CountDownLatch,Semaphore로 스레드 조율을 간결하게 한다. 직접wait()/notify()를 구현하지 않는다.
생각해 볼 문제
CompletableFuture.supplyAsync()에 Executor를 지정하지 않으면 어느 스레드 풀에서 실행되는가? (ForkJoinPool.commonPool)CompletableFuture와 Java 21의 Virtual Threads를 조합하면 어떤 이점이 있는가?ConcurrentHashMap.size()는 정확한 값을 반환하는가? 왜?CopyOnWriteArrayList에서 쓰기가 빈번하면 어떤 일이 발생하는가?ThreadPoolExecutor에서corePoolSize=4, maxPoolSize=8, queue=LinkedBlockingQueue(100)일 때, 105번째 작업을 제출하면 어떻게 되는가?CompletableFuture.thenApply()와thenApplyAsync()의 차이는 무엇인가? 어느 스레드에서 실행되는가?Semaphore의 공정성 모드(new Semaphore(5, true))가 의미하는 바는 무엇인가?
참고
- java.util.concurrent 패키지 (Java 25) - 접근 2026-07-10
- CompletableFuture API - 접근 2026-07-10
- ThreadPoolExecutor API - 접근 2026-07-10
- ConcurrentHashMap API - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 21. jdbc (0) | 2026.07.12 |
|---|---|
| Java - 20. virtual threads (0) | 2026.07.12 |
| Java - 18. thread sync (0) | 2026.07.12 |
| Java - 17. annotation-reflection (0) | 2026.07.12 |
| Java - 16. io nio (0) | 2026.07.12 |
