Amadeus NDC — 프로토콜·전문

module-amadeusndc arch-infrastructure pattern-soap api-ndc config-secrets

한 줄 요약

Amadeus NDC 모듈의 통신은 “SOAP 1.1 envelope + WS-Security(UsernameToken/PasswordDigest) + Amadeus 전용 헤더”를 공통 골격으로 한다. 메인 채널(amadeusndc-operations의 검색/발권/예약 등)은 모두 AmadeusndcClient가 같은 SOAP 골격으로 호출하지만, 실어 나르는 body 전문은 두 세대가 섞여 있다: 검색·큐는 클래식 Amadeus 1A GDS 전문(Fare_MasterPricerTravelBoardSearch, Queue_*), 가격·예약·발권·재발행은 NDC 전문(AMA_TravelOrder*RQ/RS). 여기에 운임규정용 ART(REST/JSON)와 KAL 카드검증용 GPS(별도 SOAP) 두 개의 보조 클라이언트가 따로 붙는다.


1. 큰 그림: 3개의 클라이언트, 2세대의 전문

infrastructure 패키지에는 외부 통신 진입점이 셋이다. 각각 baseURL·인증·직렬화 방식이 다르므로 처음 보는 사람은 반드시 구분해야 한다.

flowchart TD
    APP["application 레이어<br/>Search · Booking · Ticketing · FareRule · Queue 서비스"]
    APP --> AC["AmadeusndcClient<br/>메인 SOAP"]
    APP --> NC["NdcArtClient<br/>REST 또는 JSON"]
    APP --> GC["GpsClient<br/>amadeusndcGpsClient"]
    AC -->|"SOAP 1.1 + WS-Security (TOPAS 1A 엔드포인트)"| G1["Amadeus 1A 게이트<br/>TOPAS"]
    NC -->|"HTTP POST · JSON + x-api-key"| G2["Amadeus ART<br/>운임규정 번역"]
    GC -->|"SOAP 별도 envelope · WS-Security 없음"| G3["KAL GPS_Approval<br/>카드 유효성검증"]
클라이언트파일 (file:line)프로토콜직렬화인증타임아웃주 용도
AmadeusndcClientinfrastructure/AmadeusndcClient.kt:53SOAP 1.1 (HTTP POST)xmlMapper (Jackson XML)WS-Security UsernameToken (PasswordDigest) + AMA_SecurityHostedUsersearchTimeout/defaultTimeout = 60000ms검색·가격·예약·발권·조회·취소·재발행·큐
NdcArtClientinfrastructure/art/NdcArtClient.kt:20REST (HTTP POST)objectMapper (Jackson JSON)HTTP 헤더 x-api-keydefaultTimeout = 60000ms운임규정(FareRule) 텍스트 조회·한글 번역
GpsClientinfrastructure/gps/GpsClient.kt:24 (@Component("amadeusndcGpsClient"))SOAP (HTTP POST, 별도 envelope)xmlMapper인증 헤더 없음 (body의 authentication 필드로 처리)defaultTimeout = 40000msKAL 카드 유효성/승인 검증

이름에 속지 말 것 — "NDC" 모듈인데 검색은 GDS 전문이다

모듈 이름이 amadeusndc지만 검색(search)과 큐(getPnrsInQueue 등)는 NDC 전문이 아니라 클래식 Amadeus 1A GDS 전문을 쓴다. 근거: SOAP Action / 루트 엘리먼트가 Fare_MasterPricerTravelBoardSearch(AmadeusndcClient.kt:107, action FMPTBQ_23_1_1A), Queue_CountTotal/Queue_List/Queue_MoveItem/Queue_RemoveItem이다. 반면 가격/예약/발권/재발행/취소만 진짜 NDC 전문(AMA_TravelOrder*)이다. 이 모듈은 “NDC 컨텐츠를 TOPAS(1A) 채널로 받아오되, 검색·큐 같은 인프라성 오퍼레이션은 기존 GDS 전문을 재사용”하는 하이브리드 구조다.


