Sabre 큐 관리(Queue) API 심층 분석

1. API 개요

1.1 주요 기능

  • 큐 PNR 조회: 특정 큐에 있는 PNR 목록 조회
  • 큐 개수 조회: 큐별 PNR 개수 조회
  • 큐에서 제거: 처리 완료된 PNR을 큐에서 순차 제거
  • 병렬 처리: 여러 큐를 동시에 조회하여 성능 향상
  • 순차 제거: 큐 내 PNR을 하나씩 순회하며 조건부 제거

1.2 큐 시스템 개요

  • Queue: Sabre GDS에서 PNR을 관리하는 대기열 시스템
  • PCC (Pseudo City Code): 각 대리점/사무소를 식별하는 코드
  • Queue Access Action: 큐 작업 모드에서 PNR을 처리하는 액션 타입
  • Sequential Processing: 큐 내 PNR을 순차적으로 처리

1.3 관련 파일 구조

supplier/sabre/
├── application/
│   └── SabreQueueService.kt             # 큐 관리 서비스
├── infrastructure/soap/
│   ├── SabreClient.kt                   # SOAP API 클라이언트
│   ├── request/
│   │   ├── queuecount/
│   │   │   └── QueueCountRQ.kt          # 큐 카운트 요청
│   │   └── queueaccess/
│   │       └── QueueAccessRQ.kt         # 큐 접근 요청
│   └── response/
│       ├── queuecount/
│       │   └── QueueCountRS.kt          # 큐 카운트 응답
│       └── queueaccess/
│           └── QueueAccessRS.kt         # 큐 접근 응답
├── interfaces/controller/internals/
│   └── SabreQueueController.kt          # 큐 API 엔드포인트
└── support/
    ├── model/
    │   └── QueuePnrInfo.kt              # 큐 PNR 정보
    └── enums/
        └── QueueAccessActionType.kt     # 큐 액션 타입

1.4 Amadeus vs Sabre 큐 시스템 비교

항목AmadeusSabre
식별자Offline OIDPCC (Pseudo City Code)
대상 큐Queue 1, 7Queue 5, 6, 7, 20
카테고리 필터링Queue 1만 (1, 6)없음 (전체)
조회 방식한 번에 전체 조회 (최대 250개)순차 조회 (1개씩)
제거 방식일괄 제거 (PNR 목록)순차 제거 (액션 기반)
병렬 처리pmap (큐별)pmap (큐별)
세션 관리Offline OID별 단일 세션PCC별 개별 세션
재시도3회 (removePnrsInQueue만)3회 (remove만)
Legacy 제외없음LEGACY_PCC 제외 (3OGJ, 7CZJ)

2. 핵심 비즈니스 로직

2.1 큐 조회 프로세스

flowchart TD
    A[큐 조회 요청] --> B[모든 PCC 조회]
    B --> C{LEGACY_PCC?}
    C -->|Yes| D[제외]
    C -->|No| E[PCC별 세션 생성]

    E --> F[병렬 큐 처리]
    F --> G[Queue 5 조회]
    F --> H[Queue 6 조회]
    F --> I[Queue 7 조회]
    F --> J[Queue 20 조회]

    G --> K[큐 카운트 조회]
    H --> K
    I --> K
    J --> K

    K --> L{카운트 > 0?}
    L -->|No| M[emptyList]
    L -->|Yes| N[큐 PNR 목록 조회]

    N --> O[QueuePnrInfo 생성]
    O --> P[전체 결과 병합]

    style C fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style L fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000

2.2 큐 PNR 조회 (getQueuePnrs)

2.2.1 메인 로직

