지나공 : 지식을 나누는 공간

@Transactional 분리가 안되는 이유 / 실험을 통해 트랜잭션 전파 유형과 Spring AOP 이해 본문

우당탕탕 삽질기

@Transactional 분리가 안되는 이유 / 실험을 통해 트랜잭션 전파 유형과 Spring AOP 이해

해리리_ 2021. 12. 10. 01:30

이번 포스팅은 올해 5월에 올렸던 포스팅에 대한 후속 포스팅이다.

https://eocoding.tistory.com/74

 

@Transactional에서 JPA로 delete한 뒤 insert가 안될 때, duplicate entry 에러가 날 때 해결하기

일단 원인과 해결 방법부터 적고 내 사례와 해봤던 시도들을 구체적으로 적는 건 다음 포스팅으로 넘기려고 한다. Spring Data JPA 사용 중에 데이터를 삭제한 뒤 추가하려고 했더니 duplicate entry 에

eocoding.tistory.com

댓글로도 후속 포스팅을 궁금해하신 분이 계셨는데 그동안 인턴도 하고 취업 준비도 하느라 블로그 포스팅을 못 쓰고 있었다. 대체 얼마만의 포스팅이지 ㅎㅎ

 

그럼 이제 본론을!

목차는 아래와 같다.

1. 트랜잭션 내에서 왜 저 순서로 실행이 됐었는지? (지난 시간 리뷰)

2. 그렇다면 대체 왜 저 순서로 실행되도록 설계가 된 건지? (면접 때 들은 질문에 대한 답변 찾기)

3. "두 쿼리를 꼭 한 트랜잭션 내에서 같이 처리해야 될까? 메소드 분리해서 트랜잭션 분리시키면 안되나?" 로 시작한 여러 테스트와 이를 통한 트랜잭션 전파 유형 & AOP 이해.

4. saveAll() 과 save()를 반복문 돌리는 것의 차이


지난 글에서는 @Transactional을 통해 하나의 트랜잭션으로 로직을 묶고 그 안에서 delete와 insert 쿼리를 순차적으로 실행했을 때 의도한 바와 달리 insert가 delete 쿼리보다 먼저 실행되는 문제와 해결책에 대해 다뤘다.

 

1. 트랜잭션 내에서 왜 저 순서로 실행이 됐었는지? (지난 시간 리뷰)

그때의 원인을 정리하자면

1) 영속성 컨텍스트의 쓰기 지연 성질로 인해 한 @Transactional 내의 쿼리문들이 마지막에 한번에 몰아서 실행됨.

2) Hibernate의 쿼리 실행 인터페이스의 구현체(메소드)가 자기만의 쿼리 실행 우선순위가 있음.

즉, delete 코드가 더 위에 있어도 실행 순서는 항상 insert가 먼저임. 

 

이전 포스팅에서 실제 레퍼런스를 가져와서 확인했었다. 못 믿겠다면 이전 포스팅에 있는 레퍼런스를 직접 보자!

 

2. 그렇다면 대체 왜 저 순서로 실행되도록 설계가 된 건지?
(면접 때 들은 질문에 대한 답변 찾기)

최근 면접에서 이 썰을 풀다가 들었던 질문 중에 당황스러운 게 있었다.

"잘 들었어요, 근데 왜 그 말씀하신 hibernate 구현체 메소드는 insert를 항상 먼저 실행하게 만들었을까요?"

나는 잘 모르겠어서 추측컨대 삽입되지 않은 데이터에 대한 delete 쿼리가 실행됐을 때 데이터 무결성이 깨질테니 이걸 막기 위함이라고 생각한다고 했다. 댓글로 주시는 다양한 의견은 환영입니다.

 

그래서 다시 한번 레퍼런스를 읽어봤는데..... 역시 레퍼런스는 친절해. 다 적혀 있었다;; 안 본 내가 바보지 ㅠ

레퍼런스에 적힌 메소드 설명. 사진 확대하세요~

앞선 포스팅에서 말했듯이, 트랜잭션의 쓰기 지연 저장소에 있는 SQL문들이 한번에 실행될 때 performExecutions 라는 메소드가 실행되는데, 이에 대한 설명을 보면 아래와 같다.

 

