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):
| 코드 | 의미 | 처리 |
|---|---|---|
325 | No inventory available | 예외 던지지 않고 빈 결과로 통과 |
719 | No 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 | 발생 지점 |
|---|---|---|
| search | SEARCH_FAILED | KoreanairClient.kt:139,157 / KoreanairFlightSearchService.kt:75 |
| pricing | PRICING_FAILED | KoreanairClient.kt:192,197 |
| book | BOOKING_FAILED | KoreanairClient.kt:229,234 |
| retrieve | RETRIEVE_FAILED, ALREADY_CANCELED_PNR | KoreanairClient.kt:256,262,269 |
| issue/reissue | TICKETING_FAILED | KoreanairClient.kt:291,299,539,544 |
| cancel | CANCEL_FAILED | KoreanairClient.kt:315,319,341,348 |
| refundCalculate | CALCULATE_CANCEL_FEE_FAILED | KoreanairClient.kt:373,382 / OrderReshopRS.kt:51 |
| changeApis | PASSENGER_CHANGE_FAILED | KoreanairClient.kt:407,415 |
| split | DIVIDE_FAILED | KoreanairClient.kt:436,444 / KoreanairBookingService.kt:107~160 |
| reissueSearch | REISSUE_SEARCH_FAILED | KoreanairClient.kt:499,517 |
| reissueDetail | REISSUE_NON_CHANGEABLE_FARE_SCHEDULE | KoreanairSearchController.kt:97 |
| 발권 전 상태검증 | NOT_OK_SCHEDULE, TICKETING_FAILED | KoreanairTicketingService.kt:45,54 |
| payment | PAYMENT_ETC, PAYMENT_CANCEL_FAILED | KoreanairPaymentClient.kt:53,77 |
| 채널/퍼널 미존재 | NOT_SUPPORTED_SALES_CHANNEL/FUNNEL | Properties.kt:573,586 |
reissueSearch만 두 번째checkError오버로드를 쓴다
OrderReshopRS.kt:35의checkError(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 적용 여부 |
|---|---|
@CircuitBreaker | search 컨트롤러 1곳만 |
@Retry | 없음 |
@Bulkhead | 없음 |
@RateLimiter / @TimeLimiter | 없음 |
서킷브레이커 설정(application.yml:67-68 → baseConfig: search, 41-47):
| 항목 | 값 |
|---|---|
| slidingWindow | 180s, TIME_BASED |
| minimumNumberOfCalls | 30 |
| failureRateThreshold | 35% |
| waitDurationInOpenState | 120s |
| 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을 의심하라(Datadogsupplier.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/issuedCancel 은 failure.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면toCancelableTypeDetail이CALCULATE_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,71 | ZonedDateTime.parse(it).toLocalDateTime() | 파싱된 원 오프셋의 wall-clock 을 그대로 떼어냄(KST 변환 안 함) |
OrderReshopRS.kt:98 | ZonedDateTime.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시간 어긋난다. 결제기한 비교(cancelable의now()는 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가 동일해질 수 있다. 또한weekOfYear는Locale.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: 카드 결제 기능 추가 필요
)재발행 시 추가요금 카드결제가 아예 안 들어간다
reissue는payment=null고정. 즉 추가 징수가 필요한 재발행에서 결제 augmentationPoint가 비어 KE로 나간다(OrderChangeRQ.ofReissue→createPaymentAugmentationPoints미호출,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 /addition 은 polling{}(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.unissued 면 unissuedCancel(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:110 | payment = null // TODO: 카드 결제 기능 추가 필요 | 재발행 카드결제 미구현 (§6-1) |
KoreanairTicketingService.kt:113 | // 재발행 이후 수하물 정보 누락 이슈로 5초 대기 후 retrieve | 5초 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 segments | CONFIRMED 서비스의 세그먼트만 스케줄로 노출 |
신입 자가진단
다음을 코드 근거와 함께 설명할 수 있으면 KE 함정을 이해한 것이다.
- 발권 중간에 예외가 나면 결제와 예약은 각각 어떻게 롤백되며, 그 롤백이 실패하면 누가 알게 되나?
- KE 응답이 운임을 USD
BaseAmount로만 줄 때 우리 시스템에는 얼마로 기록되나?- 재발행에 추가요금이 있으면 지금 코드는 결제를 어떻게 처리하나?
[!answer]- 정답 보기
- 결제는
paymentCancelAsync(5초 뒤), 예약은cancelAsync(10초 뒤)가 새CoroutineScope(Dispatchers.IO)에서 fire-and-forget으로 돈다(KoreanairTicketingService.kt:89-155). 실패하면AdapterCoroutineExceptionHandler가 Sentry/로그로만 남기고, 결제취소 실패는slackService.sendPaymentCancelFail로 Slack 알림(§3-3). 호출자에게는 안 올라간다.Price.airPrice(response/Price.kt:32-33)가 KRW Equiv가 없으면BaseAmount.value(USD 숫자)를 환율 적용 없이 그대로 반환 → USD 금액이 원화 숫자로 기록된다(§4-1). 통화 검증 없음.reissue(..., payment = null)(KoreanairTicketingService.kt:110) — 카드결제 미구현 TODO. 결제 augmentationPoint 없이 KE로 나가 거부/미수금 위험(§6-1).
관련 노트
- KE 오퍼레이션별 흐름 — 각 함정이 어느 단계에서 터지는지
- NicePay 프로토콜 — 인코딩·전문 구조 상세
- 공통 예외 처리 — InternationalAdapterException / capture / 코루틴 핸들러
- Resilience4j & Slack 경보 — 서킷브레이커·보상·이벤트 전파
- 전체 지뢰 인덱스
- 타 NDC(SQ) 분석 비교