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

xyzw·2023년 9월 8일
0

Spring

목록 보기
21/22

트랜잭션과 락, 2차 캐시

트랜잭션과 락

트랜잭션과 격리 수준

트랜잭션은 ACID를 보장해야 한다.

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

트랜잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 차례대로 실행해야 하는데, 이러면 동시성 처리 성능이 매우 나빠진다.

트랜잭션의 격리 수준과 문제점

  • READ UNCOMMITED (커밋되지 않은 읽기): DIRTY READ, NON-REPEATABLE READ, PHANTOM READ
  • READ COMMITED (커밋된 읽기): NON-REPEATABLE READ, PHANTOM READ
  • REPEATABLE READ (반복 가능한 읽기): PHANTOM READ
  • SERIALIZABLE (직렬화 가능)

순서대로 READ UNCOMMITED의 격리 수준이 가장 낮고 SERIALIZABLE의 격리 수준이 가장 높다.
격리 수준이 낮을수록 동시성은 증가하지만 격리 수준에 따른 다양한 문제가 발생한다.

  • READ UNCOMMITED: 커밋하지 않은 데이터를 읽을 수 있다.
    예를 들어 트랜잭션1이 데이터를 수정하고 있는데 커밋하지 않아도 트랜잭션2가 수정 중인 데이터를 조회할 수 있다(DIRTY READ).
  • READ COMMITED: 커밋한 데이터만 읽을 수 있다. 따라서 DIRTY READ는 발생하지 않지만, NON-REPEATABLE READ는 발생할 수 있다.
    예를 들어 트랜잭션1이 회원 A를 조회 중인데 갑자기 트랜잭션2가 회원 A를 수정하고 커밋하면 트랜잭션1이 다시 회원 A를 조회했을 때 수정된 데이터가 조회된다. 이처럼 반복해서 같은 데이터를 읽을 수 없는 상태를 NON-REPEATABLE READ라 한다.
  • REPEATABLE READ: 한 번 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 하지만
    PHANTOM READ는 발생할 수 있다.
    예를 들어 트랜잭션1이 10살 이하의 회원을 조회했는데 트랜잭션2가 5살 회원을 추가하고 커밋하면 트랜잭션1이 다시 10살 이하의 회원을 조회했을 때 회원 하나가 추가된 상태로 조회된다. 이처럼 반복 조회 시 결과 집합이 달라지는 것을 PHANTOM READ라 한다.
  • SERIALIZABLE: 가장 엄격한 트랜잭션 격리 수준이다. 여기서는 PHANTOM READ가 발생하지 않는다. 하지만 동시성 처리 성능이 급격히 떨어질 수 있다.

애플리케이션 대부분은 동시성 처리가 중요하므로 데이터베이스들은 보통 READ COMMITED 격리 수준을 기본으로 사용한다. 일부 중요한 비즈니스 로직에 더 높은 격리 수준이 필요하면 데이터베이스 트랜잭션이 제공하는 잠금 기능을 사용하면 된다.

낙관적 락과 비관적 락 기초

JPA의 영속성 컨텍스트를 적절히 활용하면 데이터베이스 트랜잭션이 READ COMMITED 수준이어도 애플리케이션 레벨에서 반복 가능한 읽기가 가능하다.

JPA는 데이터베이스 트랜잭션 격리 수준을 READ COMMITED 정도로 가정한다. 만약 일부 로직에 더 높은 격리 수준이 필요하면 낙관적 락과 비관적 락 중 하나를 사용하면 된다.

낙관적 락

  • 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다.
  • 데이터베이스가 제공하는 락 기능을 사용하는 것이 아니라 JPA가 제공하는 버전 관리 기능을 사용한다.
  • 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다는 특징이 있다.

비관적 락

  • 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법이다.
  • 데이터베이스가 제공하는 락 기능을 사용한다.
    대표적으로 select for update 구문이 있다.

두 번의 갱신 분실 문제 (second lost updates problem)

사용자 A, B가 동시에 제목이 같은 공지사항을 수정한다. 이때 A가 먼저 수정완료 버튼을 누르고 잠시 후에 B가 수정 완료 버튼을 누른다. 결과적으로 먼저 완료한 A의 수정사항은 사라지고 나중에 완료한 B의 수정사항만 남게 된다. 이것을 두 번의 갱신 분실 문제라 한다.

두번의 갱신 분실 문제는 데이터베이스 트랜잭션의 범위를 넘어선다. 따라서 트랜잭션만으로는 문제를 해결할 수 없다.

  • 마지막 커밋만 인정하기: A의 내용은 무시하고 마지막에 커밋한 B의 내용만 인정한다.
  • 최초 커밋만 인정하기: A가 이미 수정을 완료했으므로 B가 수정을 완료할 때 오류가 발생한다.
  • 충돌하는 갱신 내용 병합하기: A와 B의 수정사항을 병합한다.

