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·인증·직렬화 방식이 다르므로 처음 보는 사람은 반드시 구분해야 한다.
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()의 기본)을 따른다.
getFormattedTime(now("UTC")): yyyy-MM-dd'T'HH:mm:ss.SSS'Z'. 반드시 UTC — AmadeusndcClient.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로 나간다 — 디버깅 시 반드시 의식할 것.
응답 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다.
예약(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(...)
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는 검색 응답 식별자에 강결합.
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). 임의로 고치면 검증 실패. 코드 변경 금지 항목.
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. 온보딩 체크포인트
셀프 점검 — 전문 흐름을 손으로 그려보기
book() 호출 시 만들어지는 SOAP envelope의 header 블록 4개를 순서대로 적어보라.
같은 호출의 SOAP Action 값과 body 루트 엘리먼트의 namespace를 말해보라.