support 공통 모듈

arch-cross-cutting pattern-utility config-infra

이 노트의 목적

support 패키지는 11개 공급사 모듈 전체가 공유하는 공통 인프라 코드의 집합소다. 공급사 코드(supplier/{name}/...)는 비즈니스 로직에 집중하고, “값 변환·암호화·로깅·HTTP 호출·MDC 전파·코루틴 헬퍼” 같은 횡단 관심사(cross-cutting concern)는 전부 여기로 모았다. 신입이 어느 공급사 코드를 읽든 결국 support.*를 import하기 때문에, 이 패키지를 먼저 이해하면 모든 모듈의 공통 언어를 한 번에 익힐 수 있다.

비동기/코루틴 부분만은 분량과 중요도 때문에 async-coroutines로 분리했다. 이 노트는 코루틴은 “어떤 진입점이 있는지”만 가리키고, 나머지 9개 하위 패키지를 전수 정리한다.


0. 패키지 한눈에 보기

분석 대상 루트: support/ (10개 하위 패키지 + 최상위 Constants.kt)

support/
├── Constants.kt            ← HTTP 헤더명·MDC 키·가짜 여권 상수 (object)
├── annotation/             ← Jackson/리플렉션용 커스텀 애너테이션 4종
├── cache/                  ← Redis 캐시 정의(CacheSet/키생성기/압축 직렬화기)
├── configuration/          ← @ConfigurationProperties (OpenApi)
├── converter/              ← Spring MVC String→Date 컨버터 2종
├── enums/                  ← 공급사 코드 매핑의 핵심. 30+ enum
├── exception/              ← 예외/에러메시지/핸들러 → [[error-handling]]
├── filter/                 ← Servlet Filter 3종 (로깅/MDC/캐싱)
├── log/                    ← Logback 인코더 + 민감정보 마스킹
├── model/                  ← 공급사 공통 도메인 모델(VO) → [[interfaces-dtos]]
├── util/                   ← 확장함수·유틸 오브젝트 21개 (가장 큼)
└── web/                    ← OkHttp 래퍼·MDCHolder·Result 모나드

"support는 라이브러리, supplier는 애플리케이션"

개념적으로 support는 사내 미니 라이브러리다. Spring Bean으로 등록되는 것(BeanUtils, 필터 등)도 있지만, 대부분은 확장함수(extension function)와 object(싱글톤) 로 정적 호출된다. 그래서 import 한 줄로 어디서든 쓰인다. 단점은 “전역 의존”이라 변경 시 폭발 반경(blast radius)이 크다는 것 → 하단 landmines 참고.


1. enums — 공급사 코드 매핑의 심장부

가장 먼저 이해해야 할 것

