04. 예외와 null 처리
실패했을 때 어떻게 알리고 어디서 처리할지, 값이 없을 수 있는 경우를 어떻게 표현할지 정리합니다.
검사 예외와 비검사 예외
Java는 예외를 두 갈래로 나눕니다.
Throwable
├── Error — JVM 수준의 문제. 잡지 않습니다
└── Exception
├── RuntimeException — 비검사 예외
└── 그 외 — 검사 예외검사 예외는 반드시 처리하거나 던진다고 선언해야 컴파일됩니다.
public List<Todo> load(Path path) throws IOException {
return Files.readAllLines(path).stream().map(this::parse).toList();
}비검사 예외는 선언하지 않아도 됩니다. 프로그래밍 실수에 해당하는 것들입니다.
NullPointerException
IllegalArgumentException
IndexOutOfBoundsException
IllegalStateException기준은 "호출하는 쪽이 복구할 수 있는가"입니다. 파일이 없으면 기본값을 쓰거나 다시 물어볼 수 있으니 검사 예외가 어울립니다. 인자가 null인 것은 코드를 고쳐야 할 문제이므로 비검사 예외입니다.
검사 예외를 그냥 삼키는 것이 가장 나쁜 처리입니다.
try {
load(path);
} catch (IOException e) {
// 아무것도 하지 않습니다 — 문제가 조용히 사라집니다
}처리할 수 없다면 올려보냅니다.
try {
return load(path);
} catch (IOException e) {
throw new TodoLoadException(path + " 를 읽을 수 없습니다", e);
}원인 예외를 두 번째 인자로 넘기면 스택 추적에 함께 남아 원인을 찾을 수 있습니다.
사용자 정의 예외
도메인의 실패에는 이름을 붙입니다. 무엇이 잘못됐는지 타입으로 드러납니다.
public class TodoException extends RuntimeException {
public TodoException(String message) {
super(message);
}
public TodoException(String message, Throwable cause) {
super(message, cause);
}
}
public class EmptyTitleException extends TodoException {
public EmptyTitleException() {
super("제목이 비어 있습니다");
}
}try {
service.add(" ");
} catch (TodoException e) { // 하위 예외도 함께 잡힙니다
log.warn(e.getMessage());
}여러 예외를 같은 방식으로 처리할 때는 묶어 적습니다.
try {
process();
} catch (IOException | ParseException e) {
throw new TodoException("처리 실패", e);
}catch 순서는 구체적인 것이 먼저여야 합니다. 상위 타입을 먼저 적으면 뒤쪽이 닿지 않아 컴파일 오류가 납니다.
try-with-resources와 자원 관리
파일이나 연결은 다 쓰면 닫아야 합니다. 예전에는 finally에서 직접 닫았습니다.
BufferedReader reader = null;
try {
reader = Files.newBufferedReader(path);
...
} finally {
if (reader != null) {
reader.close(); // 여기서도 예외가 날 수 있습니다
}
}괄호 안에서 연 자원은 블록을 벗어날 때 자동으로 닫힙니다.
try (var reader = Files.newBufferedReader(path)) {
return reader.lines().toList();
} catch (IOException e) {
throw new TodoException("읽기 실패", e);
}여러 개를 열면 선언의 역순으로 닫힙니다.
try (var input = Files.newInputStream(source);
var output = Files.newOutputStream(target)) {
input.transferTo(output);
}AutoCloseable을 구현하면 직접 만든 클래스도 쓸 수 있습니다.
public class Timer implements AutoCloseable {
private final long start = System.nanoTime();
@Override
public void close() {
System.out.printf("%.3f초%n", (System.nanoTime() - start) / 1e9);
}
}
try (var timer = new Timer()) {
load();
}Optional과 null 다루기
null을 돌려주면 호출하는 쪽이 확인을 잊기 쉽습니다. 없을 수 있다는 사실을 타입에 적습니다.
public Optional<Todo> findById(long id) {
return todos.stream().filter(todo -> todo.id() == id).findFirst();
}받는 쪽은 꺼내기 전에 처리를 정합니다.
Optional<Todo> found = findById(1);
found.map(Todo::title).orElse("없음");
found.ifPresent(todo -> print(todo));
found.ifPresentOrElse(
todo -> print(todo),
() -> print("없습니다")
);
Todo todo = found.orElseThrow(() -> new TodoException("찾을 수 없습니다"));get()은 쓰지 않습니다. 비어 있으면 그대로 터지므로 null 확인을 빠뜨린 것과 다를 바가 없습니다.
found.get(); // 권장하지 않습니다
found.isPresent() ? found.get() : fallback; // 이것도 Optional을 쓰는 의미가 없습니다연결해서 쓸 수 있다는 점이 가장 큰 이점입니다.
findById(1)
.filter(todo -> !todo.done())
.map(Todo::title)
.map(String::toUpperCase)
.orElse("해당 없음");쓰지 않는 자리도 있습니다.
- 필드: 직렬화되지 않고 메모리만 더 씁니다. 필드는
null이나 빈 값으로 둡니다 - 매개변수: 호출하는 쪽이
Optional.of(...)로 감싸야 해서 번거롭습니다. 메서드를 둘로 나누는 편이 낫습니다 - 컬렉션 반환: 비어 있음은 빈 리스트로 표현합니다
public List<Todo> findAll() {
return List.of(); // Optional<List<Todo>>가 아니라 빈 리스트
}Optional은 반환 타입에 쓰라고 만들어진 것입니다. "결과가 없을 수도 있다"를 알리는 용도입니다.
NullPointerException 읽기
최근 JVM은 어떤 값이 null이었는지 알려 줍니다.
Cannot invoke "String.length()" because the return value of
"Todo.memo()" is null예전처럼 줄 번호만 보고 추측하지 않아도 됩니다.
인자를 미리 막아 두면 터지는 위치가 원인에 가까워집니다.
public TodoService(TodoStorage storage) {
this.storage = Objects.requireNonNull(storage, "storage는 필수입니다");
}나중에 엉뚱한 곳에서 NullPointerException이 나는 대신, 생성하는 시점에 바로 알 수 있습니다.