Spring Transaction Exception 상황에서 Rollback 처리하기
스프링을 사용하면서 많은 서비스들이 트랜잭션을 단위로 하는 비즈니스 로직을 구현합니다.
저는 비즈니스 로직을 구현하는 과정에서 많이들 놓치는 Exception이 발생 상황에 대해서 살펴보려고 합니다.
가장 중요한 것은 Exception 타입에 따라서 어떻게 비즈니스 로직이 진행될지 판단하고, 처리하는 것입니다.
쿠팡 사용자 회원가입 기능을 예로 들겠습니다. 가입 시에 사용자에게 쿠폰을 발급하는 경우가 존재할 수 있습니다.
사용자가 가입하는 도중에 원인 모를 상황이 발생할 수 있습니다.
시스템이 셧다운 되거나, 메모리를 초과할 수도 있고, 또는 개발 단계에서 잘못된 구현으로 트랜잭션이 완료되지 못할 수 있습니다.
여기서 우리가 구분해야 하는 부분이 있습니다. Error와 Exception의 구분입니다.
- 시스템 셧다운이나 메모리 문제는 Error 상황입니다.
: Error는 시스템 레벨에서 정상적으로 실행되지 못하는 상황으로 H/W와 관련이 있습니다. Error가 발생하면 메모리를 확충하는 방법으로 해결이 가능합니다.
- 개발 단계에서 잘못된 구현의 문제는 Exception 상황입니다.
: Exception은 구현 과정에서 예측하지 못한 상황으로 S/W와 관련이 있습니다. Exception이 발생하면 구현 로직을 수정하여 처리가 가능한 상황입니다.
저희는 이 글에서 스프링에서 처리가 가능한 Exception 상황에 대해서만 살펴보겠습니다.
다시 돌아와서, 쿠팡 사용자 회원가입 기능을 Exception 관점에서 바라보겠습니다.
Exception 상황은 비즈니스의 요구사항에 따라서 달라질 수 있는데요. 두 가지의 다른 예로 설명하겠습니다.
사용자 가입은 허용하지만, 쿠폰 발급은 보류해도 된다.
Or
사용자 가입과 쿠폰 발급이 동시에 완료되지 못한 경우에는 모두 Rollback 되어야 한다.
위 두 가지 상황은 구현에 있어서 다른 개념으로 접근해야 합니다.
설명에 앞서서, Exception에 대해서 설명하고 진행하겠습니다.
Exception이란?
: 정상적인 프로그램의 흐름을 방해하는 예외적인 이벤트라는 뜻으로 "Exceptional event"의 약어입니다.
위에서 했던 이야기를 Hierarchy 그림으로 설명드리겠습니다. Error에 대해서는 깊게 설명하지 않겠습니다.
Error와 Exception은 Throwable을 상속합니다. 부모는 같죠. 하지만 하는 역할은 위에서 설명했던 것처럼 다릅니다.
아마 천천히 살펴보시면 한 번쯤은 들어보시고 경험해보셨을 Exception일 겁니다.
여기서 저희는 아까 "쿠팡 회원가입" 기능 구현을 위해서 Exception에 대해서 한 단계 더 들어가 보겠습니다.
위 계층도에서 Exception은 다시 두 갈래로 나뉘게 됩니다.
하나는 Checked Exception
나머지 하나는 Unchecked Exception
입니다.
정리하면 다음과 같습니다.
Checked Exception | Unchecked Exception | |
정의 | Exception의 상속받는 하위 클래스 중 Runtime Exception을 제외한 모든 Exception | Runtime Exception 하위 Exception |
발생 이유 |
주로 외부의 영향으로 발생할 수 있는 것들로서, 프로그램의 사용자들의 동작에 의해서 발생하는 경우가 많습니다. 예를 들면, 존재하지 않는 파일을 처리하려는 경우(FileNotFoundException), 실수로 클래스의 이름을 잘못 적은 경우(ClassNotFoundException), 입력한 데이터의 형식이 잘못된 경우(DataFormatException)에 발생합니다. |
주로 프로그래머의 실수에 의해서 발생될 수 있는 예외들로서 자바의 프로그래밍 요소들과 관계가 깊습니다. 예를 들면, 배열의 범위를 벗어난 경우(IndexOutOfBoundsException), |
처리여부 | 명시적인 예외 처리를 해주어야합니다. | 명시적인 처리를 강제하지는 않습니다. |
확인시점 |
Compile time : 이미 컴파일 시점에 에러를 나타내기 때문에 try~catch~finally 또는 throws 구문을 통해서 처리할 수 있게 IDE에서 알려줍니다. |
Runtime : 이미 컴파일이 끝나고, 애플리케이션이 서비스가 런타임일 때 발생하기 때문에 try~catch 또는 throws 구문을 사용해서 로직상에서 방어 코드를 만들어 주어야 합니다. |
예외발생시 트랜잭션 처리 | non-rollback | rollback |
보통 Exception 처리는 다음 두 가지의 방식으로 처리할 수 있습니다.
1. try catch finally - 직접 처리 방식
2. throws - 간접 처리 방식
1. try catch finally - 직접 처리 방식
: 메소드 내에서 직접 Exception을 처리하는 경우 사용합니다.
try {
// 예외 발생 가능성이 있는 코드
// 예외가 발생하면 try 블록의 나머지 문장들은 수행되지 않습니다.
} catch(Exception ex) {
// 최소 하나의 catch 블록
// Exception은 java.lang.Throwable 클래스의 하위 클래스 타입으로 선언되어야 합니다.
// try 블록 안에서 발생한 예외와 동일한 예외 타입 catch 블록을 수행합니다.
// 보통 catch 문 안에는 로직을 넣지 않습니다. log만 찍고 끝냅니다.
// catch 블록이 여러 개로 구성될 때, 계층구조가 높을수록 아래로 내려갑니다. 즉, 자식 Exception부터 아래로 구성합니다.
// 부모 Exception부터 Catch 블록이 구성되면 UnReachableException이 발생합니다.
} finally {
// finally 블록은 필수 블록이 아닙니다
// 무조건 실행됩니다. 그래서 항상 수행해야 할 필요가 있는 코드를 넣습니다
}
2. throws - 간접 처리 방식
: 코드가 있는 메서드를 호출하는 곳으로 예외 처리의 책임을 넘깁니다.
void innerMethod() throws UnCheckedException {
UnCheckedException uce = new UnCheckedException();
uce.unCheckedException(); // Exception 발생
}
public static void main(String[] args) {
try{
innerMethod(); // 메서드 호출
} catch(UnCheckedException ex){
System.err.println(e);
}
}
* 로직상에 Exception 처리와는 관련 없지만, 예외를 발생시키는 방법: throw
public class Main {
public static void throwExample() throws Exception {
throw new Exception();
}
public static void main(String[] args) {
try {
throw new Exception();
} catch (Exception e){
System.out.println("예외가 발생했습니다...");
}
}
}
여기까지 Exception에 대해서 설명을 마치겠습니다.
이제 Exception에 대해서도 설명이 끝났습니다. 그러면 이제 원래 주제에 대해서 다시 이어서 이야기해보겠습니다.
쿠팡 사용자 회원가입 기능을 구현하는 데에 Transaction 내에서 Exception 관점에서 바라보겠습니다.
비즈니스의 요구사항에 따라서 Exception 처리는 달라질 수 있습니다.
1번 경우) 사용자 가입은 허용하지만, 쿠폰 발급은 보류해도 된다.
Or
2번 경우) 사용자 가입과 쿠폰 발급이 동시에 완료되지 못한 경우에는 모두 Rollback 되어야 한다.
1번 경우는 Checked exception을 이용할 수 있습니다.
2번 경우는 Unchecked exception을 이용할 수 있습니다.
다음과 같은 Service Componet가 있다고 가정합니다.
@Service
public class UserService {
@Autowired
private CouponRepository couponRepository;
@Transactional
public void signUpUser1(UserDto userDto) throws Exception {
save(userDto);
try {
throw new Exception();
couponRepository.issueCoupon(userDto.getId());
} catch(Exception ex) {
// skip
}
}
@Transactional
public void signUpUser2(UserDto userDto) throws RuntimeException {
couponRepository.issueCoupon(userDto.getId());
save(userDto);
throw new RuntimeException("RuntimeException for rollback");
}
}
1번 경우) 사용자 가입은 허용하지만, 쿠폰 발급은 보류해도 된다.
: 임의로 Checked Exception을 발생시켰지만, 회원 정보는 생성되었고, 쿠폰 발급은 문제없이 진행됩니다.
@Transactional
public void signUpUser1(UserDto userDto) throws Exception {
save(userDto); // 회원 정보 생성
try {
throw new Exception(); // Checked Exception 발생
couponRepository.issueCoupon(userDto.getId()); // 쿠폰 발급 보류
} catch(Exception ex) {
// skip
}
// rollback 없이 Transaction 완료
}
- 스프링에서는 @Transactional을 사용한 Checked Exception은 롤백되지 않습니다.
- 이유는 스프링이 EJB에서의 관습을 따르기 때문이라고 합니다.
2번 경우) 사용자 가입과 쿠폰 발급이 동시에 완료되지 못한 경우에는 모두 Rollback 되어야 한다.
: 임의로 Unchecked Exception을 발생과 동시에 모두 Rollback 됩니다.
@Transactional
public void signUpUser2(UserDto userDto) throws RuntimeException {
save(userDto); // 회원 정보 생성
// Transaction 안에서 Unchecked Exception 발생
throw new RuntimeException("RuntimeException for rollback");
couponRepository.issueCoupon(userDto.getId()); // 쿠폰 정보 생성
// Transaction rollback
}
- Unchecked Exception은 Rollback 됩니다.
그래서 결국 Exception을 Checked 또는 Unchecked를 구분하여 로직의 Rollback 여부를 판단하여, 구현할 수 있습니다.
물론 스프링은 기본적으로 Checked 또는 Unchecked를 구분하여 Rollback을 구분하지만, rollback 시킬 Exception을 지정할 수 있습니다.
방법은 다음과 같습니다.
@Transactional의 rollbackFor 옵션을 이용하면 Rollback이 되는 클래스를 지정할 수 있습니다.
// Exception예외로 롤백을 하려면 다음과 같이 지정하면됩니다.
@Transactional(rollbackFor = Exception.class)
// 여러개의 예외를 지정할 수도 있습니다.
@Transactional(rollbackFro = {RuntimeException.class, Exception.class})
추가적으로 특정 예외가 발생하면 롤백이 되지 않도록 지정하는 방법입니다.
@Transactional(noRollbackFor={IgnoreRollbackException.class})
이렇게 "Spring Transaction Exception 상황에서 Rollback 처리하기"를 마치겠습니다.