runCatching, CompletableFuture, 가상 스레드, 코루틴 — 뭘 써야 하나
발단
거래소 API 3개사와 블록체인 gRPC를 호출하는 서비스가 있다. 어느 날 블록체인 노드가 3초 만에 timeout을 뱉었는데, 알람만 오고 정작 그 요청에서 거래소는 얼마나 걸렸는지, 전체 API는 몇 초였는지 알 수 있는 로그가 없었다.
그래서 네 가지를 한 번에 작업했다:
- 외부 호출마다
elapsed로깅 - 요청별 TID로 로그 매칭 (MDC)
- 직렬 호출을 병렬로 전환
- 스레드 풀 개선
간단해 보이지만 각 단계에서 "이거 말고 저걸 쓰면 안 되나?" 하는 선택지가 나왔다. 정리해 본다.
1. runCatching vs try/catch/finally
원래 코드: runCatching
Kotlin의 runCatching은 try/catch를 함수형으로 감싼 것이다.
fun getAssets(accessToken: String): List<Assets> {
return runCatching {
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
mapper.readValue(response.body(), object : TypeReference<List<Assets>>() {})
}.getOrElse { e -> throw ServerException(e) }
}
깔끔하다. 한 줄에 "시도 → 실패 시 감싸서 throw"가 끝난다.
elapsed 로깅 넣기: runCatching + .also로 가능하다
외부 호출 전후에 시간을 재고, 성공이든 실패든 로그를 남겨야 한다. runCatching에 .also { } 를 쓰면 된다:
fun getAssets(accessToken: String): List<Assets> {
val startTime = System.currentTimeMillis()
return runCatching {
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
mapper.readValue(response.body(), object : TypeReference<List<Assets>>() {})
}.also {
logger.info("[Exchange] getAssets elapsed={}ms", System.currentTimeMillis() - startTime)
}.getOrElse { e -> throw ServerException(e) }
}
.also는 Result가 성공이든 실패든 실행된다. timeout으로 10초 뒤에 터져도 elapsed는 찍힌다:
[Exchange] getAssets elapsed=10003ms
"실패 시에도 elapsed가 남아야 한다"는 요구사항은 이것만으로 충족된다.
더 나아가기: response body도 함께 남기고 싶다면
elapsed만 찍으면 "얼마나 걸렸는지"는 알 수 있다. 그런데 왜 실패했는지까지 한 줄에서 알고 싶으면?
response: null→ 응답 수신 전에 터짐 → 네트워크 문제 또는 timeoutresponse: {"error": "invalid_token"}→ 응답은 받았지만 서버 에러
이 구분을 하려면 response body 원문을 로그에 남겨야 한다. 그런데 runCatching의 .also 블록에서는 Result<T>(파싱된 객체)만 접근 가능하고, response body 원문에 접근할 깔끔한 방법이 없다.
이때 try/catch/finally가 자연스럽다고 생각했다
fun getAssets(accessToken: String): List<Assets> {
val startTime = System.currentTimeMillis()
var responseBody: String? = null
try {
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
responseBody = response.body()
return mapper.readValue(responseBody, object : TypeReference<List<Assets>>() {})
} catch (e: Throwable) {
throw ServerException(e)
} finally {
logger.info("[Exchange] getAssets elapsed={}ms, response: {}",
System.currentTimeMillis() - startTime, responseBody)
}
}
finally는 자바/코틀린에서 정상이든 예외든 무조건 실행되는 블록이다. return으로 빠져나가든, throw로 예외가 터지든, 그 직전에 반드시 한 번 실행된다.
외부 변수 responseBody에 body를 저장해두면, 성공 시에는 실제 body가, timeout 시에는 null이 찍힌다:
# 성공
[Exchange] getAssets elapsed=820ms, response: {"balance": "100.5"}
# timeout (read 10초 설정)
[Exchange] getAssets elapsed=10003ms, response: null
# 응답은 받았지만 서버 에러
[Exchange] getAssets elapsed=350ms, response: {"error": "invalid_token"}
한 줄로 "얼마나 걸렸고, 응답을 받았는지, 받았다면 뭐가 왔는지"를 전부 알 수 있다.
정리: 언제 뭘 쓰나
| 상황 | 권장 |
|---|---|
| 반환값 변환 + 예외 wrap만 필요 | runCatching — 간결 |
| 실패 시에도 elapsed를 남기고 싶다 | runCatching + .also — 충분 |
| elapsed + response body까지 남기고 싶다 | try/catch/finally — 지역 변수로 body 캡처 가능 |
| 예외 종류별 다른 처리 (multi-catch) | try/catch — Kotlin의 when 패턴 매칭과 조합 |
핵심:
runCatching이 나쁜 게 아니다. elapsed만 찍으면.also로 충분하다. 지역 상태 (body 등)까지 참조하면서 성공/실패 모두에서 로깅해야 할 때는 try/catch/finally가 더 자연스럽다고 생각했다.
2. CompletableFuture vs 코루틴
먼저: "병렬"이란
지금까지의 코드는 이렇다:
[거래소 3사 호출] → [블록체인 호출] → [응답]
2초 3초 = 5초
거래소가 끝나야 블록체인을 시작한다. 줄 서서 하나씩 처리 — 이게 직렬이다.
두 호출은 서로의 결과를 안 쓴다 (거래소 잔액 조회에 블록체인 정보가 필요하지 않고, 반대도 마찬가지). 그러면 동시에 시작할 수 있다:
[거래소 3사 호출] ─┐
├→ 둘 다 끝나면 [응답]
[블록체인 호출] ─┘
max(2초, 3초) = 3초
느린 쪽이 끝날 때까지만 기다리면 된다 — 이게 병렬이다. 5초가 3초가 됐다.
이 "동시에"를 구현하는 방법이 두 가지 있다.
선택지 A: CompletableFuture
Java 8부터 있는 비동기 결과 컨테이너. "이 작업을 다른 스레드에서 실행하고, 끝나면 결과를 담아줘"라는 뜻이다.
// 두 작업을 동시에 시작
val fa = CompletableFuture.supplyAsync({ getMarketBalances() }, executor)
val fb = CompletableFuture.supplyAsync({ getBlockchainBalance() }, executor)
// 둘 다 끝날 때까지 대기 후 결과 조합
return fa.join() to fb.join()
supplyAsync: "이 함수를 executor(스레드 풀)에서 비동기로 실행해줘"join(): "결과가 나올 때까지 여기서 기다릴게"- 두
supplyAsync가 거의 동시에 실행되고,join()에서 결과를 모아서 합침
선택지 B: Kotlin 코루틴
Kotlin이 제공하는 비동기 프로그래밍 모델. 자바의 CompletableFuture와 같은 목적이지만 문법이 다르다.
coroutineScope {
val balance = async(Dispatchers.IO) { getMarketBalances() }
val onchain = async(Dispatchers.IO) { getBlockchainBalance() }
balance.await() to onchain.await()
}
async { ... }: "이 함수를 비동기로 시작해줘" (supplyAsync와 같은 역할)await(): "결과가 나올 때까지 기다릴게" (join과 같은 역할)Dispatchers.IO: I/O 작업용 스레드 풀에서 실행하라는 지시coroutineScope: 이 안에서 시작된 async들을 관리하는 울타리
코루틴의 장점
1. 문법이 눈에 잘 들어온다
async { ... }.await()는 "비동기로 시작 → 결과 기다림"을 직관적으로 표현한다. CompletableFuture.supplyAsync({ ... }, executor).join()보다 읽기 쉽다.
2. 구조적 동시성 (Structured Concurrency)
coroutineScope { } 는 안에서 시작된 모든 async를 관리하는 울타리다. 울타리 안의 모든 작업이 끝나야 울타리가 닫힌다. 한 쪽이 예외를 던지면 나머지도 자동 취소된다.
"잠깐, 4개를 호출했는데 1개가 실패하면 나머지 3개도 취소되는 건가?" → 기본 동작이 그렇다.
이건 상황에 따라 장점이기도 하고 단점이기도 하다:
- 장점인 경우: 4개 결과가 전부 필요한 작업. 하나라도 실패하면 어차피 전체가 쓸모없음 → 나머지도 빨리 취소해서 자원 낭비를 막는 게 맞음. 예) 4개 API 결과를 합쳐서 리포트를 만드는 경우.
- 단점인 경우: "실패한 건 기본값으로 대체하고 나머지는 정상 반환" 해야 하는 경우 → 자동 취소가 오히려 문제. 예) 거래소 잔액 조회에서 1곳 실패해도 "0"으로 대체하고 나머지는 보여줘야 하는 경우.
이번 서비스가 정확히 후자다. 거래소 3사 중 하나가 timeout 나도 해당 잔액만 "0"으로 내려주고 나머지는 정상 응답해야 한다. 코루틴의 자동 취소가 방해가 될 수 있다.
코루틴에서 이걸 해결하려면 supervisorScope라는 별도 스코프를 써야 한다:
supervisorScope {
val a = async { callExchangeA() }
val b = async { callExchangeB() } // timeout → 예외
val c = async { callExchangeC() } // b가 터져도 계속 실행됨
// await() 마다 개별적으로 예외를 잡아서 "0" 폴백
val resultA = runCatching { a.await() }.getOrDefault("0")
val resultB = runCatching { b.await() }.getOrDefault("0")
val resultC = runCatching { c.await() }.getOrDefault("0")
}
가능은 하지만 supervisorScope 전환 + 각 await()마다 runCatching 감싸기 — 기본 동작이 아니라 추가 설정이 필요하다.
CompletableFuture는 기본 동작이 부분 실패 허용이다:
futures["A"] = runAsync { callExchangeA() }
futures["B"] = runAsync { callExchangeB() } // timeout → 예외
futures["C"] = runAsync { callExchangeC() } // B와 무관하게 끝까지 실행
for ((market, future) in futures) {
try {
balanceArray[market] = future.get()
} catch (e: Exception) {
// B만 실패 → B만 빠지고 A, C는 정상
}
}
A가 실패해도 B/C는 백그라운드에서 끝까지 돈다. 결과 루프에서 실패한 것만 건너뛰면 된다. 별도 설정 없이 "실패하면 0, 나머지는 정상" 시나리오에 그대로 맞아떨어진다.
3. 예외 처리 차이
await()는 원본 예외를 그대로 던진다. CompletableFuture.join()은 CompletionException이라는 래퍼로 한 번 감싸서 던진다. 그래서 원본 예외를 꺼내는 코드가 필요하다:
// CompletableFuture — 래퍼를 벗겨야 원본 예외가 나옴
try {
fa.join() to fb.join()
} catch (e: CompletionException) {
throw e.cause ?: e // 원본 꺼내기
}
// 코루틴 — 원본 그대로
try {
balance.await() to onchain.await()
} catch (e: Exception) {
throw e
}
코루틴의 현실적 비용
1. suspend 전염
코루틴의 핵심 키워드는 suspend다. 먼저 이게 뭔지부터.
일반 함수 — 시작하면 끝날 때까지 스레드를 놓지 않는다. 네트워크 응답을 3초 기다리면 그 3초 동안 스레드가 잡혀 있다:
fun getBalance(): String {
val result = client.send(request) // 3초 대기 — 스레드가 여기서 멈춤
return result.body() // 3초 뒤에야 여기 도달
}
suspend 함수 — "이 함수는 중간에 일시정지했다가 나중에 이어서 실행될 수 있다"는 표시다. 네트워크 대기가 시작되면 함수 실행을 일시정지하고 스레드를 다른 작업에 넘겨준다. 응답이 오면 빈 스레드를 다시 잡아서 재개한다:
suspend fun getBalance(): String {
val result = client.send(request) // 여기서 일시정지, 스레드를 양보
return result.body() // 응답 오면 재개
}
비유: 식당에서 음식을 주문하고 자리에 앉아서 멍하니 기다리는 것 (일반 함수) vs 번호표 받고 다른 볼일 보다가 호출되면 돌아오는 것 (suspend 함수).
여기까지는 좋다. 문제는 suspend 함수는 일반 함수에서 직접 호출할 수 없다는 제약이다:
fun normalFunction() {
getBalance() // 컴파일 에러! suspend 함수는 suspend 안에서만 호출 가능
}
suspend fun suspendFunction() {
getBalance() // OK
}
async를 쓰려면 coroutineScope { } 안이어야 하고, coroutineScope를 쓰려면 그 함수가 suspend여야 한다. 그러면 그 함수를 부르는 함수도 suspend여야 하고... 전염처럼 호출 체인 전체에 퍼진다:
// 이 함수를 suspend 로 바꾸면...
suspend fun getMainAccountList(): Response {
coroutineScope {
val a = async { ... }
val b = async { ... }
}
}
// 이걸 부르는 함수도 suspend 여야 하고...
suspend fun connectAccount(req: Request): Response {
return getMainAccountList()
}
// Controller까지 전파된다
@PostMapping("v3/account")
suspend fun account(@RequestBody req: Request): Response {
return service.connectAccount(req)
}
한 곳에서 async를 쓰겠다고 선언하면 Controller까지 올라가며 전부 suspend로 바꿔야 한다. Spring MVC가 suspend 컨트롤러를 지원하긴 하지만, 기존 동기 체인을 전부 바꾸는 건 PR 범위가 몇 배로 커진다.
runBlocking { }으로 감싸면 전염을 끊을 수 있다. "현재 스레드를 블로킹하면서 그 안에서 코루틴을 실행"하는 것인데, request 스레드를 잡아먹으면서 내부에서 코루틴을 돌리는 거라 병렬화의 이점이 반감된다.
CompletableFuture는 이 문제가 없다. 일반 함수 안에서 supplyAsync를 쓰고 join()으로 기다리면 된다. 호출 체인을 바꿀 필요가 없다.
2. Dispatchers.IO는 가상 스레드가 아니다
코루틴에서 async(Dispatchers.IO) { ... }라고 쓰면 "이 작업을 I/O 전용 스레드 풀에서 실행해라"라는 뜻이다.
Dispatchers는 코루틴이 어떤 스레드에서 실행될지 지정하는 것이다:
Dispatchers.Main: UI 스레드 (안드로이드)Dispatchers.Default: CPU 연산용 스레드 풀 (commonPool과 비슷)Dispatchers.IO: 네트워크/파일/DB 같은 I/O 작업용 스레드 풀
Dispatchers.IO의 기본 구현은 플랫폼 스레드 풀 (기본 64개)이다. blocking I/O에 쓰라고 만든 거지만 결국 풀 크기 제한이 있다. 동시 요청이 많아지면 대기가 발생한다 (뒤에서 설명할 commonPool 문제와 비슷하다).
가상 스레드를 코루틴에서 쓰려면 별도 dispatcher를 만들어야 한다:
val vtDispatcher = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()
coroutineScope {
val a = async(vtDispatcher) { ... }
val b = async(vtDispatcher) { ... }
}
가능은 하지만 kotlinx-coroutines-core 의존성 + dispatcher 설정이 추가된다.
3. 디버깅
suspend 함수는 컴파일러가 상태 머신 (state machine)으로 변환한다. 원래 코드는 순차적인 함수 호출인데, 컴파일 후에는 "단계별로 쪼개진 콜백 체인"이 된다. 예외가 터졌을 때 스택 트레이스에 invokeSuspend, BaseContinuationImpl 같은 내부 구현체가 나온다. IntelliJ의 coroutine debugger 없이는 읽기 힘들다.
선택 기준
| 상황 | 권장 | 이유 |
|---|---|---|
| 기존 동기 코드에 "이 두 호출만 병렬로" | CompletableFuture | caller 변경 없음, 부분 실패가 기본 동작 |
| 실패하면 기본값("0")으로 대체, 나머지는 정상 | CompletableFuture | 별도 설정 없이 그대로 동작 |
| 4개 결과가 전부 필요 (하나라도 실패하면 전체 무의미) | 코루틴 coroutineScope | 자동 취소로 자원 낭비 방지 |
| 프로젝트 전체가 suspend/Flow 기반 | 코루틴 | 구조적 동시성 이점 극대화 |
| Spring WebFlux 환경 | 코루틴 | 이미 suspend 체인 존재 |
| 취소/타임아웃 로직이 복잡 | 코루틴 | withTimeout { }, 자동 취소 |
핵심: "코루틴이 더 좋냐"가 아니라 **"지금 이 서비스에서 실패를 어떻게 다루느냐"**가 판단 기준이다. "1곳 실패해도 나머지는 보여줘야 한다"면 CompletableFuture의 기본 동작이 바로 그것이다. "전부 성공해야 의미 있다"면 코루틴의 자동 취소가 오히려 이점이다.
3. MDC — 동시 요청 로그가 뒤섞일 때
문제: "느리다"는 아는데 "어떤 요청이" 느린지 모른다
elapsed 로그를 찍으면 이런 줄이 쌓인다:
[ExchangeA] getBalance elapsed=820ms
[ExchangeB] getBalance elapsed=1100ms
[Blockchain] accountGet elapsed=3001ms
[ExchangeA] getBalance elapsed=450ms
[ExchangeC] getAssets elapsed=2200ms
[Blockchain] accountGet elapsed=180ms
동시에 여러 요청이 들어오면 이 로그들이 뒤섞인다. 세 번째 줄의 accountGet 3001ms가 첫 번째 줄의 getBalance 820ms와 같은 요청인지, 네 번째 줄의 getBalance 450ms와 같은 요청인지 알 수 없다.
MDC(Mapped Diagnostic Context)란
SLF4J/Log4j2가 제공하는 스레드 로컬 key-value 저장소다.
스레드 로컬 (ThreadLocal)이란: 같은 변수인데 스레드마다 다른 값을 가지는 저장소다. A 스레드에서 넣은 값은 B 스레드에서 보이지 않는다. 요청마다 다른 스레드가 처리하는 웹 서버에서, "이 요청의 고유 ID"를 담기에 적합한 구조다.
핵심 아이디어: 요청이 들어올 때 MDC.put("tid", "260410074905")를 한 번 해두면, 그 스레드에서 찍는 모든 로그 라인에 자동으로 tid가 박힌다. 로깅 코드에서 매번 tid를 인자로 넘길 필요가 없다.
// Filter — 요청 시작 시 한 번만
MDC.put("tid", requestBean.tid)
try {
filterChain.doFilter(request, response)
} finally {
MDC.remove("tid") // 스레드 재사용 시 이전 tid 누출 방지
}
<!-- log4j2 pattern에 %X{tid} 추가 -->
<Property name="PATTERN">
%d %-5level [%t|%logger{2}|%X{tid}] %M:%L - %msg%n
</Property>
%X{tid}는 Log4j2의 문법으로 "현재 스레드의 MDC에서 tid 값을 꺼내서 여기에 출력해라"라는 뜻이다.
결과
... [tid=260410074905] [ExchangeA] getBalance elapsed=820ms
... [tid=260410074905] [ExchangeB] getBalance elapsed=1100ms
... [tid=260410074905] [Blockchain] accountGet elapsed=3001ms
... [tid=260410085512] [ExchangeA] getBalance elapsed=450ms
... [tid=260410085512] [ExchangeC] getAssets elapsed=2200ms
... [tid=260410085512] [Blockchain] accountGet elapsed=180ms
이제 grep "260410074905" service.log 한 줄이면 그 요청 하나의 거래소 3사 + 블록체인 + 전체 응답 시간을 한눈에 볼 수 있다.
함정: 병렬 worker 스레드에서 MDC가 사라진다
MDC는 스레드 로컬이다. CompletableFuture.supplyAsync로 작업을 다른 스레드에 넘기면, 그 worker 스레드는 request 스레드와 다른 스레드다. 다른 스레드의 MDC는 비어 있다. elapsed 로그에 tid가 안 찍힌다.
... [tid=260410074905] [요청 시작] ← request 스레드, tid 있음
... [tid=] [ExchangeA] elapsed=820ms ← worker 스레드, tid 없음!
... [tid=] [ExchangeB] elapsed=1100ms ← worker 스레드, tid 없음!
이러면 MDC를 넣은 의미가 없다.
해결: worker 진입 시 MDC 스냅샷 복사
fun <T> runAsync(task: () -> T): CompletableFuture<T> {
val mdc = MDC.getCopyOfContextMap() // 호출자(request 스레드)의 MDC를 복사해둔다
return CompletableFuture.supplyAsync({
val prev = MDC.getCopyOfContextMap() // worker의 기존 MDC 상태를 백업
mdc?.let { MDC.setContextMap(it) } // 호출자의 MDC를 덮어쓴다
try {
task() // 이 동안 찍히는 로그에 tid가 포함됨
} finally {
MDC.clear()
prev?.let { MDC.setContextMap(it) } // worker의 원래 상태로 복구
}
}, executor)
}
왜 prev를 백업하고 복원하는가?
스레드 풀에서는 worker 스레드가 재사용된다. A 요청의 작업이 끝난 뒤 같은 worker가 B 요청의 작업을 처리할 수 있다. 만약 A의 tid가 MDC에 남아 있으면 B의 로그에 A의 tid가 찍힌다. finally에서 원래 상태로 복원해야 이 누출을 막을 수 있다.
참고로, 뒤에서 소개할 가상 스레드 (newVirtualThreadPerTaskExecutor)는 작업마다 새 스레드를 만들어서 엄밀하게는 복원이 불필요하다. 하지만 나중에 executor를 바꿔도 안전하도록 방어적으로 넣었다.
4. commonPool vs 가상 스레드
먼저: 스레드(Thread)란
스레드는 프로그램 안에서 하나의 실행 흐름이다. 함수 하나를 실행하는 "일꾼"이라고 생각하면 된다.
컴퓨터의 CPU는 코어 수만큼 동시에 "정말로" 일을 할 수 있다. 8코어면 8개 일꾼이 동시에 일한다. 그보다 많은 스레드는 OS가 번갈아 가며 실행시켜서 동시에 도는 것처럼 보이게 한다.
문제는 스레드가 비싸다는 것이다:
- 하나당 기본 1MB 메모리를 차지한다 (스택 공간)
- OS 커널이 스레드 교체(컨텍스트 스위치)를 처리해야 해서 CPU 오버헤드가 있다
- 생성/삭제도 밀리초 단위로 시간이 든다
그래서 스레드를 매번 만들고 버리는 대신, 미리 n개를 만들어 두고 작업이 들어오면 재사용하는 게 스레드 풀 (Thread Pool)이다.
스레드 풀 — 비유: 카페 직원
카페에 직원(worker)이 3명이다. 주문(작업)이 들어오면 빈 직원이 처리한다.
[주문1] → 직원A가 처리 중 (커피 내리는 중)
[주문2] → 직원B가 처리 중 (음료 만드는 중)
[주문3] → 직원C가 처리 중 (포장 중)
[주문4] → 빈 직원 없음 → 대기줄에서 기다림
직원 3명이 전부 바쁘면 주문4는 누군가 끝날 때까지 대기한다.
commonPool이란
자바가 기본 제공하는 공용 스레드 풀이다. CompletableFuture.supplyAsync { ... }처럼 실행할 스레드 풀을 따로 지정하지 않으면 여기에 작업이 던져진다.
크기는 CPU 코어 수 - 1. 8코어 서버에서 약 7개.
설계 목적: 숫자 정렬이나 데이터 변환 같은 짧고 CPU를 쓰는 계산 작업이다. "0.001초 만에 끝나는 계산을 7개 동시에" — 이런 용도로 만들어진 것.
안티패턴(Antipattern)이란
"기술적으로 동작은 하지만, 쓰면 안 되는 잘못된 패턴"을 뜻하는 소프트웨어 용어다. 겉으로는 작동하기 때문에 문제를 모르고 지나치기 쉽다. 대신 특정 조건(부하, 동시성 등)에서 서비스 장애로 이어진다.
Blocking I/O란
외부와 데이터를 주고받을 때 (네트워크, 파일, DB 등) 응답이 올 때까지 현재 스레드가 멈춰서 기다리는 것을 blocking I/O라고 한다.
거래소 HTTP 호출을 예로 들면:
[HTTP 요청 전송 0.001초] → [응답 대기 3초] → [응답 수신 0.001초]
총 3초 중 2.998초는 아무 일 안 하고 기다리기만 한다. 이 동안 해당 스레드는 "블로킹"됨 — CPU는 안 쓰지만 스레드 자리를 차지한다.
비유: 전화해서 상대방이 받을 때까지 수화기 들고 가만히 기다리는 것. 그 사이 다른 일을 할 수 있는데 안 하고 서 있는 상태.
반대 개념은 Non-blocking I/O: 요청 보내놓고 즉시 돌아와서 다른 일을 하다가, 응답이 오면 알림을 받아 처리. 비유: 문자 보내놓고 답장 올 때까지 다른 볼일 보는 것.
commonPool에 blocking I/O를 던지면 생기는 문제
commonPool worker 7개:
요청 1: worker 3개 사용 (거래소 3사, 각 3~5초 대기)
→ 남은 worker 4개
요청 2: worker 3개 사용
→ 남은 worker 1개
요청 3: worker 3개 필요 → 1개만 남음 → 2개 task가 대기줄
→ 응답이 느려짐
요청 10: worker 30개 필요 → 대부분 대기줄
→ 전체 서비스가 멈춘 것처럼 느려짐
더 나쁜 점: commonPool은 JVM 전역 공유 자원이다. 자바의 parallelStream이나 다른 곳에서 쓰는 CompletableFuture.supplyAsync도 같은 7개 worker를 나눠 쓴다. 거래소 호출이 worker를 다 잡아먹으면 관계 없는 기능까지 느려진다.
JDK 공식 문서에서도 blocking I/O로 commonPool을 사용하는 건 antipattern이라고 명시하고 있다.
Java 21 가상 스레드
Java 21(2023년 9월 정식 출시)에서 추가된 경량 스레드다.
기존 스레드(플랫폼 스레드)는 OS 커널의 스레드와 1:1로 묶여 있다. 가상 스레드는 JVM이 자체 관리하는 스레드로, Carrier Thread 위에 올라타서 실행되다가 I/O 대기가 시작되면 Carrier에서 내려와 다른 가상 스레드에 자리를 양보한다.
Carrier Thread (캐리어 스레드)란: 가상 스레드를 실제로 실행해주는 플랫폼 스레드 (OS 스레드)다. 택시에 비유하면 이해하기 쉽다:
- 가상 스레드 = 승객
- Carrier Thread = 택시
- 승객(가상 스레드)이 택시(Carrier)를 타고 이동하다가, 목적지에서 내려서 기다리는 동안(I/O 대기) 택시는 다른 승객을 태우러 간다. 기다리던 일이 끝나면 빈 택시를 잡아서 다시 이동한다.
JVM이 내부적으로 Carrier Thread 풀(보통 CPU 코어 수만큼)을 관리하며, 가상 스레드는 이 풀 위에서 올라타고(mount) 내리는(unmount) 과정을 반복한다. 개발자가 직접 Carrier Thread를 관리할 필요는 없다.
private val virtualExecutor: Executor = Executors.newVirtualThreadPerTaskExecutor()
// 사용 — executor 인자만 추가
CompletableFuture.supplyAsync({ callExchangeA() }, virtualExecutor)
비유: 은행 창구와 대기실
플랫폼 스레드 (commonPool) = 은행 창구 7개
고객 한 명이 창구를 잡으면, 서류 작성하는 동안(= I/O 대기) 창구를 계속 점유한다. 다음 고객은 줄 서서 기다린다.
- 창구 7개, 고객 30명 → 23명이 대기줄
- 서류 작성이 3분 걸리면, 마지막 고객은 한참 뒤에야 순서가 온다
가상 스레드 = 번호표 시스템
고객이 서류 작성하러 가면(= I/O 대기) 창구에서 비켜서 대기실로 간다. 창구는 다음 고객이 쓴다. 서류 다 쓴 고객은 번호를 호출 받아 다시 창구로.
- 창구는 여전히 7개지만, 고객이 300명이어도 대기실에 앉아있으면 됨
- 창구를 "실제로 쓰는 시간"만 점유 → 서류 작성(I/O 대기) 시간엔 다른 고객이 쓰고 있음
- OS 입장에서는 7-8 스레드짜리 프로그램과 동일
수치 비교
| 항목 | 플랫폼 스레드 (commonPool) | 가상 스레드 |
|---|---|---|
| 기본 스택 메모리 | 1MB (선할당) | 수 KB (필요 시 동적 확장) |
| 10,000개 생성 시 메모리 | ~10GB | 수십~수백 MB |
| 생성 비용 | 밀리초 (OS 시스템 콜) | 마이크로초 이하 (힙 객체) |
| I/O 대기 시 | Carrier(OS 스레드) 점유 | Carrier 해제 (unmount) |
| 풀 크기 제한 | CPU 코어 수 - 1 | 없음 |
| 다른 기능에 영향 | JVM 전역 공유 → 영향 있음 | 독립 executor → 영향 없음 |
주의: Pinning
가상 스레드의 핵심은 "I/O 대기 시 Carrier Thread에서 내려오는 것"이다. 그런데 내려오지 못하는 상황이 있다. 이걸 Pinning (고정)이라고 한다.
대표적인 원인이 synchronized다.
synchronized란: 자바에서 **"이 코드 블록은 한 번에 하나의 스레드만 실행할 수 있다"**고 선언하는 키워드다. 여러 스레드가 같은 데이터를 동시에 수정하면 데이터가 꼬이기 때문에 사용하는 잠금(lock) 장치다.
synchronized (lock) {
// 이 안에서는 한 번에 하나의 스레드만 실행 가능
sharedData.update(value);
}
문제는 가상 스레드가 synchronized 블록 안에서 blocking I/O를 만났을 때 발생한다. 일반적으로 가상 스레드는 I/O 대기 시 Carrier Thread에서 내려오지만, synchronized 블록 안에서는 내려올 수 없다 — 잠금을 쥔 채로 내려오면 다른 스레드가 그 잠금을 획득할 수 없게 되기 때문이다. 그래서 Carrier Thread를 붙잡고 있게 된다. 플랫폼 스레드처럼 동작하는 셈이다.
비유: 택시에서 내려서 기다려야 하는데, 손에 다른 사람이 필요로 하는 열쇠를 쥐고 있어서 택시 안에서 기다릴 수밖에 없는 것. 택시가 묶이니 다른 승객을 태울 수 없다.
Pinning이 발생해도 기능은 정상 동작한다. 다만 가상 스레드의 이점 (Carrier 양보)이 사라지고 플랫폼 스레드처럼 자원을 점유하게 된다.
Pinning-free란: synchronized 대신 ReentrantLock 같은 잠금 방식을 써서 가상 스레드가 pinning 없이 자유롭게 Carrier에서 내려올 수 있게 설계된 것을 말한다. Java 21의 HttpClient는 내부적으로 pinning-free로 구현돼 있어 HTTP 호출에서는 이 문제가 발생하지 않는다. 다만 구식 JDBC 드라이버 중 synchronized를 많이 쓰는 경우에는 pinning이 발생할 수 있으니 주의가 필요하다.
적용
// Before: commonPool — worker 점유
CompletableFuture.supplyAsync { callExchangeA() }
// After: 가상 스레드 — I/O 대기 시 Carrier 해제
CompletableFuture.supplyAsync({ callExchangeA() }, virtualExecutor)
코드 변경은 executor 인자 하나 추가. 나머지(예외 처리, 반환값, 로깅)는 전부 동일하다. commonPool을 건드리지 않으니 기존에 commonPool을 쓰는 다른 코드에 영향도 없다.
5. 네 가지를 조합한 최종 구조
ParallelUtil
object ParallelUtil {
private val virtualExecutor: Executor = Executors.newVirtualThreadPerTaskExecutor()
// 단일 비동기 실행 + MDC 전파
fun <T> runAsync(task: () -> T): CompletableFuture<T> {
val mdc = MDC.getCopyOfContextMap()
return CompletableFuture.supplyAsync({ runWithMdc(mdc, task) }, virtualExecutor)
}
// 두 독립 작업 병렬 실행
fun <A, B> runPair(taskA: () -> A, taskB: () -> B): Pair<A, B> {
val fa = runAsync(taskA)
val fb = runAsync(taskB)
return try {
fa.join() to fb.join()
} catch (e: CompletionException) {
throw e.cause ?: e
}
}
private fun <T> runWithMdc(mdc: Map<String, String>?, task: () -> T): T {
val prev = MDC.getCopyOfContextMap()
mdc?.let { MDC.setContextMap(it) }
try { return task() }
finally {
MDC.clear()
prev?.let { MDC.setContextMap(it) }
}
}
}
설계 포인트:
virtualExecutor는private— 외부에서 직접 접근하지 않고runAsync/runPair로만 사용. 나중에 executor 구현을 바꿔도 호출부 수정 없음.runWithMdc가 MDC 복사/복원을 한 곳에서 처리 — 호출부마다 보일러플레이트를 반복하지 않음.CompletionException언래핑 —join()이 감싸는 래퍼를 벗겨서 호출자가 원본 예외를 받도록.
호출부
// 거래소 3사 병렬 (각각 가상 스레드)
futures["A"] = ParallelUtil.runAsync { callExchangeA() }
futures["B"] = ParallelUtil.runAsync { callExchangeB() }
futures["C"] = ParallelUtil.runAsync { callExchangeC() }
// 상위: 거래소 블록 + 블록체인을 또 병렬
val (balanceMap, onchainBalance) = ParallelUtil.runPair(
taskA = { getMarketBalances() }, // 내부에서 3사 병렬
taskB = {
try { blockchainService?.getAccount(addr)?.balance }
catch (e: Exception) { null } // 실패 시 "0" 폴백
}
)
거래소 3사가 가상 스레드 3개, 상위 병렬이 가상 스레드 2개 — 총 5개. 가상 스레드는 수천 개 띄워도 되니까 이 정도 중첩은 아무 문제 없다. commonPool이었다면 5개 worker가 각각 수 초씩 blocking → 고부하에서 풀 고갈.
배운 것
runCatching과try/catch/finally는 용도가 다르다. 반환값 변환은 runCatching이 간결하지만, "지역 상태를 참조하면서 성공/실패 모두에서 반드시 실행"이 필요하면 finally가 자연스럽다고 생각했다.- 코루틴이 항상 정답은 아니다. 구조적 동시성의 자동 취소가 오히려 방해되는 경우도 있다 (부분 실패 허용 시나리오). 기존 동기 코드에서 한 곳만 병렬화하는데 suspend 체인 전체를 바꾸는 건 과한 투자라고 생각했다.
- commonPool에 blocking I/O를 던지지 않는 게 좋다. CPU 코어 수 - 1개라는 제한된 풀에 수 초짜리 네트워크 대기를 넣으면 서비스 전체가 느려질 수 있다. JDK 공식 문서에도 antipattern으로 명시돼 있다.
- 가상 스레드는 "동기 코드를 쓰면서 비동기의 이점"을 얻는 방법이다. 코루틴처럼 문법을 바꿀 필요 없이, executor만 바꾸면 I/O 대기에서 플랫폼 스레드를 놓아준다.
- MDC는 스레드 로컬이라 병렬 worker에 자동 전파되지 않는다. 명시적으로 복사해야 한다. 빠뜨리면 동시 요청 로그가 뒤섞여서 장애 분석이 불가능해진다.
- 관측성이 먼저, 최적화는 그 다음이다. elapsed 로그 없이 "이게 병목일 것 같다"로 병렬화하면 효과를 검증할 수 없다. 로그부터 넣고, 수치를 보고, 그 다음에 판단하는 순서가 낫다고 생각했다.