Sabre — 프로토콜·전문

module-sabre arch-infrastructure pattern-soap pattern-rest api-protocol

한 문장 요약

Sabre 어댑터는 두 개의 전송 스택을 동시에 쓴다. (1) 레거시 SOAP/XMLSabreClient가 PNR 라이프사이클 전반(세션·검색·예약·발권·큐·환불)을 webservices.sabre.com의 eb XML SOAP로 처리하고, (2) 모던 REST/JSONSabreRestClient가 신형 API(BargainFinderMax v5, CreatePNR v2.5, EnhancedAirTicket v1.3, Trip Orders v1)를 OAuth Bearer 토큰으로 호출한다. 같은 오퍼레이션(검색/예약/발권/취소)이 SOAP 버전과 REST 버전으로 둘 다 존재하며, 어느 쪽을 쓸지는 상위 서비스가 결정한다.

관련 노트: sabre-overview · sabre-operations · sabre-pitfalls · interfaces-dtos · error-handling · 기존 분석: sabre-gds


1. 프로토콜 전체 지도 — 왜 두 스택인가

flowchart TD
    subgraph ADP["air-intl-adapter — Sabre 모듈 infrastructure"]
        SOAP["SOAP 스택 — SabreClient.kt<br/>전송 text/xml SOAP 1.1<br/>인증 BinarySecurityToken<br/>직렬화 xmlMapper Jackson XML<br/>세션 Stateful PNR 세션 보유<br/>endpoint properties.endpoint"]
        REST["REST 스택 — SabreRestClient.kt<br/>전송 application/json<br/>인증 OAuth2 Bearer<br/>직렬화 objectMapper JSON<br/>무상태 Stateless 호출<br/>endpoint restEndpoint"]
        FARE["FareRule HTTP — SabreFareRuleClient<br/>전송 GET 더하기 querystring text/xml<br/>BfmRule를 Deflate 더하기 Hex 압축 전달"]
        PAY["Payment SOAP — SabrePaymentClient<br/>전송 SOAP 자체 결제 WS<br/>getToken 다음 approve 또는 cancel"]
    end
    GDS["webservices.sabre.com 1S GDS<br/>services.sabre.com REST"]
    PG["결제 또는 PG 게이트웨이<br/>asianasabre 또는 abacus FEP"]
    SOAP --> GDS
    REST --> GDS
    FARE --> GDS
    PAY --> PG
클라이언트파일프로토콜인증ObjectMapper비고
SabreClientinfrastructure/soap/SabreClient.ktSOAP 1.1 / XMLWSSE BinarySecurityToken (세션)@Qualifier("xmlMapper")가장 큰 클라이언트, 24개 오퍼레이션
SabreRestClientinfrastructure/rest/SabreRestClient.ktREST / JSONOAuth2 Bearer (/v3/auth/token)기본 objectMapper (JSON)BFM v5, CreatePNR, Trip Orders
SabreFareRuleClientinfrastructure/rest/SabreFareRuleClient.ktHTTP GET + querystringPCC 기반(헤더 없음)xmlMapper(응답) + jsonMapper(BfmRule)응답은 XML, 요청 파라미터에 압축 JSON 삽입
SabrePaymentClientinfrastructure/soap/SabrePaymentClient.ktSOAP / XML자체 getToken (WS_CODE)xmlMapper카드/토스페이/현금영수증, FEP 결제망

패키지 구조가 곧 프로토콜이다

infrastructure/soap/... 아래는 전부 XML SOAP 전문, infrastructure/rest/... 아래는 전부 JSON REST 전문이다. 요청 모델은 request/{operation}/, 응답 모델은 response/{operation}/로 오퍼레이션별 하위 패키지에 모여 있다. 예: soap/request/getreservation/GetReservationRQ.kt, rest/request/bargainfindermax/BargainFinderMaxRequest.kt.


2. SOAP 스택 상세 (SabreClient)

2.1 SOAP Envelope 조립 — 직접 손으로 짠다

핵심: Sabre는 표준 JAX-WS 스텁을 쓰지 않는다. Body는 Jackson XML로 직렬화하고, Header(ebXML + WSSE)는 jakarta.xml.soap DSL로 직접 조립한다. 이 조립 로직이 SabreClient.soapRequestBodyConverter (SabreClient.kt:1034-1107)다.

