프로그램은 늘 어긋날 여지를 안고 돈다. 파일이 없거나, 네트워크가 끊기거나, 숫자 자리에 글자가 들어오거나, 0으로 나누는 일이 벌어진다. 정상 흐름을 벗어난 이 상황을 자바는 예외라는 객체로 표현한다. 문제가 생기면 그 지점에서 예외가 던져지고 정상 실행은 멈춘 채, 처리할 곳을 찾아 호출 스택을 거슬러 올라간다. 어디서도 처리하지 못하면 프로그램이 멈춘다. 예외 처리는 이 흐름을 붙잡아 문제가 생겨도 무너지지 않고 대응하게 만드는 장치다.


[Java 11] 예외 처리


예외는 계층 구조를 이룬다. 뿌리는 Throwable이고 그 아래가 ErrorException으로 갈린다. Error는 메모리 고갈처럼 손쓸 수 없는 상황이라 보통 잡지 않고, 우리가 다루는 것은 Exception 쪽이다. Exception은 다시 RuntimeException과 그 밖으로 나뉜다. 이 갈래가 확인 예외와 미확인 예외를 가르는 경계다.


확인 예외는 컴파일러가 처리를 강제한다. RuntimeException이 아닌 Exception을 확인 예외라 한다. 파일 입출력의 IOException이 대표적이다. 이런 예외가 날 수 있는 코드를 부르면 자바는 반드시 try-catch로 잡거나 throws로 위에 떠넘기라고 컴파일 단계에서 강제하고, 처리하지 않으면 컴파일이 안 된다. 충분히 예상되는 실패에 쓰인다.


미확인 예외는 강제하지 않는다. RuntimeException과 그 자식들이 미확인 예외다. NullPointerException, ArrayIndexOutOfBoundsException, IllegalArgumentException이 여기 속한다. 대개 프로그래머의 실수에서 나오는 것이라 잡으라고 강제하지 않는다. 강제한다면 모든 코드가 try-catch로 뒤덮일 것이다. 잡기보다 애초에 나지 않게 코드를 바로잡는 것이 옳다.


try-catch로 예외를 붙잡는다. 예외가 날 수 있는 코드를 try에 두고, 대응을 catch에 적는다. try 안에서 예외가 생기면 즉시 남은 코드를 건너뛰고 catch로 넘어간다. 잡을 예외 타입을 지정하고, 잡힌 예외 객체를 변수로 받아 정보를 쓸 수 있다.

try {

int n = Integer.parseInt(input);

System.out.println(100 / n);

} catch (NumberFormatException e) {

System.out.println("숫자가 아닙니다: " + e.getMessage());

} catch (ArithmeticException e) {

System.out.println("0으로 나눌 수 없습니다");

}


여러 예외는 한 번에 잡을 수도 있다. 위처럼 catch를 이어 각기 다르게 처리하거나, 같은 대응이면 catch (IOException | SQLException e)처럼 세로 막대로 묶는 멀티 캐치로 중복을 없앤다. 잡는 순서도 중요해, 자식 예외를 부모보다 먼저 적어야 한다. 부모를 먼저 적으면 자식이 거기 걸려 뒤의 catch가 죽은 코드가 되고 컴파일러가 막는다.


finally는 예외와 상관없이 실행된다. finallytry에서 예외가 나든 안 나든, return으로 빠져나가든 반드시 실행돼 뒷정리에 썼다. 다만 finally 안의 return은 피해야 한다. try가 던진 예외를 finallyreturn이 조용히 삼켜, 문제가 있었는데도 없던 일처럼 넘어가는 위험이 생긴다.


try-with-resources가 자원 정리를 자동화한다. 파일, DB 연결, 소켓 같은 자원은 다 쓰면 반드시 닫아야 하고, 안 닫으면 새어 나가 시스템이 고갈된다. 예전엔 finally에서 손으로 닫아 장황했다. 자바 7부터의 try-with-resourcestry 괄호 안에 자원을 선언하면 블록이 끝날 때 예외 여부와 무관하게 자동으로 닫아 준다. AutoCloseable을 구현한 자원이면 무엇이든 된다.

try (var reader = Files.newBufferedReader(path)) {

return reader.lines().count();

} catch (IOException e) {

throw new UncheckedIOException(e); // 원인을 담아 다시 던짐

}


