Java

자바 예외처리란 무엇인가

sh1mj1 2022. 12. 31. 19:15

프로그래밍을 할 때 "예외가 많이 발생할 것 같은 case 니까 예외 처리 잘 해야 될 것 같은데?" 라는 말을 자주 들은 적이 있다. 그래서 예외가 일어날 것 같은 상황에서 try - catch 문(java에서)으로 예외를 처리하고는 했는데 정작 이 경우 if 조건문으로 처리하는 것과 무엇이 다른지 정확히 알지 못하는 것 같았다. 그래서 자바 예외 처리를 자세히 공부하고 정리하려고 한다.

기본적인 내용은 "Do it! 자바 프로그래밍 입문" 교재를 참고하였습니다.

예외 클래스

시스템에서 오류는 보통 프로그래머가 코드 작성 실수로 인해 발생하는 'Compile Error' 와 프로그램이 실행되는 도중 의도하지 않은 동작을 하여 발생하는 'Runtime Error' 로 나뉜다.

그리고 RuntimeError는 크게 두 가지로 나뉜다.

- 시스템 오류(Error) 

프로그램에서 제어할 수 없다.

Ex) 사용 가능한 동적 메모리가 더 이상 없을 때, 스택 메모리에서 overflow 가 발생할 때

- 예외(Exception)

프로그램에서 제어할 수 있다.

Ex) 프로그램에서 파일을 읽어서 사용하려는데 파일이 없을 때, 네트워크로 데이터를 전송하려는데 네트워크 연결이 되어있지 않을 때, 배열 값을 출력하려는데 배열 요소가 없는 경우 등.

 

오류에 대한 전체 클래스 계층도는 아래와 같다.

출처 : https://data-flair.training/blogs/java-exception/

위 계층에 있는 것들보다 당연히 훨씬 많은 클래스가 존재한다.

 

예외가 발생하면 IDE 에서 대부분 처리하라는 Compile Error Message를 띄운다. 그래서 try - catch 문을 사용하여 예외 처리를 해야 한다. 하지만 RuntimeException 을 try - catch 문으로 예외 처리를 하지 않아도 Compile Error 가 발생하지 않아서 프로그래머가 직접 찾아서 해주어야 하는 경우도 있다. 예를 들면 ArithmeticException 에서 0으로 숫자를 나누는 동작을 했을 시의 예외는 프로그래머가 알아서 처리해 주어야 한다. 

 

예외 처리하기

try - catch 문 사용

try {
	// 예외가 발생할 수 있는 코드 부분
} catch(처리할 예외 타입 e){
	// try 블록 안에서 예외 발생 시 예외를 처리하는 부분
}

예를 들기 위해 일부러 예외를 발생시켜봅시다.

int[] arr = new int[5];

for(int i = 0; i<=5; i++){
    arr[i] = 1;
    System.out.println(arr[i]);
}

요소가 5개인 정수형 배열인데 0 ~ 5까지 6개의 index 에 숫자를 넣었기 때문에 배열에 저장하려는 값의 개수가 배열 범위를 벗어나서 예외가 발생한다. 예외는 RuntimeException 의 subclass 인 ArrayIndexOutofBoundsException 로 예외 처리를 하지 않아도 컴파일 오류가 발생하지 않는 클래스이다.

이를 고쳐봅시다.

public class ArrayExceptionHandling{
    public static void main(String[] args){
        int[] arr = new int[5];

        try{
            for(int i = 0; i<=5; i++){
                arr[i] = i;
                System.out.println(arr[i]);
            }
        }catch(ArrayIndexOutOfBoundsException e){
            System.out.println(e);
            System.out.println("예외 처리 부분입니다.");
        }
        System.out.println("Program Ended");
    }
}

 

실행 결과

만약 예외가 발생하여 프로그램이 비정상적으로 종료되면 System.out.println("Program Ended") 문장을 수행할 수 없다. 위 코드처럼 예외 처리를 함으로써 프로그램이 비정상 종료되는 것을 방지할 수 있다.

 

컴파일러에 의해 예외가 체크되는 경우

위에서의 ArrayIndexOutofBoundsException 의 예시는 예외 처리를 하지 않아도 컴파일 오류가 나지 않지만 실제로는 많은 예외 클래스는 컴파일러가 알아서 처리한다. 

이 때 우리가 예외처리를 하지 않으면 컴파일 오류가 계속 남는다! 이번에는 예외 처리해야하는 예를 봅시다.

 