// SabreClient.kt:1034
private val soapRequestBodyConverter: (SabreApiProperties) -> ((Any?) -> String) =
    { sabreApiProperties ->
        { request ->
            soap {
                request as SabreRequest
                header {
                    headerElement("MessageHeader", SabreSoapHeaderNamespace.EB) { ... }
                    headerElement("Security", SabreSoapHeaderNamespace.WSSE) { ... }
                }
                body(objectMapper.writeValueAsBytes(request).inputStream())  // ← Body는 Jackson XML
            }.replace(" xmlns=\"\"", "")  // ← 빈 namespace 제거 (지뢰)
        }
    }

DSL(soap{}, header{}, headerElement{}, childElement{}, text{}, body())의 정의는 공용 유틸 support/util/SoapExtensions.kt에 있다. soap(soapMessage) (역방향, 응답 파싱)와 soap { ... } (정방향, 요청 생성)이 같은 파일에 공존한다.

.replace(" xmlns=\"\"", "") — 빈 네임스페이스 제거 핵

jakarta.xml.soap로 헤더를 만들면 자식 엘리먼트에 xmlns=""가 자동 삽입되는데, Sabre가 이를 거부한다. 그래서 최종 문자열에서 정규식이 아닌 단순 문자열 치환으로 강제 제거한다(SabreClient.kt:1105). 페이로드 안에 우연히 xmlns="" 문자열이 들어가면 깨질 수 있는 취약한 핵이다. 자세한 함정은 sabre-pitfalls 참조.

2.2 SOAP Header — ebXML MessageHeader

헤더 네임스페이스는 SabreSoapHeaderNamespace enum(support/enums/SabreSoapHeaderNamespace.kt)으로 고정된다.

enum class SabreSoapHeaderNamespace(...) : SoapHeaderNamespace {
    EB("http://www.ebxml.org/namespaces/messageHeader", "eb"),
    WSSE("http://schemas.xmlsoap.org/ws/2002/12/secext", "wsse"),
}

MessageHeader(eb:)의 주요 필드와 채우는 값:

eb 엘리먼트값 출처 (SabreClient.kt)비고
From/PartyId"[email protected]" (하드코딩)코드에 //? 주석 (의미 불명, 함정)
To/PartyId"webservice.sabre.com" (하드코딩)
CPAIdsabreApiProperties.pcc.online온라인 PCC
ConversationIdUUID.randomUUID()호출마다 새 UUID
Service / Actionrequest.action둘 다 같은 값(오퍼레이션명)
MessageData/MessageId"mid:[email protected]" (하드코딩)
MessageData/TimestampLocalDateTime.now() ISO8601

request.actionSabreRequest 인터페이스의 action 프로퍼티(soap/request/SabreRequest.kt)에서 온다. 각 RQ 모델이 override var action으로 자기 SOAP 액션명을 들고 있다.

실제 액션명 목록 (코드에서 추출)

TokenCreateRQ, SessionCreateRQ, SessionCloseRQ, BargainFinderMaxRQ(=OtaAirLowFareSearchRQ의 action), OTA_AirPriceLLSRQ, EnhancedAirBookRQ, PassengerDetailsRQ, SpecialServiceLLSRQ, SabreCommandLLSRQ, AirTicketRQ, EndTransactionLLSRQ, IgnoreTransactionLLSRQ, GetReservationRQ, OTA_CancelLLSRQ, VoidTicketLLSRQ, DeletePriceQuoteLLSRQ, AddRemarkLLSRQ, TravelItineraryDivideLLSRQ, QueueCountLLSRQ, QueueAccessLLSRQ, QueueMoveLLSRQ, StructureFareRulesRQ. LLS는 Sabre Low-Level Services를 뜻한다.

2.3 SOAP 인증·세션 — WSSE Security 헤더

Security(wsse:) 헤더는 토큰 유무에 따라 두 가지 형태로 갈린다(SabreClient.kt:1073-1102).

flowchart TD
    REQ["wsse Security 헤더 조립"]
    REQ -->|"요청에 token 이 null"| UT["wsse UsernameToken — 자격증명으로 토큰 발급용<br/>Username 은 epr<br/>Password 는 password<br/>Organization 은 pcc.online<br/>Domain 은 DEFAULT<br/>ClientId 와 ClientSecret"]
    REQ -->|"요청에 token 이 존재"| BST["wsse BinarySecurityToken<br/>valueType String<br/>EncodingType wsse Base64Binary<br/>본문은 token 값"]

