Spring/Spring Data JPA

[자바 ORM 표준 JPA 프로그래밍] 16. 트랜잭션과 락, 2차 캐시

noahkim_ 2025. 4. 26. 14:24

김영한 님의 "자바 ORM 표준 JPA 프로그래밍" 책을 정리한 포스팅 입니다.


1. 트랜잭션과 락

NON-REPEATABLE READ

  • 사용자 A, B가 동시에 같은 데이터를 수정 → 나중에 저장한 B의 값만 반영되어 A의 수정사항이 유실됨.
  • 트랜잭션만으로는 해결 불가능 (트랜잭션 범위를 넘어선 문제)
해결 방법 설명 구현 방식
마지막 커밋만 인정 마지막에 커밋한 트랜잭션의 값이 저장됨
(이전 변경 내용이 덮어씌워짐)
버전 관리 없이 단순히 UPDATE 수행
최초 커밋만 인정 먼저 커밋한 트랜잭션만 인정
이후 커밋은 버전 불일치로 예외 발생
JPA 낙관적 락 적용 (@Version)
충돌 내용 병합 동시에 수정된 데이터를 병합하여 둘 다 반영
OptimisticLockException 발생 시 직접 예외 처리

 

낙관적 락과 비관적 락

구분 낙관적 락 (Optimistic Lock)
비관적 락 (Pessimistic Lock)
가정 충돌이 거의 없다고 가정
충돌이 자주 발생한다고 가정
데이터베이스 락 사용
✅  (SELECT ... FOR UPDATE)
구현 방법 JPA의 @Version 애노테이션으로 버전 관리
JPA의 @Lock(LockModeType.PESSIMISTIC_*) 사용
충돌 감지 시점 트랜잭션 커밋 시점에 버전 비교하여 충돌 감지
트랜잭션 시작 시점에 락 걸어 충돌 방지
처리 방식 충돌 발생 시 예외 발생
충돌 자체를 사전에 차단
장점 성능 부담이 적고 낙관적인 상황에 적합
충돌 가능성이 높은 환경에서도 데이터 정합성 보장 가능
단점 충돌 시점이 늦어 예외 처리 필요
락으로 인해 성능 저하 및 데드락 위험 존재

 

JPA 추천 전략

  • READ COMMITTED 트랜잭션 격리 수준 + 낙관적 버전 관리 (JPA는 기본적으로 READ COMMITTED 수준을 가정함)
  • 영속성 컨텍스트를 활용하면 애플리케이션 수준에서 REPEATABLE READ 가능
    • NON-REPEATABLE READ 문제 해결 가능

 

JPA 낙관적 락

  • @Version 필드를 이용해서 동작한다. (필수)
    • 별도의 락 설정 없이 @Version만 있어도 낙관적 락이 기본 적용
  • 트랜잭션 커밋 시점에 엔티티의 버전 정보를 비교하여 충돌 여부 판단
옵션 설명 특징
@Version 갱신 시에만 엔티티 일관성 보장 엔티티당 하나의 필드만 적용 가능
VERSION 컬럼 필요 (INT 타입)
OPTIMISTIC
(LockModeType)
조회한 후 트랜잭션 끝날 때까지 다른 트랜잭션 수정 불가 트랜잭션 커밋 시 SELECT로 버전 체크
OPTIMISTIC_FORCE_INCREMENT
(LockModeType)
논리적 단위(묶음) 버전 관리 필요 시 사용
- 하위 엔티티 수정으로 인한, 부모 엔티티 버전 증가
엔티티 수정 없이도 버전 강제 증가
수정하면 2번 증가 가능

 

예시) @Version

더보기
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT에 해당
    private Integer id;
    private String name;
    private String nickname;

    @Version
    private Integer age;
}
  • 버전 관리하고 싶은 필드에 @Version 붙이기

 

