Galileo (Travelport) — 오퍼레이션 흐름

module-galileo arch-layered api-soap pattern-repricing pattern-coroutines

이 노트의 위치

Galileo 모듈의 6개 오퍼레이션(Search / Booking / Ticketing / FareRule / Queue / CashReceipt)을 각각 Controller → application 서비스 → infrastructure 클라이언트 → 외부 API → 응답 매핑 체인으로 정밀 추적합니다. 모듈 전반 개요는 galileo-overview, 프로토콜(SOAP/REST/Universal API) 디테일은 galileo-protocol, 함정은 galileo-pitfalls를 참고하세요. 11개 공급사 공통 골격은 common-operations · request-flow · caller-callee-map.


0. 큰 그림 — 두 개의 채널을 가진 단일 어댑터

Galileo는 다른 GDS와 달리 검색(Search)과 예약/발권(Booking·Ticketing·…)이 서로 다른 프로토콜·다른 클라이언트를 사용합니다. 이 분기를 먼저 머리에 넣어야 나머지가 쉽게 읽힙니다.

flowchart TD
  Triple["Triple 예약 시스템 (내부 API 호출)"]
  Triple --> SearchCtrl["SearchController"]
  Triple --> BookingCtrl["BookingController"]
  Triple --> TicketingCtrl["TicketingController"]
  Triple --> FareRuleCtrl["FareRuleController"]
  Triple --> QueueCtrl["QueueController"]
  Triple --> CashReceiptCtrl["CashReceiptController"]

  SearchCtrl --> FlightSearchSvc["FlightSearchService"]
  BookingCtrl --> BookingSvc["Booking / Cancel / Passenger Service"]
  TicketingCtrl --> TicketingSvc["TicketingService"]
  FareRuleCtrl --> FareRuleSvc["FareRuleService"]
  QueueCtrl --> QueueSvc["QueueService"]
  CashReceiptCtrl --> CashReceiptSvc["CashReceiptService"]

  FlightSearchSvc --> RestClient["GalileoRestClient (REST/JSON)"]
  BookingSvc --> SoapClient["GalileoClient (SOAP) = Travelport Universal API (1G)"]
  TicketingSvc --> SoapClient
  FareRuleSvc --> SoapClient
  QueueSvc --> SoapClient
  CashReceiptSvc --> KpsClient["KpsPaymentClient"]
  FareRuleSvc --> KrtClient["KrtClient (번역)"]

  RestClient --> CatalogEp["catalogproductofferings (11)"]
  SoapClient --> SoapEp["AirService / UniversalRecordService / GdsQueueService (Universal API SOAP)"]
  KrtClient --> KrtEp["FareRuleTransKor (.aspx, 한글 번역)"]

위 그림 보존 정보:

  • 검색(Search)은 GalileoRestClient(REST/JSON), 예약/발권/조회/환불/큐는 GalileoClient(SOAP, Travelport Universal API 1G)로 서로 다른 프로토콜·다른 클라이언트 사용.
  • 결제(현금영수증 등)는 KpsPaymentClient, 운임규정 한글번역은 KrtClient.
  • BookingController 산하 서비스는 Booking / Cancel / Passenger Service로 갈라짐.
구분클라이언트프로토콜외부 엔드포인트파일
검색GalileoRestClientREST/JSON (OAuth Bearer)/11/air/catalog/search/catalogproductofferingsinfrastructure/rest/GalileoRestClient.kt
예약/발권/조회/환불/큐GalileoClientSOAP (Universal API)/AirService, /UniversalRecordService, /GdsQueueServiceinfrastructure/soap/GalileoClient.kt (907 lines)
결제(카드 승인/취소, 현금영수증)KpsPaymentClientREST/JSON (.aspx)/KpsBspCardAuth.aspxinfrastructure/kps/KpsPaymentClient.kt
운임규정 한글번역KrtClientREST/XML (.aspx)/FareRuleTransKor.aspxinfrastructure/krt/KrtClient.kt

가장 먼저 알아야 할 식별자 3종

GalileoClient.getBooking()이 돌려주는 Booking(support/model/Booking.kt:13)에는 PNR 식별자가 3개 들어 있고, 거의 모든 후속 SOAP 호출이 이 셋을 짝지어 씁니다.

  • pnr — providerPnr (실제 항공사 GDS PNR, 외부에 노출되는 키)
  • subPnr — reservationPnr (Universal API 내부의 reservation 단위, 발권/void/문서조회에 필수)
  • supplierIdentificationKey — universalRecordPnr (UR 레벨 키, 취소/divide에 필수) 셋을 헷갈리면 SOAP 호출이 조용히 빈 결과를 주거나 에러가 납니다. galileo-pitfalls 참고.

1. Search (검색)

호출 체인: GalileoSearchController.searchGalileoFlightSearchService.searchGalileoRestClient.getToken + GalileoRestClient.search → Travelport REST → FareItinerary 매핑 → Redis 캐시

