포스트

Java (6) - Exception: 무엇을 잡고 무엇을 던지나

체크 예외가 컴파일을 막는 것과 스트림 안에서 못 던지는 것을 확인하고 try-with-resources가 close를 부르는 순서를 다룹니다.

Java (6) - Exception: 무엇을 잡고 무엇을 던지나

자바 기초 시리즈의 6편입니다. 전체 목차는 0편에 있습니다.

5편 마지막 표에 “중간에 체크 예외를 던져야 하면 for 라고 적었습니다. 그 이유가 이번 편에 있습니다.

그리고 실무 코드에서 커스텀 예외를 만들 때는 보통 RuntimeException을 상속합니다. 왜 Exception이 아니었는지도 여기서 설명됩니다.

계층

자바의 모든 예외는 Throwable 아래에 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Throwable
 │
 ├── Error                              ← 복구할 수 없다. 잡지 않는다
 │     ├── OutOfMemoryError
 │     └── StackOverflowError
 │
 └── Exception                          ← 체크 예외. 처리를 강제한다
       ├── IOException
       ├── SQLException
       ├── ClassNotFoundException
       │
       └── RuntimeException             ← 언체크 예외. 강제하지 않는다
             ├── NullPointerException
             ├── IllegalArgumentException
             ├── IllegalStateException
             └── ClassCastException
갈래 컴파일러가 강제하나
Error 아니오 복구할 수 없다. 잡지 않는다 OutOfMemoryError, StackOverflowError
체크 예외 잡거나 던진다고 선언해야 컴파일된다 IOException, SQLException
언체크 예외 아니오 안 잡아도 컴파일된다 NullPointerException, IllegalArgumentException

경계는 RuntimeException을 상속했느냐 하나입니다. Exception을 바로 상속하면 체크 예외, RuntimeException을 상속하면 언체크 예외입니다.

1편에서 재귀로 만든 StackOverflowErrorError였습니다. 그래서 catch (Exception e)로는 안 잡힙니다.

체크 예외는 컴파일을 막는다

확인 1: 안 잡고 넘어가 본다

1
2
3
4
5
6
7
8
9
10
11
12
import java.io.*;

public class D {
    static String read(String path) throws IOException {
        return new BufferedReader(new FileReader(path)).readLine();
    }

    public static void main(String[] args) {
        String s = read("a.txt");
        System.out.println(s);
    }
}
1
2
3
4
D.java:8: error: unreported exception IOException; must be caught or declared to be thrown
        String s = read("a.txt");
                       ^
1 error

실행조차 못 합니다. 방법은 둘 중 하나입니다.

1
2
3
4
5
6
7
8
9
10
11
// ① 잡는다
try {
    String s = read("a.txt");
} catch (IOException e) {
    ...
}

// ② 나도 던진다고 선언한다
public static void main(String[] args) throws IOException {
    String s = read("a.txt");
}

체크 예외를 만든 의도는 좋았습니다. 파일이 없을 수 있다는 사실을 잊지 말라는 것입니다. 컴파일러가 강제하니 잊을 수가 없습니다.

문제는 실제로 쓰이는 모습이 이렇게 된다는 것입니다.

1
2
3
4
5
try {
    String s = read("a.txt");
} catch (IOException e) {
    // 여기서 뭘 해야 하지?
}

대부분의 자리에서는 할 수 있는 게 없습니다. 파일을 못 읽었으면 그 요청은 실패한 것이고, 여기서 복구할 방법은 없습니다. 그래서 throws를 계속 위로 붙이거나, 최악의 경우 잡아서 아무것도 안 합니다.

컴파일러가 강제로 만든 catch 블록이 비어 있게 되는 것이 체크 예외의 실패입니다.

컴파일러가 잡아주는 다른 것들

체크 예외는 컴파일 시점 검사가 촘촘합니다. 안 나는 예외를 잡으려 해도 막고, catch 순서가 뒤집혀도 막습니다.

1
2
3
4
5
6
7
8
try {
    System.out.println("아무것도 안 던진다");
} catch (IOException e) { ... }               // ①

try {
    throw new IOException("x");
} catch (Exception e) { ... }
  catch (IOException e) { ... }               // ②
1
2
① error: exception IOException is never thrown in body of corresponding try statement
② error: exception IOException has already been caught

②는 넓은 것을 먼저 잡으면 좁은 것이 영원히 안 불리기 때문입니다. catch는 위에서부터 순서대로 맞춰보므로 항상 좁은 것부터 씁니다. 언체크 예외에는 ①의 검사가 없습니다. 어디서든 날 수 있기 때문입니다.