기본은 마지막 커밋만 인정하기가 사용된다. 하지만 상황에 따라 최초 커밋만 인정하기가 더 합리적일 수 있다.
JPA가 제공하는 버전 관리 기능을 사용하면 손쉽게 최초 커밋만 인정하기를 구현할 수 있다.
충돌하는 갱신 내용 병합하기는 최초 커밋만 인정하기를 조금 더 우아하게 처리하는 방법인데 애플리케이션 개발자가 직접 사용자를 위해 병합 방법을 제공해야 한다.

@Version

@Version 적용 가능 타입

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

버전 관리 기능을 적용하려면 엔티티에 버전 관리용 필드를 하나 추가하고 @Version을 붙이면 된다.

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

이제부터 엔티티를 수정할 때마다 버전이 하나씩 자동으로 증가한다. 그리고 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다.
따라서 버전 정보를 사용하면 최초 커밋만 인정하기가 적용된다.

버전 정보 비교 방법

엔티티를 수정하고 트랜잭션을 커밋하면 영속성 컨텍스트를 플러시하면서 UPDATE 쿼리를 실행한다.
이때 버전을 사용하는 엔티티면 검색 조건에 엔티티의 버전 정보를 추가한다.

데이터베이스 버전과 엔티티 버전이 같으면 데이터를 수정하면서 동시에 버전도 하나 증가시킨다.
만약 데이터베이스에 버전이 이미 증가해서 수정 중인 엔티티의 버전과 다르면 UPDATE 쿼리의 WHERE 문에서 VERSION 값이 다르므로 수정할 대상이 없다. 이때는 버전이 이미 증가한 것으로 판단해서 JPA가 예외를 발생시킨다.

버전은 엔티티의 값을 변경하면 증가한다. 단 연관관계 필드는 외래 키를 관리하는 연관관계의 주인 필드를 수정할 때만 버전이 증가한다.

@Version으로 추가한 버전 관리 필드는 JPA가 직접 관리하므로 개발자가 임의로 수정하면 안 된다. 버전 값을 강제로 증가하려면 특별한 락 옵션을 선택하면 된다.

JPA 락 사용

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

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

조회하면서 즉시 락을 걸 수도 있고, 필요할 때 락을 걸 수도 있다.

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

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

JPA 낙관적 락

JPA가 제공하는 낙관적 락은 버전(@Version)을 사용한다.
낙관적 락은 트랜잭션을 커밋하는 시점에 충돌을 알 수 있다는 특징이 있다.

낙관적 락에서 발생하는 예외

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

락 옵션

NONE

락 옵션을 적용하지 않아도 엔티티에 @Version이 적용된 필드만 있으면 낙관적 락이 적용된다.

  • 용도: 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경(삭제)되지 않아야 한다. 조회 시점부터 수정 시점까지를 보장한다.
  • 동작: 엔티티를 수정할 때 버전을 체크하면서 버전을 증가한다. 이때 데이터베이스의 버전 값이 현재 버전이 아니면 예외가 발생한다.
  • 이점: 두번의 갱신 분실 문제를 예방한다.

OPTIMISTIC

@Version만 적용했을 때는 엔티티를 수정해야 버전을 체크하지만 이 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크한다. 쉽게 이야기해서 한 번 조회한 엔티티는 트랜잭션을 종료할 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.

  • 용도: 조회한 엔티티는 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 변경되지 않아야 한다. 조회 시점부터 트랜잭션이 끝날 때까지 조회한 엔티티가 변경되지 않음을 보장한다.
  • 동작: 트랜잭션을 커밋할 때 버전 정보를 조회해서 현재 엔티티의 버전과 같은지 검증한다. 만약 같지 않으면 예외가 발생한다.
  • 이점: OPTIMISTIC 옵션은 DIRTY READ와 NON-REPEATABLE READ를 방지한다.

OPTIMISTIC_FORCE_INCREMENT

낙관적 락을 사용하면서 버전 정보를 강제로 증가한다.

  • 용도: 논리적인 단위의 엔티티 묶음을 관리할 수 있다.
  • 동작: 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가시킨다. 이때 데이터베이스의 버전이 엔티티의 버전과 다르면 예외가 발생한다.
    추가로 엔티티를 수정하면 수정 시 버전 UPDATE가 발생한다. 따라서 총 2번의 버전 증가가 나타날 수 있다.
  • 이점: 강제로 버전을 증가해서 논리적인 단위의 엔티티 묶음을 버전 관리할 수 있다.

JPA 비관적 락

  • 데이터베이스 트랜잭션 락 메커니즘에 의존하는 방법이다.
  • 주로 SQL 쿼리에 select for update 구문을 사용하면서 시작하고 버전 정보는 사용하지 않는다.
  • 주로 PESSIMITIC_WRITE 모드를 사용한다.
  • 엔티티가 아닌 스칼라 타입을 조회할 때도 사용할 수 있다.
  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.

