Jeju Air — 지뢰요소

module-jejuair pattern-error-handling pattern-resilience api-rest

이 노트의 목적

Jeju Air (7C, LCC/REST) 어댑터에서 운영 중 실제로 사람을 다치게 하는 함정만 모았다. 각 항목은 실제 코드 라인을 근거로 한다. 정상 흐름(예약→발권→재발행→취소)은 jejuair-operations, REST 프로토콜·SEED 헤더·암호화는 jejuair-protocol 를 먼저 읽어라. 공통 예외 체계는 error-handling, @Retry/서킷브레이커/Slack 경보 전파는 resilience-and-events, 전 공급사 지뢰 색인은 landmines 에 모인다.

Jeju Air 모듈은 11개 공급사 중 가장 작은 축에 속하지만, LCC 특성상 stateful PSS 토큰 + RSA 카드암호화 + KST 하드코딩이 겹쳐 있어 함정 밀도는 높다. 함정은 7개 군으로 나눈다.

flowchart TD
    MAP["Jeju Air 지뢰 지도 (7개 군)"]
    MAP --> G1["① 에러코드/ErrorMessage 매핑"]
    MAP --> G2["② 상태/세션 (PssToken)"]
    MAP --> G3["③ @Retryable 동작/폴백"]
    MAP --> G4["④ 운임/통화/세금/수수료 계산"]
    MAP --> G5["⑤ 인코딩/암호화/시간대"]
    MAP --> G6["⑥ 재발행/환불/부분취소 엣지"]
    MAP --> G7["⑦ 코드 내 주석 경고"]
    G1 --> H1["checkError + PaymentError"]
    G2 --> H2["헤더 전파 + retrieveWithToken"]
    G3 --> H3["JejuairClient/CancelService"]
    G4 --> H4["INF min(2), 음수, KRW 가정"]
    G5 --> H5["JejuairCipher RSA, KST 고정"]
    G6 --> H6["calculateCancelFee 부수효과"]
    G7 --> H7["MOBILE 넣어도 입력 안됨 등"]

① 공급사 고유 에러코드와 ErrorMessage 매핑

Jeju Air는 SOAP fault가 아니라 JSON body의 code/message 로 오류를 전달한다. 핵심은 JejuairResponse.checkError다.

// infrastructure/response/JejuairResponse.kt
data class JejuairResponse<T>(
    val code: String, val message: String, val data: T?, val pssToken: String? = null
) {
    fun checkError(callback: ((code: String, message: String) -> Unit)) {
        if (code != "0000") {           // ← "0000" 만 성공
            callback(code, message)
        }
    }
}

함정 1-1: HTTP 200 + code != "0000" = 실패

Jeju Air는 비즈니스 오류도 HTTP 200으로 내려준다. code == "0000" 이 아니면 전부 오류다. data 가 non-null이어도 code"0000" 이 아니면 신뢰하면 안 된다. 모든 호출부가 response.checkError { ... } 를 먼저 통과시킨 뒤에야 response.data!! 를 강제 언랩한다(JejuairClient.kt).

코드별 분기 (실측)

code의미어댑터 처리위치
0000성공통과JejuairResponse.kt:11
OTAUSV113/116, SEEUSV002/003/004판매구간 아님 / 330일 초과 / 일자·구간 오류검색에서는 warn 로그만, 빈 결과JejuairClient.kt:90-92,163-165
COMESV504매진(SOLD_OUT)StatusInvalidException(SOLD_OUT)JejuairClient.kt:235,307
OTAUSV719취소 불가StatusInvalidException(CANCEL_UNABLE)JejuairClient.kt:479
OTAUSV900(취소수수료 조회) 토큰 관련 일시 오류.retry() 부착 → 상위에서 토큰 재발급 후 재시도JejuairClient.kt:485-489
OTAUSV51*/53*/55*, PAYESV006*결제 거절류PaymentError.findErrorMessage(message) 로 메시지 매핑JejuairClient.kt:382-389,416-423
PAYESV010결제 기타PAYMENT_ETC + capture(silence=true)JejuairClient.kt:390-396,424-430
그 외미분류InternationalAdapterException(...).capture()각 메서드 else 분기

