예외 처리
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:
런타임에 발생하며, 컴파일 시점에는 체크되지 않아요. 대표적으로 NullPointerException, ArrayIndexOutOfBoundsException, IllegalArgumentException 등이 있어요.
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는 원인 예외를 저장
}
}
- readFile() 메서드는 파일 이름이 null이면 IOException을 발생시켜요. 이건 파일 처리 중 생긴 실제 원인 예외(cause)입니다.
- processFile() 메서드는 이 IOException을 받아서, 로그를 남기고 더 높은 수준의 의미를 담은 CustomException으로 감싸서 다시 던져요.
→ 이게 바로 예외 되던지기(Rethrowing)예요. - 마지막으로 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 등을 상속받아 체계적으로 구성하면 유지보수가 쉬워져요.