Galileo (Travelport) — 프로토콜·전문

module-galileo arch-supplier-module api-gds pattern-soap pattern-rest config-auth

한 줄 요약

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-operations, 함정은 galileo-pitfalls, 어댑터 공통 DTO 규약은 interfaces-dtos를 참고하라.


1. 프로토콜 전체 지도 — 한 모듈, 네 개의 통신 채널

Galileo 인프라 패키지(.../galileo/infrastructure)에는 클라이언트가 4개 있고, 각각 프로토콜·인증·직렬화가 전부 다르다.

채널클라이언트프로토콜 / 포맷인증ObjectMapper근거 파일
검색GalileoRestClientREST / JSONOAuth2 Bearer (+Redis 캐시)objectMapper(JSON)infrastructure/rest/GalileoRestClient.kt
예약·발권·운임규정·큐·취소·조회·수정·분리·EMDGalileoClientSOAP 1.1 / XML (Universal API v52)HTTP Basic@Qualifier("xmlMapper")infrastructure/soap/GalileoClient.kt
운임규정 한글번역KrtClientHTTP POST / XML (FareRuleTransKor.aspx)없음(엔드포인트 자체가 사설)@Qualifier("xmlMapper")infrastructure/krt/KrtClient.kt
결제(BSP카드·현금영수증)KpsPaymentClientHTTP 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:95
"${galileoApiProperties.rest.endpoint}/11/air/catalog/search/catalogproductofferings"
    .post(CatalogProductOfferRequest.of(...))
    .header(headers)          // Accept/Content-Type: application/json, Accept-Encoding: gzip
    .bearer(token)            // Authorization: Bearer {token}
    .client(searchClient)     // 검색 전용 30초 타임아웃 클라이언트
    .log(enableSearchLog.or(logging))
    .execute<CatalogProductOfferResponse>()

핵심 헤더(GalileoRestClient.kt:40~45, 76~94):

헤더비고
AuthorizationBearer {accessToken}bearer() (ClientSupport.kt:136)
Content-Type / Acceptapplication/json
Accept-Encodinggzip, deflate응답 압축
Cache-Controlno-cache
XAUTH_TRAVELPORT_ACCESSGROUPrest.xauthAccessGroupTravelport 액세스 그룹 식별자(검색에만 추가)
taxBreakDown"true"세금 분해 응답 요청

2-2. OAuth2 토큰 발급 — password grant + Redis 캐시

검색 전에 getToken()이 먼저 호출되고, 토큰은 PCC별로 Redis에 캐시된다.

// GalileoRestClient.kt:48~74
fun getToken(): String {
    return findGalileoAccessTokenInRedis(pcc)   // 1) 캐시 hit면 그대로
        ?: run {
            // 2) miss → authEndpoint로 password grant
            galileoApiProperties.rest.authEndpoint
                .post(makeGetTokenRequest(galileoApiProperties))   // form-urlencoded
                .header(headers + (Content-Type to APPLICATION_FORM_URLENCODED))
                .execute<AuthTokenResponse>()
                .fold(success = { saveGalileoAccessTokenInRedis(...) }, failure = { GET_TOKEN_FAILED })
        }
}

토큰 요청 본문(makeGetTokenRequest, GalileoRestClient.kt:171~179)은 JSON이 아니라 form-urlencoded 문자열이다:

client_id={clientId}&client_secret={clientSecret}&grant_type=password&username={rest.userName}&password={rest.password}

응답(AuthTokenResponse.kt):

JSON 필드코틀린 프로퍼티용도
access_tokenaccessTokenBearer 토큰 본문
token_typetokenType(저장 안 함)
expires_inexpiresIn캐시 TTL 계산

토큰 캐시 TTL은 일부러 10분 짧게 잡는다

GalileoRestClient.kt:65expiresIn = 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개의 서비스 경로로 나뉜다. 어느 서비스로 보낼지는 오퍼레이션이 결정한다.

