스프링 트랜잭션

하마·2025년 4월 17일

Spring

목록 보기
22/22
post-thumbnail

트랜잭션이란?


  • 일상생활에서 가장 쉽게 빗대어 표현할 수 있는 것은 계좌이체
    • 돈을 주는 사람 계좌는 금액이 줄어야하고, 돈을 받는 사람은 금액이 늘어야 합니다.
    • 이 두 작업 중 하나라도 실패하면? 위 과정 전체가 실패처리되어야 합니다.
  • 이처럼 어떤 작업 단위가 정상적으로 끝나지 않았을 때, 진행했던 작업들을 취소하기 위해(= 롤백) 사용되는 것이 트랜잭션 입니다.

ACID 원칙


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

Atomicity

여러개의 작업을 하나의 단위로 묶어 처리해야 합니다.

하나로 묶인 작업은 5개의 작업으로 이루어져있더라도,

  1. 5개가 전부 성공 ✅
  2. 5개가 전부 실패 ❌

의 2가지로만 실제로 반영됩니다.

Consistency

트랜잭션 수행 전후로 항상 일관적인 데이터 구조와 제약을 가져야 합니다.

Isolation

여러 트랜잭션이 동시에 실행되더라도, 각 트랜잭션 간 서로 영향을 주지 않아야 합니다.

Duration

한 번 완료된 트랜잭션은 이후에 그 내용이 소실되지 않습니다.

이 개념은 데이터베이스에서 출발했습니다.


트랜잭션 상태


상태설명
활동초기 상태로, 트랜잭션이 작업을 시작해 실행중인 상태
부분 완료트랜잭션의 마지막 연산까지 실행하고 최종 결과를 DB에 반영하지 않은 상태
완료트랜잭션이 성공적으로 commit되어 DB에 정상적으로 반영된 상태
실패실행 중 오류가 발생하여 트랜잭션을 더 이상 진행할 수 없는 상태
철회실패로 인해 트랜잭션이 롤백되었고, 데이터베이스가 트랜잭션 이전 상태로 돌아간 상태

스프링의 롤백


앞서 살펴봤던 Atomicity(원자성)에 따라, 실패한 지점까지 진행되었던 내용이 롤백되어야 합니다.
여기서 실패는 Exception 상황을 의미합니다.

자바에는 언체크/체크 예외가 존재하기 때문에 각각 다르게 처리해야 합니다.

조건롤백설명
Unchecked Exception
(RuntimeException 또는 Error 계열)
ORunimteException / Error 발생 시 롤백
Checked Exception
(Exception을 상속하고, RuntimeException이 아닌 예외)
X기본적으로 롤백 트리거가 아님
(명시적으로 설정해야 롤백 가능)
@Transactional의 rollBackFor 설정에 포함된
Checked Exception 발생
OrollbackFor 속성에 지정된 체크 예외는 롤백 트리거로 작동
try-catch로 RuntimeException을 처리한 경우ORuntimeException 발생 시 이미 롤백 상태로 마킹되므로,
catch 처리 여부에 무관하게 롤백
try-catch로 Checked Exception을 처리한 경우X
@Transactional의 noRollbackFor 설정에 포함된
Exception 발생한 경우
X
트랜잭션이 외부 트랜잭션에서 관리되는 경우
(Propagation.REQUIRED)
외부 트랜잭션에 따름
트랜잭션이 새 트랜잭션인 경우
(Propagation.REQUIRES_NEW)
내부 트랜잭션이 따름

결론은

Checked Exception → 롤백 ❌
Unchecked Exception → 롤백 ✅
이 기본 베이스만 깔고 가면 됩니다.

위 강조 부분은 배민 기술 블로그에서 다룬 이야기입니다.
응? 이게 왜 롤백되는거지?


트랜잭션 내부 호출 문제


예제 코드를 살펴봅시다.

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    public void outerMethod() {
        memberRepository.save(new Member("outer", 20L));
        innerMethod();
    }

    @Transactional
    public void innerMethod() {
        memberRepository.save(new Member("inner", 10L));
        throw new RuntimeException("innerMethod 실패");
    }
}

outerMethod() 메소드 내부에서 innerMethod() 메소드를 호출하고 있습니다.
여기서 innerMethod()@Transactional 어노테이션이 적용되어있기 때문에,
예외 발생 시 롤백이 일어난다고 생각할 수 있습니다.

하지만 아닙니다!

스프링 컨테이너는 @Service , @Repository 등의
어노테이션을 적용하면 프록시 객체를 자동으로 생성해줍니다.

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

하지만 위 경우는 프록시 객체가 아닌, 실제 객체의 메소드를 호출하는 것입니다.
결론적으로 innerMethod() 메소드의 호출/종료 시점을 알 수 없게 되는겁니다.

@Test
void test_internal_call_fail() {
    try {
        memberService.outerMethod();
    } catch (Exception e) {
        System.out.println("예외 발생: " + e.getMessage());
    }
}

위 코드를 실행해보면, outer , inner 모두 DB에 저장되어 롤백되지 않음을 확인할 수 있습니다.

