T’way Air — 지뢰요소

module-tway pattern-error-handling api-pitfalls config-resilience

이 노트의 목적

T’way Air(LCC/REST, TwaySEED.jar SEED 암호화) 모듈을 운영하다 마주칠 함정·예외·엣지케이스를 전수한다. 모든 항목은 실제 소스에서 확인했고 파일:line을 명시했다. 작업 전 반드시 tway-operations(오퍼레이션 흐름) · [tway-protocol]와 함께 읽어라. 공통 예외 흐름은 error-handling, 리트라이/서킷브레이커/Slack 경보는 resilience-and-events, 전체 지뢰 인덱스는 landmines를 참고.


0. 한눈에 보는 위험 지도

flowchart TD
    SEARCH["검색 AirAvailability<br/>warningCodes 분기로 조용히 빈 리스트 반환 (누락 위험)<br/>직항 segment 1개만 판매, 경유는 toItineraries에서 버림"]
    PRICE["가격 FareQuote<br/>total = airPrice + tax (fuelCharge가 tax에 합산됨)"]
    SEAT["좌석 MarkSeats<br/>pnrSessionId 반환 = 짧은 수명의 상태 토큰"]
    BOOK["예약 CreateBooking<br/>미확정 스케줄 시 즉시 cancel 후 SOLD_OUT"]
    ISSUE["발권 ModifyBooking<br/>결제오류 vs 발권오류 분기, 결제오류면 cancel 안 함<br/>timeout 시 cancelAsync 5초 delay + Slack"]
    CANCEL["취소 CancelBooking<br/>Retryable, SEED 동시성 락 ERR360 또는 BKG_CONCURRECY"]
    REISSUE["재발행 EnhancedAirAvailability 다음 MarkSeats 다음 ConfirmPrice 다음 ModifyBooking ITR_CHANGE<br/>금액 감소 음수 또는 ERR133 다운그레이드 별도 처리"]
    ANCILLARY["부가 ModifyBooking ADD_ANCILLARY 또는 ReleaseAncillary checkError 미호출<br/>DeepLink ancillaryType 파라미터 FIXME 임시 제거 상태"]

    SEARCH --> PRICE --> SEAT --> BOOK --> ISSUE --> CANCEL --> REISSUE --> ANCILLARY

위 단계별 위험 요소 상세:

  • 검색(AirAvailability): warningCodes 분기로 조용히 빈 리스트 반환 — 누락 위험. 직항(segment 1개)만 판매, 경유는 toItineraries에서 버림
  • 가격(FareQuote): total = airPrice + tax — fuelCharge가 tax에 합산됨
  • 좌석(MarkSeats): pnrSessionId 반환 = 짧은 수명의 상태 토큰 (위험)
  • 예약(CreateBooking): 미확정 스케줄 시 즉시 cancel 후 SOLD_OUT
  • 발권(ModifyBooking): 결제오류 vs 발권오류 분기 — 결제오류면 cancel 안 함 (위험). timeout 시 cancelAsync(5초 delay) + Slack
  • 취소(CancelBooking): @Retryable (SEED 동시성 락 ERR360/BKG_CONCURRECY) (위험)
  • 재발행: EnhancedAirAvailability → MarkSeats → ConfirmPrice → ModifyBooking(ITR_CHANGE). 금액 감소(<0) / ERR133 다운그레이드 별도 처리 (위험)
  • 부가: ModifyBooking(ADD_ANCILLARY) / ReleaseAncillary(checkError 미호출, 위험). DeepLink — ancillaryType 파라미터 FIXME 임시 제거 상태

핵심: T’way는 상태가 있는 세션 토큰(pnrSessionId) + SEED 양방향 암호화 + 결제대행(PG) 에러코드 수백 개를 동시에 다루는 LCC 모듈이다. 함정의 대부분이 여기서 나온다.


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

1-1. 결제 PG 에러코드 거대 테이블 — TwayPaymentError

infrastructure/TwayPaymentError.kt는 항공사 응답 에러코드를 결제 의미별 ErrorMessage로 매핑하는 수백 개짜리 정적 테이블이다. 발권(ticketing)·부가서비스 결제(modifyBookingWithAncillaries)·재발행(reissue)에서 공통으로 사용된다.

매핑 그룹ErrorMessage예시 코드동작
카드 거절PAYMENT_CREDIT_CARD_DENIAL8000, 9090, PAYMENT_084MethodArgumentInvalidException (capture 안 함)
1회 한도초과PAYMENT_ONCE_LIMIT_EXCEEDED8327동일
한도 소진PAYMENT_USAGE_EXCEEDED8328, 9050동일
일일 한도PAYMENT_DAILY_LIMIT_EXCEEDED8321, 8332동일
최대 한도PAYMENT_MAXIMUM_EXCEEDED8008, PYM_FEE_52동일
카드정보 오류PAYMENT_INVALID_CARD_INFORMATIONERR270, 4253동일
시도횟수 초과PAYMENT_TRIES_EXCEEDED8311, 9053동일
인증 실패PAYMENT_CERTIFICATION_FAILED8021, 8721동일
잔액 부족PAYMENT_INSUFFICIENT_FUNDS8035, 8527동일
할부 불가PAYMENT_INSTALLMENT_NOT_ALLOWED4324, 8741동일
PG 거절PAYMENT_PROVIDER_REJECT2016, 8313동일
기타(거대 셋)PAYMENT_ETC1203~PYM_GATEWAY_21 (200+개)MethodArgumentInvalidException + capture()

함정 1: PAYMENT_ETCcapture() 된다

