Skip to Content
독학사독학사 3단계객체지향프로그래밍12. 예외 처리와 예외 계층 구조

이번 문서의 목표: 이 파일을 다 읽으면 Java 예외 클래스 계층에서 어떤 예외가 checked이고 어떤 예외가 unchecked인지 그 이유까지 설명할 수 있고, try-catch-finally가 섞인 코드의 실행 순서를 한 줄씩 추적해 정확히 예측할 수 있다.

왜 이 주제가 중요한가

09편에서 다형성을 배울 때 “실행 중 문제가 생기면 프로그램이 곧바로 죽어버릴 수도 있다”는 점을 잠깐 언급했다. 배열의 범위를 넘어선 인덱스에 접근하거나, null인 참조 변수의 메서드를 호출하거나, 정수를 0으로 나누면 프로그램이 비정상 종료된다. 예외 처리(exception handling)는 이런 실행 중 오류 상황을 프로그램이 직접 붙잡아, 죽지 않고 적절히 대응하도록 만드는 문법이다.

독학사 3단계 시험에서 예외 처리는 단순 암기가 아니라 실행 순서 추적형 문제로 자주 나온다. try 블록 중간에서 예외가 터졌을 때 그 뒤 코드가 실행되는지, catch에서 잡은 뒤 finally가 언제 실행되는지, 여러 catch 중 어떤 것이 먼저 매칭되는지를 코드로 묻는다. 이 편에서는 예외 클래스 계층 구조부터 시작해 checked·unchecked 구분, throws 선언, try-catch-finally의 정확한 실행 순서까지 빈틈없이 다진다.

예외란 무엇인가: 오류 상황의 객체화

쉽게 말하면: 예외(exception)는 “지금 프로그램에 문제가 생겼다”는 사실을 하나의 객체로 만들어, 코드 사이로 던지고 받을 수 있게 한 것이다.

프로그램을 실행하다 보면 컴파일러가 걸러내지 못하는 오류가 실행 중(runtime, 런타임)에 발생할 수 있다. 배열 인덱스가 범위를 벗어나거나, 파일이 존재하지 않거나, 사용자가 숫자 대신 문자를 입력했을 때가 그렇다. Java는 이런 상황을 예외 객체(exception object)로 표현한다. 오류가 발생한 지점에서 예외 객체를 만들어 “던지고”(throw, 스로우), 이를 처리할 수 있는 코드 블록이 “잡아서”(catch, 캐치) 대응한다.

이 방식의 장점은 오류 발생 지점과 오류 처리 지점을 분리할 수 있다는 점이다. 오류가 발생한 메서드가 스스로 처리 방법을 몰라도, 그 메서드를 호출한 상위 코드가 예외 객체를 넘겨받아 대신 처리할 수 있다. 이것이 뒤에서 다룰 예외 전파(exception propagation)의 핵심 아이디어다.

예외 클래스 계층: Throwable부터 시작한다

쉽게 말하면: Java의 모든 예외는 결국 하나의 조상 클래스 Throwable에서 뻗어 나온 자손 클래스이며, 이 계층에서 어느 가지에 속하는지가 곧 그 예외의 성격을 결정한다.

08편에서 배운 상속 개념이 예외 클래스에도 그대로 적용된다. Java의 모든 예외와 오류는 java.lang.Throwable(스로어블, “던질 수 있는 것”이라는 뜻)이라는 최상위 클래스를 상속한다. Throwable은 크게 두 갈래로 나뉜다.

  • Error(에러): OutOfMemoryError(메모리 부족), StackOverflowError(스택 오버플로, 재귀 호출이 너무 깊어져 호출 스택이 넘침)처럼 애플리케이션 코드가 손쓸 수 없는 심각한 시스템 레벨 문제다. 일반적으로 잡아서 처리하지 않는다.
  • Exception(예외): 프로그램 로직에서 발생하며, 적절히 처리하면 프로그램을 계속 실행할 수 있는 문제다. 시험에서 다루는 예외 처리는 사실상 이 Exception 계열에 집중된다.

Exception은 다시 checked 예외unchecked 예외(정확히는 RuntimeException과 그 자손)로 나뉘는데, 이 구분이 시험에서 가장 자주 출제되는 지점이다.

checked 예외 vs unchecked 예외: 컴파일러가 강제하는가

쉽게 말하면: checked 예외는 “너 이거 처리 안 하면 컴파일 자체를 안 시켜줄게”라고 컴파일러가 강제하는 예외이고, unchecked 예외는 처리하지 않아도 컴파일은 되지만 실행 중 문제가 될 수 있는 예외다.

