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 생성 과정
- Nonce 생성 (Base64 인코딩된 랜덤 바이트)
- Created Timestamp 생성 (UTC 시간)
- Password Digest = Base64(SHA1(Nonce + Created + Password))
- SOAP Header에 Username, Password Digest, Nonce, Created 포함
인증 구조
- PseudoCityCode: Singapore Air에서 발급한 에이전시 코드
- Username/Password: WS-Security 인증 정보
- IATA Code: 여행사 IATA 번호
- Agency Name: 여행사명
2. API 엔드포인트별 상세 분석
2.1 항공편 검색 API (Flight Search)
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) | Amadeus | Sabre | Galileo |
|---|---|---|---|---|
| 공급사 타입 | Airline Direct (NDC) | GDS | GDS | GDS |
| API 타입 | SOAP (NDC 표준) | SOAP only | REST + SOAP | REST + SOAP |
| 인증 방식 | WS-Security Password Digest | Session (Security Token) | OAuth 2.0 + Session | OAuth 2.0 + Basic Auth |
| 검색 API | AirShoppingRQ | FareMasterPricer | BargainFinderMax | CatalogProductOfferings |
| 운임 규정 | OfferPriceRQ (MiniRules) | CommandCryptic + ART | FareRuleClient | AirFareRules + KRT |
| 재발행 | OrderReshopRQ + OrderChangeRQ | 없음 | 없음 | 없음 |
| 좌석/부가서비스 | SeatAvailability, ServiceList | 제한적 | 제한적 | 없음 |
| 발권 | OrderChangeRQ (Payment) | DocIssuance | TicketingClient | AirTicketing |
| 취소 | OrderCancelRQ | Void/Refund 분리 | Void/Refund 분리 | Void/Refund 분리 |
| 병렬 처리 | pmap (Cartesian Product) | pmap (Cartesian Product) | pmap | pmap |
| 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 CLOSED | SOLD_OUT (재검색 필요) |
| 911 + “PREVIOUSLY CANCELLED” | 이미 취소된 PNR | ALREADY_CANCELED_PNR |
| NO_QUOTA | MAXIMUM TICKET LIMIT REACHED | Slack 알림, 항공사 할당량 확인 |
| CMS 조회 실패 | LOCALIZED CONTENT COULD NOT BE RETRIEVED | Slack 알림, 운임 규정 확인 |
| 탑승객 번호 불일치 | 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.0 | 2025-09-30 | 최초 작성 | Claude Code |
이 문서는 Triple Air International Adapter 프로젝트의 Singapore Airlines NDC API 분석 문서입니다.