Spring Boot 서버의 @Transactional 사용법과 주의사항

코-드 텐카이·2025년 1월 9일

Spring Boot

목록 보기
5/10

오늘은 Spring Boot에서 가장 중요한 어노테이션 중 하나인 @Transactional에 대해 자세히 알아보도록 하겠습니다.

목차

  1. @Transactional이란?
  2. 트랜잭션의 기본 속성
  3. @Transactional 동작 원리
  4. 주요 속성 (옵션) 설명
  5. 레이어별 @Transactional 사용
  6. 조회 작업과 @Transactional
  7. @Transactional 사용 시 주의사항
  8. Best Practices

1. @Transactional이란?

@Transactional은 Spring Framework에서 제공하는 선언적 트랜잭션 관리를 위한 어노테이션입니다. 데이터베이스 작업을 하나의 트랜잭션으로 처리하고자 할 때 사용됩니다.

기본 사용법

@Service
public class UserService {
    
    @Transactional
    public void createUser(User user) {
        // 데이터베이스 작업 수행
    }
}

2. 트랜잭션의 기본 속성

트랜잭션은 ACID 속성을 가집니다:

  • 원자성(Atomicity): 트랜잭션 내의 모든 작업은 전부 성공하거나 전부 실패해야 합니다.
  • 일관성(Consistency): 트랜잭션 실행 전과 후의 데이터베이스는 일관된 상태를 유지해야 합니다.
  • 격리성(Isolation): 동시에 실행되는 트랜잭션들은 서로 영향을 미치지 않아야 합니다.
  • 지속성(Durability): 성공적으로 완료된 트랜잭션의 결과는 영구적으로 반영되어야 합니다.

3. @Transactional 동작 원리

Spring은 AOP(Aspect-Oriented Programming)를 사용하여 @Transactional을 구현합니다:

  1. 프록시 생성: Spring은 @Transactional이 붙은 클래스나 메서드에 대해 프록시를 생성합니다.
  2. 트랜잭션 시작: 메서드 호출 시 프록시가 트랜잭션을 시작합니다.
  3. 비즈니스 로직 실행: 실제 메서드가 실행됩니다.
  4. 커밋 또는 롤백: 예외 발생 여부에 따라 트랜잭션을 커밋하거나 롤백합니다.
// 내부 동작 예시
public class UserServiceProxy extends UserService {
    public void createUser(User user) {
        TransactionStatus status = null;
        try {
            status = transactionManager.getTransaction(new DefaultTransactionDefinition());
            super.createUser(user);  // 실제 메서드 호출
            transactionManager.commit(status);
        } catch (RuntimeException e) {
            transactionManager.rollback(status);
            throw e;
        }
    }
}

4. 주요 속성 (옵션) 설명

@Transactional은 다양한 옵션을 제공합니다:

@Transactional(
    propagation = Propagation.REQUIRED,
    isolation = Isolation.DEFAULT,
    timeout = -1,
    readOnly = false,
    rollbackFor = Exception.class,
    noRollbackFor = {IllegalStateException.class}
)

propagation (전파 옵션)

  • REQUIRED: 기본값, 트랜잭션이 없으면 새로 생성
  • REQUIRES_NEW: 항상 새로운 트랜잭션 생성
  • SUPPORTS: 트랜잭션이 있으면 참여, 없으면 없이 진행
  • MANDATORY: 트랜잭션이 반드시 있어야 함
  • NEVER: 트랜잭션이 있으면 안 됨
  • NOT_SUPPORTED: 트랜잭션 없이 실행

각각의 전파 옵션을 실제 예시와 함께 자세히 설명해드리겠습니다:

  1. REQUIRED (기본값)
@Transactional
public void parentMethod() {  // 트랜잭션 시작
    childMethod();  // 부모의 트랜잭션을 그대로 사용
}

@Transactional
public void childMethod() {  // 새로운 트랜잭션 생성하지 않음
    // 부모 트랜잭션이 있으면 그걸 사용
    // 없으면 새로 생성
}
  1. REQUIRES_NEW
