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로 갈라짐.
| 구분 | 클라이언트 | 프로토콜 | 외부 엔드포인트 | 파일 |
|---|---|---|---|---|
| 검색 | GalileoRestClient | REST/JSON (OAuth Bearer) | /11/air/catalog/search/catalogproductofferings | infrastructure/rest/GalileoRestClient.kt |
| 예약/발권/조회/환불/큐 | GalileoClient | SOAP (Universal API) | /AirService, /UniversalRecordService, /GdsQueueService | infrastructure/soap/GalileoClient.kt (907 lines) |
| 결제(카드 승인/취소, 현금영수증) | KpsPaymentClient | REST/JSON (.aspx) | /KpsBspCardAuth.aspx 외 | infrastructure/kps/KpsPaymentClient.kt |
| 운임규정 한글번역 | KrtClient | REST/XML (.aspx) | /FareRuleTransKor.aspx | infrastructure/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.search → GalileoFlightSearchService.search → GalileoRestClient.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 되면searchFallback이emptyList()를 반환하고 Datadog span에supplier.circuit-breaker=OPEN태그를 박습니다(:65-72). 설정은application.yml:63galileoSearch: 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.create → GalileoBookingService.book → GalileoClient.pricing → GalileoClient.book → GalileoClient.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} retrieve | bookingService.retrieve | getBooking + getTicketDocuments + EMD 조회 | 발권된 PNR이면 e-Ticket + EMD까지 합성 |
GET /{pnr}/check-pnr | bookingService.checkPnr → galileoClient.checkPnr | UniversalRecordRetrieve | PNR 유효성만 검증 |
GET /{pnr}/confirm | bookingService.confirm | getBooking + 검증 | INFT 좌석 품절(isInfantSoldOutAtTicketing) 시 throw, 실패 시 pnrCancelAsync |
GET /{pnr}/repricing | bookingService.repricing | §7 참고 (복잡) | 운임 재계산 |
PUT /{pnr} changeApis | galileoPassengerService.changeApis | §6 참고 | APIS 정보 변경 |
PUT /{pnr}/cancel | cancelService.cancel | §5 참고 | void/환불 |
GET /{pnr}/expected-cancel / cancelable | cancelService.isVoidable / cancelable | getBooking + getTicketDocuments | VOID/REFUND 판정 |
POST /{pnr}/divide | bookingService.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 후 비동기 코루틴에서:
- 발급된 티켓이 있으면
cancelService.voidRepeat(...)(최대 3회, 1초 간격)cancelService.paymentCancelAsync(...)— KPS 카드 취소keepPnr == false면pnrCancelAsync(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.getFareRules → GalileoFareRuleService.getFareRules → GalileoClient.pricing → GalileoClient.getFareRules → KrtClient.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은 별도 컨트롤러가 아니라
GalileoBookingController의PUT /{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_INthrow.
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
위 그림 보존 정보:
voidRepeat—GalileoCancelService.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.changeApis → GalileoClient.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):
cancelService.pnrCancelAsync(originBooking.pnr)— PNR 비동기 취소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.divide → GalileoClient.divide → retrieve(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 병렬
호출 체인: GalileoQueueController → GalileoQueueService → GalileoClient.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 (현금영수증)
호출 체인: GalileoCashReceiptController → GalileoCashReceiptService → GalileoClient.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/handleCancellationFailure가CoroutineScope(Dispatchers.IO).withLaunch로 Slack을 보냅니다(:117,:128). 본 흐름을 막지 않음.
11. 비동기 / 코루틴 / Resilience 한눈에
| 위치 | 패턴 | 의미 |
|---|---|---|
GalileoFlightSearchService.search | withBlocking + cartesianProduct().pmap{}.onFailure{}.getOrEmpty() | 멀티공항 병렬 검색, 부분실패 허용 |
GalileoFareRuleService.getFareRules | withBlocking + pmap{}.getOrThrow() | 한글번역 병렬, 하나라도 실패 시 전체 실패 |
GalileoQueueService.getQueuePnrs/remove | withBlocking + pmap, chunked | PCC별 병렬, 실패는 Slack+빈결과 |
GalileoBookingService book/confirm/repricing 보상 | cancelService.pnrCancelAsync | fire-and-forget PNR 취소 |
GalileoTicketingService.cancelAsync | CoroutineScope(IO).withLaunch { delay; void; paymentCancel; pnrCancel } | 발권실패 시 결제+티켓 비동기 롤백 |
GalileoCancelService.pnrCancelAsync/paymentCancelAsync | withLaunch { delay(5000); ... } | 비동기 취소(5초 지연) |
GalileoBookingService.book TimeLimit 재조회 | withBlocking { delay(3000); getBooking } | 동기 블로킹 1회 재조회 |
GalileoCancelService.voidRepeat | withBlocking { 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)은 전부 privateuniversalRecordModify(GalileoClient.kt:792)를 거치고, delete 후 재조회로 version을 갱신하는 규칙이 반복됩니다.
연관 노트
- Galileo 개요 · 스키마 · 지뢰
- 공통 오퍼레이션 · 요청 흐름 · 콜러-콜리 맵
- 코루틴 · Resilience & 이벤트
- 기존 분석: galileo-gds