flowchart TD
  Ctrl["GalileoSearchController.search(SearchRequest)"]
  Ctrl -->|"CB galileoSearch (유일) + generateSearchRequestKey"| Svc["GalileoFlightSearchService.search(...)"]
  Svc --> CacheCheck{"flightSearchKeyRepository.findKey(requestKey)?"}
  CacheCheck -->|"YES 캐시 HIT"| HitPath["fareItineraryRepository.getFareItineraries(key) 외부 호출 없음"]
  CacheCheck -->|"NO 캐시 MISS"| MissPath["key = generateFareItineraryKey(GALILEO)<br/>token = galileoClient.getToken() REST OAuth, Redis 캐싱"]
  MissPath --> Block["withBlocking(Dispatchers.IO) 코루틴 진입점"]
  Block --> Cartesian["makeOriginDestinations().cartesianProduct() 멀티-공항 조합 폭발"]
  Cartesian --> Pmap["pmap 조합마다 병렬 galileoClient.search(token, ...)"]
  Pmap --> OnFail["onFailure: successes.isEmpty() 이면 throw SEARCH_FAILED, 아니면 getOrEmpty()"]
  OnFail --> Post["flatten -> distinctBy(id) -> filter(국내공항 경유 제거 + origin/destination 코드 일치)"]
  Post --> SaveCache["useCache 이고 비어있지 않으면 addKey + saveFareItineraries"]
  SaveCache --> Filter2["filterByUnexposedFareItinerary (품절 제거) -> filterIncludedAirline(airlines)"]

위 그림 보존 정보:

  • GalileoSearchController.search — controller/internals/GalileoSearchController.kt:27. @CircuitBreaker(name=galileoSearch, fallback=searchFallback) = 이 모듈 유일한 CB.
  • GalileoFlightSearchService.search — application/GalileoFlightSearchService.kt:33.
  • 병렬 검색 호출: GalileoRestClient.search:76.

핵심 디테일

  • CircuitBreaker는 Search에만: @CircuitBreaker(name = "galileoSearch", fallbackMethod = "searchFallback") (GalileoSearchController.kt:24). OPEN 되면 searchFallbackemptyList()를 반환하고 Datadog span에 supplier.circuit-breaker=OPEN 태그를 박습니다(:65-72). 설정은 application.yml:63 galileoSearch: baseConfig: search (슬라이딩 윈도우 180s, 최소 30콜, 실패율 35%, OPEN 120s). 예약/발권 컨트롤러에는 CB가 없습니다.
  • 토큰 캐싱: GalileoRestClient.getToken()(:48)은 Redis(GALILEO_REST_TOKEN::{pcc})에서 먼저 조회하고 없으면 grant_type=password로 OAuth 토큰을 발급, expiresIn - 600(만료 10분 전)으로 setIfAbsent 저장합니다(:181). PCC 단위로 토큰을 공유합니다.
  • pmap = 병렬 + 부분실패 허용: cartesianProduct()로 출발/도착 공항 조합이 만들어지고 각 조합을 pmap(support/util/CoroutineExtensions.kt:36)으로 병렬 호출합니다. pmap은 각 호출을 try/catch로 감싸 AsyncSuccess/AsyncFail로 모은 뒤 awaitAll하나라도 성공하면 결과를 살립니다. .onFailure { ... if (successes.isEmpty()) throw } 패턴이 이 “전부 실패할 때만 throw” 정책을 구현합니다. 자세한 의미는 async-coroutines.
  • 9000: NO OFFERS FOUND 는 정상: GalileoRestClient.search:119에서 해당 에러쌍은 예외로 올리지 않고 빈 결과로 처리합니다(검색 결과 없음 ≠ 장애).
  • NonAir 필터: 기차/버스 같은 비항공 구간(NonAirEquipment)이거나 MH(말레이시아) 발권 불가 케이스는 filterNot { isNonAir() && hasNonTicketableCarrier() }로 제거합니다(:144, :155-169).
  • detail / structured fare rule: GET /internals/GALILEO/search(:50)는 캐시에서 FareItinerary를 꺼내 amenity와 합칩니다. 구조화 운임규정은 GalileoFareRuleController.getStructuredFareRules(:31)가 동일하게 캐시 itinerary를 읽어 StructuredFareRuleView.of로 변환(외부 호출 없음).

2. Booking (예약 생성) — pricing→book→retrieve 3단 SOAP

호출 체인: GalileoBookingController.createGalileoBookingService.bookGalileoClient.pricingGalileoClient.bookGalileoClient.getBooking

flowchart TD
  Ctrl["GalileoBookingController.create(BookingRequest)<br/>ReservationUser.of(request), passengers.map Passenger.of(it)"]
  Ctrl --> Svc["GalileoBookingService.book(key, reservationUser, passengers)<br/>fareItinerary = getFareItinerary(key) 검색단계 캐시<br/>airportMap = 모든 leg 공항 -> cityClient.getAirportByIata"]
  Svc --> Step1["1. galileoClient.pricing(fareItinerary, airportMap, passengerCountMap) SOAP AirPriceRQ<br/>-> PricingFare 운임 확정 SOLD_OUT / MINIMUM_CONNECTION_TIME 감지"]
  Step1 --> Step2["2. providerPnr = galileoClient.book(fareItinerary, pricingFare, ...) SOAP AirCreateReservationRQ<br/>-> providerPnr 문자열"]
  Step2 --> Step3["3. booking = galileoClient.getBooking(providerPnr, findTimeZoneId) SOAP UniversalRecordRetrieveRQ"]
  Step3 --> TimeLimit{"booking.carrierTimeLimit == null ?"}
  TimeLimit -->|"null"| Retry["withBlocking delay(3000); getBooking(...) TimeLimit 채워질 때까지 1회 재조회"]
  TimeLimit -->|"있음"| Done["copyScheduleSequenceByFareItinerary(fareItinerary) 스케줄 순서 보정"]
  Retry --> Done
  Done --> Side["비동기 launch: saveFareItineraryByPnr(pnr); removeFlightSearchKey(key)"]
  Svc -.->|"catch(e) 보상 트랜잭션"| Catch["isUnexposedFareItinerary(e) 이면 removeFlightSearchKey + saveUnexposedFareItinerary<br/>providerPnr 있으면 cancelService.pnrCancelAsync 비동기 취소<br/>throw e"]

