FlightMetaSearchAdminController DFS (기능명세서)

1. API 개요 및 목적

1.1 개요

항목
클래스명FlightMetaSearchAdminController
패키지com.triple.air.intl.search.interfaces.controller.internal.admin
소스 위치air-intl-search/src/main/kotlin/.../interfaces/controller/internal/admin/FlightMetaSearchAdminController.kt
Base URL/internals/admin/flights/meta

1.2 목적

메타 검색(Meta Search) 파트너사(네이버, 스카이스캐너 등)를 위한 관리자용 국제선 항공권 검색 API입니다.

주요 특징:

  • 메타 검색 전용: 일반 검색과 달리 MetaFunnel별로 다른 운임 결정 전략(FareDecisionStrategy) 및 그룹핑 기준(FlightGroupCriteria)을 적용
  • 관리자 전용: /internals/admin/ 경로로 내부 시스템에서만 접근 가능
  • 필수 헤더 요구: x-triple-sales-channel, x-triple-sales-funnel 헤더가 반드시 필요

1.3 지원 여정 타입

여정 타입엔드포인트 패턴설명
편도 (One Way)/search/{origin}-{destination}/{outboundDate}단일 구간
왕복 (Round Trip)/search/{origin}-{destination}/{outboundDate}/{inboundDate}출발-귀국 구간
다구간 2구간/search/{...}/{date1}/{...}/{date2}2개 구간
다구간 3구간/search/{...}/{date1}/{...}/{date2}/{...}/{date3}3개 구간
다구간 4구간/search/{...}/{date1}/{...}/{date2}/{...}/{date3}/{...}/{date4}4개 구간

2. 필수 헤더 요구사항

소스 위치: FlightMetaSearchAdminController.kt:21-24

@RequestMapping(
    "/internals/admin/flights/meta",
    headers = [Constants.TRIPLE_SALES_CHANNEL_HEADER, Constants.TRIPLE_SALES_FUNNEL_HEADER]
)

2.1 필수 헤더

헤더명상수값 예시설명
x-triple-sales-channelConstants.TRIPLE_SALES_CHANNEL_HEADERTRIPLE, YANOLJA, INTERPARK판매 채널
x-triple-sales-funnelConstants.TRIPLE_SALES_FUNNEL_HEADERNAVER, SKYSCANNER, TRIPLE판매 유입 경로

소스 위치: Constants.kt:6-7

2.2 Channel 열거형

소스 위치: Channel.kt:3-9

enum class Channel {
    YANOLJA,
    TRIPLE,
    INTERPARK,
    KAKAOMOBILITY,
    BTMS,
}

2.3 Funnel 열거형

소스 위치: Funnel.kt:3-13

enum class Funnel {
    YANOLJA,
    SKYSCANNER,
    TRIPLE,
    NAVER,
    NAVER_SMART,
    NAVER_GOLD,
    INTERPARK,
    KAKAOMOBILITY,
    BTMS,
    NOL_UNIVERSE;
}

3. 엔드포인트별 상세 분석

3.1 편도 검색 (One Way Trip)

항목
HTTP MethodGET
URL/internals/admin/flights/meta/search/{originType}:{origin}-{destinationType}:{destination}/{outboundDate}
메서드searchOneWayTrip()
소스 위치FlightMetaSearchAdminController.kt:31-58

Path Variables

파라미터타입필수설명예시
originTypeLocationTypeY출발지 유형 (CITY/AIRPORT)CITY, AIRPORT
originStringY출발지 IATA 코드SEL, ICN
destinationTypeLocationTypeY도착지 유형 (CITY/AIRPORT)CITY, AIRPORT
destinationStringY도착지 IATA 코드TYO, NRT
outboundDateLocalDateY출발일 (ISO 8601)2025-03-01

Query Parameters

파라미터타입필수기본값설명
cabinsSet<CabinType>Y-좌석 등급
adultIntY-성인 승객 수
childIntN0소아 승객 수
infantIntN0유아 승객 수
freeBaggageOnlyBooleanNfalse무료 수하물 포함 운임만
useCacheBooleanNfalse캐시 사용 여부

