Singapore Airlines — 지뢰요소

module-singaporeair pattern-error-handling api-ndc arch-resilience

이 노트의 목적

Singapore Airlines(SQ) 모듈에서 신입이 “당연히 동작하겠지”라고 믿었다가 운영 장애·정산 오류·발권 사고로 이어지는 지점을 전수 정리한다. 각 항목은 실제 코드 파일·라인을 근거로 한다. 오퍼레이션 흐름은 singaporeair-operations, EDIST/SOAP 프로토콜 세부는 singaporeair-protocol, 공통 에러 체계는 error-handling, 서킷브레이커/Slack 경보 체계는 resilience-and-events, 전체 지뢰 인덱스는 landmines 참조.

SQ는 Amadeus Altea 기반 NDC EDIST 18.1 스키마를 SOAP로 호출한다. NDC지만 GDS(Amadeus)의 인프라를 쓰기 때문에 SOAP 헤더에 Amadeus 보안 토큰(AMA_SecurityHostedUser)이 들어가는 하이브리드 구조다. 이 특성이 여러 함정의 뿌리가 된다.


0. 전체 지뢰 지도 (한눈에)

flowchart LR
    A1["인증/헤더"] --> A2["SHA-1 PasswordDigest<br/>UTC 타임스탬프<br/>Nonce 매 요청 생성"]
    B1["세션"] --> B2["stateless (PNR이 상태)<br/>단 book에서 retrieve 재조회 강제"]
    C1["에러매핑"] --> C2["문자열 message.contains 매칭에 의존<br/>코드 미정리, TODO 다수"]
    D1["회복탄력성"] --> D2["CircuitBreaker는 search 1곳뿐<br/>Retry 전무, book/issue 보호 없음"]
    E1["요금/통화"] --> E2["CurCode 전부 KRW 하드코딩<br/>tax 음수, 환불액 abs 처리"]
    F1["재발행/환불"] --> F2["reshopOffers.first<br/>abs(refund)<br/>가격 불일치 검증의 빈틈"]
    G1["인코딩/시간"] --> G2["xmlns 빈문자열 치환<br/>mobile 10 prefix 보정<br/>stayInfo 누락"]
    H1["미서비스"] --> H2["saveSeat / divide / saveAncillary / repricingWithAncillary 死코드"]

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

SQ 응답의 에러는 Errors > Error{Code, DescText} 구조(infrastructure/response/Error.kt)이며, 각 RS 타입의 checkError { code, message -> ... } 콜백에서 분기한다. 매핑 규칙이 에러코드가 아니라 사람이 읽는 영어 메시지 문자열을 == 또는 .contains() 로 비교하는 데 의존한다는 점이 핵심 지뢰다.

위치(file:line)트리거처리ErrorMessage
SingaporeairClient.kt:98-107search 응답 code "710"/"367"무시(빈 결과), 그 외 throwSEARCH_FAILED
SingaporeairClient.kt:276-278book code "911"throw + capture()BOOKING_FAILED
SingaporeairClient.kt:307-313retrieve message == "RESERVATION PREVIOUSLY CANCELLED"throwALREADY_CANCELED_PNR
SingaporeairClient.kt:347-353cancel message == "RESERVATION PREVIOUSLY CANCELLED"throwALREADY_CANCELED_PNR
SingaporeairClient.kt:397-398savePayment message .contains("MAXIMUM TICKET LIMIT REACHED")throwNO_QUOTA
SingaporeairClient.kt:814-822reissue message .contains("TICKET IS NOT ELIGIBLE FOR EXCHANGE")throw + captureTICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE / 그 외 RETICKETING_FAILED

지뢰 1: 영어 메시지 문자열 매칭 — 공급사가 문구만 바꿔도 분기 붕괴

retrieve/cancelmessage == "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-60singaporeSearch.baseConfig: search. config search(:41-47)는 TIME_BASED 슬라이딩 윈도우 180초, 최소 30콜, OPEN 유지 120초, 실패율 임계 35%.
flowchart TD
    CB["CircuitBreaker 보호"] --> CBS["search (singaporeSearch) 적용됨"]
    NP["보호 없음"] --> NP1["detail, reissueSearch, reissueDetail"]
    NP --> NP2["book, retrieve, divide"]
    NP --> NP3["ticketing (issue / ready / reissue)"]
    NP --> NP4["cancel / cancelable / expectedCancel"]
    NP --> NP5["ancillary (seat / baggage)"]
    RT["Retry"] --> RTN["없음 — 0건"]

폴백 동작: OPEN이면 빈 배열 + Datadog 태그

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에 박혀 있음

SingaporeairClientClientSupport(searchTimeout = 15000, defaultTimeout = 60000)(:45-49)을 상속. search 15초, 그 외(book/issue/cancel 등) 60초. cancel 타임아웃 시 slackService.sendCancelFailTimeout(:364-368), issue(savePayment) 타임아웃 시 timeoutCallbacksendTicketingTimeout(TicketingService.kt:42-47). 이 Slack 경보가 사실상의 “이벤트 전파” 역할(resilience-and-events).


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

지뢰 8: 통화가 전부 "KRW" 하드코딩

요청 DTO의 통화코드가 전부 KRW 기본값이다:

  • Amount.kt:9 val curCode: String = "KRW"
  • request/CurrencyAmount.kt:9 val curCode: String = "KRW"
  • ResponseParameter.kt:18, 32 curCode = "KRW", overrideCurCode = "KRW"

