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

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

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

예외의 종류와 처리방식

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

Error와 Exception

  • Error는 복구 불가
    컴파일 타임 에러 : 문법 오류, 타입 체크
    런타임 에러 : StackOverFlowError, OutOfMemoryError

  • Exception은 복구 가능
    Checked Exception : 컴파일러에 의해 예외처리 코드가 강제화, 컴파일 타임에 확인(예측 가능)하여 예외처리를 강제화함
    UnCheckedException : RuntimeException을 상속받는 예외들로 예외처리가 강제화 되지 않음, 런타임에 확인 가능(예측불가)

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

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

 

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

 

예외 상속관계

.

예외는 체크예외와 언체크예외로 다시 한번 나뉘게 되는데 언체크 예외는 예외처리가 강제화 되지 않는다. 

따라서 catch문으로 예외를 잡아서 반드시 처리해야하거나, 처리하지 않을시 thorw를 최상위 호출부까지 선언하는 코드를 작성하지 않아도 된다.
(아무 처리를 하지 않음에도 throw를 계속 작성하는 것은 코드 가독성을 떨어트릴 수 있다.)

 

예외회피

예외 처리는 우리가 다 알고 있듯 try-catch문을 통해 작성한다.

예제를 통해 살펴보자.

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을 강제화 한다.

따라서 해당 블록을 try-catch로 감싸서 처리하였다.

 

만일 try-catch를 사용하지 않는다면 체크 예외는 호출부에서 throw 처리를 진행해야한다.

중간에 catch 하지 않는다면 예외는 결국 최상위 Main 클래스에까지 전달될 것이다. 

public class NumberWriter {
    public void write() throws IOException {  // throw 선언
        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();
    }
}
public class Main {
    public static void main(String[] args) throws IOException { // throw 선언
        new NumberWriter().write();
    }
}

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

throw코드를 통해 예외를 호출부로 지속적으로 던지는 것은 좋지 않다.

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

영속화 관련 체크예외 발생시 throw를 비즈니스 계층과 웹(프레젠테이션) 계층까지 명시한다면 결합도가 높은 구조를 갖게 된다.
만일 위 구조에서 다른 영속화 프레임워크를 사용하게 되어 예외타입이 바뀌면 비즈니스 계층과 웹 계층 모두 수정을 필요로 한다.
예외 또한 객체의 책임의 일부이다. 따라서 객체 내에 존재하는 예외가 다른 곳으로 전파되지 않도록 처리를 하는 것이 좋다.

 

예외전환

예외 회피를 통해 예외가 throw 코드를 통해 예외가 명시적으로 전파되는 것이 좋지 않다면 이를 어떻게 해결해야하나.

그 해결 방법이 예외전환이 될 수 있다.

예외전환이란 예외를 catch하여 다른 예외를 발생시키는 것이다.

 

보통 예외전환을 직접 처리하는 경우는 의존도를 낮추기 위함이다. 

DB 예외가 웹 계층까지 전달되지 않거나, 라이브러리의 예외가 비즈니스 계층까지 전달되지 않도록 특정 라이브러리에 종속되지 않는 예외를 발생시켜 의존도를 낮추는 것이다.

 

[예제]

아래와 같은 코드가 있다고 하자.
JpaRepositoryImp이 호출하는 saveDto 메서드 내부에서 JpaCheckException이 발생한다고 해보자.

public class JpaRepositoryImpl {

   public boolean save(BusinessDto businessDto) throws JpaCheckException {

       return saveDto(businessDto);

   }

}

 

public class BusinessServiceImpl {

   . . .

 

   public boolean save(BusinessDto businessDto) throws JpaCheckException {

       return jpaRepository.save(businessDto);

   }

}

 

public class WebController {

   . . .

 

   public Boolean save(BusinessDto businessDto) throws JpaCheckException {

       return businessService.save(businessDto);

   }

}

 

영속화 계층에서 throw한 체크 예외로 인해 서비스 계층과 컨트롤러 계층까지 영속화 예외에 대해 throw를 선언하였다.

만일 위 상황에서 JpaRepository가 MybatisRepository로 바뀌어 예외 타입이 MybatisCheckException으로 바뀌었다고 해보자.
WebController와 BusinessSerivce에 선언된 throws JpaCheckException을 모두 MybatisCheckException으로 변경해줘야한다.

체크예외의 경우 compiler가 잡아주기에 변경에 용이함은 있겠지만 다른 클래스의 변경사항에 영향을 미치는 것은 좋지 못한 구조이다.

애초에 이런 상황이 발생하더라도 영향도가 없게끔 아래와 같이 라이브러리에 종속되지 않은 런타임 예외로 전환하면  다른 계층에 예외가 전파되는 것을 막을 수 있다.

 

public class JpaRepository {

 

   public boolean save(BusinessDto businessDto)  {

       try{

           return saveDto(businessDto);

       } catch (JpaCheckException e) {

           throw new PersistenceSaveException("businessDto save 오류 id:" + businessDto.id );

       }

   }

}

 

public class BusinessServiceImpl {

public class BusinessServiceImpl {

   . . .

 

   public boolean save(BusinessDto businessDto){

       return jpaRepository.save(businessDto);

   }

}

 

public class WebController {

   . . .

 

   public Boolean save(BusinessDto businessDto){

       return businessService.save(businessDto);

   }

}



예외처리 고려사항

예외처리에 고려해야하는 사항들이 많이 있겠지만 개인적으로 중요하다고 생각하는 것을 정리하겠다.

