thenullpage.com

프로그램은 늘 뜻대로 풀리지 않는다. 숫자를 받아야 하는 자리에 글자가 들어오고, 열려던 파일이 없고, 0으로 나누는 계산이 섞인다. 이런 순간 자바는 실행을 멈추고 무엇이 잘못됐는지 적힌 객체를 던진다. 그게 예외다. 아무도 받지 않으면 프로그램은 그대로 끝난다.


잡지 않은 예외는 프로그램을 종료시킨다. 배열 범위를 넘겨 보면 바로 확인된다.


int[] nums = {1, 2, 3};
System.out.println(nums[5]);
System.out.println("이 줄은 실행되지 않는다");
// 출력:
// Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 3
// at Main.main(Main.java:4)


마지막 줄은 아예 실행되지 않았다. 예외는 자기를 부른 쪽으로 거슬러 올라가며 받아 줄 곳을 찾고, 끝까지 못 찾으면 JVM이 저 메시지를 찍고 끝낸다. at으로 시작하는 줄이 예외가 터진 위치다.


try와 catch로 받는다. 위험한 코드를 try 블록에 넣고, 받을 예외 타입을 catch에 적는다.


String input = "열";
try {
int n = Integer.parseInt(input);
System.out.println(n * 2);
} catch (NumberFormatException e) {
System.out.println("숫자가 아니다: " + e.getMessage());
}
System.out.println("계속 진행");
// 출력:
// 숫자가 아니다: For input string: "열"
// 계속 진행


parseInt가 예외를 던지는 순간 try 안의 나머지 줄은 건너뛰고 catch로 넘어간다. n * 2는 실행되지 않았지만 프로그램이 죽지 않아 아래 문장은 그대로 이어졌다. getMessage는 예외에 담긴 설명을 꺼낸다.


catch는 위에서부터 맞는 것 하나만 걸린다. 여러 개를 늘어놓으면 순서대로 검사해 처음 맞는 블록만 실행한다.


try {
int[] arr = new int[3];
arr[5] = 10;
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("범위를 벗어났다");
} catch (RuntimeException e) {
System.out.println("그 밖의 실행 예외");
}
// 출력: 범위를 벗어났다


이 둘의 순서를 바꾸면 컴파일이 안 된다. RuntimeException이 위에 오면 아래는 영영 걸릴 일이 없어서 exception has already been caught 에러가 난다. 넓은 타입일수록 아래로 내린다. 처리가 같으면 세로 막대로 묶어 한 블록에서 받는다.


catch (NumberFormatException | ArithmeticException e) {
System.out.println("입력이 이상하다");
}


finally는 무슨 일이 있어도 실행된다. 예외가 나든 안 나든, 심지어 return을 만나도 빠져나가기 전에 먼저 돈다.


static int divide(int a, int b) {
try {
return a / b;
} catch (ArithmeticException e) {
System.out.println("0으로 나눌 수 없다");
return -1;
} finally {
System.out.println("정리 끝");
}
}

System.out.println(divide(10, 0));
// 출력:
// 0으로 나눌 수 없다
// 정리 끝
// -1


return -1을 만난 뒤에도 정리 끝이 먼저 찍혔다. 다만 finally 안에서 return을 하면 원래 반환값과 던져지던 예외를 통째로 삼켜 원인이 사라진다. 이건 하면 안 된다.


try-with-resources가 닫는 코드를 대신 써 준다. 파일이나 접속처럼 다 쓰고 닫아야 하는 것들은 예전엔 finally에 close를 적었는데 길고 빠뜨리기 쉬웠다. 지금은 try 옆 괄호 안에서 만들면 된다.


try (BufferedReader r = new BufferedReader(new FileReader("data.txt"))) {
System.out.println(r.readLine());
} catch (IOException e) {
System.out.println("파일을 읽지 못했다");
}


괄호 안에서 만든 객체는 블록을 벗어날 때 자동으로 close된다. 중간에 예외가 터져도 닫힌다. AutoCloseable을 구현한 타입이면 전부 이 자리에 넣을 수 있다.


컴파일러가 강제하는 예외가 따로 있다. 위 코드에서 IOException을 안 받으면 컴파일 자체가 안 된다. 이렇게 처리를 강제당하는 쪽을 checked, 강제되지 않는 쪽을 unchecked라 부른다. RuntimeException을 상속한 것이 unchecked고 나머지가 checked다. NumberFormatException은 unchecked라 try 없이도 컴파일된다. 잡기 싫으면 던진다고 표시하고 넘긴다.


static String read(String path) throws IOException {
return Files.readString(Path.of(path));
}


throws는 나는 처리하지 않을 테니 부르는 쪽이 알아서 하라는 선언이다. 결국 try로 감싸거나 throws로 미루거나 둘 중 하나이고, main까지 미루면 아까 본 종료 화면을 보게 된다.


필요하면 직접 던지고 직접 만든다. 잔액이 모자란 출금 같은 건 자바가 알 수 없으니 조건을 확인해 던져야 한다.


class NotEnoughMoneyException extends RuntimeException {
NotEnoughMoneyException(String message) {
super(message);
}
}

static void withdraw(int balance, int amount) {
if (amount > balance) {
throw new NotEnoughMoneyException("잔액 부족: " + balance);
}
System.out.println(amount + "원 출금");
}

withdraw(1000, 5000);
// 출력:
// Exception in thread "main" NotEnoughMoneyException: 잔액 부족: 1000


throw는 지금 던지는 문장이고 throws는 던질 수 있다는 표시다. 실패를 -1 같은 반환값으로 알리면 부르는 쪽이 확인을 잊어도 조용히 넘어가지만, 예외는 무시하면 프로그램이 멈춰 사고를 덮지 못한다.


가장 나쁜 코드는 빈 catch다. 아래처럼 적으면 에러는 흔적도 없이 사라지고 나중에 원인을 찾을 방법이 없다.


try {
save(data);
} catch (Exception e) {
}


잘못된 건 두 가지다. 블록이 비어 있는 것, Exception으로 뭉뚱그려 생각 못 한 버그까지 함께 삼킨 것이다. 처리할 수 없는 예외라면 잡지 말고 위로 올려보내는 편이 낫다. 기록을 남길 때도 예외 객체를 같이 넘겨 위치 정보를 살려 둔다.


이번 편에서 손에 쥐어야 할 감각은 세 가지다. 예외는 받아 주는 곳이 없으면 프로그램을 끝낸다는 것, checked는 try나 throws 중 하나를 컴파일러가 요구한다는 것, 닫아야 하는 자원은 try-with-resources로 연다는 것이다. parseInt에 아무 글자나 넣어 스택 트레이스를 띄워 보고 다시 try로 감싸 보면 감이 잡힌다.