Spring

[인프런/스프링 DB 1편] 5. 자바 예외

주니어주니 2023. 6. 24. 00:29

 

 

1. 예외 계층

 

 

 

 

  • Object : 예외의 최상위 부모도 Object
  • Throwable : 최상위 예외 - 하위에 Exception과 Error가 있음. 이 예외는 잡으면 안됨. 
  • Error : 메모리 부족이나 심각한 시스템 오류같이 애플리케이션에서 복구 불가능한 시스템 예외. 이 예외는 잡으면 안됨. (처리할 수 X)
    • 상위 예외를 catch로 잡으면 그 하위 예외까지 함께 잡음.
      -> Throwable 예외를 잡으면 안되는 이유 -> Error 예외까지 잡아버릴 수 있기 때문 (잡으면 안되는데)
      -> Exception 부터 잡아야 함 
    • Error도 언체크 예외

  • Exception : 체크 예외 
    • 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외
    • Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외
      단, RuntimeException은 아님

  • RuntimeException : 언체크 예외 (= 런타임 예외)
    • RuntimeException과 그 하위 예외는 모두 컴파일러가 체크하지 않는 언체크 예외

 

 

 

 

예외 처리 2가지 

  1. 잡아서 처리 (그 자식 예외도 모두 잡음)
  2. 던짐 (그 자식 예외도 모두 던짐) -> 처리하지 못하고 계속 던질 때는 오류 페이지 

 

 

 

2. 체크 예외 (Exception)

 

  • Exception을 상속받으면 '체크 예외'
  • Exception과 그 하위 예외는 모두 컴파일러가 체크하는 '체크 예외'
    단, RuntimeException은 예외
  • 체크예외는 잡아서 처리하거나, 밖으로 던지도록 선언하거나 둘중 하나 필수

 

package hello.jdbc.exception.basic;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

@Slf4j
public class CheckedTest {

    @Test
    void checked_catch() {
        Service service = new Service();
        service.callCatch();
    }

    @Test
    void checked_throw() {
        Service service = new Service();
        Assertions.assertThatThrownBy(() -> service.callThrow())
                .isInstanceOf(MyCheckedException.class);
    }

    /**
     * Exception을 상속받은 예외는 체크 예외가 됨
     */
    static class MyCheckedException extends Exception {
        public MyCheckedException(String message) {
            super(message);
        }
    }

    /**
     * Checked 예외는 잡아서 처리하거나, 던지거나 둘 중 하나 필수
     */
    static class Service {
        Repository repository = new Repository();

        /**
         * 예외 잡아서 처리
         */
        public void callCatch() {
            try {
                repository.call();
            } catch (MyCheckedException e) {
                log.info("예외 처리, message={}", e.getMessage(), e);
            }
        }

        /**
         * 체크 예외를 밖으로 던짐 - throws 예외 선언 필수
         */
        public void callThrow() throws MyCheckedException {
            repository.call();
        }
    }

    /**
     * 예외 발생
     */
    static class Repository {
        public void call() throws MyCheckedException {
            throw new MyCheckedException("ex");
        }
    }
}

 

 

(1) 예외를 잡아서 처리 

@Test
void checked_catch() {
    Service service = new Service();
    service.callCatch();
}

/**
 * Exception을 상속받은 예외는 체크 예외가 됨
 */
static class MyCheckedException extends Exception {
    public MyCheckedException(String message) {
        super(message);
    }
}

/**
 * Checked 예외는 잡아서 처리하거나, 던지거나 둘 중 하나 필수
 */
static class Service {
    Repository repository = new Repository();

    /**
     * 예외 잡아서 처리
     */
    public void callCatch() {
        try {
            repository.call();
        } catch (MyCheckedException e) {
            log.info("예외 처리, message={}", e.getMessage(), e);
        }
    }
}

/**
 * 예외 발생
 */
static class Repository {
    public void call() throws MyCheckedException {
        throw new MyCheckedException("ex");
    }
}

 

