에러 처리 & 예외 전파

arch-cross-cutting pattern-exception error-handling

이 노트의 목적

air-intl-adapter는 11개 공급사의 제각각인 오류(SOAP Fault, REST 에러 JSON, 결제 거절 코드, 타임아웃…)를 단 하나의 예외 계층으로 정규화해 Triple 예약 시스템에 일관된 응답을 돌려준다. 이 노트는 그 정규화 파이프라인 전체 — 예외 클래스 계층, ErrorMessage 코드 체계, 공급사 오류 → 공통 비즈니스 오류 매핑, REST 응답 변환(RestExceptionView), 코루틴 예외 전파, Sentry 캡처/Slack 경보 트리거 — 를 코드 라인 단위로 해부한다.

분석 대상 루트: support/exception/ 4개 파일

support/exception/
├── Exceptions.kt                      ← ApiException 계층 + capture/retry 확장함수
├── ErrorMessage.kt                    ← 비즈니스 오류 코드 enum (단일 진실 원천)
├── RestExceptionHandler.kt           ← @ControllerAdvice 전역 핸들러 + Sentry 캡처
└── AdapterCoroutineExceptionHandler.kt ← 코루틴 미처리 예외 → 핸들러 위임

연관: 회복(Resilience4j/Spring Retry) 메커니즘은 resilience-and-events, 코루틴 구조는 async-coroutines, 요청 진입~응답 흐름은 request-flow, 함정 모음은 landmines.


0. 한 장으로 보는 예외 라이프사이클