TwayClient.ticketing()(TwayClient.kt:655-659modifyBookingWithAncillaries()(:621-622reissue()(:762-765)에서 결제 에러를 매핑한 뒤, **paymentError == ErrorMessage.PAYMENT_ETC일 때만 this.capture()**를 호출한다.

throw MethodArgumentInvalidException(paymentError, message).apply {
    if (paymentError == ErrorMessage.PAYMENT_ETC) this.capture()
}

즉, 카드 거절/한도초과 같은 “고객 귀책” 에러는 Sentry/Slack 캡처를 의도적으로 생략하고, 분류되지 않은 PAYMENT_ETC(원인 미상)만 캡처해 모니터링한다. 신규 PG 코드가 추가되면 처음엔 무조건 PAYMENT_ETC로 떨어져 알림이 오므로, 알림을 보고 위 표의 적절한 그룹으로 코드를 이관하는 것이 운영 사이클이다. 새 코드를 함부로 PAYMENT_ETC에서 빼면 알림이 끊긴다.

함정 2: errorshashSetOf(...) 중첩 — 코드 중복 시 우선순위

errorsHashSet<Pair<HashSet<String>, ErrorMessage>>이고 getErrorMessage()errors.find { ... }첫 매칭을 반환한다. HashSet 순회 순서는 비결정적이므로, 만약 같은 코드가 두 그룹에 중복 등록되면 어느 ErrorMessage가 반환될지 보장되지 않는다. 실제로 7415PAYMENT_CREDIT_CARD_DENIALPAYMENT_ETC(주석상 64번 줄 근처) 양쪽 후보로 보이는 패턴이 있으니, 코드 추가 시 중복 여부를 반드시 확인하라. (TwayPaymentError.kt:8,67)

1-2. 응답별 ErrorMessage 매핑 (PG 외)

각 RS DTO에 checkError(callback)가 있고, callback이 없으면 기본 ErrorMessage를 던진다. 콜백을 주는 호출부에서 코드별 세분화가 일어난다.

오퍼레이션 / 코드ErrorMessage위치
검색 일반 실패SEARCH_FAILEDAirAvailabilityRS.kt:23, TwayClient.kt:111
ERR360 (체크인 완료)CANCEL_UNABLE_BY_ALREADY_CHECK_IN + captureTwayClient.kt:424,471
BKG_CONCURRECY* (동시성 락)LOCKED_PNR + retry()TwayClient.kt:478-483
취소 일반 실패CANCEL_FAILED + captureTwayClient.kt:485
가격 일반 실패PRICING_FAILED + captureFareQuoteRS.kt:59
ERR133 (다운그레이드 금액 불일치)RETICKETING_FAILED_BY_MISMATCH_PRICETwayClient.kt:766-770
재발행 총액 < 0REISSUE_NON_CHANGEABLE_FARE_SCHEDULETwayClient.kt:541-556
BKG_BOE_208 (동일 여권 중복)BOOKING_FAILED + captureCreateBookingRS.kt:140-143
운임규정 실패FETCH_FARE_RULES_FAILED + captureTwayClient.kt:368,397
PNR CANCELLEDALREADY_CANCELED_PNRTwayClient.kt:335-336
ValidatingCarrier nullPARSE_FAILED + captureTwayClient.kt:339-343
부가 토큰 발급 실패ANCILLARY_TOKEN_CREATE_FAILEDTwayAncillaryClient.kt:43,107
부가 취소(release) 실패CANCEL_ANCILLARY_FAILED + captureReleaseAncillaryRS.kt:25

함정 3: 검색에서 에러를 "경고"로 삼켜 빈 리스트를 반환

TwayClient.search()(:99-118)는 AirAvailabilityRS.warningCodes 또는 startWithErrorCodes(“Validation Failed”)로 시작하는 코드면 throw하지 않고 logger.warn만 찍고 빈 결과로 흘려보낸다.

val warningCodes = setOf("ERR016","ERR193","AVAILABILITY_069","AVAILABILITY_072",
                         "WS_321","ERR147","WS_1111","ERR361","ERR362")
  • WS_1111(“system is currently busy”)은 사실상 일시 장애인데 경고로 분류되어 빈 검색결과처럼 보인다. “검색은 되는데 항공편이 안 나온다”는 CS는 이 코드를 먼저 의심하라.
  • ERR361/ERR362는 대륙(권역) 검증 실패(예: ICN-VTE/VTE-DMK 조합)로, 정상 비즈니스 제약이라 경고 처리한다.
  • 따라서 검색 0건 == 정상일 수도, WS_1111 같은 숨은 장애일 수도 있다. 반드시 warn 로그를 확인해야 구분된다. (AirAvailabilityRS.kt:28-57)

함정 4: releaseAncillarycheckError()를 호출하지 않는다

ReleaseAncillaryRS에는 checkError()(→ CANCEL_ANCILLARY_FAILED)가 정의돼 있지만, TwayClient.releaseAncillary()(TwayClient.kt:1017-1019)는 이를 건너뛰고 곧장 it.releaseAncillaryRS.toReleaseAncillariesStatus()를 호출한다. toReleaseAncillariesStatus()details!!(ReleaseAncillaryRS.kt:35)로 non-null 단언하므로, 항공사가 ErrorType만 채우고 details를 비워 응답하면 의미 있는 CANCEL_ANCILLARY_FAILED 대신 **raw NullPointerException**이 던져진다. 부가서비스 취소 디버깅 시 NPE가 보이면 이 누락을 의심하라.


2. 상태/세션 함정 (세션·토큰·키 만료)

T’way는 GDS처럼 stateful PNR 세션을 유지하진 않지만, 단명(short-lived) 상태 토큰이 여럿 존재한다.

2-1. pnrSessionId — 좌석 점유 토큰

MarkSeats 응답의 pnrSessionId(TwayClient.kt:280)는 좌석을 잠시 점유한 상태 토큰이다. 예약/재발행 흐름에서 반드시 같은 세션 안에서 이어 써야 한다.

flowchart LR
    subgraph BOOK["예약 흐름 TwayBookingService.kt 57-68"]
        B1["markSeat()"] --> B2["pnrSessionId"] --> B3["createBooking(pnrSessionId)"]
    end
    subgraph REISSUE["재발행 흐름 TwayTicketingService.kt 96-112"]
        R1["reissueMarkSeat()"] --> R2["pnrSessionId"] --> R3["중간 호출"] --> R4["reissue(pnrSessionId)"]
    end
  • 예약: markSeat() → pnrSessionId → createBooking(pnrSessionId) (TwayBookingService.kt:57-68)
  • 재발행: reissueMarkSeat() → pnrSessionId → … → reissue(pnrSessionId) (TwayTicketingService.kt:96-112)

함정 5: pnrSessionId는 만료된다 — 사이 단계가 느리면 예약 실패

markSeatcreateBooking/reissue 사이에 doPricing(예약) 혹은 confirmPrice(재발행) 등 추가 호출이 끼어 있다. 이 사이가 지연되면 좌석 점유가 풀려 createBooking이 좌석 미확정으로 떨어진다. 예약 흐름은 그 직후 booking.schedules!!.any { !it.confirmed }를 검사해(TwayBookingService.kt:70-73) 미확정이면 **즉시 cancelSOLD_OUT**을 던진다.

if (booking.schedules!!.any { !it.confirmed }) {
    twayClient.cancel(booking.pnr)
    throw StatusInvalidException(ErrorMessage.SOLD_OUT, booking.pnr, "schedule is not confirmed")
}

이 경우 isUnexposedFareItinerary(e)(:224-227)가 true가 되어 해당 운임을 UnexposedFareItineraryRepository에 저장하고, 이후 검색 결과에서 필터링한다(tway-operations 참고). 즉, “방금 검색에 보였는데 예약하니 매진” 운임은 의도적으로 한동안 숨겨진다.

2-2. 부가서비스 토큰 tokenId

TwayAncillaryClient.issueToken()resultCode == "BKG_AUTH_SUCCESS" && resultMessage == "Success"인 경우에만 tokenId를 받는다(TwayAncillaryClient.kt:40-48). 이 토큰으로 딥링크 URL을 만든다. 토큰 발급과 딥링크 노출 사이 지연도 만료 위험이 있다.

2-3. Redis 운임 캐시 키 (FareItinerary) 만료

검색 결과 FareItinerary는 Redis 해시로 저장되며(TwayFareItineraryRepository.kt:20-26), CacheSet.FARE_ITINERARY.ttl로 만료된다.

함정 6: 상세조회/예약 시점에 캐시 키가 이미 만료될 수 있다

getFareItinerary(hashKey)(:32-35)는 키가 없으면 CacheKeyInvalidException(INVALID_CACHE_KEY)를 던진다. 검색 후 오래 머무른 사용자가 예약/상세조회를 시도하면 이 예외가 난다. 또한 재발행 검색은 결과를 캐시에 저장하지만 별도의 flightSearchKeyRepository.addKey를 호출하지 않으므로(TwayFlightSearchService.kt:128-133) requestKey → key 매핑이 없다. 재발행 상세조회는 클라이언트가 받은 combinedKey를 그대로 들고 와야만 동작한다.

캐시 직렬화 주의

TwayRedisConfiguration(RedisConfiguration.kt:24-28)은 GzipRedisSerializer<FareItinerary>Gzip 압축 + Jackson JSON을 사용한다. FareItinerary/Booking/Fare 등은 Serializable + serialVersionUID=1L을 갖는다(예: Fare.kt:16-18, Booking.kt:20-22). 모델 필드를 바꾸면 기존 캐시 역직렬화가 깨질 수 있다.


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

3-1. @CircuitBreaker — 검색 컨트롤러

@CircuitBreaker(name = "twaySearch", fallbackMethod = "searchFallback")
@PostMapping
fun search(...): ResponseEntity<List<FareItineraryView>>   // TwaySearchController.kt:27-28
  • 설정은 application.ymltwaySearch: { baseConfig: search }(application.yml:55)로, 공통 search 베이스 설정을 상속한다.
  • 폴백 searchFallback(:120-128)은 CallNotPermittedException(서킷 OPEN)만 받아 Datadog span에 supplier.circuit-breaker=OPEN 태그를 찍고 빈 리스트를 반환한다.

함정 7: 서킷 OPEN이면 "검색 결과 없음"으로 위장된다

폴백이 200 + []를 반환하므로, 호출자(Triple 예약 시스템)는 T’way 장애를 “그냥 항공편 없음”으로 인식한다. 검색에서 T’way만 빠지는 현상이 보이면 빈 결과가 아니라 서킷 OPEN일 수 있으니 Datadog 태그/메트릭을 확인하라. 또한 @CircuitBreaker검색(search) 컨트롤러에만 붙어 있다. 발권·취소·예약·재발행에는 서킷브레이커가 없으므로, 발권 단계의 항공사 장애는 그대로 예외로 전파된다.

함정 8: 폴백 시그니처는 정확히 CallNotPermittedException만 매칭

폴백 메서드가 searchFallback(exception: CallNotPermittedException) 단일 시그니처라, 만약 서킷이 slow-call/실패율로 OPEN되지 않은 일반 비즈니스 예외가 났다면 폴백으로 가지 않고 그대로 던져진다(의도된 동작). 모든 예외를 삼키는 폴백이 아님에 주의.

3-2. @Retryable — 취소(cancel)

@Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000),
           exceptionExpression = "@twayClient.shouldCancelRetryable(#root)")
