학습 자료

예외 — 컴파일러가 실패를 묻는다


Article

사용자가 입력한 글자를 숫자로 바꾼다.

public class Main {
    static int parse(String s) {
        return Integer.parseInt(s);
    }

    static void middle(String s) {
        System.out.println(parse(s));
    }

    public static void main(String[] args) {
        middle("abc");
        System.out.println("여기까지 왔다");
    }
}
터미널
  • Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
  • at java.base/java.lang.Integer.parseInt(Integer.java:668)
  • at Main.parse(Main.java:3)
  • at Main.middle(Main.java:7)
  • at Main.main(Main.java:11)

여기까지 왔다 는 찍히지 않았다. 프로그램이 그 자리에서 죽었다.

이번 강의에서 두 가지를 한다. 저 글자 뭉치를 읽는 법과, 죽지 않고 붙잡는 법이다.

예외도 객체다

NumberFormatException 은 클래스 이름이다. 문제가 생기면 자바는 그 정보를 담은 객체를 하나 만들어서 던진다.

던진다는 말이 비유가 아니다. throw 라는 키워드가 실제로 있다.

모든 예외 클래스는 하나의 뿌리에서 나온다. 14강의 Object 처럼.

예외의 계보
  • Throwable던질 수 있는 것
    • ErrorJVM 이 낸다. 붙잡지 않는다
      • StackOverflowError10강에서 만났다
      • OutOfMemoryError메모리가 바닥났다
    • Exception우리가 다루는 것
      • RuntimeExceptionunchecked — 컴파일러가 안 묻는다
        • NullPointerException4강
        • ArrayIndexOutOfBoundsException9강
        • ArithmeticException5강
        • ClassCastException17강
        • IllegalArgumentException12강에서 직접 던졌다
      • IOExceptionchecked — 컴파일러가 묻는다

지금까지 만난 예외들이 전부 한 자리에 모였다. 그리고 대부분 RuntimeException 아래에 있다.

Error 는 다르다. JVM 자체에 문제가 생겼다는 뜻이라, 우리가 붙잡아도 할 수 있는 게 없다. 10강의 StackOverflowErrorException 이 아니라 Error 인 이유다.

스택 트레이스를 읽는다

에러 메시지의 at ... 줄들을 스택 트레이스라고 부른다. 10강의 콜 스택을 그대로 찍어낸 것이다.

예외가 터진 순간의 콜 스택
  • Integer.parseInt()
  • parse()
  • middle()
  • main()

Call Stack

출력은 위가 터진 곳, 아래가 시작한 곳이다. 스택을 위에서부터 훑어 내려온 것이다.

읽는 순서
  1. 01

    첫 줄에서 무엇이 · 왜

    NumberFormatException. For input string: "abc" — "abc" 를 숫자로 못 바꾸겠다.

  2. 02

    내 코드가 처음 나오는 줄을 찾는다

    java.base 로 시작하는 줄은 자바 내부다. at Main.parse(Main.java:3) 이 첫 단서다.

  3. 03

    아래로 내려가며 경로를 확인한다

    main 이 middle 을 부르고, middle 이 parse 를 불렀다. 어디서 온 값인지 거슬러 올라간다.

가장 위가 아니라 내 코드가 처음 나오는 줄을 본다. 자바 내부 코드는 대개 잘못이 없다.

붙잡는다

try 안에서 터진 예외를 catch 가 받는다.

try {
    middle("abc");
} catch (NumberFormatException e) {
    System.out.println("잡았다: " + e.getMessage());
}
System.out.println("여기까지 왔다");
콘솔
  • 잡았다: For input string: "abc"
  • 여기까지 왔다

프로그램이 살아남았다.

e 는 던져진 그 객체다. e.getMessage() 로 메시지를, e.printStackTrace() 로 스택 트레이스를 꺼낼 수 있다.

finally예외가 나든 안 나든 실행된다.

try {
    return "try";
} finally {
    System.out.println("finally 먼저");
}
콘솔
  • finally 먼저
  • try

return 을 만나도 finally 를 거쳐 간다. 그래서 정리 작업을 여기에 둔다. 파일을 닫거나 연결을 끊는 일이다.

catch 는 부모로도 받는다

계보에서 위에 있는 것으로 받으면 아래 것들이 전부 걸린다.

try {
    throw new IllegalStateException("직접 던짐");
} catch (RuntimeException e) {
    System.out.println("부모로 잡힌다: " + e.getMessage());
}

IllegalStateExceptionRuntimeException 의 자식이다. 14강의 다형성이 여기서도 일한다.

그래서 순서가 중요하다. 부모를 위에 두면 자식은 영원히 못 잡는다.

catch (RuntimeException e) { }
catch (IllegalArgumentException e) { }
터미널
  • error: exception IllegalArgumentException has already been caught

컴파일러가 막아준다. 좁은 것을 위에, 넓은 것을 아래에. 7강의 else if 와 같은 규칙이다.

둘을 똑같이 처리할 거면 세로줄로 묶는다.

