Java (6) - Exception: 무엇을 잡고 무엇을 던지나
체크 예외가 컴파일을 막는 것과 스트림 안에서 못 던지는 것을 확인하고 try-with-resources가 close를 부르는 순서를 다룹니다.
자바 기초 시리즈의 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편에서 재귀로 만든 StackOverflowError가 Error였습니다. 그래서 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);
}
}
main에 throws 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가 아무 예외도 던진다고 선언하지 않았습니다. 구현이 그보다 넓은 예외를 던질 수는 없습니다. main의 throws는 람다 안까지 미치지 않습니다.
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]에서 close가 catch보다 먼저 찍혔습니다. 예외가 밖으로 나가기 전에 자원이 닫힙니다.
[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가 대신 해주지 않는 것들을 정리하며 시리즈를 마칩니다.