T’way 모듈은 중앙 디스패처 없이 6개의 자체 REST 컨트롤러(interfaces/controller/internals/Tway*Controller)가 Triple 예약 시스템의 내부 API로 노출되고, 각 컨트롤러가 application 서비스 → 단일 infrastructure/TwayClient → 항공사 REST(XML) 호출로 내려간다. 오퍼레이션은 task 명세상 6종(Search, Booking, Ticketing, FareRule, Ancillary, AgencyCredit)이지만, 실제로는 그 안에 예매 전 운임확정(ConfirmPrice)·재발행(reissue)·취소/환불(cancel)·분리(divide)·부가서비스 구매/해제까지 들어있어 사실상 LCC 예약 전 라이프사이클을 모두 커버한다. 이 노트는 코드에서 확인한 콜러→콜리 체인을 오퍼레이션별로 빠짐없이 추적한다.
interfaces(컨트롤러/DTO) → application(서비스, 비즈니스 조합) → infrastructure/TwayClient(외부 호출/매핑) → 항공사 REST. 모든 외부 호출은 TwayClient 한 곳에 모여 있다. 서비스는 여러 TwayClient 호출을 “조합/검증”하는 역할만 한다.
발권(ticketing), 재발행(reissue), 부가서비스 구매(modifyBookingWithAncillaries)가 전부 /book/ModifyBooking 엔드포인트를 호출한다. TwayClient에서 세 메서드가 별개로 존재하지만 외부 경로는 같고, 차이는 ModifyBookingRQmsg.of(...)의 오버로드로만 구분된다(ModifyBookingRQmsg.kt:18, 35, 55). 따라서 결제 에러 코드 매핑(TwayPaymentError)도 이 세 군데에서 공유된다. → tway-pitfalls
flowchart TD
T["Triple 예약 시스템"] -->|"HTTP internal API"| C["interfaces/controller/internals/Tway{Op}Controller<br/>@RestController"]
C -->|"DTO 변환 Passenger.of, PaymentInfo.ofKeyInCard"| S["application/Tway{...}Service<br/>조합/검증, 코루틴 fan-out"]
S --> CL["infrastructure/TwayClient 단일<br/>외부 호출 + RQ/RS 매핑"]
CL -->|"post.authenticate.execute.fold"| API["T'way REST API XML"]
API --> SHOP["/shop/* = 조회"]
API --> BOOK["/book/* = 예약/결제"]
검색은 originDestinations.cartesianProduct()(왕복·다구간 조합)를 pmap으로 병렬 호출한다(TwayFlightSearchService.kt:58-59). pmap은 support/util/CoroutineExtensions.kt의 확장으로, 각 호출을 withAsync로 띄우고 실패를 AsyncFail로 모아 onFailure에서 “전부 실패했을 때만” 예외를 던진다(일부 실패는 무시). 또한 TwayClient.search()만 .client(searchClient)를 써서 15초 타임아웃(ClientSupport.searchTimeout=15000)을 적용한다. 나머지 예약계 호출은 기본 60초(defaultTimeout=60000)다. → async-coroutines
직항만, 노선 화이트리스트만
TwayClient.toItineraries()는 segmentReferenceInfos.size == 1인 직항만 남긴다(TwayClient.kt:1043-1044). 그리고 검색 진입 전에 TwayRouteService가 RetrieveRoute 화이트리스트로 거른다. 즉 “검색 결과가 0건”이어도 정상일 수 있다. 서킷브레이커 fallback도 0건을 돌려주므로(searchFallback, TwaySearchController.kt:121), 0건의 원인을 구분하려면 로그를 봐야 한다. → tway-pitfalls
1-2. detail / structured fare-rule (캐시된 검색 결과 재사용)
GET /internals/TWAY/search → detail(SearchDetailRequest) (TwaySearchController.kt:57): 검색 시 저장한 키로 flightSearchService.getFareItineraries(key)를 꺼내 FareItinerary.validate(child, infant)로 좌석점유 가능 여부를 검증하고, FlightAmenityService.findAmenityMap(...)으로 어메니티를 합친다. 공급사 재호출 없음(Redis 조회).
키 구조 파싱은 destructKey()가 담당한다(TwayFlightSearchService.kt:215): TWAY_{uuid}::{출발편ID}_{도착편ID} → 두 키로 분해.
2. Booking — 예약 생성 (가장 복잡)
2-1. 콜러→콜리 체인
flowchart TD
A["TwayBookingController.create BookingRequest<br/>ReservationUser.of, Passenger.of 변환"] --> B["TwayBookingService.book key, reservationUser, passengers"]
B --> S1["① getFareItineraries key<br/>검색 캐시에서 운임 복원 없으면 예외"]
S1 --> S2["② pricingService.doPricing → TwayClient.doPricing → /shop/FareQuote<br/>예약 직전 운임 재확정, 좌석 매진/운임 변동 포착"]
S2 --> S3["③ passengers.withFares confirmedPricing.passengerFaresMap type<br/>확정운임 주입"]
S3 --> S4["④ twayClient.markSeat schedules, pricedPassengers → /book/MarkSeats → pnrSessionId 획득"]
S4 --> S5["⑤ twayClient.createBooking pnrSessionId, promotionCode → /book/CreateBooking"]
S5 --> S6{"⑥ booking.schedules.any not confirmed"}
S6 -->|"부분 확정"| COMP["twayClient.cancel pnr 후 StatusInvalidException SOLD_OUT throw<br/>보상취소"]
S6 -->|"전부 확정"| S7["⑦ removeFlightSearchKey requestKey<br/>비동기 코루틴 캐시 키 제거"]
S7 --> S8["⑧ 연락처 변경 필요 승객 있으면 modifyPassengers → /book/ChangeGuestContactPoint"]
S8 --> S9["⑨ hiddenStop 있으면 getHiddenStopsMap 보정"]
S9 --> R["BookingView.of booking 반환"]
B -.->|"catch e: isUnexposedFareItinerary 면 매진운임 마킹 후 rethrow"| ERR["예외 전파"]
markSeat → pnrSessionId → createBooking 의 stateless 세션 흉내
T’way는 GDS 세션이 없지만, MarkSeats가 돌려준 pnrSessionId를 CreateBooking에 넘겨 “방금 점유한 좌석”을 묶는다(TwayClient.markSeat()→MarkSeatsRS.pnrSessionId, TwayClient.kt:280; createBooking(..., pnrSessionId), TwayClient.kt:292). 이 pnrSessionId는 응답에서 받아 즉시 다음 호출에 넣는 단발성 토큰이지, 클라이언트가 들고 있는 상태가 아니다. → 개요
예약 직전 doPricing은 "운임 재계산(repricing)"의 첫 관문
book()의 ②번 doPricing(=FareQuote)은 검색 시점 운임이 아직 유효한지 예약 직전에 다시 확정하는 단계다. 여기서 매진/유아매진이면 isUnexposedFareItinerary(e)가 true가 되어 UnexposedFareItineraryRepository에 “노출 금지 ID”로 마킹하고(saveUnexposedFareItinerary, TwayBookingService.kt:215), 검색 결과에서 자동으로 빠진다(TwayFlightSearchService.filterByUnexposedFareItinerary). 즉 예약 실패가 다음 검색을 정화하는 피드백 루프다. → tway-pitfalls
부분 확정 시 자동 보상취소
createBooking 응답의 스케줄 중 하나라도 !confirmed면, 코드가 즉시 twayClient.cancel(pnr)을 호출해 만든 PNR을 되돌리고SOLD_OUT을 던진다(TwayBookingService.kt:70-73). 왕복 중 한 편만 확정된 “반쪽 예약”을 막는 보상 로직이다.
confirmPrice(pnr): Booking (TwayClient.kt:517) — 기존 PNR의 현재 결제예정액 재조회. 발권 ready/issue·repricing에서 사용.
confirmPrice(pnr, fareItineraries): List<Passenger> (TwayClient.kt:526) — 변경 스케줄 기준 재가격. 재발행(reissue)에서 사용하며, totalAmountToBePaid < 0(다운그레이드/금액 감소)이면 REISSUE_NON_CHANGEABLE_FARE_SCHEDULE를 던져 재발행을 막는다(TwayClient.kt:541-556). 부가서비스 구매용 confirmPriceWithAncillaries도 내부적으로 같은 private confirmPrice(request)를 탄다.
3. Cancel / Refund — 취소·환불 (Booking 컨트롤러에 함께 노출)
별도 "Cancel 오퍼레이션"은 없지만 흐름이 복잡해 따로 정리한다
task 명세의 6개 오퍼레이션에 Cancel은 없지만, 코드상 TwayCancelService가 독립 서비스로 존재하고 TwayBookingController가 노출한다. 환불 정책(VOID vs REFUND)이 들어있어 디버깅 빈도가 높다.
flowchart TD
A["PUT /{pnr}/cancel → TwayCancelService.cancel pnr"] --> B["isVoidable pnr ← retrieve pnr 후 검증"]
B --> C1["승객 ticketStatus 에 CHECKED IN 있으면 CANCEL_UNABLE_BY_ALREADY_CHECK_IN"]
B --> C2["not cancelable 승객 있으면 CANCEL_UNABLE"]
B --> C3["Booking.isVoidable = 모든 승객 발권일이 오늘이면 true 당일 VOID"]
B --> D["twayClient.cancel pnr → /book/CancelBooking"]
D --> R["Pair voided Boolean, refunds List Refund → CancelView"]
재시도 판정은 shouldCancelRetryable이 ApiException.retryable을 보고 결정한다(TwayClient.kt:513). 그리고 응답 에러코드가 BKG_CONCURRECY로 시작하면(PNR 락) LOCKED_PNR 예외에 .retry()를 붙여 재시도 대상으로 만든다(TwayClient.kt:478-483). ERR360(이미 체크인)은 .capture()만 하고 재시도하지 않는다. 또 SocketTimeoutException/SocketException이면 slackService.sendCancelFailTimeout(...)으로 Slack 경보를 쏜다(TwayClient.kt:498). → resilience-and-events · error-handling
VOID와 REFUND는 동의어가 아니다
cancelable(pnr)은 isVoidable이면 CancelActionType.VOID(수수료 없는 당일 취소), 아니면 CancelActionType.REFUND + confirmCancelPrice(pnr)로 계산한 환불액을 돌려준다(TwayCancelService.kt:24-34). confirmCancelPrice의 환불 수수료는 응답 feeInformations 중 feeCode == "CXL"만 추려 적용한다(TwayClient.kt:439, 492).
issue가 결제오류 이외의 예외를 만나면 cancelAsync(pnr)을 호출한다(TwayTicketingService.kt:50-52). 이는 CoroutineScope(Dispatchers.IO).withLaunch { delay(5000); twayClient.cancel(pnr) }로(TwayTicketingService.kt:65-78) 메인 응답과 분리된 채 5초 뒤 취소를 시도한다. 취소마저 실패하면 slackService.sendCancelFail(...)로 경보. 즉 발권 트랜잭션 정합성은 “동기 응답 + 비동기 보상 + Slack”의 3중 구조로 보장된다.
단, 결제검증오류(MethodArgumentInvalidException, 카드 거절 등 TwayPaymentError 매핑)는 PNR을 살려둬야 재시도가 가능하므로 취소하지 않는다.
4-2. reissue — 재발행 (비동기 폴링)
가장 복잡한 플로우 — 폴링 + 멀티 호출 체인
재발행은 응답이 오래 걸려 즉시 결과를 주지 않고, 키만 돌려준 뒤 클라이언트가 폴링한다.
GET /ticketing/addition/{reissueKey} — TwayTicketingController.kt:70
재발행 = 검색(EnhancedAirAvailability) → 재가격 → ModifyBooking 의 3단 결합
재발행은 두 개의 검색계 호출과 한 개의 결제계 호출이 묶인다.
TwaySearchController.reissueSearch/reissueDetail(검색·상세, → 1-2와 flightSearchService.reissueSearch/reissueDetail)이 변경 가능한 스케줄을 찾고 EnhancedAirAvailability(TwayClient.reissueSearch, TwayClient.kt:680)로 대체편을 검색한다.
발권 단계(/ticketing/addition)에서 위 시퀀스대로 MarkSeats→ConfirmPrice→ModifyBooking.
재발행 제약(코드로 강제됨):
출/도착지는 변경 불가 → 출/도착지로 기존 스케줄을 찾는다(reissueDetail, TwayFlightSearchService.kt:148-153).
출발일시 & cabin이 모두 그대로면 NON_CHANGEABLE_SCHEDULES(:154-157).
변경 대상 스케줄에 부가서비스가 붙어 있으면 NON_CHANGEABLE_SCHEDULES_BY_ANCILLARY(:160-162).
결제금액이 줄어들면(다운그레이드) ConfirmPrice 또는 ModifyBooking(ERR133)에서 차단 → 항공사 사이트에서 직접 진행하라는 메시지.
→ tway-pitfalls
reissue 가격은 confirmPrice에서만 정확
재발행 결과 승객의 fares는 reissuedBooking이 아니라 ④의 pricedPassengers.fares로 치환한다(TwayTicketingService.kt:116-122). 주석대로 “리이슈 price/fee가 confirmPrice에서만 확인 가능”하기 때문. 응답 매핑 시 어느 소스에서 금액을 가져오는지 혼동하면 오결제 분석이 어긋난다.
TwayClient.releaseAncillary(TwayClient.kt:1001)는 성공 시 곧장 toReleaseAncillariesStatus()로 매핑한다(부분 성공/실패 상태를 응답 본문으로 표현). 다른 메서드와 달리 명시적 checkError가 없다는 점을 디버깅 시 유의. → tway-pitfalls
6-4. 딥링크 (별도 클라이언트 · JSON · 토큰)
flowchart TD
A["POST /ancillaries/types/{type}/deep-link TwayAncillaryDeepLinkRequest<br/>type seat 또는 bundle 또는 meal 또는 extra-baggage → TwayDeepLinkAncillaryType"] --> B["TwayAncillaryService.getAncillaryDeepLink carrierPnr, passengers, departureAt, type"]
B --> C["TwayAncillaryClient.getAncillaryDeepLink"]
C --> C1["① issueToken request → GET 토큰 발급, resultCode == BKG_AUTH_SUCCESS 검증"]
C1 --> C2["② TwayAncillaryDeepLink.of tokenId → SEED 암호화 PNR/대리점코드/이름 + URL 조립"]
C2 --> R["AncillaryDeeplinkView pc / mobile URL"]
POST /ancillaries/types/{type}/deep-link — TwayAncillaryController.kt:110
부가서비스 딥링크만 별도 ClientSupport 구현체 TwayAncillaryClient를 쓴다. **Content-Type이 application/json, User-Agent=“InterparkTriple”**이며(TwayAncillaryClient.kt:29-32), 검색 타임아웃 30s. 토큰 발급(issueToken) 후 받은 tokenId와 SEED 암호화한 PNR/이름을 URL 쿼리로 조립한다. 구버전 getAncillaryDeepLink(pnr, ...)는 @Deprecated로 남아있다(:80).
FIXME 잠긴 파라미터
딥링크 URL의 ancillaryType 파라미터가 “티웨이 이슈로 임시 제거(2025-12-23 이후 원복 예정)” 주석과 함께 주석처리돼 있다(TwayAncillaryDeepLink.kt:34, TwayAncillaryClient.kt:127). 즉 {type}을 받아도 현재는 URL에 반영되지 않는다. → tway-pitfalls
7. AgencyCredit — 대리점 크레딧 (가장 단순)
flowchart TD
A["GET /internals/TWAY/agency-credit"] --> B["TwayAgencyCreditService.getAgencyCredit"]
B --> C["TwayClient.getAgencyCredit → /book/RetrieveAgencyCredit"]
C --> D["checkError → retrieveAgencyCreditRS.totalAmountAvaialable.toLong"]
D --> R["AgencyCreditView amount"]
GET /internals/TWAY/agency-credit — TwayAgencyCreditController.kt:14
Q1. T'way 발권( issue)이 카드 거절(예: 코드 9090)로 실패하면 PNR은 자동 취소될까?
정답 보기
취소되지 않는다. 카드 거절은 TwayPaymentError에 매핑되어 MethodArgumentInvalidException으로 던져지고, issue의 catch는 if (e !is MethodArgumentInvalidException) cancelAsync(pnr) 조건이라 결제검증오류는 보상취소 대상에서 제외된다(TwayTicketingService.kt:48-52). 카드를 바꿔 재시도할 수 있게 PNR을 살려두는 의도다.
Q2. 재발행에서 변경 후 결제금액이 기존보다 줄어들면 어떻게 되나?
정답 보기
두 지점에서 막힌다. (1) confirmPrice(pnr, fareItineraries)에서 totalAmountToBePaid < 0이면 REISSUE_NON_CHANGEABLE_FARE_SCHEDULE(TwayClient.kt:541-556), (2) /book/ModifyBooking 응답 ERR133이면 RETICKETING_FAILED_BY_MISMATCH_PRICE(TwayClient.kt:766-770). 둘 다 “항공사 사이트에서 직접 재발행” 메시지로 유도한다.
Q3. 발권·재발행·수하물구매는 서로 다른 외부 API를 부를까?
정답 보기
아니다. 셋 다 /book/ModifyBooking을 호출한다. 차이는 ModifyBookingRQmsg.of(...)의 오버로드(서로 다른 인자 조합)뿐이다(ModifyBookingRQmsg.kt:18/35/55). 그래서 결제 에러 매핑(TwayPaymentError)도 세 흐름이 공유한다.
Q4. 검색 결과가 0건이다. 가능한 원인 3가지는?
정답 보기
(1) 노선이 화이트리스트에 없어 makeOriginDestinationsFilterByRoutes가 빈 값을 반환(공급사 호출 자체 안 함), (2) 서킷브레이커 twaySearch가 OPEN이라 searchFallback이 빈 리스트 반환, (3) 직항이 아니거나 cabin/좌석가용 조건 미충족으로 toItineraries가 전부 필터링(TwayClient.kt:1036-1068). 추가로 filterByUnexposedFareItinerary가 예약실패로 마킹된 운임을 제거했을 수도 있다.