Skip to content

Split View: 데이터베이스 캐싱 전략 완전 가이드: 결국 무효화가 전부다

|

데이터베이스 캐싱 전략 완전 가이드: 결국 무효화가 전부다

들어가며

이 블로그에는 Redis 캐싱 전략 완벽 가이드가 이미 있습니다. Cache-Aside, Read-Through, Write-Through, Write-Behind 같은 패턴 카탈로그를 다루는 글입니다.

이 글은 캐시를 데이터베이스 쪽에서부터 올라가며 봅니다. 순서가 다릅니다. 외부 캐시를 붙이자는 결정이 나오기 전에, 이미 존재하는 캐시 계층이 제대로 쓰이고 있는지 먼저 확인해야 하기 때문입니다. PostgreSQL에는 이미 shared_buffers가 있고, 운영체제 페이지 캐시가 있고, 머티리얼라이즈드 뷰라는 계산 캐시가 있습니다. 이것들이 놀고 있는 상태에서 Redis를 얹으면, 문제는 해결되지 않고 정합성 문제만 새로 생깁니다.

그리고 이 글의 절반은 무효화에 씁니다. 캐시 도입의 어려운 부분은 캐시를 채우는 것이 아니라 정확한 순간에 비우는 것이기 때문입니다. 패턴 이름을 외워도 이 부분을 틀리면 사용자에게 옛 데이터가 보입니다.

기준 엔진은 PostgreSQL 18 이며, 파라미터 기본값과 잠금 동작은 PostgreSQL 18 문서에서 확인했습니다.

1. 캐시를 붙이기 전에 확인할 것

"조회가 느리니 캐시를 붙이자"는 제안이 나왔을 때 먼저 던져야 할 질문이 세 개 있습니다.

질문 1 — 느린 이유가 계산인가 I/O인가. 같은 결과를 반복해서 만드느라 느린 것이라면 캐시가 답입니다. 인덱스가 없어서 매번 전체 테이블을 훑느라 느린 것이라면 캐시는 문제를 미룰 뿐입니다. 캐시가 만료될 때마다 같은 느린 쿼리가 다시 돕니다.

질문 2 — 읽기 대 쓰기 비율이 어떤가. 읽기가 압도적이면 캐시 적중률이 높고 무효화 빈도가 낮습니다. 쓰기가 잦으면 캐시가 계속 무효화되어 이득이 적고, 정합성 위험만 커집니다.

질문 3 — 옛 데이터를 얼마나 오래 보여 줘도 되는가. 이 질문에 대한 답이 초 단위인지 분 단위인지에 따라 설계가 완전히 달라집니다. "절대 안 된다"는 답이 나오면 캐시가 아니라 다른 해법을 찾아야 합니다.

세 질문에 답하기 전에 캐시를 붙이면, 나중에 "왜 가끔 옛날 값이 보이죠?"라는 버그 리포트를 받고 근본 원인을 찾지 못하게 됩니다.

2. 이미 있는 캐시 — shared_buffers와 운영체제 캐시

PostgreSQL은 자체 버퍼 풀과 운영체제 페이지 캐시를 함께 씁니다. 이 이중 구조를 이해하면 첫 번째 캐시 튜닝을 할 수 있습니다.

shared_buffers기본값은 128MB 입니다. 문서의 조정 지침은 명확합니다. "전용 데이터베이스 서버에 1GB 이상의 RAM이 있다면 shared_buffers의 합리적인 시작값은 시스템 메모리의 25%다. 더 큰 설정이 효과적인 워크로드도 있지만, PostgreSQL이 운영체제 캐시에도 의존하므로 RAM의 40%를 넘게 할당하는 것이 더 적은 양보다 나을 가능성은 낮다."

여기서 자주 나오는 오해가 있습니다. effective_cache_size는 메모리를 할당하지 않습니다. 플래너의 가정만 바꿉니다. 운영체제 캐시를 포함해 얼마나 많은 데이터가 캐시되어 있을지에 대한 추정치이며, 기본값은 4GB입니다. 메모리가 256GB인 서버에서 이 값이 기본값 그대로면 플래너는 인덱스 스캔이 디스크를 많이 때릴 것이라 보고 순차 스캔으로 기웁니다.

적중률은 이렇게 측정합니다.

-- 테이블별 버퍼 적중률: 낮은 순서로
SELECT schemaname, relname,
       heap_blks_read, heap_blks_hit,
       CASE WHEN heap_blks_hit + heap_blks_read = 0 THEN NULL
            ELSE round(heap_blks_hit * 100.0 / (heap_blks_hit + heap_blks_read), 2)
       END AS heap_hit_pct,
       CASE WHEN idx_blks_hit + idx_blks_read = 0 THEN NULL
            ELSE round(idx_blks_hit * 100.0 / (idx_blks_hit + idx_blks_read), 2)
       END AS idx_hit_pct
FROM pg_statio_user_tables
ORDER BY heap_blks_read DESC
LIMIT 20;

주의할 점 하나. 여기서 말하는 "read"는 PostgreSQL 버퍼 풀에 없어서 운영체제에 요청한 블록이지 반드시 디스크를 때린 것은 아닙니다. 운영체제 페이지 캐시에 있었다면 실제 디스크 I/O는 없었습니다. 그래서 이 숫자만으로 디스크 부하를 판단하면 과대평가하게 됩니다.

PostgreSQL 16 이후에는 pg_stat_io 뷰가 있어 더 정확한 그림을 볼 수 있습니다. backend_type, object, context별로 reads, hits, evictions, fsyncs를 분리해 보여 줍니다. contextbulkreadvacuum인 항목이 많다면 그것은 캐시 부족이 아니라 정상적인 대량 작업입니다.

3. 머티리얼라이즈드 뷰 — 데이터베이스 안의 계산 캐시

