왜 DB 저장 서비스를 따로 만들었을까? 2편(실전편)

이승환·2026년 8월 19일

실제 구현을 통해 확실히 눈으로 봐보자

1편에서는 외부 데이터 처리와 DB 저장 서비스를 분리하면서, 서비스의 경계가 트랜잭션 프록시의 호출 경계가 될 수 있다는 점을 정리했다. 이번 글에서는 그 판단이 실제로 맞는지 네 가지 호출 방식으로 검증한다.

1편 다시 보기

안보신 분들이 있으면 1편을 먼저 보고 오시면 더 이해가 쉬울 것입니다!
1편 다시 보기

1. 정말 호출 경로에 따라 결과가 달라질까?

앞선 글에서 같은 객체 내부의 @Transactional 메서드를 호출하면 Spring 프록시를 통과하지 않는다는 것을 살펴봤다. 이론은 이해했지만 다음과 같은 궁금증이 남았다.

실제로 같은 INSERT 코드를 실행해도 호출 방식에 따라 롤백 결과가 달라질까?

이를 확인하기 위해 작은 Spring Boot 프로젝트를 만들었다.
비교할 방식은 네가지다.
1. 다른 Bean의 @Transactional 메서드 호출
2. 같은 객체 내부의 @Transactional 메서드 호출
3. 트랜잭션 메서드를 별도 Bean으로 분리
4. TransactionTemplate으로 트랜잭션 범위 직접 지정

  • 모든 시나리오는 동일하게 레코드 두 건을 저장한 뒤 강제로 IllegalStateException을 발생
  • 코드의 차이는 트랜잭션을 시작하는 방법과 호출 경로뿐이다.

2. 실험 환경

이번 실험은 트랜잭션 프록시 동작만 확인하는 것이 목적이므로 구성을 최대한 단순하게 만들었다.

  • Java 21
  • Spring Boot
  • Spring Web
  • Spring JDBC
  • JdbcTemplate
  • H2 인메모리 DB

JPA나 Spring Data Repository는 사용하지 않았다. INSERT가 언제 실행되고 최종 DB에 몇 건이 남는지 명확하게 보기 위해 JdbcTemplate을 사용했다.

CREATE TABLE TX_RECORD (
    ID BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    SCENARIO VARCHAR(50) NOT NULL,
    MESSAGE VARCHAR(200) NOT NULL,
    CREATE_DATE TIMESTAMP NOT NULL
);
  • 각 시나리오가 실행되기 전에는 기존 데이터를 삭제한다. 이후 INSERT 두 번과 예외 발생을 수행하고 Controller에서 예외를 받은 뒤 DB를 다시 조회한다.

3. 트랜잭션 활성화 여부를 어떻게 확인할까?

단순히 @Transactional이 붙어 있는지만 봐서는 실제 트랜잭션이 시작됐는지 알 수 없다.
그래서 두 가지 값을 로그로 확인했다.

현재 객체가 프록시인지 확인

AopUtils.isAopProxy(bean);

현재 스레드에 실제 트랜잭션이 활성화 됏는지 확인

TransactionSynchronizationManager
.isActualTransactionActive();

로그에서는 다음 두 값을 중심으로 확인했다

  • beanIsAopProxy=true: 주입받은 Bean이 Spring AOP 프록시라는 의미
  • txActive=true: 현재 코드가 실제 트랜잭션 안에서 실행되고 있다는 의미

중요한 것은 Bean이 프록시인지와 현재 메서드에 트랜잭션이 활성화 됏는지는 서로 다른 질문이라는 점.
Bean 자체가 프록시여도 호출한 메서드에 트랜잭션 Advice가 적용되지 않을 수 있다.

실험 시작

실험 1. 외부에서 @Transactional 메서드 호출

첫 번째는 가장 일반적인 방식이다.
Controller가 ExternalTransactionService의 @Transactional 메서드를 호출한다.

@Service
public class ExternalTransactionService {

    private final TxRecordRepository txRecordRepository;

    @Transactional
    public void execute() {
        log.info(
            "scenario=external txActive={}",
            TransactionSynchronizationManager.isActualTransactionActive()
        );

        txRecordRepository.insert(
            "external",
            "external insert 1"
        );

        txRecordRepository.insert(
            "external",
            "external insert 2"
        );

        throw new IllegalStateException(
            "external scenario failure"
        );
    }
}

호출자는 Spring 컨테이너에서 주입받은 ExternalTransactionService를 사용한다.
따라서 호출은 실제 객체로 바로 전달되지 않고, 먼저 Spring 트랜잭션 프록시를 통과한다.

실험 1의 결과

호출

curl -X POST http://localhost:8080/demo/external

결과

  • 두 건의 INSERT가 실행됏지만 같은 트랜잭션 안에서 예외가 발생했다. 프록시는 예외를 확인한 뒤 트랜잭션을 롤백한다.
  • 따라서 최종 DB에는 아무 데이터도 남지 않는다.

