SQ는 Amadeus Altea 기반 NDC EDIST 18.1 스키마를 SOAP로 호출한다. NDC지만 GDS(Amadeus)의 인프라를 쓰기 때문에 SOAP 헤더에 Amadeus 보안 토큰(AMA_SecurityHostedUser)이 들어가는 하이브리드 구조다. 이 특성이 여러 함정의 뿌리가 된다.
SQ 응답의 에러는 Errors > Error{Code, DescText} 구조(infrastructure/response/Error.kt)이며, 각 RS 타입의 checkError { code, message -> ... } 콜백에서 분기한다. 매핑 규칙이 에러코드가 아니라 사람이 읽는 영어 메시지 문자열을 == 또는 .contains() 로 비교하는 데 의존한다는 점이 핵심 지뢰다.
reissue message .contains("TICKET IS NOT ELIGIBLE FOR EXCHANGE")
throw + capture
TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE / 그 외 RETICKETING_FAILED
지뢰 1: 영어 메시지 문자열 매칭 — 공급사가 문구만 바꿔도 분기 붕괴
retrieve/cancel은 message == "RESERVATION PREVIOUSLY CANCELLED"완전 일치(SingaporeairClient.kt:307, 347)에 의존한다. SQ가 메시지를 "Reservation previously cancelled."처럼 대소문자/마침표만 바꿔도 ALREADY_CANCELED_PNR 분기를 타지 못하고 일반 RETRIEVE_FAILED/CANCEL_FAILED로 떨어진다. 그 결과 “이미 취소된 PNR”이라는 정상 케이스가 운영 알람을 울리는 오탐이 된다. NO_QUOTA, TICKET IS NOT ELIGIBLE 도 동일한 .contains() 의존.
지뢰 2: 에러코드 사전이 미정리 상태 (TODO 3곳)
savePayment(:400), saveAncillary(:697), reissue(:813) 세 곳 모두 동일 주석:
// TODO payment error code 정리 필요(ref: Code set Dictionary 18.1.pdf - 9321)
결제·발권 단계의 SQ 에러코드가 의미별로 분류되지 않았다. 현재는 MAXIMUM TICKET LIMIT REACHED 외 모든 결제 오류가 뭉뚱그려 TICKETING_FAILED로 처리되고 capture()(Slack 경보) 된다. 신규 결제 에러 유형이 와도 원인 분류 없이 알람만 발생한다.
지뢰 3: search code "710"/"367"을 "정상 빈 결과"로 흡수
SingaporeairClient.kt:98-101에서 710(NO FARE FOUND), 367(NO ACTIVE ITINERARY)은 예외를 던지지 않고 빈 결과로 처리한다. 의도된 동작이지만, 새 코드가 추가되어 사실은 “조회 가능한데 일시 오류”인 케이스도 그 외(else)로 가서 SEARCH_FAILED로 throw + capture 된다. 코드 사전 변화에 취약. search는 카테시안 곱 병렬(pmap)로 호출되므로 일부만 실패하면 onFailure에서 성공 1건이라도 있으면 통과(SingaporeairFlightSearchService.kt:72-76)한다 — 부분 실패가 조용히 묻힐 수 있다.
checkError 두 가지 시그니처 주의
OrderReshopRS.kt:29-42에는 checkError(code, message)와 checkError(errors: List<Error>)오버로드 2개가 있다. reissueSearch는 리스트 버전을 써서 모든 error의 descText를 합쳐 사용자 메시지로 노출(SingaporeairClient.kt:736-746)하고, repricingWithReissue는 _,_ -> 로 메시지를 통째로 버린다(:780-782). 같은 RS인데 호출부마다 에러 노출 정책이 다르다.
2. 상태·세션 함정
SQ NDC는 세션리스(stateless) 다. Amadeus GDS 모듈(amadeus-pitfalls)과 달리 stateful PNR 세션을 보유하지 않으며, 매 요청이 독립 SOAP 호출이다. 상태는 전적으로 PNR(Order)에 귀속된다. 하지만 그로 인한 다른 함정이 있다.
지뢰 4: book 직후 retrieve를 한 번 더 강제 호출
SingaporeairBookingService.book(:51-60)은 singaporeairClient.book(...) 으로 PNR을 받은 뒤 반드시 retrieve(pnr=booking.pnr!!) 를 한 번 더 호출한다. 주석(:57):
//OrderCreateRQ의 응답으로 생성된 탑승객 번호가 변경 될수 있으므로 한번더 retrieve 하도록 함.
OrderCreateRS의 탑승객 식별번호(PAX id)가 OrderViewRS(retrieve)에서 달라질 수 있다는 SQ 특성 때문이다. 즉 book 응답을 그대로 신뢰하면 안 된다. 1 booking = 최소 3회 순차 SOAP 호출(pricing → book → retrieve)이고, 어느 하나라도 타임아웃이면 부분 상태가 남는다. 이 구간에는 서킷브레이커가 없다(지뢰 7 참조).
지뢰 5: book 응답에서 탑승객 정보가 부분 누락 → request로 보충
SingaporeairClient.book(:280-281):
}.response!!.toBooking(passengers = passengers)// 예약 시 넣었던 탑승객 정보 중 일부가 response에 누락되기 때문에 찾아서 꽂아주자.(ex: stayInfo.state)
SQ가 응답에 stayInfo.state 등을 내려주지 않으므로 toPassenger(DataList.kt:451-)에서 원본 request 탑승객(requestPassenger)을 매칭해 채운다. 매칭 키는 firstName + lastName + title + type(DataList.kt:462). 동명이인 + 동일 title + 동일 타입이면 잘못된 사람의 체류지/연락처가 꽂힐 수 있다.
지뢰 6: 인증 토큰 만료 — Created+5분
SQ는 세션 토큰 대신 요청마다 WS-Security UsernameToken을 새로 만든다(SingaporeairClient.kt:857-882). PasswordDigest.getExpiredTime()(PasswordDigest.kt:57)은 UTC 기준 +5분 만료를 정의한다. 즉 생성한 SOAP 본문이 5분 안에 전송되지 못하면 SQ가 인증 거부한다. 본문 생성과 전송 사이에 큐잉/지연이 끼면(예: 대량 병렬 search의 백프레셔) 인증 실패가 산발적으로 발생할 수 있다.
3. @Retry / @CircuitBreaker 동작과 폴백
지뢰 7: 회복탄력성 어노테이션이 search 단 1곳뿐, @Retry는 전무
모듈 전체를 grep한 결과 resilience4j 어노테이션은 SingaporeairSearchController.kt:25 의 @CircuitBreaker(name = "singaporeSearch", ...) 하나뿐이다. @Retry / @Bulkhead / @RateLimiter 는 SQ 모듈 어디에도 없다.
book / issue(발권) / cancel / reissue / refundCalculate 컨트롤러에는 서킷브레이커가 없다. 이들은 외부 SOAP 장애가 나면 그대로 예외가 올라오고, 자동 차단/폴백이 없다.
설정: application.yml:59-60 → singaporeSearch.baseConfig: search. config search(:41-47)는 TIME_BASED 슬라이딩 윈도우 180초, 최소 30콜, OPEN 유지 120초, 실패율 임계 35%.
searchFallback(SingaporeairSearchController.kt:104-111)은 CallNotPermittedException(서킷 OPEN)일 때 빈 리스트를 200 OK로 반환하고 Datadog span에 supplier.circuit-breaker=OPEN 태그를 단다.
주의: 폴백은 CallNotPermittedException(서킷이 이미 OPEN)일 때만 탄다. 실제 SOAP 실패(타임아웃·SOAPFault)는 폴백을 타지 않고 예외가 그대로 올라가 실패율 카운터를 올린다.
주의: 빈 배열이 정상 200으로 나가므로 호출자(Triple 예약)는 “SQ에 좌석 없음”과 “SQ 서킷 차단됨”을 응답만으로 구분할 수 없다. Datadog 태그를 봐야 안다.
타임아웃은 ClientSupport에 박혀 있음
SingaporeairClient는 ClientSupport(searchTimeout = 15000, defaultTimeout = 60000)(:45-49)을 상속. search 15초, 그 외(book/issue/cancel 등) 60초. cancel 타임아웃 시 slackService.sendCancelFailTimeout(:364-368), issue(savePayment) 타임아웃 시 timeoutCallback→sendTicketingTimeout(TicketingService.kt:42-47). 이 Slack 경보가 사실상의 “이벤트 전파” 역할(resilience-and-events).
즉 SQ에 항상 KRW 정산을 요청하고, 응답의 CurCode(response/CurrencyAmount.kt)는 읽지만 검증하지 않는다. SQ가 SGD/USD로 응답하는 운임(특정 출발지/POS 조합)이 오면 숫자값을 그대로 KRW로 취급해 정산 사고가 난다. 통화 일치 검증 로직 없음.
지뢰 9: tax 음수 가능 — total/airPrice 계산이 깨질 수 있음
재발행 가격 파싱(OrderReshopRS.kt:143-146) 주석:
total = (offerItem.price.totalAmount?.value?.toLong() ?: 0) - penalty, // totalAmount = 추가금 + 패널티, total 은 airPrice + tax(단, tax가 마이너스 금액 아닌경우)
airPrice = total - tax(SingaporeairFlightSearch.kt:82-84)인데 tax가 음수면 airPrice가 부풀려진다. 재발행 상세에서 airPrice < 0 || tax < 0 이면 아예 재발행 차단(SingaporeairFlightSearchService.kt:146-175, REISSUE_NON_CHANGEABLE_FARE_SCHEDULE)으로 방어하지만, 이는 “감액 재발행 불가” 정책일 뿐 음수 tax의 모든 케이스를 막지는 않는다.
지뢰 10: 연료할증료(YQ)만 fuelCharge로 집계, 나머지 세금은 tax 덩어리
fuelCharge는 taxs.filter { it.taxCode == "YQ" } 합으로만 계산(DataList.kt:479-482, OrderReshopRS.kt:312-315). qCharge는 항상 0(여러 곳 qCharge = 0 하드코딩). YQ 외 유류/보험할증(예: YR)은 전부 tax에 묻힌다. 운임 분해 정합성을 따질 때 주의.
지뢰 11: total 추출 경로가 detail/simple 두 갈래 fallback
OrderReshopRS.kt:309-310:
total = this.price.totalAmount?.detailCurrencyPrice?.total?.value?.toLong() ?: this.price.totalAmount?.simpleCurrencyPrice?.value?.toLong() ?: 0,
응답 형태가 DetailCurrencyPrice 또는 SimpleCurrencyPrice 두 가지로 올 수 있고, 둘 다 없으면 조용히 0원이 된다. 0원 운임이 그대로 흘러가면 발권 가격 검증(지뢰 13)에서야 드러난다.
지뢰 12: 수하물 무게 환산 매직넘버 2.205
FreeBaggage.convertToMeasure()(SingaporeairFlightSearch.kt:165-172)는 KG가 아니면(=LB로 간주) freeAllowance / 2.205로 KG 환산한다. 정확한 1lb=0.45359237kg(1kg=2.2046226lb)이 아닌 2.205 근사이고, QUANTITY 단위(PC)는 환산 없이 그대로 둔다. 무게 표기 오차가 누적될 수 있다.
지뢰 13: 발권 가격 불일치 검증 — carrierFee 포함 합산
재발행 발권(SingaporeairTicketingService.reissue)은 사전결제액과 재가격 결과를 비교한다(:78-86):
carrierFee(=penalty)를 더해야 일치한다. 일반 발권 issue(:36)는 cardPrice + cashPrice 합을 그대로 SQ에 보낸다 — 여기엔 재가격 검증이 없다. 가격은 금액(원) 정수 비교라 1원이라도 어긋나면 발권 실패.
5. 인코딩 / 암호화 / 날짜·시간대 함정
지뢰 14: 빈 네임스페이스 문자열을 정규식 아닌 문자열 치환으로 제거
SOAP 본문 생성 마지막(SingaporeairClient.kt:894):
}.replace(" xmlns=\"\"", "")
Jackson XML이 자식 엘리먼트에 자동으로 붙이는 xmlns=""(빈 네임스페이스)를 전역 문자열 치환으로 지운다. 만약 운임/이름 등 텍스트 값 안에 우연히 xmlns="" 문자열이 들어오면 함께 지워진다(이론적 위험). NDC 스키마 검증이 까다로워 이 치환이 빠지면 SQ가 스키마 오류를 낸다 — 함부로 제거 금지.
지뢰 15: 비밀번호 다이제스트는 SHA-1
PasswordDigest(PasswordDigest.kt:91)는 MessageDigest.getInstance("SHA-1") 사용. WS-Security PasswordDigest = Base64(SHA1(nonce + created + SHA1(password))) 구조(:17-35). SHA-1은 약하지만 SQ/Amadeus 스펙이 요구하는 알고리즘이라 바꿀 수 없다. Nonce는 SecureRandom("SHA1PRNG") 32바이트(:41-45)로 매 요청 새로 생성하고, Password Type 네임스페이스는 ...#PasswordDigest(SingaporeairSoapHeaderNamespace.kt:28-31).
지뢰 16: 인증 타임스탬프는 UTC, 그러나 코드 곳곳의 now()는 Asia/Seoul
인증 Created는 PasswordDigest.getFormattedTime(now("UTC"))(SingaporeairClient.kt:860)로 명시적 UTC다. 그러나 공통 now()의 기본 zone은 Asia/Seoul(DateExtensions.kt:12). UTC를 깜빡하고 기본 now()로 created를 만들면 SQ 인증이 9시간 시계 오차로 거부된다. 포맷은 yyyy-MM-dd'T'HH:mm:ss.SSS'Z'(PasswordDigest.kt:14) — 밀리초 + ‘Z’ 리터럴 고정.
지뢰 17: 휴대폰 번호 "10" 접두 보정
retrieve 응답에서 모바일 번호가 "10..."으로 시작하면 앞에 0을 붙인다(DataList.kt:487-492). SQ가 국가코드/0을 떼고 내려주는 한국 번호 보정인데, 외국 번호가 우연히 10으로 시작하면 잘못된 0이 붙는다. 또한 mobile 라벨은 대문자 "MOBILE" 매칭(:487)에 의존.
지뢰 18: 날짜 파싱 — ZonedDateTime → LocalDateTime 절단
결제기한은 ZonedDateTime.parse(it).toLocalDateTime()(OrderViewRS.kt:52-54, 96-98)로 존 정보를 버린다. SQ가 내려준 paymentTimeLimit의 타임존이 KST가 아니면 결제기한이 9시간 어긋난 채 저장된다. pnrCreatedAt = LocalDateTime.now()(:87)는 서버 로컬(Asia/Seoul) 기준.
stayInfo.state 누락 (TODO)
DataList.kt:507-508: 리트리브 시 CountrySubDivisionName(주/도)이 누락되어 request 값으로 폴백. 유아는 체류지 정보 자체가 없어 request로 세팅(:513-524). NDC 응답이 입력값을 100% 반향하지 않는 SQ 특성.
환불 수수료가 0원인 정상 케이스(무료 환불 가능 운임)를 “계산 실패”로 간주한다. void 가능/미발권 건은 상위(expectedCancel)에서 걸러지지만, 발권됐는데 패널티 0인 운임이 있으면 환불 자체가 막힐 수 있다. 매칭 키도 위험: id.replace("Refund-P","") == identificationKey.replace("PAX","")(:444) — 문자열 치환 기반이라 SQ가 id 패턴을 바꾸면 매칭 전부 실패.
지뢰 20: 환불액에 abs() — 부호 손실
:459expectedRefundAmount = abs(deleteOrderItem.expectedRefundAmount). SQ가 음수(차감)로 내려도 절대값을 취한다. 추가 징수가 필요한 케이스(환불이 아니라 추가 결제)가 환불처럼 보일 수 있다. usedTax는 (originalTax - newTax).takeIf { it > 0 }(OrderReshopRS.kt:237-238)로 양수일 때만 채운다.
지뢰 21: reshopOffers.first() — 다중 오퍼 가정 없음
repricingWithReissue의 toFareItinerary(OrderReshopRS.kt:110-111)는 reshopResult.reshopOffers.first() 와 addOfferItems!!.first() 를 무조건 첫 번째로 집는다. SQ가 복수 reshop offer를 내려주면 나머지를 버린다. !! non-null 단언이라 비어 있으면 NPE.
지뢰 22: 취소 분기 — voidable vs refund 이중 판단
SingaporeairCancelService(:16-41)는 voidable || 전원 미발권이면 cancel(pnr)(금액 없이 void), 아니면 refundCalculate 후 expectedRefundAmount 합을 cancel(pnr, expectedRefundAmount=...)로 넘긴다. 체크인 승객 1명이라도 있으면 취소 전체 차단(:33-34, 45-46CANCEL_UNABLE_BY_ALREADY_CHECK_IN). void 판정은 retrieve 응답 metadata VOID_ELIGIBILITY 상태에 의존(OrderViewRS.kt:90) — 이 메타데이터가 없으면 voidable=false로 처리되어 발권 직후에도 환불 경로를 탄다.
issue 실패 시 cancelAsync(pnr)(TicketingService.kt:57, 97-111)는 별도 코루틴에서 delay(5000) 후 cancel. 호출 스레드와 분리되어 cancel 결과를 발권 응답이 기다리지 않는다. cancel마저 실패하면 sendCancelFail Slack 후 코루틴 내에서 throw(상위로 전파 안 됨). 즉 발권 실패 + 자동취소 실패 시 PNR이 미결제 상태로 떠 있을 수 있고, 사용자는 발권 실패만 통보받는다. 코루틴 예외 처리는 async-coroutines / AdapterCoroutineExceptionHandler 참조.
지뢰 24: reissueDetail — 출도착지로 기존 스케줄 매칭
SingaporeairFlightSearchService.reissueDetail(:177-194)은 “출/도착지 변경 불가” 정책 하에 출도착지로 기존 스케줄을 찾아, 시간·편명·캐빈이 전부 동일하면 NON_CHANGEABLE_SCHEDULES throw. 같은 구간을 여러 번 비행하는 복잡 여정(같은 출도착 반복)에서는 잘못된 스케줄을 매칭할 수 있다. departureAt은 originSchedule.departureAt.toLocalDateTime()로 비교(:186).
reissue 시 신규 발권 티켓만 골라내기
toReissueBooking(OrderViewRS.kt:117-131)은 ticketDocInfo 중 원본 booking에 없던 티켓번호만 신규 티켓으로 매칭(:121-124). 재발행으로 새로 끊긴 티켓을 식별하기 위함. voidable = false 고정.
7. 코드 내 TODO / FIXME / 주석 경고 전수
file:line
주석
분류
SingaporeairClient.kt:281
예약 시 넣었던 탑승객 정보 일부 누락 → request로 보충
데이터 정합 (지뢰 5)
SingaporeairClient.kt:400, 697, 813
TODO payment error code 정리 필요(Code set Dictionary 18.1.pdf - 9321)
individualRefID = "PAX1" //TODO 확인 우선 예약자 정보 1번 승객참조
하드코딩 가정
OrderViewRS.kt:76
// TODO 재발행 케이스를 확인해보자 (ticketDocInfo 필터)
미검증
OrderViewRS.kt:84
//TODO ASSOCIATED_BOOKING으로 디바이드 관련 PNR 목록 존재 (parentPnrs=null)
미구현
DataList.kt:508
//TODO 리트리브시 CountrySubDivisionName 누락 확인 필요
데이터 누락
TicketingService.kt:61
// TODO card 결제가 추가되면 Payment 리턴할수도
미구현
OrderReshopRS.kt:97,127
//없음 (baggageAllowanceRefId 매칭)
데이터 없음
SingaporeairAncillaryService.kt:30
// SQ는 SEG 정보만으로 좌석 구매 가능 여부를 알 수 없음
제약
지뢰 25: 死코드 — "현재 서비스하지 않음" 함수들
saveSeat(:472), repricingWithAncillary(:622), divide(:648), saveAncillary(:678)는 //현재 서비스하지 않음 주석이 달린 미사용/비활성 경로다. 이들은 정식 InternationalAdapterException이 아니라 생짜 Exception("$code, $message") 또는 throw it.exception 으로 처리(:490, 589, 611, 667, 698)되어 capture()(Slack)/ErrorMessage 매핑을 거치지 않는다. 만약 다시 활성화하면 에러 처리가 운영 체계와 어긋난다. OrderCreateRQ.kt:106의 PAX1 하드코딩(예약자=1번 승객 가정)도 검증되지 않은 가정이다.
PAX1 하드코딩 (지뢰 26)
OrderCreateRQ.kt:103-110에서 Notification 연락처의 individualRefID = "PAX1"로 고정 — “예약자 정보는 1번 승객”이라는 미검증 가정. 승객 순서가 바뀌거나 PAX id 채번 규칙이 다르면 알림이 엉뚱한 승객에게 연결될 수 있다.
8. 신입을 위한 정리
SQ 모듈을 만질 때 체크리스트
에러 분기를 추가/수정할 때 영어 메시지 문자열 매칭의 취약성(지뢰 1)을 항상 의심. 가능하면 코드 기반으로.
결제·발권·취소 경로에는 서킷브레이커가 없다(지뢰 7). 외부 장애 시 그대로 전파된다고 가정하고 호출자 측 타임아웃/재시도를 설계.
통화는 KRW 가정(지뢰 8). 비-KRW 운임을 만나면 정산 사고. 통화 검증을 넣을 거면 응답 CurCode를 읽어 비교.
아니다. searchFallback은 CallNotPermittedException(서킷이 이미 OPEN)에만 반응한다(SingaporeairSearchController.kt:104). 실제 SOAP 타임아웃/Fault는 폴백을 타지 않고 예외가 올라가며 실패율 카운터만 올린다. 충분히 누적되어 서킷이 OPEN(실패율 35%, 최소 30콜)되어야 그제서야 빈 배열 폴백이 작동한다.
Q2. 환불 수수료가 0원인 운임을 취소하면 어떻게 되나?
정답 보기
refundCalculate에서 refundFee == 0L을 계산 실패(CALCULATE_CANCEL_FEE_FAILED) 로 던진다(SingaporeairClient.kt:449-454). 단, expectedCancel/cancelService에서 voidable이거나 전원 미발권이면 refundCalculate를 호출하지 않고 바로 void cancel로 처리하므로(CancelService.kt:36), 이 함정은 “발권됐는데 패널티 0인 운임”에서 드러난다.
Q3. book이 성공했는데 왜 retrieve를 또 호출하나?
정답 보기
OrderCreateRS가 내려준 탑승객 식별번호(PAX id)가 OrderViewRS(retrieve)에서 바뀔 수 있기 때문(SingaporeairBookingService.kt:57). 이후 divide/ticketing 등에서 쓰는 identificationKey의 정합성을 위해 retrieve 결과를 진실로 삼는다.