[☕JAVA] 예외 처리(Exception Handling)
“예외 처리는 그냥 try-catch 감싸면 되는 거 아닌가요?” 면접에서 이렇게 생각했다간 Checked/Unchecked 차이, 예외 계층 구조를 물어보는 순간 막힌다. 이론적으로 한 번 제대로 정리해보자!
Java 예외 계층 구조 🌳
Java에서 예외는 아래 계층 구조를 따른다. 이 트리를 머릿속에 넣어두면 이후 내용이 훨씬 쉽게 이해된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Throwable
├── Error (JVM 수준 심각 오류 — 복구 불가)
│ ├── OutOfMemoryError 힙 메모리 부족
│ ├── StackOverflowError 무한 재귀
│ └── ...
└── Exception (프로그램 수준 예외 — 복구 가능)
├── RuntimeException (Unchecked — 컴파일러가 강제하지 않음)
│ ├── NullPointerException
│ ├── IllegalArgumentException
│ ├── ArrayIndexOutOfBoundsException
│ ├── ClassCastException
│ └── ...
└── (그 외 Exception 직접 상속) (Checked — 컴파일러가 강제함)
├── IOException
│ └── FileNotFoundException
├── SQLException
└── ClassNotFoundException
Throwable 클래스 해부 🔍
Throwable은 throw 키워드로 던질 수 있는 모든 객체의 최상위 클래스이다.
1
2
throw new String("오류!"); // ❌ 컴파일 에러! String은 Throwable이 아님
throw new RuntimeException("오류"); // ✅ Throwable의 자손이므로 OK
Throwable이 제공하는 핵심 필드와 메서드
Throwable은 생성 시점에 세 가지를 기록한다.
| 항목 | 설명 | 접근 메서드 |
|---|---|---|
detailMessage | 예외 설명 문자열 | getMessage() |
cause | 이 예외를 유발한 원인 예외 | getCause() |
| stack trace | 예외 발생 시점의 호출 스택 | printStackTrace(), getStackTrace() |
1
2
3
4
5
6
7
8
try {
connectDB();
} catch (Exception e) {
System.out.println(e.getMessage()); // "Connection refused: localhost:3306"
System.out.println(e.getCause()); // 원인 예외 (없으면 null)
System.out.println(e.getClass().getName()); // "java.sql.SQLException"
e.printStackTrace(); // 전체 스택 트레이스 출력
}
생성자 패턴 세 가지
1
2
3
4
5
6
7
8
9
10
11
12
// 1. 메시지만
new RuntimeException("파일을 찾을 수 없습니다");
// 2. 메시지 + 원인 예외 (예외 체이닝에서 핵심!)
try {
connectDB();
} catch (SQLException e) {
throw new ServiceException("DB 연결 실패", e); // e를 cause로 전달
}
// 3. 원인만
throw new RuntimeException(originalException);
Throwable을 직접 catch하면 안 되는 이유
1
2
3
4
5
6
7
8
// ❌ 절대 하지 말 것
try {
riskyCode();
} catch (Throwable t) { // Error까지 통째로 잡힘
log(t);
// OutOfMemoryError를 "처리됐다"고 계속 실행하면
// 어떤 데이터가 손상됐는지 모른 채 프로그램이 달려감
}
Error는 JVM이 더 이상 정상 작동을 보장할 수 없는 상황이다. 잡지 말고 JVM이 종료되도록 두는 것이 원칙이다. catch는 항상 Exception 이하로만 해야 한다!
Checked vs Unchecked 예외 🤔
면접에서 가장 자주 나오는 질문이다. 판단 기준 하나만 기억해두면 된다.
💡 판단 기준 “호출자가 이 예외를 복구할 수 있는가?” → Yes → Checked 예외 → No (프로그래밍 버그) → Unchecked 예외
비교표
| 구분 | Checked 예외 | Unchecked 예외 |
|---|---|---|
| 상속 | Exception 직접 상속 | RuntimeException 상속 |
| 컴파일러 | 처리 강제 | 처리 선택 |
| 처리 방법 | try-catch 또는 throws 선언 필수 | 선택적 try-catch |
| 언제 사용 | 외부 요인의 복구 가능한 상황 | 프로그래밍 오류 (버그) |
| 대표 예시 | IOException, SQLException | NullPointerException, IllegalArgumentException |
Checked vs Unchecked 예외 판단 플로우차트
위 플로우차트는 예외 클래스가 RuntimeException을 상속하는지 여부를 핵심 판단점으로 보여준다. RuntimeException을 상속하면 Unchecked 예외로 분류되며, 컴파일러가 처리를 강제하지 않는다. 즉, try-catch로 처리하거나 throws로 선언하지 않아도 컴파일이 된다. 반면 RuntimeException을 상속하지 않고 Exception을 직접 상속하면 Checked 예외가 되며, 반드시 try-catch 블록으로 직접 처리하거나 throws 선언으로 호출자에게 전파해야 한다. 둘 중 하나를 선택하지 않으면 컴파일 에러가 발생한다.
Checked 예외 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 컴파일러가 처리를 강제한다
// 방법 1: throws로 호출자에게 전파
public void readFile(String path) throws IOException {
FileReader fr = new FileReader(path); // IOException 발생 가능
// ...
}
// 방법 2: try-catch로 직접 처리
public void readFile(String path) {
try {
FileReader fr = new FileReader(path);
// ...
} catch (FileNotFoundException e) {
System.err.println("파일이 없습니다: " + path);
// 사용자에게 알리거나, 기본값을 사용하거나 — 복구 가능!
} catch (IOException e) {
e.printStackTrace();
}
}
Unchecked 예외 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
// 처리 안 해도 컴파일 됨 — 버그이므로 처리보다 수정이 답
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("0으로 나눌 수 없습니다: b=" + b);
}
return a / b;
}
// NullPointerException — 애초에 null이 들어오지 않도록 설계가 정답
public String getUpperCase(String s) {
Objects.requireNonNull(s, "s는 null일 수 없습니다"); // NPE 대신 명확한 메시지
return s.toUpperCase();
}
실제 선택 기준
파일 읽기를 예시로 들면:
- 파일이 없는 경우 →
FileNotFoundException(Checked) → 사용자가 파일 경로를 잘못 입력한 것일 수 있으므로 복구 가능 - null을 인자로 넘긴 경우 →
NullPointerException(Unchecked) → 코드를 수정해야 하는 버그
실전 예외 처리 패턴 💪
기본 try-catch-finally
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
FileReader fr = null;
try {
fr = new FileReader("data.txt");
// 작업 수행
} catch (FileNotFoundException e) {
// 더 구체적인 예외를 먼저! 순서가 바뀌면 컴파일 에러
System.err.println("파일 없음: " + e.getMessage());
} catch (IOException e) {
e.printStackTrace();
} finally {
// 예외 발생 여부와 무관하게 항상 실행 — 리소스 정리에 사용
if (fr != null) {
try {
fr.close();
} catch (IOException e) {
// close 실패는 보통 무시
}
}
}
finally는 return이 있어도 실행된다. 주의할 점이 하나 있다.
1
2
3
4
5
6
7
8
9
10
public String test() {
try {
return "try"; // 이 return이 실행되려는 찰나
} finally {
System.out.println("finally 실행!"); // 여기가 먼저 실행됨
// 만약 여기서 return "finally"; 하면 try의 return이 덮어씌워짐!
}
}
// 출력: "finally 실행!"
// 반환값: "try"
try-catch-finally 실행 흐름 - 정상/예외/미처리 세 시나리오
위 다이어그램은 try-catch-finally 구문의 세 가지 실행 시나리오를 나란히 배치했다. 왼쪽(정상 실행): try 블록이 예외 없이 완료되면 catch 블록을 건너뛰고 finally로 직행한 뒤 종료된다. 중간(예외 발생 후 처리): try 블록에서 예외가 발생하면 즉시 해당 catch 블록으로 점프해서 처리하고, 이후 finally를 실행한 뒤 정상 종료된다. 오른쪽(예외 미처리/전파): try 블록에서 예외가 발생했지만 매칭되는 catch가 없으면 finally를 먼저 실행한 뒤 예외를 호출자에게 전파(throw)한다. 핵심은 finally 블록이 가로로 걸쳐 세 경로 모두에서 실행된다는 점이다. 예외 발생 여부, 처리 여부와 무관하게 항상 실행되므로 리소스 정리 코드를 배치하기에 완벽한 위치다.
try-with-resources (Java 7+) — 권장 방식 ✅
AutoCloseable을 구현한 객체는 블록이 끝날 때 자동으로 close()가 호출된다. finally에서 수동으로 닫을 필요가 없어진다!
1
2
3
4
5
6
7
8
9
10
11
12
13
// 여러 리소스도 순서대로 선언 가능 — 역순으로 close()됨
try (FileInputStream fis = new FileInputStream("data.txt");
BufferedReader br = new BufferedReader(new InputStreamReader(fis))) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
// br, fis 모두 예외 발생 여부와 관계없이 자동 close() ✅
FileReader, Connection, PreparedStatement 등 대부분의 I/O, DB 리소스가 AutoCloseable을 구현하고 있다.
예외 체이닝 (Exception Chaining) 🔗
저수준 예외를 잡아 고수준 예외로 재포장할 때, 원인 예외를 반드시 전달해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 나쁜 예 — 원인 정보 소실
try {
connectDB();
} catch (SQLException e) {
throw new ServiceException("DB 연결 실패"); // e를 버림 → 나중에 디버깅 불가
}
// ✅ 좋은 예 — 원인 체이닝 유지
try {
connectDB();
} catch (SQLException e) {
throw new ServiceException("DB 연결 실패", e); // e를 cause로 전달
}
원인이 체이닝되면 스택 트레이스에서 이렇게 보인다.
1
2
3
4
5
6
ServiceException: DB 연결 실패
at UserService.loadUser(UserService.java:42)
...
Caused by: java.sql.SQLException: Connection refused
at com.mysql.jdbc.ConnectionImpl.createNewIO(...)
...
Caused by: 라인이 있으면 진짜 원인을 바로 찾을 수 있다. 없으면 ServiceException만 보이고 원인 추적이 끊긴다!
왼쪽(원인 예외 소실): catch (SQLException e) 블록에서 예외를 잡은 뒤, 새로운 ServiceException을 생성할 때 원인 예외 e를 생성자에 전달하지 않았다. 결과적으로 스택 트레이스에는 ServiceException만 나타나고, 실제 근본 원인인 SQLException의 정보(메시지, 발생 위치, 원인)가 완전히 사라진다. 디버깅할 때 “DB 연결 실패”라는 메시지만 보고 구체적으로 무엇이 잘못됐는지(포트 오류? 인증 실패? 타임아웃?) 알 수 없어 원인 추적이 막힌다.
오른쪽(예외 체이닝 유지): new ServiceException("DB 연결 실패", e)처럼 생성자에 원인 예외 e를 함께 전달했다. 이렇게 하면 ServiceException의 cause 필드에 SQLException이 저장되고, 스택 트레이스에 Caused by: java.sql.SQLException: Connection refused 라인이 추가된다. getCause() 메서드로 체인을 따라가며 최초 근본 원인까지 추적할 수 있어, “Connection refused”라는 구체적인 에러 원인을 파악할 수 있다. 디버깅 효율이 극적으로 향상된다.
커스텀 예외 설계 🎨
표준 예외가 의미를 충분히 전달하지 못할 때 커스텀 예외를 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Unchecked 커스텀 예외 — 비즈니스 규칙 위반
public class InsufficientBalanceException extends RuntimeException {
private final int currentBalance;
private final int requestedAmount;
public InsufficientBalanceException(int currentBalance, int requestedAmount) {
super(String.format(
"잔액 부족 — 현재 잔액: %d원, 요청 금액: %d원",
currentBalance, requestedAmount
));
this.currentBalance = currentBalance;
this.requestedAmount = requestedAmount;
}
// 예외에서 구조화된 데이터를 꺼낼 수 있음
public int getCurrentBalance() { return currentBalance; }
public int getRequestedAmount() { return requestedAmount; }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 사용
public void withdraw(int amount) {
if (amount > balance) {
throw new InsufficientBalanceException(balance, amount);
}
balance -= amount;
}
// 처리
try {
account.withdraw(50000);
} catch (InsufficientBalanceException e) {
System.out.printf(
"출금 실패: %d원 부족합니다 (현재 잔액: %d원)%n",
e.getRequestedAmount() - e.getCurrentBalance(),
e.getCurrentBalance()
);
}
커스텀 예외 설계 팁
- 메시지에 디버깅에 필요한 값을 포함하자 (현재값, 요청값 등)
- cause 생성자도 함께 만들어두면 좋다 (
super(message, cause))- 표준 예외(
IllegalArgumentException,IllegalStateException등)로 충분하다면 만들지 않는 게 낫다!
자주 하는 실수 ⚠️
실수 1 — 예외를 무시하는 빈 catch 블록
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 최악의 패턴 — 예외를 삼켜버림
try {
riskyCode();
} catch (Exception e) {
// 아무것도 안 함... 버그가 숨어버린다
}
// ✅ 최소한 로깅은 해야 한다
try {
riskyCode();
} catch (Exception e) {
log.error("예상치 못한 오류 발생", e);
throw e; // 또는 적절히 처리
}
실수 2 — catch 순서 역전
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ 컴파일 에러 — 넓은 예외가 앞에 오면 뒤 catch는 절대 도달 불가
try {
readFile();
} catch (Exception e) { // IOException을 포함한 모든 예외
...
} catch (IOException e) { // 여기 절대 도달 안 함 → 컴파일 에러
...
}
// ✅ 구체적인 것 → 넓은 것 순서로
try {
readFile();
} catch (FileNotFoundException e) { // 더 구체적
...
} catch (IOException e) { // 더 넓음
...
} catch (Exception e) { // 가장 넓음
...
}
실수 3 — 너무 넓게 잡기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ❌ 모든 예외를 뭉뚱그려 잡으면 진짜 문제를 숨긴다
try {
processOrder(orderId);
} catch (Exception e) {
System.out.println("오류 발생"); // 어떤 오류인지 전혀 모름
}
// ✅ 예상되는 예외는 명확하게 처리
try {
processOrder(orderId);
} catch (OrderNotFoundException e) {
return ResponseEntity.notFound().build();
} catch (InsufficientBalanceException e) {
return ResponseEntity.badRequest().body(e.getMessage());
} catch (Exception e) {
log.error("처리되지 않은 예외", e); // 예상 밖 예외는 로깅 후 재던지기
throw e;
}
실수 4 — 예외 재포장 시 원인 소실
1
2
3
4
5
6
7
8
9
// ❌ cause를 빠뜨리면 스택 트레이스에서 진짜 원인이 사라짐
} catch (SQLException e) {
throw new DataAccessException("쿼리 실패"); // e 없음!
}
// ✅ 항상 cause 전달
} catch (SQLException e) {
throw new DataAccessException("쿼리 실패", e); // e 포함
}
핵심 정리 📌
- 계층 구조:
Throwable→Error(복구 불가, JVM 수준) /Exception(복구 가능) Exception→RuntimeException(Unchecked) / 그 외(Checked)- Checked vs Unchecked 판단 기준: “호출자가 복구할 수 있는가?” → Yes면 Checked, No면 Unchecked
- Checked 대표:
IOException,SQLException/ Unchecked 대표:NullPointerException,IllegalArgumentException - catch 순서: 구체적인 것 → 넓은 것 (역순이면 컴파일 에러)
- try-with-resources: Java 7+,
AutoCloseable구현 객체를 예외와 무관하게 자동 close - 예외 체이닝: 재포장 시
cause를 반드시 전달해야 디버깅 가능 Throwable직접 catch 금지:Error까지 잡게 되어 JVM 비정상 상태에서도 실행이 계속될 수 있음- 빈 catch 블록 금지: 예외를 삼키면 버그가 숨어버림!

