Theme:

프로그램을 만들다 보면 반드시 마주치는 순간이 있다. "왜 갑자기 에러가 나지?" — 파일이 없거나, 숫자를 0으로 나누거나, 없는 인덱스에 접근하거나. 이런 상황을 그냥 무시하면 프로그램이 죽어버린다. 그래서 Java에는 예외 처리라는 메커니즘이 있다. 이번 글에서는 예외가 뭔지부터 시작해서 try-catch, checked/unchecked, try-with-resources, 커스텀 예외까지 한 번에 정리한다.

예외란?

에러(Error)와 예외(Exception)는 다르다

공부하다 보니 "에러"와 "예외"를 같은 의미로 쓰는 경우가 많은데, Java에서는 명확하게 구분한다.

  • 에러(Error): 시스템 레벨의 심각한 문제. OutOfMemoryError, StackOverflowError 같은 것들이다. 개발자가 코드로 처리할 수 없다.
  • 예외(Exception): 프로그램 로직에서 발생할 수 있는 문제. NullPointerException, IOException 같은 것들이다. 코드로 처리할 수 있다.

Throwable 계층 구조

Java의 모든 에러와 예외는 Throwable 클래스를 상속받는다.

PLAINTEXT
              Throwable
              /       \
          Error      Exception
          /              /        \
  OutOfMemoryError  IOException  RuntimeException
  StackOverflowError             /              \
                    NullPointerException  IllegalArgumentException
  • Error — 시스템 문제, 처리 불가
  • Exception — 프로그램 문제, 처리 가능
    • RuntimeException — Unchecked 예외 (뒤에서 자세히)
    • 그 외 Exception — Checked 예외

try-catch-finally 기본

기본 구조

예외가 발생할 수 있는 코드를 try 블록에 넣고, 발생했을 때의 처리를 catch에 작성한다.

JAVA
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("프로그램이 계속 실행됩니다");
    }
}
PLAINTEXT
0으로 나눌 수 없습니다: / by zero
프로그램이 계속 실행됩니다

try-catch가 없었다면 프로그램이 그대로 종료됐을 것이다. 예외를 잡았기 때문에 이후 코드가 정상적으로 실행된다.

finally — 무조건 실행되는 블록

finally는 예외 발생 여부와 상관없이 항상 실행된다. 주로 리소스 정리(파일 닫기, DB 연결 해제 등)에 쓰인다.

JAVA
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 블록 실행 — 항상 실행됨");
        }
    }
}
PLAINTEXT
try 블록 실행
catch 블록 실행
finally 블록 실행 — 항상 실행됨

finally의 함정 — return과의 관계

여기서 헷갈렸는데, try에서 return을 해도 finally는 실행된다.

JAVA
public static int getValue() {
    try {
        return 1;
    } finally {
        System.out.println("finally 실행!");
        // 여기서 return 2; 를 하면 1 대신 2가 반환됨 — 절대 하지 말 것!
    }
}
// "finally 실행!" 출력 후 1 반환

주의: finally에서 return을 쓰면 try의 반환값을 덮어쓴다. 이건 의도치 않은 버그를 만들기 딱 좋으므로, finally에서는 return을 쓰지 않는 게 좋다.

여러 예외 잡기 — 다중 catch

JAVA
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+

같은 방식으로 처리할 예외가 여러 개라면 |로 묶을 수 있다.

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

공부하다 보니 이렇게 외우면 편했다:

  • Checked = "외부 세계와 소통할 때 생기는 예외" (파일, 네트워크, DB)
  • Unchecked = "내 코드의 실수로 생기는 예외" (null, 잘못된 인덱스)

throws — 예외 떠넘기기

예외를 직접 처리하지 않고, 호출한 쪽에 처리 책임을 넘기는 방법이다.

JAVA
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이 프로그램을 종료시킨다.

JAVA
// 예외가 계속 올라간다
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가 붙음)와 헷갈리기 쉬운데, 완전히 다른 용도다.

JAVA
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());
        }
    }
}
PLAINTEXT
나이는 음수일 수 없습니다: -5

throw vs throws 정리

구분throwthrows
위치메서드 메서드 선언부
역할예외를 발생시킨다예외를 떠넘긴다고 선언
예시throw new Exception("에러")void method() throws Exception

