Spring

스프링으로 예외처리와 반복 문제 해결

gogi masidda 2024. 2. 19. 16:51

서비스 계층은 특정 기술에 의존하지 않고, 순수하게 유지하는 것이 좋은데 그러려면 예외에 대한 의존도 해결해야 한다. 

서비스 계층에서 처리할 수 없는 예외에 대한 의존을 제거하려면 체크 예외를 런타임 예외로 전환해서 서비스 계층에 던져야 한다. 그러면 서비스 계층에서 무시할 수 있기 때문에, 특정 구현 기술에 의존하는 부분을 제거하고 서비스 계층을 순수하게 유지할 수 있다.

 

인터페이스

인터페이스를 도입해서 구현 기술의 버전이 바뀌면 쉽게 변경할 수 있도록 할 수 있다.

하지만 인터페이스 구현체가 체크 예외를 던지려면, 인터페이스 메서드에 먼저 체크 예외를 던지는 부분이 있어야 한다.

인터페이스가 특정 구현 기술에 종속적인 체크 예외를 사용하게 되면 순수한 인터페이스를 만들 수 없다. 인터페이스를 만드는 목적은 구현체를 쉽게 변경하기 위함인데, 인터페이스가 이미 특정 구현 기술에 의존하면 인터페이스 자체를 변경해주어야 한다.

런타임 예외는 이런 부분에서 자유롭다. 인터페이스에 런타임 예외를 선언해줄 필요가 없어서 인터페이스가 특정 기술에 종속적일 필요가 없다. 런타임 예외로 변경하여 던짐으로써 다른 구현 기술로 변경해도 인터페이스 자체를 바꿀 일은 줄어들게 된다.


데이터 접근 예외 직접 만들기

데이터베이스 오류에 따라서 특정 예외는 복구하고 싶을 때

예를 들어, 데이터를 DB에 저장할 때 같은 ID가 이미 데이터베이스에 저장되어 있다면, 데이터베이스는 오류 코드를 반환하고, 이 오류 코드를 받은 JDBC 드라이버는 SQLException을 던진다. SQLException에는 데이터베이스가 제공하는 errorCode라는 것이 들어있다.

이 errorCode는 각 데이터베이스 메뉴얼을 확인해봐야 한다.

 

이 SQLException의 errorCode를 활용하여 리포지토리에서 예외를 변환하고 서비스로 던져야 한다.

 

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

h2 DB에서 키 중복 에러코드인 23505를 활용하여 키 중복 에러를 잡아내 따로 처리할 수 있다.

하지만 데이터베이스마다 에러코드가 달라서 다른 데이터베이스를 쓴다면 에러코드를 변경해주어야 한다.

이 문제를 스프링이 해결해준다.

 


스프링 예외 추상화

  • 스프링은 데이터 접근 계층에 대한 예외들을 정리해서 일관된 예외 계층을 제공한다.
  • 각각의 예외는 특정 기술에 종속되지 않게 설계되어 있다. 따라서 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다. 어떤 DB 기술을 사용하든 스프링이 제공하는 예외를 사용하면 된다.
  • JDBC나 JPA를 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환해주는 것도 스프링이 한다.
  • 예외의 최고 상위는 org.springframework.DataAccessException이고, 스프링이 제공하는 예외들은 RuntimeException을 상속받았기 때문에 모두 런타임 예외다.
  • DataAccessException은 NonTransient 예외와 Transient 예외로 나뉜다.
    • Transient는 일시적이라는 뜻이다. Transient 하위의 예외는 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있다. 
      • 예를 들어 쿼리 타임 아웃, 락과 관련된 오류. 데이터 베이스 상태가 좋아지거나, 락이 풀렸을 때 다시 시도하면 성공할 수도
    • NonTransient는 일시적이지 않다라는 뜻이다. 같은 SQL을 그대로 반복해서 실행하면 실패한다.
      • SQL 문법 오류, 데이터베이스 제약조건 위배 등이 있다.

스프링 SQL 예외 변환기

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

        try {
            Connection con = dataSource.getConnection();
            PreparedStatement stmt = con.prepareStatement(sql);
            stmt.executeQuery();
        } catch (SQLException e) {
            assertThat(e.getErrorCode()).isEqualTo(42122);

            SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
            DataAccessException resultEx = exTranslator.translate("select", sql, e);
            log.info("resultEx", resultEx);
            assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
        }
    }
