[Spring] transaction silently rolled back because it has been marked as rollback-only 오류

하원·2024년 9월 25일
post-thumbnail

안녕하세요, 하원입니다.
이번에는 제가 겪었던 transaction silently rolled back because it has been marked as rollback-only 오류에 대해 소개해 보겠습니다.


롤백 오류

프로젝트를 진행하면서 서비스 단 코드에서 transaction silently rolled back because it has been marked as rollback-only 오류를 겪었는데요, 오류 원인을 찾는데 꽤.. 오랜 시간이 걸렸습니다..!

오류 발생 코드

    // 포스터 전체 조회 (서비스 단)
    public Page<PosterDto> getPosters(Status status, int page, int size) {
        // 페이징 조건 추가
        Pageable pageable = createPageable(page, size);

        Member member;
        try {
            // Member 정보 조회 -> 유저가 없을 경우 GuestUserException throw
            member = memberService.findMemberByEmail();

            // 조건에 맞는 Poster 가져오기
            return posterRepository.findByStatus(status, pageable)
                    .map(poster -> {
                        Boolean isLike = posterLikeRepository.findByMemberIdAndPosterId(member.getMemberId(), poster.getPosterId()).isPresent();
                        return new PosterDto(poster, isLike);
                    });
        } catch (GuestUserException e) {
            // GuestUserException에 대한 처리
            // 게스트용 조회
            return posterRepository.findByStatus(status, pageable)
                    .map(poster -> {
                        Boolean isLike = false;
                        return new PosterDto(poster, isLike);
                    });
        }
    }

예상한 플로우

  1. 서비스 단 코드에서 memberService.findMemberByEmail()을 통해 유저를 조회 (유저가 없다면 GuestUserException throw)
  2. 유저가 정상적으로 조회됐다면, 포스터 관심등록 상태를 조회 및 반영하여 포스터 조회
  3. 만약, 1번에서 GuestUserException이 발생하면 catch문으로 잡아 게스트용 포스터 조회
{
    "message": "Transaction silently rolled back because it has been marked as rollback-only"
}

위와 같은 플로우를 예상하며 서비스 단에서 커스텀 Exception과 catch문을 사용하여 코드를 작성했는데 결과는.. 위와 같았습니다.


원인

// 커스텀 Exception
public class GuestUserException extends RuntimeException{
    public GuestUserException(String message) {
        super(message);
    }
}

원인은 제가 커스텀 Exception에서 상속 받았던 RuntimeExceptionUnchecked Exception으로 예외 발생 시 트랜잭션을 롤백(Rollback) 처리해야 한다는 특징이 있었습니다.

결국, catch문에서 GuestUserException을 잡아도 트랜잭션이 롤백 처리되면서 해당 트랜잭션은 실패 상태로 마치게 된 겁니다. 그래서 catch문에 있던 조회가 동작하지 않았던 것 같습니다.

그러면 Unchecked Exception이 무엇인지 간단하게 소개해 보겠습니다.


Checked Exception & Unchecked Exception

Checked Exception

  • 컴파일 시점에 반드시 예외처리 해야 하는 사항들
  • 예외 발생 시, 트랜잭션을 롤백 처리 않지 않는다.
  • ex) IOException, FileNotFoundException, SQLException, AWTException

Unchecked Exception

  • 런타임 시점에 확인하며, 예외처리를 강제하지 않는다.
  • 개발자의 실수로 인해 발생하는 사항들
  • 예외 발생 시, 트랜잭션을 롤백 처리한다.
  • ex) RuntimeException, NullPointerException, AssertionError, ThreadDeath

위의 내용처럼 RuntimeExceptionUnchecked Exception에 해당하며, 예외 발생 시 트랜잭션을 롤백 처리해 버립니다.

저는 일부로 예외를 발생시켜서 따로 처리하려고 한 건데, 그러면 어떻게 처리해야 할까요?


예외 처리

Service 코드

    // 포스터 전체 조회 (서비스 단)
    public Page<PosterDto> getPosters(Status status, int page, int size) {
        // 페이징 조건 추가
        Pageable pageable = createPageable(page, size);

        // Member 정보 -> 유저가 없을 경우 GuestUserException throw
        Member member = memberService.findMemberByEmail();

        // 조건에 맞는 Poster 가져오기
        return posterRepository.findByStatus(status, pageable)
                .map(poster -> {
                    Boolean isLike = posterLikeRepository.findByMemberIdAndPosterId(member.getMemberId(), poster.getPosterId()).isPresent();
                    return new PosterDto(poster, isLike);
                });
    }

위 코드처럼 서비스 단 코드에서 예외처리를 하지 않고, 그대로 컨트롤러 단에 전달해 주었습니다.


Controller 코드

    @GetMapping
    public ApiResponse<GetPostersResponse> getPosters(
            @RequestParam(name = "status", defaultValue = "ACTIVE") String status,
            @RequestParam(name = "page", defaultValue = "0") int page,
            @RequestParam(name = "size", defaultValue = "10") int size
    ) {
        Status findStatus = Status.valueOf(status);

        try {
            // 포스터 전체 조회 실행
            List<PosterDto> posters = posterService.getPosters(findStatus, page, size).getContent();
            GetPostersResponse result = new GetPostersResponse(posters);

            return new ApiResponse<>(ResponseMessage.POSTER_SUCCESS.getCode(), ResponseMessage.POSTER_SUCCESS.getMessage(), result);
        } catch (GuestUserException e) {
            // 게스트용 포스터 전체 조회 실행
            List<PosterDto> posters = posterService.getGuestPosters(findStatus, page, size).getContent();
            GetPostersResponse result = new GetPostersResponse(posters);

            return new ApiResponse<>(ResponseMessage.POSTER_SUCCESS.getCode(), ResponseMessage.POSTER_SUCCESS.getMessage(), result);
        }
    }

서비스 단으로부터 전달받은 GuestUserException을 컨트롤러 단에서 catch문으로 처리해 주면 됩니다. 이때는 transaction silently rolled back because it has been marked as rollback-only 오류가 발생하지 않습니다.

서비스 단에서 컨트롤러 단으로 GuestUserException이 전달되면서 트랜잭션이 종료되었기 때문에, 컨트롤러의 catch문에서 새로운 트랜잭션을 열어 서비스 단의 메서드를 실행할 수 있게 됩니다.

트랜잭션 종료

  • 서비스 단의 메서드가 완료되면 트랜잭션이 커밋 또는 롤백 처리 되면서 트랜잭션이 종료됩니다.

반성

저는 트랜잭션이 Service 계층과 Repository 계층에서 유지되며, Controller 계층으로 넘어오면 종료된다는 정도로만 알고 있었습니다.

하지만 이번 오류를 통해 Checked Exception과 Unchecked Exception 사이에 트랜잭션 롤백이라는 차이점이 존재한다는 것을 처음 알게 되었습니다. 오류 덕분에 트랜잭션의 동작 원리와 흐름을 자세히 학습하게 되었습니다. 트랜잭션을 가볍게 생각한 저를 반성하게 하는 것 같습니다.


마무리

역시 실제 프로젝트를 진행해야 많은 변수를 만나게 되는 것 같다. 이론 공부를 하면서 겪지 못했던 다양한 상황을 접하게 되면서 경험이 하나씩 쌓이고 있다.
현재 1개의 프로젝트를 대략 5개월 넘게 진행하고 있는데도, 여전히 생소한 오류 발생이 많다.
이런 오류들을 만날수록 성장하고 있다는 뜻 아닐까..?


참고

profile
호기심 저장소

0개의 댓글