[아이티센 부트캠프] (예외처리)

이언덕·2026년 4월 14일

아이티센 부트캠프

목록 보기
30/115
post-thumbnail

예외 처리(exception handling)

예외 처리는 문법 이름만 외우면 자꾸 헷갈린다.
먼저 오류가 어떤 종류로 나뉘는지 보고, 그중 코드로 대비할 수 있는 문제가 무엇인지 확인해야 한다.
그다음 try-catch, finally, throw, throws가 각각 어떤 역할을 하는지 흐름으로 연결하면 된다.


자바는 실행 중 생길 수 있는 문제를 클래스 형태로 다룬다.
그중 Exception은 코드로 대비할 수 있는 실행 중 문제를 의미한다.
예외 처리는 문제가 생겼을 때 프로그램이 갑자기 끝나지 않도록, 흐름을 안전하게 바꾸는 방법이다.


예외 처리는 오류 자체를 없애는 문법이 아니라, 오류가 발생했을 때 프로그램 흐름을 안전하게 바꾸는 방법이다.
예를 들어 숫자를 입력해야 하는데 사용자가 문자를 입력할 수 있다.
이때 아무 대비가 없으면 프로그램은 바로 멈출 수 있다.
하지만 예외 처리를 해 두면 “숫자를 입력해야 합니다”처럼 안내하고 프로그램 흐름을 정리할 수 있다.


이번 글의 핵심은 문법 암기가 아니다.
예외가 어디서 발생하고, 어디로 전달되고, 어디서 처리되는지 흐름으로 보는 것이다.




오류와 예외 구조 먼저 잡기

예외 처리 문법을 보기 전에 오류 전체 구조를 먼저 잡아야 한다.
예외는 모든 오류를 뜻하는 말이 아니다.
프로그램 오류 중에서도 실행 중 발생하고, 코드로 대비할 수 있는 문제를 주로 예외라고 본다.


따라서 try-catch부터 바로 외우기보다 오류 전체 → 실행 오류 → Error와 Exception → Checked와 Unchecked 순서로 내려가면 훨씬 덜 헷갈린다.


프로그램 오류의 종류

프로그램에서 생기는 오류는 크게 세 가지로 나눌 수 있다.
첫째는 컴파일 오류다.
컴파일 오류는 실행하기 전에 발견되는 오류다.
자바 소스 코드를 class 파일로 바꾸는 과정에서 문제가 생기면 컴파일 자체가 끝나지 않는다.
세미콜론이 빠졌거나, 중괄호 개수가 맞지 않거나, 존재하지 않는 변수나 메서드를 사용하면 여기에 해당한다.


둘째는 실행 오류다.
컴파일은 성공했지만 프로그램을 실행하는 중에 문제가 생기는 경우다.
예를 들어 10 / 0처럼 0으로 나누거나, 배열 범위를 벗어난 위치를 꺼내려고 하면 실행 중 문제가 발생한다.
예외 처리는 주로 이 실행 오류와 연결된다.


셋째는 논리적 오류다.
프로그램이 컴파일도 되고 실행도 되지만 결과가 틀린 경우다.
예를 들어 잔액이 1000원인데 2000원을 출금했는데도 그대로 처리된다면, 에러 메시지는 없어도 프로그램의 논리가 잘못된 것이다.


모든 오류가 예외는 아니다.
예외 처리는 주로 실행 중 발생하는 문제 중에서 코드로 대비할 수 있는 상황을 다룬다.


Error와 Exception

자바는 실행 중 발생할 수 있는 문제를 클래스로 정의해 둔다.
가장 큰 부모는 Throwable이다.
그리고 그 아래에서 크게 Error와 Exception으로 나뉜다.


Error는 프로그램 코드로 복구하기 어려운 심각한 문제다.
예를 들어 OutOfMemoryError는 메모리가 부족한 상황이고, StackOverflowError는 호출이 너무 깊어져 스택이 넘치는 상황이다.
이런 문제는 일반적인 코드 흐름에서 잡아서 복구하기 어렵다.


반대로 Exception은 코드로 대비할 수 있는 실행 중 문제다.
예를 들어 숫자 형식이 잘못되었거나, 배열 인덱스를 잘못 사용했거나, 파일을 찾지 못하는 상황처럼 프로그램에서 예측하고 대응할 수 있는 문제가 여기에 들어간다.


여기서 중요한 점은 Exception이 가벼운 문제라는 뜻이 아니라는 것이다.
예외도 처리하지 않으면 프로그램을 멈추게 할 수 있다.
다만 코드로 대비하고 처리할 수 있는 종류라는 점이 핵심이다.


[이미지 필요]
캡처 장면: 강의자료 PDF 445쪽의 Throwable 아래에서 Error, Exception, RuntimeException으로 갈라지는 예외 계층구조 그림
들어갈 위치: Error와 Exception 설명 바로 아래
이미지 아래 설명: Throwable은 실행 중 던질 수 있는 문제 상황의 가장 큰 부모이고, Error는 복구가 어려운 심각한 문제, Exception은 코드로 처리할 수 있는 문제다.


Checked 예외와 Unchecked 예외

Exception은 다시 두 종류로 나누어 볼 수 있다.
하나는 Checked 예외이고, 다른 하나는 Unchecked 예외다.


Checked 예외는 컴파일러가 “이 예외 처리했어?”라고 확인하는 예외다.
그래서 try-catch로 직접 잡거나 throws로 밖에 넘기지 않으면 컴파일이 되지 않는다.
대표적으로 InterruptedException 같은 예외가 있다.


Unchecked 예외는 컴파일러가 처리 여부를 강제하지 않는 예외다.
RuntimeException과 그 자식 예외들이 여기에 들어간다.
대표적으로 ArithmeticException, ArrayIndexOutOfBoundsException, NumberFormatException, NullPointerException이 있다.


