[Codeit Review]/객체 지향 프로그래밍

[05] 예외 처리 (Exception Handling)

uzlru 2026. 1. 26. 15:18

01. 예외

1. 오류 vs 예외

  오류
(Error)
예외
(Exception)
의미 시스템 레벨에서 발생하는 치명적인 문제 개발자가 처리할 수 있는 문제
예시 메모리 부족(OutOfMemoryError),
스택 오버플로우(StackOverflowError)
NullPointerException,
IllegalArgumentException
복구 가능 여부 거의 불가능 비교적 복구 가능
처리 방법 JVM 차원 처리 개발자가 try-catch 등으로 처리

 

2. 예외 처리의 중요성

예외 처리는 모든 프로그래밍 언어에서 매우 중요한 개념이다. 프로그램이 실행되는 동안 파일 부재, 네트워크 연결 실패, 잘못된 입력 제공, DB 연결 문제 등 다양한 상황이 발생하는데 이때 예외 처리가 없다면 프로그램은 즉시 종료되기 때문이다. 하지만 자바는 체계적인 예외 계층 구조를 제공하여 프로그램의 문제를 감지하고 적절하게 대응할 수 있다.

 

그러므로 예외의 원인은 명확하게 기록해야 하며, 특수한 문제 상황을 다루는 용도로만 사용해야 한다.

 

예외 처리의 주요 역할  
프로그램 안정성 예기치 못한 종료 방지
사용자 경험 향상 친절한 오류 메시지 제공
에러 로깅 문제 원인 분석 가능
방어적 프로그래밍 잠재적인 문제를 미리 차단

 

 

3. 자바의 예외 계층 구조

자바는 모든 예외를 하나의 체계적인 계층 구조로 관리한다.

예외 계층 구조 설명
Throwable 자바에서 던질 수 있는 모든 객체의 최상위 부모
Error 시스템 단계에서 발생하는 치명적인 오류로, 개발자가 처리하지 않음
Exception 개발자가 처리 가능한 모든 예외의 부모 클래스
RuntimeException 실행 중 자주 발생하는 예외들의 부모

 

[사진 1] Java의 예외 계층 구조

 

/* 예외로 인한 프로그램 종료 예시 */

public class Example {
    public static void main(String[] args) {
        methodA();
    }

    static void methodA() {
        methodB();
    }

    static void methodB() {
        String s = null;
        s.length(); 		// NullPointerException → methodB → methodA → main 순으로 전달
    }
}

 


 

02. 체크드 예외 (Checked Exception) vs 언체크드 예외 (Unchecked Exception)

우선 체크드 예외란 컴파일 시점에 반드시 처리해야 하는 예외이다. 만약 체크드 예외를 처리하지 않고 프로그램을 그대로 실행시키면, 컴파일 에러가 발생한다. 대표적으로 입출력 예외(IOException), 데이터베이스 예외(SQL Exception), 파일 부재 예외(FileNotFoundException)가 있다.

 

그에 비해 언체크드 예외란 컴파일러가 처리 여부를 강제하지 않는 예외를 의미한다. 즉, try-catch를 쓰지 않아도 아무런 문제없이 컴파일되지만, 실행 중 실패하는 예외이다. 대표적으로 Null 예외(NullPointerException), 인자 값 예외 (IllgalArgumentException), 인덱스 예외(IndexOutOfBoundsException)가 있다.

 

주요 특징 체크드 예외 언체크드 예외
처리 강제 여부 반드시 처리해야 함 강제되지 않음
예외 처리 방식 try-catch 혹은 throws 선택적 처리
발생 시점 컴파일 단계에서 감지 실행 중 (Runtime)
발생 이유 외부 환경의 불확실성
>> 파일, 네트워크, DB 등
주로 개발자의 실수 또는 논리 오류
복구 가능성 있음 없음
대표 발생 주체 JVM 외부 시스템 코드 내부 논리
실무 활용 안전성 보장을 위해 꼭 필요한 경우 사용 일반적으로 선호
사용 상황 외부 시스템과 상호작용
>> 실패가 예상되고, 복구할 방법이 있을 때
잘못된 파리미터 전달, 비지니스 규칙 위반, 복구 불가능 오류 표현
>> 예외 메시지를 자세히 남겨야 함

 

// 1. 체크드 예외

public void readFile(String path) throws IOException {
    Files.readAllLines(Path.of(path));
}

