[JPA] 비관적 락과 낙관적 락

HyeBin, Park·2022년 6월 18일
1

JPA - 비관적 락과 낙관적 락

📃 Overview

  • JPA의 영속성 컨텍스트를 적절히 활용하면 DB 트랜잭션이 READ COMMITTED 격리 수준이어도 애플리케이션 레벨에서 REPETABLE READ가 가능하다.
  • JPA는 데이터베이스 트랜잭션 격리 수준을 READ COMMITTED 정도로 가정한다.
  • 일부 로직에 더 높은 격리 수준이 필요하다면 ? => 낙관적 락과 비관적 락을 사용하자

👊 1. 비관적 락

  • 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법
  • 데이터베이스가 제공하는 락 기능을 사용한다.
  • 주로 SQL 쿼리에 SELECT FOR UPDATE 구문을 사용하면서 시작하고 버전 정보는 사용하지 않는다.
  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.

✌ 2. 낙관적 락

2.1 낙관적 락 이란?

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

2.2 두 번의 갱신 분실 문제

  • 데이터베이스 트랜잭션 범위를 넘어서는 문제
  • 사용자 A와 B가 동시에 제목이 같은 내용을 수정할 때, 둘이 동시에 수정 화면을 열어서 내용을 수정하는 중에 사용자 A가 먼저 수정완료 버튼을 누르고 잠시 후에 사용자 B가 수정 완료 버튼을 눌렀다.
  • 이때, 사용자 A의 수정 사항은 사라지고 나중에 완료한 사용자 B의 수정사항만 남게 되는 현상을 두 번의 갱신 분실 문제라고 한다.
  • 트랜잭션만으로는 문제를 해결할 수 없다.

2.3 해결 방법

  • 마지막 커밋만 인정하기 : 사용자 A의 내용은 무시하고 마지막에 커밋한 사용자 B의 내용만 인정한다.
    • 기본으로 사용된다.
  • 최초 커밋만 인정하기 : 사용자 A가 이미 수정을 완료했으므로 사용자 B가 수정을 완료할 때 오류가 발생한다.
    • 상황에 따라 더 합리적일 수 있다. JPA의 버전 관리 기능을 사용하면 손쉽게 구현할 수 있다.
  • 충돌하는 갱신 내용 병합하기 : 사용자 A와 사용자 B의 수정사항을 병합한다.

💎 3. JPA의 버전 관리 기능

3.1 @Version

@Entity
public class Board {
	@Id
    private String id;
    private String title;
    
    @Version
    private Integer version;
}
  • 적용 가능 타입
    • Long (long)
    • Integer (int)
    • Short (short)
    • Timestamp
  • 엔티티에 버전 관리용 필드를 하나 추가하고 @Version을 붙이면 된다.
  • 엔티티를 수정할 때 마다 버전이 하나씩 자동으로 증가한다.
  • 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다.

3.2 발생하는 예외

  • OptimisticLockException (JPA 예외)
  • StaleObjectStateException (하이버네이트 예외)
  • ObjectOptimisticLockingFailureException (스프링 예외 추상화)

3.3 최초 커밋만 인정하기

//트랜잭션 1 조회 title = "제목a",  version = 1
Board board = em.find(Board.class, id);
//트랜잭션 2에서 해당 게시물을 수정해서 title="제목c", version=2로 증가
board.setTitle("제목B");

em.save(board);
tx.commit(); // 예외 발생, 데이터베이스의 version = 2, 엔티티의 version = 1;
  • 버전 정보를 사용하면 최초 커밋만 인정하기가 적용된다.

3.4 JPA가 버전 정보 비교 방법

UPDATE BOARD
SET
	TITLE=?,
    VERSION=? (version 1 증가)
