← Posts

Cache · Performance · Scaling

캐시 - 같은 답을 두 번 만들지 않는다

한 번 만든 결과를 들고 있다가 다시 준다. 쉬운 이야기라서 위험한데, 진짜 알맹이는 무엇에는 캐시를 붙이면 안 되는가다.

목차
  1. 같은 답을 초당 수백 번 다시 만든다
  2. 캐시는 결과를 기억해두는 것이다
  3. 캐시가 이득이 되는 조건
  4. 캐시하면 안 되는 것
  5. 캐시 키가 곧 정확성이다
  6. 낡음을 다루는 두 가지: 만료와 무효화
  7. 메모리는 유한하니 무엇을 버릴지 정해야 한다
  8. 그래서 실무에선 어떻게 쓰이나
  9. 정리

메인 화면이 여는 상품 목록 API가 있다. 접속자가 늘면서 이 조회 하나가 초당 수백 번씩 들어온다.

같은 답을 초당 수백 번 다시 만든다

그 목록은 사람마다 다르지 않다. 누가 봐도 같은 상품 스무 개다. 그런데 서버는 요청이 올 때마다 처음부터 다시 만든다.

java
@GetMapping("/products/popular")
public List<ProductDto> popular() {
    List<Product> products = productRepository.findPopular();   // DB 조회
    return products.stream().map(ProductDto::from).toList();    // 변환
}

같은 쿼리를 초당 수백 번 던지고, 같은 변환을 초당 수백 번 한다. 결과는 매번 똑같다. 앞 글의 표현을 빌리면 이 반복이 앱 CPU와 DB 조회라는 두 칸을 동시에 좁히고 있다.

캐시는 여기에 대한 답이다. 한 번 만든 결과를 들고 있다가, 다음에 같은 요청이 오면 만들지 않고 그대로 준다.

캐시는 결과를 기억해두는 것이다

동작은 한 그림이면 끝난다.

diagramdiagram

용어도 이 그림에 다 있다.

  • 히트(hit) - 캐시에 있어서 그대로 준 경우
  • 미스(miss) - 없어서 원래대로 만든 경우
  • 히트율 - 전체 요청 중 히트의 비율

여기서 캐시의 값어치가 결정된다. 히트율이 낮은 캐시는 이득이 없다. 매번 miss면 원래 하던 일에 “캐시를 뒤져보는 일”이 얹힌 것뿐이다. 캐시는 공짜가 아니라 적중할 때만 이득이고, 빗나가면 손해가 조금 나는 도구다.

캐시가 이득이 되는 조건

그러니 붙이기 전에 물어볼 게 있다. 아래 넷이 모두 참일 때 캐시가 잘 듣는다.

조건안 맞으면
만드는 게 비싸다캐시 조회가 더 비쌀 수도 있다
같은 키가 반복해서 조회된다히트율이 안 나온다
읽기가 쓰기보다 훨씬 많다넣자마자 무효화된다
조금 낡아도 괜찮다애초에 캐시하면 안 된다

메인 화면 인기 목록은 넷을 다 만족한다. 만드는 데 DB 조회가 들고, 모두가 같은 목록을 보고, 하루에 몇 번 바뀌고, 몇 초 낡아도 아무 일 안 난다.

반대로 “주문 상세를 주문 번호로 조회”는 두 번째가 깨진다. 주문 번호는 거의 다 다르니 같은 키가 반복되지 않는다. 캐시에 넣어봐야 대부분 한 번 읽히고 밀려난다.

참고

넷째 조건이 특히 중요하다. 캐시는 최신성을 팔아 속도를 사는 거래다. HTTP 캐시 글에서 브라우저 층에 대해 같은 말을 한다. 층이 달라도 거래 내용은 똑같다.

캐시하면 안 되는 것

여기가 이 글의 알맹이다. 캐시는 붙이기가 너무 쉬워서, 붙이면 안 되는 자리에 붙는 사고가 자주 난다. 그것도 조용히 난다. 응답은 200으로 잘 나가고 아무 예외도 안 터지는데 내용이 틀리다.

사람마다 달라야 하는 응답. 가장 위험하다. “내 장바구니”를 cart라는 키로 캐시하면, 다음 사용자가 남의 장바구니를 받는다. 에러가 안 나기 때문에 배포하고 한참 뒤에 문의로 발견된다.

정확성이 걸린 값. 잔액, 재고 수량, 좌석 남은 수. 이건 “조금 낡아도 되는” 값이 아니다. 재고가 0인데 캐시에 3이 남아 있으면 팔면 안 되는 걸 판다.

