Jin Air — 지뢰요소

module-jinair pattern-error-handling pattern-resilience api-lcc-rest

이 노트의 목적

Jin Air(LJ) 모듈을 운영/개선할 때 사람을 다치게 하는 함정을 전수 조사한 문서다. 단순 버그가 아니라 “결제는 됐는데 발권이 안 됐다”, “취소했는데 환불이 안 됐다”, “당일 출발편이 검색에 안 나온다” 같은 돈/CS 사고로 직결되는 함정 위주로 정리했다. 각 항목은 실제 코드 file:line을 명시한다.

흐름·정상 동작은 jinair-operations, 프로토콜(XML SOAP-over-JSON, SEED 암호화)은 jinair-protocol 참고. 공통 예외 체계는 error-handling, 서킷브레이커/리트라이는 resilience-and-events, 전체 지뢰 인덱스는 landmines.

Jin Air는 IBS iRes(LCC PSS) 기반의 REST API다. GDS(Amadeus/Sabre)처럼 명시적 세션 토큰은 없지만, markSeat가 반환하는 pnrSessionId라는 단기 좌석 점유 상태, 결제 게이트웨이(PG) 비동기 에러 코드, 재발행 시 운임 매칭 등에서 함정이 집중돼 있다.


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

1-1. 에러는 “두 군데”에서 나온다 — errorType(envelope) vs body.errorType

JinairResponse.checkError(infrastructure/response/JinairResponse.kt:22-37)는 에러를 두 단계로 검사한다.

fun checkError(bodyErrorType: ErrorType?, callback: ((code: String, message: String) -> Unit)) {
    if (this.errorType == "Error") {                       // ① envelope 레벨
        val errorMessage = this.errorMessage ?: "Unknown Error"
        if (!errorMessage.contains("Please check departure/arrival airport code")) {
            throw InternationalAdapterException(ErrorMessage.INTERNAL_SERVER_ERROR, ...).capture()
        }
    }
    bodyErrorType?.run { callback(this.errorCode, this.errorValue) }  // ② body 레벨
}

함정 1: envelope errorType == "Error"는 콜백을 못 탄다

envelope 레벨 에러는 무조건 INTERNAL_SERVER_ERROR로 통째로 던진다. 호출부의 callback(code/message로 분기하는 로직)은 실행되지 않는다.search/issue 등에서 정성껏 작성한 when(code) 분기는 오직 body.errorType(=ErrorType 데이터)로 들어온 에러만 처리한다. 진에어가 같은 의미의 에러를 envelope로 올리느냐 body로 올리느냐에 따라 분기 결과가 완전히 달라진다.

그리고 "Please check departure/arrival airport code" 한 문구만 예외적으로 통과시키는 하드코딩이 있다(JinairResponse.kt:25). 메시지 문자열 매칭이라 진에어가 문구를 바꾸면 깨진다.

JSON 응답은 검사 로직이 다르다

JinairJsonBodyResponse.checkError(JinairResponse.kt:65-69)는 별도 오버로드로, errorType != null이면 errorTypecode 자리에, errorMessage를 message 자리에 넣어 콜백한다. seatmap/fareRule(JSON body)과 availability/reservation(XML body)에서 검사 시그니처가 다르므로 혼동 주의.

1-2. Search 경고 코드 — 던지지 않고 삼킨다

JinairClient.search(JinairClient.kt:108-131)는 일부 코드를 “정상 빈 결과”로 처리한다.

분기코드동작
경고(warn 후 무시)AVAILABILITY_069(Flight not found), AVAILABILITY_072(Mileage not defined), AVAILABILITY_8001(과거일자), WS_321(No flights), WS_1111(busy), ERR016/ERR193/ERR147/ERR361logger.warnemptyList()
Validation Failed로 시작startWithErrorCodes 매칭경고로 간주
그 외모든 코드SEARCH_FAILED throw + .capture()

경고 코드 목록은 AirAvailabilityRS.warningCodes/startWithErrorCodes(AirAvailabilityRS.kt:33-49)에 하드코딩.

함정 2: WS_1111("시스템 혼잡, 재시도하세요")을 경고로 삼킨다

WS_1111은 명백히 재시도 가능한 일시 장애인데 search에서는 빈 결과로 처리한다. 즉 진에어 PSS가 잠깐 바빠서 항공편이 안 나온 것을 “해당 노선 없음”과 구분 없이 빈 리스트로 반환 → 고객은 멀쩡한 항공편을 못 본다. 서킷브레이커(jinairSearch)는 예외가 아니라 빈 결과로 종료되므로 이 실패를 카운트하지 못한다.

reissueSearch는 경고 코드 처리 방식이 다르다

