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+, ExecutorServiceAutoCloseable 구현)로 종료한다.

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

ConcurrentHashMapHashtable이나 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()를 구현하지 않는다.

생각해 볼 문제

  1. CompletableFuture.supplyAsync()에 Executor를 지정하지 않으면 어느 스레드 풀에서 실행되는가? (ForkJoinPool.commonPool)

  2. CompletableFuture와 Java 21의 Virtual Threads를 조합하면 어떤 이점이 있는가?

  3. ConcurrentHashMap.size()는 정확한 값을 반환하는가? 왜?

  4. CopyOnWriteArrayList에서 쓰기가 빈번하면 어떤 일이 발생하는가?

  5. ThreadPoolExecutor에서 corePoolSize=4, maxPoolSize=8, queue=LinkedBlockingQueue(100)일 때, 105번째 작업을 제출하면 어떻게 되는가?

  6. CompletableFuture.thenApply()thenApplyAsync()의 차이는 무엇인가? 어느 스레드에서 실행되는가?

  7. Semaphore의 공정성 모드(new Semaphore(5, true))가 의미하는 바는 무엇인가?


참고

'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