2. 메인 채널의 SOAP envelope 조립

AmadeusndcClient는 모든 오퍼레이션에서 동일한 패턴으로 요청을 만든다. 핵심은 세 단계다.

flowchart TD
    P1["post(request)<br/>AmadeusndcRequest 구현체 (DTO)"]
    P2["header(getHeaderMap(request))<br/>HTTP 헤더: Content-Type, SOAPAction"]
    P3["requestBodyConvert(soapRequestBodyConverter)<br/>envelope 조립"]
    P4["execute AmadeusndcResponse T (soapBodyDeserializerOf)<br/>응답 해체"]
    P1 --> P2 --> P3 --> P4

2.1 요청 마커 인터페이스 — AmadeusndcRequest

모든 메인 요청 DTO는 AmadeusndcRequest(infrastructure/request/AmadeusndcRequest.kt)를 구현한다.

interface AmadeusndcRequest {
    @get:JsonIgnore
    val action: String
}
  • action은 SOAP Action(wsa:Action 헤더 + HTTP SOAPAction 헤더)에 박힌다.
  • @JsonIgnore이므로 body XML에는 직렬화되지 않는다(메타데이터일 뿐).

2.2 HTTP 헤더 — getHeaderMap (AmadeusndcClient.kt:859)

mapOf(
    HttpHeaders.CONTENT_TYPE to MediaType.TEXT_XML_VALUE,   // text/xml → SOAP 1.1
    HttpHeaders.ACCEPT_ENCODING to "gzip,deflate",
    "SOAPAction" to request.action
)

text/xml은 SOAP 1.1의 시그널

Content-Type이 text/xml(+ 별도 SOAPAction 헤더)이면 SOAP 1.1, application/soap+xml이면 SOAP 1.2다. 여기선 1.1이다. 따라서 envelope namespace도 1.1 표준(jakarta.xml.soap.MessageFactory.newInstance()의 기본)을 따른다.

2.3 envelope 조립 — soapRequestBodyConverter (AmadeusndcClient.kt:793)

이 함수가 NDC 통신의 심장이다. 매 호출마다 새 envelope를 짓고, header에 4개의 Amadeus 전용 블록을 채운 뒤, body에 DTO를 XML로 직렬화해 끼워 넣는다. 조립은 support/util/SoapExtensions.kt의 Kotlin DSL(soap { header { ... }; body(...) })로 이뤄진다.

flowchart TD
    ENV["soap:Envelope"]
    ENV --> HDR["soap:Header"]
    ENV --> BODY["soap:Body"]
    HDR --> TO["wsa:To = endpoint (Addressing)"]
    HDR --> ACT["wsa:Action = request.action (Addressing)"]
    HDR --> MID["wsa:MessageID = urn:uuid 랜덤 UUID (Addressing)"]
    HDR --> SEC["oas:Security (WS-Security)"]
    HDR --> HU["AMA_SecurityHostedUser (Amadeus Security_v1)"]
    SEC --> UT["oas:UsernameToken Id=UsernameToken-2"]
    UT --> UN["Username = userName"]
    UT --> NON["Nonce — Base64, EncodingType=Base64Binary"]
    UT --> CRE["Created — UTC, yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"]
    UT --> PW["Password Type=#PasswordDigest = digest nonce·created·pw"]
    HU --> UID["UserID AgentDutyCode=SU POS_Type=1 PseudoCityCode=officeId 또는 offlineOid RequestorType=U"]
    BODY --> RE["RootElement xmlns=전문별 namespace<br/>... DTO 직렬화 결과 ..."]

마지막에 .replace(" xmlns=\"\"", "") — Jackson XML이 자식 엘리먼트에 붙이는 빈 namespace 선언(xmlns="")을 제거하는 후처리다. 이게 없으면 일부 전문이 Amadeus 측 스키마 검증에서 거부된다.