재발행 검색(JinairClient.kt:890-901)은 when(code)"AVAILABILITY_069","AVAILABILITY_072","AVAILABILITY_8001","WS_321","WS_1111"만 warn하고 그 외(Validation Failed 류 포함)는 전부 throw한다. 일반 search의 startWithErrorCodes 매칭이 빠져 있어 같은 진에어 에러라도 일반검색은 통과/재발행검색은 예외가 될 수 있다. 두 경로의 화이트리스트가 동기화되어 있지 않다.

1-3. 예약/취소/발권 단계별 특수 코드 분기

각 단계에서 진에어 고유 코드를 StatusInvalidException(비즈니스 상태 오류) / InternationalAdapterException(시스템 오류) / .retry()로 분기한다.

단계(메서드:line)코드의미처리
markSeat(JinairClient.kt:363)BKG_BOE_1628overbook 접근 불가StatusInvalidException(SOLD_OUT)
createBooking(JinairClient.kt:404-407)BKG_BOE_1500/BKG_TLT_7/BKG_BOE_208시간제한/즉시결제필요/동일여권중복주석만 있고 분기 없음 → 전부 BOOKING_FAILED
getCancelInfo/cancelBooking(JinairClient.kt:610-631, 665-695)BKG_BOE_46/BKG_BOE_5체크인됨/삭제불가StatusInvalidException(CANCEL_UNABLE)
cancelBookingBKG_BOE_64이미 취소됨StatusInvalidException(ALREADY_CANCELED_PNR)
getCancelInfo/cancelBookingBKG_CONCURRECY_01PNR 잠금(동시성)InternationalAdapterException(LOCKED_PNR).retry()

함정 3: createBooking의 특수 코드는 "주석으로만" 존재한다

JinairClient.kt:404-406의 BKG_BOE_1500, BKG_TLT_7, BKG_BOE_208주석으로만 적혀 있고 실제 when 분기가 없다. 셋 다 ErrorMessage.BOOKING_FAILED로 뭉뚱그려진다. 특히 BKG_TLT_7(“즉시 전액 결제 없이는 PNR 저장 불가”)는 당일 출발편 정책과 직결되는데(아래 §2-1) 별도 메시지로 구분되지 않아 CS가 원인을 모른 채 받는다.

함정 4: BKG_CONCURRECY_01(오타 포함) — Resilience4j가 아니라 Spring @Retryable로 재시도

코드 상수 이름이 BKG_CONCURRECY_01(CONCURRENCY 오타)이다. 진에어 응답 코드 그대로이므로 절대 고치면 안 된다. 이 코드는 .retry()exception.retryable=true를 세팅하고, @Retryable(exceptionExpression="@jinairClient.shouldCancelRetryable(#root)")가 이를 보고 재시도한다(§3 참고).

1-4. 결제(PG) 에러 코드 매핑 — PaymentError enum

발권/재발행에서 PG 결제 실패는 코드 prefix로 분기된다(JinairClient.kt:482-517, 1016-1051).

code == "WS_000" && message.startsWith("PYM_") -> { /* 비동기로 상세 메시지 조회 후 */ MethodArgumentInvalidException(PAYMENT_INVALID_CARD_INFORMATION ...).capture(silence = true) }
code.startsWith("PAYMENT_") || "PYM_" || "AMEX_" || "FDMS_" -> MethodArgumentInvalidException(PaymentError.getMessage(code), ...).capture(silence = true)
else -> InternationalAdapterException(TICKETING_FAILED ...).capture()

PaymentError(support/enums/PaymentError.kt)는 60여 개 PG 코드를 6종 ErrorMessage로 매핑. getMessage미등록 코드는 전부 PAYMENT_ETC로 폴백(PaymentError.kt:66-68).

함정 5: 결제 에러는 silence = true로 Slack/Sentry 알림이 꺼진다

결제 실패는 MethodArgumentInvalidException(...).capture(silence = true)로 던진다. capture(silence=true)capturable=true, notifiable=false를 세팅(Exceptions.kt:11-15) → Sentry는 캡처하되 Slack 알림은 안 간다. “고객 카드 문제”로 간주하기 때문. 하지만 PAYMENT_ETC 폴백이 자주 발생하면 실제 PG 연동 장애를 놓칠 수 있다. PG 코드 추가 시 PaymentError.kt에 등록하지 않으면 영영 PAYMENT_ETC로 묻힌다.

WS_000 + PYM_ 메시지: 상세 에러를 사후 비동기 조회

이 케이스는 CoroutineScope(Dispatchers.IO).withLaunch { getTicketingErrorMessage(clientSessionId) }로 PG의 authFailRs 엔드포인트를 fire-and-forget으로 호출해 logger.warn만 남긴다(JinairClient.kt:484-488). 사용자 응답에는 반영되지 않고 로그 디버깅용이다. 이 코루틴이 실패해도 발권 흐름엔 영향 없음(§5 fire-and-forget 함정 참고).