실행 순서

  1. test -> service.callCatch() -> repository.call() [예외 발생, 던짐]
  2. repository.call() -> service.callCatch() [예외 잡아서 처리] -> test

 

  • service.callCatch()에서 예외를 잡아서 처리했기 때문에 테스트까지 예외가 올라오지 않음 
  • catch (Exception e) 상위타입 Exception을 넣어도 잡을 수 있음 -> 하위 타입까지 모두 잡음

 

 

(2) 예외를 밖으로 던짐 

@Test
void checked_throw() {
    Service service = new Service();
    Assertions.assertThatThrownBy(() -> service.callThrow())
            .isInstanceOf(MyCheckedException.class);
}

/**
 * Exception을 상속받은 예외는 체크 예외가 됨
 */
static class MyCheckedException extends Exception {
    public MyCheckedException(String message) {
        super(message);
    }
}

/**
 * Checked 예외는 잡아서 처리하거나, 던지거나 둘 중 하나 필수
 */
static class Service {
    Repository repository = new Repository();

    /**
     * 체크 예외를 밖으로 던짐 - throws 예외 선언 필수
     */
    public void callThrow() throws MyCheckedException {
        repository.call();
    }
}

/**
 * 예외 발생
 */
static class Repository {
    public void call() throws MyCheckedException {
        throw new MyCheckedException("ex");
    }
}

 

실행 순서 

  1. test -> service.callThrow() -> repository.call() [ 예외 발생, 던짐 ]
  2. repository.call() -> service.callThrow() [ 예외 던짐 ] -> test [ 예외 도착 ]

 

  • service.callThrow() 에서 예외를 처리하지 않고, 밖으로 던짐 -> 테스트까지 예외가 올라옴
  • 던질 때도 상위 타입인 Exception을 던질 수 있음
  • 체크 예외를 처리할 수 없을 때는 "throws 예외" 를 사용해서 밖으로 던질 예외를 필수로 지정 !
    (던지지 않으면 컴파일 오류)
    • 장점 : 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아줌
    • 단점 : 모든 체크 예외를 잡거나 던져야 함 -> 번거로움

 

 

 

3. 언체크 예외 RuntimeException

 

  •  RuntimeException을 상속받으면 '언체크 예외
  • 언체크 예외는 컴파일러가 예외를 체크하지 않음
    • 장점 : 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있음
    • 단점 : 실수로 예외를 누락할 수 있음

  • 체크 예외 vs 언체크 예외 
    • 체크 예외 : 예외를 잡아서 처리하지 않으면, throws 에 던지는 예외 선언 필수
    • 언체크 예외 : 예외를 잡아서 처리하지 않아도, throws 생략 가능

 

package hello.jdbc.exception.basic;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

@Slf4j
public class UncheckedTest {

    @Test
    void unchecked_catch() {
        Service service = new Service();
        service.callCatch();
    }

    @Test
    void unchecked_throw() {
        Service service = new Service();
        Assertions.assertThatThrownBy(() -> service.callThrow())
                .isInstanceOf(MyUnCheckedException.class);
    }

    /**
     * RuntimeException을 상속받은 예외는 언체크 예외가 됨
     */
    static class MyUnCheckedException extends RuntimeException {
        public MyUnCheckedException(String message) {
            super(message);
        }
    }

    /**
     * UnChecked 예외는 예외를 잡거나, 던지지는 않아도 됨
     * -> 예외를 잡지 않으면 자동으로 던짐
     */
    static class Service {
        Repository repository = new Repository();

        /**
         * 필요한 경우 예외를 잡아서 처리
         */
        public void callCatch() {
            try {
                repository.call();
            } catch (MyUnCheckedException e) {
                // 예외처리 로직
                log.info("예외 처리, message={}", e.getMessage(), e);
            }
        }

