Spring/토비의 스프링 3.1
3.6 스프링의 JdbcTemplate
다비드박
2019. 1. 23. 17:06
스프링이 제공하는 템플릿/콜백 기술
- 기본 템플릿 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을 전달받는 콜백
동작방식
- PreparedStatementCreator 콜백은 템플릿으로부터 Connection을 받고 PreparedStatement를 돌려줌
- 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 받아 사용할 수도 있다.
개선 방안
- userMapper
- 인스턴스 변수로 설정되어 있음
- 한 번 만들어지면 변경되지 않는 프로퍼티의 성격을 가지고 있음
- DI용 프로퍼티로 만들면?
- XML 설정에 테이블 필드 이름과 User 오브젝트 프로퍼티 매핑정보를 담으면 DB 테이블 필드의 이름이 바뀌어도 UserDao를 건드리지 않을 수 있다.
- SQL 문장의 분리
- UserDao의 코드가 아닌 외부 리소스에 담고 이를 읽어와 사용하도록
- SQL이 독립된 파일로 분리되면 편할 것 같다.