2. 상태 / 세션 함정

2-1. 당일/익일 출발편은 검색 자체가 막혀 있다 (정책 하드코딩)

JinairSearchController.search(JinairSearchController.kt:30-33):

//진에어는 24시간 이내 출발편 스케쥴 예약시 예약과 결제가 동시에 처리되야하므로 당일/익일 검색은 불가 처리
if (request.departureDate < today().plusDays(2)) {
    return emptyList()
}

함정 6: today()는 KST 고정, 컷오프는 "+2일"

today()Asia/Seoul 고정(DateExtensions.kt:9). departureDate < today().plusDays(2)이면 빈 결과 → 모레부터 검색 가능. 진에어 iRes는 24시간 이내 출발편은 예약+결제 동시(BKG_TLT_7) 처리가 필요한데 어댑터가 분리 흐름(예약→결제)이라 막아둔 것. 단순 검색 장애로 오인하기 쉽다. 출발임박편이 “왜 진에어만 안 나오지?”의 거의 항상의 답이다.

2-2. pnrSessionId — 명시적이지 않은 단기 좌석 점유 상태

예약 흐름(JinairBookingService.book, JinairBookingService.kt:53-63)은 markSeatcreateBooking 순서다.

flowchart LR
    A["doPricing"] --> B["markSeat()"]
    B -->|"pnrSessionId 좌석 점유"| C["createBooking(pnrSessionId)"]
  • markSeat()pnrSessionId(단기 좌석 점유 상태)를 반환하고, 이 ID를 createBooking에 그대로 전달한다.
  • 두 호출 사이 지연이 생기면 진에어 측 점유가 만료될 수 있다(만료 감지/재점유 로직 없음, 함정 7 참고).

함정 7: markSeatcreateBooking 사이의 시간차 = 점유 만료 위험

markSeat(JinairClient.kt:344-373)는 좌석을 임시 점유하고 pnrSessionId!!(non-null 단언)를 반환한다. 이 ID를 createBooking에 넘기는데, 두 호출 사이에 지연이 생기면 진에어 측 점유가 만료될 수 있다. 어댑터에는 만료 감지/재점유 로직이 없다 — 만료되면 createBooking이 임의의 BOOKING_FAILED를 던질 뿐 별도 처리가 없다. pnrSessionId!!가 null이면 KotlinNullPointerException으로 죽는다.

2-3. 예약은 됐지만 confirmed가 아니면 — PNR이 떠 있는 채로 예외 (미구현 TODO)

if (booking.schedules.any { !it.confirmed }) {
    //TODO 확정 예약이 아닐경우 PNR 취소 처리
    saveUnexposedFareItinerary(...)
    throw StatusInvalidException(ErrorMessage.SOLD_OUT, booking.pnr, "schedule is not confirmed")
}

함정 8: 미확정 PNR이 진에어 측에 남는다 (TODO 미구현)

JinairBookingService.kt:67-74. Schedule.confirmedstatus == SeatStatus.CONFIRMED.jinair(Schedule.kt:39-40). 미확정이면 SOLD_OUT을 던지지만 만들어진 PNR을 취소하는 로직이 TODO로 비어 있다. 좌석이 빠진 채 진에어 PSS에 유령 PNR이 남는다. saveUnexposedFareItinerary로 해당 운임을 캐시에서 가리기만 한다(다음 검색에서 노출 제외). 운영자가 수동 정리해야 할 수 있다.

또 다른 미구현 TODO: 탑승객 연락처 저장

JinairBookingService.kt:76-78. if (passengers.any { it.mobile != null || it.emergencyContact != null }) { //TODO 탑승객 연락처 pnr 저장 } — 조건만 있고 본문이 비어 있다. mobile/비상연락처가 들어와도 PNR에 저장되지 않는다.

2-4. 검색 키 / 캐시 키 파싱 — 구분자 의존

getCombinedFareItinerary(JinairFlightSearchService.kt:126-137)는 combinedKey"::""_"로 쪼갠다(destructKey). 키 포맷이 깨지거나 운임 항목이 Redis에서 만료(GZIP 직렬화, JinairRedisConfiguration.kt)되면 getFareItinerary가 던지는 예외로 예약/발권이 실패한다. 검색-예약 사이 캐시 TTL이 곧 예약 가능 윈도우다.


3. @Retry / @CircuitBreaker 동작과 폴백

3-1. 서킷브레이커는 “검색”에만 걸려 있다

@CircuitBreaker(name = "jinairSearch", fallbackMethod = "searchFallback")JinairSearchController.search에만 붙어 있다(JinairSearchController.kt:26). 설정은 application.yml:57-58jinairSearch → baseConfig: search(공통 search 설정 상속: slidingWindow 180s/time-based, failureRate 35%, open 120s).

폴백(JinairSearchController.kt:122-130):

private fun searchFallback(exception: CallNotPermittedException): List<FareItineraryView> {
    // Datadog span에 supplier.circuit-breaker=OPEN 태그만 찍고
    return emptyList()  // 빈 결과 반환
}

함정 9: 예약/발권/취소에는 서킷브레이커가 없다

detail, reissueSearch, reissueDetail(같은 컨트롤러), 그리고 예약/발권/취소/재발행 컨트롤러 전체에는 @CircuitBreaker가 없다. 진에어 PSS가 죽어도 결제·발권 호출은 그대로 나간다. 검색만 보호받고 돈이 오가는 경로는 무방비. 또한 폴백 시그니처가 CallNotPermittedException만 받으므로 회로 OPEN 상태에서 차단된 호출만 빈 결과로 처리되고, 일반 실패는 폴백을 타지 않고 그대로 전파된다(§1-2 처럼 search 내부에서 이미 emptyList로 흡수한 것 제외).

3-2. @Retryable은 취소 경로 2곳에만, 조건은 exception.retryable

@Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000),
           exceptionExpression = "@jinairClient.shouldCancelRetryable(#root)")
