Spring Data

@Transactional은 만능이 아닙니다 - 1: Transaction의 개념과 트레이드오프

Woonys 2022. 9. 16. 22:01
반응형

Introduction

현재 작업 중인 프로젝트와 관련해 올렸던 PR에서 아래와 같은 리뷰를 받았다.

Can you do it without getting @Transactional? @Transactional is not cheaper. Please think about why we use Transaction on RDBMS DB.

왜 이런 말이 나오게 됐을까? 먼저 원래 코드부터 살펴보자. 유저의 필드 중 하나인 날짜를 특정 시점으로 수정하는 API이다. 내부 코드를 공개할 수 없기에 간단한 형태로 수정했다.

Code

UserController

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

UserService

@Transactional
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);
    }

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을 쓰는 게 딱히 문제가 되어 보이지 않는 것 같다. 근데 왜 문제가 됐을까? 이쯤에서 트랜잭션이 정확히 무엇을 뜻하며 @Transactional의 역할이 무엇인지에 대해서 알아보도록 하자.

트랜잭션이 대체 뭐고 어따 쓰니?

트랜잭션은 애플리케이션에서 몇 개의 읽기와 쓰기를 하나의 논리적 단위로 묶는 방법이다. 즉, 여러 연산이 있을 때 이를 하나의 연산으로 정의하는 방식이다. 트랜잭션은 전체가 성공하거나 실패하는 두 가지 결과만 허용하며, 그 외 부분적으로 수정 혹은 저장되는 것을 막는다.

트랜잭션은 데이터가 여러 관계를 맺는 상황에서 정합성을 보장하기 위해 사용한다. 이는 특히 핀테크 도메인에서 중요한 요소이다. 예를 들어 A가 B에게 10만원을 보냈다고 하자. 그러면 연산은 아래와 같이 진행될 것이다.

  1. A의 계좌에 10만원 이상의 잔고가 있는지 확인(Read)
  2. B의 계좌가 돈을 받을 수 있는 상태인지 체크(Read)
  3. A의 계좌에서 10만원을 인출(Update)
  4. B의 계좌에 10만원을 입금(Update)

1 - 4까지의 연산 중 에러가 발생해 3번까지만 진행됐다고 가정해보자. 돈을 받은 사람은 아무도 없는데 A 혼자 돈을 날린 셈이 된다. 위와 같은 상황이 발생하지 않도록 트랜잭션은 다음과 같이 작업을 처리한다.

  • 모든 작업이 완료되었을 때 비로소 DB에 업데이트(커밋)하고 그 전까지는 쓰기를 지연하며
  • 혹시나 문제가 생길 경우 전부 원상태로 되돌린다(롤백).

이쯤에서 다시 현재 비즈니스 요구사항을 살펴보자. 우리가 짠 API는 특정 유저의 필드값 중 하나를 변경하는 기능이다. 비즈니스 로직은 아래와 같다.

  1. id를 키로 특정 유저 엔티티를 DB에서 읽어들인다 (findById())
  2. 유저의 dateTime 필드값을 수정한다 (setDateTime())
  3. 변경사항을 DB에 저장한다(save())

1-3 중 에러가 발생해 중간 어디쯤에서 멈췄다고 가정해보자. 1번만 진행됐다거나, 2번까지만 진행됐다고 해서 문제가 될 일이 있을까? 중간에 에러가 생기면 예외 처리만 하면 될 뿐, 애초에 DB에는 어떤 값도 변경되지 않았기 때문에 위와 같은 정합성을 보장할 이유가 없다. 즉, 우리가 수정할 값이 다른 엔티티와 연관 관계를 맺고 있지 않는 상황이기 때문에 트랜잭션을 전체에 걸어줄 필요가 없다. 트랜잭션은 일반적으로 여러개의 DB에 접근하는 상황에서 한 DB에만 작업의 결과가 적용되는 것을 방지하기 위해 사용하는 것이기 때문이다.

그러면 여기서 질문이 나올 수 있다.

아니, 3번이 진행되는 와중에 끊길 수도 있는 거 아닌가요?

  

이는 걱정할 필요가 없다. 우리는 Spring Data JPA에서 제공하는 메소드를 사용하고 있기 때문이다. JPARepository에서 제공하는 데이터를 읽고 쓰고 저장하는 일련의 모든 데이터 연산에는 트랜잭션이 기본적으로 적용된다. 즉, 위의 1번 - findById(), 2번 - setDateTime(), 3번 - save() 각각 작업 당 하나의 트랜잭션이 적용된 것이다.

그러면 여기서 또 한 가지 질문이 나올 수 있다.

아니, 어차피 트랜잭션이 적용되는 거라면 각각 트랜잭션을 매기나 한 번에 다같은 트랜잭션으로 매기나 그게 거기서 거기 아닌가요?

  

그렇지 않다. 트랜잭션은 길수록 오버헤드가 발생하기 때문이다. 이를 이해하기 위해서는 JPA가 영속성 컨텍스트를 어떻게 관리하는지를 알아야 한다.

