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

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 예외 판단 플로우차트 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 실패는 보통 무시
        }
    }
}

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로 직행한 뒤 종료된다. 중간(예외 발생 후 처리): 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를 함께 전달했다. 이렇게 하면 ServiceExceptioncause 필드에 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 포함
}

핵심 정리 📌

  • 계층 구조: 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 블록 금지: 예외를 삼키면 버그가 숨어버림!