쓰고 바로 읽는 값. 사용자가 프로필을 수정하고 바로 자기 프로필을 본다. 캐시가 옛 값을 주면 “저장이 안 됐다”고 느낀다. 기능이 망가진 것으로 보인다.

결과가 매번 달라지는 것. 현재 시각, 난수, 랜덤 추천. 캐시하면 그건 이미 다른 기능이다.

키가 거의 안 겹치는 조회. 앞에서 본 주문 상세가 그렇다. 히트율이 안 나오는데 메모리는 계속 먹는다. 결국 다른 유용한 항목을 밀어내서 전체 히트율을 떨어뜨린다. 손해가 자기 자리에서만 나지 않는다.

주의

캐시 사고의 대부분은 “느려졌다”가 아니라 **“틀린 값을 줬다”**로 나타난다. 그리고 로그에도 모니터링에도 안 잡힌다. 캐시를 붙일 때는 “이 응답이 다른 사람에게 그대로 나가도 되는가”를 먼저 묻는다.

캐시 키가 곧 정확성이다

앞의 사고 대부분은 사실 키를 잘못 잡은 것이다. 응답이 무엇에 따라 달라지는지를 키가 전부 담고 있어야 한다.

java
// 위험: 파라미터가 없다. 사용자는 로그인 정보에서 꺼내는데 키에는 안 들어간다
@Cacheable(value = "cart")
public CartDto myCart() {
    Long userId = currentUser().getId();   // 응답을 가르는 값이 여기 숨어 있다
    ...
}

// 안전: 응답을 가르는 값을 파라미터로 꺼내 키에 넣는다
@Cacheable(value = "cart", key = "#userId")
public CartDto cart(Long userId) { ... }

위쪽은 캐시 항목이 하나만 생긴다. 먼저 들어온 사람의 장바구니가 그 자리에 앉고, 뒤에 오는 전원이 그걸 받는다.

참고

파라미터가 있으면 대개는 그것이 자동으로 키가 된다. Spring의 기본 키 생성기는 파라미터가 하나면 그 값을, 여럿이면 그 조합을 키로 쓴다. 그래서 사고는 “키를 안 적어서”가 아니라 응답을 가르는 값이 파라미터 밖(로그인 정보·요청 헤더·쓰레드 로컬)에 있을 때 난다.

규칙은 하나다. 응답을 바꾸는 입력은 전부 키에 들어가야 한다. 사용자, 권한 등급, 언어, 페이지 번호, 정렬 기준. 하나라도 빠지면 그 값이 다른 두 요청이 같은 답을 받는다.

이 문제는 층이 바뀌어도 똑같이 나온다. HTTP 공유 캐시에서 Vary 헤더가 하는 일이 바로 “이 헤더가 다르면 다른 응답으로 취급하라”는 키 선언이다. 이름만 다르고 같은 문제다.

다만 키를 잘게 쪼갤수록 히트율은 떨어진다. 사용자별로 나누면 사용자 수만큼 항목이 생긴다. 그래서 실제 판단은 **“이 값을 키에서 뺄 수 있게 응답에서 개인화된 부분을 떼어낼 수 있나”**로 간다. 목록은 공용으로 캐시하고, 사용자별 표시(찜 여부 같은 것)는 따로 붙이는 식이다.

낡음을 다루는 두 가지: 만료와 무효화

캐시에 넣은 값은 원본이 바뀌면 틀린 값이 된다. 다루는 방법이 둘이다.

diagramdiagram

만료는 단순하고 안전하다. 아무것도 안 해도 언젠가는 맞는 값으로 돌아온다. 대신 그 시간만큼은 낡은 값이 나간다. 수명을 정하는 기준은 “이 데이터가 몇 초까지 틀려도 사업적으로 괜찮은가”이지 “얼마나 자주 바뀌는가”가 아니다.

무효화는 정확하지만 빠뜨리기 쉽다. 상품을 고치는 경로가 관리자 화면 하나면 쉽다. 그런데 배치가 고치고, 다른 서비스가 고치고, DB에서 직접 고치면 어느 하나에서 무효화가 빠진다. 그 순간부터 캐시는 영영 낡은 값을 준다. 만료가 없으면 영영이다.

그래서 무효화를 걸더라도 TTL을 같이 건다. 무효화는 정확성을 위해, TTL은 실수를 위해. TTL 없는 캐시는 언젠가 반드시 틀린다.

무효화할 때는 갱신보다 삭제가 안전하다. 새 값으로 덮어쓰려면 지금 원본이 무엇인지 알아야 하는데, 동시에 두 곳에서 고치면 순서가 엇갈려 오래된 값이 나중에 덮어쓸 수 있다. 그냥 지우면 다음 조회가 원본을 다시 읽는다.

