Group Air — 프로토콜·전문

module-groupair api-rest pattern-protocol config-endpoints

이 노트의 위치

Group Air 모듈이 외부 콘솔리데이터와 실제로 어떤 전선(wire)으로 무슨 메시지를 주고받는지를 다룹니다. 오퍼레이션 시퀀스는 groupair-operations, 함정은 groupair-pitfalls, 모듈 전체 그림은 groupair-overview를 참고하세요. 어댑터 내부 컨트롤러 DTO(우리 쪽 API 스펙)는 interfaces-dtos와 구분됩니다. 이 노트는 어댑터 ↔ 콘솔리데이터 구간만 봅니다.


0. 한 줄 요약

결론부터

Group Air는 순수 JSON REST입니다. SOAP envelope도, NDC XML도, XSD 스키마도 없습니다. 인증은 토큰/시그니처/세션이 아니라 쿼리 파라미터 agentId(대리점 ID) 하나뿐인 무상태(stateless) B2B 연동입니다. 모든 응답은 GroupairResponse<T>{ code, data, message } 제네릭 봉투로 감싸여 오고, code != "OK"면 에러입니다.

분석 대상: infrastructure/GroupairClient.kt, infrastructure/request/*, infrastructure/response/*, 전송 공통 support/web/ClientSupport.kt. (src/test/schema/groupair, src/test/resources/mockData/groupair존재하지 않음 — 실측 전문 샘플 없음.)


1. 프로토콜과 전송 방식

1.1 무엇으로 전송하는가 — REST JSON (SOAP/XML/NDC 아님)

Group Air는 11개 공급사 중 유일하게 그룹/단체 운임 콘솔리데이터(중개사)의 사설 JSON REST API에 직결합니다. GDS(amadeus/sabre/galileo)의 SOAP envelope, NDC(koreanair/lufthansa/singaporeair)의 EDIST/IATA XML과 달리 전송 단위가 평범한 JSON입니다.

코드로 확인되는 근거:

근거위치의미
기본 헤더 Content-Type: application/jsonsupport/web/ClientSupport.kt:31JSON 본문 전송
요청 본문 변환 = Jackson objectMapper.writeValueAsStringClientSupport.kt:32DTO → JSON 직렬화 (SOAP 마샬링 아님)
응답 역직렬화 = Jackson readValue(TypeReference<RES>)ClientSupport.kt:176JSON → DTO
경로가 /goods/search, /reservations, /fare-rules 같은 RESTful 리소스GroupairClient.kt:55,107,152리소스 지향 URL
HTTP 동사 GET/POST/PUT/DELETE를 의미대로 사용ClientSupport.kt:50-60REST 시맨틱

SOAP 경로는 정의돼 있지만 groupair는 안 씀

공통 ClientSupport에는 SOAP fault를 파싱하는 handleSoapFaultException(...)(ClientSupport.kt:62-74)이 있지만 이는 GDS 모듈용입니다. GroupairClient는 이 함수를 전혀 호출하지 않습니다. groupair의 에러는 SOAP fault가 아니라 JSON 봉투의 code 필드로 옵니다(아래 4절).

1.2 HTTP 동사 ↔ 동작 매핑

GroupairClient의 8개 호출이 사용하는 동사와 경로는 다음과 같습니다. base는 검색계(searchEndpoint)와 예약계(bookingEndpoint) 두 종류로 나뉩니다.

#함수동사경로 (base 제외)본문base
1searchGET/goods/search/{출발타입:코드}-{도착타입:코드}/{출발일}/{귀국일}없음(쿼리)search
2findFareRulesGET/fare-rules없음(쿼리)search
3createReservationPOST/reservationsReservationCreateRequestbooking
4getReservationGET/reservations/{supplierIdentificationKey}없음(쿼리)booking
5updateReservationPUT/reservations/{supplierIdentificationKey}ReservationUpdateRequestbooking
6ticketingPUT/reservations/{supplierIdentificationKey}/channel-confirm없음booking
7expectedCancelGET/reservations/{supplierIdentificationKey}/expected-cancel없음(쿼리)booking
8cancelDELETE/reservations/{supplierIdentificationKey}없음(쿼리 passengerSequences)booking
9changeApisPUT/passengers/apisList<PassengerApisUpdateRequest>booking

PUT/POST의 빈 본문 처리에 주의

ClientSupport.buildRequest는 POST/PUT을 만들 때 일단 "".toRequestBody()(빈 본문)로 빌드하고(ClientSupport.kt:82-83), 실제 본문은 execute() 단계에서 requestBody를 JSON으로 직렬화해 교체합니다(ClientSupport.kt:149-160). ticketing처럼 본문이 없는 PUT은 .put()(인자 없음)이라 빈 문자열이 전송됩니다. 즉 본문 없는 PUT도 빈 바디로 정상 전송됩니다.

1.3 경로 조립 디테일 — search 엔드포인트

검색 URL이 가장 특이합니다. RESTful path에 출발/도착 location과 날짜를 통째로 끼워 넣고, 나머지는 쿼리로 보냅니다.

// GroupairClient.kt:55
"${searchEndpoint}/goods/search/${dep.originType}:${dep.origin}-${arr.originType}:${arr.origin}/${dep.departureDate}/${arr.departureDate}"
  • originTypeAIRPORT/CITY 같은 location 종류(상위 support.model.OriginDestination).
  • 예: .../goods/search/AIRPORT:ICN-AIRPORT:NRT/2026-06-10/2026-06-15
  • 왕복 가정: originDestinations[0]=출발편, originDestinations[1]=귀국편으로 고정 인덱스 접근(GroupairClient.kt:51-52). 편도/다구간 처리 함정은 groupair-pitfalls 참고.

검색 쿼리 파라미터(GroupairClient.kt:57-69):

파라미터비고
agentIdgroupairApiProperties.agencyId인증 (2절)
cabinsCabinType 다중좌석등급 반복 추가
airlines항공사 코드 다중선택(nullable)
adult / child / infant인원 수preference.passenger.*.count
freeBaggageOnlyBoolean무료수하물 포함 상품만

2. 인증·세션

인증 = agentId 쿼리 파라미터 하나. 세션/토큰/시그니처 없음

GDS의 stateful PNR 세션, NDC의 OAuth 토큰, tway의 SEED 암호화 같은 게 전혀 없습니다. 모든 요청에 대리점 식별자(agencyId)를 쿼리 또는 본문 필드로 실어 보내는 것이 인증의 전부입니다. 따라서 groupair는 완전 무상태(stateless) 입니다.

agentId가 실리는 자리:

위치형태코드
검색쿼리 agentIdGroupairClient.kt:58
운임규정쿼리 agentIdGroupairClient.kt:110
예약 생성본문 필드 agentIdReservationCreateRequest.kt:16GroupairClient.kt:149

값의 출처는 설정의 GroupairApiProperties.agencyId이며, 채널/퍼널별로 다른 대리점 ID·엔드포인트가 선택됩니다(3절).

Authorization 헤더는 안 쓴다

OkHttpRequestBuilder에는 authenticate(user, pwd)(Basic), bearer(token) 헬퍼가 있지만(ClientSupport.kt:131-139), GroupairClient는 둘 다 호출하지 않습니다. 표준 HTTP 인증 헤더 없이 agentId만으로 식별합니다. 운영 보안은 콘솔리데이터 쪽 IP allowlist/사설망에 의존한다고 보는 것이 안전합니다.


3. 엔드포인트 설정 — 채널/퍼널별 라우팅

엔드포인트는 코드 상수가 아니라 설정(GroupairProperties)에서 채널·퍼널별로 선택됩니다. 단일 콘솔리데이터가 아니라 판매 채널/퍼널에 따라 다른 백엔드/대리점이 붙을 수 있는 구조입니다.

// configuration/Properties.kt:470-505 (요약)
data class GroupairApiProperties(
    override val funnel: String,
    val searchEndpoint: String,    // 검색계 base
    val bookingEndpoint: String,   // 예약계 base
    val agencyId: String,          // 인증용 대리점 ID
) : FunnelProperties
 
@ConfigurationProperties(prefix = "supplier.groupair")
data class GroupairProperties(val channels: List<GroupairChannelProperties>) {
    fun getApiProperties(
        salesChannel: String = MDCHolder.SalesChannel.get(),
        salesFunnel:  String = MDCHolder.SalesFunnel.get(),
    ): GroupairApiProperties { ... }   // 채널→퍼널 2단 조회
}

흐름:

flowchart TD
    H["요청 헤더"] --> M["MDC<br/>SalesChannel, SalesFunnel"]
    M --> P["GroupairClient.groupairApiProperties<br/>get 프로퍼티, 매 호출 평가"]
    P -->|"groupairProperties.getApiProperties()"| FC["channels.find 채널 == salesChannel"]
    FC -->|"실패"| EC["NOT_SUPPORTED_SALES_CHANNEL"]
    FC -->|"성공"| FF["get funnel == salesFunnel"]
    FF -->|"실패"| EF["NOT_SUPPORTED_SALES_FUNNEL"]
    FF -->|"성공"| R["GroupairApiProperties<br/>searchEndpoint, bookingEndpoint, agencyId"]
  • GroupairClient.groupairApiPropertiesget() 프로퍼티라 매 호출 시점의 MDC 값으로 재계산됨.
  • 채널 조회 실패 시 NOT_SUPPORTED_SALES_CHANNEL, 퍼널 조회 실패 시 NOT_SUPPORTED_SALES_FUNNEL 예외.
  • 최종 결과 GroupairApiPropertiessearchEndpoint, bookingEndpoint, agencyId를 담음.

엔드포인트는 매 호출 MDC에서 동적 결정

GroupairClient.groupairApiPropertiesval이 아니라 get() 프로퍼티라 호출 시점의 MDC 값으로 매번 다시 계산됩니다(GroupairClient.kt:37-38). MDC에 SalesChannel/SalesFunnel이 안 채워져 있으면 위 두 예외(NOT_SUPPORTED_SALES_*)가 터집니다. MDC 전파는 configuration-and-infra 참고.

설정 import는 supplier/groupair.yml에서 환경별로 AWS Secrets Manager를 optional import 합니다(dev|qa|staging|prod/air-intl-adapter/groupair). 즉 실제 엔드포인트/대리점 ID는 시크릿에 들어 있습니다(resources/supplier/groupair.yml:7,15,23,31). 빌드/배포 설정 전반은 build-deploy-config 참고.


4. 응답 봉투(envelope)와 에러 모델

모든 응답은 동일한 제네릭 봉투로 감싸여 옵니다.

// infrastructure/response/GroupairResponse.kt
data class GroupairResponse<T>(
    val code: String,    // "OK"면 성공, 그 외는 에러코드
    val data: T?,        // 페이로드 (제네릭)
    val message: String?,// 에러 메시지
) {
    fun checkError(callback: ((code: String, message: String) -> Unit)) {
        if (code != HttpStatus.OK.name) {        // "OK" 문자열 비교
            callback(code, message ?: "Unknown Error")
        }
    }
}

2겹 에러 판정 — HTTP 상태 + 봉투 code

에러는 두 단계로 걸러집니다.

  1. HTTP 레벨: response.isSuccessful(2xx)이 아니면 ClientSupportResult.failure(OkHttpError(...))로 만듭니다(ClientSupport.kt:180-187). 각 함수의 failure = { ... } 분기로 빠짐.
  2. 봉투 레벨: HTTP 2xx여도 봉투 code != "OK"checkError가 콜백을 호출해 InternationalAdapterException을 던집니다.

HTTP 200 + code:"BOOKING_FAILED" 같은 “성공 상태 코드인데 실패한 응답”을 반드시 봉투로 다시 판정합니다. 이걸 놓치면 실패를 성공으로 오인합니다.

봉투 code에 따른 분기는 거의 일괄 변환이지만, 취소(cancel)만 특정 코드를 의미있게 분기합니다:

// GroupairClient.kt:288-296 (cancel)
when (code) {
    "NON_CANCELABLE_RESERVATION" -> throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE)
    "ALREADY_CANCELED_RESERVATION" -> throw InternationalAdapterException(ErrorMessage.ALREADY_CANCELED_PNR)
    else -> throw InternationalAdapterException(ErrorMessage.CANCEL_FAILED, code, message).capture()
}
오퍼레이션매핑 에러(ErrorMessage)특이코드 분기
searchSEARCH_FAILED
findFareRulesFETCH_FARE_RULES_FAILED
createReservationBOOKING_FAILED
getReservationRETRIEVE_FAILED//todo already canceled 주석만(미구현)
updateReservationSAVE_FAILED
ticketingTICKETING_FAILED
expectedCancelCALCULATE_CANCEL_FEE_FAILED
cancelCANCEL_FAILEDNON_CANCELABLE_RESERVATIONCANCEL_UNABLE, ALREADY_CANCELED_RESERVATIONALREADY_CANCELED_PNR
changeApisPASSENGER_CHANGE_FAILED

.capture()의 의미

에러 생성 시 끝에 붙는 .capture()(예: GroupairClient.kt:81)는 Slack 경보·관측(observability)으로 흘려보내는 캡처입니다. 단, cancelCANCEL_UNABLE/ALREADY_CANCELED_PNR.capture()없습니다 — “정상 범주의 비즈니스 거절”은 경보를 울리지 않습니다. 에러 전파/경보 큰 그림은 error-handling, resilience-and-events 참고.

response.data!! 강제 언랩

checkError를 통과(=code OK)한 뒤 곧바로 response.data!!(non-null 단언)를 씁니다(GroupairClient.kt:84,127,160,183,263,298,327). 콘솔리데이터가 code:"OK"인데 data:null을 주면 NullPointerException이 터지고 failure 분기가 아니라 일반 예외로 빠집니다. 이는 groupair-pitfalls에서 자세히 다룹니다.


5. 전송 계층 부가 사양 (공통 ClientSupport)

groupair만의 특수 전송은 없고, 공통 OkHttp 래퍼를 그대로 씁니다. 그래도 protocol로서 알아야 할 점:

항목값/동작위치
타임아웃(검색)connect/read/write/call 각 30,000msGroupairClient.kt:34(searchTimeout=30000)
타임아웃(그 외)60,000msGroupairClient.kt:35(defaultTimeout=60000)
검색만 searchClient 사용.client(searchClient) 명시GroupairClient.kt:71 (나머지는 defaultClient)
응답 압축 해제Content-Encodinggzip/deflate/br 자동 해제OkhttpClientConfiguration.kt:18-26
요청/응답 로깅LoggingAndCompressionInterceptor가 본문을 구조화 로그로OkhttpClientConfiguration.kt:28-69
Unit/String 응답RES==Unit 또는 String이면 역직렬화 건너뜀ClientSupport.kt:173-174

GroupairResponse<Unit> 의 함정성 동작

updateReservation/ticketingexecute<GroupairResponse<Unit>>()을 씁니다(GroupairClient.kt:200,223). RESGroupairResponse<Unit>(Unit이 아님)이라 봉투 자체는 정상적으로 JSON 파싱되고, 내부 data:Unit?만 비게 됩니다. 즉 봉투의 code 검사는 그대로 동작합니다. (위 표의 “Unit이면 역직렬화 스킵”은 RES==Unit::class일 때만 — 여기 해당 없음.)


6. 요청 전문 모델 — 핵심 필드 매핑

6.1 ReservationCreateRequest (예약 생성 본문)

// infrastructure/request/ReservationCreateRequest.kt
data class ReservationCreateRequest(
    val departureGoodSequence: Long,                 // 어떤 "상품(good)"을 잡을지
    val goodCategory: GoodCategory = GoodCategory.REAL_TIME,
    val agentId: String,                             // 인증 = 대리점 ID
    val agentReservationNumber: String? = null,
    val passengers: List<PassengerCreateRequest>,
)
필드출처(도메인)의미
departureGoodSequenceFareItinerary.departureGoodSequence상품 식별자 — 검색 결과에서 고른 그룹운임 상품
goodCategory기본 REAL_TIME상품종류(REAL_TIME/PACKAGE/FREE, support/enums/GoodCategory.kt)
agentIdgroupairApiProperties.agencyId채널/퍼널별 대리점 ID
passengers[]PassengerCreateModel아래 6.2

PassengerCreateRequest (승객 1명):

필드비고
typePassengerType(ADULT/CHILD/INFANT)
lastName / firstName영문 성/이름
birthDateLocalDate
titleTitle(MR/MS/MSTR/MISS) — 성별 추론에도 쓰임
passportNumber/ExpireDate/IssueNation/Nation여권(APIS), nullable
stayInfo체류정보(목적지 입국용), nullable
remark특이사항

예약의 핵심은 단 하나, departureGoodSequence

GDS/NDC가 운임을 다시 가격책정(repricing)하는 것과 달리, groupair 예약은 검색 때 받은 상품 일련번호 하나로 “그 상품을 잡아라” 라고 지시할 뿐입니다. 운임/스케줄을 본문에 다시 보내지 않습니다. 단체 운임이 미리 묶여 있기 때문입니다(groupair-overview 1.1).

6.2 그 외 요청 전문

DTO쓰임핵심 필드
ReservationUpdateRequestupdateReservation(발권 1단계)agentReservationNumber, eventId — 둘 다 MDC 주문번호로 채움(GroupairClient.kt:192-196)
PassengerApisUpdateRequestchangeApis(여권/체류정보 변경)sequence(= 승객 identificationKey), passport, stayInfo

PassengerApisUpdateRequest.of!!

sequence = passenger.identificationKey!!.toLong()(ReservationCreateRequest.kt:85) — APIS 변경 대상 승객에 identificationKey가 없으면 NPE. 이 키는 예약 조회 응답의 PassengerResponse.sequence에서 채워지므로, 예약을 먼저 조회한 승객 객체여야 안전합니다.


7. 응답 전문 모델 — 핵심 필드 매핑

응답 DTO는 7개 파일에 흩어져 있습니다. 봉투(GroupairResponse<T>)의 T 자리에 들어가는 페이로드 타입을 정리합니다.

오퍼레이션봉투 T 타입→ 변환되는 도메인
searchList<GoodSearchItemResponse>List<FareItinerary>
findFareRulesList<FareRuleResponse>List<FareRule>
createReservation / getReservationReservationDetailResponseBooking
expectedCancelList<PassengerCancelResponse>List<Refund>
cancelCancelResponseList<Refund>
changeApisList<PassengerResponse>List<Passenger>
update / ticketingUnit(없음)

7.1 검색 응답 — GoodSearchItemResponse

GoodSearchItemResponse                  (상품 1건)
├── goodSequence / departureGoodSequence / salesMarkupSequence
├── validatingCarrier                   ← 발권 항공사 (상품마다 다름!)
├── schedules : List<GoodScheduleResponse>
│     ├── departure/arrival, departureAt/arrivalAt, addDay, stop, avail
│     ├── freeBaggage : FreeBaggageResponse?
│     └── segments : List<GoodSegmentResponse>
│           └── marketing/operatingCarrier, bookingClass, flightNumber, cabin, flightTime(ISO Duration)
└── passengerFares : List<GoodPassengerFareResponse>
      └── type, airPrice, tax, fuelCharge, total

핵심 매핑 디테일(response/GoodSearchItemResponse.kt):

동작위치주의점
flightTime이 ISO-8601 Duration 문자열:58-60Duration.parse(it.flightTime)"PT2H30M" 형식. 숫자/분 아님
세그먼트 합산이 schedule 총 비행시간:58-60멀티세그먼트면 각 flightTime을 ms로 합산
tax = tax + fuelCharge로 합산 저장:144유류할증료를 tax에 합쳐 보관 (유의: 이중집계 X, 도메인 규약)
무료수하물 단위 환산:111BaggageUnit.getBaggageUnitBySupplier(unit, Supplier.GROUPAIR) — 공급사별 단위 매핑

7.2 예약상세 응답 — ReservationDetailResponseBooking

가장 큰 응답 DTO이며 조회/예약 양쪽이 공유합니다. 변환 시 굳은 규칙들:

도메인 필드소스규칙 (ReservationDetailResponse.kt)
Booking.supplierIdentificationKeysequence.toString()후속 모든 호출의 키 (PNR 아님!) :55
Booking.pnrpnr?.pnrNumber없으면 UNDEFINED_PNR("UNKNOWN") :49
carrierPnrpnr?.carrierPnrNumber ?: pnrNumber없으면 pnr로 폴백 :50
carrierTimeLimitnow().plusHours(1)콘솔리데이터가 안 줘서 임의 +1h 하드코딩 :59
paymentTimeLimitnull항상 null :60
voidabletrue항상 void 가능 고정 :62
segment status"HK"모든 세그먼트 상태 하드코딩 :120
bookingClassbookingClass ?: "Y"없으면 Y로 폴백 :118

PassengerResponsePassenger 매핑 디테일:

도메인소스/규칙
identificationKeysequence.toString() — APIS 변경/취소 시 승객 키
gendertitle로 추론: MR/MSTR→MALE, MS/MISS→FEMALE (:168-171)
commissionfares.first().commissionAmount, 타입 NET (:183-187)
ticketsfares.mapNotNull { it.ticket?.toTicket() } (발권 후에만 채워짐)

fares.first() 빈 리스트 위험

PassengerResponse.toPassenger()fares.first().commissionAmount를 호출합니다(:183). fares가 빈 리스트면 NoSuchElementException. 발권 전/특수 상태 응답에서 위험. groupair-pitfalls 참고.

TicketResponse.toTicket()의 타입 분기: type == "TICKET"TicketType.TICKET, 그 외 전부 → TicketType.EMD(:243).

7.3 취소/환불 응답

// CancelResponse.kt
data class CancelResponse(val sequence: Long, val passengers: List<PassengerCancelResponse>)
data class PassengerCancelResponse(
    val sequence: Long, val cancelStatus: String, val type: String, val penalty: Long,
) { fun toRefund() = Refund(sequence.toString(), PassengerType.getPassengerTypeCodeBySupplier(type, Supplier.GROUPAIR), penalty) }
  • expectedCancel은 봉투 TList<PassengerCancelResponse>(승객별 예상 환불수수료)이고, cancelCancelResponse(래퍼) — 같은 PassengerCancelResponse를 다른 봉투 형태로 받습니다(GroupairClient.kt:253,284,298).
  • penalty(Long)가 Refund.refundFee로 그대로 들어갑니다. 승객 type 문자열은 공급사별 매핑 함수로 표준 PassengerType으로 환산.

7.4 운임규정 응답 — FareRuleResponse

// FareRuleResponse.kt
data class FareRuleResponse(val name: String, val content: String, val order: Int)

카테고리를 "이름 문자열 한글 키워드"로 추론

toFareRule()name"취소"/"환불"/"변경"이 있으면 REFUND_AND_CHANGE, "수하물"/"수화물"이면 BAGGAGE, "마일리지"MILEAGE, 아니면 COMMON으로 분류합니다(FareRuleResponse.kt:14-21). 즉 운임규정 분류가 한국어 키워드 매칭에 의존합니다 — 콘솔리데이터가 규정 이름 표기를 바꾸면 분류가 깨질 수 있습니다(groupair-pitfalls).


8. XSD 스키마 / 실측 전문 샘플

groupair에는 XSD도, mockData 샘플도 없음

  • src/test/schema/groupair : 존재하지 않음. XSD 스키마 자체가 없습니다. JSON REST라 스키마는 Kotlin DTO 클래스가 사실상의 “스키마”입니다. (SOAP/NDC 모듈인 amadeus/galileo/singaporeair는 XSD가 있지만 groupair는 무관.)
  • src/test/resources/mockData/groupair : 존재하지 않음. 캡처된 실전문 샘플이 없어, 본 노트의 전문 구조는 전부 DTO 정의에서 역설계한 것입니다.

따라서 신입은 “전송 포맷을 알고 싶다 = 위 DTO 클래스를 읽어라”가 정답입니다. SOAP envelope처럼 외부 스키마 파일을 뒤질 필요가 없습니다.

전문 구조를 빠르게 알고 싶으면 보는 파일:

알고 싶은 것보는 파일
검색 요청 쿼리GroupairClient.kt:40-95
검색 응답 JSON 구조infrastructure/response/GoodSearchItemResponse.kt
예약 요청 JSON 구조infrastructure/request/ReservationCreateRequest.kt
예약 응답 JSON 구조infrastructure/response/ReservationDetailResponse.kt
공통 봉투/에러infrastructure/response/GroupairResponse.kt

9. 기존 분석문서 교차참조

groupair 전용 supplier-api-analysis 문서는 없음

content/nol/supplier-api-analysis/ 아래에는 GDS/대형 NDC 위주로 amadeus-gds, sabre-gds, galileo-gds, singaporeair-ndc만 존재하고 groupair 분석문서는 없습니다. groupair의 1차 자료는 이 노트 시리즈(groupair-overview/groupair-operations/groupair-protocol/groupair-pitfalls)가 사실상 유일합니다.


다음으로 읽을 노트