Technology/Java

Java의 예외(Exception)에 대해 알아보자!

ikjo 2022. 9. 12. 21:54

프로그램에서 발생할 수 있는 3가지 에러

예외 처리를 다루기 앞서 우선 프로그램에서 발생할 수 있는 3가지의 에러에 대해 알아보자.

 

첫 번째는 컴파일 에러이다. 이는 컴파일러가 소스코드를 컴파일할 경우 오타, 자료형 체크 등의 기본적인 검사를 수행함으로써 프로그램이 실행되기 전에 에러가 발생한다.

 

두 번째는 런타임 에러이다. 이는 프로그램이 실행 중에 발생하는 에러로 이로 인해 잘못된 결과를 얻거나 프로그램이 비정상적으로 종료될 수 있다.

 

마지막으로 논리적 에러이다. 이는 컴파일 에러나 런타임 에러는 없지만, 개발자가 원래 의도한 것과 다르게 동작하는 것을 말한다. 

 

 

예외(Exception)란?

앞서 프로그램에서 발생할 수 있는 3가지 에러에 대해 간단히 살펴보았는데, 자바에서는 런타임 에러를 '에러(Error)'와 '예외(Exception)'로 구분한다. 

 

여기서 에러는 메모리 부족, 스택오버플로우 등 발생하면 코드로 수습할 수 없는 심각한 오류를 뜻하고, 예외는 발생하더라도 코드로 수습할 수 있는 다소 미약한 오류를 뜻한다. 즉, 예외는 개발자가 프로그램 실행 도중 발생할 수 있는 여러 경우의 수를 미리 고려함으로써 코드로 프로그램의 비정상적인 종료를 방지할 수 있는 것이다.

 

참고로 자바에서는 Throwable 클래스의 하위 클래스로서 에러를 Error 클래스로, 예외를 Exception 클래스로 정의하였고 각 클래스에는 다음과 같이 여러 하위 클래스들이 존재한다.

 

image source : https://www.geeksforgeeks.org/exceptions-in-java/

 

이때 Exception 클래스의 하위 클래스는 Checked 예외인지 Unchecked 예외인지에 따라 구분할 수 있는데, Checked 예외 클래스들(Exception 클래스 등)은 컴파일러가 예외 처리를 확인하는 예외로서 예외 처리가 되어야 할 부분에 예외 처리가 되어 있지 않으면 컴파일 에러가 발생하게 된다.

 

Exception 예외는 Checked 예외이다.

 

반대로 Unchecked 예외 클래스들(RuntimeException 클래스 등)은 컴파일러가 예외 처리를 확인하지 않아 별도로 예외 처리를 해주지 않아도 컴파일이 정상 처리된다.

 

RuntimeException 예외는 Unchecked 예외이다.

 

위와 같이 throw 키워드와 new 연산자를 이용하면 개발자가 의도적으로 특정 예외를 발생시킬 수 있다.

 

 

자바가 제공하는 예외 처리와 관련된 키워드

앞서 '예외 처리'라는 키워드를 언급했는데, 예외 처리란 개발자가 프로그램 실행 시 발생할 수 있는 예외의 발생에 대비한 코드를 미리 작성하는 것을 의미한다. 이를 통해 프로그램이 예외 발생으로 인해 갑작스럽게 종료되는 문제를 방지할 수 있게 된다. 참고로 발생한 예외를 처리하지 못하면 프로그램이 종료되며 JVM의 '예외처리기'가 예외의 원인을 화면에 출력해준다.

 

try - catch 문

우선 자바에서 예외 처리는 try - catch 문으로 할 수 있다. 이때 try 블럭에는 예외가 발생할 가능성이 있는 코드를 작성하고 catch 블럭에는 특정 예외가 발생했을 경우 이를 처리하기 위한 코드를 작성한다.

 

아래 예시에서는 try 블럭에 배열에 유효한 인덱스 범위(0 ~ 9)를 벗어나 접근한 코드를 작성했고, catch 블럭에는 IndexOutOfBoundsException 예외가 발생했을 경우 관련 메시지를 콘솔창에 출력하는 코드를 작성했다.

 

    try {
        int[] arr = new int[10];
        arr[10] = 10; // IndexOutOfBoundsException 예외 발생
        System.out.println("정상 처리!!"); // 실행되지 않는다.
        
    } catch (IndexOutOfBoundsException e) {
        System.out.println("배열의 지정된 인덱스 범위 0 ~ 9를 벗어났습니다.");
    }
    
    // 이후에 실행될 부분들

 

