11개 공급사가 똑같이 쓰는 withBlocking + pmap + awaitAll 병렬 호출 패턴의 정체
코루틴에서 던진 예외는 어디로 사라지는가? (AdapterCoroutineExceptionHandler)
신입이 가장 자주 밟는 코루틴 지뢰 — 예외 유실, 디스패처 누락, 취소 전파
1. 큰 그림: 동기 세계 위에 얹은 얇은 코루틴 층
air-intl-adapter는 본질적으로 블로킹 동기 서비스다. 컨트롤러(...interfaces/controller/internals/{Name}{Op}Controller)는 일반 Spring MVC(서블릿/Tomcat 스레드) 위에서 동작하고, 외부 API 클라이언트(SOAP/REST)는 전부 blocking 호출이다. 코드베이스 전체에서 suspend fun은 단 두 개뿐이다.
support/util/CoroutineExtensions.kt:36 suspend fun <T, R> Iterable<T>.pmap(...)
support/util/CoroutineExtensions.kt:52 suspend fun <T> AsyncResults<T>.onFailure(...)
즉, 이 시스템에서 코루틴은 “엔드투엔드 논블로킹”이 아니라 두 가지 좁은 목적으로만 쓰인다.
flowchart TD
T["Tomcat 요청 스레드 (블로킹)"]
T --> A["목적 A — 병렬 호출<br/>withBlocking(IO) 안에서 pmap<br/>여러 외부호출 동시 실행<br/>요청 스레드는 여기서 대기"]
T --> B["목적 B — fire-and-forget<br/>CoroutineScope(IO).withLaunch<br/>요청 응답과 무관한 뒷정리/알림<br/>요청 스레드는 즉시 반환"]
코루틴 라이브러리 의존성
build.gradle.kts:59-60 에 kotlinx-coroutines-core 와 kotlinx-coroutines-slf4j(= MDCContext 제공)가 선언돼 있다. MDCContext는 로깅 추적(MDC)을 코루틴 경계 너머로 전파하기 위한 핵심 조각이다(support-common 참고).
2. 모든 코루틴의 입구: CoroutineExtensions.kt
이 파일(support/util/CoroutineExtensions.kt)이 전체 코루틴 사용의 단일 관문이다. 11개 공급사 서비스는 kotlinx.coroutines를 직접 부르지 않고 거의 항상 이 래퍼들을 거친다. 래퍼는 매번 동일한 CoroutineContext 3종 세트를 자동 주입한다.
기본 Job은 자식 하나가 실패하면 형제 전부를 취소한다(부모-자식 양방향 전파). SupervisorJob은 실패를 위로 전파하지 않아 형제 코루틴이 살아남는다. 병렬 검색(pmap)에서 공급사 한 노선(origin-dest 조합)이 실패해도 나머지 조합 결과는 살리겠다는 설계 의도다. 이 의도는 pmap 안에서 try/catch로 한 번 더 보강된다(아래 4절).
withAsync만 예외 핸들러가 없다
withBlocking/withLaunch는 AdapterCoroutineExceptionHandler()를 주입하지만 withAsync(:28-34)는 주입하지 않는다. 이유는 withAsync가 Deferred를 만들고, Deferred의 예외는 핸들러로 가지 않고 await() 시점에 다시 던져지기 때문(코루틴 표준 동작). 그래서 pmap은 withAsync 내부에서 직접 try/catch로 잡아 AsyncFail로 변환한다. 이 비대칭을 모르면 “왜 withAsync 예외는 Sentry에 안 찍히지?”로 헤맨다.
3. 목적 A — 병렬 외부 호출: withBlocking(IO) { ... pmap { } }
3.1 표준 검색 패턴 (11개 공급사 공통)
거의 모든 {Supplier}FlightSearchService.search()가 글자 그대로 같은 골격을 쓴다. 대표 예시는 Amadeus / Sabre 검색 서비스다.
// AmadeusFlightSearchService.kt:58-79 (Sabre도 동일 구조: SabreFlightSearchService.kt:67-88)withBlocking(Dispatchers.IO) { AirportUtils.makeOriginDestinations(originDestinationLocationInfos) .cartesianProduct() // ① 노선 조합 펼치기 .pmap { originDestRequests -> // ② 조합마다 동시 외부 호출 amadeusClient.search( ... ) }.onFailure { exceptions, successes -> // ③ 전부 실패면 예외 if (successes.isEmpty()) { throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first()) } }.getOrEmpty() // ④ 성공분만 추출}.flatten()
flowchart TD
M["makeOriginDestinations + cartesianProduct()"] --> L["조합1, 조합2, 조합3, ..."]
subgraph WB["withBlocking(Dispatchers.IO) — 요청 스레드는 여기서 블로킹 대기"]
L --> P["pmap 으로 withAsync(IO) 동시 실행"]
P --> S1["search 조합1"]
P --> S2["search 조합2"]
P --> S3["search 조합3"]
S1 --> AW["awaitAll() — 전부 끝날 때까지 모음"]
S2 --> AW
S3 --> AW
end
AW --> R["AsyncResults(successes, exceptions)"]
블로킹 경계는 정확히 withBlocking 한 줄이다
요청 스레드(Tomcat) 입장에서 withBlocking { ... } 호출은 완전히 동기다. 내부에서 N개를 병렬로 쏘더라도, withBlocking은 awaitAll이 끝날 때까지 호출 스레드를 점유한다. 즉 “비동기 검색”이라기보다 **“동기 함수 안에서의 내부 병렬화(fan-out/fan-in)“**다. 응답 지연 = 가장 느린 조합 1건의 지연(직렬 합이 아님).
3.2 pmap의 내부 — 예외를 값으로 바꾸는 게 핵심
// CoroutineExtensions.kt:36-50suspend fun <T, R> Iterable<T>.pmap( block: suspend CoroutineScope.(T) -> R): AsyncResults<R> = coroutineScope { // ← 구조화된 동시성 경계 val results = map { withAsync<AsyncMapResult<R>> { try { AsyncSuccess(block(it)) // 성공 → 값 래핑 } catch (e: Exception) { AsyncFail(e) // 실패 → 예외를 "값"으로 포착 } } }.awaitAll() // ← 전부 대기 (병렬) AsyncResults.of(results) // 성공/실패 분리 (AsyncMapResult.kt:25)}
설계의 핵심은 각 작업의 예외를 던지지 않고 AsyncFail이라는 값으로 바꾼다는 것이다. 만약 try/catch 없이 그냥 던졌다면, awaitAll()은 첫 번째 실패가 다른 작업을 취소시키고 그 예외를 재던져 부분 성공이 사라진다. 이 패턴 덕분에 “조합 5개 중 3개 성공”을 그대로 살린다. 결과 분리는 AsyncResults.of(AsyncMapResult.kt:25-31)가 partition으로 수행한다.
후처리 헬퍼
위치
동작
onFailure {}
:52-60
실패가 1건이라도 있으면 콜백 실행(검색은 “전부 실패면 throw”)
getOrEmpty()
:62
성공분 리스트만(successes) 반환, 실패는 무시
getOrThrow()
:64-68
실패가 있으면 exceptions.first()를 즉시 throw
두 가지 실패 정책의 분기점
검색(Search) → getOrEmpty(): 일부 노선 실패해도 나머지 운임은 노출 (“관대”). onFailure로 “전부 실패”일 때만 막는다.
현금영수증/환불(CashReceipt/Refund) → getOrThrow() (AmadeusCashReceiptService.kt:72, AmadeusRefundService.kt:74, GalileoFareRuleService.kt:87, GalileoCashReceiptService.kt:68): 돈/문서가 걸린 작업은 한 건이라도 실패하면 전체 실패로 간주 (“엄격”).
같은 pmap 결과지만 종결 헬퍼 선택이 비즈니스 안전성을 결정한다. 신규 오퍼레이션 추가 시 반드시 의식적으로 고를 것.
3.3 디스패처: Dispatchers.IO를 왜 명시하나
pmap 안의 block(= 공급사 클라이언트 호출)은 전부 블로킹 I/O(SOAP/HTTP)다. Dispatchers.IO는 블로킹 호출 전용 풀(기본 64 스레드)로, 스레드가 I/O 대기로 묶여도 다른 작업이 굶지 않게 한다.
withBlocking {} 에 디스패처를 빼면 어디서 도느냐
검색은 withBlocking(Dispatchers.IO)지만, 디스패처 없는 withBlocking {} 호출이 실제로 존재한다.
runBlocking()에 디스패처가 없으면 호출 스레드(= 요청 스레드)에서 그대로 실행된다(Dispatchers.IO로 점프하지 않음). 위 사례들은 대부분 stateful PNR 세션 안에서 delay()로 잠깐 쉬었다가 단일 호출을 하는 용도라(예: AmadeusBookingService.kt:134-138의 delay(3000) 후 PnrInfo 재조회 — 항공사 시간제한 갱신 대기) 의도적으로 같은 스레드를 잡는다. 하지만 “검색처럼 병렬”인데 디스패처를 빼면 모든 withAsync가 단일 스레드에서 협력적으로만 도므로 진짜 병렬 I/O가 안 나고 직렬에 가깝게 느려진다. 새 코드 작성 시 “병렬 I/O면 반드시 Dispatchers.IO”를 규칙으로.
4. 목적 B — fire-and-forget: CoroutineScope(Dispatchers.IO).withLaunch { }
요청 응답과 무관한 뒷정리/알림/보정을 즉시 백그라운드로 던지고 컨트롤러는 바로 반환하는 패턴. 코드베이스에서 가장 많은 코루틴 사용처다(60+ 건).
// SlackClient.kt:25-37 — 알림은 절대 본 흐름을 막지 않는다fun send(channel: String, blocks: List<LayoutBlock>) { if (active.not()) return CoroutineScope(Dispatchers.IO).withLaunch { // ← 던지고 잊음 slack.chatPostMessage { ... } }}
방금 만든 PNR이 GDS 측에서 “lock” 상태일 수 있어 5초 기다렸다 취소한다. 함정: ① 이 코루틴은 요청 스레드와 수명이 분리돼 있어, 컨테이너가 5초 안에 종료되거나 재배포되면 취소가 영영 안 일어난다(고아 PNR). ② delay는 협력적 일시정지라 withLaunch가 만든 Job이 취소되면 즉시 중단된다 — 하지만 여기 CoroutineScope는 부모가 없는 GlobalScope-유사 스코프라 취소 주체가 없다(아래 5.2).
5. fire-and-forget의 구조적 위험과 폴링 패턴
5.1 CoroutineScope(Dispatchers.IO) 직접 생성 = 스코프 누수 위험
flowchart TD
E["미처리 예외<br/>(withLaunch/withBlocking 내부)"]
E --> H["AdapterCoroutineExceptionHandler.handleException"]
H --> B["BeanUtils.getBean(RestExceptionHandler)<br/>코루틴엔 DI 주입이 안 되므로 정적 룩업"]
B --> RC["findRootCause()"]
RC --> LG["logger.error"]
LG --> SL["sentryLog() — Sentry 전송"]
왜 BeanUtils.getBean으로 빈을 직접 꺼내나
AdapterCoroutineExceptionHandler는 new로 매번 생성되는 컨텍스트 요소(Spring 빈이 아님)라 생성자 주입을 못 받는다. 그래서 ApplicationContextAware로 컨텍스트를 정적 보관한 BeanUtils(support/util/BeanUtils.kt:21-35)에서 런타임에 RestExceptionHandler를 꺼낸다. 자세한 예외 파이프라인은 error-handling 참고.
CoroutineExceptionHandler는 launch에만 작동한다 — async엔 무력
코루틴 표준 규칙: CoroutineExceptionHandler는 루트 코루틴이 launch(또는 runBlocking)일 때만 동작한다. async/Deferred의 예외는 핸들러를 무시하고 await() 호출 지점에서 다시 던져진다. 그래서:
withLaunch(launch 기반) → 핸들러 작동 → Sentry 자동 기록 ✅
withBlocking(runBlocking 기반, 자식이 throw) → 핸들러보다 runBlocking 자체가 먼저 재던짐 → 요청 스레드로 전파됨 ✅
withAsync(async 기반) → 핸들러 안 탐 → pmap 내부 try/catch가 없으면 예외 유실/엉뚱한 전파 ⚠️
이 비대칭이 코드에 그대로 반영돼 withAsync(:28)만 핸들러 미주입 + pmap(:42-46)이 직접 try/catch한다(3절).
6.1 예외 유실(silent swallow) 시나리오 정리
시나리오
예외는 어디로?
위험
withLaunch {} 내부 throw
핸들러 → 로그+Sentry. 호출자는 모름
의도된 동작이나, fire-and-forget 작업 실패를 호출자가 인지 못함
pmap 내부 작업 throw
AsyncFail로 포착 → successes/exceptions 분리
getOrEmpty()로 받으면 실패가 조용히 사라짐
polling의 init() throw
runCatching → Redis ERROR 저장
poller를 호출해야만 노출됨. 호출 안 하면 유실
withAsync 직접 사용(가상)
핸들러 안 탐, await 안 하면 영영 안 던짐
그래서 pmap 밖에서 withAsync 직접 사용 금지
7. 디스패처/스레딩 모델 한눈에
flowchart TD
subgraph TW["Tomcat Worker Thread (Spring MVC, 블로킹)"]
CS["Controller 에서 Service 로"]
CS --> A["withBlocking(Dispatchers.IO) 안에서 pmap"]
A --> A2["Dispatchers.IO 풀(기본 64)에서 N개 병렬 I/O<br/>요청 스레드는 awaitAll 까지 블로킹 대기"]
CS --> B["withBlocking 디스패처 없음"]
B --> B2["요청 스레드에서 그대로 실행 (delay 등)"]
CS --> C["CoroutineScope(Dispatchers.IO).withLaunch"]
C --> C2["분리된 스코프, 요청 스레드 즉시 반환<br/>생명주기 비관리 — 셧다운 시 유실 가능"]
end
TW --> MDC["MDCContext 가 위 경계 전부에 MDC(추적 로그) 전파"]
MDCContext 없으면 추적이 끊긴다
Dispatchers.IO로 점프하면 스레드가 바뀌어 SLF4J MDC(요청ID, supplier, fareKey 등)가 사라진다. 모든 래퍼가 MDCContext()를 컨텍스트에 넣어 코루틴 시작 시점의 MDC 스냅샷을 새 스레드에 복제한다. 누락하면 로그/Sentry에서 요청 상관관계가 끊긴다(support-common).
8. Sabre의 특수 사례 — SabrePassengerService.runBlocking
대상 시스템 맥락대로 코루틴 사용이 집중된 또 한 곳. 단, 여기선 withBlocking 래퍼가 아니라 runBlocking을 직접 쓴다.
내부 로직은 전부 순차 실행(forEach, 단일 호출 체인)이다. 병렬 의도가 전혀 없는데 왜 runBlocking? — 이유는 stateful 세션 안에서 일련의 SOAP 호출을 한 묶음으로 묶고, 코드 주석(:73-74)처럼 **삭제 순서(큰 id부터 역순)**가 깨지면 안 되기 때문. runBlocking은 컨텍스트(예외 핸들러/MDC) 주입 없이 가장 단순하게 블로킹 경계만 친다.
함정 두 가지:
token 생명주기: getSessionToken() → 반드시 finally의 closeSessionToken(token)로 닫아야 한다. runBlocking 안에서 예외가 나도 finally는 실행되므로 세션 누수는 막힌다. 이 try/finally를 빼면 GDS 세션이 고갈된다 → sabre-operations, sabre-pitfalls.
withBlocking이 아니라 runBlocking이라 AdapterCoroutineExceptionHandler/MDCContext가 안 붙는다. 예외는 runBlocking이 호출 스레드로 그대로 재던지므로 상위 RestExceptionHandler(MVC 레벨)가 받는다. 의도된 동작이지만, “왜 여기만 코루틴 로그 추적이 다르지?”의 답이 이것.
9. 신입용 체크리스트 (코루틴을 손댈 때)
새 비동기 코드 작성/수정 전 자문
이건 병렬 I/O인가(→ withBlocking(Dispatchers.IO) + pmap) 아니면 던지고 잊기인가(→ withLaunch)?
pmap 결과를 getOrEmpty(관대)로 받을지 getOrThrow(엄격)로 받을지 — 돈/문서면 엄격.
withBlocking에 디스패처를 명시했나? 병렬 I/O인데 빠뜨리면 직렬화된다.
fire-and-forget 작업이 셧다운 시 유실돼도 되는가? 안 되면 polling+Redis로.
세션/토큰을 여는 코드면 finally에서 닫는가? (Sabre·Amadeus stateful)
연습 문제
Q1. pmap이 withAsync 안에서 try/catch로 예외를 AsyncFail로 바꾸지 않고, 그냥 block(it)을 호출하면 무슨 일이 벌어지나?
정답 보기
awaitAll()이 첫 번째 실패 Deferred의 예외를 재던지면서 coroutineScope가 나머지 형제 코루틴을 전부 취소한다(구조화된 동시성). 결과적으로 부분 성공이 모두 사라지고, 검색은 한 노선만 실패해도 전체가 SEARCH_FAILED로 죽는다. 또 withAsync엔 CoroutineExceptionHandler가 없어 Sentry 자동 기록도 안 된다. → 그래서 일부러 예외를 “값”(AsyncFail)으로 변환한다.
Q2. 검색 서비스는 getOrEmpty(), 현금영수증 서비스는 getOrThrow()를 쓴다. 이 선택을 반대로 하면 각각 어떤 사고가 나나?
정답 보기
검색에 getOrThrow() → 노선 조합 하나라도 실패하면 전체 검색이 실패. 가용 운임이 있는데도 사용자에게 “검색 실패”가 뜬다(매출 손실, 가용성 저하).
현금영수증/환불에 getOrEmpty() → 일부 건이 조용히 누락된 채 성공 처리. 돈/세금 문서가 빠진 줄 모르고 넘어가 정산 사고로 이어진다. 그래서 돈 관련은 반드시 엄격(getOrThrow).
Q3. pnrCancelAsync(AmadeusCancelService.kt:311)의 delay(5000) 동안 애플리케이션이 재배포(셧다운)되면?
정답 보기
CoroutineScope(Dispatchers.IO)는 어떤 부모(요청/애플리케이션 생명주기)에도 묶이지 않은 분리 스코프라, 셧다운 시 graceful하게 대기되지 않는다. 5초 타이머가 만료되기 전에 프로세스가 죽으면 취소가 영영 실행되지 않아 고아 PNR이 남는다. 신뢰성이 필요하면 Redis 기반 재시도 큐/polling 같은 영속 상태가 필요하다. 이런 미보장 후처리가 메시지큐 부재의 비용 → resilience-and-events.
교차 참조
error-handling — RestExceptionHandler / findRootCause / sentryLog 전체 파이프라인