Korean Air — 프로토콜·전문

module-koreanair arch-infrastructure pattern-soap api-ndc config-properties

이 노트의 범위

Korean Air(대한항공, KE) 어댑터가 실제로 바이트를 주고받는 방법을 다룬다. NDC 메시지를 어떻게 만들고(직렬화), 어떤 봉투(SOAP)에 담아, 어떤 인증을 붙여 보내며, 응답을 어떻게 꺼내 파싱하는지를 추적한다. 오퍼레이션별 비즈니스 흐름은 koreanair-operations, 빠지기 쉬운 함정은 koreanair-pitfalls, 어댑터 공통 DTO 규약은 interfaces-dtos 참고.


1. 한눈에 보는 프로토콜 스택

KE는 “NDC V21.3”이라고 부르지만, 실제 전송은 순수 NDC XML 단독이 아니라 NDC 페이로드를 SOAP 봉투 안에 넣는 하이브리드 구조다. 이 점이 KE를 처음 만지는 사람을 가장 헷갈리게 한다.

flowchart TD
    T["Triple 예약 시스템"] -->|"내부 REST 호출"| Ctrl["KoreanairXxxController (interfaces 레이어)"]
    Ctrl --> Client["KoreanairClient (infrastructure 분석 대상)"]
    Client -->|"1) NDC XML 본문 생성 XmlMapper<br/>2) SOAP Envelope 로 한 번 더 감싸기 SOAPMessage<br/>3) HTTP 헤더 부착 구독키 IATA PCC SOAPAction"| Post["HTTP POST Content-Type text slash xml"]
    Post --> GW["Accelya 또는 FLX 게이트웨이 orgId F1"]
    GW --> KE["KE orgId KE"]
    KE --> Resp["SOAP 응답 Envelope 안 Body 안 IATA_XxxRS"]
    Resp -->|"4) soapBody elementTagName 로 NDC RS 만 추출<br/>5) XmlMapper 로 RS DTO 역직렬화"| Fold["KoreanairClient.fold success 또는 failure"]
  • HTTP 전송: HTTP POST, Content-Type: text/xml
  • 게이트웨이 경유: Accelya/FLX 게이트웨이(orgId="F1") → KE(orgId="KE")
  • 응답 추출 순서: soapBody(elementTagName)로 NDC RS만 추출 후 XmlMapper로 RS DTO 역직렬화

전송 방식 정리

항목근거
메시지 표준IATA NDC V21.3 (PayloadAttribute.version = "21.3")request/PayloadAttribute.kt:17
전송 프로토콜HTTP POST, Content-Type: text/xmlKoreanairClient.kt:84
봉투SOAP Envelope (NDC payload 를 Body 에 중첩)KoreanairClient.kt:549-593
직렬화기XmlMapper (Jackson XML) bean "xmlMapper"WebMvcConfiguration.kt:74
결제 채널별도 TCP 소켓 + SEED 암호화 고정폭 전문 (NicePay)KoreanairPaymentClient.kt

2. SOAP 봉투 구조 — NDC 를 두 번 감싼다

KoreanairClientClientSupport를 상속하며, 요청 본문을 만드는 핵심은 soapRequestBodyConverter 람다다 (KoreanairClient.kt:549-593). 이 람다가 만드는 봉투의 모양:

<SOAP-ENV:Envelope SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <SOAP-ENV:Header>
    <t:Transaction xmlns:t="http://www.w3.org/2001/XMLSchema">  <!-- 인증/세션 헤더 -->
      <tc>
        <iden u="..." p="..." pseudocity="..." agt="..." agtpwd="..."
              agtrole="..." agy="..."/>           <!-- 자격증명 (평문 속성) -->
        <agent user="..."/>
        <trace>...</trace>
        <script engine="FLXDM" name="nol_universe-ke-dispatch.flxdm"/>  <!-- 라우팅 스크립트 -->
      </tc>
    </t:Transaction>
  </SOAP-ENV:Header>
  <SOAP-ENV:Body>
    <ns1:XXTransaction xmlns:ns1="http://www.w3.org/2001/XMLSchema">
      <REQ>
        <IATA_AirShoppingRQ xmlns="...IATA_OffersAndOrdersMessage"> ... </IATA_AirShoppingRQ>
      </REQ>
    </ns1:XXTransaction>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

