반응형
체크 예외를 던졌는데 회원 데이터가 롤백되지 않았다 - @Transactional 기본 롤백 정책과 rollbackFor

@Transactional을 붙여두면 메서드 안에서 예외가 터졌을 때 알아서 롤백해줄 거라고 막연히 믿고 있었다. 그런데 회원가입 로직에 정책 위반을 알리는 예외를 체크 예외(checked exception)로 만들어 던졌더니, 분명히 예외가 터졌는데도 회원 데이터가 그대로 커밋되어 있는 걸 발견했다.

결론부터 말하면 @Transactional의 기본 롤백 규칙은 "예외가 터지면 무조건 롤백"이 아니라 "RuntimeException이나 Error가 터지면 롤백, 체크 예외는 롤백 안 함"이다. 이 글에서는 같은 회원가입 로직을 언체크 예외/체크 예외/rollbackFor 지정/rollbackFor 타입을 잘못 지정한 경우 네 가지로 나눠서 실제로 DB에 데이터가 남는지 직접 확인하고 왜 이렇게 동작하는지 살펴본다.

준비물

항목버전비고
JDK21 LTS
Spring Boot3.3.4Spring Web + Spring Data JPA
MariaDB10.11Ubuntu 24.04 apt 기본 패키지 버전
MariaDB Connector/J3.4.1JDBC 드라이버
빌드 도구Mavenmvn spring-boot:run으로 실행

MariaDB가 없다면 먼저 띄우자.

sudo apt-get update
sudo apt-get install -y mariadb-server

# systemd가 없는 컨테이너 환경이면 데몬을 직접 띄운다
sudo mkdir -p /var/run/mysqld && sudo chown mysql:mysql /var/run/mysqld
sudo -u mysql /usr/sbin/mariadbd --user=mysql &

# systemd가 있는 일반 환경이면 이걸로 충분하다
# sudo systemctl start mariadb

전용 데이터베이스와 계정을 만든다.

mysql -u root <<'EOF'
CREATE DATABASE IF NOT EXISTS txdemo CHARACTER SET utf8mb4;
CREATE USER IF NOT EXISTS 'txuser'@'localhost' IDENTIFIED BY 'txpass1234';
GRANT ALL PRIVILEGES ON txdemo.* TO 'txuser'@'localhost';
FLUSH PRIVILEGES;
EOF

pom.xml에는 Spring Web, Spring Data JPA, MariaDB 드라이버만 있으면 된다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.mariadb.jdbc</groupId>
    <artifactId>mariadb-java-client</artifactId>
    <version>3.4.1</version>
</dependency>

src/main/resources/application.yml은 이렇게 잡았다. 트랜잭션이 실제로 커밋되는지 롤백되는지를 눈으로 보려고 JpaTransactionManager 로그 레벨을 DEBUG로 켰다.

spring:
  datasource:
    url: jdbc:mariadb://localhost:3306/txdemo
    username: txuser
    password: txpass1234
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    org.springframework.orm.jpa.JpaTransactionManager: DEBUG

따라 하기

Step 1. 회원 엔티티와 체크 예외 클래스 만들기

이름과 이메일만 가진 최소한의 회원 엔티티를 만든다.

@Entity
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;

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

    public Long getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }
}

public interface MemberRepository extends JpaRepository<Member, Long> {
}

가입을 거절할 때 쓸 예외를 체크 예외로 만든다. RuntimeException이 아니라 Exception을 직접 상속한다는 점이 핵심이다.

public class SignupRejectedException extends Exception {
    public SignupRejectedException(String message) {
        super(message);
    }
}

Step 2. 네 가지 시나리오를 서비스 메서드로 만들기

실험 방법은 단순하다. 이메일이 @blocked.com으로 끝나면 회원을 저장한 "다음에" 예외를 던진다. @Transactional이 제대로 작동한다면 저장했던 회원 행도 예외와 함께 롤백되어야 정상이다.

@Service
public class SignupService {

    private final MemberRepository memberRepository;

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

    private void rejectBlockedDomain(String email) throws SignupRejectedException {
        if (email.endsWith("@blocked.com")) {
            throw new SignupRejectedException("차단된 도메인입니다: " + email);
        }
    }

    // A: 언체크 예외(RuntimeException) + 기본 @Transactional
    @Transactional
    public Member signupRuntimeFail(String name, String email) {
        Member saved = memberRepository.save(new Member(name, email));
        if (email.endsWith("@blocked.com")) {
            throw new IllegalStateException("차단된 도메인입니다: " + email);
        }
        return saved;
    }

