1편에서는 외부 데이터 처리와 DB 저장 서비스를 분리하면서, 서비스의 경계가 트랜잭션 프록시의 호출 경계가 될 수 있다는 점을 정리했다. 이번 글에서는 그 판단이 실제로 맞는지 네 가지 호출 방식으로 검증한다.
안보신 분들이 있으면 1편을 먼저 보고 오시면 더 이해가 쉬울 것입니다!
1편 다시 보기
앞선 글에서 같은 객체 내부의 @Transactional 메서드를 호출하면 Spring 프록시를 통과하지 않는다는 것을 살펴봤다. 이론은 이해했지만 다음과 같은 궁금증이 남았다.
실제로 같은 INSERT 코드를 실행해도 호출 방식에 따라 롤백 결과가 달라질까?
이를 확인하기 위해 작은 Spring Boot 프로젝트를 만들었다.
비교할 방식은 네가지다.
1. 다른 Bean의 @Transactional 메서드 호출
2. 같은 객체 내부의 @Transactional 메서드 호출
3. 트랜잭션 메서드를 별도 Bean으로 분리
4. TransactionTemplate으로 트랜잭션 범위 직접 지정

이번 실험은 트랜잭션 프록시 동작만 확인하는 것이 목적이므로 구성을 최대한 단순하게 만들었다.
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
);

단순히 @Transactional이 붙어 있는지만 봐서는 실제 트랜잭션이 시작됐는지 알 수 없다.
그래서 두 가지 값을 로그로 확인했다.
AopUtils.isAopProxy(bean);
TransactionSynchronizationManager
.isActualTransactionActive();
로그에서는 다음 두 값을 중심으로 확인했다
중요한 것은 Bean이 프록시인지와 현재 메서드에 트랜잭션이 활성화 됏는지는 서로 다른 질문이라는 점.
Bean 자체가 프록시여도 호출한 메서드에 트랜잭션 Advice가 적용되지 않을 수 있다.
첫 번째는 가장 일반적인 방식이다.
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 트랜잭션 프록시를 통과한다.

호출
curl -X POST http://localhost:8080/demo/external
결과

두 번째 실험이 이번 글의 핵심이다.
외부에서 호출하는 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 설정이 적용되지 않는다.
호출
curl -X POST http://localhost:8080/demo/self-invocation
결과

Self-invocation을 설명할 때 다음과 같이 단순화하기 쉽다.
내부 메서드 호출에서는 트랜잭션이 동작하지 않는다.
하지만 이 표현은 조금 부정확하다.
만약 외부에서 호출한 메서드에 이미 @Transactional이 선언돼 있었다면?
-> 내부 메서드도 기존 트랜잭션 안에서 실행된다.
@Transactional
public void execute() {
this.innerTransactional();
}
이 경우 excute()를 외부에서 호출할 때 트랜잭션이 시작된다. innerTransactional()의 어노테이션이 프록시에 의해 새롭게 적용되지는 않지만, 메서드 코드는 이미 시작된 트랜잭션 안에서 실행된다.
그러므로 정확한 표현은 다음과 같다.
Self-invocation에서는 내부 메서드에 선언된 @Transactional 설정이 프록시에 의해 새롭게 적용되지 않는다.
Self-invocation 문제를 피하는 가장 명확한 방법은 트랜잭션 책임을 다른 Bean으로 분리하는 것이다.
이번 실험에서는 다음 두 클래스로 나눴다.
@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 앞의 프록시를 통과한다.

호출
curl -X POST http://localhost:8080/demo/separate-bean
결과

Refresh Service
→ API 호출
→ 파싱
→ 검증
→ 데이터 변환
→ Data Save Service 외부 호출
→ 트랜잭션 시작
→ DB 반영
→ 커밋 또는 롤백
별도 Bean 분리는 단순히 Self-invocation을 피하기 위한 우회 방법이 아니다.
이렇게 외부 데이터 처리와 DB 저장 책임을 분리하고, 트랜잭션이 필요한 구간을 하나의 서비스 경계로 표현한다.
마지막은 선언적 트랜잭션 대신 프로그래밍 방식의 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의 콜백 안으로 들어갈 때 트랜잭션이 시작된다.

호출
curl -X POST http://localhost:8080/demo/transaction-template
결과

결과를 표로 정리하면 다음과 같다.
시나리오 트랜잭션 시작 방식 실제 txActive예외 후 DB 외부 Bean 호출 @Transactional프록시Service 내부 true0건 Self-invocation 내부 메서드의 @Transactionalouter false, innerfalse2건 별도 Bean Worker의 @Transactional프록시Facade false, Workertrue0건 TransactionTemplate템플릿 콜백 호출 전 false, 블록 안true0건

네 시나리오 모두 INSERT 두 번과 IllegalStateException 발생이라는 조건은 동일했다.
결과를 바꾼 것은 @Transactional 어노테이션의 존재 여부만이 아니었다.
실제 실행 경로에서 트랜잭션이 시작됐는지가 결과를 결정했다.
처음에는 DB 저장 서비스를 별도로 만든 이유를 단순한 책임 분리라고 생각했다.
Self-invocation 문제를 떠올린 뒤에는 “프록시를 통과시키기 위해 서비스를 나눴구나”라고 이해했다.
그리고 직접 실험해 본 뒤에는 한 단계 더 나아가 다음과 같이 생각하게 됐다.
별도 서비스는 프록시를 통과하기 위해 억지로 만든 클래스가 아니다.
DB 저장이라는 책임과 트랜잭션의 경계를 같은 위치에 표현한 것이다.
이번 실험에서는 같은 INSERT와 같은 예외를 사용했지만 호출 방식에 따라 최종 결과는 0건과 2건으로 달라졌다. 결국 우리가 @Transactional을 이해할 때 가장 먼저 확인해야 할 것은 어노테이션의 위치가 아니다.
결국 트랜잭션을 이해할 때 가장 먼저 확인해야 할 것은 어노테이션의 위치가 아니라, 실제 트랜잭션이 어디에서 어떤 방식으로 시작되는가이다.