코드로 보면:

private val soapRequestBodyConverter: (KoreanairApiProperties) -> ((Any?) -> String) = { apiProperties ->
    { request ->
        soap(isEncoding = true) {
            header {
                headerElement("Transaction", KoreanairSoapHeaderNamespace.TRANSACTION) {
                    childElement("tc") {
                        childElement("iden") {
                            attribute("u") { apiProperties.userName }
                            attribute("p") { apiProperties.password }
                            // ... pseudocity / agt / agtpwd / agtrole / agy
                        }
                        childElement("script") {
                            attribute("engine") { "FLXDM" }
                            attribute("name") { "nol_universe-ke-dispatch.flxdm" }
                        }
                    }
                }
            }
            body {
                childElement("ns1:XXTransaction") {
                    attribute("xmlns:ns1") { "http://www.w3.org/2001/XMLSchema" }
                    childDocument("REQ", objectMapper.writeValueAsBytes(request).inputStream())
                }
            }
        }.replace(" xmlns=\"\"", "")  // ← 빈 네임스페이스 선언 제거 (중요)
    }
}

두 단계 직렬화 함정

  1. NDC 페이로드(AirShoppingRQ 등)는 objectMapper.writeValueAsBytes(request)로 먼저 독립적인 XML 문서가 된다 (KoreanairClient.kt:580).
  2. 그 XML 을 childDocument("REQ", ...)<REQ> 아래에 DOM import 한다 (SoapExtensions.kt:170-185).
  3. 마지막에 .replace(" xmlns=\"\"", "")빈 네임스페이스 선언을 문자열 치환으로 제거한다 (KoreanairClient.kt:583). 이는 Jackson XML 이 자식 요소에 xmlns=""를 남기는 버그성 출력을 우회하기 위한 임시방편이다. NDC DTO 의 네임스페이스 매핑을 잘못 만지면 이 치환만으로 해결되지 않으니 주의.

SOAP Header 의 정체 — 세션이 아니라 매 요청 자격증명

KE 는 stateful PNR 세션이 없다

Amadeus(TOPAS)나 Galileo 같은 GDS 와 달리, KE NDC 는 로그인 토큰을 받아 들고 다니는 세션 모델이 아니다. <iden> 속성에 사용자/패스워드/대리점 정보를 매 요청마다 평문 속성으로 동봉한다 (KoreanairClient.kt:554-563). 즉 인증 상태(state)는 어댑터가 보관하지 않고, 봉투 헤더가 자격증명 자체를 운반한다. → caller-callee-map에서 KE 가 “무상태” 그룹에 속하는 이유.

<script engine="FLXDM" name="nol_universe-ke-dispatch.flxdm"/>는 KE 가 직접 받는 게 아니라 Accelya/FLX 게이트웨이(Travel Ledger/FLX 플랫폼)가 실행할 디스패치 스크립트 이름이다. DistributionChainorgId="F1"(Accelya), orgId="KE"(대한항공) 구성과 함께 보면, KE NDC 는 Accelya 가 운영하는 NDC 게이트웨이를 경유한다는 사실을 알 수 있다.

헤더 <iden> 속성출처 (KoreanairApiProperties)의미
uuserNameNDC 사용자 ID
ppasswordNDC 패스워드
pseudocitypseudoCityCodePCC
agtagencyName대리점명
agtpwdagencyPassword대리점 패스워드
agtroleagencyRole대리점 역할
agyiataCodeIATA 번호
<trace> 텍스트trace추적 ID

3. HTTP 헤더 — 게이트웨이 구독키 + SOAPAction

NDC 봉투와 별개로 HTTP 레벨 헤더는 getHeaderMap()이 만든다 (KoreanairClient.kt:80-92):

fun getHeaderMap(request: KoreanairRequest): Map<String, String> {
    val apiProperties = koreanairProperties.getApiProperties()
    return mapOf(
        HttpHeaders.CONTENT_TYPE to MediaType.TEXT_XML_VALUE,      // text/xml
        HttpHeaders.ACCEPT_ENCODING to "gzip,deflate,br",
        "Ocp-Apim-Subscription-Key" to apiProperties.ocpApimSubscriptionKey,  // Azure APIM 구독키
        "IATA" to apiProperties.iataCode,
        "Agency" to apiProperties.agencyName,
        "PCC" to apiProperties.pseudoCityCode,
        "SOAPAction" to request.action,           // ← 오퍼레이션 식별
    )
}

