Java

[Java]Exception

voider 2021. 2. 7. 20:49

Exception

Exception과 RuntimeException

일반 예외와 런타임 예외를 구분하는 데서 시작해야겠다.

  • Exception

    반드시 처리해야 하는 예외. 그래서 체크 예외라고 불린다. 이 예외는 처리하지 않으면 컴파일할 수 없어서 컴파일 예외라고도 부른다. 이를 테면 ClassNotFoundException이나 IOException같은 것들이 체크 예외에 속한다.

  • RuntimeException

    RuntimeExceptionException을 상속받는 예외 클래스다. 프로그램 실행 중에 발생하는 에러를 런타임 예외라고 부르며 반드시 처리할 필요는 없다. 따라서 언체크 예외라고 부른다. 런타임 예외로는 흔히 볼 수 있는 NullPointerException이 있다. 컴파일하는 데에는 아무 문제 없지만 런타임 중에 널포인터 예외가 발생하면 프로그램이 예상한 대로 작동하지 않을 수 있다.

예외 처리 try - catch

언체크 예외는 예외가 발생할 수 있는 상황을 미리 예측하여 try - catch보다 문법이 비교적 간단한 조건문을 사용하여 처리해도 된다. 하지만 이미 말했듯이 체크 예외는 반드시 처리해야 한다. 그때 사용하는 문법이 try - catch문이다.

배열이나 컬렉션 프레임워크를 사용할 때 가장 자주 접하는 체크 예외 중 하나가 ArrayIndexOutOfBoundsException이다. 배열의 인덱스 범위를 벗어났을 때 발생하는 예외다.

public static void main(String[] args) {
    int[] arr = new int[5];

    for(int i=0; i<10; i++) {
        arr[i] = i; //ArrayIndexOutOfBoundsException
    }

    //입력에 성공한 값만이라도 반드시 출력해야 한다.
    System.out.println(Arrays.toString(arr));
}

이 코드를 실행하면 ArrayIndexOutOfBoundsException가 발생하면서 컴파일에 실패한다. 반복문을 돌면서 배열의 크기 즉, 배열이 가진 인덱스 범위를 벗어나기 때문이다.

그런데 이 코드가 매우 중요한 코드라서 어쨌든 입력에 성공한 값만이라도 반드시 출력해야 한다고 가정해보자. try - catch는 이 문제를 해결한다. 예외를 잡아서, 처리한다. 그것이 try - catch의 목적이다.

public static void main(String[] args) {
    int[] arr = new int[5];

    try {
        for(int i=0; i<10; i++) {
            arr[i] = i; //ArrayIndexOutOfBoundsException
        }
        //입력에 성공한 값만이라도 반드시 출력해야 한다.
        System.out.println(Arrays.toString(arr));
    } catch (ArrayIndexOutOfBoundsException e) {
        //예외 발생할 시 처리해야 할 작업
        e.printStackTrace(); //예외 로그
        System.out.println(Arrays.toString(arr));
    }    
}

예외가 발생할 수도 있는 코드를 try 블럭 안에 두고 예외가 발생하면 catch문이 붙잡아서 처리한다. 이때 발생한 예외가 catch문에 선언된 예외와 같아야 예외를 처리할 수 있다. 여러 개의 catch블록을 사용하는 것이 가능하기 때문에 여러 예외를 처리할 수 있다. 또한 try블럭이나 catch블럭 안에 다중으로 try - catch문을 집어넣는 것도 가능하다.

멀티 catch

여러 개의 catch블럭을 사용할 수 있다고 말했는데 JDK1.7부터는 하나의 캐치블럭에서 여러 예외를 처리할 수 있게 되었다.

try {
    //IOException을 발생시킬 수 있는 코드
    //IllegalArgumentException을 발생시킬 수 있는 코드
} catch (IOException e) {
    //IOException 예외처리
} catch (IllegalArgumentException e) {
    //IllegalArgumentException 예외 처리
}

이런 다중 catch블록을 아래처럼 하나의 catch블록에서 처리할 수 있다.

