Group Air 는 중앙 디스패처 없이 공급사 자체 REST 컨트롤러 4개가 Triple 예약 시스템의 내부 API로 노출됩니다. 컨트롤러는 모두 interfaces/controller/internals 패키지에 있고, 베이스 경로는 /internals/GROUPAIR/... 입니다.
오퍼레이션
컨트롤러
application 서비스
외부 호출 메서드(GroupairClient)
비고
Search (목록)
GroupairSearchController.search
GroupairFlightSearchService.search
search
코루틴 fan-out, @CircuitBreaker
Search (상세)
GroupairSearchController.detail
GroupairFlightSearchService.getFareItinerary
(없음, 캐시 조회만)
외부 호출 없음
FareRule
GroupairFareRuleController.getFareRules
GroupairFareRuleService.findFareRules
findFareRules
캐시 우선
Booking (생성)
GroupairBookingController.create
GroupairBookingService.book
createReservation
Booking (조회)
GroupairBookingController.retrieve / confirm
GroupairBookingService.retrieve
getReservation
Ticketing
GroupairTicketingController.issue
GroupairTicketingService.issue
updateReservation → ticketing
2단계 호출
Cancel (예상)
GroupairBookingController.expectedCancel
GroupairCancelService.expectedCancel
getReservation → expectedCancel
Cancel (실행)
GroupairBookingController.cancel
GroupairCancelService.cancel
getReservation → cancel
Cancel 가능여부
GroupairBookingController.cancelable
GroupairCancelService.cancelable
getReservation → expectedCancel
VOID/REFUND 판정
APIS 변경
GroupairBookingController.changeApis
GroupairPassengerService.changeApis
changeApis
여권/체류정보 보정
과제에서 명시한 4대 오퍼레이션(Search/Booking/Ticketing/FareRule) 외에, 코드에는 취소(Cancel) 계열 5개 엔드포인트와 APIS 변경이 추가로 구현되어 있습니다. 단체 운임이라도 일반 항공권과 동일한 예약 생애주기(검색→예약→발권→취소)를 지원하기 때문입니다. 이 노트는 코드에 실제로 존재하는 모든 오퍼레이션을 다룹니다.
모듈 전체에서 @Retry / @Bulkhead / @RateLimiter 어노테이션은 단 한 개도 없습니다. Resilience4j 중 사용되는 것은 GroupairSearchController.search의 @CircuitBreaker(name = "groupairSearch")하나뿐입니다(GroupairSearchController.kt:25). 즉 Booking/Ticketing/Cancel/FareRule 경로에는 서킷브레이커·리트라이가 전혀 걸려 있지 않습니다. 자세한 위험은 resilience-and-events, groupair-pitfalls 참고.
1. 공통 호출 기반 — GroupairClient & ClientSupport
모든 외부 API 호출은 infrastructure/GroupairClient.kt 한 파일에 모여 있습니다. GroupairClient는 support/web/ClientSupport를 상속하며, 생성자에서 타임아웃을 명시합니다.
// GroupairClient.kt:27-36@Componentclass GroupairClient( private val groupairProperties: GroupairProperties, @Qualifier("objectMapper") objectMapper: ObjectMapper,) : ClientSupport( objectMapper = objectMapper, searchTimeout = 30000, // 검색 전용 OkHttpClient (searchClient) defaultTimeout = 60000, // 그 외 호출 (defaultClient))
호출 DSL 패턴은 모든 메서드가 동일합니다(ClientSupport의 String.get/post/put/delete 확장 → .client(...) → .execute<T>() → .fold(success, failure)).
GroupairResponse<T>(response/GroupairResponse.kt)는 모든 응답을 감싸는 봉투(envelope)입니다. checkError는 code != "OK"(HttpStatus.OK.name)이면 콜백을 실행해 예외를 던집니다. 즉 HTTP 200 이어도 봉투 안의 code가 OK가 아니면 비즈니스 에러입니다. 이 패턴은 groupair-protocol에서 상세히 다룹니다.
검색은 이 모듈에서 유일하게 코루틴을 쓰는 동기 진입점입니다. withBlocking(Dispatchers.IO)(support/util/CoroutineExtensions.kt:13)가 runBlocking + SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext()로 코루틴 스코프를 만들고, pmap(같은 파일 :36)이 각 출발-목적지 조합을 withAsync로 병렬 실행합니다. 비동기 상세는 async-coroutines 참고.
cartesianProduct()(support/util/CollectionUtils.kt:26) 가 다중 공항/멀티시티 입력에서 출발×목적지 조합 수만큼 외부 호출을 곱셈으로 증폭시킵니다. 예: 출발 후보 3 × 목적지 후보 3 = 9개의 동시 GET. 검색은 서킷브레이커가 있지만(groupairSearch), 호출 폭발 자체는 차단되지 않으므로 부하 관점의 함정입니다. groupair-pitfalls에서 강조합니다.
부분 실패 허용: pmap 결과는 onFailure { exceptions, successes -> if (successes.isEmpty()) throw ... }로 처리됩니다(FlightSearchService.kt:59). 즉 조합 일부만 실패하고 하나라도 성공하면 그 결과만 반환합니다. 전부 실패할 때만 첫 예외로 SEARCH_FAILED를 던집니다. 그래서 .getOrEmpty()(성공분만 추출)를 씁니다.
multiFare 미지원 — preferences.first()만 사용
FlightSearchService.kt:52:
preference = SearchPreferenceInfo.of(preferences.first()), // multiFare 옵션이 없기 때문에 첫번째 하나만 사용
Group Air 는 여러 운임 선호(multiFare)를 받지 않으므로, 들어온 preference 목록 중 첫 번째만 사용합니다. 호출자가 여러 preference를 넣어도 무시됩니다.
서킷이 OPEN이면 searchFallback(exception: CallNotPermittedException)(SearchController.kt:69)가 호출되어, Datadog 스팬에 supplier.circuit-breaker = OPEN 태그를 달고 빈 목록을 반환합니다. 즉 검색 장애가 전체 멀티 공급사 검색을 막지 않도록 설계되어 있습니다.
2.2 상세 검색 — detail
flowchart TD
A["GET internals GROUPAIR search (SearchDetailRequest)"] --> B["GroupairSearchController.detail"]
B --> C["flightSearchService.getFareItinerary(key)<br/>캐시에서 단건 조회"]
C --> D[".validate(child, infant)<br/>CHILD INFANT 운임 존재 검증"]
D --> E["flightAmenityService.findAmenityMap(amenityKey, segmentKeys)"]
E --> F["FareItineraryDetailView.of(fareItinerary, amenityMap)"]
상세 조회는 외부 API를 호출하지 않습니다. 목록 검색 단계에서 캐시에 저장된 FareItinerary를 GroupairFareItineraryRepository.getFareItinerary(key)로 꺼낼 뿐입니다(FlightSearchService.kt:89). FareItinerary.validate(domain/model/GroupairFlightSearch.kt:48)는 child/infant가 요청됐는데 해당 PassengerFare가 없으면 INVALID_PASSENGER_TYPE을 던집니다.
캐시에 키가 없으면 getFareItinerary가 그대로 예외를 던집니다(repository가 null-safe하지 않음). 검색→상세→예약은 반드시 같은 검색 캐시 키 위에서 순차적으로 이뤄져야 합니다. 캐시 TTL이 지나면 후속 상세/예약이 깨집니다. groupair-pitfalls 참고.
3. FareRule — 운임 규정
flowchart TD
A["GET internals GROUPAIR fare-rules (key, adult, child, infant)"] --> B["GroupairFareRuleController.getFareRules"]
B --> C["GroupairFareRuleService.findFareRules"]
C --> D["generateFareRuleKey(key, adult, child, infant) → fareRuleKey<br/>fareRuleRepository.findFareRules(fareRuleKey)"]
D -->|"캐시 HIT"| H["FareRuleView.of(fareRule) 변환"]
D -->|"캐시 MISS"| E["fareItineraryRepository.getFareItinerary(key) 검색 캐시"]
E --> F["GroupairClient.findFareRules(fareItinerary)<br/>GET searchEndpoint fare-rules<br/>쿼리: agentId departureGoodSequence"]
F --> G["Group Air REST<br/>GroupairResponse List FareRuleResponse"]
G --> I["FareRuleResponse.toFareRule()<br/>saveFareRules(fareRuleKey) 캐시 저장"]
I --> H
예약 성공 직후 book()은 removeFlightSearchKey(requestKey)를 호출하는데, 이게 fire-and-forget 코루틴입니다.
// BookingService.kt:39-43private fun removeFlightSearchKey(key: String) { CoroutineScope(Dispatchers.IO).withLaunch { flightSearchKeyRepository.removeKey(key) }}
withLaunch(CoroutineExtensions.kt:20)는 SupervisorJob + AdapterCoroutineExceptionHandler를 붙여 실행하고 결과를 기다리지 않습니다. 캐시 키 삭제가 실패해도 예약 응답에는 영향이 없지만, 삭제 실패 시 같은 검색 결과로 중복 예약이 가능해지는 잠재 위험이 있습니다. 비동기 처리 상세는 async-coroutines 참고.
ReservationDetailResponse.toBooking()(:48-63)에는 검증이 거의 없습니다. carrierTimeLimit = LocalDateTime.now().plusHours(1)로 고정 1시간을 부여하고, voidable = true를 무조건 true로 채웁니다. 실제 발권취소(void) 가능 여부와 무관하게 응답에 박히는 값이므로 호출 측에서 신뢰하면 안 됩니다. groupair-pitfalls 참고.
4.2 예약 조회 — retrieve / confirm
flowchart TD
A1["GET internals GROUPAIR bookings pnr (retrieve)"] --> B
A2["GET internals GROUPAIR bookings pnr confirm (confirm)"] --> B
B["GroupairBookingService.retrieve(pnr, supplierIdentificationKey)<br/>둘 다 동일한 서비스 호출"] --> C
C["GroupairClient.getReservation(pnr, supplierIdentificationKey)<br/>GET bookingEndpoint reservations supplierIdentificationKey<br/>쿼리 pnrNumber=pnr (단 pnr이 UNKNOWN UNDEFINED_PNR이면 쿼리 생략)"] --> D
D["Group Air REST GroupairResponse ReservationDetailResponse → toBooking()"] --> E["BookingView.of(booking)"]
confirm과 retrieve는 코드상 완전히 동일한 서비스 호출(bookingService.retrieve)을 합니다. URL과 응답 status만 다릅니다. supplierIdentificationKey는 Group Air의 내부 예약 sequence(숫자)이며, toBooking에서 sequence.toString()으로 채워집니다(ReservationDetailResponse.kt:55).
getReservation의 success 분기에 //todo already canceled 주석이 있습니다(GroupairClient.kt:182). 이미 취소된 예약 조회 시 별도 처리가 미구현입니다. groupair-pitfalls 참고.
5. Ticketing — 발권 (2단계 호출)
발권은 단일 외부 호출이 아니라 순차 2단계입니다. 이 모듈에서 가장 주의해야 할 흐름입니다.
sequenceDiagram
participant T as "Triple"
participant TC as "GroupairTicketingController.issue"
participant SVC as "GroupairTicketingService.issue"
participant CL as "GroupairClient"
T->>TC: "POST internals GROUPAIR ticketing (TicketingRequest)"
Note over TC: "request.supplierIdentificationKey!! (널이면 즉시 NPE)"
TC->>SVC: "issue(supplierIdentificationKey)"
SVC->>CL: "① updateReservation(key) 예약 정보 갱신 PUT"
SVC->>CL: "② ticketing(key) 발권 확정 PUT"
Note over SVC,CL: "2단계는 순차 호출이며 트랜잭션 멱등성 서킷브레이커 없음"
SVC-->>TC: "결과"
Note over TC: "TicketingView(passengers = emptyList) 항상 빈 승객 리스트"
TC-->>T: "응답"
// GroupairClient.kt:191fun updateReservation(supplierIdentificationKey: String) { val orderNumber = MDCHolder.OrderNumber.getOrNull()?.takeIf { it.isNotBlank() } val reservationUpdateRequest = ReservationUpdateRequest( agentReservationNumber = orderNumber, eventId = orderNumber, ) // PUT {bookingEndpoint}/reservations/{supplierIdentificationKey} // 실패 → SAVE_FAILED}
MDCHolder.OrderNumber(요청 컨텍스트의 주문번호)를 읽어 agentReservationNumber/eventId에 동시에 넣고, 발권 직전 예약에 주문번호를 각인합니다.
5.2 2단계 — ticketing (발권 확정)
// GroupairClient.kt:220fun ticketing(supplierIdentificationKey: String) { // PUT {bookingEndpoint}/reservations/{supplierIdentificationKey}/channel-confirm // 본문 없는 PUT (.put()) // 실패 → TICKETING_FAILED}
원자성 없음 + 멱등성 없음 + 서킷브레이커 없음. 발권은 PUT 두 번을 순차로 호출하지만 트랜잭션이 아닙니다. ① updateReservation 성공 후 ② ticketing이 타임아웃/실패하면, 예약은 갱신됐지만 발권은 안 된 중간 상태가 남습니다. 재시도 시 ①이 다시 실행되며, 두 호출 모두 @Retry/@CircuitBreaker가 없어 일시 장애에 그대로 노출됩니다. 또 컨트롤러가 발권 결과를 매핑하지 않고 passengers = emptyList()만 반환하므로, 발권 성공 여부를 응답으로 확인할 수 없습니다(예외가 안 나면 성공). groupair-pitfalls, resilience-and-events에서 다룹니다.
왜 발권이 "갱신→확정" 2단계인가?
Group Air 는 콘솔리데이터로, 예약(reservation)과 발권(channel-confirm)을 분리합니다. 발권 전 마지막에 우리 측 주문번호(agentReservationNumber)를 예약에 동기화한 뒤(=updateReservation), 채널 확정(channel-confirm)으로 발권을 트리거합니다. GDS의 PNR 갱신 후 TKT 발행과 개념이 비슷하지만, 여기선 모두 REST PUT 입니다.
6. Cancel — 취소/환불 (예상·실행·가능여부)
취소 계열 3개 오퍼레이션은 모두 GroupairCancelService(application/GroupairCancelService.kt)에 있고, 공통적으로 먼저 getReservation으로 예약을 조회해 승객별 identificationKey를 모은 뒤 외부 취소 API를 부릅니다.
6.1 환불 예상액 조회 — expectedCancel
flowchart TD
A["GET internals GROUPAIR bookings pnr expected-cancel"] --> B["GroupairCancelService.expectedCancel"]
B --> C["① booking = groupairClient.getReservation(pnr, key)<br/>② voidable = booking.passengers.map(it.tickets).isEmpty() (주의)<br/>③ refunds = groupairClient.expectedCancel(key, passengerIdentificationKeys)"]
C --> D["GroupairClient.expectedCancel<br/>GET bookingEndpoint reservations key expected-cancel<br/>쿼리 passengerSequences = key1 key2 ..."]
D --> E["GroupairResponse List PassengerCancelResponse"]
E --> F["PassengerCancelResponse.toRefund()<br/>Refund(type, identificationKey, refundFee=penalty)"]
F --> G["ExpectedCancelView(voidable, refunds.filter(refundFee gt 0).map RefundView.of)"]
부분취소(승객 단위) 구조.cancel/expectedCancel은 PNR 전체가 아니라 passengerSequences 콤마 목록으로 동작합니다(GroupairClient.kt:250,281). 현재 서비스 코드는 booking.passengers.map { it.identificationKey!! }로 전원을 넘기지만(CancelService.kt:25), 외부 API 자체는 일부 승객만 넘기는 부분취소를 지원하는 형태입니다. 향후 부분취소 확장 시 이 시그니처를 그대로 쓰면 됩니다.
컨트롤러가 응답을 refundFee > 0인 승객만 필터링합니다(BookingController.kt:67,88). 환불수수료가 0인 승객(예: 전액 환불 케이스)은 응답에서 사라집니다. 또 CancelView.voided는 항상 false로 하드코딩됩니다(BookingController.kt:65). groupair-pitfalls 참고.
취소는 getReservation → cancel(DELETE)2호출이지만 둘 다 @Retry/@CircuitBreaker가 없습니다. getReservation 성공 후 cancel이 네트워크 타임아웃으로 끊기면, 실제 외부에서는 취소가 됐는데 우리 응답은 실패가 될 수 있습니다(상태 불일치). ALREADY_CANCELED_RESERVATION 코드 매핑은 이런 재시도 상황을 어느 정도 흡수하기 위한 장치입니다.
6.3 취소 가능 유형 판정 — cancelable
flowchart TD
A["GET internals GROUPAIR bookings pnr cancelable"] --> B["GroupairCancelService.cancelable"]
B --> C["booking = groupairClient.getReservation(...)"]
C --> D{"booking.passengers.map(it.tickets).isEmpty()"}
D -->|"true"| E1["(VOID, null) 발권 전 무료 취소 void"]
D -->|"false"| E2["(REFUND, expectedCancel) 발권 후 환불 수수료 계산"]
E1 --> F["CancelableTypeDetail(action, refunds)"]
E2 --> F
F --> G["CancelableTypeDetailView.of(cancelable)"]
발권 여부 판정식은 CancelService.kt:31 (의심 버그, 아래 danger 콜아웃 참고)
isEmpty() 버그 의심 지점.expectedCancel(:15)과 cancelable(:31)은 발권 여부를 booking.passengers.map { it.tickets }.isEmpty()로 판정합니다. 이 식은 "각 승객의 tickets 리스트들로 구성된 List<List>"이 비었는지를 보는 것이라, **승객이 한 명이라도 있으면 항상 false**가 됩니다(개별 승객의 tickets가 비었는지를 보지 않음). 의도는 "발권된 티켓이 없으면 VOID"였을 가능성이 높습니다(flatMap/all { it.tickets.isEmpty() }가 맞는 형태). 결과적으로 cancelable은 거의 항상 REFUND로 판정될 수 있습니다. 소스는 읽기 전용이므로 수정하지 않고, groupair-pitfalls에 지뢰로 기록합니다.
// CancelService.kt:31return when (booking.passengers.map { it.tickets }.isEmpty()) { ... }
CancelActionType(VOID/REFUND)는 어댑터 공통 enum입니다. 발권 전 취소는 수수료 없는 VOID, 발권 후는 환불 REFUND로 구분하는 게 의도입니다. 취소 정책 자체는 common-operations 참고.
7. APIS 변경 — changeApis (여권/체류정보 보정)
탑승 전 승객의 여권(passport)·체류정보(stayInfo)를 수정하는 부가 오퍼레이션입니다.
flowchart TD
A["PUT internals GROUPAIR bookings pnr (BookingChangeRequest)"] --> B["GroupairBookingController.changeApis<br/>request.passengers.map Passenger.of"]
B --> C["GroupairPassengerService.changeApis(passengers)"]
C --> D["GroupairClient.changeApis(passengers)<br/>passengers.map PassengerApisUpdateRequest.of<br/>sequence = passenger.identificationKey!!.toLong() (널 비숫자면 예외)<br/>passport stayInfo<br/>PUT bookingEndpoint passengers apis"]
D --> E["GroupairResponse List PassengerResponse"]
E --> F["PassengerResponse.toPassenger()"]
F --> G["PassengerApisChangeView.of(passenger)"]
PassengerApisUpdateRequest.of(request/ReservationCreateRequest.kt:85)는 passenger.identificationKey!!.toLong()을 합니다. identificationKey가 널이거나 숫자가 아니면 NPE/NumberFormatException이 그대로 터집니다. 이 키는 예약 조회 시 sequence.toString()으로 채워진 값이어야 하므로, 예약 조회 결과를 그대로 들고 와서 변경하는 플로우가 전제됩니다.
8. 운임 재계산/재발행 — Group Air 에는 “없다”
이 모듈에는 재발행(reissue)·운임 재계산(repricing)·일정 변경(rebooking) 오퍼레이션이 구현되어 있지 않습니다.GroupairClient 전체에서 reissue/reprice/repricing/exchange 류 메서드는 존재하지 않습니다. 그룹/단체 운임 특성상 발권 후 변경은 콘솔리데이터 오프라인 처리로 넘어가기 때문으로 보입니다.
따라서 다른 GDS/NDC 공급사의 재발행 시퀀스(예: Singapore Air의 reissue, Amadeus의 repricing)와 비교하면 Group Air는 생애주기가 가장 단순합니다. 이 점이 “가장 작은 모듈”인 이유 중 하나입니다.
9. 전체 콜러-콜리 요약 다이어그램
flowchart TD
T["Triple 예약 시스템 — 내부 API 호출자"]
subgraph "controller internals"
SC["SearchController"]
FC["FareRuleController"]
BC["BookingController"]
TC["TicketingController"]
end
subgraph "application"
SS["FlightSearchService"]
FS["FareRuleService"]
BS["Booking Cancel Passenger Service"]
TS["TicketingService"]
end
GC["GroupairClient (infrastructure)<br/>search · findFareRules · createReservation · getReservation<br/>updateReservation · ticketing · expectedCancel · cancel · changeApis<br/>ClientSupport: OkHttp + GroupairResponse T + fold"]
EXT["Group Air 콘솔리데이터 REST API<br/>searchEndpoint (검색 규정) · bookingEndpoint (예약 발권 취소 APIS)"]
T --> SC
T --> FC
T --> BC
T --> TC
SC --> SS
FC --> FS
BC --> BS
TC --> TS
SS --> GC
FS --> GC
BS --> GC
TS --> GC
GC --> EXT
핵심 비대칭: 검색만 코루틴 fan-out + 서킷브레이커 + 캐시 우선. 나머지 전부(예약/발권/취소/규정/APIS)는 동기 단일 흐름이며, 발권·취소만 내부적으로 2호출입니다. 이 구조를 머리에 넣으면 Group Air 디버깅이 쉬워집니다.
10. 정리 — 면접식 점검
Group Air 발권은 왜 한 번의 호출이 아닌가? 어떤 위험이 있나?
정답 보기
GroupairTicketingService.issue가 updateReservation(PUT reservations/{key})로 주문번호를 동기화한 뒤 ticketing(PUT …/channel-confirm)으로 발권을 확정하는 2단계입니다(TicketingService.kt:10-13). 두 호출 사이에 트랜잭션·멱등성·서킷브레이커가 없어, ②가 실패하면 “예약은 갱신됐는데 발권은 안 됨” 중간 상태가 남고 재시도 시 ①이 재실행됩니다. 게다가 컨트롤러는 passengers = emptyList()만 돌려줘 결과 검증이 불가합니다.
검색에서 외부 호출이 갑자기 폭증하는 이유는?
정답 보기
AirportUtils.makeOriginDestinations(...).cartesianProduct()(FlightSearchService.kt:46-48, CollectionUtils.kt:26)가 출발/목적지의 다중 공항·멀티시티 후보를 데카르트 곱으로 펼치고, 그 조합마다 pmap으로 병렬 GET을 날리기 때문입니다. 후보가 3×3이면 9개의 동시 검색 호출이 됩니다.
cancelable이 거의 항상 REFUND를 반환할 수 있는 이유는?
정답 보기
booking.passengers.map { it.tickets }.isEmpty()(CancelService.kt:31)는 승객별 tickets 리스트들의 “바깥 리스트”가 비었는지를 봅니다. 승객이 1명 이상이면 이 바깥 리스트는 절대 비지 않으므로 항상 false→REFUND가 됩니다. 발권 전(VOID) 판정이 사실상 동작하지 않는 의심 버그입니다.