[스프링 DB 1편] - 자바 예외 이해(2)

Chooooo·2023년 1월 19일
0

스프링 DB 1편

목록 보기
9/11
post-thumbnail

이 글은 강의 : 김영한님의 - "[스프링 DB 1편 - 데이터 접근 핵심 원리]"을 듣고 정리한 내용입니다. 😁😁


체크 예외 활용

그렇다면 언제 체크 예외를 사용하고 언제 언체크(런타임) 예외를 사용하면 좋을까?

기본 원칙

내가 고려해야 할 수많은 문제 100개가 있다고 가정해보자. 이 문제를 모두 해결하기 위한 방법은 하나씩 모든 문제를 처리하는 방법이 있을 것이다. 또 다른 방법은 대원칙을 하나 정하고, 그 대원칙에서 벗어나는 문제들을 하나씩 처리하는 방법이 있을 것이다. 개발자 입장에서는 후자의 방법이 더 좋다. 앞으로 예외를 처리할 때는 다음과 같이 처리할 것.

🎈 기본적으로 언체크(런타임) 예외를 사용하자
🎈 체크 예외는 비즈니스 로직 상 의도적으로 던지는 예외에만 사용하자(트렌드는 거의 안씀)

  • 이 경우 해당 예외를 잡아서 반드시 처리해야 하는 문제일 때만 체크 예외를 사용해야 한다. 예를 들어...

    • 계좌이체 실패 예외 → 비즈니스 적으로 중요. 복구가 필요하거나, 손님에게 알려야 한다.
    • 결제 시 포인트 부족 예외
    • 로그인ID, PW 불일치 예외
  • 물론 이 경우에도 무조건 체크 예외로 만들어야 하는 것은 아니다. 다만 계좌 이체 실패처럼 매우 심각한 문제는 개발자가 실수로 예외를 놓치면 안된다고 판단할 수 있다. 이 경우 체크 예외로 만들어 두면 컴파일러를 통해 놓친 예외를 인지할 수 있다.

체크 예외의 문제점

체크 예외는 컴파일러가 컴파일 단계에서 미리 처리를 해주는 장점이 있는데, 왜 런타임 예외를 주로 사용하라고 하는 것일까? 이것은 체크 예외에 아주 큰 단점이 하나 존재하기 때문이다. 그 단점은 현재 상태에서 Exception을 처리할 수 없는 경우, "method() throws 예외" 형식으로 체크 예외를 던져야 한다는 점이다.

해당 그림에서 SQL Exception/ConnectException이 Service/Controller계층으로 넘어오는 것을 볼 수 있다.

🎈 리포지토리는 DB에 접근해서 데이터를 저장하고 관리한다. 여기서는 SQL Exception 체크 예외를 던진다.
🎈 NetworkClient외부 네트워크에 접속해서 어떤 기능을 처리하는 객체이다. 여기서는 ConnectException 체크 예외를 던진다.

해당 과정을 생각하면

  1. Repository는 SQL Exception / NetworkClient는 ConnectException이 발생한다.

  2. Service는 두 곳에서 올라오는 체크 예외인 SQLException, ConnectionException 체크 예외를 처리해야한다.

  • Service에서는 이 두 예외를 처리할 방법이 없다. ConnectException처럼 연결 실패 / SQL Exception처럼 DB 문제들은 Service에서 처리할 수 없다.
  • Service는 처리할 수 없는 문제기 때문에 Controller로 예외를 던진다. → Method에 Throws SQLException, ConnectionException이 명시된다.(메서드 단위에 에러 명시)
  1. Controller도 두 Exception을 처리할 방법이 없다.
  • Controller도 예외를 처리할 수 없으므로 윗쪽으로 throws SQLException, ConnectException을 한다.
  • Controller도 두 Exception으로 인한 문제가 발생한다. Controller가 100개가 있다면, 100개에 대한 예외 처리를 해줘야한다.
  1. WEB은 서블릿 컨테이너 / ControllerAdvice에서 이런 예외를 공통으로 처리해준다.
  • 사용자들에게 오류 페이지를 렌더링 해준다.
  • 개발자들에게는 해당 오류를 인지할 수 있도록 메일링 처리를 해준다.

