예외처리(exception handling)

김운채·2023년 5월 8일
0

TIL

목록 보기
2/22

예외처리 exception handling

1-1. 프로그램 오류

프로그램이 실행 중 어떤 원인에 의해 오작동하거나 비정상적으로 종료되는 경우가 있다. 이러한 결과를 초래하는 원인을 프로그램 에러 또는 오류라고 한다.

발생시점에 따라 컴파일에러런타임에러로 나뉘는데,

✔ 컴파일 할때 발생하는 에러를 컴파일 에러라고 하고,
✔ 프로그램 실행도중에 발생하는 에러를 런타임에러라고 한다.
✔ 이외에도 컴파일도 잘되고 실행도 잘되지만 의도한 것과 다르게 동작하는 것을 논리적 에러 라고 한다.

컴파일러가 소스코드의 기본적인 사항은 컴파일 시에 모두 걸러줄 수 는 있지만, 실행도중에 발생할 수 있는 잠재적 오류까지 검사할 수 없기 때문에 런타임오류를 방지하는 것이 필요하다.

자바에서는 런타임 오류를 에러와 예외로 구분하였다.

에러: 발생하면 복구할 수 없는 심각한 오류
예외: 발생하더라도 수습될 수 있는 다소 미약한 오류

1-2. 예외 클래스의 계층구조

자바에서는 이런 에러와 예외를 클래스로 정의하였다. 모든 클래스의 조상은 Object 클래스 이므로 Exception과 Error 클래스 역시 Object클래스의 자손들이다.

모든예외의 최고 조상은 Exception 클래스이며, 상속계층도는 다음과 같다.

RuntimeException 클래스들은 주소 프로그래머의 실수에 의해서 발생될 수 있는 예외들로 자바의 프로그래밍 요소들과 관계가 있다.

ex) 배열의 범위를 벗어난다거나(ArrayIndexOutOfBoundsException),
값이 null인 참조변수의 멤버를 호출했다던가(NullPointerException),
클래스간 형변환을 잘못했다던가(ClassCastException),
정수를 0으로 나누려고(ArithmeticException)하는 경우에 발생한다.

Exception 클래스들은 주소 외부의 영향으로 발생할 수 있는 것들로서, 프로그램의 사용자들의 동작에 의해서 발생하는 경우가 많다.

ex) 존재하지 않는 파일의 이름을 입력했다던가(FileNotFoundException),
실수로 클래스의 이름을 잘못적었다던가(ClassNotFoundException)
입력한 데이터 형식이 잘못된(DataFormatException) 경우에 발생한다.

RuntimeException 클래스들 : 프로그래머의 실수로 발생
Exception 클래스들: 사용자의 실수와 같은 외적인 요인으로 발생


1-3. 예외처리하기: try-catch 문

발생한 예외를 처리하지 못하면, 프로그램은 비정상적으로 종료되며, 처리되지 못한 예외는 JVM의 '예외처리기(UncaughtExceptionHandler)'가 받아서 예외의 원인을 화면에 출력한다.

예외를 처리하기 위해서는 try-catch문을 작성한다.

try{
	//예외가 발생될만한 코드
}catch(FileNotFoundException e){	//FileNotFoundException이 발생했다면
	//이를 처리하기 위한 문장
}catch(IOException e){ //IOException이 발생했다면
	//이를 처리하기 위한 문장
}catch(Exception e){	//Exception이 발생했다면
	//이를 처리하기 위한 문장
}

만약 catch블럭내에 또 하나의 try-catch문이 포함된 경우, 같은 이름의 참조변수를 사용해선 안된다. 각catch블럭에 선언된 두 참조변수의 영역이 서로 겹치므로 다른 이름을 사용해야만 구별되기 때문이다.

try{
}catch(Exception e){
	try{
    }catch(Exception e2){
    }
}

try-catch 문의 흐름

✅ try 블럭 내에서 예외가 발생한 경우,

  1. 발생한 예외와 일치하는 catch 블럭이 있는지 확인
  2. 일치하는 catch블럭을 찾게 되면 그 블럭내의 문장들을 수행하고 전체 try-catch문을 빠져나가서 다음 문장을 수행한다.
  3. 만약 일치하는 catch블럭을 찾지 못하면 예외는 처리되지 못함

✅ try 블럭 내에서 예외가 발생하지 않은 경우

  1. catch 블럭을 거치지 않고 전체 try-catch문을 빠져나가서 수행을 계속함.