@Transactional
void crud() {
    User user = userRepository.findById(1).get();

    user.setAge(30);
}
UPDATE user
SET age = ?, version = version + 1
WHERE id = ? AND version = ?
  • 실제 쿼리는 최초 읽은 엔티티의 버전값 갱신을 처리함
  • 최초 읽은 엔티티의 버전 정보가, 커밋 직전 시점에 현재 DB에 저장된 버전과 다르면 OptimisticLockException 예외 발생

 

예시) OPTIMISTIC

더보기

흐름

  1. 트랜잭션 1: 재고 ID=1 조회 (버전=5) + OPTIMISTIC 락 설정
  2. 트랜잭션 2: 같은 재고 ID=1 조회
  3. 트랜잭션 2: 재고를 수정하고 커밋 → 버전 5 → 6
  4. 트랜잭션 1: 결제 시도 (커밋하려고 함) → 버전 5 != 현재 버전 6 → OptimisticLockException 발생

 

@Transactional
void optimistic() {
    Stock stock = em.find(Stock.class, stockId, LockModeType.OPTIMISTIC);

    // 커밋 시, 버전 달라질 경우 예외 발생
}
SELECT version
FROM stock
WHERE id = :stockId
  • 트랜잭션 커밋 시 추가로 나가는 쿼리
  • 내가 처음에 읽은 버전과 같은지 비교하고, 다를 경우 OptimisticLockException 예외 발생

 

예시) OPTIMISTIC_FORCE_INCREMENT

더보기
@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @Version
    private int version;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
    private List<Attachment> attachments = new ArrayList<>();
}

@Entity
public class Attachment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String fileName;

    @ManyToOne
    @JoinColumn(name = "post_id")
    private Post post;
}
@Transactional
public void updatePostWithAttachment(Long postId) {
    // 게시물 조회
    Post post = em.find(Post.class, postId, LockModeType.OPTIMISTIC_FORCE_INCREMENT);

    // 첨부파일 추가
    Attachment attachment = new Attachment();
    attachment.setFileName("new_attachment.jpg");
    attachment.setPost(post);
    post.getAttachments().add(attachment);

    // 부모 엔티티(게시물)의 버전이 증가되고, 트랜잭션 커밋 시 저장됩니다.
}
UPDATE post SET version = version + 1 WHERE id = ?;
INSERT INTO attachment (file_name, post_id) VALUES ('new_attachment.jpg', ?);
  • 트랜잭션 커밋할 때 무조건 버전 하나 올리는 쿼리가 날라감 (수정 안해도 강제로 version 올림)
  • version 조건 안맞으면 OptimisticLockException 예외 발생

 

JPA 비관적 락

  • 데이터 일관성 보장: 데이터베이스의 트랜잭션 락 매커니즘을 활용 여러 트랜잭션간 동시성 문제를 해결
  • 타임아웃: 락을 획득할 때까지 대기 (무한정 기다릴 수는 없으므로 타임아웃 시간을 줄 수 있음)
락 모드 설명 사용 예시
PESSIMISTIC_WRITE 데이터베이스에 쓰기 락을 걺
데이터를 수정할 때 사용되는 락
데이터 수정 시, 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 함.
PESSIMISTIC_READ 데이터베이스에 읽기 락을 걺
데이터를 읽기만 하고 수정하지 않는 경우
읽기 전용 작업에서 데이터를 다른 트랜잭션에서 수정하지 않도록 락을 건다. 
PESSIMISTIC_FORCE_INCREMENT 버전 정보를 강제로 증가시킴
락을 걸 때마다 데이터의 버전이 증가
nowait 옵션을 통해 즉시 락을 획득하거나 실패

 

예제) PESSIMISTIC_WRITE

더보기
@Test
@Transactional
void pessimistic_write() {
    Map<String, Object> properties = new HashMap<>();
    properties.put("javax.persistence.lock.timeout", 10 * 1000);

    User user = entityManager.find(User.class, 1, LockModeType.PESSIMISTIC_WRITE, properties);
}
SELECT * FROM user WHERE id = ? FOR UPDATE;
  • User 테이블의 해당 행에 쓰기 락 걸기

 