try {
    //IOException을 발생시킬 수 있는 코드
    //IllegalArgumentException을 발생시킬 수 있는 코드
} catch (IOException | IllegalArgumentException e) {
    if(e instanceof IOException) {
        //IOException 예외처리
    } else if (e instanceof IllegalArgumentException) {
        //IllegalArgumentException 예외 처리
    }
}

멀티 catch에 상속 관계에 있는 예외 클래스를 함께 둘 수 없다는 제약이 있다. 그러니까 catch(IOException | Exception e ) 이런 식으로 선언할 수는 없다는 말이다. 이건 Exception이 이미 IOException을 포함한 예외이니 여러 번 선언하지 말라는 뜻이다. 개인적으로는 멀티 catch문보다 다중 catch블럭을 사용하는 것이 좀 더 명시적이고 읽기 쉬운 코드인 것 같다.

throw

직접 예외를 발생시켜야 하는 상황이 있다. 흔히 예외를 던진다, 고 말한다. 예외의 상황이 발생할 것 같을 때, 직접 예외를 던질 수 있다. 이를 테면 ARS상담 메서드를 정의한다고 가정해보자.

private void arsService (int number) {
    switch(number) {
        case 0 : System.out.println("상담사 연결"); break;
        case 1 : System.out.println("요금 조회"); break;
        case 2 : System.out.println("요금 납부"); break;
        case 3 : System.out.println("다시 듣기"); break;

        default : 
            throw new IllegalArgumentException("잘못된 입력입니다. 다시 입력하세요.");
    }
}

0, 1, 2, 3, 4를 제외한 다른 숫자를 입력했을 때 적절한 예외와 함께 메시지를 던져야 한다. 그렇지 않으면 그냥 아무 일도 일어나지 않을 것이다.

throws

메서드/생성자 선언부에 throws를 사용할 수 있다. 여러 개의 예외가 발생할 수 있다면 콤마(,)를 사용하여 여러 예외를 선언할 수 있다.

public Scanner(Path source) throws IOException    //IOException, FileNotFoundException, ....여러 예외 선언 가능

이 메서드가 IOException을 발생시킬 수도 있다는 것을 명시하는 동시에 try - catch로 예외처리를 하지 않고 메서드 밖으로 예외를 던질 수 있다.

그러니까 Scanner()가 자신이 발생시킨 IOException을 try - catch로 복구하지 않고 Scanner()를 호출한 클라이언트 쪽에서 예외를 처리하도록 책임을 위임할 수 있다는 것이다.

클라이언트 쪽에서도 throws를 사용하여 다시 책임을 위임할 수 있다. 이렇게 위임 -> 위임 -> 위임 ...반복되다가 마지막까지 예외 처리가 되지 않으면 그곳에서 예외가 발생한다. 어디선가는 반드시 예외를 처리해야 한다. 이렇게 예외를 던지기만 하고 밖으로 던지기만 하는 것은 좋은 방법이 아니다. 처리할 수 있는 예외는 처리하고, 자신이 처리할 수 없는 예외만 밖으로 던져서 책임을 위임하는 것이 좋다.

finally

try - catch문 마지막에 finally블럭을 사용할 수 있다. 실행 순서는 try --> catch(예외 발생 시) --> finally 순이다. 예외가 발생하지 않으면 try -> finally순으로 실행된다. 즉, finally는 예외 발생 여부와 상관없이 반드시 한 번 실행된다.

맨 처음에 예시로 사용한 코드를 보자.

    int[] arr = new int[5];

    try {
        for(int i=0; i<10; i++) {
            arr[i] = i; //ArrayIndexOutOfBoundsException
        }
        //입력에 성공한 값만이라도 반드시 출력해야 한다.
        System.out.println(Arrays.toString(arr));
    } catch (ArrayIndexOutOfBoundsException e) {
        //예외 발생할 시 처리해야 할 작업
        e.printStackTrace(); //예외 로그
        System.out.println(Arrays.toString(arr));
    }    