fun cancel(pnr: String): List<Refund>   // TwayClient.kt:450-507

shouldCancelRetryable(:513-515)은 (exception as? ApiException)?.retryable이 true일 때만 재시도한다. 그리고 cancel 본문에서 BKG_CONCURRECY* 코드일 때 InternationalAdapterException(LOCKED_PNR).retry()(:478-483)로 retryable 플래그를 켜 던지므로, PNR 동시성 락이 걸린 취소만 최대 3회(5초 간격) 재시도한다.

함정 9: 취소 재시도 + cancelAsync(발권 실패 보상)의 이중 작동

발권 실패 시 TwayTicketingService.issue()는 결제오류(MethodArgumentInvalidException)가 아닌 경우에만 cancelAsync(pnr)을 호출한다(TwayTicketingService.kt:48-52).

} catch (e: Exception) {
    if (e !is MethodArgumentInvalidException) { //payment error
        cancelAsync(pnr)
    }
    throw e
}

cancelAsync(:65-78)는 CoroutineScope(Dispatchers.IO).withLaunch { delay(5000); twayClient.cancel(pnr) }fire-and-forget한다. 주의점:

  1. 결제 에러면 보상 취소를 하지 않는다 → 결제는 안 됐으니 PNR만 timeLimit로 자동 만료시키는 전략. 그러나 PAYMENT_ETCMethodArgumentInvalidException이므로 동일하게 cancel을 안 한다.
  2. cancelAsync 내부 cancel@Retryable이 코루틴 스레드에서 호출돼도 Spring AOP 프록시를 통한다(같은 빈의 내부호출이 아니라 주입된 twayClient를 통하므로 프록시가 적용됨). 단, 결과/예외는 호출자에게 전파되지 않고 실패 시 slackService.sendCancelFail만 보낸다(:71-76).
  3. cancel이 SocketTimeoutException/SocketException이면 slackService.sendCancelFailTimeout을 보낸다(TwayClient.kt:498-503). 타임아웃 시 실제 취소 성공 여부는 불명이라 수동 확인이 필요하다.