다만 Unchecked 예외가 “처리하지 않아도 안전하다”는 뜻은 아니다.
컴파일러가 강제하지 않을 뿐, 실행 중 발생하면 프로그램이 멈출 수 있다.


Checked와 Unchecked의 핵심 차이는 컴파일러가 예외 처리를 강제하느냐이다.
Checked 예외는 처리하거나 넘겨야 하고, Unchecked 예외는 문법상 강제되지는 않지만 실행 중 문제가 될 수 있다.

Checked 예외 예시

아래 코드는 Thread.sleep()을 사용한다.
Thread.sleep()은 InterruptedException을 발생시킬 수 있다.
이 예외는 Checked 예외라서 throws로 넘기거나 try-catch로 잡아야 한다.

// CheckedExceptionExample.java
public class CheckedExceptionExample {
	public static void main(String[] args) throws InterruptedException {
		// InterruptedException은 Checked 예외라서 처리하거나 throws로 넘겨야 함
		Thread.sleep(1000);

		System.out.println("1초 뒤 실행");
	}
}
// 출력결과
// 1초 뒤 실행

이 예제는 Checked 예외가 왜 선언부에 throws를 요구하는지 보여 준다.


Unchecked 예외 예시

아래 코드는 10 / 0을 실행한다.
이 코드는 컴파일은 되지만 실행 중 ArithmeticException이 발생한다.

// UncheckedExceptionExample.java
public class UncheckedExceptionExample {
	public static void main(String[] args) {
		// ArithmeticException은 Unchecked 예외라서 컴파일러가 처리를 강제하지 않음
		System.out.println(10 / 0);
	}
}
// 출력결과
// Exception in thread "main" java.lang.ArithmeticException: / by zero

Unchecked 예외는 컴파일러가 미리 막아 주지 않는다.
그래서 실행 중에 실제 값이 어떻게 들어오는지, 조건 검사를 제대로 했는지가 중요하다.




예외 처리 문법 이해하기

예외 처리 문법은 이름이 비슷해서 처음에 자주 헷갈린다.
try-catch, finally, throw, throws는 모두 예외와 관련되어 있지만 역할은 다르다.


핵심은 단순하다.
지금 여기서 직접 잡는지, 마지막에 정리하는지, 직접 발생시키는지, 밖으로 넘기는지를 구분하면 된다.


try-catch

try-catch는 예외가 생길 가능성이 있는 코드를 try 안에 넣고, 실제로 예외가 발생했을 때 실행할 코드를 catch에 넣는 구조다.
즉, 지금 여기서 직접 처리하는 기본 문법이다.


예외가 발생하지 않으면 catch는 실행되지 않는다.
반대로 try 안에서 예외가 발생하면 자바는 그 예외와 맞는 catch를 찾는다.
그리고 일치하는 catch 하나를 실행한 뒤 try-catch 바깥으로 빠져나간다.


예외가 발생한 순간 try 안의 남은 코드는 실행되지 않는다.
바로 맞는 catch를 찾고, catch 실행이 끝나면 try-catch 바깥으로 흐름이 이동한다.

예제: 예외 발생 후 try 아래 코드가 실행되지 않는 흐름

// TryCatchFlowExample.java
public class TryCatchFlowExample {
	public static void main(String[] args) {
		System.out.println("1. 시작");

		try {
			System.out.println("2. try 시작");
			System.out.println(10 / 0); // 여기서 ArithmeticException 발생
			System.out.println("3. 이 문장은 실행되지 않음");
		} catch (ArithmeticException e) {
			System.out.println("4. 0으로 나눌 수 없음");
		}

		System.out.println("5. try-catch 이후 실행");
	}
}
// 출력결과
// 1. 시작
// 2. try 시작
// 4. 0으로 나눌 수 없음
// 5. try-catch 이후 실행

10 / 0에서 예외가 발생했기 때문에 그 아래의 "3. 이 문장은 실행되지 않음"은 출력되지 않는다.
예외가 발생한 지점에서 정상 흐름이 멈추고, 맞는 catch로 이동하기 때문이다.


catch 블록에서 하는 일

catch 안에서는 보통 예외 정보를 확인하거나, 사용자에게 안내 메시지를 보여 주거나, 현재 흐름을 끝내는 작업을 한다.
즉, 예외가 생겼을 때 “이제 어떻게 대응할지”를 적는 구간이다.


여기서 자주 쓰는 메서드가 e.getMessage()와 e.printStackTrace()다.
e.getMessage()는 예외 메시지만 간단히 확인할 때 사용한다.
e.printStackTrace()는 예외가 어디서 발생했는지 자세히 확인할 때 사용한다.


또 상황에 따라 return으로 현재 메서드를 끝낼 수도 있다.
즉, catch는 예외를 잡는 데서 끝나는 것이 아니라, 잡은 뒤 어떤 반응을 할지 정하는 구간이라고 이해하면 된다.


여러 catch가 필요한 이유

같은 코드 안에서도 서로 다른 원인의 예외가 발생할 수 있다.
예를 들어 인자가 부족한 경우, 숫자가 아닌 값을 넣은 경우, 0으로 나눈 경우는 원인이 서로 다르다.
그래서 예외 종류에 따라 처리 내용을 다르게 나누는 것이 좋다.


catch는 위에서 아래로 검사한다.
그래서 자식 예외를 먼저 쓰고, 부모 예외를 뒤에 써야 한다.
부모 예외는 범위가 넓기 때문에 앞에서 먼저 잡아버리면, 뒤쪽의 더 구체적인 catch는 실행될 기회가 없다.

잘못된 예시: 부모 예외를 먼저 쓴 경우

아래 코드는 설명을 위한 잘못된 예시다.
Exception은 ArithmeticException의 부모라서 대부분의 예외를 먼저 잡아 버린다.
그래서 뒤의 ArithmeticException catch는 도달할 수 없어 컴파일 오류가 날 수 있다.

