코딩/sparta TIL

TIL 45 : Transaction

americanoallday 2025. 5. 6. 17:47

Transaction 속성

하나의 트랜잭션이 발생했을 때 안전성을 보장하기 위한 중요한 4가지 속성을 말합니다.

Atomicity (원자성)  모든 작업이 성공하거나 아무 작업도 일어나지 않음
Consistency (일관성) 하나의 트랜잭션 끝난 뒤에도 모든 상태는 이전과 같이 유효
Isolation (격리성) 모든 트랜잭션은 다른 트랜잭션으로부터 독립적
Durability (지속성) 하나의 트랜잭션이 완료되었다면 영구적으로 저장

이 개념은 1970년대 말에 짐 그레이(Jim Gray)가 신뢰할 수 있는 트랜잭션 시스템의 이러한 특성으로 정의했고, 자동으로 이들을 수행하는 기술을 개발했습니다.

Transaction 상태

  • 활동 상태 (active) : 초기 상태로, 트랜잭션이 실행 중일 때 가지는 상태
  • 부분 완료 상태 (partially committed) : 트랜잭션의 모든 명령문이 성공적으로 실행된 후, 커밋만 남은 상태
  • 완료 상태 (committed) : 트랜잭션이 성공적으로 커밋되어 DB에 영구 반영된 상태
  • 실패 상태 (failed) : 실행 중 오류가 발생하여 트랜잭션을 더 이상 진행할 수 없는 상태
  • 철회 상태 (aborted) : 실패로 인해 트랜잭션이 롤백되었고, 데이터베이스가 트랜잭션 이전 상태로 환원된 상태

Transaction 롤백

결론

Checked Exception → 롤백 X

Unchecked Exception → 롤백 O

 

우리는 앞서 트랜잭션의 ACID 중 Atomicity(원자성) 속성을 배웠습니다.

모든 작업이 성공하거나 아무 작업도 일어나지 않음

이 속성에 의하면 트랜잭션 중에 실패하면 반드시 아무 작업도 일어나지 않아야 합니다. 이는 즉 기존 완료된 작업이 Rollback이 되어야 한다는 의미가 됩니다.

여기서 실패는 Exception 상황을 의미하고, 이를 정리하자면, 아래와 같이 생각할 수 있습니다.

Tx 내부에서 Exception 발생 → Atomicity 보장 필요 → Rollback

하지만 이 말은 반은 맞고 반은 틀립니다.

Exception 에는 Unchecked/Checked Exception 가 존재하고 각각 처리가 다릅니다.

위 오렌지 부분의 이야기가 바로 아래 배민 블로그의 이야기 입니다. 한 번 정도 정독해보시면 좋을 것 같습니다.

⚠️아래 링크의 내용을 다르고 있습니다. 자세한 내용은 이 블로그를 보시는게 좋습니다.

Transaction 내부 호출 문제

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    public void saveWithTx() {
        memberRepository.save(new Member("Outer method", 20L));
        saveInnerMember();
    }

    @Transactional
    public void saveInnerMember() {
        memberRepository.save(new Member ("Inner method - no transaction", 10L));
        throw new RuntimeException("Exception in saveInnerPost");
    }
 
}


@SpringBootTest
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @Test
    void saveWithTx() {
        try {
            memberService.saveWithTx();
        } catch (RuntimeException ex) {
            System.out.println("Caught exception: " + ex.getMessage());
        }
    }
}

saveWithTx() 안에서 saveInnerMember()를 호출하고 있습니다. saveInnerMember는 @Tx가 적용되어 있기 때문에 예외 발생시 롤백이 일어난다고 생각할 수 있습니다.

하지만 아닙니다.

saveInnerMember()는 @Tx가 적용되지 않습니다. 아래 이미지를 보겠습니다.

스프링은 트랜잭션을 적용하기 위해서는 반드시 프록시 객체를 통해야 합니다. 프록시 객체는 우리가 Contoller, @Service, @Repository와 같은 어노테이션을 적용하면 사용할 수 있는 것입니다. 이미 익숙하게 쓰고 있는 어노테이션이고 이 어노테이션을 적용하면 우리는 프록시 객체를 쓰는 것입니다.

