Phase 3: Galileo 발권 API 심층 분석

1. API 엔드포인트 개요

1.1 발권 관련 엔드포인트

엔드포인트메서드API 타입기능위치
/internals/GALILEO/ticketing/readyPOSTSOAP발권 준비 (Repricing)GalileoTicketingController.kt:18-23
/internals/GALILEO/ticketingPOSTSOAP + KPS실제 발권 실행GalileoTicketingController.kt:25-46

2. 발권 준비 (Ready)

2.1 전체 발권 준비 플로우

flowchart TD
    A[발권 준비 요청] --> B[PNR 조회<br/>getBooking]
    B --> C[발권 가능 상태 검증<br/>validateBookingConditionForTicketing]

    C --> D{모든 PricingInfo<br/>Guaranteed?}
    D -->|Yes| E[승객 정보 null 처리<br/>withPassengers]
    D -->|No| F[공항 정보 수집<br/>airportMap]

    F --> G[기존 PricingInfo<br/>삭제]
    G --> H[예약 재조회<br/>API version 갱신]
    H --> I[Pricing 재실행<br/>galileoClient.pricing]
    I --> J[PriceInfo 추가<br/>addPriceInfo]
    J --> K[예약 재조회]
    K --> L[FareBasis 변경 검증<br/>validateFareBasisChange]

    E --> M[Ready 완료]
    L --> M

     에러 플로우
    L --> ERR{예외 발생}
    ERR --> V[비동기 취소<br/>cancelAsync]
    V --> W[5초 대기]
    W --> X[Void 실행<br/>voidRepeat]
    X --> Y[결제 취소<br/>paymentCancelAsync]
    Y --> Z{keepPnr?}
    Z -->|No| AA[PNR 취소<br/>pnrCancelAsync]
    Z -->|Yes| AB[PNR 유지]
    AA --> AC[예외 재발생]
    AB --> AC

    style C fill:#B3D9FF,stroke:#333,stroke-width:2px,color:#000
    style D fill:#FFE5B4,stroke:#333,stroke-width:2px,color:#000
    style G fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style M fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style P fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style U fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
    style ERR fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style V fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000

3.2 단계별 상세 분석

Step 1: 예약 조회 및 검증

위치: GalileoTicketingService.kt:91-107

fun issue(
    pnr: String,
    validatingCarrier: String,
    passengerPrices: List<PassengerPrice>,
    cardInfo: PaymentInfo.KeyInCard,
    keepPnr: Boolean,
): Booking {
    val payment = try {
        galileoClient.getBooking(providerPnr = pnr, findTimeZoneId = findTimeZoneId).also {
            it.validateBookingConditionForTicketing()
        }
    } catch (e: Exception) {
        if (keepPnr.not()) {
            cancelService.pnrCancelAsync(pnr)
        }
        throw e
    }.run { ... }
 
    // ...
}

keepPnr 파라미터:

  • 목적: 발권 실패 시 PNR 유지 여부 결정
  • true: 발권 재시도 가능 (PNR 유지)
  • false: PNR 취소 (기본값)

Step 2: KPS 결제 승인

위치: GalileoTicketingService.kt:108-117

kpsClient.approve(
    cardInfo = cardInfo,
    pnr = pnr,
    validatingCarrier = validatingCarrier,
    reservationCode = this.supplierIdentificationKey!!,
    amount = passengerPrices.sumOf { it.cardPrice }.toString()
).also {
    galileoClient.addPaymentInfoRemark(booking = this, payment = it)
}

KPS (Korean Payment Service):

  • API: KpsBspCardAuthRQ
  • 파라미터:
    • cardInfo: 카드 정보 (카드번호, 유효기간, CVC 등)
    • pnr: Provider PNR
    • validatingCarrier: 발권 항공사
    • reservationCode: Universal Record PNR
    • amount: 총 결제 금액 (cardPrice 합산)
  • 반환: Payment 객체 (승인번호, 거래일시 등)

Payment Remark 추가:

  • API: UniversalRecordModifyRQ
  • 목적: PNR에 결제 정보 저장
  • 내용: 승인번호, 결제 금액, 카드 정보 등

Step 3: Commission 처리

위치: GalileoTicketingService.kt:121-136

// remark 추가 후 api version 갱신을 위해 retrieve
val booking = galileoClient.getBooking(providerPnr = pnr)
booking.bookingReference?.pricingInfoReferences
    ?.mapNotNull { pricingInfoReference ->
        val passenger = booking.passengers!!.find { it.type == pricingInfoReference.type }
        val passengerPrice = passengerPrices.find {
            it.type == passenger?.type && it.identificationKey == passenger.identificationKey
        }
        convertCommission(
            pricingCommission = passenger?.commission,
            overCommission = passengerPrice?.commission
        )?.let {
            pricingInfoReference.type to it
        }
    }?.run {
        galileoClient.saveCommission(booking = booking, commissions = this)
    }

