throws Exception이 최악의 예외 처리인 이유 — 예외 계층과 자원 관리

메서드 시그니처에 throws Exception이라고 적힌 코드를 본 적이 있는가. "뭔가 예외가 날 수 있는데, 구체적으로 뭔지는 모르겠고, 어차피 호출하는 쪽에서 처리해"라는 뜻이다. 이것은 예외 처리가 아니라 예외 무시다. 호출하는 쪽은 catch (Exception e)를 강제받고, 정작 복구할 수 있는 정보는 없다.

"복구할 수 있으면 checked, 못 하면 unchecked." — 이 한 문장이 Java 예외 시스템의 핵심이다. (JLS §11) 이 글은 checked vs unchecked의 선택 기준, try-with-resources의 자동 자원 관리, 그리고 "예외를 삼키면 안 되는 이유"를 풀어간다.

예외 계층 — Throwable의 세 갈래

flowchart TD
    T["Throwable"] --> E["Error<br/>(JVM 수준 복구 불가)"]
    T --> EX["Exception"]
    EX --> RE["RuntimeException<br/>(unchecked)"]
    EX --> CE["기타 Exception<br/>(checked)"]
타입 성격 예시 처리 의무
Error JVM 수준 심각한 문제. 복구 불가. OutOfMemoryError, StackOverflowError 잡지 않는 것이 원칙
RuntimeException (unchecked) 프로그래밍 오류. 코드 수정으로 예방. NullPointerException, IllegalArgumentException, IndexOutOfBoundsException 컴파일러가 강제 안 함
기타 Exception (checked) 외부 조건. 복구 가능. IOException, SQLException, ClassNotFoundException 컴파일러가 try-catch 또는 throws 강제

(Throwable API)

checked vs unchecked — 언제 무엇을?

checked exception은 호출하는 쪽이 복구할 수 있는 상황에 쓴다:

  • 파일이 없음 → 다른 파일로 재시도
  • 네트워크 연결 실패 → 재시도 또는 대체 경로
  • DB 연결 끊김 → 재연결

unchecked exception프로그래밍 오류, 즉 코드를 수정하지 않으면 복구할 수 없는 상황에 쓴다:

  • null 참조 (NullPointerException)
  • 잘못된 인자 (IllegalArgumentException)
  • 배열 범위 초과 (IndexOutOfBoundsException)

checked exception의 남용은 Java 커뮤니티의 오랜 논쟁거리다. Spring은 모든 checked exception을 DataAccessException(unchecked)으로 래핑하여 전파한다. 핵심 원칙: 호출자가 의미 있게 복구할 수 없으면 unchecked로 만든다. (Effective Java Item 71)

왜 두 종류로 나뉘어 있는가 — 설계 이유: Java 설계자는 예외를 두 부류로 나눴다 — (1) 호출자가 복구할 수 있는 예외는 checked로 강제하여 대응 코드를 잊지 않게, (2) 복구 불가능한 프로그래밍 오류는 unchecked로 놓아서 모든 메서드에 throws를 붙이는 것을 피하자. 하지만 현대에서는 "대부분의 예외는 복구 불가능하다"는 경향이 강해져 — Spring, Hibernate 등 주요 프레임워크가 checked를 unchecked로 래핑하는 패턴이 표준이 됐다.

비유: checked exception은 택배 수령 시 서명 요구다 — 부재중이면 다시 와야 하고, 반드시 처리해야 한다. unchecked exception은 스팸 전화다 — 받아서 처리할 수도 있지만, 무시해도 된다. Spring은 "대부분의 DB 예외는 스팸(복구 불가)이므로 서명 요구를 없애자"라고 결정한 것이다.

try-catch-finally — 전통적 예외 처리

// Java 25 — 전통적 패턴 (자원 누수 위험!)
FileReader reader = null;
try {
    reader = new FileReader("data.txt");
    // 파일 읽기
} catch (IOException e) {
    System.err.println("읽기 실패: " + e.getMessage());
} finally {
    if (reader != null) {
        try {
            reader.close();   // close()도 IOException을 던질 수 있음
        } catch (IOException e) {
            // 닫기 실패 — 무시? 로그? 또 다른 try-catch?
        }
    }
}

이 패턴의 문제:

  • finally 블록이 지저분하다
  • close() 호출 자체가 예외를 던질 수 있어 중첩 try-catch 발생
  • tryfinally에서 모두 예외가 발생하면, finally의 예외가 try의 예외를 덮어쓴다 — 원인 추적 불가

try-with-resources (Java 7+) — 자원 관리의 해답

AutoCloseable 인터페이스를 구현한 객체를 try 헤더에서 선언하면, 블록 종료 시 자동으로 close()가 호출된다. (JLS §14.20.3)

