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

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

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

예외의 종류와 처리방식

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

Error와 Exception


  Error Exception
Checked Exception Runtime Exception
복구 여부 복구 불가 복구 가능
예외 종류 컴파일 타임 - 문법 오류
- 타입 체크
   
런타임 - StackOverFlowError
- OutOfMemoryError
   
예외 처리 처리불가   컴파일러에 의해
예외 처리 강제
예외 처리 강제화 되지 않음

예외 상속관계

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

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

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

 

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

따라서 catch문으로 예외를 잡아서 반드시 처리해야하거나, 처리하지 않을시 thorw를 최상위 호출부까지 선언하는 코드를 작성하지 않아도 된다.

 

예외 처리 방식


1. 예외회피

예외 처리는 우리가 다 알고 있듯 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를 비즈니스 계층과 웹(프레젠테이션) 계층까지 명시한다면 결합도가 높은 구조를 갖게 된다.
만일 위 구조에서 다른 영속화 프레임워크를 사용하게 되어 예외타입이 바뀌면 비즈니스 계층과 웹 계층 모두 수정을 필요로 한다.

위와 같이 throws 를 통해 예외가 지속적으로 전파되게 되면 코드 파악에 어려움을 만드는 것 뿐만 아니라, 예외로 인해 객체 간의 의존성을 높이는 구조를 유발한다.

 

2. 예외 전환

예외 회피를 통해 예외가 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);

   }

 

 

3. 예외처리 고려사항

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

  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초 간격으로 재요청하도록 로직을 구현하였다.

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

 

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

 

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

 

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

 

반응형