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이면errorType을 code 자리에,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/ERR361 | logger.warn 후 emptyList() |
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_1628 | overbook 접근 불가 | 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) |
cancelBooking | BKG_BOE_64 | 이미 취소됨 | StatusInvalidException(ALREADY_CANCELED_PNR) |
getCancelInfo/cancelBooking | BKG_CONCURRECY_01 | PNR 잠금(동시성) | 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)은 markSeat → createBooking 순서다.
flowchart LR A["doPricing"] --> B["markSeat()"] B -->|"pnrSessionId 좌석 점유"| C["createBooking(pnrSessionId)"]
markSeat()가pnrSessionId(단기 좌석 점유 상태)를 반환하고, 이 ID를createBooking에 그대로 전달한다.- 두 호출 사이 지연이 생기면 진에어 측 점유가 만료될 수 있다(만료 감지/재점유 로직 없음, 함정 7 참고).
함정 7:
markSeat와createBooking사이의 시간차 = 점유 만료 위험
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.confirmed는status == 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-58의 jinairSearch → 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): CancelInfoshouldCancelRetryable(JinairClient.kt:1086-1088)는 (exception as? ApiException)?.retryable ?: false. retryable은 BKG_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/cancelBooking은JinairClient의 public 메서드를 다른 빈이 호출할 때만 재시도가 적용된다.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이라 실패해도 응답엔 안 보인다
cancelAsync는CoroutineScope(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"하드코딩FareInfo의currency = "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 } ?: 0TaxCode.FUEL_SURCHARGE = "YR"(support/enums/TaxCode.kt:4).
함정 14: 검색과 운임확정의 세금/유류할증 분해 로직이 다르다
검색(AirAvailabilityRS)은
SurchargeXML 필드로, 운임확정(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 object에lateinit 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, originalTicketIssueDate는 withZoneSameInstant(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 후보마다 inboundReissueSearch를 pmap으로 병렬 호출해 조합한다.
함정 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:68 | TODO | //TODO 확정 예약이 아닐경우 PNR 취소 처리 | 미확정 PNR 정리 미구현 (§2-3, 함정 8) |
| JinairBookingService.kt:77 | TODO | //TODO 탑승객 연락처 pnr 저장 | 연락처 저장 미구현 (§2-3) |
| EmdInformation.kt:49 | TODO | else -> false // TODO 언제 생기는지 확인필요 (DEFAULT, PER PNR, PAX/OandD) | EMD fee applicationType 일부 미처리 → 해당 부가요금 누락 |
| JinairAncillaryController.kt:27, 36 | FIXME | // FIXME: View 변경으로 임시 처리합니다. | it.first().availAncillaries로 첫 요소만 노출(임시) |
| JinairCipher.kt:67 | Deprecated | @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:369 | it.body!!.pnrSessionId!! | markSeat 응답에 세션 없으면 NPE |
| JinairBookingRS.kt:39 | validatingCarrier = airlineCode!! | (retrieve는 별도로 null 체크 후 PARSE_FAILED, JinairClient.kt:573-578) |
| JinairBookingRS.kt:57 | ZonedDateTime.parse(creationDateAndTime!!) | 생성일시 없으면 NPE/DateTimeParseException |
| ItinPrice.kt:53 | priceBreakDown.appliedFareDetails!!.first() | 운임 분해 없으면 NPE |
| GuestDetail.kt:122, 129 | guestPaymentInfos!! | 결제정보 없으면 NPE |
| ConfirmPriceRS.kt:55-56 | itineraries!!.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 참고.
연관 노트
- 정상 흐름·오퍼레이션: jinair-operations
- 프로토콜(XML/SEED 암호화/봉투): jinair-protocol
- 공통 예외 체계(ApiException/capture/retry): error-handling
- 서킷브레이커/리트라이/이벤트 전파: resilience-and-events
- 전체 지뢰 인덱스: landmines