공공부하자개발 · 영어 학습 노트
자바
중급객체지향과 코어 라이브러리0/9 완료
  • 01상속, 다형성, 오버라이딩
  • 02추상 클래스 vs 인터페이스
  • 03예외 처리
  • 04java.lang 심화
  • 05컬렉션 프레임워크 딥다이브
  • 06메서드 활용 패턴 (중급)
  • 07Object 메서드와 비교
  • 08java.time 실무 날짜 계산
  • 09HTTP 와 JSON 기초
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 중급 › 03 / 9

예외 처리

섹션 7진행 0 / 9
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

예외를 "던지느냐 마느냐"부터 다시 생각한다. 예상 가능한 실패는 결과 객체로, 일시적 실패는 재시도로, 여러 건 처리의 부분 실패는 집계로, finally의 함정은 try-with-resources로, 그리고 고빈도 예외는 스택트레이스를 끄는 선택까지 다룬다.

변형 1: 예외 대신 결과 객체 — 예상 가능한 실패

사용자 입력이 잘못된 것은 "예외적 상황"이 아니라 정상 흐름의 한 갈래다. 실수 5(정상 흐름을 예외로 처리)의 대안으로, 파싱 결과를 sealed 계층으로 표현하면 호출자가 switch로 두 경우를 빠짐없이 처리하게 된다. 반면 DB 연결 끊김처럼 호출자가 복구할 수 없는 문제는 여전히 예외가 맞다.

java
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
    }
}

변형 2: 재시도 유틸 — 횟수와 동작을 매개변수로

네트워크·DB의 일시적 오류는 몇 번 다시 시도하면 성공하는 경우가 많다. 재시도 횟수와 실행할 동작을 매개변수로 받는 범용 메서드를 만들되, 재시도할 예외 종류를 멀티 catch로 한정한다. 잘못된 입력(IllegalArgumentException)은 다시 해도 같은 결과이므로 재시도하지 않고 그대로 통과시킨다.

java
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=항상 실패
        // 재시도 안 함: 잘못된 입력
    }
}

변형 3: 부분 실패 집계 — 로그 파일명 파싱

배치에서 파일 100개 중 하나가 잘못됐다고 전체를 중단하면 안 된다. 건별로 try를 감싸 실패는 모아 두고 성공분은 계속 처리한다. 파싱 메서드는 내부의 여러 예외(IllegalArgumentException, DateTimeParseException)를 하나의 도메인 예외로 전환하면서 cause를 보존하고, checked로 선언해 호출자가 반드시 처리하게 한다.

java
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)
    }
}

변형 4: finally의 예외가 본문 예외를 덮는다 — 그리고 메시지가 null인 예외

수동 finally에서 close()가 예외를 던지면 본문 예외는 흔적도 없이 사라진다. try-with-resources는 본문 예외를 살리고 close 예외를 suppressed로 붙인다. 함께 자주 마주치는 엣지 케이스로, 메시지 없이 만든 예외의 getMessage()는 null이고, cause만 넘긴 예외의 메시지는 cause의 toString()이 된다는 점도 확인한다.

java
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: 스택트레이스 없는 예외 — 고빈도 검증 실패의 비용 줄이기

예제 5에서 예외가 비싼 이유는 fillInStackTrace()였다. 입력 검증처럼 초당 수천 번 던져지고 위치 정보가 필요 없는 예외는 생성자의 writableStackTrace=false로 캡처를 끌 수 있다. 대신 어디서 났는지 추적할 수 없으므로 메시지에 문맥(필드명, 값)을 반드시 담아야 하고, 인프라 오류에는 절대 쓰지 않는다.

java
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
    }
}
응용 변형 예제
  • 변형 1: 예외 대신 결과 객체 — 예상 가능한 실패
  • 변형 2: 재시도 유틸 — 횟수와 동작을 매개변수로
  • 변형 3: 부분 실패 집계 — 로그 파일명 파싱
  • 변형 4: finally의 예외가 본문 예외를 덮는다 — 그리고 메시지가 null인 예외
  • 변형 5: 스택트레이스 없는 예외 — 고빈도 검증 실패의 비용 줄이기
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)