@Transactional
public void parentMethod() {  // 트랜잭션 A 시작
    childMethod();  // 트랜잭션 B 시작 (완전히 새로운 트랜잭션)
    // childMethod가 실패해도 parentMethod는 커밋될 수 있음
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void childMethod() {  // 무조건 새로운 트랜잭션 생성
    // 부모 트랜잭션과 완전히 독립적으로 동작
}
  1. SUPPORTS
@Transactional(propagation = Propagation.SUPPORTS)
public void someMethod() {
    // 트랜잭션이 있는 곳에서 호출되면 -> 해당 트랜잭션 사용
    // 트랜잭션이 없는 곳에서 호출되면 -> 트랜잭션 없이 실행
}
  1. MANDATORY
@Transactional
public void parentMethod() {
    mandatoryMethod();  // OK - 부모 트랜잭션이 있어서 실행 가능
}

public void nonTransactionalMethod() {
    mandatoryMethod();  // 에러 발생! - 트랜잭션이 없어서 실행 불가
}

@Transactional(propagation = Propagation.MANDATORY)
public void mandatoryMethod() {
    // 반드시 다른 트랜잭션 내부에서 호출되어야 함
    // 단독으로 호출되면 예외 발생
}
  1. NEVER
@Transactional
public void parentMethod() {
    neverMethod();  // 에러 발생! - 트랜잭션이 있는 상태에서 호출 불가
}

public void nonTransactionalMethod() {
    neverMethod();  // OK - 트랜잭션이 없는 상태에서 호출
}

@Transactional(propagation = Propagation.NEVER)
public void neverMethod() {
    // 트랜잭션이 있으면 예외 발생
    // 트랜잭션이 없어야만 실행 가능
}
  1. NOT_SUPPORTED
@Transactional
public void parentMethod() {
    notSupportedMethod();  // 호출되는 동안 트랜잭션 잠시 중단
}

@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void notSupportedMethod() {
    // 기존 트랜잭션을 일시 중단하고
    // 트랜잭션 없이 실행
    // 메서드 완료 후 기존 트랜잭션 재개
}

실무에서 주로 사용되는 옵션은:

  • REQUIRED: 가장 많이 사용 (기본값)
  • REQUIRES_NEW: 독립적인 트랜잭션이 필요할 때
  • SUPPORTS: 트랜잭션이 필수가 아닌 조회 작업

나머지 옵션들은 특수한 경우에만 사용됩니다. MANDATORY, NEVER, NOT_SUPPORTED는 상대적으로 덜 사용되는 옵션들입니다.

isolation (격리 수준)

  • DEFAULT: 데이터베이스의 기본 격리 수준
  • READ_UNCOMMITTED: 다른 트랜잭션의 미완료된 데이터 읽기 가능
  • READ_COMMITTED: 완료된 데이터만 읽기 가능
  • REPEATABLE_READ: 동일 데이터 여러 번 읽을 때 일관성 보장
  • SERIALIZABLE: 가장 높은 격리 수준

rollbackFor와 noRollbackFor

기본적으로 @Transactional은 Runtime Exception과 Error가 발생했을 때만 롤백합니다. Checked Exception은 롤백하지 않습니다.

@Service
public class UserService {
    // RuntimeException 발생시 롤백
    @Transactional
    public void createUser1() {
        throw new RuntimeException(); // 롤백됨
        throw new Exception();        // 롤백 안됨
    }

    // Exception이 발생하면 무조건 롤백
    @Transactional(rollbackFor = Exception.class)
    public void createUser2() {
        throw new Exception();        // 롤백됨
    }

    // NullPointerException이 발생해도 롤백하지 않음
    @Transactional(noRollbackFor = NullPointerException.class)
    public void createUser3() {
        throw new NullPointerException(); // 롤백 안됨
    }

    // 여러 예외 지정 가능
    @Transactional(
        rollbackFor = {SQLException.class, IOException.class},
        noRollbackFor = {IllegalStateException.class}
    )
    public void createUser4() {
        // SQLException, IOException -> 롤백
        // IllegalStateException -> 롤백 안함
        // 다른 RuntimeException -> 롤백
    }
}

timeout

트랜잭션 수행 시간을 제한합니다. 단위는 초이며, -1은 no timeout을 의미합니다.

readOnly

읽기 전용 트랜잭션임을 명시합니다. 성능 최적화와 실수 방지를 위해 사용됩니다.

5. 레이어별 @Transactional 사용

컨트롤러 레이어에서의 @Transactional

컨트롤러에서 @Transactional을 사용하는 것은 가능하지만 권장되지 않습니다:

@Controller
public class UserController {
    @Autowired
    private UserService userService;

    @Transactional  // 동작은 하지만 권장되지 않음
    public ResponseEntity<String> createUser() {
        userService.saveUser();     // DB 작업 1
        userService.saveUserLog();  // DB 작업 2
        return ResponseEntity.ok("Success");
    }
}

문제점:
1. 트랜잭션 범위가 너무 넓어짐
2. 프레젠테이션 계층과 비즈니스 로직의 분리 원칙 위배
3. 예외 처리가 복잡해짐

서비스 레이어에서의 @Transactional

비즈니스 로직을 담당하는 서비스 레이어에서 트랜잭션을 관리하는 것이 가장 적절합니다:

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;

    @Transactional
    public void createUser() {
        // 비즈니스 로직 + DB 작업이 하나의 트랜잭션으로 처리됨
        userRepository.save(user);
        userRepository.saveLog(log);
    }
}

