Spring

JDBC 라이브러리 구현하기 리뷰

우아한 테크코스 4단계는 크루 간 리뷰를 주고받는다.

그 과정에 있어 이번 JDBC 라이브러리 구현하기 미션에서 받은 피드백들이 많은 생각을 하게 해주었던 터라 글을 남기게 되었다.

 

미션의 요구사항을 요약하자면 다음과 같다.

1단계

자바 진영에서는 애플리케이션의 DB 관련 처리를 위해 JDBC API를 제공한다.

문서를 참고해 JDBC API를 적용해보니 반복적인 DB 관련 작업을 수행하는 코드가 나타났다.
그리고 프레임워크를 사용하는 개발자 입장에서 매번 복잡한 코드를 작성하다보니 생산성이 떨어진다.

개발자는 SQL 쿼리 작성, 쿼리에 전달할 인자, SELECT 구문일 경우 조회 결과를 추출하는 것만 집중할 수 있도록 라이브러리를 만들자.

힌트

  1. 리팩터링은 UserDaoTest를 활용해 진행한다.
  2. 중복을 제거하기 위한 라이브러리는 JdbcTemplate 클래스에 구현한다.

2단계

자바가 제공하는 기능을 극한으로 활용해 깔끔한 코드를 작성하는 연습을 한다.

  • 익명 클래스
  • 함수형 인터페이스
  • 제네릭
  • 가변 인자
  • 람다
  • try-with-resources
  • checked vs unchecked exception

여태까지의 미션 중, 사실 가장 어려웠던 미션이었다.

왜인지 모르겠지만, 2단계 요구사항을 먼저 보았기 때문이라고 생각한다. 먼 발치를 먼저 내다보고 시작하려니, 거기까지 도달하기 위한 과정을 놓치게 되었던 것 같다.

 

그래서 처음 시도해본 것은, 토비의 스프링 3장을 읽는 것이었다. 팀 프로젝트를 함께하는 크루들이 토비 스프링을 읽고 나면 미션을 하는 것이 더 수월해질 것이라고 하였다. 그래서 허겁지겁 3장을 읽었다.

 

토비의 스프링 3장에서 다루는 내용을 요약하자면 다음과 같다.

  • 분리와 재사용을 위한 디자인 패턴의 적용
  • JDBC 전략 패턴의 최적화
  • 컨텍스트와 DI
  • 템플릿과 콜백

 

그렇게 허겁지겁 읽고 나니, 남는 것이 없었다. 왜냐? 미션에 적용하기 위한 읽음이었기에, 이유를 찾지 않았기 때문이다. 그래서 예제 코드를 따라치며 PreparedStatementStrategy와 같은 전략을 이유 없이 주입하며 알 수 없는 코드를 만들어갔다. 그리고, 나름 좀 해보겠다고 실제 JdbcTemplate 내부 코드도 뜯어봤다. update, queryForObject 등의 내부를 확인해가며 이유없는 유사한 코드를 작성해나갔다.

 

그래서 처음 제출한 코드는 다음과 같다. 이 글에선 다른 클래스의 내부 코드는 제외하고, 대표적으로 JdbcTemplate만 예시로 들겠다.

public class JdbcTemplate {

    private final DataSource dataSource;

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

    private static final Logger log = LoggerFactory.getLogger(JdbcTemplate.class);

    public int update(String sql, Object... args) {
        return execute(
            conn -> {
                PreparedStatement preparedStatement = conn.prepareStatement(sql);
                PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(args);
                pss.setValues(preparedStatement);
                return preparedStatement;
            }
        );
    }

    public <T> T query(String sql, RowMapper<T> rowMapper, Object... args) {
        List<T> result = query(sql, args, new RowMapperResultSetExtractor<>(rowMapper, 1));
        return DataAccessUtils.singleResult(result);
    }

    public <T> List<T> query(String sql, RowMapper<T> rowMapper) {
     return result(query(sql, new RowMapperResultSetExtractor<>(rowMapper)));
    }

    private <T> List<T> result(List<T> query) {
        return query;
    }

    private <T> List<T> query(String sql, Object[] args, RowMapperResultSetExtractor<T> rse) {
        return query(sql, new ArgumentPreparedStatementSetter(args), rse);
    }

    private <T> List<T> query(final String sql, final RowMapperResultSetExtractor<T> rse) {
        return execute(conn -> conn.prepareStatement(sql), rse);
    }

    private <T> List<T> query(String sql, PreparedStatementSetter pss, RowMapperResultSetExtractor<T> rse) {
        return execute(
            conn -> {
                PreparedStatement preparedStatement = conn.prepareStatement(sql);
                pss.setValues(preparedStatement);
                return preparedStatement;
            },
            rse
        );
    }

    private int execute(PreparedStatementStrategy strategy) {
        try (Connection conn = dataSource.getConnection();
            PreparedStatement pstmt = strategy.makePreparedStatement(conn))
        {
            return pstmt.executeUpdate();
        } catch (SQLException e) {
            log.error(e.getMessage(), e);
            throw new DataAccessException(e);
        }
    }

    private <T> List<T> execute(PreparedStatementStrategy strategy, RowMapperResultSetExtractor<T> rse) {
        try (Connection conn = dataSource.getConnection();
            PreparedStatement pstmt = strategy.makePreparedStatement(conn);
            ResultSet rs = pstmt.executeQuery())
        {
            return rse.extractData(rs);
        } catch (SQLException e) {
            log.error(e.getMessage(), e);
            throw new DataAccessException(e);
        }
    }
}