try-with-resources — 자동 리소스 관리

문제 — 기존 방식의 불편함

파일이나 DB 연결 같은 리소스는 사용 후 반드시 닫아야 한다. 기존에는 finally에서 직접 닫았다.

JAVA
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+)

JAVA
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 인터페이스를 구현해야 한다.

JAVA
// AutoCloseable 인터페이스
public interface AutoCloseable {
    void close() throws Exception;
}

Java의 I/O 관련 클래스들(InputStream, OutputStream, Reader, Writer, Connection 등)은 대부분 AutoCloseable을 이미 구현하고 있다.

여러 리소스 동시에 사용

JAVA
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을 상속받으면 된다.

JAVA
// 잔액 부족 예외 — 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;
    }
}

사용 예시

JAVA
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()) + "원");
        }
    }
}
PLAINTEXT
5000원 출금 완료. 잔액: 5000원
잔액 부족: 현재 5000원, 출금 요청 8000원
부족한 금액: 3000원

Checked 커스텀 예외

Exception을 상속받으면 Checked 예외가 된다.

JAVA
// 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. 구체적인 예외를 잡자

JAVA
// 나쁜 예 — 모든 예외를 뭉뚱그려 잡음
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. 예외를 삼키지 말자

JAVA
// 최악의 패턴 — 예외를 잡고 아무것도 안 함
try {
    // 코드
} catch (Exception e) {
    // 빈 catch 블록 — 절대 이러지 말 것!
}

// 최소한 로그라도 남기자
try {
    // 코드
} catch (IOException e) {
    logger.error("파일 처리 중 오류 발생", e); // 로그 기록
    throw new RuntimeException("파일 처리 실패", e); // 필요하면 다시 던지기
}

빈 catch 블록은 문제를 숨겨버린다. 나중에 디버깅할 때 어디서 문제가 생겼는지 전혀 알 수 없게 된다.

3. 예외 메시지는 구체적으로

JAVA
// 나쁜 예
throw new IllegalArgumentException("잘못된 값");

// 좋은 예 — 무엇이, 왜 잘못됐는지 알려줌
throw new IllegalArgumentException("나이는 0 이상이어야 합니다. 입력값: " + age);

4. 예외를 흐름 제어에 쓰지 말자

JAVA
// 나쁜 예 — 예외를 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. 예외 변환 시 원인을 보존하자

JAVA
try {
    // 하위 레벨 작업
    readFromDatabase();
} catch (SQLException e) {
    // 원인 예외(e)를 함께 전달 — 디버깅할 때 스택 트레이스를 추적할 수 있다
    throw new ServiceException("사용자 조회 실패", e);
}

실전 예제 — 사용자 입력 처리

배운 내용을 모두 합쳐서 간단한 실전 예제를 만들어 보자.

JAVA
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: readScoreFromFilesaveResult는 IOException을 호출자에게 떠넘김
  • try-with-resources: 파일 읽기/쓰기에서 자동 리소스 관리
  • 커스텀 예외: InvalidScoreException으로 의미 있는 에러 메시지 전달

TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.

정리

개념핵심
에러 vs 예외Error는 시스템 문제, Exception은 코드로 처리 가능한 문제
try-catch-finally예외를 잡고, finally는 항상 실행
Checked 예외컴파일러가 처리를 강제 (IOException 등)
Unchecked 예외처리를 강제하지 않음 (NullPointerException 등)
throws예외 처리를 호출자에게 떠넘기는 선언
throw예외를 직접 발생시키는 키워드
try-with-resourcesAutoCloseable 구현 객체를 자동으로 닫아줌 (Java 7+)
커스텀 예외RuntimeException 또는 Exception을 상속해서 직접 만듦

예외 처리는 "프로그램이 안 죽게 만드는 기술"이라기보다, **"문제가 생겼을 때 적절하게 대응하는 설계"**에 가깝다. 어떤 예외를 어디서 잡을지, Checked로 할지 Unchecked로 할지 — 이런 결정이 코드의 안정성과 가독성을 크게 좌우한다.

다음 글에서는 문자열을 다룬다. String이 왜 불변인지, StringBuilder는 언제 써야 하는지, String Pool은 뭔지 — 의외로 깊은 문자열의 세계를 정리해 본다.

댓글 로딩 중...