Singapore Airlines NDC API 분석 문서

1. 개요

1.1 Singapore Airlines NDC 시스템

  • 공급사 타입: Airline Direct Connect (NDC 표준)
  • 통신 방식: SOAP API (IATA NDC 표준)
  • 인증 방식: WS-Security Username Token with Password Digest
  • 트랜잭션 관리: Stateless (PNR 기반 Order 관리)
  • 주요 특징: NDC 표준 준수, SQ 직연동, 재발행(Reissue) 지원, 좌석/부가서비스 지원

1.2 아키텍처 특징

Controller Layer → Service Layer → Client Layer → Singapore Air NDC API
     ↓                  ↓               ↓
  Request DTO      Business Logic   SOAP Client
     ↓                  ↓               ↓
  Response DTO     Domain Model    WS-Security Auth
                                   (Password Digest)

API 타입별 분류

  • 검색 (Search): AirShoppingRQ/RS
  • 가격 확인 (Pricing): OfferPriceRQ/RS
  • 예약 (Booking): OrderCreateRQ, OrderRetrieveRQ
  • 발권 (Ticketing): OrderChangeRQ (Payment)
  • 취소 (Cancel): OrderCancelRQ/RS, OrderReshopRQ (Refund Calculation)
  • 재발행 (Reissue): OrderReshopRQ (Search/Pricing), OrderChangeRQ
  • 부가 서비스: SeatAvailabilityRQ, ServiceListRQ (현재 미서비스)

1.3 인증 메커니즘

1.3.1 WS-Security Password Digest

// SingaporeairClient.kt:773-836
private val soapRequestBodyConverter: (SingaporeairApiProperties) -> ((Any?) -> String) =
    { singaporeairApiProperties ->
        { request ->
            soap {
                header {
                    // 1. AMA_SecurityHostedUser
                    headerElement("AMA_SecurityHostedUser", ...) {
                        childElement("UserID", ...) {
                            attribute("POS_Type") { "1" }
                            attribute("RequestorType") { "U" }
                            attribute("PseudoCityCode") { singaporeairApiProperties.pseudoCityCode }
                            attribute("AgentDutyCode") { "SU" }
                        }
                    }
 
                    // 2. Security (WS-Security)
                    headerElement("Security", SingaporeairSoapHeaderNamespace.WS_SECURITY_WSSE) {
                        childElement("UsernameToken", ...) {
                            val nonce = PasswordDigest.genNonce()
                            val formattedCreated = PasswordDigest.getFormattedTime(now("UTC"))
 
                            childElement("Username", ...) { text { userName } }
                            childElement("Password", ...) {
                                attribute("Type") { "PasswordDigest" }
                                text { PasswordDigest.getPasswordDigestFromClearTextPW(nonce, formattedCreated, password) }
                            }
                            childElement("Nonce", ...) { text { nonce } }
                            childElement("Created", ...) { text { formattedCreated } }
                        }
                    }
 
                    // 3. Addressing Headers
                    headerElement("Action", ...) { text { request.action } }
                    headerElement("MessageID", ...) { text { "urn:uuid:${UUID.randomUUID()}" } }
                    headerElement("To", ...) { text { endpoint } }
                }
                body(request)
            }
        }
    }

Password Digest 생성 과정

  1. Nonce 생성 (Base64 인코딩된 랜덤 바이트)
  2. Created Timestamp 생성 (UTC 시간)
  3. Password Digest = Base64(SHA1(Nonce + Created + Password))
  4. SOAP Header에 Username, Password Digest, Nonce, Created 포함

인증 구조

  • PseudoCityCode: Singapore Air에서 발급한 에이전시 코드
  • Username/Password: WS-Security 인증 정보
  • IATA Code: 여행사 IATA 번호
  • Agency Name: 여행사명

2. API 엔드포인트별 상세 분석

2.1.1 검색 API

엔드포인트: POST /internals/SINGAPOREAIR/search

API 타입: SOAP API (AirShoppingRQ v18.2)

기능 설명

  • Singapore Airlines NDC를 통한 항공편 검색
  • SOAP API 기반 병렬 검색 처리
  • Redis 캐싱 및 중복 제거

요청 파라미터

data class SearchRequest(
    val originDestinationLocationInfos: List<OriginDestinationLocationInfo>,
    val cabins: List<CabinType>,
    val preferences: List<SearchPreference>,
    val airlines: List<String>?,
    val onlyDirect: Boolean,
    val onlyFreeBaggageInclude: Boolean,
    val useCache: Boolean = true,
    val logging: Boolean = false
)

비즈니스 로직 단계

// SingaporeairFlightSearchService.kt:34-93
1. 캐시 확인: Redis에서 기존 검색 결과 확인

2. 공항 필터링: makeOriginDestinationsFilterByRoutes() - SQ 지원 노선만

3. Cartesian Product: 모든 공항 조합 생성

4. 병렬 검색: pmap으로 병렬 처리 (Coroutines)

5. SOAP 요청: AirShoppingRQ 호출 (15초 타임아웃)