// SabreQueueService.kt:32-83
fun getQueuePnrs(): List<QueuePnrInfo> {
    // 1. 모든 PCC 조회 및 필터링
    return sabreProperties.channels.flatMap { channel ->
        channel.funnels.flatMap { listOf(it.pcc.online, it.pcc.offline) }
            .filter { it !in LEGACY_PCC }  // Legacy PCC 제외
    }.distinct()
        .flatMap { pcc ->
            // 2. PCC별 API 속성 및 세션 토큰 조회
            val (channel, sabreProperties) = sabreProperties.getApiProperties(pcc = pcc)
            val token = sabreClient.getSessionToken(
                channel = channel,
                funnel = sabreProperties.funnel,
                targetDate = sabreProperties.period?.from ?: now()
            )
 
            try {
                withBlocking(Dispatchers.IO) {
                    // 3. 대상 큐 병렬 처리
                    TARGET_ORIGIN_QUEUE_NUMBERS.pmap { originQueueNumber ->
                        // 4. 큐 카운트 조회
                        val queueCount = sabreClient.getPnrCountInQueue(
                            token = token,
                            queueNumber = originQueueNumber,
                            pcc = pcc,
                        )
                        logger.info("[${Supplier.SABRE}] Get - Queue Number: $originQueueNumber, Counts: $queueCount, PCC: $pcc")
 
                        // 5. 카운트가 0보다 크면 PNR 목록 조회
                        if (queueCount > 0) {
                            sabreClient.getPnrsInQueueAction(
                                token = token,
                                queueNumber = originQueueNumber,
                                listOfRecordLocator = true,  // PNR 목록 조회 모드
                                pcc = pcc,
                            ).also {
                                logger.info("[${Supplier.SABRE}] Get - Queue Number: $originQueueNumber, PNRs: $it, OID: $pcc")
                            }.map { pnr ->
                                QueuePnrInfo(pnr = pnr, queueNumber = originQueueNumber, pccOid = pcc)
                            }
                        } else {
                            emptyList()
                        }
                    }.getOrEmpty()
                }
            } catch (e: Exception) {
                logger.error("[${Supplier.SABRE}][$pcc] Queue Error", e)
                slackService.sendQueueFail(supplier = "${Supplier.SABRE.name}($pcc)", reason = e.message)
                emptyList()
            } finally {
                // 6. 세션 종료
                sabreClient.closeSessionToken(
                    token = token,
                    channel = channel,
                    funnel = sabreProperties.funnel,
                    targetDate = sabreProperties.period?.from ?: now()
                )
            }.flatten()
        }
}

2.2.2 대상 큐 번호 및 Legacy PCC

// SabreQueueService.kt:27-30
companion object {
    val TARGET_ORIGIN_QUEUE_NUMBERS = listOf("5", "6", "7", "20")
    val LEGACY_PCC = listOf("3OGJ", "7CZJ")
}
  • Queue 5: 일반 처리 큐 1
  • Queue 6: 일반 처리 큐 2
  • Queue 7: 특별 처리 큐
  • Queue 20: 긴급 처리 큐
  • Legacy PCC: 이전 시스템 PCC (제외 대상)

2.3 큐 제거 프로세스 (remove)

2.3.1 순차 제거 로직

