본문 바로가기
Java/Java-basic

[Java Study 9주차] 예외 처리

by 어썸오184 2021. 1. 10.
728x90
반응형

본 포스팅은 백기선님이 진행하시는 자바 스터디 를 진행하며 혼자 공부하고 이해한 내용을 바탕으로 정리한 글입니다. 오류나 지적 사항이 있다면 댓글로 알려주시면 감사하겠습니다.

예외(Exception)와 에러(Error)

다들 프로그램을 사용하다가 프로그램이 비정상적으로 종료되는 경험을 해본 적이 있을 것이다. 이러한 결과를 초래하는 원인을 프로그램 에러(Error) 또는 오류라고 한다.

에러는 크게 컴파일 에러런타임 에러로 구분할 수 있다. 컴파일 에러는 말 그대로 컴파일 과정에서 일어나는 에러이고, 런타임 에러는 실행 과정에서 일어나는 에러이다.

컴파일 에러는 기본적으로 자바 컴파일러가 문법 검사를 통해서 오류를 잡아내 준다. 우리는 컴파일러가 알려주는 오류를 수정하면 성공적으로 컴파일을 해서 프로그램을 실행할 수 있다.

그러나 컴파일이 문제없이 되더라도 실행 과정(runtime)에서 오류가 발생할 수 있는데, 이를 런타임 에러라고 하며 런타임 에러를 방지하기 위해서는 프로그램 실행 도중 일어날 수 있는 모든 경우의 수를 고려하여 대비할 필요가 있다. 자바에서는 런타임 에러를 예외(Exception)와 에러(Error) 두 가지로 구분하여 대응하고 있다.

에러는 메모리 부족(OutOfMemoryError), 스택오버플로우(StackOverFlowError)처럼 JVM이나 하드웨어 등의 기반 시스템의 문제로 발생하는 것이다. 발생했을 때를 대비해서 프로그래머가 뭔가 할 수 있는게 없다. 발생하는 순간 무조건 프로그램은 비정상 종료되기 때문에 애초에 발생하지 않도록 해야 한다.

예외는 발생하더라도 프로그래머가 미리 적절한 코드를 작성해서 프로그램이 비정상적으로 종료되지 않도록 핸들링 해줄 수 있다.

예외 처리 방법

try-catch

예외 처리를 위해서는 try-catch 구문을 이용하며 그 구조는 다음과 같다.

try {
    /// 예외가 발생할 가능성이 있는 코드/
} catch (Exception1 e1) {
    /// Exception1이 발생했을 때, 이를 처리하기 위한 코드/
} catch (Exception2 e2) {
    /// Exception2가 발생했을 때, 이를 처리하기 위한 코드/
} catch (ExceptionN eN) {
    /// ExceptionN이 발생했을 때, 이를 처리하기 위한 코드/
}

try 블럭에는 여러 개의 catch 블록이 올 수 있으며, 이 중 발생한 예외의 종류와 일치하는 단 한 개의 catch 블록만 수행된다. catch 블럭 안의 ExceptionN은 예외 클래스이며 eN은 해당 클래스의 인스턴스를 가리키는 참조 변수이다.

public class ExceptionDemo {

    public static void main(String[] args) {
        try {
            /// 1을 0으로 나눴으므로 예외가 발생한다./
            System.out.println(1 / 0);

        } catch (IllegalArgumentException e) {
            System.out.println(e.getClass().getName());
            System.out.println(e.getMessage());
        } catch (ArithmeticException e) {
            System.out.println(e.getClass().getName());
            System.out.println(e.getMessage());
        } catch (NullPointerException e) {
            System.out.println(e.getClass().getName());
            System.out.println(e.getMessage());
        }
    }
}

Output

Task :ExceptionDemo.main()
java.lang.ArithmeticException
/ by zero

BUILD SUCCESSFUL in 243ms
2 actionable tasks: 2 executed

