MOM.SOFT

MOMSOFT  /  기술 블로그

Database

Redis를 캐시로 쓸 때 실제로 겪는 문제들

캐시는 붙이는 순간이 아니라 만료되고 지워지는 순간에 무너집니다. 스탬피드·TTL·관통·직렬화·무효화를 실무 판단 기준으로 정리했습니다.

프로젝트 문의하기 →

결론부터 말하면, Redis 캐시에서 사고가 나는 지점은 거의 항상 읽기가 아니라 만료와 무효화입니다. 캐시를 붙이는 코드는 애노테이션 한 줄이면 끝나지만, 그 캐시가 언제 사라지고 누가 다시 채우며 사라진 사이에 무슨 값이 보이는지는 아무도 설계하지 않은 채로 운영에 들어갑니다. 그래서 평소에는 잘 돌다가, 트래픽이 몰리거나 배포를 하거나 데이터 모델이 바뀌는 순간에 한꺼번에 터집니다.

이 글은 Redis를 조회 캐시(look-aside cache)로 쓰는 상황을 전제로, 실제로 반복해서 겪는 다섯 가지 문제를 다룹니다. 캐시 스탬피드, TTL 설계와 만료 몰림, 없는 값과 뜨거운 키, 직렬화 포맷, 무효화 시점입니다. 마지막으로 캐시를 아예 붙이면 안 되는 데이터와, 캐시 도입 자체를 미뤄야 하는 상황을 정리합니다.

1. 캐시 스탬피드: 캐시가 원본을 죽이는 순간

look-aside 캐시의 기본 흐름은 단순합니다. 캐시를 읽고, 없으면 DB에서 읽어서 캐시에 넣고 반환합니다. 문제는 이 "없으면"이 동시에 여러 스레드에서 발생할 때입니다.

인기 있는 키 하나가 만료되는 순간을 생각해 봅시다. 그 키를 초당 수백 번 조회하고 있었다면, 만료 직후 도착한 요청들은 전부 캐시 미스가 됩니다. 각 요청은 자기가 원본을 조회해서 캐시를 채워야 한다고 판단하고, 결과적으로 동일한 쿼리가 한꺼번에 DB로 쏟아집니다. 이것이 캐시 스탬피드(cache stampede), 또는 thundering herd라고 부르는 현상입니다.

여기서 무서운 것은 되먹임입니다. DB가 밀리면 원본 조회가 느려지고, 조회가 느려지는 동안 캐시는 여전히 비어 있으니 미스가 계속 쌓이고, 쌓인 요청이 다시 DB를 누릅니다. 캐시를 붙여서 DB 부하를 줄이려던 시스템이 캐시 때문에 평소보다 더 큰 순간 부하를 만드는 구조가 됩니다. 캐시 히트율이 높을수록, 다시 말해 캐시가 잘 동작하고 있을수록 만료 순간의 낙차가 커집니다.

방어 1 — 재계산 잠금

가장 직관적인 방어는 "원본을 조회할 권리를 한 명에게만 준다"입니다. Redis에서는 SET key value NX PX ttl로 만든 짧은 수명의 잠금 키를 씁니다.

// 잠금 획득: 성공한 스레드만 원본을 조회한다
Boolean got = redis.opsForValue()
    .setIfAbsent("lock:" + key, token, Duration.ofSeconds(5));

if (Boolean.TRUE.equals(got)) {
    try {
        T value = loadFromDb();           // 단 한 번만 수행
        redis.opsForValue().set(key, value, ttlWithJitter());
        return value;
    } finally {
        unlockIfMine(key, token);         // Lua로 소유자 확인 후 삭제
    }
}
// 실패한 스레드는 DB로 가지 않는다