헤더 namespace 매핑 ( support/enums/AmadeusSoapHeaderNamespace.kt)

블록enumnamespace
wsa:To/Action/MessageIDADDRESSINGhttp://www.w3.org/2005/08/addressing
AMA_SecurityHostedUserAMA_SECURITY_HOSTED_USERhttp://xml.amadeus.com/2010/06/Security_v1
oas:Security, Username, Nonce, PasswordWS_SECURITY_OAS...oasis-200401-wss-wssecurity-secext-1.0.xsd
oas1 Id, CreatedWS_SECURITY_OAS1...oasis-200401-wss-wssecurity-utility-1.0.xsd
Nonce EncodingTypeNONCE_ENCODING_TYPE...soap-message-security-1.0#Base64Binary
Password TypePASSWORD_TYPE...username-token-profile-1.0#PasswordDigest

SESSION(Session_v3) namespace도 enum에 정의돼 있지만 메인 요청 조립에는 쓰이지 않는다 — 응답 헤더 파싱(awsse:Session)과 짝을 이루는 흔적이다(amadeusndc-pitfalls 참고: 요청은 stateless, 응답에만 세션 메타가 따라온다).


3. 인증·세션 — WS-Security PasswordDigest

NDC 메인 채널은 요청마다 WS-Security UsernameToken을 새로 만든다. 토큰을 저장해두고 재사용하지 않는다(요청 측은 사실상 stateless).

3.1 PasswordDigest 생성 (support/util/PasswordDigest.kt)

OASIS WS-Security UsernameToken Profile 1.0 표준 알고리즘이다.

PasswordDigest = Base64( SHA1( Base64Decode(Nonce) + Created + SHA1(ClearPassword) ) )
  • genNonce(): SecureRandom("SHA1PRNG")로 32바이트 난수 → Base64 (PasswordDigest.kt:41).
  • getFormattedTime(now("UTC")): yyyy-MM-dd'T'HH:mm:ss.SSS'Z'. 반드시 UTCAmadeusndcClient.kt:813에서 now("UTC") 호출. now()의 기본값은 Asia/Seoul(support/util/DateExtensions.kt:12)이므로 인자 누락 시 9시간 어긋나 인증이 깨진다.
  • 비밀번호는 평문이 아니라 SHA1(password)를 먼저 해시한 뒤 nonce·created와 다시 SHA1로 묶는다.

시계 동기화 의존성

Created 타임스탬프가 Amadeus 서버 허용 윈도우(보통 수 분)를 벗어나면 인증이 거부된다. 컨테이너 NTP/시계 드리프트가 곧 인증 장애로 이어진다. getExpiredTime()(now+5분)도 정의돼 있지만 메인 envelope 조립에는 사용되지 않는다.

3.2 자격증명의 출처 — 설정/시크릿

자격증명은 코드가 아니라 설정에서 온다. Amadeus NDC는 별도 설정 클래스를 두지 않고 클래식 Amadeus와 AmadeusProperties를 공유한다(configuration/Properties.kt:42, prefix supplier.amadeus).

data class AmadeusApiProperties(
    val funnel: String, val officeId: String, val offlineOid: String,
    val iataCode: String, val userName: String, val password: String,
    val endpoint: String, val art: ArtProperties,  // ART 설정도 여기 중첩
)
  • 실제 값은 코드/yml에 없고 AWS Secrets Manager에서 주입된다 — src/main/resources/supplier/amadeus.yml이 프로파일별로 optional:aws-secretsmanager:{env}/air-intl-adapter/amadeus만 import한다.
  • getApiProperties()는 MDC의 SalesChannel/SalesFunnel로 채널·퍼널을 고르고, PNR 생성일이 changedDate 이전이면 강제로 LEGACY 퍼널을 쓴다(Properties.kt:65). 즉 같은 NDC 호출이라도 옛 예약은 다른 office/credential로 나간다 — 디버깅 시 반드시 의식할 것.
  • 큐 오퍼레이션은 getApiProperties(offlineOid)로 오프라인 OID에 매핑된 퍼널을 찾고(Properties.kt:81), envelope의 PseudoCityCodeofficeId 대신 offlineOid를 넣는다(AmadeusndcClient.kt:843, useOfflineOid=true).

