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>... 형태로 한 번 더 감싸서 HTTP POST로 전송한다. 인증은 세션리스(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 제거 핵
    }
}

여기서 핵심 단계는:

  1. objectMapper.writeValueAsBytes(request)@Qualifier("xmlMapper") 로 주입된 Jackson XmlMapper가 AirShoppingRQ 등 Kotlin 데이터 클래스를 NDC XML로 직렬화.
  2. childDocument("REQ", ...) — 직렬화된 NDC XML을 DOM으로 파싱해 <REQ> 자식으로 import (SoapExtensions.kt:170-185).
  3. .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-KeyocpApimSubscriptionKeyAzure API Management 구독키. 게이트웨이 1차 관문
HTTP 헤더IATAiataCodeIATA 대리점 번호
HTTP 헤더AgencyagencyName대리점명
HTTP 헤더PCCpseudoCityCodePseudo City Code
HTTP 헤더SOAPActionrequest.actionNDC 오퍼레이션명 (예: AirShoppingRQ)
HTTP 헤더Content-Type고정text/xml
HTTP 헤더Accept-Encoding고정gzip,deflate,br
SOAP idenu / puserName / password사용자 로그인/비밀번호 (평문)
SOAP idenagt / agtpwd / agtroleagencyName / agencyPassword / agencyRole대리점 자격 (예: Ticketing Agent)
SOAP idenagy / pseudocityiataCode / pseudoCityCodeIATA·PCC
SOAP scriptname${userName}-dispatch.flxdmFLXDM 디스패치 스크립트 지정 (Farelogix 라우팅)
SOAP tracetexttrace추적 식별자

코드: HTTP 헤더는 LufthansaClient.getHeaderMap() (LufthansaClient.kt:64-76), SOAP idensoapRequestBodyConverter (LufthansaClient.kt:877-885).

보안 지뢰: 비밀번호 평문 전송 + 로깅

userName/password/agtpwd 가 SOAP iden 속성에 평문으로 들어간다 (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/lufthansa

LufthansaProperties.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,
) : FunnelProperties

3. 요청/응답 전문 모델 — 오퍼레이션 ↔ NDC 메시지 매핑

LufthansaRequest 인터페이스(request/LufthansaRequest.kt)는 단 하나의 멤버 action(= SOAPAction 헤더 + NDC 루트 태그)만 강제한다. @get:JsonIgnore 이므로 XML 본문에는 직렬화되지 않는다.

interface LufthansaRequest {
    @get:JsonIgnore
    val action: String
}

3.1 오퍼레이션 → 요청 RQ → 응답 RS 표

어댑터 메서드NDC 요청 (action)응답 클래스비고
searchAirShoppingRQAirShoppingRS검색 전용 OkHttpClient(searchClient, 15초 타임아웃)
getFareRule / pricingOfferPriceRQOfferPriceRS동일 요청, 응답에서 운임규정 vs 가격 추출만 다름
bookingOrderCreateRQOrderViewRSPNR 생성
retrieve / retrieveForAncillaryOrderRetrieveRQOrderViewRS주문 조회
savePayment (발권)OrderChangeRQ.ofPaymentOrderViewRS현금(CA) 0원 결제로 발권
cancelOrderCancelRQOrderCancelRS자동 환불 취소
refundCalculateOrderReshopRQ.ofRefundCalculateOrderReshopRS환불 수수료 산정(Delete)
reissueSearchOrderReshopRQ.ofReissueSearchOrderReshopRS재발행 검색(Add+Delete, TrueReshop)
reissueOrderChangeRQ.ofReissueOrderViewRS재발행 확정
changeApisOrderChangeRQ.ofApisOrderViewRSAPIS(여권/연락처) 변경
divideOrderChangeRQ.ofDivideOrderViewRSLH 미지원 (코드만 존재)
bookAncillaries / purchaseAncillariesOrderChangeRQ.ofBook/ofPurchaseOrderViewRS부가서비스 예약/구매
searchExtraBaggagesServiceListRQServiceListRS수하물 부가서비스
searchSeatsSeatAvailabilityRQSeatAvailabilityRS좌석 부가서비스

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 필드값/생성의미
@Versionversion고정 "17.2"NDC 메시지 버전
@TransactionIdentifiertransactionIdentifierUUID...replace("-","")요청 고유 ID (매번 신규 난수)
<PointOfSale><Location><CountryCode>pointOfSale고정 "KR" (PointOfSale.kt)판매 국가
<Document><ReferenceVersion>document고정 "17.2" (Document.kt)메시지 버전 참조
<Party><Sender><TravelAgencySender>partyiataCode/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>originDestinationsOriginDestination(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.toFareItinerariesofferItem.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(보존할 서비스 ID ServiceRetainRequestIDs 계산).
// 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.xsdEDIST 공통 구조/타입 (PassengerType, ContactInformationType 등)
aidm_commontypes.xsdAIDM 공통 타입 (IATA_CodeType 등)
transaction.xsd트랜잭션 래퍼 타입
flx_augmentation.xsdFarelogix(FLX) 자체 확장 — 표준 NDC를 LH 게이트웨이가 확장한 부분
Backport_SecurePayment_17.2.xsd17.2 백포트 보안결제 타입

XSD 구조 특징

모든 XSD가 <xsd:schema ... elementFormDefault="qualified"> 이지만 targetNamespace 가 없다(AirShoppingRQ.xsd:3). 루트 요소는 <xsd:element name="AirShoppingRQ"> 처럼 단순 로컬명으로 선언된다(AirShoppingRQ.xsd:14). 그래서 어댑터의 Jackson 모델도 네임스페이스 없이 로컬 태그명만 @JacksonXmlProperty(localName=...) 로 맞춘다. 응답 추출 시 getElementsByTagNameNS("*", ...) 로 네임스페이스를 무시하는 이유와도 맞물린다. 핵심 타입 예: AirShoppingRQParameters(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"> 형태다(mockData LH-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) 가 reshopDifferentialoriginalOrderItem / reshopDue / penaltyAmount 를 더하고 빼서 refundFee/expectedRefundAmount/usedTax/usedAirPrice 를 산출한다. 모델은 response/OrderReshopRS.kt:176-250.

5.3 예약/조회 응답 구조 (OrderViewRS)

certification/truereshop/LH/6_OrderChangeRS.xmlOrderChangeRQ 의 응답이 실제로 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> 는 몇 단계 깊이에 있는가?

Q2. booking, reissue, savePayment, divide 의 응답 클래스 공통점은? 어떻게 동작을 구별하나?

Q3. 응답 XML에서 NDC 본문만 뽑을 때 네임스페이스를 "*" 와일드카드로 무시하는 이유는?