// SabreQueueService.kt:85-138
@Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000))
fun remove(
    queueNumber: String,
    pcc: String,
    pnrs: Set<String>,
): Boolean {
    // 1. PCC별 세션 생성
    val (channel, sabreProperties) = sabreProperties.getApiProperties(pcc = pcc)
    val token = sabreClient.getSessionToken(
        channel = channel,
        funnel = sabreProperties.funnel,
        targetDate = sabreProperties.period?.from ?: now()
    )
 
    val removePnrs = mutableListOf<String>()
    return try {
        // 2. 큐 카운트 조회
        val queueCount = sabreClient.getPnrCountInQueue(
            token = token,
            queueNumber = queueNumber,
            pcc = pcc,
        )
 
        // 3. 큐 작업 모드 최초 진입 - 첫 번째 PNR 조회
        var currentPnr = sabreClient.getPnrsInQueueAction(
            token = token,
            pcc = pcc,
            queueNumber = queueNumber,
        ).firstOrNull()
 
        // 4. 큐 내 모든 PNR 순회
        repeat(queueCount) {
            // 5. 제거 대상 여부 확인 후 액션 실행
            val nextPnr = sabreClient.getPnrsInQueueAction(
                token = token,
                pcc = pcc,
                actionType = if (currentPnr in pnrs) QueueAccessActionType.QR else QueueAccessActionType.I
                // QR: 큐에서 제거, I: 건너뛰기
            ).firstOrNull()
 
            if (currentPnr in pnrs) removePnrs.add(currentPnr!!)
            currentPnr = nextPnr
        }
 
        logger.info("[${Supplier.SABRE}] Remove - Queue Number: $queueNumber, Counts: ${pnrs.count()}, PNRs:$removePnrs, PCC: $pcc")
        true
    } catch (e: Exception) {
        logger.error("[${Supplier.SABRE}] Remove Failed - Queue Number: $queueNumber, Target PNRs: $pnrs, Remove PNRs: $removePnrs, PCC: $pcc")
        slackService.sendQueueFail(supplier = "${Supplier.SABRE.name}($pcc)", reason = e.message)
        false
    } finally {
        // 6. 세션 종료
        sabreClient.closeSessionToken(
            token = token,
            channel = channel,
            funnel = sabreProperties.funnel,
            targetDate = sabreProperties.period?.from ?: now()
        )
    }
}

2.3.2 제거 프로세스 플로우

flowchart TD
    A[제거 요청] --> B[세션 생성]
    B --> C[큐 카운트 조회]
    C --> D[첫 PNR 조회]
    D --> E{repeat queueCount}

    E --> F[현재 PNR 확인]
    F --> G{제거 대상?}

    G -->|Yes| H[QR 액션<br/>큐에서 제거]
    G -->|No| I[I 액션<br/>건너뛰기]

    H --> J[다음 PNR 조회]
    I --> J

    J --> K[removePnrs 기록]
    K --> L{다음 PNR?}

    L -->|Yes| F
    L -->|No| M[완료]

    M --> N[로깅 및 true 반환]

    style G fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style L fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000

3. QueueAccessActionType

3.1 액션 타입 정의

// QueueAccessActionType.kt:3-15
enum class QueueAccessActionType {
    QR,  // 조회 중인 PNR을 Q에서 제거하고 다음 PNR 조회
    I,   // PNR 변경 작업 취소 후 Q에 남겨두고 다음 PNR 조회
    E,   // PNR 변경 작업 저장 후 Q에서 제거하고 다음 PNR 조회
    EL,  // PNR 변경 작업 저장 후 Q에 남겨두고 다음 PNR 조회
    EW,  // PNR 코드 업데이트 후 Q에서 제거하고 다음 PNR 조회
    EWL, // PNR 코드 업데이트 후 Q에 남겨두고 다음 PNR 조회
    EWR, // PNR 코드 업데이트 후 해당 PNR 재조회
    QXI, // 작업 종료 및 가장 마지막 PNR 변경 작업 취소
    QXE, // 작업 종료 및 가장 마지막 PNR 변경 작업 저장
    QXIR,// 작업 종료 및 가장 마지막 PNR 변경 작업 취소 후 재조회
    QXER,// 작업 종료 및 가장 마지막 PNR 변경 작업 저장 후 재조회
}

3.2 주요 액션 사용

flowchart LR
    A[PNR 확인] --> B{처리 대상?}
    B -->|Yes| C[QR 액션]
    B -->|No| D[I 액션]

    C --> E[큐에서 제거]
    D --> F[큐에 유지]

    E --> G[다음 PNR]
    F --> G

    style B fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style C fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style D fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000

4. API 클라이언트 구현

4.1 큐 카운트 조회