fun getCancelInfo(pnr: String): CancelInfo
 
@Retryable(maxAttempts = 2, backoff = Backoff(delay = 5000), ...)
fun cancelBooking(cancelInfo: CancelInfo): CancelInfo

shouldCancelRetryable(JinairClient.kt:1086-1088)는 (exception as? ApiException)?.retryable ?: false. retryableBKG_CONCURRECY_01에서만 .retry()로 true가 된다.

함정 10: 재시도 횟수가 비대칭이다 (getCancelInfo=3, cancelBooking=2)

같은 PNR 잠금이라도 getCancelInfo는 3회·cancelBooking은 2회 재시도한다. 둘 다 backoff 5초 고정(지수증가 아님). cancelBooking은 멱등이 아닌 변경 작업인데, 진에어가 응답을 늦게 주고 실제로는 취소가 됐는데 타임아웃 → 재시도하면 BKG_BOE_64(이미 취소됨)를 받아 ALREADY_CANCELED_PNR로 끝난다(다행히 방어됨). 하지만 backoff 5초 × 재시도는 응답 지연을 키워 상위 호출 타임아웃을 유발할 수 있다.

@Retryable은 Spring AOP 프록시 경유에서만 동작

getCancelInfo/cancelBookingJinairClientpublic 메서드를 다른 빈이 호출할 때만 재시도가 적용된다. JinairCancelService/JinairTicketingService가 주입받은 jinairClient를 통해 부르므로 정상 동작. 단 클래스 내부에서 this.getCancelInfo(...)로 self-invocation 하면 프록시를 안 거쳐 재시도가 무력화된다 — 현재는 self-invocation 없음.

3-3. 발권 실패 시 자동 취소 — 단, fire-and-forget

JinairTicketingService.issue(JinairTicketingService.kt:37-54)는 발권 예외 시:

} catch (e: Exception) {
    if (e !is MethodArgumentInvalidException) { cancelAsync(pnr) }  // 결제오류면 취소 안 함
    throw e
}

cancelAsync(JinairTicketingService.kt:71-86)는 코루틴으로 delay(5000)getCancelInfo→cancelBooking, 실패하면 slackService.sendCancelFail.

함정 11: 자동 취소가 fire-and-forget이라 실패해도 응답엔 안 보인다

cancelAsyncCoroutineScope(Dispatchers.IO).withLaunch { ... } 안에서 돈다. 발권 예외는 즉시 호출자에게 던져지지만, 보상 취소(자동 취소)는 백그라운드에서 진행된다. 취소까지 실패하면 Slack 경보만 가고 API 응답은 이미 끝난 상태 → 결제는 됐는데(또는 좌석만 잡혔는데) 취소도 실패한 PNR이 남을 수 있다. MethodArgumentInvalidException(=결제거절)일 때는 애초에 발권이 안 됐다고 보고 취소를 건너뛴다 — 만약 PG 승인 후 진에어 발권만 실패한 케이스를 결제오류로 오분류하면 결제 후 미발권이 방치된다.


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

4-1. 통화는 전 구간 KRW로 하드코딩, 환율은 "1.0"

GuestPaymentInfo.ofCard/ofCash(SaveModifyBookingRQ.kt:273-324)와 FareDetailsForGuestType.of(FareInfo.kt:81-92)에서:

  • paymentCurrency = "KRW", exchangeRate = "1.0" 하드코딩
  • FareInfocurrency = "KRW" 하드코딩

함정 12: KRW 전제 — 다통화 정산이 들어오면 깨진다

