Korean Air — 지뢰요소

module-koreanair pattern-error-handling pattern-async-coroutines config-resilience

이 노트의 목적

Korean Air(대한항공, NDC V21.3 / IATA OAS 표준) 모듈에서 운영 중 터지기 쉬운 함정을 전수한다. 신입은 “여기서 NPE/예외가 난다”를 먼저 익히고, 시니어는 “왜 이렇게 설계됐고 어떻게 고쳐야 하나”까지 이해하도록 구성했다. 동작 흐름·연산자 의미는 koreanair-operations / koreanair-protocol 를, 공통 예외/서킷브레이커 골격은 error-handling / resilience-and-events 를, 전체 지뢰 인덱스는 landmines 를 함께 보라.

KE 모듈은 11개 공급사 중 유일하게 두 개의 완전히 다른 프로토콜을 한 모듈에서 다룬다.

  • 항공 예약/발권: IATA NDC SOAP (TOPAS 경유, IATA_*RQ/RS 메시지)
  • 카드 결제: NicePay 고정폭 바이트 전문 + SEED 암호화 + 생 TCP Socket

이 이중성이 대부분의 함정의 뿌리다.

flowchart TD
    T["Triple 예약"] --> ISSUE
    subgraph ISSUE["KoreanairTicketingService.issue"]
        S1["1) retrieve NDC SOAP - 상태검증"] --> S2["2) approve NicePay TCP/SEED - 결제"]
        S2 --> S3["3) issue NDC SOAP - 발권"]
    end
    ISSUE -->|"catch 예외 발생"| CATCH["paymentCancelAsync + cancelAsync"]
    CATCH -.->|"fire-and-forget 비동기 보상"| DONE["보상 트랜잭션"]
  • retrieve(NDC SOAP): 발권 전 스케줄 상태검증
  • approve(NicePay TCP/SEED): 카드 결제 승인
  • issue(NDC SOAP): 발권
  • catch 블록: paymentCancelAsync(결제취소) + cancelAsync(예약취소)를 fire-and-forget 코루틴으로 실행(§3-3 참조)

1. 공급사 고유 예외·에러코드와 ErrorMessage 매핑

1-1. NDC 응답 에러: errors.first() 단일 추출 + 코드 화이트리스트

모든 NDC 응답 DTO(AirShoppingRS, OfferPriceRS, OrderViewRS, OrderReshopRS)는 checkError에서 errors.first() 콜백으로 넘긴다. 복수 에러 시 2번째 이후는 버려진다.

// AirShoppingRS.kt:28
fun checkError(callback: ((code: String, message: String) -> Unit)) {
    if (!errors.isNullOrEmpty()) {
        val error = errors.first()
        callback(error.code ?: "", error.descriptionText ?: "")
    }
}

검색은 특정 코드를 정상으로 흡수한다 (KoreanairClient.kt:134-147):

코드의미처리
325No inventory available예외 던지지 않고 빈 결과로 통과
719No fares available예외 던지지 않고 빈 결과로 통과
그 외InternationalAdapterException(SEARCH_FAILED, code, message).capture()

코드 화이트리스트가 매직 스트링이다

"325", "719" 는 코드에 하드코딩되어 있고 주석으로만 설명된다. KE가 “재고 없음”을 다른 코드로 바꾸면 즉시 검색 전체가 SEARCH_FAILED로 실패한다(서킷브레이커 OPEN까지 연쇄). enum/설정으로 분리되어 있지 않다.

1-2. NicePay 결제 에러: responseCode != "0000" + 에러코드 매핑 미구현

// NicePayApproveResponse.kt:84
fun checkError(callback: ((Pair<String, String>) -> Unit)) {
    if (responseCode != "0000") { callback(Pair(responseCode, responseMessage)) }
}
// KoreanairPaymentClient.kt:49-56
response.checkError { (responseCode, responseMessage) ->
    //TODO 에러 코드 매핑
    throw MethodArgumentInvalidException(
        ErrorMessage.PAYMENT_ETC, pnr, "${responseCode}:${responseMessage}"
    )
}

//TODO 에러 코드 매핑 — 결제 거절이 전부 PAYMENT_ETC로 뭉개진다

NicePay/카드사가 돌려주는 responseCode(한도초과/도난카드/잔액부족 등)가 하나도 분기되지 않는다. 모든 거절이 PAYMENT_ETC(승인 외 결제 일반 에러)로 매핑되어, 운영에서 “왜 결제가 안 되는지” 사용자/CS가 구분할 수 없다. 응답 코드 문자열만 메시지에 박혀 나간다. KoreanairPaymentClient.kt:50 의 TODO가 그대로 남아 있다.

1-3. ErrorMessage 매핑 표 (KE 사용분)