Ocp-Apim-Subscription-Key

이 헤더는 **Azure API Management(APIM)**의 표준 구독키 헤더다. KE/Accelya 게이트웨이가 Azure APIM 뒤에 있음을 시사한다. 키가 잘못되면 NDC 레벨 오류가 아니라 APIM 게이트웨이 401/403 이 떨어지므로, NDC 응답 파싱 단계 이전에 실패한다 → koreanair-pitfalls.

SOAPAction 헤더 값은 각 RQ DTO 의 action 프로퍼티에서 나온다. KoreanairRequest 인터페이스가 이를 강제한다 (request/KoreanairRequest.kt):

interface KoreanairRequest {
    @get:JsonIgnore          // ← action XML 직렬화에서 제외
    val action: String
}

@JsonIgnore가 핵심이다. action은 HTTP 헤더/파일명 용도일 뿐, NDC 페이로드 XML 에는 들어가지 않는다.

RQ DTOaction / SOAPAction루트 element사용 오퍼레이션
AirShoppingRQIATA_AirShoppingRQ동일검색 (search)
OfferPriceRQIATA_OfferPriceRQ동일가격 재확인 (pricing)
OrderCreateRQIATA_OrderCreateRQ동일예약 생성 (book)
OrderRetrieveRQIATA_OrderRetrieveRQ동일예약 조회 (retrieve)
OrderReshopRQIATA_OrderReshopRQ동일환불계산 / 재발행검색 (refundCalculate, reissueSearch)
OrderChangeRQIATA_OrderChangeRQ동일발권/취소/재발행/연락처변경/분리 (issue, cancel, reissue, changeContactInfo, splitPnr)

한 RQ 가 여러 오퍼레이션을 처리

OrderChangeRQ 는 SOAPAction 이 하나(IATA_OrderChangeRQ)인데 발권/취소/재발행 등 6가지 시나리오를 모두 담당한다. 분기는 OrderChangeRQ.ofIssue / ofIssuedCancel / ofUnIssuedCancel / ofContactInfo / ofReissue / ofSplit 팩토리 함수가 내부 OrderChangeRequest(ChangeOrderChoice)를 다르게 구성해서 만든다 (request/OrderChangeRQ.kt:36-153). 오퍼레이션 구분을 SOAPAction 으로 하지 않고 본문 내용으로 한다는 점이 GDS 와 다른 NDC 특유의 설계다. → koreanair-operations


4. NDC 페이로드 모델 — 공통 골격

모든 RQ DTO 는 동일한 4개 상위 블록을 가진다. 아래는 AirShoppingRQ(request/AirShoppingRQ.kt) 기준이지만 OfferPriceRQ/OrderCreateRQ/OrderChangeRQ/OrderReshopRQ/OrderRetrieveRQ 가 모두 같은 구조다.

@JacksonXmlRootElement(localName = "IATA_AirShoppingRQ",
    namespace = KoreanairNameSpace.OFFERS_AND_ORDERS_MESSAGE)
data class AirShoppingRQ(
    @JacksonXmlProperty(localName = "DistributionChain") val distributionChain: DistributionChain,
    @JacksonXmlProperty(localName = "PayloadAttributes")  val payloadAttribute: PayloadAttribute? = PayloadAttribute(),
    @JacksonXmlProperty(localName = "POS")                val pointOfSale: PointOfSale = PointOfSale(),
    @JacksonXmlProperty(localName = "Request")            val request: Request,
) : KoreanairRequest { override val action = "IATA_AirShoppingRQ" }
블록클래스역할핵심 필드
DistributionChainrequest/DistributionChain.kt거래 참여 조직 체인3개 링크 고정: ①Carrier(우리 대리점) ②Distributor Accelya/F1 ③Carrier KE
PayloadAttributesrequest/PayloadAttribute.kt메시지 메타TrxID(UUID, 하이픈 제거), VersionNumber="21.3"
POSrequest/PointOfSale.kt판매지점Country
RequestRQ 별 상이실제 요청 본문검색조건/패신저/오퍼선택 등

