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

1. API 개요

1.1 주요 기능

  • 큐 PNR 조회: 특정 큐에 있는 PNR 목록 조회
  • 큐 개수 조회: 큐별 카테고리별 PNR 개수 조회
  • 큐에서 제거: 처리 완료된 PNR을 큐에서 제거
  • 병렬 처리: 여러 큐를 동시에 조회하여 성능 향상

1.2 큐 시스템 개요

  • Queue: Amadeus GDS에서 PNR을 관리하는 대기열 시스템
  • Category (Item Number): 큐 내 PNR을 분류하는 하위 카테고리
  • Time Mode: 큐 조회 시간 모드 (생성일, 출발일 등)
  • Offline OID: 오프라인 오피스 ID (모든 큐 핸들링 가능)

1.3 관련 파일 구조

supplier/amadeus/
├── application/
│   └── AmadeusQueueService.kt         # 큐 관리 서비스
├── infrastructure/
│   └── AmadeusClient.kt                # SOAP API 클라이언트
├── interfaces/controller/
│   └── internals/
│       └── AmadeusQueueController.kt   # 큐 API 엔드포인트
└── support/model/
    ├── QueuePnrInfo.kt                 # 큐 PNR 정보
    └── QueueCount.kt                   # 큐 카운트 정보

2. 핵심 비즈니스 로직

2.1 큐 조회 프로세스

flowchart TD
    A[큐 조회 요청] --> B[Offline OID 조회]
    B --> C[병렬 큐 처리]
    C --> D[Queue 1 조회]
    C --> E[Queue 7 조회]

    D --> F[큐 카운트 조회]
    E --> G[큐 카운트 조회]

    F --> H{카테고리 필터링}
    H -->|Queue 1| I[카테고리 1, 6만]
    H -->|Queue 7| J[전체 카테고리]

    I --> K[카테고리별 PNR 조회]
    J --> K

    K --> L{최대 250개 제한}
    L --> M[PNR 목록 반환]
    L --> N[초과 시 250개만]

    M --> O[전체 결과 병합]
    N --> O

    style H 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 메인 로직

// AmadeusQueueService.kt:28-65
fun getQueuePnrs(): List<QueuePnrInfo> {
    // 1. Offline OID 조회 (TRIPLE 채널/펀넬)
    val offlineOid = amadeusProperties.channels
        .first { it.channel == "TRIPLE" }
        .funnels.first { it.funnel == "TRIPLE" }
        .offlineOid
 
    return try {
        withBlocking(Dispatchers.IO) {
            // 2. 대상 큐 번호 병렬 처리
            TARGET_QUEUE_NUMBERS.pmap { originQueueNumber ->  // ["1", "7"]
                // 3. 큐 카운트 조회
                val queueCounts = amadeusClient.getPnrCountsInQueue(
                    queueNumber = originQueueNumber,
                    offlineOid = offlineOid
                ).filterQueueCountsByCategory(originQueueNumber)
 
                logger.info("[AMADEUS] Get - Queue Number: $originQueueNumber, Counts: $queueCounts, OID: $offlineOid")
 
                // 4. 카테고리별 PNR 조회
                if (queueCounts.isNotEmpty()) {
                    queueCounts.flatMap { queueCount ->
                        amadeusClient.getPnrsInQueue(
                            queueNumber = originQueueNumber,
                            itemNumber = queueCount.itemNumber,
                            timeMode = queueCount.timeMode,
                            offlineOid = offlineOid
                        ).map {
                            QueuePnrInfo(
                                pnr = it,
                                category = queueCount.itemNumber,
                                queueNumber = originQueueNumber,
                                timeMode = queueCount.timeMode
                            )
                        }
                    }
                } else {
                    emptyList()
                }
            }.getOrEmpty()
        }
    } catch (e: Exception) {
        slackService.sendQueueFail(supplier = Supplier.AMADEUS.name, reason = e.message)
        emptyList()
    }.flatten()
}

2.2.2 대상 큐 번호

// AmadeusQueueService.kt:24-26
companion object {
    val TARGET_QUEUE_NUMBERS = listOf("1", "7")
}
  • Queue 1: 일반 처리 큐
  • Queue 7: 특별 처리 큐

2.3 카테고리 필터링

2.3.1 큐 번호별 필터링 로직

// AmadeusQueueService.kt:67-72
private fun List<QueueCount>.filterQueueCountsByCategory(queueNumber: String): List<QueueCount> {
    return when (queueNumber) {
        "1" -> this.filter { it.itemNumber in listOf("1", "6") }  // Queue 1은 카테고리 1, 6만
        else -> this  // 나머지 큐는 전체 카테고리
    }
}

