본문 바로가기

3.구현/Java or Kotlin

[kotlin] JPA 11: Spring Boot에서 트랜잭션과 락

들어가기

트랜잭션은 단순해보이면서도 복잡하다. 이는 단지 한개 처리만 다루는게 아니라 여러 처리가 동시에 발생할 경우를 다루기 때문이다. 데이터베이스에서도 이를 해결하기 위한 기능을 제공하고 있지만 JPA에서 트랜잰션을 다룰 다양한 기능을 제공하고 있다. 하나씩 살펴보자.

작성자: http://ospace.tistory.com/ (ospace114@empal.com)

트랜잭션

트랜잭션은 ACID를 보장하기 위한 기술이다. ACID는 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)의 약자이다. 만약, 여러 트랜잭션이 실행될 경우 다른 트랜잭션에서 대한 액세스 권한을 제어 해야한다. 이를 위해 ANSI에서는 트랜잭션 격리 수준 4단계로 나누어서 제어한다.

  • READ UNCOMMITED: 커밋되지 않은 데이터도 조회 가능한 가장 낮은 격리 수준이다. 다른 트랜잭션의 커밋 안된 데이터를 조회할 수 있는 DIRTY READ를 허용한다.
  • READ COMMITTED: 커밋된 데이터만 조회 할 수 있다. 즉, DIRTY READ을 허용하지 않는다. 조회 중인 데이터를 다른 트랜잭션에 의해서 커밋 된 경우 다시 조회에 대한 NON-REPEATABLE READ를 허용한다. 중간에 커밋에 의해 변경된 데이터 조회가 가능한 격리 수준이다.
  • REPEATABLE READ: 같은 데이터로 반복 조회 가능하다. DIRTY READ와 NON-REPEATABLE READ을 허용하지 않는다. 즉, 중간에 변경되어도 처음 조회한 데이터로 계속 가져오게 된다. 그러나, 반복 조회에서 데이터 집합이 변경된 경우인 PHANTOM READ은 허용한다.
  • SERIALIZABLE: 직렬화로 가장 엄격한 격리수준이다. PHANTOM READ까지 포함해서 모두 허용하지 않는다. 가장 성능이 떨어지는 격리수준이다.

이슈 중에 DIRTY READ는 다른 트랜잭션의 수정 중인 데이터 조회할 경우 발생한다. 이는 롤백시 심각한 문제가 될 수 있다. 다른 이슈로 NON-REPEATABLE READ은 다른 트랜잰션에서 조회 중인 데이터를 수정해서 커밋하면 수정된 데이터로 조회 된다. 즉, 처음 조회된 데이터가 반복해서 조회할 경우 같은 데이터가 조회 되지 않는다. 마지막으로 PHANTOM READ은 다른 트랜잭션에서 목록 조회하고 데이터를 추가하거나 삭제해서 커밋한 경우이다. 처음 조회된 목록이 커밋후 변경된 목록으로 결과 목록이 달라지는 경우이다. 기본 격리 수준은 READ COMMITTED로 어느정도 동시성을 허용한다.

락(Lock)

트랙잭션에 의해서 어느 정도 격리 수준을 제공하지만 일부 로직에서 더 높은 격리 수준이 필요하다면 락을 사용하면 된다. 사용할 수 있는 락 종류로는 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)가 있다.

낙관적 락은 트랜잭션에서 충돌이 없을 거라고 보고 낙관적으로 락을 사용하는 방식이고, 비관적 락은 트랜잭션에서 충돌은 반드시 있다고 가정해서 비관적으로 락을 사용하는 방식이다. 전자는 애플리케이션에서 락을 관리한다면 후자는 데이터베이스에서 락을 관리한다. 그렇기에 낙관적 락은 버전관리에 의해서 처리할 수 있고 비관적 락은 데이터베이스의 제공하는 기능을 사용한다.

추가로 갱신 분실 문제(second lost updates problem)도 살펴보자. 이 경우는 모든 처리가 이상없이 완료되지만 같은 데이터에 대해서 여러 곳에서 동시에 수정하는 경우에 발생하는 문제이다. 한 곳이 먼저 락을 걸어 수정한 후에 다른 곳이 락을 걸어 수정한다면 마지막에 수정된 내용이 반영된다. 결국, 이전에 수정된 내용은 사라진다. 이는 트랜잭션만으로는 처리할 수 없는 상황이다. 이를 해결하는 방법은 3가지이다.

  • 마지막 수정 반영: 마지막에 수정 내용이 반영된다.
  • 처음 수정만 반영: 이전에 수정 내용만 허용 되고 나중 변경은 실패로 처리한다.
  • 수정 병합: 변경된 내용을 머지해서 처리한다.