여기서 이야기하는 문제는 DB단에서 올라온 문제를 Service / Controller 단에서 처리할 수 있는 방법이 없음에도 불구하고 체크 예외기 때문에 명시적으로 Throws를 해줘야한다는 것이다. (해당 예외를 알고 싶지도 않지만 throws를 통해 던져줘야 함)

ConnectException : 체크 예외 처리

static class ConnectException extends Exception{
    public ConnectException(String message) {
        super(message);
    }
}

Repository, NetworkClient

static class Repository{
    public void call() throws SQLException {
        throw new SQLException();
    }
}

static class NetworkClient{
    public void call() throws ConnectException {
        throw new ConnectException("ex");
    }
}

🎈 각각 SQL Exception, ConnectException을 발생시킨다.
🎈 각 예외는 Exception을 상속했기 때문에 체크 예외다. 따라서 Throws로 던져줘야 한다.

Controller / Service

static class Controller{
    Service service = new Service();

    public void request() throws SQLException, ConnectException {
        service.call();
    }

}

static class Service{

    Repository repository = new Repository();
    NetworkClient networkClient = new NetworkClient();

    public void call() throws SQLException, ConnectException {
        repository.call();
        networkClient.call();
    }
}

🎈 Controller / Service 모두 해당 오류(SQL Exception, ConnectException)을 처리할 수 없어 모두 throws한다.(던져야지..)

테스트

@Test
void checked() {
    Controller controller = new Controller();
    Assertions.assertThatThrownBy(() -> controller.request()).isInstanceOf(Exception.class);
}
  • Controller.request() 를 하면 체크 예외가 넘어오게 된다.

💢 체크 예외의 두가지 문제

  • 복구 불가능한 예외
  • 의존 관계 문제

복구 불가능한 예외

대부분의 예외는 복구가 불가능하다. 예를 들어 SQLException은 DB에 문제가 있어서 발생하는 문제다. SQL 문제, DB 문제, DB 서버 다운 같은 문제들인데 모두 복구가 불가능하다. 그리고 이런 문제는 Controller / Service 단에서 복구할 수 있는 문제가 아니다.

이처럼 복구 불가능한 예외들은 일관성있게 공통으로 처리해야한다. 복구 불가능한 예외들은 사용자들에게는 오류 페이지를 보여주고, 개발자가 해당 오류를 빠르게 인식할 수 있도록 처리해야한다. 이런 공통의 예외처리를 하는 것은 Filter / Interceptor, ControllerAdvice 등을 사용해서 처리할 수 있다.

의존 관계에 대한 문제

체크 예외는 각 계층에 의존관계 문제를 남긴다. 대부분의 예외는 복구가 불가능하다. 따라서 Controller / Service 입장에서 이런 예외를 신경 쓸 필요가 없다. 왜냐하면 어차피 대응을 할 수 없는 문제다. 그렇지만 체크 예외를 사용하게 될 경우, 신경을 써야하는 것이 강제된다. 즉, 각 메서드에 Throws를 통해 체크 예외를 명시해줘야한다.
(어쩔 수 없이 하나하나 오류들을 던져줘야 한다)

왜 이것이 문제일까? 바로 Service / Controller 계층에 각 예외 정보가 남기 때문이다.(위와 같은 경우 throws SQLException, ConnectException) 예를 들어 SQLException은 java.sql.SQLException이다. 이 예외가 Service / Controller 계층에 명시되는 순간 그 계층은 JDBC 기술에 의존하게 된다.

  • 나중에 Repository의 기술을 JPA로 변경하게 된다면, Service / Controller 계층은 SQLException이 아닌 JPAException이 나오도록 처리가 되어야 한다.

  • 체크 에외 구현 기술 변경 시 파급 효과

