PHpullh
심층 가이드/Java/예외처리

JAVA · 심층 가이드

Java 예외처리 완전 정리

checked 예외 설계 기준부터 try-with-resources의 suppressed 예외, 예외 번역과 로깅까지 Java 오류 처리 코드를 12개 주제로 다듬습니다.

주제 12개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

checked 예외는 주류 언어 중 Java가 거의 유일하게 밀고 나간 실험입니다. 컴파일러가 '이 오류를 처리했는가'를 강제한다는 발상은 훌륭했지만, 람다와 Stream이 들어오면서 균열이 생겼습니다. Function이나 Supplier의 시그니처에는 throws 절이 없어서, 스트림 파이프라인 안에서 checked 예외를 던지는 메서드를 호출하는 순간 코드가 컴파일되지 않습니다. 그래서 실무에서는 '어디서 unchecked로 감싸 올릴 것인가'가 오류 처리 설계의 실질적인 첫 질문이 됩니다.

이 가이드는 그 질문에 답하는 순서로 배치돼 있습니다. checked vs unchecked 예외에서 호출자가 복구할 수 있는 상황인지를 판단 기준으로 세우고, 커스텀 예외 만들기예외 계층 설계로 도메인 어휘를 가진 예외 타입을 만든 다음, 예외 번역(Exception Translation)예외 체이닝에서 하위 계층의 SQLException을 상위 계층 언어로 옮기되 원인 정보는 잃지 않는 방법을 봅니다. try-with-resources 심화는 이 흐름과 직교하는 리소스 수명 문제를 맡습니다.

try-with-resources에서 본문과 close()가 모두 예외를 던지면, 호출자에게 전달되는 것은 본문 예외이고 close() 쪽은 suppressed 목록에 붙습니다. getSuppressed()를 확인하지 않는 로깅 코드는 이 정보를 통째로 버립니다. 예외 번역 시 new ServiceException(msg)처럼 원인을 넘기지 않는 것도 같은 종류의 손실입니다. 반드시 cause를 받는 생성자를 쓰고, 로깅할 때는 문자열 연결이 아니라 log.error("...", e)처럼 Throwable을 마지막 인자로 넘겨야 스택 트레이스가 남습니다.

01예외 처리 & try-with-resources

Checked/Unchecked 예외, 커스텀 예외 계층, try-with-resources로 리소스를 안전하게 관리합니다.

Java code

import java.io.*;

// 커스텀 예외 계층
public class AppException extends RuntimeException {  // Unchecked
    private final int errorCode;

    public AppException(String message, int errorCode) {
        super(message);
        this.errorCode = errorCode;
    }

    public AppException(String message, int errorCode, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    public int getErrorCode() { return errorCode; }
}

public class NotFoundException extends AppException {
    public NotFoundException(String resource, long id) {
        super("%s #%d를 찾을 수 없습니다".formatted(resource, id), 404);
    }
}

public class ExceptionDemo {
    // try-with-resources — AutoCloseable 자동 닫기
    static String readFile(String path) throws IOException {
        try (var reader = new BufferedReader(new FileReader(path))) {
            var sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append(System.lineSeparator());
            }
            return sb.toString();
        }
    }

    // 멀티 catch (Java 7+)
    static void process(String input) {
        try {
            int n = Integer.parseInt(input);
            int[] arr = new int[n];
            arr[n] = 1;  // 일부러 에러
        } catch (NumberFormatException | NegativeArraySizeException e) {
            System.out.println("입력 오류: " + e.getMessage());
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("범위 초과: " + e.getMessage());
        } finally {
            System.out.println("항상 실행");
        }
    }

    public static void main(String[] args) {
        // 예외 체이닝
        try {
            throw new AppException("DB 연결 실패", 500,
                new RuntimeException("Connection timeout"));
        } catch (AppException e) {
            System.out.println(e.getMessage() + " [" + e.getErrorCode() + "]");
            System.out.println("원인: " + e.getCause().getMessage());
        }
        process("3");
    }
}
알아두면 좋은 점

RuntimeException(Unchecked)을 상속하면 throws 선언 없이 사용 가능합니다. 현대 Java에서는 Checked Exception보다 Unchecked Exception이 선호됩니다.

자주 하는 실수

catch (Exception e) {}처럼 빈 catch 블록은 절대 작성하지 마세요. 최소한 로그라도 남겨야 합니다.

02Sequenced Map 순회

LinkedHashMap의 SequencedMap 인터페이스 활용

Java code

<span class="cm">// Sequenced Map 순회 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Sequenced Map 순회") }
알아두면 좋은 점

