📗 시작하며

이 글은 커스텀 예외 처리를 어떻게 할지 고민하며 점진적으로 개선해나가는 과정을 담고 있습니다. 처음 커스텀 예외 클래스를 작성하시는 분부터, 더 좋은 예외 처리를 위해 고민하고 계시는 분들 모두에게 도움이 되는 글을 작성하고자 했습니다!

🙋‍♂️커스텀 예외를 왜 만들어야 하나요?

로또 번호는 1~45 사이의 숫자입니다. validateLottoNumber() 메서드는 이를 확인하고 범위를 벗어난 숫자에 대해 IllegalArgumentException을 던집니다.

public class LottoNumber implements Comparable<LottoNumber> {

    private final int lottoNumber;

    public LottoNumber(int lottoNumber) {
        validateLottoNumber(lottoNumber);
        this.lottoNumber = lottoNumber;
    }

    private void validateLottoNumber(int lottoNumber) {
        if(lottoNumber < 1 || lottoNumber > 45) {
            throw new IllegalArgumentException(lottoNumber);
        }
    }
}

메서드 파라미터에 잘못된 값이 입력되면 아래와 같이 에러가 발생합니다.

Exception in thread "main" java.lang.IllegalArgumentException
	at lotto.domain.lotto.LottoNumber.validateLottoNumber(LottoNumber.java:18)
	at lotto.domain.lotto.LottoNumber.<init>(LottoNumber.java:12)
    /* 생략 */

❓🙋‍이러한 에러 처리 방식에는 어떠한 문제점들이 있을까요?

  1. 먼저, 예외의 원인을 분명하게 파악하기 어렵습니다.
    에러 메시지를 통해 validateLottoNumber 메서드에 문제가 발생했다는 것은 알 수 있습니다. 하지만 정확히 해당 메서드에 무슨 문제가 발생했는지 알 수 없습니다.

  2. 또한, 어떤 값이 문제를 일으켰는지 알기 어렵습니다. 개발자는 로그를 통해 에러의 상세 내용을 확인할 수 있어야 합니다. 사용자가 잘못된 값을 입력했다면, 이를 로그로 남겨 개발자가 확인할 수 있어야 합니다.

단순히 IllegalArgumentException을 던지고, 아무런 메시지도 남기지 않는다면 예외가 발생할 때마다 디버깅을 반복적으로 해야겠죠!

위와 같은 이유로, 커스텀 예외를 만들고 예외가 발생한 원인을 구체적으로 남기는 것을 선호합니다.


🌱 어떻게 만드나요?

아주 간단합니다! 이번 3주차 미션의 요구사항에는 IllegalArgumentException을 던지는 것으로 되어 있었습니다. 그래서 CustomException 클래스를 만들고, IllegalArgumentException을 상속받도록 구현하면 됩니다.

public class InvalidLottoNumberException extends IllegalArgumentException{

    private static final String ERROR_MESSAGE = "[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.";
    
    public InvalidLottoNumberException() {
        super(ERROR_MESSAGE);
    }
}

이제 잘못된 값이 전달되면 아래와 같이 직접 정의한 메시지를 함께 확인할 수 있습니다.