Repository를 인터페이스로 구현해서 DI를 한다고 하더라도, Repository 계열에서 발생한 Exception 누수가 Service / Controller 계층을 특정 DB 기술에 종속적으로 만들어 버리기 때문에 다른 DB 기술의 Repository를 DI 하게되면 체크 예외를 추가해야한다는 큰 불상사가 발생한다. 즉, 안티패턴의 역할을 한다.

❓ throws Exception

SQLException , ConnectException 같은 시스템 예외는 컨트롤러나 서비스에서는 대부분 복구가 불가능하고 처리할 수 없는 체크 예외이다. 따라서 다음과 같이 처리해주어야 한다.
void method() throws SQLException, ConnectException {..}

그런데, 최상위 예외는 Exception을 던져도 문제를 해결할 수 있다.
void method() throws Exception {..}
이렇게 하면 Exception 은 물론이고 그 하위 타입인 SQLException , ConnectException 도 함께 던지게 된다. 코드가 깔끔해지는 것 같지만, Exception 은 최상위 타입이므로 모든 체크 예외를 다 밖으로 던지는 문제가 발생한다.

결과적으로 체크 예외의 최상위 타입인 Exception 을 던지게 되면 다른 체크 예외를 체크할 수 있는 기능이 무효화 되고, 중요한 체크 예외를 다 놓치게 된다. 중간에 중요한 체크 예외가 발생해도 컴파일러는 Exception 을 던지기 때문에 문법에 맞다고 판단해서 컴파일 오류가 발생하지 않는다. 이렇게 하면 모든 예외를 다 던지기 때문에 체크 예외를 의도한 대로 사용하는 것이 아니다. 따라서 꼭 필요한 경우가 아니면 이렇게 Exception 자체를 밖으로 던지는 것은 좋지 않은 방법이다.

정리

🎃 대부분의 예외는 시스템적으로 발생하는 것이기 때문에 Service / Controller 계층에서 처리할 수 없다. 따라서 Service / Controller 계층은 이런 예외들에 대해 모르는 것이 최선이다. 이 때 체크 예외를 사용하게 되면 Service / Controller 계층이 각 체크 예외를 명시해야만 한다. 이 과정에서 불필요한 의존관계 문제도 발생할 수 있다.

🎃 불필요한 의존 관계 해결을 위해서 Throws Exception으로 처리할 수 없다. 왜냐하면 모든 예외를 던지기 때문이다. 특정 예외들은 내가 특정 계층에서 잡아서 처리해줄 수 있는 방법이 있다. 그리고 모든 예외를 항상 던지기 때문에 사실상 체크 예외의 기능이 무효화 된다. 즉, 중요한 예외를 잡아야 하는데 잡지 못하게 된다.

방법은 기본적으로 런타임 예외를 사용한다.

❗ 런타임 예외 활용

체크 예외를 사용하게 되면 각 계층에 불필요한 의존성 문제가 발생할 수 있음을 확인했다. 이런 문제는 런타임 예외를 사용할 경우 깔끔하게 해결된다.

앞서 만들었던 SQLException / ConnectException을 모두 런타임 예외로 바꿔서 던지면 의존성 문제가 해결된다. 런타임 예외는 반드시 예외를 Catch / Throws를 하지 않아도 되기 때문에 각 계층에 명시하지 않아도 되기 때문(throws Exception, ...과정이 필요 없어짐)이다. 다시 말해, 런타임 예외이기 때문에 Service / Controller는 처리할 수 없는 예외를 별도의 선언없이 그냥 두기만 하면 된다.

테스트

SQLException / ConnectionException을 모두 RuntimeException을 상속받은 런타임 예외로 만들어서 발생시킨다. 그리고 Controller / Service 계층에서 런타임 예외가 표기 되지 않아서 불필요한 의존 관계가 사라지는 것을 확인하면 된다.

🎈 런타임 예외 작성

static class RuntimeConnectException extends RuntimeException{
    public RuntimeConnectException(String message) {
        super(message);
    }
}

static class RuntimeSQLException extends RuntimeException{
    public RuntimeSQLException(String message) {
        super(message);
    }
}
  • RuntimeException을 상속받은 각 예외를 만듦.