// Java 25 — try-with-resources (권장 패턴)
try (FileReader reader = new FileReader("data.txt");
     BufferedReader br = new BufferedReader(reader)) {
    String line = br.readLine();
    System.out.println(line);
} catch (IOException e) {
    System.err.println("읽기 실패: " + e.getMessage());
}
// reader.close()와 br.close()가 자동 호출됨 — finally 불필요

try-with-resources가 finally보다 나은 점

// Java 25 — suppressed exception 처리
try (FileReader reader = new FileReader("data.txt")) {
    // 여기서 IOException(원인) 발생
    throw new IOException("읽기 중 에러");
} catch (IOException e) {
    // close()에서도 예외가 발생했다면,
    // close()의 예외는 e.getSuppressed()로 접근 가능
    System.err.println(e.getMessage());   // "읽기 중 에러"
    for (Throwable suppressed : e.getSuppressed()) {
        System.err.println("자원 해제 중: " + suppressed.getMessage());
    }
}

try-with-resources는:

  • 본문과 close()에서 모두 예외가 발생해도, 본문의 예외가 메인으로 보존되고 close()의 예외는 getSuppressed()로 숨겨진다(suppressed exception).
  • 선언 순서의 역순으로 close() 가 호출된다 — br.close() 먼저, 그 다음 reader.close().

AutoCloseable은 단 하나의 메서드 void close() throws Exception을 가진다. (AutoCloseable API) 직접 구현한 자원 클래스에 AutoCloseable을 구현하면 try-with-resources를 쓸 수 있다.

여러 자원 — 세미콜론으로 구분

// Java 25 — 여러 자원을 한 번에
try (var input = new FileInputStream("in.txt");
     var output = new FileOutputStream("out.txt")) {
    input.transferTo(output);   // Java 9+ 메서드
}
// output.close() → input.close() 순서로 자동 해제

multi-catch (Java 7+) — 여러 예외를 하나의 catch로

// Java 25 — multi-catch
try {
    riskyOperation();
} catch (IOException | SQLException e) {
    // IOException 또는 SQLException을 같은 로직으로 처리
    log.error("외부 리소스 오류", e);
    throw new ServiceException("일시적 오류", e);
}

|로 여러 예외 타입을 하나의 catch에서 처리한다. 변수 e의 컴파일 타임 타입은 공통 조상(여기서는 Exception)이다.

예외 연결(chaining) — 원인 보존

하위 계층의 예외를 상위 계층의 예외로 감쌀 때, 원인(cause)을 보존한다:

// Java 25 — 예외 연결
try {
    dbConnection.execute(sql);
} catch (SQLException e) {
    throw new ServiceException("사용자 조회 실패", e);   // e를 cause로
}
// Java 25 — cause 조회
catch (ServiceException e) {
    System.err.println(e.getMessage());        // "사용자 조회 실패"
    e.printStackTrace();
    // Caused by: java.sql.SQLException: connection timeout
    //     at ...
}

예외를 감쌀 때 원인을 보존하지 않으면 throw new ServiceException("실패")처럼 근본 원인이 사라진다. 로그에는 "실패"만 남고 stacktrace가 없어 디버깅이 불가능해진다. 항상 cause를 포함하여 예외를 감싼다.

커스텀 예외 설계

// Java 25 — 커스텀 예외
// 복구 불가능한 비즈니스 규칙 위반 → unchecked
public class InvalidEmailException extends RuntimeException {
    public InvalidEmailException(String email) {
        super("유효하지 않은 이메일: " + email);
    }
}

// 외부 서비스 일시적 실패 → checked (복구 가능: 재시도)
public class ServiceUnavailableException extends Exception {
    public ServiceUnavailableException(String service, Throwable cause) {
        super(service + " 일시적 사용 불가", cause);
    }
}

설계 원칙:

  1. 복구 가능 → checked exception
  2. 프로그래밍 오류 → unchecked exception (RuntimeException 하위)
  3. 항상 의미 있는 메시지와 cause 포함
  4. 예외 타입 이름은 문제 상황을 설명 (InvalidEmailException, not EmailError)

예외 처리 안티패턴 — 하지 말아야 할 것들

// Java 25 — 안티패턴 1: 빈 catch (예외 삼키기)
try {
    riskyOperation();
} catch (Exception e) {
    // 아무것도 안 함 — 최악의 패턴. 에러가 발생했는데 아무도 모른다
}

// Java 25 — 안티패턴 2: catch 후 로그만 (여전히 삼킴)
try {
    riskyOperation();
} catch (Exception e) {
    log.error("에러", e);
    // throw 없이 다음 줄로 진행 — 에러가 있었는데 정상 흐름으로 복귀
}

