Singapore Airlines 운임 규정 API 심층 분석

1. API 엔드포인트 개요

1.1 운임 규정 API

엔드포인트메서드API 타입기능위치
/internals/SINGAPOREAIR/fare-rulesGETSOAP (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 })
    }
}

추출 로직:

  1. OfferItem → FareDetail → FareComponent
  2. FareComponent.priceClassRef로 PriceClass 찾기
  3. FareComponent.segmentRef로 PaxSegment 찾기
  4. 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_FAILEDgetMiniRulesInternationalAdapterException (Sentry 캡처)No
CMS 조회 실패getMiniRules경고 로그 + Slack 알림Yes
fareComponents is nullgetMiniRulesInternationalAdapterException (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)GalileoAmadeusSabre
APIOfferPriceRQ (MiniRules)AirFareRules + KRTCommandCryptic + ARTFareRuleClient
규정 형식MiniRule (구조화)Text (비구조화)Text (비구조화)Text (비구조화)
CMSSingapore Air CMSN/AN/AN/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 고유 특징

  1. OfferPriceRQ 다중 용도:

    • Pricing
    • MiniRules
    • Ancillary Repricing
  2. 구조화된 MiniRule:

    • PENALTIES, REFUND, CHANGE 분리
    • AppCode로 카테고리 구분
    • 파싱 불필요
  3. CMS 의존성:

    • Singapore Air CMS 시스템 사용
    • CMS 조회 실패 시 불완전한 정보
    • Slack 알림으로 수동 확인
  4. PriceClass 기반:

    • PriceClass에 MiniRule 포함
    • FareComponent로 매핑

7. 성능 최적화

7.1 OfferPriceRQ 재사용

  • Pricing과 동일한 OfferPriceRQ 사용
  • 별도 API 호출 불필요
  • 응답 파싱만 다름

7.2 캐싱 전략

  • FareItinerary에서 조회
  • Redis 캐싱 활용

8. 주요 발견사항

8.1 Singapore Airlines NDC 운임 규정 시스템의 특징

  1. OfferPriceRQ 다중 용도: Pricing, MiniRules, Ancillary
  2. 구조화된 MiniRule: PENALTIES, REFUND, CHANGE 분리
  3. CMS 의존성: Singapore Air CMS 시스템
  4. 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: 응답 DTO
  • PriceClass.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 심층 분석 문서입니다.