6. 결과 필터링:
   - NonAir 항목 제거 (BUS, TRAIN 등)
   - Non-SQ 항공사 제거 (SQ 외 항공사 포함 편 제외)
   - 중복 제거 (distinctBy id)
   - 직항 필터링 (onlyDirect)
   - 무료 수하물 필터링 (onlyFreeBaggageInclude)

7. 캐싱 저장: Redis에 결과 저장

8. 항공사 필터링 및 결과 수 제한

에러 처리

// SingaporeairClient.kt:96-106
airShoppingRS.checkError { code, message ->
    when (code) {
        "710" -> {}  // NO FARE FOUND FOR REQUESTED ITINERARY (정상)
        "367" -> {}  // NO ACTIVE ITINERARY IN THE AIRLINE PROFILE (정상)
        else -> throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, code, message)
    }
}

필터링 로직

// SingaporeairClient.kt:125-137
// 1. NonAir 제거
private fun FareItinerary.isNonAir(): Boolean {
    return this.schedules.any { schedule ->
        schedule.segments.any {
            it.equipmentType != null && NonAirEquipment.existValueOf(it.equipmentType)
        }
    }
}
 
// 2. Non-SQ 항공사 제거
private fun FareItinerary.isNonSingaporeAirlineSchedule(): Boolean {
    return this.schedules.any { schedule ->
        schedule.segments.any { it.marketingCarrier != "SQ" }
    }
}

2.2 예약 API (Booking)

2.2.1 예약 생성

엔드포인트: POST /internals/SINGAPOREAIR/bookings

API 타입: SOAP API (OrderCreateRQ)

비즈니스 로직 단계

// SingaporeairBookingService.kt:30-61
1. FareItinerary 조회 (검색 결과)

2. Pricing API 호출 (가격 재확인)
   - OfferPriceRQ
   - SOLD_OUT 검증

3. Book API 호출 (Order 생성)
   - OrderCreateRQ
   - PNR 획득

4. Retrieve API 호출 (예약 확인)
   - OrderRetrieveRQ
   - 탑승객 번호 변경 가능성으로 인한 재조회

5. 성공 시:
   - 검색 캐시 삭제 (비동기)
   실패 시:
   - 에러 발생 (911 코드 특별 처리)

OrderCreateRQ 특징

// SingaporeairClient.kt:250-286
fun book(
    reservationUser: ReservationUser,
    passengers: List<Passenger>,
    fareItinerary: FareItinerary,
): Booking {
    val request = OrderCreateRQ.of(
        fareItinerary = fareItinerary,
        reservationUser = reservationUser,
        passengers = passengers,
        iataNumber = iataCode,
        agencyName = agencyName
    )
 
    return singaporeairApiProperties.endpoint
        .post(request)
        .execute<OrderViewRS>()
        .fold(
            success = { orderViewRS ->
                orderViewRS.checkError { code, message ->
                    // 911: NOT AVAILABLE AND WAITLIST CLOSED
                    throw InternationalAdapterException(ErrorMessage.BOOKING_FAILED, code, message)
                        .apply { if (code == "911") this.capture() }
                }
                orderViewRS.response!!.toBooking(passengers = passengers)
            },
            failure = { failure ->
                throw failure.handleSoapFaultException(ErrorMessage.BOOKING_FAILED)
            }
        )
}

주요 처리 사항

  • 승객 정보: 이름, 생년월일, 성별, 여권 정보, 연락처
  • PNR 생성: Singapore Air Order ID
  • 탑승객 번호 재조회: OrderCreateRQ 응답 후 탑승객 번호가 변경될 수 있어 재조회 필요

2.2.2 예약 조회

엔드포인트: GET /internals/SINGAPOREAIR/bookings/{pnr}

처리 로직

// SingaporeairClient.kt:288-322
fun retrieve(pnr: String, passengers: List<Passenger>? = null): Booking {
    val request = OrderRetrieveRQ.of(pnr, iataNumber, agencyName)
 
    return endpoint.post(request)
        .execute<OrderViewRS>()
        .fold(
            success = { orderViewRS ->
                orderViewRS.checkError { code, message ->
                    // 911 + "RESERVATION PREVIOUSLY CANCELLED"
                    if (code == "911" && message == "RESERVATION PREVIOUSLY CANCELLED") {
                        throw InternationalAdapterException(ErrorMessage.ALREADY_CANCELED_PNR, pnr, code, message)
                    }
                    throw InternationalAdapterException(ErrorMessage.RETRIEVE_FAILED, code, message)
                }
                orderViewRS.response!!.toBooking(passengers = passengers)
            },
            failure = { failure ->
                throw failure.handleSoapFaultException(ErrorMessage.RETRIEVE_FAILED)
            }
        )
}

2.2.3 PNR 분할 (Divide)

엔드포인트: POST /internals/SINGAPOREAIR/bookings/{pnr}/divide

처리 로직

// SingaporeairBookingService.kt:73-84
fun divide(pnr: String, passengers: List<PassengerIdentification>): Booking {
    validate(requestPassengers = passengers, retrievedPassengers = retrieve(pnr).passengers)
 
    return singaporeairClient.divide(
        pnr = pnr,
        passengers = passengers.filter {
            it.type != PassengerType.INFANT  // 유아는 자동으로 부모 따라감
        }
    ).let {
        singaporeairClient.retrieve(it.pnr)
    }
}

