Java 개발자라면 예외(Exception)와 예외 처리(Exception Handling)에 대한 탄탄한 기본기를 갖추고 있어야 합니다.
이 글에서는 Java 프로그램을 다루는 모든 개발자가 반드시 알아야 할 예외의 기본 개념부터 실전적인 처리 방법까지 체계적으로 정리해 드립니다. 먼저 Java 예외가 정확히 무엇인지부터 살펴보겠습니다.
Java 예외(Exception)란 무엇인가
Java 프로그램은 실행 도중 여러 가지 문제에 부딪힐 수 있으며, 이로 인해 프로그램이 갑자기 종료되는 경우가 발생합니다. 이러한 문제를 예외(Exception)라고 부릅니다.
훌륭한 개발자는 실행 중 발생할 수 있는 오류를 미리 인지하고, 예외 상황이 생겼을 때 프로그램이 대체 경로로 동작할 수 있도록 설계해야 합니다. 바로 이러한 작업을 예외 처리(Exception Handling)라고 합니다.
그렇다면 "예외가 아예 발생하지 않도록 코드를 작성하면 되지 않을까?"라는 의문이 들 수 있습니다.
왜 예외 처리가 필요한가
결론부터 말하면, 예외가 절대 발생하지 않는 프로그램을 작성하는 것은 생각보다 훨씬 어렵습니다. 대부분의 경우 이러한 오류는 개발자가 통제할 수 없는 외부 요인에서 비롯됩니다.
사용자 입력을 받는 프로그램은 잘못된 값이 입력될 때마다 예외가 발생할 위험이 있습니다. 외부 파일을 읽는 경우도 마찬가지입니다. 개발자가 모르는 사이에 파일이 이동, 이름 변경 또는 삭제되었을 수 있기 때문입니다.
따라서 이런 상황에서도 프로그램이 실행을 멈추지 않고 우아하게(gracefully) 대응할 수 있어야 하며, 그것이 바로 예외 처리의 목적입니다.
Java 예외의 계층 구조
Java의 모든 예외는 Exception 클래스를 상속받아야 하며, Exception 클래스 자체는 Throwable 클래스의 하위 클래스입니다.
Exception 클래스의 두 가지 주요 서브클래스는 RuntimeException과 IOException입니다.
Exception과 Error의 차이
Throwable 클래스에는 Error라는 또 다른 하위 클래스가 있습니다. 하지만 Error는 Exception과 성격이 다릅니다.
Error는 JVM이 실행 중 직면하는 문제를 의미하며, 대부분 치명적이고 복구가 불가능합니다. 메모리 누수나 라이브러리 호환성 문제가 대표적인 원인입니다.
StackOverflowError와 OutOfMemoryError가 Java Error의 대표적인 예입니다.
Checked 예외와 Unchecked 예외
Java 예외는 크게 Checked(확인된) 예외와 Unchecked(미확인) 예외 두 가지로 나눌 수 있습니다.
Checked 예외는 컴파일 전에 반드시 처리해야 하는 예외입니다. 처리하지 않으면 Java 컴파일러가 컴파일을 허용하지 않기 때문에 '컴파일 타임 예외'라고도 부릅니다. 대표적인 예가 IOException입니다.
반면 Unchecked 예외는 컴파일러가 컴파일 시점에 신경 쓰지 않는 예외입니다. 프로그램 내에서 이를 처리했는지 여부와 관계없이 컴파일이 진행되며, 강제성이 없다 보니 RuntimeException으로 인해 프로그램이 비정상 종료될 위험이 있습니다.
RuntimeException을 상속하는 모든 클래스가 Unchecked 예외에 해당하며, 대표적인 예로 NullPointerException과 ArrayIndexOutOfBoundsException이 있습니다.
Exception 클래스의 주요 메서드
Java Exception 클래스에서 자주 사용되는 메서드를 살펴보겠습니다.
getMessage(): 발생한 예외에 대한 세부 정보를 담은 메시지를 반환합니다.printStackTrace(): 발생한 예외의 스택 트레이스(stack trace)를 출력합니다.toString(): 클래스 이름과getMessage()가 반환하는 메시지를 함께 반환합니다.
예외 처리 방법
이제 Java에서 예외를 처리하는 다양한 방법을 하나씩 살펴보겠습니다.
try-catch 블록
Java에서는 try-catch 블록을 사용해 예외를 잡아내고 적절히 처리할 수 있습니다.
try 블록 안에는 예외가 발생할 가능성이 있는 코드를 배치하고, catch 블록(또는 여러 개의 catch 블록)에서 던져진 예외를 잡아 개발자가 정의한 로직에 따라 처리합니다.
기본 문법은 다음과 같습니다:
try {
//exception-prone code
}
catch(Exception e) {
//error handling logic
}
이 방식을 사용하면 예외가 발생해도 프로그램이 즉시 중단되지 않고, 우아하게 처리된 후 계속 실행됩니다.
FileReader 클래스가 던지는 IOException을 처리하는 예제를 확인해 보겠습니다.
예제:
import java.io.FileReader;
public class TryCatchBlockExample {
public static void main(String[] args) {
try {
FileReader file = new FileReader("source.txt");
file.read();
}
catch(Exception e) {
e.printStackTrace();
}
}
}
여기서는 단일 catch 블록을 사용하여, FileReader 객체 생성 시 발생하는 FileNotFoundException과 read() 메서드 호출 시 발생하는 IOException을 모두 처리했습니다. 두 예외 모두 Exception 클래스의 하위 클래스이기 때문입니다.
하나의 try 블록에서 던질 수 있는 오류 유형이 여러 개라면, catch 문을 여러 개 사용해 각각 다르게 처리할 수도 있습니다. 앞선 예제를 FileNotFoundException 전용 catch 블록과 IOException 전용 catch 블록으로 분리하면 다음과 같습니다:
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
public class TryCatchBlockExample {
public static void main(String[] args) {
try {
FileReader file = new FileReader("source.txt");
file.read();
file.close();
}
catch(FileNotFoundException e) {
e.printStackTrace();
}
catch(IOException e) {
e.printStackTrace();
}
}
}
발생한 예외가 첫 번째 catch 문이 처리하는 예외와 일치하면 첫 번째 catch 블록의 로직이 실행됩니다. 일치하지 않으면 두 번째 catch 문으로 넘어가고, catch 문이 더 있다면 해당 예외 유형을 처리하는 catch 문을 만날 때까지 이 과정이 반복됩니다.
단, 주의할 점이 있습니다. FileNotFoundException은 IOException의 하위 타입(subtype)이므로, 두 번째 catch 문에서 FileNotFoundException을 처리하려 해도 동작하지 않습니다. 이미 첫 번째 catch 문이 해당 예외를 가로채기 때문에 두 번째 문에는 도달하지 않습니다.
참고: try 문을 사용할 때는 최소한 하나의 catch 문을 함께 사용하는 것이 필수입니다.
finally 블록
try-catch 블록으로 예외를 처리할 때, 예외 발생 여부와 상관없이 특정 로직을 반드시 실행해야 하는 경우가 있습니다. 이럴 때 일반적인 try-catch 대신 try-catch-finally 블록을 사용할 수 있습니다.
finally 블록의 코드는 예외가 발생하든 아니든 항상 실행되며, try-catch-finally 블록의 맨 마지막에 위치해야 합니다.
예를 들어, FileReader 클래스로 파일을 읽을 때는 예외 발생 여부와 관계없이 처리가 끝난 후 열린 파일을 반드시 닫아야 합니다. 이를 보장하기 위해 파일을 닫는 코드를 finally 블록 안에 넣을 수 있습니다.
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
public class TryCatchFinallyBlockExample {
public static void main(String[] args) {
FileReader file = null;
try {
file = new FileReader("source.txt");
file.read();
}
catch(FileNotFoundException e) {
e.printStackTrace();
}
catch(IOException e) {
e.printStackTrace();
}
finally {
file.close();
}
}
}
하지만 위 코드를 그대로 컴파일하면 처리되지 않은 IOException 때문에 컴파일되지 않습니다. FileReader 클래스의 close() 메서드 역시 IOException을 던질 수 있기 때문입니다. 따라서 이 부분을 아래처럼 별도의 try 블록으로 감싸야 합니다:
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
public class TryCatchFinallyBlockExample {
public static void main(String[] args) {
FileReader file = null;
try {
file = new FileReader("source.txt");
file.read();
}
catch(FileNotFoundException e) {
e.printStackTrace();
}
catch(IOException e) {
e.printStackTrace();
}
finally {
try {
file.close();
}
catch(IOException e) {
e.printStackTrace();
}
}
}
}
throws 키워드
Java에서 throws 키워드를 활용한 예외 처리는 매우 간단합니다. 이 방식에서는 예외가 발생한 지점에서 직접 처리하는 것이 아니라, 현재 메서드를 호출한 상위 메서드로 예외를 넘깁니다. 그러면 오류를 처리할 책임은 외부 메서드에게 넘어갑니다.
메서드 밖으로 예외를 던지려면, 해당 메서드가 특정 예외를 던질 수 있다고 선언하기만 하면 됩니다. FileReader 클래스가 던지는 IOException을 이 방식으로 처리하는 예제를 살펴보겠습니다.
예제:
import java.io.FileReader;
import java.io.IOException;
public class ThrowsExample {
public void readFile throws IOException {
FileReader file = new FileReader("source.txt");
file.read();
file.close();
}
}
throw 키워드
앞서 소개한 방법들과 달리, throw 키워드는 예외를 '처리'하는 용도가 아닙니다. 하지만 많은 개발자가 throw와 throws를 혼동하기 때문에 이 자리에서 함께 짚고 넘어가겠습니다.
throw 키워드는 예외를 명시적으로 발생시킬 때 사용합니다. 새로 생성한 예외 객체를 던질 수도 있고, 메서드 내에서 잡은(catch한) 예외를 다시 던질 수도 있습니다.
public class ThrowExample {
public void invalidate(int amount) throws Exception {
if (amount < 500) {
throw new Exception("Amount not sufficient");
}
}
}
사용자 정의 예외(User-Defined Exceptions)
Java에서 기본 제공하는 예외뿐만 아니라, 필요에 따라 직접 예외를 정의해서 사용할 수도 있습니다. 사용자 정의 예외는 Checked 또는 Unchecked 중 원하는 방식으로 만들 수 있습니다.
Checked 예외를 만들려면 Exception 클래스를 상속받으면 됩니다. 반대로 Unchecked 예외를 만들려면 RuntimeException 클래스를 상속받습니다.
다음 코드는 사용자 정의 Checked 예외를 생성한 예입니다:
public class InvalidLengthException extends Exception {
private int length;
private String message;
public InvalidLengthException(int length, String message) {
this.length=length;
this.message=message;
}
public int getAmount() {
return this.length;
}
public String getMessage() {
return this.message;
}
}
이제 이 예외를 프로그램 로직에서 다음과 같이 활용할 수 있습니다:
public class InputChecker {
private int minLength;
private int maxLength;
public InputChecker(int minLength, int maxLength) {
this.minLength=minLength;
this.maxLength=maxLength;
}
public void checkStringLength(String strInput) throws InvalidLengthException {
int strLength = strInput.length();
if (strLength < minLength) {
throw new InvalidLengthException(strLength, "Input should have minimum "+minLength+" characters");
}
else if (strLength > maxLength){
throw new InvalidLengthException(strLength, "Input should have maximum "+maxLength+" character");
}
}
}
InputChecker 클래스로 문자열 길이를 검사하면, 길이가 최소 길이보다 짧거나 최대 길이보다 길 때 InvalidLengthException이 발생합니다.
public class Main {
public static void main(String[] args) {
InputChecker ic = new InputChecker(2, 7);
try {
ic.checkStringLength("longer than the maximum length");
}
catch(InvalidLengthException e) {
e.printStackTrace();
}
}
}
위 코드를 실행하면 InvalidLengthException이 발생하며, 다음과 같은 결과를 확인할 수 있습니다:
InvalidLengthException: Input should have maximum 7 character
at InputChecker.checkStringLength(InputChecker.java:17)
at Main.main(Main.java:6)
마무리
이번 글에서는 Java 예외에 대해 간결하면서도 핵심적인 내용을 소개했습니다. 예외가 무엇인지, 그리고 Java 프로그램에서 예외를 어떻게 처리해야 하는지 확실히 이해하셨기를 바랍니다. try-catch-finally, throws, throw, 그리고 사용자 정의 예외까지 활용한다면 더욱 견고하고 안정적인 애플리케이션을 개발할 수 있습니다.