이 구분은 클래스 계층상의 위치로 정해진다. RuntimeException(런타임익셉션)과 그 자손 클래스는 unchecked 예외이고, Exception을 상속하되 RuntimeException을 거치지 않는 나머지는 모두 checked 예외다. Error 계열은 checked·unchecked 구분 대상이 아니며 애초에 처리를 강제하지 않는다.

구분checked 예외unchecked 예외
계층상 위치Exception 직계 자손(단, RuntimeException 제외)RuntimeException과 그 자손
컴파일러 검사처리(try-catch)하거나 throws로 선언하지 않으면 컴파일 오류처리하지 않아도 컴파일은 정상
발생 시점 성격파일 없음, 네트워크 끊김처럼 외부 환경에 원인이 있는 경우가 많음배열 범위 초과, null 참조처럼 코드 논리 실수인 경우가 많음
대표 예IOException, ClassNotFoundException, SQLExceptionNullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException, ClassCastException
설계 의도호출자가 반드시 실패 가능성을 인지하고 대비하게 강제애초에 코드를 고쳐서 발생하지 않도록 예방하는 것이 정석
import java.io.FileReader; import java.io.IOException; public class CheckedExample { public static void main(String[] args) { try { FileReader reader = new FileReader("없는파일.txt"); } catch (IOException e) { System.out.println("파일을 열 수 없습니다: " + e.getMessage()); } } }
파일을 열 수 없습니다: 없는파일.txt (지정된 파일을 찾을 수 없습니다)

FileReader의 생성자는 파일이 없을 수도 있다는 사실을 IOException(checked 예외)으로 알린다. 이 코드에서 try-catch를 빼고 컴파일하면, “예외가 처리되지 않았다”는 컴파일 오류가 발생한다. 반면 다음 코드는 try-catch 없이도 컴파일이 된다.

public class UncheckedExample { public static void main(String[] args) { int[] 배열 = {1, 2, 3}; System.out.println(배열[5]); } }
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 3 at UncheckedExample.main(UncheckedExample.java:4)

ArrayIndexOutOfBoundsException(배열인덱스아웃오브바운즈익셉션, 배열 인덱스가 범위를 벗어남)은 RuntimeException의 자손이므로 unchecked 예외다. 컴파일은 정상적으로 통과하지만, 실행하면 프로그램이 이 지점에서 즉시 종료되며 스택 트레이스(stack trace, 예외가 발생하기까지 호출된 메서드 목록을 역순으로 보여주는 정보)를 출력한다.

시험 함정: “unchecked 예외는 발생하지 않는 예외다”라고 착각하면 안 된다. unchecked는 컴파일러가 처리를 강제하지 않는다는 뜻이지, 실행 중 절대 발생하지 않는다는 뜻이 아니다. 오히려 unchecked 예외는 실전에서 가장 흔하게 프로그램을 죽이는 원인이다. “옳지 않은 것 고르기” 문제에서 이 둘을 혼동시키는 선택지가 자주 나온다.

throws 선언: 메서드가 예외를 넘기겠다고 미리 알리기

쉽게 말하면: throws는 “이 메서드를 실행하다가 이런 예외가 생길 수 있으니, 나를 부르는 쪽에서 대비하라”고 메서드 선언부에 미리 적어두는 표시다.

checked 예외를 발생시킬 수 있는 코드를 작성할 때, 그 메서드 안에서 직접 try-catch로 처리하지 않고 호출한 쪽에 처리 책임을 넘기고 싶다면 메서드 선언부에 throws 예외타입을 적는다. 이는 04편에서 배운 메서드 시그니처의 일부로, 이 메서드를 호출하는 모든 코드는 이제 그 예외를 처리하거나 자신도 throws로 다시 미뤄야 한다.

import java.io.IOException; import java.io.FileReader; public class ThrowsExample { static void 파일읽기(String 경로) throws IOException { FileReader reader = new FileReader(경로); System.out.println("파일을 열었습니다."); } public static void main(String[] args) { try { 파일읽기("없는파일.txt"); } catch (IOException e) { System.out.println("main에서 처리: " + e.getClass().getSimpleName()); } } }
main에서 처리: FileNotFoundException

파일읽기 메서드는 IOException을 직접 처리하지 않고 throws IOException으로 호출자인 main에게 책임을 넘겼다. main은 이를 try-catch로 받아 처리했다. e.getClass().getSimpleName()은 04편에서 다룬 객체의 실제 클래스 이름을 문자열로 반환하는데, 실제로 던져진 예외는 IOException의 자손인 FileNotFoundException(파일을찾을수없음예외)이었음을 확인할 수 있다 — 이는 09편에서 배운 동적 바인딩과 같은 원리로, catch (IOException e)IOException과 그 모든 자손 타입의 예외를 함께 잡는다.

