본문 바로가기

Spring/토비의 스프링 3.1

3.6 스프링의 JdbcTemplate


스프링이 제공하는 템플릿/콜백 기술

  • 기본 템플릿 JdbcTemplate

JdbcContext를 JdbcTemplate으로 변경

  • 현재 UserDao : DataSource를 DI받아 JdbcContext에 주입하여 템플릿 오브젝트로 만들어서 사용
  • JdbcTemplate : 생성자의 파라미터로 DataSource를 주입

UserDao

public class UserDao {
    DataSource dataSource;
    private JdbcTemplate jdbcTemplate;

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

3.6.1 update()

deleteAll()에 적용

  • 기존 : StatementStrategy 인터페이스의 makePreparedStatement() 메소드
  • JdbcTemplate : PreparedStatementCreator 인터페이스의 createPreparedStatement() 메소드
public void deleteAll() throws SQLException {
    this.jdbcTemplate.update(new PreparedStatementCreator() {
        @Override
        public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
            return con.prepareStatement("DELETE FROM users WHERE 1=1");
        }
    });
}

executeSql()은 SQL 전달만으로 미리 준비된 콜백을 만들어 템플릿 호출이 가능

  • jdbcTemplate에서는 update() 메소드로 사용 가능
public void deleteAll() throws SQLException {
    this.jdbcTemplate.update("DELETE FROM users WHERE 1=1");
}

add() 메소드에 적용

  • PreparedStatement를 만들고, 함께 제공되는 파라미터를 순서대로 바인딩 해 줌
public void add(final User user) throws SQLException {
    this.jdbcTemplate.update("INSERT INTO users(id, name, password) VALUES (?, ?, ?)", 
        user.getId(), user.getName(), user.getPassword());
}

3.6.2 queryForInt()

getCount()에 적용

  • 쿼리를 실행하고 ResultSet을 통해 결과값을 가져오는 코드
  • 템플릿 : PreparedStatementCreator 콜백과 ResultSetExtractor 콜백을 파라미터로 받는 query() 메소드
  • ResultSetExtractor : PreparedStatement의 쿼리를 실행해서 얻은 ResultSet을 전달받는 콜백

동작방식

  1. PreparedStatementCreator 콜백은 템플릿으로부터 Connection을 받고 PreparedStatement를 돌려줌
  2. ResultSetExtractor는 템플릿으로부터 ResultSet을 받고 거기서 추출한 결과를 돌려줌
public int getCount() throws SQLException{
    return this.jdbcTemplate.query(new PreparedStatementCreator() {
        @Override
        public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
            return con.prepareStatement("SELECT COUNT(*) FROM users");
        }
    }, new ResultSetExtractor<Integer>() {
        public Integer extractData(ResultSet rs) throws SQLException, DataAccessException {
            rs.next();
            return rs.getInt(1);
        }
    });
}

내부 클래스가 두 번 등장

  • getCount() 메소드에 있던 코드 중 변하는 부분만 콜백으로 만들어져서 제공되는 것
  • 두 번째 콜백의 리턴 값은 lineReadTemplate()과 유사, 템플릿 메소드의 결과로 다시 리턴

ResultSetExtractor

  • 제네릭스 타입 파라미터를 가짐
  • lineReadTemplate()과 LineCallback에서 적용한 방법과 동일

좀 더 쉬운 방법

  • queryForInt() 사용
  • queryForInt() Deprecated
  • queryForObject(sql, obj) 사용
public int getCount() throws SQLException{
    return this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
}

3.6.3 queryForObject()

get() 메소드에 적용

  • get() : UserDao에서 가장 복잡
  • SQL : 바인딩이 필요함 (add에서 사용한 방법 적용)
  • ResultSet : 단순한 값이 아닌 User 오브젝트
  • ResultSetExtractor가 아닌 RowMapper 콜백 사용
