Galileo는 단일 프로토콜이 아니라 4개 채널을 섞어 쓴다. ① 검색은 REST/JSON(Travelport JSON API v11, OAuth2 Bearer 토큰 + Redis 캐시), ② 예약·발권·운임규정·큐·취소는 SOAP(Universal API v52, HTTP Basic Auth, 세션 없는 stateless), ③ 운임규정 한글번역은 KRT(별도 .aspx, XML), ④ 결제(BSP카드/현금영수증)는 KPS(별도 .aspx, JSON)다. SOAP 전문은 Jackson XML로 직렬화한 뒤 jakarta.xml.soap으로 SOAP Envelope에 감싸는 2단 변환으로 만든다.
Galileo 인프라 패키지(.../galileo/infrastructure)에는 클라이언트가 4개 있고, 각각 프로토콜·인증·직렬화가 전부 다르다.
채널
클라이언트
프로토콜 / 포맷
인증
ObjectMapper
근거 파일
검색
GalileoRestClient
REST / JSON
OAuth2 Bearer (+Redis 캐시)
objectMapper(JSON)
infrastructure/rest/GalileoRestClient.kt
예약·발권·운임규정·큐·취소·조회·수정·분리·EMD
GalileoClient
SOAP 1.1 / XML (Universal API v52)
HTTP Basic
@Qualifier("xmlMapper")
infrastructure/soap/GalileoClient.kt
운임규정 한글번역
KrtClient
HTTP POST / XML (FareRuleTransKor.aspx)
없음(엔드포인트 자체가 사설)
@Qualifier("xmlMapper")
infrastructure/krt/KrtClient.kt
결제(BSP카드·현금영수증)
KpsPaymentClient
HTTP POST / JSON (*.aspx)
요청 본문에 AgtID/AgtPWD 동봉
objectMapper(JSON)
infrastructure/kps/KpsPaymentClient.kt
flowchart LR
subgraph adapter["air-intl-adapter galileo 모듈"]
REST["GalileoRestClient"]
TOKEN["getToken<br/>OAuth2 password grant"]
SOAP["GalileoClient"]
KRT["KrtClient"]
KPS["KpsPaymentClient"]
end
REDIS["Redis 토큰 캐시<br/>CacheSet.GALILEO_REST_TOKEN 콜론콜론 pcc"]
JSONAPI["Travelport JSON API<br/>11 air catalog search"]
AUTH["rest.authEndpoint"]
UAPI["Universal API SOAP"]
AIRSVC["AirService<br/>Price Ticketing FareRules Void RetrieveDoc EMD"]
URSVC["UniversalRecordService<br/>Create 별 Cancel Retrieve Modify Divide"]
QSVC["GdsQueueService<br/>Queue Count List Remove"]
KRTEP["FareRuleTransKor.aspx<br/>XML 한글 운임규정"]
KPSEP["KpsBspCardAuth 와 X KpsCashReceipt aspx<br/>JSON 결제"]
REST -->|"Bearer"| JSONAPI
REST -.->|"토큰 캐시"| REDIS
TOKEN -->|"OAuth2 password grant"| AUTH
REST --> TOKEN
SOAP -->|"Basic"| UAPI
UAPI --> AIRSVC
UAPI --> URSVC
UAPI --> QSVC
KRT -->|"인증 없음"| KRTEP
KPS -->|"body creds"| KPSEP
Create*(AirCreateReservation)는 요청은 Air, 응답은 Universal 스키마로 떨어진다.
KRT 인증은 없음(엔드포인트 자체가 사설), KPS 인증은 요청 본문에 자격증명 동봉.
왜 한 공급사에 4개 채널인가
Travelport는 레거시 Universal API(SOAP) 와 신형 JSON/REST를 둘 다 제공한다. 검색은 신형 JSON이 빠르고 페이로드가 작아 REST로, 좌석을 실제로 잡고 발권하는 무거운 트랜잭션은 검증된 SOAP로 간다. KRT/KPS는 Travelport 표준이 아니라 한국 시장용 부가 게이트웨이(한글 운임규정 번역, 국내 BSP 카드결제)다. 그래서 엔드포인트가 .aspx(ASP.NET) 형태다.
2. REST 채널 — 검색과 OAuth2 토큰
2-1. 전송 방식
GalileoRestClient는 OkHttp 기반 ClientSupport를 상속한다. 검색은 JSON POST다.
GalileoRestClient.kt:65 — expiresIn = response.expiresIn - 600. 즉 실제 만료보다 10분 먼저 Redis에서 만료시킨다. 만료 직전에 토큰을 써서 401이 나는 경계 케이스를 피하기 위한 안전마진이다. 캐시 키는 CacheSet.GALILEO_REST_TOKEN.cacheName + "::" + pcc이고, 저장은 setIfAbsent(GalileoRestClient.kt:181~188)라 동시에 여러 스레드가 토큰을 받아도 한 번만 기록된다.
인증정보가 평문으로 본문에 들어간다
client_secret·password가 form 본문 평문으로 나간다(makeGetTokenRequest). 로그에 본문이 찍히지 않도록 검색 로깅은 SupplierLoggingProperties로 명시 제어한다(GalileoRestClient.kt:37, enableSearchLog). 토큰 발급 호출 자체는 .log() 지정이 없어 기본값(LogMessage.enable=true, ClientSupport.kt:98~102)으로 로깅되니, 운영 로그에서 민감정보 노출 여부를 항상 점검하라. → galileo-pitfalls
3. SOAP 채널 — Universal API v52 (이 모듈의 핵심)
3-1. 엔드포인트와 서비스 분리
SOAP는 하나의 base endpoint(soap.endpoint)에 3개의 서비스 경로로 나뉜다. 어느 서비스로 보낼지는 오퍼레이션이 결정한다.
만 한다. 세션 토큰을 들고 다니지 않으므로 “세션 만료” 같은 장애 모드가 없다. 대신 모든 트랜잭션 컨텍스트(예약 식별)는 본문 안의 PNR/UniversalRecordLocator + Version 으로 전달된다(§3-6).
test/schema/galileo/SessionContext_v1/SessionContext_v1.xsd에 Travelport의 세션 토큰(SessTok)·세션 속성(SessProp) 스키마가 존재하긴 한다. 그러나 main 코드 어디에서도 SessionContext를 참조하지 않는다(grep SessionContext src/main → 0건). 즉 이 어댑터는 세션 기능을 의도적으로 쓰지 않는다.
WS-Security 헬퍼는 있으나 Galileo는 안 쓴다
공통 유틸 SoapExtensions.kt에 secHeader { }(wss4j WSSecHeader 기반) 확장이 있지만, Galileo 클라이언트에서는 secHeader 호출이 0건이다(다른 SOAP 공급사용). Galileo의 인증은 전적으로 HTTP Basic 헤더다.
공통 헤더(GalileoClient.kt:88~91):
헤더
값
Content-Type
text/xml (MediaType.TEXT_XML_VALUE)
Accept-Encoding
gzip,deflate
Authorization
Basic base64(user:pw)
3-3. 요청 직렬화 — 2단계 변환의 핵심
여기가 Galileo SOAP의 가장 까다로운 부분이다. 요청 DTO는 Jackson XML 애너테이션이 붙은 평범한 data class일 뿐, SOAP Envelope를 모른다. Envelope는 soapRequestBodyConverter()가 감싼다.
DTO → XML 바이트 — xmlMapper.writeValueAsBytes(request). @JacksonXmlRootElement(localName="AirPriceReq", namespace=...) 가 루트 엘리먼트를 만든다.
XML → SOAP Body — soap { body(...) }(SoapExtensions.kt:41,148). jakarta.xml.soap.MessageFactory로 빈 Envelope를 만들고, 위에서 만든 XML 문서를 soapPart.envelope.body.addDocument(it)로 Body에 통째로 붙인다.
문자열 후처리 — String.replace 두 번.
후처리 replace 두 줄을 지우면 SOAP이 깨진다
.replace(" xmlns=\"\"", "") : 자식 엘리먼트가 부모와 다른 네임스페이스를 가질 때 Jackson이 xmlns=""(빈 기본 네임스페이스)를 잘못 끼워넣는다. 이게 남아 있으면 Travelport가 스키마 검증에서 거부한다.
.replace(Regex("(:)?(wstxns\\d+)(:)?"), "") : Woodstox(Jackson XML 백엔드)가 자동 생성하는 wstxns1:류 임시 네임스페이스 접두사를 제거한다. 접두사가 그대로 나가면 Travelport 스키마의 elementFormDefault="qualified" 와 충돌한다.
이 두 줄은 “Jackson XML이 만든 XML”과 “Travelport가 받아들이는 XML”의 간극을 메우는 방어 코드다. DTO 추가 시에도 동일하게 적용된다. → galileo-pitfalls
3-4. 응답 역직렬화 — soapBodyDeserializerOf
응답은 SOAP Envelope → Fault 검사 → Body 추출 → DTO 매핑 순으로 처리된다. 이 로직은 companion의 인라인 팩토리에 들어있다.
// GalileoClient.kt:72~86inline fun <reified T : GalileoResponseBody> soapBodyDeserializerOf(...): (String, Response) -> GalileoResponse<T> = { content, _ -> val soapContent = soap(content) // 문자열 → SOAPMessage GalileoResponse( soapFault = soapContent.soapBody.fault, // <soap:Fault> 있으면 추출 body = soapContent.takeIf { it.soapBody.fault == null }?.soapBody(mapper) // Fault 없으면 Body 첫 자식을 DTO로 ) }
soap(content) = MessageFactory.createMessage(...) 로 문자열을 SOAPMessage로 파싱(SoapExtensions.kt:23).
soapBody(mapper) = soapBody.firstChild를 xmlMapper.readValue<T>()로 매핑(SoapExtensions.kt:33). Body의 첫 자식(즉 AirPriceRsp 등)이 곧 응답 DTO다.
파싱 실패 시 원문을 ResponseLog로 남기고 예외를 재던진다(GalileoClient.kt:82~85) — 망가진 전문 디버깅의 단서.
Galileo SOAP 에러는 두 층이다: ① 프로토콜 레벨 SOAPFault, ② 비즈니스 레벨 ResponseMessage(type=“Error”). 이걸 단일 인터페이스로 통합한 게 GalileoResponse다.
// GalileoResponse.ktdata class GalileoResponse<T : GalileoResponseBody>(val soapFault: SOAPFault? = null, val body: T?) { fun checkError(callback: (List<Pair<String, String>>) -> Unit) { if (soapFault != null) callback(listOf(soapFault.faultCode to soapFault.faultString)) // ① 프로토콜 에러 else body?.checkError(callback) // ② 비즈니스 에러 }}interface GalileoResponseBody { fun checkError(callback: (List<Pair<String, String>>) -> Unit) }
각 응답 DTO(AirPriceRS, AirCreateReservationRS …)는 GalileoResponseBody를 구현하고 자기만의 에러 위치를 안다. 예: AirCreateReservationRS.checkError(AirCreateReservationRS.kt:29~51)는 responseMessages의 type=="Error" 와 segmentSellFailureInfo를 검사한다. AirPriceRS(AirPriceRS.kt:44~58)는 responseMessages 와 priceResults[].priceError 두 곳을 본다.
flowchart TD
STR["응답 문자열"] -->|"soap 파싱"| MSG["SOAPMessage"]
MSG --> COND{"soapBody.fault 존재?"}
COND -->|"yes"| FAULT["GalileoResponse.soapFault<br/>예: CHECK CONNECTION 등"]
COND -->|"no"| BODY["soapBody.firstChild"]
BODY -->|"xmlMapper"| DTO["T 타입 GalileoResponseBody"]
DTO --> CHECK["checkError<br/>ResponseMessage 또는 PriceError 또는 SegmentSellFailure"]
호출부는 항상 같은 패턴이다
모든 SOAP 오퍼레이션은 execute(...).fold(success = { response -> response.checkError { ... throw } ; 정상값 }, failure = { throw it.handleSoapFaultException(...) }) 구조다. checkError의 콜백이 실행되면 = 에러가 있다는 뜻이고, 거기서 메시지 내용을 보고 적절한 예외를 던진다(예: pricing()에서 "are not bookable" → SOLD_OUT, "CHECK CONNECTION" → MINIMUM_CONNECTION_TIME, GalileoClient.kt:118~133). → 에러 매핑 상세는 error-handling.
3-6. 트랜잭션 컨텍스트 — 세션 대신 본문 필드로 상태 전달
세션이 없으니 “이 요청이 어느 예약에 대한 것인가”는 전부 본문 필드로 식별한다. 핵심 식별자:
세션이 없으므로 예약 수정/취소 시 “내가 본 버전 == 서버의 현재 버전”인지를 Version 속성으로 검증한다(UniversalRecordCancelRQ.kt:21). 버전이 어긋나면 Travelport가 거부한다. 그래서 수정 전엔 보통 retrieve()로 최신 버전을 읽어 와야 한다. 이것이 stateful 세션 대신 쓰는 낙관적 락 패턴이다. → galileo-pitfalls
3-7. 실제 SOAP 요청 전문 모양 (AirPriceReq 예시)
mockData에 Galileo 샘플 전문은 없다(src/test/resources/mockData/galileo는 빈 디렉터리). 대신 DTO 애너테이션과 직렬화 로직으로부터 다음과 같은 Envelope가 만들어진다.
근거: 각 RQ의 @JacksonXmlRootElement(AirPriceRQ.kt:13, AirTicketingRQ.kt:11, AirFareRulesRQ.kt:10, UniversalRecordCancelRQ.kt:7, UniversalRecordRetrieveRQ.kt:7, GdsQueueCountRQ.kt:9).
3-8. 핵심 RQ 필드 매핑 (예시 표)
AirPriceRQ(AirPriceRQ.kt:17~48)의 주요 필드:
코틀린 프로퍼티
XML 이름
종류
비고
targetBranch
TargetBranch
속성
= soap.branchCode
traceId / authorizedBy
TraceId / AuthorizedBy
속성
추적/권한
billingPointOfSaleInfo
BillingPointOfSaleInfo
엘리먼트(common)
OriginApplication="uAPI" 고정
itinerary
AirItinerary
엘리먼트
여정/세그먼트
pricingModifier
AirPricingModifiers
엘리먼트
accountCode·sellCheck 반영
passengers
SearchPassenger(common, non-wrapping 반복)
엘리먼트 리스트
type.galileo 코드, reference=UUID
pricingCommands
AirPricingCommand(non-wrapping 반복)
엘리먼트 리스트
세그먼트 기반
reservationLocatorCode
AirReservationLocatorCode
엘리먼트
repricing 시 booking.subPnr
@JacksonXmlElementWrapper(useWrapping = false)의 의미
리스트 필드(passengers, pricingCommands)에 이 애너테이션이 붙으면 <SearchPassengers><SearchPassenger/>... 같은 래퍼 없이 <SearchPassenger/>가 형제로 여러 개 반복된다. Travelport 스키마가 반복 엘리먼트를 그대로 기대하기 때문이다. 새 반복 필드를 추가할 때 이 애너테이션을 빠뜨리면 전문이 스키마와 어긋난다.
4. KRT 채널 — 운임규정 한글 번역 (XML)
KrtClient는 SOAP 응답으로 받은 영문 운임규정을 한글로 번역받는 별도 게이트웨이다(Universal API가 아님).
// KrtClient.kt:36~46"${galileoApiProperties.krt.endpoint}/FareRuleTransKor.aspx" .post(KrtRQ.of(carrier = fareItinerary.validatingCarrier, fareRules = fareRules)) .header(mapOf(ACCEPT_ENCODING to "gzip,deflate", CONTENT_TYPE to APPLICATION_XML_VALUE)) // text가 아닌 application/xml .execute<KrtRS>()
요청 전문(KrtRQ.kt) — SOAP Envelope가 아니라 순수 XML 도큐먼트다:
CardNumber, AgtPWD가 본문에 평문으로 들어간다(KpsBspCardAuthRQ.kt:11,32). 결제 로깅에서 마스킹/비로깅 정책이 반드시 필요하다. → galileo-pitfalls
6. XSD 스키마 — 위치와 핵심 타입
XSD는 src/test/schema/galileo/ 아래에 버전별 디렉터리로 보관된다(코드 생성용이 아니라 검증/참고용). 핵심:
디렉터리
핵심 파일
내용
air_v52_0/
AirReqRsp.xsd(131KB), Air.xsd(625KB)
Air* 요청/응답·도메인 타입 전체. AirPriceReq/Rsp, AirTicketingReq/Rsp, AirFareRulesReq/Rsp, AirExchange*(재발행), AirRefund* 등 정의(AirReqRsp.xsd:161~1010)
universal_v52_0/
UniversalRecordReqRsp.xsd, UniversalRecord.xsd
UniversalRecord 생성/취소/조회/수정
common_v52_0/
Common.xsd, CommonReqRsp.xsd
BillingPointOfSaleInfo, SearchPassenger 등 공통 타입
gdsQueue_v52_0/
GDSQueue.xsd
큐 카운트/리스트/제거
SessionContext_v1/
SessionContext_v1.xsd
세션토큰(SessTok)/세션속성(SessProp) — main 미사용(§3-2)
(그 외)
passive, sharedBooking, vehicle, cruise, uprofile, terminal 등
Travelport 전체 도메인. 이 모듈은 air/universal/common/gdsQueue만 사용
각 도메인 폴더의 Kestrel.xsd는 루트 진입 스키마다(예: air_v52_0/Kestrel.xsd는 단순히 <xs:include schemaLocation="AirReqRsp.xsd"/> 한 줄). 스키마 전체가 targetNamespace="...air_v52_0" + elementFormDefault="qualified"이며, 이 “qualified” 정책이 §3-3의 wstxns/xmlns="" 후처리를 필요하게 만든 근본 원인이다.
새 SOAP 오퍼레이션을 만들 때 XSD를 먼저 읽어라
DTO 필드명·속성/엘리먼트 구분·반복 여부는 전부 해당 XSD에 정의돼 있다. 예컨대 발권 응답의 ProviderReservationInfo는 AirReqRsp.xsd:342에, 재발행 요청 AirExchangeReq는 :438에 있다. DTO를 추측으로 만들지 말고 XSD의 minOccurs/maxOccurs를 보고 @JacksonXmlElementWrapper/nullable을 결정하라.
ticketing()은 타임아웃 시 timeoutCallback()을 호출한 뒤 예외를 던진다(GalileoClient.kt:397~402). 발권은 Travelport 측에서 성공했는데 응답만 못 받았을 수 있어, 후속 조회로 실제 발권 여부를 확인하기 위한 훅이다. 단순 재시도하면 이중발권 위험이 있다. → galileo-pitfalls, 비동기/예외 전파는 error-handling.
설정 프로퍼티: configuration/Properties.kt:421~468 (GalileoApiProperties)
9. 셀프 체크
Q1. Galileo SOAP은 Amadeus처럼 세션을 유지하는가? 아니라면 예약 수정 시 "내가 보던 예약이 그새 바뀌었는지"는 무엇으로 판별하는가?
정답 보기
아니다. Galileo Universal API는 요청마다 HTTP Basic Auth만 붙이는 완전 stateless 호출이다(ClientSupport.kt:131, 모든 SOAP 메서드의 .authenticate(...)). 세션이 없으므로 동시성은 본문의 Version 속성(낙관적 락)으로 판별한다(UniversalRecordCancelRQ.kt:21). 그래서 수정/취소 전엔 retrieve()로 최신 버전을 읽어와야 한다. SessionContext_v1.xsd가 존재하지만 main 코드는 쓰지 않는다(§3-2).
Q2. soapRequestBodyConverter()의 두 replace 호출을 지우면 무슨 일이 생기나?
정답 보기
Jackson XML(Woodstox)이 만든 XML과 Travelport 스키마 사이의 간극이 드러나 스키마 검증에서 거부된다. " xmlns=\"\"" 제거는 자식 엘리먼트에 잘못 끼는 빈 기본 네임스페이스를 없애고, wstxns\d+ 제거는 Woodstox가 만든 임시 접두사를 없앤다. XSD가 elementFormDefault="qualified"라 네임스페이스가 정확해야 하기 때문(GalileoClient.kt:904~905, §3-3).
Q3. 검색 토큰 캐시 TTL을 expiresIn - 600으로 깎는 이유는?
정답 보기
실제 만료 직전에 토큰을 써서 401이 나는 경계 케이스를 피하려는 10분 안전마진이다(GalileoRestClient.kt:65). 캐시 키는 PCC별(GALILEO_REST_TOKEN::{pcc}), 저장은 setIfAbsent라 동시 발급 시 한 번만 기록된다.