서비스 경로담당 오퍼레이션근거(GalileoClient.kt)
/AirServicePricing, FareRules, Ticketing, Void, RetrieveDocument, EMD Retrieve, AirCreateReservation(예약):111, :225, :376, :418, :546, :667
/UniversalRecordServiceCancel, Retrieve, Modify(가격/결제/삭제/커미션/APIS), Divide:317, :452, :491, :794
/GdsQueueServiceQueue Count / List / Remove:807, :844, :879

예약 요청은 Air, 응답은 Universal 스키마

book()/AirServiceAirCreateReservationRQ(루트 AirCreateReservationReq)를 보내지만, 응답 루트는 AirCreateReservationRsp이고 네임스페이스는 universal_v52_0이다(AirCreateReservationRS.kt:8~11). Travelport에서 “예약 생성”은 Air 도메인 요청이지만 결과는 Universal Record(통합 예약레코드)로 떨어지기 때문이다.

3-2. 인증 — HTTP Basic, 세션 없음 (stateful 아님)

다른 GDS와 결정적 차이: Galileo SOAP은 세션·로그인·시그니처가 없다

Amadeus는 PNR 세션(Start/End Transaction)과 SOAP Security 헤더를 유지하는 stateful 방식이지만, Galileo Universal API는 요청마다 HTTP Basic Auth만 붙이는 완전 stateless 호출이다. 모든 SOAP 호출이 동일하게:

.authenticate(galileoApiProperties.soap.userName, galileoApiProperties.soap.password)
// → header["Authorization"] = Credentials.basic(user, pw)  (ClientSupport.kt:131~134)

만 한다. 세션 토큰을 들고 다니지 않으므로 “세션 만료” 같은 장애 모드가 없다. 대신 모든 트랜잭션 컨텍스트(예약 식별)는 본문 안의 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.ktsecHeader { }(wss4j WSSecHeader 기반) 확장이 있지만, Galileo 클라이언트에서는 secHeader 호출이 0건이다(다른 SOAP 공급사용). Galileo의 인증은 전적으로 HTTP Basic 헤더다.

공통 헤더(GalileoClient.kt:88~91):

헤더
Content-Typetext/xml (MediaType.TEXT_XML_VALUE)
Accept-Encodinggzip,deflate
AuthorizationBasic base64(user:pw)

3-3. 요청 직렬화 — 2단계 변환의 핵심

여기가 Galileo SOAP의 가장 까다로운 부분이다. 요청 DTO는 Jackson XML 애너테이션이 붙은 평범한 data class일 뿐, SOAP Envelope를 모른다. Envelope는 soapRequestBodyConverter()가 감싼다.

// GalileoClient.kt:901~907
private fun soapRequestBodyConverter(): (Any?) -> String {
    return { request ->
        soap { body(objectMapper.writeValueAsBytes(request).inputStream()) }  // 1) DTO→XML→Body에 삽입
            .replace(" xmlns=\"\"", "")                                       // 2) 빈 네임스페이스 제거
            .replace(Regex("(:)?(wstxns\\d+)(:)?"), "")                       // 3) Woodstox 접두사 제거
    }
}

3단계로 일어나는 일:

  1. DTO → XML 바이트xmlMapper.writeValueAsBytes(request). @JacksonXmlRootElement(localName="AirPriceReq", namespace=...) 가 루트 엘리먼트를 만든다.
  2. XML → SOAP Bodysoap { body(...) }(SoapExtensions.kt:41,148). jakarta.xml.soap.MessageFactory로 빈 Envelope를 만들고, 위에서 만든 XML 문서를 soapPart.envelope.body.addDocument(it)로 Body에 통째로 붙인다.
  3. 문자열 후처리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~86