위처럼 참조 변수를 통해서 발생한 예외 클래스의 인스턴스를 참조할 수 있다. 해당 인스턴스에는 발생한 예외에 대한 정보가 담겨 있어, 이를 통해 Message, StackTrace 등 여러 정보를 얻어올 수 있다. 0으로 숫자를 나눌 경우 ArithmeticException이 발생하는 것을 확인할 수 있다. 이때 앞에 IllegalArgumentException을 잡는 catch 블록은 try 블록에서 발생한 예외가 속한 클래스가 아니므로 실행되지 않은 것을 확인할 수 있다. 또 NullPointerException을 잡는 catch 블록은 실행되지 않는다.

  • printStackTrace() : 예외 발생 당시의 호출스택에 있었던 메서드 정보와 예외 메시지를 화면에 출력한다. 많은 시스템 자원을 소모하므로 정말 필요한 경우가 아니면 사용하지 않는 것이 좋다.
  • getMessage() : 발생한 예외클래스의 인스턴스에 저장된 메시지를 얻을 수 있다.

참조 변수 중복

catch 블럭 안에 다시 try-catch 구문을 사용할 수 있는데, 이때 상위 catch 블록 안에 참조 변수의 이름이 중복되어서는 안 된다. (왜 안되는지 이해가 안 가면 변수의 스코프에 대한 내용을 참조)

public class ExceptionDemo {
    public static void main(String[] args) {
        try {
            methodA();
        } catch (RuntimeException e) {
            try {
                methodB();
            } catch (IllegalArgumentException e) {   /// 에러 발생: 해당 변수의 이름을 e로 할 수 없음/
                …
            }
        }
    }
}

try-catch문의 흐름

try-catch문은 예외가 발생하는 경우와 아닌 경우로 나눠서 실행 흐름을 살펴볼 수 있다.

예외가 발생하지 않는 경우

image

Output

1
2
3
4
6

try 블록 내 코드를 실행 중에 예외가 발생하지 않으면 catch 블록을 스킵한 후 try-catch문 뒤에 있는 문장을 실행한다.

예외가 발생하는 경우

image

Output

1
2
3
6
8

try 블록 내의 문장을 실행하다가 예외가 발생하는 경우 해당 예외의 인스턴스를 생성하고 첫 번째 catch 블록으로 이동한다. 따라서 try블록 안에 예외가 발생한 문장 다음의 코드는 실행되지 않는다.

첫 번째 catch 블록부터 차례대로 살펴보면서 괄호() 내에 선언된 참조 변수의 종류와 생성된 예외 클래스의 인스턴스에 instancof 연산자를 이용해서 검사를 한다. 검사 결과가 false이면 다음 블록으로 이동하고, true이면 해당 catch 블록의 코드를 실행한 후 try-catch문을 탈출한다. 만약 catch문 안에서 해당 예외가 처리되지 않으면 프로그램은 종료된다.

Multicatch block

JDK 1.7부터 여러 catch block을 하나로 합칠 수 있게 되었다.

public class ExceptionDemo {
    public static void main(String[] args) {
        try {
            System.out.println(1 / 0);
        } catch (IllegalArgumentException | ArithmeticException e) {
            System.out.println(e.getMessage());
        }
    }
}

단 이때, 나열된 예외 클래스들이 부모-자식 관계에 있다면 오류가 발생한다.

public class ExceptionDemo {

    public static void main(String[] args) {
        try {
            System.out.println(1 / 0);
        } catch (RuntimeException | ArithmeticException e) {
                 /// 에러 발생: ArithmeticException은 RuntimeException을 상속받는 클래스이다./
            System.out.println(e.getMessage());
        }
    }
}

왜냐하면, 자식 클래스로 잡아낼 수 있는 예외는 부모 클래스로도 잡아낼 수 있기 때문에 사실상 코드가 중복된 것이나 마찬가지이기 때문이다. 이때 컴파일러는 중복된 코드를 제거하라는 의미에서 에러를 발생시킨다.

