Sabre는 세계 2위 GDS(1S) 와 연동하는 공급사 모듈이다. air-intl-adapter 안에서 파일 수 1위(882개), 코드 라인 2위(약 25,194 LOC) 의 대형 모듈이며, 다른 공급사와 달리 두 가지 프로토콜을 동시에 쓴다 — 검색은 신형 REST(BargainFinderMax v3), 예약/발권/큐/현금영수증은 구형 SOAP(stateful 세션). 비동기는 검색의 병렬 fan-out과 fire-and-forget 후처리에 코루틴을 쓰며, 가장 특이한 코루틴 사용처는 APIS 변경 파이프라인을 runBlocking으로 묶는 SabrePassengerService이다.
LCC 모듈(tway, jinair, jejuair)은 항공사 한 곳의 REST API를 호출한다. GDS인 Sabre는 하나의 연동으로 수백 개 항공사의 운임을 검색·예약한다. 그래서 응답 스키마가 “범용”이고 거대하며(파일 882개), 항공사별 예외 처리·운임 규칙 해석이 훨씬 복잡하다. NDC 모듈(koreanair, lufthansa 등)이 항공사 직접연동인 것과도 대비된다.
왜 이렇게(SOAP+REST 혼합) 연동하는가 — 코드로 검증된 사실
Sabre는 레거시 SOAP(OTA_*, EnhancedAirBook, PassengerDetails 등 OpenTravel 기반)와 신형 REST API(BargainFinderMax, BookingManagement, EnhancedAirTicket)를 모두 제공한다. 이 모듈은 오퍼레이션별로 둘을 섞어 쓴다.
그러나 SOAP search/revalidate도 살아있음 → SabreClient.search()(infrastructure/soap/SabreClient.kt:141), SabreClient.revalidate()(:236). 그중 revalidate는 FareRule 흐름에서 실제 호출됨(SabreFlightSearchService.kt:185, SabreFareRuleService.kt:42).
예약/발권/큐/현금영수증은 전부 SOAP 클라이언트(sabreClient 또는 sabrePaymentClient)를 주입받음(§4 표 참조).
"SOAP 중심"이라는 표현의 정확한 의미
운영의 핵심 트랜잭션(예약→발권→취소) 은 전부 SOAP 세션 위에서 돈다. REST는 주로 검색(읽기 성격, 무상태) 에 도입됐다. 즉 “검색만 REST로 현대화했고 트랜잭션은 여전히 SOAP”라고 이해하면 정확하다. 자세한 프로토콜 차이는 sabre-protocol 참고.
2. 모듈 규모와 서브패키지 구조
어댑터 전체에서 Sabre의 위치
공급사
파일 수
LOC(근사)
비고
galileo
525
27,447
LOC 1위 (Universal API 스키마)
sabre
882
25,194
파일 1위 / LOC 2위
amadeus
873
24,324
파일 2위
amadeusndc
357
12,693
tway
253
10,843
(이하 생략)
신입에게
Sabre는 amadeus와 함께 어댑터에서 가장 무거운 두 모듈이다. 882개 파일 중 803개(91%)가 infrastructure — 대부분 SOAP/REST 요청·응답 DTO(JAXB/Jackson 매핑용)다. 즉 “비즈니스 로직 코드”는 의외로 적고(application 9개, 약 1,892 LOC), 대부분이 외부 스키마를 그대로 옮긴 DTO다. 코드를 읽을 때 DTO 800개에 압도되지 말고 application(서비스)과 infrastructure/soap/SabreClient.kt(1,109 라인, 27개 메서드)부터 보면 된다.
이 어댑터에는 중앙 라우터가 없고, 공급사마다 자체 컨트롤러가 @RequestMapping("/internals/SABRE/...")로 Triple 예약 시스템의 내부 API를 직접 노출한다. Sabre는 컨트롤러 6개로 6개 오퍼레이션을 커버한다. 전체 라우팅 규칙은 request-flow 참고.
4.2 컨트롤러 → 엔드포인트 매핑 (실측)
컨트롤러
베이스 경로
엔드포인트
오퍼레이션
SabreSearchController
/internals/SABRE/search
POST 검색 / GET 상세(detail)
Search
SabreBookingController
/internals/SABRE/bookings
POST 생성 · PUT /{pnr} APIS변경 · PUT /{pnr}/cancel 취소 · GET /{pnr}/expected-cancel · GET /{pnr}/cancelable · GET /{pnr} 조회 · GET /{pnr}/check-pnr · GET /{pnr}/confirm · GET /{pnr}/repricing · POST /{pnr}/divide
(SabreSearchController.kt:24) 검색은 트래픽이 많고 실패해도 빈 목록(emptyList())으로 graceful degradation이 가능하기 때문이다. fallback에서 Datadog 스팬에 supplier.circuit-breaker=OPEN 태그를 찍는다(:67-73). 예약/발권은 서킷을 열면 안 되므로(돈·재고 정합성) 컨트롤러에 @CircuitBreaker가 없다. 이 “이벤트=상태전이+경보” 모델은 resilience-and-events 참고.
4.3 application 서비스 9개
서비스
주입 클라이언트
담당 오퍼레이션
SabreFlightSearchService
SOAP + REST
Search (+ revalidate)
SabreBookingService
SOAP
Booking(생성/조회/확정/분리/repricing)
SabreCancelService
SOAP + REST
Booking(취소/void/refund)
SabrePassengerService
SOAP
Booking(APIS 변경)
SabreTicketingService
SOAP
Ticketing
SabreFareRuleService
REST(FareRule) + SOAP
FareRule
SabreQueueService
SOAP
Queue
SabreCashReceiptService
SOAP(Payment)
CashReceipt
SabrePaymentService
SOAP(Payment)
(결제 — Ticketing/Cancel에서 사용)
컨트롤러 6 ≠ 서비스 9
Booking 컨트롤러 하나가 4개 서비스(SabreBookingService/SabrePassengerService/SabreCancelService + 내부적으로 SabrePaymentService)로 분해된다. 책임이 큰 오퍼레이션을 서비스로 쪼갠 결과다. 각 메서드의 시퀀스는 sabre-operations에서 다룬다.
5. SabrePassengerService — 코루틴 사용처 (자세히)
이 모듈에서 가장 독특한 코루틴 사용
어댑터 전체에서 코루틴은 support/util/CoroutineExtensions.kt와 SabrePassengerService에 집중된다. Sabre 안에서는 두 가지 패턴이 명확히 갈린다.
패턴 A — runBlocking으로 stateful 세션 파이프라인 묶기 (SabrePassengerService 전용)
SabrePassengerService.changeApis()는 하나의 SOAP 세션 토큰 안에서 조회→삭제→재생성→커밋→재조회를 순서대로 실행해야 한다. 이 일련의 작업을 runBlocking { ... }으로 감싸고, finally에서 반드시 세션을 닫는다.
SSR/OSI를 삭제하면 id가 앞으로 밀린다. 그래서 작은 id부터 지우면 잘못된 항목이 지워진다. 이 함수는 [1,2,3,5,7,8,9,10]을 [7-10,5,1-3]처럼 큰 번호부터 역순 범위로 변환해 안전하게 삭제한다(:73-92). 이 손맛 나는 로직이 깨지면 엉뚱한 승객 정보가 삭제된다. sabre-pitfalls 참조.
패턴 B — CoroutineScope(Dispatchers.IO).withLaunch { } fire-and-forget 후처리 (나머지 서비스 다수)
검색 키 제거, 미노출 운임 저장, 비동기 PNR 취소 등 결과를 기다릴 필요 없는 후처리에 쓰인다.
private fun cancelAsync(pnr: String) { CoroutineScope(Dispatchers.IO).withLaunch { cancelService.onlyPnrCancel(pnr) }}