공통 인터페이스 & DTO 계약

arch-overview pattern-adapter api-internals pattern-factory

이 노트의 목적

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 objectof(...) 팩토리

응답 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목적 / 핵심 필드
BaseSearchRequestrequest/BaseSearchRequest.kt:5검색 공통 계약(interface). cabins, airlines?, onlyDirect, onlyFreeBaggageInclude, useCache, preferences
SearchRequestrequest/SearchRequest.kt:9신규 항공편 검색. originDestinationLocationInfos(구간 목록), advancedOption, useMultiTicket, sotoAirlines?, logging. @get:JsonIgnore val departureDate(첫 구간 출발일 파생), fun isSearchable(supplier)(공급사별 검색 가능 여부 사전 판정)
ReissueSearchRequestrequest/ReissueSearchRequest.kt:5재발행(여정 변경) 후보 검색. BaseSearchRequest 구현 + originDestinationLocationInfos: List<ReissueOriginDestinationLocation>, pnr?, supplierIdentificationKey?
SearchPreferencerequest/SearchRequest.kt:50검색 조건 단위. passenger, promotionCodes?, fareBasisCodes?, fareFamilies?
Passenger / PassengerTypeQuantityrequest/SearchRequest.kt:72,78인원 구성. 성인/소아/유아 각 PassengerTypeQuantity(type, count). 기본 성인 1명
FareFamilyrequest/SearchRequest.kt:57항공사별 운임 패밀리. airline, bookingClasses: Set<String>
AdvancedOption / SearchRatiorequest/SearchRequest.kt:62,66결과 분배 비율(total/outbound?/inbound?)
SearchDetailRequestrequest/SearchRequest.kt:43검색 후 상세 조회. key(검색이 발급한 캐시 키), adult/child/infant 인원
OriginDestinationLocationrequest/OriginDestinationLocation.kt:6구간 공통 계약(interface). origin/destination: LocationInfo, departureDate
OriginDestinationLocationInforequest/OriginDestinationLocation.kt:12일반 검색용 구간 구현
ReissueOriginDestinationLocationrequest/OriginDestinationLocation.kt:18재발행용 구간 + departureTimePreference?, segmentNumber?
LocationInforequest/OriginDestinationLocation.kt:26city?, airports: List<String>(IATA 공항 코드 복수)

SearchRequest.isSearchable() — 호출 전에 공급사 능력을 거른다

// SearchRequest.kt:26
fun isSearchable(supplier: Supplier): Boolean = when (supplier) {
    Supplier.TWAY        -> originDestinationLocationInfos.size <= 2 && airlines?.contains("TW") == true
    Supplier.JINAIR      -> originDestinationLocationInfos.size <= 2 && airlines?.contains("LJ") == true
    Supplier.SINGAPOREAIR-> airlines?.contains("SQ") == true
    Supplier.GROUPAIR    -> originDestinationLocationInfos.isRoundTrip()  // 왕복만
    else                 -> true
}

LCC/특수 공급사는 자기 항공사 코드가 요청에 포함됐을 때만, 그리고 구간 수 제한이 있을 때만 검색을 수행한다. 컨트롤러는 이게 false면 외부 호출 없이 emptyList()를 반환한다(TwaySearchController.kt:30,43). 이는 불필요한 외부 API 호출과 서킷브레이커 카운트를 아끼는 방어선이다(resilience-and-events 참고).

2.2 예약 / 발권 / 변경 계열

DTO파일:line목적 / 핵심 필드
BookingRequestrequest/BookingRequest.kt:8PNR 생성. key(검색이 준 운임 키), userMobile/userEmail, emergencyContact?, passengers: List<PassengerRequest>
PassengerRequestrequest/BookingRequest.kt:16탑승객 입력. lastName/firstName, type(PassengerType), gender, birthDate, passport?, stayInfo?(미국행 등)
PassportRequestrequest/BookingRequest.kt:28issueNationality, number, nationality, expiration
StayInfoRequestrequest/BookingRequest.kt:35체류지 APIS. of(stayInfo: StayInfo) 역변환 헬퍼 보유
BookingChangeRequestrequest/BookingRequest.kt:54예약 정보 변경. validatingCarrier, supplierIdentificationKey?, passengers: List<PassengerChangeRequest>
BookingDivideRequestrequest/BookingRequest.kt:60동반자 분리(divide). passengers: List<PassengerDivideRequest>
TicketingRequestrequest/TicketingRequest.kt:9발권. pnr, subPnr?, validatingCarrier, passengerPrices, paymentInfo?, prepayment, keepPnr. init 블록에서 prepayment가 아닌데 결제정보 없으면 예외
ReticketingRequestrequest/TicketingRequest.kt:26재발권(재발행 발권). detailKey, cardInfo?/prepaidPrice? 중 결제수단
PaymentInfoRequest(sealed)request/TicketingRequest.kt:43결제수단 다형. KeyInCard(수기 카드) / TossPay(토스). @JsonTypeInfo(DEDUCTION) 으로 필드 기반 자동 판별
PassengerPriceRequestrequest/TicketingRequest.kt:64탑승객별 발권 가격. cardPrice/cashPrice(복합결제), commissionType?/commissionValue?, tourCode?
PassengerChangeRequestrequest/PassengerChangeRequest.kt:7APIS/연락처 변경. identificationKey(어느 탑승객), 변경할 인적/여권/체류지
PassengerTicketRequestrequest/PassengerChangeRequest.kt:21외부 발권번호 주입. ticketNumber, conjunctionTicketNumber?
PassengerDivideRequestrequest/PassengerChangeRequest.kt:27분리 대상 탑승객 식별