검증 로직

  • 동일 탑승객 유무 확인
  • 유아-성인 페어 검증
  • 유아는 부모 탑승객과 자동으로 이동

2.3 발권 API (Ticketing)

2.3.1 발권 준비

엔드포인트: POST /internals/SINGAPOREAIR/ticketing/ready

검증 사항

// SingaporeairTicketingService.kt:27-30
fun ready(pnr: String): Pair<List<Passenger>?, List<Schedule>> {
    val booking = singaporeairClient.retrieve(pnr)
    return null to booking.schedules!!
}
  • 예약 조회만 수행 (별도 검증 없음)

2.3.2 발권 실행

엔드포인트: POST /internals/SINGAPOREAIR/ticketing

API 타입: SOAP API (OrderChangeRQ with Payment)

발권 프로세스

// SingaporeairTicketingService.kt:32-62
val amount = passengerPrices.sumOf { it.cardPrice + it.cashPrice }
 
val booking = try {
    singaporeairClient.savePayment(
        pnr = pnr,
        amount = amount,
        timeoutCallback = {
            slackService.sendTicketingTimeout(
                supplier = Supplier.SINGAPOREAIR,
                pnr = pnr,
            )
        }
    )
} catch (e: Exception) {
    // NO_QUOTA 특별 처리
    if (e is ApiException && e.errorMessage == ErrorMessage.NO_QUOTA) {
        slackService.sendNoQuota(
            supplier = Supplier.SINGAPOREAIR,
            airline = "SQ",
            reason = e.message
        )
    }
    // 발권 실패 시 5초 대기 후 비동기 취소
    cancelAsync(pnr)
    throw e
}
 
return booking.passengers

주요 API 호출

// SingaporeairClient.kt:366-409
fun savePayment(pnr: String, amount: Long, timeoutCallback: () -> Unit): Booking {
    val request = OrderChangeRQ.ofPayment(pnr, amount, iataNumber, agencyName)
 
    return endpoint.post(request)
        .execute<OrderViewRS>()
        .fold(
            success = { orderViewRS ->
                orderViewRS.checkError { code, message ->
                    // MAXIMUM TICKET LIMIT REACHED
                    if (message.contains("MAXIMUM TICKET LIMIT REACHED")) {
                        throw InternationalAdapterException(ErrorMessage.NO_QUOTA, code, message)
                    } else {
                        throw InternationalAdapterException(ErrorMessage.TICKETING_FAILED, pnr, code, message)
                    }
                }
                orderViewRS.response!!.toBooking()
            },
            failure = { failure ->
                if (failure.isTimeout) {
                    timeoutCallback()  // Slack 알림
                }
                throw failure.handleSoapFaultException(ErrorMessage.TICKETING_FAILED, pnr)
            }
        )
}

특징

  • NDC 표준: Payment 정보를 OrderChangeRQ로 전송하여 발권
  • NO_QUOTA 에러: 항공사 할당량 초과 시 Slack 알림
  • 타임아웃 시 Slack 알림
  • 발권 실패 시 5초 대기 후 비동기 취소

2.4 운임 규정 API (Fare Rules)

엔드포인트: GET /internals/SINGAPOREAIR/fare-rules

API 타입: SOAP API (OfferPriceRQ with MiniRules)

기능 설명

  • 운임 규정 및 제한 사항 조회 (MiniRules)
  • 환불/변경 수수료 정보
  • CMS(Content Management System) 기반 규정 제공

처리 로직

// SingaporeairFareRuleService.kt:26-42
fun findFareRules(key: String, adult: Int, child: Int, infant: Int): List<FareRule> {
    val fareItinerary = fareItineraryRepository.getFareItinerary(hashKey = key)
 
    return singaporeairClient.getMiniRules(
        adult = adult,
        child = child,
        infant = infant,
        fareItinerary = fareItinerary,
        sendSlackMessage = sendSlackMessage,
    ).flatMapIndexed { index, priceRule -> priceRule.toFareRule(index + 1) }
}

MiniRules 조회

// SingaporeairClient.kt:139-213
fun getMiniRules(
    adult: Int,
    child: Int,
    infant: Int,
    fareItinerary: FareItinerary,
    sendSlackMessage: (String) -> Unit,
): List<MiniRule> {
    val request = OfferPriceRQ.of(adult, child, infant, fareItinerary, iataNumber, agencyName)
 
    return endpoint.post(request)
        .execute<OfferPriceRS>()
        .fold(
            success = { offerPriceRS ->
                offerPriceRS.checkError { code, message ->
                    throw InternationalAdapterException(ErrorMessage.FETCH_FARE_RULES_FAILED, ...)
                }
 
                val response = offerPriceRS.response!!
 
                // CMS 규정 조회 실패 경고
                if (response.warnings?.any { it.descText == "LOCALIZED CONTENT COULD NOT BE RETRIEVED FROM CMS" } == true) {
                    sendSlackMessage("규정 확인 필요")
                }
 
                // FareComponent별 MiniRule 추출
                response.pricedOffer.offer.offerItems.flatMap { offerItem ->
                    offerItem.fareDetails?.first()?.fareComponents?.mapNotNull { fareComponent ->
                        response.dataList.priceClasses.find { it.id == fareComponent.priceClassRef }
                            ?.toMiniRule(response.dataList.paxSegments.find { it.id == fareComponent.segmentRef })
                    } ?: throw InternationalAdapterException(...)
                }
            },
            failure = { failure ->
                throw failure.handleSoapFaultException(ErrorMessage.FETCH_FARE_RULES_FAILED)
            }
        )
}