// 2. 언체크드 예외 (파리미터 검증)
public void setAge(int age) {
    if (age < 0) {
        throw new IllegalArgumentException("나이는 음수가 될 수 없습니다.");
    }
}

 


 

03. 예외 처리 문법

1. throws 키워드

throws 키워드는 메서드에서 발생할 수 있는 예외의 책임을 호출한 쪽으로 넘기는 문법이다. 그렇기에 하위 메서드를 호출하는 코드는 직접 try-catch 문을 통해 예외를 잡아서 처리하거나, 다시 throws를 통해 상위 클래스로 전파해야 한다.

 

2. try-catch-finally 기본 문법

try-catch-finally 블록은 예외 처리의 핵심이다. 해당 구조는 프로그램이 예외를 만났을 떄, 종료되지 않고 안전하게 복구하거나 정리 작업을 수행하도록 만들어준다.

 

블록 종류   목적
try 예외가 발생할 가능성이 있는 코드를 감싸는 영역 정상 흐름에서 실행해야 할 코드를 작성
>> 예외가 발생하는 즉시 catch 블록으로 흐름 이동
catch try 블록에서 발생한 예외를 처리하는 영역 예외 처리를 위한 catch 블록을 위에서 아래 방향으로 실행
>> 발생한 예외 타입과 catch의 예외 타입이 일치하면 실행
finally 예외가 발생하든 발생하지 않든 반드시 실행되는 영역 자원을 담는 역할을 수행
>> 파일, 소켓, DB 커넥션 등

 

// 1. 단일 try-catch 문
try {
    int result = 10 / 0;
} catch (ArithmeticException e) {
    System.out.println("0으로 나눌 수 없습니다.");
}

// 2. 다중 try-catch 문
try {
    FileReader fr = new FileReader("test.txt");
    int data = fr.read();
} catch (FileNotFoundException e) {
    System.out.println("파일을 찾을 수 없습니다.");
} catch (IOException e) {
    System.out.println("파일을 읽는 도중 오류가 발생했습니다.");
}

// 3. try-catch-finally 문
try {
    FileInputStream fis = new FileInputStream("data.txt");
} catch (IOException e) {
    System.out.println("IOException 발생");
} finally {
    System.out.println("항상 실행됩니다!");
}

 

3. finally 실행 여부표

상황 finally 실행 여부
예외 발생 ✔ 실행됨
예외 발생하지 않음 ✔ 실행됨
catch에서 return 실행 ✔ 실행됨
try에서 return 실행 ✔ 실행됨
catch에서 예외 발생 ✔ 실행됨

 

4. try-with-resources

자원을 사용하는 작업에서 finally 블록의 가독성 개선을 위해 등장한 문법이다. try (...) 내부에 선언한 자원은 자동으로 close()를 호출할 뿐만 아니라 close 호출 순서를 역순으로 보장하여, finally 블록을 사용하지 않아도 된다. 단, TWR에 넣는 객체는 반드시 AutoCloseable 인터페이스를 구현해야 한다. 객체는 자체적으로 close() 함수를 호출하고, 만약 try 블록 혹은 인터페이스에서 예외가 발생한다면, 해당 인터페이스를 구현한 것이 자원 객체이므로 프로그램은 예외를 들고 catch 블록으로 이동하여 예외를 처리한다.

 

// 1. try-catch-finally 문 사용
FileInputStream fis = null;

try {
    fis = new FileInputStream("data.txt");
} catch (IOException e) {
    // ...
} finally {
    if (fis != null) {
        try { fis.close(); } catch (IOException e) {}
    }
}

// 2. try-with-resources 문 사용
try (FileInputStream fis = new FileInputStream("data.txt")) {
    int data = fis.read();
} catch (IOException e) {
    e.printStackTrace();
}

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

 

5. TWR과 finally 자원 해제 비교

  try-finally try-with-resources
자원 해제 위치 finally 내부 자동 close()
코드 길이 길고 복잡 매우 짧고 직관적
예외 중첩 처리 어려움 suppressed exception으로 자동 관리
실무 권장 비추천 적극 권장

 


04. 예외 처리 시 고려사항 및 베스트 프랙티스

 

예외 처리는 프로그램의 안정성과 유지보수에 직접적인 영향을 미치는 설계 작업이다. 예외 처리를 잘 설계한다면 시스템을 튼튼하게 유지하지만 반대로 잘못된 예외 처리 설계는 문제를 숨기거나 버그를 추적하기 어렵게 만든다.

 

1. Fail-fast 원칙