3.3 응답 세션 메타 — Session (infrastructure/Session.kt)

응답 SOAP 헤더의 awsse:Session을 파싱하는 모델. AmadeusndcResponse.session에 담기지만 후속 요청에 다시 실리지는 않는다(stateful PNR 세션을 유지하는 클래식 Amadeus와 대조적).

data class Session(
    @JacksonXmlProperty(isAttribute = true, localName = "TransactionStatusCode")
    val transactionStatusCode: TransactionStatusCode,   // Start / InSeries / End
    val sessionId: String? = null,
    val sequenceNumber: Int? = null,
    val securityToken: String? = null,
)

TransactionStatusCode(support/enums/TransactionStatusCode.kt)는 Start/InSeries/End 세 값. 세션 기반 흐름의 흔적이지만 현재 구현은 매 요청 stateless다.


4. 응답 해체 — AmadeusndcResponse<T>와 SOAP 해체

4.1 envelope → (Session header + body) 분해 (AmadeusndcClient.kt:64)

inline fun <reified T : Any> soapBodyDeserializerOf(logger, mapper): (String, Response) -> AmadeusndcResponse<T> =
    { content, _ ->
        soap(content).let {
            AmadeusndcResponse(
                session = it.soapHeader(mapper, "awsse:Session"),  // 헤더에서 Session
                body    = it.soapBody(mapper),                      // body 첫 자식을 T로
            )
        }
    }
  • soap(content): 문자열 → jakarta.xml.soap.SOAPMessage (SoapExtensions.kt:23).
  • soapHeader(mapper, "awsse:Session"): 헤더에서 태그 검색, 있으면 Session으로 역직렬화(없으면 null).
  • soapBody(mapper): soapBody.firstChild(= 전문 루트)를 xmlMapperT에 매핑(SoapExtensions.kt:33).
  • 파싱 실패 시 원문 body를 ResponseLog.toStructuredArgument로 로깅 후 예외 재전파 → 깨진 전문이 로그에 남는다.

AmadeusndcResponse<T>는 envelope 래퍼다

data class AmadeusndcResponse<T>(val session: Session?, val body: T). 서비스 코드는 거의 항상 response.body만 본다. 헤더의 session은 보유하되 사용처는 제한적(향후 세션 흐름 대비).

4.2 비즈니스 오류 vs SOAP Fault — 이중 오류 경로

응답 처리는 항상 .fold(success, failure)로 두 갈래다.

flowchart TD
    EX["execute AmadeusndcResponse T (...)"]
    EX --> FOLD{"fold"}
    FOLD -->|"success — HTTP 200 + 파싱 성공"| S1["response.body.checkError { ... }<br/>전문 내부 Error 또는 errorMessage 검사"]
    S1 --> S2["response.body.toXxx()<br/>도메인 변환"]
    FOLD -->|"failure — HTTP 오류 또는 타임아웃 또는 SOAP Fault"| F1["it.handleSoapFaultException(ErrorMessage.XXX)"]
  • 비즈니스 오류: 전문 body 안의 오류 요소. 각 RS의 checkError(...)가 검사하고 InternationalAdapterException으로 변환. 전문 세대별로 오류 표현이 다르다(아래 표).
  • 전송/프로토콜 오류: handleSoapFaultException(ClientSupport.kt:62)이 응답 원문에서 soap(errorData).soapBody.fault를 꺼내 InternationalAdapterException.of(... soapFault ...)로 만든다. 타임아웃은 OkHttpError.isTimeout으로 판별돼 timeoutCallback()을 부른다(발권/취소에서 중요 — 결과 불확실 상태 보정).
