Singapore Airlines 운임 규정 API 심층 분석
1. API 엔드포인트 개요
1.1 운임 규정 API
| 엔드포인트 | 메서드 | API 타입 | 기능 | 위치 |
|---|---|---|---|---|
/internals/SINGAPOREAIR/fare-rules | GET | SOAP (NDC) | 운임 규정 조회 | SingaporeairFareRuleService.kt:26-42 |
2. 운임 규정 조회 (Find Fare Rules)
2.1 전체 플로우
flowchart TD A[운임 규정 요청] --> B[FareItinerary<br/>조회] B --> C[MiniRules 조회<br/>singaporeairClient.getMiniRules] C --> D[SOAP 요청<br/>OfferPriceRQ] D --> E{에러 체크} E -->|에러| F[FETCH_FARE_RULES_FAILED<br/>예외 발생] E -->|정상| G{CMS 조회<br/>성공?} G -->|실패| H[Slack 알림<br/>규정 확인 필요] G -->|성공| I[응답 파싱] H --> I I --> J[MiniRule 추출<br/>PriceClass → MiniRule] J --> K[FareRule 변환<br/>toFareRule] K --> L[운임 규정 반환] style B fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000 style D fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000 style E fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style F fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style G fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000 style H fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style L fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
2.2 상세 분석
Step 1: FareItinerary 조회
위치: SingaporeairFareRuleService.kt:26-33
fun findFareRules(
key: String,
adult: Int,
child: Int,
infant: Int
): List<FareRule> {
val fareItinerary: FareItinerary =
fareItineraryRepository.getFareItinerary(hashKey = key)
// ...
}Step 2: MiniRules 조회
위치: SingaporeairFareRuleService.kt:35-41
return singaporeairClient.getMiniRules(
adult = adult,
child = child,
infant = infant,
fareItinerary = fareItinerary,
sendSlackMessage = sendSlackMessage,
).flatMapIndexed { index, priceRule -> priceRule.toFareRule(index + 1) }sendSlackMessage 콜백:
private val sendSlackMessage: (String) -> Unit = {
slackService.sendWarnings(
supplier = Supplier.SINGAPOREAIR,
warnings = it
)
}Step 3: getMiniRules API
위치: SingaporeairClient.kt:139-213
fun getMiniRules(
adult: Int,
child: Int,
infant: Int,
fareItinerary: FareItinerary,
sendSlackMessage: (String) -> Unit,
): List<MiniRule> {
val singaporeairApiProperties = singaporeairProperties.getApiProperties()
val request = OfferPriceRQ.of(
adult = adult,
child = child,
infant = infant,
fareItinerary = fareItinerary,
iataNumber = singaporeairApiProperties.iataCode,
agencyName = singaporeairApiProperties.agencyName
)
val itinerarySummary = "VC:${fareItinerary.validatingCarrier} ${
fareItinerary.schedules.flatMap { it.segments }
.joinToString { "CLS:${it.bookingClass} ${it.departure}-${it.arrival} ${it.departureAt}" }
}"
return singaporeairApiProperties.endpoint
.post(request)
.header(getHeaderMap(request))
.requestBodyConvert(soapRequestBodyConverter(singaporeairApiProperties))
.execute<OfferPriceRS>(soapBodyDeserializerOf(logger, objectMapper))
.fold(
success = { offerPriceRS ->
offerPriceRS.checkError { code, message ->
throw InternationalAdapterException(
ErrorMessage.FETCH_FARE_RULES_FAILED,
itinerarySummary,
code,
message
).capture()
}
offerPriceRS.response!!.let { response ->
// CMS 조회 실패 경고
if (response.warnings?.any { it.descText == "LOCALIZED CONTENT COULD NOT BE RETRIEVED FROM CMS" } == true) {
sendSlackMessage("규정 확인 필요")
}
response.pricedOffer.offer.offerItems.flatMap { offerItem ->
offerItem.fareDetails?.first()?.fareComponents?.mapNotNull { fareComponent ->
response.dataList.priceClasses.find { priceClass -> priceClass.id == fareComponent.priceClassRef }
?.toMiniRule(response.dataList.paxSegments.find { paxSegment -> paxSegment.id == fareComponent.segmentRef })
} ?: throw InternationalAdapterException(
ErrorMessage.FETCH_FARE_RULES_FAILED,
itinerarySummary,
"fareComponents is null"
).capture()
}
}
},
failure = { failure ->
throw failure.handleSoapFaultException(
ErrorMessage.FETCH_FARE_RULES_FAILED,
itinerarySummary,
)
}
)
}itinerarySummary:
VC:SQ CLS:Y ICN-SIN 2025-10-15T10:00 CLS:Y SIN-ICN 2025-10-20T18:00
- 검증 대상 항공편 요약 (에러 발생 시 로그)
Step 4: CMS 조회 실패 처리
if (response.warnings?.any { it.descText == "LOCALIZED CONTENT COULD NOT BE RETRIEVED FROM CMS" } == true) {
sendSlackMessage("규정 확인 필요")
}CMS (Content Management System):
- Singapore Airlines의 운임 규정 관리 시스템
- 조회 실패 시 MiniRule 정보가 불완전할 수 있음
- Slack 알림으로 수동 확인 필요
Step 5: MiniRule 추출
response.pricedOffer.offer.offerItems.flatMap { offerItem ->
offerItem.fareDetails?.first()?.fareComponents?.mapNotNull { fareComponent ->
response.dataList.priceClasses.find { priceClass -> priceClass.id == fareComponent.priceClassRef }
?.toMiniRule(response.dataList.paxSegments.find { paxSegment -> paxSegment.id == fareComponent.segmentRef })
}
}추출 로직:
- OfferItem → FareDetail → FareComponent
- FareComponent.priceClassRef로 PriceClass 찾기
- FareComponent.segmentRef로 PaxSegment 찾기
- PriceClass를 MiniRule로 변환
3. OfferPriceRS 구조 (MiniRules)
3.1 주요 필드
위치: infrastructure/response/OfferPriceRS.kt
data class OfferPriceRS(
val response: Response?,
val errors: List<Error>?,
) {
data class Response(
val pricedOffer: PricedOffer,
val dataList: DataList,
val warnings: List<Warning>?, // CMS 조회 실패 경고
)
}3.2 DataList 구조
data class DataList(
val priceClasses: List<PriceClass>, // 운임 클래스 (MiniRule 포함)
val paxSegments: List<PaxSegment>, // 여정 세그먼트
val paxs: List<Pax>, // 승객
val baggageAllowances: List<BaggageAllowance>?, // 수하물
)3.3 PriceClass 구조 (MiniRule 정보 포함)
data class PriceClass(
val id: String, // priceClassRef
val name: Name?, // 운임 클래스 이름
val code: Code?, // 운임 코드
val classOfService: ClassOfService?, // 서비스 클래스
val fareBasisCode: FareBasisCode?, // Fare Basis Code
val descriptions: List<Description>?, // MiniRule 설명
)
data class Description(
val descText: DescText?, // 설명 텍스트
val appCode: AppCode?, // 적용 코드 (예: PENALTIES, REFUND, CHANGE)
)3.4 FareComponent 구조
data class FareComponent(
val priceClassRef: String, // PriceClass ID
val segmentRef: String, // PaxSegment ID
val fareBasisCode: FareBasisCode?, // Fare Basis Code
)3.5 PaxSegment 구조
data class PaxSegment(
val id: String, // segmentRef
val departure: Departure, // 출발
val arrival: Arrival, // 도착
val marketingCarrier: MarketingCarrier, // 마케팅 항공사
)4. MiniRule 변환
4.1 toMiniRule 확장 함수
위치: PriceClass.kt (extension function)
fun PriceClass.toMiniRule(paxSegment: PaxSegment?): MiniRule {
return MiniRule(
segment = paxSegment?.let {
"${it.departure.airportCode.value}-${it.arrival.airportCode.value}"
} ?: "",
fareBasisCode = this.fareBasisCode?.code?.value ?: "",
classOfService = this.classOfService?.code?.value ?: "",
penalties = this.descriptions?.filter { it.appCode?.value == "PENALTIES" }
?.mapNotNull { it.descText?.value } ?: emptyList(),
refund = this.descriptions?.filter { it.appCode?.value == "REFUND" }
?.mapNotNull { it.descText?.value } ?: emptyList(),
change = this.descriptions?.filter { it.appCode?.value == "CHANGE" }
?.mapNotNull { it.descText?.value } ?: emptyList(),
)
}MiniRule 구조:
data class MiniRule(
val segment: String, // ICN-SIN
val fareBasisCode: String, // Y26RT
val classOfService: String, // Y
val penalties: List<String>, // 위약금 규정
val refund: List<String>, // 환불 규정
val change: List<String>, // 변경 규정
)AppCode 종류:
| 코드 | 의미 |
|---|---|
| PENALTIES | 위약금 규정 |
| REFUND | 환불 규정 |
| CHANGE | 변경 규정 |
| BAGGAGE | 수하물 규정 |
| MEALS | 기내식 규정 |
4.2 toFareRule 변환
위치: MiniRule.kt (extension function)
fun MiniRule.toFareRule(sequence: Int): List<FareRule> {
return listOf(
if (penalties.isNotEmpty()) {
FareRule(
sequence = sequence,
category = "PENALTIES",
segment = segment,
fareBasisCode = fareBasisCode,
rules = penalties.joinToString("\n")
)
} else null,
if (refund.isNotEmpty()) {
FareRule(
sequence = sequence,
category = "REFUND",
segment = segment,
fareBasisCode = fareBasisCode,
rules = refund.joinToString("\n")
)
} else null,
if (change.isNotEmpty()) {
FareRule(
sequence = sequence,
category = "CHANGE",
segment = segment,
fareBasisCode = fareBasisCode,
rules = change.joinToString("\n")
)
} else null,
).filterNotNull()
}FareRule 구조:
data class FareRule(
val sequence: Int, // 순서
val category: String, // PENALTIES, REFUND, CHANGE
val segment: String, // ICN-SIN
val fareBasisCode: String, // Y26RT
val rules: String, // 규정 텍스트 (줄바꿈으로 구분)
)5. 에러 처리
5.1 에러 타입
| 에러 타입 | 발생 시점 | 처리 방법 | Slack 알림 |
|---|---|---|---|
FETCH_FARE_RULES_FAILED | getMiniRules | InternationalAdapterException (Sentry 캡처) | No |
CMS 조회 실패 | getMiniRules | 경고 로그 + Slack 알림 | Yes |
fareComponents is null | getMiniRules | InternationalAdapterException (Sentry 캡처) | No |
5.2 CMS 조회 실패 처리
if (response.warnings?.any { it.descText == "LOCALIZED CONTENT COULD NOT BE RETRIEVED FROM CMS" } == true) {
sendSlackMessage("규정 확인 필요")
}처리 방식:
- 에러가 아닌 경고로 처리
- Slack 알림 전송
- MiniRule 정보가 불완전할 수 있으므로 수동 확인 필요
6. GDS와의 차이점 비교
6.1 운임 규정 API 비교
| 항목 | Singapore Air (NDC) | Galileo | Amadeus | Sabre |
|---|---|---|---|---|
| API | OfferPriceRQ (MiniRules) | AirFareRules + KRT | CommandCryptic + ART | FareRuleClient |
| 규정 형식 | MiniRule (구조화) | Text (비구조화) | Text (비구조화) | Text (비구조화) |
| CMS | Singapore Air CMS | N/A | N/A | N/A |
| 구조화 정도 | 높음 (PENALTIES, REFUND, CHANGE 분리) | 낮음 (Text) | 낮음 (Text) | 낮음 (Text) |
| 에러 처리 | CMS 조회 실패 경고 | Cryptic 파싱 실패 | Cryptic 파싱 실패 | API 에러 |
6.2 규정 형식 비교
Singapore Air (NDC) - MiniRule
MiniRule(
segment = "ICN-SIN",
fareBasisCode = "Y26RT",
classOfService = "Y",
penalties = [
"Changes permitted with fee",
"Fee: USD 100 per change"
],
refund = [
"Refund permitted with fee",
"Fee: USD 200 per refund"
],
change = [
"Changes permitted before departure",
"Fee applies"
]
)GDS - Text
PENALTIES
CHANGES PERMITTED WITH FEE.
FEE - USD100.00 PER CHANGE.
REFUNDS
REFUND PERMITTED WITH FEE.
FEE - USD200.00 PER REFUND.
CHANGES
CHANGES PERMITTED BEFORE DEPARTURE.
FEE APPLIES.
6.3 독특한 특징
Singapore Airlines NDC 고유 특징
-
OfferPriceRQ 다중 용도:
- Pricing
- MiniRules
- Ancillary Repricing
-
구조화된 MiniRule:
- PENALTIES, REFUND, CHANGE 분리
- AppCode로 카테고리 구분
- 파싱 불필요
-
CMS 의존성:
- Singapore Air CMS 시스템 사용
- CMS 조회 실패 시 불완전한 정보
- Slack 알림으로 수동 확인
-
PriceClass 기반:
- PriceClass에 MiniRule 포함
- FareComponent로 매핑
7. 성능 최적화
7.1 OfferPriceRQ 재사용
- Pricing과 동일한 OfferPriceRQ 사용
- 별도 API 호출 불필요
- 응답 파싱만 다름
7.2 캐싱 전략
- FareItinerary에서 조회
- Redis 캐싱 활용
8. 주요 발견사항
8.1 Singapore Airlines NDC 운임 규정 시스템의 특징
- OfferPriceRQ 다중 용도: Pricing, MiniRules, Ancillary
- 구조화된 MiniRule: PENALTIES, REFUND, CHANGE 분리
- CMS 의존성: Singapore Air CMS 시스템
- PriceClass 기반: FareComponent 매핑
8.2 개선 가능 영역
1. CMS 조회 실패 상세 로깅
현황: Slack 알림만
sendSlackMessage("규정 확인 필요")제안: 로깅 추가
logger.warn(
"[SINGAPOREAIR] CMS content retrieval failed, " +
"itinerary: $itinerarySummary, " +
"manual verification required"
)
sendSlackMessage("규정 확인 필요: $itinerarySummary")2. fareComponents null 처리 개선
현황: 예외 발생
?: throw InternationalAdapterException(
ErrorMessage.FETCH_FARE_RULES_FAILED,
itinerarySummary,
"fareComponents is null"
).capture()제안: 빈 리스트 반환 또는 상세 로깅
?: run {
logger.warn("[SINGAPOREAIR] fareComponents is null, itinerary: $itinerarySummary")
emptyList()
}3. MiniRule 변환 검증
현황: 검증 없음
제안: 필수 필드 검증
fun PriceClass.toMiniRule(paxSegment: PaxSegment?): MiniRule? {
if (fareBasisCode == null || classOfService == null) {
logger.warn("[SINGAPOREAIR] Missing required fields in PriceClass, id: $id")
return null
}
// ...
}9. 참고 자료
9.1 주요 클래스
SingaporeairFareRuleService.kt: 운임 규정 서비스 (26-42)SingaporeairClient.kt: SOAP 클라이언트 (139-213)OfferPriceRQ.kt: 요청 DTO (Pricing과 동일)OfferPriceRS.kt: 응답 DTOPriceClass.kt: MiniRule 정보 포함MiniRule.kt: MiniRule 도메인 모델
9.2 주요 메소드
SingaporeairFareRuleService.findFareRules(): 운임 규정 조회 (26-42)SingaporeairClient.getMiniRules(): SOAP MiniRules API (139-213)PriceClass.toMiniRule(): MiniRule 변환MiniRule.toFareRule(): FareRule 변환
9.3 관련 API
- OfferPriceRQ: 운임 규정 조회 (Pricing과 동일)
- OfferPriceRS: 운임 규정 조회 응답
- PriceClass: MiniRule 정보
- FareComponent: PriceClass 참조
이 문서는 Triple Air International Adapter 프로젝트의 Singapore Airlines 운임 규정 API 심층 분석 문서입니다.