오퍼레이션던지는 ErrorMessage발생 지점
searchSEARCH_FAILEDKoreanairClient.kt:139,157 / KoreanairFlightSearchService.kt:75
pricingPRICING_FAILEDKoreanairClient.kt:192,197
bookBOOKING_FAILEDKoreanairClient.kt:229,234
retrieveRETRIEVE_FAILED, ALREADY_CANCELED_PNRKoreanairClient.kt:256,262,269
issue/reissueTICKETING_FAILEDKoreanairClient.kt:291,299,539,544
cancelCANCEL_FAILEDKoreanairClient.kt:315,319,341,348
refundCalculateCALCULATE_CANCEL_FEE_FAILEDKoreanairClient.kt:373,382 / OrderReshopRS.kt:51
changeApisPASSENGER_CHANGE_FAILEDKoreanairClient.kt:407,415
splitDIVIDE_FAILEDKoreanairClient.kt:436,444 / KoreanairBookingService.kt:107~160
reissueSearchREISSUE_SEARCH_FAILEDKoreanairClient.kt:499,517
reissueDetailREISSUE_NON_CHANGEABLE_FARE_SCHEDULEKoreanairSearchController.kt:97
발권 전 상태검증NOT_OK_SCHEDULE, TICKETING_FAILEDKoreanairTicketingService.kt:45,54
paymentPAYMENT_ETC, PAYMENT_CANCEL_FAILEDKoreanairPaymentClient.kt:53,77
채널/퍼널 미존재NOT_SUPPORTED_SALES_CHANNEL/FUNNELProperties.kt:573,586

reissueSearch 만 두 번째 checkError 오버로드를 쓴다

OrderReshopRS.kt:35checkError(callback: (errors: List<Error>) -> Unit)전체 에러 리스트를 받아 모든 descriptionText를 합쳐 메시지로 만든다(KoreanairClient.kt:498-508). 코드 동일하지만 오버로드 두 개가 공존하므로, 새 오퍼레이션 추가 시 어느 쪽을 호출하는지 반드시 확인하라.


2. 상태/세션 함정 (세션·토큰·시간제한)

2-1. 발권 전 스케줄 상태 검증 — valueOf 폭탄과 NPE 동시 존재

KoreanairTicketingService.issue() 는 발권 직전 retrieve 결과를 검증한다.

// KoreanairTicketingService.kt:44-60
if (booking.schedules == null || booking.schedules.any { it.status != ServiceStatusCode.CONFIRMED })
    throw StatusInvalidException(NOT_OK_SCHEDULE, ...)
if (booking.schedules.any { it.carrierPnr.isNullOrBlank() })
    throw StatusInvalidException(TICKETING_FAILED, ..., "carrier pnr is null")

PassengerTypeCode.valueOf(type!!) — 미지의 PTC/누락이면 발권 직전 폭발

Passenger.typeCode(response/Passenger.kt:36-37)는 PassengerTypeCode.valueOf(type!!) 를 쓴다.

  • type 이 null → NPE
  • KE가 ADT/CHD/CNN/INF 외 PTC(예: SRC 우대, YTH 청소년)를 내려주면 → IllegalArgumentException

반면 Ticket.status(response/Ticket.kt:26-29)는 TicketStatusCode.entries.find{...} ?: ETC안전한 폴백을 쓴다. 같은 모듈인데 enum 변환 정책이 일관되지 않다. 발권 트랜잭션 한복판(retrieve→approve 사이)에서 터지면 catch로 결제취소+예약취소가 비동기로 돌지만(§3-3), 정작 결제는 아직 안 된 상태일 수도 있어 불필요한 취소 호출이 나간다.

2-2. enum 디폴트값 미설정 → 미지 상태코드 역직렬화 자체가 실패할 수 있음

xmlMapper(WebMvcConfiguration.kt:74-88)는 failOnUnknownProperties(false) 만 끄고, READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE 는 켜지 않는다. 그리고 ServiceStatusCode/PassengerTypeCode 에는 @JsonEnumDefaultValue 가 없다.

  • ServiceStatusCode(enums/ServiceStatusCode.kt)는 enum이며 ETC 멤버가 있으나, Jackson 디폴트 폴백이 설정돼 있지 않다. 미지의 statusCode가 enum 필드로 바인딩되는 경로가 있다면 역직렬화 예외.
  • Ticket.status/TicketStatusCode 는 String으로 받은 뒤 코드에서 수동 폴백하므로 안전.

상태코드 enum은 "수동 폴백"과 "Jackson 직접 바인딩"이 혼재