세션 토큰 라이프사이클:

flowchart TD
    GT["getToken<br/>TokenCreateRQ"] --> GTC["Cacheable SABRE_TOKEN_CACHE key SEARCH Redis<br/>검색 전용 토큰 캐시"]
    GST["getSessionToken<br/>SessionCreateRQ"] --> GSTC["발급 — PNR 작업용 Stateful<br/>Retryable IOException maxAttempts 2<br/>callTimeout SESSION_TIMEOUT 5000ms"]
    GSTC --> USE["검색 또는 예약 또는 발권 또는 큐 등<br/>withToken token 으로 같은 세션에 묶임"]
    USE --> CST["closeSessionToken<br/>SessionCloseRQ — 세션 종료"]
  • 검색용 토큰(getToken)은 Redis 캐시(SABRE_TOKEN_CACHE, redisCacheManager, SabreClient.kt:122). 응답의 security.token을 캐싱.
  • 세션용 토큰(getSessionToken)은 PNR을 만드는 stateful 트랜잭션에 쓰이며 매번 발급. 채널/퍼널/날짜(MDCHolder)로 PCC가 분기된다 — 이 stateful 세션 모델이 Sabre 모듈을 가장 무겁게 만드는 핵심이다(sabre-overview 참조).
  • 응답 토큰 추출: SabreResponse.security(soap/response/SabreResponse.kt)의 Security.token(BinarySecurityToken 매핑).

SOAP 응답은 항상 SabreResponse<T>로 감싼다

모든 SOAP 호출은 .execute<SabreResponse<T>>(soapBodyDeserializerOf(logger, objectMapper))로 실행된다. soapBodyDeserializerOf(SabreClient.kt:96)가 응답 XML을 받아 wsse:Security 헤더(soapHeader)와 Body(soapBody)를 각각 파싱해 SabreResponse(security, body)로 묶는다. 즉 토큰(헤더)과 데이터(Body)를 한 번에 꺼낸다.

2.4 SOAP 요청 모델 — SabreRequest 인터페이스

// soap/request/SabreRequest.kt
interface SabreRequest {
    @get:JsonIgnore var action: String       // SOAP 액션 (eb:Service/Action)
    @get:JsonIgnore var token: String?        // 세션 토큰 (직렬화 제외)
    fun withAction(action: String): SabreRequest = this.apply { this.action = action }
    fun withToken(token: String?): SabreRequest = this.apply { this.token = token }
}
  • action/token@JsonIgnoreBody XML에 직렬화되지 않는다(헤더에만 반영). 핵심 트릭이다.
  • 모든 SOAP RQ는 data class XxxRQ(...) : SabreRequest이고 @JacksonXmlRootElement(localName, namespace)로 루트 엘리먼트·네임스페이스를 선언한다.
  • 호출부는 일관되게 RQ.of(...).withToken(token).post(request).requestBodyConvert(soapRequestBodyConverter(...)) 패턴.

SOAP RQ 루트 네임스페이스(코드에서 추출 — Body 루트에 박히는 네임스페이스):

오퍼레이션 군네임스페이스
대부분 LLS (Cancel, Price, Queue, Void, AddRemark, Command, Divide, SpecialService …)http://webservices.sabre.com/sabreXML/2011/10
검색 OTA_AirLowFareSearchRQhttp://www.opentravel.org/OTA/2003/05
EnhancedAirBookRQhttp://services.sabre.com/sp/eab/v3_10
PassengerDetailsRQhttp://services.sabre.com/sp/pd/v3_4
GetReservationRQhttp://webservices.sabre.com/pnrbuilder/v1_19
AirTicketRQ (SOAP)http://services.sabre.com/sp/air/ticket/v1_3
TokenCreateRShttp://webservices.sabre.com

2.5 SOAP 에러 판별 — ApplicationResult / checkError

SOAP 응답 Body는 오퍼레이션마다 다르지만, 에러 판별은 두 가지 패턴으로 수렴한다.

  1. 공용 ApplicationResult (soap/response/common/ApplicationResult.kt) — status(attribute) + Error 리스트. checkError(callback)가 에러가 있으면 Error → SystemSpecificResults → Message를 합쳐 콜백.
  2. 각 RS 자체 checkError — 예: GetReservationRS.checkError(soap/response/getreservation/GetReservationRS.kt:57)는 errors(Errors/Error)를 "${code}:${message}"로 만들어 콜백 없으면 BOOKING_FAILED throw.