Commission 로직:

private fun convertCommission(
    pricingCommission: Commission?,
    overCommission: Commission?,
): Commission? {
    return when {
        pricingCommission != null -> overCommission?.takeIf { pricingCommission.value < it.value }
        overCommission != null -> overCommission
        else -> Commission(
            type = CommissionType.GROSS,
            value = 0.0,
            tourCode = null
        )
    }
}

Commission 우선순위:

  1. pricingCommission < overCommission: overCommission 사용
  2. pricingCommission == null: overCommission 사용
  3. overCommission == null: 0.0 (GROSS) 사용
  4. pricingCommission >= overCommission: null (변경 없음)

Step 4: 승객별 발권 처리

위치: GalileoTicketingService.kt:138-158

passengerPrices.groupBy { "${it.type}${it.cashPrice}" }
    .forEach { (_, groupedPassengerPrices) ->
        groupedPassengerPrices.chunked(4).forEach { chunkedPassengerPrice ->
            val pricingReference =
                booking.bookingReference?.pricingInfoReferences?.first { it.type == chunkedPassengerPrice.first().type }
            galileoClient.ticketing(
                reservationPnr = booking.subPnr!!,
                passengerPrices = chunkedPassengerPrice,
                cardInfo = cardInfo,
                payment = payment,
                pricingReference = pricingReference!!,
                timeoutCallback = {
                    slackService.sendTicketingTimeout(
                        supplier = Supplier.GALILEO,
                        pnr = pnr,
                        subPnr = booking.subPnr
                    )
                }
            )
        }
    }

발권 전략:

  1. 그룹화: 승객 타입 + 현금 가격으로 그룹화

    • 이유: 동일 운임을 한 번에 처리
    • 예: “ADULT10000”, “CHILD8000”
  2. Chunked 처리: 4명씩 분할

    • 이유: Galileo API 제한 (한 번에 최대 4명 발권)
    • 예: [P1, P2, P3, P4], [P5, P6]
  3. 타임아웃 콜백: Slack 알림

    • 발권 타임아웃 시 자동 알림
    • 운영팀 즉시 대응 가능

SOAP API: GalileoClient.kt:368-413

  • 엔드포인트: /AirService (AirTicketingRQ)
  • 파라미터:
    • reservationPnr: Sub PNR (Reservation PNR)
    • passengerPrices: 승객별 가격 정보
    • cardInfo: 카드 정보
    • payment: 결제 승인 정보
    • pricingReference: Pricing 참조 (발권 대상)

Step 5: 티켓 조회 및 검증

위치: GalileoTicketingService.kt:160-180

val issuedBooking = galileoClient.getBooking(providerPnr = pnr, findTimeZoneId = findTimeZoneId)
    .also {
        if (it.tickets.isNullOrEmpty()) throw InternationalAdapterException(
            ErrorMessage.TICKETING_FAILED,
            pnr
        ).capture()
    }
val passengerTicketMap = galileoClient.getTicketDocuments(
    providerPnr = booking.pnr,
    universalRecordPnr = booking.supplierIdentificationKey!!,
    reservationPnr = booking.subPnr!!,
    passengerPrices = passengerPrices
).groupBy { it.identificationKey }
 
return issuedBooking
    .withPassengers(
        passengers = issuedBooking.passengers!!.map {
            it.withPassengerTickets(ticketDocuments = passengerTicketMap[it.identificationKey])
        }
    )
    .withPayment(payment = payment)

티켓 조회 플로우:

  1. 발권 완료 예약 조회: getBooking
  2. 티켓 존재 검증: tickets.isNullOrEmpty() 체크
  3. 티켓 문서 조회: getTicketDocuments (AirRetrieveDocumentRQ)
  4. 승객별 매핑: identificationKey로 그룹화
  5. 최종 결합: 예약 + 티켓 + 결제 정보

4. 에러 처리 및 복구

4.1 발권 실패 시 비동기 취소

위치: GalileoTicketingService.kt:181-190

try {
    // 발권 로직
} catch (e: Exception) {
    cancelAsync(
        pnr = pnr,
        validatingCarrier = validatingCarrier,
        payment = payment,
        keepPnr = keepPnr
    )
    throw e
}

4.2 취소 로직 상세

위치: GalileoTicketingService.kt:231-260

