설정 & 인프라 (Redis·OkHttp·S3)
arch-cross-cutting config-infrastructure config-redis config-okhttp
이 노트의 범위
air-intl-adapter가 외부 세계와 닿는 “공통 인프라 배선”을 다룬다. 공급사별 비즈니스 로직이 아니라 모든 공급사가 공유하는 토대 — Redis 캐싱/직렬화, OkHttp HTTP 클라이언트, S3, Slack, City API, 그리고 프로파일/Secrets Manager 기반 설정 로딩이다. 공급사 인증값(IATA, PCC, 패스워드 등)이 어떻게 주입되는지는 support-common의
Properties.kt·MDC 흐름과 함께 읽으면 좋다.
1. 전체 인프라 지도
flowchart TD Triple["HTTP from Triple"] --> IF["interfaces"] subgraph ADAPTER ["air-intl-adapter (JVM)"] IF --> APP["application"] APP --> INFRA["infrastructure"] INFRA --> OkHttp["OkHttp (ClientSupport)"] APP --> Cache["@Cacheable"] INFRA --> CityClient["CityClient"] INFRA --> SlackClient["SlackClient"] Cache --> RCM["RedisCacheManager"] RCM --> Redisson["Redisson (RLockless)"] INFRA --> S3["AmazonS3"] end OkHttp --> GDS["공급사 GDS/NDC/LCC"] CityClient --> CityApi["city-api (내부)"] SlackClient --> SlackApi["Slack API"] S3 --> S3Bucket["S3 (ap-northeast-2)"] Redisson --> ElastiCache["ElastiCache Serverless (rediss:// 클러스터)"]
핵심 관찰 3가지
- 단일 진입점 X. 중앙 디스패처가 없듯 인프라도 “공통 추상 클래스 + 공급사별 주입”으로 구성된다. 예: OkHttp 클라이언트는
ClientSupport라는 추상 클래스가 만들고 11개 공급사 클라이언트가 이를 상속한다.- Redisson이 Redis 연결의 단일 소스. Spring Data Redis(
@Cacheable)·RedisTemplate·향후 분산락 모두가 단 하나의RedissonClient위에서 동작한다.- 민감정보는 코드/yml에 없다. 공급사 인증값은 전부 AWS Secrets Manager에서 런타임에 로딩된다.
supplier/*.yml은 “어느 시크릿을 import할지”만 적힌 껍데기다.
2. Redis — Redisson 단일 클라이언트 + 다층 직렬화
2.1 연결 계층: RedisConfiguration
configuration/RedisConfiguration.kt가 모든 Redis 접근의 뿌리다.
@Primary
@Bean(destroyMethod = "shutdown")
fun redissonClient(redissonProperties: RedissonProperties): RedissonClient {
val config = Config.fromYAML(resourceLoader.getResource(redissonProperties.file).inputStream)
return Redisson.create(config)
}
@Primary @Bean
fun redissonConnectionFactory(redisson: RedissonClient) = RedissonConnectionFactory(redisson)| 빈 | 타입 | 역할 |
|---|---|---|
redissonClient | RedissonClient | spring.redis.redisson.file이 가리키는 redisson/redisson-{env}.yml을 읽어 생성. destroyMethod="shutdown"로 종료 시 풀 정리 |
redissonConnectionFactory | RedissonConnectionFactory | Spring Data Redis ↔ Redisson 브릿지. @Primary 라 모든 RedisConnectionFactory 주입의 기본값 |
objectRedisTemplate | RedisTemplate<String, Any> | 키만 StringRedisSerializer. 값 직렬화는 명시 안 함(JDK 직렬화 기본) — 그래서 도메인 객체가 Serializable이어야 함 |
stringRedisTemplate | StringRedisTemplate | 문자열 전용 |
@Primary가 세 군데나 붙어 있다
redissonClient,redissonConnectionFactory,objectRedisTemplate모두@Primary. Spring Boot가 자동 구성하는 Lettuce 기반RedisConnectionFactory를 Redisson 것으로 강제 교체하기 위함이다. 공급사 모듈이 자체RedisTemplate을 정의할 때(아래 2.4) 이@Primary팩토리를 주입받는다.@Primary를 떼면 “여러 후보 빈 충돌”로 부팅 실패한다.
2.2 캐시 매니저: RedisCacheConfiguration + CacheSet
@Cacheable 어노테이션이 동작하는 근거다.
@EnableCaching @Configuration
class RedisCacheConfiguration(private val redisConnectionFactory: RedisConnectionFactory) {
@Bean
fun redisCacheManager(): RedisCacheManager =
RedisCacheManager.builder(redisConnectionFactory)
.withInitialCacheConfigurations(
CacheSet.entries.associate { it.cacheName to redisCacheConfiguration().entryTtl(it.ttl) }
)
.disableCreateOnMissingCache() // ← 사전 등록된 캐시만 허용
.build()
}
disableCreateOnMissingCache()-- 오타 캐시명은 런타임 폭발
@Cacheable(value = ["오타난-캐시명"])처럼CacheSet에 없는 이름을 쓰면, 캐시가 lazy 생성되지 않고IllegalArgumentException("Cannot find cache named ...")가 던져진다. 새 캐시를 추가하려면 반드시support/cache/CacheSet.kt의 enum에 항목을 먼저 등록해야 한다. 이건 의도된 안전장치다 — TTL 없는 캐시가 슬그머니 생기는 것을 막는다.
disableCachingNullValues()도 걸려 있어서 null 결과는 캐싱되지 않는다(다음 호출에서 다시 원본을 친다).
2.3 캐시 카탈로그 (support/cache/CacheSet.kt)
전체 캐시와 TTL. TTL 설계 의도를 읽는 게 핵심이다.
| CacheSet | 캐시명(Redis 키 prefix) | TTL | 의미·주의 |
|---|---|---|---|
FLIGHT_SEARCH_KEY | ADAPTER-FLIGHT-SEARCH-KEY | 20분 | 검색결과→재조회 키 매핑. 검색 세션 수명 |
FARE_ITINERARY | ADAPTER-FARE-ITINERARY | 60분 | 운임 여정 캐시 |
FARE_RULE | ADAPTER-FARE-RULES | 60분 | 운임 규정 |
SABRE_TOKEN | SABRE-TOKEN | 6일 | Sabre 세션 토큰 (장수명) |
SABRE_ACCESS_TOKEN | SABRE-ACCESS-TOKEN | 6일 | Sabre REST 액세스 토큰 |
AIRPORT | INTERNATIONAL-ADAPTER-AIRPORT | 1일 | City API 공항 조회 캐시 |
CITY | INTERNATIONAL-ADAPTER-CITY | 1일 | City API 도시 조회 캐시 |
UNEXPOSED_FARE_ITINERARY | ADAPTER-FARE-ITINERARY-SCHEDULE | 60분 | 미노출 운임 |
QUEUE_PNR_INFO_KEY | QUEUE-PNR-INFO | 1일 | 큐 PNR 정보 |
FLIGHT_AMENITY | ADAPTER-FLIGHT-AMENITY | 20분 | 좌석 편의 |
REISSUE | ADAPTER-REISSUE-CACHE | 3분 | 재발행 임시 컨텍스트 (초단명) |
GALILEO_REST_TOKEN | GALILEO-REST-TOKEN | 85800초(=23h50m) | 주석: 토큰 만료(24h)에서 -10분 마진 |
AMADEUS_INIT_REFUND | INTERNATIONAL-ADAPTER-AMADEUS-INIT-REFUND | 1시간 | 환불 init 단계 상태 |
KOREANAIR_CANCELABLE_TYPE_DETAIL | ...KOREANAIR-ANCELABLE-TYPE-DETAIL | 15분 | (캐시명에 오타 ANCELABLE. 상수와 일치하므로 동작엔 무해) |
TWAY_ROUTE | INTERNATIONAL-ADAPTER-TWAY-ROUTE | 1일 | T’way 노선 |
AMADEUSNDC_BOOKING | INTERNATIONAL-ADAPTER-AMADEUSNDC-BOOKING | 60분 | NDC 예약 컨텍스트 |
TTL이 의미를 가진다 -- 왜 6일 vs 3분인가?
- 토큰류(6일, 23h50m): 공급사가 발급한 토큰의 실제 만료시간에 맞춘다.
GALILEO_REST_TOKEN은 “토큰 만료 -10분”을 명시 주석으로 남길 만큼 만료 직전 재사용 사고를 의식했다.- 상태 컨텍스트(3분, 1시간):
REISSUE(3분),AMADEUS_INIT_REFUND(1시간)는 “사용자가 이 화면에서 다음 액션을 할 때까지” 살아있어야 하는 작업 중간상태. 짧을수록 stale 위험이 적다.- 참조 데이터(1일): 공항/도시/노선은 거의 안 변하므로 길게.
2.4 직렬화 전략 — 두 갈래로 갈린다
여기가 신입이 가장 헷갈리는 지점이다. Redis에 값을 쓰는 경로가 두 개다.
flowchart TD A["@Cacheable 경로 (City, FareRule 등)"] --> B["redisCacheManager"] B --> C["defaultCacheConfig<br/>= JDK 직렬화, Serializable 필요"] D["RedisTemplate 경로 (Amadeus FareItinerary, InitRefund 등)"] --> E["공급사별 @Bean RedisTemplate"] E --> F["Gzip/Snappy + Jackson JSON"]
| 경로 | 직렬화기 | 압축 | 적용 예 |
|---|---|---|---|
@Cacheable (redisCacheManager) | 기본 defaultCacheConfig() (JDK) | 없음 | CityClient.getCityByIata, getAirportByIata — 그래서 City/Airport가 Serializable 구현 |
공급사 RedisTemplate | Jackson2JsonRedisSerializer 래핑 | GZIP 또는 Snappy | AmadeusRedisConfiguration의 FareItinerary, InitRefund |
support/cache/에 두 압축 직렬화기가 있다:
// GzipRedisSerializer: 기존 직렬화기를 감싸 GZIP(BEST_SPEED) 압축
class GzipRedisSerializer<T>(private val serializer: RedisSerializer<T>) : RedisSerializer<T> { ... }
// SnappyRedisSerializer: Snappy 압축 (속도 우선)
class SnappyRedisSerializer<T>(private val serializer: RedisSerializer<T>) : RedisSerializer<T> { ... }supplier/amadeus/configuration/RedisConfiguration.kt 사용 예:
@Bean
fun amadeusFareItineraryRedisTemplate(redissonConnectionFactory: RedisConnectionFactory): RedisTemplate<String, FareItinerary> =
RedisTemplate<String, FareItinerary>().apply {
connectionFactory = redissonConnectionFactory
keySerializer = StringRedisSerializer()
valueSerializer = GzipRedisSerializer(Jackson2JsonRedisSerializer(objectMapper, FareItinerary::class.java))
hashValueSerializer = GzipRedisSerializer(Jackson2JsonRedisSerializer(objectMapper, FareItinerary::class.java))
}왜 압축하나?
GDS 운임 여정(
FareItinerary)은 한 검색에 수백 KB~MB 규모의 거대 JSON이다. ElastiCache Serverless는 저장량/네트워크 과금이므로 GZIP로 60~90% 줄이면 비용·대역폭이 크게 준다. 11개 공급사 모두 자체configuration/RedisConfiguration.kt를 갖고(grep결과 11개) 도메인 타입별RedisTemplate을 정의하므로, 압축 직렬화기는 공통 자산이지만 어떤 타입에 쓸지는 공급사가 결정한다.
직렬화기 불일치 = 역직렬화 폭발
같은 Redis 키를 한쪽은 JDK(
@Cacheable), 다른쪽은 Gzip+JSON(RedisTemplate)으로 읽으면 깨진다. 키 prefix(CacheSet상수)로 영역이 분리돼 있으니 새 캐시 추가 시 어느 경로를 쓸지부터 정하고 키 충돌을 피하라.
2.5 캐시 키 생성: CacheKeyGenerator
검색 요청을 결정론적 키로 압축한다(support/cache/CacheKeyGenerator.kt).
return "${MDCHolder.SalesChannel.get()}${MDCHolder.SalesFunnel.get()}${supplier}_${scheduleKey}${extraKey}${airlineKey}"| 키 구성요소 | 출처 | 비고 |
|---|---|---|
| SalesChannel / SalesFunnel | MDCHolder (요청 헤더) | 채널/퍼널별로 캐시 분리 — 같은 검색도 TRIPLE/YANOLJA가 다른 키 |
| supplier | enum | 공급사별 분리 |
| scheduleKey | origin+destination(정렬)+departureDate(yyMMdd) | 공항코드를 정렬해 순서 무관 동일키 |
| extraKey | preferences·cabins·onlyDirect·freeBaggage·multiTicket | 검색 옵션 전부 반영 |
| airlineKey | airlines(정렬) | 항공사 필터 |
정렬이 캐시 적중률을 좌우한다
origin.airports.sorted(),cabins.sorted()처럼 입력 순서를 정규화한다. 안 그러면[ICN,GMP]와[GMP,ICN]이 다른 키가 되어 캐시 미스가 난다. 새 검색 옵션을 추가할 때 키 생성에 빠뜨리면 “옵션이 달라도 같은 캐시를 반환”하는 버그가 생긴다 — 반대로 정렬을 빠뜨리면 적중률이 떨어진다.
2.6 분산락(Redisson RLock)은 현재 미사용
"분산락" 기대했다면
RedissonClient가 있으니getLock()/tryLock()을 쓸 법하지만, 현재 메인 소스에RLock/getLock/tryLock직접 호출은 없다(전체 grep 결과RedisConfiguration.kt만RedissonClient를 참조). 즉 Redisson은 연결 팩토리로만 쓰이고 분산락 기능은 아직 활용 안 된다. 동시성 제어가 필요한 발권/취소 흐름의 “이벤트/상태 전파”는 메시지큐가 아니라 Resilience4j 상태전이 + 예외 전파 + Slack 경보로 구현된다 → resilience-and-events.
3. OkHttp — 추상 클래스 기반 공급사별 클라이언트
3.1 오해 주의: OkhttpClientConfiguration.kt엔 클라이언트 빈이 없다
파일명만 보면 OkHttpClient @Bean이 있을 것 같지만, configuration/OkhttpClientConfiguration.kt에는 LoggingAndCompressionInterceptor 한 클래스만 들어있다(@Bean 없음, @Configuration 없음). 실제 OkHttpClient 생성은 support/web/ClientSupport.kt에 있다.
flowchart TD CS["ClientSupport (abstract)<br/>OkHttpClient 빌더 2개 보유"] CS --> SC["searchClient<br/>timeout=searchTimeout, 검색용(짧음)"] CS --> DC["defaultClient<br/>timeout=defaultTimeout, 발권/조회용(김)"] CS --> City["CityClient (search=30000, default=3000)"] CS --> Amadeus["AmadeusClient (search=25000, default=60000)"] CS --> Sabre["SabreClient / SabreRestClient / ..."] CS --> Etc["...11개 공급사 + 서브클라이언트(ART, GPS, KRT, KPS 등)"]
abstract class ClientSupport(
val objectMapper: ObjectMapper,
val searchTimeout: Int = 30000, // 검색 기본 30s
val defaultTimeout: Int = 60000, // 그 외 기본 60s
) {
val searchClient: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(LoggingAndCompressionInterceptor())
.connectTimeout(searchTimeout..., MILLISECONDS)
.readTimeout(...).writeTimeout(...).callTimeout(...)
.build()
val defaultClient: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(LoggingAndCompressionInterceptor())
.connectTimeout(defaultTimeout...).readTimeout(...).writeTimeout(...).callTimeout(...)
.build()
...
}| 측면 | 설계 | 주의 |
|---|---|---|
| 클라이언트 종류 | searchClient(짧음) / defaultClient(김) 2개 | 공급사 클라이언트가 .client(searchClient)로 명시 선택. 미지정 시 defaultClient |
| 타임아웃 단위 | connect/read/write/callTimeout 모두 동일값 | callTimeout은 호출 전체 상한 — 가장 강력한 차단막 |
| 타임아웃 오버라이드 | 공급사가 생성자에서 searchTimeout=25000 등으로 조정 / 요청 단위 .callTimeout(...) 메서드 | 예: CityClient는 defaultTimeout=3000(내부 API라 빠름) |
| 인터셉터 | 모든 클라이언트에 LoggingAndCompressionInterceptor 1개 | 풀/디스패처/HTTP2 protocols는 별도 설정 없음(OkHttp 기본값) |
공급사 클라이언트마다 OkHttpClient 인스턴스가 새로 생긴다
searchClient/defaultClient는ClientSupport의 인스턴스 필드다. 즉 공급사 클라이언트 빈마다 OkHttpClient 2개씩 생성된다(연결풀/디스패처 비공유). OkHttp 권장은 “클라이언트 공유,newBuilder()로 파생”이지만 여기선 그렇지 않다. 단 공급사 클라이언트는 싱글톤 빈이라 인스턴스 수 자체는 제한적이다. 추가 클라이언트를 만들 때 이 비공유 특성을 인지하라.
3.2 LoggingAndCompressionInterceptor — 로깅과 압축해제를 겸한다
configuration/OkhttpClientConfiguration.kt. 이름대로 두 가지를 한다.
- 응답 압축 해제:
Content-Encoding이gzip/deflate/br(Brotli)면 디코딩해 원문 로깅 + body 교체. - 요청/응답 구조화 로깅:
LogMessage태그(request.tag())를 읽어RQ:/RS:라인을clientLogger(SUPPLIER.{ClientName})로 남긴다.StructuredArguments로 url/headers를 JSON 필드화.
// 200KB 초과 응답은 청크 분할 로깅 (로그 시스템 라인길이 한계 회피)
val chunkedSize = 1_024 * 200
... "RS[${index+1}/${size}]: $requestName %s" ...
// Lufthansa만 개행/탭 제거 (NDC XML이 줄바꿈 범벅이라 로그 깨짐 방지)
if (clientLogger.name == "SUPPLIER.LufthansaClient") message?.replace(Regex("[\\n\\r\\t]"), "")로그 활성화 토글은 두 단계
- 공급사 전체 검색 로그:
supplier.logging.search(SupplierLoggingProperties) —application-{env}.yml에서AMADEUS,SABRE,...형태로 켠다. prod는 기본 OFF(${SEARCH_LOG_SUPPLIERS:}환경변수).- 요청 단위:
OkHttpRequestBuilder.log(false)로 끌 수 있고,LogMessage.enable로 제어. 거대 검색응답을 로그에서 빼고 싶을 때 사용.
로거 이름 규칙
LogMessage.clientLogger는SUPPLIER.${logger.name.takeLastWhile { it != '.' }}— 즉...AmadeusClient의 로거는SUPPLIER.AmadeusClient가 된다.logback-prod.xml이<logger name="SUPPLIER" level="TRACE">로 전체 공급사 트래픽을 JSON appender로 모은다. → support-common
4. S3 — 구성만 있고 직접 사용처는 없음
configuration/S3Configuration.kt:
@Bean
fun amazonS3Client(): AmazonS3 =
AmazonS3ClientBuilder.standard()
.withRegion(Regions.AP_NORTHEAST_2) // 서울
.withClientConfiguration(ClientConfiguration()
.withClientExecutionTimeout(10 * 60 * 1000)) // 10분
.build()| 항목 | 값 | 비고 |
|---|---|---|
| SDK | AWS SDK v1 (com.amazonaws) | v2 아님 |
| Region | AP_NORTHEAST_2 (서울) 하드코딩 | 환경변수 아님 |
| Client execution timeout | 10분 | 대용량 파일(eDoc/티켓 PDF 등) 업로드 대비로 추정되는 긴 값 |
| 자격증명 | 명시 없음 | 기본 DefaultAWSCredentialsProviderChain(IAM Role/EC2/ECS) |
현재 메인 소스에서
AmazonS3주입처가 없다
grep결과AmazonS3를 사용하는 곳은S3Configuration.kt자신뿐이다. 빈은 등록되지만 런타임 사용처는 (현 버전 기준) 비활성/예정 상태로 보인다. 향후 발권 문서 저장 등에 쓰일 토대로 이해하라. 빌드/배포 시 S3 권한이 IAM Role로 부여되는지는 build-deploy-config를 참고.
5. 내부/외부 인프라 클라이언트 (infrastructure/)
5.1 CityClient — 내부 City API + 캐시
infrastructure/city/CityClient.kt. ClientSupport를 상속하고 @Cacheable로 결과를 1일 캐싱한다.
@Cacheable(value = [AIRPORT_CACHE], key = "#iata", cacheManager = "redisCacheManager")
fun getAirportByIata(iata: String): Airport = "$endpoint/airports/$iata".get().execute<Airport>().fold(...)
@Cacheable(value = [CITY_CACHE], key = "#iata", cacheManager = "redisCacheManager")
fun getCityByIata(iata: String): City = "$endpoint/cities/iata/$iata".get().execute<City>().fold(...)| 항목 | 내용 |
|---|---|
| 엔드포인트 | infrastructure.city.internal-endpoint (InfrastructureProperties.city) |
| 타임아웃 | defaultTimeout=3000 (3초, 내부망이라 짧게) |
| 캐시 키 | #iata (공항/도시 IATA 코드) |
| 응답 모델 | City/Airport 등이 Serializable — @Cacheable JDK 직렬화 경로이므로 필수 |
환경별 엔드포인트:
| 프로파일 | city internal-endpoint |
|---|---|
| local | https://city-api.proxy.triple-dev.titicaca-corp.com/internal |
| dev | http://city-api.dev.triple.run/internal |
| qa | http://city-api.qa.triple.run/internal |
| staging | http://city-api.staging.triple.run/internal |
| prod | http://city-api.prod.triple.run/internal |
5.2 SlackClient — 비동기 경보 송신
infrastructure/slack/SlackClient.kt. 운영 경보의 송신 말단이다(메시지 조립은 application/SlackService.kt).
@Component
class SlackClient(
@Value("\${slack.token}") private val token: String,
@Value("\${slack.active}") private val active: Boolean,
private val env: Environment,
) {
private val slack = Slack.getInstance().methods(token)
private val profile = env.activeProfiles.first { profile -> profile != "worker" }
fun send(channel: String, blocks: List<LayoutBlock>) {
if (active.not()) return // ← 토글로 전체 차단
CoroutineScope(Dispatchers.IO).withLaunch { // ← 논블로킹 fire-and-forget
...
if (profile != "prod") it.username("항공($profile)") // 비-prod는 환경 표시
}
}
}| 측면 | 설계 | 주의 |
|---|---|---|
| 활성화 | slack.active — local=false, prod/staging=true | 로컬에서 슬랙 도배 방지 |
| 비동기 | CoroutineScope(Dispatchers.IO).withLaunch | fire-and-forget. 실패해도 본 흐름 영향 X (→ async-coroutines) |
| 토큰 | slack.token (Secrets Manager) | 코드에 없음 |
| 프로파일 표시 | 비-prod는 발신자명에 항공(dev) 등 | 환경 혼동 방지 |
경보 채널은 채널/이벤트별로 라우팅된다
SlackProperties.getSlackEmergencyChannelProperties(salesChannel)는 판매채널별 이머전시 채널을 고른다 (interpark는 인터파크 전용 채널, 나머지는default).getSlackOperationChannelProperties()는 운영로그용.SlackService는 “발권 일부 실패→수동 VOID 필요” 같은 운영자 액션이 필요한 사건을 이 채널들로 쏜다. 이것이 이 시스템의 “상태 전파” 메커니즘의 일부다 → resilience-and-events.
메서드 prod 채널ID 의미 emergency.default C05FJBD1HPC(항공팀_이머전시)수동 개입 필요 emergency.interpark C06NB02KPT5(인터파크항공_이머전시)인터파크 전용 operation.default C05FSAESXR9(항공팀_운영로그)모니터링 (참고:
application.yml의 base 값은 전부 테스트 채널C0766644BPZ이며, prod/staging yml이 실제 채널로 override한다.)
6. 프로파일 & 환경별 설정
6.1 프로파일 enum (configuration/Profiles.kt)
enum class Profile(val profile: String) { LOCAL, DEV, QA, STAGING, PROD, WORKER }
fun Environment.getProfile() = when { isProductionProfile() -> PROD; ...; else -> LOCAL }Environment.isXxxProfile() 확장함수로 코드 곳곳에서 분기한다. WORKER는 특수 프로파일 — SlackClient/SlackService가 activeProfiles.first { it != "worker" }로 worker 프로파일을 건너뛰고 실제 환경(dev/prod 등)을 찾는 패턴을 쓴다(워커가 항상 다른 env 프로파일과 함께 활성화됨을 전제).
6.2 설정 로딩 체인
flowchart TD A["application.yml<br/>공통: server.port=8000, resilience4j, supplier import, slack base"] A -->|"spring.config.import"| B["classpath supplier/{amadeus,sabre,...}.yml ×10<br/>각 supplier yml은 on-profile별 aws-secretsmanager import"] A --> C["application-{env}.yml<br/>env별: logback, redisson file, city endpoint, secretsmanager"] C -->|"spring.config.import"| D["optional aws-secretsmanager {env}/air-intl-adapter<br/>공통 시크릿"] C --> E["supplier/{name}.yml (on-profile별)<br/>optional aws-secretsmanager {env}/air-intl-adapter/{name}<br/>공급사 시크릿"]
| 설정 항목 | local | dev | qa | staging | prod |
|---|---|---|---|---|---|
| logback | logback-local | logback-dev | logback-qa | logback-staging | logback-prod |
| redisson 파일 | redisson-local | redisson-dev | redisson-qa | redisson-staging | redisson-prod |
springdoc(api-docs.enabled) | true | true | true | (미설정=false) | (미설정=false) |
supplier.logging.search | 전체 ON | 전체 ON | 전체 ON | (미설정) | ${SEARCH_LOG_SUPPLIERS:} env |
| slack.active | false | true(base) | true(base) | true | true |
| Secrets Manager | dev/... (local은 dev 시크릿 재사용) | dev/... | qa/... | staging/... | prod/... |
| Sentry | (없음) | (없음) | (없음) | environment=staging | environment=prod |
local이 dev 시크릿을 쓴다
application-local.yml과application-dev.yml모두aws-secretsmanager:dev/air-intl-adapter를 import한다. 즉 로컬 구동도 dev AWS 자격증명/Secrets Manager 접근이 필요하다(자격증명 미설정 시optional:덕에 부팅은 되지만 공급사 호출은 실패).redisson-local.yml도 dev ElastiCache(air-international-dev-...)를 가리킨다.
6.3 Redisson 환경별 연결 (redisson/redisson-{env}.yml)
모든 환경이 동일 구조의 clusterServersConfig, 노드 주소만 다르다.
clusterServersConfig:
idleConnectionTimeout: 5000
connectTimeout: 5000
retryAttempts: 1 # ← 재시도 1회만 (빠른 실패)
retryInterval: 500
masterConnectionMinimumIdleSize: 24
masterConnectionPoolSize: 64
slaveConnectionMinimumIdleSize: 24
slaveConnectionPoolSize: 64
sslEnableEndpointIdentification: true # rediss:// TLS
nodeAddresses:
- "rediss://air-international-{env}-...serverless.apn2.cache.amazonaws.com:6379"| 항목 | 값 | 의미 |
|---|---|---|
| 모드 | clusterServersConfig | AWS ElastiCache Serverless 클러스터 |
| 프로토콜 | rediss:// + sslEnableEndpointIdentification: true | TLS 강제 |
| retryAttempts | 1 | Redis 장애 시 빠른 실패 — 캐시는 best-effort, 막히면 원본 호출로 폴백되는 게 낫다 |
| 풀 | master/slave 각 idle 24 / max 64 | 동시성 대비 |
| 노드 | local·dev 동일(air-international-dev-), qa/staging/prod 별도 | local=dev 공유 재확인 |
6.4 Secrets Manager가 채우는 값들
configuration/Properties.kt의 @ConfigurationProperties 클래스들이 시크릿 값을 바인딩한다(값 자체는 yml에 없음). 11개 공급사 yml은 전부 “4개 프로파일 × secretsmanager import”만 들어있는 껍데기다(local은 dev와 묶임: on-profile: dev,local).
| Properties 클래스 (prefix) | 바인딩되는 민감/환경 값 |
|---|---|
AmadeusProperties (supplier.amadeus) | channels/funnels의 officeId, iataCode, userName, password, endpoint, ART agentCode/apiKey, GPS endpoint |
SabreProperties (supplier.sabre) | epr, password, PCC(online/offline), endpoint/restEndpoint, clientId/Secret, decryptKey, payment/pg/fareRule |
GalileoProperties (supplier.galileo) | clientId/Secret, REST/SOAP/KRT/KPS endpoint·계정, offline PCC |
SingaporeairProperties/LufthansaProperties/KoreanairProperties (NDC) | iataCode, userName, password, agency*, pseudoCityCode, endpoint, ocpApimSubscriptionKey(LH/KE), KE nicePay(seedKey/iv 등) |
TwayProperties (supplier.tway) | endpoint, agencyCode, password, paymentPassword, FTP(host/port/id/pw), ancillary token |
JinairProperties (supplier.jinair) | endpoint, agencyCode, key, FTP, payment/dsr endpoint·key |
JejuairProperties (supplier.jejuair) | endpoint, clientId/Secret, channelCode, agencyId |
GroupairProperties (supplier.groupair) | searchEndpoint, bookingEndpoint, agencyId |
SlackProperties/AgentProperties/InfrastructureProperties | slack 채널/토큰, 채널별 상담전화, city internal-endpoint |
채널×퍼널 매트릭스 -- 인증값이 단일이 아니다
대부분 Properties는
channels: List<...>→funnels: List<...ApiProperties>2단계다. 같은 공급사라도 판매채널(TRIPLE/YANOLJA/INTERPARK…) × 퍼널마다 다른 PCC/Office/계정을 쓴다.getApiProperties()는MDCHolder(요청 헤더의 SalesChannel/SalesFunnel)로 올바른 자격을 고른다. 못 찾으면NOT_SUPPORTED_SALES_CHANNEL/NOT_SUPPORTED_SALES_FUNNEL예외. → support-common, error-handlingAmadeus·Sabre는 한술 더 떠 날짜 분기까지 한다:
AmadeusProperties는MDCHolder.PnrCreatedAt < changedDate면LEGACY퍼널로,SabreProperties는SabrePccPeriod.between(date)로 PCC 이관 기간을 가른다. PNR 생성일에 따라 다른 GDS 계정으로 라우팅하는 마이그레이션 장치다.
7. 기타 공통 구성 빈
7.1 WebMvcConfig (파일명 WebMvcConfiguration.kt)
| 빈/메서드 | 역할 | 주의 |
|---|---|---|
objectMapper (@Primary) | JSON: NON_NULL, unknown 무시, 날짜 ISO, Kotlin/JavaTime/Jdk8/ParameterNames/NoCtorDeser 모듈 | 앱 전역 기본 매퍼 |
xmlMapper | XML(Jackson): GDS SOAP/XML 직렬화용 | Amadeus 등이 @Qualifier("xmlMapper")로 주입 |
configureMessageConverters | 기본 XML 컨버터 제거 | 응답이 의도치 않게 XML로 나가는 것 방지 |
addFormatters | LocalDateConverter/LocalDateTimeConverter 등록 | 요청 파라미터 날짜 파싱 |
contentCachingWrapperFilter(Order 1) / mdcHolderFilter(Order 2) / requestLoggingFilter(Order 3) | 필터 체인 | MDC 채움·바디 재읽기·로깅 (→ support-common) |
@EnableConfigurationProperties([...]) | 위 모든 Properties 활성화 | 여기 등록 안 하면 @ConfigurationProperties 바인딩 안 됨 |
새
@ConfigurationProperties추가 시 두 곳을 건드린다
Properties.kt에 클래스를 만들기만 하면 끝이 아니다.WebMvcConfig의@EnableConfigurationProperties배열(또는OpenApiConfiguration)에 등록해야 빈으로 산다.SupplierLoggingProperties,AgentProperties,SlackProperties까지 모두 여기 명시돼 있다.
7.2 OpenApiConfiguration (Swagger)
| 측면 | 내용 |
|---|---|
| 활성 조건 | @ConditionalOnProperty(SPRINGDOC_ENABLED, "true") — dev/qa/local만 ON(prod/staging OFF) |
| 그룹 | 공급사별 GroupedOpenApi 11개. 각자 ...supplier.{name}.interfaces.controller.internals 패키지만 스캔 |
| 공통 헤더 | 모든 오퍼레이션에 TRIPLE_SALES_CHANNEL(필수)·TRIPLE_SALES_FUNNEL(필수)·TRIPLE_PNR_CREATED_AT(선택) 헤더 자동 주입 |
| 서버 URL | open-api.url (OpenApiProperties, env별) |
| 무시 래퍼 | Mono, Supplier enum을 요청 래퍼에서 제외 |
Swagger 그룹이 "중앙 디스패처 없음"을 증명한다
11개
GroupedOpenApi가 각 공급사의interfaces.controller.internals패키지를 따로 스캔한다 — 공급사별 컨트롤러가 독립 API로 노출되는 구조 그대로다. 공통 헤더 3종(SalesChannel/SalesFunnel/PnrCreatedAt)이 모든 API의 필수 입력임을 여기서 한눈에 확인할 수 있다. → interfaces-dtos, request-flow
8. 빠른 점검 체크리스트 (온보딩용)
"이 변경, 어디를 건드려야 하나?" 표
하고 싶은 것 건드릴 파일 새 캐시 추가 support/cache/CacheSet.kt(enum+상수) →@Cacheable(value=[새상수])또는 공급사RedisTemplate캐시 TTL 변경 CacheSet.kt의 해당 enumttl거대 응답 캐시 압축 공급사 configuration/RedisConfiguration.kt에Gzip/SnappyRedisSerializer래핑RedisTemplate공급사 HTTP 타임아웃 조정 해당 XxxClient생성자searchTimeout/defaultTimeout, 또는 요청별.callTimeout(...)새 공급사 인증 설정 추가 Properties.kt(클래스) +WebMvcConfig(@EnableConfigurationProperties) +supplier/{name}.yml+ Secrets ManagerSlack 경보 추가 application/SlackService.kt(메시지) — 송신은SlackClient가 자동환경별 엔드포인트 변경 application-{env}.ymlRedis 노드/풀 변경 redisson/redisson-{env}.yml
관련 노트
- support-common —
MDCHolder(SalesChannel/Funnel/PnrCreatedAt), 필터 체인, 로거 규칙,ClientSupport의 요청 빌더 - resilience-and-events — Resilience4j 서킷브레이커(
application.yml의searchconfig)·Slack 경보로 구현된 상태전파 - build-deploy-config — IAM Role/AWS 자격증명, Secrets Manager 권한, 배포 프로파일
- error-handling —
NOT_SUPPORTED_SALES_CHANNEL/FUNNEL등 설정 미스 예외 - async-coroutines —
SlackClient의 fire-and-forgetwithLaunch - interfaces-dtos / request-flow — 공통 헤더와 컨트롤러 구조