캐시는 정답지가 아니라 복사본이다
캐시는 자주 읽는 데이터를 더 가까운 곳에 두는 기술이다. 애플리케이션 메모리, Redis, Memcached, CDN, 브라우저 캐시가 모두 이 범주에 들어간다. 목적은 단순하다. 느린 계산, 네트워크 왕복, 데이터베이스 조회를 줄여 응답 시간을 낮추는 것이다.
하지만 캐시는 대부분 원본이 아니다. 원본은 보통 관계형 데이터베이스나 문서형 데이터베이스에 있고, 캐시는 그 값을 복사해 둔 공간이다. 그래서 캐시를 도입하면 성능 문제는 줄어들 수 있지만 새로운 질문이 생긴다.
"이 복사본은 지금도 맞는가?"
캐시가 틀린 값을 담고 있으면 시스템은 더 빠르게 틀린 응답을 돌려준다. 상품 가격, 재고, 권한, 결제 상태처럼 신선도가 중요한 데이터에서는 이 문제가 성능 이득보다 더 큰 장애가 된다. 캐시는 빠르게 만드는 기술이지만, 잘못 쓰면 오래된 데이터를 빠르게 보여주는 장치가 된다.
AI 생성 이미지
캐시 패턴은 읽기와 쓰기 흐름을 정하는 일이다
캐시 패턴은 "어디를 먼저 읽고, 어디를 먼저 쓰며, 실패하면 무엇을 신뢰할 것인가"를 정하는 약속이다. 이름보다 중요한 것은 실제 흐름이다.
| 패턴 | 동작 방식 | 적합한 상황 | 주의할 점 |
|---|---|---|---|
| Cache-Aside | 애플리케이션이 캐시를 먼저 조회한다. 없으면 DB에서 읽고 캐시에 저장한 뒤 응답한다. | 가장 일반적인 방식. 읽기 빈도가 높고 일부 데이터만 자주 조회될 때 | 첫 조회는 느리다. 쓰기 후 캐시를 지우거나 갱신하지 않으면 오래된 값을 읽는다. |
| Read-Through | 애플리케이션은 캐시에 요청한다. 캐시 계층이 미스 시 DB에서 읽어 캐시를 채운다. | 캐시 로직을 중앙화하고 싶을 때 | 캐시 계층이 데이터 접근 경로의 핵심이 된다. 구현과 인프라가 더 복잡하다. |
| Write-Through | 쓰기 요청이 들어오면 DB와 캐시를 동기적으로 함께 갱신한 뒤 성공을 반환한다. | 쓰기 직후 읽기 신선도가 중요하고 쓰기량이 감당 가능할 때 | 쓰기 지연이 커진다. DB 또는 캐시 중 하나만 성공한 부분 실패를 처리해야 한다. |
| Write-Behind | 먼저 캐시나 버퍼에 쓰고, DB 반영은 비동기로 나중에 수행한다. | 매우 높은 쓰기 처리량이 필요하고 지연 반영을 허용할 수 있을 때 | 캐시 장애나 비동기 작업 실패 시 데이터 손실 또는 반영 지연이 생길 수 있다. |
Cache-Aside는 실무에서 가장 많이 만나는 방식이다. 예를 들어 상품 상세 조회 API는 product:{id} 키로 Redis를 먼저 조회한다. 값이 있으면 바로 반환하고, 없으면 DB에서 상품을 읽어 Redis에 저장한다. AWS도 이를 lazy loading 방식으로 설명하며, 실제로 요청된 데이터만 캐시에 들어가기 때문에 비용을 줄이기 쉽다고 설명한다.1
Read-Through는 비슷해 보이지만 책임 위치가 다르다. Cache-Aside에서는 애플리케이션 코드가 DB 조회와 캐시 저장을 직접 한다. Read-Through에서는 캐시 계층이 그 일을 대신한다. 덕분에 애플리케이션 코드는 단순해질 수 있지만, 캐시 계층에 장애가 나면 DB가 살아 있어도 읽기 경로가 막힐 수 있다.
Write-Through는 쓰기 직후 캐시가 최신일 가능성을 높인다. 예를 들어 관리자 페이지에서 상품명을 바꿀 때 DB와 캐시를 함께 갱신한다. 이후 사용자 조회는 캐시에서 새 상품명을 읽는다. 그러나 모든 쓰기가 캐시 갱신까지 기다려야 하므로 쓰기 지연이 늘어난다. 자주 바뀌지만 거의 읽히지 않는 데이터까지 캐시에 들어가는 낭비도 생긴다.2
Write-Behind는 가장 빠른 쓰기를 제공하지만 가장 조심해야 한다. 요청은 캐시나 버퍼에 기록한 시점에 성공으로 처리되고, DB 저장은 백그라운드에서 일어난다. 로그, 통계 카운터, 클릭 이벤트처럼 일부 지연이나 손실을 감당할 수 있는 데이터에는 쓸 수 있다. 반대로 주문 생성, 결제 승인, 포인트 차감처럼 원본 정합성이 중요한 작업에는 위험하다.
TTL은 안전장치이지 정합성 보장이 아니다
TTL(Time To Live)은 캐시 항목이 살아 있는 시간을 뜻한다. product:123을 5분 TTL로 저장하면 5분 뒤 캐시에서 사라지고, 다음 요청은 다시 DB를 읽는다.
TTL은 오래된 데이터가 영원히 남는 일을 막는다. 하지만 TTL만으로 정합성을 보장할 수는 없다. 가격이 변경된 직후에도 기존 캐시의 TTL이 4분 남아 있다면 사용자는 그 4분 동안 예전 가격을 볼 수 있다. 그래서 TTL은 "최대 얼마 동안 오래된 값을 허용할 것인가"라는 업무 판단에 가깝다.
데이터별 TTL은 다르게 잡아야 한다.
서비스 설정, 약관 버전, 지역 코드처럼 거의 바뀌지 않는 데이터: 긴 TTL 가능
상품 상세, 게시글, 사용자 프로필처럼 변경은 있지만 약간의 지연을 허용할 수 있는 데이터: 중간 TTL과 무효화 조합
재고, 잔액, 권한, 결제 상태처럼 틀리면 안 되는 데이터: 짧은 TTL만으로 해결하려 하지 말고 원본 조회, 조건부 업데이트, 트랜잭션을 우선 고려
캐시 무효화는 캐시를 삭제하거나 최신 값으로 바꾸는 작업이다. Cache-Aside에서는 보통 쓰기 성공 후 관련 캐시를 삭제한다.
1. DB의 상품명 변경
2. product:123 캐시 삭제
3. 다음 조회에서 DB를 읽고 캐시 재생성삭제가 갱신보다 안전한 경우가 많다. 갱신은 새 값을 정확히 만들어 넣어야 하지만, 삭제는 다음 읽기가 원본에서 다시 채우게 만든다. 특히 여러 API가 같은 캐시 키를 공유하거나 응답에 조인·집계 결과가 포함될 때는 "캐시를 직접 새 값으로 수정"하는 코드가 버그를 만들기 쉽다.
키 설계는 장애 범위를 정한다
캐시 키는 단순한 문자열이 아니다. 어떤 데이터를 같은 단위로 저장하고 지울지 정하는 설계다.
좋은 키는 의미가 분명해야 한다.
user:42:profile
product:123:detail:v2
category:10:products:sort=popular:page=1키에는 필요한 조회 조건이 포함되어야 한다. 같은 카테고리 목록이라도 정렬 기준, 페이지, 언어, 로그인 여부에 따라 결과가 다르면 키도 달라야 한다. 조건을 빠뜨리면 한 사용자를 위해 만든 결과를 다른 사용자에게 보여줄 수 있다. 이는 성능 문제가 아니라 보안 문제가 될 수 있다.
반대로 키를 너무 잘게 쪼개면 무효화가 어려워진다. 상품이 변경될 때 product:123:detail 하나만 지우면 되는 구조와, 여러 목록·추천·검색 결과에 흩어진 구조는 운영 난이도가 다르다. 목록 캐시는 특히 조심해야 한다. 게시글 하나가 수정되어도 최신순 목록, 인기순 목록, 태그별 목록, 작성자별 목록이 모두 영향을 받을 수 있다.
실무에서는 다음 중 하나를 선택한다.
상세 데이터는 적극적으로 캐시하고, 목록은 짧은 TTL만 둔다.
목록 캐시는 버전 키를 둔다. 예:
category:10:version값을 올려 새 키 공간을 사용한다.중요한 목록은 캐시하지 않고 인덱스와 쿼리 최적화로 해결한다.
정답은 데이터 변경 빈도와 신선도 요구에 따라 달라진다.
캐시가 몰릴 때 생기는 세 가지 문제
캐시는 평균 응답 시간을 줄이지만, 트래픽이 몰리는 순간에는 오히려 DB를 더 세게 때릴 수 있다.
첫째, 캐시 스탬피드다. 인기 상품 캐시가 만료되는 순간 수천 개 요청이 동시에 DB로 향한다. 모두가 "캐시가 없네?"라고 판단하고 같은 데이터를 다시 계산하거나 조회한다. 해결 방법은 여러 가지다.
TTL에 작은 랜덤 값을 섞어 동시에 만료되지 않게 한다.
한 요청만 재생성하도록 분산 락이나 single flight 기법을 쓴다.
만료 직전 백그라운드에서 미리 갱신한다.
오래된 값을 잠시 제공하면서 비동기로 새 값을 만든다.
둘째, 핫 키다. 하나의 키에 요청이 집중되어 Redis의 특정 노드나 CPU가 병목이 된다. 실시간 인기 랭킹, 대형 이벤트 상품, 메인 배너 설정에서 자주 발생한다. 읽기 복제, 로컬 메모리 캐시, 키 분산, 응답 조각 캐싱으로 완화할 수 있다. 단, 로컬 캐시를 쓰면 서버마다 값이 달라질 수 있으므로 TTL을 짧게 두거나 무효화 이벤트를 함께 설계해야 한다.
셋째, 캐시 관통이다. 존재하지 않는 데이터를 계속 조회해 캐시를 지나 DB까지 도달하는 문제다. 예를 들어 공격자가 임의의 상품 ID를 계속 요청하면 매번 캐시 미스가 나고 DB 조회가 발생한다. 이때는 "없음"도 짧게 캐시하는 negative caching을 사용할 수 있다. 또한 ID 형식 검증, 요청 제한, Bloom filter 같은 사전 필터를 조합할 수 있다.
DB와 캐시 업데이트 순서는 항상 위험하다
가장 흔한 불일치는 DB와 캐시를 둘 다 수정하는 순간에 생긴다.
예를 들어 상품 가격을 10,000원에서 12,000원으로 바꾼다고 하자.
1. DB 가격을 12,000원으로 변경
2. 캐시 product:123 삭제 실패
3. 사용자는 캐시에서 10,000원을 계속 조회순서를 바꿔도 문제가 사라지지 않는다.
1. 캐시 product:123 삭제
2. 다른 요청이 DB의 예전 가격 10,000원을 읽어 캐시에 저장
3. DB 가격을 12,000원으로 변경
4. 캐시에는 다시 10,000원이 남음이런 문제를 완전히 없애려면 분산 트랜잭션이 필요할 수 있지만, 대부분의 서비스는 그 비용을 감당하지 않는다. 대신 위험을 줄이는 방식을 쓴다.
원본은 DB라는 원칙을 명확히 한다.
쓰기 성공 후 캐시 삭제를 기본값으로 둔다.
삭제 실패는 로그만 남기지 말고 재시도 큐에 넣는다.
TTL을 함께 둬 삭제 누락이 영구 장애가 되지 않게 한다.
중요한 읽기는 캐시를 우회하거나 DB 검증을 추가한다.
이벤트 기반 무효화를 쓸 때는 이벤트 유실과 중복 처리를 고려한다.
특히 권한과 결제 상태는 캐시만 믿으면 위험하다. "관리자 권한 제거 후에도 10분 동안 관리자 기능 사용 가능" 같은 문제는 짧은 TTL로도 받아들이기 어렵다. 이런 데이터는 요청마다 DB를 보거나, 권한 버전 값을 함께 비교하거나, 매우 짧은 TTL과 강제 무효화를 조합해야 한다.
무엇을 캐시하지 않을지도 설계해야 한다
캐시는 모든 느린 쿼리의 답이 아니다. 먼저 인덱스가 적절한지, 쿼리가 불필요하게 많은 행을 읽는지, N+1 쿼리가 있는지 확인해야 한다. 원본 조회가 비효율적인 상태에서 캐시를 얹으면 장애가 늦게 드러날 뿐이다.
캐시에 적합한 데이터는 보통 다음 조건을 가진다.
읽기 빈도가 높다.
같은 요청이 반복된다.
값이 자주 바뀌지 않는다.
약간의 오래된 값을 허용할 수 있다.
DB 조회나 계산 비용이 크다.
반대로 다음 데이터는 신중해야 한다.
잔액, 재고, 포인트처럼 변경과 검증이 함께 필요한 값
사용자별 민감 정보처럼 키 실수가 보안 사고로 이어지는 값
한 번 쓰고 거의 읽지 않는 값
조건 조합이 너무 많아 캐시 적중률이 낮은 목록
오래된 값이 비즈니스 손실로 바로 이어지는 값
캐시 도입 전에는 세 가지를 먼저 정해야 한다.
원본은 어디인가?
얼마나 오래된 값을 허용할 수 있는가?
캐시 삭제나 갱신이 실패하면 어떻게 복구할 것인가?
이 질문에 답하지 못한 캐시는 성능 최적화가 아니라 미래의 장애 원인이다. 좋은 캐시 설계는 빠른 응답만 목표로 하지 않는다. 틀릴 수 있는 범위를 작게 만들고, 틀렸을 때 자동으로 회복되게 만든다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.