반응형

1. 템플릿/콜백 패턴

 - 전략 패턴의 기본구조 + 익명 내부 클래스

 

템플릿
일반적으로 템플릿은 어떤 목적을 위해 미리 만들어둔 틀을 뜻한다. 프로그래밍에서는 어떤 고정된 패턴안에 바꿀 수 있는 부분을 넣어서 사용하는 경우 템플릿이라고 부른다.
콜백
콜백은 메서드가 실행되는 것을 목적으로 다른 오브젝트 메서드에 전달되는 오브젝트를 말한다. 자바에선 메서드 자체를 파라미터로 전달할 수 없기때문에 메서드가 담긴 오브젝트를 전달하며, 이러한 오브젝트를 펑셔널 오브젝트(functional object)라고도 한다.
전략패턴
전략 패턴은 자신의 기능 중 필요에 따라 변경이 필요한 로직을 인터페이스를 통해 외부로 분리시키고, 이를 구현한 구체적인 로직을 필요에 따라 바꿔 사용할 수 있게 하는 디자인 패턴이다.

 

1.1. 예제

public void deleteAll() throws SQLException{
	// 전략패턴 + 익명 내부 클래스 사용 = 템플릿/콜백 패턴
        jdbcContext.workWithStatementStrategy(new StatementStrategy() {
            @Override
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                return c.prepareStatement("delete from users");
            }
        });
    }

 - jdbcContext.workWithStatementStrategy 메서드 파라미터의 인자에 인터페이스에 대한 구현 클래스가 아닌 익명 클래스를 넣고 있다. (== 전략 패턴의 기본구조 + 익명 내부 클래스)

 

1.2. 동작원리

템플릿/콜백의 작업 흐름

1) 클라이언트는 템플릿 안에서 실행될 콜백 오브젝트를 만든다.

2) 만든 콜백과 함께 템플릿을 호출한다. (메서드 레벨 DI)

3, 4) 템플릿에서는 사용할 참조정보를 생성한다.

5) 클라이언트로부터 전달받은 콜백을 참조변수와 함께 호출한다.

6) 콜백 함수에서는 Client의 final 변수를 참조한다.

7) 콜백 비지니스 로직을 수행한다.

8) 콜백 리턴 값을 템플릿에 전달한다.

9, 10) 템플릿에서는 나머지 로직을 수행한다. 

11) 최종적으로 템플릿의 결과 값을 클라이언트에게 리턴한다.

 

1.3. 단점

 - 익명 클래스 사용으로 인해 일반적인 코드보다는 상대적으로 코드를 작성하고 읽기가 불편하다. 하지만 익명클래스 코드 부분을 재사용하도록 템플릿화 시킨다면 코드 작성은 훨씬 간단해질 것이다. 

 

1.4. 콜백 분리 및 재사용

1) 메서드 분리

 - 기존 deleteAll 메서드에서 바뀌는 부분은 오직 SQL 부분이다. UserDao에 SQL을 받는 메서드를 만들고, 재사용하도록 구현한다. 이로써 응답 값이 없는 요청은 executeSql 메서드에 SQL만 넣어 호출하면 된다.

public void deleteAll() throws SQLException{
        executeSql("delete from users");
}

...

 public void executeSql(final String query) throws SQLException{
        jdbcContext.workWithStatementStrategy(
                new StatementStrategy() {
                    @Override
                    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                        return c.prepareStatement(query);
                }
        });
}

 

2) 클래스 분리

- UserDao 클래스 뿐 아니라 모든 Dao 클래스에서도 executeSql 메서드를 재사용할 수 있도록 executeSql 메서드를 JdbcContext 클래스로 이동시켜주자. 이로써 UserDao의 구현부에는 익명 클래스를 구현할 필요가 없어졌다.

 

1.5. executeSql 메서드를 새로운 클래스로 분리하지 않은 이유

 - 일반적으로 기능을 분리할 때에는 클래스로 분리하는 것이 재사용성이 좋다. executeSql도 클래스를 따로 생성할 수 있었지만, JdbcContext 클래스로 이동시킨 이유는 응집도 때문이다. JdbcContext는 DB와의 Connection을 맺고 쿼리를 실행시키는 책임이 있다. executeSql 메서드도 마찬가지로 이 책임을 위해 존재하므로 JdbcContext와 긴밀한 관계를 맺고있다고 볼 수 있다. 이렇게 동일한 목적을 가진 긴밀한 코드들은 한 군데 모여있는게 좋다.

 


2. 템플릿/콜백 패턴 예제 실습

2.1. 계산기 예제 코드 작성

 - 텍스트 파일에서 숫자를 읽어와 계산 기능을 처리하는 코드를 작성한다.

public class Calculator {
    public int calcSum(String path) throws IOException {

        BufferedReader br = new BufferedReader(new FileReader(path));
        Integer sum = 0;

        String line = null;

        while((line = br.readLine()) != null){
            sum += Integer.valueOf(line);
        }

        br.close();
        return sum;
    }
}

 

