반응형

1. 템플릿이란?

 - 성격이 다른 여러 코드 중에서 중복되거나 일정한 패턴을 가진 부분을 독립시켜서 재사용할 수 있도록 하는 방법이다. 예를들어 DB에서 데이터를 CRUD 하기 위해 여러 메서드들을 생성할 것이다. 이 메서드들은 각기다른 목적과, SQL 쿼리를 갖고 있으나 Connection을 생성하고, 관련 리소스들을 close하는 부분은 공통적으로 들어간다.즉, 중복되는 부분이 있고, 쿼리가 다르다는 패턴을 갖고 있다. 이러한 부분을 독립시켜 재사용할 수 있도록 하는 것이 바로 템플릿이다.

 


2. 초난감 DAO 템플릿

2.1. 초난감 DAO에 예외처리 기능 추가

 - 템플릿 작업 이전에 초난감 DAO에 예외처리 기능을 추가한다. 로직 중간에 예외가 발생했을 때 리소스들이 close 될 수 있도록 하기 위함이다. 일단 deleteAll 메서드만 작성해보도록 하자.

public class UserDao {

    private DataSource dataSource;

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

    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){
            e.printStackTrace();
            throw e;
        }finally {
            if(ps != null){
                try{
                    ps.close();
                }catch(Exception e){
                    e.printStackTrace();
                }
            }

            if(c != null){
                try{
                    c.close();
                }catch(Exception e){
                    e.printStackTrace();
                }

            }

        }
    }

 

2.2. 코드의 문제점

 - 모든 메서드에 try, catch, finally 중복코드가 발생하게 된다. 리소스를 해제하는 코드이기에 실수로 하나를 누락한다면 Out Of Memory 가 발생하게 되고, 어플리케이션이 죽을 수 있다. 템플릿 작업을 시작해보자.

 

2.3. 메서드 추출

 - 모든 메서드 내에서 변하지 않는 부분, 혹은 변하는 부분을 메서드로 분리하는 방법이다.

 - 이 메서드들은 변하지 않는 부분이 변하는 부분을 감싸고 있는 구조이므로 변하는 부분을 메서드로 분리하였으나, 이 부분은 다른 코드에서 재사용하기 힘든 부분이기에 비효율적인 방법이다.

public class UserDao {

    ...

    public void deleteAll() throws SQLException{

        Connection c = null;
        PreparedStatement ps = null;

        try{
            c = dataSource.getConnection();

            ps = makeStatement(c);

            ps.executeUpdate();

        }catch(SQLException e){
            e.printStackTrace();
            throw e;
        }finally {
            ...
        }
    }
    
    private PreparedStatement makeStatement(Connection c) throws SQLException{
    	PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}

 

2.4. 템플릿 메서드 패턴

 - 템플릿 메서드 패턴은 변하지 않는 부분은 슈퍼클래스에, 변하는 부분은 추상 메서드로 정의해두고, 서브 클래스에서 오브라이드하여 추상 메서드를 구현하여 쓰도록 하는 패턴이다. 여기서 Connection 생성 부분 및 try, catch, finally 부분은 슈퍼클래스에 구현하고, PreparedStatement를 생성하는 makeStatement() 를 추상 메서드로 정의하여 서브클래스에서 구현하도록 하였다. 서브 클래스 명은UserDaoDeleteAll이다.

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

 - 이제 UserDao의 기능을 확장하고 싶을 때마다 상속을 통해 자유롭게 확장할 수 있으며, 기존 UserDao에 불필요한 변화는 생기지 않는다. OCP 원칙을 지키는 것처럼 보인다. 하지만 템플릿 메서드 패턴은 다음과 같은 한계이 있다.

 첫째, DAO 메서드마다 상속을 통해 새로운 클래스를 만들어야 한다. 현재 UserDao의 메서드가 4개이니 4개의 서브 클래스를 만들어야 한다.

 둘째, 확장구조가 클래스 설계 시점에 고정된다.