진에어 국제선(LJ)은 출발지가 국내라 KRW 전제가 현재 유효하지만, 응답의 PaxBaseFare.currencyCode/PointOfOriginCurrency는 무시되고 강제로 KRW로 기록한다. PG가 외화 결제를 요구하는 노선이 생기면 환율 1.0이 그대로 적용돼 금액 오류가 난다.

4-2. 세금(tax)에 유류할증료(surcharge)를 합산 — 이중 의미

검색 응답 매핑 PaxPricingInfo.toPassengerFare(AirAvailabilityRS.kt:384-407):

tax = tax.amount.toLong() + surcharge.amount.toLong(),  // tax + 유류할증
fuelCharge = surcharge.amount.toLong(),                  // 유류할증 별도 보관

함정 13: tax에 유류할증이 포함돼 있고 fuelCharge에도 동일 금액이 있다

tax 필드 = 순수세금 + 유류할증, fuelCharge = 유류할증. 두 필드를 단순 합산하면 유류할증을 이중 계산한다. Fare.total = airPrice + tax(Fare.kt:22-23)이므로 total에는 이미 유류할증이 1회 포함. 총액 계산 시 fuelCharge를 또 더하면 안 된다.

4-3. 운임확정(pricing)의 tax는 fareComponentId로 필터링, 유류할증은 YR 코드로

GuestPriceBreakDown.toFares(ItinPrice.kt:47-65):

val filteredTaxes = taxes?.filter { it.fareComponentId == fareDetail?.fareComponentId }
tax = filteredTaxes?.sumOf { it.amount } ?: 0
fuelCharge = filteredTaxes?.filter { it.code == TaxCode.FUEL_SURCHARGE.code }?.sumOf { it.amount } ?: 0

TaxCode.FUEL_SURCHARGE = "YR"(support/enums/TaxCode.kt:4).

함정 14: 검색과 운임확정의 세금/유류할증 분해 로직이 다르다

검색(AirAvailabilityRS)은 Surcharge XML 필드로, 운임확정(ItinPrice)은 세금 항목 중 code == "YR"로 유류할증을 식별한다. 진에어가 유류할증을 YR이 아닌 다른 코드(YQ 등)로 보내면 운임확정 단계에서 fuelCharge=0이 된다(전체 tax엔 포함). 또한 운임확정 tax는 fareComponentId가 일치해야만 합산 → 매핑이 어긋나면 세금이 누락된다.

4-4. 재발행 결제 금액 = reissuePrice

SaveModifyBookingRQ.ofReissue(SaveModifyBookingRQ.kt:152-160):

passenger.fares?.sumOf { it.reissuePrice }?.takeIf { it > 0 }?.let { GuestPaymentInfo.ofCard(...) }

Fare.reissuePrice = total + (carrierFee ?: 0)(Fare.kt:25-26).

함정 15: 재발행 금액이 0 이하면 결제정보를 안 만든다

takeIf { it > 0 }이므로 재발행 차액이 0이거나 환불(음수)이면 guestPaymentInfos에 해당 승객이 빠진다. 하향 재발행(환불 발생)에 대한 결제/환불 처리가 없다 — 진에어 재발행 API에 결제 노드 없이 보내져 운임 차감 처리가 진에어 정책에 맡겨진다.

4-5. 재발행 환불 운임은 paxAmountsToBePaid, 일반은 guestPriceBreakDowns

GuestDetail.toPassenger(GuestDetail.kt:75-96)는 isReissue 분기로 운임 출처를 바꾼다. 재발행이면 totalAmountDetails.paxAmountsToBePaid에서 찾고, 못 찾으면 airPrice=0, carrierFee=0의 더미 Fare를 만든다(GuestDetail.kt:80-86).

함정 16: 재발행 운임 매칭 실패 시 0원 Fare로 폴백

재발행 응답에서 paxId == guestId인 금액을 못 찾으면 조용히 0원 Fare를 만든다. 발권 완료처럼 보이지만 금액이 0으로 기록돼 정산/CS 데이터가 틀어진다. 예외가 아니라 폴백이라 모니터링에 안 잡힌다.


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

5-1. SEED 비대칭+대칭 하이브리드 암호화 (JinairCipher)

JinairCipher(support/util/JinairCipher.kt)는 결제 민감정보(카드번호/유효기간/CVV/카드명/비밀번호 앞 2자리 등)를 암호화한다. @Encrypt(cipher = JinairCipher::class)가 붙은 필드(SaveModifyBookingRQ.kt:229-256, 335-337)가 대상.

구조: nonce 40자 랜덤 생성 → 공개키(X.509, RSA/ECB/PKCS1Padding)로 nonce 암호화 → nonce의 SHA-256 앞 16바이트를 AES 키로 평문 암호화 → "${암호문}${DELIMITER}${암호화된nonce}" 형태로 합침.

