트랜잭션과 락

트랜잭션과 격리 수준

트랜잭션은 ACID라 하는 원자성, 일관성, 격리성, 지속성을 보장한다.

속성 설명
원자성
(Atomicity)
트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 실패 또는 성공이여야 한다.
일관성
(Consistency)
모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다.
격리성
(Isolation)
동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리해야 한다.
지속성
(Durability)
트랜잭션을 성공적으로 끝내면 그 결과가 영구적으로 반영되어야 한다.

이 중 격리성은 동시성 성능과 밀접하게 관련되어 있다. 이런 문제로 인해 ANSI 표준은 트랜잭션의 격리 수준을 아래와 같이 4단계로 정의한다. 격리 수준이 낮을수록 더 많은 문제가 발생한다.

격리 수준 DIRTY READ NON-REPEATABLE READ PHANTOM READ
READ UNCOMMITED O O O
READ COMMITTED   O O
REPEATABLE READ     O
SERIALIZABLE      

DIRTY READ

다른 트랜잭션에 의해 수정됐지만 아직 커밋되지 않은 데이터를 읽는 현상. 변경 후 아직 커밋되지 않은 값을 읽었는데 변경을 가한 트랜잭션이 최종적으로 롤백된다면 그 값을 읽은 트랜잭션은 데이터 정합성 문제가 발생한다. 예를 들어 트랜잭션 1이 데이터를 수정하고 있는데 커밋하지 않아도 트랜잭션 2가 수정중인 데이터를 조회할 수 있다.

NON-REPEATABLE READ

한 트랜잭션 내에서 같은 쿼리를 두 번 수행했는데, 그 사이에 다른 트랜잭션이 값을 수정 또는 삭제하면 두 쿼리 결과가 다르게 나타나는 현상. 예를 들어 트랜잭션 1이 회원 A를 조회중인데 트랜잭션 2가 회원 A를 수정하고 커밋하면 트랜잭션 1이 다시 회원 A를 조회할 때 수정된 데이터가 조회된다.

PHANTOM READ

한 트랜잭션 내에서 같은 쿼리를 두 번 수행했는데, 첫 번째 쿼리에서 없던 PHANTOM(유령) 레코드가 두 번째 쿼리에서 나타나는 현상. 예를 들어 트랜잭션 1이 10살 이하의 회원을 조회했는데 트랜잭션 2가 5살 회원을 추가하고 커밋하면 트랜잭션 1이 다시 10살 이하의 회원을 조회했을 때 회원 하나가 추가된 상태로 조회된다.

SERIALIZABLE 격리 수준

가장 엄격한 트랜잭션 격리 수준으로 DIRTY READ, NON-REPEATABLE READ, PHANTOM READ 문제가 발생하지 않지만 동시성 성능이 급격하게 떨어질 수 있다.

트랜잭션 격리 수준에 따른 동작 방식은 DB마다 다르게 처리된다. 최근 DB는 동시성 처리를 위해 락보다는 MVCC를 사용한다.

낙관적 락과 비관적 락

낙관적 락

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

비관적 락

  • 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 거는 방법이다.
  • DB가 제공하는 락 기능을 사용한다.

두 번의 갱신 분실 문제

A와 B가 동시에 같은 게시물을 수정할 때, A가 먼저 수정완료를 누른 후 B가 수정을 한다면 A의 수정사항은 사라지고 B의 수정사항만 남는 현상을 말한다. 이 때 3가지 선택지가 있다.

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

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

JPA 락 사용

  • JPA 영속성 컨텍스트(1차 캐시)를 적절히 활용하면 READ COMMITTED 격리 수준이어도 애플리케이션 레벨에서는 REPEATABLE READ가 가능하다.
  • JPA는 DB 트랜잭션 격리 수준을 READ COMMITTED 정도로 가정한다. 만약 일부 로직에 더 높은 격리 수준이 필요하면 낙관적 락과 비관적 락 중 하나를 사용하면 된다.
  • JPA를 사용할 때 추천하는 전략은 READ COMMITTED 트랜잭션 격리 수준 + 낙관적 락 설정이다.

락을 적용될 수 있는 위치

  • EntityManager.lock(), EntityManager.find(), EntityManager.refresh()
  • Query.setLockMode() (TypeQuery 포함)
  • @NamedQuery
// 조회 시 Locking
Board board = em.find(Board.class, id, LockModeType.OPTIMISTIC);


// 조회 후 필요할 때 Locking
Board board = em.find(Board.class, id);
em.lock(board, LockModeType.OPTIMISTIC);

LockModeType 속성

