@Transactional 과 TransactionTemplate

Elena·2026년 1월 29일
post-thumbnail
  • @Transactional: 어노테이션 하나로 트랜잭션 처리를 끝내는 선언적 방식. 코드와 트랜잭션 로직을 분리하여 비즈니스 로직에 집중할 수 있게 해준다.

  • Transaction Template: 코드를 통해 직접 트랜잭션의 시작과 끝을 명시하는 프로그래밍 방식. 템플릿 콜백 패턴을 사용하여 트랜잭션 내에서 실행될 작업을 감싸준다.

그동안 나는 트랜잭션을 다룰 때 @Transactional만 사용해봐서 Transaction Template이 어떤건지 감이 잘 잡히지 않는다. 어노테이션의 Transactional이 더 자동화된 방식이라는 것은 짐작이 가는데 Transaction Template은 어떻게 사용하는 것이고 언제 사용하는 것일까?

1. 사용 방식의 결정적 차이

@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)
이 방식은 직접 코드로 트랜잭션의 시작과 끝을 감싼다. 스프링이 제공하는 템플릿에 내가 실행하고 싶은 로직을 '전달'하는 방식.

  • 장점: 트랜잭션의 시작과 끝이 코드에 명확히 보임. 프록시 관련 제약(내부 호출 등)에서 자유로움.
  • 단점: 비즈니스 로직과 트랜잭션 코드가 섞여 가독성이 떨어짐.

2. TransactionTemplate 사용법

TransactionTemplate은 주로 두 가지 메소드를 사용한다. 결과를 반환할 때와 하지 않을 때의 차이가 존재

결과가 있는 경우 (execute)

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;
}

결과가 없는 경우 (executeWithoutResult)

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);
    // 메소드가 끝날 때 자동 커밋
}

3. 왜 이렇게 길게 쓸까? (@Transactional과의 차이점)

@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);
    }
}
구분@TransactionalTransactionTemplate
코드 가독성매우 높음 (깔끔함)낮음 (보일러플레이트 코드 발생)
제어 정밀도메소드 단위 (거시적)코드 블록 단위 (미시적)
테스트 용이성설정이 필요함유닛 테스트가 상대적으로 직관적

보일러플레이트 코드(Boilerplate code): 내용은 거의 똑같은데 매번 작성해야 하는 번거로운 코드
ex) java: getter, setter
-> Lombok으로 보일러플레이트 코드를 제거 가능
TransactionTemplate: 트랜잭션을 시작하고, 예외를 잡고(try-catch), 롤백을 호출하고, 결과를 반환하는 코드
-> @Transactional로 보일러플레이트 코드 제거 가능

4. 쓰이는 상황 vs 쓸 수 없는 상황

@Transactional을 쓰는 상황 (권장)

  • 일반적인 모든 비즈니스 로직
  • 트랜잭션의 경계가 메소드 시작과 끝인 경우
  • 가독성과 생산성이 중요한 대규모 프로젝트

TransactionTemplate을 쓰는 상황

  • 트랜잭션 범위를 아주 세밀하게 조정해야 할 때
    ex)매우 긴 메소드 중 특정 부분만 트랜잭션이 필요한 경우
  • 프록시의 한계(자기 호출 문제)를 우회해야 할 때
  • 동적으로 트랜잭션 속성을 변경해야 할 때.

@Transactional을 쓸 수 없는(주의할) 상황

  • Self-Invocation (자기 호출): 같은 클래스 내에서 @Transactional이 없는 메소드가 @Transactional이 있는 메소드를 호출하면 프록시를 거치지 않으므로 트랜잭션이 적용되지 않음
  • Private 메소드: 프록시는 기본적으로 외부 호출을 가로채기 때문에 private 메소드에는 적용되지 않음
  • Final 메소드/클래스: 프록시 생성을 방해하므로 적용되지 않음

TransactionTemplate을 쓸 수 없는(주의할) 상황

  • @Transactional만 허용되는 환경: 일부 엄격한 프레임워크나 기업 표준 아키텍처에서는 비즈니스 로직(Service)에 인프라스트럭처 코드(TransactionTemplate)가 섞이는 것을 강력히 금지하기도 한다.
  • 수많은 메소드에 트랜잭션을 적용해야 할 때
  • @Transactional의 '전파(Propagation)' 속성을 복잡하게 쓸 때: @Transactional(propagation = Propagation.REQUIRES_NEW) 처럼 트랜잭션 전파 속성을 아주 복잡하게 연동해서 사용하는 경우, 이를 TransactionTemplate으로 똑같이 구현하려면 코드가 기하급수적으로 복잡해짐
  • 분산 트랜잭션 (2PC 등) 상황: 두 개 이상의 서로 다른 DB나 메시지 큐를 하나의 트랜잭션으로 묶어야 하는 경우(JTA 사용 등), 코드로 이를 일일이 제어하는 것은 매우 위험

@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의 가로채기 원리에 의존하지 않고 코드 자체로 트랜잭션을 실행하기 때문에, 내부 호출 문제에서 완벽하게 자유로울 수 있다.

5. 무엇을 써야할까?

그냥 @Transactional을 쓰는게 맞다!

TransactionTemplate은 만능 도구라기보다는 특수 목적용 정밀 도구이다. 99%의 상황에서는 @Transactional을 쓰고, 프록시 내부 호출 문제가 발생하거나 트랜잭션 범위를 아주 짧게 줄여야 하는 특수한 1%의 상황에서만 TransactionTemplate을 꺼내 드는 것이 정석.

profile
一切唯心造

0개의 댓글