Spring Exception Handling

Uncheck Error + Interfaced Repository

Service 계층의 코드는 순수한 자바 코드로 구성해야한다. 그렇기 하기 위해서는 아래의 경우들을 고려해야한다.

  1. 인터페이스 형태로 repository 사용
  2. SQLException 에러 -> unchecked error 에러

우선, 현재 Service 계층에서는 JDBC로 구현된 Repository에 의존하는 형태이다. 따라서, JPA와 같이 다른 DA로 변경하게 되면 해당 부분을 수정해줘야한다. 이러한 부분을 보완하기 위해 Repository를 인터페이스 형태로 추상화해서 관리하게 되면 구현 클래스가 변경되더라도, Service 계층에서는 interface에 의존하고 있기 때문에 수정할 필요가 없어진다.

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

하지만, 여기서 2번째 문제가 발생한다. 현재 Repository에서는 SQLExcepion 예외를 전달하도록 설계되어있다. SQLException 예외는 체크 예외로 반드시 throws/catch 형태로 예외를 처리해야한다. 따라서, 위와 같은 인터페이스 내에 구현될 메소드에도 throws SQLException 구문을 추가해야한다.

따라서, 완전한 추상화를 위해서는 체크 예외를 언체크 예외로 변환해서 넘겨야한다.

MyDbException

public class MyDbException extends RuntimeException{
    public MyDbException() {
        super();
    }

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

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

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

위와 같이 RuntimeException 을 상속하는 언체크 예외를 생성해서

아래의 repository에 적용한다.

Member Repository

@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository{
    private final DataSource dataSource;

    public MemberRepositoryV4_1(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public Member save(Member member) {
         String sql = "INSERT INTO MEMBER VALUES(?,?)";

         Connection conn = null;
         PreparedStatement pstmt = null;

         try{
             conn = getConnection();
             pstmt = conn.prepareStatement(sql);
             pstmt.setString(1, member.getMemberId());
             pstmt.setInt(2, member.getMoney());
             pstmt.executeUpdate();
             return member;
         } catch (SQLException e) {
             log.error("db error", e);
             throw new MyDbException(e);
         }
         finally{
             close(conn, pstmt, null);
         }
     }
    @Override
    public Member findById(String memberId) {
        String sql = "SELECT * FROM MEMBER WHERE MEMBER_ID =?";

        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try{
            conn = getConnection();
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, memberId);

            rs=pstmt.executeQuery();

            if (rs.next()) {
                Member member = new Member();
                member.setMemberId(rs.getString(1));
                member.setMoney(rs.getInt(2));
                return member;
            }
            else
                throw new NoSuchElementException("member not found memberId=" + memberId);

        } catch (SQLException e) {
            log.error("db error", e);
            throw new MyDbException(e);
        }
        finally{
            close(conn, pstmt, rs);
        }
    }
    @Override
    public void update(String memberId,int money){
        String sql = "UPDATE MEMBER SET MONEY=? WHERE MEMBER_ID =?";

        Connection conn = null;
        PreparedStatement pstmt = null;

        try{
            conn = getConnection();
            pstmt = conn.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            int resultSize=pstmt.executeUpdate();
            log.info("resultSize={}", resultSize);
        } catch (SQLException e) {
            log.error("db error", e);
            throw new MyDbException(e);
        }
        finally{
            close(conn, pstmt, null);
        }
    }
    @Override
    public void delete(String memberId) {
        String sql = "DELETE FROM MEMBER WHERE MEMBER_ID =?";

        Connection conn = null;
        PreparedStatement pstmt = null;

        try{
            conn = getConnection();
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, memberId);

            int resultSize=pstmt.executeUpdate();
            log.info("resultSize={}", resultSize);

        } catch (SQLException e) {
            log.error("db error", e);
            throw new MyDbException(e);
        }
        finally{
            close(conn, pstmt, null);
        }
    }
...

Unchecked Exception

} catch (SQLException e) {
    log.error("db error", e);
    throw new MyDbException(e);
}