잠금 값에 무작위 토큰을 넣고 해제할 때 Lua로 "내 토큰일 때만 삭제"를 확인하는 이유는 명확합니다. 잠금 TTL보다 원본 조회가 오래 걸리면 잠금이 먼저 풀리고, 그 사이 다른 스레드가 새로 잡은 잠금을 앞선 스레드가 지워 버리기 때문입니다. 그래서 잠금 TTL은 원본 조회의 꼬리 지연보다 넉넉하게 잡아야 합니다. 다만 무한정 길게 잡으면 잠금을 쥔 인스턴스가 죽었을 때 그 키의 갱신이 TTL이 끝날 때까지 멈춥니다. 원본 조회 시간의 상한을 알 수 없다면, 잠금 TTL을 그 조회의 타임아웃과 같은 값으로 맞추는 것이 그나마 근거 있는 선택입니다.

잠금을 놓친 나머지 스레드를 어떻게 처리할지가 실제 설계 포인트입니다. 선택지는 세 가지입니다.

주의할 점이 하나 있습니다. Spring Cache의 @Cacheable(sync = true)는 캐시 구현체의 get(key, valueLoader)에 위임하는 방식이고, 일반적인 구현에서는 같은 JVM 안에서만 동시 계산을 막습니다. 인스턴스가 여러 대면 인스턴스 수만큼 원본 조회가 나갑니다. 게다가 잠금 범위가 키 단위가 아니라 캐시 인스턴스 단위인 구현도 있어서, 한 키의 느린 조회가 같은 캐시의 다른 키 조회까지 붙잡을 수 있습니다. 쓰기 전에 사용하는 캐시 구현의 동작을 직접 확인하십시오. 인스턴스가 서너 대이고 원본 조회가 가벼우면 그것으로 충분할 수도 있지만, 수십 대로 스케일아웃한 상태에서는 분산 잠금이 필요합니다. 어느 쪽을 쓸지는 인스턴스 수와 원본 조회 비용의 곱으로 판단하면 됩니다.

방어 2 — 확률적 조기 재계산

잠금은 만료가 이미 일어난 뒤에 수습하는 방식입니다. 반대로, 만료가 일어나기 전에 미리 갱신해 버리면 미스 자체가 발생하지 않습니다.

핵심 아이디어는 이렇습니다. 캐시 값과 함께 이 값을 만드는 데 걸린 시간만료 예정 시각을 같이 저장합니다. 읽을 때마다 난수를 뽑아, 만료 시각에 가까워질수록 그리고 재계산 비용이 클수록 높은 확률로 "아직 유효하지만 지금 갱신한다"를 선택합니다. 대부분의 요청은 캐시 값을 그대로 쓰고 극소수의 요청만 백그라운드 갱신을 트리거하므로, 만료 순간의 절벽이 완만한 경사로 바뀝니다.

// entry.delta   : 지난번 원본 조회에 걸린 시간(ms)
// entry.expireAt: 논리적 만료 시각
// beta          : 클수록 더 일찍 갱신 (보통 1 근처에서 시작)
boolean early = now - entry.delta * beta * Math.log(random())
                >= entry.expireAt;
if (early) refreshAsync(key);
return entry.value;

Math.log(random())은 음수이므로 결과적으로 만료 시각을 앞당겨 판정하는 식이고, 재계산이 오래 걸린 키일수록 더 일찍 갱신됩니다. 이 방식은 재계산이 무겁고 조회가 잦은 소수의 키에 잘 맞습니다. 반대로 키가 수백만 개이고 각각은 가끔 조회되는 형태라면 이득이 거의 없습니다. 갱신 자체가 비동기이므로 갱신 작업을 넣는 스레드 풀에 반드시 상한을 두어야 하고, 큐가 가득 차면 갱신을 버리고 옛 값을 계속 쓰는 쪽이 안전합니다. 계산이 무거운 상위 몇 개 키에만 선택적으로 적용하는 편이 현실적입니다.

방어 3 — 논리적 만료

물리 TTL을 넉넉히 길게 잡거나 아예 두지 않고, 값 안에 만료 시각 필드를 넣어 애플리케이션이 판단하게 하는 방식입니다.

{"expireAt": 1730000000000, "data": { ... }}

