핵심 답변
Java의 CompletableFuture는 논리적으로 병렬이지만, 실제 CPU 점유는 OS 스케줄러가 결정한다.
코드가 작업을 "제출"하는 시점과 "실제로 실행"되는 시점은 다르다.

로컬에서 CompletableFuture.runAsync()로 A, B, C 세 작업을 동시에 제출했을 때, 전체 시간이 max(A, B, C)가 아니라 A + B + C에 가깝게 나온다면 코드 버그가 아니라 OS 스케줄러의 실행 순서 결정 때문일 가능성이 높다.

개발 머신은 IDE, 브라우저, 시스템 데몬 등 수십~수백 개의 스레드가 경쟁하고 있다. 내 워커 스레드는 그 경쟁에서 언제 CPU를 받을지 보장받지 못한다.

현상과 배경: 무슨 일이 벌어진 건가

관찰된 이상 현상

왜 "논리적 병렬"과 "물리적 동시성"은 다른가

runAsync()를 호출하면 JVM은 작업을 ForkJoinPool(공통 풀)의 큐에 넣는다. 이 시점은 제출(submission)이지, 실행(execution)이 아니다. 실제 CPU를 점유하려면 OS 스케줄러가 해당 워커 스레드를 선택해야 한다.

Main Thread Worker-1 (A) Worker-2 (B) submit A,B A 실행 B 실행 t=0 t=0 submit A,B 대기 A 실행 대기 (50ms+) B 실행 ← 기대 (병렬) → ← 실제 (OS 지연) →

왼쪽(기대)에서는 A와 B가 거의 동시에 시작한다. 오른쪽(실제)에서는 OS가 Worker-2에 CPU를 늦게 할당하여 B의 시작이 50ms 이상 밀린다. 결과적으로 겹치는 구간이 줄어 전체 시간이 늘어난다.

OS 스케줄러 — 왜 내 스레드가 바로 실행되지 않는가

스케줄러의 역할

OS 스케줄러는 CPU라는 희소 자원을 수백 개의 스레드에게 공정하게 나눠주는 교통 정리사다. Linux의 CFS(Completely Fair Scheduler), macOS/Windows의 멀티레벨 피드백 큐 등 구체적인 구현은 다르지만 핵심 개념은 같다.

개념 설명 영향
선점형 스케줄링 실행 중인 스레드라도 타임슬라이스가 끝나면 CPU를 빼앗김 예측 불가
우선순위 IDE, 시스템 프로세스가 워커 스레드보다 높은 우선순위일 수 있음 지연 원인
Time Slice 한 스레드가 연속으로 CPU를 점유할 수 있는 시간 (보통 1~10ms) 짧을수록 전환 잦음
Context Switching 스레드 교체 시 레지스터·스택 상태 저장/복원 — 수 마이크로초 오버헤드 누적 시 유의미
Run Queue 경쟁 새로 생성된 스레드는 런큐 끝에 줄 서서 순서를 기다림 시작 지연 원인

스레드가 실제로 실행되기까지의 과정

  1. 1
    runAsync(task) 호출 → 태스크가 ForkJoinPool 큐에 등록된다. 스레드 생성이나 실행이 아님.
  2. 2
    풀에서 유휴 워커 스레드가 태스크를 가져간다 (Work Stealing 방식).
  3. 3
    워커 스레드가 RUNNABLE 상태로 전환되어 OS 런큐(run queue)에 진입한다.
  4. 4
    OS 스케줄러가 해당 스레드에 CPU 코어를 할당할 때 비로소 실행된다. 이 대기 시간이 0이 아니다.

로컬 개발 환경이 특히 나쁜 이유

프로덕션 서버는 애플리케이션 외 프로세스가 거의 없다. 반면 개발 머신은 IntelliJ/Eclipse, Chrome, 슬랙, Docker Desktop, 터미널 등 수백 개의 스레드가 같은 CPU를 놓고 경쟁한다. 워커 스레드가 런큐에서 기다리는 시간이 서버 환경보다 훨씬 길다.

⚠️ 로컬에서 직렬처럼 보여도 서버에서는 제대로 병렬 동작할 수 있다.
성능 측정은 반드시 프로덕션에 가까운 환경(부하 낮은 서버)에서 해야 한다.
도커로 해결할 수 있을까? — 잘못된 직관과 올바른 이해

직관: "도커는 격리된 환경이니 간섭이 없겠지"

도커 컨테이너가 호스트와 분리되어 있다는 인식에서 나온 발상이다. 결론적으로 도커는 이 문제를 해결하지 못한다.

CPU Hardware (물리 코어) Host OS Kernel + CPU Scheduler Chrome 스레드 n개 IntelliJ 스레드 n개 🐳 Docker Container JVM 워커 스레드 컨테이너 내부 스레드도 OS 스케줄러가 관리한다 도커는 네임스페이스/cgroup 격리이지, CPU 스케줄러 교체가 아님

도커가 격리하는 것 vs 격리하지 않는 것

도커가 격리하는 것 도커가 격리하지 않는 것
파일시스템 (레이어) CPU 스케줄러
네트워크 네임스페이스 OS 커널 (호스트 커널 공유)
PID 네임스페이스 스레드 실행 우선순위 경쟁
CPU/메모리 사용량 (cgroup) Context Switching 오버헤드
cgroup으로 CPU 쿼터를 설정할 수는 있지만, 이는 최대 사용량 제한이지 전용 할당이 아니다. 컨테이너 안의 스레드는 여전히 호스트의 다른 스레드와 경쟁한다.

그렇다면 실제 해결책은?

이 문제는 "해결"보다 "이해와 수용"이 맞는 접근이다.

// 커스텀 ThreadFactory로 우선순위 높이기
ThreadFactory highPriority = r -> {
    Thread t = new Thread(r);
    t.setPriority(Thread.MAX_PRIORITY);
    return t;
};
ExecutorService exec = Executors.newFixedThreadPool(4, highPriority);

CompletableFuture<Void> a = CompletableFuture.runAsync(taskA, exec);
CompletableFuture<Void> b = CompletableFuture.runAsync(taskB, exec);
CompletableFuture<Void> c = CompletableFuture.runAsync(taskC, exec);
CompletableFuture.allOf(a, b, c).join();
✅ 실무에서 중요한 것은 "로컬에서 완벽한 병렬"이 아니라 "프로덕션에서 충분한 병렬"이다. 로컬 지연은 노이즈로 보고, 실제 환경 측정으로 검증하는 것이 올바른 접근이다.
CompletableFuture 실행 흐름 전체 그림
runAsync() 호출 Main Thread ForkJoinPool 큐에 태스크 등록 메모리에 있는 큐 — 아직 실행 아님 워커 스레드가 태스크 획득 (Work Steal) RUNNABLE 상태로 전환 OS Run Queue 대기 ⚠️ 이 구간이 가변적 — 수 ms ~ 수십 ms 다른 스레드들과 CPU 경쟁 ✅ 실제 CPU에서 태스크 실행

4단계 "OS Run Queue 대기"가 핵심 병목이다. 코드에서는 runAsync() 한 줄이지만, 내부적으로는 위 5단계를 모두 거친다. 로컬 환경에서 이 대기 시간이 길어지면 A, B, C의 실제 시작 시점이 분산되어 직렬처럼 보인다.

함께 알면 좋은 개념