호출부는 checkError { messages -> ... }오퍼레이션별로 메시지를 검사한다. 검색은 “NO AVAILABILITY”, “NO FARE” 등 정상적 “결과 없음” 메시지면 예외를 던지지 않고 빈 결과로 처리(SabreClient.kt:191-205). 발권은 “INCORRECT CARD NUMBER”, “VERIFY COMMISSION-2133” 등을 인식해 사용자 메시지를 가공(SabreClient.kt:565-595). 자세한 매핑은 error-handling / sabre-pitfalls.

어댑터의 "이벤트 전파"는 메시지큐가 아니다

이 시스템엔 큐 기반 비동기 이벤트가 없다. SOAP/REST 응답의 에러 메시지를 checkError로 판별 → InternationalAdapterException/StatusInvalidException으로 변환 → Resilience4j 상태전이 + Slack 경보로 전파된다. .capture(), .noRetry() 호출이 이 흐름의 마커다. resilience-and-events 참조.


3. REST 스택 상세 (SabreRestClient)

3.1 인증 — OAuth2 Client Credentials (Bearer)

// SabreRestClient.kt:67  getToken()
// 1) Redis에서 PCC별 토큰 조회 (CacheSet.SABRE_ACCESS_TOKEN::{pcc})
// 2) 없으면 POST /v3/auth/token (form-urlencoded)
//    Authorization: Basic base64Url(clientId:clientSecret)
//    body: grant_type=password&username={epr}-{pcc}-AA&password={password}
// 3) AuthTokenRS.accessToken 을 Redis에 TTL과 함께 저장
  • clientId:clientSecretbase64Url(encodeUtf8().base64Url())로 인코딩해 Basic 헤더.
  • username 포맷이 ${epr}-${pcc}-AA로 조합되는 점에 주의(SabreRestClient.kt:84).
  • 토큰 캐시 키: SabreRestClient.kt:486-498 (StringRedisTemplate, CacheSet.SABRE_ACCESS_TOKEN).
  • 응답 모델 AuthTokenRS(rest/response/token/AuthTokenRS.kt)는 @JsonNaming(SnakeCaseStrategy)access_token/token_type/expires_in 매핑.
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class)
data class AuthTokenRS(val accessToken: String, val tokenType: String, val expiresIn: Int)

3.2 REST 엔드포인트·전문 매핑

모든 REST 호출은 "${restEndpoint}{path}".post(model).bearer(token).execute<RES>() 패턴.

메서드엔드포인트요청 모델응답 모델
getTokenPOST /v3/auth/tokenform-urlencodedAuthTokenRS
searchPOST /v5/offers/shopBargainFinderMaxRequestBargainFinderMaxResponse
revalidatePOST /v5/shop/flights/revalidateBargainFinderMaxRequestBargainFinderMaxResponse
bookPOST /v2.5.0/passenger/records?mode=createCreatePassengerNameRecordRequestCreatePassengerNameRecordResponse
getBookingPOST /v1/trip/orders/getBookingGetBookingRequestGetBookingResponse
ticketingPOST /v1.3.0/air/ticketEnhancedAirTicketRequestEnhancedAirTicketResponse
checkCancellableTicketsPOST /v1/trip/orders/checkFlightTicketsCheckTicketsRequestCheckTicketsResponse
voidTicketsPOST /v1/trip/orders/voidFlightTicketsVoidTicketsRequestVoidTicketsResponse
refundTicketsPOST /v1/trip/orders/refundFlightTicketsRefundTicketsRequestRefundTicketsResponse

같은 오퍼레이션이 SOAP·REST 둘 다 있다

검색은 SOAP OtaAirLowFareSearchRQ(SabreClient.search)와 REST BargainFinderMaxRequest(SabreRestClient.search) 두 경로가 모두 구현돼 있다. 발권도 SOAP AirTicketRQ와 REST EnhancedAirTicketRequest가 공존(REST 쪽은 EnhancedAirTicketResponse.airTicketRS로 SOAP 응답 모델을 재사용). 어느 쪽을 쓰는지는 상위 application 서비스가 결정한다(sabre-operations).