이 프록시 객체를 통해 메소드 호출이 일어나면 메소드가 호출 되는 시점, 종료되는 시점에 각각 트랜잭션 처리가 되는 원리입니다.

하지만 Service 내부에서 메소드를 호출하는 경우는 프록시 객체가 아닌 실체 객체의 메소드를 호출하게 되는 것입니다. 이 때문에 스프링 입장에서는 메소드가 호출되고 종료되는 시점을 알 수 없습니다.

이런 문제로 앞서 살펴본 예제에서는 트랜잭션이 적용되지 않는 것입니다.

코드로 예를 들면 대략 이런 형태입니다.

// Spring이 생성하는 프록시 클래스
public class MyServiceProxy extends MyService {

    private MyService target; // 실제 서비스 인스턴스

    @Override
    public void saveWithTx() {
        try {
            target.saveWithTx();  // 실제 서비스 메소드 호출
        } catch (Exception e) {
            throw e;
        }
    }

    @Override
    public void saveInnerMember() {
        // 트랜잭션 시작
        transaction.start()
        try {
            target.saveInnerMember();
            // 트랜잭션 커밋
		        transaction.commit()
        } catch (Exception e) {
            // 트랜잭션 롤백
		        transaction.rollback()
            throw e;
        }
    }
    
}

Transaction 전파 속성 (Propagation)

트랜잭션에서 전파 속성은 하나의 트랜잭션이 시작된 상태에서, 또 다른 트랜잭션이 시작되었을 때 이를 어떻게 처리할지를 정의하는 것입니다.

종류  기존 트랜잭션 X  기존 트랜잭션 O  적용 사례
REQUIRED 새 트랜잭션 생성 기존 트랜잭션에 참여 기본값. 대부분의 비지니스 로직
REQUIRES_NEW 새 트랜잭션 생성 기존 트랜잭션 일시중단, 새로운 트랜잭션 생성 독립적인 작업 처리
SUPPORTS 트랜잭션 없이 진행 기존 트랜잭션 참여 트랜잭션이 필수가 아닌 작업
NOT_SUPPORTED 트랜잭션 없이 진행 기존 트랜잭션 일시중단, 트랜잭션 없이 진행 로그 저장 등 트랜잭션과 독집적인 작업
MANDATORY IllegalTransactionStateException 발생 기존 트랜잭션 참여 트랜잭션 내부에서만 호출 가능한 메소드
NEVER 트랜잭션 없이 진행 IllegalTransactionStateException 발생 외부 시스템 호출시
NESTED 새 트랜잭션 생성 중첩 트랜잭션 생성 부분적으로 롤백 가능한 작업

이처럼 트랜잭션은 굉장이 복잡하고 어려운 기술 중에 하나입니다.

비록 오늘 이 모든 것을 이해할 수 없더라도 지속적으로 관심을 가지고 학습하시길 바라겠습니다.

Transaction 격리 수준 (Isolation Level)

트랜잭션 격리레벨은 DB마다 다릅니다! 아래의 내용은 MySQL 기준이니 참고 바랍니다! 동일한 트랜잭션 레벨의 이름을 갖고 있더라도 DB마다 세부적인 내용은 다를 수 있습니다!

1. READ UNCOMMITTED

  • 아직 커밋되지 않은 데이터도 다른 트랜잭션에서 접근할 수 있는 격리 수준
  • Dirty Read : 커밋-트랜잭션이 끝나지 않았는데도 데이터를 읽을 수 있기 때문에 정합성에 문제가 많음.

2. READ COMMITTED

  • 커밋된 데이터만 다른 트랜잭션에서 읽기 가능한 격리 수준
  • Non-Repeatable Read: 한 트랜잭션에서 첫 번째 조회를 한 이후 두 번째 조회를 하기 전에 사이에 다른 트랜잭션에서 데이터 삽입/수정 커밋이 이뤄지면 한 트랜잭션 내에서 같은 조회 쿼리를 날려도 결과가 달라질 수 있음.

