토비의 스프링 정리 프로젝트 #3.6 스프링의 JdbcTemplate

Jake Seo·2021년 8월 2일
0

토비의 스프링

목록 보기
22/29

스프링의 JdbcTemplate

이번엔 스프링이 제공하는 템플릿/콜백 기술을 살펴보자. 거의 모든 종류의 JDBC 코드에 사용 가능한 템플릿/콜백을 제공할 뿐만 아니라, 자주 사용되는 패턴을 가진 콜백은 다시 템플릿에 결합시켜서 간단한 메소드 호출만으로 사용가능하도록 만들어져 있기 때문에 템플릿/콜백 방식의 기술을 사용하는지 모르고도 쓸 수 있을 정도로 편리하다.

스프링이 제공하는 JDBC 코드용 기본 템플릿은 JdbcTemplate이다. 앞에서 만들던 JdbcContext와 유사하지만 훨씬 강력하고 편리한 기능을 제공한다. 기존 UserDao 클래스에 있던 코드를 JdbcTemplate 을 이용해 단계적으로 변경시켜보자.

JdbcTemplate 초기화

public class UserDao {
    DataSource dataSource;
    JdbcTemplate jdbcTemplate;

    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
    ...

위와 같이 초기화를 해주면, 이제 JdbcTemplate를 사용할 준비가 됐다.

update()

deleteAll()

deleteAll()을 변경해보자. 기존 deleteAll() 메소드는 connection에 있는 .prepareStatement()sql구문인 "delete from users"를 이용하여 구성했었다.

이전에는 다양한 sql 구문을 이용하여 StatementStrategy 인터페이스 내부의 makePreparedStatement() 추상 메소드를 구현해 .prepareStatement()의 결과인 PreparedStatement 타입의 객체를 만드는 것이 전략이었으며, PreparedStatement 내부 메소드인 .executeUpdate()를 수행하고, 커넥션을 잘 회수해주는 것이 템플릿이었다.

스프링에서 제공하는 JdbcTemplate도 마찬가지로 .update() 메소드 내부에서 Connection 객체를 통해 PreparedStatement 객체를 반환하는 추상메소드를 구현하면 동일하게 활용할 수 있다.

jdbcTemplate.update(new PreparedStatementCreator() {
            @Override
            public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
                PreparedStatement deleteFromUsers = con.prepareStatement("delete from users");
                return deleteFromUsers;
            }
        });

PreparedStatementCreator()라는 추상 메소드는 makePareparedStatement()와 동일한 역할을 하는 추상메소드이다. 사실 스프링 JdbcTemplateupdate() 메소드도 이전에 우리가 작성했던 executeSql() 메소드와 같이, sql만 넘겨주어도 내장 템플릿으로 콜백을 만들어 넘겨줄 수 있다.

최종적으로 .deleteAll() 메소드를 아래와 같이 변경할 수 있다.

public void deleteAll() {
        jdbcTemplate.update("delete from users");
    }

add()

.update()의 기능은 단순히 sql 문자열을 받아 DB에서 수행주는 것을 넘어서, 파라미터를 순서대로 바인딩 해줄 수도 있다.

public void add(User user) throws SQLException {
        this.jdbcTemplate.update("insert into users(id, name, password) values (?, ?, ?)"
                , user.getId()
                , user.getName()
                , user.getPassword()
        );
    }

사실 이전에 JdbcContext에서 구현했던 .executeSql() 메소드는 JdbcTemplate에 이미 .update()란 이름으로 구현되어 있는 메소드였다. 다만, .update()의 구현이 더욱 풍부하다.

다양한 파라미터 오버로딩을 지원한다.

테스트

구현을 변경한 뒤에는 항상 테스트를 수행하여 확인해보자.

queryForObject()와 ResultSetExtractor 콜백

다음은 아직 템플릿/콜백 방식을 적용하지 않았던 메소드에 JdbcTemplate을 적용해볼 것이다.