Exception in thread "main" lotto.common.exception.InvalidLottoNumberException: [ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.
	at lotto.domain.lotto.LottoNumber.validateLottoNumber(LottoNumber.java:18)
	at lotto.domain.lotto.LottoNumber.<init>(LottoNumber.java:12)
	at
    /* 생략 */

❓🙋‍ super()는 왜 하는거에요?

super(ERROR_MESSAGE)를 통해 부모 생성자가 호출됩니다. IllegalArgumentException를 잠깐 살펴보면 아래와 같은 생성자가 있습니다.

public IllegalArgumentException(String s) {
	super(s);
}

여기서도 부모 생성자를 호출하는데요, 이를 타고 들어가다 보면 최상위 Throwable이 나옵니다.

public Throwable(String message) {
    fillInStackTrace();
	detailMessage = message;
}

fillInStackTrace()는 예외가 발생한 시점의 스택 트레이스를 캡처합니다. 이를 통해 예외가 발생한 위치와 호출 경로를 추적할 수 있습니다.

detailMessagegetMessage() 메서드를 통해 외부에서 조회할 수 있습니다. 따라서, InvalidLottoNumberException에서 전달한 메시지는 최상위 Throwable 클래스의 detailMessage 필드에 저장됩니다.


🐉 커스텀 예외 잘 만들기

이제부터 앞서 만든 커스텀 예외를 하나씩 고쳐보도록 하겠습니다!

1️⃣ 동적 메시지를 제공한다.

"[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다."

해당 에러 메시지로는 어떤 로또 번호가 예외를 발생시켰는지 알기 어렵습니다.
-1이었을 수도 있고, Integer.MAX_VALUE 정말 큰 수였을 수도 있죠.

개발자는 이슈가 생겼을 때 로그로 문제를 쉽게 파악할 수 있어야 합니다. 어떤 파라미터가 문제를 일으킨건지 빠르게 확인할 수 있어야 하는데요.

이때, 동적 메시지를 활용할 수 있습니다. 즉, 예외를 발생시킨 값을 메시지에 함께 제공하면 예외를 발생시킨 상황에 대한 파악을 더 빠르게 할 수 있습니다.

public class InvalidLottoNumberException extends IllegalArgumentException{

private static final String ERROR_MESSAGE = "[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.";

    public InvalidLottoNumberException(int invalidLottoNumber) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber + ")");
    }
}

커스텀 예외 클래스의 생성자에서 int 타입의 invalidLottoNumber를 전달받고 있습니다. 예외를 던지는 쪽에서는 아래와 같이 값을 전달합니다.

private void validateLottoNumber(int lottoNumber) {
	if(lottoNumber < 1 || lottoNumber > 45) {
    	throw new InvalidLottoNumberException(lottoNumber);
	}
}

예외를 발생시킨 값을 에러 메시지를 통해 확인하고, 구체적인 상황을 파악하기가 쉬워졌습니다!

[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다. (잘못된 로또 번호 : 90)

2️⃣ 예외 체이닝을 걸어준다.

예외 클래스를 만들땐 꼭 기존 예외랑 체이닝을 시켜줘야 합니다.

public LottoNumber readBonusNumber() {
	int bonusNumber;
    String input = Console.readLine();
    try {
    	bonusNumber = Integer.parseInt(input);
    } catch (NumberFormatException e) {
    	throw new InvalidLottoNumberException(input);
    }
    return new LottoNumber(bonusNumber);
}

보너스 번호를 사용자에게 입력받고, 숫자가 아니라면 InvalidLottoNumberException을 던지고 있습니다. String 변수를 int 타입으로 변환하는 과정에서 숫자가 아니라면 NumberFormatException이 발생합니다. 이를 try-catch로 잡아 커스텀 예외를 다시 던지고 있습니다.

이제 잘못된 보너스 번호를 입력해보겠습니다.

Exception in thread "main" lotto.common.exception.InvalidLottoNumberException: [ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다. (잘못된 로또 번호 : invalid)
	at lotto.interfaces.input.InputHandler.readBonusNumber(InputHandler.java:53)
	at lotto.interfaces.lotto.LottoController.getWinningLotto(LottoController.java:62)
	at lotto.interfaces.lotto.LottoController.lottoGameStart(LottoController.java:33)
	at lotto.Application.main(Application.java:10)

보시다시피 스택 트레이스에 NumberFormatException에 대한 정보가 어디에도 없습니다. 원천 예외가 있을 때는, 이를 cause에 담아야 스택 트레이스에 남습니다. 최상위 Throwable에서는 아래와 같이 cause를 받고 있습니다.

public Throwable(String message, Throwable cause) {
    fillInStackTrace();
    detailMessage = message;
	this.cause = cause;
}

여기에 cause를 전달하기 위해 커스텀 예외 클래스를 아래와 같이 수정할 수 있습니다.

public class InvalidLottoNumberException extends IllegalArgumentException{

    private static final String ERROR_MESSAGE = "[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.";
    
    /* 생략 */
    
    // 1번: 원천 예외가 없을 때
    public InvalidLottoNumberException(String invalidLottoNumber) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")");
    }

	// 2번: 원천 예외가 있을 때
    public InvalidLottoNumberException(String invalidLottoNumber, Exception e) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")", e);
    }
}

