[Java] 16. 예외처리(try~catch~finally, throw)를 하는 방법


Study/Java  2020. 5. 14. 20:34

안녕하세요. 명월입니다.


이 글은 Java의 예외처리(try~catch~finally, throw)를 하는 방법에 대한 글입니다.


프로그램을 작성하다 보면, 예기치 못한 예외를 만날 수 있습니다. 이 예외라는 것은 꼭 프로그램을 잘못 구현해서 발생하는 버그도 있지만, 사용하는 유저로 부터 잘못된 데이터를 받거나 기존에 있던 데이터가 null인 경우 발생할 수 있습니다.

프로그램 하다 보면 가장 자주 만나는 예외가 null exception입니다.

public class Example {
  // 함수
  public void run() {
    // 콘솔 출력
    System.out.println("run!!");
  }
  // 실행 함수
  public static void main(String... args) {
    // 클래스 ex를 선언하고, 인스턴스 생성은 하지 않았다. 
    Example ex = null;
    // Example 클래스의 run 함수 호출
    ex.run();
    // 콘솔 출력
    System.out.println("hello world");
  }
}

위 예제를 보면 ex 변수는 인스턴스를 생성해서 할당을 하지 않았기 때문에 ex.run()을 실행하면 heap 메모리에 생성되지 않는 함수를 찾기 때문에 에러가 발생합니다.

여기서 에러가 발생하면 프로그램이 에러를 무시하고 지나가는 것이 아니라 그대로 멈춰버립니다. 보시면 ex.run에서 에러가 발생하고 다음에 오는 hello world의 콘솔 출력이 진행이 되지 않았습니다.


여기서 우리가 할 수 있는 방법은 예외 처리를 하는 것인데, 어디에서 에러가 날 가능성이 있다하는 부분을 try 키워드를 사용해서 스택 영역을 잡고 예외가 발생하면 catch로 점프할 수 있게 설정하면 됩니다.

public class Example {
  // 함수
  public void run() {
    // 콘솔 출력
    System.out.println("run!!");
  }
  // 실행 함수
  public static void main(String... args) {
    // 예외 처리
    // try 스택 영역에서 에러가 발생하면 catch로 점프합니다.
    try {
      // 클래스 ex를 선언하고, 인스턴스 생성은 하지 않았다. 
      Example ex = null;
      // Example 클래스의 run 함수 호출
      ex.run();
      // 콘솔 출력
      System.out.println("not error");
    } catch (Throwable e) {
      // 콘솔 에러 출력 출력
      System.out.println(e);
    }
    // 콘솔 출력
    System.out.println("hello world");
  }
}

위 예제를 보면 ex.run에서 에러가 발생했기 때문에 catch의 에러 내용이 콘솔 출력되었고 다음의 hello world가 출력되었습니다. not error라는 메시지는 같은 try 영역에 포함되어 있기 때문에 에러를 건너뛰었습니다.


여기서 제가 catch안에 Throwable이라는 인터페이스를 사용했습니다. catch에는 조건식에 에러 클래스나 인터페이스가 있어야 하는데 Throwable 인터페이스는 모든 에러 클래스의 가장 최상위 인터페이스입니다.

이 가장 최상위 인터페이스를 설정하면 모든 에러가 catch (Throwable)에 잡히게 됩니다.


다시 쉽게 예제로 설명하겠습니다.

public class Example {
  // 함수
  public void run() {
    // 콘솔 출력
    System.out.println("run!!");
  }
  // 실행 함수
  public static void main(String... args) {
    try {
      // 클래스 ex를 선언하고, 인스턴스 생성은 하지 않았다. 
      Example ex = null;
      // Example 클래스의 run 함수 호출
      ex.run();
      // 콘솔 출력
      System.out.println("not error");
    // NullPointerException만 잡는다.
    } catch(NullPointerException e) {
      // 에러 콘솔 출력
      System.out.println("null exception");
    // 모든 에러를 잡는다.
    } catch (Throwable e) {
      // 에러 콘솔 출력
      System.out.println(e);
    }
    // 콘솔 출력
    System.out.println("hello world");
  }
}

위 예제는 catch가 두 개가 있습니다. ex.run()에서 NullPointerException가 발생(참고 - 가장 처음 예제를 보면 결과에 NullPointerException가 발생했다고 빨간색으로 표시됩니다.)했기 때문에 catch(NullPointerException)의 영역으로 실행이 되는 것입니다.

