예외 처리 — 에러가 나면 어떻게 해야 하나요
프로그램을 만들다 보면 파일이 없거나, 숫자를 0으로 나누거나, 없는 인덱스에 접근하는 상황이 반드시 생긴다. 이런 상황을 그냥 무시하면 프로그램이 죽어버리는데, 어떻게 대응해야 할까?
예외 처리는 프로그램 실행 중 발생할 수 있는 문제에 적절히 대응하는 메커니즘 이다. 단순히 "안 죽게 만드는 기술"이 아니라, 문제가 생겼을 때 어디서 어떻게 처리할지를 설계하는 것 에 가깝다.
Error vs Exception
Java에서 "에러"와 "예외"는 명확하게 구분된다. 둘 다 Throwable을 상속받지만 성격이 다르다.
Throwable
/ \
Error Exception
| / \
OutOfMemoryError IOException RuntimeException
|
NullPointerException
- Error: 시스템 레벨의 심각한 문제.
OutOfMemoryError,StackOverflowError같은 것들이다. 코드로 처리할 수 없다. - Exception: 프로그램 로직에서 발생할 수 있는 문제. 코드로 처리할 수 있다.
try-catch-finally
예외가 발생할 수 있는 코드를 try에 넣고, 발생했을 때의 처리를 catch에 작성한다.
try {
int result = 10 / 0;
} catch (ArithmeticException e) {
System.out.println("0으로 나눌 수 없습니다: " + e.getMessage());
}
System.out.println("프로그램이 계속 실행됩니다");
try-catch가 없었다면 프로그램이 그대로 종료됐을 것이다. 예외를 잡았기 때문에 이후 코드가 정상적으로 실행된다.
finally는 예외 발생 여부와 상관없이 ** 항상 실행 **된다. 주로 리소스 정리에 쓰인다. try에서 return을 해도 finally는 실행된다.
catch 순서가 중요하다
여러 catch를 쓸 때는 ** 구체적인 예외를 먼저, 포괄적인 예외를 나중에** 써야 한다. Exception을 맨 위에 쓰면 아래 catch가 절대 실행되지 않아 컴파일 에러가 난다.
try { ... }
catch (FileNotFoundException e) { ... } // 구체적
catch (IOException e) { ... } // 포괄적
catch (Exception e) { ... } // 최후의 안전망
같은 방식으로 처리할 예외가 여러 개라면 Java 7+에서 |로 묶을 수 있다.
catch (NullPointerException | IllegalArgumentException e) {
System.out.println("잘못된 입력: " + e.getMessage());
}
Checked vs Unchecked — 가장 중요한 구분
Checked 예외
Exception을 상속받되 RuntimeException은 상속받지 ** 않는** 예외다. 컴파일러가 try-catch 또는 throws 선언을 ** 강제 **한다. 파일, 네트워크, DB처럼 ** 외부 환경 **과 소통할 때 주로 발생한다.
// try-catch 없이 쓰면 컴파일 에러
FileReader reader = new FileReader("없는파일.txt");
Unchecked 예외
RuntimeException을 상속받는 예외다. 컴파일러가 처리를 강제하지 않는다. null 참조, 잘못된 인덱스처럼 ** 코드의 실수 **로 발생한다.
| 구분 | Checked | Unchecked |
|---|---|---|
| 상속 | Exception (RuntimeException 제외) | RuntimeException |
| 컴파일러 검사 | O — 반드시 처리해야 함 | X — 처리 안 해도 컴파일 됨 |
| 발생 원인 | 외부 환경 (파일, 네트워크, DB) | 프로그래밍 실수 (null, 잘못된 인덱스) |
| 대표 예외 | IOException, SQLException | NullPointerException, IllegalArgumentException |
Checked는 "외부 세계와 소통할 때 생기는 예외", Unchecked는 "내 코드의 실수로 생기는 예외"로 기억하면 구분하기 쉽다.
throws와 throw
throws — 예외를 호출자에게 떠넘기기
예외를 직접 처리하지 않고 ** 호출한 쪽에 처리 책임을 넘긴다 **.
static String readFile(String path) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(path));
return reader.readLine();
}
throws를 계속 떠넘기면 결국 main까지 올라가고, main에서도 떠넘기면 JVM이 프로그램을 종료시킨다. 적절한 계층에서 잡아서 처리하는 게 좋다.
throw — 예외를 직접 발생시키기
개발자가 의도적으로 예외를 던진다.
static void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("나이는 음수일 수 없습니다: " + age);
}
}
| 구분 | throw | throws |
|---|---|---|
| 위치 | 메서드 ** 안** | 메서드 ** 선언부** |
| 역할 | 예외를 ** 발생 **시킨다 | 예외를 ** 떠넘긴다 **고 선언 |
try-with-resources — 자동 리소스 관리
파일이나 DB 연결은 사용 후 반드시 닫아야 한다. 기존에는 finally에서 직접 닫았는데, finally 안에 또 try-catch가 들어가는 불편함이 있었다.
Java 7의 try-with-resources는 AutoCloseable을 구현한 객체를 ** 자동으로 닫아준다 **.
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
System.out.println(reader.readLine());
} catch (IOException e) {
System.out.println("에러: " + e.getMessage());
}
// reader.close() 호출 불필요 — 자동으로 닫힘
여러 리소스를 세미콜론으로 구분해서 선언할 수 있으며, 닫히는 순서는 ** 선언의 역순 **이다. Java의 I/O 관련 클래스들(InputStream, Reader, Connection 등)은 대부분 AutoCloseable을 이미 구현하고 있다.
커스텀 예외
기본 제공 예외만으로 부족할 때 직접 만들 수 있다. RuntimeException을 상속하면 Unchecked, Exception을 상속하면 Checked 예외가 된다.
public class InsufficientBalanceException extends RuntimeException {
private final int currentBalance;
private final int withdrawAmount;
public InsufficientBalanceException(int current, int withdraw) {
super("잔액 부족: 현재 " + current + "원, 출금 요청 " + withdraw + "원");
this.currentBalance = current;
this.withdrawAmount = withdraw;
}
}
커스텀 예외를 만들 때는 이름을 ~Exception으로 끝내고, 의미 있는 메시지를 super()로 전달하며, 필요하면 추가 정보를 필드로 담는다. 원인 예외가 있으면 Throwable cause를 받는 생성자도 만들어두자.
주의할 점
빈 catch 블록은 최악의 패턴이다
// 절대 이러지 말 것
try { ... }
catch (Exception e) { }
예외를 잡고 아무것도 안 하면 문제가 숨겨진다. 나중에 디버깅할 때 어디서 문제가 생겼는지 전혀 알 수 없게 된다. 최소한 로그라도 남기고, 필요하면 다시 던져야 한다.
예외를 흐름 제어에 쓰지 말자
// 나쁜 예 — 예외를 if문처럼 사용
try {
int value = Integer.parseInt(input);
} catch (NumberFormatException e) {
value = 0;
}
예외 처리는 스택 트레이스 생성 등 비용이 크다. 정상적인 흐름 제어에 예외를 쓰면 성능도 떨어지고 코드 의도도 불명확해진다. 먼저 검증하고 변환하는 방식이 낫다.
예외 변환 시 원인을 보존하자
try {
readFromDatabase();
} catch (SQLException e) {
// 원인 예외(e)를 함께 전달
throw new ServiceException("사용자 조회 실패", e);
}
원인 예외를 넘기지 않으면 스택 트레이스에서 근본 원인을 추적할 수 없다. 예외를 감쌀 때는 반드시 Throwable cause를 함께 전달하자.
finally에서 return은 쓰지 말자
finally에서 return을 쓰면 try의 반환값을 ** 덮어쓴다 **. 의도치 않은 버그를 만들기 딱 좋으므로, finally에서는 리소스 정리만 하자.
정리
| 개념 | 핵심 |
|---|---|
| Error vs Exception | Error는 시스템 문제(처리 불가). Exception은 프로그램 문제(처리 가능) |
| try-catch-finally | 예외를 잡고 처리. finally는 항상 실행 |
| Checked 예외 | 컴파일러가 처리 강제. 외부 환경 관련 (IOException 등) |
| Unchecked 예외 | 처리 강제 안 함. 프로그래밍 실수 (NullPointerException 등) |
| throws / throw | throws는 떠넘기기 선언. throw는 직접 발생 |
| try-with-resources | AutoCloseable 객체를 자동으로 닫아줌 (Java 7+) |
| 커스텀 예외 | RuntimeException(Unchecked) 또는 Exception(Checked) 상속 |
| 빈 catch 블록 | 문제를 숨기는 최악의 패턴. 로그라도 남길 것 |