CMS 규정 조회 실패 처리

  • Warning: “LOCALIZED CONTENT COULD NOT BE RETRIEVED FROM CMS”
  • Slack 알림: “규정 확인 필요”
  • 운임 규정이 제대로 조회되지 않았음을 알림

2.5 취소 API (Cancel)

엔드포인트: PUT /internals/SINGAPOREAIR/bookings/{pnr}/cancel

API 타입: SOAP API (OrderCancelRQ + OrderReshopRQ)

취소 유형 판별

// SingaporeairCancelService.kt:16-29
fun cancel(pnr: String): Pair<Boolean, List<Passenger>> {
    return expectedCancel(pnr).also { (voidable, passengers) ->
        if (voidable || passengers.all { it.tickets.isNullOrEmpty() }) {
            // Void 가능 또는 티켓 없음 → 단순 취소
            singaporeairClient.cancel(pnr = pnr)
        } else {
            // Refund → 환불 금액 포함 취소
            singaporeairClient.cancel(
                pnr = pnr,
                expectedRefundAmount = passengers.sumOf { passenger ->
                    passenger.fare?.expectedRefundAmount ?: 0
                }
            )
        }
    }
}

취소 가능 여부 확인

// SingaporeairCancelService.kt:31-41
fun expectedCancel(pnr: String): Pair<Boolean, List<Passenger>> {
    val booking = singaporeairClient.retrieve(pnr = pnr)
 
    // Check-in 상태 확인
    if (booking.passengers.any { it.isCheckIn }) {
        throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE_BY_ALREADY_CHECK_IN, pnr)
    }
 
    return booking.voidable to if (booking.voidable || booking.passengers.all { it.tickets.isNullOrEmpty() }) {
        booking.passengers
    } else {
        singaporeairClient.refundCalculate(booking)  // 환불 금액 계산
    }
}

환불 금액 계산

// SingaporeairClient.kt:411-457
fun refundCalculate(booking: Booking): List<Passenger> {
    val request = OrderReshopRQ.ofRefundCalculate(booking, iataNumber, agencyName)
 
    return endpoint.post(request)
        .execute<OrderReshopRS>()
        .fold(
            success = { orderReshopRS ->
                orderReshopRS.checkError { code, message ->
                    throw InternationalAdapterException(ErrorMessage.CALCULATE_CANCEL_FEE_FAILED, code, message)
                }
 
                val response = orderReshopRS.response!!
 
                booking.passengers.map { passenger ->
                    val deleteOrderItem = response.reshopResult.reshopOffers
                        .find { it.id.replace("Refund-P", "") == passenger.identificationKey!!.replace("PAX", "") }
                        ?.deleteOrderItems?.firstOrNull()
 
                    if (deleteOrderItem != null) {
                        passenger.copy(
                            fare = passenger.fare!!.copy(
                                refundFee = deleteOrderItem.penaltyDifferential?.amount?.value?.toLong(),
                                expectedRefundAmount = abs(deleteOrderItem.differentialAmountDue.amount.value.toLong()),
                                usedTax = deleteOrderItem.usedTax
                            )
                        )
                    } else {
                        passenger
                    }
                }
            },
            failure = { failure ->
                throw failure.handleSoapFaultException(ErrorMessage.CALCULATE_CANCEL_FEE_FAILED)
            }
        )
}

OrderCancelRQ 호출

// SingaporeairClient.kt:324-364
fun cancel(pnr: String, expectedRefundAmount: Long? = null): Long {
    val request = OrderCancelRQ.of(pnr, expectedRefundAmount, iataNumber, agencyName)
 
    return endpoint.post(request)
        .execute<OrderCancelRS>()
        .fold(
            success = { orderCancelRS ->
                orderCancelRS.checkError { code, message ->
                    // 911 + "RESERVATION PREVIOUSLY CANCELLED"
                    if (code == "911" && message == "RESERVATION PREVIOUSLY CANCELLED") {
                        throw InternationalAdapterException(ErrorMessage.ALREADY_CANCELED_PNR, pnr, code, message)
                    }
                    throw InternationalAdapterException(ErrorMessage.CANCEL_FAILED, code, message)
                }
                orderCancelRS.response?.changeFees?.penaltyAmount?.value?.toLong() ?: 0
            },
            failure = { failure ->
                // 타임아웃 시 Slack 알림
                if (failure.isTimeout) {
                    slackService.sendCancelFailTimeout(
                        supplier = Supplier.SINGAPOREAIR,
                        pnr = pnr,
                    )
                }
                throw failure.handleSoapFaultException(ErrorMessage.CANCEL_FAILED)
            }
        )
}

