Phase 1: Galileo 검색 API 심층 분석

1. API 엔드포인트 개요

1.1 검색 API

  • 엔드포인트: POST /internals/GALILEO/search
  • API 타입: REST API (CatalogProductOfferings v11)
  • Controller: GalileoSearchController.kt:24-33
  • Service: GalileoFlightSearchService.kt:33-93
  • Client: GalileoRestClient.kt:76-161

1.2 상세 조회 API

  • 엔드포인트: GET /internals/GALILEO/search
  • Controller: GalileoSearchController.kt:35-37
  • Service: GalileoFlightSearchService.kt:112-114

2. 핵심 비즈니스 로직 플로우

2.1 전체 처리 흐름도

flowchart TD
    A[요청 수신] --> B[캐시 키 생성]
    B --> C{Redis 캐시<br/>확인}
    C -->|캐시 히트| D[캐시된 결과 반환]
    C -->|캐시 미스| E[REST Token 발급<br/>OAuth 2.0]
    E --> F[공항 코드 확장<br/>makeOriginDestinations]
    F --> G[Cartesian Product<br/>모든 조합 생성]
    G --> H[병렬 검색 실행<br/>pmap]
    H --> I[REST 요청<br/>CatalogProductOfferings]
    I --> J{에러 체크}
    J -->|에러| K[빈 리스트 반환]
    J -->|정상| L[결과 파싱]
    L --> M[중복 제거<br/>distinctBy id]
    M --> N[필터링 1:<br/>국내공항 경유 제외]
    N --> O[필터링 2:<br/>출발/도착 검증]
    O --> P[필터링 3:<br/>미노출 FareItinerary]
    P --> Q[필터링 4:<br/>항공사 매칭]
    Q --> R{캐싱 조건<br/>확인}
    R -->|useCache=true &<br/>결과 존재| S[Redis 저장<br/>Gzip 압축]
    R -->|조건 미충족| T[응답 반환]
    S --> T[응답 반환]

    style C fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
    style D fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
    style E fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style H fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000
    style I fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style J fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style K fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000

2.2 단계별 상세 분석

Step 1: 캐시 키 생성

위치: GalileoSearchController.kt:27

val requestKey = CacheKeyGenerator.generateSearchRequestKey(
    supplier = Supplier.GALILEO,
    request = request
)

캐시 키 구조:

GALILEO_SEARCH_{originDestinations}_{cabins}_{passenger}_{timestamp}

Step 2: Redis 캐시 확인

위치: GalileoFlightSearchService.kt:45-47

flightSearchKeyRepository.findKey(requestKey = requestKey)?.let { key ->
    fareItineraryRepository.getFareItineraries(key = key)
}

캐시 히트 시: 즉시 결과 반환 (성능 최적화) 캐시 미스 시: 새로운 검색 수행

Step 3: REST Token 발급 (OAuth 2.0)

위치: GalileoRestClient.kt:48-74

fun getToken(): String {
    val galileoApiProperties = galileoProperties.getApiProperties()
 
    // 1. Redis 캐시 확인
    return findGalileoAccessTokenInRedis(pcc = galileoApiProperties.pcc) ?: run {
        // 2. OAuth 2.0 Token 발급 (grant_type=password)
        "${galileoApiProperties.rest.endpoint}/oauth2/v1/token"
            .post(
                FormBody.Builder()
                    .add("grant_type", "password")
                    .add("username", galileoApiProperties.rest.username)
                    .add("password", galileoApiProperties.rest.password)
                    .build()
            )
            .execute<AuthTokenRS>()
            .fold(
                success = { response ->
                    response.accessToken.also {
                        // 3. Redis 캐싱 (만료 10분 전까지)
                        saveGalileoAccessTokenInRedis(
                            pcc = galileoApiProperties.pcc,
                            accessToken = it,
                            expiresIn = response.expiresIn - 600  // -10분
                        )
                    }
                },
                failure = {
                    throw InternationalAdapterException(ErrorMessage.GET_TOKEN_FAILED)
                }
            )
    }
}