flowchart TD
    EXT["공급사 외부 API<br/>SOAP Fault / REST 에러 JSON / 결제 거절 코드 / 타임아웃"]
    EXT --> INFRA
    subgraph INFRA_L["infrastructure (Client)"]
        INFRA["1. OkHttpError.handleSoapFaultException(ErrorMessage.XXX) — SOAP Fault를 ApiException으로<br/>2. PaymentError.getMessage(code)로 ErrorMessage 코드표 lookup<br/>3. throw InternationalAdapterException(ErrorMessage.XXX)<br/>.capture() — Sentry 캡처 대상으로 표시<br/>.retry() / .noRetry() — 재시도 가능 여부 표시(선택)"]
    end
    INFRA -->|"throw (ApiException)"| APP
    subgraph APP_L["application (Service)"]
        APP["@Retryable + shouldXxxRetryable(#root)로 retryable 검사<br/>수동 retry 루프(pnrCancelRepeat/voidRepeat) + Slack 경보<br/>비즈니스 검증 실패 시 StatusInvalidException 등 직접 throw"]
    end
    APP -->|"throw (재시도 소진 시 그대로 전파, Recover 없음)"| IFACE
    subgraph IFACE_L["interfaces (Controller)로 ControllerAdvice"]
        IFACE["RestExceptionHandler.handle(e)<br/>a. e.findRootCause() — 근본 원인 추출<br/>b. .log() — logger.error<br/>c. .sentryLog() — capturable=true 일 때만 Sentry 전송<br/>d. RestExceptionView.of(e)로 code, message 생성<br/>e. HTTP status 결정 (예외 타입별)"]
    end
    IFACE --> TRIPLE["Triple 예약 시스템<br/>code: BOOKING_FAILED, message: ..."]
  • infrastructure (Client): OkHttpError.handleSoapFaultException(ErrorMessage.XXX)는 SOAP Fault를 ApiException으로 변환, PaymentError.getMessage(code)는 코드표 lookup, .capture()는 Sentry 캡처 대상 표시, .retry()/.noRetry()는 재시도 가능 여부 표시(선택).
  • application (Service): @Retryable + shouldXxxRetryable(#root)로 retryable 검사(resilience-and-events), 수동 retry 루프(pnrCancelRepeat/voidRepeat) + Slack 경보, 비즈니스 검증 실패 시 StatusInvalidException 등 직접 throw.
  • interfaces (Controller)@ControllerAdvice: findRootCause() 근본 원인 추출 → .log() logger.error → .sentryLog() (capturable=true 일 때만 Sentry 전송) → RestExceptionView.of(e){ code, message } → 예외 타입별 HTTP status 결정.
  • 최종 응답: { "code": "BOOKING_FAILED", "message": "..." }.

핵심을 미리 정리하면:

단계책임코드 위치
정규화공급사 오류 → ApiException + ErrorMessage*Client.kt, ClientSupport.handleSoapFaultException
분류 표시.capture()/.retry()/.noRetry() 메타데이터 부착Exceptions.kt 확장함수
재시도@Retryable + retryable 플래그 검사application 레이어 (resilience-and-events)
경보Sentry(자동) / Slack(수동 호출)RestExceptionHandler, SlackService
응답 변환ApiExceptionRestExceptionViewRestExceptionView.of

1. 예외 계층 — ApiException 와 4개 구현체

Exceptions.kt:21ApiException 이 모든 비즈니스 예외의 추상 부모다. 이 시스템에는 “표준 자바 예외를 쓰지 말고 무조건 ApiException 하위로 던진다”는 암묵적 규칙이 있다.

abstract class ApiException : RuntimeException, SentryAlertHandler {
    val errorMessage: ErrorMessage   // 비즈니스 오류 코드 (필수)
    val objs: Array<*>               // 부가 정보(메시지/PNR/코드 등)
 
    override var capturable: Boolean = false  // Sentry 전송 여부 (기본 false!)
    override var notifiable: Boolean = true    // Sentry "notifiable" 태그
    var retryable: Boolean? = null             // 재시도 가능 여부 (기본 null = 미지정)
}
flowchart TD
    RTE["RuntimeException"]
    SAH["SentryAlertHandler (interface)"]
    API["ApiException (abstract)"]
    RTE --> SAH
    SAH --> API
    API --> IAE["InternationalAdapterException<br/>범용, 가장 다용도"]
    API --> MAIE["MethodArgumentInvalidException<br/>요청 파라미터 검증 실패"]
    API --> CKIE["CacheKeyInvalidException<br/>캐시키 만료/무효"]
    API --> SIE["StatusInvalidException<br/>상태 검증 실패, 대부분 비재시도"]

Exceptions.kt:82~115 의 4개 구현체는 사실상 코드가 동일하고 생성자 2개(vararg objs / cause + vararg objs)만 위임한다. 차이는 오직 RestExceptionHandler에서 매핑되는 HTTP status의미론적 용도에 있다.

예외 클래스HTTP Status의미/용도정의 위치
InternationalAdapterException500 INTERNAL_SERVER_ERROR범용. 공급사 오류 대부분, of(soapFault) 팩토리 보유Exceptions.kt:82
MethodArgumentInvalidException400 BAD_REQUEST요청 파라미터/페이로드 검증 실패Exceptions.kt:102
CacheKeyInvalidException410 GONE검색 캐시키 만료·무효 (재검색 유도)Exceptions.kt:107
StatusInvalidException500 INTERNAL_SERVER_ERRORPNR/스케줄/티켓 상태가 작업 불가 상태Exceptions.kt:112

왜 클래스 4개를 굳이 나눴나?

본문 메시지(ErrorMessage)만으로는 HTTP status를 결정할 수 없기 때문이다. 같은 TICKETING_FAILED라도 “파라미터가 틀려서(400)“인지 “공급사 장애(500)“인지 구분이 필요하다. @ControllerAdvice예외의 타입으로 status를 분기하므로(RestExceptionHandler.kt:17~63), 의미별로 클래스를 쪼개 둔 것이다. 신입은 새 예외를 던질 때 “이게 호출자 잘못이면 MethodArgumentInvalidException, 캐시 만료면 CacheKeyInvalidException, 상태 문제면 StatusInvalidException, 나머지는 InternationalAdapterException” 으로 고르면 된다.

1.1 생성자가 자동으로 끼워 넣는 컨텍스트 (buildObjs)

ApiException의 모든 생성자는 buildObjs(objs)(Exceptions.kt:26~39)를 거친다. 이게 중요한 디테일이다.

private fun buildObjs(objs: Array<out Any>): Set<Any> {
    val orderNumber = MDCHolder.OrderNumber.getOrNull()?.takeIf { it.isNotBlank() }
    val pnr = MDCHolder.Pnr.getOrNull()?.takeIf { it.isNotBlank() }
    return buildSet {
        if (orderNumber != null) add(orderNumber)
        if (pnr != null) add(pnr)
        addAll(objs)
    }
}

즉 예외를 생성하는 순간 현재 요청의 주문번호(OrderNumber)와 PNR이 MDC에서 자동으로 뽑혀 예외 메시지에 합쳐진다. 그래서 ApiExceptionmessage"BOOKING_FAILED:ORD-123, ABC123, code, msg" 처럼 컨텍스트가 박혀 나온다. 이 MDC 채우기는 MDCFilter(support/filter/MDCFilter.kt)가 요청 진입 시 헤더에서 putAll로 채운다 → request-flow, support-common.

함정: objsSet 이라 중복/순서가 사라진다

buildObjsbuildSet { ... }으로 모으므로 중복 문자열이 1개로 합쳐지고 순서가 비결정적이다. 같은 값(예: 두 번 넘긴 PNR)을 부가정보로 던지면 하나만 남는다. 또 RestExceptionView가 응답 message로 쓰는 건 objsResponseMessage 타입뿐(아래 4절)이라, 단순 String으로 던진 부가정보는 응답 본문에 나가지 않고 로그/Sentry에만 남는다.


2. ErrorMessage — 비즈니스 오류 코드의 단일 진실 원천

ErrorMessage.kt약 100개의 enum 상수로 이뤄진 단순 enum(파라미터 없음, 메서드 없음)이다. 코드(=enum.name)가 곧 응답의 code 필드가 된다.

enum class ErrorMessage {
    INTERNAL_SERVER_ERROR,
    SEARCH_FAILED, BOOKING_FAILED, TICKETING_FAILED, CANCEL_FAILED, ...
    PAYMENT_CREDIT_CARD_DENIAL, PAYMENT_INSUFFICIENT_FUNDS, ...
    NO_QUOTA, SOLD_OUT, INFANT_SOLD_OUT, ...
}

도메인별로 묶어 보면 코드 체계의 의도가 보인다(전부 ErrorMessage.kt):

그룹대표 코드라인의미
오퍼레이션 실패SEARCH_FAILED BOOKING_FAILED TICKETING_FAILED CANCEL_FAILED REPRICING_FAILED6~46작업 단위의 일반 실패
부분 실패PARTIALLY_TICKETING_FAILED RETICKETING_FAILED_BY_MISMATCH_PRICE18~20일부만 성공 → 수동 개입 필요
부가서비스(Ancillary)SEARCH_ANCILLARY_FAILED BOOKING_ANCILLARY_FAILED ANCILLARY_BOOKING_PENDING36~43LCC/NDC 부가상품
입력 검증INVALID_PARAMETER INVALID_CACHE_KEY PASSENGER_NAME_TOO_LONG50~55400/410 계열
상태 검증NON_CHANGEABLE_SCHEDULES ALREADY_CANCELED_PNR LOCKED_PNR CANCEL_UNABLE_BY_ALREADY_CHECK_IN58~100상태 불일치
재고SOLD_OUT INFANT_SOLD_OUT NO_QUOTA66~90좌석/쿼터 부족
결제PAYMENT_CREDIT_CARD_DENIAL PAYMENT_INSUFFICIENT_FUNDS PAYMENT_PROVIDER_REJECT PAYMENT_ETC72~86카드/PG 거절 (15종)
큐(GDS)QUEUE_ACCESS_FAILED QUEUE_MOVE_FAILED107~110GDS Queue 작업
현금영수증CASH_RECEIPT_ISSUE_FAILED CASH_RECEIPT_CANCEL_FAILED102~103국내 세무

enum에 메시지·status가 없는 이유

ErrorMessage코드(식별자)만 담는다. 사람이 읽을 메시지는 호출자(Triple)가 코드를 보고 i18n 처리할 책임이고, HTTP status는 예외 클래스가 결정한다. 그래서 adapter는 “무엇이 실패했는가(code)“만 정확히 분류하면 되고, “어떻게 보여줄까”는 관여하지 않는다. 이 분리가 11개 공급사 응답을 통일하는 비결이다.

함정: 코드가 곧 외부 계약(contract)이다

RestExceptionView.code = exception.errorMessage.name 이므로 enum 상수 이름을 바꾸면 응답 JSON의 code가 바뀌어 Triple 쪽이 깨진다. 리네임/삭제는 절대 가벼운 작업이 아니다(사실상 API 브레이킹 체인지). 새 코드는 추가만 안전하다.


3. 공급사 오류 → 공통 비즈니스 오류 매핑 (정규화 계층)

이 시스템의 가장 중요한 책임이다. 공급사마다 오류 표현이 다르므로 정규화 전략도 3가지로 나뉜다.

3.1 전략 A — SOAP Fault → ApiException (GDS 공급사)

GDS(Amadeus/Sabre/Galileo)는 SOAP이라 응답 본문이 <soap:Fault>다. ClientSupport.handleSoapFaultException(support/web/ClientSupport.kt:62)이 이를 파싱해 예외로 변환한다.

fun OkHttpError.handleSoapFaultException(errorMessage: ErrorMessage, vararg message: String): Throwable {
    return this.errorData?.let { errorData ->
        try {
            InternationalAdapterException.of(
                errorMessage = errorMessage,
                soapFault = soap(errorData).soapBody.fault!!,   // SOAP 본문 파싱
                message = message
            ).capture()                                          // Sentry 대상 표시
        } catch (_: Exception) {
            this.exception      // ← 파싱 실패 시 원본 예외로 폴백
        }
    } ?: this.exception
}

여기서 호출되는 InternationalAdapterException.of(Exceptions.kt:88~98)는 SOAP fault의 faultCode, faultString을 부가정보로 합친다.

fun of(errorMessage: ErrorMessage, soapFault: SOAPFault, vararg message: String) =
    InternationalAdapterException(errorMessage, "${soapFault.faultCode}, ${soapFault.faultString}", *message)

사용 예 — AmadeusClient.kt:185 throw it.handleSoapFaultException(ErrorMessage.SEARCH_FAILED). 같은 패턴이 amadeus/amadeusndc/galileo 등 10개 클라이언트에서 반복된다.

함정: SOAP fault 파싱이 실패하면 원본 예외가 그대로 새 나간다

catch (_: Exception) { this.exception } 와 마지막 ?: this.exception 때문에, errorData가 null이거나 SOAP 형식이 아니면 ErrorMessage가 안 붙은 raw 예외(예: IOException) 가 전파된다. 그러면 @ControllerAdvice의 마지막 fallback 핸들러(RestExceptionHandler.kt:65)로 떨어져 응답 code가 INTERNAL_SERVER_ERROR로만 나가고(아래 4절) 분류 정보를 잃는다. 타임아웃(OkHttpError.isTimeout, ClientSupport.kt:206)도 errorData가 없으니 이 경로로 빠진다.

3.2 전략 B — 코드 lookup 테이블 → ErrorMessage (결제 거절)

결제 거절은 공급사/PG별 코드가 수백 개라 enum 매핑 테이블로 관리한다. 대표 예 supplier/jinair/support/enums/PaymentError.kt:

enum class PaymentError(val errorMessage: ErrorMessage) {
    PAYMENT_084(ErrorMessage.PAYMENT_CREDIT_CARD_DENIAL),
    PAYMENT_097(ErrorMessage.PAYMENT_PROVIDER_REJECT),
    PAYMENT_AKBANK_51(ErrorMessage.PAYMENT_INSUFFICIENT_FUNDS),
    // ... 60여 개
    ;
    companion object {
        private val lookup = entries.associateBy({ it.name }, { it.errorMessage })
        fun getMessage(errorCode: String): ErrorMessage =
            lookup[errorCode] ?: ErrorMessage.PAYMENT_ETC    // ← 미등록 코드는 PAYMENT_ETC로 흡수
    }
}

같은 발상의 매핑이 공급사별로 형태만 다르게 존재한다:

공급사매핑 방식위치
jinairenum + getMessage(code) lookupsupplier/jinair/support/enums/PaymentError.kt:5
jejuairHashSet<문자열> to ErrorMessage (한글 사유 매칭)supplier/jejuair/support/util/PaymentError.kt:7
amadeus(TOPAS)GpsError 코드 → ErrorMessage 다수 매핑supplier/amadeus/infrastructure/topas/GpsError.kt:85,582,656
amadeus(Key-In)AmadeusKeyInErrorsupplier/amadeus/infrastructure/AmadeusKeyInError.kt:18

"미등록은 *_ETC 로 흡수" 패턴을 기억하라

모든 매핑 테이블은 매칭 실패 시 PAYMENT_ETC 같은 catch-all 코드로 폴백한다. 덕분에 새 거절 코드가 와도 NPE 없이 흘러가지만, 반대로 새 코드가 조용히 *_ETC로 묻혀 분류 통계가 부정확해질 수 있다. 결제 실패 분석 시 PAYMENT_ETC 비율이 높으면 매핑 테이블 보강이 필요하다는 신호다.

3.3 전략 C — 비즈니스 검증 → 직접 throw (application 레이어)

공급사 응답은 정상이지만 우리 비즈니스 규칙상 진행 불가인 경우, 서비스가 직접 던진다.

// AmadeusCancelService.kt:108
throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE_BY_ALREADY_CHECK_IN).capture()
// AmadeusTicketingService.kt:61
throw StatusInvalidException(ErrorMessage.TICKETING_FAILED, pnr, "carrier pnr is null").capture()

