숑숑이의 개발일기
Published 2023. 6. 30. 00:24
[Java] #14 예외 처리 Backend/JAVA

본글은 'Do it 자바 완전 정복' 책을 통한 공부내용을 정리한 글입니다.

 

예외

개발자가 프로그램을 작성하는 과정에서 실수를 하거나 사용자가 잘못된 값을 입력시에 오류가 발생할 수 있다. 이 중 개발자가 해결할 수 있는 오류를 예외(exception)라고 하며, 이러한 예외가 발생했을 때 적절히 처리하는 것을 '예외 처리'라고 한다.

 

예외와 에러의 차이점

예외는 연산 오류, 숫자 포맷 오류 등과 같이 상황에 따라 개발자가 해결할 수 있는 오류를 말한다. '해결 할 수 있는'의 의미는 오류 자체를 수정할 수 있다는 것이 아닌 오류 발생시에 차선택을 선택하는 것을 말한다. 예외의 최상위 클래스는 Exception 클래스다.

에러는 자바 가상 머신 자체에서 발생하는 오류로 개발자가 해결할 수 없는 오류를 말한다. 에러는 차선택을 선택하는 것 자체도 불가능 한 때를 말한다. 에러의 최상위 클래스는 Error 클래스다.

두 클래스는 모두 Throwable 클래스를 상속하고 있으므로, Throwable 클래스의 모든 기능을 포함한다.

 

예외 클래스의 상속 구조

Exception 클래스는 일반 예외(checked exception) 클래스와 실행 예외(unchecked exception)클래스로 나뉜다. 일반 예외는 Exception 클래스에게서 직접 상속받고 컴파일 전에 예외 발생 문법을 발생하며, 예외 처리를 하지 않으면 문법 오류가 발생한다. 실행 예외는 RuntimeException 클래스를 상속 받고 예외 처리를 따로 하지 않더라도 문법 오류가 발생하지 않는다. 프로그램 실행 시 프로그램이 강제 종료되는 이유는 대부분 실행 예외 때문이다.

 

일반 예외 클래스

InterruptedException

Thread.sleep() 메서드는 일정 시간 동안 해당 쓰레드를 일시정지 상태로 만드는 Thread 클래스의 정적 메서드다. 해당 메서드는 일반 예외가 발생할 수 있기 때문에 반드시 예외 처리를 해야한다.

public class A {
	public static void main(String[] args) {
    	Thread.sleep(1000);
    }
}

 

ClassNotFoundException

Class.forName("패키지명.클래스명")은 클래스를 동적으로 메모리에 로딩하는 메서드로, 해당 클래스의 정보를 담고 있는 
Class 타입의 객체를 리턴한다. 만일 클래스를 메모리에 동적으로 로딩하는 과정에서 해당 클래스가 존재하지 않을 때는 ClassNotFoundException이 발생하므로 예외 처리를 반드시 포함해야 한다.

public class A {
	public static void main(String[] args) {
    	Class cls = Class.forName("java.lang.Object");
    }
}

 

IOException

자바 입출력 부분에서 자주 보게 될 일반 예외로, 콘솔이나 파일에 데이터를 쓰거나(write()) 읽을 때(read()) 발생하며, 반드시 IOException에 대한 예외 처리를 해야한다.

public class A {
	public static void main(String[] args) {
    	InputStreamReader isr = new InputStreamReader(System.in);
        isr.read();
    }
}

 

FileNotFoundException

파일을 읽을 때 해당 경로에 파일이 없으면 FileNotFoundException이 발생한다. 실제 파일의 존재 유무와는 상관없이 파일이 존재하지 않을 가능성이 있는 코드이므로 반드시 예외 처리를 해야한다.

public class A {
	public static void main(String[] args) {
    	FileInputStream fis = new FileInputStream("text.txt");
    }
}

 

CloneNotSupportedException

