• [토비의 스프링 vol.1]3장 템플릿

    2021. 5. 16.

    by. 웰시코더

    반응형

    -토비의 스프링 3.1 vol.1의 3장을 정리 및 실습한다.


     1장의 초난감DAO코드에 템플릿 기법을 적용하여 완성도를 높여본다.

     

    3.1 다시 보는 초난감 DAO

    3.1.1 예외처리 기능을 갖춘 DAO

    JDBC 수정 기능의 예외처리 코드

     1장에서 만든 UserDao에 예외 처리를 추가한다. DB 커넥션 관련 코드는 리소스를 공유하기 때문에 예외가 발생하여 정상적으로 close()가 안 되면, 리소스 반환이 안 되어 나중에 리소스 부족과 관련된 에러가 발생할 수 있다. 일반적으로 서버에서는 제한된 DB 커넥션을 만들어 풀(Pool)로 관리한다. close()를 안 하면 다시 풀에 리소스를 넣지 못해서 반환하지 못한 커넥션이 쌓이면 다음 커넥션 요청 때 풀에 리소스가 없어서 서버 중단이 될 수 있다. 다음과 같이 try/catch/finally로 예외처리를 한다.

     

    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 {
                if(ps!=null) {try {ps.close();} catch(SQLException e) {}}
                if(c!=null) {try{c.close();}catch(SQLException e) {}}
            }
        }

     deletaAll()에 예외처리를 적용했다. c나 ps가 null인 상태에서 close()를 호출하면 NullPointerException이 발생하기 때문에 위와 같이 null여부를 우선 체크한 후 null이 아니면 close()를 호출하였다. close() 자체도 exception이 발생할 수 있기 때문에 try/catch문을 한 번 더 적용했다.

     

    JDBC 조회 기능의 예외처리

     getCount()에도 예외처리를 적용해야 하는데, 조회 메소드는 ResultSet의 rs.close()도 해야하기 때문에 더 길어진다. 위와 유사하기 때문에 코드는 생략하겠다.

     

     

    3.2 변하는 것과 변하지 않는 것

    3.2.1 JDBC try/catch/finally 코드의 문제점

     try/catch/finally가 계속해서 모든 메소드마다 반복된다. 중복의 문제가 발생하는 것이다.

     

    3.2.2 분리와 재사용을 위한 디자인 패턴 적용

     메소드마다 preparedStatement로 SQL을 작성하는 부분을 제외하고 대부분 같은 코드가 반복된다. 이 코드를 분리해야한다. 우선 메소드 추출기법을 생각해볼 수 있겠는데 이 방법은 변하는 부분을 makeStatement()로 빼기 때문에 문제가 있다. 왜냐하면 보통 메소드 추출 기법은 공통된 코드를 빼서 여러 곳에서 재사용하기 위함인데, 이는 반대로 변하는 부분을 뺐기 때문에 재사용이 어렵다.

     

    방법1.템플릿 메소드 패턴 적용하기

     템플릿 메소드 패턴은 공통된 알고리즘 흐름을 호출하는 템플릿 메소드를 정의하고 변하는 부분은 따로 서브클래스에서 오버라이드 재정의하여 사용하는 디자인패턴 기법이다. 

     deleteAll()을 예로 든다면, preparedStatement() 호출 부분이 다른 dao 메소드에서도 변하는 부분이기 때문에 이부분을 따로 추상 메소드 선언을 하고 이를 따로 구현한 코드를 사용하는 것이다. UserDao는 당연히 추상 클래스로 정의해야 한다. 상속해서 만든 클래스는 다음과 같다.

     

    UserDaoDeleteAll.java

    public class UserDaoDeleteAll extends UserDao {
        protected PreparedStatment makeStatement(Connection c) throws SQLException {
            PreparedStatement ps = c.prepareStatement("delete from users");
        }
    }

     그러나 이런식으로 사용하면 각각의 dao 메소드마다 클래스를 만들어야 한다는 문제점이 있다. 또한 확장구조가 클래스 설계 시점에 고정되어 OCP원칙에 위배된다.

     

    방법2.전략 패턴 적용하기

     일정한 구조가 로직은 컨텍스트(context)를 만들고 확장 기능은 전략(Strategy)으로 만들어 사용하는 패턴이다. 컨텍스트는 상황에 따라 변경된 전략을 사용하는 구조이다.

     우선 preparedStatement를 만드는 전략을 인터페이스(StatemStrategy.java)로 다음과 같이 만든다.

     

    StatementStrategy.java 

    public interface StatementStrategy {
        PreparedStatment makePreparedStatement(Connection c) throws SQLException;
    }

     

    그리고 이것을 상속한 전략(DeleteAllStatement.java)로 만든다.

     

    DeleteAllStatement.java

    public class DeleteAllStatement implements StatementStrategy{
        public PreparedStatement makePreparedStatemnt(Connection c) throws SQLException{
            PreparedStatement ps = c.preparedStatement("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){
            //생략
        }
    }

     전략 패턴의 방식을 적용했으나 문제점이 있다. deleteAll()이 컨텍스트의 역할을 하는데 컨텍스트가 구체적인 전략클래스인 DeleteAllStatement를 사용하도록 고정되어 있다. 만약 DeleteAllStatement가 수정되면 컨텍스트에 직접적인 영향을 미치기 때문에 OCP원칙에 잘 맞는 코드라고 볼 수 없다. 실제로 구체적인 전략클래스를 생성 및 사용하는 것은 컨텍스트의 역할이 아니라 '클라이언트'의 역할이기 때문에 컨텍스트와 클라이언트의 분리작업이 필요하다.

     

    방법2개선.DI적용을 위한 클라이언트와 컨텍스트 분리

     컨텍스트가 어떤 전략을 사용할지에 대해서는 클라이언트가 결정하도록 해야한다.

    출처.토비의스프링3.1 vol1

     위의 그림은 UserDao의 ConnectionMaker DI 방식이 따랐던 흐름과 유사하다. add()메소드가 필요한 커넥션객체를 UserDaoTest 클라이언트에서 전략적으로 생성하여 UserDao에 넘겨준 후 add() 메소드가 이를 사용했다. 지금 상황에서도 이런 흐름에 맞게 개선할 필요가 있다.

     여기서는 try/catch/finally 코드가 유사하게 반복되기 때문에 이 코드를 따로 빼서 컨텍스트로 만든다. 그리고 기존의 deleteAll()은 클라이언트 역할을 하도록 DeleteAllStatement()를 만든 후 컨텍스트의 인자로 넘겨 컨텍스트를 호출하는 역할로 변경한다. 다음과 같다.

     

    UserDao.java 일부

    //전략패턴 컨텍스트 역할
    public void jdbcContextWithStatementStrategy(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 {
            if(ps!=null) {try {ps.close();} catch(SQLException e) {}}
            if(c!=null) {try {ps.close();} catch(SQLException e) {}}
        }
    }
    
    //클라이언트 역할
    public void deleteAll() throws SQLException {
        StatementStrategy st = new DeleteAllStatement();    //전략 인스턴스 생성
        jdbcContextWithStatementStrategy(st);    //컨텍스트에 전략 인스턴스 인자로 호출
    }

     이제 클라이언트와 컨텍스트가 DI를 이용하여 분리된 코드로 개선되었다.

     

     

    3.3 JDBC 전략 패턴의 최적화

    3.3.1 전략 클래스의 추가 정보(User 객체)

        add() 메소드도 위와 같이 전략패턴을 이용하여 리펙토링을 해보려고 한다. 그러나 위와 같이 만들 때 deleteAll()과 다른 점이 있는데 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;
    	}
    }
    

     

    기존 add()는 클라이언트 역할을 하도록 다음과 같이 변경한다.

     

    UserDao.java 일부

    public void add(User user) throws ClassNotFoundException, SQLException{
            StatementStrategy st = new AddStatement(user);
            jdbcContextWithStatementStrategy(st);
    }

     

     

    3.3.2 전략과 클라이언트의 동거

     전략을 클래스로 만들기 때문에 클래스의 양이 계속 늘어나고 있다. 이를 개선하기 위한 방법을 소개한다.

     

    방법1.로컬 클래스로 만들기

     자바에서는 클래스를 메소드 안에 정의할 수 있는 방법이 있다. 마치 메소드 안의 로컬변수처럼 선언하여 사용하는 것이다. 또한 자신이 정의된 메소드의 파라미터 로컬 변수에 접근할 수 있기 때문에 로컬 클래스에 user를 받는 생성자가 필요 없다. 대신 메소드의 파라미터 로컬 변수는 반드시 final로 정의해줘야 한다. 다음과 같다.

     

    UserDao.java 일부

    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());	//외부 add메소드의 user 변수에 접근 가능
                ps.setString(2, user.getName()); //외부 add메소드의 user 변수에 접근 가능
                ps.setString(3, user.getPassword()); //외부 add메소드의 user 변수에 접근 가능
                return ps;
            }
        }
        StatementStrategy st = new AddStatement(); //내부 클래스가 외부 메소드 user를 사용할 수 있으므로 생성자로 user를 넘길 필요가 없어짐. 
        jdbcContextWithStatementStrategy(st);
    }

     

    방법2.익명 내부 클래스로 만들기

     로컬 클래스 AddStatement가 add()에서만 사용된다면 이름조차 필요 없는 익명 내부 클래스로 만들 수도 있다.

     

    UserDao.java 일부

    public void add(final User user) throws ClassNotFoundException, SQLException{
        StatementStrategy st = new StatementStrategy() {
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement("insert into users(id,name,password) value(?,?,?)");
                ps.setString(1, user.getId());	//외부 add메소드의 user 변수에 접근 가능
                ps.setString(2, user.getName()); //외부 add메소드의 user 변수에 접근 가능
                ps.setString(3, user.getPassword()); //외부 add메소드의 user 변수에 접근 가능
                return ps;
            }
        };
        jdbcContextWithStatementStrategy(st);
    }

     어차피 한 번만 사용할 것이라면 컨텍스트 메소드 호출 시에 바로 생성하여 넘겨줄 수도 있다. deleteAll()도 같은 방법으로 개선할 수 있다. 다음과 같다.

     

    UserDao.java 일부

    public void add(final User user) throws ClassNotFoundException, SQLException{
        jdbcContextWithStatementStrategy(new StatementStrategy() {
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement("insert into users(id,name,password) value(?,?,?)");
                ps.setString(1, user.getId());	//외부 add메소드의 user 변수에 접근 가능
                ps.setString(2, user.getName()); //외부 add메소드의 user 변수에 접근 가능
                ps.setString(3, user.getPassword()); //외부 add메소드의 user 변수에 접근 가능
                return ps;
            }
        });
    }
    
    public void deleteAll() throws SQLException {
        jdbcContextWithStatementStrategy(new StatementStrategy() {
                public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement("delete from users");
    	        return ps;
            }
        });
    }

     

     

    3.4 컨텍스트와 DI

    3.4.1 컨텍스트를 JdbcContext 클래스로 분리하기

     jdbcContextWithStateStrategy()는 JDBC의 기본적 흐름을 담고 있는 컨텍스트로써 다른 DAO에서도 사용 가능하다. 그렇기 때문에 UserDao에서 따로 클래스로 분리하는 작업을 해본다.

     

    클래스로 분리하기

     일단 UserDao에 있는 컨테스트 코드를 JdbcContext라는 클래스를 만든 후에 workWithStatementStrategy() 라는 이름으로 변경하여 작성한다. 그리고 DataSource에 의존하기 때문에 DataSource도 setter로 DI받을 수 있도록 만든다. 

     

    JdbcContext.java

    //import 코드 생략
    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 {
                 if(ps!=null) {try {ps.close();} catch(SQLException e) {}}
                 if(c!=null) {try {ps.close();} catch(SQLException e) {}}
             }
        }
    }
    

     

     이제 UserDao의 코드를 수정한다. 기존의 컨텍스트는 삭제한 후, JdbcContext를 DI받도록 만든다. 기존의 DataSource 코드는 아직 사용하고 있는 메소드들이 있기 때문에 그대로 냅둔다.


    UserDao.java 일부

    public class UserDao {
        private DataSource dataSource;
    	
        public void setDataSource(DataSource dataSource) {
            this.dataSource = dataSource;
        }
    	
        private JdbcContext jdbcContext;
    	
        public void setJdbcContext(JdbcContext jdbcContext) {
             this.jdbcContext = jdbcContext;
        }
    	
    	public void add(final User user) throws ClassNotFoundException, SQLException{
            this.jdbcContext.workWithStatementStrategy(new StatementStrategy() {
    			public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
    				PreparedStatement ps = c.prepareStatement("insert into users(id,name,password) value(?,?,?)");
    				ps.setString(1, user.getId());	//외부 add메소드의 user 변수에 접근 가능
    				ps.setString(2, user.getName()); //외부 add메소드의 user 변수에 접근 가능
    				ps.setString(3, user.getPassword()); //외부 add메소드의 user 변수에 접근 가능
    				return ps;
    			}
    		});
    	}	
        //다른 코드 생략
    }

      jdbcContext를 DI받도록 setter도 만들어줬다. 그리고 add() 메소드의 기존 컨텍스트 호출 부분을 this.jdbcContext.workWithStatementStrategy()로 변경해주었다. 현재의 변경된 빈 의존관계에 맞게 applicationContext.xml 설정파일을 다음과 같이 수정해준다.

     

    applicationContext.xml 일부

    <bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost/springbook"/>
        <property name="username" value="spring"/>
        <property name="password" value="book"/>
    </bean>
    	
    <bean id="jdbcContext" class="springbook.user.dao.jdbcContext">
        <property name="dataSource" ref="dataSource"/>
    </bean>
    
    <bean id="userDao" class="springbook.user.dao.UserDao">
        <property name="dataSource" ref="dataSource"/>
        <property name="jdbcContext" ref="jdbcContext"/>
    </bean>

    jdbcContext 빈을 만든 후, userdao가 DI받을 수 있도록 의존관계를 설정해주었다. 

     

    3.4.2.JdbcContext의 특별한 DI(인터페이스 없는 DI)

     인터페이스로 추상화한 후 UserDao와 JdbcContext 간의 결합을 느슨하게 만들지 않고 클래스로 결합력이 강하게 만든 이유가 무엇인지 알아볼 필요가 있다.

     

    JdbcContext를 굳이 스프링 빈으로 DI를 해야 하는 이유

     그전에 JdbcContext를 Userdao와 DI 구조로 만들어야 하는 이유는 다음과 같다.

    1. JdbcContext가 싱글톤 레지스트리에서 관리되는 싱글톤 빈이 된다. 우선 JdbcContext는 그 자체로 변경되는 상태정보가 없다. 그리고 dataSource 인스턴스 변수를 사용하지만 읽기 전용이기 때문에 문제가 되지 않는다. 그러므로 싱글톤으로 사용하여도 문제가 없으며, 여러 Dao에서 공유해서 사용할 수 있기 때문에 싱글톤으로 관리되는 것이 이상적이다.
    2. JdbcContext는 DI를 통해 dataSource에 의존하고 있다. DI를 사용하려면 주입받는 오브젝트(JdbcContext)와 주입되는 오브젝트(DataSource) 모두 스프링 IoC 대상이 되어야 하기 때문에 스프링 빈으로 등록되어야 한다. 따라서 다른 빈을 DI 받기 위해 스프링 빈으로 등록되어야 한다.

    인터페이스 사용 안 한 이유

     UserDao와 JdbcContext가 긴밀하게 결합되어 있지만 사실 이 둘은 강한 응집도를 갖고 있다. 즉, UserDao가 Jdbc방식 대신 JPA나 하이버네이트 ORM을 사용한다면 JdbcContext를 아예 바꿔버려야 한다. 이런 경우 굳이 인터페이스를 두지 않고 강력한 결합관계를 허용하면서 위의 이유로 인하여 DI 필요성을 위해 클래스로 만들어도 된다.

     

    코드를 이용하는 수동 DI

     위의 방법과 다르게 스프링 빈이 아니라 UserDao에서 직접 DI를 적용할 수도 있다. JdbcContext를 스프링 빈으로 등록 안 한다면 JdbcContext와 DataSource의 DI를 어떻게 해야할까? 이런 경우 UserDao에게 DI까지 맡기면 된다. DataSource를 UserDao가 대신 DI받고, UserDao가 사용할 목적이 아닌 JdbcContext에 전달해줄 목적으로 DI 받는다.

     우선 다음과 같이 applicationContext.xml을 수정한다.

     

    applicationContext.xml 일부

    <bean id="dataSource" class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost/springbook"/>
        <property name="username" value="spring"/>
        <property name="password" value="book"/>
    </bean>
    
    <bean id="userDao" class="springbook.user.dao.UserDao">
        <property name="dataSource" ref="dataSource"/>
    </bean>
    

     

     UserDao의 setDataSource는 다음과 같이 변경한다.

     

    UserDao.java 일부

    private DataSource dataSource;
    private JdbcContext jdbcContext;
    	
    public void setDataSource(DataSource dataSource) {
        this.jdbcContext = new JdbcContext();	//UserDao에서 jdbcContext의 초기화를 담당
        this.jdbcContext.setDataSource(dataSource);	//UserDao가 대신 DI받은 dataSource 인스턴스를 인자로 넘겨줌 
        this.dataSource = dataSource;	//기존 전략패턴 변경 전 dao 메소드들을 위해 남겨둠
    }

    이제 JdbcContext를 스프링 빈으로 등록하지 않고도 사용가능하도록 변경되었다.

     

    장단점 비교(JdbcContext의 스프링 빈 처리 방식 vs UserDao 자체 수동 DI 처리 방식)

     JdbcContext의 스프링 빈 처리 방식은 다음과 같은 장단점이 있다.

    • 오브젝트 사이의 실제 의존관계가 설정파일에 명확이 드러남
    • DI의 근본원칙에 부합하지 않는 구체적인 클래스관계가 노출된다는 단점

    UserDao 자체 수동 DI 처리 방식은 다음과 같은 장단점이 있다.

    • JdbcContext가 UserDao 내부에 만들어져 그 관계를 외부에 안 드러낸다는 장점
    • 싱글톤으로 만들지 못한다는 단점
    • DI작업을 위한 부가 코드 필요하다는 단점

    어느 것이 더 낫다는 것은 없으며 상황에 맞게 사용해야 한다.

     

     

    3.5 템플릿과 콜백

     템플릿/콜백 패턴은 '전략패턴 + 익명 내부 클래스'이다. 컨텍스트(JdbcContext)를 템플릿, 익명 내부 클래스를 콜백으로 본다.

     

    3.5.1 템플릿/콜백 동작원리

    템플릿/콜백 특징

     

     전략패턴과 달리 콜백은 단일 메소드 인터페이스로 사용한다. 템플릿 작업 흐름 중 보통 한 번만 호출되기 때문이다. 즉 콜백은 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스라고 볼 수 있다. 또한 콜백 인터페이스 메소드는 보통 파라미터가 있는데, 템플릿 작업 흐름 중에 만들어지는 컨텍스트 정보를 전달 받을 때 사용된다. JdbcContext의 workWithStatementStrategy() 메소드 내부에서 생성된 Connection 오브젝트가 콜백 메소드 makePreparedStatement() 파라미터로 넘어간다.

     

    템플릿/콜백 패턴 흐름도

     

    add() 템플릿/콜백 흐름도

     

    3.5.2 편리한 콜백의 재활용

     

    콜백의 분리와 재활용

     콜백으로 전달하는 익명 내부 클래스의 코드를 보면 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()은 UserDao만 사용하기 아깝기 때문에 재사용 가능한 콜백 기능이라면 DAO가 공유할 수 있는 템플릿 클래스 안으로 옮겨도 된다.  다음과 같이 수정한다.

     

    JdbcContext.java 일부

    public void executeSql(final String query) throws SQLException {
        workWithStatementStrategy(new StatementStrategy() {
            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");
    }

     

    실습 - add도 가변인자 써서 수정해보기

     책에서 add()는 알아서 해보라고 실습으로 줬다. 일단 쿼리와 함께 User 오브젝트의 값들을 넘겨줘야 한다. 우선 클라이언트인 add()는 다음과 같이 변경한다.

     

    UserDao.java 일부

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

     위와 같이 인자가 몇 개가 될지 모를 때 가변인자를 사용한다. 가변인자를 사용하면 executeSql()의 파라미터를 여러개로 선언할 필요도 없고 다이나믹 하게 파라미터를 받을 수 있다. 가변인자로 executeSql()을 수정한 후, 받은 가변인자를 PreparedStatement에 set해주고 템플릿으로 넘기도록 변경한다. 쿼리의 ?에 들어갈 인자가 없는 get()같은 경우는 가변인자를 받지 않기 때문에 가변인자의 null여부 체크를 한 후 분기문을 작성한다. 다음과 같다.

     

    JdbcContext.java 일부

    public void executeSql(final String query, final String... user) throws SQLException {
        workWithStatementStrategy(new StatementStrategy() {
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement(query);
                	
                if(user!=null) {
                    for(int i=1; i<=user.length; i++) {
                        ps.setString(i, user[i-1]);
                    }
                }
                
                return ps;
            }
        });
    }
    
    //전략패턴 컨텍스트 역할
    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 {
            if(ps!=null) {try {ps.close();} catch(SQLException e) {}}
            if(c!=null) {try {ps.close();} catch(SQLException e) {}}
        }
    }

     가변인자 파라미터 user는 몇 개가 들어올지 알 수 없다. 또한 가변인자는 배열이다. 위의 if(user!=null)로 가변인자가 있는지 없는지 체크한 후, 있다면 가변인자의 길이만큼 반복문을 돌려서 PreparedStatement에 가변인자의 값들을 셋팅해준다. 그리고 addAndGet() 테스트를 돌려보면 성공이다!

     

     

    3.6 스프링의 JdbcTemplate

     스프링은 JdbcTemplate라는 JDBC코드용 기본 템플릿을 제공한다. 템플릿/콜백이 적용된 스프링 제공 기술을 이용해본다.

    3.6.1 update()

    deleteAll()

     deleteAll()에 적용된 콜백은 StatementStrategy 인터페이스의 makePreapredStatement() 메소드인데 JdbcTemplate의 콜백은 PreparedStatementCreator 인터페이스의 createPreparedStatement() 메소드이다. 흐름, 구조가 동일하며 템플릿 메소드의 이름은 update()다.

     

    UserDao.java 일부

    public void deleteAll() throws SQLException {
        //콜백을 익명 클래스로 직접 만들어서 만들어 전달
        this.jdbcTemplate.update(new PreparedStatementCreator() {
             public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
                return con.prepareStatement("delete from users");
            }
        });
    }

    위와 같이 콜백 인터페이스를 구현하여 넘길 수도 있지만, 우리가 만든 executeSql()처럼 SQL문장만 넘기면 내장콜백을 사용하는 update()도 있다. 다음과 같이 사용하면 된다.

     

    UserDao.java 일부

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

     

    add()

     add() 메소드는 값을 바인딩하는 인자들도 넘겨줘야 한다. update() 메소드는 가변인자를 사용하여 업데이트해주는 메소드도 오버로딩 되어 있다.

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

     

    3.6.2 queryForInt()

    query()

     예전에 만든 getCount()를 jdbcTemplate가 제공하는 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>() {
                public Integer extractData(ResultSet rs) 
                    throws SQLException, DataAccessException {
                    rs.next();
                    return rs.getInt(1);
             }
         });
    }

     getCount() 메소드를 클라이언트로 사용하고 query()는 템플릿 메소드이다. 인자로 콜백함수 2개를 받도록 되어 있다. PreparedStatementCreator 콜백의 실행 결과가 템플릿에 전달되고, ResultSetExtractor콜백은 템플릿이 제공하는 ResultSet을 이용해 원하는 값을 템플릿에 전달하고 최종적으로 query()의 리턴값으로 돌려주게 된다. 또한 ResultSetExtractor는 제네릭스 타입 파라미터를 가지게 되어 숫자뿐만 아니라 다양한 타입의 값을 추출할 수 있다.

     위의 코드를 재사용하려면 ResultSetExtractor 콜백을 템플릿 안으로 옮겨 재활용할 수 있다. JdbcTemplate는 queryForInt() 메소드가 이런 기능을 내장하고 있다.

     

    UserDao.java 일부

    public int getCount() throws SQLException{
        return this.jdbcTemplate.queryForInt("select count(*) from users");
    }

     

    3.6.3 queryForObject()

     get() 메소드는 SQL 바인딩이 필요하고 ResultSet 결과를 User 오브젝트에 매핑시켜줘야 한다. 다음과 같이 RowMapper 콜백을 이용한다.

     

    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;
    				}
    	});	
    }

     queryForObject()는 SQL 결과를 한 개의 로우만 얻을 것으로 기대한다. RowMapper 호출 시점에 자동으로 첫 번째 로우를 가리키고 있으므로 rs.next()를 호출할 필요는 없다. RowMapper에서는 User 오브젝트를 초기화 후 setter 매핑 후 User 오브젝트를 리턴해주면 된다.

     

    3.6.4 query()

    기능 정의와 테스트 작성하기

     모든 사용자 정보를 다 가져오는 getAll()을 만들려고 한다. RowMapper를 사용하여 List<User>타입으로 반환하도록 한다. 테스트를 먼저 만드는 TDD방식을 적용한다. 우선 @Before가 붙은 메소드에서 User 오브젝트 픽스쳐를 3개 만들어둔다. @Test가 붙은 getAll() 메소드에서는 add()로 하나씩 User 픽스쳐를 넣으며 사이즈와 데이터를 비교해본다. 최종적으론 다음과 같은 코드가 된다.

     

    UserDaoTest.java 일부

    @Before
    public void setUp() {
        this.user1 = new User("gummy","kim","1234");
        this.user2 = new User("land","jin","1234");
        this.user3 = new User("boom","yoon","1234");
    }
    
    
    @Test
    public void getAll() throws SQLException, ClassNotFoundException {
        dao.deleteAll();
    		
        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));
    }
    	
    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()));	
    }

    user 오브젝트와 getAll()로 가져온 user 오브젝트와 비교하는 로직은 checkSameUser로 메소드 추출하여 사용했다.

     

    query() 템플릿을 사용하여 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>를 던진다.

     

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

    중복 제거(RowMapper 분리하기)

     get()과 getAll()에 쓰인 RowMapper가 중복된다. 재사용하게 만들 필요가 있다. 일단 RowMapper 콜백 오브젝트에는 상태정보가 없기 때문에 멀티스레드에서 동시 사용해도 괜찮다. 다음과 같이 인스턴스 변수 초기화한 후 get()과 getAll()에서 사용하도록 한다.

     

     UserDao.java 일부

    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 User get(String id) throws ClassNotFoundException, SQLException{
        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);
    }

    참고자료 : 토비의 스프링 3.1 Vol.1

    반응형

    댓글