| 개념 | 설명 |
|---|---|
| Atomicity(원자성) | 모든 작업은 성공적으로 처리되거나, 아무 작업도 일어나지 않음 |
| Consistency(일관성) | 하나의 트랜잭션이 끝난 뒤에도 모든 상태는 이전과 같이 유효함 |
| Isolation (격리성) | 모든 트랜잭션은 다른 트랜잭션으로부터 독립적임 |
| Durability (지속성) | 하나의 트랜잭션이 완료되면 영구적으로 저장됨 |
여러개의 작업을 하나의 단위로 묶어 처리해야 합니다.
하나로 묶인 작업은 5개의 작업으로 이루어져있더라도,
의 2가지로만 실제로 반영됩니다.
트랜잭션 수행 전후로 항상 일관적인 데이터 구조와 제약을 가져야 합니다.
여러 트랜잭션이 동시에 실행되더라도, 각 트랜잭션 간 서로 영향을 주지 않아야 합니다.
한 번 완료된 트랜잭션은 이후에 그 내용이 소실되지 않습니다.
이 개념은 데이터베이스에서 출발했습니다.

| 상태 | 설명 |
|---|---|
| 활동 | 초기 상태로, 트랜잭션이 작업을 시작해 실행중인 상태 |
| 부분 완료 | 트랜잭션의 마지막 연산까지 실행하고 최종 결과를 DB에 반영하지 않은 상태 |
| 완료 | 트랜잭션이 성공적으로 commit되어 DB에 정상적으로 반영된 상태 |
| 실패 | 실행 중 오류가 발생하여 트랜잭션을 더 이상 진행할 수 없는 상태 |
| 철회 | 실패로 인해 트랜잭션이 롤백되었고, 데이터베이스가 트랜잭션 이전 상태로 돌아간 상태 |
앞서 살펴봤던 Atomicity(원자성)에 따라, 실패한 지점까지 진행되었던 내용이 롤백되어야 합니다.
여기서 실패는 Exception 상황을 의미합니다.
자바에는 언체크/체크 예외가 존재하기 때문에 각각 다르게 처리해야 합니다.
| 조건 | 롤백 | 설명 |
|---|---|---|
| Unchecked Exception (RuntimeException 또는 Error 계열) | O | RunimteException / Error 발생 시 롤백 |
| Checked Exception (Exception을 상속하고, RuntimeException이 아닌 예외) | X | 기본적으로 롤백 트리거가 아님 (명시적으로 설정해야 롤백 가능) |
@Transactional의 rollBackFor 설정에 포함된Checked Exception 발생 | O | rollbackFor 속성에 지정된 체크 예외는 롤백 트리거로 작동 |
| try-catch로 RuntimeException을 처리한 경우 | O | RuntimeException 발생 시 이미 롤백 상태로 마킹되므로, 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
@Transactional이outerMethod()에 선언된 경우@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에서 아무것도 해주지 않아도, 결과적으로 롤백되는 겁니다.
여러 케이스가 있겠지만, 이건 직접 해보면서 느껴야 확 와닿을 것 같습니다.
하나의 트랜잭션이 시작된 상태에서 또 다른 트랜잭션이 시작되면
해당 트랜잭션을 어떻게 처리할 지 정의하는 것입니다.
| 종류 | 기존 트랜잭션 존재 X | 기존 트랜잭션 존재 O | 적용 사례 |
|---|---|---|---|
| REQUIRED | 새 트랜잭션 생성 | 기존 트랜잭션에 참여 | 기본값. 대부분의 비즈니스 로직에 사용 |
| REQUIRES_NEW | 새 트랜잭션 생성 | 기존 트랜잭션 일시 중단 및 새로운 트랜잭션 생성 | 독립적인 작업으로 처리해야할 때 사용 |
SUPPORTS, MANDATORY, 등등 많은데 위 2개만 알면 됩니다.
오늘 벨로그 버그때문에 썸네일이안보이네요 ㅠㅠ