T’way Air — 프로토콜·전문

module-tway arch-infrastructure pattern-protocol api-rest

한 줄 요약

T’way Air는 LCC지만 SOAP가 아닌 “REST 위에 XML 전문” 구조다. POST {endpoint}/{shop|book}/{Operation}Content-Type: application/xml XML 바디를 보내고, 인증은 HTTP Basic Auth 헤더(username=agencyCode, password=SEED암호화된 비밀번호)로 한다. 카드번호·카드비밀번호·대리점결제비밀번호 같은 민감 정보는 모두 사내 배포 라이브러리 TwaySEED.jarSeedUtil.encoding()(KISA SEED-CBC + Base64)으로 암호화해 전문에 싣는다.

관련 노트: T’way 오퍼레이션 · T’way 지뢰 · DTO · 설정·인프라 · 복원력·이벤트


1. 프로토콜 개요 — “REST 봉투 안의 XML 전문”

T’way는 공급사 특성표상 LCC/REST 로 분류되지만, 실제 와이어 포맷은 GDS 스타일의 XML 메시지다. SOAP Envelope(<soap:Envelope>)도 NDC 스키마도 아니고, 루트 엘리먼트가 곧 메시지 봉투(...RQmsg / ...RSmsg)인 평범한 XML을 HTTP POST 바디에 그대로 싣는다.

flowchart TD
    A["Triple 예약 시스템"] -->|"내부 REST 호출"| B["TwayController → TwayService"]
    B --> C
    subgraph C["TwayClient (ClientSupport 상속, OkHttp)"]
        C1["POST endpoint/shop/AirAvailability"]
        C2["Authorization 헤더 Basic base64 agencyCode SEED"]
        C3["Content-Type application/xml"]
        C4["요청 봉투 AirAvailabilityRQmsg 안에 AirAvailabilityRQ"]
        C1 --> C2 --> C3 --> C4
    end
    C -->|"HTTP/1.1 (OkHttp)"| D["T'way 항공사 API (shop / book 도메인)"]
    D --> E["응답 봉투 AirAvailabilityRSmsg 안에 AirAvailabilityRS"]

전송 사실은 TwayClient.kt 전체에서 동일한 관용구로 확인된다.

// TwayClient.kt:89-95 (search 예시)
return "${twayApiProperties.endpoint}/shop/AirAvailability"
    .post(request)                                                   // POST + XML 바디
    .client(searchClient)                                            // 검색은 15s 타임아웃 클라이언트
    .authenticate(twayApiProperties.agencyCode,
                  SeedUtil.encoding(twayApiProperties.password))     // Basic Auth (SEED 암호화 PW)
    .header(headerMap)                                               // Content-Type: application/xml
    .log(enableSearchLog.or(logging))
    .execute<AirAvailabilityRSmsg>()                                 // XmlMapper로 역직렬화

왜 "REST/XML" 인가

다른 GDS(amadeus/sabre/galileo)는 SOAP Envelope + WS-Security 헤더를 쓴다(support/util/...soap). T’way는 그 무게를 버리고 엔드포인트 경로로 오퍼레이션을 구분(REST스럽게) 하되, 바디는 항공 도메인 XML을 그대로 쓴다. 그래서 TwayClient는 SOAP 유틸을 호출하지 않고, OkHttp에 XML 문자열을 그냥 POST한다.

1.1 엔드포인트 컨벤션 (shop vs book)

오퍼레이션은 두 개의 논리 도메인으로 나뉜다. 베이스 URL은 twayApiProperties.endpoint 하나이고 그 뒤에 도메인/오퍼레이션 경로가 붙는다.