이전까지는 단순히 update()에 대한 쿼리만 날려보았다. update()에 들어가는 쿼리는 사실 보통 성공, 실패 외에 딱히 결과가 없는 쿼리이다. 결과가 있는 쿼리는 query() 메소드를 사용한다.

query() 메소드는 결과를 받아야 하기 때문에 2가지 콜백을 받아야 한다.

  • 1번째는 쿼리에 대한 PreparedStatement를 생성하는 PreparedStatementCreator 콜백이다.
  • 2번째는 반환받은 결과를 추출, 매핑해주는 ResultSetExtractor 콜백이다.

query() 메소드를 getCount() 메소드 내부에서 이용하여 전체 데이터 숫자 결과를 돌려받아보자.

public int getCount() throws SQLException {
    return jdbcTemplate.query("select count(*) from users", new ResultSetExtractor<Integer>() {
        public Integer extractData(ResultSet resultSet) throws SQLException, DataAccessException {
            resultSet.next();
            return resultSet.getInt(1);
        }
    });
}

개선할 필요성이 보이지만, 일단은 위와 같이 작성했다.

첫번째 콜백은 앞서 설명했던 것과 같이, 문자열로 된 sql을 넘기면 PreparedStatementCreator 콜백을 이용하여 자동으로 PreparedStatement를 만들어준다.

앞서 만들었던 lineReadTemplate()와 유사하게 두 번째 콜백에서 리턴하는 값은 결국 템플릿 메소드의 결과로 다시 리턴된다. 클라이언트/템플릿/콜백의 3단계 구조이니, 콜백이 만들어낸 결과는 템플릿을 거쳐야만 클라이언트인 getCount() 메소드로 넘어오는 것이다.

또 한가지 눈여겨 볼 점은 ResultSetExtractor는 제네릭스 타입 파라미터를 갖는다는 점이다. lineReadTemplate()LineCallback에 적용했던 방법과 유사하다. 파라미터로 설정된 인터페이스가 갖는 제네릭 타입을 기준으로 클래스 내부 메소드의 타입이 설정될 것이다.

사실 JdbcTemplate은 위와 같이 특정한 타입의 결과를 출력하는 경우에 대해 queryForObject()라는 편리한 메소드를 제공한다. 결과를 반환하는 SQL 문장과 반환하는 타입의 정보만 클래스 형태로 넘겨주면 된다.

public int getCount() throws SQLException {
    return jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
}

getCount()는 이전에 34줄로 복잡하게 작성되어 있던 코드였지만, 이제는 한 줄로 바뀌었으며 그 의미도 몇배는 명확해졌다. 여러 메소드에 반복되며 변화하지 않는 부분은 템플릿으로 빼고, 변화하는 부분은 콜백으로 만들어 멋지게 코드를 변경하였다.

queryForObject()와 RowMapper 콜백

이번엔 get() 메소드에 JdbcTemplate을 적용해보자. get() 메소드에서 하는 일을 정리해보면,

  • sql을 이용하여 PreparedStatement를 만들어주어야 한다.
  • id로 검색하기 때문에 파라미터에 대한 처리도 해주어야 한다.
  • 결과로 User 객체를 만들어야 하기 때문에, 결과로 받은 ResultSet에 대한 처리도 해주어야 한다.

이전의 count()와 같은 단순 개수 조회가 아닌, 어떠한 객체로 매핑해야 할 때는 ResultSetExtractor와 같은 콜백 대신에 RowMapper와 같은 콜백을 사용해야 한다. 코드로 보면 다음과 같다.

    public User get(String id) {
        return jdbcTemplate.queryForObject("select * from users where id = ?", new RowMapper<User>() {
            @Override
            public User mapRow(ResultSet rs, int rowNum) throws SQLException {
                User user = new User();
                user.setId(rs.getString("id"));
                user.setName(rs.getString("name"));
                user.setPassword(rs.getString("password"));

                return user;
            }
        }, id);
    }