Token 캐싱 구조

  • 캐시 키: GALILEO_ACCESS_TOKEN::{pcc}
  • TTL: Token 만료 10분 전까지
  • 갱신: 자동 (만료 시 재발급)

Step 4: 공항 확장 (도시 → 공항 리스트)

위치: GalileoFlightSearchService.kt:51-52

AirportUtils.makeOriginDestinations(originDestinationLocationInfos)
    .cartesianProduct()  // 모든 조합 생성

예시:

입력: SEL (서울) → TYO (도쿄)
↓
확장: [ICN, GMP] → [NRT, HND]
↓
조합:
  - ICN → NRT
  - ICN → HND
  - GMP → NRT
  - GMP → HND

Step 5: 병렬 검색 실행

위치: GalileoFlightSearchService.kt:53-71

withBlocking(Dispatchers.IO) {
    // 모든 공항 조합을 병렬로 검색
    .pmap { originDestRequests ->  // 병렬 매핑 (Coroutines)
        galileoClient.search(
            token = token,
            requestKey = requestKey,
            key = key,
            passengerInfo = PassengerInfo.of(passenger = preferences.first().passenger),
            originDestinations = originDestRequests,
            cabins = cabins,
            airlines = airlines,
            onlyFreeBaggageInclude = onlyFreeBaggageInclude,
            promotionCodes = preferences.first().promotionCodes?.toList(),
            logging = logging
        )
    }.onFailure { exceptions, successes ->
        // 모든 요청이 실패한 경우에만 예외 발생
        if (successes.isEmpty()) {
            throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first())
        }
    }.getOrEmpty()
}

병렬 처리 특징

  • 도구: Kotlin Coroutines (Dispatchers.IO)
  • 패턴: pmap (parallel map)
  • 실패 전략: 일부 성공 시 계속 진행
  • 성능: I/O 대기 시간 최소화

Step 6: REST 요청 처리

위치: GalileoRestClient.kt:87-104

"${galileoApiProperties.rest.endpoint}/11/air/catalog/search/catalogproductofferings"
    .post(
        CatalogProductOfferingsRequest.of(
            passengerCounts = passengerInfo,
            searchAirLeg = originDestinations.map {
                SearchAirLeg.of(
                    originLocation = it.origin,
                    destinationLocation = it.destination,
                    searchDepTime = it.departureDate
                )
            },
            fareTransformKeys = cabins.map { FareTransformKeys.of(it) },
            maxResults = if (airlines == null) 300 else 50,
            preferredCarriers = airlines,
            accountCode = promotionCodes,
            ...
        )
    )
    .bearer(token)          // OAuth 2.0 Token
    .gzip()                 // Gzip 압축
    .client(searchClient)  // 30초 타임아웃
    .log(logging)
    .execute<CatalogProductOfferingsResponse>()

API 엔드포인트

  • URL: /11/air/catalog/search/catalogproductofferings
  • Method: POST
  • Timeout: 30초 (GalileoRestClient.kt:35)
  • Content-Type: application/json
  • Accept-Encoding: gzip

Step 7: 응답 파싱 및 변환

위치: GalileoRestClient.kt:107-145

response.fold(
    success = { response ->
        // 에러 체크
        response.checkError()
 
        // Air Segment 매핑
        val airSegmentMap = response.airSegmentList
            ?.associateBy { it.key }
            ?: emptyMap()
 
        // FlightDetails 매핑
        val flightDetailsMap = response.flightDetailsList
            ?.associateBy { it.key }
            ?: emptyMap()
 
        // FareInfo 매핑
        val fareInfoMap = response.fareInfoList
            ?.associateBy { it.key }
            ?: emptyMap()
 
        // Brand 매핑
        val brandMap = response.brandList
            ?.associateBy { it.key }
            ?: emptyMap()
 
        // 결과 변환
        response.airPriceResult?.map { airPriceResult ->
            airPriceResult.toFareItinerary(
                requestKey = requestKey,
                key = key,
                airSegmentMap = airSegmentMap,
                flightDetailsMap = flightDetailsMap,
                fareInfoMap = fareInfoMap,
                brandMap = brandMap,
                fareItinerary = null
            )
        } ?: emptyList()
    },
    failure = {
        throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED)
    }
)