try 블럭에서 배열의 유효하지 안은 인덱스(10)에 접근했을 때 IndexOutOfBoundsException 예외가 발생하는데, 이 경우 try 블럭 내 해당 예외가 발생한 이후에 로직은 실행되지 않으며, 발생한 예외와 일치(instanceof 연산자 사용)하는 catch 블럭이 있는지 확인하고 있으면 catch 블럭에 정의된 코드로 예외 처리를 한다. 예외 처리 이후에는 프로그램이 비정상적으로 종료되지 않고 try - catch 문 이후의 코드들이 실행된다.

 

참고로 Exception 클래스는 모든 예외 클래스들의 상위 클래스이므로 try - catch 문 마지막에 Exception 클래스 타입의 참조변수를 선언한 catch 블럭을 추가하면, IndexOutOfBoundsException 예외가 아니더라도 어떤 종류의 예외든지 해당 catch 블럭에서 예외를 처리하도록 할 수 있다. 또한 '|' 기호를 이용해 여러 catch 블럭을 하나의 catch 블럭으로 합칠 수도 있다.

 

    try {
    
        int[] arr = new int[10];
       
        // ...
        
    } catch (IndexOutOfBoundsException e) {
        System.out.println("배열의 지정된 인덱스 범위 0 ~ 9를 벗어났습니다.");
    } catch (ExampleExceptionA | ExampleExceptionB e) {
        // ...
    } catch (Exception e) {
        System.out.println("예외 발생!!");
    }
    
    // 이후에 실행될 부분들

 

throws 키워드

try - catch 문 외에도 메서의 선언부에 throws 키워드를 사용함으로써 예외 처리를 해줄 수도 있다. throws 키워드는 사실 예외를 직접 처리하는 것이라기 보다도 해당 메서드를 호출한 메서드에게 예외를 전달하여 예외 처리를 맡기는 것이다.

 

아래 예시에서는 IOException 클래스를 메서드에 선언했는데, 이는 main 메서드에서 IOException 예외가 발생할 가능성이 있다는 것을 의미한다. (참고로 IOException은 Checked 예외이므로 예외 처리를 해주어야 컴파일러 에러 발생하지 않는다.)

 

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int n = Integer.parseInt(br.readLine());
        
        // ...
        
    }

 

만일, Exception 클래스를 메서드에 선언한다면 해당 메서드는 모든 종류의 예외가 발생할 가능성이 있다는 의미가 되는데, 이는 Exception 클래스의 하위 클래스 예외들이 발생할 가능성이 있다는 것을 의미하기도 한다.

 

이렇게 자신(A)을 호출한 메서드(B)에 예외를 전달하고, 다시 그 메서드(B)를 호출한 메서드(C)에 예외를 전달할 수도 있다. 최종적으로 main 메서드에서도 예외가 처리되지 않는다면 프로그램이 종료된다.

 