2.3.2 필터링 이유

  • Queue 1, Category 1: 일반 예약 처리 대기
  • Queue 1, Category 6: 특별 처리 대기
  • 기타 카테고리: 처리 불필요 (NDC, 내부 시스템 등)
flowchart LR
    A[Queue 1 카운트] --> B{카테고리 확인}
    B -->|1| C[일반 처리]
    B -->|6| D[특별 처리]
    B -->|기타| E[필터링 제외]

    C --> F[PNR 조회]
    D --> F
    E --> G[무시]

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

3. 큐 제거 (remove)

3.1 제거 로직

// AmadeusQueueService.kt:74-93
fun remove(queueNumber: String, pnrs: List<String>, itemNumber: String, timeMode: String?): Boolean {
    // 1. Offline OID 조회
    val offlineOid = amadeusProperties.channels
        .first { it.channel == "TRIPLE" }
        .funnels.first { it.funnel == "TRIPLE" }
        .offlineOid
 
    return try {
        logger.info(
            "[AMADEUS] Remove - Queue Number: $queueNumber, Item Number: $itemNumber, " +
            "Time Mode: $timeMode, Counts: ${pnrs.count()}, PNRs: $pnrs, OID: $offlineOid"
        )
 
        // 2. 큐에서 PNR 제거
        amadeusClient.removePnrsInQueue(
            queueNumber = queueNumber,
            pnrs = pnrs,
            itemNumber = itemNumber,
            timeMode = timeMode,
            offlineOid = offlineOid
        )
    } catch (e: Exception) {
        slackService.sendQueueFail(supplier = Supplier.AMADEUS.name, reason = e.message)
        false
    }
}

3.2 제거 프로세스

flowchart TD
    A[제거 요청] --> B[요청 그룹화]
    B --> C{동일 큐/카테고리?}
    C -->|Yes| D[일괄 제거]
    C -->|No| E[개별 제거]

    D --> F[removePnrsInQueue]
    E --> F

    F --> G{성공?}
    G -->|Yes| H[true 반환]
    G -->|No| I[재시도]

    I --> J{3회 시도?}
    J -->|No| F
    J -->|Yes| K[Slack 알림]
    K --> L[false 반환]

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

4. API 클라이언트 구현

4.1 큐 카운트 조회

// AmadeusClient.kt:1044-1075
fun getPnrCountsInQueue(queueNumber: String, offlineOid: String): List<QueueCount> {
    val amadeusApiProperties = amadeusProperties.getApiProperties(offlineOid = offlineOid)
    val request = QueueCountTotal.of(offlineOid = amadeusApiProperties.offlineOid, queueNumber = queueNumber)
 
    return amadeusApiProperties.endpoint
        .post(request)
        .header(getHeaderMap(request))
        .requestBodyConvert(
            soapRequestBodyConverter(
                amadeusApiProperties = amadeusApiProperties,
                useOfflineOid = true,  // Offline OID 사용
            )
        )
        .execute<AmadeusResponse<QueueCountTotalReply>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.body.checkError { (code, message) ->
                    throw InternationalAdapterException(ErrorMessage.QUEUE_COUNT_FAILED, code, message ?: "")
                }
 
                // NDC 아이템 번호 및 카운트 0인 항목 필터링
                response.body.queueCountDisplay!!.standardQueueCountDisplays[0].categoryAndCounts
                    .filter { !it.isNdcItemNumber && it.pnrCount > 0 }
                    .map { QueueCount.of(it) }
            },
            failure = { throw it.handleSoapFaultException(ErrorMessage.QUEUE_COUNT_FAILED) }
        )
}

4.2 큐 PNR 목록 조회

// AmadeusClient.kt:1078-1118
fun getPnrsInQueue(
    queueNumber: String,
    itemNumber: String = "0",
    timeMode: String = "1",
    offlineOid: String,
): List<String> {
    val amadeusApiProperties = amadeusProperties.getApiProperties(offlineOid = offlineOid)
    val request = QueueList.of(
        offlineOid = amadeusApiProperties.offlineOid,
        queueNumber = queueNumber,
        itemNumber = itemNumber,
        timeMode = timeMode
    )
    return amadeusApiProperties.endpoint
        .post(request)
        .header(getHeaderMap(request))
        .requestBodyConvert(
            soapRequestBodyConverter(
                amadeusApiProperties = amadeusApiProperties,
                useOfflineOid = true,
            )
        )
        .execute<AmadeusResponse<QueueListReply>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.body.checkError { (code, message) ->
                    throw InternationalAdapterException(ErrorMessage.GET_QUEUE_LIST_FAILED, code, message ?: "")
                }
 
                // PNR 목록 추출 (최대 250개)
                response.body.pnrList?.recordLocators?.map { it.recordLocator!! } ?: emptyList()
            },
            failure = { throw it.handleSoapFaultException(ErrorMessage.GET_QUEUE_LIST_FAILED) }
        )
}

