
@Transactional: 어노테이션 하나로 트랜잭션 처리를 끝내는 선언적 방식. 코드와 트랜잭션 로직을 분리하여 비즈니스 로직에 집중할 수 있게 해준다.
Transaction Template: 코드를 통해 직접 트랜잭션의 시작과 끝을 명시하는 프로그래밍 방식. 템플릿 콜백 패턴을 사용하여 트랜잭션 내에서 실행될 작업을 감싸준다.
그동안 나는 트랜잭션을 다룰 때 @Transactional만 사용해봐서 Transaction Template이 어떤건지 감이 잘 잡히지 않는다. 어노테이션의 Transactional이 더 자동화된 방식이라는 것은 짐작이 가는데 Transaction Template은 어떻게 사용하는 것이고 언제 사용하는 것일까?
@Transactional: 선언적 (Declarative)
이 방식은 AOP(Aspect Oriented Programming)를 이용한다. 내가 만든 클래스 밖에 '대리인(Proxy)'을 세워두고, 그 대리인이 트랜잭션을 켜고 끄는 일을 대신 해줌
AOP (Aspect Oriented Programming: 관점 지향 프로그래밍)
AOP는 프로그래밍을 할 때 핵심 비즈니스 로직과 공통적으로 반복되는 부가 기능을 분리해서 생각하자는 철학.
만약 서비스에 createUser, updateUser, deleteUser가 있다고 할 때모든 메소드에 공통적으로 들어가는 코드가 있다.
- 로그 남기기
- 권한 체크하기
- 트랜잭션 시작/종료
이런 공통 기능을 일일이 복사해서 붙여넣으면 코드도 지저분해지고 수정도 힘들다. AOP는 이 공통 기능을 한곳에 모아서 정의하고, 필요한 곳에 가로질러서(Cross-cutting) 끼워 넣는다.
AOP 적용 전 (코드 중복)
public void 로직A() {
[트랜잭션 시작] // 중복
... 실제 로직 A ...
[트랜잭션 종료] // 중복
}
public void 로직B() {
[트랜잭션 시작] // 중복
... 실제 로직 B ...
[트랜잭션 종료] // 중복
}
AOP 적용 후 (가로지르기)
트랜잭션이라는 관점(Aspect)이 여러 로직을 뚫고 지나가며 필요한 순간에 끼어든다.
1. 가로채기: 클라이언트가 로직A()를 호출하면, AOP 프록시가 이를 중간에 가로챈다.
2. 끼워넣기 (Before): 실제 로직이 실행되기 전, 트랜잭션 시작 코드를 끼워 넣는다.
3. 수행: 실제 로직A()를 실행한다.
4. 끼워넣기 (After): 로직이 끝나면 트랜잭션 종료(커밋/롤백) 코드를 끼워 넣는다.
Proxy (프록시: 대리인)
스프링에서 프록시 객체는 실제 서비스 객체 대신 앞에 서서 클라이언트의 요청을 대신 받는 가짜 객체
스프링에서의 모습:
1. @Transactional을 붙인 클래스를 빈으로 등록하면, 스프링은 그 클래스를 복제한 프록시 객체를 만든다.
2. 다른 클래스에서 이 빈을 호출하면, 실제 객체가 아닌 프록시가 호출된다.
3. 프록시가 DB Connection을 열고 트랜잭션을 시작한 뒤에야 실제 코드를 실행해준다.
TransactionTemplate: 프로그래밍 방식 (Programmatic)
이 방식은 직접 코드로 트랜잭션의 시작과 끝을 감싼다. 스프링이 제공하는 템플릿에 내가 실행하고 싶은 로직을 '전달'하는 방식.
TransactionTemplate은 주로 두 가지 메소드를 사용한다. 결과를 반환할 때와 하지 않을 때의 차이가 존재
public User createUser(UserDto dto) {
return transactionTemplate.execute(status -> {
// 이 안의 로직은 하나의 트랜잭션으로 묶임
User user = userRepository.save(new User(dto.getName()));
logRepository.save(new Log("User created"));
return user; // 결과 반환 가능
});
}
-> @Transactional을 사용했을 경우의 코드
/**
* @Transactional 어노테이션을 붙이면:
* 1. 메소드 시작 시 트랜잭션 시작
* 2. 로직 수행 (User 저장 및 로그 기록)
* 3. 예외 없이 종료되면 자동 커밋 (Commit)
* 4. RuntimeException 발생 시 자동 롤백 (Rollback)
*/
@Transactional
public User createUser(UserDto dto) {
// 1. 유저 저장
User user = userRepository.save(new User(dto.getName()));
// 2. 로그 저장 (둘 중 하나라도 실패하면 전체 롤백됨)
logRepository.save(new Log(user.getName() + "님이 가입했습니다."));
return user;
}
transactionTemplate의 사용법을 조금 더 자세하게 코드로 설명하겠다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
// 1. 스프링이 관리하는 트랜잭션 매니저를 가져온다.
private final PlatformTransactionManager transactionManager;
// 2. TransactionTemplate 타입의 변수를 선언한다.
private TransactionTemplate transactionTemplate;
// 3. 생성자에서 TransactionTemplate을 초기화한다.
@PostConstruct
public void init() {
this.transactionTemplate = new TransactionTemplate(transactionManager);
}
public void deleteUser(Long id) {
// 4. 정의된 템플릿을 사용하여 로직을 실행한다.
transactionTemplate.executeWithoutResult(status -> {
try {
userRepository.deleteById(id);
System.out.println("사용자 삭제 성공: " + id);
} catch (Exception e) {
// 예외 발생 시 수동으로 롤백 표시를 남긴다.
status.setRollbackOnly();
System.err.println("삭제 중 오류 발생, 롤백합니다: " + e.getMessage());
}
});
}
}
-> @Transactional을 사용했을 경우의 코드
@Transactional
public void deleteUser(Long id) {
userRepository.deleteById(id);
// 메소드가 끝날 때 자동 커밋
}
@Transactional을 쓰면 위 코드의 90%가 사라진다. 하지만 TransactionTemplate을 이렇게 명시적으로 쓰는 이유는 코드 제어권 때문.
@Transactional은 프록시(Proxy)가 필요함: 클래스 외부에서 호출해야만 트랜잭션이 작동
TransactionTemplate은 객체가 필요함: 프록시와 상관없이 클래스 내부 어디서든 transactionTemplate.execute()만 호출하면 즉시 트랜잭션이 시작
TIP. 빈(Bean)으로 등록해서 쓰기
매번 서비스마다 new TransactionTemplate(manager)를 하는 게 귀찮다면, 아예 설정 클래스(Config)에서 빈으로 등록해두고 @Autowired로 바로 주입받아 쓰기도 함
@Configuration
public class TransactionConfig {
@Bean
public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
return new TransactionTemplate(transactionManager);
}
}
| 구분 | @Transactional | TransactionTemplate |
|---|---|---|
| 코드 가독성 | 매우 높음 (깔끔함) | 낮음 (보일러플레이트 코드 발생) |
| 제어 정밀도 | 메소드 단위 (거시적) | 코드 블록 단위 (미시적) |
| 테스트 용이성 | 설정이 필요함 | 유닛 테스트가 상대적으로 직관적 |
보일러플레이트 코드(Boilerplate code): 내용은 거의 똑같은데 매번 작성해야 하는 번거로운 코드
ex) java: getter, setter
->Lombok으로 보일러플레이트 코드를 제거 가능
TransactionTemplate: 트랜잭션을 시작하고, 예외를 잡고(try-catch), 롤백을 호출하고, 결과를 반환하는 코드
->@Transactional로 보일러플레이트 코드 제거 가능
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
// 1. 외부에서 호출하는 메소드 (트랜잭션 없음)
public void registerUser(UserDto dto) {
System.out.println("--- registerUser 시작 ---");
// 2. 같은 클래스 내부의 트랜잭션 메소드 호출
insertData(dto);
System.out.println("--- registerUser 종료 ---");
}
// 3. 실제 DB 작업 (트랜잭션 있음)
@Transactional
public void insertData(UserDto dto) {
System.out.println("--- insertData(트랜잭션) 실행 ---");
userRepository.save(new User(dto.getName()));
// 만약 여기서 예외가 터진다면? 롤백이 될까?
throw new RuntimeException("DB 저장 중 에러 발생!");
}
}
-> 롤백이 되지 않습니다. 데이터는 그대로 DB에 저장되어 버린다.
이유는?
외부 호출: 다른 클래스(예: Controller)에서 userService.registerUser()를 호출한다면
1. 프록시 통과: 이때 호출되는 userService는 실제 객체가 아니라 스프링이 만든 프록시 객체
2. 가로채기 실패: 하지만 registerUser 메소드에는 어노테이션이 없으므로, 프록시는 "어? 트랜잭션 필요 없네?" 하고 실제 객체의 registerUser를 그대로 호출
3. 내부 호출의 비극: 실제 객체 내부에서 insertData()를 호출할 때는 프록시를 거치지 않고 자기 자신의 코드(this.insertData())를 직접 실행
4. 결론: 프록시가 중간에 끼어들어서 begin transaction을 해줄 기회 자체가 없었다.
1) 가장 권장되는 방법: 메소드 분리
트랜잭션이 필요한 로직을 별도의 서비스 클래스로 뽑아낸다.
UserService에서 UserWriterService.insertData()를 호출하게 만들면, 다른 클래스를 호출하는 것이므로 반드시 프록시를 거치게 된다.
2) 호출되는 쪽에 @Transactional 붙이기
외부에서 호출하는 시작점인 registerUser 자체에 @Transactional을 붙인다. 그러면 메소드 전체에 트랜잭션이 걸리므로 내부 호출도 그 트랜잭션 안에서 안전하게 실행된다.
3) TransactionTemplate 사용 (오늘 배운 것!)
public void registerUser(UserDto dto) {
// 프록시 방식이 아니기 때문에 내부 호출 중에도 트랜잭션을 확실히 보장함
transactionTemplate.executeWithoutResult(status -> {
insertData(dto);
});
}
TransactionTemplate은 proxy의 가로채기 원리에 의존하지 않고 코드 자체로 트랜잭션을 실행하기 때문에, 내부 호출 문제에서 완벽하게 자유로울 수 있다.
그냥 @Transactional을 쓰는게 맞다!
TransactionTemplate은 만능 도구라기보다는 특수 목적용 정밀 도구이다. 99%의 상황에서는 @Transactional을 쓰고, 프록시 내부 호출 문제가 발생하거나 트랜잭션 범위를 아주 짧게 줄여야 하는 특수한 1%의 상황에서만 TransactionTemplate을 꺼내 드는 것이 정석.