public User get(String id) throws SQLException {
    return this.jdbcTemplate.queryForObject("SELECT * FROM users WHERE ID = ?", new Object[] {id},
        new RowMapper<User>() {
            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;
            }
        }
    );
}
  • 첫 번째 파라미터 : PreparedStatement를 만들기 위한 SQL
  • 두 번째 파라미터 : 바인딩, 뒤에 다른 파라미터가 있으므로 Object 타입 배열을 사용해야함

QueryForObject

  • ResultSet의 하나의 로우만 얻을 것을 기대
  • 하나가 아니라면 예외를 던짐(EmptyResultDataAccessException)
  • 따라서 UserDaoTest의 getUserFailure()를 위한 추가적인 작업은 하지 않아도 된다.

3.6.4 query()

기능정의와 테스트작성

getAll() 메소드 추가

  • 현재 등록되어 있는 모든 사용자 정보를 가져오는 메서드
  • User가 여러 개라면 User 오브젝트의 컬렉션에 담는 것이 좋을 것 같다. List이용
  • 담는 순서는 id 순으로 정렬

테스트 작성

  • user1, user2, user3 세 개를 등록하고 getAll()을 호출하면 List 오브젝트는 돌려받아야 한다.
  • 리스트의 크기는 3이어야 한다.
  • user1, user2, user3와 동일한 내용을 가진 오브젝트가 id 순서대로 담겨있어야 한다.
  • User 오브젝트를 비교할 때는 동일성 비교가 아닌 동등성 비교를 하여야한다.
@Test
public void getAllUsers() throws SQLException {
    dao.deleteAll();
    
    dao.add(user1); // gyumee
    List<User> users1 = dao.getAll();
    assertThat(users1.size(), is(1));
    checkSameUser(user1, users1.get(0));
    
    dao.add(user2); // leegw700
    List<User> users2 = dao.getAll();
    assertThat(users2.size(), is(2));
    checkSameUser(user1, users2.get(0));
    checkSameUser(user2, users2.get(1));
    
    dao.add(user3); // bumjin
    List<User> users3 = dao.getAll();
    assertThat(users3.size(), is(3));
    checkSameUser(user3, users3.get(0));
    checkSameUser(user1, users3.get(1));
    checkSameUser(user2, users3.get(2));
}

private void checkSameUser(User user1, User user2) {
    assertThat(user1.getId(), is(user2.getId()));
    assertThat(user1.getName(), is(user2.getName()));
    assertThat(user1.getPassword(), is(user2.getPassword()));
}

UserDaoTest 내에 픽스처로 준비해둔 user1, user2, user3을 차례로 추가

  • getAll()이 돌려주는 리스트의 크기 비교
  • 리스트에 담긴 User 오브젝트의 내용을 픽스쳐와 비교
  • 아이디 순서로 정렬됨을 주의
  • User를 비교하는 메서드는 반복되므로 메소드 추출 기법을 이용한다.

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

getAll() 메소드 구현

  • JdbcTemplate의 query() 메소드를 사용
  • queryForObject() : ResultSet 결과가 하나일 때
  • query() : ResultSet의 결과가 여러 개일 때
  • 리턴 타입 : List<T>
  • RowMapper<T> 콜백 오브젝트에서 타입 결정
public List<User> getAll() {
    return this.jdbcTemplate.query("SELECT * FROM users ORDER BY id", new Object[] {},
        new RowMapper<User>() {
            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;
            }
        }
    );
}
  • 첫번째 파라미터 : 실행할 SQL 쿼리
  • 두번째 파라미터 ~ : 바인딩할 파라미터 (생략가능)
  • 마지막 파라미터 : RowMapper의 콜백

테스트 보완

getAll() 메소드에 대한 예외 조건 테스트

  • Id가 없을 때는?
  • 결과가 하나도 없는 경우는?
  • query()는 결과가 없을 경우 크기 0의 List를 리턴한다.
  • 그대로 쓰도록 하자
@Test
public void getAllUsers() throws SQLException {
    dao.deleteAll();
    List<User> users0 = dao.getAll();
    assertThat(users0.size(), is(0));
}