사실 이 코드를 제출했을 때엔 나름 멋지다라고 생각했었다. ㅋㅋㅋㅋ 여러 가지 각종 클래스와 인터페이스로 분리되어있는 것이 멋지다고 생각했던 것 같다. 그리고 리뷰어였던 손너잘이 이러한 리뷰를 남겨주었다.

  • 메서드의 존재 이유
  • 인터페이스의 존재 이유
  • 클래스의 존재 이유

등등등..

 

리뷰를 받고 많은 생각을 할 수 있었다. 이렇게 해라. 저렇게 해라는 등 원하는 코드를 알려주는 것이 아닌 생각할 수 있는 리뷰들을 남겨주었기 때문이다. 리뷰를 받고, 내가 이 미션을 수행하면서 생각하지 않고 코드를 작성했다는 것이 느껴졌다. 단순히 Spring의 JdbcTemplate을 따라 하기만 하면 좋은 코드라고 생각했고, 해당 클래스/인터페이스/메서드의 존재 이유에 대해 고민해보지 않았다.

 

그래서 다시 리팩토링하였다.

Spring에서 제공하는 JdbcTemplate 내부에서 사용되는 클래스를 모방하는 것이 아닌, 요구사항을 만족하기 위해 가장 적절한 형태로 설계하는 것을 목표로 했다. (설계라기엔 거창하지만...;;) 무조건적으로 클래스와 인터페이스로 분리하는 것이 아니라, 우선은 코드를 작성하고, 중복된 코드를 메서드 단위로 나누고, 그럼에도 클래스/인터페이스로의 분리 가치가 있는 것들을 분리했다.

 

첫번째 미션 제출 시 존재했던 클래스는 다음과 같았다면,

  • JdbcTemplate
  • PreparedStatementSetter
  • RowMapperResultSetExtractor
  • SingleRowMapperResultSetExtractor
  • DataAccessUtils
  • PreparedStatementStrategy
  • RowMapper

 

리팩토링 후 마지막 제출 시 남은 클래스는 다음과 같았다.

  • JdbcTemplate
  • RowMapper

 

그리고 변경된 JdbcTemplate은 다음과 같아졌다.

public class JdbcTemplate {

    private final DataSource dataSource;

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

    private static final Logger log = LoggerFactory.getLogger(JdbcTemplate.class);

    interface QueryResult<T> {

        T getResult(PreparedStatement preparedStatement) throws SQLException;
    }

    public int update(String sql, Object... args) {
        log(sql);
        return execute(sql, PreparedStatement::executeUpdate, args);
    }

    private <T> T execute(String sql, QueryResult<T> queryResult, Object... args) {
        try (
            Connection connection = dataSource.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
        ) {
            if (args.length != 0) {
                setValues(preparedStatement, args);
            }
            return queryResult.getResult(preparedStatement);
        } catch (SQLException e) {
            throw new DataAccessException(e);
        }
    }

    private void setValues(PreparedStatement preparedStatement, Object[] args) throws SQLException {
        for (int row = 0; row < args.length; row++) {
            preparedStatement.setObject(row + 1, args[row]);
        }
    }

    public <T> T queryForObject(String sql, RowMapper<T> rowMapper, Object... args) {
        log(sql);

        List<T> results = queryForList(sql, rowMapper, args);
        if (results.size() != 1) {
            throw new IncorrectDataSizeException(1, results.size());
        }
        return results.get(0);
    }

    public <T> List<T> queryForList(String sql, RowMapper<T> rowMapper, Object... args) {
        log(sql);

        return execute(
            sql,
            preparedStatement -> {
                try (ResultSet resultSet = executeQuery(preparedStatement, args)) {
                    return mapRows(rowMapper, resultSet);
                } catch (SQLException e) {
                    throw new DataAccessException(e);
                }
            }
        );
    }

    private ResultSet executeQuery(PreparedStatement preparedStatement, Object[] args) throws SQLException {
        setValues(preparedStatement, args);
        return preparedStatement.executeQuery();
    }

    private <T> List<T> mapRows(RowMapper<T> rowMapper, ResultSet resultSet) throws SQLException {
        List<T> results = new ArrayList<>();
        int rowNum = 0;
        while (resultSet.next()) {
            results.add(rowMapper.mapRow(resultSet, rowNum++));
        }
        return results;
    }

    private void log(String sql) {
        log.info("query: {}", sql);
    }
}

 

아무튼 주절 주절 길게 남겼는데, 결론은 이번 미션을 통해 다시 한번 레벨1,2로 돌아간 기분이 들었다. 무작정 이상향을 따라가는 코드를 작성하는 것이 아니라, 내 생각이 담기고 이유를 이야기할 수 있는 코드를 작성해야겠고, 하고 싶다. ㅎㅎ

 

많은 생각을 하게 해준 리뷰어 너잘에게 감사를 전하며.. 너잘의 마지막 코멘트를 끝으로 끗!

마지막으로 몇가지 이야기를 드리자면, 무조건 클래스를 나누고 인터페이스를 사용한다고 해서 확장성이 늘어나는것은 아닙니다. 객체간의 응집도와 결합도를 생각해보고, 클래스가 유틸성 클래스인지, 아니면 진짜 '객체'인지를 생각해 보고, 인터페이스는 함수형 인터페이스로 사용되는것인지 아니면 정말 확장을 위해 사용되는것인지를 생각하고 적절한 곳에 사용을 해야 좋은 설계를 얻을 수 있다고 생각해요!

클래스를 아무리 많이 나누더라고 그것이 모두 유틸성 클래스라면 강한 결합도로 인해 변경에 자유로운 코드가 되기는 어렵고, 함수형 인터페이스로 정의된 인터페이스는 기능의 확장보다는, 코드의 재사용에 가까운 목적을 지니는것 처럼요!