본문 바로가기
👨‍💻 프로그래밍/📦 Backend

Spring에서 DB 예외 처리하는 법 알아보기

by 개발자 진개미 2024. 5. 7.
반응형


기존 DB Exception의 문제점

Spring이 있기 전에 DB에서 오류가 나면 크게 3가지 문제가 있었습니다.

  1. 특정 기술에 종속된 에러가 터짐 (JDBC, JPA, Hibernate, JDO)
  2. 모두 CheckedException이라 불편함
  3. 특정 에러를 처리하고 싶으면 에러 코드를 직접 다뤄야 함

 

🐜 1 - 특정 기술에 종속된 에러가 터짐 (JDBC, JPA, Hibernate, JDO)

JDBC를 열심히 쓰고 있어서 코드 곳곳에 SQLException을 처리하고 있었는데 갑자기 JPA를 도입하고자 하면 어떻게 될까요? JPA는 SQLException을 던지지 않고 PersistenceException을 던집니다. 그럼 SQLException을 쓴 모든 곳에 이걸 PersistenceException으로 바꿔 줘야 합니다.

Spring이 여기서 SQLException이든, PersistenceException이든, Spring의 DataAccessException으로 변환시켜 줘서 내부의 기술을 바꾸더라도 위와 같은 상황이 없게 해 줍니다. (물론 Spring을 버리게 된다면 다시 저 짓을 해야 합니다. 🤮)

 

🐜 2 - 모두 CheckedException이라 불편함

Checked Exception이란 try-catch로 반드시 처리해야 하는 에러를 말합니다. 보통 DB 기술에서 던지는 에러는 Checked Exception인 경우가 많습니다. 당장 JDBC가 던지는 SQLException이 Checked Exception입니다.

하지만 DB가 던지는 에러는 대부분 애플리케이션 수준에서 어떻게 해 볼 수 있는게 아닙니다. Connection Timeout, 잘못된 SQL 문법, FK나 PK 오류 등을 어떻게 처리할 수 있을까요? 아무것도 할 수 없습니다. 결국 try-catch로 잡고 다시 에러를 던지는 경우가 대부분입니다.

이럴 바에야 Unchecked Exception으로 바꾸고 필요할 경우에만 처리하는게 낫지 않을까요? Spring이 정확히 이걸 해 줍니다.

 

🐜 3 - 특정 에러를 처리하고 싶으면 에러 코드를 직접 다뤄야 함

사실 Spring을 안 쓰더라도 JDBC는 쓰는 경우가 많아 JDBC가 DB 에러 코드를 SqlException으로 변환해 주기 때문에 DB에서 던지는 에러 코드를 직접 다룰 일은 없겠지만, JDBC를 쓰지 않거나 SqlException을 더 자세히 처리하고 싶을 때는 결국 에러 코드를 직접 다뤄야 합니다.

Spring에서는 에러 코드를 자세하게 DataAccessException으로 변환 해 줘서 에러 코드를 직접 다룰 일을 줄여줍니다.


Spring DB Exception의 구조

위에서도 몇 번 언급했지만 Spring은 모든 DB 에러를 어떤 기술을 쓰든, 어떤 DB를 쓰든 DataAccessException으로 변환해 줍니다. DataAccessException은 UnChecked Exception이기 때문에 당연히 RuntimeException을 상속합니다.

여기서 또 DataAccessException은 2가지 종류로 나뉘는데요.

  1. TransientException
  2. NonTransientException

Transient는 일시적인이라는 뜻의 영어 단어 입니다. 

이 말은 TransientException은 지금 문제가 일시적으로 그럴 가능성이 있는 에러들을 말합니다. 이 경우 try-catch로 잡아서 재처리하는게 의미가 있겠죠. 위의 그림의 예시인 QueryTimeoutException, OptimisticLockingFailureException 등은 Query가 일시적으로 Timeout 났지만 재시도하면 성공할 수도 있고, Lock을 못 잡은 경우도 재시도하면 그 사이 Lock이 풀렸을 수도 있으니 재시도가 의미가 있어 TransientException으로 분류됐습니다.

반대로 NonTransientException재시도한다고 해결될 가능성이 없는 일시적이지 않은 에러입니다. SQL문 자체가 잘못된 경우 배포를 다시 해야 해결되고, 중복된 값이 있다면 재시도가 아니라 값을 바꿔야 해결 되겠죠?


코드로 실제 까 보기

우선 SQLException을 DataAccessException으로 변환하는 역할은 SQLExceptionTranslator에서 담당합니다.

public interface SQLExceptionTranslator {
    DataAccessException translate(String task, @Nullable String sql, SQLException ex);
}

이 아이의 구현체는 4개가 있는데요.

