본문 바로가기
Language/자바&코틀린

자바 예외의 종류와 처리방식

by 코딩공장공장장 2024. 2. 19.

예외종류

자바에서 프로그램이 비정상적인 동작을 일으키는 경우 Error 또는 Exception이 발생하게 된다.

 

Error와 Exception의 차이는 복구가능하냐 복구 불가능하냐로 구분할 수 있습니다.

 

문법, 타입체크와 같은 과정을 통해 발생하는 컴파일 타임에러와 OutOfMemoryError나 StackOverflowError와 같은 실행중에 발생할 수 있는 런타임 에러는 개발자가 복구할 수 있는 동작이 아니다.

 

그에 반면 IOException , SqlException, NullPoiterException과 같은 예외는 개발자가 예외를 catch하여 처리할 수 있다.

 

Error와 Exception 모두 Throwable 클래스를 상속 받는다.

 

Throwable 클래스는 message와 stackTrace를 담고 있는데 message는 Error와 Exception의 구체적인 정보를 간단히 제공하는 메시지이고 stackTrace는 StackTraceElement의 객체로 비정상 동작이 발생한 파일, 메서드, 라인수와 같은 보다 구체적인 정보를 가지고 있습니다.

 

따라서 Error와 Exception 모두 Throwable을 상속하고 있으니 개발자들은 message와 stackTrace를 통해 비정상적인 동작이 무엇인지 그리고 그 원인이 무엇인지에 대해 구체적으로 알 수 있다.

 

예외 상속관계

 

예외의 상속관계는 위와 같다.

 

예외는 체크예외와 언체크예외로 다시 한번 나뉘게 되는데

 

체크예외는 컴파일러에 의해 예외처리 코드가 강제화되고

 

언체크 예외는 RuntimeException을 상속받는 예외들로 예외처리가 강제화 되지 않는다. 

 

따라서 catch문으로 예외를 잡아서 반드시 처리해야하거나 처리하지 않고 최상위 호출부로 예외를 지속적으로 던지는 예외전파를 하지 않아도 된다.

 

예외처리방법

예외처리 방법은 누구나 다 알고 있듯이 try-catch 구문을 통해 예외가 발생할 가능성이 있는 부분을 try로 감싸고 예외가 발생했을 때 처리해야될 내용을 catch문에 구현하는 것이다.

 

파일에 1부터 10까지 쓰는 코드를 작성하였다.

public class NumberWriter {
    public void write() {
        String fileName = "numbers.txt";

        try(OutputStream outputStream = new FileOutputStream(fileName)) {
            for (int i = 1; i <= 10; i++) {
                String numberAsString = i + "\n";
                byte[] bytes = numberAsString.getBytes(StandardCharsets.UTF_8);
                outputStream.write(bytes);
            }
        } catch (IOException e) {
            System.out.println("exception throw : " + e.getMessage());
        }
    }
}

 

 

FileOutputStream은 파일 없는 경우를 대비하여 FileNotFoundException을 강제적으로 처리하도록 하였다.

 

outputStream의 write 함수는 IOException을 강제화 한다.

 

따라서 위와 같이 더 상위 타입인 IOException을 catch하여 구체적인 예외의 내용을 출력하도록 하였다.

 

try-catch를 사용하지 않고 예외를 호출한 객체에 넘겨줄 수 있다.

public class NumberWriter {
    public void write() throws IOException {
        String fileName = "numbers.txt";

        OutputStream outputStream = new FileOutputStream(fileName);
        for (int i = 1; i <= 10; i++) {
            String numberAsString = i + "\n";
            byte[] bytes = numberAsString.getBytes(StandardCharsets.UTF_8);
            outputStream.write(bytes);
        }
        outputStream.close();
    }
}

 

예외를 지속적으로 던지다보면 결국 Main 클래스에까지 예외가 전달된다. 

public class Main {
    public static void main(String[] args) throws IOException {
        new NumberWriter().write();
    }
}

 

위와 같이 예외를 예외를 어느쪽에서도 처리하지 않고 호출한 쪽으로 넘기는 방식을 예외 회피라고 한다.

 

예외를 호출부로 지속적으로 던지는 것은 좋지 않다.

 

예를 들어 컨트롤러-서비스-DB 영속화 와 같은 계층이 있다고 하자.

 

SQL 관련 에러가 비즈니스 로직인 서비스와 클라이언트와 소통하는 컨트롤러단까지 내려가게 된다.

 

각 레이어의 책임과 관련 없는 예외가 덕지덕지 전파될 수 있다.

 

레이어간 분리는 단순히 객체나 레이어간의 역할에 따른 분리 뿐만 아니라 예외에 대한 분리도 이루어져야한다.

 

예외를 던진다면 호출부에서 예외를 확실히 처리하는 로직이 있을때 예외를 던지도록 하자.

 

예외전환