또한 발생할 수 있는 예외가 여러 개 일 때도 아래와 같이 쉼표(,)를 통해 구분하여 작성할 수 있다.

 

    public static void main(String[] args) throws ExampleExceptionA, ExampleExceptionB, ... {
        // ...

 

finally 블럭

앞서 try - catch 문을 다루면서 예외 발생 유무에 따라 try 블럭 내 예외가 발생한 코드 이후의 코드들과 catch 블럭 내 코드들의 실행 유무가 결정되곤했다. 이때 finally 블럭을 이용하면 예외의 발생 여부와 상관없이 'try 블럭이 종료될 때' 특정 코드를 실행시킬 수 있다.(예외가 발생한 경우 catch 블럭이 종료된 후 실행)

 

이러한 finally 블럭은 주로 예외나 return, continue, break 같이 특정 코드들을 건너 뛰는 바람에 실행되지 못하는 cleanup code(스트림 등 자원 반환 코드)가 실행되도록 도와주는 역할을 한다.

 

아래 코드에선 BufferdReader 객체로 콘솔(console)을 통해 데이터를 입력받고 출력하고있는데, 중간에 exit를 입력하여 정상적으로 try 블럭을 빠져나오는 경우에서나, readLine 메서드 실행 중 예외가 발생한 경우에서나 finally 블럭을 통해 항상 close 메서드를 통해 스트림과 관련 자원들을 반환하도록 하고 있다. 이때 close 메서드를 실행하는 중에도 예외가 발생할 수 있으므로 별도 예외 처리를 해주었다.

 

        BufferedReader br = null;
        
        try {
            br = new BufferedReader(new InputStreamReader(System.in));
            String inLineData;
            while ((inLineData = br.readLine()) != null) {
            	if (inLineData.equals("exit")) break;
                System.out.println(inLineData);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                if (br != null) {
                    br.close();
                }
            } catch (IOException e){
                e.printStackTrace();
            }
        }

 

참고로 이때 BufferedReader 객체를 닫음(close)함으로써 System.in 역시 close 되는데, System.in을 reopen할 수 있는 것은 오직 JVM만 가능하기에 이후에 System.in으로부터 새로운 BufferedReader 등의 스트림 객체를 만들어서 사용하는 것이 불가능해지니 유의해야한다.

 

try - with - resources 문

JDK1.7부터는 try - with - resources 문을 지원하는데, 이는 자동 자원 반환 기능을 제공한다. try - with - resources괄호 ()에 객체를 생성하는 코드를 작성하면 따로 close 메서드를 호출하지 않아도 try 블럭을 벗어나는 순간 자동으로 close 메서드가 호출되는 것이다.

※ 여러 개의 객체를 생성하는 경우 괄호 () 안에서 세미콜론(;)으로 구분한다.

 

앞선 코드에서는 BufferdReader 객체를 사용하고나서 finally 블럭을 통해 항상 close 메서드가 호출되도록 하였는데, 코드가 다소 장황한 감이 있다. 앞선 코드를 아래와 같이 try - with -resources 문으로 바꾸면 한결 깔끔해진다.

 

        try (BufferedReader br = new BufferedReader(new InputStreamReader(System.in))) {
            String inLineData;
            while ((inLineData = br.readLine()) != null) {
                if (inLineData.equals("exit")) break;
                System.out.println(inLineData);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

 

참고로 BufferedReader 클래스의 상위 클래스인 Reader 클래스가 구현한 Closeable 인터페이스는 다시 AutoCloswable 인터페이스를 상속받는데, 이 AutoCloseable 인터페이스를 구현한 클래스만이 try - with - resources 문에 의해 자동으로 close 메서드가 호출된다.

 

 

public interface AutoCloseable {
    void close() throws Exception;
}

 

 

커스텀한 예외 만들기

앞서 자바는 예외와 관련하여 Exception, RuntimeException 등 여러가지 클래스를 제공한다고 했었다. 하지만 개발자가 필요에 따라 기존 예외 클래스(보통 Exception 클래스나 RuntimeException 클래스)를 상속받아 새로운 예외 클래스를 정의하여 사용할 수도 있다.

 

아래 코드는 RuntimeException 예외 클래스를 상속받아 커스텀한 예외 클래스를 정의한 것이다. 이때 RuntimeException 클래스를 상속받았으므로, ImageUploadException 클래스 역시 Unchecked 예외에 속하게 된다. 만일 Exception 클래스를 상속받으면 Checked 예외에 속하게 된다.

 

public class ImageUploadException extends RuntimeException {

    public ImageUploadException( String message) {
        super(message);
    }
}

 

참고로 관행상으론 커스텀한 예외 클래스를 만들기 보다는 가급적 기존 예외 클래스를 활용하는 것을 권장하는 편이다. 커스텀 예외는 단순 리터럴 형태의 정보("에러가 발생했습니다." 등) 보다 상세한 예외 정보(로직이 수행되는 등)를 제공할 필요가 있는 등의 용도로 사용되는 편이다.

 

 

참고자료

  • 도우출판 "자바의 정석"
  • https://docs.oracle.com/javase/tutorial/essential/exceptions/finally.html