Spring, Apache, Java

예외2

cwchoiit 2023. 12. 4. 10:49
728x90
반응형
SMALL

https://cwchoiit.tistory.com/68

 

예외

자바에서 예외는 크게 두 가지(체크 예외, 언체크 예외)로 나뉘어진다. 체크 예외 컴파일러가 체크하는 예외이다. 체크 예외는 잡아서 처리하거나 또는 밖으로 던지도록 선언해야한다. 그렇지

cwchoiit.tistory.com

이전 포스팅인 1편에서 기본적인 예외의 내용을 알아보았고 이제는 스프링과 관련된 내용을 작성하고자 한다.

 

만약, 데이터 접근 시 에러가 발생했을 때 그 에러를 복구하거나 플랜B로 처리하고 싶다면 어떻게 할까?

예를 들어, 새로운 데이터를 데이터베이스에 생성하고자 하는데 새로운 데이터의 기본키가 이미 존재한다면 어떻게 하면 좋을까?

 

프로젝트나 비즈니스마다 다르겠지만 이런 경우도 있을것이다.

새로운 값으로 또는 다른 값으로 대체하여 추가하자.


이런 경우를 알아보자.

우선, DB마다 에러가 발생하면 에러 코드를 반환하는데 그 에러 코드가 DB별로 상이하다. 일단 이 부분이 굉장히 큰 문제거리가 되지만 우선 H2 데이터베이스를 사용한다고 했을 때, 중복 PK 에러 코드는 '23505'이다.

 

그 경우에는 새로운 PK값으로 재시도를 할 것이다. 그러기 위해선 SQLException은 체크 예외이기 때문에 런타임 예외로 커스텀을 해야한다. 그렇지 않으면 의존 관계 문제가 발생하면서 동시에 SQLException 하위 에러 모두 이 에러로 반환될 수 있다.

 

728x90
반응형
SMALL

중복 키 에러에 대한 Custom Exception

package com.example.jdbc.exception;

public class MyDuplicateKeyException extends RuntimeException {

    public MyDuplicateKeyException() {
    }

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

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

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

 

이제 중복키 관련 에러가 발생하면 런타임 예외를 상속받은 MyDuplicateKeyException을 반환할 것이다.

아래 코드는 DB에 INSERT 쿼리를 날렸을 때 SQLException이 발생하는 에러를 catch에서 잡아준다. 이 때 이 에러의 에러 코드가 23505(H2의 중복키 에러코드)일 경우 위에서 새로 만든 MyDuplicateKeyException을 던진다.

@RequiredArgsConstructor
static class Repository {
    private final DataSource dataSource;

    public Member save(Member member) {
        String sql = "INSERT INTO member(member_id, money) values(?, ?)";
        Connection connection = null;
        PreparedStatement pstmt = null;

        try {
            connection = dataSource.getConnection();
            pstmt = connection.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            // 데이터 베이스의 오류 중 특정 예외는 복구하고 싶을 수 있는 경우가 있다. 그럴 때 이렇게 우리만의 에러를 만들어서 리턴해주고 받는 쪽은
            // 그 에러인지 확인해서 그 에러라면 복구 로직을 작성하면 된다.
            if (e.getErrorCode() == 23505) {
                throw new MyDuplicateKeyException(e);
            }
            throw new MyDbException(e);
        } finally {
            JdbcUtils.closeStatement(pstmt);
            JdbcUtils.closeConnection(connection);
        }
    }
}

 

그럼 이제 서비스는 이 리포지토리를 호출한다. 

@RequiredArgsConstructor
static class Service {
    private final Repository repository;

    public void create(String memberId) {
        try {
            repository.save(new Member(memberId, 0));
            log.info("saveId={}", 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);
    }
}

 

서비스에서 리포지토리가 가진 create() 메소드를 호출할 때 catch에서 MyDuplicateKeyException을 받는 경우엔 새로운 키를 생성하는 메소드 generateNewId()를 호출한다. 해당 메소드로부터 돌려받는 키로 다시 새로운 멤버를 만드는 save() 메소드를 호출하여 재실행한다.

 

이제 테스트 코드로 실행해보자.

@Slf4j
public class ExTranslatorV1Test {