3. 조건부 분기 및 예외 처리

3.1 에러 메시지 처리

에러 코드 체계

위치: CatalogProductOfferingsResponse.kt:19-39

fun checkError() {
    this.responseMessage?.firstOrNull { it.code != 0 }?.let { message ->
        when (message.type) {
            // 1000 번대: 객체 검증 에러
            in 1000..1999 -> throw InternationalAdapterException(
                ErrorMessage.SEARCH_FAILED,
                message.code,
                message.value
            )
 
            // 2000 번대: 시스템 에러
            in 2000..2999 -> throw InternationalAdapterException(
                ErrorMessage.SEARCH_FAILED,
                message.code,
                message.value
            )
 
            // 9000 번대: 검색 결과 없음
            in 9000..9999 -> {
                // 검색 결과 없음은 빈 리스트 반환 (에러 아님)
            }
        }
    }
}

에러 코드 분류:

  • 1000~1999: 객체 검증 에러 (잘못된 요청)
  • 2000~2999: 시스템 에러 (서버 문제)
  • 9000~9999: 검색 결과 없음 (정상 응답)

3.2 필터링 로직

3.2.1 국내 공항 경유 필터링

위치: GalileoFlightSearchService.kt:99-110

private fun List<Schedule>.withoutDomesticAirportInViaRoute(): Boolean {
    // ICN 출발이 아니면 패스
    if (CityUtils.isDomesticAirport(this.first().segments.first().departure).not()) {
        return true
    }
 
    // 출발지와 최종 도착지를 제외한 모든 공항이 해외 공항인지 확인
    return this.flatMapIndexed { groupIndex, schedule ->
        schedule.segments.flatMapIndexed { index, segment ->
            listOfNotNull(
                segment.departure.takeUnless { groupIndex == 0 && index == 0 },
                segment.arrival.takeUnless {
                    groupIndex == this.lastIndex && index == schedule.segments.lastIndex
                }
            )
        }
    }.none { CityUtils.isDomesticAirport(it) }
}

필터링 조건:

flowchart TD
    A[Schedule] --> B{ICN 출발?}
    B -->|No| C[통과]
    B -->|Yes| D{경유지에<br/>국내 공항 있음?}
    D -->|Yes| E[제외]
    D -->|No| C

    style B fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style E fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style C fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

목적: ICN 출발 시 중간 경유지에 국내 공항이 없어야 함

예시:

OK:     ICN → NRT → LAX (해외 경유)
OK:     ICN → LAX (직항)
제외:    ICN → GMP → NRT (국내 경유)
제외:    ICN → PUS → LAX (국내 경유)

3.2.2 NonTicketableCarrier 제외 (MH 항공사)

위치: GalileoRestClient.kt:163-169

private fun FareItinerary.hasNonTicketableCarrier(): Boolean {
    return this.validatingCarrier == "MH" && this.schedules.any { schedule ->
        schedule.segments.any {
            it.marketingCarrier != this.validatingCarrier
        }
    }
}

판별 로직:

flowchart TD
    A[FareItinerary] --> B{Validating Carrier<br/>== MH?}
    B -->|No| C[Ticketable]
    B -->|Yes| D{Marketing Carrier<br/>!= MH?}
    D -->|Yes| E[NonTicketable<br/>제외]
    D -->|No| C

    style B fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style E fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style C fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

제외 이유: MH (Malaysia Airlines) 항공사가 Validating Carrier이면서 타 항공사가 Marketing Carrier인 경우 발권 불가

3.2.3 미노출 FareItinerary 필터링

위치: GalileoFlightSearchService.kt:116-124

