donghakim.dev — zsh
← ls ../blog

항공권 검색 캐시 전략: 무엇을 캐시하고, 언제 비우는가

외부 GDS 호출 비용을 절감하면서 stale 좌석 정보는 회피하는 무효화 설계입니다.

항공권 검색 캐시 전략: 무엇을 캐시하고, 언제 비우는가 · cover

이번 글에서는 우리가 운영 중인 항공권 검색 캐시의 무효화 전략을 설명합니다. 왜 캐시를 두는지, 키를 어떻게 설계했는지, 어떤 트리거가 어떤 범위를 비우는지, 무효화 단계를 본 흐름과 결합할지 분리할지를 어떤 기준으로 결정하는지까지 다룹니다.

이 글은 캐시 전략 자체에 초점이 있습니다. 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. 정리: 캐시 전략에서 매번 다시 물어야 하는 질문들

이 글을 정리하면서 다시 검토하니, 캐시 전략은 다음 질문들에 대한 답으로 구성됩니다.

  1. 왜 캐시하는가: 비용·지연을 감소시키려고.
  2. 무엇이 동일한 입력인가: 캐시 키의 차원 결정.
  3. 언제 stale해지는가: 무효화 트리거 결정.
  4. 어떻게 비울 것인가: 패턴 매칭의 구현 도구 (SCAN vs SET 인덱스 등).
  5. 본 흐름과 어떻게 결합할 것인가: 동기/비동기, best-effort/필수.

이번 사고에서 문제가 된 부분은 5번이었습니다. 1~4번은 비용 분석으로 비교적 명확하게 답을 결정할 수 있는데, 5번은 그 트리거가 실패했을 때 본 흐름이 어떻게 되는지를 같이 검토해야 답이 도출됩니다.


참고

#airline#architecture#cache#cache-invalidation#redis#valkey