// Java 25 — 안티패턴 3: catch Exception (너무 넓은 범위)
try {
    fileOperation();
    listOperation();
    numberOperation();
} catch (Exception e) {
    // 어떤 연산에서 어떤 예외가 났는지 구분 불가 → 복구 불가
}

// Java 25 — 안티패턴 4: finally에서 return
try {
    return computeValue();
} finally {
    return defaultValue;   // try의 return을 덮어씀! computeValue() 결과가 사라짐
}

올바른 패턴

// Java 25 — 올바른 예외 처리
try {
    riskyOperation();
} catch (SpecificException e) {
    // 1. 구체적인 예외 타입 catch
    log.error("구체적 에러 메시지: {}", context, e);
    // 2. 복구 가능하면 복구
    return fallback();
    // 3. 복구 불가면 새 예외로 감싸서 전파 (cause 보존)
    // throw new AppException("의미 있는 메시지", e);
}

자주 발생하는 unchecked exception 정리

예외 원인 예방
NullPointerException null 참조에 메서드 호출 Optional, null 검사, Objects.requireNonNull
IllegalArgumentException 잘못된 인자 값 생성자/setter에서 검증
IllegalStateException 객체가 부적절한 상태 상태 전이 검증
ClassCastException 잘못된 타입 캐스트 instanceof 확인 후 캐스트
ArithmeticException 정수 나눗셈 by zero 분母 확인
IndexOutOfBoundsException 배열/리스트 범위 초과 length/size() 확인

NullPointerException(NPE)은 Java에서 가장 흔한 런타임 예외다. Java 14+의 helpful NullPointerException은 어떤 참조가 null이었는지 정확히 알려준다. (JEP-358) 예: Cannot invoke "String.length()" because "name" is null.

실습 — try-with-resources로 자원 관리 체감

// Java 25 — ResourceDemo.java
public class ResourceDemo implements AutoCloseable {
    private final String name;

    public ResourceDemo(String name) {
        this.name = name;
        System.out.println(name + " 열림");
    }

    @Override
    public void close() {
        System.out.println(name + " 닫힘 (자동)");
    }

    public static void main(String[] args) {
        // try-with-resources로 두 자원 관리
        try (var r1 = new ResourceDemo("리소스A");
             var r2 = new ResourceDemo("리소스B")) {
            System.out.println("작업 수행 중...");
        }
        // 역순으로 닫힘: 리소스B → 리소스A
    }
}
java ResourceDemo.java
리소스A 열림
리소스B 열림
작업 수행 중...
리소스B 닫힘 (자동)
리소스A 닫힘 (자동)

확인할 것: try 블록이 끝나면 자동으로 역순으로 close()가 호출된다. finally 없이 자원이 안전하게 해제된다.

요약 — 이 글의 결론

  • checked exception은 복구 가능한 상황, unchecked는 프로그래밍 오류에 쓴다. 모든 예외를 checked로 만들면 호출자가 무의미한 catch로 둘러싸게 된다. 복구할 수 없으면 unchecked로 만든다.
  • try-with-resources를 항상 쓴다. AutoCloseable 자원은 finally로 수동 닫지 말고 try(...) 선언부에서 관리한다. suppressed exception 보존, 역순 해제, 코드 간결성 — 세 가지가 모두 개선된다.
  • 예외를 감쌀 때 원인(cause)을 보존한다. throw new AppException("msg", e)e 없이 감싸면 근본 원인이 사라진다.
  • throws Exception은 예외 처리가 아니다. 구체적인 예외 타입을 선언하거나, unchecked로 래핑하여 전파한다. "모든 예외"를 선언하는 것은 호출자에게 아무 정보도 주지 않는다.
  • Error는 잡지 않는다. OutOfMemoryErrorcatch하는 것은 보통 문제를 더 악화시킨다 — JVM 자체가 비정상 상태다.

생각해 볼 문제

  1. RuntimeException을 상속받은 커스텀 예외를 throws로 선언하면 컴파일러가 강제하는가? 강제하지 않는다면 왜 선언하는 것이 유용한가?
  2. try-with-resources에서 close()IOException을 던지지만 catch에서 잡지 않으면 어떻게 되는가?
  3. Java의 checked exception이 Kotlin, Scala에서 사라진 이유는 무엇인가? checked exception의 장단점을 정리해 보자.
  4. Thread.setDefaultUncaughtExceptionHandler를 설정하면 잡히지 않은 unchecked exception을 어떻게 처리할 수 있는가?
  5. finally 블록에서 return을 실행하면 어떤 일이 일어나는가? (금기 사항인 이유를 설명하라.)

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 11. generics  (0) 2026.07.12
Java - 10. collections  (0) 2026.07.12
Java - 08. record sealed enum  (0) 2026.07.12
Java - 07. interface and abstract  (0) 2026.07.12
Java - 06. inheritance and polymorphism  (0) 2026.07.12