catch (ArrayIndexOutOfBoundsException | NullPointerException e) { ... }

컴파일러가 묻는 예외, 안 묻는 예외

파일을 읽어보자.

FileReader r = new FileReader("x.txt");
터미널
  • error: unreported exception FileNotFoundException;
  • must be caught or declared to be thrown

실행도 안 했는데 컴파일 에러다. "붙잡든지, 던진다고 선언하든지 하라"고 한다.

반면 이건 컴파일이 잘 된다.

String s = null;
s.length();      // 컴파일 통과. 실행하면 NPE

이 차이가 자바 예외의 가장 큰 특징이다.

두 부류

checked 예외

Exception 의 자식 (RuntimeException 제외)

  • 컴파일러가 처리했는지 묻는다
  • IOException, FileNotFoundException
  • 파일·네트워크처럼 내 잘못이 아닌 실패
  • 안 다루면 컴파일이 안 된다

unchecked 예외

RuntimeException 의 자식

  • 컴파일러가 묻지 않는다
  • NullPointerException, ArithmeticException
  • 대개 코드가 잘못돼서 나는 실패
  • 붙잡기보다 안 나게 고친다

checked 예외를 처리하는 방법은 둘이다. 여기서 붙잡거나, 부른 쪽에게 떠넘기거나.

static void read() throws IOException {
    FileReader r = new FileReader("x.txt");
}

throws 는 "나는 이 예외를 낼 수 있으니 알아서 처리하라"는 선언이다. throw(던진다)와 글자 하나 차이인데 뜻이 다르다.

떠넘기면 부른 쪽이 같은 고민을 한다. main 까지 올라가면 프로그램이 죽는다.

직접 던진다

12강에서 이미 해봤다.

public void setPrice(int price) {
    if (price < 0) {
        throw new IllegalArgumentException("가격은 음수일 수 없다: " + price);
    }
    this.price = price;
}

IllegalArgumentExceptionRuntimeException 의 자식이라 throws 를 안 적어도 된다. 잘못된 값을 넘긴 쪽의 코드가 틀린 것이니 unchecked 가 맞다.

내 예외를 만들 수도 있다. 상속만 하면 된다.

class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(String title) {
        super(title + " 을(를) 찾을 수 없다");
    }
}

super(...) 로 부모에게 메시지를 넘긴다. 14강의 그 super 다.

이름만 봐도 무슨 일인지 아는 예외를 만드는 것. 그게 예외를 쓰는 이유다. null 을 돌려주면 부르는 쪽은 아무것도 모른다.

삼키지 않는다

가장 나쁜 코드는 이것이다.

try {
    중요한작업();
} catch (Exception e) {
    // 아무것도 안 함
}

catch (Exception e) 처럼 넓게 잡는 것도 조심한다. 예상한 예외만 잡는 게 낫다.

자원은 자동으로 닫는다

파일이나 연결은 다 쓰면 닫아야 한다. finally 에 적으면 되지만 길어진다.

try 옆의 괄호 안에서 만들면 자바가 알아서 닫아준다.

try (FileReader r = new FileReader("x.txt")) {
    // 읽는다
} catch (IOException e) {
    System.out.println("못 읽었다");
}

블록을 벗어날 때 r.close() 가 자동으로 불린다. 예외가 터져도 불린다. 이걸 try-with-resources 라고 한다.

정리하면

예외는 객체다. 컴파일러는 checked 만 묻고, unchecked 는 대개 코드를 고쳐야 할 신호다.

다시 짚어보기
  1. 01

    스택 트레이스는 아래에서 위로 흘렀다

    위가 터진 곳. 내 코드가 처음 나오는 줄부터 본다.

  2. 02

    try · catch · finally

    finally 는 return 을 만나도 실행된다. 정리 작업을 둔다.

  3. 03

    좁은 catch 를 위에 둔다

    부모로 먼저 잡으면 자식은 영원히 안 잡힌다. 컴파일러가 막아준다.

  4. 04

    checked 는 붙잡거나 throws 로 떠넘긴다

    unchecked 는 묻지 않는다. 대개 코드가 잘못된 것이라 고치는 게 맞다.

    static void read() throws IOException { ... }
  5. 05

    예외를 삼키지 않는다

    빈 catch 블록은 실패를 없었던 일로 만든다. 죽는 게 낫다.

예외는 실패를 사건으로 다루는 방법이다. 값을 돌려주는 대신 흐름을 끊고 위로 던진다.

그런데 실패가 아니라 "값이 없음"이라면 어떨까. 16강에서 map.get() 은 없는 키에 null 을 돌려줬다. 예외를 던지지도 않았다. 그 null 은 한참 뒤에 엉뚱한 곳에서 터진다.

20강에서 자바가 준비한 답을 본다. 그전에 19강에서, 자바를 아주 많이 바꿔놓은 문법 두 개를 먼저 배운다. 화살표(->)와 점 세 개로 이어지는 체인이다.

예외 — 컴파일러가 실패를 묻는다 — 디코드랩(DCODELAB)