Java 동시성 한눈에 보기 : 프로세스, 스레드, Thread Pool, Sync/Async, CompletableFuture까지
들어가며
Java 동시성을 공부할 때 가장 헷갈리는 이유는 여러 질문이 한꺼번에 등장하기 때문이다.
- 프로세스와 스레드는 무엇이 다른가
- 왜 여러 스레드가 동시에 돌면 값이 꼬이는가
synchronized,Lock,Atomic은 각각 무엇을 해결하는가- 스레드가 많으면 왜 오히려 느려질 수 있는가
- 동기 / 비동기와 Blocking / Non-blocking은 무엇이 다른가
ExecutorService,Future,CompletableFuture,WebFlux는 어디에 연결되는가
즉 동시성은 단순히 "여러 개를 동시에 실행한다"는 이야기가 아니다.
- 실행 단위를 이해해야 하고
- 공유 자원을 안전하게 다뤄야 하며
- 대기 방식과 결과 전달 방식을 구분해야 하고
- 스레드 수를 제어 가능한 수준으로 관리해야 한다
이 글은 위 질문을 하나의 흐름으로 묶어서 정리한다.
- 프로세스와 스레드
- Java에서
Thread,Runnable,start(),run() - Race Condition과 동기화 도구
- Deadlock과 진단 방법
- Thread Pool,
ExecutorService, 스레드 개수 설정 - 동기 / 비동기와 Blocking / Non-blocking
- Event Loop, WebFlux,
Future,CompletableFuture
목차
- 1. 동시성을 볼 때 먼저 나눠야 할 것
- 2. 프로세스와 스레드
- 3. Java에서
Thread와Runnable - 4.
start()vsrun() - 5. 왜 값이 꼬이는가: Race Condition
- 6.
synchronized,Lock,Atomic,volatile - 7. Deadlock은 왜 무섭고 어떻게 확인할까
- 8. Thread Pool과
ExecutorService - 9. 하드웨어 스레드 vs 소프트웨어 스레드
- 10. Thread Pool 크기는 어떻게 잡을까
- 11. 서버에서 왜 단순한 CPU Bound 공식으로 끝나지 않을까
- 12. Thread Pool이 너무 많을 때의 문제
- 13. 동기 vs 비동기, Blocking vs Non-blocking
- 14. Event Loop와 WebFlux
- 15.
FuturevsCompletableFuture - 16.
CompletableFuture자주 쓰는 메서드 - 17. 전체 흐름 한 번에 정리
1. 동시성을 볼 때 먼저 나눠야 할 것
동시성은 보통 하나의 주제로 묶어 부르지만, 실제로는 아래 네 축으로 나눠서 보면 훨씬 덜 헷갈린다.
| 축 | 질문 | 대표 키워드 |
|---|---|---|
| 실행 단위 | 무엇이 실제로 실행되는가 | Process, Thread, Thread Pool |
| 안전성 | 여러 실행 흐름이 같은 자원을 만질 때 어떻게 지킬 것인가 | Race Condition, synchronized, Lock, Atomic, volatile |
| 대기 모델 | 현재 스레드가 기다리는가 | Blocking, Non-blocking |
| 결과 전달 모델 | 완료를 누가 언제 받아가는가 | Sync, Async, Callback, Future, CompletableFuture |
많은 혼란은 이 네 축을 한꺼번에 섞어서 생긴다.
예를 들어:
CompletableFuture를 쓴다고 해서 자동으로 Non-blocking이 되는 것은 아니다- Thread Pool을 쓴다고 해서 Race Condition이 해결되는 것도 아니다
- WebFlux를 쓴다고 해서 모든 코드가 자동으로 빠른 것도 아니다
그래서 동시성은 항상 "지금 내가 풀고 있는 문제가 어느 축의 문제인가"부터 구분하는 것이 좋다.
2. 프로세스와 스레드
프로세스는 실행 중인 프로그램 단위이고, 스레드는 그 안에서 실제 작업을 수행하는 실행 흐름이다.
핵심 차이는 메모리 공유 방식에 있다.
- 프로세스는 서로 기본적으로 메모리를 분리해서 사용한다
- 같은 프로세스 안의 스레드는 Heap 같은 주요 메모리 영역을 공유한다
- 대신 각 스레드는 자신의 Stack을 가진다
아주 단순화하면 아래처럼 볼 수 있다.