Fail-Fast란 잘못된 입력, 잘못된 상태, 예기치 못한 흐름 등을 발견했을 때 최대한 빠르게 오류를 발생시키는 것을 의미한다. 즉, 문제가 발생한 즉시 예외를 던져 더 큰 사고로 번지지 않도록 막는 것이다. 이러한 Fail-Fast는 오류를 초기 단계에서 발견할 수 있을 뿐만 아니라 디버깅 및 문제 분석을 쉽게 하고, 버그가 전파되는 것을 막아 시스템 전체의 안전성을 확보한다.

 

// 1. Fail-Fast 적용
public class UserService {

    public User findUserById(Long id) {
        // 예제 1: null 값 허용하지 않음
        if (id == null) {
            throw new IllegalArgumentException("사용자 ID는 null일 수 없습니다.");
        }

        User user = repository.findById(id);

        // 예제 2: 유효하지 않은 상태 즉시 중단
        if (user == null) {
            throw new IllegalStateException("해당 ID의 사용자를 찾을 수 없습니다. id=" + id);
        }

        return user;
    }
}

// 2. Fail-Fast 미적용
public User findUserById(Long id) {
    if (id == null) return null;		// 문제를 조용히 넘기는 행위

    User user = repository.findById(id);
    return user;				// user가 null이어도 호출한 쪽에서 예외를 만나 문제 발생
}

 

2. 예외 삼키키 (Swallowing Exception)

예외 삼키기란 예외를 catch 했지만 아무 처리를 하지 않고 넘어가 버리는 것을 의미한다. 즉, catch 블록이 비워져 있는 상태로, 개발자가 절대 해서는 안 되는 행위 중 하나다.

 

예외 삼키기의 문제점  
버그 조기 발견 불가능 오류는 발생했지만 로그가 없어 개발자가 인지하지 못함
장애 전파 예외가 숨겨지면 더 큰 문제로 번짐
상태 불일치 예외 후의 상태가 정상인지 보장할 수 없음
디버깅 불가능 무엇이 문제였는지 기록이 없어 원인 추적 어려움

 

// 1. 예외 숨기기 예제
public void updateUser(User user) {
    try {
        repository.save(user);
    } catch (Exception e) {
    	// 예외 삼키기
    }
}

2. 올바른 catch 블록
catch (Exception e) {
    log.error("사용자 저장 실패: {}", user.getId(), e);
    throw e; // 다시 던져 호출자가 처리하도록 위임
}

 

3. 예외 전환 (Exception Chaining) 및 래핑 (Wrapping)

 

예외 전환이란 낮은 레벨의 예외를 더 의미 있는 상위 레벨의 예외로 바꿔 던지는 기술이다. 즉, 예외는 낮은 레벨는 맥락만 추가하고 높은 레벨에서 처리함을 의미한다. 예외 전환은 내부 구현을 노출하지 않고 추상화 수준을 유지하여, 호출자에게 예외의 의미와 적절한 정보를 명확하게 전달한다.

 

이러한 예외 전환을 할 때에는, 에러의 원인 정보를 담고 있는 예외 객체(e)와 에러가 발생하기까지 거쳐온 메서드들의 목록인 스택 트레이스(Stack Trace)를 함께 로깅(Logging)해야 한다. 즉, 오류의 원인과 원인이 발생하기까지의 경로를 함께 기록해야만 오류를 빠르고 정확하게 바로 잡을 수 있다.

좋은 예외 메시지 작성 기준  
주체성 무엇이, 왜, 어떤 값 때문에 발생했는지
맥락 제공 상황, 입력값, 시스템 상태 등을 포함
사용자 메시지와 개발자 메시지 분리 운영 사용자에게 과도한 내부 정보 노출 금지

 

// 1. 잘못된 예외 전환 예시
catch (SQLException e) {
    throw new RuntimeException("저장 실패");	// 원인 정보가 사라짐
}

// 2. 올바른 예외 전환 예시
public void saveOrder(Order order) {
    try {
        repository.insert(order);
    } catch (SQLException e) {
        throw new OrderSaveFailedException("주문 저장 중 문제가 발생했습니다.", e);
    }
}

// 3. 좋은 메시지 예시
throw new IllegalArgumentException(
    "상품 가격(price=\" + price + ")은 0보다 커야 합니다. 입력값을 확인해주세요."
);

// 4. 나쁜 메시지 예시
throw new IllegalArgumentException("오류 발생");	// 추상적인 설명