전문오류 요소 위치checkError 시그니처 (file:line)특수 처리
Fare_MasterPricerTravelBoardSearchReplyerrorMessage (단수)...reply/...Reply.kt:79textSubjectQualifier=="WRN"이면 무시(경고). 코드 866/931/977/996/118/950/9212/9211은 “결과 없음”으로 보고 통과(AmadeusndcClient.kt:120)
AMA_TravelOrderViewRSErrors/Error (복수)orderview/OrderViewRS.kt:35"ORDER ALREADY CANCELLED"ALREADY_CANCELED_PNR.noRetry(); 응답 Warning “INVOLUNTARY CHANGE…” → NON_RETRIEVABLE_SCHEDULE_STATUS.noRetry() (OrderViewRS.kt:167)
AMA_TravelOfferPriceRSError (복수)offerprice/OfferPriceRS.kt:23코드 65010/490 → SOLD_OUT
AMA_TravelOrderReshopRSErrors/Errororderreshopreply/OrderReshopRS.kt:22,28코드 41913 → “검색 가능한 스케줄이 없습니다”
AMA_TravelOrderCancelRSErrorordercancel/OrderCancelRS.kt:19
Queue_*Replycode/message 쌍queue*reply큐 카운트/접근/이동/삭제별 ErrorMessage

5. 전문 카탈로그 — 루트 엘리먼트 / namespace / SOAP Action

메인 채널이 주고받는 전문 전체 목록. 검색·큐는 1A(GDS), 나머지는 NDC임이 namespace에서 그대로 드러난다.

오퍼레이션요청 루트 (namespace)SOAP Action (action)응답 루트세대
검색Fare_MasterPricerTravelBoardSearch (.../FMPTBQ_23_1_1A).../FMPTBQ_23_1_1AFare_MasterPricerTravelBoardSearchReply (.../FMPTBR_18_1_1A)1A GDS
가격(OfferPrice)AMA_TravelOfferPriceRQ (.../Travel_OfferPriceRQ_v1).../Travel_OfferPrice_1.3AMA_TravelOfferPriceRSNDC
예약(OrderCreate)AMA_TravelOrderCreateRQ (.../Travel_OrderCreateRQ_v1).../Travel_OrderCreate_1.7AMA_TravelOrderViewRSNDC
발권(OrderPay)AMA_TravelOrderPayRQ (.../Travel_OrderPayRQ_v1).../Travel_OrderPay_1.7AMA_TravelOrderViewRSNDC
조회(OrderRetrieve)AMA_TravelOrderRetrieveRQ (.../Travel_OrderRetrieveRQ_v1).../Travel_OrderRetrieve_1.7AMA_TravelOrderViewRSNDC
취소(OrderCancel)AMA_TravelOrderCancelRQ (.../Travel_OrderCancelRQ_v1).../Travel_OrderCancel_1.0AMA_TravelOrderCancelRSNDC
분리/재발행(OrderChange)AMA_TravelOrderChangeRQ (.../Travel_OrderChangeRQ_v1).../Travel_OrderChange_1.9AMA_TravelOrderViewRSNDC
재발행검색/취소수수료(OrderReshop)AMA_TravelOrderReshopRQ (.../Travel_OrderReshopRQ_v1).../Travel_OrderReshop_1.6AMA_TravelOrderReshopRSNDC
큐 카운트Queue_CountTotal (.../QCSDRQ_13_1_1A).../QCSDRQ_13_1_1AQueue_CountTotalReply1A GDS
큐 목록Queue_List (.../QDQLRQ_11_1_1A).../QDQLRQ_11_1_1AQueue_ListReply1A GDS
큐 이동Queue_MoveItem (.../QUQMUQ_03_1_1A).../QUQMUQ_03_1_1AQueue_MoveItemReply1A GDS
큐 삭제Queue_RemoveItem (.../QUQMDQ_03_1_1A).../QUQMDQ_03_1_1AQueue_RemoveItemReply1A GDS

