왜 자바 동시성은 아직도 어려운가
스레드 하나가 15만 개의 연결을 감당해야 한다면
웹 서버 하나가 초당 만 건이 넘는 요청을 받는 상황을 생각해 봅시다. 요청마다 데이터베이스 조회가 있고, 외부 API 호출이 있고, 파일을 읽는 구간이 있습니다. 이 모든 작업은 결국 어딘가에서 "기다림"을 포함합니다. CPU 는 그 순간 아무 일도 하지 않습니다. 네트워크 카드가 응답을 채워줄 때까지, 디스크 컨트롤러가 섹터를 읽어줄 때까지 그냥 대기합니다.
전통적인 자바 애플리케이션은 이 대기를 스레드 하나가 통째로 떠안는 방식으로 만들어졌습니다. 요청 하나에 스레드 하나(thread-per-request). 단순하고, 디버깅하기 쉽고, 스택 트레이스가 요청의 흐름과 정확히 일치합니다. 문제는 이 모델이 스레드를 공짜라고 가정한다는 점입니다. 실제로는 전혀 공짜가 아닙니다.
OS 스레드는 비싸다
JVM 의 일반 스레드(플랫폼 스레드)는 결국 운영체제 스레드입니다. 리눅스에서 스레드 하나를 만들면 기본 스택으로 1MB 안팎의 메모리가 예약되고, 스레드 사이를 전환할 때마다 커널이 개입해 레지스터를 저장하고 복원합니다. 이 컨텍스트 스위칭은 수 마이크로초 단위지만, 초당 수만 번 발생하면 무시할 수 없는 CPU 시간을 잡아먹습니다.
그래서 실무에서는 스레드 풀을 씁니다. 톰캣 기본 설정은 스레드 200개, 조금 넉넉하게 잡아도 수백에서 수천 개 수준입니다. 동시 요청이 그 이상으로 몰리면 나머지는 큐에서 기다립니다. 응답 시간이 느려지는 게 아니라, 큐에서 대기하는 시간이 늘어나는 겁니다. 서버는 놀고 있는데(CPU 사용률은 낮은데) 요청은 밀립니다. I/O 대기가 스레드를 묶어 두고, 스레드 개수가 하드웨어 한계가 아니라 운영체제 스케줄러 부담 때문에 막혀 있기 때문입니다.
// 흔한 스레드 풀 설정 — 이 숫자는 어디서 왔을까?
ExecutorService pool = Executors.newFixedThreadPool(200);
for (int i = 0; i < 100_000; i++) {
pool.submit(() -> {
// DB 조회, 외부 API 호출 등 I/O 대기가 대부분인 작업
String result = callExternalService();
process(result);
});
}200 이라는 숫자는 대개 경험적으로 정해집니다. 늘리면 컨텍스트 스위칭 비용과 메모리 사용량이 같이 늘어나고, 줄이면 처리량이 떨어집니다. 이 트레이드오프 자체가 "스레드는 비싼 자원"이라는 전제에서 나온 겁니다.
비동기로 도망친 사람들
이 문제를 풀기 위해 많은 언어와 프레임워크가 논블로킹 I/O + 이벤트 루프 방식을 택했습니다. Node.js 가 대표적이고, 자바 진영에서는 Netty, WebFlux, RxJava 가 같은 길을 갑니다. 스레드 하나가 수천 개의 연결을 동시에 처리할 수 있게 되니 확장성 문제는 풀립니다.
대신 코드가 달라집니다. callExternalService() 처럼 결과를 바로 리턴받는 대신, 콜백이나 Mono, Flux, CompletableFuture 체인으로 로직을 다시 짜야 합니다. 스택 트레이스는 실제 호출 흐름과 무관한 리액터 내부 프레임으로 채워지고, 디버거로 한 줄씩 따라가던 습관은 통하지 않습니다. try-catch 로 감싸던 예외 처리도 onError 핸들러로 흩어집니다.
저는 이 트레이드오프가 정당했다고 생각하지 않습니다. 확장성을 얻는 대가로 코드 전체의 사고방식을 바꿔야 한다는 건 너무 비쌉니다. 실제로 많은 팀이 WebFlux 로 전환했다가, "동시성 문제는 해결했는데 아무도 이 코드를 못 읽는다"는 이유로 후회하는 경우를 여럿 봤습니다.
가상 스레드가 노리는 지점
자바 21 에서 정식 도입된 가상 스레드(Virtual Thread)는 다른 답을 시도합니다. 코드는 동기 스타일 그대로 두고 — callExternalService() 를 그냥 호출하고 결과를 기다리고 — 그 "기다림"을 OS 스레드가 아니라 JVM 이 관리하는 훨씬 가벼운 스레드가 떠안게 만드는 겁니다. 스레드 하나에 1MB 짜리 스택 대신, 필요한 만큼만 자라는 훨씬 작은 메모리 구조를 씁니다. 100만 개를 동시에 띄우는 벤치마크가 여기저기서 나온 이유입니다.
이게 공짜는 아닙니다. 뒤에서 다룰 pinning 문제, ThreadLocal 남용 문제처럼 기존 코드가 암묵적으로 의존하던 가정들이 깨지는 지점이 분명히 있습니다. 하지만 "동기 코드를 그대로 두고 확장성을 얻는다"는 방향 자체는, 적어도 지금까지 나온 대안들 중에서는 가장 실용적인 타협이라고 봅니다. 이 책은 그 타협이 정확히 어디서 성립하고 어디서 깨지는지를 따라갑니다.