Hibernate 2차 캐시 실전 가이드: 반복 조회를 DB 없이 처리하기

같은 상품 정보를 화면마다 조회하고, 바뀌지도 않는 코드 테이블을 요청마다 다시 읽는다. JPA로 개발하다 보면 이렇게 “거의 변하지 않는데 자주 읽히는” 데이터가 DB를 계속 두드리는 걸 보게 된다. 1차 캐시가 있긴 하지만 이건 하나의 영속성 컨텍스트(트랜잭션) 안에서만 살아 있어서, 요청이 끝나면 사라진다. 다음 요청은 다시 DB로 간다. Hibernate 2차 캐시는 이 지점을 겨냥한다. 세션(트랜잭션) 경계를 넘어 엔티티를 캐싱해서, 여러 요청이 같은 데이터를 공유하고 DB 조회 자체를 건너뛰게 만든다. 이번 포스팅에서는 Hibernate 2차 캐시를 Spring Boot에서 켜는 것부터 캐시 전략 선택, 그리고 “분명히 켰는데 왜 안 먹지” 싶은 흔한 함정까지 정리하고자 한다.

1차 캐시로는 왜 부족할까

Hibernate 2차 캐시가 필요한 이유는 1차 캐시의 수명이 너무 짧기 때문이다. 1차 캐시는 영속성 컨텍스트(persistence context) 단위로 동작해서, 하나의 트랜잭션 안에서 같은 엔티티를 두 번 조회하면 두 번째는 DB를 안 친다. 하지만 트랜잭션이 끝나면 이 캐시는 통째로 버려진다. 웹 요청마다 트랜잭션이 새로 열리므로, 요청이 다르면 1차 캐시는 아무 도움이 안 된다.

// 같은 트랜잭션 안 — 두 번째 find는 1차 캐시 히트 (DB 안 감)
Product p1 = em.find(Product.class, 1L); // SELECT 실행
Product p2 = em.find(Product.class, 1L); // 캐시 히트, SELECT 없음
// 트랜잭션 종료 → 1차 캐시 소멸

// 다음 요청(새 트랜잭션) — 다시 DB SELECT
Product p3 = em2.find(Product.class, 1L); // SELECT 다시 실행
Java

위 코드에서 p1p2는 같은 영속성 컨텍스트를 공유하므로 두 번째 조회가 캐시 히트로 처리된다. 하지만 트랜잭션이 끝나고 새 요청이 들어온 p3은 캐시가 비워진 상태라 다시 SELECT를 날린다. 2차 캐시는 이 p3 같은 조회를 잡아준다. SessionFactory(EntityManagerFactory) 단위로 살아 있어서 애플리케이션이 떠 있는 동안 여러 트랜잭션이 캐시를 공유한다. 상품 카탈로그, 코드 테이블, 설정값처럼 자주 읽히고 거의 안 바뀌는 데이터에 특히 효과가 크다.

1차 캐시와 2차 캐시의 수명 비교 - 1차 캐시는 트랜잭션 종료 시 소멸하고, 2차 캐시는 SessionFactory 단위로 여러 트랜잭션이 공유하는 차이
1차 캐시와 2차 캐시의 수명 비교 – 1차 캐시는 트랜잭션 종료 시 소멸하고, 2차 캐시는 SessionFactory 단위로 여러 트랜잭션이 공유하는 차이

Spring Boot에서 Hibernate 2차 캐시 켜기

Hibernate 2차 캐시는 캐시 프로바이더와 설정 두 가지를 붙여야 동작한다. Hibernate 6부터는 JCache(JSR-107) 어댑터를 통해 표준 방식으로 붙이며, 실제 캐시 저장소로는 Caffeine을 많이 쓴다. 의존성과 프로퍼티만 맞추면 켜진다.

<!-- pom.xml — JCache 어댑터 + Caffeine 저장소 -->
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>jcache</artifactId>
</dependency>
XML
# application.properties — 2차 캐시 활성화
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
# JCache 구현체로 Caffeine 지정
spring.jpa.properties.hibernate.javax.cache.provider=com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider
# 캐시 통계 수집 (실제 히트/미스 확인용)
spring.jpa.properties.hibernate.generate_statistics=true