// CatchOrderWrongExample.java
public class CatchOrderWrongExample {
	public static void main(String[] args) {
		try {
			int result = 10 / 0;
			System.out.println(result);
		} catch (Exception e) {
			// Exception이 먼저 대부분의 예외를 잡아버림
			System.out.println("부모 예외가 먼저 잡음");
		} catch (ArithmeticException e) {
			// 위에서 이미 잡히므로 여기는 도달할 수 없음
			System.out.println("0으로 나눌 수 없음");
		}
	}
}

이 예시는 일부러 잘못 작성한 코드다.
구체적인 예외가 뒤에 있으므로 실제 코드에서는 이렇게 작성하면 안 된다.


올바른 예시: 자식 예외를 먼저 쓴 경우

// CatchOrderRightExample.java
public class CatchOrderRightExample {
	public static void main(String[] args) {
		try {
			int result = 10 / 0;
			System.out.println(result);
		} catch (ArithmeticException e) {
			// 구체적인 예외를 먼저 처리함
			System.out.println("0으로 나눌 수 없음");
		} catch (Exception e) {
			// 더 넓은 예외는 마지막에 처리함
			System.out.println("그 밖의 예외 처리");
		}
	}
}
// 출력결과
// 0으로 나눌 수 없음

여러 catch를 사용할 때는 작은 범위의 예외부터 큰 범위의 예외 순서로 작성한다.
그래야 구체적인 원인에 맞게 예외를 나누어 처리할 수 있다.


multi-catch

여러 예외를 같은 방식으로 처리할 때는 multi-catch를 사용할 수 있다.
multi-catch는 하나의 catch에서 여러 예외 타입을 |로 묶는 방식이다.


여기서 |는 일반적인 논리 연산자로 쓰는 것이 아니라, catch에서 여러 예외 타입을 묶기 위한 기호다.
단, 부모와 자식 관계인 예외를 함께 묶으면 안 된다.
부모 예외 하나만 써도 자식 예외까지 잡을 수 있기 때문이다.

예제: multi-catch

// MultiCatchExample.java
public class MultiCatchExample {
	public static void main(String[] args) {
		try {
			String input = "abc";
			int num = Integer.parseInt(input);
			System.out.println(10 / num);
		} catch (NumberFormatException | ArithmeticException e) {
			// 두 예외를 같은 방식으로 처리함
			System.out.println("숫자 형식이 잘못됐거나 0으로 나눴습니다.");
		}
	}
}
// 출력결과
// 숫자 형식이 잘못됐거나 0으로 나눴습니다.

NumberFormatException과 ArithmeticException을 같은 안내 메시지로 처리하고 싶다면 이렇게 하나의 catch로 묶을 수 있다.


finally

finally는 예외가 생기든 안 생기든 마지막에 공통으로 실행되는 마무리 구간이다.
즉, 정상 실행이든 예외 발생이든 반드시 해야 하는 작업을 모아 두는 블록이다.


예를 들어 마지막 안내 문구를 출력하거나, 정리 작업을 하거나, 자원을 닫는 작업처럼 꼭 수행해야 하는 코드를 여기에 둔다.
그래서 finally는 단순한 덤이 아니라, 흐름을 정리하는 중요한 자리다.


중간에 return이 있어도 finally는 여전히 동작한다.
즉, finally는 예외가 날 수도 있으니 혹시 넣어두는 것이 아니라, 마지막에 반드시 거쳐야 하는 공통 정리 블록으로 이해하는 것이 맞다.


finally가 자주 쓰이는 대표 상황은 자원 정리다.
파일, 네트워크, 데이터베이스 연결처럼 사용 후 반드시 닫아야 하는 자원이 있다.
이런 자원은 중간에 예외가 발생해도 닫아야 하므로 finally에 정리 코드를 넣는다.


throw

throw는 예외를 직접 발생시키는 문장이다.
즉, 예외가 자동으로 터질 때만 기다리는 것이 아니라, 개발자가 필요할 때 직접 예외를 던질 수 있다는 뜻이다.


throw는 문자열을 던지는 문법이 아니다.
예외 객체를 만들고, 그 예외 객체를 실제로 던지는 문법이다.

예제: 예외 객체를 만들어 직접 던지기

// ThrowBasicExample.java
public class ThrowBasicExample {
	public static void main(String[] args) {
		try {
			// 1. 예외 객체 생성
			Exception e = new Exception("직접 만든 예외 메시지");

			// 2. 예외 객체를 실제로 던짐
			throw e;
		} catch (Exception e) {
			System.out.println("예외 메시지 : " + e.getMessage());
		}
	}
}
// 출력결과
// 예외 메시지 : 직접 만든 예외 메시지

이 코드는 Exception 객체를 직접 만든 뒤 throw로 던진다.
실제로는 예외 객체를 변수에 먼저 담지 않고, 아래처럼 throw new 예외클래스(...) 형태로 바로 던지는 경우가 더 많다.

// ThrowOneLineExample.java
public class ThrowOneLineExample {
	public static void main(String[] args) {
		try {
			// 예외 객체를 만들자마자 바로 던짐
			throw new Exception("직접 만든 예외 메시지");
		} catch (Exception e) {
			System.out.println("예외 메시지 : " + e.getMessage());
		}
	}
}
// 출력결과
// 예외 메시지 : 직접 만든 예외 메시지

중요한 점은 throw 뒤에는 단순 문자열이 아니라 예외 객체가 온다는 것이다.


throw와 Checked 예외

Exception은 Checked 예외다.
그래서 throw new Exception()처럼 직접 던지면 반드시 처리하거나 throws로 넘겨야 한다.

잘못된 예시: Checked 예외를 그냥 던지는 경우

아래 코드는 설명을 위한 잘못된 예시다.
Exception은 Checked 예외라서 그냥 던지면 컴파일 오류가 난다.

// CheckedThrowWrongExample.java
public class CheckedThrowWrongExample {
	public static void main(String[] args) {
		// Exception은 Checked 예외라서 그냥 던지면 컴파일 오류가 남
		throw new Exception("문제 발생");
	}
}