새 상태코드 대응 시: String으로 받아 코드에서 entries.find{} ?: ETC 패턴을 쓸지, enum 직접 바인딩 + @JsonEnumDefaultValue 를 쓸지 정책을 통일해야 한다. 현재는 전자(Ticket)와 위험한 후자(Passenger.PTC)가 섞여 있다.

2-3. CLOSED 주문 / 빈 orderItems → ALREADY_CANCELED_PNR

// KoreanairClient.kt:258-265
val orders = orderViewRS.response?.orders
if (orders?.flatMap { it.orderItems }.isNullOrEmpty() || orders?.first()?.statusCode == "CLOSED")
    throw InternationalAdapterException(ALREADY_CANCELED_PNR, supplierIdentificationKey)

orders?.first() 는 비어 있으면 NoSuchElementException이 아니라 short-circuit

|| 좌변 isNullOrEmpty() 가 먼저 true면 우변 orders?.first() 는 평가되지 않아 안전. 단 statusCode 문자열 "CLOSED" 도 매직 스트링이다.

2-4. 환불계산 결과 캐시의 offerTimeLimit 만료 — 1분 버퍼

cancelable()refundCalculate 결과를 offerTimeLimit - 1분 동안만 Redis에 캐시한다.

// KoreanairCancelService.kt:56-64
detail.offerTimeLimit?.run {
    val diff = Duration.between(now(), this.minusMinutes(1))
    if (diff > Duration.ZERO) repository.save(key, detail, ttl = diff)
}

그리고 cancel() 은 캐시가 있으면 재계산 없이 캐시의 offerId 로 취소한다.

// KoreanairCancelService.kt:32-39
val cancelableTypeDetail = repository.find(key) ?: koreanairClient.refundCalculate(booking)
koreanairClient.issuedCancel(..., offerId = cancelableTypeDetail.offerId!!, ...)

만료된 offerId로 취소 시도 + offerId!! non-null 강제

  • 캐시 TTL은 offerTimeLimit - 1분. 사용자가 “예상 취소 수수료”를 본 뒤 1~2분 뒤 실제 취소를 누르면 캐시가 방금 만료되어 재계산이 돈다. 재계산된 offerId/수수료가 사용자가 본 것과 달라질 수 있다(수수료 표시값 ≠ 실제 청구값 위험).
  • offerId!!(KoreanairCancelService.kt:39): refundCalculate가 offer 없이 성공 응답을 주면 KotlinNullPointerException. OrderReshopRS.toCancelableTypeDetail 에서 offer = reshopOffer?.offers?.firstOrNull() 로 null 가능(OrderReshopRS.kt:57,96).

2-5. SOAP 인증은 매 요청 stateless 자격증명 주입 — 세션 PNR 없음

KE는 GDS(Amadeus/Sabre)와 달리 stateful 세션을 쓰지 않는다. 매 요청 SOAP 헤더에 iden(u/p/agt/agtpwd/agtrole/agy)을 박는다(KoreanairClient.kt:549-575). 즉 세션 만료 함정은 없지만, 자격증명이 매 전문에 평문 속성으로 들어가므로 로깅 시 노출 위험이 있다(§5-3).


3. @Retry / @CircuitBreaker 동작과 주의·폴백

3-1. KE에는 @CircuitBreaker 가 search 한 곳뿐, @Retry/@Bulkhead 는 전무

검증 결과(grep 전수):

// KoreanairSearchController.kt:28  ← 유일한 Resilience4j 어노테이션
@CircuitBreaker(name = "koreanairSearch", fallbackMethod = "searchFallback")
fun search(...): List<FareItineraryView>
어노테이션KE 적용 여부
@CircuitBreakersearch 컨트롤러 1곳만
@Retry없음
@Bulkhead없음
@RateLimiter / @TimeLimiter없음

서킷브레이커 설정(application.yml:67-68baseConfig: search, 41-47):

항목
slidingWindow180s, TIME_BASED
minimumNumberOfCalls30
failureRateThreshold35%
waitDurationInOpenState120s
half-open 허용 호출10
// KoreanairSearchController.kt:122-130  ← 폴백
private fun searchFallback(exception: CallNotPermittedException): List<FareItineraryView> {
    // Datadog span 태그만 찍고
    return emptyList()   // ← 빈 결과 반환
}

폴백 시그니처가 CallNotPermittedException 한정 — 그 외 예외는 폴백 안 탄다

searchFallback(exception: CallNotPermittedException)서킷이 OPEN이라 호출 차단된 경우만 잡는다. search 내부에서 던지는 InternationalAdapterException(SEARCH_FAILED) 등 일반 예외는 폴백으로 가지 않고 그대로 전파되며(서킷 실패 카운트만 올린다), 클라이언트는 빈 결과가 아니라 에러를 받는다. “검색이 가끔 빈 배열로 떨어진다”면 서킷 OPEN을 의심하라(Datadog supplier.circuit-breaker=OPEN 태그).