비관적 락에서 발생하는 예외

  • javax.persistence.PessimisticLockException(JPA 예외)
  • org.springframework.dao.PessimisticLockingFailureException(스프링 예외 추상화)

PESSIMITIC_WRITE

비관적 락이라 하면 일반적으로 이 옵션을 뜻한다.

  • 용도: 데이터베이스에 쓰기 락을 건다.
  • 동작: 데이터베이스 select for update를 사용해서 락을 건다.
  • 이점: NON-REPEATABLE READ를 방지한다. 락이 걸린 로우는 다른 트랜잭션이 수정할 수 없다.

PESSIMITIC_READ

데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용한다.
일반적으로 잘 사용하지 않는다.

  • MySQL: lock in share mode
  • PostgreSQL: for share

PESSIMITIC_FORCE_INCREMENT

비관적 락 중 유일하게 버전 정보를 사용한다.
비관적 락이지만 버전 정보를 강제로 증가시킨다.
하이번이트는 nowait를 지원하는 데이터베이스에 대해서 for update nowait 옵션을 적용한다.

  • 오라클: for update nowait
  • PostgreSQL: for update nowait
  • nowait를 지원하지 않으면 for update가 사용된다.

비관적 락과 타임아웃

비관적 락을 사용하면 락을 획득할 때까지 트랜잭션이 대기한다. 무한정 기다리 수 없으므로 타임아웃 시간을 줄 수 있다.

2차 캐시

1차 캐시와 2차 캐시

1차 캐시

영속성 컨텍스트 내부에 있다. 엔티티 매니저로 조회하거나 변경하는 모든 엔티티는 1차 캐시에 저장된다. 트랜잭션을 커밋하거나 플러시를 호출하면 1차 캐시에 있는 엔티티의 변경 내역을 데이터베이스에 동기화한다.
JPA를 J2EE나 스프링 프레임워크 같은 컨테이너 윙서 실행하면 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션을 종료할 때 영속성 컨텍스트도 종료한다.
1차 캐시는 끄고 켤 수 있는 옵션이 아니다. 영속성 컨텍스트 자체가 사실상 1차 캐시다.

특징

  • 1차 캐시는 같은 엔티티가 있으면 해당 엔티티를 그대로 반환한다. 따라서 1차 캐시는 객체 동일성(a==b)을 보장한다.
  • 1차 캐시는 기본적으로 영속성 컨텍스트 범위의 캐시다.

2차 캐시

애플리케이션에서 공유하는 캐시를 JPA는 공유 캐시라 하는데 일반적으로 2차 캐시라 부른다.
2차 캐시는 애플리케이션 범위의 캐시다. 따라서 애플리케이션을 종료할 때까지 캐시가 유지된다. 분산 캐시나 클러스터링 환경의 캐시는 애플리케이션보다 더 오래 유지될 수도 있다.
2차 캐시를 적용하면 엔티티 매니저를 통해 데이터를 조회할 때 우선 2차 캐시에서 찾고 없으면 데이터베이스에서 찾는다. 2차 캐시를 적절히 활용하면 데이터베이스 조회 횟수를 획기적으로 줄일 수 있다.

특징

  • 2차 캐시는 영속성 유닛 범위의 캐시다.
  • 2차 캐시는 조회한 객체를 그대로 반환하는 것이 아니라 복사본을 만들어서 반환한다.
  • 2차 캐시는 데이터베이스 기본 키를 기준으로 캐시하지만 영속성 컨텍스트가 다르면 객체 동일성(a==b)을 보장하지 않는다.

JPA 2차 캐시 기능

JPA 캐시 표준은 여러 구현체가 공통으로 사용하는 부분만 표준화해서 세밀한 설정을 하려면 구현체에 의존적인 기능을 사용해야 한다.

JPA 캐시 표준 기능

캐시 모드 설정

2차 캐시를 사용하려면 엔티티에 javax.persistence.Cacheable 어노테이션을 사용하면 된다.
@Cacheable은 true, false를 설정할 수 있는데 기본값은 true이다.

캐시 조회, 저장 방식 설정

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

캐시 조회 모드나 보관 모드에 따라 사용할 프로퍼티와 옵션이 다르다.

  • javax.persistence.cache.retrieveMode: 캐시 조회 모드 프로퍼티 이름
  • javax.persistence.cache.storeMode: 캐시 보관 모드 프로퍼티 이름
  • javax.persistence.CacheRetrieveMode: 캐시 조회 모드 설정 옵션
  • javax.persistence.CacheStoreMode: 캐시 보관 모드 설정 옵션

JPA 캐시 관리 API

JPA는 캐시를 관리하기 위한 javax.persistence.Cache 인터페이스를 제공한다.
EntityManagerFactory에서 구할 수 있다.

하이버네이트와 EHCACHE 적용

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

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

@Cache

하이버네이트 전용인 org.hibernate.annotations.Cache 어노테이션을 사용하면 세밀한 캐시 설정이 가능하다.

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

0개의 댓글