또한 멀티캐치는 하나의 블록으로 여러 예외를 처리하는 것이기 때문에 멀티 캐치 블록 내에서는 발생한 예외가 정확이 어디에 속한 것인지 알 수 없다. 그래서 참조 변수 e에는 ‘|’로 연결된 예외들의 공통 조상 클래스에 대한 정보가 담긴다.

throw

throw 키워드를 이용해서 고의로 예외를 발생시킬 수도 있다. 예를 들어 사용자가 “바보”라는 닉네임을 사용하지 못하게 하고 싶다면 다음과 같이 예외를 발생시켜 프로그램을 중단시킬 수 있다.

public class ExceptionDemo {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        System.out.println(“아이디를 입력하세요”);
        String userName = scanner.nextLine();

        try {
            if (userName.equals(“바보”)) {
                throw new IllegalArgumentException(“부적절한 이름입니다.”);
            }
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}

Output

Task :ExceptionDemo.main()
아이디를 입력하세요
바보
부적절한 이름입니다.

BUILD SUCCESSFUL in 6s
2 actionable tasks: 2 executed

new 키워드로 예외 인스턴스를 생성하고, throw 키워드로 해당 예외를 발생시킨 것이다. throw new … 부분은 다음 문장을 축약한 것이다.

IllegalArgumentException e = new IllegalArgumentException(“부적적한 이름입니다.”);
throw e;

throws

throws 키워드를 통해 메서드에 예외를 선언할 수 있다. 여러 개의 메서드를 쉼표로 구분해서 선언할 수 있다. 형태는 다음과 같다.

void method() throws Exception1, Exception2, … ExceptionN {
    // 메서드 내용/
}

thorws는 메서드 선언부에 예외를 선언해둠으로써 해당 메서드를 사용하는 사람들이 어떤 예외를 처리해야 하는 지를 알려주는 역할을 한다.

throws 자체는 예외의 처리와는 관계가 없다. throws로 예외가 선언된 메서드를 사용할 때, 사용자가 각자 알아서 예외를 처리해줘야 한다. 즉 throws는 해당 메서드에서 예외를 처리하지 않고, 해당 메서드를 사용하는 쪽이 예외를 처리하도록 책임을 전가하는 역할을 한다.

public class ExceptionDemo {

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);

        try {
            System.out.println(“파일 이름을 입력하세요”);
            String fileName = scanner.nextLine();

            File f = createFile(fileName);

        } catch (Exception e) {
            System.out.println(e.getMessage());
        }

    }

    static File createFile(String fileName) throws Exception {
        if (fileName == null || fileName.equals(“”)) {
            throw new Exception(“파일이름이 유효하지 않습니다.”);
        }

        File f = new File(fileName);
        f.createNewFile();
        return f;
    }
}

createFile이라는 메서드는 파일 이름을 입력받아서 파일을 생성하는 역할을 한다. 만약 파일 이름이 null이거나 빈 문자열이라면 예외를 던진다. createFlie 메서드 내에서는 해당 예외에 대한 처리를 하지 않고, 단지 thorws로 예외를 선언하기만 했다. 그래서 createFile을 사용하는 사람들이 각자 상황에 맞게 예외를 어떻게 처리할지 선택해야 한다.

finally

finally는 try-catch와 함께 예외의 발생 여부와 상관없이 항상 실행되어야 할 코드를 포함시킬 목적으로 사용된다. try-catch문의 끝에 선택적으로 덧붙여 사용할 수 있으며, try-catch-finally의 순서로 구성된다.

try {
    // 예외가 발생할 가능성이 있는 문장을 넣는다.
} catch {
    // 예외 처리를 위한 문장을 넣는다.
} finally {
    // 예외 발생 여부와 상관없이 항상 실행되어야 할 문장을 넣는다.
}

예외가 발생한 경우에는 try -> catch -> finally 순으로 실행되고, 예외가 발생하지 않은 경우에는 try - finally 순으로 실행된다.

한 가지 짚고 넘어갈 점은 finally 블록 내의 문장은 try, catch 블록에 return문이 있더라도 실행된다는 것이다.

public class ExceptionDemo {

