개발/Java

예외

함수형 인간 2025. 3. 27. 17:40

자바(Java) 예외 처리 

자바에서 예외(Exception)란 프로그램 실행 중 예기치 않게 발생하는 오류나 비정상적인 상황을 의미한다. 예외 처리는 이러한 상황에 대비하여 프로그램이 비정상적으로 종료되는 것을 막고, 오류 상황을 적절히 처리하여 프로그램의 안정성과 견고성을 높이는 중요한 메커니즘이다.

1. 예외 계층 구조 (Exception Hierarchy)

자바의 모든 예외 클래스는 Throwable 클래스를 상속받는다. Throwable은 다시 Error와 Exception으로 나뉜다.

  • Error: 시스템 레벨에서 발생하는 심각한 오류로, 주로 JVM 자체의 문제(메모리 부족 - OutOfMemoryError, 스택 오버플로우 - StackOverflowError 등)로 발생. 애플리케이션 코드 수준에서는 일반적으로 복구하거나 처리할 수 없다.
  • Exception: 애플리케이션 코드 수준에서 발생하는 오류로, 개발자가 예외 처리를 통해 대응할 수 있는 대상. Exception은 다시 두 가지 종류로 나뉜다.
    • Checked Exception: Exception 클래스를 직접 상속받으면서 RuntimeException을 상속받지 않는 예외들입니다. (예: IOException, SQLException, ClassNotFoundException). 컴파일 시점에 반드시 예외 처리를 강제 (try-catch 또는 throws 필요). 주로 외부 자원과의 상호작용(파일 입출력, 네트워크 통신, 데이터베이스 연동 등)에서 발생하며, 호출하는 쪽에서 복구 가능성이 있는 상황에 사용.
    • Unchecked Exception (Runtime Exception): RuntimeException 클래스를 상속받는 예외. (예: NullPointerException, ArrayIndexOutOfBoundsException, IllegalArgumentException, ArithmeticException). 컴파일 시점에 예외 처리를 강제하지 않는다. 주로 프로그래머의 논리적인 실수나 부주의로 인해 발생하며, 런타임 시에 감지됨.
             Throwable
              /     \
           Error    Exception
                     /      \
          Checked Ex.   RuntimeException (Unchecked Ex.)
          (IOException,      (NullPointerException,
           SQLException)      ArrayIndexOutOfBoundsException)

 

2. 예외 처리 기본 문법: try, catch, finally

  • try: 예외가 발생할 가능성이 있는 코드를 감싸는 블록.
  • catch: try 블록 내에서 특정 타입의 예외가 발생했을 때 이를 잡아서 처리하는 블록. 여러 개의 catch 블록을 사용하여 다양한 타입의 예외를 처리할 수 있으며, 이때는 하위 타입의 예외를 먼저 명시해야 한다.
  • finally: try 블록 내의 코드가 예외 발생 여부나 catch 블록의 실행 여부와 관계없이 항상 실행되는 블록. 주로 사용했던 자원(파일 핸들, 네트워크 소켓, DB 커넥션 등)을 해제하는 코드를 넣는다. (try-with-resources 등장 이후 사용 빈도가 줄었다.)
Java
 
public void readFile(String filePath) {
    FileReader reader = null;
    try {
        reader = new FileReader(filePath);
        // 파일 읽기 로직...
        System.out.println("파일 읽기 성공");
        // int result = 10 / 0; // ArithmeticException (Unchecked) 발생 시뮬레이션
    } catch (FileNotFoundException e) { // 구체적인 Checked Exception 처리
        System.err.println("파일을 찾을 수 없습니다: " + e.getMessage());
        // 오류 로깅, 사용자 알림 등
    } catch (IOException e) { // FileNotFoundException의 상위 타입 또는 다른 IO 관련 예외 처리
        System.err.println("파일 읽기 중 오류 발생: " + e.getMessage());
    } catch (Exception e) { // 그 외 모든 Exception 처리 (가장 마지막에 위치)
        System.err.println("알 수 없는 오류 발생: " + e.getMessage());
        // 스택 트레이스 출력 등
        e.printStackTrace();
    } finally {
        System.out.println("finally 블록 실행");
        if (reader != null) {
            try {
                reader.close(); // 자원 해제
                System.out.println("FileReader 자원 해제 완료");
            } catch (IOException e) {
                System.err.println("FileReader 자원 해제 중 오류: " + e.getMessage());
            }
        }
    }
}

 

3. 예외 발생시키기: throw

개발자가 코드 내에서 의도적으로 특정 조건에서 예외 객체를 생성하여 발생시킬 수 있다. 주로 유효성 검사 실패 시 사용.

Java
 
public void setAge(int age) {
    if (age < 0) {
        throw new IllegalArgumentException("나이는 음수일 수 없습니다."); // Unchecked Exception 발생
    }
    this.age = age;
}

 

4. 예외 처리 위임하기: throws

메소드 선언부에서 throws 키워드를 사용하여, 해당 메소드 내에서 발생할 수 있는 Checked Exception의 처리를 메소드를 호출한 쪽으로 위임(떠넘기기)할 수 있다. Unchecked Exception은 throws로 선언하지 않아도 됨 (선언해도 상관은 없음).

Java
 
// 이 메소드는 IOException 처리를 호출한 쪽으로 위임
public void processFile(String filePath) throws IOException {
    FileReader reader = new FileReader(filePath); // IOException 발생 가능
    // ... 파일 처리 로직 ...
    reader.close();
}

// processFile 메소드를 호출하는 쪽에서 예외 처리
public void handleFileProcessing() {
    try {
        processFile("myFile.txt");
    } catch (IOException e) {
        System.err.println("파일 처리 중 오류: " + e.getMessage());
    }
}

 

