Sabre — 프로토콜·전문
module-sabre arch-infrastructure pattern-soap pattern-rest api-protocol
한 문장 요약
Sabre 어댑터는 두 개의 전송 스택을 동시에 쓴다. (1) 레거시 SOAP/XML —
SabreClient가 PNR 라이프사이클 전반(세션·검색·예약·발권·큐·환불)을webservices.sabre.com의 eb XML SOAP로 처리하고, (2) 모던 REST/JSON —SabreRestClient가 신형 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 | 비고 |
|---|---|---|---|---|---|
SabreClient | infrastructure/soap/SabreClient.kt | SOAP 1.1 / XML | WSSE BinarySecurityToken (세션) | @Qualifier("xmlMapper") | 가장 큰 클라이언트, 24개 오퍼레이션 |
SabreRestClient | infrastructure/rest/SabreRestClient.kt | REST / JSON | OAuth2 Bearer (/v3/auth/token) | 기본 objectMapper (JSON) | BFM v5, CreatePNR, Trip Orders |
SabreFareRuleClient | infrastructure/rest/SabreFareRuleClient.kt | HTTP GET + querystring | PCC 기반(헤더 없음) | xmlMapper(응답) + jsonMapper(BfmRule) | 응답은 XML, 요청 파라미터에 압축 JSON 삽입 |
SabrePaymentClient | infrastructure/soap/SabrePaymentClient.kt | SOAP / 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" (하드코딩) | |
CPAId | sabreApiProperties.pcc.online | 온라인 PCC |
ConversationId | UUID.randomUUID() | 호출마다 새 UUID |
Service / Action | request.action | 둘 다 같은 값(오퍼레이션명) |
MessageData/MessageId | "mid:[email protected]" (하드코딩) | |
MessageData/Timestamp | LocalDateTime.now() ISO8601 |
request.action은 SabreRequest 인터페이스의 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은@JsonIgnore→ Body 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_AirLowFareSearchRQ | http://www.opentravel.org/OTA/2003/05 |
EnhancedAirBookRQ | http://services.sabre.com/sp/eab/v3_10 |
PassengerDetailsRQ | http://services.sabre.com/sp/pd/v3_4 |
GetReservationRQ | http://webservices.sabre.com/pnrbuilder/v1_19 |
AirTicketRQ (SOAP) | http://services.sabre.com/sp/air/ticket/v1_3 |
TokenCreateRS | http://webservices.sabre.com |
2.5 SOAP 에러 판별 — ApplicationResult / checkError
SOAP 응답 Body는 오퍼레이션마다 다르지만, 에러 판별은 두 가지 패턴으로 수렴한다.
- 공용
ApplicationResult(soap/response/common/ApplicationResult.kt) —status(attribute) +Error리스트.checkError(callback)가 에러가 있으면Error → SystemSpecificResults → Message를 합쳐 콜백. - 각 RS 자체
checkError— 예:GetReservationRS.checkError(soap/response/getreservation/GetReservationRS.kt:57)는errors(Errors/Error)를"${code}:${message}"로 만들어 콜백 없으면BOOKING_FAILEDthrow.
호출부는 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:clientSecret을 base64Url(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>() 패턴.
| 메서드 | 엔드포인트 | 요청 모델 | 응답 모델 |
|---|---|---|---|
getToken | POST /v3/auth/token | form-urlencoded | AuthTokenRS |
search | POST /v5/offers/shop | BargainFinderMaxRequest | BargainFinderMaxResponse |
revalidate | POST /v5/shop/flights/revalidate | BargainFinderMaxRequest | BargainFinderMaxResponse |
book | POST /v2.5.0/passenger/records?mode=create | CreatePassengerNameRecordRequest | CreatePassengerNameRecordResponse |
getBooking | POST /v1/trip/orders/getBooking | GetBookingRequest | GetBookingResponse |
ticketing | POST /v1.3.0/air/ticket | EnhancedAirTicketRequest | EnhancedAirTicketResponse |
checkCancellableTickets | POST /v1/trip/orders/checkFlightTickets | CheckTicketsRequest | CheckTicketsResponse |
voidTickets | POST /v1/trip/orders/voidFlightTickets | VoidTicketsRequest | VoidTicketsResponse |
refundTickets | POST /v1/trip/orders/refundFlightTickets | RefundTicketsRequest | RefundTicketsResponse |
같은 오퍼레이션이 SOAP·REST 둘 다 있다
검색은 SOAP
OtaAirLowFareSearchRQ(SabreClient.search)와 RESTBargainFinderMaxRequest(SabreRestClient.search) 두 경로가 모두 구현돼 있다. 발권도 SOAPAirTicketRQ와 RESTEnhancedAirTicketRequest가 공존(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). - 핵심 파라미터
BfmRule은BfmRule객체(soap/request/farerule/SabreFareRuleRequst.kt의BfmRule)를 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/".PaymentTokenRQ는auth: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/Header | envelope.xsd, msg-header-2_0.xsd, sws_common.xsd | 표준 SOAP + Sabre 공통 헤더 |
| WS-Security | wsse.xsd, xmldsig-core-schema.xsd | BinarySecurityToken 정의 |
| 검색(BFM) | BargainFinderMaxRQ_v6-5-0.xsd, BargainFinderMaxRS_v6-5-0.xsd, BargainFinderMax_CommonTypes_v6-5-0.xsd, GroupedItineraryResponse_v6.5.0.xsd | REST/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.xsd | PNR 빌더 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*.xsd | RQ/RS/RQRS 세트 |
| 취소/Void/Refund/Divide | OTA_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.xsd | Service-oriented STL |
XSD 버전 = 코드 버전 확인용
RQ 모델의
version/네임스페이스가 XSD 파일명과 일치하는지 확인하면 스펙 정합성을 검증할 수 있다. 예:GetReservationRQ.version = "1.19.0"↔GetReservationSTLRS_v1.19.0.xsd,PassengerDetailsRQ네임스페이스.../pd/v3_4↔PassengerDetails3.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-220의ssrCarrierTimeLimitByAirline()은 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.
SabreRequest의action/token필드에@JsonIgnore가 붙은 이유는?정답 보기
Body XML은 Jackson
xmlMapper로 RQ 객체를 통째 직렬화한다. 하지만action(SOAP 액션)과token(세션 토큰)은 **Body가 아니라 SOAP Header(eb:Action, wsse:BinarySecurityToken)**에 들어가야 한다.@JsonIgnore로 Body 직렬화에서 빼고,soapRequestBodyConverter가 헤더 조립 시request.action/request.token을 따로 읽어 헤더에 넣는다. (soap/request/SabreRequest.kt,SabreClient.kt:1058-1099)
Q2. 검색(
search)에서 "NO AVAILABILITY" 응답이 와도 예외를 던지지 않는 이유와 구현 위치는?정답 보기
“NO AVAILABILITY”/“NO FARE” 등은 시스템 오류가 아니라 정상적인 “검색 결과 없음”이다.
SabreClient.kt:191-205(SOAP)와SabreRestClient.kt:135-149(REST)에서 에러 메시지가 이 화이트리스트에 모두 포함되면 예외를 던지지 않고 빈/필터링된 결과를 반환한다. 화이트리스트에 없는 메시지가 하나라도 있으면InternationalAdapterException(SEARCH_FAILED).capture().
Q3. FareRule 요청을 와이어에서 캡처했는데
BfmRule파라미터가 16진수 문자열이라 못 읽는다. 어떻게 디버깅하나?정답 보기
BfmRule은 JSON → Deflate 압축 → Hex 인코딩(SabreFareRuleClient.toCompressString)되어 전송된다. 압축 전 원본 JSON은SabreFareRuleClient.kt:56에서logger.info("[bfmRule] ...")로 로그에 남긴다. 애플리케이션 로그에서[bfmRule]를 찾으면 사람이 읽을 수 있는 운임 규정 요청 본문을 볼 수 있다.
부록: 분석한 핵심 파일
| 파일 | 역할 |
|---|---|
infrastructure/soap/SabreClient.kt | SOAP 메인 클라이언트(24 op), Envelope 조립, 토큰/세션 |
infrastructure/rest/SabreRestClient.kt | REST 클라이언트(BFM/CreatePNR/Trip Orders), OAuth Bearer |
infrastructure/rest/SabreFareRuleClient.kt | FareRule HTTP GET, BfmRule 압축 |
infrastructure/soap/SabrePaymentClient.kt | 결제 SOAP(카드/토스/현금영수증), FEP |
infrastructure/soap/PaymentError.kt | 카드사 오류코드 → ErrorMessage 테이블 |
infrastructure/soap/request/SabreRequest.kt | SOAP RQ 공통 인터페이스(action/token) |
infrastructure/soap/response/SabreResponse.kt | SOAP 응답 래퍼(security + body) |
infrastructure/soap/response/common/ApplicationResult.kt | SOAP 공통 에러 구조 |
infrastructure/rest/response/ApplicationResult.kt | REST 공통 에러 구조(JSON) |
infrastructure/rest/response/token/AuthTokenRS.kt | OAuth 토큰 응답 |
support/util/SoapExtensions.kt | SOAP Envelope DSL(공용) |
support/enums/SabreSoapHeaderNamespace.kt | eb/wsse 네임스페이스 |
configuration/Properties.kt (SabreApiProperties) | 엔드포인트/PCC/자격증명 |
configuration/WebMvcConfiguration.kt | xmlMapper 빈 |
src/test/schema/sabre/*.xsd | 메시지 스펙(검증) |
src/test/resources/mockData/sabre/*.xml | 실제 전문 픽스처 |