AbstractFallbackSQLExceptionTranslator 코드 재사용을 위해 있는 Abstract Class
SQLErrorCodeSQLExceptionTranslator DB의 에러 코드를 DataAccessException으로 변환
SQLStateSQLExceptionTranslator SQLException의 에러 코드를 DataAccessException으로 변환
SQLExceptionSubclassTranslator SQLException의 SubClass들을 DataAccessException으로 변환

 

우선 AbstractClass부터 봐 보겠습니다.

@Getter
@Setter
public abstract class AbstractFallbackSQLExceptionTranslator implements SQLExceptionTranslator {

    private SQLExceptionTranslator fallbackTranslator;

    @Override
    public DataAccessException translate(String task, @Nullable String sql, SQLException ex) {
        Assert.notNull(ex, "Cannot translate a null SQLException");

        DataAccessException dae = doTranslate(task, sql, ex);
        if (dae != null) {
            return dae;
        }

        SQLExceptionTranslator fallback = getFallbackTranslator();
        if (fallback != null) {
            return fallback.translate(task, sql, ex);
        }

        return null;
    }

    protected abstract DataAccessException doTranslate(String task, @Nullable String sql, SQLException ex);
}

구조는 사실 굉장히 단순합니다. SQLExceptionTranslator를 구현해서 translate에 기본적인 처리를 해 줍니다. 그리고 doTranslate에 이 AbstractClass를 실제로 구현할 구현체한테 실제 변환을 맡기는게 끝입니다.

  • SQLException이 null이 아닌지 확인하고,
  • doTranslate를 호출해서 변환을 시도하고,
  • 변환이 실패하면 다른 Translator를 불러서 변환을 시도하고
  • 그래도 null이면 최종적으로 null을 return

 

나머지 구현체들이 이 Abstract Class를 구현하는데요. 사실 굉장히 뻔한 코드라 넘어가겠습니다. 중요한건 이 SQLExceptionTranslator를 실제 호출하는 부분이 어디냐!? 하는 것입니다.

이건 당연히 변환이 필요한 모든 곳에서 호출합니다. 그래서 호출처가 굉장히 많습니다. 제가 당장 떠올릴 수 있는 건 JdbcTemplate에서 호출하지 않을까 싶어서 JdbcTemplate 쪽을 봐 보겠습니다.

private <T> T execute(StatementCallback<T> action, boolean closeResources) throws DataAccessException {
    Assert.notNull(action, "Callback object must not be null");

    Connection con = DataSourceUtils.getConnection(obtainDataSource());
    Statement stmt = null;
    try {
        stmt = con.createStatement();
        applyStatementSettings(stmt);
        T result = action.doInStatement(stmt);
        handleWarnings(stmt);
        return result;
    } catch (SQLException ex) {
        String sql = getSql(action);
        JdbcUtils.closeStatement(stmt);
        stmt = null;
        DataSourceUtils.releaseConnection(con, getDataSource());
        con = null;
        throw translateException("StatementCallback", sql, ex);
    } finally {
        if (closeResources) {
            JdbcUtils.closeStatement(stmt);
            DataSourceUtils.releaseConnection(con, getDataSource());
        }
    }
}

사실 Jdbc를 한 번이라도 직접 다뤄본 분들이라면 웬지 익숙한 코드들이 많이 보이실 겁니다. 🤔 (저도 국비학원의 추억이 😂) 하지만 중요한건 Exception을 catch하는 부분인데요. Connection을 닫고 어쩌구는 알겠는데, 마지막에 translateException이라는 함수를 호출합니다. 이 함수를 봐 볼까요?

protected DataAccessException translateException(String task, @Nullable String sql, SQLException ex) {
    DataAccessException dae = getExceptionTranslator().translate(task, sql, ex);
    return (dae != null ? dae : new UncategorizedSQLException(task, sql, ex));
}

getExceptionTranslator()를 호출해 SQLExceptionTranslator를 가져온 후, .translate()를 호출하고 있습니다! 위에서 보셨든 이렇게 하면 Abstract Class에서 공통적인 처리를 한 뒤에 실제 변환 로직을 호출하겠죠?

getExceptionTranslator() 함수도 자세히 봐 보면 아래와 같이 설정에 따라 적절한 구현체를 호출하고 있습니다.

public SQLExceptionTranslator getExceptionTranslator() {
    SQLExceptionTranslator exceptionTranslator = this.exceptionTranslator;
    if (exceptionTranslator != null) {
        return exceptionTranslator;
    }
    
    synchronized (this) {
        exceptionTranslator = this.exceptionTranslator;
        if (exceptionTranslator == null) {
            DataSource dataSource = getDataSource();
            if (shouldIgnoreXml) {
                exceptionTranslator = new SQLExceptionSubclassTranslator();
            } else if (dataSource != null) {
                exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
            } else {
                exceptionTranslator = new SQLStateSQLExceptionTranslator();
            }
            this.exceptionTranslator = exceptionTranslator;
        }
        return exceptionTranslator;
    }
}

반응형