[Java] 예외 처리가 뭔데? 오류 흐름을 제어하는 방법

예외 처리

Java 프로그램이 실행 중에 에러 예외가 발생하면 프로그램이 갑자기 종료될 수 있어요. 이런 상황을 방지하고 프로그램의 안정성과 신뢰성을 높이기 위해, 예외 처리(Exception Handling)는 꼭 필요해요. 예외 처리를 제대로 하면 오류 상황에서도 프로그램이 계속 실행될 수 있도록 만들어주고, 사용자에게 적절한 메시지를 전달할 수도 있어요.

 

예외 계층 구조

Java에서는 실행 시 발생할 수 있는 모든 에러(Error) 예외(exception)를 클래스로 정의하였고 이 클래스들은 Throwable 클래스를 상속받아요. Throwable 클래스를 상속받는 클래스는 크게 두 가지 하위 클래스로 나뉘어요:

 

1) Error

시스템 레벨에서 발생하는 심각한 문제로, 개발자가 직접 처리할 수 없는 경우가 많아요. 예를 들어 OutOfMemoryError, StackOverflow, JVM의 비정상적인 동작 등이 있어요.

2) Exception

애플리케이션에서 발생하는 문제로, 프로그래머가 직접 처리할 수 있어요. 이 중 일부는 체크드 예외(Checked Exception)이고, 일부는 언체크드 예외(Unchecked Exception)예요.

 

🔸 Checked Exception:

컴파일 타임에 검사되며, 반드시 예외 처리를 해줘야 해요. 대표적인 예시로 IOException, SQLException 등이 있어요.

public void runQuery() throws SQLException {
    Connection conn = DriverManager.getConnection(...); // 여기서 SQLException 발생 가능
    ...
}

실제로 연결해 봐야 예외가 발생하는지 알 수 있지만 Java는 그 가능성이 있다면 미리 처리하도록 강제하는 겁니다.

👉  즉, 예측 가능한 외부 요인에 의한 예외 (파일 없음, DB 접속 실패 등)은 명시적으로 처리하도록 강제해요

 

🔸 Unchecked Exception:

런타임에 발생하며, 컴파일 시점에는 체크되지 않아요. 대표적으로 NullPointerExceptionArrayIndexOutOfBoundsExceptionIllegalArgumentException 등이 있어요.

String s = null;
System.out.println(s.length());  // NullPointerException

이런 코드는 정확히 어떤 조건에서 예외가 터질지 컴파일러가 예측할 수 없어요. 그래서 NullPointerException은 개발자의 논리적 실수로 간주하고, 컴파일러는 체크하지 않아요.

👉  즉, 프로그래머의 실수나 버그에 의한 예외 (null 접근 등)은 강제하지 않아요.

 

 

예외 처리하는 방법: try-catch-finally

Java에서는 주로 try-catch 구문을 통해 예외를 처리해요.

try {
    // 예외가 발생할 수 있는 코드
} catch (Exception e) {
    // 예외 처리
} finally {
    // 무조건 실행되는 블록 (선택적)
}
  • try: 예외가 발생할 수 있는 코드를 작성해요.
  • catch: 발생한 예외를 잡아 처리해요.
  • finally: 예외 발생 여부와 관계없이 항상 실행돼요. 주로 자원 해체 코드가 들어가요.

💡 try-with-resources (Java 7 이상)

try-with-resources 구문은 자원을 자동으로 닫아줘요. 이 구문을 사용하면 finally에서 굳이 close()를 호출하지 않아도 돼서, 코드가 훨씬 깔끔해져요. AutoCloseable 인터페이스를 구현한 클래스에서만 사용할 수 있어요.

try (BufferedReader br = new BufferedReader(new FileReader("file.txt"))) {
    // 파일 읽기
} catch (IOException e) {
    e.printStackTrace();
}

 

메서드 단위의 예외 처리

예외는 메서드 내부에서 직접 처리하거나, 호출한 쪽으로 던질 수도 있어요.

1. 메서드 내부에서 처리하기

public void readFile(String fileName) {
    try {
        BufferedReader reader = new BufferedReader(new FileReader(fileName));
        String line = reader.readLine();
    } catch (IOException e) {
        System.out.println("파일을 읽는 중 오류 발생: " + e.getMessage());
    } finally {
        reader.close();
    }
}