그럼 만약 여기서 NullPointerException이 아닌 다른 에러가 발생하면, catch (Throwable)로 가게 되는 것입니다.

public class Example {
  // 함수
  public void run() {
    // 콘솔 출력
    System.out.println("run!!");
  }
  // 실행 함수
  public static void main(String... args) {
    try {
      // 클래스 ex를 선언하고, 인스턴스 생성은 하지 않았다.
      Example ex = null;
      // 10을 0으로 나누어서 에러를 발생시킨다.
      int a = 10 / 0;
      // 콘솔 출력
      System.out.println("not error");
    // NullPointerException만 잡는다.
    } catch(NullPointerException e) {
      // 에러 콘솔 출력
      System.out.println("null exception");
    // 모든 에러를 잡는다.
    } catch (Throwable e) {
      // 에러 콘솔 출력
      System.out.println(e);
    }
    // 콘솔 출력
    System.out.println("hello world");
  }
}

위 예제를 보면 10/0으로 나누면 ArithmeticException에러가 발생한다고 catch (Throwable) 영역으로 넘어갑니다.

이렇게 나누어져 있는 이유는 사양에 따라 에러마다 처리를 달리 할 수 있기 때문입니다. 여기서 주의해야 할 점은 catch의 순서입니다.

만약 위 null 예외가 발생할 때, catch (Throwable e)영역이 catch(NullPointerException e)영역 보다 위에 있으면 그냥 catch (Throwable e)로 빠지게 됩니다.

애초에 컴파일 에러가 발생합니다.


이번에는 함수에서 사용해 보겠습니다.

public class Example {
  // 멤버 변수
  private int data;
  // 생성자
  public Example(int data) {
    // 멤버 변수 설정
    this.data = data;
  }
  // 계산 함수
  public int calc() {
    try {
      // 100에서 data의 값으로 나눈다.
      return 100 / this.data;
    } catch (ArithmeticException e) {
      // 에러가 발생하면 0을 리턴
      return 0;
    }
  }
  // 실행 함수
  public static void main(String... args) {
    // 인스턴스 생성
    Example ex = new Example(0);
    // 결과 콘솔 출력
    System.out.println("result - " + ex.calc());
    // 결과 콘솔 출력
    System.out.println("result - " + ex.calc());
  }
}

이번에는 calc함수에서 멤버 변수 data가 0이 되니 당연히 ArithmeticException 에러가 발생해서 결과를 0을 리턴합니다.


그런데 여기서 저는 try~catch와 관계 없이 try영역이 끝나면 무조건 실행하고 싶은 구문이 있을 수 있습니다. 예를 들면 this.data를 1로 바꾼다던가 입니다.

그러면 return하기 전에 try나 catch앞에 this.data=1를 추가하면 됩니다만, 조금 더 품격있게.. finally를 사용할 수 있습니다.

public class Example {
  // 멤버 변수
  private int data;
  // 생성자
  public Example(int data) {
    // 멤버 변수 설정
    this.data = data;
  }
  // 계산 함수
  public int calc() {
    try {
      // 100에서 data의 값으로 나눈다.
      return 100 / this.data;
    } catch (ArithmeticException e) {
      // 에러가 발생하면 0을 리턴
      return 0;
    } finally {
      this.data = 1;
    }
  }
  // 실행 함수
  public static void main(String... args) {
    // 인스턴스 생성
    Example ex = new Example(0);
    // 결과 콘솔 출력
    System.out.println("result - " + ex.calc());
    // 결과 콘솔 출력
    System.out.println("result - " + ex.calc());
  }
}

이미 return을 했지만 finally가 있으면 함수가 끝나기 전에 finally 구문을 실행하게 됩니다.

이 finally는 IO나 소켓 등의 외부 리소스를 사용할 때, 리소스 반환식으로 자주 사용하게 됩니다. 그런데 finally는 굳이 catch가 없어도 사용 가능합니다. 즉, try~finally만으로도 작성 가능합니다.

그럼 여기서 이제 제가 에러를 임의로 만들어 보고 싶습니다. 에러를 임의로 만든다는 것은 다른 객체에서 참조를 할 때 에러를 일부러 발생시켜서 클래스의 신뢰성을 높이는 것입니다.