// SabreClient.kt:784-808
fun getPnrCountInQueue(
    token: String,
    queueNumber: String,
    pcc: String,
): Int {
    val (_, sabreApiProperties) = sabreProperties.getApiProperties(pcc = pcc)
    val request = QueueCountRQ.of(
        queueNumber = queueNumber,
        pseudoCityCode = pcc
    ).withToken(token)
 
    return sabreApiProperties.endpoint
        .post(request)
        .header(headerMap)
        .requestBodyConvert(soapRequestBodyConverter(sabreApiProperties))
        .execute<SabreResponse<QueueCountRS>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.body!!.checkError()
                response.body.queueInfo.queueIdentifiers?.firstOrNull()?.count?.toInt() ?: 0
            },
            failure = {
                throw it.handleSoapFaultException(ErrorMessage.QUEUE_COUNT_FAILED)
            }
        )
}

4.2 큐 PNR 목록 조회 및 액션 실행

// SabreClient.kt:810-843
fun getPnrsInQueueAction(
    token: String,
    pcc: String,
    queueNumber: String? = null,
    actionType: QueueAccessActionType? = null,
    listOfRecordLocator: Boolean = false,
): List<String> {
    val (_, sabreApiProperties) = sabreProperties.getApiProperties(pcc = pcc)
 
    // 1. 초기 진입 또는 액션 실행 분기
    val request = if (actionType == null) {
        // 초기 진입: 큐 접근 및 PNR 목록 조회
        QueueAccessRQ.of(
            pseudoCityCode = pcc,
            queueNumber = queueNumber!!,
            listOfRecordLocator = listOfRecordLocator,
        )
    } else {
        // 액션 실행: 현재 PNR 처리 후 다음 PNR 조회
        QueueAccessRQ.ofAction(
            actionType = actionType,
        )
    }.withToken(token)
 
    return sabreApiProperties.endpoint
        .post(request)
        .header(headerMap)
        .requestBodyConvert(soapRequestBodyConverter(sabreApiProperties))
        .execute<SabreResponse<QueueAccessRS>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.body!!.checkError()
                response.body.lines?.mapNotNull { it.uniqueID?.id } ?: emptyList()
            },
            failure = {
                throw it.handleSoapFaultException(ErrorMessage.QUEUE_ACCESS_FAILED)
            }
        )
}

4.3 QueueAccessRQ 요청 구조

// QueueAccessRQ.kt:30-51
companion object {
    // 초기 큐 접근 요청
    fun of(
        pseudoCityCode: String,
        queueNumber: String,
        listOfRecordLocator: Boolean = false,
    ): QueueAccessRQ {
        return QueueAccessRQ(
            queueIdentifiers = listOf(
                QueueIdentifier(
                    list = Item(listOfRecordLocator = listOfRecordLocator),
                    pseudoCityCode = pseudoCityCode,
                    number = queueNumber
                )
            )
        )
    }
 
    // 액션 실행 요청
    fun ofAction(actionType: QueueAccessActionType): QueueAccessRQ {
        return QueueAccessRQ(
            navigation = Navigation(action = actionType.name)
        )
    }
}

5. 병렬 처리 전략

5.1 큐 병렬 조회

// SabreQueueService.kt:45-69
withBlocking(Dispatchers.IO) {
    TARGET_ORIGIN_QUEUE_NUMBERS.pmap { originQueueNumber ->  // 병렬 처리
        val queueCount = sabreClient.getPnrCountInQueue(...)
 
        if (queueCount > 0) {
            sabreClient.getPnrsInQueueAction(...)
                .map { pnr ->
                    QueuePnrInfo(pnr = pnr, queueNumber = originQueueNumber, pccOid = pcc)
                }
        } else {
            emptyList()
        }
    }.getOrEmpty()
}

5.2 병렬 처리 플로우