이 문제를 해결하려면 try-catch로 잡거나 throws로 넘겨야 한다.

해결 방법 1: try-catch로 직접 처리하기

// CheckedThrowCatchExample.java
public class CheckedThrowCatchExample {
	public static void main(String[] args) {
		try {
			throw new Exception("문제 발생");
		} catch (Exception e) {
			System.out.println(e.getMessage());
		}
	}
}
// 출력결과
// 문제 발생

해결 방법 2: throws로 밖에 넘기기

// CheckedThrowThrowsExample.java
public class CheckedThrowThrowsExample {
	public static void main(String[] args) throws Exception {
		throw new Exception("문제 발생");
	}
}

이 경우 예외를 현재 메서드에서 처리한 것이 아니다.
main()을 호출한 쪽으로 예외를 넘긴 것이다.


throws

throws는 메서드 안에서 예외를 직접 처리하지 않고, 밖으로 넘길 때 선언부에 적는 문법이다.
즉, “여기서는 try-catch로 잡지 않고 위쪽 호출자에게 맡기겠다”는 뜻이다.


throws는 예외를 해결한 것이 아니다.
그저 지금 이 메서드 안에서는 처리하지 않겠다고 알리는 것이다.
즉, 처리가 아니라 전달이다.


특히 Checked 예외는 처리하거나 throws로 넘겨야 하므로, throws는 예외 처리 문법에서 매우 중요한 역할을 한다.
하지만 throws만 붙이고 끝내면 결국 위쪽 어딘가에서는 예외를 실제로 잡아야 한다.


throws는 메서드 이름 뒤에 붙는다.
여러 예외를 넘겨야 하면 ,로 나열할 수 있다.

// ThrowsMultipleExample.java
import java.io.IOException;

public class ThrowsMultipleExample {
	static void work() throws InterruptedException, IOException {
		// InterruptedException이 발생할 수 있는 코드
		Thread.sleep(1000);

		// IOException을 직접 발생시켜 밖으로 넘김
		throw new IOException("파일 처리 중 문제 발생");
	}
}

이 예제에서 work()는 InterruptedException과 IOException을 직접 처리하지 않는다.
대신 호출한 쪽에 넘긴다.
Exception처럼 너무 넓은 부모 예외를 쓰면 여러 예외를 나열하는 의미가 흐려질 수 있으므로, 예시에서는 서로 다른 구체적인 예외를 나누어 적었다.


throw와 throws 차이

throw와 throws는 이름이 비슷하지만 역할이 완전히 다르다.
이 둘은 꼭 비교해서 기억해야 한다.

  • throw는 메서드 안에서 예외 객체를 직접 던지는 문장이다.
  • throws는 메서드 선언부에서 예외를 밖으로 넘긴다고 알리는 문법이다.

정리하면 throw는 실제로 던지는 행동이고, throws는 던질 수 있다고 선언하는 표시다.


throw는 예외를 발생시키고, throws는 예외 처리를 호출한 쪽으로 미룬다.




사용자 정의 예외

기본 예외만으로는 현재 상황을 충분히 설명하기 어려울 때가 있다.
이럴 때는 프로그램 상황에 맞는 예외 클래스를 직접 만들어서 사용할 수 있다.


사용자 정의 예외는 “예외도 결국 클래스다”라는 점을 가장 잘 보여 준다.
특정 상황에 맞는 이름을 가진 예외 클래스를 만들면, 예외 이름만 보고도 어떤 문제가 생겼는지 더 쉽게 파악할 수 있다.


다만 사용자 정의 예외를 무조건 새로 만드는 것이 좋은 것은 아니다.
이미 의미가 맞는 표준 예외가 있다면 먼저 기존 예외를 활용하고, 기존 예외만으로 상황을 충분히 설명하기 어려울 때 직접 만드는 편이 좋다.


예외 클래스를 직접 만드는 이유

기본 예외만으로는 현재 상황을 충분히 설명하기 어려울 수 있다.
예를 들어 지금 발생한 문제가 단순한 숫자 형식 오류인지, 프로그램에서 직접 정한 특별한 규칙 위반인지에 따라 의미가 달라진다.
이럴 때는 프로그램 상황에 맞는 예외 클래스를 직접 만드는 편이 더 분명하다.


예를 들어 프로그램 설치 중 공간이 부족하면 SpaceException, 메모리가 부족하면 MemoryException처럼 만들 수 있다.
회원 가입에서 나이가 조건에 맞지 않으면 AgeException처럼 만들 수도 있다.
이렇게 하면 예외 이름만 보고도 어떤 상황인지 더 쉽게 알 수 있다.


즉, 사용자 정의 예외는 단순히 새로운 이름을 붙이는 것이 아니라, 현재 상황을 더 정확하게 설명하는 예외 객체를 만드는 것이다.


Exception 상속과 RuntimeException 상속

사용자 정의 예외를 만들 때는 보통 Exception 또는 RuntimeException을 상속한다.
둘 중 무엇을 상속하느냐에 따라 처리 방식이 달라진다.


Exception을 상속하면 Checked 예외가 된다.
그래서 이 예외를 발생시키는 메서드는 직접 처리하거나 throws로 넘겨야 한다.
반대로 RuntimeException을 상속하면 Unchecked 예외가 된다.
이 경우 컴파일러가 처리를 강제하지 않는다.

// CustomCheckedException.java
class AgeException extends Exception {
	AgeException(String message) {
		super(message);
	}
}
// CustomUncheckedException.java
class AgeRuntimeException extends RuntimeException {
	AgeRuntimeException(String message) {
		super(message);
	}
}

반드시 처리하게 만들고 싶다면 Exception을 상속한다.
개발자의 잘못된 사용을 나타내고 처리 강제를 원하지 않는다면 RuntimeException을 상속하는 방식도 있다.


예외 메시지를 전달하는 방식

