support 패키지는 11개 공급사 모듈 전체가 공유하는 공통 인프라 코드의 집합소다. 공급사 코드(supplier/{name}/...)는 비즈니스 로직에 집중하고, “값 변환·암호화·로깅·HTTP 호출·MDC 전파·코루틴 헬퍼” 같은 횡단 관심사(cross-cutting concern)는 전부 여기로 모았다. 신입이 어느 공급사 코드를 읽든 결국 support.*를 import하기 때문에, 이 패키지를 먼저 이해하면 모든 모듈의 공통 언어를 한 번에 익힐 수 있다.
비동기/코루틴 부분만은 분량과 중요도 때문에 async-coroutines로 분리했다. 이 노트는 코루틴은 “어떤 진입점이 있는지”만 가리키고, 나머지 9개 하위 패키지를 전수 정리한다.
0. 패키지 한눈에 보기
분석 대상 루트: support/ (10개 하위 패키지 + 최상위 Constants.kt)
개념적으로 support는 사내 미니 라이브러리다. Spring Bean으로 등록되는 것(BeanUtils, 필터 등)도 있지만, 대부분은 확장함수(extension function)와 object(싱글톤) 로 정적 호출된다. 그래서 import 한 줄로 어디서든 쓰인다. 단점은 “전역 의존”이라 변경 시 폭발 반경(blast radius)이 크다는 것 → 하단 landmines 참고.
1. enums — 공급사 코드 매핑의 심장부
가장 먼저 이해해야 할 것
이 어댑터의 본질은 “Triple 표준 enum ↔ 공급사별 코드 문자열”의 양방향 변환이다. enum 한 개가 컬럼(필드)으로 11개 공급사의 코드를 동시에 들고 있고, companion object의 getXxxBySupplier(value, supplier) 함수가 역매핑을 담당한다. 공급사를 추가/수정할 때 가장 자주 건드리는 곳이다.
1.1 핵심 enum 표 (task 지정 항목 포함 전수)
enum
패턴
의미
공급사별 매핑 필드
비고 (file:line)
CabinType
멀티필드
좌석 등급 (ECONOMY/PREMIUM_ECONOMY/BUSINESS/FIRST)
amadeus/amadeusndc/singaporeair/tway/jinair/sabre/lufthansa/galileo/groupair/jejuair 10개
getCabinBySupplier, KOREANAIR·JEJUAIR은 무조건 ECONOMY, amadeusndc “J”→BUSINESS 특례 CabinType.kt:65-83
List: ScheduleStatus.tway = listOf("WAS_CONFIRMED", "CANCELLED") — 한 상태에 복수 코드가 매핑 (contains로 조회)
HashSet: TicketStatus.amadeus = hashSetOf("I", "OPE") — 빠른 조회를 위한 집합
enum 추가/수정 시 컴파일은 통과해도 런타임에 깨지는 함정
getXxxBySupplier의 when (supplier) 분기는 Supplier enum 전부를 다룬다. BaggageUnit.getBaggageUnitBySupplier는 미지원 공급사에 else -> TODO()(=NotImplementedError 던짐)가 박혀 있다(BaggageUnit.kt:61). 또 대부분 함수가 매칭 실패 시 ?: ECONOMY / ?: ADULT / ?: ETC처럼 조용한 기본값(silent default) 을 반환한다 → 잘못된 코드를 넣어도 예외 없이 ECONOMY로 처리되어 버그가 숨는다. enum 매핑 작업 후엔 반드시 양방향(역매핑까지) 테스트하라. → landmines
getCabinBySupplier의 특례 코드 읽기 연습
// CabinType.kt:65-83fun getCabinBySupplier(value: String, supplier: Supplier): CabinType { return when (supplier) { Supplier.AMADEUS -> entries.find { it.amadeus == value } Supplier.AMADEUSNDC -> when (value) { "J" -> BUSINESS // NDC만의 비즈니스 코드 'J' 별도 처리 else -> entries.find { value == it.amadeusndc } } // ... Supplier.KOREANAIR -> ECONOMY // KE/제주는 캐빈 매핑 안 함 → 항상 ECONOMY Supplier.JEJUAIR -> ECONOMY } ?: ECONOMY // 매칭 실패 시 조용히 ECONOMY}
연습 문제: 왜 KOREANAIR/JEJUAIR는 매핑 컬럼이 없을까?
CabinType의 생성자 파라미터에는 koreanair/jejuair 필드가 아예 없다(CabinType.kt:3-14). 그런데 getCabinBySupplier는 두 공급사를 ECONOMY로 하드코딩한다. 이유를 추론해보라.
[!answer]- 정답 보기
두 공급사는 NDC/LCC 특성상 검색 단계에서 캐빈을 “코드”로 구분하지 않거나, 시스템 정책상 이코노미만 취급하기 때문이다. enum 필드를 추가하는 대신 when 분기에서 상수 반환하는 것이 더 간단하다고 판단한 것. 다만 이는 “필드 누락”과 “의도적 미지원”이 코드상 구분되지 않는다는 약점이 있다. 새로 캐빈을 지원하려면 enum 생성자 필드 추가 + getCabinBySupplier 분기 수정 두 곳을 모두 손봐야 한다.
2. annotation — Jackson/리플렉션 기반 마법
support.annotation에는 4개 애너테이션이 있다. @SeedEncrypt/@ByteRange/@TextRange는 리플렉션 기반 고정폭(fixed-width) 직렬화용이고, @IgnoreSerialize는 프로파일별 직렬화 제외용이다.
T’way(TwaySEED.jar) 같은 LCC는 요청을 JSON이 아니라 “한 줄 고정폭 문자열” 로 받는다. 예: 010바이트는 이름, 1018바이트는 날짜… 식이다. @ByteRange(0,10)을 필드에 붙이면 ReflectionUtils.deserializeOfLiteralTextByByte가 그 위치의 바이트를 잘라 객체 필드에 채운다. 반대로 serializeToLiteralTextByByte는 객체를 고정폭 문자열로 직렬화하며, 부족하면 공백 패딩·초과하면 절단한다(ReflectionUtils.kt:136-148).
// ReflectionUtils.kt:125-131 — @SeedEncrypt가 붙은 필드만 암호화parameters.forEach { (param, byteRange, needsEncryption) -> val strValue = memberMap[param.name]?.call(obj)?.toString() ?: "" val processed = if (needsEncryption) { SeedEncryptor.encrypt(plainText = strValue, seedKey = seedKey, iv = iv) } else { strValue } // ... 바이트 길이에 맞춰 패딩/절단}
@IgnoreSerialize는 두 곳에서 처리된다 — 중복 주의
같은 동작을 IgnoreSerializer(애너테이션에 @JsonSerialize로 직접 연결, IgnoreSerialize.kt:21)와 IgnoreSerializeBeanSerializerModifier(전역 Bean 수정자, EnvBeanSerializerModifier.kt:10) 두 군데에서 구현한다. 둘 다 env.activeProfiles.first { it != "worker" } 로직을 쓴다. active profile에 worker 외 프로파일이 없으면 NoSuchElementException이 터질 수 있는 구조다. → landmines / configuration-and-infra
PnrUtils.isPnrCreatedAtBeforeYesterdayOrNoShow는 명시적으로 now("UTC")를 쓴다(PnrUtils.kt:9,12). 항공 예약 시각 비교는 반드시 어느 타임존 기준인지 따져야 한다. DateExtensions의 기본값이 KST라서, UTC 비교 로직에서 기본 now()를 그냥 쓰면 9시간 오차가 난다.
sequential은 코루틴 없는 동기판 부분실패 처리, cartesianProduct는 검색 조합 생성
Base62.kt
Long.toBase62Encoded()
짧은 키 생성
sequential vs pmap — 동기/비동기 부분실패 패턴 쌍
CollectionUtils.sequential(동기)과 CoroutineExtensions.pmap(비동기, → async-coroutines)은 “여러 개를 처리하되 일부 실패는 모아서 콜백” 이라는 동일 철학의 쌍둥이다. 둘 다 성공분은 반환하고 실패분은 onFailure/AsyncResults.exceptions로 모은다. 이 시스템이 “메시지큐 없이” 부분실패를 다루는 방식의 한 축이다 → resilience-and-events.
3.3 항공 도메인 포맷터 (공급사 코드 변환 로직)
OSI/SSR — GDS 예약의 "특수 지시문"
GDS(아마데우스/세이버)는 연락처·이메일을 PNR에 OSI(Other Service Information) / SSR(Special Service Request) 라는 자유형식 문자열로 넣는다. 항공사마다 접두사·구분자 규칙이 달라(@→//, _→.. 등) 변환 테이블이 필요하다. 이걸 모은 게 APISUtils/PassengerUtils다.
파일
object
핵심 메서드
공급사 특이점 (file:line)
APISUtils.kt
OSIFormat.Mobile/Email
of(validatingCarrier, value)
항공사별 prefix(CTCM/CTCE/EMAIL…) 대량 분기 APISUtils.kt:9-37
APISUtils.kt
SSRFormat
ofInfantName, FOID.ofPassport(“PP”+번호)
유아 SSR 이름 포맷 APISUtils.kt:42
PassengerUtils.kt
PassengerFormat.Name
toCarrier/fromCarrier, expandSingleCharacterSurname(외자 성 2자로)
ZE/YP/7C 항공사 firstName 공백제거 등 PassengerUtils.kt:16-57
PassengerUtils.kt
PassengerFormat.Mobile/Email
SSR/OSI 변환, @→// 등 이메일 이스케이프
Email.Ssr.toCarrierPassengerUtils.kt:118-127
CityUtils.kt
CityUtils
isMultiCity(다중공항 도시 220+개 HashSet), isDomesticAirport
하드코딩된 IATA 목록 “2022.08.25 기준” CityUtils.kt:6
CountryUtils.kt
CountryUtils
iso3CodeToIso2Code/역변환
JDK Locale ISO 코드 캐시 맵 CountryUtils.kt:7
CityUtils.multiCities는 하드코딩 스냅샷 — 노후화 위험
220개가 넘는 IATA 코드가 코드에 박혀 있고 주석에 “2022.08.25 기준”이 붙어 있다(CityUtils.kt:5-22). 주석 처리된 multiAirports에는 “2022.10.03 //TODO 이날 변경 처리 하자”가 방치돼 있다(CityUtils.kt:24-31). 신규 다중공항 도시가 추가돼도 자동 갱신되지 않는다 → 검색 누락의 잠재 원인. → landmines
3.4 암호화/해시
파일
내용
용도 (file)
SeedEncryptor.kt
BouncyCastle SEED/CBC/PKCS5Padding 암호화
T’way 등 SEED 인증 (@SeedEncrypt가 호출) SeedEncryptor.kt:17
CryptoUtils.kt
String.toSha3(), CipherHandler 인터페이스 + @Encrypt/@Decrypt Jackson 애너테이션
① SeedEncryptor(SEED, 고정폭 LCC) ② CryptoUtils.CipherHandler(공급사가 구현하는 범용 암복호 — @Encrypt/@Decrypt로 JSON 필드 자동 처리, KoreanairPaymentClient가 사용) ③ PasswordDigest(SOAP WS-Security). “암호화”라는 키워드로 검색하면 셋 다 나오니, 어느 프로토콜용인지 먼저 구분하라.
3.5 SOAP/XML/리플렉션 DSL
파일
내용
비고
SoapExtensions.kt
soap{} 빌더 DSL(header/body/element/attribute), secHeader(WS-Security), @SoapBody Jackson 직렬화, SOAPMessage.soapBody<T>() 역직렬화
XML 변환이 실패하면 빈 문자열이 반환되고 로그조차 안 남는다. SOAP 응답 파싱이 “이유 없이” 비는 장애가 나면 이 함수를 의심하라. → error-handling / landmines
BeanUtils는 안티패턴이지만 필요악
Jackson 커스텀 직렬화기(SoapBodySerializer, JsonBodyDeserializer)는 Spring이 생성·DI하지 않는 객체라 @Autowired를 못 쓴다. 그래서 BeanUtils.getBean("xmlMapper")로 컨텍스트에서 직접 꺼낸다. 정적 전역 상태(lateinit var applicationContext)라 테스트 격리가 어렵고, 컨텍스트 초기화 전 호출 시 lateinit 예외가 난다. → landmines
4. web — OkHttp 래퍼 / MDCHolder / Result
4.1 ClientSupport — 모든 공급사 HTTP 클라이언트의 추상 부모
11개 공급사 클라이언트 전부가 이걸 상속한다
grep 결과 AmadeusClient, SabreClient, GalileoClient, TwayClient, JinairClient, JejuairClient, KoreanairClient, LufthansaClient, SingaporeairClient, GroupairClient, AmadeusndcClient 등 모든 인프라 클라이언트가 ClientSupport를 상속한다(web/ClientSupport.kt:25). 따라서 이 클래스의 동작을 이해하면 모든 외부 호출의 공통 골격을 안다.
flowchart TD
A["String.post(body).header().bearer(token).execute RES"]
B["OkHttpRequestBuilder (fluent)"]
C["client 선택<br/>searchClient 30s 또는 defaultClient 60s"]
D["requestBody<br/>objectMapper.writeValueAsString 기본<br/>String이면 그대로"]
E["header / bearer / authenticate<br/>헤더 누적"]
F["LoggingAndCompressionInterceptor 자동 부착<br/>configuration 패키지"]
G["execute()"]
H["Result.Success RES<br/>String 또는 Unit이면 raw<br/>아니면 objectMapper 역직렬화"]
I["Result.Failure OkHttpError<br/>findRootCause로 근본원인 추출"]
A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
G -->|"2xx"| H
G -->|"else 또는 예외"| I
요소
설명
file:line
searchClient vs defaultClient
검색은 30s, 그 외는 60s 타임아웃의 별도 OkHttpClient
ClientSupport.kt:34-48
String.get/post/put/delete
확장함수로 URL에서 바로 빌더 생성
ClientSupport.kt:50-60
execute<RES>()
reified 제네릭으로 응답 타입 추론, Result<RES,OkHttpError> 반환
실패도 Result.failure(OkHttpError(...))로 정상 반환된다(ClientSupport.kt:181-190). 호출부가 .fold(success, failure)를 안 하고 성공만 가정하면 에러를 놓친다. 이는 4.3의 Result 모나드와 짝을 이루는 설계다.
4.2 MDCHolder — 요청 컨텍스트 전파의 단일 창구
로그 추적·캐시 키·코루틴 전파가 전부 여기에 의존
MDCHolder는 sealed class로, SalesChannel/SalesFunnel/Pnr/ValidatingCarrier/OrderNumber/PnrCreatedAt/DatadogTraceId/SpanId/Env/SotoUserId object들을 하위로 갖는다. HTTP 헤더(Constants.kt의 x-triple-*)를 받아 SLF4J MDC에 넣고(putAll), 코루틴은 MDCContext()로 이 값을 전파한다(→ async-coroutines).
flowchart TD
A["요청 헤더<br/>x-triple-sales-channel 등"]
B["MDCFilter.doFilterInternal"]
C["MDCHolder.putAll(request, env)"]
D["SLF4J MDC<br/>로그에 자동 주입"]
E["Datadog 또는 Logback 패턴에 노출"]
F["캐시 키 = SalesChannel + SalesFunnel + supplier + ..."]
G["finally 에서 MDCHolder.clear()<br/>반드시 정리 스레드풀 재사용 누수 방지"]
A --> B
B --> C
C --> D
D --> E
C -->|"CacheKeyGenerator가 .get()으로 읽음"| F
F --> G
동작
메서드
비고 (file:line)
헤더→MDC 일괄 적재
putAll(request, env) — sealedSubclasses 순회
MDCHolder.kt:45-62
전체 MDC 맵 조회
contextMap() — 코루틴/스레드 전파용
MDCHolder.kt:26-43
일괄 정리
clear() — MDCFilter finally에서 호출
MDCHolder.kt:64-67
필수값 강제
SalesChannel.get()/SalesFunnel.get() — 없으면 InternationalAdapterException
MDC는 ThreadLocal 기반이라, 스레드풀이 재사용될 때 이전 요청 값이 남으면 다른 요청 로그에 엉뚱한 PNR/채널이 찍힌다. MDCFilter.doFilterInternal이 try { putAll } finally { clear }로 보장한다(MDCFilter.kt:18-23). 코루틴(withAsync/withLaunch)은 MDCContext()를 컨텍스트에 넣어 자식 코루틴에도 전파한다 → async-coroutines.
4.3 Result<V,E> — 함수형 결과 모나드
// web/Result.ktsealed class Result<out V, out E> { data class Success<V>(val value: V) : Result<V, Nothing>() data class Failure<E>(val error: E) : Result<Nothing, E>() inline fun <X> fold(success: (V) -> X, failure: (E) -> X): X = ...}
Kotlin 표준 kotlin.Result가 아니다
이 시스템은 자체 Result를 쓴다(에러 타입 E를 제네릭으로 지정 가능 — 표준 Result는 Throwable 고정). ClientSupport.execute()가 Result<RES, OkHttpError>를 반환하고, .fold(success={}, failure={})로 분기하는 게 정석. import할 때 kotlin.Result와 헷갈리지 말 것.
4.4 EnvBeanSerializerModifier
IgnoreSerializeBeanSerializerModifier — @IgnoreSerialize 필드를 active profile에 따라 직렬화에서 제거하는 전역 Jackson 수정자(2장 참고).
5. cache — Redis 캐시 정의
파일
내용
file
CacheSet.kt
19개 캐시 상수명 + CacheSet enum(cacheName + TTL)
CacheSet.kt:5-91
CacheKeyGenerator.kt
검색요청→캐시키 생성(generateSearchRequestKey 등)
MDC 채널/퍼널을 키에 포함 CacheKeyGenerator.kt:31
GzipRedisSerializer.kt
GZIP 압축 Redis 직렬화 래퍼(BEST_SPEED)
GzipRedisSerializer.kt:11
SnappyRedisSerializer.kt
Snappy 압축 Redis 직렬화 래퍼
SnappyRedisSerializer.kt:7
캐시 키에 판매 채널/퍼널이 들어간다 — 멀티테넌시 분리
CacheKeyGenerator.generateSearchRequestKey는 키 맨 앞에 MDCHolder.SalesChannel.get() + SalesFunnel.get()을 붙인다(CacheKeyGenerator.kt:31). 즉 같은 검색조건이라도 채널이 다르면 캐시가 분리된다. 검색 캐시 TTL은 20분(FLIGHT_SEARCH_KEY), 운임 1시간(FARE_ITINERARY), Sabre 토큰 6일 등 도메인별로 천차만별이다.
TTL 한 줄짜리 함정 — GALILEO_REST_TOKEN
GALILEO_REST_TOKEN의 TTL은 85800초(=23.83시간)다. 주석에 “값을 항상 주고 있기에 내려주는 값과 동일하게 세팅 (-10분)“이라 적혀 있다(CacheSet.kt:72). 토큰 만료 직전 갱신을 위한 안전 마진이다. 압축 직렬화기(Gzip/Snappy)는 검색 응답처럼 큰 페이로드를 Redis에 넣을 때 메모리·네트워크를 아끼기 위함이다.
6. filter / log / converter / configuration
6.1 filter (Servlet Filter 3종)
필터
역할
file:line
MDCFilter
헤더→MDC 적재(putAll)·finally에서 clear. Datadog span에 환경 태깅
Servlet의 InputStream은 한 번만 읽힌다. 로깅 필터가 바디를 읽으면 컨트롤러가 못 읽는다. ContentCachingWrapperFilter가 바디를 메모리에 캐싱해 양쪽이 모두 읽게 한다. AdapterLoggingFilter는 정규식으로 공급사명을 추출해 Datadog span에 태깅한다(internals/(AMADEUS|SABRE|...), AdapterLoggingFilter.kt:28-29) — 단 이 정규식 목록에 일부 공급사(JEJUAIR/KOREANAIR/GROUPAIR)가 빠져 있다.
EncryptValueMasker는 TARGET_CONTEXTS = {"message", "request_body"} 안의 4개 키만 마스킹한다(EncryptValueMasker.kt:8-13). response_body나 다른 JSON 컨텍스트에 카드번호가 실리면 마스킹되지 않고 평문 로깅된다. 새 결제 필드를 추가할 때 이 목록도 갱신해야 PCI 누출을 막는다. → landmines
6.3 converter / configuration
파일
역할
LocalDateConverter / LocalDateTimeConverter
Spring MVC 쿼리파라미터 String→날짜 변환(object, Converter 구현)
국내선 환승 등 여권이 불필요한 구간에 ofFake()가 KR 국적·X1234567·만료 10년 후의 더미 여권을 만든다(Passport.kt:32-39). isFake()로 식별한다. 실제 발권 전에 가짜 여권이 그대로 전송되면 항공사에서 거부되거나 데이터 오염이 생긴다. 가짜 여권의 흐름을 추적할 때 FAKE_PASSPORT_NUMBER 상수를 grep하라. → landmines
두 클래스 모두 cardNumber/password를 평문 String으로 갖고 Serializable이라 Redis 캐시(REISSUE 등)에 직렬화될 수 있다(PaymentInfo.kt:32-41, ReissueResult.kt:22-31). 6.2의 로그 마스킹과 함께 다뤄야 할 민감정보다. → landmines
8. Constants — 전역 상수
// Constants.ktobject Constants { const val TRIPLE_SALES_CHANNEL_HEADER = "x-triple-sales-channel" const val TRIPLE_SALES_FUNNEL_HEADER = "x-triple-sales-funnel" const val AIR_VALIDATING_CARRIER_HEADER = "x-air-validating-carrier" const val AIR_PNR_HEADER = "x-air-pnr" // ... MDC 키, FAKE_PASSPORT_* 더미값}
T’way 요청은 JSON이 아니라 무엇으로 직렬화되며, 어떤 애너테이션·유틸이 관여하는가?
같은 검색조건인데 캐시가 분리되는 이유는? (힌트: 캐시 키 맨 앞)
ClientSupport.execute()가 실패해도 예외가 안 나는 이유와, 호출부가 해야 할 처리는?
코루틴 안에서도 로그에 PNR이 찍히는 메커니즘은?
[!answer]- 정답 보기
고정폭 문자열(fixed-width). 필드에 @ByteRange(start,end)(+ 민감필드는 @SeedEncrypt)를 붙이고, ReflectionUtils.serializeToLiteralTextByByte가 바이트 위치에 맞춰 패딩/절단하며 @SeedEncrypt 필드는 SeedEncryptor로 SEED 암호화한다.
CacheKeyGenerator.generateSearchRequestKey가 키 앞에 MDCHolder.SalesChannel + SalesFunnel을 붙이기 때문(CacheKeyGenerator.kt:31). 판매 채널/퍼널별로 캐시가 격리된다.
execute()는 실패를 Result.Failure(OkHttpError)로 정상 반환한다(ClientSupport.kt:181-190). 호출부는 .fold(success, failure)로 두 경우를 모두 처리해야 하며, OkHttpError.isTimeout으로 타임아웃 여부를 판정해 Resilience4j 재시도와 연계한다.
모든 코루틴 헬퍼(withAsync/withLaunch/withBlocking)가 컨텍스트에 MDCContext()를 주입해, 부모 스레드의 SLF4J MDC(=MDCHolder가 채운 값)를 자식 코루틴에 복사하기 때문(CoroutineExtensions.kt:16,25,33).
관련 노트
async-coroutines — CoroutineExtensions, AsyncMapResult, MDCContext 전파 상세
error-handling — Exceptions, RestExceptionHandler, AdapterCoroutineExceptionHandler, SOAP Fault 변환
configuration-and-infra — @ConfigurationProperties, profile, IgnoreSerialize, Redis/OkHttp Bean 구성
interfaces-dtos — model.*가 변환하는 외부 요청 DTO(SearchRequest/BookingRequest)
resilience-and-events — OkHttpError.isTimeout ↔ Resilience4j, 부분실패(sequential/pmap) 전파