Repository / NetworkClient

static class Repository{
    public void call() {
        throw new RuntimeSQLException("ex");
    }
}

static class NetworkClient{
    public void call() {
        throw new RuntimeConnectException("ex");
    }
}
  • Repository / NetworkClient 클래스는 각 런타임 예외를 발생한다.
  • 런타임 예외이기 때문에 (메서드 단위에서)throws를 하지 않아도 된다. (핵심 !)

Controller / Service

static class Controller{
    Service service = new Service();

    public void request(){
        service.call();
    }

}

static class Service{

    Repository repository = new Repository();
    NetworkClient networkClient = new NetworkClient();

    public void call(){
        repository.call();
        networkClient.call();
    }
}
  • Controller / Service에서 각각 아래 계층을 호출한다.
  • Repository / NetworkClient 계층부터 발생한 문제는 언체크 예외이기 때문에 Controller / Service 계층에서 처리할 필요가 없다. 즉 throws, 그리고 불필요한 의존 문제가 해결된다.

체크 예외 → 언체크(런타임) 에외 변경 이점

  • 예외 전환
    🎈 런타임 예외는 대부분 복구 불가능한 예외
  • 시스템에서 발생한 예외는 애플리케이션에서 복구 할 수 있는 방법이 전무하다. 이 때, 런타임 예외를 사용하면 Service / Controller가 복구 불가능한 예외를 신경쓰지 않아도 된다(Throws). 이렇게 던져진 예외는 ControllerAdvice 등에서 일관적으로 처리될 수 있도록 한다.

🎈 런타임 예외는 의존관계 문제를 처리해준다.

  • 런타임 예외를 사용하게 되면 Throws를 사용하지 않아도 된다. Throws를 사용하게 되면, 해당 메서드 + 해당 계층에서 불필요한 의존관계가 형성되게 된다. 런타임 예외는 이런 것을 방지해주기 때문에 불필요한 의존관계 문제를 처리해준다.

런타임 예외 구현 기술 변경시 팔급 효과


체크 예외를 사용하게 되면 Repository에서 발생하는 JDBC 기술의 SQLException이 Service / Controller 계층에 전파되었던 것을 알 수 있다. 이 때, JDBC → JPA로 변경하게 되면 체크 예외가 JPAException이 추가되면서 Service / Controller 계층에서의 코드 변경이 필요했다.

런타임 예외를 사용하면, JDBC → JPA로 기술 변경을 해도 Repository 단에서 발생하는 예외를 RuntimeSQLException → RuntimeJPAException으로 바꿔주기만 하면 된다.

그리고 Service / Controller는 런타임 예외 덕분에 Repository 단에서 발생하는 예외 누수를 걱정하지 않아도 된다. 즉, 변경범위가 Repository 단으로 최소화 된다.

해당 과정을 체크 예외, 언체크 예외로 다시 생각하면

  1. 체크 예외 사용 시, Repository 단의 예외가 누수되면서 Repository의 기술 변경 시 전범위한 코드 수정이 필요하다.

  2. 런타임 예외 사용 시, Repository 단의 예외가 누수되지 않기 때문에 Service / Controller 계층은 Repository와 완전 독립된다.

정리

🎃 처음에는 체크 예외가 컴파일 시점에 모든 예외를 잡아주기 때문에 좋은 선택이라고 생각했다. 그렇지만 시간이 흐르면서 복구할 수 없는 예외가 많아지며 체크 예외 때문에 불필요한 의존성이 추가되었다. 개발자들은 이 문제를 해결하기 위해 "throws Exception"이라는 극단적인 방법도 자주 사용하게 되었다. 이런 사용법은 체크 예외 자체의 장점을 무력화 시키는 방법이다.

🎃 기본적으로 런타임 예외를 사용한다. 위와 같은 이유 때문에 대부분의 라이브러리들은 런타임 예외를 기본으로 제공한다. 런타임 예외도 잡을 수 있는 건 잡을 수 있다. 필요한 경우에는 예외를 잡아서 처리해주면 되고, 그렇지 않은 경우 공통 예외 처리계층까지 나가도록 두기만 하면 된다.

