Group Air — 지뢰요소

module-groupair pattern-error-handling arch-resilience

이 노트의 위치

Group Air 는 11개 공급사 중 가장 작은 모듈(파일 약 32개, 외부 인증/세션 없음, REST 단순 위임)이지만, 작다고 안전한 것은 아니다. 오히려 검증·널 안전성·계산 로직이 다른 GDS 모듈보다 얇게 구현되어 있어 런타임 NPE/오매핑 함정이 곳곳에 숨어 있다. 운영(groupair-operations)·프로토콜(groupair-protocol) 노트를 읽고 이 노트로 “지뢰밭”을 확인하라.

관련: groupair-operations · groupair-protocol · error-handling · resilience-and-events · landmines


0. 지뢰 지도 (한눈에)

flowchart TD
  subgraph CLIENT["GroupairClient (infrastructure)"]
    SEARCH["search"]
    FARERULES["findFareRules"]
    CREATE["createReservation"]
    GET["getReservation"]
    UPDATE["updateReservation"]
    TICKET["ticketing"]
    EXPCANCEL["expectedCancel"]
    CANCEL["cancel"]
  end

  subgraph MAPPING["응답 매핑 (response/*)"]
    TOBOOKING["ReservationDetailResponse.toBooking"]
    TOFARE["FareResponse.toFare"]
    TOPASSENGER["PassengerResponse.toPassenger"]
    TOSCHEDULE["toSchedule"]
  end

  subgraph COMMON["공통 / 설정"]
    CHECKERROR["GroupairResponse.checkError"]
    BAGGAGE["getBaggageUnitBySupplier"]
    APIPROPS["getApiProperties (MDC)"]
    CIRCUIT["@CircuitBreaker(groupairSearch)"]
  end

  M1["지뢰 1 — response.data 강제 언랩"]
  M2["지뢰 2 — 한글 키워드 contains 분류"]
  M3["지뢰 3 — todo already canceled, data 언랩"]
  M4["지뢰 4 — identificationKey 강제 언랩"]
  M5["지뢰 5 — 문자열 에러코드 분기"]
  M6["지뢰 6 — carrierTimeLimit now 더하기 1h 하드코딩"]
  M7["지뢰 7 — tax 는 taxAmount 더하기 fuelCharge 중복합산 위험"]
  M8["지뢰 8 — issue 2-step 비원자성"]
  M9["지뢰 9 — fares.first 빈 리스트 NPE"]
  M10["지뢰 10 — agentReservationNumber 미전달"]
  M11["지뢰 11 — orderNumber null 허용 PUT"]
  M12["지뢰 12 — bookingClass 기본 Y, status HK 하드코딩"]
  M13["지뢰 13 — code 가 OK 아니면 문자열 비교"]
  M14["지뢰 14 — else TODO 또는 기본 WEIGHT_KG"]
  M15["지뢰 15 — 세일즈 채널 퍼널 MDC 의존"]
  M16["지뢰 16 — Search 에만 존재, 빈 리스트 폴백"]

  SEARCH --> M1
  FARERULES --> M2
  CREATE --> M1
  CREATE --> M10
  GET --> M3
  UPDATE --> M11
  TICKET --> M8
  EXPCANCEL --> M4
  CANCEL --> M5
  CANCEL --> M4

  TOBOOKING --> M6
  TOFARE --> M7
  TOPASSENGER --> M9
  TOSCHEDULE --> M12

  CHECKERROR --> M13
  BAGGAGE --> M14
  APIPROPS --> M15
  CIRCUIT --> M16

1. 공급사 고유 예외·에러코드와 ErrorMessage 매핑

1-1. 에러코드 → ErrorMessage 매핑표

Group Air API 응답은 GroupairResponse<T>(code, data, message) 래퍼 구조다(infrastructure/response/GroupairResponse.kt). 성공 판정은 code == HttpStatus.OK.name(“OK”) 문자열 일치이며, 그 외 코드는 모두 에러 콜백으로 흐른다.

오퍼레이션함수 (GroupairClient.kt)매핑되는 ErrorMessage특수 처리
Searchsearch (line 73~94)SEARCH_FAILED성공 fold 내부 에러 + 통신 실패(failure) 모두 매핑
FareRulefindFareRules (line 115~139)FETCH_FARE_RULES_FAILED노선 요약 문자열을 objs 로 첨부
BookingcreateReservation (line 155~165)BOOKING_FAILEDfailure 시 it.exception 그대로 재던짐(랩핑 없음)
RetrievegetReservation (line 177~188)RETRIEVE_FAILEDfailure 시 it.exception 그대로 재던짐
Update(예약수정)updateReservation (line 201~217)SAVE_FAILED
Ticketingticketing (line 224~240)TICKETING_FAILED
취소예상요금expectedCancel (line 254~271)CALCULATE_CANCEL_FEE_FAILED
취소cancel (line 285~306)분기 처리(아래)
APIS 변경changeApis (line 318~335)PASSENGER_CHANGE_FAILED