try블럭과 catch블럭에서 System.out.println(Arrays.toString(arr)) arr을 출력하는 코드가 반복된다. 예외가 발생할 지 정확하게 알 수 없기 때문이다. 이럴 때 finally를 사용할 수 있다.

    try {
        for(int i=0; i<10; i++) {
            arr[i] = i; //ArrayIndexOutOfBoundsException
        }
        //입력에 성공한 값만이라도 반드시 출력해야 한다.
    } catch (ArrayIndexOutOfBoundsException e) {
        //예외 발생할 시 처리해야 할 작업
        e.printStackTrace(); //예외 로그
    } finally {
        System.out.println(Arrays.toString(arr));
    }    

이렇듯, 예외 여부와 관계없이 한 번은 실행해야 하는 코드가 있다면 finally를 사용하면 된다. finally는 try - catch블럭에 return문이 있더라도 반드시 거치게 된다.

try - with - resources

사용이 끝나면 닫아야 하는 객체들이 있다.(close()) 이를 테면 JDBC가 제공하는 ResultSet, PreparedStatement, Connection이 사용 후 반드시 close()를 호출하여 자원을 반납해야 하는 객체들이다.

 try {
            Class.forName("com.mysql.jdbc.Driver");
            conn = DriverManager.getConnection(dburl, dbUser, dbpasswd);
            String sql = "SELECT description,role_id FROM role WHERE role_id = ?";
            ps = conn.prepareStatement(sql);
            ps.setInt(1, roleId);
            rs = ps.executeQuery();

            if (rs.next()) {
                String description = rs.getString(1);
                int id = rs.getInt("role_id");
                role = new Role(id, description);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (rs != null) {
                try {
                    rs.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            if (ps != null) {
                try {
                    ps.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            if (conn != null) {
                try {
                    conn.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
        }

출처: https://androphil.tistory.com/763 [소림사의 홍반장!]

catch블럭 안에서 다시 try-catch를 사용하여 객체를 닫는 이유는 close()가 예외를 발생시킬 수도 있기 때문이다. 어쨌거나 매우 복잡해보이는 코드다. 이 문제를 해결하기 위해 JDK1.7에 try - with - resources가 추가되었다.

try(
    Connection conn = ds.getConnection();
    PreparedStatement pstmt = conn.prepareStatement(sql);
    ResultSet rs = pstmt.executeQuery();) {

    rs.next();        
    return rs.getInt("total");

} catch (Exception e) {
    log.info(e.getMessage());
}

try - with - resources문의 괄호()안에서 생성한 객체는 따로 close()를 호출하여 닫지 않아도 try 블럭이 끝나면 close()가 자동 호출된다.

try - with - resources를 사용하여 자동으로 close()를 호출하려면 AutoCloseable인터페이스를 상속 받는 객체여야 한다. 실제로 Connection이나 PreparedStatement, ResultSet모두 AutoCloseable인터페이스를 상속받고 있다.

try - with - resources에서 괄호() 안에서 생성하는 객체가 하나 뿐이라면 세미콜론(;)을 붙이지 않는다.

Custom Exception

Exception이나 RuntimeException 같은 Exception의 하위 클래스를 상속 받아서 사용자 정의 예외를 만들 수도 있다. 아래는 RuntimeException을 상속 받아서 만든 사용자 정의 예외다.

public class NotEnoughStockException extends RuntimeException {
    public NotEnoughStockException() {
    }

    public NotEnoughStockException(String message) {
        super(message);
    }

    public NotEnoughStockException(String message, Throwable cause) {
        super(message, cause);
    }

    public NotEnoughStockException(Throwable cause) {
        super(cause);
    }
}

반드시 처리해야만 하는 예외가 아니라면 RuntimeException을 상속 받는 게 좋다. Exception을 상속 받아서 체크 예외로 만들면 예외 처리를 강제하므로 코드가 지저분해질 수 있다.

tip : ErrorException 은 다르다. 말 그대로 Error는 발생하면 손 쓸 수 없고, Exception은 발생하더라도 프로그래머가 예측하여 수습할 수 있다.