5. Java 7 이상 기능

  • Multi-catch: 하나의 catch 블록에서 | 기호를 사용하여 여러 타입의 예외를 동시에 처리할 수 있다. 코드 중복을 줄여준다.
Java
 
try {
    // ...
} catch (IOException | SQLException e) { // 여러 예외를 한 번에 처리
    System.err.println("IO 또는 SQL 오류 발생: " + e.getMessage());
    // 공통 처리 로직
}

 

  • try-with-resources: AutoCloseable 인터페이스를 구현한 자원(예: InputStream, OutputStream, Reader, Writer, Connection, Statement, ResultSet 등)을 사용할 때 finally 블록 없이도 자동으로 자원을 안전하게 해제해주는 기능.
  • 코드가 간결해지고 자원 누수 위험을 줄여준다. 매우 권장되는 방식.
Java
 
public void readFileWithResources(String filePath) {
    // try() 괄호 안에 선언된 자원은 블록 종료 시 자동으로 close() 호출됨
    try (FileReader reader = new FileReader(filePath);
         BufferedReader br = new BufferedReader(reader)) {

        String line;
        while ((line = br.readLine()) != null) {
            System.out.println(line);
        }
    } catch (FileNotFoundException e) {
        System.err.println("파일을 찾을 수 없습니다: " + e.getMessage());
    } catch (IOException e) {
        System.err.println("파일 읽기 중 오류 발생: " + e.getMessage());
    }
    // finally 블록에서 수동으로 close() 할 필요 없음
}

 

6. 사용자 정의 예외 (Custom Exception)

애플리케이션의 특정 비즈니스 로직에 맞는 예외를 직접 정의하여 사용할 수 있다. Exception 또는 RuntimeException을 상속받아 만든다. 이를 통해 예외의 의미를 더 명확하게 전달하고, 예외 처리 로직을 구분하기 용이.

Java
 
// Checked Exception으로 정의
class InsufficientBalanceException extends Exception {
    public InsufficientBalanceException(String message) {
        super(message);
    }
}

// Unchecked Exception으로 정의
class InvalidAccountException extends RuntimeException {
    public InvalidAccountException(String message) {
        super(message);
    }
}

public void withdraw(double amount) throws InsufficientBalanceException {
    if (this.balance < amount) {
        throw new InsufficientBalanceException("잔액이 부족합니다.");
    }
    this.balance -= amount;
}

 

7. 예외 전환과 연결 (Exception Chaining)

낮은 수준(Low-level)의 예외를 잡아서 더 의미있고 추상화된 높은 수준(High-level)의 예외로 변환하여 다시 던지는 기법입니다. 이때 원래 발생했던 근본 원인 예외(cause)를 새로운 예외에 포함시켜 전달할 수 있는데, 이를 예외 연결(Exception Chaining)이라고 한다. 디버깅 시 근본 원인을 추적하는 데 매우 유용.

Java
 
public void processUserData(String userId) throws UserDataAccessException {
    try {
        // 데이터베이스 조회 로직...
        // SQLException 발생 가능
    } catch (SQLException e) {
        // SQLException을 UserDataAccessException으로 전환하여 던짐
        // 원래 SQLException을 cause로 연결
        throw new UserDataAccessException("사용자 데이터 접근 중 오류 발생 (ID: " + userId + ")", e);
    }
}

class UserDataAccessException extends Exception {
    public UserDataAccessException(String message, Throwable cause) {
        super(message, cause); // Throwable을 받는 생성자 사용
    }
}

 

8. 예외 처리 Best Practice 및 주의사항

  • 예외를 무시하지 마세요 (Don't swallow exceptions): catch 블록을 비워두거나 단순히 e.printStackTrace()만 호출하는 것은 좋지 않다. 최소한 로그를 남기거나, 적절한 복구 로직을 수행하거나, 더 적합한 예외로 전환하여 던져야 한다.
  • 구체적인 예외를 잡으세요: catch (Exception e)와 같이 너무 일반적인 예외를 잡기보다는, 처리 가능한 구체적인 하위 타입의 예외를 먼저 잡는 것이 좋다.
  • 자원 해제는 finally 또는 try-with-resources에서: 사용한 자원은 반드시 해제하여 메모리 누수나 시스템 자원 고갈을 방지해야 한다. try-with-resources 사용을 적극 권장합니다.
  • Checked vs Unchecked 신중히 선택: 복구가 가능하고 호출자가 반드시 인지해야 하는 상황에는 Checked Exception을, 프로그래밍 오류나 복구가 불가능한 내부 상태 오류에는 Unchecked Exception을 사용하는 것이 일반적. (API 설계 시 중요한 고려사항)
  • 예외를 흐름 제어용으로 사용하지 마세요: 예외는 이름 그대로 예외적인 상황을 처리하기 위한 것이지, 일반적인 프로그램 로직 흐름을 제어하기 위해 남용해서는 안된다. 성능 저하를 유발할 수 있다.
  • 적절한 추상화 수준의 예외 던지기: 예를 들어, 서비스 계층에서는 데이터 접근 계층의 SQLException을 그대로 던지기보다는, 서비스 계층에 맞는 비즈니스 예외(예: UserDataAccessException)로 전환하여 던지는 것이 좋다. (캡슐화)
  • Javadoc @throws 활용: 메소드가 던질 수 있는 Checked Exception은 Javadoc의 @throws 태그를 사용하여 명시적으로 문서화하는 것이 좋다.
  • 로그는 필수: 예외 발생 시 원인 분석을 위해 로그 레벨(Error, Warn 등)을 적절히 설정하고, 스택 트레이스와 함께 유의미한 컨텍스트 정보(파라미터 값 등)를 로그로 남겨야 한다.