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-channelTRIPLE_SALES_CHANNEL_HEADER필수판매 채널 식별자
x-triple-sales-funnelTRIPLE_SALES_FUNNEL_HEADER필수판매 퍼널 식별자
cached-search-keyCACHED_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

파라미터타입설명예시
originTypeLocationType출발지 유형CITY, AIRPORT
originString출발지 IATA 코드ICN, SEL
destinationTypeLocationType도착지 유형CITY, AIRPORT
destinationString도착지 IATA 코드NRT, TYO
outboundDateLocalDate출발일 (ISO 형식)2024-03-15

Request Parameters

파라미터타입필수기본값설명
cabinsSet<CabinType>Y-좌석 등급
adultIntY-성인 승객 수
childIntN0아동 승객 수
infantIntN0유아 승객 수
freeBaggageOnlyBooleanNfalse무료 수하물 포함 항공편만
useRecommendationBooleanNtrue추천 점수 적용 여부
useCacheBooleanNfalse상세 조회용 캐싱 사용
flightGroupCriteriaFlightGroupCriteriaNSCHEDULE항공편 그룹핑 기준

예시 요청

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

파라미터타입설명
originTypeLocationType출발지 유형
originString출발지 IATA 코드
destinationTypeLocationType도착지 유형
destinationString도착지 IATA 코드
outboundDateLocalDate출발일
inboundDateLocalDate귀국일

Request Parameters (왕복 전용 추가)

파라미터타입필수기본값설명
mixResponseTypeMixFlightResponseTypeNnullMIX 항공권 응답 타입
searchTripDirectionTypeSearchTripDirectionTypeNnull검색 방향 (OUTBOUND/INBOUND)
detailKeyStringNnull앵커 항공편 상세 키
promotionPrincipleIdLongNnull프로모션 정책 ID
debugBooleanNfalse디버그 모드

Request Headers (왕복 전용)

헤더타입필수설명
cached-search-keyStringN캐시된 검색 키 (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국내선 검색 시도
제한 국가MethodArgumentInvalidExceptionSY, IR, UA, CU 경유
MIX 파라미터MethodArgumentInvalidExceptionINBOUND 검색 시 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-15Claude초기 문서 작성

관련 문서