개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › 구조와 운영

캐시

캐시는 무엇이고, 쓰면 무엇을 얻고 무엇을 잃는가

캐시는 오래 걸려 얻은 결과를 가까운 곳에 적어 두었다가, 같은 것을 다시 달라고 하면 적어 둔 것을 주는 장치입니다. 이 실습의 코드에서는 cache라는 Map이 적어 두는 곳이고, find 메서드 안의 cache 줄들이 "적어 둔 것이 있으면 그것을 주고, 없을 때만 느린 조회(slowLoad)를 한 뒤 적어 둔다"는 일을 합니다. slowLoad는 원본(source)에서 값을 읽어 오는 느린 일이고, 부른 횟수를 slowCount로 셉니다. 실제 시간은 재지 않고 slowLoad가 불린 횟수로 "얼마나 느린 일을 했는가"를 보입니다. 이 실습은 cache 줄을 지워 보고, 두 번 찾는 사이에 원본의 값을 바꾸거나 cache.remove(1) 줄로 캐시의 값을 지워 가며 같은 코드를 컴파일해 실행하고, 느린 조회가 몇 번 일어나는지와 두 번째로 받은 값이 원본과 같은지를 봅니다. (static은 main이 객체를 만들지 않고 바로 부를 수 있게 붙인 것으로 보고, 이 실습에서는 다루지 않습니다.)

실행으로 대조 · JDK 21에서 다섯 가지 조합을 javax.tools로 컴파일하고 실행해 대조

이 실습에 나오는 개념

캐시
자주 사용하는 데이터나 계산 결과를 빠르게 접근할 수 있는 곳에 미리 저장해 두고 재사용하는 것이다. 원본(주로 데이터베이스)을 매번 조회하지 않아 응답이 빨라지고 원본의 부하가 줄어든다. 대신 원본이 바뀌었을 때 캐시의 값이 오래된 상태로 남는 문제를 관리해야 한다.
캐시 적중과 미스
캐시에서 값을 찾을 때 두 가지 일이 일어난다. 찾는 것이 캐시에 이미 적혀 있으면 적중(hit)이라고 하며, 적어 둔 값을 그대로 돌려주고 원본까지 가지 않는다. 적혀 있지 않으면 미스(miss)라고 하며, 원본에서 값을 읽어 와 캐시에 적어 두고 돌려준다. 스프링의 캐시 추상화도 같은 순서로, 같은 인자로 이미 호출한 적이 있으면 메서드를 실행하지 않고 캐시된 결과를 돌려주고, 아니면 메서드를 실행해 그 결과를 캐시에 넣어 돌려준다. 처음 찾는 것은 늘 미스이고, 적중이 많을수록 원본까지 가는 느린 일이 줄어든다.
Cache-Aside 패턴
애플리케이션이 캐시를 직접 관리하는 가장 일반적인 방식이다. 데이터를 조회할 때 먼저 캐시를 확인하고, 없으면 데이터베이스에서 읽어 캐시에 저장한 뒤 반환한다. 데이터를 변경할 때는 데이터베이스를 갱신하고 해당 캐시를 삭제한다.
캐시의 옛 값(Stale)
캐시에 적어 둔 뒤 원본의 값이 바뀌어, 원본과 달라진 채 캐시에 남아 있는 값이다. 캐시를 쓰는 쪽은 적중하면 원본을 보지 않으므로 바뀐 줄 모르고 옛 값을 받는다. 원본을 바꿀 때 캐시에 적어 둔 그 값도 함께 지우면, 다음에 찾을 때 미스가 되어 바뀐 값을 새로 읽어 온다(대신 그 한 번은 느리다). 적어 둔 값에 유효 시간을 두는 방법도 있다. HTTP 캐시 명세(RFC 9111 4.2절)는 저장한 응답이 정해진 유효 기간을 넘기면 stale이라고 부르며, 이 말은 시간이 지난 응답을 가리키는 명세의 용어이다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. main이 시작되고 첫 줄 source.put(1, "키보드"); 줄이 실행됩니다. 원본에 1번 값 "키보드"가 들어갑니다. 원본은 값이 원래 들어 있는 곳이고, slowLoad가 읽어 오는 곳입니다. cache는 new HashMap<>() 호출로 만든 빈 Map이어서 아직 아무것도 적혀 있지 않습니다.
  2. find(1); 줄이 실행됩니다. find는 먼저 cache.containsKey(1) 호출로 캐시에 1번 값이 적혀 있는지 봅니다. 캐시가 비어 있으므로 적혀 있지 않습니다. 캐시에 적혀 있지 않은 이런 경우를 미스라고 합니다.
  3. 그래서 find가 slowLoad 메서드를 부릅니다. slowLoad는 원본에서 1번 값 "키보드"를 읽어 오는 느린 일이고, slowCount를 올려 부른 횟수를 셉니다. 지금까지 slowLoad가 불린 횟수는 1번입니다.
  4. find는 읽어 온 값 "키보드"를 cache.put(1, v) 호출로 캐시에 적어 두고, 그 값을 돌려줍니다. 이제 캐시에 1번 값 "키보드"가 적혀 있습니다.
  5. String second = find(1); 줄이 실행됩니다. find가 cache.containsKey(1) 호출로 캐시를 보니 1번 값이 적혀 있습니다. 캐시에 적혀 있는 이런 경우를 적중이라고 합니다. find는 slowLoad를 부르지 않고 적어 둔 값 "키보드"를 그대로 돌려줍니다. slowLoad가 불린 횟수는 1번 그대로입니다.
  6. 마지막 줄 System.out.println이 slowCount, second, source.get(1) 값을 차례로 출력합니다. 출력은 "1 / 키보드 / 키보드"입니다. 받은 값과 원본의 값이 같습니다.