    public static void main(String[] args) throws Exception {
        methodA();
        System.out.println(“methodA가 복귀한 후 실행될 문장”);
    }

    static void methodA() {
        try {
            System.out.println(“트라이 블록 수행”);
            return;
        } catch (Exception e) {
            System.out.println(“캐치 블록 수행”);
        } finally {
            System.out.println(“파이널리 블록 수행”);
        }
    }
}

Output

Task :ExceptionDemo.main()
트라이 블록 수행
파이널리 블록 수행
methodA가 복귀한 후 실행될 문장

BUILD SUCCESSFUL in 257ms
2 actionable tasks: 2 executed

methodA를 보면 try문에서 “트라이 블록 수행”을 출력하고 분명 return을 하는데도, 그전에 finally 블록이 수행되는 것을 확인할 수 있다. catch 블록을 수행하다가 return문을 만날 때에도 finally 블록이 수행되고 리턴한다.

try-with-resource

java7부터 자원 해제를 자동으로 해주는 try-with-resources 구문이 추가되었다. 원래 File input이나 DB 커넥션 생성 같이 시스템 자원을 사용하는 코드의 경우 finally 구문을 통해서 예외가 발생하더라도 반드시 자원을 닫아주는 형태로 코드를 작성했다.

public static void main(String args[]) throws IOException {
    FileInputStream is = null;
    BufferedInputStream bis = null;
    try {
        is = new FileInputStream("file.txt");
        bis = new BufferedInputStream(is);
        //... do something

    } catch (IOException e) {
        // 에러처리
    } finally {
        // 어떤 경우에도 반드시 자원을 닫아야함
        if (is != null) is.close();
        if (bis != null) bis.close();
    }
}

하지만 try-with-resources 구문을 사용하면 같은 기능을 좀 더 깔끔한 코드로 작성할 수 있다.

public static void main(String args[]) {
    try (
        FileInputStream is = new FileInputStream("file.txt");
        BufferedInputStream bis = new BufferedInputStream(is)
    ) {
        //... do something
    } catch (IOException e) {
        // 에러처리
    }
}

try 옆에 괄호를 열고 자원을 할당 받으면 해당 자원은 블록 수행 이후 자동으로 반환이 된다. 모든 객체가 자동으로 반환되는 것은 아니고 AutoCloseable 인터페이스를 구현한 클래스만 자동으로 반환이 된다.

예외 계층 구조

자바에서는 실행 시 발생할 수 있는 오류(Exception & Error)를 클래스로 정의하고 있다. 예외와 에러의 상속계층도는 다음과 같다.

image

출처: https://madplay.github.io/post/java-checked-unchecked-exceptions

Exception과 Error는 Throwable이라는 클래스를 상속받고 있으며 Throwable은 Object를 직접 상속받고 있다.

Checked Exceptions VS Unchecked Exceptions

위 그림을 살펴보면 Exception을 상속받는 클래스들 중에서 RuntimeException을 제외하고는 모두 Checked Exception이라고 표시되어 있다.

Checked Exception은 컴파일 시점에서 확인될 수 있는 예외이다. 만약 코드 내에서 Checked Exception을 발생시킨다면, 해당 예외는 반드시 처리되거나, 해당 코드가 속한 메서드의 선언부에 예외를 선언해줘야 한다.
예를 들어 Checked Exception 중에 하나인 IOException을 발생시키는 메서드를 선언했다고 치자.

image

이 코드는 애초에 컴파일 자체가 안된다. IOException은 Checked Exception이기 때문에 컴파일 단계에서 예외가 확인이 된다. 따라서 위 코드를 컴파일하려면 try-catch로 예외를 처리하거나 thorws로 예외를 던져줘야 한다.

image

이렇게 예외를 던져주면 컴파일은 가능하다.

Unchecked Exception은 컴파일 단계에서 확인되지 않는 예외이다. Java에서는 RuntimeException과 그 하위 클래스, 그리고 Error와 그 하위 클래스가 이에 속한다. 이 예외들은 컴파일러가 예외를 처리하거나 선언하도록 강제하지 않는다. 프로그래머가 알아서 처리를 해야 한다. 예를 들어 위의 예시를 RuntimeException으로 바꾸면 컴파일 에러가 발생하지 않는다.

