본문 바로가기
프로그래밍/JPA, Database

[JPA] 비관적 락과 낙관적 락, 트랜잭션의 격리 수준

by 카프카뮈 2021. 10. 15.

얼마 전 면접에서 질문을 받았지만 전혀 몰랐던 비관적 락과 낙관적 락 개념.

다행히 김영한님의 자바 ORM 표준 JPA 프로그래밍에서 관련 내용을 소개하고 있어, 참고하여 정리해 보았다.

 

이 글은 다음 내용을 다룬다.

  • 트랜잭션의 격리에 대해 알아본다.
  • 낙관적 락과 비관적 락에 대해 알아본다.

트랜잭션과 격리 수준

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

그 중 격리성의 경우, 동시에 실행되는 트랜잭션들이 서로에게 영향을 끼치지 못하게 격리한다 라는 의미를 가진다.

사실 이를 제일 완벽히 구현하는 방법은 트랜잭션이 여러 개일때 순차적으로 실행하는 것이지만, 이는 성능에 너무 큰 영향을 준다는 점. 그래서 트랜잭션의 격리는 4단계로 나눠서 수행하게 된다.

 

이에 대해서는 이전에 작성한 글을 링크한다.

 

@Transactional 어노테이션의 다양한 옵션 활용

본 글은 아래의 글에서 이어지는 글이다. https://kafcamus.tistory.com/30 @Transactional 어노테이션의 이해 나는 보통 서비스 코드에 @Transactional 어노테이션을 활용해준다. 그런데 사실 뜻도 잘 모르고 좋..

kafcamus.tistory.com

위 글의 Transaction Isolation 포인트를 간단히 정리해보자.

아래 세 가지 문제에 어떻게 대응하느냐가 우선이다.

  • Dirty read: 변경사항이 반영되지 않은 값을 다른 트랜잭션에서 읽는 경우
  • Nonrepeatable read: 한 트랜잭션 내에서 값을 조회할 때, 동시성 문제로 인하여 같은 쿼리가 다른 결과를 반환하는 경우. 즉 트랜잭션이 끝나기 전에 수정사항이 반영되어, 트랜잭션 내에서 쿼리 결과가 일관성을 가지지 못하는 경우
  • phantom read: 외부에서 수행되는 삽입/삭제 작업으로 인해 트랜잭션 내에서의 동일한 쿼리가 다른 값을 반환하는 경우

그리고 이를 해결하기 위한 4개의 트랜잭션 고립 단계가 존재한다.

  • READ UNCOMMITTED: 커밋하지 않은 데이터(= 다른 트랜잭션이 수정중인 데이터)를 읽을 수 있다. 이 경우 Dirty read가 발생할 수밖에 없다.
  • READ COMMITTED: 커밋한 데이터(= 다른 트랜잭션이 수정을 끝낸 데이터, 혹은 수정 중이지 않은 데이터)만 읽을 수 있다. Dirty read는 방지할 수 있지만, Nonrepeatable read는 방지할 수 없다.
  • REPEATABLE READ: 한번 조회한 데이터를 반복해서 조회해도 같은 데이터가 나온다. 다만 Phantom read는 발생하게 된다.
  • SERIALIZABLE: 가장 엄격한 수준으로, Phantom read 역시 발생하지 않는다. 그러나 동시성 처리 성능이 급격히 떨어진다.

애플리케이션 대부분은 동시성 처리가 중요하므로, DB들은 보통 READ COMMITTED 격리 수준을 DEFAULT로 사용한다. (MySQL만 REPEATABLE READ를 기본으로 사용한다) 

 

다만 그럼에도, 일부 중요한 비즈니스 로직에 더 높은 격리 수준이 필요할 수 있다. 

이 때, 데이터베이스 트랜잭션이 제공하는 잠금(락) 기능을 사용하면 된다.

  • 다만 최근에는 성능상의 개선과 더 많은 동시성 처리를 위해 MVCC를 사용하는 데이터베이스들이 많다고 한다.
    이에 대해서는 추후의 포스트 예정이다.

낙관적 락과 비관적 락

JPA는 데이터베이스 트랜잭션의 격리 수준이 READ COMMITTED 정도라고 가정한다.

  • JPA의 영속성 컨텍스트를 적절히 활용(1차 캐시)하면 READ COMMITTED를 REPEATABLE READ 수준까지 올릴 수 있기 때문이다.

이때, JPA가 생각하는 수준보다 더 높은 고립 수준이 필요할 수 있다. 

이 경우 사용할 수 있는 옵션이 낙관적 락과 비관적 락이다.

낙관적 락

낙관적 락은 이름 그대로 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다.