3.3 REST 요청 모델 구조 — JSON 키 = OTA 엘리먼트명

REST이지만 페이로드는 OTA 스키마의 JSON 표현이다. 즉 JSON 키가 SOAP의 XML 엘리먼트명과 같은 PascalCase.

// rest/request/bargainfindermax/BargainFinderMaxRequest.kt
data class BargainFinderMaxRequest(
    @JsonProperty("OTA_AirLowFareSearchRQ")
    val otaAirLowFareSearchRQ: OtaAirLowFareSearchRQ,
)
data class OtaAirLowFareSearchRQ(
    @JsonProperty("Version") val version: String = "5",
    @JsonProperty("POS") val pos: Pos,
    @JsonProperty("OriginDestinationInformation") val originDestinationInformations: List<...>,
    @JsonProperty("TravelPreferences") val travelPreference: TravelPreference,
    @JsonProperty("TravelerInfoSummary") val travelerInfoSummary: TravelerInfoSummary,
    @JsonProperty("TPA_Extensions") val tpaExtension: ...,
)
  • SOAP 버전(soap/request/otaairlowfaresearch/OtaAirLowFareSearchRQ.kt)은 같은 필드를 @JacksonXmlProperty + @JacksonXmlElementWrapper(useWrapping=false)로 선언. 데이터 모델이 거의 평행하게 두 벌 존재한다 — 둘 다 유지보수해야 하는 부담.
  • CreatePassengerNameRecordRequest 트리는 무려 102개 요청 모델 파일로 구성될 만큼 방대(rest/request/createpassengernamerecord/ 디렉토리). 항공 PNR의 모든 디테일(승객/좌석/SSR/카드/리마크 등)을 표현.

3.4 REST 에러 판별 — ApplicationResult(JSON)

REST 응답도 SOAP과 동형의 ApplicationResult를 가진다. 단 JSON 버전(rest/response/ApplicationResult.kt)은 @JsonProperty로 매핑하고 status: ApplicationResultStatus(enum), Success/Error/Warning 각각의 ProblemInformation 리스트 + SystemSpecificResults → Message{content, code} 트리를 갖는다. 각 RES의 checkError가 이 구조를 순회한다.


4. FareRule HTTP — 특이한 압축 querystring (SabreFareRuleClient)

운임 규정(FareRule)은 SOAP도 순수 REST도 아닌 세 번째 전송 방식이다.

// SabreFareRuleClient.findFareRules()  SabreFareRuleClient.kt:64
sabreApiProperties.fareRule.endpoint
    .get(listOf(
        "fep" to "Y", "adt" to adult, "chd" to child, "inf" to infant,
        "origin" to ..., "L1Code" to "2000", "GscCode" to ...,
        "LangCode" to "KOR", "bfmpoolpd" to pcc.online,
        "BfmRule" to bfmRule.toCompressString(),  // ← 압축된 요금 정보
        *schedules.toTypedArray(),                 // dep0/arr0/depdate0 ...
    ))
    .header(CONTENT_TYPE = text/xml)
    .execute<SabreFareRuleResponse>()
  • GET + querystring. 응답은 XML(SabreFareRuleResponse, xmlMapper).
  • 핵심 파라미터 BfmRuleBfmRule 객체(soap/request/farerule/SabreFareRuleRequst.ktBfmRule)를 JSON 직렬화 → Deflate 압축 → 16진수 문자열로 변환해 보낸다.
// SabreFareRuleClient.kt:112
private fun BfmRule.toCompressString() = byteToString(compress(jsonMapper.writeValueAsString(this)))
// compress: DeflaterOutputStream, byteToString: %02X Hex 인코딩
  • BfmRule은 운임을 CC/PF/TL/ODs 같은 약어 JSON 키로 표현(@JsonProperty("VC"), "FB", "BG" 등). 세그먼트는 ^, 필드는 ,로 join한 문자열을 다시 압축한다 — 매우 압축적이고 깨지기 쉬운 포맷이라 디버깅이 어렵다.
  • 구조화 FareRule은 별도로 SOAP StructureFareRulesRQ(SabreClient.getStructureFareRules)로도 받을 수 있다. 두 경로가 공존한다.

FareRule 디버깅 함정

