← Posts

Cache · Redis · Scaling

캐시가 프로세스 밖으로 나가면

서버마다 캐시가 달라지는 문제를 공용 캐시로 푼다. 대신 네트워크·직렬화·단일 장애점·스탬피드라는 계산서가 새로 온다.

목차
  1. 서버마다 캐시가 다르다
  2. 캐시를 프로세스 밖으로 꺼낸다
  3. 대가 하나: 네트워크 왕복이 생긴다
  4. 대가 둘: 직렬화가 필요하다
  5. 대가 셋: 캐시가 죽으면 전부 DB로 간다
  6. 스탬피드 - 하나가 만료되면 전부 몰려간다
  7. 무효화는 여전히 어렵다
  8. 그래서 실무에선 어떻게 쓰이나
  9. 정리

캐시를 붙여 잘 돌아가던 서비스에 서버를 한 대 더 넣었다. 그때부터 상품 정보를 고쳐도 어떤 사용자는 새 값을, 어떤 사용자는 옛 값을 본다.

서버마다 캐시가 다르다

원인은 앞 글의 캐시가 앱 프로세스 메모리 안에 있기 때문이다. 서버가 두 대면 캐시도 두 벌이다. 서로 모른다.

diagramdiagram

관리자가 상품을 고치면서 캐시 무효화가 일어났는데, 그 요청은 서버 B에만 도착했다. B의 캐시만 지워졌다. A는 자기 캐시에 옛 값을 그대로 들고 있고, A로 배정된 사용자에게 계속 옛 값을 준다.

문제가 이것만이 아니다.

  • 재시작하면 캐시가 통째로 사라진다. 배포할 때마다 모든 서버가 빈 캐시로 시작해서 DB로 몰려간다.
  • 서버를 늘릴수록 히트율이 떨어진다. 같은 키 요청이 여러 서버로 흩어지니, 서버마다 따로 miss를 겪고 따로 채운다.
  • 메모리가 서버 수만큼 중복된다. 같은 데이터를 대수만큼 복사해 들고 있는 셈이다.

캐시를 프로세스 밖으로 꺼낸다

해법은 방향이 하나다. 캐시를 앱 안이 아니라 모든 서버가 함께 보는 밖에 둔다.

diagramdiagram

이 자리를 맡는 대표적인 것이 Redis다. 메모리에 키-값을 담아두고 네트워크로 읽고 쓰게 해주는 서버다. 앱 입장에서는 “아주 빠른, 그리고 우리 프로세스 밖에 있는 맵” 정도로 보면 된다.

캐시가 한 벌이 되면 앞의 문제가 한 번에 정리된다.

문제공용 캐시에서는
서버마다 값이 다르다한 벌이라 다를 수가 없다
무효화가 한 대에만 닿는다한 곳을 지우면 전부에 반영된다
재시작하면 비어버린다앱과 수명이 분리돼 살아남는다
서버를 늘리면 히트율이 떨어진다대수와 무관하다

여기까지만 보면 안 쓸 이유가 없어 보인다. 그런데 캐시를 프로세스 밖으로 꺼낸 순간, 로컬 캐시에서는 없던 대가가 셋 생긴다.

대가 하나: 네트워크 왕복이 생긴다

로컬 캐시의 조회는 그냥 메모리 참조였다. 함수 호출과 다를 게 없다.

공용 캐시의 조회는 네트워크 요청이다. 직렬화하고, 소켓으로 보내고, 상대가 처리하고, 돌아온다. 아무리 가까워도 메모리 참조보다는 확실히 비싸다.

diagramdiagram

그래서 판단 기준이 바뀐다. 로컬 캐시는 DB 조회를 대체하면 무조건 이득이었지만, 공용 캐시는 **“DB에 가는 것보다 싼가”**를 물어야 한다. 대개는 싸다. DB는 디스크와 쿼리 계산이 붙지만 캐시는 메모리 조회 하나니까. 하지만 아주 단순한 조회나, 어차피 앱 메모리에 있는 값이라면 굳이 나갈 이유가 없다.

그리고 왕복 횟수가 중요해진다. 한 요청에서 캐시를 스무 번 조회하면 왕복이 스무 번이다. 여러 키를 한 번에 가져오는 방식(멀티 조회, 파이프라이닝)이 있는 건 이 때문이다.

그래서 실무에서는 로컬 캐시와 공용 캐시를 겹쳐 쓰기도 한다. 거의 안 바뀌는 값(코드 테이블, 설정)은 로컬에 짧게 두고, 나머지는 공용에 둔다. 다만 로컬 층이 생기는 순간 첫 장면의 “서버마다 다르다”가 그 층에서 다시 살아난다는 걸 알고 써야 한다.

대가 둘: 직렬화가 필요하다

