엔지니어로 가는 길

Java Exceptions의 정의와 Exception의 장점 본문

프로그래밍/Java

Java Exceptions의 정의와 Exception의 장점

탐p슨 2022. 11. 6. 17:36
728x90

최근에 지인에게 예외가 뭐냐는 질문을 받았다. 코드에서 예외를 다루는 건 익숙한데 예외에 대해 설명하려니 긴가민가한 부분이 있었다. ‘예외랑 에러랑 어떻게 다르더라? 예외는 이름에서 알 수 있듯 예외적인 상황과 관련이 있는데, 어디선가 예외도 정상 플로우라고 들었던 것 같은데 핸들링하고 있는 예외는 정상 플로우로 보는 거였나?’

아래의 글에서 토비의 스프링을 읽으며 Checked exception과 Unchecked exception에 대해 간략히 정리한 적이 있는데, 이번에는 오라클 문서를 읽으며 예외가 무엇인지 기본 개념에 대해 정리해보려고 한다.

 

 

JAVA Error와 Checked/Unchecked Exception

Error java.lang.Error 클래스의 서브 클래스들이 해당된다. 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우 사용된다. 그래서 주로 JVM에서 발생시킨다. 애플리케이션 코드는 에러를 잡으려

live-everyday.tistory.com

 

What is an Exception?

- Exception이란 “exceptional event”의 약자이다.
- Exception이란 프로그램의 실행 중에 발생하며 프로그램의 정상 흐름을 방해하는 이벤트이다.

메소드 내에서 에러가 발생하면 메서드는 객체를 하나 만들어 런타임 시스템에 전달한다. 여기서 메서드가 만드는 객체를 exception object라고 하며, 이 객체는 에러에 대한 정보와 타입, 에러가 발생했을 때의 프로그램의 상태를 가지고 있다. 예외 객체를 만들어 런타임 시스템에 넘기는 과정을 throwing an exception이라 한다.

메서드가 예외를 던지고 나면 런타임 시스템은 해당 예외를 처리할 ‘무언가’를 찾는다.  여기서 예외를 처리할 가능성이 있는 ‘무언가’란 에러가 발생한 메서드를 호출한 메서드들로, 에러가 발생한 메서드와의 거리에 대한 오름차순으로 정렬되어 있다. 이러한 메서드들을 call stack이라 부른다.

 

 

런타임 시스템은 예외를 처리할 수 있는 코드 블록을 가진 메소드를 찾기 위해 call stack을 탐색한다. 예외를 처리할 수 있는 코드 블록을 exception handler라 부른다. 탐색은 에러가 발생한 메서드에서 시작해서 call stack에서 메서드가 호출된 순서의 역순으로 진행된다. (에러가 발생한 메서드와 가까운 메서드부터 먼 메서드 순으로 탐색을 진행한다.) 적절한 핸들러를 찾으면 런타임 시스템은 예외를 해당 핸들러에 전달한다. 예외를 처리할 ‘적절한’ 핸들러란 예외의 타입이 핸들러에서 처리할 수 있다고 명시한 타입과 매치되는 핸들러를 말한다.

 

예외를 처리할 적절한 핸들러를 찾았을 때 핸들러 입장에서 보면 이는 catch the exception로 볼 수 있다. 만약 런타임 시스템이 call stack의 모든 메소드를 뒤졌으나 적당한 exception handler를 찾지 못했을 경우 런타임 시스템은 종료된다.

 

Advantages of Exceptions

Advantage 1: Separating Error-Handling Code from “Regular” Code

 

예외는 예외적인 이벤트가 발생했을 때 무엇을 할지에 대한 구체적인 내용을 프로그램의 메인 로직에서 분리할 수 있는 수단을 제공한다. 전통적인 프로그래밍에서는 error detection, reporting, and handling가 종종 혼란스러운 스파게티 코드로 이어졌다. 예를 들어 전체 파일을 메모리로 읽어 들이는 아래와 같은 수도코드 메서드를 보자.

 