 템플릿 메서드 패턴도 비효율적인 방법이다.

 

2.5. 전략 패턴

전략패턴
전략 패턴은 자신의 기능 중 필요에 따라 변경이 필요한 로직을 인터페이스를 통해 외부로 분리시키고, 이를 구현한 구체적인 로직을 필요에 따라 바꿔 사용할 수 있게 하는 디자인 패턴이다.

 

- 템플릿 메서드 패턴보다 유연하고 확장성이 뛰어난 전략패턴을 적용한다. 필요에 따라 변경이 필요한 로직은 PreparedStatement를 생성하는 부분이다. 이 기능을 인터페이스 메서드로 만들어두고, UserDao에서 이를 호출하는 방식을 사용한다.

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

...

public class DeleteAllStatement implements StatementStrategy{
    @Override
    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
        PreparedStatement ps = c.prepareStatement("delete from users");
        return ps;
    }
}

...

public class UserDao {

    private DataSource dataSource;

    public void setDataSource(DataSource dataSource){
        this.dataSource = dataSource;
    }
    ...
    
    public void deleteAll() throws SQLException{

        Connection c = null;
        PreparedStatement ps = null;

        try{
            c = dataSource.getConnection();

            StatementStrategy strategy = new DeleteAllStatement();
            ps = strategy.makePreparedStatement(c);

            ps.executeUpdate();

        ...
    }
}

- 전략 패턴은 필요에 따라 바꿔 사용해야 하는데, 현재 deleteAll이라는 컨텍스트 안에 구체적인 전략 클래스인 DeleteAllStatement가 고정되어 있다. 컨텍스트가 StatementStrategy 인터페이스 뿐 아니라 구현 클래스도 직접 알고있다는 건 확장에 자유롭지 못하다는 의미이다. 이는 전략 패턴에도, OCP에도 잘 들어맞는다고 볼 수 없다.

 

 전략 패턴에 사용되는 전략은 Client가 결정하는게 일반적이다. Client가 구체적인 전략 하나를 선택하고 오브젝트로 만들어 컨텍스트(UserDao)에 전달하는 것이다. 

 

 현재 UserDao는 Client 부분이 없다. 그렇기에 전략패턴의 일반적인 사용방법에 맞게 Client 부분을 만들어줘야한다. Client 부분은 StatementStrategy의 구현체를 결정 및 생성하는 부분이다.

 중요한 것은 이 컨텍스트에 해당하는 코드를 Client 코드와 분리시켜야 한다. 왜? Client가 전략 하나를 선택하고 전달해야하는 구조여야 하기 때문이다. deleteAll이 Client의 역할을, 그 외의 코드(컨텍스트 코드)는 별도의 메서드로 독립시켜 호출하는 형태로 구현한다.

public class UserDao {

    ...

    public void deleteAll() throws SQLException{
        StatementStrategy strategy = new DeleteAllStatement();
        jdbcContextWithStatementStrategy(strategy);
    }

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

 

전략 결정 책임을 갖는 부분은 deleteAll() 메서드이므로, 이 메서드가 곧 Client가 된다. 이 메서드는 전략 오브젝트를 만들고 컨텍스트를 호출하는 책임을 지고 있다. 이렇게 Client가 컨텍스트가 사용할 전략을 정해서 전달한다는 면에서 DI 구조라고도 이해할 수 있다. 정확히는 마이크로 DI 구조이다.

 

마이크로 DI
 DI는 다양한 형태로 적용할 수 있다. 일반적인 DI는 의존관계에 있는 두 개의 오브젝트와 이 관계를 설정해주는 오브젝트 팩토리, 이를 사용하는 클라이언트라는 4개의 오브젝트 사이에서 일어난다. 하지만 때로는 클라이언트가 오브젝트 팩토리의 책임을 함께 지고있을 수도 , 클라이언트와 전략이 결합될 수도 있다. IoC 컨테이너 도움 없이 코드 내에서 DI를 적용할 수도 있는데 이를 마이크로 DI라고 한다.

 

2.6. add 메서드 리펙토링