로컬 캐시에는 객체를 그대로 넣었다. 공용 캐시에는 바이트로 바꿔서 넣어야 한다. 나갈 때 직렬화, 들어올 때 역직렬화가 붙는다.

여기서 두 가지가 발목을 잡는다.

비용이 든다. 큰 객체일수록 변환에 CPU를 쓴다. DB 조회를 아끼려고 캐시를 붙였는데 직렬화 비용이 그만큼 나가면 남는 게 없다. 그래서 캐시에는 필요한 필드만 담은 작은 DTO를 넣는 편이 낫다.

형식이 바뀌면 깨진다. 이게 더 아프다. 배포로 DTO에 필드를 추가했는데 캐시에는 옛 형식이 남아 있으면, 역직렬화에서 예외가 난다. 그것도 캐시 히트일 때만 나서 재현이 어렵다.

주의

캐시에 담는 클래스는 호환을 깨는 변경에 조심해야 한다. 필드 추가는 대개 견디지만 이름 변경·타입 변경·삭제는 옛 데이터를 못 읽게 만든다. 형식을 바꿔야 하면 캐시 키의 이름에 버전을 넣어(product:v2:...) 옛 항목과 겹치지 않게 하는 방법이 있다.

대가 셋: 캐시가 죽으면 전부 DB로 간다

로컬 캐시가 죽는 일은 앱이 죽는 일과 같았다. 이제는 캐시만 따로 죽을 수 있다.

그 순간 모든 서버의 모든 요청이 miss가 되고, DB로 직행한다. 평소에 캐시가 막아주던 부하가 통째로 DB에 꽂힌다. 캐시가 잘 듣던 시스템일수록 캐시가 없어졌을 때 더 크게 무너진다.

그래서 캐시를 쓸 때 정해야 하는 게 있다. 캐시가 없을 때 서비스는 어떻게 되어야 하는가.

태도동작맞는 경우
캐시는 있으면 좋은 것캐시 오류를 삼키고 DB로 간다대부분의 조회 캐시
캐시가 필수캐시 오류를 예외로 올린다캐시가 유일한 저장소인 데이터

첫 번째를 고른다면 캐시 호출에 짧은 타임아웃이 반드시 필요하다. 안 그러면 응답 없는 캐시를 기다리느라 스레드가 다 묶여서, “캐시가 느린 것”이 “서비스 전체 정지”가 된다. 앞 글에서 본 병목의 이동이 그대로 일어난다. 느린 상대는 죽은 상대보다 나쁘다가 캐시에도 그대로 적용되는 것이다.

스탬피드 - 하나가 만료되면 전부 몰려간다

캐시에서 가장 유명한 사고다. 이름이 여럿인데(cache stampede, thundering herd) 그림은 하나다. 공용 캐시만의 문제는 아니다 - 서버 한 대의 로컬 캐시에서도 스레드 수십 개가 같은 키의 만료를 동시에 만나면 똑같이 벌어진다. 다만 공용 캐시에서는 서버 전체가 한꺼번에 몰리므로 규모가 커진다.

인기 목록이 캐시에 있고 TTL이 다 됐다고 하자. 그 순간 그 키를 찾는 요청이 초당 수백 건 들어오고 있다.

diagramdiagram

첫 요청이 값을 채워 넣기 전까지, 뒤따르는 요청들은 전부 miss를 본다. 그래서 같은 무거운 쿼리를 동시에 수백 번 던진다. 평소에 캐시가 잘 막아주던 인기 키일수록 몰리는 양이 크다. DB가 그 순간 무너지고, 무너져서 느려지니 채워 넣는 것도 늦어지고, 그동안 더 쌓인다.

완화하는 방법이 몇 가지 있다.

하나만 다시 만들게 한다. miss가 났을 때 잠금을 하나 잡아서, 잠금을 얻은 요청만 DB에 가고 나머지는 기다렸다가 채워진 값을 읽는다. 대기하는 요청이 생기고, 여러 서버에 걸친 잠금은 그 자체가 까다롭다 - 잠금에 건 시간이 재생성보다 짧으면 둘 이상이 동시에 DB로 가고, 잠금을 쥔 쪽이 죽으면 나머지가 그 시간만큼 멈춘다. 한 서버 안에서라면 훨씬 간단하다.

만료 시각을 흩뿌린다. TTL에 약간의 무작위를 더한다. 같은 시각에 대량으로 넣은 키들이 같은 시각에 한꺼번에 만료되는 걸 막는다. 배포 직후 캐시를 한꺼번에 채웠을 때 특히 유효하다.

만료 전에 미리 갱신한다. TTL이 끝나기 전에 백그라운드로 새 값을 만들어 덮어쓴다. 사용자 요청은 항상 히트를 본다.

낡은 값을 잠시 더 쓴다. 만료된 값을 즉시 버리지 않고, 새 값을 만드는 동안에는 옛 값을 내준다. 최신성을 조금 더 팔아서 몰림을 막는 거래다.