.queryForObject() 메소드 하나로 코드가 상당히 짧아졌다. 2개의 콜백을 이용함에도 그다지 복잡하지 않다. 그러나 get() 메소드에는 한가지 더 고려해야 할 것이 있는데, 기존에 조회 결과가 없을 때 EmptyResultDataAccessException을 던지도록 만들었다. 사실 해당 예외는 queryForObject()에서 결과가 1개가 아니라면, 즉 2개 이상이거나 없을 때, 원래 던지던 예외이다. 그래서 별달리 예외처리를 추가하지 않아도, 기존의 예외 테스트는 잘 작동할 것이다.

query()

기능 정의와 테스트 작성

여태까지는 단일 row에 대해서만 데이터를 조회해보았다. getAll()과 같은 메소드는 users 테이블에 존재하는 모든 row를 가져와야 한다. get()으로 단일 row를 조회하는 것에 대해서는 User 객체 자체가 결과값이었다면, getAll()으로 모든 row를 조회할 때는 List<User>가 결과값이 되면 좋을 것이다. 그리고 정렬은 id를 기준으로 한다고 기능을 정의해보자.

user1, user2, user3을 등록하고 id 순서대로 가져올 것이다. 매 유저를 등록 시에 .getAll() 메소드로 조회를 하고 올바른 id 순서로 가져왔는지 확인할 것이다.

    @Test
    @DisplayName("전체 유저 추가 및 불러오기")
    public void getAll() {
        userDao.deleteAll();

        userDao.add(user1); // id: user1
        List<User> users = userDao.getAll();
        assertEquals(users.size(), 1);
        checkSameUser(user1, users.get(0));

        userDao.add(user2); // id: user2
        users = userDao.getAll();
        assertEquals(users.size(), 2);
        checkSameUser(user1, users.get(0));
        checkSameUser(user2, users.get(1));

        userDao.add(user3); // id: user2
        users = userDao.getAll();
        assertEquals(users.size(), 3);
        checkSameUser(user1, users.get(0));
        checkSameUser(user2, users.get(1));
        checkSameUser(user3, users.get(2));
    }
    
    private void checkSameUser(User user1, User user2) {
        assertEquals(user1.getId(), user2.getId());
        assertEquals(user1.getName(), user2.getName());
        assertEquals(user1.getPassword(), user2.getPassword());
    }

checkSameUser()와 같이 테스트에서 반복되는 부분을 따로 분리하고 재사용하는 것은 좋은 습관이다. 여러 테스트 클래스에 걸쳐 재사용되는 코드라면 별도의 클래스로 분리하는 것도 고려해볼 수 있다.

현재 위의 테스트는 성공하지 않으니 위의 테스트를 성공할 수 있도록 .getAll() 메소드를 구성해보자.

query() 템플릿을 이용하는 getAll() 구현

    public List<User> getAll() {
        return this.jdbcTemplate.query("select * from users", (rs, rowNum) -> {
            User user = new User();

            user.setId(rs.getString("id"));
            user.setName(rs.getString("name"));
            user.setPassword(rs.getString("password"));

            return user;
        });
    }

queryFor...() 메소드는 일반적으로 쿼리의 결과가 하나일 때 사용하고, query()는 일반적으로 여러 개의 로우가 결과로 나오는 경우에 사용한다. 리턴타입은 제네릭 타입을 가진 List<T>이며, RowMapper<T> 콜백 오브젝트에서 결정된다.

RowMapper<T>는 쿼리의 결과로 반환된 모든 Row에 대해 매핑 작업을 수행 후에 List 형태로 반환한다. 위의 코드를 작성하면, 이전에 동작하지 않았던 코드가 올바르게 동작하는 것을 확인할 수 있다.

테스트 보완

성공적인 테스트 결과를 보면 빨리 다음 기능으로 넘어가고 싶겠지만, 너무 서두르는 것은 좋지 않다. 항상 꼼꼼하게 빠진 것은 없는지 더 개선할 부분은 없는지 한번쯤 생각해보는 것이 좋다.