취소 프로세스

1. 예약 조회 (Retrieve)
   ↓
2. Check-in 상태 확인
   ↓
3. Voidable 여부 확인
   ↓
4-1. Voidable 또는 티켓 없음
     → OrderCancelRQ (단순 취소)

4-2. Refund 필요
     → OrderReshopRQ (환불 금액 계산)
     → OrderCancelRQ (expectedRefundAmount 포함)

2.6 재발행 API (Reissue)

2.6.1 재발행 검색

엔드포인트: POST /internals/SINGAPOREAIR/reissue/search

처리 로직

// SingaporeairFlightSearchService.kt:101-140
fun reissueSearch(
    pnr: String,
    originDestinationInfos: List<ReissueOriginDestinationLocation>,
    cabins: List<CabinType>,
    onlyDirect: Boolean,
    onlyFreeBaggageInclude: Boolean,
    useCache: Boolean,
): List<FareItinerary> {
    val booking = singaporeairClient.retrieve(pnr)
    val key = CacheKeyGenerator.generateFareItineraryKey(Supplier.SINGAPOREAIR)
 
    val originDestinations = originDestinationInfos.map {
        OriginDestination(...)
    }
 
    // 변경하지 않을 item (유지되는 구간)
    val remainItem = booking.schedules!!.groupBy { it.groupSequence }
        .filterNot { (_, segments) ->
            originDestinations.any {
                it.origin == segments.first().departure && it.destination == segments.last().arrival
            }
        }.values.flatten()
 
    return singaporeairClient.reissueSearch(
        key = key,
        booking = booking,
        originDestinations = originDestinations,
        remainItem = remainItem,
        cabins = cabins,
        onlyDirect = onlyDirect,
        onlyFreeBaggageInclude = onlyFreeBaggageInclude
    ).also {
        if (useCache && it.isNotEmpty()) {
            fareItineraryRepository.saveFareItineraries(key, it.associateBy { it.itemKey })
        }
    }
}

OrderReshopRQ (재발행 검색)

// SingaporeairClient.kt:648-698
fun reissueSearch(
    key: String,
    booking: Booking,
    originDestinations: List<OriginDestination>,
    remainItem: List<Schedule>,
    cabins: List<CabinType>,
    onlyDirect: Boolean,
    onlyFreeBaggageInclude: Boolean,
): List<FareItinerary> {
    val request = OrderReshopRQ.ofReissueSearch(
        booking, originDestinations, remainItem, iataNumber, agencyName, cabins
    )
 
    return endpoint.post(request)
        .execute<OrderReshopRS>()
        .fold(
            success = { orderReshopRS ->
                orderReshopRS.checkError { errors ->
                    throw InternationalAdapterException(
                        ErrorMessage.REISSUE_SEARCH_FAILED,
                        ResponseMessage("검색 오류가 발생했습니다." + ...)
                    )
                }
                orderReshopRS.response!!.toFareItineraries(
                    key = key,
                    onlyDirect = onlyDirect,
                    remainServiceIds = remainItem.flatMap { it.referenceServiceIds }
                )
            },
            failure = { throw it.exception }
        )
}

2.6.2 재발행 상세 조회

엔드포인트: POST /internals/SINGAPOREAIR/reissue/detail

처리 로직

// SingaporeairFlightSearchService.kt:142-169
fun reissueDetail(pnr: String, key: String): FareItinerary {
    val fareItinerary = getFareItinerary(key)
 
    // 다운그레이드 가격 검증
    fareItinerary.passengerFares.forEach {
        if (it.tax < 0 || it.total < 0) {
            throw InternationalAdapterException(ErrorMessage.CHANGED_DOWN_GRADE_PRICE)
        }
    }
 
    val booking = singaporeairClient.retrieve(pnr)
 
    // 출/도착지, 출발시간, 도착시간, 편명, 클래스 변경 여부 확인
    booking.schedules!!.filterNot { fareItinerary.remainServiceIds?.containsAll(it.referenceServiceIds) ?: false }
        .forEach { originSchedule ->
            fareItinerary.schedules.flatMap { it.segments }.find {
                it.departure == originSchedule.departure && it.arrival == originSchedule.arrival
            }?.also {
                if (
                    it.departureAt == originSchedule.departureAt.toLocalDateTime()
                    && it.arrivalAt == originSchedule.arrivalAt.toLocalDateTime()
                    && it.flightNumber == originSchedule.flightNumber
                    && it.cabin == originSchedule.cabin
                ) {
                    throw InternationalAdapterException(ErrorMessage.NON_CHANGEABLE_SCHEDULES)
                }
            }
        }
 
    return singaporeairClient.repricingWithReissue(booking, fareItinerary)
}

2.6.3 재발행 실행

엔드포인트: POST /internals/SINGAPOREAIR/ticketing/reissue

처리 로직