자바에서 파일 입출력에서 발생하는 예외입니다.

a.txt 파일을 읽기 위해 스트림 객체를 생성할 때 아래처럼 오류가 발생합니다. IntelliJ 에서 'option + Enter' 을 누르면 아래처럼 뜹니다.

try/ catch 로 감싼 후 실행을 해보면 아래처럼 결과가 나오는 것을 확인할 수 있다. 

그런데 이상합니다. try - catch 로 감싸서 프로그램이 비정상 종료되지 않아야 하는데 콘솔에서 빨간색으로 예외가 표시되어 비정상 종료된 것 같아 보인다. 하지만 사실은 이는 비정상 종료된 것이 아니다!! 

위에 Program Ended 가 수행되는 것을 보면 정상적으로 프로그램이 종료되는 것을 확인할 수 있다.

 

예외 처리를 한다고 해서 프로그램의 예외 상황 자체를 막을 수는 없지만 예외 상황을 알려주는 메시지를 볼 수 있고, 프로그램이 비정상 종료되지 않고 수행되도록 만들 수 있다.

 

try-catch-finally 문

예를 들어 프로그램에서 끝나지 않고 계속 수행되는 서비스 같은 경우에 리소스를 여러 번 반복해서 열기만 하고 닫지 않는다면 문제가 생긴다. 시스템의 리소스는 한계가 있기 때문이다. 그러므로 반드시 close() 메서드로 닫아주어야 한다.

 

이전 코드에서 생성한 스트림 객체를 close 로 해제해주어야 하는데 이 때 프로그램이 정상적으로 종료된 경우, 비정상 종료된 경우, 둘 모두에서 리소스를 close 해야 한다. 따라서 try 블록과 catch 블록 모두에서 close() 메서드를 사용해야 한다. 이는 쓸데없는 코드 반복이다... 다행히 자바에서는 try-catch- finally 형식을 제공하여 아래와 같이 사용할 수 있다.

try {
            // 예외가 발생할 수 있는 부분
        } catch (Exception e) {
            // 예외를 처리하는 부분
        }finally {
            // 항상 수행되는 부분
        }

try 블록이 수행되면 finally 블록은 반드시 수행된다. 심지어 try나 catch 문에 return 문이 있어도 수행된다!

 

public class ExceptionHandleing{
    public static void main(String[] args) {
        FileInputStream fis = null;
        try {
            fis = new FileInputStream("a.txt");
        }catch (FileNotFoundException e){
            System.out.println(e);
            return;
        }finally {
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("FINALLY 부분은 항상 수행됨");
        }
        System.out.println("항상 수행됨");
    }
}

 

try-with-resources 문

자바 7부터는 try-with-resources 문을 제공하여 close() 메서드를 명시적으로 호출하지 않아도 try 블록 내에서 열린 리소스를 자동으로 해제할 수 있다. 이를 위해 AutoCloseable 인터페이스를 구현해야 한다. FileInputStream 은 Closeable 과 AutoCloseable 을 구현하고 있기 때문에 명시적으로 close() 을 호출하지 않아도 항상 close 메서드가 호출된다. 이외에 socket 과 DB connection 관련 클래스도 AutoClosable 인터페이스를 구현하고 있다.

 

그렇다면 직접 AutoCloseable 인터페이스를 구현한 클래스를 만들어서 테스트해봅시다.

public class AutoCloseObj implements AutoCloseable {

    @Override
    public void close() throws Exception {
        System.out.println("리소스가 close() 되었습니다.");
    }
}
public class AutoCloseText{
    public static void main(String[] args) {
        try (AutoCloseObj obj = new AutoCloseObj())/* 사용할 리소스 선언 */{ 
        } catch (Exception e) {
            System.out.println("예외 처리 부분.");
        }
    }
}

 

try-with-resources 문을 사용할 때 try 문의 괄호 안에 리소스를 선언한다. close() 메서드가 자동으로 호출되어 리소스가 cose() 되었습니다. 문장이 출력된다.

 

위 예시는 정상적으로 종료되는 경우이므로 예외가 발생하여 종료되는 경우에도 close() 가 호출되는지 확인해 봅시다.

throw new Exception() 문장을 사용하여 프로그램에서 강제로 예외를 발생시켜서 catch 블록이 수행되도록 구현합니다.