이 구조 때문에 스레드는 프로세스보다 가볍다.
하나의 프로세스 안에서 여러 스레드를 돌리면 메모리 공유와 통신 비용 측면에서 유리하다.
하지만 바로 그 공유 때문에 동시성 문제가 생긴다.
- 같은 Heap 데이터를 여러 스레드가 동시에 읽고 쓸 수 있고
- 한 스레드가 값을 바꾸는 동안 다른 스레드가 같은 값을 참조할 수 있으며
- 실행 순서가 조금만 달라져도 결과가 달라질 수 있다
즉 정리하면:
- 프로세스는 상대적으로 격리되어 있지만 무겁고
- 스레드는 가볍지만 공유 자원 때문에 제어가 필요하다
2-1. Context Switching
CPU 코어 수보다 많은 스레드가 동시에 runnable 상태가 되면, 운영체제는 이들을 번갈아 실행한다.
이때 현재 실행 중인 스레드 상태를 저장하고 다른 스레드 상태를 복원하는 작업이 발생하는데, 이것이 Context Switching이다.
Context Switching이 비용이 드는 이유는 단순히 "교체한다"에서 끝나지 않기 때문이다.
- 레지스터 상태를 저장하고 복원해야 하고
- CPU 캐시 친화성이 깨질 수 있고
- 스케줄링 오버헤드가 쌓인다
즉 스레드는 많다고 무조건 좋은 것이 아니다.
어느 수준을 넘어서면 실제 작업보다 전환 비용이 더 커질 수 있다.
3. Java에서 Thread와 Runnable
Java에서 스레드를 처음 설명할 때 가장 자주 나오는 타입이 Thread와 Runnable이다.
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName());
});
thread.start();이 둘은 역할을 분리해서 보면 이해가 쉽다.
Thread: 실행 주체Runnable: 실행할 작업
즉 Runnable은 "무엇을 할까"를 표현하고,Thread는 "그 작업을 어떤 스레드에서 돌릴까"를 표현한다.
실무에서는 Thread를 직접 만드는 경우보다, 작업을 Runnable 또는 Callable로 감싸고 ExecutorService에 제출하는 쪽이 훨씬 흔하다.
이유는 단순하다.
- 작업과 실행 정책을 분리할 수 있고
- 스레드 생성 비용을 매번 치르지 않아도 되며
- 개수 제한, 큐, 종료 정책 같은 운영 제어가 가능해지기 때문이다
4. start() vs run()
이 둘은 이름이 비슷하지만 의미는 완전히 다르다.
run()은 그냥 메서드 호출이다start()는 새로운 스레드를 시작하고, 그 스레드 안에서run()이 실행되게 한다
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread().getName());
});
thread.run(); // 현재 스레드에서 그냥 실행
thread.start(); // 새로운 스레드에서 실행즉 run()을 직접 호출하면 멀티스레드가 아니다.
그냥 현재 스레드가 메서드 하나를 실행한 것에 불과하다.
이 구분이 중요한 이유는 Race Condition이 실제로는 "여러 스레드가 같은 자원에 동시에 접근하는 상황"에서만 의미가 있기 때문이다.
5. 왜 값이 꼬이는가: Race Condition
동시성의 핵심 문제는 대부분 공유 자원에서 시작된다.
아래 코드 한 줄은 겉으로 보기에는 단순하다.
count++;하지만 개념적으로는 보통 아래 단계로 나뉜다.
- 현재 값을 읽는다
- 1을 더한다
- 다시 저장한다
두 스레드가 동시에 이 연산을 수행하면 아래 같은 일이 생길 수 있다.