printStackTrace() 와 getMessage()

예외가 발생했을 때 생성되는 예외 클래스의 인스턴스에는 발생한 예외에 대한 정보가 담겨있다. printStackTrace() 와 getMessage()를 통해서 정보를 얻을 수 있다.
catch블럭 안에서만 사용이 가능하다.

public static void main(String[] args) {
    try{
        System.out.println("start");
        String str = "hello";
        int num = Integer.parseInt(str);

        System.out.println("num = " + num);
    }catch(NumberFormatException error){
        error.printStackTrace();
        System.out.println("Invalid str: "+error.getMessage());
    }

    System.out.println("end");
}

  • getMessage 메서드를 이용해서 해당 로직의 예외 발생원인을 출력했다.
  • printStackTrace() 메서드를 사용하니 프로그램은 예외처리를 통해 정상적으로 종료되었지만 에러 발생당시의 호출스택(call stack)에 대한 정보와 예외 메세지를 출력했다.

멀티 catch 블럭

JDK 1.7부터 여러 catch 블럭을 | 기호를 이용해 합칠 수 있다. 이를 통해 중복된 코드를 줄일 수 있다. 합칠 수 있는 예외클래스의 개수엔 제한이 없다.

try{
 ...
}catch(NumberFormatException |ArithmeticException error){
	error.printStackTrace();
    System.out.println("Invalid str: "+error.getMessage());
}

❗ 멀티 catch 블럭을 사용할 때 두 예외 클래스가 조상-관계라면 컴파일시 에러가 발생한다. 그 이유는 그냥 조상 클래스만 써주는것과 동일하기 때문이다. 필요한 코드는 제거하라는 의미인 것이다.

❗ 멀티 catch 블럭에서 발생하는 예외는 여러 예외를 처리하기에 발생한 예외가 실제로 어떤 예외인지 알기가 쉽지 않다.

1-4. 예외 발생시키기

throw 를 사용해서 개발자가 의도적으로 예외를 발생시킬 수 있다.

사용법

  1. 발생시키고자 하는 예외를 생성한다.

    ⇒ Exception e = new Exception("고의 발생 예외");
  2. throw 키워드를 이용해 예외를 발생시킨다.

    ⇒ throw e;

    아니면 바로 new 키워드로 예외 객체를 생성해도 된다.

    ⇒ throw new Exception("고의 발생 예외");


1-5. 메서드에 예외 선언하기

try-catch문을 사용하는 것 이외에, 예외를 메서드에 선언하는 방법이 있다.
메서드의 선언부에 throws를 사용해서 선언할 수 있다.

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

이렇게 예외를 선언하면, 메서드레벨에서 선언한 예외와 그 자손타입까지 발생할 수 있다는 걸 의미한다.
👉 throws RuntimeException 을 선언할 경우 RuntimeException을 상속하는 하위 예외객체들 모두가 발생 할 가능성이 있다.

자바에서는 메서드를 작성할 때 메서드 내에서 발생할 가능성이 있는 예외를 메서드의 선언부에 명시하여 이 메서드를 사용하는 쪽에서는 이에 대한 처리를 하도록 강요하기 때문에, 개발자들의 짐을 덜어주는 것은 물론, 보다 견고한 프로그램 코드를 작성할 수 있게 도와준다.

사실 throws 예외클래스명 을 기재해주면, 만일 해당 메서드 안에서 예외가 발생할 경우, try - catch 문이 없으면 해당 메서드를 호출한 상위 스택 메서드로 가서 예외 처리를 하게 된다.


출처

즉, 예외클래스를 메서드의 throws에 명시하는 것은 이곳에서 예외를 처리하는 것이 아니라 자신을 호출한 메서드에게 예외를 전달하여 예외 처리를 떠맡기는 것이다.

또한, 예외를 전달받은 메서드가 또다시 자신을 호출한 메서드에게 전달할 수 있다. 이런 식으로 계속 호출 스택에 있는 메서드들을 따라 전달되다가, 제일 마지막에 있는 main 메소드에서 throws를 사용하면 가상머신에서 처리된다.

1-5. Finally

finally 블럭은 예외 발생 여부와 상관없이 무조건 수행되어야 할 로직이 있을 경우 사용하는 블럭이다.

예외가 발생하지 않는다면 try -> finally 순으로 블럭이 수행된다.
예외가 발생한다면 try -> catch -> finally 순으로 실행된다.