OrderView가 응답을 공유한다

예약(book)·발권(savePayment)·조회(retrieve)·분리/재발행(divide/reissue)이 모두 AMA_TravelOrderViewRS를 받는다. 따라서 OrderViewRS.toBooking()/toReissueBooking()이 NDC 예약 도메인 변환의 단일 진입점이고, 변경 한 줄이 4개 오퍼레이션에 전파된다(amadeusndc-operations 참고).


6. 핵심 요청 전문 모델·필드 매핑

전문은 Jackson XML 어노테이션으로 매핑된다(@JacksonXmlRootElement/@JacksonXmlProperty/@JacksonXmlElementWrapper(useWrapping=false)). 모든 RQ는 companion object.of(...)로 도메인 입력에서 조립한다.

6.1 검색 — FareMasterPricerTravelBoardSearch (request/faremasterpricertravelboardsearch/)

@JacksonXmlRootElement(localName="Fare_MasterPricerTravelBoardSearch",
                       namespace="http://xml.amadeus.com/FMPTBQ_23_1_1A")
@JsonPropertyOrder(["numberOfUnit","paxReference","fareOption","travelFlightInfo","itinerary"])
data class FareMasterPricerTravelBoardSearch(...)
전문 필드도메인 입력의미
numberOfUnit성인+소아 좌석 수, advancedOption.ratio응답 추천 개수/비율 제어
paxReference (list)preference.passenger ADT/CH/INF승객타입·인원. PaxReference.ofAdult/ofChild/ofInfant
travelFlightInfocabins, airlines, onlyDirect좌석등급·항공사 필터·직항 여부
itinerary (list)originDestinations구간별 출발/도착/날짜
fareOptionsinfant 수, onlyFreeBaggageInclude, promotionCodes(corporateIds), inclusiveTour운임옵션·기업운임·무료수하물 강제

@JsonPropertyOrder가 곧 스키마 순서다

Amadeus FMPTBQ XSD는 엘리먼트 순서가 엄격하다. 코드는 @JsonPropertyOrder(["numberOfUnit","paxReference","fareOption","travelFlightInfo","itinerary"])로 직렬화 순서를 못 박는다(필드 선언 순서와 다름에 주의). 순서가 어긋나면 1A가 거부한다.

6.2 가격 — OfferPriceRQ (request/offerprice/)

@JacksonXmlRootElement(localName="AMA_TravelOfferPriceRQ",
                       namespace="http://xml.amadeus.com/2010/06/Travel_OfferPriceRQ_v1")
data class OfferPriceRQ(val party: Party, val request: Request)
  • Party = Sender > TravelAgency(AgencyID=iataCode, PseudoCityID=officeId) (offerprice/Party.kt). NDC 전문은 헤더의 AMA_SecurityHostedUser별도로 body 안에도 대리점 식별(POS)을 다시 넣는다.
  • Request = DataLists(승객운임) + PricedOffer(검색에서 받은 offer 참조). 검색 결과(FareItinerary.ndcReference: shoppingId/offerId/offerItems)를 그대로 되돌려 보내 가격을 확정한다 → OfferPrice는 검색 응답 식별자에 강결합.

6.3 예약 — OrderCreateRQ (request/ordercreate/)

  • 루트 AMA_TravelOrderCreateRQ. Request = CreateOrder(offerPriceInfo) + DataLists(passengers).
  • 승객 분리 로직: passengerModels.partition { it.isInfant }로 유아/비유아를 나누고, 비유아 승객에 유아 ID를 결합(OrderCreateRQ.kt:32). 유아는 좌석 없는 동반자라 별도 PTC로 묶인다.

6.4 조회 — OrderRetrieveRQ (request/orderretrieve/)

of가 두 오버로드:

  • PNR 기반(OrderFilterCriteria.of(pnr)),
  • supplierIdentificationKey(orderId)+validatingCarrier 기반(OrderFilterCriteria.of(orderId, owner)).

