Singapore Airlines — 오퍼레이션 흐름

module-singaporeair arch-layered pattern-repricing api-soap

이 노트의 위치

이 문서는 SQ(Singapore Airlines) 모듈의 오퍼레이션별 내부 처리 흐름을 다룬다. 모듈 전반의 성격/스키마/인증은 singaporeair-overview, SOAP/EDIST 프로토콜 디테일은 singaporeair-protocol, 함정/주의점은 singaporeair-pitfalls에서 다룬다. 어댑터 공통 오퍼레이션 규약은 common-operations, 요청이 컨트롤러까지 도달하는 큰 그림은 request-flow, 모듈 간 콜 그래프는 caller-callee-map 참고.


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)
검색AirShoppingRQNDC_AirShopping_18.1AirShoppingRS
운임 확정/규정OfferPriceRQNDC_OfferPrice_18.1OfferPriceRS
예약 생성OrderCreateRQNDC_OrderCreate_18.1OrderViewRS
예약 조회OrderRetrieveRQNDC_OrderRetrieve_18.1OrderViewRS
결제/발권·재발행·분리·좌석·부가OrderChangeRQNDC_OrderChange_18.1OrderViewRS
환불계산·재발행검색·재발행리프라이싱OrderReshopRQNDC_OrderReshop_18.1OrderReshopRS
예약취소OrderCancelRQNDC_OrderCancel_18.1OrderCancelRS
부가서비스 목록ServiceListRQNDC_ServiceList_18.1ServiceListRS
좌석맵SeatAvailabilityRQNDC_SeatAvailability_18.1SeatAvailabilityRS

핵심 통찰 1 — "한 RQ 클래스가 여러 비즈니스 동작을 담당"

OrderChangeRQ는 companion의 팩토리 메서드(ofPayment, ofReissue, ofDivide, ofSeat, ofAncillary)로 발권·재발행·분리·좌석·부가를 전부 처리한다. OrderReshopRQofRefundCalculate, 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로 병렬 호출하고 awaitAllAsyncResults로 모은다. 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으로 만들고, 이 구간들의 referenceServiceIdsOrderReshopRQDeleteOrderItem.retainServiceIds(RetainServiceID)로 넘긴다(OrderReshopRQ.kt:88-89). 응답에서 remainServiceIdsFareItinerary에 보존되어 이후 리프라이싱 단계로 전파된다.


2. Booking — 예약 / 조회 / 분리 / 취소

2-1. 예약 생성 (POST /internals/SINGAPOREAIR/bookings)

flowchart TD
    Ctrl["SingaporeairBookingController.create"]
    Ctrl --> Svc["SingaporeairBookingService.book(key reservationUser passengers)"]
    Svc --> Restore["flightSearchService.getFareItinerary(key) 캐시 복원 와 JSON 로깅"]
    Svc --> Pricing["SingaporeairClient.pricing(adult child infant fareItinerary) 별표 1차 OfferPrice"]
    Svc --> Book["SingaporeairClient.book(fareItinerary reservationUser passengers) 별표 OrderCreate"]
    Book --> ToBooking["OrderViewRS.response.toBooking(passengers)"]
    Svc --> Retrieve["SingaporeairClient.retrieve(pnr passengers) 별표 OrderRetrieve 재조회"]
    Retrieve --> RemoveKey["removeFlightSearchKey(requestKey) 비동기 fire-and-forget 코루틴"]

예약은 SOAP를 3번 친다 (Pricing → Create → Retrieve)

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}/confirmbookingService.retrieve(pnr)OrderRetrieveretrieve와 동일
GET /{pnr}/repricingbookingService.retrieve(pnr).passengersOrderRetrieveRepricingView.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.ofDividereason="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.

refundCalculate() (SingaporeairClient.kt:420-469)는 OrderReshopRQ.ofRefundCalculate(DeleteOrderItem)로 PAX별 환불 차액을 받아온다. PAX 매칭은 Refund-P 접두/PAX 접두 제거 후 비교(it.id.replace("Refund-P","") == identificationKey.replace("PAX","")). 차액 항목이 없거나 refundFee==0이면 CALCULATE_CANCEL_FEE_FAILED. 결과를 passenger.fare.copy(refundFee, expectedRefundAmount=abs(...), usedTax)로 채운다.