1-2. 취소(cancel)의 문자열 에러코드 분기 — 가장 위험한 매핑

지뢰 #5 — 취소 에러코드가 하드코딩된 문자열 비교

GroupairClient.cancel (line 287~297) 만 유일하게 공급사 에러코드를 분기한다.

response.checkError { code, message ->
    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()
    }
}
  • 공급사가 코드 문자열을 바꾸면(NON_CANCELABLE_RESERVATIONNOT_CANCELABLE 등) 즉시 else 로 빠져 일반 CANCEL_FAILED + Sentry capture 가 발생한다. 즉 “이미 취소된 예약”인데도 운영 알림이 울린다.
  • 이 두 문자열은 코드 어디에도 상수화되어 있지 않다. Constants.kt 에는 UNDEFINED_PNR = "UNKNOWN" 하나뿐이다. 공급사 명세를 코드로 검증할 방법이 없다.
  • 비교: expectedCancel/cancelable/getReservation다른 취소 경로는 이 분기가 전혀 없어 동일 상황에서 RETRIEVE_FAILED/CALCULATE_CANCEL_FEE_FAILED 로 떨어진다. 같은 “이미 취소” 상황이 진입 경로에 따라 다른 에러로 보고된다.

1-3. .capture() 의 의미 — Sentry/Slack 경보 발화 지점

capture()support/exception/Exceptions.kt:11 에서 capturable=true, notifiable=!silence 를 세팅한다. 이게 켜진 예외만 Sentry 캡처 + Slack 경보(application/SlackService.kt)로 전파된다(resilience-and-events 참조).

capture() 적용이 일관되지 않다

  • fold success 내부(공급사가 200 OK + 에러코드 응답): 거의 모두 .capture() 호출 → 경보 발생.
  • fold failure(통신 자체 실패/타임아웃): createReservation/getReservationthrow it.exception (capture 없음, 래핑 없음). 나머지는 InternationalAdapterException(..., it.exception) 으로 래핑하지만 .capture()없음 → 경보 안 울림.
  • 결과: 타임아웃 같은 인프라 실패는 조용히 묻히고, 비즈니스 에러코드만 시끄럽게 알린다. 신입은 “예약 실패했는데 왜 알림이 없지?”를 겪을 수 있다.

2. 강제 언랩(!!) 지뢰 — NPE 폭탄밭

Group Air 모듈의 가장 흔하고 위험한 패턴이다. 응답 데이터·식별키를 검증 없이 !! 로 언랩한다.

지뢰 #1 — response.data!! 강제 언랩 (전 오퍼레이션)

GroupairClient 의 모든 success fold 는 checkError 통과 후 response.data!! 를 호출한다(line 84, 160, 183, 263, 298, 327 등).

  • checkErrorcode != "OK" 일 때만 콜백을 호출한다. 즉 code == "OK" 이면서 data == null 인 응답이 오면 checkError 를 통과하고 data!! 에서 NullPointerException (KotlinNullPointerException)이 터진다.
  • 이 NPE 는 InternationalAdapterException 이 아니라 순수 NPE 라 ErrorMessage 매핑·capture 없이 글로벌 핸들러로 직행한다. 디버깅 시 ErrorMessage 로그가 없어 원인 추적이 어렵다.
  • 방어 코드가 없으므로 공급사가 “OK + 빈 body” 를 반환하는 엣지(예: 비동기 처리 수락 응답)에 취약하다.

지뢰 #4 — passenger.identificationKey!! 강제 언랩 (취소 경로)

GroupairCancelService.expectedCancel/cancel/cancelable (line 17, 25, 36) 은 모두:

passengerIdentificationKeys = booking.passengers.map { it.identificationKey!! }
  • Passenger.identificationKeyString? = null 디폴트 널 허용 필드(support/model/Passenger.kt:47). toPassenger() 매핑에서는 sequence.toString() 으로 채워지지만, getReservation 응답에 승객이 한 명이라도 키 없이 오면 NPE.
  • 같은 !!PassengerApisUpdateRequest.of (request/ReservationCreateRequest.kt:85)에도 있다: sequence = passenger.identificationKey!!.toLong(). APIS 변경 요청에서 식별키가 없으면 NPE + .toLong() 추가로 숫자 형식이 아니면 NumberFormatException.

