Singapore Airlines 재발행 API 심층 분석

1. API 엔드포인트 개요

1.1 재발행 관련 엔드포인트

엔드포인트메서드API 타입기능위치
/internals/SINGAPOREAIR/reissue/searchPOSTSOAP (NDC)재발행 검색SingaporeairFlightSearchService.kt:101-140
/internals/SINGAPOREAIR/reissue/detailPOSTSOAP (NDC)재발행 상세 조회SingaporeairFlightSearchService.kt:142-169
/internals/SINGAPOREAIR/ticketing/reissuePOSTSOAP (NDC)재발행 실행SingaporeairTicketingService.kt:64-95

2.1 전체 플로우

flowchart TD
    A[재발행 검색 요청] --> B[예약 조회<br/>retrieve]
    B --> C[검색 키 생성<br/>CacheKeyGenerator]
    C --> D[OriginDestination<br/>변환]
    D --> E[변경하지 않을 구간<br/>remainItem 추출]
    E --> F[재발행 검색<br/>reissueSearch]
    F --> G[SOAP 요청<br/>OrderReshopRQ]
    G --> H{에러 체크}
    H -->|에러| I[REISSUE_SEARCH_FAILED<br/>예외 발생]
    H -->|정상| J[응답 파싱<br/>toFareItineraries]
    J --> K{캐싱 조건<br/>확인}
    K -->|useCache=true &<br/>결과 존재| L[Redis 저장]
    K -->|조건 미충족| M[응답 반환]
    L --> M

    style B fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
    style E fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style G fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000
    style H fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style I fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style M fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

2.2 단계별 상세 분석

Step 1: 예약 조회

위치: SingaporeairFlightSearchService.kt:119

val booking = singaporeairClient.retrieve(pnr)

Step 2: 검색 키 생성

위치: SingaporeairFlightSearchService.kt:120

val key = CacheKeyGenerator.generateFareItineraryKey(Supplier.SINGAPOREAIR)

Step 3: OriginDestination 변환

위치: SingaporeairFlightSearchService.kt:122-127

val originDestinations = originDestinationInfos.map {
    OriginDestination(
        origin = it.origin,
        destination = it.destination,
        departureDate = it.departureDate,
        // ...
    )
}

Step 4: remainItem 추출 (변경하지 않을 구간)

위치: SingaporeairFlightSearchService.kt:129-136

// 변경하지 않을 item (유지되는 구간)
val remainItem = booking.schedules!!.groupBy { it.groupSequence }
    .filterNot { (_, segments) ->
        originDestinations.any {
            it.origin == segments.first().departure && it.destination == segments.last().arrival
        }
    }.values.flatten()

로직 설명:

  1. 예약의 모든 스케줄을 groupSequence로 그룹화 (왕복: 1, 2)
  2. 변경 요청한 구간과 일치하는 그룹 제외
  3. 남은 그룹을 flatten하여 유지할 구간 추출

예시:

원본 예약: ICN-SIN (groupSequence=1), SIN-ICN (groupSequence=2)
변경 요청: ICN-SIN (새 날짜)
remainItem: SIN-ICN (groupSequence=2)

Step 5: reissueSearch API

위치: 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 singaporeairApiProperties = singaporeairProperties.getApiProperties()
 
    val request = OrderReshopRQ.ofReissueSearch(
        booking = booking,
        originDestinations = originDestinations,
        remainItem = remainItem,
        iataNumber = singaporeairApiProperties.iataCode,
        agencyName = singaporeairApiProperties.agencyName,
        cabins = cabins
    )
 
    return singaporeairApiProperties.endpoint
        .post(request)
        .header(getHeaderMap(request))
        .requestBodyConvert(soapRequestBodyConverter(singaporeairApiProperties))
        .execute<OrderReshopRS>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { orderReshopRS ->
                orderReshopRS.checkError { errors ->
                    throw InternationalAdapterException(
                        ErrorMessage.REISSUE_SEARCH_FAILED,
                        ResponseMessage(
                            "검색 오류가 발생했습니다." +
                                    (errors.mapNotNull { error -> error.descText }
                                        .joinToString("\n")
                                        .takeIf { it.isNotEmpty() }
                                        ?.let { "\n공급사 메시지:\n${it}" } ?: "")
                        ),
                    ).capture()
                }
                orderReshopRS.response!!.toFareItineraries(
                    key = key,
                    onlyDirect = onlyDirect,
                    remainServiceIds = remainItem.flatMap { it.referenceServiceIds }
                )
            },
            failure = {
                throw it.exception
            }
        )
}