함정 10: 발권 타임아웃 콜백과 보상취소가 별개다

ticketing()failure 블록은 it.isTimeout이면 timeoutCallback()(→ sendTicketingTimeout Slack)만 호출하고 예외를 다시 던진다(TwayClient.kt:671-676). 타임아웃 시 항공사 쪽에서 발권이 성공했을 수 있는데 어댑터는 실패로 간주해 cancelAsync를 돌린다. 즉 “발권 타임아웃 → Slack 알림 + 5초 뒤 자동취소 시도”가 동시에 일어난다. 발권 타임아웃 PNR은 항상 후속 상태 확인 대상이다.

3-3. ClientSupport 타임아웃 값

TwayClientClientSupport(searchTimeout = 15000, defaultTimeout = 60000)(TwayClient.kt:42-43), TwayAncillaryClientsearchTimeout = 30000, defaultTimeout = 60000(TwayAncillaryClient.kt:24-26). 발권/취소 등 비검색 호출은 60초 타임아웃이다.


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

4-1. Fare.totaltax에 유류할증료가 섞여 들어간다

// Fare.kt:20-24
val total: Long get() = airPrice + tax
val reissuePrice: Long get() = airPrice + (carrierFee ?: 0)
// GuestPriceBreakDown.kt:32-38
val fuelCharge = priceBreakDown.surcharges?.filter { it.code == "FUEL" }?.sumOf { it.amount.toLong() } ?: 0
Fare(
    airPrice = priceBreakDown.appliedFareDetailsType.appliedFare.toLong(),
    tax = (taxes?.filter { it.fareComponentId == fareDetail.fareComponentId }
              ?.sumOf { it.amount.toLong() } ?: 0) + fuelCharge,   // ← fuelCharge가 tax에 합산
    fuelCharge = fuelCharge,
    ...
)

함정 11: tax는 "세금 + 유류할증료" 합계다 (별도 필드 fuelCharge와 중복)

Fare.fuelCharge 필드가 따로 있지만, tax에 이미 fuelCharge가 더해져 있다. 따라서 total = airPrice + tax는 유류할증료를 포함한다. 외부에 세금만 따로 보여줘야 한다면 tax - fuelCharge로 빼야 한다. taxfuelCharge를 단순 합산하면 유류할증료가 이중 계산된다. 이 매핑은 taxesfareComponentId로 필터링하므로 운임 컴포넌트 매칭이 틀어지면 세금이 0이 될 수도 있다.

함정 12: qCharge/refundFee/carrierFee는 검색·가격 단계에서 채워지지 않는다

FareqCharge(기본 0), refundFee(null), carrierFee(null)는 toFares()에서 세팅하지 않는다. carrierFee는 재발행(reissuePrice = airPrice + carrierFee)과 부가서비스 결제(PaymentSummary.ofAncillary에서 fare.carrierFee ?: 0 합산, ModifyBookingRQ.kt:120)에서만 쓰인다. 재발행/부가 흐름이 아닌 곳에서 carrierFee에 의존하면 항상 0이다.

