자바에서의 오류와 예외 처리 정리

zayn_noh·2026년 5월 22일

Java 개념 정립

목록 보기
8/8
post-thumbnail

1. 오류란,

자바에서는 발생할 수 있는 오류(예외, 에러)에 대해서
기본적으로 다음 두 개의 클래스를 통해 정의하였다.

public class Exception extends Throwable { ... }

public class Error extends Throwable { ... }

에러(Error) : 프로그램 코드로 수습될 수 없는 심각한 오류
OutOfMemoryError StackOverFlowError NoClassDefFoundError

예외(Exception) : 프로그램 코드로 수습될 수 있는 미약한 오류
ArithmeticException NullPointerException FileNotFoundException

이때 두 오류 클래스의 선언부를 보면
Throwable이라는 클래스를 상속 받는 것이 확인 가능한데

public class Throwable implements Serializable { ... }

이를 통해 Exception뿐만 아니라 Error 역시
throw 키워드를 통해 개발자가 에러 객체를 생성할 수 있음을 알 수 있다.

Error객체를 throw 하는 경우?
자바에서의 Error는 JVM 수준의 심각한 문제이다.
따라서 일반적으로 개발자가 Error를 직접 throw하는 것은 권장되지 않는다.

굳이 사용한다면 다음과같은 사항에 넣을 수 있다.

// 절대 발생해서는 안 되는 코드 경로임을 명시
throw new AssertionError("이 코드는 절대 실행되면 안 됩니다.");
// switch의 모든 case를 처리했음에도 도달했을 때
switch (direction) {
    case NORTH: ...
    case SOUTH: ...
    default:
        throw new AssertionError("알 수 없는 방향: " + direction);
}
// 테스트코드에서의 상황
throw new Error("테스트 환경에서 강제 실패");

Throwable 클래스는 왜 모든 오류(예외 + 에러) 객체의 조상 클래스일까?

이는 throw 키워드와 catch 블록 내에서
Throwable클래스 타입만 다룰 수 있도록 JVM이 설계되어 있기에

Throwable을 상속받는 클래스만 예외나 에러를,
던지고(throw) 받을(catch) 수 있도록 하였다.

또한 프레임워크나 테스트 도구에서 모든 종류의 오류를 한 번에
처리해야하는 코드의 경우 다음처럼

catch(Throwable t) { ... }

단일 표현이 가능하여 코드의 중복을 막아 복잡도를 개선할 수 있고
상속을 통한 자바의 다형성 측면이 드러남을 알 수 있다.

+ 프로그램의 오류는 크게 다음과 같은 분류로 나눌 수 있다.

  • 컴파일 에러 : 컴파일 시 발생하는 오류
    문법 오류 타입 오류 checked exception 미처리
  • 런타임 에러 : 런타임 시 발생하는 오류
    NullPointerException OutOfMemoryError
  • 논리적 에러 : 실행은 되지만, 의도와 다른 동작

2. 클래스 계층 구조

Throwable
		<- Error
		<- Exception
				<- RuntimeException (checked exception)
		
				(checked exception)
				<- IOException
				<- ReflectiveOperationException
				<- ClassNotFoundException
				<- ...

이 중 개발자가 제어가 불가능한 Error 클래스를 제외하고
Exception 클래스의 상속 클래스들을 살펴보면 크게 다음 두 가지로 나눌 수 있다.

  • RuntimeException
  • ~RuntimeException

RuntimeException의 경우,
unchecked exception으로 컴파일 시점에
try-catch 와 같은 예외 처리를 강제하지 않지만,
올바르게 예외처리가 되어있지 않다면 발생 시 예상치 못한 문제를 야기할 수 있다.

// 사용자 id 길이 check해야하는데 런타임에 null값이 할당된 경우
String username = null;
if (username.length() < 5) System.out.println("id 길이 5자 이상");
// NullPointerException 발생
Exception in thread "main" java.lang.NullPointerException:
	Cannot invoke "String.length()" because "username" is null

RuntimeException 외 Exception 상속 클래스의 경우
checked exception이며
소스코드에서 try-catch 와 같은 예외처리를 강제한다.
예외처리가 되지 않은 checked exception은,
컴파일을 진행할 경우 컴파일 에러가 발생한다.

public static void main(String[] args) {
	Thread t = new Thread(() -> System.out.println("test Thread1"));
	t.start();
	t.join();
}
// InterruptedException
java: unreported exception java.lang.InterruptedException;
	must be caught or declared to be thrown

정리하자면 컴파일러가 강제하는 checked exception의 예외처리와,
런타임 상황에서 발생할 수 있는 uncheked exception으로 인한 문제를 예방하기 위해

예외 처리가 필요하다.


3. 예외 처리

예외처리의 근본적인 목적은 예외의 발생으로 인한
실행 중인 프로그램의 비정상 종료를 막으며 정상 실행상태를 유지하는 것이다.

Error와 Exception은 모두 실행 시 (runtime) 발생할 수 있는 오류이다.
→ 따라서 실행 중 발생하는 에러는 다음 문제이고,
=> 예외는 코드레벨에서 미리 처리해야 한다.

try {
    // 예외가 발생할 가능성이 있는 문장들
} catch (Exception1 e1) {
    // Exception1이 발생할 경우, 이를 처리하기 위한 문장
} catch (Exception2 e2) {
    // Exception2가 발생할 경우, 이를 처리하기 위한 문장
} catch (ExceptionN eN){
    // ExceptionN이 발생할 경우, 이를 처리하기 위한 문장
}

위 코드는 기본적인 try-catch의 문법 구조이다.
try 코드 블럭 내에서 예외가 발생하면, 발생한 예외에 해당하는 인스턴스가 만들어진다.

