RECENT POSTS

최근에 작성한 글

01 / 03

전체 글

전체 보기

[CS] Java의 예외와 스프링의 트랜잭션

1. Java의 예외

1. 예외(Exception)란?

프로그램 실행 중 예상하지 못한 상황이 발생하여 정상적인 실행 흐름을 방해하는 문제

예를 들어 데이터베이스에서 데이터를 조회하거나 외부 API를 호출하는 과정에서 문제가 발생할 수 있다.

public User findUser(Long id) {
    return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException());
}

 

사용자가 존재하지 않는 경우 UserNotFoundException 이라는 예외를 발생시키고 호출한 쪽에서 해당 예외를 처리하도록 구현할 수 있다.

Java에서는 예외가 발생하면 예외 객체를 생성하여 호출한 메서드 방향으로 전달한다. 이를 예외 전파라고 한다.

 

2. 예외 처리

예외가 발생했을 때 대표적인 처리 방법에는 try-catch 가 있다.

try {
    userService.findUser(id);
} catch (UserNotFoundException e) {
    // 예외 처리
}

 

try 블록에서 예외가 발생하면 이후 코드는 실행되지 않고 해당 예외를 처리할 수 있는 catch 블록으로 실행 흐름이 이동한다.

try {
    System.out.println("1");
    throw new RuntimeException();
    // System.out.println("2");  // 실행되지 않음
} catch (RuntimeException e) {
    System.out.println("예외 발생");
}

 

또는 현재 메서드에서 처리핮 ㅣ않고 throws를 사용하여 호출한 메서드로 예외 처리를 넘길 수 있다.

public void method() throws IOException {
    // IOException 발생 가능
}

3. Java 예외 계층 구조


Java에서 예외와 오류의 최상위 클래스는 Throwable이다.

Throwable은 크게 Error와 Exception으로 나뉜다.

  • Error: JVM이나 시스템 수준에서 발생하는 심각한 문제
    • OutOfMemoryError
    • StackOverflowError
  • Exception: 애플리케이션 실행 중 발생하며 프로그램에서 처리할 수 있는 문제

그리고 Exception은 다시 Checked Exception과 Unchecked Exception으로 나눌 수 있다.

4. Checked Exception

컴파일 시점에 컴파일러가 예외 처리 여부를 확인하는 예외
RuntimeException을 상속하지 않는 Exception의 하위 클래스가 해당한다.

 

대표적으로 IOException, SQLException, ClassNotFoundException이 있다.

 

Checked Exception이 발생할 가능성이 있는 코드를 작성하면 반드시 try-catch로 처리하거나 throws를 사용하여 호출자에게 예외 처리를 넘겨야 한다.

public void readFile() {
    try {
        FileReader file = new FileReader("test.txt");
    } catch (IOException e) {
        // 예외 처리
    }
}

 

호출한 쪽에서 해당 문제를 인지하고 복구할 가능성이 있으며 예외 처리를 강제할 필요가 있을 때 사용할 수 있다.
예를 들어 파일을 읽지 못했을 때 다른 파일을 사용하거나 사용자에게 다시 파일을 선택하도록 요구하는 경우 등이 있다.

5. Unchecked Exception

컴파일러가 예외 처리를 강제하지 않는 예외이다.

대표적으로 NullPointerException, IllegalArgumentException, IllegalStateException, IndexOutOfBoundsException 등이 있다.

public void withdraw(int amount) {
    if (amount <= 0) {
        throw new IllegalArgumentException("출금 금액은 0보다 커야 합니다.");
    }
}

 

try-catch나 throws를 작성하지 않아도 컴파일 오류가 발생하지 않는다.

그렇다고 Unchecked Exception을 처리할 수 없다는 의미는 아니다.

try {
    withdraw(-1000);
} catch (IllegalArgumentException e) {
    // 처리 가능
}

 

단지 컴파일러가 처리를 강제하지 않는 것이다.

 

잘못된 인자나 애플리케이션의 잘못된 상태처럼 호출자가 반드시 복구하도록 강제할 필요가 없는 문제에 주로 사용한다.

Spring 애플리케이션에서 비즈니스 예외를 직접 정의할 때도 RuntimeException을 상속하여 Unchecked Exception으로 만드는 경우가 많다.

public class UserNotFoundException extends RuntimeException {

    public UserNotFoundException(String message) {
        super(message);
    }
}

6. Checked Exception VS Unchecked Exception

구분 Checked Exception Unchecked Exception
상속 관계 RuntimeException을 제외한 Exception 하위 클래스 RuntimeException 및 하위 클래스
컴파일러 검사 O X
예외 처리 강제 O X
try-catch 가능 가능
throws 가능 가능
대표 예외 IOException, SQLException NullPointerException,
IllegalArgumentException
사용 호출자의 복구를 강제할 필요가 있는 경우 잘못된 인자·상태, 비즈니스 예외 등
@Transactional 기본 Rollback X O