위 그림 보존 정보:

  • GalileoBookingController.create — controller/…/GalileoBookingController.kt:30.
  • GalileoBookingService.book — application/GalileoBookingService.kt:50.
  • 단계: 1 pricing(AirPriceRQ) → 2 book(AirCreateReservationRQ) → 3 getBooking(UniversalRecordRetrieveRQ).

예약은 "실패 시 보상"이 핵심

book()try 블록 어디서든 예외가 나면 catch에서 ① 품절 운임이면 검색키 제거 + unexposed 저장, ② 이미 providerPnr가 생성됐으면 cancelService.pnrCancelAsync(it)로 비동기 PNR 취소를 시도합니다(GalileoBookingService.kt:98-106). 즉 “예약은 됐는데 후속이 깨진” 좀비 PNR을 자동 정리합니다. pnrCancelAsync는 5초 delay 후 pnrCancelRepeat(최대 2회)을 도는 fire-and-forget 코루틴(GalileoCancelService.kt:104).

carrierTimeLimit == null 재조회의 이유

예약 직후 Universal Record에 항공사 발권시한(TimeLimit)이 아직 반영 안 된 경우가 있어, null이면 delay(3000) 후 한 번 더 getBooking을 호출합니다(:81-87). 발권 가능 여부 판단의 기준값이라 비워둘 수 없습니다.

부수 오퍼레이션 (BookingController 내 그 외 엔드포인트)

엔드포인트서비스 메서드외부 호출비고
GET /{pnr} retrievebookingService.retrievegetBooking + getTicketDocuments + EMD 조회발권된 PNR이면 e-Ticket + EMD까지 합성
GET /{pnr}/check-pnrbookingService.checkPnrgalileoClient.checkPnrUniversalRecordRetrievePNR 유효성만 검증
GET /{pnr}/confirmbookingService.confirmgetBooking + 검증INFT 좌석 품절(isInfantSoldOutAtTicketing) 시 throw, 실패 시 pnrCancelAsync
GET /{pnr}/repricingbookingService.repricing§7 참고 (복잡)운임 재계산
PUT /{pnr} changeApisgalileoPassengerService.changeApis§6 참고APIS 정보 변경
PUT /{pnr}/cancelcancelService.cancel§5 참고void/환불
GET /{pnr}/expected-cancel / cancelablecancelService.isVoidable / cancelablegetBooking + getTicketDocumentsVOID/REFUND 판정
POST /{pnr}/dividebookingService.divide§8 참고동반자 분리

retrieve 의 티켓 합성 (GalileoBookingService.kt:190)

flowchart TD
  Start["retrieve(pnr)"]
  Start --> Get["booking = getBooking(pnr, findTimeZoneId)"]
  Get --> Check{"booking.tickets != null ?"}
  Check -->|"YES"| ETickets["eTickets = getTicketDocuments(pnr, URpnr, subPnr) AirRetrieveDocumentRQ"]
  ETickets --> EmdList["emdTickets = getEmdTicketDocuments(pnr, passengers) EMD 목록조회"]
  EmdList --> EmdDetail["map getEmdTicketDocument(pnr, idKey, ticketNumber) EMD 상세조회"]
  EmdDetail --> WithEmd["emdTickets 있고 booking.hasEmdTicket == false 이면 booking.withTickets(...)"]
  WithEmd --> WithPax["passengers.forEach withPassengerTickets (idKey & type 매칭)"]
  Check -->|"NO"| Skip["티켓 합성 생략"]

위 그림 보존 정보:

  • retrieve의 티켓 합성 — GalileoBookingService.kt:190.

3. Ticketing (발권) — ready / issue 2-step

GalileoTicketingController(/internals/GALILEO/ticketing)는 발권 전 준비(ready)와 실제 발권(issue) 두 엔드포인트를 노출합니다.

3-1. ready (POST /ready) — 발권 직전 운임 갱신

flowchart TD
  Ctrl["ready(TicketingRequest)"]
  Ctrl --> Svc["GalileoTicketingService.ready(pnr)"]
  Svc --> Get["booking = getBooking(pnr).also validateBookingConditionForTicketing()<br/>catch -> pnrCancelAsync(pnr); throw"]
  Get --> Guard{"pricingInfoReferences.all isGuaranteed ?"}
  Guard -->|"YES 운임 보장"| Ret["return booking.withPassengers(null) 그대로"]
  Guard -->|"NO 보장 안됨 재가격 (§7 repricing과 동일)"| Del["deleteElements(AIR_PRICING, 기존 pricing key) UniversalRecordModify(ofDelete)"]
  Del --> Re1["getBooking(pnr).run pricing(...); addPriceInfo(...)"]
  Re1 --> Re2["getBooking(pnr).also validateFareBasisChange(origin, new) fareBasis 변동 검증"]

위 그림 보존 정보:

  • ready 컨트롤러 — controller/…/GalileoTicketingController.kt:20.
  • GalileoTicketingService.ready — application/GalileoTicketingService.kt:45.

3-2. issue (POST) — 결제 승인 → 발권 → 문서 조회

