T’way Air — 오퍼레이션 흐름

module-tway arch-supplier pattern-lcc-rest api-rest pattern-async

한 줄 요약

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 예약 전 라이프사이클을 모두 커버한다. 이 노트는 코드에서 확인한 콜러→콜리 체인을 오퍼레이션별로 빠짐없이 추적한다.

관련 노트: 개요(인증·DTO·아키텍처) · 엔드포인트) · 지뢰밭 · 공통 오퍼레이션 패턴 · 요청 흐름 · 콜러-콜리 맵 · 코루틴


0. 컨트롤러 ↔ 서비스 ↔ 클라이언트 한눈에 보기

레이어 규칙

interfaces(컨트롤러/DTO) → application(서비스, 비즈니스 조합) → infrastructure/TwayClient(외부 호출/매핑) → 항공사 REST. 모든 외부 호출은 TwayClient 한 곳에 모여 있다. 서비스는 여러 TwayClient 호출을 “조합/검증”하는 역할만 한다.

오퍼레이션컨트롤러 (@RequestMapping)application 서비스주요 TwayClient 메서드 → 외부 경로
SearchTwaySearchController (/internals/TWAY/search)TwayFlightSearchService, TwayRouteServicesearch()/shop/AirAvailability, retrieveRoute()/shop/RetrieveRoute, getHiddenStopsMap()/shop/ThroughFlightInfo
Search(재발행용)동일 (/reissue)TwayFlightSearchServicereissueSearch()/book/EnhancedAirAvailability, confirmPrice(pnr, fareItineraries)/book/ConfirmPrice
BookingTwayBookingController (/internals/TWAY/bookings)TwayBookingService, TwayPricingService, TwayPassengerService, TwayCancelServicedoPricing()/shop/FareQuote, markSeat()/book/MarkSeats, createBooking()/book/CreateBooking, retrieve()/book/RetrieveBooking, divide()/book/SplitBooking, confirmPrice(pnr)/book/ConfirmPrice
TicketingTwayTicketingController (/internals/TWAY/ticketing)TwayTicketingServiceticketing()·reissue()/book/ModifyBooking
FareRuleTwayFareRuleController (/internals/TWAY/fare-rules)TwayFareRuleServicegetFareRule()/shop/FareRuleDescriptionByRoute
AncillaryTwayAncillaryController (/internals/TWAY/ancillaries)TwayAncillaryServicesearchAvailAncillary()/shop/AncillaryAvailability, searchBaggage()/shop/BaggageAvailability, searchSeat()/shop/SeatAvailability, confirmPriceWithAncillaries()/modifyBookingWithAncillaries()/book/ConfirmPrice·/book/ModifyBooking, releaseAncillary()/book/ReleaseAncillary, 딥링크는 TwayAncillaryClient
AgencyCreditTwayAgencyCreditController (/internals/TWAY/agency-credit)TwayAgencyCreditServicegetAgencyCredit()/book/RetrieveAgencyCredit

/book/ModifyBooking 하나가 결제계의 만능 도어