WHERE 
	ID=?
    AND VERSION=? (버전 비교, 즉 현재의 버전과 같은 버전이 있는지) 
  • 엔티티를 수정하고 트랜잭션을 커밋하면 영속성 컨텍스트를 플러시 하면서 update 쿼리를 실행한다.
  • 버전을 사용하는 엔티티면 검색 조건에 엔티티의 버전 정보를 추가함과 동시에 버전을 1 증가시킨다.
    => 버전은 엔티티의 값을 변경하면 증가한다.
  • 연관관계 필드는 외래 키를 관리하는 연관관계의 주인 필드를 수정할 때만 버전이 증가한다.
  • 버전 관리 필드는 JPA가 직접 관리하므로 개발자가 임의로 수정하면 안 된다.
    => 벌크 연산의 경우 버전을 무시하기 떄문에 강제로 증가 시켜야한다.

🐾 4. JPA 락 사용

4.1 락을 적용할 수 있는 위치

  • EntityManager.lock(), EntityManager.find(), EntityManager.refresh()
// 즉시 락 걸기
Board board = em.find(Board.class, id, LockModeType.OPTIMISTIC);

// 필요할 때 걸기
Board board = em.find(Board.class, id);
...
em.lock(board, LockModeType.OPTIMISTIC);
  • Query.setLockMode()
Query query = em.createQuery("SELECT b FROM Borad b WHERE id = :id");
query.setParameter("id", boardId);
query.setLockMode(LockModeType.OPTIMISTIC);
query.getResultList()
  • @NamedQuery
@NamedQuery(name="lockBoard",
  query="SELECT b FROM Borad b WHERE b.id = :boardId",
  lockMode = OPTIMISTIC)
  • @Lock(LockModeType.PESSIMISTIC_WRITE) JPA Repository
@Lock(LockModeType.PESSIMISTIC_WRITE)
findBy~ 

4.2 JPA가 제공하는 락 옵션

  • javax.persistence.LockModeType

    모드설명설명
    낙관적 락OPTIMISTIC낙관적 락을 사용한다.
    OPTIMISTIC_FORCE_INCREMENT낙관적 락 + 버전정보를 강제로 증가 시킨다.
    비관적 락PESSIMISTIC_READ읽기 잠금을 사용한다.
    PESSIMISTIC_WRITE쓰기 잠금을 사용한다.
    PESSIMISTIC_FORCE_INCREMENT비관적 락 + 버전정보를 강제로 증가시킨다.
    기타NONE
    READOPTIMISTIC과 같다.
    WRITEOPTIMISTIC_FORCE_INCREMENT 와 같다.
  • 다크 모드 사용자를 위한 표

4.2.1 NONE

  • 락 옵션을 적용하지 않아도 엔티티에 @Version이 적용된 필드만 있으면 낙관적 락이 적용된다.
  • 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경(삭제) 되지 않아야 한다.
  • 조회 시점부터 수정 시점까지를 보장한다.
  • 엔티티를 수정할 때 버전을 체크하면서 버전을 증가하고 db의 버전 값이 현재 버전이 아니면 예외가 발생한다.
  • 두 번의 갱신 분실 문제를 예방한다.

✌ 4.3 낙관적 락

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

4.3.1 OPTIMISTIC (Read)

//트랜잭션 1 조회 title = "제목A", version = 1
Board board = em.find(Board.class, id, LockModeType.OPTIMISTIC);
// 중간에 트랜잭션 2에서 해당 게시물을 수정해서 title="제목 C", version=2로 증가
//트랜잭션 1 커밋 시점에 버전 정보 검증, 예외 발생
// db version = 2, entity version - 1
tx.commit():
  • 엔티티를 조회만 해도 버전을 체크한다.
  • 한 번 조회한 엔티티는 트랜잭션을 종료할 때까지 다른 트랜잭션에서 변경하지 않음을 보장한다.
  • 트랜잭션을 커밋할 때 버전 정보를 조회해서(select 쿼리 사용) 현재 엔티티의 버전과 같은지 검증하고 같지 않으면 예외 발생
  • DIRTY READNON-REPEATABLE READ를 방지한다.

4.3.2 OPTIMISTIC_FORCE_INCREMENT (Write)