.capture()가 무려 103곳에서 호출된다(grep 확인). 즉 “비즈니스 검증 실패도 Sentry로 본다”는 운영 정책이다.


4. REST 응답 변환 — RestExceptionView

interfaces/response/RestExceptionView.kt 가 예외를 외부 응답 DTO로 바꾼다. 이게 Triple이 실제로 받는 JSON이다.

data class RestExceptionView(val code: String, val message: String? = null) {
    companion object {
        fun of(exception: Exception): RestExceptionView = when (exception) {
            is ApiException -> {
                val responseMessage = exception.objs.firstNotNullOfOrNull { it as? ResponseMessage }
                RestExceptionView(
                    code = exception.errorMessage.name,           // ← enum 이름이 code
                    message = responseMessage?.message.orEmpty()  // ← ResponseMessage만 노출
                )
            }
            else -> RestExceptionView(code = HttpStatus.INTERNAL_SERVER_ERROR.name)  // 비-ApiException
        }
    }
}
입력 예외응답 code응답 message
ApiException 하위errorMessage.name (예: BOOKING_FAILED)objsResponseMessage의 message, 없으면 ""
그 외 모든 예외"INTERNAL_SERVER_ERROR"(필드 없음/기본)

message에 내용을 담으려면 반드시 ResponseMessage로 감싸 던져라

