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.1RQ 루트 네임스페이스 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") ObjectMapperSingaporeairClient 생성자
인증WS-Security UsernameToken + PasswordDigest (Amadeus 방식)soapRequestBodyConverter, PasswordDigest.kt
라우팅WS-Addressing wsa:Action = 오퍼레이션별 URISingaporeairRequest.action
실제 호스트 게이트웨이Amadeus(webservices.amadeus.com), 직접 SQ 아님action URI, mockData의 1ASIWCLTSQ 엔드포인트

왜 "NDC인데 Amadeus 네임스페이스"인가?

Triple은 SQ NDC를 Amadeus의 NDC 게이트웨이(ART) 를 거쳐 호출한다. 그래서 Body(전문 내용)는 순수 IATA NDC(iata.org 네임스페이스)지만, 봉투/헤더(전송·보안)는 Amadeus(xml.amadeus.com, webservices.amadeus.com)다. 같은 NDC 공급사라도 대한항공·루프트한자는 항공사 직결이라 이 Amadeus 헤더가 없다 — SQ만의 특징이다.


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 상수prefixnamespace용도
ADDRESSINGwsahttp://www.w3.org/2005/08/addressingWS-Addressing (Action/MessageID/To)
AMA_SECURITY_HOSTED_USERsechttp://xml.amadeus.com/2010/06/Security_v1Amadeus 호스티드 사용자(PCC, duty code)
WS_SECURITY_WSSEwsse...oasis...wssecurity-secext-1.0.xsdUsernameToken 컨테이너
WS_SECURITY_WSUwsu...oasis...wssecurity-utility-1.0.xsdCreated(타임스탬프)
NONCE_ENCODING_TYPEwsse...#Base64BinaryNonce 인코딩 타입 속성
PASSWORD_TYPE(없음)...#PasswordDigestPassword Type 속성
USER_TYPEtyphttp://xml.amadeus.com/2010/06/Types_v1RequestorID 타입
IATAiathttp://www.iata.org/IATA/2007/00/IATA2010.1CompanyName(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
NoncePasswordDigest.genNonce() = SecureRandom("SHA1PRNG") 32바이트 → Base64PasswordDigest.kt:41
CreatedgetFormattedTime(now("UTC")), 포맷 yyyy-MM-dd'T'HH:mm:ss.SSS'Z'PasswordDigest.kt:50
Password위 digest 공식, Type=...#PasswordDigestSingaporeairClient.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.pseudoCityCodeAmadeus 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 헤더 + SOAPAction HTTP 헤더의 URI 값으로만 구분된다. 이 값은 각 RQ DTO의 override val action 에 하드코딩되어 있고, getHeaderMap 이 같은 값을 HTTP SOAPAction 헤더에도 복사한다 (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
}
공통 블록클래스기본값/고정값근거
PayloadAttributesPayloadAttributeVersion="18.1"AirShoppingRQ.kt:16
PointOfSalePointOfSaleCountryCountryCode="KR" 고정PointOfSale.kt:11
PartyParty(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/OriginDestRequestFlightRequest.originDestRequests@JacksonXmlElementWrapper(useWrapping=false) — 래퍼 없이 반복
OriginDepRequest/IATA_LocationCode,DateOriginDepRequestDate 포맷 yyyy-MM-dd
Request/Paxs/PaxRequest.paxs: List<Pax>Paxs로 래핑
Pax/PaxID,PTCPax.paxId,ptcPAX1,PAX2…; 유아는 PAX{n}1
ShoppingCriteria/CabinTypeCriteria/CabinTypeNamecabins 매핑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}"로 동반 성인을 가리킨다. mockData 1_AirShoppingRQ.xml:87에서도 <PaxID>PAX11</PaxID><PTC>INF</PTC>로 확인된다.

OrderCreateRQ (OrderCreateRQ.kt) — 검색 결과의 ID 3종 세트를 그대로 되돌려보낸다.

NDC 엘리먼트출처 (FareItinerary)의미
SelectedOffer/OfferRefIDofferId검색에서 받은 Offer ID
SelectedOffer/OwnerCodeownerCodeSQ
SelectedOffer/ShoppingResponseRefIDresponseId검색 응답 컨텍스트 ID
SelectedOfferItem/OfferItemRefIDofferItemId선택 Offer Item
DataLists/Pax + Individual/IdentityDoc승객 정보여권(IdentityDoc, TypeCode="PT"), 생년월일/이름
ContactInfo (Standard/Mailing/Notification)연락처승객 휴대폰 없으면 예약자 번호로 대체

NDC는 "stateless 참조 체인"이다