private const val DELIMITER = "%~~`%~~~~~~~%^**(%$#%"
private val symmetricCipher = Cipher.getInstance("AES")  // ECB, no IV

함정 17: 복호화 미지원 — decrypt는 무조건 예외

JinairCipher.decrypt(JinairCipher.kt:67-69)는 @Deprecated("Jinair not supported decrypt algorithm") + throw Exception(...). 즉 한 번 암호화한 값은 어댑터에서 되돌릴 수 없다(진에어가 자기 개인키로만 복호화). 암호문 검증/로깅 시 평문 비교 불가.

함정 18: 공개키 맵이 lateinit companion object — 초기화 순서/스레드 함정

JinairSecureKey(JinairCipher.kt:19-40)는 companion objectlateinit var publicKeyMap/properties를 두고 @Autowired fun init()에서 채운다. JinairCipher 인스턴스의 asymmetricCipherMap생성 시점에 JinairSecureKey.publicKeyMap을 읽는다(JinairCipher.kt:48). init보다 먼저 JinairCipher가 생성되면 UninitializedPropertyAccessException. 클래스패스 인증서(/supplier/jinair/{payment.key})가 없으면 부팅 시 init에서 터진다.

함정 19: AES가 ECB 모드 (IV 없음) + getApiProperties()를 암호화 때마다 호출

Cipher.getInstance("AES")는 기본 ECB/PKCS5Padding(IV 없음). 보안상 약하지만 진에어 스펙. symmetricCipher싱글 인스턴스를 공유하므로 동시 암호화 시 Cipher 객체 스레드 비안전 문제 소지. 또 encrypt마다 JinairSecureKey.properties.getApiProperties()로 현재 MDC의 채널/퍼널을 다시 읽어 키를 고른다 — 암호화 시점의 MDC 컨텍스트가 발권 요청과 일치해야 한다(fire-and-forget 코루틴 내 암호화는 MDCContext 전파 필요, withLaunch는 MDCContext를 전파함).

5-2. CVV2 하드코딩 "999"

GuestPaymentInfo.ofCard(SaveModifyBookingRQ.kt:283, 305): cvv2Number = "999".

함정 20: CVV2를 실제 값이 아니라 "999" 더미로 보낸다

ofCard 모두 CVV2를 "999"로 고정. 진에어/PG가 CVV 검증을 안 하거나 키인 결제에서 무시한다는 전제. PG 정책이 바뀌어 CVV 검증을 켜면 전 건 결제 실패한다. 키인카드(PaymentInfo.KeyInCard)에는 CVV 필드가 없어 더미를 쓸 수밖에 없는 구조.

5-3. 카드비밀번호 앞 2자리, 카드사 식별 폴백 = BC

AdditionalCreditCardInfo.of(SaveModifyBookingRQ.kt:343-352): cardPassword = cardInfo.password.take(2), 개인은 생년월일 yyMMdd, 법인은 사업자번호. CardType.getCardType(support/enums/CardType.kt:24-26): BIN 매칭 실패 시 BC로 폴백.

함정 21: 미식별 카드는 전부 BC카드로 분류

entries.find{...} ?: BC — VISA/MASTER/AMEX 등 BIN에 안 걸리면 무조건 BC. 새 BIN 대역(예: 신규 MASTERCARD 2221-2720은 매핑돼 있으나)이 누락되면 오분류 → PG가 카드사 불일치로 거절할 수 있다. password.take(2)는 비밀번호 앞 2자리만 — 진에어 PG 스펙이지만 전체 비밀번호가 들어오면 앞 2자리만 잘려 나간다.

5-4. 날짜/시간대 — 티켓 발권일 비교에서 KST/UTC 혼선

PaxTicketDetail.toTicket(GuestDetail.kt:187-221)의 발권/원발권 시각 처리:

// 주석: IssueDate는 KST, OriginalTicketIssueDate는 UTC 라고 적혀 있으나...
val originalTicketIssueDateKST = LocalDateTime.ofInstant(Instant.parse(originalTicketIssueDateTime), ZoneId.of("Asia/Seoul"))
val issuedDateTimeKST = LocalDateTime.ofInstant(Instant.parse(issueDateTime), ZoneId.of("UTC"))  // ← UTC로 파싱
val isReissued = originalTicketIssueDateKST.isBefore(issuedDateTimeKST)

함정 22: 변수명과 실제 ZoneId가 뒤바뀌어 있다 (재발권 판정 오류 위험)

필드 주석(GuestDetail.kt:171-178)은 IssueDate=KST, OriginalTicketIssueDate=UTC라 적혀 있다. 그런데 코드에서는:

  • originalTicketIssueDateKST 변수에 Asia/Seoul 존을 적용
  • issuedDateTimeKST(이름은 KST) 변수에 UTC 존을 적용