    // B: 체크 예외(SignupRejectedException) + 기본 @Transactional
    @Transactional
    public Member signupCheckedFail(String name, String email) throws SignupRejectedException {
        Member saved = memberRepository.save(new Member(name, email));
        rejectBlockedDomain(email);
        return saved;
    }

    // C: 체크 예외 + rollbackFor = Exception.class
    @Transactional(rollbackFor = Exception.class)
    public Member signupCheckedFailRollbackFor(String name, String email) throws SignupRejectedException {
        Member saved = memberRepository.save(new Member(name, email));
        rejectBlockedDomain(email);
        return saved;
    }

    // D: 체크 예외 + rollbackFor를 엉뚱한 타입(IOException)으로 지정
    @Transactional(rollbackFor = java.io.IOException.class)
    public Member signupCheckedFailWrongRollbackFor(String name, String email) throws SignupRejectedException {
        Member saved = memberRepository.save(new Member(name, email));
        rejectBlockedDomain(email);
        return saved;
    }
}

Step 3. 컨트롤러와 예외 핸들러 만들기

체크 예외를 메서드 시그니처에 throws로 선언해야 컴파일이 된다. 컨트롤러까지 그대로 전파되므로 @ExceptionHandler로 잡아서 400으로 정리해준다.

@RestController
public class SignupController {

    private final SignupService signupService;
    private final MemberRepository memberRepository;

    public SignupController(SignupService signupService, MemberRepository memberRepository) {
        this.signupService = signupService;
        this.memberRepository = memberRepository;
    }

    @PostMapping("/signup/runtime")
    public ResponseEntity<Member> signupRuntime(@RequestBody SignupRequest request) {
        Member member = signupService.signupRuntimeFail(request.name(), request.email());
        return ResponseEntity.ok(member);
    }

    @PostMapping("/signup/checked")
    public ResponseEntity<Member> signupChecked(@RequestBody SignupRequest request) throws SignupRejectedException {
        Member member = signupService.signupCheckedFail(request.name(), request.email());
        return ResponseEntity.ok(member);
    }

    @PostMapping("/signup/checked-rollback-for")
    public ResponseEntity<Member> signupCheckedRollbackFor(@RequestBody SignupRequest request) throws SignupRejectedException {
        Member member = signupService.signupCheckedFailRollbackFor(request.name(), request.email());
        return ResponseEntity.ok(member);
    }

    @PostMapping("/signup/checked-wrong-rollback-for")
    public ResponseEntity<Member> signupCheckedWrongRollbackFor(@RequestBody SignupRequest request) throws SignupRejectedException {
        Member member = signupService.signupCheckedFailWrongRollbackFor(request.name(), request.email());
        return ResponseEntity.ok(member);
    }

    @GetMapping("/members")
    public List<Member> members() {
        return memberRepository.findAll();
    }

    @ExceptionHandler(SignupRejectedException.class)
    public ResponseEntity<String> handleSignupRejected(SignupRejectedException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
    }
}

throws를 빼먹으면 실행도 해보기 전에 컴파일러가 막아준다. (자세한 내용은 아래 「자주 만나는 에러와 해결」 참고.)

Step 4. 네 시나리오를 실제로 호출해서 DB로 확인하기

mvn spring-boot:run으로 띄운 다음, 순서대로 호출하면서 매번 회원 목록을 확인한다.

# A. 언체크 예외 + 기본 @Transactional
curl -sS -X POST http://localhost:8080/signup/runtime \
  -H "Content-Type: application/json" \
  -d '{"name":"A-런타임","email":"a@blocked.com"}'

curl -sS http://localhost:8080/members
여기까지 하면 이렇게 나온다. 응답은 500 Internal Server Error이고, /members는 빈 배열 []이다. 언체크 예외는 기본 설정 그대로 롤백된다는 것을 확인할 수 있다.
# B. 체크 예외 + 기본 @Transactional (핵심)
curl -sS -X POST http://localhost:8080/signup/checked \
  -H "Content-Type: application/json" \
  -d '{"name":"B-체크예외","email":"b@blocked.com"}'