BfmRule은 압축·인코딩된 hex 문자열로 전송되어 캡처해도 사람이 못 읽는다. SabreFareRuleClient.kt:56에서 압축 전 JSON을 logger.info("[bfmRule] ...")로 찍어 두는 이유다. 로그에서 [bfmRule]를 찾아라. sabre-pitfalls 참조.


5. 결제 SOAP — SabrePaymentClient (GDS 외부 결제망)

GDS와 별개로, 카드/토스페이/현금영수증은 Sabre가 아닌 FEP(아시아나세이버/abacus) 결제 웹서비스에 SOAP로 호출한다.

flowchart TD
    GT["getToken paymentMethodDetail"] --> GTE["POST payment.endpoint 슬래시 WsAuthService wsdl<br/>요청 PaymentTokenRQ<br/>응답 PaymentTokenRS.wsAuthResponse.accessToken"]
    AP["approve"] --> APE["POST payment.endpoint 슬래시 PaymentService wsdl<br/>CardApprovalRQ ofApprove"]
    ATP["approveTossPay"] --> ATPE["POST payment.endpoint 슬래시 TossPayService<br/>TossPayApprovalRQ ofApprove"]
    CN["cancel 또는 cancelTossPay 또는 cashReceipt 또는 cancelCashReceipt"] --> CNE["동일 endpoint 군"]
    PG["pgCardCode"] --> PGE["POST pg.endpoint 슬래시 PgCardTktCode.lts CardNo Car<br/>String 응답"]
  • 결제 SOAP Body 루트는 결제망 고유 네임스페이스를 직접 attribute로 박는다. 예: CardApprovalRQ(soap/request/payment/CardApprovalRQ.kt)는 @JacksonXmlRootElement(localName="pay:CardApproval") + @JacksonXmlProperty(isAttribute=true, localName="xmlns:pay")="http://payment.ws.fep.abacus.com/". PaymentTokenRQauth:getToken + xmlns:auth="http://auth.ws.fep.asianasabre.co.kr/".
  • 결제 SOAP는 헤더 없이 Body만(soapRequestBodyConverter = header {} 빈 헤더 + body, SabrePaymentClient.kt:289). GDS SOAP과 헤더 구조가 완전히 다르다.
  • pgCardCode는 응답이 String이다 — 정상/오류 모두 200 + 문자열로 오므로 PaymentUtils.matchesCardCodeFormat로 형식 검증(SabrePaymentClient.kt:209-230).
  • 결제 오류 코드 매핑은 PaymentError(soap/PaymentError.kt)에 카드사 코드 → ErrorMessage 해시셋으로 테이블화(예: "0061"/"8326"... → PAYMENT_MAXIMUM_EXCEEDED).

발권 타임아웃 콜백 — 정합성 위험 지점

SabreClient.ticketing(...)timeoutCallback: () -> Unit 파라미터를 받고, failure에서 it.isTimeout이면 콜백 실행 후 예외 throw(SabreClient.kt:602-605). 발권은 타임아웃돼도 항공사 측엔 발권 완료일 수 있는 stateful 작업이라, 결제 취소/정합성 보정이 콜백에 묶인다. sabre-pitfalls 참조.


6. XSD 스키마 위치와 핵심 타입

테스트 검증용 XSD는 src/test/schema/sabre/에 70여 개 있다. 요청 모델이 직접 XSD를 참조해 생성된 것은 아니지만, Sabre가 게시한 메시지 스펙의 권위 있는 출처로서 응답 구조를 이해할 때 필수다.

