비동기 / 코루틴

arch-async pattern-coroutine pattern-fire-and-forget pattern-parallel-call

이 노트가 답하는 질문

  • 동기 REST 컨트롤러 위에서 어떻게 “비동기”를 구현하는가?
  • runBlocking(블로킹 경계)은 정확히 어디에 있고, 왜 거기에 있어야 하나?
  • 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-60kotlinx-coroutines-corekotlinx-coroutines-slf4j(= MDCContext 제공)가 선언돼 있다. MDCContext는 로깅 추적(MDC)을 코루틴 경계 너머로 전파하기 위한 핵심 조각이다(support-common 참고).


2. 모든 코루틴의 입구: CoroutineExtensions.kt

이 파일(support/util/CoroutineExtensions.kt)이 전체 코루틴 사용의 단일 관문이다. 11개 공급사 서비스는 kotlinx.coroutines를 직접 부르지 않고 거의 항상 이 래퍼들을 거친다. 래퍼는 매번 동일한 CoroutineContext 3종 세트를 자동 주입한다.

래퍼시그니처 위치주입하는 컨텍스트반환용도
withBlocking:13SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext()T (값)블로킹 경계. runBlocking 래핑
withLaunch:20SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext()Jobfire-and-forget
withAsync:28SupervisorJob() + MDCContext()Deferred<T>병렬 작업 1건 (pmap 내부 전용)
pmap:36(coroutineScope 상속)AsyncResults<R>컬렉션 병렬 map
onFailure:52(coroutineScope 상속)AsyncResults<T>실패 후처리
// CoroutineExtensions.kt:13-18
fun <T> withBlocking(
    context: CoroutineContext = EmptyCoroutineContext,
    block: suspend CoroutineScope.() -> T
) = runBlocking(context + SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext()) {
    block()
}

왜 항상 SupervisorJob()인가?

기본 Job자식 하나가 실패하면 형제 전부를 취소한다(부모-자식 양방향 전파). SupervisorJob은 실패를 위로 전파하지 않아 형제 코루틴이 살아남는다. 병렬 검색(pmap)에서 공급사 한 노선(origin-dest 조합)이 실패해도 나머지 조합 결과는 살리겠다는 설계 의도다. 이 의도는 pmap 안에서 try/catch로 한 번 더 보강된다(아래 4절).

withAsync만 예외 핸들러가 없다

withBlocking/withLaunchAdapterCoroutineExceptionHandler()를 주입하지만 withAsync(:28-34)주입하지 않는다. 이유는 withAsyncDeferred를 만들고, Deferred의 예외는 핸들러로 가지 않고 await() 시점에 다시 던져지기 때문(코루틴 표준 동작). 그래서 pmapwithAsync 내부에서 직접 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개를 병렬로 쏘더라도, withBlockingawaitAll이 끝날 때까지 호출 스레드를 점유한다. 즉 “비동기 검색”이라기보다 **“동기 함수 안에서의 내부 병렬화(fan-out/fan-in)“**다. 응답 지연 = 가장 느린 조합 1건의 지연(직렬 합이 아님).

3.2 pmap의 내부 — 예외를 값으로 바꾸는 게 핵심

// CoroutineExtensions.kt:36-50
suspend 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 {} 호출이 실제로 존재한다.

AmadeusBookingService.kt:134     withBlocking { ... delay(3000) ... }
AmadeusndcTicketingService.kt:57  withBlocking { ... }
GalileoBookingService.kt:82 / GalileoCancelService.kt:175
SabreBookingService.kt:108 / SabreCancelService.kt:422,481
KoreanairTicketingService.kt:114 / LufthansaAncillaryService.kt:33,65

runBlocking()에 디스패처가 없으면 호출 스레드(= 요청 스레드)에서 그대로 실행된다(Dispatchers.IO로 점프하지 않음). 위 사례들은 대부분 stateful PNR 세션 안에서 delay()로 잠깐 쉬었다가 단일 호출을 하는 용도라(예: AmadeusBookingService.kt:134-138delay(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 { ... }
    }
}

대표 사용처:

사용처위치무엇을 던지나
Slack 경보 발송SlackClient.kt:27알림이 느려도/실패해도 본 흐름 무영향 → resilience-and-events
검색 어메니티 저장SabreFlightSearchService.kt:111-118응답엔 불필요한 부가데이터를 Redis에 비동기 적재
미노출 운임 저장 / 키 삭제AmadeusBookingService.kt:192, 198예약 실패 후 캐시 보정
PNR 비동기 취소AmadeusCancelService.kt:311 (pnrCancelAsync)delay(5000) 후 취소 (아래 danger)
결제 비동기 취소AmadeusCancelService.kt:427 (paymentCancelAsync)보상 트랜잭션
비동기 폴링 작업PollingUtils.kt:21 (polling)긴 작업을 백그라운드 실행, 결과는 Redis로 (아래 5절)
Jinair 결제 오류 메시지 조회JinairClient.kt:484, 1018예외를 던지기 직전 부가 로그 수집