이 인스턴스는 발생한 예외에 대한 정보가 담겨있으며,
catch 문의 괄호 () 를 통해 참조변수로 정보를 얻을 수 있다.

예외 발생 시 try-catch 동작 과정
try 블록 내에서 예외가 발생했다면, 코드 순서대로 catch의 괄호 내 선언된
참조변수의 타입과 발생한 예외를,
instanceof 연산자를 통해 검사하는 과정을 거친다.

try {
	...
} catch (Exception e) {
	...
} catch (IllegalArgumentException e) {
	...
}
// Exception의 경우 모든 예외의 조상이기에
// 하단 IllegalArgumentException에 해당하더라도
// catch (Exception e) catch 블럭 내부만 실행된다.

4. 고의적 예외 발생

개발자는 throw 키워드를 통해 고의적으로 예외(Exception)를 발생시킬 수 있다.

class ExceptionEx {
    public static void main(String[] args) {
        try {
            Exception e = new Exception("고의 발생 예외");
            throw e; // 예외를 발생시킨다.
            // Exception객체를 만들 때 String을 인자로 넣어주면
			// 해당 문자열이 Exception 객체의 메시지로 저장된다.
						
        } catch (Exception e) {
            System.out.println(e.getMessage());
            e.printStackTrace();
        }
        
        System.out.println("정상 종료");
    }
}
고의 발생 예외 // by e.getMessage()
정상 종료 // catch 이후 로직 진행으로 인해
java.lang.Exception: 고의 발생 예외
	at ExceptionEx.main(Main.java:4)

이러한 고의적 예외 발생 방식을 통해
checked exception과 unchecked exception의 차이에 대해 알 수 있는데

class ExceptionEx {
    public static void main(String[] args) {
//        throw new ArrayIndexOutOfBoundsException();
//        throw new IllegalArgumentException();
//        throw new IOException();
//        throw new ClassNotFoundException();
    }
}

throw를 통해 고의적으로 발생 시킨 각 예외 객체를,
실제로 주석을 풀어 main 메서드에서 실행시켜보면 각각 아래와 같은 결과를 얻는다.

  • ArrayIndexOutOfBoundsException IllegalArgumentException
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException
	at ExceptionEx.main(Main.java:5)

Exception in thread "main" java.lang.IllegalArgumentException
	at ExceptionEx.main(Main.java:6)
  • IOException ClassNotFoundException
java: unreported exception java.io.IOException; 
	must be caught or declared to be thrown
	
java: unreported exception java.lang.ClassNotFoundException;
	must be caught or declared to be thrown

checked exception인 ArrayIndexOutOfBoundsException 와 IllegalArgumentException의 경우
컴파일이 정상 진행되나 이후 런타임에서 예외가 발생하여 프로그램이 종료된다.

반대로 checked exception인 IOException과 ClassNotFoundException은
예외처리를 하지 않아 컴파일 조차 진행되지 않음을 알 수 있다.

그렇다면 throw를 통한 예외 객체는 왜 쓸까?
throw 로 예외 객체를 만드는 이유 중 하나는
단순 return , if 와 같은 분기를 통해 표현하기 어려운
비정상 상황을 명시적으로 하여 알리기 위해서이다.

  • 예외 발생 시 return 사용 분기
    호출자가 반환된 값을 제대로 처리하지 못하면, 예외가 묻힘
  • 예외 발생 시 if 사용 분기
    메서드 호출 스택이 깊게 쌓여 있다면, 오류 전달하기 번거러움

throw의 경우 발생한 예외를 강제로 위로 전달하고 무시할 수 없게 한다.

throw를 통해 발생한 예외 객체는 try-catch와 같은
예외 처리를 담당하는 부분까지 Call Stack을 타고 올라가며

예외처리 담당 부분을 만나면 catch의 괄호 내에서
instanceof 연산을 통해 확인하고 이를 처리하는 것이다.


5. 메서드 예외 선언

메서드의 선언부에 붙은 throws 키워드는 실제로 예외를 처리한다가 아닌,
예외 처리의 책임을 해당 메서드 호출부에 넘긴다는 의미이다.

void method() throws Exception1, Exception2, ... ExceptionN {
	...
}

한 throws 로 여러 예외 클래스 타입을 선언부에 쓸 수 있는데
이를 통해 해당 메서드를 사용할 때 어떠한 예외가 발생할 수 있는 지 알 수 있다.

  • checked exception의 경우
    반드시 예외처리를 해야하기에, 호출부에서 예외처리가 이루어져야 한다.
    호출부에서는 해당 메서드를 try-catch로 감싸거나,
    throws를 통해 다시 상위 호출 메서드로 책임을 넘길 수 있다.
  • unchecked exception의 경우
    예외처리가 필수는 아니나,
    호출부에서 이를 사용할 때 이러한 런타임예외가 발생할 수 있음을 알 수 있다.

main 메서드에 throws 가 붙으면 어떻게 될까?

class ExceptionEx {
    public static void main(String[] args) throws ClassNotFoundException {
        throw new ClassNotFoundException();
    }
}

위 예제를 보면 ClassNotFoundException은
checked exception이기에 원래는 예외처리가 없으면 컴파일되지 않는 코드임에도
main 메서드 선언부에 붙은 throws를 통해 컴파일이 진행되어 프로그램이 실행된다.

이후 main을 호출한 JVM에서 해당 예외를 받아 예외에 대해 출력하며,
프로그램이 비정상 종료된다.

Exception in thread "main" java.lang.ClassNotFoundException
	at ExceptionEx.main(Main.java:5)

profile
개발 및 IT기술에 대해 정리하고 기록합니다.

0개의 댓글