트랜잭션은 재사용할 수 없다구요!

혁콩·2024년 5월 25일

모두의 음악

목록 보기
2/17
post-thumbnail

들어가기에 앞서

프로젝트 진행 도중 UnexpectedRollbackException 이 발생했다.

원인을 찾기 위한 디버깅 과정과 문제 해결을 위해 고민한 내용들을 정리해봤다.

상황

그룹에 가입하는 서비스 로직이다. 기존에 가입했던 전적을 통해 분기가 진행된다.

@Transactional
public void joinGroup(GroupJoinRequestDto dto, Long groupId) {
    ...
    try {
        Profile loggedInProfile = profileService.getLoggedInProfile(groupId);
        // 이미 가입했던 그룹에 대한 처리
    } catch (NoLoggedInProfileException e) {
        // 새로 가입하는 그룹에 대한 처리
    }
}

로그인 된 프로필을 조회하는 로직은 다음과 같다.

@Transactional(readOnly = true)
public Profile getLoggedInProfile(Long groupId) {
    Long userId = jwtReader.getUserId();
    
    return profileRepository.findByUserIdAndGroupId(userId, groupId)
            .orElseThrow(NoLoggedInProfileException::new);
}

구현을 마치고 해당 기능을 실행했더니 UnexpectedRollbackException이 발생했다.

한참동안 이유를 찾은 것 같다. 디버깅 과정을 거쳐 찾은 이유는 바로바로

트랜잭션은 재사용 할 수 없다구요!

원인

생각해보면 굉장히 당연한 예외다.

Spring의 트랜잭션은 기본 전파 레벨이 REQUIRED며, 이는 기존 트랜잭션이 존재한다면 참여한다는 설정이다.

실제로 DB 커넥션을 붙잡고 있는 물리 트랜잭션은 가장 바깥쪽에 단 하나만 생성되며, 나머지는 스프링 레벨에서 논리 트랜잭션으로 적용된다.

논리 트랜잭션은 실패 시 (여기선 RuntimeException을 throw했다) Rollback-only 마크를 하고, Rollback-only가 마크되었다면 내부 트랜잭션이 실패했다고 판단, 전체 트랜잭션을 롤백한다.

	catch (NoLoggedInProfileException e) {
        // 새로 가입하는 그룹에 대한 처리
    }

위 부분에서 발생하는 예외에 대한 처리를 진행했다고 생각했는데

@Transactional(readOnly = true)
public Profile getLoggedInProfile(Long groupId) {
    Long userId = jwtReader.getUserId();
    
    return profileRepository.findByUserIdAndGroupId(userId, groupId)
            .orElseThrow(NoLoggedInProfileException::new);
}

이미 이 내부 트랜잭션에서 Rollback-only 가 마크되었기에, 바깥에서 예외 처리를 진행했어도 롤백이 되는 상황이다.

핑계를 대보자면.. 프로필 조회 시 RuntimeException 을 던진 이유는 다음과 같다.

  1. 그룹 가입 기능을 제외한 다른 동작은 모두 가입을 필수로 한다.
  2. 이에, 예외를 던져 해당 동작을 전역적으로 컨트롤 해 불필요한 반복을 피한다.

자. 이제 해결 방법에 대해 고민해보자.

방법

1. NoLoggedInProfileException을 Checked Exception으로 변경

처음 고민했던 방법이다. Spring Transaction의 기본 전략은 Unchecked Exception 발생시에만 롤백한다. 따라서, 기존의 UnChecked Exception을 Checked Exception으로 바꾸어 rollback을 사용하지 않고, 예외 처리를 강제한다.

다만, 로그인 된 사용자의 프로필을 가져오는 경우는 굉장히 빈번하게 발생하며, Checked Exception을 사용할 경우 강제되는 예외 처리가 생산성 저하로 이어질 수 있다고 생각했다.

2. 별도의 메소드 구현

Optional 자체를 반환하는 그룹 가입 전용 별도의 메소드를 구현한다.

그룹 가입 시에는 Optional을 직접 받아 체크하여 분기를 실시하며, 그룹 가입이 아닌 다른 메소드들은 NoLoggedInProfileException 을 던지는 기존의 메소드를 사용한다.

이럴 경우 다른 추가 기능의 경우 매우 간편하게 사용할 수 있지만, 메소드 이름과 사용법을 명확히 구분해야 하므로 개발 복잡성이 증가할 것 같았다.

3. Transactional 제거

로그인 된 프로필 조회에 @Transactional 어노테이션을 제거한다.

트랜잭션이 아니기에 더이상 Rollback-only 가 마크되지 않으며, 기존에 생각했던 로직대로 NoLoggedInProfileException의 핸들링을 통해 신규 가입에 대한 처리가 가능해진다.

다만, JPA의 영속성 컨텍스트에 들어가지 않기에 프로필을 수정하기 위해선 영속성 컨텍스트에 추가하는 작업이 필요하다.

4. NoLoggedInProfileException의 처리 전략을 변경

1번 방법의 연장선으로, Spring Transaction의 롤백 전략을 수정하는 방법이다. 프로필 조회 시 NoLoggedInProfileException이 발생해도 롤백을 하지 않도록 수정한다.

@Transactional(noRollbackFor = NoLoggedInProfileException.class)
public Profile getLoggedInProfile(Long groupId) { ... }

Checked Exception을 사용할 시 발생할 수 있는 생산성 저하를 완전히 대체할 수 있다.

다만, 특정 Unchecked Exception의 처리 전략을 바꾼다는 건 예외 처리의 일관성이 깨진다고도 볼 수 있으며, 처리를 강제하지 않는 Unchecked Exception 특성 상 시스템이 커질수록 복잡해질 수 있다는 것이다.

해결

결론 먼저 말하자면, 필자는 4번 방법을 택했다. 그 이유는 다음과 같다.

  1. 로그인 된 프로필 조회는 데이터를 변경시키지 않으므로, 단일 트랜잭션이 실패해도 데이터 정합성이 깨지지 않는다.

  2. 트랜잭션 전파를 통해 기존 트랜잭션에 참여했을 경우, rollback only가 마크되지 않아 상위 트랜잭션에서 해당 Unchecked Exception을 핸들링할 수 있다.

  3. 예외 처리 로직이 없는 경우 상위 트랜잭션 또한 동일한 예외를 전파하기에, 별다른 처리 로직이 없을 경우 전체 트랜잭션은 롤백된다.

  4. 그룹 가입을 제외한다면 NoLoggedInProfileException이 발생하는 모든 트랜잭션은 실패해야 한다. 즉, 그룹 가입 메소드에서만 처리를 해준다면 다른 메소드에선 별도의 처리가 불필요하다.

마치면서

내부 트랜잭션에서 rollback only가 마크되면 외부 트랜잭션이 롤백된다.

스프링을 공부할 때, 당연하게도 다뤘던 부분이다.

머리로는 이미 알고있는 내용임에도 불구하고 문제가 발생했고, 뭐가 문제인지 찾는데도 한참 걸렸다.

실패를 통해 배운다

공부한 내용을 오류 없이 적용하는 것이 가장 좋겠지만 이러한 실패를 바탕으로 문제를 경험하고, 해결하는 과정을 겪으며 더욱 단단해진다고 생각한다.

동일한 문제가 발생한다면 더욱 빠르게 원인을 파악하고 문제를 해결할 수 있을테니..!

참고자료

우아한 기술블로그 - 응? 이게 왜 롤백되는거지?

profile
아는 척 하기 좋아하는 콩

0개의 댓글