// SingaporeairTicketingService.kt:64-95
fun reissue(pnr: String, detailKey: String, prepaidPrice: Long): ReissueResult<Booking, PassengerFare> {
    val originBooking = singaporeairClient.retrieve(pnr)
    val fareItinerary = singaporeairFlightSearchService.getFareItinerary(detailKey)
 
    // Repricing
    val passengerFares = singaporeairClient.repricingWithReissue(
        booking = originBooking,
        fareItinerary = fareItinerary
    ).passengerFares
 
    // 가격 일치 확인
    if (prepaidPrice != passengerFares.sumOf { (it.total + (it.carrierFee ?: 0)) * it.count }) {
        throw InternationalAdapterException(
            ErrorMessage.RETICKETING_FAILED_BY_MISMATCH_PRICE,
            pnr,
            "price : $prepaidPrice , pricedTotal : ${...}"
        )
    }
 
    // 재발행 실행
    val newBooking = singaporeairClient.reissue(
        booking = originBooking,
        price = prepaidPrice,
        fareItinerary = fareItinerary
    )
 
    return ReissueResult(booking = newBooking, passengers = passengerFares)
}

OrderChangeRQ (재발행)

// SingaporeairClient.kt:731-771
fun reissue(booking: Booking, price: Long, fareItinerary: FareItinerary): Booking {
    val request = OrderChangeRQ.ofReissue(booking, price, fareItinerary, iataNumber, agencyName)
 
    return endpoint.post(request)
        .execute<OrderViewRS>()
        .fold(
            success = { orderViewRS ->
                orderViewRS.checkError { code, message ->
                    throw if (message.contains("TICKET IS NOT ELIGIBLE FOR EXCHANGE")) {
                        InternationalAdapterException(
                            ErrorMessage.TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE,
                            booking.pnr, code, message
                        )
                    } else {
                        InternationalAdapterException(ErrorMessage.RETICKETING_FAILED, booking.pnr, code, message)
                    }.capture()
                }
                orderViewRS.response!!.toReissueBooking(originBooking = booking)
            },
            failure = { throw it.exception }
        )
}

2.7 부가 서비스 API (현재 미서비스)

2.7.1 좌석 조회

API: SeatAvailabilityRQ/RS

// SingaporeairClient.kt:460-489
fun retrieveSeat(pnr: String): List<SeatAvailability> {
    val request = SeatAvailabilityRQ.of(pnr, iataNumber, agencyName)
 
    return endpoint.post(request)
        .execute<SeatAvailabilityRS>()
        .fold(
            success = { it.response!!.seatMaps.map { seatMap ->
                seatMap.toSeatAvailability(it.response.aLaCarteOffer!!, it.response.dataList.serviceDefinitions)
            }},
            failure = { throw it.exception }
        )
}

2.7.2 부가 서비스 조회

API: ServiceListRQ/RS

// SingaporeairClient.kt:521-559
fun searchAncillary(booking: Booking): List<Ancillary> {
    val request = ServiceListRQ.of(booking, iataNumber, agencyName)
 
    return endpoint.post(request)
        .execute<ServiceListRS>()
        .fold(
            success = { it.response!!.aLaCarteOffer.aLaCarteOfferItems.map { item ->
                item.toAncillary(...)
            }},
            failure = { throw it.exception }
        )
}

3. 공통 컴포넌트

3.1 인증 및 보안

WS-Security Username Token

  • Password Digest 방식
  • Nonce + Created + Password → SHA1 → Base64
  • 매 요청마다 새로운 Nonce 및 Timestamp 생성

인증 정보

  • PseudoCityCode: Singapore Air 에이전시 코드
  • Username/Password: WS-Security 인증
  • IATA Code: 여행사 IATA 번호
  • Agency Name: 여행사명

3.2 캐싱 전략

캐시 키 구조

SINGAPOREAIR_{type}_{key}_{timestamp}

TTL 설정

  • 검색 결과: 설정된 TTL (flightSearchKey, fareItinerary)
  • 운임 규정: Redis 캐싱 없음 (매번 조회)

Redis 압축

  • FareItinerary: 기본 직렬화 (Jackson)
  • 별도 압축 없음 (Galileo의 Gzip, Amadeus의 Snappy와 달리)

3.3 재시도 및 복구

재시도 정책

  • 현재 별도 재시도 로직 없음
  • 병렬 검색 시 부분 실패 허용 (onFailure { exceptions, successes })

에러 처리

  • SoapFault 자동 처리
  • 특정 에러 코드별 커스텀 처리 (911, 710, 367 등)

3.4 비동기 처리 패턴

5초 대기 후 비동기 처리

// SingaporeairTicketingService.kt:97-111
private fun cancelAsync(pnr: String) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        delay(5000)
        try {
            singaporeairClient.cancel(pnr)
        } catch (e: Exception) {
            slackService.sendCancelFail(
                supplier = Supplier.SINGAPOREAIR,
                pnr = pnr,
                reason = e.message
            )
            throw e
        }
    }
}

검색 캐시 삭제 비동기

// SingaporeairBookingService.kt:63-67
private fun removeFlightSearchKey(key: String) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        flightSearchKeyRepository.removeKey(key)
    }
}

3.5 에러 처리 패턴

에러 타입별 처리

  • StatusInvalidException: 예약/발권 상태 불일치
  • InternationalAdapterException: 비즈니스 로직 에러
  • ApiException: API 호출 실패 (특정 에러 메시지 처리)

