자바 예외처리 완전 정복(try-catch, throws, 커스텀 예외) — Java Exception Handling

개념 요약

  • 예외(Exception) 계층: 검사 예외(Checked)와 비검사 예외(Unchecked, RuntimeException)로 나뉨. ThrowableException/Error 트리.
  • 처리 방식: try-catch-finally, throws 선언, try-with-resources(자동 자원 해제), 멀티 캐치(catch (A | B e)).
  • 전파(Propagation)와 래핑(Wrapping): 상위로 던지기(throw), 원인 보존(new XxxException("msg", cause)), 재던지기(rethrow).
  • 커스텀 예외(Custom Exception): 도메인 에러를 의도적으로 표현. 규칙: 메시지/원인 포함 생성자 제공.
  • 베스트 프랙티스: “복구 가능하면 잡고(recover), 불가능하면 전파”. 로그 중복 금지, 예외에 의미 있는 메시지와 컨텍스트 담기.

배경

  • 자바에서 예외는 정상 흐름을 벗어난 오류 상황을 객체로 다루는 메커니즘이야. 강제 처리되는 검사 예외와 선택 처리되는 비검사 예외가 있어.
  • I/O, 네트워크 같은 외부 자원은 실패 가능성이 높아서 예외 처리 설계가 중요해.
  • 실제 서비스에서는 “어디서 잡고 어디서 넘길지”의 경계 설정이 품질을 좌우해.

왜 필요한가

  • 실패를 안전하게 격리하고, 자원 누수를 막고(파일/DB 커넥션), 사용자에게 이해 가능한 에러로 전달하기 위해.
  • 원인 파악(디버깅)과 관측성(로그/메트릭)을 높이고, 복구 가능한 시나리오를 코드로 표현하기 위해.

예제 코드

1) try-catch-finally + 커스텀 예외 + 원인 래핑

// 도메인용 커스텀 검사 예외
class InvalidOrderException extends Exception {
    public InvalidOrderException(String message) { super(message); }
    public InvalidOrderException(String message, Throwable cause) { super(message, cause); }
}

class OrderService {
    // 외부 시스템 호출을 흉내 낸 메서드
    private String fetchCustomerName(long customerId) throws Exception {
        if (customerId <= 0) throw new IllegalArgumentException("customerId must be positive");
        // 네트워크/DB 실패 가정
        if (customerId == 13) throw new RuntimeException("Downstream timeout");
        return "Alice";
    }

    public String placeOrder(long customerId, int quantity) throws InvalidOrderException {
        // 입력 검증
        if (quantity <= 0) {
            throw new InvalidOrderException("수량은 1개 이상이어야 해요: quantity=" + quantity);
        }

        try {
            String name = fetchCustomerName(customerId); // 예외 발생 가능
            // 비즈니스 로직...
            return "Order accepted for " + name + " x " + quantity;
        } catch (IllegalArgumentException e) {
            // 개발자 실수/계약 위반 → 즉시 의미 있는 검사 예외로 변환
            throw new InvalidOrderException("잘못된 고객 ID: " + customerId, e);
        } catch (RuntimeException e) {
            // 외부 시스템 실패를 도메인 예외로 래핑하여 상위 계층에 전달
            throw new InvalidOrderException("고객 정보를 불러오지 못했어요(customerId=" + customerId + ")", e);
        } finally {
            // 자원 정리(여기서는 예시용 로그)
            System.out.println("placeOrder() finished (cleanup)");
        }
    }
}

public class App1 {
    public static void main(String[] args) {
        OrderService s = new OrderService();
        try {
            System.out.println(s.placeOrder(10L, 2));
            System.out.println(s.placeOrder(13L, 1)); // Downstream 실패 케이스
        } catch (InvalidOrderException e) {
            // 단 한 곳에서 의미 있게 로깅/사용자 응답 변환(중복 로그 지양)
            System.err.println("[ORDER-ERR] " + e.getMessage());
            Throwable cause = e.getCause();
            if (cause != null) cause.printStackTrace();
        }
    }
}

2) try-with-resources + 멀티 캐치 + 재던지기(rethrow)

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

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