    Repository repository;
    Service service;

    @BeforeEach
    void init() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        repository = new Repository(dataSource);
        service = new Service(repository);
    }

    @Test
    void duplicateKeySave() {
        service.create("myId");
        service.create("myId"); // 같은 ID 저장 시도
    }
    
    ...
}

 

Spring Boot Test가 아니기 때문에 Test를 실행하기 전 DataSource를 직접 만들어 repository에게 전달해줘야 한다. 그 부분이 @BeforeEach. 그리고 @Test 어노테이션이 붙은 메소드를 실행해보자. 같은 아이디를 사용해서 create() 메소드를 두번 호출하기 때문에 두번째 생성 시 MyDuplicateKeyException이 발생한다. 그러나 해당 예외를 처리하면서 다른 ID로 재시도를 하기 때문에 myId를 가진 멤버 한명과 새로운 아이디를 가진 멤버 한명 이렇게 두 명의 멤버가 정상적으로 만들어진다.

 

결과

 

전체 코드

package com.example.jdbc.exception.translator;

import com.example.jdbc.connection.ConnectionConst;
import com.example.jdbc.domain.Member;
import com.example.jdbc.exception.MyDbException;
import com.example.jdbc.exception.MyDuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.support.JdbcUtils;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Random;

import static com.example.jdbc.connection.ConnectionConst.*;

@Slf4j
public class ExTranslatorV1Test {

    Repository repository;
    Service service;

    @BeforeEach
    void init() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        repository = new Repository(dataSource);
        service = new Service(repository);
    }

    @Test
    void duplicateKeySave() {
        service.create("myId");
        service.create("myId"); // 같은 ID 저장 시도
    }

    @RequiredArgsConstructor
    static class Service {
        private final Repository repository;