만약 예외가 발생하더라도 로그를 남기는 것 외에 별다른 처리 로직이 없는 경우 어떻게 할까?

 

아래와 같은 상황을 만들어볼 수 있다.

 

  1. 구체적인 예외로 전환 가능하다면 구체적인 예외로 전환
  2. 체크 예외의 경우 언체크 예외로 전환

 

구체적인 예외로 전환

 

SQLException의 경우 아래와 같은 에러코드를 제공해준다.

/**
 * Retrieves the vendor-specific exception code
 * for this {@code SQLException} object.
 *
 * @return the vendor's error code
 */
public int getErrorCode() {
    return (vendorCode);
}

 

벤더사(DB공급업체) 마다 가지고 있는 에러 코드를 제공해준다.

catch(SQLException e) {
    if (e.getErrorCode() == MysqlErrorNumber.ER_DUP_ENTRY) {
        throw new DuplicateIdException("id 중복 오류");
    }
}

 

에러코드를 파악하여 구체적인 커스텀 예외를 만들어 전달 해줄 수 있다.

 

구체적인 예외를 받는다면 비정상적인 상황에 대한 디버깅을 훨씬 빠르게 진행할 수 있다.

 

 

체크예외를 언체크 예외로 전환

 

위의 SQLException은 체크예외이다. 키값이 중복 되었을 때 별다른 처리할 수 있는게 없다.

 

DB 영속화 처리를 반드시 진행해야하는데 영속화 오류가 났다면 이후의 처리는 진행되어서 안되고 롤백 시키는게 합리적인 상황에서 별다른 처리를 할 수 있나.

 

언체크 예외로 전환하여 별다른 처리 없이도 롤백 처리 되게끔 구현하는게 좋은 방식일 수 있다.

 

위의 예제에서 보인 DuplicateIdException 또한 RuntimeExecption을 상속하여 만든 것이다.

 

 

체크예외를 사용하면 좋은 경우

지금까지 설명에서 체크예외는 예외처리가 강제화 되어있다보니 예외 회피를 일으킬 수 있고, 별다른 처리 로직을 할 수 없는 경우에도 강제적으로 처리 코드를 넣어야 하니 굉장히 안좋은 안티패턴처럼 설명이 된것 같다.

 

하지만 체크예외를 사용하는 것이 유리한 경우도 있다.

public class HttpTemplate {
    public int getMethod(String host) throws IOException {
        URL url = new URL(host);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestMethod("GET");

        int responseCode = connection.getResponseCode();
        if (responseCode == HttpURLConnection.HTTP_OK) return 200;
        else return 0;
    }
}

 

위의 코드는 http 요청을 날리는 단순한 템플릿이다.

 

실행중 비정상적인 로직이 발생하는 경우 예외처리를 하지 않고 그냥 호출부로 예외를 던졌다.

public class HttpClient {
    private static HttpTemplate httpTemplate = new HttpTemplate();
    private static final int MAX_RETRY = 3;
    private static final int RETRY_INTERVAL_MS = 1000; // 1초

    public static void main(String[] args) {
        String host = "https://newtworkcommunicationexception.com/";
        int retryCount = 0;
        boolean success = false;

        while (retryCount < MAX_RETRY) {
            try {
                httpTemplate.getMethod(host);
            } catch (IOException e) {
                retryCount++;
                try {
                    Thread.sleep(RETRY_INTERVAL_MS); // 재시도 전 대기
                } catch (InterruptedException ex) {
                    System.err.println("재시도 대기 오류: " + ex.getMessage());
                }
            }
        }
        if (!success)  System.out.println("Maximum retry count over");
    }
}

 

그리고 해당 템플릿을 사용하는 클라이언트에서 예외가 발생하는 경우 최대 3번 1초 간격으로 재요청하도록 로직을 구현하였다.

 

체크예외를 던지니 클라이언트 입장에서도 예외가 발생한다는 것을 알 수 있고 이에대한 처리까지 구현할 수 있다.

 

모듈이나 라이브러리, 더 작게는 클래스 단위의 템플릿을 제공하는 입장에서 예외가 발생했을 때 사용자가 유연하게 처리할 수 있다면 체크예외를 던지는 것도 좋은 방식일 수 있다.

 

위 코드는 간단하지만 모듈 단위의 코드 묶음이고 언체크 예외를 던진다면 클라이언트 입장에서 소스코드 하나하나 분석하지 않는 이상 어디서 예외가 발생할지 알 수 없고 예외가 발생되야 그제서야 처리로직을 구현할 것이다.

 

클라이언트가 예외 발생 가능성을 알아야하고 이에 대한 유여한 처리가 가능하다면 제공자 입장에서는 체크예외를 던지는 것도 좋은 방식이라고 생각한다.

 

 

++++부가++++

try with resource

 

중첩 try catch

 

코틀린 run catch

 

반응형