항공권 검색 캐시 전략: 무엇을 캐시하고, 언제 비우는가
외부 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분) | 자연스럽게 키 단위로 사라짐 | 1차 방어선. 다른 트리거가 없어도 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번은 그 트리거가 망가졌을 때 본 흐름이 어떻게 보일지를 같이 시뮬레이션해야 답이 보입니다. 다음에 캐시 전략을 설계할 때는 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