함정 1-2: 검색 에러코드 화이트리스트는 "조용히 빈 결과"가 된다

search/reissueSearch에서 OTAUSV113/116, SEEUSV002/003/004logger.warn 만 찍고 예외를 던지지 않는다(JejuairClient.kt:90-92). 즉 매진/날짜오류여도 빈 FareItinerary 리스트가 정상 반환된다. 상위 JejuairFlightSearchServicepmap().onFailure { if (successes.isEmpty()) throw ... } 이므로(JejuairFlightSearchService.kt:68-72), 일부 OD만 비고 일부만 성공하면 예외 없이 부분 결과만 노출된다. “왕복인데 편도만 나온다”류 버그의 근원.

함정 1-3: 결제 에러 메시지 매핑은 한국어 부분 문자열 contains

PaymentError는 카드사 한국어 메시지를 contains 로 매핑한다.

// support/util/PaymentError.kt
hashSetOf("잔액 부족") to ErrorMessage.PAYMENT_INSUFFICIENT_FUNDS,
hashSetOf("한도초과") to ErrorMessage.PAYMENT_MAXIMUM_EXCEEDED, ...
fun findErrorMessage(errorMessage: String) =
    errors.find { (messages, _) -> messages.any { errorMessage.contains(it) } }?.second

Jeju Air/VAN이 메시지 문구를 한 글자라도 바꾸면 매핑이 깨져 nullPAYMENT_ETC로 떨어진다(에러 코드는 그대로지만 사용자 표시 메시지가 일반화됨). “한도 초과”(공백 추가)처럼 띄어쓰기만 달라도 미스. 신규 거절 메시지가 보이면 이 Set에 추가해야 한다.


② 상태/세션 함정 — PssToken 전파

Jeju Air는 LCC이지만 stateful PSS 토큰을 쓴다. getTripSell(pricing) 응답이나 각 응답 HTTP 헤더의 PssToken 을 받아 이후 모든 변경/결제/취소 호출 헤더에 .plus("PssToken" to ...) 로 실어 보낸다.

// JejuairClient.kt:653-671  — 응답 헤더에서 PssToken 추출
inline fun <reified T : Any> jejuairDeserializerOf(...) = { content, response ->
    val jejuairResponse = mapper.readValue<JejuairResponse<T>>(...)
    response.headers("PssToken").firstOrNull()
        ?.let { jejuairResponse.copy(pssToken = it) } ?: jejuairResponse
}

함정 2-1: pssToken!! 강제 언랩이 도처에 있다

paymentAndIssue, calculateCancelFee, cancel, confirm, divide, calculateChange 전부 booking.reference.pssToken!! 로 헤더를 만든다(JejuairClient.kt:378,472,526,552,607,632). 토큰이 만료/누락된 Booking을 흘려보내면 그 자리에서 NPE다. 그래서 토큰이 필요한 흐름은 반드시 retrieveWithToken(pnr) 으로 시작해야 한다:

// JejuairClient.kt:364-370
fun retrieveWithToken(pnr: String): Booking = retrieve(pnr).also {
    if (it.reference.pssToken == null)
        throw InternationalAdapterException(RETRIEVE_FAILED, pnr, "pssToken is null").retry()
}

retrieve()는 토큰을 보장하지 않으므로, 단순 조회(retrieve, /repricing, /{pnr} 컨트롤러)는 토큰이 null일 수 있다. 변경계 작업에서 retrieve를 쓰면 안 되고 반드시 retrieveWithToken.

함정 2-2: 토큰 만료는 OTAUSV900 으로 위장한다

취소수수료 조회(calculateCancelFee)에서 토큰 관련 오류는 OTAUSV900로 오고, 코드는 이를 .retry() 로 표시한다(주석: “호출 서비스 단에 @Retryable 처리하여 token 재발급 후 재시도”, JejuairClient.kt:489). 즉 이 한 줄이 재시도 가능 플래그를 켜야만 상위 JejuairCancelService@RetryableretrieveWithToken을 다시 돌려 새 토큰을 얻는다. 이 메커니즘이 빠지면 토큰 만료가 영구 실패가 된다. 자세한 재시도 연쇄는 resilience-and-events 참고.