fun List<FareItinerary>.filterByUnexposedFareItinerary(): List<FareItinerary> {
    return if (this.isEmpty()) {
        this
    } else {
        unexposedFareItineraryRepository.findUnexposedFareItineraries(this.first().requestKey)
            ?.let { soldOutIds ->
                this.filterNot { soldOutIds.contains(it.id) }
            } ?: this
    }
}

목적: SOLD_OUT으로 감지된 FareItinerary는 검색 결과에서 제외 저장 시점: 예약/Pricing 실패 시 자동 저장 (GalileoBookingService.kt:98-101)

3.2.4 항공사 필터링

위치: GalileoFlightSearchService.kt:126-132

private fun List<FareItinerary>.filterIncludedAirline(airlines: List<String>?): List<FareItinerary> {
    return if (airlines == null) {
        this
    } else {
        this.filter { airlines.contains(it.validatingCarrier) }
    }
}

필터링 시점: 모든 다른 필터링 이후 마지막 단계


4. 캐싱 전략

4.1 Redis 캐싱 구조

Galileo 캐싱 레이어:
1. REST Token: GALILEO_ACCESS_TOKEN::{pcc}
2. Search Key: flightSearchKey:{requestKey}
3. FareItinerary: fareItinerary:{key}

4.2 캐싱 로직

위치: GalileoFlightSearchService.kt:82-90

.also {
    // Redis 캐싱 (Gzip 압축)
    if (useCache && it.isNotEmpty()) {
        flightSearchKeyRepository.addKey(requestKey = requestKey, key = key)
        fareItineraryRepository.saveFareItineraries(
            key = key,
            values = it.associateBy { fareItinerary -> fareItinerary.itemKey }
        )
    }
}

캐싱 조건:

  1. useCache == true
  2. 검색 결과가 비어있지 않음 (it.isNotEmpty())

4.3 Redis Gzip 압축

위치: RedisConfiguration.kt:24-27

this.valueSerializer = GzipRedisSerializer<FareItinerary>(
    Jackson2JsonRedisSerializer(objectMapper, FareItinerary::class.java)
)

압축 이유: FareItinerary는 직렬화 크기가 크므로 Gzip 압축으로 메모리 절약 비교: Amadeus는 Snappy 압축 사용, Sabre는 압축 없음


5. API 응답 구조

5.1 CatalogProductOfferingsResponse 구조

data class CatalogProductOfferingsResponse(
    val responseMessage: List<ResponseMessage>?,
    val airSegmentList: List<AirSegment>?,
    val flightDetailsList: List<FlightDetails>?,
    val fareInfoList: List<FareInfo>?,
    val brandList: List<Brand>?,
    val airPriceResult: List<AirPriceResult>?
)

응답 파싱 전략:

  • 각 List를 Map으로 변환 (key 기반 빠른 조회)
  • associateBy { it.key } 사용

5.2 응답 데이터 매핑

위치: GalileoRestClient.kt:107-145

val airSegmentMap = response.airSegmentList?.associateBy { it.key } ?: emptyMap()
val flightDetailsMap = response.flightDetailsList?.associateBy { it.key } ?: emptyMap()
val fareInfoMap = response.fareInfoList?.associateBy { it.key } ?: emptyMap()
val brandMap = response.brandList?.associateBy { it.key } ?: emptyMap()
 
response.airPriceResult?.map { airPriceResult ->
    airPriceResult.toFareItinerary(
        requestKey = requestKey,
        key = key,
        airSegmentMap = airSegmentMap,
        flightDetailsMap = flightDetailsMap,
        fareInfoMap = fareInfoMap,
        brandMap = brandMap,
        fareItinerary = null
    )
}

성능 최적화: List → Map 변환으로 O(1) 조회


6. 성능 최적화

6.1 병렬 처리

도구: Kotlin Coroutines pmap

위치: GalileoFlightSearchService.kt:53-71

