Phase 3: Sabre 발권 API 심층 분석
1. API 엔드포인트 개요
1.1 발권 관련 엔드포인트
| 엔드포인트 | 메서드 | 기능 | 위치 |
|---|---|---|---|
/internals/SABRE/ticketing/ready | POST | 발권 준비 및 검증 (Deprecated) | SabreTicketingController.kt |
/internals/SABRE/ticketing | POST | 실제 발권 실행 | SabreTicketingController.kt |
2. 발권 준비 (Ready) - @Deprecated
2.1 발권 준비 플로우
flowchart TD A[발권 준비 요청] --> B[Session Token 획득<br/>getSessionToken] B --> C[PNR 정보 조회<br/>getBooking] C --> D[발권 가능 상태 검증<br/>validateBookingConditionForTicketing] D --> E{PriceQuote<br/>생성일 확인} E -->|당일| F[스케줄 정보만 반환<br/>null to schedules] E -->|당일 아님| G[기존 PriceQuote 삭제<br/>deletePriceQuote] G --> H[Repricing 수행<br/>repricing] H --> I[트랜잭션 종료<br/>endTransaction] I --> J[PNR 재조회<br/>getBooking] J --> K[FareBasis 변경 검증<br/>validateFareBasisChange] K --> L[승객 & 스케줄 반환<br/>passengers to schedules] F --> M[closeSessionToken] L --> M M --> N[결과 반환] 다크/라이트 모드 호환 색상 style E fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style D fill:#B3D9FF,stroke:#333,stroke-width:2px,color:#000 style K fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style O fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style N fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
위치: SabreTicketingService.kt:26-50
2.2 PriceQuote 생성일 기반 Repricing
return if (booking.priceQuoteCreatedAt.toLocalDate() != today()) {
sabreClient.deletePriceQuote(token)
sabreClient.repricing(token, booking.passengers)
sabreClient.endTransaction(token)
sabreClient.getBooking(token = token, pnr = pnr).also {
validateFareBasisChange(originBooking = booking, newBooking = it)
}.let { it.passengers to it.schedules }
} else {
null to booking.schedules
}위치: SabreTicketingService.kt:32-40
Repricing 조건
- 조건: PriceQuote 생성일이 오늘이 아닌 경우
- 목적: 운임 재계산으로 최신 가격 반영
- 프로세스:
- 기존 PriceQuote 삭제 (DeletePriceQuoteRQ)
- 새로운 PriceQuote 생성 (OtaAirPriceRQ)
- 트랜잭션 종료로 PNR 저장
- PNR 재조회로 새 PriceQuote 검증
2.3 FareBasis 변경 검증
private fun validateFareBasisChange(originBooking: Booking, newBooking: Booking) {
newBooking.passengers.forEach { newPassenger ->
val originFareBasis = originBooking.passengers
.first { it.type == newPassenger.type && it.identificationKey == newPassenger.identificationKey }
.fare?.fareComponents?.joinToString(",")
val newFareBasis = newPassenger.fare?.fareComponents?.joinToString(",")
if (originFareBasis != newFareBasis) {
cancelService.onlyPnrCancel(pnr = originBooking.pnr!!)
throw StatusInvalidException(
ErrorMessage.SOLD_OUT,
"${newPassenger.type.name} fareBasis is changed ($originFareBasis->$newFareBasis)",
).capture()
}
}
}위치: SabreTicketingService.kt:199-216
검증 로직
- 목적: Repricing 후 운임 코드 변경 감지
- 비교 대상: FareComponent 리스트 (콤마 구분 문자열 비교)
- 실패 시: PNR 취소 후
SOLD_OUT예외 발생 - 대상: 모든 승객 타입 & IdentificationKey 매칭
3. 발권 실행 (Issue)
3.1 전체 발권 플로우
flowchart TD A[발권 요청] --> B[Session Token 획득<br/>getSessionToken] B --> C[PNR 정보 조회 & 검증<br/>getBooking] C --> D{결제 방식<br/>분기} D -->|TossPay| E[TossPay 결제 승인<br/>approveTossPay] D -->|KeyInCard| F[KeyInCard 결제 승인<br/>approve + pgCardCode] E --> G[발권 요청<br/>ticketing] F --> G G --> H{Uncommitted<br/>Ticket 확인} H -->|존재| I[예외 발생<br/>TICKETING_FAILED] H -->|없음| J[티켓 조회<br/>retrieveTicket] J --> K[Payment 정보 결합<br/>withPayment] K --> L[closeSessionToken] L --> M[발권 완료 반환] 다크/라이트 모드 호환 색상 style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style H fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style I fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style N fill:#FFE5B4,stroke:#333,stroke-width:2px,color:#000 style O fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style M fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
위치: SabreTicketingService.kt:52-134
3.2 결제 처리 분기
3.2.1 결제 방식 분기 로직
fun approve(
pnr: String,
validatingCarrier: String,
passengerPrices: List<PassengerPrice>,
paymentInfo: PaymentInfo,
) = when (paymentInfo) {
is PaymentInfo.TossPay -> {
sabrePaymentClient.approveTossPay(
pnr = pnr,
validatingCarrier = validatingCarrier,
passengerPrices = passengerPrices,
tossPay = paymentInfo,
)
}
is PaymentInfo.KeyInCard -> {
sabrePaymentClient.approve(
validatingCarrier = validatingCarrier,
passengerPrices = passengerPrices,
cardInfo = paymentInfo,
cardCode = sabrePaymentClient.pgCardCode(
cardNumber = paymentInfo.cardNumber,
validatingCarrier = validatingCarrier
),
)
}
}위치: SabrePaymentService.kt:23-49
3.2.2 TossPay vs KeyInCard 비교
| 항목 | TossPay | KeyInCard |
|---|---|---|
| 메서드 | approveTossPay | approve |
| 카드 코드 | TossPay에서 제공 | pgCardCode 조회 필요 |
| PNR 파라미터 | 필요 | 불필요 |
| 취소 메서드 | cancelTossPay | cancel |
| Payment 플래그 | isTossPayPayment = true | isTossPayPayment = false |
3.3 발권 요청 (AirTicketRQ)
3.3.1 발권 API 호출
val passengerTickets = sabreClient.ticketing(
token = token,
pnr = pnr,
validatingCarrier = validatingCarrier,
passengers = passengers,
passengerPrices = passengerPrices,
payment = payment,
paymentInfo = paymentInfo,
addEndorsement = "RFND ONLY TO ISSUE AGT".takeIf { validatingCarrier == "PR" },
timeoutCallback = {
slackService.sendTicketingTimeout(
supplier = Supplier.SABRE,
pnr = pnr,
)
}
)위치: SabreTicketingService.kt:75-90
3.3.2 항공사별 Endorsement 처리
PR 항공사 (Philippine Airlines) 특별 처리
addEndorsement = "RFND ONLY TO ISSUE AGT".takeIf { validatingCarrier == "PR" }위치: SabreTicketingService.kt:83
- 조건: validatingCarrier == “PR”
- Endorsement: “RFND ONLY TO ISSUE AGT”
- 목적: PR 항공사 환불 정책 명시 (발권 대리점에서만 환불 가능)
- 전달:
AirTicketRQ.of()→MiscQualifier.of()→Endorsement.of()
4. 항공사별 특별 발권 처리
4.1 MU 항공사 (중국동방항공) - DOB Endorsement
4.1.1 비성인 티켓 분리 로직
private fun individualTickets(
validatingCarrier: String,
passengers: List<Passenger>,
passengerPrices: List<PassengerPrice>,
payment: Payment,
paymentInfo: PaymentInfo,
addEndorsement: String?,
): List<Ticketing> {
val (adults, childOrInfants) = passengers.partition { it.type == PassengerType.ADULT }
val adultKeys = adults.mapNotNull { it.identificationKey }.toSet()
val childOrInfantKeys = childOrInfants.mapNotNull { it.identificationKey }.toSet()
return buildList {
addAll(
groupedTickets(
validatingCarrier = validatingCarrier,
passengers = adults,
passengerPrices = passengerPrices.filter { it.identificationKey in adultKeys },
payment = payment,
paymentInfo = paymentInfo,
addEndorsement = addEndorsement,
)
)
addAll(
dobTickets(
validatingCarrier = validatingCarrier,
passengers = childOrInfants,
passengerPrices = passengerPrices.filter { it.identificationKey in childOrInfantKeys },
payment = payment,
paymentInfo = paymentInfo,
)
)
}
}위치: AirTicketRQ.kt:78-112
4.1.2 DOB Endorsement 생성
private fun dobTickets(
validatingCarrier: String,
passengers: List<Passenger>,
passengerPrices: List<PassengerPrice>,
payment: Payment,
paymentInfo: PaymentInfo,
): List<Ticketing> {
return passengerPrices.map { price ->
val passenger = passengers.first { passenger ->
passenger.identificationKey == price.identificationKey
}
val isNetRemit = passenger.fare?.isNetRemit == true
val (commission, needAdditionalCommission) = convertCommission(
pqCommission = passenger.fare?.commission,
overCommission = price.commission
)
Ticketing(
flightQualifier = FlightQualifier.of(validatingCarrier = validatingCarrier),
fopQualifier = FopQualifier.of(
payment = payment,
paymentInfo = paymentInfo,
cashPrice = price.cashPrice
),
pricingQualifier = PricingQualifier.of(passengers = listOf(passenger)),
miscQualifier = MiscQualifier.of(
commission = commission.takeIf { needAdditionalCommission },
addEndorsement = passenger.birthDate?.let {
"DOB${
passenger.birthDate.format("ddMMMyy", Locale.ENGLISH).uppercase(Locale.getDefault())
}"
},
approvalNumber = payment.approvalNumber!!,
cardPrice = price.cardPrice.takeIf { isNetRemit }
)
)
}
}위치: AirTicketRQ.kt:114-152
4.1.3 DOB 포맷
- 형식:
DOB{ddMMMyy}(예:DOB15JAN20) - 대상: Child & Infant (PassengerType.ADULT 제외)
- 예시: 2020년 1월 15일 생 →
DOB15JAN20 - Locale:
Locale.ENGLISH(월 영문 표기)
4.1.4 MU 항공사 발권 플로우
flowchart TD A[AirTicketRQ.of 호출] --> B{validatingCarrier<br/>== 'MU' &&<br/>비성인 포함?} B -->|Yes| C[individualTickets<br/>성인/비성인 분리] B -->|No| D[groupedTickets<br/>통합 발권] C --> E[성인: groupedTickets<br/>addEndorsement 포함] C --> F[비성인: dobTickets<br/>DOB Endorsement] F --> G[각 비성인별<br/>개별 Ticketing 생성] G --> H["DOB{ddMMMyy}"<br/>Endorsement 추가] E --> I[Ticketing 리스트 병합] H --> I D --> I I --> J[AirTicketRQ 반환] 다크/라이트 모드 호환 색상 style B fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style E fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style F fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000 style G fill:#B3D9FF,stroke:#333,stroke-width:2px,color:#000 style H fill:#B3D9FF,stroke:#333,stroke-width:2px,color:#000 style I fill:#FFE5B4,stroke:#333,stroke-width:2px,color:#000
5.3 MiscQualifier Commission 설정
miscQualifier = MiscQualifier.of(
commission = commission.takeIf { needAdditionalCommission },
addEndorsement = addEndorsement,
approvalNumber = payment.approvalNumber!!,
cardPrice = firstPassengerPrice.cardPrice.takeIf { isNetRemit }
)위치: AirTicketRQ.kt:184-189
- needAdditionalCommission = false: MiscQualifier에 commission 추가 안 함 (PQ 기존 값 유지)
- needAdditionalCommission = true: MiscQualifier에 commission 추가 (PQ 값 오버라이드)
6. 발권 검증 및 에러 처리
6.1 Uncommitted Ticket 검증
ticketCount = passengerTickets.size
val uncommittedTickets = passengerTickets.filterNot { it.committed }
if (uncommittedTickets.isNotEmpty()) {
throw StatusInvalidException(
ErrorMessage.TICKETING_FAILED,
pnr,
"uncommitted tickets",
uncommittedTickets.joinToString()
).capture()
}위치: SabreTicketingService.kt:91-100
Committed 플래그
- 의미: 티켓이 GDS에 정상 저장되었는지 여부
- 확인:
PassengerTicket.committed필드 - 실패 시: 발권 실패 예외 발생 (티켓 리스트 포함)
6.2 발권 API 에러 처리
6.2.1 INCORRECT CARD NUMBER
when {
any { it.contains("INCORRECT CARD NUMBER") } -> {
listOf(
messages.first(),
"INCORRECT CARD NUMBER",
validatingCarrier,
(paymentInfo as? PaymentInfo.KeyInCard)?.cardNumber?.take(8) ?: "",
payment.cardCode ?: ""
)
}
}위치: SabreClient.kt:528-536
- 조건: 응답 메시지에 “INCORRECT CARD NUMBER” 포함
- 로그: 항공사, 카드 앞 8자리, cardCode
- 원인: 항공사가 인식하지 못하는 카드 코드
6.2.2 VERIFY COMMISSION-2133
any { it.contains("VERIFY COMMISSION-2133") } -> {
listOf(
messages.first(),
"COMMISSION TYPE",
validatingCarrier,
"OVER COMMISSION: ${passengerPrices.first().commission?.type}",
"PQ COMMISSION: ${passengers.first().fare?.commission?.type}",
)
}위치: SabreClient.kt:539-547
- 조건: Commission 불일치 에러
- 로그: Over Commission vs PQ Commission 타입 비교
- 원인: 요청한 commission이 PQ의 commission보다 클 때
6.2.3 Timeout 처리
failure = {
if (it.isTimeout) {
timeoutCallback()
}
throw it.handleSoapFaultException(ErrorMessage.TICKETING_FAILED, pnr)
}위치: SabreClient.kt:558-563
- 콜백:
slackService.sendTicketingTimeout() - 목적: 발권 타임아웃 즉시 알림
- 후속 처리: 예외 재발생
6.3 발권 후 티켓 조회 (retrieveTicket)
6.3.1 티켓 조회 플로우
flowchart TD A[retrieveTicket 호출] --> B[PNR 재조회<br/>getBooking] B --> C[승객-티켓 매칭<br/>PassengerFormat.Name.fromCarrier] C --> D{매칭 실패<br/>승객 존재?} D -->|Yes| E[Slack 알림<br/>sendPartiallyTicketingFail] E --> F[예외 발생<br/>PARTIALLY_TICKETING_FAILED] D -->|No| G[정상 Booking 반환] 다크/라이트 모드 호환 색상 style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style I fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style K fill:#FFE5B4,stroke:#333,stroke-width:2px,color:#000 style L fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style J fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
closeSessionToken 실패 시
- Slack 알림:
sendIncompleteTicketing(PNR, 실패 사유) - 예외 재발생: finally 블록에서 예외 throw
- 목적: Session 미종료로 인한 리소스 누수 방지
9. Amadeus vs Sabre 발권 비교
| 항목 | Amadeus | Sabre |
|---|---|---|
| Ready API | 실제 사용 | @Deprecated (API 분리 예정) |
| Repricing 조건 | 명시적 조건 없음 | PriceQuote 생성일 != 오늘 |
| 결제 분기 | KE vs 기타 (GPS) | TossPay vs KeyInCard |
| 항공사별 특별 처리 | CZ (성인 티켓 저장) MU (Endorsement) | PR (Endorsement) MU (DOB Endorsement) |
| Endorsement | MU만 특별 처리 | PR, MU 각각 다른 처리 |
| MU 비성인 처리 | issueWithEndorsements | dobTickets (DOB{ddMMMyy}) |
| PR 항공사 | 없음 | ”RFND ONLY TO ISSUE AGT” |
| Commission | removeElements 후 설정 | convertCommission 우선순위 |
| 티켓 그룹핑 | 없음 (개별 발권) | chunked(5) 그룹핑 |
| 비동기 취소 | 5초 대기 | 5초 대기 (동일) |
| Session 관리 | Start → InSeries → End | getSessionToken → closeSessionToken |
| closeSessionToken 실패 | 로그만 | Slack 알림 + 예외 발생 |
10. 주요 데이터 구조
10.1 AirTicketRQ 구조
@JacksonXmlRootElement(localName = "AirTicketRQ", namespace = "http://services.sabre.com/sp/air/ticket/v1_3")
data class AirTicketRQ(
@JacksonXmlProperty(isAttribute = true, localName = "version")
val version: String = "1.3.0",
@JacksonXmlProperty(localName = "DesignatePrinter")
val designatePrinter: DesignatePrinter,
@JacksonXmlProperty(localName = "Itinerary")
val itinerary: Itinerary,
@JacksonXmlProperty(localName = "Ticketing")
@JacksonXmlElementWrapper(useWrapping = false)
val ticketings: List<Ticketing>,
@JacksonXmlProperty(localName = "PostProcessing")
val postProcessing: PostProcessing = PostProcessing(),
) : SabreRequest위치: AirTicketRQ.kt:18-34
10.2 Ticketing 구조
data class Ticketing(
val flightQualifier: FlightQualifier, // validatingCarrier
val fopQualifier: FopQualifier, // 결제 정보
val pricingQualifier: PricingQualifier, // 승객 정보
val miscQualifier: MiscQualifier, // Commission, Endorsement, NetRemit
)10.3 MiscQualifier 상세
data class MiscQualifier(
val commission: Commission?, // 커미션 (needAdditional=true일 때만)
val discount: Discount, // 승인번호 (approvalNumber)
val endorsement: Endorsement?, // PR: "RFND ONLY...", MU: "DOB..."
val invoice: Invoice? = Invoice(), // ETR 영수증
val netRemit: NetRemit? = null, // isNetRemit일 때 cardPrice
val ticket: Ticket? = Ticket(), // Type: ETR (기본)
val tourCode: TourCode? = null, // commission의 tourCode
)위치: MiscQualifier.kt:7-28
11. 발권 상태 검증 (validateBookingConditionForTicketing)
11.1 검증 항목
fun Booking.validateBookingConditionForTicketing() {
// 1. 티켓 존재 확인
if (passengers.any { it.eTicket != null }) {
throw StatusInvalidException(ErrorMessage.TICKETING_FAILED, pnr, "ticket exists")
}
// 2. 스케줄 상태 확인
schedules.forEach { schedule ->
if (!schedule.confirmed && !schedule.confirming) {
throw StatusInvalidException(
ErrorMessage.NOT_OK_SCHEDULE,
pnr,
"schedule status: ${schedule.status}"
)
}
}
}참고: Phase 2 문서 참조 (Booking 검증 로직)
12. 요약 및 핵심 포인트
12.1 발권 프로세스 핵심
- Ready (Deprecated): PriceQuote 생성일 기반 Repricing
- Issue: Session Token → 결제 → 발권 → 티켓 조회
- 결제 분기: TossPay vs KeyInCard (pgCardCode 조회)
- 항공사별 처리:
- PR: “RFND ONLY TO ISSUE AGT” Endorsement
- MU: Child/Infant DOB Endorsement (
DOB{ddMMMyy})
- Commission: Over > PQ > 기본 (0.0% GROSS)
- 티켓 그룹핑: 타입+가격별 그룹 → 5명씩 청킹
- 에러 처리:
- Uncommitted Ticket 검증
- INCORRECT CARD NUMBER, VERIFY COMMISSION
- 부분 발권 실패 감지
- 비동기 취소: 5초 대기 → Void + 결제취소 + PNR취소
- Session 관리: try-finally 패턴, closeSessionToken 실패 시 Slack 알림
12.2 코드 참조 요약
| 기능 | 파일 | 라인 |
|---|---|---|
| Ready (Deprecated) | SabreTicketingService.kt | 26-50 |
| Issue | SabreTicketingService.kt | 52-134 |
| 결제 분기 | SabrePaymentService.kt | 23-49 |
| PR Endorsement | SabreTicketingService.kt | 83 |
| MU DOB Tickets | AirTicketRQ.kt | 114-152 |
| Individual Tickets | AirTicketRQ.kt | 78-112 |
| Grouped Tickets | AirTicketRQ.kt | 154-193 |
| Convert Commission | AirTicketRQ.kt | 195-224 |
| FareBasis 변경 검증 | SabreTicketingService.kt | 199-216 |
| Retrieve Ticket | SabreTicketingService.kt | 166-197 |
| Cancel Async | SabreTicketingService.kt | 136-164 |
| Ticketing API | SabreClient.kt | 493-565 |
| MiscQualifier | MiscQualifier.kt | 30-44 |
| Endorsement | MiscQualifier.kt | 67-79 |
문서 버전: 1.0 작성일: 2025-09-30 분석 대상: Sabre GDS Ticketing API (SOAP) 참조 프로젝트: air-intl-adapter