본문 바로가기
Old Posts/Java

[Java] 예외처리 코드 잘 작성하는 9가지 방법

by A6K 2021. 7. 2.

자바 프로그래밍에서 '예외처리(Exception Handling)'는 다소 까다로운 주제다. 개발 조직들은 자신들만의 예외처리 규칙을 만들고 사용하는 경우가 많다. 일반적으로 자바 프로젝트에서 따르면 좋은 예외처리를 정리해보자.

자바 예외(Exception)

자바 프로그램이 실행과정에서 만날 수 있는 의도하지 않은 상황에 대한 처리를 위해 '예외(Exception)'를 제공한다. 예를 들어 존재하지 않는 파일을 열어보려고 한다던가, 권한이 없는 파일에 데이터를 쓰려고 할 때 예외가 발생한다.

이런 예외상황에서 프로그램의 실행을 종료하지 않고, 예외 상황을 벗어나기 위해서 '예외처리(Exception Handling)'를 하게 된다. 예외처리를 통해 예외 상황에서 벗어나도록 코드를 작성하던가 시스템을 망가트리지 않는 상황에서 자연스럽게 프로그램이 종료되고 디버깅 정보를 남기는 등의 처리를 할 수 있다.

대부분의 모던 언어에서는 예외를 발생시키고 'catch'절에서 예외를 처리할 수 있는 메커니즘을 가지고 있다. 예외처리에 대한 메커니즘이 없는 C언어에서는 리턴 코드를 이용해 예외 상황에 대한 정보를 상위 컨텍스트로 넘겨줬어야 했다. (C언어에서 조차 setjmp(), longjmp() 함수와 Recovery Information 등을 전역변수로 관리하면서 TRY-CATCH 매크로를 만들어 쓰기도 했다.)

다행히 자바에서는 Exception 객체와 try-catch 구문이 제공되어 예외처리를 할 수 있게 되었다.

예외처리 잘 하는 9가지 방법

1. 리소스 정리

try-catch 블록에서 리소스에 접근하는 경우가 있다. 예를 들어 JDBC 드라이버를 통해 DB에 접근하거나 파일을 열고 데이터를 읽어오는 경우가 있다.

FileInputStream inputStream = null;

try {

    File file = new File("./tmp.txt");
    
    byte[] buffer = new byte[512];

    inputStream = new FileInputStream(file);
    
    inputStream.read(buffer, 0, 512);

    inputStream.close();

} catch (FileNotFoundException e) {
    log.error(e);
} catch (IOException e) {
    log.error(e);
}

 

 

파일을 열고 512 바이트 버퍼를 생성한 다음, 파일로부터 512바이트만큼 데이터를 읽은 후 파일 스트림을 닫는 코드다. 정상적으로 진행된다면 문제가 없다. 하지만 inputStream.read(buffer, 0, 512) 에서 IOException이 발생한다면? 프로그램은 문제없이 정상적으로 에러 로그를 찍고 진행하겠지만 파일 스트림은 열려있는 채로 남아있을 것이다. 만약 초당 수백명의 사용자가 접속해 사용하는 시스템이라면 제때 정리되지 않은 리소스가 문제를 일으킬 가능성이 있다.

try-catch 구문에서 리소스를 열었다면, finally 블록에서 리소스를 정리하거나 try-with-resource 구문을 이용해야한다.

FileInputStream inputStream = null;