 - 이제 add 메서드를 리팩토링한다. 전략 결정 책임을 갖는 부분은 add() 메서드이며, 이 메서드가 곧 Client가 되도록 한다. 마찬가지로 전략 오브젝트를 만들고 컨텍스트를 호출하도록 하자. 특이사항으로 add 메서드는  요청 User 객체를 받아 처리해야하는 부분이 있다. 이 부분은 AddStatement 생성자에 User 객체를 받아 멤버필드에 주입하도록 한다.

public class AddStatement implements StatementStrategy{

    private User user; // 멤버필드 추가

    public AddStatement(User user){ // makePreparedStatement에서 users를 파라미터로 받지 않고 생성자 파라미터로 설정
        this.user = user;
    }
    @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 class UserDao {
	...
    public void add(User user) throws SQLException {
        StatementStrategy strategy = new AddStatement(user);
        jdbcContextWithStatementStrategy(strategy);
    }
    ...
}

 

 

구현을 하고 나니 문제점이 하나 보인다. 바로 DAO 메서드마다 새로운 StatementStrategy 구현 클래스를 만들어야 한다는 점이다. 런타임 시에 DI 해준다는 점을 제외하면 로직마다 상속을 사용하는 템플릿 메서드 패턴보다 크게 나은 점이 없다. 또한 AddStatement의 User 처럼 부가적인 정보가 있는 경우 이를 전달받는 생성자와 인스턴스 변수를 만들어야한다. 이 두 문제를 해결할 수 있는 방법을 알아보자.

 

 

2.7. 로컬 클래스 활용

 - 클래스 파일이 많아지는 문제는 생성할 클래스를 UserDao의 내부 클래스로 구현하는 방법으로 해결할 수 있다. DeleteAllStatement나 AddStatement는 UserDao에서만 사용한다. UserDao와 각 메서드와 강하게 결합되어 있고, 외부에서 사용하지 않는다. 이처럼 특정 메서드에서만 사용되는 것이라면 로컬 클래스로 만들어 사용할 수 있다. 변수처럼 사용하는 개념이라 클래스 파일을 추가로 생성하지 않아도 되며 요청 User에 직접 접근 가능하므로 파라미터도 제거 가능하다.

public void add(User user) throws SQLException {
        class AddStatement implements StatementStrategy{

	// User 파라미터를 받는 생성자 제거
            
            @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;
            }
        }

        StatementStrategy strategy = new AddStatement(); // User 파라미터 제거
        jdbcContextWithStatementStrategy(strategy);
    }

 - 로컬 클래스는 메서드가 종료되면 사라지고, GC에 의해 메모리에서 제거된다.  

 


2.8. 익명 내부 클래스 활용

 - AddStatement 클래스는 add() 메서드에서만 사용할 용도로 만들어졌기에 클래스 이름을 제거하고 클래스를 구현하는 익명 내부 클래스를 적용할 수 있다.

 

익명 내부 클래스
익명 내부 클래스는 이름을 갖지 않는 클래스로, 클래스를 재사용할 필요가 없고, 구현한 인터페이스 타입으로만 사용할 경우 유용하다. 아래와 같은 형태로 만들어 사용한다.

new 인터페이스 이름() { 클래스 본문 };

 

 익명 클래스를 사용하여 ps 클래스 변수를 초기화하였으나, 재사용되는 부분이 없어 jdbcContextWithStatementStrategy의 파라미터 안에 익명 클래스를 사용하였다.

 

  public void add(User user) throws SQLException {
        
        StatementStrategy strategy = 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;
            }
        };
        
        jdbcContextWithStatementStrategy(strategy);
 }
 
 // ↓ jdbcContextWithStatementStrategy 메서드 내에 익명 클래스 선언
 
 public void add(User user) throws 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;
                }
        });
    }

 마찬가지로 deleteAll도 작업을 해주었다.

    public void deleteAll() throws SQLException{
        jdbcContextWithStatementStrategy(new StatementStrategy() {
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement("delete from users");
                return ps;
            }
        });
    }

 