RestExceptionViewobjs 배열에서 ResponseMessage 타입만 message로 꺼낸다(firstNotNullOfOrNull { it as? ResponseMessage }). 그냥 String으로 던진 부가정보("carrier pnr is null" 등)는 응답에 안 나가고 로그/Sentry에만 남는다. 외부에 메시지를 보여주려면:

throw InternationalAdapterException(ErrorMessage.PNR_CHECK_FAILED, ResponseMessage(it))
// SabreClient.kt:743 실제 사용 예

5. 전역 예외 핸들러 — RestExceptionHandler (@ControllerAdvice)

중앙 디스패처가 없는 이 시스템에서, 모든 컨트롤러의 예외는 단 하나의 @ControllerAdvice로 수렴한다(RestExceptionHandler.kt:14, ResponseEntityExceptionHandler 상속). 5개의 @ExceptionHandler가 예외 타입별로 HTTP status를 분기한다.

@ExceptionHandler(MethodArgumentInvalidException::class)   // → 400 BAD_REQUEST
@ExceptionHandler(InternationalAdapterException::class)    // → 500
@ExceptionHandler(CacheKeyInvalidException::class)         // → 410 GONE
@ExceptionHandler(StatusInvalidException::class)           // → 500
@ExceptionHandler(Exception::class)                        // → 500 (최종 fallback)

