Singapore Airlines 예약 API 심층 분석

1. API 엔드포인트 개요

1.1 예약 관련 엔드포인트

엔드포인트메서드API 타입기능위치
/internals/SINGAPOREAIR/bookingsPOSTSOAP (NDC)예약 생성SingaporeairBookingService.kt:30-61
/internals/SINGAPOREAIR/bookings/{pnr}GETSOAP (NDC)예약 조회SingaporeairBookingService.kt:69-71
/internals/SINGAPOREAIR/bookings/{pnr}/dividePOSTSOAP (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)}")
        }
    // ...
}

처리 내용:

  1. Redis에서 FareItinerary 조회 (검색 시 캐싱된 데이터)
  2. 승객 타입별 인원 카운트 (성인/소아/유아)
  3. 로깅: 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
  • 목적:
    1. 실시간 운임 재계산 및 유효성 검증
    2. PassengerFare 정보 획득 (운임/세금/수수료)
    3. 좌석 가용성 확인

요청 구조 (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)

에러 코드:

코드메시지처리
911NOT AVAILABLE AND WAITLIST CLOSEDBOOKING_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 AirGalileo
재조회 이유탑승객 번호 변경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”이미 취소된 PNRALREADY_CANCELED_PNR
기타RETRIEVE_FAILED

Galileo와의 차이점:

항목Singapore AirGalileo
티켓 조회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
    }
}

검증 내용:

  1. 성인 탑승객: 연결된 유아가 있으면 함께 이동해야 함
  2. 유아 탑승객: 부모 탑승객과 함께 이동해야 함
  3. 페어가 맞지 않으면 DIVIDE_FAILED 예외 발생

Galileo와의 차이점:

항목Singapore AirGalileo
유아 처리자동으로 부모 따라감N/A (유소아 Divide 불가)
검증 로직유아-성인 페어 검증유소아 존재 시 DIVIDE_FAILED
APIOrderChangeRQ.ofDivideProviderReservationDivideRQ

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_FAILEDPricingInternationalAdapterExceptionYes
BOOKING_FAILEDBookInternationalAdapterException911일 때
RETRIEVE_FAILEDRetrieveInternationalAdapterExceptionNo
ALREADY_CANCELED_PNRRetrieveInternationalAdapterExceptionNo
DIVIDE_FAILEDDivideInternationalAdapterExceptionYes

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)GalileoAmadeusSabre
Session 관리Stateless (WS-Security)Stateless (Basic Auth)Stateful (Session Token)Stateful (Session Token)
Pricing 단계필수 (OfferPriceRQ)필수 (AirPriceRQ)필수 (FarePricePNR)필수 (PriceQuote)
예약 APIOrderCreateRQAirCreateReservationRQPNR_AddMultiElementsEnhancedAirBookRQ + PassengerDetailsRQ
PNR 구조Order 기반 (단일)Universal Record + Provider PNRPNRProvider PNR
재조회 이유탑승객 번호 변경CarrierTimeLimit 누락없음없음
재조회 대기없음3초N/AN/A
DivideOrderChangeRQ.ofDivideProviderReservationDivideRQN/AN/A
유아 처리자동으로 부모 따라감유소아 Divide 불가N/AN/A

7.2 에러 처리 비교

에러 시나리오Singapore AirGalileoAmadeusSabre
좌석 매진911 (Book 단계)“are not bookable” (Pricing 단계)“NO FARE FOR CLASS USED”Schedule confirmed 체크
이미 취소911 + “PREVIOUSLY CANCELLED”ALREADY_CANCELED_PNRALREADY_CANCELED_PNRALREADY_CANCELED_PNR
Divide 제약유아-성인 페어 검증유소아 Divide 불가N/AN/A

7.3 독특한 특징

Singapore Airlines NDC 고유 특징

  1. Order 기반 PNR 관리:

    • NDC 표준의 Order 개념 사용
    • GDS의 Universal Record/Provider PNR 구조와 다름
  2. 탑승객 번호 재조회:

    • OrderCreateRQ 응답 후 identificationKey가 변경될 수 있음
    • 즉시 재조회 필요 (대기 시간 없음)
  3. 유아 자동 이동:

    • Divide 시 유아는 부모 탑승객과 자동으로 이동
    • 유아를 Divide 요청에 포함하면 에러 발생
  4. NDC 표준 준수:

    • IATA NDC 18.2 표준 사용
    • GDS와 다른 메시지 구조 (AirShopping, OfferPrice, OrderCreate 등)
  5. 통합 응답 구조:

    • 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 예약 시스템의 특징

  1. Stateless 아키텍처: WS-Security 기반 인증, Session Token 불필요
  2. Order 기반 PNR: NDC 표준의 Order 개념 사용
  3. 탑승객 번호 변경: OrderCreateRQ 응답 후 재조회 필수
  4. 유아 자동 이동: Divide 시 유아는 부모와 자동 이동
  5. 통합 응답: 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 심층 분석 문서입니다.