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-channel | Constants.TRIPLE_SALES_CHANNEL_HEADER | TRIPLE, YANOLJA, INTERPARK 등 | 판매 채널 |
x-triple-sales-funnel | Constants.TRIPLE_SALES_FUNNEL_HEADER | NAVER, 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 Method | GET |
| URL | /internals/admin/flights/meta/search/{originType}:{origin}-{destinationType}:{destination}/{outboundDate} |
| 메서드 | searchOneWayTrip() |
| 소스 위치 | FlightMetaSearchAdminController.kt:31-58 |
Path Variables
| 파라미터 | 타입 | 필수 | 설명 | 예시 |
|---|---|---|---|---|
originType | LocationType | Y | 출발지 유형 (CITY/AIRPORT) | CITY, AIRPORT |
origin | String | Y | 출발지 IATA 코드 | SEL, ICN |
destinationType | LocationType | Y | 도착지 유형 (CITY/AIRPORT) | CITY, AIRPORT |
destination | String | Y | 도착지 IATA 코드 | TYO, NRT |
outboundDate | LocalDate | Y | 출발일 (ISO 8601) | 2025-03-01 |
Query Parameters
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
cabins | Set<CabinType> | Y | - | 좌석 등급 |
adult | Int | Y | - | 성인 승객 수 |
child | Int | N | 0 | 소아 승객 수 |
infant | Int | N | 0 | 유아 승객 수 |
freeBaggageOnly | Boolean | N | false | 무료 수하물 포함 운임만 |
useCache | Boolean | N | false | 캐시 사용 여부 |
URL 예시
GET /internals/admin/flights/meta/search/CITY:SEL-CITY:TYO/2025-03-01?cabins=ECONOMY&adult=2
3.2 왕복 검색 (Round Trip)
| 항목 | 값 |
|---|---|
| HTTP Method | GET |
| URL | /internals/admin/flights/meta/search/{originType}:{origin}-{destinationType}:{destination}/{outboundDate}/{inboundDate} |
| 메서드 | searchRoundTrip() |
| 소스 위치 | FlightMetaSearchAdminController.kt:60-116 |
추가 Path Variables
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
inboundDate | LocalDate | Y | 귀국일 (ISO 8601) |
추가 Query Parameters
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
useMultiTicket | Boolean? | N | false | 멀티티켓 검색 사용 여부 (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
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
useRecommendation | Boolean | N | true | 추천 사용 여부 (다구간에서만 존재하나 실제 사용되지 않음) |
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_FARE | SKYSCANNER | 성인 최저가 기준으로 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 | 키 구성 |
|---|---|---|
SCHEDULE | SKYSCANNER, TRIPLE 등 | {marketingCarrier}{flightNumber}{dayOfWeek} |
SCHEDULE_WITH_FREE_BAGGAGE | NAVER, 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
| useMultiTicket | UseCase | 결과 |
|---|---|---|
true | CombineFlightSearchUseCase | 왕복 + MIX 항공권 모두 검색 |
false (기본) | StandardFlightSearchUseCase | 왕복 항공권만 검색 |
7.3 CombineFlightSearchUseCase 동작
소스 위치:
CombineFlightSearchUseCase.kt:28-65
FlightSearchService.searchFlights(useMultiTicket=true)호출- 결과를 MIX 항공편과 왕복 항공편으로 분리
- MIX 항공편: outbound/inbound 조합하여
CombinedFlightItem생성 - 왕복 항공편:
CombinedFlightItem으로 래핑 - 최종 결과 반환
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. 참조 문서
- StandardFlightSearchUseCase - 표준 항공 검색 UseCase
- CombineFlightSearchUseCase - MIX 항공권 검색 UseCase
- FlightSearchService - 항공 검색 서비스
- FlightItem - 항공편 도메인 모델
- CombinedFlightItem - MIX 항공편 도메인 모델