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 / Refund
  • ArtClient 담당: FareRule (운임규정)
  • GpsClient 담당: 결제승인 / 취소 / 현금영수증
클라이언트파일프로토콜직렬화인증세션담당 오퍼레이션
AmadeusClientinfrastructure/AmadeusClient.ktSOAP 1.1 over HTTPSXML (xmlMapper)WS-Security PasswordDigest + AMA_SecurityHostedUserStateful (Start→InSeries→End)Search, Booking, Ticketing, Queue, Refund, PNR 조작 전부
ArtClientinfrastructure/topas/ArtClient.ktREST over HTTPSJSON (objectMapper)HTTP 헤더 x-api-keyStatelessFareRule(운임규정)
GpsClientinfrastructure/topas/GpsClient.ktSOAP over HTTPSXML (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&lt;T&gt; (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             // 메시지별 고유값
)

타임아웃은 AmadeusClientClientSupport를 상속하며 지정한다(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_1AFare_MasterPricerTravelBoardSearch v18
운임확정 pricing().../TPCBRQ_18_1_1AFare_PricePNRWithBookingClass v18
TST 생성 tstCreate().../TAUTCQ_04_1_1ATicket_CreateTSTFromPricing v4
TST 수정 tstUpdate().../TTSTUQ_15_1_1ATicket_UpdateTST v15
TST 조회 getPnrFares().../TTSTRQ_15_1_1ATicket_DisplayTST v15
좌석확보 markSeat().../ITAREQ_05_2_IAAir_SellFromRecommendation v5
PNR 추가/저장 save*() confirmPnr().../PNRADD_17_1_1APNR_AddMultiElements v17
PNR 조회 retrieve().../PNRRET_17_1_1APNR_Retrieve v17
PNR 취소/요소삭제 pnrCancel() removeElements().../PNRXCL_17_1_1APNR_Cancel v17
PNR 분리 splitPnr().../PNRSPL_21_1_1APNR_Split v21
PNR 히스토리 getPnrInfoHistory().../PHIDRQ_16_1_1APNR_DisplayHistory v16
발권 ticketing().../TTKTIQ_15_1_1ADocIssuance_IssueTicket v15
결제(FOP) savePaymentInfo().../TFOPCQ_15_4_1AFOP_CreateFormOfPayment v15
키인 결제(crypt) approveByKeyIn().../HSFREQ_07_3_1ACommand_Cryptic v7
항공권 e-Doc 조회 getPnrTicketDocuments().../TATREQ_15_2_1ATicket_ProcessEDoc v15
항공권 VOID void().../TRCANQ_11_1_1ATicket_CancelDocument v11
환불산정 initRefund().../TRFSRQ_14_1_1ADocRefund_InitRefund v14
환불수정 updateRefund().../TRFUUQ_13_1_1ADocRefund_UpdateRefund v13
환불확정 processRefund().../TRFPCQ_13_1_1ADocRefund_ProcessRefund v13
큐 카운트 getPnrCountsInQueue().../QCSDRQ_13_1_1AQueue_CountTotal v13
큐 목록 getPnrsInQueue().../QDQLRQ_11_1_1AQueue_List v11
큐 이동 moveQueuePnrs().../QUQMUQ_03_1_1AQueue_MoveItem v3
큐 삭제 removePnrsInQueue().../QUQMDQ_03_1_1AQueue_RemoveItem v3
세션 종료 signOut().../VLSSOQ_04_1_1ASecurity_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 선해시
요소생성비고
noncePasswordDigest.genNonce()SHA1PRNG 32바이트 SecureRandom → Base64
createdPasswordDigest.getFormattedTime(now("UTC"))yyyy-MM-dd'T'HH:mm:ss.SSS'Z', UTC
password digestgetPasswordDigestFromClearTextPW(nonce, created, pw)위 공식. 비밀번호는 먼저 SHA-1 해시 후 사용

POS/오피스 식별은 별도 헤더 AMA_SecurityHostedUser로 전달된다(AmadeusClient.kt:1398-1411). AgentDutyCode="SU", POS_Type="1", RequestorType="U", PseudoCityCode는 온라인이면 officeId, 큐 작업처럼 useOfflineOid=trueofflineOid를 쓴다.

인증·세션·오피스는 "첫 호출에만" 보낸다

if (isStart) 블록이 두 번 등장한다: <Security>(인증)와 <AMA_SecurityHostedUser>(오피스/POS). 세션이 열린 뒤(InSeries/End)에는 보내지 않는다 — 이미 SecurityToken이 자격을 대신하기 때문. 신규 stateless 단발 호출(큐 계열)은 session==null이라 항상 isStart=true로 인증을 매번 동봉한다.

네임스페이스 정의

모든 SOAP 헤더 네임스페이스는 enum support/enums/AmadeusSoapHeaderNamespace.kt에 집중되어 있다.

enumnamespace용도
ADDRESSING (wsa)http://www.w3.org/2005/08/addressingTo / Action / MessageID
WS_SECURITY_OAS (oas)...oasis-200401-wss-wssecurity-secext-1.0.xsdSecurity / UsernameToken / Username / Nonce / Password
WS_SECURITY_OAS1 (oas1)...wssecurity-utility-1.0.xsdCreated / Id 속성
SESSIONhttp://xml.amadeus.com/2010/06/Session_v3<awsse:Session>
AMA_SECURITY_HOSTED_USERhttp://xml.amadeus.com/2010/06/Security_v1오피스/POS
PASSWORD_TYPE...#PasswordDigestPassword Type 속성값
NONCE_ENCODING_TYPE...#Base64BinaryNonce 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(SOAPAction VLSSOQ)으로도 가능

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). 호출 순서가 꼬이거나 세션을 공유/재사용하면 시퀀스 불일치로 세션이 깨진다. 또한 Sessiondata class라 thread-safe하지 않다 — 하나의 StatefulBuilder를 동시에 여러 스레드가 만지면 안 된다. → amadeus-pitfalls

비동기 흐름이 어떻게 stateful 세션과 공존하는지는 async-coroutines 참고.

4.3 PNR 액션 코드 (PNR_AddMultiElements의 세션 명령)

PNR_AddMultiElements(가장 다용도 메시지) 호출은 본문의 PnrActionPnrActionCode를 실어 “이 호출에서 무엇을 할지”를 지시한다. AmadeusClient가 사용하는 액션 코드:

메서드PnrActionCode의미
savePnrWithRetrieve / saveCancelEND_TRANSACTION_WITH_RETRIEVEEOT 후 PNR 재조회
savePnrWithShowWarningsEND_TRANSACTION + SHOW_WARNINGS_AT_FIRST_EOTEOT + 첫 경고 노출
saveSplitPnrEND_TRANSACT_SPLIT_PNR + SHOW_WARNINGS_AT_FIRST_EOT분리 PNR EOT
confirmPnrEND_TRANSACT_WITH_RETRIEVE_AND_CHANGE_ADVICE_CODESEOT + 재조회 + 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핵심 필드(요약)
faremasterpricertravelboardsearchreplyFareMasterPricerTravelBoardSearchReplyflightIndexes, recommendations, serviceFeesGroups, errorMessage
pnrreplyPnrReplypnr, passengers, originDestinationDetails, schedules, generalErrorInfos
farepricepnrwithbookingclassreplyFarePricePnrWithBookingClassReply운임 후보(AcceptableFare)
ticketdisplaytstTicketDisplayTstReplyfares(TST별 운임/세금)
ticketprocessedocreplyTicketProcessEDocReplydocGroups(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:24 defaultTimeout=60000).

6.2 요청/응답 전문 (JSON)

ArtRequest(topas/ArtRequest.kt)는 Amadeus가 아니라 TOPAS(한국 GDS) 자체 규격이라 필드명이 한글 도메인 약어다. 핵심 매핑:

JSON 키DTO 필드의미
farerulerqagtuuidagentUUID요청 UUID (UUID.randomUUID())
depcitycd/arrcitycddepartures/arrivals구간 출발/도착 도시
stockaircdvalidationCarrier발권항공사
aircd/flightno/bkgclassmarketingCarriers/flightNumbers/bookingClasses마케팅사/편명/예약클래스
faretypegdsFareTypesR.. 운임타입을 ^로 join (toArtRequest() ArtRequest.kt:179-193)
svctypesvcType"1A" 고정 (=Amadeus)
diflagdiFlag"D" 고정 (국내출발)
langlang"ko" 고정 (한글 규정)
triptypetripTypeOW/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가 없다. 대신 본문 GeneralInfoofficeId/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가지 동작을 표현한다:

팩토리channelstatustype동작
ofCardVerify1UC카드 유효성 검증
ofApprove3AC카드 결제 승인
ofCancel3RC결제 취소
ofCashReceipt3AH현금영수증 발행
ofCancelCashReceipt3RH현금영수증 취소

고정 메타값(GeneralInfo): currency="KRW", errorLanguage="ENG", forcedApproval="N", requestSystem="IBE_OFF", domIntType="I"(국제). 현금영수증은 tktNumberAdditionalInfo(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.xsdTRFUUQ/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, endpointAmadeusApiProperties코어 SOAP 인증/오피스
art.{agentCode, apiKey, endpoint}AmadeusApiProperties.ArtPropertiesArtClient
gps.endpointAmadeusProperties.GpsPropertiesGpsClient (채널 무관 단일)
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가지

  1. 세 프로토콜 동거: 코어 GDS=SOAP/stateful, ART=REST/JSON(FareRule), GPS=SOAP(결제). 디버깅 전 “어느 클라이언트인가”부터 정한다.
  2. 수제 SOAP: WSDL 스텁 없이 soap{} DSL + Jackson XML로 Envelope를 손조립. 인증/세션/오피스 헤더는 첫 호출(isStart)에만. 끝에 .replace(" xmlns=\"\"","") 필수.
  3. PasswordDigest 인증: nonce(SecureRandom)+created(UTC)+SHA1(pw) → Base64 SHA1 digest. (PasswordDigest.kt)
  4. 클라이언트 주도 세션: StatefulBuilder가 응답 세션을 회수→다음 요청에 시퀀스 +1로 재주입. data class라 동시성 주의.
  5. 에러는 화이트리스트 + 거대 사전: 검색 무결과 코드, 발권 재시도 문자열, GpsError VAN 코드 수천 개. 미분류만 Slack 캡처.

연습문제

Q1. markSeat 호출 시 SOAP Envelope의 <oas:Security> 헤더가 들어갈 조건은?

Q2. 검색 응답에서 코드 977이 와도 예외를 던지지 않는 이유는?

Q3. 운임규정을 못 가져오는데 Amadeus SOAP 로그가 깨끗하다. 어디를 봐야 하나?

더 많은 문제: exercises-suppliers · exercises-debugging