Slack 알림 케이스

  • 발권 타임아웃
  • 발권 실패 (NO_QUOTA)
  • 취소 타임아웃
  • 취소 실패
  • 운임 규정 조회 실패 (CMS 조회 실패)

4. Singapore Airlines NDC 특징

4.1 NDC 표준 준수

IATA NDC (New Distribution Capability)

구분설명
표준IATA NDC 18.2
통신SOAP/XML
메시지AirShopping, OfferPrice, OrderCreate, OrderChange, OrderCancel, OrderReshop 등
특징항공사 직연동, Rich Content 제공 (좌석/부가서비스)

4.2 Singapore Airlines 전용 처리

4.2.1 SQ 항공사만 지원

// SingaporeairClient.kt:133-137
private fun FareItinerary.isNonSingaporeAirlineSchedule(): Boolean {
    return this.schedules.any { schedule ->
        schedule.segments.any { it.marketingCarrier != "SQ" }
    }
}
  • 마케팅 항공사: SQ (Singapore Airlines)만 허용
  • 타 항공사 포함 편은 검색 결과에서 제외

4.2.2 NonAir Equipment 제거

// SingaporeairClient.kt:125-131
private fun FareItinerary.isNonAir(): Boolean {
    return this.schedules.any { schedule ->
        schedule.segments.any {
            it.equipmentType != null && NonAirEquipment.existValueOf(it.equipmentType)
        }
    }
}
  • NonAir: BUS, TRAIN 등 항공편이 아닌 구간 제거

4.2.3 유아 처리 (Divide)

// SingaporeairBookingService.kt:77-80
passengers.filter {
    it.type != PassengerType.INFANT  // 유아는 자동으로 부모 탑승객 따라감
}
  • 예약 분할(Divide) 시 유아는 부모와 자동으로 이동
  • 유아를 Divide 요청에 포함하면 에러 발생

4.3 NDC만의 고유 개념

4.3.1 OfferPriceRQ의 다중 용도

// 1. Pricing (가격 확인)
fun pricing(adult: Int, child: Int, infant: Int, fareItinerary: FareItinerary): List<PassengerFare>
 
// 2. MiniRules (운임 규정)
fun getMiniRules(adult: Int, child: Int, infant: Int, fareItinerary: FareItinerary, ...): List<MiniRule>
 
// 3. Ancillary Repricing (부가서비스 가격)
fun repricingWithAncillary(ancillaries: List<Ancillary>): Long
  • OfferPriceRQ는 용도에 따라 다른 Response 파싱

4.3.2 OrderChangeRQ의 다중 용도

// 1. Payment (발권)
fun savePayment(pnr: String, amount: Long, timeoutCallback: () -> Unit): Booking
 
// 2. Seat (좌석 선택)
fun saveSeat(pnr: String, seats: List<SelectedSeat>)
 
// 3. Ancillary (부가서비스)
fun saveAncillary(pnr: String, ancillaries: List<Ancillary>, totalPrice: Long): Booking
 
// 4. Divide (예약 분할)
fun divide(pnr: String, passengers: List<PassengerIdentification>): Booking
 
// 5. Reissue (재발행)
fun reissue(booking: Booking, price: Long, fareItinerary: FareItinerary): Booking
  • OrderChangeRQ는 용도에 따라 다른 Request Body 구성

4.3.3 OrderReshopRQ의 다중 용도

// 1. Refund Calculate (환불 금액 계산)
fun refundCalculate(booking: Booking): List<Passenger>
 
// 2. Reissue Search (재발행 검색)
fun reissueSearch(key: String, booking: Booking, originDestinations: List<OriginDestination>, ...): List<FareItinerary>
 
// 3. Reissue Pricing (재발행 가격 확인)
fun repricingWithReissue(booking: Booking, fareItinerary: FareItinerary): FareItinerary
  • OrderReshopRQ는 용도에 따라 다른 Request Body 구성

4.3.4 재발행(Reissue) 지원

  • NDC 표준의 고유 기능
  • 기존 GDS (Amadeus, Sabre, Galileo)는 재발행 API가 없음
  • 출/도착지 변경 불가, 일정/클래스만 변경 가능

4.3.5 탑승객 번호 재조회

// SingaporeairBookingService.kt:57-60
//OrderCreateRQ의 응답으로 생성된 탑승객 번호가 변경 될수 있으므로 한번더 retrieve 하도록 함.
return singaporeairClient.retrieve(pnr = booking.pnr!!, passengers = passengers)
  • OrderCreateRQ 응답 후 탑승객 identificationKey가 변경될 수 있음
  • 예약 생성 직후 재조회 필수

5. 주의사항

5.1 성능 고려사항

  • 병렬 검색 (Cartesian Product + pmap)
  • Redis 캐싱 (검색 결과, FareItinerary)
  • 별도 압축 없음 (JSON 기본 직렬화)

5.2 에러 처리

  • 검색 에러 710, 367: 정상 처리 (결과 없음)
  • 예약 에러 911: 특별 처리 (SOLD_OUT)
  • 발권 에러 NO_QUOTA: Slack 알림
  • 취소 에러 911 + “PREVIOUSLY CANCELLED”: ALREADY_CANCELED_PNR