함정 2-3: confirm은 토큰을 응답에서 갱신하되 폴백으로 기존 토큰 사용

confirm/divide/calculateChangetoBooking(pssToken = response.pssToken ?: booking.reference.pssToken) 로, 응답에 새 토큰이 없으면 기존 토큰을 재사용한다(JejuairClient.kt:561,616). 응답 헤더에 토큰이 누락되는 경우를 대비한 안전장치이지만, 기존 토큰이 이미 만료됐다면 다음 호출에서 또 실패한다.


③ @Retryable 동작과 주의·폴백

Jeju Air의 재시도는 두 레이어에 걸쳐 있고, 공통 게이트는 ApiException.retryable 플래그다.

// JejuairClient.kt:595-597 / JejuairCancelService.kt:81-83 (동일 패턴)
fun shouldRetry(exception: Exception): Boolean =
    (exception as? ApiException)?.retryable ?: false

exceptionExpression = "@jejuairClient.shouldRetry(#root)" (SpEL)로 스프링 @Retryable이 이 메서드를 호출해 재시도 여부를 판단한다. 즉 .retry()가 부착된 ApiException 재시도된다(Exceptions.kt:72-75).

메서드maxAttemptsbackoff트리거 조건위치
JejuairClient.pricing32000msshouldRetry = retryable flagJejuairClient.kt:216-220
JejuairClient.retrieve32000msshouldRetryJejuairClient.kt:359-363
JejuairClient.retrieveWithToken32000msshouldRetryJejuairClient.kt:324-328 ※중첩
JejuairCancelService.cancelable23000msinclude=[InternationalAdapterException] + shouldRetryJejuairCancelService.kt:20-25
JejuairCancelService.cancel23000ms동일JejuairCancelService.kt:40-45

함정 3-1: retrieveWithTokenretrieve@Retryable 중첩

retrieveWithToken(maxAttempts=3) → 내부에서 retrieve(maxAttempts=3)를 호출한다. 둘 다 @Retryable이고, 그 위에 JejuairCancelService.cancel(maxAttempts=2)이 또 @Retryable이다. 셋이 곱해지면 최악의 경우 토큰성 오류 1건이 수 초~수십 초간 여러 번 외부 API를 때릴 수 있다(self-invocation 프록시 한계로 실제 적용 여부는 호출 경로에 따라 다름 — Spring AOP는 this.retrieve() 자기호출에는 프록시가 안 걸린다는 점도 유의). 외부 7C API에 과도한 부하/계정 잠금 위험.

함정 3-2: 재시도가 부수효과를 반복 실행할 수 있다

cancel은 재시도되는데, 내부에서 calculateCancelFee를 먼저 부른다. 그런데 아래(⑥)에서 보듯 calculateCancelFee 호출은 미발권 예약을 실제로 취소시키는 부수효과가 있다(JejuairCancelService.kt:49 주석). 재시도로 이 호출이 반복되면 이미 취소된 예약에 다시 취소수수료를 묻는 셈이라 2차 오류가 날 수 있다.

함정 3-3: 타임아웃은 재시도가 아니라 Slack 경보 + 콜백

결제(paymentAndIssue)·취소(cancel)의 failure 분기는 if (it.isTimeout) timeoutCallback() 후 예외를 던진다(JejuairClient.kt:401-404,536-539). 타임아웃 콜백은 Slack 경보를 쏜다:

// JejuairCancelService.kt:54-59
timeoutCallback = { slackService.sendCancelFailTimeout(supplier = JEJUAIR, pnr = pnr) }

타임아웃 시 외부에서는 처리됐을 수도 있는데 어댑터는 실패로 본다(불확정 상태). 그래서 메시지큐가 아니라 Slack으로 사람이 PNR을 수동 확인하도록 설계됐다(resilience-and-events).

함정 3-4: pricingpssToken null 도 retry 대상

pricing 성공 응답에 pssToken == null 이면 InternationalAdapterException(PRICING_FAILED, "pssToken is null").retry() 를 던진다(JejuairClient.kt:240-242). 정상 응답인데도 토큰만 비면 재시도된다. 7C가 토큰을 헤더 vs 바디 중 어디로 줄지 일관적이지 않을 때를 방어한 것.


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