2.2. 코드 분석

 - 현재는 더하기 메서드만 구현하였으나, 곱하기, 나누기 메서드도 생성된다 가정하고 중복 또는 바뀌는 부분을 찾아본다.

 * 중복되는 부분

 1) path에 대한 BufferedReader가 생성 부분

 2) close 되는 부분 - try, catch, finally 포함 예정

 

 * 바뀌는 부분

 1) 읽은 문자열에 대해 계산하는 부분

 

2.3. 코드 리팩토링

 - 중복되는 부분을 메서드화 한다. 다른 클래스에서 재사용하지 않기 때문에 클래스로 분리하지 않았다.

 - 바뀌는 부분에 대해서는 템플릿/콜백 패턴을 적용하여 익명 클래스를 구현하도록 하였다.

 - 곱하기를 처리하는 calcMultiplay 메서드도 구현하였다.

public class Calculator {
    public int calcSum(String path) throws IOException {

        return fileReaderTemplate(path, new BufferedReaderCallback() {
            @Override
            public Integer doSomeThingWithReader(BufferedReader br) throws IOException {
                Integer sum = 0;
                String line;

                while((line = br.readLine())!= null){
                    sum += Integer.valueOf(line);
                }
                return sum;
            }
        });
    }

    public int calcMultiply(String path) throws IOException{

        return fileReaderTemplate(path, new BufferedReaderCallback() {
            @Override
            public Integer doSomeThingWithReader(BufferedReader br) throws IOException {
                Integer multiply = 1;
                String line;

                while((line = br.readLine())!= null){
                    multiply *= Integer.valueOf(line);
                }
                return multiply;
            }
        });
    }

    public Integer fileReaderTemplate(String filePath, BufferedReaderCallback callback) throws IOException {
        BufferedReader br = null;

        try{
            br = new BufferedReader(new FileReader(filePath));
            int ret = callback.doSomeThingWithReader(br);
            return ret;
        } catch (IOException e) {
            e.printStackTrace();
            throw e;
        } finally {
            if(br != null){
                try{br.close();}catch(Exception e){e.printStackTrace();}
            }
        }
    }
}

...

public interface BufferedReaderCallback {

    Integer doSomeThingWithReader(BufferedReader br) throws IOException;
}

 

2.4. 2차 코드 분석

 - 구현된 메서드들에 대해 2차적으로 중복 또는 바뀌는 부분을 찾아본다.

 * 중복되는 부분

 1) line을 읽는 부분

 2) 계산된 결과를 리턴하는 부분

 

 * 바뀌는 부분

 1) 읽은 line에 대해 연산을 수행하는 부분

 2) 계산 시작 값 (더하기 = 0, 곱하기 = 1)

 

2.5. 2차 코드 리팩토링

 - 마찬가지로 중복되는 부분을 메서드로 생성한다. 기존 fileReaderTemplate을 lineReadTemplate으로 수정하여 구현했다.

 - LineCallback 인터페이스는 읽은 line에 대한 값과 파라미터 값을 더하거나, 곱하기 위해 생성했다.

 - initVal는 초기 값으로 더하기일 경우 0을, 곱하기일 경우 1을 넣어준다.

 

public interface LineCallback {
    Integer doSomethingWithLing(String line, Integer value);
}

...
public class Calculator {
    public int calcSum(String path) throws IOException {

        return lineReadTemplate(path, new LineCallback() {
            @Override
            public Integer doSomethingWithLing(String line, Integer value) {
                return value + Integer.valueOf(line);
            }
        }, 0);
    }

    public int calcMultiply(String path) throws IOException{

        return lineReadTemplate(path, new LineCallback() {
            @Override
            public Integer doSomethingWithLing(String line, Integer value) {
                return value * Integer.valueOf(line);
            }
        }, 1);
    }
    
    public Integer lineReadTemplate(String filePath, LineCallback callback, int initVal) throws IOException {
        BufferedReader br = null;
        try{
            br = new BufferedReader(new FileReader(filePath));
            int res = initVal;
            String line;

            while((line = br.readLine()) != null){
                res = callback.doSomethingWithLing(line, res);
            }
            return res;
        } catch (IOException e) {
            e.printStackTrace();
            throw e;
        } finally {
            if(br != null){
                try{br.close();}catch(Exception e){e.printStackTrace();}
            }
        }
    }
}

 

 - 이로써 로우 레벨의 파일 처리 코드가 템플릿으로 분리되고, 연산 관련 메서드들은 데이터를 가져와 계산한다는 기능에 충실한 코드만을 갖게 되었다.

 


3. 스프링의 JdbcTemplate

 - 예제를 통해 자바 코드를 통한 DB 처리 로직을 개발했으나, 스프링에서도 JDBC를 이용하는 DAO에서 사용할 수 있도록 준비된 다양한 템플릿과 콜백을 제공한다. 그 중 하나가 JdbcTemplate이다. JdbcContext에서 JdbcTemplate을 사용하는 코드로 DAO를 변경해보자. JdbcTemplate은 생성자 파라미터로 DataSource를 주입해주면 된다.

 

