[☕JAVA] 예외 처리(Exception Handling)

[☕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

Java 예외 계층 구조

Throwable을 최상위로 하는 Java 예외 계층 구조다. ErrorOutOfMemoryError, StackOverflowError 같은 JVM 수준의 심각한 오류로 복구 불가능하므로 catch하면 안 된다. Exception은 프로그램 수준의 복구 가능한 예외다. Exception은 다시 두 가지로 나뉘는데, RuntimeException을 상속하면 Unchecked 예외가 되어 처리가 선택적이고, 그 외는 Checked 예외가 되어 반드시 처리해야 한다.


Throwable 클래스 해부 🔍

Throwablethrow 키워드로 던질 수 있는 모든 객체의 최상위 클래스이다.

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, SQLExceptionNullPointerException, IllegalArgumentException

Checked vs Unchecked 예외 판단 플로우차트

예외 클래스가 RuntimeException을 상속하는지가 핵심 판단점이다. RuntimeException을 상속하면 Unchecked 예외로 분류되어 컴파일러가 처리를 강제하지 않는다. 반면 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 실패는 보통 무시
        }
    }
}

finallyreturn이 있어도 실행된다. 주의할 점이 하나 있다.

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를 먼저 실행한 뒤 예외를 호출자에게 전파한다. 핵심은 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)처럼 원인 예외를 함께 전달했다. 스택 트레이스에 Caused by: java.sql.SQLException: Connection refused 라인이 추가되어 getCause()로 근본 원인까지 추적할 수 있다. 디버깅 효율이 극적으로 향상된다.


커스텀 예외 설계 🎨

표준 예외가 의미를 충분히 전달하지 못할 때 커스텀 예외를 만든다.

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 포함
}

핵심 정리 📌

  • 계층 구조: ThrowableError(복구 불가, JVM 수준) / Exception(복구 가능)
  • ExceptionRuntimeException(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 블록 금지: 예외를 삼키면 버그가 숨어버림!