즉 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 덩어리

fuelChargetaxs.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):

if (prepaidPrice != passengerFares.sumOf { (it.total + (it.carrierFee ?: 0)) * it.count }) // total = airPrice + tax
    throw RETICKETING_FAILED_BY_MISMATCH_PRICE

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 특성.


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

지뢰 19: refundCalculate — refundFee 0이면 전체 실패 처리

SingaporeairClient.refundCalculate(:449-454):

if (deleteOrderItem == null || (deleteOrderItem.refundFee ?: 0) == 0L)
    throw CALCULATE_CANCEL_FEE_FAILED

환불 수수료가 0원인 정상 케이스(무료 환불 가능 운임)를 “계산 실패”로 간주한다. void 가능/미발권 건은 상위(expectedCancel)에서 걸러지지만, 발권됐는데 패널티 0인 운임이 있으면 환불 자체가 막힐 수 있다. 매칭 키도 위험: id.replace("Refund-P","") == identificationKey.replace("PAX","")(:444) — 문자열 치환 기반이라 SQ가 id 패턴을 바꾸면 매칭 전부 실패.

지뢰 20: 환불액에 abs() — 부호 손실

:459 expectedRefundAmount = abs(deleteOrderItem.expectedRefundAmount). SQ가 음수(차감)로 내려도 절대값을 취한다. 추가 징수가 필요한 케이스(환불이 아니라 추가 결제)가 환불처럼 보일 수 있다. usedTax(originalTax - newTax).takeIf { it > 0 }(OrderReshopRS.kt:237-238)로 양수일 때만 채운다.

지뢰 21: reshopOffers.first() — 다중 오퍼 가정 없음

repricingWithReissuetoFareItinerary(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), 아니면 refundCalculateexpectedRefundAmount 합을 cancel(pnr, expectedRefundAmount=...)로 넘긴다. 체크인 승객 1명이라도 있으면 취소 전체 차단(:33-34, 45-46 CANCEL_UNABLE_BY_ALREADY_CHECK_IN). void 판정은 retrieve 응답 metadata VOID_ELIGIBILITY 상태에 의존(OrderViewRS.kt:90) — 이 메타데이터가 없으면 voidable=false로 처리되어 발권 직후에도 환불 경로를 탄다.

지뢰 23: 발권 실패 시 비동기 cancel — fire-and-forget 5초 delay

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. 같은 구간을 여러 번 비행하는 복잡 여정(같은 출도착 반복)에서는 잘못된 스케줄을 매칭할 수 있다. departureAtoriginSchedule.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, 813TODO payment error code 정리 필요(Code set Dictionary 18.1.pdf - 9321)에러매핑 (지뢰 2)
SingaporeairClient.kt:471,621,647,677//현재 서비스하지 않음 (saveSeat / repricingWithAncillary / divide / saveAncillary)死코드 (지뢰 25)
OrderCreateRQ.kt:106individualRefID = "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:106PAX1 하드코딩(예약자=1번 승객 가정)도 검증되지 않은 가정이다.

PAX1 하드코딩 (지뢰 26)

OrderCreateRQ.kt:103-110에서 Notification 연락처의 individualRefID = "PAX1"로 고정 — “예약자 정보는 1번 승객”이라는 미검증 가정. 승객 순서가 바뀌거나 PAX id 채번 규칙이 다르면 알림이 엉뚱한 승객에게 연결될 수 있다.


8. 신입을 위한 정리

SQ 모듈을 만질 때 체크리스트

  1. 에러 분기를 추가/수정할 때 영어 메시지 문자열 매칭의 취약성(지뢰 1)을 항상 의심. 가능하면 코드 기반으로.
  2. 결제·발권·취소 경로에는 서킷브레이커가 없다(지뢰 7). 외부 장애 시 그대로 전파된다고 가정하고 호출자 측 타임아웃/재시도를 설계.
  3. 통화는 KRW 가정(지뢰 8). 비-KRW 운임을 만나면 정산 사고. 통화 검증을 넣을 거면 응답 CurCode를 읽어 비교.
  4. 재발행/환불 금액은 부호·penalty·tax 음수가 끼어 있다(지뢰 9, 13, 19, 20). abs()/penalty 합산을 반드시 의식.
  5. SOAP 본문 replace(" xmlns=\"\"","")(지뢰 14)는 NDC 스키마 통과에 필수 — 건드리지 말 것.
  6. 인증 created는 UTC + 5분 만료(지뢰 6, 16). 기본 now()(KST)와 혼동 금지.

자가 점검 퀴즈

Q1. SQ search가 SOAP 장애로 503을 반환하면 searchFallback이 빈 배열을 돌려줄까?

Q2. 환불 수수료가 0원인 운임을 취소하면 어떻게 되나?

Q3. book이 성공했는데 왜 retrieve를 또 호출하나?


관련 노트

오퍼레이션 흐름 singaporeair-operations · 프로토콜(EDIST/SOAP) singaporeair-protocol · 공통 에러 체계 error-handling · 서킷브레이커/Slack 경보 resilience-and-events · 비동기/코루틴 async-coroutines · 전체 지뢰 인덱스 landmines · 기존 API 분석 singaporeair-ndc