JPA에서는 다양한 방식으로 락을 관리할 수 있는 기능을 제공하고 있다. 하나씩 살펴보자.

@Version

JPA에서 @Version 어노테이션으로 버전관리를 사용해 쉽게 난관적 락을 제공한다. 버전에 의해 처리하는 방식을 간단하게 살펴보자. 엔티티마다 버전이 있고 버전이 같으면 수정되지만, 버전이 다르면 수정이 안되고 실패한다. 그리고, 성공적으로 수정되면 버전 번호를 증가한다. @Version 어노테이션으로 이런 버전관리 기능을 쉽게 적용할 수 있다. @Version 어노테이션으로 아무 속성 타입에 지정할 수 없다. 지정해서 관리할 수 있는 속성 타입은 Long, Int, Short, Timestamp만 가능하다.

@Entity
open class Member {
  @Id
  private var id: String = ""
  @Version
  private version: Int = 0
  //...
}

앞의 예제에서는 정수형인 version 속성에 지정하고 있다. @Version이 적용된 속성은 엔티티가 수정될 때마다 버전 번호가 1씩 증가한다. 그렇기 때문에 현재 수정된 엔티티와 데이터베이스에 있는 버전 번호가 다르면 OptimisticLockException 예외가 발생한다.

JPA Lock API

JPA에서도 락을 직접 관리할 수 있는 위치가 EntityManager.find(), EntityManager.lock(), EntityManager.refresh(), Query.setLockMode(), @NamedQuery 등이 있다. 이를 사용해서 낙관적 락 또는 비관적 락을 처리할 수 있다. 먼저 각각에 대해 간단한 예를 보자.

// EntityManager.find()
val member = em.find(Member::class.java, id, LockModeType.OPTIMISTIC)
// EntityManager.lock()
em.lock(member, LockModeType.OPTIMISTIC)
// EntityManager.frefresh()
em.fresh(member, LockModeType.OPTIMISTIC)
// Query.setLockMode()
val query = em.createQuery("...")
query.setLockMode(LockModeType.OPTIMISTIC)
// @NamedQuery
@NamedQuery(name="foo", query="...", lockMode=OPTIMISTIC)

find()에서 세번째 인자에 LockModeType 열거형으로 락 모드를 선택해서 락을 걸 수 있다. fresh()와 lock()은 두번째 인자에서 락 모드를 선택한다. Query객체는 setLockMode()로 락 모드를 지정한다. 마지막으로 @NamedQuery는 lockMode속성으로 락모드를 지정했다. 앞의 예는 낙관적 락을 사용했다. lock()을 사용하면 별도로 락을 걸 수도 있다.

앞에서 예는 낙관적 락 형태면 보았지만 LockModeType에서는 아래와 같은 다양한 락 형태를 제공하고 있다.

  • NONE: 락을 사용하지 않는다.
    • 만약 @Version이 적용된 엔티티라면 낙관적 락이 적용된다.
  • OPTIMISTIC: 낙관적 락을 사용하며 조회할 때나 중간에 버전을 확인한다.
    • DIRTY READ와 NON-REPEATABLE READ을 허용하지 않는다.
  • OPTIMISTIC_FORCED_INCREMENT: 낙관적 락을 사용하며 강제로 버전을 증가한다.
    • 엔티티 연관관계를 논리 단위로 묶어서 처리한다.
    • 예를 들어 어떤 멤버의 게시글이 추가되었다면 멤버가 직접적으로 변경되지 않았기에 버전에 영향은 없지만, 멤버에 연관 관계인 게시글 목록은 변경되었다. 그렇기에 게시글 목록 변경으로 멤버도 같이 버전을 증가하는 락이다.
  • PESSIMISTIC_READ: 비관적 락으로 다른 사용자가 수정할 없게 만든 공유락이라 한다. 보통 읽기 전용 락이다.
    • MySQL(~ lock in share mode, ~ for share), PostgreSQL(~ for share)
  • PESSIMISTIC_WRITE: 일반적인 비관적 락으로 데이터베이스의 쓰기 락을 사용한 배타적 락이다.
    • 락이 걸린 경우 다른 트랜잭션에서 수정을 할 수 없다.
    • NON-REPEATABLE READ을 허용하지 않는다.
    • RDBMS(select ~ for update)
  • PESSIMISTIC_FORCED_INCREMENT: 비관적 락과 버전정보을 사용한다.
  • READ: OPTIMISTIC와 동일
  • WRITE: OPTIMISTIC_FORCE_INCREMENT와 동일