읽을 때 expireAt이 지났으면 일단 옛 값을 반환하고, 잠금을 잡은 한 스레드만 비동기로 갱신합니다. 캐시 미스가 구조적으로 사라지므로 스탬피드도 사라집니다. 대가는 두 가지입니다. 첫째, 사용자가 잠시 옛 값을 봅니다. 둘째, 물리 TTL이 길거나 없으므로 메모리를 스스로 반납하지 않습니다. 쓰지 않는 키가 계속 쌓이면 결국 축출 정책에 의존하게 되는데, maxmemory-policyvolatile-lru처럼 TTL이 설정된 키만 대상으로 하는 값이면 TTL 없는 키는 축출 대상에서 아예 빠져 메모리가 차오릅니다. 논리적 만료를 쓸 때는 물리 TTL을 논리 TTL의 몇 배로 함께 걸어 두는 편이 안전합니다.

2. TTL 설계와 만료 몰림

TTL은 대개 "적당히 5분" 같은 식으로 정해집니다. 그런데 TTL은 두 가지 서로 다른 질문에 동시에 답하는 값입니다. 얼마나 오래된 데이터를 보여줘도 되는가(정합성)와 얼마나 오래 메모리를 점유해도 되는가(용량)입니다. 이 둘을 구분하지 않으면 어느 쪽도 만족시키지 못합니다.

같은 시각에 태어난 키는 같은 시각에 죽는다

배포 직후를 생각해 봅시다. 캐시가 비어 있는 상태에서 트래픽이 들어오면 수많은 키가 거의 동시에 채워집니다. TTL이 전부 동일하면 그 키들은 거의 같은 시각에 한꺼번에 만료됩니다. 배포 시점을 기준으로 TTL 주기마다 DB 부하가 톱니처럼 튀는 그래프가 나오면 이 문제입니다. 배치 작업이 캐시를 미리 채우는 워밍업 구조에서도 똑같이 발생합니다.

해결은 간단합니다. TTL에 무작위 지터를 더합니다.

Duration ttlWithJitter(Duration base) {
    long ms = base.toMillis();
    long jitter = (long) (ms * 0.2 * ThreadLocalRandom.current().nextDouble());
    return Duration.ofMillis(ms + jitter);   // base ~ base*1.2 사이로 분산
}

지터 폭은 기준 TTL의 10~20% 정도면 대개 충분합니다. 중요한 것은 폭의 정확한 값이 아니라, 동시에 생성된 키들의 만료 시각이 서로 다르다는 사실 자체입니다. 다만 지터는 위로만 더하십시오. 아래로 빼면 "최대 이만큼 낡을 수 있다"는 약속은 지켜지지만 예상보다 히트율이 떨어지고, 위로 더하면 정합성 상한이 그만큼 늘어나니 아래 1번에서 합의한 허용치 안에서 지터 폭을 정해야 합니다.

TTL을 정하는 순서

TTL을 감으로 정하지 않으려면 이 순서로 물어보면 됩니다.

  1. 이 데이터가 최대 몇 초까지 낡아도 업무상 문제가 없는가. 기획이나 현업에게 물어야 하는 질문입니다. 개발자가 혼자 정할 값이 아닙니다.
  2. 원본이 변경될 때 무효화 이벤트를 잡을 수 있는가. 잡을 수 있다면 TTL은 정합성 장치가 아니라 안전망이 되므로 훨씬 길게 잡아도 됩니다. 잡을 수 없다면 TTL이 유일한 정합성 수단이므로 1번의 답을 그대로 써야 합니다.
  3. 전체 키 개수 곱하기 평균 값 크기가 메모리에 들어가는가. 들어가지 않으면 TTL을 줄이는 게 아니라 캐시 대상을 줄여야 합니다. TTL로 용량 문제를 해결하려 들면 히트율만 떨어집니다.

만료는 정확한 시각에 일어나지 않습니다

Redis의 만료는 TTL이 지나는 순간 정확히 메모리가 회수되는 방식이 아닙니다. 키에 접근할 때 지워지는 지연 삭제와, 만료 후보를 표본으로 뽑아 지우는 주기적 검사를 함께 씁니다. 따라서 "TTL을 짧게 걸었으니 메모리는 알아서 유지되겠지"라는 가정은 위험합니다. 논리적으로 만료된 키가 실제 메모리를 얼마간 더 붙들고 있을 수 있으므로, 용량은 maxmemory와 축출 정책으로 별도로 방어해야 합니다.