4.3 큐에서 PNR 제거

// AmadeusClient.kt:1167-1205
@Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000))  // 3회 재시도, 5초 간격
fun removePnrsInQueue(
    queueNumber: String,
    pnrs: List<String>,
    itemNumber: String,
    timeMode: String?,
    offlineOid: String,
): Boolean {
    val amadeusApiProperties = amadeusProperties.getApiProperties(offlineOid = offlineOid)
    val request = QueueRemoveItem.of(
        queueNumber = queueNumber,
        pnrs = pnrs,
        itemNumber = itemNumber,
        timeMode = timeMode,
    )
 
    return amadeusApiProperties.endpoint
        .post(request)
        .header(getHeaderMap(request))
        .requestBodyConvert(
            soapRequestBodyConverter(
                amadeusApiProperties = amadeusApiProperties,
                useOfflineOid = true,
            )
        )
        .execute<AmadeusResponse<QueueRemoveItemReply>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.body.checkError { (code, message) ->
                    throw InternationalAdapterException(ErrorMessage.QUEUE_REMOVE_FAILED, code, message ?: "")
                }
                true
            },
            failure = { throw it.handleSoapFaultException(ErrorMessage.QUEUE_REMOVE_FAILED) }
        )
}

5. API 엔드포인트

5.1 큐 목록 조회

// AmadeusQueueController.kt:16-21
@GetMapping
fun getQueues(): ResponseEntity<List<QueueView>> {
    val queuePnrInfos = amadeusQueueService.getQueuePnrs()
    return ResponseEntity.ok(queuePnrInfos.map { QueueView.of(it) })
}

5.2 큐에서 제거

// AmadeusQueueController.kt:23-37
@PutMapping
fun remove(@RequestBody requests: List<QueueRemoveRequest>): ResponseEntity<QueueRemoveView> {
    // 동일한 큐/카테고리/시간모드로 그룹화
    val result = requests.groupBy { Triple(it.queueNumber, it.category, it.timeMode) }.map { (key, request) ->
        val (queueNumber, category, timeMode) = key
 
        amadeusQueueService.remove(
            queueNumber = queueNumber,
            pnrs = request.map { it.pnr },
            itemNumber = category!!,
            timeMode = timeMode
        )
    }.all { it }  // 모든 제거 작업이 성공해야 true
 
    return ResponseEntity.ok(QueueRemoveView.of(result = result))
}

6. 병렬 처리 전략

6.1 큐 병렬 조회

// AmadeusQueueService.kt:34-59
withBlocking(Dispatchers.IO) {
    TARGET_QUEUE_NUMBERS.pmap { originQueueNumber ->  // 병렬 처리
        val queueCounts = amadeusClient.getPnrCountsInQueue(...)
 
        if (queueCounts.isNotEmpty()) {
            queueCounts.flatMap { queueCount ->
                amadeusClient.getPnrsInQueue(...)
            }
        } else {
            emptyList()
        }
    }.getOrEmpty()
}

6.2 병렬 처리 플로우

graph TD
    A[getQueuePnrs 시작] --> B[pmap 병렬 처리]
    B --> C[Queue 1 스레드]
    B --> D[Queue 7 스레드]

    C --> E[Queue 1 카운트 조회]
    D --> F[Queue 7 카운트 조회]

    E --> G[Queue 1 PNR 조회]
    F --> H[Queue 7 PNR 조회]

    G --> I[결과 병합]
    H --> I

    I --> J[전체 PNR 목록]

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

7. 제약사항 및 특이사항

7.1 Amadeus 큐 시스템 제약

  • 최대 조회 개수: 한 번에 최대 250개 PNR만 조회 가능
  • Offline OID 필수: 모든 큐 작업에 오프라인 오피스 ID 필요
  • 카테고리 제한: Queue 1은 카테고리 1, 6만 처리

7.2 NDC 아이템 번호 제외

