[자바 ORM 표준 JPA 프로그래밍] Chapter 16

YUSHIN KIM·2025년 10월 17일

Chapter 16. 트랜잭션과 락, 2차 캐시

16.1 트랜잭션과 락

16.1.1 트랜잭션과 격리 수준

트랜잭션은 ACID 원칙을 준수해야 한다.

  • 원자성(Atomicity): 트랜잭션 내의 작업은 모두 성공하거나 모두 실패해야 한다.
  • 일관성(Consistency): 모든 트랜잭션은 무결성 제약 조건 준수 등 일관성 있는 데이터베이스 상태를 유지해야 한다.
  • 격리성(Isolation): 동시에 실행되는 트랜잭션들은 서로 격리되어야 한다. 동시성과 관련된 성능 이슈를 해결하기 위해 격리 수준을 선택할 수 있다.
  • 지속성(Durability): 트랜잭션을 성공적으로 끝내면 그 결과는 영구히 기록되어야 한다. 중간에 시스템 문제가 발생해도 데이터베이스 로그 등으로 성공한 트랜잭션의 내용은 복구되어야 한다.

격리성을 완벽히 보장하는 트랜잭션은 순차적으로 실행되어야 하는데, 이는 동시 처리 성능이 매우 나쁘기 때문에 ANSI 표준에서는 트랜잭션의 격리 수준을 다음과 같이 4단계로 나누어 정의한다.

  • READ UNCOMMITED: 커밋되지 않은 데이터 읽기
  • READ COMMITTED: 커밋된 데이터 읽기
  • REPEATABLE READ: 반복적인 시도에도 항상 일관적인 결과를 보장하는 읽기
  • SERIALIZABLE: 완전히 순차적인 읽기

격리 수준에 따라 발생할 수 있는 문제점들은 다음과 같다.

  • DIRTY READ: 커밋되지 않은 데이터를 읽는 것, READ COMMITTED 격리 수준부터 해결 가능
  • NON-REPEATABLE READ: 같은 트랜잭션 내에서의 반복된 읽기의 결과가 서로 다른 것, REPEATABLE READ 격리 수준부터 해결 가능
  • PHANTOM READ: NON-REPEATABLE READ와 비슷하지만 이미 존재하는 레코드 값 변경이 아닌 레코드 집합의 자체의 변경으로 유발되는 반복적인 읽기 결과의 불일치, SERIALIZABLE 격리 수준 또는 InnoDB의 넥스트 키 락이나 MVCC 등 데이터베이스 자체적인 기능으로 해결 가능

애플리케이션 대부분은 동시성 처리가 중요하기 때문에 데이터베이스들은 보통 READ COMMITTED 격리 수준을 기본으로 사용한다.

16.1.2 낙관적 락과 비관적 락 기초

JPA의 영속성 컨텍스트(1차 캐시)는 데이터베이스 트랜잭션이 READ COMMITTED 격리 수준이어도 애플리케이션 레벨에서 REPEATABLE READ 격리 수준을 보장한다. 하지만 이는 스칼라 값이 아닌 1차 캐시에서 직접 관리되는 엔터티 조회에만 한정된다.

JPA는 데이터베이스 트랜잭션의 격리 수준을 READ COMMITTED 정도로 가정하기 때문에 일부 로직에 더 높은 격리 수준이 필요하면 낙관적 락 또는 비관적 락을 사용할 수 있다.

낙관적 락은 트랜잭션 대부분이 충돌이 발생하지 않을 것이라고 낙관적으로 가정하고 데이터를 수정하는 시점에 해당 수정의 유효성을 검사하는 방법이다. 이는 JPA가 제공하는 버전 관리 기능을 활용해 구현할 수 있으며 트랜잭션 커밋 전까지 트랜잭션의 충돌을 알 수 없다는 특징이 있다.

비관적 락은 트랜잭션의 충돌이 발생한다고 가정하고 데이터를 읽기 시작하는 시점부터 우선 락을 거는 방법이다. 이는 데이터베이스에서 제공하는 락 기능을 사용한다. 대표적으로 넥스트 키 락을 활성화하는 SELECT FOR UPDATE 구문 등이 있다.

여기에 추가로 두 번의 갱신 분실 문제(second lost updates problem)라는 트랜잭션의 범위를 넘어서는 문제도 있다. 예를 들어 사용자 A와 B가 제목이 "O"로 같은 공지사항의 제목을 각각 "A", "B"로 수정한다고 생각해 보자. 사용자 A와 B가 제목을 수정하기 시작할 때 본 제목은 똑같이 "O"이고, A가 먼저 수정을 마친 후 B가 수정을 마치면 데이터베이스에는 A가 수정한 내용이 남지 않고 "O" \rightarrow "B"로의 업데이트 기록만 남게 된다. 이를 해결하기 위한 방법은 3가지가 있다.

  • 마지막 커밋만 인정하기: 마지막에 커밋한 버전만 인정한다.
  • 최초 커밋만 인정하기: 최초 커밋한 버전만 인정하고 충돌 시 오류를 발생시킨다.
  • 충돌하는 갱신 내용 병합하기: 두 버전 모두 순서에 따라 인정한다.

기본은 마지막 커밋만 인정하는 방식이 사용되지만 결제 기능과 같이 상황에 따라 최초 커밋만 인정하는 방식이 더 합리적일 수 있다. JPA가 기본적으로 제공하는 버전 관리 기능을 사용하면 손쉽게 최초 커밋만 인정하는 방식을 구현할 수 있다.

16.1.3 @Version

JPA가 제공하는 낙관적 락을 사용하려면 @Version 애너테이션을 사용해야 한다. 이 애너테이션으로 지정 가능한 타입은 다음과 같다.

  • Long (long)
  • Integer (int)
  • Short (short)
  • Timestamp

예제 16.1. 엔터티에 버전 관리 추가

@Entity
public class Board {
    
    @Id
    private String id;
    private String title;
    
    @Version
    private Integer version;
}

엔터티에 버전 관리용 필드를 추가하면 수정 시마다 버전이 자동으로 증가한다. 엔터티를 수정할 때 조회 시점의 버전과 비교하여 버전이 다르면 예외가 발생한다.

예제 16.2. 버전 관리 사용 예제

// BoardService.java
@Service
public class BoardService {