캐시 전용 인스턴스라면 축출 정책을 반드시 명시적으로 확인하십시오. 축출 대신 쓰기 오류를 반환하는 설정(noeviction 계열)이라면 메모리가 차는 순간 캐시 갱신 쓰기가 실패하면서 조용히 히트율이 무너집니다. 이때 애플리케이션 로그에는 캐시 쓰기 실패만 찍히고 응답은 정상이라, 원인을 찾기까지 시간이 오래 걸립니다.

복제 구성을 쓴다면 하나 더 있습니다. 복제본은 스스로 키를 만료시키지 않고 마스터가 보내는 삭제 전파를 기다립니다. 논리적으로 만료된 키를 복제본에서 읽으면 없는 것으로 취급하도록 되어 있지만, 메모리 회수 자체는 마스터의 전파 시점에 달려 있다는 점을 기억하면 복제본 메모리 그래프가 마스터와 다르게 움직이는 이유를 설명할 수 있습니다.

프레임워크 기본값도 확인해야 합니다. 캐시 추상화 계층의 기본 설정은 TTL을 지정하지 않는 경우가 흔하고, 그러면 애노테이션만 붙인 캐시가 영구 키가 됩니다. 캐시 이름별 TTL을 설정 파일 한곳에 모아 두고, TTL 없는 캐시가 새로 추가되면 리뷰에서 걸러지도록 만드는 편이 낫습니다.

3. 없는 값과 뜨거운 키

스탬피드가 "있던 값이 사라져서" 생기는 문제라면, 이 절의 두 문제는 각각 "처음부터 없는 값"과 "한 키에만 몰리는 트래픽"에서 옵니다. 둘 다 히트율 지표만 보면 잘 드러나지 않습니다.

존재하지 않는 키는 캐시를 통과합니다

없는 식별자를 조회하면 원본도 결과가 없고, 결과가 없으니 캐시에 넣을 것도 없습니다. 그래서 같은 요청이 올 때마다 매번 DB까지 갑니다. 정상 트래픽에서는 별일 아니지만, 잘못된 링크가 퍼지거나 크롤러가 없는 ID를 훑거나 공격자가 무작위 식별자를 던지면 캐시가 있으나 마나 한 상태로 원본이 그대로 노출됩니다.

방어는 "없음"도 캐시하는 것입니다. 다만 두 가지 규칙을 함께 지켜야 합니다.

스프링 캐시 추상화는 null 반환값도 캐시할 수 있고, Redis 구현에는 이를 끄는 옵션이 따로 있습니다. 어느 쪽이 켜져 있는지 모른 채 쓰면 "없음이 캐시되지 않아 원본이 계속 노출"과 "없음이 너무 오래 캐시되어 신규 데이터가 안 보임" 중 한쪽을 반드시 겪게 됩니다. 무작위 식별자가 대량으로 들어오는 공개 API라면 빈 값 키가 메모리를 잠식하지 않도록 별도 접두사와 짧은 TTL로 분리해 두십시오.

뜨거운 키는 노드를 늘려도 흩어지지 않습니다

Redis 클러스터는 키를 해시로 분산하지만, 트래픽이 한 개의 키에 몰리면 그 키를 가진 노드 하나만 뜨거워집니다. 노드를 늘려도 해결되지 않는 유일한 종류의 부하입니다. 메인 화면 설정값, 전체 공지, 상단 배너처럼 모든 요청이 반드시 읽는 키가 후보입니다.