public class App2 {
    // 설정 파일을 읽어 첫 줄을 반환
    static String loadConfigFirstLine(String path) {
        try (BufferedReader br = new BufferedReader(new FileReader(path))) {
            // 자원은 블록 종료 시 자동 close (예외 발생해도 보장)
            return br.readLine();
        } catch (IOException | SecurityException e) {
            // 서로 다른 예외를 한 번에 처리(멀티 캐치)
            // 필요한 컨텍스트를 메시지에 추가하고 비검사 예외로 래핑하여 전파
            throw new ConfigLoadException("설정 로드 실패: path=" + path, e);
        }
    }

    public static void main(String[] args) {
        // 존재하지 않는 파일로 실패 유도
        System.out.println(loadConfigFirstLine("config/app.conf"));
    }
}

동작 원리

  • 검사 예외(Checked Exception): 메서드 시그니처에 throws로 선언하거나 try-catch로 반드시 처리해야 해. 컴파일 타임에 강제하므로 호출자가 복구 전략을 고민하게 만들지.
  • 비검사 예외(Unchecked = RuntimeException 하위): 선언/처리 강제 없음. 주로 프로그래밍 오류(계약 위반, Null, IllegalArgument)나 복구 불가 상황에 사용.
  • finally/try-with-resources: 자원 정리 보장 장치. try-with-resourcesAutoCloseable 구현체를 자동으로 close() 호출해 누수를 예방해.
  • 멀티 캐치: catch (A | B e)로 예외 형제가 여러 개일 때 중복 코드를 줄여. 단, 상위 타입과 하위 타입을 동시에 나열하면 컴파일 에러.
  • 래핑과 전파: 낮은 계층의 기술적 예외를 도메인 예외로 변환해 상위 계층에 “비즈니스 의미”를 전달. 이때 원인(cause)을 반드시 보존해서 디버깅 가능하게 만들기.
  • 로깅 전략: “한 번만, 의미 있게”. 하위에서 잡고 다시 던질 때 이미 로그를 찍었다면 상위에서 중복 로깅 금지(노이즈 감소, 원인 탐색 용이).
  • 경계 설정: 컨트롤러/핸들러(최상위)에서 사용자 메시지 변환, 서비스/도메인에서는 의미 있는 예외 모델링, 리포지토리/게이트웨이는 기술 예외를 도메인 예외로 래핑.

대안과 선택 기준

  • 결과형 반환(Either/Result, Optional): 예외 대신 실패를 값으로 표현. 빈번한 실패가 “정상 흐름”일 때(예: 조회 결과 없음) 적합. 단, 자바 표준 라이브러리에는 내장 Result가 없어 Vavr 등 서드파티 사용 검토.
  • 검사 vs 비검사 선택: 호출자가 복구해야 하는 시나리오(재시도/대안 경로)가 명확하면 검사 예외, 그렇지 않으면 비검사 예외가 보통 더 단순.

요약

  • 무엇: 자바 예외는 실패를 객체로 다루는 메커니즘. try-catch-finally/throws/try-with-resources로 처리하고, 멀티 캐치와 래핑으로 중복/누수를 줄여.
  • 왜: 자원 누수 방지, 복구 가능한 실패 처리, 의미 있는 사용자/로그 메시지 제공, 디버깅 가능성 확보를 위해.
  • 어떻게: 하위에서 기술 예외 → 도메인 예외로 변환, 상위에서 일관되게 응답 변환. 로깅은 한 번만, 메시지는 구체적으로, cause 보존 필수.
  • 이어서 보면 좋은 주제: 스프링 @ControllerAdvice/ExceptionHandler로 전역 예외 처리, 레코드/Sealed 클래스 기반 에러 모델링.

'Backend > Java' 카테고리의 다른 글

[JAVA] 클래스  (0) 2025.09.15
[JAVA] 반복문  (0) 2025.09.08
[JAVA] 변수  (0) 2025.09.08
[Java] 기본형 vs 참조형  (1) 2025.02.11
스파르타 코딩 클럽 SQL 1주차  (0) 2022.06.28

+ Recent posts