2. @Transactional

1. @Transactional이란?

Spring에서 트랜잭션의 시작, 커밋, 롤백을 관리하기 위해 사용하는 어노테이션

Spring의 @Transactional은 기본적으로

  • RuntimeException 및 Error 발생 => Rollback
  • Checked Exception 발생 => 기본적으로 Rollback하지 않음
    이라는 정책을 사용한다.
    Checked Exception에서도 롤백하고 싶으면 다음과 같이 설정할 수 있다.
    @Transactional(rollbackFor = Exception.class)
    public void process() throws Exception {
      // ...
    }

2. @Transactional 동작 원리

Spring의 @Transactional은 AOP(Aspect-Oriented Programming)와 Proxy 기반으로 동작한다.

 

@Transactional이 적용된 객체를 Spring Bean으로 등록하면 Spring은 실제 객체를 직접 사용하는 대신 트랜잭션 처리를 담당하는 Proxy 객체를 생성한다.

Controller
    ↓
Proxy
    ↓
Service
    ↓
Repository

 

외부에서 @Transactional이 적용된 메서드를 호출하면 실제 Service 객체를 바로 호출하는 것이 아니라 Proxy를 거쳐 호출하게 된다.

 

Proxy는 메서드 호출 전후에 트랜잭션 처리를 추가한다.

예를 들어 다음과 같이 @Transactional을 적용했다고 하자.

@Transactional
public void transfer() {
    accountA.withdraw(10000);
    accountB.deposit(10000);
}

 

실제로는 Proxy가 트랜잭션을 시작한 후 transfer()를 호출하고, 메서드가 정상적으로 종료되면 트랜잭션을 Commit한다.

반대로 예외가 발생하면 예외의 종류와 설정에 따라 Rollback 여부를 결정한다.

 

즉, 개발자가 직접 트랜잭션의 시작과 종료를 작성하지 않아도 Spring의 Proxy가 이를 대신 처리해준다.

3. 같은 클래스 내부에서 호출하면 @Transactional이 적용되지 않는 이유

@Transactional은 Proxy를 통해 트랜잭션 기능을 적용하기 때문에 같은 클래스 내부에서 트랜잭션 메서드를 직접 호출하면 적용되지 않을 수 있다.

 

예를 들어 다음과 같은 Service가 있다고 하자.

@Service
public class UserService {

    public void createUser() {
        saveUser();
    }

    @Transactional
    public void saveUser() {
        // DB 작업
    }
}

 

외부에서 createUser()를 호출하면 다음과 같이 동작한다.

Controller
    ↓
Proxy
    ↓
createUser()
    ↓
saveUser()

 

문제는 createUser() 내부에서 saveUser()를 호출하는 부분이다.

saveUser();

 

같은 객체 내부의 메서드 호출은 사실상 다음과 같다.

this.saveUser();

 

이 호출은 Spring의 Proxy를 다시 거치지 않고 실제 객체 내부에서 직접 호출된다.

 

따라서 saveUser()에 @Transactional이 붙어 있어도 Proxy가 해당 호출을 가로채지 못해 새롭게 트랜잭션 처리가 적용되지 않는다.

 

정상적인 외부 호출

Controller
    ↓
Proxy
    ↓  트랜잭션 시작
@Transactional
saveUser()


같은 클래스 내부 호출

Controller
    ↓
Proxy
    ↓
createUser()
    ↓
this.saveUser()
     ↑
Proxy를 거치지 않음

 

이를 Self Invocation(자기 호출) 문제라고 한다.

 

단, createUser() 자체에 이미 @Transactional이 적용되어 있다면 saveUser()의 코드도 기존 트랜잭션 안에서 실행될 수 있다. 문제는 saveUser()에 붙은 @Transactional 설정이 내부 호출만으로는 별도로 적용되지 않는다는 것이다.

4. Self Invocation 해결 방법

대표적인 해결 방법은 트랜잭션이 필요한 메서드를 별도의 Spring Bean으로 분리하는 것이다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserTransactionService userTransactionService;

    public void createUser() {
        userTransactionService.saveUser();
    }
}
@Service
public class UserTransactionService {

    @Transactional
    public void saveUser() {
        // DB 작업
    }
}

 

이제 다른 Spring Bean의 메서드를 호출하기 때문에 Proxy를 거치게 되고 @Transactional이 정상적으로 적용된다.

UserService
    ↓
UserTransactionService Proxy
    ↓
트랜잭션 시작
    ↓
saveUser()
    ↓
Commit / Rollback