image

예외를 처리하거나 던지지 않아도 컴파일이 잘 된다. 그렇다면 왜 이렇게 예외를 두 타입으로 나눠놨을까? 오라클 공식 문서 에서 이에 대해 설명하고 있다.

Because the Java programming language does not require methods to catch or to specify unchecked exceptions (RuntimeException, Error, and their subclasses), programmers may be tempted to write code that throws only unchecked exceptions or to make all their exception subclasses inherit from RuntimeException. Both of these shortcuts allow programmers to write code without bothering with compiler errors and without bothering to specify or to catch any exceptions. Although this may seem convenient to the programmer, it sidesteps the intent of the catch or specify requirement and can cause problems for others using your classes.

Why did the designers decide to force a method to specify all uncaught checked exceptions that can be thrown within its scope? Any Exception that can be thrown by a method is part of the method’s public programming interface. Those who call a method must know about the exceptions that a method can throw so that they can decide what to do about them. These exceptions are as much a part of that method’s programming interface as its parameters and return value.

The next question might be: “If it’s so good to document a method’s API, including the exceptions it can throw, why not specify runtime exceptions too?” Runtime exceptions represent problems that are the result of a programming problem, and as such, the API client code cannot reasonably be expected to recover from them or to handle them in any way. Such problems include arithmetic exceptions, such as dividing by zero; pointer exceptions, such as trying to access an object through a null reference; and indexing exceptions, such as attempting to access an array element through an index that is too large or too small.

Runtime exceptions can occur anywhere in a program, and in a typical one they can be very numerous. Having to add runtime exceptions in every method declaration would reduce a program’s clarity. Thus, the compiler does not require that you catch or specify runtime exceptions (although you can).

One case where it is common practice to throw a RuntimeException is when the user calls a method incorrectly. For example, a method can check if one of its arguments is incorrectly null. If an argument is null, the method might throw a NullPointerException, which is an unchecked exception.

Generally speaking, do not throw a RuntimeException or create a subclass of RuntimeException simply because you don’t want to be bothered with specifying the exceptions your methods can throw.

Here’s the bottom line guideline: If a client can reasonably be expected to recover from an exception, make it a checked exception. If a client cannot do anything to recover from the exception, make it an unchecked exception.

 

요약하자면, 예외는 메서드의 파라미터나 반환 값만큼이나 중요한 공용 인터페이스 중 하나이다. 메서드를 호출하는 쪽은 그 메서드가 어떤 예외를 발생시킬 수 있는가에 대해 반드시 알아야 한다. 따라서 Java는 checked exception을 통해 해당 메서드가 발생시킬 수 있는 예외를 명세하도록 강제하고 있다.

그럼 Runtime Exception은 왜 예외를 명세하지 않아도 되도록 했을까? Runtime Exception은 프로그램 코드의 문제로 발생하는 예외이다(즉 코드를 짠 개발자의 잘못). 따라서 클라이언트 쪽(메서드를 호출하는 쪽)에서 이를 복구(or 회복)하거나 대처할 수 있을 거라고 예상하긴 어렵다. 또 Runtime Exception은 프로그램 어디서나 매우 빈번하게 발생할 수 있기 때문에 모든 Runtime Exception을 메서드에 명시하도록 강제하는 것은 프로그램의 명확성을 떨어뜨릴 수 있다.

따라서 클라이언트가 exception을 적절히 회복할 수 있을 것이라고 예상되는 경우 checked exception으로 만들고, 그렇지 않은 경우 unchecked exception으로 만드는 것이 좋다.

사용자 정의 예외 만들기

기존에 정의된 예외 클래스 외에 필요에 따라 새로운 예외를 정의할 수 있다. Exception 클래스를 상속받거나, 필요에 따라 알맞은 예외 클래스를 상속받아 만든다.