집계 결과처럼 "만드는 데 오래 걸리지만 자주 바뀌지 않는" 데이터에는 외부 캐시보다 머티리얼라이즈드 뷰가 나은 경우가 많습니다. 이유는 세 가지입니다. SQL로 조인할 수 있고, 인덱스를 걸 수 있고, 무효화 로직을 애플리케이션이 아니라 갱신 스케줄로 표현할 수 있습니다.

CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT tenant_id,
       date_trunc('day', created_at) AS sales_day,
       count(*)         AS order_count,
       sum(total_amount) AS total_amount
FROM orders
WHERE status = 'PAID'
GROUP BY 1, 2;

-- CONCURRENTLY 갱신에는 유일 인덱스가 필수다
CREATE UNIQUE INDEX uq_mv_daily_sales
  ON mv_daily_sales (tenant_id, sales_day);

경고: CONCURRENTLY 없는 REFRESH MATERIALIZED VIEWACCESS EXCLUSIVE 잠금을 잡습니다. 문서 표현으로 "많은 행에 영향을 주는 갱신은 자원을 덜 쓰고 더 빨리 끝나지만, 머티리얼라이즈드 뷰를 읽으려는 다른 연결을 막을 수 있습니다." 즉 갱신하는 몇 분 동안 그 뷰를 조회하는 모든 요청이 멈춥니다. 서비스가 읽는 뷰라면 반드시 CONCURRENTLY를 쓰세요.

CONCURRENTLY의 조건은 문서에 명시되어 있습니다. "이 옵션은 컬럼 이름만 사용하고 모든 행을 포함하는 UNIQUE 인덱스가 최소 하나 있을 때만 허용된다. 즉 표현식 인덱스이거나 WHERE 절을 포함해서는 안 된다." 그리고 "이미 채워져 있는 머티리얼라이즈드 뷰에만 사용할 수 있다"와 "이 옵션을 써도 하나의 머티리얼라이즈드 뷰에 대해 한 번에 하나의 REFRESH만 실행될 수 있다"도 함께 기억해야 합니다.

-- 읽기를 막지 않는 갱신
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;

문서는 성능 상충도 짚습니다. CONCURRENTLY는 "영향받는 행이 적을 때 더 빠를 수 있다"고 합니다. 즉 전체를 다시 계산하는 상황이라면 오히려 느립니다. 매일 전량 재계산하는 야간 배치라면 서비스가 조용한 시간에 CONCURRENTLY 없이 돌리는 편이 나을 수 있습니다. 판단 기준은 그 시간대에 그 뷰를 읽는 트래픽이 있는가입니다.

머티리얼라이즈드 뷰가 맞지 않는 경우도 분명합니다. 갱신 주기가 초 단위여야 하거나, 사용자별로 결과가 전부 다르거나, 결과가 SQL로 표현되지 않는 계산이라면 외부 캐시나 다른 구조가 필요합니다.

4. 외부 캐시를 도입하는 판단 기준

2절과 3절을 다 해 보고도 부족할 때 외부 캐시가 등장합니다. 도입 판단에 쓸 수 있는 기준입니다.

도입이 타당한 신호

  • 같은 키에 대한 조회가 압도적으로 반복된다(적중률이 높을 것이 예상된다)
  • 원본 계산이 비싸고 결과가 작다
  • 초 단위의 지연된 정합성이 도메인상 허용된다
  • 데이터베이스가 CPU나 I/O 한계에 실제로 닿아 있다

도입을 미뤄야 하는 신호

  • 조회 키가 매번 달라 적중률이 낮을 것으로 보인다
  • 쓰기가 잦아 무효화가 계속 발생한다
  • 옛 데이터 노출이 금전이나 안전과 연결된다
  • 아직 인덱스 튜닝이나 쿼리 재작성을 해 보지 않았다

특히 마지막 항목을 강조합니다. 캐시는 성능 문제를 해결하지 않고 이동시킵니다. 캐시가 비어 있는 순간(배포 직후, 캐시 노드 재시작, 대규모 무효화 직후)에는 모든 부하가 그대로 데이터베이스로 갑니다. 원본 쿼리가 감당 가능한 수준이 아니면 그 순간 서비스가 무너집니다. 캐시는 정상 상태의 최적화이지 장애 대비책이 아닙니다.

5. 무효화의 네 가지 실패 모드

여기가 이 글의 핵심입니다. 캐시 정합성이 깨지는 방식은 유형화할 수 있습니다.

실패 모드 1 — 갱신 후 삭제 사이의 창. 데이터베이스를 갱신하고 캐시를 삭제하는 사이에 다른 요청이 캐시를 읽으면 옛 값을 봅니다. 창은 짧지만 0은 아닙니다. 트래픽이 많으면 반드시 발생합니다.

실패 모드 2 — 읽기와 쓰기의 경쟁. 더 고약한 경우입니다. 요청 A가 캐시 미스로 데이터베이스에서 값 V1을 읽습니다. 그 직후 요청 B가 값을 V2로 갱신하고 캐시를 삭제합니다. 그다음 A가 뒤늦게 V1을 캐시에 씁니다. 결과적으로 캐시에는 옛 값 V1이 만료될 때까지 남습니다. 삭제가 쓰기보다 먼저 일어났기 때문에 삭제로도 지워지지 않습니다.

실패 모드 3 — 트랜잭션 롤백. 캐시를 먼저 지우고 데이터베이스를 갱신했는데 트랜잭션이 롤백되면, 캐시는 비었고 데이터는 옛 상태입니다. 다음 조회가 옛 값을 다시 캐시에 채우므로 결과적으로는 정합하지만, 그사이에 새 값을 본 사용자가 있으면 혼란이 생깁니다.

실패 모드 4 — 부분 실패. 데이터베이스 갱신은 성공했는데 캐시 삭제가 네트워크 오류로 실패합니다. 재시도하지 않으면 만료 시각까지 옛 값이 남습니다.