Execute all SQL (and second-level cache updates) in a special order so that foreign-key constraints cannot be violated: 

 

외래케 제약 조건을 지키려고!....였구나.

 

※참고 : 데이터 무결성과 외래키 제약조건에 대해 펼쳐보세요.

더보기

1. 널 무결성 : 릴레이션의 특정속성 값이 Null이 될 수 없도록 하는 규정

2. 고유 무결성 : 릴레이션의 특정 속성에 대해서 각 튜플이 갖는 값들이 서로 달라야 한다는 규정

3. 참조 무결성 : 외래키 값은 Null이거나 참조 릴레이션의 기본키 값과 동일해야 한다는 규정 즉 릴레이션은 참조할 수 없는 외래키 값을 가질 수 없다는 규정

4. 도메인 무결성 : 특정 속성의 값이, 그 속성이 정의된 도메인에 속한 값이어야 한다는 규정

5. 키 무결성 : 하나의 테이블에는 적어도 하나의 키가 존재해야 한다는 규정

 

외래키 제약 조건이란?

외래키를 갖는 테이블에 데이터를 삽입할 때는 기준 테이블(외래키에 해당되는 테이블)에 실제로 존재하는 데이터만 참조해야 한다. 없는 데이터를 참조해서 외래키로 써먹으면 안된다고. 그래서 항상 insert가 먼저 되게 하는 것 같다.

 

3. "두 쿼리를 꼭 한 트랜잭션 내에서 같이 처리해야 될까? 메소드 분리해서 트랜잭션 분리시키면 안되나?" 로 시작한 여러 테스트와 이를 통한 트랜잭션 전파 유형 & AOP 이해.

delete를 한 다음에 insert를 처리하는 게 목적이었다. 근데 이걸 @Transactional이 붙은 한 메소드에서 같이 처리하면 순서가 뒤바뀐다고 하니... "메소드를 분리하고 각각 다르게 @Transactional을 붙이면 안되나? 그럼 트랜잭션이 분리되니까 실행 순서도 우리가 의도한 대로 delete가 먼저되지 않을까?" 라고 생각했다.

 

그래서 트랜잭션을 나눠봤다. 그런데 아무런 속성 없이 나누면 두 트랜잭션이 분리되지 않는다. 그 이유는 트랜잭션의 전파유형 때문이다. 

 

전파 유형이란 트랜잭션이 두개 이상 중첩됐을 때 부모의 트랜잭션에 포함되게 할지 아니면 새 트랜잭션을 열게 할지 등을 설정하는 속성이다. 고립 레벨은 (Isolation Level) 두 트랜잭션이 동시에 처리될 때 한 트랜잭션이 다른 트랜잭션에서 변경한 데이터를 조회하도록 허용할지 말지를 결정하는 속성이다. 

 

@Transactional(propagation = Propagation.REQUIRES_NEW) 와 같이 어노테이션으로 설정할 수 있다.

속성 의미
REQUIRED 기본값. 부모 트랜잭션이 존재하면 거기에 합류되고, 부모 트랜잭션이 없다면 새 트랜잭션을 생성한다. 중간에 롤백이 발생하더라도 부모부터 자식까지 모두 하나의 트랜잭션이어서 함께 롤백된다.
REQUIRES_NEW 부모가 있든 없든 무조건 새로운 트랜잭션을 시작한다. 각각의 트랜잭션은 따로인거라 여러 개 중에 하나가 롤백되도 서로 영향을 주지 않는다.

속성이 몇개 더 있는데 여기선 이렇게 두 개만 쓰일 거라 따로 정리안하겠다. 그럼 이제 트랜잭션을 분리해보자!

 

상황1.

트랜잭션을 분리하고 두 트랜잭션이 중첩될 경우 requires_new를 통해 두 개의 트랜잭션이 각각 시작되게 한다.

단, 첫 번째 트랜잭션에서 호출할, 또다른 트랜잭션으로 감싸진 메소드는 '같은 클래스'의 메소드다. 

 

상황2.

트랜잭션을 분리하고 두 트랜잭션이 중첩될 경우 requires_new를 통해 두 개의 트랜잭션이 각각 시작되게 한다.

단, 첫 번째 트랜잭션에서 호출할, 또다른 트랜잭션으로 감싸진 메소드는 '다른 클래스'의 메소드다. 

 

 

 

 