앞의 예는 락이 걸경우 타임아웃이 없다. 만약 타임아웃을 설정하고 싶다면 아래과 같은 설정을 통해서 가능하다.

val props = mutableMapOf<String, Any>()
props["javax.persistence.lock.timeout"] = 10000 // 10 sec
val m = em.find(Member::class.java, "...", LockModeType.PESSIMISTIC_WRITE, props)

또한 락 범위도 제어 가능하다. 일반적으로 엔티티 자체 내에서만 락이 유효하다. 그러나 이를 조인에 의해 관련된 엔티티까지 락 범위를 설정할 수 있다.

val props = mutableMapOf<String, Any>()
props["jakarta.persistence"] = PessimisticLockScope.EXTENDED // 기본값은 NORMAL
val m = em.find(Member::class.java, "...", LockModeType.PESSIMISTIC_WRITE, props)

@Lock

Spring Data JPA에서 락를 위해 @Lock 어노테이션을 제공한다. 이 어노테이션는 메소드에서 사용하여 사용할 락을 열거형 LockModeType으로 지정할 수 있다. 간단한 예를 보자.

interface MemberRepository : Repository<Member, Long> {
  @Lock(LockModeType.READ)
  fun findById(id: Long): Member
}

findById() 쿼리 메소드에 READ 락을 지정하고 있다.

@Transactional

@Transactional은 트랜잭션 관리하기 위한 어노테이션이다. 기본 구성된 트랜잭션을 변경하고 싶다면 @Transactional 어노테이션을 사용해서 변경할 수 있다. 메소드에 지정할 경우 메소드 범위가 트랜잭션 범위가 된다. 이 어노테이션이 지원하는 속성은 다음과 같다.

  • isolation: 트랜잭션 격리 수준으로 Isolation 열거형을 사용해서 지정
    • Isolation: READ_COMMITTED, READ_UNCOMMITTED, REPEATABLE_READ, SERIALIZABLE 등
  • label: 트랜잭션 라벨
  • readOnly: 읽기 전용 트랜잭션 설정(기본: false), true인 경우 읽기 전용
  • timeout, timeoutString: 타임아웃 시간(초단위)
  • propagation: 트랜잭션 전파 유형으로 Propagation 열거형으로 지정 가능
    • Propagation: MANDATORY, NESTED, REQUIRED, REQUIRES_NEW 등
  • noRollbackFor, noRollbackForClassName: 롤백 조건으로 지정된 예외 발생시 롤백

간단한 사용 예를 보자.

@Transactional(readOnly=true)
interface MemberRepository : Repository<Member, Long> {
  fun findById(id: Long): Member

  @Modifying
  @Transactional
  @Query("DELETE FROM Member m WHERE m.nickname = :nickname")
  fun deleteByNickName(nickname: String)
}

MemberRepository 인터페이스가 기본적으로 주로 읽기 기능을 제공하고 있다. 그러나 중간에 deleteByNickName()로 변경이 발생하는 경우 @Transactional으로 트랜잭션 범위가 한정되고 readOnly가 false로 된다. @Modifying은 @Query에서 변경되는 경우에 사용된다.

마무리

트랜잭션과 JPA에서 락을 제어하는 방법을 살펴보았다. 이런 트랜잭션은 대부분 크게 문제되지 않는다. 분야마다 틀릴 수 있지만 대부분 단순 수정인 경우가 많기 때문에 복잡한 트랜잭션이 발생할 경우 많지 않다. 또한 갱신 분실 문제도 대부분 자신 데이터를 수정하기 때문에 많이 발생하지 않는다. 이 부분에 대해 민감한 곳에서는 @Version으로도 쉽게 해결한다. 그러나 항상 복잡하고 수많은 요청이 동시에 처리되야하는 곳에서는 신경써야할 부분이다. 특히, 민감한 시스템인 경우 비관적인 관점에서 접근할 수 밖에 없다.

이 글이 여러분에게 도움이 되었으면 하네요. 항상 즐거운 코딩 생활되세요. ospace.

참고

[1] 최범균, JPA 프로그래밍 입문, 가메출판사, 2017

[2] 김영한, 자바 ORM 표준 JPA 프로그래밍, 에이콘, 2015

[3] Spring Data JPA, https://docs.spring.io/spring-data/jpa/reference/jpa.html

[4] baeldung, Pessimistic Locking in JPA, https://www.baeldung.com/jpa-pessimistic-locking, 2024.03.11

[5] Transactionality, https://docs.spring.io/spring-data/jpa/reference/jpa/transactions.html, 2024.06.24

[6] Annotation Interface Transactional, https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/annotation/Transactional.html, 2024.06.24

반응형