예외도 결국 클래스다.
그래서 일반 클래스를 만들듯이 직접 만들 수 있다.
보통은 생성자에서 메시지를 받고, 부모 클래스 생성자에 그 메시지를 전달한다.

// AgeException.java
class AgeException extends Exception {
	AgeException(String message) {
		// 부모 Exception에 예외 메시지를 전달함
		super(message);
	}
}

이렇게 하면 예외가 발생했을 때 getMessage()로 현재 문제를 더 구체적으로 확인할 수 있다.
즉, 사용자 정의 예외는 예외 이름과 예외 메시지로 상황을 더 정확하게 표현하는 방법이다.


예외에 추가 정보를 담는 방식

사용자 정의 예외에는 메시지만 넣을 수도 있지만, 필요하면 에러 코드 같은 추가 정보를 넣을 수도 있다.
예를 들어 같은 회원 가입 오류라도 입력 누락인지, 나이 제한 위반인지, 이미 가입된 계정인지에 따라 코드 값을 다르게 둘 수 있다.


이렇게 하면 catch에서 메시지뿐 아니라 에러 코드까지 함께 확인할 수 있다.

// CodeException.java
class CodeException extends Exception {
	private final int errorCode;

	CodeException(String message, int errorCode) {
		// 부모 Exception에 예외 메시지를 전달함
		super(message);

		// 예외 객체 안에 에러 코드를 함께 저장함
		this.errorCode = errorCode;
	}

	int getErrorCode() {
		return errorCode;
	}
}

이 예제에서 getMessage()는 예외 메시지를 가져오고, getErrorCode()는 직접 추가한 에러 코드를 가져온다.
즉, 사용자 정의 예외는 필요에 따라 상황을 설명하는 정보를 더 담을 수 있다.




자원 정리와 예외 확장 문법

예외 처리에서는 예외를 잡는 것만 중요한 것이 아니다.
예외가 발생해도 마지막에 정리해야 하는 자원이 있고, 이미 잡은 예외를 다시 던져야 하는 경우도 있다.
또 하나의 예외 안에 원인이 된 다른 예외를 연결해야 하는 경우도 있다.


이 구간은 try-catch보다 한 단계 뒤에서 예외 처리 흐름을 더 정교하게 만드는 문법이다.


try-with-resources

try-with-resources는 자원을 자동으로 닫아 주는 try 문법이다.
기존에는 파일, 네트워크, 데이터베이스 연결처럼 닫아야 하는 자원을 finally에서 직접 닫는 방식이 많이 사용되었다.
하지만 close() 자체도 예외를 발생시킬 수 있어서 코드가 복잡해질 수 있다.


이 문제를 줄이기 위해 try 괄호 안에 자원 객체를 만들면, try 블록이 끝날 때 자동으로 close()가 호출되는 방식이 추가되었다.
이때 자동으로 닫히려면 해당 객체가 AutoCloseable을 구현하고 있어야 한다.


try-with-resources는 정리해야 하는 자원을 try 괄호 안에 넣고, 블록이 끝날 때 자동으로 닫게 하는 문법이다.

예제: try-with-resources 기본 흐름

// TryWithResourcesExample.java
class SimpleResource implements AutoCloseable {
	void work() {
		System.out.println("자원 사용");
	}

	@Override
	public void close() {
		System.out.println("자원 닫기");
	}
}

public class TryWithResourcesExample {
	public static void main(String[] args) {
		try (SimpleResource resource = new SimpleResource()) {
			resource.work();
		} catch (Exception e) {
			System.out.println("예외 처리");
		}
	}
}
// 출력결과
// 자원 사용
// 자원 닫기

resource.close()를 직접 호출하지 않았는데도 try 블록이 끝나면 자동으로 close()가 실행된다.
지금 단계에서는 “닫아야 하는 자원을 try 괄호 안에 넣으면 자동으로 닫힌다” 정도로 이해하면 된다.


try-with-resources에서 예외가 두 번 발생하면 어떻게 될까

try-with-resources에서는 try 블록 안에서도 예외가 발생할 수 있고, 자동으로 호출되는 close()에서도 예외가 발생할 수 있다.
이때 두 예외를 똑같이 밖으로 던질 수는 없다.
자바는 보통 try 블록에서 먼저 발생한 예외를 대표 예외로 보고, close()에서 발생한 예외는 억제된 예외로 함께 보관한다.


여기서 억제된 예외는 사라진 예외가 아니다.
getSuppressed()를 사용하면 대표 예외 안에 함께 저장된 억제 예외를 확인할 수 있다.


try 블록과 close()에서 모두 예외가 발생하면, close() 예외는 대표 예외 안에 억제된 예외로 붙을 수 있다.

예제: 억제된 예외 확인하기

// SuppressedExceptionExample.java
class SuppressedResource implements AutoCloseable {
	void work() throws Exception {
		// try 블록 안에서 발생하는 대표 예외
		throw new Exception("작업 중 예외");
	}

	@Override
	public void close() throws Exception {
		// 자원을 닫는 중에도 예외가 발생할 수 있음
		throw new Exception("자원 닫기 예외");
	}
}

public class SuppressedExceptionExample {
	public static void main(String[] args) {
		try (SuppressedResource resource = new SuppressedResource()) {
			resource.work();
		} catch (Exception e) {
			System.out.println("대표 예외 : " + e.getMessage());

			for (Throwable suppressed : e.getSuppressed()) {
				System.out.println("억제된 예외 : " + suppressed.getMessage());
			}
		}
	}
}
// 출력결과
// 대표 예외 : 작업 중 예외
// 억제된 예외 : 자원 닫기 예외

이 예제에서 work()에서 발생한 예외가 대표 예외가 된다.
그리고 close()에서 발생한 예외는 getSuppressed()로 확인할 수 있는 억제된 예외가 된다.
즉, try-with-resources는 자원을 자동으로 닫을 뿐 아니라, 닫는 과정에서 발생한 예외까지 함께 보관할 수 있다.


예외 되던지기