curl -sS http://localhost:8080/members
이번엔 결과가 좀 다르다. 응답은 400이고 메시지도 정상 출력된다. 그런데 /members를 확인해보면 {"id":2,"name":"B-체크예외","email":"b@blocked.com"} 행이 그대로 남아 있다. 예외가 처리돼서 깔끔한 400이 나갔다고 안심할 부분이 아니라는 것을 확인할 수 있다.
# C. 체크 예외 + rollbackFor = Exception.class
curl -sS -X POST http://localhost:8080/signup/checked-rollback-for \
  -H "Content-Type: application/json" \
  -d '{"name":"C-롤백포","email":"c@blocked.com"}'

curl -sS http://localhost:8080/members
이쪽은 반대다. 응답은 400이고 /members에는 새 행이 추가되지 않는다. rollbackFor = Exception.class를 지정하면 체크 예외도 정상적으로 롤백되는 것을 확인할 수 있다.
# D. 체크 예외 + rollbackFor를 엉뚱한 타입으로 지정
curl -sS -X POST http://localhost:8080/signup/checked-wrong-rollback-for \
  -H "Content-Type: application/json" \
  -d '{"name":"D-잘못된롤백포","email":"d@blocked.com"}'

curl -sS http://localhost:8080/members
마지막 D는 다시 B를 닮았다. 응답은 400인데 /members{"id":4,"name":"D-잘못된롤백포", ...} 행이 또 추가된다. rollbackFor를 지정하긴 했지만 실제로 던진 예외(SignupRejectedException)와 무관한 타입(IOException)을 지정하면 B와 똑같이 롤백이 안 된다는 것을 확인할 수 있다.

전체 코드 (복붙용)

Member.java

package com.example.txdemo;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Member {

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

    private String name;
    private String email;

    protected Member() {
    }

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

MemberRepository.java

package com.example.txdemo;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}

SignupRejectedException.java

package com.example.txdemo;

public class SignupRejectedException extends Exception {
    public SignupRejectedException(String message) {
        super(message);
    }
}

SignupRequest.java

package com.example.txdemo;

public record SignupRequest(String name, String email) {
}

SignupService.java

package com.example.txdemo;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class SignupService {

    private final MemberRepository memberRepository;

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

    private void rejectBlockedDomain(String email) throws SignupRejectedException {
        if (email.endsWith("@blocked.com")) {
            throw new SignupRejectedException("차단된 도메인입니다: " + email);
        }
    }

    @Transactional
    public Member signupRuntimeFail(String name, String email) {
        Member saved = memberRepository.save(new Member(name, email));
        if (email.endsWith("@blocked.com")) {
            throw new IllegalStateException("차단된 도메인입니다: " + email);
        }
        return saved;
    }

    @Transactional
    public Member signupCheckedFail(String name, String email) throws SignupRejectedException {
        Member saved = memberRepository.save(new Member(name, email));
        rejectBlockedDomain(email);
        return saved;
    }

    @Transactional(rollbackFor = Exception.class)
    public Member signupCheckedFailRollbackFor(String name, String email) throws SignupRejectedException {
        Member saved = memberRepository.save(new Member(name, email));
        rejectBlockedDomain(email);
        return saved;
    }

    @Transactional(rollbackFor = java.io.IOException.class)
    public Member signupCheckedFailWrongRollbackFor(String name, String email) throws SignupRejectedException {
        Member saved = memberRepository.save(new Member(name, email));
        rejectBlockedDomain(email);
        return saved;
    }
}

SignupController.java

package com.example.txdemo;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
public class SignupController {

    private final SignupService signupService;
    private final MemberRepository memberRepository;

    public SignupController(SignupService signupService, MemberRepository memberRepository) {
        this.signupService = signupService;
        this.memberRepository = memberRepository;
    }

    @PostMapping("/signup/runtime")
    public ResponseEntity<Member> signupRuntime(@RequestBody SignupRequest request) {
        Member member = signupService.signupRuntimeFail(request.name(), request.email());
        return ResponseEntity.ok(member);
    }

    @PostMapping("/signup/checked")
    public ResponseEntity<Member> signupChecked(@RequestBody SignupRequest request) throws SignupRejectedException {
        Member member = signupService.signupCheckedFail(request.name(), request.email());
        return ResponseEntity.ok(member);
    }

    @PostMapping("/signup/checked-rollback-for")
    public ResponseEntity<Member> signupCheckedRollbackFor(@RequestBody SignupRequest request) throws SignupRejectedException {
        Member member = signupService.signupCheckedFailRollbackFor(request.name(), request.email());
        return ResponseEntity.ok(member);
    }