private fun cancelAsync(
    pnr: String,
    validatingCarrier: String,
    payment: Payment,
    keepPnr: Boolean,
) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        delay(5000)
        val booking = galileoClient.getBooking(providerPnr = pnr)
 
        booking.tickets?.run {
            cancelService.voidRepeat(
                providerPnr = pnr,
                reservationPnr = booking.subPnr!!,
                ticketNumbers = this.map { it.ticketNumber }.distinct()
            )
        }
 
        cancelService.paymentCancelAsync(
            pnr = pnr,
            validatingCarrier = validatingCarrier,
            reservationCode = booking.supplierIdentificationKey!!,
            payment = payment
        )
 
        if (keepPnr.not()) {
            cancelService.pnrCancelAsync(pnr)
        }
    }
}

취소 순서:

  1. 5초 대기: GDS 시스템 안정화
  2. 예약 조회: 최신 상태 확인
  3. 티켓 Void: 발권된 티켓 취소 (voidRepeat)
  4. 결제 취소: KPS 결제 취소 (paymentCancelAsync)
  5. PNR 취소: keepPnr=false인 경우만 (pnrCancelAsync)

4.3 Void 재시도 로직

위치: GalileoCancelService.kt:166-201

fun voidRepeat(
    providerPnr: String,
    reservationPnr: String,
    ticketNumbers: List<String>,
) {
    val voidedTicketNumbers = mutableSetOf<String>()
    for (count in 1..3) {
        val aliveTicketNumbers = ticketNumbers - voidedTicketNumbers
        try {
            withBlocking {
                delay(1000)
                galileoClient.void(
                    providerPnr = providerPnr,
                    reservationPnr = reservationPnr,
                    ticketNumbers = aliveTicketNumbers
                ).apply {
                    voidedTicketNumbers.addAll(this)
                }
            }
            break
 
        } catch (e: Exception) {
            logger.info(e.message, e)
            if (count >= 3) {
                slackService.sendVoidFail(
                    supplier = Supplier.GALILEO,
                    pnr = providerPnr,
                    targetTickets = ticketNumbers,
                    failTickets = aliveTicketNumbers,
                    reason = e.message
                )
                throw e
            }
        }
    }
}

재시도 전략:

  • 최대 3회 시도
  • 1초 대기 (각 시도 간)
  • 부분 성공 허용 (Void된 티켓 제외하고 재시도)
  • 실패 시: Slack 알림 + 예외 발생

5. 항공사별 특별 처리

5.1 Commission 처리

위치: GalileoTicketingService.kt:192-208

Commission 타입:

enum class CommissionType {
    GROSS,  // 총액 기준
    NET     // 순액 기준
}

Commission 계산 로직:

  • Pricing Commission: Pricing API에서 반환된 Commission
  • Over Commission: 요청에서 지정한 추가 Commission
  • 선택 기준: 둘 중 큰 값 사용

활용 예:

  • 프로모션 커미션 적용
  • 특별 할인 처리
  • 대리점 수수료 조정

5.2 KPS Payment 통합

위치: GalileoTicketingService.kt:108-117

KPS 연동 특징:

  • BSP 카드 결제: 항공사 공인 결제 시스템
  • Real-time 승인: 즉시 승인/거절
  • 취소 지원: 결제 취소 API 제공
  • 다국적 카드: 해외 발급 카드 지원

에러 처리:

kpsClient.approve(...).also {
    galileoClient.addPaymentInfoRemark(booking = this, payment = it)
}
  • KPS 승인 후 즉시 PNR에 Remark 추가
  • 승인 성공 증적 보존
  • 취소 시 승인번호 활용

6. 성능 최적화

6.1 승객 그룹화 전략

위치: GalileoTicketingService.kt:138-158

그룹화 키: "${type}${cashPrice}"

  • 목적: 동일 운임 승객 묶음 처리
  • 효과: API 호출 횟수 감소

예시:

승객 목록:
- P1: ADULT, 10000원
- P2: ADULT, 10000원
- P3: ADULT, 10000원
- P4: CHILD, 8000원
- P5: ADULT, 10000원

그룹화 결과:
- "ADULT10000": [P1, P2, P3, P5] → chunk → [[P1,P2,P3,P5]]
- "CHILD8000": [P4] → chunk → [[P4]]

API 호출: 2회 (5명을 2번에 발권)

6.2 Chunked 처리

이유: Galileo API 제한 (한 번에 최대 4명)

장점:

  • 큰 그룹 자동 분할
  • 에러 격리 (일부 실패 시 전체 롤백 방지)
  • 병렬 처리 가능성

6.3 비동기 작업

비동기 처리 대상:

  • Payment Remark 추가 (로깅 성격)
  • 발권 실패 취소 (5초 지연)
  • PNR 취소 (최종 정리)

성능 향상:

  • API 응답 시간 단축
  • 사용자 대기 시간 감소

7. Amadeus/Sabre와의 비교

7.1 발권 플로우 비교