예제) PESSIMISTIC_READ

더보기
@Test
@Transactional
void pessimistic_read() {
    Map<String, Object> properties = new HashMap<>();
    properties.put("javax.persistence.lock.timeout", 10 * 1000);

    User user = entityManager.find(User.class, 1, LockModeType.PESSIMISTIC_READ, properties);
}
SELECT * FROM user WHERE id = ? FOR SHARE;
  • User 테이블의 해당 행에 읽기 락 걸기

 

예제) PESSIMISTIC_FORCE_INCREMENT

더보기
@Test
@Transactional
void pessimistic_nowait() {
    Map<String, Object> properties = new HashMap<>();
    properties.put("javax.persistence.lock.timeout", 10 * 1000);

    User user = entityManager.find(User.class, 1, LockModeType.PESSIMISTIC_FORCE_INCREMENT, properties);
}
SELECT * FROM user WHERE id = ? FOR UPDATE NOWAIT;
UPDATE user SET age=? WHERE id=? and age=?;
  • nowait 옵션: 락을 걸려고 시도할 때, 락이 이미 다른 트랜잭션에 의해 걸려있으면 기다리지 않고 바로 실패

 

2. 2차 캐시

vs 1차 캐시

항목 1차 캐시 2차 캐시
위치 영속성 컨텍스트 내부
애플리케이션 범위 (공유 캐시)
유효 범위 트랜잭션 단위 (요청~응답)
애플리케이션 전체 (서버 전체 공유)
동작 방식 조회 시 영속성 컨텍스트에서 먼저 탐색
조회 시 2차 캐시에서 먼저 탐색 후 없으면 DB 조회
저장 방식 조회하거나 변경한 엔티티를 저장
DB 기본 키 기준으로 복사본 저장
동일성 보장 보장함 (같은 영속성 컨텍스트 안)
보장하지 않음 (복사본 반환)
설정 여부 항상 활성 (옵션 없음)
설정 필요 (@Cacheable 등)
주 용도 트랜잭션 내 엔티티 관리 최적화
DB 접근 최소화 및 전체 성능 향상
특징 트랜잭션 종료 시 사라짐
명시적으로 제거하거나 서버 종료 시 사라짐

 

2차 캐시 필요성

  • DB 부하를 줄이기 위함
    • 1차 캐시: 트랜잭션 내에서의 재사용이므로 근본적인 DB 부하 감소 ❌
    • 2차 캐시: 여러 요청이 DB를 거치지 않고 메모리에 조회하므로 DB 부하 감소 ✅
  • 애플리케이션 성능 개선
    • 조회한 데이터를 메모리에 캐시해서 데이터베이스 접근 횟수 줄이기
    • 네트워크를 통해 데이터베이스에 접근하는 비용 ⋙⋙⋙ 내부 메모리에 접근하는 비용 (수만~수십만 배 이상 차이)

 

사용

예시) 캐시 모드 설정

더보기

1. @Cacheable 설정

@Entity
@Cacheable
public class User { ... }
  • 2차 캐시를 적용할 엔티티 붙이면, DB 조회 전에 2차 캐시를 참조함

 

예시) 캐시 적용 끄기