예외 되던지기는 catch에서 예외를 잡은 뒤, 다시 throw로 던지는 흐름을 말한다.
즉, 한 번 잡았다고 무조건 거기서 끝내는 것이 아니다.
현재 메서드에서 일부 처리를 한 뒤, 위쪽 호출자에게 다시 넘길 수도 있다.


예를 들어 현재 메서드에서는 로그를 남기고, 최종 판단은 호출한 쪽에서 하게 만들고 싶을 수 있다.
이럴 때 예외 되던지기를 사용한다.

예제: catch에서 잡은 예외를 다시 던지기

// RethrowExample.java
public class RethrowExample {
	public static void main(String[] args) {
		try {
			method1();
		} catch (Exception e) {
			System.out.println("main()에서 다시 처리");
		}
	}

	static void method1() throws Exception {
		try {
			throw new Exception("문제 발생");
		} catch (Exception e) {
			System.out.println("method1()에서 먼저 처리");
			throw e; // 잡은 예외를 다시 던짐
		}
	}
}
// 출력결과
// method1()에서 먼저 처리
// main()에서 다시 처리

method1()은 예외를 잡고 메시지를 출력한 뒤 다시 던진다.
그래서 main()에서도 같은 예외를 다시 처리할 수 있다.


연결된 예외

연결된 예외는 예외 안에 원인이 된 다른 예외를 넣어 두는 방식이다.
겉으로는 설치 실패 예외가 발생했지만, 실제 원인은 공간 부족 예외일 수 있다.
이때 공간 부족 예외를 설치 실패 예외의 원인으로 연결하면, 상위 흐름에서는 “설치 실패”라는 큰 예외로 처리하면서도 실제 원인을 따로 확인할 수 있다.


initCause()는 원인 예외를 등록한다.
getCause()는 등록된 원인 예외를 꺼낸다.

예제: 원인 예외 연결하기

// ChainedExceptionExample.java
class InstallException extends Exception {
	InstallException(String message) {
		super(message);
	}
}

class SpaceException extends Exception {
	SpaceException(String message) {
		super(message);
	}
}

public class ChainedExceptionExample {
	public static void main(String[] args) {
		try {
			install();
		} catch (InstallException e) {
			System.out.println("겉 예외 : " + e.getMessage());
			System.out.println("원인 예외 : " + e.getCause().getMessage());
		}
	}

	static void install() throws InstallException {
		SpaceException cause = new SpaceException("설치 공간이 부족합니다.");
		InstallException wrapper = new InstallException("설치 중 예외가 발생했습니다.");

		// 원인 예외를 연결함
		wrapper.initCause(cause);
		throw wrapper;
	}
}
// 출력결과
// 겉 예외 : 설치 중 예외가 발생했습니다.
// 원인 예외 : 설치 공간이 부족합니다.

연결된 예외를 쓰면 여러 예외를 하나의 큰 예외로 묶어 다루면서도 실제 원인을 잃지 않을 수 있다.


Checked 예외를 Unchecked 예외처럼 감싸기

때로는 Checked 예외를 RuntimeException 안에 감싸서 던질 수 있다.
그러면 바깥에서는 Unchecked 예외처럼 다룰 수 있다.


다만 이 방식은 예외 처리를 피하려고 남용하면 안 된다.
예외를 더 큰 의미의 예외로 바꿔 전달할 때 사용해야 한다.

// CheckedToUncheckedExample.java
class MemoryException extends Exception {
	MemoryException(String message) {
		super(message);
	}
}

public class CheckedToUncheckedExample {
	public static void main(String[] args) {
		try {
			startInstall();
		} catch (RuntimeException e) {
			System.out.println("설치 실패 : " + e.getCause().getMessage());
		}
	}

	static void startInstall() {
		MemoryException cause = new MemoryException("메모리가 부족합니다.");

		// Checked 예외를 RuntimeException으로 감싸서 던짐
		throw new RuntimeException(cause);
	}
}
// 출력결과
// 설치 실패 : 메모리가 부족합니다.

MemoryException은 Checked 예외지만, RuntimeException 안에 원인으로 들어가면서 바깥에서는 Unchecked 예외처럼 전달된다.
이 방식은 원인을 감추는 것이 아니라, 원인을 감싼 채 더 큰 흐름으로 전달하는 것이다.




예제로 다시 확인하기

이제 앞에서 설명한 내용을 예제 흐름으로 다시 확인하면 된다.
이번 예제들은 각각 역할이 다르다.
throws만 보여 주는 예제, 여러 예외를 나눠 처리하는 예제, 사용자 정의 예외를 직접 만드는 예제, throws로만 계속 넘기는 예제가 순서대로 이어진다.


즉, 이 구간은 새로운 개념을 더 배우는 것보다 앞에서 설명한 문법과 흐름이 실제 코드에서 어떻게 보이는지 다시 확인하는 구간이다.


ExceptionTest1

ExceptionTest1은 throws의 가장 단순한 형태를 보여준다.
Thread.sleep(3000)은 InterruptedException을 발생시킬 수 있기 때문에, main() 선언부에 throws InterruptedException이 붙어 있다.
즉, 이 예제는 예외를 현재 메서드 안에서 직접 처리하지 않고 넘긴 상태를 보여 준다.

코드 흐름

  1. main()이 시작된다.
  2. "수행시작"을 출력한다.
  3. Thread.sleep(3000)에서 잠깐 멈춘다.
  4. 문제가 없으면 "수행종료"를 출력한다.


    이 흐름에서 중요한 점은 InterruptedException을 try-catch로 잡은 것이 아니라, 선언부의 throws로 넘겼다는 것이다.
    즉, 이 코드는 예외를 처리한 것이 아니라 전달한 것이다.
// ExceptionTest1.java
package day10;

