본문 바로가기
카테고리 없음

[Java] 9가지 예외처리 Best Practice - Exception Handling

by 왕 달팽이 2020. 8. 8.
반응형

자바 프로그래밍에서 '예외(Exception)' 처리는 다소 까다로운 주제입니다. 때문에 각 개발팀들은 자신들만의 예외처리 규칙을 만들고 사용하는데요. 일반적으로 자바 프로젝트에서 따르면 좋은 예외처리 'Best Practice'를 정리해보겠습니다.

자바 예외(Exception)

프로그램 실행과정에서 발생하는 비정상적인 상황을 '예외(Exception)'라고 합니다. 예를들어 존재하지 않는 파일을 오픈하려고 한다던가, 설정되지 않은 객체의 메소드를 호출하려고 하는 경우 예외가 발생합니다.

프로그램 실행중에 발생하는 예외를 잘 처리해주는 것을 '예외처리(Exception Handling)'이라고 합니다. 예외 상황에서 벗어나도록 코드를 작성하던가 시스템을 망가트리지 않는 상황에서 자연스럽게 프로그램이 종료되도록 처리해줘야 합니다.

대부분의 현대 언어에서는 예외를 발생시키고 'catch' 절에서 받아 처리하는 메커니즘을 가지고 있습니다. 예외처리에 대한 메커니즘이 없으면 'Return Code'를 이용해 예외 상황에 대한 정보를 상위 컨텍스트로 넘겨줘야 합니다. 대표적으로 C언어로 작성된 코드가 이런 방식을 사용합니다.

(사족을 달자면, C언어에서 조차 setjmp(), longjmp(), Recovery Information을 전역 변수 혹은 Shared Memory 등에 만들어 놓고 TRY-CATCH 매크로를 만들어 사용하는 경우가 많습니다. 이 포스트에서는 자바를 다루기 때문에 넘어가도록 하겠습니다. 나중에 시간이 되면 다뤄보도록 하죠)

프로그램의 실행과정에서 발생하는 이상상황을 에러코드로 처리하면 간단합니다. 프로그램의 실행 컨텍스트가 중간에 끊기는 일도 없습니다.

하지만 함수를 사용하는 쪽 코드에서 항상 리턴코드를 체크하고 에러 상황에 대한 동작을 작성해놔야 합니다. 이렇게 되면 예외상황에 대한 처리 코드가 덕지덕지 붙게되어 코드의 가독성이 떨어지고, 단순한 로직을 구현함에도 복잡한 예외 상황에 대한 방어코드가 많아지는 현상을 경험하게 됩니다.

또 한, 단순히 리턴코드로만 에러를 전파하는 경우 에러 발생의 컨텍스트를 알 수 없다는 단점이 있습니다. 예를 들어 파일을 열수 없다는 에러코드가 리턴되었을 때, '어떤 파일이 없는데???'라는 의문에는 답을 할 수가 없습니다.

 

 

프로그래밍 언에서의 '예외(Exception)'라는 것이 이런 상황에서 프로그래머를 구해주었습니다.

리턴코드를 사용하는 C언어 방식과 다르게 자바에서는 예외가 발생했을 때 프로그램의 실행 컨텍스트가 인터럽트(Interrupt) 됩니다. 당장 중단되고 예외가 발생하여 콜스택을 따라 상위로 예외가 던져집니다. 발생한 예외를 처리하는 catch 절이 없다면 프로그램은 결국 종료됩니다.

예외는 객체 형태로 다양한 정보를 담아 상위 컨텍스트로 던져집니다. 예외를 처리하는 쪽에서는 예외 객체를 통해 예외처리에 필요한 정보를 얻어 낼 수 있습니다. 파일이 없다는 예외가 발생한 경우, 어떤 파일이 없는지는 예외 객체를 통해 알 수 있습니다.

예외는 던져지고(Throw), 받아서(Catch) 처리되는데요. 자바 프로그램을 예로 들어보겠습니다.

public class Test { 

    public static void main(String[] args){
    
        System.out.println("Start program");
        
        int[] tempArray = new int[]{1,2,3};
        
        printArrayItem(tempArray);
        
        System.out.println("Finish program");
    }
    
    private static void printArrayItem(int[] array) {
        System.out.println("first : " + array[0]);
        System.out.println("first : " + array[1]);
        System.out.println("first : " + array[2]);
        System.out.println("first : " + array[3]);
    }
}

