air-intl-adapter는 11개 공급사를 어댑터로 감싸지만, Triple 예약 시스템이 보는 “겉모습(계약)“은 단 하나다. 그 계약이 바로 interfaces/request(들어오는 요청 DTO)와 interfaces/response(나가는 응답 View)다.
공급사가 GDS(SOAP)든 NDC든 LCC(REST)든, 컨트롤러가 받는 입력 타입과 돌려주는 출력 타입은 공급사 중립적인 공통 DTO다. 공급사별 차이는 전부 어댑터 내부(supplier/{name}/...domain.model.*, ...support.model.*)에 갇혀 있고, 경계에서 공통 View로 변환된다.
이 노트는 “그 계약의 필드가 무엇을 의미하고, 누가 채우고, 어떻게 변환되는가”를 신입이 한 번에 잡도록 정밀 정리한다. 흐름은 common-operations, 누가 이 DTO를 호출/생성하는지는 caller-callee-map, DTO가 의존하는 공통 enum/model은 support-common을 함께 보라.
0. 패키지 한눈에 보기
분석 대상: interfaces/ 패키지. 4개 하위 패키지로 나뉜다.
interfaces/
├── request/ ← Triple → 어댑터 (들어오는 요청 바디, @RequestBody)
├── response/ ← 어댑터 → Triple (나가는 응답, *View)
├── application/ ← 공급사 공통으로 쓰는 경량 서비스 (AirportService, FlightAmenityService)
└── controller/ ← HealthController 단 1개 (공급사 컨트롤러는 supplier/* 아래에 있음!)
흔한 오해 — 컨트롤러는 여기 거의 없다
interfaces/controller/에는 HealthController 하나뿐이다(GET /health → "ok").
실제 비즈니스 컨트롤러는 supplier/{name}/interfaces/controller/internals/{Name}{Op}Controller에 흩어져 있다. 중앙 디스패처가 없기 때문이다(system-architecture 참고). interfaces/request·interfaces/response의 DTO는 그 11개 모듈 컨트롤러들이 공유하는 공통 계약이다.
1. 핵심 설계 패턴 — companion object의 of(...) 팩토리
응답 View를 읽기 전에 이 패턴 하나만 머리에 박으면 99%가 풀린다.
거의 모든 *View는 데이터 클래스 + companion object 안의 fun of(...) 오버로드 더미로 구성된다. 각 공급사의 내부 도메인 모델을 인자로 받아 동일한 View를 만든다.
// FareItineraryView.kt (발췌) — 같은 View를 공급사 수만큼 of()로 만든다data class FareItineraryView( /* 공통 필드 */ ) { companion object { fun of(fareItinerary: AmadeusFareItinerary): FareItineraryView = ... fun of(fareItinerary: SabreFareItinerary): FareItineraryView = ... fun of(fareItinerary: GalileoFareItinerary): FareItineraryView = ... fun of(fareItinerary: KoreanairFareItinerary): FareItineraryView = ... // ... 11개 공급사 각각 }}
왜 이렇게 하나? — "겉은 같고 속만 다르다"의 구현
Kotlin은 인자 타입이 다르면 같은 이름의 함수(of)를 여러 개 가질 수 있다(오버로드). 그래서 공급사별 도메인 모델(AmadeusFareItinerary, SabreFareItinerary…)마다 of를 하나씩 두면, 호출 측 컨트롤러는 그냥 FareItineraryView.of(it)만 쓰고 컴파일러가 알아서 맞는 변환을 고른다. 변환 로직(공급사 차이 흡수)은 전부 이 companion object 안에 모인다.
FlightSearchView.kt 한 파일이 1,917줄이나 되는 이유가 이것이다 — 11개 공급사 × (FareItinerary/Schedule/Segment/Leg/Fare…) 변환이 한 곳에 모여 있다.
import alias 떡칠 — 읽을 때 당황 금지
같은 이름(FareItinerary, Segment, Passenger, Booking…)이 공급사마다 존재하므로, 파일 최상단에 import ... as AmadeusFareItinerary처럼 별칭 import가 수십 줄 깔린다(FlightSearchView.kt 185행, BookingView.kt 1399행). 이건 정상이다. 클래스 이름만 보지 말고 별칭 접두사(Amadeus/Sabre/…)로 어느 공급사 모델인지 식별하라.
2. 요청 DTO (interfaces/request)
Triple 예약 시스템이 @RequestBody로 보내는 입력. 오퍼레이션 단위로 정리한다. 흐름상 순서는 common-operations 참고.
2.1 검색 계열 — BaseSearchRequest 인터페이스 + 두 구현
BaseSearchRequest는 검색의 공통 필드를 인터페이스로 추상화한 것이다. 일반 검색(SearchRequest)과 재발행 검색(ReissueSearchRequest)이 이를 구현한다.
DTO
파일:line
목적 / 핵심 필드
BaseSearchRequest
request/BaseSearchRequest.kt:5
검색 공통 계약(interface). cabins, airlines?, onlyDirect, onlyFreeBaggageInclude, useCache, preferences
SearchRequest
request/SearchRequest.kt:9
신규 항공편 검색. originDestinationLocationInfos(구간 목록), advancedOption, useMultiTicket, sotoAirlines?, logging. @get:JsonIgnore val departureDate(첫 구간 출발일 파생), fun isSearchable(supplier)(공급사별 검색 가능 여부 사전 판정)
LCC/특수 공급사는 자기 항공사 코드가 요청에 포함됐을 때만, 그리고 구간 수 제한이 있을 때만 검색을 수행한다. 컨트롤러는 이게 false면 외부 호출 없이 emptyList()를 반환한다(TwaySearchController.kt:30,43). 이는 불필요한 외부 API 호출과 서킷브레이커 카운트를 아끼는 방어선이다(resilience-and-events 참고).
2.2 예약 / 발권 / 변경 계열
DTO
파일:line
목적 / 핵심 필드
BookingRequest
request/BookingRequest.kt:8
PNR 생성. key(검색이 준 운임 키), userMobile/userEmail, emergencyContact?, passengers: List<PassengerRequest>
PassengerRequest
request/BookingRequest.kt:16
탑승객 입력. lastName/firstName, type(PassengerType), gender, birthDate, passport?, stayInfo?(미국행 등)
PassportRequest
request/BookingRequest.kt:28
issueNationality, number, nationality, expiration
StayInfoRequest
request/BookingRequest.kt:35
체류지 APIS. of(stayInfo: StayInfo) 역변환 헬퍼 보유
BookingChangeRequest
request/BookingRequest.kt:54
예약 정보 변경. validatingCarrier, supplierIdentificationKey?, passengers: List<PassengerChangeRequest>
Jackson이 역직렬화로 객체를 만드는 순간 이 init이 돈다. 즉 선결제(prepayment)가 아니면 결제정보는 필수라는 규칙이 컨트롤러 진입 전에 강제된다. CancelRequest도 비슷하게 validate()/isKeyInCardPaymentInfo()/isTossPayPayment()로 결제 조합을 검증한다(CancelRequest.kt:20).
@JsonTypeInfo(use = DEDUCTION)의 의미 — 결제수단 자동 분기
PaymentInfoRequest는 sealed class라 JSON으로 받을 때 “이게 KeyInCard냐 TossPay냐”를 알아야 한다. 보통은 "type":"KEY_IN" 같은 판별 필드를 쓰지만, 여기선 DEDUCTION을 써서 들어온 JSON의 필드 구성(예: cardNumber가 있으면 KeyInCard, billingKey가 있으면 TossPay)으로 자동 추론한다(TicketingRequest.kt:36). 클라이언트가 type 필드를 안 보내도 동작.
(NDC) 부가서비스 구매. cardInfo, baggages?/seats?(offerId/offerItemId 보유 — NDC 오퍼 식별)
함정 — "AncillaryRequest"라는 클래스는 존재하지 않는다
파일명은 AncillaryRequest.kt지만, 그 안에는 BaggageRequest, AncillaryReleaseRequest, AncillaryDeepLinkRequest, AncillaryPurchaseRequest가 들어 있고 AncillaryRequest라는 이름의 클래스는 없다. 코드에서 AncillaryRequest로 grep하면 안 나온다. 파일명 ≠ 클래스명. 또한 결제수단 PaymentInfoRequest가 두 개 존재한다 — 발권용(TicketingRequest.kt:43, sealed)과 취소용(CancelRequest.kt:43, 일반 data class). import 시 같은 단순명이라 헷갈리기 쉽다.
3. 응답 View (interfaces/response)
어댑터 → Triple로 나가는 출력. 모든 View는 공급사 도메인 모델을 of(...)로 변환한 결과다(섹션 1 참고).
3.1 검색 응답 — FlightSearchView.kt(1,917줄, 최대 파일)
검색/상세 응답의 모든 빌딩블록이 이 한 파일에 있다. 두 단계로 나뉜다: 목록용(Simple) vs 상세용(상세 필드 포함).
좌석 어메니티(콘텐츠성 정보). FlightAmenityService가 채워준 Amenity에서 변환
flightNumber.padStart(4, '0') — 일관성을 위한 정규화
거의 모든 Segment 변환에서 flightNumber를 4자리로 0 패딩한다("KE5" → "0005" 아님; "5"→"0005"). 공급사마다 편명 자릿수 표기가 달라서 소비자(Triple)가 동일하게 다루도록 경계에서 맞춰주는 것. 이런 “조용한 정규화”가 View 변환의 진짜 가치다.
id/itemKey/scheduleKey는 공급사마다 생성법이 다르다
Amadeus/Sabre 등 GDS는 도메인 모델이 이미 id를 들고 있어 그대로 복사하지만, Tway/Jinair는 왕복을 한 건으로 합치며 of(combinedFareItinerary: Pair<...>)에서 직접 SHA3 해시로 id를 합성한다(FlightSearchView.kt:142~202, toSha3()). Jinair는 of가 아니라 별도 이름 ofJinair 를 쓴다 — Tway와 시그니처(Pair<FareItinerary, FareItinerary?>)가 같아 오버로드 충돌이 나기 때문(FlightSearchView.kt:169, 870). KoreanAir는 passengerFares를 associateBy { it.type }로 타입당 1개로 dedup한다(:266). 같은 View라도 공급사 분기의 디테일이 다르니 주의.
3.2 예약 / 탑승객 — BookingView.kt
BookingView는 예약 조회/생성 응답의 정점. 여기 정의된 PassengerView/ScheduleView/FareView/TicketView(별도 파일) 등이 다른 응답에서도 재사용된다.
// BookingView.kt:762@get:JsonIgnoreval order: Int get() = identificationKey.replace(Regex("[^0-9]"), "").toInt()
identificationKey(예: "PAX1", "ADT2")에서 숫자만 추출해 정렬 기준으로 쓴다. LCC 응답을 .sortedWith(compareBy({ it.type.order }, { it.order }))로 정렬할 때 사용(BookingView.kt:150 등). @JsonIgnore라 응답 JSON엔 안 나간다. 숫자가 없는 키가 오면 toInt()에서 터진다는 잠재 위험이 있다.
ScheduleStatus.getScheduleStatusBySupplier — 공급사 상태코드 → 공통 enum
GDS마다 일정 상태 코드(HK/KK/…)가 제각각이라, 공통 ScheduleStatus로 매핑할 때 어느 공급사 코드인지를 같이 넘긴다(BookingView.kt:393 등). KoreanAir/Jejuair는 이미 도메인에 매핑된 status.scheduleStatus를 그대로 쓴다(:619,642). 공급사별 상태 매핑 테이블은 support-common에서 다룬다.
재발권 시 추가 발생 항목(차액/EMD 등). FareType{AIR, CARRIER_FEE}로 분기
@JvmName("ofTway") — 시그니처가 같은 of들을 구분하는 트릭
RepricingView·TicketingReadyView에서 of(passengers: List<X>)처럼 제네릭 타입 소거(type erasure) 후 시그니처가 동일해지는 오버로드들이 있다(List<AmadeusPassenger> vs List<SabrePassenger>는 런타임에 둘 다 List). Kotlin이 이를 막으므로 @JvmName("ofAmadeus"), @JvmName("ofSabre")로 JVM 레벨 이름을 다르게 줘서 공존시킨다(RepricingView.kt:17,39). 소스에선 같은 of로 호출되지만 컴파일된 이름은 다르다.
3.4 재가격(Repricing) — RepricingView.kt
View
파일:line
목적
RepricingView
RepricingView.kt:13
예약 후 재가격 결과. passengers: List<PassengerView>(BookingView의 PassengerView 재사용). 공급사별 of는 @JvmName으로 구분
Repricing이 왜 PassengerView를 그대로 쓰나
재가격은 “이 예약을 지금 발권하면 운임이 얼마인가”를 다시 묻는 것이라, 응답 구조가 예약 탑승객 정보(PassengerView + FareView)와 동일하다. 그래서 새 View를 만들지 않고 BookingView의 PassengerView를 재사용한다 — DTO 재사용의 좋은 예. 재가격의 의미/시나리오는 common-operations 참고.
CancelView vs ExpectedCancelView vs CancelableTypeDetailView — 헷갈리는 3형제
ExpectedCancelView/CancelableTypeDetailView: 취소하기 전 “취소되나? 환불 얼마?”를 미리 묻는 응답(조회).
CancelView: 실제 취소를 실행한 뒤의 결과.
세 View 모두 공급사의 CancelableTypeDetail 모델을 변환하지만, voided/waiverRefundable/cancelable 의미가 미묘하게 다르고 환불 필터 조건도 다르다(ExpectedCancelView는 voidable || waiverRefundable만 cancelable). 취소 흐름은 common-operations 참고.
3.6 운임 규정 — FareRuleView.kt, StructuredFareRuleView.kt
View
파일:line
목적 / 핵심 포인트
FareRuleView
FareRuleView.kt:6
원문(텍스트) 운임 규정. category?, type?, title, contents?(GDS가 주는 그대로의 규정 문구). 공통 domain.FareRule에서 변환
FareRuleView는 GDS 운임 규정을 사람이 읽는 텍스트 덩어리로 주고, StructuredFareRuleView는 그것을 취소/변경/수하물 정책으로 파싱한 구조로 준다. 흥미롭게도 StructuredFareRuleView의 대부분 공급사 of는 운임규정 API를 호출하는 게 아니라, 이미 검색 결과(FareItinerary)에 들어있던 수하물 정보(freeBaggage)만 BaggagePolicyView로 채운다(StructuredFareRuleView.kt:84~). 즉 cancellation/change 정책은 비어 있고 baggage만 있는 경우가 많다. of(structuredFareRule: StructuredFareRule)(:25)만이 완전한 정책을 채운다. 이 비대칭은 신입이 “왜 취소정책이 안 오지?”로 빠지기 쉬운 지점.
3.7 큐 / 현금영수증 / 부가서비스 / 대리점 / 기타
View
파일:line
목적
QueueView
QueueView.kt:8
GDS 대기열 PNR 정보. pnr, queueNumber, category?/timeMode?/pccOid?(공급사마다 채우는 필드 다름)
QueueRemoveView
QueueRemoveView.kt:3
큐 제거 결과. result: Boolean
CashReceiptView
CashReceiptView.kt:8
현금영수증 발행 결과. approvalNumber, approvedAt, price, ticketNumbers(취소 시 필요)
(Tway/Jinair) 대리점 크레딧 잔액. amount: Long. 변환 로직 없는 순수 출력 DTO
DeferredView<T>(sealed)/DeferredKeyView
DeferredView.kt:5,15
비동기 폴링 응답. Pending / Complete<T>(content). status: DeferredStatus. 오래 걸리는 작업을 key로 폴링
RestExceptionView
RestExceptionView.kt:7
공통 에러 응답. code, message?. of(exception)이 ApiException을 분해(errorMessage.name+메시지)
DeferredView — 메시지큐 없는 비동기의 흔적
이 시스템은 메시지큐가 없지만(상태 전파는 Resilience4j/예외/Slack — resilience-and-events), 오래 걸리는 작업은 즉시 응답 대신 DeferredView.Pending을 주고 클라이언트가 key로 폴링하는 패턴을 쓴다. DeferredView.Complete<T>가 오면 완료. support.util.DeferredStatus와 연동(async-coroutines, support-common 참고).
RestExceptionView는 에러 핸들러가 채운다
정상 View와 달리 이건 예외를 받아 만든다. 실제로 어떤 핸들러가 어떤 예외를 이 View로 변환하는지는 error-handling에서 다룬다.
4. interfaces/application — 공급사 공통 경량 서비스
컨트롤러 패키지가 아니라 모든 공급사가 공유하는 작은 서비스 2개가 여기 있다.
서비스
파일:line
역할 / 호출 관계
AirportService
application/AirportService.kt:10
IATA 코드 집합 → Map<String, Airport> 조회. infrastructure.city.CityClient 위임. 공항별 실패를 runCatching+warn으로 흡수하고 성공분만 반환(부분 실패 허용). 호출처: SabreFareRuleService(supplier/sabre/application/SabreFareRuleService.kt:26)
FlightAmenityService
application/FlightAmenityService.kt:8
좌석 어메니티 저장/조회. saveFlightAmenities(amenityKey, map) / findAmenityMap(amenityKey, segmentKeys). domain.repository.FlightAmenityRepository 위임. 호출처: 거의 모든 {Supplier}SearchController(amadeus/sabre/galileo/tway/jinair/jejuair/lufthansa/singaporeair/amadeusndc/groupair 등)
그 map을 FareItineraryDetailView.of(..., amenityMap)에 넘기면, SegmentView.of가 amenityMap?.get(segment.segmentKey)로 구간별 AmenityView를 채운다(FlightSearchView.kt:1164 등).
즉 목록 검색엔 어메니티가 없고, 상세 조회에서만 SegmentView.amenity가 채워진다. 호출 사슬 전체는 caller-callee-map 참고.
공항 한 곳 조회가 실패해도 전체를 실패시키지 않고 그 공항만 빠뜨린다(mapNotNull + getOrNull). 운임규정 표시에 공항명이 일부 없어도 화면은 떠야 하므로 합리적. 단 “일부 공항명이 비어 보이는” 증상이 여기서 조용히 나올 수 있으니 디버깅 시 warn 로그를 확인하라(exercises-debugging 연계).
5. 점검 문제 (실력 확인)
Q1. FareItineraryView.of가 Amadeus는 인자 하나(fareItinerary)인데 Tway/Jinair는 Pair<...>를 받고, Jinair는 이름이 ofJinair다. 왜 이렇게 다른가? (정답 보기)
정답
Amadeus 같은 GDS는 왕복도 하나의 FareItinerary로 표현되지만, LCC(Tway/Jinair)는 편도 단위로 운임이 분리되어 가는편/오는편 두 FareItinerary를 Pair로 묶어 들어온다. of가 이를 합쳐 하나의 View로 만들며 id/itemKey/scheduleKey를 직접 SHA3로 합성한다(FlightSearchView.kt:142~202). Jinair는 Tway와 Pair<FareItinerary, FareItinerary?> 시그니처가 동일해 오버로드 충돌이 나므로 이름을 ofJinair로 바꿨다(:169). → caller-callee-map
Q2. TicketingRequest로 paymentInfo도 없고 prepayment=false인 JSON을 보내면 컨트롤러 코드가 실행되기 전에 무슨 일이 일어나는가? (정답 보기)
정답
Jackson이 객체를 만드는 순간 init 블록(TicketingRequest.kt:19)이 돌고, MethodArgumentInvalidException(ErrorMessage.INVALID_PAYMENT_METHOD)가 던져진다. 컨트롤러 메서드 본문은 실행조차 안 된다. 이 예외가 어떻게 RestExceptionView로 바뀌는지는 error-handling 참고.
Q3. 검색 목록 응답( FareItineraryView)에는 좌석 어메니티가 없는데 상세 응답(FareItineraryDetailView)에는 있다. 어떤 메커니즘 때문인가? (정답 보기)
정답
SegmentView(상세)만 amenity 필드를 갖고, SimpleSegmentView(목록)는 안 갖는다. 상세 변환 시 컨트롤러가 FlightAmenityService.findAmenityMap(...)으로 어메니티 맵을 조회해 of(..., amenityMap)에 주입하면, SegmentView.of가 amenityMap?.get(segment.segmentKey)로 채운다(FlightSearchView.kt:1164). 목록 단계는 이 주입을 안 한다. → 섹션 4 예시