[Java] 자바의 예외(1) - 예외란 무엇인가?

sewonK·2022년 9월 25일
1

백기선님 자바 기초 스터디 9주차 과제 질문을 참조하여 포스트를 작성하였습니다.

📖 들어가기 전에

"checked exception과 unchecked exception의 차이는 무엇인가요? 어떤 경우에 사용해야하는지 설명해보세요."

경험했던 면접의 수가 많지는 않지만 면접에서 항상 빠지지 않고 들었던 질문이 바로 예외 처리에 관한 내용이었습니다.

예외 처리를 중요하게 다룬다는 것은 그만큼 프로그램을 잘 운영하는데에 있어 예외 처리가 아주 중요한 역할을 한다는 것이겠지요.

저 질문을 들을 때마다 두 exception의 차이는 어떻게든 설명할 수 있었지만, 어떤 경우에 어떤 exception을 사용해야하는지는 감이 오지 않았습니다. 이번 포스트를 통해서 어떻게하면 예외를 "잘" 다룰 수 있는지에 대해 공부한 내용을 기록해보려 합니다.

0. Exception에 대해 알아보자

프로그램이 실행되는 동안 어떤 원인에 의해 오작동을 하거나 비정상적으로 종료되는 경우, 이를 에러 또는 오류라고 부릅니다. 이 에러는 발생 시점에 따라 컴파일 에러와 런타임 에러로 분류될 수 있습니다.

소스코드를 컴파일 할 때 컴파일러에 의해 검사된 오류를 컴파일 에러라 부릅니다. 그러나 이 에러들을 모두 수정해서 컴파일에 성공했다 하더라도 프로그램 실행 중에도 에러가 발생할 수 있습니다. 이를 실행 중(Runtime)에 발생한 오류라 하여 런타임 에러라고 부릅니다.

자바는 실행 시 발생할 수 있는 오류를 에러예외 두 가지로 구분했습니다. 에러는 메모리 부족이나 스택오버플로우와 같이 발생하면 복구가 불가능한 심각한 오류를, 예외는 발생하더라도 수습할 수 있는 오류를 의미합니다. 따라서 에러가 발생하면 프로그램의 비정상적인 종료를 막을 수 없지만, 예외가 발생하면 프로그래머가 이에 대한 적절한 코드를 미리 작성함으로써 비정상적인 종료를 막을 수 있게 되는 것입니다.

자바는 예외를 또 RuntimeException 클래스와 그것이 아닌 것으로 나누었습니다. 예외 클래스의 계층도를 보면 모든 클래스의 조상인 Object의 자손으로 Throwable을, Throwable의 자손으로 Exception과 Error를, Exception의 자손으로 RuntimeException과 그 외 Exception 클래스가 있음을 볼 수 있습니다.

RuntimeException클래스와 그 자손 클래스들은 주로 프로그래머의 실수에 의해 발생될 수 있는 예외들이 주를 이루고 있습니다. 예를 들면 값이 null인 참조변수의 멤버를 호출하려 했다던가(NullPointerException), 수를 0으로 나누려고(ArithmeticException)하는 경우입니다.

Exception클래스와 그 자손 클래스들(RuntimeException 제외)은 주로 외부의 영향으로 발생할 수 있는 것들이 많습니다. 예를 들면, 사용자에 의해 존재하지 않는 파일의 이름을 입력받았다던가(IOException 중 FileNotFoundException), SQL 서버에서 반환하는 경고 또는 오류인 SQLException 등이 있습니다.

RuntimeException클래스와 그 자손에 해당하는 예외는 프로그래머의 실수로 발생하는 것들이기 때문에, 예외처리를 강제하지 않습니다. 만약 이런 클래스들에도 예외 처리가 필수라면 참조변수의 멤버를 호출하는 모든 코드나 나누는 연산을 하는 모든 코드에서 예외 처리를 해주어야 할 것입니다(끔찍). 피할 수 있지만 개발자가 부주의하여 프로그램의 오류가 발생하는 경우이며 예상하지 못했던 예외상황에서 발생하는게 아니기 때문에 굳이 catch나 throws를 사용하지 않아도 되도록 만든 것입니다.

이렇게 컴파일러가 예외처리를 확인하지 않는 RuntimeException클래스들은 Unchecked 예외라고 부르고, 예외처리를 확인하는 나머지 Exception클래스와 그 자손 클래스들은 checked 예외라고 부릅니다.

컴파일러가 예외처리를 확인한다는 것은, 예외에 대비한 코드를 작성했는지 여부를 확인한다는 것입니다. 다음은 예외를 처리하는 방법에 대해 알아보겠습니다.

1. 예외처리 방법

Checked 예외가 발생할 수 있는 메소드를 사용할 경우, 반드시 예외를 처리하는 코드를 함께 작성해야 합니다. catch문으로 잡든지, 아니면 다시 throws를 정의해서 메소드 밖으로 던져야 합니다. 그렇지 않으면 컴파일 에러가 발생합니다. Unchecked 예외는 에러와 마찬가지로 catch 문으로 잡거나 throws로 선언하지 않아도 됩니다. 물론 명시적으로 잡거나 throws를 선언해도 상관없습니다.

