Skip to Content
독학사독학사 3단계객체지향프로그래밍13. 예외 처리 심화와 사용자 정의 예외

이번 문서의 목표: 이 파일을 다 읽으면 여러 catch 블록이 있을 때 어떤 순서로 매칭되는지, 예외를 잡았다가 다시 던지는 상황과 사용자 정의 예외를 언제 어떻게 설계하는지, 리소스를 안전하게 정리하는 패턴까지 코드로 정확히 설명할 수 있다.

왜 이 주제가 중요한가

13편에서 예외 계층과 기본 try-catch-finally 실행 순서를 다졌다. 그런데 실제 시험 문제는 여기서 한 단계 더 들어가, catch 블록이 여러 개 있을 때 어느 것이 먼저 실행되는지, 잡은 예외를 그대로 던지거나 다른 예외로 바꿔 던지면 어떻게 되는지, 특정 상황에 맞는 사용자 정의 예외를 어떻게 설계하는지를 묻는다. 이 편에서는 다중 catch의 매칭 규칙, 예외 재던지기(rethrow), 사용자 정의 예외 클래스, 그리고 파일·네트워크 자원을 안전하게 정리하는 패턴을 코드 추적으로 끝까지 파고든다.

여러 catch 블록: 위에서부터 순서대로, 첫 매칭에서 멈춘다

쉽게 말하면: catch 블록이 여러 개 있으면 Java는 위에서부터 차례로 “이 예외가 이 catch 타입에 맞는가”를 검사하다가, 처음으로 맞는 catch 하나만 실행하고 나머지는 모두 건너뛴다.

09편에서 다형성을 배울 때 다뤘던 “부모 타입 참조가 자식 타입 객체를 가리킬 수 있다”는 원리가 catch 매칭에도 그대로 적용된다. catch 블록은 지정한 예외 타입 자신뿐 아니라 그 자손 타입의 예외까지 모두 잡는다. 따라서 여러 catch를 나열할 때는 더 구체적인(자손) 타입을 먼저, 더 포괄적인(조상) 타입을 나중에 배치해야 한다.

public class MultiCatch { public static void main(String[] args) { int[] arr = {1, 2, 3}; try { System.out.println(arr[10]); } catch (ArrayIndexOutOfBoundsException e) { System.out.println("1. 배열 범위 예외: " + e.getMessage()); } catch (RuntimeException e) { System.out.println("2. 런타임 예외: " + e.getMessage()); } catch (Exception e) { System.out.println("3. 일반 예외: " + e.getMessage()); } } }
1. 배열 범위 예외: Index 10 out of bounds for length 3

한 줄씩 실행 추적: 실제로 발생한 예외는 ArrayIndexOutOfBoundsException이다. 이 클래스는 IndexOutOfBoundsException을 거쳐 RuntimeException, 다시 Exception을 상속하는 자손이므로, 세 catch 모두 이론상 이 예외를 잡을 수 있는 타입이다. 하지만 Java는 위에서부터 순서대로 검사하다가 첫 번째로 매칭되는 catch (ArrayIndexOutOfBoundsException e)에서 멈춘다. 두 번째, 세 번째 catch는 애초에 검사조차 되지 않고 건너뛴다.

만약 순서를 반대로 뒤집어 Exception을 가장 위에 둔다면 어떻게 될까?

public class MultiCatchWrongOrder { public static void main(String[] args) { try { System.out.println(1 / 0); } catch (Exception e) { System.out.println("1. 일반 예외로 먼저 잡힘"); } catch (ArithmeticException e) { System.out.println("2. 이 줄은 컴파일 오류를 유발한다"); } } }
컴파일 오류: exception ArithmeticException has already been caught

ArithmeticExceptionException의 자손이므로, 이미 위에서 catch (Exception e)가 모든 Exception 계열을 다 잡을 수 있는 상태다. 이 아래에 더 구체적인 ArithmeticException을 놓으면 절대 도달할 수 없는 catch 블록이 되므로, Java 컴파일러는 이를 컴파일 오류로 처리한다.

시험 함정: “catch 블록은 조상 타입을 먼저 적어도 문제없다”는 설명은 옳지 않은 것 고르기의 단골 오답이다. 자손 타입을 조상 타입보다 반드시 먼저 적어야 하며, 순서를 어기면 컴파일 오류가 발생한다.