    @PersistenceContext
    private EntityManager em;

    // 1번 트랜잭션 (먼저 커밋)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void transactionA_FirstCommit(String id) {
        Board board = em.find(Board.class, id);
        board.setTitle("Title from T-A");
        em.flush(); // T-A 커밋 시점에 버전 0 -> 1
    }

    // 2번 트랜잭션 (나중에 커밋, 충돌 발생)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void transactionB_Conflict(String id, String newTitle, Board boardFromA) {
        // T-B는 이미 T-A가 변경한 시점 이후에 커밋을 시도합니다.
        // T-A가 조회한 boardFromA를 사용하므로, T-B는 버전 0을 기반으로 합니다.
        boardFromA.setTitle(newTitle);
        em.merge(boardFromA); // 영속성 컨텍스트에 다시 병합하여 변경 감지 활성화

        // 커밋 시점: 버전 0 vs DB 버전 1 비교 -> OptimisticLockException 발생
        em.flush();
    }

    // 테스트 헬퍼: 트랜잭션 없이 조회
    @Transactional(readOnly = true)
    public Board findBoard(String id) {
        return em.find(Board.class, id);
    }

    @Transactional
    public void setUp() {
        Board board = new Board();
        board.setId("board1");
        board.setTitle("First title");
        em.persist(board);
        em.flush();
    }
}

@SpringBootTest
class Ch16JpaTransactionLockCacheApplicationTests {

    @Autowired
    private BoardService boardService;

    @Test
    void optimisticLockTest() {
        // 0. 데이터 초기화
        boardService.setUp();

        // 1. 초기 조회: 트랜잭션 없이 엔티티를 로드합니다. (버전 0)
        Board boardA = boardService.findBoard("board1");

        // 2. 트랜잭션 A (먼저 커밋): 별도의 트랜잭션으로 수정 및 커밋 (버전 0 -> 1)
        boardService.transactionA_FirstCommit("board1");

        // 3. 트랜잭션 B (충돌 유도): boardA(버전 0)를 기반으로 수정 시도
        // boardA를 인자로 넘기거나 merge할 때 OptimisticLockException이 발생합니다.
        Assertions.assertThrows(OptimisticLockException.class, () -> {
            boardService.transactionB_Conflict("board1", "Title from T-B", boardA);
        });

        // 4. 검증 (선택적): DB에 최종적으로 트랜잭션 A의 값이 남아있는지 확인
        // (트랜잭션 B는 롤백되었으므로 T-A의 값이 DB에 남아있음)
        Board finalBoard = boardService.findBoard("board1");
        Assertions.assertEquals("Title from T-A", finalBoard.getTitle());
        Assertions.assertEquals(1, finalBoard.getVersion());
    }
}

버전 정보를 사용하면 최초 커밋만 인정하기가 적용된다.

버전 정보 비교 방법

JPA는 엔터티 수정 후 트랜잭션을 커밋할 때 영속성 컨텍스트를 플러시하면서 다음과 같은 UPDATE 쿼리를 실행한다.

예제 16.3. 버전 사용 엔터티 SQL

Hibernate: 
    /* update
        for jpabook.domain.Board */
    update board 
    set
        title=?,
        version=? 
    where
        id=? 
        and version=?

만약 같은 버전의 엔터티가 이미 수정되었다면 쿼리 결과로 어떤 레코드도 조회되지 않을 것이다. 이때는 버전이 이미 증가한 것으로 판단하고 JPA가 예외(OptimisticLockException)를 발생시킨다. 버전은 엔터티의 값을 변경하면 증가한다. 이는 값 타입인 임베디드 타입, 값 타입 컬렉션도 해당된다. 단, 연관 관계 필드는 연관 관계의 주인 필드에 대한 수정만 버전에 영향을 준다.

@Version 애너테이션으로 지정한 버전 필드는 JPA가 직접 관리하기 때문에 사용자가 임의로 수정하면 안 된다. 단, 벌크 연산의 경우 영속성 컨텍스트의 관리를 받지 못하기 때문에 사용자가 직접 위 쿼리와 같은 논리로 버전 필드를 갱신해 주어야 한다.

16.1.4 JPA 락 사용

JPA 사용 시 추천되는 전략은 READ COMMITTED 트랜잭션 격리 수준 설정 및 낙관적 버전 사용이다.

락은 다음 위치에 적용할 수 있다.

  • EntityManager.lock(), EntityManager.find(), EntityManager.refresh()
  • Query.setLockMode() (TypedQuery 포함)
  • @NamedQuery

JPA가 제공하는 락 옵션은 jakarta.persistence.LockModeType에 정의되어 있다.

락 모드타입설명
낙관적 락OPTIMISTIC낙관적 락을 사용한다.
낙관적 락OPTIMISTIC_FORCE_INCREMENT낙관적 락을 사용하면서 버전 정보를 강제로 증가시킨다.
비관적 락PESSIMISTIC_READ비관적 락, 읽기 락을 사용한다.
비관적 락PESSIMISTIC_WRITE비관적 락, 쓰기 락을 사용한다.
비관적 락PESSIMISTIC_FORCE_INCREMENT비관적 락을 사용하면서 버전 정보를 강제로 증가시킨다.
기타NONE락을 사용하지 않는다.
기타READJPA 1.0 호환 기능으로 OPTIMISTIC과 같다.
기타WRITEJPA 1.0 호환 기능으로 OPTIMISTIC_FORCE_INCREMENT와 같다.

16.1.5 JPA 낙관적 락

JPA가 제공하는 낙관적 락은 버전(@Version)을 필요로 하며 트랜잭션 커밋 시점에 충돌을 감지할 수 있다는 특징이 있다. 낙관적 락 사용 시 다음과 같은 예외가 발생할 수 있다.

  • jakarta.persistence.OptimisticLockException: JPA 예외
  • org.hibernate.StaleObjectStateException: 하이버네이트 예외
  • org.springframework.orm.ObjectOptimisticLockingFailureException: 스프링 예외 추상화

락 옵션 없이 @Version 애너테이션만 사용해도 낙관적 락이 적용되지만 락 옵션 사용 시 더 세밀하게 제어할 수 있다.

NONE