TicketingRequest.init — 생성자 단계에서 막는 결제 검증

// TicketingRequest.kt:19
init {
    if (prepayment.not() && paymentInfo == null)
        throw MethodArgumentInvalidException(ErrorMessage.INVALID_PAYMENT_METHOD)
}

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 필드를 안 보내도 동작.

2.3 취소 / 환불 / 부가서비스 / 기타

DTO파일:line목적 / 핵심 필드
CancelRequestrequest/CancelRequest.kt:10취소/환불. validatingCarrier, paymentInfo?(취소 결제 역승인용), autoRefundable, waivers?. 자체 중첩 PaymentInfoRequest(발권용과 다름!) + Waiver(type, text). installment2Code(할부 2자리 패딩)
CashReceiptRequestrequest/CashReceiptRequest.kt:6현금영수증 발행. pnr, price, validatingCarrier, type(소득공제/지출증빙), identityNumber
CashReceiptCancelRequestrequest/CashReceiptRequest.kt:14현금영수증 취소. 승인번호/승인시각/티켓번호/pnrCreatedAt(UTC)
QueueRemoveRequestrequest/QueueRemoveRequest.kt:3GDS 대기열 제거. queueNumber, pnr, category?, timeMode?, pccOid?
DocumentRequestrequest/DocumentRequest.kt:6발권 서류 요청. types: List<DocumentType>, subPnr, passengerName, departureDate
BaggageRequestrequest/AncillaryRequest.kt:6(Tway/Jinair) 위탁수하물 구매. cardInfo: PaymentInfoRequest.KeyInCard, baggages: List<BaggageDetailRequest>
AncillaryReleaseRequestrequest/AncillaryRequest.kt:19부가서비스 해제. ancillaryIdentificationKey, type(AncillaryType)
AncillaryDeepLinkRequestrequest/AncillaryRequest.kt:27부가서비스 딥링크. pnr, passengers, departureAt
AncillaryPurchaseRequestrequest/AncillaryRequest.kt:33(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 상세용(상세 필드 포함).

flowchart TD
    subgraph "검색 목록 응답"
        L0["FareItineraryView"]
        L1["SimpleScheduleView"]
        L2["SimpleSegmentView"]
        L3["FlightLegView"]
        L4["PassengerFareView"]
        L0 --> L1
        L1 --> L2
        L2 --> L3
        L0 --> L4
    end
    subgraph "검색 상세 응답 - key로 재조회"
        D0["FareItineraryDetailView"]
        D1["FlightScheduleView"]
        D2["SegmentView (+ AmenityView)"]
        D3["FlightLegView"]
        D4["PassengerFareView"]
        D0 --> D1
        D1 --> D2
        D2 --> D3
        D0 --> D4
    end
View파일:line목적 / 핵심 포인트
FareItineraryViewFlightSearchView.kt:87검색 결과 1건(목록용). key/itemKey/scheduleKey/id(SHA3 해시), supplier, schedules, passengerFares, validatingCarrier, tripDirectionType?. 파생: maxStop, avail(최소 좌석)
SimpleScheduleViewFlightSearchView.kt:286목록용 일정. mainCarrier, segments, stop, addDay, avail, freeBaggage?(구간 중 최소). 파생: departure/arrival/departureAt/arrivalAt
SimpleSegmentViewFlightSearchView.kt:460목록용 구간. marketingCarrier/operatingCarrier?, bookingClass, flightNumber(4자리 패딩), cabin, flightTime, connectingTime?, legs
FareItineraryDetailViewFlightSearchView.kt:689상세 응답. FlightScheduleView(어메니티 포함), amenityMap 주입 받음
FlightScheduleViewFlightSearchView.kt:983상세 일정
SegmentViewFlightSearchView.kt:1120상세 구간. + departureTerminal?/arrivalTerminal?, overNightStay, amenity: AmenityView?(amenityMap에서 segmentKey로 조회)
FlightLegViewFlightSearchView.kt:1419운항 leg(경유 포함 실제 비행 단위). 출도착 공항/시각
FreeBaggageViewFlightSearchView.kt:1516무료 수하물. volume, unit(BaggageUnit). 공급사마다 pieces/weight 다름
PassengerFareViewFlightSearchView.kt:1591탑승객 유형별 운임. total/tax/fuelCharge/qCharge/carrierFee?, fareBasisCodes?, identityCode?/identityType?
AmenityView 외(Beverage/Entertainment/Food/Layout/Power/Seat/Wifi)FlightSearchView.kt:1803~좌석 어메니티(콘텐츠성 정보). 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()). Jinairof가 아니라 별도 이름 ofJinair 를 쓴다 — Tway와 시그니처(Pair<FareItinerary, FareItinerary?>)가 같아 오버로드 충돌이 나기 때문(FlightSearchView.kt:169, 870). KoreanAir는 passengerFaresassociateBy { it.type }타입당 1개로 dedup한다(:266). 같은 View라도 공급사 분기의 디테일이 다르니 주의.