3. jdbcContextWithStatementStrategy 템플릿

 - 현재 다른 DAO를 생성할 경우 해당 클래스에 jdbcContextWithStatementStrategy 메서드를 생성해야 한다. 결국 이것도 중복되므로 jdbcContextWithStatementStrategy 메서드를 다른 DAO에서 재사용 할 수 있도로 템플릿화 해보자.

 

3.1. 클래스 분리

 - 새로운 클래스 만들고, workWithStatementStrategy 라는 이름으로 기존 로직을 옮겼다.

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

            // execute : 수행 결과를 Boolean 타입으로 반환
            // executeQuery : select 구문을 처리할 때 사용하며, 수행 결과를 ResultSet 타입으로 반환
            // executeUpdate : INSERT, UPDATE, DELETE 구문을 처리할 때 사용하며, 반영된 레코드 수를 int 타입으로 반환.
        }catch(SQLException e){
            e.printStackTrace();
            throw e;
        }finally {
            if(ps != null){ try{ ps.close();} catch (Exception e){e.printStackTrace();} }
            if(c != null){ try{ c.close(); }catch(Exception e){ e.printStackTrace();} }
        }
    }
}

 

- 클래스가 분리됨에 따라 UserDao 코드의 컴파일 에러 부분을 수정한다. 참고로 현재 JdbcContext와 DataSource가 같이 사용되고 있는데, 이는 add, deleteAll 메서드 외에는 jdbcContext를 사용하고 있지 않기 때문이다.

public class UserDao {

    private JdbcContext jdbcContext;

    private DataSource dataSource;

    public void setJdbcContext(JdbcContext jdbcContext){
        this.jdbcContext = jdbcContext;
    }

    public void setDataSource(DataSource dataSource){
        this.dataSource = dataSource;
    }
    public void add(User user) throws SQLException {

        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{
        jdbcContext.workWithStatementStrategy(new StatementStrategy() {
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                PreparedStatement ps = c.prepareStatement("delete from users");
                return ps;
            }
        });
    }
	...
}

 

 

 - JdbcContext가 DataSource를 의존하도록 하고, UserDao가 JdbcContext를 추가로 의존하도록 DI 정보를 수정한다.

@Configuration
public class DaoFactory {
    @Bean
    public UserDao userDao(){
        UserDao userDao = new UserDao();
        userDao.setJdbcContext(jdbcContext());
        userDao.setDataSource(dataSource());
        return userDao;
    }

    @Bean
    public JdbcContext jdbcContext(){
        JdbcContext jdbcContext = new JdbcContext();
        jdbcContext.setDataSource(dataSource());
        return jdbcContext;
    }

    @Bean
    public DataSource dataSource(){
        SimpleDriverDataSource dataSource = new SimpleDriverDataSource();

        dataSource.setDriverClass(com.mysql.jdbc.Driver.class);
        dataSource.setUrl("jdbc:mysql://localhost/spring");
        dataSource.setUsername("tlatmsrud");
        dataSource.setPassword("tla1203#");

        return dataSource;
    }
}

 

Xml을 사용할 경우 다음과 같이 수정한다.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id = "dataSource" class ="org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.jdbc.Driver"></property>
        <property name="url" value = "jdbc:mysql://localhost/spring"></property>
        <property name="username" value = "tlatmsrud"></property>
        <property name="password" value = "tla1203#"></property>
    </bean>

    <bean id = "userDao" class = "org.example.user.dao.UserDao">
        <property name = "dataSource" ref = "dataSource"></property>
        <property name = "jdbcContext" ref = "jdbcContext"></property>
    </bean>

    <bean id = "jdbcContext" class = "org.example.user.dao.JdbcContext">
        <property name = "dataSource" ref = "dataSource"></property>
    </bean>
</beans>