에러 메시지 처리:

"검색 오류가 발생했습니다." +
    (errors.mapNotNull { error -> error.descText }
        .joinToString("\n")
        .takeIf { it.isNotEmpty() }
        ?.let { "\n공급사 메시지:\n${it}" } ?: "")
  • 공급사 에러 메시지를 사용자 친화적으로 변환
  • 여러 에러 메시지를 줄바꿈으로 구분

3. 재발행 상세 조회 (Reissue Detail)

3.1 전체 플로우

flowchart TD
    A[재발행 상세 요청] --> B[FareItinerary<br/>조회]
    B --> C{다운그레이드<br/>가격?}
    C -->|Yes| D[CHANGED_DOWN_GRADE_PRICE<br/>예외 발생]
    C -->|No| E[예약 조회<br/>retrieve]
    E --> F[변경 항목 검증<br/>출발/도착/시간/편명/클래스]
    F --> G{변경 사항<br/>있음?}
    G -->|No| H[NON_CHANGEABLE_SCHEDULES<br/>예외 발생]
    G -->|Yes| I[Repricing<br/>repricingWithReissue]
    I --> J[재발행 상세<br/>반환]

    style B fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
    style C fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style D fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style F fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000
    style G fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style H fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style J fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

3.2 단계별 상세 분석

Step 1: FareItinerary 조회

위치: SingaporeairFlightSearchService.kt:143

val fareItinerary = getFareItinerary(key)

Step 2: 다운그레이드 가격 검증

위치: SingaporeairFlightSearchService.kt:145-150

// 다운그레이드 가격 검증
fareItinerary.passengerFares.forEach {
    if (it.tax < 0 || it.total < 0) {
        throw InternationalAdapterException(ErrorMessage.CHANGED_DOWN_GRADE_PRICE)
    }
}

다운그레이드:

  • 운임이 기존보다 낮아지는 경우
  • tax < 0 또는 total < 0
  • 환불이 발생하는 재발행은 불가

Step 3: 예약 조회

위치: SingaporeairFlightSearchService.kt:152

val booking = singaporeairClient.retrieve(pnr)

Step 4: 변경 항목 검증

위치: SingaporeairFlightSearchService.kt:154-168

// 출/도착지, 출발시간, 도착시간, 편명, 클래스 변경 여부 확인
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)
            }
        }
    }

검증 내용:

  1. remainServiceIds에 없는 스케줄만 검증 (변경 대상)
  2. 출발/도착지가 동일한 세그먼트 찾기
  3. 다음 항목이 모두 동일하면 NON_CHANGEABLE_SCHEDULES 예외:
    • 출발 시간 (departureAt)
    • 도착 시간 (arrivalAt)
    • 편명 (flightNumber)
    • 좌석 등급 (cabin)

목적:

  • 실질적인 변경 사항이 있는지 확인
  • 같은 항공편으로 재발행 방지

Step 5: Repricing

위치: SingaporeairFlightSearchService.kt:170

return singaporeairClient.repricingWithReissue(booking, fareItinerary)

4. 재발행 실행 (Reissue)

4.1 전체 플로우

위치: SingaporeairTicketingService.kt:64-95

(발권 API 문서에서 이미 다룬 내용과 동일)

flowchart TD
    A[재발행 요청] --> B[원본 예약 조회<br/>retrieve]
    B --> C[FareItinerary<br/>조회]
    C --> D[Repricing<br/>repricingWithReissue]
    D --> E{가격 일치<br/>확인}
    E -->|불일치| F[RETICKETING_FAILED_BY_MISMATCH_PRICE]
    E -->|일치| G[재발행 실행<br/>reissue]
    G --> H[재발행 완료<br/>ReissueResult 반환]

    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:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style H fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

5. Request/Response 구조 상세

5.1 OrderReshopRQ.ofReissueSearch 구조

위치: infrastructure/request/OrderReshopRQ.kt