use_second_level_cache=true가 2차 캐시 기능 자체를 켜고, region.factory_class가 Hibernate와 캐시 저장소를 잇는 어댑터를 지정한다. 여기까지 해도 아직 아무 엔티티도 캐싱되지 않는다는 점이 중요하다. 2차 캐시는 “켰다고 전부 캐싱”이 아니라, 캐싱할 엔티티를 하나씩 명시적으로 지정해야 한다. 그 지정이 다음 절의 @Cache 애너테이션이다. 참고로 Hibernate 네이티브 @org.hibernate.annotations.Cache를 쓰면 JPA 표준의 shared-cache-mode(jakarta.persistence.sharedCache.mode)를 따로 건드릴 필요가 없다 — @Cache를 붙인 엔티티만 선별적으로 캐싱된다.

@Cache 전략 4가지, 뭘 골라야 하나

캐싱할 엔티티에는 @Cache 애너테이션과 함께 동시성 전략(CacheConcurrencyStrategy)을 지정한다. 이 전략은 “캐시된 데이터가 바뀔 때 어떻게 정합성을 지킬지”를 정하며, 데이터의 변경 빈도와 정합성 요구 수준에 따라 고른다.

import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
import jakarta.persistence.*;

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int price;

    // getter/setter 생략
}
Java

위처럼 @Cache(usage = ...)를 엔티티에 붙여야 비로소 그 엔티티가 2차 캐시 대상이 된다. 전략은 네 가지이며, 각각 쓰임새가 다르다.

전략특징언제 쓰나
READ_ONLY캐시 후 수정 불가, 가장 빠름코드 테이블·국가 목록 등 절대 안 바뀌는 데이터
NONSTRICT_READ_WRITE수정 시 캐시를 무효화(삭제), 락 없음가끔 바뀌고, 짧은 순간 옛 값이 보여도 되는 데이터
READ_WRITE소프트 락으로 정합성 보장자주 읽고 가끔 쓰며 정합성이 필요한 일반적 경우
TRANSACTIONALJTA 트랜잭션과 완전 연동XA 트랜잭션이 필요한 분산 환경(설정 까다로움)

가장 무난한 선택은 READ_WRITE다. 읽기가 많고 쓰기가 가끔 있는 대부분의 엔티티에 맞고, 수정 시 소프트 락으로 다른 트랜잭션이 옛 캐시를 읽는 걸 막아준다. 반대로 절대 안 바뀌는 데이터라면 READ_ONLY가 오버헤드 없이 가장 빠르다. NONSTRICT_READ_WRITE는 락이 없어 살짝 빠르지만, 수정 직후 아주 짧은 순간 다른 트랜잭션이 옛 값을 볼 수 있어 정합성이 느슨하다. 정합성이 중요한 곳에는 쓰지 않는 게 좋다.

엔티티는 캐시되는데 쿼리는 왜 계속 DB를 칠까

엔티티 캐시와 쿼리 캐시의 동작 차이 - ID 조회는 엔티티 캐시 히트, JPQL 쿼리는 별도 쿼리 캐시와 setHint가 필요하다는 비교
엔티티 캐시와 쿼리 캐시의 동작 차이 – ID 조회는 엔티티 캐시 히트, JPQL 쿼리는 별도 쿼리 캐시와 setHint가 필요하다는 비교

@Cache를 붙여도 Hibernate 2차 캐시가 먹는 건 ID로 조회하는 경우(find, getReference)뿐이다. JPQL이나 findAll 같은 쿼리는 엔티티 캐시와 별개라, 엔티티를 캐싱해뒀어도 쿼리는 매번 DB로 나간다. 여기서부터가 2차 캐시를 처음 붙인 사람이 가장 많이 걸리는 지점이다.

// 엔티티 캐시가 먹는 경우 — ID 조회
Product p = em.find(Product.class, 1L);       // 2차 캐시 히트 가능

// 엔티티 캐시가 안 먹는 경우 — 쿼리
List<Product> list = em.createQuery(
        "select p from Product p where p.price > 1000", Product.class)
        .getResultList();                     // 매번 DB SELECT
Java

find는 엔티티 캐시를 확인하지만, JPQL 쿼리는 “이 조건에 맞는 결과 집합”을 캐시에서 찾지 못한다. 쿼리 결과까지 캐싱하려면 쿼리 캐시(query cache)를 따로 켜고, 쿼리마다 명시적으로 캐시 힌트를 줘야 한다. 이 두 단계 중 하나만 빠져도 쿼리 캐시는 동작하지 않는다. 실제로 Baeldung의 2차 캐시 가이드나 Hibernate 공식 포럼에서도 프로바이더 설정만 하고 쿼리 캐시 힌트를 빼먹어 “캐시가 안 먹는다”고 묻는 사례가 반복된다.