락 옵션 미적용 시에도 @Version 애너테이션으로 지정된 필드가 있다면 낙관적 락이 적용된다.

  • 용도: 조회 시점부터 수정 시점까지 다른 트랜잭션에 의한 엔터티 수정 보호
  • 동작: 엔터티 수정 시 UPDATE 쿼리를 사용해 버전 검사 및 증가를 수행하고 버전 값 불일치 시 예외 발생
  • 이점: 두 번의 갱신 분실 문제(second lost updates problem) 예방

OPTIMISTIC

OPTIMISTIC 옵션을 적용하면 엔터티를 조회만 하더라도 트랜잭션 커밋 시 해당 엔터티의 버전을 검사하게 된다. 그러므로 트랜잭션이 유효하려면 한 번 조회한 엔터티는 트랜잭션 종료 시까지 다른 트랜잭션에서 변경하지 않음이 보장되어야 한다.

  • 용도: 조회 시점부터 트랜잭션 종료 시점까지 다른 트랜잭션에 의해 엔터티가 수정되지 않음을 보장
  • 동작: 트랜잭션 커밋 시 버전 정보를 조회 및 대조하여 현재 엔터티의 버전과 같은지 검증하고 버전 값 불일치 시 예외 발생
  • 이점: DIRTY READ, NON-REPEATABLE READ 문제 방지

예제 16.4. OPTIMISTIC 예제

@Test
void optimisticLockTest() {
    // 0. 데이터 초기화
    boardService.setUp();

    // 1. 초기 조회: 트랜잭션 없이 낙관적 락을 보유한 엔티티를 조회한다. (버전 0)
    Board board = boardService.findBoardWithOptimisticLock("board1");

    // 2. 트랜잭션 B (먼저 커밋): 별도의 트랜잭션으로 수정 및 커밋 (버전 0 -> 1)
    boardService.boardUpdate("board1");

    // 3. 트랜잭션 A (충돌 유도): board(버전 0)를 기반으로 합병 시도
    Assertions.assertThrows(OptimisticLockException.class, () -> {
        boardService.boardMerge(board);
    });

    // 4. 검증 (선택적): DB에 최종적으로 트랜잭션 B의 값이 남아있는지 확인
    Board finalBoard = boardService.findBoard("board1");
    Assertions.assertEquals("update title from boardUpdate", finalBoard.getTitle());
    Assertions.assertEquals(1, finalBoard.getVersion());
}

// BoardService.java
@Service
public class BoardService {

    @PersistenceContext
    private EntityManager em;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void boardUpdate(String id) {
        Board board = em.find(Board.class, id);
        board.setTitle("update title from boardUpdate");
        em.flush();
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void boardMerge(Board board) {
        em.merge(board);

        em.flush();
    }

    @Transactional(readOnly = true)
    public Board findBoard(String id) {
        return em.find(Board.class, id);
    }

    @Transactional(readOnly = true)
    public Board findBoardWithOptimisticLock(String id) {
        return em.find(Board.class, id, LockModeType.OPTIMISTIC);
    }

    @Transactional
    public void setUp() {
        Board board = new Board();
        board.setId("board1");
        board.setTitle("First title");
        em.persist(board);
        em.flush();
    }
}

테스트 메서드에서 OPTIMISTIC 락을 획득한 버전이 0인 엔터티를 조회했다. 그리고 다른 트랜잭션에서 같은 식별자로 엔터티를 조회한 후 수정한다. 그 다음 최초로 조회한 버전이 0인 엔터티를 다시 영속성 컨텍스트에 합병하는 트랜잭션 메서드를 호출하면 해당 메서드 종료 시 트랜잭션이 커밋되면서 SELECT 쿼리로 해당 시점의 엔터티를 조회하고 처음에 조회한 엔터티의 버전 정보와 비교한다. 이때 버전 정보가 다르면 예외가 발생한다.

이처럼 OPTIMISTIC 옵션을 사용하면 엔터티를 사용하지 않고 단순히 조회만 해도 버전을 확인한다.

OPTIMISTIC_FORCE_INCREMENT

OPTIMISTIC_FORCE_INCREMENT 옵션을 적용하면 조회 시에도 강제로 버전을 증가시킨다.

  • 용도: 논리적인 단위의 엔터티 집합을 관리 가능
  • 동작: 엔터티를 수정하지 않아도 트랜잭션 커밋 시 UPDATE 쿼리로 버전 정보를 강제로 증가하며 데이터베이스의 버전과 불일치 시 예외 발생, 같은 트랜잭션 내에서 추가적으로 엔터티를 수정하면 한 번의 트랜잭션에서 총 2번의 버전 증가 발생 가능
  • 이점: 강제로 버전을 증가시켜 논리적인 단위의 엔터티 집합의 버전을 관리 가능

OPTIMISTIC_FORCE_INCREMENTAggregate Root에 사용할 수 있다. 예를 들어 Aggregate Root는 수정하지 않았지만 관리되는 엔터티가 수정되었을 때 Aggregate Root의 버전을 강제로 증가시키는 것이다.

예제 16.5. OPTIMISTIC_FORCE_INCREMENT

@Test
void optimisticForceIncrementLockTest() {
    boardService.setUp();

    Board board = boardService.findBoardWithOptimisticForceIncrementLock("board1");
    Assertions.assertEquals(1, board.getVersion());

    board = boardService.boardMergeAndUpdate(board);
    Assertions.assertEquals(2, board.getVersion());

    board = boardService.boardFindAndUpdateWithForceIncrementLock("board1");
    Assertions.assertEquals(4, board.getVersion());
}

// BoardService.java
@Service
public class BoardService {

    @PersistenceContext
    private EntityManager em;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Board boardMergeAndUpdate(Board board) {
        board = em.merge(board);
        board.setTitle("update title from boardUpdate");
        return board;
    }