inline 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.firstChildxmlMapper.readValue<T>()로 매핑(SoapExtensions.kt:33). Body의 첫 자식(즉 AirPriceRsp 등)이 곧 응답 DTO다.
  • 파싱 실패 시 원문을 ResponseLog로 남기고 예외를 재던진다(GalileoClient.kt:82~85) — 망가진 전문 디버깅의 단서.

3-5. 응답 래퍼 — GalileoResponse / GalileoResponseBody (2단 에러 모델)

Galileo SOAP 에러는 두 층이다: ① 프로토콜 레벨 SOAPFault, ② 비즈니스 레벨 ResponseMessage(type=“Error”). 이걸 단일 인터페이스로 통합한 게 GalileoResponse다.

// GalileoResponse.kt
data 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)는 responseMessagestype=="Error"segmentSellFailureInfo를 검사한다. AirPriceRS(AirPriceRS.kt:44~58)는 responseMessagespriceResults[].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. 트랜잭션 컨텍스트 — 세션 대신 본문 필드로 상태 전달

세션이 없으니 “이 요청이 어느 예약에 대한 것인가”는 전부 본문 필드로 식별한다. 핵심 식별자:

필드의미어디서
TargetBranch타깃 브랜치(여행사 단말 컨텍스트) = soap.branchCode거의 모든 RQ 루트 속성
ProviderCode = "1G"Galileo 시스템 코드(고정)ProviderReservationInfo.code, GdsQueueCountRQ.providerCode
ProviderLocatorCode항공사 PNR(provider PNR)조회/취소
UniversalRecordLocatorCode통합 예약레코드 PNR취소/조회/수정
Version낙관적 동시성 버전 — 수정·취소 시 필수UniversalRecordCancelRQ.version(:21~22)
BillingPointOfSaleInfo @OriginApplication="uAPI"POS 정보(고정값 uAPI)모든 RQ 공통(BillingPointOfSaleInfo.kt)

Version은 stateless의 대가다

세션이 없으므로 예약 수정/취소 시 “내가 본 버전 == 서버의 현재 버전”인지를 Version 속성으로 검증한다(UniversalRecordCancelRQ.kt:21). 버전이 어긋나면 Travelport가 거부한다. 그래서 수정 전엔 보통 retrieve()로 최신 버전을 읽어 와야 한다. 이것이 stateful 세션 대신 쓰는 낙관적 락 패턴이다. → galileo-pitfalls

3-7. 실제 SOAP 요청 전문 모양 (AirPriceReq 예시)

mockData에 Galileo 샘플 전문은 없다(src/test/resources/mockData/galileo는 빈 디렉터리). 대신 DTO 애너테이션과 직렬화 로직으로부터 다음과 같은 Envelope가 만들어진다.

AirPriceRQ.kt의 애너테이션을 근거로 한 개념 전문:

<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
  <SOAP-ENV:Body>
    <air:AirPriceReq xmlns:air="http://www.travelport.com/schema/air_v52_0"
                     TargetBranch="{branchCode}" TraceId="..." AuthorizedBy="...">
      <com:BillingPointOfSaleInfo xmlns:com="http://www.travelport.com/schema/common_v52_0"
                                  OriginApplication="uAPI"/>
      <air:AirItinerary> ... <AirSegment .../> ... </air:AirItinerary>
      <air:AirPricingModifiers AccountCode="..."/>             <!-- 프로모션/계정코드 -->
      <com:SearchPassenger Code="ADT" .../>                    <!-- 승객 타입(galileo 코드) -->
      <air:AirPricingCommand> ... </air:AirPricingCommand>
      <air:AirReservationLocatorCode>{subPnr}</air:AirReservationLocatorCode>  <!-- 재가격(repricing) 시 -->
    </air:AirPriceReq>
  </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

네임스페이스 매핑(GalileoNameSpace.kt):