이름과 적용 존이 서로 어긋나 있어, isReissued 비교가 최대 9시간(KST-UTC) 만큼 오차를 가질 수 있다. 발권 직후 같은 날 재발권 여부 판정이 틀어지면 결제 매칭 윈도우(±1분, GuestDetail.kt:199)가 어긋나 운임이 0으로 빠지거나 잘못된 결제건과 매칭된다. 수정 시 진에어 실제 응답으로 반드시 검증 필요 — 명백한 land mine.

5-5. isVoidable(당일 발권 = void 가능)도 KST 기준

Booking.isVoidable(Booking.kt:35-36):

get() = passengers.all { it.airTicket?.originalTicketIssueDate?.isEqual(today()) ?: false }

today()=KST, originalTicketIssueDatewithZoneSameInstant(Asia/Seoul).toLocalDate()(GuestDetail.kt:214-215).

함정 23: void 가능 판정이 발권일 KST 한정

모든 승객의 원발권일이 “오늘(KST)“이어야 void(전액취소). 자정 직전 발권 후 자정 넘어 취소하면 void가 아니라 환불(수수료)로 빠진다. 그리고 airTicket이 null인 승객이 하나라도 있으면 ?: false로 전체가 void 불가. 발권 안 된 승객 혼재 시 보수적으로 환불 경로를 탄다.


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

6-1. 부가서비스가 있으면 재발행(일정변경) 자체를 막는다

JinairFlightSearchService.reissueDetail(JinairFlightSearchService.kt:261-264):

if (originBooking.reference?.ancillaries?.isNotEmpty() == true) {
    throw InternationalAdapterException(ErrorMessage.NON_CHANGEABLE_SCHEDULES_BY_ANCILLARY)
}

함정 24: 부가서비스(수하물/좌석) 1건이라도 있으면 재발행 불가

부가서비스가 붙은 PNR은 일정변경(재발행) 불가. ancillaries 파싱(JinairBookingRS.kt:68-75)이 SSR/Fee/PaymentElement 3중 조건으로 추출되므로, 파싱이 과탐지하면 부가서비스 없는데도 재발행이 막힌다. 반대로 누락하면 부가서비스 있는데 재발행되어 진에어 측에서 거절.

6-2. 재발행 검색의 “출/도착지+편명+시각+캐빈 동일” 필터

reissueSearch(JinairClient.kt:923-932)는 원 스케줄과 편명/출발시각/캐빈이 같은 후보를 filterNot으로 제거(동일 항공편 재선택 방지). minimumAdultAirPrice(JinairClient.kt:913-922)는 원 운임 이상만 노출.

함정 25: 재발행 후보 가격 하한 = 원 운임 (상향만 노출)

pricingInfo.adultPrice >= minimumAdultAirPrice 필터(JinairClient.kt:181, toItineraries)로 원 운임보다 싼 대체편은 검색에서 제외된다. 하향 재발행을 막는 정책 구현이지만, 원 운임을 못 찾으면 BigDecimal.ZERO로 폴백(JinairClient.kt:922)해 모든 후보가 노출된다(필터 무력화). fares.find{...segmentIds.contains(cancelSchedule.reference.segmentId)} 매칭 실패가 곧 필터 해제.

6-3. 왕복 재발행의 inbound는 outbound 선택에 의존 (N×M 검색)

reissueSearch(JinairFlightSearchService.kt:184-226)는 왕복(size==2)일 때 outbound 후보마다 inboundReissueSearchpmap으로 병렬 호출해 조합한다.

함정 26: inbound 검색이 outbound 운임에 종속 → 부분 실패가 빈 조합

pmap{...}.onFailure{ if(successes.isEmpty()) throw }(JinairFlightSearchService.kt:206-213)이므로 일부 outbound의 inbound 검색이 실패해도 하나라도 성공하면 통과한다. 즉 일부 조합만 누락된 채 정상 응답으로 보일 수 있다. 진에어 PSS 부하 시 조합 수만큼 호출이 폭증한다.

6-4. 분리(divide)·취소의 검증 — 성인/유아 페어, 체크인

  • divide(JinairBookingService.kt:107-122): 성인↔유아 페어가 함께 분리되어야 함(validateAdultInfantPair/validateInfantParentPair). 깨지면 DIVIDE_FAILED.
  • 취소(checkIfCancelable, JinairCancelService.kt:60-79): checkedInPassengerExists(ticketStatus에 "CHECKED IN") → CANCEL_UNABLE_BY_ALREADY_CHECK_IN. nonCancelablePassengerExists(cancelable=CONFIRMED/NO_SHOW 외) → CANCEL_UNABLE.

함정 27: 취소 가능 상태 문자열 하드코딩