영역XSD (대표)비고
SOAP Envelope/Headerenvelope.xsd, msg-header-2_0.xsd, sws_common.xsd표준 SOAP + Sabre 공통 헤더
WS-Securitywsse.xsd, xmldsig-core-schema.xsdBinarySecurityToken 정의
검색(BFM)BargainFinderMaxRQ_v6-5-0.xsd, BargainFinderMaxRS_v6-5-0.xsd, BargainFinderMax_CommonTypes_v6-5-0.xsd, GroupedItineraryResponse_v6.5.0.xsdREST/SOAP 검색 공용 타입
예약(EAB/PD/PNR)EnhancedAirBook3.10.0RQ/RS.xsd, PassengerDetails3.4.0RQ/RS.xsd, GetReservationSTLRS_v1.19.x.xsd, PNRBuilderTypes_v1.19.0.xsd, OpenReservation.1.14.0.xsdPNR 빌더 v1.19
발권/가격AirTicket1.3.0RQ/RS.xsd, OTA_AirPriceLLS2.17.0RQ/RS.xsd, PriceQuoteServices_v.4.3.0.xsd, TicketingTypeLibrary_v.1.6.0.xsd
QueueAccessLLS2.1.0*.xsd, QueueCountLLS2.2.1*.xsd, QueueMoveLLS2.0.0*.xsdRQ/RS/RQRS 세트
취소/Void/Refund/DivideOTA_CancelLLS2.0.3*.xsd, VoidTicketLLS2.1.0*.xsd, DeletePriceQuoteLLS2.1.0*.xsd
SSR/리마크/명령SpecialServiceLLS2.3.1*.xsd, SabreCommandLLS2.0.0*.xsd
STL 프로토콜STL_For_SabreProtocol_v.1.2.0.xsd, STL_Header_v.1.2.0.xsdService-oriented STL

XSD 버전 = 코드 버전 확인용

RQ 모델의 version/네임스페이스가 XSD 파일명과 일치하는지 확인하면 스펙 정합성을 검증할 수 있다. 예: GetReservationRQ.version = "1.19.0"GetReservationSTLRS_v1.19.0.xsd, PassengerDetailsRQ 네임스페이스 .../pd/v3_4PassengerDetails3.4.0RS.xsd.


7. 실제 전문 예시 (mockData)

7.1 SOAP 응답 Envelope (GetReservation)

src/test/resources/mockData/sabre/GetReservationRS.xml (실제 캡처, PII 마스킹됨):

<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Header>
    <eb:MessageHeader xmlns:eb="http://www.ebxml.org/namespaces/messageHeader" eb:version="1.0" soap-env:mustUnderstand="1">
      <eb:From><eb:PartyId eb:type="URI">webservice.sabre.com</eb:PartyId></eb:From>
      <eb:To><eb:PartyId eb:type="URI">[email protected]</eb:PartyId></eb:To>
      <eb:CPAId>7C9K</eb:CPAId>
      <eb:Service>GetReservationRQ</eb:Service>
      <eb:Action>GetReservationRQ</eb:Action>
      ...
    </eb:MessageHeader>
    <wsse:Security xmlns:wsse="http://schemas.xmlsoap.org/ws/2002/12/secext">
      <wsse:BinarySecurityToken valueType="String" EncodingType="wsse:Base64Binary">
        Shared/IDL:IceSess\/SessMgr:1\.0...!1692177119661!833!549
      </wsse:BinarySecurityToken>
    </wsse:Security>
  </soap-env:Header>
  ...
</soap-env:Envelope>
  • 응답 헤더의 From/To가 요청과 반대로 채워진다(서버→클라). BinarySecurityToken이 세션 토큰의 실제 모양(Shared/IDL:IceSess...)임을 보여준다.
  • soapBodyDeserializerOf가 이 Envelope에서 wsse:Security(토큰)와 Body(GetReservationRS)를 각각 파싱한다.

7.2 추가 픽스처

경로용도
mockData/sabre/GetReservationRS.xml정상 PNR 조회
mockData/sabre/GetReservationRS_EOF_ERROR.xml, ..._FLOWN_ERROR.xml에러/탑승완료 케이스 회귀 테스트
mockData/sabre/commission/AirTicketRS_ERROR.xml, commission/GetReservationRS.xml발권/커미션 시나리오
mockData/sabre/tl/{AIRLINE}_{PNR}_retrieve.xml (~80개)항공사별 PNR retrieve 회귀(tl = ticket time-limit/티켓라인 파싱) — GetReservationRS.ssrCarrierTimeLimitByAirline()의 정규식 파싱을 항공사별로 검증

tl/ 디렉토리가 거대한 이유

GetReservationRS.kt:147-220ssrCarrierTimeLimitByAirline()은 SSR 자유텍스트에서 항공사별로 제각각인 발권시한(time limit) 표기를 정규식으로 파싱한다. 항공사마다 표기/시간대(getZoneId)가 달라 80여 개 실제 PNR로 회귀 테스트를 건다. 이 파서가 깨지면 발권시한 오인 → 자동 취소 사고로 이어진다. sabre-pitfalls 참조.


8. 직렬화 설정 (xmlMapper)