# 1단계: 쿼리 캐시 기능 켜기
spring.jpa.properties.hibernate.cache.use_query_cache=true
// 2단계: 쿼리마다 캐시 힌트 명시 (이게 없으면 쿼리 캐시 미동작)
List<Product> list = em.createQuery(
        "select p from Product p where p.price > 1000", Product.class)
        .setHint("org.hibernate.cacheable", true)
        .getResultList();
Java

use_query_cache=true로 기능을 켜고, 각 쿼리에 setHint("org.hibernate.cacheable", true)를 줘야 그 쿼리의 결과가 캐싱된다. Spring Data JPA라면 @QueryHints로 같은 힌트를 리포지토리 메서드에 붙일 수 있다. 다만 쿼리 캐시는 대상 엔티티 테이블이 하나라도 변경되면 관련 쿼리 캐시가 통째로 무효화되므로, 쓰기가 잦은 테이블에 걸면 캐시가 계속 비워져 오히려 손해다. 거의 안 바뀌는 마스터 데이터 조회에만 선별적으로 쓰는 게 안전하다.

연관관계(컬렉션)도 캐시하려면

엔티티를 캐싱해도 그 엔티티가 들고 있는 @OneToMany 같은 컬렉션은 자동으로 캐시되지 않는다. 컬렉션 캐시는 별도로 @Cache를 컬렉션 필드에 붙여야 한다. 이걸 빼먹으면 부모 엔티티는 캐시 히트인데 자식 컬렉션을 읽는 순간 다시 DB로 나가는 상황이 생긴다.

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Category {

    @Id
    private Long id;

    private String name;

    @OneToMany(mappedBy = "category")
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // 컬렉션도 별도 지정
    private List<Product> products = new ArrayList<>();
}
Java

컬렉션 캐시는 연관된 엔티티 객체 전체가 아니라 자식들의 ID 목록만 저장한다는 점을 알아둘 필요가 있다. 즉 Categoryproducts 컬렉션 캐시는 상품 ID 리스트를 갖고 있고, 실제 각 ProductProduct 엔티티 캐시에서 가져온다. 그래서 컬렉션 캐시가 효과를 보려면 자식 엔티티(Product)에도 @Cache가 걸려 있어야 한다. 둘 중 하나만 걸면 반쪽짜리가 된다.

캐시가 실제로 먹히는지 어떻게 확인할까

Hibernate 2차 캐시를 걸었으면 정말 DB 조회가 줄었는지 숫자로 확인해야 한다. 감으로 “빨라진 것 같다”가 아니라, Hibernate Statistics의 히트/미스 카운트를 보는 게 확실하다. 앞서 설정한 hibernate.generate_statistics=true가 이 통계를 켠다.

import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;

Statistics stats = entityManagerFactory
        .unwrap(SessionFactory.class)
        .getStatistics();

long hit = stats.getSecondLevelCacheHitCount();   // 2차 캐시 히트 수
long miss = stats.getSecondLevelCacheMissCount();  // 미스 수
long put = stats.getSecondLevelCachePutCount();    // 캐시에 저장된 횟수

System.out.printf("L2 cache — hit: %d, miss: %d, put: %d%n", hit, miss, put);
Java

같은 엔티티를 서로 다른 트랜잭션에서 두 번 조회한 뒤 이 통계를 찍어보면, 두 번째 조회에서 hitCount가 올라가고 SQL 로그에 SELECT가 안 찍히는 걸 확인할 수 있다. 이런 검증은 Testcontainers 통합 테스트 안에서 실제 DB로 돌려보면 더 신뢰할 수 있다. 반대로 아무리 조회해도 missCount만 오르고 hitCount가 0이라면, @Cache를 안 붙였거나 프로바이더 설정이 틀렸거나 쿼리로만 조회하고 있는 것이다. 개발 단계에서는 spring.jpa.show-sql=true로 실제 SELECT가 사라지는지 함께 보면 가장 직관적이다.

실제로 Spring Boot 4.1 + Hibernate 프로젝트로 직접 돌려봤다. 캡처와 같은 결과를 재현하는 최소 구성은 아래 세 조각이다. 먼저 2차 캐시를 켜고 통계를 수집하는 설정이다.