//예외 변환 코드
SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);

translate() 메서드의 첫번째 파라미터는 읽을 수 있는 설명, 두번째는 실행한 sql, 마지막은 발생된 예외인 SQLException을 전달하면 된다. 이렇게 하면 적절한 스프링 데이터 접근 계층 예외로 변환해서 반환해준다.

여기서는 SQL 문법이 잘못된 것이라 BadSqlGrammarException이 반환된다.

 

이것을 사용하여 특정 기술에 종속적이지 않게 되었다. JDBC에서 JPA로 변경해도 예외로 인한 변경을 최소화 할 수 있다.

 

적용

/**
 * SQLExceptionTranslator 추가
 */
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository{

    private final DataSource dataSource;
    private final SQLExceptionTranslator exTranslator;
    public MemberRepositoryV4_2(DataSource dataSource) {
        this.dataSource = dataSource;
        this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); //dataSource를 넣어주는건 어떤 DB를 쓰는지..
    }

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

        Connection con = null;
        PreparedStatement pstmt = null; //PreparedStatement는 파라미터 할당 기능이 추가된 것.

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate(); //데이터를 변경할 때. 위에서 설정한게 실행됨. 영향받은 row 수만큼 반환
            return member;
        } catch (SQLException e) {
            DataAccessException ex = exTranslator.translate("save", sql, e);
            throw ex;
        } finally {
            close(con, pstmt, null); //finally에서 close해야 항상 close
        }

    }
    @Override
    public Member findById(String memberId){
        String sql = "select * from member where member_id = ?";

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

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

            rs = pstmt.executeQuery(); //select할 때는 executeQuery. 결과를 ResultSet에 담아서 반환
            if(rs.next()) { //내부에 커서가 있는데 한번은 next를 해줘야 실제 데이터가 있는 곳부터 실행됨.
                Member member = new Member();
                member.setMemberId(rs.getString("member_id"));
                member.setMoney(rs.getInt("money"));
                return member;
            } else { //데이터가 없을 때
                throw new NoSuchElementException("member not found memberId = " + memberId);
            }
        } catch (SQLException e) {
            throw exTranslator.translate("save", sql, e);
        } finally {
            close(con, pstmt, rs);
        }
    }
    ...

 


JDBC 반복 문제 해결

커넥션을 가져오고, prePareStatement를 만들고, 파라미터 바인딩, 쿼리 실행, 결과 바인딩, 예외변환 등이 계속 반복되어 나타난다.

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

스프링은 JDBC의 반복 문제 해결을 위해 JDBCTemplate를 제공한다.

 

/**
 * JDBCTemplate 사용
 */
@Slf4j
public class MemberRepositoryV5 implements MemberRepository{

    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 (?, ?)";
        template.update(sql, member.getMemberId(), member.getMoney());
        return member; //커넥션 받아오고, 예외변환 등을 다 해줌.
    }
    @Override
    public Member findById(String memberId){
        String sql = "select * from member where member_id = ?";
        Member member = template.queryForObject(sql, memberRowMapper(), memberId);
        return member;
    }

    @Override
    public void update(String memberId, int money){
        String sql = "update member set money=? where member_id = ?";
        template.update(sql, money, memberId);

    }
    @Override
    public void delete(String memberId){
        String sql = "delete from member where member_id = ?";
        template.update(sql, memberId);
    }

    private RowMapper<Member> memberRowMapper() { //SQL 결과로 resultSet이 오면 그것을 member로 반환
        return (rs, rowNum) -> {
            Member member = new Member();
            member.setMemberId(rs.getString("member_id"));
            member.setMoney(rs.getInt("money"));
            return member;
        };
    }
    //커넥션 닫고, 동기화하는 것도 다 해줌.
}

 

findById에서 queryForObject()는 쿼리를 실행하고 ResultSet을 반환하는데, 이 ResultSet을 변환하기 위해 RowMapper를 사용한다. memberRowMapper() 메서드는 결과 집합의 각 행마다 호출되고 Member 객체를 반환한다. 

SQL 쿼리 결과가 Member 객체와 일치하므로 findById의 리턴형이 Member인 것이다.

728x90