장점:
1. 비즈니스 로직 단위로 트랜잭션 관리 가능
2. 실제 DB 작업과 가까운 위치에서 트랜잭션 관리
3. 명확한 예외 처리

레포지토리 레이어에서의 @Transactional

레포지토리 레벨의 @Transactional은 주의해서 사용해야 합니다:

@Repository
public class UserRepository {
    @Transactional  // 주의 필요!
    public void saveWithTransaction(User user) {
        // DB 작업
    }
}

주의사항:
1. JpaRepository의 기본 메서드들은 이미 @Transactional이 적용되어 있음
2. 트랜잭션 범위가 너무 작아질 수 있음
3. 비즈니스 로직 단위의 트랜잭션 관리가 어려움

중복 @Transactional과 전파 속성(Propagation)

서비스와 레포지토리에 @Transactional이 중복으로 있는 경우를 살펴보겠습니다:

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
    
    @Transactional  // 외부 트랜잭션
    public void createUser() {
        userRepository.saveWithTransaction(new User());
    }
}

@Repository
public class UserRepository {
    @Transactional  // 내부 트랜잭션
    public void saveWithTransaction(User user) {
        // DB 작업
    }
}

이 경우 트랜잭션 전파 속성(Propagation)에 따라 동작이 달라집니다:

  1. 기본 동작 (REQUIRED):
  • 기본값인 Propagation.REQUIRED를 사용할 경우
  • 내부 트랜잭션(@Repository의 트랜잭션)은 외부 트랜잭션(@Service의 트랜잭션)에 참여합니다
  • 실제로는 하나의 트랜잭션으로 동작
  • 서비스의 트랜잭션이 커밋/롤백될 때 함께 커밋/롤백됨
  1. 만약 REQUIRES_NEW를 사용한다면:
@Repository
public class UserRepository {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveWithTransaction(User user) {
        // 새로운 트랜잭션 시작
    }
}
  • 완전히 별도의 트랜잭션으로 동작
  • 서비스의 트랜잭션과 독립적으로 커밋/롤백됨
  1. 실제 동작 예시:
// Case 1: REQUIRED (기본값)
@Transactional (Service)  // 트랜잭션 시작
    @Transactional (Repository)  // 같은 트랜잭션 사용
        // DB 작업
    // Repository 트랜잭션 종료
// Service 트랜잭션 종료 (실제 커밋/롤백 발생)

// Case 2: REQUIRES_NEW
@Transactional (Service)  // 외부 트랜잭션 시작
    @Transactional(REQUIRES_NEW)  // 새로운 트랜잭션 시작
        // DB 작업
    // 내부 트랜잭션 종료 (커밋/롤백)