# application.properties — 2차 캐시 활성화 + 통계
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.JCacheRegionFactory
spring.jpa.properties.hibernate.javax.cache.provider=com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider
spring.jpa.properties.hibernate.generate_statistics=true
spring.jpa.show-sql=true

그리고 @Cache로 2차 캐시 대상으로 지정한 엔티티다. 이 애너테이션이 없으면 설정을 켜둬도 캐싱되지 않는다.

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // 2차 캐시 대상 지정
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int price;
    // 생성자·getter 생략
}
Java

마지막으로 같은 상품을 서로 다른 세 트랜잭션에서 조회하는 실행 코드다.

// 상품 1건 저장 후, 서로 다른 트랜잭션에서 같은 ID를 3번 조회
Long id = repository.save(new Product("기계식 키보드", 89000)).getId();

Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.clear();

// Tx1: 최초 조회 → DB SELECT + 캐시 저장
txTemplate.executeWithoutResult(s -> repository.findById(id).orElseThrow());
// Tx2, Tx3: 같은 ID 재조회 → 2차 캐시 히트 (SELECT 없음)
txTemplate.executeWithoutResult(s -> repository.findById(id).orElseThrow());
txTemplate.executeWithoutResult(s -> repository.findById(id).orElseThrow());

System.out.printf("L2 cache -> hit: %d, miss: %d, put: %d%n",
        stats.getSecondLevelCacheHitCount(),
        stats.getSecondLevelCacheMissCount(),
        stats.getSecondLevelCachePutCount());
Java

TransactionTemplate으로 매번 새 트랜잭션을 열어 조회하므로 1차 캐시는 도움이 안 되고, 순수하게 2차 캐시 동작만 관찰할 수 있다. 아래는 이 코드를 실행한 로그다. 첫 조회(Tx1)에서만 select ... from product SQL이 나가고, 재조회(Tx2·Tx3)에서는 SELECT가 사라진다. 통계로도 hit: 2, miss: 1, put: 1이 찍혀 첫 조회만 DB로 갔고 나머지는 캐시에서 처리됐음이 그대로 드러난다.

Hibernate 2차 캐시 실행 결과 - 첫 조회에서만 SELECT가 발생하고 재조회는 캐시 히트로 처리되며 통계에 hit 2 miss 1 put 1이 찍힌 실제 터미널 출력
Hibernate 2차 캐시 실행 결과 – 첫 조회에서만 SELECT가 발생하고 재조회는 캐시 히트로 처리되며 통계에 hit 2 miss 1 put 1이 찍힌 실제 터미널 출력

2차 캐시를 쓰면 안 되는 경우

2차 캐시는 만능이 아니다. 오히려 독이 되는 상황이 분명히 있다. 자주 변경되는 엔티티, 정합성이 극도로 중요한 데이터, 그리고 캐시 정합성 관리가 어려운 다중 인스턴스 환경이 대표적이다.

쓰기가 잦은 엔티티에 캐시를 걸면, 수정할 때마다 캐시를 무효화하고 다시 채우는 비용이 조회로 아끼는 비용을 넘어선다. 캐시 히트율은 낮은데 관리 오버헤드만 늘어나는 것이다. 또 하나 주의할 점은 애플리케이션을 여러 인스턴스로 띄우는 경우다. Caffeine 같은 로컬(인메모리) 캐시는 각 인스턴스가 자기 캐시를 따로 들고 있어서, 한 인스턴스에서 데이터를 수정해도 다른 인스턴스의 캐시는 여전히 옛 값을 들고 있다. 이런 분산 환경에서 정합성이 중요하다면 로컬 캐시 대신 Redis나 Infinispan 같은 분산 캐시를 프로바이더로 써야 하고, 그마저도 정합성 시나리오를 신중히 검토해야 한다. “읽기가 압도적으로 많고, 짧은 지연은 허용되며, 거의 안 바뀌는 데이터”라는 조건이 맞을 때만 2차 캐시가 이득이라는 걸 기억하는 게 좋다.

Spring @Cacheable과 Hibernate 2차 캐시는 뭐가 다를까

이름이 비슷해서 헷갈리지만, Spring의 @Cacheable과 Hibernate 2차 캐시는 캐싱하는 계층이 다르다. Spring @Cacheable은 메서드 반환값을 캐싱하는 애플리케이션 레벨 캐시이고, Hibernate 2차 캐시는 엔티티 자체를 영속성 계층에서 캐싱한다.