위 처럼, SQLException이 발생하는 부분에서, 언체크 예외인 MyDbException 예외로 변환해주는 작업을 수행한다.

이와 같이 구성하게 되면, 인터페이스 형태로 Repository를 참조하는 것이 가능하다.

MemberService

private final MemberRepository memberRepositoryV3;

Configuration

static class TestConfig {
    private final DataSource dataSource;
    public TestConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }
    @Bean
    MemberRepository memberRepository() {
        return new MemberRepositoryV4_1(dataSource); //단순 예외 변환
    }
    @Bean
    MemberServiceV4 memberServiceV4() {
        return new MemberServiceV4(memberRepository());
    }
}

위와 같이, Configuration 파일을 등록하기만 하면, 저절로 DI가 수행되어, 적절한 형태의 Repsitory가 DI 된다.

Handling Specific Exception

DB에 발생하는 SQLException의 경우 대부분 Application level에서 처리할 수 없는 부분이다. 하지만, 중복된 ID를 등록하려고 시도하는 경우에도 SQLException이 발생하게 된다. 이와 같은 특정 에러가 발생하는 경우, Application에서 해당 에러에 대한 복구 작업을 수행하도록 할 수 있다. 그러면 해당 에러에대한 에러코드를 알아야한다.

key_duplication_error

키 중복 오류 같은 경우 위의 그림을 보면 에러 코드가 23505인것을 확인할 수 있다.

하지만, 서비스 계층에서 예외 코드에 대한 분석 작업을 처리하게 되면, 순수한 자바 코드가 깨지게 된다. DB 종류에 따라 예외 코드가 다르기 때문에, 특정 기술에 의존적인 코드를 생성하는 꼴이 된다.

이러한 특정 예외에 대해서 처리하기 위해, Repository에서 특정 예외로 변환해서 넘겨줘야한다.

가령

MyDuplicateException

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

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

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

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

중복된 키값 오류에 대한 처리를 위해 클래스를 생성한다.

MemberRepository

catch (SQLException e) {
    log.error("db error", e);
    if (e.getErrorCode() == 23505) {
        throw new MyDuplicateKeyException(e);
    }
    throw new MyDbException(e);
}

위와 같이 처리하고자 하는 예외 코드에 대해 위에서 생성한 예외 클래스로 변환해서 Service 계층으로 넘겨주면 된다.

MemberService

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

Service 계층에서는 넘어온 예외에 대해서 처리를 수행하면 된다.

하지만, 위와 같이 직접 예외 클래스를 변환해서 처리하기에는 비현실적이다. DB의 종류는 다양하고, 해당 DB 안에서 이용되는 예외코드를 매우 많다. 따라서, 모든 상황에 대한 예외 클래스를 만들어주는 것은 불가능하다.

Spring Exception Abstraction

다행히도, Spring에서는 DB 오류 코드에 대해 여러 가지 Exception class로 변환해주는 작업을 구현해놨다.

spring_exception_abstraction

Spring에서는 특정 기술에 종속적이지 않게 각종 예외 대해서 클래스로 구현해놓았다. 그래서, 어떠한 DA 기술을 사용하더라도, Spring이 제공하는 예외를 사용할 수 있다.

Transient: 일시적인 예외로 주로, DB 락을 통해, 해당 row에 접근하지 못해 발생하는 류의 에러로, 다음에 다시 실행했을 때, 되는 경우가 있다.

Non-Transient: 일시적이지 않는 예외로, 일관성 문제가 깨지는 경우의 예외에 해당한다.

SpringExceptionTranslator

Spring에서는 Spring Type Exception으로 바꿔주는 translator가 있어, 자동으로 특정 기술에 의존적인 예외를 스프링 예외로 변환해준다.

MemberRepository

private final SQLExceptionTranslator exTranslator;

public MemberRepositoryV4_2(DataSource dataSource) {
    this.dataSource = dataSource;
    this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
}
...
catch (SQLException e) {
throw exTranslator.translate("save", sql, e);
}

