프록시로 동작하는 @Transactional의 사용시 주의할 점

mylime·2024년 6월 26일
1

이 포스팅은 2024.06.27에 작성되었습니다.

  • 2024.06.29 @Transactional이 붙은 메서드가 @Transactional이 붙은 메서드를 호출할 때 동작 추가



(평소보다 조금 긴)서론

(좀 길 수도 있으니, 개념만 필요하신 분은 아래로 스킵해주세요!)

이 부분을 너무 이해하고 싶어서 AOP 프록시를 공부하고 왔다. 물론 개념만 알고있어도 이해할 수 있는 내용지만, 이번 기회에 완벽하게 돌아가는 동작을 이해하고 싶었다.

나는 항상 개발할 때 Service메서드 코드에서 같은 Service 메서드를 호출하지 않고, 대신 여러 개의 Repository를 주입받는 식으로 개발했었다. @Transactional이 겹치면 왠지 모르게 불편했고, 에러가 생길 것 같다는 걱정 때문이었다. 제대로 된 동작을 몰랐으니 당연한 일이었던 것 같다. 다행히 지금까지 구현했던 프로젝트들은 이 방법대로 구현해도 중복로직이 많이 발생하지 않아 항상 이렇게 진행해왔다.

하지만 이번 프로젝트를 진행하면서 이전 방법대로 처리하면 로직이 많이 중복되는 문제가 발생했다. 특히 핵심 비즈니스로직이 다른 서비스 코드 내부에 들어가야하는 상황도 발생했다. 다른 서비스 코드의 메서드를 사용하면 쉽게 처리될 수 있는 로직인데, 이렇게 구현하면 안될 것 같다는 생각이 들었다. 그래서 이번 기회에 @Transactional에 대해 공부하고, 돌아가는 원리를 이해하여 다른 서비스의 메서드를 에러없이 사용하고자 했다.


트랜잭션에 대해 공부하면서 느꼈는데, 나는 @Transactional이 만능이라고 생각했던 것 같다. (굉장히 부끄러운 일이다) 메서드 위에 Transactional만 붙이면 알아서 트랜잭션으로 묶이고, 원자적으로 에러없이 동작할 수 있다고 생각했다. 게다가 MySQL을 사용하면 기본 트랜잭션 격리수준인 REPEATABLE_READ가 PHANTOM READ까지 모두 방지해준다고 알고있었기 때문에 (lost update는 당연히 몰랐다) 정말 마음놓고 사용했었다. 이번에 락을 공부하고 트랜잭션을 공부하면서 굉장히 흥미로움을 느낌과 동시에, 지금까지는 정말 하나도 몰랐다는 걸 알게되었다. 지금까지 프로젝트들은 정말 기능구현에만 집중했던 것 같다는 생각이 들었다.


많은 반성과 공부를 하게 해준 mini-pay 레포지토리가 너무 고맙다. 개발자 오픈 톡방에서 우연히 보게된 레포지토리인데 이렇게 큰 도움이 될 줄은 몰랐다. 그리고 방학동안 함께 공부해준 스터디원들도 너무 고맙다.



@Transactional

org.springframework.transaction.annotation.Transactional

Spring에서 @Transactional은 선언적 트랜잭션을 구현할 수 있는 어노테이션이다. 이 어노테이션을 사용하면 트랜잭션 경계 설정이라는 부가 기능을 AOP로 뺄 수 있고, 그 덕분에 비즈니스로직만 코드에 남길 수 있다.

@Transactional 어노테이션은 프록시로 동작하기 때문에 유의해야할 사항들이 있다.



1. private 메서드에 사용 불가능

@Transactional이 붙은 메서드는 프록시로 처리되기 때문에 외부에서 접근이 가능해야한다. 그래서 private 접근 지정자는 사용할 수 없다.



2. 내부 메서드에서 호출 시 동작하지 않음

클래스 메서드 내부에서 같은 클래스의 메서드를 호출할 때 @Transactional이 작동하지 않는 경우가 있다. 예시를 통해 살펴보겠다.

다음과 같이 회원의 이름을 바꾸는 changeName 메서드가 있다.

@Service
public class MemberServiceImpl implements MemberService {
    @Autowired
    private MemberRepository memberRepository;

    @Transactional
    public void changeName(final Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new RuntimeException());
        member.setName("바뀐 이름");
    }
}

@Transactional이 붙어있기 때문에 메서드 호출 전 transaction이 시작되고, 메서드가 끝난 후 commit되는 걸 생각할 수 있다. 아직까지는 문제가 없지만, 이 메서드를 같은 클래스의 다른 메서드에서 호출할 때 문제가 발생한다.


