Galileo 모듈의 지뢰는 대부분 **“문자열을 의미로 신뢰하는 코드”**에서 나온다. 운임 파싱(substring(3).toLong()), SOAP 에러 메시지 부분문자열 매칭(contains("has already been cancelled")), 항공사 코드 화이트리스트(when(validatingCarrier)), 자정 경계 voidable 판정(isEqual(today()))이 4대 폭발 지점이다. 게다가 SOAP는 stateless라서 pricing → deleteElements → retrieve → pricing → addPriceInfo → retrieve처럼 버전을 재조회로 따라잡는 다단계 시퀀스가 곳곳에 있고, 중간 단계 실패 시 PNR이 불완전 상태로 남는다.
천단위 구분자/공백이 없다 — "KRW 330,000" 같은 변형은 전부 깨진다.
근거: infrastructure/soap/response/universalrecordretrieve/PricingInfo.kt:122-130, infrastructure/soap/response/airretrievedocument/ElectronicTicketingRecord.kt:103,127,137,155, emdretrieve/ElectronicMiscellaneousDocumentInfo.kt:44-65 전부 이 함수에 의존.
EquivalentBasePrice(현지통화→정산통화 환산액)가 있으면 그걸, 없으면 BasePrice(발권통화 원본)를 쓴다. 두 필드의 통화가 다를 수 있는데 코드는 통화를 비교하지 않고 둘 다 substring(3)으로 숫자만 떼낸다. 환산이 누락된 응답에서 운임이 잘못된 통화 기준으로 들어가도 검증 없이 통과한다.
getTicketDocuments(GalileoClient.kt:560-568)는 동일 승객의 티켓을 ticketNumber로 정렬한 뒤 직전 티켓(index-1)을 originTicket으로 삼아 차액을 계산한다(재발행 차액 표현). 함정:
재발행 차액 계산의 전제
티켓번호 정렬 순서 = 발권 시간 순서라고 가정한다(sortedBy { it.tickets.first().ticketNumber }). 항공사가 번호를 비순차 배정하거나 conjunction이 섞이면 origin이 엉뚱한 티켓이 된다.
차액이 음수가 될 수 있다(airPrice - originAirPrice, tax - originTax). 하류에서 음수 금액을 거르지 않으면 정산 오류. 통화가 다르면 음수가 무의미.
originTicket?.tax가 null이면 0 처리 → 신규 발권을 재발행 차액으로 오인할 여지.
1.4 카드/현금 분할 — UA형 합산 결제 폴백의 coerceAtMost
// ElectronicTicketingRecord.kt:130-140val cardPrice = if (passengerPrice != null) { passengerPrice.cardPrice} else { // 전액 카드 발권 시 동일 유형의 탑승객 숫자만큼 payment의 총금액이 들어올수도 있다. ex) UA 항공 val cardFormOfPaymentKey = formOfPayments?.find { it.isCard }?.key val cardPayment = payments?.find { it.formOfPaymentReference == cardFormOfPaymentKey } cardPayment?.amount?.removeCurrencyCode()?.coerceAtMost(totalPrice) ?: 0}val cashPrice = totalPrice - cardPrice
UA형 합산 카드결제
주석대로 UA 등 일부 항공사는 승객 수만큼 합산된 카드 결제 총액을 각 티켓 레코드에 동일하게 실어 보낸다. 이를 그대로 쓰면 1인 카드금액이 N배가 된다. 코드는 coerceAtMost(totalPrice)로 1인 totalPrice를 상한으로 깎아 방어하지만, 이는 “카드금액 ≤ 1인 총액”이라는 가정일 뿐 정확한 1/N 분배가 아니다. 부분 현금/카드 혼합 결제에서 cashPrice = total - card가 왜곡될 수 있다.
Commission.value는 Double이다(support/model/Commission.kt). CommissionType.NET은 정액(Flat), GROSS는 정률(PercentBase)인데(support/enums/CommissionType.kt), convertCommission은 type을 무시하고 value만 비교한다. 정액 5000(원)과 정률 5(%)를 5000.0 < 5.0처럼 비교하면 의미가 깨진다. 또 Double 비교라 부동소수 오차에 노출된다.
2. 상태·세션·버전 함정 (SOAP는 stateless라서 더 위험)
2.1 Universal Record version — 매 수정마다 증가, 재조회로 따라잡아야 함
Galileo SOAP의 모든 수정 요청(UniversalRecordModifyRQ, UniversalRecordCancelRQ)은 현재 version 번호를 함께 보내야 한다. version은 toBooking에서 universalRecord.version ?: 0으로 읽는다(UniversalRecordRetrieveRS.kt:68).
// GalileoTicketingService.kt:74-82 (ready) 와 GalileoBookingService.repricing:169-184galileoClient.deleteElements(booking, AIR_PRICING, deleteKeys = ...)// element 삭제 후 api version 갱신을 위해 retrievegalileoClient.getBooking(pnr).run { val pricingFare = galileoClient.pricing(booking = this, ...) galileoClient.addPriceInfo(booking = this, pricingFare = pricingFare)}galileoClient.getBooking(pnr) // 다시 retrieve
version 불일치 = 수정 실패
deleteElements → 재조회 없이addPriceInfo를 호출하면 stale version으로 SOAP fault. 그래서 코드는 매 수정 사이에 getBooking(retrieve)을 끼워 version을 새로 읽는다. 이 패턴을 모르고 단계를 합치거나 캐시하면 곧바로 깨진다. version ?: 0 폴백은 version 누락 응답에서 0을 보내 더 큰 불일치를 만들 수 있다.
deleteElements로 기존 Air_Pricing을 먼저 지운 뒤pricing/addPriceInfo가 실패하면, PNR에는 운임이 사라진 채로 남는다. 주석도 이 위험을 인정한다: // pricing을 삭제한 retrieve에는 fareBasis가 없으므로 비교가 불가능하여 원본으로 비교(GalileoTicketingService.kt:85). 비교 기준을 삭제 전 원본 booking으로 잡아 두는 이유가 이것. validateFareBasisChange에서 fareBasis가 바뀌면 pnrCancelAsync 후 SOLD_OUT을 던진다(:282-301, :214-233).
저장은 setIfAbsent다. 동시에 두 스레드가 캐시 miss → 둘 다 새 토큰 발급 → 먼저 쓴 토큰만 캐시 적중, 나머지 토큰은 캐시에 없지만 유효한 채 떠돈다(낭비이지 버그는 아님). 그러나 만료된 키를 갱신하려는 시점에 다른 인스턴스가 이미 setIfAbsent로 점유했다면 이번에 발급한 새 토큰이 캐시에 반영되지 않는다.
TTL은 expiresIn - 600(10분 여유). REST 검색 자체 타임아웃은 30초(searchTimeout = 30000)지만 토큰 만료와 검색 시작 사이 경계에서 401이 날 수 있다(401에 대한 자동 재발급 로직 없음 — 실패하면 GET_TOKEN_FAILED/SEARCH_FAILED로 끝).
토큰 키는 GALILEO_REST_TOKEN::$pcc라 PCC 단위 공유. 채널/퍼널이 같은 PCC를 쓰면 토큰을 공유한다.
예약 직후 carrierTimeLimit(Vendor remark에서 파싱)이 아직 안 들어오면 3초 sleep 후 단 1회 재조회한다. 두 번째도 null이면 그대로 진행한다(추가 검증 없음). carrierTimeLimit이 끝까지 null이면 발권 시 validateBookingConditionForTicketing의 시간제한 검사가 무력화된다(아래 4.3).
3. 예외·에러코드 매핑 함정 (메시지 부분문자열에 의존)
3.1 에러 분류를 SOAP faultString 부분문자열로 결정
GalileoClient는 공급사 에러를 ErrorMessage로 매핑할 때 **에러 코드가 아니라 메시지 텍스트의 contains**로 분기한다.
메시지 부분문자열
매핑 ErrorMessage
근거 (file:line)
"are not bookable", "NOT AVAIL CHECK AVAILABILITY"
SOLD_OUT (StatusInvalid)
GalileoClient.kt:93-96,120-122,179-181
"CHECK CONNECTION"
MINIMUM_CONNECTION_TIME
:124-126
"has already been cancelled"
ALREADY_CANCELED_PNR
:327-335, :501-507
"No EMD list data found" (이 문자열이 없으면 에러로 던짐)
GET_TICKET_DOCUMENT_FAILED
:628-635
Pair("9000","NO OFFERS FOUND FOR THE CHANNEL") (검색, 코드+메시지 정확 일치)
무시(빈 결과)
GalileoRestClient.kt:119
영문 메시지가 바뀌면 분류가 통째로 무너진다
Travelport가 에러 문구를 한 글자라도 바꾸거나 다국어로 내려주면, SOLD_OUT이 일반 PRICING_FAILED로, “이미 취소됨”이 CANCEL_FAILED로 잘못 분류된다. 특히 SOLD_OUT 오분류는 매진 운임 숨김 로직(UnexposedFareItinerary 저장, isUnexposedFareItinerary)을 무력화한다. 에러 코드(code)는 메시지와 함께 받아두지만 분기 판정에는 거의 안 쓰고 로그/메시지 조합에만 쓴다.
pricing(booking,...) 오버로드는 CHECK CONNECTION 미처리
pricing(fareItinerary,...)(:117-156)는 SOLD_OUT과 MINIMUM_CONNECTION_TIME 둘 다 처리하지만, 재가격용 pricing(booking,...)(:176-205)는 SOLD_OUT만 처리한다. 재가격 중 최소연결시간 위반은 일반 PRICING_FAILED로 떨어진다 → isUnexposedFareItinerary가 못 잡는다.
3.2 9000 NO OFFERS — 코드+메시지 둘 다 정확히 일치해야 무시
// GalileoRestClient.kt:119-126if (errors.contains(Pair("9000", "NO OFFERS FOUND FOR THE CHANNEL")).not()) throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, ...)
검색에서 “결과 없음”은 정상(빈 리스트)으로 처리하려는 의도지만, 판정이 Pair("9000", "NO OFFERS FOUND FOR THE CHANNEL")완전 일치다. 메시지 대소문자/공백/문구가 다르면 SEARCH_FAILED 예외 → 검색 컨트롤러의 서킷브레이커가 이를 실패로 집계한다(4.x 참조). 코드 주석에 9000=Search errors 분류가 적혀 있다(:115-118).
3.3 KPS 결제 에러코드 → ErrorMessage 매핑 (Set 기반)
// infrastructure/kps/KpsPaymentError.ktprivate val errors = hashSetOf( hashSetOf("2","3","8326","8373") to ErrorMessage.PAYMENT_MAXIMUM_EXCEEDED, hashSetOf("8327") to ErrorMessage.PAYMENT_DAILY_LIMIT_EXCEEDED, hashSetOf("4") to ErrorMessage.PAYMENT_INSTALLMENT_NOT_ALLOWED, hashSetOf("5") to ErrorMessage.PAYMENT_INVALID_CARD_INFORMATION,)fun getErrorMessage(message: String): ErrorMessage = errors.find { (codes,_) -> codes.contains(message) }?.second ?: ErrorMessage.PAYMENT_ETC
결제 에러코드 화이트리스트
여기 나열되지 않은 모든 코드는 PAYMENT_ETC로 뭉뚱그려진다. KPS가 새 거절 코드를 추가해도 사용자에게는 일반 결제오류로만 보인다. 인자로 받는 변수명이 message지만 실제로는 errorCode가 넘어온다(KpsPaymentClient.kt:70 → getErrorMessage(errorCode)). 단, KPS 카드 승인 거절은 MethodArgumentInvalidException(4xx류)으로 던져진다(:69-75) — 다른 SOAP 실패의 InternationalAdapterException과 예외 타입이 다르니 상위 핸들러 주의.
deleteElements는 SOAP fault일 때만 예외를 던지고, body 레벨 ResponseMessage 에러는 로그만 남기고 통과한다. repricing/ready 시퀀스(2.2)에서 운임 삭제가 body 에러로 실패하면, 후속 pricing이 잘못된 상태에서 진행될 수 있다. 반면 addPaymentInfoRemark(:710-718)는 의도적으로 fail-soft(에러 로그만) — remark 실패가 발권을 막지 않게.
4. 인코딩·날짜·시간대 함정
4.1 PNR 텍스트 인코딩 — 항공사 코드 화이트리스트 누락 시 잘못된 전송
PnrUtils(support/util/PnrUtils.kt)는 이메일/모바일/여권을 항공사 코드별로 다르게 인코딩한다.
makeSsrMobileText/else분기 makeOsiMobileText는 substring(1)로 앞 1자리(보통 0)를 떼고 82를 붙인다. 입력이 010-...이 아니거나 빈 문자열이면 StringIndexOutOfBounds 또는 잘못된 번호. +82로 시작하는 국제표기, 010이 아닌 번호에 대한 방어 없음.
ssrEmailSuffix/osiEmailPrefix/osiMobilePrefix는 수십 개 항공사 코드를 일일이 나열한다. 새 항공사가 동일 규칙을 요구하면 코드 수정 없이는 누락 → 항공사가 연락처 SSR/OSI를 거부해 발권 실패. 누락 항공사는 조용히 null/빈 접미사로 처리된다.
makeEmailText의 치환은 역변환 불가능(//,..,./로 다대일 매핑). PNR에 박힌 이메일을 다시 파싱할 수 없다.
FOID_PASSPORT_CARRIERS.contains(marketingCarrier)는 CSV 문자열의 부분문자열 검사다. 2글자 정상 항공사 코드는 괜찮지만, marketingCarrier가 "C" 같은 1글자거나 "CA,..."의 조각이면 의도치 않게 매치된다. 또 여권이 없는 승객(makePassportText는 Passport.ofFake()로 가짜 여권 생성, PnrUtils.kt:38)에 대해 가짜 여권번호가 DOCS에 들어갈 수 있어 checkPnr의 여권검증(UniversalRecordRetrieveRS.checkDocs)이 fake 여권을 별도 처리한다(:183 !it.isFakePassport()).
void(발권 당일 취소)는 발권일 == 오늘(KST) 일 때만 허용한다(today()=LocalDate.now("Asia/Seoul"), DateExtensions.kt:9). issuedDate가 KST로 변환됐다는 전제와 today()가 KST라는 전제가 맞물려야 한다. UTC 자정KST 자정 사이(한국 오전 09시 부근)에 어제 발권 건이 들어오면, 또는 BSP 정산 시각 기준이 다른 항공사에서, void가 막히고 환불 경로로 빠질 수 있다. 시각이 아니라 날짜 동일성만 보므로 항공사별 void 마감시각(보통 23:59 발권국 기준)과 어긋날 수 있다.
4.4 toCarrierTimeLimit 정규식 + +1년 보정
// UniversalRecordRetrieveRS.kt:153-171val regex = Regex(""".*BY\s+(\d{1,2}[A-Za-z]{3})\s+(\d{4})\s+([A-Za-z]+)\s+TIME ZONE.*""")val match = regex.matchEntire(this) ?: return nullval zoneId = findTimeZoneId(zone) ?: return null // zone 문자열로 timezone 조회 실패 시 null... if (localDateTime.isBefore(now)) localDateTime.plusYears(1) else localDateTime
carrierTimeLimit 파싱 함정
Vendor remark의 영문 패턴(...BY 25DEC 1430 LOCAL TIME ZONE...)에 정규식이 정확히 맞아야 시한이 잡힌다. 문구가 다르면 null → 시간제한 검사 무력화(2.4와 연결).
연도가 remark에 없어 now.year를 붙인 뒤(:161), 과거가 되면 +1년. 연말연시 경계에서 오판 여지.
zone 문자열(예: KOREAN)을 findTimeZoneId로 ZoneId로 바꾸는데, 이는 cityClient.getAirportByIata/getCityByIata 기반이라(GalileoBookingService.kt:39-48) zone명이 IATA가 아니면 실패해 null.
5. 재발행·환불·부분취소·분리(Divide) 엣지케이스
5.1 취소 분기 — 발권 전(PNR cancel) vs 발권 후(Void) vs 환불
// GalileoCancelService.cancel:29-60if (booking.tickets.isNullOrEmpty()) { // 발권 전: 출발 임박/노쇼면 취소 불가, 아니면 pnrCancelRepeat if (isPnrCreatedAtBeforeYesterdayOrNoShow(pnrCreatedAt, departureAt)) throw InternationalAdapterException(CANCEL_UNABLE, pnr, "Ticket is Empty").capture() pnrCancelRepeat(pnr)} else { if (isVoidable(booking).not()) throw InternationalAdapterException(CANCEL_UNABLE, pnr) voidAll(booking) if (payment != null) paymentCancelAsync(...) // 결제취소는 비동기 pnrCancelAsync(pnr) // PNR 취소도 비동기}
void 후 결제취소·PNR취소가 비동기 → 정합성 창
발권 후 취소는 voidAll(동기, 재시도 3회) 성공 후paymentCancelAsync/pnrCancelAsync를 별도 코루틴으로 던지고 메서드는 즉시 반환한다(CoroutineScope(Dispatchers.IO).withLaunch{ delay(5000); ... }, :104-109,209-231). 결제취소가 실패하면 Slack 경보 후 PAYMENT_CANCEL_FAILED를 던지지만 이미 호출자에게는 성공 반환된 뒤다. void는 됐는데 결제취소가 실패하면 환불 누락이 생길 수 있다(Slack 수동 대응 의존). 코루틴 예외는 async-coroutines/AdapterCoroutineExceptionHandler 참조.
① NonVoidableAirline 화이트리스트(공통 enum)에 든 항공사, ② EMD 티켓 보유, ③ PNR의 tickets 수 ≠ 실제 발권문서 수, ④ 발권일이 오늘이 아니거나 상태가 ISSUE/AIRPORT_CONTROL 외 → 전부 void 불가. 특히 ③의 수 불일치는 EMD/conjunction로 흔히 발생할 수 있다. CHECKIN 상태는 bool 반환이 아니라 예외를 던져 흐름을 끊는다(voidAll 안에서도 별도 선검사 :126).
void는 이미 void된 티켓을 빼고(diff) 재시도하므로, 일부만 void되고 일부 실패하면 다음 시도에서 남은 것만 다시 시도한다(부분 성공 보존). 그러나 matchingTicketsFrom(:153-164)은 conjunction 티켓이 있으면 TicketType.TICKET이고 유효한 번호만 남긴다 — conjunction 처리 가정이 깨지면 일부 쿠폰이 void 누락될 수 있다. 3회 실패 시 Slack 경보 후 예외.
confirm/repricing/ready에서 validateBookingConditionForTicketing 등이 실패하면 즉시 비동기 PNR 취소를 건다. 발권 가능 조건 미충족(스케줄 미확정, 이미 티켓 존재, carrier PNR 없음, 시간제한 임박)이 곧 예약 파기로 이어진다. issue에서는 keepPnr=true면 취소를 건너뛴다(GalileoTicketingService.kt:103-105, 260). issue 본 발권 실패 시에는 void+결제취소+PNR취소를 묶은 cancelAsync(:235-264)가 돈다.
5.6 INFANT 발권 시점 매진 검사
// GalileoBookingService.confirm:118-131 + SsrInfo.ktif (ssrInfo.isInfantSoldOutAtTicketing()) // type==INFT && status !in {"KK","HK"} throw StatusInvalidException(INFANT_SOLD_OUT, pnr, "INFT status is not KK or HK : ${ssrInfo.fullText}")
INFANT SSR 상태가 KK/HK가 아니면(매진/대기) 발권 직전에 차단. ACCEPTABLE_STATUSES_AT_TICKETING = setOf("KK","HK")만 허용(SsrInfo.kt:13). 새 허용 상태가 생기면 누락.
Galileo SOAP 메서드 중 removePnrsInQueue만 Spring @Retryable(3회, 5초 간격)이다. pricing/book/ticketing/void 등은 Spring 재시도가 없다(취소·void는 서비스 레이어에서 수동 for(1..2)/for(1..3) 루프로 재시도). @Retryable이 self-invocation(같은 빈 내부 호출)이면 프록시를 안 타 무효이지만, 여기선 GalileoQueueService가 galileoClient.removePnrsInQueue를 외부 빈으로 호출하므로 적용된다(GalileoQueueService.kt:68).
6.3 수동 재시도 루프들 — 멱등성 가정
메서드
재시도
종료/경보
근거
pnrCancelRepeat
2회
ALREADY_CANCELED_PNR이면 성공처리, 2회 실패 시 sendCancelFail 후 throw
GalileoCancelService.kt:78-102
voidRepeat
3회 (void된 것 제외하고)
3회 실패 시 sendVoidFail 후 throw
:166-201
removePnrsInQueue
3회(@Retryable)
—
GalileoClient.kt:871
재시도 멱등성
pnrCancel/void는 같은 PNR/티켓에 반복 호출돼도 안전하다고 가정한다. 실제로 pnrCancel 재시도는 “이미 취소됨” 메시지를 성공으로 흡수하지만(:85-88), 그 외 일시 오류와 영구 오류를 구분하지 않고 무조건 재시도한다.
ticketing 호출이 타임아웃(OkHttpError.isTimeout: SocketTimeout/IOException/메시지에 “timeout”)이면 timeoutCallback으로 긴급 Slack 채널 경보(sendTicketingTimeoutEmergencyChannel)를 보내고 예외를 던진다. SOAP는 stateless이고 응답을 못 받았을 뿐 항공사 측에서는 발권됐을 수 있다. 자동 재발권을 하면 중복 발권 위험이라 사람이 확인하라고 긴급 경보만 띄운다. isTimeout 판정이 IOException 전체를 포함해 광범위함도 유의(ClientSupport.kt:206-210).
7. 코드 내 TODO·주석 경고 (원문 인용)
미완성/확인필요 표식
//TODO ERK/EWR 처리 프로세스 확인 필요!! — GalileoTicketingService.kt:54 (ready 진입부). EWR/ERK 공항 코드 관련 발권 프로세스가 미검증.
//TODO OSI 승객 연결 및 삭제 처리 확인 필요 — GalileoPassengerService.kt:26 (changeApis). APIS 변경 시 OSI 연락처 요소의 연결/삭제가 미검증.
// 오프라인 예약의 경우는 passenger.identityCode 가 없을수도 있다. / // 리프라이싱(운임삭제)시에 pricingInfo가 없을 수 있다. — UniversalRecordRetrieveRS.kt:54-55, 98-99. 그래서 (passenger.identityCode ?: pricingInfo?.passengerIdentityCode)!!로 폴백하지만 둘 다 없으면 !!로 NPE.
//message에 pnr 포함되어 있어 아규먼트 처리 안한다. — GalileoClient.kt:328, 502. ALREADY_CANCELED_PNR 메시지에 PNR이 박혀 있어 별도 인자화 생략.
//가는 편(firstOffers)도 하나가 배열로 들어오는 경우가 있음 — GalileoRestClient.kt:131. 단일 결과도 배열 래핑되는 XML/JSON 다형성 함정.
// pricing을 삭제한 retrieve에는 fareBasis가 없으므로 비교가 불가능하여 원본으로 비교 — GalileoTicketingService.kt:85.
// pricingInfo 하위의 탑승객 유형의 레퍼런스가 null 인 경우가 있기 때문에 탑승객 유형이 하나인 경우를 먼저 고려해서 사용 — ElectronicTicketingRecord.kt:111.
// 전액 카드 발권 시 동일 유형의 탑승객 숫자만큼 payment의 총금액이 들어올수도 있다. ex) UA 항공 — ElectronicTicketingRecord.kt:133.
Fare.kt 전반의 // KeyReference 하위 객체 필요 류 주석(support/model/Fare.kt:74-76,233-236,...) — 매핑이 단순 List으로 축소돼 하위 객체 구조 정보가 유실됨(필요 시 확장해야 함).
8. 광범위 non-null 단언(!!) NPE 후보 모음
Galileo 코드는 SOAP 응답의 옵셔널 필드에 !!를 광범위하게 쓴다. 응답 구조가 가정과 다르면 즉시 NPE.
findPricingSolution(AirPriceRS.kt:69,72,100,103)의 pricingSegments.first{...}, fareInfos!!.first{...}는 매칭 실패 시 NoSuchElementException을 던진다(예외 매핑 없이 raw 전파). 세그먼트/운임 참조키가 응답 간 어긋나면 가격 매칭이 깨진다. 두 findPricingSolution 오버로드의 차이: itinerary용은 fareBasis == 완전일치, schedule(재가격)용은 fareBasis.startsWith(...) 부분일치(:85 vs :116).