Spring Data

@Transactional은 만능이 아닙니다 -2 트랜잭션의 격리성과 lock

Woonys 2022. 9. 25. 00:56
반응형

Introduction

지난 시간에는 트랜잭션의 개념과 어떨 때 트랜잭션을 사용해야 하는지에 대해서 배웠다. 그런데 1부에서 얘기했던 것과 같이, @Transactional을 제거했더니 Exception이 발생했다. 이 문제는 어떻게 해결할 수 있을까? 다시 코드를 살펴보도록 하자.

Code

UserController

@PutMapping("/user/{Id}")
    public void moveDateTime(@PathVariable long userId) {
        userService.moveDateTime(userId);
    }

UserService

public void moveDateTime(long userId) {
        Timestamp targetDateTime = Timestamp.valueOf(ZonedDateTime.now().plusDays(1).toLocalDateTime());
                User user = userRepository
            .findByIdForUpdate(userId)
            .orElseThrow(() ->
                         {
                             logger.info("moveDateTime(): can not found user by {}", userId);
                             return new ResourceNotFoundException("can not found user by " + userId);
                         });
        user.setDateTime(targetDateTime);
                userRepository.save(user);
    }

User

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {

        @Id
    @Column(name = "usr_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

        @Column(name = "date_time")
    private Timestamp dateTime;

        ...

        public void setDateTime(Timestamp dateTime) {
        this.dateTime = dateTime;
    }
}

UserRepository

public interface ClientBankStatementRepository extends JpaRepository<User, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select usr from User usr where usr.id = :id")
    Optional<User> findByIdForUpdate(@Param("id") long id);

저번 시간에서는 UserService에서 트랜잭션을 굳이 사용할 이유가 없음에도 사용한 것을 문제로 지적했다. 그래서 @Transactional을 제거하고 save() 로직을 추가했다. 그런데 이번에는 다른 점이 이상하다. userRepository를 보자.

UserRepository

public interface ClientBankStatementRepository extends JpaRepository<User, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select usr from User usr where usr.id = :id")
    Optional<User> findByIdForUpdate(@Param("id") long id);

우리는 이미 JPA를 사용하고 있기 때문에 findById()를 사용할 때 굳이 새롭게 정의할 이유가 없다. 그런데 위의 repository에서는 findByForUpdate()라는 새로운 메소드가 정의되어 있다. UserService에서는 해당 메소드를 사용하고 있고. 이 메소드에는 이전에 보지 못했던 새로운 어노테이션이 추가되어 있다. @Lock이 바로 그것.

 

이번 시간에는 트랜잭션과 락에 대해서 알아보고 왜 TransactionRequiredException: no transaction is in progress이 발생했는지 알아보도록 하자.

트랜잭션에서 Lock

트랜잭션은 1부에서 언급했듯 그 의미가 일련의 연산을 하나의 논리적 작업으로 정의하는 행위 라고 했다. 특정 논리적 작업을 수행하는 연산 과정에서 끝까지 완료했다면 커밋을, 중간에 이슈가 생겨 멈추게 될 경우 롤백하는 개념이 핵심이다. 여기서 트랜잭션에 대해 잘 모른다면 은연중에 이런 생각을 할 것이다.

아, 트랜잭션은 논리적 작업을 수행하는데 있어 그 어떤 방해도 용납하지 않는구나! 그럼 동시성 문제도 트랜잭션만 걸면 무조건 해결되겠는데?

 

하지만 이는 동시성을 보장하기 위해 트랜잭션의 격리 수준을 어디까지 설정할 것인가에 따라 달라진다. 이는 트랜잭션에서 얘기하는 ACID 중 격리성(I, Isolation)에 해당하는 문제다. 트랜잭션은 ACID를 만족해야 하니 당연히 격리성도 빡세게 보장해야 하는 것 아닌가요!라고 할 수 있지만, 현실은 그리 녹록치 않다. 어떤 상황에서도 동시성을 보장하게 되면 성능 저하라는 트레이드오프가 발생할 수밖에 없기 때문이다.

 

 

위의 예시를 보자. 유저가 방문했을 때마다 카운트를 올리는 로직이다. User 1과 User 2가 비슷한 시점에 사이트를 방문했다. User 1이 방문한 순간 현재 카운터가 얼마인지 가져온다(get). 아직 유저 1이 카운트를 올리지 않은 시점에 User 2가 방문해 역시 현재 카운터를 가져온다. 아직 User 1의 트랜잭션이 끝나지 않은 상황이기에 역시 같은 값을 가져온다. 뒤이어 User1이 카운트를 올린다(set). User2 입장에서는 이전에 42를 가져왔으니 역시 43이라는 값으로 업데이트한다. 즉, 동시성 문제가 발생한 상황. 이를 해결하기 위해 데이터베이스는 트랜잭션이 커밋했을 때의 결과가 트랜잭션이 순차적(하나씩 차례로) 실행됐을 때의 결과와 동일하도록 보장해줄 필요가 있다.

 

문제는 이를 어디까지 엄격하게 처리할 것인가라는 것. 트랜잭션에서 이를 가장 엄격하게 처리하는 방식은 직렬성 격리(serializable isolation)이다. 말 그대로 동시에 이뤄지는 여러 작업이 사실상 같은 라인에서 처리되도록 일렬로 세우는 것이다. 하지만 이 방식이면 100개의 요청이 왔을 때 무조건 하나하나 차례대로 작업을 해야 하는 것일테니 그만큼 작업 속도가 느려질 것이다. 성능 손해를 동반할 수밖에 없기에 이 방법은 실무에서 적용할 일이 거의 없다. 실제로는 이보다 훨씬 약한 격리(비직렬성) 수준을 구현한다.

 

1부에서도 언급했듯 한 작업을 하는 동안 혼자 풀을 하루종일 점유한다던지, 여기서는 혼자 락 걸고 작업 끝날 때까지 다른 애들한테 계속 기다리라고 하는 건 일종의 민폐와도 같으니까. 그래서 비즈니스 로직을 만족하는 선에서 어디까지 격리성을 완화할 것인지가 중요한 것.

 

즉, 트랜잭션을 걸면 무조건 동시성이 보장된다 는 틀린 명제다. 어디까지 격리성을 유지하면서 동시성을 체크할 것인지는 사용자의 몫이다. 따라서 트랜잭션에 어느 정도의 격리성을 보장하기 위해 어떤 락을 걸 것인지가 중요한 기술적 의사결정에 해당한다.

 

트랜잭션에 적용할 수 있는 lock의 종류를 살펴보자.

 

Lock의 종류

1. 낙관적 Lock(Optimisstic Lock)

낙관적 Lock은 동시성 문제에서 경쟁 조건(race condition)이 발생하지 않을 것으로 보고 거는 Lock이다. 예를 들어 특정 회원 정보에 대한 갱신과 같은 비즈니스 로직은 그 회원 이외의 누군가가 작업할 경우가 매우 낮기에 동시에 여러 요청이 발생할 가능성이 낮다. 따라서 동시에 수정이 이뤄진 경우를 감지해서 예외를 발생시키더라도 실제 상황에서는 예외가 발생할 가능성이 낮다고 낙관적으로 보는 것이다.

2. 비관적 Lock(Pessimistic Lock)

위의 JPA 메소드에 걸려있는 어노테이션이 여기에 해당한다. 동일한 데이터를 동시에 수정할 가능성이 높다는 비관적인 관점으로 Lock을 거는 방식이다. 예컨대 어떤 상품을 주문하는 비즈니스 로직이 있다고 하자. 상품은 동시에 여러 명이 주문할 가능성이 높으니 재고 관리의 정합성을 보장하기 위해서라도 반드시 충돌 가능성을 염두에 두고 Lock을 걸어야 할 것이다. 물론 이 과정에서 정합성을 보장하는 만큼 성능은 떨어질 수밖에 없다.  

 

여기서 우리는 SELECT…FOR UPDATE에 주목할 필요가 있다. 해당 쿼리는 동시성 문제를 해결하기 위해 MySQL에서 거는 쿼리로, 하나 이상의 row에 대해 여러 세션에서 접근함으로써 발생하는 동시성 문제를 해결한다. 즉, 데이터베이스 단에서 락이 걸리는 것. 우리가 어노테이션으로 거는 락과 별개로 MySQL에서는 이렇게 쿼리를 날림으로써 동시성 문제를 해결하나 그만큼의 성능 저하 역시 이해하고 사용해야 한다.  

 

게다가 트랜잭션에 비관적 락을 걸게 될 경우 한 가지 더 알아야 할 것이 있다. 바로 락의 범위와 트랜잭션의 범위는 동등해야 한다는 점이다.  

 

여기서 다시 우리의 로직을 살펴보자.  

 

  1. 지금 비즈니스 로직은 단일 엔티티 내의 특정 필드값인 DateTime을 특정 시점으로 옮겨버리는 로직이다. 여러 엔티티가 관련되어 있지 않을 뿐더러, 이 작업을 하는 주체는 어드민(실제 비즈니스 상에서는 QA팀 및 백엔드 팀에게만 해당) 뿐이기 때문에 동시에 충돌이 발생할 가능성이 낮다.
  2. 위에는 이와 연관된 다른 비즈니스 로직 코드를 넣지는 않았으나, 본 기능의 핵심은 DateTime을 과거의 특정 시점으로 옮김으로써 해당 엔티티와 관련된 문서의 기한을 만료시키는 로직이다. 즉, “날짜를 만료시킨다”라는 로직만 있지 만료시킨 날짜를 원복하는 기능은 굳이 필요 없다. 이런 상황에서는 두 어드민이 각각 특정 사용자의 문서를 만료시키려고 동시에 작업하는 상황이 생기더라도 둘의 목적은 이미 동일하기 때문에 애초에 동시성 문제 자체를 고민할 필요가 없다. 동시에 누르더라도 결과는 만료됨 으로 같기 때문이다.

이렇기 때문에 우리는 이 비즈니스 로직에 어떤 락을 걸 필요도 없다. 심지어 낙관적 락을 걸 이유도 없다. 동시성 문제가 발생하든 안하든 상관이 없기 때문이다. 문제는 여기에다가 비관적 lock이 걸린 findByIdForUpdate() 메소드를 가져다 씀으로써 TransactionRequiredException이 발생했던 것.  

 

서비스 단에서 @Transactional 어노테이션을 제거함으로써 트랜잭션 범위는 서비스 범위가 아닌 리포지토리 범위로 줄어들었다. 그런데 정작 비관적 락이 걸린 리포지토리 메소드를 서비스 로직에서 사용하면 트랜잭션 범위(리포지토리)와 락의 범위(서비스-리포지토리)가 달라진다.TransactionRequiredException: no transaction is in progress 가 발생했던 건 이 때문이었다.  

 

따라서 이 문제는 JPARepository에 따로 커스텀한 메소드가 아닌 기본 메소드인 findById를 사용함으로써 간단히 해결된다.

public void moveDateTime(long userId) {
        Timestamp targetDateTime = Timestamp.valueOf(ZonedDateTime.now().plusDays(1).toLocalDateTime());
                User user = userRepository
            .findById(userId) // findById로 수정
            .orElseThrow(() ->
                         {
                             logger.info("moveDateTime(): can not found user by {}", userId);
                             return new ResourceNotFoundException("can not found user by " + userId);
                         });
        user.setDateTime(targetDateTime);
                userRepository.save(user);
    }

사실 트랜잭션 관련해서는 훨씬 더 다뤄야 할 내용이 많다. 격리 수준에 대해서도 완화된 격리 수준을 구현하는 스냅숏 격리라던지, 쓰기 과정에서 일어나는 왜곡인 쓰기 스큐 등. 하지만 지금 문제에서 핵심은 아니기에 앞으로 또 좋은 기회가 생기면 다뤄볼 예정.

Lesson learn: 기술적 의사결정에 대해 깊게 고민할 것

이 문제에서 배워야 할 핵심은 이것이다.  

 

“기술을 함부로 가져다 쓰지 말 것. 어떤 상황에도 좋은 은총알은 없다는 것.”

  

도메인에서 어떤 비즈니스 로직을 다루느냐에 따라 이렇게 간단한 기능도 깊게 고민을 하고 써야 한다. 특히나 이 글을 작성하면서 구글링했을 때, 많은 블로그에서 @Transactional을 은총알처럼 쓰는 경우가 많았다. 반면 그 어느 누구도 이런 트레이드오프에 대해서 언급하지 않았다. 하지만 우리는 소프트웨어 엔지니어이다. 엔지니어링은 의사결정의 싸움이다. 기능만 만드는 건 1년차도 10년차도 똑같이 해낸다. 그 과정에서 어떤 맥락에서 이 기술을 택했는지에 대한 논리와 얼마나 길게 보고 만들 것인가에 대한 시간지평선의 차이가 실력의 차이를 만들어낸다는 걸 명심하자.

반응형