실험 2. 같은 객체 내부에서 @Transactional 메서드 호출

두 번째 실험이 이번 글의 핵심이다.
외부에서 호출하는 excute()에는 @Transactional이 없다. 대신 같은 클래스의 innerTransactional()에 @Transactional을 선언한다.

@Service
public class SelfInvocationService {

    private final TxRecordRepository txRecordRepository;

    public void execute() {
        log.info(
            "scenario=self-invocation outer txActive={}",
            TransactionSynchronizationManager
                .isActualTransactionActive()
        );

        this.innerTransactional();
    }

    @Transactional
    public void innerTransactional() {
        log.info(
            "scenario=self-invocation inner txActive={} thisIsAopProxy={}",
            TransactionSynchronizationManager
                .isActualTransactionActive(),
            AopUtils.isAopProxy(this)
        );

        txRecordRepository.insert(
            "self-invocation",
            "self-invocation insert 1"
        );

        txRecordRepository.insert(
            "self-invocation",
            "self-invocation insert 2"
        );

        throw new IllegalStateException(
            "self-invocation scenario failure"
        );
    }
}

SelfInvocationService에는 @Transactional 메서드가 존재하기 때문에 컨테이너에 주입된 Bean 자체는 프록시다.
하지만 Controller가 호출한 excute()에는 @Transactional이 없다. 프록시는 execute() 호출에 트랜잭션을 시작하지 않고 실제 객체로 호출을 전달한다.
이후 실제 객체에서 실행되는 this.innerTransactional()은 프록시를 다시 통과하지 않는다.


여기서 중요한 점은 innerTransactional() 메서드가 실행되지 않는 것이 아니라는 점이다.
메서드는 정상적으로 실행된다. INSERT도 실행되고 예외도 발생한다.
다만 호출이 프록시를 통과하지 않았기 때문에 innerTransactional()에 선언한 @Transactional 설정이 적용되지 않는다.

실험 2의 결과

호출

curl -X POST http://localhost:8080/demo/self-invocation

결과

  • 결과에서 볼 수 있듯이 thisIsAopProxy가 false인 것을 알 수 있다.
  • 즉, this.innerTransactional() 호출은 트랜잭션 인터셉터를 지나지 않는다.

그렇다면 내부 호출이면 트랜잭션이 무조건 없는가?

Self-invocation을 설명할 때 다음과 같이 단순화하기 쉽다.

내부 메서드 호출에서는 트랜잭션이 동작하지 않는다.

하지만 이 표현은 조금 부정확하다.
만약 외부에서 호출한 메서드에 이미 @Transactional이 선언돼 있었다면?
-> 내부 메서드도 기존 트랜잭션 안에서 실행된다.

@Transactional
public void execute() {
    this.innerTransactional();
}

이 경우 excute()를 외부에서 호출할 때 트랜잭션이 시작된다. innerTransactional()의 어노테이션이 프록시에 의해 새롭게 적용되지는 않지만, 메서드 코드는 이미 시작된 트랜잭션 안에서 실행된다.

그러므로 정확한 표현은 다음과 같다.

Self-invocation에서는 내부 메서드에 선언된 @Transactional 설정이 프록시에 의해 새롭게 적용되지 않는다.

실험 3. 트랜잭션 메서드를 별도 Bean으로 분리

Self-invocation 문제를 피하는 가장 명확한 방법은 트랜잭션 책임을 다른 Bean으로 분리하는 것이다.
이번 실험에서는 다음 두 클래스로 나눴다.

  • SeparateBeanFacadeService: 전체 흐름 담당(@Transactional O)
  • TransactionalWorker: 트랜잭션이 필요한 DB 작업 담당(@Transactional X)
@Service
public class SeparateBeanFacadeService {

    private final TransactionalWorker transactionalWorker;

    public void execute() {
        log.info(
            "scenario=separate-bean facade txActive={} workerIsAopProxy={}",
            TransactionSynchronizationManager
                .isActualTransactionActive(),
            AopUtils.isAopProxy(transactionalWorker)
        );

        transactionalWorker.execute();
    }
}
@Service
public class TransactionalWorker {

    private final TxRecordRepository txRecordRepository;

    @Transactional
    public void execute() {
        log.info(
            "scenario=separate-bean worker txActive={}",
            TransactionSynchronizationManager
                .isActualTransactionActive()
        );

        txRecordRepository.insert(
            "separate-bean",
            "separate-bean insert 1"
        );

        txRecordRepository.insert(
            "separate-bean",
            "separate-bean insert 2"
        );

        throw new IllegalStateException(
            "separate-bean scenario failure"
        );
    }
}