data class OrderReshopRQ(
    val query: Query,
) : SingaporeairRequest {
    override val action = "http://www.iata.org/IATA/2015/00/2018.2/OrderReshopRQ"
 
    companion object {
        fun ofReissueSearch(
            booking: Booking,
            originDestinations: List<OriginDestination>,
            remainItem: List<Schedule>,
            iataNumber: String,
            agencyName: String,
            cabins: List<CabinType>,
        ): OrderReshopRQ {
            return OrderReshopRQ(
                query = Query(
                    order = Order.ofReissueSearch(
                        booking = booking,
                        originDestinations = originDestinations,
                        remainItem = remainItem,
                        iataNumber = iataNumber,
                        agencyName = agencyName,
                        cabins = cabins
                    )
                )
            )
        }
    }
}

Order.ofReissueSearch 구조:

data class Order(
    val orderId: OrderId,                  // 원본 PNR
    val deleteOrderItems: List<DeleteOrderItem>?, // 삭제할 항목 (변경 구간)
    val reshopCriteria: ReshopCriteria?,   // 재검색 조건
    val pointOfSale: PointOfSale,         // 판매 지점
) {
    companion object {
        fun ofReissueSearch(
            booking: Booking,
            originDestinations: List<OriginDestination>,
            remainItem: List<Schedule>,
            iataNumber: String,
            agencyName: String,
            cabins: List<CabinType>,
        ): Order {
            // 변경할 구간의 orderItemId 추출
            val deleteOrderItems = booking.schedules!!
                .filterNot { remainItem.contains(it) }
                .map { DeleteOrderItem(orderItemRefId = OrderItemRefId(value = it.orderItemId)) }
 
            return Order(
                orderId = OrderId(value = booking.pnr),
                deleteOrderItems = deleteOrderItems,
                reshopCriteria = ReshopCriteria(
                    originDestinations = originDestinations.map { OriginDestination.of(it) },
                    cabins = cabins.map { CabinTypeCode(code = it.singaporeairCabinCode) },
                    paxs = booking.passengers!!.map { Pax.of(it) }
                ),
                pointOfSale = PointOfSale(
                    country = Country(countryCode = "KR"),
                    agencyID = AgencyID(value = iataNumber),
                    agencyName = AgencyName(value = agencyName),
                )
            )
        }
    }
}

ReshopCriteria 구조:

data class ReshopCriteria(
    val originDestinations: List<OriginDestination>, // 변경할 구간
    val cabins: List<CabinTypeCode>,                // 좌석 등급
    val paxs: List<Pax>,                            // 승객 정보
)

5.2 OrderReshopRS 구조 (재발행 검색)

위치: infrastructure/response/OrderReshopRS.kt

data class OrderReshopRS(
    val response: Response?,
    val errors: List<Error>?,
) {
    data class Response(
        val reshopResult: ReshopResult,    // 재구매 결과
        val dataList: DataList,            // 참조 데이터
    )
}

ReshopResult 구조:

data class ReshopResult(
    val reshopOffers: List<ReshopOffer>,   // 재구매 제안
)
 
data class ReshopOffer(
    val id: String,
    val addOrderItems: List<AddOrderItem>?,      // 추가할 항목 (새 항공편)
    val deleteOrderItems: List<DeleteOrderItem>?, // 삭제할 항목 (기존 항공편)
    val priceComparison: PriceComparison?,       // 가격 비교
)

toFareItineraries 변환: 위치: OrderReshopRS.kt (extension function)

fun Response.toFareItineraries(
    key: String,
    onlyDirect: Boolean,
    remainServiceIds: List<String>,
): List<FareItinerary> {
    return this.reshopResult.reshopOffers.flatMap { reshopOffer ->
        reshopOffer.addOrderItems?.map { addOrderItem ->
            FareItinerary(
                key = key,
                schedules = addOrderItem.services.map { service ->
                    service.toSchedule(this.dataList.paxSegments, this.dataList.paxJourneys)
                },
                passengerFares = addOrderItem.fareDetails.map { it.toPassengerFare(this.dataList.paxs) },
                remainServiceIds = remainServiceIds,  // 유지할 구간 ID
                // ...
            )
        } ?: emptyList()
    }
}

remainServiceIds:

  • 변경하지 않을 구간의 Service ID 리스트
  • 재발행 상세 조회 시 검증에 사용

5.3 OrderReshopRQ.ofPricing 구조 (재발행 Pricing)

위치: infrastructure/request/OrderReshopRQ.kt