query()의 리턴 값은 정해져 있는데 굳이 테스트해야하나?

  • UserDaoTest는 UserDao의 메소드에 대해 기대하는 동작에 대한 검증이 먼저다.
  • query()를 사용했다 하더라도 내부적으로 다른 값을 리턴하게 만들 수도 있다.
  • 학습 테스트로서의 의미가 있다.

3.6.5 재사용 가능한 콜백의 분리

DI를 위한 코드 정리

필요 없어진 변수(DataSource) 정리

  • 수정자 메소드는 JdbcTemplate 생성시 직접 DI 해야하므로 남겨둔다.

중복제거

get()과 getAll()의 RowMapper 중복

  • 두 번밖에 중복되지 않았으나, 앞으로 계속 생길 것 같다.
  • User용 RowMapper 콜백을 메소드에서 분리

RowMapper

  • 상태 정보가 없으므로 멀티스레드에서 동시에 사용 가능
  • 매번 새로운 오브젝트를 만들 필요가 없다.

템플릿/콜백 패턴과 UserDao

최종 완성된 UserDao

public class UserDao {
    private JdbcTemplate jdbcTemplate;

    private RowMapper<User> userMapper = new RowMapper<User>() {
        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;
        }
    };

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

    public void add(final User user) throws SQLException {
        this.jdbcTemplate.update("INSERT INTO users(id, name, password) VALUES (?, ?, ?)", 
                user.getId(), user.getName(), user.getPassword());
    }

    public User get(String id) throws SQLException {
        return this.jdbcTemplate.queryForObject("SELECT * FROM users WHERE ID = ?", 
            new Object[] {id}, this.userMapper
        );
    }

    public void deleteAll() throws SQLException {
        this.jdbcTemplate.update("DELETE FROM users WHERE 1=1");
    }

    public int getCount() throws SQLException{
        return this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users", Integer.class);
    }

    public List<User> getAll() {
        return this.jdbcTemplate.query("SELECT * FROM users ORDER BY id", 
            new Object[] {}, this.userMapper
        );
    }
}

UserDao

  • User 정보를 DB에 넣거나 가져오거나 조작하는 방법에 대한 핵심적인 로직만 담겨 있음
  • 테이블과 필드 정보가 바뀌면 UserDao의 거의 모든 코드가 함께 바뀐다.(높은 응집도)
  • JDBC API 사용방식, 예외처리, 리소스 반납, DB 연결 등의 책임과 관심은 모두 JdbcTemplate이 가지고 있음(낮은 결합도)
  • 단, JdbcTemplate이라는 템플릿 클래스를 직접 이용
  • JDBC를 이용하는 DAO의 표준이므로 JDBC 외 다른 기술을 사용하지 않는 한 바뀔 일이 거의 없음
  • 그럼에도 불구하고, 더 낮은 결합도를 원한다면 JdbcTemplate을 독립된 빈으로 등록하고, JdbcOperations 인터페이스를 통해 DI 받아 사용할 수도 있다.

개선 방안

  1. userMapper
    • 인스턴스 변수로 설정되어 있음
    • 한 번 만들어지면 변경되지 않는 프로퍼티의 성격을 가지고 있음
    • DI용 프로퍼티로 만들면?
    • XML 설정에 테이블 필드 이름과 User 오브젝트 프로퍼티 매핑정보를 담으면 DB 테이블 필드의 이름이 바뀌어도 UserDao를 건드리지 않을 수 있다.
  2. SQL 문장의 분리
    • UserDao의 코드가 아닌 외부 리소스에 담고 이를 읽어와 사용하도록
    • SQL이 독립된 파일로 분리되면 편할 것 같다.


' Spring > 토비의 스프링 3.1' 카테고리의 다른 글

4장. 예외  (0) 2019.01.25
3.7 정리  (0) 2019.01.23
3.5 템플릿과 콜백  (0) 2019.01.23
3.4 컨텍스트와 DI  (0) 2019.01.22
3.3 JDBC 전략 패턴의 최적화  (0) 2019.01.18