Passenger.cancelable(Passenger.kt:53-54)은 ticketStatus가 비면 true, 아니면 "CONFIRMED" 또는 "NO_SHOW"만 true. checkedInPassengerExists"CHECKED IN"(공백 포함) 문자열 매칭(Booking.kt:29-30). 진에어가 상태 문자열을 바꾸면(대소문자/공백) 판정이 깨진다. 부분취소 개념은 없고 PNR 전체 취소만 지원.

6-5. 취소/예약 성공 후 DSR 등록 = fire-and-forget (dev 프로파일 skip)

registerDsr(JinairClient.kt:768-797)은 env.isDevProfile()이면 즉시 return. 발권/재발행/취소/분리 성공 시 CoroutineScope(Dispatchers.IO).withLaunch { registerDsr(...) }로 비동기 호출하고 실패는 logger.error만.

함정 28: DSR(판매보고) 등록 실패는 로그만 남고 무시된다

DSR 등록은 fire-and-forget. 실패해도 본 트랜잭션은 성공으로 끝난다. 진에어 정산/판매보고가 누락될 수 있는데 알림(Slack)조차 없다(logger.error만). dev에서는 아예 호출 안 하므로 통합테스트에서 발견 불가.


7. 코드 내 TODO / FIXME / 주석 경고 (원문 인용)

위치종류원문의미
JinairBookingService.kt:68TODO//TODO 확정 예약이 아닐경우 PNR 취소 처리미확정 PNR 정리 미구현 (§2-3, 함정 8)
JinairBookingService.kt:77TODO//TODO 탑승객 연락처 pnr 저장연락처 저장 미구현 (§2-3)
EmdInformation.kt:49TODOelse -> false // TODO 언제 생기는지 확인필요 (DEFAULT, PER PNR, PAX/OandD)EMD fee applicationType 일부 미처리 → 해당 부가요금 누락
JinairAncillaryController.kt:27, 36FIXME// FIXME: View 변경으로 임시 처리합니다.it.first().availAncillaries로 첫 요소만 노출(임시)
JinairCipher.kt:67Deprecated@Deprecated("Jinair not supported decrypt algorithm")복호화 불가 (§5-1, 함정 17)

함정 29: 부가서비스 응답에서 첫 요소만 노출하는 FIXME

JinairAncillaryController의 두 searchAncillary.let { AncillaryAvailView.of(availAncillaries = it.first().availAncillaries) }리스트의 첫 항목만 View로 만든다(FIXME 임시처리). 왕복/다구간에서 두 번째 구간 이후 부가서비스가 잘려 나간다. baggage 쪽(searchBaggage)은 .map{...}으로 전부 노출되어 동작이 비대칭이다.

함정 30: EMD fee 매칭에서 미지의 applicationType은 무시(false)

EmdInformation.toTicket(EmdInformation.kt:42-49)은 applicationType"PAX/SEGMENT"/"PER PAX PER PNR"이 아니면 else -> false로 매칭 자체를 포기한다. 진에어가 DEFAULT/PER PNR/PAX/OandD로 EMD를 보내면 부가요금 결제정보가 RetrieveTicket에 안 잡힌다. 작성자도 “언제 생기는지 확인필요”라고 남김.


부록: non-null 단언(!!) 지뢰밭

진에어 응답 파싱은 !!(non-null assertion)이 광범위하다. 응답 누락 시 의미 없는 NullPointerException(KotlinNPE)으로 죽는다 — 진짜 원인을 가린다.

위치단언누락 시
JinairClient.kt:369it.body!!.pnrSessionId!!markSeat 응답에 세션 없으면 NPE
JinairBookingRS.kt:39validatingCarrier = airlineCode!!(retrieve는 별도로 null 체크 후 PARSE_FAILED, JinairClient.kt:573-578)
JinairBookingRS.kt:57ZonedDateTime.parse(creationDateAndTime!!)생성일시 없으면 NPE/DateTimeParseException
ItinPrice.kt:53priceBreakDown.appliedFareDetails!!.first()운임 분해 없으면 NPE
GuestDetail.kt:122, 129guestPaymentInfos!!결제정보 없으면 NPE
ConfirmPriceRS.kt:55-56itineraries!!.first(), itinPrices!!.first()빈 응답이면 NPE

디버깅 팁

진에어 연동에서 NullPointerException/NoSuchElementException(first())가 뜨면 거의 항상 진에어 응답 필드 누락이다. JinairClient.deserializerOf(JinairClient.kt:51-65)가 역직렬화 실패 시 raw body를 logger.info(ResponseLog...)로 남기므로, 해당 로그에서 실제 XML/JSON 페이로드를 확인하라. envelope는 JinairXmlBodyResponse(SOAP-over-JSON), 자세한 봉투 구조는 jinair-protocol 참고.


연관 노트