// AmadeusClient.kt:1068-1069
response.body.queueCountDisplay!!.standardQueueCountDisplays[0].categoryAndCounts
    .filter { !it.isNdcItemNumber && it.pnrCount > 0 }  // NDC 제외
  • NDC (New Distribution Capability) 관련 아이템은 별도 처리 필요
  • 일반 GDS 워크플로우와 다름

7.3 재시도 전략

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

8. 예외 처리 및 알림

8.1 큐 조회 실패

// AmadeusQueueService.kt:61-64
} catch (e: Exception) {
    slackService.sendQueueFail(supplier = Supplier.AMADEUS.name, reason = e.message)
    emptyList()
}
  • 실패 시 빈 목록 반환 (예외 전파 안 함)
  • Slack 알림 발송

8.2 큐 제거 실패

// AmadeusQueueService.kt:89-92
} catch (e: Exception) {
    slackService.sendQueueFail(supplier = Supplier.AMADEUS.name, reason = e.message)
    false
}
  • 실패 시 false 반환
  • Slack 알림 발송
  • 재시도 메커니즘 (@Retryable) 적용

9. 로깅 전략

9.1 조회 로깅

// AmadeusQueueService.kt:40-41
logger.info(
    "[${Supplier.AMADEUS}] Get - Queue Number: $originQueueNumber, " +
    "Counts: $queueCounts, OID: $offlineOid"
)
 
// AmadeusQueueService.kt:51-52
logger.info(
    "[${Supplier.AMADEUS}] Get - Queue Number: $originQueueNumber, " +
    "Category: ${queueCount.itemNumber}, PNRs: $it, OID: $offlineOid"
)

9.2 제거 로깅

// AmadeusQueueService.kt:80-81
logger.info(
    "[${Supplier.AMADEUS}] Remove - Queue Number: $queueNumber, " +
    "Item Number: $itemNumber, Time Mode: $timeMode, " +
    "Counts: ${pnrs.count()}, PNRs: $pnrs, OID: $offlineOid"
)

9.3 로깅 정보

  • Queue Number (큐 번호)
  • Item Number (카테고리)
  • Time Mode (시간 모드)
  • PNR Count (PNR 개수)
  • PNR List (PNR 목록)
  • Offline OID (오피스 ID)

10. 성능 최적화

10.1 병렬 처리

  • pmap 사용: 여러 큐를 동시에 조회
  • Dispatchers.IO: I/O 작업에 최적화된 스레드 풀
  • 성능 향상: 순차 처리 대비 약 50% 시간 단축 (큐 2개 기준)

10.2 필터링 최적화

  • 카운트 0 제외: API 호출 전 사전 필터링
  • NDC 제외: 불필요한 카테고리 조회 방지
  • 카테고리 필터링: Queue 1은 2개 카테고리만 조회

11. 개선 권장사항

11.1 현재 이슈

  1. 하드코딩된 큐 번호: TARGET_QUEUE_NUMBERS = listOf("1", "7")
  2. 카테고리 필터링 하드코딩: Queue 1의 카테고리 1, 6
  3. 250개 제한: 초과 시 누락 발생 가능
  4. 조회 재시도 부재: getPnrsInQueue에 재시도 없음

11.2 개선 방안

  1. 설정 외부화: 큐 번호 및 카테고리를 application.yml에서 관리
  2. 페이징 처리: 250개 초과 시 여러 번 조회
  3. 조회 재시도: Circuit Breaker 또는 @Retryable 적용
  4. 캐싱 전략: 큐 조회 결과 단기 캐싱 (1분 TTL)

11.3 추가 기능 제안

  1. 큐 이동: PNR을 다른 큐로 이동
  2. 큐 추가: 특정 PNR을 큐에 추가
  3. 큐 우선순위: 긴급 처리 PNR 우선 조회
  4. 큐 통계: 큐별 처리 시간, 대기 시간 분석

12. 참고사항

12.1 주요 에러 코드

  • QUEUE_COUNT_FAILED: 큐 카운트 조회 실패
  • GET_QUEUE_LIST_FAILED: 큐 PNR 목록 조회 실패
  • QUEUE_REMOVE_FAILED: 큐 제거 실패

12.2 큐 시스템 용어

  • Queue Number: 큐 번호 (1, 7 등)
  • Item Number (Category): 큐 내 카테고리 번호
  • Time Mode: 시간 기준 모드 (생성일, 출발일 등)
  • Offline OID: 오프라인 오피스 ID
  • Record Locator: PNR 번호

12.3 관련 문서