예외 재던지기: 잡았다가 다시 던지기

쉽게 말하면: catch 블록에서 예외를 일단 붙잡아 로그를 남기거나 뒷정리를 한 다음, “이 문제는 나보다 상위에서 처리해야 한다”고 판단되면 같은 예외 혹은 다른 예외로 다시 던질 수 있다.

public class RethrowExample { static void 파일처리() throws Exception { try { int[] arr = new int[2]; System.out.println(arr[5]); } catch (ArrayIndexOutOfBoundsException e) { System.out.println("파일처리에서 1차 로그: " + e.getMessage()); throw e; } } public static void main(String[] args) { try { 파일처리(); } catch (Exception e) { System.out.println("main에서 최종 처리: " + e.getMessage()); } } }
파일처리에서 1차 로그: Index 5 out of bounds for length 2 main에서 최종 처리: Index 5 out of bounds for length 2

한 줄씩 실행 추적: 파일처리 메서드는 예외를 catch로 일단 붙잡아 로그("파일처리에서 1차 로그: ...")를 남긴 뒤, throw e;로 같은 예외 객체를 다시 던진다. 이 메서드는 throws Exception을 선언했으므로 재던지기가 허용된다. 다시 던져진 예외는 파일처리를 호출한 main으로 전파되고, maincatch (Exception e)에서 최종적으로 처리된다.

예외를 다른 타입으로 감싸서 던지기: 예외 전환

때로는 저수준 예외를 그대로 노출하지 않고, 더 의미 있는 상위 수준의 예외로 바꿔 던지는 것이 바람직하다. 이때 원래 예외를 원인(cause)으로 함께 담아두면, 근본 원인을 잃지 않으면서도 호출자에게는 더 명확한 의미의 예외를 전달할 수 있다.

public class ExceptionWrapping { static void 사용자조회(int id) throws RuntimeException { try { int[] db = {100, 200}; System.out.println(db[id]); } catch (ArrayIndexOutOfBoundsException e) { throw new RuntimeException("존재하지 않는 사용자 ID: " + id, e); } } public static void main(String[] args) { try { 사용자조회(5); } catch (RuntimeException e) { System.out.println("메시지: " + e.getMessage()); System.out.println("원래 원인: " + e.getCause()); } } }
메시지: 존재하지 않는 사용자 ID: 5 원래 원인: java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 2

new RuntimeException(메시지, e)처럼 두 번째 인자로 원래 예외를 넘기면, getCause()(예외의 근본 원인을 반환)로 나중에 원래 문제(배열 범위 초과)를 그대로 추적할 수 있다. 이는 “배열 구현이 바뀌어도 호출자는 여전히 의미 있는 예외 메시지를 받는다”는 캡슐화(06편)의 원리를 예외 처리에 적용한 것이다.

사용자 정의 예외: 도메인에 맞는 예외 클래스 만들기

쉽게 말하면: Java가 기본으로 제공하는 예외만으로는 “잔액 부족”, “나이 제한 위반” 같은 우리 프로그램만의 특수한 오류 상황을 표현하기 어려우므로, 직접 예외 클래스를 만들어 쓴다.

사용자 정의 예외는 Exception(checked 예외로 만들고 싶을 때) 또는 RuntimeException(unchecked 예외로 만들고 싶을 때)을 상속해 정의한다. 대부분 필드 없이 생성자만 오버로딩(08편)해, 부모 클래스의 생성자를 super(05편)로 호출하는 짧은 구조를 갖는다.

class 잔액부족예외 extends Exception { public 잔액부족예외(String message) { super(message); } } class 계좌 { private int 잔액; public 계좌(int 초기잔액) { this.잔액 = 초기잔액; } public void 출금(int 금액) throws 잔액부족예외 { if (금액 > this.잔액) { throw new 잔액부족예외("잔액 " + this.잔액 + "원보다 " + 금액 + "원을 더 많이 출금할 수 없습니다."); } this.잔액 -= 금액; System.out.println(금액 + "원 출금 완료. 남은 잔액: " + this.잔액 + "원"); } } public class CustomExceptionExample { public static void main(String[] args) { 계좌 내계좌 = new 계좌(10000); try { 내계좌.출금(3000); 내계좌.출금(20000); } catch (잔액부족예외 e) { System.out.println("출금 실패: " + e.getMessage()); } } }
3000원 출금 완료. 남은 잔액: 7000원 출금 실패: 잔액 7000원보다 20000원을 더 많이 출금할 수 없습니다.

