명쾌한 Custom Exception in Java

2020. 9. 3. 15:30개발/Java, Spring


Custom Exception을 언제 사용해야 할지 알 수 있다 : Standard Exception이 마땅치 않은 상황에 대하여

Custom Exception 작성법을 알 수 있다 : 지켜야 할 것들

어떤 점을 주의해서 사용해야 하는지 알 수 있다 : 성능 최적화

 

이 글에서는 Exception에 대한 의미나 종류, 처리 방식등을 다루지 않습니다.


1. 🔎 Standard Exception과 Custom Exception

서비스를 만들다보면 예외를 발생시킬 일이 많습니다.

 

  • null을 허용하지 않는 메서드에 null을 건넸을 때 NullPointerException
  • 배열, 문자열, 벡터 등에서 범위 밖의 index에 접근할 때 IndexOutOfBoundsException
  • 허용하지 않는 값이 인수로 건네졌을 때 IllegalArgumentException
  • 객체가 메서드를 수행하기에 적절하지 않은 상태일 때 IllegalStateException

위와 같이 이미 Java는 사용자가 상황에 따라 적절히 예외를 처리할 수 있도록 많은 예외를 만들어두었습니다. 

표준 Exception
표준 Exception - Runtime Exception(Unchecked Exception)

Effective Java에서도 아래와 같은 이유로 표준 예외 사용을 권장하고 있습니다. (3판, Item 72)

 

  • 배우기 쉽고 사용하기 편리한 API를 만들 수 있다.
  • 표준 예외를 사용한 API는 가독성이 높다.
  • 예외도 재사용하는 것이 좋다. 예외 클래스의 수가 적을수록 프로그램의 메모리 사용량이 줄고, 클래스를 적재 시간도 줄어든다.

하지만 모든 예외를 표준 예외로 사용할 수는 없는 상황도 존재하기 마련이죠 😈

1) 예외를 발생시키는 조건이 해당 예외 문서에 기술된 것과 일치하지 않는 경우

사실 이 경우는 거의 없는 것 같습니다. 이미 많은 표준 예외가 존재하기 때문이죠. 그럼에도 말해보자면,

표준 예외를 사용하기 위해서는 예외를 발생시키는 조건이 해당 예외의 문서에 기술된 것과 일치해야합니다. 이 때, 억지로 표준 예외를 사용하기 위해 의미가 일치하지 않는 예외를 사용하게 된다면, 위에서 언급한 장점(1,2번)이 단점으로 작용하게 되는 것이지요. 즉, 예외가 잘못 사용된 API를 사용하는 클라이언트는 예외 클래스 명만 보고 자연스럽게 본인이 알고 있던 표준 예외에 맞추어 상황을 인지하게 되고 이는 큰 혼란을 일으킬 수 있습니다.

2) 여러 예외와 맞물려 어느 하나를 선택하기 모호한 경우, 즉 비즈니스 로직의 명확성을 높이고 싶은 경우

예외는 상호 배제적이지 않습니다. 즉, AException에도 해당되고 BException에도 해당 될 수 있는 것이지요.

예를 들어, 숫자를 입력받아 해당 숫자만큼 카드를 나눠주는 메서드가 있습니다. 만약 인자로 전달된 숫자가 너무 크다면 인자의 값이 너무 컸다(비정상적이다)는 뜻에서 IllegalArgumentException 을 발생 시킬수도 있고 혹은 객체의 현 상태로는 메서드 호출을 처리할 수 없다는 뜻에서 IllegalStateException 을 발생 시킬 수도 있습니다. 이런 경우 InvalidCardCountException과 같은 이름을 가진 예외를 생성하여 비즈니스의 명확성을 높일 수 있는 것이죠.

 

여기서부터는 혼잣말이니 넘어가셔도 좋습니다.. 

사실 이런 경우
"하나의 예외를 골라쓰고 메세지로 구체적인 상황을 전달하여 명확성을 높이면 되지 않나요?"
라는 의견이 있는데, 저는 이 예외가 딱 한 번만 등장하는 게 아니라면 Custom Exception을 사용할 것 같아요.

우선 예외 메세지를 관리하는 것도 비용이라고 생각이 들기 때문인데요, 동일한 메세지를 여러 군데에 문자열 형태로 넣게 되면 일관성과 유지보수 측면에서 문제가 발생합니다. (거기에 예외에 대한 테스트까지 작성한다면 메세지 비교시 드는 비용은 배가 되겠죠?)

"상수로 빼면 되지 않나요?"

그럼 그 상수의 위치가 굉장히 애매해지지 않나요? 그래서 전 차라리 Custom Exception을 만들어서 관련 있는 메세지나 메서드들을 모아 두는 것을 선호합니다.


"Custom Exception을 만드는 비용도 만만치않은데 그게 그거 아닌가요?"