시험 함정: throws(메서드 선언부에 붙여 “예외를 던질 수 있다”고 알리는 키워드)와 throw(실제로 예외 객체를 생성해 던지는 실행문, 예: throw new Exception();)는 철자 하나 차이지만 역할이 완전히 다르다. 단답형·빈칸 문제에서 자주 바꿔치기된다.

unchecked 예외에도 throws를 적을 수는 있지만, 컴파일러가 강제하지 않으므로 대개 생략한다. checked 예외에서만 throws 선언 또는 try-catch 처리 둘 중 하나가 필수라는 점이 핵심이다.

try-catch-finally: 정확한 실행 순서

쉽게 말하면: try는 “일단 해보고”, catch는 “문제가 생기면 이렇게 대응하고”, finally는 “성공하든 실패하든 무조건 마지막에 실행한다”는 세 단계다.

public class TryCatchFinally { public static void main(String[] args) { try { System.out.println("1. try 시작"); int 결과 = 10 / 0; System.out.println("2. 이 줄은 실행되지 않는다"); } catch (ArithmeticException e) { System.out.println("3. catch: " + e.getMessage()); } finally { System.out.println("4. finally: 항상 실행"); } System.out.println("5. try-catch-finally 이후"); } }
1. try 시작 3. catch: / by zero 4. finally: 항상 실행 5. try-catch-finally 이후

한 줄씩 실행 추적:

단계실행 위치상태
1System.out.println("1. try 시작");정상 출력
2int 결과 = 10 / 0;ArithmeticException(산술연산예외) 발생. 정수를 0으로 나누면 이 예외가 던져진다
3System.out.println("2. ...")건너뜀 — 예외가 발생한 순간 try 블록의 나머지 코드는 실행되지 않고 즉시 catch로 이동
4catch (ArithmeticException e)예외 타입이 일치하므로 이 블록이 실행됨. e.getMessage()는 예외 메시지 "/ by zero" 반환
5finally 블록try 또는 catch 실행이 끝난 뒤 예외 발생 여부와 무관하게 항상 실행
6System.out.println("5. ...")try-catch-finally 전체가 끝난 뒤 정상적으로 이어서 실행

정수 나눗셈에서 분모가 0이면 ArithmeticException이 발생한다는 점에 주의한다(실수 나눗셈 10.0 / 0은 예외 없이 Infinity를 반환한다 — 02편에서 다룬 자료형의 차이가 여기서도 드러난다).

finally가 실행되지 않는다고 착각하기 쉬운 경우

finally 블록은 try나 catch 안에서 return이 있어도, JVM이 즉시 종료되는 System.exit()을 만나지 않는 한 반드시 실행된다. 이는 시험에서 실행 순서를 헷갈리게 만드는 대표적인 함정이다.

public class FinallyWithReturn { static int 값구하기() { try { System.out.println("try 실행"); return 1; } finally { System.out.println("finally 실행 (return 이후에도)"); } } public static void main(String[] args) { int 결과 = 값구하기(); System.out.println("결과 = " + 결과); } }
try 실행 finally 실행 (return 이후에도) 결과 = 1

한 줄씩 실행 추적: return 1;이 실행되면 “1을 반환하겠다”는 값이 일단 확정되지만, 메서드가 실제로 호출자에게 돌아가기 전에 finally 블록이 먼저 실행된다. finally 실행이 끝난 뒤에야 미리 확정해둔 값 1이 값구하기()의 최종 반환값으로 main에 전달된다.

시험 함정: finally 블록 안에서도 return을 쓰면, try·catch의 return 값을 무시하고 finally의 return 값으로 덮어써 버린다. 이는 의도치 않은 버그의 흔한 원인이며, “출력 예측” 문제에서 정답을 가르는 핵심 포인트로 자주 나온다. 실무에서는 finallyreturn을 넣는 것을 나쁜 관례로 본다.

예외 전파: catch가 없으면 어디까지 올라가는가

쉽게 말하면: 예외를 처리할 catch를 만나지 못하면, 예외는 현재 메서드를 호출한 메서드, 그 메서드를 호출한 메서드로 계속 거슬러 올라가다가 아무도 못 잡으면 프로그램이 죽는다.

public class PropagationExample { static void 메서드C() { System.out.println("메서드C 시작"); int[] arr = new int[2]; System.out.println(arr[5]); System.out.println("메서드C 끝 (실행 안 됨)"); } static void 메서드B() { System.out.println("메서드B 시작"); 메서드C(); System.out.println("메서드B 끝 (실행 안 됨)"); } static void 메서드A() { System.out.println("메서드A 시작"); try { 메서드B(); } catch (ArrayIndexOutOfBoundsException e) { System.out.println("메서드A에서 잡음: " + e.getMessage()); } System.out.println("메서드A 끝"); } public static void main(String[] args) { 메서드A(); } }
메서드A 시작 메서드B 시작 메서드C 시작 메서드A에서 잡음: Index 5 out of bounds for length 2 메서드A 끝

