데이터는 반드시 낡는다. 그리고 그 사실을 받아들일 때 진짜 설계가 시작된다.
TTL과 태그 기반 무효화, stale-while-revalidate를 언제 선택해야 하는지 구체적인 판단 기준을 제시한다. 캐시로 인한 데이터 불일치를 비즈니스 관점에서 어디까지 용인할지 결정하는 프레임워크와 무효화 비용을 최소화하는 실전 패턴을 함께 다룬다. 사용자 프로필 페이지에서 '최근 구매 목록'이 3초 전 데이터를 보여주고 있다.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
사용자 프로필 페이지에서 '최근 구매 목록'이 3초 전 데이터를 보여주고 있다. 관리자 대시보드는 동시에 같은 주문을 '처리 완료'로 표시한다. 캐시가 깨지는 순간은 언제나 이렇게 조용하다. 누구도 에러를 띄워주지 않는다. 그냥 묵묵히 낡은 데이터를 내보낼 뿐이다.
많은 팀이 이 지점에서 "TTL을 0으로 만들자"라는 결론으로 달려간다. 그리고 곧바로 데이터베이스에 직격탄을 맞는다. 문제는 캐시를 쓸지 말지가 아니다. 낡은 데이터를 허용할지 말지, 허용한다면 얼마나 오래, 어떤 조건에서인지를 정하지 않은 게 문제다.
세 가지 무효화 방식, 실패하는 지점이 다르다
TTL 기반 만료는 가장 단순하다. 키에 수명을 걸어두고, 시간이 지나면 다음 읽기에서 새로 계산한다. 장점은 구현 비용이 거의 제로에 가깝다는 점. 레디스 EXPIRE, CDN max-age, 브라우저 캐시 헤더까지 거의 모든 인프라가 이 모델을 기본으로 지원한다. 문제는 '시간'이라는 프록시가 실제 데이터 변경과 무관하다는 사실이다. 5분 TTL을 걸어둔 상품 가격이 1초 만에 변경되면, 그 1초부터 남은 299초까지 모든 사용자는 낡은 가격을 본다. 반대로 아무도 안 건드리는 데이터를 5분마다 새로 계산하는 낭비도 발생한다.
태그 기반 무효화는 이 문제를 뒤집는다. 캐시 항목에 태그를 붙여두고, 원본 데이터가 변경될 때 해당 태그를 가진 모든 캐시를 날린다. '시간' 대신 '변경 이벤트'를 트리거로 삼는 셈이다. product:123이라는 태그를 단 모든 캐시는 상품 정보가 갱신되는 즉시 사라진다. 일견 완벽해 보이지만, 태그 의존성 그래프가 복잡해지면 금세 무너진다. 주문이 생성되면 상품 재고, 사용자 주문 내역, 판매자 대시보드 통계, 추천 알고리즘 입력까지 연쇄적으로 날려야 한다. 이 의존성을 사람이 추적하기 시작하는 순간, 언젠가 하나쯤 빠뜨리게 되어 있다. 그리고 빠진 태그는 아무도 모르게 오래된 캐시를 살려둔다.
stale-while-revalidate(SWR)는 아예 발상을 전환한다. "캐시가 만료되었지만, 일단 낡은 데이터를 주고 백그라운드에서 새로 고친다." HTTP Cache-Control: stale-while-revalidate=30이 이 동작을 정의한다. 사용자 입장에서는 즉시 응답을 받고, 다음 요청부터는 갱신된 데이터를 보게 된다. 레이턴시 관점에서는 환상적이다. 다만 전제가 하나 있다. 잠시 낡은 데이터를 보여줘도 괜찮다는 판단이 선행되어야 한다. 주문 완료 직후 "아직 배송 준비 중"이라는 응답이 5초간 유지되는 건 괜찮다. 하지만 결제 금액이 5초간 이전 가격으로 표시된다면? 이건 단순한 지연이 아니라 법적 문제로 번질 수 있다.
이 세 방식은 서로 배타적이지 않다. 실제로 대부분의 시스템은 TTL과 태그 기반 무효화를 함께 쓴다. TTL은 최대 허용 기간을 제한하는 안전망 역할을 하고, 태그는 변경 이벤트에 반응하는 정밀 타격 역할을 한다. SWR은 그 위에 레이턴시 완충층을 덧댄다. 어느 하나를 '선택'하는 문제가 아니라, 각 계층의 책임을 어떻게 배분할지의 문제다.
낡은 데이터를 용인하는 선 긋기
이게 진짜 어려운 부분이다. 캐시 정합성을 기술 명세로만 정의하려 들면 끝이 없다. "모든 데이터는 항상 최신이어야 한다"는 요구사항은 기술적으로는 실현 불가능에 가깝고, 비용 대비 효용도 바닥을 친다. 분산 시스템에서 강한 일관성을 보장하려면 합의 알고리즘과 동기 복제가 필요하고, 이는 응답 시간을 수백 밀리초에서 수 초로 밀어 올린다. 캐시를 쓰는 근본 이유가 응답 속도인데, 정합성 때문에 그 속도를 포기하면 처음부터 캐시를 쓰지 말았어야 한다.
그러니 질문을 바꿔야 한다. "이 데이터가 X초 동안 낡은 상태로 사용자에게 노출되었을 때 발생하는 비즈니스 손실은 얼마인가?" 여기에 답하려면 데이터를 세 축으로 분류하는 프레임이 필요하다.
금전적 민감도가 첫 번째 축이다. 결제 금액, 계좌 잔액, 입찰 가격처럼 오차가 곧바로 금전적 손실로 이어지는 데이터는 stale 윈도우를 0에 가깝게 유지해야 한다. 캐시를 아예 배제하거나, 쓰기 직후 해당 키를 즉시 무효화하는 write-through 패턴을 강제한다. 재고 수량처럼 약간의 오차가 오버셀링으로 이어질 수 있는 데이터도 여기에 준한다.
사용자 인지 민감도가 두 번째 축이다. 본인 프로필 사진이 30초 전 이미지로 보이는 것과, 타인의 프로필 사진이 30초 전 이미지로 보이는 것은 체감되는 불편함이 완전히 다르다. 내 정보는 내가 변경했으므로 오래된 데이터를 보면 즉시 위화감을 느낀다. 반면 타인의 정보나 집계 데이터는 원래 최신인지 아닌지 알 수 없기 때문에 staleness에 둔감하다. 이 차이를 활용하면, 자신의 리소스에 한해서만 캐시를 보수적으로 무효화하는 패턴을 쓸 수 있다. 사용자 A가 자신의 프로필을 수정하면 user:A:profile 태그를 즉시 날리고, 다른 사용자가 A의 프로필을 조회할 때는 SWR로 10초 윈도우를 허용하는 식이다.
세 번째 축은 데이터의 변경 빈도와 읽기 패턴이다. 하루에 수백만 번 읽히고 한 달에 한 번 수정되는 설정값이라면, TTL을 1시간으로 잡아도 문제없다. 변경 시점에 태그로 날리기만 하면 거의 항상 최신 상태를 유지하면서 캐시 히트율은 99%를 넘긴다. 반대로 초당 수천 건의 읽기와 수백 건의 쓰기가 동시에 일어나는 실시간 지표라면, TTL을 짧게 잡는다 해도 무효화 경쟁이 캐시의 존재 의미를 상실시킨다. 이런 데이터는 캐시를 포기하고 별도의 실시간 집계 파이프라인을 구축하는 편이 낫다.
이 세 축을 종합하면, 대부분의 서비스에서 데이터는 자연스럽게 세 부류로 갈린다. 캐시 없이 직접 읽어야 하는 데이터(금전+자기정보), SWR을 얹어도 무방한 데이터(타인정보+통계), 긴 TTL로 충분한 데이터(설정+참조데이터). 이 분류는 시스템을 설계할 때마다 다시 해야 하는 게 아니라, 도메인 모델에 주석처럼 달아두는 게 실용적이다.
무효화를 싸게 만드는 세 가지 패턴
캐시 무효화의 진짜 비용은 데이터베이스 부하가 아니다. 오히려 무효화가 누락되었을 때 아무도 모르게 지나가는 것, 이것이 진짜 비용이다. 모니터링이 안 되는 실패는 실패가 아니다.
첫 번째 패턴은 단일 진실 공급원에서만 캐시를 갱신하게 만드는 것이다. 여러 마이크로서비스가 각자의 캐시를 제각각 무효화하다 보면, 어느 서비스가 깜빡했을 때 추적이 불가능해진다. 데이터를 소유한 서비스 하나가 이벤트(예: Kafka 메시지나 CDC 스트림)를 발행하고, 나머지는 그 이벤트를 구독해서 캐시를 맞추는 구조다. 무효화 로직을 한 곳으로 모으면, 로그 한 줄로 누락 여부를 확인할 수 있다.
두 번째는 무효화 건수를 항상 세는 것이다. "30초 동안 무효화 요청 1,200건, 실제 삭제 1,198건"이라는 메트릭을 남기는 것만으로도 패턴을 찾을 수 있다. 2건은 왜 실패했는가? 태그가 존재하지 않아서인가, 레디스 파티션 문제인가? 이걸 모르면 시스템이 조용히 썩어간다. 프로메테우스와 그라파나로 cache_invalidation_total과 cache_invalidation_failed_total 같은 커스텀 메트릭을 남기는 건 20줄도 안 되는 코드로 가능하다.
세 번째는 TTL을 안전망으로만 쓰는 자세다. 태그 기반 무효화를 주력으로 삼더라도, 모든 캐시 키에는 반드시 TTL을 함께 설정한다. 태그 삭제가 실패하거나 이벤트가 유실되더라도, 최대 TTL 시간이 지나면 결국 낡은 데이터는 사라진다. "태그로 즉시 무효화 + TTL로 최종 방어"라는 이중 구조 없이는 분산 환경에서 완전한 무효화를 보장할 방법이 없다. TTL 값은 "이 데이터의 staleness를 비즈니스가 견딜 수 있는 최대 시간"으로 설정하고, 그보다 길어질 이유는 없다.
SWR을 도입하기 전에 확인해야 할 한 가지
SWR은 매력적이지만, 캐시 미스가 발생했을 때 백그라운드 재검증이 완료되기까지의 짧은 윈도우 동안은 여전히 낡은 데이터를 제공할 수 있다. 이 윈도우가 100ms면 대부분 무시할 만하다. 30초면 이야기가 다르다. 문제는 이 윈도우 길이를 결정하는 요소가 백엔드 재계산 시간이라는 점이다. 복잡한 SQL 집계가 5초 걸리는 지표에 SWR을 걸면, 최대 5초간 낡은 데이터가 유지된다.
이걸 제어하려면 SWR을 CDN 레이어에서만 쓰거나, SWR 윈도우 동안에도 stale 여부를 헤더로 노출해 프론트엔드가 판단할 수 있게 해야 한다. X-Cache-Stale: true 같은 커스텀 헤더를 붙여주면, 프론트엔드는 데이터가 오래되었음을 인지하고 로딩 인디케이터를 보여주거나 사용자 입력을 잠시 막을 수 있다. 투명성이 무효화보다 더 강력한 무기일 때가 있다.
진짜 위험은 SWR의 편리함에 익숙해져서 모든 엔드포인트에 기계적으로 적용하는 패턴이다. 새 기능을 배포할 때마다 "일단 SWR 걸어두고 보자"라는 판단이 반복되면, 어느 순간부터 스테일 데이터가 시스템 전체에 스며들어 있고 어디서부터 손대야 할지 모르는 상태가 된다. SWR은 도메인별로 명시적인 승인이 필요한 장치라는 인식을 팀에 심어두는 편이 낫다.
낡은 데이터를 받아들이는 건 실패가 아니라 설계 결정이다. 그 결정이 무책임했는지 아닌지를 가르는 기준은 단 하나다. 지금 당신 시스템에서 가장 오래된 캐시 항목이 몇 초인지, 즉시 답할 수 있는가. 답하지 못한다면, 아직 캐시를 설계한 게 아니라 그냥 갖다 붙인 것이다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
DB 폴링과 작별할 시간을 아는 기술
DB 폴링만으로 버티던 서비스가 커넥션 풀 고갈과 작업 지연 누적이라는 벽에 부딪히는 순간이 온다. 이 글은 그 신호를 감지하는 구체적인 기준과 함께, Redis/BullMQ 등 메시지 큐 도입 시 실제로 발생하는 consumer 확장·재시도·데드레터 관리 같은 운영 부담을 생략 없이 다룬다. 큐 도입은 해결책이자 새 책임이며, '정말 필요한가'를 판단하는 실용적인 기준을 제시한다.
1년 동안 DB 테이블로 큐 대신 쓰면서 내가 놓친 것들
DB 폴링 기반 작업 처리로 버틸 수 있는 한계와 Redis·메시지 큐 도입을 결정해야 하는 실전 신호, 그리고 큐를 도입할 때 따라오는 운영 부담을 사례 중심으로 정리한다. DB 테이블 하나로 작업 큐를 대신한 지 1년이 지났다. 처음엔 "언젠가 Redis나 RabbitMQ를 도입해야지"라고 생각했지만, 그 언젠가는 오지 않았다.
이전 글
당신의 RLS 정책은 조용히 거짓말을 하고 있다
다음 글
시크릿은 매번 같은 길로 도망친다
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.