Singapore Airlines 예약 API 심층 분석
1. API 엔드포인트 개요
1.1 예약 관련 엔드포인트
| 엔드포인트 | 메서드 | API 타입 | 기능 | 위치 |
|---|---|---|---|---|
/internals/SINGAPOREAIR/bookings | POST | SOAP (NDC) | 예약 생성 | SingaporeairBookingService.kt:30-61 |
/internals/SINGAPOREAIR/bookings/{pnr} | GET | SOAP (NDC) | 예약 조회 | SingaporeairBookingService.kt:69-71 |
/internals/SINGAPOREAIR/bookings/{pnr}/divide | POST | SOAP (NDC) | 예약 분할 | SingaporeairBookingService.kt:73-84 |
2. 예약 생성 (Create Booking)
2.1 전체 예약 플로우
flowchart TD A[예약 요청 수신] --> B[FareItinerary<br/>조회] B --> C[승객 타입<br/>카운트] C --> D[Step 1: Pricing<br/>singaporeairClient.pricing] D --> E[Step 2: Book<br/>singaporeairClient.book] E --> F[Step 3: Retrieve<br/>탑승객 번호 재조회] F --> G[검색 캐시<br/>제거] G --> H[예약 완료<br/>Booking 반환] %% 에러 플로우 D --> ERR{예외 발생} E --> ERR ERR --> I[예외 재발생] style B fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000 style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style E fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000 style F fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style H fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000 style ERR fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
2.2 단계별 상세 분석
Step 0: FareItinerary 조회 및 준비
위치: SingaporeairBookingService.kt:30-42
fun book(
key: String,
reservationUser: ReservationUser,
passengers: List<Passenger>,
): Booking {
val adult = passengers.count { it.type == PassengerType.ADULT }
val child = passengers.count { it.type == PassengerType.CHILD }
val infant = passengers.count { it.type == PassengerType.INFANT }
val fareItinerary = flightSearchService.getFareItinerary(key = key)
.also {
logger.info("fareItineraryJsonType : ${objectMapper.writeValueAsString(it)}")
}
// ...
}처리 내용:
- Redis에서 FareItinerary 조회 (검색 시 캐싱된 데이터)
- 승객 타입별 인원 카운트 (성인/소아/유아)
- 로깅: FareItinerary JSON 출력
Galileo와의 차이점:
- Galileo: 공항 정보 수집 (
airportMap생성) 필요 - Singapore Air: 공항 정보 수집 불필요 (NDC 표준)
Step 1: Pricing (운임 재계산)
위치: SingaporeairBookingService.kt:44-49
singaporeairClient.pricing(
adult = adult,
child = child,
infant = infant,
fareItinerary = fareItinerary
)SOAP API: SingaporeairClient.kt:215-248
- 엔드포인트: NDC API (OfferPriceRQ)
- 인증: WS-Security Password Digest
- 목적:
- 실시간 운임 재계산 및 유효성 검증
- PassengerFare 정보 획득 (운임/세금/수수료)
- 좌석 가용성 확인
요청 구조 (OfferPriceRQ.of):
OfferPriceRQ.of(
adult = adult,
child = child,
infant = infant,
fareItinerary = fareItinerary,
iataNumber = singaporeairApiProperties.iataCode,
agencyName = singaporeairApiProperties.agencyName
)응답 처리:
response.checkError { code, message ->
throw InternationalAdapterException(ErrorMessage.PRICING_FAILED, code, message).capture()
}
response.pricedOffer.offer.offerItems.first().fareDetails!!.map {
it.toPassengerFare(response.dataList.paxs)
}PassengerFare 구조:
data class PassengerFare(
val type: PassengerType,
val count: Int,
val airPrice: Long, // 운임
val tax: Long, // 세금
val total: Long, // 총액 (airPrice + tax)
val carrierFee: Long?, // 항공사 수수료
)에러 판별:
- 모든 에러:
PRICING_FAILED - Galileo와의 차이: SOLD_OUT 별도 처리 없음 (Book 단계에서 처리)
Step 2: Book (예약 생성)
위치: SingaporeairBookingService.kt:51-55
val booking = singaporeairClient.book(
fareItinerary = fareItinerary,
reservationUser = reservationUser,
passengers = passengers
)SOAP API: SingaporeairClient.kt:250-286
- 엔드포인트: NDC API (OrderCreateRQ)
- 반환값: Booking (PNR 포함)
- 포함 정보:
- 승객 정보 (이름, 생년월일, 성별, 여권)
- 연락처 (이메일, 전화번호)
- FareItinerary 정보 (선택한 항공편)
- IATA 정보 (여행사 코드, 이름)
요청 구조 (OrderCreateRQ.of):
OrderCreateRQ.of(
fareItinerary = fareItinerary,
reservationUser = reservationUser,
passengers = passengers,
iataNumber = singaporeairApiProperties.iataCode,
agencyName = singaporeairApiProperties.agencyName
)응답 처리:
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)에러 코드:
| 코드 | 메시지 | 처리 |
|---|---|---|
| 911 | NOT AVAILABLE AND WAITLIST CLOSED | BOOKING_FAILED (Sentry 캡처) |
| 기타 | … | BOOKING_FAILED |
PNR 구조:
- NDC Order 기반 PNR (6자리 영숫자)
- 예시:
SQ1A2B
Step 3: Retrieve (탑승객 번호 재조회)
위치: SingaporeairBookingService.kt:57-60
//OrderCreateRQ의 응답으로 생성된 탑승객 번호가 변경 될수 있으므로 한번더 retrieve 하도록 함.
return singaporeairClient.retrieve(pnr = booking.pnr!!, passengers = passengers).also {
removeFlightSearchKey(fareItinerary.requestKey)
}탑승객 번호 재조회 이유:
- 배경: OrderCreateRQ 응답 후 탑승객 identificationKey가 변경될 수 있음
- 처리: 예약 생성 직후 OrderRetrieveRQ로 재조회
- 목적: 정확한 탑승객 번호 확보 (발권/취소 시 필수)
Galileo와의 차이점:
| 항목 | Singapore Air | Galileo |
|---|---|---|
| 재조회 이유 | 탑승객 번호 변경 | CarrierTimeLimit 누락 |
| 재조회 시점 | 즉시 | carrierTimeLimit == null 시 |
| 대기 시간 | 없음 | 3초 |
Step 4: 후처리 작업
위치: SingaporeairBookingService.kt:63-67
검색 캐시 제거:
private fun removeFlightSearchKey(key: String) {
CoroutineScope(Dispatchers.IO).withLaunch {
flightSearchKeyRepository.removeKey(key)
}
}- 목적: 예약 완료된 FareItinerary는 더 이상 검색 결과에 노출되지 않음
- 방식: 비동기 처리 (성능 최적화)
Galileo와의 차이점:
- Galileo: FareItinerary PNR 매핑 저장 + 검색 캐시 제거
- Singapore Air: 검색 캐시 제거만 (PNR 매핑 불필요)
3. 예약 조회 (Retrieve)
3.1 전체 조회 플로우
위치: SingaporeairBookingService.kt:69-71
fun retrieve(pnr: String): Booking {
return singaporeairClient.retrieve(pnr)
}SOAP API: SingaporeairClient.kt:288-322
- 엔드포인트: NDC API (OrderRetrieveRQ)
- 파라미터: PNR
- 반환값: Booking
요청 구조 (OrderRetrieveRQ.of):
OrderRetrieveRQ.of(
pnr = pnr,
iataNumber = singaporeairApiProperties.iataCode,
agencyName = singaporeairApiProperties.agencyName
)응답 처리:
orderViewRS.checkError { code, message ->
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)에러 처리:
| 에러 코드 | 메시지 | 처리 |
|---|---|---|
| 911 + “RESERVATION PREVIOUSLY CANCELLED” | 이미 취소된 PNR | ALREADY_CANCELED_PNR |
| 기타 | … | RETRIEVE_FAILED |
Galileo와의 차이점:
| 항목 | Singapore Air | Galileo |
|---|---|---|
| 티켓 조회 | OrderViewRS에 포함 | 별도 API 호출 (getTicketDocuments, getEmdTicketDocuments) |
| EMD 처리 | OrderViewRS에 포함 | 별도 조회 후 매핑 |
| PNR 구조 | Order 기반 (단일) | Universal Record + Provider PNR (계층) |
4. 예약 분할 (Divide)
4.1 Divide 플로우
위치: SingaporeairBookingService.kt:73-84
flowchart TD A[Divide 요청] --> B[예약 조회<br/>retrieve] B --> C[승객 검증<br/>validate] C --> D{유아<br/>포함?} D -->|Yes| E[유아 제외<br/>filter] D -->|No| F[Divide API 호출<br/>singaporeairClient.divide] E --> F F --> G[신규 PNR 반환] G --> H[Retrieve<br/>신규 예약 조회] H --> I[Divide 완료] style C fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style D fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000 style F fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000 style I fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
4.2 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)
}
}유아 처리 특징:
passengers.filter {
it.type != PassengerType.INFANT // 유아는 부모 탑승객 자동으로 따라감(유아 승객 RQ 생성시 오류 발생)
}- 유아는 부모 탑승객과 자동으로 이동
- 유아를 Divide 요청에 포함하면 에러 발생
- NDC 표준의 특징
4.3 Divide 검증 로직
검증 1: 동일 탑승객 유무 확인
위치: SingaporeairBookingService.kt:86-95
private fun validate(requestPassengers: List<PassengerIdentification>, retrievedPassengers: List<Passenger>) {
//동일 탑승객 유무 확인
requestPassengers.forEach { requestPassenger ->
if (retrievedPassengers.none { requestPassenger.isSamePassenger(it) }) {
throw InternationalAdapterException(
ErrorMessage.DIVIDE_FAILED,
ResponseMessage("mismatch passenger ${requestPassenger.firstName} ${requestPassenger.lastName}")
).capture()
}
}
// ...
}isSamePassenger 로직:
private fun PassengerIdentification.isSamePassenger(passenger: Passenger): Boolean {
return this.firstName == passenger.firstName &&
this.lastName == passenger.lastName &&
this.gender == passenger.gender &&
this.type == passenger.type
}검증 2: 유아-성인 페어 검증
위치: SingaporeairBookingService.kt:98-127
//유아연결된 성인 탑승객 유무 확인
requestPassengers.forEach { requestPassenger ->
when (requestPassenger.type) {
PassengerType.ADULT -> {
retrievedPassengers.find {
it.parentIdentificationKey == requestPassenger.identificationKey
}?.also { retrievedInfantPassenger ->
if (requestPassengers.none { it.identificationKey == retrievedInfantPassenger.identificationKey }) {
throw InternationalAdapterException(
ErrorMessage.DIVIDE_FAILED,
ResponseMessage("adult or infant pair mismatch ${retrievedInfantPassenger.identificationKey}")
).capture()
}
}
}
PassengerType.INFANT -> {
val retrievedInfantPassenger = retrievedPassengers.first { retrievedPassenger ->
retrievedPassenger.identificationKey == requestPassenger.identificationKey
}
if (requestPassengers.none { it.identificationKey == retrievedInfantPassenger.parentIdentificationKey }) {
throw InternationalAdapterException(
ErrorMessage.DIVIDE_FAILED,
ResponseMessage("adult or infant pair mismatch ${retrievedInfantPassenger.parentIdentificationKey}")
).capture()
}
}
else -> Unit
}
}검증 내용:
- 성인 탑승객: 연결된 유아가 있으면 함께 이동해야 함
- 유아 탑승객: 부모 탑승객과 함께 이동해야 함
- 페어가 맞지 않으면
DIVIDE_FAILED예외 발생
Galileo와의 차이점:
| 항목 | Singapore Air | Galileo |
|---|---|---|
| 유아 처리 | 자동으로 부모 따라감 | N/A (유소아 Divide 불가) |
| 검증 로직 | 유아-성인 페어 검증 | 유소아 존재 시 DIVIDE_FAILED |
| API | OrderChangeRQ.ofDivide | ProviderReservationDivideRQ |
5. Request/Response 구조 상세
5.1 OrderCreateRQ 구조
위치: infrastructure/request/OrderCreateRQ.kt
주요 필드:
data class OrderCreateRQ(
val query: Query,
val party: Party,
) : SingaporeairRequest {
override val action = "http://www.iata.org/IATA/2015/00/2018.2/OrderCreateRQ"
data class Query(
val order: CreateOrder,
)
companion object {
fun of(
fareItinerary: FareItinerary,
reservationUser: ReservationUser,
passengers: List<Passenger>,
iataNumber: String,
agencyName: String,
): OrderCreateRQ {
return OrderCreateRQ(
query = Query(
order = CreateOrder.of(
fareItinerary = fareItinerary,
reservationUser = reservationUser,
passengers = passengers,
iataNumber = iataNumber,
agencyName = agencyName
)
),
party = Party.of(
reservationUser = reservationUser,
passengers = passengers
)
)
}
}
}CreateOrder 구조:
data class CreateOrder(
val selectedOffer: SelectedOffer, // 선택한 항공편
val contactInfos: List<ContactInfo>, // 연락처
val paxs: List<Pax>, // 승객 정보
val pointOfSale: PointOfSale, // 판매 지점
)SelectedOffer 구조:
data class SelectedOffer(
val selectedOfferItem: SelectedOfferItem,
val shoppingResponseRefId: ShoppingResponseRefId, // AirShopping 응답 참조
)
data class SelectedOfferItem(
val offerId: OfferId, // Offer ID
val offerItemRefId: OfferItemRefId, // OfferItem ID
)Pax (승객) 구조:
data class Pax(
val paxId: PaxId,
val ptc: PTC, // ADT, CHD, INF
val individual: Individual, // 개인 정보
val identityDoc: IdentityDoc?, // 여권 정보
)
data class Individual(
val givenName: GivenName, // 이름
val surname: Surname, // 성
val birthdate: Birthdate, // 생년월일
val gender: Gender, // 성별
)
data class IdentityDoc(
val identityDocId: IdentityDocId,
val identityDocType: IdentityDocType, // PT (여권)
val identityDocNbr: IdentityDocNbr, // 여권 번호
val expiryDate: ExpiryDate, // 만료일
val birthCountry: BirthCountry, // 발행 국가
)5.2 OrderViewRS 구조 (Book, Retrieve 공통)
위치: infrastructure/response/OrderViewRS.kt
주요 필드:
data class OrderViewRS(
val response: Response?,
val errors: List<Error>?,
) {
data class Response(
val order: Order,
val dataList: DataList,
val warnings: List<Warning>?,
)
}Order 구조:
data class Order(
val orderId: OrderId, // PNR
val orderItems: OrderItems, // 주문 항목 (항공편)
val ownerCode: OwnerCode, // SQ
val statusCode: StatusCode, // CONFIRMED, TICKETED, CANCELLED
val totalPrice: TotalPrice, // 총 가격
val bookingRefs: BookingRefs?, // 항공사 예약 번호
val paymentInfo: List<PaymentInfo>?, // 결제 정보
val ticketDocInfos: List<TicketDocInfo>?, // 티켓 정보
)OrderItems 구조:
data class OrderItems(
val orderItem: List<OrderItem>,
)
data class OrderItem(
val orderItemId: OrderItemId, // 항목 ID
val fareDetail: FareDetail, // 운임 상세
val services: List<Service>, // 서비스 (항공편)
val associations: Associations?, // 연관 정보 (승객)
)BookingRefs (항공사 예약 번호):
data class BookingRefs(
val bookingRef: List<BookingRef>,
)
data class BookingRef(
val bookingRefId: BookingRefId, // 항공사 PNR
val airlineId: AirlineId, // SQ
val otherID: OtherID?, // 기타 ID
)TicketDocInfos (티켓 정보):
data class TicketDocInfos(
val ticketDocInfo: List<TicketDocInfo>,
)
data class TicketDocInfo(
val ticketDocNbr: TicketDocNbr, // 티켓 번호 (13자리)
val ticketDocTypeCode: TicketDocTypeCode, // 701 (E-Ticket), 702 (EMD)
val paxRefId: PaxRefId, // 승객 참조 ID
val reportingTypeCode: ReportingTypeCode?, // RPT
)5.3 OrderRetrieveRQ 구조
위치: infrastructure/request/OrderRetrieveRQ.kt
주요 필드:
data class OrderRetrieveRQ(
val query: Query,
) : SingaporeairRequest {
override val action = "http://www.iata.org/IATA/2015/00/2018.2/OrderRetrieveRQ"
data class Query(
val orderFilterCriteria: OrderFilterCriteria,
)
companion object {
fun of(
pnr: String,
iataNumber: String,
agencyName: String,
): OrderRetrieveRQ {
return OrderRetrieveRQ(
query = Query(
orderFilterCriteria = OrderFilterCriteria(
orderId = OrderId(value = pnr),
pointOfSale = PointOfSale(
country = Country(countryCode = "KR"),
agencyID = AgencyID(value = iataNumber),
agencyName = AgencyName(value = agencyName),
)
)
)
)
}
}
}OrderFilterCriteria 구조:
data class OrderFilterCriteria(
val orderId: OrderId, // PNR
val pointOfSale: PointOfSale, // 판매 지점
)5.4 OrderChangeRQ.ofDivide 구조
위치: infrastructure/request/OrderChangeRQ.kt
주요 필드:
data class OrderChangeRQ(
val query: Query,
) : SingaporeairRequest {
override val action = "http://www.iata.org/IATA/2015/00/2018.2/OrderChangeRQ"
companion object {
fun ofDivide(
pnr: String,
passengers: List<PassengerIdentification>,
iataNumber: String,
agencyName: String,
): OrderChangeRQ {
return OrderChangeRQ(
query = Query(
order = ChangeOrder.ofDivide(
pnr = pnr,
passengers = passengers,
iataNumber = iataNumber,
agencyName = agencyName
)
)
)
}
}
}ChangeOrder.ofDivide 구조:
data class ChangeOrder(
val actionType: ActionType, // DIVIDE
val orderId: OrderId, // 원본 PNR
val divideOrderItems: List<DivideOrderItem>?,
) {
companion object {
fun ofDivide(
pnr: String,
passengers: List<PassengerIdentification>,
iataNumber: String,
agencyName: String,
): ChangeOrder {
return ChangeOrder(
actionType = ActionType(value = "DIVIDE"),
orderId = OrderId(value = pnr),
divideOrderItems = passengers.map { passenger ->
DivideOrderItem(
paxRefId = PaxRefId(value = passenger.identificationKey)
)
}
)
}
}
}6. 에러 처리 및 재시도 메커니즘
6.1 에러 타입별 처리 매트릭스
| 에러 타입 | 발생 시점 | 처리 방법 | Sentry 캡처 |
|---|---|---|---|
PRICING_FAILED | Pricing | InternationalAdapterException | Yes |
BOOKING_FAILED | Book | InternationalAdapterException | 911일 때 |
RETRIEVE_FAILED | Retrieve | InternationalAdapterException | No |
ALREADY_CANCELED_PNR | Retrieve | InternationalAdapterException | No |
DIVIDE_FAILED | Divide | InternationalAdapterException | Yes |
6.2 SOAP Fault 처리
위치: ClientSupport.kt
response.fold(
success = { response ->
response.checkError { code, message ->
throw InternationalAdapterException(
ErrorMessage.BOOKING_FAILED,
code,
message
)
}
// 성공 처리
},
failure = { failure ->
throw failure.handleSoapFaultException(ErrorMessage.BOOKING_FAILED)
}
)handleSoapFaultException:
fun Failure.handleSoapFaultException(errorMessage: ErrorMessage, vararg args: Any?): InternationalAdapterException {
return if (this.exception is SoapFaultException) {
InternationalAdapterException(
errorMessage,
*args,
this.exception.faultCode,
this.exception.faultString
)
} else {
InternationalAdapterException(errorMessage, *args, this.exception)
}
}6.3 재시도 메커니즘
현재 상태: 재시도 로직 없음
Galileo와의 차이점:
- Galileo: CarrierTimeLimit 누락 시 3초 대기 후 재조회
- Singapore Air: 탑승객 번호 변경 시 즉시 재조회 (대기 없음)
7. GDS와의 차이점 비교
7.1 예약 플로우 비교
| 항목 | Singapore Air (NDC) | Galileo | Amadeus | Sabre |
|---|---|---|---|---|
| Session 관리 | Stateless (WS-Security) | Stateless (Basic Auth) | Stateful (Session Token) | Stateful (Session Token) |
| Pricing 단계 | 필수 (OfferPriceRQ) | 필수 (AirPriceRQ) | 필수 (FarePricePNR) | 필수 (PriceQuote) |
| 예약 API | OrderCreateRQ | AirCreateReservationRQ | PNR_AddMultiElements | EnhancedAirBookRQ + PassengerDetailsRQ |
| PNR 구조 | Order 기반 (단일) | Universal Record + Provider PNR | PNR | Provider PNR |
| 재조회 이유 | 탑승객 번호 변경 | CarrierTimeLimit 누락 | 없음 | 없음 |
| 재조회 대기 | 없음 | 3초 | N/A | N/A |
| Divide | OrderChangeRQ.ofDivide | ProviderReservationDivideRQ | N/A | N/A |
| 유아 처리 | 자동으로 부모 따라감 | 유소아 Divide 불가 | N/A | N/A |
7.2 에러 처리 비교
| 에러 시나리오 | Singapore Air | Galileo | Amadeus | Sabre |
|---|---|---|---|---|
| 좌석 매진 | 911 (Book 단계) | “are not bookable” (Pricing 단계) | “NO FARE FOR CLASS USED” | Schedule confirmed 체크 |
| 이미 취소 | 911 + “PREVIOUSLY CANCELLED” | ALREADY_CANCELED_PNR | ALREADY_CANCELED_PNR | ALREADY_CANCELED_PNR |
| Divide 제약 | 유아-성인 페어 검증 | 유소아 Divide 불가 | N/A | N/A |
7.3 독특한 특징
Singapore Airlines NDC 고유 특징
-
Order 기반 PNR 관리:
- NDC 표준의 Order 개념 사용
- GDS의 Universal Record/Provider PNR 구조와 다름
-
탑승객 번호 재조회:
- OrderCreateRQ 응답 후 identificationKey가 변경될 수 있음
- 즉시 재조회 필요 (대기 시간 없음)
-
유아 자동 이동:
- Divide 시 유아는 부모 탑승객과 자동으로 이동
- 유아를 Divide 요청에 포함하면 에러 발생
-
NDC 표준 준수:
- IATA NDC 18.2 표준 사용
- GDS와 다른 메시지 구조 (AirShopping, OfferPrice, OrderCreate 등)
-
통합 응답 구조:
- OrderViewRS에 예약/티켓 정보 모두 포함
- Galileo처럼 별도 티켓 조회 API 불필요
8. 성능 최적화
8.1 비동기 처리
위치: SingaporeairBookingService.kt:63-67
private fun removeFlightSearchKey(key: String) {
CoroutineScope(Dispatchers.IO).withLaunch {
flightSearchKeyRepository.removeKey(key)
}
}비동기 처리 대상:
- 검색 캐시 제거
성능 향상:
- Redis 작업 시 API 응답 지연 방지
- I/O 병렬 처리
Galileo와의 차이점:
- Galileo: FareItinerary PNR 매핑 저장 + 검색 캐시 제거 (2개 작업)
- Singapore Air: 검색 캐시 제거만 (1개 작업)
8.2 즉시 재조회
- 대기 시간 없이 즉시 재조회
- Galileo의 3초 대기보다 빠름
9. 주요 발견사항
9.1 Singapore Airlines NDC 예약 시스템의 특징
- Stateless 아키텍처: WS-Security 기반 인증, Session Token 불필요
- Order 기반 PNR: NDC 표준의 Order 개념 사용
- 탑승객 번호 변경: OrderCreateRQ 응답 후 재조회 필수
- 유아 자동 이동: Divide 시 유아는 부모와 자동 이동
- 통합 응답: OrderViewRS에 예약/티켓 정보 모두 포함
9.2 개선 가능 영역
1. 탑승객 번호 재조회 로깅
현황: 주석만 존재
//OrderCreateRQ의 응답으로 생성된 탑승객 번호가 변경 될수 있으므로 한번더 retrieve 하도록 함.제안: 로깅 추가
logger.info("[SINGAPOREAIR] Passenger identificationKey may have changed, retrieving order: $pnr")
return singaporeairClient.retrieve(pnr = booking.pnr!!, passengers = passengers).also {
logger.info("[SINGAPOREAIR] Order retrieved successfully: $pnr")
removeFlightSearchKey(fareItinerary.requestKey)
}2. Divide 검증 로직 문서화
현황: 코드 내 검증만 존재
제안: API 명세서에 명시
- 유아는 자동으로 부모와 이동
- 유아-성인 페어 검증 필수
- 유아를 Divide 요청에 포함하면 에러 발생
3. 에러 코드 911 특별 처리
현황: Sentry 캡처만
throw InternationalAdapterException(ErrorMessage.BOOKING_FAILED, code, message).apply {
if (code == "911") this.capture()
}제안: 로깅 추가
if (code == "911") {
logger.warn("[SINGAPOREAIR] SOLD_OUT: NOT AVAILABLE AND WAITLIST CLOSED, PNR: $pnr")
this.capture()
}10. 참고 자료
10.1 주요 클래스
SingaporeairBookingService.kt: 예약 서비스 (30-136)SingaporeairClient.kt: SOAP 클라이언트 (250-322, 588-615)OrderCreateRQ.kt: 예약 요청 모델OrderRetrieveRQ.kt: 조회 요청 모델OrderChangeRQ.kt: 변경 요청 모델 (Divide)OrderViewRS.kt: 예약/조회 응답 모델
10.2 주요 메소드
SingaporeairBookingService.book(): 예약 생성 (30-61)SingaporeairBookingService.retrieve(): 예약 조회 (69-71)SingaporeairBookingService.divide(): 예약 분할 (73-84)SingaporeairClient.book(): SOAP 예약 API (250-286)SingaporeairClient.retrieve(): SOAP 조회 API (288-322)SingaporeairClient.divide(): SOAP 분할 API (588-615)
10.3 관련 API
- OfferPriceRQ: 운임 재계산
- OrderCreateRQ: 예약 생성
- OrderRetrieveRQ: 예약 조회
- OrderChangeRQ: 예약 변경 (Divide)
- OrderViewRS: 예약/조회 응답
이 문서는 Triple Air International Adapter 프로젝트의 Singapore Airlines 예약 API 심층 분석 문서입니다.