flowchart TD
  Ctrl["issue(TicketingRequest)<br/>cardInfo = request.paymentInfo?.let PaymentInfo.ofKeyInCard(it)"]
  Ctrl --> Svc["GalileoTicketingService.issue(pnr, validatingCarrier, passengerPrices, cardInfo, keepPnr)"]
  Svc --> Pay["payment = getBooking(pnr).also validateBookingConditionForTicketing()<br/>catch -> if(!keepPnr) pnrCancelAsync(pnr); throw"]
  Pay --> CardCheck{"cardInfo != null ?"}
  CardCheck -->|"YES"| Approve["kpsClient.approve(cardInfo, pnr, validatingCarrier, reservationCode=URpnr, amount) KPS 카드승인<br/>galileoClient.addPaymentInfoRemark(booking, payment) UniversalRecordModify"]
  CardCheck -->|"NO"| TryBody
  Approve --> TryBody["try 발권 본체"]
  TryBody --> Reget["booking = getBooking(pnr) remark 추가 후 version 갱신 위해 재조회"]
  Reget --> Comm["커미션 저장: pricingInfoReferences.mapNotNull convertCommission(...) -> saveCommission(booking, commissions) UniversalRecordModify"]
  Comm --> Group["passengerPrices.groupBy type+cashPrice -> group.chunked(4) ★ 4명 제한"]
  Group --> Ticket["galileoClient.ticketing(reservationPnr=subPnr, passengerPrices=chunk, payment, pricingReference, timeoutCallback=sendTicketingTimeoutEmergencyChannel) SOAP AirTicketingRQ"]
  Ticket --> Issued["issuedBooking = getBooking(pnr).also if(tickets.isNullOrEmpty()) throw TICKETING_FAILED"]
  Issued --> Docs["passengerTicketMap = getTicketDocuments(pnr, URpnr, subPnr, passengerPrices).groupBy idKey"]
  Docs --> RetOk["return issuedBooking.withPassengers(withPassengerTickets).withPayment(payment)"]
  Svc -.->|"catch(e) 발권 실패 = 결제까지 롤백"| Catch["payment != null 이면 cancelAsync(pnr, validatingCarrier, payment, keepPnr)<br/>throw e"]

위 그림 보존 정보:

  • issue 컨트롤러 — GalileoTicketingController.kt:27.
  • GalileoTicketingService.issue — :91.

발권 실패 시 결제 자동 취소 ( cancelAsync, GalileoTicketingService.kt:235)

카드결제(payment != null)가 이미 승인된 상태에서 발권이 깨지면, 5초 delay 후 비동기 코루틴에서:

  1. 발급된 티켓이 있으면 cancelService.voidRepeat(...) (최대 3회, 1초 간격)
  2. cancelService.paymentCancelAsync(...) — KPS 카드 취소
  3. keepPnr == falsepnrCancelAsync(pnr) 순서로 보상합니다. keepPnr=true(재시도 의도)면 PNR은 살려둡니다. 결제는 살고 티켓은 안 나온 “이중 손실”을 막는 안전망입니다.

4명 chunk 제한의 의미

group.chunked(4)(:143)는 Travelport AirTicketing이 한 번에 처리하는 승객 수 제약을 우회합니다. 동일 운임(type+cashPrice)끼리 묶고 4명 단위로 끊어 여러 번 ticketing()을 호출합니다. 부분 발권 도중 실패하면 위 cancelAsync가 발급분까지 void합니다.

convertCommission 규칙 (:196)

상황결과
pricing 커미션 존재over 커미션이 더 클 때만 over 적용, 아니면 null(변경 없음)
pricing 없고 over 존재over 커미션 적용
둘 다 없음Commission(GROSS, 0.0, null) — 0% 명시 저장

4. FareRule (운임규정) — pricing→AirFareRules→KRT 번역

호출 체인: GalileoFareRuleController.getFareRulesGalileoFareRuleService.getFareRulesGalileoClient.pricingGalileoClient.getFareRulesKrtClient.getFareRules(한글 번역)

flowchart TD
  Ctrl["getFareRules(key, adult, child, infant)"]
  Ctrl --> Svc["GalileoFareRuleService.getFareRules(detailKey, adult, child, infant)<br/>fareRuleKey = generateFareRuleKey(...)"]
  Svc --> Cache{"findFareRules(fareRuleKey) 비어있지않음 ?"}
  Cache -->|"YES 캐시 HIT"| Ret["return"]
  Cache -->|"NO"| Itin["fareItinerary = getFareItinerary(detailKey)"]
  Itin --> Try["try"]
  Try --> Airport["airportMap = 모든 leg -> cityClient.getAirportByIata"]
  Airport --> Pricing["fareInfos = galileoClient.pricing(fareItinerary, airportMap, ADULT/CHILD/INFANT) SOAP AirPriceRQ<br/>.passengerFares.filter ADULT .flatMap fareInfos 성인 운임만"]
  Pricing --> Block["withBlocking(Dispatchers.IO)"]
  Block --> Rules["galileoClient.getFareRules(fareItinerary, fareInfos) SOAP AirFareRulesRQ -> groupBy groupSequence"]
  Rules --> Trans["pmap krtClient.getFareRules(fareItinerary, fareRules) 병렬 한글번역 -> getOrThrow() 하나라도 실패면 throw"]
  Trans --> Sort["flatten -> sortedWith compareBy(groupSequence, ordered) -> saveFareRules(...)"]
  Try -.->|"catch(e)"| Catch["isUnexposedFareItinerary(e) 이면 removeFlightSearchKey + saveUnexposedFareItinerary; throw"]