참고

비슷하지만 다른 것이 **캐시 관통(penetration)**이다. 존재하지 않는 키를 계속 조회하면 매번 miss가 나고 매번 DB에 간다. 캐시는 “없음”을 저장하지 않기 때문이다. 없다는 사실 자체를 짧은 TTL로 캐시해두면 막힌다.

무효화는 여전히 어렵다

공용 캐시로 옮기면 “서버마다 다르다”는 사라지지만, 캐시와 DB가 서로 다른 저장소라는 문제는 그대로다.

값을 고칠 때 DB와 캐시를 둘 다 손대야 하는데, 이 둘은 한 트랜잭션이 아니다. 둘 사이에 순서와 실패 가능성이 낀다.

diagramdiagram

두 순서 모두 틈이 있다. 둘째가 더 나쁘다. 잘못 채워진 값이 TTL이 끝날 때까지 남기 때문이다. 그래서 보통 DB를 먼저 쓰고 캐시를 지운다를 고른다. 틈이 짧고, 대개 다음 조회가 바로잡는다.

참고

첫째 순서에도 드물게 같은 함정이 있다. 조회가 miss를 겪고 옛 값을 읽어온 직후에 쓰기가 DB를 고치고 캐시를 지우면, 뒤늦게 도착한 그 조회가 옛 값을 다시 채워 넣는다. 확률이 낮을 뿐 성질은 같다. 바로 아래의 TTL이 이 경우의 안전망이기도 하다.

그리고 앞 글의 규칙이 여기서 더 중요해진다. 무효화를 걸어도 TTL을 반드시 같이 건다. 무효화 호출이 실패하거나 빠진 경로가 있어도, TTL이 있으면 언젠가는 맞는 값으로 돌아온다. TTL이 없으면 누군가 손으로 지울 때까지 틀린 값이 남는다.

주의

캐시를 DB와 완전히 일치시키려는 시도는 대개 실패한다. 두 저장소를 원자적으로 함께 바꿀 방법이 없기 때문이다. 목표를 “항상 일치”가 아니라 **“틀리는 창이 짧고, 반드시 스스로 복구된다”**로 잡는 편이 현실적이다.

그래서 실무에선 어떻게 쓰이나

Spring에서는 앞 글의 애노테이션을 그대로 쓰고 캐시 구현만 바꾼다. 코드는 안 바뀐다.

java
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
    var config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10));   // TTL은 반드시 건다
    return RedisCacheManager.builder(factory).cacheDefaults(config).build();
}
java
@Cacheable(value = "popular", key = "#category")
public List<ProductDto> popular(String category) { ... }

@Cacheable은 그대로인데 저장 위치만 프로세스 밖으로 옮겨갔다. 다만 직렬화가 생겼다는 사실은 코드가 감춰주지 않는다. 반환 타입이 직렬화 가능한 DTO인지, 형식을 바꿀 때 옛 데이터가 남아 있는지는 여전히 우리 몫이다.

이 공용 저장소는 캐시 말고 다른 용도로도 쓰인다. 무상태 글에서 본 세션 저장소가 대표적이다. 서버 메모리에 세션을 두면 첫 장면과 똑같이 서버마다 갈라지니, 밖으로 꺼내는 것이다. 캐시를 꺼내는 이유와 완전히 같은 이유다.

접속 자체도 커넥션 풀로 관리된다. 캐시 서버 역시 무제한으로 연결을 받아주지 않고, 앱이 여러 대면 풀 크기의 합이 그 상한에 걸린다. DB에서 겪은 계산이 여기서도 반복된다.

정리

왜 꺼내나서버마다 캐시가 갈라지고 · 재시작하면 사라지고 · 대수만큼 중복된다
꺼내면캐시가 한 벌. 무효화가 한 번에 닿고 앱 수명과 분리된다
대가 하나네트워크 왕복. 판단 기준이 “DB보다 싼가”로 바뀐다
대가 둘직렬화 비용과 형식 호환. 배포로 조용히 깨질 수 있다
대가 셋캐시만 죽을 수 있다. 짧은 타임아웃과 “없으면 어떻게 할지”를 정해둔다
스탬피드인기 키가 만료되는 순간 전부 DB로 몰린다
완화하나만 다시 만들기 · TTL 흩뿌리기 · 만료 전 갱신 · 낡은 값 잠시 사용
무효화DB를 먼저 쓰고 캐시를 지운다. 그래도 TTL을 같이 건다

여기까지 두 편은 “같은 일을 덜 하는 법”이었다. 그런데 할 일 자체가 늘어나면 덜 할 방법이 없다. 다음 글은 서버를 키울 것인가 늘릴 것인가, 그리고 늘리려면 무엇이 먼저 전제되어야 하는지를 본다.

관련 글