FlightSearchAdminController DFS (기능명세서)
1. API 개요 및 목적
1.1 기본 정보
| 항목 | 값 |
|---|---|
| 컨트롤러 | FlightSearchAdminController |
| 소스 위치 | air-intl-search/src/main/kotlin/com/triple/air/intl/search/interfaces/controller/internal/admin/FlightSearchAdminController.kt |
| 기본 경로 | /internals/admin/flights |
| 용도 | 관리자용 국제선 항공편 검색 API |
1.2 목적
관리자(Admin)가 국제선 항공편을 검색할 수 있는 내부 전용 API입니다. 일반 사용자 API와 달리 다양한 검색 모드(SPLIT, COMBINED, 표준)를 지원하며, 디버그 옵션 등 개발/운영에 필요한 추가 기능을 제공합니다.
1.3 의존성
FlightSearchAdminController
|-- StandardFlightSearchUseCase # 표준 검색
|-- SplitFlightSearchUseCase # SPLIT 모드 검색 (MIX 항공권 분리)
|-- CombineFlightSearchUseCase # COMBINED 모드 검색 (MIX 항공권 조합)
|-- LocationService # 위치 정보 조회
소스 위치: FlightSearchAdminController.kt:29-34
2. 필수 헤더 요구사항
2.1 헤더 정의
| 헤더 이름 | 상수명 | 필수 여부 | 설명 |
|---|---|---|---|
x-triple-sales-channel | TRIPLE_SALES_CHANNEL_HEADER | 필수 | 판매 채널 식별자 |
x-triple-sales-funnel | TRIPLE_SALES_FUNNEL_HEADER | 필수 | 판매 퍼널 식별자 |
cached-search-key | CACHED_SEARCH_KEY | 선택 | 캐시된 검색 키 (왕복 검색 시) |
소스 위치: Constants.kt:6-7,10
2.2 헤더 강제 적용
@RequestMapping(
"/internals/admin/flights",
headers = [Constants.TRIPLE_SALES_CHANNEL_HEADER, Constants.TRIPLE_SALES_FUNNEL_HEADER]
)소스 위치: FlightSearchAdminController.kt:25-28
두 헤더가 없으면 요청이 매핑되지 않음 (404 또는 헤더 누락 에러)
3. 엔드포인트별 상세 분석
3.1 편도 검색 (One-Way Trip)
기본 정보
| 항목 | 값 |
|---|---|
| 메서드 | GET |
| 경로 | /internals/admin/flights/search/{originType}:{origin}-{destinationType}:{destination}/{outboundDate} |
| 함수명 | searchOneWayTrip |
| 소스 위치 | FlightSearchAdminController.kt:35-69 |
Path Variables
| 파라미터 | 타입 | 설명 | 예시 |
|---|---|---|---|
originType | LocationType | 출발지 유형 | CITY, AIRPORT |
origin | String | 출발지 IATA 코드 | ICN, SEL |
destinationType | LocationType | 도착지 유형 | CITY, AIRPORT |
destination | String | 도착지 IATA 코드 | NRT, TYO |
outboundDate | LocalDate | 출발일 (ISO 형식) | 2024-03-15 |
Request Parameters
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
cabins | Set<CabinType> | Y | - | 좌석 등급 |
adult | Int | Y | - | 성인 승객 수 |
child | Int | N | 0 | 아동 승객 수 |
infant | Int | N | 0 | 유아 승객 수 |
freeBaggageOnly | Boolean | N | false | 무료 수하물 포함 항공편만 |
useRecommendation | Boolean | N | true | 추천 점수 적용 여부 |
useCache | Boolean | N | false | 상세 조회용 캐싱 사용 |
flightGroupCriteria | FlightGroupCriteria | N | SCHEDULE | 항공편 그룹핑 기준 |
예시 요청
GET /internals/admin/flights/search/CITY:SEL-AIRPORT:NRT/2024-03-15?cabins=ECONOMY&adult=2&child=1
3.2 왕복 검색 (Round Trip)
기본 정보
| 항목 | 값 |
|---|---|
| 메서드 | GET |
| 경로 | /internals/admin/flights/search/{originType}:{origin}-{destinationType}:{destination}/{outboundDate}/{inboundDate} |
| 함수명 | searchRoundTrip |
| 소스 위치 | FlightSearchAdminController.kt:71-124 |
Path Variables
| 파라미터 | 타입 | 설명 |
|---|---|---|
originType | LocationType | 출발지 유형 |
origin | String | 출발지 IATA 코드 |
destinationType | LocationType | 도착지 유형 |
destination | String | 도착지 IATA 코드 |
outboundDate | LocalDate | 출발일 |
inboundDate | LocalDate | 귀국일 |
Request Parameters (왕복 전용 추가)
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
mixResponseType | MixFlightResponseType | N | null | MIX 항공권 응답 타입 |
searchTripDirectionType | SearchTripDirectionType | N | null | 검색 방향 (OUTBOUND/INBOUND) |
detailKey | String | N | null | 앵커 항공편 상세 키 |
promotionPrincipleId | Long | N | null | 프로모션 정책 ID |
debug | Boolean | N | false | 디버그 모드 |
Request Headers (왕복 전용)
| 헤더 | 타입 | 필수 | 설명 |
|---|---|---|---|
cached-search-key | String | N | 캐시된 검색 키 (UUID 형식) |
소스 위치: FlightSearchAdminController.kt:92
3.3 다구간 검색 (Multi-City Trip)
2구간 다구간
| 항목 | 값 |
|---|---|
| 메서드 | GET |
| 경로 | /internals/admin/flights/search/{originType1}:{origin1}-{destinationType1}:{destination1}/{date1}/{originType2}:{origin2}-{destinationType2}:{destination2}/{date2} |
| 함수명 | searchMultiCityTrip (2구간) |
| 소스 위치 | FlightSearchAdminController.kt:126-175 |
3구간 다구간
| 항목 | 값 |
|---|---|
| 경로 | .../search/{구간1}/{date1}/{구간2}/{date2}/{구간3}/{date3} |
| 소스 위치 | FlightSearchAdminController.kt:177-238 |
4구간 다구간
| 항목 | 값 |
|---|---|
| 경로 | .../search/{구간1}/{date1}/{구간2}/{date2}/{구간3}/{date3}/{구간4}/{date4} |
| 소스 위치 | FlightSearchAdminController.kt:240-313 |
4. Request/Response 구조
4.1 공통 Request 모델
SearchInfo
검색 요청 정보를 담는 핵심 모델
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, // MIX 항공권 정보
val useCache: Boolean, // 캐시 사용 여부
)소스 위치: SearchInfo.kt:15-24
MultiTicket
MIX 항공권(SPLIT 모드) 검색 시 추가 정보
data class MultiTicket(
val searchTripDirectionType: SearchTripDirectionType?, // 검색 방향
val detailKey: String?, // 앵커 항공편 키
val promotionPrincipleId: Long?, // 프로모션 정책 ID
val cachedListKey: UUID?, // 캐시 키
)소스 위치: SearchInfo.kt:108-113
4.2 Enum 타입
LocationType
enum class LocationType(val shorter: String) {
CITY(shorter = "c"), // 도시 (복수 공항 포함 가능)
AIRPORT(shorter = "a"); // 공항 (단일 공항)
}소스 위치: LocationType.kt:3-6
CabinType
enum class CabinType(val value: String) {
ECONOMY(value = "Y"), // 이코노미
PREMIUM_ECONOMY(value = "W"), // 프리미엄 이코노미
BUSINESS(value = "C"), // 비지니스
FIRST(value = "F") // 일등석
}소스 위치: CabinType.kt:3-9
MixFlightResponseType
enum class MixFlightResponseType {
SPLIT, // MIX 항공권 Outbound, Inbound 각각 반환
COMBINED; // MIX 항공권 outbound X inbound 조합으로 반환
}소스 위치: MixFlightResponseType.kt:3-8
SearchTripDirectionType
enum class SearchTripDirectionType {
OUTBOUND, // 가는편 검색
INBOUND, // 오는편 검색
}소스 위치: SearchTripDirectionType.kt:3-6
FlightGroupCriteria
enum class FlightGroupCriteria {
NONE, // 그룹핑 없음
SCHEDULE, // 스케줄 기준 (bookingClass 제외)
SCHEDULE_WITH_CLASS, // 스케줄 + 예약 클래스
SCHEDULE_WITH_FREE_BAGGAGE, // 스케줄 + 무료 수하물
}소스 위치: FlightGroupCriteria.kt:6-11
4.3 Response 구조
응답 형식
ResponseEntity<List<FlightItemAdminView>>소스 위치: FlightSearchAdminController.kt:51,95,152,210,280
응답 헤더
| 헤더 | 설명 |
|---|---|
cached-search-key | 검색 결과 캐시 키 (UUID) |
소스 위치: FlightSearchAdminController.kt:384-386
ResponseEntity.ok()
.headers(HttpHeaders().apply { set(Constants.CACHED_SEARCH_KEY, listKey.toString()) })
.body(results)FlightItemAdminView 변환
.mapNotNull {
when (it) {
is CombinedFlightItem -> FlightItemAdminView.of(it)
is FlightItem -> FlightItemAdminView.of(it)
else -> null
}
}소스 위치: FlightSearchAdminController.kt:376-382
5. 비즈니스 로직 흐름
5.1 전체 흐름 다이어그램
flowchart TD A[Controller 엔드포인트] --> B[searchFlights 내부 메서드] B --> C{mixResponseType 확인} C -->|SPLIT| D[SplitFlightSearchUseCase] C -->|COMBINED| E[CombineFlightSearchUseCase] C -->|null/기타| F[StandardFlightSearchUseCase] D --> G[FlightSearchService.searchFlights] E --> G F --> G G --> H[AdapterClient.getFareItineraries] H --> I[운임 필터링 및 가격 정책 적용] I --> J[FlightItem 생성] J --> K{useRecommendation?} K -->|true| L[추천 점수 적용] K -->|false| M[결과 반환] L --> M M --> N[FlightItemAdminView 변환] N --> O[ResponseEntity 반환]
5.2 searchFlights 내부 메서드
소스 위치: FlightSearchAdminController.kt:315-387
SearchInfo 생성
val searchInfo = SearchInfo(
adult = adult,
child = child,
infant = infant,
freeBaggageOnly = freeBaggageOnly,
cabins = cabins,
originDestinationLocationInfos = originDestinations,
multiTicket = if (mixResponseType == MixFlightResponseType.SPLIT) {
MultiTicket(
searchTripDirectionType = searchTripDirectionType,
detailKey = detailKey,
promotionPrincipleId = promotionPrincipleId,
cachedListKey = cachedListKey,
)
} else null,
useCache = useCache,
)소스 위치: FlightSearchAdminController.kt:332-348
캐시 키 생성
val listKey = cachedListKey ?: CacheKeyGenerator.getFlightSearchCacheKey()소스 위치: FlightSearchAdminController.kt:350
6. MixFlightResponseType별 처리 로직
6.1 분기 로직
val funnel = MDCHolder.SalesFunnel.get()
val results = when (mixResponseType) {
MixFlightResponseType.SPLIT -> splitFlightSearchUseCase.searchFlights(...)
MixFlightResponseType.COMBINED -> combineFlightSearchUseCase.searchFlights(...)
else -> standardFlightSearchUseCase.searchFlights(...)
}소스 위치: FlightSearchAdminController.kt:352-375
6.2 SPLIT 모드
UseCase: SplitFlightSearchUseCase
소스 위치: SplitFlightSearchUseCase.kt:18-167
특징
- MIX 항공권의 가는편(Outbound)과 오는편(Inbound)을 분리하여 반환
- 캐시된 검색 결과 재사용 가능
- 앵커 항공편 기반 인바운드 필터링
처리 흐름
flowchart TD A[searchFlights] --> B{cachedListKey 존재?} B -->|Yes| C[캐시에서 FlightItems 조회] B -->|No| D[FlightSearchService 호출] C --> E[splitMultiTicketSearchFlights] D --> E E --> F[MIX/왕복 항공편 분리] F --> G{searchTripDirectionType?} G -->|INBOUND| H[앵커 기반 인바운드 필터링] G -->|OUTBOUND| I[아웃바운드만 반환] G -->|null| J[전체 반환] H --> K[그룹핑 및 최적 운임 선택] I --> K J --> K
핵심 로직
fun splitMultiTicketSearchFlights(
flightItems: List<FlightItem>,
detailKey: String?,
promotionPrincipleId: Long?,
searchTripDirectionType: SearchTripDirectionType?,
flightGroupCriteria: FlightGroupCriteria,
): List<FlightItem> {
val (mixFlightItems, roundTripFlightItems) = flightItems.partition { it.isMix }
val (mixOutboundFlightItems, mixInboundFlightItems) = mixFlightItems.map { flightItem ->
// MIX 항공권 조회 시 기본 PTC 값만 허용
flightItem.copy(fares = flightItem.fares.filter { it.identityType.isDefault })
}.partition { it.tripDirectionType == TripDirectionType.OUTBOUND }
// ...
}소스 위치: SplitFlightSearchUseCase.kt:58-114
앵커 항공편 매칭 (INBOUND 검색)
SearchTripDirectionType.INBOUND -> {
anchorFlightItem?.let {
val inboundFlightItems = if (it.isMix) {
mixInboundFlightItems.filter { inboundFlightItem ->
// 가는편 도착시간과 오는편 출발시간 차이가 3시간 이상
isValidConnectionTime(outbound = anchorFlightItem, inbound = inboundFlightItem)
}
} else {
// 왕복이면 동일한 가는편 스케줄 키를 가진 항공편 반환
roundTripFlightItems.filter { ... }
}
// ...
}
}소스 위치: SplitFlightSearchUseCase.kt:73-94
6.3 COMBINED 모드
UseCase: CombineFlightSearchUseCase
소스 위치: CombineFlightSearchUseCase.kt:22-154
특징
- MIX 항공권의 가는편 X 오는편 모든 조합 생성
- 왕복 항공권도 CombinedFlightItem으로 래핑
- 동일 스케줄의 중복 제거 (compact)
- 디버그 모드 지원
처리 흐름
flowchart TD A[searchFlights] --> B[FlightSearchService.searchFlights] B --> C[combineMultiTicketSearchFlights] C --> D[MIX/왕복 분리] D --> E[왕복 -> CombinedFlightItem 래핑] D --> F[MIX Outbound X Inbound 조합] E --> G[결과 합치기] F --> G G --> H[compact - 중복 제거] H --> I{debug?} I -->|true| J[숨김 항목 포함] I -->|false| K[최적 항목만] J --> L[그룹핑] K --> L L --> M{useRecommendation?} M -->|true| N[추천 점수 적용] M -->|false| O[반환] N --> O
조합 생성 로직
private fun combineFlightItems(
outbound: FlightItem,
inbounds: List<FlightItem>,
): List<CombinedFlightItem> {
return inbounds.filter { inbound ->
isValidConnectionTime(outbound = outbound, inbound = inbound)
}.mapNotNull { inbound ->
val fares = outbound.fares.flatMap { outboundFare ->
inbound.fares.filterByOutboundAndPolicy(outboundFare)
.map { inboundFare ->
CombinedFlightFare.ofMix(
outbound = outbound,
outboundFare = outboundFare,
inbound = inbound,
inboundFare = inboundFare
)
}
}
// ...
}
}소스 위치: CombineFlightSearchUseCase.kt:88-115
호출 파라미터
combineFlightSearchUseCase.searchFlights(
searchInfo = searchInfo,
flightGroupCriteria = flightGroupCriteria,
funnels = listOf(funnel),
fareDecisionStrategy = FareDecisionStrategy.LOWEST_FARE_BY_IDENTITY_AND_CARD_TYPE,
useRecommendation = useRecommendation,
onlyDirect = false,
debug = debug
)소스 위치: FlightSearchAdminController.kt:360-368
6.4 표준 모드 (기본)
UseCase: StandardFlightSearchUseCase
소스 위치: StandardFlightSearchUseCase.kt:21-102
특징
- MIX 항공권 없이 일반 검색
- 폴링 지원 (비동기 검색)
- 추천 점수 적용 옵션
호출 파라미터
standardFlightSearchUseCase.searchFlights(
searchInfo = searchInfo,
flightGroupCriteria = flightGroupCriteria,
useRecommendation = useRecommendation,
onlyDirect = false
)소스 위치: FlightSearchAdminController.kt:370-375
7. 에러 처리 및 예외 상황
7.1 SearchInfo 유효성 검사
소스 위치: SearchInfo.kt:46-98
검사 항목
| 검사 | 예외 | 조건 |
|---|---|---|
| 예약 가능 일자 | MethodArgumentInvalidException | 예약 불가 기간 |
| 일자 순서 | MethodArgumentInvalidException | 이전 구간보다 이른 출발일 |
| 동일 출도착지 | MethodArgumentInvalidException | 출발지와 도착지가 동일 |
| 국내선 | MethodArgumentInvalidException | 국내선 검색 시도 |
| 제한 국가 | MethodArgumentInvalidException | SY, IR, UA, CU 경유 |
| MIX 파라미터 | MethodArgumentInvalidException | INBOUND 검색 시 detailKey 누락 |
예시 코드
private fun checkSearchableDates() {
originDestinationLocationInfos.reduce { before, after ->
if (before.date > after.date) {
throw MethodArgumentInvalidException(MessageKey.INVALID_DATES)
.withMessageArguments(before.date, after.date)
}
after
}
}소스 위치: SearchInfo.kt:59-67
7.2 위치 정보 조회 실패
fun getLocationInfo(type: LocationType, iata: String): LocationInfo {
return try {
// ...
} catch (e: Exception) {
throw MethodArgumentInvalidException(
messageKey = MessageKey.INVALID_PARAMETER,
cause = e,
iata
)
}
}소스 위치: LocationService.kt:15-31
7.3 캐시 키 유효성
throw CacheKeyInvalidException(MessageKey.INVALID_CACHE_KEY, detailKey)소스 위치: FlightSearchService.kt:240
7.4 검색 실패
throw InternationalSearchException(MessageKey.SEARCH_FAILED, "empty search list")소스 위치: FlightSearchService.kt:116-119
8. 관련 도메인 모델 분석
8.1 FlightItem
국제선 항공편 정보를 담는 핵심 도메인 모델
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 = "", // 그룹 키
)소스 위치: FlightItem.kt:21-33
주요 프로퍼티
val isMix: Boolean
get() = tripDirectionType != null
val lowestFare: FlightFare = fares.minBy { it.adultFare.total }
val isDirect: Boolean = schedules.all { schedule -> schedule.stop == 0 }
val maxStop: Int = schedules.maxOf { it.stop }소스 위치: FlightItem.kt:34-44
8.2 CombinedFlightItem
COMBINED 모드에서 사용되는 조합된 항공편 모델
data class CombinedFlightItem(
val listKey: UUID,
val schedules: List<Schedule>,
val fares: List<CombinedFlightFare>,
var score: BigDecimal?,
var tags: List<RecommendationTag>?,
val isMix: Boolean,
val hidden: Boolean = false, // 디버그용 숨김 플래그
val groupKey: String = ""
)소스 위치: CombinedFlightItem.kt:14-23
8.3 FlightFare
운임 정보
data class FlightFare(
val id: String,
val lookUpKey: String = "",
val funnel: Funnel,
val avail: Int, // 잔여 좌석
val detailKey: String,
val passengerFares: List<PassengerFare>,
val representative: Boolean? = null,
val naverCardType: String? = null,
val cardPromotionName: String? = null,
val cardPromotionId: Long? = null,
val promotionPrincipleId: Long? = null,
val tags: List<String> = emptyList(),
var lowestAdultTotalPrice: Long? = null, // MIX용 최저가
var lowestTotalPrice: Long? = null,
)소스 위치: FlightItem.kt:331-346
8.4 Schedule
스케줄(구간) 정보
data class Schedule(
val key: String = "",
val flightTime: String, // 총 비행시간
val departure: String, // 출발 공항
val arrival: String, // 도착 공항
val departureAt: LocalDateTime,
val arrivalAt: LocalDateTime,
val stopPoints: List<String>, // 경유지
val addDay: Int, // 추가일
val stop: Int, // 경유 횟수
val avail: Int,
val freeBaggage: FreeBaggage?,
val segments: List<Segment>,
)소스 위치: FlightItem.kt:598-611
8.5 OriginDestinationLocationInfo
구간별 출도착지 정보
data class OriginDestinationLocationInfo(
val origin: LocationInfo,
val destination: LocationInfo,
val date: LocalDate,
)소스 위치: OriginDestinationLocationInfo.kt:11-15
8.6 연결 시간 검증
fun isValidConnectionTime(
outbound: FlightItem,
inbound: FlightItem,
): Boolean {
return outbound.inboundArrivalAt differ inbound.outboundDepartureAt >=
Duration.ofHours(Constants.FLIGHT_INTERVAL_HOURS) // 3시간
}소스 위치: FlightItem.kt:801-806, Constants.kt:14
9. 참고 사항
9.1 FareDecisionStrategy
운임 선택 전략
| 전략 | 설명 | 사용처 |
|---|---|---|
LOWEST_ADULT_FARE | 성인 최저가 기준 | Skyscanner |
LOWEST_FARE_BY_IDENTITY_AND_CARD_TYPE | 신분유형 & 카드 프로모션별 최저가 | 기본값 |
소스 위치: FareDecisionStrategy.kt:3-14
9.2 관련 서비스
| 서비스 | 역할 | 소스 위치 |
|---|---|---|
FlightSearchService | 항공편 검색 핵심 로직 | FlightSearchService.kt |
LocationService | 위치 정보 조회 | LocationService.kt |
BookableDateService | 예약 가능 일자 조회 | [확인 필요] |
AirConsoleService | 검색 조건 조회 | [확인 필요] |
PricingClient | 가격 정책 조회 | [확인 필요] |
9.3 캐시 관련
- 검색 결과는
listKey(UUID)로 캐싱 - SPLIT 모드에서
cachedListKey로 이전 검색 결과 재사용 가능 - 응답 헤더
cached-search-key로 캐시 키 반환
10. 변경 이력
| 날짜 | 작성자 | 내용 |
|---|---|---|
| 2024-12-15 | Claude | 초기 문서 작성 |