- 이로써 JdbcContext를 UserDao로부터 완전히 분리하고 DI를 통해 연결될 수 있도록 설정을 마쳤다. 이 방법은 스프링 IoC 컨테이너를 통한 DI 방법인데, 이 방법 외에 UserDao에서 직접 DI하는 방법도 있다.

 

3.2. UserDao 내부 직접 DI

 - JdbcContext를 스프링 빈으로 등록해서 UserDao에 DI 하는 대신 UserDao 내부에서 직접 DI 할 수 있다. JdbcContext 객체를 UserDao의 dataSource 수정자 메서드 내에서 생성 후 생성된 JdbcContext의 수정자 메서드를 사용해 dataSource를 DI하는 방식이다.

 

1) jdbcContext 빈 제거

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">

    <bean id = "dataSource" class ="org.springframework.jdbc.datasource.SimpleDriverDataSource">
        <property name="driverClass" value="com.mysql.jdbc.Driver"></property>
        <property name="url" value = "jdbc:mysql://localhost/spring"></property>
        <property name="username" value = "tlatmsrud"></property>
        <property name="password" value = "tla1203#"></property>
    </bean>

    <bean id = "userDao" class = "org.example.user.dao.UserDao">
        <property name = "dataSource" ref = "dataSource"></property>
    </bean>
    
    // jdbcContext 빈 제거
</beans>

 

2) UserDao 수정

public class UserDao {

    private JdbcContext jdbcContext;

    private DataSource dataSource;

    public void setDataSource(DataSource dataSource){
        this.dataSource = dataSource; // 아직 JdbcContext를 적용하지 않는 나머지 메서드를 위해 보존
        jdbcContext = new JdbcContext(); // JdbcContext 객체 생성 (IoC)
        jdbcContext.setDataSource(dataSource); // dataSource DI
    }
   ...
}

 - setDataSource() 메서드는 DI 컨테이너가 DataSource 오브젝트를 주입해줄 때 호출된다. 이때 JdbcContext에 대한 수동 DI 작업(마이크로 DI)을 진행하는 것이다. 이 방법은 인터페이스를 두지 않아도 될 만큼 긴밀한 관계를 갖는 클래스(DAO와 JdbcContext)들을 굳이 빈으로 분리하고 싶지 않을 때 사용한다. 하지만 JdbcContext를 싱글톤으로 만들 수 없고, DI 작업을 위한 부가적인 코드가 필요하다는 단점이 있다.

 

 직접 DI 하는 방법과 DI 컨테이너를 통해 DI하는 방법 중 어떤 방법이 더 낫다고 할 수 없다. 상황에 맞게 사용하면 된다. 긴말한 관계를 갖는 클래스이거나, 외부 설정 파일에 DI 정보를 노출시키지 않고 싶다면 직접 DI를 사용하고, 그렇지 않다면 컨테이너를 통한 DI를 하면 된다.


4. 회고

 템플릿의 개념을 예제 코드를 통해 알아보았는데, 여러 패턴들과 개념들이 짬뽕되어있어 이해하는데 어려움있었다. 하지만 역시 인간은 적응의 동물인건가... 반복해서 읽고 두드리다보니 내용이 머릿속에 정리되더라.

 처음엔 템플릿이 리팩토링과 비슷하다는 느낌을 많이 받았는데, 리팩토링은 코드를 개선한다는 느낌이 강하고, 템플릿은 코드를 설계한다는 느낌이 강하게 들었다. '템플릿화를 통해 좋은 코드 구조를 설계하고, 이후 내부 로직을 리펙토링한다.' 라고 분리되어 생각됐다.

 템플릿은 개발자마다 다르기에 어떤 방법이 낫다고 설명할 수 없다고 한다. 다만 본인이 만들었던 인터페이스, 클래스들을 본인이 분석한 내용을 바탕으로 명확한 근거와 분명한 이유를 알고 있는게 중요하다는 토비님의 말씀에 오늘도 참회의 눈물을 흘린다.

 

 

반응형

+ Recent posts