상수URI쓰임
AIRhttp://www.travelport.com/schema/air_v52_0Air* 요청 루트
UNIVERSALhttp://www.travelport.com/schema/universal_v52_0UniversalRecord*, AirCreateReservation 응답
COMMONhttp://www.travelport.com/schema/common_v52_0BillingPointOfSaleInfo, SearchPassenger 등 공통 타입
GDS_QUEUEhttp://www.travelport.com/schema/gdsQueue_v52_0GdsQueue* 요청 루트

루트 엘리먼트 ↔ 오퍼레이션 매핑 (RQ 기준)

오퍼레이션루트 localName네임스페이스
PricingAirPriceReqair
FareRulesAirFareRulesReqair
TicketingAirTicketingReqair
BookingAirCreateReservationReq(RS는 AirCreateReservationRsp, universal)air→universal
CancelUniversalRecordCancelRequniversal
RetrieveUniversalRecordRetrieveRequniversal
Queue CountGdsQueueCountReqgdsQueue

근거: 각 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 이름종류비고
targetBranchTargetBranch속성= soap.branchCode
traceId / authorizedByTraceId / AuthorizedBy속성추적/권한
billingPointOfSaleInfoBillingPointOfSaleInfo엘리먼트(common)OriginApplication="uAPI" 고정
itineraryAirItinerary엘리먼트여정/세그먼트
pricingModifierAirPricingModifiers엘리먼트accountCode·sellCheck 반영
passengersSearchPassenger(common, non-wrapping 반복)엘리먼트 리스트type.galileo 코드, reference=UUID
pricingCommandsAirPricingCommand(non-wrapping 반복)엘리먼트 리스트세그먼트 기반
reservationLocatorCodeAirReservationLocatorCode엘리먼트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 도큐먼트다:

<FareRuleRQ>
  <OtaCode>INT</OtaCode>
  <PlatingCarrier>{validatingCarrier}</PlatingCarrier>
  <OriginInd>N</OriginInd>
  <TransText><![CDATA[1. ...운임규정 본문...]]></TransText>   <!-- @JacksonXmlCData -->
</FareRuleRQ>
필드XML비고
otaCodeOtaCode"INT" 고정
carrierPlatingCarrier발권 항공사
originExposureIndicatorOriginInd"N" 기본
translateTextTransTextCDATA로 감싼 규정 원문(@JacksonXmlCData, KrtRQ.kt:19)

인증이 없다

KRT 호출엔 Basic/Bearer가 없다. 엔드포인트 자체가 내부망 사설 게이트웨이라 별도 인증을 두지 않는다. Content-Type은 SOAP의 text/xml이 아니라 application/xml이다.


5. KPS 채널 — BSP 결제·현금영수증 (JSON)

KpsPaymentClient는 국내 BSP 카드결제·현금영수증 게이트웨이(*.aspx, JSON)다. CashReceipt 오퍼레이션의 실제 백엔드.

메서드엔드포인트용도
approveKpsBspCardAuth.aspxBSP 카드 승인
cancelKpsBspCardAuthX.aspx카드 승인취소
issueCashReceiptKpsCashReceiptIssue.aspx현금영수증 발행
cancelCashReceiptKpsCashReceiptCancel.aspx현금영수증 취소

인증은 헤더가 아니라 요청 본문 필드(KpsBspCardAuthRQ.kt)로 들어간다:

JSON 필드코틀린비고
AgtID / AgtPWDagentId / agentPassword= kps.id / kps.password (자격증명)
TktPCCpcc발권 PCC(최대 4자)
GDSTypegdsType"G"(Galileo 1G) 고정
PlatingAirVvalidatingCarrier승인 항공사 2-letter
PNR / ReservationCode / ReqIDpnr / 예약번호 / 요청자ID
CardNumber/ExpireY/ExpireM/CardInstallment/CardTotAmount카드정보ExpireY는 2자리(YY)

