3장 템플릿

템플릿 메소드 / 전략 패턴 / 중첩 클래스 / 템플릿 콜백 패턴로 중복 코드 효율적으로 재사용하기

앞서 1장에서는 중복 코드를 메소드로 추출하여 재사용하였다. 이때 중복 코드 중에 변하는 부분/변하지 않는 부분이 섞여 있을 수 있었다. 따라서 변하는 부분은 슈퍼클래스의 추상 메소드로 정의화고 하위 클래스에서 상속을 통해 오버라이딩하였다. 이를 템플릿 메소드 패턴이라고 하고, 이를 통해 복잡한 중복 코드도 효율적으로 재사용할 수 있었다. 여기서는 템플릿 메소드에서 나아가 중복 코드를 효율적으로 개선하는 방법들을 소개한다. 우선 안 좋은 예시부터 개선해나가는 방식으로 각각의 디자인 패턴을 알아보았다.

Bad Case

중복되는 코드가 변하지 않는 코드뿐이라면 메소드로 추출하는게 효율적이다. 그러나 아래의 코드(JDBC)에서는 메소드로 추출된 부분이 매번 변하는 부분이다. 다음 JDBC를 통해 DB에 접근하는 과정에서 중복되는 코드를 메소드화하였다. 그러나 메소드화한 부분이 쿼리 내용마다 변하기 때문에 재사용이 어렵다.

  • JDBC로 DB 사용하는 과정

    1. Connection 생성

    2. 쿼리 생성

      → 중복되는 코드이지만, 쿼리 내용이 매번 변한다. 아래 코드에서 이 부분을 메소드 추출하였다.

    3. 쿼리 실행

    4. Connection 해제

public void deleteAll() throws SQLException {
    ...
    try {
        c = dataSource.getConnection();
        ps = makeStatement(c);
        ps.executeUpdate();
    } catch (SQLException e) {...}
}

private PreparedStatement makeStatement(Connection c) throws SQLException {
    PreparedStatement ps;
    ps = c.preparedStatement("delete from users"); // 쿼리 매번 변경
    return ps;

이처럼 변하는 부분을 메소드로 추출한 경우, 재사용하기 어려워 메소드화한 의미가 없다.

이를 개선하여 변하는 부분/변하지 않는 부분을 분리하여 상속을 통해 재사용 + 확장하는 방식이 템플릿 메소드 패턴이다.

1. 템플릿 메소드 패턴

우선 변하지 않는 부분과 변하는 부분을 구분한다. 변하지 않는 부분은 슈퍼 클래스에 정의한다. 그리고 상속을 통해 변하는 부분(슈퍼 클래스의 추상 메소드로 정의)은 하위 클래스에서 재정의한다. 이를 통해 기본 구조는 바뀌지 않으면서 기능 확장을 할 수 있다.

  • 슈퍼 클래스에서 변하는 부분은 추상 메소드로 정의한다. (변하지 않는 부분은 메소드로 구현한다)

public class UserDao {
    abstract PreparedStatement makeStatement(Connection c);
}
  • 하위 클래스에서 각자에 맞게 추상 메소드를 구현한다.

public class UserDaoDeleteAll extends UserDao {

    protected PreparedStatement makeStatement(Connection C) throws SQLException {
        PreparedStatment ps = c.prepareStatement("delete from users");
        return ps;
    }
}

이처럼 템플릿 메소드 패턴은 변하는 부분과 변하지 않는 부분을 분리하고, 변하는 부분은 하위 클래스에서 오버라이딩해준다. 이를 통해 기능을 확장하고 싶을 때, 기존 구조는 변하지 않으면서 상속을 통해 기능을 확장할 수 있다. (OCP) 예를 들어, 새로운 Dao 로직을 만들고 싶으면 UserDaoDeleteAll 처럼 UserDao을 상속받아 오버라이딩해주면 재사용하면서도 변경/확장할 수 있다.

그러나 여기에도 문제가 있다. Dao 로직마다 새로운 클래스를 만들어서 상속받아야 한다. 상속 관계가 설계 시점에 정해져 유연성이 떨어지게 된다.(다중 상속이 안되므로, 상속 구조가 하나로 고정되면 유연성/확장성이 떨어진다!) 이를 개선하여 오브젝트를 분리하고, 인터페이스에만 의존하는 방식이 전략 패턴이다.

2. 전략 패턴

전략 패턴이란

  1. 오브젝트를 둘로 분리한다.

    • 컨텍스트 : 변하지 않는 부분

    • 전략 인터페이스 : 변하는 부분

  2. 컨텍스트는 전략 인터페이스에 의존한다.

전략 패턴은 컨택스트가 추상화된 전략 인터페이스에 의존하는 방식이다. 컨택스트를 바꾸지 않고 필요한 전략을 바꿔가면서 쓸 수 있다.

전략 패턴 예시

아래 그림/코드는 전략 패턴에 클라이언트의 DI 방식을 적용했다. 즉, 클라이언트 코드에서 전략 구현체를 선택하여 컨택스트에 주입한다.

  • 전략 인터페이스

컨텍스트가 만든 연결을 받아서 PreparedStatement를 만들고 만들어진PreparedStatement 오브젝트를 돌려준다.

public interface StatementStrategy {
    PreparedStatement makePreparedStatement(Connection c) throws SQLException;
}
  • 전략 구현체

전략 인터페이스를 상속받아 구체적으로 어떤 PreparedStatement 오브젝트를 생성할지 구현한다.

public class DeleteAllStatement implements StatementStrategy {
    protected PreparedStatement makeStatement(Connection c) throws SQLException {
        PreparedStatement ps = c.preparedStatement("delete from users");
        return ps;
    }
}
  • 컨텍스트 : 변하지 않는 맥락

StatementStrategy 인터페이스를 매개변수로 정의하여, StatementStrategy를 상속받은 구현체들을 모두 받을 수 있다. (ex. DeleteAllStatement)

public void jdbcContext(StatementStrategy stmt) throws SQLException {
    connection c = null;
    PreparedStatement ps = null;

    try {
        c = dataSource.getConnection();

        ps = stmt.makePreparedStatement(c);

        ps.executeUpdate();
	// ...
}
  • 클라이언트 코드

컨택스트에 전략 구현체를 주입한다.

public class UserDaoDeleteAll extends UserDao {

	public void deleteAll() throws SQLException {
	    StatementStrategy st = new DeleteAllStatement(); // 전략 구현체 생성
	    jdbcContext(st); // 컨텍스트에 전략 구현체 전달함
	}
}

이렇게 전략 패턴을 사용하면, 전략 구현체를 바꾸더라도 기존 컨택스트 코드는 바뀌지 않는다.

단, 이 방법에도 단점이 몇가지 있다.

우선 클라이언트단(UserDaoDeleteAll)에서 메소드(deleteAll)마다 새로운 전략 구현체를 매번 만들어야 한다. (클래스 객체를 만드는 과정에 리소스가 든다) 뿐만 아니라, 만약 메소드에서 전략 구현체 생성 시에 전달할 정보들(인자들)이 있다면, 이를 저장해둘 변수를 따로 만들어두어야 한다. 이를 개선하기 위해 중첩 클래스를 활용할 수 있다.

3. 중첩 클래스로 전략 패턴 최적화

중첩 클래스로 전략 패턴을 개선시켜 매번 클래스를 생성하지 고, 변수 사용에 부담을 덜 수 있다. 우선 중첩 클래스의 종류는 다음과 같다.

중첩 클래스(Nested class) : 다른 클래스 내부에 정의되는 클래스

  • Static class : 독립적인 오브젝트로 생성

  • Inner class : 자신이 정의된 클래스의 오브젝트 안에서 생성

    • Local Class : 메소드 내부에 정의, 메소드 실행 시 생성

    • Anonymous Class : 이름이 없는, 선언 시 생성

    • Inner Class : 클래스 내부에 정의, 해당 객체 생성 시 생성 (멤버 필드)

로컬 클래스로 최적화

로컬 클래스는 자신이 정의된 메소드 실행 시 생성된다. 메소드마다 클래스 파일을 생성하지 않아도 된다는 장점이 있다. 또한 해당 메소드의 로컬 변수에 바로 접근할 수 있다. (로컬 클래스가 외부 변수를 사용하려면 final로 선언되어야 한다)

public void add(final User user) throws SQLException {
  //  내부 클래스
  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) values(?,?,?)");
          ...
      }

      StatementStrategy st = new AddStatement(user);
      jdbcContextWithStatementStrategy(st);
  }
}