URL 예시

GET /internals/admin/flights/meta/search/CITY:SEL-CITY:TYO/2025-03-01?cabins=ECONOMY&adult=2

3.2 왕복 검색 (Round Trip)

항목
HTTP MethodGET
URL/internals/admin/flights/meta/search/{originType}:{origin}-{destinationType}:{destination}/{outboundDate}/{inboundDate}
메서드searchRoundTrip()
소스 위치FlightMetaSearchAdminController.kt:60-116

추가 Path Variables

파라미터타입필수설명
inboundDateLocalDateY귀국일 (ISO 8601)

추가 Query Parameters

파라미터타입필수기본값설명
useMultiTicketBoolean?Nfalse멀티티켓 검색 사용 여부 (MIX 항공권 검색)

useMultiTicket 분기 처리

소스 위치: FlightMetaSearchAdminController.kt:77-115

return if (useMultiTicket == true) {
    searchRoundFlights(...)  // CombineFlightSearchUseCase 사용
} else {
    searchFlights(...)       // StandardFlightSearchUseCase 사용
}

3.3 다구간 검색 (Multi City Trip)

3.3.1 다구간 2구간

항목
URL 패턴/search/{originType1}:{origin1}-{destinationType1}:{destination1}/{date1}/{originType2}:{origin2}-{destinationType2}:{destination2}/{date2}
소스 위치FlightMetaSearchAdminController.kt:118-159

3.3.2 다구간 3구간

항목
URL 패턴/search/.../{date1}/.../​{date2}/.../​{date3}
소스 위치FlightMetaSearchAdminController.kt:161-212

3.3.3 다구간 4구간

항목
URL 패턴/search/.../{date1}/.../​{date2}/.../​{date3}/.../​{date4}
소스 위치FlightMetaSearchAdminController.kt:214-275

다구간 추가 Query Parameters

파라미터타입필수기본값설명
useRecommendationBooleanNtrue추천 사용 여부 (다구간에서만 존재하나 실제 사용되지 않음)

4. Request/Response 구조

4.1 LocationType

소스 위치: LocationType.kt:3-6

enum class LocationType(val shorter: String) {
    CITY(shorter = "c"),    // 도시 코드 (SEL, TYO 등)
    AIRPORT(shorter = "a"); // 공항 코드 (ICN, NRT 등)
}

4.2 CabinType

소스 위치: CabinType.kt:3-10

enum class CabinType(val value: String) {
    ECONOMY(value = "Y"),           // 이코노미
    PREMIUM_ECONOMY(value = "W"),   // 프리미엄 이코노미
    BUSINESS(value = "C"),          // 비지니스
    FIRST(value = "F")              // 일등석
}

4.3 Response - FlightItemAdminView

소스 위치: FlightSearchView.kt:477-536

data class FlightItemAdminView(
    val listKey: UUID,              // 검색 결과 목록 키
    val supplier: String,           // 공급사
    val scheduleKey: String,        // 스케줄 키
    val validatingCarrier: String,  // 발권 항공사
    val tag: FlightTagView?,        // 추천 태그
    val adultPrice: Long,           // 성인 1인 가격
    val totalPrice: Long,           // 총 가격
    val schedules: List<FlightScheduleAdminView>,  // 스케줄 정보
    val fares: List<FlightFareAdminView>,          // 운임 정보
    val tripDirectionType: TripDirectionType?,     // 여정 방향 (편도편의 경우)
    val isMix: Boolean?,            // MIX 항공권 여부
    val hidden: Boolean,            // 숨김 여부 (debug 모드)
    val groupKey: String = ""       // 그룹 키
)

4.4 FlightScheduleAdminView

소스 위치: FlightSearchView.kt:538-593