네거티브 테스트라고 불리는 예외 상황에 대한 테스트는 항상 빼먹기 쉬우므로 주의해야 한다. getAll() 메소드의 결과가 없다면 어떻게 해야 할까? 조회용 메소드의 조회 결과가 없을 때는 null 반환, 사이즈가 0인 리스트 반환, 예외 던지기 등 다양한 방식으로 처리될 수 있다.

JdbcTemplatequery() 메소드는 결과가 없을 때 단순히 사이즈가 0인 리스트를 반환한다. 우리가 구현한 getAll()은 그냥 그것을 그대로 반환하게 만들자.

    @Test
    @DisplayName("조회할 유저가 존재하지 않는 경우")
    public void getAllWithoutUser() {
        userDao.deleteAll();
        
        List<User> users = userDao.getAll();
        assertEquals(users.size(), 0);
    }

위와 같은 테스트를 새로 생성했다. 이러한 테스트 코드를 볼 때, 왜 query()의 결과에 손댈 것도 아니면서 굳이 검증코드를 추가할까? 라는 생각이 들 수 있다.

그러나, 우리가 만든 것은 .getAll() 메소드이며, 이 메소드가 query()의 결과를 반환하는지는 코드를 개발한 개발자 말고는 알 수 없다. 이전에 말했듯, 예외를 던질 수도 있고, null을 반환할 수도 있다.

UserDao를 사용하는 입장에서는 JdbcTemplate으로 구현됐는지, JDBC 코드를 직접 사용했는지 알 수도 알 필요도 없다. getAll()이라는 메소드가 어떻게 동작하는지에만 관심이 있다.

위와 같은 면에서 query()의 결과와 상관 없이 getAll() 메소드의 예외상황에 대한 테스트는 반드시 필요하다.

실상 getAll() 메소드의 구현을 아는 개발자라도 query() 메소드에 대한 학습 테스트로서의 의미도 있다.

재사용 가능한 콜백의 분리

이제 코드에는 핵심적인 SQL 문장, 파라미터, 생성되는 결과의 타입 정보만 남기고 모든 템플릿 코드는 제거되었다. 그러나 아직 몇가지 할 일이 남았다.

DI를 위한 코드 정리

  • 이제 DataSource를 직접 사용할 일은 없으니 UserDao에서 정리하자.
    • 단, 수정자에서는 JdbcTemplate을 초기화하는 데 필요하니 그대로 두자.
public class UserDao {
    JdbcTemplate jdbcTemplate;

    public UserDao() {
    }