// 외부 트랜잭션 종료 (커밋/롤백)

권장사항:
1. 일반적으로 중복 @Transactional은 피하는 것이 좋습니다
2. 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 관리하는 것이 바람직
3. 특별한 이유가 없다면 레포지토리의 @Transactional은 제거하는 것이 좋음
4. JpaRepository의 기본 메서드들은 이미 트랜잭션이 적용되어 있으므로 추가로 설정할 필요 없음

실제로는 중복으로 설정하더라도 큰 문제는 발생하지 않지만, 코드의 명확성과 유지보수를 위해 서비스 계층에만 @Transactional을 사용하는 것이 좋습니다.

6. 조회 작업과 @Transactional

조회 작업에서의 트랜잭션 처리 방법

1. 트랜잭션 없이 조회하는 경우:

@Service
public class UserService {
    @Autowired 
    private UserRepository userRepository;
    
    // @Transactional 없음
    public User findUser(Long id) {
        return userRepository.findById(id);
    }
}
  • 트랜잭션 없이 즉시 DB 연결/해제
  • JPA 영속성 컨텍스트가 트랜잭션 범위를 벗어나 즉시 닫힘
  • 여러 번의 조회시 매번 새로운 DB 커넥션 생성
  • 데이터 일관성이 보장되지 않음

2. @Transactional(readOnly = true)를 사용하는 경우:

@Service
public class UserService {
    @Autowired 
    private UserRepository userRepository;
    
    @Transactional(readOnly = true)
    public UserDTO findUserWithDetails(Long id) {
        User user = userRepository.findById(id);        
        List<Order> orders = orderRepository.findByUserId(id);  
        return new UserDTO(user, orders);  // 데이터 일관성 보장
    }
}
  • 하나의 트랜잭션 안에서 여러 조회 작업 가능
  • 동일한 DB 커넥션 재사용
  • 데이터 일관성 보장
  • Hibernate 성능 최적화 적용

JPA 영속성 컨텍스트와 @Transactional

JPA에서 @Transactional의 동작을 이해하기 위해서는 영속성 컨텍스트의 핵심 개념인 스냅샷과 플러시를 이해해야 합니다.

1. 스냅샷(Snapshot)
  • 엔티티를 최초로 조회했을 때의 상태를 저장해두는 것
  • 변경 감지(Dirty Checking)를 위해 사용
@Transactional
public void updateUser(Long id) {
    User user = userRepository.findById(id);  // 스냅샷 생성
    // 최초 상태: name="John", age=20
    
    user.setName("Mike");  // 엔티티 변경
    // 트랜잭션 종료시 스냅샷과 비교해서
    // name이 "John"에서 "Mike"로 변경된 것을 감지
} // UPDATE 쿼리 실행
2. 플러시(Flush)
  • 영속성 컨텍스트의 변경 내용을 DB에 반영하는 것
  • INSERT, UPDATE, DELETE 쿼리가 실제 실행되는 시점
@Transactional
public void process() {
    User user = new User("John");
    userRepository.save(user);  // 영속성 컨텍스트에만 존재
    
    // 1. 강제 플러시
    entityManager.flush();  
    
    // 2. 자동 플러시
    List<User> users = userRepository.findAll();  // JPQL 실행시
    
    // 3. 트랜잭션 커밋시 자동 플러시
}

플러시가 발생하는 시점:
1. 트랜잭션 커밋시
2. JPQL 쿼리 실행시
3. 강제로 flush() 호출시

readOnly의 성능상 이점

@Service
public class UserService {
    
    @Transactional  // 일반 트랜잭션
    public void updateUser(Long id) {
        User user = userRepository.findById(id);  
        // 1. 스냅샷 생성 (메모리 사용)
        
        user.setName("Mike");  
        // 2. 변경 감지 수행
        // 3. 플러시 발생 (UPDATE 쿼리 실행)
    }
    
    @Transactional(readOnly = true)  // 읽기 전용
    public User findUser(Long id) {
        return userRepository.findById(id);
        // 1. 스냅샷 생성 안함 (메모리 절약)
        // 2. 변경 감지 안함
        // 3. 플러시 호출 안함
    }
}