companion object {
    fun ofPricing(
        booking: Booking,
        iataNumber: String,
        agencyName: String,
        fareItinerary: FareItinerary,
    ): OrderReshopRQ {
        return OrderReshopRQ(
            query = Query(
                order = Order.ofPricing(
                    booking = booking,
                    fareItinerary = fareItinerary,
                    iataNumber = iataNumber,
                    agencyName = agencyName
                )
            )
        )
    }
}

5.4 OrderChangeRQ.ofReissue 구조 (재발행 실행)

위치: infrastructure/request/OrderChangeRQ.kt

(발권 API 문서에서 이미 다룬 내용과 동일)

companion object {
    fun ofReissue(
        booking: Booking,
        price: Long,
        fareItinerary: FareItinerary,
        iataNumber: String,
        agencyName: String,
    ): OrderChangeRQ {
        return OrderChangeRQ(
            query = Query(
                order = ChangeOrder.ofReissue(
                    booking = booking,
                    price = price,
                    fareItinerary = fareItinerary,
                    iataNumber = iataNumber,
                    agencyName = agencyName
                )
            )
        )
    }
}

6. 에러 처리 및 재시도 메커니즘

6.1 에러 타입별 처리 매트릭스

에러 타입발생 시점처리 방법Sentry 캡처
REISSUE_SEARCH_FAILEDreissueSearchInternationalAdapterExceptionYes
CHANGED_DOWN_GRADE_PRICEreissueDetailInternationalAdapterExceptionNo
NON_CHANGEABLE_SCHEDULESreissueDetailInternationalAdapterExceptionNo
PRICING_FAILEDrepricingWithReissueInternationalAdapterExceptionYes
RETICKETING_FAILED_BY_MISMATCH_PRICEreissueInternationalAdapterExceptionNo
TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGEreissue (OrderChangeRQ)InternationalAdapterExceptionYes
RETICKETING_FAILEDreissue (OrderChangeRQ)InternationalAdapterExceptionYes

6.2 에러 메시지 처리

ResponseMessage(
    "검색 오류가 발생했습니다." +
        (errors.mapNotNull { error -> error.descText }
            .joinToString("\n")
            .takeIf { it.isNotEmpty() }
            ?.let { "\n공급사 메시지:\n${it}" } ?: "")
)

특징:

  • 사용자 친화적 메시지
  • 공급사 에러 메시지 포함
  • 여러 에러 메시지 줄바꿈으로 구분

7. GDS와의 차이점 비교

7.1 재발행 플로우 비교

항목Singapore Air (NDC)GalileoAmadeusSabre
재발행 지원Yes (NDC 표준)NoNoNo
재발행 검색OrderReshopRQ.ofReissueSearchN/AN/AN/A
재발행 PricingOrderReshopRQ.ofPricingN/AN/AN/A
재발행 실행OrderChangeRQ.ofReissueN/AN/AN/A
변경 제약출/도착지 변경 불가N/AN/AN/A
다운그레이드불가 (tax < 0 체크)N/AN/AN/A

7.2 에러 처리 비교

에러 시나리오Singapore AirGalileoAmadeusSabre
재발행 검색 실패REISSUE_SEARCH_FAILEDN/AN/AN/A
다운그레이드CHANGED_DOWN_GRADE_PRICEN/AN/AN/A
변경 사항 없음NON_CHANGEABLE_SCHEDULESN/AN/AN/A
교환 불가TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGEN/AN/AN/A

7.3 독특한 특징

Singapore Airlines NDC 고유 특징

  1. 재발행 지원:

    • IATA NDC 표준의 고유 기능
    • GDS는 재발행 API 없음 (취소 후 재예약)
  2. OrderReshopRQ 다중 용도:

    • 재발행 검색 (ofReissueSearch)
    • 재발행 Pricing (ofPricing)
    • 환불 계산 (ofRefundCalculate)
  3. remainServiceIds:

    • 변경하지 않을 구간 ID 관리
    • 왕복 중 편도만 변경 가능
  4. 변경 항목 검증:

    • 출발/도착지, 시간, 편명, 좌석 등급 검증
    • 실질적인 변경 사항 확인
  5. 다운그레이드 방지:

    • tax < 0 또는 total < 0 체크
    • 환불 발생 재발행 불가

8. 성능 최적화

8.1 Redis 캐싱

.also {
    if (useCache && it.isNotEmpty()) {
        fareItineraryRepository.saveFareItineraries(key, it.associateBy { it.itemKey })
    }
}