컨트롤러는 환불 응답에서 refundFee>0 || usedTax>0인 PAX만 노출한다(BookingController.kt:47-49, 63-65).

엔드포인트서비스의미
PUT /{pnr}/cancelcancelService.cancel()실제 취소 실행 (VOID 또는 REFUND)
GET /{pnr}/expected-cancelcancelService.expectedCancel()취소 시 예상 환불 (호출만, 취소 안 함)
GET /{pnr}/cancelablecancelService.cancelable()CancelActionType.VOID/REFUND 판정

cancel() 클라이언트는 타임아웃 시 slackService.sendCancelFailTimeout(...)을 보내고(SingaporeairClient.kt:364-369), 응답의 changeFees.penaltyAmount.value를 패널티로 반환한다. → 경보 메커니즘은 resilience-and-events.


3. FareRule — 운임 규정

flowchart TD
    Ctrl["SingaporeairFareRuleController.getFareRules"]
    Ctrl --> Svc["SingaporeairFareRuleService.findFareRules(key adult child infant)"]
    Svc --> Restore["fareItineraryRepository.getFareItinerary(hashKey 가 key) 캐시 복원"]
    Svc --> Mini["SingaporeairClient.getMiniRules(adult child infant fareItinerary sendSlackMessage)"]
    Mini --> RQ["OfferPriceRQ.of 별표 OfferPrice 재호출"]
    Mini --> Exec["execute OfferPriceRS"]
    Mini --> Flat["response.pricedOffer.offer.offerItems flatMap offerItem.fareDetails.first.fareComponents mapNotNull priceClass.toMiniRule(paxSegment)"]
    Svc --> ToRule["priceRule.toFareRule(index 더하기 1) flatMapIndexed"]

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).


4. Ticketing — 발권 / 발권준비 / 재발행(reissue)

발권은 SQ에서 결제(OrderChange.ofPayment)와 동치다.

4-1. 발권준비 (POST /internals/SINGAPOREAIR/ticketing/ready)

SingaporeairTicketingController.ready()TicketingService.ready(pnr)retrieve(pnr). 반환은 null to booking.schedules. 즉 passenger는 항상 null이고 schedules만 내려준다(TicketingService.kt:27-30).

4-2. 발권 (POST /internals/SINGAPOREAIR/ticketing)

flowchart TD
    Ctrl["SingaporeairTicketingController.issue"]
    Ctrl -->|"if request.prepayment.not throw INVALID_PAYMENT_METHOD 선불만 허용"| Svc["SingaporeairTicketingService.issue(pnr passengerPrices)"]
    Svc --> Amount["amount 가 sumOf cardPrice 더하기 cashPrice"]
    Amount --> Try["try SingaporeairClient.savePayment(pnr amount timeoutCallback) 별표 OrderChange.ofPayment"]
    Try -->|"성공"| Success["OrderViewRS.toBooking"]
    Try -->|"catch e"| Catch["catch e 처리"]
    Catch --> NoQuota["if NO_QUOTA slackService.sendNoQuota(SINGAPOREAIR SQ ...)"]
    Catch --> CancelAsync["cancelAsync(pnr) 비동기 보상 취소 코루틴 5s delay"]
    Catch --> Throw["throw e"]

발권 실패 시 비동기 보상 취소 (Saga 보상 트랜잭션)

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/additionGET /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 실제 재발행 커밋"]

재발행 = 리프라이싱 검증 후 커밋 (2-phase)

재발행은 차액 재계산(repricingWithReissue) → 금액 일치 검증 → 커밋(reissue) 순서다. prepaidPrice(클라이언트가 보낸 선불액)와 SQ가 다시 계산한 (total + carrierFee) * count 합이 다르면 커밋하지 않고 RETICKETING_FAILED_BY_MISMATCH_PRICE로 막는다(TicketingService.kt:78-86). 결제 금액이 살짝이라도 틀어지면 발권 자체를 거부하는 안전장치다.

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.
  • carrierFee = penalty — 재발행 패널티를 carrierFee로 보존.
  • count = 1 — passengerRefs 크기만큼 1건씩 PassengerFare를 생성(List(size){...}).