readOnly = true의 장점:
1. 스냅샷을 만들지 않아 메모리 사용량 감소
2. 변경 감지(Dirty Checking)를 하지 않아 CPU 사용량 감소
3. 불필요한 플러시 호출 방지로 성능 향상
4. DB에 읽기 전용임을 알려줘서 DB 내부적으로 최적화 가능

또한 @Transactional(readOnly = true)를 사용할 때의 제약사항과 특징은 아래와 같습니다:

@Service
public class UserService {
    
    @Transactional(readOnly = true)
    public void someMethod() {
        // 1. 엔티티 수정 시도 시 예외 발생
        user.setName("새이름");  // 예외 발생!
        
        // 2. save 메서드 호출 시 예외 발생
        userRepository.save(user);  // 예외 발생!
        
        // 3. @Modifying 쿼리 실행 시 예외 발생
        userRepository.updateUsername(1L, "새이름");  // 예외 발생!
        
        // 4. JPA의 dirty checking 비활성화
        User user = userRepository.findById(1L);
        user.setName("새이름");  // 변경 감지 자체가 동작하지 않음
    }
}

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    @Modifying
    @Query("UPDATE User u SET u.username = :username WHERE u.id = :id")
    void updateUsername(Long id, String username);
}

readOnly = true 설정 시 발생하는 제약:

  1. 엔티티 변경 감지(Dirty Checking) 비활성화

    • JPA는 엔티티의 변경 사항을 감지하지 않음
    • 메모리 사용량이 감소하고 성능이 향상됨
  2. INSERT, UPDATE, DELETE 쿼리 실행 불가

    • 데이터 변경을 시도하면 예외 발생
    • JPA Repository의 save(), delete() 등 호출 시 예외
    • @Modifying이 붙은 쿼리 실행 시 예외
  3. flush 모드가 NEVER로 설정

    • 영속성 컨텍스트의 flush가 일어나지 않음
    • 데이터베이스에 쓰기 작업이 방지됨
  4. 데이터베이스 최적화

    • DB가 읽기 전용임을 인지하여 리소스 사용 최적화
    • 특히 MySQL의 경우 read-only 트랜잭션을 slave 서버로 라우팅 가능

실수 예방 효과:

@Service
public class UserService {
    
    @Transactional(readOnly = true)
    public UserDTO getUserInfo(Long id) {
        User user = userRepository.findById(id);
        
        // 실수로 데이터를 변경하려 해도 예외가 발생하거나 무시됨
        user.setLastAccessDate(LocalDateTime.now());  // 이런 실수를 방지
        
        return new UserDTO(user);
    }
}

이러한 제약사항들 덕분에:
1. 읽기 전용 메서드에서 실수로 데이터를 수정하는 것을 방지
2. 코드 리뷰 시 메서드의 의도가 명확히 드러남 (조회 전용)
3. JPA/Hibernate의 불필요한 처리가 줄어 성능 향상

언제 어떤 방식을 사용해야 할까?

  1. 트랜잭션 없이 조회:
  • 단순 단건 조회
  • 빠른 응답이 필요한 경우
  • 데이터 일관성이 크게 중요하지 않은 경우
  1. @Transactional(readOnly = true):
  • 여러 테이블을 조인해서 조회하는 경우
  • 한 번의 트랜잭션으로 여러 번의 조회가 필요한 경우
  • 데이터 일관성이 중요한 경우
  • 대량의 데이터를 조회하는 경우

7. @Transactional 사용 시 주의사항

1) private 메서드

  • private 메서드에는 @Transactional을 사용할 수 없습니다.
  • AOP 프록시가 private 메서드를 가로챌 수 없기 때문입니다.

2) 롤백 처리

  • RuntimeException과 Error만 기본적으로 롤백됩니다.
  • checked exception은 rollbackFor 옵션으로 명시해야 롤백됩니다.

3) 프록시 내부 호출 문제

@Transactional은 Spring AOP를 기반으로 동작하기 때문에 프록시 내부 호출시 트랜잭션이 적용되지 않는 문제가 발생할 수 있습니다.

