0. 한눈에 보기 — SQ는 “Amadeus Altea NDC(EDIST 18.1)” 어댑터다
Singapore Airlines는 NDC 표준(EDIST 18.1)을 쓰지만, 실제 엔드포인트는 Amadeus webservices다. 모든 요청 클래스의 SOAPAction이 http://webservices.amadeus.com/NDC_*_18.1 형태로, SQ가 Amadeus Altea NDC 플랫폼 위에서 동작함을 보여준다.
infrastructure/request/SingaporeairRequest.kt 인터페이스의 단 하나의 멤버가 action(SOAPAction)이며, 각 RQ가 이를 오버라이드한다.
비즈니스 동작
RQ 클래스
SOAPAction (action 필드)
응답(RS)
검색
AirShoppingRQ
NDC_AirShopping_18.1
AirShoppingRS
운임 확정/규정
OfferPriceRQ
NDC_OfferPrice_18.1
OfferPriceRS
예약 생성
OrderCreateRQ
NDC_OrderCreate_18.1
OrderViewRS
예약 조회
OrderRetrieveRQ
NDC_OrderRetrieve_18.1
OrderViewRS
결제/발권·재발행·분리·좌석·부가
OrderChangeRQ
NDC_OrderChange_18.1
OrderViewRS
환불계산·재발행검색·재발행리프라이싱
OrderReshopRQ
NDC_OrderReshop_18.1
OrderReshopRS
예약취소
OrderCancelRQ
NDC_OrderCancel_18.1
OrderCancelRS
부가서비스 목록
ServiceListRQ
NDC_ServiceList_18.1
ServiceListRS
좌석맵
SeatAvailabilityRQ
NDC_SeatAvailability_18.1
SeatAvailabilityRS
핵심 통찰 1 — "한 RQ 클래스가 여러 비즈니스 동작을 담당"
OrderChangeRQ는 companion의 팩토리 메서드(ofPayment, ofReissue, ofDivide, ofSeat, ofAncillary)로 발권·재발행·분리·좌석·부가를 전부 처리한다. OrderReshopRQ도 ofRefundCalculate, ofReissueSearch, ofPricing으로 환불계산·재발행검색·재발행리프라이싱을 처리한다. SOAPAction은 같아도 Request.UpdateOrder/ChangeOrder의 내용물이 달라 의미가 갈린다. 콜리 추적 시 “어떤 RQ인가”가 아니라 “어떤 of* 팩토리인가”를 봐야 한다.
핵심 통찰 2 — 레이어 구조가 일관적이다
모든 오퍼레이션이 동일 패턴을 따른다:
Controller(interfaces) → {Op}Service(application) → SingaporeairClient(infrastructure) → SOAP API → {RS}.toXxx() 매핑.
SingaporeairClient는 모든 외부 호출의 단일 진입점이며, 11개 무상태 메서드(search/pricing/book/retrieve/cancel/savePayment/refundCalculate/reissueSearch/repricingWithReissue/reissue/getMiniRules + ancillary)가 전부 동일한 SOAP 호출 체인(endpoint.post().header().requestBodyConvert().execute().fold())을 사용한다.
1. Search — 검색 / 상세 / 재발행 검색
1-1. 일반 검색 (POST /internals/SINGAPOREAIR/search)
콜러→콜리 체인:
flowchart TD
Ctrl["SingaporeairSearchController.search (interfaces internals)"]
Ctrl -->|"@CircuitBreaker name singaporeSearch fallback searchFallback"| Svc["SingaporeairFlightSearchService.search (application)"]
Svc -->|"캐시 HIT 시 바로 반환"| Hit["flightSearchKeyRepository.findKey"]
Hit --> Hit2["fareItineraryRepository.getFareItineraries(key)"]
Svc -->|"캐시 MISS"| Miss["withBlocking Dispatchers.IO 코루틴 진입 블로킹 브리지"]
Miss --> Cart["filteredOriginDestinations.cartesianProduct OD 조합 폭발"]
Cart --> Pmap["pmap SingaporeairClient.search 병렬 SOAP 호출"]
Pmap --> Fail["onFailure 전부 실패 시 첫 예외 throw"]
Fail --> Empty["getOrEmpty"]
Empty --> Flat["flatten.distinctBy it.id"]
Flat --> Save["캐시 저장 useCache flightSearchKeyRepository.addKey 와 saveFareItineraries"]
Svc --> Client["SingaporeairClient.search (infrastructure)"]
Client --> RQ["AirShoppingRQ.of iataCode agencyName 주입"]
RQ --> Soap["searchClient 15s 타임아웃 와 soapRequestBodyConverter WS-Security 헤더"]
Soap --> Exec["execute AirShoppingRS soapBodyDeserializerOf"]
Exec --> Fold["fold success 또는 failure"]
Fold --> Check["checkError 코드 710 또는 367 결과없음 무시 그 외 SEARCH_FAILED"]
Fold --> Map["AirShoppingRS.response.toFareItineraries filterNot isNonAir 또는 isNonSingaporeAirlineSchedule"]
SQ 검색만의 두 필터
SingaporeairClient.search() (SingaporeairClient.kt:115-118)는 응답에서 두 종류를 제거한다:
isNonAir() — NonAirEquipment.existValueOf(equipmentType)로 기차/버스 등 비항공 구간(예: AVE, TGV) 필터.
isNonSingaporeAirlineSchedule() — marketingCarrier != "SQ"인 구간이 하나라도 있으면 제거. 즉 SQ marketing 편만 판매한다.
또한 검색 성공 판정이 특이하다: 응답 에러코드 710(NO FARE FOUND), 367(NO ACTIVE ITINERARY)은 정상으로 간주하고 빈 리스트로 흘려보낸다(SingaporeairClient.kt:100-101). 신입이 “검색 0건 = 에러”로 오해하기 쉬운 지점.
비동기: pmap은 OD 조합(cartesianProduct)마다 withAsync로 병렬 호출하고 awaitAll 후 AsyncResults로 모은다. pmap/withBlocking/onFailure/getOrEmpty는 전부 support/util/CoroutineExtensions.kt에 정의 → async-coroutines 참고. 부분 실패 허용: onFailure에서 successes가 비어있을 때만 첫 예외를 던지므로, 일부 OD만 성공해도 결과를 반환한다.
multiFare 미지원
SingaporeairFlightSearchService.search() (FlightSearchService.kt:65)는 preferences.first()만 사용한다. 주석대로 “multiFare 옵션이 없기 때문에 첫번째 하나만 사용”. 타 GDS와 달리 SQ는 운임 옵션 다중 검색을 하지 않는다.
1-2. 상세 조회 (GET /internals/SINGAPOREAIR/search)
SingaporeairSearchController.detail() → FlightSearchService.getFareItinerary(key) → fareItineraryRepository.getFareItinerary(hashKey) (Redis에서 직전 검색 결과 복원). 외부 SOAP 호출 없음. 이후 fareItinerary.validate(adult, child, infant)로 인원 검증, flightAmenityService.findAmenityMap(...)으로 어메니티 병합. 즉 상세는 캐시 의존이며 검색 결과가 만료되면 상세가 깨진다.
1-3. 재발행 검색 (POST /internals/SINGAPOREAIR/search/reissue)
이것이 SQ 검색에서 가장 복잡한 흐름이다.
flowchart TD
Ctrl["SingaporeairSearchController.reissueSearch"]
Ctrl --> Svc["FlightSearchService.reissueSearch(pnr ...)"]
Svc --> Retrieve["SingaporeairClient.retrieve(pnr) 기존 예약 조회 OrderRetrieveRQ"]
Svc --> Remain["변경하지 않을 item 계산 remainItem"]
Remain --> Group["booking.schedules.groupBy groupSequence filterNot 요청 OD와 출 도착지 일치 그룹 즉 변경 안 하는 구간만 남김"]
Svc --> Reissue["SingaporeairClient.reissueSearch(key booking originDestinations remainItem cabins ...)"]
Reissue --> RQ["OrderReshopRQ.ofReissueSearch AddOfferItem 와 DeleteOrderItem retainServiceIds"]
Reissue --> Exec["execute OrderReshopRS"]
Reissue --> Map["OrderReshopRS.Response.toFareItineraries(key onlyDirect remainServiceIds)"]
Map --> Select["reshopOffers를 journey 기준 groupBy 후 onlyDirect 필터 후 최저가 minByOrNull totalPrice 선택"]
reissueSearch의 remainItem/retainServiceID 메커니즘
SQ 재발행은 “바꿀 구간만 새로 사고, 안 바꾸는 구간은 그대로 유지”하는 부분 재발행이다. FlightSearchService.kt:122-124에서 요청 OD와 출발/도착이 일치하지 않는 스케줄 그룹(=유지 구간)을 추려 remainItem으로 만들고, 이 구간들의 referenceServiceIds를 OrderReshopRQ의 DeleteOrderItem.retainServiceIds(RetainServiceID)로 넘긴다(OrderReshopRQ.kt:88-89). 응답에서 remainServiceIds가 FareItinerary에 보존되어 이후 리프라이싱 단계로 전파된다.
2. Booking — 예약 / 조회 / 분리 / 취소
2-1. 예약 생성 (POST /internals/SINGAPOREAIR/bookings)
book()은 예약 직전 pricing()(OfferPrice)으로 운임을 다시 확정한 뒤 book()(OrderCreate)을 호출한다. 그리고 반드시 한 번 더 retrieve() 한다. 이유는 BookingService.kt:57 주석: “OrderCreateRQ의 응답으로 생성된 탑승객 번호가 변경될 수 있으므로 한번 더 retrieve”. 즉 OrderCreate 응답의 PAX 식별자(identificationKey)를 신뢰하지 않고 재조회로 정합성을 맞춘다.
예약 성공 후 removeFlightSearchKey()는 CoroutineScope(Dispatchers.IO).withLaunch { }로 fire-and-forget(결과/예외를 기다리지 않음). 캐시키 삭제가 실패해도 예약은 성공으로 응답한다.
book() 에러: 코드 911(NOT AVAILABLE AND WAITLIST CLOSED)은 .capture()(Slack/Sentry 캡처)하고, 그 외는 BOOKING_FAILED만 던진다(SingaporeairClient.kt:275-278).
2-2. 조회 / confirm / repricing / check-pnr
엔드포인트
서비스 메서드
외부 호출
비고
GET /{pnr}
bookingService.retrieve(pnr)
OrderRetrieve
GET /{pnr}/confirm
bookingService.retrieve(pnr)
OrderRetrieve
retrieve와 동일
GET /{pnr}/repricing
bookingService.retrieve(pnr).passengers
OrderRetrieve
RepricingView.of(passengers)
GET /{pnr}/check-pnr
없음
없음
무조건 true 반환 (BookingController.kt:101-104)
/check-pnr는 항상 true
SingaporeairBookingController.checkPnr()는 외부 호출 없이 ResponseEntity.ok(true)만 반환한다. 다른 공급사의 PNR 유효성 검사 인터페이스를 형식적으로 맞춘 스텁이다.
2-3. 분리 (Divide, POST /{pnr}/divide)
flowchart TD
Ctrl["SingaporeairBookingController.divide"]
Ctrl --> Svc["SingaporeairBookingService.divide(pnr passengers)"]
Svc --> Validate["retrieve(pnr) 후 validate 요청 PAX vs 조회 PAX"]
Validate --> SamePax["동일 탑승객 존재 확인 firstName lastName gender type"]
Validate --> PairCheck["유아-성인 페어 무결성 확인 parentIdentificationKey"]
Svc --> Divide["SingaporeairClient.divide(pnr passengers.filter type 가 INFANT 아님)"]
Divide --> Re["retrieve(it.pnr) 분리 후 재조회"]
분리는 "현재 서비스하지 않음" 마킹 + 유아 제외
SingaporeairClient.divide()에는 //현재 서비스하지 않음 주석이 붙어 있다(SingaporeairClient.kt:647). 또한 BookingService.kt:79 주석대로 유아(INFANT)는 RQ에서 제외한다 — “유아는 부모 탑승객 자동으로 따라감(유아 승객 RQ 생성시 오류 발생)“. OrderChangeRQ.ofDivide는 reason="DIV"를 넘긴다(OrderChangeRQ.kt:187).
2-4. 취소 / 예상취소 / cancelable — VOID vs REFUND 분기
이 모듈에서 가장 중요한 상태 기반 분기다. 취소는 두 경로로 갈린다: 발권 전(=VOID, 무료 취소)과 발권 후(=REFUND, 환불수수료 계산).
flowchart TD
Ctrl["SingaporeairBookingController.cancel"]
Ctrl --> Svc["SingaporeairCancelService.cancel(pnr)"]
Svc --> Expected["expectedCancel(pnr)"]
Expected --> Retrieve["SingaporeairClient.retrieve(pnr)"]
Retrieve --> CheckIn["if passenger.isCheckIn throw CANCEL_UNABLE_BY_ALREADY_CHECK_IN 체크인 시 취소 불가"]
CheckIn --> Voidable{"booking.voidable 판정"}
Voidable -->|"voidable true 또는 모든 PAX tickets 비어있음"| FreeCancel["그냥 취소 가능 refund 계산 안 함"]
Voidable -->|"else"| RefundCalc["SingaporeairClient.refundCalculate(booking) 별표 OrderReshop ofRefundCalculate"]
Svc --> Actual{"실제 취소"}
Actual -->|"voidable 또는 발권 전"| Void["SingaporeairClient.cancel(pnr) VOID"]
Actual -->|"else"| Refund["SingaporeairClient.cancel(pnr expectedRefundAmount 합계) REFUND"]
voidable 플래그는 어디서 오나
OrderViewRS.toBooking() (OrderViewRS.kt:90)이 메타데이터에서 추출한다:
metadata.other.otherMetadata.ruleMetaDataList.find { it.metadataKey == "VOID_ELIGIBILITY" }?.status == true.
즉 SQ(Amadeus)가 응답 메타로 내려주는 VOID 가능 여부를 그대로 신뢰한다. voidable=true면 무료 취소(VOID), false면 환불수수료 계산 후 REFUND.
SingaporeairFareRuleController.getStructuredFareRules()는 SOAP 호출 없이 캐시의 FareItinerary만으로 StructuredFareRuleView.of(...)를 구성한다.
규정 누락 시 Slack 경보
getMiniRules() (SingaporeairClient.kt:178-180)는 응답 warning에 "LOCALIZED CONTENT COULD NOT BE RETRIEVED FROM CMS"가 있으면 sendSlackMessage("규정 확인 필요")를 호출한다. 이 콜백은 FareRuleService.kt:19-24에서 slackService.sendWarnings(SINGAPOREAIR, ...)로 바인딩되어 있다. fareComponents가 null이면 FETCH_FARE_RULES_FAILED로 즉시 실패한다.
규정 조회가 검색이 아니라 OfferPrice를 재호출한다는 점에 주의 — SQ는 운임 규정을 가격 확정 응답(OfferPrice)의 priceClass에서 추출한다. 코드에는 journeyOverview 기반의 대체 구현이 주석으로 남아 있다(SingaporeairClient.kt:192-204).
SingaporeairTicketingController.ready() → TicketingService.ready(pnr) → retrieve(pnr). 반환은 null to booking.schedules. 즉 passenger는 항상 null이고 schedules만 내려준다(TicketingService.kt:27-30).
issue()가 savePayment()에서 예외를 받으면 cancelAsync(pnr)로 예약을 자동 롤백한다(TicketingService.kt:57, 97-111):
private fun cancelAsync(pnr: String) { CoroutineScope(Dispatchers.IO).withLaunch { delay(5000) // 5초 대기 후 try { singaporeairClient.cancel(pnr) } catch (e: Exception) { slackService.sendCancelFail(SINGAPOREAIR, pnr, e.message); throw e } }}
이것이 SQ의 “이벤트/상태 전파”가 메시지큐 없이 어떻게 구현되는지를 보여주는 전형 — 예외 전파 + 코루틴 보상 + Slack 경보. 보상 취소조차 실패하면 운영자에게 Slack을 쏜다. delay(5000)은 SQ 측 PNR 상태 안정화를 기다리는 의도로 보인다. → resilience-and-events, async-coroutines.
savePayment의 NO_QUOTA / 타임아웃
savePayment() (SingaporeairClient.kt:375-418)는 응답에 "MAXIMUM TICKET LIMIT REACHED"가 있으면 NO_QUOTA로, 그 외는 TICKETING_FAILED로 던진다. 타임아웃 시timeoutCallback()을 호출 → TicketingService에서 slackService.sendTicketingTimeout(...)으로 연결되어 있다(TicketingService.kt:42-47). 발권 타임아웃은 “실패”가 아니라 “결과 불확실”이므로 운영자 확인이 필요하다.
4-3. 재발행 (POST /ticketing/addition → GET /ticketing/addition/{reissueKey})
재발행은 비동기 폴링 패턴으로 처리되는 유일한 오퍼레이션이다. 컨트롤러가 즉시 202 ACCEPTED + 폴링키를 주고, 클라이언트가 키로 결과를 폴링한다.
flowchart TD
subgraph S1 ["1단계 POST /ticketing/addition ReticketingRequest pnr detailKey prepaidPrice"]
Ctrl1["SingaporeairTicketingController.reissue"]
Ctrl1 --> Polling["polling key REISSUE SINGAPOREAIR pnr ttl REISSUE.ttl redisTemplate 블록 안에서 ticketingService.reissue(pnr detailKey prepaidPrice) 백그라운드 코루틴에서 실행"]
Polling --> Ret["return DeferredKeyView(pollingKey) HTTP 202 ACCEPTED"]
Ret --> Inner["polling 내부 PollingUtils.kt Redis에 DeferredResult.pending 저장 후 CoroutineScope IO withLaunch runCatching init"]
Inner -->|"onSuccess"| OnSuccess["DeferredResult.complete(data)"]
Inner -->|"onFailure"| OnFailure["DeferredResult.error(throwable)"]
end
subgraph S2 ["2단계 GET /ticketing/addition/reissueKey"]
Ctrl2["SingaporeairTicketingController.checkReissue"]
Ctrl2 --> Poller["poller ReissueResult Booking PassengerFare reissueKey redisTemplate"]
Poller -->|"PENDING"| Pending["DeferredView.Pending"]
Poller -->|"ERROR"| Error["throw throwable"]
Poller -->|"COMPLETE"| Complete["DeferredView.Complete ReticketingView.of(booking passengers)"]
end
Ret -.-> Ctrl2
ticketingService.reissue()의 실제 작업(TicketingService.kt:64-95):
flowchart TD
Start["reissue(pnr detailKey prepaidPrice)"]
Start --> Origin["originBooking 가 SingaporeairClient.retrieve(pnr)"]
Origin --> Fare["fareItinerary 가 flightSearchService.getFareItinerary(detailKey) reissueDetail 결과 캐시"]
Fare --> Reprice["passengerFares 가 SingaporeairClient.repricingWithReissue(originBooking fareItinerary).passengerFares 별표 OrderReshop.ofPricing 재발행 차액 재계산"]
Reprice --> Verify["if prepaidPrice 가 sumOf total 더하기 carrierFee 곱하기 count 와 다르면 throw RETICKETING_FAILED_BY_MISMATCH_PRICE"]
Verify --> Commit["newBooking 가 SingaporeairClient.reissue(originBooking prepaidPrice fareItinerary) 별표 OrderChange.ofReissue 실제 재발행 커밋"]
reissue() 클라이언트(SingaporeairClient.kt:791-831)는 "TICKET IS NOT ELIGIBLE FOR EXCHANGE" 메시지를 TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE로 따로 구분한다. 응답은 toReissueBooking(originBooking)으로 매핑되며, 기존 티켓번호와 다른 신규 티켓만 추려낸다(OrderViewRS.kt:121-124).
5. 운임 재계산(Repricing) / 재발행(Reissue) 전체 그림
여기가 SQ 모듈의 가장 어려운 부분이다. “재발행 검색 → 재발행 상세(리프라이싱) → 재발행 발권”의 3단 파이프라인을 정리한다.
flowchart TD
A["A 재발행 검색 POST /search/reissue<br/>retrieve(pnr) 후 remainItem 계산 후 OrderReshop.ofReissueSearch<br/>후 reshopOffers groupBy journey 후 최저가 선택 후 FareItinerary 와 remainServiceIds 캐시저장"]
A -->|"key detailKey 발급"| B["B 재발행 상세 GET /search/reissue pnr key<br/>FlightSearchService.reissueDetail(pnr key)<br/>1 캐시 FareItinerary 로드<br/>2 별표 마이너스 검증 passengerFares.any airPrice 음수 또는 tax 음수 후 REISSUE_NON_CHANGEABLE_FARE_SCHEDULE 금액 감소 가 재발행 불가<br/>3 retrieve(pnr)로 기존 스케줄과 대조 후 출 도착 동일 와 시각 편명 cabin 모두 동일이면 NON_CHANGEABLE_SCHEDULES<br/>4 별표 repricingWithReissue(booking fareItinerary) OrderReshop.ofPricing 후 OrderReshopRS.toFareItinerary penalty 추가tax carrierFee 계산해 FareItinerary 재구성"]
B --> C["C 재발행 발권 POST /ticketing/addition 4-3 참조 비동기 폴링<br/>reissue repricingWithReissue로 재검증 후 금액일치 후 OrderChange.ofReissue"]
repricingWithReissue의 금액 계산이 까다롭다
OrderReshopRS.toFareItinerary() (OrderReshopRS.kt:130-159)에서 PassengerFare를 만드는 식:
total = (offerItem.price.totalAmount - penalty) — totalAmount = 추가금 + 패널티이므로 패널티를 빼야 순수 운임차액(airPrice+tax)이 된다(OrderReshopRS.kt:143-144 주석).
tax = differentialAmountDue.taxSummary.totalTaxAmount — 추가된 tax.
그래서 발권 검증식이 (total + carrierFee) * count인 것이다(= 운임차액 + 패널티). singaporeair-pitfalls에서 이 계산의 함정을 더 다룬다.
"금액 감소 시 재발행 불가"의 의미
reissueDetail() (FlightSearchService.kt:146-175)은 차액이 음수(airPrice<0 || tax<0)면 SQ를 통한 재발행을 막고 “항공사 사이트에서 진행” 안내 메시지를 던진다. 환불성 재발행(downgrade)은 이 어댑터가 처리하지 않는다는 정책이다.
6. Ancillary — 부가서비스 가용성
부가서비스는 조회(가용성)만 서비스한다. 실제 구매(saveAncillary/saveSeat/repricingWithAncillary)는 전부 //현재 서비스하지 않음 주석 상태다.
flowchart TD
Ctrl["SingaporeairAncillaryController"]
Ctrl --> Avail["/avail/key 와 /avail/pnr 후 searchAvailAncillary 후 ServiceList 후 toAncillaryAvail"]
Ctrl --> Baggage["/baggage/key 와 /baggage/pnr 후 searchBaggage 후 ServiceList 후 toBaggageAvails(adult child)"]
Ctrl --> Seat["/seat/pnr 후 searchSeat 후 retrieve(pnr) 와 SeatAvailability 후 toSeatAvails(booking)"]
pnr 기반 가용성 조회는 병렬 + SQ 특이사항
AncillaryService.searchAvailAncillary(pnr) (AncillaryService.kt:36-46)는 withBlocking(IO){ AncillaryType.availOf(SINGAPOREAIR).pmap{...} }로 SEAT/EXTRA_BAGGAGE를 병렬 조회한다. SEAT는 isSeatAvail(pnr)(SeatAvailability), 수하물은 searchAvailAncillary(pnr)(ServiceList)로 분기.
key 기반 좌석 가용성은 지원하지 않는다 — AncillaryService.kt:30 주석: “SQ는 SEG 정보만으로 좌석 구매 가능 여부를 알 수 없음.” 그래서 좌석은 PNR 기반(/seat/pnr)만 존재한다.
수하물은 “좌석을 점유하는 성인/소아”만 대상(AncillaryService.kt:73), 유아 제외.
OPEN 시 searchFallback(CallNotPermittedException)이 Datadog span에 supplier.circuit-breaker=OPEN 태그를 찍고 빈 리스트를 반환한다(SearchController.kt:104-111). 즉 SQ 검색 장애는 “에러”가 아니라 “결과 0건”으로 graceful degradation 된다. 예약/발권/취소에는 서킷브레이커가 없으므로 이들은 예외를 그대로 전파한다.