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/xmlXML 바디를 보내고, 인증은 HTTP Basic Auth 헤더(username=agencyCode,password=SEED암호화된 비밀번호)로 한다. 카드번호·카드비밀번호·대리점결제비밀번호 같은 민감 정보는 모두 사내 배포 라이브러리TwaySEED.jar의SeedUtil.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 슬롯 | 값 | 출처 |
|---|---|---|
| username | agencyCode (대리점 코드, 평문) | TwayApiProperties.agencyCode |
| password | SeedUtil.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_KISA | KISA 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에 하드코딩, 라이브러리는 빌드 산출물에 직접 포함
seedKey가TwaySEED.jar내부에 평문 상수로 박혀 있다. 키 로테이션은 어댑터 코드가 아니라 T’way가 새 jar를 주는 방식으로만 가능하다. 키를 바꾸려면libs/TwaySEED.jar교체 → 재빌드.implementation(files("libs/TwaySEED.jar"))는 로컬 파일 의존성이라 Maven 중앙저장소·내부 Nexus에 없다. 클린 체크아웃 시libs/TwaySEED.jar파일이 없으면 컴파일 자체가 깨진다. CI/Docker 빌드에서 이 파일이 반드시 따라가야 한다(build-deploy-config 참조).- 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, 645 → SeedUtil.encoding(paymentPassword) | PaymentSummary > CCAGInfo > AGPassword |
| 카드번호 | CardInfo.kt:24 | ...OfflineAuthInfo > CardNumber |
| 카드 유효기간(YYMM) | CardInfo.kt:25 | OfflineAuthInfo > ExpireDate |
| 생년월일/사업자번호 | CardInfo.kt:26-31 | OfflineAuthInfo > RegistrationNumber |
| 카드 비밀번호 | CardInfo.kt:32 | OfflineAuthInfo > CardPassword |
| 부가서비스 토큰: PNR | TwayAncillaryIssueTokenRequest.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 / RSmsg → RQ / 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/RetrieveRoute | RetrieveRouteRQmsg | RetrieveRouteRSmsg |
search | /shop/AirAvailability | AirAvailabilityRQmsg | AirAvailabilityRSmsg |
getHiddenStopsMap | /shop/ThroughFlightInfo | ThroughFlightInfoRQmsg | ThroughFlightInfoRSmsg |
doPricing | /shop/FareQuote | FareQuoteRQmsg | FareQuoteRSmsg |
getFareRule | /shop/FareRuleDescriptionByRoute | FareRuleDescriptionByRouteRQmsg | FareRuleDescriptionByRouteRSmsg |
searchAvailAncillary | /shop/AncillaryAvailability | AncillaryAvailabilityRQmsg | AncillaryAvailabilityRSmsg |
searchBaggage | /shop/BaggageAvailability | BaggageAvailabilityRQmsg | BaggageAvailabilityRSmsg |
searchSeat | /shop/SeatAvailability | SeatAvailabilityRQmsg | SeatAvailabilityRSmsg |
markSeat | /book/MarkSeats | MarkSeatsRQmsg | MarkSeatsRSmsg |
createBooking | /book/CreateBooking | CreatBookingRQmsg (철자 주의) | CreateBookingRSmsg |
retrieve | /book/RetrieveBooking | RetrieveBookingRQmsg | RetrieveBookingRSmsg |
confirmPrice | /book/ConfirmPrice | ConfirmPriceRQmsg | ConfirmPriceRSmsg |
confirmCancelPrice | /book/ConfirmCancelPrice | ConfirmCancelPriceRQmsg | ConfirmCancelPriceRSmsg |
cancel | /book/CancelBooking | CancelBookingRQmsg | CancelBookingRSmsg |
ticketing / reissue / modifyBookingWithAncillaries | /book/ModifyBooking | ModifyBookingRQmsg | ModifyBookingRSmsg |
reissueSearch | /book/EnhancedAirAvailability | EnhancedAirAvailabilityRQmsg | EnhancedAirAvailabilityRSmsg |
changeApis | /book/TravelDocuments | TravelDocumentsRQmsg | TravelDocumentsRSmsg |
modifyPassengers | /book/ChangeGuestContactPoint | ChangeGuestContactPointRQmsg | ChangeGuestContactPointRSmsg |
divide | /book/SplitBooking | SplitBookingRQmsg | SplitBookingRSmsg |
getAgencyCredit | /book/RetrieveAgencyCredit | RetrieveAgencyCreditRQmsg | RetrieveAgencyCreditRSmsg |
releaseAncillary | /book/ReleaseAncillary | ReleaseAncillaryRQmsg | ReleaseAncillaryRSmsg |
지뢰 —
CreatBookingRQmsg오타 클래스명예약 생성 요청 봉투의 클래스명/파일명이
CreatBookingRQmsg(CreateBooking이 아닌CreatBooking, e가 빠짐)다. 응답은 정상 철자CreateBookingRSmsg. grep으로 클래스를 찾을 때 오타 철자를 그대로 써야 잡힌다. 와이어 XML 루트 엘리먼트는 T’way가 정의한CreateBookingRQmsg일 가능성이 높으므로@JacksonXmlRootElement(localName=...)확인 필요.
3.3 핵심 요청 전문 — AirAvailabilityRQ 필드 매핑 (검색)
AirAvailabilityRQ.kt에서 검색 요청이 어떤 XML 엘리먼트로 나가는지.
| Kotlin 필드 | XML localName | 값/규칙 |
|---|---|---|
agencyCode | AgencyCode | 대리점 코드(평문, 인증과 동일 값) |
availabilitySearches | AvailabilitySearches (useWrapping=false) | OD별 1개. Origin/Destination/TravelDate(yyyy-MM-dd) |
paxCountDetails | PaxCountDetails (useWrapping=false) | ADT/CHD/INF 카운트. 0명도 포함될 수 있음 |
tripType | TripType | OW(편도) / RT(왕복) / MC(다구간) — OD 개수·왕복여부로 결정 |
fareLevel | FareLevels | nullable |
pointOfPurchase | PointOfPurchase | 상수 "KR" |
promoCodeDetail | PromoCodeDetails | 프로모션 코드의 첫 번째만 사용(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 | 비고 |
|---|---|---|
pnrNumber | PnrNumber | 대상 PNR |
agencyCode | AgencyCode | |
guestPaymentDetails | GuestPaymentDetails (useWrapping=false) | 승객별 결제금액(GuestPaymentDetail) |
paymentSummary | PaymentSummary | 카드정보·결제수단 컨테이너 (3.5 참조) |
actionType | ActionType | ISSUE_TICKET / ITR_CHANGE / ADD_ANCILLARY |
itineraryChangeType | ItineraryChangeType | 재발행 시에만. pnrSessionId + 세그먼트 변경 |
fareInfo | FareInfo | 재발행 시 대체 운임 |
ssrModifyType | SsrModifyType (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 | 채워지는 자식 엘리먼트 | 의미 |
|---|---|---|
CC | CardInfo | 일반 신용카드(현금가=0) |
CCAG | CCAGInfo(= 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만으로는 실패를 못 잡고, 각 *RS의 checkError()가 본문의 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직렬화
xmlMapper는serializationInclusion(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 off | LocalDate는 ISO 문자열로. 단 T’way는 대부분 String(yyyy-MM-dd) 직접 포맷을 쓴다(AvailabilitySearch.kt:20의 .format("yyyy-MM-dd")) |
| 타임아웃 | 검색 15s / 기본 60s (TwayClient.kt:42-43 → ClientSupport 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:127에ancillaryType파라미터가 “티웨이 이슈로 임시 제거, 2025-12-23 이후 원복 예정” 주석과 함께 주석처리돼 있다. 부가서비스 딥링크에서 좌석/번들/기내식/수하물 타입 구분이 현재 빠져 있다는 운영 메모. → tway-pitfalls에 기록.
7. 신입용 체크리스트 / 연습
손으로 추적해 보기
TwayClient.ticketing(...)이 보내는 요청을 그려보라. 어떤 엔드포인트?ActionType?FormOfPaymentCode가CC인지CCAG인지는 무엇이 결정하나?- 카드번호가 평문으로 와이어에 나갈 수 있는 경로가 있는가?
OfflineAuthInfo를 직접 생성하면?WS_321에러가 왔을 때 사용자에게 예외가 던져질까, 빈 결과가 반환될까? (AirAvailabilityRS.warningCodes추적)
정답 보기
POST /book/ModifyBooking,ActionType=ISSUE_TICKET(ModifyBookingRQ.kt:51).PaymentSummary.of(...)에서cashPrice>0이면CCAG(대리점결제비번 동반), 아니면CC(PaymentSummary.kt:48).- 있다.
CardInfo.of()는 내부에서SeedUtil.encoding()을 부르지만,OfflineAuthInfo(...)를 생성자로 직접 만들면 암호화가 안 된다(클래스 주석//Must be encrypted seed가 경고). 반드시CardInfo.of()/PaymentSummary.of()팩토리 경유.- 빈 결과.
WS_321은warningCodes에 포함(AirAvailabilityRS.kt:43)되어checkError콜백이logger.warn만 하고 예외를 안 던진다(TwayClient.kt:101-109). 그 뒤originDestinationInfos가 null이면?: emptyList()로 빈 리스트 반환(:135).
8. 한눈에 보기 (Quick facts)
| 항목 | 값 | 근거 |
|---|---|---|
| 와이어 포맷 | REST 경로 + XML 바디(SOAP/NDC 아님) | TwayClient.kt 전역, headerMap=application/xml |
| 인증 | HTTP Basic Auth | ClientSupport.kt:131 |
| Basic username | agencyCode(평문) | TwayApiProperties.agencyCode |
| Basic password | SeedUtil.encoding(password) | TwayClient.kt:58 … |
| 암호화 | KISA SEED-CBC + Base64, UTF-8 | TwaySEED.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