JPA의 영속성 컨텍스트 관리

위 그림과 같이, 사용자의 요청이 들어올 때마다 EntityManagerFactory에서는 각 요청 당 EntityManager를 생성해 발급한다. 이 엔티티 매니저가 영속성 컨텍스트를 관리하는 것이다. 이때 각 엔티티 매니저는 DB에 접근할 때 커넥션 풀을 점유해야 읽고 쓰는 작업을 진행할 수 있다.

  

이 커넥션 풀이라는 건 데이터베이스와 미리 연결되어 있는 객체를 담아놓은 풀이다. 일정 수의 커넥션 객체를 미리 만들어두면 DB에 접근할 때마다 매번 커넥션 객체를 생성할 필요 없이 풀에서 남는 애를 가져다 쓰면 되니 보다 빠르게 DB에 접근이 가능한 것. 이 커넥션 객체는 스레드가 사용하고나면 재깍재깍 반납해야 한다. 그래야 다음 스레드가 와서 작업할 수 있을 테니까.

  

그런데 어떤 놈이 특정 커넥션 객체를 오랫동안 붙잡고 있는다고 생각해보자. 그만큼 다른 애들이 기다려야 할 가능성이 높아질 수밖에 없다. 여기서 엔티티 매니저가 여러 작업에 대해 트랜잭션을 묶었다고 생각해보자. 그 작업이 시작하는 순간 하나의 영속성 컨텍스트가 형성되고, 이 영속성 컨텍스트는 해당 트랜잭션이 끝날 때까지 유지된다. 당연히 그 기간 동안 DB 커넥션 역시 붙잡고 있어야 한다. 이 작업이 반드시 필요한 경우라면(여러 데이터 간 연관관계에서 정합성을 보장받아야 하는 경우라면) 당연히 써야 할 수밖에 없으나, 굳이 오래 쓸 필요 없는 커넥션 객체를 오래 붙들고 있는 건 자원의 낭비일 뿐이다. 트랜잭션을 어느 범위까지 적용할 것인지를 결정하는 것을 트랜잭션 전파(Propagation) 전략이라고 한다. 여러 엔티티에 걸쳐 정보를 저장하고 수정해야 하는 경우는 트랜잭션 범위를 서비스 단까지 넓혀야겠으나 그럴 필요가 없는 경우는 트랜잭션 범위를 리포지토리 내로 한정하는 게 더 좋을 수 있다.

  

수정한 버전은 아래와 같다. UserService만 고치면 된다. @Transactional을 제거하고 save() 로직을 추가한다.

  

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);
    }

이렇게 지금까지 @Transactional을 남용하면 안된다는 따뜻한 교훈을 배웠다. 끝!

  

…이면 좋겠으나..사실상 이 글은 2부작이다.

  

왜냐면 @Transactional만 지우면 다 끝날 줄 알았던 것과 달리 현실은

[Exception]TransactionRequiredException: no transaction is in progress

Caused by: javax.persistence.TransactionRequiredException: no transaction is in progress
    at org.hibernate.query.internal.AbstractProducedQuery.doList(AbstractProducedQuery.java:1553)
    at org.hibernate.query.internal.AbstractProducedQuery.list(AbstractProducedQuery.java:1526)
    at org.hibernate.query.internal.AbstractProducedQuery.getSingleResult(AbstractProducedQuery.java:1574)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:566)
    at org.springframework.orm.jpa.SharedEntityManagerCreator$DeferredQueryInvocationHandler.invoke(SharedEntityManagerCreator.java:409)
    at com.sun.proxy.$Proxy309.getSingleResult(Unknown Source)
    at org.springframework.data.jpa.repository.query.JpaQueryExecution$SingleEntityExecution.doExecute(JpaQueryExecution.java:196)
    at org.springframework.data.jpa.repository.query.JpaQueryExecution.execute(JpaQueryExecution.java:88)
    at org.springframework.data.jpa.repository.query.AbstractJpaQuery.doExecute(AbstractJpaQuery.java:154)
    at org.springframework.data.jpa.repository.query.AbstractJpaQuery.execute(AbstractJpaQuery.java:142)
    at org.springframework.data.repository.core.support.RepositoryFactorySupport$QueryExecutorMethodInterceptor.doInvoke(RepositoryFactorySupport.java:618)
    at org.springframework.data.repository.core.support.RepositoryFactorySupport$QueryExecutorMethodInterceptor.invoke(RepositoryFactorySupport.java:605)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
    at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:80)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
    at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:366)
    at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:99)
    at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186)
    at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:139)

이 모양이었기 때문..

다음 글에서는 그렇게 남용하지 말라던 @Transactional이 정작 뺐더니만 에러를 일으켰던 이슈와 이를 해결하는 과정에서 배운 트랜잭션에서의 동시성 문제, DB Lock에 대해서 알아보도록 하겠다.

반응형