Singapore Airlines 재발행 API 심층 분석
1. API 엔드포인트 개요
1.1 재발행 관련 엔드포인트
| 엔드포인트 | 메서드 | API 타입 | 기능 | 위치 |
|---|---|---|---|---|
/internals/SINGAPOREAIR/reissue/search | POST | SOAP (NDC) | 재발행 검색 | SingaporeairFlightSearchService.kt:101-140 |
/internals/SINGAPOREAIR/reissue/detail | POST | SOAP (NDC) | 재발행 상세 조회 | SingaporeairFlightSearchService.kt:142-169 |
/internals/SINGAPOREAIR/ticketing/reissue | POST | SOAP (NDC) | 재발행 실행 | SingaporeairTicketingService.kt:64-95 |
2. 재발행 검색 (Reissue Search)
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()로직 설명:
- 예약의 모든 스케줄을
groupSequence로 그룹화 (왕복: 1, 2) - 변경 요청한 구간과 일치하는 그룹 제외
- 남은 그룹을 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)
}
}
}검증 내용:
remainServiceIds에 없는 스케줄만 검증 (변경 대상)- 출발/도착지가 동일한 세그먼트 찾기
- 다음 항목이 모두 동일하면
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_FAILED | reissueSearch | InternationalAdapterException | Yes |
CHANGED_DOWN_GRADE_PRICE | reissueDetail | InternationalAdapterException | No |
NON_CHANGEABLE_SCHEDULES | reissueDetail | InternationalAdapterException | No |
PRICING_FAILED | repricingWithReissue | InternationalAdapterException | Yes |
RETICKETING_FAILED_BY_MISMATCH_PRICE | reissue | InternationalAdapterException | No |
TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE | reissue (OrderChangeRQ) | InternationalAdapterException | Yes |
RETICKETING_FAILED | reissue (OrderChangeRQ) | InternationalAdapterException | Yes |
6.2 에러 메시지 처리
ResponseMessage(
"검색 오류가 발생했습니다." +
(errors.mapNotNull { error -> error.descText }
.joinToString("\n")
.takeIf { it.isNotEmpty() }
?.let { "\n공급사 메시지:\n${it}" } ?: "")
)특징:
- 사용자 친화적 메시지
- 공급사 에러 메시지 포함
- 여러 에러 메시지 줄바꿈으로 구분
7. GDS와의 차이점 비교
7.1 재발행 플로우 비교
| 항목 | Singapore Air (NDC) | Galileo | Amadeus | Sabre |
|---|---|---|---|---|
| 재발행 지원 | Yes (NDC 표준) | No | No | No |
| 재발행 검색 | OrderReshopRQ.ofReissueSearch | N/A | N/A | N/A |
| 재발행 Pricing | OrderReshopRQ.ofPricing | N/A | N/A | N/A |
| 재발행 실행 | OrderChangeRQ.ofReissue | N/A | N/A | N/A |
| 변경 제약 | 출/도착지 변경 불가 | N/A | N/A | N/A |
| 다운그레이드 | 불가 (tax < 0 체크) | N/A | N/A | N/A |
7.2 에러 처리 비교
| 에러 시나리오 | Singapore Air | Galileo | Amadeus | Sabre |
|---|---|---|---|---|
| 재발행 검색 실패 | REISSUE_SEARCH_FAILED | N/A | N/A | N/A |
| 다운그레이드 | CHANGED_DOWN_GRADE_PRICE | N/A | N/A | N/A |
| 변경 사항 없음 | NON_CHANGEABLE_SCHEDULES | N/A | N/A | N/A |
| 교환 불가 | TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE | N/A | N/A | N/A |
7.3 독특한 특징
Singapore Airlines NDC 고유 특징
-
재발행 지원:
- IATA NDC 표준의 고유 기능
- GDS는 재발행 API 없음 (취소 후 재예약)
-
OrderReshopRQ 다중 용도:
- 재발행 검색 (ofReissueSearch)
- 재발행 Pricing (ofPricing)
- 환불 계산 (ofRefundCalculate)
-
remainServiceIds:
- 변경하지 않을 구간 ID 관리
- 왕복 중 편도만 변경 가능
-
변경 항목 검증:
- 출발/도착지, 시간, 편명, 좌석 등급 검증
- 실질적인 변경 사항 확인
-
다운그레이드 방지:
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 재발행 시스템의 특징
- NDC 고유 기능: GDS는 지원하지 않음
- OrderReshopRQ 다중 용도: 검색, Pricing, 환불 계산
- remainServiceIds: 변경하지 않을 구간 관리
- 변경 항목 검증: 실질적인 변경 사항 확인
- 다운그레이드 방지: 환불 발생 재발행 불가
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 요청 DTOOrderReshopRS.kt: 재발행 검색/Pricing 응답 DTOOrderChangeRQ.kt: 재발행 실행 요청 DTOOrderViewRS.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 심층 분석 문서입니다.