이 네 가지에 대한 실무적 대응은 다음과 같습니다.

  • 삭제하되 채우지 않는다. 쓰기 경로에서 캐시에 새 값을 넣지 말고 삭제만 합니다. 새 값을 넣는 순간 실패 모드 2의 경쟁에 참여하게 됩니다.
  • 삭제를 커밋 이후로 미룬다. 트랜잭션 안에서 캐시를 지우면 롤백 시 불일치가 생깁니다. 커밋 성공을 확인한 뒤 지웁니다. 아웃박스 테이블에 무효화 이벤트를 트랜잭션과 함께 기록하고 별도 워커가 처리하면 실패 모드 3과 4를 함께 막을 수 있습니다.
  • 만료 시간을 반드시 건다. 무효화가 실패해도 만료가 최종 방어선입니다. 만료 없는 캐시 엔트리는 언젠가 반드시 사고를 냅니다.
  • 지연 이중 삭제를 고려한다. 갱신 직후 한 번 지우고, 짧은 지연 후 한 번 더 지우는 방식입니다. 실패 모드 2의 늦게 도착한 쓰기를 걷어냅니다. 완벽하지는 않지만 비용 대비 효과가 좋습니다.

6. 쓰기 경로의 순서 문제

5절을 순서 문제로 다시 정리하면 선택지는 네 가지이고, 각각의 위험이 다릅니다.

순서위험
캐시 갱신 → DB 갱신DB 갱신 실패 시 캐시에 존재하지 않는 값이 남는다. 쓰면 안 된다
DB 갱신 → 캐시 갱신두 쓰기의 순서가 뒤바뀌면 옛 값이 캐시에 남는다
캐시 삭제 → DB 갱신삭제와 갱신 사이의 조회가 옛 값을 다시 채운다
DB 갱신(커밋) → 캐시 삭제가장 안전하다. 남는 위험은 삭제 실패와 짧은 경쟁 창

네 번째가 표준입니다. 나머지는 특별한 이유가 없으면 쓰지 않습니다.

그리고 반드시 지켜야 할 것 하나. 캐시 조작을 데이터베이스 트랜잭션 안에 넣지 마세요. 두 가지 이유가 있습니다. 첫째, 트랜잭션이 롤백되어도 캐시 조작은 되돌아가지 않습니다. 둘째, 캐시 서버의 응답을 기다리는 동안 데이터베이스 트랜잭션이 열려 있게 되고, 그 트랜잭션은 VACUUM이 죽은 행을 회수하지 못하게 막습니다. 네트워크 지연이 몇 초만 되어도 데이터베이스 전체에 영향을 줍니다.

-- 무효화 이벤트를 트랜잭션과 원자적으로 기록하는 아웃박스
CREATE TABLE cache_invalidation_outbox (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  cache_key   text        NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);

CREATE INDEX idx_cache_outbox_pending
  ON cache_invalidation_outbox (created_at)
  WHERE processed_at IS NULL;

워커는 4절의 큐 패턴, 즉 FOR UPDATE SKIP LOCKED로 이 테이블을 소비하면 됩니다. 이렇게 하면 "데이터베이스 커밋이 성공한 경우에만, 그리고 반드시 한 번은" 무효화가 일어납니다.

7. 캐시 스탬피드와 방어

인기 있는 키의 캐시가 만료되는 순간, 그 키를 조회하던 모든 요청이 동시에 캐시 미스를 겪고 전부 데이터베이스로 갑니다. 초당 수천 건의 동일 쿼리가 한꺼번에 도착합니다. 이것이 캐시 스탬피드입니다.

방어 수단은 세 가지입니다.

첫째, 만료 시각에 무작위 오프셋을 준다. 같은 시각에 만들어진 엔트리들이 같은 시각에 만료되지 않도록 흩뿌립니다. 가장 싸고 가장 효과적입니다.

둘째, 재계산을 한 요청만 하도록 잠근다. 미스가 난 요청 중 하나만 원본을 계산하고 나머지는 기다리거나 옛 값을 씁니다. 애플리케이션 프로세스가 여럿이면 분산 잠금이 필요한데, PostgreSQL의 트랜잭션 수준 어드바이저리 락이 이 용도로 쓸 만합니다.

-- 재계산 권한을 한 요청만 갖게 한다
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('recompute:daily_sales:42'));
-- true를 받은 요청만 무거운 집계를 수행하고 캐시에 채운다
COMMIT;

세션 수준이 아니라 트랜잭션 수준 함수를 쓰는 것이 중요합니다. 커넥션 풀 뒤에서 세션 수준 락은 해제되지 않고 남을 수 있습니다.

셋째, 만료 전에 미리 갱신한다. 남은 수명이 일정 비율 아래로 떨어지면 백그라운드에서 미리 다시 계산합니다. 사용자 요청은 항상 캐시에서 응답받습니다. 트래픽이 예측 가능한 대시보드류에 잘 맞습니다.

여기에 하나 더. 캐시 계층이 죽었을 때의 동작을 미리 정하세요. 캐시 서버가 응답하지 않으면 원본으로 폴백할 것인지, 폴백한다면 데이터베이스가 그 부하를 견딜 수 있는지, 못 견딘다면 일부 요청을 빠르게 거절할 것인지. 이 결정을 장애 중에 내리면 늦습니다.

8. 무엇을 측정할 것인가

캐시는 지표 없이 운영하면 이득도 손해도 보이지 않습니다. 최소한 다음 네 가지는 있어야 합니다.

적중률. 낮으면 캐시가 일을 못 하는 것이고, 지나치게 높으면(예: 99.99%) 애초에 부하가 없었을 가능성도 있습니다. 키 그룹별로 나눠 보는 것이 중요합니다. 전체 평균은 대개 아무것도 말해 주지 않습니다.

