[Spring] @Transactional 어노테이션 사용하기

jina·2024년 6월 8일

Spring

목록 보기
2/8

Spring Framework에서 제공하는 @Transactional 어노테이션 기능을 사용해서 트랜잭션을 관리하는 방법을 정리해 보았습니다.

📌 사용 이유

@Transactional 어노테이션을 사용하면 데이터베이스와 관련된 작업을 안전하게 수행할 수 있습니다. 여기서 안전하다는 말은 데이터가 일관된 상태로 유지될 수 있다는 것을 의미합니다. 연관성이 있는 여러 개별 작업들을 트랜잭션이란 하나의 단위로 묶어서 처리하기 때문에 이러한 안전성을 보장할 수 있게 됩니다.

💡 트랜잭션이란?

  • 여러 데이터베이스 작업을 하나의 단위로 묶어서 처리
  • 작업 중간에 오류 발생시 모든 작업을 원상태로 되돌림

📌 작동 방식

  1. 트랜잭션 프록시 생성
    @Transactional 어노테이션이 적용된 메소드를 실행할 때, Spring은트랜잭션 프록시라는 객체를 생성합니다. 메소드가 호출되면 이 프록시 객체가 메소드의 전체 작업을 가로채서 트랜잭션 처리 로직을 추가합니다. 이로써 메소드가 안전히 트랜잭션으로서 시작되면, 트랜잭션 매니저가 호출되어 해당 메소드를 실행하고 제어합니다.

  2. ACID 원칙을 지켜 작업 수행

  • 원자성 (Atomicity): 작업이 모두 성공하거나 모두 실패해야 함
  • 일관성 (Consistency): 완료 후 데이터는 일관되게 유지
  • 독립성 (Isolation): 각 트랜잭션은 서로 영향받지 않음
  • 지속성 (Durability): 완료 후 데이터는 영구적으로 유지
  1. 트랜잭션 종료
  • 정상적으로 작업이 완료되면 트랜잭션 매니저가 트랜잭션을 커밋하고, 커밋된 경우에 완료된 작업 결과가 영구적으로 반영됩니다.
  • 예외가 발생하면 (예: unchecked 예외인 RuntimeException) 모두 롤백합니다. 하지만 CheckedException은 로직에 오류가 발생해도 기본적으로 커밋을 진행합니다. @Transactional에 rollbackFor 속성을 지정하여, 특정 예외에서 트랜잭션을 롤백하도록 설정할 수 있습니다. (예: @Transactional(rollbackFor = IOException.class))

📌 적용 예시

여러 메소드를 호출해서 하나의 비즈니스 로직으로 수행하기를 원하는 경우에 '트랜잭션'으로서 관리해 줄 수 있습니다.

  • 사용자의 주문으로 생성된 주문 정보작업1와 결제 정보작업2를 모두 누락없이 데이터베이스에 저장해야 하는 경우
  • 가입한 사용자 정보를 기록하는 작업작업1을 완료 후 사용자 정보의 이메일로 가입 완료 메일 전송하고, 해당 작업을 완료했다는 기록을 사용자 정보에 업데이트 해야 하는 작업작업2을 수행하는 경우
  • 송금자 계좌에서 돈을 인출하고작업1 수신자 계좌에 돈이 입금되는작업2 두 작업이 반드시 함께 성공하거나 실패해야 하는 송금 기능의 경우

📌 설정 타입

✅ 클래스 전체에 적용하기

해당 클래스에 포함된 모든 메소드를 트랜잭션으로 처리합니다.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional // 클래스 이름 위에 추가
public class AccountService {
	// 여러 메소드들
}

✅ 특정 메소드에 적용하기

특정 메소드만 트랜잭션으로 처리합니다.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    public void createOrder(Order order) {
        // 주문 생성 로직
    }

    @Transactional // 메소드 이름 위에 추가
    public void processOrder(Order order) {
        // 주문 처리 로직
        // 이 메소드에만 트랜잭션이 적용됨
    }

    public void cancelOrder(Long orderId) {
        // 주문 취소 로직
    }
}

✅ 읽기 전용 기능을 적용하기

트랜잭션 내에서 데이터를 변경하려고 시도하면 예외가 발생하도록 처리합니다.

@Service
public class OrderService {
    @Transactional(readOnly = true) // 읽기 전용 적용
    public Order getOrder(Long id) {
        // 조회만 가능하고 수정은 불가
    }
}

✅ 격리 수준을 설정하기

💡 격리 수준은 동시성 문제를 해결한다!

하나의 트랜잭션으로서 처리되는 메소드에 격리 수준이 설정된 경우, 해당 트랜잭션이 완전히 처리되기 전에는 다른 트랜잭션이 데이터를 읽을 수 없습니다. 아래의 격리 수준은 데이터를 읽을 수 있는 수준을 구체적으로 설정하는데 사용됩니다.

// 격리 수준 설정 예시
@Transactional(isolation = Isolation.SERIALIZABLE)
public void someDatabaseOperation() {
    // 데이터베이스 작업
}

🟠 READ_UNCOMMITTED
커밋되지 않은 상태에서도 다른 트랜잭션이 변경 사항을 읽을 수 있습니다. Dirty Read 현상이 생길 수 있습니다.