지뢰 #9 — fares.first() 빈 리스트 NPE/예외

PassengerResponse.toPassenger (response/ReservationDetailResponse.kt:183):

commission = fares.first().commissionAmount?.let { ... }
  • fares 가 빈 리스트면 NoSuchElementException. 승객 운임 정보가 비어 있는 예약(취소 완료/오류 상태) 조회 시 위험. fares.firstOrNull() 이 아니라 first() 다.

신입 가이드 — !! 를 만나면 항상 물어라

“이 필드가 null 이면 무슨 일이 일어나는가?” Group Air 는 data!!, identificationKey!!, fares.first(), passengerFares.first() 처럼 널/빈컬렉션 가정이 코드 전체에 깔려 있다. 공급사 명세상 항상 채워진다는 보장이 있어도, 장애 응답·부분 응답에서 깨진다.


3. 상태/세션 함정 (세션·토큰 만료, 캐시 키)

Group Air 는 GDS 가 아니다 — 세션/토큰이 없다

Amadeus/Galileo 같은 stateful PNR 세션이나 OAuth 토큰 만료 함정은 없다. GroupairClient 는 매 호출마다 agentId(=agencyId) 쿼리 파라미터를 붙이는 무상태 REST 다. 인증 헤더(Authorization/Bearer)도 사용하지 않는다(ClientSupportauthenticate/bearer 미사용). 대신 MDC 기반 채널/퍼널 라우팅Redis 캐시 키가 상태 함정의 자리를 차지한다.

지뢰 #15 — 엔드포인트가 MDC(ThreadLocal) 에 의존한다

GroupairProperties.getApiProperties (configuration/Properties.kt:494)는 인자 디폴트로 MDCHolder.SalesChannel.get() / MDCHolder.SalesFunnel.get() 을 읽어 채널·퍼널별 엔드포인트/agencyId 를 고른다.

  • 채널이 등록 안 됐으면 NOT_SUPPORTED_SALES_CHANNEL, 퍼널이 없으면 NOT_SUPPORTED_SALES_FUNNEL 예외.
  • GroupairClient.search 가 코루틴 pmap 안에서 실행된다(GroupairFlightSearchService.search line 48). withBlocking/pmapMDCContext() 로 MDC 를 자식 코루틴에 전파하지만(support/util/CoroutineExtensions.kt:16,33), groupairApiProperties getter 가 코루틴 스레드에서 호출되므로 MDC 전파가 깨지면 엉뚱한 채널 엔드포인트를 쓰거나 NOT_SUPPORTED_SALES_CHANNEL 이 터진다. 비동기 컨텍스트와 ThreadLocal 의 고전적 함정.

지뢰 — FareItinerary 캐시 키 만료( INVALID_CACHE_KEY)

GroupairFareItineraryRepository.getFareItinerary (domain/repository/...:32)는 Redis 해시에서 못 찾으면 CacheKeyInvalidException(INVALID_CACHE_KEY). TTL 은 CacheSet.FARE_ITINERARY.ttl.

  • Booking(book)·Detail·FareRule(structured)이 모두 이 캐시 키에 의존한다. 검색 후 시간이 지나(TTL 만료) 예약하면 INVALID_CACHE_KEY 로 실패. GDS 의 “세션 만료”에 해당하는 Group Air 의 만료 함정.
  • 키 파싱이 문자열 기반이다: getFareItineraryhashKey.substringBeforeLast("::") 로 셋 키를 복원한다. itemKey = "$key::$id" 포맷(domain/model/GroupairFlightSearch.kt:38)에 의존하므로, id 자체에 :: 가 끼면 키 파싱이 어긋난다(id 는 toSha3() 해시라 현재는 안전하지만 포맷 가정에 묶여 있음).

검색 키 재사용 vs 예약 후 삭제 경합

GroupairBookingService.book (line 27~29)은 예약 성공 후 removeFlightSearchKey(fareItinerary.requestKey)fire-and-forget 코루틴(CoroutineScope(Dispatchers.IO).withLaunch)으로 호출한다.

  • 삭제가 비동기라 예약 직후 동일 requestKey 로 재검색하면 삭제 전 캐시가 히트할 수 있다(GroupairFlightSearchService.search line 40: findKey 가 살아 있으면 캐시 반환).
  • 또한 flightSearchKeyRepository.removeKey 만 지우고 fareItineraryRepository 의 해시 데이터는 안 지운다. 키-데이터 정합성이 깨진 채로 TTL 만료까지 잔존.