    public void setDataSource(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
...

만일, JdbcTemplate을 직접 스프링 빈으로 등록하는 방식으로 변경하고 싶다면, setDataSourcesetJdbcTemplate으로 바꿔주면 된다.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
    <bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="username" value="postgres" />
        <property name="password" value="iwaz123!@#" />
        <property name="driverClass" value="org.postgresql.Driver" />
        <property name="url" value="jdbc:postgresql://localhost/toby_spring" />
    </bean>

    <bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
        <property name="dataSource" ref="dataSource" />
    </bean>

    <bean id="userDao" class="toby_spring.user.dao.UserDao">
        <property name="jdbcTemplate" ref="jdbcTemplate" />
    </bean>
</beans>
public class UserDao {
    JdbcTemplate jdbcTemplate;

    public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
    
    ...

위와 같이 jdbcTemplate 빈을 직접 등록해주고, userDao로 주입해주어도 아무런 문제가 없다.

중복 제거

User를 매핑시키는 RowMapper 콜백의 구현이 get() 메소드와 getAll() 메소드에서 동일하다.

public class UserDao {
    JdbcTemplate jdbcTemplate;
    RowMapper<User> userRowMapper;

    public UserDao() {
        this.userRowMapper = (rs, rowNum) -> {
            User user = new User();
            user.setId(rs.getString("id"));
            user.setName(rs.getString("name"));
            user.setPassword(rs.getString("password"));
            return user;
        };
    }

    public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void add(User user) {
        this.jdbcTemplate.update("insert into users(id, name, password) values (?, ?, ?)"
                , user.getId()
                , user.getName()
                , user.getPassword()
        );
    }

    public User get(String id) {
        return jdbcTemplate.queryForObject("select * from users where id = ?", userRowMapper, id);
    }

    public void deleteAll() {
        jdbcTemplate.update("delete from users");
    }

    public int getCount() {
        return jdbcTemplate.queryForObject("select count(*) from users", Integer.class);
    }

    public List<User> getAll() {
        return this.jdbcTemplate.query("select * from users", userRowMapper);
    }
}

위와 같이 userRowMapper를 필드로 빼서, 필요한 다양한 메소드에서 활용하도록 코드를 정리할 수 있다. 현재는 get()getAll()에서 밖에 쓰이지 않았지만, 언제라도 조회와 관련된 메소드가 추가될 때, 사용될 수 있다. 혹여나 조회와 관련된 메소드가 추가되고 User에 컬럼이 몇개 추가된다면 복사 붙여넣기 한 부분을 전부 찾아다니면서 수정해야 하는데 끔찍하다.

응집도/결합도 관점에서의 UserDao

현재 작성된 UserDao 코드에는 User의 정보를 DB에 넣거나 가져오거나 조작하는 방법에 대한 핵심적인 로직만 담겨있다. 만약 사용할 테이블과 필드정보가 바뀌면 UserDao의 거의 모든 코드가 함께 바뀔 것이다. 따라서 응집도가 높다고 볼 수 있다.

반면에 JDBC API를 사용하는 방식, 예외처리, 리소스의 반납, DB 연결을 어떻게 가져올지에 대한 책임은 모두 JdbcTemplate에게 있다. 따라서 위 내용에 대한 변경이 일어난다고 해도 UserDao의 소스코드에는 아무런 영향을 미치지 않는다. 그런 면에서 책임이 다른 코드와는 낮은 결합도를 유지하고 있다.

다만, JdbcTemplate이라는 템플릿 클래스를 직접 이용한다는 면에서는 특정 템플릿/콜백 구현에 강한 결합을 갖고 있다고 할 수 있다. 그래도 더 낮은 결합도를 유지하고 싶다면 JdbcOperations라는 인터페이스를 통해 JdbcTemplate을 DI 받도록 해도 된다.

UserDao에서 더 개선할 점은?

  • userRowMapper가 인스턴스 변수로 설정되어 있고, 한번 만들어지면 변경되지 않는 프로퍼티와 같은 성격을 띠고 있으니, 아예 UserDao 빈의 DI용 프로퍼티로 만들어버리면 어떨까? UserMapper를 독립된 빈으로 만들고 XML 설정에 User 테이블의 필드 이름과 User 오브젝트 프로퍼티의 매핑 정보를 담을 수도 있을 것이다. UserMapper를 분리할 수 있다면, User의 프로퍼티와 User 테이블의 필드 이름이 바뀌거나 매핑 방식이 바뀌는 경우에 UserDao 코드를 수정하지 않고도 매핑정보를 변경할 수 있따는 장점이 있다.

  • DAO 메소드에서 사용하는 SQL 문장을 UserDao 코드가 아니라 외부 리소스에 담고 이를 읽어와 사용하게 만들면 어떨까? 이렇게 해두면 DB 테이블의 이름이나 필드 이름을 변경하거나 sQL 쿼리를 최적화해야 할 때도 UserDao 코드에는 손을 댈 필요가 없다. 어떤 개발팀은 정책적으로 모든 SQL 쿼리를 DBA들이 만들어서 제공하고 관리하는 경우가 있다. 이럴 때 SQL이 독립된 파일에 담겨있다면 편리할 것이다.

스프링에는 JdbcTemplate 외에도 십여가지 템플릿/콜백 패턴을 적용한 API가 존재한다. 클래스 이름이 Template로 끝나거나 인터페이스 이름이 Callback으로 끝난다면, 템플릿/콜백이 적용된 것이라고 보면 된다.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

0개의 댓글