elevne's Study Note
JPA 트랜잭션과 락, 2차 캐시 본문
트랜잭션은 ACID 라고 하는 원자성, 일관성, 격리성, 지속성을 보장해야 한다. 여기에서 격리성을 완벽하게 보장하려면, 트랜잭션을 거의 차례대로 실행시켜야 한다는 문제가 있다. 이렇게 하면 동시성 처리 성능이 매우 나빠진다. 이러한 문제로 ANSI 표준은 트랜잭션의 격리 수준을 4 단계로 나누어 정의한다. 순서대로 READ UNCOMMITTED 의 격리수준이 가장 낮고 SERIALIZABLE 의 격리수준이 가장 높다.
- READ UNCOMMITTED (커밋되지 않은 읽기)
- READ COMMITTED (커밋된 읽기)
- REPEATABLE READ (반복 가능한 읽기)
- SERIALIZABLE (직렬화 가능)
격리수준 | DIRTY READ | NON-REPEATABLE READ | PHANTOM READ |
READ UNCOMMITTED | O | O | O |
READ COMMITTED | O | O | |
REPEATABLE READ | O | ||
SERIALIZABLE |
격리수준이 낮을수록 더 많은 문제가 발생한다.
- READ UNOMMITTED: 커밋하지 않은 데이터를 읽을 수 있다. (다른 트랜잭션이 수정 중인 데이터를 읽을 수 있는 것. 만약 다른 트랜잭션이 롤백된다면 데이터 정합성 심각한 문제 유발)
- READ COMMITTED: 커밋한 데이터만 읽을 수 있다. NON-REPEATABLE READ 가 발생한다. (반복해서 같은 데이터를 읽을 수 없는 상태. 트랜잭션 작업 중 변경된 데이터가 잇으면 변경된 데이터를 읽음)
- REPEATABLE READ: 한 번 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 하지만 PHANTOM READ 는 발생할 수 있다. (반복 조회시 결과 집합이 달라지게 되는 것을 뜻함)
- SERIALIZABLE: PHANTOM READ 도 발생하지 않는다. 다만 동시성 처리 성능이 급격히 떨어질 수 있다.
데이터베이스들은 보통 READ COMMITTED 격리 수준을 기본으로 사용한다고 한다. (일부 중요한 비즈니스 로직에 더 높은 격리 수준이 필요할 시 트랜잭션이 제공하는 잠금 기능을 사용한다)
JPA 의 영속성 컨텍스트를 활용하면 데이터베이스에서 트랜잭션이 READ COMMITTED 격리 수준이더라도 애플리케이션 레벨에서 REPEATABLE READ 가 가능하다. (엔티티가 아닌 스칼라 값을 직접 조회하면 영속성 컨텍스트를 사용할 수 없으니 이 또한 불가능) JPA 는 데이터베이스 트랜잭션 수준을 READ COMMITTED 로 가정한다. 만약 일부 로직에 더 높은 격리수준이 필요하다면 낙관적 락, 비관적 락 중 하나를 선택하여 사용한다.
낙관적 락은 트랜잭션 대부분은 충돌이 발생하지 않는다고 가정하는 방법이다. DB 에서 제공하는 락 기능을 사용하는 것이 아니라 JPA 가 제공하는 버전관리 기능을 사용한다. 낙관적 락을 사용하면 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다. 반대로 비관적 락은 트랜잭션의 충돌이 발생한다고 가정하고 락을 걸고보는 방법이다. 이는 DB 에서 제공하는 락 기능을 사용한다.
또, 예를 들어 두 명의 다른 사용자가 동일한 데이터를 동시에 업데이트 하는 로직을 실행시킨다면, 조금 더 늦게 실행시킨 사용자의 데이터만 남게될 것이다. 이를 두 번의 갱신 분실 문제라고 한다. 이는 데이터베이스 트랜잭션 범위를 넘어서는 문제다. 3 가지 방법 중 하나로 해결해야 한다.
- 마지막 커밋만 인정하기
- 최초 커밋만 인정하기
- 충돌하는 갱신 내용 병합하기
기본적으로는 마지막 커밋만 인정하기가 사용된다. 하지만 이는 어떠한 로직이냐에 따라 상황 별로 달라질 수 있는 것이다.
JPA 에서 낙관적 락을 사용하기 위해서는 @Version 애노테이션을 사용해야 한다. 이는 엔티티에 버전 관리 기능을 추가해줄 수 있는 애노테이션이다. @Version 은 Long, Integer, Short 혹은 Timestamp 타입에만 붙여줄 수 있다.
@Entity
public class Member {
@Version
private Integer version;
...
이를 추가해주면, 엔티티를 수정할 때마다 버전이 하나씩 자동으로 증가하게 된다. 그리고, 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생하게 되는 것이다. (트랜잭션 도중 다른 트랜잭션에서 해당 엔티티 정보를 수정하여 커밋하면 예외가 발생한다는 뜻) 버전 정보를 사용하면 최초 커밋만 인정하기가 적용되는 것이다. 버전 정보를 비교할 때에는 UPDATE 쿼리 시 WHERE 절에 VERSION=? 조건을 넣어 확인한다.
그렇다면 이를 사용해서 어떻게 락을 사용할 수 있는 것일까? 우선 락을 거는 것은 데이터 조회 시에도 가능하며, 중간에 영속성 컨텍스트에서 설정해서 걸 수도 있다. JPA 가 제공하는 락 옵션은 javax.persistence.LockModeType 에 정의되어 있다.
락 모드 | 타입 | 설명 |
낙관적 락 | OPTIMISTIC | 낙관적 락 사용 |
낙관적 락 | OPTIMISTIC_FORCE_INCREMENT | 낙관적 락 + 버전정보를 강제로 증가시킴 |
비관적 락 | PESSIMISTIC_READ | 비관적 락, 읽기 락을 사용 |
비관적 락 | PESSIMISTIC_WRITE | 비관적 락, 쓰기 락을 사용 |
비관적 락 | PESSIMISTIC_FORCE_INCREMENT | 비관적 락 + 버전정보 강제로 증가시킴 |
기타 | NONE | 락을 걸지 않음 |
기타 | READ | JPA 1.0 호환 기능. OPTIMISTIC 과 같으므로 OPTIMISTIC 을 사용하면 됨 |
기타 | WRITE | JPA 1.0 호환 기능. OPTIMISTC_FORCE_INCREMENT 와 동일 |
우선 낙관적 락을 사용하기 위해서는 @Version 이 있어야 한다. 낙관적 락은 트랜잭션을 커밋하는 시점에 충돌을 알 수 있다는 특징이 있다. 참고로 락 옵션 없이 @Version 만 있어도 낙관적 락이 적용된다.
NONE
락 옵션을 적용하지 않아도 @Version 만 있으면 낙관적 락이 적용된다. NONE 은 (@Version 필드가 있다고 했을 때) 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경되지 않아야 하며, 조회 시점부터 수정 시점까지를 보장한다. 그리고 엔티티를 수정할 때, 버전을 체크하면서 버전을 증가시키는 것이다.
OPTIMISTIC
@Version 만 적용했을 때는 엔티티를 수정해야 버전을 체크하지만, 이 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크한다. 한 번 조회한 엔티티는 트랜잭션을 종료할 때까지 다른 트랜잭션에서 변경하지 않음을 보장하는 것이다. 트랜잭션을 커밋할 때 버전 정보를 조회해서 현재 엔티티의 버전과 같은지 검증하고, 만약 같지 않다면 예외를 발생시킨다. 이 옵션은 DIRTY READ 와 NON-REPEATABLE READ 를 방지한다.
OPTIMISTIC_FORCE_INCREMENT
낙관적 락을 사용하면서 버전을 강제로 증가시킨다. 이는 논리적인 엔티티 단위의 묶음을 관리할 수 있다. (e.g., 게시물과 첨부파일이 일대다 관계로 있을 때, 게시물을 수정하며 단순히 첨부파일만 추가한다면 게시물의 버전이 증가하지 않는다. 논리적으로는 버전이 증가하는게 맞다. 이 때 게시물의 버전을 강제로 증가시키는 옵션이 OPTIMISTIC_FORCE_INCREMENT 이다) 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가시킨다. 이 때 데이터베이스의 버전이 엔티티의 버전과 다르면 예외가 발생하는 것이다.
비관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존한다. 주로 SQL 쿼리에 SELECT FOR UPDATE 구문을 사용하며 시작하고 버전 정보는 사용하지 않는다. 비관적 락은 엔티티가 아닌 스칼라 타입을 조회할 때에도 사용할 수 있으며, 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다. 비관적 락을 사용하면 락을 획득할 때까지 트랜잭션이 대기하게 되는데, 무한정 기다릴 수 없으니 타임아웃 시간을 줄 수 있다.
public void pLock() {
Map<String, Object> props = new HashMap<>();
props.put("javax.persistence.lock.timeout", 10000);
Member member = em.find(Member.class, 1L, LockModeType.PESSIMISTIC_WRITE, props);
}
PESSIMISTIC_WRITE
일반적인 비관적 락 옵션이다. 데이터베이스에 SELECT FOR UPDATE 를 통해 쓰기 락을 건다. 이는 NON-REPEATABLE READ 를 방지하며, 락이 걸린 로우는 다른 트랜잭션이 수정할 수 없다.
PESSIMISTIC_READ
데이터를 반복 읽기만 하고, 수정하지 않는 용도로 락을 걸 때 사용한다. (잘 사용하지 않는다) 데이터베이스 대부분은 방언에 의해 PESSIMISTIC_WRITE 로 동작한다고 한다.
PESSIMISTIC_FORCE_INCREMENT
이는 버전정보를 사용한다. 비관적 락이지만 버전 정보를 강제로 증가시킨다. 하이버네이트는 NOWAIT 을 지원하는 데이터베이스에 대해서 FOR UPDATE NOWAIT 옵션을 적용한다.
그 다음으로는 JPA 가 제공하는 애플리케이션 범위의 캐시에 대해 알아본다. 또, 하이버네이트와 EHCACHE 를 사용해서 실제 캐시를 적용해본다.
네트워크를 통해 DB 에 접근하는 시간은 애플리케이션 서버에서 내부 메모리에 접근하는 비용보다 수만에서 수십만 배 이상 비싸다고 한다. 따라서 조회 메모리를 캐시해서 데이터베이스 접근 횟수를 줄이는 것이 좋다. 영속성 컨텍스트는 1차 캐시에 엔티티를 보관한다. 하지만 일반적으로 트랜잭션이 종료되면 1차 캐시 내부에서 데이터가 삭제된다. (OSIV 를 사용해도 클라이언트 요청이 끝나면 삭제된다) 따라서 애플리케이션 전체로 보면 데이터베이스 접근 횟수를 획기적으로 줄이지는 못한다. 하이버네이트에서는 대신 애플리케이션 범위의 캐시를 지원하는데, 이를 공유 캐시 또는 2차 캐시라고 한다. 2차 캐시는 애플리케이션이 종료될 때까지 유지되며, 분산 캐시나 클러스터링 환경의 캐시는 애플리케이션보다 더 오래 유지될 수도 있다. 2차 캐시는 동시성을 극대화하기 위해 캐시한 객체를 직접 반환하지 않고 복사본을 만들어 반환한다. 만약 캐시한 객체를 그대로 반환하면 여러 곳에서 같은 객체를 수정하는 문제가 발생할 수 있다.
2차 캐시를 사용하려면 javax.persistence.Cacheable 애노테이션을 사용하면 된다.
@Entity
@Cacheable
public class Member {
...
그리고
<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
<property name="sharedCacheMode" value="ENABLE_SELECTIVE" />
</bean>
위 Bean 을 등록해준다. 보통 ENABLE_SELECTIVE 로 value 를 넣는데, 이는 @Cacheable(true) 로 설정된 엔티티만 캐시를 적용한다.
그 다음으로는 EHCACHE 를 사용해본다. 하이버네이트는 엔티티, 컬렉션, 쿼리를 캐시할 수 있게끔 지원해준다. 우선 hibernate-ehcache 를 dependency 에 추가한다. EHCACHE 는 ehcache.xml 파일을 기본 설정 파일로 사용한다. 이 설정파일에서 캐시를 얼만큼, 얼마 동안 보관할지 등에 대해 설정할 수 있다.
<ehcache>
<defaultCache
maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="1200"
timeToLiveSeconds="1200"
diskExpiryThreadIntervalSeconds="1200"
memoryStoreEvictionPolicy="LRU"
/>
</ehcache>
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Member {
@BatchSize(size = 5)
@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
private List<Item> items;
...
Reference:
자바 ORM 표준 JPA 프로그래밍
'Backend > JPA 프로그래밍' 카테고리의 다른 글
JPA 고급주제와 성능 최적화 (0) | 2023.07.27 |
---|---|
JPA 컬렉션과 부가기능 (0) | 2023.07.26 |
JPA N+1 & OSIV (0) | 2023.07.20 |
Spring Data JPA (0) | 2023.07.19 |
Spring Maven 설정 (0) | 2023.07.17 |