Group Air — 오퍼레이션 흐름

module-groupair arch-supplier-module api-rest pattern-operation-flow

이 노트의 위치

이 문서는 Group Air 모듈의 오퍼레이션별 내부 처리 흐름을 코드 레벨로 추적합니다. 콜러(Controller) → 콜리(application 서비스) → infrastructure 클라이언트 → 외부 API → 응답 매핑까지 메서드/클래스/파일경로를 모두 명시합니다. 모듈 전반은 groupair-overview, 외부 API 엔드포인트/요청 포맷은 groupair-protocol, 함정은 groupair-pitfalls를 참고하세요. 11개 공급사 공통 오퍼레이션 규약은 common-operations, 요청 한 건의 전체 경로는 request-flow, 콜러-콜리 전역 지도는 caller-callee-map, 비동기 패턴은 async-coroutines에서 다룹니다.


0. 오퍼레이션 한눈에 보기

Group Air 는 중앙 디스패처 없이 공급사 자체 REST 컨트롤러 4개가 Triple 예약 시스템의 내부 API로 노출됩니다. 컨트롤러는 모두 interfaces/controller/internals 패키지에 있고, 베이스 경로는 /internals/GROUPAIR/... 입니다.

오퍼레이션컨트롤러application 서비스외부 호출 메서드(GroupairClient)비고
Search (목록)GroupairSearchController.searchGroupairFlightSearchService.searchsearch코루틴 fan-out, @CircuitBreaker
Search (상세)GroupairSearchController.detailGroupairFlightSearchService.getFareItinerary(없음, 캐시 조회만)외부 호출 없음
FareRuleGroupairFareRuleController.getFareRulesGroupairFareRuleService.findFareRulesfindFareRules캐시 우선
Booking (생성)GroupairBookingController.createGroupairBookingService.bookcreateReservation
Booking (조회)GroupairBookingController.retrieve / confirmGroupairBookingService.retrievegetReservation
TicketingGroupairTicketingController.issueGroupairTicketingService.issueupdateReservationticketing2단계 호출
Cancel (예상)GroupairBookingController.expectedCancelGroupairCancelService.expectedCancelgetReservationexpectedCancel
Cancel (실행)GroupairBookingController.cancelGroupairCancelService.cancelgetReservationcancel
Cancel 가능여부GroupairBookingController.cancelableGroupairCancelService.cancelablegetReservationexpectedCancelVOID/REFUND 판정
APIS 변경GroupairBookingController.changeApisGroupairPassengerService.changeApischangeApis여권/체류정보 보정

과제에서 명시한 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 한 파일에 모여 있습니다. GroupairClientsupport/web/ClientSupport를 상속하며, 생성자에서 타임아웃을 명시합니다.

// GroupairClient.kt:27-36
@Component
class GroupairClient(
    private val groupairProperties: GroupairProperties,
    @Qualifier("objectMapper") objectMapper: ObjectMapper,
) : ClientSupport(
    objectMapper = objectMapper,
    searchTimeout = 30000,   // 검색 전용 OkHttpClient (searchClient)
    defaultTimeout = 60000,  // 그 외 호출 (defaultClient)
)

호출 DSL 패턴은 모든 메서드가 동일합니다(ClientSupportString.get/post/put/delete 확장 → .client(...).execute<T>().fold(success, failure)).

"<url>".get(쿼리) / .post(body) / .put(body) / .delete(쿼리)
   └─ .client(searchClient)        // search 만 명시. 나머지는 defaultClient(기본)
   └─ .execute<GroupairResponse<T>>()
   └─ .fold(
          success = { response -> response.checkError { ... } ; response.data!!.map { ... } },
          failure = { throw ... }
      )

GroupairResponse<T>(response/GroupairResponse.kt)는 모든 응답을 감싸는 봉투(envelope)입니다. checkErrorcode != "OK"(HttpStatus.OK.name)이면 콜백을 실행해 예외를 던집니다. 즉 HTTP 200 이어도 봉투 안의 code가 OK가 아니면 비즈니스 에러입니다. 이 패턴은 groupair-protocol에서 상세히 다룹니다.

엔드포인트는 GroupairApiProperties(configuration/Properties.kt:470)에서 채널/퍼널별로 주입됩니다.

필드용도
searchEndpointSearch, FareRule 호출 베이스
bookingEndpointBooking, Ticketing, Cancel, APIS 호출 베이스
agencyId대리점 식별자, 검색/예약/운임규정 쿼리에 부착