  1. 예외 의존성 약화
    예외 또한 객체가 처리해야하는 책임이다. 예외가 다른 객체로 전파되는 것은 마냥 좋지 않다.
    특히 특정 라이브러리에 종속된 예외가 여기저기 전파된다면 라이브러리 변경시 전파된 모든 객체에서 수정을 필요로 한다.
    이런 상황에서 라이브러리에 종속되지 않은 커스텀 예외로 전환하게 되면 라이브러리 변경시 수정사항을 최소화 할 수 있다.

  2. 구체적인 예외로 전환
    만일 라이브러리 종속 예외를 커스텀 예외로 바꾸는 것을 고려한다면 예외 발생 상황을 구체적으로 알 수 있는 커스텀 예외를 만들자.
    이는 예외 상황에 대한 디버깅을 수월하게 할 수 있게 해준다.
    만일 상황에 따른 커스텀 예외를 만드는 것이 너무 많은 클래스를 생성하게 하여 오히려 유지보수에 복잡성을 일으킨다면
    아래와 같이 예외 메시지에 구체적인 상황을 명시하는 것도 좋은 방법이 될 수 있다.
    throw new PersistenceSaveException("businessDto save 오류 id:" + businessDto.id );

  3. 체크 예외를 언체크 예외로 전환
    스프링 프레임워크에서만 해당되는 상황인데 체크예외의 롤백을 지원하지 않는다.
    만일 영속화 작업중 체크예외가 발생했을 때 별다른 처리 로직이 없이 직접 명시적으로 캐치하여 롤백 명령어를 수행해야하는 상황이라면, 발생 지점에서 캐치하여 언체크 예외로 전환해주는 것도 좋은 처리 방법일 수 있다.
    트랜잭션 처리를 개발자가 직접 처리하는 것보다 언체크 예외를 발생시켜 프레임워크에 처리를 넘기는 것이
    더욱 안정적이고 코드 중복 제거와 변경사항에 보다 유연한 구조를 갖출 수 있다.

  4. 예외 복구
    예외 발생히 예외 이전 상황으로 복구가 가능한 처리를 구현하는 것을 예외 복구라고 한다.
    대표적인 예가 http 통신 상황에서 예외 발생시 몇번 더 시도를 해보는 것이 있다.

 

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

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

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

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-cath보다 try-with-resources

이전에 사용한 예제에서 try-with-resources를 통해 아래와 같이 처리하였다.

String fileName = "numbers.txt";
try(OutputStream outputStream = new FileOutputStream(fileName)) {
      // ...
} catch (IOException e) {
      // ...
}

이를 try-catch로 바꾸면 아래와 같이 직접 자원을 반납해줘야한다.

String fileName = "numbers.txt";
OutputStream outputStream = null;
try {
      // ...
} catch (IOException e) {
      // ...
} finally {
      if (outputStream != null) outputStream.close();
}

stream은 os 자원을 필요로 하기에 다 사용하고 반납하지 않으면 자원을 계속 점유하는 메모리 누수 현상이 발생할 수 있다.

이처럼 자바에서는 사용 이후 자원을 반납해야하는 타입들을 Cloaseable 인터페이스를 상속 받아 주로 표현한다.

 

Cloaseable 인터페이스는 AutoCloseable 인터페이스를 상속 받는데
AutoCloseable 인터페이스를 상속 받은 타입들은 try-with-resouces문을 통한 자동 자원 반납 처리를 제공 받을 수 있다.

 

public interface Closeable extends AutoCloseable {

   public void close() throws IOException;

}

 

public interface AutoCloseable {

   void close() throws Exception;

}


(자바 7부터 기존 Cloaseable인터페이스에 AutoCloaseable을 상속 받도록 하였다. 이를 통해 기존 Cloaseable을 상속한 모든 구현체는 try-resouces문을 적용하면 자동으로 자원을 반납하는 처리를 제공받을 수 있다.) [하위 호환]

 

try-with-resouces의 장점

  • 실수로 자원 반납을 하지 않는 상황 해결

  • 다른 에러로 자원 반납 하지 않는 상황 해결
    만일 위 try-catch-finally 문법의 finally 구문 안에서 close 메서드 호출하기전에 다른 로직이 존재하고
    해당 로직으로 인해 예외가 다시 발생하는 경우 close 메서드가 실행되지 않아 자원 반납이 이뤄지지 못할 수 있다.

* 참고, 몇몇 블로그 글에서 try-with-resources는 try문에서 예외 발생하고 catch에서 예외 로그 안찍어도 로그 출력되는 것처럼 설명된 글이 있지만 전혀 그렇지 않다.

 

try-resources와 try-catch문으로 구성된 메서드를 하나씩 실행해보자. 확인할 수 있을 것이다.

public class TryResoucesLog {

    public static void main(String[] args) throws IOException {
        tryResources();
    }

    public static void tryResources() {
        String fileName = "numbers.txt";
        try (OutputStream outputStream = new FileOutputStream(fileName)) {
            throw new NullPointerException();
        } catch (Exception e) {
        } finally {
            throw new IllegalArgumentException();
        }
    }

    public static void tryCatch() throws IOException {
        try {
            throw new NullPointerException();
        } catch (Exception e) {
        } finally {
            throw new IllegalArgumentException();

        }
    }
}

 

반응형