graph TD
    A[PCC별 조회 시작] --> B[pmap 병렬 처리]
    B --> C[Queue 5 스레드]
    B --> D[Queue 6 스레드]
    B --> E[Queue 7 스레드]
    B --> F[Queue 20 스레드]

    C --> G[Queue 5 카운트]
    D --> H[Queue 6 카운트]
    E --> I[Queue 7 카운트]
    F --> J[Queue 20 카운트]

    G --> K[Queue 5 PNR 목록]
    H --> L[Queue 6 PNR 목록]
    I --> M[Queue 7 PNR 목록]
    J --> N[Queue 20 PNR 목록]

    K --> O[결과 병합]
    L --> O
    M --> O
    N --> O

    O --> P[QueuePnrInfo 리스트]

    style B fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style O fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000

6. 제약사항 및 특이사항

6.1 Sabre 큐 시스템 제약

  • 순차 처리 필수: 큐 내 PNR을 반드시 순차적으로 처리
  • 세션 상태 유지: 큐 작업 중 세션이 끊기면 처음부터 재시작
  • PCC별 세션: 각 PCC마다 별도 세션 필요
  • 액션 기반 제거: 일괄 제거 불가, 하나씩 액션 실행

6.2 Legacy PCC 제외

// SabreQueueService.kt:29-34
.flatMap { channel.funnels.flatMap { listOf(it.pcc.online, it.pcc.offline) }
    .filter { it !in LEGACY_PCC }  // 3OGJ, 7CZJ 제외
}
  • 이전 시스템 PCC는 조회 제외
  • 마이그레이션 완료 후 제거 예정

6.3 재시도 전략

// SabreQueueService.kt:85
@Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000))
  • 최대 시도 횟수: 3회
  • 재시도 간격: 5초
  • 적용 대상: remove만 (조회는 재시도 없음)

6.4 Amadeus vs Sabre 제거 방식 차이

graph TD
    subgraph Amadeus
        A1[PNR 목록 조회] --> A2[일괄 제거 요청]
        A2 --> A3[완료]
    end

    subgraph Sabre
        B1[첫 PNR 조회] --> B2[PNR 확인]
        B2 --> B3{제거 대상?}
        B3 -->|Yes| B4[QR 액션]
        B3 -->|No| B5[I 액션]
        B4 --> B6[다음 PNR]
        B5 --> B6
        B6 --> B7{마지막?}
        B7 -->|No| B2
        B7 -->|Yes| B8[완료]
    end

    style A2 fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
    style B3 fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000

7. 예외 처리 및 알림

7.1 큐 조회 실패

// SabreQueueService.kt:70-73
} catch (e: Exception) {
    logger.error("[${Supplier.SABRE}][$pcc] Queue Error", e)
    slackService.sendQueueFail(supplier = "${Supplier.SABRE.name}($pcc)", reason = e.message)
    emptyList()
}
  • 실패 시 빈 목록 반환 (예외 전파 안 함)
  • PCC별 Slack 알림 발송
  • 로깅으로 상세 에러 추적

7.2 큐 제거 실패

// SabreQueueService.kt:126-129
} catch (e: Exception) {
    logger.error("[${Supplier.SABRE}] Remove Failed - Queue Number: $queueNumber, Target PNRs: $pnrs, Remove PNRs: $removePnrs, PCC: $pcc")
    slackService.sendQueueFail(supplier = "${Supplier.SABRE.name}($pcc)", reason = e.message)
    false
}
  • 실패 시 false 반환
  • Target PNRs vs Remove PNRs 비교 로깅
  • PCC별 Slack 알림 발송
  • 재시도 메커니즘 (@Retryable) 적용

8. 로깅 전략

8.1 조회 로깅

// SabreQueueService.kt:52
logger.info("[${Supplier.SABRE}] Get - Queue Number: $originQueueNumber, Counts: $queueCount, PCC: $pcc")
 
// SabreQueueService.kt:61
logger.info("[${Supplier.SABRE}] Get - Queue Number: $originQueueNumber, PNRs: $it, OID: $pcc")

8.2 제거 로깅