원본 쿼리의 실행 빈도. 캐시 도입 전후로 pg_stat_statementscalls가 얼마나 줄었는지 봅니다.

SELECT calls, round(total_exec_time::numeric, 1) AS total_ms,
       round(mean_exec_time::numeric, 2) AS mean_ms,
       rows, left(query, 70) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

pg_stat_statementsshared_preload_libraries에 등록하고 확장을 설치해야 동작합니다. pg_stat_statements.max의 기본값은 5000, track의 기본값은 top입니다.

무효화 지연. 데이터가 바뀐 시각과 캐시가 실제로 비워진 시각의 차이입니다. 아웃박스 테이블을 쓴다면 created_atprocessed_at의 차이로 바로 측정됩니다.

캐시 비었을 때의 원본 부하. 정기적으로(예: 배포 때마다) 캐시를 비운 직후의 데이터베이스 부하를 기록해 두세요. 이 값이 데이터베이스 용량에 근접해 있으면, 서비스는 캐시가 없으면 못 사는 상태입니다. 그 자체가 리스크로 관리되어야 합니다.

퀴즈: 실력을 확인해 보세요

퀴즈 1: 아래 코드의 문제는 무엇인가요?
BEGIN;
UPDATE products SET price = 19900 WHERE id = 42;
-- 애플리케이션이 여기서 Redis DEL products:42 호출
COMMIT;

정답: 캐시 삭제가 트랜잭션 안에 있습니다. 두 가지가 깨집니다.

설명: 첫째, COMMIT이 실패해 롤백되면 데이터는 옛 값인데 캐시는 비어 있습니다. 다음 조회가 옛 값을 다시 채우므로 최종적으로는 정합하지만, 그사이에 잘못된 상태가 존재합니다. 둘째, 더 심각하게, 캐시 서버 응답을 기다리는 동안 데이터베이스 트랜잭션이 열려 있습니다. 열린 트랜잭션은 데이터베이스 전체에서 죽은 행 회수를 막습니다. 캐시 서버가 느려지면 그 지연이 그대로 VACUUM 지연이 됩니다. 올바른 순서는 커밋을 먼저 완료하고 그다음 캐시를 지우는 것이며, 삭제 실패에 대비해 아웃박스 테이블에 무효화 이벤트를 트랜잭션과 함께 기록하는 방식이 가장 안전합니다.

퀴즈 2: 갱신할 때마다 캐시에 새 값을 넣는데도 가끔 옛 값이 보입니다. 왜일까요?

정답: 읽기와 쓰기의 경쟁 때문입니다. 캐시에 값을 채우는 대신 삭제만 해야 합니다.

설명: 요청 A가 캐시 미스로 데이터베이스에서 V1을 읽고, 그 직후 요청 B가 V2로 갱신하며 캐시에 V2를 씁니다. 그런데 A가 뒤늦게 자기가 읽은 V1을 캐시에 쓰면 최종 상태는 V1입니다. 만료될 때까지 옛 값이 남습니다. 쓰기 경로에서 캐시를 채우지 않고 삭제만 하면 다음 조회가 최신 값을 읽어 채우므로 이 경쟁의 창이 크게 줄어듭니다. 완전히 없어지지는 않으므로, 짧은 지연 후 한 번 더 지우는 지연 이중 삭제와 만료 시간을 함께 씁니다.

퀴즈 3: 대시보드용 집계 뷰를 REFRESH MATERIALIZED VIEW로 갱신하는데 갱신 중 대시보드가 멈춥니다.

정답: CONCURRENTLY가 빠졌습니다.

설명: CONCURRENTLY 없는 REFRESH MATERIALIZED VIEWACCESS EXCLUSIVE 잠금을 잡습니다. 이 잠금은 평범한 SELECT가 잡는 ACCESS SHARE와도 충돌하므로 조회가 전부 막힙니다. CONCURRENTLY를 쓰려면 문서가 명시한 조건을 만족해야 합니다. 컬럼 이름만 사용하고 모든 행을 포함하는 UNIQUE 인덱스가 최소 하나 있어야 하며, 표현식 인덱스이거나 WHERE 절을 포함해서는 안 됩니다. 또 이미 채워져 있는 뷰에만 쓸 수 있습니다.

CREATE UNIQUE INDEX uq_mv_daily_sales ON mv_daily_sales (tenant_id, sales_day);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;

다만 문서는 CONCURRENTLY가 "영향받는 행이 적을 때 더 빠를 수 있다"고 하므로, 전량 재계산이고 그 시간대에 조회 트래픽이 없다면 일반 갱신이 더 나은 선택입니다.

퀴즈 4: 캐시 적중률이 98%인데 데이터베이스 부하가 줄지 않았습니다. 어디를 봐야 할까요?

정답: 적중률을 키 그룹별로 나눠 보고, pg_stat_statements에서 실제로 어떤 쿼리가 부하를 만드는지 확인해야 합니다.

설명: 전체 평균 적중률은 오해를 부르기 쉽습니다. 가벼운 키가 대량으로 적중하면서 평균을 끌어올리고, 정작 무거운 쿼리는 캐시를 거치지 않고 있을 수 있습니다. 확인 순서는 이렇습니다. 먼저 pg_stat_statementstotal_exec_time 내림차순으로 보고 상위 쿼리가 무엇인지 봅니다. 그 쿼리가 캐시 대상이 아니었다면 캐시 대상 선정이 잘못된 것입니다. 캐시 대상인데도 호출이 많다면 무효화가 너무 잦은 것이고, 갱신 빈도와 캐시 키 설계를 다시 봐야 합니다. 키를 너무 잘게 나눠 하나의 갱신이 수백 개 키를 무효화하고 있는 경우가 흔합니다.

퀴즈 5: 배포할 때마다 배포 직후 몇 분간 데이터베이스 CPU가 100%를 칩니다.