5.3 보안

  • WS-Security Password Digest (Nonce + Created + Password)
  • 매 요청마다 새로운 Nonce 생성
  • PseudoCityCode, Username/Password 암호화 저장

5.4 비동기 처리 주의

  • 발권 실패 시 5초 대기 후 비동기 취소
  • 검색 캐시 삭제 비동기 처리

5.5 NDC 표준 제약

  • SQ 항공사만 지원
  • 재발행 시 출/도착지 변경 불가
  • 유아는 부모와 자동으로 이동 (Divide)

5.6 운임 규정 조회

  • CMS 조회 실패 시 Slack 알림
  • 운임 규정이 제대로 조회되지 않을 수 있음

6. Amadeus/Sabre/Galileo와의 차이점 비교

항목Singapore Air (NDC)AmadeusSabreGalileo
공급사 타입Airline Direct (NDC)GDSGDSGDS
API 타입SOAP (NDC 표준)SOAP onlyREST + SOAPREST + SOAP
인증 방식WS-Security Password DigestSession (Security Token)OAuth 2.0 + SessionOAuth 2.0 + Basic Auth
검색 APIAirShoppingRQFareMasterPricerBargainFinderMaxCatalogProductOfferings
운임 규정OfferPriceRQ (MiniRules)CommandCryptic + ARTFareRuleClientAirFareRules + KRT
재발행OrderReshopRQ + OrderChangeRQ없음없음없음
좌석/부가서비스SeatAvailability, ServiceList제한적제한적없음
발권OrderChangeRQ (Payment)DocIssuanceTicketingClientAirTicketing
취소OrderCancelRQVoid/Refund 분리Void/Refund 분리Void/Refund 분리
병렬 처리pmap (Cartesian Product)pmap (Cartesian Product)pmappmap
Circuit Breaker없음있음 (Resilience4j)없음있음
Redis 압축없음 (기본 JSON)Snappy없음Gzip
지원 항공사SQ only다수 항공사다수 항공사다수 항공사
PNR 관리Order 기반 (NDC)PNR 기반 (GDS)PNR 기반 (GDS)PNR 기반 (GDS)
Void 조건voidable 플래그발권일=오늘 + 항공사별 제약발권일=오늘발권일=오늘 + 상태 체크
타임아웃15초 (검색), 60초 (기타)30초 (검색)30초 (검색)30초 (검색)

7. 트러블슈팅 가이드

7.1 일반적인 문제

문제원인해결 방법
710 에러 (검색)NO FARE FOUND정상 처리 (결과 없음)
367 에러 (검색)NO ACTIVE ITINERARY정상 처리 (결과 없음)
911 에러 (예약)NOT AVAILABLE AND WAITLIST CLOSEDSOLD_OUT (재검색 필요)
911 + “PREVIOUSLY CANCELLED”이미 취소된 PNRALREADY_CANCELED_PNR
NO_QUOTAMAXIMUM TICKET LIMIT REACHEDSlack 알림, 항공사 할당량 확인
CMS 조회 실패LOCALIZED CONTENT COULD NOT BE RETRIEVEDSlack 알림, 운임 규정 확인
탑승객 번호 불일치OrderCreateRQ 응답 후 변경Retrieve 재조회
재발행 실패TICKET IS NOT ELIGIBLE FOR EXCHANGE티켓 재발행 불가 상태

7.2 디버깅 팁

  • SOAP 요청/응답 로깅 활성화 (supplierLoggingProperties)
  • Redis 캐시 확인 (검색 결과, FareItinerary)
  • Slack 알림 확인 (에러 발생 시)
  • voidable 플래그 확인 (취소 가능 여부)

7.3 성능 이슈

  • 검색 결과 캐싱 확인
  • 병렬 처리 (pmap) 활용 확인
  • 타임아웃 설정 확인 (검색 15초, 기타 60초)

8. 참고 자료

8.1 주요 클래스

  • SingaporeairClient: SOAP 통신 클라이언트 (846 라인, 핵심)
  • SingaporeairFlightSearchService: 검색 서비스
  • SingaporeairBookingService: 예약 서비스
  • SingaporeairTicketingService: 발권 서비스
  • SingaporeairCancelService: 취소 서비스
  • SingaporeairFareRuleService: 운임 규칙 서비스

8.2 설정 파일

  • application.yml: 기본 설정
  • application-{env}.yml: 환경별 설정
  • redisson-{env}.yml: Redis 설정
  • SingaporeairProperties.kt: Singapore Air 공급사 설정

8.3 상수 파일

  • NonAirEquipment.kt: 비항공 운송 수단 (BUS, TRAIN 등)
  • SingaporeairSoapHeaderNamespace.kt: SOAP Header Namespace

8.4 관련 문서

  • IATA NDC 18.2 Documentation
  • Singapore Airlines NDC Implementation Guide
  • WS-Security Username Token Profile

9. 변경 이력

버전날짜변경 내용작성자
1.02025-09-30최초 작성Claude Code

이 문서는 Triple Air International Adapter 프로젝트의 Singapore Airlines NDC API 분석 문서입니다.