항공권 검색 캐시 전략: 무엇을 캐시하고, 언제 비우는가
외부 GDS 호출 비용을 절감하면서 stale 좌석 정보는 회피하는 무효화 설계입니다.

이번 글에서는 우리가 운영 중인 항공권 검색 캐시의 무효화 전략을 설명합니다. 왜 캐시를 두는지, 키를 어떻게 설계했는지, 어떤 트리거가 어떤 범위를 비우는지, 무효화 단계를 본 흐름과 결합할지 분리할지를 어떤 기준으로 결정하는지까지 다룹니다.
이 글은 캐시 전략 자체에 초점이 있습니다. SCAN을 통한 패턴 무효화 도중 실제로 발생했던 사고는 Valkey Serverless의 SCAN cursor가 Java long 범위를 넘어서 NPE까지 가는 길에서 따로 다뤘습니다.
1. 왜 캐시하는가: 비용과 지연
우리 서비스의 항공권 조회 트래픽은 자체 웹사이트뿐 아니라 OTA, NDC 같은 외부 채널로도 유입됩니다. 같은 노선·날짜로 초당 수 건씩 동일 조회가 유입되는 경우가 흔합니다. 매 요청을 외부 GDS / NDC로 그대로 전달하면 두 가지가 같이 증가합니다.
- 외부 호출 단가. 채널·노선·구성에 따라 다르지만 결국 호출 단위로 과금되는 구조라, 트래픽이 증가하면 비용이 거의 선형으로 증가합니다. 동일 조건의 조회를 N번 수신하든 1번 수신하든 외부에 N번을 모두 전송하는 것은 비효율입니다.
- 응답 지연. GDS의 응답은 수백 ms에서 수 초까지 들쭉날쭉합니다. 그대로 사용자에게 노출하면 검색 UX가 저하됩니다.
그래서 검색 결과 자체를 캐시에 저장하고, 같은 조건의 후속 조회는 캐시에서 즉시 반환합니다. 저장소는 AWS ElastiCache의 Valkey Serverless를 선택했고, 그 배경은 다음 절에서 다시 다룹니다.
이 시점에서 가장 중요한 결정은 "무엇을 키로 삼을 것인가" 입니다.
2. 캐시 키 설계: 무엇을 같은 결과로 볼 것인가
캐시는 "동일 입력 → 동일 출력"이라는 가정을 전제로 합니다. 그러려면 무엇이 동일한 입력인가를 정확히 정의해야 합니다. 항공권 검색에서 같은 결과를 산출하는 조건은 대략 이렇습니다.
- 조회 채널 (자체 / OTA / NDC, 그리고 그 안의 어떤 파트너인지)
- 출발지·도착지 (공항 코드)
- 출발 / 도착 날짜
- 승객 유형과 인원 (성인 / 소아 / 유아)
- 좌석 등급, 직항 여부 같은 필터
이 조건들을 조합해 하나의 캐시 키로 생성합니다. 키 한 개에 해당 조건의 검색 결과 한 페이지 전체가 저장되는 구조입니다.
search:<channel>:<origin>-<destination>:<date>:<paxType>:<extra...>
예) search:OTA:HIN-GMP:2026-05-07:ADT
이 키 형태가 무효화 전략을 결정하는 또 다른 기준이 됩니다. 무효화는 거의 항상 "패턴 매칭"으로 수행되기 때문입니다.
예를 들어 어떤 노선·날짜에서 좌석 점유가 발생했다고 합시다. 그 노선·날짜에 해당하는 모든 채널의 캐시를 비워야 하므로, 무효화 시 키 한 개만 지정해 삭제하는 것이 아니라
search:*:HIN-GMP:2026-05-07:*
같은 패턴에 매칭되는 키 묶음을 조회해 일괄 삭제합니다. 그래서 키 설계가 곧 무효화 단위 설계입니다. 키 안 어디에 어떤 토큰을 배치하느냐가, 나중에 "이 좌석 변동에 어떤 범위를 비울 수 있는가"를 결정합니다.
유의점. 이 글에서 다루는 키 구조는 단순화한 예시입니다. 실제로는 통화, 운임 클래스, 추가 옵션 등이 더 추가되어 키가 더 길어집니다. 키가 길어지면 일치 키 묶음을 조회하는 무효화 비용도 같이 증가하기 때문에, "이 차원을 키에 포함시킬 가치가 있는가" 는 캐시 hit률과 무효화 부담 사이의 trade-off로 결정됩니다.
3. 무효화 전략: 어떤 트리거가 어떤 범위를 비우는가
이제 본론입니다. 캐시는 언제 비울까요? 우리가 사용하는 트리거는 크게 네 가지입니다.
| 트리거 | 언제 발동되나 | 삭제 범위 | 비고 |
|---|---|---|---|
| TTL 만료 | 키 생성 후 N분 (예: 10분) | 키 단위로 만료되어 삭제됨 | 기본 장치. 다른 트리거가 없어도 stale 노출의 상한선 |
| 주문 생성 직전 | createOrder 호출이 시작되는 순간 | 해당 항공편의 모든 채널 캐시 (search:*:<OD>:<date>:*) | 좌석 점유 가능성이 가장 높은 시점 |
| 결제 완료 / 예약 확정 후 | 결제 캡처, 좌석 holding 확정 | 해당 항공편의 모든 채널 캐시 | 좌석 카운트가 실제로 감소했으니 stale 위험이 큼 |
| 예약 취소 / 환불 처리 | 취소 콜백, 환불 워크플로 종료 | 해당 항공편의 모든 채널 캐시 | 좌석이 다시 판매 가능해짐 → 검색 결과를 다시 채워야 함 |
트리거마다 이렇게 결정한 이유가 다릅니다.
TTL 만료: 모든 캐시에 적용되는 상한
먼저 모든 키는 짧은 TTL(현재 10분)을 갖고 저장됩니다. 다른 트리거가 모두 실패해도 적어도 10분 안에는 캐시가 만료되어 삭제된다는 보장이 생깁니다. stale 노출의 상한선이 TTL이라고 보면 됩니다.
TTL을 더 짧게 설정하면 stale 위험은 감소하지만 hit률이 하락해 비용이 증가하고, 더 길게 설정하면 반대입니다. 10분이 절대적인 정답은 아니고, 우리 트래픽 패턴과 좌석 변동 빈도에서 trade-off가 가장 적합한 지점이었을 뿐입니다.
주문 생성 직전: 가장 민감한 순간
사용자가 "이 항공편 결제할게요"를 클릭하는 순간, 그 항공편의 캐시는 사실상 신뢰할 수 없습니다. 곧 좌석이 점유될 예정이기 때문입니다. 그래서 외부 시스템에 createOrder를 전송하기 직전에 캐시를 한 번 비웁니다.
이 트리거가 본 흐름 한가운데에 동기로 포함되어 있다는 점은 뒤(5절)에서 다시 다룹니다. 이번 사고는 이 결합에서 시작되었습니다.
결제 완료 / 예약 확정 후: 변경이 확정된 시점
결제가 캡처되고 좌석 holding이 확정되면, 그 시점부터 캐시는 명확하게 stale입니다. 좌석 카운트가 실제로 감소해 있을 것이기 때문입니다. 다만 이 트리거는 본 흐름의 뒤에서 발생하므로, 본 흐름 자체의 성공/실패와 분리해 비동기로 처리해도 큰 문제가 없습니다.
예약 취소 / 환불 처리: 역방향 변경
취소나 환불이 완료되면 좌석이 다시 판매 가능한 상태가 됩니다. 이 시점에 캐시를 비우지 않으면, "검색하니 직전까지 매진이던 항공편이 갑자기 다시 나타나는" 식의 어색한 UX가 됩니다. 그래서 취소 워크플로 끝단에서도 동일한 패턴으로 무효화를 실행합니다.
4. 무효화 도구: 패턴 매칭은 무엇으로 구현하는가
위 세 트리거 모두 "특정 패턴의 키 묶음을 한 번에 비운다"라는 동작을 공통으로 합니다. 이 동작을 Redis/Valkey에서 어떻게 구현할지가 다음 결정 포인트입니다.
선택지는 크게 둘입니다.
A. SCAN 기반: 무효화 시점에 SCAN으로 패턴에 매칭되는 키들을 조회해 DEL합니다.
- 장점: 추가 자료구조가 필요 없습니다. 키 저장은 평소처럼 단순한
SET key value EX ttl로 끝납니다. - 단점: 무효화 시 SCAN 비용이 발생하고, 키가 많을수록 비용이 증가합니다. 그리고 우리가 실제로 직면한, 클라이언트-서버 cursor 호환성 문제가 있을 수 있습니다.
B. SET 기반 키 인덱스: 캐시 키를 저장할 때 그 키를 별도 Redis SET에도 추가해 둡니다(예: set:keys-by-flight:<OD>:<date>에 해당 키 이름을 멤버로 추가). 무효화 시엔 SET 멤버 목록을 조회해 그 키들만 명시적으로 DEL합니다.
- 장점: SCAN을 사용하지 않습니다. 무효화 비용이 인덱스 크기에 비례하고, 호환성 이슈가 없습니다.
- 단점: 모든 쓰기 경로에서 인덱스 SET 정합성을 함께 관리해야 합니다. TTL로 만료되는 키와 SET 멤버 간 동기화 같은 부수 문제가 발생합니다.
우리는 처음에 A안(SCAN 기반) 을 선택했습니다. 단순하고, "운영 금지" 패턴(KEYS)을 회피하면서 모범 답안에 가까운 선택이었기 때문입니다. 이 결정이 어떻게 변경되었는지는 별도 글에 자세히 기록했습니다. 결론만 말하면, A안의 단순한 구조가 우리가 사용하는 매니지드 서비스(Valkey Serverless)의 한 동작 방식과 부합하지 않아 발생한 사고였습니다. B안으로 전환하는 것도 후속 작업 중 하나로 검토 중입니다.
5. 본 흐름과 무효화의 결합: 동기일까, 비동기일까
이 부분이 캐시 전략에서 가장 미묘한 결정입니다. 위 4가지 트리거를 본 흐름과 어떻게 결합할지는 트리거마다 답이 다릅니다.
| 트리거 | 본 흐름과의 관계 | 결합 정도 |
|---|---|---|
| TTL 만료 | 본 흐름과 완전 분리 (Redis 자체가 처리) | 비동기 |
| 주문 생성 직전 | createOrder 안에서 동기로 호출 | 강한 결합 |
| 결제 완료 후 | 결제 후처리 큐에서 비동기 | 약한 결합 |
| 예약 취소 / 환불 | 취소 워크플로 끝단에서 비동기 | 약한 결합 |
2번(주문 생성 직전) 이 유일하게 본 흐름 한가운데에 동기로 포함되어 있습니다. 동기로 구성한 이유는 "주문이 만들어지기 전에 stale 캐시를 반드시 비워둬야 한다"는 정합성 요구 때문이었습니다. 비동기로 분리하면 createOrder 직후 짧은 시간 동안 stale 캐시가 노출될 수 있고, 그 사이 같은 항공편에 대한 다른 검색이 유입되면 잘못된 결과를 반환할 수 있다고 판단했습니다.
그런데 이 동기 결합 구간에서 사고가 발생했을 때, 캐시(보조 장치)에서 발생한 예외 하나 때문에 주문(본 흐름) 전체가 실패했습니다. 무효화는 어디까지나 best-effort여야 하는데, 구조상 무효화 실패가 본 흐름을 중단시킬 수 있었습니다.
이 사고를 겪고 다시 검토하면, 무효화의 정합성 vs 본 흐름의 회복력 사이에서 우선순위가 명확하지 않았다는 것이 확인됩니다. "stale 캐시가 잠깐 노출되는 것"과 "결제 후 주문이 생성되지 않는 것" 중 사용자에게 더 심각한 쪽은 후자입니다.
그래서 후속 검토 항목 중 하나는 "2번 트리거를 큐 기반 비동기로 분리" 입니다. 정합성은 짧은 시간 동안 약화되지만, 본 흐름은 캐시 단계의 어떤 사고로도 중단되지 않게 됩니다.
6. 정리: 캐시 전략에서 매번 다시 물어야 하는 질문들
이 글을 정리하면서 다시 검토하니, 캐시 전략은 다음 질문들에 대한 답으로 구성됩니다.
- 왜 캐시하는가: 비용·지연을 감소시키려고.
- 무엇이 동일한 입력인가: 캐시 키의 차원 결정.
- 언제 stale해지는가: 무효화 트리거 결정.
- 어떻게 비울 것인가: 패턴 매칭의 구현 도구 (SCAN vs SET 인덱스 등).
- 본 흐름과 어떻게 결합할 것인가: 동기/비동기, best-effort/필수.
이번 사고에서 문제가 된 부분은 5번이었습니다. 1~4번은 비용 분석으로 비교적 명확하게 답을 결정할 수 있는데, 5번은 그 트리거가 실패했을 때 본 흐름이 어떻게 되는지를 같이 검토해야 답이 도출됩니다.
참고
- 이 전략 위에서 발생한 실제 사고: Valkey Serverless의 SCAN cursor가 Java long 범위를 넘어서 NPE까지 가는 길
- Redis
SCAN명세: https://redis.io/commands/scan/ - AWS ElastiCache Serverless 문서: https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/serverless-overview.html