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 }
)
}
}캐싱 조건:
useCache == true- 검색 결과가 비어있지 않음 (
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 기반의 장점
- 무상태 (Stateless): Session 관리 불필요
- Token 재사용: Redis 캐싱으로 성능 향상
- 표준 HTTP: 디버깅 및 모니터링 용이
- JSON 응답: 파싱 간편
- Gzip 압축: 네트워크 대역폭 절약
8.2 Amadeus/Sabre와의 차이점
| 항목 | Galileo | Amadeus | Sabre |
|---|---|---|---|
| API 타입 | REST | SOAP | REST |
| 인증 방식 | OAuth 2.0 (Password Grant) | Session Token (Stateful) | OAuth 2.0 (Client Credentials) |
| 엔드포인트 | CatalogProductOfferings v11 | FareMasterPricerTravelBoardSearch | BargainFinderMax 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 압축 | Gzip | Snappy | 없음 |
| NonTicketableCarrier | MH | MH | OZ + 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 성능 최적화 기법
- Token 캐싱: 만료 10분 전까지 재사용
- 병렬 검색: pmap으로 I/O 대기 최소화
- 검색 결과 캐싱: 동일 요청 즉시 반환
- Redis Gzip 압축: 메모리 절약
- Circuit Breaker: 장애 전파 방지
- 부분 실패 허용: 일부 성공 시 계속 진행
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 심층 분석 문서입니다.