트랜잭션은 ACID 원칙을 준수해야 한다.
격리성을 완벽히 보장하는 트랜잭션은 순차적으로 실행되어야 하는데, 이는 동시 처리 성능이 매우 나쁘기 때문에 ANSI 표준에서는 트랜잭션의 격리 수준을 다음과 같이 4단계로 나누어 정의한다.
격리 수준에 따라 발생할 수 있는 문제점들은 다음과 같다.
애플리케이션 대부분은 동시성 처리가 중요하기 때문에 데이터베이스들은 보통 READ COMMITTED 격리 수준을 기본으로 사용한다.
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" "B"로의 업데이트 기록만 남게 된다. 이를 해결하기 위한 방법은 3가지가 있다.
기본은 마지막 커밋만 인정하는 방식이 사용되지만 결제 기능과 같이 상황에 따라 최초 커밋만 인정하는 방식이 더 합리적일 수 있다. JPA가 기본적으로 제공하는 버전 관리 기능을 사용하면 손쉽게 최초 커밋만 인정하는 방식을 구현할 수 있다.
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가 직접 관리하기 때문에 사용자가 임의로 수정하면 안 된다. 단, 벌크 연산의 경우 영속성 컨텍스트의 관리를 받지 못하기 때문에 사용자가 직접 위 쿼리와 같은 논리로 버전 필드를 갱신해 주어야 한다.
JPA 사용 시 추천되는 전략은 READ COMMITTED 트랜잭션 격리 수준 설정 및 낙관적 버전 사용이다.
락은 다음 위치에 적용할 수 있다.
EntityManager.lock(), EntityManager.find(), EntityManager.refresh()Query.setLockMode() (TypedQuery 포함)@NamedQueryJPA가 제공하는 락 옵션은 jakarta.persistence.LockModeType에 정의되어 있다.
| 락 모드 | 타입 | 설명 |
|---|---|---|
| 낙관적 락 | OPTIMISTIC | 낙관적 락을 사용한다. |
| 낙관적 락 | OPTIMISTIC_FORCE_INCREMENT | 낙관적 락을 사용하면서 버전 정보를 강제로 증가시킨다. |
| 비관적 락 | PESSIMISTIC_READ | 비관적 락, 읽기 락을 사용한다. |
| 비관적 락 | PESSIMISTIC_WRITE | 비관적 락, 쓰기 락을 사용한다. |
| 비관적 락 | PESSIMISTIC_FORCE_INCREMENT | 비관적 락을 사용하면서 버전 정보를 강제로 증가시킨다. |
| 기타 | NONE | 락을 사용하지 않는다. |
| 기타 | READ | JPA 1.0 호환 기능으로 OPTIMISTIC과 같다. |
| 기타 | WRITE | JPA 1.0 호환 기능으로 OPTIMISTIC_FORCE_INCREMENT와 같다. |
JPA가 제공하는 낙관적 락은 버전(@Version)을 필요로 하며 트랜잭션 커밋 시점에 충돌을 감지할 수 있다는 특징이 있다. 낙관적 락 사용 시 다음과 같은 예외가 발생할 수 있다.
jakarta.persistence.OptimisticLockException: JPA 예외org.hibernate.StaleObjectStateException: 하이버네이트 예외org.springframework.orm.ObjectOptimisticLockingFailureException: 스프링 예외 추상화락 옵션 없이 @Version 애너테이션만 사용해도 낙관적 락이 적용되지만 락 옵션 사용 시 더 세밀하게 제어할 수 있다.
락 옵션 미적용 시에도 @Version 애너테이션으로 지정된 필드가 있다면 낙관적 락이 적용된다.
UPDATE 쿼리를 사용해 버전 검사 및 증가를 수행하고 버전 값 불일치 시 예외 발생OPTIMISTIC 옵션을 적용하면 엔터티를 조회만 하더라도 트랜잭션 커밋 시 해당 엔터티의 버전을 검사하게 된다. 그러므로 트랜잭션이 유효하려면 한 번 조회한 엔터티는 트랜잭션 종료 시까지 다른 트랜잭션에서 변경하지 않음이 보장되어야 한다.
예제 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는 Aggregate 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가 된다.
JPA가 제공하는 비관적 락은 데이터베이스의 트랜잭션 락 메커니즘에 의존하는 방식으로 구현되어 있다. 주로 SQL 쿼리에 SELECT FOR UPDATE 구문을 사용하며 시작하고 버전 정보는 사용하지 않는다. 비관적 락은 주로 PESSIMISTIC_WRITE 모드를 사용한다.
비관적 락은 다음과 같은 특징이 있다.
비관적 락 사용 시 다음과 같은 예외가 발생할 수 있다.
jakarta.persistence.PessimisticLockException: JPA 예외org.springframework.dao.PessimisticLockingFailureException: 스프링 예외 추상화애플리케이션 레벨의 비관적 락은 일반적으로 PESSIMISTIC_WRITE 옵션을 의미한다. 데이터베이스에 쓰기 락을 걸 때 사용한다.
SELECT FOR UPDATE 쿼리를 전송PESSIMISTIC_READ 옵션은 데이터를 반복 읽기만 하고 수정하지는 않을 때 사용될 수 있는데 그런 사례는 흔하지 않기 때문에 일반적으로 잘 사용되지 않는다. 데이터베이스 대부분은 방언에 의해 PESSIMISTIC_WRITE 옵션으로 동작한다.
LOCK IN SHARE MODEFOR SHAREPESSIMISTIC_FORCE_INCREMENT는 비관적 락이면서 버전 정보를 유지하는 유일한 옵션이다. 하이버네이트는 NOWAIT을 지원하는 데이터베이스에 대해 FOR UPDATE NOWAIT 옵션을 적용한다.
FOR UPDATE NOWAITFOR UPDATE NOWAITNOWAIT을 지원하지 않으면 FOR UPDATE가 사용된다.비관적 락을 사용하면 락 획득 시까지 트랜잭션이 무한정 대기할 수 있다. 그러므로 다음과 같이 타임아웃을 설정할 수 있는데 데이터베이스 특성에 따라 동작하지 않을 수도 있다.
비관적 락 타임아웃 설정
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 예외가 발생한다.
네트워크 통신 비용은 메모리 접근 비용보다 월등히 크기 때문에 조회한 데이터를 메모리에 캐시하여 데이터베이스 접근 횟수를 줄이면 애플리케이션 성능을 획기적으로 개선할 수 있다.
영속성 컨텍스트 내부에는 1차 캐시라고 불리는 엔터티 저장소가 있다. 일반적인 웹 애플리케이션 환경에서는 트랜잭션 생명 주기 동안에만 1차 캐시가 유효하며, OSIV(Open Session In View, 뷰 렌더링 시점까지 영속성 컨텍스트를 열어두는 기능)을 활성화해도 요청 스코프 동안에만 1차 캐시가 유효하다. 따라서 요청마다 데이터베이스 통신이 필연적으로 발생하기 때문에 애플리케이션 전체적으로 보았을 때 데이터베이스 접근 횟수에 있어서 획기적인 성능 개선이 이루어지진 않는다.

