예외를 "던지느냐 마느냐"부터 다시 생각한다. 예상 가능한 실패는 결과 객체로, 일시적 실패는 재시도로, 여러 건 처리의 부분 실패는 집계로, finally의 함정은 try-with-resources로, 그리고 고빈도 예외는 스택트레이스를 끄는 선택까지 다룬다.
사용자 입력이 잘못된 것은 "예외적 상황"이 아니라 정상 흐름의 한 갈래다. 실수 5(정상 흐름을 예외로 처리)의 대안으로, 파싱 결과를 sealed 계층으로 표현하면 호출자가 switch로 두 경우를 빠짐없이 처리하게 된다. 반면 DB 연결 끊김처럼 호출자가 복구할 수 없는 문제는 여전히 예외가 맞다.
import java.util.List;
sealed interface ParseResult permits Ok, Fail {}
record Ok(int value) implements ParseResult {}
record Fail(String reason) implements ParseResult {}
public class Main {
static ParseResult parseQty(String s) {
try {
int v = Integer.parseInt(s.strip());
return v > 0 ? new Ok(v) : new Fail("0 이하: " + v);
} catch (NumberFormatException e) {
return new Fail("숫자 아님: " + s); // 경계에서 예외를 결과로 바꿈
}
}
public static void main(String[] args) {
for (String s : List.of("3", " 12 ", "0", "abc")) {
String msg = switch (parseQty(s)) { // default 없음: Ok/Fail 모두 처리해야 컴파일됨
case Ok ok -> "OK " + ok.value();
case Fail f -> "FAIL " + f.reason();
};
System.out.println(msg);
}
// 출력:
// OK 3
// OK 12
// FAIL 0 이하: 0
// FAIL 숫자 아님: abc
}
}네트워크·DB의 일시적 오류는 몇 번 다시 시도하면 성공하는 경우가 많다. 재시도 횟수와 실행할 동작을 매개변수로 받는 범용 메서드를 만들되, 재시도할 예외 종류를 멀티 catch로 한정한다. 잘못된 입력(IllegalArgumentException)은 다시 해도 같은 결과이므로 재시도하지 않고 그대로 통과시킨다.
import java.io.UncheckedIOException;
import java.util.function.Supplier;
public class Main {
static <T> T retry(int maxAttempts, Supplier<T> action) {
RuntimeException last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return action.get();
} catch (IllegalStateException | UncheckedIOException e) { // 일시적 오류만 재시도
last = e;
System.out.println(attempt + "회차 실패: " + e.getMessage());
}
}
throw new RuntimeException(maxAttempts + "회 모두 실패", last); // 마지막 원인 보존
}
public static void main(String[] args) {
int[] calls = {0};
String result = retry(3, () -> {
if (++calls[0] < 3) throw new IllegalStateException("연결 끊김");
return "성공(" + calls[0] + "회차)";
});
System.out.println(result);
try {
retry(2, () -> { throw new IllegalStateException("항상 실패"); });
} catch (RuntimeException e) {
System.out.println(e.getMessage() + " / cause=" + e.getCause().getMessage());
}
try {
retry(3, () -> { throw new IllegalArgumentException("잘못된 입력"); });
} catch (IllegalArgumentException e) {
System.out.println("재시도 안 함: " + e.getMessage());
}
// 출력:
// 1회차 실패: 연결 끊김
// 2회차 실패: 연결 끊김
// 성공(3회차)
// 1회차 실패: 항상 실패
// 2회차 실패: 항상 실패
// 2회 모두 실패 / cause=항상 실패
// 재시도 안 함: 잘못된 입력
}
}배치에서 파일 100개 중 하나가 잘못됐다고 전체를 중단하면 안 된다. 건별로 try를 감싸 실패는 모아 두고 성공분은 계속 처리한다. 파싱 메서드는 내부의 여러 예외(IllegalArgumentException, DateTimeParseException)를 하나의 도메인 예외로 전환하면서 cause를 보존하고, checked로 선언해 호출자가 반드시 처리하게 한다.
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
class InvalidFileNameException extends Exception { // checked: 호출자가 반드시 처리
InvalidFileNameException(String message, Throwable cause) { super(message, cause); }
}
record LogFile(String service, LocalDate date) {}
public class Main {
// 형식: service_YYYY-MM-DD.log
static LogFile parse(String name) throws InvalidFileNameException {
try {
if (!name.endsWith(".log")) throw new IllegalArgumentException("확장자가 .log가 아님");
String base = name.substring(0, name.length() - 4);
int us = base.lastIndexOf('_');
if (us < 0) throw new IllegalArgumentException("'_' 구분자 없음");
return new LogFile(base.substring(0, us), LocalDate.parse(base.substring(us + 1)));
} catch (IllegalArgumentException | DateTimeParseException e) {
throw new InvalidFileNameException("파일명 형식 오류: " + name, e); // 전환 + cause
}
}
public static void main(String[] args) {
List<String> names = List.of("order_2026-09-08.log", "payment_2026-13-01.log", "readme.txt", "member_2026-09-07.log");
List<LogFile> ok = new ArrayList<>();
List<String> failed = new ArrayList<>();
for (String n : names) {
try {
ok.add(parse(n));
} catch (InvalidFileNameException e) { // 건별로 잡고 계속
failed.add(e.getMessage() + " (" + e.getCause().getClass().getSimpleName() + ")");
}
}
System.out.println("성공 " + ok.size() + "건: " + ok);
System.out.println("실패 " + failed.size() + "건:");
failed.forEach(f -> System.out.println(" " + f));
// 출력:
// 성공 2건: [LogFile[service=order, date=2026-09-08], LogFile[service=member, date=2026-09-07]]
// 실패 2건:
// 파일명 형식 오류: payment_2026-13-01.log (DateTimeParseException)
// 파일명 형식 오류: readme.txt (IllegalArgumentException)
}
}수동 finally에서 close()가 예외를 던지면 본문 예외는 흔적도 없이 사라진다. try-with-resources는 본문 예외를 살리고 close 예외를 suppressed로 붙인다. 함께 자주 마주치는 엣지 케이스로, 메시지 없이 만든 예외의 getMessage()는 null이고, cause만 넘긴 예외의 메시지는 cause의 toString()이 된다는 점도 확인한다.
import java.io.IOException;
class Conn implements AutoCloseable {
@Override
public void close() { throw new IllegalStateException("close 실패"); }
}
public class Main {
static void manual() {
Conn c = new Conn();
try {
throw new IllegalStateException("본문 예외");
} finally {
c.close(); // 여기서 던지면 본문 예외는 사라짐
}
}
static void automatic() {
try (Conn c = new Conn()) {
throw new IllegalStateException("본문 예외");
}
}
public static void main(String[] args) {
try {
manual();
} catch (RuntimeException e) {
System.out.println("manual: " + e.getMessage() + ", suppressed=" + e.getSuppressed().length);
}
try {
automatic();
} catch (RuntimeException e) {
System.out.println("auto: " + e.getMessage() + ", suppressed=" + e.getSuppressed()[0].getMessage());
}
// 출력:
// manual: close 실패, suppressed=0
// auto: 본문 예외, suppressed=close 실패
Throwable noMessage = new RuntimeException();
System.out.println(noMessage.getMessage()); // 출력: null
Throwable causeOnly = new RuntimeException(new IOException("io"));
System.out.println(causeOnly.getMessage()); // 출력: java.io.IOException: io
System.out.println("로그: " + String.valueOf(noMessage.getMessage()).length()); // 출력: 로그: 4 ("null" 문자열)
}
}로그에 e.getMessage()만 찍으면 null이 나올 수 있으므로, 실무에서는 e.toString()(클래스명 포함)이나 로거의 log.error("...", e) 형태로 예외 객체 자체를 넘긴다.
예제 5에서 예외가 비싼 이유는 fillInStackTrace()였다. 입력 검증처럼 초당 수천 번 던져지고 위치 정보가 필요 없는 예외는 생성자의 writableStackTrace=false로 캡처를 끌 수 있다. 대신 어디서 났는지 추적할 수 없으므로 메시지에 문맥(필드명, 값)을 반드시 담아야 하고, 인프라 오류에는 절대 쓰지 않는다.
public class Main {
static class ValidationException extends RuntimeException {
ValidationException(String message) {
super(message, null, false, false); // enableSuppression=false, writableStackTrace=false
}
}
static class NormalException extends RuntimeException {
NormalException(String message) { super(message); }
}
static long timeMs(Runnable r) {
long t0 = System.nanoTime();
r.run();
return (System.nanoTime() - t0) / 1_000_000;
}
public static void main(String[] args) {
System.out.println(new NormalException("a").getStackTrace().length > 0); // 출력: true
System.out.println(new ValidationException("b").getStackTrace().length); // 출력: 0
int n = 300_000;
long normal = timeMs(() -> {
for (int i = 0; i < n; i++) {
try { throw new NormalException("email 형식 오류: x"); } catch (NormalException ignored) { }
}
});
long light = timeMs(() -> {
for (int i = 0; i < n; i++) {
try { throw new ValidationException("email 형식 오류: x"); } catch (ValidationException ignored) { }
}
});
System.out.println("스택트레이스 O: " + normal + "ms / X: " + light + "ms");
// 출력 (환경에 따라 다름):
// 스택트레이스 O: 1959ms / X: 155ms
}
}