retrieveByPnr@Retryable(maxAttempts=3, backoff=2000ms, exceptionExpression="@amadeusndcClient.shouldRetrieveRetryable(#root)")ApiException.retryable이 false면 재시도 중단(AmadeusndcClient.kt:279,370).

6.5 재발행/분리 — OrderChangeRQ (request/orderchange/)

  • ofDivide: ChangeOrder.ofSplit(passengerReferenceIds) — PNR 승객 분리.
  • ofReissue: ChangeOrder.ofReissue(...) + PaymentInfo(OrderChangeRQ.kt:46). 재발행은 결제정보까지 한 전문에 싣는다.

7. 보조 클라이언트 ① ART — 운임규정 (REST/JSON)

운임규정은 NDC 전문이 아니라 Amadeus ART(Automated Rules Translation) REST API로 받는다. SOAP envelope·WS-Security 전혀 없음.

// NdcArtClient.kt:44
"${art.endpoint}/api/v1/art/getrule/${art.agentCode}/${officeId}"
    .post(request)                                  // JSON body
    .header(Content-Type=application/json, "x-api-key"=art.apiKey)
    .execute<ArtResponse>()
항목내용
메서드@Retryable(maxAttempts=2) getFareRules(adult,child,infant,fareItinerary)
인증HTTP 헤더 x-api-key (envelope 인증 아님)
요청 모델ArtRequest > FareRuleRequest(agtuuid=UUID, data=FlightRuleData) (art/request/ArtRequest.kt)
응답 모델ArtResponse(errorType, errorMessage, farerulers) (art/response/ArtResponse.kt)

FlightRuleData는 약어 JSON 키가 많아 처음엔 난독스럽다 — 주요 키:

JSON 키필드의미
stockaircdvalidationCarrier발권(validating) 항공사
aircdmarketingCarriers마케팅 항공사
bkgclassbookingClasses예약 클래스
shoppingresponseid / offerid / offeritemsidfareItinerary.ndcReference.*NDC 식별자 — ART도 NDC offer 컨텍스트를 요구
faretypegdsFareTypesR로 시작·2자 운임타입 최대 2개를 ^로 연결 (toArtRequest())
lang”ko” 고정한글 번역
svctype”1A” 고정Amadeus
diflag”D”/“I”국내출발/해외출발

응답은 farerulers.statusinfo.returncd != "200"이거나 data가 null이면 오류(FareRuleResponse.checkError, ArtResponse.kt:34). 정상 시 rulegrp별로 정렬(적용구간/항공사 수수료/수하물/...)해 도메인 FareRule로 변환.

ART 운임타입 코드 추출 규칙 ( ArtRequest.kt:194)

val fareTypes = this.filter { it.startsWith("R") && it.length == 2 }...
if (fareTypes.size == 1) this.find { it=="MSP" || it=="SF" }?.run { fareTypes.add(this) }
return fareTypes.take(2).joinToString("^")

“R”로 시작하는 2자 코드가 없으면 MethodArgumentInvalidException(INTERNAL_SERVER_ERROR). 운임타입 데이터가 비면 규정 조회 자체가 실패한다.


8. 보조 클라이언트 ② GPS — KAL 카드검증 (별도 SOAP)

대한항공(KAL) 발권 시 카드 유효성/승인을 받는 별도 SOAP 서비스. NDC와 무관한 KAL 자체 게이트웨이다.

// GpsClient.kt:99
"${gps.endpoint}/GPS_Approval_RequestService"
    .post(request)
    .header(Content-Type=text/xml)
    .requestBodyConvert(offlineSoapRequestBodyConverter)   // WS-Security 없는 단순 envelope
    .execute<ApprovalResponse>(soapBodyDeserializerOf(...))