//트랜잭션 1 조회 title="제목a", version=1
Board board = em.find(Board.class, id, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
//트랜잭션 1 커밋 시점에 버전 강제 증가
tx.commit();
  • 낙관적 락을 사용하면서 버전 정보를 강제로 증가한다.
  • 논리적인 단위의 엔티티 묶음을 관리할 수 있다.
  • 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가시킨다.
  • 엔티티를 수정하면 수정 시 버전 UPDATE가 발생한다. 즉, 총 2번의 버전 증가가 나타날 수 있다.
  • 강제로 버전을 증가해서 논리적인 단위의 엔티티 묶음을 버전 관리할 수 있다.

<OPTIMISTIC_FORCE_INCREMENT 사용하지 않을 때 예시>

@Test
void test() {
	Post post = new Post();
    post.setTitle("제목");
    PostImage postImage = new PostImage();
    postImage.addPost(post);
    postRepository.save(Post);
    
    // Post에 새로운 PostImage를 추가하는 method 
    postService.addPostImage();
    
    Post foundPost = postRepository.findById(post.getId());
}
  1. Post와 PostImage가 일대다 양방향 연관관계이고 PostImage가 연관관계의 주인일 때
  2. 단순히 Post에 PostImage만 추가하면 Post의 버전은 증가하지 않는다.
  3. 해당 Post는 물리적으로는 변경되지 않았지만, 논리적으로는 변경되었다.
    (3-1) Post 엔티티 PostImage 필드에 새로운 PostImage를 추가
    (3-2) 연관관계의 주인이 FK 를 관리하고 있어서 Post에는 PostImage가 추가된다.
    (3-3) Post Table에는 물리적인 변화가 없어서 업데이터 쿼리가 나가지 않는다.
    (3-4) version 이 증가하지 않는다.
  4. 이 게시물의 버전도 강제로 증가하고 싶을 때 사용한다.

🧸 4.4 비관적 락

  • 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법
  • 데이터베이스가 제공하는 락 기능을 사용한다.
  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.

4.4.1 발생하는 예외

  • PessimisticLockException (JPA 예외)
  • PessimisticLockingFailureException (스프링 예외 추상화)

4.4.2 PESSIMISTIC_WRITE

  • 일반적으로 사용되는 옵션으로 데이터 베이스에 쓰기 락을 걸 때 사용한다.
  • 데이터베이스 SELECT FOR UPDATE를 사용해서 락을 건다.
  • 락이 걸린 로우는 다른 트랜잭션이 수행할 수 없다.
  • NON-REPEATABLE READ를 방지한다.

4.4.3 PESSIMISTIC_READ

  • 데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용한다.
  • 일반적으로 잘 사용하지 않는다.
  • 데이터베이스 대부분은 방언에 의해 PESSIMISTIC_WRITE로 동작한다.

4.4.4 PESSIMISTIC_FORCE_INCREMENT

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

4.5 비관적 락과 타임아웃

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

🤔 5. 어떨 때 사용해야하는데 ?

  • 비관적 락은?
    • 동시성이 떨어져 성능 저하가 있고, 읽기가 많이 이루어지는 데이터베이스에는 좋지 않다.
    • 서로 자원이 필요한 경우에, 로우 자체에 락이 걸려있어 데드락이 일어날 가능성이 있다.
    • 데이터의 무결성이 중요하고, 충돌이 많이 발생하여 잦은 롤백이 있는 프로젝트에 사용하는 것이 좋다.
  • 낙관적 락은?
    • 트랜잭션 충돌이 많아 복구 작업을 많이 해야 하는 로직이라면 좋지 않다.
    • 데이터 충돌이 자주 일어나지 않을 것이라고 예상되는 시나리오에서 좋다.

재고가 1개인 상품에 동시적으로 주문을 요청하는 경우?

  • 비관적 락 : 1명의 사용자 말고는 대기를 하다가 트랜잭션 충돌 여부를 파악하게 된다.
    => 재고가 없음을 미리 알 수 있어 롤백처리가 필요없다.

  • 낙관적 락 : 동시 요청을 순차적으로 처리 한다.
    => commit을 해야 재고가 없는 걸 알 수 있고 처리한 만큼 롤백을 해줘야한다.

우리는?

  • 저희 프로젝트에서는 동시 수정이 가능한 기능이 잘 떠오르지 않습니다. 그래서 낙관적 락을 사용해도 될 것 같다고 생각합니다.

실제 락을 실행한 예제 코드 사이트

  • 모든 락에 대한 예시 코드가 잘 작성 되어 있습니다.
  • 정리 tistory

프로젝트에 LOCK 적용하기 (비관적 락)

우리 프로젝트에는 락을 적용할만한 부분이 없습니다. 하지만 락을 경험해보기 위해 일단 적용해봅니다 ! Post Update 부분에 적용을 할 것입니다
테스트 방법은 postman을 사용하여 Lock을 사용한 update api 실행 후 기존의 update메서드를 실행합니다.(테스트 진행 코드에는 postService에 Thread.sleep 을 시켰습니다.)

PostService의 update 메서드 입니다.

  • 기존의 update메서드와 비교하면 repository의 메서드만 다릅니다.

PESSIMISTIC_WRITE

1 PostRepository 에 PESSIMISTIC_WRITE 설정

  • 비관적 락 중 PESSIMISTIC_WRITE 옵션을 줬습니다.

2. Lock이 설정된 update API 실행 후

  • lock이 걸린 update API를 실행합니다.
  • 테스트시 postService 코드에는 Thread.sleep(60 * 1000); 이 들어가 있습니다.

2.2 기존의 update API

  • 이후 Lock 이 걸려있지 않은 update API 를 실행합니다.
  • 실행하면 Request요청이 Thread.sleep이 끝날때까지 가게됩니다.

3. Thread.sleep 이 끝나면?

  • PessimisticLockingFailureException 이 터지게 됩니다.
  • 해당 예외는 Spring에서 터지는 트랜잭션 충돌 예외입니다.
  • LOCK이 잘 걸려있음을 알 수 있습니다.

PESSIMISTIC_READ

1. PostRepository 에 PESSIMISTIC_READ 설정

  • 이번에는 PESSIMISTIC_READ 설정을 줬습니다.

2. LOCK이 걸린 UPDATE API 실행

  • select for share 쿼리가 나갑니다.

3. 기존의 UPDATE API 실행

  • select 쿼리가 나가게됩니다.

4. Thread.sleep 이 끝나면

  • 아까와 같은 에러가 나게됩니다.

SELECT FOR UPDATE 가 뭔데 ?

  • 쓰기 잠금 (내가 update하기 전에 거는 잠금)을 설정하여 다른 트랜잭션에서는 그 레코드를 변경하는 것뿐만 아니라 읽기도 수행할 수 없습니다.

SELECT FOR SHARE 는 뭔데 ?

  • SELECT 된 레코드에 대해 읽기 잠금 (내가 select 하기전에 거는 잠금)을 설정하고 다른 세션에서 해당 레코드를 변경하지 못하게 합니다.
  • 다른 세션에서 잠금이 걸린 레코드를 읽는 것은 가능합니다.

근데 왜 SELECT FOR UPDATE에서 기존의 update API를 실행하면 select 쿼리가 나가?

  • FOR UPDATE나 FOR SHARE절을 가지지 않는 SELECT쿼리는 INNO DB 스토리 엔진을 사용하는 테이블에서는 잠금없는 읽기가 지원되기 됩니다.
  • 이런 이유 때문에 SELECT FOR UPDATE 쿼리로 잠겨진 상태라 해도 단순 SELECT쿼리는 아무런 대기 없이 실행됩니다.
  • 현재 MYSQL 5.5 버전부터 INNODB가 default로 사용됩니다.

0개의 댓글