Object 클래스의 메서드 중 clone()은 자신의 객체를 복사한 클론 객체를 생성해 리턴하는 메서드다. 복사의 대상이 되는 클래스는 반드시 Cloneable 인터페이스를 상속해야 한다. 만약 Clonable 인터페이스를 상속하지 않은 클래스의 객체를 복사하기 위해 clone() 메서드를 호출하면 CloneNotSupportedException이 발생한다.

 

실행 예외

실행 예외는 예외 처리 없이 컴파일과 실행이 가능하지만, 실행 중 실행 예외가 발생하면 프로그램이 강제 종료된다.

 

ArithmeticException

ArithmeticException는 연산 자체가 불가능할 때 발생하는 실행 예외다.

public class A {
	public static void main(String[] args) {
    	System.out.println(3 / 0);
        // 절대 존재할 수 없는 연산 (분모가 0)
    }
}

 

ClassCastException

상속 관계에 있는 클래스 간의 업캐스팅은 항상 가능하지만, 다운 캐스팅은 가능할 수도, 불가능 할 수도 있다. ClassCastException은 다운캐스팅이 불가능한 상황에서 다운캐스팅을 시도할 때 발생한다.

class A {}
class B extends A {}

public class Test {
	public void main(String[] args) {
    	A a = new A();
        B b = (B) a;
    }
}

 

ArrayIndexOutOfBoundsException

배열의 인덱스를 잘못 사용했을 때 발생한다. 배열의 인덱스는 항상 0에서부터 배열의 길이 - 1 까지의 값만 사용하므로 범위 밖에 있는 인덱스를 사용하면 예외가 발생한다.

public class A {
	public void main(String[] args) {
    	int[] a = {1, 2, 3};
        System.out.println(a[3]);
        // 인덱스의 범위를 넘어섬
    }
}

 

NumberFormatException

문자열을 숫자 또는 실수로 변환할 때 문자열이 변환하고자 하는 숫자 형식이 아니면 변환이 실패한다. 이때 발생하는 실행 예외.

public class A {
	public void main(String[] args) {
    	int num = Integer.parseInt("10!");
        // 숫자가 아닌 것을 숫자로 바꾸려함
    }
}
문자열을 불리언값으로 변환하는 과정은 숫자가 아니기 때문에 NumberFormatException이 발생하지 않는다. 문자열을 불리언값으로 변경할 때는 문자열이 "true"일 때만 true로 변환되고, 이외의 모든 문자열(비어 있는 문자열 포함)을 false로 변환한다.

 

NullPointerException

참조 변수가 실제 객체를 가리키고 있지 않은 상황에서 필드나 메서드를 호출할 때 발생한다.  null은 참조 변수의 초깃값으로만 사용할 수 있으며, 현재 가리키고 있는 객체가 없다는 것을 의미한다.

public class A {
	public void main(String[] args) {
    	String a = null;
        System.out.println(a.charAt(2));
    }
}

 

 

예외 처리

예외 처리는 에러가 발생했을 때 처리하는 방법을 제공하는 문법 요소로, 예외 처리 구문이 포함되면 예외가 발생하더라도 프로그램이 계속 실행된다. 심지어 예외 처리 구문 내에 아무런 코드를 작성하지 않아도 예외가 처리된 것으로 간주한다.

 

예외 처리 문법

예외 처리 문법은 try, catch, finally의 3가지 요소로 구성되어 있다.

try {
// 일반 예외, 실행 예외 발생 가능 코드
} catch (예외 클래스명 참조 변수명) {
// 예외가 발생했을 때 처리
} finally {
// 예외 발생 여부에 상관없이 무조건 실행 (생략 가능)
}

하나의 catch(){} 블록이 모든 예외를 처리하는 것은 아니며, 소괄호 안의 예외 타입에 해당하는 예외에 한해서만 처리할 수 있다. 예외 없이 정상적으로 동작할 때는 실행되지 않는다. finally{} 블록은 일반적으로 리소스 해제 또는 try catch 블록의 공통 기능 코드가 포함되어 있다.

 

예외 처리 과정