발권·결제·취소에는 서킷브레이커가 없다 — 의도된 설계지만 주의

돈이 오가는 issue/cancel/payment에는 자동 재시도/차단이 없다. KE NDC 발권은 멱등하지 않으므로 자동 재시도가 이중발권을 유발할 수 있어 의도적으로 뺀 것으로 보인다. 대신 실패는 Slack 경보 + 비동기 보상 으로 처리된다(§3-3). 신입은 “왜 발권엔 재시도가 없지?”를 멱등성 관점에서 이해해야 한다.

3-2. 타임아웃 값 — search 15s, 그 외 60s, NicePay 5s

// KoreanairClient.kt:50-53 (ClientSupport 상속)
searchTimeout = 15000,   // search 전용 OkHttp client
defaultTimeout = 60000,  // pricing/book/issue/cancel/reshop
// KoreanairPaymentClient.kt:31
private val timeOut = 5000   // NicePay 소켓 connect/read 양쪽

NicePay 5초 read 타임아웃 + chunk 종료 조건이 \r

KoreanairPaymentClient.send() 는 응답을 '\r' 가 나올 때까지 읽는다(KoreanairPaymentClient.kt:114). 5초 안에 \r 가 안 오면 SocketTimeoutException. 결제 승인은 성공했는데 응답만 늦게 오면(또는 \r 누락) → 타임아웃 → 결제는 됐는데 우리는 실패로 인식timeoutCallback로 Slack 경보(KoreanairPaymentClient.kt:60-62). 이른바 망상거래(orphan approval) 위험. transactionNumber 가 망상취소용으로 설계된 이유다(NicePayApproveRequest.kt:34 주석).

3-3. 보상 트랜잭션은 fire-and-forget 코루틴 — 실패하면 추적만 됨

// KoreanairTicketingService.kt:89-96  발권 실패 시
} catch (e: Exception) {
    payment?.run { paymentCancelAsync(payment = this, pnr = pnr) }  // 5초 뒤 결제취소
    cancelAsync(supplierIdentificationKey, validatingCarrier)       // 10초 뒤 예약취소
    throw e
}
// KoreanairTicketingService.kt:125-155
private fun paymentCancelAsync(...) {
    CoroutineScope(Dispatchers.IO).withLaunch {   // ← 요청 생명주기와 무관한 ad-hoc 스코프
        delay(5_000); try { paymentService.cancel(payment) }
        catch (e) { slackService.sendPaymentCancelFail(...); throw e }  // throw 해봐야 핸들러만 받음
    }
}
private fun cancelAsync(...) {
    CoroutineScope(Dispatchers.IO).withLaunch { delay(10_000); cancelService.cancel(...) }
}

보상 실패는 사용자에게도, 호출자에게도 전파되지 않는다

  • CoroutineScope(Dispatchers.IO) 를 매번 새로 만들어 withLaunch(CoroutineExtensions.kt:20-26)로 띄운다 → SupervisorJob + AdapterCoroutineExceptionHandler 가 붙어 예외는 Sentry/로그로만 흐른다(error-handling 참조). 코루틴 내부의 throw e 는 호출 스택으로 못 올라간다.
  • delay(5000)/delay(10000) 동안 인스턴스가 재배포/스케일인되면 보상이 통째로 유실된다(인메모리 코루틴, 영속 큐 아님).
  • 결제취소(5s)와 예약취소(10s)의 순서 보장도 약하다. 결제는 취소됐는데 예약 취소가 실패하면 “결제 없는 예약” 상태가 남을 수 있다.
  • 운영에서 발권 실패가 났다면 Slack sendPaymentCancelFail/sendCancelFailTimeout 채널을 반드시 확인해야 한다(resilience-and-events).

3-4. issue/cancel의 timeoutCallback 은 Slack 알림만, 흐름은 계속 예외 전파

KoreanairClient.issue/issuedCancelfailure.isTimeout 일 때 timeoutCallback() 을 부르고 그 후에도 handleSoapFaultException 으로 예외를 던진다(KoreanairClient.kt:295-300, 344-349). 즉 타임아웃 시 “Slack 알림 + 예외” 둘 다 발생. 발권 타임아웃은 곧 발권 됐는지 미확정 상태이므로 CS가 PNR을 직접 조회해야 한다.


4. 운임·통화·세금·수수료 계산 함정

4-1. Price.airPrice — KRW가 아니면 환산 없이 base를 그대로 KRW 취급

// response/Price.kt:32-33
val airPrice: BigDecimal?
    get() = equivalentAmount?.takeIf { it.currencyCode == "KRW" }?.value ?: baseAmount?.value