data class FlightScheduleAdminView(
    val departure: String,                    // 출발 공항
    val arrival: String,                      // 도착 공항
    val departureDateTime: LocalDateTime,     // 출발 일시
    val arrivalDateTime: LocalDateTime,       // 도착 일시
    val stop: Int,                            // 경유 횟수
    val totalFlightTime: String,              // 총 비행 시간
    val addDay: Int,                          // 추가 일수
    val freeBaggage: FreeBaggageView?,        // 무료 수하물
    val segments: List<FlightSegmentAdminView> // 구간별 상세
)

4.5 FlightFareAdminView

소스 위치: FlightSearchView.kt:595-650

data class FlightFareAdminView(
    val id: String,                    // 운임 ID
    val avail: Int,                    // 잔여 좌석
    val detailKey: String,             // 상세 조회 키
    val adultPrice: Long,              // 성인 가격
    val promotionPrincipleId: Long?,   // 프로모션 정책 ID
    val cardPromotionName: String?,    // 카드 프로모션명
    val tags: List<String>,            // 태그 목록
    val identityType: IdentityType,    // 신분 유형
    val fareBasisCodes: List<String>,  // Fare Basis 코드
    val items: List<FlightItemFragmentAdminView> // 항공권 조각 (Mix의 경우 2개)
)

5. 비즈니스 로직 흐름

5.1 전체 처리 흐름

flowchart TD
    A[Controller 요청 수신] --> B{useMultiTicket?}
    B -->|true| C[searchRoundFlights]
    B -->|false| D[searchFlights]

    C --> E[CombineFlightSearchUseCase]
    D --> F[StandardFlightSearchUseCase]

    E --> G[FlightSearchService.searchFlights]
    F --> G

    G --> H[AdapterClient.getFareItineraries]
    H --> I[운임/스케줄 필터링]
    I --> J[PricingClient.getActivePricingPrinciples]
    J --> K[FlightItem 생성]

    K --> L{useMultiTicket?}
    L -->|true| M[combineMultiTicketSearchFlights]
    L -->|false| N[그룹핑 및 최적 운임 선택]

    M --> O[CombinedFlightItem 생성]
    O --> P[FlightItemAdminView 변환]
    N --> P

    P --> Q[Response 반환]

5.2 searchFlights 메서드 (표준 검색)

소스 위치: FlightMetaSearchAdminController.kt:277-310

private fun searchFlights(
    adult: Int,
    child: Int,
    infant: Int,
    freeBaggageOnly: Boolean,
    cabins: Set<CabinType>,
    useCache: Boolean,
    vararg originDestinationLocationInfo: OriginDestinationLocationInfo,
): List<FlightItemAdminView> {
    val searchInfo = SearchInfo(...)
 
    val funnel = MDCHolder.SalesFunnel.get()
    val flightItems = standardFlightSearchUseCase.searchFlights(
        searchInfo = searchInfo,
        onlyDirect = false,
        useRecommendation = false,
        fareDecisionStrategy = FareDecisionStrategy.getByMetaFunnel(funnel),
        flightGroupCriteria = FlightGroupCriteria.getByMetaFunnel(funnel),
        onlyRepresentativeCardPromotion = true,
        funnels = funnel.getMetaSearchFunnels(),
    )
 
    return flightItems.map { FlightItemAdminView.of(it) }
}

5.3 searchRoundFlights 메서드 (MIX 검색)

소스 위치: FlightMetaSearchAdminController.kt:312-343

private fun searchRoundFlights(...): List<FlightItemAdminView> {
    val searchInfo = SearchInfo(...)
 
    val funnel = MDCHolder.SalesFunnel.get()
    return combineFlightSearchUseCase.searchFlights(
        searchInfo = searchInfo,
        onlyDirect = false,
        useRecommendation = false,
        fareDecisionStrategy = FareDecisionStrategy.getByMetaFunnel(funnel),
        flightGroupCriteria = FlightGroupCriteria.getByMetaFunnel(funnel),
        onlyRepresentativeCardPromotion = true,
        funnels = funnel.getMetaSearchFunnels()
    ).map { FlightItemAdminView.of(flightItem = it) }
}