groupairProperties.getApiProperties()MDCHolder.SalesChannel / SalesFunnel을 읽어 채널별 설정을 찾습니다(Properties.kt:494). 채널/퍼널이 매핑에 없으면 NOT_SUPPORTED_SALES_CHANNEL / NOT_SUPPORTED_SALES_FUNNEL 예외가 납니다.


2. Search — 검색 (목록 + 상세)

2.1 목록 검색 — 콜러→콜리 체인

sequenceDiagram
    participant T as "Triple 예약시스템"
    participant SC as "GroupairSearchController.search"
    participant SVC as "GroupairFlightSearchService.search"
    participant CL as "GroupairClient.search"
    participant EXT as "Group Air 콘솔리데이터 REST"
    T->>SC: "POST internals GROUPAIR search (SearchRequest)"
    Note over SC: "CircuitBreaker groupairSearch fallback searchFallback<br/>isSearchable 아니면 emptyList<br/>generateSearchRequestKey → requestKey"
    SC->>SVC: "search(requestKey)"
    Note over SVC: "① findKey(requestKey)"
    alt 캐시 HIT
        SVC-->>SC: "getFareItineraries + filterByUnexposedFareItinerary (외부호출 없음)"
    else 캐시 MISS
        Note over SVC: "② generateFareItineraryKey GROUPAIR<br/>③ withBlocking Dispatchers.IO 코루틴 진입<br/>makeOriginDestinations.cartesianProduct (출발×목적지 조합 폭발)<br/>.pmap 조합별 병렬 호출<br/>onFailure: 성공분 비면 SEARCH_FAILED<br/>flatten.distinctBy(id).filterByUnexposedFareItinerary<br/>④ useCache이고 결과 있으면 addKey + saveFareItineraries"
        loop 출발-목적지 조합마다 병렬
            SVC->>CL: "groupairClient.search(...)"
            Note over CL: "GET searchEndpoint goods search<br/>쿼리: agentId cabins airlines adult child infant freeBaggageOnly<br/>.client(searchClient) → 30s 타임아웃"
            CL->>EXT: "GET 검색 요청"
            EXT-->>CL: "GroupairResponse List GoodSearchItemResponse"
            Note over CL: "toFareItinerary(requestKey, key) → FareItinerary<br/>schedules passengerFares 매핑"
            CL-->>SVC: "List FareItinerary"
        end
        SVC-->>SC: "결과"
    end
    Note over SC: "FareItineraryView.of(it) 변환 후 200 응답"
    SC-->>T: "검색 결과"
  • GroupairSearchController.searchSearchController.kt:26
  • GroupairFlightSearchService.searchFlightSearchService.kt:29
  • GroupairClient.searchGroupairClient.kt:40
  • GoodSearchItemResponse.toFareItineraryGoodSearchItemResponse.kt:22

검색은 이 모듈에서 유일하게 코루틴을 쓰는 동기 진입점입니다. 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()(성공분만 추출)를 씁니다.

서킷이 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)"]
  • GroupairSearchController.detailSearchController.kt:54
  • 외부 API 호출 없음 (검색 캐시에서 FareItinerary 단건 조회만)

상세 조회는 외부 API를 호출하지 않습니다. 목록 검색 단계에서 캐시에 저장된 FareItineraryGroupairFareItineraryRepository.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
  • GroupairFareRuleController.getFareRulesFareRuleController.kt:20
  • GroupairFareRuleService.findFareRulesFareRuleService.kt:16
  • GroupairClient.findFareRulesGroupairClient.kt:97
  • FareRuleResponse.toFareRuleFareRuleResponse.kt:11
  • departureGoodSequence = fareItinerary.departureGoodSequence

또 다른 운임규정 엔드포인트 getStructuredFareRules(FareRuleController.kt:35)는 외부 호출 없이 검색 캐시의 FareItinerary만으로 StructuredFareRuleView.of(...)를 생성합니다.


4. Booking — 예약 생성/조회

4.1 예약 생성 — create / book