javac 문구는 JDK 버전에 따라 조금씩 다를 수 있습니다. 이 편의 컴파일 에러들은 문장을 외우는 게 아니라 어떤 상황에서 컴파일이 멈추는지를 보면 됩니다.

람다와 스트림에서는 못 던진다

체크 예외가 결정적으로 불편해진 건 자바 8부터입니다.

확인 2: 스트림 안에서 체크 예외

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.stream.*;

public class E {
    public static void main(String[] args) throws IOException {
        List<String> paths = List.of("a.txt", "b.txt");

        List<String> contents = paths.stream()
                .map(p -> Files.readString(Path.of(p)))
                .collect(Collectors.toList());

        System.out.println(contents);
    }
}

mainthrows IOException을 붙여뒀는데도 컴파일이 안 됩니다.

1
2
3
4
E.java:10: error: unreported exception IOException; must be caught or declared to be thrown
                .map(p -> Files.readString(Path.of(p)))
                                          ^
1 error

이유는 람다가 main의 일부가 아니기 때문입니다. .map()에 넘긴 람다는 Function 인터페이스의 구현이고, 그 인터페이스는 이렇게 생겼습니다.

1
2
3
4
@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);          // throws 가 없다
}

apply가 아무 예외도 던진다고 선언하지 않았습니다. 구현이 그보다 넓은 예외를 던질 수는 없습니다. mainthrows는 람다 안까지 미치지 않습니다.

1
2
3
4
5
6
   main() throws IOException
     └─ stream()
          └─ map( 람다 )   ← 이 람다는 Function.apply() 의 구현이다
                           └─ Function.apply() 는 throws 가 없다
                              → 체크 예외를 못 던진다
                              → main 의 throws 와 무관하다

그래서 이렇게 감쌀 수밖에 없습니다.

1
2
3
4
5
6
7
.map(p -> {
    try {
        return Files.readString(Path.of(p));
    } catch (IOException e) {
        throw new UncheckedIOException(e);      // 언체크로 바꿔 던진다
    }
})

스트림 안에 try/catch가 들어가면 스트림으로 쓴 이유가 대부분 사라집니다. 5편에서 “중간에 체크 예외를 던져야 하면 for“라고 적은 게 이 상황입니다.

자바 표준 라이브러리도 이 문제를 알고 있어서 UncheckedIOException처럼 체크 예외를 언체크로 감싸는 클래스를 따로 두고 있습니다.

그래서 언체크를 쓴다

요즘 코드가 언체크 예외 쪽으로 기울어 있는 이유를 정리하면 셋입니다.

1. 대부분의 자리에서 복구할 수 없습니다. 복구할 수 없으면 잡아봐야 할 일이 없고, 그럼 강제할 이유도 없습니다.

2. throws가 전염됩니다. 아래 계층에서 던진 체크 예외를 위로 올리려면 중간의 모든 메서드에 throws를 붙여야 합니다. 중간 계층이 알 필요 없는 예외까지 시그니처에 드러납니다.

3. 람다·스트림과 안 맞습니다. 확인 2에서 본 그대로입니다.

그래서 실무의 커스텀 예외는 보통 이렇게 생겼습니다.

1
2
3
4
5
public class MemberNotFoundException extends RuntimeException {
    public MemberNotFoundException(Long id) {
        super("회원을 찾을 수 없습니다. id=" + id);
    }
}

RuntimeException을 상속했으니 언체크입니다. 서비스에서 던지면 컨트롤러까지 아무 throws 없이 올라가고, @RestControllerAdvice가 받아서 응답으로 바꿉니다.

스프링에서는 이게 더 중요합니다. @Transactional의 기본 롤백 규칙은 언체크 예외에만 적용됩니다. 체크 예외를 던지면 트랜잭션이 커밋됩니다. 도메인 예외를 체크로 만들면 이 함정을 밟습니다.

스프링이 SQLException(체크)을 DataAccessException(언체크) 계층으로 바꿔서 던지는 것도 같은 맥락입니다.

자원은 try-with-resources로 닫는다

