본문 바로가기
spring

스프링 DB 1편 - 3

by 오우지 2022. 12. 12.

5. 자바 예외 이해

https://github.com/effectiveJava-study/java-study/blob/main/week9/%EB%B0%B1%EA%B8%B0%EC%84%A0%20%EC%9E%90%EB%B0%94%20%EC%8A%A4%ED%84%B0%EB%94%94%209%EC%A3%BC%EC%B0%A8%20-%20%ED%98%B8%EC%A7%84.md

 

GitHub - effectiveJava-study/java-study: 자바 기초 스터디

자바 기초 스터디. Contribute to effectiveJava-study/java-study development by creating an account on GitHub.

github.com

 

상위 예외를 catch로 잡으면 하위 예외가 모두 잡히므로 Throwable이 아닌 Exception부터 catch 해야한다.

Exception은 모두 체크예외지만 RuntimException와 그 하위 Exception들은 언체크 예외로 컴파일러가 체크하지 않는다.

 

예외 기본 규칙

1. 예외는 잡아서 처리하거나 던져야 한다.

2. 예외는 잡거나 던질 때 지정한 예외 뿐만 아니라 하위 예외도 처리된다.

 

* 예외를 처리하지 못하고 계속 던지면

main() 스레드의 경우 예외 로그를 출력하며 시스템이 종료되지만, 웹 어플리케이션의 경우 여러 사용자의 요청을 처리하기 때문에 WAS가 해당 예외를 받아서 오류 페이지를 보여준다

 

체크 예외 기본 이해

Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외로 RuntimeException은 그 밖의 예외다. 체크 예외는 잡아서 처리하거나 밖으로 던져야 한다.

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

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

    /**
     * 체크 예외를 밖으로 던지는 코드
     * 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언해야 한다.
     * @throws MyCheckedException
     */
    public void callThrow() throws MyCheckedException {
        repository.call();
    }
}

    @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);
    }

위의 서비스를 사용하는 테스트 코드가 아래와 같고 모두 정상 실행된다.

 

체크 예외를 처리해주지 않으면 컴파일 에러가 나게 된다.

하지만 실제로 모든 체크 예외를 개발자가 처리해야 한다면 신경쓰고 싶지 않은 예외까지 모두 챙겨야 한다.

 

언체크 예외 기본 이해

언체크 예외는 throws를 생략할 수 있고, 자동으로 예외를 던진다.

 

 

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

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

    /**
     * 예외를 잡지 않아도 자연스럽게 상위로 넘어간다.
     * 체크 예외와 다르게 throws를 해주지 않아도 된다.
     */
    public void callThrow() {
        repository.call();
    }
}

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

@Test
void unchecked_throw() {
    Service service = new Service();
    Assertions.assertThatThrownBy(service::callThrow);
}

언체크 예외는 주로 생략하지만 중요한 예외의 경우 선언해두면 호출하는 개발자가 예외 발생 가능성을 인지할 수 있다.

 

장점: 신경쓰고 싶지 않은 언체크 예외를 모두 무시할 수 있다, 신경쓰고 싶지 않은 예외의 의존관계를 참조하지 않아도 된다.

단점: 개발자가 실수로 예외를 누락할 수 있다.

 

체크 예외 활용

1. 기본적으로 런타임 예외를 사용하자.

2. 비즈니스 로직상 의도적으로 던지는 예외에만 사용한다. ex) 계좌 이체 등의 예외

 

체크 예외의 상황을 생각해보자. 리포지토리가 DB에 접근해서 문제가 있을 때 SQLException이라는 체크 예외를 던지는데 이런 문제는 애플리케이션 로직에서 처리할 수 없고 밖으로 던지게 된다.

 

웹 어플리케이션이라면 서블릿 오류페이지나 ControllerAdvice에서 예외를 공통으로 처리한다.

 

체크 예외는 두 가지 문제가 있다.

1. 복구 불가능한 예외: SQLException같은 문제는 컨트롤러, 서비스 단에서 대부분 복구가 불가능하다. 따라서 공통으로 일관성있게 처리하고 오류 로그를 남겨 개발자가 인지해야 한다.

2. 의존 관계에 대한 문제: throws를 통해서 전파되기 때문에 컨트롤러에서 SQLException(jdbc 기술)에 의존하게 된다.

 

물론 Exception으로 의존관계 없이 한번에 던져버릴 수 있지만 중요한 체크 예외를 처리할 수 없다는 단점이 있다. 따라서 런타임 예외를 활용해야 한다.

 