함정 4-1: 유아(INF) 수수료는 "2건일 때만 min", 아니면 0

취소/변경 수수료에서 유아 처리가 가장 위험하다.

// response/PassengerPenaltyFee.kt:17-28  (취소)
fun getCancelFee(type: PassengerTypeCode): Long =
    farePenaltyFeeInfos.groupBy { it.journeyKey }.values.sumOf { values ->
        when {
            type == PassengerTypeCode.INF ->
                values.takeIf { it.size == 2 }?.minOf { it.penaltyAmount } ?: 0   // ★
            else -> values.maxOf { it.penaltyAmount }
        }
    }

동일 로직이 PenaltyFee.getCarrierFee(변경, flightReference 로 그룹, createDate max 그룹만, response/PenaltyFee.kt:10-25)에도 있다. 유아는 journey당 2건이 와야 정상이고, 1건/3건이면 수수료가 0으로 산정된다. 7C가 유아 penalty를 1건만 내려주는 케이스에서 환불 수수료가 누락(과다 환불)될 수 있다. 성인/소아는 max라 보수적이지만, 유아는 그렇지 않음을 반드시 인지.

함정 4-2: 재발행 시 음수 운임 = 재발행 차단(사람 개입)

재발행 견적에서 차액이 음수면 “환불성 재발행”이라 시스템이 막는다.

// JejuairFlightSearchService.kt:131-164 (reissueDetail)
if (passengerFares.any { it.airPrice < 0 || it.tax < 0 }) {
    throw InternationalAdapterException(REISSUE_NON_CHANGEABLE_FARE_SCHEDULE,
        ResponseMessage("결제 금액이 감소하여 재발행이 불가합니다. ..."))
}

차액은 Booking.getCalculatePassengerFares = 현재운임 - 원본운임(Booking.kt:50-68). 즉 더 싼 스케줄로 변경 시 어댑터에서 거부한다(항공사 사이트에서 직접 하라고 안내). 운임 비교는 항공료/세금을 별개로 음수 판정하므로, 항공료는 올랐는데 세금이 1원 내려가도 전체가 차단될 수 있다.

함정 4-3: 통화는 전 구간 KRW(원화) 고정 가정

결제·요청 DTO가 통화를 하드코딩한다.

// request/PaymentInfo.kt
val currencyCode: String = "KRW"
// request/PaymentRQ.kt:22-23
val currencyCode: String = "KRW"

운임 금액(fareAmount, taxTotal, penaltyAmount)은 전부 Long이고 통화 변환이 없다. 응답의 currencyCode(예: TaxInfo.currencyCode, FareInfo.currencyCode, AvailabilityRS.currencyCode)는 읽기만 하고 검증하지 않는다. 7C가 해외 발권에서 외화로 응답하는 시나리오가 생기면 금액이 원화로 오인되어 결제 금액이 어긋난다.

함정 4-4: 발권 결제액은 어댑터가 직접 합산 (서버 금액 무시)

// request/PaymentRQ.kt:31-33 (ofIssue)
PaymentInfo(amount = booking.passengers.sumOf { it.fares?.sumOf { f -> f.total } ?: 0L })

발권 결제액을 7C가 준 balanceDue가 아니라 어댑터가 fares.total(airPrice+tax) 을 합산해 보낸다. 재발행은 반대로 balanceDue를 그대로 쓴다(ofReissue, PaymentRQ.kt:44-45). 두 경로의 금액 산출 기준이 다르므로, fare 매핑이 틀어지면 발권 금액 불일치가 난다. Fare.total = airPrice + tax(fuelCharge 미포함, domain/model/FareItinerary.kt:66-68)인 점도 주의 — fuelCharge가 별도 항목이면 누락될 수 있다.

함정 4-5: 무료 수하물은 코드 내 하드코딩 정책 테이블

성인 무료 수하물은 응답이 아니라 BaggagePolicy하드코딩 맵에서 가져온다(검색 시).

// support/util/BaggagePolicy.kt
RegionType.GENERAL → { N→30KG, V→15KG, Y→null, B→null }
RegionType.GUAM_SAIPAN → { N→2PC, V→1PC, ... }