아래 예제코드로 내부적으로 예외 처리가 되는 메커니즘을 알아보자.

try {
	System.out.println(3 / 0);
} catch (ArithmeticException e) {
	System.out.println("숫자는 0으로 나눌 수 없습니다.");
} finally {
	System.out.println("프로그램 종료");
    System.exit(0);	// 현재 실행하고 있는 프로세스 강제 종료 (정상 종료)
}

먼저 try{} 구문이 실행되고 예외가 발생하면 JVM은 발생한 예외 타입의 객체를 생성해 catch(){} 블록의 매개변수로 전달한다. 만일 JVM이 생성해 넘겨 준 객체 타입을 catch(){} 블록이 처리할 수 없을 때에는 예외 처리가 되지 않아 프로그램이 강제 종료된다.

 

다중 예외 처리

메서드가 다양한 입력매개변수 타입으로 오버로딩될 수 있는 것처럼 catch(){} 블록도 예외 타입에 따라 여러 개를 포함할 수 있다.

try {
	System.out.println(3 / 0);
    int a = Integer.parseInt("20!");
} catch (ArithmeticException e) {
	System.out.println("숫자는 0으로 나눌 수 없습니다.");
} catch (NumberFormatException e) {
	System.out.println("숫자로 변환할 수 없습니다.")
} finally {
	System.out.println("프로그램 종료");
    System.exit(0);	// 현재 실행하고 있는 프로세스 강제 종료 (정상 종료)
}

이러한 다중 예외 처리 구문을 작성할 때 주의해야할 사항은 try{} 블록에서 예외가 발생하고, 여러 개의 catch(){} 블록이 있을 때 실행할 catch(){} 블록의 선택 과정은 항상 위에서부터 확인한다는 것이다. if-else if-else 구문에서 조건식을 위에서부터 검사하는 것처럼 말이다. 고로 unreachable code가 될 수 있으므로 catch(){} 블록의 순서를 유의하자.

try {
	System.out.println(3 / 0);
    int a = Integer.parseInt("20!");
} catch (NumberFormatException e) {
	System.out.println("숫자로 변환할 수 없습니다.")
} catch (Exception e) {	// if문의 else와 같은 역할
	System.out.println("숫자는 0으로 나눌 수 없습니다.")
}finally {
	System.out.println("프로그램 종료");
    System.exit(0);
}

 

다중 예외 처리 과정에서 각각의 예외가 발생했을 때 처리하는 내용이 동일한 경우, 각각의 예외 타입을 OR(|) 기호를 사용해 예외를 동시에 처리하도록 통합할 수 있다. 

try {
	System.out.println(3 / 0);
    int a = Integer.parseInt("20!");
} catch (ArithmeticException e | NumberFormatException e) {
	System.out.println("예외 발생")
} finally {
	System.out.println("프로그램 종료");
    System.exit(0);
}

 

리소스 자동 해제 예외 처리

앞서 finally{} 블록은 항상 실행해야 하는 기능이 있을 때 사용하는 블록이라고 했다. 가장 대표적인 기능은 리소스를 해제하는 것인데, 리소스 해제는 더이상 사용하지 않는 자원을 반납하는 것을 의미한다.

InputStreamReader is = null;
try {
	is = new InputStreamReader(System.in);
    System.out.println(is.read());
} catch (IOException e) {
	// 예외 처리
} finally {
	if (is != null) {
    	try {
        	is.close();
        } catch (IOException e) {
        	// 예외처리
        }
    }
}

위의 반복적인 구조를 간략화하기 위해 추가된 예외 처리 구문이 리소스 자동 해제 예외 처리 구문이다.

try (리소스 자동 해제가 필요한 객체 생성) {
	// 예외 발생 가능 코드
} catch (예외 클래스명 참조 변수명) {
	// 해당 예외가 발생했을 때 처리하는 블록
} finally {
	// 예외 발생 여부에 상관없이 무조건 실행하는 블록
}