도메인성격대표 오퍼레이션 (경로)
/shop/*조회·견적(읽기 위주)RetrieveRoute, AirAvailability, ThroughFlightInfo, FareQuote, FareRuleDescriptionByRoute, AncillaryAvailability, BaggageAvailability, SeatAvailability
/book/*예약·발권·변경(상태 변경)MarkSeats, CreateBooking, RetrieveBooking, ConfirmPrice, ConfirmCancelPrice, CancelBooking, ModifyBooking, EnhancedAirAvailability, TravelDocuments, ChangeGuestContactPoint, SplitBooking, RetrieveAgencyCredit, ReleaseAncillary

디버깅 단서

와이어 로그(SUPPLIER.TwayClient)에서 어떤 오퍼레이션인지 보려면 URL의 마지막 path 세그먼트를 보면 된다. /shop/이면 무해한 조회, /book/이면 PNR 상태가 바뀌는 호출이다. 자세한 호출별 의미는 tway-operations 참조.


2. 인증·세션 — Basic Auth + SEED 암호화

2.1 세션이 없다 (Stateless)

amadeus/galileo 같은 GDS와 달리 T’way는 로그인 토큰이나 PNR 세션 상태를 어댑터가 들고 있지 않다. 매 요청마다 동일한 Authorization 헤더를 새로 만들어 붙이는 stateless 인증이다.

// ClientSupport.kt:131-134
fun authenticate(userName: String, password: String): OkHttpRequestBuilder {
    this.header.put("Authorization", Credentials.basic(userName, password))   // RFC7617 Basic
    return this
}

Credentials.basic(OkHttp)은 Basic base64("userName:password") 헤더를 만든다. 즉 T’way 인증 자격은:

Basic Auth 슬롯출처
usernameagencyCode (대리점 코드, 평문)TwayApiProperties.agencyCode
passwordSeedUtil.encoding(password) (SEED 암호화 + Base64)TwayApiProperties.password 를 매 호출마다 암호화

TwayClient모든 메서드가 .authenticate(agencyCode, SeedUtil.encoding(password)) 를 반복한다(TwayClient.kt:58, 92, 155, 185, 223, 273, 307, 327, 363, 418, 464, 567, 607, 649, 700, 755, 797, 821, 846, 897, 959, 989, 1014).

"PnrSessionId"는 인증 세션이 아니다

markSeat이 응답으로 돌려주는 pnrSessionId(MarkSeatsRS.pnrSessionId)와 ModifyBooking/CreateBooking에 다시 넣는 값은 인증 세션이 아니라 “좌석 점유(hold) 토큰” 이다. 좌석 마킹 → 예약 생성/재발행 사이의 짧은 워크플로우 상관관계용 식별자다. 인증 헤더(Basic Auth)는 이와 무관하게 매번 독립적으로 만들어진다. 워크플로우는 tway-operations 참조.

2.2 SEED 암호화 — TwaySEED.jar

T’way가 요구하는 암호화는 한국 표준 블록암호 SEED다. 어댑터는 직접 구현하지 않고 T’way가 배포한 jar를 그대로 로컬 의존성으로 포함한다.

// build.gradle.kts:67
implementation(files("libs/TwaySEED.jar"))

jar 패키지 com.twayair.security.seed 안에는 3개의 클래스가 들어있다(jar 내부 구조 확인 결과).

클래스역할
SeedUtil진입점. encoding(txt) / decoding(txt) / Encrypt(...) / Decrypt(...)
SEED_KISAKISA SEED 알고리즘 코어(SeedEncrypt, SeedDecrypt, SeedRoundKey, S-box SS0~SS3)
Base64암호문 → ASCII Base64 인코딩

SeedUtil 클래스 상수 풀에서 다음을 확인했다(클래스 파일 디스어셈블).

  • 하드코딩 SEED 키: 정적 필드 seedKey = "twayBookingAPI@014" (<clinit>에서 초기화). → 키가 jar에 박혀 있고, 호출자가 키를 주입하지 않는다.
  • 문자 인코딩: UTF-8 (평문 getBytes("UTF-8")).
  • 모드: CBC. 코드의 cbcPad, PrevData, outDataBlock 변수, SeedEncrypt(Data, RoundKey, ...) 라운드 처리에서 블록 체이닝 + 패딩 확인.
  • 출력: Base64.encodeBytes(...) → 헤더/XML에 그대로 들어갈 수 있는 ASCII 문자열.
flowchart TD
    A["평문"] -->|"UTF-8 getBytes"| B["byte 배열"]
    B -->|"SEED_KISA.SeedRoundKey(seedKey=twayBookingAPI@014)"| C["라운드키 생성"]
    C -->|"SEED_KISA.SeedEncrypt(블록, 라운드키) — CBC 체이닝 + 패딩"| D["암호 byte 배열"]
    D -->|"Base64.encodeBytes(...)"| E["Base64 문자열 (예 C8zxnPOLEfZi+mwq4vDvcA==)"]

SeedUtil.encoding(평문) 흐름. 키는 jar 내부 하드코딩 상수 seedKey="twayBookingAPI@014", 문자 인코딩은 UTF-8, 모드는 CBC(체이닝+패딩), 최종 출력은 헤더/XML에 그대로 들어갈 수 있는 ASCII Base64 문자열이다.

지뢰 — 키가 jar에 하드코딩, 라이브러리는 빌드 산출물에 직접 포함

  1. seedKeyTwaySEED.jar 내부에 평문 상수로 박혀 있다. 키 로테이션은 어댑터 코드가 아니라 T’way가 새 jar를 주는 방식으로만 가능하다. 키를 바꾸려면 libs/TwaySEED.jar 교체 → 재빌드.
  2. implementation(files("libs/TwaySEED.jar"))로컬 파일 의존성이라 Maven 중앙저장소·내부 Nexus에 없다. 클린 체크아웃 시 libs/TwaySEED.jar 파일이 없으면 컴파일 자체가 깨진다. CI/Docker 빌드에서 이 파일이 반드시 따라가야 한다(build-deploy-config 참조).
  3. jar의 decoding()은 양방향이므로, 로그에 SEED 문자열이 남으면 키를 아는 사람은 복호 가능. 카드번호/비밀번호가 든 /book/ModifyBooking 요청은 절대 평문 바디 로깅 금지.

2.3 SEED로 암호화되는 필드 목록

SeedUtil.encoding() 호출 지점을 코드 전역에서 수집하면, T’way 전문에서 암호화 대상이 정확히 어디인지 알 수 있다.

암호화 필드호출 위치(file:line)XML 위치
인증용 비밀번호(Basic Auth password)TwayClient.kt 거의 모든 메서드의 .authenticate(...)HTTP Authorization 헤더
대리점 결제 비밀번호(AGPassword)TwayClient.kt:602, 645SeedUtil.encoding(paymentPassword)PaymentSummary > CCAGInfo > AGPassword
카드번호CardInfo.kt:24...OfflineAuthInfo > CardNumber
카드 유효기간(YYMM)CardInfo.kt:25OfflineAuthInfo > ExpireDate
생년월일/사업자번호CardInfo.kt:26-31OfflineAuthInfo > RegistrationNumber
카드 비밀번호CardInfo.kt:32OfflineAuthInfo > CardPassword
부가서비스 토큰: PNRTwayAncillaryIssueTokenRequest.kt:14토큰발급 REST 쿼리파라미터 encPnrNumber
부가서비스 토큰: 대리점코드TwayAncillaryIssueTokenRequest.kt:15쿼리파라미터 agencyCode
부가서비스 딥링크: 승객명TwayAncillaryDeepLink.kt:24-26딥링크 URL 파라미터 name (URL 인코딩 추가)

OfflineAuthInfo.kt에는 클래스 주석으로 못박혀 있다.

// OfflineAuthInfo.kt:5
//Must be encrypted seed
data class OfflineAuthInfo(
    @JacksonXmlProperty(localName = "CardNumber")  val cardNumber: String,   // SEED 암호문이 들어와야 함
    ...

CardInfo는 "이미 암호화된 값"을 담는 운반체

CardInfo.of(...)(CardInfo.kt:20)가 평문 카드정보를 받아 그 자리에서 SeedUtil.encoding()을 호출한다. 즉 OfflineAuthInfo의 모든 필드는 호출자가 평문을 넣더라도 of()를 거치면 자동으로 SEED 암호문으로 바뀐다. 직접 OfflineAuthInfo(...)를 생성하면 암호화가 누락되니 반드시 CardInfo.of()를 통해 만들 것.


3. 전문(메시지) 모델 구조

3.1 봉투 패턴 — RQmsg / RSmsgRQ / RS

모든 T’way 전문은 2단 래핑 구조다. Jackson XML 애너테이션으로 매핑된다.

flowchart TD
    subgraph REQ["요청"]
        Q1["XxxRQmsg — JacksonXmlRootElement localName XxxRQmsg"]
        Q2["XxxRQ — JacksonXmlProperty localName XxxRQ"]
        Q3["실제 도메인 필드들"]
        Q1 --> Q2 --> Q3
    end
    subgraph RES["응답"]
        S1["XxxRSmsg — JacksonXmlRootElement localName XxxRSmsg"]
        S2["XxxRS — JacksonXmlProperty localName XxxRS"]
        S3["실제 도메인 필드들"]
        S4["ErrorType? — 비즈니스 에러는 여기로 (HTTP 200이어도)"]
        S1 --> S2
        S2 --> S3
        S2 --> S4
    end

예 — AirAvailabilityRQmsg.kt:8-12:

@JacksonXmlRootElement(localName = "AirAvailabilityRQmsg")
data class AirAvailabilityRQmsg(
    @JacksonXmlProperty(localName = "AirAvailabilityRQ")
    val airAvailabilityRQ: AirAvailabilityRQ
)

RQmsg는 거의 항상 of(...) 팩토리만 들고 있다

*RQmsg.kt 파일들은 필드가 *RQ 하나뿐이고, companion object { fun of(...) }로 도메인 객체(Booking, Passenger, FareItinerary)를 받아 내부 *RQ를 조립한다. 따라서 “어떤 필드가 와이어에 나가나” 를 보려면 *RQmsg가 아니라 한 단계 안쪽 *RQ 파일을 봐야 한다.

3.2 RQmsg ↔ 오퍼레이션 ↔ 엔드포인트 대응표

TwayClient가 만들어 보내는 요청 봉투와 받는 응답 봉투의 전체 매핑이다.

오퍼레이션 (TwayClient 메서드)엔드포인트요청 봉투응답 봉투
retrieveRoute/shop/RetrieveRouteRetrieveRouteRQmsgRetrieveRouteRSmsg
search/shop/AirAvailabilityAirAvailabilityRQmsgAirAvailabilityRSmsg
getHiddenStopsMap/shop/ThroughFlightInfoThroughFlightInfoRQmsgThroughFlightInfoRSmsg
doPricing/shop/FareQuoteFareQuoteRQmsgFareQuoteRSmsg
getFareRule/shop/FareRuleDescriptionByRouteFareRuleDescriptionByRouteRQmsgFareRuleDescriptionByRouteRSmsg
searchAvailAncillary/shop/AncillaryAvailabilityAncillaryAvailabilityRQmsgAncillaryAvailabilityRSmsg
searchBaggage/shop/BaggageAvailabilityBaggageAvailabilityRQmsgBaggageAvailabilityRSmsg
searchSeat/shop/SeatAvailabilitySeatAvailabilityRQmsgSeatAvailabilityRSmsg
markSeat/book/MarkSeatsMarkSeatsRQmsgMarkSeatsRSmsg
createBooking/book/CreateBookingCreatBookingRQmsg (철자 주의)CreateBookingRSmsg
retrieve/book/RetrieveBookingRetrieveBookingRQmsgRetrieveBookingRSmsg
confirmPrice/book/ConfirmPriceConfirmPriceRQmsgConfirmPriceRSmsg
confirmCancelPrice/book/ConfirmCancelPriceConfirmCancelPriceRQmsgConfirmCancelPriceRSmsg
cancel/book/CancelBookingCancelBookingRQmsgCancelBookingRSmsg
ticketing / reissue / modifyBookingWithAncillaries/book/ModifyBookingModifyBookingRQmsgModifyBookingRSmsg
reissueSearch/book/EnhancedAirAvailabilityEnhancedAirAvailabilityRQmsgEnhancedAirAvailabilityRSmsg
changeApis/book/TravelDocumentsTravelDocumentsRQmsgTravelDocumentsRSmsg
modifyPassengers/book/ChangeGuestContactPointChangeGuestContactPointRQmsgChangeGuestContactPointRSmsg
divide/book/SplitBookingSplitBookingRQmsgSplitBookingRSmsg
getAgencyCredit/book/RetrieveAgencyCreditRetrieveAgencyCreditRQmsgRetrieveAgencyCreditRSmsg
releaseAncillary/book/ReleaseAncillaryReleaseAncillaryRQmsgReleaseAncillaryRSmsg

지뢰 — CreatBookingRQmsg 오타 클래스명

예약 생성 요청 봉투의 클래스명/파일명이 CreatBookingRQmsg(CreateBooking이 아닌 CreatBooking, e가 빠짐)다. 응답은 정상 철자 CreateBookingRSmsg. grep으로 클래스를 찾을 때 오타 철자를 그대로 써야 잡힌다. 와이어 XML 루트 엘리먼트는 T’way가 정의한 CreateBookingRQmsg일 가능성이 높으므로 @JacksonXmlRootElement(localName=...) 확인 필요.

3.3 핵심 요청 전문 — AirAvailabilityRQ 필드 매핑 (검색)

AirAvailabilityRQ.kt에서 검색 요청이 어떤 XML 엘리먼트로 나가는지.

Kotlin 필드XML localName값/규칙
agencyCodeAgencyCode대리점 코드(평문, 인증과 동일 값)
availabilitySearchesAvailabilitySearches (useWrapping=false)OD별 1개. Origin/Destination/TravelDate(yyyy-MM-dd)
paxCountDetailsPaxCountDetails (useWrapping=false)ADT/CHD/INF 카운트. 0명도 포함될 수 있음
tripTypeTripTypeOW(편도) / RT(왕복) / MC(다구간) — OD 개수·왕복여부로 결정
fareLevelFareLevelsnullable
pointOfPurchasePointOfPurchase상수 "KR"
promoCodeDetailPromoCodeDetails프로모션 코드의 첫 번째만 사용(promotionCodes.firstOrNull())

useWrapping=false 가 중요한 이유

@JacksonXmlElementWrapper(useWrapping = false)가 붙으면 리스트가 <AvailabilitySearches>...</AvailabilitySearches> 래퍼 태그 없이 <AvailabilitySearches> 엘리먼트 자체가 반복된다. T’way 스키마가 래핑 없는 반복 엘리먼트를 기대하기 때문이다. 이 한 줄을 빼먹으면 와이어 XML 구조가 깨져 공급사가 파싱 실패한다.

tripType 결정 로직(AirAvailabilityRQ.kt:62-66, 71-75):

tripType = when {
    originDestinations.size == 1 -> "OW"
    isRoundTrip(originDestinations) -> "RT"   // OD 2개 & 출도착이 정확히 뒤바뀜
    else -> "MC"
}

3.4 핵심 요청 전문 — ModifyBookingRQ (발권·재발행·부가결제 공용)

발권(ISSUE_TICKET), 일정변경/재발행(ITR_CHANGE), 부가서비스추가(ADD_ANCILLARY)가 하나의 ModifyBooking 오퍼레이션을 ActionType로 분기해서 공유한다(ModifyBookingRQ.kt).

Kotlin 필드XML localName비고
pnrNumberPnrNumber대상 PNR
agencyCodeAgencyCode
guestPaymentDetailsGuestPaymentDetails (useWrapping=false)승객별 결제금액(GuestPaymentDetail)
paymentSummaryPaymentSummary카드정보·결제수단 컨테이너 (3.5 참조)
actionTypeActionTypeISSUE_TICKET / ITR_CHANGE / ADD_ANCILLARY
itineraryChangeTypeItineraryChangeType재발행 시에만. pnrSessionId + 세그먼트 변경
fareInfoFareInfo재발행 시 대체 운임
ssrModifyTypeSsrModifyType (useWrapping=false)부가서비스 추가 시 SSR

같은 엔드포인트, 다른 의미 — ActionType가 분기점

ticketing / reissue / modifyBookingWithAncillaries 세 메서드 모두 /book/ModifyBooking을 친다(TwayClient.kt:647, 753, 605). 응답 에러 처리 분기도 다르다(재발행은 ERR133=다운그레이드 가격 불일치 별도 처리). 호출 의도는 요청 바디의 ActionType 엘리먼트로만 구분되므로, 디버깅 시 ModifyBooking 로그를 보면 <ActionType>을 먼저 확인할 것.

3.5 결제 컨테이너 — PaymentSummary 와 결제수단 코드

PaymentSummary.kt가 결제수단별로 자식 엘리먼트를 선택적으로 채운다. FormOfPaymentCode가 어떤 자식이 채워졌는지를 가리키는 키다.

FormOfPaymentCode채워지는 자식 엘리먼트의미
CCCardInfo일반 신용카드(현금가=0)
CCAGCCAGInfo(= CardInfo + AGPassword)카드 + 대리점 결제비밀번호(현금가>0)
(구조상 존재)NaverpayInfo, AgencyCreditInfo네이버페이 / 대리점 크레딧
// PaymentSummary.kt:48-59 — cashPrice 유무로 CC vs CCAG 결정
return if (cashPrice > 0) {
    PaymentSummary(formOfPaymentCode = "CCAG", ...,
        cardAgencyInfo = CCAGInfo.of(cardInfo, encryptedPaymentPassword))
} else {
    PaymentSummary(formOfPaymentCode = "CC", ...,
        cardInfo = CardInfo.of(cardInfo))
}
  • PaymentCurrency상수 "KRW" (PaymentSummary.kt:36).
  • CardInfo.cardAuthType상수 "OFFLINE" (CardInfo.kt:17) → 카드 정보는 항상 OfflineAuthInfo로 직접 전달(키-인) 방식.

3.6 응답 에러 모델 — ErrorType + checkError()

T’way는 HTTP 200으로 응답하면서 본문 안에 비즈니스 에러를 담는다. 그래서 OkHttp의 isSuccessful만으로는 실패를 못 잡고, 각 *RScheckError()가 본문의 ErrorType을 검사한다.

// ErrorType.kt
data class ErrorType(
    @JacksonXmlProperty(localName = "errorCode")  val errorCode: String? = null,
    @JacksonXmlProperty(localName = "errorValue") val errorValue: String? = null
)
 
// AirAvailabilityRS.kt:16-26
fun checkError(callback: ((code, message) -> Unit)? = null) {
    if (errorType != null) { ... callback or throw InternationalAdapterException(...) }
}

errorCode / errorValue 는 소문자 카멜로 매핑

다른 필드는 PascalCase(AgencyCode)인데 에러 필드만 errorCode/errorValue 소문자 시작이다(ErrorType.kt:6, 9). T’way 스키마의 일관성 부재이므로 매핑 시 그대로 따라야 한다.

T’way 에러는 3종으로 갈린다(자세한 코드표는 tway-pitfalls / tway-operations):

분류예시 코드처리
경고(빈 결과 등)ERR016(노선없음), WS_321(편없음), WS_1111(혼잡), ERR361/362(경유 대륙 불일치)warningCodes/startWith("Validation Failed") → 로그만, 빈 리스트 반환 (AirAvailabilityRS.kt:38-56, TwayClient.kt:99-118)
결제 실패1215, 8000대 다수, PAYMENT_*TwayPaymentError.getErrorMessage(code)로 사용자 메시지 매핑 → MethodArgumentInvalidException (TwayPaymentError.kt)
운영 실패ERR360(체크인됨 취소불가), ERR133(다운그레이드 금액불일치), BKG_CONCURRECY*(락)케이스별 도메인 예외 + 일부 리트라이(BKG_CONCURRECY*.retry()) (TwayClient.kt:424-490, 766)

4. XSD 스키마 / 샘플 전문

T'way는 XSD도 mockData도 리포지토리에 없다

  • src/test/schema/에는 amadeus, galileo, jinair, koreanair, lufthansa, sabre, singaporeair 디렉터리만 있고 tway는 없다.
  • src/test/resources/mockData/에도 tway 샘플 전문이 없다.
  • 따라서 T’way 전문의 “스키마 정의”는 Kotlin DTO의 Jackson 애너테이션이 사실상의 계약이다. 진짜 와이어 형태를 보려면 런타임 로그(SUPPLIER.TwayClient)나 T’way가 제공하는 별도 명세서를 봐야 한다.
  • 검색 응답 코드 주석(AirAvailabilityRS.kt:29-37)에 실제 에러 메시지 샘플이 남아 있어, 사실상 유일한 “실 전문 샘플” 역할을 한다.

테스트는 통합테스트(TwayClientTest.kt)뿐이고, mock 없이 @SpringBootTest + local 프로파일로 실서버에 직접 호출한다(retrieveRouteTest). 즉 단위 수준 XML 직렬화 골든파일 검증은 없다 → tway-pitfalls의 회귀 위험.

4.1 재구성한 요청 전문 예시 (AirAvailability)

코드에서 역산한, 실제로 나갈 XML의 근사 형태다(샘플 파일이 없으므로 추정/재구성임을 명시).

<!-- POST {endpoint}/shop/AirAvailability  Content-Type: application/xml
     Authorization: Basic base64( {agencyCode} : SEED("...password...") ) -->
<AirAvailabilityRQmsg>
  <AirAvailabilityRQ>
    <AgencyCode>TRIPLExxxx</AgencyCode>
    <AvailabilitySearches>
      <Origin>ICN</Origin>
      <Destination>NRT</Destination>
      <TravelDate>2026-07-01</TravelDate>
    </AvailabilitySearches>
    <PaxCountDetails> <!-- ADT/CHD/INF 각 1개씩 반복, useWrapping=false -->
      ...
    </PaxCountDetails>
    <TripType>OW</TripType>
    <PointOfPurchase>KR</PointOfPurchase>
    <!-- PromoCodeDetails 는 프로모션 있을 때만 (NON_NULL) -->
  </AirAvailabilityRQ>
</AirAvailabilityRQmsg>

NON_NULL 직렬화

xmlMapperserializationInclusion(NON_NULL)(WebMvcConfiguration.kt:76)로 빌드된다. 따라서 null 필드는 아예 엘리먼트로 나가지 않는다. 위 예시에서 FareLevels, PromoCodeDetails가 빠진 이유다. 응답 역직렬화는 failOnUnknownProperties(false)(:77)라 모르는 엘리먼트는 무시한다(공급사가 필드를 추가해도 안 깨짐).


5. XML 직렬화 인프라 (xmlMapper)

T’way 전송 계층의 동작을 결정하는 공용 빈 설정이다.

// WebMvcConfiguration.kt:74-88
@Bean
fun xmlMapper(): ObjectMapper =
    Jackson2ObjectMapperBuilder.xml()
        .serializationInclusion(JsonInclude.Include.NON_NULL)   // null → 엘리먼트 생략
        .failOnUnknownProperties(false)                          // 모르는 엘리먼트 무시
        .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .modules(JavaTimeModule(), Jdk8Module(), kotlinModule(),
                 ParameterNamesModule(), NoCtorDeserModule())
        .build()

TwayClient가 생성자에서 @Qualifier("xmlMapper") 로 이 빈을 주입받아 ClientSupport(objectMapper = xmlMapper)로 넘긴다(TwayClient.kt:37-44). ClientSupport.execute<RES>()는 응답 본문 문자열을 objectMapper.readValue(...)로 역직렬화하고, 요청 바디는 objectMapper.writeValueAsString(request)로 직렬화한다(ClientSupport.kt:32, 176).

설정의미 / T’way에 미치는 영향
NON_NULL옵셔널 필드는 와이어에서 생략. “안 보내는 것”과 “빈 값” 구분 가능
failOnUnknownProperties=false공급사가 응답에 필드를 추가해도 어댑터가 안 깨짐(상호운용성↑)
JavaTimeModule + WRITE_DATES_AS_TIMESTAMPS offLocalDate는 ISO 문자열로. 단 T’way는 대부분 String(yyyy-MM-dd) 직접 포맷을 쓴다(AvailabilitySearch.kt:20.format("yyyy-MM-dd"))
타임아웃검색 15s / 기본 60s (TwayClient.kt:42-43ClientSupport searchClient/defaultClient)

검색만 별도 클라이언트

search.client(searchClient)(TwayClient.kt:91)로 15초 타임아웃 클라이언트를 쓰고, 나머지는 기본 60초 클라이언트다. 검색은 응답이 느리면 빨리 끊고 다른 공급사 결과로 fallback하기 위함. 발권/취소는 절대 일찍 끊으면 안 되므로 60초. 타임아웃 시 동작은 resilience-and-events 참조.


6. 부가서비스 토큰 — REST/JSON (XML 예외 경로)

전문 대부분은 XML이지만, 부가서비스 딥링크 토큰 발급만 JSON/REST다(TwayAncillaryClient.kt). 별도 클라이언트이며 인증 방식도 다르다(Basic Auth 없이, SEED 암호화한 PNR/대리점코드를 GET 쿼리파라미터로 전달).

flowchart TD
    A["GET ancillary.token.endpoint<br/>쿼리 encPnrNumber=SEED(pnr) 와 agencyCode=SEED(agencyCode)<br/>Content-Type application/json<br/>User-Agent InterparkTriple"] --> B["AncillaryTokenResponse JSON<br/>resultCode / resultMsg / tokenId / expireDtime"]
    B -->|"resultCode=BKG_AUTH_SUCCESS AND resultMsg=Success 일 때만 tokenId 채택"| C["딥링크 URL 조립 TwayAncillaryDeepLink<br/>토큰 + SEED(승객명) + flightDate ..."]
항목
프로토콜REST + JSON (MediaType.APPLICATION_JSON_VALUE, TwayAncillaryClient.kt:30)
인증Basic Auth 아님. SEED 암호화 PNR/대리점코드를 쿼리파라미터로
응답 모델AncillaryTokenResponse(@JsonProperty, XML 아님)
성공 판정resultCode=="BKG_AUTH_SUCCESS" AND resultMessage=="Success" (:41, :104)
헤더 특이점User-Agent: InterparkTriple 고정

임시 패치 흔적

TwayAncillaryDeepLink.kt:34, TwayAncillaryClient.kt:127ancillaryType 파라미터가 “티웨이 이슈로 임시 제거, 2025-12-23 이후 원복 예정” 주석과 함께 주석처리돼 있다. 부가서비스 딥링크에서 좌석/번들/기내식/수하물 타입 구분이 현재 빠져 있다는 운영 메모. → tway-pitfalls에 기록.


7. 신입용 체크리스트 / 연습

손으로 추적해 보기

  1. TwayClient.ticketing(...)이 보내는 요청을 그려보라. 어떤 엔드포인트? ActionType? FormOfPaymentCodeCC인지 CCAG인지는 무엇이 결정하나?
  2. 카드번호가 평문으로 와이어에 나갈 수 있는 경로가 있는가? OfflineAuthInfo를 직접 생성하면?
  3. WS_321 에러가 왔을 때 사용자에게 예외가 던져질까, 빈 결과가 반환될까? (AirAvailabilityRS.warningCodes 추적)

8. 한눈에 보기 (Quick facts)

항목근거
와이어 포맷REST 경로 + XML 바디(SOAP/NDC 아님)TwayClient.kt 전역, headerMap=application/xml
인증HTTP Basic AuthClientSupport.kt:131
Basic usernameagencyCode(평문)TwayApiProperties.agencyCode
Basic passwordSeedUtil.encoding(password)TwayClient.kt:58 …
암호화KISA SEED-CBC + Base64, UTF-8TwaySEED.jar / SeedUtil
SEED 키하드코딩 "twayBookingAPI@014" (jar 내부)SeedUtil.class 상수풀
세션없음(stateless), PnrSessionId는 좌석 hold 토큰MarkSeatsRS.pnrSessionId
봉투 패턴XxxRQmsg→XxxRQ / XxxRSmsg→XxxRS*RQmsg.kt
에러HTTP 200 + 본문 ErrorType(errorCode/errorValue)ErrorType.kt, checkError()
발권/재발행/부가결제단일 /book/ModifyBooking + ActionType 분기ModifyBookingRQ.kt
검색 타임아웃15s(검색)/60s(기타)TwayClient.kt:42-43, 91
XSD/mockData없음 (DTO 애너테이션이 계약)src/test/schema/·mockData/ 미존재
부가서비스 토큰REST/JSON(예외)TwayAncillaryClient.kt

관련: T’way 개요 · 오퍼레이션 · 지뢰 · interfaces-dtos · 에러 처리 · configuration-and-infra · build-deploy-config