원천 예외를 받아, 이를 부모 생성자로 넘기고 있습니다.

원천 예외가 없을 때는 위쪽에 있는 생성자가 호출되고, NumberFormatException와 같이 원천 예외가 있을 때는 아래쪽에 있는 생성자가 호출됩니다.

따라서 커스텀 예외 클래스를 만들 때 위와 같이 두 가지 생성자를 모두 만들어야 합니다.

예외를 던지는 쪽에서는 아래와 같이 Exception을 함께 전달해주면 됩니다.

public LottoNumber readBonusNumber() {
	int bonusNumber;
    String input = Console.readLine();
    try {
    	bonusNumber = Integer.parseInt(input);
    } catch (NumberFormatException e) {
    	throw new InvalidLottoNumberException(input, e);
    }
    return new LottoNumber(bonusNumber);
}

이제 잘못된 보너스 번호 입력 시 아래와 같이 NumberFormatException도 함께 확인할 수 있습니다.

Exception in thread "main" lotto.common.exception.InvalidLottoNumberException: [ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.(잘못된 로또 번호 : invalid)
	at lotto.interfaces.input.InputHandler.readBonusNumber(InputHandler.java:53)
	at lotto.interfaces.lotto.LottoController.getWinningLotto(LottoController.java:62)
	at lotto.interfaces.lotto.LottoController.lottoGameStart(LottoController.java:33)
	at lotto.Application.main(Application.java:10)
Caused by: java.lang.NumberFormatException: For input string: "invalid"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at lotto.interfaces.input.InputHandler.readBonusNumber(InputHandler.java:51)
	... 3 more

예외를 체이닝하면 예외의 원래 원인에 대한 자세한 정보를 제공할 수 있으며,
오류 발생 지점을 더 명확하게 파악하는데 필수적입니다.


3️⃣ 정의한 예외 vs 예기치 못한 예외: 구분의 중요성

개발을 하다보면, 종종 예외 상황을 마주하게 됩니다. 이때 커스텀 예외를 하나씩 만들며 예상치 못한 예외들을 줄여나가는데요! 이 둘을 구분하기 위해서 커스텀 예외가 상속받는 공통적인 예외 클래스를 추가적으로 하나 더 선언합니다.

바로 코드로 확인해보겠습니다!

public class LottoException extends IllegalArgumentException {

    private static final String ERROR_MESSAGE_HEADER = "[ERROR] ";
    private final String errorMessage;

    public LottoException(String message) {
        super(ERROR_MESSAGE_HEADER + message);
        this.errorMessage = ERROR_MESSAGE_HEADER + message;
    }

    public LottoException(String message, Exception e) {
        super(ERROR_MESSAGE_HEADER + message, e);
        this.errorMessage = ERROR_MESSAGE_HEADER + message;
    }
}

요구사항에 맞춰 IllegalArgumentException을 상속받은 LottoException 클래스를 하나 만들었습니다. 이는 앞으로 정의할 모든 커스텀 예외 클래스들이 상속받을 중추적인 예외 클래스인데요!

각 커스텀 예외에서 공통적으로 필요한 errorMessage를 갖고 있습니다. 또, [ERROR]를 메시지에 추가해주는 작업도 이쪽에서 수행합니다.

그럼, 다시 InvalidLottoNumberException을 수정해보겠습니다.

public class InvalidLottoNumberException extends LottoException{

    private static final String ERROR_MESSAGE = "로또 번호는 1부터 45 사이의 숫자여야 합니다.";
    
    public InvalidLottoNumberException(int invalidLottoNumber) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")");
    }

    public InvalidLottoNumberException(int invalidLottoNumber, Exception e) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")", e);
    }

    public InvalidLottoNumberException(String invalidLottoNumber) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")");
    }

    public InvalidLottoNumberException(String invalidLottoNumber, Exception e) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")", e);
    }
}