RegionType.of 는 출/도착이 GUM/SPN 인지로만 판별(RegionType.kt:8-10). 7C가 무료수하물 정책을 바꾸거나 신규 운임클래스(ProductClass)를 추가하면 이 테이블을 손으로 갱신해야 하며, 맵에 없는 ProductClass면 getValuepolicyMap[region][productClass] 조회 결과가 어긋난다. ProductClass.Y 주석은 “국제선에선 없는듯”(추정).

함정 4-6: PC↔KG 환산 매직넘버 2.205

// domain/model/FareItinerary.kt:143-150
fun convertToMeasure(): Double =
    if (baggageUnit != PC) { if (KG) freeAllowance.toDouble() else freeAllowance / 2.205 }
    else freeAllowance.toDouble()

KG도 PC도 아닌 단위(현재 enum엔 둘뿐이라 도달 불가지만)면 /2.205(LB→KG)로 떨어진다. 단위가 추가되면 잘못 환산된다.


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

함정 5-1: KST(+09:00) 하드코딩 — 시간대 폭탄

시간 처리 전반이 Asia/Seoul 고정이다.

// support/util/ZonedDateTimeDeserializer.kt:12-16
if (dateString.endsWith("Z")) ZonedDateTime.parse(dateString, ISO_ZONED_DATE_TIME)
else ZonedDateTime.parse("${dateString}+09:00", ISO_ZONED_DATE_TIME)   // ★ 무조건 +09:00
// support/util/DateExtensions.kt:9,36
fun today(zoneId: String = "Asia/Seoul") = LocalDate.now(ZoneId.of(zoneId))
fun LocalDateTime.toUTC(zoneId: ZoneId = ZoneId.of("Asia/Seoul")) = ...
  • RetrieveRS.expirationAt(LocalDateTime)을 toUTC()(기본 KST) 로 변환해 carrierTimeLimit/paymentTimeLimit로 쓴다(RetrieveRS.kt:78-79,144-145). 즉 타임리밋을 항상 KST로 가정. 괌/사이판 등 해외 출발편의 발권 시한이 잘못 계산될 위험.
  • Booking.isVoidabletoday()(KST) 기준으로 “오늘 발권분만 void”를 판정한다(Booking.kt:30-34). 자정 무렵·해외 타임존에서 당일 발권 여부 오판 가능.
  • 단, 세그먼트의 출도착 UTC 변환은 공항 zoneId를 제대로 쓴다(Segment.toSegmentdepartureAt.toUTC(departureAirport.zoneId), Segment.kt:53-54). 이 일관성 차이(타임리밋=KST, 비행시간=공항TZ)를 혼동하지 말 것.

함정 5-2: JejuairCipher — RSA(패딩 미지정) + 매 호출 createInstance + 복호화 미지원

카드정보 암호화 구현에 함정이 겹친다.

// support/util/JejuairCipher.kt
private val cipher: Cipher = Cipher.getInstance("RSA")   // ★ 패딩/모드 미지정 → JVM 기본(RSA/ECB/PKCS1Padding)
override fun encrypt(text: String): String {
    cipher.init(Cipher.ENCRYPT_MODE, publicKey)
    val encryptedText = cipher.doFinal(text.encodeToByteArray())
    return Hex.encodeHexString(encryptedText)              // Hex (Base64 아님)
}
@Deprecated("Jejuair not supported decrypt algorithm")
override fun decrypt(...) = throw Exception("...")          // 복호화 불가
  1. 공개키가 소스에 하드코딩(JejuairCipher.kt:18). 7C가 키를 회전(rotate)하면 코드 수정·배포 필요. 키 노출 시 결제정보 보호 약화.
  2. Cipher.getInstance("RSA")패딩을 명시하지 않아 제공자 기본(RSA/ECB/PKCS1Padding)에 의존 — 환경/JVM 변경 시 7C와 패딩이 어긋날 위험.
  3. 직렬화기가 필드마다 cipher.createInstance().encrypt() 를 호출한다:
    // support/util/CryptoUtils.kt:51-54
    gen.writeString(cipher!!.createInstance().encrypt(value))
    CreditCardInfo@Encrypt 필드가 8개(accountNumber, expiredDate, password, cvv 등, CreditCardInfo.kt:12-34)이므로 결제 1건당 JejuairCipher 인스턴스 8개 생성. 그런데 JejuairCipher.cipher 는 인스턴스 필드(val)이고 encrypt마다 initdoFinal — 인스턴스가 매번 새로 생기므로 동시성 문제는 회피하지만, 하나의 인스턴스를 공유하면 Cipher는 thread-safe가 아니라 위험하다는 점은 알아둘 것.
  4. decrypt는 의도적으로 미구현. 카드정보를 복호화해 재사용하려는 코드는 즉시 예외. 마스킹은 EncryptValueMasker가 담당(로그).