4. Resilience4j 동작·주의·폴백

Group Air 의 Resilience4j 적용은 Search 컨트롤러 하나뿐이다

전체 모듈에서 resilience 애너테이션은 GroupairSearchController.search@CircuitBreaker(name = "groupairSearch", fallbackMethod = "searchFallback") 단 1개다. @Retry·@Bulkhead·@RateLimitergroupair 모듈에 전무하다(grep 확인).

4-1. groupairSearch 서킷브레이커 설정 (application.yml)

groupairSearch 는 공통 search baseConfig 를 그대로 상속한다(application.yml:65):

configs:
  search:
    slidingWindowSize: 180
    slidingWindowType: TIME_BASED        # 180초 시간 윈도우
    permittedNumberOfCallsInHalfOpenState: 10
    minimumNumberOfCalls: 30
    waitDurationInOpenState: 120s        # OPEN 후 2분간 차단
    failureRateThreshold: 35             # 실패율 35% 초과 시 OPEN
instances:
  groupairSearch:
    baseConfig: search

4-2. 폴백 동작과 함정

GroupairSearchController.searchFallback (line 68~76):

private fun searchFallback(exception: CallNotPermittedException): ResponseEntity<List<FareItineraryView>> {
    // Datadog 스팬에 supplier.circuit-breaker = OPEN 태깅
    return ResponseEntity.ok(emptyList())   // 빈 리스트 + 200 OK
}

지뢰 #16 — 서킷 OPEN 시 "빈 검색결과 + 200 OK" 라 장애가 보이지 않는다

  • 폴백 파라미터 타입이 CallNotPermittedException 으로 한정되어 있다. 즉 서킷이 OPEN 되어 호출이 차단된 경우에만 폴백이 작동한다. search 실행 중 던져진 SEARCH_FAILED 같은 실제 예외는 이 폴백으로 잡히지 않고 그대로 전파된다(시그니처 불일치). 단, 그 예외들이 서킷의 실패 카운트는 올린다.
  • 폴백은 빈 리스트 + HTTP 200 을 반환한다 → Triple 예약 시스템 입장에서는 “Group Air 에 해당 노선이 없음”과 “Group Air 가 죽어서 서킷 OPEN” 이 구분되지 않는다. Datadog 스팬 태그(supplier.circuit-breaker=OPEN)로만 식별 가능.
  • Booking/Ticketing/Cancel/FareRule 컨트롤러에는 서킷브레이커가 없다. Search 만 보호되고 예약/발권 경로는 무방비 → 공급사 장애 시 예약·발권은 그대로 에러를 토한다.

검색 내부 실패의 부분 성공 처리

GroupairFlightSearchService.searchpmap 으로 출발/도착 조합(cartesianProduct)을 병렬 호출한다. onFailure 콜백(line 59~62)은:

.onFailure { exceptions, successes ->
    if (successes.isEmpty()) {
        throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first())
    }
}.getOrEmpty()
  • 하나라도 성공하면 실패한 조합은 조용히 버려진다(getOrEmpty). 멀티시티/복수 공항 검색에서 일부 노선 누락이 에러 없이 발생할 수 있다. 모두 실패해야만 SEARCH_FAILED.

5. 운임·통화·세금·수수료 계산 함정

지뢰 #7 — tax 에 fuelCharge 를 합산한다 (이중계상 위험)

검색·예약 양쪽에서 세금 필드에 유류할증료(fuelCharge)를 더해 넣는다.

  • 검색: GoodPassengerFareResponse.toPassengerFare (response/GoodSearchItemResponse.kt:144): tax = tax + fuelCharge, 그리고 fuelCharge = fuelCharge별도로도 보존.
  • 예약: FareResponse.toFare (response/ReservationDetailResponse.kt:208): tax = (taxAmount ?: ZERO).add(fuelCharge), fuelCharge = fuelCharge.toLong(), qCharge = 0.
  • 함정: tax 가 이미 fuelCharge 를 포함하므로, 하류에서 tax + fuelCharge 또는 airPrice + tax + fuelCharge 식으로 다시 더하면 유류할증료가 두 번 계산된다. Fare.total getter(support/model/Fare.kt:13)는 airPrice + tax 로만 정의되어 fuelCharge 를 또 더하지 않으니 일관되지만, 이 합산 규칙을 모르는 신입이 + fuelCharge 를 추가하면 즉시 금액 오류. qCharge 는 항상 0 으로 버린다.

