변화의 특성이 다른 부분을 구분해주고, 각각 다른 목적과 다른 이유에 의해 다른 시점에 독립적으로 변경될 수 있는 효율적인 구조를 만들어주는 것이 개발 폐쇄 원칙이다.
템플릿이란 이렇게 바뀌는 성질이 다른 코드 중에서 변경이 거의 일어나지 않으며 일정한 패턴으로 유지되는 특성을 가진 부분을 자유롭게 변경되는 성질을 가진 부분으로부터 독립시켜서 효과적으로 활용할 수 있도록 하는 방법이다.
3.1 다시 보는 초난감 DAO
UserDao의 코드에는 아직 문제점이 남아 있다. 바로 예외상황에 대한 처리다.
예외처리 기능을 갖춘 DAO
자원 과부화를 예방하기 위해 어떤 이유로든 예외가 발생했을 경우에도 사용한 리소스를 반드시 반환하도록 만들어야 한다.
JDBC 수정 기능의 예외처리 코드
UserDao.java 일부
public void deleteAll() throws SQLException{
Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("delete from users");
ps.excutedUpdate();
ps.close();
c.close();
}
이 코드에서 PreparedStaement를 처리하는 중에 예외가 발생하면 메소드 실행을 끝마치지 못하고 바로 메소드를 빠져나가게 된다. 이때 문제는 Connection과 PreparedStatment의 close() 메소드가 실행되지 않아서 제대로 리소스가 반환되지 않을 수 있다는 점이다.
DB 풀은 매번 getConnection()으로 가져간 커넥션을 명시적으로 close()해서 돌려줘야지만 재사용할 수 있다.
그런데 이런 식으로 오류가 날 때마다 미처 반환되지 못한 Connection이 계속 쌓이면 어느 순간에 커넥션 풀에 여유가 없어지고 리소스가 모자란다는 심각한 오류를 내며 서버가 중단될 수 있다.
try/ catch문을 이용해 리팩토링해보자.
UserDao.java 일부
public void deleteAll() throws SQLException{
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = c.prepareStatement("delete from users");
ps.executeUpdate();
}catch(SQLException e) {
throw e;
}finally{
//ps.executeUpdate에서 오류가 났을 경우
if(ps != null) {
try {
//c와 ps는 값이 있음
ps.close();
}catch(SQLException e) {
}
}
//c.prepareStatement에서 오류가 났을 경우
if(c != null) {
try {
//c는 값이 있음
c.close();
}catch(SQLException e) {
}
}
//dataSource.getConnection()에서 오류가 났을 경우
}
위 코드에서 오류가 날 수 있는 부분은 getConnection, PrepareStatement, executedUpdate 부분이다.
getConnection부분에서 오류가 날 경우 변수 c, ps에 값이 들어가있지 않아 close()를 할 필요가 없다. 하지만 PrepareStatement에서 오류가 났다는 것은 getConnection은 성공을 한 후이기에 변수 c에는 값이 들어가 있다. excutedUpdate경우도 변수 c, ps 둘 다 값이 있기에 close()를 통해 반환해야 한다.
JDBC 조회 기능의 예외처리
조회는 이전의 기능에 ResultSet도 반환해야 하는 리소스다.
UserDao.java 일부
public int getCount() throws SQLException{
Connection c = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
c = dataSource.getConnection();
ps = c.prepareStatement("select count(*) from users");
rs = ps.executeQuery();
rs.next();
return rs.getInt(1);
}catch (SQLException e) {
throw e;
}finally {
//rs.next()에서 오류가 났을 경우
if(rs != null) {
try {
rs.close();
}catch(SQLException e) {
}
}
if(ps != null) {
try {
ps.close();
}catch(SQLException e) {
}
}
if(c != null) {
try {
c.close();
}catch(SQLException e) {
}
}
}
}
3.2 변하는 것과 변하지 않는 것
JDBC try/catch/finally 코드의 문제점
복잡한 try/catch/finally 블록이 2중으로 중첩까지 되어 나오는데다, 모든 메소드마다 반복된다는 문제를 안고있다.
만약 코드 작성 시 close()를 하나라도 빼먹으면 커넥션을 반환하지 못하고, 반복 되다보면 결국 리소스 부족으로 서비스가 중단되는 상황이 발생한다.
그렇다고 테스트를 통해 DAO마다 예외상황에서 리소스를 반납하는지 체크하게 하는 것은 적용하기 쉽지않다.
코드를 자세히 살펴보면 Chpater01에서 살펴봤던 것과 비슷한 문제이고 같은 방법으로 접근하면 된다. 다만 성격이 약간 달라 해결 방법도 조금 다르다.
분리와 재사용을 위한 디자인 패턴 적용
deleteAll() 메서드의 코드를 보면 변하는 부분과 변하지 않는 부분이 나눠져 있다. PreparedStatement를 만들어서 업데이트용 쿼리를 실행하는 메소드라면 deleteALl()메소드와 구조는 거의 비슷할 것이다. 비슷한 기능의 메소드에서 동일하게 나타날 수 있는 변하지 않고 고정되는 부분과, 각 메소드마다 로직에 따라 변하는 부분을 구분할 수 있다.
그렇다면 이 로직에서 변하는 부분과 변하지 않는 부분을 분리해보자.
메소드 추출
먼저 생각해볼 수 있는 방법은 변하는 부분을 메소드로 뺴는 것이다.
UserDao.java 일부
public void deleteAll() throws SQLException{
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = makeStatement(c);
ps.executeUpdate();
}catch(SQLException e) {
//생략
}
private PreparedStatement makeStatement(Connection c) throws SQLException{
PreparedStatement ps;
ps = c.prepareStatement("delete from users");
return ps;
}
메소드 추출 리팩토링을 적용하는 경우에는 분리시킨 메소드를 다른 곳에서 재사용할 수 있어야 하는데, 이건 반대로 분리된 메소드가 DAO 로직마다 새롭게 만들어서 확장돼야 하는 부분이다
템플릿 메소드 패턴의 적용
템플릿 메소드 패턴은 공통된 알고릐즘 흐름을 호출하는 템플릿 메소드를 정의하고 변하는 부분은 따로 서브클래스에서 오버라이드 재정의하여 사용하는 디자인패턴 기법이다. 다음은 템플릿 메소드 패턴을 이용해서 분리해보자.
UserDaoDeleteAll.java
Public class UserDaoDeleteAll extends UserDao{
Protected PreparedStatement makeStatement(Connectin c) throws SQLException{
PreparedStatement ps = c.prepareStatement(“delete from users”);
return ps;
}
}
확장으로 인해 개방 폐쇄 원칙(OCP)를 지키는 구조이긴 하나 DAO 로직마다 상속을 통해 새로운 클래스를 만들어야 한다는 문제점이 있다. 이 또한 메소드 추출과 다른 바가 없다.
전략 패턴의 적용
전략 패턴은 오브젝트를 아예 둘로 분리하고 클래스 레벨에서는 인터페이스를 통해서만 의존하도록 만든다. Context의 contextMethod()에서 일정한 구조를 가지고 동작하다가 특정 확장 기능은 Strategy 인터페이스를 통해 외부의 독립된 전략 클래스에 위임하는 것이다.
deleteAll() 메소드에서 변하지 않는 부분이라고 명시한 것이 바로 이 contextMethod()가 된다. deleteAll()은 JDBC를 이용해 DB를 업데이트하는 작업이라는 변하지 않는 맥락(context)을 갖는다.
우선 PreparedStatement를 만드는 전략을 인터페이스로 다음과 같이 만든다.
StatementStrategy.java
package springbook.user.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
public interface StatementStrategy {
PreparedStatement makPreParedStatement(Connection c) throws SQLException;
}
그리고 이것을 상속한 전략으로 만든다.
DeleteAllStatement.java
package springbook.user.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
public class DeleteAllStatement implements StatementStrategy{
@Override
public PreparedStatement makPreParedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("delete from users");
return ps;
}
}
이제 UserDao의 deleteAll() 메소드에서 이 전략 인스턴스를 생성한 후 사용하면 된다.
UserDao.java 일부
public void deleteAll() throws SQLException{
//생략
try {
c = dataSource.getConnection();
StatementStrategy strategy = new DeleteAllStatement();
ps = strategy.makePreparedStatement(c);
ps.executeUpdate();
}catch(SQLException e){
//생략
}
}
전략 패턴의 방식을 적용했으나 문제점이 있다. 전략 패턴은 필요에 따라 컨텍스트는 그대로 유지되면서(OCP의 폐쇠 원칙) 전략을 바꿔 쓸 수 있다(OCP의 개방 원칙)는 것인데, 이렇게 컨텍스트 안에서 이미 구체적인 전략 클래스인 DeleteAllStatement를 사용하도록 고정되어 있다면 뭔가 이상하다.
DI 적용을 위한 클라잉언트/컨텍스트 분리
전략 패턴에 따르면 Client가 구체적인 전략의 하나를 선택하고 오브젝트로 만들어서 Context에 전달하는 것이다.
위의 그림은 UserDao의 ConnectionMaker DI방식이 따랐던 흐름과 유사하다. add()메소드가 필요한 커넥션객체를 UserDaoTest 클라이언트에서 전략적으로 생성하여 UserDao에 넘겨준 후 add() 메소드가 이를 사용했다. 지금 상황에서도 이런 흐름에 맞게 개선할 필요가 있다.
여기서는 try/catch/finalyy 코드가 유사하게 반복되기 때문에 이 코드를 따로 빼서 컨텍스트로 만든다. 그리고 기존의 deleteAll()은 클라이언트 역할을 하도록 DeleteAllStatement()를 만든 후 컨텍스트의 인자로 넘겨 컨텍스트를 호출하는 역할로 변경한다.
UserDao.java 일부
public void deleteAll() throws SQLException{
StatementStrategy st = new DeleteAllStatement();
jdbcContextWithStatementStrategy(st);
}
public void jdbcContextWithStatementStrategy(StatementStrategy stmt) throws SQLException{
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
// StatementStrategy strategy = new DeleteAllStatement();
ps = stmt.makPreParedStatement(c);
ps.executeUpdate();
}catch(SQLException e) {
throw e;
}finally{
//ps.executeUpdate에서 오류가 났을 경우
if(ps != null) {
try {
//c와 ps는 값이 있음
ps.close();
}catch(SQLException e) {
}
}
//c.prepareStatement에서 오류가 났을 경우
if(c != null) {
try {
//c는 값이 있음
c.close();
}catch(SQLException e) {
}
}
//dataSource.getConnection()에서 오류가 났을 경우
}
}
3.3 JDBC 전략 패턴의 최적화
독립된 JDBC 작업 흐름이 담긴 jdbcContextWithStatementStrategy() 는 DAO 메소드들이 공유할 수 있게 됐다. DAO 메서드는 전략 패턴의 클라이언트로서 컨텍스트에 해당하는 jdbcContextWithStatementStrategy() 메소드에 적절한 전략, 즉 바뀌는 로직을 제공해주는 방법으로 사용할 수 있다.
- 컨텍스트 : PreparedStatement를 실행하는 JDBC의 작업 흐름
- 전략 : PreparedStatement를 생성하는 것
전략 클래스의 추가 정보
이번엔 add() 메소드에도 적용해보자.
deleteAll()과는 달리 add() 에서는 PreparedStatement를 만들 때 user라는 부가적인 정보가 필요하기에 클라이언트가 AddStatement의 전략을 수행하려면 부가정보인 user를 제공해줘야 한다.
AddStatement.java
public class AddStatement implements StatementStrategy {
User user;
public AddStatement(User user) {
this.user = user;
}
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("insert into users(id,name,password) value(?,?,?)");
ps.setString(1, user.getId());
ps.setString(2, user.getName());
ps.setString(3, user.getPassword());
return ps;
}
}
UserDao.java 일부
public void add(User user) throws ClassNotFoundException, SQLException{
StatementStrategy st = new AddStatement(user);
jdbcContextWithStatementStrategy(st);
}
전략과 클라이언트의 동거
현재 만들어진 구조에 두 가지 문제가 있다.
- DAO 메소드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다.
- DAO 메소드에서 StatementStrategy에 전달할 User와 같은 부가적인 정보가 있는 경우, 이를 위해 오브젝트를 전달받는 생성자와 이를 저장해둘 인스턴스 변수를 번거롭게 만들어야 한다.
이를 중첩 클래스를 통해 해결해보자.
중첩 클래스의 종류
다른 클래스 내부에 정의되는 클래스를 중첩 클래스라 한다. 중첩 클래스는 독립적으로 오브젝트로 만들어질 수 있는 스태틱 클래스와 자신이 정의된 클래스의 오브젝트 안에서만 만들어질 수 있는 내부 클래스로 구분된다.
내부 클래스는 다시 범위(scope)에 따라 세 가지로 구분된다. 멤버 필드처럼 오브젝트 레벨에 정의되는 멤버 내부 클래스와 메소드 레벨에 정의되는 로컬 클래스, 그리고 이름을 갖지 않는 익명 내부 클래스다.
로컬 클래스
클래스 파일이 많아지는 문제는 간단한 해결 방법이 있다. StatementStrategy 전략 클래스를 UserDao 클래스 안에 내부 클래스로 정의해버리는 것이다.
public void add(final User user) throws ClassNotFoundException, SQLException{
//내부 클래스로 선언
class AddStatement implements StatementStrategy {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
PreparedStatement ps = c.prepareStatement("insert into users(id,name,password) value(?,?,?)");
ps.setString(1, user.getId());
ps.setString(2, user.getName());
ps.setString(3, user.getPassword());
return ps;
}
}
//내부 클래스가 외부 메소드 user를 사용할 수 있으므로 생성자로 user를 넘길 필요가 없어짐.
StatementStrategy st = new AddStatement();
jdbcContextWithStatementStrategy(st);
}
로컬 클래스로 만들어두니 장점이 많다. AddStatement는 복잡한 클래스가 아니므로 메소드 안에서 정의해도 그다지 복잡해 보이지 않는다. 메소드마다 추가해야 했던 클래스 파일을 하나 줄일 수 있다는 것도 장점이고, 내부 클래스의 특징을 이용해 로컬 변수를 바로 가져다 사용할 수 있다는 것도 큰 장점이다.
다만, 내부 클래스에서 외부의 변수를 사용할 때는 외부 변수는 반드시 final로 선언해줘야 한다.
익명 내부 클래스
AddStatement를 익명 내부 클래스로 만들어보자. 익명 내부 클래스는 선언과 동시에 오브젝트를 생성한다.
만들어진 익명 내부 클래스의 오브젝트는 딱 한 번만 사용할 테니 굳이 변수에 담아 두지 말고 jdbcContextWithStatementStrategy() 메소드의 파라미터에서 바로 생성하는 편이 낫다.
UserDao.java 일부
public void add(final User user) throws ClassNotFoundException, SQLException{
jdbcContextWithStatementStrategy(new StatementStrategy() {
@Override
public PreparedStatement makePreParedStatement(Connection c) throws SQLException {
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;
}
});
}
마찬가지로 DeleteAllStatement도 deleteAll() 메소드로 가져와서 익명 내부 클래스로 처리하면 다음과 같다.
UserDao.java 일부
public void deleteAll() throws SQLException{
jdbcContextWithStatementStrategy(new StatementStrategy() {
@Override
public PreparedStatement makePreParedStatement(Connection c) throws SQLException {
return c.prepareStatement("delete from users");
}
});
}
3.4컨텍스트와 DI
JdbcContext의 분리
JDBC의 일반적인 작업 흐름을 담고 있는 jdbcContextWithStatmentStrategy()는 다른 DAO에서도 사용 가능하다. 그러니 이 메서드를 UserDao 클래스 밖으로 독립시켜서 모든 DAO가 사용할 수 있게 해보자.
클래스 분리
분리해서 만들 클래스의 이름은 JdbcCOntext라고 하자. JdbcContext에 UserDao에 있던 컨텍스트 메소드를 workWithStatementStrategy()라는 이름으로 옮겨놓는다. 그리고 DataSource에 의존하기에 setDataSource()로 DI받을 수 있도록 만든다.
JdbcContext.java
package springbook.user.dao;
public class JdbcContext {
private DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException{
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = stmt.makePreParedStatement(c);
ps.executeUpdate();
}catch(SQLException e) {
throw e;
}finally{
//ps.executeUpdate에서 오류가 났을 경우
if(ps != null) {try { ps.close();}catch(SQLException e) {}}
//c.prepareStatement에서 오류가 났을 경우
if(c != null) {try {c.close();}catch(SQLException e) {}}
//dataSource.getConnection()에서 오류가 났을 경우는 리소스 반환이 필요 없음.
}
}
}
UserDao가 분리된 JdbcContext를 DI 받아서 사용할 수 있게 만든다.
UserDao.java 일부
package springbook.user.dao;
public class UserDao {
private DataSource dataSource;
private JdbcContext jdbcContext;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public void setJdbcContext(JdbcContext jdbcContext) {
this.jdbcContext = jdbcContext;
}
public void add(final User user) throws ClassNotFoundException, SQLException{
this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
@Override
public PreparedStatement makePreParedStatement(Connection c) throws SQLException {
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;
}
});
}
public void deleteAll() throws SQLException{
this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
@Override
public PreparedStatement makePreParedStatement(Connection c) throws SQLException {
return c.prepareStatement("delete from users");
}
});
}
}
빈 의존관계 변경
UserDao는 이제 JdbcContext에 의존하고 있다. 그런데 JdbcContext는 인터페이스인 DataSource와는 달리 구체 클래스다. 스프링의 DI는 기본적으로 인터페이스를 사이에 두고 의존 클래스를 바꿔서 사용하도록 하는 게 목적이지만 JdbcContext는 그 자체로 독립적이고 구현 방법이 바뀔 가능성이 없다. 따라서 인터페이스가 아닌 구체 클래스로 DI를 적용하는 특별한 구조가 된다. 다음 그림은 JdbcContext가 추가된 의존관계를 나타내주는 클래스 다이어그램이다.
이를 바탕으로 jdbcContext 빈 생성 후 userDao가 DI받을 수 있도록 의존관계를 설정해주었다.
DaoFactory.java
package springbook.user.dao;
@Configuration
public class DaoFactory {
@Bean
public UserDao userDao() {
UserDao userDao = new UserDao();
userDao.setDataSource(dataSource());
userDao.setJdbcContext(jdbcContext());
return userDao;
}
@Bean
public DataSource dataSource() {
SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
dataSource.setDriverClass(com.mysql.cj.jdbc.Driver.class);
dataSource.setUrl("jdbc:mysql://localhost/springbook");
dataSource.setUsername("root");
dataSource.setPassword("!dydals1!");
return dataSource;
}
@Bean
public JdbcContext jdbcContext() {
JdbcContext jdbcContext = new JdbcContext();
jdbcContext.setDataSource(dataSource());
return jdbcContext;
}
}
JdbcContext의 특별한 DI
UserDao와 JdbcContext 사이에는 지금까지 적용했던것과는 다르게 인터페이스를 사용하지 않고 바로 JdbcContext 클래스에 DI를 적용했다. 즉 UserDao와 JdbcContext는 클래스 레벨에서 의존관계가 결정된다.
스프링 빈으로 DI
이렇게 인터페이스를 사용하지 않고 DI를 적용하는 것은 문제가 있지 않을까?
DI라는 개념을 충실히 따르자면, 인터페이스를 사이에 둬서 런타임 시에 의존할 오브젝트와의 관계를 다이내믹하게 주입해주는 것이 맞다. 따라서 인터페이스를 사용하지 않았다면 온전한 DI라 볼 수 없다. 그러나 스프링의 DI는 객체의 생성 및 관계설정에 대한 제어권한을 외부로 위임했다는 IoC라는 개념을 포괄한다. 그런 의미에서 JdbcContext를 스프링을 이용해 UserDao 객체에서 사용하게 주입했다는 건 DI의 기본을 따르고 있다 볼 수 있다.
그럼 JdbcContext를 UserDao와 DI 구조로 만들어야 할 이유를 생각해보자.
- JdbcContext가 스프링 컨테이너의 싱글레지스트리에서 관리되는 싱글톤 빈이 된다. JdbcContext는 그 자체로 변경되는 상태정보를 갖고 있지 않다. 그리고 dataSource 인스턴스 변수를 사용하지만 읽기전용이므로 JdbcContext가 싱글톤이 되는 데 아무런 문제가 없다.
- JdbcContext는 DI를 통해 dataSource에 의존하고 있다. DI를 사용하려면 주입받는 오브젝트(JdbcContext)와 주입되는 오브젝트(DataSource) 모두 스프링의 IoC 대상이어야 DI에 참여할 수 있기 때문이다. 따라서 다른 빈을 DI 받기 위해 스프링 빈으로 등록되어야 한다.
인터페이스를 사용 안 한 이유
여기서 중요한 것은 인터페이스의 사용 여부자. 왜 인터페이스를 사용하지 않았을까? UserDao는 항상 JdbcContext 클래스와 함께 사용돼야 한다. 비록 클래스는 구분되어 있지만 이 둘은 강한 응집도를 갖고있다. 즉 UserDao가 JDBC 방식 대신 ORM을 사용해야 한다면 JdbcContext도 통째로 바뀌기에 상관없다.
코드를 이용하는 수동 DI
위의 방법 대신 UserDao 내부에서 직접 DI를 적용하는 방법이 있다. JdbcContext를 스프링 빈으로 등록하지 안 한다면 JdbcContext와 DataSource의 DI를 어떻게 해야할까? JdbcCOntext에 대한 제어권을 갖고 생성과 관리를 담당하는 UserDao에게 DI까지 맡기는 것이다. UserDao는 직접 DataSource 빈을 필요로 하지 않지만 JdbcContext에 대한 DI 작업에 사용할 용도로 제공받는 것이다.
DaoFactory.java 일부
package springbook.user.dao;
@Configuration
public class DaoFactory {
@Bean
public UserDao userDao() {
UserDao userDao = new UserDao();
userDao.setDataSource(dataSource());
return userDao;
}
@Bean
public DataSource dataSource() {
SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
dataSource.setDriverClass(com.mysql.cj.jdbc.Driver.class);
dataSource.setUrl("jdbc:mysql://localhost/springbook");
dataSource.setUsername("root");
dataSource.setPassword("!dydals1!");
return dataSource;
}
}
UserDao의 setDataSource() 메소드는 다음과 같이 변경한다.
UserDao.java 일부
public class UserDao {
private DataSource dataSource;
private JdbcContext jdbcContext;
//Dependency Injection방식
public void setDataSource(DataSource dataSource) {
this.jdbcContext = new JdbcContext();
this.jdbcContext.setDataSource(dataSource);
this.dataSource = dataSource;
}
}
이 방법의 장점은 굳이 인터페이스를 두지 않아도 될 만큼 긴밀한 관계를 갖는 DAO 클래스와 JdbcContext를 어색하게 따로 빈으로 분리하지 않고 내부에서 직접 만들어 사용하면서도 다른 오브젝트에 대한 DI를 적용할 수 있다는 점이다.
장단점 비교(JdbcContext의 스프링 빈 처리 방식 vs UserDao 자체 수동 DI 처리 방식)
- JdbcContext의 스프링 빈 처리 방식은 다음과 같은 장단점이 있다.
- 오브젝트 사이의 실제 의존관계가 설정파일에 명확하게 드러난다.
- DI의 근본적인 원칙에 부합하지 않는 구체적인 클래스와의 관계가 설정에 직접 노출된다.
- UserDao 자체 수동 DI 처리 방식
- JdbcContext가 UserDao의 내부에서 만들어지고 사용되면서 그 관계를 외부에는 드러내지 않는다.
- JdbcContext를 여러 오브젝트가 사용하더라도 싱글톤으로 만들 수 없다.
- DI 작업을 위한 부가적인 코드가 필요하다.
3.5 템플릿과 콜백
템플릿/콜백 패턴은 전략 패턴의 기본 구조에 익명 내부 클래스를 활용한 방식이다. UserDao와 StatementStrategy, JdbcContext가 그러하다. 전략 패턴의 컨텍스트를 템플리이라 부르고, 익명 내부 클래스로 만들어지는 오브젝트를 콜백이라 한다.
템플릿/ 콜백의 동작원리
- 템플릿 -고정된 작업 흐름을 가진 코드를 재사용한다는 의미에서 붙인 이름이다.
- 콜백 - 템플릿 안에서 호출되는 것을 목적으로 만들어진 오브젝트
템플릿/ 콜백의 특징
템플릿의 작업 흐름 중 특정 기능을 위해 한 번 호출되는 경우가 일반적이기에 콜백은 단일 메소드 인터페이스를 사용한다. 즉 콜백은 일반적으로 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스로 만들어진다고 보면된다.
콜백 인터페이스의 메소드에는 템플릿의 작업 흐름 중에 만들어지는 컨텍스트 정보를 전달 받기 위해 보통 파라미터가 있다. JdbcContext에서는 템플릿인 workWithStatementStrategy() 메소드 내에서 생성한 Connection 오브젝트를 콜백의 메소드인 makePreparedStatement()를 실행할 때 파라미터로 넘겨준다.
편리한 콜백의 재활용
템플릿/콜백 방식에서 한 가지 아쉬운 점이 있다면 DAO 메소드에서 매번 익명 내부 클래스를 사용하기에 가독성이 안 좋다는 단점이다.
콜백의 분리와 재활용
콜백으로 전달하는 익명 내부 클래스의 코드를 보면 SQL 문장을 제외하고는 비슷한 코드가 반복된다. 그렇다면 SQL 문장만 파라미터로 받아서 바꿀 수 있게 하고 메소드 내용 전체를 분리해 별도의 메소드로 만들어보자.
UserDao.java 일부
public void deleteAll() throws SQLException {
executeSql("delete from users");
}
private void executeSql(final String query) throws SQLException {
this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
return c.prepareStatement(query);
}
});
}
복잡한 익명 내부 클래스인 콜백을 직접 만들 필요조차 없어졌다.
콜백과 템플릿의 결합
재사용 가능한 콜백을 담고 있는 executeSql() 메소드를 DAO가 공유할 수 있는 템플릿 클래스 안으로 옮겨도 된다. 엄밀히 말해서 템플릿은 JdbcContext 클래스가 아니라 workWithStatmentStrategy() 메소드이므로 JdbcContext 클래스로 executeSql() 메소드를 옮긴다고 해도 문제 될 것은 없다.
JdbcContext.java
package springbook.user.dao;
public class JdbcContext {
private DataSource dataSource;
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException{
Connection c = null;
PreparedStatement ps = null;
try {
c = dataSource.getConnection();
ps = stmt.makePreParedStatement(c);
ps.executeUpdate();
}catch(SQLException e) {
throw e;
}finally{
//ps.executeUpdate에서 오류가 났을 경우
if(ps != null) {try { ps.close();}catch(SQLException e) {}}
//c.prepareStatement에서 오류가 났을 경우
if(c != null) {try {c.close();}catch(SQLException e) {}}
//dataSource.getConnection()에서 오류가 났을 경우는 리소스 반환이 필요 없음.
}
}
public void executeSql(final String query) throws SQLException{
workWithStatementStrategy(new StatementStrategy() {
@Override
public PreparedStatement makePreParedStatement(Connection c) throws SQLException {
return c.prepareStatement(query);
}
});
}
}
deleteAll()클라이언트 메소드는 호출방식을 다음과 같이 수정한다.
UserDao.java 일부
public void deleteAll() throws SQLException {
this.jdbcContext.executeSql("delete from users");
}
3.6 스프링의 JdbcTemplate
스프링은 JDBC를 이용하는 DAO에서 사용할 수 있도록 준비된 다양한 템플릿과 콜백을 제공한다. 스프링이 제공하는 JDBC 템플릿은 JdbcTemplate이다. JdbcContext를 JdbcTemplate로 변경하자.
UserDao.java 일부
public class UserDao {
private JdbcTemplate jdbcTemplate;
private DataSource dataSource;
//Dependency Injection방식
public void setDataSource(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
this.dataSource = dataSource;
}
}
update()
deleteAll()
StatementStrategy 인터페이스의 makePreparedStatement() 메소드와 대응되는 JdbcTemplate 콜백은 PreparedStatementCreator 인터페이스의 createPreaparedStatement() 메소드다. 또 PreparedStatmentCreator 타입의 콜백을 받아서 사용하는 JdbcTemplate의 템플릿 메소드는 update()다.
JdbcTemplate의 콜백과 템플릿 메소드를 사용하도록 수정한 deleteAll()메소드는 다음과 같다.
UserDao.java 일부
public void deleteAll(){
this.jdbcTemplate.update(new PreparedStatementCreator(){
@Override
public PreparedStatement createPreparedStatement(Connection con) throws SQLException{
return con.prepareStatement("delete from users");
}
});
}
이를 더 함축시키면 다음과 같다.
public void deleteAll() throws SQLException{
this.jdbcTemplate.update("delete from users");
}
add()
add() 메소드는 값을 바인딩하는 인자들도 넘겨줘야한다.
UserDao.java 일부
public void add(final User user) throws SQLException{
this.jdbcTemplate.update("insert into users(id,name, password) values(?,?,?)",user.getId(),user.getName(),user.getPassword());
}
queryForObject()
다음은 아직 템플릿/콜백 방식을 적용하지 않았던 메소드에 JdbcTemplate을 적용해보자.
getCount()
ResultSetExtractor는 preparedStatement의 쿼리를 실행해서 얻은 ResultSet을 전달받는 콜백이다. ResultSetExtractor 콜백은 템플릿이 제공하는 ResultSet을 이용해 원하는 값을 추출해서 템플릿에 전달하면, 템플릿은 나머지 작업을 수행한 뒤에 그 값을 query() 메소드의 리턴 값으로 돌려준다.
UserDao.java 일부
public int getCount() throws SQLException{
return this.jdbcTemplate.query(new PreparedStatementCreator() {
public PreparedStatement createPreparedStatement(Connection con) throws SQLException{
return con.prepareStatement("select count(*) from users");
}
}, new ResultSetExtractor<Integer>() {
@Override
public Integer extractData(ResultSet rs) throws SQLException, DataAccessException {
rs.next();
return rs.getInt(1);
}
});
}
눈여겨볼 것은 ReultSetExtrator는 제너릭스 타입 파라미터를 갖는다는 점이다. ResultSet에서 추출할 수 있는 값의 타입은 다양하기 떄문에 타임 파라미터를 사용한 것이다.
원래는 queryForInt()라는 메서드를 이용해 ResultSetExtractor를 생략할 수 있었지만 현재는 삭제되었다. 대신 queryForObject로 다음과 같이 사용할 수 있다.
public int getCount() throws SQLException{
return this.jdbcTemplate.queryForObject( "SELECT count(*) from users", Integer.class );
}
get()
get()은 ResultSet에서 getCount() 처럼 단순한 값이 아니라 복잡한 User 오브젝트로 만드는 작업이 추가적으로 필요하다. ResultSet이 결과를 User 오브젝트를 만들어 프로퍼티에 넣어줘야 한다.
이를 위해, getCout)(에 적용했던 ResultSetExtractor 콜백 대신 RowMapper 콜백을 사용하겠다. RowMapper는 ResultSetExtractor와 다르게 ResultSet의 로우 하나를 매핑하기 위해 사용되기 때문에 여러 번 호출될 수 있다.
UserDao.java 일부
public User get(String id) throws ClassNotFoundException, 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;
}
});
}
첫 번째 파라미터는 PrepareStatement를 만들기 위한 SQL이고, 두 번째는 여기에 바인딩할 값들이다. 가변인자는 뒤에 파라미터가 있기에 대신 Object 타입 배열을 사용해야 한다.
query()
기능 정의와 테스트 작성
현재 등록되어 있는 모든 사용자 정보를 가져오는 getAll() 메소드를 추가한다. RowMapper를 사용하여 List<User>타입으로 반환하도록 한다.
이번에도 테스트를 먼저 만들어보자. User 타입의 오브젝트인 user1,2,3 세 개를 차례대로 DB에 등록하고 그 중간마다 getAll() 메소드를 사용하고 Result 오브젝트 개수를 비교해보자. 저장할 떄의 User 오브젝트와 getAll()로 가져온 User오브젝트를 비교할 때는 동일성 비교가 아니라 동등성 비교를 해야 한다는 점에 주의하자.
UserDaoTest.java
@SuppressWarnings("deprecation")
@Test
public void getAll() throws SQLException {
User user1 = new User("gyumee","박성철","springno1");
User user2 = new User("leegw700","이길원","springno2");
User user3 = new User("bumjin","박범진","springno3");
dao.deleteAll();
List<User> users0 = dao.getAll();
assertThat(users0.size(),is(0));
dao.add(user1);
List<User> users1 = dao.getAll();
assertThat(users1.size(),is(1));
checkSameUser(user1,users1.get(0));
dao.add(user2);
List<User> users2 = dao.getAll();
assertThat(users2.size(),is(2));
checkSameUser(user1,users2.get(0));
checkSameUser(user2,users2.get(1));
dao.add(user3);
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));
}
@SuppressWarnings("deprecation")
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()));
}
이때 주의할 점은 Id 순서대로 정렬된다는 점을 주의하자.
query() 템플릿을 이용하는 getAll() 구현
이제 이 테스트를 성공시키는 getAll() 메소드를 만들어보자.
UserDao.java
public List<User> getAll(){
return this.jdbcTemplate.query("select * from users order by id", new RowMapper() {
public Object 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;
}
});
}
query()템플릿은 SQL 결과 ResultSet의 모든 로우를 열람하며 로우마다 RowMapper 콜백을 호출한다. 또한 queryForObject()는 결과가 없을 때 Exception을 던지지만 query()는 결과가 없으면 크기가 0인 List<T>를 던진다.
재사용 가능한 콜백의 분리
테스트는 이제 충분한 듯하니 이쯤에서 UserDao 코드를 한번 살펴보자.
DI를 위한 코드 정리
UserDao의 모든 메소드가 JdbcTemplate을 이용하도록 만들었으니 DataSource를 직접 사용할 일은 없다. 단지 JdbcTemplate을 생성하면서 직접 DI 해주기 위해 필요한 DataSource를 전달받아야 하니 수정자 메소드는 남겨둔다.
UserDao.java 일부
public class UserDao {
private JdbcTemplate jdbcTemplate;
//Dependency Injection방식
public void setDataSource(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
}
중복 제거
get()과 getAll()을 보면 사용한 RowMapper의 내용이 똑같다는 사실을 알 수 있다. 사용되는 상황은 다르지만 ResultSet 로우 하나를 User 오브젝트 하나로 변환해주는 동일한 기능을 가진 콜백이다.
RowMapper 콜백 오브젝트에는 상태정보가 없다. 따라서 하나의 콜백 오브젝트를 멀티스레드에서 동시에 사용해도 문제가 되지 않는다. RowMapper 콜백은 하나만 만들어서 공유하자.
UserDao.java 일부
package springbook.user.dao;
public class UserDao {
public User get(String id ){
return this.jdbcTemplate.queryForObject("select * from users where id=?",
new Object[] {id},
this.userMapper);
}
public List<User> getAll(){
return this.jdbcTemplate.query("select * from users order by id", this.userMapper);
}
private RowMapper<User> userMapper = 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;
}
};
}
[참고]
https://roadofdevelopment.tistory.com/49?category=463221
'Spring' 카테고리의 다른 글
토비의 Spring_Chapter06_AOP (0) | 2022.11.10 |
---|---|
토비의 Spring_Chapter04_예외 (0) | 2022.10.27 |
토비의 Spring_Chapter02_테스트 (0) | 2022.10.14 |
토비의 Spring_Chapter01_오브젝트와 의존관계 (0) | 2022.10.11 |
SpringBoot_Chapter01_개요 (0) | 2022.10.05 |