함정 5-3: 전화번호 정규식 — 국제번호/내선 미대응

// support/util/FormatUtils.kt
fun String.convertPhoneNumber(): String =
    "(\\d{3})(\\d{3,4})(\\d{4})".toRegex().replace(this, "$1-$2-$3")

국내 휴대폰(10~11자리) 가정. 국가코드/하이픈 포함/길이 불일치 번호는 변환되지 않고 원본 그대로 반환된다. 7C가 형식 검증하면 그대로 실패. 또 Contact는 같은 번호를 HOME+MOBILE 둘 다 보내는데 “MOBILE 넣어도 입력 안됨” 주석이 붙어 있다(⑦ 참고).

함정 5-4: 생년월일 포맷 변환은 빈 문자열 방어가 양쪽에 다르다

응답 파싱: dateOfBirth?.takeIf { it.isNotBlank() }?.transformDateFormat("yyyy-MM-dd'T'HH:mm:ss","yyyy-MM-dd") (response/PassengerInfo.kt:69-70). transformDateFormat은 내부에서 toLocalDate를 호출하므로 형식이 다르면 예외(DateExtensions.kt:65). 요청 생성: birthDate?.toString() ?: "" 로 빈 문자열을 보낸다(request/PassengerInfo.kt:64,88). 입력 측은 빈 문자열 허용, 출력 측은 형식 엄격 — 비대칭.


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

함정 6-1: calculateCancelFee 호출 자체가 미발권 예약을 취소시킨다 (부수효과!)

가장 비직관적인 함정. 취소수수료 “조회” API가 미발권(Hold/UnderPaid) 예약은 실제로 취소해 버린다.

// JejuairCancelService.kt:46-64
fun cancel(pnr: String): Pair<Boolean, List<Refund>> {
    val booking = jejuairClient.retrieveWithToken(pnr)
    validateCancellation(booking)
    //발권 되지 않은 예약의 경우 calculateCancelFee 호출시 예약 취소 처리됨  ← 주석 경고
    val refunds = jejuairClient.calculateCancelFee(booking)
    if (!booking.nonTicketing) {                 // 발권된 경우에만 별도 executeCancel
        jejuairClient.cancel(booking, timeoutCallback = { ... })
    }
    return booking.isVoidable to refunds
}

nonTicketing = (status == "Hold" && paymentStatus == "UnderPaid")(Booking.kt:41-43). 미발권이면 executeCancel부르지 않는다 — 이미 calculateCancelFee가 취소했기 때문. 따라서 “수수료만 조회”하려고 calculateCancelFee를 호출하는 신규 코드는 의도치 않게 미발권 예약을 취소시킬 수 있다. cancelable(예상취소 조회)도 내부적으로 calculateCancelFee를 부르므로(JejuairCancelService.kt:31) 주의.

함정 6-2: 취소 전제조건 — 부가서비스/체크인 시 차단

// JejuairCancelService.kt:66-76
private fun validateCancellation(booking: Booking) {
    if (booking.hasPaidAncillary) throw InternationalAdapterException(CANCEL_UNABLE, ...).capture()
    if (booking.passengers.any { it.isCheckIn }) throw StatusInvalidException(CANCEL_UNABLE_BY_ALREADY_CHECK_IN).capture()
}

유료 부가서비스가 있거나(hasPaidAncillary) 1명이라도 체크인(liftStatus로 판정)했으면 전체 예약 취소 불가. 부분 취소/부분 환불은 지원하지 않는다. isCheckIntickets.status == CHECKIN이고, 이는 LiftStatus.CHECKIN(priority 1)에서 파생된다(enums/LiftStatus.kt).