지뢰 — BigDecimal → Long 절단 (소수점 손실)

응답 금액은 BigDecimal(salesNetAmount, taxAmount, fuelCharge, airPrice, penalty 등)인데 도메인 모델로 옮길 때 모두 .toLong() 한다(toFare, toTicket line 249~252, CancelResponse penalty 는 애초에 Long).

  • BigDecimal.toLong()반올림이 아니라 소수부 절단(truncation). 통화에 소수 단위가 있는 경우(Group Air 가 KRW 외 통화를 줄 수 있다면) 1원 미만이 버려진다. 통화 코드(currency)는 모델 어디에도 없어 KRW 정수 가정이 깔려 있다.

수수료(commission) 매핑

  • PassengerResponse.toPassenger (line 183): commission = fares.first().commissionAmount?.let { Commission(type = CommissionType.NET, value = it.toDouble()) }. 커미션 타입은 항상 NET 하드코딩. commissionAmount 가 null 이면 commission 자체가 null.
  • TicketResponse.toTicket (line 253~254): commissionType = CommissionType.NET, commissionValue = commission.toString() — 여기선 BigDecimal.toString() 이라 소수 보존되지만 타입은 String.
  • 환불 수수료: PassengerCancelResponse.toRefund (response/CancelResponse.kt:24)의 refundFee = penalty. penalty 는 “위약금” 인데 필드명이 refundFee(환불수수료) 다. 컨트롤러에서 refundFee > 0 인 승객만 노출하므로(GroupairBookingController line 67, 88), penalty 0 인 무료취소 승객은 응답에서 사라진다.

6. 인코딩 / 날짜·시간대 함정

지뢰 #6 — carrierTimeLimitnow().plusHours(1)하드코딩

ReservationDetailResponse.toBooking (response/ReservationDetailResponse.kt:59):

carrierTimeLimit = LocalDateTime.now().plusHours(1),
paymentTimeLimit = null,
  • 응답의 issueTimeLimit(LocalDate) 필드를 무시하고, 조회 시각 + 1시간을 발권 시한으로 만들어 버린다. 응답 DTO 에 issueTimeLimit 이 분명히 존재하는데(line 39) toBooking 에서 쓰이지 않는다.
  • 결과: 예약 조회를 다시 할 때마다 발권 시한이 “지금+1h” 로 갱신된다. 실제 공급사 시한과 무관하다. 발권 마감 임박 알림·자동 취소 로직이 이 값을 신뢰하면 실제보다 시한을 길게 오판할 수 있다.
  • paymentTimeLimit = null, voidable = true 도 무조건 하드코딩.

지뢰 — 시간대(타임존) 처리 비일관

  • pnrCreatedAt = reservationAt.toUTC() (line 61): reservationAt(LocalDateTime)을 toUTC()(support/util/DateExtensions.kt:36)로 변환. toUTC()입력을 무조건 Asia/Seoul 로 간주하여 UTC 로 변환한다. 공급사가 이미 UTC 나 현지시각으로 줬다면 9시간 어긋난다.
  • 반면 세그먼트 출발/도착 시각은 타임존 변환이 전혀 없다: toSchedule (line 105~106)은 departureDate.atTime(departureTime.toLocalTime("HHmm")) 로 LocalDateTime 을 그대로 쓴다. 즉 PNR 생성시각만 UTC 변환, 항공편 시각은 현지시각 그대로 → 한 Booking 안에서 시간대 기준이 섞여 있다.
  • toLocalTime("HHmm") (line 105): 시각 문자열 포맷이 "HHmm"(콜론 없는 4자리). DateExtensions.toLocalTimepadStart(4, '0') 로 좌측 0 패딩하므로 "905""0905" 보정은 되지만, "930"(=09:30 의도)인지 "0930"인지 모호. 분이 한 자리인 경우 깨질 수 있다.

검색 vs 예약 스케줄의 addDay 계산 차이

  • 검색(GoodSegmentResponse.toSegment line 124): addDay = ChronoUnit.DAYS.between(출발일, 도착일)계산.
  • 예약(ReservationItinerarySegmentResponse)에는 dateChangeDays, arrivalWeekday 등 익일도착 필드가 응답에 있지만(line 76,82), toSchedule 에서 사용하지 않는다. 동일 항공편이 검색/예약에서 다른 익일도착 표현을 가질 수 있다.