그래서 발권 검증식이 (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), 유아 제외.


7. 비동기 / 코루틴 / Resilience4j 정리표

위치메커니즘목적
FlightSearchService.searchwithBlocking(IO) + cartesianProduct.pmap.onFailure.getOrEmptyOD 조합 병렬 검색, 부분 실패 허용
AncillaryService.searchAvailAncillary(pnr)withBlocking(IO) + pmapSEAT/BAGGAGE 병렬 조회
BookingService.removeFlightSearchKeyCoroutineScope(IO).withLaunch (fire-and-forget)예약 후 검색키 삭제
TicketingService.cancelAsyncCoroutineScope(IO).withLaunch + delay(5000)발권 실패 시 보상 취소
TicketingController.reissue/checkReissuepolling() / poller() (Redis DeferredResult)재발행 비동기 + 폴링
SearchController.search@CircuitBreaker(name="singaporeSearch")검색 서킷브레이커, fallback=빈 리스트

Resilience4j는 검색에만, @Retry는 없음

SQ 모듈에서 Resilience4j 애너테이션은 SingaporeairSearchController.search@CircuitBreaker(name="singaporeSearch") 단 하나뿐이다. @Retry/@Bulkhead는 모듈 전체에 없다(grep 확인). 설정은 application.ymlresilience4j.circuitbreaker.instances.singaporeSearch: { baseConfig: search }이며 search 기본 설정은:

  • slidingWindowType: TIME_BASED, slidingWindowSize: 180(초)
  • minimumNumberOfCalls: 30, failureRateThreshold: 35%
  • waitDurationInOpenState: 120s, permittedNumberOfCallsInHalfOpenState: 10

OPEN 시 searchFallback(CallNotPermittedException)이 Datadog span에 supplier.circuit-breaker=OPEN 태그를 찍고 빈 리스트를 반환한다(SearchController.kt:104-111). 즉 SQ 검색 장애는 “에러”가 아니라 “결과 0건”으로 graceful degradation 된다. 예약/발권/취소에는 서킷브레이커가 없으므로 이들은 예외를 그대로 전파한다.


8. SOAP 호출 공통 메커니즘 (모든 오퍼레이션 공통)

모든 SingaporeairClient 메서드가 공유하는 호출 체인:

singaporeairApiProperties.endpoint
    .post(request)
    .client(searchClient)                                 // 검색만 15s, 나머지 60s(default)
    .header(getHeaderMap(request))                        // Content-Type:text/xml, SOAPAction
    .requestBodyConvert(soapRequestBodyConverter(props))  // SOAP Envelope + WS-Security 생성
    .log(...)
    .execute<RS>(soapBodyDeserializerOf(logger, mapper))  // soap(content).soapBody(mapper)
    .fold(success = { ...checkError... }, failure = { ...handleSoapFaultException... })

WS-Security 헤더가 매 요청 동적 생성

soapRequestBodyConverter (SingaporeairClient.kt:833-896)는 매 요청마다 AMA_SecurityHostedUser(PseudoCityCode, AgentDutyCode “SU”, CompanyName “TRIPLE”)와 WS-Security UsernameToken(Username + PasswordDigest + Nonce + Created)을 생성한다. 비밀번호는 PasswordDigest.getPasswordDigestFromClearTextPW(nonce, formattedCreated, password)로 매번 다이제스트화된다 — 즉 SQ는 세션리스이며, GDS(Amadeus 1A/Sabre 1S)처럼 stateful PNR 세션을 쓰지 않는다. 끝에 .replace(" xmlns=\"\"", "")로 빈 네임스페이스를 제거하는 것도 주의 포인트(EDIST 스키마 호환). 디테일은 singaporeair-protocol 참고.

응답은 objectMapperxmlMapper(@Qualifier("xmlMapper"))이고, soapBodyDeserializerOf가 SOAP Envelope에서 Body를 떼어 타입별로 역직렬화한다. 역직렬화 실패 시 원문 body를 구조화 로그로 남긴다.


관련 노트