3. REPEATABLE READ (MySQL의 디폴트 트랜잭션 레벨)

  • 데이터 변경 전의 레코드를 트랜잭션 번호와 함께 언두 공간에 백업해놓기 때문에, READ COMMITTED 처럼 그 사이에 다른 트랜잭션이 커밋되었더라도 언두 공간에 백업해놓은 데이터를 참조하기 때문에 항상 같은 조회 결과를 보장하는 격리 수준
  • Phantom Read : 다른 트랜잭션에서 데이터 변경이 아닌 추가를 할 경우, 언두 공간에 백업할 게 없기 때문에 한 트랜잭션 내에서 데이터가 안 보였다 → 보였다하는 현상.

4. SERIALIZABLE

  • 데이터 접근 시 락을 걸어서 같은 데이터에 다른 트랜잭션이 동시에 접근할 수 없는 격리 수준

이를 정리하면 아래와 같습니다.

수준  Dirty Read  Non-repeatable Read  Phantom Read
READ_UNCOMMITTED O O O
READ_COMMITTED X O O
REPEATABLE_READ X X O
SERIALIZABLE X X X

각 항목에 대한 예제코드를 보고 어떤 결과가 나오는지 확인해보겠습니다.

 

READ UNCOMMITTED

@Service
public class ReadUncommittedExample {

    @Transactional(isolation = Isolation.READ_UNCOMMITTED)
    public void transactionA(UserRepository userRepository) {
        User user = userRepository.findById(1L).orElseThrow();
        user.setBalance(500); // 커밋하지 않고 값만 변경
        try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); }
    }

    @Transactional(isolation = Isolation.READ_UNCOMMITTED)
    public void transactionB(UserRepository userRepository) {
        User user = userRepository.findById(1L).orElseThrow();
        System.out.println("Tx2 Read balance: " + user.getBalance());
        // 결과: Transaction 1에서 커밋되지 않은 500 출력 (Dirty Read 발생).
    }
}

 

READ COMMITTED

@Service
public class ReadCommittedExample {

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void transactionA(UserRepository userRepository) {
        User user = userRepository.findById(1L).orElseThrow();
        user.setBalance(500); // 커밋하지 않고 값만 변경
        try { Thread.sleep(5000); }
	        catch (InterruptedException e) { e.printStackTrace(); }
    }

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void transactionB(UserRepository userRepository) {
        User user = userRepository.findById(1L).orElseThrow();
        System.out.println("Tx2 Read balance: " + user.getBalance());
        // 결과: Transaction 1에서 커밋되지 않은 상태에서는 변경 전 값 출력.
    }
}

 

REPEATABLE READ

@Service
public class RepeatableReadExample {

    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void transactionA(UserRepository userRepository) {
        User user = userRepository.findById(1L).orElseThrow();
        System.out.println("Tx1 First balance: " + user.getBalance());

        try { Thread.sleep(5000); }
	        catch (InterruptedException e) { e.printStackTrace(); }

        User userAfter = userRepository.findById(1L).orElseThrow();
        System.out.println("Tx1 Second balance: " + userAfter.getBalance());
        // 결과: First balance와 Second balance 동일 (Non-Repeatable Read 방지).
    }

    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void transactionB(UserRepository userRepository) {
        User user = userRepository.findById(1L).orElseThrow();
        user.setBalance(500); // 변경된 값은 Transaction 1에 영향 없음
        userRepository.save(user);
    }
}

 

SERIALIZABLE

@Service
public class SerializableExample {

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void transactionA(UserRepository userRepository) {
        List<User> users = userRepository.findAll();
        System.out.println("Tx1 Users: " + users.size());

        try { Thread.sleep(5000); }
	        catch (InterruptedException e) { e.printStackTrace(); }
    }

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void transactionB(UserRepository userRepository) {
        User newUser = new User("New User", 1000);
        userRepository.save(newUser); // Transaction 1 종료 전까지 삽입 대기
    }
}

 

스프링 트랜잭션 어노테이션

스프링에는 2가지 @Tx 어노테이션이 존재합니다.

이미지에서 첫번째는 Java에서 지원하는 어노테이션이고, 두번째는 스프링이 지원하는 어노테이션입니다.

두 가지는 트랜잭션의 기본 기능은 모두 동일하게 지원합니다.

하지만 스프링의 어노테이션이 더 많은 기능을 지원하고 있기 때문에 jakara 보다는 스프링의 @Tx 어노테이션을 사용하는 것이 더 좋습니다.

 

<스프링의 @Transactional 옵션>