예외(Exception) 처리
적절한 예외 핸들링은 애플리케이션에 필수적이지만 생각보다 중요성을 간과할 수 있는 부분이라 생각합니다.
이 글에서는 예외를 다룰 때마다 고민했던 포인트들과 예외를 처리하고 생성하는 Best Practice에 대해 정리합니다.
1) 예외 처리 (Handling Exceptions) 할 때
메서드가 에러로 인해 완료되지 않았을 때 상태를 복구한다.
메서드에서 예외가 발생했을 때, 호출자는 부작용이 없다고 가정할 수 있어야 합니다.
계좌 이체 코드 (한 계좌에서 출금하고 다른 계좌에 입금하는 코드)를 예로 들면,
출금 로직은 정상적으로 성공했는데 입금 로직에서 예외가 발생하면, 출금이 그대로 완료되지 않도록 해야 합니다.
이를 위해 처리할 수 있는 방안은, 입금 거래 과정 중 한 곳이라도 예외가 발생하면 그전에 정상적으로 완료한 출금을 롤백하도록 하는 것입니다.
롤백 방식은 코드의 상황에 따라 다를 수 있겠지만, 여기에서는 try-catch문의 catch문에서 롤백 처리하도록 하겠습니다.
입금 로직을 try 문으로 감싸고 예외가 발생하면 catch 문에서 출금을 롤백한 후에, 예외를 던지도록 합니다.
이때 예외는 새로 정의한 커스텀 예외를 던지면서 원래 예외를 내부 예외에 포함키도록 하였습니다.
private static void TransferFunds(Account from, Account to, decimal amount) {
String withdrawalTrxID = from.withdrawal(amount);
try {
to.deposit(amount);
}
catch (Exception ex) {
from.RollbackTransaction(withdrawalTrxID);
throw CustomException(ex);
}
}
예외를 적절하게 잡아서 다시 던진다.
예외가 발생하면, 그 정보들의 일부가 스택 트레이스 (Stack trace)에 담깁니다.
스택 트레이스는 예외를 발생시킨 메서드에서 시작해서 예외를 잡는 메서드에서 끝나는,
메서드의 호출 계층 목록입니다.
즉, 어디에선가 예외가 발생했는데
그 예외를 잡을 때, 기존 예외의 정보를 담지 않고 새로운 예외로 다시 던지게 되면
스택 트레이스가 현재 메서드에서 다시 시작되기 때문에
예외를 처음 던진 원래 메서드와 현재 메서드 사이의 스택 트레이스 (메서드 호출 목록)이 사라지게 됩니다.
결국! 발생한 Exception의 Stack trace 정보를 유지하려면 기존 Exception을 인자로 전달해야 하는데,
이 내용은 아래 3-2에서 다시 다루겠습니다.
"예외를 잡아서 다시 던질 때는 기존의 예외 정보도 함께 포함시켜 던져야 한다"가 포인트입니다.
2) 예외 발생 (Throwing Exceptions) 시킬 때
이미 정의된 exception을 적용하지 못할 때만 새 Exception 클래스를 도입하자.
Ex)
- 객체의 현재 상태에서 프로퍼티 설정이나 메서드 호출이 적절하지 않다면, InvalidOperationException 예외를 발생시킨다.
- 만약 부적절한 파라미터를 전달받았다면, ArgumentException 예외 또는 ArgumentException에서 파생된 미리 정의된 클래스 중 하나를 발생시킨다.
**이때 예약된 예외 클래스 (reserved exception) 는 발생시키지 않도록 주의해야 합니다.
reserved exception이란, 일반적으로 시스템 수준의 예외에 해당됩니다.
이러한 예외는 사용자가 재정의하거나 확장하지 않는 것을 권장하고 있습니다.
Java에서는 `java.lang.Exception`이 해당.
Exception Builder 메서드를 사용하자.
보통 프로젝트에서 같은 예외를 각기 다른 위치에서 던지는 것은 흔하게 볼 수 있는데요,
값이 null이거나 비어있는지 체크하고 비어있으면 예외를 던지는 로직만 생각해도
여러 곳에서 중복 코드가 발생할 수 있을 것 같습니다.
이때 예외를 생성해서 반환하는 Helper 메서드를 별도로 생성해서 이러한 중복 코드를 제거할 수 있습니다.
값이 null이거나 비어있는 경우 IllegalArgumentException을 던지려고 하는 로직을 작성해보면 이렇게 될 것 같아요
그리고 값이 있는지 체크해야하는 곳곳에서 이런 코드들이 중복적으로 구현됩니다.
public class ExampleClass {
public void performAction(String param) {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException(parameterName + " cannot be null or empty");
}
// ..
}
}
이러한 중복 코드를 Helper 메서드로 추출하게 되면??
public class ArgumentValidator {
public static void throwIfNullOrEmpty(String value, String parameterName) {
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException(parameterName + " cannot be null or empty");
}
}
}
값을 체크하고 예외를 발생시키는 중복된 코드가 아래와 같이 간단해질 수 있습니다.
public class ExampleClass {
public void performAction(String param) {
ArgumentValidator.throwIfNullOrEmpty(param, "param");
// ..
}
}
그외
1. 적절한 문법을 사용한다. (영어를 사용할 때 특히 더 중요)
2. finally 문에서는 throw 예외를 두지 말자.
3. 예상치 못할 곳에서는 예외를 던지지 말자.
- equals(), toString(), static 생성자 와 같은 메서드에서는 예외를 던지지 않는다
커스텀 예외 클래스를 생성할 때
1. 사용자 정의 예외는 -Exception 으로 끝나도록 네이밍하자.
2. 사용자 정의(user-defined) 예외 클래스를 만들 때 3개의 생성자는 기본적으로 생성하자.
- Exception() : Default.
- Exception(String) : 메시지를 전달한다.
- Exception(String, Exception) : 메시지와 내부 예외를 전달한다.
2. 필요할 때 추가 프로퍼티를 제공하자.
추가 정보가 필요한 시나리오가 있을 때에만 프로퍼티를 추가하는 것을 권장합니다.
Ex) FileNotFoundException은 'FileName' 필드를 추가로 제공합니다.
3. 그외 예외 처리 관련 참고하면 좋을 9가지
1. 어디에서 예외를 던져야할까?
1) "프로그램의 정상적인 흐름을 제어하기 위해" 예외 던지는 방식을 사용하면 안된다.
예외는 오직 예외인 상황에서 사용해야 한다.
만약 외부 API를 이용해 데이터를 조회했는데 데이터가 존재하지 않는 경우, 이 메서드에서는 예외를 발생시키기 보다 해당 데이터를 전달하는 것이 적절하다.
단, 데이터가 존재하지 않을 때 Null 값 보다는 Null Object pattern을 활용해서 결과를 반환하는 것이 좋다.
그러면 이 데이터를 받은 호출자 메서드에서 그 결과를 확인하고 적절한 액션을 취하면 되는것이다.
호출자에서 적절한 액션을 취하는 것에는
에러를 커스텀 에러로 변환하여 에러를 던질 수도 있고,
에러까지 발생시키지 않아야 하는 경우라면 간단한 로깅만 남기고 로직을 넘어갈 수 있다.
따로 에러를 커스텀 에러로 변환하지 않아도 되는 경우라면
아래 2) 처럼 가장 최상위 계층에서 예외를 처리하도록 할 수도 있다.
상황에 따라 적절한 방식으로 예외 처리하는 것이 필요하다.
2) 가능한 늦게 예외를 처리 한다.
Exception을 throw 하자마자 잡지 않고, 가능한 가장 최상위 계층에서 처리한다.
아래와 같이 divide() 에서 b == 0일 때 던진 에러를, calculate()에서 예외를 처리하게 되면 모든 divide() 를 호출하는 곳에서는 에러를 처리해야할 것이다.
좋지 않은 예외 처리
public void divide(int a, int b) {
if (b == 0) {
throw new DivideZeroError("Cannot divided by zero.");
}
// .. 생략
}
public void calculate() {
try {
divide(Math.random(), Math.random());
} catch (Exception e) {
if (error instanceof DivideZeroError) {
// ...
}
}
}
적절한 예외 처리
public void divide(int a, int b) {
if (b == 0) {
throw new DivideZeroError("Cannot divided by zero.");
}
}
public void calculate() {
divide(Math.random(), Math.random());
}
그리고 글로벌 핸들러와 같은 최상위 계층에서 예외 처리를 하면 가독성도 좋아지고 코드를 재사용할 수 있다.
@ExceptionHandler(DivideZeroError.class)
public void divideZeroErrorHandler(DivideZeroError error) {
// 로깅
// 사용자에게 응답할 에러 response 세팅
}
2. 예외 로그를 남길 시, 하나의 로그로 작성한다.
애플리케이션을 서비스하면, 수많은 요청이 동시에 들어오기 때문에 여러 줄의 로그로 남기게 되면 다른 로그들과 섞여 로그 간 멀어지게 되어 보기 어려울 수 있다.
좋지 않은 예외 처리
try {
//...
} catch(Exception ex) {
log.error("error1");
log.error("error2");
}
적절한 예외 처리
try {
//...
} catch(Exception ex) {
log.error("error1. error2");
}
3. 발생하는 예외를 커스텀 예외로 전환시킬 때에는 기존 예외의 원인(Throwable)을 인자로 받는 것이 좋다.
발생한 Throwable을 파라미터를 통해 가져올 수 있는 생성자를 최소한 하나를 구현하고 super 생성자에 Throwable를 전달해준다.
try {
// do something
} catch (NumberFormatException ex) {
throw new CustomException("A message that describes the error.", ex, ErrorCode.INVALID_PORT_CONFIGURATION);
}
public class BusinessException extends Exception {
public BusinessException(String message, Throwable cause, ErrorCode code) {
super(message, cause);
this.code = code;
}
}
특히 이 사항은 직접 경험하며 느낀 내용인데요, 기존 예외의 원인(Throwable)을 인자로 전달한 상황과
인자로 전달하지 않은 상황에는 에러를 디버깅하고 원인을 찾아가는 과정에 차이가 있었습니다.
에러가 발생하는 상황은 아래와 같습니다.
1) 로그인 클래스 A의 메서드에서 쿠키를 처리하는 유틸 클래스 B 호출
2) 유틸 클래스 B의 메서드에서 IllegalArgumentException이 발생.
2) 로그인 클래스 A의 메서드 내에서 2)번 호출 로직에 대한 try/catch문 존재
try {
CookieUtils.setValidateCookie(generatedTokens);
} catch (Exception ex) {
throw new CookieProcessFailException(String.format("cause: %S", ex.getClass()));
}
해결
try {
CookieUtils.setValidateCookie(generatedTokens);
} catch (Exception ex) {
throw new CookieProcessFailException(String.format("cause: %S", ex.getClass()), ex);
}
4. 예외를 잡는 곳은 상황에 따라 다르다.
예외가 발생한 곳에서 무조건 예외를 처리하려고 하지 않아도 됩니다.
즉, 처리하기 적합하지 않은 곳에서 무리하게 예외 처리하려고 하지 말라는 것인데요
예를 들어 최상위 레이어에서 사용자에게 파일의 위치를 입력 받고, 최하위 레이어에서 이 파일의 내용을 읽어 드리는 어플리케이션이 있습니다. 파일이 존재하지 않는 예외의 처리 방법으로 사용자 입력을 다시 받게 하려면, 최상위 레이어가 예외를 처리해야하고 사이의 레이어들은 체이닝만 하는 것이 좋습니다.
5. 리소스는 정리해야 한다.
try-catch 구문에서 리소스를 연다면, finally 블록에서 리소스들을 정리하거나 try-with-resource 구문을 이용해야합니다.
AutoCloseable 인터페이스를 구현한 리소스라면 try-with-resource 구문을 이용하면 됩니다.
6. 발생할 수 있는 예외는 문서화한다.
JavaDoc을 통해 어떤 예외가 발생할 수 있는지 명시합니다.
/**
* Member 엔티티를 조회합니다.
* @param memberId 조회할 회원의 id
* @throws MemberNotFoundException 조회한 회원이 없는 경우에 예외가 발생합니다.
*/
public Member findOne(String memberId) {
return memberRepository.findByMemberId(String memberId)
.orElseThrow(() -> new MemberNotFoundException(String.format("회원 '%s'이 존재하지 않습니다.", memberId)));
}
7. 예외 메세지를 자세하게 적는다.
사용자 정의 예외를 만들 때에는 예외가 발생한 상황에 대해 로그 메시지를 적을 수 있는데,
어떤 상황에서 예외가 발생했는지 간결하게 적어줍니다.
🚨그러나, 예외 이름 자체에서 어떤 문제로 발생했는지 알 수 있을 경우에는 너무 많은 정보를 적지 않아도 됩니다.
Ex) NumberFormatException : 숫자 포맷이 아닌 포맷의 문자열을 숫자로 변환하려할 때 문제가 발생했음을 짐작할 수 있습니다. 이런 경우에는 어떤 문자열을 변환시키려했는지만 작성해놔도 괜찮습니다.
8. 예외를 상위 메서드로 던질 때 로그를 남기지 마라
예외가 발생한 코드에서 상위 메서드로 예외를 던질 때에는, 별도의 로그 처리를 하지 않아도 됩니다.
상위로 예외를 throw 할 경우 로그가 너무 많아져 가독성을 훼손하기 때문입니다.
다시 상위로 throw하고자 한다면, 로그를 찍지 말고
그상황에 대한 Context를 남기고자 한다면 이를 감싸는 커스텀 클래스로 만들고, 이를 상위로 던지길 권장합니다.
예외를 래핑하는 커스텀 클래스 예시
public class MyException extends BusinessException {
public MyException(String message, Exception ex) {
super(message, ex);
}
}
9. 예외를 잡은 후 처리할 때 유형
예외를 잡았을 때 catch문에서 로그만 남겨야 하는 일은 거의 드뭅니다. 예외를 잡았으면 무언가를 해야합니다.
처리 유형은 3가지 케이스가 있을 것 같습니다.
- 처리하고 예외 상황 종료
- 다시 던진다.
- 새 예외로 커스텀하여 던진다.
10. 로그 메시지는 어떻게 남기는게 좋을까?
<발생 상황 + 어떤 Exception인가 + Exception 메세지> 정도만 알려주어도 좋습니다.
더 나아가서는, <발생 IP + 발생 Port + 발생 스레드> 등의 정보도 포함시킬 수 있습니다.
충분한 로그 예)
“second data reading failed. cause=IOException, message=“socket read failed.”], peer=“10.10.10.13”, port=1234, auth=“base123”, thread=32"
참고글