템플릿이란 바뀌는 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법이다.
예외처리 기능을 갖춘 DAO
publicvoiddeleteAll() throws SQLException{Connection c =null;PreparedStatement ps =null;//예외가 발생할 가능성이 있는 코드를 모두 try 블록으로 묶어준다. try{ c =dataSource.getConnection(); ps =c.prepareStatement("delete from users");ps.executeUpdate(); }catch(SQLException e){throw e; }finally{//예외 발생하건 안하건 항상 실행 if(ps !=null){ //ps가 null일때 close 호출하면 NullPointerException try{ps.close(); //close()도 예외 발생할 수 있다. }catch(SQLException e){ } }if(c !=null){try{c.close(); }catch(SQLException e){ } } }// finally end}
close()는 만들어진 순서의 반대로 하는 것이 원칙이다.
이제 이 deleteAll() 메서드에 담겨 있던 변하지 않는 부분, 자주 변하는 부분을 전략 패턴을 사용해 깔끔하게 분리해보자.
클라이언트 책임을 담당할 deleteAll() 메서드
publicvoiddeleteAll() throws SQLException{StatementStrategy st =newDeleteAllStatement();jdbcContextWithStatementStrategy(st);}
publicinterfaceStatementStrategy{PreparedStatementmakePreparedStatement(Connection c) throwsSQLException;}
deleteAll() 메서드의 기능을 구현한 StatementStrategy 전략 클래스
publicclassDeleteAllStatementimplementsStatementStrategy{publicPreparedStatementmakePreparedStatement(Connection c) throwsSQLException{PreparedStatement ps =c.preparedStatement("delete from users");return ps; }}
이 방법은 두 가지 개선할 부분이 있다. 첫째는 DAO 메서드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다는 점이다. 이렇게 되면 상속을 사용하는 템플릿 메서드 패턴을 적용했을 때보다 그다지 나을게 없다. 두 번째는 add() 메서드 같은 경우, 새로운 user에 대한 부가적인 정보가 있어서 번거롭게 인스턴스 변수를 만들어야 한다는 점이다.
publicclassAddStatementimplementsStatementStrategy {User user;publicAddStatement(User user) {this.user= user; }publicPreparedStatementmakePreparedStatement(Connection c) {...ps.setString(1,user.getId());ps.setString(2,user.getName());ps.setString(3,user.getPassword());... }}
publicvoidadd(User user) throws SQLException {StatementStrategy st =newAddStatement(user);jdbcContextWithStatementStrategy(st);}
이 두 가지 문제를 해결할 수 있는 방법을 생각해보자.
로컬 클래스
StatementStrategy 전략 클래스를 매번 독립된 파일로 만들지 말고 UserDao 클래스 안에 내부 클래스로 정의해버리는 간단한 방법이 있다.
//내부 클래스에서 외부의 변수를 사용할 때는 외부변수를 반드시 final로 선언해줘야된다.publicvoidadd(finalUser user) throws SQLException{classAddStatementimplementsStatementStrategy{publicPreparedStatementmakePreparedStatement(Connection c) throwsSQLException{PreparedStatement ps =c.prepareStatement("insert into users(id, name, password) values(?,?,?)");ps.setString(1,user.getId());ps.setString(2,user.getName());ps.setString(3,user.getPassword());return ps; } }// class end StatementStrategy st =newAddStatement(); // 생성자 파라미터로 user 전달하지 않아도 된다.jdbcContextWithStatementStrategy(st); }
로컬 클래스로 만들어두니 장점이 많다. AddStatement는 복잡한 클래스가 아니므로 메서드 안에서 정의해도 그다지 복잡해 보이지 않는다. 메서드마다 추가해야 했던 클래스 파일을 하나 줄일 수 있다는 것도 장점이고, 내부 클래스의 특징을 이용해 로컬 변수를 바로 가져다 사용할 수 있다는 것도 큰 장점이다.
익명 내부 클래스
클래스 선언과 오브젝트 생성이 결합된 형태로 만들어지며, 클래스를 재사용할 필요가 없고 구현한 인터페이스 타입으로만 사용할 경우에 유용하다.
익명 내부 클래스는 선언과 동시에 오브젝트를 생성한다. 이름이 없기 때문에 클래스 자신의 타입을 가질 수 없고, 구현한 인터페이스 타입의 변수에만 저장할 수 있다.
publicvoidadd(finalUser user) throws SQLExecption{jdbcContextWithStatementStrategy(new StatementStrategy(){publicPreparedStatement makePreparedStatement(Connection c) throwsSQLException{PreparedStatement ps =c.prepareStatement("insert into users(id, name, password) values(?,?,?)");ps.setString(1,user.getId());ps.setString(2,user.getName());ps.setString(3,user.getPassword());return ps; } } );}
publicvoiddeleteAll() throws SQLException{jdbcContextWithStatementStrategy(new StatementStrategy(){publicPreparedStatement makePreparedStatement(Connection c) throwsSQLException{returnc.preparedStatement("delete from users"); } } );}
컨텍스트와 DI
jdbcContext의 분리 전략 패턴의 구조로 보자면 UserDao의 메서드가 클라이언트이고, 익명 내부 클래스로 만들어지는 것이 개별적인 전략이고, jdbcContextWithStatementStrategy() 메서드는 컨텍스트다. 컨텍스트 메서드는 UserDao 내의 PreparedStatement를 실행하는 기능을 가진 메서드에서 공유할 수 있다. 그런데 JDBC의 일반적인 작업 흐름을 담고 있는 jdbcContextWithStatementStrategy()는 다른 DAO에서도 사용 가능하다. 그러니 jdbcContextWithStatementStrategy()를 UserDao 클래스 밖으로 독립시켜서 모든 DAO가 사용할 수 있게 해보자.
앞에서 만들었던 executeSql()은 SQL 문장만 전달하면 미리 준비된 콜백을 만들어서 템플릿을 호출하는 것까지 한 번에 해주는 편리한 메서드였다. JdbcTemplate에도 기능이 비슷한 메서드가 존재한다. 콜백을 받는 update() 메서드와 이름은 동일한데 파라미터로 SQL 문장을 전달한다는 것만 다르다.
publicvoiddeleteAll() {this.jdbcTemplate.update("delete from users");}
JdbcTemplate은 add() 메서드에 대한 편리한 메서드도 제공된다. 치환자를 가진 SQL로 PreparedStatement를 만들고 함께 제공하는 파라미터를 순서대로 바인딩해주는 기능을 가진 update() 메서드를 사용할 수 있다. SQL과 함께 가변인자로 선언된 파라미터를 제공해주면 된다. 현재 add() 메서드에서 만드는 콜백은 아래와 같이 PreparedStatement를 만드는 것과 파라미터를 바인딩하는 두 가지 작업을 수행한다.
PreparedStatement ps =c.prepareStatement("insert into users(id, name, password) values(?,?,?)");ps.setString(1,user.getId());ps.setString(2,user.getName());ps.setString(3,user.getPassword());
이를 JdbcTemplate에서 제공하는 편리한 메서드로 바꿔보면 다음과 같이 간단하게 바꿀수 있다. PreparedStatement를 만들 때 사용하는 SQL은 동일하며 바인딩할 파라미터는 순서대로 넣어주면 된다.
this.jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)",user.getId(),user.getName(),user.getPassword());
queryForInt() 다음은 아직 템플릿/콜백 방식을 적용하지 않았던 getCount 메서드에 JdbcTemplate을 적용해보자.
publicintgetCount() throws SQLException {Connection c =dataSource.getConnection();PreparedStatement ps =c.prepareStatement("select count(*) from users");ResultSet rs =ps.executeQuery();rs.next();int count =rs.getInt(1);rs.close();ps.close();c.close();return count;}
getCount()는 SQL 쿼리를 실행하고 ResultSet을 통해 결과 값을 가져오는 코드다. 이런 작업 흐름을 가진 코드에서 사용할 수 있는 템플릿은 PreparedStatementCreator 콜백과 ResultSetExtractor 콜백을 파라미터로 받는 query() 메서드다. ResultSetExtractor 콜백은 템플릿이 제공하는 ResultSet을 이용해 원하는 값을 추출해서 템플릿에 전달하면, 템플릿은 나머지 작업을 수행한 뒤에 그 값을 query() 메서드의 리턴 값으로 돌려준다.
위의 콜백 오브젝트 코드는 재사용하기 좋은 구조다. SQL을 가지고 PreparedStatement를 만드는 첫 번째 콜백은 이미 재사용 방법을 알아봤다. 두 번째 콜백도 간단하다. SQL의 실행 결과가 하나의 정수 값이 되는 경우는 자주 볼 수 있다. 클라이언트에서 콜백의 작업을 위해 특별히 제공할 값도 없어서 단순하다. 손쉽게 ResultSetExtractor 콜백을 템플릿 안으로 옮겨 재활용할 수 있다. JdbcTemplate은 이런 기능을 가진 콜백을 내장하고 있는 queryForInt()라는 편리한 메서드를 제공한다. Integer 타입의 결과를 가져올 수 있는 SQL 문장만 전달해주면 된다.
publicintgetCount() {returnthis.jdbcTemplate.queryForInt("select count(*) from users");}
JdbcTemplate은 스프링이 제공하는 클래스이지만 DI 컨테이너를 굳이 필요로 하지 않는다. 직접 JdbcTemplate 오브젝트를 생성하고 필요한 DataSource를 전달해주기만 하면 JdbcTemplate의 모든 기능을 자유롭게 활용할 수 있다.
queryForObject() 이번엔 id를 통해 User 정보를 갖고오는 get() 메서드에 JdbcTemplate을 적용해보자. 문제는 ResultSet에서 getCount()처럼 단순한 값이 아니라 복잡한 User 오브젝트로 만들어야 한다. 즉, ResultSet의 결과를 User 오브젝트를 만들어 프로퍼티에 넣어줘야 한다.
이를 위해 getCount()에 적용했던 ResultExtractor 콜백 대신 RowMapper 콜백을 사용하겠다. ResultExtractor와 RowMapper 모두 템플릿으로부터 ResultSet을 전달받고, 필요한 정보를 추출해서 리턴하는 방식으로 동작한다. 다른 점은 ResultExtractor은 ResultSet을 한 번 전달받아 알아서 추출 작업을 모두 진행하고 최종 결과만 리턴해주면 되는 데 반해, RowMapper는 ResultSet의 로우 하나를 매핑하기 위해 사용되기 때문에 여러 번 호출될 수 있다는 점이다.
publicUserget(String id) {returnthis.jdbcTemplate.queryForObject("select * from users where id = ?",newObject[] {id},// SQL에 바인딩할 파라미터 값newRowMapper<User>() {publicUsermapRow(ResultSet rs,int rowNum) throwsSQLException {User user =newUser();user.setId(rs.getString("id"));user.setName(rs.getString("name"));user.setPassword(rs.getString("password"));return user; } });}
첫 번째 파라미터는 PreparedStatement를 만들기 위한 SQL이고, 두 번째는 여기에 바인딩할 값들이다. update() 에서처럼 가변인자를 사용하면 좋겠지만 뒤에 다른 파라미터가 있기 때문에 가변인자 대신 Object 타입 배열을 사용해야 한다. 배열 초기화 블록을 사용해서 SQL의 ?에 바인딩할 id 값을 전달한다. queryForObject() 내부에서 이 두 가지 파라미터를 사용하는 PreparedStatement 콜백이 만들어질 것이다.
queryForObject()는 SQL을 실행하면 한 개의 로우만 얻을 것이라고 기대한다. 그리고 ResultSet의 next()를 실행해서 첫 번째 로우로 이동시킨 후에 RowMapper 콜백을 호출한다. 이미 RowMapper가 호출되는 시점에서 ResultSet은 첫 번째 로우를 가리키고 있으므로 다시 rx.next()를 호출할 필요는 없다. RowMapper에서는 현재 ResultSet이 가리키고 있는 로우의 내용을 User 오브젝트에 그대로 담아서 리턴해주기만 하면 된다. RowMapper가 리턴한 User 오브젝트는 queryForObject() 메서드의 리턴 값으로 get() 메서드에 전달된다.
이렇게만 해도 일단 User 오브젝트를 조회하는 get() 메서드의 기본 기능은 충분히 구현됐다. 하지만 get() 메서드에는 한 가지 더 고려해야 할 게 있다. queryForObject()를 이용할 때 조회 결과가 없는 예외상황을 어떻게 처리해야 할까? 이를 위해 특별히 해줄 것은 없다. 이미 queryForObject()는 SQL을 실행해서 받은 로우의 개수가 하나가 아니라면 예외를 던지도록 만들어져 있다. 이때 던져지는 예외가 바로 EmptyResultDataAccessException이다.
query 앞에서 사용한 queryForObject()는 쿼리의 결과가 로우 하나일 때 사용하고, query()는 여러 개의 로우가 결과로 나오는 일반적인 경우에 쓸 수 있다. query()의 리턴 타입은 List<T>다.
publicList<User>getAll() {returnthis.jdbcTemplate.query("select * from users order by id",newRowMapper<User>() {publicUsermapRow(ResultSet rs,int rowNum) throwsSQLException {User user =newUser();user.setId(rs.getString("id"));user.setName(rs.getString("name"));user.setPassword(rs.getString("password"));return user; } });}
quert()는 결과가 없을 경우에 queryForObject()처럼 예외를 던지지는 않는다. 대신 크기가 0인 List<T> 오브젝트를 돌려준다.
중복 제거 get()과 getAll()을 보면 사용한 RowMapper의 내용이 똑같다는 사실을 알 수 있다. 사용되는 상황은 다르지만 ResultSet 로우 하나를 User 오브젝트 하나로 변환해주는 동일한 기능을 가진 콜백이다. RowMapper 콜백은 하나만 만들어서 공유하자.
인스턴스 변수에 저장해둔 userMapper 콜백 오브젝트는 아래와 같이 get()과 getAll()에서 사용하면 된다.
publicUserget(String id) {returnthis.jdbcTemplate.queryForObject("select * from users where id = ?",newObject[] {id},this.userMapper);}publicList<User>getAll() {returnthis.jdbcTemplate.query("select * from users order by id",this.userMapper);}