try {
	//예외발생 가능성이 있는 로직
} catch(Exception err) {
	//예외 처리 로직
} finally {
	//공통 로직
}

1-6. try-with-resource

JDK 1.7부터 추가된 기능으로 try-catch문의 변형이다. 입출력, 소켓, 커넥션풀 등 연결/종료가 무조건 한 쌍이 되어야 하는 로직의 경우 유용하게 사용된다.

try-with-resources를 살펴보기 전에 try-catch-finally로 자원을 반납하는 경우를 먼저 살펴보도록 하자.
두 가지를 모두 보고 비교함으로써 왜 try-with-resources를 사용해야 하는지 더욱 납득할 수 있을 것이다.

✅ Java7 이전의 try-catch-finally

사용 후에 반납해주어야 하는 자원들은 Closable 인터페이스를 구현하고 있으며, 사용 후에 close 메소드를 호출해주어야 했다.

Java7 이전에는 close를 호출하기 위해서 try-catch-finally를 이용해서 Null 검사와 함께 직접 호출해야 했는데, 대표적으로 파일의 내용을 읽는 경우를 다음과 같이 구현할 수 있다.

public static void main(String args[]) throws IOException {
    FileInputStream is = null;
    BufferedInputStream bis = null;
    try {
        is = new FileInputStream("file.txt");
        bis = new BufferedInputStream(is);
        int data = -1;
        while((data = bis.read()) != -1){
            System.out.print((char) data);
        }
    } finally {
        // close resources
        if (is != null) is.close();
        if (bis != null) bis.close();
    }
}

문제는 이러한 과정이 여러가지 단점을 가지고 있다는 것이다.

  • 자원 반납에 의해 코드가 복잡해짐
  • 작업이 번거로움
  • 실수로 자원을 반납하지 못하는 경우 발생
  • 에러로 자원을 반납하지 못하는 경우 발생
  • 에러 스택 트레이스가 누락되어 디버깅이 어려움

그래서 이러한 문제를 해결하기 위해 try-with-resources라는 문법이 Java7부터 추가되었다.

✅ Java7 부터의 try-with-resources

Java는 이러한 문제점을 해결하고자 Java7부터 자원을 자동으로 반납해주는 try-with-resources 문법을 추가하였다. try에 자원 객체를 전달하면, try 코드 블록이 끝나면 자동으로 자원을 종료해주는 기능인 것이다.
=> 즉, 따로 finally 블록이나 모든 catch 블록에 종료 처리를 하지 않아도 된다.

Java는 AutoCloseable 인터페이스를 구현하고 있는 자원에 대해 try-with-resources를 적용 가능하도록 하였고, 이를 사용함으로써 코드가 유연해지고, 누락되는 에러없이 모든 에러를 잡을 수 있게 되었다.

AutoCloseable은 JDK1.7부터 추가된 인터페이스다. ( java.lang.AutoCloseable 인터페이스)
Scanner 클래스도 AutoCloseable 인터페이스가 구현되어 있다.

public static void main(String args[]) throws IOException {
    try (FileInputStream is = new FileInputStream("file.txt"); BufferedInputStream bis = new BufferedInputStream(is)) {
        int data;
        while ((data = bis.read()) != -1) {
            System.out.print((char) data);
        }
    }
}

FileInputStream 에서는 Closeable 를 구현하고 있다. Closeable 의 경우에는 try-with-resource 를 사용할 수 있게 해주는 AutoCloseable 를 상속받아서 사용하고 있다.

👉 위 예제 처럼 try 키워드 괄호()내에 객체 생성 코드를 작성하면 해당 객체는 close()를 명시하지 않아도 try 블럭을 벗어나는 순간 자동으로 close()가 호출된다. 개발자가 명시적으로 닫아주지 않아도 되기에 코드축소가 가능하다.

❓ Hoxy..... ❓ close() 로직에서 예외가 발생한다면?

👉 우선, 두 예외가 동시에 발생할수는 없다는 점을 기억해야 한다.
그렇기에 실제로 발생하는 (try 블럭 내부 로직 예외)예외는 정상적으로 출력되고 close()에서 발생하는 예외는 억제된(suppressed) 예외로 실제 발생하는 예외 내부에 포함되어 노출된다.

1-7. 사용자 정의 예외 만들기