    @PostMapping("/signup/checked-wrong-rollback-for")
    public ResponseEntity<Member> signupCheckedWrongRollbackFor(@RequestBody SignupRequest request) throws SignupRejectedException {
        Member member = signupService.signupCheckedFailWrongRollbackFor(request.name(), request.email());
        return ResponseEntity.ok(member);
    }

    @GetMapping("/members")
    public List<Member> members() {
        return memberRepository.findAll();
    }

    @ExceptionHandler(SignupRejectedException.class)
    public ResponseEntity<String> handleSignupRejected(SignupRejectedException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
    }
}

TxDemoApplication.java

package com.example.txdemo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TxDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(TxDemoApplication.class, args);
    }
}

실행 결과 확인

네 시나리오를 실제로 호출한 결과를 표로 정리했다.

시나리오던진 예외rollbackForHTTP 응답DB 반영
A. 언체크 예외 + 기본IllegalStateException(기본)500롤백됨
B. 체크 예외 + 기본SignupRejectedException(기본)400커밋됨 (버그)
C. 체크 예외 + 올바른 rollbackForSignupRejectedExceptionException.class400롤백됨
D. 체크 예외 + 잘못된 rollbackForSignupRejectedExceptionIOException.class400커밋됨 (버그)

가장 눈여겨봐야 할 건 B다. JpaTransactionManagerDEBUG로 찍어보면 A는 이렇게 나온다.

Creating new transaction with name [...signupRuntimeFail]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Initiating transaction rollback
Rolling back JPA transaction on EntityManager [...]

그런데 B는 예외가 똑같이 발생했는데도 로그가 이렇게 찍힌다.

Creating new transaction with name [...signupCheckedFail]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
Initiating transaction commit
Committing JPA transaction on EntityManager [...]

롤백이 아니라 커밋이다. 컨트롤러 입장에서는 SignupRejectedException을 잡아서 400으로 잘 처리했으니 아무 문제 없어 보이지만 그 사이에 저장했던 회원 행은 이미 커밋되어 DB에 남아버린 뒤다.

C는 트랜잭션 이름 뒤에 붙는 속성 문자열부터 다르다.

Creating new transaction with name [...signupCheckedFailRollbackFor]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-java.lang.Exception
Initiating transaction rollback
Rolling back JPA transaction on EntityManager [...]

-java.lang.Exception이라고 붙은 부분이 바로 "이 타입이 발생하면 롤백한다"는 규칙이 트랜잭션 정의에 실제로 실려 있다는 표시다. rollbackFor = Exception.class를 지정하니 체크 예외도 이 규칙에 걸려서 정상적으로 롤백됐다.

D는 겉보기엔 C와 똑같이 rollbackFor를 지정했는데도 결과가 다르다.

Creating new transaction with name [...signupCheckedFailWrongRollbackFor]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-java.io.IOException
Committing JPA transaction on EntityManager [...]

트랜잭션 정의에는 -java.io.IOException 규칙이 실려 있지만 실제로 던진 예외는 SignupRejectedException이고 이 클래스는 IOException의 하위 타입이 아니다. 규칙에 안 걸리니 B와 똑같이 그냥 커밋되어버린다. rollbackFor를 넣기만 하면 되는 게 아니라 실제로 던지는 예외 타입(또는 그 상위 타입)을 정확히 지정해야 한다는 것을 확인할 수 있다.

[TODO: 본인 환경에서 실행한 터미널 화면(curl 응답, /members 조회 결과, JpaTransactionManager 로그)을 여기에 캡처해서 추가하세요]

같은 트랜잭션인데 예외 종류와 rollbackFor 설정에 따라 커밋/롤백이 갈리는 흐름

왜 체크 예외는 기본으로 롤백이 안 될까

@Transactional은 실제로는 프록시가 메서드 호출을 감싸고 있다가 메서드가 예외를 던지면 그 예외 타입을 정해진 규칙과 비교해서 롤백할지 커밋할지 결정하는 구조다. 스프링이 기본으로 들고 있는 규칙은 단순하다. RuntimeException이거나 Error면 롤백, 그 외(체크 예외)는 커밋.

이 규칙은 스프링이 처음 나온 시절 자바 진영의 관례를 그대로 반영했다. 체크 예외는 "복구 가능한 예외적 상황"(재고 부족, 결제 실패처럼 호출한 쪽이 처리해서 계속 진행할 수도 있는 상황)에 쓰고, 언체크 예외는 "복구 불가능한 프로그래밍 오류"에 쓴다는 관례가 있었다. 그래서 스프링은 "체크 예외는 호출한 쪽이 알아서 처리할 상황이니 트랜잭션은 일단 커밋해도 된다"는 쪽으로 기본값을 잡았다.