항목내용
빈 이름@Component("amadeusndcGpsClient") — sabre 등 다른 모듈과 충돌 방지
envelopeofflineSoapRequestBodyConverter = soap { body(...) } + xmlns="" 제거. WS-Security/Addressing 헤더 없음
요청 루트app:ApprovalRequestService, ns http://AppChannel.webservice.gps.kal.com/ (gps/request/GpsRequest.kt)
인증헤더 아닌 body GeneralInfo.authentication = "SSL{식별번호}"
응답ApprovalRequestServiceResponse > return > Response(resultCode, resultMessage, cardType)

GeneralInfo는 KAL 특화 상수 다수: tid="KE", paymentType="VAN", currency="KRW", domIntType="I"(국제선), status="U"(유효성체크). 성공 코드 화이트리스트(0,VNV20000,VNV30000,… — GpsResponse.kt:24)에 없으면 MethodArgumentInvalidException(PAYMENT_ETC). 성공 시 cardType(vendorCode)을 뽑아 VerifiedCardInfo로 변환해 발권에 사용한다.

카드 비밀번호 필드 오탈자에 주의

GeneralInfo의 카드 비밀번호 XML 태그는 cardPasssword(s 3개) — Amadeus/KAL 측 스펙 그대로다(GpsRequest.kt:95). 임의로 고치면 검증 실패. 코드 변경 금지 항목.


9. XSD 스키마 / 실제 전문 예시 위치

amadeusndc 전용 XSD·mockData는 저장소에 없다

확인 결과:

  • src/test/schema/에 amadeus, galileo, koreanair, lufthansa, sabre, singaporeair, jinair 디렉터리는 있으나 amadeusndc는 없음.
  • src/test/resources/mockData/에는 amadeus/no-farebasis-tst.xml 하나만 존재(클래식 Amadeus용). amadeusndc 샘플 전문/테스트 픽스처 없음.
  • amadeusndc 패키지의 단위/통합 테스트 파일도 없음(src/test 하위 amadeusndc 디렉터리 부재).

따라서 전문 구조의 “정답지”는 (1) 코드의 Jackson 어노테이션(루트/필드/순서), (2) action·namespace 문자열, (3) 기존 Amadeus GDS 분석 문서(검색/큐는 동일 1A 전문)로 역추적해야 한다. NDC 전문(AMA_TravelOrder*)의 외부 XSD는 Amadeus 개발자 포털 자료를 별도 참조.

src/test/schema에 존재하는 NDC 계열 참고용 스키마(amadeusndc는 아니지만 NDC 전문 형태 이해에 유용):

  • koreanair/NDC_V21.3_Schema_V2025.2
  • lufthansa/NDC_V17.2_Schema_V2023.3
  • singaporeair/18_1_EDIST_schemas

10. 온보딩 체크포인트

셀프 점검 — 전문 흐름을 손으로 그려보기

  1. book() 호출 시 만들어지는 SOAP envelope의 header 블록 4개를 순서대로 적어보라.
  2. 같은 호출의 SOAP Action 값과 body 루트 엘리먼트의 namespace를 말해보라.
  3. 검색과 가격(OfferPrice) 전문의 namespace가 다른 이유는?

[!answer]- 정답 보기

  1. wsa:Towsa:Actionwsa:MessageID(urn:uuid) → oas:Security(UsernameToken: Username/Nonce/Created/Password-digest) → AMA_SecurityHostedUser(UserID, PseudoCityCode=officeId). (AmadeusndcClient.kt:793)
  2. Action = http://webservices.amadeus.com/Travel_OrderCreate_1.7, 루트 = AMA_TravelOrderCreateRQ ns http://xml.amadeus.com/2010/06/Travel_OrderCreateRQ_v1.
  3. 검색은 클래식 1A GDS 전문(Fare_MasterPricerTravelBoardSearch, FMPTBQ)이고, 가격·예약·발권만 NDC(AMA_TravelOrder*)이기 때문. 이 모듈은 하이브리드다.

관련 노트