인코딩(암호화) 함정은 없다

tway 의 SEED 암호화(TwaySEED.jar) 같은 공급사 고유 인코딩은 Group Air 에 없다. 요청은 평문 JSON(ClientSupport.defaultRequestBodyConverter), URL 파라미터는 OkHttp 가 인코딩한다. 단, 검색 URL 이 경로 세그먼트에 코드를 직접 끼워 넣는다: .../goods/search/${originType}:${origin}-${destType}:${dest}/...(GroupairClient.search line 55). :- 를 구분자로 쓰므로 공항/도시 코드에 이 문자가 들어오면 경로가 깨진다(IATA 코드라 현재는 안전).


7. 재발행 / 환불 / 부분취소 엣지케이스

Group Air 는 재발행(reissue)을 지원하지 않는다

그룹/단체 운임 특성상 일정 변경·재발행 API 가 없다. RETICKETING_* ErrorMessage 도 groupair 코드에서 참조하지 않는다. 변경 가능한 것은 APIS(여권·체류정보) 변경뿐(changeApis). 운임/일정 변경 요청이 들어오면 어댑터 차원에서 처리할 경로 자체가 없다.

7-1. 취소 타입 판정 — tickets.isEmpty() 로직 버그

지뢰 #3 + 로직오류 — passengers.map { it.tickets }.isEmpty()항상 false

GroupairCancelService.expectedCancel(line 15)·cancelable(line 31) 가 VOID/REFUND 를 가르는 핵심 조건:

booking.passengers.map { it.tickets }.isEmpty()
  • 이 식은 승객별 발권 여부를 보려는 의도다. 하지만 map { it.tickets }List<List<Ticket>?> 를 만든다. 승객이 1명이라도 있으면 이 외부 리스트는 비어 있지 않으므로 .isEmpty() 는 항상 false 다(각 승객의 tickets 가 null/empty 여도 무관).
  • 의도는 passengers.all { it.tickets.isNullOrEmpty() } 또는 passengers.flatMap { it.tickets ?: emptyList() }.isEmpty() 였을 것이다.
  • 영향: expectedCancel 의 첫 번째 반환값(voidable 추정치)이 사실상 항상 false. cancelable 은 항상 REFUND 분기로 가서(line 33~38) expectedCancel API 를 부른다 → 미발권(HOLD) 예약인데도 VOID 가 아니라 환불 흐름으로 판정될 수 있다. 미발권 취소는 위약금 0 이어야 하는데 환불 계산 API 호출이 일어난다.
  • 더불어 컨트롤러 cancel(GroupairBookingController line 65~66)은 응답 voided = false무조건 하드코딩한다. VOID 취소 여부가 응답에 정확히 반영되지 않는다.

7-2. 부분취소(승객 단위)

취소는 승객 일부가 아니라 전체 승객 키를 항상 전달한다

GroupairCancelService.cancel(line 21~27)은 booking.passengers.map { it.identificationKey!! }조회된 모든 승객의 키를 전달한다. 컨트롤러 CancelRequest 에는 취소할 승객을 선택하는 필드가 사용되지 않는다. 즉 어댑터 레벨에서는 부분 승객 취소를 지원하지 않고 전건 취소다. (Group Air API passengerSequences 파라미터는 부분취소를 지원할 수 있으나 여기선 항상 전체.)

7-3. //todo already canceled

지뢰 #3 — 코드 내 미해결 TODO

GroupairClient.getReservation (infrastructure/GroupairClient.kt:182):

response.data!!.toBooking()
//todo already canceled
  • 이미 취소된 예약을 조회했을 때의 처리가 미구현으로 명시되어 있다. ReservationDetailResponse 에는 canceledAt, deleted, status 필드가 있지만(line 35,40,42) toBooking 에서 취소 상태를 전혀 검사하지 않는다. 취소된 예약도 정상 Booking 으로 변환되어 voidable=true 로 내려간다 → 이미 취소된 건에 재취소를 시도하면 §1-2 의 ALREADY_CANCELED_RESERVATION 분기까지 가야 비로소 막힌다(그 전까지 정상으로 오인).

8. 발권 / 예약 비원자성·요청 누락 함정

지뢰 #8 — issue() 는 비원자적 2-step

GroupairTicketingService.issue (application/GroupairTicketingService.kt:10~13):