외화 baseAmount를 환율 적용 없이 그대로 금액으로 쓴다

EquivAmount 가 KRW일 때만 그것을 쓰고, 아니면 BaseAmount.value 로 폴백한다. 그런데 BaseAmount는 통화가 KRW가 아닐 수 있다(Amount.currencyCode). 그 경우 외화 숫자가 그대로 원화 금액처럼 합산된다. KE 국내 발권이 항상 KRW Equiv를 준다는 전제에 의존하는 코드이며, 전제가 깨지면 운임이 통째로 틀어진다. 통화 단위 검증/예외가 없다.

4-2. 유류할증료(fuelCharge)는 YR 만, YQ 누락

// response/Price.kt:35-38  &  response/Passenger.kt:87-92
.filter { it.taxCode == "YR" }.sumOf { it.amount.value.toLong() }

항공유류할증은 통상 YQ/YR 두 코드 — YR 만 집계

KE가 유류할증을 YQ로 싣는 노선/시점이 있으면 fuelCharge가 0으로 빠진다. fuelCharge는 표시·정산용 분해값이라 total에는 영향이 없지만(tax 합계에는 포함), 유류할증 표기가 누락될 수 있다. qCharge 분리 로직도 별도 확인 필요.

4-3. BigDecimal → Long .toLong() 절사 — 반올림 아님

운임/세금 합산 후 거의 모든 곳에서 .toLong() 으로 변환한다(Passenger.kt:85-94, OrderReshopRS.kt:87-90, AirShoppingRS.kt:90,149-151). BigDecimal.toLong()소수점 버림(truncation). KRW는 보통 정수라 문제 없지만, KE 응답에 소수 세금이 섞이면 합산 순서에 따라 1원 단위 오차가 누적될 수 있다.

4-4. 환불계산: used/unused 분해식이 음수가 될 수 있음

// OrderReshopRS.kt:78-90  (REFUND 시)
val unUsedAirPrice = differencePrice?.dueByAirlineAmount?.value?.minus(unUsedTax) ?: BigDecimal.ZERO
val penaltyPrice = deleteOfferItem.penaltyIds?.sumOf { penaltyMap[it]?.price?.totalAmount?.value ?: ZERO } ?: ZERO
passenger.fare!!.copy(
    refundFee = penaltyPrice.toLong(),
    expectedRefundAmount = (unUsedAirPrice + unUsedTax).toLong(),
    usedTax = (originTax - unUsedTax).toLong(),
    usedAirPrice = (originAirPrice - unUsedAirPrice - penaltyPrice).toLong()
)

usedAirPrice = origin - unused - penalty 는 음수 방지 가드가 없다

penalty가 크거나 KE 응답의 origin/difference 정합이 깨지면 usedAirPrice/usedTax 가 음수가 될 수 있다. 또한 passenger.fare!!(OrderReshopRS.kt:64,86)는 fare가 null이면 NPE. VOID/REFUND가 섞인 deleteOrderItems면 toCancelableTypeDetailCALCULATE_CANCEL_FEE_FAILED 를 던진다(OrderReshopRS.kt:44-55) — 전부 VOID이거나 전부 REFUND여야만 정상 처리.

4-5. 재발행 결제 금액 감소 차단 — 음수 운임 검출

// KoreanairSearchController.kt:88-117 (reissueDetail)
if (fareItinerary.passengerFares.any { it.airPrice < 0 || it.tax < 0 })
    throw InternationalAdapterException(REISSUE_NON_CHANGEABLE_FARE_SCHEDULE, ...)

재발행은 "금액 감소" 시 어댑터가 막고 항공사 사이트로 유도한다

airPrice = total - tax(FareItinerary.kt:143-144)가 음수면 차감(환불성) 재발행으로 보고 차단. 즉 KE 재발행은 추가 징수만 지원한다고 봐야 한다. 카드 결제 자체도 미구현(§6-1).

4-6. 발권 카드 금액 = passengerPrices.sumOf { cardPrice }, 0이면 결제 스킵

// KoreanairTicketingService.kt:63-72
payment = passengerPrices.takeIf { !prepayment }?.sumOf { it.cardPrice }
    ?.run { paymentService.approve(cardPrice = passengerPrices.sumOf { it.cardPrice }, cardInfo = cardInfo!!, pnr) }

cardInfo!! 강제 — prepayment=false인데 cardInfo가 null이면 NPE

sumOf{cardPrice} 가 0이어도 ?.run{} 은 실행된다(0은 non-null Long). cardInfo!!prepayment=false일 때 카드정보가 반드시 온다는 전제. 컨트롤러는 request.paymentInfo?.let{...}(KoreanairTicketingController.kt:30)로 null 허용이라, 둘이 어긋나면 결제 호출 직전 NPE → catch → 불필요한 보상취소.


