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 강제 |
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 발생try과finally에서 모두 예외가 발생하면,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);
}
}
설계 원칙:
- 복구 가능 → checked exception
- 프로그래밍 오류 → unchecked exception (RuntimeException 하위)
- 항상 의미 있는 메시지와 cause 포함
- 예외 타입 이름은 문제 상황을 설명 (
InvalidEmailException, notEmailError)
예외 처리 안티패턴 — 하지 말아야 할 것들
// 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는 잡지 않는다.
OutOfMemoryError를catch하는 것은 보통 문제를 더 악화시킨다 — JVM 자체가 비정상 상태다.
생각해 볼 문제
RuntimeException을 상속받은 커스텀 예외를throws로 선언하면 컴파일러가 강제하는가? 강제하지 않는다면 왜 선언하는 것이 유용한가?- try-with-resources에서
close()가IOException을 던지지만catch에서 잡지 않으면 어떻게 되는가? - Java의 checked exception이 Kotlin, Scala에서 사라진 이유는 무엇인가? checked exception의 장단점을 정리해 보자.
Thread.setDefaultUncaughtExceptionHandler를 설정하면 잡히지 않은 unchecked exception을 어떻게 처리할 수 있는가?finally블록에서return을 실행하면 어떤 일이 일어나는가? (금기 사항인 이유를 설명하라.)
참고
- JLS §11 - Exceptions - 접근 2026-07-10
- JLS §14.20.3 - try-with-resources - 접근 2026-07-10
- Throwable API (Java 25) - 접근 2026-07-10
- AutoCloseable API (Java 25) - 접근 2026-07-10
- Exception API (Java 25) - 접근 2026-07-10
'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 |