발권(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/* = 예약/결제"]
  • TwayClient 호출 관용구: .post(RQmsg).authenticate(agencyCode, SEED(password)).execute<RSmsg>().fold{...}

1. Search — 항공편 검색

1-1. 콜러→콜리 체인

flowchart TD
    A["TwaySearchController.search SearchRequest<br/>@CircuitBreaker name twaySearch fallback searchFallback<br/>서킷 OPEN 시 빈 리스트"] --> B["TwayFlightSearchService.search"]
    B --> B1["1) flightSearchKeyRepository.findKey requestKey<br/>캐시 히트 시 재사용"]
    B1 --> B2["2) twayRouteService.makeOriginDestinationsFilterByRoutes<br/>노선 화이트리스트 필터, 비어있으면 즉시 emptyList"]
    B2 --> B3["3) withBlocking Dispatchers.IO cartesianProduct.pmap twayClient.search<br/>pmap 병렬, onFailure 전부 실패 시 SEARCH_FAILED throw"]
    B3 --> B4["4) withHiddenStops 경유 편이면 getHiddenStopsMap 추가 호출"]
    B4 --> B5["5) useCache면 결과 저장 flightSearchKeyRepository + fareItineraryRepository"]
    B3 --> CL["TwayClient.search"]
    CL --> CL1["AirAvailabilityRQmsg.of → POST /shop/AirAvailability<br/>client searchClient 검색 전용 15s 타임아웃"]
    CL1 --> CL2["fold.success checkError → originDestinationInfos.toItineraries"]
    CL2 --> R["응답 매핑 AirAvailabilityRSmsg → FareItinerary<br/>컨트롤러에서 FareItineraryView.of 로 외부 DTO 변환"]
  • TwaySearchController.searchTwaySearchController.kt:28, 가드 request.isSearchable(Supplier.TWAY), CacheKeyGenerator.generateSearchRequestKey(...)
  • TwayFlightSearchService.searchTwayFlightSearchService.kt:36
  • TwayClient.searchTwayClient.kt:72
  • 응답 도메인 모델: domain/model/TwayFlightSearch.kt

검색에만 코루틴 fan-out + 검색 전용 OkHttpClient

검색은 originDestinations.cartesianProduct()(왕복·다구간 조합)를 pmap으로 병렬 호출한다(TwayFlightSearchService.kt:58-59). pmapsupport/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). 그리고 검색 진입 전에 TwayRouteServiceRetrieveRoute 화이트리스트로 거른다. 즉 “검색 결과가 0건”이어도 정상일 수 있다. 서킷브레이커 fallback도 0건을 돌려주므로(searchFallback, TwaySearchController.kt:121), 0건의 원인을 구분하려면 로그를 봐야 한다.tway-pitfalls

1-2. detail / structured fare-rule (캐시된 검색 결과 재사용)

  • GET /internals/TWAY/searchdetail(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["예외 전파"]
  • TwayBookingController.createTwayBookingController.kt:27
  • TwayBookingService.bookTwayBookingService.kt:33

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). 왕복 중 한 편만 확정된 “반쪽 예약”을 막는 보상 로직이다.

2-2. 그 외 Booking 컨트롤러 엔드포인트

엔드포인트메서드서비스 → TwayClient특이사항
PUT /{pnr}changeApisTwayPassengerService.changeApismodifyPassengers/changeApis/retrieve여권(APIS)·체류정보 변경. 누락 필드는 기존값으로 보완(TwayPassengerService.kt:18-27)
GET /{pnr} / /{pnr}/confirmretrieve / confirmTwayBookingService.retrieve/book/RetrieveBooking둘 다 동일하게 retrieve 호출 + hiddenStop 보정
PUT /{pnr}/cancelcancelTwayCancelService.cancel↓ 3장
GET /{pnr}/expected-cancelexpectedCancelTwayCancelService.expectedCancelconfirmCancelPrice환불 예상액 계산(실제 취소 안 함)
GET /{pnr}/cancelablecancelableTwayCancelService.cancelableVOID/REFUND 구분
POST /{pnr}/dividedivideTwayBookingService.divide/book/SplitBooking승객 분리. 유아-성인 페어 검증(validate, TwayBookingService.kt:166)
GET /{pnr}/check-pnrcheckPnr없음무조건 true 반환(헬스/존재 확인용 stub)
GET /{pnr}/repricingrepricingTwayBookingService.confirmPrice(pnr)/book/ConfirmPrice↓ 2-3

2-3. repricing(운임 재조회) — confirmPrice(pnr)

flowchart TD
    A["GET /internals/TWAY/bookings/{pnr}/repricing"] --> B["TwayBookingService.confirmPrice pnr"]
    B --> C["twayClient.confirmPrice pnr → /book/ConfirmPrice 단일 인자 오버로드"]
    C --> D["ConfirmPriceRS.toBooking → withHiddenStops 보정"]
    D --> R["RepricingView.of booking.passengers"]
  • GET .../repricingTwayBookingController.kt:131
  • TwayBookingService.confirmPriceTwayBookingService.kt:137

ConfirmPrice의 두 얼굴

TwayClient.confirmPrice오버로드 2종이다.

  • 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"]
  • TwayCancelService.cancelTwayCancelService.kt:16

cancel@Retryable — 동시성 락 충돌 재시도

TwayClient.cancel()은 모듈에서 유일한 @Retryable 메서드다(TwayClient.kt:450-454).

@Retryable(
    maxAttempts = 3,
    backoff = Backoff(delay = 5000),
    exceptionExpression = "@twayClient.shouldCancelRetryable(#root)"
)

재시도 판정은 shouldCancelRetryableApiException.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의 환불 수수료는 응답 feeInformationsfeeCode == "CXL"만 추려 적용한다(TwayClient.kt:439, 492).


4. Ticketing — 발권 · 재발행 (reissue)

4-1. ready / issue (동기)

flowchart TD
    RD["POST /ticketing/ready → TwayTicketingService.ready pnr"] --> RD1["bookingService.confirmPrice pnr<br/>발권 전 결제예정액 확정"]

    IS["POST /ticketing → TwayTicketingService.issue"] --> IS1["PaymentInfo.ofKeyInCard request.paymentInfo 카드 KeyIn"]
    IS1 --> IS2["pricedBooking = bookingService.confirmPrice pnr 재확정"]
    IS2 --> IS3["twayClient.ticketing pricedBooking, passengerPrices, cardInfo, timeoutCallback → /book/ModifyBooking<br/>timeout 시 slackService.sendTicketingTimeout TWAY, pnr"]
    IS3 --> R["TicketingView passengers = TicketingPassenger.of"]
    IS -.->|"catch e: e가 MethodArgumentInvalidException 아니면 cancelAsync pnr"| ERR["비동기 보상취소"]
  • TwayTicketingService.readyTwayTicketingService.kt:26
  • TwayTicketingService.issueTwayTicketingService.kt:30

발권 실패 시 비동기 보상취소 (5초 지연)

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 — 재발행 (비동기 폴링)

가장 복잡한 플로우 — 폴링 + 멀티 호출 체인

재발행은 응답이 오래 걸려 즉시 결과를 주지 않고, 키만 돌려준 뒤 클라이언트가 폴링한다.

flowchart TD
    A["POST /ticketing/addition ReticketingRequest"] --> A1["polling key ADAPTER...REISSUE::TWAY_pnr, ttl CacheSet.REISSUE.ttl, redisTemplate<br/>백그라운드 코루틴 실행"]
    A1 --> A2["return HTTP 202 ACCEPTED + DeferredKeyView pollingKey"]
    A1 -.->|"백그라운드"| B["TwayTicketingService.reissue"]
    B --> B1["① originBooking = twayClient.retrieve pnr"]
    B1 --> B2["② fareItineraries = getFareItineraries detailKey<br/>출/도착지로 기존 스케줄 매칭 → withOldSegmentGroupId originSchedule"]
    B2 --> B3["③ pnrSessionId = twayClient.reissueMarkSeat fareItineraries, passengers → /book/MarkSeats"]
    B3 --> B4["④ pricedPassengers = twayClient.confirmPrice pnr, fareItineraries → /book/ConfirmPrice<br/>금액 감소 downgrade 면 REISSUE_NON_CHANGEABLE_FARE_SCHEDULE"]
    B4 --> B5["⑤ reissuedBooking = twayClient.reissue originBooking, cardInfo, pricedPassengers, alternativeFareItineraries, pnrSessionId → /book/ModifyBooking<br/>ERR133 TotalPaymentAmount 불일치 다운그레이드 → RETICKETING_FAILED_BY_MISMATCH_PRICE"]
    B5 --> B6["⑥ newBooking = twayClient.retrieve reissuedBooking.pnr"]
    B6 --> B7["⑦ passengers reissuedBooking 기반 + fares는 ④의 pricedPassengers 값으로 치환"]
    B7 --> B8["ReissueResult newBooking, passengers, CardInfo → Redis 에 complete 저장"]

    P["GET /ticketing/addition/{reissueKey}"] --> P1["poller ReissueResult key, redisTemplate"]
    P1 -->|"PENDING"| PP["DeferredView.Pending"]
    P1 -->|"ERROR"| PE["throw throwable"]
    P1 -->|"COMPLETE"| PC["DeferredView.Complete ReticketingView.of"]
    B8 -.->|"Redis"| P1
  • POST /ticketing/additionTwayTicketingController.kt:55
  • TwayTicketingService.reissueTwayTicketingService.kt:81
  • GET /ticketing/addition/{reissueKey}TwayTicketingController.kt:70

재발행 = 검색(EnhancedAirAvailability) → 재가격 → ModifyBooking 의 3단 결합

재발행은 두 개의 검색계 호출과 한 개의 결제계 호출이 묶인다.

  1. TwaySearchController.reissueSearch/reissueDetail(검색·상세, → 1-2와 flightSearchService.reissueSearch/reissueDetail)이 변경 가능한 스케줄을 찾고 EnhancedAirAvailability(TwayClient.reissueSearch, TwayClient.kt:680)로 대체편을 검색한다.
  2. 발권 단계(/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에서만 정확

재발행 결과 승객의 faresreissuedBooking이 아니라 ④의 pricedPassengers.fares치환한다(TwayTicketingService.kt:116-122). 주석대로 “리이슈 price/fee가 confirmPrice에서만 확인 가능”하기 때문. 응답 매핑 시 어느 소스에서 금액을 가져오는지 혼동하면 오결제 분석이 어긋난다.


5. FareRule — 운임 규정

flowchart TD
    A["GET /internals/TWAY/fare-rules?key&adult&child&infant"] --> B["TwayFareRuleService.findFareRules key, adult, child, infant"]
    B --> B1["fareRuleKey = CacheKeyGenerator.generateFareRuleKey"]
    B1 --> B2["fareRuleRepository.findFareRules fareRuleKey 캐시 우선"]
    B2 -->|"miss"| B3["key.destructKey → fareItineraryRepository.getFareItinerary eachKey"]
    B3 --> B4["twayClient.getFareRule fareItineraries → /shop/FareRuleDescriptionByRoute"]
    B4 --> B5["결과를 fareRuleRepository.saveFareRules 로 캐싱"]
    B5 --> R["List FareRule → FareRuleView.of"]
    B2 -->|"hit"| R

    S["GET /fare-rules/structured?key"] --> S1["twayFlightSearchService.getFareItineraries key → StructuredFareRuleView.of<br/>공급사 재호출 없이 검색 캐시 운임으로 구조화 규정 생성"]
  • GET /internals/TWAY/fare-rulesTwayFareRuleController.kt:20
  • TwayFareRuleService.findFareRulesTwayFareRuleService.kt:17
  • GET /fare-rules/structuredTwayFareRuleController.kt:35

세그먼트별 규정 + "공통규정" 합성

TwayClient.getFareRule은 응답 fareRuleDescriptionssegmentId로 그룹핑하고, 그룹마다 commonRuleContentsFareRuleType.COMMON(title=“공통규정”, ordered=9)으로 한 건씩 덧붙인다(TwayClient.kt:380-394). 실패 시 FETCH_FARE_RULES_FAILED에 노선요약(itinerarySummary)을 담아 던진다.


6. Ancillary — 부가서비스 (조회 / 구매 / 해제 / 딥링크)

T’way의 Ancillary는 (a) 가용 조회, (b) 수하물 구매, (c) 부가서비스 해제, (d) 항공사 딥링크 발급(좌석/번들/기내식/추가수하물)로 나뉜다.

6-1. 가용 조회 (key 기반 / pnr 기반, 병렬)

flowchart LR
    A["GET /ancillaries/avail/key 또는 /avail/pnr"] --> A1["searchAvailAncillary → /shop/AncillaryAvailability"]
    B["GET /ancillaries/baggage/key 또는 /baggage/pnr"] --> B1["searchBaggage → /shop/BaggageAvailability"]
    C["seat 조회 내부용 TwayAncillaryService.searchSeat"] --> C1["/shop/SeatAvailability"]

수하물·좌석 조회는 세그먼트 단위 코루틴 fan-out

TwayAncillaryService.searchBaggage/searchSeatwithBlocking(Dispatchers.IO){ schedules.pmap{ ... } }세그먼트(또는 운임)별로 병렬 호출하고 onFailure에서 “전부 실패 시” SEARCH_BAGGAGE_ANCILLARY_FAILED/SEARCH_SEAT_ANCILLARY_FAILED를 던진다(TwayAncillaryService.kt:71-88, 103-121, 126-141). 검색과 같은 fan-out 패턴이지만 여기선 groupSequence(인덱스)를 함께 넘겨 응답을 세그먼트와 매칭한다. 가용 조회는 좌석을 점유하는 성인/소아 수만 넘긴다(유아 제외, 주석 명시). → async-coroutines

6-2. 수하물 구매 (결제 동반)

flowchart TD
    A["POST /ancillaries/baggage?pnr BaggageRequest"] --> B["TwayAncillaryService.purchaseBaggage pnr, cardInfo, ancillaries"]
    B --> S1["① booking = twayClient.retrieve pnr"]
    S1 --> S2["② confirmPriceWithAncillaries booking, ancillaries → /book/ConfirmPrice<br/>부가서비스 포함 재가격 → booking.withPassengers"]
    S2 --> S3["③ modifyBookingWithAncillaries booking, cardInfo, ancillaries → /book/ModifyBooking 결제"]
    S3 --> R["BookingView.of booking"]
  • POST /ancillaries/baggageTwayAncillaryController.kt:66
  • TwayAncillaryService.purchaseBaggageTwayAncillaryService.kt:143

수하물 구매도 결국 ModifyBooking — 결제 에러 매핑 공유

modifyBookingWithAncillaries는 발권과 동일하게 /book/ModifyBooking을 호출하고, 에러코드를 TwayPaymentError.getErrorMessage(code)로 매핑한다(TwayClient.kt:612-625). 결제 매핑이 없으면 PURCHASE_ANCILLARY_FAILED, 있으면 MethodArgumentInvalidException(paymentError). PAYMENT_ETC.capture()(센트리 기록)한다. → error-handling

6-3. 부가서비스 해제

flowchart TD
    A["DELETE /ancillaries?pnr List AncillaryReleaseRequest"] --> B["TwayAncillaryService.releaseAncillaries pnr, ancillaries"]
    B --> C["retrieve pnr → twayClient.releaseAncillary booking, ancillaries → /book/ReleaseAncillary"]
    C --> R["List ReleaseAncillaryStatus → AncillaryReleaseView.of"]
  • DELETE /ancillariesTwayAncillaryController.kt:81
  • TwayAncillaryService.releaseAncillariesTwayAncillaryService.kt:160

releaseAncillary는 checkError를 호출하지 않는다

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-linkTwayAncillaryController.kt:110
  • TwayAncillaryClient.getAncillaryDeepLinkTwayAncillaryClient.kt:55

딥링크는 TwayClient가 아니라 TwayAncillaryClient(JSON)

부가서비스 딥링크만 별도 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-creditTwayAgencyCreditController.kt:14
  • TwayAgencyCreditService.getAgencyCreditTwayAgencyCreditService.kt:10
  • TwayClient.getAgencyCreditTwayClient.kt:839

입력 파라미터가 전혀 없다

AgencyCredit은 PNR도 key도 받지 않는다. getApiProperties()가 MDC의 판매채널/퍼널로 대리점 컨텍스트를 결정하므로(Properties.kt:181-190), 응답은 호출 시점의 판매채널에 묶인 대리점 잔액이다. (오타가 아니라 실제 필드명이 totalAmountAvaialable임에 유의 — TwayClient.kt:852.)


8. 오퍼레이션별 비동기 / Resilience 요약

오퍼레이션코루틴/비동기@CircuitBreaker@RetryableSlack 경보
SearchwithBlocking+pmap (조합 병렬), searchClient 15stwaySearch (fallback=빈리스트)
BookingremoveFlightSearchKey/saveUnexposedFareItinerary = withLaunch(fire-and-forget)
Ticketing(issue)cancelAsync = withLaunch{ delay(5000); cancel }(취소는 내부 cancel의 @Retryable)sendTicketingTimeout, sendCancelFail
Ticketing(reissue)polling/poller (Redis 기반 deferred, HTTP 202)
Cancelcancel (3회, 5s backoff, BKG_CONCURRECY/retryable)sendCancelFailTimeout
FareRule
Ancillary(조회)withBlocking+pmap (세그먼트 병렬)
Ancillary(구매/해제/딥링크)
AgencyCredit

"이벤트/상태 전파"의 정체

이 어댑터에는 메시지큐가 없다. 상태 전파는 (1) Resilience4j 서킷브레이커(검색만, @CircuitBreaker(name="twaySearch")) (2) @Retryable + 예외의 .retry()/.capture() 플래그(취소) (3) Slack 경보(발권/취소 타임아웃·실패) (4) Redis 폴링 결과(재발행)로 이루어진다. 비동기 보상취소(withLaunch)는 응답과 분리되어 실행되며, 예외는 AdapterCoroutineExceptionHandler가 잡는다. → resilience-and-events · async-coroutines


9. 공통 호출 관용구 — .post().authenticate().header().execute().fold{}

거의 모든 TwayClient 메서드가 동일한 빌더 체인을 쓴다.

// TwayClient.kt 의 전형 (예: retrieve, TwayClient.kt:322-351)
"${endpoint}/book/RetrieveBooking"
    .post(RetrieveBookingRQmsg.of(agencyCode, pnr))            // RQ 작성
    .authenticate(agencyCode, SeedUtil.encoding(password))     // SEED 암호화 → HTTP Basic
    .header(headerMap)                                         // Content-Type: application/xml
    .execute<RetrieveBookingRSmsg>()                           // OkHttp 동기 호출 + XML 역직렬화
    .fold(
        success = { it.retrieveBookingRS.checkError(); it.retrieveBookingRS.toBooking() },
        failure = { throw it.exception },
    )

신입이 새 오퍼레이션을 추가한다면

  1. infrastructure/request/{Op}RQ.kt + {Op}RQmsg.kt(JacksonXml 루트) 작성, infrastructure/response/{Op}RS.kt/{Op}RSmsg.kt 작성.
  2. TwayClient에 위 체인 그대로 메서드 추가(/shop/*=조회, /book/*=예약/결제).
  3. fold.success에서 반드시 checkError(...)로 공급사 에러를 도메인 예외로 변환.
  4. application/Tway*Service에서 여러 호출을 조합(필요 시 pmap/withLaunch).
  5. interfaces/controller/internals/Tway*Controller에서 외부 DTO ↔ 도메인 모델 변환. → common-operations · caller-callee-map

부록 A — 자가진단 퀴즈

Q1. T'way 발권( issue)이 카드 거절(예: 코드 9090)로 실패하면 PNR은 자동 취소될까?

Q2. 재발행에서 변경 후 결제금액이 기존보다 줄어들면 어떻게 되나?

Q3. 발권·재발행·수하물구매는 서로 다른 외부 API를 부를까?

Q4. 검색 결과가 0건이다. 가능한 원인 3가지는?


부록 B — 핵심 파일 빠른 참조

역할파일
검색 서비스supplier/tway/application/TwayFlightSearchService.kt
예약/취소확정/분리 서비스supplier/tway/application/TwayBookingService.kt
발권/재발행 서비스supplier/tway/application/TwayTicketingService.kt
취소/환불 서비스supplier/tway/application/TwayCancelService.kt
부가서비스 서비스supplier/tway/application/TwayAncillaryService.kt
단일 외부 클라이언트 (전 오퍼레이션)supplier/tway/infrastructure/TwayClient.kt
딥링크 전용 클라이언트(JSON/토큰)supplier/tway/infrastructure/ancillary/TwayAncillaryClient.kt
결제 에러코드 매핑supplier/tway/infrastructure/TwayPaymentError.kt
코루틴 헬퍼 (pmap/withLaunch/withBlocking)support/util/CoroutineExtensions.kt
폴링 헬퍼 (polling/poller)support/util/PollingUtils.kt
HTTP 빌더/타임아웃 (post/execute/fold)support/web/ClientSupport.kt
채널/퍼널별 엔드포인트·자격증명configuration/Properties.kt (TwayProperties, TwayApiProperties)