언체크 예외 활용

런타임 예외를 사용해보면

SQLException을 RuntimeSQLException으로 변환했다.

 @Test
void unchecked() {
    Controller controller = new Controller();
    Assertions.assertThatThrownBy(() -> controller.request())
            .isInstanceOf(RuntimeSQLException.class);
}
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 Repository {
    public void call() {
        try {
            runSQL();
        } catch (SQLException e) {
            throw new RuntimeSQLException(e);
        }
    }

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

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

이렇게 만들어주면 컨트롤러나 서비스에 대한 의존관계가 발생하지 않는다. JDBC에서 JPA로 바꿨을 때도 변화에 보다 용이하다.

 

처음 자바를 만들 때는 체크 예외가 더 나은 선택이었지만 체크 예외의 수가 수도없이 많아지면서 대부분의 현대 라이브러리들은 런타임 예외를 제공한다.

 

예외 포함과 스택 트레이스

예외 전환시에는 꼭 기존 예외를 포함해야 한다. 그렇지 않으면 스택 트레이스를 확인할 때 심각한 문제가 발생한다.

 

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

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

다음 코드를 실행했을 때 아래 call에서 catch한 SQLException을 커스텀한 exception에 넣어주지 않으면

원래 JDBC에서 SQLException을 던졌을때의 Exception 이유가 출력되지 않는다. Caused by 부터 출력이 되지 않는다는 말이다. 

 

따라서 예외를 전환할 때는 꼭 기존 예외를 포함해야 한다.

 

 

6. 스프링의 예외처리, 반복

체크예외와 인터페이스

이제 이전에 적었던 스프링 코드를 생각해보자. 아직 SQLException을 해결하지 못해서 Controller단까지 SQLException을 throws하고 있었는데 이 코드를 변경에 용이하게 만들기 위해 interface를 선언한다고 생각해보자.

 

public interface MemberRepositoryEx {
    Member save(Member member) throws SQLException;
    Member findById(String memberId) throws SQLException;
    void update(String memberId, int money) throws SQLException;
    void delete(String memberId) throws SQLException;
}

이 방법은 문제가 있는데 interface에서 SQLException을 던져줘야 한다. 유연성을 위한 인터페이스가 JDBC에 종속적인 코드가 되어버린다. 위에서 했던 대로 체크 예외를 런타임 예외로 전환해서 서비스 계층에 던져주면 해결할 수 있다.

 

@Override
public Member save(Member member) {
    String sql = "insert into member(member_id, money) values (?, ?)";

    Connection con = null;
    PreparedStatement pstmt = null;

    try {
        con = getConnection();
        pstmt = con.prepareStatement(sql);
        pstmt.setString(1, member.getMemberId());
        pstmt.setInt(2, member.getMoney());
        pstmt.executeUpdate();
        return member;
    } catch (SQLException e) {
        throw new MyDbException(e); // 이제 RuntimeException을 extends한 것을 대신 던져준다
    } finally {
        close(con, pstmt, null);
    }
}

이런 식으로 모두 MyDbException으로 변환해서 넘겨주면 서비스, 컨트롤러에서 굳이 체크해줄 필요가 없어지고 ControllerAdvice에서 공통적으로 처리하게 될 것이다.

 

하지만 이런 식으로는 예외를 구분할 수 없다는 단점이 있다. 복구 가능한 예외 상황을 가정했을 때 복구불가능한 Exception과 구분해서 던져주고, 처리하면 되는데 DB가 제공하는 errorCode를 받아서 다른 Exception을 던져주면 된다. 

 

public class MyDuplicateKeyException extends MyDbException {
    public MyDuplicateKeyException() {
    }

    public MyDuplicateKeyException(String message) {
        super(message);
    }

    public MyDuplicateKeyException(String message, Throwable cause) {
        super(message, cause);
    }

    public MyDuplicateKeyException(Throwable cause) {
        super(cause);
    }
}

MyDbException을 extends 함으로써 Exception을 하나로 묶어줬다.

 

@Slf4j
@RequiredArgsConstructor
static class Service {
    private final Repository repository;

    public void create(String memberId) {

        try {
            repository.save(new Member(memberId, 0));
            log.info("savedId={}", memberId);
        } catch (MyDuplicateKeyException e) {
            log.info("키 중복, 복구 시도");
            String retryId = generateNewId(memberId);
            log.info("retryId={}", retryId);
            repository.save(new Member(retryId, 0));
        } catch (MyDbException e) {
            log.info("데이터 접근 계층 예외", e);
            throw e;
        }
    }
    private String generateNewId(String memberId) {
        return memberId + new Random().nextInt(10000);
    }
}
@RequiredArgsConstructor
static class Repository {
    private final DataSource dataSource;

    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values(?,?)";
        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = dataSource.getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            // h2 db
            if (e.getErrorCode() == 23505) {
                throw new MyDuplicateKeyException(e);
            }
            throw new MyDbException(e);
        } finally {
            JdbcUtils.closeStatement(pstmt);
            JdbcUtils.closeConnection(con);
        }
    }
}