        public void create(String memberId) {
            try {
                repository.save(new Member(memberId, 0));
                log.info("saveId={}", 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;
            }
        }

        public 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 connection = null;
            PreparedStatement pstmt = null;

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

 

 

그런데, 이렇게 막상 코드를 짜보니 이걸 일일이 다 할 수 있을까? 라는 의문이 생긴다. 그도 그럴것이 데이터베이스에서 반환되는 에러코드만 따져도 수십개 수백개는 될 것같은데 이게 데이터베이스 별로 다르다는 건 또 다른 문제가 된다. 이 문제를 스프링이 해결해준다.

 

스프링 데이터 접근 예외 계층

 

스프링이 만들어준 데이터 접근 예외 계층 구조도이다. 그림에서 볼 수 있듯 가장 상위 예외는 RuntimeException이다. 즉, 스프링이 제공하는 데이터 접근 예외들 모두가 언체크 예외라는 의미이다. 그 바로 하위에는 DataAccessException이 있다. 그리고 여기서 두 분류로 분개를 하는데 하나는 NonTransient 나머지 하나는 Transient이다. 

 

Transient는 일시적이라는 의미이다. 즉, 이 에러부터 하위 에러는 다시 시도했을 때 성공할 가능성이 있는 경우이다. 예를 들면 쿼리 타임아웃이나 락과 관련된 에러이다. 데이터베이스의 부하가 줄어들면 쿼리 타임아웃에 걸리지 않거나 락이 풀린 상태에서 접근했을 땐 문제 없이 성공할 수 있는 그런 경우들을 말한다.

 

NonTransient는 일시적이지 않다는 의미이다. 같은 SQL을 그대로 반복해서 실행하면 실패한다. SQL문법 오류인 경우 같은 쿼리를 백날 다시 날려도 똑같이 에러가 날 것이다. 데이터베이스 제약 조건에 위배되는 경우 언제나 위배가 될 것이다 제약 조건이 변경되기 전까지.

 

 

이 스프링이 제공하는 데이터 접근 예외를 사용해서 일일이 커스텀할 필요 없이 가져다가 사용해보자.

package com.example.jdbc.exception.translator;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;

import javax.sql.DataSource;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

import static com.example.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;

@Slf4j
public class SpringExceptionTranslatorTest {

    DataSource dataSource;

    @BeforeEach
    void init() {
        dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    }

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

        try {
            Connection connection = dataSource.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            preparedStatement.executeQuery();
        } catch (SQLException e) {
            int errorCode = e.getErrorCode();
            assertThat(errorCode).isEqualTo(42122);

            SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
            DataAccessException resultEx = exTranslator.translate("selectQuery", sql, e);
            log.info("resultEx", resultEx);

            assert resultEx != null;
            assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
        }
    }
}

 

위 코드에서 주의깊게 볼 부분은 이 부분이다.

SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("selectQuery", sql, e);
log.info("resultEx", resultEx);

 

SQLException이 터지는 경우 catch에서 해당 에러를 다루는데, 그 에러의 종류를 스프링이 위에서 봤듯이 만들어 놨기 때문에 가져다가 사용하면 된다. 그 에러를 가져오는 방법은 SQLErrorCodeSQLExceptionTranslator를 사용하면 된다. 

 

새로운 SQLErrorCodeSQLExceptionTranslator객체를 만들어야 하는데 객체를 만들 때 DataSource 객체를 파라미터로 넘겨야한다. 그렇게 만들어진 SQLErrorCodeSQLExceptionTranslator객체를 이용해 실제 SQLException을 SQL문과 같이 translate()에 넘겨주면 스프링이 알아서 변환해준다. 그 객체의 타입은 DataAccessException이고 이 객체는 위 구조도에서 봤듯 가장 상위 에러 객체이다. 

 

그리고 실제 이 변환된 에러의 클래스는 BadSqlGrammarException이다. 그것을 확인하는 코드가 다음 한 줄이다.

assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);

 

이렇게 우리 대신 스프링이 알아서 다 만들어준다. 근데 그럼 스프링은 각기 다른 DB의 에러코드를 어떻게 알고 이런 에러를 반환해주는걸까? 스프링은 이미 다 정의를 해놨다. 그 정의 파일은 다음 파일이다. sql-error-codes.xml

 

이 파일을 열어보면 다음 사진처럼 생겼다.

 

이렇게 모든 코드가 Database별로 정의가 되어 있고, 해당 코드이면 어떤 에러를 뱉을지 모두 다 정의가 되어있다. 이렇게 우리 대신 정의된 스프링이 만들어준 것을 가져다가 사용하면 일일이 커스텀하여 에러를 만들 필요는 없어지고 그렇기에 실용적인 코드를 작성할 수 있다.

 

그러나, 물론 스프링이 만든 데이터 접근 예외를 가져다가 사용을 한다면 의존 관계에서 독립적이지 못하게 되는 서비스 코드가 만들어진다. 즉, 다음 패키지가 서비스 또는 리포지토리에 자리잡게 된다.

import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.BadSqlGrammarException;

 

그럼 이러한 의존 관계에 대한 독립성과 실용적인 코드작성 사이에 Trade-off가 생기는 것인데, 이는 프로젝트 별 어떤 선택을 하냐에 달려있다. 사실 모든 데이터베이스가 보내는 에러 코드를 다 커스텀한 에러로 변환하는 것은 거의 불가능에 가깝고 너무나 비효율적이기 때문에 이런 경우 trade-off를 가져가는 것이 방안이 될 수 있다. 순수함에 취해 효율과 실용성을 잃는다면 그것또한 다시 고려해 봐야 하지 않을까?

 

그럼에도 DataAccessException은 스프링이 제공하는 데이터 접근 예외라 어떤 특정 기술(JDBC, JPA,...)에 종속적이지 않고 기술을 변경하더라도 그대로 사용할 수 있다. 

 

728x90
반응형
LIST