Facade에서 Worker를 호출하면 같은 객체 내부 호출이 아니라 다른 Spring Bean을 호출하게 된다.
따라서 호출은 TransactionalWorker 앞의 프록시를 통과한다.

실험 3의 결과

호출

curl -X POST http://localhost:8080/demo/separate-bean

결과

  • Facade에서는 트랜잭션이 활성화 되지 않는다.
  • Worker 프록시를 통과하는 순간 트랜잭션이 시작되고, DB 작업이 끝나는 지점에서 커밋 혹은 롤백된다.

이 구조는? 1편에서 고민했던 서비스 분리 구조와 동일

Refresh Service
→ API 호출
→ 파싱
→ 검증
→ 데이터 변환
→ Data Save Service 외부 호출
→ 트랜잭션 시작
→ DB 반영
→ 커밋 또는 롤백

별도 Bean 분리는 단순히 Self-invocation을 피하기 위한 우회 방법이 아니다.
이렇게 외부 데이터 처리와 DB 저장 책임을 분리하고, 트랜잭션이 필요한 구간을 하나의 서비스 경계로 표현한다.

실험 4. TransactionTemplate 사용

마지막은 선언적 트랜잭션 대신 프로그래밍 방식의 TransactionTemplate을 사용하는 방법이다.

@Service
public class TransactionTemplateService {

    private final TxRecordRepository txRecordRepository;
    private final TransactionTemplate transactionTemplate;

    public void execute() {
        log.info(
            "scenario=transaction-template before txActive={}",
            TransactionSynchronizationManager
                .isActualTransactionActive()
        );

        transactionTemplate.executeWithoutResult(status -> {
            log.info(
                "scenario=transaction-template inside txActive={}",
                TransactionSynchronizationManager
                    .isActualTransactionActive()
            );

            txRecordRepository.insert(
                "transaction-template",
                "transaction-template insert 1"
            );

            txRecordRepository.insert(
                "transaction-template",
                "transaction-template insert 2"
            );

            throw new IllegalStateException(
                "transaction-template scenario failure"
            );
        });
    }
}

이 서비스에는 @Transactional이 없다. 따라서 Service 자체가 트랜잭션 프록시일 필요도 없다.
대신 TransactionTemplate의 콜백 안으로 들어갈 때 트랜잭션이 시작된다.

실험 4의 결과

호출

curl -X POST http://localhost:8080/demo/transaction-template

결과

  • TransactionTemplate을 사용하면 같은 Service 안에서도 DB 작업 구간만 정확하게 트랜잭션으로 지정할 수 있다.
  • 프록시 호출 여부에 의존하지 않는다는 장점
  • 반면 업무 서비스가 Spring의 트랜잭션 API를 직접 사용하게 된다.
    • 트랜잭션의 시작과 종료를 세밀하게 제어해야 하는 경우에는 좋은 선택
    • 하지만 단순한 DB 저장 책임이라면 별도 Bean과 선언적 트랜잭션이 구조를 더 명확하게 표현이 가능하다.

네 가지 실험 결과

결과를 표로 정리하면 다음과 같다.

시나리오트랜잭션 시작 방식실제 txActive예외 후 DB
외부 Bean 호출@Transactional 프록시Service 내부 true0건
Self-invocation내부 메서드의 @Transactionalouter false, inner false2건
별도 BeanWorker의 @Transactional 프록시Facade false, Worker true0건
TransactionTemplate템플릿 콜백호출 전 false, 블록 안 true0건


네 시나리오 모두 INSERT 두 번과 IllegalStateException 발생이라는 조건은 동일했다.
결과를 바꾼 것은 @Transactional 어노테이션의 존재 여부만이 아니었다.

실제 실행 경로에서 트랜잭션이 시작됐는지가 결과를 결정했다.

마무리

처음에는 DB 저장 서비스를 별도로 만든 이유를 단순한 책임 분리라고 생각했다.
Self-invocation 문제를 떠올린 뒤에는 “프록시를 통과시키기 위해 서비스를 나눴구나”라고 이해했다.
그리고 직접 실험해 본 뒤에는 한 단계 더 나아가 다음과 같이 생각하게 됐다.

별도 서비스는 프록시를 통과하기 위해 억지로 만든 클래스가 아니다.
DB 저장이라는 책임과 트랜잭션의 경계를 같은 위치에 표현한 것이다.

이번 실험에서는 같은 INSERT와 같은 예외를 사용했지만 호출 방식에 따라 최종 결과는 0건과 2건으로 달라졌다. 결국 우리가 @Transactional을 이해할 때 가장 먼저 확인해야 할 것은 어노테이션의 위치가 아니다.

결국 트랜잭션을 이해할 때 가장 먼저 확인해야 할 것은 어노테이션의 위치가 아니라, 실제 트랜잭션이 어디에서 어떤 방식으로 시작되는가이다.

출처

profile
데이터를 쌓자

0개의 댓글