원래 기대는 0 -> 1 -> 2였지만 실제 결과는 1이 될 수 있다.
이처럼 실행 순서에 따라 결과가 달라지는 문제를 보통 Race Condition이라고 부른다.
여기서 같이 구분해 두면 좋은 개념이 세 가지 있다.
- 원자성(atomicity): 연산이 쪼개지지 않고 한 번에 보장되는가
- 가시성(visibility): 한 스레드가 바꾼 값을 다른 스레드가 제때 보는가
- 순서성(ordering): 실행 순서가 의도와 다르게 재배치되지 않는가
동시성 버그는 결국 이 셋이 깨질 때 발생한다.
6. synchronized, Lock, Atomic, volatile
Race Condition을 해결하려면 여러 스레드가 공유 자원에 접근하는 방식을 통제해야 한다.
6-1. synchronized
가장 기본이 되는 도구는 synchronized다.
public synchronized void increase() {
count++;
}또는 블록 단위로 사용할 수도 있다.
synchronized (this) {
count++;
}핵심은 임계 구역에 한 번에 하나의 스레드만 들어오게 하는 것이다.
장점:
- 문법이 단순하다
- 실수할 여지가 상대적으로 적다
- 기본적인 mutual exclusion에 적합하다
주의할 점:
- 락 범위를 너무 넓게 잡으면 병렬성이 줄어든다
- 경쟁이 심하면 대기 시간이 늘어난다
6-2. Lock
Lock은 synchronized보다 더 유연한 제어가 필요할 때 사용한다.
lock.lock();
try {
count++;
} finally {
lock.unlock();
}ReentrantLock 같은 구현을 쓰면 아래 기능이 가능하다.
tryLock()으로 즉시 실패 또는 제한 시간 내 시도- 인터럽트 가능한 락 대기
- 여러
Condition사용
장점은 유연성이고, 단점은 해제를 개발자가 직접 책임져야 한다는 점이다.
그래서 unlock()은 거의 항상 finally에 둔다.
6-3. Atomic
단순 카운터 증가처럼 작은 원자 연산은 Atomic 클래스로 처리할 수 있다.
AtomicInteger count = new AtomicInteger();
count.incrementAndGet();장점:
- 단순 연산에서 락 없이 동작할 수 있다
- 카운터, 플래그, 상태값 갱신에 유용하다
한계:
- 여러 필드를 함께 일관성 있게 바꾸는 문제는 잘 풀지 못한다
- 복잡한 비즈니스 규칙 전체를 Atomic만으로 해결할 수는 없다
즉 Atomic은 "작은 원자 연산"에 강하고, "복합 상태 전이"에는 한계가 있다.
6-4. volatile
volatile은 자주 오해되는 키워드다.
private volatile boolean running = true;volatile의 핵심은 가시성이다.
- 한 스레드가 바꾼 값을 다른 스레드가 더 빨리 볼 수 있게 한다
- 하지만 복합 연산을 원자적으로 만들어 주지는 않는다
즉 아래 코드는 여전히 안전하지 않다.
private volatile int count = 0;
count++;volatile은 count++를 안전하게 만들지 않는다.
여기에는 여전히 읽기, 증가, 쓰기의 여러 단계가 존재하기 때문이다.
6-5. 무엇을 언제 쓸까
정리하면 아래처럼 보는 것이 실용적이다.
| 도구 | 주로 해결하는 문제 | 적합한 상황 |
|---|---|---|
synchronized |
상호 배제 + 기본 가시성 | 기본 임계 구역 보호 |
Lock |
상호 배제 + 유연한 제어 | 타임아웃, 인터럽트, 세밀한 락 전략 |
Atomic |
원자적 단일 상태 변경 | 카운터, 상태값, CAS 기반 갱신 |
volatile |
가시성 | 종료 플래그, 단순 상태 표시 |
7. Deadlock은 왜 무섭고 어떻게 확인할까
Deadlock은 둘 이상의 스레드가 서로가 가진 락을 기다리느라 영원히 진행하지 못하는 상태다.
아주 단순한 예시는 아래와 같다.
- Thread A는 Lock 1을 잡고 Lock 2를 기다린다
- Thread B는 Lock 2를 잡고 Lock 1을 기다린다
이 상태가 되면 둘 다 앞으로 가지 못한다.
7-1. Deadlock이 잘 안 보이는 이유
Deadlock은 버그 중에서도 특히 발견하기 어렵다.
이유는 보통 아래와 같다.
- 특정 타이밍에서만 발생한다
- 로컬에서는 잘 안 나오고 부하 상황에서만 드러난다
- 에러 로그가 남지 않고 그냥 "멈춘 것처럼" 보일 수 있다
- 외형상 응답 지연, 장애, 큐 적체와 구분이 어렵다
실무에서는 Thread Pool 고갈이나 외부 I/O 대기 때문에 생기는 "멈춘 것 같은 현상"이 Deadlock처럼 보이는 경우도 많다.
그래서 실제 진단은 반드시 스레드 상태를 확인해야 한다.
7-2. 어떻게 확인할까
Java에서는 아래 방식으로 확인할 수 있다.
jstack <pid>jcmd <pid> Thread.print- JConsole, VisualVM 같은 도구의 Thread 탭
- 애플리케이션 내부에서
ThreadMXBean.findDeadlockedThreads()
실제 Deadlock이 잡히면 thread dump에 보통 Found one Java-level deadlock 같은 문구가 나타난다.
7-3. 어떻게 줄일까
예방이 훨씬 중요하다.
- 락 획득 순서를 일관되게 맞춘다
- 여러 락을 동시에 잡는 설계를 줄인다
- 락 범위를 최소화한다
- 가능하면
tryLock()같은 타임아웃 전략을 고려한다 - 블로킹 호출을 락 안에 오래 두지 않는다
Deadlock은 "락을 쓴다"는 사실보다 "락 순서와 범위가 통제되지 않는다"는 점에서 더 자주 발생한다.
8. Thread Pool과 ExecutorService
매 요청마다 new Thread()를 만드는 방식은 금방 한계가 온다.
- 스레드 생성 비용이 반복되고
- 스레드 수가 무제한으로 늘어날 수 있으며
- Context Switching 비용이 커지고
- 종료, 큐잉, 거부 정책을 통제하기 어렵다
그래서 보통은 Thread Pool을 사용한다.
Thread Pool은 스레드를 미리 만들어 두고 재사용하는 구조다.

