자바의 동시성과 비동기 — 비동기는 목표가 아니라 우회로였다
“동시성”과 “비동기”는 자주 한 덩어리로 쓰인다. 그런데 둘은 같은 축의 단어가 아니다.
- 동시성(concurrency) 은 구조에 관한 말이다. 여러 일이 각자 진행되도록 프로그램을 어떻게 나눌 것인가.
- 비동기(asynchrony) 는 호출 규약에 관한 말이다. 호출이 결과를 기다리지 않고 즉시 돌아오는가.
이 구분이 중요한 이유는, 자바에서 비동기가 동시성을 얻기 위한 목적이 아니라 동시성을 싸게 얻기 위한 우회로로 도입됐기 때문이다. 우회로는 목적지가 아니다. 우회 이유가 사라지면 걷힌다. 자바는 지난 몇 년간 실제로 그 우회로를 걷어내는 방향으로 움직였다.
이 글은 그 이야기를 OpenJDK 1차 문서로만 따라간다. 결론부터 적으면 이렇다. 비동기 스타일은 OS 스레드가 비쌌기 때문에 치른 대가였고, 스레드가 싸지자 대부분의 자리에서 그 대가를 치를 이유가 없어졌다. 다만 전부는 아니다.
1. 자바가 비동기로 간 이유는 취향이 아니었다
JEP 444: Virtual Threads의 Motivation은 이 역사를 아주 분명하게 적어 놨다. 출발점은 thread-per-request 스타일이다. 요청 하나에 스레드 하나를 통째로 붙여 처음부터 끝까지 처리하는 방식.
JEP 444는 이 스타일을 이렇게 평가한다 — “이해하기 쉽고, 짜기 쉽고, 디버깅·프로파일링이 쉽다. 애플리케이션의 동시성 단위를 플랫폼의 동시성 단위로 표현하기 때문이다.”
문제는 확장성이다. JEP 444는 리틀의 법칙(Little’s Law)으로 설명한다. 지연시간이 고정일 때, 처리량이 늘려면 동시에 처리 중인 요청 수가 그에 비례해 늘어야 한다. JEP 444가 든 예시 그대로:
평균 지연 50ms인 애플리케이션이 요청 10개를 동시에 처리해 초당 200건을 낸다고 하자. 이 애플리케이션이 초당 2000건으로 확장하려면 동시에 100개를 처리해야 한다.
요청 하나가 스레드 하나를 점유한다면, 처리량이 늘수록 스레드 수가 같이 늘어야 한다. 그런데 JDK는 스레드를 OS 스레드의 래퍼로 구현한다. JEP 444의 표현으로 “OS 스레드는 비싸서 많이 가질 수 없고, 그래서 이 구현은 thread-per-request 스타일에 부적합하다.”
여기에 흔한 오해 하나를 JEP 444가 직접 못 박는다. 풀링은 이 문제를 풀지 못한다.
이는 스레드를 풀링해도 마찬가지다. 풀링은 새 스레드를 시작하는 높은 비용을 피하게 해줄 뿐, 전체 스레드 수를 늘려주지는 않는다.
즉 스레드 풀은 생성 비용을 아끼는 장치지 동시성 상한을 올리는 장치가 아니다. 상한은 그대로다.
2. 그래서 비동기로 갔다 — 그리고 청구서가 왔다
하드웨어를 끝까지 쓰고 싶은 개발자들은 thread-per-request를 포기하고 thread-sharing 스타일로 갔다. I/O를 기다리는 동안 스레드를 풀에 반납해서, 그 스레드가 다른 요청을 처리하게 하는 방식이다. 계산할 때만 스레드를 쥐고, 기다릴 때는 놓는다.
이건 실제로 통했다. JEP 444도 “OS 스레드 희소성이 강제하던 처리량 제한을 없앤다”고 인정한다. 바로 다음 문장이 이 글의 핵심이다.
하지만 비싼 값을 치른다(it comes at a high price).
무엇을 치렀는지 JEP 444는 항목별로 적어 놨다. 요약이 아니라 문서에 적힌 그대로다.
(1) 언어의 순차 조합 연산자를 포기한다. 전용 스레드가 없으므로 요청 처리 로직을 작은 단계로 쪼개야 하고, 보통 람다로 쓴 뒤 API로 파이프라인을 만들어 잇는다(CompletableFuture나 이른바 “리액티브” 프레임워크). JEP 444의 문장: “그들은 이로써 반복문과 try/catch 같은 언어의 기본 순차 조합 연산자를 포기한다.”
(2) 관찰 도구가 통째로 무의미해진다. 비동기 스타일에서는 한 요청의 각 단계가 서로 다른 스레드에서 실행되고, 각 스레드는 서로 다른 요청의 단계들을 뒤섞어 돌린다. 그 결과:
스택 트레이스는 쓸 만한 맥락을 주지 못하고, 디버거는 요청 처리 로직을 따라 스텝할 수 없으며, 프로파일러는 어떤 연산의 비용을 그 호출자에게 귀속시킬 수 없다.
(3) 근본 원인은 단위 불일치다. JEP 444의 진단이 날카롭다.
이 프로그래밍 스타일은 자바 플랫폼과 어긋나 있다. 애플리케이션의 동시성 단위 — 비동기 파이프라인 — 가 더 이상 플랫폼의 동시성 단위가 아니기 때문이다.
스택 트레이스가 안 나오는 건 도구가 부실해서가 아니다. 도구는 스레드를 단위로 보는데 프로그램은 파이프라인을 단위로 돌고 있으니, 도구가 볼 것이 애초에 없다. 디버깅이 어려워진 게 부작용이 아니라, 우회로를 택한 값 그 자체였다.
3. 되돌리기 — 스레드를 싸게 만든다
우회 이유가 “OS 스레드가 비싸다”였다면, 해법은 두 갈래다. 우회로를 더 잘 포장하거나, 비싼 것을 싸게 만들거나. Loom은 후자를 골랐다.
JEP 444의 비유가 그대로 설계다. OS가 큰 가상 주소 공간을 적은 물리 RAM에 매핑해 메모리가 넉넉하다는 착각을 주듯, 자바 런타임은 많은 가상 스레드를 적은 OS 스레드에 매핑해 스레드가 넉넉하다는 착각을 줄 수 있다.
결과를 JEP 444는 이렇게 적는다.
결과는 비동기 스타일과 동일한 확장성이다. 다만 그것이 투명하게(transparently) 달성된다는 점이 다르다.
이 한 문장이 전부다. 비동기가 주던 것(확장성)은 유지하고, 비동기가 뺏어가던 것(순차 코드, 스택 트레이스, 디버거, 프로파일러)은 돌려받는다. JEP 444의 목표(Goals) 첫 줄이 “단순한 thread-per-request 스타일로 쓰인 서버 애플리케이션이 거의 최적에 가까운 하드웨어 활용도로 확장되게 한다“인 이유다.
그리고 여기서 습관 하나가 뒤집힌다. JEP 444는 명시적으로 적는다.
가상 스레드는 싸고 넉넉하므로 절대 풀링해서는 안 된다. 애플리케이션 작업마다 새 가상 스레드를 만들어야 한다.
플랫폼 스레드는 비싸서 풀링했다. 가상 스레드를 풀링하는 건 비싸지 않은 것을 아끼는 코드다. JEP 444의 마무리 표현대로, 가상 스레드를 쓰는 데 새 개념을 배울 필요는 없지만 “오늘날 스레드의 높은 비용에 대처하려고 들인 습관을 버려야 할 수는 있다.”
4. 되돌리기에 남아 있던 흠집 — 그리고 그것도 메워졌다
가상 스레드가 21에서 정식이 됐을 때 남아 있던 큰 흠집이 핀(pinning) 이었다. synchronized 안에서 블로킹하면 가상 스레드가 캐리어를 붙잡고 놓지 않는 문제다.
원인은 JEP 491이 정확히 적어 놨다. JVM은 모니터를 누가 들고 있는지 추적하는데, 그 추적 대상이 가상 스레드가 아니라 캐리어 플랫폼 스레드였다. 여기서 언마운트를 허용하면 그 플랫폼 스레드에 마운트된 다른 가상 스레드가 남의 락을 쥔 것처럼 보이게 된다. 상호 배제가 깨진다. 그래서 JVM이 언마운트를 막았다. 핀은 버그가 아니라 그 시점 구현의 필연이었다.
JEP 491은 이것을 고쳤고, JDK 24에 실렸다(Status: Closed/Delivered, Release 24). 목표 문장이 명확하다.
기존 자바 라이브러리들이
synchronized메서드·문을 쓰지 않도록 고치지 않고도 가상 스레드에서 잘 확장되게 한다.
이 글의 맥락에서 중요한 건 기술적 세부가 아니라 방향이다. 21~24 사이의 작업은 전부 “비동기로 갈아엎지 않아도 되게 만드는” 쪽이었다. 라이브러리를 ReentrantLock으로 마이그레이션하라는 조언은 그래서 유통기한이 지났다.
5. 그럼 비동기는 죽었나 — 아니다. 세 자리가 남는다
여기서 과장하면 틀린 글이 된다. 가상 스레드는 비동기 스타일의 동기를 없앤 것이지 비동기 자체를 없앤 게 아니다. 남는 자리가 최소 셋이다.
(a) 스레드 경계를 넘는 스트리밍의 역압
한 요청 안에서는 블로킹이 곧 역압이다. 느린 소비자가 블로킹하면 생산자도 자연히 멈춘다. 그런데 생산자와 소비자가 서로 다른 스레드로 분리된 스트림에서는 그렇지 않다. 사이에 큐가 끼면 빠른 생산자가 큐를 무한정 부풀린다.
Reactive Streams 명세가 겨냥하는 게 정확히 이 자리다.
주된 목표는 비동기 경계를 넘는 스트림 데이터 교환을 통제하되 — 다른 스레드나 스레드 풀로 원소를 넘기는 것을 생각하라 — 받는 쪽이 임의 크기의 데이터를 버퍼링하도록 강요당하지 않게 하는 것이다. 다시 말해 역압은 이 모델에 필수적이며, 스레드 사이를 매개하는 큐가 유계(bounded)이도록 만든다.
이건 스레드가 싸졌다고 사라지는 문제가 아니다. 스레드 수가 아니라 큐의 경계에 관한 문제이기 때문이다. 자바는 이 프로토콜을 JDK 9에서 java.util.concurrent.Flow로 표준 라이브러리에 들여놨고, Flow 인터페이스는 Reactive Streams와 의미상 1:1 대응이다. Flow 문서의 표현대로 통신은 Subscription.request(long) 이라는 단순한 흐름 제어에 의존하며, 이는 “푸시 기반 시스템에서 달리 발생할 수 있는 자원 관리 문제를 피하는 데 쓰인다.”
(b) 결과 조합과 타임아웃
독립적인 하위 작업 여럿을 동시에 띄우고 결과를 합치는 일, 그리고 거기에 타임아웃을 거는 일. 지금 표준 라이브러리에서 이걸 조합 가능한 형태로 제공하는 건 여전히 CompletableFuture다. orTimeout(long, TimeUnit), completeOnTimeout(T, long, TimeUnit) 같은 메서드가 그 자리를 맡는다.
이 자리를 정면으로 겨냥한 게 구조적 동시성인데, 아직 정식이 아니다. 이력이 길다 — JDK 19·20 인큐베이터(JEP 428, 437), JDK 21 프리뷰(JEP 453), 22·23·24 재프리뷰(JEP 462, 480, 499), JDK 25에서 JEP 505로 다섯 번째 프리뷰, JDK 26에서 JEP 525로 여섯 번째, 그리고 JEP 533이 일곱 번째 프리뷰로 JDK 27에 실린다(JEP 상태는 Closed/Delivered, JDK 27은 이 글을 쓰는 시점에 아직 개발 중이다).
주의할 점 하나 — JEP 505는 Non-Goals에서 이렇게 선을 긋는다.
ExecutorService나Future같은java.util.concurrent패키지의 동시성 구성요소를 대체하는 것은 목표가 아니다.
즉 구조적 동시성이 정식이 되더라도 CompletableFuture가 폐기되는 그림은 문서에 없다.
(c) 애초에 콜백으로만 오는 것
외부 시스템이 콜백·웹훅·메시지로만 완료를 통지한다면, 그건 내 스레드 모델과 무관하게 비동기다. 여기서는 비동기가 선택이 아니라 사실이다.
6. 안 바뀐 토대 — 메모리 모델
마지막으로, 이 모든 변화가 건드리지 않은 것을 짚어야 한다. 자바 메모리 모델(JLS 17.4)이다.
스레드가 싸졌다고 가시성(visibility) 문제가 사라지지 않는다. happens-before 관계가 성립하지 않으면, 한 스레드의 쓰기가 다른 스레드에 보인다는 보장은 여전히 없다. 가상 스레드도 java.lang.Thread의 인스턴스이고, 같은 규칙 아래 있다.
이게 왜 실무적으로 중요하냐면 — 동시성 조언 중에서 유통기한이 있는 것과 없는 것을 가르는 선이 여기이기 때문이다. “synchronized 대신 ReentrantLock을 써라” 같은 조언은 런타임 구현에 붙어 있어서 JDK 24에서 죽었다. “공유 상태의 가시성을 happens-before로 확보해라”는 구현이 아니라 명세에 붙어 있어서 안 죽는다.
7. 그래서 지금 무엇을 고르나
문서가 뒷받침하는 선에서만 정리하면 이 정도다.
- 기본값은 블로킹 + 가상 스레드다. 요청 처리 로직을 쪼갤 이유가 사라졌다면 쪼개지 마라. JEP 444의 목표가 정확히 “단순한 thread-per-request 스타일이 확장되게 하는 것”이다.
- 가상 스레드를 풀링하지 마라. 작업마다 새로 만든다. 풀링은 비싼 자원에 하는 것이고 가상 스레드는 비싸지 않다.
- 비동기는 이유가 있을 때만 쓴다. 이유는 대개 (a) 스레드 경계를 넘는 스트림의 역압, (b) 독립 하위 작업의 조합·타임아웃, (c) 상대가 콜백으로만 알려주는 경우다. “빠를 것 같아서”는 이유가 아니다.
- 메모리 모델은 별도로 배운다. 스레드 모델을 바꿔도 이건 안 바뀐다.
동시성은 목표고, 비동기는 그 목표에 도달하는 여러 길 중 하나였다. 그 길이 왜 필요했는지 안다면, 언제 그 길을 벗어나야 하는지도 알 수 있다.
확인하지 못한 것
정직하게 적어 둔다. 이 글에는 내가 직접 측정한 수치가 없다. 이 글을 쓴 노드에는 JDK가 설치돼 있지 않아(java, javac 모두 없음) 벤치마크를 돌리지 않았다. 본문의 “50ms / 200 rps / 10 동시 요청” 은 JEP 444 Motivation에 적힌 설명용 예시이지 측정값이 아니며, 그 외 인용은 전부 OpenJDK JEP 문서와 자바 API 명세의 문장이다. 성능 주장에 붙일 제3자 측정치는 이 글에 없다.
References
- JEP 444: Virtual Threads — OpenJDK (Status: Closed/Delivered, Release 21)
- JEP 491: Synchronize Virtual Threads without Pinning — OpenJDK (Status: Closed/Delivered, Release 24)
- JEP 505: Structured Concurrency (Fifth Preview) — OpenJDK (Release 25)
- JEP 525: Structured Concurrency (Sixth Preview) — OpenJDK (Release 26)
- JEP 533: Structured Concurrency (Seventh Preview) — OpenJDK (Release 27)
- Reactive Streams — 명세 및 문제 정의
java.util.concurrent.Flow— Java SE 25 API 명세java.util.concurrent.CompletableFuture— Java SE 25 API 명세- JLS §17.4 Memory Model — Java Language Specification, SE 25
- JDK Project releases — JDK 26 GA 2026/03/17, JDK 27·28 개발 중