4-2. 통화는 KRW로 하드코딩

// PaymentSummary.kt:36  &  GuestPaymentDetail.kt:16
@JacksonXmlProperty(localName = "PaymentCurrency")
private val paymentCurrency: String = "KRW"

함정 13: 결제 통화 KRW 고정

결제 통화는 PaymentSummary/GuestPaymentDetail 모두 KRW 하드코딩이다. 한편 운임 통화는 pricingComponentInfo.paxBaseFare.currencyCode(SegmentInfo.kt:104)와 FareRuleSearch.currency(FareRuleSearch.kt:44)로 응답에서 받아온다. 운임 표시 통화와 결제 통화가 다를 수 있다는 점을 인지하라. 비-KRW 정산이 필요한 채널이 생기면 여기를 바꿔야 한다.

4-3. 결제수단 분기 (현금/카드대행)

PaymentSummary.of/ofAncillarycashPrice > 0이면 formOfPaymentCode = "CCAG"(카드+대리점크레딧), 아니면 "CC"(순수 카드)로 분기한다(PaymentSummary.kt:48-69, 94-115). 재발행 결제(of(passengers,...))는 항상 "CC" 고정(:77-83).

함정 14: 결제금액 산식이 흐름마다 다르다

  • 발권: passengers.sumOf { fares.sumOf { it.total } } (= airPrice+tax)
  • 재발행: passengers.sumOf { fares.sumOf { it.reissuePrice } } (= airPrice+carrierFee)
  • 부가: passengers.sumOf { fares.sumOf { it.carrierFee ?: 0 } } GuestPaymentDetail.ofpaymentAmount > 0일 때만 객체를 생성(아니면 null)한다(GuestPaymentDetail.kt:20-27). 유아 등 결제금액 0인 승객은 명세에서 빠진다. 재발행 path에는 //todo 전체 탑승객의 결제할 금액이 없을때 확인(ModifyBookingRQ.kt:82) 주석이 달려 있어, 전 탑승객 결제액이 0인 케이스가 미검증 상태임을 코드가 명시한다.

4-4. 재발행 다운그레이드(금액 감소) 차단

// TwayClient.kt:541-556  (confirmPrice with fareItineraries)
if (totalAmountToBePaid < 0) {
    throw InternationalAdapterException(REISSUE_NON_CHANGEABLE_FARE_SCHEDULE, ...).capture()
}

함정 15: 재발행 환급(감액) 불가 + ERR133 별도 처리

ConfirmPrice 결과 totalAmountToBePaid < 0(결제액 감소)이면 “항공사 사이트에서 직접 진행” 메시지와 함께 차단한다. 또한 항공사가 ERR133(“Incorrect TotalPaymentAmount(다운 그레이드)“)을 주면 reissue()에서 RETICKETING_FAILED_BY_MISMATCH_PRICE로 매핑한다(TwayClient.kt:766-770). 둘은 같은 원인(감액 재발행)에 대한 사전/사후 두 겹의 방어다. 재발행 가격 검증 로직을 건드릴 때 둘을 함께 고려하라.

4-5. 검색은 최저가 운임 1개만 선택, 직항만 판매

// TwayClient.kt:1043-1057  toItineraries
val segmentReferenceInfo = pricingInfo.segmentReferenceInfos.first()
// segmentReferenceInfo가 2개 이상인 것은 경유 편명이다. TW는 직항만 팔아야 한다.
... pricingInfo.segmentReferenceInfos.size == 1 ...
.minByOrNull { ADULT 운임 amount }   // 캐빈별 최저가 1개만

함정 16: 경유편 제거 + 캐빈별 최저운임 단일 선택

segmentReferenceInfos.size == 1(직항)만 통과시키고, 캐빈별로 성인 적용운임 최저가 1건만 골라낸다. 따라서 같은 편의 다른 운임클래스/부킹클래스는 검색결과에 노출되지 않는다. 히든스톱(stop>0)은 별도 ThroughFlightInfo로 보완한다(getHiddenStopsMap, TwayClient.kt:144-172).


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

5-1. SEED 암호화 — com.twayair.security.seed.SeedUtil

T’way는 외부 jar(TwaySEED.jar)의 SeedUtil.encoding(...)으로 인증/카드정보를 양방향 암호화한다.

암호화 대상위치
모든 API 인증 비밀번호 authenticate(agencyCode, SeedUtil.encoding(password))TwayClient.kt 전 메서드 (예: :58,92,155,...)
카드번호/유효기간/주민·사업자번호/카드비밀번호CardInfo.kt:24-32
결제 비밀번호 paymentPasswordTwayClient.kt:602,645CCAGInfo
부가 딥링크: PNR/대리점코드/승객명TwayAncillaryDeepLink.kt:24-32, TwayAncillaryClient.kt:85-91

함정 17: SEED 인코딩은 매 호출마다 즉석 수행 — 평문이 메모리/로그에 노출될 위험

authenticate(agencyCode, SeedUtil.encoding(twayApiProperties.password))가 모든 호출부에 반복된다. 비밀번호/카드정보는 TwayProperties(평문 설정)에서 읽어 호출 직전에 인코딩한다. 즉 평문이 객체 필드(TwayApiProperties.password, paymentPassword)와 PaymentInfo.KeyInCard에 존재한다. 로깅 시 카드/비번이 새지 않도록 주의하라. enableSearchLog(TwayClient.kt:49) 또는 요청의 logging=true로 검색 페이로드 로깅이 켜질 수 있는데, 검색은 카드정보가 없지만 다른 흐름에서 .log(true)를 켜면 위험하다.