위 그림 보존 정보:

  • getFareRules 컨트롤러 — controller/…/GalileoFareRuleController.kt:18.
  • GalileoFareRuleService.getFareRules — application/…FareRuleService.kt:35.
  • 번역 단계 getOrThrow() = Search의 getOrEmpty()와 정반대 (하나라도 실패 시 전체 실패).

핵심 디테일

  • 2-홉 외부 호출: Travelport AirFareRulesRQ로 영문 운임규정을 받고, 그룹별로 KrtClient(/FareRuleTransKor.aspx)에 던져 한글로 번역합니다. KRT는 isVisible인 규정만 추립니다(KrtClient.kt:57).
  • Search와 정반대 실패 정책: 번역 단계는 pmap{...}.getOrThrow()(:87)라 하나라도 실패하면 전체 실패. Search의 getOrEmpty()와 대비됩니다. → async-coroutines
  • 성인 운임만 사용: filter { it.passengerType == PassengerType.ADULT }(:75) — 운임규정은 성인 기준으로만 조회.
  • 품절 동기화: pricing/번역에서 SOLD_OUT 또는 MINIMUM_CONNECTION_TIME이 나면 검색키 제거 + unexposed 저장(isUnexposedFareItinerary, :113). Booking/Search와 동일한 “운임이 죽었음” 전파.
  • structured fare rule(/structured)은 외부 호출 없이 캐시 itinerary만 변환(§1 참고).

5. Cancel / Refund (취소·환불) — void 중심

Cancel은 별도 컨트롤러가 아니라 GalileoBookingControllerPUT /{pnr}/cancel 등으로 노출되고, 로직은 GalileoCancelService에 모여 있습니다.

flowchart TD
  Ctrl["GalileoBookingController.cancel(pnr, CancelRequest)<br/>cancelRequest.validate()<br/>payment = takeIf isKeyInCardPaymentInfo()?.validateCancelRequest()?.let(Payment::of)"]
  Ctrl --> Svc["GalileoCancelService.cancel(pnr, payment)<br/>booking = galileoClient.getBooking(pnr)"]
  Svc --> Branch{"tickets 있음 ?"}
  Branch -->|"NO 미발권"| Unissued["departureAt = calculateTimezoneService.calculateToUTC(...)<br/>어제이전 생성 또는 NoShow 이면 throw CANCEL_UNABLE<br/>pnrCancelRepeat(pnr) 동기 최대 2회"]
  Branch -->|"YES 발권됨"| Issued["isVoidable(booking).not() 이면 throw CANCEL_UNABLE<br/>voidAll(booking) void 처리<br/>payment != null 이면 paymentCancelAsync(...) KPS 카드취소<br/>pnrCancelAsync(pnr) 비동기 PNR취소"]

위 그림 보존 정보:

  • GalileoBookingController.cancel — GalileoBookingController.kt:55.
  • GalileoCancelService.cancel — application/GalileoCancelService.kt:29.

voidable 판정 (isVoidable, GalileoCancelService.kt:234)

다음이면 void 불가(=환불 대상):

  • NonVoidableAirline에 포함되는 발권사 / EMD 티켓 보유 / 티켓 없음
  • 그렇지 않으면 getTicketDocuments 결과 수가 booking.tickets 수와 다르면 false, 같으면 isVoidable()(:248) 호출
  • isVoidable(): 최종 발권일이 오늘이고 모든 티켓 상태가 ISSUE 또는 AIRPORT_CONTROL이면 true. CHECKIN이면 즉시 CANCEL_UNABLE_BY_ALREADY_CHECK_IN throw.

VOID vs REFUND

cancelable(pnr)(:67)는 isVoidable 결과로 CancelActionType.VOID(당일발권 취소) / CancelActionType.REFUND(환불)을 돌려줍니다. 이 모듈의 실제 코드 경로는 VOID(당일 취소)까지만 구현되어 있고, cancel()의 응답도 CancelView(voided=true, refunds=emptyList())로 환불금액을 비웁니다. 진짜 환불(refund) 금액 계산/처리는 이 어댑터 범위 밖(상위 시스템)입니다. → galileo-pitfalls

voidRepeat — 멱등 재시도 (:166)

flowchart TD
  Start["voidRepeat(providerPnr, reservationPnr, ticketNumbers)<br/>voidedTicketNumbers = mutableSet()"]
  Start --> Loop["for count in 1..3"]
  Loop --> Alive["aliveTicketNumbers = ticketNumbers - voidedTicketNumbers 이미 void된 것 제외"]
  Alive --> TryVoid["try withBlocking delay(1000); void(...).apply voidedTicketNumbers.addAll(this); break"]
  TryVoid -->|"성공"| Break["break 종료"]
  TryVoid -->|"실패 catch"| CatchCheck{"count >= 3 ?"}
  CatchCheck -->|"YES"| Fail["slackService.sendVoidFail(...); throw"]
  CatchCheck -->|"NO"| Loop

위 그림 보존 정보:

  • voidRepeatGalileoCancelService.kt:166. 이미 void된 티켓을 빼고 남은 것만 재시도하는 멱등 누적 패턴, 3회 실패 시 Slack 경보 후 throw. 이미 void된 티켓을 빼고 남은 것만 재시도하는 멱등 누적 패턴. 3회 다 실패하면 Slack 경보 후 throw. 메시지큐가 아닌 Slack+예외 전파로 상태를 알리는 이 모듈 전반의 resilience-and-events 철학과 일치.