함정 6-3: 발권 실패 시 비동기 자동취소 — fire-and-forget + 5초 지연

발권 실패하면 별도 코루틴이 5초 후 자동 취소를 시도한다.

// JejuairTicketingService.kt:28-47, 114-128
} catch (e: Exception) {
    if (e !is MethodArgumentInvalidException) cancelAsync(pnr)   // 결제거절(MAIE)은 취소 안 함
    throw e
}
private fun cancelAsync(pnr: String) = CoroutineScope(Dispatchers.IO).withLaunch {
    delay(5000)
    try { jejuairCancelService.cancel(pnr) }
    catch (e: Exception) { slackService.sendCancelFail(JEJUAIR, pnr, e.message); throw e }
}
  • MethodArgumentInvalidException(결제 거절류)은 자동취소 제외 — 카드만 거절됐으니 예약은 살려둔다.
  • 그 외 예외는 CoroutineScope(Dispatchers.IO)(루트 스코프) 에서 fire-and-forget. 컨트롤러는 이미 예외를 던져 응답을 끝낸 뒤이므로 클라이언트는 자동취소 결과를 알 수 없다. 자동취소가 또 실패하면 Slack 경보만 남고 예약은 매달린 채로 둔다. 코루틴 예외는 AdapterCoroutineExceptionHandler가 처리(async-coroutines).
  • delay(5000): 7C 측 발권 상태가 반영될 시간을 주는 임시 지연(매직넘버).

함정 6-4: 재발행 결제 분기 — balanceDue 0이면 무결제 변경

// JejuairTicketingService.kt:60-64
if (newBooking.reference.balanceDue > 0)
    jejuairClient.paymentAndReissue(pssToken = originBooking.reference.pssToken!!, booking = newBooking)
else
    jejuairClient.executeChange(pssToken = originBooking.reference.pssToken!!, booking = newBooking)

balanceDue > 0 → 결제 후 재발권(EXC), <= 0executeChange(무결제 변경). 음수(환불 발생) 케이스는 ④-2에서 reissueDetail이 이미 막았어야 하지만, reissue 본 경로에는 음수 차단이 없다reissueDetail(견적)과 reissue(실행)가 별도 호출이므로, 견적 후 가격이 음수로 바뀌면 executeChange로 흘러갈 수 있다(레이스). 재발행 후 EMD(항공사수수료) 티켓을 별도로 추가하는 로직도 주의(carrierFee > 0, JejuairTicketingService.kt:87-102).

함정 6-5: divide(PNR 분리) — 유아는 부모를 자동으로 따라간다

// JejuairBookingService.kt:88-89
passengers = passengers.filter { it.typeCode != PassengerTypeCode.INF } //유아는 부모 탑승객 자동으로 따라감

divide 요청에서 유아를 명시적으로 빼야 한다(보내면 안 됨). 또 validateDivideCondition이 성인-유아 쌍 매칭을 강제(JejuairBookingService.kt:95-140): 분리 대상에 성인은 있는데 연결 유아가 빠지면 DIVIDE_FAILED. 유아만 따로 분리도 불가.

함정 6-6: canceled/isNoShow/isAbnormal 판정의 복합 조건

  • RetrieveRS.canceled: pnrStatus ∈ {Closed, HoldCanceled} AND paidStatus == PaidInFull AND 모든 leg status가 blank (RetrieveRS.kt:71-76). 이 셋이 모두 맞아야 “취소됨”으로 보고 ALREADY_CANCELED_PNR 예외. 하나라도 어긋나면 취소된 예약을 정상으로 오인.
  • isNoShow: balanceDue < 0 && paidStatus == OverPaid && totalRefundFee > 0 (RetrieveRS.kt:140).
  • isAbnormal: passengerFares.isEmpty() (RetrieveRS.kt:141). 운임 정보 없는 PNR은 비정상으로 표시.
  • validateSchedulesCancellation: leg 중 하나라도 status == "Canceled"NON_RETRIEVABLE_SCHEDULE_STATUS(JejuairClient.kt:673-680). 단 retrieve(validateScheduleStatus=false) 로 우회 가능(조회 컨트롤러는 false 사용, JejuairBookingController.kt:78,99).