그래서 저는 최대한 성능에 영향을 끼치지 않도록 생성하는 방식을 사용하는 것도 방법인 것 같아요. 그건 아래에서 소개해드릴게요👀
그리고 사실 저는 항상 "요즘 같이 하드웨어가 좋아진 시대에, 이런 하드웨어적인 비용보다 개발하는데 드는 비용을 아끼는 것이 더 가치있다"라는 말을 종종 들어서.. 더 그런 걸지도 모르겠네요!

3) 클라이언트 코드에 대한 추가적인 정보 혹은 행동을 제공하고 싶은 경우

예를 들어 회원 가입을 위해 이름을 입력받는 상황을 생각해봅시다. 중복된 이름을 입력한 경우 사용자에게 아래와 같은 기능을 제공해야 합니다.

1. 해당 이름은 사용할 수 없음을 알려주는 기능 - requestedUsername()

2. 대신 사용할 수 있는 이름을 보여주는 기능 - availableNames()

 

표준 예외를 사용하는 개발자는 아래와 같은 로직을 작성할 것입니다. 

public void join(String requestUsername) {
	try {
    	// 이름 추가 로직
    } catch(IllegalArgumentException e) { 
    // DB와 연동되어 있다면 기본키 중복 혹은 제약조건 위배로 인한 Exception이 발생하겠죠?
    	requestedUsername();
        availableNames();
    } finally{
    	...
    }
}    

public String requestedUsername(){...}
public String[] availableNames(){...}

이렇게 표준 예외를 사용하면 예외를 발생시키고, 그 뒤에 사용할 추가적인 정보나 행동을 한 곳에서 관리하기 어려워집니다. 즉, 로직이 여기저기 흩어질 가능성이 있고, 중복될 가능성도 존재하게 됩니다.

 

위와 같은 문제를 해결하기 위해서는 Custom Exception을 만들고, 그 내에 추가적인 정보나 행동을 함께 제공해주면 됩니다.

public class DuplicateUsernameException
    extends Exception {
    public DuplicateUsernameException 
        (String username){....}
    public String requestedUsername(){...}
    public String[] availableNames(){...}
}

위와 같은 사용자 지정 예외는 이름으로서의 명확성을 높여줄 뿐만 아니라, 추가적인 정보와 기능을 함께 제공해줌으로써 로직을 한 곳에서 관리할 수 있는 장점도 있는 것이지요.

2.  🛠 Custom Exception 만들기

1) 클래스 명 끝엔 Exception을 붙이자 

(간단 명료)

2) 메서드가 던지는 모든 예외를 문서화 하자 

메서드가 던지는 예외는 그 메서드를 올바르게 사용하도록 하는 중요한 정보가 되기 때문에, 예외가 발생하는 상황을 정확히 문서화 해놓는 것이 좋습니다.

 

그럼 어떻게 문서화를 해야할까요?

검사예외는 @throws 태그를 써서 모든 예외가 발생하는 상황을 정확히 문서화하고, throws선언문에서도 공통 상위 클래스 하나로 뭉뚱그리지 말고, 비검사예외는 @throws로 정확히 명시하되 선언문은 쓰지 않는게 구분하기 좋다........................ 🤯

Effective Java(3판 Item 74)

백마디 말보다 한줄의 코드가 이해가 잘 되니, 아래서 예를 들어보겠습니다.

 

(1) 검사 예외(Checked Exception)

 

❌잘못된 예시

public void foo(String arg) throws Exception {

}

이 코드의 문제는 무엇일까요?

1. 문서화 주석이 안되어있다.

이는 곧 메서드 사용자가 각 예외에 대처할 수 있는 힌트를 얻지 못하게 됩니다.

2. 공통 상위 예외 클래스로 뭉뚱그려 선언했다.

이는 같은 맥락에서 발생할 여지가 있는 다른 예외들까지 삼켜버릴 수 있기 때문에 API 사용성을 떨어뜨립니다.

 

그렇다면 어떻게 개선시켜야 사용자(다른 개발자)가 사용하기도 편하면서, 많은 정보를 얻을 수 있을까요?

 

⭕️잘 된 예시

/**
 *
 * @throws NumberFormatException - arg가 숫자형 데이터가 아닌 경우 throw
 * @throws SQLException - 예외가 발생할 수 있는 상황 기술 ... 
 */
public void foo(String arg) throws NumberFormatException, SQLException {
	
}

 

(2) 비검사예외 (Unchecked Exception)

비검사 예외도 마찬가지로 @throws를 통해 메서드 내에서 발생할 예외에 대해 문서화를 해놓는 것이 좋습니다. 다만 검사 예외와 명확히 구분하기 위해 메서드에 throws 선언문은 생략하는 편이 좋습니다. 

 

/**
 *
 * @throws NumberFormatException - arg가 숫자형 데이터가 아닌 경우 throw
 * @throws SQLException - 예외가 발생할 수 있는 상황 기술 ... 
 */
public void foo(String arg){
	
}

 

3) 예외의 상세 메시지에 실패 관련 정보를 담자 (Effective Java 3판 Item 75)

예외가 발생하면 시스템은 스택 추적 정보(StackTrace)를 자동으로 출력합니다. 우리가 프로그램이 터질 때 콘솔창에 찍히던 바로 그것이죠.. 이 스택 추적 정보는 언제, 어떻게 생성될까요?

 