public class AutoCloseText{
    public static void main(String[] args) {
        try (AutoCloseObj obj = new AutoCloseObj()){
            throw new Exception();
        } catch (Exception e) {
            System.out.println("예외 처리 부분.");
        }
    }
}

강제로 예외를 발생시켜서 catch 블록이 수행된다. 콘솔의 결과를 보면 리소스가 먼저 close 되고 난 후 catch 블록이 수행되는 것을 알 수 있다. 즉, 정상 종료, 비정상 종료 두 경우 모두 AutoClosable 인터페이스를 구현하였기 때문에 프로그램 종료 시 자동으로 close() 메서드가 실행되는 것을 확인할 수 있다.

 

자바 9 에서는 향상된 try-with-resources 문을 지원한다. 자바 7 에서와 달리 자바 9에서는 try 문의 괄호 안에서 외부에서 선언한 변수를 쓸 수 있게 되었다. 그래서 가독성도 좋고 반복하여 선언하는 일도 줄어들었다.

public class AutoCloseText{
    public static void main(String[] args) {
        AutoCloseObj obj = new AutoCloseObj();
        try (obj)/* 외부에서 선언한 obj 을 try 안에서도 사용할 수 있음.(자바 9 부터) */{ 
            throw new Exception();
        } catch (Exception e) {
            System.out.println("예외 처리 부분.");
        }
    }
}

 

try-cath, if-else 의 차이

여기까지 실습을 해보니 확실히 if-else , try-catch 문의 차이를 제대로 설명할 수 있을 것 같다.

try-catch 문은 정상적인 프로그램 흐름이 아닌 제어할 수 없는 문제에 대한 것이다. try-catch 에서 예외가 발생했을 경우 try-catch 안의 모든 객체는 스코프를 벗어나서 참조할 수 없게 되어 안전하지만 if-else 문은 스코프가 벗어나지 않아서 참조할 수 있게 되어 더 위험하다. 또 스택에서 생성되는 지역 변수/객체 등의 자동 소멸이 가능하다. 즉, if-else 문의 예외 처리와는 달리 지역 객체들의 소멸이 자동으로 호출되어 메모리 누수 문제에서도 더 유리하다.

if-else 문은 정상적인 흐름과 일반적인 오류 검사를 한다. 만약 if-else 문으로 에러를 처리한다면 에러 발생 객체가 수명이 유지되어 에러 처리 중에서도 에러 발생한 객체를 참조하는 코드가 정상적으로 컴파일된다.

물론 try-catch 블록은 유지해야 할 정보가 많고 예외 발생 시 해야하는 일이 많아서 코드 크기가 크며, 처리 속도도 if-else 보다 느리다는 단점을 가지고 있다. 그럼에도 불구하고 try-catch 을 사용하여 예외 형태로 처리 하게 되면 해당 부분에서 문제가 생겼을 때, 상위 컨텍스트에서 그 예외를 알 수 있다는게 장점과, 문제 생긴 부분의 정보를 trace 형태로 어디서 발생했는지 다 알려주기 때문에 try-catch 문을 잘 사용해야 한다.

 

예외 처리 미루기

맨 처음에 FileInputStream 을 생성하고 'option + enter' 을 입력했을 때 'Add exceptions to method signature'  을 통해 클래스에 throws FileNotFoundException 을 추가할 수 있었다. 이는 예외를 해당 메서드에서 처리하지 않고 나중으로 미루고 메서드를 호출하여 사용하는 부분에서 예외를 처리하는 방법을 사용하곘다는 의미이다.

 

위와 같이 코드를 작성하여 loadClass 에서 FileNotFoundException, ClassNotFoundException 예외 처리를 미룬다는 의미로 throws 을 사용하였다. 

그리고 main 메서드에서 loadClass 을 사용하려고 한다. 이 때 또 똑같은 'Add exceptions to method signature' 라는 안내를 볼 수 있다. main 함수에서 예외처리를 미루게 되면 이 예외처리는 main() 함수를 호출하는 JVM(자바 가상 머신)으로 보내지기 때문에 예외 처리가 되지 않고 대부분의 프로그램이 비정상 종료된다. 그러므로 main() 함수에서는 try-catch 을 이용하여 예외를 처리해 주어야 한다.

 

이 때 두가지 방법으로 사용될 수 있다.

첫번째 방법은 하나의 catch 블록에서 여러 예외를 한번에 처리하는 것이다.