네임스페이스 두 개 (KoreanairNameSpace.kt)

object KoreanairNameSpace {
    const val OFFERS_AND_ORDERS_MESSAGE =
        "http://www.iata.org/IATA/2015/EASD/00/IATA_OffersAndOrdersMessage"
    const val OFFERS_AND_ORDERS_MESSAGE_COMMON_TYPES =
        "http://www.iata.org/IATA/2015/EASD/00/IATA_OffersAndOrdersCommonTypes"
}

네임스페이스 분리가 곧 버그 지점

루트 element 와 최상위 블록(DistributionChain, Request…)은 ...Message 네임스페이스, 그 안쪽 자식(DistributionChainLink, Ordinal, OrgRole, FlightRelatedCriteria, Pax…)은 ...CommonTypes 네임스페이스를 쓴다. DistributionChainLink.kt의 모든 @JacksonXmlProperty(namespace = ...COMMON_TYPES)가 그 증거다. 한 DTO 안에서 네임스페이스가 섞이기 때문에, 새 필드 추가 시 네임스페이스를 빠뜨리면 KE 가 element 를 무시하거나 schema validation 오류를 낸다 → koreanair-pitfalls.

DistributionChain — KE 배포 체인은 하드코딩

fun of(apiProperties: KoreanairApiProperties): DistributionChain = DistributionChain(listOf(
    DistributionChainLink(ordinal = 1, originRole = "Carrier",
        participatingOrg = ParticipatingOrg(name = apiProperties.agencyName, orgId = apiProperties.userName),
        salesAgent = SalesAgent(apiProperties.agencyName),
        salesBranch = SalesBranch(apiProperties.pseudoCityCode)),
    DistributionChainLink(ordinal = 2, originRole = "Distributor",
        participatingOrg = ParticipatingOrg(name = "Accelya", orgId = "F1")),   // ← 고정
    DistributionChainLink(ordinal = 3, originRole = "Carrier",
        participatingOrg = ParticipatingOrg(orgId = "KE")),                     // ← 고정
))

Accelya/F1KE 는 코드에 하드코딩되어 있다 (request/DistributionChain.kt:30-43). 다른 항공사로 이 모듈을 재사용할 수 없다는 의미이며, KE 전용 모듈임을 분명히 한다.

PayloadAttribute — TrxID 와 버전

data class PayloadAttribute(
    @JacksonXmlProperty(localName = "TrxID", namespace = ...COMMON_TYPES)
    val transactionId: String? = UUID.randomUUID().toString().replace("-", ""),   // 매 요청 새 UUID
    @JacksonXmlProperty(localName = "VersionNumber", namespace = ...COMMON_TYPES)
    val version: String = "21.3",
)

TrxID는 기본값으로 매 인스턴스마다 새 UUID 가 생성된다. NDC V21.3 임을 선언하는 VersionNumber도 여기서 박힌다.

AugmentationPoint — 비표준 확장(결제 정보 운반)

OrderChangeRQ 의 발권/재발행은 AugmentationPoint카드 승인번호와 할부개월을 NDC 표준 밖 확장 슬롯에 실어 보낸다 (request/OrderChangeRQ.kt:134-153, request/AugmentationPoint.kt):

private fun createPaymentAugmentationPoints(payment: Payment): List<AugmentationPoint> = buildList {
    add(AugmentationPoint(approvalCode = payment.approvalNumber,        // CCApprovalCode
                          association = Association(PaymentId.CARD_PAYMENT)))
    payment.installment.takeIf { it > 1 }?.run {
        add(AugmentationPoint(installment = this,                       // ExtendedPaymentCode
                              association = Association(PaymentId.CARD_PAYMENT)))
    }
}

AugmentationPoint = NDC 의 "구현자 확장 구조"

XSD 에서 AugmentationPointcns:AugmentationPointType(IATA_AirShoppingRQ.xsd:27, “Implementer-Augmented Structure”)로 정의된 표준 확장 지점이다. KE 는 이 슬롯에 외부 PG(NicePay)에서 받은 카드 승인 결과를 담아 발권 시 함께 전달한다. 즉 KE 결제 흐름은 “외부 PG 승인 → 승인번호를 NDC 발권 요청에 첨부” 라는 2단계다 → 6절·koreanair-operations.