fun issue(supplierIdentificationKey: String) {
    groupairClient.updateReservation(supplierIdentificationKey)  // PUT /reservations/{key}
    groupairClient.ticketing(supplierIdentificationKey)          // PUT .../channel-confirm
}
  • 두 번의 외부 PUT 호출이며 트랜잭션·롤백·재시도가 없다. 1단계(예약 갱신=SAVE_FAILED) 성공 후 2단계(발권확정=TICKETING_FAILED) 실패하면, 예약 메타는 갱신됐지만 발권은 안 된 중간 상태로 남는다. 호출자(Triple)가 발권 재시도를 하면 updateReservation 이 다시 실행된다(멱등성 보장 없음).

지뢰 11agentReservationNumber/eventId 가 누락되거나 null 일 수 있다

  • 예약 생성 ReservationCreateRequest.of (request/ReservationCreateRequest.kt:27~31)은 agentReservationNumber세팅하지 않는다(디폴트 null). 즉 예약 시점엔 주문번호가 공급사에 전달되지 않는다.
  • 주문번호는 발권 단계의 updateReservation(GroupairClient.kt:191~196)에서 비로소 MDC 로부터 채워진다:
    val orderNumber = MDCHolder.OrderNumber.getOrNull()?.takeIf { it.isNotBlank() }
    val reservationUpdateRequest = ReservationUpdateRequest(agentReservationNumber = orderNumber, eventId = orderNumber)
    orderNumbernull 이어도 그대로 PUT 한다. MDC 에 주문번호가 없는 호출(테스트·재처리 등)에서는 agentReservationNumber/eventId 가 null 로 저장되어 공급사 측 예약-주문 매칭이 깨질 수 있다. 두 필드에 동일한 orderNumber 를 중복 세팅하는 점도 의도 확인 필요.

9. 매핑·열거형 변환 함정 (조용한 오매핑)

지뢰 #14 — BaggageUnit 매핑이 ?: WEIGHT_KG 로 fallback + else TODO()

getBaggageUnitBySupplier (support/enums/BaggageUnit.kt:54~63):

Supplier.GROUPAIR -> entries.find { it.groupair == value }
else -> TODO()
} ?: WEIGHT_KG
  • Group Air 가 인식 못 할 unit 문자열을 주면 조용히 WEIGHT_KG(킬로그램)으로 둔갑한다. WEIGHT_LB(파운드)의 groupair 매핑은 ""(빈 문자열)이라 파운드 단위 자체를 표현할 수 없다 → 파운드 수하물이 kg 로 잘못 표기될 수 있다.
  • else -> TODO() 는 미지원 공급사로 호출 시 NotImplementedError 를 던지지만, Group Air 경로는 else 에 안 걸리므로 직접 위험은 아니다(다른 공급사 추가 시 함정).

지뢰 #13 — getPassengerTypeCodeBySupplier?: ADULT fallback

PassengerType.getPassengerTypeCodeBySupplier(value, GROUPAIR) (support/enums/PassengerType.kt:86): entries.find { value == it.groupair } ?: ADULT.

  • 알 수 없는 승객 타입 문자열은 무조건 ADULT 로 강등된다. PassengerCancelResponse.toRefund(response/CancelResponse.kt:22)가 이 매핑을 쓰므로, 공급사가 예상 밖 타입 코드를 주면 소아/유아 환불이 성인으로 오분류된다.

지뢰 #2 — FareRule 분류가 한글 키워드 contains() 기반

FareRuleResponse.toFareRule (response/FareRuleResponse.kt:15~21):

type = if (listOf("취소","환불","변경").any { name.contains(it) }) FareRuleType.REFUND_AND_CHANGE
       else if (listOf("수하물","수화물").any { name.contains(it) }) FareRuleType.BAGGAGE
       else if (name.contains("마일리지")) FareRuleType.MILEAGE
       else FareRuleType.COMMON
  • 운임 규정 분류를 규정 제목의 한글 부분 문자열 매칭으로 결정한다. 공급사가 영어 제목(“Cancellation”)을 주거나 표현을 바꾸면(예: “취소 수수료 없음” → REFUND_AND_CHANGE 로 잡힘) 분류가 어긋난다. groupSequence = 1 도 하드코딩.

지뢰 #12 — Schedule 기본값 하드코딩

ReservationItinerarySegmentResponse.toSchedule (response/ReservationDetailResponse.kt): bookingClass = bookingClass ?: "Y"(line 118), status = "HK"(line 120) 고정. 실제 세그먼트 상태(pnrStatus: HOLD/CONFIRMED)는 응답에 있으나(line 96) 무시되고 항상 “HK”(확약). 또 Gender 판정은 title(MR/MSTR→MALE, MS/MISS→FEMALE)에 의존하는데(line 168~171) whennon-exhaustive 하지 않도록 두 가지만 다룬다 — Title enum 에 다른 값이 추가되면 컴파일 에러(반대로 현재는 안전망).


