Phase 1: 아마데우스 검색 API 심층 분석
1. API 엔드포인트 개요
1.1 검색 API
- 엔드포인트:
POST /internals/AMADEUS/search - Controller:
AmadeusSearchController.kt:25-53 - Service:
AmadeusFlightSearchService.kt:39-120 - Client:
AmadeusClient.kt:114-195
1.2 상세 조회 API
- 엔드포인트:
GET /internals/AMADEUS/search - Controller:
AmadeusSearchController.kt:55-70
2. 핵심 비즈니스 로직 플로우
2.1 전체 처리 흐름도
flowchart TD A[요청 수신] --> B{Circuit Breaker<br/>체크} B -->|정상| C[캐시 키 생성] B -->|OPEN| D[빈 리스트 반환<br/>Fallback] C --> E{Redis 캐시<br/>확인} E -->|캐시 히트| F[캐시된 결과 반환] E -->|캐시 미스| G[공항 코드 확장] G --> H[Cartesian Product<br/>모든 조합 생성] H --> I[병렬 검색 실행<br/>pmap] I --> J[SOAP 요청<br/>FareMasterPricerTravelBoardSearch] J --> K[결과 병합<br/>flatten] K --> L[중복 제거<br/>distinctBy] L --> M[필터링<br/>출발/도착 검증] M --> N{캐싱 조건<br/>확인} N -->|useCache=true &<br/>결과 존재| O[Redis 저장] N -->|조건 미충족| P[응답 반환] O --> P[응답 반환] 다크/라이트 모드 호환 색상 style B fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style D fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000 style E fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
조건 판단 코드
위치: AmadeusFlightSearchService.kt:40-44
useMultiTicket = if (request.preferences.all { it.fareFamilies.isNullOrEmpty() }) {
request.useMultiTicket // fareFamilies가 없을 때만 multi-ticket 사용
} else {
false
}필터링 로직
위치: AmadeusFlightSearchService.kt:83-97
.filter { fareItinerary ->
originDestinationLocationInfos.all {
val index = when {
useMultiTicket && fareItinerary.tripDirectionType != null -> 0
else -> originDestinationLocationInfos.indexOf(it)
}
val originDestinationLocationInfo = when (fareItinerary.tripDirectionType) {
TripDirectionType.OUTBOUND -> originDestinationLocationInfos.first()
TripDirectionType.INBOUND -> originDestinationLocationInfos.last()
else -> it
}
// 출발/도착 공항 일치 확인
val segments = fareItinerary.schedules[index].segments
originDestinationLocationInfo.origin.isSameCode(segments.first().departure)
&& originDestinationLocationInfo.destination.isSameCode(segments.last().arrival)
}
}4. 성능 최적화 전략
4.1 병렬 처리
위치: AmadeusFlightSearchService.kt:58-79
withBlocking(Dispatchers.IO) {
AirportUtils.makeOriginDestinations(originDestinationLocationInfos)
.cartesianProduct()
.pmap { /* 병렬 실행 */ }
.onFailure { exceptions, successes ->
if (successes.isEmpty()) {
throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first())
}
}
.getOrEmpty()
}4.2 캐싱 전략
캐시 저장 조건
위치: AmadeusFlightSearchService.kt:100-108
.also {
if (useCache && it.isNotEmpty()) {
flightSearchKeyRepository.set(requestKey = requestKey, key = key)
fareItineraryRepository.setFareItineraries(
key = key,
fareItineraries = it
)
}
}캐시 TTL
- 검색 결과: 30분 (Redis 설정)
- 키 구조:
AMADEUS_{channel}_{requestHash}_{timestamp}
4.3 중복 제거
위치: AmadeusFlightSearchService.kt:81
.flatten()
.distinctBy { it.id } // ID 기준 중복 제거5. 에러 처리 및 복구
에러 처리 플로우
flowchart TD A[검색 요청 실행] --> B{Circuit Breaker<br/>상태 확인} B -->|CLOSED| C[정상 실행] B -->|OPEN| D[즉시 Fallback<br/>빈 리스트 반환] B -->|HALF_OPEN| E[테스트 실행] C --> F{실행 결과} F -->|성공| G[결과 반환] F -->|실패| H{실패율 계산<br/>35% 초과?} H -->|예| I[Circuit Breaker<br/>OPEN 전환] H -->|아니오| J[실패 카운트 증가] E --> K{테스트 결과} K -->|성공| L[CLOSED 전환] K -->|실패| M[OPEN 유지] I --> N[120초 대기 후<br/>HALF_OPEN 전환] C --> O{병렬 요청 실패 시} O -->|모두 실패| P[예외 발생] O -->|부분 실패| Q[성공한 결과만<br/>사용] %% 다크/라이트 모드 호환 색상 style D fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style I fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000 style P fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style Q fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
5.1 Circuit Breaker 설정
위치: application.yml
resilience4j.circuitbreaker:
instances:
amadeusSearch:
slidingWindowSize: 180 # 180개 요청 기준
failureRateThreshold: 35 # 실패율 35% 초과 시 OPEN
waitDurationInOpenState: 120s # OPEN 상태 120초 유지
slowCallRateThreshold: 90 # Slow call 비율 90%
slowCallDurationThreshold: 15s # 15초 이상 시 slow call5.2 Fallback 메커니즘
위치: AmadeusSearchController.kt:73-80
private fun searchFallback(exception: CallNotPermittedException): ResponseEntity<List<FareItineraryView>> {
val span = GlobalTracer.get().activeSpan()
if (span != null) {
span.setTag("supplier.circuit-breaker", "OPEN")
(span as? MutableSpan)?.localRootSpan?.setTag("supplier.circuit-breaker", "OPEN")
}
return ResponseEntity.ok(emptyList()) // 빈 결과 반환
}5.3 부분 실패 처리
위치: AmadeusFlightSearchService.kt:75-79
.onFailure { exceptions, successes ->
if (successes.isEmpty()) {
// 모든 요청 실패 시만 예외 발생
throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first())
}
// 부분 성공 시 성공한 결과만 사용
}6. 타임아웃 설정
6.1 검색 타임아웃
위치: AmadeusClient.kt:95
searchTimeout = 25000 // 25초6.2 기본 타임아웃
defaultTimeout = 60000 // 60초7. 필터링 조건
7.1 직항 필터
위치: AmadeusFlightSearchService.kt (파라미터)
onlyDirect: Boolean // true 시 직항만 검색7.2 무료 수하물 포함 필터
onlyFreeBaggageInclude: Boolean // true 시 무료 수하물 포함 항공편만7.3 항공사 필터
airlines: List<String>? // 특정 항공사만 검색
sotoAirlines: List<String>? // 제외할 항공사8. 로깅 및 모니터링
8.1 검색 로깅
위치: AmadeusClient.kt:113
private val enableSearchLog = supplierLoggingProperties.search.contains(Supplier.AMADEUS)8.2 MDC 컨텍스트
MDC_SUPPLIER_KEY -> "AMADEUS"
MDC_FARE_KEY -> fareKey
MDC_SCHEDULE_KEY -> scheduleKey
MDC_VALIDATING_CARRIER_KEY -> validatingCarrier9. 주요 예외 사항 및 주의점
9.1 FareFamilies 존재 시 Multi-ticket 비활성화
- 이유: FareFamilies 옵션과 Multi-ticket은 상호 배타적
- 위치:
AmadeusFlightSearchService.kt:40-44
9.2 부분 실패 허용
- 정책: 일부 요청 성공 시 성공한 결과만 반환
- 전체 실패 시만 예외 발생
9.3 특정 에러 코드 무시
- 866, 931, 977, 996, 118, 950: 운임 관련 경미한 오류로 무시
10. 트러블슈팅 가이드
문제 1: Circuit Breaker OPEN
증상: 검색 결과가 항상 빈 배열 해결:
- Circuit Breaker 상태 확인
- 실패율 확인 (35% 미만인지)
- 120초 대기 후 자동 복구
문제 2: 캐시 미스 발생
증상: 동일 검색이 매번 느림 해결:
- Redis 연결 상태 확인
- TTL 설정 확인
- 캐시 키 생성 로직 확인
문제 3: MH 항공사 예약 불가
증상: MH 항공사 포함 시 오류 원인: Non-ticketable carrier 체크 해결: 마케팅 항공사 일치 여부 확인
다음 Phase
Phase 2: 예약 API 심층 분석으로 진행