(문제 상황을 위해 억지로 만든 코드라 어색할 수 있습니다)

@Service
public class MemberServiceImpl implements MemberService {
    @Autowired
    private MemberRepository memberRepository;

    public void changeFiveName(String name) {
        //5명의 이름을 바꿈
        for (int i=1; i<6; i++) {
            changeName((long)i);
        }
    }
    
    @Transactional
    public void changeName(final Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new RuntimeException());
        member.setName("바뀐 이름");
    }
}

changeName 메서드를 같은 클래스 메서드인 changeFiveName에서 호출하였다.

@Transactional의 프록시 동작방법을 생각하지 않는다면, memberId가 1부터 5까지의 사람들 이름이 모두 정삭적으로 바뀔 것이라고 생각할 수 있다. 하지만 이 코드에서 @Transactional은 동작하지 않기 때문에 데이터베이스에는 하나도 반영되지 않는다.

(5명 중 한 명도 안바뀜)

왜 이 경우 @Transactional이 동작하지 않을까? 이유는 @Transactional이 Proxy방식으로 동작하기 때문이다.


@Transactional의 동작방식

@Transactional이 붙은 클래스는 Proxy객체가 만들어진다. MemberServiceImpl을 사용할 때, 스프링이 제공하는 MemberServiceProxy 객체를 사용하게 되며, 이 때 트랜잭션 처리가 이루어진다. 코드로 나타내보면 이런 식이다.

public class MemberServiceProxy {  
  private final MemberService memberService;  
  private final TransactonManager manager = TransactionManager.getInstance();  
  
  public MemberServiceProxy(MemberService memberService) {    
      this.memberService = memberService;  
  }    

  public void changeName(final Long memberId) {    
      try {      
          manager.begin();      
          memberService.changeName(memberId);      
          manager.commit();    
      } catch (Exception e) {      
          manager.rollback();    
      }  
  }
}

이렇게 실제 비즈니스 로직이 담긴 메서드를 호출하기 전, transaction begin()이 호출되고 메서드가 끝난 후 commit이 수행된다.

이를 통해 직접 changeName 메서드를 호출하면 트랜잭션 처리가 제대로 수행되지 않고, MemberServiceProxy 객체가 제공하는 changeName 메서드를 사용해야만 제대로 수행된다는 걸 알 수 있다.


이제 다시 위 문제상황으로 돌아가보자

@Service
public class MemberServiceImpl implements MemberService {
    @Autowired
    private MemberRepository memberRepository;

    public void changeFiveName(String name) {
        //5명의 이름을 바꿈
        for (int i=1; i<6; i++) {
            changeName((long)i);
        }
    }
    
    @Transactional
    public void changeName(final Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new RuntimeException());
        member.setName("바뀐 이름");
    }
}

이제는 문제가 보일 것이다.

MemberServiceImpl 클래스 내부 메서드인 changeFiveName에서 직접 changeName 메서드를 사용하였기 때문에 Proxy를 통해 제공받을 수 있는 트랜잭션 처리를 받지 못한다. 그래서 @Transactional이 동작하지 않았던 것이다!


그래도 내부 메서드를 호출하고 싶다면?

웬만하면 @Transactional 어노테이션이 붙은 메서드를 클래스 밖에서 호출하는 게 좋겠지만, 그래도 내부 메서드를 꼭 사용해야겠다면 자신의 Proxy 인스턴스를 자체적으로 가져와서 사용할 수도 있다.


@Service
public class MemberServiceImpl implements MemberService {
    @Autowired
    private MemberRepository memberRepository;
    
    @Autowired
    private ApplicationContext applicationContext;

    @Override
    public void changeFiveName(String name) {
        MemberService proxy = applicationContext.getBean(MemberService.class);

        //5명의 이름을 바꿈
        for (int i=1; i<6; i++) {
            proxy.changeName((long)i); //대신 프록시객체를 호출
        }
    }

    @Transactional
    public void changeName(final Long memberId) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> new RuntimeException());
        member.setName("바뀐 이름");
    }
}

여기서는 applicationContext를 이용하여 직접 Bean을 가져왔다. (자기 자신을 주입받아버리면 Spring에서 exception이 발생하기 때문)
이렇게 되면 직접적으로 MemberServiceImpl의 순수한 changeName 메서드를 호출하는 것이 아니라, 프록시로 감싸진 메서드를 통하기 때문에 @Transactional이 정상적으로 동작하게 된다.

