부가: 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_DENIAL
8000, 9090, PAYMENT_084
MethodArgumentInvalidException (capture 안 함)
1회 한도초과
PAYMENT_ONCE_LIMIT_EXCEEDED
8327
동일
한도 소진
PAYMENT_USAGE_EXCEEDED
8328, 9050
동일
일일 한도
PAYMENT_DAILY_LIMIT_EXCEEDED
8321, 8332
동일
최대 한도
PAYMENT_MAXIMUM_EXCEEDED
8008, PYM_FEE_52
동일
카드정보 오류
PAYMENT_INVALID_CARD_INFORMATION
ERR270, 4253
동일
시도횟수 초과
PAYMENT_TRIES_EXCEEDED
8311, 9053
동일
인증 실패
PAYMENT_CERTIFICATION_FAILED
8021, 8721
동일
잔액 부족
PAYMENT_INSUFFICIENT_FUNDS
8035, 8527
동일
할부 불가
PAYMENT_INSTALLMENT_NOT_ALLOWED
4324, 8741
동일
PG 거절
PAYMENT_PROVIDER_REJECT
2016, 8313
동일
기타(거대 셋)
PAYMENT_ETC
1203~PYM_GATEWAY_21 (200+개)
MethodArgumentInvalidException+ capture()
함정 1: PAYMENT_ETC만 capture() 된다
TwayClient.ticketing()(TwayClient.kt:655-659)·modifyBookingWithAncillaries()(:621-622)·reissue()(: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: errors가 hashSetOf(...) 중첩 — 코드 중복 시 우선순위
errors는 HashSet<Pair<HashSet<String>, ErrorMessage>>이고 getErrorMessage()는 errors.find { ... }로 첫 매칭을 반환한다. HashSet 순회 순서는 비결정적이므로, 만약 같은 코드가 두 그룹에 중복 등록되면 어느 ErrorMessage가 반환될지 보장되지 않는다. 실제로 7415는 PAYMENT_CREDIT_CARD_DENIAL과 PAYMENT_ETC(주석상 64번 줄 근처) 양쪽 후보로 보이는 패턴이 있으니, 코드 추가 시 중복 여부를 반드시 확인하라. (TwayPaymentError.kt:8,67)
1-2. 응답별 ErrorMessage 매핑 (PG 외)
각 RS DTO에 checkError(callback)가 있고, callback이 없으면 기본 ErrorMessage를 던진다. 콜백을 주는 호출부에서 코드별 세분화가 일어난다.
오퍼레이션 / 코드
ErrorMessage
위치
검색 일반 실패
SEARCH_FAILED
AirAvailabilityRS.kt:23, TwayClient.kt:111
ERR360 (체크인 완료)
CANCEL_UNABLE_BY_ALREADY_CHECK_IN + capture
TwayClient.kt:424,471
BKG_CONCURRECY* (동시성 락)
LOCKED_PNR + retry()
TwayClient.kt:478-483
취소 일반 실패
CANCEL_FAILED + capture
TwayClient.kt:485
가격 일반 실패
PRICING_FAILED + capture
FareQuoteRS.kt:59
ERR133 (다운그레이드 금액 불일치)
RETICKETING_FAILED_BY_MISMATCH_PRICE
TwayClient.kt:766-770
재발행 총액 < 0
REISSUE_NON_CHANGEABLE_FARE_SCHEDULE
TwayClient.kt:541-556
BKG_BOE_208 (동일 여권 중복)
BOOKING_FAILED + capture
CreateBookingRS.kt:140-143
운임규정 실패
FETCH_FARE_RULES_FAILED + capture
TwayClient.kt:368,397
PNR CANCELLED
ALREADY_CANCELED_PNR
TwayClient.kt:335-336
ValidatingCarrier null
PARSE_FAILED + capture
TwayClient.kt:339-343
부가 토큰 발급 실패
ANCILLARY_TOKEN_CREATE_FAILED
TwayAncillaryClient.kt:43,107
부가 취소(release) 실패
CANCEL_ANCILLARY_FAILED + capture
ReleaseAncillaryRS.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: releaseAncillary만 checkError()를 호출하지 않는다
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과 createBooking/reissue 사이에 doPricing(예약) 혹은 confirmPrice(재발행) 등 추가 호출이 끼어 있다. 이 사이가 지연되면 좌석 점유가 풀려 createBooking이 좌석 미확정으로 떨어진다. 예약 흐름은 그 직후 booking.schedules!!.any { !it.confirmed }를 검사해(TwayBookingService.kt:70-73) 미확정이면 **즉시 cancel 후 SOLD_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). 모델 필드를 바꾸면 기존 캐시 역직렬화가 깨질 수 있다.
설정은 application.yml의 twaySearch: { 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되지 않은 일반 비즈니스 예외가 났다면 폴백으로 가지 않고 그대로 던져진다(의도된 동작). 모든 예외를 삼키는 폴백이 아님에 주의.
결제 에러면 보상 취소를 하지 않는다 → 결제는 안 됐으니 PNR만 timeLimit로 자동 만료시키는 전략. 그러나 PAYMENT_ETC도 MethodArgumentInvalidException이므로 동일하게 cancel을 안 한다.
cancelAsync 내부 cancel은 @Retryable이 코루틴 스레드에서 호출돼도 Spring AOP 프록시를 통한다(같은 빈의 내부호출이 아니라 주입된 twayClient를 통하므로 프록시가 적용됨). 단, 결과/예외는 호출자에게 전파되지 않고 실패 시 slackService.sendCancelFail만 보낸다(:71-76).
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은 항상 후속 상태 확인 대상이다.
Fare.fuelCharge 필드가 따로 있지만, tax에 이미 fuelCharge가 더해져 있다. 따라서 total = airPrice + tax는 유류할증료를 포함한다. 외부에 세금만 따로 보여줘야 한다면 tax - fuelCharge로 빼야 한다. tax와 fuelCharge를 단순 합산하면 유류할증료가 이중 계산된다. 이 매핑은 taxes를 fareComponentId로 필터링하므로 운임 컴포넌트 매칭이 틀어지면 세금이 0이 될 수도 있다.
함정 12: qCharge/refundFee/carrierFee는 검색·가격 단계에서 채워지지 않는다
Fare의 qCharge(기본 0), refundFee(null), carrierFee(null)는 toFares()에서 세팅하지 않는다. carrierFee는 재발행(reissuePrice = airPrice + carrierFee)과 부가서비스 결제(PaymentSummary.ofAncillary에서 fare.carrierFee ?: 0 합산, ModifyBookingRQ.kt:120)에서만 쓰인다. 재발행/부가 흐름이 아닌 곳에서 carrierFee에 의존하면 항상 0이다.
결제 통화는 PaymentSummary/GuestPaymentDetail 모두 KRW 하드코딩이다. 한편 운임 통화는 pricingComponentInfo.paxBaseFare.currencyCode(SegmentInfo.kt:104)와 FareRuleSearch.currency(FareRuleSearch.kt:44)로 응답에서 받아온다. 운임 표시 통화와 결제 통화가 다를 수 있다는 점을 인지하라. 비-KRW 정산이 필요한 채널이 생기면 여기를 바꿔야 한다.
4-3. 결제수단 분기 (현금/카드대행)
PaymentSummary.of/ofAncillary는 cashPrice > 0이면 formOfPaymentCode = "CCAG"(카드+대리점크레딧), 아니면 "CC"(순수 카드)로 분기한다(PaymentSummary.kt:48-69, 94-115). 재발행 결제(of(passengers,...))는 항상 "CC" 고정(:77-83).
ConfirmPrice 결과 totalAmountToBePaid < 0(결제액 감소)이면 “항공사 사이트에서 직접 진행” 메시지와 함께 차단한다. 또한 항공사가 ERR133(“Incorrect TotalPaymentAmount(다운 그레이드)“)을 주면 reissue()에서 RETICKETING_FAILED_BY_MISMATCH_PRICE로 매핑한다(TwayClient.kt:766-770). 둘은 같은 원인(감액 재발행)에 대한 사전/사후 두 겹의 방어다. 재발행 가격 검증 로직을 건드릴 때 둘을 함께 고려하라.
4-5. 검색은 최저가 운임 1개만 선택, 직항만 판매
// TwayClient.kt:1043-1057 toItinerariesval 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).
함정 17: SEED 인코딩은 매 호출마다 즉석 수행 — 평문이 메모리/로그에 노출될 위험
authenticate(agencyCode, SeedUtil.encoding(twayApiProperties.password))가 모든 호출부에 반복된다. 비밀번호/카드정보는 TwayProperties(평문 설정)에서 읽어 호출 직전에 인코딩한다. 즉 평문이 객체 필드(TwayApiProperties.password, paymentPassword)와 PaymentInfo.KeyInCard에 존재한다. 로깅 시 카드/비번이 새지 않도록 주의하라. enableSearchLog(TwayClient.kt:49) 또는 요청의 logging=true로 검색 페이로드 로깅이 켜질 수 있는데, 검색은 카드정보가 없지만 다른 흐름에서 .log(true)를 켜면 위험하다.
개인카드는 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 인자)은 현재 사실상 미사용 상태다.
신 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으로 취급 (오프셋 소실)
끝의 Z(UTC 표기)를 문자열로 잘라내고 그대로 LocalDateTime처럼 저장한다. 즉 시각을 현지(공항) 시각으로 가정하고 오프셋을 버린다. 별도로 departureTimeZone/arrivalTimeZone을 ScheduleReference에 보관(: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-40issuedDate = 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로 바뀐다.
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-137if (originDestinationInfos.size == 1) { //왕복예약 스케줄중 오는편만 재검색 할때 tripDirection 이 RT로 온다.. 매칭불가 fareItineraries.map { it to null }}
재발행 검색에서 구간이 1개면 tripDirection이 RT로 와서 toCombinedFareItinerary의 OW/RT 결합 로직과 안 맞으므로, 강제로 it to null(단독)로 처리한다. 왕복 결합은 validateTimeGap(도착+3시간 ≤ 다음 출발, :240-242)도 통과해야 한다.
재발행 운임 치환
재발행 후 응답 Booking에는 정확한 price/fee가 없어, confirmPrice에서 받은 pricedPassengers의 fares로 치환한다(TwayTicketingService.kt:118-121, 주석 명시). reissueMarkSeat은 MarkSeatsRQmsg.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)를 하고:
승객 중 ticketStatus에 "CHECKED IN"이 있으면 CANCEL_UNABLE_BY_ALREADY_CHECK_IN + capture.
!it.cancelable(아래)인 승객이 있으면 CANCEL_UNABLE.
// Passenger.kt:75-76val 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코드의 수수료는 환불 계산에서 누락된다. confirmCancelPrice는 ERR360이면 체크인완료로 매핑, 그 외엔 CALCULATE_CANCEL_FEE_FAILED(:423-437, ERR360 외엔 capture 안 함).
6-3. 부분취소(divide/split)
함정 28: 분리(divide) 시 유아-성인 페어가 깨지면 차단
TwayBookingService.divide(:156-164)는 validate로:
요청 승객이 실제 PNR 승객과 일치하는지(이름/성별/타입, isSamePassenger, :208-213).
유아 연결 성인 페어 검증(:177-205): 분리 대상에 성인만 있고 그 성인에 연결된 유아가 빠지거나, 유아만 있고 부모 성인이 빠지면 DIVIDE_FAILED + capture.
검증 통과 시 SplitBooking으로 자식 PNR(childPnr!!, TwayClient.kt:803)을 받아 retrieve한다. 자식 PNR이 null이면 NPE.
AncillaryType.availOf(Supplier.TWAY)가 EXTRA_BAGGAGE 외 타입(좌석·기내식·번들 등)을 반환하는 순간 kotlin.NotImplementedError가 던져진다. 현재는 T’way의 avail 타입이 수하물뿐이라 동작하지만, enum에 타입을 추가하면 부가 가용성 조회가 즉시 깨진다. enum 변경 시 이 when을 반드시 같이 수정하라.
8. 온보딩 체크리스트 (실수 방지)
T'way 작업 전 점검표
검색 0건이면 WS_1111/ERR361 등 warning 로그부터 확인 (함정 3).
서킷 OPEN인지 Datadog supplier.circuit-breaker 태그 확인 (함정 7).
발권 실패 시 결제오류(MethodArgumentInvalidException) 여부로 보상취소 발생 여부가 갈린다 (함정 9).
발권/취소 타임아웃 PNR은 항상 실제 상태 재확인 (함정 9·10).
운임 표시 시 tax에 유류할증료가 포함됨을 기억 (함정 11).
PG 에러코드 추가 시 적절한 그룹에 넣고 중복 체크. PAYMENT_ETC만 캡처됨 (함정 1·2).
시각 값은 “공항 현지시각(오프셋 버림)“으로 가정 (함정 21·22). VOID 판정은 KST today() 기준 (함정 23).
ancillaryType FIXME 원복 일자(2025-12-23) 경과 여부 확인 (함정 19).
카드 등록번호는 개인=생년월일(non-null 단언), 법인=사업자번호 (함정 18).
부가 release 디버깅 시 NPE는 checkError 누락 때문일 수 있음 (함정 4).