한 줄씩 실행 추적: 잔액부족예외Exception을 상속해 checked 예외로 만들었으므로, 출금 메서드는 반드시 throws 잔액부족예외를 선언해야 하고 호출자인 main도 반드시 try-catch로 처리해야 한다. 첫 번째 출금(3000)은 잔액(10000원)이 충분해 정상 실행되어 잔액이 7000원으로 줄어든다. 두 번째 출금(20000)은 잔액(7000원)보다 큰 금액을 요청하므로 조건문이 참이 되어 잔액부족예외를 생성해 던진다. 이 예외는 main의 catch에서 잡혀 e.getMessage()로 미리 담아둔 상세 메시지를 그대로 꺼내 볼 수 있다.

시험 함정: 사용자 정의 예외가 Exception을 상속하면 checked, RuntimeException을 상속하면 unchecked가 된다는 규칙은 사용자 정의 예외에도 그대로 적용된다. “사용자가 직접 만든 예외는 항상 unchecked다”라는 설명은 틀린 문장이다.

리소스 정리 패턴: try-with-resources

쉽게 말하면: 파일이나 네트워크 연결처럼 다 쓰고 나면 반드시 닫아야 하는 자원을, 예외가 발생하든 안 하든 자동으로 닫아 주는 문법이다.

13편에서 finally가 예외 발생 여부와 무관하게 항상 실행된다는 점을 배웠다. 전통적으로는 파일 같은 자원을 다 쓴 뒤 닫는 코드를 finally 블록에 직접 작성했다.

import java.io.FileReader; import java.io.IOException; public class FinallyCloseExample { public static void main(String[] args) { FileReader reader = null; try { reader = new FileReader("data.txt"); System.out.println("파일 읽기 시작"); } catch (IOException e) { System.out.println("오류: " + e.getMessage()); } finally { if (reader != null) { try { reader.close(); } catch (IOException e) { System.out.println("닫기 오류: " + e.getMessage()); } } } } }

finally 안에서 자원을 닫는 코드조차 또 다른 예외(close()가 실패할 수 있음)를 던질 수 있어, 위 코드처럼 try-catch가 중첩되는 번거로움이 생긴다. Java 7부터는 AutoCloseable(오토클로저블, “스스로 닫을 수 있는” 인터페이스로 close() 메서드를 정의)을 구현한 자원이라면 try-with-resources 구문으로 이 과정을 훨씬 간결하게 표현할 수 있다.

import java.io.FileReader; import java.io.IOException; public class TryWithResourcesExample { public static void main(String[] args) { try (FileReader reader = new FileReader("없는파일.txt")) { System.out.println("파일 읽기 시작"); } catch (IOException e) { System.out.println("오류: " + e.getMessage()); } System.out.println("자원은 이미 자동으로 닫혔다"); } }
오류: 없는파일.txt (지정된 파일을 찾을 수 없습니다) 자원은 이미 자동으로 닫혔다

try (자원선언) 괄호 안에서 선언한 자원은, try 블록을 벗어나는 순간(정상 종료든 예외 발생이든) 자동으로 close()가 호출된다. 개발자가 finally에 닫는 코드를 직접 쓰지 않아도 되므로, 자원을 닫는 것을 깜박하는 실수를 원천적으로 막는다.

방식자원 닫기 코드 위치닫기를 깜박할 위험도입 시점
전통적인 finally개발자가 finally 안에 직접 작성, close() 자체의 예외도 처리해야 함있음 (finally 작성을 빠뜨리면 자원 누수)초기 Java부터
try-with-resourcestry 괄호 안 선언만으로 자동 처리낮음 (컴파일러가 자동으로 닫는 코드를 생성)Java 7 이상