readFile {
    open the file;
    determine its size;
    allocate that much memory;
    read the file into memory;
    close the file;
}

 

위의 코드는 한 눈에 이해가 되는 충분히 단순한 함수이다. 하지만 위의 코드에는 아래와 같은 잠재적 에러가 존재한다.

 

  • 파일이 열리지 않을 경우
  • 파일의 길이가 결정될 수 없는 경우
  • 충분한 메모리가 할당될 수 없는 경우
  • 파일 읽기에 실패한 경우
  • 파일을 닫을 수 없는 경우

 

위의 잠재적 에러를 처리하기 위해서 readFile이라는 함수는 아래와 같이 error detection, reporting, handling을 위한 코드를 반드시 포함해야 한다. 아래와 같이 말이다.

 

errorCodeType readFile {
    initialize errorCode = 0;
    
    open the file;
    if (theFileIsOpen) {
        determine the length of the file;
        if (gotTheFileLength) {
            allocate that much memory;
            if (gotEnoughMemory) {
                read the file into memory;
                if (readFailed) {
                    errorCode = -1;
                }
            } else {
                errorCode = -2;
            }
        } else {
            errorCode = -3;
        }
        close the file;
        if (theFileDidntClose && errorCode == 0) {
            errorCode = -4;
        } else {
            errorCode = errorCode and -4;
        }
    } else {
        errorCode = -5;
    }
    return errorCode;
}

 

error detection, reporting, returning이 이곳저곳에 너무 많이 생긴 나머지 원래의 핵심 로직에 해당하는 5줄이 대놓고 드러나지 않고 코드 속으로 자취를 감췄다. 이제는 코드가 제 할 일을 제대로 하는지 확인하는 것조차 어려워졌다.

 

Exception은 예외적인 상황을 메인 플로우가 아닌 다른 곳에서 처리할 수 있도록 도와준다. readFile 함수가 전통적인 error-management techniques 대신 예외를 사용한다면 코드를 아래와 같이 작성할 수 있다.

 

readFile {
    try {
        open the file;
        determine its size;
        allocate that much memory;
        read the file into memory;
        close the file;
    } catch (fileOpenFailed) {
       doSomething;
    } catch (sizeDeterminationFailed) {
        doSomething;
    } catch (memoryAllocationFailed) {
        doSomething;
    } catch (readFailed) {
        doSomething;
    } catch (fileCloseFailed) {
        doSomething;
    }
}

 

Advantage 2: Propagating Errors Up the Call Stack

 

예외의 두 번째 장점은 error reporting을 call stack of methods 위로 전파시키는 능력이다. readFile 메서드가 메인 프로그램에 의해 호출된 일련의 중첩 메서드 콜 중 네 번째 메서드라고 가정해보자.

 

// main -> method1 -> method2 -> method3 -> readFile

method1 {
    call method2;
}

method2 {
    call method3;
}

method3 {
    call readFile;
}

 

그리고 method1이 readFile에서 발생할 수 있는 에러에 유일하게 관심있는 메서드라고 가정해보자. 전통적인 error-notification techniques의 경우 readFile에 의해 리턴되는 에러 코드가 method1에 도달할 때까지 call stack 위로 전파시킨다. (method3에서 에러 코드를 리턴해야 하고, method2에서도 에러 코드를 리턴해야 method1에서 드디어 에러를 처리할 수 있다.)

 

// main -> method1 -> method2 -> method3 -> readFile

method1 {
    errorCodeType error;
    error = call method2;
    if (error)
        doErrorProcessing;
    else
        proceed;
}

errorCodeType method2 {
    errorCodeType error;
    error = call method3;
    if (error)
        return error;
    else
        proceed;
}

errorCodeType method3 {
    errorCodeType error;
    error = call readFile;
    if (error)
        return error;
    else
        proceed;
}

 