2. 예외를 호출자에게 전달하기 (throws 사용)

public void readFile(String fileName) throws IOException {
    BufferedReader reader = new BufferedReader(new FileReader(fileName));
    String line = reader.readLine();
    reader.close();
}

public static void main(String[] args) {
    try {
        readFile("test.txt");
    } catch (IOException e) {
        System.out.println("예외 발생: " + e.getMessage());
    }
}

 

예외 되던지기 (Rethrowing)

예외를 잡아서 다시 던지는 것도 가능해요. 이를 통해 더 풍부한 정보와 함께 예외를 전달할 수 있어요. 예외를 감싸서 다시 던지면, 예외 흐름도 이해하기 쉽고, 원인 예외(cause)를 추적할 수 있어 디버깅이 훨씬 쉬워져요.

public class RethrowingExceptionExample {
    public static void main(String[] args) {
        try {
            processFile("file.txt");
        } catch (CustomException e) {
            System.out.println("main 메소드에서 최종적으로 예외가 처리되었습니다.");
            e.printStackTrace(); // 예외의 원인을 추적
        }
    }

    public static void processFile(String fileName) throws CustomException {
        try {
            readFile(fileName);
        } catch (IOException e) {
            System.out.println("processFile 메소드에서 예외가 처리되었습니다.");
            
            // IOException을 CustomException으로 감싸서 다시 던짐
            throw new CustomException("파일 처리 중 문제가 발생했습니다.", e);
        }
    }

    public static void readFile(String fileName) throws IOException {
        if (fileName == null) {
            System.out.println("readFile 메소드에서 예외가 발생했습니다.");
            throw new IOException("파일 이름이 null입니다.");
        }
        // 코드 ...
    }
}

// 사용자 정의 예외
class CustomException extends Exception {
    public CustomException(String message, Throwable cause) {
        super(message, cause); // cause는 원인 예외를 저장
    }
}
  1. readFile() 메서드는 파일 이름이 null이면 IOException을 발생시켜요. 이건 파일 처리 중 생긴 실제 원인 예외(cause)입니다.
  2. processFile() 메서드는 이 IOException을 받아서, 로그를 남기고 더 높은 수준의 의미를 담은 CustomException으로 감싸서 다시 던져요.
    → 이게 바로 예외 되던지기(Rethrowing)예요.
  3. 마지막으로 main() 메서드에서 이 CustomException을 받아서 최종 처리합니다.

 

예외 정보를 얻는 주요 메서드

메서드 설명
getMessage() 예외 메시지를 문자열로 반환해요
printStackTrace() 예외가 발생한 위치를 스택 트레이스로 출력해요. 예외의 원인을 추적해갈 때 유용해요.
getCause() 예외의 근본 원인을 반환해요.

 

사용자 정의 예외 만들기

특정 도메인 로직에 맞게 커스텀 예외 클래스를 정의할 수 있어요.

class MyCustomException  extends Exception {
	private final int code;  // 불변

    public MyCustomException (String message, int code) {
        super(message);
        this.code = code;    // 생성자에서 단 한 번만 초기화
    }
    
    public MyCustomException (String message) {
        super(message);
        this(message, 500);
    }
    
    public int getCode() {
    	return code;
    }
}

사용 예:

try {
    throw new MyCustomException("데이터 오류", 400);
} catch (MyCustomException e) {
    System.out.println("에러: " + e.getMessage() + " (코드: " + e.getCode() + ")");
}

 

예외를 잘 다루기 위한 실전 팁

  • 불필요한 예외 남발 금지: 예외는 비용이 큰 연산이에요. 흐름 제어 용도로 사용하지 않는 게 좋아요.
  • 구체적인 예외 처리 권장: catch (Exception e) 보다 catch (IOException e) 처럼 구체적으로 예외를 처리하는 것이 좋습니다.
  • 예외 메시지는 로그에 남기기: 운영 환경에서는 사용자 메시지와 별개로 e.printStackTrace()나 Logger를 사용해 로그를 남기는 습관이 필요해요.
  • 예외 계층 설계: 사용자 정의 예외를 만들 때는 BaseException 등을 상속받아 체계적으로 구성하면 유지보수가 쉬워져요.