이번 파트에서는 catch문으로 예외를 잡는 방법과 throws로 예외를 선언하는 등 코드로서 예외를 처리하는 방법을 알아보겠습니다.

(1) try-catch-finally, try-with-resources

🔎 try

try 블럭은 예외가 발생할 수 있는 코드를 감싸는 블럭입니다.

try {
	// statement(s) that might cause exception
}

🔎 catch

catch 블럭은 try 블럭에서 발생하는 예상치 못한 상황을 처리하는 블럭입니다. catch블럭은 항상 try 블럭과 함께 try 블럭에서 발생한 예외를 처리하는데 사용됩니다.

catch {
	// statement(s) that handle an exception
    // examples, closing a connection, closing
    // file, exiting the process after writing
    // details to a log file
}

🔎 finally

catch 블럭 이후에 실행되는 블럭입니다. 여러 개의 catch 블럭이 있을 때 예외 발생 여부와 관계없이 실행되는 공통적인 코드를 넣고싶을 때 사용합니다.

🔎 try-catch-finally

예를 들어보겠습니다.

  class ExceptionPractice {
      public static void main(String[] args)
      {
          int a=10, b=5, c=5, result;
          try {
              result = a / (b - c) ; // 1
              System.out.println("result" + result);
          }
          catch (ArithmeticException e) {
            System.out.println("Exception caught:Division by zero");
          }
          finally {
            System.out.println("I am in final block");
          }
      }
  }

이 클래스가 실행되면 우선 try 블럭에서 a / (b-c) 를 계산하여 result에 대입하려 합니다(1번 주석). 그러나 b-c는 0이기 때문에, 0으로 나누었을 때 발생하는 RuntimeException인 ArithmeticException이 발생하게 됩니다.

예외가 발생하면 result 값을 출력하는 코드가 실행되지 않고 바로 catch 블럭으로 넘어가게 됩니다. catch 블럭에서는 ArithmeticException을 잡고 있기 때문에, 0으로 나누었다는 예외 메세지를 출력하게 됩니다.

catch 블럭이 종료된 후, 마지막으로 finally 블럭이 실행되고 마지막에는 I am in final block이라는 문구가 출력되게 됩니다.

만약 catch 블럭에서 예외를 잡지 못하는 경우에는 어떨까요?

public class ExceptionPractice {
  public static void main(String[] args) {
    int a=10, b=5, c=5, result;
    try {
      result = a / (b - c) ;
      System.out.println("result" + result);
    }
//    catch (ArithmeticException e) {
//      System.out.println("Exception caught:Division by zero");
//    }
    catch (NullPointerException e) {
      System.out.println("Exception caught:another exception");
    }
    finally {
      System.out.println("I am in final block");
    }
  }
}

catch 블럭에서 ArithmeticException을 잡지 못하고 있는 상황입니다. 이러한 경우에는 예외가 발생한 것을 잡지 못하고 finally 블럭이 실행된 이후 예외가 발생한 뒤 종료되게 될 것입니다.

🔎 try-with-resources

try-with-resources 구문은 Java 7 버전 이후에 추가된 구문으로, 하나 이상의 자원을 선언하는 try 구문입니다. 여기서 자원이란, 프로그램이 한번이라도 그 자원을 사용하고 나면 반드시 close(=해제)되어야하는 객체를 의미합니다. 예를 들면 파일 리소스나 소켓 커넥션은 반드시 사용 후 해제되어야 합니다.

try-with-resources 구문은 각 자원이 코드 실행 끝에 해제되도록 보장해줍니다. 만약 자원이 해제되지 않는다면, 자원의 leak이 발생되어 프로그램이 이용할 수 있는 자원이 부족해지게 될 것입니다. 실제로 까먹고 자원 해제시켜주지 않는 경우가 있을 수 있으니, 자원 사용 시 사용하면 좋을 구문인 것 같습니다.

java.lang.AutoCloseable(java.io.Closeable을 상속하는 모든 객체들을 포함)을 상속하는 자원이라면 해당 구문으로 자원을 자동해제 시킬 수 있습니다.

구문은 다음과 같이 사용할 수 있습니다.

try(declare resource here) {
	//use resources
}
catch(exception e){
	//exception handling
}

다음은 하나의 자원을 사용하는 예시입니다.

import java.io.FileOutputStream;

public class ExceptionPractice {
  public static void main(String[] args) {
    // Try block to check for exceptions
    try (
            // Creating an object of FileOutputStream
            // to write stream or raw data

            // Adding resource
            FileOutputStream fos
                    = new FileOutputStream("textfile.txt")) {

      // Custom string input
      String text
              = "Hello World. This is my java program";

      // Converting string to bytes
      byte arr[] = text.getBytes();

      // Text written in the file
      fos.write(arr);
    }

    // Catch block to handle exceptions
    catch (Exception e) {

      // Display message for the occurred exception
      System.out.println(e);
    }

    // Display message for successful execution of
    // program
    System.out.println(
            "Resource are closed and message has been written into the textfile.txt");
  }
}