5. 응답 전문 파싱 — SOAP Body 에서 RS 만 꺼낸다

응답은 ClientSupport.execute<T>()에 넘기는 soapBodyDeserializerOf 람다가 처리한다 (KoreanairClient.kt:56-75):

inline fun <reified T : Any> soapBodyDeserializerOf(
    logger, mapper, elementTagName, env,
): (String, Response) -> T = { content, _ ->
    // (local 프로파일이면 certi/ 폴더에 응답 원문 .xml 저장 — certification 디버깅용)
    soap(content).soapBody(objectMapper = mapper, elementTagName = elementTagName)
}

soapBody(elementTagName)SoapExtensions.kt:36-38:

inline fun <reified T : Any> SOAPMessage.soapBody(objectMapper, elementTagName): T =
    objectMapper.readValue(
        this.soapBody.getElementsByTagNameNS("*", elementTagName).item(0).asString()  // 네임스페이스 무시 태그검색
    )

getElementsByTagNameNS("*", tag) 의 의미

응답 SOAP Body 안 어디에 묻혀 있든, 네임스페이스를 무시하고("*") IATA_AirShoppingRS 같은 루트 RS element 를 첫 번째로 찾아 그 부분만 XmlMapper 로 역직렬화한다. SOAP 봉투/래퍼 구조에 둔감하게 만들어 견고성을 높이지만, 동일 태그명이 본문 내 중첩되면 첫 번째만 잡는다는 함정이 있다.

각 RS 루트 element 명은 RS DTO 의 ROOT_ELEMENT_NAME 상수에 박혀 있다:

RS DTOROOT_ELEMENT_NAME최상위 구조매핑 산출물
AirShoppingRS (response/AirShoppingRS.kt:25)IATA_AirShoppingRSError* + Response(DataLists+OffersGroup) + AugmentationPoint*List<FareItinerary>
OfferPriceRSIATA_OfferPriceRSError* + ResponseOfferPriceInfo
OrderViewRS (response/OrderViewRS.kt:20)IATA_OrderViewRSError* + Response + PaymentFunctions*Booking
OrderReshopRSIATA_OrderReshopRSError* + ResponseCancelableTypeDetail / List<FareItinerary>

응답은 단일 IATA_OrderViewRS 로 수렴

예약(book)·발권(issue)·취소·재발행·연락처변경·분리·조회(retrieve) — Order 계열 변경/조회 결과는 모두 OrderViewRS 로 돌아온다(KoreanairClient.ktbook/retrieve/orderChange 가 전부 execute<OrderViewRS>). 그래서 OrderViewRS.toBooking() 하나가 모든 변경 후의 최종 예약 상태를 만든다 → koreanair-operations.

에러 판별 — HTTP 200 안에 비즈니스 에러

KE 는 비즈니스 실패도 HTTP 200 + 본문 <Error> 로 내려보낸다. 각 RS 의 checkError(AirShoppingRS.kt:28-33 등)가 첫 ErrorCode/DescText를 콜백으로 넘긴다.

data class Error(
    @JacksonXmlProperty(localName = "Code")     val code: String?,
    @JacksonXmlProperty(localName = "DescText") val descriptionText: String?,
)

검색에서 "에러지만 정상"인 코드

KoreanairClient.search()(KoreanairClient.kt:134-147)는 Error 가 있어도 code == "325"(No inventory available)·"719"(No fares available)면 예외를 던지지 않고 빈 결과로 처리한다. “좌석/운임 없음”은 장애가 아니라 정상 검색 결과이기 때문. → error-handling, koreanair-pitfalls.

SOAP Fault(전송 레벨 실패)는 별개로 failure.handleSoapFaultException(...)이 처리하며, 발권/취소는 failure.isTimeouttimeoutCallback()을 호출해 망취소(타임아웃 후속 처리)를 트리거한다 (KoreanairClient.kt:296-300, 345-349). → error-handling.


6. 결제 채널 — NDC 와 완전히 분리된 SEED 소켓 전문