대응은 두 가지 방향입니다. 하나는 애플리케이션 로컬 캐시를 아주 짧게 얹는 2계층 구조입니다. 초 단위 TTL이면 정합성 손해는 작고 Redis 왕복은 크게 줄어듭니다. 대신 인스턴스마다 값이 잠깐 다를 수 있으니, 인스턴스 간 차이가 문제가 되는 데이터에는 쓸 수 없습니다. 다른 하나는 키를 인위적으로 쪼개는 것입니다. 같은 값을 접미사가 다른 키 여러 개에 복제해 두고 요청마다 하나를 무작위로 고르면 부하가 여러 슬롯으로 흩어집니다. 대신 무효화할 때 복제본 전부를 지워야 하므로 무효화 규칙이 복잡해집니다. 읽기가 압도적이고 갱신이 드문 키에만 쓰십시오.

값이 지나치게 큰 키도 같은 계열의 문제입니다. Redis는 명령을 단일 스레드로 처리하므로, 수 MB짜리 값 하나를 읽고 쓰는 동안 그 인스턴스의 다른 모든 명령이 뒤에서 기다립니다. 평균 지연은 멀쩡한데 꼬리 지연만 튀는 그래프가 나오면 큰 키를 먼저 의심하십시오.

4. 직렬화 포맷: 나중에 발목을 잡는 결정

직렬화 포맷은 도입 시점에는 아무 차이가 없어 보입니다. 문제가 드러나는 시점은 클래스가 바뀔 때다른 언어나 다른 서비스가 같은 캐시를 읽으려 할 때입니다. 그때는 이미 캐시에 수많은 옛 포맷 값이 들어 있습니다.

세 가지 선택지

포맷장점발목을 잡는 지점
언어 기본 직렬화(JDK 등) 설정이 없다시피 하고 그대로 동작 클래스 구조가 바뀌면 역직렬화 실패. 다른 언어에서 읽을 수 없음. 신뢰할 수 없는 데이터를 역직렬화하면 보안 위험. 값이 사람 눈으로 읽히지 않아 장애 시 확인이 어려움
JSON 언어 중립, redis-cli로 바로 확인 가능, 필드 추가에 관대함 크기가 큼. 타입 정보를 함께 저장하는 설정이면 값 안에 클래스 경로가 박혀서, 패키지 이동이나 리팩터링이 곧 캐시 전면 무효화로 이어짐
스키마 기반 이진 포맷 작고 빠르며 스키마 진화 규칙이 명시적 스키마 관리 비용. 값이 눈으로 읽히지 않음. 캐시 하나 때문에 도입하기에는 무거움

대부분의 웹 서비스라면 JSON이 무난한 기본값입니다. 장애 상황에서 redis-cli로 값을 그대로 읽을 수 있다는 이점이 크기 손해보다 큽니다. 다만 그냥 JSON을 고르는 것과, 아래 규칙을 함께 지키는 것은 전혀 다릅니다.

포맷을 고를 때 같이 정해야 하는 것들

값의 크기도 미리 정해 두는 게 좋습니다. 지나치게 큰 값을 캐시에 넣으면 네트워크 왕복과 역직렬화 비용이 DB 조회보다 커지는 역전이 일어납니다. 큰 목록은 통째로 캐시하지 말고, 식별자 목록만 캐시한 뒤 개별 항목을 다시 캐시에서 조회하는 구조가 대개 낫습니다. 개별 항목은 여러 화면에서 재사용되므로 히트율도 같이 올라갑니다.

5. 무효화 시점: 순서가 전부입니다

"캐시 무효화가 컴퓨터 과학의 두 가지 어려운 문제 중 하나"라는 오래된 농담이 있습니다. 실무에서 이 말이 뜻하는 바는 대체로 타이밍입니다.

갱신하지 말고 삭제하십시오

데이터가 바뀔 때 캐시에 새 값을 써 넣는 방식은 직관적이지만, 동시에 두 건의 수정이 일어나면 DB와 캐시의 최종 순서가 어긋날 수 있습니다. A가 DB를 쓰고, B가 DB를 쓰고, B가 캐시를 쓰고, 마지막에 A가 캐시를 쓰면 DB에는 B의 값이, 캐시에는 A의 값이 남습니다. 이 불일치는 TTL이 만료될 때까지 지속됩니다.