정답: 캐시가 비워진 상태에서 모든 부하가 원본으로 몰리는 콜드 스타트 문제입니다.

설명: 캐시는 정상 상태의 최적화이지 용량 대책이 아닙니다. 캐시가 빈 순간의 부하를 견딜 수 없다면, 서비스는 캐시 없이는 못 사는 상태이고 이는 리스크로 관리되어야 합니다. 대응은 세 가지입니다. 첫째, 배포가 캐시를 비우지 않게 합니다. 캐시 키에 배포 버전을 넣는 관행이 흔한 원인입니다. 스키마가 실제로 바뀐 키에만 버전을 붙이세요. 둘째, 워밍업 단계를 배포 절차에 넣습니다. 트래픽을 받기 전에 상위 키들을 미리 채웁니다. 셋째, 스탬피드 방어를 겁니다. 만료 시각 무작위화와 트랜잭션 수준 어드바이저리 락으로 동일 키의 재계산을 하나로 제한하면 순간 부하가 크게 줄어듭니다.

마치며

캐시 설계의 난이도는 캐시를 채우는 쪽이 아니라 비우는 쪽에 있습니다. 그리고 비우는 문제는 분산 시스템의 순서 문제입니다. 두 개의 저장소에 대한 두 개의 쓰기가 있고, 그 순서를 보장할 방법이 없다는 것이 문제의 본질입니다. 패턴 이름을 외우는 것으로는 이 문제가 풀리지 않습니다.

실무적인 결론은 세 줄입니다. 캐시를 붙이기 전에 데이터베이스 안의 캐시 계층을 먼저 확인한다. 쓰기 경로에서는 커밋 후에 삭제만 하고 채우지 않는다. 무효화가 실패할 것을 전제로 만료 시간과 아웃박스를 함께 둔다.

집계 쿼리와 머티리얼라이즈드 뷰를 직접 실험해 보려면 Postgres 놀이터를, 분석형 워크로드의 집계 성능을 비교해 보려면 DuckDB 놀이터를 활용하세요.

참고 자료

이어서 읽기

The Complete Guide to Database Caching Strategy: In the End, It's All Invalidation

Introduction

This blog already has The Complete Guide to Redis Caching Strategies, an article that covers a catalog of patterns for how a cache and the system of record hand data back and forth — Cache-Aside, Read-Through, Write-Through, and Write-Behind.

This post looks at caching from a different angle: it works up from the database side, one existing layer at a time. The order matters, because before you ever decide to bolt an external cache on top, you first need to confirm that the cache layers PostgreSQL already gives you for free are actually being used properly. PostgreSQL already ships with its own shared_buffers, sits on top of the operating system's own page cache, and already has a computed cache available in the form of materialized views. If you pile Redis on top of all of that while these existing layers are sitting there idle and untuned, the underlying performance problem does not get solved at all — all you have done is create a brand-new consistency problem on top of the performance problem you started with.

And roughly half of this post is devoted entirely to invalidation, because the hard part of adopting a cache was never filling it — it is emptying it at exactly the right moment. You can memorize every pattern name in the catalog and still get this part wrong, and the moment you do, users see stale data.

The reference engine throughout is PostgreSQL 18, and every parameter default and every locking behavior cited in this post was confirmed directly against the PostgreSQL 18 documentation, not assumed from memory or from an older version.

1. What to Check Before You Add a Cache

When someone proposes "reads are slow, so let's just add a cache," there are three questions that need answering before anyone writes a line of code.

Question 1 — is the slowness coming from computation or from I/O? If it is slow because the same expensive result keeps getting produced over and over, a cache is genuinely the answer. If it is slow because there is no index and every query has to scan the entire table, a cache only postpones the problem instead of solving it — the same slow query runs all over again the moment the cache entry expires, and you are back where you started.

Question 2 — what does the read-to-write ratio actually look like? If reads overwhelmingly dominate, the hit rate stays high and invalidation happens rarely. If writes are frequent, the cache keeps getting invalidated over and over, the benefit shrinks toward nothing, and about all you have gained is a new source of consistency risk.

Question 3 — how long is it acceptable for users to see stale data? Whether the honest answer is a few seconds or several minutes changes the entire design. And if the honest answer is "never, under any circumstance," then what you need is a different solution entirely, not a cache.

Add a cache before you have answered these three questions, and sooner or later you will get a bug report asking "why do I sometimes see old values here?" — and you will not be able to find the root cause, because the real root cause is the missing analysis, not a bug in the code.

2. The Cache You Already Have — shared_buffers and the OS Cache

PostgreSQL uses its own internal buffer pool together with the operating system's page cache, and the two work together as a single two-layer system whether you think about them that way or not. Understanding this two-layer structure is what gives you your very first real caching optimization, before you have added a single new moving part to the system.

The default value of shared_buffers is 128MB. The documentation's tuning guidance is explicit: "If you have a dedicated database server with 1GB or more of RAM, a reasonable starting value for shared_buffers is 25% of the memory in your system. There are some workloads where even larger settings are effective, but because PostgreSQL also relies on the operating system cache, it is unlikely that an allocation of more than 40% of RAM will work better than a smaller amount."

There is a common misunderstanding that shows up here, over and over, in review after review. effective_cache_size does not allocate any memory at all, and never has. All it does is change the planner's assumptions about how much data is already cached. It is nothing more than an estimate of how much data is likely to be cached at any given moment — including the operating system's own cache, not just PostgreSQL's buffer pool — and its default value is 4GB. On a server with 256GB of memory, if this value is simply left at its out-of-the-box default, the planner will assume that an index scan is going to hit disk heavily, and it will lean toward a sequential scan instead, even in cases where an index scan would actually have been far cheaper.

Here's how you measure the hit rate.