커스텀 예외 클래스가 상속받는 부모 클래스가 IllegalArgumentException에서 LottoException으로 변경되었습니다!

이렇게 했을 때의 장점은, 개발자가 정의한 예외와 그렇지 못한 예외를 구분할 수 있다는 점인데요!

예외를 처리하는 곳을 한 번 살펴보겠습니다.

private LottoMoney getLottoMoney() {
	while (true) {
    	try {
        	return inputHandler.readPurchaseAmount();
        } catch (LottoException e) {
        	System.out.println(e.getMessage());
    	} catch (Exception e) {
        	System.out.println("예상치 못한 예외가 발생했습니다.");
	}
}

먼저 try-catch 블록에서 LottoException을 잡아 이를 처리합니다. 만약 LottoException이 아닌 다른 예외가 발생할 경우에는 하위 catch 블록이 실행되어 별도의 메시지를 출력하도록 했습니다.

❓🙋‍♂️ 이렇게 했을 때 장점이 무엇인가요?

😊 개발자가 정의된 예외와 예상치 못한 예외분리하여 처리할 수 있다.

private LottoMoney getLottoMoney() {
	while (true) {
    	try {
        	return inputHandler.readPurchaseAmount();
        } catch (IllegalArgumentException e) {
        	System.out.println(e.getMessage());
    	} 
	}
}

위의 코드에서는 개발자가 예상할 수 있는 예외(커스텀 예외)와 예상하지 못한 예외한곳에서 함께 처리되고 있습니다.

개발자가 미리 정의한 커스텀 예외는 특정 에러 메시지를 출력하고, 사용자로부터 다시 입력을 받도록 설계되어 있습니다. 그러나 예상치 못한 예외에 대해서는 다른 방식의 처리가 필요할 수 있습니다. 예를 들어, 에러 메시지를 띄운 후 시스템을 종료하거나, 별도의 에러 처리 로직을 실행할 수도 있습니다.

이럴 때 중추적인 예외를 만들어 처리하게 만들면, 개발자가 정의된 예외와 예상치 못한 예외분리하여 처리할 수 있습니다.

😊 예상치 못한 예외를 빠르게 파악하고, 그 수를 줄일 수 있다.

시스템에서 발생할 수 있는 예외를 미리 예측하고 대처하기 위해 예상치 못한 예외를 줄이고, 커스텀 예외를 정의하게 되는데요,

이때 try-catch에서 잡히지 못하는 예외는 모두 예상치 못한 예외로 간주되어, 미처 파악하지 못했던 예외 상황을 더 빠르게 구체화하는 데 도움을 줍니다.

이러한 이유로, LottoException 처럼 중추적인 예외를 하나 선언하고, 각 커스텀 예외 클래스가 이를 상속받도록 합니다. 예외를 처리하는 쪽에서 이 둘을 구분하여 핸들링해주면 얻을 수 있는 이점을 말씀드렸습니다.

🌳 마치며

Before & After

😈 Before

Exception in thread "main" java.lang.IllegalArgumentException
	at lotto.domain.lotto.LottoNumber.validateLottoNumber(LottoNumber.java:18)
	at lotto.domain.lotto.LottoNumber.<init>(LottoNumber.java:12)
    /* 생략 */
private void validateLottoNumber(int lottoNumber) {
    if(lottoNumber < 1 || lottoNumber > 45) {
    	throw new IllegalArgumentException(lottoNumber);
	}
}

😁 After

Exception in thread "main" lotto.common.exception.InvalidLottoNumberException: [ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다. (잘못된 로또 번호 : invalid)
	at lotto.interfaces.input.InputHandler.readBonusNumber(InputHandler.java:53)
	at lotto.interfaces.lotto.LottoController.getWinningLotto(LottoController.java:62)
	at lotto.interfaces.lotto.LottoController.lottoGameStart(LottoController.java:33)
	at lotto.Application.main(Application.java:10)
Caused by: java.lang.NumberFormatException: For input string: "invalid"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:662)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at lotto.interfaces.input.InputHandler.readBonusNumber(InputHandler.java:51)
	... 3 more
public class InvalidLottoNumberException extends LottoException{

    private static final String ERROR_MESSAGE = "[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.";
    
    public InvalidLottoNumberException(int invalidLottoNumber) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")");
    }

    public InvalidLottoNumberException(int invalidLottoNumber, Exception e) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")", e);
    }

    public InvalidLottoNumberException(String invalidLottoNumber) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")");
    }

    public InvalidLottoNumberException(String invalidLottoNumber, Exception e) {
        super(ERROR_MESSAGE + "(잘못된 로또 번호 : " + invalidLottoNumber+ ")", e);
    }
}