위와 같이 SqlExceptionTranslator의 translater 메소드를 활용해서 특정 예외를 스프링에서 제공하는 예외 클래스로 변환할 수 있다.

SQLErrorCodeSQLExceptionTranslator 클래스는 예외코드 기반으로 스프링 예외 클래스로 변환해주는 클래스를 뜻한다.

Spring이 여러 DB에 대해 예외 코드를 Spring Exception으로 변환해줄 수 있는 것은 아래와 같이 xml을 통해 예외 코드를 분류해놨기 때문이다.

각각의 DB에 대해, 해당하는 예외 코드를 스프링 예외로 묶어놓았다.

<bean id="H2" class="org.springframework.jdbc.support.SQLErrorCodes">
    <property name="badSqlGrammarCodes">
        <value>42000,42001,42101,42102,42111,42112,42121,42122,42132</value>
    </property>
    <property name="duplicateKeyCodes">
        <value>23001,23505</value>
    </property>
</bean>

<bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes">
    <property name="badSqlGrammarCodes">
        <value>1054,1064,1146</value>
    </property>
    <property name="duplicateKeyCodes">
        <value>1062</value>
    </property>
</bean>

이제는, Service 계층에서는 스프링 기반의 예외에 대해서 예외 처리를 수행할 수 있다. 특정 기술에 의존적이지 않으므로 어떠한 DA를 사용하더라도 해당 예외를 활용할 수 있다.

JDBC Template

JDBC를 이용하는 경우, Connection, PrepareStatement, ResultSet, ExceptionTranslator,등 여러 부분에서 반복적으로 코드가 사용된다. 이러한 반복되는 코드를 줄이기 위해 Spring에서 JDBCTemplate이 제공한다.

save method

String sql = "INSERT INTO MEMBER VALUES(?,?)";

Connection conn = null;
PreparedStatement pstmt = null;

try {
    conn = getConnection();
    pstmt = conn.prepareStatement(sql);
    pstmt.setString(1, member.getMemberId());
    pstmt.setInt(2, member.getMoney());
    pstmt.executeUpdate();
    return member;
} catch (SQLException e) {
    log.error("db error", e);
    throw exceptionTranslator.translate("save", sql, e);
} finally {
    close(conn, pstmt, null);
}

위의 jdbc 코드를 아래와 같이 jdbc template를 활용하면 간결하게 변경할 수 있다.

String sql = "INSERT INTO MEMBER VALUES(?,?)";
int update = template.update(sql, member.getMemberId(), member.getMoney());
return member;

db의 데이터를 변경하는 류의 쿼리(executeUpdate)에 대해서는 template.update를 이용해서 코드를 간결하게 구성할 수 있다.

findById 메소드

String sql = "SELECT * FROM MEMBER WHERE MEMBER_ID =?";

Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;

try {
    conn = getConnection();
    pstmt = conn.prepareStatement(sql);
    pstmt.setString(1, memberId);

    rs = pstmt.executeQuery();

    if (rs.next()) {
        Member member = new Member();
        member.setMemberId(rs.getString(1));
        member.setMoney(rs.getInt(2));
        return member;
    } else
        throw new NoSuchElementException("member not found memberId=" + memberId);

} catch (SQLException e) {
    log.error("db error", e);
    throw exceptionTranslator.translate("find", sql, e);
} finally {
    close(conn, pstmt, rs);
}

다만, 데이터를 조회하는 쿼리(executeQuery)의 경우 쿼리 결과를 Member 객체로 매핑해주는 RowMapper을 필요로 한다.

public Member findById(String memberId) {
    String sql = "SELECT * FROM MEMBER WHERE MEMBER_ID =?";
    Member member = template.queryForObject(sql, memberRowMapper(), memberId);
    return member;
}

private RowMapper<Member> memberRowMapper() {
    return (rs, rowNum) -> {
        Member member = new Member();
        member.setMemberId(rs.getString("member_id"));
        member.setMoney(rs.getInt("money"));
        return member;
    };
}

References

link: inflearn

link:springdb

댓글남기기