5. 인코딩·암호화·날짜/시간대 함정

5-1. NicePay = EUC-KR + SEED/CBC + 고정폭 바이트 슬라이싱

// KoreanairPaymentClient.kt:95-104
val requestMessage = serializeToLiteralTextByByte(request, seedKey, iv, charset = "EUC-KR")
output.write(requestMessage.toByteArray(Charset.forName("EUC-KR")))

전문은 @ByteRange(start,end) 기반 고정폭(NicePayApproveRequest.kt). serializeToLiteralTextByByte(ReflectionUtils.kt:102-154)는 각 필드를 byte 길이에 맞춰 공백 패딩하거나 초과분을 잘라낸다(line 147 bytes.copyOf(targetSize)).

멀티바이트 필드가 바이트 경계에서 잘리면 깨진 문자가 전송된다

EUC-KR 한글은 2바이트. 발급사명/매입사명 등 한글 필드가 targetSize 경계에서 잘리면 반쪽 바이트가 남아 전문이 깨진다. 응답 파싱(deserializeOfLiteralTextByByte, ReflectionUtils.kt:49-100)도 byte 인덱스로 자르므로, 응답 길이가 스펙과 1바이트라도 어긋나면 이후 모든 필드가 밀린다(off-by-one 전파). if (byteRange.start >= bytes.size) 가드는 있으나(ReflectionUtils.kt:59) 부분 누락은 못 막는다.

5-2. SEED 키/IV는 String.toByteArray()(플랫폼 기본 charset) — UTF-8 명시 안 됨

// SeedEncryptor.kt:19-23
val keySpec = SecretKeySpec(seedKey.toByteArray(), "SEED")          // charset 미지정
val ivSpec = IvParameterSpec(iv.toByteArray())                      // charset 미지정
val encrypted = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))

키/IV는 플랫폼 기본 charset, 평문은 UTF-8 — 비대칭

seedKey.toByteArray()/iv.toByteArray() 는 JVM 기본 charset에 의존한다. 키/IV가 ASCII면 문제 없지만, SEED는 키 16바이트·IV 16바이트가 정확해야 한다. 길이가 안 맞으면 InvalidKeyException/InvalidAlgorithmParameterException. 평문만 UTF-8을 명시(line 23)했고 결과는 Base64 인코딩. SeedEncryptor 는 BouncyCastle(BC) provider에 의존한다(line 12).

5-3. 자격증명·카드정보가 로깅·로컬파일에 노출될 수 있음

  • SOAP 요청은 iden 속성에 u/p/agtpwd 평문(KoreanairClient.kt:556-562).
  • 로컬 프로파일이면 RQ/RS XML을 certi/yyMMdd/ 디렉터리에 그대로 파일로 저장한다(KoreanairClient.kt:64-68, 584-590). 주석 //certification 까지만 유지 — 인증 시기 한정 임시 코드.
  • NicePay RQ/RS 전문을 logger.info 로 통째 출력(KoreanairPaymentClient.kt:102,117). track2data는 SEED 암호화되지만 전문 자체가 로그에 남는다.

//certification 까지만 유지 주석 = 제거 예정인 디버그 코드가 운영 분기 옆에 남아 있음

env.isLocalProfile() 가드가 있어 운영에서는 파일이 안 써지지만, 프로파일 오설정 시 카드/인증정보 XML이 디스크에 평문 저장된다. 인증 종료 후 삭제되어야 할 코드가 남아 있는 상태.

5-4. 날짜·시간대 — toLocalDateTime() 오프셋 처리 불일치

위치코드동작
OrderViewResponse.kt:33,68,71ZonedDateTime.parse(it).toLocalDateTime()파싱된 원 오프셋의 wall-clock 을 그대로 떼어냄(KST 변환 안 함)
OrderReshopRS.kt:98ZonedDateTime.parse(it).toLocalDateTime(ZoneId.of("Asia/Seoul"))KST로 변환 후 LocalDateTime

같은 응답군인데 시간 변환 정책이 다르다

DateExtensions.kt:30-31 의 커스텀 toLocalDateTime(zoneId)withZoneSameInstant(zoneId)KST 환산한다. 그런데 OrderViewResponse 는 인자 없는 표준 ZonedDateTime.toLocalDateTime() 를 써서 환산 없이 오프셋만 버린다. KE가 시간을 +09:00 로 주면 결과는 같지만, UTC나 다른 오프셋으로 주면 PNR 생성시각/결제기한(paymentTimeLimit)이 9시간 어긋난다. 결제기한 비교(cancelablenow() 는 KST)와 섞이면 만료 판정 오류로 이어진다.