상황1.

 

결과 : 트랜잭션1 시작(begin) -> transactionTest() 메소드 실행 -> 트랜잭션2가 새로 시작되지 않고 트랜잭션 1에 합쳐짐 -> test() 메소드 실행 -> 트랜잭션1 종료(commit)

 

나는 분명히 트랜잭션을 나눴고, propagation 유형도 REQUIRES_NEW로 했다. 그럼 새 트랜잭션이 시작되어야 하는데!! 시작이 안되고.... 한 트랜잭션으로 묶였다. 같은 클래스의 메소드에서는 전파유형이 안 먹힌다는 말이다. 왜 그런지는 상황2를 보고 다시 보자.

 

참고로 transaction begin 로그 찍는 건 application.yaml 파일에서 설정했다.

 

 

상황2.

결과 : 트랜잭션1 시작(begin) -> transactionTest() 메소드 실행 -> 트랜잭션2가 새로 시작(begin) -> test() 메소드 실행 -> 트랜잭션2 종료(commit) -> 트랜잭션1 종료(commit)

 

다른 클래스의 메소드를 실행했더니 트랜잭션이 잘 분리됐다. 이 현상의 원인에 대해 알아보려고 한다.

 

일단 트랜잭션이 메소드를 감싸는 원리부터 알아보자. 트랜잭션이 Spring AOP를 사용하는 대표적인 사례다.

public class Main {

    public static void main(String[] args) {
        Pojo pojo = new SimplePojo();
        // this is a direct method call on the 'pojo' reference
        pojo.foo();
    }
}

public class Main {

    public static void main(String[] args) {
        ProxyFactory factory = new ProxyFactory(new SimplePojo());
        factory.addInterface(Pojo.class);
        factory.addAdvice(new RetryAdvice());
        factory.setExposeProxy(true);

        Pojo pojo = (Pojo) factory.getProxy();
        // this is a method call on the proxy!
        pojo.foo();
    }
}

 

위처럼 일반 방식으로 메소드를 호출하면 그냥 그 즉시 객체에 접근한다. 그런데 프록시를 적용하면 pojo.foo라는 타겟을 요청하긴 하지만 DI를 통해 프록시가 주입되고 메소드 호출 시 프록시 내에서 모든 메소드 호출에 대한 interceptor들을 위임받아 설정한 Advice를 실행시킬 수 있게 된다. 이걸 실행한 후에 실제 foo를 호출한다.

 

@Transactional이 적용되는 원리도 마찬가지다.

  1. 본 객체의 메소드 호출 → 프록시가 호출됨
  2. 프록시가 보조 업무 처리함
  3. 보조업무 끝나면 실제 구현 함수를 호출해서 주요 업무 처리
  4. 제어권이 다시 프록시로 돌아와서 나머지 보조 업무를 처리함
  5. 모든 처리 끝나면 메소드를 호출한 곳으로 반환됨.

 

결론

보다보면 알아차렸을 것이다. 프록시는 객체를 단위로 감싸진다. 프록시가 객체 단위로 감싸지기 때문에 상황1처럼 같은 객체 내의 메소드를 호출하는 것은 프록시를 새로 타지 않는 것. 같은 클래스 내의 메소드를 다른 트랜잭션을 켜서 호출해봤자 이미 둘의 프록시 객체가 같으니까 프록시를 새로 안 타고, 그렇기 때문에 @Transactional의 보조 업무가 시작되지 않으니 트랜잭션도 새로 열리지 않는 것이다. 반면 상황2처럼 다른 클래스의 메소드를 다른 트랜잭션을 켜서 호출하면 각각의 @Transactional이 인지하는 보조업무가 다르다. 둘의 프록시 객체가 다르니까! 그래서 전파유형 속성이 잘 먹히고 둘이 각각의 다른 트랜잭션을 생성하는 것이다.

 

(이는 공부한 내용으로 실험 후 추측성 정리를 한 것이니.. 많은 분들이 댓글로 의견을 주시면 환영입니다.)

 

추가로, saveAll과 save의 성능차이가 나는 이유도 위 내용과 관계가 깊다.

 

 

 

사진자료 출처 : https://taes-k.github.io/2019/05/15/spring-aop/

728x90
Comments