public class ExceptionDemo {

    public static void main(String[] args) throws SpaceException {
        methodA(5);
    }

    static void methodA(int space) throws SpaceException {
        if (space < 1) {
            throw new SpaceException(“공간 부족”);
        }
    }
}

class SpaceException extends Exception {
    public SpaceException(String message) {
        super(message);    /// 조상 클래스인 Exception의 생성자 호출/
    }
}

예외 되던지기(exception re-throwing)

한 메서드에서 발생할 수 있는 예외가 여러 개인 경우, 일부는 메서드 내부에서 처리하고 일부는 선언부에 지정해서 메서드를 호출한 쪽에서 처리하도록 할 수 있다. 또, 하나의 예외에 대해서도 양쪽에서 처리하도록 할 수 있는데 이를 ‘예외 되던지기’라고 한다. catch문에서 throw를 사용해 예외를 다시 던지는 방식으로 구현 가능하다.

public class ExceptionDemo {

    public static void main(String[] args) {
        try {
            methodA();
        } catch (Exception e) {
            System.out.println(“main에서 예외 처리”);
        }
    }

    static void methodA() throws Exception {
        try {
            throw new Exception();
        } catch (Exception e) {
            System.out.println(“methodA에서 예외 처리”);
            throw e;
        } 
    }
}

Chained Exception

한 예외가 다른 예외를 발생시킬 수도 있다. 예를 들어 예외 A가 예외 B를 발생시켰다면, A를 B의 ‘원인 예외(cause exception)’라고 한다. 원인 예외는 initCause()로 지정할 수 있다. initCause()는 Throwable 클래스에 정의되어 있기 때문에 모든 예외 클래스에서 사용할 수 있다.

public class ExceptionDemo {

    public static void main(String[] args) {
        try {
            methodA(0);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    static void methodA(int num) throws IOException{
        try {
            if (num == 0) {
                throw new IllegalArgumentException();
            }
        } catch (IllegalArgumentException e) {
            IOException ioException = new IOException();
            ioException.initCause(e);  /// IOException의 예외를 IllegalArgumentException으로 지정/
            throw ioException;
        }
    }
}

Output

Task :ExceptionDemo.main()
java.io.IOException
    at exceptiondemo.ExceptionDemo.methodA(ExceptionDemo.java:21)
    at exceptiondemo.ExceptionDemo.main(ExceptionDemo.java:9)
Caused by: java.lang.IllegalArgumentException
Caused by: java.lang.IllegalArgumentException

    at exceptiondemo.ExceptionDemo.methodA(ExceptionDemo.java:18)
    … 1 more

이런 식으로 원인 예외를 등록해서 예외를 발생시키면 여러 가지 예외를 하나의 큰 분류의 예외로 묶어서 다루는 것이 가능하다. 서로 연결되는 예외는 상속 관계가 아니어도 상관없다.

또 이 방식을 이용해서 checked exception을 unchecked exception으로 바꾸는 것도 가능하다.

public class ExceptionDemo {

    public static void main(String[] args) {
        try {
            methodA(0);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    static void methodA(int num){
        try {
            if (num == 0) {
                throw new IllegalArgumentException();
            }
        } catch (IllegalArgumentException e) {
            throw new RuntimeException(e);
        }
    }
}

Output

java.lang.RuntimeException: java.lang.IllegalArgumentException
    at exceptiondemo.ExceptionDemo.methodA(ExceptionDemo.java:21)
    at exceptiondemo.ExceptionDemo.main(ExceptionDemo.java:9)
Caused by: java.lang.IllegalArgumentException
    at exceptiondemo.ExceptionDemo.methodA(ExceptionDemo.java:18)
    … 1 more
Caused by: java.lang.IllegalArgumentException

image

RuntimeException(Throwable cause) / 원인 예외를 등록하는 생성자/

참고자료

자바의 정석(남궁성 저)

Geeksforgeeks - https://www.geeksforgeeks.org/exceptions-in-java/?ref=lbp

728x90
반응형

댓글