public class ExceptionTest1 {
	public static void main(String[] args) throws InterruptedException {
		// 프로그램 시작 메시지
		System.out.println("수행시작");

		// 3초 동안 잠시 멈춤
		Thread.sleep(3000);

		// 예외가 없으면 마지막 메시지 출력
		System.out.println("수행종료");
	}
}
// 출력결과
// 수행시작
// (3초 대기)
// 수행종료

이 예제는 throws는 처리한 것이 아니라 넘긴 것이라는 점을 가장 단순하게 보여 준다.


ExceptionTest2

ExceptionTest2는 예외 처리에서 중요한 문법이 한 번에 들어 있는 예제다.
try-catch, 여러 개의 catch, getMessage(), printStackTrace(), finally, return까지 함께 나온다.
프로그램 인자를 두 개 받아 정수로 바꾸고 나눗셈을 수행하는데, 그 과정에서 생길 수 있는 여러 문제를 예외별로 나눠 처리한다.

args

여기서 먼저 알아야 할 것이 args다.
args는 프로그램 실행할 때 전달되는 문자열들이다.
args[0]은 첫 번째 값이고, args[1]은 두 번째 값이다.
즉, 이 예제는 프로그램 인자를 받아서 계산하는 흐름 위에 예외 처리를 붙여 놓은 구조다.

예외별 처리 흐름

이 예제는 세 가지 다른 문제를 각각 다른 catch로 처리한다.
인자가 부족하면 ArrayIndexOutOfBoundsException이 발생할 수 있다.
두 번째 값을 0으로 주면 ArithmeticException이 발생할 수 있다.
숫자로 바꿀 수 없는 값을 넣으면 NumberFormatException이 발생할 수 있다.


즉, 이 예제의 핵심은 예외마다 원인이 다르므로 처리도 달라져야 한다는 점이다.
서로 다른 문제를 하나의 catch로 뭉뚱그려 처리하지 않고, 각각 나눠서 대응하고 있다.

finally와 return

이 예제에서는 ArithmeticException이 발생했을 때 return이 나온다.
그런데도 finally는 실행된다.
이 흐름은 finally가 단순한 옵션이 아니라, 마지막에 반드시 거치는 마무리 블록이라는 점을 보여 준다.


즉, return이 있더라도 finally는 여전히 중요하게 동작한다.
이 부분이 초보자가 자주 헷갈리는 포인트다.

// ExceptionTest2.java
package day10;

public class ExceptionTest2 {
	public static void main(String[] args) {
		System.out.println("수행시작");

		try {
			// 프로그램 인자를 정수로 변환
			int num1 = Integer.parseInt(args[0]);
			int num2 = Integer.parseInt(args[1]);

			// 나눗셈 수행
			int result = num1 / num2;
			System.out.println("연산 결과 : " + result);

		} catch (ArrayIndexOutOfBoundsException e) {
			System.out.println("프로그램 아규먼트를 2 개 전달하세요!!");

		} catch (ArithmeticException e) {
			System.out.println(e.getMessage());
			System.out.println("두번째 프로그램 아규먼트는 0이 아닌 값을 전달하세요!!");
			return;

		} catch (NumberFormatException e) {
			e.printStackTrace();
			System.out.println("프로그램 아규먼트로 숫자를 전달하세요!!");

		} finally {
			System.out.println("항상 수행!!");
		}

		System.out.println("수행종료");
	}
}
// 출력결과 예시 1
// 수행시작
// 프로그램 아규먼트를 2 개 전달하세요!!
// 항상 수행!!
// 수행종료
// 출력결과 예시 2
// 수행시작
// / by zero
// 두번째 프로그램 아규먼트는 0이 아닌 값을 전달하세요!!
// 항상 수행!!
// 출력결과 예시 3
// 수행시작
// (예외 스택 정보 출력)
// 프로그램 아규먼트로 숫자를 전달하세요!!
// 항상 수행!!
// 수행종료
// 출력결과 예시 4
// 수행시작
// 연산 결과 : 5
// 항상 수행!!
// 수행종료

ArithmeticException 상황에서는 catch 안에서 return이 실행되므로 "수행종료"는 출력되지 않는다.
하지만 finally는 return 전에 실행된다.


ExceptionTest3

ExceptionTest3는 사용자 정의 예외, throw, throws, try-catch, getMessage()를 한 번에 보여준다.
TestException이라는 예외 클래스를 직접 만들고, c()에서 조건에 따라 직접 예외를 발생시킨다.
그 예외는 b()에서 throws로 넘기고, a()에서 catch로 처리한다.

사용자 정의 예외

이 예제에서 먼저 봐야 할 것은 TestException이다.
이 클래스는 Exception을 상속받고, 생성자에서 메시지를 받아 부모 클래스에 전달한다.
즉, 앞에서 설명한 사용자 정의 예외의 기본 구조가 그대로 들어 있다.

throw와 throws 흐름

c()에서는 조건이 맞으면 throw new TestException(...)으로 예외를 직접 발생시킨다.
즉, 자동으로 생긴 예외를 기다리는 것이 아니라 개발자가 직접 예외를 던지는 구조다.


그다음 b()는 그 예외를 직접 처리하지 않고 throws TestException으로 넘긴다.
즉, 여기서는 처리하지 않고 위로 전달하는 흐름이 보인다.


마지막으로 a()에서 try-catch로 그 예외를 잡는다.
즉, 발생 → 전달 → 처리 흐름이 이 예제 안에 한 번에 들어 있다.

호출 흐름 따라 올라가는 예외

예외가 발생하면 그 메서드 아래 문장만 중단되는 것이 아니다.
그 예외를 처리할 catch를 찾을 때까지 호출 흐름을 따라 위로 올라간다.
그래서 c()에서 예외가 발생하면 b()로 올라가고, b()에서 안 잡으면 a()로 올라간다.
이 흐름이 예외 처리에서 매우 중요하다.

// ExceptionTest3.java
package day10;

import java.util.Random;

class TestException extends Exception {
	private static final long serialVersionUID = 1L;

	TestException(String message) {
		super(message);
	}
}