try {
    File file = new File("./tmp.txt");
    byte[] buffer = new byte[512];
    inputStream = new FileInputStream(file);
    inputStream.read(buffer, 0, 512);
    inputStream.close();
} catch (FileNotFoundException e) {
    log.error(e);
} catch (IOException e) {
    log.error(e);
} finally {
    if (inputStream != null) {
    try {
        inputStream.close();
    } catch (IOException e) {
        log.error(e);
    }
}

이렇게 finally 블록에 리소스를 정리하는 코드를 넣어주면 된다.

만약 리소스가 AutoCloseable 인터페이스를 구현하고 있다면, try-with-resource 구문을 이용해서 좀 더 깔끔한 코드를 작성할 수 있다.

File file = new File("./tmp.txt");

try (FileInputStream inputStream = new FileInputStream(file);) {            
    byte[] buffer = new byte[512];
    inputStream.read(buffer, 0, 512);
    inputStream.close();
} catch (FileNotFoundException e) {
    log.error(e);
} catch (IOException e) {
    log.error(e);
}

2. 더 자세한 예외

메소드를 정의할 때, 이 메소드에서 발생할 수 있는 예외를 throw 구문으로 명시하도록 되어 있다. 이 때, 메소드에서 발생할 수 있는 예외는 최대한 자세한 예외를 명시하는 것이 좋다.

public void exceptableMethod() throw Exception;

public void execptableMethod() throw NumberFormatException;

두 메소드 정의 모두 예외를 발생시킨다. 첫 번째는 Exception 클래스를 사용해서 뭔지는 모르겠지만 예외가 발생할 수 있다는 것을 명시했고, 두번째는 좀 더 자세한 예외 클래스인 NumberFormatException을 명시했다.

Exception 클래스로 퉁쳐버리면 이 메소드를 호출하는 쪽에서 예외처리하는 코드가 복잡해진다. Exception으로 받은 예외가 NumberFormatException인 경우도 있고, llegalArgumentException, IOException 등 일 수도 있다. 따라서 모든 경우에 대한 코드를 작성할 수 밖에 없다.

3. javadoc으로 설명하라

메소드가 예외를 발생시킬 수 있는 경우, 어떤 경우에 어떤 예외가 발생하는지 javadoc 문서를 통해서 기술해야한다. throws 절에 최대한 구체적인 예외를 사용하더라도 어떤 상황에서 예외가 발생할 수 있는지 글로 자세히 설명해줘야 메소드를 호출하는 쪽에서 적당한 예외코드를 작성할 수 있다.

/**
 * 이 메소드는 어떨때 사용하는 메소드입니다.
 *
 * @param input 입력 값
 * @throws MyException 어떤어떤 경우에 이 예외가 발생합니다.
 */
public void myMethod(String input) throws MyException { ... }

javadoc 문서에 @throws 선언으로 예외에 대한 설명을 글로 적어줘야한다. 최대한 자세히..

4. 메시지를 자세하게 적는다

사용자 정의 예외를 만들 때, 예외가 발생한 상황에 대한 메시지를 적을 수 있다. 이 때, 어떤 상황에서 예외가 발생했는지 한 두문장 정도로 간결하게 잘 적어둬야한다.

단, 예외의 이름에서 어떤 상황인지 알 수 있을 때에는 너무 많은 정보를 메시지에 적지 않아도 된다. 그렇기 때문에 최대한 상황을 잘 설명할 수 있는 예외이름을 짓는것이 중요하다.

예를 들어 IOException의 경우 왜 IO 실패를 했는지 부가적인 정보가 필요하다. 반면 NumberFormatException 예외의 경우 숫자 포맷이 아닌 문자열을 숫자로 변환하려고 했음을 이름에서 짐작할 수 있다. 이 경우 어떤 문자열을 변환하려했는지만 적당하게 적어주면 된다.

try {

    new Long("xyz");

} catch (NumberFormatException e) {

    System.out.println(e);

}

이 코드를 실행한 경우

java.lang.NumberFormatException: For input string: "xyz"

이런 에러가 발생한다. "문자열을 숫자로 변경할 수 없습니다." 같은 내용이 예외 메시지에 없어도 충분히 상황을 파악할 수 있다.

5. catch 절 순서

try-catch 블럭에서 여러 예외가 발생할 수 있는 경우 좀 더 상세한 예외부터 처리해야 한다. 예외가 상속 관계에 있을 경우 앞쪽 catch 절에서 더 넓은 범위의 예외를 먼저 처리해버리면, 뒤 쪽 catch 절이 쓸모없게 되어버리게 됩니다.

예를 들어 ,

try {

    method();

} catch (Exception e) {

    log.error(e);

} catch (IllegalArgumentException e) {
    // 실행 안 됨
    log.error(e);

}

이렇게 처리해버리면 모든 예외가 Exception 절에서 걸려버리므로 IllegalArgumentException 처리 블록은 실행조차 안된다.

try {

    method();

} catch (IllegalArgumentException e) {

    log.error(e);

} catch (Exception e) {

    log.error(e);

}

따라서 순서를 이렇게 상세한 예외부터 처리해줘야한다. (IDE에서 잡아주는 경우가 있다.)

6. Throwable은 catch 하지마라.

Throwable은 모든 예외 클래스와 에러의 슈퍼 클래스다. Throwable 역시 catch 절에서 잡아 처리할 수 있다. 하지만 이렇게 하면 곤란하다.

Throwable을 catch 절에서 잡아 처리해버리면, 예외뿐만아니라 에러도 잡아서 처리해버린다. 예외와 다르게 에러의 경우 JVM이 프로그램 실행을 못 할 정도의 심각한 문제라고 판단될 경우 발생한다. 사용자가 이런 에러까지 잡아서 처리해버리면 JVM에서 예상치못한 동작들을 할 수도 있다.

예를 들어 OutOfMemoryError, StackOverflowError 같은건 사용자가 catch 절에서 잡아도 처리할 수 있는게 없다. 따라서 Throwable은 예외처리하지 않는 것을 추천한다.

7. 읽씹하지마라

코드를 빠르게 작성하다보면 임시방편으로 예외를 잡아서 무시하는 경우가 있다.

try {
    method();
} catch (Exception e) {
    /* 예외는 발생할 수 없음 */
}

메소드에서 절대 예외가 발생할 수 없는 경우라고해도 메소드 시그니처를 바꾸지 않으면 나중에 예외가 발생하도록 패치가 될 수도 있다. 그렇게 패치가 될 경우 이 코드는 문제를 발생시킬 수 있다.

예외가 발생하지 않는다면 시그니처에서 예외를 제거하는게 옳다. 그럴 수 없다면 로그라도 찍어서 상황을 모니터링할 수 있게 해야한다.

8. 로그 찍고 다시 던지지 마라

예외를 처리할 때 습관적으로 로그를 남기고, 다시 상위 메소드로 발생한 예외를 던지는 경우가 있다. 예를 들어

try {

    new Long("xyz");

} catch (NumberFormatException e) {

    log.error(e);

    throw e;
}

NumberFormatException이 발생한 경우 로그를 찍고 다시 상위로 예외를 던져버리는 경우가 있다. 심지어 오픈소스 라이브러리에서도 이렇게 쓰는 경우가 많이 있다. 이렇게하면 로그가 너무 자주 찍혀서 가독성을 훼손한다.

다시 상위로 Throw 할 경우 로그를 찍지 말아야한다. 그 상황에 대한 컨텍스트를 남기고 싶으면 차라리 예외를 래핑해서 새로운 클래스로 만들고 컨텍스트에 대한 정보를 담아 상위로 던져야 한다.

9. 예외를 래핑할 경우 Cause 예외를 담아서 던져라

8번에서 말했던 것처럼 컨텍스트를 추가해서 상위로 던지고 싶을 때, 예외를 래핑해서 사용할 수 있다. 이 때, 반드시 원래 발생했던 예외를 생성자에 넘겨줘야 스택 정보와 메시지, 컨텍스트 정보 등이 상위로 전달된다. 이런 정보가 사라지면 디버깅이 힘들어진다.

예를 들어 다음과 같이 처리해줘야 한다.

try {

    method();
    
} catch (NumberFormatException e) {

    throw new MyException("New Message", e);

}

댓글