5-5. NicePay transactionNumber 생성 — 주 단위 + Base62, 충돌·연말 경계 위험

// NicePayApproveRequest.kt:129-135
val weekOfYear = localDateTime.get(WeekFields.of(Locale.KOREA).weekOfYear())
val dayOfWeek = localDateTime.dayOfWeek.value
return (pnr + "$weekOfYear$dayOfWeek$year$time".toLong().toBase62Encoded()).padStart(12, '0')

거래일련번호가 초 단위 — 같은 PNR이 1초 내 두 번 결제하면 충돌 가능

시간 해상도가 HHmmss(초)다. 동일 PNR로 1초 이내 재시도하면 transactionNumber 가 동일해질 수 있다. 또한 weekOfYearLocale.KOREA 기준이라 연초/연말 주차 경계에서 값이 점프한다. 망상취소가 이 번호로 매칭되므로(주석 “전문 고유번호(망상취소 시 사용)”), 충돌은 엉뚱한 거래 취소 위험. 최종 결과를 padStart(12,'0') 하지만, pnr+... 합이 12바이트(@ByteRange 29~41)를 넘으면 §5-1 규칙대로 잘려서 고유성이 더 떨어진다.


6. 재발행·환불·부분취소 엣지케이스

6-1. 재발행 카드 결제 미구현 — payment = null // TODO

// KoreanairTicketingService.kt:107-111
val reissuedBooking = koreanairClient.reissue(
    booking = booking, fareItinerary = fareItinerary,
    payment = null // TODO: 카드 결제 기능 추가 필요
)

재발행 시 추가요금 카드결제가 아예 안 들어간다

reissuepayment=null 고정. 즉 추가 징수가 필요한 재발행에서 결제 augmentationPoint가 비어 KE로 나간다(OrderChangeRQ.ofReissuecreatePaymentAugmentationPoints 미호출, OrderChangeRQ.kt:109). reissueDetail이 금액 감소만 차단(§4-5)하므로 “추가요금 있는 재발행”은 현재 결제 없이 시도되어 KE에서 거부되거나 미수금이 발생할 수 있다. 미완성 기능임을 인지하라.

6-2. 재발행 후 5초 sleep 워크어라운드 — 수하물 누락 회피

// KoreanairTicketingService.kt:113-122
// 재발행 이후 수하물 정보 누락 이슈로 5초 대기 후 retrieve 응답 조회 처리
return withBlocking { delay(5000); koreanairClient.retrieve(reissuedBooking.supplierIdentificationKey).let { ... } }

delay(5000) 고정 대기 — KE 반영 지연에 대한 추측성 워크어라운드

KE가 재발행 직후 수하물을 비동기로 채우는 타이밍 이슈를 5초 고정 sleep으로 회피한다. 5초 안에 안 채워지면 여전히 누락이고, 채워졌어도 5초를 무조건 까먹는다. 게다가 retrieve 결과(it)를 받아놓고 실제로는 reissuedBooking/reissuedBooking.passengers 를 반환한다(line 116-120) — retrieve 호출은 “5초 흘려보내기 + KE 반영 트리거” 용도로만 쓰이고 그 데이터는 버려진다. 의도 대비 코드가 어긋난 듯한 부분.

6-3. reissue는 폴링(deferred) 비동기 — 클라이언트가 polling 키로 결과를 받음

POST /additionpolling{}(KoreanairTicketingController.kt:54-67)으로 Redis 폴링 키를 돌려주고, GET /addition/{reissueKey} 로 결과를 회수한다. PENDING/ERROR/COMPLETE 3상태. ERROR면 throw throwable!!(line 78)로 원 예외 재던짐. 즉 재발행 실패는 폴링 시점에 표면화된다.

6-4. 부분취소(split, PNR 분리)의 강한 제약

KoreanairBookingService.splitPnr + validate(KoreanairBookingService.kt:82-166):

규칙위반 시
분리 대상 ≤ 2명size>2 → DIVIDE_FAILED (:113)
size==2면 반드시 (성인 1 + 유아 1)아니면 DIVIDE_FAILED (:107)
요청 승객이 retrieve 승객에 존재미스매치 → DIVIDE_FAILED (:122)
유아-성인 페어 일관성깨지면 DIVIDE_FAILED (:138,151,157)
// KoreanairBookingService.kt:88
splitPassengerId = passengers.first { it.type != PassengerType.INFANT }.identificationKey
// 유아는 부모 탑승객 자동으로 따라감(유아 승객 RQ 생성시 오류 발생)

유아 단독 분리 불가 + split 후 즉시 재조회로 pnrCreatedAt 보정

