Java

[Java] 예외와 예외 처리

nooblette 2023. 12. 2. 16:08

목차

    배경

    응용 프로그램은 동작 중에 예상치 못한 어떠한 이유로 중단될 수 있다. 개발자로서 이처럼 프로그램에 실행 오류가 발생할때 왜 발생했고 어떻게 대처할지, 어떻게 방지할지할 수 있을지 등은 반드시 알아두어야하며, 응용 프로그램 개발 중에 꼭 고민해야하는 부분이라고 생각한다. 그 중 java에서는 이러한 프로그램 실행 오류를 어떻게 정의하고 있으며 개발자가 어떻게 사용할 수 있는지 정리해두었다.

    에러(Error)와 예외(Exception)

    java에서 프로그램 실행 오류는 크게 에러와 예외라는 두가지 개념으로 나뉜다.

     

    에러(Error)는 컴퓨터 하드웨어의 고장으로 응용프로그램의 실행 오류가 발생하는 것이며, 아무리 견고하게 작성할지라도 개발자가 대처할 수 있는 방법이 없는 오류이다. 반면 예외(Exception)는 라이브러리나 클래스 등의 잘못된 사용 또는 코딩으로 인한 오류로 발생한다. 에러와 동일하게 예외도 응용 프로그램의 실행을 멈추지만, 예외는 프로그램에서 처리(Handling)하여 예외 전파를 막고 실행상태를 유지할 수 있다는 차이가 있다.

     

    예외(Exception)는 다시 일반 예외(Exception)실행 예외(Runtime Exception)으로 나뉜다. 일반 예외컴파일러가 예외 처리 코드의 존재 여부를 검사하고 예외 처리가 없다면 컴파일 에러를 발생시키는 예외이며, 실행 예외컴파일러가 예외 처리 코드의 존재 여부를 검사하지 않는 예외이다.(따라서 프로그램 실행중에 런타임 에러가 발생할 수 있다.)

    예외 클래스(Exception Class)

    java의 에러와 예외 클래스는 모두 Throwable 클래스를 상속받아 만들어진다.(Throwable 클래스는 Java의 모든 클래스 중 최상위 클래스인 Object 클래스를 상속받는다.) 추가적으로 예외 클래스는 java.lang.Exception 클래스를 상속 받는다. java에서는 예외가 발생하면 예외 클래스(Exception Class)로부터 객체를 생성하여, 이 예외 클래스가 예외 처리시에 사용된다.

    예외 클래스인 Exception Class의 하위 클래스로 IOException, ClassNotFoundException, InterruptException 등이 있으며, 이는 모두 컴파일러가 예외 처리 코드의 여부를 검사하는 일반 예외이다. 반면 Excpetion 클래스의 자식 클래스 중 RuntimeException 클래스로부터 상속받는 클래스들은 실행 예외(Runtime Exception) 클래스이며, 컴파일러가 예외 처리 코드의 여부를 검사하지 않는다. RuntimeException 클래스의 자식 클래스로는 NullPointerException, ArtihmeticException, ArrayIndexOutOfBoundsException, NumberFormatException 등이 있다.

    예외 처리

    java에서 이러한 예외 클래스를 처리(Handling)하기 위해서는 예외 처리 코드가 필요한데, 이 예외 처리 코드를 통해 프로그램 동작 중에 예외가 발생했을때 중단을 막고 실행 상태를 유지할 수 있도록 처리한다. 예외 처리 코드는 기본적으로 다음과 같이 try - catch- finally 블록으로 구성되어 있다.

    try{
    	...
    } catch(ArrayIndexOutOfBoundsException e) {
    	// ArrayIndexOutOfBoundsException 예외 클래스에 대한 예외 처리
    } catch(NumberFormatException e) {
    	// NumberFormatException 예외 클래스에 대한 예외 처리
    } catch(Exception e) {
    	// Exception 예외 클래스에 대한 예외 처리
    finally {
    	...
    }

     

    try 블록 내부에 작성한 코드가 실행 중에 catch 블록의 괄호 () 내부에 예외클래스로 정의한 예외가 발생하면 catch 블록 내부의 코드가 실행된다. 만일 catch() 블록의 예외클래스로 정의한 예외가 발생하지 않는다면 try 블록 내부의 코드가 전부 실행된다.

    try 블록 혹은 catch 블록내의 코드가 전부 실행되고나서 finally 블록의 코드가 실행된다. 즉 예외 발생 여부와 상관없이 finally 블록은 항상 실행되며, try 블록과 catch 블록에서 메서드 실행 중 return 구문을 사용하는 등 종료가 발생하더라도 finally 블록은 항상 실행된다. 또한 finally 블록은 생략 가능하다.

     

    catch 블록의 괄호 () 내부에 어떤 예외가 발생했을때 처리할지 그 예외 클래스를 정의하는데, catch 블록이 여러개라 할지라도 예외가 발생하면 단 하나의 catch 블록만이 실행된다. (try 블록에서 동시다발적으로 예외가 발생하지는 않으며, 하나의 예외가 발생하면 즉시 실행을 멈추고 해당 catch 블록으로 이동하기 때문이다.) 다시 말해, 처리해야할 예외 클래스가 여러개이고 이러한 예외 클래스가 상속 관계에 있을 경우(예를들어, NullPointerException과 Excption 예외 클래스를 처리해야하는 경우) 상대적으로 자식 관계에 있는 에외 클래스(이 예시에서는 NullPointerException 클래스)에 대한 catch 블록을 먼저 작성하고 상대적으로 부모 관계에 있는 예외 클래스(이 예시에서는 Exception 클래스에 해당한다.)에 대한 catch 블록을 나중에 작성해야 한다.

     

    만일 이러한 순서를 어긴다면 다음와 같이, Exception 'java.lang.하위예외클래스' has already been caught 컴파일 에러가 발생한다.

     

    또한, 하나의 catch 블록으로 여러개의 예외 클래스를 처리하고 싶다면 or 연산자 (|) 로 예외 클래스를 연결하면 된다.

    try{
    	...
    } catch(ArrayIndexOutOfBoundsException | NumberFormatException e) {
    	// ArrayIndexOutOfBoundsException와 NumberFormatException 예외 클래스에 대한 예외 처리
    }
    finally {
    	...
    }

     

    catch 블록 내부에서는 getMessage(), toStirng(), printStackTree() 메서드를 통해서 예외 정보를 얻을 수 있다. 세 메서드의 차이는 다음과 같다. 일반적으로 예외 정보를 알아야하는 경우 어느 지점에서 예외가 발생했는지 알기 위해서 printStackTree() 메서드를 사용하는것이 권장된다.

     

     

    • getMessage() : 예외가 발생한 이유만을 반환
    public static void printLength(String data) {
    		try{
    			int result = data.length(); // 문자열의 길이를 반환
    			System.out.println("문자 수 : " + result);
    		} catch(NullPointerException e){
    			System.out.println(e.getMessage());
    		} finally {
    			System.out.println("printLength 메서드 종료");
    		}
    }
    
    >> Cannot invoke "String.length()" because "data" is null

     

     

    • toString() : 예외 클래스와 함께 발생한 이유를 반환
    public static void printLength(String data) {
    		try{
    			int result = data.length(); // 문자열의 길이를 반환
    			System.out.println("문자 수 : " + result);
    		} catch(NullPointerException e){
    			System.out.println(e.toString());
    		} finally {
    			System.out.println("printLength 메서드 종료");
    		}
    	}
    
    >> java.lang.NullPointerException: Cannot invoke "String.length()" because "data" is null

     

     

    • printStackTrace() : 예외가 어디서 발생했는지 추적한 내용까지 출력
    public static void printLength(String data) {
    		try{
    			int result = data.length(); // 문자열의 길이를 반환
    			System.out.println("문자 수 : " + result);
    		} catch(NullPointerException e){
    			e.printStackTrace();
    		} finally {
    			System.out.println("printLength 메서드 종료");
    		}
    }
    >> java.lang.NullPointerException: Cannot invoke "String.length()" because "data" is null
    	at thisIsJava/part02.ch11.sec02.예외처리코드.ExceptionHandlingExample2.printLength(ExceptionHandlingExample2.java:6)
    	at thisIsJava/part02.ch11.sec02.예외처리코드.ExceptionHandlingExample2.main(ExceptionHandlingExample2.java:20)

     

    리소스 자동 닫기

    java에서 리소스를 사용하기 위해서는 open() 메서드로 open 해줘야하며, 사용을 다 하고나서는 close() 메서드로 close 해주어야 한다.(여기서 리소스(resource)는 데이터를 제공하는 객체를 의미한다.) 리소스를 사용하다가 예외가 발생한 경우에도 다음과 같이 안전하게 리소스를 닫는것이 중요하다. (만일 그렇지 않으면 리소스가 불안정한 상태로 남아있게 될 것이다.)

    FileInputStream fis;
    try {
    	fis = new FileInputStream("file.txt"); // file open
    
    	...
    } catch(IOException e) {
    	...
    } finally {
    	fis.close(); // file close
    }

     

    매번 위와 같이 try-catch-finally 로 리소스 사용 코드를 작성할 경우 코드 반복이 늘어나고 금방 지저분해질 것이다. 이와 같은 상황에서 try-with-resources 블록으로 구현하여 try 블록이 정상적으로 실행을 완료하거나 도중에 예외가 발생하면 자동으로 리소스의 close () 메서드를 호출할 수 있다. 

    try(FileInputStream fis = new FileInputStream("file.txt")){
    	...
    } catch(IOException e) {
    	...
    }

     

    복수개의 리소스 객체도 사용할 수 있다. 이때 각 객체를 세미콜론(;)로 연결한다.

    try(
    	FileInputStream fis1 = new FileInputStream("file1.txt");
    	FileInputStream fis2 = new FileInputStream("file2.txt")
    ){
    	...
    } catch(IOException e) {
    	...
    }

     

    java8 까지는 try 블록 내부에 리소스 변수를 반드시 선언해줘야 했지만, java 9부터는 외부 리소스 변수도 try-with-resources 구문으로 사용할 수 있다. 

    FileInputStream fis1 = new FileInputStream("file1.txt");
    FileInputStream fis2 = new FileInputStream("file2.txt");
    
    try(fis1; fis){
    	...
    } catch(IOException e) {
    	...
    }

     

    단, try-with-resources 블록으로 리소스를 사용할 경우 리소스 객체는 반드시 java.lang.AutoCloseable 인터페이스를 구현며, close() 메서드를 오버라이딩해야한다. 만일 리소스 객체가 AutoCloseable 인터페이스를 구현하지 않는다면 다음과 같은 컴파일 에러가 발생한다.

    예외 떠넘기기

    메서드 내부에서 로직이 실행 중에 예외가 발생한 경우 try-catch 블록으로 이러한 예외를 적절하게 처리하는것이 기본이지만, 경우에 따라 메서드를 호출한 곳으로 예외를 전파하는게 적절할 때도 있다. 이 때 메서드를 호출한 곳으로 예외를 떠넘기기(일반적으로 예외를 전파한다고 표현한다) 위해 throws 키워드를 사용한다. throws 키워드는 메서드 선언부 끝에 작성하며, 전파할 예외 클래스를 쉼표(,)로 구분하여 나열한다.

    void method1() {
    	try(
    		method2();
    	) catch(Exception e) {
    		// method2에서 발생한 예외는 이를 호출한 method1에서 처리한다.
    		...
    	}
    }
    
    // method2에서 발생한 예외는 이를 호출한 메서드로 떠넘긴다.
    void method2(args1, args2, ...) throws ExceptionClass1, ExceptionClass2, ... {
    	...
    }

    사용자 정의 예외

    대부분의 예외는 자바 표준 라이브러리에서 제공하지만, 적절한 예외 클래스가 없어 개발자가 직접 예외를 정의하는게 적절한 경우도 있다.

    이때 사용자 정의 예외는 일반 예외(컴파일러가 예외 처리 코드의 존재 여부를 검사하는 예외 클래스)로 선언할 수도 있고 실행 예외(컴파일러가 예외 처리 코드의 존재 여부를 검사하지 않는 예외 클래스)로 선언할 수 도 있다. 둘 중 어떤 예외로 선언할지는 경우에 따라 다르며, 통상적으로 사용자 정의 예외를 일반 예외로 선언할 경우 Exception 클래스의 자식 클래스로 선언하며 실행 예외로 선언할 경우 Exception 클래스의 자식 클래스인 RuntimeException 클래스의 자식 클래스로 선언한다.


    출처

    • 이것이 자바다