2차 캐시 — 영속성 컨텍스트 밖에서도 캐시할 수 있을까
같은 데이터를 여러 트랜잭션에서 반복 조회한다면, 매번 DB에 쿼리를 보내야 할까요?
JPA의 1차 캐시는 영속성 컨텍스트 범위에서만 동작합니다. 트랜잭션이 끝나면 캐시도 사라집니다. 여러 사용자가 동일한 데이터를 반복 조회하는 상황에서 2차 캐시 를 활용하면 DB 부하를 크게 줄일 수 있습니다.
1차 캐시 vs 2차 캐시
1차 캐시
트랜잭션 A 트랜잭션 B
┌─────────────────────┐ ┌─────────────────────┐
│ 영속성 컨텍스트 (1차 캐시) │ │ 영속성 컨텍스트 (1차 캐시) │
│ Member(id=1) │ │ (비어있음) │
└─────────────────────┘ └─────────────────────┘
↓ ↓
DB 1번 조회 DB 1번 조회 (또!)
- 영속성 컨텍스트(EntityManager) 범위
- 같은 트랜잭션 내에서만 캐시 효과
- 트랜잭션 종료 시 소멸
2차 캐시
트랜잭션 A 트랜잭션 B 트랜잭션 C
↓ ↓ ↓
┌──────────────────────────────────────┐
│ 2차 캐시 (공유) │
│ Member(id=1) → {name:"심", age:25} │
└──────────────────────────────────────┘
↓
DB 1번만 조회
- 애플리케이션 범위 (SessionFactory 수준)
- 모든 트랜잭션이 공유
- 애플리케이션 종료 시 소멸 (설정에 따라 디스크 저장 가능)
JPA 2차 캐시 설정
Hibernate + Ehcache
// build.gradle
implementation 'org.hibernate.orm:hibernate-jcache'
implementation 'org.ehcache:ehcache:3.10.8'
# application.yml
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
region.factory_class: jcache
javax:
cache:
provider: org.ehcache.jsr107.EhcacheCachingProvider
엔티티에 캐시 적용
@Entity
@Cacheable // JPA 표준 어노테이션
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Hibernate 전용
public class Category {
@Id @GeneratedValue
private Long id;
private String name;
private String description;
}
캐시 동시성 전략
| 전략 | 설명 | 사용 상황 |
|---|---|---|
| READ_ONLY | 읽기 전용, 수정 불가 | 변경되지 않는 코드 테이블 |
| NONSTRICT_READ_WRITE | 동시 수정 시 일관성 미보장 | 가끔 변경되는 데이터 |
| READ_WRITE | 읽기/쓰기 캐시, 락 사용 | 자주 읽고 가끔 수정 |
| TRANSACTIONAL | JTA 트랜잭션 기반 | 엄격한 일관성 필요 |
컬렉션 캐시
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Team {
@OneToMany(mappedBy = "team")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private List<Member> members;
}
컬렉션에 @Cache를 별도로 붙여야 연관 관계도 캐시됩니다.
쿼리 캐시
엔티티 캐시가 find()를 캐시한다면, 쿼리 캐시는 JPQL 쿼리 결과 를 캐시합니다.
spring:
jpa:
properties:
hibernate:
cache:
use_query_cache: true
@QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
List<Member> findByAge(int age);
쿼리 캐시의 동작
- JPQL + 파라미터 값을 키로 사용
- 캐시에 저장되는 값은 엔티티 ID 목록
- 실제 엔티티는 2차 캐시에서 가져옴
따라서 ** 쿼리 캐시와 2차 캐시를 함께 사용 **해야 효과가 있습니다. 쿼리 캐시만 사용하면 ID 목록을 캐시해도 각 엔티티를 다시 DB에서 조회해야 합니다.
쿼리 캐시 주의사항
- 해당 테이블에 UPDATE/INSERT/DELETE가 발생하면 캐시가 무효화 됩니다.
- 변경이 잦은 테이블에서는 캐시 적중률이 극도로 낮아집니다.
- 읽기 비율이 압도적으로 높은 데이터 에만 적용해야 합니다.
Spring Cache Abstraction (@Cacheable)
JPA 2차 캐시와 별개로, Spring은 메서드 단위의 캐시 추상화 를 제공합니다.
설정
@Configuration
@EnableCaching
public class CacheConfig {
}
사용법
@Cacheable은 캐시에 데이터가 있으면 메서드를 실행하지 않고 캐시를 반환합니다.
@Cacheable(value = "categories", key = "#id")
public CategoryDto getCategory(Long id) {
Category category = categoryRepository.findById(id).orElseThrow();
return CategoryDto.from(category);
}
데이터가 수정/삭제되면 @CachePut이나 @CacheEvict로 캐시를 갱신해야 합니다.
@CachePut(value = "categories", key = "#id")
public CategoryDto updateCategory(Long id, UpdateRequest request) {
Category category = categoryRepository.findById(id).orElseThrow();
category.update(request);
return CategoryDto.from(category);
}
@CacheEvict(value = "categories", key = "#id")
public void deleteCategory(Long id) {
categoryRepository.deleteById(id);
}
주요 어노테이션
| 어노테이션 | 역할 |
|---|---|
| @Cacheable | 캐시에 있으면 캐시 반환, 없으면 메서드 실행 후 캐시 저장 |
| @CachePut | 항상 메서드를 실행하고 결과를 캐시에 갱신 |
| @CacheEvict | 캐시에서 해당 키 제거 |
Caffeine 캐시 설정
Caffeine은 고성능 로컬 캐시 라이브러리입니다.
implementation 'com.github.ben-manes.caffeine:caffeine'
implementation 'org.springframework.boot:spring-boot-starter-cache'
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=500,expireAfterWrite=10m
캐시별 세밀한 설정
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.registerCustomCache("categories",
Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(Duration.ofMinutes(30))
.build());
manager.registerCustomCache("members",
Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(5))
.build());
return manager;
}
}
JPA 2차 캐시 vs Spring Cache
| 항목 | JPA 2차 캐시 | Spring @Cacheable |
|---|---|---|
| 캐시 단위 | 엔티티(ID 기반) | 메서드 결과(키 기반) |
| 적용 위치 | 엔티티 클래스 | 서비스 메서드 |
| 캐시 무효화 | JPA가 자동 관리 | 개발자가 직접 관리 |
| 유연성 | JPA에 종속 | 범용적 |
| 복잡도 | 설정이 복잡 | 상대적으로 단순 |
JPA 2차 캐시는 엔티티 단위로 동작하기 때문에 캐시 무효화가 JPA 내부에서 자동으로 이루어지지만, 설정이 복잡하고 제어가 어렵습니다. 따라서 실무에서는 Spring @Cacheable + Caffeine(또는 Redis) 로 DTO 단위 캐시를 하는 것이 더 직관적이고, 캐시 정책을 명시적으로 제어할 수 있어 많이 사용됩니다.
주의할 점
쿼리 캐시만 사용하면 오히려 느려질 수 있다
쿼리 캐시는 결과로 엔티티 ID 목록 만 저장합니다. 2차 캐시 없이 쿼리 캐시만 사용하면 ID 목록을 가져온 후 각 엔티티를 다시 DB에서 조회 해야 합니다. 쿼리 캐시와 2차 캐시를 반드시 함께 사용해야 효과가 있습니다.
변경이 잦은 테이블에 2차 캐시를 적용하면 역효과다
해당 테이블에 UPDATE/INSERT/DELETE가 발생하면 캐시가 무효화됩니다. 변경이 잦으면 캐시 적중률이 극도로 낮아지고 무효화 비용만 추가 됩니다. 잘 변경되지 않는 코드성 데이터(국가, 카테고리)에만 적용해야 합니다.
@CacheEvict를 누락하면 오래된 데이터가 반환된다
Spring @Cacheable을 사용할 때 데이터를 수정/삭제하면서 @CacheEvict를 빠트리면, 캐시에 수정 전 데이터가 계속 남아 사용자에게 잘못된 정보가 보입니다.
정리
| 항목 | 설명 |
|---|---|
| 1차 캐시 | 영속성 컨텍스트(트랜잭션) 범위 |
| 2차 캐시 | 애플리케이션 범위, 분해된 상태로 저장 |
| 쿼리 캐시 | 2차 캐시와 함께 사용해야 효과, 변경 잦으면 부적합 |
| Spring @Cacheable | 메서드 결과를 캐시하는 범용 기능 |
| 실무 추천 | Spring Cache + Caffeine(또는 Redis) 조합 |