락 모드 타입 설명
낙관적 락 OPTIMISTIC 낙관적 락 사용
낙관적 락 OPTIMISTIC_FORCE_INCREMENT 낙관적 락 + 버전정보를 강제 증가
비관적 락 PESSIMISTIC_READ 비관적 락, 읽기 락을 사용
비관적 락 PESSIMISTIC_WRTIE 비관적 락, 쓰기 락을 사용
비관적 락 PESSIMISTIC_FORCE_INCREMENT 비관적 락 + 버전정보를 강제로 증가
기타 NONE 락을 걸지 않음
기타 READ JPA 1.0 호환기능. OPTIMISTIC 와 같음.
기타 WRITE JPA 1.0 호환기능. OPTIMISTIC_FORCE_INCREMENT 와 같음.

JPA 낙관적 락

  • JPA가 제공하는 낙관적 락을 사용하려면 @Version 애너테이션을 사용하여 버전관리 기능을 추가해야 한다.
  • @Version을 적용할 수 있는 타입에는 Long(long), Integer(int), Short(short), Timestamp가 있다.
@Entity
public class Board {
    @Id
    private String id;
    private String title;

    @Version
    private Integer version;
}

엔티티에 버전 관리용 필드를 하나 추가하고 @Version 애너테이션을 붙이면 엔티티를 수정할 때마다 버전이 하나씩 자동으로 증가한다. 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다.

버전 정보 비교 방법

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

UPDATE BOARD
SET
TITLE = ?
, VERSION = ? (버전 + 1)
WHERE ID = ?
AND VERSION = ? (버전 비교)
  • DB 버전과 엔티티 버전이 같으면 데이터를 수정하면서 동시에 버전도 하나 증가시킨다. DB 버전의 값과 다르다면 예외가 발생한다.
  • @Version으로 추가한 버전 관리 필드는 JPA가 직접 관리하므로 개발자가 임의로 수정하면 안 된다(벌크 연산 제외). 만약 버전 값을 강제로 증가시키려면 특별한 락 옵션을 선택해야 한다.
  • 벌크 연산은 버전을 무시한다. 벌크 연산에서 버전을 증가시키려면 버전 필드를 강제로 증가시키면 된다.

LockModeType.NONE

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

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

LockModeType.OPTIMISTIC

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

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

LockModeType.OPTIMISTIC_FORCE_INCREMENT

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

  • 용도 : 논리적인 단위의 엔티티 묶음을 관리할 수 있다. 예를 들어 게시물과 첨부파일이 일대다, 다대일의 양방향 관계이고 첨부파일이 연관관계의 주인이다. 게시물을 수정하는 데 단순히 첨부파일만 추가하면 게시물의 버전은 증가하지 않는다. 이 때 게시물의 버전도 강제로 증가시킬 때 사용한다.
  • 동작 : 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가시킨다. 추가로 엔티티를 수정하면 수정 시 버전 UPDATE가 발생한다. 따라서 총 2번의 버전 증가가 일어날 수 있다.
  • 이점 : 강제로 버전을 증가해서 논리적인 단위의 엔티티 묶음을 버전 관리할 수 있다.

JPA 비관적 락

  • JPA가 제공하는 비관적 락은 DB 트랜잭션 락 메커니즘에 의존하는 방법이다.
  • 주로 SQL 쿼리에 select for update 구문을 사용하면서 시작하고 버전 정보는 사용하지 않는다.
  • 엔티티가 아닌 스칼라 타입을 조회할 때도 사용할 수 있다.
  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.
public interface ItemRepository extends JpaRepository<Item, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select i from Item i where i.id = :id")
    Item findByIdForUpdate(Long id);
}

LockModeType.PESSIMISTIC_WRITE

비관적 락은 일반적으로 PESSIMISTIC_WRITE 옵션을 사용한다.

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

LockModeType.PESSIMISTIC_READ

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

LockModeType.PESSIMISTIC_FORCE_INCREMENT

비관적 락 중 유일하게 버전 정보를 사용한다. 하이버네이트는 nowait를 지원하는 DB에 대해서 for update nowait 옵션을 적용하고, nowait를 지원하지 않으면 for update를 사용한다.

비관적 락과 타임아웃

비관적 락을 사용하면 락을 획득할 때까지 트랜잭션이 대기한다. 무한정 기다릴 수는 없으므로 타임아웃 시간 설정이 가능하다. 타임아웃은 DB 특성에 따라 동작하지 않을 수도 있다.

@Transactional의 격리 수준은 해당 트랜잭션이 다른 트랜잭션에서 변경한 데이터를 볼 수 있는 기준을 정의한다. JPA Repository Method의 @Lock은 다른 트랜잭션에서 해당 데이터에 접근하는 것을 막는 기능을 수행한다.

Categories:

Updated:

Comments