Exception in thread "main" java.lang.NullPointerException
한 번쯤은 봤을 이 메시지. 프로그램이 그냥 죽어버리는 상황이다. 예외처리는 이런 상황을 미리 대비하고, 프로그램이 갑자기 종료되지 않도록 제어하는 방법이다.
프로그램 실행 중 발생하는 문제는 크게 두 가지로 나뉜다.
Error는 JVM 자체에 문제가 생긴 것이다. 메모리가 꽉 찼거나(OutOfMemoryError), 스택이 넘쳤거나(StackOverflowError) 하는 경우다. 개발자가 코드로 처리할 수 있는 영역이 아니다.
Exception은 개발자가 충분히 예측하고 처리할 수 있는 문제다. 없는 파일을 열려 한다거나, 숫자로 변환할 수 없는 문자열을 변환하려 한다거나 하는 경우들이다. 예외처리는 바로 이 Exception을 다루는 것이다.

Exception은 다시 두 종류로 나뉜다. 이 구분이 처음엔 헷갈리지만 알고 나면 꽤 중요하다.
| 구분 | Checked Exception | Unchecked Exception |
|---|---|---|
| 다른 이름 | 컴파일 예외 | 런타임 예외 |
| 처리 여부 | 반드시 처리해야 함 (안 하면 컴파일 에러) | 처리 안 해도 컴파일은 됨 |
| 대표 예시 | IOException, SQLException | NullPointerException, ArrayIndexOutOfBoundsException |
| 발생 시점 | 주로 외부 자원 접근 (파일, DB 등) | 주로 개발자 실수 |
Checked Exception은 컴파일러가 "이거 처리 안 하면 빌드 안 해줄게"라고 강제한다. 파일을 읽거나 DB에 접근할 때처럼 외부 환경에 의존하는 작업은 언제든 실패할 수 있으니, 반드시 대비책을 마련하라는 것이다.
Unchecked Exception은 보통 코드 로직의 실수에서 온다. null인 객체의 메서드를 호출하거나, 배열 범위를 벗어난 인덱스에 접근하는 경우다. 컴파일러가 강제하진 않지만, 런타임에 터지면 프로그램이 그냥 죽는다.

예외를 처리하는 기본 문법이다.
try {
// 예외가 발생할 수 있는 코드
int result = 10 / 0;
} catch (ArithmeticException e) {
// 예외가 발생했을 때 처리하는 코드
System.out.println("0으로 나눌 수 없습니다: " + e.getMessage());
} finally {
// 예외 발생 여부와 관계없이 무조건 실행되는 코드
System.out.println("항상 실행됩니다.");
}
catch 블록은 여러 개 쓸 수 있다.
try {
String str = null;
int[] arr = new int[5];
System.out.println(str.length()); // NullPointerException 발생 가능
System.out.println(arr[10]); // ArrayIndexOutOfBoundsException 발생 가능
} catch (NullPointerException e) {
System.out.println("null 참조 오류: " + e.getMessage());
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("배열 범위 초과: " + e.getMessage());
} catch (Exception e) {
// 위에서 잡지 못한 나머지 예외를 모두 처리
System.out.println("알 수 없는 오류: " + e.getMessage());
}
catch 블록은 위에서부터 순서대로 매칭된다. 자식 예외 클래스를 위에, 부모 예외 클래스를 아래에 써야 한다.
Exception을 가장 위에 쓰면 모든 예외를 잡아버려서 아래 catch 블록에 도달하지 못한다.
예외를 직접 발생시키거나, 호출한 쪽으로 넘길 때 사용한다.
throw: 예외를 직접 발생시킨다.
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("나이는 0보다 작을 수 없습니다.");
}
this.age = age;
}
throws: 이 메서드에서 예외가 발생할 수 있으니, 호출한 쪽에서 처리하라고 선언한다.
// 이 메서드를 호출하는 쪽에서 IOException을 처리해야 한다
public void readFile(String path) throws IOException {
FileReader reader = new FileReader(path);
// ...
}
throw는 예외를 "던지는" 행위고, throws는 "나 이런 예외 던질 수 있어"라고 메서드에 선언하는 것이다. s가 붙은 throws가 선언이라고 기억하면 구분하기 편하다.
프로젝트를 하다 보면 자바가 기본으로 제공하는 예외만으론 부족할 때가 있다. 직접 예외 클래스를 만들 수 있다.
// RuntimeException을 상속하면 Unchecked Exception이 된다
public class InsufficientBalanceException extends RuntimeException {
private int balance;
private int amount;
public InsufficientBalanceException(int balance, int amount) {
super("잔액이 부족합니다. 현재 잔액: " + balance + "원, 출금 요청: " + amount + "원");
this.balance = balance;
this.amount = amount;
}
}
// 사용
public void withdraw(int amount) {
if (this.balance < amount) {
throw new InsufficientBalanceException(this.balance, amount);
}
this.balance -= amount;
}
기본 예외(IllegalArgumentException 등)를 그대로 쓰는 것보다, 도메인에 맞는 이름의 예외를 만들면 코드를 읽는 사람이 어떤 상황에서 발생한 오류인지 바로 알 수 있다.
try-catch로 무조건 감싼다고 예외처리가 잘 된 게 아니다. 어떤 예외가 왜 발생하는지 이해하고, 그 상황에 맞는 처리를 해야 한다. 예외처리가 제대로 된 코드는 문제가 생겼을 때 원인을 찾는 시간을 확 줄여준다.