유아만 분리하려 하면 first{ type != INFANT }NoSuchElementException(유아밖에 없으면). 또 split 직후 retrieve 로 다시 읽어 pnrCreatedAt = lastModifiedAt ?: pnrCreatedAt 로 덮어쓴다(KoreanairBookingService.kt:90-92) — split 직후 KE 데이터가 안정화 안 됐으면 부정확할 수 있다.

6-5. unissued 판정이 “전원 무발권”이라 부분발권 PNR은 issuedCancel로 감

// support/model/Booking.kt:23-24
val unissued: Boolean get() = passengers.all { it.tickets.isNullOrEmpty() }

cancel()booking.unissuedunissuedCancel(VOID), 아니면 issuedCancel(환불계산 경유)로 분기(KoreanairCancelService.kt:25-50).

한 명이라도 발권됐으면 전체가 "발권됨"으로 분기 → 부분발권 PNR 취소가 위험

all{ tickets.isNullOrEmpty() }전원 무발권일 때만 unissued=true. 일부만 발권된 PNR(부분발권)은 unissued=false → issuedCancel 경로로 가 환불계산을 돌린다. 부분발권 상태에서 환불계산/취소가 KE 정책상 어떻게 처리되는지 검증 없이 일괄 issuedCancel로 보내므로, 미발권 승객까지 환불 로직에 섞일 수 있다.


7. 코드 내 TODO/FIXME/주석 경고 전수 인용

파일:라인인용의미
KoreanairPaymentClient.kt:50//TODO 에러 코드 매핑결제 거절코드 미분기, 전부 PAYMENT_ETC (§1-2)
KoreanairTicketingService.kt:110payment = null // TODO: 카드 결제 기능 추가 필요재발행 카드결제 미구현 (§6-1)
KoreanairTicketingService.kt:113// 재발행 이후 수하물 정보 누락 이슈로 5초 대기 후 retrieve5초 sleep 워크어라운드 (§6-2)
KoreanairClient.kt:63,584//certification 까지만 유지로컬 RQ/RS 파일 덤프 = 인증 한정 임시코드 (§5-3)
KoreanairClient.kt:135-136// 325: No inventory / 719: No fares정상 흡수 코드 매직 스트링 (§1-1)
KoreanairBookingService.kt:88//유아는 부모 탑승객 자동으로 따라감(유아 승객 RQ 생성시 오류 발생)유아 split RQ 생성 시 KE 오류 (§6-4)
KoreanairFlightSearchService.kt:161// 기존 예약과 동일한 스케쥴인지 확인하여 중복 제거reissueSearch 중복 제거 의존
AirShoppingRS.kt:57//2구간일 경우 offer 분할 되어 옵니다.2구간 응답 구조가 달라 별도 파싱(separatedOffers...)
NicePayApproveResponse.kt:96-98// 카드번호/유효기간/할부는 NicePay 응답에 미포함결제 응답에서 카드정보 재구성 불가, 요청값 재사용
Ticket.kt:72//패널티 금액을 tax로 처리재발행 차액의 패널티를 tax 항목에 욱여넣음 (§4-4 연관)
OrderViewResponse.kt:50// Only active segmentsCONFIRMED 서비스의 세그먼트만 스케줄로 노출

신입 자가진단

다음을 코드 근거와 함께 설명할 수 있으면 KE 함정을 이해한 것이다.

  1. 발권 중간에 예외가 나면 결제와 예약은 각각 어떻게 롤백되며, 그 롤백이 실패하면 누가 알게 되나?
  2. KE 응답이 운임을 USD BaseAmount로만 줄 때 우리 시스템에는 얼마로 기록되나?
  3. 재발행에 추가요금이 있으면 지금 코드는 결제를 어떻게 처리하나?

[!answer]- 정답 보기

  1. 결제는 paymentCancelAsync(5초 뒤), 예약은 cancelAsync(10초 뒤)가 CoroutineScope(Dispatchers.IO) 에서 fire-and-forget으로 돈다(KoreanairTicketingService.kt:89-155). 실패하면 AdapterCoroutineExceptionHandler 가 Sentry/로그로만 남기고, 결제취소 실패는 slackService.sendPaymentCancelFail 로 Slack 알림(§3-3). 호출자에게는 안 올라간다.
  2. Price.airPrice(response/Price.kt:32-33)가 KRW Equiv가 없으면 BaseAmount.value(USD 숫자)를 환율 적용 없이 그대로 반환 → USD 금액이 원화 숫자로 기록된다(§4-1). 통화 검증 없음.
  3. reissue(..., payment = null)(KoreanairTicketingService.kt:110) — 카드결제 미구현 TODO. 결제 augmentationPoint 없이 KE로 나가 거부/미수금 위험(§6-1).

관련 노트