4.1 보상 트랜잭션(SAGA-lite)의 뼈대

예약/발권 실패 시 “이미 만든 PNR을 비동기로 취소”하는 패턴이 전 공급사에 퍼져 있다(pnrCancelAsync, paymentCancelAsync, cancelAsync). 호출 측은 동기 흐름에서 예외를 던지면서, 정리 작업만 백그라운드로 분리한다.

// AmadeusBookingService.kt:179 (예약 실패 catch 블록 안)
pnr?.run { cancelService.pnrCancelAsync(this) }   // 취소는 비동기로
throw e                                            // 사용자에겐 즉시 실패 응답

pnrCancelAsyncdelay(5000) — 타이밍 의존 코드

// AmadeusCancelService.kt:311-316
fun pnrCancelAsync(pnr: String) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        delay(5000)          // locked pnr 방지
        pnrCancelRepeat(pnr)
    }
}

방금 만든 PNR이 GDS 측에서 “lock” 상태일 수 있어 5초 기다렸다 취소한다. 함정: ① 이 코루틴은 요청 스레드와 수명이 분리돼 있어, 컨테이너가 5초 안에 종료되거나 재배포되면 취소가 영영 안 일어난다(고아 PNR). ② delay는 협력적 일시정지라 withLaunch가 만든 Job이 취소되면 즉시 중단된다 — 하지만 여기 CoroutineScope부모가 없는 GlobalScope-유사 스코프라 취소 주체가 없다(아래 5.2).


5. fire-and-forget의 구조적 위험과 폴링 패턴

5.1 CoroutineScope(Dispatchers.IO) 직접 생성 = 스코프 누수 위험

CoroutineScope(Dispatchers.IO).withLaunch { ... }   // 60+곳에서 반복

이 스코프는 누구의 자식도 아니다

매번 new CoroutineScope를 만들고 Job을 보관하지 않는다. 즉 구조화된 동시성(structured concurrency) 밖이다.

  • 요청이 끝나도/취소돼도 이 코루틴은 계속 산다 (장점: fire-and-forget 의도, 단점: 취소·생명주기 제어 불가).
  • 애플리케이션 셧다운 시 graceful하게 기다려주지 않는다 → 진행 중이던 보상/저장 작업 유실 가능.
  • 무한정 쌓이면(폭주 트래픽) Dispatchers.IO 풀(기본 64) 포화 → 백그라운드 작업 지연.

이것이 “메시지큐 없이” 이벤트성 후처리를 구현한 대가다. 진짜 신뢰성이 필요한 후처리(돈 관련)는 PollingUtils.polling처럼 Redis에 상태를 남겨 복구 가능성을 확보하는 쪽이 안전하다.

5.2 비동기 폴링: polling + poller + Redis DeferredResult

긴 작업(발권 등)을 즉시 응답 못 줄 때 쓰는 패턴. fire-and-forget으로 작업을 돌리되 결과를 Redis에 남겨 폴링으로 회수한다.

// PollingUtils.kt:12-40
fun <R> polling(key, ttl, redisTemplate, init: () -> R): String {
    val deferredKey = "ADAPTER-DEFERRED-RESULT::$key"
    redisTemplate.opsForValue().set(deferredKey, DeferredResult.pending(), ttl)  // ① PENDING 선기록
    CoroutineScope(Dispatchers.IO).withLaunch {
        runCatching { init() }                                                   // ② 백그라운드 실행
            .onSuccess { redisTemplate...set(COMPLETE) }                         // ③ 성공 기록
            .onFailure { redisTemplate...set(ERROR(it)) }                        // ④ 실패도 기록
    }
    return key                                                                   // ⑤ 즉시 키 반환
}
// poller(:43)는 나중에 Redis에서 DeferredResult를 읽어 ERROR면 throwable 재던짐(:47)
sequenceDiagram
    participant R1 as "요청1"
    participant RD as "Redis"
    participant BG as "백그라운드 작업"
    participant R2 as "요청2"
    R1->>RD: "polling(key) — PENDING 기록"
    R1->>BG: "백그라운드 작업 시작"
    R1-->>R1: "key 즉시 반환"
    BG->>RD: "완료 시 COMPLETE/ERROR 기록"
    R2->>RD: "poller(key) — Redis 조회"
    RD-->>R2: "ERROR면 throwable 재던짐"

여기서 runCatchingAdapterCoroutineExceptionHandler를 대신한다