    @Transactional
    public Board boardFindAndUpdateWithForceIncrementLock(String id) {
        Board board = em.find(Board.class, id, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
        board.setTitle("update title from boardFindAndUpdateWithForceIncrementLock");
        return board;
    }

    @Transactional
    public Board findBoardWithOptimisticForceIncrementLock(String id) {
        return em.find(Board.class, id, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
    }

    @Transactional
    public void setUp() {
        Board board = new Board();
        board.setId("board1");
        board.setTitle("First title");
        em.persist(board);
        em.flush();
    }
}

board는 처음 데이터베이스에 생성될 때 버전이 0인 상태로 저장되지만, OPTIMISTIC_FORCE_INCREMENT 옵션으로 조회하니 트랜잭션이 종료되자마자 버전이 1로 증가되었음을 확인할 수 있다. 그리고 해당 엔터티를 다시 영속성 컨텍스트에 병합해서 변경을 수행하면 버전이 1에서 2로 증가한다. 만약 OPTIMISTIC_FORCE_INCREMENT 옵션으로 엔터티를 조회함과 동시에 동일한 트랜잭션에서 엔터티를 수정하면 버전이 2번 증가하여 2에서 4가 된다.

16.1.6 JPA 비관적 락

JPA가 제공하는 비관적 락은 데이터베이스의 트랜잭션 락 메커니즘에 의존하는 방식으로 구현되어 있다. 주로 SQL 쿼리에 SELECT FOR UPDATE 구문을 사용하며 시작하고 버전 정보는 사용하지 않는다. 비관적 락은 주로 PESSIMISTIC_WRITE 모드를 사용한다.

비관적 락은 다음과 같은 특징이 있다.

  • 엔터티가 아닌 스칼라 타입 조회 시에도 사용할 수 있다.
  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.

비관적 락 사용 시 다음과 같은 예외가 발생할 수 있다.

  • jakarta.persistence.PessimisticLockException: JPA 예외
  • org.springframework.dao.PessimisticLockingFailureException: 스프링 예외 추상화

PESSIMISTIC_WRITE

애플리케이션 레벨의 비관적 락은 일반적으로 PESSIMISTIC_WRITE 옵션을 의미한다. 데이터베이스에 쓰기 락을 걸 때 사용한다.

  • 용도: 데이터베이스에 쓰기 락 설정
  • 동작: 데이터베이스에 SELECT FOR UPDATE 쿼리를 전송
  • 이점: NON-REPEATABLE READ 문제 방지 가능, 락이 걸린 레코드는 다른 트랜잭션에서 수정 불가

PESSIMISTIC_READ

PESSIMISTIC_READ 옵션은 데이터를 반복 읽기만 하고 수정하지는 않을 때 사용될 수 있는데 그런 사례는 흔하지 않기 때문에 일반적으로 잘 사용되지 않는다. 데이터베이스 대부분은 방언에 의해 PESSIMISTIC_WRITE 옵션으로 동작한다.

  • MySQL: LOCK IN SHARE MODE
  • PostgreSQL: FOR SHARE

PESSIMISTIC_FORCE_INCREMENT

PESSIMISTIC_FORCE_INCREMENT는 비관적 락이면서 버전 정보를 유지하는 유일한 옵션이다. 하이버네이트는 NOWAIT을 지원하는 데이터베이스에 대해 FOR UPDATE NOWAIT 옵션을 적용한다.

  • 오라클: FOR UPDATE NOWAIT
  • PostgreSQLP: FOR UPDATE NOWAIT
  • NOWAIT을 지원하지 않으면 FOR UPDATE가 사용된다.

16.1.7 비관적 락과 타임아웃

비관적 락을 사용하면 락 획득 시까지 트랜잭션이 무한정 대기할 수 있다. 그러므로 다음과 같이 타임아웃을 설정할 수 있는데 데이터베이스 특성에 따라 동작하지 않을 수도 있다.

비관적 락 타임아웃 설정

Map<String, Object> properties = new HashMap<String, Object>();

// 타임아웃을 10초로 설정
properties.put("jakarta.persistence.lock.timeout", 10000);

Board board = em.find(Board.class, "boardId", LockModeType.PESSIMISTIC_WRITE, properties);

타임아웃 도달 시 jakarta.persistence.LockTimeoutException 예외가 발생한다.

16.2 2차 캐시

16.2.1 1차 캐시와 2차 캐시

네트워크 통신 비용은 메모리 접근 비용보다 월등히 크기 때문에 조회한 데이터를 메모리에 캐시하여 데이터베이스 접근 횟수를 줄이면 애플리케이션 성능을 획기적으로 개선할 수 있다.

영속성 컨텍스트 내부에는 1차 캐시라고 불리는 엔터티 저장소가 있다. 일반적인 웹 애플리케이션 환경에서는 트랜잭션 생명 주기 동안에만 1차 캐시가 유효하며, OSIV(Open Session In View, 뷰 렌더링 시점까지 영속성 컨텍스트를 열어두는 기능)을 활성화해도 요청 스코프 동안에만 1차 캐시가 유효하다. 따라서 요청마다 데이터베이스 통신이 필연적으로 발생하기 때문에 애플리케이션 전체적으로 보았을 때 데이터베이스 접근 횟수에 있어서 획기적인 성능 개선이 이루어지진 않는다.

하이버네이트를 포함한 대부분의 JPA 구현체들은 애플리케이션 스코프의 공유 캐시(또는 2차 캐시)를 지원한다. 2차 캐시의 엔터티는 여러 트랜잭션 간 공유할 수 있기 때문에 실질적으로 데이터베이스 접근 횟수를 개선할 수 있다.

1차 캐시

1차 캐시는 영속성 컨텍스트 내부에 위치하여 엔터티 매니저로 조회하거나 변경하는 모든 엔터티를 저장한다. 트랜잭션을 커밋하거나 영속성 컨텍스트를 명시적으로 플러시(em.flush())하면 쓰기 지연된 1차 캐시의 변경 사항이 데이터베이스에 동기화된다. 영속성 컨텍스트 자체가 사실상 1차 캐시와 같다.

J2EE나 스프링 프레임워크 같은 컨테이너 위에서 JPA를 실행하면 영속성 컨텍스트는 트랜잭션과 생명 주기를 공유한다. OSIV를 사용하더라도 요청 스코프와 생명 주기를 공유하게 된다. 1차 캐시는 다음과 같은 특징을 갖는다.

  • 1차 캐시에 없는 엔터티를 최초로 조회할 시에만 데이터베이스에서 조회한다.
  • 1차 캐시에 같은 엔터티가 있으면 해당 엔터티가 그대로 반환되므로 객체 동일성(identity)을 보장한다.
  • 1차 캐시는 기본적으로 영속성 컨텍스트 스코프의 캐시이다. 컨테이너 환경에서는 트랜잭션 스코프, OSIV를 적용하면 요청 스코프의 캐시가 된다.

2차 캐시

JPA는 애플리케이션 스코프에서 공유되는 캐시를 공유 캐시(shared cache) 또는 2차 캐시(second level cache, L2 cache)라고 한다. 2차 캐시는 애플리케이션과 생명 주기를 공유하며 분산 캐시나 클러스터링 환경의 캐시는 애플리케이션보다도 더 오랫동안 유지될 수도 있다.

엔터티 조회 요청이 발생할 시 1차 캐시 - 2차 캐시 - 데이터베이스 순으로 탐색을 진행한다. 그러므로 2차 캐시 활용을 통해 데이터베이스 접근 횟수를 줄일 수 있다. 2차 캐시의 특징은 다음과 같다.

  • 2차 캐시는 애플리케이션 스코프(또는 영속성 유닛 스코프)의 캐시이다.
  • 2차 캐시는 공유 캐시 특유의 동시성 문제 해결을 위해 조회에 성공할 시 일반적으로 객체의 복사본을 반환한다.
  • 2차 캐시는 데이터베이스 기본 키를 기준으로 객체를 캐싱하지만 조회 시 일반적으로 복사본이 반환되기 때문에 서로 다른 영속성 컨텍스트 간 객체 동일성(identity)을 보장하지 않는다.
  • 2차 캐시 활용 시 READ_ONLY 전략을 사용하면 객체가 불변할 것이라고 가정하고 복사본이 아닌 원본을 반환하여 서로 다른 영속성 컨텍스트 간 객체 동일성을 보장할 수 있다.

16.2.2 JPA 2차 캐시 기능

JPA 구현체 대부분은 2차 캐시 기능을 개별적으로 지원해 왔지만 JPA는 2.0 버전에서야 2차 캐시 표준을 정의했다. 이는 여러 구현체가 공통적으로 사용하는 부분만 표준화했기 때문에 세밀한 설정을 하려면 구현체에 의존적인 기능을 사용해야 한다. JPA 2차 캐시의 표준적인 기능에 대해 알아보자.

캐시 모드 설정

그리고 마지막으로 다음과 같이 엔터티 클래스 정의에 jakarta.persistence.Cacheable 애너테이션을 사용하면 된다. @Cacheabletrue 또는 value를 값으로 설정할 수 있고 기본 값은 true이다.

예제 16.6. 캐시 모드 설정

@Entity
@Cacheable
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }
}

그리고 스프링 부트를 기준으로 영속성 유닛에 캐시를 어떻게 적용할지 옵션을 설정해야 한다.

예제 16.7. application.properties에 캐시 모드 설정

spring.jpa.properties.jakarta.persistence.sharedCache.mode=ENABLE_SELECTIVE

캐시 모드는 jakarta.persistence.SharedCacheMode에 정의되어 있다. 보통 ENABLE_SELECTIVE를 사용한다.

캐시 모드설명
ALL모든 엔터티를 캐싱한다.
NONE캐시를 사용하지 않는다.
ENABLE_SELECTIVE@Cacheable(true)로 지정된 엔터티만 캐싱한다.
DISABLE_SELECTIVE모든 엔터티를 캐싱하되 @Cacheable(false)로 지정된 엔터티는 캐싱하지 않는다.
UNSPECIFIEDJPA 구현체가 정의한 설정을 따른다.

2차 캐시 조회, 저장 방식 설정

2차 캐시를 무시하고 데이터베이스를 직접 조회하거나 캐시를 갱신하려면 캐시 조회 모드와 캐시 보관 모드를 사용할 수 있다.

em.setProperty("jakarta.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);

프로퍼티 이름은 다음과 같다.

  • jakarta.persistence.cache.retrieveMode: 캐시 조회 모드 프로퍼티 이름
  • jakarta.persistence.cache.storeMode: 캐시 보관 모드 프로퍼티 이름

옵션은 다음과 같다.

  • jakarta.persistence.CacheRetrieveMode: 캐시 조회 모드 설정 옵션
  • jakarta.persistence.CacheStoreMode: 캐시 보관 모드 설정 옵션

예제 16.9. 캐시 조회 모드

public enum CacheRetrieveMode {
	USE,
    BYPASS
}
  • USE: 캐시에서 조회한다. 기본 값이다.
  • BYPASS: 캐시를 무시하고 데이터베이스에 직접 접근한다.

예제 16.10. 캐시 보관 모드

public enum CacheStoreMode {
	USE,
    BYPASS,
    REFRESH
}
  • USE: 조회한 데이터를 캐시에 저장한다. 조회한 데이터가 이미 캐시에 있으면 캐시 데이터를 최신 상태로 갱신하지는 않는다. 트랜잭션 커밋 시 등록/수정한 엔터티도 캐시에 저장한다. 기본 값이다.
  • BYPASS: 캐시에 저장하지 않는다.
  • REFRESH: USE 전략에 추가로 데이터베이스에서 조회한 엔터티를 최신 상태로 다시 캐싱한다.

캐시 모드는 EntityManager.setProperty() 메서드를 통해 엔터티 매니저 단위로 설정하거나 EntityManager.find(), EntityManager.refresh()로 더 세밀하게 설정할 수 있다. 그리고 Query.setHint()(TypedQuery 포함)에 사용할 수도 있다.

예제 16.11. 엔터티 매니저 범위

em.setProperty("jakarta.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
em.setProperty("jakarta.persistence.cache.storeMode", CacheStoreMode.BYPASS);

예제 16.12. find()

Map<String, Object> param = new HashMap<>();
param.put("jakarta.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
param.put("jakarta.persistence.cache.storeMode", CacheStoreMode.BYPASS);

em.find(TestEntity.class, id, param);

예제 16.13. JPQL

em.createQuery("SELECT e FROM TestEntity e WHERE e.id = :id", TestEntity.class)
	.setParameter("id", id)
    .setHint("jakarta.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS)
    .setHint("jakarta.persistence.cache.storeMode", CacheStoreMode.BYPASS)
    .getSingleResult();

JPA 2차 캐시 관리 API

JPA는 2차 캐시를 관리하기 위한 jakarta.persistence.Cache 인터페이스를 제공한다. 이것은 EntityManagerFactory로부터 구할 수 있다.

예제 16.14. Cache 관리 객체 조회

Cache cahce = emf.getCache();
boolean contains = cache.contains(TestEntity.class, testEntity.getId());

예제 16.15. Cache 인터페이스

public interface Cache {

	// 해당 엔터티가 캐시에 있는지 확인
    boolean contains(Class var1, Object var2);

	// 해당 엔터티 객체 중 특정 식별자를 가진 객체를 2차 캐시에서 제거
    void evict(Class var1, Object var2);

	// 해당 엔터티의 모든 객체를 2차 캐시에서 제거
    void evict(Class var1);

	// 모든 캐시 데이터 제거
    void evictAll();

	// JPA Cache 구현체 조회
    <T> T unwrap(Class<T> var1);
}

JPA가 표준화한 캐시 기능은 여기까지이다.

16.2.3 하이버네이트와 EHCACHE 적용

하이버네이트가 지원하는 2차 캐시는 크게 3가지가 있다.

  • 엔터티 캐시: 엔터티 단위로 캐싱한다. 식별자로 엔터티를 조회하거나 컬렉션이 아닌 연관 엔터티를 로딩할 때 사용한다.
  • 컬렉션 캐시: 엔터티와 연관된 컬렉션을 캐싱한다. 컬렉션이 엔터티를 담고 있으면 식별자 값만 캐싱한다(하이버네이트 기능).
  • 쿼리 캐시: 쿼리와 파라미터 정보를 키로 사용해 캐싱한다. 결과가 엔터티면 식별자 값만 캐싱한다(하이버네이트 기능).

JPA 표준에는 엔터티 캐시만 정의되어 있고 나머지는 하이버네이트에 종속적인 기능들이다.

2차 캐시는 외부의 캐시 제공자(Cache Provider)에 의존적인 기능으로 다음과 같은 캐시 제공자들이 존재한다.

  • EHCACHE: 가장 흔히 사용되는 In-memory 기반의 캐시 제공자, 구현이 JCache 표준을 준수
  • Redis/Memcached: 분산 환경에서 사용되는 외부 캐시 서버

환경 설정

이 섹션에서는 서적과 조금 다르게 스프링 부트를 사용할 것이며 EHCACHE가 JCache 표준을 준수하고 있으므로 JCache를 활용한 설정을 진행할 것이다. 이를 위해 다음과 같은 순서로 작업해야 한다.

  • 캐시 제공자를 사용하기 위한 의존성 설치
  • ehcache.xml: EHCACHE의 세밀한 설정 명시
  • application.properties 설정 파일에 2차 캐시의 사용 여부, 공유 캐시 모드 및 제공자 등 명시
  • 애플리케이션에서 엔터티 클래스를 @Cacheable, @Cache 애너테이션으로 지정

먼저 EHCACHE 캐시 제공자를 사용하기 위한 의존성을 설치한다.

예제 16.16. pom.xml에 hibernate-ehcache 추가

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
</dependency>
<dependency>
    <groupId>org.hibernate.orm</groupId>
    <artifactId>hibernate-jcache</artifactId>
</dependency>

EHCACHE는 다음과 같이 ehcache.xml을 설정 파일로 사용한다. 이 파일을 클래스패스 루트인 resources 디렉터리에 정의하면 된다.

예제 16.17. ehcache.xml 추가

<config
        xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
        xmlns='http://www.ehcache.org/v3'
        xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd">

    <cache-template name="default-template">
        <expiry>
            <tti unit="minutes">20</tti>
<!--            <ttl unit="minutes">20</ttl>-->
        </expiry>
        <resources>
            <heap unit="entries">10000</heap>
        </resources>
    </cache-template>

    <cache alias="L2C1" uses-template="default-template" />
    <cache alias="L2C2" uses-template="default-template" />

</config>

스프링 부트 3.X 버전에서는 ehcache.xml을 위와 같이 정의한다. 캐시 템플릿으로 캐시의 세밀한 설정을 조정한 뒤 L2C1, L2C2 캐시를 생성했다. 애플리케이션에서는 @Cacheable(value = "L2C1") 애너테이션으로 사용할 수 있다. 하지만 이는 다소 구식으로 최근에는 Java 애플리케이션 코드로 직접 캐시를 설정하는 방법이 권장된다.

다음으로 애플리케이션 설정 파일에 2차 캐시 사용 여부, 모드 및 제공자 등을 명시한다.

예제 16.18. application.properties에 캐시 정보 추가

# 0. Load the EHCACHE configuration file
spring.cache.jcache.config=classpath:ehcache.xml
# 1. Enable the L2 cache globally (mandatory flag)
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
# 2. Set the cache factory as the JCache standard implementation (recommended for Hibernate 6.x)
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.internal.JCacheRegionFactory
# 3. Set the JCache provider to Ehcache
spring.cache.jcache.provider=org.ehcache.jsr107.EhcacheCachingProvider
# 4. Set the L2 cache scope mode (Jakarta EE namespace)
spring.jpa.properties.jakarta.persistence.sharedCache.mode=ENABLE_SELECTIVE
# 5. Enable the query cache
spring.jpa.properties.hibernate.cache.use_query_cache=true
# 6. Enable statistics generation
spring.jpa.properties.hibernate.generate_statistics=true
  1. spring.cache.jcache.config
    • 스프링의 JCache 캐시 매니저(CacheManager)를 초기화할 때 사용할 설정 파일의 위치를 지정한다.
    • 스프링의 메서드 캐시(@Cacheable)가 이 파일의 설정을 사용한다.
    • Hibernate L2 캐시도 2번, 3번 설정에 의해 동일한 JCache 매니저를 공유하여 캐시 설정이 하나로 통합 관리된다.
  2. spring.jpa.properties.hibernate.cache.use_second_level_cache
    • Hibernate에게 2차 캐시 기능을 키라고 지시하는 메인 스위치이다. 필수 설정이다.
  3. spring.jpa.properties.hibernate.cache.region.factory_class
    • Hibernate가 캐시 데이터를 관리하기 위해 사용할 캐시의 공장(Region Factory)을 지정한다.
    • JCacheRegionFactory를 사용하겠다고 선언함으로써 Hibernate가 특정 캐시 기술에 직접 의존하지 않고 JCache(JSR-107) 표준 인터페이스를 통해 캐시와 통신하도록 설정한다.
  4. spring.cache.jcache.provider
    • JCache라는 표준 인터페이스를 실제로 구현한 구현체로 EHCACHE를 지정한다.
    • Hibernate가 JCache 표준에 따라 캐시를 요청하면 EhcacheCachingProvider가 그 요청을 받아 ehcache.xml 설정을 바탕으로 실제 캐싱 작업을 수행한다.
  5. spring.jpa.properties.jakarta.persistence.sharedCache.mode
    • L2 캐시를 적용할 엔터티의 범위를 설정한다.
  6. spring.jpa.properties.hibernate.cache.use_query_cache
    • 쿼리 캐시 기능을 활성화한다.
    • 활성화하더라도 쿼리 실행 시 .setCacheable(true) 또는 @QueryHint를 사용해 캐싱을 명령해야만 동작한다.
  7. spring.jpa.properties.hibernate.generate_statistics
    • Hibernate가 캐시 관련 통계 정보를 수집하도록 설정한다.
    • 성능에 영향을 주기 때문에 개발 환경에서만 적용하는 것이 좋다.

이로써 JCache와 EHCACHE를 활용한 환경 설정을 마쳤다.

기본적인 동작을 위해 시도한 테스트 코드를 첨부한다.

2차 캐시를 적용한 엔터티 클래스 정의

package jpabook.domain;

import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

테스트 헬퍼 서비스 클래스 정의

package jpabook.service;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jpabook.domain.Member;
import jpabook.repository.MemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CacheTestHelperService {

    @Autowired
    private MemberRepository memberRepository;

    @PersistenceContext
    private EntityManager em;

    // 초기 데이터 저장 (Commit 발생 -> L2C Put 가능)
    @Transactional
    public Member setupMember(Member member) {
        return memberRepository.save(member);
    }

    // L2 Cache Put 유도 (Commit 발생 -> L2C Put 완료)
    @Transactional
    public Member triggerCachePut(Long id) {
        // findById() 호출 후 트랜잭션 커밋 시 L2C Put 완료
        Member member = memberRepository.findById(id).get();
        return member;
    }
}

스프링 부트 테스트 클래스 정의

package jpabook;

import jakarta.persistence.Cache;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jpabook.domain.Member;
import jpabook.repository.MemberRepository;
import jpabook.service.CacheTestHelperService;
import org.assertj.core.api.Assertions;
import org.hibernate.Session;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@TestPropertySource(properties = {
        "spring.jpa.properties.hibernate.generate_statistics=true"  // Hibernate 통계 활성화 (필수)
})
public class MemberCacheTest {

    @PersistenceContext
    private EntityManager em;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private CacheTestHelperService helperService;

    private Long memberId;

    @BeforeEach
    void setUp() {
        Member member = new Member();
        member.setName("TestName");
        // Save 로직은 트랜잭션 환경에서 실행되도록 분리
        memberId = helperService.setupMember(member).getId();

        // 테스트를 위한 1차 캐시 비우기 (필요)
        em.clear();
    }

    @Test
    void secondLevelCacheHitTest() {

        // L2 Cache Put 유도
        helperService.triggerCachePut(memberId);

        // L1 캐시 비우기
        em.clear();

        // 통계 객체 초기화
        Statistics statistics = em.unwrap(Session.class).getSessionFactory().getStatistics();
        statistics.clear();

        // L2 Cache Hit 발생
        memberRepository.findById(memberId);

        // L2 Cache Hit 검증
        Assertions.assertThat(statistics.getSecondLevelCacheHitCount())
                .as("L2 Cache Hit Count")
                .isGreaterThanOrEqualTo(1);

        // DB 쿼리 발생 여부 검증
        Assertions.assertThat(statistics.getPrepareStatementCount())
                .as("DB Query Count")
                .isLessThanOrEqualTo(0);
    }
}

엔터티 캐시와 컬렉션 캐시

예제 16.19. 2차 캐시 적용 코드

package jpabook.domain;

import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

import java.util.ArrayList;
import java.util.List;

@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@Entity
public class ParentMember {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    @OneToMany(mappedBy = "parentMember", cascade = CascadeType.ALL)
    private List<ChildMember> childMembers = new ArrayList<>();

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<ChildMember> getChildMembers() {
        return childMembers;
    }

    public void setChildMembers(List<ChildMember> childMembers) {
        this.childMembers = childMembers;
    }
}
  • jakarta.persistence.Cacheable: 엔터티 캐시를 적용하고자 할 때 사용하는 애너테이션이다.
  • org.hibernate.annotations.Cache: 하이버네이트 전용 애너테이션으로 2차 캐시와 관련된 세밀한 설정을 하거나 컬렉션 캐시를 적용하고자 할 때 사용한다.

@Cache

하이버네이트 전용인 org.hibernate.annotations.Cache 애너테이션을 사용하면 세밀한 2차 캐시 제어가 가능하다.

속성설명
usageCacheConcurrencyStrategy를 사용해서 캐시 동시성 전략을 설정한다.
region캐시 지역 설정
include연관 객체를 캐시에 포함할지 선택한다. all, non-lazy 옵션을 선택할 수 있고 기본 값은 all이다.

캐시 동시성 전략과 관련된 usage 속성에는 다음과 같은 org.hibernate.annotations.CacheConcurrencyStrategy 열거형 값을 설정할 수 있다.

속성설명
NONE캐시를 설정하지 않는다.
READ_ONLY읽기 전용으로 설정한다.
등록/삭제는 가능하지만 수정은 불가능하다.
읽기 전용인 불변 객체는 수정되지 않으므로 하이버네이트는 2차 캐시 조회 시 원본 객체를 반환한다.
NONSTRICT_READ_WRITE엄격하지 않은 읽기/쓰기 전략이다.
동시에 같은 엔터티를 수정하면 데이터 일관성이 깨질 수 있다.
EHCACHE는 데이터 수정 시 캐시 데이터를 무효화한다.
READ_WRITE읽기/쓰기가 가능하고 READ COMMITTED 정도의 격리 수준을 보장한다.
EHCACHE는 데이터 수정 시 캐시 데이터도 함께 수정한다.
TRANSACTIONAL컨테이너 관리 환경에서 사용할 수 있다.
설정에 따라 REPEATABLE READ 정도의 격리 수준을 보장받을 수 있다.

캐시 종류에 따른 동시성 전략 지원 여부는 다음과 같다. 이 중 ConcurrentHashMap은 개발 단계에서만 사용해야 한다.

Cacheread-onlynonstrict-read-writeread-writetransactional
ConcurrentHashMapyesyesyes
EHCacheyesyesyesyes
Infinispanyesyes

캐시 영역

2차 캐싱을 적용한 코드는 다음과 같은 캐시 영역(Cache Region)에 저장된다.

  • 엔터티 캐시 영역: [package].[class]
  • 컬렉션 캐시 영역: [package].[class].[field]

@Cache 애너테이션의 region 속성을 명시하여 캐시 영역을 직접 지정할 수도 있다. 캐시 영역별 세밀한 설정을 원할 시 ehcache.xml 파일에서 cache 태그의 name 속성을 캐시 영역 이름과 통일하고 필요한 설정을 진행하면 된다.

쿼리 캐시

쿼리 캐시는 쿼리와 파라미터의 정보를 key, 쿼리의 결과를 value로 하여 캐싱하는 방법이다. 쿼리 캐시를 적용하려면 영속성 유닛 설정에서 hibernate.cache.use_query_cache 옵션을 반드시 설정해야 한다. 그리고 다음과 같이 쿼리 캐시를 적용하려는 쿼리마다 org.hibernate.cacheabletrue로 설정하는 힌트를 주면 된다.

예제 16.21. 쿼리 캐시 적용

em.createQuery("SELECT i FROM Item i", Item.class)
	.setHint("org.hibernate.cacheable", true)
    .getResultList();

예제 16.22. NamedQuery에 쿼리 캐시 적용

@Entity
@NamedQuery(
	hints = @QueryHint(name = "org.hibernate.cacheable", value = "true"),
    name = "Member.findByUsername",
    query = "SELECT m.address FROM Member m WHERE m.name = :username"
)
public class Member {
	...
}

쿼리 캐시 영역

쿼리 캐시 활성화 시 다음 두 캐시 영역이 추가된다.

  • org.hibernate.cache.internal.StandardQueryCache: 쿼리 캐시가 저장되는 영역이다. 쿼리, 쿼리 결과 집합, 쿼리 실행 시점의 타임스탬프가 저장된다.
  • org.hibernate.cache.spi.UpdateTimestampCache: 쿼리 캐시의 유효성 판단을 위해 쿼리 대상 테이블의 가장 최근 변경 시점을 저장하는 영역이다. 테이블 이름과 해당 테이블의 최근 변경 시점 타임스탬프가 저장된다.

쿼리 캐시는 대상 테이블에 조금이라도 변경이 발생하면 기존의 캐시를 무효화하고 데이터베이스로부터 쿼리 결과를 다시 캐싱한다. 예를 들어 엔터티 변경 시 org.hibernate.cache.spi.UpdateTimestampCache 캐시 영역에 해당 엔터티가 매핑한 테이블 이름으로 타임스탬프가 갱신된다.

예제 16.23. 쿼리 캐시 사용

public List<ParentMember> findParentMembers() {
	return em.createQuery("SELECT p FROM ParentMember p JOIN p.childMembers c", ParentMember.class)
    	.setHint("org.hibernate.cacheable", true)
        .getResultList();
}

위 쿼리 실행 시 우선 StandardQueryCache 캐시 영역에서 쿼리 실행 시점의 타임스탬프를 조회하고, UpdateTimestampCache 캐시 영역에서 쿼리에서 사용되는 테이블인 PARENTMEMBER, CHILDMEMBER의 최근 변경 시점의 타임스탬프를 가져와 대조한다. 이때 변경 시점이 조회 시점보다 더 최근이라면 캐시를 무효화하고 쿼리를 다시 실행한다.

그러므로 변경이 자주 발생하는 테이블에 쿼리 캐시를 사용하면 2차 캐시를 사용하는 의미가 퇴색될 수 있다. 수정이 거의 발생하지 않는 테이블에 사용해야 극적인 성능 향상을 기대할 수 있다.

org.hibernate.cache.spi.UpdateTimestampsCache 쿼리 캐시 영역이 만료되면 모든 쿼리 캐시가 무효화되기 때문에 EHCACHE의 eternal="true" 옵션으로 만료되지 않도록 설정해야 한다.

쿼리 캐시와 컬렉션 캐시의 주의점

엔터티 캐시를 활용한 엔터티 캐싱은 엔터티의 정보를 모두 포함하지만 쿼리 캐시와 컬렉션 캐시는 결과 집합의 식별자 값만 캐싱한다. 따라서 쿼리 캐시와 컬렉션 캐시에 캐시 히트가 발생하더라도 식별자 값만 조회되고, 조회된 식별자 값을 엔터티 캐시에서 조회하여 실제 엔터티를 찾는 것이다.

그러므로 쿼리 캐시나 컬렉션 캐시만 사용하고 대상 엔터티에 엔터티 캐시를 적용하지 않으면 쿼리 캐시나 컬렉션 캐시에서 발견된 각각의 식별자 값마다 별개의 데이터베이스 조회 쿼리가 발생하여 N+1 문제와 유사한 성능상의 심각한 문제가 발생할 수 있다. 그러므로 쿼리 캐시나 컬렉션 캐시를 사용하면 결과 대상 엔터티에는 반드시 엔터티 캐시를 적용해야 한다.

16.3 정리

  • 트랜잭션의 격리 수준은 4단계가 있다. 격리 수준이 낮을수록 동시성은 증가하지만 조회에 있어 다양한 이상 현상이 발생한다.
  • 영속성 컨텍스트는 데이터베이스 트랜잭션이 READ COMMITTED 격리 수준이어도 애플리케이션 레벨의 트랜잭션 스코프에서 반복 가능한 읽기(REPEATABLE READ)를 제공한다.
  • JPA는 낙관적 락과 비관적 락을 지원한다. 낙관적 락은 수정 쿼리 수행 시 데이터베이스의 경량화된 락에 의존하도록 구현되어 있고, 비관적 락은 데이터베이스의 트랜잭션 락 메커니즘에 의존하도록 구현되어 있다.
  • 2차 캐시를 사용하여 데이터베이스와의 통신 횟수를 줄이면 애플리케이션의 조회 성능을 극적으로 끌어올릴 수 있다.
profile
안녕하세요

0개의 댓글