카드 PAN·자격증명이 평문 JSON으로 나간다

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.xsdUniversalRecord 생성/취소/조회/수정
common_v52_0/Common.xsd, CommonReqRsp.xsdBillingPointOfSaleInfo, SearchPassenger 등 공통 타입
gdsQueue_v52_0/GDSQueue.xsd큐 카운트/리스트/제거
SessionContext_v1/SessionContext_v1.xsd세션토큰(SessTok)/세션속성(SessProp) — main 미사용(§3-2)
(그 외)passive, sharedBooking, vehicle, cruise, uprofile, terminalTravelport 전체 도메인. 이 모듈은 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에 정의돼 있다. 예컨대 발권 응답의 ProviderReservationInfoAirReqRsp.xsd:342에, 재발행 요청 AirExchangeReq:438에 있다. DTO를 추측으로 만들지 말고 XSD의 minOccurs/maxOccurs를 보고 @JacksonXmlElementWrapper/nullable을 결정하라.


7. 타임아웃·재시도·압축

항목근거
SOAP 기본 타임아웃60초(connect/read/write/call)GalileoClientClientSupport(defaultTimeout = 60000) (GalileoClient.kt:69)
검색 타임아웃30초GalileoRestClient(searchTimeout = 30000) (GalileoRestClient.kt:34) → searchClient
KRT/KPS 타임아웃60초defaultTimeout = 60000
Queue Remove 재시도@Retryable(maxAttempts=3, backoff=5초)GalileoClient.kt:871 (큐 제거만 Spring Retry)
발권 타임아웃 콜백it.isTimeout → timeoutCallback()GalileoClient.kt:398 (발권 타임아웃 시 보정 훅)
압축gzip/deflate 요청모든 채널 Accept-Encoding

발권 타임아웃은 "실패"로 단정하지 않는다

ticketing()은 타임아웃 시 timeoutCallback()을 호출한 뒤 예외를 던진다(GalileoClient.kt:397~402). 발권은 Travelport 측에서 성공했는데 응답만 못 받았을 수 있어, 후속 조회로 실제 발권 여부를 확인하기 위한 훅이다. 단순 재시도하면 이중발권 위험이 있다. → galileo-pitfalls, 비동기/예외 전파는 error-handling.


8. 한눈에 보는 프로토콜 요약 (Quick Reference)

검색      REST/JSON   Bearer(Redis캐시,TTL-10분)  /11/air/catalog/search/catalogproductofferings
가격/예약  SOAP/XML    Basic, stateless            /AirService , /UniversalRecordService
발권/규정  SOAP/XML    Basic                       /AirService
큐        SOAP/XML    Basic, @Retryable(3,5s)     /GdsQueueService
운임번역   HTTP/XML    none                        FareRuleTransKor.aspx (CDATA)
결제      HTTP/JSON   본문 AgtID/AgtPWD            Kps*.aspx

핵심 코드 좌표:

  • SOAP Envelope 조립/후처리: GalileoClient.kt:901~907 (soapRequestBodyConverter)
  • SOAP 응답 역직렬화/Fault: GalileoClient.kt:72~86 (soapBodyDeserializerOf) + GalileoResponse.kt
  • 2단 에러 모델: GalileoResponse.kt + 각 *RS.checkError
  • OAuth2 토큰+캐시: GalileoRestClient.kt:48~74, 171~192
  • HTTP Basic/Bearer 주입: ClientSupport.kt:131~139
  • SOAP 유틸: support/util/SoapExtensions.kt
  • 네임스페이스: soap/request/GalileoNameSpace.kt
  • 설정 프로퍼티: configuration/Properties.kt:421~468 (GalileoApiProperties)

9. 셀프 체크

Q1. Galileo SOAP은 Amadeus처럼 세션을 유지하는가? 아니라면 예약 수정 시 "내가 보던 예약이 그새 바뀌었는지"는 무엇으로 판별하는가?

Q2. soapRequestBodyConverter()의 두 replace 호출을 지우면 무슨 일이 생기나?

Q3. 검색 토큰 캐시 TTL을 expiresIn - 600으로 깎는 이유는?


관련 노트