따라서 기본 전략은 삭제여야 합니다. 삭제는 멱등이고, 다음 조회가 원본에서 최신 값을 다시 채웁니다. 캐시에 새 값을 써 넣는 것은 원본 조회가 극도로 비싸고 쓰기가 드문 경우에만 고려할 예외입니다.

커밋 전에 지우면 안 됩니다

가장 흔한 실수는 트랜잭션 안에서 캐시를 지우는 것입니다.

@Transactional
public void updateProfile(Long id, ProfileForm form) {
    repository.update(id, form);
    cache.evict(key(id));   // 아직 커밋 전이다
    // 이후 로직에서 예외가 나면 DB는 롤백, 캐시는 이미 지워짐
    // 더 나쁜 경우: 이 사이에 들어온 조회가 커밋 전 옛 값을 다시 캐싱한다
}

커밋 전에 지우면 두 가지가 동시에 문제입니다. 롤백되면 지울 필요가 없던 캐시를 지운 것이고(이건 성능 손실로 끝납니다), 지운 직후 커밋 전에 들어온 조회가 아직 옛 값인 DB를 읽어 캐시를 다시 채웁니다. 이쪽이 훨씬 나쁩니다. 옛 값이 TTL이 다할 때까지 최신 값 행세를 합니다.

그러므로 무효화는 커밋 이후에 실행해야 합니다. 스프링이라면 트랜잭션 동기화의 커밋 후 콜백에 거는 방법이 있습니다.

TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override public void afterCommit() {
            cache.evict(key(id));
        }
    });

이 등록은 트랜잭션 동기화가 활성화된 상태에서만 유효합니다. 트랜잭션 밖에서 호출하면 예외가 나므로, 트랜잭션 유무를 가리지 않는 공통 유틸로 감싸거나 커밋 후 실행되는 이벤트 리스너를 쓰는 편이 안전합니다. 어느 쪽이든 핵심은 같습니다. 삭제는 커밋 뒤에입니다.

그런데 커밋 후로 옮겨도 창이 완전히 닫히지는 않습니다. 커밋과 삭제 사이의 짧은 구간, 그리고 읽기 복제본을 쓰는 경우의 복제 지연 구간이 남습니다. 더 미묘한 경우도 있습니다. 캐시 미스로 DB를 읽은 스레드가 값을 캐시에 쓰기 직전에 멈춰 있는 동안 갱신과 삭제가 모두 끝나면, 그 스레드가 뒤늦게 옛 값을 다시 써 넣습니다. 이 구간까지 신경 써야 하는 데이터라면 지연 이중 삭제를 씁니다. 커밋 직후 한 번 지우고, 복제 지연과 그 재삽입 창보다 넉넉한 시간 뒤에 한 번 더 지웁니다. 두 번째 삭제는 그 사이에 잘못 채워진 값을 걷어내는 역할입니다. 다만 두 번째 삭제를 요청 스레드의 sleep으로 구현하면 안 됩니다. 지연 큐나 스케줄러로 넘겨서 응답 지연과 분리하십시오. 완벽한 해법은 아니지만, 남는 불일치 창을 실용적인 수준으로 줄여 줍니다.

삭제 자체가 실패하면

Redis 삭제 호출은 실패할 수 있습니다. 커밋은 됐는데 삭제가 실패하면 옛 값이 남습니다. 여기서 선택은 서비스 성격에 달렸습니다.

연관 키를 어떻게 찾을 것인가

사용자 프로필 하나를 고쳤을 때 지워야 할 키가 프로필 캐시 하나로 끝나는 경우는 드뭅니다. 목록 캐시, 검색 결과 캐시, 집계 캐시가 함께 낡습니다. 여기서 KEYS user:* 같은 명령으로 훑겠다는 생각을 하게 되는데, 운영 환경에서 KEYS는 쓰지 마십시오. Redis는 명령을 단일 스레드로 처리하므로 키 전체를 순회하는 동안 다른 모든 요청이 멈춥니다. 순회가 꼭 필요하면 커서 기반의 SCAN을 쓰되, 이쪽도 스냅숏을 보장하지는 않습니다. 순회 도중 같은 키가 여러 번 나올 수 있고 순회 중에 추가된 키는 나오지 않을 수 있으므로, 무효화의 정확성을 SCAN에 의존해서는 안 됩니다. 순회가 잦다면 그 자체가 설계가 잘못됐다는 신호입니다.