예외는 직접 던질 수도 있다. 우리가 throw로 직접 던진다. 잘못된 값을 받았을 때 throw new IllegalArgumentException("잔액 부족")으로 막는 식이다. 메서드가 확인 예외를 던질 수 있으면 그 사실을 throws로 시그니처에 밝혀야 한다. 부르는 쪽에 실패 가능성을 알리는 계약이라, 부르는 쪽은 잡거나 위로 떠넘겨야 한다.


우리만의 예외를 만들 수 있다. 표준 예외로 뜻이 부족하면 Exception이나 RuntimeException을 상속해 직접 만든다. 확인 예외는 Exception을, 미확인은 RuntimeException을 물려받는다. 잔액 부족을 InsufficientBalanceException으로 표현하면 잡는 쪽도 무엇을 다루는지 타입 이름만으로 바로 안다.


예외를 감쌀 때는 원인을 담아야 한다. 낮은 계층의 SQLException을 잡아 도메인 예외로 바꿔 던지는 일이 흔하다. 이때 원래 예외를 새 예외의 원인으로 함께 담아야 한다. 대부분의 예외 생성자가 원인을 받는 자리를 둔다. 원인을 버리면 진짜 원인이 사라져 디버깅이 막막해지니, 감싸되 원인은 반드시 남긴다.


가장 나쁜 것은 예외를 조용히 삼키는 것이다. 최악의 습관은 catch 블록을 비워 두는 것이다. 잡아 놓고 아무것도 안 하면 문제가 생겼는데도 넘어가고, 나중에 엉뚱한 곳에서 결과가 틀어지는데 단서가 없다. 잡았으면 최소한 로그로 기록하거나, 대응하거나, 다시 던져야 한다. 처리할 방법이 없다면 차라리 잡지 말고 위로 흘려보내는 게 낫다.


너무 넓게 잡는 것도 피한다. catch (Exception e)로 모든 예외를 뭉뚱그리면 예상 못 한 심각한 문제까지 같은 자리에서 삼킨다. 처리할 수 있는 예외만 정확히 잡고, 못 하는 것은 위로 올려보낸다. 또 예외를 흐름 제어에 쓰지 않는다. 값이 없음을 알리는 평범한 흐름은 Optional이나 특별한 반환값으로 표현하는 게 맞다. 흔히 일어나는 일을 예외로 다루면 그건 이미 예외가 아니다.


스택 트레이스는 예외의 발자취다. 예외가 처리되지 않고 멈추면 스택 트레이스라는 긴 목록이 찍힌다. 예외가 어느 메서드에서 시작해 어떤 호출을 거쳐 올라왔는지를 보여 준다. 맨 위가 처음 터진 지점이고 아래로 내려갈수록 그것을 부른 곳이라, 우리 코드에서 시작한 줄을 찾아 그 위치를 여는 것이 디버깅의 첫걸음이다. 원인을 담아 감싼 예외는 원인 사슬까지 함께 찍혀 뿌리까지 거슬러 볼 수 있다.


다른 언어와 견주면 자바의 방식이 상대화된다. Go에는 예외가 없다시피 하고, 함수가 결과와 함께 오류 값을 돌려주면 부르는 쪽이 if err != nil로 매번 확인한다. 러스트는 실패 가능성을 Result 타입에 담아 돌려주고 물음표 연산자로 간결하게 전파한다. 자바만이 예외를 던지고 위에서 잡는 모델에 확인 예외로 일부를 강제한다. 이 확인 예외는 논쟁이 많아, 처리 못 할 예외까지 떠넘기다 의미 없이 삼키는 습관을 부추긴다는 비판에 많은 프레임워크가 확인 예외를 미확인으로 감싸 던진다.


예외를 잘 다루면 프로그램이 견고해진다. 예상되는 실패는 확인 예외로 대비하고, 프로그래머의 실수는 애초에 나지 않게 바로잡는다. 자원은 try-with-resources로 닫고, 잡은 예외는 삼키지 말고 기록하거나 원인을 담아 다시 던진다. 잡을 예외는 구체적으로 짚고 평범한 흐름 제어에 쓰지 않는다. 견고한 프로그램은 잘 도는 코드가 아니라 잘 실패하는 코드에서 나온다. 이것으로 실행 구조에서 타입, 컬렉션, 클래스와 상속, 제네릭, 예외까지 자바의 뼈대를 한 바퀴 돌았다. 다음은 람다와 스트림, Optional, 멀티스레딩으로 이어진다.