pnrCancel / paymentCancel

  • pnrCancelRepeat(:78): 최대 2회. ALREADY_CANCELED_PNR이면 정상 종료(break). 2회 실패 시 slackService.sendCancelFail 후 throw.
  • pnrCancel(:111): getBooking → galileoClient.pnrCancel(universalPnr=supplierIdentificationKey, version)UniversalRecordCancelRQ. hasAliveBooking이면 CANCEL_FAILED.
  • paymentCancelAsync(:203): 비동기 코루틴에서 kpsPaymentClient.cancel(...). 실패 시 Slack 경보 + PAYMENT_CANCEL_FAILED.

6. ChangeApis (APIS/여권정보 변경)

호출 체인: GalileoBookingController.changeApis (PUT /{pnr}) → GalileoPassengerService.changeApisGalileoClient.changeApis

flowchart TD
  Ctrl["changeApis(pnr, BookingChangeRequest)<br/>passengers = request.passengers.map Passenger.of(it)"]
  Ctrl --> Svc["GalileoPassengerService.changeApis(pnr, passengers)<br/>booking = galileoClient.getBooking(pnr)"]
  Svc --> Copy["passengers.forEach withInfantIdentificationKey(booking.passengers.find idKey 일치 ?.infantIdentificationKey) 유아-성인 연결키 복사"]
  Copy --> Note["TODO OSI 승객 연결 및 삭제 처리 확인 필요"]
  Note --> Change["galileoClient.changeApis(booking, passengers) UniversalRecordModify(ofChangeApis)"]
  Change --> Ret["return galileoClient.getBooking(pnr).passengers 변경 후 재조회"]

위 그림 보존 정보:

  • changeApis 컨트롤러 — GalileoBookingController.kt:42 (PUT /{pnr}).
  • GalileoPassengerService.changeApis — application/GalileoPassengerService.kt:12.
  • 가장 단순한 서비스(GalileoPassengerService는 메서드 1개). UniversalRecordModifyRQ.ofChangeApis로 APIS 정보를 수정하고 다시 조회해 결과를 돌려줍니다.
  • infantIdentificationKey 복사: 변경 요청 승객에 기존 booking의 유아 연결키를 다시 붙여 유아-성인 관계가 끊기지 않게 합니다.

7. Repricing (운임 재계산) — delete→pricing→addPriceInfo→retrieve→검증

가장 복잡한 흐름 중 하나. GET /{pnr}/repricing. GalileoTicketingService.ready(§3-1)와 거의 동일한 4-step입니다.

flowchart TD
  Ctrl["GalileoBookingController.repricing(pnr)"]
  Ctrl --> Svc["GalileoBookingService.repricing(pnr)<br/>booking = getBooking(pnr).also validateBookingConditionForTicketing()<br/>catch -> pnrCancelAsync(pnr); throw"]
  Svc --> Guard{"pricingInfoReferences.all isGuaranteed ?"}
  Guard -->|"YES 운임 보장"| Ret["return booking 재계산 불필요"]
  Guard -->|"NO"| Airport["airportMap = booking.schedules의 모든 leg -> cityClient.getAirportByIata"]
  Airport --> Del["1. galileoClient.deleteElements(booking, AIR_PRICING, deleteKeys = pricingInfoReferences.map it.key)<br/>UniversalRecordModify(ofDelete, AIR_PRICING) 기존 AirPricingInfo 삭제"]
  Del --> Reget["2. getBooking(pnr).run 삭제 후 version 갱신 위해 재조회 (필수!)"]
  Reget --> Pricing["pricingFare = galileoClient.pricing(booking=this, originSchedules=booking.schedules, airportMap) AirPriceRQ"]
  Pricing --> Add["galileoClient.addPriceInfo(booking=this, pricingFare) UniversalRecordModify(ofAddPricingInfo)"]
  Add --> Validate["3. getBooking(pnr).also validateFareBasisChange(origin=booking, new=it) 운임 변동 검증"]

위 그림 보존 정보:

  • GalileoBookingController.repricing — GalileoBookingController.kt:111 (GET /{pnr}/repricing).
  • GalileoBookingService.repricing — application/GalileoBookingService.kt:148.
  • repricing은 pricing(booking, originSchedules, airportMap) 두 번째 오버로드(GalileoClient.kt:159) 사용 (예약된 스케줄 기준 재가격).

delete 후 반드시 재조회하는 이유 (version 동기성)

Universal API는 모든 modify에 현재 version을 넣어야 합니다(낙관적 락). deleteElements로 가격을 지우면 version이 올라가므로, 곧바로 getBooking(pnr)으로 최신 version을 받아온 다음에야 pricing/addPriceInfo를 호출할 수 있습니다. 코드 주석도 // element 삭제 후 api version 갱신을 위해 retrieve(GalileoTicketingService.kt:74)라고 명시. version이 어긋나면 modify가 실패합니다. → galileo-pitfalls

validateFareBasisChange — 운임 바뀌면 즉시 품절 처리

재가격 전/후 승객별 fareBasis(정렬·조인 문자열)를 비교해 달라지면(GalileoBookingService.kt:282, GalileoTicketingService.kt:214):

  1. cancelService.pnrCancelAsync(originBooking.pnr) — PNR 비동기 취소
  2. StatusInvalidException(SOLD_OUT, "...fareBasis is changed (old->new)") throw + .capture() 즉 “고객이 보던 운임과 실제 발권가가 달라졌다”를 SOLD_OUT으로 막아 잘못된 가격 발권을 차단합니다. ready/repricing 둘 다 같은 가드를 둡니다. 주의: repricing은 pricing(booking, originSchedules, airportMap) 두 번째 오버로드(GalileoClient.kt:159)를 씁니다(검색단계 itinerary가 아닌 예약된 스케줄 기준 재가격).