6. MetaFunnel별 처리 전략

6.1 FareDecisionStrategy (운임 결정 전략)

소스 위치: FareDecisionStrategy.kt:3-14

enum class FareDecisionStrategy {
    LOWEST_ADULT_FARE,                    // 성인 최저가 기준
    LOWEST_FARE_BY_IDENTITY_AND_CARD_TYPE; // 신분유형 & 카드타입별 최저가
 
    companion object {
        fun getByMetaFunnel(metaFunnel: Funnel): FareDecisionStrategy {
            return when (metaFunnel) {
                Funnel.SKYSCANNER -> LOWEST_ADULT_FARE
                else -> LOWEST_FARE_BY_IDENTITY_AND_CARD_TYPE
            }
        }
    }
}

전략별 동작

전략적용 Funnel동작
LOWEST_ADULT_FARESKYSCANNER성인 최저가 기준으로 FlightItem 선택
LOWEST_FARE_BY_IDENTITY_AND_CARD_TYPE나머지 전체신분유형 & 카드 프로모션별 최저가 fares 추출 후 첫번째 FlightItem에 병합

6.2 FlightGroupCriteria (항공편 그룹핑 기준)

소스 위치: FlightGroupCriteria.kt:6-58

enum class FlightGroupCriteria {
    NONE,                      // 그룹핑 없음
    SCHEDULE,                  // 스케줄 기준 (ITX운임과 일반운임 경합)
    SCHEDULE_WITH_CLASS,       // 스케줄 + 예약 클래스
    SCHEDULE_WITH_FREE_BAGGAGE; // 스케줄 + 무료 수하물
 
    companion object {
        fun getByMetaFunnel(funnel: Funnel): FlightGroupCriteria {
            return when (funnel) {
                Funnel.NAVER, Funnel.NAVER_SMART, Funnel.NAVER_GOLD -> SCHEDULE_WITH_FREE_BAGGAGE
                else -> SCHEDULE
            }
        }
    }
}

그룹핑 기준별 키 생성

기준적용 Funnel키 구성
SCHEDULESKYSCANNER, TRIPLE 등{marketingCarrier}{flightNumber}{dayOfWeek}
SCHEDULE_WITH_FREE_BAGGAGENAVER, NAVER_SMART, NAVER_GOLD{marketingCarrier}{flightNumber}{dayOfWeek}{수하물정보}

수하물 그룹핑 로직

소스 위치: FlightGroupCriteria.kt:34-46

SCHEDULE_WITH_FREE_BAGGAGE -> {
    // PC 단위 수하물은 2개 이상부터 별도 그룹핑
    if (schedule.freeBaggage?.unit == BaggageUnit.QUANTITY && schedule.freeBaggage.volume > 1) {
        append("${schedule.freeBaggage.volume}PC")
    } else {
        append(if ((schedule.freeBaggage?.volume ?: 0) > 0) "Y" else "")
    }
}

6.3 Funnel.getMetaSearchFunnels()

소스 위치: Funnel.kt:15-20

fun getMetaSearchFunnels(): List<Funnel> {
    return when (this) {
        NAVER -> listOf(NAVER, NAVER_SMART, NAVER_GOLD) // 순서 변경 x
        else -> listOf(this)
    }
}

NAVER Funnel 특수 처리: NAVER 검색 시 NAVER, NAVER_SMART, NAVER_GOLD 3개 Funnel에 대해 운임을 모두 조회하여 가장 저렴한 운임 제공


7. useMultiTicket 옵션 분석

7.1 개요

useMultiTicket 옵션은 왕복 검색에서만 사용되며, MIX 항공권(가는편/오는편 별도 발권)을 검색할지 여부를 결정합니다.

7.2 분기 처리

소스 위치: FlightMetaSearchAdminController.kt:77-115

useMultiTicketUseCase결과
trueCombineFlightSearchUseCase왕복 + MIX 항공권 모두 검색
false (기본)StandardFlightSearchUseCase왕복 항공권만 검색