문제 상황

@Service
public class UserService {
    
    @Transactional
    public void createUser(User user) {  // 외부에서 호출 시 트랜잭션 적용됨
        userRepository.save(user);
    }
    
    public void processUser(User user) {
        createUser(user);  // 내부 호출 시 트랜잭션 적용 안됨!
        // 다른 로직들...
    }
}

문제가 발생하는 이유

Spring AOP의 동작 방식 때문에 발생합니다:
1. Spring은 @Transactional이 적용된 클래스의 프록시 객체를 생성
2. 외부에서 호출할 때만 이 프록시를 통해 호출됨
3. 내부 메서드 호출은 프록시를 거치지 않고 직접 호출되어 트랜잭션 적용 안됨

프록시의 실제 동작을 보면 다음과 같습니다:

// Spring이 생성하는 프록시 클래스 (개념적인 예시)
class UserServiceProxy extends UserService {
    UserService target; // 실제 UserService 객체
    
    @Override
    public void createUser(User user) {
        // 트랜잭션 시작
        try {
            target.createUser(user);
            // 트랜잭션 커밋
        } catch (Exception e) {
            // 트랜잭션 롤백
        }
    }
    
    @Override
    public void processUser(User user) {
        // 프록시 없이 직접 호출됨
        target.processUser(user);
    }
}

해결 방법

1. 자기 자신 주입받기
@Service
public class UserService {
    @Autowired
    private UserService self; // 프록시 객체 주입
    
    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
    }
    
    public void processUser(User user) {
        self.createUser(user);  // 프록시를 통한 호출
    }
}
2. 메서드 분리 (권장)
@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
    
    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
    }
}

@Service
public class UserProcessService {
    @Autowired
    private UserService userService;
    
    public void processUser(User user) {
        userService.createUser(user);  // 다른 클래스를 통한 호출
    }
}
3. AopContext 사용
@Service
@EnableAspectJAutoProxy(exposeProxy = true)
public class UserService {
    
    @Transactional
    public void createUser(User user) {
        userRepository.save(user);
    }
    
    public void processUser(User user) {
        ((UserService) AopContext.currentProxy())
            .createUser(user);  // 현재 프록시를 통한 호출
    }
}

해결 방법별 장단점

  1. 자기 자신 주입

    • 장점: 구현이 간단함
    • 단점: 순환 참조 가능성, 코드가 명확하지 않음
  2. 메서드 분리 (권장)

    • 장점:
      • 가장 명확하고 깔끔한 해결책
      • 단일 책임 원칙(SRP)에 부합
      • 테스트하기 쉬움
    • 단점: 클래스가 늘어남
  3. AopContext 사용

    • 장점: 원본 코드 구조 유지 가능
    • 단점:
      • 설정이 추가로 필요
      • AOP 관련 코드가 비즈니스 로직에 침투

권장사항

  1. 가능하면 트랜잭션이 필요한 메서드는 외부에서 호출되도록 설계
  2. 내부 호출이 필요한 경우 메서드 분리 방법을 우선적으로 고려
  3. 구조를 개선하기 어려운 레거시 코드의 경우 자기 자신 주입 방식 고려

8. Best Practices

  1. 적절한 범위 설정

    • 트랜잭션 범위는 가능한 한 작게 유지하세요.
    • 불필요한 데이터베이스 작업을 트랜잭션에 포함시키지 마세요.
  2. readOnly 속성 활용

    • 조회 작업에는 readOnly = true를 설정하여 성능을 최적화하세요.
  3. 예외 처리 전략

    • rollbackFor, noRollbackFor 속성을 통해 명확한 롤백 정책을 수립하세요.
  4. 전파 옵션 이해

    • 비즈니스 로직에 맞는 적절한 전파 옵션을 선택하세요.

결론

@Transactional은 Spring Boot에서 데이터 무결성을 보장하는 핵심적인 기능입니다. 올바른 사용을 위해서는 각 속성의 의미와 동작 방식을 잘 이해하고, 적절한 상황에 맞게 사용하는 것이 중요합니다.

0개의 댓글