-- Per-table buffer hit rate, lowest first
SELECT schemaname, relname,
       heap_blks_read, heap_blks_hit,
       CASE WHEN heap_blks_hit + heap_blks_read = 0 THEN NULL
            ELSE round(heap_blks_hit * 100.0 / (heap_blks_hit + heap_blks_read), 2)
       END AS heap_hit_pct,
       CASE WHEN idx_blks_hit + idx_blks_read = 0 THEN NULL
            ELSE round(idx_blks_hit * 100.0 / (idx_blks_hit + idx_blks_read), 2)
       END AS idx_hit_pct
FROM pg_statio_user_tables
ORDER BY heap_blks_read DESC
LIMIT 20;

One caveat is worth spelling out here. What "read" actually means in this view is a block that was not sitting in PostgreSQL's own buffer pool and therefore had to be requested from the operating system — it does not necessarily mean a block that hit physical disk. If that block happened to already be sitting in the OS page cache, there was no actual disk I/O involved at all. So if you judge disk load from this number alone, without accounting for the OS cache sitting underneath it, you will end up overestimating it, sometimes by a wide margin.

Since PostgreSQL 16, the pg_stat_io view gives you a considerably more accurate picture than pg_statio_user_tables can offer on its own. It breaks reads, hits, evictions, and fsyncs down separately by backend_type, object, and context, so you can actually tell what kind of activity generated the I/O in the first place. If you see a large number of entries where context is bulkread or vacuum, that is not evidence of a cache shortage at all — it is simply normal bulk activity doing exactly what it is supposed to do.

3. Materialized Views — The Computed Cache Inside the Database

For data that is "expensive to produce but does not change very often" — aggregate results are the obvious example — a materialized view is very often a better fit than reaching straight for an external cache. There are three separate reasons for this: you can join it directly in SQL just like any other table, you can put an index on it, and you can express the entire invalidation logic as a refresh schedule instead of scattering that logic through application code.

CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT tenant_id,
       date_trunc('day', created_at) AS sales_day,
       count(*)         AS order_count,
       sum(total_amount) AS total_amount
FROM orders
WHERE status = 'PAID'
GROUP BY 1, 2;

-- a unique index is required for CONCURRENTLY refreshes
CREATE UNIQUE INDEX uq_mv_daily_sales
  ON mv_daily_sales (tenant_id, sales_day);

Warning: Running REFRESH MATERIALIZED VIEW without CONCURRENTLY takes a full ACCESS EXCLUSIVE lock on the view. In the documentation's own words: "the update affecting many rows will use fewer resources and complete faster, but it may lock out other connections trying to read from the materialized view." In plain terms, every single request that tries to query that view simply stops dead for however many minutes the refresh takes to finish. If that view is read by anything in your production service, you must use CONCURRENTLY — there is no safe way around it.

The requirements for using CONCURRENTLY at all are spelled out precisely in the documentation: "This option may only be used if there is at least one UNIQUE index on the materialized view which uses only column names and includes all rows; that is, it must not be an expression index or include a WHERE clause." On top of that, you also need to remember that CONCURRENTLY "may only be used on a materialized view that has already been populated," and that even with this option turned on, "only one REFRESH may run at a time against any one materialized view" — it buys you concurrent reads during a refresh, not concurrent refreshes of the same view.

-- a refresh that doesn't block reads
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;

The documentation also calls out the performance trade-off honestly, rather than pretending CONCURRENTLY is free. It notes that CONCURRENTLY "may be faster in cases where a small number of rows are affected" — which, read the other way around, means that when you are recomputing the entire view from scratch, it can actually end up slower than a plain refresh would have been. For a nightly batch job that fully recomputes the view every single day regardless of what actually changed, running it without CONCURRENTLY during a quiet window may well be the better choice. The deciding factor is not a fixed rule but simply whether there is any traffic actually reading that view during that particular window.

There are also clear cases where a materialized view is simply the wrong tool for the job. If the refresh interval genuinely needs to be measured in seconds rather than minutes, if the results differ for every individual user rather than being shared across everyone, or if the computation cannot be expressed in SQL at all, you need an external cache or some other structure entirely — a materialized view will not stretch to cover it.

4. Criteria for Adopting an External Cache

An external cache only really enters the picture once you have done everything described in Sections 2 and 3 above and it still is not enough on its own. Here are the criteria you can actually use to make that call, rather than guessing.

Signals that adoption makes sense

  • Lookups against the same key repeat overwhelmingly (you can expect a high hit rate)
  • The original computation is expensive and the result is small
  • Seconds-scale eventual consistency is acceptable for the domain
  • The database is actually hitting its CPU or I/O limits

Signals that adoption should wait

  • The lookup key differs every time, so the hit rate looks likely to be low
  • Writes are frequent, so invalidation keeps firing
  • Exposing stale data has financial or safety consequences
  • You haven't tried index tuning or query rewriting yet

The last item on that second list deserves particular emphasis, more than any of the others. A cache does not actually solve a performance problem — it only relocates that problem somewhere else, and only temporarily. The moment the cache is empty for any reason at all (right after a deploy, a cache node restart, the aftermath of a large-scale invalidation), the full weight of the load goes straight through to the database with nothing standing in front of it anymore. If the underlying query cannot handle that load on its own, the service falls over at exactly that moment, with essentially no warning beforehand. A cache is an optimization for the steady state of normal operation, not a disaster-preparedness measure you can lean on when things already start going wrong.

5. The Four Failure Modes of Invalidation

This section is the heart of the post. The ways cache consistency actually breaks down in practice can be sorted into a small, fixed number of categories, and almost every incident you will ever see traces back to one of them.

Failure mode 1 — the window between updating the database and deleting the cache entry. If another request happens to read the cache during the gap between those two steps, it sees the old value, plain and simple. That window is short, often just milliseconds, but it is never actually zero. Given enough traffic passing through it, it will happen — not as some rare edge case, but as a near statistical certainty over time.