확인 3: close()가 언제 불리나

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class Res {
    static class Connection implements AutoCloseable {
        private final String name;
        Connection(String name) { this.name = name; System.out.println("  open: " + name); }
        void use()  { System.out.println("  use: " + name); }
        void fail() { throw new IllegalStateException("쿼리 실패: " + name); }
        @Override public void close() { System.out.println("  close: " + name); }
    }

    public static void main(String[] args) {
        System.out.println("[1] 정상 종료");
        try (Connection c = new Connection("db1")) {
            c.use();
        }

        System.out.println("[2] 예외 발생");
        try (Connection c = new Connection("db2")) {
            c.fail();
        } catch (IllegalStateException e) {
            System.out.println("  catch: " + e.getMessage());
        }

        System.out.println("[3] 자원 두 개");
        try (Connection a = new Connection("db3");
             Connection b = new Connection("db4")) {
            a.use(); b.use();
        }

        System.out.println("[4] close() 안 부르는 코드");
        Connection c = new Connection("db5");
        c.use();
        System.out.println("  (close 안 불림)");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[1] 정상 종료
  open: db1
  use: db1
  close: db1
[2] 예외 발생
  open: db2
  close: db2
  catch: 쿼리 실패: db2
[3] 자원 두 개
  open: db3
  open: db4
  use: db3
  use: db4
  close: db4
  close: db3
[4] close() 안 부르는 코드
  open: db5
  use: db5
  (close 안 불림)

[2]에서 closecatch보다 먼저 찍혔습니다. 예외가 밖으로 나가기 전에 자원이 닫힙니다.

[3]에서 닫는 순서가 db4 → db3입니다. 연 순서의 반대입니다. 나중에 연 것이 먼저 연 것에 의존할 수 있으니 이 순서가 맞습니다.

[4]는 그냥 안 닫힙니다. GC는 close()를 대신 불러주지 않습니다. 커넥션, 파일 핸들, 소켓은 힙 밖의 자원이라 7편의 GC와 무관합니다. 안 닫으면 그대로 새어나갑니다.

AutoCloseable을 구현한 것만 try(...) 괄호 안에 들어갈 수 있습니다.

finally와 무엇이 다른가

finally에서 닫으면 되지 않나”에 대한 답입니다. close()도 실패할 수 있게 만들어 보면 차이가 드러납니다.

1
2
3
4
static class Conn implements AutoCloseable {
    @Override public void close() { throw new IllegalStateException("close 실패"); }
    void use() { throw new RuntimeException("작업 실패"); }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
System.out.println("[try-with-resources]");
try (Conn c = new Conn()) {
    c.use();
} catch (Exception e) {
    System.out.println("잡힌 예외 = " + e);
    for (Throwable s : e.getSuppressed()) System.out.println("  숨겨진 예외 = " + s);
}

System.out.println("[finally 로 직접 닫기]");
try {
    Conn c = new Conn();
    try {
        c.use();
    } finally {
        c.close();
    }
} catch (Exception e) {
    System.out.println("잡힌 예외 = " + e);
    System.out.println("  숨겨진 예외 개수 = " + e.getSuppressed().length);
}
1
2
3
4
5
6
[try-with-resources]
잡힌 예외 = java.lang.RuntimeException: 작업 실패
  숨겨진 예외 = java.lang.IllegalStateException: close 실패
[finally 로 직접 닫기]
잡힌 예외 = java.lang.IllegalStateException: close 실패
  숨겨진 예외 개수 = 0

finally로 닫으면 원래 예외가 사라집니다.

1
2
3
4
5
6
7
8
9
10
[ finally ]
   작업 실패 예외 발생 ──▶ finally 실행 ──▶ close 실패 예외 발생
                                             │
                                             └─▶ 이게 밖으로 나간다
                                                 "작업 실패"는 흔적도 없다

[ try-with-resources ]
   작업 실패 예외 발생 ──▶ close 실행 ──▶ close 실패 예외 발생
        │                                    │
        └─▶ 이게 밖으로 나간다                └─▶ suppressed 로 붙어서 같이 간다

진짜 원인은 “작업 실패”인데 로그에는 “close 실패”만 남습니다. try-with-resources는 원래 예외를 살리고 close()에서 난 예외를 거기에 붙여줍니다. 스택 트레이스에 Suppressed:로 같이 출력됩니다.

예외를 삼키지 않는다

가장 흔한 실수입니다.

1
2
3
4
5
try {
    doSomething();
} catch (Exception e) {
    // 나중에 처리
}

아무 일도 없었던 것처럼 진행됩니다. 데이터가 안 들어갔는데 성공 응답이 나가고, 로그에는 아무것도 안 남습니다. “예외가 안 나는 버그”를 손으로 만드는 셈입니다.

원인을 붙이지 않고 감싸는 것도 비슷합니다.

1
2
3
4
5
6
7
8
9
// 원인을 버린다
catch (NumberFormatException e) {
    throw new IllegalArgumentException("나이 형식이 잘못되었습니다");
}

// 원인을 붙인다
catch (NumberFormatException e) {
    throw new IllegalArgumentException("나이 형식이 잘못되었습니다", e);
}

확인 4: 스택 트레이스 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
[3] 원인 없이 감싸기
java.lang.IllegalArgumentException: 나이 형식이 잘못되었습니다
	at Exc.wrapWithoutCause(Exc.java:30)
	at Exc.main(Exc.java:42)

[4] 원인 붙여 감싸기
java.lang.IllegalArgumentException: 나이 형식이 잘못되었습니다
	at Exc.wrapWithCause(Exc.java:22)
	at Exc.main(Exc.java:45)
Caused by: java.lang.NumberFormatException: For input string: "abc"
	at java.base/java.lang.Integer.parseInt(...)
	at Exc.wrapWithCause(Exc.java:20)
	... 1 more

Caused by: 한 덩어리가 통째로 있고 없고의 차이입니다. 원인을 안 붙이면 “어떤 값이 문제였는지”(For input string: "abc")가 통째로 사라집니다. 두 번째 인자로 e를 넘기는 것 하나로 남습니다.

위 출력에서 JDK 내부 프레임은 줄여서 적었습니다. 실제로는 몇 줄 더 나옵니다.

로그를 남길 때도 같습니다.

1
2
log.error("주문 처리 실패: {}", e.getMessage());       // 스택 트레이스가 없다
log.error("주문 처리 실패. orderId={}", orderId, e);   // 예외 객체를 마지막에 넘긴다

커스텀 예외를 만드는 기준

만들면 좋은 경우입니다.

기준
잡는 쪽이 구분해서 다르게 처리해야 한다 MemberNotFoundException → 404, DuplicateEmailException → 409
도메인 용어로 말해야 로그가 읽힌다 IllegalStateException보다 StockShortageException
부가 정보를 담아야 한다 부족한 재고 수량, 요청한 회원 id

안 만들어도 되는 경우입니다.

  • 표준 예외로 충분한 경우 — 인자가 잘못됐으면 IllegalArgumentException, 지금 상태에서 할 수 없는 일이면 IllegalStateException
  • 메시지만 다른 예외를 계속 늘리는 경우 — 잡는 쪽이 구분하지 않는다면 클래스를 나눌 이유가 없습니다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StockShortageException extends RuntimeException {

    private final Long stockId;
    private final Long requested;
    private final Long available;

    public StockShortageException(Long stockId, Long requested, Long available) {
        super("재고가 부족합니다. stockId=%d, 요청=%d, 남은수량=%d"
                .formatted(stockId, requested, available));
        this.stockId = stockId;
        this.requested = requested;
        this.available = available;
    }
    // getter
}

메시지에 값을 넣고, 필드로도 들고 있습니다. 메시지는 로그를 읽는 사람을 위한 것이고, 필드는 잡는 쪽에서 응답을 만들 때 쓰기 위한 것입니다.

내가 헷갈렸던 지점

정리

  • 갈래는 Error / 체크 예외 / 언체크 예외 셋이고, 경계는 RuntimeException을 상속했느냐 하나다. Error는 잡지 않는다
  • 체크 예외는 잡거나 던진다고 선언하지 않으면 컴파일이 안 된다. 의도는 좋았지만, 대부분의 자리에서는 복구할 방법이 없어서 catch 블록을 만들어낸다
  • 람다 안에서는 체크 예외를 못 던진다. Function.apply()throws가 없기 때문이고, 감싸는 메서드의 throws는 람다 안까지 미치지 않는다. 그래서 스트림 안에 try/catch가 들어가게 된다
  • 요즘 언체크를 쓰는 이유는 셋이다. 복구할 수 없고, throws가 전염되고, 람다와 안 맞는다. 실무의 커스텀 예외가 RuntimeException을 상속하는 이유이자, 스프링의 @Transactional 롤백 규칙이 언체크에만 적용되는 것과 맞물린다
  • try-with-resources는 예외가 밖으로 나가기 전에 close()를 부르고, 연 순서의 반대로 닫는다. finally로 닫으면 close()에서 난 예외가 원래 예외를 덮어써서 진짜 원인이 사라진다
  • 예외를 삼키면 안 되고(catch (Exception e) {}), 감쌀 때는 원인을 두 번째 인자로 넘긴다. Caused by: 한 덩어리가 통째로 달라진다
  • 커스텀 예외는 잡는 쪽이 구분해서 처리해야 할 때 만든다. 메시지만 다른 예외를 늘리는 건 의미가 없다

7편에서 힙에 쌓인 객체가 언제 사라지는지 봅니다. 그리고 이번 편의 close()처럼 GC가 대신 해주지 않는 것들을 정리하며 시리즈를 마칩니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.