8. Divide (동반자 분리)

호출 체인: GalileoBookingController.divide (POST /{pnr}/divide) → GalileoBookingService.divideGalileoClient.divideretrieve(newPnr)

flowchart TD
  Svc["GalileoBookingService.divide(pnr, requestPassengers)"]
  Svc --> Get["booking = getBooking(pnr).also"]
  Get --> G1{"passengers.any type != ADULT ?"}
  G1 -->|"YES 유소아 분리 불가"| Throw1["throw DIVIDE_FAILED(exists child or infant)"]
  G1 -->|"NO"| G2{"passengers.size == requestPassengers.size ?"}
  G2 -->|"YES 전원 분리 불가"| Throw2["throw DIVIDE_FAILED(requested all passengers)"]
  G2 -->|"NO"| Divide["newPnr = galileoClient.divide(universalPnr=supplierIdentificationKey, providerPnr=booking.pnr, passengerReferences=요청 idKey들)<br/>SOAP ProviderReservationDivideRQ (UniversalRecordService)"]
  Divide --> Ret["return retrieve(newPnr) 분리된 새 PNR 재조회 (티켓 합성 포함)"]

위 그림 보존 정보:

  • GalileoBookingService.divide — application/GalileoBookingService.kt:230 (POST /{pnr}/divide).
  • 두 가드 모두 DIVIDE_FAILED + .capture().
  • 가드 두 개: 유아/소아가 있으면 불가, 전원을 분리 요청하면 불가(분리 의미가 없음). 둘 다 DIVIDE_FAILED + .capture().
  • divide 후 retrieve(newPnr)(§2의 retrieve)로 새 PNR을 e-Ticket/EMD까지 포함해 반환.

9. Queue (큐 관리) — 멀티 PCC 병렬

호출 체인: GalileoQueueControllerGalileoQueueServiceGalileoClient.getCountInQueue / getPnrsInQueue / removePnrsInQueue (/GdsQueueService)

flowchart TD
  GetCtrl["getQueues() GET /internals/GALILEO/queues"]
  GetCtrl --> GetSvc["GalileoQueueService.getQueuePnrs()<br/>queuePropertyMap = getQueuePropertyMap() 모든 channel.funnel의 pcc + offline pcc"]
  GetSvc --> GetBlock["try withBlocking(Dispatchers.IO)"]
  GetBlock --> Count["1. queuePropertyMap.values.pmap getCountInQueue(it).filter number in 21,22,23 PCC별 병렬 카운트<br/>.getOrEmpty().filter 비어있지않음"]
  Count --> Pnrs["2. queueCountsByPcc.pmap counts.flatMap getPnrsInQueue(prop pcc, queueNumber) 큐별 PNR 목록 병렬조회<br/>.map QueuePnrInfo(pnr, queueNumber, pccOid=pcc) .getOrEmpty()"]
  Pnrs --> GetCatch["catch -> slackService.sendQueueFail(...); emptyList() -> flatten"]

  RemCtrl["remove(List QueueRemoveRequest) PUT"]
  RemCtrl --> RemSvc["GalileoQueueService.remove(queuePnrInfos)<br/>withBlocking(Dispatchers.IO)"]
  RemSvc --> Chunk["queuePnrInfos.chunked(10).flatMap 10개씩 끊어서"]
  Chunk --> RemPmap["chunk.pmap removePnrsInQueue(prop it.pccOid, it) @Retryable(3회,5s) 병렬 삭제<br/>.onFailure error = ex.first() .getOrEmpty()"]
  RemPmap --> Remain["remainQueues = 요청 - 삭제됨; 남으면 throw(error 또는 QUEUE_REMOVE_FAILED)"]
  Remain --> RemCatch["catch -> slackService.sendQueueFail(...); false"]

위 그림 보존 정보:

  • getQueues 컨트롤러 — controller/…/GalileoQueueController.kt:18. GalileoQueueService.getQueuePnrs — application/GalileoQueueService.kt:31.
  • remove 컨트롤러 — GalileoQueueController.kt:25. GalileoQueueService.remove — application/GalileoQueueService.kt:59.

핵심 디테일

  • @Retryable은 여기 1곳뿐: GalileoClient.removePnrsInQueue(GalileoClient.kt:871)에 @Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000)). Spring Retry AOP. (Search의 @CircuitBreaker와 함께 이 모듈의 유일한 선언적 resilience 어노테이션 2곳.) → resilience-and-events
  • 대상 큐: TARGET_QUEUE_NUMBERS = ["21","22","23"](GalileoQueueService.kt:28).
  • 멀티 PCC: getQueuePropertyMap(:95)이 모든 channel→funnel의 pcc와 추가로 soap.offline.pcc(오프라인 발권 PCC)까지 묶어 QueueRequestProperty로 만듭니다. 큐는 PCC마다 따로 있어 병렬 조회.
  • 실패는 던지지 않고 Slack + 빈 결과/false: 스케줄러성 작업이라 장애 시 전체 흐름을 막지 않고 경보만 보냅니다.

10. CashReceipt (현금영수증)

호출 체인: GalileoCashReceiptControllerGalileoCashReceiptServiceGalileoClient.getBooking/getTicketDocuments + KpsPaymentClient.issueCashReceipt/cancelCashReceipt

