설정 & 인프라 (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-commonProperties.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가지

  1. 단일 진입점 X. 중앙 디스패처가 없듯 인프라도 “공통 추상 클래스 + 공급사별 주입”으로 구성된다. 예: OkHttp 클라이언트는 ClientSupport라는 추상 클래스가 만들고 11개 공급사 클라이언트가 이를 상속한다.
  2. Redisson이 Redis 연결의 단일 소스. Spring Data Redis(@CacheableRedisTemplate·향후 분산락 모두가 단 하나의 RedissonClient 위에서 동작한다.
  3. 민감정보는 코드/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)
타입역할
redissonClientRedissonClientspring.redis.redisson.file이 가리키는 redisson/redisson-{env}.yml을 읽어 생성. destroyMethod="shutdown"로 종료 시 풀 정리
redissonConnectionFactoryRedissonConnectionFactorySpring Data Redis ↔ Redisson 브릿지. @Primary 라 모든 RedisConnectionFactory 주입의 기본값
objectRedisTemplateRedisTemplate<String, Any>키만 StringRedisSerializer. 값 직렬화는 명시 안 함(JDK 직렬화 기본) — 그래서 도메인 객체가 Serializable이어야 함
stringRedisTemplateStringRedisTemplate문자열 전용

@Primary가 세 군데나 붙어 있다

redissonClient, redissonConnectionFactory, objectRedisTemplate 모두 @Primary. Spring Boot가 자동 구성하는 Lettuce 기반 RedisConnectionFactoryRedisson 것으로 강제 교체하기 위함이다. 공급사 모듈이 자체 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_KEYADAPTER-FLIGHT-SEARCH-KEY20분검색결과→재조회 키 매핑. 검색 세션 수명
FARE_ITINERARYADAPTER-FARE-ITINERARY60분운임 여정 캐시
FARE_RULEADAPTER-FARE-RULES60분운임 규정
SABRE_TOKENSABRE-TOKEN6일Sabre 세션 토큰 (장수명)
SABRE_ACCESS_TOKENSABRE-ACCESS-TOKEN6일Sabre REST 액세스 토큰
AIRPORTINTERNATIONAL-ADAPTER-AIRPORT1일City API 공항 조회 캐시
CITYINTERNATIONAL-ADAPTER-CITY1일City API 도시 조회 캐시
UNEXPOSED_FARE_ITINERARYADAPTER-FARE-ITINERARY-SCHEDULE60분미노출 운임
QUEUE_PNR_INFO_KEYQUEUE-PNR-INFO1일큐 PNR 정보
FLIGHT_AMENITYADAPTER-FLIGHT-AMENITY20분좌석 편의
REISSUEADAPTER-REISSUE-CACHE3분재발행 임시 컨텍스트 (초단명)
GALILEO_REST_TOKENGALILEO-REST-TOKEN85800초(=23h50m)주석: 토큰 만료(24h)에서 -10분 마진
AMADEUS_INIT_REFUNDINTERNATIONAL-ADAPTER-AMADEUS-INIT-REFUND1시간환불 init 단계 상태
KOREANAIR_CANCELABLE_TYPE_DETAIL...KOREANAIR-ANCELABLE-TYPE-DETAIL15분(캐시명에 오타 ANCELABLE. 상수와 일치하므로 동작엔 무해)
TWAY_ROUTEINTERNATIONAL-ADAPTER-TWAY-ROUTE1일T’way 노선
AMADEUSNDC_BOOKINGINTERNATIONAL-ADAPTER-AMADEUSNDC-BOOKING60분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/AirportSerializable 구현
공급사 RedisTemplateJackson2JsonRedisSerializer 래핑GZIP 또는 SnappyAmadeusRedisConfigurationFareItinerary, 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 / SalesFunnelMDCHolder (요청 헤더)채널/퍼널별로 캐시 분리 — 같은 검색도 TRIPLE/YANOLJA가 다른 키
supplierenum공급사별 분리
scheduleKeyorigin+destination(정렬)+departureDate(yyMMdd)공항코드를 정렬해 순서 무관 동일키
extraKeypreferences·cabins·onlyDirect·freeBaggage·multiTicket검색 옵션 전부 반영
airlineKeyairlines(정렬)항공사 필터

정렬이 캐시 적중률을 좌우한다

origin.airports.sorted(), cabins.sorted()처럼 입력 순서를 정규화한다. 안 그러면 [ICN,GMP][GMP,ICN]이 다른 키가 되어 캐시 미스가 난다. 새 검색 옵션을 추가할 때 키 생성에 빠뜨리면 “옵션이 달라도 같은 캐시를 반환”하는 버그가 생긴다 — 반대로 정렬을 빠뜨리면 적중률이 떨어진다.

2.6 분산락(Redisson RLock)은 현재 미사용

"분산락" 기대했다면

RedissonClient가 있으니 getLock()/tryLock()을 쓸 법하지만, 현재 메인 소스에 RLock/getLock/tryLock 직접 호출은 없다(전체 grep 결과 RedisConfiguration.ktRedissonClient를 참조). 즉 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(...) 메서드예: CityClientdefaultTimeout=3000(내부 API라 빠름)
인터셉터모든 클라이언트에 LoggingAndCompressionInterceptor 1개풀/디스패처/HTTP2 protocols는 별도 설정 없음(OkHttp 기본값)

공급사 클라이언트마다 OkHttpClient 인스턴스가 새로 생긴다

searchClient/defaultClientClientSupport의 인스턴스 필드다. 즉 공급사 클라이언트 빈마다 OkHttpClient 2개씩 생성된다(연결풀/디스패처 비공유). OkHttp 권장은 “클라이언트 공유, newBuilder()로 파생”이지만 여기선 그렇지 않다. 단 공급사 클라이언트는 싱글톤 빈이라 인스턴스 수 자체는 제한적이다. 추가 클라이언트를 만들 때 이 비공유 특성을 인지하라.

3.2 LoggingAndCompressionInterceptor — 로깅과 압축해제를 겸한다

configuration/OkhttpClientConfiguration.kt. 이름대로 두 가지를 한다.

  1. 응답 압축 해제: Content-Encodinggzip/deflate/br(Brotli)면 디코딩해 원문 로깅 + body 교체.
  2. 요청/응답 구조화 로깅: 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.clientLoggerSUPPLIER.${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()
항목비고
SDKAWS SDK v1 (com.amazonaws)v2 아님
RegionAP_NORTHEAST_2 (서울) 하드코딩환경변수 아님
Client execution timeout10분대용량 파일(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
localhttps://city-api.proxy.triple-dev.titicaca-corp.com/internal
devhttp://city-api.dev.triple.run/internal
qahttp://city-api.qa.triple.run/internal
staginghttp://city-api.staging.triple.run/internal
prodhttp://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.activelocal=false, prod/staging=true로컬에서 슬랙 도배 방지
비동기CoroutineScope(Dispatchers.IO).withLaunchfire-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.defaultC05FJBD1HPC (항공팀_이머전시)수동 개입 필요
emergency.interparkC06NB02KPT5 (인터파크항공_이머전시)인터파크 전용
operation.defaultC05FSAESXR9 (항공팀_운영로그)모니터링

(참고: 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/SlackServiceactiveProfiles.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/>공급사 시크릿"]
설정 항목localdevqastagingprod
logbacklogback-locallogback-devlogback-qalogback-staginglogback-prod
redisson 파일redisson-localredisson-devredisson-qaredisson-stagingredisson-prod
springdoc(api-docs.enabled)truetruetrue(미설정=false)(미설정=false)
supplier.logging.search전체 ON전체 ON전체 ON(미설정)${SEARCH_LOG_SUPPLIERS:} env
slack.activefalsetrue(base)true(base)truetrue
Secrets Managerdev/... (local은 dev 시크릿 재사용)dev/...qa/...staging/...prod/...
Sentry(없음)(없음)(없음)environment=stagingenvironment=prod

local이 dev 시크릿을 쓴다

application-local.ymlapplication-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"
항목의미
모드clusterServersConfigAWS ElastiCache Serverless 클러스터
프로토콜rediss:// + sslEnableEndpointIdentification: trueTLS 강제
retryAttempts1Redis 장애 시 빠른 실패 — 캐시는 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/InfrastructurePropertiesslack 채널/토큰, 채널별 상담전화, 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-handling

Amadeus·Sabre는 한술 더 떠 날짜 분기까지 한다: AmadeusPropertiesMDCHolder.PnrCreatedAt < changedDateLEGACY 퍼널로, SabrePropertiesSabrePccPeriod.between(date)로 PCC 이관 기간을 가른다. PNR 생성일에 따라 다른 GDS 계정으로 라우팅하는 마이그레이션 장치다.


7. 기타 공통 구성 빈

7.1 WebMvcConfig (파일명 WebMvcConfiguration.kt)

빈/메서드역할주의
objectMapper (@Primary)JSON: NON_NULL, unknown 무시, 날짜 ISO, Kotlin/JavaTime/Jdk8/ParameterNames/NoCtorDeser 모듈앱 전역 기본 매퍼
xmlMapperXML(Jackson): GDS SOAP/XML 직렬화용Amadeus 등이 @Qualifier("xmlMapper")로 주입
configureMessageConverters기본 XML 컨버터 제거응답이 의도치 않게 XML로 나가는 것 방지
addFormattersLocalDateConverter/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(선택) 헤더 자동 주입
서버 URLopen-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의 해당 enum ttl
거대 응답 캐시 압축공급사 configuration/RedisConfiguration.ktGzip/SnappyRedisSerializer 래핑 RedisTemplate
공급사 HTTP 타임아웃 조정해당 XxxClient 생성자 searchTimeout/defaultTimeout, 또는 요청별 .callTimeout(...)
새 공급사 인증 설정 추가Properties.kt(클래스) + WebMvcConfig(@EnableConfigurationProperties) + supplier/{name}.yml + Secrets Manager
Slack 경보 추가application/SlackService.kt(메시지) — 송신은 SlackClient가 자동
환경별 엔드포인트 변경application-{env}.yml
Redis 노드/풀 변경redisson/redisson-{env}.yml

관련 노트

  • support-commonMDCHolder(SalesChannel/Funnel/PnrCreatedAt), 필터 체인, 로거 규칙, ClientSupport의 요청 빌더
  • resilience-and-events — Resilience4j 서킷브레이커(application.ymlsearch config)·Slack 경보로 구현된 상태전파
  • build-deploy-config — IAM Role/AWS 자격증명, Secrets Manager 권한, 배포 프로파일
  • error-handlingNOT_SUPPORTED_SALES_CHANNEL/FUNNEL 등 설정 미스 예외
  • async-coroutinesSlackClient의 fire-and-forget withLaunch
  • interfaces-dtos / request-flow — 공통 헤더와 컨트롤러 구조