이 어댑터의 본질은 “Triple 표준 enum ↔ 공급사별 코드 문자열”의 양방향 변환이다. enum 한 개가 컬럼(필드)으로 11개 공급사의 코드를 동시에 들고 있고, companion objectgetXxxBySupplier(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
CancelActionType단순취소 행위VOID(발권취소) / REFUND(환불)CancelActionType.kt:3
FareRuleType단순운임 규정 분류REFUND_AND_CHANGE / BAGGAGE / MILEAGE / COMMONFareRuleType.kt:3
CommissionType단순수수료 기준NET / GROSSCommissionType.kt:3
DocumentType코드보유발급 문서ETICKET(“ITR”) / PAYMENT_RECEIPT(“PRC”)amadeus 코드 보유 DocumentType.kt:3
CardBrand단순카드 브랜드LOCAL/AMEX/DINERS/DISCOVER/JCB/MASTER/UNIONPAY/VISACardBrand.kt:3
Supplier단순공급사 식별자 (전체 시스템의 분기 키)11개 상수Supplier.kt:3 — 모든 getXxxBySupplierwhen 분기 기준
PassengerType멀티필드+order승객 유형 (ADULT/CHILD/INFANT)11개 공급사 코드 + ordersabre는 C05/I10처럼 접두문자로 판별 PassengerType.kt:68-77
IdentityType멀티필드승객 신분 (ADULT~INCLUSIVE_TOUR 등 8종)amadeus/tway/jinair/lufthansa/singaporeair/galileoPassengerInfo가 보유, IdentityType.kt:5
Gender코드보유성별MALE(“M”)/FEMALE(“F”)Title 기반 유추 getGenderByTitle Gender.kt:8
Title코드+gender호칭MR/MS/MSTR/MISSgender·승객유형으로 호칭 도출 Title.kt:14
TicketStatus멀티필드(HashSet)발권 상태 (USED/VOID/REFUND 등 12종)코드값이 여러 개HashSet<String> 사용getTicketStatusByCode LCC는 null TicketStatus.kt:102
ScheduleStatus멀티필드(List)여정 상태 (CONFIRMED/CANCELED 등)tway/jinair는 List<String>(복수 상태값)ScheduleStatus.kt:80
SeatStatus멀티필드좌석 상태REQUEST_SEGMENT(“NN”)/CANCEL(“OX”)/CONFIRMED 등GDS segment 좌석 요청코드 SeatStatus.kt:4
BaggageType단순수하물 위치CABIN(기내)/CHECKED(위탁)BaggageType.kt:8
BaggageUnit멀티필드수하물 단위QUANTITY/WEIGHT_KG/WEIGHT_LB + shorter(한글 약어)getBaggageUnitBySupplierelse -> TODO() 존재 BaggageUnit.kt:61
AncillaryType멀티필드(Boolean)부가서비스 (EXTRA_BAGGAGE/MEAL/SEAT/BUNDLE)공급사별 지원여부 Boolean 플래그availOf(supplier)로 지원 목록 필터 AncillaryType.kt:22
PenaltyType단순페널티 시점BEFORE_DEPARTURE/AFTER_DEPARTURE/NO_SHOWPenaltyType.kt:9
WaiverType단순면제 처리 유형OSI/SSR/AUTH_CODE/REMARKWaiverType.kt:3
PaymentMethodType단순결제 수단CARD / CASHPaymentMethodType.kt:3
PaymentMethodDetail함수보유결제 상세KEY_IN/TOSS_PAY + getTokenWsCode()PaymentMethodDetail.kt:6
CardUserType단순카드 소유 주체PERSONAL / CORPORATECardUserType.kt:3
CardCorporate단순카드 발급사KOOKMIN/SAMSUNG/SHINHAN 등 20종CardCorporate.kt:3
CashReceiptType단순현금영수증PERSONAL / CORPORATECashReceiptType.kt:3
CarrierSearchType코드보유항공사 포함/제외INCLUDE(“I”) / EXCLUDE(“E”)CarrierSearchType.kt:3
LocationType단순출도착지 타입AIRPORT / CITYOriginDestination이 보유 LocationType.kt:3
TripDirectionType단순여정 방향OUTBOUND / INBOUNDTripDirectionType.kt:3
TicketTypeenum참조티켓 종류TICKET(FareType.AIR)/EMD(FareType.CARRIER_FEE)interfaces.response.FareType 참조 TicketType.kt:5
NonVoidableAirline화이트리스트VOID 불가 항공사HY/MF/SU/QHnotContains/contains 헬퍼 NonVoidableAirline.kt:11
NonAirEquipment화이트리스트항공기 아닌 운송수단BUS/TGV/헬리콥터 코드 등existValueOf로 존재여부 NonAirEquipment.kt:40
AmenityItemTypes.kt멀티 enum기내 편의시설 6종CostType/BeverageType/EntertainmentType/FoodType/PowerType/SeatType/WifiType한 파일에 7개 enum, fromValue로 안전 파싱

멀티필드 enum의 3가지 매핑 자료형

공급사 코드의 “다양성”에 따라 자료형이 다르다. 이것을 구분하지 못하면 매핑이 깨진다.

  • 단일 String: CabinType.amadeus = "M" — 1:1 코드
  • List: ScheduleStatus.tway = listOf("WAS_CONFIRMED", "CANCELLED") — 한 상태에 복수 코드가 매핑 (contains로 조회)
  • HashSet: TicketStatus.amadeus = hashSetOf("I", "OPE") — 빠른 조회를 위한 집합

enum 추가/수정 시 컴파일은 통과해도 런타임에 깨지는 함정

getXxxBySupplierwhen (supplier) 분기는 Supplier enum 전부를 다룬다. BaggageUnit.getBaggageUnitBySupplier는 미지원 공급사에 else -> TODO()(=NotImplementedError 던짐)가 박혀 있다(BaggageUnit.kt:61). 또 대부분 함수가 매칭 실패 시 ?: ECONOMY / ?: ADULT / ?: ETC처럼 조용한 기본값(silent default) 을 반환한다 → 잘못된 코드를 넣어도 예외 없이 ECONOMY로 처리되어 버그가 숨는다. enum 매핑 작업 후엔 반드시 양방향(역매핑까지) 테스트하라. → landmines

getCabinBySupplier의 특례 코드 읽기 연습

// CabinType.kt:65-83
fun 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프로파일별 직렬화 제외용이다.

애너테이션타깃역할처리 주체file
@ByteRange(start,end)FIELD/PARAM/PROPERTY바이트 단위 고정폭 위치 지정ReflectionUtilsdeserializeOfLiteralTextByByte/serializeToLiteralTextByByteByteRange.kt
@TextRange(start,end)FIELD/PARAM/TYPE_PARAM/PROPERTY문자(char) 단위 고정폭 위치ReflectionUtilsdeserializeOfLiteralTextTextRange.kt
@SeedEncryptFIELD/PARAM/PROPERTY”이 필드는 SEED 암호화 후 직렬화” 마킹serializeToLiteralTextByByte가 발견 시 SeedEncryptor.encrypt 호출SeedEncrypt.kt
@IgnoreSerialize(profiles)FIELD특정 active profile일 때 JSON에서 필드 제외IgnoreSerializer(Jackson) + IgnoreSerializeBeanSerializerModifierIgnoreSerialize.kt

고정폭 직렬화(fixed-width)란? — LCC SEED 인증의 핵심

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


3. util — 확장함수·헬퍼 21종 (가장 큰 패키지)

분류해서 본다. (코루틴 관련 CoroutineExtensions.kt는 → async-coroutines)

3.1 날짜/시간 (DateExtensions.kt)

함수시그니처/동작주의점
today/yesterday/tomorrow/now기본 zoneId = "Asia/Seoul"KST가 기본값. UTC가 필요하면 명시적으로 now("UTC") 호출
String.toLocalDate/toLocalDateTime/toLocalTime포맷 지정 파싱, parseCaseInsensitive() 사용toLocalTimepadStart(4,'0')로 “930”→“0930” 보정
String.isDate/isDateTime/isTimetry-catch로 파싱 가능 여부 Boolean예외를 삼켜서 검증용으로 사용
ZonedDateTime.toKSTLocalDate / LocalDateTime.toUTC/toKST타임존 변환항공 도메인은 KST↔UTC 변환이 빈번
String.parseToDuration()"0230"PT02H30M비행시간 문자열 전용, 앞 2자=시 뒤 2자=분
infix differ / between / toMillis시간 차/구간 판정differ는 분 단위 Duration

타임존 버그의 단골 — now()의 기본값

PnrUtils.isPnrCreatedAtBeforeYesterdayOrNoShow는 명시적으로 now("UTC")를 쓴다(PnrUtils.kt:9,12). 항공 예약 시각 비교는 반드시 어느 타임존 기준인지 따져야 한다. DateExtensions의 기본값이 KST라서, UTC 비교 로직에서 기본 now()를 그냥 쓰면 9시간 오차가 난다.

3.2 문자열/숫자/컬렉션

파일핵심 함수용도
StringUtils.kttrimJsonString(로그용 줄바꿈/공백 제거), capitalizeFirstLetter, trimStartZero(“007”→“7”)trimStartZero는 ReflectionUtils 숫자 파싱에서 선행 0 제거에 필수
Extensions.ktcalculatePercentageChange(origin,new)요금 변동률 계산, origin==0이면 0.0 (0 나눗셈 방어)
CollectionUtils.ktIterable.sequential(task,onFailure)(순차 실행+부분실패 수집), List<List<T>>.cartesianProduct()(곱집합)sequential은 코루틴 없는 동기판 부분실패 처리, cartesianProduct는 검색 조합 생성
Base62.ktLong.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.ktOSIFormat.Mobile/Emailof(validatingCarrier, value)항공사별 prefix(CTCM/CTCE/EMAIL…) 대량 분기 APISUtils.kt:9-37
APISUtils.ktSSRFormatofInfantName, FOID.ofPassport(“PP”+번호)유아 SSR 이름 포맷 APISUtils.kt:42
PassengerUtils.ktPassengerFormat.NametoCarrier/fromCarrier, expandSingleCharacterSurname(외자 성 2자로)ZE/YP/7C 항공사 firstName 공백제거 등 PassengerUtils.kt:16-57
PassengerUtils.ktPassengerFormat.Mobile/EmailSSR/OSI 변환, @// 등 이메일 이스케이프Email.Ssr.toCarrier PassengerUtils.kt:118-127
CityUtils.ktCityUtilsisMultiCity(다중공항 도시 220+개 HashSet), isDomesticAirport하드코딩된 IATA 목록 “2022.08.25 기준” CityUtils.kt:6
CountryUtils.ktCountryUtilsiso3CodeToIso2Code/역변환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.ktBouncyCastle SEED/CBC/PKCS5Padding 암호화T’way 등 SEED 인증 (@SeedEncrypt가 호출) SeedEncryptor.kt:17
CryptoUtils.ktString.toSha3(), CipherHandler 인터페이스 + @Encrypt/@Decrypt Jackson 애너테이션필드 단위 암복호화 직렬화 CryptoUtils.kt:33-43
PasswordDigest.ktWS-Security PasswordDigest(SHA-1+nonce+created), genNonce, getExpiredTime(현재+5분)SOAP 인증 헤더 PasswordDigest.kt:17

암호화가 3가지 경로로 흩어져 있다

SeedEncryptor(SEED, 고정폭 LCC) ② CryptoUtils.CipherHandler(공급사가 구현하는 범용 암복호 — @Encrypt/@Decrypt로 JSON 필드 자동 처리, KoreanairPaymentClient가 사용) ③ PasswordDigest(SOAP WS-Security). “암호화”라는 키워드로 검색하면 셋 다 나오니, 어느 프로토콜용인지 먼저 구분하라.

3.5 SOAP/XML/리플렉션 DSL

파일내용비고
SoapExtensions.ktsoap{} 빌더 DSL(header/body/element/attribute), secHeader(WS-Security), @SoapBody Jackson 직렬화, SOAPMessage.soapBody<T>() 역직렬화GDS 3사(아마데우스/세이버/갈릴레오)의 SOAP 골격. xmlMapper Bean을 BeanUtils로 조회 SoapExtensions.kt:207,222
DOMExtensions.ktNode.asString()/Source.asString() (Transformer로 XML→String)예외를 조용히 삼킨다(catch(e){} 빈 블록) DOMExtensions.kt:17-19
ReflectionUtils.kt고정폭 직렬화/역직렬화 3종 (2장 참고)e.printStackTrace() 후 rethrow ReflectionUtils.kt:43-46
BeanUtils.ktApplicationContextAware로 static 컨텍스트 보관, getBean(name/type)정적 Bean 조회의 백도어. objectMapper/xmlMapper를 비-Bean 코드(직렬화기 등)에서 꺼낼 때 사용 BeanUtils.kt:21
JsonUtils.kt@JsonBody — String 안에 든 JSON을 다시 객체로 역직렬화BeanUtils.getBean("objectMapper") 의존 JsonUtils.kt:24
PollingUtils.ktpolling()(비동기 작업을 Redis에 PENDING→COMPLETE/ERROR 저장)/poller()(조회+에러 rethrow)비동기 결과를 Redis로 폴링. 내부에서 withLaunch 코루틴 사용 → async-coroutines PollingUtils.kt:12-49
ServletExtensions.kttoContentCachingRequestWrapper/ResponseWrapper요청 바디를 두 번 읽기 위한 래핑(필터에서 사용)

DOMExtensions.asString()의 빈 catch — 디버깅 지옥의 단골

// DOMExtensions.kt:11-21
fun Node.asString(): String {
    val writer = StringWriter()
    try { /* transform */ } catch (e: Exception) { }  // ← 통째로 삼킴
    return writer.toString()
}

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 타임아웃의 별도 OkHttpClientClientSupport.kt:34-48
String.get/post/put/delete확장함수로 URL에서 바로 빌더 생성ClientSupport.kt:50-60
execute<RES>()reified 제네릭으로 응답 타입 추론, Result<RES,OkHttpError> 반환ClientSupport.kt:146
OkHttpError.isTimeoutSocket/IO 예외 또는 메시지에 “timeout” 포함 시 trueClientSupport.kt:206 — Resilience4j 재시도 판정에 활용 → resilience-and-events
handleSoapFaultExceptionSOAP Fault 응답을 InternationalAdapterException으로 변환·capture()ClientSupport.kt:62error-handling
LogMessage로깅용 메타(요청명/SUPPLIER.* 로거)ClientSupport.kt:194

execute()는 절대 예외를 던지지 않는다 — Result를 까봐야 한다

실패도 Result.failure(OkHttpError(...))정상 반환된다(ClientSupport.kt:181-190). 호출부가 .fold(success, failure)를 안 하고 성공만 가정하면 에러를 놓친다. 이는 4.3의 Result 모나드와 짝을 이루는 설계다.

4.2 MDCHolder — 요청 컨텍스트 전파의 단일 창구

로그 추적·캐시 키·코루틴 전파가 전부 여기에 의존

MDCHoldersealed class로, SalesChannel/SalesFunnel/Pnr/ValidatingCarrier/OrderNumber/PnrCreatedAt/DatadogTraceId/SpanId/Env/SotoUserId object들을 하위로 갖는다. HTTP 헤더(Constants.ktx-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() — 없으면 InternationalAdapterExceptionMDCHolder.kt:86-103error-handling
PnrCreatedAt.get()헤더 없으면 now()(KST) fallbackMDCHolder.kt:130-132

MDCHolder.clear()를 빠뜨리면 스레드 누수 — MDCFilter의 finally가 생명줄

MDC는 ThreadLocal 기반이라, 스레드풀이 재사용될 때 이전 요청 값이 남으면 다른 요청 로그에 엉뚱한 PNR/채널이 찍힌다. MDCFilter.doFilterInternaltry { putAll } finally { clear }로 보장한다(MDCFilter.kt:18-23). 코루틴(withAsync/withLaunch)은 MDCContext()를 컨텍스트에 넣어 자식 코루틴에도 전파한다 → async-coroutines.

4.3 Result<V,E> — 함수형 결과 모나드

// web/Result.kt
sealed 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를 제네릭으로 지정 가능 — 표준 ResultThrowable 고정). 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.kt19개 캐시 상수명 + CacheSet enum(cacheName + TTL)CacheSet.kt:5-91
CacheKeyGenerator.kt검색요청→캐시키 생성(generateSearchRequestKey 등)MDC 채널/퍼널을 키에 포함 CacheKeyGenerator.kt:31
GzipRedisSerializer.ktGZIP 압축 Redis 직렬화 래퍼(BEST_SPEED)GzipRedisSerializer.kt:11
SnappyRedisSerializer.ktSnappy 압축 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 적재(putAllfinally에서 clear. Datadog span에 환경 태깅MDCFilter.kt:13-24
AdapterLoggingFilterCommonsRequestLoggingFilter 확장. POST/PUT 바디만 구조화 로깅, /health//swagger 제외, span에 supplier.name 태깅AdapterLoggingFilter.kt:26-54
ContentCachingWrapperFilter요청/응답 바디를 캐싱 래퍼로 감싸 두 번 읽기 가능하게(로깅 위해)ContentCachingWrapperFilter.kt:12-22

바디를 두 번 읽으려면 래핑이 필요하다

Servlet의 InputStream은 한 번만 읽힌다. 로깅 필터가 바디를 읽으면 컨트롤러가 못 읽는다. ContentCachingWrapperFilter가 바디를 메모리에 캐싱해 양쪽이 모두 읽게 한다. AdapterLoggingFilter는 정규식으로 공급사명을 추출해 Datadog span에 태깅한다(internals/(AMADEUS|SABRE|...), AdapterLoggingFilter.kt:28-29) — 단 이 정규식 목록에 일부 공급사(JEJUAIR/KOREANAIR/GROUPAIR)가 빠져 있다.

6.2 log (민감정보 마스킹)

파일역할file:line
EncryptValueMasker로그에서 cardNumber/expiryDate/password/userMobile*로 마스킹EncryptValueMasker.kt:8-13
CustomLayoutWrappingEncoderLogback EncoderBase 커스텀 — layout을 바이트로 인코딩CustomLayoutWrappingEncoder.kt:8

마스킹 대상은 컨텍스트( message/request_body) 한정 — 누락 위험

EncryptValueMaskerTARGET_CONTEXTS = {"message", "request_body"} 안의 4개 키만 마스킹한다(EncryptValueMasker.kt:8-13). response_body나 다른 JSON 컨텍스트에 카드번호가 실리면 마스킹되지 않고 평문 로깅된다. 새 결제 필드를 추가할 때 이 목록도 갱신해야 PCI 누출을 막는다. → landmines

6.3 converter / configuration

파일역할
LocalDateConverter / LocalDateTimeConverterSpring MVC 쿼리파라미터 String→날짜 변환(object, Converter 구현)
OpenApiProperties@ConfigurationProperties("open-api"), springdoc 활성 시에만 로드(@ConditionalOnProperty) → configuration-and-infra

7. model — 공급사 공통 도메인 VO

interfaces의 외부 요청 DTO(SearchRequest, BookingRequest…)를 받아 공급사 모듈이 공유하는 내부 모델로 변환한 것. (외부 DTO는 → interfaces-dtos)

모델역할변환원천 / 비고 (file)
PassengerInfo/PassengerTypeQuantity/SearchPreferenceInfo/FareFamily승객수·운임가족 검색조건of(Passenger) 팩토리, IdentityType 보유 PassengerInfo.kt
PaymentInfo (sealed)결제수단 — KeyInCard/TossPay카드 만료년/월·할부코드 등 파생 프로퍼티, @Serializable PaymentInfo.kt:32-96
Passport여권 — isFake(), ofFake()(가짜 여권 KR/X1234567)Constants.FAKE_PASSPORT_* 사용 Passport.kt:16,32
ReissueResult<Booking,Passenger> / CardInfo재발행 결과 + 카드정보제네릭 컨테이너 ReissueResult.kt:12
StructuredFareRule 외 다수운임규정 구조화(취소/변경/수하물/마일리지 정책)PenaltyType/BaggageType 보유, sabre 모델 참조 StructuredFareRule.kt
OriginDestination출도착 구간LocationType 보유
StayInfo/ReservationUser/TicketTimeLimitInfo/AdvancedOptionInfo체류지/예약자/발권시한/고급검색옵션각각 of() 팩토리
AsyncMapResult/AsyncSuccess/AsyncFail/AsyncResults코루틴 부분실패 결과pmap이 반환 → async-coroutines AsyncMapResult.kt

가짜 여권( Passport.ofFake)의 존재 이유와 위험

국내선 환승 등 여권이 불필요한 구간에 ofFake()가 KR 국적·X1234567·만료 10년 후의 더미 여권을 만든다(Passport.kt:32-39). isFake()로 식별한다. 실제 발권 전에 가짜 여권이 그대로 전송되면 항공사에서 거부되거나 데이터 오염이 생긴다. 가짜 여권의 흐름을 추적할 때 FAKE_PASSPORT_NUMBER 상수를 grep하라. → landmines

PaymentInfo.KeyInCard/CardInfo는 평문 카드정보를 메모리·Redis(Serializable)에 담는다

두 클래스 모두 cardNumber/password를 평문 String으로 갖고 Serializable이라 Redis 캐시(REISSUE 등)에 직렬화될 수 있다(PaymentInfo.kt:32-41, ReissueResult.kt:22-31). 6.2의 로그 마스킹과 함께 다뤄야 할 민감정보다. → landmines


8. Constants — 전역 상수

// Constants.kt
object 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_* 더미값
}
그룹상수쓰임
HTTP 헤더x-triple-sales-channel/funnel, x-air-pnr, x-air-validating-carrier, x-air-order-number, x-soto-user-id, x-triple-pnr-created-atMDCHolder가 읽음
MDC 키Supplier, ValidatingCarrier, ScheduleKey, FareKey, Environment로그/캐시 컨텍스트
가짜 여권FAKE_PASSPORT_* (KR/X1234567)Passport.ofFake

9. 코루틴은 별도 노트로 — 진입점만

util/CoroutineExtensions.ktmodel/AsyncMapResult.ktasync-coroutines에서 상세히 다룬다

여기서는 “어떤 함수가 있는지”만 가리킨다. 본 패키지 안의 코루틴 진입점:

  • withBlocking / CoroutineScope.withLaunch / CoroutineScope.withAsync — 모두 SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext()를 컨텍스트로 주입 (CoroutineExtensions.kt:13-34)
  • Iterable.pmap{} — 병렬 map + 부분실패 수집 → AsyncResults (CoroutineExtensions.kt:36-50)
  • AsyncResults.onFailure/getOrEmpty/getOrThrow — 결과 처리 (CoroutineExtensions.kt:52-68)
  • PollingUtils.polling/poller — Redis 기반 비동기 결과 폴링(withLaunch 내부 사용)

핵심: 모든 코루틴 헬퍼가 MDCContext()를 넣어 로그 컨텍스트(4.2 MDCHolder)를 자식 코루틴까지 전파하고, AdapterCoroutineExceptionHandler로 예외를 일괄 처리한다 → error-handling.


10. 의존관계 요약 다이어그램

flowchart TD
    HDR["HTTP 헤더"]
    CLIENT["공급사 Client 11개 전부"]
    CODE["공급사 코드"]
    subgraph SUP["support (공통 모듈)"]
        MDCF["filter.MDCFilter"]
        MDCH["web.MDCHolder"]
        MDC["SLF4J MDC"]
        LOG["log.* (마스킹)"]
        CKG["cache.CacheKeyGenerator"]
        CS["web.ClientSupport"]
        RES["web.Result"]
        SOAP["util.soap DSL (GDS)"]
        BEAN["BeanUtils (xmlMapper)"]
        ENUMS["enums.* (코드 매핑)"]
        MODEL["model.* (공통 VO)"]
        UTIL["util.* (날짜/문자/암호화/포맷터)"]
        ANNO["annotation.* (@ByteRange / @SeedEncrypt ...)"]
        REFL["util.ReflectionUtils (고정폭)"]
    end
    ASYNC["async-coroutines<br/>CoroutineExtensions, AsyncMapResult"]
    ERR["error-handling<br/>Exceptions, RestExceptionHandler,<br/>AdapterCoroutineExceptionHandler"]
    HDR --> MDCF
    MDCF --> MDCH
    MDCH --> MDC
    MDC --> LOG
    MDCH --> CKG
    CLIENT -->|"상속"| CS
    CS --> RES
    CS --> SOAP
    BEAN --> SOAP
    CODE --> ENUMS
    CODE --> MODEL
    ANNO --> REFL
    SUP --> ASYNC
    SUP --> ERR

이 다이어그램이 가리키는 두 분리 노트(위키링크 보존):


11. 온보딩 셀프 체크

스스로 답해보기

  1. T’way 요청은 JSON이 아니라 무엇으로 직렬화되며, 어떤 애너테이션·유틸이 관여하는가?
  2. 같은 검색조건인데 캐시가 분리되는 이유는? (힌트: 캐시 키 맨 앞)
  3. ClientSupport.execute()가 실패해도 예외가 안 나는 이유와, 호출부가 해야 할 처리는?
  4. 코루틴 안에서도 로그에 PNR이 찍히는 메커니즘은?

[!answer]- 정답 보기

  1. 고정폭 문자열(fixed-width). 필드에 @ByteRange(start,end)(+ 민감필드는 @SeedEncrypt)를 붙이고, ReflectionUtils.serializeToLiteralTextByByte가 바이트 위치에 맞춰 패딩/절단하며 @SeedEncrypt 필드는 SeedEncryptor로 SEED 암호화한다.
  2. CacheKeyGenerator.generateSearchRequestKey가 키 앞에 MDCHolder.SalesChannel + SalesFunnel을 붙이기 때문(CacheKeyGenerator.kt:31). 판매 채널/퍼널별로 캐시가 격리된다.
  3. execute()는 실패를 Result.Failure(OkHttpError)정상 반환한다(ClientSupport.kt:181-190). 호출부는 .fold(success, failure)로 두 경우를 모두 처리해야 하며, OkHttpError.isTimeout으로 타임아웃 여부를 판정해 Resilience4j 재시도와 연계한다.
  4. 모든 코루틴 헬퍼(withAsync/withLaunch/withBlocking)가 컨텍스트에 MDCContext()를 주입해, 부모 스레드의 SLF4J MDC(=MDCHolder가 채운 값)를 자식 코루틴에 복사하기 때문(CoroutineExtensions.kt:16,25,33).

관련 노트