7.3 CombineFlightSearchUseCase 동작

소스 위치: CombineFlightSearchUseCase.kt:28-65

  1. FlightSearchService.searchFlights(useMultiTicket=true) 호출
  2. 결과를 MIX 항공편과 왕복 항공편으로 분리
  3. MIX 항공편: outbound/inbound 조합하여 CombinedFlightItem 생성
  4. 왕복 항공편: CombinedFlightItem으로 래핑
  5. 최종 결과 반환

7.4 MIX 항공권 조합 로직

소스 위치: CombineFlightSearchUseCase.kt:67-86

private fun combineMultiTicketSearchFlights(
    flightItems: List<FlightItem>,
): List<CombinedFlightItem> {
    val (mixedFlightItems, roundTripFlightItems) = flightItems.partition { it.tripDirectionType != null }
    val (outbounds, inbounds) = mixedFlightItems.partition { it.tripDirectionType == TripDirectionType.OUTBOUND }
 
    // 왕복 항공권도 CombinedFlightItem 형태로 wrapping
    val wrappedRoundTripFlightItem = roundTripFlightItems.map {
        CombinedFlightItem.ofRoundTrip(it)
    }
 
    if (outbounds.isEmpty() || inbounds.isEmpty()) {
        return wrappedRoundTripFlightItem
    }
 
    val combinedMixFlightItems = outbounds.flatMap { outbound ->
        combineFlightItems(outbound, inbounds)
    }
    return (wrappedRoundTripFlightItem + combinedMixFlightItems)
}

7.5 MIX 항공권 연결 시간 검증

소스 위치: FlightItem.kt:801-806

fun isValidConnectionTime(
    outbound: FlightItem,
    inbound: FlightItem,
): Boolean {
    return outbound.inboundArrivalAt differ inbound.outboundDepartureAt >= Duration.ofHours(Constants.FLIGHT_INTERVAL_HOURS)
}
  • 최소 연결 시간: 3시간 (Constants.FLIGHT_INTERVAL_HOURS = 3L)

8. 에러 처리 및 예외 상황

8.1 SearchInfo 유효성 검사

소스 위치: SearchInfo.kt:46-52

fun validate(bookableDateRange: LongRange) {
    checkBookableDate(bookableDateRange = bookableDateRange)
    checkSearchableDates()
    checkSearchablePassengers(adult = this.adult, child = this.child, infant = this.infant)
    checkSearchableItinerary()
    checkMultiTicketSearch()
}

8.2 예외 케이스

검증 항목예외 상황MessageKey
예약 가능 날짜예약 불가능한 날짜 범위-
날짜 순서뒷 여정 날짜가 앞 여정보다 이른 경우INVALID_DATES
동일 출도착출발지와 도착지가 같은 경우INVALID_ITINERARY
국내선국내선 여정이 포함된 경우INVALID_ITINERARY
제한 국가제한 국가(SY, IR, UA, CU) 경유INVALID_ITINERARY_RESTRICTED_COUNTRY
잘못된 파라미터MultiTicket 검색 시 잘못된 파라미터INVALID_PARAMETER

8.3 LocationService 에러

소스 위치: LocationService.kt:15-31

fun getLocationInfo(type: LocationType, iata: String): LocationInfo {
    return try {
        when (type) {
            LocationType.CITY -> {...}
            LocationType.AIRPORT -> {...}
        }
    } catch (e: Exception) {
        throw MethodArgumentInvalidException(messageKey = MessageKey.INVALID_PARAMETER, cause = e, iata)
    }
}

8.4 검색 실패

소스 위치: FlightSearchService.kt:115-121

.onFailure { _, successes ->
    if (successes.isEmpty()) {
        throw InternationalSearchException(
            MessageKey.SEARCH_FAILED,
            "empty search list"
        )
    }
}

9. 관련 도메인 모델

9.1 SearchInfo

소스 위치: SearchInfo.kt:15-106