🟠 READ_COMMITTED
다른 트랜잭션에서 커밋된 데이터만 읽을 수 있습니다. 하지만 트랜잭션 중간에 데이터를 읽게 되면 Non-Repeatable 현상이 생길 수 있습니다.

🟠 REPEATABLE_READ
트랜잭션이 시작된 후 읽은 데이터는 트랜잭션이 끝날 때까지 변경되지 않습니다. (동일 트랜잭션 내에서 동일 데이터를 여러 번 읽어도 항상 결과가 같게 유지) 특정 데이터 항목(관계형 데이터베이스에서는 행)의 변경은 방지하지만, 새로운 데이터 항목의 추가나 기존 항목 삭제를 방지하지는 못하기 때문에 동일한 명령(쿼리)에서 Phantom Read 현상이 생길 수 있습니다.

🟠 SERIALIZABLE
가장 높은 격리 수준으로, 모든 트랜잭션이 순차적으로 실행되며 동시 실행이 불가능합니다.

  • (예시) 은행 시스템에서 두 개의 송금 트랜잭션이 동시에 한 계좌의 잔액을 읽고 업데이트하면 데이터 일관성이 깨질 수 있습니다. 이럴 때 @Transactional(isolation = Isolation.SERIALIZABLE)로 격리 수준을 설정하면 모든 트랜잭션이 순차적으로 동작하는 것처럼 실행하기 때문에 데이터 일관성을 보장할 수 있습니다.

💡 격리 수준이 적합하지 않아 발생하는 현상

  • Dirty Read: 수정되지 않은 데이터 값을 보는 현상
  • Non Repeatable Read: 동일한 작업을 두 번 수행했을 때 두 결과가 다르게 나타나는 현상
  • Phantom Read: 동일한 작업을 두 번 수행했을 때 두 결과의 전체 데이터 크기가 달라지는 현상

✅ 전파 수준을 설정하기

💡 전파 수준은 트랜잭션 경계를 관리한다!

전파 수준은 한 트랜잭션이 다른 트랜잭션 내에서 어떻게 처리되길 원하는지 지정합니다. 동시성 문제 해결을 위해 사용할 수도 있지만 격리 수준만큼 동시성 문제를 철저히 해결하지는 못합니다.

@Service
public class OrderService {

    @Transactional(propagation = Propagation.REQUIRED) // 트랜잭션 경계 지정 예시
    public void processOrder(Order order) {
    	// 영향을 받는 로직
    }
}

대표적으로 REQUIRED, REQUIRES_NEW, NESTED가 있습니다.

🟡 REQUIRED
기존 트랜잭션이 있으면 그 트랜잭션 내에 포함되어 실행되고, 없으면 스스로 새로운 트랜잭션을 시작합니다.

  • 상황: 프로세스A에 @Transactional이 사용되고, 프로세스B에 @Transactional(propagation = Propagation.REQUIRED) 사용
  • 결과: A가 B를 호출한 경우, 먼저 실행된 A의 트랜잭션 맥락을 B가 그대로 이어서 사용합니다.

🟡 REQUIRES_NEW
외부 메소드와 상관없이 항상 새로운 트랜잭션을 생성하고, 외부 메소드가 진행하던 기존 트랜잭션이 있는 경우 그 트랜잭션을 일시 정지시킵니다.

  • 상황: 프로세스B에 @Transactional(propagation = Propagation.REQUIRES_NEW) 사용
  • 결과: A가 B를 호출한 경우, 먼저 실행된 A의 트랜잭션의 맥락과는 무관하게 B는 새로운 트랜잭션으로서 처리됩니다.

🟡 NESTED
중첩된 트랜잭션을 생성합니다.

  • 상황: 외부 트랜잭션, 중첩 트랜잭션 A, 중첩 트랜잭션 B를 생성하고 외부 트랜잭션에 의해서 두 중첩 트랜잭션이 시작되면
  • 결과:
    • 중첩 트랜잭션 A와 B는 외부 트랜잭션의 일부로 동작합니다. (외부 트랜잭션이 최종적으로 커밋되거나 롤백될 때까지 완전히 적용되지 않음!)
    • 중첩 트랜잭션 A나 B 중 하나라도 롤백되면 전체 트랜잭션도 롤백됩니다. (외부 트랜잭션이 롤백되어도 중첩 트랜잭션은 모두 롤백 처리)

✅ 테스트 코드의 DB 업데이트 방지

테스트 환경에서 @Transactional을 사용하면 Spring이 트랜잭션을 롤백하는 설정을 추가합니다. @Test, @SpringBootTest, @ExtendWith(MockitoExtension.class)같은 어노테이션이 붙은 테스트 환경에서는 테스트가 종료되면 트랜잭션이 자동으로 롤백되어 실제 데이터베이스가 테스트에서 사용된 데이터로 변경되지 않습니다.

@Test
@Transactional
void createDiary() {
    diaryServiceImpl.createDiary(date, text);
    verify(diaryRepository, times(1)).save(any(Diary.class));
    // 테스트 종료 시 트랜잭션 롤백
}

참고
https://medium.com/@jkha7371/is-transactional-readonly-true-a-silver-bullet-1dbf130c97f8

profile
오늘의 기록은 내일의 보물

0개의 댓글