public class ExceptionTest3 {
	public static void main(String[] args) {
		System.out.println("main()수행시작");
		a();
		System.out.println("main()수행종료");
	}

	static void a() {
		System.out.println("a()수행시작");

		try {
			b();
		} catch (TestException e) {
			System.out.println("오류 발생 : " + e.getMessage());
		}

		System.out.println("a()수행종료");
	}

	static void b() throws TestException {
		System.out.println("b()수행시작");
		c();
		System.out.println("b()수행종료");
	}

	static void c() throws TestException {
		System.out.println("c()수행시작");
		boolean flag = new Random().nextBoolean();

		if (flag) {
			throw new TestException("<<:::::테스트로 예외발생시킴:::::>>");
		} else {
			System.out.println("ㅋㅋㅋㅋ");
		}

		System.out.println("c()수행종료");
	}
}
// 출력결과 예시 1
// main()수행시작
// a()수행시작
// b()수행시작
// c()수행시작
// ㅋㅋㅋㅋ
// c()수행종료
// b()수행종료
// a()수행종료
// main()수행종료
// 출력결과 예시 2
// main()수행시작
// a()수행시작
// b()수행시작
// c()수행시작
// 오류 발생 : <<:::::테스트로 예외발생시킴:::::>>
// a()수행종료
// main()수행종료

예외가 발생한 경우에는 c()의 남은 코드와 b()의 남은 코드는 실행되지 않는다.
예외가 a()의 catch까지 올라가서 처리되기 때문이다.


ExceptionTest3_1

ExceptionTest3_1은 ExceptionTest3와 거의 비슷하지만 중요한 차이가 있다.
중간 어디에서도 catch로 처리하지 않고, main(), a(), b(), c()가 모두 throws TestException만 선언하고 있다.
즉, 예외를 계속 위로 넘기기만 한다.

계속 넘기기만 하면 어떻게 되는가

c()에서 예외가 발생하면 처리할 catch가 없으므로 b()로 올라간다.
b()도 안 잡으니 a()로 올라간다.
a()도 안 잡으니 main()으로 올라간다.
그런데 main()도 안 잡고 throws만 했으므로, 결국 프로그램은 예외와 함께 끝날 수 있다.


즉, throws를 여러 번 붙였다고 해서 예외 처리가 끝난 것이 아니다.
누군가 실제로 catch로 잡아줘야 비로소 처리된 것이다.
이 점을 보여 주는 것이 이 예제의 핵심이다.


이 코드는 앞의 ExceptionTest3에서 만든 TestException 클래스가 같은 day10 패키지에 이미 있다고 가정한다.
그래서 여기서는 TestException 클래스를 다시 만들지 않고 사용한다.

// ExceptionTest3_1.java
package day10;

import java.util.Random;

public class ExceptionTest3_1 {
	public static void main(String[] args) throws TestException {
		System.out.println("main()수행시작");
		a();
		System.out.println("main()수행종료");
	}

	static void a() throws TestException {
		System.out.println("a()수행시작");
		b();
		System.out.println("a()수행종료");
	}

	static void b() throws TestException {
		System.out.println("b()수행시작");
		c();
		System.out.println("b()수행종료");
	}

	static void c() throws TestException {
		System.out.println("c()수행시작");
		boolean flag = new Random().nextBoolean();

		if (flag) {
			throw new TestException("<<:::::테스트로 예외발생시킴:::::>>");
		} else {
			System.out.println("ㅋㅋㅋㅋ");
		}

		System.out.println("c()수행종료");
	}
}
// 출력결과 예시 1
// main()수행시작
// a()수행시작
// b()수행시작
// c()수행시작
// ㅋㅋㅋㅋ
// c()수행종료
// b()수행종료
// a()수행종료
// main()수행종료
// 출력결과 예시 2
// main()수행시작
// a()수행시작
// b()수행시작
// c()수행시작
// Exception in thread "main" day10.TestException: <<:::::테스트로 예외발생시킴:::::>>
// (실행 환경에 따라 이어서 스택 정보가 출력됨)

이 예제는 throws가 처리 문법이 아니라 전달 문법이라는 점을 가장 분명하게 보여 준다.




참고

예외 처리는 문법보다 흐름이 더 중요하다.
오류 종류를 먼저 나누고, 실행 오류 안에서 Error와 Exception을 구분하고, 예외 안에서 다시 Checked와 Unchecked를 나눠 봐야 한다.


그다음 try-catch-finally, throw, throws, 사용자 정의 예외, try-with-resources, 예외 되던지기, 연결된 예외를 흐름으로 연결하면 된다.


UncaughtExceptionHandler

처리되지 못한 예외는 결국 프로그램 바깥까지 올라간다.
이때 마지막으로 처리되지 않은 예외를 어떻게 다룰지 정하는 장치가 UncaughtExceptionHandler다.
지금 단계에서는 “끝까지 catch되지 않은 예외를 마지막에 다루는 흐름” 정도로 이해하면 된다.


핵심 흐름 정리

예외 처리는 문법보다 흐름이 더 중요하다.
먼저 오류가 컴파일 오류, 실행 오류, 논리적 오류로 나뉜다는 것을 알아야 한다.
그다음 실행 오류 안에서 Error와 Exception을 구분해야 한다.
그리고 예외 안에서 다시 Checked와 Unchecked를 나눠 봐야 한다.


try-catch-finally는 지금 여기서 직접 처리하는 구문이다.
throws는 처리하지 않고 넘기는 선언이다.
throw는 예외를 직접 발생시키는 문장이다.
try-with-resources는 자원을 자동으로 닫는 문법이다.
예외 되던지기는 잡은 예외를 다시 던지는 흐름이다.
연결된 예외는 큰 예외 안에 원인 예외를 함께 담는 방식이다.


예외 처리의 핵심은 예외가 어디서 발생했고, 어디로 전달됐고, 어디서 처리됐는지 흐름으로 보는 것이다.

0개의 댓글