KE 카드 결제는 NDC 가 아니다. KoreanairPaymentClient(KoreanairPaymentClient.kt)가 NicePay PG 와 raw TCP 소켓으로, SEED 암호화 고정폭(fixed-width) 바이트 전문을 EUC-KR 로 주고받는다. 완전히 다른 프로토콜이므로 별도로 다룬다.

flowchart TD
    Approve["KoreanairPaymentClient.approve()"]
    Approve -->|"NicePayApproveRequest.of 생성 고정폭 353바이트 track2data 는 SEED 암호화<br/>Socket host port soTimeout 5000 java.net.Socket 직접 사용<br/>serializeToLiteralTextByByte EUC-KR 후 output.write<br/>input.read 루프 캐리지리턴 만나면 종료"| Resp["NicePayApproveResponse 고정폭 파싱 responseCode 가 0000 아니면 에러"]
    Resp -->|"승인번호 approvalNumber 획득"| Aug["OrderChangeRQ.ofIssue 의 AugmentationPoint 로 전달 4절 참고"]
  • 요청 생성: NicePayApproveRequest.of(...), 고정폭 353바이트, track2data는 SEED 암호화
  • 소켓: Socket(host, port, soTimeout=5000) (java.net.Socket 직접 사용)
  • 직렬화/전송: serializeToLiteralTextByByte(EUC-KR)output.write
  • 수신 루프: input.read'\r'을 만나면 종료
  • 응답: NicePayApproveResponse 고정폭 파싱, responseCode != "0000"이면 에러
  • 후속: 획득한 approvalNumberOrderChangeRQ.ofIssueAugmentationPoint로 전달(4절 참고)

고정폭 전문 직렬화 — @ByteRange + @SeedEncrypt

전문 레이아웃은 DTO 필드의 어노테이션으로 선언한다 (request/nicepay/NicePayApproveRequest.kt):

data class NicePayApproveRequest(
    @ByteRange(start = 0, end = 2)  val version: String = "KA",        // 전문 버전
    @ByteRange(start = 2, end = 4)  val type: String = "CC",           // CC=신용
    @ByteRange(start = 4, end = 6)  val jobKind: String = "D1",        // D1=승인
    @ByteRange(start = 7, end = 17) val terminalId: String,            // 단말기번호
    @SeedEncrypt
    @ByteRange(start = 65, end = 129) val track2data: String,          // 카드번호=유효기간 (SEED 암호화)
    @ByteRange(start = 130, end = 132) val installment: String,        // 할부 '00'=일시불
    @ByteRange(start = 132, end = 144) val amount: String,             // 거래금액
    // ... end = '\r' (352~353)
)
어노테이션정의동작
@ByteRange(start,end)support/annotation/ByteRange.kt필드의 바이트 오프셋 구간. 부족하면 공백 패딩, 초과하면 절단
@SeedEncryptsupport/annotation/SeedEncrypt.kt해당 필드를 SEED 암호화 후 Base64 인코딩하여 배치

직렬화 엔진은 리플렉션 기반 serializeToLiteralTextByByte(support/util/ReflectionUtils.kt:102-154):

val processed = if (needsEncryption)
    SeedEncryptor.encrypt(plainText = strValue, seedKey, iv)   // SEED/CBC/PKCS5Padding → Base64
else strValue
val padded = when {
    bytes.size < targetSize -> /* 공백(0x20) 패딩 */
    bytes.size > targetSize -> bytes.copyOf(targetSize)        // 절단
    else -> bytes
}

SEED 암호화 본체는 SeedEncryptor(support/util/SeedEncryptor.kt)로, BouncyCastle 의 SEED/CBC/PKCS5Padding 을 쓴다. 키(seedKey)와 IV(iv)는 KoreanairApiProperties.NicePayProperties 설정값이다.

object SeedEncryptor {
    private const val TRANSFORMATION = "SEED/CBC/PKCS5Padding"
    fun encrypt(plainText, seedKey, iv): String { /* BC provider, Base64 인코딩 반환 */ }
}