모든 핸들러 본문이 동일한 3-콜 체인을 탄다(RestExceptionHandler.kt:19 등):

e.findRootCause().log().sentryLog()
return handleExceptionInternal(e, RestExceptionView.of(exception = e), HttpHeaders.EMPTY, <STATUS>, request)

5.1 findRootCause() — 근본 원인 추출

fun Throwable.findRootCause(): Throwable {
    var rootCause = this
    while (rootCause.cause != null && rootCause.cause != rootCause) rootCause = rootCause.cause!!
    return rootCause
}

래핑된 예외 체인을 끝까지 따라가 가장 안쪽 원인을 로그/Sentry 대상으로 삼는다. rootCause.cause != rootCause 가드로 자기참조(무한루프)도 방어한다.

함정: findRootCause()ApiException 분류를 통째로 버릴 수 있다

로그·Sentry에 보내는 건 root cause다. 만약 InternationalAdapterException(ErrorMessage.BOOKING_FAILED, cause = ioException) 처럼 cause를 끼워 던지면, findRootCause()는 안쪽 ioException을 꺼낸다. 그런데 ioExceptionSentryAlertHandler가 아니므로(sentryLog()if (this is SentryAlertHandler && capturable.not()) 체크에 안 걸림) 무조건 Sentry로 전송된다. 즉 cause를 단 예외는 .capture()를 안 해도 Sentry로 새 나갈 수 있고, 반대로 BOOKING_FAILED라는 분류 정보는 Sentry 이벤트에서 사라진다. cause 래핑 시 의도와 다르게 동작할 수 있으니 주의. 또한 응답 변환(RestExceptionView.of)에는 root cause가 아니라 원본 e 를 넘기므로(같은 라인 exception = e), 응답 code는 정상적으로 BOOKING_FAILED로 나간다 — 로그/Sentry와 응답이 서로 다른 예외를 본다는 점이 헷갈리는 지점이다.