기존에는 finally 블럭 내부에 java.io.FileOutputStream.close() 메서드를 사용하여 자원을 해제시켜주어야 했습니다.

그러나 try with resources 구문으로 해제 메서드나 finally 블럭 없이 자원의 해제가 가능해지게 되었습니다.

여러 자원을 사용하는 예시를 보겠습니다.

(2) throw

throw 키워드는 제어권을 try 블럭에서 catch 블럭으로 전송하는데 사용됩니다. 제어권을 catch 블럭으로 넘긴다는 뜻은, try 블럭에서 일부러 예외를 발생시켜 catch블럭에서 상황을 처리하도록 넘기는 것입니다.

public class ExceptionPractice {
  public static void main(String[] args) {
    try {
      throw new NullPointerException();
    }
    catch (NullPointerException e) {
      System.out.println("Exception caught:another exception");
    }
    finally {
      System.out.println("I am in final block");
    }
  }
}

이 상황에서는 try 블럭 안에서 throw 키워드로 NullPointerException이라는 예외를 발생시켰습니다. 그러면 예외를 잡은 catch 블럭이 에러 메세지를 출력하고, finally 블럭 내부의 출력 코드가 실행될 것입니다.

(3) throws

throws 키워드는 try-catch 블럭 없이 예외를 처리할 때 사용됩니다. 메서드가 호출자에게 보낼 수 있는 예외를 지정하고 메서드에서는 자체적으로 처리하지 않는 방법입니다.

예외가 발생한 메서드가 아닌, 메서드를 호출하는 호출자가 예외를 처리하도록 하는 방법입니다.

예를 들어보겠습니다.

public class AccountPractice {
    private long balance;
    public long getBalance(){
        return balance;
    }
    public void deposit(long money){
        balance += money;
    }
    public void withdraw(long money) throws Exception{
        if(balance < money) {
            throw new Exception("돈 부족 에러");
        }
        balance -= money;
    }

    public static void main(String[] args) {
        AccountPractice a = new AccountPractice();
        a.balance = 0;
        a.deposit(10000);
        a.withdraw(20000);
        System.out.println(a.getBalance());
    }
}

deposit으로 돈을 입금하고, withdraw로 돈을 출금하는 메서드입니다. 만약 가지고있는 돈(balance)이 출금할 money보다 작다면 checked 예외를 발생시키는 예시입니다.

만약 withdraw의 메서드가 아래와 같았다면 어떨까요?

...
    public void withdraw(long money) {
        if(balance < money) {
            throw new Exception("돈 부족 에러");
        }
        balance -= money;
    }
...

Exception을 발생시키지만 예외처리를 해주지 않아 Unhandled exception이라는 에러를 발생시킬 겁니다.

이런 경우, withdraw 메서드 내부에서 예외를 처리해줄 방법이 없습니다. 예외를 잡더라도 내부 코드에서 해결할 수 없는 것이지요(돈이 없는데 인출할 수 있는 방법이 없으니..).

따라서 이럴 때에는 인출하는 메서드를 호출하는 단에서 처리할 수 있도록 throws를 사용합니다. 그러나 무분별한 throws는 코드의 가독성을 떨어뜨리고, 어떤 부분에서 오류가 발생했는지 찾기가 어려워 주의하는 것이 필요합니다.

(4) 사용자 정의 예외

자바에서 제공하는 예외 클래스 만으로는 구체적인 예외를 특정할 수 없습니다. 예를 들어 바로 위의 예시에서 인출할 때 돈이 부족할 경우, 자바가 제공하는 기본 예외 클래스로는 예외 자체만으로 "돈이 부족한" 상황을 설명하기 어렵습니다.

이러한 경우 사용자가 직접 예외 클래스를 생성할 수 있습니다.

사용자 정의 예외도 Runtime Exception을 상속받고 있느냐에 따라 checked exception(Runtime Exception 이외 상속)과 unchecked exception(Runtime Exception 상속)으로 생성할 수 있습니다.

예외 클래스만으로 돈이 부족한 상황을 설명하기 위해, 직접 사용자 정의 예외 클래스를 생성해보겠습니다.

아래와 같이 RuntimeException을 상속받는 예외를 생성한 뒤,

public class BalanceInsufficientException extends RuntimeException {
    public BalanceInsufficientException() {};
    public BalanceInsufficientException(String message){
        super(message);
    }
}

이렇게 직접 만든 Exception을 발생시키면, 예외 클래스의 명칭만 보고 어떤 예외인지 쉽게 파악할 수 있습니다.

...
	public void withdraw(long money) throws BalanceInsufficientException{
        if(balance < money) {
            throw new BalanceInsufficientException("돈 부족 에러");
        }
        balance -= money;
    }
...

(2편에서 계속)

📄 참고

  1. Try, catch, throw and throws in Java - GeeksForGeeks
  2. 자바의 정석 제 3판
  3. 토비의 스프링

0개의 댓글