🌙 동적 메시지를 제공한다.
🌙 예외 체이닝을 걸어준다.
🌙 정의한 예외예상치 못한 예외구분한다.

이 세 가지를 적용해 커스텀 예외 클래스를 만들며 예외 처리 로직을 개선해보았습니다! 이슈가 생겼을 때 문제를 파악하기 쉽게 하려면, 어떤 파라미터가 문제를 일으킨 건지, 원천 예외가 있다면 무엇인지를 메시지에 잘 적어주는 것이 필요하다고 생각합니다.


아직 글에 담지 못한 내용이 많은데, 이는 2탄에서 이어가도록 하겠습니다..! ✨

2탄에서는, 아래와 같은 내용을 담아보려고 합니다!

1. 에러 코드, 에러 메시지 관리하기
2. 커스텀 예외 클래스를 어디까지 나눠야 하지? 너무 많아지는거 아니야..?
3. 스프링에서 예외 처리
4. 자바의 표준 예외 활용하기?!?!
5. 로그도 IO야

1탄 내용에 부족한 점이 있거나, 2탄에서 듣고 싶은 이야기가 있으시다면 댓글로 많이 남겨주세요! 😁💚


참고자료

예외 체이닝 관련
https://www.baeldung.com/java-chained-exceptions

profile
Backend Developer

9개의 댓글

comment-user-thumbnail
2024년 11월 6일

매번 별 생각없이 예외를 만들고 던졌는데 고민을 하신게 느껴져서 제가 반성이 되네요 글 내용 참고해서 예외처리에 대해서 저도 고민해봐야겠어요!

1개의 답글
comment-user-thumbnail
2024년 11월 6일

유익한 글 잘 읽었습니다!
저도 4주차에는 커스텀 예외를 적용해서 발생 원인을 부가하고 싶다는 생각을 하고있었는데 좋은 참고가 된 것 같아요! 😄

동적 메시지를 활용할 때, 매개변수로 int를 받고 계신데 Integer로 받는 건 어떻게 생각하시나요?? 예외 생성 과정에서 오히려 NPE 예외가 발생할지도 모른다는 생각이 들었습니다..!

원천 예외를 표기하기 위해 예외 체이닝을 걸어준 부분도 인상깊었어요!
다만 public InvalidLottoNumberException(int invalidLottoNumber, Exception e) 만 사용한다면
public InvalidLottoNumberException(int invalidLottoNumber)는 생략할 수 있을 것 같은데 두 생성자 모두 유지해야 한다고 생각하신 이유가 궁금해요!

4개의 답글
comment-user-thumbnail
2024년 11월 7일

항상 예외를 어떻게 처리해야 하는가 고민이 되었는데 이 글로 인해 조금은 해결이 된 것 같아요!! 좋은 글 읽었습니다. 앞으로도 좋은 글 부탁드립니다. 추가적으로 스프링에서는 예외처리를 어떤식으로 하는지 궁금해요!!

1개의 답글