익명 내부 클래스

new 인터페이스명() { 익명 내부 클래스 내용 };

익명 내부 클래스는 선언과 동시에 생성된다. 딱 한 번만 사용하므로 따로 변수에 담지 않고 바로 생성하여 파라미터로 넘겨준다.

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());	//외부 메소드의 user 변수에 접근 가능
            ps.setString(2, user.getName()); 
            ps.setString(3, user.getPassword()); 
            return ps;
        }
    };
    jdbcContextWithStatementStrategy(st);
}

4. 템플릿 콜백 패턴

앞서 전략 패턴은 변하는 부분과 변하지 않는 부분을 분리하여, 상속을 통해 변하는 부분을 하위 클래스에서 오버라이딩하였다. 이를 통해 복잡한 코드도 재사용 + 확장을 할 수 있었다.

템플릿 콜백 패턴은 전략 패턴에서 상속 대신 익명 내부 클래스를 활용한 방식이다.

  • 템플릿 : 전략 패턴의 컨텍스트 (바뀌지 않는 코드 부분)

  • 콜백 : 익명 내부 클래스로 만들어지는 오브젝트 (상황에 따라 바뀌는 코드 부분)

콜백은 보통 단일 메소드 인터페이스를 사용한다. 템플릿은 바뀌지 않는 컨텍스트이고, 콜백은 하나의 메소드를 가진 인터페이스를 구현한 익명 내부 클래스로 볼 수 있다.

public void deleteAll() throws SQLException {
    this.jdbcContext.workWithStatementStrategy(
        new StatementStrategy() {  
            public PreparedStatement makePreparedStatement(Connection c) throws SQLException {
                return c.preparedStatement("delete from users");
            }
        }
    );
}

여기서 한 번 더 개선하면 변하는 부분만 인자로 전달하도록 하는 메소드로 추출할 수 있다.

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.preparedStatement(query);
            }
        }
    );
}

또 이 메소드를 여러 클라이언트(UserDao 외의 클래스들)에서 사용할 경우, 컨텍스트(템플릿 클래스) 안으로 옮겨 재사용할 수 있다.

public class JdbcContext {
    ...
    public void executeSql(final String query) throws SQLException {
        workWithStatementStrategy(
            new StatementStrategy() {...}
        );
    }
}

Last updated