→ 런타임 예외는 놓칠 가능성도 존재하긴 해. 따라서 문서화가 아주 중요하다 !!!

  • 이제 스프링에서 이 예외들을 어떻게 사용하는지 실제 활용해봐야해 !

예외 포함과 스택 트레이스(StackTrace)

  • 그 전에 실무에서 중요한 내용 !

체크 예외를 런타임 예외로 바꾼다면, 반드시 기존 예외를 포함해야 한다. 기존 예외를 포함하지 않으면 Stack Trace에서 어떤 문제 때문에 실제 예외가 발생했는지 확인할 수 있는 방법이 없어진다.

Repository

체크 예외를 런타임 예외로 변경할 때, 체크 예외를 런타임 예외로 반드시 한번 더 감싸준다. 그렇게 해야지만 StackTrace에서 예외 히스토리를 정상적으로 확인할 수 있다.

static class Repository{
    public void call() {
        try {
            runSQL();
        }catch (SQLException e){
            // 예외를 포함한다 → StackTrace 정상 출력됨.
            throw new RuntimeSQLException(e);
        }
    }

    private void runSQL() throws SQLException {
        throw new SQLException("ex");
    }
}

🎈 반드시 RuntimeSQLException(e)를 통해 한번 더 예외를 감싸준다. → 이렇게 설정해야지 stackTrace에서 정상적인 값을 볼 수 있다.

테스트

@Test
void printEx() {
    Controller controller = new Controller();

    try {
        controller.request();
    } catch (Exception e) {
        log.info("ex",e);
    }

}


caused by 줄을 통해 어디서 예외가 터졌는지 확인 가능해진다.

순서대로 해석하면
1. RuntimeException이 터짐. 그 옆에 로그 메세지
2. Repository.call에 의해 상위 Service로 예외 던짐
3. Service.logic에 의해 상위 Controller로 예외 던짐
4. 그 예외를 printEx 테스트에서 받음 (log.info("ex", e);)
5. 그 밑에 caused by를 통해 어디서 최초에 발생했다는 것을 보여준다.

🎈 로그를 출력할 때 마지막 파라미터에 예외를 넣어주면 로그에 스택 트레이스를 출력할 수 있다.

  • log.info("message = {}", "message", e); 여기에서 마지막에 e를 전달하는 것을 확인할 수 있다. 이렇게 하면 스택 트레이스에 로그를 출력할 수 있다. (실무에서는 항상 로그를 사용해야 한다)

체크 예외를 포함해서 던져준다면, 위 이미지처럼 StackTrace가 정상적으로 출력되는 것을 볼 수 있다. 무슨 말이냐면 RuntimeSQLException이 발생했는데, 얘는 최초에 SQLException 때문에 발생했다는 것을 잘 보여준다.

체크예외를 런타임 예외로 감싸서 던져주지 않으면 예외의 최초 원인이 되는 곳이 무엇인지 어디인지 확인할 수 없는 문제가 발생한다. (예외를 포함하지 않아서 기존에 발생한 java.sql.SQLException 과 스택 트레이스를 확인할 수 없다. 변환한 RuntimeSQLException 부터 예외를 확인할 수 있다.) 그렇기 때문에 체크 예외를 포함해서 던져주자 !
(만약 실제 DB에 연동했다면 DB에서 발생한 예외를 확인할 수 없는 심각한 문제가 발생한다.)

static class RuntimeSQLException extends RuntimeException {
        public RuntimeSQLException(Throwable cause) {
            super(cause);
        }
    }

Throwable을 통해 기존 예외를 가질 수 있게 하자 !

정리

체크 예외에서 새로운 예외로 감싸서 변경해줄 때는, 반드시 기존의 예외를 감싸서 던져줘야 한다.

  • 그렇지 않을 경우 stackTrace에서 최초 예외가 발생한 이유를 확인할 수 없게 된다.
profile
back-end, 지속 성장 가능한 개발자를 향하여

0개의 댓글