함정 18: 카드 등록번호 인코딩 — 개인/법인 분기

// CardInfo.kt:26-31
registrationNumber = SeedUtil.encoding(when (cardInfo.cardUserType) {
    CardUserType.PERSONAL -> cardInfo.cardHolderBirthDate!!.format("yyMMdd")   // 생년월일 6자리
    CardUserType.CORPORATE -> cardInfo.identityNumber                          // 사업자번호
})

개인카드는 cardHolderBirthDate!!(non-null 단언)을 yyMMdd로 포맷한다. 개인카드인데 생년월일이 null이면 NPE가 난다. 법인카드는 identityNumber를 그대로 쓴다. 만료일은 expiryYear + expiryMonth를 합쳐 인코딩(:25)하므로 자리수/순서가 항공사 규약과 맞아야 한다.

5-2. 부가서비스 딥링크 ancillaryType 파라미터 — FIXME 임시 제거

함정 19 (코드 내 FIXME): ancillaryType 딥링크 파라미터가 주석처리됨

// TwayAncillaryDeepLink.kt:34
//  "ancillaryType" to type, FIXME 티웨이 이슈로 파라미터 임시 제거. 2025-12-23 이후 원복 예정
 
// TwayAncillaryClient.kt:127 (Deprecated 경로에도 동일)
//  "ancillaryType" to "SEAT", ... FIXME 티웨이 이슈로 파라미터 임시 제거. 2025-12-23 이후 원복 예정

좌석(SEAT)/번들(BUND)/기내식(MEAL)/위탁수하물(EXBG)을 구분하던 ancillaryType 딥링크 파라미터가 T’way 측 이슈로 임시 제거되었고 “2025-12-23 이후 원복 예정”이라고 명시돼 있다. 현재(원복 전) 딥링크는 부가서비스 종류를 지정하지 않은 채 열린다. 이 날짜가 지났으면 원복 작업이 누락된 것이니 T’way와 재확인 후 주석을 되살려야 한다. TwayDeepLinkAncillaryType enum(type 인자)은 현재 사실상 미사용 상태다.

5-3. Deprecated 딥링크 경로의 날짜 포맷 차이

함정 20: 신/구 딥링크 메서드의 날짜 타입·포맷이 다르다

  • 구(@Deprecated) getAncillaryDeepLink(pnr, passengers, departureAt: String): departureAt.transformDateFormat(from="yyyy-MM-dd'T'HH:mm:ss", to="yyyy-MM-dd")문자열 입력, from 포맷과 다르면 파싱 실패(TwayAncillaryClient.kt:128).
  • getAncillaryDeepLink(..., departureAt: LocalDateTime, ...): departureAt.format("yyyy-MM-dd")(TwayAncillaryDeepLink.kt:35). 두 경로 모두 살아있으니(TwayAncillaryService.kt:173-190, TwayAncillaryController.kt:95) 어느 쪽을 호출하는지 확인하라. 구 경로는 제거 대상.

5-4. 시간대(timezone) 함정 — 가장 함정이 많은 영역

함정 21: Itinerary.toSchedules는 응답 시각의 끝 Z를 잘라 LocalDateTime으로 취급 (오프셋 소실)

// Itinerary.kt:43-45, 60-61
departureAt = segment.scheduledDepartureTime.dropLast(1),   // "...T10:00:00Z" → "...T10:00:00"
arrivalAt   = segment.scheduledArrivalTime.dropLast(1),

끝의 Z(UTC 표기)를 문자열로 잘라내고 그대로 LocalDateTime처럼 저장한다. 즉 시각을 현지(공항) 시각으로 가정하고 오프셋을 버린다. 별도로 departureTimeZone/arrivalTimeZoneScheduleReference에 보관(:69-70)하지만 departureAt 자체에는 반영하지 않는다. 시각 비교/표시 시 “이 값은 UTC가 아니라 공항 현지시각”임을 전제해야 한다.

함정 22: 검색 응답 vs 예약 응답의 파싱 방식 불일치

  • 검색 경로 SegmentInfo.toSegment: departureInfo.dateTime.toLocalDateTime("yyyy-MM-dd'T'HH:mm:ss'Z'")'Z'포맷 리터럴로 무시하고 파싱(SegmentInfo.kt:58-59).
  • 예약/조회 경로 Itinerary.toSchedules: 위처럼 .dropLast(1)로 잘라냄. 두 방식 모두 결과적으로 “Z 무시한 현지시각”이라 일관되긴 하나, 파싱 전략이 파일마다 달라 한쪽 응답 포맷이 바뀌면(밀리초/오프셋 부착 등) 한쪽만 깨진다.

함정 23: 발권일/취소 가능 판정은 KST 기준 — originalTicketIssueDate만 KST 변환

// PaxTicketDetail.kt:38-40
issuedDate = issueDateTime.toLocalDate("yyyy-MM-dd'T'HH:mm:ss'Z'"),  // KST 주석, Z 무시
originalTicketIssueDate = ZonedDateTime.parse(originalTicketIssueDateTime, ISO_DATE_TIME)
    .withZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDate(),     // UTC→KST 변환

issuedDate(주석상 KST)는 Z를 무시하고, originalTicketIssueDate(주석상 UTC)는 실제 KST로 변환한다. 그리고 Booking.isVoidable(Booking.kt:27-28)은:

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