JAVA 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

03checked vs unchecked 예외

checked 예외와 unchecked 예외의 차이, 설계 기준을 이해합니다.

Java code

import java.io.*;

public class CheckedUnchecked {
    // Checked — 호출자가 반드시 처리해야 함
    // IOException, SQLException, ClassNotFoundException
    static String readFile(String path) throws IOException {
        // IOException은 checked -> 선언 필수
        try (var reader = new BufferedReader(new FileReader(path))) {
            return reader.readLine();
        }
    }

    // Unchecked — 처리 강제 안 됨
    // RuntimeException 하위: NullPointerException, IllegalArgumentException
    static int divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("0으로 나눌 수 없음");
        }
        return a / b;
    }

    // 설계 기준
    // Checked: 호출자가 복구할 수 있는 예외 (파일 없음 -> 대체 파일)
    // Unchecked: 프로그래밍 에러 (null 접근, 잘못된 인자)

    public static void main(String[] args) {
        // checked — 반드시 처리
        try {
            readFile("test.txt");
        } catch (FileNotFoundException e) {
            System.out.println("파일 없음: " + e.getMessage());
        } catch (IOException e) {
            System.out.println("I/O 에러: " + e.getMessage());
        }

        // unchecked — 선택적 처리
        try {
            divide(10, 0);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}
알아두면 좋은 점

현대 Java에서는 unchecked 예외를 선호하는 추세입니다. Spring Framework도 대부분 unchecked 예외를 사용합니다.

자주 하는 실수

checked 예외를 무시하기 위해 빈 catch 블록을 작성하지 마세요. 최소한 로그를 남기거나 RuntimeException으로 래핑하세요.

04try-with-resources 심화

AutoCloseable 리소스를 자동으로 정리하는 패턴의 심화 활용입니다.

Java code

import java.io.*;

public class TryWithResources {
    // AutoCloseable 구현
    static class DatabaseConnection implements AutoCloseable {
        final String name;

        DatabaseConnection(String name) {
            this.name = name;
            System.out.println("연결: " + name);
        }

        void query(String sql) {
            System.out.println("[" + name + "] " + sql);
        }

        @Override
        public void close() {
            System.out.println("해제: " + name);
        }
    }

    public static void main(String[] args) {
        // 여러 리소스 (역순으로 close)
        try (var db1 = new DatabaseConnection("primary");
             var db2 = new DatabaseConnection("replica")) {
            db1.query("SELECT * FROM users");
            db2.query("SELECT * FROM orders");
        }
        // 출력: 해제: replica -> 해제: primary (역순!)

        // 기존 변수 참조 (Java 9+)
        DatabaseConnection conn = new DatabaseConnection("legacy");
        try (conn) { // 이미 선언된 변수 사용 가능
            conn.query("SELECT 1");
        }

        // suppressed exceptions
        try (var res = new AutoCloseable() {
            public void close() throws Exception {
                throw new Exception("close 에러");
            }
        }) {
            throw new Exception("본문 에러");
        } catch (Exception e) {
            System.out.println("주 예외: " + e.getMessage());
            for (Throwable s : e.getSuppressed()) {
                System.out.println("억제 예외: " + s.getMessage());
            }
        }
    }
}
알아두면 좋은 점

try 본문과 close() 모두 예외가 발생하면, 본문 예외가 주 예외가 되고 close 예외는 getSuppressed()에 저장됩니다.

자주 하는 실수

close()에서 예외를 던지면 복잡해집니다. 가능하면 close에서는 예외를 로깅만 하고 던지지 않는 것이 좋습니다.

05multi-catch와 예외 재던지기

여러 예외를 한 번에 잡는 multi-catch와 예외 재던지기 패턴입니다.

Java code

import java.io.*;
import java.sql.*;

public class MultiCatch {
    // multi-catch (Java 7+)
    static void handleMultiple(String input) {
        try {
            if (input.equals("io")) throw new IOException("I/O 에러");
            if (input.equals("num")) throw new NumberFormatException("숫자 에러");
            if (input.equals("sql")) throw new SQLException("DB 에러");
        } catch (IOException | NumberFormatException e) {
            // 여러 예외를 하나의 catch로
            System.out.println("처리: " + e.getClass().getSimpleName());
            // e는 암묵적 final — 재할당 불가
        } catch (SQLException e) {
            System.out.println("DB: " + e.getMessage());
        }
    }

    // 예외 래핑 후 재던지기
    static void process() {
        try {
            riskyOperation();
        } catch (IOException e) {
            // 원인 체인 유지하면서 래핑
            throw new RuntimeException("처리 실패", e);
        }
    }

    static void riskyOperation() throws IOException {
        throw new IOException("원본 에러");
    }

    public static void main(String[] args) {
        handleMultiple("io");
        handleMultiple("num");

        try {
            process();
        } catch (RuntimeException e) {
            System.out.println("예외: " + e.getMessage());
            System.out.println("원인: " + e.getCause().getMessage());
        }
    }
}
알아두면 좋은 점

multi-catch에서 상위-하위 예외를 함께 쓸 수 없습니다. IOException | Exception은 컴파일 에러입니다.

자주 하는 실수

예외를 래핑할 때 원인(cause)을 전달하지 않으면 디버깅 시 원본 스택 트레이스를 잃습니다. 항상 new RuntimeException(msg, cause)를 사용하세요.

06예외 계층 설계

애플리케이션의 예외 계층을 체계적으로 설계하는 방법입니다.

Java code

// 기본 비즈니스 예외
abstract class BusinessException extends RuntimeException {
    private final String errorCode;

    BusinessException(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    BusinessException(String errorCode, String message, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
    }

    String getErrorCode() { return errorCode; }
}

// 도메인별 예외 계층
class UserException extends BusinessException {
    UserException(String code, String msg) { super(code, msg); }
}

class UserNotFoundException extends UserException {
    UserNotFoundException(String userId) {
        super("USER_NOT_FOUND", "사용자를 찾을 수 없습니다: " + userId);
    }
}

class DuplicateUserException extends UserException {
    DuplicateUserException(String email) {
        super("USER_DUPLICATE", "이미 존재하는 이메일: " + email);
    }
}

// 사용
class UserService {
    void findUser(String id) {
        throw new UserNotFoundException(id);
    }

    public static void main(String[] args) {
        try {
            new UserService().findUser("123");
        } catch (UserNotFoundException e) {
            System.out.printf("[%s] %s%n", e.getErrorCode(), e.getMessage());
        } catch (BusinessException e) {
            System.out.println("비즈니스 에러: " + e.getMessage());
        }
    }
}
알아두면 좋은 점

에러 코드를 예외에 포함하면 API 응답이나 로깅에서 일관된 에러 식별이 가능합니다.

자주 하는 실수

예외 계층이 너무 깊으면 관리가 어렵습니다. 보통 2~3단계가 적당합니다. 과도한 세분화를 피하세요.

07커스텀 예외 만들기

도메인에 맞는 커스텀 예외를 올바르게 만드는 방법입니다.

Java code

// 좋은 커스텀 예외
class InsufficientBalanceException extends RuntimeException {
    private final long currentBalance;
    private final long requestedAmount;

    InsufficientBalanceException(long current, long requested) {
        super(String.format("잔액 부족: 현재 %d, 요청 %d", current, requested));
        this.currentBalance = current;
        this.requestedAmount = requested;
    }

    long getCurrentBalance() { return currentBalance; }
    long getRequestedAmount() { return requestedAmount; }
    long getShortfall() { return requestedAmount - currentBalance; }
}

class Account {
    private long balance;

    Account(long balance) { this.balance = balance; }

    void withdraw(long amount) {
        if (amount > balance) {
            throw new InsufficientBalanceException(balance, amount);
        }
        balance -= amount;
    }
}

class Main {
    public static void main(String[] args) {
        Account acc = new Account(10000);
        try {
            acc.withdraw(15000);
        } catch (InsufficientBalanceException e) {
            System.out.println(e.getMessage());
            System.out.printf("부족액: %d원%n", e.getShortfall());
        }
    }
}
알아두면 좋은 점

커스텀 예외에 진단에 필요한 컨텍스트 정보를 필드로 포함하세요. 로깅이나 에러 응답 생성에 유용합니다.

자주 하는 실수

모든 예외를 Exception으로 던지면 호출자가 적절히 처리할 수 없습니다. 의미 있는 커스텀 예외를 정의하세요.

08예외 번역(Exception Translation)

저수준 예외를 고수준 예외로 변환하는 예외 번역 패턴입니다.

Java code

import java.sql.*;

// 저수준 예외를 고수준으로 변환
class RepositoryException extends RuntimeException {
    RepositoryException(String msg, Throwable cause) { super(msg, cause); }
}

class ServiceException extends RuntimeException {
    ServiceException(String msg, Throwable cause) { super(msg, cause); }
}

class UserRepository {
    void save(String user) {
        try {
            // JDBC 호출 시뮬레이션
            if (user.isEmpty()) {
                throw new SQLException("제약 조건 위반");
            }
        } catch (SQLException e) {
            // SQL 예외 -> 리포지토리 예외로 번역
            throw new RepositoryException("사용자 저장 실패", e);
        }
    }
}

class UserService2 {
    private final UserRepository repo = new UserRepository();

    void createUser(String user) {
        try {
            repo.save(user);
        } catch (RepositoryException e) {
            // 리포지토리 예외 -> 서비스 예외로 번역
            throw new ServiceException("사용자 생성 실패", e);
        }
    }
}

class Main {
    public static void main(String[] args) {
        try {
            new UserService2().createUser("");
        } catch (ServiceException e) {
            System.out.println("서비스: " + e.getMessage());
            System.out.println("원인1: " + e.getCause().getMessage());
            System.out.println("원인2: " + e.getCause().getCause().getMessage());
        }
    }
}
알아두면 좋은 점

예외 번역으로 계층 간 추상화를 유지합니다. 컨트롤러에서 SQLException을 직접 알 필요가 없어집니다.

자주 하는 실수

번역 시 원인 예외(cause)를 생략하면 디버깅이 불가능합니다. 항상 원본 예외를 체이닝하세요.

09예외 체이닝

예외의 원인 체인을 활용하여 근본 원인을 추적합니다.

Java code

public class ExceptionChaining {
    // 근본 원인 추출 유틸리티
    static Throwable getRootCause(Throwable t) {
        Throwable root = t;
        while (root.getCause() != null && root.getCause() != root) {
            root = root.getCause();
        }
        return root;
    }

    // 예외 체인 출력
    static void printChain(Throwable t) {
        Throwable current = t;
        int depth = 0;
        while (current != null) {
            System.out.printf("%s[%d] %s: %s%n",
                "  ".repeat(depth),
                depth,
                current.getClass().getSimpleName(),
                current.getMessage());
            current = current.getCause();
            depth++;
        }
    }

    public static void main(String[] args) {
        try {
            try {
                try {
                    throw new java.io.IOException("디스크 가득 참");
                } catch (java.io.IOException e) {
                    throw new RuntimeException("파일 저장 실패", e);
                }
            } catch (RuntimeException e) {
                throw new IllegalStateException("주문 처리 실패", e);
            }
        } catch (IllegalStateException e) {
            System.out.println("=== 예외 체인 ===");
            printChain(e);
            System.out.println("\n근본 원인: " +
                getRootCause(e).getMessage());
        }
    }
}
알아두면 좋은 점

Throwable.getCause()로 원인 체인을 탐색합니다. 프레임워크에서는 ExceptionUtils.getRootCause()(Apache Commons)를 활용하세요.

자주 하는 실수

예외 체인에서 순환 참조가 발생하면 무한 루프입니다. getCause() != this 검사를 추가하세요.

10로깅 (SLF4J / Logback)

SLF4J 로깅 퍼사드와 Logback 구현체로 체계적인 로깅을 구현합니다.

Java code

// SLF4J + Logback 의존성:
// org.slf4j:slf4j-api:2.0.9
// ch.qos.logback:logback-classic:1.4.14

// import org.slf4j.Logger;
// import org.slf4j.LoggerFactory;

// SLF4J 표준 사용법 (의사코드)
class OrderService {
    // private static final Logger log =
    //     LoggerFactory.getLogger(OrderService.class);

    void processOrder(String orderId) {
        // log.info("주문 처리 시작: orderId={}", orderId);
        // log.debug("주문 상세: {}", orderDetail);
        // log.warn("재고 부족: product={}, stock={}", product, stock);
        // log.error("주문 처리 실패: orderId={}", orderId, exception);
    }
}

// java.util.logging (JDK 내장) 대안
import java.util.logging.*;

public class LoggingDemo {
    private static final Logger log =
        Logger.getLogger(LoggingDemo.class.getName());

    public static void main(String[] args) {
        // 레벨: SEVERE > WARNING > INFO > CONFIG > FINE > FINER > FINEST
        log.info("애플리케이션 시작");
        log.warning("메모리 사용량 높음");

        try {
            int result = 10 / 0;
        } catch (ArithmeticException e) {
            log.log(Level.SEVERE, "계산 오류", e);
        }

        // 조건부 로깅 (비용 절약)
        if (log.isLoggable(Level.FINE)) {
            log.fine("비용 큰 연산 결과: " + expensiveCalc());
        }
    }

    static String expensiveCalc() { return "결과"; }
}
알아두면 좋은 점

SLF4J의 {} 플레이스홀더는 문자열 연결보다 효율적입니다. 로그 레벨이 비활성이면 문자열 생성을 건너뜁니다.

자주 하는 실수

log.error("에러: " + e.getMessage())처럼 메시지만 기록하면 스택 트레이스를 잃습니다. 예외 객체를 마지막 인자로 전달하세요.

11어설션(assert)과 계약 프로그래밍

assert 키워드와 불변조건 검사로 프로그래밍 오류를 조기에 발견합니다.

Java code

public class AssertionDemo {
    // 사전 조건 (Precondition)
    static double sqrt(double x) {
        assert x >= 0 : "음수의 제곱근: " + x;
        return Math.sqrt(x);
    }

    // 사후 조건 (Postcondition)
    static int[] sort(int[] arr) {
        int[] result = arr.clone();
        java.util.Arrays.sort(result);

        // 정렬 검증
        assert isSorted(result) : "정렬 실패!";
        assert result.length == arr.length : "크기 불일치!";
        return result;
    }

    static boolean isSorted(int[] arr) {
        for (int i = 1; i < arr.length; i++) {
            if (arr[i] < arr[i - 1]) return false;
        }
        return true;
    }

    // 프로덕션에서는 Objects.requireNonNull 사용
    static void process(String input) {
        java.util.Objects.requireNonNull(input, "input은 null 불가");
        if (input.length() < 3) {
            throw new IllegalArgumentException("최소 3자 이상");
        }
        System.out.println("처리: " + input);
    }

    public static void main(String[] args) {
        // assert는 -ea 옵션으로 활성화
        // java -ea AssertionDemo
        System.out.println(sqrt(16));

        // 프로덕션용 검증
        process("Hello");
        // process(null); // NullPointerException
    }
}
알아두면 좋은 점

assert는 개발/테스트용입니다. 프로덕션에서는 Objects.requireNonNull()이나 IllegalArgumentException을 사용하세요.

자주 하는 실수

assert는 기본적으로 비활성입니다. -ea 플래그 없이 실행하면 검사가 생략됩니다. 비즈니스 로직에 assert를 사용하지 마세요.

12검증 패턴과 Validation

입력 검증을 체계적으로 수행하는 패턴과 에러 수집 전략입니다.

Java code

import java.util.*;

public class ValidationPattern {
    // 검증 결과를 수집하는 패턴
    record ValidationResult(boolean valid, List<String> errors) {
        static ValidationResult ok() {
            return new ValidationResult(true, List.of());
        }
        static ValidationResult fail(List<String> errors) {
            return new ValidationResult(false, List.copyOf(errors));
        }
    }

    // 검증기 합성
    interface Validator<T> {
        Optional<String> validate(T value);

        static <T> ValidationResult validateAll(T value,
                List<Validator<T>> validators) {
            List<String> errors = validators.stream()
                .map(v -> v.validate(value))
                .filter(Optional::isPresent)
                .map(Optional::get)
                .toList();
            return errors.isEmpty()
                ? ValidationResult.ok()
                : ValidationResult.fail(errors);
        }
    }

    record UserForm(String name, String email, int age) {}

    public static void main(String[] args) {
        List<Validator<UserForm>> validators = List.of(
            form -> form.name().isBlank()
                ? Optional.of("이름은 필수입니다") : Optional.empty(),
            form -> !form.email().contains("@")
                ? Optional.of("이메일 형식이 올바르지 않습니다") : Optional.empty(),
            form -> form.age() < 0 || form.age() > 150
                ? Optional.of("나이가 유효하지 않습니다") : Optional.empty()
        );

        UserForm form = new UserForm("", "invalid", -1);
        ValidationResult result = Validator.validateAll(form, validators);

        if (!result.valid()) {
            result.errors().forEach(e -> System.out.println("- " + e));
        }
    }
}
알아두면 좋은 점

모든 검증 에러를 수집하면 사용자가 한 번에 수정할 수 있습니다. 첫 번째 에러에서 멈추는 것보다 UX가 좋습니다.

자주 하는 실수

검증 로직을 여러 곳에 분산시키면 일관성이 떨어집니다. 중앙화된 검증기를 만들고 재사용하세요.

정리하며

  • 호출자가 실제로 복구 가능한 상황에만 checked 예외를 쓰고 나머지는 unchecked로 올립니다
  • 계층 경계에서 예외를 번역하되 cause를 반드시 연결해 원인 스택을 보존합니다
  • try-with-resources를 쓴 곳에서는 getSuppressed()로 close() 실패를 함께 기록합니다
  • 로거에는 예외를 문자열로 붙이지 말고 마지막 인자로 Throwable을 그대로 전달합니다

더 깊이 들어가고 싶다면 Java 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.