이 경우 데이터베이스가 제공하는 락 기능 대신 JPA가 제공하는 버전 관리 기능을 사용한다.

쉽게 말하자면, 어플리케이션이 제공하는 락이라고 할 수 있다.

  • 낙관적 락은 트랜잭션 커밋 전까지는 트랜잭션의 충돌을 알 수 없다는 특징이 있다.

비관적 락

비관적 락은 이름 그대로 트랜잭션간 충돌이 발생한다고 가정하여 우선 락을 거는 방법이다.

데이터베이스가 제공하는 락 기능을 사용하는데, 대표적으로 select for update 구문이 있다.

 

이제 낙관적 락부터 사용 방법을 알아보자.

그와 함께, 두 번의 갱신 분실 문제 Second lost updated problem에 대해서도 알아보려고 한다.


두번의 갱신 분실 문제

만약 사용자 A와 사용자 B가 동일한 게시물을 수정한다고 해보자.

두 명이 동시에 수정 화면을 열어 내용을 수정중인데, A가 먼저 수정을 마쳤다고 해보자.

해당 게시물의 내용은 A가 수정한 내용을 반영하지만, 이 때 B가 수정을 마치면 B의 내용으로 덮어씌워진다.

이를 두 번의 갱신 분실 문제 라고 한다. 왜냐하면, A가 수정한 내용이 분실되기 때문이다.

 

우리가 서비스를 이용할때 위와 같은 상황을 겪으면 어떨까?

물론 위와 같은 상황이 올바르다고 생각할 수도 있다. 하지만 사용자 A의 입장에서는, 이를 오류로 받아들이지 정상적인 상황으로 받아들이기는 어려울 것이다.

어쩌면, B가 작성을 완료할 때 A의 수정이 적용된 것을 확인한 서버가 예외를 발생시켜서, "최근 수정이 이루어져 해당 동작을 처리할 수 없습니다" 라는 알림을 주는 것이 더 올바른 방법일수도 있다.

 

일단 두 번의 갱신 분실 문제를 해결하는 방법은 다음 세 가지가 있다.

  • 마지막 커밋만 인정하기: B의 내용만 인정하고 A의 내용은 무시한다.
  • 최초 커밋만 인정하기: A의 내용을 인정하고, B가 수정 시 오류를 발생시킨다.
  • 충돌하는 내용 병합하기: A와 B의 내용을 병합시킨다.

세 번째 솔루션은 특수한 상황이 아니라면 선택하기 어려울 것이고,

아무래도 우리는 두 번째 솔루션에 눈길이 간다.

 

JPA는 이를 위해, 버전 관리 기능을 제공한다.

@Version

@Version 어노테이션은 JPA에서 버전 관리를 위해 제공하는 어노테이션이다.

해당 어노테이션은 Long, Integer, Short, Timestamp 필드에 적용할 수 있다.

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

위와 같이 코드를 작성하면, 엔티티를 수정할 때마다 해당 version 필드가 하나씩 자동으로 증가한다.

그리고 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외를 발생시킨다.

예를 들어 앞서의 A와 B의 수정 상황이라면, B가 수정을 할 때 버전이 다르므로(A가 수정하면서 버전이 하나 증가) 예외가 발생하는 것이다.

따라서 @Version 어노테이션을 사용하면 앞서 언급한 "최초 커밋만 인정하기"가 가능해진다!

 

그렇다면 이 동작은 어떻게 수행될까?

JPA가 버전 정보를 비교하는 방법은 단순하다. 엔티티를 수정하고 트랜잭션을 커밋할 때, 영속성 컨텍스트를 플러시하면서 UPDATE 쿼리를 실행한다. 아래처럼 말이다.

UPDATE BOARD
SET
    TITLE=?,
    VERSION=? (버전 + 1 증가)
WHERE
    ID=?
    AND VERSION=? (버전 비교)

데이터베이스의 버전과 엔티티의 버전이 같으면 데이터를 수정하면서 동시에 버전도 하나 증가시킨다.

그러나 이미 데이터베이스에 수정이 이루어져 두 버전이 다르면, WHERE 문에서 조건이 달라지므로 수정할 대상이 없어진다. 이 경우 JPA가 예외를 발생시킨다.

 

추가로, 아래의 주의사항을 참고하자.

  • 버전은 JPA가 관리하므로 특수한 상황 외에는 임의 수정하면 안된다.
    • 벌크 연산 시 버전을 무시한다. 이 경우에 한해 버전을 강제로 증가시켜야 한다.
  • 버전은 엔티티의 값을 변경하면 증가한다.
    • 값 타입인 임베디드 타입과 값 타입 컬랙션 역시 수정하면 버전이 증가한다.
    • 연관관계 필드의 경우, 주인 필드를 수정할 때에만 버전이 증가한다.

