Group Air 는 11개 공급사 중 가장 작은 모듈(파일 약 32개, 외부 인증/세션 없음, REST 단순 위임)이지만, 작다고 안전한 것은 아니다. 오히려 검증·널 안전성·계산 로직이 다른 GDS 모듈보다 얇게 구현되어 있어 런타임 NPE/오매핑 함정이 곳곳에 숨어 있다. 운영(groupair-operations)·프로토콜(groupair-protocol) 노트를 읽고 이 노트로 “지뢰밭”을 확인하라.
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
특수 처리
Search
search (line 73~94)
SEARCH_FAILED
성공 fold 내부 에러 + 통신 실패(failure) 모두 매핑
FareRule
findFareRules (line 115~139)
FETCH_FARE_RULES_FAILED
노선 요약 문자열을 objs 로 첨부
Booking
createReservation (line 155~165)
BOOKING_FAILED
failure 시 it.exception 그대로 재던짐(랩핑 없음)
Retrieve
getReservation (line 177~188)
RETRIEVE_FAILED
failure 시 it.exception 그대로 재던짐
Update(예약수정)
updateReservation (line 201~217)
SAVE_FAILED
Ticketing
ticketing (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) 만 유일하게 공급사 에러코드를 분기한다.
공급사가 코드 문자열을 바꾸면(NON_CANCELABLE_RESERVATION → NOT_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/getReservation 은 throw 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 등).
checkError 는 code != "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) 은 모두:
Passenger.identificationKey 는 String? = 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.
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)도 사용하지 않는다(ClientSupport 의 authenticate/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/pmap 은 MDCContext() 로 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 의 만료 함정.
키 파싱이 문자열 기반이다: getFareItinerary 는 hashKey.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·@RateLimiter 는 groupair 모듈에 전무하다(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% 초과 시 OPENinstances: groupairSearch: baseConfig: search
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.search 는 pmap 으로 출발/도착 조합(cartesianProduct)을 병렬 호출한다. onFailure 콜백(line 59~62)은:
함정: 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.
응답의 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.toLocalTime 은 padStart(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 파라미터는 부분취소를 지원할 수 있으나 여기선 항상 전체.)
이미 취소된 예약을 조회했을 때의 처리가 미구현으로 명시되어 있다. ReservationDetailResponse 에는 canceledAt, deleted, status 필드가 있지만(line 35,40,42) toBooking 에서 취소 상태를 전혀 검사하지 않는다. 취소된 예약도 정상 Booking 으로 변환되어 voidable=true 로 내려간다 → 이미 취소된 건에 재취소를 시도하면 §1-2 의 ALREADY_CANCELED_RESERVATION 분기까지 가야 비로소 막힌다(그 전까지 정상으로 오인).
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 이 다시 실행된다(멱등성 보장 없음).
지뢰 11 — agentReservationNumber/eventId 가 누락되거나 null 일 수 있다
예약 생성 ReservationCreateRequest.of (request/ReservationCreateRequest.kt:27~31)은 agentReservationNumber 를 세팅하지 않는다(디폴트 null). 즉 예약 시점엔 주문번호가 공급사에 전달되지 않는다.
주문번호는 발권 단계의 updateReservation(GroupairClient.kt:191~196)에서 비로소 MDC 로부터 채워진다:
orderNumber 가 null 이어도 그대로 PUT 한다. MDC 에 주문번호가 없는 호출(테스트·재처리 등)에서는 agentReservationNumber/eventId 가 null 로 저장되어 공급사 측 예약-주문 매칭이 깨질 수 있다. 두 필드에 동일한 orderNumber 를 중복 세팅하는 점도 의도 확인 필요.
알 수 없는 승객 타입 문자열은 무조건 ADULT 로 강등된다. PassengerCancelResponse.toRefund(response/CancelResponse.kt:22)가 이 매핑을 쓰므로, 공급사가 예상 밖 타입 코드를 주면 소아/유아 환불이 성인으로 오분류된다.
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) when 이 non-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. 한눈에 보는 지뢰 우선순위 표
#
지뢰
심각도
위치
핵심 위험
1
response.data!! 강제 언랩
high
GroupairClient 전반
OK+빈body → 순수 NPE, 매핑/경보 없음
3
tickets.isEmpty() VOID 판정 버그
high
GroupairCancelService:15,31
항상 false → 미발권도 환불 흐름
6
carrierTimeLimit=now()+1h 하드코딩
high
ReservationDetailResponse:59
실제 발권시한 무시·갱신 오판
7
tax += fuelCharge
high
toFare/toPassengerFare
하류 재합산 시 유류료 이중계상
8
issue() 비원자 2-step
high
GroupairTicketingService
중간상태 잔존, 멱등성 없음
4
identificationKey!! / .toLong()
high
CancelService·ApisUpdate
키 null → NPE/NumberFormat
5
취소 문자열 에러코드 분기
med
GroupairClient:287
코드 변경 시 오탐 경보
9
fares.first()
med
toPassenger:183
빈 운임 → NoSuchElement
15
엔드포인트 MDC 의존
med
Properties:494
코루틴 MDC 전파 깨지면 오라우팅
16
서킷 폴백=빈리스트200
med
SearchController:69
장애가 “결과없음”으로 위장
2
FareRule 한글 contains 분류
med
FareRuleResponse:15
제목 변경 시 오분류
13/14
미지 enum → ADULT/WEIGHT_KG fallback
med
PassengerType/BaggageUnit
조용한 오매핑(LB→KG)
10/11
orderNumber/eventId null PUT
low
GroupairClient:192
주문 매칭 누락
12
bookingClass?:“Y”/status”HK”
low
toSchedule
좌석등급/상태 오표기
—
BigDecimal.toLong() 절단
low
toFare/toTicket
소수통화 손실(KRW 가정)
캐시
INVALID_CACHE_KEY / Gzip 스키마
med
Repository·RedisConfig
TTL만료·DTO변경 시 예약 실패
12. 신입을 위한 디버깅 체크리스트
"Group Air 예약이 실패해요" 들어왔을 때
로그에 ErrorMessage 가 있나? 없으면 §2 의 !! NPE / §9 의 fallback 의심(매핑 없이 글로벌 핸들러로 감).
INVALID_CACHE_KEY? → §3 검색 캐시 TTL 만료. 검색→예약 사이 시간 확인.
NOT_SUPPORTED_SALES_CHANNEL/FUNNEL? → §3 MDC 채널/퍼널 누락. 코루틴 컨텍스트 전파 확인.
취소가 자꾸 환불로? → §7-1 의 tickets.isEmpty() 항상 false 버그.
금액이 안 맞아요 → §5 의 tax += fuelCharge 이중계상 / BigDecimal.toLong() 절단.
발권은 됐는데 예약갱신이 꼬임 → §8 의 비원자 2-step issue().
검색 결과가 비었는데 에러가 없음 → §4 서킷 OPEN 폴백(Datadog 스팬 supplier.circuit-breaker=OPEN 확인).