Lufthansa — 프로토콜·전문
module-lufthansa arch-infrastructure pattern-soap api-ndc
한 줄 요약
Lufthansa(LH) 어댑터는 IATA NDC V17.2(EDIST 스키마) XML 전문을, 루프트한자 그룹의 게이트웨이(FLX/Farelogix)가 요구하는 SOAP 1.1 Envelope 안에
<ns1:XXTransaction><REQ>...형태로 한 번 더 감싸서 HTTPPOST로 전송한다. 인증은 세션리스(stateless) 다 — 토큰을 미리 발급받지 않고, 매 요청 SOAP 헤더(<t:TransactionControl>)에 자격증명을 평문으로 실어 보낸다. 응답은<ns1:XXTransactionResponse><RSP>안의 NDC...RS전문이다.
관련 노트: 오퍼레이션별 흐름 · 지뢰·함정 · DTO · Singapore Airlines NDC 비교
1. 프로토콜 개요 — “NDC를 SOAP로 감싼” 3중 봉투
LH는 “NDC = REST/JSON”이라는 통념과 다르다. NDC 메시지 모델(EDIST 17.2) 은 표준을 따르지만, 전송 계층은 SOAP 1.1 이며 그 안에 또 한 겹의 자체 트랜잭션 래퍼(XXTransaction)가 들어간다. 즉 전문은 3중으로 중첩된다.
flowchart TD HTTP["HTTP POST text-xml<br/>Headers Ocp-Apim-Subscription-Key IATA Agency PCC SOAPAction"] subgraph ENV["SOAP-ENV Envelope"] subgraph HDR["SOAP-ENV Header — 인증·세션"] TC["t TransactionControl<br/>자격증명 iden agent script"] end subgraph BODY["SOAP-ENV Body"] XX["ns1 XXTransaction ns1=xxs — FLX 래퍼"] REQ["REQ"] NDC["AirShoppingRQ Version=17.2 — 실제 NDC 17.2 전문<br/>PointOfSale Party CoreQuery ..."] end end HTTP --> ENV HDR --> BODY XX --> REQ --> NDC
- 최상위 HTTP 헤더:
Ocp-Apim-Subscription-Key,IATA,Agency,PCC,SOAPAction <SOAP-ENV:Header>안의<t:TransactionControl>에 자격증명(iden/agent/script)이 실린다 (인증·세션 계층)<SOAP-ENV:Body>→<ns1:XXTransaction ns1="xxs">(FLX 래퍼) →<REQ>→<AirShoppingRQ Version="17.2" ...>(실제 NDC 17.2 전문,<PointOfSale/>·<Party/>·<CoreQuery/>등 포함)
응답은 봉투의 태그명만 바뀐다.
<SOAP-ENV:Envelope ... SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<SOAP-ENV:Header><t:Transaction xmlns:t="xxs"> ... </t:Transaction></SOAP-ENV:Header>
<SOAP-ENV:Body>
<ns1:XXTransactionResponse xmlns:ns1="xxs">
<RSP>
<OrderReshopRS Version="17.2" TransactionIdentifier="..."> ... </OrderReshopRS>
</RSP>
</ns1:XXTransactionResponse>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
출처
요청 봉투:
src/test/schema/lufthansa/certification/truereshop/LH/3_OrderReshopRQ.xml응답 봉투:src/test/resources/mockData/lufthansa/LH-OrderReshopRS.xml(1~30행) 및certification/.../4_OrderReshopRS.xml
1.1 봉투를 만드는 코드 — soapRequestBodyConverter
이 3중 구조를 만들어내는 단 하나의 함수가 LufthansaClient.soapRequestBodyConverter 다 (LufthansaClient.kt:866-907). 모든 오퍼레이션(search, pricing, booking, cancel, …)이 .requestBodyConvert(soapRequestBodyConverter(lufthansaApiProperties)) 로 이 컨버터를 끼운다.
// LufthansaClient.kt:866
private val soapRequestBodyConverter: (LufthansaApiProperties) -> ((Any?) -> String) = { lufthansaApiProperties ->
{ request ->
soap {
header {
headerElement("Transaction", LufthansaSoapHeaderNamespace.TRANSACTION) {
childElement("tc") {
childElement("app") { ...; text { "SOAP" } }
childElement("iden") { // ← 자격증명이 여기 평문으로 들어감
attribute("u") { lufthansaApiProperties.userName }
attribute("p") { lufthansaApiProperties.password }
attribute("pseudocity") { lufthansaApiProperties.pseudoCityCode }
attribute("agt") { lufthansaApiProperties.agencyName }
attribute("agtpwd") { lufthansaApiProperties.agencyPassword }
attribute("agtrole") { lufthansaApiProperties.agencyRole }
attribute("agy") { lufthansaApiProperties.iataCode }
}
childElement("agent") { attribute("user") { ...userName } }
childElement("trace") { text { lufthansaApiProperties.trace } }
childElement("script") { // ← FLXDM 디스패치 스크립트
attribute("engine") { "FLXDM" }
attribute("name") { "${...userName}-dispatch.flxdm" }
}
}
}
}
body {
childElement("ns1:XXTransaction") {
attribute("ns1") { "xxs" }
childDocument("REQ", objectMapper.writeValueAsBytes(request).inputStream()) // ← NDC 전문 주입
}
}
}.replace(" xmlns=\"\"", "") // ← 빈 namespace 제거 핵
}
}여기서 핵심 단계는:
objectMapper.writeValueAsBytes(request)—@Qualifier("xmlMapper")로 주입된 Jackson XmlMapper가AirShoppingRQ등 Kotlin 데이터 클래스를 NDC XML로 직렬화.childDocument("REQ", ...)— 직렬화된 NDC XML을 DOM으로 파싱해<REQ>자식으로 import (SoapExtensions.kt:170-185)..replace(" xmlns=\"\"", "")— Jackson이 자식 요소에 자동으로 붙이는 빈 디폴트 네임스페이스 선언을 문자열 치환으로 제거.
지뢰:
replace(" xmlns=\"\"", "")의 정체Jackson XmlMapper로 직렬화한 NDC XML을 SOAP DOM에 import하면 자식 요소마다
xmlns=""(빈 네임스페이스 재정의)가 붙는다. LH 게이트웨이는 이걸 싫어한다. 그래서 완성된 봉투 문자열 전체에 무차별 문자열 치환을 가한다. 만약 NDC 전문의 텍스트값 안에 우연히xmlns=""문자열이 들어오면 같이 지워질 수 있는 잠재적 버그. 정상 운임 데이터에는 거의 없지만, 디버깅 시 “응답 XML이 깨졌다”면 이 치환을 의심하라. SOAP 빌더는 SoapExtensions.kt 참조.
1.2 응답 역직렬화 — soapBodyDeserializerOf
응답 파싱은 LufthansaClient.companion.soapBodyDeserializerOf (LufthansaClient.kt:50-61) 가 담당한다. 봉투 안 깊숙이 묻힌 NDC ...RS 요소만 뽑아낸다.
// LufthansaClient.kt:50
inline fun <reified T : Any> soapBodyDeserializerOf(logger, mapper, elementTagName: String) =
{ content: String, _ ->
try { soap(content).soapBody(mapper, elementTagName) } // ← 태그명으로 NDC 본문 추출
catch (e: Exception) { logger.info(...ResponseLog...); throw e }
}soapBody(mapper, elementTagName) 는 getElementsByTagNameNS("*", elementTagName).item(0) 로 네임스페이스 무시("*") + 태그명 매칭 으로 첫 번째 OrderReshopRS 등을 찾아 그 부분만 다시 XmlMapper에 먹인다 (SoapExtensions.kt:36-38). 호출부에서 AirShoppingRS::class.simpleName!! 처럼 클래스 이름 = NDC 루트 태그명임을 전제로 넘긴다.
왜
getElementsByTagNameNS("*", ...)인가응답 봉투는
<RSP>안에 NDC RS가 들어있고, SOAP/FLX 네임스페이스가 뒤섞여 있다. 네임스페이스를 정확히 매칭하려면 prefix를 알아야 하는데 게이트웨이가 prefix를 바꿀 수 있다. 그래서 “네임스페이스 와일드카드 + 로컬 태그명”으로 견고하게 추출한다.
2. 인증·세션 — 세션리스, 자격증명은 매 요청 평문
LH는 Amadeus/Galileo 같은 stateful PNR 세션이 없다. 별도 로그인/토큰 발급 단계가 없고, 매 요청이 독립적이다. 자격증명은 두 군데에 동시에 실린다.
| 위치 | 필드 | 소스 | 의미 |
|---|---|---|---|
| HTTP 헤더 | Ocp-Apim-Subscription-Key | ocpApimSubscriptionKey | Azure API Management 구독키. 게이트웨이 1차 관문 |
| HTTP 헤더 | IATA | iataCode | IATA 대리점 번호 |
| HTTP 헤더 | Agency | agencyName | 대리점명 |
| HTTP 헤더 | PCC | pseudoCityCode | Pseudo City Code |
| HTTP 헤더 | SOAPAction | request.action | NDC 오퍼레이션명 (예: AirShoppingRQ) |
| HTTP 헤더 | Content-Type | 고정 | text/xml |
| HTTP 헤더 | Accept-Encoding | 고정 | gzip,deflate,br |
SOAP iden | u / p | userName / password | 사용자 로그인/비밀번호 (평문) |
SOAP iden | agt / agtpwd / agtrole | agencyName / agencyPassword / agencyRole | 대리점 자격 (예: Ticketing Agent) |
SOAP iden | agy / pseudocity | iataCode / pseudoCityCode | IATA·PCC |
SOAP script | name | ${userName}-dispatch.flxdm | FLXDM 디스패치 스크립트 지정 (Farelogix 라우팅) |
SOAP trace | text | trace | 추적 식별자 |
코드: HTTP 헤더는 LufthansaClient.getHeaderMap() (LufthansaClient.kt:64-76), SOAP iden은 soapRequestBodyConverter (LufthansaClient.kt:877-885).
보안 지뢰: 비밀번호 평문 전송 + 로깅
userName/password/agtpwd가 SOAPiden속성에 평문으로 들어간다 (LufthansaClient.kt:878-882). 인증서 인증서 XML(certification/.../3_OrderReshopRQ.xml:10)에도p="HQ0mWQt26JmR"형태로 실제 패스워드가 그대로 노출되어 있다. 요청 로깅(.log(...))이 켜진 채로 전문이 로그에 찍히면 자격증명이 평문 노출된다. 검색 로깅은SupplierLoggingProperties.search플래그로 제어된다 (LufthansaClient.kt:78).
2.1 자격증명 출처 — AWS Secrets Manager
LufthansaApiProperties 의 모든 필드는 supplier/lufthansa.yml 을 통해 AWS Secrets Manager 에서 주입된다 (src/main/resources/supplier/lufthansa.yml).
# supplier/lufthansa.yml (prod 예시)
spring:
config:
activate: { on-profile: prod }
import:
- optional:aws-secretsmanager:prod/air-intl-adapter/lufthansaLufthansaProperties.getApiProperties() 는 판매 채널/퍼널(MDC) 별로 다른 자격증명 세트를 고른다 (Properties.kt:349-358). 채널이 매칭 안 되면 NOT_SUPPORTED_SALES_CHANNEL 예외. 자세한 것은 설정·인프라 참조.
// Properties.kt:374 — LufthansaApiProperties 필드
data class LufthansaApiProperties(
override val funnel: String,
val iataCode, userName, password, agencyName,
agencyPassword, agencyRole, trace, pseudoCityCode,
endpoint, ocpApimSubscriptionKey: String,
) : FunnelProperties3. 요청/응답 전문 모델 — 오퍼레이션 ↔ NDC 메시지 매핑
LufthansaRequest 인터페이스(request/LufthansaRequest.kt)는 단 하나의 멤버 action(= SOAPAction 헤더 + NDC 루트 태그)만 강제한다. @get:JsonIgnore 이므로 XML 본문에는 직렬화되지 않는다.
interface LufthansaRequest {
@get:JsonIgnore
val action: String
}3.1 오퍼레이션 → 요청 RQ → 응답 RS 표
| 어댑터 메서드 | NDC 요청 (action) | 응답 클래스 | 비고 |
|---|---|---|---|
search | AirShoppingRQ | AirShoppingRS | 검색 전용 OkHttpClient(searchClient, 15초 타임아웃) |
getFareRule / pricing | OfferPriceRQ | OfferPriceRS | 동일 요청, 응답에서 운임규정 vs 가격 추출만 다름 |
booking | OrderCreateRQ | OrderViewRS | PNR 생성 |
retrieve / retrieveForAncillary | OrderRetrieveRQ | OrderViewRS | 주문 조회 |
savePayment (발권) | OrderChangeRQ.ofPayment | OrderViewRS | 현금(CA) 0원 결제로 발권 |
cancel | OrderCancelRQ | OrderCancelRS | 자동 환불 취소 |
refundCalculate | OrderReshopRQ.ofRefundCalculate | OrderReshopRS | 환불 수수료 산정(Delete) |
reissueSearch | OrderReshopRQ.ofReissueSearch | OrderReshopRS | 재발행 검색(Add+Delete, TrueReshop) |
reissue | OrderChangeRQ.ofReissue | OrderViewRS | 재발행 확정 |
changeApis | OrderChangeRQ.ofApis | OrderViewRS | APIS(여권/연락처) 변경 |
divide | OrderChangeRQ.ofDivide | OrderViewRS | LH 미지원 (코드만 존재) |
bookAncillaries / purchaseAncillaries | OrderChangeRQ.ofBook/ofPurchase | OrderViewRS | 부가서비스 예약/구매 |
searchExtraBaggages | ServiceListRQ | ServiceListRS | 수하물 부가서비스 |
searchSeats | SeatAvailabilityRQ | SeatAvailabilityRS | 좌석 부가서비스 |
OrderChangeRQ는 다목적 스위스 군용 칼발권·재발행·APIS변경·분할·부가서비스 — 6가지 전혀 다른 시나리오가 모두 같은
OrderChangeRQ전문 한 종류로 처리되고 응답도 모두OrderViewRS다. 차이는 companion 팩토리(ofPayment/ofReissue/ofApis/ofDivide/ofBookAncillaries/ofPurchaseAncillaries)가 채우는 하위 요소(Payments/OrderServicing/PassengerServicing)뿐이다. 디버깅 시 “어떤 동작인지”는 채워진 하위 요소로 판별하라. 코드는request/OrderChangeRQ.kt:40-291.
3.2 모든 RQ에 공통인 봉투 머리 (NDC 표준 헤더)
모든 요청 데이터 클래스는 거의 동일한 머리 5종을 가진다 (예: AirShoppingRQ.kt:9-33):
| XML 요소 | Kotlin 필드 | 값/생성 | 의미 |
|---|---|---|---|
@Version | version | 고정 "17.2" | NDC 메시지 버전 |
@TransactionIdentifier | transactionIdentifier | UUID...replace("-","") | 요청 고유 ID (매번 신규 난수) |
<PointOfSale><Location><CountryCode> | pointOfSale | 고정 "KR" (PointOfSale.kt) | 판매 국가 |
<Document><ReferenceVersion> | document | 고정 "17.2" (Document.kt) | 메시지 버전 참조 |
<Party><Sender><TravelAgencySender> | party | iataCode/userName/pseudoCity (Party.kt) | 발신 대리점 |
TransactionIdentifier는 추적 ID가 아니다
AirShoppingRQ.of()에// TODO: LH 난수값 말고 트레이스 ID 값 넣을까?주석(AirShoppingRQ.kt:46)이 있다. 현재는 매 요청 새 UUID(하이픈 제거)를 쓰므로 요청-응답을 이 ID로 상관(correlate) 할 수는 있어도, 서비스 전체의 traceId와는 무관하다. 로그 추적 시 헷갈리지 말 것.
3.3 핵심 전문별 페이로드 (요점만)
AirShoppingRQ (검색) — request/AirShoppingRQ.kt
| 요소 | 매핑 | 비고 |
|---|---|---|
<CoreQuery><OriginDestinations> | originDestinations → OriginDestination(originDestinationKey="O${i+1}", Departure, Arrival) | 출발지에만 Date, 도착지엔 공항코드만 |
<DataLists><PassengerList> | Passenger.listOf(adult, child, infant) | ID 규칙 T1,T2…; 유아는 T1.1 형태, 성인 InfantRef로 연결 |
<Preference><FlightPreferences><Characteristic><DirectPreferences> | onlyDirect 시 "Preferred" | 직항 선호 |
검색 시 좌석등급(Cabin) 미지정
AirShoppingRQ.of()에서cabins/airlinePreferences매핑이 주석 처리되어 있다 (AirShoppingRQ.kt:90-100):// TODO: LH 다중 좌석선택 불가,// TODO: LH 지정 제외. LH NDC는 검색 요청에서 다중 캐빈 선택을 지원하지 않아, 캐빈 필터링은 응답 후 코드에서 한다 (AirShoppingRS.toFareItineraries의offerItem.checkedCabins(cabins)). 단, 재발행 검색(OrderReshopRQ.ofReissueSearch) 에서는cabins.first().lufthansa로 단일 캐빈을 요청에 싣는다 (OrderReshopRQ.kt:131-135).
OfferPriceRQ (가격/운임규정) — request/OfferPriceRQ.kt
<Query><Offer> 에 검색 결과의 offerId, OfferItem.id, responseId, 승객참조(PassengerReferences)를 그대로 되돌려 보낸다(OfferPriceRQ.kt:55-69). 즉 검색 응답의 식별자를 그대로 echo-back 하는 NDC의 전형적 stateless 패턴. <Parameters><Pricing><OverrideCurrency> 는 고정 KRW (Pricing.kt).
OrderCreateRQ (예약) — request/OrderCreateRQ.kt
<Query><Order><Offer>:Offer.ofPassenger(offerPriceInfo)로 가격확정된 오퍼를 실음.<DataLists><PassengerList><Passenger>: 승객별PTC(ADT/CNN/INF),IdentityDocument(여권),Individual(이름/성별/생일),ContactInfoRef.<DataLists><ContactList>: 이메일/전화/주소. 유아(INFANT)는 ContactInfo 제외 (OrderCreateRQ.kt:78-88).- 승객 ID ↔ ContactID 규칙:
"CI" + passengerId.removePrefix("T")(OrderCreateRQ.kt:106).
OrderReshopRQ (재발행 검색 / 환불 산정) — request/OrderReshopRQ.kt
<Query><Reshop><OrderServicing> 안에 Add(새 여정)/Delete(기존 OrderItem)를 조합한다.
- 환불 산정(
ofRefundCalculate):Delete만,AutoExchRequestInd="false". - 재발행 검색(
ofReissueSearch, TrueReshop):Add(새FlightQuery+단일Cabin) +Delete(보존할 서비스 IDServiceRetainRequestIDs계산).
// OrderReshopRQ.kt:171 — AutoExchRequestInd 주석이 핵심
// If True and no tickets issued → error. If no tickets issued → must send "False".
@JacksonXmlProperty(isAttribute = true, localName = "AutoExchRequestInd")
val autoExchRequestInd: Boolean인증서 XML과 코드의 미묘한 차이
코드는
autoExchRequestInd = false로 보내지만(OrderReshopRQ.kt:54,97), 인증 샘플(certification/.../3_OrderReshopRQ.xml:40)은AutoExchRequestInd="true". 이는 인증 시나리오가 “이미 발권된 주문”을 전제로 했기 때문 — 발권 전이면 반드시false여야 에러가 안 난다는 위 주석과 일관된다.
OrderCancelRQ (취소) — request/OrderCancelRQ.kt
<Query><Order @OrderID @Owner> 만으로 자동환불 취소. ExpectedRefundAmount 는 // TODO 예상 환불 금액을 보내지 말라고 서티 응답 받음 으로 null 강제 (OrderCancelRQ.kt:45-46).
4. XSD 스키마 — 위치와 핵심 타입
NDC 17.2 스키마 원본은 테스트 리소스에 동봉돼 있다 (런타임이 아니라 참조/검증용, 코드는 Jackson 직렬화이지 JAXB 바인딩이 아니다).
위치: src/test/schema/lufthansa/NDC_V17.2_Schema_V2023.3/
| XSD 파일 | 역할 |
|---|---|
AirShoppingRQ.xsd / AirShoppingRS.xsd | 검색 요청/응답 |
OfferPriceRQ.xsd / OfferPriceRS.xsd | 가격/운임규정 |
OrderCreateRQ.xsd / OrderViewRS.xsd | 예약 생성 / 모든 Order 변경의 공통 응답 |
OrderRetrieveRQ.xsd | 주문 조회 |
OrderCancelRQ.xsd / OrderCancelRS.xsd | 취소 |
OrderChangeRQ.xsd | 발권/재발행/APIS/부가서비스 변경 |
OrderReshopRQ.xsd / OrderReshopRS.xsd | 재발행 검색 / 환불 산정 |
FareRulesRQ.xsd / FareRulesRS.xsd | 운임규정 (별도, 어댑터는 OfferPrice로 대체 사용) |
ServiceListRQ/RS.xsd, SeatAvailabilityRQ/RS.xsd | 부가서비스/좌석 |
OrderListRQ/RS.xsd, OrderChangeNotif.xsd, Acknowledgement.xsd | (어댑터 미사용) |
| 공통 타입 모듈 | |
edist_structures.xsd, edist_commontypes.xsd | EDIST 공통 구조/타입 (PassengerType, ContactInformationType 등) |
aidm_commontypes.xsd | AIDM 공통 타입 (IATA_CodeType 등) |
transaction.xsd | 트랜잭션 래퍼 타입 |
flx_augmentation.xsd | Farelogix(FLX) 자체 확장 — 표준 NDC를 LH 게이트웨이가 확장한 부분 |
Backport_SecurePayment_17.2.xsd | 17.2 백포트 보안결제 타입 |
XSD 구조 특징
모든 XSD가
<xsd:schema ... elementFormDefault="qualified">이지만targetNamespace가 없다(AirShoppingRQ.xsd:3). 루트 요소는<xsd:element name="AirShoppingRQ">처럼 단순 로컬명으로 선언된다(AirShoppingRQ.xsd:14). 그래서 어댑터의 Jackson 모델도 네임스페이스 없이 로컬 태그명만@JacksonXmlProperty(localName=...)로 맞춘다. 응답 추출 시getElementsByTagNameNS("*", ...)로 네임스페이스를 무시하는 이유와도 맞물린다. 핵심 타입 예:AirShoppingRQ→Parameters(AirShopReqParamsType),CoreQuery(AirShopReqAttributeQueryType),Preference,DataLists(PassengerList: PassengerType, ContactList: ContactInformationType),Metadata(AirShoppingRQ.xsd:14-239).
5. 실제 전문 예시 (mockData / certification)
5.1 요청 봉투 전체 (재발행 검색)
src/test/schema/lufthansa/certification/truereshop/LH/3_OrderReshopRQ.xml 발췌 (자격증명은 인증 테스트용):
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
<SOAP-ENV:Header>
<t:TransactionControl xmlns:t="http://www.w3.org/2001/XMLSchema">
<tc>
<app language="en-US" version="5.0.0">SOAP</app>
<iden agt="xmlint001" agtpwd="X3iFMVZC3W" agtrole="Ticketing Agent"
agy="17315465" p="HQ0mWQt26JmR" pseudocity="APMK" u="InterparktourLHG"/>
<agent user="xmlint001"/>
<trace>APMK_int</trace>
<script engine="FLXDM" name="InterparktourLHG-dispatch.flxdm"/>
</tc>
</t:TransactionControl>
</SOAP-ENV:Header>
<SOAP-ENV:Body>
<ns1:XXTransaction>
<REQ>
<OrderReshopRQ TransactionIdentifier="8e0504e9..." Version="17.2">
<Document><ReferenceVersion>17.2</ReferenceVersion></Document>
<Party><Sender><TravelAgencySender>
<Name>InterparktourLHG</Name><PseudoCity>APMK</PseudoCity>
<AgencyID>17315465</AgencyID>
<AgentUser><Name>xmlint001</Name>
<AgentUserID>xmlint001</AgentUserID>
<UserRole>Ticketing Agent</UserRole></AgentUser>
</TravelAgencySender></Sender></Party>
<ReshopParameters><Notices>
<PricingParameters AutoExchRequestInd="true"/></Notices></ReshopParameters>
<Query>
<OrderID>LH220HL6HJUA4</OrderID>
<Reshop><OrderServicing>
<Add><FlightQuery><OriginDestinations>
<OriginDestination OriginDestinationKey="O1">
<Departure><AirportCode>LHR</AirportCode>
<Date>2023-12-01</Date><Time>13:25</Time></Departure>
<Arrival><AirportCode>MUC</AirportCode></Arrival>
</OriginDestination> ...
</OriginDestinations></FlightQuery>
<Preference><CabinPreferences>
<CabinType><Code>Y</Code></CabinType></CabinPreferences></Preference>
</Add>
<Delete> ... </Delete>
</OrderServicing></Reshop>
</Query>
</OrderReshopRQ>
</REQ>
</ns1:XXTransaction>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>헤더 태그명 주의: 요청은
TransactionControl, 응답은Transaction어댑터 코드는 헤더 요소명을
headerElement("Transaction", ...)로 만든다(LufthansaClient.kt:870). 인증 샘플 요청은<t:TransactionControl>로 되어있다. 게이트웨이가 둘을 모두 받아들이는 것으로 보이며, 응답 헤더는<t:Transaction xmlns:t="xxs">형태다(mockDataLH-OrderReshopRS.xml:11). 즉 요청 헤더의 네임스페이스 prefix는http://www.w3.org/2001/XMLSchema(코드LufthansaSoapHeaderNamespace.TRANSACTION), 응답 헤더는xxs. 검증 로직과 무관하니(헤더는 파싱하지 않음) 혼동만 주의.
5.2 응답 봉투 (환불 산정)
src/test/resources/mockData/lufthansa/LH-OrderReshopRS.xml 발췌 — 환불 금액 계산 공식이 주석으로 명시되어 있다:
<!--미사용항공요금(돌려받는항공요금) = ByAirlineTotal - Tax(RefundInd=true)-->
<!--텍스 사용 금액(used_tax) = <Tax RefundInd="false"> 합계-->
<!--패널티(carrier_refund_fee) = PenaltyAmount.Total.Amount-->
<SOAP-ENV:Envelope ... SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<SOAP-ENV:Header><t:Transaction xmlns:t="xxs"><tc>
<pid>FLX DMServer TC1 (8800/3070) STG 0a19</pid>
<tid>32667F7D-4270F2F0</tid><dt>2023-10-10T18:14:20</dt>
</tc></t:Transaction></SOAP-ENV:Header>
<SOAP-ENV:Body>
<ns1:XXTransactionResponse xmlns:ns1="xxs"><RSP>
<OrderReshopRS Version="17.2" TransactionIdentifier="b1a01496...">
<Document></Document><Success></Success>
<Response>
<ReShoppingResponseID><ResponseID>R5A26C425-931D-4013-90B</ResponseID></ReShoppingResponseID>
<ReshopOffers>
<ReshopOffer OfferID="R5A26C425-931D-4013-90B-1" Owner="LH"> ... </ReshopOffer>
...이 응답의 금액 계산 로직은 LufthansaClient.refundCalculate() (LufthansaClient.kt:458-538) 가 reshopDifferential 의 originalOrderItem / reshopDue / penaltyAmount 를 더하고 빼서 refundFee/expectedRefundAmount/usedTax/usedAirPrice 를 산출한다. 모델은 response/OrderReshopRS.kt:176-250.
5.3 예약/조회 응답 구조 (OrderViewRS)
certification/truereshop/LH/6_OrderChangeRS.xml 은 OrderChangeRQ 의 응답이 실제로 OrderViewRS 임을 보여준다(:16). 주목할 요소:
<PseudoCity Owner="F1">APMK</PseudoCity>— F1 = Triple/판매측 PNR 오너, LH = 항공사.<Warning Owner="LH">Infant ... is not yet confirmed</Warning>— 에러가 아닌 경고는 별도.<Order OrderID="LH220HL6HJUA4" Owner="LH">— 주문 본체.<TicketDocInfos><TicketDocInfo>— 발권 후 티켓 문서.
매핑은 OrderViewRS.Response.toBooking() (response/OrderViewRS.kt:50-109):
- PNR:
bookingReferences.first { otherId != null }.id(Triple측), subPnr:airlineId != null(항공사측). validatingCarrier = order.owner.take(2)— LX(스위스항공)는 owner가LXA로 와서 앞 2글자만 (OrderViewRS.kt:67).
6. 에러 처리 — <Errors> vs SOAP Fault
LH는 두 층위의 실패가 있다.
flowchart TD RECV["응답 수신"] RECV -->|"HTTP 200 + RS Errors Error Code Status Type 본문메시지"| APP["각 RS의 checkError code message 콜백<br/>LufthansaClient 오퍼레이션별 분기"] RECV -->|"HTTP 비정상 / SOAP Fault / 타임아웃"| FAULT["OkHttpError.handleSoapFaultException"] FAULT --> TO["isTimeout 시 검색은 emptyList 취소는 Slack 경보"] FAULT --> PARSE["soap errorData soapBody fault 파싱"]
-
애플리케이션 레벨 오류:
HTTP 200+<...RS><Errors><Error Code Status Type>본문메시지</Error>→ 각 RS의checkError {code, message -> ...}콜백(LufthansaClient 오퍼레이션별 분기) -
전송/프로토콜 레벨 오류: HTTP 비정상 · SOAP Fault · 타임아웃 →
OkHttpError.handleSoapFaultException(...)(ClientSupport.kt:62-74)isTimeout→ 검색은emptyList, 취소는 Slack 경보soap(errorData).soapBody.fault파싱
-
에러 모델:
response/Error.kt—@Code,@Status,@Type속성 +@JacksonXmlText value(본문 메시지). -
검색은
325(No inventory)/719(No fares)는 정상 빈 결과로 흡수, 그 외는 예외 (LufthansaClient.kt:113-124). -
가격은
"No fares found for booking class"메시지면SOLD_OUT으로 분기 (LufthansaClient.kt:179-186). -
취소는
"Checked In status not valid for Auto-Refund"메시지면 체크인됨 전용 에러 (LufthansaClient.kt:358-364).
자세한 에러 전략은 공통 에러 처리 및 LH 함정 참조.
연습문제: 봉투 추적
Q1.
search()요청이 만들어내는 최종 HTTP body의 최상위 XML 요소는 무엇이며, NDC<AirShoppingRQ>는 몇 단계 깊이에 있는가?정답 보기
<SOAP-ENV:Envelope>. 깊이는Envelope > Body > ns1:XXTransaction > REQ > AirShoppingRQ로 4단계 안쪽. 만드는 코드는LufthansaClient.soapRequestBodyConverter(LufthansaClient.kt:866-907).최상위는
Q2.
booking,reissue,savePayment,divide의 응답 클래스 공통점은? 어떻게 동작을 구별하나?정답 보기
OrderViewRS다. 요청도 모두OrderChangeRQ(booking만OrderCreateRQ). 동작 구별은 요청OrderChangeRQ안에 채워진 하위 요소(Payments/OrderServicing.AcceptOffer/PassengerServicing/OrderChangeParameters.Reason="DIV")로 한다. 팩토리:OrderChangeRQ.kt:107~291.넷 다 응답이
Q3. 응답 XML에서 NDC 본문만 뽑을 때 네임스페이스를
"*"와일드카드로 무시하는 이유는?정답 보기
xxs)·SOAP 네임스페이스가 섞여 있고 게이트웨이가 prefix를 바꿀 수 있어, 정확한 네임스페이스 매칭이 깨지기 쉽다. NDC XSD가targetNamespace없이 로컬명만 쓰는 것과도 맞물려,getElementsByTagNameNS("*", "OrderViewRS")처럼 "로컬 태그명만" 매칭하는 게 견고하다 (SoapExtensions.kt:36-38).응답 봉투는 FLX(