AirportUtils.makeOriginDestinations(originDestinationLocationInfos)
    .cartesianProduct()
    .pmap { originDestRequests ->  // 병렬 실행 (Dispatchers.IO)
        galileoClient.search(/* ... */)
    }
    .onFailure { exceptions, successes ->
        // 부분 성공 허용
        if (successes.isEmpty()) {
            throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first())
        }
    }
    .getOrEmpty()

병렬 처리 장점:

  • 다중 구간 검색 시 동시 실행
  • 일부 실패해도 성공한 결과 반환
  • I/O 대기 시간 최소화

성능 비교:

순차 처리: 4개 조합 * 3초 = 12초
병렬 처리: max(3초) = 3초 (약 4배 향상)

6.2 Token 캐싱

위치: GalileoRestClient.kt:48-74

  • Redis에 Token 캐싱
  • 만료 10분 전까지 재사용
  • 불필요한 OAuth 2.0 요청 방지

성능 향상:

Token 발급 시간: ~500ms
캐싱 조회 시간: ~10ms (약 50배 향상)

6.3 검색 결과 캐싱

위치: GalileoFlightSearchService.kt:45-47, 82-90

  • 동일 검색 요청 시 Redis에서 즉시 반환
  • Gzip 압축으로 메모리 절약
  • TTL 기반 자동 만료
  • useCache 파라미터로 제어

성능 향상:

검색 API 호출: ~3초
캐시 조회: ~50ms (약 60배 향상)

6.4 Circuit Breaker

위치: GalileoSearchController.kt:24

@CircuitBreaker(name = "galileoSearch", fallbackMethod = "searchFallback")
@PostMapping
fun search(@RequestBody request: SearchRequest): List<FareItineraryView>

설정 (application.yml):

resilience4j.circuitbreaker:
  instances:
    galileoSearch:
      slidingWindowSize: 180
      failureRateThreshold: 35
      waitDurationInOpenState: 120s

동작:

  • 180초 윈도우에서 실패율 35% 초과 시 Open
  • Open 상태 2분 유지
  • Half-Open → Closed 전환

7. 에러 처리 전략

7.1 에러 타입별 처리

// 1. 검색 결과 없음 (9000번대)
9000~9999
→ 빈 리스트 반환 (정상 응답)
 
// 2. 객체 검증 에러 (1000번대)
1000~1999
InternationalAdapterException(ErrorMessage.SEARCH_FAILED)
 
// 3. 시스템 에러 (2000번대)
2000~2999
InternationalAdapterException(ErrorMessage.SEARCH_FAILED)
 
// 4. Token 발급 실패
InternationalAdapterException(ErrorMessage.GET_TOKEN_FAILED)
 
// 5. 네트워크 타임아웃
SocketTimeoutException (30초)

7.2 부분 실패 허용

위치: GalileoFlightSearchService.kt:66-70

.onFailure { exceptions, successes ->
    // 모든 요청이 실패한 경우에만 예외 발생
    if (successes.isEmpty()) {
        throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first())
    }
}

예시:

4개 조합 검색:
- ICN → NRT: 성공 (100개 결과)
- ICN → HND: 실패 (타임아웃)
- GMP → NRT: 성공 (50개 결과)
- GMP → HND: 실패 (에러)

결과: 150개 결과 반환 (성공한 2개만)

8. 주요 발견사항 및 특징

8.1 REST API 기반의 장점

  1. 무상태 (Stateless): Session 관리 불필요
  2. Token 재사용: Redis 캐싱으로 성능 향상
  3. 표준 HTTP: 디버깅 및 모니터링 용이
  4. JSON 응답: 파싱 간편
  5. Gzip 압축: 네트워크 대역폭 절약

8.2 Amadeus/Sabre와의 차이점