문제는 실무에서는 이 관례대로 체크 예외를 쓰는 경우가 많지 않다는 데 있다. 이번 실험처럼 "정책 위반이니 저장한 것도 되돌려야 한다"는 의도로 체크 예외를 만들면 의도와 정반대로 동작한다. rollbackFor(또는 짝인 noRollbackFor)로 이 기본 규칙을 직접 덮어써야 하고 그때 지정하는 타입은 반드시 실제로 던지는 예외의 클래스이거나 그 상위 타입이어야 한다.

자주 만나는 에러와 해결

체크 예외를 던지는 메서드에 throws를 안 붙이면 컴파일이 안 된다

public ResponseEntity<Member> signupChecked(@RequestBody SignupRequest request) {
    Member member = signupService.signupCheckedFail(request.name(), request.email());
    return ResponseEntity.ok(member);
}

이렇게 컨트롤러 메서드에 throws SignupRejectedException을 빼먹으면 이런 컴파일 에러가 난다.

[ERROR] unreported exception com.example.txdemo.SignupRejectedException;
must be caught or declared to be thrown

원인: 체크 예외는 자바 컴파일러가 "이 메서드를 호출하는 쪽은 이 예외를 알고 있어야 한다"고 강제하는 예외다. 호출하는 메서드도 똑같이 throws로 선언하거나 try-catch로 잡아야 한다.

해결: 컨트롤러 메서드 시그니처에 throws SignupRejectedException을 추가하거나 메서드 안에서 직접 try-catch로 잡아서 원하는 응답으로 변환한다.

체크 예외인데 원인도 없이 500만 뜬다

@ExceptionHandler를 안 만들어두면 어떻게 되는지도 확인해봤다. 이 경우엔 체크 예외든 언체크 예외든 스프링 MVC의 기본 에러 처리를 그대로 타서 응답은 이렇게 나간다.

{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/signup/checked"}

여기서 더 위험한 게 있다. 500이 떴다고 해서 "서버 에러니까 저장은 안 됐겠지"라고 넘겨짚기 쉬운데 /members로 확인해보면 회원 행은 그대로 커밋되어 남아 있다. 500이라는 응답 코드와 실제 롤백 여부는 완전히 별개다. 응답 코드만 보고 데이터 정합성을 판단하면 안 되는 이유가 여기에 있다.

해결: @ExceptionHandler로 원하는 상태 코드와 메시지를 명시적으로 내려주고 체크 예외를 롤백 대상으로 만들 생각이었다면 rollbackFor도 반드시 같이 지정한다.

rollbackFor를 지정했는데도 롤백이 안 된다

D에서 확인했듯 rollbackFor에 지정한 타입과 실제로 던지는 예외 타입이 안 맞으면 지정 자체가 무의미해진다. 상속 계층이 다른 예외를 넣거나 오타로 비슷한 이름의 다른 예외 클래스를 넣는 실수가 흔하다.

해결: 확실하지 않으면 rollbackFor = Exception.class처럼 넓게 잡아서 "이 트랜잭션은 어떤 예외든 롤백한다"로 시작하고 필요하면 noRollbackFor로 특정 예외만 예외 처리한다.

마무리

@Transactional이 예외만 터지면 무조건 롤백해줄 거라고 믿고 있었는데 실제로는 체크 예외냐 언체크 예외냐에 따라 기본 동작이 갈린다는 걸 직접 재현해서 확인했다. 게다가 400이라는 멀쩡해 보이는 응답 뒤에서 데이터가 커밋되어 있었다는 게 제일 찜찜했다. rollbackFor로 고칠 수 있지만 타입을 정확히 맞춰야 한다는 것도 D에서 같이 확인했다.

응답 코드만 보고 "예외 처리 잘 됐네"라고 넘어갔으면 계속 몰랐을 문제라 회원가입 같은 도메인 예외를 체크 예외로 설계할 때는 꼭 rollbackFor를 같이 챙겨야겠다고 생각했다. 재현 자체는 시나리오 네 개를 순서대로 호출하고 DB만 확인하면 되는 거라 어렵지 않았다.

다음에는 서비스 메서드 안에서 예외를 catch로 잡아서 로그만 남기고 삼켜버리면 rollbackFor를 제대로 지정해놨어도 왜 롤백 자체가 트리거되지 않는지를 직접 재현해볼 생각이다.

반응형