카테고리 없음

[DB] 스프링의 예외 변환기

작은별._. 2024. 8. 4. 15:38
728x90

스프링은 아래와 같이 데이터 접근과 관련한 예외를 추상화해서 제공해주고 있습니다.


 

 

예외의 최상위 예외는 org.springframework.dao.DataAccessException 입니다. 런타임 예외를 상속 받았기 때문에 스프링이 제공하는 데이터 접근 계층의 모든 예외는 런타임 예외입니다. 

DataAccessException 은 크게 NonTransient 예외Transient 예외로 구분할 수 있습니다.

  • Transient : 일시적이라는 뜻으로 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있습니다.
    • 예를 들어 쿼리 timeout, lock과 관련된 오류들입니다. 이런 오류들은 DB 상태가 좋아지거나 lock이 풀렸을 때 다시 시도하면 성공할 수 있습니다.
  • NonTransient : 일시적이지 않다는 뜻으로 같은 SQL을 반복해서 실행해도 실패합니다.
    • SQL 문법 오류나 데이터베이스 제약조건 위배 등이 있습니다.

이제 이 스프링 예외 변환기가 필요한 이유를 먼저 설명하겠습니다.

 

데이터베이스의 오류에 따라서 특정 예외는 따로 처리하여 복구하고 싶은 경우도 있을 것 입니다. 예를 들어 DB에 동일한 ID를 가진 데이터를 저장하고자 하면 SQLException이 발생하는데, 이때 해당 예외를 그냥 두는 것이 아니라 다른 ID로 바꾸어서 저장하도록 처리하고 싶을 수 있을 것입니다.

 

SQLException 에는 데이터베이스가 제공하는 errorCode 라는 것이 들어 있습니다. 예를 들어 H2 데이터베이스의 키 중복 오류 코드는 아래와 같습니다.

e.getErrorCode() == 23505

 

 

이 오류 코드를 이용하여 예외를 아래와 같이 처리할 수도 있을 것입니다.

1. MyDuplicateKeyException 예외 정의


package hello.jdbc.repository.ex;

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

2. Service 및 Repository 클래스 정의

아래 코드의 Repository 클래스에서 rcode가 23505 이면 MyDuplicateKeyException을 던지도록 하였습니다.

 

그리고 Service 클래스에서는 MyDuplicateKeyException 예외가 발생하면 generateNewId(accountId) 를 통해 새로운 ID 생성을 시도 후 다시 저장합니다. (예외 복구)


package hello.jdbc;

import hello.jdbc.domain.Account;
import hello.jdbc.repository.ex.MyDbException;
import hello.jdbc.repository.ex.MyDuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;

public class TestClass {

    @RequiredArgsConstructor
    static class Repository {
        private final DataSource dataSource;

        public Account save(Account account) {
            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, account.getAccountId());
                pstmt.setInt(2, account.getMoney());
                pstmt.executeUpdate();
                return account;
            } catch (SQLException e) {
                //h2 db
                if (e.getErrorCode() == 23505) {
                    throw new MyDuplicateKeyException(e);
                }
                throw new MyDbException(e);
            } finally {
                JdbcUtils.closeStatement(pstmt);
                JdbcUtils.closeConnection(con);
            }
        }
    }

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

        public void create(String accountId) {
            try {
                repository.save(new Account(accountId, 0));
                log.info("saveId={}", accountId);
            } catch (MyDuplicateKeyException e) {
                log.info("키 중복, 복구 시도");
                String retryId = generateNewId(accountId);
                log.info("retryId={}", retryId);
                repository.save(new Account(retryId, 0));
            } catch (MyDbException e) {
                log.info("데이터 접근 계층 예외", e);
                throw e;
            }
        }

        private String generateNewId(String accountId) {
            return accountId + new Random().nextInt(10000);
        }

    }
   
    
    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 저장 시도
    }
}

 

 

하지만 여기서 문제가 발생합니다. SQL ErrorCode는 데이터베이스마다 다릅니다. 그렇기 때문에 데이터베이스가 변경될 때마다 ErrorCode도 모두 변경해야 합니다.

그리고 데이터베이스가 전달하는 오류는 키 중복 외에도 많은 오류가 있을 것입니다. 이런 코드들을 모두 알고 코드에 작성하는 것은 매우 번거로울 것입니다.

 

 

위와 같은 문제들을 해결하기 위해서 스프링의 예외 변환기를 사용합니다.  예외 변환기는 아래와 같이 사용합니다.


@Test
void exceptionTranslator() {
    String sql = "select bad grammar";
    try {
       Connection con = dataSource.getConnection();
       PreparedStatement stmt = con.prepareStatement(sql);
       stmt.executeQuery();
     } catch (SQLException e) {
        SQLErrorCodeSQLExceptionTranslator exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
        DataAccessException resultEx = exceptionTranslator.translate("select", sql, e);
        log.info("resultEx", resultEx);
        assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
       }
  }

 

 

  • translate() 메서드의 첫 번째 파라미터는 읽을 수 있는 설명을, 두 번째는 실행한 SQL 문을, 마지막은 발생된 SQLException을 전달하면 됩니다. 
  • 눈에 보이는 반환 타입은 최상위 타입인 DataAccessException 이지만 실제로는 BadSqlGrammarException 예외가 반환되는 것을 확인할 수 있습니다. (BadSqlGrammarException은 DataAccessException의 자식 클래스)

 

이렇게 스프링 예외 추상화 (스프링 예외 변환기) 덕분에 특정 기술에 종속되지 않게 되었고 다른 기술로 변경되어도 예외로 인한 변경을 최소화할 수 있습니다.


끝으로 TestClass를 스프링 예외 변환기를 이용하여 수정하였습니다. 

 

Repository 클래스 수정


 

Service 클래스 수정


 

init 부분 수정


 

 

전체코드


public class TestClass {
    Repository repository;
    Service service;

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

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

    @RequiredArgsConstructor
    static class Repository {
        private final DataSource dataSource;
        private final SQLErrorCodeSQLExceptionTranslator translator;

        public Account save(Account account) {
            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, account.getAccountId());
                pstmt.setInt(2, account.getMoney());
                pstmt.executeUpdate();
                return account;
            } catch (SQLException e) {
                //h2 db
               /* if (e.getErrorCode() == 23505) {
                    throw new MyDuplicateKeyException(e);
                }
               throw new MyDbException(e);*/
                throw translator.translate("insertTest", sql, e);
            } finally {
                JdbcUtils.closeStatement(pstmt);
                JdbcUtils.closeConnection(con);
            }
        }
    }

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

        public void create(String accountId) {
            try {
                repository.save(new Account(accountId, 0));
                log.info("saveId={}", accountId);
            } catch (DuplicateKeyException e) {
                log.info("키 중복, 복구 시도");
                String retryId = generateNewId(accountId);
                log.info("retryId={}", retryId);
                repository.save(new Account(retryId, 0));
            } catch (DataAccessException e) {
                log.info("데이터 접근 계층 예외", e);
                throw e;
            }
        }

        private String generateNewId(String memberId) {
            return memberId + new Random().nextInt(10000);
        }
    }
}

 

 

 

728x90
반응형