withLaunch는 핸들러를 주입하지만, polling은 예외를 로깅이 아니라 Redis에 저장해 호출자에게 되돌려줘야 하므로 runCatching으로 먼저 잡는다. 잡힌 예외는 핸들러까지 가지 않는다(이미 소비됨).


6. 코루틴 예외 전파 — AdapterCoroutineExceptionHandler

withBlocking/withLaunch가 던진(잡지 않은) 예외의 최종 종착지.

// support/exception/AdapterCoroutineExceptionHandler.kt:15-25
override fun handleException(context: CoroutineContext, exception: Throwable) {
    BeanUtils.getBean(RestExceptionHandler::class.java)?.also { restExceptionHandler ->
        with(restExceptionHandler) {
            exception.findRootCause()              // 원인 체인 끝까지 (RestExceptionHandler.kt:108)
                .also { logger.error(it.message, it) }
                .also { it.sentryLog() }           // Sentry 캡처 (RestExceptionHandler.kt:82)
        }
    }
}
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으로 빈을 직접 꺼내나

AdapterCoroutineExceptionHandlernew로 매번 생성되는 컨텍스트 요소(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 내부 작업 throwAsyncFail로 포착 → successes/exceptions 분리getOrEmpty()로 받으면 실패가 조용히 사라짐
pollinginit() throwrunCatching → 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을 직접 쓴다.

// SabrePassengerService.kt:12-71 (요약)
fun changeApis(pnr, validatingCarrier, passengers): List<Passenger> {
    val token = sabreClient.getSessionToken()
    try {
        return runBlocking {                          // ← 직접 runBlocking
            val booking = sabreClient.getBooking(token, pnr)
            val changePassengers = passengers.map { ... }   // 일반 동기 map (병렬 아님)
            ...
            consecutiveNumbersToRange(legacySsrIds)?.forEach { sabreClient.deleteSsr(...) }  // 순차 삭제
            sabreClient.createApis(...); sabreClient.endTransaction(token)
            sabreClient.getBooking(token, pnr).passengers
        }
    } finally {
        sabreClient.closeSessionToken(token)          // ← 세션은 반드시 닫음
    }
}

runBlocking은 병렬화가 아니라 "세션 일관성"용이다

내부 로직은 전부 순차 실행(forEach, 단일 호출 체인)이다. 병렬 의도가 전혀 없는데 왜 runBlocking? — 이유는 stateful 세션 안에서 일련의 SOAP 호출을 한 묶음으로 묶고, 코드 주석(:73-74)처럼 **삭제 순서(큰 id부터 역순)**가 깨지면 안 되기 때문. runBlocking은 컨텍스트(예외 핸들러/MDC) 주입 없이 가장 단순하게 블로킹 경계만 친다. 함정 두 가지:

  1. token 생명주기: getSessionToken() → 반드시 finallycloseSessionToken(token)로 닫아야 한다. runBlocking 안에서 예외가 나도 finally는 실행되므로 세션 누수는 막힌다. 이 try/finally를 빼면 GDS 세션이 고갈된다 → sabre-operations, sabre-pitfalls.
  2. withBlocking이 아니라 runBlocking이라 AdapterCoroutineExceptionHandler/MDCContext가 안 붙는다. 예외는 runBlocking이 호출 스레드로 그대로 재던지므로 상위 RestExceptionHandler(MVC 레벨)가 받는다. 의도된 동작이지만, “왜 여기만 코루틴 로그 추적이 다르지?”의 답이 이것.

9. 신입용 체크리스트 (코루틴을 손댈 때)

새 비동기 코드 작성/수정 전 자문

  1. 이건 병렬 I/O인가(→ withBlocking(Dispatchers.IO) + pmap) 아니면 던지고 잊기인가(→ withLaunch)?
  2. pmap 결과를 getOrEmpty(관대)로 받을지 getOrThrow(엄격)로 받을지 — 돈/문서면 엄격.
  3. withBlocking디스패처를 명시했나? 병렬 I/O인데 빠뜨리면 직렬화된다.
  4. fire-and-forget 작업이 셧다운 시 유실돼도 되는가? 안 되면 polling+Redis로.
  5. 예외가 어디로 가는지 추적했나? (launch→핸들러, asyncawait지점, runCatching→소비됨)
  6. 세션/토큰을 여는 코드면 finally에서 닫는가? (Sabre·Amadeus stateful)

연습 문제


교차 참조

  • error-handlingRestExceptionHandler / findRootCause / sentryLog 전체 파이프라인
  • support-commonMDCContext/MDC 전파, BeanUtils, CollectionUtils.cartesianProduct
  • resilience-and-events — Slack 경보·보상 트랜잭션이 “이벤트 전파”를 대신하는 방식
  • sabre-operationsSabrePassengerService의 stateful 세션·토큰 수명
  • Sabre GDS API 분석 — SOAP 세션 모델 배경