본문으로 건너뛰기
  1. Posts/

[JPA] 영속성 컨텍스트와 캐시 — Auto Scaling 환경에서 데이터 정합성은 괜찮을까?

NineKoo9
작성자
NineKoo9
목차

이 문서는 의문을 가지고 있던 지점을 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()로 다시 영속 상태로
상태설명
Transientnew로 생성만 한 상태. 영속성 컨텍스트와 무관
Managedpersist() 또는 조회로 영속성 컨텍스트에 들어온 상태
Detached영속성 컨텍스트에서 분리된 상태 (트랜잭션 종료 등)
Removedremove() 호출. 커밋 시 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_WRITEsoft lock 기반. Hibernate가 관리하는 수정에 대해 비교적 강한 일관성을 제공일반적 추천
TRANSACTIONALJTA와 통합되는 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. 1차 캐시(영속성 컨텍스트)는 현재 Session / EntityManager 범위이며, 일반적인 Spring 앱에서는 보통 트랜잭션 범위로 짧게 사용된다
  2. 따라서 1차 캐시는 노드 간 공유 캐시 불일치의 직접 원인 이 되지 않는다
  3. 2차 캐시를 로컬(Ehcache 등)로 사용하면 서버가 2대 이상일 때 stale data가 생길 수 있다
  4. 공유 캐시(Redis 등) 는 이 문제를 줄일 수 있지만, 외부 DB 수정·무효화·provider 구성에 따라 stale data 가능성은 남는다
  5. 그래서 실무적인 해법은 2차 캐시를 안 쓰거나, 공유 캐시를 신중히 구성하거나, TTL/무효화 전략을 명시적으로 가져가는 것이다