Hibernate 2차 캐시란?
Hibernate 2차 캐시(Second-Level Cache)는 SessionFactory 범위에서 엔티티 데이터를 캐싱하여 동일한 엔티티 조회 시 DB 접근을 생략하는 기능입니다. 1차 캐시가 단일 트랜잭션(Session) 내에서만 유효한 것과 달리, 2차 캐시는 모든 세션에서 공유됩니다.
Spring Cache 추상화 실전에서 다룬 애플리케이션 레벨 캐시와 달리, 2차 캐시는 Hibernate가 내부적으로 관리하므로 엔티티 변경 시 자동 무효화가 핵심 장점입니다.
캐시 레벨 구조
| 레벨 | 범위 | 자동 관리 | 설정 필요 |
|---|---|---|---|
| 1차 캐시 | Session(EntityManager) 단위 | ✅ 항상 활성 | ❌ |
| 2차 캐시 | SessionFactory 전역 | ✅ 변경 시 무효화 | ✅ Provider 필요 |
| 쿼리 캐시 | SessionFactory 전역 | ⚠️ 테이블 변경 시 무효화 | ✅ 별도 활성화 |
Spring Boot 설정
Ehcache 3 또는 Caffeine을 Provider로 사용하는 것이 일반적입니다. 여기서는 Ehcache 3(JCache 호환)을 사용합니다.
의존성 추가
<!-- build.gradle.kts -->
dependencies {
implementation("org.hibernate.orm:hibernate-jcache")
implementation("org.ehcache:ehcache:3.10.8:jakarta")
}
application.yml 설정
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
use_query_cache: true
region.factory_class: jcache
jakarta:
cache:
provider: org.ehcache.jsr107.EhcacheCachingProvider
uri: classpath:ehcache.xml
ehcache.xml 설정
<config xmlns='http://www.ehcache.org/v3'>
<!-- 기본 캐시 템플릿 -->
<cache-template name="default">
<expiry>
<ttl unit="minutes">30</ttl>
</expiry>
<heap unit="entries">1000</heap>
</cache-template>
<!-- 엔티티별 캐시 리전 -->
<cache alias="com.example.domain.Product" uses-template="default">
<heap unit="entries">5000</heap>
</cache>
<cache alias="com.example.domain.Category" uses-template="default">
<expiry>
<ttl unit="hours">2</ttl>
</expiry>
<heap unit="entries">200</heap>
</cache>
</config>
엔티티 캐시 적용: @Cache와 CacheConcurrencyStrategy
@Entity
@Cacheable // JPA 표준
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Hibernate 전용
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private BigDecimal price;
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
@OneToMany(mappedBy = "product")
private List<ProductImage> images;
}
| 전략 | 동시성 | 적합한 데이터 |
|---|---|---|
READ_ONLY |
변경 불가, 최고 성능 | 코드 테이블, enum, 불변 데이터 |
NONSTRICT_READ_WRITE |
soft lock, 약간의 stale 허용 | 가끔 변경되는 참조 데이터 |
READ_WRITE |
soft lock, 일관성 보장 | 자주 읽고 가끔 변경되는 데이터 |
TRANSACTIONAL |
JTA 트랜잭션 동기화 | 분산 트랜잭션 환경 |
컬렉션 캐시와 쿼리 캐시
컬렉션 캐시
연관관계 컬렉션도 별도로 캐시할 수 있습니다. 컬렉션 캐시는 엔티티 ID 목록만 저장하고, 각 엔티티는 2차 캐시에서 가져옵니다.
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Category {
@Id @GeneratedValue
private Long id;
private String name;
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@OneToMany(mappedBy = "category")
private List<Product> products; // ID 목록만 캐시됨
}
쿼리 캐시
JPQL/Criteria 쿼리 결과를 캐시합니다. 단, 관련 테이블에 어떤 변경이든 발생하면 전체 무효화되므로 신중하게 사용해야 합니다.
public interface ProductRepository extends JpaRepository<Product, Long> {
@QueryHints(@QueryHint(
name = "org.hibernate.cacheable",
value = "true"
))
List<Product> findByCategory(Category category);
}
// EntityManager 직접 사용 시
List<Product> products = em.createQuery(
"SELECT p FROM Product p WHERE p.price < :max", Product.class)
.setParameter("max", BigDecimal.valueOf(10000))
.setHint("org.hibernate.cacheable", true)
.getResultList();
캐시 무효화와 동시성 제어
Hibernate는 엔티티 변경(insert/update/delete) 시 해당 리전의 캐시를 자동 무효화합니다. 하지만 네이티브 SQL이나 외부 시스템에서 직접 DB를 변경하면 캐시와 불일치가 발생합니다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final EntityManager em;
// 네이티브 SQL 실행 후 수동 캐시 제거
@Transactional
public void bulkUpdatePrice(BigDecimal rate) {
em.createNativeQuery("UPDATE product SET price = price * :rate")
.setParameter("rate", rate)
.executeUpdate();
// 2차 캐시 수동 제거
em.getEntityManagerFactory().getCache().evict(Product.class);
}
// 특정 엔티티만 제거
public void evictProduct(Long productId) {
em.getEntityManagerFactory().getCache().evict(Product.class, productId);
}
// 전체 2차 캐시 클리어
public void evictAll() {
em.getEntityManagerFactory().getCache().evictAll();
}
}
캐시 통계와 모니터링
Hibernate Statistics를 활성화하면 캐시 적중률을 모니터링할 수 있습니다. Spring Micrometer 커스텀 메트릭과 연동하면 Grafana 대시보드에서 실시간 확인이 가능합니다.
# application.yml
spring:
jpa:
properties:
hibernate:
generate_statistics: true
@Component
@RequiredArgsConstructor
public class CacheStatsLogger {
private final EntityManagerFactory emf;
@Scheduled(fixedRate = 60000)
public void logCacheStats() {
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
log.info("2nd Level Cache hit ratio: {}/{}",
stats.getSecondLevelCacheHitCount(),
stats.getSecondLevelCacheMissCount());
log.info("Query Cache hit ratio: {}/{}",
stats.getQueryCacheHitCount(),
stats.getQueryCacheMissCount());
}
}
실전 팁: 캐시 적용 판단 기준
| 적용 추천 | 적용 비추천 |
|---|---|
| 읽기 빈도 높고 변경 적은 데이터 | 자주 변경되는 데이터 (주문, 재고) |
| 코드 테이블, 카테고리, 설정 | 외부 시스템이 직접 DB 변경 |
| 단일 인스턴스 또는 분산 캐시 사용 | 다중 인스턴스 + 로컬 캐시만 |
| 조회 쿼리가 단순한 경우 | 복잡한 집계·조인 쿼리 |
다중 인스턴스 환경에서 로컬 캐시(Ehcache heap)만 사용하면 인스턴스 간 캐시 불일치가 발생합니다. Redis나 Hazelcast 같은 분산 캐시를 Provider로 설정하거나, TTL을 짧게 유지해야 합니다.
정리
Hibernate 2차 캐시는 읽기 빈도가 높고 변경이 적은 엔티티에 적용하면 DB 부하를 크게 줄입니다. CacheConcurrencyStrategy로 동시성을 제어하고, 네이티브 SQL 사용 시 수동 무효화를 잊지 말아야 합니다. 쿼리 캐시는 테이블 단위 무효화라 적용 범위를 좁게 유지하고, Statistics로 적중률을 모니터링하여 실제 효과를 검증하세요.