Posts [Spring] @Transactional(rollbackFor={exceptionClass})
Post
Cancel

[Spring] @Transactional(rollbackFor={exceptionClass})

스프링에서 @Transactional을 사용하면 기본적으로 모든 예외에 대해 롤백하는 줄 알았다.
근데 아니었다.

@Transactional에는 rollbackFor 옵션이 있다.
요 옵션의 주석을 보면 다음과 같다.

By default, a transaction will be rolling back on RuntimeException and Error but not on checked exceptions (business exceptions). See org.springframework.transaction.interceptor.DefaultTransactionAttribute.rollbackOn(Throwable) for a detailed explanation.

기본적으로 RuntimeExceptionError에 대해서만 롤백하고, Exception에 대해서는 롤백하지 않는다.
즉 unchecked exception이 발생하면 롤백하고, checked exception이 발생하면 커밋한다.


주석을 따라 DefaultTransactionAttribute 클래스를 확인했다.
요 클래스에는 많은 메소드가 있는데, 그 중 rollbackOn()의 주석을 읽어봤다.

The default behavior is as with EJB: rollback on unchecked exception ({@link RuntimeException}), assuming an unexpected outcome outside of any business rules. Additionally, we also attempt to rollback on {@link Error} which is clearly an unexpected outcome as well. By contrast, a checked exception is considered a business exception and therefore a regular expected outcome of the transactional business method, i.e. a kind of alternative return value which still allows for regular completion of resource operations.

This is largely consistent with TransactionTemplate’s default behavior, except that TransactionTemplate also rolls back on undeclared checked exceptions (a corner case). For declarative transactions, we expect checked exceptions to be intentionally declared as business exceptions, leading to a commit by default.

결국 unchecked exception은 비즈니스상 예상하지 못해 롤백하고, checked exception은 의도했을 수 있어 커밋한다.


여기까지 @Transactional의 기본적인 롤백 전략을 이해했다.

요 전략이 실제로 잘 적용되는 건지 궁금했다.
직접 코드를 통해 확인하고 싶었다.
그래서 학습 테스트를 진행했다.

학습 테스트는 아래에서 볼 수 있다.


학습 테스트

코드 작성

Member 엔티티를 생성했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Member.java
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    protected Member() {
    }

    public Member(String name) {
        this.name = name;
    }

    // getter
}


MemberController에 Member를 저장하는 API를 만들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// MemberController.java
@RestController
public class MemberController {

    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @PostMapping("/api/members")
    public ResponseEntity<Void> save(String name) throws Exception {
        memberService.save(name);
        return ResponseEntity.ok().build();
    }
}


MemberService에 각 예외를 분기 처리하는 로직을 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// MemberService.java
@Service
@Transactional
public class MemberService {

    private static final Logger LOG = LoggerFactory.getLogger(MemberService.class);

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public void save(String name) throws Exception {
        Member member = new Member(name);
        memberRepository.save(member);

        // 예외는 아무거나 던졌다.
        if ("커밋".equals(name)) {
            LOG.info("[Exception] member 저장 성공 - 커밋 O, 롤백 X");
            throw new ActionException();
        }
        if ("롤백 1".equals(name)) {
            LOG.info("[RuntimeException] member 저장 실패 - 커밋 X, 롤백 O");
            throw new IllegalStateException();
        }
        if ("롤백 2".equals(name)) {
            LOG.info("[Error] member 저장 실패 - 커밋 X, 롤백 O");
            throw new IllegalAccessError();
        }
        LOG.info("member 저장 성공 - 커밋 O, 롤백 X");
    }
}


MemberRepository도 정의했다.

1
2
3
4
// MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> {

}


결과 확인

포스트맨에서 API를 호출하고, DB에서 결과를 확인했다.


  1. localhost:8080/api/members?name=다니
  2. localhost:8080/api/members?name=롤백 1
  3. localhost:8080/api/members?name=롤백 2
  4. localhost:8080/api/members?name=커밋

주석 내용대로 unchecked exception은 롤백됐고, checked exception은 커밋됐다.
ID 값이 1, 4인 걸 보면 알 수 있다.


rollbackFor 추가

어떤 예외가 발생해도 롤백하고 싶어 rollbackFor에 Exception.class를 지정했다.
Exception.class는 모든 예외의 상단에 위치해 있기 때문이다.

1
2
3
4
5
6
@Service
@Transactional(rollbackFor = {Exception.class})
public class MemberService {

    ...
}


  1. localhost:8080/api/members?name=다니
  2. localhost:8080/api/members?name=롤백 1
  3. localhost:8080/api/members?name=롤백 2
  4. localhost:8080/api/members?name=커밋
  5. localhost:8080/api/members?name=데이지

요기서는 Exception.class를 롤백 조건에 명시하여 모든 예외에 대해 롤백이 일어났다.
ID 값이 1, 5인 것을 보면 알 수 있다.


결론

@Transactional의 기본적인 롤백 전략은 unchecked exception이다.
하지만 커스텀하게 변경할 수 있다.
특정 예외에 대해 롤백하고 싶다면, 해당 예외 클래스를 rollbackFor에 적어주면 된다.

새로운 사실을 알았으니 앞으로 잘 활용해야겠다.
요것과 관련한 이슈가 생겼을 때, 디버깅을 좀 더 쉽게 할 수 있을 것 같다.


References

This post is licensed under CC BY 4.0 by the author.