3.2 예약 / 탑승객 — BookingView.kt

BookingView는 예약 조회/생성 응답의 정점. 여기 정의된 PassengerView/ScheduleView/FareView/TicketView(별도 파일) 등이 다른 응답에서도 재사용된다.

View파일:line목적 / 핵심 포인트
BookingViewBookingView.kt:101예약 1건. pnr: PnrView, validatingCarrier, schedules, passengers, carrierTimeLimit?/paymentTimeLimit?(발권 마감), pnrCreatedAt, reference?
PnrViewBookingView.kt:279PNR 식별. main, sub?, parents?(분리 전 원본), supplierIdentificationKey?(NDC 주문 ID 등)
ScheduleViewBookingView.kt:359예약 확정 일정. status: ScheduleStatus(ScheduleStatus.getScheduleStatusBySupplier(raw, Supplier)로 공급사 코드→공통 상태 매핑), carrierPnr?, fareBasis?, baggages, legs
BaggageViewBookingView.kt:659수하물 허용량. passengerType, unit, volume
PassengerViewBookingView.kt:744가장 많이 재사용되는 View. identificationKey, 인적/여권/체류지, fare: FareView, tickets?, identityType?. 파생 order(키에서 숫자만 뽑아 정렬)
PassportView/StayInfoViewBookingView.kt:1014,1032여권/체류지(공통 support.model에서 변환)
FareViewBookingView.kt:1052운임 합계. airPrice/tax/fuelCharge/qCharge(@get:JsonProperty("qCharge"))/carrierFee?, fareComponents. LCC는 fares.sumOf{}로 합산(Tway/Jinair/Groupair/Jejuair)
LegViewBookingView.kt:1168예약 일정의 leg
BookingChangeView/PassengerApisChangeViewBookingView.kt:1290,1294APIS 변경 결과
FareComponentViewBookingView.kt:1381fareBasis 한 줄. 일부 공급사는 null 가능해 mapNotNull
BookingReferenceView/AncillaryViewBookingView.kt:1439,1455(Tway/Jinair) 부가서비스 참조

PassengerView.order — 정렬을 위한 영리한 파생 프로퍼티

// BookingView.kt:762
@get:JsonIgnore
val 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에서 다룬다.

3.3 발권 / 재발권 — TicketingView.kt, ReticketingView.kt, TicketingReadyView.kt