5.2 sentryLog() — 조건부 Sentry 캡처가 진짜 경보 트리거

fun <T : Throwable> T.sentryLog(): T {
    if (this is SentryAlertHandler && this.capturable.not()) return this   // ★ capturable=false면 스킵
    Sentry.configureScope { scope ->
        MDCHolder.contextMap().forEach { (key, value) -> ... scope.setContexts/setTag ... }  // MDC 전체 첨부
        if (this is SentryAlertHandler) scope.setTag("notifiable", this.notifiable.toString())
    }
    Sentry.captureException(this)
    return this
}

이 메서드가 이 시스템의 “치명 오류 경보” 1차 트리거다(메시지큐가 아니라 Sentry). 동작 규칙:

예외 종류capturableSentry 전송?
ApiException + .capture() 호출함true전송 ✅
ApiException (.capture() 안 함)false(기본)스킵
SentryAlertHandler가 아닌 일반 예외 (IOException 등)(해당 없음)무조건 전송

.capture() 를 호출해야 비로소 Sentry에 잡힌다

ApiExceptioncapturable 기본값은 false(Exceptions.kt:67). 따라서 ApiException을 던지되 .capture()를 빼먹으면 Sentry에 안 잡힌다(의도적 “예상된 비즈니스 실패”는 이렇게 조용히 처리). 반대로 raw 예외는 위 표대로 무조건 잡히므로, 정규화를 못 하면(3.1 함정) Sentry 노이즈가 증가한다. notifiable 태그는 .capture(silence = true)(Exceptions.kt:11~15)로 끌 수 있고, jejuair/jinair에서 “Sentry에는 남기되 알림은 끄는” 용도로 쓴다(JejuairClient.kt:389,395, JinairClient.kt:494 등). Sentry 측 알림 규칙이 이 태그를 보고 PagerDuty/Slack 발송 여부를 가린다.

5.3 Slack 경보는 별개 — 자동이 아니라 서비스가 수동 호출

흔한 오해: "치명 오류면 자동으로 Slack이 간다" — 아니다

RestExceptionHandler/AdapterCoroutineExceptionHandlerSlack을 전혀 호출하지 않는다. 자동 경보 채널은 Sentry뿐이다. Slack 경보(application/SlackService.kt)는 운영상 수동 개입이 필요한 특정 비즈니스 실패 지점에서 서비스 코드가 직접 호출한다. 예: AmadeusCancelService.pnrCancelRepeat(AmadeusCancelService.kt:237~260)는 취소를 2회 재시도하고 마지막에 실패하면 slackService.sendCancelFail(...)을 부른 뒤 예외를 다시 throw한다. 즉 Slack은 “사람이 수동 취소/환불/발권해야 함”을 알리는 운영 알림이고, Sentry는 “개발자가 봐야 할 에러”다. 두 채널은 독립적이며 한 실패가 양쪽 모두에 갈 수도, 한쪽만 갈 수도 있다.

Slack 메시지에는 항상 MDCHolder.DatadogTraceId, 채널/경로, 공급사, PNR이 박힌다(SlackService.kt 전반) → 운영자가 Datadog 추적으로 바로 연결 가능. getSlackEmergencyChannelProperties()(긴급) vs getSlackOperationChannelProperties()(운영) 두 채널로 심각도를 가른다. 설정은 application.yml:73 slack.channels.{emergency|operation}. 자세한 인프라 연결은 configuration-and-infra.


6. 재시도 표시와 예외 — retryable / .retry() / .noRetry()

예외 객체가 “이건 재시도해도 된다/안 된다”는 메타데이터를 들고 다니는 점이 독특하다.

var retryable: Boolean? = null   // Exceptions.kt:69, 기본 null = "미지정"
 
fun <T : ApiException> T.retry(): T   { this.retryable = true;  return this }   // Exceptions.kt:72
fun <T : ApiException> T.noRetry(): T { this.retryable = false; return this }   // Exceptions.kt:77