하이버네이트를 포함한 대부분의 JPA 구현체들은 애플리케이션 스코프의 공유 캐시(또는 2차 캐시)를 지원한다. 2차 캐시의 엔터티는 여러 트랜잭션 간 공유할 수 있기 때문에 실질적으로 데이터베이스 접근 횟수를 개선할 수 있다.
1차 캐시는 영속성 컨텍스트 내부에 위치하여 엔터티 매니저로 조회하거나 변경하는 모든 엔터티를 저장한다. 트랜잭션을 커밋하거나 영속성 컨텍스트를 명시적으로 플러시(em.flush())하면 쓰기 지연된 1차 캐시의 변경 사항이 데이터베이스에 동기화된다. 영속성 컨텍스트 자체가 사실상 1차 캐시와 같다.
J2EE나 스프링 프레임워크 같은 컨테이너 위에서 JPA를 실행하면 영속성 컨텍스트는 트랜잭션과 생명 주기를 공유한다. OSIV를 사용하더라도 요청 스코프와 생명 주기를 공유하게 된다. 1차 캐시는 다음과 같은 특징을 갖는다.
JPA는 애플리케이션 스코프에서 공유되는 캐시를 공유 캐시(shared cache) 또는 2차 캐시(second level cache, L2 cache)라고 한다. 2차 캐시는 애플리케이션과 생명 주기를 공유하며 분산 캐시나 클러스터링 환경의 캐시는 애플리케이션보다도 더 오랫동안 유지될 수도 있다.
엔터티 조회 요청이 발생할 시 1차 캐시 - 2차 캐시 - 데이터베이스 순으로 탐색을 진행한다. 그러므로 2차 캐시 활용을 통해 데이터베이스 접근 횟수를 줄일 수 있다. 2차 캐시의 특징은 다음과 같다.
READ_ONLY 전략을 사용하면 객체가 불변할 것이라고 가정하고 복사본이 아닌 원본을 반환하여 서로 다른 영속성 컨텍스트 간 객체 동일성을 보장할 수 있다.JPA 구현체 대부분은 2차 캐시 기능을 개별적으로 지원해 왔지만 JPA는 2.0 버전에서야 2차 캐시 표준을 정의했다. 이는 여러 구현체가 공통적으로 사용하는 부분만 표준화했기 때문에 세밀한 설정을 하려면 구현체에 의존적인 기능을 사용해야 한다. JPA 2차 캐시의 표준적인 기능에 대해 알아보자.
그리고 마지막으로 다음과 같이 엔터티 클래스 정의에 jakarta.persistence.Cacheable 애너테이션을 사용하면 된다. @Cacheable은 true 또는 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)로 지정된 엔터티는 캐싱하지 않는다. |
| UNSPECIFIED | JPA 구현체가 정의한 설정을 따른다. |
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차 캐시를 관리하기 위한 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가 표준화한 캐시 기능은 여기까지이다.
하이버네이트가 지원하는 2차 캐시는 크게 3가지가 있다.
JPA 표준에는 엔터티 캐시만 정의되어 있고 나머지는 하이버네이트에 종속적인 기능들이다.
2차 캐시는 외부의 캐시 제공자(Cache Provider)에 의존적인 기능으로 다음과 같은 캐시 제공자들이 존재한다.
이 섹션에서는 서적과 조금 다르게 스프링 부트를 사용할 것이며 EHCACHE가 JCache 표준을 준수하고 있으므로 JCache를 활용한 설정을 진행할 것이다. 이를 위해 다음과 같은 순서로 작업해야 한다.
먼저 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
spring.cache.jcache.configCacheManager)를 초기화할 때 사용할 설정 파일의 위치를 지정한다.spring.jpa.properties.hibernate.cache.use_second_level_cachespring.jpa.properties.hibernate.cache.region.factory_classJCacheRegionFactory를 사용하겠다고 선언함으로써 Hibernate가 특정 캐시 기술에 직접 의존하지 않고 JCache(JSR-107) 표준 인터페이스를 통해 캐시와 통신하도록 설정한다.spring.cache.jcache.providerEhcacheCachingProvider가 그 요청을 받아 ehcache.xml 설정을 바탕으로 실제 캐싱 작업을 수행한다.spring.jpa.properties.jakarta.persistence.sharedCache.modespring.jpa.properties.hibernate.cache.use_query_cache.setCacheable(true) 또는 @QueryHint를 사용해 캐싱을 명령해야만 동작한다.spring.jpa.properties.hibernate.generate_statistics이로써 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차 캐시와 관련된 세밀한 설정을 하거나 컬렉션 캐시를 적용하고자 할 때 사용한다.하이버네이트 전용인 org.hibernate.annotations.Cache 애너테이션을 사용하면 세밀한 2차 캐시 제어가 가능하다.
| 속성 | 설명 |
|---|---|
| usage | CacheConcurrencyStrategy를 사용해서 캐시 동시성 전략을 설정한다. |
| 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은 개발 단계에서만 사용해야 한다.
| Cache | read-only | nonstrict-read-write | read-write | transactional |
|---|---|---|---|---|
| ConcurrentHashMap | yes | yes | yes | |
| EHCache | yes | yes | yes | yes |
| Infinispan | yes | yes |
2차 캐싱을 적용한 코드는 다음과 같은 캐시 영역(Cache Region)에 저장된다.
[package].[class][package].[class].[field]@Cache 애너테이션의 region 속성을 명시하여 캐시 영역을 직접 지정할 수도 있다. 캐시 영역별 세밀한 설정을 원할 시 ehcache.xml 파일에서 cache 태그의 name 속성을 캐시 영역 이름과 통일하고 필요한 설정을 진행하면 된다.
쿼리 캐시는 쿼리와 파라미터의 정보를 key, 쿼리의 결과를 value로 하여 캐싱하는 방법이다. 쿼리 캐시를 적용하려면 영속성 유닛 설정에서 hibernate.cache.use_query_cache 옵션을 반드시 설정해야 한다. 그리고 다음과 같이 쿼리 캐시를 적용하려는 쿼리마다 org.hibernate.cacheable을 true로 설정하는 힌트를 주면 된다.
예제 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 문제와 유사한 성능상의 심각한 문제가 발생할 수 있다. 그러므로 쿼리 캐시나 컬렉션 캐시를 사용하면 결과 대상 엔터티에는 반드시 엔터티 캐시를 적용해야 한다.