다음은 여러 코드 예제를 보면서 상황을 봅시다.


Case 1

  • @TransactionalouterMethod() 에 선언된 경우
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional
	public void outerMethod() {
    	memberRepository.save(new Member("outer", 20L));
	    innerMethod(); // 트랜잭션 범위 안에 포함됨
	}

	public void innerMethod() {
    	memberRepository.save(new Member("inner", 10L));
	    throw new RuntimeException("innerMethod 실패");
	}
}

이 경우에는 innerMethod() 가 사실상
throw new RuntimeException("innerMethod 실패") 로 대체되는 것이기 때문에,
정상적으로 모두 롤백되게 됩니다.

Case 2

  • try-catch로 내부 호출을 감싼 형태
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    public void outerMethod() {
        memberRepository.save(new Member("outer", 20L));
        try {
            innerMethod();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Transactional
    public void innerMethod() {
        memberRepository.save(new Member("inner", 10L));
        throw new RuntimeException("예외 발생!");
    }
}

이 경우에는 Case 1 와 마찬가지로 내부 호출의 형태이기 때문에 모두 DB에 저장됩니다.
반대로 @Transactional 어노테이션이 outerMethod() 에 위치해있다면 정상적으로 롤백되겠죠?

Case 3

  • try-catch에서 예외를 던지는 형태
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional
    public void saveAll() {
        memberRepository.save(new Member("outer", 20L));
        try {
            memberRepository.save(new Member("inner", 10L));
            throw new RuntimeException("강제 예외");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

이건 어떨지 예상해 봅시다.
.
.
여기서는 롤백되지 않습니다 (!)
왜냐면 예외가 발생했지만, 예외를 catch 해버렸기 때문입니다.

스프링에서 롤백은 예외가 던져져야만 롤백됩니다.

Case 2와 Case 3의 차이점은??

// Case 2
public void outerMethod() {
	memberRepository.save(new Member("outer", 20L));
	try {
		innerMethod();
	} catch (Exception e) {
		e.printStackTrace();
	}
}

@Transactional
public void innerMethod() {
	memberRepository.save(new Member("inner", 10L));
	throw new RuntimeException("예외 발생!");
}
// Case 3
@Transactional
public void saveAll() {
	memberRepository.save(new Member("outer", 20L));
	try {
		memberRepository.save(new Member("inner", 10L));
		throw new RuntimeException("강제 예외");
	} catch (Exception e) {
		System.out.println("예외 잡힘: " + e.getMessage());
	}
}

어차피 Case2 의 innerMethod()@Transactioanl 이 의미없어지는거니까,
innerMethod() 내부의 코드를 그대로 outerMethod() 의 try 안에 옮겨와도
어차피 둘 다 롤백이 되지 않으니 동일한 코드가 아닐까?

는 아니구요..
예외 전파와 롤백은 두 메소드 모두 이루어지지 않고 있습니다.

하지만 outer + inner 는 트랜잭션 자체가 적용되지 않은 상태입니다.
반대로 saveAll 은 트랜잭션은 적용되어 있습니다.

주요한 원인은 이유입니다.
outer + inner 는 트랜잭션 자체가 없기 때문이고,
saveAll 은 트랜잭션은 적용되어있지만, 예외가 전파되지 않아서 롤백이 되지 않는겁니다.

Case 4

  • Case 2에서 outerMethod()@Transactional 를 붙여봅시다.
@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional
    public void outerMethod() {
        memberRepository.save(new Member("outer", 20L));
        try {
            innerMethod();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Transactional
    public void innerMethod() {
        memberRepository.save(new Member("inner", 10L));
        throw new RuntimeException("예외 발생!");
    }
}

이 경우에는 정상적으로 롤백됩니다.

innerMethod 의 트랜잭션은 시작되지 않지만, outerMethod 의 트랜잭션에 포함되게 됩니다.
그리고 try 안에서 예외가 발생한 걸 outerMethod@Transactional 덕분에 인지하게 됩니다.

결국 catch에서 아무것도 해주지 않아도, 결과적으로 롤백되는 겁니다.

여러 케이스가 있겠지만, 이건 직접 해보면서 느껴야 확 와닿을 것 같습니다.


전파 속성 (Propagation)

하나의 트랜잭션이 시작된 상태에서 또 다른 트랜잭션이 시작되면
해당 트랜잭션을 어떻게 처리할 지 정의하는 것입니다.

종류기존 트랜잭션 존재 X기존 트랜잭션 존재 O적용 사례
REQUIRED새 트랜잭션 생성기존 트랜잭션에 참여기본값. 대부분의 비즈니스 로직에 사용
REQUIRES_NEW새 트랜잭션 생성기존 트랜잭션 일시 중단 및
새로운 트랜잭션 생성
독립적인 작업으로 처리해야할 때 사용

SUPPORTS, MANDATORY, 등등 많은데 위 2개만 알면 됩니다.

1개의 댓글

comment-user-thumbnail
2025년 4월 17일

오늘 벨로그 버그때문에 썸네일이안보이네요 ㅠㅠ

답글 달기