자바 런타임 환경은 특정 예외를 처리하는 데 관심 있는 메서드를 찾기 위해 call stack을 위에서부터 아래로 탐색한다는 사실을 떠올려보자. 메서드는 그 안에서 던져지는 어떤 예외든 call stack 저 멀리에 있는 메서드가 잡을 수 있도록 다음으로 던질 수 있다. 따라서 에러에 관심 있는 유일한 메서드에서만 detecting errors에 관심을 기울이면 된다.

 

method1 {
    try {
        call method2;
    } catch (exception e) {
        doErrorProcessing;
    }
}

method2 throws exception {
    call method3;
}

method3 throws exception {
    call readFile;
}

 

 

수도코드에서 볼 수 있듯 에러를 처리할 수 있는 메서드에 도달하기 전까지 그 사이에 위치한 메서드들은 throws clause에 예외를 명시함으로써 자신이 처리하지 않고 예외를 call stack의 다음 순번 메서드에게 던지겠다고 말해야 한다.

 

Advantage 3: Grouping and Differentiating Error Types

 

프로그램에서 던져지는 모든 예외는 객체이므로 클래스 계층구조에 따라 예외를 그루핑하고 카테고라이징 할 수 있다. java.io의 IOException과 이 예외의 자손들이 바로 예외 클래스 그룹의 한 예시이다. IOException은 입출력 관련 작업을 할 때 발생할 수 있는 모든 타입의 에러를 표현하는 가장 일반적인 예외이다. IOException의 자손들은 더 구체적인 에러를 표현한다. 예를 들어, FileNotFoundException 클래스는 디스크에서 해당 파일이 발견되지 않았음을 의미한다.

 

메서드에서 매우 구체적인 예외를 처리하는 핸들러를 작성할 수 있다. FileNotFoundException 클래스는 자손이 없으므로 아래와 같은 핸들러는 오직 하나의 타입의 예외만 처리할 수 있다.

 

catch (FileNotFoundException e) {

    ...

}

 

메서드에서 부모 클래스에 해당하는 예외 클래스를 잡겠다고 명시하면, 해당 부모 클래스의 모든 자손들까지 처리할 수 있는 핸들러를 작성할 수 있다.

 

catch (IOException e) {

    ...

}

 

위의 핸들러는 FileNotFoundException, EOFException 뿐만 아니라 모든 I/O 예외를 잡을 수 있다.

 

아래와 같이 exception handler에 전달되는 인자를 통해 예외에 대한 구체적인 내용을 얻을 수도 있다.

 

catch (IOException e) {
    // Output goes to System.err.
    e.printStackTrace();
    // Send trace to stdout.
    e.printStackTrace(System.out);
}

 

만약 아래와 같이 핸들러를 작성하면 어떻게 될까?

 

// A (too) general exception handler

catch (Exception e) {

    ...

}

 

Exception 클래스는 Throwable 클래스 계층구조에서 가장 Throwable과 가까운 클래스이다. 따라서 이 핸들러는 핸들러가 처리하고자 했던 예외를 포함하여 다른 모든 종류의 예외까지 처리할 수 있다.

 

대부분의 상황에서 exception handler는 구체적일수록 좋다. 풀려고 하는 문제가 구체적으로 주어질수록 효과적이고 효율적으로 대처할 수 있기 때문이다. 변수를 만들 때 데이터 타입을 명시하지 않으면 컴파일러 입장에서는 가능한 변수 중 가장 큰 크기의 변수를 고려하고 그 만큼의 메모리를 할당할 수 밖에 없듯이, 구체적인 에러를 명시하지 않으면 핸들러는 모든 가능성을 따질 수밖에 없다.

 

매우 일반적인 exception handler는 프로그래머가 생각하지 않은, 핸들러가 의도하지 않은 예외를 잡고 처리함으로써 코드를 오히려 더욱 error-prone 하게 만들 수 있다.

 

참고

https://docs.oracle.com/javase/tutorial/essential/exceptions/index.html

https://docs.oracle.com/javase/tutorial/essential/exceptions/definition.html

https://docs.oracle.com/javase/tutorial/essential/exceptions/advantages.html

728x90
Comments