이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. Auto Scaling 환경에서 JPA 1차 캐시와 2차 캐시의 범위가 어떻게 다르고, 어떤 경우에 데이터 정합성 문제가 생길 수 있는지 정리합니다.
결론부터#
| 캐시 | 범위 | Auto Scaling 시 정합성 |
|---|---|---|
| 1차 캐시 (영속성 컨텍스트) | Session / EntityManager 범위 (일반적인 Spring 앱에서는 보통 트랜잭션 범위) | 노드 간 공유되지 않으므로 분산 환경 정합성 문제의 직접 원인은 아님 |
| 2차 캐시 (로컬 — Ehcache 등) | 서버 JVM | 깨질 수 있음 |
| 2차 캐시 (공유/분산 — Redis 등) | 여러 서버가 같은 캐시를 공유 | 로컬 캐시보다 유리하지만, 구성/무효화/외부 수정 여부에 따라 stale data 가능 |
1차 캐시 — 영속성 컨텍스트 (Persistence Context)#
정의#
Hibernate 공식 문서에 따르면:
A persistence context acts as a cache, often called the ‘first-level cache’. It maintains a unique mapping of entity identifiers to entity instances that have been read or made persistent within its scope.
영속성 컨텍스트는 엔티티 ID → 엔티티 인스턴스의 맵으로 동작하는 1차 캐시다.
엔티티의 생명주기#
엔티티 lifecycle은 보통 4가지 상태로 설명한다:
┌──────────┐ persist() ┌──────────┐ detach()/clear() ┌──────────┐
│ Transient │ ───────────> │ Managed │ ─────────────────> │ Detached │
│ (비영속) │ │ (영속) │ │ (준영속) │
└──────────┘ └──────────┘ └──────────┘
│ │ │
│ │ remove() │ merge()
│ ▼ │
│ ┌──────────┐ │
│ │ Removed │ │
│ │ (삭제) │ │
│ └──────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
merge()로 다시 영속 상태로| 상태 | 설명 |
|---|---|
| Transient | new로 생성만 한 상태. 영속성 컨텍스트와 무관 |
| Managed | persist() 또는 조회로 영속성 컨텍스트에 들어온 상태 |
| Detached | 영속성 컨텍스트에서 분리된 상태 (트랜잭션 종료 등) |
| Removed | remove() 호출. 커밋 시 DELETE 실행 |
핵심: 일반적인 Spring 앱에서는 트랜잭션 범위로 이해해도 된다#
@Service
public class UserService {
@Transactional // ← 트랜잭션 시작 = 영속성 컨텍스트 생성
public void updateUser(Long id) {
User user = userRepository.findById(id).get(); // DB 조회 → 1차 캐시에 저장
User same = userRepository.findById(id).get(); // 1차 캐시에서 반환 (SQL 안 나감)
System.out.println(user == same); // true (같은 인스턴스)
user.setName("김철수"); // 더티 체킹 대상
} // ← 트랜잭션 종료 = flush + commit + 영속성 컨텍스트 소멸
}일반적인 Spring Boot + @Transactional 요청 처리에서는 트랜잭션이 끝날 때 해당 영속성 컨텍스트도 함께 정리된다고 이해해도 된다.
다만 정확히 말하면 1차 캐시의 범위는 현재 Session / EntityManager 다. 즉, 보통은 트랜잭션 범위로 짧게 쓰이지만, extended persistence context 같은 예외적인 구성도 가능하다.
1차 캐시가 제공하는 기능#
Hibernate 공식 문서:
Persistence contexts offer benefits such as preventing data aliasing, enabling automatic dirty checking, improving performance by caching entities, and facilitating transparent batching of database operations.
| 기능 | 설명 |
|---|---|
| 동일성 보장 | 같은 트랜잭션 내에서 같은 ID의 엔티티는 항상 같은 인스턴스 |
| 반복 읽기 | 같은 엔티티를 여러 번 조회해도 SQL은 1번만 실행 |
| 더티 체킹 | 엔티티 필드 변경 시 자동으로 UPDATE 감지 |
| 쓰기 지연 | flush 시점까지 SQL을 모아서 배치 실행 |
Auto Scaling에서 왜 직접적인 정합성 문제의 원인이 아닌가#
[Server A] [Server B]
요청 1 도착 요청 2 도착
│ │
▼ ▼
트랜잭션 시작 트랜잭션 시작
영속성 컨텍스트 A 생성 영속성 컨텍스트 B 생성
│ │
User(id=1) 조회 → DB SELECT User(id=1) 조회 → DB SELECT
1차 캐시에 저장 1차 캐시에 저장
│ │
트랜잭션 종료 트랜잭션 종료
영속성 컨텍스트 A 소멸 ← 여기서 사라짐 영속성 컨텍스트 B 소멸- 각 서버의 1차 캐시는 완전히 독립적
- 보통 요청/트랜잭션 단위로 생성되고 정리되므로 서버 간 공유할 것 자체가 없음
- 따라서 “Server A의 1차 캐시 때문에 Server B가 옛값을 본다” 같은 문제는 생기지 않는다
다만 이것이 곧 “항상 최신 데이터를 본다” 는 뜻은 아니다. 같은 영속성 컨텍스트 안에서는 이미 관리 중인 엔티티를 다시 반환할 수 있고, 최신성은 결국 트랜잭션 격리 수준 / 조회 시점 / refresh() 여부 에도 영향을 받는다.
2차 캐시 — 서버 레벨 캐시#
정의#
1차 캐시와 달리 트랜잭션이 끝나도 살아있는 캐시. SessionFactory(애플리케이션) 레벨에서 동작한다.
요청 → 1차 캐시(영속성 컨텍스트) → miss → 2차 캐시 → miss → DB
hit ↓
바로 반환중요한 제약이 하나 있다. Hibernate 2차 캐시는 Hibernate 바깥에서 일어난 DB 변경을 자동으로 알지 못한다.
- JDBC로 직접 수정
- 배치 프로그램이 직접 수정
- 다른 애플리케이션이 같은 테이블 수정
이런 변경은 캐시에 자동 반영되지 않으므로, 필요한 경우 명시적 무효화나 만료 정책이 필요하다.
설정 방법#
Hibernate에서 2차 캐시를 활성화하려면:
# application.properties
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=jcache
spring.jpa.properties.hibernate.javax.cache.uri=/ehcache.xml엔티티에 @Cache 어노테이션 적용:
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
@Id
private Long id;
private String name;
private int price;
}동시성 전략 (CacheConcurrencyStrategy)#
Hibernate 공식 문서:
The second-level cache offers four concurrency strategies: READ_ONLY, NONSTRICT_READ_WRITE, READ_WRITE, and TRANSACTIONAL. READ_WRITE is generally recommended as a default for strong consistency.
| 전략 | 설명 | 적합한 경우 |
|---|---|---|
| READ_ONLY | 읽기 전용. 변경 시 예외 | 코드 테이블, 설정값 등 불변 데이터 |
| NONSTRICT_READ_WRITE | 변경 후 캐시 무효화 (약한 일관성) | 간헐적 변경, 약간의 불일치 허용 |
| READ_WRITE | soft lock 기반. Hibernate가 관리하는 수정에 대해 비교적 강한 일관성을 제공 | 일반적 추천 |
| TRANSACTIONAL | JTA와 통합되는 transactional cache provider 전제 | 특수한 엔터프라이즈 환경 |
캐시 관리 API#
// SessionFactory에서 캐시 직접 제어
Cache cache = sessionFactory.getCache();
cache.evict(Product.class, 1L); // 특정 엔티티 제거
cache.evict(Product.class); // 해당 타입 전체 제거
cache.evictAllRegions(); // 전체 캐시 초기화
boolean cached = cache.contains(Product.class, 1L); // 캐시 존재 여부2차 캐시 + Auto Scaling = 정합성 문제#
문제 시나리오 (로컬 캐시 사용 시)#
시간순서 Server A (Ehcache) Server B (Ehcache) DB
────────────────────────────────────────────────────────────────────────────────
T1 Product(id=1, price=1000) Product(id=1, price=1000) price=1000
2차 캐시에 저장 2차 캐시에 저장
T2 price를 2000으로 UPDATE (변경 모름) price=2000
자기 캐시 갱신: price=2000 캐시: price=1000 ← 오래된 값!
T3 Product(id=1) 조회
→ 2차 캐시 hit
→ price=1000 반환 ← 틀린 값!핵심: 로컬 2차 캐시는 각 JVM 안에서만 존재하므로, 다른 서버의 변경을 알 수 없다.
코드로 보는 문제 상황#
// Server A에서 실행
@Transactional
public void updatePrice(Long productId, int newPrice) {
Product product = productRepository.findById(productId).get();
product.setPrice(newPrice); // DB 반영 + Server A의 2차 캐시 갱신
// Server B의 2차 캐시는? → 그대로 옛날 값
}
// Server B에서 실행 (직후)
@Transactional(readOnly = true)
public Product getProduct(Long productId) {
return productRepository.findById(productId).get();
// 2차 캐시 hit → 옛날 가격 반환!
}이건 Auto Scaling만의 문제가 아니다#
서버가 2대 이상이면 동일한 문제가 발생한다. Auto Scaling은 서버 수가 동적으로 변하면서 문제가 더 예측하기 어려워질 뿐이다.
해결 방법#
방법 1: 2차 캐시를 안 쓴다 (가장 단순)#
# provider를 구성했더라도 전역적으로 끄고 싶을 때
spring.jpa.properties.hibernate.cache.use_second_level_cache=false대부분의 서비스는 1차 캐시 + 적절한 쿼리 최적화만으로 충분하다. Hibernate는 provider 설정과 엔티티 매핑(@Cache)이 있어야 실제로 2차 캐시를 활용하므로, 많은 프로젝트에서는 애초에 적극적으로 사용하지 않는다.
방법 2: 공유 캐시 사용 (Redis 등)#
모든 서버가 하나의 외부 캐시를 바라보게 하면, 로컬 캐시보다 노드 간 stale data 가능성을 줄일 수 있다.
Server A ──┐
Server B ──┼──> Redis (공유 캐시) ──> DB
Server C ──┘// Redis를 2차 캐시로 사용 (redisson-hibernate 등)
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
@Id
private Long id;
private String name;
private int price;
}# Redisson을 Hibernate 2차 캐시로 설정
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.redisson.hibernate.RedissonRegionFactory
spring.jpa.properties.hibernate.cache.redisson.config=redisson.yaml시간순서 Server A Server B Redis DB
──────────────────────────────────────────────────────────────────────────────────────
T1 Product(id=1) 조회 Product(id=1) 조회
→ Redis에 캐시 → Redis 캐시 hit price=1000 price=1000
T2 price=2000 UPDATE
→ Redis 캐시 갱신 price=2000 price=2000
T3 Product(id=1) 조회
→ Redis 캐시 hit
→ price=2000 반환 가능 price=2000단, 이 결론은 다음 전제가 있을 때에만 비교적 잘 성립한다.
- 모든 쓰기가 Hibernate를 통해 일어난다
- cache provider의 무효화/락 전략이 올바르게 설정되어 있다
- Hibernate 바깥에서 DB를 직접 수정하는 경로가 없거나, 별도 무효화 전략이 있다
즉, 공유 Redis를 쓴다고 해서 무조건 “문제없음"이 되는 것은 아니다. 그래도 서버마다 로컬 캐시를 따로 두는 것보다는 일반적으로 안전하다.
방법 3: 짧은 TTL + 허용 가능한 불일치#
<!-- ehcache.xml -->
<cache alias="products">
<expiry>
<ttl unit="seconds">30</ttl> <!-- 30초 후 만료 -->
</expiry>
<heap unit="entries">1000</heap>
</cache>- 최대 30초간 오래된 데이터를 볼 수 있음
- 상품 목록, 게시글 조회수 등 약간의 불일치가 허용되는 경우에 적합
- 이는 정합성 보장이 아니라 stale data의 지속 시간을 줄이는 완화책이다
방법 4: Spring @Cacheable + Redis (2차 캐시 대신 애플리케이션 레벨)#
JPA 2차 캐시 대신 Spring 캐시 추상화를 사용하는 방법도 있다.
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepository.findById(id).get(); // 캐시 miss 시에만 실행
}
@Transactional
@CacheEvict(value = "products", key = "#id")
public void updateProduct(Long id, int newPrice) {
Product product = productRepository.findById(id).get();
product.setPrice(newPrice);
}
}spring.cache.type=redis@CacheEvict로 변경 시 캐시 무효화- Redis 같은 공유 캐시를 쓰면 서버별 로컬 캐시 불일치를 줄일 수 있다
- 다만 Spring Cache는 추상화 계층 이므로 TTL, 전파 방식, 즉시성은 Redis/CacheManager 설정에 달려 있다
각 방법 비교#
| 방법 | 정합성 | 성능 | 복잡도 | 적합한 경우 |
|---|---|---|---|---|
| 2차 캐시 안 쓰기 | 캐시로 인한 stale risk 가장 낮음 | 보통 | 낮음 | 대부분의 서비스 |
| 공유 캐시 (Redis 등) | 적절히 구성하면 높음 | 높음 | 중간 | 읽기 비율 높은 서비스 |
| 짧은 TTL | 약한 불일치 | 높음 | 낮음 | 조회수, 목록 등 허용 가능한 경우 |
| @Cacheable + Redis | 적절히 구성하면 높음 | 높음 | 중간 | JPA 2차 캐시보다 직관적 |
전체 그림#
1차 캐시 2차 캐시
(트랜잭션 범위) (서버/공유 범위)
│ │
[Server A] ── 요청 → 영속성 컨텍스트 → miss → 로컬 or Redis → miss → DB
│ │
트랜잭션 끝나면 소멸 서버 살아있는 동안 유지
│ │
서버 간 공유 X 로컬: 서버 간 공유 X
Redis: 서버 간 공유 O정리#
- 1차 캐시(영속성 컨텍스트)는 현재
Session/EntityManager범위이며, 일반적인 Spring 앱에서는 보통 트랜잭션 범위로 짧게 사용된다 - 따라서 1차 캐시는 노드 간 공유 캐시 불일치의 직접 원인 이 되지 않는다
- 2차 캐시를 로컬(Ehcache 등)로 사용하면 서버가 2대 이상일 때 stale data가 생길 수 있다
- 공유 캐시(Redis 등) 는 이 문제를 줄일 수 있지만, 외부 DB 수정·무효화·provider 구성에 따라 stale data 가능성은 남는다
- 그래서 실무적인 해법은 2차 캐시를 안 쓰거나, 공유 캐시를 신중히 구성하거나, TTL/무효화 전략을 명시적으로 가져가는 것이다