기존 예외 처리 구문과의 차이점은 try 구문에도 소괄호가 포함된다는 것이다. 예외 처리 구문이 완료되면 try(){} 블록의 소괄호 안에서 생성된 객체 내부의 close() 메서드를 자동으로 호출함으로써 리소스를 자동으로 해제한다. 리소스 자동 해제를 위해서는 반드시 객체 내부에 close() 메서드가 포함되어 있어야 한다.

try (InputStreamReader is = new InputStreamReader(System.in);) {
    System.out.println(is.read());
} catch (IOException e) {
	// 예외 처리
}

 

그렇다면 모든 객체를 try 블록의 소괄호 안에 넣을 수 있을까? try 구문 소괄호 안에서 생성된 객체 내부에 close() 메서드가 포함되어 있다는 것을 어떻게 보장할 수 있을까? 리소스 자동 해제를 위한 클래스는 AutoCloseable 인터페이스를 구현해야 한다. 인터페이스 내부에는 close() 추상 메서드가 포함되어 있기 때문에 이 인터페이스를 구현한 모든 클래스의 객체는 내부에 close() 메서드를 포함하고 있다는 것을 보장받는다.

class A implements AutoCloseable {
	String resource;
    A(String resouse) {
    	this.resource = resouce
    }
    @Override
    public void close() throws Exception {
    	if (resource != null) {
        	resource = null;
            System.out.println("리소스 해제");
        }
    }
}

public class TryResouce {
	public static void main(String[] args) {
    	// 1. 수동해제
    	A a1 = null;
        try {
        	a1 = new A("특정 파일");
        } catch (Exception e) {
        	System.out.println("예외");
        } finally {
        	if (a1.resource != null) {
            	try {
                	al.close();
                } catch (Exception e) {}
            }
        }
        
        // 2. 자동해제
        try (A a1 = new A("특정 파일");) {
        } catch (Exception e) {
        	System.out.println("예외");
        }
    }
}

 

예외 전가

예외가 발생했을때 자신을 호출한 지점으로 예외를 전가(throws)할 수도 있다. 예외를 전가하면 예외 처리의 의무를 호출한 메서드가 갖게 된다.

 

예외 전가 문법

예외를 전가할 때는 메서드의 소괄호와 중괄호 사이에 전가시키고자 하는 예외 타입을 throws 키워드와 함께 삽입하는 방법을 사용한다.

void abc() {
	try {
    	bcd();
    } catch(InterruptedException e) {
    	// 예외 처리 구문
    }
}

 void bcd() throws InterruptedException {
 	Thread.sleep(1000);
 }

위의 코드에서 bcd() 메서드 내부에 예외 발생 가능 코드가 포함되어 있다. bcd() 메서드는 직접 예외를 처리하는 대신 자신이 호출한 메서드로 예외를 전가했다.

 

상위 메서드들이 예외를 직접 처리하지 않고 계속 전가만 하여 최상위 메서드인 main() 메서드 까지 올라가고, main() 메서드 마저 예외를 전가하면 자바 가상 머신이 직접 예외를 처리한다. (발생한 예외의 정보를 화면에 출력하고 프로그램을 강제 종료시킨다.)

 

예외 처리시에 다중 예외 처리가 가능했던 것처럼 예외 전가 또한 한 번에 전가할 수 있다. 이때에는 쉼표(,)로 구분해 나열한다.

// 1. 하위 메서드에 직접 예외 처리시
class A {
	void abc() {
    	bcd();
    }
    void bcd() {
    	try {
        	Class cls = Class.forName("java.lang.Object");
            Thread.sleep(1000);
        } catch (InterruptedException | ClassNotFoundException e) {
        	// 예외 처리 구문
        }
    }
}

// 2. 예외를 호출 메서드로 전가시
class B {
	void abc() {
    	try {
        	bcd();
        } catch (InterruptedException | ClassNotFoundException e) {
        	// 예외 처리 구문
        }
    }
    void bcd() throws InterruptedException, ClassNotFoundException {
    	Class cls = Class.forName("java.lang.Object");
        Thread.sleep(1000);
    }
}

 