Failure mode 2 — a race between a read and a write. This one is nastier. Request A gets a cache miss and reads value V1 from the database. Immediately after that, request B updates the value to V2 and deletes the cache entry. Then A, arriving late with stale data in hand, writes V1 into the cache. The result: the old value V1 sits in the cache until it eventually expires on its own. Because the delete happened before A's late write ever arrived, that earlier delete does nothing to clear it — there is nothing left standing in the way by that point.

Failure mode 3 — a transaction that rolls back. If your code deletes the cache entry first and only afterward updates the database, and that transaction happens to roll back, the cache is left empty while the underlying data is still sitting in its old state. The next lookup will refill the cache with that old value, so the system does eventually become consistent again on its own — but if even one user saw a value in between that never actually became real, that is a confusing experience to explain after the fact.

Failure mode 4 — a partial failure. The database update succeeds without any trouble, but the cache delete fails on account of a network error, a timeout, or the cache node simply being unreachable at that particular moment. Without some kind of retry mechanism built in, the old value just sits there in the cache, completely unchanged, until it eventually expires.

Here is the practical, field-tested response to all four of these modes at once.

  • Delete, do not fill. On the write path, never put a freshly computed new value directly into the cache — only ever delete the existing entry. The instant you write a new value from the write path, you have made yourself a participant in the failure-mode-2 race described above, whether you meant to or not.
  • Defer the delete until after the commit, never before it. Deleting the cache entry from inside the transaction creates exactly the inconsistency described in failure mode 3 the moment that transaction rolls back. Delete only once you have confirmed the commit itself has actually succeeded. Recording the invalidation event in an outbox table as part of the very same transaction, and having a separate worker process that outbox afterward, guards against failure modes 3 and 4 simultaneously, with one single mechanism.
  • Always set an expiration, with no exceptions. Even when invalidation itself fails for whatever reason, expiration is still standing there as the last line of defense. A cache entry configured with no expiration at all will, sooner or later, cause a real production incident — that is not a risk you are taking, it is a guarantee you are making yourself.
  • Consider a delayed double-delete. Delete the entry once immediately after the update completes, and then delete it again a second time after a short delay has passed. This mops up exactly the kind of late-arriving stale write described in failure mode 2. It does not close the window completely, but the cost you pay for it is tiny relative to the risk it removes.

6. The Write-Path Ordering Problem

Reframing everything from Section 5 as a pure ordering problem gives you four possible options in total, and each one carries a different kind of risk.

OrderRisk
Update cache → update DBIf the DB update fails, a value that was never real is left in the cache. Do not use.
Update DB → update cacheIf the two writes get reordered, the old value stays in the cache.
Delete cache → update DBA lookup between the delete and the update refills the cache with the old value.
Update DB (commit) → delete cacheThe safest option. What's left is delete failure and a short race window.

The fourth pattern is the standard, and should be treated as the default. Do not reach for any of the other three unless you have a genuinely specific reason to.

And there is one rule on top of all this that you must never break: never put a cache operation inside a database transaction, under any circumstance. There are two separate reasons for this. First, if the transaction rolls back for any reason, the cache operation you already performed does not roll back along with it — the two systems simply do not share a rollback mechanism. Second, and more dangerously, while your code is waiting for the cache server to respond, the database transaction sits there open the entire time, and that open transaction blocks VACUUM from reclaiming dead rows anywhere in the database. Even a few seconds of unlucky network latency at that point ends up affecting the health of the entire database, not just the one table involved.

-- an outbox that records invalidation events atomically with the transaction
CREATE TABLE cache_invalidation_outbox (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  cache_key   text        NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);

CREATE INDEX idx_cache_outbox_pending
  ON cache_invalidation_outbox (created_at)
  WHERE processed_at IS NULL;

A worker can consume this table with the queue pattern from Section 4 — FOR UPDATE SKIP LOCKED. That way, invalidation happens "only when the database commit has actually succeeded, and always at least once."

7. Cache Stampedes and Defenses

The moment the cache entry for a genuinely popular key expires, every single request that happened to be looking up that key hits a cache miss at exactly the same instant and goes straight through to the database, all together. Thousands of identical queries per second can arrive all at once, in a single burst. This is what is known as a cache stampede.

There are three defenses worth having in place.

First, add a random offset to the expiration time. Spread expirations out so that entries created at roughly the same moment do not all expire at that same moment later on. This is by far the cheapest option available, and also one of the most effective.

Second, lock things down so that only one single request is allowed to recompute the value. Only one of the requests that missed actually performs the expensive computation; every other request either waits for it to finish or serves the stale value in the meantime. If you are running multiple application processes, you need some form of distributed lock for this, and PostgreSQL's transaction-level advisory locks work quite well for exactly this purpose.

-- grant recompute rights to only one request
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('recompute:daily_sales:42'));
-- only the request that gets true runs the heavy aggregation and fills the cache
COMMIT;

It genuinely matters that you use the transaction-level function here, and not the session-level one. Sitting behind a connection pool, a session-level lock can easily be left behind, unreleased, long after the connection that took it has moved on to serve someone else entirely.

Third, refresh ahead of expiration, before anyone actually needs to wait for it. Once the remaining TTL on an entry drops below some configured fraction of its original lifetime, recompute it in the background ahead of time, quietly, before it ever actually expires. User-facing requests are then always served straight from the cache, with nobody ever hitting a miss. This fits dashboard-style workloads with fairly predictable traffic patterns particularly well.

One more thing on top of all three of these. Decide in advance, on paper, what should happen when the entire cache layer dies outright. If the cache server stops responding altogether, do you fall back to the source of truth automatically? If you do fall back, can the database actually absorb that load without falling over itself? And if it cannot absorb it, do you instead fail some requests fast on purpose, rather than let everything queue up and time out? Making this decision for the first time in the middle of a live incident is already too late to matter.

8. What to Measure