기존의 정의된 예외 클래스 외에 필요에 따라 개발자가 새로운 예외 클래스를 정의하여 사용할 수 있다. Exception클래스 또는 RuntimeException클래스로부터 상속받아 클래스를 만들기도 하고, 필요에 따라 알맞은 예외 클래스를 선택하여 상속받아 만들 수 있다.

class MyException extends Exception {
	MyException(String msg) { // 문자열을 메개변수로 받는 생성자
    	super(msg); // 부모인 Exception 클래스의 생성자를 호출
   

Exception 클래스를 상속받아서 MyException 클래스를 만들었다.
여기서 필요하다면, 멤버 변수나 메서드를 추가할 수 있다.

기존의 예외 클래스는 주로 Exception을 상속받아서 'checked예외'로 작성하는 경우가 많았지만

요즘은 예외처리를 선택적으로 할 수 있도록 RuntimeException을 상속받아서 Unchecked예외 를 작성하는 쪽으로 바뀌어가고 있다.

🤦‍♀️ 잠만 checked / unchecked 는 또 뭔데...??

앞서 저~ 위에서 말했던 예외임

RuntimeException 클래스들 : 프로그래머의 실수로 발생 => Checked Exception
Exception 클래스들: 사용자의 실수와 같은 외적인 요인으로 발생 => Unchecked Exception


자 그럼 다시 돌아와서,

❓ 왜 요즘은 RuntimeException을 상속받아서 unchecked예외가 더 많아지는 걸까?

💁‍♂️ checked예외는 반드시 예외처리를 해주어야 하기 때문에 불필요한 경우에도 try-catch문을 넣어서 코드가 복잡해지기도 하고,
필수적으로 처리해야만 할 것 같았던 예외들이 선택적으로 처리해도 되는 상황으로 바뀌는 경우가 종종 발생하기 때문에 unchecked예외가 더 환영받고 있다.

1-8. 예외 되던지기(exception re-throwing)

예외 되던지기 : 예외의 일부는 해당 메서드에서 처리 한 후 예외를 인위적으로 다시 발생시키는 것

예외가 발생한 지역에서 try-catch 블럭을 이용하여 예외를 처리한다.
다시 예외를 발생시켜 예외가 발생한 메서드를 호출한 메서드에서 처리하도록 한다.

public class Hello {
    public static void main(String[] args) throws Exception {
        try {
            method1(); //3. 폭탄(다시 발생한 예외)받아서
        } catch(Exception e) {
            System.out.println("main 메서드에서 예외가 처리되었습니다."); //4.처리함
        }
    }
    
    static void method1() throws Exception {
        try {
            throw new Exception();
        } catch (Exception e) {
            System.out.println("method1 메서드에서 예외가 처리되었습니다."); //1.예외처리
            throw e; //2. 다시 예외 발생
        }
    }
}

결과에서 보듯이 method1()과 main 메서드 양쪽의 catch 블럭이 모두 수행되었음을 볼 수 있다.

💁‍♀️ 이짓을 왜할까?

양쪽에서 예외를 처리할 때가 있을 수 있다.
사용 예) Scanner 예외 IOException 공통 처리, 숫자 연산 예외 기본값 기본 처리

1-9. 연결된 예외(chained exception)

한 예외가 다른 예외를 발생시킬 수도 있다.

어떤 한 예외가, 다른 예외를 발생시킬 수 있고,
예를 들어서 예외 A가 예외 B를 발생시키면, A는 B의 원인 예외(cause exception)이라고 한다.
즉, 예외 A는 예외 B가 발생하게 된 원인이라는 뜻이다.

연결된 예외를 사용하는 이유는

여러 예외를 하나로 묶어서 다루기 위해, checked 예외를 unchecked 예외로 변경하려 할 때 이다.

  1. 여러 예외를 하나로 묶어서 다루기 위해
class InstallException extends Exception { ... }
class SpaceException extends Exception { ... }
class MemoryException extends Exception { ... }

public class Main {
    public static void main(String[] args) {
        try {
            install();
        } catch (InstallException e) {
            System.out.println("원인 예외 : " + e.getCause()); // 6. InstallException 예외를 catch하고 getCause() 메서드를 통해 원인 예외 로그를 출력한다
            e.printStackTrace();
        }
    }

