이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 백엔드 서비스에서 트랜잭션 경계는 어디에 두는 것이 좋은가?
- 긴 트랜잭션은 왜 장애와 성능 문제를 만드는가?
- 외부 API와 DB 트랜잭션은 어떻게 분리해야 하는가?
개요
트랜잭션 경계는 “어디서 시작하고 어디서 끝나는가”를 뜻한다. Spring에서는 보통 서비스 계층 메서드에 @Transactional을 붙여 경계를 만든다. 그러나 어디에 붙이느냐에 따라 DB 커넥션 점유 시간, 락 보유 시간, 롤백 범위가 달라진다.
좋은 경계는 비즈니스 일관성 단위를 표현하면서도 짧다.
원리
트랜잭션은 커넥션에 묶인다. 트랜잭션이 열려 있는 동안 애플리케이션은 DB 커넥션을 점유하고, 변경한 row의 lock을 유지할 수 있다.
@Transactional
public void updateProfile(UpdateProfileCommand command) {
Member member = memberRepository.findById(command.memberId())
.orElseThrow();
member.changeName(command.name());
member.changeAddress(command.address());
}이 정도 경계는 명확하다. 하나의 회원 프로필 변경이 하나의 DB 변경 단위다.
운영에서는 트랜잭션 경계가 다음 지표로 드러난다.
- HikariCP active connection과 pending thread
- connection acquire time
- lock wait와 deadlock count
- PostgreSQL long transaction,
idle in transaction - MySQL InnoDB transaction history와 lock wait
- API p95/p99와 transaction timeout
나쁜 경계
다음 코드는 위험하다.
@Transactional
public void register(RegisterCommand command) {
Member member = memberRepository.save(command.toMember());
emailClient.sendWelcomeMail(member.email());
fileStorage.createDefaultFolder(member.id());
}이 코드는 이메일 발송과 파일 저장소 호출이 끝날 때까지 DB 트랜잭션이 열린다. 외부 시스템이 느리거나 실패하면 DB 커넥션과 lock이 오래 유지될 수 있다. 또 DB 롤백이 이메일 발송을 되돌리지는 못한다.
장애 상황에서는 이런 코드가 “외부 메일 서비스 지연”으로 끝나지 않는다. 메일 API가 5초 느려지면 그 5초 동안 DB connection이 붙잡히고, 동시에 가입 요청이 몰리면 pool pending과 DB lock wait가 같이 증가할 수 있다.
더 나은 경계
외부 부작용은 DB 커밋 이후 처리하거나 outbox로 분리한다.
@Transactional
public Long register(RegisterCommand command) {
Member member = memberRepository.save(command.toMember());
outboxRepository.save(OutboxEvent.welcomeMail(member.id(), member.email()));
return member.id();
}이후 별도 worker가 outbox event를 읽고 이메일을 보낸다. 이렇게 하면 DB 변경과 이벤트 기록은 하나의 트랜잭션으로 묶고, 외부 호출은 재시도 가능한 비동기 작업으로 분리할 수 있다.
트랜잭션 경계 위치
보통 Controller보다 Service에 둔다. Controller는 HTTP 요청/응답, 인증 사용자, DTO 변환을 다루고, Service는 비즈니스 변경 단위를 표현한다.
Repository 메서드마다 작은 트랜잭션을 두면 하나의 비즈니스 작업이 여러 트랜잭션으로 쪼개져 중간 실패 상태가 생길 수 있다. 반대로 Controller 전체를 트랜잭션으로 감싸면 view rendering, 파일 처리, 외부 호출까지 경계에 들어갈 수 있다.
REQUIRES_NEW 같은 propagation도 신중하게 쓴다. 감사 로그나 outbox 기록을 별도 트랜잭션으로 남기고 싶을 때 유용할 수 있지만, 바깥 트랜잭션이 롤백되어도 안쪽 트랜잭션은 커밋될 수 있다. 이 불일치가 의도인지 문서화해야 한다.
실전 팁
- 트랜잭션 안에서 해야 하는 DB 변경만 남긴다.
- 외부 API, 파일 I/O, 긴 계산, 사용자 응답 대기는 트랜잭션 밖으로 뺀다.
- 읽기 전용 작업은
@Transactional(readOnly = true)로 의도를 표시한다. - 트랜잭션 경계를 서비스 메서드 이름과 일치시킨다.
- 긴 batch는 작은 트랜잭션으로 나누고 진행률을 기록한다.
- 외부 I/O를 트랜잭션 밖으로 뺄 때는 outbox, after-commit hook, worker 재시도 중 어떤 방식이 맞는지 고른다.
- propagation을 바꾸는 코드는 “바깥 실패 시 안쪽 결과가 남아도 되는가”를 리뷰 질문으로 둔다.
- transaction timeout은 API timeout, DB statement timeout, connection timeout과 순서를 맞춘다.
위험 신호!
@Transactional이 붙은 메서드가 수백 줄이다.- 트랜잭션 안에서 REST client, email client, file storage를 호출한다.
- Controller에 광범위한 트랜잭션을 건다.
- batch 하나가 수십만 row를 한 트랜잭션에서 변경한다.
- 트랜잭션 timeout이 없고 실행 시간이 예측되지 않는다.
REQUIRES_NEW로 남긴 로그나 이벤트가 바깥 트랜잭션 rollback 후에도 남는다는 사실을 모른다.- 외부 API 지연 장애인데 DB pool 고갈까지 같이 발생한다.
확인 질문
- 트랜잭션 경계를 짧게 유지해야 하는 이유는 무엇인가?
- DB 커넥션과 lock을 오래 점유하지 않아 풀 고갈, lock wait, vacuum 방해 같은 문제를 줄이기 위해서다.
- 외부 API 호출을 트랜잭션 밖으로 빼야 하는 이유는 무엇인가?
- 외부 지연과 실패가 DB 자원 점유 시간과 롤백 범위를 늘리고, 외부 부작용은 DB 롤백으로 되돌릴 수 없기 때문이다.
- Service 계층에 트랜잭션을 두는 이유는 무엇인가?
- 하나의 비즈니스 변경 단위를 표현하기 좋고, HTTP 처리나 view rendering 같은 외부 관심사를 트랜잭션 밖에 둘 수 있기 때문이다.
REQUIRES_NEW를 사용할 때 확인해야 할 것은 무엇인가?- 바깥 트랜잭션이 실패해도 안쪽 트랜잭션 결과가 커밋되어 남는 것이 비즈니스적으로 의도된 동작인지 확인해야 한다.