이 프로그램은 실행을 완료하지못하고 'ArrayIndexOutOfBoundsException'을 발생시키고 종료됩니다.

Start program
first : 1
first : 2
first : 3
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 3
	at Test.printArrayItem(Test.java:19)
	at Test.main(Test.java:10)

배열에 담겨진 항목은 3개인데 '3'으로 접근했기 때문입니다.

이런 상황을 try-catch 구문을 이용해 다음과 같이 넘어갈 수 있습니다.

public class Test {

    public static void main(String[] args){

        System.out.println("Start program");

        int[] tempArray = new int[]{1,2,3};

        try {
            printArrayItem(tempArray);
        } catch (ArrayIndexOutOfBoundsException e) {
            /* No-op */
        }

        System.out.println("Finish program");
    }

    private static void printArrayItem(int[] array) {
        System.out.println("first : " + array[0]);
        System.out.println("first : " + array[1]);
        System.out.println("first : " + array[2]);
        System.out.println("first : " + array[3]);
    }
}

try - catch 구문을 이용해 예외를 잡았고, 그냥 넘어가도록 처리를 했습니다.

Start program
first : 1
first : 2
first : 3
Finish program

Process finished with exit code 0

try-catch 구문을 이용하면 코드를 깔끔하게 유지하면서도 우아하게(Gracefully) 예외 사항에 대한 처리를 할 수 있습니다.

 

Java Exception best practice

try-catch 구문을 잘 쓰면 코드가 굉장히 깔끔해지지만 대충 쓰면 안쓰느니만 못 한 코드를 만들어 낼 수도 있습니다. 예외처리를 잘 하기 위한 'best practice'에 대한 글을 정리해보겠습니다.

1. 리소스 정리

try-catch 블록에서 리소스에 접근하는 경우가 있습니다. 예를들어 JDBC 드라이버를 통해 데이터베이스에 접근한다던지 파일을 열어서 내용을 읽거나 쓰는 코드가 있을 수 있습니다.

예를들어 다음과 같은 코드를 보겠습니다.

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. 더 자세한 예외

메소드를 정의할 때 발생할 수 있는 예외를 명시하게 되어 있습니다. 메소드에서 발생할 수 있는 예외는 최대한 자세한 예외를 명시하는 것이 좋습니다.

public void exceptableMethod() throw Exception;

public void execptableMethod() throw NumberFormatException;

가장 간단한 방법은 Exception 클래스로 다 던져버리는 것입니다. 하지만 이렇게 되면 이 메소드를 사용하는 쪽에서 Exception을 받아 처리하는 코드가 복잡해집니다.

Exception으로 명시한 경우 발생한 예외가 NumberFormatException인 경우도 있고, IllegalArgumentException, IOException 등이 발생했는지 알 수 없으므로 모든 경우에 대해서 처리를 해야합니다.

3. 발생할 수 있는 예외를 기술하라

메소드가 예외를 발생시킬 수 있는 경우, 어떤 경우에 어떤 예외가 발생할 수 있는지 javadoc을 통해 명시해야합니다. 최대한 자세한 예외를 사용하더라도 어떤 경우에 예외가 발생할 수 있는지 자세히 글로 설명해줘야 메소드를 호출하는 쪽에서 적절히 대응할 수 있습니다.

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

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

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

사용자 정의 예외를 만들 때, 예외가 발생한 상황에 대한 메시지를 적을 수 있습니다. 이 때, 어떤 상황에서 예외가 발생했는지 1~2문장 정도로 간결하게 잘 적어둬야 합니다. 단 너무 장황하게 쓰지는 말아야 합니다.

예외의 이름에서 어떤 상황인지 알 수 있을 때에는 너무 많은 정보를 메시지에 적지 않아도 됩니다.

예를 들어 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 같은건 사용자가 잡아서 처리하지 않는게 좋습니다. 따라서 Throwable은 예외처리하지 않는 것을 추천합니다.

7. Exception을 씹지 말아라

빠른 속도로 코드를 작성하다보면 다음과 같은 임시방편 코드를 만들어내는 경우가 있습니다.

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);

}

예외를 잘 처리해야 깔끔하고 좋은 코드입니다.

반응형

댓글