결제 전문 함정 모음

  • 인코딩이 EUC-KR: NDC(UTF-8)와 달리 결제 전문은 Charset.forName("EUC-KR")로 송수신한다 (KoreanairPaymentClient.kt:104,112). 바이트 오프셋(@ByteRange)도 EUC-KR 바이트 기준이라 한글이 섞이면 위치가 어긋난다.
  • 응답 종료 판정이 '\r': input.read 루프가 chunkedMessage.contains('\r')이면 멈춘다 (KoreanairPaymentClient.kt:114). 응답에 \r이 없으면 무한 대기 → soTimeout=5000(5초)로만 방어.
  • 거래일련번호 생성 규칙: transactionNumber = (pnr + Base62(주차+요일+연+시간)).padStart(12,'0') (NicePayApproveRequest.kt:129-134). 망상취소(timeout 후 취소) 식별에 쓰이므로 멱등성에 중요.
  • 승인 타임아웃 콜백: SocketTimeoutExceptiontimeoutCallback(transactionNumber, message)로 후속 망취소를 위임한다 (KoreanairPaymentClient.kt:60-62).
  • 자세한 결제 시퀀스/취소 보상은 koreanair-operations, koreanair-pitfalls 참고.

응답 파싱은 동일하게 @ByteRange로 선언된 NicePayApproveResponse(response/nicepay/NicePayApproveResponse.kt)를 deserializeOfLiteralTextByByte<RES>(line, "EUC-KR")로 역직렬화하며, responseCode != "0000"이면 에러로 본다.


7. XSD 스키마 위치와 핵심 타입

KE 의 NDC 계약(스키마)은 테스트 리소스에 번들되어 있다. 런타임 검증용이 아니라 DTO 작성/검증 시 참조용 문서다.

src/test/schema/koreanair/NDC_V21.3_Schema_V2025.2/
├── IATA_AirShoppingRQ.xsd / IATA_AirShoppingRS.xsd
├── IATA_OfferPriceRQ.xsd  / IATA_OfferPriceRS.xsd
├── IATA_OrderCreateRQ.xsd
├── IATA_OrderChangeRQ.xsd
├── IATA_OrderReshopRQ.xsd / IATA_OrderReshopRS.xsd
├── IATA_OrderRetrieveRQ.xsd
├── IATA_OrderViewRS.xsd
├── IATA_OrderRulesRQ.xsd  / IATA_OrderRulesRS.xsd   ← FareRule
├── IATA_ServiceListRQ.xsd / IATA_ServiceListRS.xsd
├── IATA_SeatAvailabilityRQ/RS.xsd, IATA_OrderListRQ/RS.xsd ...
├── IATA_OffersAndOrdersCommonTypes.xsd               ← 공통 복합타입 (DistributionChainType 등)
├── IATA_OffersAndOrdersCommonTypesFullyOptional.xsd
├── IATA_CommonTypes.xsd / IATA_SimpleTypes.xsd       ← 기본 타입
├── flx_augmentation.xsd                              ← Accelya/FLX 확장 (AugmentationPoint)
├── transaction.xsd                                   ← SOAP <Transaction> 헤더 스키마
└── xmldsig-core-schema.xsd                           ← XML 서명 (사용 안 함)

스키마 버전 라벨 주의

폴더명은 NDC_V21.3_Schema_V2025.2지만, IATA_AirShoppingRQ.xsd의 실제 schema 속성은 version="9.002" id="IATA2024.1"이다 (IATA_AirShoppingRQ.xsd:3,6). 즉 “21.3”은 KE/Accelya 가 부여한 배포 버전 라벨이고, 내부 IATA 스키마 리비전은 별도다. 코드의 PayloadAttribute.version = "21.3"은 폴더 라벨과 일치시킨 값.

핵심 타입 정리 (IATA_OffersAndOrdersCommonTypes.xsd 기반, IATA_AirShoppingRQ.xsd에서 참조):

XSD 타입대응 Kotlin DTO용도
DistributionChainTypeDistributionChain거래 참여 조직 체인
IATA_PayloadStandardAttributesTypePayloadAttributeTrxID/VersionNumber
POS_TypePointOfSale판매지점
AirShoppingRequestTypeRequest검색 요청 본문
AugmentationPointTypeAugmentationPoint구현자 확장 슬롯(결제 정보)

transaction.xsdflx_augmentation.xsd

이 둘은 순수 IATA 표준이 아니라 Accelya/FLX 게이트웨이가 추가한 스키마다. transaction.xsd는 2절에서 본 SOAP <Transaction> 인증 헤더의 구조를, flx_augmentation.xsd는 KE 가 결제 정보를 싣는 확장 구조를 정의한다. KE 가 “순수 NDC”가 아니라 “Accelya 게이트웨이 위의 NDC”임을 스키마 차원에서도 확인할 수 있다.