사용자 정의 예외 클래스

자바는 다양한 형태의 일반 예외 클래스와 실행 예외 클래스를 제공하지만 모든 예외 클래스를 제공하는 것은 불가능하다. 예를 들어 int age = -1을 실행시에 나이는 음수가 될 수 없으므로 예외를 발생시켜야 할 것이다. 이 때 예외 클래스르 직접 정의해 사용할 수 있다.

 

사용자 정의 예외 클래스 생성 방법

크게 아래의 3단계로 이루어져 있다.

1. 예외 클래스를 사용자가 직접 정의

2. 예외 클래스를 통해 객체 생성

3. 고려하는 예외 상황에서 예외 객체를 던짐(throw)

 

사용자 정의 예외 클래스 작성

class MyException extends Exception {	// 일반 예외
	MyException() {}
    MyException(String s) {
    	super(s);	// 부모 생성자 호출
    }
}

class MyRTException extends RuntimeException {	// 실행 예외
	MyRTException() {}
    MyRTException(String s) {
    	super(s);
    }
}

 

사용자 정의 예외 객체 생성

MyException me1 = new MyException();
MyException me2 = new MyException("예외 메시지");

MyRTException mre1 = new MyRTException();
MyRTException mre2 = new MyRTException("예외 메시지");

 

예외 상황에서 예외 객체 던지기

예외 상황이 발생하면 생성한 객체를 던진다는 것은 실제 자바 가상 머신에게 예외 객체를 만들어 전달한다는 의미다.

예외 객체를 전달할 때는 throw 예외 객체 의 형식을 사용한다. 예외를 전가하는 throws와 혼동 우려가 있으니 조심한다.

// 1. 예외를 직접 처리
void abc1(int num) {
	try {
    	if (num > 70)	System.out.println("정상 작동");
        else			throw me1;	// 예외를 던진 시점에 예외 발생
    } catch (MyException e) {
    	System.out.println("예외 처리1");
    }
}

void bcd1() {
	abc1(65);
}

// 2. 예외 전가
void abc2(int num) throws MyException {
	if (num > 70)	System.out.println("정상 작동");
    else			throw me1;	// 예외를 던진 시점에 예외 발생
}

void bcd2() {
	try {
    	abc2(65);
    } catch(MyException e) {
    	System.out.println("예외 처리2");    
    }
}

 

예외 클래스의 메서드

사용자 정의 예외 클래스는 Exception 또는 RuntimeException 클래스를 상속한다. 이 들의 부모 클래스인 Throwable 클래스의 메서드를 내려받는다. 아래 두 가지의 메서드를 알아본다.

 

getMessage()

예외가 발생했을 때 생성자로 넘긴 메시지를 문자열 형태로 리턴하는 메서드

public class ExceptionMethod {
	public static void main(String[] args) {
    	try {
        	thorw new Exception("나이가 음숫값을 가짐");	// 예외 발생
        } catch(Exception e) {
        	System.out.println(e.getMessage());
            // 나이가 음숫값을 가짐
        }
    }
}

 

printStackTrace()

예외 발생이 전달되는 경로. 즉, 예외가 전가된 과정을 한눈에 확인할 수 있는 메서드

class A {
	void abc() throws NumberFormatException {
    	bcd();
    }
 	void bcd() throws NumberFormatException {
 		cde();
    }
 	void cde() throws NumberFormatException {
 		int num = Integer.parseInt("10A");
    }
}

public class ExceptionMethod {
	public static void main(String[] args) {
    	A a = new A();
        try {
        	a.abc();
        } catch(NumberFormatException e) {
        	e.printStackTrace();
        }
    }
}

실행시 Integer.parseInt() > cde() > bcd() > abc() > main()의 순으로 출력한다.

profile

숑숑이의 개발일기

@숑숑-

풀스택 개발자 준비중입니다