자주 조회되지만 잘 바뀌지 않는 데이터를 매번 DB에서 읽지 않도록, Hibernate 2차 캐시를 Redis(Redisson)로 적용한 내용을 정리한다.
작성 당시 환경은 다음과 같다. 최신 버전에서 달라진 부분은 글 마지막에 따로 정리했다.
| 항목 | 버전 |
|---|---|
| Spring Boot | 2.7.x |
| Hibernate | 5.6.x |
| Redisson | 3.18.x |
1차 캐시와 2차 캐시
Hibernate에는 캐시가 두 단계로 있다.
1차 캐시 (First-Level Cache)
영속성 컨텍스트(EntityManager) 안에 있는 캐시다. 별도 설정 없이 항상 켜져 있다.
@Transactional
public void example() {
Member m1 = em.find(Member.class, 1L); // DB 조회 → 1차 캐시에 저장
Member m2 = em.find(Member.class, 1L); // 1차 캐시에서 반환 (SQL 없음)
}
영속성 컨텍스트는 보통 트랜잭션 하나 동안만 유지된다. 그래서 다른 요청, 다른 트랜잭션에서는 1차 캐시를 공유하지 못하고 다시 DB를 조회한다.
2차 캐시 (Second-Level Cache)
애플리케이션 전체(SessionFactory)가 공유하는 캐시다. 트랜잭션이 끝나도 남아 있어서, 다른 요청에서도 같은 데이터를 DB 조회 없이 가져올 수 있다. 기본으로 꺼져 있고 설정이 필요하다.
엔티티를 조회하면 다음 순서로 찾는다.
em.find(Member.class, 1L)
│
├─▶ 1차 캐시에 있음? ── 있음 → 반환
│
├─▶ 2차 캐시에 있음? ── 있음 → 엔티티 생성 후 1차 캐시에 저장, 반환
│
└─▶ DB 조회 → 2차 캐시, 1차 캐시에 저장 후 반환
| 1차 캐시 | 2차 캐시 | |
|---|---|---|
| 범위 | 영속성 컨텍스트 (보통 트랜잭션 하나) | 애플리케이션 전체 |
| 설정 | 항상 켜져 있음 | 직접 켜야 함 |
| 저장 형태 | 엔티티 객체 그대로 | 엔티티의 필드 값 (분해된 형태) |
| 저장소 | 메모리 (JVM 힙) | 구현체에 따라 다름 (Ehcache, Redis 등) |
2차 캐시는 엔티티 객체를 그대로 공유하지 않고 필드 값만 저장했다가, 꺼낼 때 새 엔티티 객체를 만든다. 여러 트랜잭션이 같은 객체를 동시에 수정하는 문제를 막기 위해서다.
왜 Redis인가
2차 캐시 구현체로 Ehcache, Caffeine 같은 로컬(JVM 메모리) 캐시를 쓸 수도 있다. 하지만 서버가 여러 대면 서버마다 캐시가 따로 생겨, A 서버에서 데이터를 수정해도 B 서버의 캐시에는 이전 값이 남는다.
Redis 같은 외부 저장소에 캐시를 두면 모든 서버가 하나의 캐시를 공유하므로 이런 불일치가 생기지 않는다. 대신 네트워크를 거치므로 로컬 캐시보다는 느리다. Redisson은 Redis용 Java 클라이언트로, Hibernate 2차 캐시 구현체(RedissonRegionFactory)를 함께 제공한다.
1. 의존성 추가
Redisson은 Hibernate 버전별로 모듈이 나뉘어 있다. 사용 중인 Hibernate 버전에 맞는 모듈을 골라야 한다.
| Hibernate 버전 | Redisson 모듈 |
|---|---|
| 5.3.x ~ 5.6.x | redisson-hibernate-53 |
| 6.x | redisson-hibernate-6 |
Spring Boot 2.7은 Hibernate 5.6을 사용하므로 redisson-hibernate-53을 추가한다.
implementation 'org.redisson:redisson-hibernate-53:3.18.1'
Hibernate 버전은 Spring Boot가 관리하므로 hibernate-core를 따로 지정할 필요는 없다.
처음 정리할 때는
hibernate-core:5.6.14.Final과redisson-hibernate-6를 함께 적었었다.redisson-hibernate-6는 Hibernate 6용 모듈이라 Hibernate 5.6과는 맞지 않는다.
2. 설정
application.properties
# 2차 캐시, 쿼리 캐시 활성화
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.use_query_cache=true
# 2차 캐시 구현체로 Redisson 사용
spring.jpa.properties.hibernate.cache.region.factory_class=org.redisson.hibernate.RedissonRegionFactory
spring.jpa.properties.hibernate.cache.redisson.config=redisson.yml
# Redis에 연결할 수 없으면 캐시 없이 DB 조회로 동작
spring.jpa.properties.hibernate.cache.redisson.fallback=true
# 엔티티 캐시 만료 설정 (단위: ms)
spring.jpa.properties.hibernate.cache.redisson.entity.expiration.time_to_live=600000
spring.jpa.properties.hibernate.cache.redisson.entity.expiration.max_idle_time=300000
# 쿼리 캐시 설정
spring.jpa.properties.hibernate.cache.redisson.query.eviction.max_entries=10000
spring.jpa.properties.hibernate.cache.redisson.query.expiration.time_to_live=600000
spring.jpa.properties.hibernate.cache.redisson.query.expiration.max_idle_time=300000
| 설정 | 의미 |
|---|---|
use_second_level_cache |
엔티티 2차 캐시를 켠다. |
use_query_cache |
쿼리 결과 캐시를 켠다. (쿼리별로 따로 지정해야 적용됨, 아래 참고) |
region.factory_class |
캐시를 실제로 저장할 구현체 |
fallback |
true면 Redis 장애 시 예외 대신 캐시 없이 동작한다. 캐시 서버 장애가 서비스 장애로 번지지 않게 한다. |
time_to_live |
저장 후 이 시간이 지나면 만료 (600000ms = 10분) |
max_idle_time |
이 시간 동안 조회되지 않으면 만료 (300000ms = 5분) |
max_entries |
캐시에 보관할 최대 항목 수. 넘으면 오래된 것부터 제거 |
entity, query 자리에 region 이름을 넣으면 region별로 다르게 설정할 수도 있다.
# "member" region만 만료 시간을 1시간으로
spring.jpa.properties.hibernate.cache.redisson.member.expiration.time_to_live=3600000
redisson.yml
src/main/resources/redisson.yml에 Redis 접속 정보를 작성한다.
singleServerConfig:
address: "redis://localhost:6379"
password: "password"
Redis Sentinel이나 Cluster를 쓴다면 sentinelServersConfig, clusterServersConfig로 작성한다.
3. 엔티티에 캐시 적용
설정만 켠다고 모든 엔티티가 캐시되지는 않는다. Hibernate는 기본적으로 캐시하겠다고 표시한 엔티티만 캐시한다(ENABLE_SELECTIVE).
import javax.persistence.Cacheable;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "team")
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
// 연관 컬렉션은 따로 캐시 지정이 필요하다
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "team.members")
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "member")
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
@Cacheable(JPA 표준) — 이 엔티티를 2차 캐시 대상으로 표시한다.@Cache(Hibernate) — 동시성 전략과 region(캐시 저장 공간 이름)을 지정한다. Redis에는 region 이름으로 데이터가 저장된다.
컬렉션 캐시는 연관 엔티티의 ID 목록만 저장한다. team.members 캐시에는 [1, 2, 3] 같은 Member ID만 들어 있고, 실제 Member 데이터는 Member 엔티티 캐시에서 찾는다. 그래서 컬렉션을 캐시하려면 연관 엔티티(Member)도 반드시 캐시 대상이어야 한다. 그렇지 않으면 ID 목록만 캐시에서 가져온 뒤 Member를 하나씩 DB에서 조회하게 되어 오히려 쿼리가 늘어난다.
캐시 동시성 전략
| 전략 | 동작 | 적합한 데이터 |
|---|---|---|
READ_ONLY |
읽기만 가능. 수정하면 예외 발생 | 코드표, 국가 목록 등 절대 안 바뀌는 데이터 |
NONSTRICT_READ_WRITE |
수정 시 캐시를 무효화만 함. 잠깐 이전 값이 보일 수 있음 | 가끔 바뀌고, 약간의 불일치가 괜찮은 데이터 |
READ_WRITE |
수정 중인 항목에 잠금(soft lock)을 걸어 일관성 유지 | 자주 읽고 가끔 수정하는 일반 데이터 |
TRANSACTIONAL |
JTA 트랜잭션과 함께 동작 | JTA 트랜잭션을 쓰는 환경 |
READ_WRITE는 Hibernate를 통해 엔티티를 수정하면, 트랜잭션이 커밋될 때 캐시에도 반영해 다른 트랜잭션이 수정 전 값을 읽지 않도록 한다.
단, 이 일관성은 Hibernate를 거친 수정에만 해당한다. DB 콘솔에서 직접 고치거나, 같은 DB를 쓰는 다른 애플리케이션이 수정하면 Hibernate는 알 수 없다. 이런 경우 캐시가 만료(
time_to_live)될 때까지 이전 값이 조회된다. 또 네이티브 쿼리로UPDATE/DELETE를 실행하면, Hibernate는 어떤 엔티티가 바뀌었는지 모르기 때문에 기본적으로 2차 캐시 전체를 비운다.
4. 쿼리 캐시 사용
em.find()나 findById()처럼 ID로 조회하는 경우에만 엔티티 캐시가 사용된다. findByName() 같은 조건 조회는 엔티티 캐시를 타지 않고 SQL을 실행한다.
조건 조회 결과를 캐시하려면 쿼리 캐시를 쓰고, 쿼리마다 캐시 사용을 지정해야 한다.
public interface MemberRepository extends JpaRepository<Member, Long> {
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Member> findByName(String name);
}
쿼리 캐시도 컬렉션 캐시처럼 결과 엔티티의 ID 목록만 저장한다. 결과 엔티티가 엔티티 캐시 대상이 아니면 ID마다 DB를 조회하게 된다.
또 쿼리 캐시는 Hibernate를 통해 관련 테이블에 변경이 한 건이라도 생기면 그 테이블을 사용하는 쿼리 캐시가 모두 무효화된다. 수정이 잦은 테이블에서는 효과가 거의 없으므로, 거의 바뀌지 않는 데이터에만 쓰는 것이 좋다.
5. 동작 확인
통계 로그
Hibernate 통계를 켜면 요청마다 2차 캐시 적중 여부가 로그로 출력된다.
spring.jpa.properties.hibernate.generate_statistics=true
같은 엔티티를 두 번(서로 다른 트랜잭션에서) 조회하면 다음과 같은 차이를 볼 수 있다.
# 첫 번째 조회: 캐시에 없어서 DB 조회 후 저장 (일부 생략)
... nanoseconds spent executing 1 JDBC statements;
... nanoseconds spent performing 1 L2C puts;
... nanoseconds spent performing 0 L2C hits;
... nanoseconds spent performing 1 L2C misses;
# 두 번째 조회: 캐시에서 가져옴
... nanoseconds spent executing 0 JDBC statements;
... nanoseconds spent performing 0 L2C puts;
... nanoseconds spent performing 1 L2C hits;
... nanoseconds spent performing 0 L2C misses;
두 번째 조회에서 실행된 JDBC 문이 0개이고 L2C hits가 1이면 2차 캐시에서 가져온 것이다.
통계 수집에는 비용이 있으므로 확인이 끝나면 끈다.
Redis에서 확인
redis-cli
> KEYS *
member, team, team.members처럼 region 이름으로 된 키가 보이면 캐시가 저장된 것이다. 쿼리 캐시를 쓰면 default-query-results-region 같은 Hibernate 기본 region도 함께 생긴다. 만료 시간 관리를 위해 Redisson이 만드는 보조 키(redisson__timeout__set:{member} 등)도 같이 보일 수 있다.
KEYS *는 Redis 전체를 훑는 명령이라 운영 서버에서는 쓰지 않는다. 운영 환경에서는SCAN을 사용한다.
Spring Boot 3 / Hibernate 6에서 달라진 점
Redisson 모듈 변경
Spring Boot 3는 Hibernate 6를 사용하므로 redisson-hibernate-6로 바꾼다.
// Spring Boot 2.7 (Hibernate 5.6)
implementation 'org.redisson:redisson-hibernate-53:3.18.1'
// Spring Boot 3.x (Hibernate 6.x)
implementation 'org.redisson:redisson-hibernate-6:<최신 버전>'
RedissonRegionFactory 클래스 이름과 hibernate.cache.redisson.* 설정은 그대로다.
패키지와 설정 이름
javax.persistence.Cacheable→jakarta.persistence.Cacheable- 캐시 모드를 직접 지정했다면 속성 이름도 바뀐다.
# Spring Boot 2.x
spring.jpa.properties.javax.persistence.sharedCache.mode=ENABLE_SELECTIVE
# Spring Boot 3.x
spring.jpa.properties.jakarta.persistence.sharedCache.mode=ENABLE_SELECTIVE
@Cache, CacheConcurrencyStrategy 등 Hibernate 어노테이션은 그대로 쓸 수 있다.