지울 키가 많을 때 DEL로 한꺼번에 지우는 것도 주의해야 합니다. 큰 자료구조를 지우는 비용은 그 명령 안에서 발생하므로, 대량 삭제는 UNLINK처럼 메모리 회수를 백그라운드로 넘기는 명령을 쓰고 한 번에 지우는 개수를 나누는 편이 낫습니다.

더 나은 방법은 지울 대상을 미리 알 수 있게 키를 설계하는 것입니다. 파생 캐시가 어떤 원본에 의존하는지 역방향 인덱스를 셋(Set)으로 유지하거나, 목록처럼 무효화 범위를 특정하기 어려운 캐시는 아예 TTL을 짧게 잡고 무효화를 포기하는 편이 단순합니다. 후자는 타협처럼 보이지만, 목록 캐시의 무효화 규칙을 정확히 유지하는 비용이 목록을 수십 초 캐시하는 손해보다 큰 경우가 대부분입니다. 무효화 규칙이 복잡해질수록 버그가 사는 곳이 됩니다.

6. 캐시를 쓰면 안 되는 데이터

여기가 이 글에서 가장 중요한 부분입니다. 위의 모든 기법은 캐시가 필요한 데이터를 전제로 합니다. 다음 데이터는 기법의 문제가 아니라 대상 선정의 문제입니다.

7. 캐시 도입 자체를 미뤄야 하는 때

데이터 종류와 별개로, 다음 상황이라면 캐시를 붙이는 것보다 먼저 해야 할 일이 있습니다.

8. 도입 체크리스트

새 캐시를 하나 추가할 때 아래 항목에 답할 수 없으면 아직 설계가 끝나지 않은 것입니다.

  1. 이 데이터는 최대 몇 초까지 낡아도 되는가. 그 숫자를 누가 승인했는가.
  2. 원본 변경 시 무효화 이벤트를 잡을 수 있는가. 잡는다면 커밋 이후에 실행되는가.
  3. 키 이름에 서비스 접두사와 스키마 버전이 들어 있는가.
  4. TTL이 명시되어 있는가. 거기에 지터가 들어 있는가.
  5. 동시 미스 시 원본 조회가 몇 번 나가는가. 그 수가 원본이 견딜 수 있는 수인가.
  6. 잠금을 쓴다면 잠금 TTL이 원본 조회 타임아웃보다 긴가. 소유자를 확인하고 해제하는가.
  7. 존재하지 않는 키 조회를 어떻게 처리하는가. 빈 값을 캐시한다면 생성 시 지우는가.
  8. 캐시에 넣는 타입이 도메인 엔티티가 아닌 전용 DTO인가.
  9. 역직렬화 실패가 캐시 미스로 처리되는가, 예외로 터지는가.
  10. Redis가 죽으면 이 API는 느려지는가, 죽는가. 그때 원본으로 몰리는 양은 견딜 수 있는가.
  11. 예상 키 개수 곱하기 값 크기가 얼마이고, 축출 정책은 무엇인가.
  12. 이 캐시의 히트율을 어디서 볼 수 있는가.

정리하면, Redis 캐시의 난이도는 Redis에 있지 않습니다. 낡은 데이터를 어디까지 허용할 것인가라는 업무 판단을 코드로 옮기는 데 있습니다. 그 판단을 먼저 문서로 정하고 나면 잠금이든 지터든 이중 삭제든 구현 자체는 어렵지 않습니다. 반대로 그 판단 없이 기법부터 도입하면, 잘 만든 방어 장치들이 정합성이 깨진 데이터를 더 오래, 더 안정적으로 서빙하게 됩니다.

같은 주제의 다른 글

맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.

기술 판단이 필요하신가요

맘소프트는 소프트웨어 개발과 CTO 대행 · 기술 자문을 제공합니다. 편하게 문의해 주세요.

[email protected]