코드는 깃헙에 있습니다.
자기 호출 이슈는 보통 개발자가 @Transactional의 기능을 조작하려고 할 때 발생하는 이슈입니다.
그럼 우선 자기 호출이란 뭔지 알아봐야겠지요.
자기 호출이란?
@Transactional은 스프링 AOP를 기반으로 만들어진 어노테이션 입니다.
AOP는 다이나믹 프록시 혹은 CGLib(Spring Boot와 JPA Hibernate는 Default가 CGLib)으로 생성된 프록시 객체를 사용하는 기술입니다.
왜 프록시 객체를 사용할까요?
@Transactional이 붙으면 트랜잭션을 시작하고 마지막에 롤백 혹은 커밋에 대한 명령어로 메소드를 감싸기 위해 프록시 객체를 만들어서 메소드를 새롭게 만든다라고 보면 됩니다.
public Proxy {
public void externalMethod(){
internalMethod(); // 자기 호출(Self Invocation) 발생
}
public void internalMethod(){
}
}
이렇게 생성된 프록시 객체에서 내부(Internal) 호출을 할 경우, 다시 말해 같은 클래스 내 메소드 호출인 자기 호출(Self-Invocation)을 이용하면 AOP 기능이 수행되지 않아 여러 블로그 글들에서 트랜잭션 기능이 동작하지 않는다고 설명하고 있습니다.
하지만 실제 실습 과정에서는 자기 호출을 하더라도 트랜잭션이 동작할 때가 굉장히 많아서 의문이 생겼는데, 과연 트랜잭션 기능이 어떨 때 동작하는지 혹은 어떨 때 동작하지 않는지 알아보겠습니다.
여러 케이스 실험하기
테스트에선 아래의 서비스 코드를 활용하겠습니다.(JPA Entity와 데이터 세팅이 보고 싶다면 여기를 참고해주세요.)
public class StudentService {
public void findAllSchoolNames(){
getSchoolName();
}
@Transactional
public void getSchoolName(){
Student student = new Student();
student.setName("stir");
Student savedStudent = studentRepository.save(student);
System.out.println(em.contains(savedStudent));
}
}
① 트랜잭션이 적용되지 않는 일반적인 예제
@Test
@DisplayName("트랜잭션 적용 안됨")
public void test1() throws Exception {
System.out.println("== start ==");
studentService.findAllSchoolNames();
System.out.println("== end ==");
}
우선 트랜잭션이 동작하지 않는 예제부터 알아보겠습니다.
코드에서는 getSchoolName() 메소드에만 @Transactional이 적용된 상태입니다.
그리고 테스트 코드에서 findAllSchoolNames()를 호출해서 해당 메소드로부터 getSchoolName()을 자기 호출했습니다.
일반적인 생각대로라면 getSchoolName()은 트랜잭션이 적용된 상태이기에 영속성 컨텍스트가 존재해야 하고 영속성 컨텍스트가 존재한다면 JPA를 통해 조회 혹은 저장 시에 1차 캐시에 보관되어야 한다는 것을 알 수 있습니다.
System.out.println(em.contains(savedStudent));
하지만 위의 코드를 통해 엔티티 매니저(영속성 컨텍스트의 주체)가 savedStudent를 1차 캐시로 보관하고 있는지 살펴봤지만 false가 떨어졌습니다.
그 말은 트랜잭션이 존재하지 않아 영속성 컨텍스트도 존재하지 않기 때문 입니다
그러므로 기능이 제대로 동작하고 있지 않아 savedStudent가 존재하지 않는다는 뜻 입니다.
이 문제에 대한 자세한 설명을 위해 제대로 적용이 되는 케이스부터 알아보겠습니다.
② 트랜잭션이 적용되는 예제 - 부모 트랜잭션 전파
@Transactional
public void findAllSchoolNames(){
getSchoolName();
}
이제 트랜잭션이 적용되는 예제를 하나씩 살펴보겠습니다.
findAllSchoolNames()에도 @Transactional을 붙이는 경우엔 트랜잭션이 하위 메소드까지 전파됩니다.
그러므로 getSchoolName() 메소드도 부모 트랜잭션의 영향을 받습니다.
그렇기에 영속성 컨텍스트가 존재해서 true라는 값이 나옵니다.
부모로부터 트랜잭션을 전파받기 때문에 getSchoolName()에 @Transactional을 지우더라도 트랜잭션 기능이 동작해서 역시 true가 나옵니다.
이런 경우엔 부모 메소드에서 예외가 발생하더라도 하위 메소드의 작업까지 롤백됩니다.
참고로 트랜잭션이 동일한 메소드들끼리에서 어떤 메소드든(하위 메소드, 상위 메소드 상관없이) 예외가 발생하면 catch로 잡든 뭘하든 트랜잭션에 롤백 마크가 적용이 되어 하위 메소드, 상위 메소드 내에 쿼리 전체에 대한 롤백이 이루어집니다.
③ 트랜잭션이 적용되는 예제 - REQUIRES_NEW 전파 방식
getSchoolName()에 부모로부터 받는 트랜잭션을 이어받지 않고 새로 트랜잭션을 시작하도록 REQUIRES_NEW를 작성했습니다.
System.out.println(em.getDelegate());
위의 코드를 통해 현재 엔티티 매니저(Session) 주소를 알 수 있습니다.
콘솔에 엔티티 매니저의 주소가 서로 동일하게 출력이 되고 있습니다.
엔티티 매니저의 주소가 동일 하다는 것은 엔티티 매니저가 관리하는 영속성 컨텍스트도 동일하다는 뜻 입니다.
마치 부모와 자식이 하나의 트랜잭션을 사용하고 있는 것 같습니다.
3가지 케이스 분석 결과
위의 3가지 케이스를 살펴봤습니다.
첫번째는 올바르게 적용되지 않는 예제이고 두번째와 세번째는 올바르게 적용되는 예제입니다.(물론 세번째는 REQUIRES_NEW를 사용했기 때문에 의도와는 다르지만)
올바르게 동작하지 않는 경우는 자기 호출된 메소드에서 트랜잭션이 최초로 실행되는 경우 즉, 자기 호출 시에 처음으로 새로운 트랜잭션이 발생하는 경우에 한해서만 동작하지 않습니다.
이유는 아래와 같습니다.
@Transactional annotations is proxy, which allows for interception of calls through the proxy only. Local calls within the same class cannot get intercepted that way.
@Transactional 어노테이션은 프록시이며 프록시를 통해 들어오는 호출을 가로채는 것만을 허용한다.
같은 클래스 내에서는 가로채는 것을 할 수 없다.
즉, 이 말은 외부에서 프록시 객체로 직접 들어오는 경우인 외부 호출에만 AOP 기반의 코드가 작동을 하고 내부 호출인 경우에는 AOP 기반의 코드가 작동을 안한다는 뜻 입니다.
그러니까 위의 3가지 케이스에서 내부 호출(Internal method calls)된 메소드들은 모두 @Transactional이 걸려있어도 @Transactional 자체가 무시된다는 뜻입니다.
첫번째 케이스는 @Transactional이 아예 없는 것으로 판단되어서 문제가 생겼던 것입니다.
나머지 2개는 내부 호출된 메소드들의 @Transactional이 없다고 봐도 무방하고 그냥 단순히 부모 메소드의 트랜잭션을 물려받은 케이스들입니다.
테스트 코드에 @Transactional을 적용해서 테스트 코드에도 엔티티 주소를 찍어보면 테스트 코드로부터 트랜잭션을 전파하기 때문에 각 메소드의 엔티티 매니저 세션 주소가 모두 같은 것을 확인할 수 있습니다.
어떤 상황에서 이런 문제가 발생하는가?
우선 1차적으로는 프록시 객체에서는 자기 호출을 하지 않는다라고 생각해야 됩니다.
사이드 이펙트의 우려가 있을만한 행동을 하지 않는 것이지요.
그런데도 불구하고 일부 개발자들은 자기 호출을 했어야 하는 경우가 있었을겁니다.
보통 이러한 문제는 개발자가 비즈니스 로직을 만들 때 일부 메소드에 대해 커밋, 롤백을 강제하거나 제한하고 싶을 때 나타나는 문제일 수 도 있습니다. 또는 메소드 기능 분리를 하는 경우에 쉽게 발생하기도 합니다.
만약 레거시 Spring xml 기반의 Transaction 설정을 했다면 모든 서비스 코드가 트랜잭션을 모두 사용하기에 이런 일이 발생할 가능성이 없습니다.
하지만 Spring Boot에선 보통 개발자가 @Transactional을 직접 작성하는 선언적 트랜잭션 방식을 따르다보니 약결합의 장점을 가져오면서 더불어 이러한 문제의 단점도 같이 생길 수 있습니다.
실제로 커밋, 롤백에 관한 사항이 오류인 상태로 실제 운영 서버에 배포되게 되면 큰 장애를 일으킬 수 있는 문제입니다.
해결 방법
만약에 새로운 트랜잭션으로 만들고 싶다면?
① 외부 클래스에 메소드를 두어 다른 트랜잭션으로 시작하기.
외부 클래스에 메소드를 두어 해당 메소드에 REQUIRES_NEW를 붙이면 아예 다른 Bean으로 취급되어 새로운 트랜잭션이 실행됩니다.
그럼 부모 트랜잭션에서 Exception이 발생하더라도 하위 메소드는 부모 트랜잭션의 영향을 받지 않기 때문에 롤백되지 않습니다.
사실 대부분의 고민은 여기서 나오는 것 같습니다.
부모 메소드는 트랜잭션에 예외가 터지면 롤백되길 바라고 하위 메소드는 커밋되길 의도하는 형태에서 시작하니까요.
혹은 그 반대일 수도 있구요. 보통 이런 경우에 REQUIRES_NEW나 REQUIRES_NESTED를 사용합니다.
그리고 그러한 상황에선 스프링 진영의 공통 Transaction을 사용하는 것보다 스프링부트 진영에서의 수동 Transaction을 사용하는 것이 바람직합니다.
예를 들어 주식 혹은 은행 프로그램은 트랜잭션의 결과와 상관없이 모든 행위 자체가 데이터베이스에 기록되어야 할 때 사용합니다. 어떻게 보면 데이터베이스 '감사 시스템'이라고 볼 수 있습니다. 은행으로 치면 원장(거래를 전부 기록하는 장부)가 모두 기록되어야 합니다.
예상치 못한 행위 자체도 기록되게 하는 것이니까요. 만약 해당 목적이 아니라 개발자의 편의성에 따라 사용한다면 그 사용은 지양해야 합니다. 그런 행위들이 모이고 모이면 데이터베이스의 일관성에 대해 예상치 못한 결과를 불러올 수가 있습니다.
그리고 개인적으로는 해당 프로세스가 민감하고 많다면(특히 금융권이라면) 트랜잭션 분리를 아예 새로운 API로 구축하는 게 더 안정적일 거라고 생각합니다.
모든 개발자가 해당 소스에 접근하고 수정할 수 있다는 것은 대형사고로 이어질 수 있기 때문입니다.
의도하지 않은 자기 호출 막기
위의 케이스를 보시다시피 자기 호출 시 의도치 않은 결과가 나타나는 것은 극히 드뭅니다. 왜냐면 대부분의 서비스를 개발할 때는 @Transactional을 당연히 붙여서 사용하기 때문입니다. 하지만 객체지향적 시선으로 바라본다면 해당 문제는 여러 개발자들끼리 개발하는 환경에서는 충분히 일어날 수도 있는 환경입니다. 개발자가 의도하지 않았더라도 자기 호출 발생이 일어나는 것을 막기 위해 아래와 같은 방법을 이용할 수 있습니다.
① 자기호출을 사용하려면 하위 메소드에는 항상 private으로 사용하기
private을 사용하면 프록시 객체는 해당 메소드를 참조할 수 없어 @Transactional을 붙이는 것 자체를 컴파일 에러로 판단합니다. 그렇기에 private으로 쓰는 것이 트랜잭션의 올바른 기능 수행을 보장할 수 있습니다. 왜냐면 항상 부모메소드로부터 Transaciton을 물려받아야 하는 구조이기 때문입니다.
하지만 이와 같이 사용하는 경우에도 Warning이 발생하기는 합니다. 어찌됐든 내부 호출은 하지말란 식으로 경고를 하는 듯 한데요.
@Transactional self-invocation (in effect, a method within the target object calling another method of the target object) does not lead to an actual transaction at runtime
인텔리제이 2023.2 버전에서부터 정상적 호출인데 Warning이 왜 뜨냐는 Issue로 인해 개선되었습니다.
잡담하나 하자면 private으로 내부 호출하는 것은 정상 처리인데, 개발자가 인텔리제이에서 Warning이 뜨는 것 자체가 사실 "의도한 것"이며 "당연한 것"이라고 인지했기 때문에 늦게 이슈가 올라온게 아닌가 싶습니다.
물론 저도 그렇구요. "Warning이 뜨니까 내부 호출 말고 죄다 외부 호출로 바꾸라는 뜻인가보다"라고 인식했었거든요.
프로그래밍 세계에서는 당연하고 완전한 것은 없다라는 인식을 다시 되새김질 할 수 있던 좋은 기회였습니다.
이와 관련해 "인텔리제이 업데이트로 느낀 엔지니어의 가치관"라는 글을 올렸으니 한번 확인해보시는 것도 좋습니다.
③ Non Proxy Pattern을 이용한 AspectJ 사용하기
실제로 이 모든 문제는 프록시 패턴으로 인한 문제이기 때문에 프록시 패턴을 제외한 AspectJ를 사용하는 것이 하나의 방법입니다.
결론
이 글을 지금 한 5번은 넘게 수정을 했던 것 같은데 두서가 없었던 탓인지 저조차도 가끔 까먹어서 결론을 추가하려고 합니다.
1. 프록시는 외부 호출할 때만 가로챌 수 있다. 즉, @Transactional은 외부 호출에서만 사용이 가능하다.
2. @Service에서 @Transactional이 붙은 것은 프록시 객체의 메소드로 생성한다.
이 부분에 대해서는 private 메소드를 하나 선언하고 @Transactional을 붙여보면 프록시 객체로 상속 받아서 메소드를 가져가질 못하므로 바로 컴파일 에러가 생성되는 것을 알 수 있다.
@Transactioanl은 프록시 객체의 메소드로 사용되어야 하기 때문이다.
@Transactional이 없는 private 메소드는 프록시 객체의 메소드로 활용되지 않는다.
참고
여태까지 프록시 객체에 대해 설명했는데 프록시 객체가 어떻게 생겼는지 궁금해졌습니다.
참고로 프록시 객체와 원본 객체는 동일한 인터페이스를 구현하도록 설계되도록 만들어집니다.
프록시 객체에는 원본 객체를 사용하기위해 컴포지션 방식으로 원본 객체를 프록시 객체에 주입해서 사용합니다.
그러므로 프록시 객체에서 다른 메소드를 호출 할 경우 해당 메소드가 원본 객체에 있으면 프록시 객체가 아닌 원본 객체를 직접 호출합니다.
위부분을 잘 모르겠다면 "상속보단 컴포지션을 사용하라"글을 확인해봅시다.
public class StudentServiceProxy implements StudentService {
private StudentService target; // 원본 객체를 참조하는 멤버 변수
public StudentServiceProxy(StudentService target) {
this.target = target;
}
@Override
public void method1() {
target.method1();
}
@Override
@Transactional
public void method2() {
target.method2();
}
@Override
public void method3() {
target.method3();
}
}
'📖 ORM' 카테고리의 다른 글
Kotlin에서 JPA 사용법 (0) | 2023.06.21 |
---|---|
Fetch Join 사용 시 조건문(Condition) 올바르게 사용하기 - 실습으로 배우는 JPA 4편 (0) | 2022.11.03 |
findAll()에 관한 N+1 테스트 - 실습으로 배우는 JPA 2편 (0) | 2022.06.30 |
@OnDelete와 CascadeType.ALL, orphanRemoval 속성 (0) | 2022.06.24 |
@ManyToOne과 @OneToMany로 배우는 JPA 기초 사용법 - 실습으로 배우는 JPA 1편 (2) | 2022.06.20 |