(잘 동작하는 모습)



+) @Transactional이 붙은 메서드가 @Transactional이 붙은 메서드를 호출할 때 동작

(댓글을 읽고 추가합니다! 댓글 달아주셔서 감사합니다)

답변: @Transactional이 붙어있든 붙어있지 않든, 프록시객체를 통해 호출하지 않는다면 @Transactional은 동작하지 않습니다!

저도 궁금해져서 한 번 실험을 해봤습니다.

@Override
@Transactional
public void changeName(final Long memberId) {
    Member member = memberRepository.findById(memberId)
            .orElseThrow(() -> new RuntimeException());
    member.setName("바뀐 이름");
}

@Override
@Transactional
public void updateMemberName(Long memberId){
    Member member = memberRepository.findById(memberId)
            .orElseThrow(() -> new RuntimeException());
    member.setName("트랜잭션!!");
}

다음 코드를 실행해봤을 때, 이렇게 잘 수행되는 걸 볼 수 있었습니다.

하지만 이 결과만으로는 @Transactional이 잘 적용된건지 알 수 없으니, 트랜잭션 전파와 관련된 코드 하나를 추가하여 실험을 진행해봤습니다.


1. @Transactional이 잘 동작하는 경우: 프록시객체 직접 사용

@Transactional
@Override
public void changeFiveName(String name) {
    MemberService proxy = applicationContext.getBean(MemberService.class);
    //아이디가 1인 사람의 이름을 바꿈
    Member member = memberRepository.findById(1L)
            .orElseThrow(() -> new RuntimeException());
    member.setName("바뀐 이름");

    //아이디가 2~5인 사람의 이름을 바꿈
    for (int i=2; i<6; i++) {
        try {
            proxy.changeName((long) i); //대신 프록시객체를 호출
        } catch(RuntimeException e) {
            e.printStackTrace();
        }
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void changeName(final Long memberId) {
    Member member = memberRepository.findById(memberId)
            .orElseThrow(() -> new RuntimeException());
    member.setName("바뀐 이름");

    throw new RuntimeException();
}

직접 호출하지 않고 자신에 대한 프록시 객체를 통해 changeName 메서드를 호출하였습니다. 이 경우 @Transactional은 잘 동작하기 때문에

  • 아이디가 2~5인 사람들은 새로운 트랜잭션을 시작한 뒤 예외가 발생하여 롤백되고, 이름은 바뀌지 않을 것입니다.
  • 하지만 아이디가 1인 사람은 정상적으로 이름이 바뀔 것입니다.

예상대로 잘 수행되는 걸 확인할 수 있습니다.


2. 같은 클래스의 @Transactional 메서드를 직접 호출할 경우

@Transactional
@Override
public void changeFiveName(String name) {
//        MemberService proxy = applicationContext.getBean(MemberService.class);
    Member member = memberRepository.findById(1L)
            .orElseThrow(() -> new RuntimeException());
    member.setName("바뀐 이름");

    //5명의 이름을 바꿈
    for (int i=2; i<6; i++) {
        try {
            this.changeName((long) i); //대신 프록시객체를 호출
        } catch(RuntimeException e) {
            e.printStackTrace();
        }
    }
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void changeName(final Long memberId) {
    Member member = memberRepository.findById(memberId)
            .orElseThrow(() -> new RuntimeException());
    member.setName("바뀐 이름");

    throw new RuntimeException();
}

이번에는 프록시를 통하지 않고 바로 메서드를 호출하였습니다.

@Transactional이 제대로 적용되었다면 아이디 2~5인 사람들의 이름은 바뀌지 않고, 동작이 롤백됐을 것입니다.

하지만 @Transactional동작하지 않았기 때문에 모든 변경사항이 그대로 다 반영된 걸 확인할 수 있습니다.


결론

프록시 객체를 통해 호출하지 않는다면 @Transactional은 동작하지 않는다!



참고자료

https://mommoo.tistory.com/92

profile
깊게 탐구하는 것을 좋아하는 백엔드 개발자 지망생 lime입니다! 게시글에 틀린 정보가 있다면 지적해주세요. 감사합니다. 이전블로그 주소: https://fladi.tistory.com/

2개의 댓글

comment-user-thumbnail
2024년 6월 28일

오! 새로운 사실! Transactional 어노테이션이 붙은 메서드가 다른 Transactional 어노테이션이 붙은 메서드를 호출할 땐 어떻게 동작하는지 궁금합니다!

1개의 답글