시험 함정: try-with-resources를 쓰려면 자원 클래스가 AutoCloseable(또는 그 자손인 Closeable) 인터페이스를 구현하고 있어야 한다. 아무 클래스나 try (...) 괄호 안에 넣을 수 있는 것은 아니다.

자주 틀리는 점

  • 여러 catch를 나열할 때 조상 타입을 자손 타입보다 먼저 적어, “도달할 수 없는 catch 블록”이라는 컴파일 오류를 만든다.
  • 예외를 재던질 때 throws 선언을 메서드 시그니처에 빠뜨려 컴파일 오류를 낸다.
  • 사용자 정의 예외가 어떤 클래스를 상속했는지 확인하지 않고, “직접 만든 예외이니 무조건 unchecked”라고 단정한다.
  • 예외를 감싸 던질 때(new RuntimeException(msg, e)) 원래 원인 e를 넘기지 않아, 나중에 getCause()로 근본 원인을 추적할 수 없게 만든다.
  • try-with-resources 괄호 안에 AutoCloseable을 구현하지 않은 일반 객체를 넣으려 해 컴파일 오류가 나는 경우를 놓친다.

핵심 정리

  • catch 블록은 위에서부터 순서대로 검사되며, 예외 타입이 처음 일치하는 하나만 실행되고 나머지는 건너뛴다. 자손 타입을 조상 타입보다 반드시 먼저 배치해야 한다.
  • throw e;로 잡은 예외를 그대로 재던지거나, new 예외타입(메시지, 원인예외)처럼 다른 타입으로 감싸 던질 수 있다. 감싸 던질 때 원인을 함께 넘기면 getCause()로 근본 원인을 추적할 수 있다.
  • 사용자 정의 예외는 Exception(checked) 또는 RuntimeException(unchecked)을 상속해 만들며, 생성자에서 super(message)로 부모 생성자를 호출하는 것이 기본 형태다.
  • try-with-resources는 AutoCloseable을 구현한 자원을 try 괄호 안에서 선언하면, try 블록을 벗어날 때 자동으로 close()를 호출해 자원 누수를 예방한다.

마무리 복습

public class Q1 { public static void main(String[] args) { try { Object o = "문자열"; Integer n = (Integer) o; } catch (NullPointerException e) { System.out.println("1"); } catch (ClassCastException e) { System.out.println("2"); } catch (RuntimeException e) { System.out.println("3"); } } }
문제 14지선다
위 코드를 실행했을 때 출력되는 값은?
class A extends Exception {} class B extends A {} public class Q2 { static void m() throws A { throw new B(); } public static void main(String[] args) { try { m(); } catch (B e) { System.out.println("B에서 잡힘"); } catch (A e) { System.out.println("A에서 잡힘"); } } }
문제 24지선다
위 코드를 컴파일하고 실행하면 어떻게 되는가?
class 재고부족예외 extends RuntimeException { public 재고부족예외(String message) { super(message); } } public class Q3 { static void 주문(int 재고, int 요청) { if (요청 > 재고) { throw new 재고부족예외("재고 부족: 남은 수량 " + 재고); } System.out.println("주문 성공"); } public static void main(String[] args) { 주문(5, 10); } }
문제 34지선다
위 코드에 대한 설명으로 옳은 것은?
문제 44지선다
예외를 다른 타입으로 감싸 던질 때(exception wrapping) 원래 예외를 두 번째 생성자 인자로 함께 넘기는 이유로 가장 적절한 것은?
문제 54지선다
try-with-resources 구문에 대한 설명으로 옳지 않은 것은?
class 계좌 { private int 잔액 = 1000; void 출금(int 금액) { try { if (금액 > 잔액) { throw new IllegalArgumentException("잔액 부족"); } 잔액 -= 금액; } catch (IllegalArgumentException e) { System.out.println("1차 처리: " + e.getMessage()); throw new RuntimeException("출금 실패", e); } } } public class Q6 { public static void main(String[] args) { 계좌 acc = new 계좌(); try { acc.출금(5000); } catch (RuntimeException e) { System.out.println("2차 처리: " + e.getMessage()); System.out.println("원인: " + e.getCause().getMessage()); } } }
문제 64지선다
위 코드를 실행했을 때 출력되는 세 줄의 순서와 내용으로 옳은 것은?

참고 자료

Last updated on