8. 실제 전문 예시

mockData 부재

src/test/resources/mockData/koreanair/ 디렉터리는 존재하지 않으며, KE 의 샘플 RQ/RS XML 파일은 리포지토리에 번들되어 있지 않다(2026-06 기준 확인). 따라서 본 노트의 XML 예시는 mockData 인용이 아니라 위 코드(2~4절)에서 직접 재구성한 모형이다.

실전에서 실제 전문을 보고 싶으면 local 프로파일로 실행하면 된다. soapRequestBodyConverter(요청)와 soapBodyDeserializerOf(응답) 모두 env.isLocalProfile()일 때 certi/yyMMdd/ 폴더에 ..._RQ_{action}.xml / ..._RS_{element}.xml로 원문을 떨군다 (KoreanairClient.kt:64-68, 584-590). 이 “certification 디버깅 덤프”가 KE 인증(certification) 단계의 흔적이다.

아래는 검색(AirShopping) 요청 페이로드(SOAP <REQ> 내부의 NDC 부분)를 코드 기준으로 재구성한 골격:

<IATA_AirShoppingRQ xmlns="...IATA_OffersAndOrdersMessage">
  <DistributionChain>
    <cns:DistributionChainLink>
      <cns:Ordinal>1</cns:Ordinal><cns:OrgRole>Carrier</cns:OrgRole>
      <cns:ParticipatingOrg>...우리 대리점...</cns:ParticipatingOrg>
      <cns:SalesAgent><cns:SalesAgentID>...</cns:SalesAgentID></cns:SalesAgent>
      <cns:SalesBranch><cns:SalesBranchID>...PCC...</cns:SalesBranchID></cns:SalesBranch>
    </cns:DistributionChainLink>
    <cns:DistributionChainLink>  <!-- Ordinal=2 Distributor Accelya/F1 -->
    <cns:DistributionChainLink>  <!-- Ordinal=3 Carrier KE -->
  </DistributionChain>
  <PayloadAttributes>
    <cns:TrxID>e3b0c44298fc...</cns:TrxID><cns:VersionNumber>21.3</cns:VersionNumber>
  </PayloadAttributes>
  <POS><cns:Country>...</cns:Country></POS>
  <Request>
    <cns:FlightRequest>...구간/날짜/Cabin...</cns:FlightRequest>
    <cns:PaxList>
      <cns:Pax>...T1 ADT...</cns:Pax>            <!-- 성인은 T1.1 유아 참조 가능 -->
    </cns:PaxList>
  </Request>
</IATA_AirShoppingRQ>

cns: 프리픽스는 무엇?

위 예시의 cns:...CommonTypes 네임스페이스 프리픽스다. 4절에서 설명한 “최상위는 Message NS, 자식은 CommonTypes NS” 규칙이 XML 로 드러난 모습. Jackson XML 이 자동으로 프리픽스를 붙이며, Request.of()(request/Request.kt:39-94)가 성인(T1)·아동·유아(T1.1) Pax ID 와 유아-성인 참조를 만든다.


9. 정리 — KE 프로토콜 핵심 5가지

  1. NDC(V21.3) 페이로드를 SOAP 봉투에 중첩한다. NDC 단독 XML 도, 순수 SOAP RPC 도 아닌 하이브리드.
  2. 세션 없는 무상태 인증: SOAP Header <iden> 속성에 자격증명을 매 요청 평문 운반. Azure APIM 구독키는 HTTP 헤더로.
  3. Accelya/FLX 게이트웨이 경유(orgId F1→KE, FLXDM 스크립트, transaction.xsd/flx_augmentation.xsd). 순수 KE 직결이 아니다.
  4. 결제는 완전 별개 채널: NicePay 와 raw TCP 소켓 + SEED 암호화 고정폭 EUC-KR 전문. 승인번호는 AugmentationPoint로 NDC 발권 요청에 첨부.
  5. 응답은 HTTP 200 + 본문 <Error> 로 비즈니스 실패를 표현. 검색의 325/719 는 “정상” 코드.

더 읽기