비동기가 빠른 이유
비동기가 “더 빠른 실행 방식”이라고 생각하면 오해가 시작된다. CPU 연산 속도는 달라지지 않는다.
이득은 한 곳에서 온다. 기다리는 동안 스레드를 붙잡지 않는다는 것.
동기 방식에서 DB 쿼리를 호출하면, 응답이 올 때까지 그 스레드는 아무것도 못 하고 묶여 있다. 쿼리가 50ms 걸리면 50ms 동안 스레드 하나가 놀면서 점유된다. 스레드는 스택 메모리(보통 1MB)를 쓰므로 수천 개를 띄울 수 없고, 늘릴수록 컨텍스트 스위칭 비용이 커진다.
비동기는 요청을 보내고 스레드를 반납한다. 응답이 도착하면 콜백이 실행된다. 스레드 몇 개로 수만 개의 대기를 감당할 수 있다.
이득이 사라지는 조건
여기에 함정이 있다. 어딘가에서 블로킹이 일어나면 그 순간 이득이 사라진다.
Mono.fromCallable(() -> jdbcTemplate.query(...)) // JDBC는 블로킹이다
리액티브 프레임워크를 쓰고 있어도 JDBC 드라이버는 응답을 기다리며 스레드를 붙잡는다. 이벤트 루프 스레드가 막히면 그 스레드가 처리하던 다른 모든 요청이 함께 멈춘다. 스레드 수가 적으니 피해는 오히려 더 크다.
별도 스케줄러로 넘겨 격리할 수는 있지만, 그러면 결국 그 뒤에 스레드 풀이 있는 것이다. 비동기의 형태만 남고 이득은 없다.
리액티브의 이득은 스택 전체가 논블로킹일 때만 온전하다. 웹 서버, DB 드라이버(R2DBC 등), HTTP 클라이언트, 캐시 클라이언트가 모두 논블로킹이어야 한다.
대가
- 디버깅이 어렵다. 스택 트레이스가 끊기고 흐름을 눈으로 좇기 어렵다
- 생태계 제약. 쓰려는 라이브러리가 논블로킹을 지원하지 않으면 거기서 막힌다
- 학습 비용. 연산자 조합에 익숙해지는 데 시간이 든다
가상 스레드가 바꾸는 지점
Java 21의 가상 스레드는 이 선택 자체를 흔든다.
블로킹 호출을 만나면 JVM이 그 자리에서 캐리어 스레드를 반납한다. 코드는 동기식으로 쓰지만 스레드는 묶이지 않는다. 비동기의 이득을 동기 코드의 가독성으로 얻는 셈이다.
기존 코드를 거의 그대로 두고 스레드 팩토리만 바꾸면 되는 경우가 많다. 다만 synchronized 블록 안의 블로킹은 캐리어 스레드를 고정(pinning)시키므로 ReentrantLock으로 바꿔야 한다.
어떻게 고를까
- CPU 위주 작업 → 스레드 풀. 코어 수만큼 두면 된다. 비동기로 얻을 게 없다
- I/O 대기가 많고 Java 21 이상 → 가상 스레드를 먼저 검토한다. 얻는 것에 비해 바꿀 게 가장 적다
- 이미 리액티브 스택이거나 스트리밍·백프레셔가 필요 → 리액티브
정리
비동기는 대기 시간을 회수하는 기법이지 실행을 빠르게 하는 기법이 아니다. 어딘가에 블로킹이 남아 있다면 형태만 비동기일 뿐이다.