그럼 이 Exception 클래스를 만드는 것은 Throwable 인터페이스를 상속받아서 만들면 될 것 같은데 Java에서는 Throwable를 상속 받아서 만들 수는 없고 클래스를 상속 받아야 합니다.

이 때, 비슷한 예외 클래스를 상속받아도 좋은데 보통은 Exception 클래스나 RuntimeException클래스를 상속 받습니다.

이 Exception 클래스와 RuntimeException 클래스는 Throwable의 다음 계층의 에러 클래스로 클래스로는 가장 최상위 에러 클래스입니다.

Exception 클래스와 RuntimeException 클래스의 차이는 아래에서 자세히 설명하고 일단 에러 클래스 만들겠습니다.

// TestException을 생성했다.
class TestException extends RuntimeException {
  // 사실 생성자나 함수 재정의를 통해서 에러 특성을 더 만들 수도 있다.
}
public class Example {
  // 함수
  public static void test() {
    // throw를 통해 에러 발생
    throw new TestException();
  }
  // 실행 함수
  public static void main(String... args) {
    // test 함수 호출
    test();
  }
}

test 함수를 호출하면 안에 throw를 통해서 에러를 발생시키는데 콘솔을 보니 어디에 어떤 에러 클래스로 발생했는지 표시가 됩니다.


여기서 이 throw를 사용할 때, 명시적 에러 처리와 묵시적 에러 처리가 있습니다.

명시적 에러 처리는 실행을 하기 전에 컴파일 단계에서 try~catch를 선언하지 않으면 컴파일이 되지 않게 에러를 발생하는 것입니다.

public class Example {
  // 함수
  public static void test() {
    // Exception 발생!!
    throw new Exception();
  }
  // 실행 함수
  public static void main(String... args) {
    test();
  }
}

throw new Exception을 넣었지만 이클립스에서 에러를 제어하지 않았다고 에러를 발생시킵니다. 즉, 컴파일조차 되지 않습니다.

public class Example {
  // 함수 옆 throws 키워드를 통해서 에러가 발생할 수 있다고 알려 줌
  public static void test() throws Exception {
    // Exception 발생!!
    throw new Exception();
  }
  // 실행 함수
  public static void main(String... args) {
    test();
  }
}

그래서 test함수 옆에 throws 키워드를 사용해서 이 함수는 에러가 발생할 수 있다고 명시해 줍니다.

이번에는 test()함수 호출하는 부분에서 에러를 제어하지 않았다고 에러가 발생합니다.

public class Example {
  // test 함수
  public static void test() throws Exception{
    // Exception 발생!!
    throw new Exception();
  }
  // 실행 함수
  public static void main(String... args) {
    // Exception의 예외 처리를 했다.
    try {
      test();
    } catch (Exception e) {
      // 에러 내용 콘솔 출력
      // printStackTrace 함수를 사용하면 call stack까지 전부 표시해 준다.(가장 자주 사용하는 에러 표시 방법이다.)
      e.printStackTrace();
    }
  }
}

이번에는 실행이 됩니다. 여기서 묵시적 에러 처리는 throws를 사용하지 않는 것인데 RuntimeException 클래스를 사용합니다.

public class Example {
  // 에러를 발생시키지만 컴파일하는 데 전혀 문제가 없다.
  public static void test(){
    throw new RuntimeException();
  }
  // 실행 함수
  public static void main(String... args) {
    test();
  }
}

물론 묵시적 에러 처리라고 try~catch에 안 잡히는 건 아닙니다. Throwable로 catch를 만들면 다 잡힙니다.


여기서 Exception 클래스와 RuntimeException 클래스의 차이는 Exception를 상속받아서 에러 클래스를 만들면 전부 명시적 에러 처리를 해야 하고, RuntimeException를 상속 받으면 전부 묵시적 에러 처리가 됩니다.

참고로 여기서 에러 클래스의 계층은 Throwable - Exception - RuntimeException 순입니다. 즉, try~catch에서 Exception를 RuntimeException보다 위에 작성하면 전부 Exception 영역으로 잡니다.


여기까지 Java의 예외처리(try~catch~finally, throw)를 하는 방법에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.