    public static void install() throws InstallException {
        try {
            throw new SpaceException("설치할 공간이 부족합니다."); // 1. SpaceException 발생

        } catch (SpaceException e) {
            InstallException ie = new InstallException("설치중 예외발생"); // 2. 예외 생성
            ie.initCause(e); // 3. InstallException 객체의 메서드 initCause()를 이용해 SpaceException 타입의 객체를 넣어 실행. 그러면 SpaceException 예외는 InstallException 예외에 포함되게 된다. (원인 예외)
            throw ie; // 4. InstallException을 발생시켜 상위 메서드로 throws 된다.
        } catch (MemoryException e) {
            // ...
        }
    }
}


출처

❓ 발생한 예외를 그대로 처리하면 될 것을, 왜 굳~~이 원인 예외로 포장해서 다른 예외로 던지는 걸까?

👉 여러가지 예외를 하나의 큰 분류의 예외로 묶어서 다루기 위해서다.

처음 부터 명확한 에러 정보를 주는 것 보다는 단계별로 어떠한 원인의 에러에 의해서 에러가 났다는 정보를 주는 것이 더 좋기 때문이다.

예를 들어 위의 사진 처럼, 단순히 '설치할 공간이 부족합니다.' 라는 에러만 띄우면 어떠한 원인 동작으로 인해 갑자기 공간이 부족하는지 추적을 못하기 때문에, 예외를 감싸서 '설치중에 → 설치할 공간이 부족해 예외 발생' 이런식으로 추적이 용이하기 때문이다.

  1. Checked 예외를 Unchecked 예외로 변환

checked예외는 Exception의 자손이고, 필수처리대상이다.
Unchecked예외는 RuntimeException의 자손이고, 선택처리 대상이다.

static void startInstall() throws SpaceException, MemoryException {
    if(!enoughSpace())		// 충분한 설치 공간이 없는 경우
    	throw new SpaceException("설치할 공간이 부족합니다.");
    
    if(!enoughMemory())		// 충분한 메모리가 없는 경우
    	throw new MemoryException("메모리가 부족합니다.");
}

startInstall()라는 메서드가 있는데,
이 메서드에는 SpaceException과 MemoryException을 던진다고 선언되어있다.

SpaceException은 Exception자손이다. 즉 예외 필수처리이다. (checked예외)
그런데 이것을 예외 선택처리로 바꾸고싶다. 그러면, 상속받는 부모 Exception을 RuntimException으로 바꾸면 된다.

바꾸기만 하면 되는데, SpaceException이 이미 다른 곳에 많이 쓰이고 있다.
그러면, 이 상속 계층도를 바꾸는 것이 쉽지 않다.

그래서 SpaceException이 상속받은 Exception을 RuntimeException으로 변경하는 것이 쉽지 않을 때, 연결된 예외를 이용해서 바꾸는 것이다.
RuntimeExcetpio안에다가, SpaceException을 집어넣는 것이다.
그래서 Exception이 발생한 것이아니라, RuntimeException이 발생한 것 처럼 위장하는 것이다.

static void startInstall() throws SpaceException {
    if(!enoughSpace())		// 충분한 설치 공간이 없는 경우
    	throw new SpaceException("설치할 공간이 부족합니다.");
    
    if(!enoughMemory())		// 충분한 메모리가 없는 경우
    	throw new RuntimeException(new MemoryException("메모리가 부족합니다.")); // 원인 예외로 등록
} // startInstall 메서드 끝

throws에 예외 처리 필수인 SpaceException만 선언해 두었고, MemoryException은 선언하지 않았다.
왜냐하면, 예외 처리가 선택으로 바뀌었기 때문이다.

그러고 나서 RuntimeException을 만들고, 그 안에 원인 예외로 MemoryException을 등록하는 것이다.
RuntimeException(Throwable cause)를 사용하는데, 괄호안에 원인예외를 넣어주면
이것이 RuntimeException예외의 원인예외가 되는 것이다.

필수 예외를 사용하면 예외처리가 필수라서 try-catch블럭을 꼭 써야한다는 제약이 생기기 때문에, 코드 짤때 불편한 경우가 많다. 실제로는 try-catch를 안써도 되는 상황인데도 꼭 사용해야 하는 불편함이 있다는 것이다.

따라서 연결된 예외(chained exception)을 이용해, checked 예외를 unchecked 예외로 바꾸면 예외처리가 선택적이 되므로 억지로 거추장 스러운 예외처리를 하지 않아도 된다.

참고자료 :

0개의 댓글