JPA 락 사용

.JPA가 제공하는 락을 어떻게 사용하는지 알아보자.

  • JPA를 사용할 때 추천하는 전략은 READ COMMITTED 트랜잭션 격리 수준 + 낙관적 버전 관리이다.

락은 아래의 위치에 적용할 수 있다.

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

JPA가 제공하는 락 옵션은 javax.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 낙관적 락

JPA가 제공하는 낙관적 락은 버전(@Version) 어노테이션을 사용한다.

따라서 낙관적 락을 사용하려면 버전이 있어야 한다.

  • 낙관적 락은 트랜잭션을 커밋하는 시점에 충돌을 알 수 있다는 특징이 있다.

낙관적 락에서 발생하는 예외는 아래와 같다.

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

참고로 락 옵션 없이 @Version만 설정해도 낙관적 락이 적용된다. 락 옵션을 사용하는 것으로 락을 더 세밀하게 제어할 수 있다고 생각하면 된다.

NONE

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

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

OPTIMISTIC

이 옵션을 적용하면 엔티티를 조회만 해도 버전을 체크한다.

즉, 한 번 조회한 엔티티는 해당 트랜잭션을 종료할 때까지 다른 트랜잭션에 의해 변경되지 않는다.(락이 걸린다!)

  • 용도: 조회한 엔티티는 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 변경되지 않아야 한다.
  • 동작: 트랜잭션을 커밋할 때 버전 정보를 조회해서(SELECT 쿼리 사용) 현재 엔티티와 버전이 같은지 검증한다. 만약 다르다면 예외를 발생시킨다.
  • 이점: 두 번의 갱신 분실 문제뿐 아니라, Dirty read와 Non-repeatable read를 방지한다.

OPTIMISTIC_FORCE_INCREMENT

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

  • 용도: 논리적인 단위의 엔티티 묶음을 관리할 수 있다.
    • 예를 들어, 양방향 연관관계에서 주인인 엔티티만 변경되는 상황을 생각해보자. 이 경우 주인이 아닌 엔티티는 물리적으로 변경되지 않았지만, 논리적으로는 변경되었으므로 버전을 증가시킬 필요가 생긴다. 이 떄 버전을 증가시키는 것이 OPTIMISTIC_FORCE_INCREMENT이다.
  • 동작: 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해 버전 정보를 강제로 증가시킨다. 이때 데이터베이스의 버전과 맞지 않으면 예외를 발생시킨다.
    • 만약 엔티티를 수정했다면, 두 번의 버전 증가가 발생할 수 있다.
  • 이점: 강제로 버전을 증가시켜 논리적인 단위의 엔티티 묶음을 버전 관리할 수 있다.

JPA 비관적 락

JPA가 제공하는 비관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존하는 방법이다.

주로 SQL 쿼리에 select for update 구문을 사용하면서 시작하고, 버전 정보는 사용하지 않는다.

  • 비관적 락은 주로 PESSIMISTIC_WRITE 모드를 사용한다.

비관적 락은 다음의 특징이 있다.

  • 엔티티가 아닌 스칼라 타입을 조회할 때에도 사용할 수 있다.
  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.

비관적 락에서 발생하는 예외는 다음과 같다.

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

PESSIMISTIC_WRITE

비관적 락의 일반적인 옵션이다. 데이터베이스에 쓰기 락을 걸 때 사용한다.

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

참고: 일부 데이터베이스 시스템은 MVCC를 사용하여 차단된 데이터를 조회할 수 있도록 지원한다.

PESSIMISTIC_READ

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

일반적으로 잘 사용하지 않는다. 데이터베이스 대부분은 방언에 의해 PESSIMISTIC_WRITE로 동작하기 때문이다.

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

PESSIMISTIC_FORCE_INCREMENT

비관적 락 중 유일하게 버전 정보를 사용한다.

비관적 락이지만 버전 정보를 강제로 증가시킨다. 

하이버네이트는 nowait를 지원하는 데이터베이스에 대해서 for update nowait 옵션을 적용한다.

  • Oracle: for update nowait
  • PostgreSQL: for update nowait
  • nowait를 지원하지 않는 경우: for update가 사용된다.

마치며

생각보다 글이 길어졌다.

잘 모르던 내용이라 책을 참고하며 최대한 옮겨적어보려고 노력했다.

출처는 아래와 같다.

 

잘못된 내용이 있다면 댓글 부탁드립니다. 감사합니다.

반응형

댓글