캐싱 조건:

  • useCache = true
  • 검색 결과가 비어있지 않음

8.2 조건부 검증

booking.schedules!!.filterNot { fareItinerary.remainServiceIds?.containsAll(it.referenceServiceIds) ?: false }
    .forEach { originSchedule ->
        // 변경 대상만 검증
    }

성능 향상:

  • remainServiceIds에 포함된 스케줄은 검증 생략
  • 변경 대상만 검증

9. 주요 발견사항

9.1 Singapore Airlines NDC 재발행 시스템의 특징

  1. NDC 고유 기능: GDS는 지원하지 않음
  2. OrderReshopRQ 다중 용도: 검색, Pricing, 환불 계산
  3. remainServiceIds: 변경하지 않을 구간 관리
  4. 변경 항목 검증: 실질적인 변경 사항 확인
  5. 다운그레이드 방지: 환불 발생 재발행 불가

9.2 개선 가능 영역

1. 다운그레이드 검증 로깅

현황: 예외만 발생

if (it.tax < 0 || it.total < 0) {
    throw InternationalAdapterException(ErrorMessage.CHANGED_DOWN_GRADE_PRICE)
}

제안: 로깅 추가

if (it.tax < 0 || it.total < 0) {
    logger.warn(
        "[SINGAPOREAIR] Downgrade detected, " +
        "passengerType: ${it.type}, tax: ${it.tax}, total: ${it.total}"
    )
    throw InternationalAdapterException(ErrorMessage.CHANGED_DOWN_GRADE_PRICE)
}

2. 변경 항목 검증 로깅

현황: 예외만 발생

제안: 로깅 추가

if (
    it.departureAt == originSchedule.departureAt.toLocalDateTime()
    && it.arrivalAt == originSchedule.arrivalAt.toLocalDateTime()
    && it.flightNumber == originSchedule.flightNumber
    && it.cabin == originSchedule.cabin
) {
    logger.warn(
        "[SINGAPOREAIR] No changes detected, " +
        "segment: ${originSchedule.departure}-${originSchedule.arrival}, " +
        "flight: ${originSchedule.flightNumber}"
    )
    throw InternationalAdapterException(ErrorMessage.NON_CHANGEABLE_SCHEDULES)
}

3. remainServiceIds null 처리

현황: null safe 연산자 사용

fareItinerary.remainServiceIds?.containsAll(it.referenceServiceIds) ?: false

제안: 명시적 처리

val remainServiceIds = fareItinerary.remainServiceIds ?: emptyList()
booking.schedules!!.filterNot { remainServiceIds.containsAll(it.referenceServiceIds) }

10. 참고 자료

10.1 주요 클래스

  • SingaporeairFlightSearchService.kt: 재발행 검색 서비스 (101-169)
  • SingaporeairTicketingService.kt: 재발행 실행 서비스 (64-95)
  • SingaporeairClient.kt: SOAP 클라이언트 (648-771)
  • OrderReshopRQ.kt: 재발행 검색/Pricing 요청 DTO
  • OrderReshopRS.kt: 재발행 검색/Pricing 응답 DTO
  • OrderChangeRQ.kt: 재발행 실행 요청 DTO
  • OrderViewRS.kt: 재발행 실행 응답 DTO

10.2 주요 메소드

  • SingaporeairFlightSearchService.reissueSearch(): 재발행 검색 (101-140)
  • SingaporeairFlightSearchService.reissueDetail(): 재발행 상세 조회 (142-169)
  • SingaporeairTicketingService.reissue(): 재발행 실행 (64-95)
  • SingaporeairClient.reissueSearch(): SOAP 재발행 검색 API (648-698)
  • SingaporeairClient.repricingWithReissue(): SOAP 재발행 Pricing API (700-729)
  • SingaporeairClient.reissue(): SOAP 재발행 실행 API (731-771)

10.3 관련 API

  • OrderReshopRQ: 재발행 검색, 재발행 Pricing, 환불 계산
  • OrderReshopRS: 재발행 검색, 재발행 Pricing, 환불 계산 응답
  • OrderChangeRQ: 재발행 실행
  • OrderViewRS: 재발행 실행 응답

이 문서는 Triple Air International Adapter 프로젝트의 Singapore Airlines 재발행 API 심층 분석 문서입니다.