예외 처리 — 에러가 나면 어떻게 해야 하나요
프로그램을 만들다 보면 반드시 마주치는 순간이 있다. "왜 갑자기 에러가 나지?" — 파일이 없거나, 숫자를 0으로 나누거나, 없는 인덱스에 접근하거나. 이런 상황을 그냥 무시하면 프로그램이 죽어버린다. 그래서 Java에는 예외 처리라는 메커니즘이 있다. 이번 글에서는 예외가 뭔지부터 시작해서 try-catch, checked/unchecked, try-with-resources, 커스텀 예외까지 한 번에 정리한다.
예외란?
에러(Error)와 예외(Exception)는 다르다
공부하다 보니 "에러"와 "예외"를 같은 의미로 쓰는 경우가 많은데, Java에서는 명확하게 구분한다.
- 에러(Error): 시스템 레벨의 심각한 문제.
OutOfMemoryError,StackOverflowError같은 것들이다. 개발자가 코드로 처리할 수 없다. - 예외(Exception): 프로그램 로직에서 발생할 수 있는 문제.
NullPointerException,IOException같은 것들이다. 코드로 처리할 수 있다.
Throwable 계층 구조
Java의 모든 에러와 예외는 Throwable 클래스를 상속받는다.
Throwable
/ \
Error Exception
/ / \
OutOfMemoryError IOException RuntimeException
StackOverflowError / \
NullPointerException IllegalArgumentException
Error— 시스템 문제, 처리 불가Exception— 프로그램 문제, 처리 가능RuntimeException— Unchecked 예외 (뒤에서 자세히)- 그 외
Exception— Checked 예외
try-catch-finally 기본
기본 구조
예외가 발생할 수 있는 코드를 try 블록에 넣고, 발생했을 때의 처리를 catch에 작성한다.
public class TryCatchExample {
public static void main(String[] args) {
try {
int result = 10 / 0; // ArithmeticException 발생!
System.out.println(result); // 이 줄은 실행되지 않음
} catch (ArithmeticException e) {
// 예외가 발생했을 때 실행되는 블록
System.out.println("0으로 나눌 수 없습니다: " + e.getMessage());
}
System.out.println("프로그램이 계속 실행됩니다");
}
}
0으로 나눌 수 없습니다: / by zero
프로그램이 계속 실행됩니다
try-catch가 없었다면 프로그램이 그대로 종료됐을 것이다. 예외를 잡았기 때문에 이후 코드가 정상적으로 실행된다.
finally — 무조건 실행되는 블록
finally는 예외 발생 여부와 상관없이 항상 실행된다. 주로 리소스 정리(파일 닫기, DB 연결 해제 등)에 쓰인다.
public class FinallyExample {
public static void main(String[] args) {
try {
System.out.println("try 블록 실행");
int result = 10 / 0;
} catch (ArithmeticException e) {
System.out.println("catch 블록 실행");
} finally {
System.out.println("finally 블록 실행 — 항상 실행됨");
}
}
}
try 블록 실행
catch 블록 실행
finally 블록 실행 — 항상 실행됨
finally의 함정 — return과의 관계
여기서 헷갈렸는데, try에서 return을 해도 finally는 실행된다.
public static int getValue() {
try {
return 1;
} finally {
System.out.println("finally 실행!");
// 여기서 return 2; 를 하면 1 대신 2가 반환됨 — 절대 하지 말 것!
}
}
// "finally 실행!" 출력 후 1 반환
주의: finally에서 return을 쓰면 try의 반환값을 덮어쓴다. 이건 의도치 않은 버그를 만들기 딱 좋으므로, finally에서는 return을 쓰지 않는 게 좋다.
여러 예외 잡기 — 다중 catch
public class MultiCatchExample {
public static void main(String[] args) {
try {
String text = null;
System.out.println(text.length()); // NullPointerException
} 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가 절대 실행되지 않아 컴파일 에러가 난다.
멀티 catch — Java 7+
같은 방식으로 처리할 예외가 여러 개라면 |로 묶을 수 있다.
try {
// 어떤 코드
} catch (NullPointerException | IllegalArgumentException e) {
// 두 예외를 같은 방식으로 처리
System.out.println("잘못된 입력: " + e.getMessage());
}
단, 상속 관계에 있는 예외를 묶을 수는 없다. IOException | Exception처럼 쓰면 컴파일 에러다.
예외 계층 구조 — Checked vs Unchecked
이 부분이 Java 예외 처리에서 가장 중요한 개념이다.
Checked 예외
Exception을 상속받되,RuntimeException은 상속받지 않는 예외- 컴파일러가 처리를 강제한다 — try-catch로 잡거나, throws로 떠넘기거나
- 대표적:
IOException,SQLException,FileNotFoundException
import java.io.*;
public class CheckedExample {
public static void main(String[] args) {
// IOException은 Checked 예외 — 반드시 처리해야 컴파일됨
try {
FileReader reader = new FileReader("없는파일.txt");
} catch (FileNotFoundException e) {
System.out.println("파일을 찾을 수 없습니다");
}
}
}
try-catch 없이 new FileReader("없는파일.txt")를 쓰면 컴파일 에러가 발생한다.
Unchecked 예외
RuntimeException을 상속받는 예외- 컴파일러가 처리를 강제하지 않는다 — 잡아도 되고 안 잡아도 됨
- 대표적:
NullPointerException,ArrayIndexOutOfBoundsException,IllegalArgumentException
public class UncheckedExample {
public static void main(String[] args) {
// NullPointerException은 Unchecked — try-catch 없어도 컴파일됨
String text = null;
System.out.println(text.length()); // 런타임에 터짐!
}
}
비교표
| 구분 | Checked 예외 | Unchecked 예외 |
|---|---|---|
| 상속 | Exception (RuntimeException 제외) | RuntimeException |
| 컴파일러 검사 | O — 반드시 처리해야 함 | X — 처리 안 해도 컴파일 됨 |
| 처리 방법 | try-catch 또는 throws 필수 | 선택적 |
| 발생 시점 | 주로 외부 환경 (파일, 네트워크, DB) | 주로 프로그래밍 실수 (null 참조, 잘못된 인덱스) |
| 대표 예외 | IOException, SQLException | NullPointerException, IllegalArgumentException |
공부하다 보니 이렇게 외우면 편했다:
- Checked = "외부 세계와 소통할 때 생기는 예외" (파일, 네트워크, DB)
- Unchecked = "내 코드의 실수로 생기는 예외" (null, 잘못된 인덱스)
throws — 예외 떠넘기기
예외를 직접 처리하지 않고, 호출한 쪽에 처리 책임을 넘기는 방법이다.
import java.io.*;
public class ThrowsExample {
// 이 메서드는 IOException을 처리하지 않고 호출자에게 떠넘긴다
static String readFile(String path) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(path));
return reader.readLine();
}
public static void main(String[] args) {
try {
// readFile을 호출하는 쪽에서 예외를 처리한다
String content = readFile("data.txt");
System.out.println(content);
} catch (IOException e) {
System.out.println("파일 읽기 실패: " + e.getMessage());
}
}
}
throws 체이닝
throws를 계속 떠넘기면 결국 main까지 올라가고, main에서도 떠넘기면 JVM이 프로그램을 종료시킨다.
// 예외가 계속 올라간다
static void methodC() throws IOException { /* 예외 발생 */ }
static void methodB() throws IOException { methodC(); }
static void methodA() throws IOException { methodB(); }
public static void main(String[] args) throws IOException {
methodA(); // 여기서도 안 잡으면 → JVM이 프로그램 종료
}
실무 팁: 무한정 떠넘기는 건 좋지 않다. 적절한 계층에서 예외를 잡아서 처리하는 게 좋다.
throw — 예외 직접 발생시키기
throw는 개발자가 의도적으로 예외를 던지는 키워드다. throws(s가 붙음)와 헷갈리기 쉬운데, 완전히 다른 용도다.
public class ThrowExample {
static void setAge(int age) {
if (age < 0) {
// 개발자가 직접 예외를 발생시킨다
throw new IllegalArgumentException("나이는 음수일 수 없습니다: " + age);
}
System.out.println("나이 설정: " + age);
}
public static void main(String[] args) {
try {
setAge(-5);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
나이는 음수일 수 없습니다: -5
throw vs throws 정리
| 구분 | throw | throws |
|---|---|---|
| 위치 | 메서드 안 | 메서드 선언부 |
| 역할 | 예외를 발생시킨다 | 예외를 떠넘긴다고 선언 |
| 예시 | throw new Exception("에러") | void method() throws Exception |
try-with-resources — 자동 리소스 관리
문제 — 기존 방식의 불편함
파일이나 DB 연결 같은 리소스는 사용 후 반드시 닫아야 한다. 기존에는 finally에서 직접 닫았다.
import java.io.*;
public class OldWay {
public static void main(String[] args) {
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader("data.txt"));
System.out.println(reader.readLine());
} catch (IOException e) {
System.out.println("에러: " + e.getMessage());
} finally {
// 리소스 닫기 — 이것도 예외가 발생할 수 있어서 또 try-catch
if (reader != null) {
try {
reader.close();
} catch (IOException e) {
System.out.println("닫기 실패: " + e.getMessage());
}
}
}
}
}
리소스를 하나 닫는데 코드가 이렇게 길어진다. finally 안에 또 try-catch가 들어가는 건 정말 불편하다.
해결 — try-with-resources (Java 7+)
import java.io.*;
public class TryWithResources {
public static void main(String[] args) {
// try() 안에 리소스를 선언하면 자동으로 닫아준다
try (BufferedReader reader = new BufferedReader(new FileReader("data.txt"))) {
System.out.println(reader.readLine());
} catch (IOException e) {
System.out.println("에러: " + e.getMessage());
}
// reader.close()를 호출하지 않아도 자동으로 닫힘!
}
}
코드가 훨씬 깔끔해졌다. finally에서 리소스를 닫는 코드가 완전히 사라졌다.
조건 — AutoCloseable 인터페이스
try-with-resources를 사용하려면 해당 클래스가 AutoCloseable 인터페이스를 구현해야 한다.
// AutoCloseable 인터페이스
public interface AutoCloseable {
void close() throws Exception;
}
Java의 I/O 관련 클래스들(InputStream, OutputStream, Reader, Writer, Connection 등)은 대부분 AutoCloseable을 이미 구현하고 있다.
여러 리소스 동시에 사용
try (
FileReader fr = new FileReader("input.txt");
BufferedReader br = new BufferedReader(fr);
FileWriter fw = new FileWriter("output.txt")
) {
String line;
while ((line = br.readLine()) != null) {
fw.write(line + "\n");
}
}
// fr, br, fw 모두 자동으로 닫힘 (선언 역순으로 닫힌다)
세미콜론(;)으로 구분해서 여러 리소스를 선언할 수 있다. 닫히는 순서는 선언의 역순이다.
커스텀 예외 만들기
기본 제공 예외만으로는 부족할 때, 직접 예외 클래스를 만들 수 있다.
Unchecked 커스텀 예외
RuntimeException을 상속받으면 된다.
// 잔액 부족 예외 — Unchecked
public class InsufficientBalanceException extends RuntimeException {
private final int currentBalance;
private final int withdrawAmount;
public InsufficientBalanceException(int currentBalance, int withdrawAmount) {
super("잔액 부족: 현재 " + currentBalance + "원, 출금 요청 " + withdrawAmount + "원");
this.currentBalance = currentBalance;
this.withdrawAmount = withdrawAmount;
}
// 추가 정보를 제공하는 getter
public int getCurrentBalance() {
return currentBalance;
}
public int getWithdrawAmount() {
return withdrawAmount;
}
}
사용 예시
public class BankAccount {
private int balance;
public BankAccount(int balance) {
this.balance = balance;
}
public void withdraw(int amount) {
if (amount > balance) {
throw new InsufficientBalanceException(balance, amount);
}
balance -= amount;
System.out.println(amount + "원 출금 완료. 잔액: " + balance + "원");
}
public static void main(String[] args) {
BankAccount account = new BankAccount(10000);
try {
account.withdraw(5000); // 성공
account.withdraw(8000); // 잔액 부족 — 예외 발생!
} catch (InsufficientBalanceException e) {
System.out.println(e.getMessage());
System.out.println("부족한 금액: " + (e.getWithdrawAmount() - e.getCurrentBalance()) + "원");
}
}
}
5000원 출금 완료. 잔액: 5000원
잔액 부족: 현재 5000원, 출금 요청 8000원
부족한 금액: 3000원
Checked 커스텀 예외
Exception을 상속받으면 Checked 예외가 된다.
// Checked 커스텀 예외
public class InvalidFileFormatException extends Exception {
public InvalidFileFormatException(String message) {
super(message);
}
public InvalidFileFormatException(String message, Throwable cause) {
super(message, cause); // 원인 예외를 함께 전달
}
}
커스텀 예외를 만들 때 팁:
- 예외 이름은
~Exception으로 끝내기 - 의미 있는 메시지를
super(message)로 전달하기 - 필요하면 추가 정보를 필드로 담기
- 원인 예외가 있으면
Throwable cause를 받는 생성자 만들기
예외 처리 Best Practice
1. 구체적인 예외를 잡자
// 나쁜 예 — 모든 예외를 뭉뚱그려 잡음
try {
// 코드
} catch (Exception e) {
e.printStackTrace();
}
// 좋은 예 — 구체적인 예외만 잡음
try {
// 코드
} catch (FileNotFoundException e) {
System.out.println("파일이 없습니다: " + e.getMessage());
} catch (IOException e) {
System.out.println("읽기 실패: " + e.getMessage());
}
Exception으로 모든 걸 잡으면 어떤 문제가 발생했는지 알 수 없다. 의도하지 않은 예외까지 삼켜버릴 수 있다.
2. 예외를 삼키지 말자
// 최악의 패턴 — 예외를 잡고 아무것도 안 함
try {
// 코드
} catch (Exception e) {
// 빈 catch 블록 — 절대 이러지 말 것!
}
// 최소한 로그라도 남기자
try {
// 코드
} catch (IOException e) {
logger.error("파일 처리 중 오류 발생", e); // 로그 기록
throw new RuntimeException("파일 처리 실패", e); // 필요하면 다시 던지기
}
빈 catch 블록은 문제를 숨겨버린다. 나중에 디버깅할 때 어디서 문제가 생겼는지 전혀 알 수 없게 된다.
3. 예외 메시지는 구체적으로
// 나쁜 예
throw new IllegalArgumentException("잘못된 값");
// 좋은 예 — 무엇이, 왜 잘못됐는지 알려줌
throw new IllegalArgumentException("나이는 0 이상이어야 합니다. 입력값: " + age);
4. 예외를 흐름 제어에 쓰지 말자
// 나쁜 예 — 예외를 if문처럼 사용
try {
int value = Integer.parseInt(input);
} catch (NumberFormatException e) {
value = 0; // 기본값
}
// 좋은 예 — 먼저 검증하고 변환
if (input != null && input.matches("\\d+")) {
int value = Integer.parseInt(input);
} else {
int value = 0;
}
예외 처리는 비용이 크다. 정상적인 흐름 제어에 예외를 쓰면 성능도 떨어지고 코드 가독성도 나빠진다.
5. 예외 변환 시 원인을 보존하자
try {
// 하위 레벨 작업
readFromDatabase();
} catch (SQLException e) {
// 원인 예외(e)를 함께 전달 — 디버깅할 때 스택 트레이스를 추적할 수 있다
throw new ServiceException("사용자 조회 실패", e);
}
실전 예제 — 사용자 입력 처리
배운 내용을 모두 합쳐서 간단한 실전 예제를 만들어 보자.
import java.io.*;
import java.util.Scanner;
// 커스텀 예외
class InvalidScoreException extends RuntimeException {
public InvalidScoreException(int score) {
super("점수는 0~100 사이여야 합니다. 입력값: " + score);
}
}
public class GradeCalculator {
// 점수를 검증하고 등급을 반환하는 메서드
static String getGrade(int score) {
if (score < 0 || score > 100) {
throw new InvalidScoreException(score); // 커스텀 예외 발생
}
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
if (score >= 60) return "D";
return "F";
}
// 파일에서 점수를 읽어오는 메서드
static int readScoreFromFile(String path) throws IOException {
try (BufferedReader reader = new BufferedReader(new FileReader(path))) {
String line = reader.readLine();
if (line == null) {
throw new IOException("파일이 비어 있습니다");
}
return Integer.parseInt(line.trim());
}
}
// 결과를 파일에 저장하는 메서드
static void saveResult(String path, String content) throws IOException {
try (BufferedWriter writer = new BufferedWriter(new FileWriter(path))) {
writer.write(content);
}
}
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("점수를 입력하세요 (0~100): ");
try {
int score = Integer.parseInt(scanner.nextLine()); // NumberFormatException 가능
String grade = getGrade(score); // InvalidScoreException 가능
String result = "점수: " + score + ", 등급: " + grade;
System.out.println(result);
// 결과를 파일에 저장
saveResult("result.txt", result);
System.out.println("결과가 파일에 저장되었습니다");
} catch (NumberFormatException e) {
System.out.println("숫자를 입력해주세요");
} catch (InvalidScoreException e) {
System.out.println(e.getMessage());
} catch (IOException e) {
System.out.println("파일 저장 실패: " + e.getMessage());
} finally {
scanner.close(); // Scanner 리소스 정리
System.out.println("프로그램을 종료합니다");
}
}
}
이 예제에는 지금까지 배운 거의 모든 내용이 들어 있다:
- try-catch-finally: 여러 예외를 구체적으로 잡고, finally에서 리소스 정리
- throw: 점수가 범위를 벗어나면 커스텀 예외 발생
- throws:
readScoreFromFile과saveResult는 IOException을 호출자에게 떠넘김 - try-with-resources: 파일 읽기/쓰기에서 자동 리소스 관리
- 커스텀 예외:
InvalidScoreException으로 의미 있는 에러 메시지 전달
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
정리
| 개념 | 핵심 |
|---|---|
| 에러 vs 예외 | Error는 시스템 문제, Exception은 코드로 처리 가능한 문제 |
| try-catch-finally | 예외를 잡고, finally는 항상 실행 |
| Checked 예외 | 컴파일러가 처리를 강제 (IOException 등) |
| Unchecked 예외 | 처리를 강제하지 않음 (NullPointerException 등) |
| throws | 예외 처리를 호출자에게 떠넘기는 선언 |
| throw | 예외를 직접 발생시키는 키워드 |
| try-with-resources | AutoCloseable 구현 객체를 자동으로 닫아줌 (Java 7+) |
| 커스텀 예외 | RuntimeException 또는 Exception을 상속해서 직접 만듦 |
예외 처리는 "프로그램이 안 죽게 만드는 기술"이라기보다, **"문제가 생겼을 때 적절하게 대응하는 설계"**에 가깝다. 어떤 예외를 어디서 잡을지, Checked로 할지 Unchecked로 할지 — 이런 결정이 코드의 안정성과 가독성을 크게 좌우한다.
다음 글에서는 문자열을 다룬다. String이 왜 불변인지, StringBuilder는 언제 써야 하는지, String Pool은 뭔지 — 의외로 깊은 문자열의 세계를 정리해 본다.