Java에서는 보통 ExecutorService를 통해 Thread Pool을 다룬다.
ExecutorService executor = Executors.newFixedThreadPool(4);
Future<String> future = executor.submit(() -> {
return Thread.currentThread().getName();
});
executor.shutdown();여기서 중요한 점은:
- 작업 제출과 스레드 관리를 분리한다
- 작업은
Runnable또는Callable로 표현한다 - 결과는
Future로 받을 수 있다
즉 Thread를 직접 만들던 단계에서, 실무에서는 보통 Executor 계열 추상화로 올라가게 된다.
9. 하드웨어 스레드 vs 소프트웨어 스레드
스레드 수를 이야기할 때는 "스레드"가 정확히 어떤 층위인지 구분해야 한다.
하드웨어 스레드
- CPU가 실제로 동시에 실행할 수 있는 실행 컨텍스트
- 흔히 논리 코어(logical core)와 비슷한 개념으로 이해한다
- 예를 들어 8코어 16스레드 CPU라면 보통 16개의 하드웨어 스레드를 가진다고 말한다
소프트웨어 스레드
- 운영체제나 런타임이 관리하는 실행 단위
- Java 플랫폼 스레드는 보통 OS 스레드와 밀접하게 매핑된다
- 애플리케이션은 이 소프트웨어 스레드를 훨씬 많이 만들 수 있다
문제는 CPU가 동시에 실행할 수 있는 하드웨어 스레드 수는 제한되어 있는데, 소프트웨어 스레드는 훨씬 많이 만들 수 있다는 점이다.
그래서 runnable 상태의 스레드가 너무 많아지면 결국 스케줄링과 Context Switching 비용이 커진다.
즉 "스레드를 만들 수 있다"와 "실제로 동시에 효율적으로 실행된다"는 전혀 다른 이야기다.
10. Thread Pool 크기는 어떻게 잡을까
Thread Pool 크기는 작업의 성격을 먼저 봐야 한다.
10-1. CPU Bound
CPU 계산이 대부분인 작업이다.
- 수학 연산
- 이미지 처리
- 압축
- 대량 데이터 계산
이 경우에는 스레드를 지나치게 늘려도 이득이 크지 않다.
오히려 Context Switching만 늘 수 있다.
그래서 보통 CPU 코어 수에 가까운 수준에서 시작한다.
10-2. IO Bound
외부 대기 시간이 긴 작업이다.
- DB 호출
- 외부 API 호출
- 파일 읽기 / 쓰기
- 네트워크 응답 대기
이 경우에는 스레드가 많은 시간을 기다리기 때문에 CPU Bound보다 더 많은 스레드를 둘 여지가 있다.
자주 소개되는 근사식은 아래와 같다.
N_threads = N_cpu × U_cpu × (1 + Wait / Compute)여기서:
N_cpu: 코어 수U_cpu: 목표 CPU 사용률Wait / Compute: 대기 시간 대비 계산 시간 비율
이 식은 절대적인 정답 공식이 아니라 방향을 잡는 휴리스틱으로 보는 편이 좋다.
핵심은 하나다.
- CPU Bound는 과도한 스레드 증가가 오히려 손해이고
- IO Bound는 대기 시간을 감안해 더 많은 스레드를 둘 수 있다
11. 서버에서 왜 단순한 CPU Bound 공식으로 끝나지 않을까
실무 서버에서는 "코어 수만큼 스레드"로 끝나지 않는 경우가 대부분이다.
이유는 서버 요청이 생각보다 순수 CPU 작업이 아니기 때문이다.
- DB를 기다리고
- 외부 API를 기다리고
- 디스크나 네트워크를 기다리고
- 락을 기다리고
- 직렬화 / 역직렬화와 GC 영향도 받는다
즉 요청 하나 안에도 CPU 작업과 대기 작업이 섞여 있다.
또 다른 이유는 애플리케이션 전체에 Thread Pool이 하나만 있는 것이 아니기 때문이다.
- 웹 요청 처리용 풀
- 비동기 작업 풀
- 스케줄러 풀
- 메시지 컨슈머 풀
- DB 커넥션 풀과 연동되는 대기
각 풀을 전부 "코어 수 기준 최대로" 잡아 버리면 노드 전체로는 과구독(oversubscription)이 발생할 수 있다.
그래서 실무에서는 보통 아래 순서로 접근한다.
- 작업이 CPU Bound인지 IO Bound인지 먼저 분류한다
- 초기값은 보수적으로 잡는다
- 큐 길이, 응답 시간, CPU 사용률, 스레드 상태를 본다
- 부하 테스트로 실제 병목 지점을 확인한다
즉 Thread Pool 크기는 공식으로 끝나는 문제가 아니라, 운영 관측과 함께 튜닝하는 문제에 가깝다.
12. Thread Pool이 너무 많을 때의 문제
스레드가 너무 많으면 Context Switching만 늘어나는 것이 아니다.
Thread Pool 자체가 너무 많아도 시스템이 복잡해진다.
대표적인 문제는 아래와 같다.
- 전체 스레드 수가 과도하게 늘어나 메모리를 많이 사용한다
- 각 풀마다 큐가 따로 쌓여 병목 위치를 파악하기 어려워진다
- 한 풀이 다른 풀의 완료를 기다리다 starvation 비슷한 상황이 생길 수 있다
- 운영자가 튜닝해야 할 포인트가 너무 많아진다
- 서비스 간 호출이 꼬이면 "비동기처럼 보이지만 실제로는 연쇄 대기"가 발생한다
특히 조심해야 할 패턴은 아래와 같다.
- Pool A의 작업이 Pool B의 결과를 기다린다
- Pool B도 바쁘거나 고갈되어 응답이 늦다
- Pool A의 워커들이 기다리느라 묶인다
이런 상황은 Deadlock은 아니어도 실제 장애 체감은 비슷하게 나타날 수 있다.
즉 Thread Pool은 많을수록 좋지 않다.
역할이 다른 풀은 분리하되, 분리 이유가 명확해야 한다.
13. 동기 vs 비동기, Blocking vs Non-blocking
이 부분이 Java 동시성에서 가장 많이 헷갈리는 구간이다.
핵심은 두 쌍이 서로 다른 축이라는 점이다.
13-1. 동기 / 비동기
이것은 결과를 누가, 어떤 방식으로 받는가에 대한 이야기다.
- 동기(Synchronous): 호출한 쪽이 요청과 응답의 흐름을 직접 이어서 관리한다
- 비동기(Asynchronous): 호출한 쪽이 즉시 다음 일을 하고, 완료 통지는 나중에 callback / future / event 등으로 받는다
13-2. Blocking / Non-blocking
이것은 현재 스레드가 기다리느냐의 문제다.
- Blocking: 결과가 준비될 때까지 현재 스레드가 멈춰 기다린다
- Non-blocking: 준비되지 않았더라도 현재 스레드가 묶이지 않고 다른 일을 할 수 있다
즉:
- 동기 / 비동기는 "제어 흐름과 완료 통지"의 문제이고
- Blocking / Non-blocking은 "현재 스레드가 점유되느냐"의 문제다
둘은 자주 같이 묶여 보이지만, 논리적으로는 별개의 축이다.
13-3. 네 가지 조합
| 조합 | 예시 | 설명 |
|---|---|---|
| 동기 + Blocking | JDBC, RestTemplate |
응답이 올 때까지 현재 스레드가 기다린다 |
| 동기 + Non-blocking | tryLock(), poll() 기반 확인 |
호출자는 직접 완료 여부를 계속 관리하지만, 한 번의 호출이 스레드를 오래 묶지는 않는다 |
| 비동기 + Blocking | 비동기 시작 후 바로 future.get() |
시작은 비동기지만 결국 기다리면 현재 스레드는 막힌다 |
| 비동기 + Non-blocking | Callback, Event Loop, Reactive | 완료 통지가 나중에 오고, 그동안 현재 스레드는 다른 일을 한다 |
실무에서 가장 자주 보이는 조합은 아래 둘이다.
- 동기 + Blocking
- 비동기 + Non-blocking
하지만 "비동기 API를 썼다"는 이유만으로 자동으로 Non-blocking이 되는 것은 아니라는 점이 중요하다.
예를 들어:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> callApi());
String result = future.get();이 코드는 시작은 비동기지만 get()에서 기다리므로 호출 지점은 결국 Blocking이다.
14. Event Loop와 WebFlux
Event Loop는 소수의 스레드가 이벤트를 받아서 적절한 핸들러에 작업을 넘기고, 준비된 작업을 순서대로 처리하는 모델이다.
핵심 아이디어는 아래와 같다.
- 연결마다 스레드를 하나씩 고정하지 않는다
- I/O 준비 여부를 이벤트로 받는다
- 준비된 작업만 빠르게 처리하고 다음 이벤트로 넘어간다
이 모델은 대기 시간이 긴 네트워크 I/O에서 특히 강하다.
14-1. WebFlux는 어디에 들어가나
Spring WebFlux는 보통 Netty 기반 Event Loop 모델 위에서 동작한다.
- 적은 수의 이벤트 루프 스레드가 많은 연결을 처리할 수 있고
- I/O 대기를 이벤트 기반으로 다루며
Mono,Flux로 비동기 조합을 표현한다
하지만 중요한 전제가 있다.
Event Loop 스레드 위에서 오래 Blocking 하면 안 된다.
예를 들어 WebFlux 핸들러 안에서:
- 블로킹 JDBC 호출
- 오래 걸리는 파일 I/O
Thread.sleep()Future.get()
같은 작업을 그대로 수행하면 Event Loop 모델의 장점이 무너진다.
즉 WebFlux는 "비동기 문법"이 아니라, Non-blocking I/O 모델을 유지해야 의미가 있는 구조다.
15. Future vs CompletableFuture
ExecutorService에 작업을 제출하면 Future를 받을 수 있다.
ExecutorService executor = Executors.newFixedThreadPool(4);
Future<Integer> future = executor.submit(() -> 1 + 2);
Integer result = future.get();Future는 기본적인 비동기 결과 핸들이다.
할 수 있는 일:
- 작업 완료 여부 확인
- 완료될 때까지 기다리기
- 취소 시도
하지만 한계가 분명하다.
get()이 Blocking이다- 콜백 체이닝이 약하다
- 여러 비동기 작업을 조합하기 불편하다
- 예외 처리 흐름이 투박하다
그래서 더 상위 추상화로 CompletableFuture가 등장한다.
CompletableFuture<Integer> future =
CompletableFuture.supplyAsync(() -> 10)
.thenApply(value -> value * 2)
.thenApply(value -> value + 1);CompletableFuture의 장점은 "완료될 미래의 값"을 선언적으로 이어 붙일 수 있다는 데 있다.
15-1. 가장 큰 차이
| 항목 | Future |
CompletableFuture |
|---|---|---|
| 결과 조회 | get() 중심 |
체이닝 + 필요 시 join() / get() |
| 작업 조합 | 불편함 | 매우 강함 |
| 콜백 처리 | 제한적 | thenApply, thenCompose, thenAccept |
| 예외 처리 | 제한적 | exceptionally, handle, whenComplete |
| 여러 작업 결합 | 수동 처리 | allOf, anyOf, thenCombine |
15-2. .get()에서의 대기 문제
많은 초보 코드가 여기서 멈춘다.
CompletableFuture<String> future = CompletableFuture.supplyAsync(this::callApi);
String result = future.get();이 코드는 비동기 작업을 시작하긴 했지만, 호출한 스레드가 바로 get()에서 기다리므로 체감상 다시 Blocking 코드가 된다.
즉:
- 비동기 시작
- 하지만 소비 지점에서 즉시 blocking
- 결과적으로 스레드 절약 효과가 작아질 수 있음
그래서 CompletableFuture를 쓸 때는 가능하면:
- 체이닝으로 후속 작업을 이어 붙이거나
- 상위 계층에
CompletableFuture자체를 반환하거나 - 정말 마지막 경계에서만
join()/get()하도록 설계하는 편이 낫다
16. CompletableFuture 자주 쓰는 메서드
실무에서 자주 만나는 메서드를 기능별로 묶으면 아래와 같다.
시작
runAsync(): 반환값 없는 비동기 작업supplyAsync(): 반환값 있는 비동기 작업
결과 변환
thenApply(): 결과를 받아 다른 값으로 변환thenAccept(): 결과를 소비하고 끝냄thenRun(): 이전 결과와 무관하게 다음 작업 실행
비동기 평탄화
thenCompose(): 비동기 안에서 또 비동기를 반환할 때 평탄화
예를 들어:
CompletableFuture<User> future =
findUserAsync(userId)
.thenCompose(user -> loadProfileAsync(user));여기서 thenCompose()를 쓰지 않고 thenApply()를 쓰면 CompletableFuture<CompletableFuture<T>>처럼 중첩될 수 있다.
결합
thenCombine(): 서로 다른 두 결과를 합침allOf(): 여러 작업이 모두 끝날 때까지 기다림anyOf(): 여러 작업 중 하나라도 끝나면 진행
예외 처리
exceptionally(): 예외 시 fallback 값 제공handle(): 성공 / 실패를 모두 받아 처리whenComplete(): 결과를 바꾸지 않고 후처리
Executor 지정
CompletableFuture는 Executor를 명시하지 않으면 기본적으로 공용 풀을 사용할 수 있다.
실무에서는 부하 특성과 격리 요구에 따라 명시적인 Executor를 넘기는 편이 더 안전한 경우가 많다.
CompletableFuture.supplyAsync(this::callApi, customExecutor);즉 CompletableFuture는 스레드를 없애는 도구가 아니라,
비동기 흐름을 더 잘 표현하고 조합하게 해 주는 도구다.
17. 전체 흐름 한 번에 정리
지금까지 흐름을 한 줄로 다시 정리하면 이렇다.
프로세스 / 스레드 이해
-> Thread / Runnable / start() / run()
-> 공유 자원 문제와 Race Condition
-> synchronized / Lock / Atomic / volatile
-> Deadlock과 진단
-> Thread Pool / ExecutorService
-> CPU Bound / IO Bound에 맞는 개수 조절
-> Sync / Async 와 Blocking / Non-blocking 구분
-> Event Loop / WebFlux
-> Future / CompletableFuture결국 Java 동시성은 아래 네 문장으로 압축할 수 있다.
- 여러 실행 흐름이 생기면 공유 자원 문제가 반드시 따라온다.
- 안전성 문제와 대기 모델 문제는 구분해서 봐야 한다.
- 스레드는 무조건 많이 만드는 것이 아니라 통제 가능하게 운영해야 한다.
CompletableFuture, WebFlux 같은 상위 도구도 결국 스레드, 대기, 결과 전달 모델 위에 서 있다.
한 줄 정리:
Java 동시성은 "동시에 많이 돌리는 기술"이 아니라, 실행 흐름과 공유 자원과 대기 방식을 일관되게 설계하는 기술이다.
'CS > JAVA' 카테고리의 다른 글
| 자바 입출력 한눈에 보기 : IO vs NIO: 왜 Buffer, Channel, Selector를 들고 나왔을까? (0) | 2026.04.04 |
|---|---|
| Java 비교 한눈에 보기 : Comparable vs Comparator: 정렬 기준은 객체 안에 둘까, 밖에 둘까? (0) | 2026.04.04 |
| 자바 컬렉션 한눈에 보기 4편: HashSet과 TreeSet, 중복 판정의 기준 (0) | 2026.03.19 |
| 자바 컬렉션 한눈에 보기 3편: ArrayList부터 Iterator, fail-fast까지 (0) | 2026.03.19 |
| 자바 컬렉션 한눈에 보기 1편: 해시부터 hashCode, equals, 충돌까지 (0) | 2026.03.19 |