SOAP Body XML 직렬화/역직렬화는 전용 xmlMapper 빈을 쓴다(configuration/WebMvcConfiguration.kt:74).

@Bean
fun xmlMapper(): ObjectMapper =
    Jackson2ObjectMapperBuilder.xml()
        .serializationInclusion(JsonInclude.Include.NON_NULL)   // null 필드 미출력
        .failOnUnknownProperties(false)                          // 미지의 응답 필드 무시
        .featuresToDisable(WRITE_DATES_AS_TIMESTAMPS)
        .modules(JavaTimeModule, Jdk8Module, kotlinModule, ParameterNamesModule, NoCtorDeserModule)
        .build()
  • NON_NULL: 옵셔널 SOAP 엘리먼트는 null이면 출력되지 않는다 → RQ 모델의 nullable 기본값이 그대로 “엘리먼트 생략”을 의미.
  • failOnUnknownProperties=false: Sabre가 응답에 새 필드를 추가해도 깨지지 않는다(견고성).
  • REST(JSON)는 기본 objectMapper(@Qualifier 없이 주입), FareRule 응답은 xmlMapper, BfmRule 직렬화는 jsonMapper를 명시적으로 골라 쓴다.

9. 호출 패턴 요약 (cheat sheet)

[SOAP]  endpoint
          .post(RQ.of(...).withToken(token))
          .header(headerMap = text/xml, gzip, Keep-Alive)
          .requestBodyConvert(soapRequestBodyConverter(sabreApiProperties))  // ← Envelope 조립
          .execute<SabreResponse<XxxRS>>(soapBodyDeserializerOf(logger, objectMapper))
          .fold(success = { it.body!!.checkError(); ... }, failure = { throw it.handleSoapFaultException(...) })

[REST]  "${restEndpoint}/path"
          .post(XxxRequest.of(...))
          .bearer(token)                       // ← Authorization: Bearer
          .execute<XxxResponse>()
          .fold(success = { it.checkError(); ... }, failure = { throw InternationalAdapterException(...) })

공통 HTTP DSL(post/get/header/bearer/requestBodyConvert/execute/fold)은 support/web/ClientSupport.kt. 검색은 별도 searchClient(타임아웃 짧음: SOAP 25s, REST 30s)를 .client(searchClient)로 갈아끼운다.


10. 연습 문제

Q1. SabreRequestaction/token 필드에 @JsonIgnore가 붙은 이유는?

Q2. 검색( search)에서 "NO AVAILABILITY" 응답이 와도 예외를 던지지 않는 이유와 구현 위치는?

Q3. FareRule 요청을 와이어에서 캡처했는데 BfmRule 파라미터가 16진수 문자열이라 못 읽는다. 어떻게 디버깅하나?


부록: 분석한 핵심 파일

파일역할
infrastructure/soap/SabreClient.ktSOAP 메인 클라이언트(24 op), Envelope 조립, 토큰/세션
infrastructure/rest/SabreRestClient.ktREST 클라이언트(BFM/CreatePNR/Trip Orders), OAuth Bearer
infrastructure/rest/SabreFareRuleClient.ktFareRule HTTP GET, BfmRule 압축
infrastructure/soap/SabrePaymentClient.kt결제 SOAP(카드/토스/현금영수증), FEP
infrastructure/soap/PaymentError.kt카드사 오류코드 → ErrorMessage 테이블
infrastructure/soap/request/SabreRequest.ktSOAP RQ 공통 인터페이스(action/token)
infrastructure/soap/response/SabreResponse.ktSOAP 응답 래퍼(security + body)
infrastructure/soap/response/common/ApplicationResult.ktSOAP 공통 에러 구조
infrastructure/rest/response/ApplicationResult.ktREST 공통 에러 구조(JSON)
infrastructure/rest/response/token/AuthTokenRS.ktOAuth 토큰 응답
support/util/SoapExtensions.ktSOAP Envelope DSL(공용)
support/enums/SabreSoapHeaderNamespace.kteb/wsse 네임스페이스
configuration/Properties.kt (SabreApiProperties)엔드포인트/PCC/자격증명
configuration/WebMvcConfiguration.ktxmlMapper
src/test/schema/sabre/*.xsd메시지 스펙(검증)
src/test/resources/mockData/sabre/*.xml실제 전문 픽스처