실행 결과

코드

import java.util.*;

public class Main {
    // 원본: 값이 원래 들어 있는 곳
    static Map<Integer, String> source = new HashMap<>();
    static int slowCount = 0;

    // 느린 조회: 원본에서 읽어 온다. 부른 횟수를 센다
    static String slowLoad(int id) {
        slowCount++;
        return source.get(id);
    }

    // 캐시: 읽어 온 값을 적어 두는 곳
    static Map<Integer, String> cache = new HashMap<>();

    static String find(int id) {
        // 적어 둔 값이 있으면 주고, 없으면 읽어 와서 적어 둔다
        if (cache.containsKey(id)) return cache.get(id);
        String v = slowLoad(id);
        cache.put(id, v);
        return v;
    }

    public static void main(String[] args) {
        source.put(1, "키보드");
        // 1번 값을 처음 찾는다
        find(1);
        // 1번 값을 다시 찾는다
        String second = find(1);
        // 느린 조회 횟수 / 두 번째로 받은 값 / 원본의 값
        System.out.println(
            slowCount + " / " + second + " / " + source.get(1));
    }
}

이 모형의 전제

컴파일러와 실행 환경은 JDK 21.0.12.1(javac 21)입니다. 다섯 가지 조합(캐시를 두는가 두 가지와 두 번 찾는 사이에 하는 일 세 가지에서, 캐시가 없으면 cache.remove(1) 줄을 쓸 수 없어 빠지는 한 가지를 뺀 것)을 모두, 화면의 코드와 같은 소스(줄 단위로 같은 글, import 줄까지 포함)를 javax.tools로 컴파일하고, 클래스 파일을 새 클래스 로더로 읽어 Main.main을 실행해 표준 출력을 잡은 결과와 대조했습니다. 대조한 것은 컴파일 성공 여부, 출력된 세 값(slowLoad가 불린 횟수, 두 번째 find가 돌려준 값, 원본의 값)과 마지막 단계 문장이 보이는 출력 글입니다. 단계 문장이 기대는 "처음 find(1) 호출을 한 번 한 뒤 slowLoad는 1번 불렸고 받은 값은 키보드"라는 사실은, 처음 find의 결과를 출력하도록 고친 별도의 소스(캐시가 있는 것과 없는 것)를 같은 방식으로 실행해 확인했습니다. 화면의 코드는 실제로 컴파일되고 실행되는 프로그램이며 줄인 것은 없습니다. slowLoad는 실제로 오래 걸리지 않고 시간도 재지 않았습니다. "느리다"는 slowLoad가 불린 횟수로만 나타냈고, 실제 DB나 네트워크의 시간은 측정하지 않았습니다. 이 캐시는 Map 하나이고, 적어 둔 값을 언제 버릴지 정하는 규칙(유효 시간, 크기 제한)이 없어서 cache.remove(1) 줄로 지우기 전까지 값이 남습니다. 문서는 직접 열어 확인했습니다. Spring Framework Reference의 Cache Abstraction 도입부(https://docs.spring.io/spring-framework/reference/integration/cache.html)는 캐싱을 기존 애플리케이션에 투명하게 더하는 지원이라는 설명까지만 열렸고, 같은 문서의 하위 쪽(https://docs.spring.io/spring-framework/reference/integration/cache/strategies.html)에서 같은 인자로 이미 호출했으면 메서드를 실행하지 않고 캐시된 결과를 돌려주며 아니면 실행해 결과를 캐시에 넣는다는 것, 캐시의 내용을 갱신하거나 항목을 지우는 기능이 있다는 것을 확인했습니다. RFC 9111(HTTP Caching)은 1절(캐시가 응답을 저장해 응답 시간과 네트워크 사용을 줄인다), 4절(저장한 응답을 다시 쓰는 조건), 4.2절과 4.2.4절(유효 기간을 넘긴 저장 응답을 stale이라 부른다)을 확인했고, 문서의 끝 7천 자 가량은 읽지 못했습니다. 적중과 미스라는 낱말을 그 문서들이 쓰는지는 확인하지 못했고, 설명집에는 이 낱말을 일반적인 용어로만 적었습니다. 이 실습의 Map 캐시는 문서가 말하는 방식의 한 예일 뿐 스프링이나 HTTP 캐시의 동작을 실행해 대조한 것은 아닙니다. 이 밖에 여러 스레드가 동시에 쓰는 캐시, 캐시가 가득 찼을 때 무엇을 버릴지, 유효 시간(TTL), 인기 있는 값이 한꺼번에 사라질 때 원본에 요청이 몰리는 일, 스프링의 @Cacheable, 브라우저와 서버 사이의 HTTP 캐시(ETag), Redis는 다루지 않았습니다. 이들은 "세부 동작 실습: 캐시와 TTL, 캐시 추상화, ETag, Redis"에서 다룹니다.

앞 실습: 로드 밸런서 · 다음 실습: 메시지 큐

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요