// SabreQueueService.kt:124
logger.info("[${Supplier.SABRE}] Remove - Queue Number: $queueNumber, Counts: ${pnrs.count()}, PNRs:$removePnrs, PCC: $pcc")
 
// SabreQueueService.kt:127
logger.error("[${Supplier.SABRE}] Remove Failed - Queue Number: $queueNumber, Target PNRs: $pnrs, Remove PNRs: $removePnrs, PCC: $pcc")

8.3 로깅 정보

  • Queue Number (큐 번호)
  • PCC (Pseudo City Code)
  • Queue Count (큐 내 PNR 개수)
  • PNR List (PNR 목록)
  • Target PNRs vs Remove PNRs (제거 대상 vs 실제 제거)

9. 성능 분석

9.1 Amadeus vs Sabre 성능 비교

항목AmadeusSabre
조회 방식한 번에 전체 (최대 250개)한 번에 전체 (listOfRecordLocator)
제거 방식일괄 제거 (단일 API 호출)순차 제거 (N번 API 호출)
네트워크 라운드트립적음 (2회: Count + List)많음 (2 + N회: Count + Access + N개 액션)
병렬 처리큐별 병렬큐별 병렬
세션 관리Offline OID별 단일PCC별 개별
시간 복잡도 (조회)O(1)O(1)
시간 복잡도 (제거)O(1)O(N)

9.2 성능 이슈

  1. 순차 제거: 큐에 PNR이 많을수록 처리 시간 증가
    • 100개 PNR 제거 시 약 100번 API 호출
    • 각 API 호출당 평균 0.5초 → 총 50초 소요
  2. 세션 타임아웃: 긴 작업 시 세션 만료 가능
  3. PCC 개수: PCC가 많을수록 전체 조회 시간 증가

10. 개선 권장사항

10.1 현재 이슈

  1. 하드코딩된 큐 번호: TARGET_ORIGIN_QUEUE_NUMBERS = listOf("5", "6", "7", "20")
  2. 순차 제거 성능: O(N) 시간 복잡도
  3. Legacy PCC 하드코딩: LEGACY_PCC = listOf("3OGJ", "7CZJ")
  4. 조회 재시도 부재: getPnrsInQueueAction에 재시도 없음

10.2 개선 방안

  1. 설정 외부화: 큐 번호 및 Legacy PCC를 application.yml에서 관리
  2. 일괄 제거 API 조사: Sabre에 일괄 제거 기능이 있는지 확인
  3. 조회 재시도: Circuit Breaker 또는 @Retryable 적용
  4. 세션 연장: 긴 작업 시 세션 타임아웃 방지
  5. 캐싱 전략: 큐 조회 결과 단기 캐싱 (1분 TTL)
  6. 배치 처리: 제거 대상이 많을 경우 배치로 분할

10.3 Sabre 큐 제거 최적화 아이디어

// 가능하다면 QueueMoveRQ API를 사용한 일괄 제거
fun removeBulk(queueNumber: String, pcc: String, pnrs: Set<String>): Boolean {
    // QueueMove API로 다른 큐로 이동 후 해당 큐 전체 삭제
    // 또는 Sabre API 문서에서 일괄 제거 방법 조사
}

11. 참고사항

11.1 주요 에러 코드

  • QUEUE_COUNT_FAILED: 큐 카운트 조회 실패
  • QUEUE_ACCESS_FAILED: 큐 접근 실패

11.2 큐 시스템 용어

  • Queue Number: 큐 번호 (5, 6, 7, 20)
  • PCC (Pseudo City Code): 대리점/사무소 식별 코드
  • Queue Access Action: 큐 작업 모드 액션
  • Record Locator: PNR 번호
  • Legacy PCC: 이전 시스템 PCC

11.3 QueueAccessActionType 용도별 분류

  • 제거: QR, E, EW, QXE, QXER
  • 유지: I, EL, EWL, QXI, QXIR
  • 재조회: EWR, QXIR, QXER

11.4 관련 문서