한 줄씩 실행 추적: 메서드C에서 예외가 발생하면, 메서드C는 그 자리에서 즉시 중단되고(“메서드C 끝”은 출력되지 않음) 예외 객체가 호출자인 메서드B로 전달된다. 메서드B에도 이를 처리할 try-catch가 없으므로 메서드B 역시 그 자리에서 중단되고(“메서드B 끝”도 출력되지 않음) 예외는 다시 메서드A로 전달된다. 메서드A에는 마침 try-catch가 있으므로 여기서 예외가 잡히고, 그 이후 메서드A의 나머지 코드(“메서드A 끝”)는 정상적으로 이어서 실행된다.

이 과정을 예외 전파(exception propagation)라 하며, 실행 중이던 메서드 호출들이 스택처럼 거슬러 올라가는 모습이 10편에서 다룬 스택 프레임의 되감기와 닮아 있다. 만약 메서드A에도 catch가 없었다면 예외는 main까지 올라가고, main에도 없으면 JVM이 예외를 잡아 스택 트레이스를 출력하고 프로그램을 비정상 종료시킨다.

자주 틀리는 점

  • checked·unchecked 구분을 “심각한 정도”로 착각한다. 실제 구분 기준은 RuntimeException을 상속했는가라는 계층 구조상의 위치일 뿐, 심각성과는 무관하다.
  • throws(선언)와 throw(실행)를 혼동한다. throws는 메서드 시그니처에, throw는 실행문 안에 쓴다.
  • finally가 예외 발생 여부와 무관하게 항상 실행된다는 점을 놓치고, try에서 예외가 나면 finally를 건너뛴다고 착각한다.
  • try 블록에서 예외가 발생한 줄 이후의 코드는 실행되지 않고 즉시 catch로 넘어간다는 사실을 놓쳐, try 블록 뒷부분의 출력문도 실행된다고 잘못 예측한다.
  • 정수 나눗셈의 0 나누기(ArithmeticException 발생)와 실수 나눗셈의 0 나누기(예외 없이 Infinity)를 구분하지 못한다.

핵심 정리

  • Java의 모든 예외는 Throwable을 상속하며, 크게 Error(시스템 레벨, 처리 대상 아님)와 Exception(프로그램에서 처리 가능)으로 나뉜다.
  • RuntimeException과 그 자손은 unchecked 예외(컴파일러가 처리를 강제하지 않음), 그 외 Exception 계열은 checked 예외(반드시 try-catch 또는 throws로 처리해야 컴파일됨)다.
  • throws는 메서드가 예외 처리 책임을 호출자에게 넘긴다는 선언이고, throw는 실제로 예외 객체를 던지는 실행문이다.
  • try 블록에서 예외가 발생하면 그 지점 이후의 try 코드는 건너뛰고 곧바로 일치하는 catch로 이동하며, finally는 예외 발생 여부·return 유무와 무관하게 항상 마지막에 실행된다.
  • catch를 만나지 못한 예외는 호출 스택을 거슬러 올라가며 전파되고, 아무도 잡지 못하면 JVM이 프로그램을 비정상 종료시킨다.

마무리 복습

문제 14지선다
Java 예외 클래스 계층에 대한 설명으로 옳은 것은?
문제 24지선다
checked 예외와 unchecked 예외를 구분하는 기준으로 옳은 것은?
public class Q3 { static void m() { int[] a = new int[3]; System.out.println(a[10]); } public static void main(String[] args) { m(); } }
문제 34지선다
위 코드를 컴파일하고 실행하면 어떻게 되는가?
문제 44지선다
throws와 throw의 차이에 대한 설명으로 옳은 것은?
public class Q5 { static int calc() { try { return 10; } finally { System.out.println("finally 실행"); } } public static void main(String[] args) { System.out.println(calc()); } }
문제 54지선다
위 코드를 실행했을 때 출력 순서로 옳은 것은?
public class Q6 { public static void main(String[] args) { try { System.out.println("A"); Object o = null; o.toString(); System.out.println("B"); } catch (NullPointerException e) { System.out.println("C"); } System.out.println("D"); } }
문제 64지선다
위 코드의 출력 결과로 옳은 것은?
public class Q7 { static void deep(int n) { if (n == 3) { int x = 1 / 0; } deep(n + 1); } public static void main(String[] args) { try { deep(0); } catch (ArithmeticException e) { System.out.println("잡음: " + e.getMessage()); } } }
문제 74지선다
위 코드를 실행했을 때 일어나는 일로 옳은 것은?

참고 자료

Last updated on