        /**
         * 예외를 잡지 않아도 상위로 넘어감 -> 체크예외와 다른점
         */
        public void callThrow() {
            repository.call();
        }
    }

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

 

 

언체크 예외를 잡아서 처리 

try {
    repository.call();
} catch (MyUnCheckedException e) {
    // 예외처리 로직
    log.info("예외 처리, message={}", e.getMessage(), e);
}

 

 

언체크 예외를 밖으로 던짐 - throws 선언 생략 -> 컴파일러가 체크하지 않음 

public void callThrow() {
    repository.call();
}

 

 

언체크 예외를 밖으로 던짐 - throws 선언 

public void callThrow() throws MyUncheckedException {
    repository.call();
}

 

 

 

 

4. 체크 예외 활용 - 체크 예외의 문제점

 

 

📌 기본적으로 언체크(런타임) 예외 사용 

📌 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용

(예- 계좌 이체 실패 예외, 결제시 포인트 부족 예외, 로그인 아이디, 패스워드 불일치 예외)

 

 

 

1) 체크 예외의 문제점 - 그림

  • 리포지토리(DB 접근)에서는 SQLException 체크 예외,
    NetworkClient(외부 네트워크 접속)에서는 ConnectException 체크 예외를 던지는 경우

  • 서비스에서 리포지토리, NetworkClient를 둘다 호출
    • 체크 예외인 SQLException, ConnectException 을 처리해야 함
    • 서비스는 이 둘을 처리할 수 X (-> 데이터베이스 문제 / 네트워크 연결 실패 라서)
    • 밖으로 던짐 -> throws SQLException, ConnectException

  • 컨트롤러도 두 예외를 처리할 수 X 
    • 밖으로 던짐 -> throws SQLException, ConnectException

  • 웹 애플리케이션은 서블릿의 오류 페이지 또는 스프링MVC가 제공하는 ControllerAdvice에서 이런 예외를 공통으로 처리해줌
    • 이렇게 해결 불가능한 공통 예외는 별도의 오류 로그를 남기고, 개발자가 오류를 빨리 인지할 수 있도록 전달

 

 

2) 체크 예외의 문제점 - 코드 

 

package hello.jdbc.exception.basic;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import java.net.ConnectException;
import java.sql.SQLException;

public class CheckedAppTest {

    @Test
    void checked() {
        Controller controller = new Controller();
        Assertions.assertThatThrownBy(() -> controller.request())
                .isInstanceOf(Exception.class);
    }

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

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

    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

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

    static class NetworkClient {
        public void call() throws ConnectException {
            throw new ConnectException("연결 실패");
        }
    }

    static class Repository {
        public void call() throws SQLException {
            throw new SQLException("ex");
        }
    }
}
  • 서비스
    • 해당 체크 예외 처리 X -> logic() throws SQLException, ConnectException 선언
  • 컨트롤러
    • 해당 체크 예외 처리 X -> request() throws SQLException, ConnectException 선언

 

 

3) 체크 예외의 문제점 정리

 

  1. 복구 불가능한 예외
    • 데이터베이스나 시스템에서 올라온 예외는 대부분 복구 불가능 
      -> 서비스나 컨트롤러가 해결할 수 없음
      -> 일관성 있는 공통 처리 필요
      -> 오류 로그 남기고 개발자에게 빨리 인지시키기
      -> 서블릿 필터, 스프링 인터셉터, 스프링의 ControllerAdvice 사용 
  2. 의존 관계에 대한 문제 
    • 처리할 수 없어도 체크 예외이기 때문에 서비스, 컨트롤러에서 일단 받아서 throws 로 던지는 예외 선언 필요
    • throws SQLException, ConnectException
      -> 서비스, 컨트롤러에서 java.sql.SQLException (JDBC)을 의존하는 것
    • JDBC를 JPA로 변경한다면, SQLException 코드를 모두 JPA에 의존하도록 고쳐야 함
    • 의존관계를 없애기 위해 throws Exception 으로 상위 예외를 던지면
      -> 코드는 깔끔해지는 것 같지만, 중요한 체크 예외까지 다 던져버림
      -> 잡아서 해결할 수가 없음

 

 

 

5. 언체크(런타임) 예외 활용 

 

1) 런타임 예외 사용 - 그림 

 

  • SQLException 체크 예외를 -> RuntimeSQLException 런타임 예외로 변환
  • ConnectException 체크 예외를 -> RuntimeConnectException 런타임 예외로 변환
  • 런타임 예외 -> 서비스, 컨트롤러는 해당 예외를 처리할 수 없으면 별도의 선언 없이 그냥 두면 됨 

 

 

