Spring + DB

선언적 트랜잭션(@Transactional) 내부 호출 주의

cwchoiit 2023. 12. 7. 16:49
728x90
반응형
SMALL
728x90
SMALL

 

스프링과 데이터베이스를 사용할 때 @Transactional 애노테이션을 사용해서 트랜잭션을 시작하고 종료하는데, 이 @Transactional 애노테이션을 사용하는 것을 '선언적 트랜잭션'이라고 한다. 근데 이 선언적 트랜잭션을 사용할 때 주의할 점이 있다. 그리고 이 주의사항은 너무너무너무 중요하다!

 

트랜잭션 프록시 객체

우선 @Transactional이 달려있는 클래스 또는 메소드를 스프링이 실행할 때 쭉 돌면서 찾는다. 찾으면 클래스 단위로 빈을 등록하는데 이 때, 등록되는 것은 실제 해당 객체가 아닌 '프록시'이다. 아래 그림을 보자.

 

위 그림처럼 실제 Service@Transactional 애노테이션이 클래스 단위로 붙든, 특정 메소드에 붙든 있기만 하면 해당 클래스를 프록시로 만든다. 그리고 그 프록시는 실제 Service를 상속받는다. 여기서 이 프록시가 스프링 컨테이너에 올라가기 때문에 애플리케이션이 실행하고 특정 지점에서 (컨트롤러가 됐던, 다른 서비스가 됐던) 이 Service를 주입받으면 그 주입받은 녀석은 프록시가 된다.

 

근데 해당 클래스에서 2개의 메소드 중 하나는 @Transactional이 붙고 하나는 붙지 않았다면 어떻게 동작할까?

그리고 붙지 않은 메소드가 붙은 메소드를 내부에서 호출한다면 트랜잭션이 동작할까?

 

선언적 트랜잭션 내부 호출 주의

다음 코드를 보자.

package com.example.springtx.apply;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@SpringBootTest
public class InternalCallV1Test {

    @Autowired
    CallService callService;

    @Test
    void printProxy() {
        log.info("callService class={}", callService.getClass());
    }

    @Test
    void internalCall() {
        callService.internal();
    }

    @Test
    void externalCall() {
        callService.external();
    }

    @TestConfiguration
    static class InternalCallV1TestConfig {

        @Bean
        CallService callService() {
            return new CallService();
        }
    }

    static class CallService {

        public void external() {
            log.info("call external");
            printTxInfo();
            internal();
        }

        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active = {}", txActive);
            boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
            log.info("tx readOnly = {}", readOnly);
        }
    }
}

 

 

이 코드에서 CallService 클래스를 유심히 봐야한다.

static class CallService {

    public void external() {
        log.info("call external");
        printTxInfo();
        internal();
    }

    @Transactional
    public void internal() {
        log.info("call internal");
        printTxInfo();
    }

    private void printTxInfo() {
        boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("tx active = {}", txActive);
        boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        log.info("tx readOnly = {}", readOnly);
    }
}

 

`external()` 메소드와 `internal()` 메소드가 있을 때, `internal()` 메소드에만 @Transactional 애노테이션이 달려있다. 그리고 `external()` 메소드에서 `internal()` 메소드를 호출한다. 이 땐 트랜잭션이 발동하지 않는다.

 

왜 그럴까? 순차적인 흐름으로 살펴보자.

1. 최초 `external()` 메소드가 호출될 때, @Transactional이 달려있지 않지만, `internal()` 메소드가 @Transactional이 달려있으므로 스프링은 프록시 객체 CallService$$CGLIB를 만든다.

2. 외부에서 이 CallService를 주입받으면 프록시 객체가 주입된다.

3. 외부에서 이 주입받은 CallService`external()` 메소드를 호출하면 프록시 객체의 `external()`이 호출된다.

4. 프록시 객체의 `external()`이 트랜잭션이 필요한지 확인하기 위해 실제 객체를 참조해서 확인한다.

5. 실제 객체에서 @Transactional이 붙지 않았기 때문에 트랜잭션은 동작하지 않는다.

6. 트랜잭션이 붙지 않은 상태에서 `external()` 메소드를 호출하는데 `external()` 메소드를 호출할 때 프록시는 자기가 참조하고 있는 실제 객체의 `external()` 메소드를 호출한다.

7. 실제 객체의 `external()` 메소드에서 `internal()` 메소드를 호출해봐야 프록시에서 트랜잭션을 실행하지 않은 상태이므로 여전히 트랜잭션은 실행되지 않는다. 

 

즉, 아래 그림이 위 흐름이라고 보면 된다.

 

결론은, `internal()`@Transactional 애노테이션이 있지만, 같은 클래스의 `external()`@Transactional이 없고, 최초에 호출된 건 `external()` 이기 때문에 트랜잭션을 적용하지 않은 상태에서 프록시 객체는 그저 실제 객체에게 `external()`을 위임한다. 그럼 실제 객체의 `external()`이 내부적으로 `internal()`을 호출해봤자 트랜잭션은 적용되지 않는것이다. 

 

그럼 이를 해결하려면 어떻게 할까? 클래스를 분리하는게 가장 간단한 방법이다.

 

클래스를 분리하여 내부 호출을 피하기

문제의 원인은 `external()`, `internal()` 메소드가 같은 클래스인데 `external()`@Transactional이 붙지 않고 `internal()`은 붙어있기에 스프링은 해당 클래스를 프록시 객체로 만들어 스프링 컨테이너에 올리고 실제로 클라이언트가 주입받는 것 역시 이 프록시 객체이다.

 

그래서 이 프록시 객체는 `external()`@Transactional이 붙지 않았기에 트랜잭션을 실행하지 않은 상태에서 실제 객체에게 위임한다. 실제 객체는 그대로 본인의 `internal()` 메소드를 호출하기 때문에 트랜잭션은 실행되지 않는다. 그럼 `external()``internal()`의 클래스를 분리하자.

 

@RequiredArgsConstructor
static class CallService {

    private final InternalService internalService;

    public void external() {
        log.info("call external");
        printTxInfo();
        internalService.internal();
    }
}

static class InternalService {
    @Transactional
    public void internal() {
        log.info("call internal");
        printTxInfo();
    }
}

 

자, 이제 이렇게 분리된 두 클래스는 어떤 차이를 보일까? `external()``internalService.internal()`을 호출하지만, `internalService.internal()`은 다른 클래스의 메소드이다. 사용하기 위해선 주입받아야 하고 주입받는건? InternalService$$CGLIB이다.

 

그럼 이 프록시 객체는 `internal()`@Transactional이 있는지 확인한 후 있기 때문에 트랜잭션을 실행한 상태에서 실제 객체에게 위임한다. 이렇게 문제를 해결할 수 있다.

 

그림으로 보면 좀 더 명확하다.

 

 

결론

앞으로 트랜잭션을 달았는데도 트랜잭션이 먹지 않았다면, 이런 현상이지 않을까를 고민해보자.

728x90
반응형
LIST

'Spring + DB' 카테고리의 다른 글

Index란? (DB)  (0) 2024.04.05
MyBatis  (4) 2023.12.06
JdbcTemplate  (0) 2023.12.06
Transaction, Auto Commit, Rollback, Lock  (0) 2023.11.30
[Spring/Spring Data JPA] @Transactional  (0) 2023.11.12