항목GalileoAmadeusSabre
Ready API있음 (Repricing)없음있음 (Repricing)
Session 관리StatelessStatefulStateful
결제 시스템KPSKPSSabre Payment (TossPay/KeyInCard)
발권 APIAirTicketingRQDocIssuance_IssueTicketAirTicketRQ
Chunked 처리4명없음 (9명)없음
CommissionAPI 수준 지원N/AAPI 수준 지원
Payment Remark추가 필요추가 필요추가 필요
티켓 조회AirRetrieveDocumentRQTicket_DisplayTSTTravelItineraryReadRQ

7.2 에러 처리 비교

에러 시나리오GalileoAmadeusSabre
발권 실패Void + Payment 취소 + PNR 취소Payment 취소 + PNR 취소Void + Payment 취소 + PNR 취소
타임아웃Slack 알림 + 재시도즉시 실패Slack 알림
부분 발권Void 재시도 (3회)N/AVoid 재시도
비동기 취소5초 지연즉시즉시

7.3 독특한 특징

Galileo 고유 특징

  1. 4명 Chunked 발권:

    • API 제한으로 인한 자동 분할
    • 대규모 그룹 예약 지원
  2. Commission API:

    • Pricing 수준의 Commission 설정
    • 프로모션/대리점 수수료 유연 처리
  3. Payment Remark:

    • 결제 정보를 PNR에 명시적 저장
    • 운영 추적성 향상
  4. Void 부분 성공:

    • Void 실패 시 성공한 티켓 제외하고 재시도
    • 안정성 향상
  5. Element 기반 수정:

    • PricingInfo를 Element로 관리
    • 삭제/추가 시 버전 동기화 필요

8. 주요 발견사항

8.1 Galileo 발권 시스템의 특징

  1. KPS 통합: 한국 항공사 표준 결제 시스템
  2. Element 기반 관리: PricingInfo, Remark 등 독립 관리
  3. API Version 동기화: 수정 후 재조회 필수
  4. Chunked 발권: 4명 제한으로 자동 분할
  5. Commission 지원: API 수준의 수수료 설정

8.2 개선 가능 영역

1. Chunked 크기 설정화

현황: 하드코딩된 4명

groupedPassengerPrices.chunked(4)

제안: 설정 파일로 관리

galileo:
  ticketing:
    chunk-size: 4
    max-concurrent-chunks: 2

2. Commission 로깅

현황: Commission 변경 로그 없음

제안:

convertCommission(...)?.let {
    logger.info("[COMMISSION_OVERRIDE] PNR: $pnr, " +
                "PassengerType: ${pricingInfoReference.type}, " +
                "Original: ${passenger?.commission}, " +
                "Override: $it")
    pricingInfoReference.type to it
}

3. 타임아웃 재시도 전략

현황: 타임아웃 시 즉시 실패

제안: 자동 재시도 (1회)

timeoutCallback = {
    if (retryCount < 1) {
        logger.warn("Ticketing timeout, retrying...")
        retryTicketing(...)
    } else {
        slackService.sendTicketingTimeout(...)
    }
}

9. 참고 자료

9.1 주요 클래스

  • GalileoTicketingService.kt: 발권 서비스 (26-261)
  • GalileoClient.kt: SOAP 클라이언트 (368-413)
  • KpsPaymentClient.kt: KPS 결제 클라이언트
  • GalileoCancelService.kt: 취소 서비스 (166-201)
  • AirTicketingRQ.kt: 발권 요청 모델
  • AirTicketingRS.kt: 발권 응답 모델

9.2 주요 메소드

  • GalileoTicketingService.ready(): 발권 준비 (45-89)
  • GalileoTicketingService.issue(): 발권 실행 (91-190)
  • GalileoTicketingService.validateFareBasisChange(): FareBasis 검증 (210-229)
  • GalileoTicketingService.convertCommission(): Commission 변환 (192-208)
  • GalileoTicketingService.cancelAsync(): 비동기 취소 (231-260)
  • GalileoClient.ticketing(): SOAP 발권 API (368-413)
  • GalileoClient.saveCommission(): Commission 저장 (763-780)

9.3 관련 API

  • AirPriceRQ: 운임 재계산 (Ready 단계)
  • UniversalRecordModifyRQ: PricingInfo 삭제/추가, Payment Remark, Commission
  • AirTicketingRQ: 발권 실행
  • AirRetrieveDocumentRQ: 티켓 문서 조회
  • AirVoidDocumentRQ: 티켓 취소 (Void)
  • KpsBspCardAuthRQ: KPS 결제 승인
  • KpsBspCardAuthXRQ: KPS 결제 취소

이 문서는 Triple Air International Adapter 프로젝트의 Galileo 발권 API 심층 분석 문서입니다.