2) 런타임 예외 사용 - 코드 

package hello.jdbc.exception.basic;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import java.net.ConnectException;
import java.sql.SQLException;

@Slf4j
public class UnCheckedAppTest {

    @Test
    void unchecked() {
        Controller controller = new Controller();
        Assertions.assertThatThrownBy(() -> controller.request())
                .isInstanceOf(Exception.class);
    }

    @Test
    void printEx() {
        Controller controller = new Controller();
        try {
            controller.request();
        } catch (Exception e) {
            // e.printStackTrace();
            log.info("ex", e);
        }
    }

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

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

    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

        public void logic() {
            repository.call();
            networkClient.call();
        }
    }

    static class NetworkClient {
        public void call() {
            throw new RuntimeConnectException("연결 실패");
        }
    }

    static class Repository {
        public void call() {
            try {
                runSQL();
            } catch (SQLException e) {
                throw new RuntimeSQLException(e);
            }
        }

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

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

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

 

예외 전환 

  • 리포지토리에서 SQLException 체크 예외 발생 
    -> 잡아서 RuntimeSQLException 런타임 예외로 전환
    (이때 RuntimeSQLException(e) 로 기존 예외 포함해야 예외 출력시 스택 트레이스에서 기존 예외도 확인할 수 있음!)
  • NetworkClient에서 단순히 기존 체크 예외를 RuntimeConnectException 런타임 예외 발생하도록 함

 

런타임 예외 

  • 복구 불가능한 예외 
    • 서비스나 컨트롤러에서 신경 X -> 일관성있게 공통으로 처리 

  • 의존 관계 
    • 해당 객체가 처리할 수 없으면 그냥 냅둠 -> 위로 올라감
    • throws 선언 생략 -> 의존 관계 발생 X
    • 중간에 기술이 변경되어도 해당 예외를 사용하지 않는 컨트롤러, 서비스에서 코드 변경할 필요 X
      공통으로 처리하는 한곳만 변경하면 됨

  • 문서화 
    • 런타임 예외는 놓칠 수 있기 때문에 문서화 중요

 

원래 - 체크 예외 선호 -> 그래서 자바는 기본적으로 체크 예외 제공이 많음
최근 - 런타임 예외 선호 -> JPA, 스프링은 대부분 런타임 예외 제공

 

 

 

 

6. 예외 포함과 스택 트레이스 

 

📌 예외를 전환할 때 -> 반드시 기존 예외 포함해야 함 

 

 

1) 런타임 예외 생성

// 런타임 예외 생성
static class RuntimeSQLException extends RuntimeException {
    public RuntimeSQLException(Throwable cause) {
        super(cause);
    }
}

 

 

2) 로그에 스택 트레이스 출력

@Test
void printEx() {
    Controller controller = new Controller();
    try {
        controller.request();
    } catch (Exception e) {
        // e.printStackTrace();
        log.info("ex", e);
    }
}
  • 로그 출력 시 마지막 파라미터에 예외를 넣어주면 -> 로그에 stack trace 출력 
  • System.out에 스택 트레이스 출력하려면 -> e.printStackTrace() 사용 ( -> 비추 -> 로그 사용 )

 

 

(1) 기존 예외 포함

public void call() {
    try {
        runSQL();
    } catch (SQLException e) {
    	// 예외 전환 (기존 예외 포함해서 던짐)
        throw new RuntimeSQLException(e);
    }
}

-> 기존에 발생한 SQLException과 스택 트레이스 확인 가능 (기존 예외인 SQL Exception에 대한 정보)

-> DB에서 발생한 원인을 알 수 있음

 

 

 

(2) 기존 예외 포함 X 

public void call() {
    try {
        runSQL();
    } catch (SQLException e) {
    	// 예외 전환 (기존 예외(e) 제외)
        throw new RuntimeSQLException();
    }
}

-> 기존 예외인 SQLException에 대한 정보를 확인할 수 X 

-> 변환한 RuntimeSQLException부터 예외 확인 가능

-> 예외의 원인을 알 수 X