Spring @Cacheable은 “이 메서드를 같은 인자로 부르면 실행 없이 저장된 반환값을 준다”는 방식이라, 서비스 메서드나 계산 결과처럼 JPA와 무관한 것도 캐싱할 수 있고 제어가 직관적이다. 반면 Hibernate 2차 캐시는 엔티티 단위로 동작해서, 어느 경로로 조회하든(다른 서비스, 다른 쿼리) 같은 엔티티라면 캐시를 공유한다. 둘은 배타적이지 않고 함께 쓸 수 있다. 실무에서는 “특정 서비스 메서드의 결과 묶음”은 Spring @Cacheable로, “여기저기서 ID로 조회되는 마스터 엔티티”는 Hibernate 2차 캐시로 나눠 거는 식의 조합이 흔하다. 다만 두 캐시를 같은 데이터에 겹쳐 걸면 무효화 타이밍이 어긋나 정합성이 꼬일 수 있으니, 한 데이터는 한 계층에서만 캐싱하는 원칙을 지키는 게 안전하다.

FAQ

Hibernate 2차 캐시와 1차 캐시의 차이는 무엇인가

1차 캐시는 하나의 영속성 컨텍스트(트랜잭션) 안에서만 살아 있어 트랜잭션이 끝나면 사라진다. Hibernate 2차 캐시는 SessionFactory 단위로 동작해 애플리케이션이 떠 있는 동안 여러 트랜잭션과 요청이 캐시를 공유한다. 요청을 넘어 반복 조회되는 데이터를 캐싱하려면 2차 캐시가 필요하다.

@Cache를 붙였는데도 캐시가 안 먹는다

가장 흔한 원인은 세 가지다. 첫째, ID(find)가 아니라 JPQL 쿼리로 조회하고 있는 경우 — 이건 쿼리 캐시를 따로 켜야 한다. 둘째, region.factory_class나 프로바이더 설정이 빠진 경우. 셋째, @Cache 애너테이션 자체를 엔티티에 안 붙인 경우다. hibernate.generate_statistics=true로 히트/미스 카운트를 찍어보면 어느 쪽인지 바로 확인된다.

쿼리 캐시는 항상 켜는 게 좋은가

아니다. 쿼리 캐시는 대상 테이블이 한 번이라도 변경되면 관련 쿼리 캐시가 통째로 무효화된다. 쓰기가 잦은 테이블에 걸면 캐시가 계속 비워져 오히려 손해다. 거의 변하지 않는 마스터 데이터 조회에만 setHint("org.hibernate.cacheable", true)로 선별 적용하는 것이 안전하다.

어떤 CacheConcurrencyStrategy를 골라야 하나

읽기가 많고 쓰기가 가끔 있는 일반적 엔티티는 READ_WRITE가 무난하다. 절대 안 바뀌는 데이터는 READ_ONLY가 가장 빠르고, 짧은 순간의 옛 값이 허용되는 데이터는 NONSTRICT_READ_WRITE도 선택지다. TRANSACTIONAL은 JTA/XA 환경에서만 쓰며 설정이 까다롭다.

여러 서버로 배포하면 2차 캐시 정합성은 어떻게 되나

Caffeine 같은 로컬 캐시는 인스턴스마다 별도라, 한 서버에서 수정해도 다른 서버 캐시는 옛 값을 들고 있다. 다중 인스턴스에서 정합성이 필요하면 Redis나 Infinispan 같은 분산 캐시 프로바이더를 써야 하며, 그 경우에도 캐시 무효화 전파 지연을 고려해야 한다.

마치며

2차 캐시를 처음 붙였을 때 가장 당황스러웠던 건 “분명 켰는데 SQL이 그대로 나가는” 상황이었다. 알고 보니 리스트 조회를 JPQL로 하고 있었고, 엔티티 캐시는 ID 조회에만 먹는다는 걸 몰랐던 것이다. 그 뒤로는 캐시를 걸 때 항상 Statistics의 히트 카운트부터 확인하는 습관이 생겼다. 숫자로 확인하지 않으면 캐시가 먹는지 안 먹는지 감으로는 알 수 없기 때문이다. 2차 캐시는 잘 맞는 데이터(자주 읽고 거의 안 바뀌는)에 걸면 DB 부하를 확실히 줄여주지만, 아무 데나 걸면 무효화 비용과 정합성 문제만 떠안는다. “이 데이터가 정말 캐시에 맞는가”를 먼저 따지는 게 설정 문법보다 중요하다는 걸 매번 느낀다.