Amadeus — 프로토콜·전문
module-amadeus arch-protocol api-soap pattern-stateful-session
한 줄 요약
Amadeus 모듈은 3개의 서로 다른 전송 프로토콜을 하나의 모듈 안에서 동시에 사용한다. (1) 코어 GDS는 SOAP/XML over HTTPS + stateful PNR 세션(TOPAS·1A WebServices), (2) 운임규정(FareRule)은 TOPAS의 ART REST/JSON API, (3) 결제·현금영수증은 대한항공 GPS VAN SOAP API. 이 노트는 이 3계층의 실제 전송 방식·인증·세션·전문 모델·XSD·샘플 전문을 코드 근거와 함께 정밀 해부한다.
관련 노트: 오퍼레이션 카탈로그 · 지뢰밭 · 모듈 개요 · 인터페이스·DTO 기존 분석문서: amadeus-gds
1. 큰 그림 — 한 모듈, 세 개의 프로토콜
infrastructure 패키지를 열면 클라이언트가 3개라는 점이 가장 먼저 눈에 띈다. 각각 다른 외부 시스템 / 다른 프로토콜 / 다른 인증을 담당한다.
flowchart LR APP["application 계층<br/>(service)"] subgraph INFRA["supplier/amadeus/infrastructure"] AC["AmadeusClient<br/>(코어 GDS)"] ART["ArtClient<br/>(운임규정)"] GPS["GpsClient<br/>(결제/VAN)"] end TOPAS["TOPAS 1A WebServices<br/>PNR·TST·Queue·Refund"] ARTAPI["TOPAS ART API<br/>/api/v1/art/getrule/..."] KAL["KAL GPS VAN<br/>GPS_Approval_RequestSvc"] APP --> AC APP --> ART APP --> GPS AC -->|"SOAP/XML (stateful)"| TOPAS ART -->|"REST/JSON (stateless)"| ARTAPI GPS -->|"SOAP/XML (stateless)"| KAL
AmadeusClient담당: Search / Booking / Ticketing / Queue / RefundArtClient담당: FareRule (운임규정)GpsClient담당: 결제승인 / 취소 / 현금영수증
| 클라이언트 | 파일 | 프로토콜 | 직렬화 | 인증 | 세션 | 담당 오퍼레이션 |
|---|---|---|---|---|---|---|
AmadeusClient | infrastructure/AmadeusClient.kt | SOAP 1.1 over HTTPS | XML (xmlMapper) | WS-Security PasswordDigest + AMA_SecurityHostedUser | Stateful (Start→InSeries→End) | Search, Booking, Ticketing, Queue, Refund, PNR 조작 전부 |
ArtClient | infrastructure/topas/ArtClient.kt | REST over HTTPS | JSON (objectMapper) | HTTP 헤더 x-api-key | Stateless | FareRule(운임규정) |
GpsClient | infrastructure/topas/GpsClient.kt | SOAP over HTTPS | XML (xmlMapper) | 본문 내 office/iata/userId 식별자 | Stateless | 키인 카드 결제 승인/취소, 현금영수증 |
신입이 가장 먼저 혼동하는 지점
“Amadeus = SOAP” 가 절반만 맞다. 운임규정은 JSON REST(
ArtClient), 결제는 KAL 사 VAN SOAP(GpsClient)이다. 같은 패키지(topas/)에 있지만 ART와 GPS는 전혀 다른 서버·전혀 다른 전문이다. 결제 오퍼레이션 디버깅 시 Amadeus SOAP 로그를 아무리 봐도 안 나오는 이유가 이것이다. → amadeus-pitfalls 참고.
2. 코어 GDS — SOAP/XML 전송 방식
2.1 요청 전문은 “코드가 직접 조립한 SOAP Envelope”
이 시스템은 WSDL/JAX-WS 스텁을 쓰지 않는다. SOAP Envelope를 Jackson XML 직렬화 + 커스텀 SOAP DSL로 손수 조립한다. 핵심은 AmadeusClient.soapRequestBodyConverter() 이다 (AmadeusClient.kt:1327-1417).
// AmadeusClient.kt:1331 (요약)
soap {
val session = (request as AmadeusRequest).session
val isStart = session?.let { it.transactionStatusCode === TransactionStatusCode.Start } ?: true
header {
headerElement("To", ADDRESSING) { text { ...endpoint } }
headerElement("Action", ADDRESSING) { text { request.action } } // = SOAPAction
headerElement("MessageID", ADDRESSING) { text { "urn:uuid:${UUID.randomUUID()}" } }
if (isStart) { headerElement("Security", WS_SECURITY_OAS) { ... } } // 인증은 첫 호출에만
if (session != null) { headerElement("Session", SESSION) { ... } } // 세션은 있을 때만
if (isStart) { headerElement("AMA_SecurityHostedUser", ...) { ... } } // 오피스/POS도 첫 호출에만
}
body(objectMapper.writeValueAsBytes(request).inputStream()) // Body = 전문 DTO를 XML로
}.replace(" xmlns=\"\"", "") // 빈 네임스페이스 제거전송 흐름을 그림으로:
flowchart TD A["요청 DTO (@JacksonXmlRootElement)"] B["soap header body 합성<br/>SOAP DSL (SoapExtensions.kt)"] C["String (SOAP Envelope)"] D["TOPAS 1A WebServices endpoint"] E["AmadeusResponse<T> (session, body)"] A -->|"xmlMapper.writeValueAsBytes() 로 Body XML 조각 생성"| B B -->|"빈 네임스페이스 제거 (중요)"| C C -->|"ClientSupport OkHttp POST, Content-Type text/xml, SOAPAction 헤더"| D D -->|"응답 SOAP Envelope, soapBodyDeserializerOf 가 Header awsse:Session 와 Body 전문 파싱"| E
- 빈 네임스페이스 제거 처리:
.replace(" xmlns=\"\"","")— Jackson이 Body 조각에 남긴 빈 기본 네임스페이스를 강제 치환 (생략 시 SOAP Fault) - HTTPS 전송: 요청은 endpoint로 POST, 응답은 동일 채널로 SOAP Envelope 수신
빈 네임스페이스 제거
.replace(" xmlns=\"\"", "")Jackson XML이 Body 조각을 만들 때 자식 엘리먼트에
xmlns=""(빈 기본 네임스페이스)를 흘리는 경우가 있는데, 이게 그대로 가면 Amadeus 파서가 거부한다. 그래서soapRequestBodyConverter(AmadeusClient.kt:1415)와GpsClient.offlineSoapRequestBodyConverter(GpsClient.kt:56) 양쪽 모두 마지막에 이 문자열을 강제 치환한다. 새 전문 추가 시 이 줄을 빼먹으면 원인 모를 SOAP Fault가 난다.
2.2 HTTP 헤더와 전송 계층
전송은 ClientSupport(OkHttp 래퍼, support/web/ClientSupport.kt)가 담당한다. Amadeus 코어 요청 헤더는 getHeaderMap()(AmadeusClient.kt:1419):
mapOf(
HttpHeaders.CONTENT_TYPE to MediaType.TEXT_XML_VALUE, // text/xml
HttpHeaders.ACCEPT_ENCODING to "gzip,deflate",
"SOAPAction" to request.action // 메시지별 고유값
)타임아웃은 AmadeusClient가 ClientSupport를 상속하며 지정한다(AmadeusClient.kt:91-95): searchTimeout=25000ms, defaultTimeout=60000ms. 검색만 별도 searchClient(짧은 타임아웃) OkHttp 인스턴스를 쓴다(AmadeusClient.kt:141 .client(searchClient)).
2.3 SOAPAction = 메시지 식별자 (전문 카탈로그)
각 요청 DTO는 AmadeusRequest 인터페이스(request/AmadeusRequest.kt)를 구현하며 action(=SOAPAction)을 고정값으로 노출한다. 이 값이 곧 Amadeus 메시지 타입 + 버전이다. 전체 목록(코드에서 추출):
| 오퍼레이션(클라이언트 메서드) | SOAPAction (request.action) | 메시지 약어/버전 |
|---|---|---|
검색 search() | .../FMPTBQ_18_1_1A | Fare_MasterPricerTravelBoardSearch v18 |
운임확정 pricing() | .../TPCBRQ_18_1_1A | Fare_PricePNRWithBookingClass v18 |
TST 생성 tstCreate() | .../TAUTCQ_04_1_1A | Ticket_CreateTSTFromPricing v4 |
TST 수정 tstUpdate() | .../TTSTUQ_15_1_1A | Ticket_UpdateTST v15 |
TST 조회 getPnrFares() | .../TTSTRQ_15_1_1A | Ticket_DisplayTST v15 |
좌석확보 markSeat() | .../ITAREQ_05_2_IA | Air_SellFromRecommendation v5 |
PNR 추가/저장 save*() confirmPnr() | .../PNRADD_17_1_1A | PNR_AddMultiElements v17 |
PNR 조회 retrieve() | .../PNRRET_17_1_1A | PNR_Retrieve v17 |
PNR 취소/요소삭제 pnrCancel() removeElements() | .../PNRXCL_17_1_1A | PNR_Cancel v17 |
PNR 분리 splitPnr() | .../PNRSPL_21_1_1A | PNR_Split v21 |
PNR 히스토리 getPnrInfoHistory() | .../PHIDRQ_16_1_1A | PNR_DisplayHistory v16 |
발권 ticketing() | .../TTKTIQ_15_1_1A | DocIssuance_IssueTicket v15 |
결제(FOP) savePaymentInfo() | .../TFOPCQ_15_4_1A | FOP_CreateFormOfPayment v15 |
키인 결제(crypt) approveByKeyIn() | .../HSFREQ_07_3_1A | Command_Cryptic v7 |
항공권 e-Doc 조회 getPnrTicketDocuments() | .../TATREQ_15_2_1A | Ticket_ProcessEDoc v15 |
항공권 VOID void() | .../TRCANQ_11_1_1A | Ticket_CancelDocument v11 |
환불산정 initRefund() | .../TRFSRQ_14_1_1A | DocRefund_InitRefund v14 |
환불수정 updateRefund() | .../TRFUUQ_13_1_1A | DocRefund_UpdateRefund v13 |
환불확정 processRefund() | .../TRFPCQ_13_1_1A | DocRefund_ProcessRefund v13 |
큐 카운트 getPnrCountsInQueue() | .../QCSDRQ_13_1_1A | Queue_CountTotal v13 |
큐 목록 getPnrsInQueue() | .../QDQLRQ_11_1_1A | Queue_List v11 |
큐 이동 moveQueuePnrs() | .../QUQMUQ_03_1_1A | Queue_MoveItem v3 |
큐 삭제 removePnrsInQueue() | .../QUQMDQ_03_1_1A | Queue_RemoveItem v3 |
세션 종료 signOut() | .../VLSSOQ_04_1_1A | Security_SignOut v4 |
Body 루트 엘리먼트 = 메시지명 + 버전 네임스페이스
각 요청 DTO에는
@JacksonXmlRootElement(localName=..., namespace="http://xml.amadeus.com/<약어>_<버전>")가 붙는다. 예:PnrRetrieve(request/pnrretreive/PnrRetrieve.kt:9-12)는localName="PNR_Retrieve", namespace="http://xml.amadeus.com/PNRRET_17_1_1A". 즉 SOAPAction 헤더와 Body 루트 네임스페이스의 버전이 짝을 이뤄야 한다.
3. 인증 — WS-Security PasswordDigest
코어 GDS 인증은 표준 WS-Security UsernameToken PasswordDigest 방식이다. soapRequestBodyConverter가 세션 시작 호출(isStart)일 때만 <oas:Security> 헤더를 붙인다(AmadeusClient.kt:1346-1373).
<oas:Security>
<oas:UsernameToken oas1:Id="UsernameToken-2">
<oas:Username>{userName}</oas:Username>
<oas:Nonce EncodingType="...#Base64Binary">{nonce}</oas:Nonce>
<oas1:Created>{UTC ISO8601 millis}</oas1:Created>
<oas:Password Type="...#PasswordDigest">{digest}</oas:Password>
</oas:UsernameToken>
</oas:Security>digest 계산은 support/util/PasswordDigest.kt:
// PasswordDigest.getPasswordDigestFromClearTextPW (요약)
// digest = Base64( SHA1( base64decode(nonce) ++ created.bytes ++ SHA1(clearPassword) ) )
sha1.update(Base64.getDecoder().decode(nonce.toByteArray())) // nonce는 디코드해서 raw bytes로
sha1.update(created.toByteArray()) // created는 문자열 그대로
Base64.encode(sha1.digest( SHA1(clearPassword) )) // 비밀번호는 SHA1 선해시| 요소 | 생성 | 비고 |
|---|---|---|
| nonce | PasswordDigest.genNonce() | SHA1PRNG 32바이트 SecureRandom → Base64 |
| created | PasswordDigest.getFormattedTime(now("UTC")) | yyyy-MM-dd'T'HH:mm:ss.SSS'Z', UTC |
| password digest | getPasswordDigestFromClearTextPW(nonce, created, pw) | 위 공식. 비밀번호는 먼저 SHA-1 해시 후 사용 |
POS/오피스 식별은 별도 헤더 AMA_SecurityHostedUser로 전달된다(AmadeusClient.kt:1398-1411). AgentDutyCode="SU", POS_Type="1", RequestorType="U", PseudoCityCode는 온라인이면 officeId, 큐 작업처럼 useOfflineOid=true면 offlineOid를 쓴다.
인증·세션·오피스는 "첫 호출에만" 보낸다
if (isStart)블록이 두 번 등장한다:<Security>(인증)와<AMA_SecurityHostedUser>(오피스/POS). 세션이 열린 뒤(InSeries/End)에는 보내지 않는다 — 이미SecurityToken이 자격을 대신하기 때문. 신규 stateless 단발 호출(큐 계열)은session==null이라 항상isStart=true로 인증을 매번 동봉한다.
네임스페이스 정의
모든 SOAP 헤더 네임스페이스는 enum support/enums/AmadeusSoapHeaderNamespace.kt에 집중되어 있다.
| enum | namespace | 용도 |
|---|---|---|
ADDRESSING (wsa) | http://www.w3.org/2005/08/addressing | To / Action / MessageID |
WS_SECURITY_OAS (oas) | ...oasis-200401-wss-wssecurity-secext-1.0.xsd | Security / UsernameToken / Username / Nonce / Password |
WS_SECURITY_OAS1 (oas1) | ...wssecurity-utility-1.0.xsd | Created / Id 속성 |
SESSION | http://xml.amadeus.com/2010/06/Session_v3 | <awsse:Session> |
AMA_SECURITY_HOSTED_USER | http://xml.amadeus.com/2010/06/Security_v1 | 오피스/POS |
PASSWORD_TYPE | ...#PasswordDigest | Password Type 속성값 |
NONCE_ENCODING_TYPE | ...#Base64Binary | Nonce EncodingType 속성값 |
4. 세션 — Stateful PNR 트랜잭션의 핵심
Amadeus GDS는 PNR을 만들고 발권하는 일련의 호출이 하나의 서버 측 세션으로 묶이는 stateful 모델이다. 이 모듈의 가장 중요한 설계 포인트이며, 다른 NDC/LCC 모듈과 결정적으로 다른 점이다.
4.1 세션 전문 모델 Session
infrastructure/Session.kt:
data class Session(
@JacksonXmlProperty(isAttribute = true, localName = "TransactionStatusCode")
val transactionStatusCode: TransactionStatusCode, // Start | InSeries | End (속성)
@JacksonXmlProperty(localName = "SessionId") val sessionId: String? = null,
@JacksonXmlProperty(localName = "SequenceNumber") val sequenceNumber: Int? = null,
@JacksonXmlProperty(localName = "SecurityToken") val securityToken: String? = null,
)TransactionStatusCode(support/enums/TransactionStatusCode.kt)는 단 3값: Start, InSeries, End.
4.2 세션 생명주기와 시퀀스 증가
세션 상태 머신은 support/util/StatefulBuilder.kt가 캡슐화한다. 응답 헤더의 세션을 받아 다음 요청에 그대로 되돌려주는 패턴이다.
stateDiagram-v2 [*] --> Start: "요청 TransactionStatusCode Start (SessionId/Token 없음)" Start --> InSeries: "응답 awsse:Session SessionId=ABC, SequenceNumber=1, SecurityToken=XYZ → receiveSession()" InSeries --> InSeries: "요청 InSeries, SequenceNumber +1 (반복: markSeat → save → pricing → tstCreate → ticketing)" InSeries --> End: "요청 TransactionStatusCode End (마지막 시퀀스)" End --> [*]: "서버 측 세션 종료"
- Start 요청:
<Session TransactionStatusCode="Start">, SessionId/Token 없음. 응답의<awsse:Session>이AmadeusResponse.session으로 파싱되어statefulBuilder.receiveSession()로 회수 - InSeries 요청: 같은 SessionId/SecurityToken 유지, SequenceNumber는 직전 응답 seq +1. 응답마다 다시
receiveSession() - End 요청: 마지막 시퀀스로 서버 측 세션 종료
- 명시적 종료는
signOut(SOAPActionVLSSOQ)으로도 가능
StatefulBuilder.withSession() 로직(StatefulBuilder.kt:10-19):
Start→ 새Session(transactionStatusCode=Start)(id/seq/token 전부 null)- 그 외 → 기존 세션을
copy(transactionStatusCode=..., sequenceNumber = 직전 seq + 1)← 클라이언트가 시퀀스를 +1 증가
AmadeusClient의 거의 모든 메서드는 statefulBuilder?.session을 요청 DTO에 .withSession(...)로 주입하고(AmadeusRequest.withSession, request/AmadeusRequest.kt:13), 성공 시 statefulBuilder?.receiveSession(response.session)로 응답 세션을 회수한다. 예: markSeat(AmadeusClient.kt:253,261), pricing(584,591), ticketing(911,919).
응답 측에서 세션을 꺼내는 곳은 soapBodyDeserializerOf(AmadeusClient.kt:97-109):
soap(content).let {
AmadeusResponse(
session = it.soapHeader(mapper, "awsse:Session"), // 헤더에서 세션 추출
body = it.soapBody(mapper) // 바디에서 전문 추출
)
}시퀀스를 클라이언트가 관리한다
SequenceNumber는 서버가 매기는 게 아니라 클라이언트가 직전 응답 seq에 +1 해서 보낸다(StatefulBuilder.kt:16). 호출 순서가 꼬이거나 세션을 공유/재사용하면 시퀀스 불일치로 세션이 깨진다. 또한Session은data class라 thread-safe하지 않다 — 하나의StatefulBuilder를 동시에 여러 스레드가 만지면 안 된다. → amadeus-pitfalls비동기 흐름이 어떻게 stateful 세션과 공존하는지는 async-coroutines 참고.
4.3 PNR 액션 코드 (PNR_AddMultiElements의 세션 명령)
PNR_AddMultiElements(가장 다용도 메시지) 호출은 본문의 PnrAction에 PnrActionCode를 실어 “이 호출에서 무엇을 할지”를 지시한다. AmadeusClient가 사용하는 액션 코드:
| 메서드 | PnrActionCode | 의미 |
|---|---|---|
savePnrWithRetrieve / saveCancel | END_TRANSACTION_WITH_RETRIEVE | EOT 후 PNR 재조회 |
savePnrWithShowWarnings | END_TRANSACTION + SHOW_WARNINGS_AT_FIRST_EOT | EOT + 첫 경고 노출 |
saveSplitPnr | END_TRANSACT_SPLIT_PNR + SHOW_WARNINGS_AT_FIRST_EOT | 분리 PNR EOT |
confirmPnr | END_TRANSACT_WITH_RETRIEVE_AND_CHANGE_ADVICE_CODES | EOT + 재조회 + advice code 갱신 |
5. 응답 전문 모델과 필드 매핑
5.1 공통 응답 래퍼
// response/AmadeusResponse.kt
data class AmadeusResponse<T>(val session: Session?, val body: T)session은 SOAP 헤더의 awsse:Session(stateless 호출이면 null), body는 SOAP 바디의 전문 DTO다. 각 응답 DTO 역시 @JacksonXmlRootElement(localName="...Reply", namespace="http://xml.amadeus.com/<약어>R_<버전>") 로 매핑된다. (요청은 ...Q, 응답은 ...R. 예: 검색 요청 FMPTBQ_18_1_1A ↔ 응답 FMPTBR_18_1_1A, faremasterpricertravelboardsearchreply/FareMasterPricerTravelBoardSearchReply.kt:12.)
5.2 응답 디렉터리 구조 (전문별 1패키지)
infrastructure/response 아래에는 메시지별 응답 패키지가 23개 존재한다 (총 794개 파일의 모듈이며 대부분이 이 request/response 전문 DTO다). 대표 매핑:
| 응답 패키지 | 루트 DTO | 핵심 필드(요약) |
|---|---|---|
faremasterpricertravelboardsearchreply | FareMasterPricerTravelBoardSearchReply | flightIndexes, recommendations, serviceFeesGroups, errorMessage |
pnrreply | PnrReply | pnr, passengers, originDestinationDetails, schedules, generalErrorInfos |
farepricepnrwithbookingclassreply | FarePricePnrWithBookingClassReply | 운임 후보(AcceptableFare) |
ticketdisplaytst | TicketDisplayTstReply | fares(TST별 운임/세금) |
ticketprocessedocreply | TicketProcessEDocReply | docGroups(e-Ticket 문서) |
docrefundinitrefundreply / docrefundupdaterefund / docrefundprocessrefundreply | 환불 3단계 Reply | 취소수수료/환불금액 |
queuecounttotalreply / queuelistreply / queuemove / queueremove | 큐 Reply | 카테고리/카운트/PNR목록 |
5.3 응답 에러 판정 패턴 — checkError
이 모듈에는 중앙 디스패처가 없는 대신, 각 응답 DTO가 자기 자신의 에러를 판정하는 checkError(...) 멤버 함수를 갖는 일관된 패턴이 있다. 검색 응답 예(FareMasterPricerTravelBoardSearchReply.kt:62-77):
fun checkError(callback: ((code: String, message: String) -> Unit)? = null) {
if (errorMessage != null
&& errorMessage.errorMessageText?.freeTextQualification?.textSubjectQualifier != "WRN") { // 경고(WRN)는 무시
callback?.invoke(
errorMessage.applicationError.applicationErrorDetail.error, // 에러 코드
errorMessage.errorMessageText?.descriptions?.joinToString("::") // 메시지
) ?: throw InternationalAdapterException(SEARCH_FAILED, ...)
}
}호출부는 코드 화이트리스트로 “비어있음=정상”을 흡수한다(AmadeusClient.search, AmadeusClient.kt:151-160):
reply.checkError { code, message ->
when (code) {
"866","931","977","996","118","950" -> Unit // 결과없음 류는 정상 처리
else -> throw InternationalAdapterException(SEARCH_FAILED, code, message).capture()
}
}에러 코드 화이트리스트는 운영 지식의 응축
866/931/977/996/118/950은 “검색 결과 없음” 계열로, 예외가 아니라 빈 리스트로 처리한다. 이런 매직 넘버는 Amadeus 운영 노하우가 코드에 박힌 것 — 함부로 지우면 정상 무결과가 장애로 둔갑한다. 발권 재시도 분기(ticketing,AmadeusClient.kt:920-934)의"NO ALLOCATION FOR NN","Time Out","BAGGAGE ALLOWANCE MISSING"문자열 매칭도 같은 성격. → error-handling, amadeus-pitfalls
6. ART — TOPAS 운임규정 REST/JSON API
운임규정(FareRule)만은 SOAP가 아니라 REST/JSON이다. ArtClient(infrastructure/topas/ArtClient.kt).
6.1 전송
// ArtClient.findFareRules (요약, ArtClient.kt:39-47)
"${art.endpoint}/api/v1/art/getrule/${art.agentCode}/${officeId}"
.post(request) // ArtRequest (JSON)
.header(mapOf(
CONTENT_TYPE to APPLICATION_JSON_VALUE, // application/json
"x-api-key" to art.apiKey, // ★ 인증: API Key 헤더
))
.execute<ArtResponse>() // JSON 역직렬화 (objectMapper)- 프로토콜: REST over HTTPS, 직렬화 JSON(
@Qualifier("objectMapper")), 인증x-api-key헤더. @Retryable(maxAttempts=2)— Spring Retry로 1회 재시도.- 타임아웃 60s (
ArtClient.kt:24defaultTimeout=60000).
6.2 요청/응답 전문 (JSON)
ArtRequest(topas/ArtRequest.kt)는 Amadeus가 아니라 TOPAS(한국 GDS) 자체 규격이라 필드명이 한글 도메인 약어다. 핵심 매핑:
| JSON 키 | DTO 필드 | 의미 |
|---|---|---|
farerulerq → agtuuid | agentUUID | 요청 UUID (UUID.randomUUID()) |
depcitycd/arrcitycd | departures/arrivals | 구간 출발/도착 도시 |
stockaircd | validationCarrier | 발권항공사 |
aircd/flightno/bkgclass | marketingCarriers/flightNumbers/bookingClasses | 마케팅사/편명/예약클래스 |
faretype | gdsFareTypes | R.. 운임타입을 ^로 join (toArtRequest() ArtRequest.kt:179-193) |
svctype | svcType | "1A" 고정 (=Amadeus) |
diflag | diFlag | "D" 고정 (국내출발) |
lang | lang | "ko" 고정 (한글 규정) |
triptype | tripType | OW/RT/MD 자동 판별(ArtRequest.kt:155-162) |
응답 ArtResponse(topas/ArtResponse.kt)는 이중 에러 구조:
// ArtResponse.checkError (topas/ArtResponse.kt:15-24)
if (errorType != null) callback(errorMessage) // ① 전송/시스템 에러
if (fareRuleResponse == null) callback("Fare Rule Is Empty")
else fareRuleResponse.checkError(callback) // ② statusInfo.returncd != "200" → 비즈니스 에러규정 텍스트는 data.fareruletextinfogrp.rulegrp[].fareruleinfo[] → FareRuleInfo.toFareRule()로 변환되며, title 기준으로 FareRuleType(REFUND_AND_CHANGE/BAGGAGE/MILEAGE/COMMON)을 분류한다(ArtResponse.kt:83-96). 번역본(transrule)이 있으면 그것을, 없으면 원문(origincontent)을 쓴다.
7. GPS — 대한항공 VAN(결제/현금영수증) SOAP API
키인 카드 결제와 현금영수증은 Amadeus가 아니라 대한항공 GPS(VAN) 시스템으로 나간다. GpsClient(infrastructure/topas/GpsClient.kt).
7.1 전송
// GpsClient (요약)
"${gps.endpoint}/GPS_Approval_RequestService"
.post(ApprovalRequest.of...(...))
.header(mapOf(CONTENT_TYPE to TEXT_XML_VALUE)) // text/xml
.requestBodyConvert(offlineSoapRequestBodyConverter) // ★ 헤더 없는 단순 SOAP Envelope
.execute<ApprovalResponse>(soapBodyDeserializerOf(...)) // 바디만 파싱(세션 없음)- 프로토콜: SOAP over HTTPS, 직렬화 XML, 타임아웃 40s(
GpsClient.kt:30). - 인증: Amadeus식 WS-Security가 없다. 대신 본문
GeneralInfo의officeId/iataNumber/userId식별자로 식별.offlineSoapRequestBodyConverter(GpsClient.kt:53-57)는 SOAP 헤더 없이body만 채운 Envelope를 만든다(여기도.replace(" xmlns=\"\"","")). - 엔드포인트는
amadeusProperties.gps.endpoint(채널/펀넬 무관 단일값,AmadeusProperties.GpsProperties).
7.2 요청 전문 ApprovalRequest
루트 @JacksonXmlRootElement(localName="app:ApprovalRequestService") + 속성 xmlns:app="http://AppChannel.webservice.gps.kal.com/"(topas/GpsRequest.kt:14-26). 한 메시지(ApprovalRequestService)에 channel/status/type 값을 바꿔 5가지 동작을 표현한다:
| 팩토리 | channel | status | type | 동작 |
|---|---|---|---|---|
ofCardVerify | 1 | U | C | 카드 유효성 검증 |
ofApprove | 3 | A | C | 카드 결제 승인 |
ofCancel | 3 | R | C | 결제 취소 |
ofCashReceipt | 3 | A | H | 현금영수증 발행 |
ofCancelCashReceipt | 3 | R | H | 현금영수증 취소 |
고정 메타값(GeneralInfo): currency="KRW", errorLanguage="ENG", forcedApproval="N", requestSystem="IBE_OFF", domIntType="I"(국제). 현금영수증은 tktNumber를 AdditionalInfo(arg1)에 비-wrapping 리스트로 동봉(GpsRequest.kt:338-348).
7.3 응답 ApprovalResponse와 거대한 에러 코드 사전
ApprovalResponse(topas/GpsResponse.kt:19)는 루트 ApprovalRequestServiceResponse, 본문 <return> 매핑. 성공 판정은 화이트리스트(GpsResponse.kt:26-31):
val successCodes = listOf("0","VNV20000","VNV30000","VNV40000","IN120000",
"VNV45101","VNV45920","IN170000","VNV13201","VNKB0000")
if (!successCodes.contains(response.resultCode)) {
throw MethodArgumentInvalidException(GpsError.getErrorMessage(resultCode), resultCode, resultMessage)
.apply { if (errorMessage == PAYMENT_ETC) capture() } // 미분류 코드만 Slack 캡처
}GpsError(topas/GpsError.kt)는 VAN 결과코드 수천 개를 사용자 에러 메시지로 매핑하는 거대한 사전이다. 두 개의 hashSetOf(...) 묶음이 각각 ErrorMessage.PAYMENT_CREDIT_CARD_DENIAL(카드사 거절류), 다른 카테고리(한도초과/잔액부족 등)에 묶인다. 매칭 안 되면 PAYMENT_ETC로 떨어지고, 이때만 Slack 경보를 친다(.capture()).
GpsError는 "건드리면 안 되는" 운영 데이터
코드 700여 줄이 전부 VAN 코드 문자열이다. 새 코드가 미분류로
PAYMENT_ETC에 떨어지면 Slack 경보가 울리며, 이게 운영자가 “이 코드는 카드거절”이라고 분류해 사전에 추가하는 트리거가 된다. 이 사전을 함부로 줄이면 정상 결제가 장애로 보이거나 그 반대가 된다. → resilience-and-events, error-handling
키인 결제 흐름은 GPS 외에 Amadeus Command_Cryptic(approveByKeyIn, AmadeusClient.kt:206)도 사용하며, 그 에러는 별도 AmadeusKeyInError(infrastructure/AmadeusKeyInError.kt)가 MAXIMUM EXCEEDED/INSTALLMENT NOT ALLOWED/INSUFFICIENT FUNDS 문자열을 매핑한다.
8. XSD 스키마 — 위치와 핵심 타입
XSD는 src/test/schema/amadeus/(총 28개)에 있다. src/main이 아니라 test에 있다는 점에 주의 — 런타임 검증용이 아니라 전문 구조 참조/테스트용이다.
8.1 코어 GDS 스키마 (이 모듈이 매핑하는 대상)
| XSD 파일 | targetNamespace | 대응 오퍼레이션 |
|---|---|---|
Fare_MasterPricerTravelBoardSearch_23_1_1A.xsd / ...Reply... | http://xml.amadeus.com/FMPTBQ_23_1_1A | 검색 (코드 DTO는 v18 사용) |
DocRefund_InitRefund_14_1_1A.xsd / ...Reply... | TRFSRQ_14 | 환불 산정 |
DocRefund_UpdateRefund_13_1_1A.xsd / DocRefund_ProcessRefund_13_1_1A.xsd | TRFUUQ/TRFPCQ_13 | 환불 수정/확정 |
Queue_CountTotal_13 / Queue_List_11 / Queue_MoveItem_03 / Queue_RemoveItem_03 (+Reply) | QCSDRQ/QDQLRQ/QUQMUQ/QUQMDQ | 큐 |
AMA_TravelCommonTypes.xsd | 공통 타입 | 공통 |
XSD 버전 ≠ 코드 버전 (검색)
스키마 폴더의 검색 XSD는 v23(
FMPTBQ_23_1_1A)인데, 실제 코드 DTO와 SOAPAction은 v18(FMPTBQ_18_1_1A,FareMasterPricerTravelBoardSearch.kt:18,43)을 쓴다. 즉 스키마와 런타임 버전이 어긋나 있다 — XSD를 “현재 전송 스펙”으로 곧이곧대로 믿으면 안 된다. 실제 전송 형태는 항상 코드(DTO + soapRequestBodyConverter)가 진실의 원천이다.
8.2 NDC Travel* 스키마는 이 모듈 것이 아니다
같은 폴더에 AMA_TravelOfferPriceRQ.xsd, AMA_TravelOrderCreateRQ.xsd, AMA_TravelOrderCancelRQ.xsd, ..._Reshop..., ..._Pay..., ..._View/Retrieve... 등 **NDC 계열(.../2010/06/Travel_*_v1 네임스페이스)*이 섞여 있다. 그러나 grep으로 확인한 결과 이들을 참조하는 코드는 전부 supplier/amadeusndc/... 다 (예: amadeusndc/infrastructure/AmadeusndcClient.kt). classic Amadeus 모듈은 NDC Travel 스키마를 전혀 쓰지 않는다. NDC 프로토콜은 → amadeusndc-protocol 참고.
같은 폴더, 다른 모듈
test/schema/amadeus에 GDS(*_NN_1_1A)와 NDC(AMA_Travel*)가 한 폴더에 공존한다. 신입이 “Amadeus는 NDC도 SOAP로 하는구나”라고 착각하기 딱 좋은 함정이다. 메시지 약어가Travel_*/AMA_*면 amadeusndc,FMPTBQ/PNRADD/TTKTIQ처럼 6자 약어면 classic GDS다.
9. 실제 전문 예시 (mockData)
리포지토리에 포함된 유일한 Amadeus 샘플 전문은 src/test/resources/mockData/amadeus/no-farebasis-tst.xml로, Ticket_DisplayTSTReply(TST 조회 응답)의 SOAP Body 조각이다. 구조 핵심:
<Ticket_DisplayTSTReply>
<fareList>
<pricingInformation><tstInformation><tstIndicator>M</tstIndicator></tstInformation><fcmi>4</fcmi></pricingInformation>
<fareReference><referenceType>TST</referenceType><uniqueReference>7</uniqueReference>...</fareReference>
<validatingCarrier><carrierInformation><carrierCode>KE</carrierCode></carrierInformation></validatingCarrier>
<fareDataInformation>
<fareDataSupInformation><fareDataQualifier>B</fareDataQualifier> <!-- B = Base fare -->
<fareAmount>1575000</fareAmount><fareCurrency>KRW</fareCurrency></fareDataSupInformation>
</fareDataInformation>
<taxInformation>...<taxIdentification><taxIdentifier>PD</taxIdentifier></taxIdentification>
<taxType><isoCountry>YR</isoCountry></taxType>...<fareAmount>291200</fareAmount>...</taxInformation>
<segmentInformation>...<fareQualifier><fareBasisDetails>
<primaryCode>ULW</primaryCode><fareBasisCode>0ZKCK</fareBasisCode> <!-- ★파일명 의미: fareBasis가 비는 케이스 테스트 -->
</fareBasisDetails></fareQualifier>...</segmentInformation>
<automaticReissueInfo>...</automaticReissueInfo> <!-- 재발행(Reissue) 정보 -->
</fareList>
</Ticket_DisplayTSTReply>읽는 법(코드 매핑):
fareDataInformation/fareDataSupInformation[fareDataQualifier=B]= base fare (1,575,000 KRW)taxInformation[].taxType.isoCountry+amountDetails.../fareAmount= 세금 항목들(YR/BP/AY/XA/…)segmentInformation[].fareQualifier.fareBasisDetails.fareBasisCode= 구간별 운임 기준코드 — 파일명no-farebasis-tst는 일부 세그먼트에서 이 값이 비는 엣지케이스 회귀 테스트용 fixture임을 시사한다.automaticReissueInfo= 재발행 시 패널티/잔액 정보(firstDpiGroup/secondDpiGroup).
이 Body 조각은 Ticket_DisplayTSTReply 루트만 담고 SOAP Envelope/Header가 없다 — soapBody(mapper)가 바디 firstChild만 역직렬화하기 때문에(SoapExtensions.kt:33-34) 테스트도 바디 조각만 두면 된다.
10. 설정·인프라 연결
코어/ART/GPS 접속 정보는 모두 supplier.amadeus.* 프로퍼티로 주입된다(configuration/Properties.kt:23-88). 실제 값은 환경별 AWS Secrets Manager(src/main/resources/supplier/amadeus.yml: aws-secretsmanager:{env}/air-intl-adapter/amadeus)에서 온다.
| 프로퍼티 | 클래스/필드 | 쓰이는 곳 |
|---|---|---|
officeId, offlineOid, iataCode, userName, password, endpoint | AmadeusApiProperties | 코어 SOAP 인증/오피스 |
art.{agentCode, apiKey, endpoint} | AmadeusApiProperties.ArtProperties | ArtClient |
gps.endpoint | AmadeusProperties.GpsProperties | GpsClient (채널 무관 단일) |
changedDate, channels[].funnels[] | AmadeusProperties | 채널/펀넬별 오피스 선택, LEGACY 분기 |
채널·펀넬·LEGACY 라우팅
getApiProperties()(Properties.kt:65-79)는 MDC(SalesChannel/SalesFunnel/PnrCreatedAt)를 읽어, PNR 생성일이changedDate이전이면 강제로"LEGACY"펀넬의 오피스/계정을 쓴다. 즉 같은 요청이라도 예약 시점에 따라 다른 Amadeus 오피스로 나간다 — 결제/발권 디버깅 시 “어느 오피스로 갔는지”를 먼저 의심해야 한다. 큐 작업은getApiProperties(offlineOid)로 오프라인 오피스를 역조회한다(Properties.kt:81-87). → configuration-and-infra
11. 정리 — 핵심 5가지
- 세 프로토콜 동거: 코어 GDS=SOAP/stateful, ART=REST/JSON(FareRule), GPS=SOAP(결제). 디버깅 전 “어느 클라이언트인가”부터 정한다.
- 수제 SOAP: WSDL 스텁 없이
soap{}DSL + Jackson XML로 Envelope를 손조립. 인증/세션/오피스 헤더는 첫 호출(isStart)에만. 끝에.replace(" xmlns=\"\"","")필수. - PasswordDigest 인증: nonce(SecureRandom)+created(UTC)+SHA1(pw) → Base64 SHA1 digest. (
PasswordDigest.kt) - 클라이언트 주도 세션:
StatefulBuilder가 응답 세션을 회수→다음 요청에 시퀀스 +1로 재주입. data class라 동시성 주의. - 에러는 화이트리스트 + 거대 사전: 검색 무결과 코드, 발권 재시도 문자열,
GpsErrorVAN 코드 수천 개. 미분류만 Slack 캡처.
연습문제
Q1.
markSeat호출 시 SOAP Envelope의<oas:Security>헤더가 들어갈 조건은?정답 보기
soapRequestBodyConverter의isStart가 true일 때만. 즉request.session == null이거나session.transactionStatusCode === Start인 경우(AmadeusClient.kt:1334, 1345). 세션이 이미 InSeries/End이면SecurityToken이 자격을 대신하므로 인증 헤더는 생략된다.
Q2. 검색 응답에서 코드
977이 와도 예외를 던지지 않는 이유는?정답 보기
AmadeusClient.search의checkError콜백 화이트리스트(866/931/977/996/118/950)에 포함되기 때문(AmadeusClient.kt:153). 이들은 “검색 결과 없음” 계열이라 예외 대신emptyList()로 처리한다. 한편FareMasterPricerTravelBoardSearchReply.checkError는textSubjectQualifier == "WRN"(경고)도 무시한다.
Q3. 운임규정을 못 가져오는데 Amadeus SOAP 로그가 깨끗하다. 어디를 봐야 하나?
정답 보기
FareRule은 SOAP가 아니라
ArtClient의 REST/JSON(/api/v1/art/getrule/...,x-api-key헤더)으로 나간다. ART 서버 로그/x-api-key/art.endpoint설정을 봐야 한다.ArtResponse.checkError의 ①errorType(시스템) ②statusInfo.returncd != "200"(비즈니스) 두 단계 중 어디서 막혔는지 확인. → amadeus-pitfalls
더 많은 문제: exercises-suppliers · exercises-debugging