메모리는 유한하니 무엇을 버릴지 정해야 한다

캐시는 메모리에 산다. 계속 넣으면 언젠가 찬다. 그때 무엇을 버릴지를 정하는 규칙이 축출(eviction) 정책이다.

정책버리는 기준
LRU가장 오래 안 쓰인 것부터
LFU가장 적게 쓰인 것부터
TTL 기반수명이 다한 것부터
크기 제한개수나 바이트 상한을 넘으면

기본은 LRU다. 최근에 쓰인 건 또 쓰일 가능성이 높다는 가정인데, 대개 맞는다.

중요한 건 정책 이름이 아니라 상한을 반드시 둬야 한다는 점이다. 상한 없는 캐시는 캐시가 아니라 메모리 누수다. 앞 글의 표현으로는, CPU 병목을 풀려다 메모리 병목을 새로 만든 것이다.

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

Spring은 캐시를 애노테이션으로 감춰준다. 메서드 호출을 가로채서 앞의 그림을 대신 수행한다.

java
@Service
public class ProductService {

    @Cacheable(value = "popular", key = "#category")
    public List<ProductDto> popular(String category) {
        return productRepository.findPopular(category)
                .stream().map(ProductDto::from).toList();
    }

    @CacheEvict(value = "popular", key = "#category")
    public void updateProduct(String category, Product product) {
        productRepository.save(product);
    }
}

@Cacheable이 붙은 메서드는 캐시에 있으면 아예 실행되지 않는다. @CacheEvict는 원본을 바꾼 뒤 해당 키를 지운다. 앞에서 본 “갱신보다 삭제”가 기본 도구로 들어가 있다.

주의

다만 지우는 시점을 확인해야 한다. 기본 설정에서는 메서드가 끝나는 순간 지우는데, 그 메서드가 트랜잭션 안이면 아직 커밋되기 전이다. 그 찰나에 다른 요청이 조회하면 옛 값을 읽어 캐시에 도로 채운다. 다음 글에서 볼 “캐시를 먼저 지우고 DB를 쓰는” 나쁜 순서가, 아무것도 안 했는데 만들어지는 것이다. 커밋된 뒤에 지우도록 맞추는 설정이 따로 있다.

수명과 크기 상한은 캐시 구현이 정한다. 로컬 캐시 구현으로는 Caffeine이 널리 쓰이고, 이런 식으로 상한을 건다.

java
@Bean
public CacheManager cacheManager() {
    var manager = new CaffeineCacheManager("popular");
    manager.setCaffeine(Caffeine.newBuilder()
            .maximumSize(1000)                          // 개수 상한
            .expireAfterWrite(Duration.ofMinutes(5)));  // 수명
    return manager;
}

실무에서 조심할 자리도 몇 개 있다.

애노테이션이 안 먹는 자리가 있다. Spring의 캐시는 프록시로 동작해서, 같은 클래스 안에서 자기 메서드를 직접 부르면 프록시를 안 거쳐 캐시가 무시된다. 미들웨어 글에서 본 “요청이 길목을 지나야 처리된다”와 같은 구조이고, 안 지나가면 아무 일도 안 일어난다.

캐시에 넣는 것은 응답 DTO여야 한다. JPA 엔티티를 그대로 넣으면 영속성 컨텍스트를 벗어난 객체가 캐시에 남아 지연 로딩에서 터진다.

히트율을 본다. 캐시를 붙였으면 히트율을 재야 붙일 값어치가 있었는지 안다. 앞 글의 규칙 그대로 추측하지 않는다.

정리

캐시란한 번 만든 결과를 들고 있다가 그대로 주는 것
겨냥하는 병목같은 답을 반복해 만드는 앱 CPU와 DB 조회
이득 조건만들기 비싸고 · 키가 반복되고 · 읽기가 많고 · 낡아도 되는 것
붙이면 안 되는 것개인화된 응답 · 정확성이 걸린 값 · 쓰고 바로 읽는 값 · 키가 안 겹치는 조회
응답을 바꾸는 입력이 전부 키에 있어야 한다
낡음만료(TTL)는 안전하고, 무효화는 정확하다. 둘 다 건다
무효화갱신보다 삭제가 안전하다
상한없으면 캐시가 아니라 메모리 누수다

여기까지는 서버가 한 대일 때의 이야기다. 캐시가 앱 프로세스 메모리 안에 있으니, 서버를 두 대로 늘리면 캐시도 두 벌이 된다. 다음 글은 캐시를 프로세스 밖으로 꺼내면 무엇이 해결되고 무엇을 새로 치러야 하는지를 본다.

관련 글