⑦ 코드 내 주석 경고 인용 (실측 그대로)

운영자가 피땀으로 남긴 주석들

이 주석들은 7C API의 비문서적/역설적 동작을 기록한 것이다. 함부로 “리팩토링”하면 재현된다.

주석 (원문)위치의미
MOBILE 넣어도 입력 안됨request/Contact.kt:27phoneTypesHOME+MOBILE 둘 다 보내도 7C는 MOBILE을 무시. HOME만 유효.
email 같이 입력안하면 mobile 넣어도 addresses 하위의 status Contact 확인 불가request/PassengerInfo.kt:68이메일 없으면 연락처 status 검증이 깨짐 → 이메일을 reservationUser에서라도 채움.
대표 예약 번호 입력된 탑승객에 넣을 시 Error 발생 (Contact 중복)request/PassengerInfo.kt:70대표 탑승객(primary)에게는 mobile을 넣지 않는다(takeIf { isPrimary.not() }). 중복 Contact 에러 회피.
체류지 정보는 문서에는 Array로 되어있지만 Array로 보내면 에러이며 하나만 보내도 전체 승객 적용request/CreateBookRQ.kt:15API 문서가 틀림. address는 단일 객체.
예약 시에는 identificationKey가 없다.request/CreateBookRQ.kt:44예약 단계 유아 매칭은 identificationKey가 아니라 passengerSequenceKey로 해야 함.
호출 서비스 단에 @Retryable 처리하여 token 재발급 후 재시도JejuairClient.kt:489OTAUSV900.retry()가 상위 재시도와 짝을 이뤄야 동작(②-2, ③-2).
아직 경유편이 없지만 매칭키도 없으므로 동일 index 배열 값으로 우선 처리response/Journey.kt:45segment↔fareSegment를 인덱스 매칭으로 임시 처리. 경유편/순서 어긋나면 운임이 잘못 붙는다.
조회시 없는 구간(tripInfo)이 있을수도 있음response/AvailabilityRS.kt:73tripInfos.size == originDestinations.size 일 때만 cartesianProduct → 일부 구간 누락 시 조합 자체를 건너뜀(①-2와 연결).
경유편이 있는 경우 세그별 운임이 매칭되는 여정만 제공response/AvailabilityRS.kt:40journey.segments.size == fare.fareSegments.size 인 운임만 노출. 불일치 운임은 조용히 탈락.
FLEX - 국제선에선 없는듯enums/ProductClass.kt:8ProductClass.Y는 추정 — 실제 응답에 오면 검증 필요.
Jejuair not supported decrypt algorithm (@Deprecated)JejuairCipher.kt:35-38복호화 미지원(⑤-2).

함정 7-1: 다구간 환승 최소 2시간 하드코딩

hasValidLayoverTimes는 다구간 여정에서 환승 체류가 2시간 미만이면 조합을 버린다(AvailabilityRS.kt:163-178). 매직넘버 >= 2 (시간). 7C 국제선 운영 정책과 어긋나면 정상 연결편이 검색에서 누락될 수 있다. 편도(journeys.size < 2)는 체크 안 함.

함정 7-2: gateNoShowPenalties.first() — 빈 리스트면 NoSuchElementException

운임규정 생성 시 게이트 노쇼 수수료를 gateNoShowPenalties.first().fee 로 꺼낸다(FareRuleRS.kt:104). 7C가 이 배열을 빈 채로 내려주면 그 자리에서 예외. 운임규정 조회(getFareRules) 전체가 FETCH_FARE_RULES_FAILED로 실패.


온보딩 체크리스트 (정답 가리고 풀어보기)

Q1. "취소 수수료만 조회"하려고 calculateCancelFee를 새로 호출하는 PR을 리뷰 중이다. 무엇을 지적해야 하나?

Q2. 해외(괌) 출발 PNR의 발권 시한( carrierTimeLimit)이 9시간 어긋난다. 왜?

Q3. 유아 1명 포함 예약의 취소 수수료가 0으로 나온다. 코드상 어디를 의심?


관련 노트