sequenceDiagram
    participant T as "Triple"
    participant BC as "GroupairBookingController.create"
    participant SVC as "GroupairBookingService.book"
    participant CL as "GroupairClient.createReservation"
    participant EXT as "Group Air REST"
    T->>BC: "POST internals GROUPAIR bookings (BookingRequest)"
    Note over BC: "passengers.map PassengerCreateModel.of"
    BC->>SVC: "book(key, passengers)"
    Note over SVC: "① getFareItinerary(key) 검색 캐시 필수"
    SVC->>CL: "createReservation(fareItinerary, passengers)"
    Note over CL: "ReservationCreateRequest.of(fareItinerary, passengers, agencyId)<br/>departureGoodSequence = fareItinerary.departureGoodSequence<br/>goodCategory = REAL_TIME 기본<br/>passengers = PassengerCreateRequest.of (여권 체류정보 remark)"
    CL->>EXT: "POST bookingEndpoint reservations"
    EXT-->>CL: "GroupairResponse ReservationDetailResponse"
    Note over CL: "checkError → BOOKING_FAILED(code, message).capture() Slack 경보<br/>toBooking()"
    CL-->>SVC: "Booking"
    Note over SVC: "② book() 후처리: removeFlightSearchKey(requestKey)"
    SVC-->>BC: "Booking"
    Note over BC: "BookingView.of(booking)"
    BC-->>T: "200 응답"
  • GroupairBookingController.createBookingController.kt:25
  • GroupairBookingService.bookBookingService.kt:18
  • GroupairClient.createReservationGroupairClient.kt:142
  • ReservationCreateRequest.ofrequest/ReservationCreateRequest.kt:22
  • ReservationDetailResponse.toBookingReservationDetailResponse.kt:48

예약 성공 직후 book()removeFlightSearchKey(requestKey)를 호출하는데, 이게 fire-and-forget 코루틴입니다.

// BookingService.kt:39-43
private 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)"]
  • GroupairBookingService.retrieveBookingService.kt:32
  • GroupairClient.getReservationGroupairClient.kt:168
  • confirmretrieve는 동일 서비스 호출, URL과 응답 status만 다름

confirmretrieve는 코드상 완전히 동일한 서비스 호출(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: "응답"
  • GroupairTicketingController.issueTicketingController.kt:17
  • GroupairTicketingService.issueTicketingService.kt:10

5.1 1단계 — updateReservation (예약 갱신)

// GroupairClient.kt:191
fun 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:220
fun 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에서 다룹니다.


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)"]
  • GroupairCancelService.expectedCancelCancelService.kt:13
  • GroupairClient.expectedCancelGroupairClient.kt:243
  • PassengerCancelResponse.toRefundCancelResponse.kt:19

6.2 취소 실행 — cancel

flowchart TD
    A["PUT internals GROUPAIR bookings pnr cancel (CancelRequest)"] --> B["GroupairCancelService.cancel"]
    B --> C["① booking = groupairClient.getReservation(...)<br/>② groupairClient.cancel(key, passengerIdentificationKeys)"]
    C --> D["GroupairClient.cancel<br/>DELETE bookingEndpoint reservations key<br/>쿼리 passengerSequences = key1 key2 ..."]
    D --> E{"checkError 분기"}
    E -->|"NON_CANCELABLE_RESERVATION"| E1["CANCEL_UNABLE"]
    E -->|"ALREADY_CANCELED_RESERVATION"| E2["ALREADY_CANCELED_PNR"]
    E -->|"else"| E3["CANCEL_FAILED(code, message).capture()"]
    E -->|"정상"| F["GroupairResponse CancelResponse"]
    F --> G["CancelResponse.passengers.map(toRefund())"]
    G --> H["CancelView(voided = false, refunds.filter(refundFee gt 0).map RefundView.of)"]
  • GroupairCancelService.cancelCancelService.kt:21
  • GroupairClient.cancelGroupairClient.kt:274

부분취소(승객 단위) 구조. 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 참고.

취소는 getReservationcancel(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)"]
  • GroupairCancelService.cancelableCancelService.kt:29
  • 발권 여부 판정식은 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:31
return 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)"]
  • GroupairBookingController.changeApisBookingController.kt:110
  • GroupairPassengerService.changeApisPassengerService.kt:11
  • GroupairClient.changeApisGroupairClient.kt:309
  • PassengerResponse.toPassengerReservationDetailResponse.kt:160

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 류 메서드는 존재하지 않습니다. 그룹/단체 운임 특성상 발권 후 변경은 콘솔리데이터 오프라인 처리로 넘어가기 때문으로 보입니다.

Group Air 가 지원하는 “변경”은 단 두 가지입니다:

  1. 취소→환불(발권 후, 수수료 계산 동반) — 6장
  2. APIS 변경(여권/체류정보 보정, 운임 영향 없음) — 7장

따라서 다른 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 발권은 왜 한 번의 호출이 아닌가? 어떤 위험이 있나?

검색에서 외부 호출이 갑자기 폭증하는 이유는?

cancelable이 거의 항상 REFUND를 반환할 수 있는 이유는?


관련 노트