이 플래그는 Spring Retry(@EnableRetryAirIntlAdapterApplication.kt:9)의 @Retryable(exceptionExpression = "...") SpEL이 소비한다. 각 클라이언트/서비스가 shouldXxxRetryable(#root) 술어를 갖는다:

// AmadeusClient.kt:894 부근
@Retryable(maxAttempts = ..., exceptionExpression = "@amadeusClient.shouldTicketingRetryable(#root)")
fun ... { ... }
fun shouldTicketingRetryable(exception: Exception): Boolean =
    (exception as? ApiException)?.retryable ?: false   // AmadeusClient.kt:952

함정: retryable 미지정(null) 시 기본값이 클라이언트마다 다르다

(exception as? ApiException)?.retryable ?: <기본값> 의 기본값이 통일돼 있지 않다.

클라이언트/술어retryable == null일 때 기본위치
AmadeusClient.shouldTicketingRetryablefalse (재시도 안 함)AmadeusClient.kt:952
AmadeusRetrieveService.shouldGetPnrInfoAndCheckInfantSoldOutRetryablefalseAmadeusRetrieveService.kt:61
JejuairCancelService / JejuairClient / JinairClient / TwayClientfalse각 파일
AmadeusndcClient.shouldRetrieveRetryabletrue (재시도 함!)AmadeusndcClient.kt:371
SabreClient.shouldRetrieveRetryabletrueSabreClient.kt:735

.retry()/.noRetry()를 명시하지 않은 예외가 amadeusndc/sabre의 retrieve에 닿으면 재시도되고, amadeus/jeju/jin/tway에서는 재시도 안 된다. 새 예외를 던질 때 의도하지 않은 재시도(부작용: 중복 예약/세션 꼬임)를 피하려면 명시적으로 .noRetry()를 붙이는 습관이 안전하다. 특히 비-ApiException(raw IOException 등)은 as? 캐스팅이 null이 되어 위 기본값을 따른다 → 타임아웃이 sabre/amadeusndc에서 자동 재시도된다는 뜻.

추가 패턴: @Recover 없음 + 수동 retry 루프

코드 전체에 Spring Retry @Recover 메서드가 하나도 없다(grep 확인). 따라서 @Retryable 재시도가 소진되면 마지막 예외가 그대로 위로 전파돼 @ControllerAdvice까지 간다. 한편 일부 서비스는 @Retryable 대신 for (count in 1..N) 수동 루프로 재시도하며 마지막에 Slack 경보 후 rethrow한다(AmadeusCancelService.pnrCancelRepeat:237, voidRepeat:262). 이 루프 안에서 ALREADY_CANCELED_PNR/NOT_FOUND_TICKET 같은 “이미 목적 달성된” 코드는 break로 성공 취급한다(멱등성 처리). 재시도 메커니즘 전반은 resilience-and-events 참조.


7. 코루틴 예외 전파 — AdapterCoroutineExceptionHandler

코루틴(검색 fan-out 등)은 일반 try/catch 스택을 안 타므로 별도 처리가 필요하다. AdapterCoroutineExceptionHandler(AdapterCoroutineExceptionHandler.kt:10)가 코루틴의 미처리(uncaught) 예외를 받아 RestExceptionHandler의 로그/Sentry 로직으로 위임한다.

class AdapterCoroutineExceptionHandler :
    AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {
    override fun handleException(context: CoroutineContext, exception: Throwable) {
        BeanUtils.getBean(RestExceptionHandler::class.java)?.also { restExceptionHandler ->
            with(restExceptionHandler) {
                exception.findRootCause()
                    .also { logger.error(it.message, it) }
                    .also { it.sentryLog() }       // ← REST 핸들러와 동일한 Sentry 로직 재사용
            }
        }
    }
}

BeanUtils.getBean(support/util/BeanUtils.kt)으로 스프링 빈을 런타임에 끌어오는 이유는, 이 핸들러가 코루틴 컨텍스트 요소(CoroutineContext.Element)라 스프링이 생성자 주입을 못 하기 때문이다.

이 핸들러는 CoroutineExtensions.kt(support/util/)에서 코루틴 시작 시 컨텍스트에 끼워진다:

// CoroutineExtensions.kt:13~26
fun <T> withBlocking(...) = runBlocking(context + SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext()) { block() }
fun CoroutineScope.withLaunch(...) = this.launch(context + SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext(), ...) { ... }

MDCContext()가 함께 붙어 부모 스레드의 MDC(주문번호/PNR/TraceId)가 코루틴으로 전파되므로, 코루틴 안에서 던진 예외도 1.1절의 컨텍스트 첨부와 5.2절의 Sentry MDC 태깅이 정상 동작한다.

함정: withAsync 에는 ExceptionHandler가 없다 (의도된 설계)

withBlocking/withLaunch에는 AdapterCoroutineExceptionHandler가 붙지만 withAsync(CoroutineExtensions.kt:28)에는 안 붙는다. CoroutineExceptionHandler는 “최상위에서 잡히지 않은” 예외에만 동작하는데, async의 예외는 Deferred.await() 호출 시점에 던져지는(rethrow되는) 모델이라 핸들러가 작동하지 않기 때문이다. 그래서 async로 흩뿌린 작업의 예외는 호출부에서 직접 모아야 한다 — 이게 pmap(CoroutineExtensions.kt:36~50)의 존재 이유다.

suspend fun <T, R> Iterable<T>.pmap(block: ...) = coroutineScope {
    val results = map { withAsync { try { AsyncSuccess(block(it)) } catch (e: Exception) { AsyncFail(e) } } }.awaitAll()
    AsyncResults.of(results)   // 성공/예외를 partition으로 분리 (AsyncMapResult.kt:25)
}

각 코루틴이 try/catch로 예외를 AsyncFail에 담아 삼키고(throw 대신 값으로 반환), 호출부가 AsyncResults.getOrThrow()(첫 예외 throw) 또는 getOrEmpty()(성공만)/ onFailure(예외 콜백)로 정책을 고른다. SupervisorJob()을 쓰는 이유도 한 자식 실패가 형제 코루틴을 취소하지 않게 하기 위함이다(병렬 검색에서 한 공급사 실패가 다른 공급사를 죽이면 안 됨). 코루틴 전반은 async-coroutines.


8. 신입 체크리스트 — 새 예외를 던질 때

올바른 throw 레시피

  1. 타입 선택: 호출자 잘못이면 MethodArgumentInvalidException(400), 캐시 만료면 CacheKeyInvalidException(410), 상태 문제면 StatusInvalidException, 나머지는 InternationalAdapterException(500).
  2. 코드 선택: 의미에 맞는 기존 ErrorMessage를 재사용. 없으면 enum 추가만(기존 리네임 금지 — 4.2 함정).
  3. 외부 메시지가 필요하면 ResponseMessage("...")로 감싸 objs에 넣는다(5절). 단순 String은 응답에 안 나간다.
  4. Sentry에 잡히게 하려면 .capture() 호출. 예상된 비즈니스 실패라 노이즈를 피하려면 생략하거나 .capture(silence = true).
  5. 재시도 의도를 명시: 재시도 안전하면 .retry(), 위험(중복 예약/세션 꼬임)하면 .noRetry(). 미지정 시 클라이언트별 기본값이 다르다(6절 함정).
  6. cause 래핑은 신중히: findRootCause()가 root만 로깅/Sentry하므로 분류 정보가 사라질 수 있다(5.1 함정).
// 좋은 예: 외부 메시지 + Sentry + 비재시도
throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE, ResponseMessage("이미 체크인된 여정"))
    .capture().noRetry()

9. 한눈 요약표

관심사메커니즘핵심 코드
예외 계층ApiException 추상 + 4 구현체Exceptions.kt:21~115
오류 코드ErrorMessage enum (~100개)ErrorMessage.kt
SOAP→예외handleSoapFaultException + of(soapFault)ClientSupport.kt:62, Exceptions.kt:88
코드표 매핑PaymentError.getMessage 등 lookup + *_ETC 폴백jinair/.../PaymentError.kt
응답 변환RestExceptionView.of{code, message}RestExceptionView.kt:12
HTTP status 분기@ControllerAdvice 타입별 핸들러RestExceptionHandler.kt:17~75
근본 원인findRootCause()RestExceptionHandler.kt:108
Sentry 경보(자동)sentryLog() + capturable/notifiableRestExceptionHandler.kt:82
Slack 경보(수동)SlackService.sendXxx() 직접 호출application/SlackService.kt
재시도 표시retryable + .retry()/.noRetry() + @Retryable SpELExceptions.kt:69~80, 각 client
코루틴 예외AdapterCoroutineExceptionHandler / pmap+AsyncResultsAdapterCoroutineExceptionHandler.kt, CoroutineExtensions.kt

더 깊이 가기