공공부하자개발 · 영어 학습 노트
자바
중급객체지향과 코어 라이브러리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정리‹ 이전다음 ›

3. 코드 예제

예제 1: finally와 return의 실행 순서

return이 먼저 평가되고 finally가 실행된 뒤 반환되며, finally의 return은 예외를 삼킨다는 것을 보여준다.

java
public class Main {
    static int normalReturn() {
        int x = 1;
        try {
            System.out.println("try");
            return x;                       // 반환값 1이 여기서 확정
        } finally {
            x = 99;                         // 반환값에 영향 없음
            System.out.println("finally (x=" + x + ")");
        }
    }

    @SuppressWarnings("finally")
    static int finallyReturn() {
        try {
            throw new IllegalStateException("사라질 예외");
        } finally {
            return -1;                      // 예외를 삼키고 -1 반환 — 절대 하지 말 것
        }
    }

    public static void main(String[] args) {
        System.out.println("결과: " + normalReturn());
        System.out.println("결과: " + finallyReturn());
        // 출력:
        // try
        // finally (x=99)
        // 결과: 1
        // 결과: -1
    }
}

예제 2: try-with-resources — 닫힘 순서와 suppressed 예외

여러 자원이 역순으로 닫히고, 본문 예외와 close 예외가 모두 보존됨을 보여준다.

java
class Resource implements AutoCloseable {
    private final String name;
    private final boolean failOnClose;

    Resource(String name, boolean failOnClose) {
        this.name = name;
        this.failOnClose = failOnClose;
        System.out.println(name + " 열림");
    }

    @Override
    public void close() {
        System.out.println(name + " 닫힘");
        if (failOnClose) throw new IllegalStateException(name + " close 실패");
    }
}

public class Main {
    public static void main(String[] args) {
        try (Resource db = new Resource("DB", true);
             Resource file = new Resource("FILE", false)) {
            System.out.println("작업 중...");
            throw new RuntimeException("본문 예외");
        } catch (RuntimeException e) {
            System.out.println("잡힘: " + e.getMessage());
            for (Throwable s : e.getSuppressed()) {
                System.out.println("  suppressed: " + s.getMessage());
            }
        }
        // 출력:
        // DB 열림
        // FILE 열림
        // 작업 중...
        // FILE 닫힘
        // DB 닫힘
        // 잡힘: 본문 예외
        //   suppressed: DB close 실패
    }
}

예제 3: 비즈니스 예외 계층과 에러 코드

에러 코드 enum을 가진 추상 부모, HTTP 상태 단위의 중간 계층, 문맥 정보를 담은 구체 예외를 설계한다.

java
enum ErrorCode {
    ORDER_NOT_FOUND(404, "주문을 찾을 수 없습니다"),
    INSUFFICIENT_STOCK(400, "재고가 부족합니다"),
    PAYMENT_DECLINED(502, "결제가 거절되었습니다");

    final int status;
    final String defaultMessage;
    ErrorCode(int status, String defaultMessage) { this.status = status; this.defaultMessage = defaultMessage; }
}

abstract class BusinessException extends RuntimeException {
    private final ErrorCode code;

    protected BusinessException(ErrorCode code, String detail) {
        super(code.defaultMessage + " (" + detail + ")");
        this.code = code;
    }

    protected BusinessException(ErrorCode code, String detail, Throwable cause) {
        super(code.defaultMessage + " (" + detail + ")", cause);
        this.code = code;
    }

    public ErrorCode getCode() { return code; }
}

class OrderNotFoundException extends BusinessException {
    OrderNotFoundException(String orderId) {
        super(ErrorCode.ORDER_NOT_FOUND, "orderId=" + orderId);
    }
}

class InsufficientStockException extends BusinessException {
    InsufficientStockException(String sku, int requested, int available) {
        super(ErrorCode.INSUFFICIENT_STOCK, "sku=" + sku + " 요청=" + requested + " 재고=" + available);
    }
}

class PaymentDeclinedException extends BusinessException {
    PaymentDeclinedException(long amount, Throwable cause) {
        super(ErrorCode.PAYMENT_DECLINED, "amount=" + amount, cause);
    }
}

public class Main {
    static void handle(Runnable action) {
        try {
            action.run();
        } catch (BusinessException e) {    // 글로벌 핸들러: 부모 하나로 전부 잡음
            System.out.println("[" + e.getCode().status + "] " + e.getCode() + ": " + e.getMessage()
                + (e.getCause() != null ? " / cause=" + e.getCause().getMessage() : ""));
        }
    }