SQ에는 PNR 세션이 없으므로, 검색→가격→예약으로 이어지는 상태는 전부 OfferID/OfferItemID/ShoppingResponseID 3종 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팩토리특징
발권(결제)OrderChangeRQofPaymentPaymentInfo.ofCash(현금 TypeCode="CA")
좌석저장OrderChangeRQofSeat현재 미서비스
부가저장OrderChangeRQofAncillary현재 미서비스
재발행 확정OrderChangeRQofReissueSelectedOffer + 결제
분리(divide)OrderChangeRQofDivideReason="DIV", 현재 미서비스
환불금 계산OrderReshopRQofRefundCalculateDeleteOrderItem
재발행 검색OrderReshopRQofReissueSearchAddOfferItem+DeleteOrderItem(RetainServiceID로 잔여구간 유지)
재발행 운임OrderReshopRQofPricingExistingOrderCriteria

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 루트매핑 대상
AirShoppingRSAirShoppingRS검색 → FareItinerary
OfferPriceRSOfferPriceRS가격/규정 → PassengerFare/MiniRule
OrderViewRSOrderViewRS예약/조회/발권/재발행 → Booking
OrderCancelRSOrderCancelRS취소 → 위약금(penaltyAmount)
OrderReshopRSOrderReshopRS환불계산/재발행검색 → 환불액/FareItinerary
ServiceListRSServiceListRS부가목록 → AncillaryType
SeatAvailabilityRSSeatAvailabilityRS좌석맵

같은 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)대부분 RSerrors.first() 만 콜백으로 넘김
checkError((errors: List<Error>) -> Unit)OrderReshopRS(재발행 검색)전체 에러 리스트를 메시지로 합쳐 전달

두 종류 에러를 구분하라 — Body Error vs SOAP Fault

  • 비즈니스 에러: Body의 <Error>checkError로 잡음 (예: 검색 코드 710/367은 “결과 없음”으로 정상 처리, 발권 911은 Slack 캡처).
  • 전송/Fault 에러: HTTP/SOAP Fault → failure 분기에서 handleSoapFaultException(...). 취소 타임아웃 시 slackService.sendCancelFailTimeout 경보.

이 이원화는 에러 처리 공통·이벤트와 연결된다. SQ는 메시지큐 없이 예외 전파 + Slack 경보로 상태를 알린다.


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.xsdXML 서명W3C 디지털 서명(현 인증엔 미사용)

공통 타입은 한 곳에서 — edist_commontypes_for_AIDM.xsd

각 메시지 XSD는 자기 루트 엘리먼트만 정의하고, PaxID·OfferID·CurrencyAmount 같은 재사용 타입은 모두 edist_commontypes_for_AIDM.xsdinclude해서 가져온다(AirShoppingRQ.xsd:5). 새 필드를 추가/디버깅할 때 타입 정의를 못 찾으면 이 공통 XSD를 보라.

EDIST 18.1 vs 다른 NDC 버전

SQ는 EDIST 18.1, 대한항공은 V21.3, 루프트한자는 V17.2다. 같은 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.ymlAWS 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. 타임아웃 & 클라이언트 기반

SingaporeairClientClientSupport를 상속하며 검색/일반 타임아웃을 분리한다 (SingaporeairClient.kt:45-49).

설정적용
searchTimeout15,000ms검색(searchClient)
defaultTimeout60,000ms예약/발권/취소 등 나머지

검색만 별도 클라이언트( searchClient)

search(...).client(searchClient) 로 짧은 타임아웃(15s)을, 나머지는 기본(60s)을 쓴다. 예약·발권·취소는 Amadeus↔SQ 왕복이 길 수 있어 넉넉히 잡는다. 타임아웃 처리(취소 타임아웃 Slack 경보, 발권 타임아웃 콜백)는 resilience-and-events·error-handling 참고.


10. 정리 — 신입이 기억할 5가지

핵심 5

  1. “NDC Body + Amadeus 봉투” 이중 구조. 페이로드는 IATA EDIST 18.1, 전송·인증은 Amadeus.
  2. Stateless — PNR 세션 없음. 매 요청 WS-Security UsernameToken(PasswordDigest) 재생성.
  3. 라우팅은 URL이 아니라 wsa:Action/SOAPAction URI 로 구분(SingaporeairRequest.action).
  4. 상태 전파 = OfferID/OfferItemID/ShoppingResponseID 3종 ID + 변경계는 OrderID="SQ_{pnr}".
  5. 함정: xmlns="" 치환, @get:JsonIgnore action, @IgnoreSerialize(prod/staging) TypeCode, mockData 폴더 부재. → singaporeair-pitfalls

연습문제

Q1. AirShoppingRQOrderCreateRQ는 같은 엔드포인트 URL로 전송되는데, Amadeus는 둘을 어떻게 구분하는가?

Q2. 운영(prod)에서 보낸 전문에는 있는데 dev 전문에는 없는 필드, 또는 그 반대가 발생할 수 있다. 어떤 메커니즘 때문인가?

Q3. SQ 응답에서 "비즈니스 에러(710)"와 "전송 Fault"는 코드 흐름이 어떻게 갈리는가?


분석 소스: 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}.