만약 아이디가 같으면 새로운 ID를 발급받아서 save 하는 로직이라고 했을 때 Repository에서 던져준 MyDuplicateKeyException을 서비스에서 받아서 새로운 아이디를 발급받아서 다시 save 해주면 특정 기술에 의존하지 않고도 문제를 복구할 수 있다.

 

하지만 위처럼 수도 없이 많은 예외를 DB가 바뀔 때마다 만드는건 말이 안된다. 이를 스프링이 해결해준다.

스프링 예외 추상화 이해

스프링은 데이터 접근 계층에 대한 예외들을 정리해 일관된 예외 계층을 제공하며 오류 코드에 따른 예외 변환기도 제공한다.

 

그 전에 변환기가 없다면

@Test
void sqlExceptionErrorCode() {
    String sql = "select bad grammar";

    try {
        Connection con = dataSource.getConnection();
        PreparedStatement pstmt = con.prepareStatement(sql);
        pstmt.executeQuery();
    } catch (SQLException e) {
        Assertions.assertThat(e.getErrorCode()).isEqualTo(42122);
        int errorCode = e.getErrorCode();
        log.info("errorCode={}", errorCode);
        log.info("error", e);
    }
}

이런 코드를 전부 다 처리해줘야 할텐데 현실성이 없다.

 

@Test
void exceptionTranslator() {
    String sql = "select bad grammar";
    try {
        Connection con = dataSource.getConnection();
        PreparedStatement pstmt = con.prepareStatement(sql);
        pstmt.executeQuery();
    } catch (SQLException e) {
        assertThat(e.getErrorCode()).isEqualTo(42122);

        SQLErrorCodeSQLExceptionTranslator exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
        DataAccessException resultEx = exceptionTranslator.translate("select", sql, e);
        log.info("resultEx", resultEx);
        assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
    }
}

Translator를 만들어주고 변환하면 적합한 Exception을 반환받을 수 있다.

 

 

JDBC 반복 문제 해결 - JdbcTemplate

jdbc를 사용하기 때문에 발생했던 반복 문제들이 있었다. 저기 위에도 있는데 getConnection, PreparedStatement, 쿼리 실행, 결과 바인딩, 예외 변환기 실행, 리소스 종료 등이 있다. 바뀌는건 SQL과 결과 밖에 없는데...

 

이런 반복을 효과적으로 처리하는 방법이 템플릿 콜백 패턴이다. 이를 구현한게 JdbcTemplate이다.

 

    private final JdbcTemplate template;

    public MemberRepositoryV5(DataSource dataSource) {
        this.template = new JdbcTemplate(dataSource);
    }

    @Override
    public Member save(Member member) {
        String sql = "insert into member(member_id, money) values (?, ?)";
        int update = template.update(sql, member.getMemberId(), member.getMoney());
        return member;
//        아..
//        Connection con = null;
//        PreparedStatement pstmt = null;
//
//        try {
//            con = getConnection();
//            pstmt = con.prepareStatement(sql);
//            pstmt.setString(1, member.getMemberId());
//            pstmt.setInt(2, member.getMoney());
//            pstmt.executeUpdate();
//            return member;
//        } catch (SQLException e) {
//            throw exTranslator.translate("save", sql, e);
//        } finally {
//            close(con, pstmt, null);
//        }
    }

노얼탱

'spring' 카테고리의 다른 글

스프링 DB 2편 - 2  (0) 2023.01.03
스프링 DB 2편 - 1  (0) 2022.12.21
스프링 DB 1편 - 2  (0) 2022.11.30
스프링 DB 1편 - 1  (0) 2022.11.02
mvc2 - 타입컨버터  (0) 2022.03.02