View파일:line목적 / 핵심 포인트
TicketingViewTicketingView.kt:50발권 결과. passengers: List<TicketingPassengerView>
TicketingPassengerViewTicketingView.kt:54탑승객 발권 결과. ticket: TicketView, payment: PaymentView?(선결제면 null). Sabre는 KeyInCard/TossPay 분기(:115~)
PaymentViewTicketingView.kt:274결제 승인 결과. 카드/토스 정보. ofKeyInCard/ofTossPay로 결제수단별 팩토리 분리. NDC는 승인번호 없어 "00000000" 하드코딩(:420)
TicketViewTicketingView.kt:433발권된 티켓 1장. ticketNumber, originalTicketNumber?(재발행 원본), conjunctionTicketNumber?, status, price: TicketPriceView?, commissionType?/commissionValue?, type(TICKET/EMD), virtualized
TicketPriceViewTicketingView.kt:764티켓 가격. cardPrice/cashPrice(복합결제), airPrice/tax
TicketingReadyViewTicketingReadyView.kt:18발권 직전 상태(repricing 후 탑승객+일정 확인). @JvmName으로 공급사별 of 구분
ReticketingViewReticketingView.kt:38재발권 결과. passengers: List<ReticketingPassengerView>
ReticketingPassengerView/ReticketingAddedInfoView/AddedFareView/AddedTicketView/AddedPaymentViewReticketingView.kt:147~재발권 시 추가 발생 항목(차액/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목적
RepricingViewRepricingView.kt:13예약 후 재가격 결과. passengers: List<PassengerView>(BookingView의 PassengerView 재사용). 공급사별 of@JvmName으로 구분

Repricing이 왜 PassengerView를 그대로 쓰나

재가격은 “이 예약을 지금 발권하면 운임이 얼마인가”를 다시 묻는 것이라, 응답 구조가 예약 탑승객 정보(PassengerView + FareView)와 동일하다. 그래서 새 View를 만들지 않고 BookingViewPassengerView를 재사용한다 — DTO 재사용의 좋은 예. 재가격의 의미/시나리오는 common-operations 참고.

3.5 취소 / 환불 — CancelView.kt, RefundView.kt, ExpectedCancelView.kt, CancelableTypeDetailView.kt

View파일:line목적 / 핵심 포인트
CancelViewCancelView.kt:8취소 실행 결과. voided(발권취소=void 여부), waiverRefunded, refunds. refundFee>0 또는 usedTax>0인 환불만 필터
RefundViewRefundView.kt:15환불 1건. type, identificationKey, refundFee, usedTax?, usedAirPrice?
ExpectedCancelViewExpectedCancelView.kt:9취소 가능 여부 사전조회. cancelable/voidable/waiverRefundable, refunds(예상 환불). expectedCancel 채널은 무료(waiver) 환불만 cancelable 처리(:31)
CancelableTypeDetailViewCancelableTypeDetailView.kt:15취소 유형 상세. action(CancelActionType), waiverRefundable, refunds?

CancelView vs ExpectedCancelView vs CancelableTypeDetailView — 헷갈리는 3형제

  • ExpectedCancelView/CancelableTypeDetailView: 취소하기 전 “취소되나? 환불 얼마?”를 미리 묻는 응답(조회).
  • CancelView: 실제 취소를 실행한 뒤의 결과. 세 View 모두 공급사의 CancelableTypeDetail 모델을 변환하지만, voided/waiverRefundable/cancelable 의미가 미묘하게 다르고 환불 필터 조건도 다르다(ExpectedCancelViewvoidable || waiverRefundable만 cancelable). 취소 흐름은 common-operations 참고.

3.6 운임 규정 — FareRuleView.kt, StructuredFareRuleView.kt

View파일:line목적 / 핵심 포인트
FareRuleViewFareRuleView.kt:6원문(텍스트) 운임 규정. category?, type?, title, contents?(GDS가 주는 그대로의 규정 문구). 공통 domain.FareRule에서 변환
StructuredFareRuleViewStructuredFareRuleView.kt:21구조화된 운임 규정. segments: List<SegmentFareRulePolicyView>
SegmentFareRulePolicyView/FareRulePolicyView:370,375구간별 정책 묶음: cancellation?/change?/baggage?/mileage?/additionalServices?
CancellationPolicyView/ChangePolicyView/PenaltyConditionView:383~취소/변경 가능 여부 + 위약금 조건(PenaltyType, period, currency, fee: BigDecimal)
BaggagePolicyView/MileagePolicyView/AdditionalServicesPolicyView:409~수하물/마일리지/부가서비스 정책

두 가지 FareRule — 원문 vs 구조화

FareRuleView는 GDS 운임 규정을 사람이 읽는 텍스트 덩어리로 주고, StructuredFareRuleView는 그것을 취소/변경/수하물 정책으로 파싱한 구조로 준다. 흥미롭게도 StructuredFareRuleView의 대부분 공급사 of운임규정 API를 호출하는 게 아니라, 이미 검색 결과(FareItinerary)에 들어있던 수하물 정보(freeBaggage)만 BaggagePolicyView로 채운다(StructuredFareRuleView.kt:84~). 즉 cancellation/change 정책은 비어 있고 baggage만 있는 경우가 많다. of(structuredFareRule: StructuredFareRule)(:25)만이 완전한 정책을 채운다. 이 비대칭은 신입이 “왜 취소정책이 안 오지?”로 빠지기 쉬운 지점.

3.7 큐 / 현금영수증 / 부가서비스 / 대리점 / 기타

View파일:line목적
QueueViewQueueView.kt:8GDS 대기열 PNR 정보. pnr, queueNumber, category?/timeMode?/pccOid?(공급사마다 채우는 필드 다름)
QueueRemoveViewQueueRemoveView.kt:3큐 제거 결과. result: Boolean
CashReceiptViewCashReceiptView.kt:8현금영수증 발행 결과. approvalNumber, approvedAt, price, ticketNumbers(취소 시 필요)
AncillaryAvailView/BaggageAvailView/SeatAvailView/AncillaryReleaseViewAncillaryView.kt:19,47,145,133부가서비스 가용성/좌석맵/해제 결과(Tway/Jinair/Lufthansa/Singapore)
AncillaryDeeplinkViewAncillaryDeeplinkView.kt:5(Tway) 부가서비스 딥링크 URL(PC/모바일). 구버전 of @Deprecated
AgencyCreditViewAgencyCreditView.kt:3(Tway/Jinair) 대리점 크레딧 잔액. amount: Long. 변환 로직 없는 순수 출력 DTO
DeferredView<T>(sealed)/DeferredKeyViewDeferredView.kt:5,15비동기 폴링 응답. Pending / Complete<T>(content). status: DeferredStatus. 오래 걸리는 작업을 key로 폴링
RestExceptionViewRestExceptionView.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역할 / 호출 관계
AirportServiceapplication/AirportService.kt:10IATA 코드 집합 → Map<String, Airport> 조회. infrastructure.city.CityClient 위임. 공항별 실패를 runCatching+warn으로 흡수하고 성공분만 반환(부분 실패 허용). 호출처: SabreFareRuleService(supplier/sabre/application/SabreFareRuleService.kt:26)
FlightAmenityServiceapplication/FlightAmenityService.kt:8좌석 어메니티 저장/조회. saveFlightAmenities(amenityKey, map) / findAmenityMap(amenityKey, segmentKeys). domain.repository.FlightAmenityRepository 위임. 호출처: 거의 모든 {Supplier}SearchController(amadeus/sabre/galileo/tway/jinair/jejuair/lufthansa/singaporeair/amadeusndc/groupair 등)

FlightAmenityService가 SegmentView.amenity로 흐르는 경로

  1. 검색 시 컨트롤러/서비스가 어메니티를 조회해 saveFlightAmenities로 캐싱.
  2. 상세 조회(detail)에서 findAmenityMap(amenityKey, segmentKeys)Map<String, Amenity>를 꺼냄(TwaySearchController.kt:62).
  3. 그 map을 FareItineraryDetailView.of(..., amenityMap)에 넘기면, SegmentView.ofamenityMap?.get(segment.segmentKey)로 구간별 AmenityView를 채운다(FlightSearchView.kt:1164 등). 즉 목록 검색엔 어메니티가 없고, 상세 조회에서만 SegmentView.amenity가 채워진다. 호출 사슬 전체는 caller-callee-map 참고.

AirportService의 부분 실패 설계

// AirportService.kt:16
codes.mapNotNull {
    cityClient.runCatching { getAirportByIata(it) }
        .onFailure { logger.warn(it.message, it) }
        .getOrNull()
}.associateBy { it.iataCode }

공항 한 곳 조회가 실패해도 전체를 실패시키지 않고 그 공항만 빠뜨린다(mapNotNull + getOrNull). 운임규정 표시에 공항명이 일부 없어도 화면은 떠야 하므로 합리적. 단 “일부 공항명이 비어 보이는” 증상이 여기서 조용히 나올 수 있으니 디버깅 시 warn 로그를 확인하라(exercises-debugging 연계).


5. 점검 문제 (실력 확인)


6. 교차 참조

  • 이 DTO들이 흐르는 오퍼레이션 생명주기 → common-operations
  • 어떤 컨트롤러/서비스가 이 DTO를 생성·소비하는지 → caller-callee-map
  • DTO가 의존하는 공통 enum(Supplier, PassengerType, CabinType, ScheduleStatus, BaggageUnit…)·support.model(StayInfo, Passport, PaymentInfo) → support-common
  • RestExceptionView/MethodArgumentInvalidException의 처리 → error-handling
  • DeferredView와 비동기 폴링 → async-coroutines
  • 시스템 전체 구조(중앙 디스패처 부재) → system-architecture / 요청 1건의 레이어 통과 → request-flow