today()Asia/Seoul(DateExtensions.kt:9). “최초 발권일이 KST 오늘인 모든 승객”일 때만 VOID(무료취소) 가능으로 본다. 한 명이라도 발권일이 어제거나 ticket이 null이면 isVoidable=false(환불 취소). 자정 경계에서 발권한 PNR은 KST 날짜가 넘어가며 VOID→REFUND로 바뀐다.

가짜 여권 만료일 / 운임규정 판매일

  • 여권 미입력 승객은 FAKE_PASSPORT_NUMBER + index(중복 방지), 만료일 LocalDate.now().plusYears(10)을 주입(TravelDocumentDetail.kt:42-47). 만료일은 머신 로컬 now()(KST 아님)를 쓴다.
  • 운임규정 조회 saleDate = today().format("yyyyMMdd")(KST, FareRuleSearch.kt:41), travelDate는 출발일. transitFltYn = "N" 하드코딩(“경유지 추가 후 수정” 주석, :43).

5-5. URL 인코딩

딥링크 파라미터는 URLEncoder.encode(value, Charsets.UTF_8)로 인코딩한다(TwayAncillaryDeepLink.kt:28-32). SEED 인코딩 결과(특수문자 포함)를 다시 URL 인코딩하는 2중 인코딩 구조다. 순서가 바뀌면 딥링크가 깨진다.


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

6-1. 재발행 사전조건 검증 (출/도착지 고정)

flowchart LR
    A["reissueSearch"] --> B["reissueDetail"] --> C["reissueMarkSeat"] --> D["confirmPrice"] --> E["reissue"] --> F["retrieve"]

함정 24: 재발행은 출/도착지 변경 불가 — 출/도착지로 기존 스케줄을 매칭

TwayTicketingService.reissue(:88-94)와 reissueDetail(TwayFlightSearchService.kt:148-165) 모두 “재발행 조건이 출/도착지 변경불가이므로 출/도착지로 기존 스케줄을 찾는다”며 first { departure == ... && arrival == ... }로 매칭한다. 동일 구간이 여러 개면 첫 번째만 잡힌다. 또한:

  • 출발시각·캐빈이 기존과 같으면 NON_CHANGEABLE_SCHEDULES(변경할 게 없음, :154-158).
  • 변경할 스케줄에 부가서비스가 붙어 있으면 NON_CHANGEABLE_SCHEDULES_BY_ANCILLARY(:160-162). 즉 좌석/수하물 등을 구매한 구간은 재발행 불가.

함정 25: 왕복 중 오는편만 재검색하면 매칭 불가

// TwayFlightSearchService.kt:135-137
if (originDestinationInfos.size == 1) {
    //왕복예약 스케줄중 오는편만 재검색 할때 tripDirection 이 RT로 온다.. 매칭불가
    fareItineraries.map { it to null }
}

재발행 검색에서 구간이 1개면 tripDirectionRT로 와서 toCombinedFareItinerary의 OW/RT 결합 로직과 안 맞으므로, 강제로 it to null(단독)로 처리한다. 왕복 결합은 validateTimeGap(도착+3시간 ≤ 다음 출발, :240-242)도 통과해야 한다.

재발행 운임 치환

재발행 후 응답 Booking에는 정확한 price/fee가 없어, confirmPrice에서 받은 pricedPassengersfares로 치환한다(TwayTicketingService.kt:118-121, 주석 명시). reissueMarkSeatMarkSeatsRQmsg.of(fareItineraries, passengers) 오버로드를 쓴다(TwayClient.kt:252-264).

6-2. 취소/환불/VOID 판정

TwayCancelService(TwayCancelService.kt)는 세 진입점을 제공한다.

메서드동작
cancel(pnr)isVoidable(pnr) to twayClient.cancel(pnr) — 실제 취소
expectedCancel(pnr)isVoidable to confirmCancelPrice(pnr) — 예상 수수료 조회
cancelable(pnr)VOID면 환불 null, REFUND면 confirmCancelPrice 호출

함정 26: isVoidable이 retrieve를 매번 호출하며 체크인/취소가능 상태를 이중 검증

isVoidable(pnr)(TwayCancelService.kt:36-55)은 매 호출마다 twayClient.retrieve(pnr)를 하고:

  1. 승객 중 ticketStatus"CHECKED IN"이 있으면 CANCEL_UNABLE_BY_ALREADY_CHECK_IN + capture.
  2. !it.cancelable(아래)인 승객이 있으면 CANCEL_UNABLE.
// Passenger.kt:75-76
val cancelable get() = if (ticketStatus.isNullOrEmpty()) true
                       else ticketStatus.any { it == "CONFIRMED" || it == "NO_SHOW" }

주의: ticketStatus가 비어있으면 무조건 cancelable=true(미발권/조회불가도 취소 시도 허용). 그리고 retrieve 자체가 pnrStatus == "CANCELLED"ALREADY_CANCELED_PNR, airlineCode == null이면 PARSE_FAILED를 던진다(TwayClient.kt:335-343). 즉 cancel 한 번에 retrieve 1~2회가 추가로 발생한다.

함정 27: 취소 수수료는 feeCode "CXL"만 집계

cancel/confirmCancelPrice 모두 feeInformations?.filter { it.feeCode == "CXL" }만 골라 toRefund에 넘긴다(TwayClient.kt:439,492). Refund.refundFee는 해당 승객의 CXL 수수료 합(GuestDetail.kt:106), 없으면 0. CXL 외 다른 fee코드의 수수료는 환불 계산에서 누락된다. confirmCancelPriceERR360이면 체크인완료로 매핑, 그 외엔 CALCULATE_CANCEL_FEE_FAILED(:423-437, ERR360 외엔 capture 안 함).

