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분)자연스럽게 키 단위로 사라짐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. 정리 — 캐시 전략에서 매번 다시 물어야 하는 질문들

이 글을 정리하면서 다시 보니, 캐시 전략은 결국 다음 질문들의 답을 한 번에 모아 둔 결과입니다.

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

이번 사고에서 가장 뼈저린 부분은 5번이었습니다. 1~4번은 비교적 정직하게 비용 분석으로 답을 낼 수 있는데, 5번은 그 트리거가 망가졌을 때 본 흐름이 어떻게 보일 것인가 라는 시나리오를 같이 시뮬레이션해야 답이 보입니다. 다음에 캐시 전략을 설계할 때는 5번을 가장 먼저 묻고 시작하려 합니다.


참고

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