Run a cache without any metrics attached to it, and you simply cannot see whether it is actually helping or quietly hurting you. At an absolute minimum, you need the following four numbers in front of you at all times.

Hit rate. A low rate means the cache isn't earning its keep; an excessively high rate (say, 99.99%) can mean there wasn't much load to begin with. Breaking it down by key group matters — an overall average usually tells you nothing.

How often the original query runs. Look at how much calls in pg_stat_statements dropped before versus after adopting the cache.

SELECT calls, round(total_exec_time::numeric, 1) AS total_ms,
       round(mean_exec_time::numeric, 2) AS mean_ms,
       rows, left(query, 70) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

pg_stat_statements only works once you register it in shared_preload_libraries and install the extension. The default for pg_stat_statements.max is 5000, and the default for track is top.

Invalidation lag. The gap between when the data changed and when the cache was actually cleared. If you're using an outbox table, this is measured directly as the difference between created_at and processed_at.

Load on the source right after the cache is empty. Record the database load right after the cache is cleared, on a regular basis — every deploy, for example. If that number is close to the database's capacity, the service can't survive without the cache. That fact needs to be managed as a risk in its own right.

Quiz: Test Your Understanding

Quiz 1: What's wrong with the code below?
BEGIN;
UPDATE products SET price = 19900 WHERE id = 42;
-- the application calls Redis DEL products:42 here
COMMIT;

Answer: The cache delete is inside the transaction. Two things break.

Explanation: First, if COMMIT fails and rolls back, the data is still the old value while the cache is empty. The next lookup refills the old value, so it's eventually consistent, but a wrong state exists in the meantime. Second, and more seriously, the database transaction stays open while you wait for the cache server to respond. An open transaction blocks dead-row reclamation across the entire database. If the cache server slows down, that delay becomes VACUUM delay, directly. The correct order is to complete the commit first and delete the cache afterward, and the safest approach — in case the delete itself fails — is to record the invalidation event in an outbox table within the same transaction.

Quiz 2: Every update writes a new value into the cache, and yet stale values show up sometimes. Why?

Answer: Because of a race between a read and a write. You should only delete the cache entry, not fill it.

Explanation: Request A gets a cache miss and reads V1 from the database. Immediately after, request B updates to V2 and writes V2 into the cache. But then A, arriving late, writes the V1 it read into the cache, and the final state is V1. The old value sits there until it expires. If the write path only deletes instead of filling the cache, the next lookup reads the latest value and refills it, which shrinks this race window considerably. It doesn't eliminate it completely, so pair it with delayed double-delete — deleting again after a short delay — and an expiration time.

Quiz 3: You refresh a dashboard's aggregate view with REFRESH MATERIALIZED VIEW, and the dashboard freezes while it refreshes.

Answer: CONCURRENTLY is missing.

Explanation: REFRESH MATERIALIZED VIEW without CONCURRENTLY takes an ACCESS EXCLUSIVE lock. That lock conflicts even with the ACCESS SHARE lock a plain SELECT takes, so every query gets blocked. To use CONCURRENTLY, you need to satisfy the conditions the documentation specifies: there must be at least one UNIQUE index that uses only column names and covers all rows, and it must not be an expression index or include a WHERE clause. It also only works on a view that's already been populated.

CREATE UNIQUE INDEX uq_mv_daily_sales ON mv_daily_sales (tenant_id, sales_day);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;

That said, the documentation notes that CONCURRENTLY "may be faster in cases where a small number of rows are affected," so if you're recomputing the whole thing and there's no query traffic during that window, a plain refresh is the better choice.

Quiz 4: The cache hit rate is 98%, but database load hasn't dropped. Where should you look?

Answer: Break the hit rate down by key group, and check pg_stat_statements to see which queries are actually generating the load.

Explanation: An overall average hit rate is easy to misread. Lightweight keys hitting in bulk can pull the average up while the genuinely heavy queries never go through the cache at all. Here's the order to check things in. First, look at pg_stat_statements sorted by total_exec_time descending and see what the top queries are. If one of those queries was never a caching target, your selection of what to cache was wrong. If it is a caching target and still gets called a lot, invalidation is firing too often, and you need to revisit the update frequency and the cache-key design. A common cause is slicing keys too finely, so that a single update invalidates hundreds of keys at once.

Quiz 5: Every deploy is followed by several minutes of the database CPU pegged at 100%.

Answer: It's a cold-start problem — the cache is empty and all the load piles onto the source of truth.

Explanation: A cache is an optimization for the steady state, not a capacity plan. If the service can't survive the load at the instant the cache is empty, it means the service can't live without the cache, and that needs to be managed as a risk. There are three responses. First, stop deploys from emptying the cache — baking the deploy version into the cache key is a common cause of this, so only version the keys whose schema actually changed. Second, add a warm-up phase to the deploy procedure: prefill the top keys before traffic starts flowing. Third, apply stampede defenses: randomizing expiration times and using transaction-level advisory locks to limit recomputation of the same key to a single request cuts the momentary load significantly.

Conclusion

The real difficulty in cache design was never on the filling side — it has always lived on the emptying side. And the emptying problem, underneath all the pattern names, is fundamentally a distributed-systems ordering problem. There are two separate writes going to two different stores, and there is no mechanism available anywhere that guarantees the order those two writes land in — that is the actual essence of the problem, and it does not go away no matter which framework or library you reach for. Memorizing pattern names does not solve it, and never will.

The practical takeaway fits in three lines, and is worth committing to memory: Before adding any cache at all, check the cache layers already sitting inside the database. On the write path, delete after commit and never fill. Assume invalidation will eventually fail, and pair expiration times with an outbox as a matter of course, not as an afterthought.

To experiment with aggregate queries and materialized views yourself, use the Postgres Playground; to compare aggregation performance on analytical workloads, try the DuckDB Playground.

References

Further Reading