10. 직렬화(Redis Gzip) 함정

FareItinerary 는 Gzip + Jackson 으로 Redis 저장 — 스키마 변경 = 역직렬화 실패

GroupairRedisConfiguration.groupairFareItineraryRedisTemplate (configuration/RedisConfiguration.kt)는 GzipRedisSerializer<FareItinerary>(Jackson2JsonRedisSerializer(...)) 로 직렬화한다.

  • FareItinerary/Schedule/Segment 등은 Serializable + serialVersionUID = 1L 이지만, 실제 직렬화는 Java Serializable 이 아니라 Jackson JSON + Gzip 이다. 두 방식이 섞여 있어 혼동 주의.
  • 함정: 배포로 FareItinerary DTO 필드를 바꾸면, 이전 버전이 캐시에 저장한 JSON 을 새 버전이 역직렬화할 때 깨질 수 있다(롤링 배포 중 검색 캐시 히트 → 예약 단계 역직렬화 실패). @JsonIgnore 가 붙은 파생 필드(id, itemKey, scheduleKey 등 line 25~46)는 저장 안 되고 역직렬화 시 재계산되므로, 계산 로직(toSha3())을 바꾸면 캐시된 데이터의 id 가 달라져 getFareItinerary 키 조회가 실패한다.

11. 한눈에 보는 지뢰 우선순위 표

#지뢰심각도위치핵심 위험
1response.data!! 강제 언랩highGroupairClient 전반OK+빈body → 순수 NPE, 매핑/경보 없음
3tickets.isEmpty() VOID 판정 버그highGroupairCancelService:15,31항상 false → 미발권도 환불 흐름
6carrierTimeLimit=now()+1h 하드코딩highReservationDetailResponse:59실제 발권시한 무시·갱신 오판
7tax += fuelChargehightoFare/toPassengerFare하류 재합산 시 유류료 이중계상
8issue() 비원자 2-stephighGroupairTicketingService중간상태 잔존, 멱등성 없음
4identificationKey!! / .toLong()highCancelService·ApisUpdate키 null → NPE/NumberFormat
5취소 문자열 에러코드 분기medGroupairClient:287코드 변경 시 오탐 경보
9fares.first()medtoPassenger:183빈 운임 → NoSuchElement
15엔드포인트 MDC 의존medProperties:494코루틴 MDC 전파 깨지면 오라우팅
16서킷 폴백=빈리스트200medSearchController:69장애가 “결과없음”으로 위장
2FareRule 한글 contains 분류medFareRuleResponse:15제목 변경 시 오분류
13/14미지 enum → ADULT/WEIGHT_KG fallbackmedPassengerType/BaggageUnit조용한 오매핑(LB→KG)
10/11orderNumber/eventId null PUTlowGroupairClient:192주문 매칭 누락
12bookingClass?:“Y”/status”HK”lowtoSchedule좌석등급/상태 오표기
BigDecimal.toLong() 절단lowtoFare/toTicket소수통화 손실(KRW 가정)
캐시INVALID_CACHE_KEY / Gzip 스키마medRepository·RedisConfigTTL만료·DTO변경 시 예약 실패

12. 신입을 위한 디버깅 체크리스트

"Group Air 예약이 실패해요" 들어왔을 때

  1. 로그에 ErrorMessage 가 있나? 없으면 §2 의 !! NPE / §9 의 fallback 의심(매핑 없이 글로벌 핸들러로 감).
  2. INVALID_CACHE_KEY? → §3 검색 캐시 TTL 만료. 검색→예약 사이 시간 확인.
  3. NOT_SUPPORTED_SALES_CHANNEL/FUNNEL? → §3 MDC 채널/퍼널 누락. 코루틴 컨텍스트 전파 확인.
  4. 취소가 자꾸 환불로? → §7-1 의 tickets.isEmpty() 항상 false 버그.
  5. 금액이 안 맞아요 → §5 의 tax += fuelCharge 이중계상 / BigDecimal.toLong() 절단.
  6. 발권은 됐는데 예약갱신이 꼬임 → §8 의 비원자 2-step issue().
  7. 검색 결과가 비었는데 에러가 없음 → §4 서킷 OPEN 폴백(Datadog 스팬 supplier.circuit-breaker=OPEN 확인).

더 깊이