6-3. 부분취소(divide/split)

함정 28: 분리(divide) 시 유아-성인 페어가 깨지면 차단

TwayBookingService.divide(:156-164)는 validate로:

  1. 요청 승객이 실제 PNR 승객과 일치하는지(이름/성별/타입, isSamePassenger, :208-213).
  2. 유아 연결 성인 페어 검증(:177-205): 분리 대상에 성인만 있고 그 성인에 연결된 유아가 빠지거나, 유아만 있고 부모 성인이 빠지면 DIVIDE_FAILED + capture. 검증 통과 시 SplitBooking으로 자식 PNR(childPnr!!, TwayClient.kt:803)을 받아 retrieve한다. 자식 PNR이 null이면 NPE.

6-4. 부가서비스 구매/취소

함정 29: 부가 구매는 confirmPrice → modify 2단계, confirmPriceWithAncillaries는 에러검증 함

purchaseBaggage(TwayAncillaryService.kt:143-158): retrieve → confirmPriceWithAncillaries(가격확정, 내부에서 confirmPricecheckError 호출됨, TwayClient.kt:588) → modifyBookingWithAncillaries(결제). 결제 단계에서 PG 에러는 TwayPaymentError로 매핑된다(:613-625). 반면 release(취소)는 함정 4처럼 checkError 누락.


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

발견한 미완성·경고 주석 목록

위치내용위험
TwayAncillaryDeepLink.kt:34// "ancillaryType" to type, FIXME 티웨이 이슈로 파라미터 임시 제거. 2025-12-23 이후 원복 예정high — 원복 누락 시 딥링크가 부가서비스 종류 미지정으로 열림 (함정 19)
TwayAncillaryClient.kt:127// "ancillaryType" to "SEAT", ... FIXME ... 2025-12-23 이후 원복 예정high — 동일 (Deprecated 경로)
ModifyBookingRQ.kt:82//todo 전체 탑승객의 결제할 금액이 없을때 확인med — 전 탑승객 결제액 0(전액 크레딧 등) 케이스 미검증 (함정 14)
FlightSegmentDetailsForAncillary.kt:62else -> TODO()high — EXTRA_BAGGAGE 외 부가타입 매핑 시 NotImplementedError 발생
PaxTicketDetail.kt:41conjunctionTicketLastNumber = null // todomed — 연결권(conjunction ticket) 번호 미구현, 항상 null
Itinerary.kt:17-19//todo retrieve / 변경(WAS_CONFIRMED) 여정 필터 처리? 순서 변경 되는지 확인 필요med — 재발행 시 스케줄 순서/필터 미확정
Ancillary.kt:151// TODO Controller 구현시 Request to Convert Model 작업 필요 (PurchaseAncillary.Seat)low — 좌석 구매 Request→Model 변환 미구현
FareRuleSearch.kt:43transitFltYn = "N", //경유지 추가 후 수정low — 경유편 운임규정 미지원(직항만 판매하므로 현재 무해)
TwayClient.kt:1043// segmentReferenceInfo가 2개 이상인 것은 경유 편명이다. TW는 직항만 팔아야 한다.note — 비즈니스 제약 명시 (함정 16)

함정 30: FlightSegmentDetailsForAncillary.toAncillaryAvailelse -> TODO()

// FlightSegmentDetailsForAncillary.kt:58-64
availAncillaries = AncillaryType.availOf(Supplier.TWAY)
    .filter { ancillaryType ->
        when (ancillaryType) {
            AncillaryType.EXTRA_BAGGAGE -> baggageAvailStatus.status
            else -> TODO()    // ☠ NotImplementedError
        }
    }

AncillaryType.availOf(Supplier.TWAY)EXTRA_BAGGAGE 외 타입(좌석·기내식·번들 등)을 반환하는 순간 kotlin.NotImplementedError가 던져진다. 현재는 T’way의 avail 타입이 수하물뿐이라 동작하지만, enum에 타입을 추가하면 부가 가용성 조회가 즉시 깨진다. enum 변경 시 이 when을 반드시 같이 수정하라.


8. 온보딩 체크리스트 (실수 방지)

T'way 작업 전 점검표

  1. 검색 0건이면 WS_1111/ERR361 등 warning 로그부터 확인 (함정 3).
  2. 서킷 OPEN인지 Datadog supplier.circuit-breaker 태그 확인 (함정 7).
  3. 발권 실패 시 결제오류(MethodArgumentInvalidException) 여부로 보상취소 발생 여부가 갈린다 (함정 9).
  4. 발권/취소 타임아웃 PNR은 항상 실제 상태 재확인 (함정 9·10).
  5. 운임 표시 시 tax에 유류할증료가 포함됨을 기억 (함정 11).
  6. PG 에러코드 추가 시 적절한 그룹에 넣고 중복 체크. PAYMENT_ETC만 캡처됨 (함정 1·2).
  7. 시각 값은 “공항 현지시각(오프셋 버림)“으로 가정 (함정 21·22). VOID 판정은 KST today() 기준 (함정 23).
  8. ancillaryType FIXME 원복 일자(2025-12-23) 경과 여부 확인 (함정 19).
  9. 카드 등록번호는 개인=생년월일(non-null 단언), 법인=사업자번호 (함정 18).
  10. 부가 release 디버깅 시 NPE는 checkError 누락 때문일 수 있음 (함정 4).

9. 교차 참조