Singapore Airlines — 프로토콜·전문
module-singaporeair arch-protocol pattern-soap api-ndc config-soap-header
한 줄 요약
Singapore Airlines(SQ)는 IATA NDC / EDIST 18.1 표준 메시지를 사용하지만, 직접 SQ에 붙지 않는다. 모든 전문은 Amadeus ART(국제선 항공 GDS 게이트웨이) 의 SOAP 1.1 엔드포인트(
*.webservices.amadeus.com/...SQ)로 NDC XML을 SOAP Body에 담아 전송된다. 즉 “메시지 페이로드는 IATA NDC, 전송 봉투(envelope)·인증은 Amadeus 방식”이라는 이중 구조다. 이 점을 이해하지 못하면 네임스페이스·인증 헤더가 왜 amadeus.com인지 영원히 혼란스럽다.
관련 노트: 오퍼레이션 · 함정·주의점 · DTO 공통 · 기존 심층 분석: singaporeair-ndc
1. 프로토콜 전체 그림
flowchart TD T["Triple 예약"] subgraph ADAPTER["air-intl-adapter (SingaporeairClient)"] RQ["RQ DTO Kotlin<br/>AirShoppingRQ 등 (IATA NDC 18.1)"] NDCXML["NDC XML (Body)"] ENV["SOAP 1.1 Envelope<br/>+ WS-Security 헤더<br/>+ WS-Addressing"] RS["RS DTO Kotlin<br/>AirShoppingRS 등"] NDCXMLRS["NDC XML (Body)"] ENVRS["SOAP Envelope"] end AMA["Amadeus ART<br/>(1ASIWCLT...SQ)"] T --> RQ RQ -->|"jackson xmlMapper"| NDCXML NDCXML -->|"soap-DSL 봉투"| ENV ENV -->|"HTTP POST text/xml"| AMA AMA -->|"응답 (Amadeus ↔ SQ 연동, 어댑터는 모름)"| ENVRS ENVRS -->|"soap(content)"| NDCXMLRS NDCXMLRS -->|"soapBody(xmlMapper)"| RS
| 항목 | 값 | 근거 |
|---|---|---|
| 메시지 표준 | IATA NDC / EDIST 18.1 | RQ 루트 네임스페이스 http://www.iata.org/IATA/2015/00/2018.1/..., PayloadAttribute("18.1") |
| 전송 방식 | SOAP 1.1 (HTTP POST, text/xml) | getHeaderMap Content-Type=TEXT_XML_VALUE, SOAPAction 헤더 |
| 봉투 생성기 | jakarta.xml.soap 기반 DSL soap { } | support/util/SoapExtensions.kt |
| Body 직렬화 | Jackson @Qualifier("xmlMapper") ObjectMapper | SingaporeairClient 생성자 |
| 인증 | WS-Security UsernameToken + PasswordDigest (Amadeus 방식) | soapRequestBodyConverter, PasswordDigest.kt |
| 라우팅 | WS-Addressing wsa:Action = 오퍼레이션별 URI | SingaporeairRequest.action |
| 실제 호스트 게이트웨이 | Amadeus(webservices.amadeus.com), 직접 SQ 아님 | action URI, mockData의 1ASIWCLTSQ 엔드포인트 |
왜 "NDC인데 Amadeus 네임스페이스"인가?
2. SOAP Envelope 조립 — soapRequestBodyConverter
전문 봉투는 클라이언트 내부 클로저 soapRequestBodyConverter가 만든다 (SingaporeairClient.kt:833-896). 모든 오퍼레이션이 .requestBodyConvert(soapRequestBodyConverter(...)) 로 이 한 곳을 공유한다.
private val soapRequestBodyConverter: (SingaporeairApiProperties) -> ((Any?) -> String) =
{ apiProps ->
{ request ->
soap {
header {
// ① Amadeus 보안 사용자 컨텍스트
headerElement("AMA_SecurityHostedUser", AMA_SECURITY_HOSTED_USER) { ... }
// ② WS-Security UsernameToken (PasswordDigest)
headerElement("Security", WS_SECURITY_WSSE) { ... }
// ③ WS-Addressing (라우팅)
headerElement("Action", ADDRESSING) { text { (request as SingaporeairRequest).action } }
headerElement("MessageID", ADDRESSING) { text { "urn:uuid:${UUID.randomUUID()}" } }
headerElement("To", ADDRESSING) { text { apiProps.endpoint } }
}
body(objectMapper.writeValueAsBytes(request).inputStream()) // ④ NDC XML 본문
}.replace(" xmlns=\"\"", "") // ⑤ 빈 네임스페이스 청소
}
}
.replace(" xmlns=\"\"", "")가 핵심 함정Jackson +
jakarta.xml.soap조합은 자식 엘리먼트에xmlns=""(빈 기본 네임스페이스)를 자주 끼워넣는다. 이게 남아 있으면 Amadeus/SQ 스키마 검증이 전문 전체를 거부한다. 마지막 문자열 치환이 이를 제거하는 안전장치다. 이건 SOAP DSL의 구조적 한계를 우회하는 “지뢰” 코드이므로 함부로 지우면 안 된다. → singaporeair-pitfalls
SOAP 헤더 네임스페이스 테이블
모든 prefix/네임스페이스는 support/enums/SingaporeairSoapHeaderNamespace.kt 의 enum으로 중앙 관리된다.
| enum 상수 | prefix | namespace | 용도 |
|---|---|---|---|
ADDRESSING | wsa | http://www.w3.org/2005/08/addressing | WS-Addressing (Action/MessageID/To) |
AMA_SECURITY_HOSTED_USER | sec | http://xml.amadeus.com/2010/06/Security_v1 | Amadeus 호스티드 사용자(PCC, duty code) |
WS_SECURITY_WSSE | wsse | ...oasis...wssecurity-secext-1.0.xsd | UsernameToken 컨테이너 |
WS_SECURITY_WSU | wsu | ...oasis...wssecurity-utility-1.0.xsd | Created(타임스탬프) |
NONCE_ENCODING_TYPE | wsse | ...#Base64Binary | Nonce 인코딩 타입 속성 |
PASSWORD_TYPE | (없음) | ...#PasswordDigest | Password Type 속성 |
USER_TYPE | typ | http://xml.amadeus.com/2010/06/Types_v1 | RequestorID 타입 |
IATA | iat | http://www.iata.org/IATA/2007/00/IATA2010.1 | CompanyName(IATA 회사명) |
3. 인증·세션 — Stateless WS-Security UsernameToken
SQ는 세션을 유지하지 않는다 (Stateless)
Amadeus GDS(1A)가 stateful PNR 세션(로그인→작업→로그아웃)을 유지하는 것과 달리, SQ NDC는 매 요청마다 WS-Security UsernameToken을 새로 만들어 붙이는 stateless 방식이다. 토큰 발급/만료/리프레시 같은 별도 세션 API가 없다. 각 전문이 자기 자신을 독립적으로 인증한다.
PasswordDigest 알고리즘 (support/util/PasswordDigest.kt)
WS-Security UsernameToken Profile 1.0의 표준 PasswordDigest 공식을 그대로 구현한다.
PasswordDigest = Base64( SHA1( Base64Decode(Nonce) + Created + SHA1(clearPassword) ) )
fun getPasswordDigestFromClearTextPW(nonce, created, clearPassword): String {
val sha1 = getMessageDigest() // "SHA-1"
sha1.update(Base64.getDecoder().decode(nonce.toByteArray())) // ① nonce(디코딩)
sha1.update(created.toByteArray()) // ② created 타임스탬프
return Base64.encode(sha1.digest(getHash(clearPassword))) // ③ + SHA1(평문비밀번호)
}| 토큰 필드 | 생성 방법 | 근거 |
|---|---|---|
Username | 설정값 apiProps.userName (예: WSSQTPL) | SingaporeairClient.kt:862 |
Nonce | PasswordDigest.genNonce() = SecureRandom("SHA1PRNG") 32바이트 → Base64 | PasswordDigest.kt:41 |
Created | getFormattedTime(now("UTC")), 포맷 yyyy-MM-dd'T'HH:mm:ss.SSS'Z' | PasswordDigest.kt:50 |
Password | 위 digest 공식, Type=...#PasswordDigest | SingaporeairClient.kt:864-872 |
Nonce·Created는 매 요청 새로 생성된다
soapRequestBodyConverter안에서val nonce = PasswordDigest.genNonce()와now("UTC")가 호출 시점마다 실행된다. 두 클라이언트 호출이 같은 토큰을 재사용하지 않는다 → 재전송(replay) 방어. 시계가 틀어지면Created가 서버 허용 윈도우를 벗어나 인증 실패하므로, 컨테이너 시각 동기화가 중요하다.
AMA_SecurityHostedUser (Amadeus 사용자 컨텍스트)
<sec:AMA_SecurityHostedUser>
<sec:UserID POS_Type="1" RequestorType="U"
PseudoCityCode="SELSQ08N2" AgentDutyCode="SU">
<typ:RequestorID>
<iat:CompanyName>TRIPLE</iat:CompanyName>
</typ:RequestorID>
</sec:UserID>
</sec:AMA_SecurityHostedUser>| 속성/엘리먼트 | 값 | 의미 |
|---|---|---|
POS_Type | "1" 고정 | Point-of-sale 타입 |
RequestorType | "U" 고정 | 사용자(User) |
PseudoCityCode | 설정값 apiProps.pseudoCityCode | Amadeus PCC(오피스 ID) |
AgentDutyCode | "SU" 고정 | 슈퍼바이저 권한 |
CompanyName | "TRIPLE" 하드코딩 | IATA 회사명 |
mockData와 코드의 미세 차이
1_AirShoppingRQ.xml샘플은<iat:CompanyName>SQ</iat:CompanyName>로 되어 있지만, 현재 코드(SingaporeairClient.kt:852)는text { "TRIPLE" }로 하드코딩되어 있다. 샘플은 Amadeus가 배포한 원본 예시이고, Triple 통합 시 회사명을 자사 값으로 바꾼 것으로 보인다. 전문 디버깅 시 코드 기준(TRIPLE)을 신뢰하라.
WS-Addressing — 오퍼레이션 라우팅의 본체
SQ에는 "중앙 디스패처"도 "URL별 엔드포인트"도 없다
모든 오퍼레이션이 동일한 엔드포인트 URL(
apiProps.endpoint)로 간다. 어떤 작업인지는wsa:Action헤더 +SOAPActionHTTP 헤더의 URI 값으로만 구분된다. 이 값은 각 RQ DTO의override val action에 하드코딩되어 있고,getHeaderMap이 같은 값을 HTTPSOAPAction헤더에도 복사한다 (SingaporeairClient.kt:898-904).
| RQ 클래스 | action URI (SOAPAction) | 오퍼레이션 |
|---|---|---|
AirShoppingRQ | .../NDC_AirShopping_18.1 | 검색 |
OfferPriceRQ | .../NDC_OfferPrice_18.1 | 운임확정/규정 |
OrderCreateRQ | .../NDC_OrderCreate_18.1 | 예약 생성 |
OrderRetrieveRQ | .../NDC_OrderRetrieve_18.1 | 예약 조회 |
OrderCancelRQ | .../NDC_OrderCancel_18.1 | 취소 |
OrderChangeRQ | .../NDC_OrderChange_18.1 | 발권/좌석/부가/분리/재발행 변경 |
OrderReshopRQ | .../NDC_OrderReshop_18.1 | 환불계산/재발행검색/재발행운임 |
ServiceListRQ | .../NDC_ServiceList_18.1 | 부가서비스 목록 |
SeatAvailabilityRQ | .../NDC_SeatAvailability_18.1 | 좌석맵 |
(prefix는 모두 http://webservices.amadeus.com/)
4. 요청 전문 모델 구조
4.1 공통 골격 — SingaporeairRequest + 4개 표준 블록
모든 RQ는 SingaporeairRequest 인터페이스를 구현하고(=action 보유), 4개의 공통 NDC 블록으로 시작한다.
interface SingaporeairRequest {
@get:JsonIgnore // action은 Body 직렬화에서 제외 (헤더 전용)
val action: String
}| 공통 블록 | 클래스 | 기본값/고정값 | 근거 |
|---|---|---|---|
PayloadAttributes | PayloadAttribute | Version="18.1" | AirShoppingRQ.kt:16 |
PointOfSale | PointOfSale → Country | CountryCode="KR" 고정 | PointOfSale.kt:11 |
Party | Party(Sender/Recipient/Participant) | ORA.AirlineDesigCode="SQ" 고정 | Party.kt:57 |
Request | 오퍼레이션별 내부 클래스 | — | 각 RQ |
@get:JsonIgnore— action이 Body에 새어 나가면 안 된다
action은 SOAP 헤더(wsa:Action)에만 쓰여야 하고 NDC Body XML에는 절대 들어가면 안 된다.SingaporeairRequest의@get:JsonIgnore가 Jackson xmlMapper가action프로퍼티를 직렬화하지 않도록 막는다. 새 오퍼레이션 DTO를 만들 때 이 인터페이스를 구현하지 않거나 어노테이션을 빠뜨리면 Body에<action>이 끼어 스키마 검증이 깨진다.
4.2 Party — 여행사 식별 블록
Party.of(iataNumber, agencyName) → Party(
participant = Participant(Aggregator(aggregatorId = "")),
recipient = Recipient(ORA(airlineDesigCode = "SQ")), // 항상 SQ
sender = Sender(TravelAgency(
agencyID = iataNumber, iataNumber = iataNumber,
agencyName = agencyName, typeCode = "OnlineTravelAgency"))
)
TypeCode는 prod/staging에서 의도적으로 생략된다
TravelAgency.typeCode필드에@IgnoreSerialize(["prod", "staging"])가 붙어 있다 (Party.kt:45). 이 커스텀 Jackson 직렬화기(support/annotation/IgnoreSerialize.kt)는 활성 프로파일이prod/staging이면 해당 필드를 Body에서 제외한다. 운영 환경 SQ/Amadeus 스키마는TypeCode를 받지 않거나 다르게 처리하기 때문으로 보인다. mockData 샘플(1_AirShoppingRQ.xml:55)에는<TypeCode>OnlineTravelAgency</TypeCode>가 들어 있다(dev 기준). 환경별 전문이 달라진다는 점을 반드시 기억하라. → singaporeair-pitfalls
4.3 핵심 필드 매핑 (검색/예약)
AirShoppingRQ (AirShoppingRQ.kt)
| NDC 엘리먼트 | Kotlin 필드 | 비고 |
|---|---|---|
Request/FlightRequest/OriginDestRequest | FlightRequest.originDestRequests | @JacksonXmlElementWrapper(useWrapping=false) — 래퍼 없이 반복 |
OriginDepRequest/IATA_LocationCode,Date | OriginDepRequest | Date 포맷 yyyy-MM-dd |
Request/Paxs/Pax | Request.paxs: List<Pax> | Paxs로 래핑 |
Pax/PaxID,PTC | Pax.paxId,ptc | PAX1,PAX2…; 유아는 PAX{n}1 |
ShoppingCriteria/CabinTypeCriteria/CabinTypeName | cabins 매핑 | SingaporeairCabinType.code |
Pax 식별 규칙 (Pax.kt:79-101, makePaxList)
성인/소아: PAX1, PAX2, ... (순번)
유아 : PAX11, PAX21, ... (= 동반 성인 PaxID + "1", PaxRefID로 성인 참조)
유아 PaxID 패턴이 왜
PAX11인가?NDC에서 유아(INF)는 좌석이 없고 성인에 종속된다. 코드는 유아를
PAX${index+1}1(예: 첫 유아=PAX11)로 만들고paxRefId="PAX${index+1}"로 동반 성인을 가리킨다. mockData1_AirShoppingRQ.xml:87에서도<PaxID>PAX11</PaxID><PTC>INF</PTC>로 확인된다.
OrderCreateRQ (OrderCreateRQ.kt) — 검색 결과의 ID 3종 세트를 그대로 되돌려보낸다.
| NDC 엘리먼트 | 출처 (FareItinerary) | 의미 |
|---|---|---|
SelectedOffer/OfferRefID | offerId | 검색에서 받은 Offer ID |
SelectedOffer/OwnerCode | ownerCode | SQ |
SelectedOffer/ShoppingResponseRefID | responseId | 검색 응답 컨텍스트 ID |
SelectedOfferItem/OfferItemRefID | offerItemId | 선택 Offer Item |
DataLists/Pax + Individual/IdentityDoc | 승객 정보 | 여권(IdentityDoc, TypeCode="PT"), 생년월일/이름 |
ContactInfo (Standard/Mailing/Notification) | 연락처 | 승객 휴대폰 없으면 예약자 번호로 대체 |
NDC는 "stateless 참조 체인"이다
SQ에는 PNR 세션이 없으므로, 검색→가격→예약으로 이어지는 상태는 전부
OfferID/OfferItemID/ShoppingResponseID3종 ID를 다음 전문에 실어 보내는 방식으로 전파된다. 이 ID들이 FareItinerary에 보관되고OfferPriceRQ·OrderCreateRQ·OrderChangeRQ(재발행)에서 모두 재사용된다. 하나라도 어긋나면 Amadeus가 컨텍스트 만료/불일치로 거부한다. → singaporeair-operations
4.4 변경계 전문 — OrderID는 SQ_ 접두사
예약 후 작업(취소/발권/재발행/좌석/부가/환불)은 PNR이 아니라 OrderID = "SQ_${pnr}" 로 주문을 지정한다 (Order.kt:13, OrderReshopRQ.kt:37 orderItemRefId="SQ_${pnr}").
| 오퍼레이션 | RQ | 팩토리 | 특징 |
|---|---|---|---|
| 발권(결제) | OrderChangeRQ | ofPayment | PaymentInfo.ofCash(현금 TypeCode="CA") |
| 좌석저장 | OrderChangeRQ | ofSeat | 현재 미서비스 |
| 부가저장 | OrderChangeRQ | ofAncillary | 현재 미서비스 |
| 재발행 확정 | OrderChangeRQ | ofReissue | SelectedOffer + 결제 |
| 분리(divide) | OrderChangeRQ | ofDivide | Reason="DIV", 현재 미서비스 |
| 환불금 계산 | OrderReshopRQ | ofRefundCalculate | DeleteOrderItem |
| 재발행 검색 | OrderReshopRQ | ofReissueSearch | AddOfferItem+DeleteOrderItem(RetainServiceID로 잔여구간 유지) |
| 재발행 운임 | OrderReshopRQ | ofPricing | ExistingOrderCriteria |
PaymentInfo.ofCash— SQ 결제는 항상 "현금(CA)"SQ는 카드 결제 DTO(
PaymentCard)가 모델에는 있으나, 실제 발권/재발행 결제는 모두PaymentInfo.ofCash(amount)(=TypeCode="CA",PaymentMethod=Cash)로 나간다 (PaymentInfo.kt:17). Triple이 대리점 크레딧/BSP 정산을 하기 때문에 항공사에는 현금 결제로 통지하는 구조다.
5. 응답 전문 모델 & 역직렬화
5.1 역직렬화 파이프라인
flowchart TD A["HTTP Response (SOAP Envelope 문자열)"] B["soap(content)<br/>jakarta.xml.soap 로 Envelope 파싱"] C["soapBody.firstChild.asString()<br/>Body의 첫 자식 (NDC RS 루트)만 추출"] D["AirShoppingRS / OrderViewRS / ... (RS DTO)"] A -->|"soapBodyDeserializerOf(logger, mapper)"| B B -->|"soapBody(mapper)"| C C -->|"xmlMapper.readValue T"| D
근거:
soapBodyDeserializerOf(logger, mapper)—SingaporeairClient.kt:52.soapBody(mapper)—SoapExtensions.kt:33
| RS 클래스 | NDC 루트 | 매핑 대상 |
|---|---|---|
AirShoppingRS | AirShoppingRS | 검색 → FareItinerary |
OfferPriceRS | OfferPriceRS | 가격/규정 → PassengerFare/MiniRule |
OrderViewRS | OrderViewRS | 예약/조회/발권/재발행 → Booking |
OrderCancelRS | OrderCancelRS | 취소 → 위약금(penaltyAmount) |
OrderReshopRS | OrderReshopRS | 환불계산/재발행검색 → 환불액/FareItinerary |
ServiceListRS | ServiceListRS | 부가목록 → AncillaryType |
SeatAvailabilityRS | SeatAvailabilityRS | 좌석맵 |
같은
OrderViewRS가 여러 작업의 응답예약·조회·발권·좌석·부가·분리·재발행 확정의 응답이 전부
OrderViewRS다. NDC는 “주문의 현재 상태 뷰”라는 단일 응답 모델을 공유하기 때문이다. 어댑터는 같은OrderViewRS를 작업별로toBooking()/toReissueBooking()등으로 다르게 매핑한다.
5.2 에러 모델 — <Error> 와 두 가지 checkError
NDC 비즈니스 에러는 SOAP Fault가 아니라 응답 Body 안의 <Error> 엘리먼트 배열로 온다.
data class Error( // response/Error.kt
val code: String?, // <Code> 예: "710", "367", "911"
val descText: String?, // <DescText> 예: "NO FARE FOUND ..."
val language: String?, val typeCode: String?)| 시그니처 | 사용처 | 동작 |
|---|---|---|
checkError((code, message) -> Unit) | 대부분 RS | errors.first() 만 콜백으로 넘김 |
checkError((errors: List<Error>) -> Unit) | OrderReshopRS(재발행 검색) | 전체 에러 리스트를 메시지로 합쳐 전달 |
두 종류 에러를 구분하라 — Body Error vs SOAP Fault
6. XSD 스키마 — 위치와 핵심 타입
전문 스키마(XSD)는 빌드에 쓰이지 않고 참조/검증용으로 테스트 트리에 보관된다.
- 루트:
src/test/schema/singaporeair/18_1_EDIST_schemas/(54개 XSD) - 실전문 샘플:
src/test/schema/singaporeair/example/재발행Sample/(1~14번, 검색→예약→재발행 전체 시나리오 XML)
핵심 XSD
| XSD 파일 | targetNamespace | 비고 |
|---|---|---|
AirShoppingRQ.xsd / RS | .../2018.1/AirShoppingRQ | 검색. version="7.000" id="IATA2018.1" |
OfferPriceRQ.xsd / RS | .../OfferPrice | 가격/규정 |
OrderCreateRQ.xsd, OrderViewRS.xsd | .../OrderCreate, OrderViewRS | 예약/주문뷰 |
OrderChangeRQ.xsd, OrderCancelRQ/RS | .../OrderChange, OrderCancel | 변경/취소 |
OrderReshopRQ.xsd / RS | .../OrderReshop | 재발행/환불 |
ServiceListRQ/RS, SeatAvailabilityRQ/RS | 부가/좌석 | |
FareRulesRQ/RS, BaggageAllowance/Charges/List | 규정/수하물 | 일부는 코드 미사용 |
edist_commontypes_for_AIDM.xsd | (공통) | 모든 RQ가 <xs:include> 하는 공통 타입 정의 |
edist_commontypes.xsd, edist_structures.xsd | (공통) | EDIST 구조/공통 타입 |
xmldsig-core-schema.xsd | XML 서명 | W3C 디지털 서명(현 인증엔 미사용) |
공통 타입은 한 곳에서 —
edist_commontypes_for_AIDM.xsd각 메시지 XSD는 자기 루트 엘리먼트만 정의하고,
PaxID·OfferID·CurrencyAmount같은 재사용 타입은 모두edist_commontypes_for_AIDM.xsd를include해서 가져온다(AirShoppingRQ.xsd:5). 새 필드를 추가/디버깅할 때 타입 정의를 못 찾으면 이 공통 XSD를 보라.
EDIST 18.1 vs 다른 NDC 버전
7. 실제 전문 예시 (mockData / example)
AirShoppingRQ — SOAP Envelope 전체 (example/재발행Sample/1_AirShoppingRQ.xml)
POST https://nodeA3.test.webservices.amadeus.com/1ASIWCLTSQ HTTP/1.1
Content-Type: text/xml;charset=UTF-8
SOAPAction: "http://webservices.amadeus.com/NDC_AirShopping_18.1"
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:sec="http://xml.amadeus.com/2010/06/Security_v1">
<soapenv:Header xmlns:wsa="http://www.w3.org/2005/08/addressing">
<sec:AMA_SecurityHostedUser>
<sec:UserID POS_Type="1" RequestorType="U"
PseudoCityCode="SELSQ08N2" AgentDutyCode="SU">
<typ:RequestorID xmlns:typ="...Types_v1" xmlns:iat="...IATA2010.1">
<iat:CompanyName>SQ</iat:CompanyName>
</typ:RequestorID>
</sec:UserID>
</sec:AMA_SecurityHostedUser>
<wsse:Security xmlns:wsse="...wssecurity-secext-1.0.xsd"
xmlns:wsu="...wssecurity-utility-1.0.xsd">
<wsse:UsernameToken>
<wsse:Username>WSSQTPL</wsse:Username>
<wsse:Password Type="...#PasswordDigest">ldFb7ChvrLzVswNDcRBPJ3EcsXg=</wsse:Password>
<wsse:Nonce EncodingType="...#Base64Binary">hteX21Mj/aaOO1iCgN1CVg==</wsse:Nonce>
<wsu:Created>2022-01-11T07:12:09.441Z</wsu:Created>
</wsse:UsernameToken>
</wsse:Security>
<wsa:Action>http://webservices.amadeus.com/NDC_AirShopping_18.1</wsa:Action>
<wsa:MessageID>uuid:5086cc2a-...</wsa:MessageID>
<wsa:To>https://nodeA3.test.webservices.amadeus.com/1ASIWCLTSQ</wsa:To>
</soapenv:Header>
<soapenv:Body>
<AirShoppingRQ xmlns="http://www.iata.org/IATA/2015/00/2018.1/AirShoppingRQ">
<PayloadAttributes><Version>18.1</Version></PayloadAttributes>
<PointOfSale><Country><CountryCode>KR</CountryCode></Country></PointOfSale>
<Party> ... <ORA><AirlineDesigCode>SQ</AirlineDesigCode></ORA> ... </Party>
<Request>
<FlightRequest>
<OriginDestRequest>...SIN→LHR 2022-03-25...</OriginDestRequest>
<OriginDestRequest>...LHR→SIN 2022-04-25...</OriginDestRequest>
</FlightRequest>
<Paxs>
<Pax><PaxID>PAX1</PaxID><PTC>ADT</PTC></Pax>
<Pax><PaxID>PAX11</PaxID><PTC>INF</PTC></Pax>
</Paxs>
<ShoppingCriteria><CabinTypeCriteria><CabinTypeName>ECO</CabinTypeName></CabinTypeCriteria></ShoppingCriteria>
</Request>
</AirShoppingRQ>
</soapenv:Body>
</soapenv:Envelope>코드 ↔ 전문 1:1 매핑 확인
위 Body는
AirShoppingRQ.of(...)가 생성하는 구조와 정확히 일치한다:Version=18.1(PayloadAttribute),CountryCode=KR(PointOfSale),AirlineDesigCode=SQ(ORA),Paxs/Pax(makePaxList),CabinTypeName=ECO(ShoppingCriteria). 헤더의wsa:Action·SOAPAction이 동일 URI인 점도getHeaderMap과 일치한다.
재발행 시나리오 샘플 묶음 (example/재발행Sample/)
검색→예약→재발행의 전 과정을 순번 XML로 제공한다 — SQ 재발행 흐름 학습의 일급 자료.
| 파일 | 전문 |
|---|---|
1_AirShoppingRQ / 2_..RS | 검색 |
3_OfferPriceRQ / 4_..RS | 운임확정 |
5_OrderCreateRQ / 6_OrderViewRS | 예약 |
7_OrderRetrieve_RQ / 8_..RS | 조회 |
9_OrderReshopRQ / 10_..RS | 재발행 검색 |
11_OrderReshopPriceRQ / 12_..RS | 재발행 운임 |
13_OrderChangeRQ / 14_OrderViewRS-AfterChange | 재발행 확정 |
mockData/singaporeair 폴더는 비어 있다
src/test/resources/mockData/에는 amadeus/lufthansa/sabre만 있고 singaporeair 디렉터리가 없다. SQ의 실전문 샘플은 위src/test/schema/singaporeair/example/...에만 존재한다. 테스트(SingaporeairTest.kt,SingaporeairAncillaryControllerTest.kt)에서 전문을 인용할 때 경로를 혼동하지 말 것.
8. 설정·인증 정보 주입 (SingaporeairApiProperties)
data class SingaporeairApiProperties(
override val funnel: String,
val iataCode: String, // TravelAgency.AgencyID / IATA_Number
val userName: String, // wsse:Username
val password: String, // PasswordDigest 평문 비밀번호 (Secrets)
val agencyName: String, // TravelAgency.Name
val pseudoCityCode: String, // sec:UserID PseudoCityCode (Amadeus PCC)
val endpoint: String, // wsa:To + 실제 POST 대상
) : FunnelProperties| 출처 | 내용 |
|---|---|
prefix = "supplier.singaporeair" | SingaporeairProperties(channels → funnels) |
| 채널/퍼널 선택 | getApiProperties(salesChannel, salesFunnel) — MDCHolder에서 현재 요청 컨텍스트 추출 |
| 비밀값 주입 | supplier/singaporeair.yml → AWS Secrets Manager({profile}/air-intl-adapter/singaporeair) |
endpoint·계정은 채널×퍼널마다 다르다
SQ 설정은
channels[].funnels[]2차원 구조다.userName/password/pseudoCityCode/endpoint가 판매 채널·퍼널 조합마다 다를 수 있다. 전문이 “특정 채널에서만 인증 실패”한다면 코드 버그가 아니라 해당 퍼널의 Secrets/PCC 설정을 의심하라. 채널·퍼널 미지원 시NOT_SUPPORTED_SALES_CHANNEL/FUNNEL예외가 난다. → configuration-and-infra
9. 타임아웃 & 클라이언트 기반
SingaporeairClient는 ClientSupport를 상속하며 검색/일반 타임아웃을 분리한다 (SingaporeairClient.kt:45-49).
| 설정 | 값 | 적용 |
|---|---|---|
searchTimeout | 15,000ms | 검색(searchClient) |
defaultTimeout | 60,000ms | 예약/발권/취소 등 나머지 |
검색만 별도 클라이언트(
searchClient)
search(...)만.client(searchClient)로 짧은 타임아웃(15s)을, 나머지는 기본(60s)을 쓴다. 예약·발권·취소는 Amadeus↔SQ 왕복이 길 수 있어 넉넉히 잡는다. 타임아웃 처리(취소 타임아웃 Slack 경보, 발권 타임아웃 콜백)는 resilience-and-events·error-handling 참고.
10. 정리 — 신입이 기억할 5가지
핵심 5
- “NDC Body + Amadeus 봉투” 이중 구조. 페이로드는 IATA EDIST 18.1, 전송·인증은 Amadeus.
- Stateless — PNR 세션 없음. 매 요청 WS-Security UsernameToken(PasswordDigest) 재생성.
- 라우팅은 URL이 아니라
wsa:Action/SOAPActionURI 로 구분(SingaporeairRequest.action).- 상태 전파 = OfferID/OfferItemID/ShoppingResponseID 3종 ID + 변경계는
OrderID="SQ_{pnr}".- 함정:
xmlns=""치환,@get:JsonIgnore action,@IgnoreSerialize(prod/staging)TypeCode, mockData 폴더 부재. → singaporeair-pitfalls
연습문제
Q1.
AirShoppingRQ와OrderCreateRQ는 같은 엔드포인트 URL로 전송되는데, Amadeus는 둘을 어떻게 구분하는가?정답 보기
두 RQ의
override val action이 다르다(NDC_AirShopping_18.1vsNDC_OrderCreate_18.1). 이 값이 SOAP 헤더wsa:Action과 HTTPSOAPAction헤더에 동일하게 실려 라우팅된다(getHeaderMap,soapRequestBodyConverter의Action헤더). 엔드포인트 URL(apiProps.endpoint)은 동일하다.
Q2. 운영(prod)에서 보낸 전문에는 있는데 dev 전문에는 없는 필드, 또는 그 반대가 발생할 수 있다. 어떤 메커니즘 때문인가?
정답 보기
Party.TravelAgency.typeCode에 붙은@IgnoreSerialize(["prod","staging"])때문이다.IgnoreSerializer가 활성 프로파일을 검사해 prod/staging이면TypeCode를 Body에서 제외한다. dev/qa/local에서는<TypeCode>OnlineTravelAgency</TypeCode>가 포함된다.
Q3. SQ 응답에서 "비즈니스 에러(710)"와 "전송 Fault"는 코드 흐름이 어떻게 갈리는가?
정답 보기
비즈니스 에러는 RS Body의
<Error>배열로 와서fold의success분기에서checkError로 처리된다(710/367은 “결과 없음”으로 정상 처리). 전송/Fault 에러는failure분기로 가서handleSoapFaultException(...)로 처리되며, 취소 타임아웃이면slackService.sendCancelFailTimeout경보가 추가로 발생한다.
분석 소스: supplier/singaporeair/infrastructure/{request,response}, SingaporeairClient.kt, support/util/{SoapExtensions,PasswordDigest}.kt, support/enums/SingaporeairSoapHeaderNamespace.kt, support/annotation/IgnoreSerialize.kt, configuration/Properties.kt, resources/supplier/singaporeair.yml, test/schema/singaporeair/{18_1_EDIST_schemas,example/재발행Sample}.