모든 예외 클래스의 부모가 되는 Throwable 클래스는 기본적으로 아래와 같은 생성자를 제공합니다.

 

기본 생성자 예외 메세지(message)를 받는 생성자 예외의 원인이 되는 throwable을 받는 생성자 throwable과 message를 받는 생성자

 

이 때, 각 예외 객체가 생성된 방식대로 스택 추적 정보가 생성됩니다. 만약 예외 메세지를 따로 생성해주지 않았다면 toString()를 이용하고, 메세지를 전달했다면 getLocalizedMessage()를 통해 예외 메세지 정보를 출력해줍니다.

 

// Throwable.java - 495line

public String toString() {
        String s = getClass().getName();
        String message = getLocalizedMessage();
        // 예외 클래스 명 : 예외 메세지 
        return (message != null) ? (s + ": " + message) : s;
    }

 

즉, 우리가 에러에 대해 자세한 정보를 얻기 위해서는

1. 예외 생성 시 구체적인 메세지를 함께 전달하거나 (= 메세지를 받는 생성자를 구현해두거나) 

2.  toString() 을 Overriding하여 실패 원인에 대한 정보(예외 발생에 영향을 준 모든 필드와 인자의 값)를 담아주어야 함을 알 수 있습니다.

 

3. 💡  Custom Exception, 더 현명하게 사용하기

서두에 이렇게 말했었습니다.

예외도 재사용하는 것이 좋다. 예외 클래스의 수가 적을수록 프로그램의 메모리 사용량이 줄고, 클래스를 적재 시간도 줄어든다.

또, Effective Java Item 57에서는 아래와 같이 말합니다.

예외는 예외상황에서만 써야 한다. 예외를 생성하고 던지고 잡는 것은 비용이 많이 드는 작업이고 JVM의 최적화 대상에서 빠질 수 있다.

이와같이 다양한 곳에서 예외를 생성하고 사용하는 작업이 비용이 많이 든다고 하는데, 왜 그럴까요?

 

실제로 예외의 어떤 부분이 성능에 영향을 끼치는지에 대해 설명하고 있는 글을 참고하면, 예외의 발생 경로를 추적하는 StackTrace를 생성하는 데에 1~5ms정도가 소비된다라고 말하고 있습니다. 사실 아직까지 1~5ms가 얼마나 성능에 큰 영향을 끼치는지 감이 안잡히긴합니다.

 

스택 추적 정보는 위에서 잠시 언급을 했었는데요, 예외가 발생하면 상위 클래스인 Throwable의 fillInStackTrace()에 의해 call stack에 있는 메소드 리스트를 저장합니다.

그리고 우리는 출력된 StackTrace를 통해 예외가 어디서 어떠한 이유로 발생했는지 알 수 있는거죠. 하지만 보통 Custom Exception은 유효하지 않은 값이라면 하위 비즈니스 로직을 수행하지 못하도록 하기 위한 용도일 때가 많습니다. 따라서 이 때는 현재 값이 어떤 call stack을 가지는지에 대한 정보가 필요 없어지고, StackTrace를 생성하지 않도록 해도 괜찮은 상황인것이지요.

따라서, StackTrace가 필요하지 않고 단순히 try-catch로 이후 행동을 제어한다든가 Spring 환경에서 Advice로 예외를 처리한다든가 하는 상황에서는 불필요한 성능 저하를 막기 위해 아무 trace도 갖지 않도록 직접 fillInStackTrace()를 오버라이딩 해주는 게 좋습니다.

@Override 
public synchronized Throwable fillInStackTrace() {
	return this;
}

이와 더불어, 위에서 언급한 클래스 명이 주는 장점때문에 사용하는 경우, 즉 3) 클라이언트 코드에 대한 추가적인 정보 혹은 행동을 제공하고 싶은 경우 가 아님에도 Custom Exception을 만들어 사용하는 경우에는 static final로 예외를 캐싱하여 사용하는 방법도 있습니다. 

 

public class CustomException extends RuntimeException {
	public static final CustomException INVALID_NICKNAME = new CustomException(ResponseType.INVALID_NICKNAME);
	public static final CustomException INVALID_TOKEN = new CustomException(ResponseType.INVALID_TOKEN);
	...
}

 

if (토큰이 유효하지 않다면) {
	throw CustomException.INVALID_TOKEN;
}

 

이렇게 사용하게 되면 예외를 생성하는 비용도 줄일 수 있을 뿐더러, 커스텀 예외를 사용해서 얻고 싶은 장점까지 얻을 수 있게 되니까요. 대신 이런 상황에서는 예외 스택 정보를 기대하긴 어렵겠죠?


이렇게 Custom Exception에 대해 평소 가지고 있던 생각들과 이번에 포스팅하며 알게 된 내용들을 정리를 해보았습니다!

이 외에도 의견이 있으시거나 궁금한 점 혹은 틀린 점이 있다면 댓글로 알려주시면 감사하겠습니다.😇

 


 References