항목GalileoAmadeusSabre
API 타입RESTSOAPREST
인증 방식OAuth 2.0 (Password Grant)Session Token (Stateful)OAuth 2.0 (Client Credentials)
엔드포인트CatalogProductOfferings v11FareMasterPricerTravelBoardSearchBargainFinderMax v5
Token 캐싱Redis (만료 10분 전)없음Redis (만료 전)
병렬 처리pmap (REST)cartesianProduct + pmap (SOAP)pmap (REST)
타임아웃30초 (searchClient)기본 타임아웃30초 (searchClient)
Circuit Breaker있음 (Resilience4j)있음 (Resilience4j)없음
에러 코드1000/2000/9000 번대866, 931, 977, 996, 118, 950에러 메시지 목록
압축Gzip (응답)없음없음
Redis 압축GzipSnappy없음
NonTicketableCarrierMHMHOZ + excludeCarriers
국내 경유 체크있음 (ICN 출발만)있음있음

8.3 독특한 처리 방식

1. OAuth 2.0 Password Grant

  • Galileo: grant_type=password (사용자 credential)
  • Sabre: grant_type=client_credentials (클라이언트 credential)

2. 국내 공항 경유 필터링

  • ICN 출발인 경우만 체크
  • Amadeus/Sabre: 모든 국내 공항 출발 체크

3. MH 항공사 특별 처리

  • Validating Carrier가 MH이면서 Marketing Carrier가 다른 항공사인 경우 제외
  • Amadeus/Sabre: 유사한 로직 없음

4. Gzip 압축 사용

  • 요청/응답 모두 Gzip 압축
  • Amadeus/Sabre: 압축 없음

8.4 성능 최적화 기법

  1. Token 캐싱: 만료 10분 전까지 재사용
  2. 병렬 검색: pmap으로 I/O 대기 최소화
  3. 검색 결과 캐싱: 동일 요청 즉시 반환
  4. Redis Gzip 압축: 메모리 절약
  5. Circuit Breaker: 장애 전파 방지
  6. 부분 실패 허용: 일부 성공 시 계속 진행

9. 개선 가능 영역

9.1 에러 메시지 상수화

현황: 에러 코드가 하드코딩되어 있음

제안:

object GalileoErrorCodes {
    val OBJECT_VALIDATION_ERRORS = 1000..1999
    val SYSTEM_ERRORS = 2000..2999
    val NO_RESULTS = 9000..9999
}

9.2 Circuit Breaker Fallback 구현

현황: Fallback 메서드 선언만 있고 구현 없음

제안:

fun searchFallback(request: SearchRequest, e: Exception): List<FareItineraryView> {
    logger.error("Galileo search failed, returning empty list", e)
    slackService.sendSearchCircuitOpen(Supplier.GALILEO, e.message)
    return emptyList()
}

9.3 캐시 워밍업

현황: 첫 요청 시 캐시 미스 발생

제안: 인기 노선에 대한 캐시 워밍업 배치 작업


10. 참고 자료

10.1 주요 클래스

  • GalileoFlightSearchService.kt: 검색 서비스 (33-133)
  • GalileoRestClient.kt: REST 클라이언트 (31-171)
  • CatalogProductOfferingsRequest.kt: 요청 모델
  • CatalogProductOfferingsResponse.kt: 응답 모델
  • AirportUtils.kt: 공항 확장 유틸리티

10.2 주요 메소드

  • GalileoRestClient.getToken(): OAuth 2.0 Token 발급 (48-74)
  • GalileoRestClient.search(): CatalogProductOfferings 검색 (76-161)
  • GalileoFlightSearchService.search(): 검색 통합 로직 (33-93)
  • List<Schedule>.withoutDomesticAirportInViaRoute(): 국내 공항 경유 필터링 (99-110)

10.3 설정 파일

  • application.yml: Circuit Breaker 설정
  • GalileoProperties.kt: Galileo API 설정
  • RedisConfiguration.kt: Gzip 압축 설정

10.4 관련 문서

  • Galileo REST API Guide (CatalogProductOfferings)
  • OAuth 2.0 Password Grant Specification

이 문서는 Triple Air International Adapter 프로젝트의 Galileo 검색 API 심층 분석 문서입니다.