들어가며
안녕하세요.
JPA에는 캐시(Cache) 가 두 단계로 존재합니다. 바로 1차 캐시와 2차 캐시입니다.
이전 글에서 영속성 컨텍스트와 엔티티 상태를 다루면서 1차 캐시를 잠깐 언급했습니다.
처음엔 "캐시가 왜 두 개나 필요하지?" 싶었는데 공부하면서 각각의 역할이 꽤 다르다는 걸 알게 됐습니다.
이번 글에서는 1차 캐시가 정확히 어떻게 동작하는지, 그리고 1차 캐시만으로는 왜 부족한지, 그 한계를 2차 캐시가 어떻게 보완하는지 정리해보려 합니다.
캐시를 잘못 쓰면 데이터 불일치 같은 버그로 이어질 수 있어서 언제 써야 하고 언제 쓰면 안 되는지도 함께 정리했습니다.
1. 1차 캐시란?
1차 캐시는 영속성 컨텍스트 내부에 있는 Map 형태의 저장소입니다.
별도 설정 없이 JPA를 사용하는 순간부터 항상 동작하는 기본 캐시입니다.
[영속성 컨텍스트]
┌─────────────────────────────────────────┐
│ 1차 캐시 (Map<PK, Entity>) │
│ ┌─────────────────────────────────┐ │
│ │ key: 1L → value: Member A │ │
│ │ key: 2L → value: Member B │ │
│ │ key: 3L → value: Member C │ │
│ └─────────────────────────────────┘ │
│ │
│ 스냅샷 저장소 (변경 감지용) │
└─────────────────────────────────────────┘
1차 캐시의 동작 흐름
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
// 첫 번째 조회
// 1. 1차 캐시에 id=1 있는지 확인 → 없음
// 2. DB에 SELECT SQL 전송
// 3. 결과를 1차 캐시에 저장
// 4. 반환
Member member1 = em.find(Member.class, 1L);
// 두 번째 조회
// 1. 1차 캐시에 id=1 있는지 확인 → 있음!
// 2. 캐시에서 바로 반환 (DB 조회 없음)
Member member2 = em.find(Member.class, 1L);
System.out.println(member1 == member2); // true
실제로 Hibernate의 SQL 로그를 보면 SELECT 문이 한 번만 출력됩니다.
Hibernate: select member0_.id as id1_0_0_, member0_.name as name2_0_0_
from member member0_
where member0_.id=?
// 두 번째 find()에서는 SQL 로그가 찍히지 않습니다
1차 캐시가 가져다주는 것
1. 같은 트랜잭션 내 반복 조회 최적화
동일 트랜잭션 안에서 같은 데이터를 여러 번 조회해도 DB에는 한 번만 갑니다. 여러 서비스 레이어에서 같은 엔티티를 조회하는 일이 잦은 경우 이 효과가 큽니다.
2. 동일성(Identity) 보장
같은 PK로 조회한 엔티티는 항상 == 비교가 성립하는 동일 인스턴스입니다. 여러 곳에서 같은 엔티티를 조회해도 항상 같은 자바 객체를 바라봅니다.
3. 변경 감지(Dirty Checking)의 기반
1차 캐시는 단순히 엔티티 객체만 저장하는 것이 아니라, 최초 조회 시점의 스냅샷도 함께 저장합니다. flush 시점에 현재 상태와 스냅샷을 비교해 변경된 부분을 감지하고 UPDATE SQL을 자동 생성합니다.
persist() 또는 find() 시점:
→ 엔티티를 1차 캐시에 저장
→ 그 순간의 상태를 스냅샷으로 복사해 별도 보관
flush() 시점:
→ 현재 엔티티 상태 vs 스냅샷 비교
→ 다른 필드 발견 → UPDATE SQL 생성
→ 변경 없음 → SQL 생성 안 함 (성능 절약)
1차 캐시의 한계 — 트랜잭션이 끝나면 사라진다
1차 캐시는 영속성 컨텍스트와 생명주기를 함께합니다. 트랜잭션이 끝나면 영속성 컨텍스트도 닫히고, 1차 캐시도 완전히 사라집니다.
사용자 A 요청 → 트랜잭션 시작 → em.find() → DB 조회 → 1차 캐시 저장 → 트랜잭션 종료
│
캐시 소멸 ✗
사용자 B 요청 → 트랜잭션 시작 → em.find() → 1차 캐시 없음 → DB 조회 → ...
사용자 C 요청 → 트랜잭션 시작 → em.find() → 1차 캐시 없음 → DB 조회 → ...
수천 명의 사용자가 자주 조회하는 데이터(예: 상품 카테고리, 공통 코드, 지역 정보 등)가 있다고 생각해보세요.
모든 요청마다 DB에 SELECT가 날아갑니다. 데이터는 거의 바뀌지 않는데도 말이죠.
이 문제를 해결 하기위 해서 등장하는것이 2차 캐시입니다.
2. 2차 캐시란?
2차 캐시는 EntityManagerFactory 수준에서 동작하는 애플리케이션 전체 공유 캐시입니다.
트랜잭션이 종료되어도 캐시가 유지되고, 여러 사용자의 요청이 같은 캐시를 공유합니다.
[애플리케이션 전체]
┌──────────────────────────────────────────────────────┐
│ EntityManagerFactory │
│ │
│ ┌────────────────────────────────────────────────┐ │
│ │ 2차 캐시 (애플리케이션 전체 공유) │ │
│ │ key: Member#1 → value: { id=1, name="홍" } │ │
│ │ key: Member#2 → value: { id=2, name="김" } │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ EM1 (사용자A) EM2 (사용자B) EM3 (사용자C) │
│ [1차 캐시] [1차 캐시] [1차 캐시] │
└──────────────────────────────────────────────────────┘
1차 캐시 vs 2차 캐시 비교
| 구분 | 1차 캐시 | 2차 캐시 |
|---|---|---|
| 범위 | 트랜잭션 단위 (EntityManager) | 애플리케이션 전체 (EntityManagerFactory) |
| 생명주기 | 트랜잭션 종료 시 소멸 | 애플리케이션 종료 시 소멸 (또는 명시적 만료) |
| 스레드 간 공유 | ✗ (독립적) | ✓ (모든 스레드 공유) |
| 기본 활성화 | 항상 활성화 | 별도 설정 필요 |
| 동시성 문제 | 없음 | 있음 → 전략 선택 필요 |
| 반환 방식 | 엔티티 직접 반환 | 복사본(Copy) 반환 |
2차 캐시 조회 흐름
em.find(Member.class, 1L) 호출
│
▼
① 1차 캐시 확인 ── 있으면 ──► 즉시 반환
│ 없으면
▼
② 2차 캐시 확인 ── 있으면 ──► 복사본 생성 → 1차 캐시 저장 → 반환
│ 없으면
▼
③ DB SELECT 실행
│
▼
→ 결과를 2차 캐시에 저장
→ 1차 캐시에도 저장
→ 반환
2차 캐시에 한 번 올라간 데이터는 이후 요청들이 DB를 전혀 거치지 않고 캐시에서 바로 응답받습니다.
2차 캐시는 왜 복사본을 반환할까?
2차 캐시가 엔티티 객체를 직접 반환하지 않고 복사본을 반환하는 이유가 있습니다.
2차 캐시는 모든 스레드가 공유하는 공간입니다. 만약 캐시의 객체를 직접 반환한다면, 여러 스레드가 동시에 그 객체를 수정하는 동시성 문제가 생깁니다.
복사본은 어떻게 만들어질까?
2차 캐시는 복사본을 만들 때 내부적으로 직렬화(Serialization) / 역직렬화(Deserialization) 방식을 사용합니다.
즉, 캐시에 저장할 때 객체를 바이트 스트림으로 변환하고, 꺼낼 때 다시 새 객체로 복원합니다.
// 2차 캐시를 사용하는 엔티티는 반드시 Serializable을 구현해야 합니다
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class ProductCategory implements Serializable { // ← 필수
@Id
private Long id;
private String name;
}
이 때문에 다음과 같은 성능 트레이드오프가 존재합니다.
| 캐시 저장 시 | 직렬화 비용 발생 |
| 캐시 조회 시 | 역직렬화 비용 발생 |
| DB 조회 시 | 네트워크 I/O + 쿼리 실행 비용 발생 |
직렬화/역직렬화 비용이 DB 조회 비용보다 훨씬 작기 때문에 일반적으로 2차 캐시가 유리하지만 조회 빈도가 낮거나 객체 크기가 매우 큰 경우에는 오히려 손해일 수 있습니다. 캐시 통계(히트율)를 꼭 확인해야 하는 이유이기도 합니다.
3. 2차 캐시 설정하기
의존성 추가 (EhCache 기준)
<!-- pom.xml -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-ehcache</artifactId>
<version>5.6.x.Final</version>
</dependency>
// build.gradle
implementation 'org.hibernate:hibernate-ehcache:5.6.x.Final'
persistence.xml 설정
<persistence-unit name="myPU">
<properties>
<!-- 2차 캐시 활성화 -->
<property name="hibernate.cache.use_second_level_cache" value="true"/>
<!-- 쿼리 캐시 활성화 (선택사항 — JPQL 결과도 캐시하고 싶을 때) -->
<property name="hibernate.cache.use_query_cache" value="true"/>
<!-- 캐시 구현체 설정 -->
<property name="hibernate.cache.region.factory_class"
value="org.hibernate.cache.ehcache.EhCacheRegionFactory"/>
<!-- 2차 캐시 통계 로그 (개발 시 유용) -->
<property name="hibernate.generate_statistics" value="true"/>
</properties>
</persistence-unit>
Spring Boot application.yml 설정
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
use_query_cache: true
region:
factory_class: org.hibernate.cache.ehcache.EhCacheRegionFactory
generate_statistics: true # 캐시 히트율 등 통계 확인용
엔티티에 캐시 적용
@Entity
@Cacheable // JPA 표준 어노테이션
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Hibernate 어노테이션 (전략 지정)
public class ProductCategory {
@Id
@GeneratedValue
private Long id;
private String name;
// 연관관계 컬렉션도 별도로 캐시 가능
@OneToMany(mappedBy = "category")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private List<Product> products = new ArrayList<>();
}
4. 동시성 전략 — 핵심 선택 포인트
2차 캐시는 여러 스레드가 동시에 접근하기 때문에 데이터 일관성을 어떻게 보장할지 동시성 전략을 선택해야 합니다. 잘못 선택하면 성능 저하 또는 데이터 불일치로 이어집니다.
READ_ONLY
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
- 특징: 캐시에 저장된 데이터를 절대 수정하지 않습니다. 수정 시도 시 예외 발생.
- 성능: 가장 빠릅니다. 동시성 제어가 필요 없기 때문입니다.
- 적합한 데이터: 시/도 코드, 국가 목록, 변경되지 않는 공통 코드 테이블
- 주의: 엔티티를 수정하려 하면
UnsupportedOperationException발생
// 이런 데이터에 적합합니다
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public class Region { // 지역 코드 — 절대 바뀌지 않는 데이터
@Id private Long id;
private String name; // "서울", "부산" ...
}
NONSTRICT_READ_WRITE
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
- 특징: 수정 가능하지만, 수정 직후 극히 짧은 시간 동안 캐시와 DB의 데이터가 다를 수 있습니다. 락(Lock)을 사용하지 않아 빠릅니다.
- 적합한 데이터: 상품 설명, 게시글처럼 자주 읽지만 수정이 드물고, 수정 즉시 최신 데이터가 반드시 보여야 하는 것은 아닌 경우
- 주의: 동시에 두 스레드가 같은 데이터를 수정하면 한쪽 변경이 유실될 수 있습니다
READ_WRITE
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
- 특징: 소프트 락(Soft Lock)을 사용해 수정 중인 데이터에 대한 캐시 접근을 제어합니다. 수정이 일어나는 동안 다른 스레드는 캐시를 우회해 DB에서 직접 읽습니다.
- 성능: NONSTRICT보다 약간 느리지만, 대부분의 일반 케이스에서 적합합니다.
- 적합한 데이터: 수정도 되고, 어느 정도 일관성도 필요한 일반적인 엔티티
- 주의: JTA 트랜잭션 환경에서는 사용 불가. 그 경우 TRANSACTIONAL을 사용해야 합니다.
소프트 락의 동작:
스레드 A: member 수정 시작
→ 캐시에 소프트 락 설정
→ 이 동안 스레드 B가 find() 호출 시 캐시 무시 → DB에서 직접 조회
스레드 A: 수정 완료 → 새 값으로 캐시 업데이트 → 소프트 락 해제
스레드 B: 이후 find() → 갱신된 캐시에서 정상 반환
TRANSACTIONAL
@Cache(usage = CacheConcurrencyStrategy.TRANSACTIONAL)
- 특징: 완전한 트랜잭션 수준의 일관성을 보장합니다. JTA(분산 트랜잭션) 환경에서만 사용 가능합니다.
- 성능: 가장 무겁습니다.
- 적합한 데이터: 금융 잔액, 재고 수량처럼 정확성이 매우 중요한 데이터
동시성 전략 선택 가이드
데이터가 절대 수정되지 않는다
→ READ_ONLY
수정이 드물고, 아주 짧은 순간의 불일치를 허용할 수 있다
→ NONSTRICT_READ_WRITE
수정도 되고, 일반적인 일관성이 필요하다 (대부분의 경우)
→ READ_WRITE
JTA 환경이고 완벽한 트랜잭션 일관성이 필요하다
→ TRANSACTIONAL
5. 쿼리 캐시 (Query Cache)
쿼리 캐쉬는 엔티티 단위가 아니라 JPQL 쿼리의 결과 자체를 캐시하는 기능입니다.
// 쿼리 캐시 사용
List<Member> members = em.createQuery("SELECT m FROM Member m WHERE m.age > :age", Member.class)
.setParameter("age", 20)
.setHint("org.hibernate.cacheable", true) // 이 쿼리 결과를 캐시
.getResultList();
쿼리 캐시는 쿼리 문자열 + 파라미터 를 키로 삼아 결과 목록(PK 리스트)을 캐시합니다. 단, 쿼리 캐시를 사용하려면 쿼리 결과로 반환되는 엔티티도 2차 캐시에 등록되어 있어야 합니다.
주의: 쿼리 대상 테이블에 변경이 발생하면 해당 테이블의 쿼리 캐시가 모두 무효화됩니다. 변경이 잦은 테이블의 쿼리에 쿼리 캐시를 적용하면 오히려 성능이 저하됩니다.
6. 2차 캐시를 쓰면 안 되는 경우
2차 캐시는 잘못 사용하면 득보다 실이 큽니다. 아래 상황에서는 적용을 피해야 합니다.
1. 데이터 변경이 잦은 엔티티
수정이 일어날 때마다 캐시 무효화와 재적재 작업이 발생합니다. 이 오버헤드가 캐시의 이점을 상쇄해버립니다.
// 주문, 결제, 배송 상태처럼 상태가 자주 바뀌는 엔티티 → 2차 캐시 비적합
@Entity
// @Cacheable 붙이지 않음
public class Order {
private OrderStatus status; // 수시로 변경됨
}
2. 캐시 적중률이 낮은 경우
조회 조건이 매우 다양해서 같은 PK의 데이터를 재조회하는 일이 거의 없다면, 캐시에 올려봤자 의미가 없습니다.
3.실시간 정확성이 매우 중요한 데이터
주식 시세, 현재 재고 수량처럼 캐시와 실제 DB 사이의 아주 짧은 불일치조차 허용할 수 없는 경우에는 캐시보다 직접 DB 조회가 안전합니다.
4. 멀티 서버(클러스터) 환경
서버가 여러 대인 경우, 각 서버의 2차 캐시가 서로 독립적으로 존재합니다. 한 서버에서 데이터를 수정해도 다른 서버의 캐시가 갱신되지 않아 데이터 불일치가 발생할 수 있습니다. 이 경우 분산 캐시(Redis, Hazelcast 등) 와 연동하는 것을 고려해야 합니다.
7. 캐시 통계 확인하기
2차 캐시를 적용했다면, 실제로 효과가 있는지 통계를 확인해야 합니다.
// hibernate.generate_statistics=true 설정 후 사용
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
long hitCount = stats.getSecondLevelCacheHitCount(); // 캐시 히트 수
long missCount = stats.getSecondLevelCacheMissCount(); // 캐시 미스 수
long putCount = stats.getSecondLevelCachePutCount(); // 캐시 저장 수
double hitRatio = (double) hitCount / (hitCount + missCount) * 100;
System.out.printf("2차 캐시 히트율: %.1f%%\n", hitRatio);
// 일반적으로 80% 이상이면 효과적이라고 봅니다
8. 정리 — 캐시 적용 판단 기준
| 기준 | 1차 캐시 | 2차 캐시 |
|---|---|---|
| 활성화 방법 | 자동 | 설정 + 엔티티 어노테이션 |
| 적합한 데이터 | 모든 엔티티 (자동 적용) | 읽기 빈도 높고 변경 드문 데이터 |
| 성능 효과 | 트랜잭션 내 반복 조회 최적화 | 트랜잭션 간 DB 조회 감소 |
| 주의사항 | 트랜잭션 종료 시 소멸 | 동시성 전략 선택, 변경 잦은 데이터 비적합 |
2차 캐시는 읽기가 압도적으로 많고, 변경이 드물며, 여러 사용자가 같은 데이터를 자주 조회하는 경우에 적용할 때 가장 큰 효과를 발휘합니다.
마치며
'JPA' 카테고리의 다른 글
| JPA 영속성 컨텍스트와 엔티티 상태 관리 — 마법 같은 동작의 원리 (0) | 2026.03.10 |
|---|