    public static void main(String[] args) {
        handle(() -> { throw new OrderNotFoundException("O-404"); });
        handle(() -> { throw new InsufficientStockException("SKU-1", 5, 2); });
        handle(() -> { throw new PaymentDeclinedException(30_000, new java.io.IOException("PG timeout")); });
        // 출력:
        // [404] ORDER_NOT_FOUND: 주문을 찾을 수 없습니다 (orderId=O-404)
        // [400] INSUFFICIENT_STOCK: 재고가 부족합니다 (sku=SKU-1 요청=5 재고=2)
        // [502] PAYMENT_DECLINED: 결제가 거절되었습니다 (amount=30000) / cause=PG timeout
    }
}

예제 4: 계층 경계에서의 예외 전환

리포지토리의 checked 예외를 서비스 계층 예외로 전환하면서 cause를 보존하고, Caused by: 체인이 어떻게 남는지 보여준다.

java
import java.sql.SQLException;

class DataAccessException extends RuntimeException {
    DataAccessException(String message, Throwable cause) { super(message, cause); }
}

class OrderRepository {
    String findById(String id) throws SQLException {          // 하위 계층: checked
        throw new SQLException("Connection refused: db-primary:5432");
    }
}

class OrderService {
    private final OrderRepository repo = new OrderRepository();

    String getOrder(String id) {
        try {
            return repo.findById(id);
        } catch (SQLException e) {
            throw new DataAccessException("주문 조회 실패: orderId=" + id, e);   // 전환 + 체이닝
        }
    }
}

public class Main {
    public static void main(String[] args) {
        try {
            new OrderService().getOrder("O-1");
        } catch (DataAccessException e) {
            System.out.println(e.getMessage());
            System.out.println("원인: " + e.getCause().getClass().getSimpleName() + " - " + e.getCause().getMessage());
        }
        // 출력:
        // 주문 조회 실패: orderId=O-1
        // 원인: SQLException - Connection refused: db-primary:5432
        //
        // (e.printStackTrace()를 하면 아래처럼 Caused by: 가 붙어 나온다)
        // DataAccessException: 주문 조회 실패: orderId=O-1
        //     at OrderService.getOrder(Main.java:...)
        // Caused by: java.sql.SQLException: Connection refused: db-primary:5432
        //     at OrderRepository.findById(Main.java:...)
    }
}

예제 5: 흐름 제어용 예외의 비용

예외로 루프를 끝내는 코드와 정상 분기 코드의 실행 시간을 비교해 예외 생성이 얼마나 비싼지 보여준다.

java
public class Main {
    static class EndOfArray extends RuntimeException {
        EndOfArray() { super("end"); }               // 매번 새 객체 + 스택트레이스 캡처
    }

    static long withException(int[] arr) {
        long sum = 0;
        try {
            for (int i = 0; ; i++) {
                if (i == arr.length) throw new EndOfArray();   // 끝을 예외로 알림
                sum += arr[i];
            }
        } catch (EndOfArray e) {
            return sum;
        }
    }

    static long withBranch(int[] arr) {
        long sum = 0;
        for (int i = 0; i < arr.length; i++) sum += arr[i];
        return sum;
    }

    public static void main(String[] args) {
        int[] arr = new int[10];
        int loops = 200_000;

        long t0 = System.nanoTime();
        for (int i = 0; i < loops; i++) withException(arr);
        long exceptionMs = (System.nanoTime() - t0) / 1_000_000;

        t0 = System.nanoTime();
        for (int i = 0; i < loops; i++) withBranch(arr);
        long branchMs = (System.nanoTime() - t0) / 1_000_000;

        System.out.println("예외 방식: " + exceptionMs + "ms");
        System.out.println("분기 방식: " + branchMs + "ms");
        // 출력 (환경마다 다름, 수십 배 차이가 일반적):
        // 예외 방식: 180ms
        // 분기 방식: 2ms
    }
}

예외 객체를 만들 때 fillInStackTrace()가 호출 스택 전체를 캡처한다. 이 비용이 예외가 비싼 주된 이유다.

참고로 arr[i]의 ArrayIndexOutOfBoundsException 같은 JVM 내장 예외는 HotSpot이 "자주 발생하는 곳"에서 스택트레이스 없는 객체를 재사용하는 최적화(OmitStackTraceInFastThrow)를 해서 벤치마크 차이가 작게 나올 수 있다. 그래서 위 예제는 직접 new로 던진다.

운영에서 이 최적화가 걸리면 스택트레이스가 사라져 원인 추적이 어려워지므로, 이것 역시 예외를 흐름 제어에 쓰면 안 되는 이유다.

예제 직접 실행

아래 폴더를 JDK 21 로 컴파일하고 실행합니다.

cd java-src\intermediate\03_exception
javac -encoding UTF-8 *.java && java Main
코드 예제
  • 예제 1: finally와 return의 실행 순서
  • 예제 2: try-with-resources — 닫힘 순서와 suppressed 예외
  • 예제 3: 비즈니스 예외 계층과 에러 코드
  • 예제 4: 계층 경계에서의 예외 전환
  • 예제 5: 흐름 제어용 예외의 비용
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제