3.1. UserDao 리펙토링

public class UserDao {

    private JdbcTemplate jdbcTemplate;

    private DataSource dataSource;

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

 

3.2. UserDao 메서드 리팩토링

 - 기존 코드는 UserDao에서 jdbcContext를 주입받고, executeSql을 실행하고 있다. executeSql은 PreparedStatement를 생성하는 익명 클래스를 구현한 후 workWithStatementStrategy 메서드에 전달하고, 여기서 DB 연결 및 executeUpdate를 통한 DB 쿼리 처리가 실행된다.

public void deleteAll() throws SQLException{
     jdbcContext.executeSql("delete from users");
}

...

public void executeSql(final String query) throws SQLException{
        workWithStatementStrategy(
                new StatementStrategy() {
                    @Override
                    public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                        return c.prepareStatement(query);
                    }
                }
        );
    }
    
 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();} }
        }
    }

 

하지만 JdbcTemplate을 사용하면 아래와 같이 단 한줄로 작업이 끝난다. SQL 쿼리만 넘기면 된다.

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

 

이게 가능한 이유는 JdbcTemplate에서도 우리가 지금껏 했던 작업과 같이 템플릿화 되있기 때문이다. 내부 코드 일부를 보면 알 수 있듯이 여기서도 메서드/콜백 패턴이 사용됨을 알 수 있다.

 

public int update(final String sql) throws DataAccessException {
		Assert.notNull(sql, "SQL must not be null");
		if (logger.isDebugEnabled()) {
			logger.debug("Executing SQL update [" + sql + "]");
		}
       		// 메서드 콜백 패턴 사용
		class UpdateStatementCallback implements StatementCallback<Integer>, SqlProvider {
			public Integer doInStatement(Statement stmt) throws SQLException {
				int rows = stmt.executeUpdate(sql);
				if (logger.isDebugEnabled()) {
					logger.debug("SQL update affected " + rows + " rows");
				}
				return rows;
			}
			public String getSql() {
				return sql;
			}
		}
        
        	// 추가 비지니스 로직 처리
		return execute(new UpdateStatementCallback());
	}

 

나머지 add, getCount, get 메서드 또한 jdbcTemplate을 사용한 방식으로 리팩토링 한다.

public void add(final User user)  {
	jdbcTemplate.update("insert into users(id, name, password) values(?,?,?)",
    		user.getId(), user.getName(),user.getPassword());
}
public int getCount(){
	return jdbcTemplate.queryForInt("select count(*) from users");
}
public User get(String id){
        return jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id},
                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;
                    }
                });
    }
public List<User> getAll() {
        return jdbcTemplate.query("select * from users order by id",
                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;
                    }
                });
    }

 

3.3. 중복 코드 제거

 - RowMapper 오브젝트를 생성하는 부분이 중복되어있다. 사용되는 RowMapper를 보면 상태정보가 없다. 즉, 먼저 생성해놓고 재사용해도 무관하다는 뜻이다.

 

public class UserDao {

	...
    
	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){
        return jdbcTemplate.queryForObject("select * from users where id = ?", new Object[]{id},
                userMapper);
    }
    
    public List<User> getAll() {
        return jdbcTemplate.query("select * from users order by id",
                userMapper);
    }
}

4. 회고

 자바를 통해 템플릿/콜백 패턴을 적용해보고, JdbcTemplate을 통해 스프링이 제공하는 템플릿/콜백이 적용된 클래스도 사용해보며, 스프링에서 제공하는 클래스를 단순하게 여기고 사용했지만, 아주 정교하게 템플릿화된 클래스들이라는 것을 몸소 느낄 수 있었다.

 필자가 작성하는 코드 중 스프링 프레임워크만을 사용해 구현한 부분도 있었으나, 비지니스 로직을 구현하는 부분도 많았다. 여러번 수정하여 나름의 리펙토링을 거쳤다고 생각했지만, 이제보니 OOP 개념은 전혀 생각하지 않은 메서드 분리 정도의 리펙토링이었을 뿐이었다.

 템플릿/콜백은 스프링이 OOP에 얼마나 가치를 두고 있는지를 잘 보여준다고 한다. 스프링이 제공하는 이러한 클래스와 메서드들을 상황에 맞게 잘 사용하는 것을 물론이고, 직접 코드를 구현할 때에도 여러 디자인 패턴들을 적용하여 활용할 수 있는 것이 자바 개발자의 기본 소양이라고 생각된다.

 추가적으로, 템플릿/콜백 패턴을 도출해내는 과정이 잘 이해되지 않아서 다시한번 정리해보았다. 이 과정에서 어려움을 겪는 분에게 좋은 참고가 되었으면 좋겠다.

https://tlatmsrud.tistory.com/101

 

반응형
반응형

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