data class SearchInfo(
    val adult: Int,                                        // 성인 수
    val child: Int,                                        // 소아 수
    val infant: Int,                                       // 유아 수
    val freeBaggageOnly: Boolean,                          // 무료 수하물만
    val cabins: Set<CabinType>,                            // 좌석 등급
    val originDestinationLocationInfos: List<OriginDestinationLocationInfo>,  // 여정 정보
    val multiTicket: MultiTicket? = null,                  // 멀티티켓 정보
    val useCache: Boolean,                                 // 캐시 사용 여부
)

9.2 OriginDestinationLocationInfo

소스 위치: OriginDestinationLocationInfo.kt:11-30

data class OriginDestinationLocationInfo(
    val origin: LocationInfo,        // 출발지 정보
    val destination: LocationInfo,   // 도착지 정보
    val date: LocalDate,             // 출발일
)

9.3 LocationInfo (Sealed Class)

소스 위치: OriginDestinationLocationInfo.kt:32-93

sealed class LocationInfo(val type: LocationType) {
    abstract val iata: String           // IATA 코드
    abstract val airportIatas: Set<String>  // 공항 IATA 코드들
    abstract val cityIata: String       // 도시 IATA 코드
    abstract val countryCode: String    // 국가 코드
    abstract val zoneId: ZoneId         // 시간대
 
    data class City(...) : LocationInfo(LocationType.CITY)
    data class Airport(...) : LocationInfo(LocationType.AIRPORT)
}

9.4 FlightItem

소스 위치: FlightItem.kt:21-33

data class FlightItem(
    val listKey: UUID,                        // 검색 목록 키
    val supplier: String,                     // 공급사
    val scheduleKey: String,                  // 스케줄 키
    val validatingCarrier: String,            // 발권 항공사
    val schedules: List<Schedule>,            // 스케줄 목록
    val fares: List<FlightFare>,              // 운임 목록
    var score: BigDecimal?,                   // 추천 점수
    var tags: List<RecommendationTag>?,       // 추천 태그
    val tripDirectionType: TripDirectionType?, // MIX 시 여정 방향
    val prepayment: Boolean?,                 // 선결제 여부
    val groupKey: String = "",                // 그룹 키
)

9.5 CombinedFlightItem (MIX 항공권용)

소스 위치: CombinedFlightItem.kt:14-71

data class CombinedFlightItem(
    val listKey: UUID,
    val schedules: List<Schedule>,
    val fares: List<CombinedFlightFare>,
    var score: BigDecimal?,
    var tags: List<RecommendationTag>?,
    val isMix: Boolean,                 // true: MIX, false: 왕복
    val hidden: Boolean = false,        // debug 모드용
    val groupKey: String = ""
)

9.6 BaggageUnit

소스 위치: BaggageUnit.kt:3-11

enum class BaggageUnit(val shorter: String) {
    QUANTITY(shorter = "개"),      // 수량 (PC)
    WEIGHT_KG(shorter = "kg"),     // 무게 킬로그램
    WEIGHT_LB(shorter = "lb");     // 무게 파운드
}

10. 의존성 구조

graph TD
    A[FlightMetaSearchAdminController] --> B[StandardFlightSearchUseCase]
    A --> C[CombineFlightSearchUseCase]
    A --> D[LocationService]

    B --> E[FlightSearchService]
    B --> F[BookableDateService]

    C --> E
    C --> F

    D --> G[CityService]
    D --> H[AirportService]

    E --> I[AdapterClient]
    E --> J[PricingClient]
    E --> K[AirConsoleService]

11. 메타 검색 특수 설정

11.1 onlyRepresentativeCardPromotion

소스 위치: FlightMetaSearchAdminController.kt:303, 338

메타 검색에서는 onlyRepresentativeCardPromotion = true로 설정하여 대표 카드 프로모션만 조회합니다.

11.2 onlyDirect, useRecommendation

  • onlyDirect = false: 직항뿐 아니라 경유편도 포함
  • useRecommendation = false: 추천 점수 적용 안함 (메타 검색에서는 가격 위주)

12. 참조 문서

관련 문서