try {
    test.loadClass("a.txt", "java.lang.String"); // 메서드를 호출할 때 예외를 처리해야 한다.
} catch (FileNotFoundException | ClassNotFoundException e) {
    e.printStackTrace();
}

혹은 두 문장에서 처리할 수도 있겠다.

try {
    test.loadClass("a.txt", "java.lang.String"); // 메서드를 호출할 때 예외를 처리해야 한다.
} catch (FileNotFoundException e) {
    e.printStackTrace();
} catch (ClassNotFoundException e) {
    e.printStackTrace();
}

당연히 필요에 따라 사용하면 된다.

 

다중 예외 처리

앞에서 계속 설명했듯이 문법적으로 예외 처리를 반드시 해야하는 경우 외에도 컴파일러에 의해 처리되지 않는 예외 처리를 해야 할 때가 있다. 이렇게 어떤 예외가 발생할지 몰라서 특정 예외 타입을 지정할 수 없지만 모든 예외 상황을 처리하고자 하면 맨 마지막에 Exception 클래스로 catch 블록을 추가하면 된다.

import java.io.FileInputStream;
import java.io.FileNotFoundException;

public class ThrowsException{
    public Class loadClass(String fileName, String className)
            throws FileNotFoundException, ClassNotFoundException{ // 두 예외를 나중에 메서드 호출 시 처리하도록 미뤘다.
        FileInputStream fis = new FileInputStream(fileName); // FileNotFoundException 발생 가능.
        Class c = Class.forName(className); // ClassNotFoundException 발생 가능.
        return c;
    }

    public static void main(String[] args) {
        ThrowsException test = new ThrowsException();
        try {
            test.loadClass("a.txt", "java.lang.String"); // 메서드를 호출할 때 예외를 처리해야 한다.
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }catch (Exception e){ // Exception 클래스로 그 외에 여러 예외 상황을 처리한다.
            e.printStackTrace(); 
        }
    }
}

Exception 클래스는 모든 예외 클래스의 최상위 클래스이므로 다른 catch 블록에 선언한 FileNotFoundException, ClassNotFoundException 이외의 예외가 발생하더라도 Exception 클래스로 자동 형 변환된다. 이 Exception 클래스를 기본(default) 예외 처리라고도 한다.

예외는 catch 문을 선언한 순서대로 검사하기 때문에 상위 클래스는 Exception 클래스는 마지막에 위치해야 한다!! 그렇지 않으면 컴파일 오류가 발생한다.

 

직접 예외를 정의해서 사용하기

개발하는 프로그램에 따라 다양한 예외 상황이 발생할 수 있기 때문에 직접 예의 클래스를 만들어 사용하는 경우가 종종 있다.

 

public class IDFormatException extends Exception {
    public IDFormatException(String message){ // 생성자의 매개변수로 예외 상황 메시지 받기
        super(message);
    }
}
public class IDFormatTest {
    private String userID;

    public String getUserID() {
        return userID;
    }

    public void setUserID(String userID) throws IDFormatException {
        if (userID == null) {
            throw new IDFormatException("ID CANNOT BE NULL");
        } else if (userID.length() < 8 || userID.length() > 20) {
            throw new IDFormatException("THE LENGTH OF ID MUST BE 8 ~ 20");
        }
        this.userID = userID;
    }

    public static void main(String[] args) {
        IDFormatTest test = new IDFormatTest();

        // ID 가 null 인 경우
        String userID = null;
        try {
            test.setUserID(userID);
        } catch (IDFormatException e) {
            System.out.println(e.getMessage());
        }

        // ID 가 8자 이하인 경우
        userID = "123456";
        try {
            test.setUserID(userID);
        } catch (IDFormatException e) {
            System.out.println(e.getMessage());
        }

    }
}

위처럼 프로그램 개발 상황에서 필요에 따라 사용자 정의 예외 클래스를 직접 만들고 이를 발생시켜서 예외 처리를 할 수 있다.

 

 

개발할 때는 로그를 남기는 것이 매우 중요합니다. 좋은 코드에서는 오류 발생시 로그를 보고 오류가 발생하는 코드를 순서대로 따라가며 원인을 찾을 수 있다. 또 로그는 정보에 따라 레벨을 나누어 관리할 수 있다. 로그를 잘 찍는 습관을 들입시다.

 

참고 자료:

https://dejavuhyo.github.io/posts/java-try-catch-vs-if-else/

https://okky.kr/articles/245058