더보기
@Test
@Transactional
void cache_disable() {
    // 앤티티 매니저 범위
    entityManager.setProperty("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);

    // find() 호출시만
    Map<String, Object> properties = new HashMap<>();
    properties.put("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
    properties.put("javax.persistence.cache.storeMode", CacheStoreMode.BYPASS);

    entityManager.find(User.class, 1, properties);

    // JPQL 사용
    User user = entityManager.createQuery("select u from User u where u.id = 1", User.class)
            .setHint("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS)
            .setHint("javax.persistence.cache.storeMode", CacheStoreMode.BYPASS)
            .getSingleResult();
}

 

예시) 캐시 관리

더보기
@Test
@Transactional
void cache_manage() {
    EntityManagerFactory entityManagerFactory = entityManager.getEntityManagerFactory();
    Cache cache = entityManagerFactory.getCache();

    // 캐시 존재 확인
    boolean existsInCache = cache.contains(User.class, 1);
    System.out.println("캐시에 존재? = " + existsInCache);

    // 캐시 제거
    cache.evict(User.class, 1); // 특정 객체
    cache.evict(User.class); // 타입 전체
    cache.evictAll(); // 전체 캐시
}

 

주의사항
  • 쿼리 캐시/컬렉션 캐시: 식별자 값만 캐시하고, 실제 엔티티는 엔티티 캐시에서 조회.
  • 엔티티 캐시 미적용 시 성능 문제: 쿼리 캐시나 컬렉션 캐시만 사용하고 엔티티 캐시를 사용하지 않으면 성능 문제가 발생할 수 있음.

 

3. 하이버네이트 EHCACHE

  • 하이버네이트에서 캐시를 구현하는 캐시 제공 라이브러리 중 하나
  • 하이버네이트의 1차 캐시, 2차 캐시 등을 실제로 구현하고 관리

 

종류

캐시 종류 설명 캐시 영역
엔티티 캐시
엔티티 단위로 캐시
- 식별자로 엔티티 조회 시 사용
- 연관된 엔티티 로딩 시에도 사용
[패키지명 + 클래스명]
컬렉션 캐시
엔티티와 연관된 컬렉션을 캐시
- 컬렉션이 엔티티를 담고 있을 경우 사용
- 식별자 값만 캐시
[엔티티명 + 컬렉션 필드명]
쿼리 캐시
쿼리 실행 결과를 캐시하는 방식
- 쿼리와 파라미터를 키로 결과를 캐시
- 결과가 엔티티라면 식별자 값만 캐시
StandardQueryCache
- 쿼리 캐시를 저장하는 영역
- 쿼리, 결과 집합, 실행 타임스탬프 등 보관


UpdateTimestampsCache
- 쿼리 대상 테이블의 최근 변경 타임스탬프를 저장
- 쿼리 캐시 유효성 검사

 

 

예제) 엔티티 캐시

더보기
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // 캐시 동시성 전략
public class User { ... }

 

예제) 컬렉션 캐시

더보기
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT에 해당
    private Integer id;
    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    private List<User> members = new ArrayList<>();
}

 

예제) 쿼리 캐시

더보기
  1. 캐시 조회: 쿼리 실행 시 StandardQueryCache에서 캐시된 타임스탬프 조회.
  2. 타임스탬프 비교: UpdateTimestampsCache에서 테이블의 최신 변경 타임스탬프 조회.
  3. 캐시 유효성 검사: 캐시 타임스탬프가 더 오래되면, DB에서 데이터를 다시 읽어와 캐시 갱신.

 

@Test
@Transactional
void cache_query() {
    List<User> users = entityManager.createQuery("SELECT u FROM User u", User.class)
            .setHint("org.hibernate.cacheable", true)
            .getResultList();
}

 

캐시 동시성 전략(CacheConcurrencyStrategy)

전략 설명
NONE
캐시 설정을 사용하지 않음.
READ_ONLY
읽기 전용 캐시
- 수정 ❌
- 캐시 조회 시 원본 객체 반환 (불변 객체에 적합)
NONSTRICT_READ_WRITE
읽고 쓰기 전략 (엄격 ❌)
- 읽은 캐시 값을 다른 트랜잭션에서 데이터 수정 허용
- 일관성 문제 방지
READ_WRITE 읽고 쓰기 전략 (엄격)
- 읽은 캐시 값을 다른 트랜잭션에서 데이터 수정 허용
- 일관성 문제 방지 ✅
- 수정된 데이터가 캐시에서도 갱신됨
TRANSACTIONAL
트랜잭션 기반 캐시
- REPEATABLE READ 수준의 격리 제공.