flowchart TD
  IssueCtrl["issueCashReceipt(CashReceiptRequest) POST /issue"]
  IssueCtrl --> IssueSvc["GalileoCashReceiptService.issueCashReceipt(pnr, price, validatingCarrier, type, identityNumber)<br/>runCatching"]
  IssueSvc --> Get["booking = galileoClient.getBooking(pnr).also if(tickets 비었으면) throw CASH_RECEIPT_ISSUE_FAILED"]
  Get --> Validate["ticketNumber = getTicketDocuments(pnr, URpnr, subPnr).let validateTicket(pnr, it, price)<br/>1 티켓 비었으면 실패 / 2 cardPrice 합 > 0 이면 실패 (카드결제분 현금영수증 불가) / 3 총 cashPrice < 요청 price 면 실패<br/>it.sortedBy passengerType.order .firstNotNullOf tickets.first().ticketNumber"]
  Validate --> Kps["kpsPaymentClient.issueCashReceipt(pnr, type, identityNumber, validatingCarrier, pnrCreateDate=pnrCreatedAt.toKST(UTC).yyyyMMdd, price, ticketNumber) KPS /KpsCashReceiptIssue.aspx"]
  Kps --> IssueFail["onFailure -> handleIssuanceFailure(pnr, type, e) Slack 비동기 경보 -> getOrThrow()"]

  CancelCtrl["cancelCashReceipt(CashReceiptCancelRequest) PUT /cancel"]
  CancelCtrl --> CancelSvc["GalileoCashReceiptService.cancelCashReceipt(pnr, pnrCreatedAt, ticketNumber, approvalNumber)<br/>runCatching kpsPaymentClient.cancelCashReceipt(...) KPS /KpsCashReceiptCancel.aspx"]
  CancelSvc --> CancelFail["onFailure -> handleCancellationFailure(...); throw e"]

위 그림 보존 정보:

  • issueCashReceipt 컨트롤러 — controller/…/GalileoCashReceiptController.kt:19. 서비스 메서드 :28.
  • cancelCashReceipt 컨트롤러 — GalileoCashReceiptController.kt:33. 서비스 메서드 :97.

핵심 디테일

  • 카드결제분은 현금영수증 발행 불가: validateTicket(:71)에서 ticketDocuments.sumOf { cardPrice } > 0이면 즉시 실패. 현금영수증은 현금/계좌결제분에 대해서만.
  • 요청금액 검증: 요청 price가 총 발권 cashPrice보다 크면 실패(:88).
  • 티켓번호 선정: 승객유형 순서(passengerType.order)로 정렬해 첫 티켓번호를 KPS에 전달.
  • 실패 경보는 비동기: handleIssuanceFailure/handleCancellationFailureCoroutineScope(Dispatchers.IO).withLaunch로 Slack을 보냅니다(:117, :128). 본 흐름을 막지 않음.

11. 비동기 / 코루틴 / Resilience 한눈에

위치패턴의미
GalileoFlightSearchService.searchwithBlocking + cartesianProduct().pmap{}.onFailure{}.getOrEmpty()멀티공항 병렬 검색, 부분실패 허용
GalileoFareRuleService.getFareRuleswithBlocking + pmap{}.getOrThrow()한글번역 병렬, 하나라도 실패 시 전체 실패
GalileoQueueService.getQueuePnrs/removewithBlocking + pmap, chunkedPCC별 병렬, 실패는 Slack+빈결과
GalileoBookingService book/confirm/repricing 보상cancelService.pnrCancelAsyncfire-and-forget PNR 취소
GalileoTicketingService.cancelAsyncCoroutineScope(IO).withLaunch { delay; void; paymentCancel; pnrCancel }발권실패 시 결제+티켓 비동기 롤백
GalileoCancelService.pnrCancelAsync/paymentCancelAsyncwithLaunch { delay(5000); ... }비동기 취소(5초 지연)
GalileoBookingService.book TimeLimit 재조회withBlocking { delay(3000); getBooking }동기 블로킹 1회 재조회
GalileoCancelService.voidRepeatwithBlocking { delay(1000); void } for 1..3멱등 누적 void
GalileoSearchController.search@CircuitBreaker(galileoSearch)검색 서킷브레이커(유일)
GalileoClient.removePnrsInQueue@Retryable(3, 5s)큐 제거 재시도(유일)
모든 코루틴withBlocking/withLaunch/withAsync(CoroutineExtensions.kt)SupervisorJob + AdapterCoroutineExceptionHandler + MDCContext 자동 부착

신입을 위한 정리

  • 외부 호출의 선언적 resilience(@CircuitBreaker/@Retry)는 단 2곳(검색 CB, 큐삭제 Retry). 나머지는 모두 코드 레벨 보상 트랜잭션 + 재시도 루프 + Slack 경보로 구현됩니다. 이 모듈에 메시지큐는 없습니다 → resilience-and-events.
  • pmap의 후처리가 getOrEmpty()getOrThrow()냐로 “부분실패 허용/불허”가 갈립니다. 새 오퍼레이션을 만들 때 어느 쪽을 쓸지 의식적으로 골라야 합니다.
  • SOAP modify 계열(addPriceInfo/deleteElements/saveCommission/changeApis/addPaymentInfoRemark)은 전부 private universalRecordModify(GalileoClient.kt:792)를 거치고, delete 후 재조회로 version을 갱신하는 규칙이 반복됩니다.

연관 노트