본문 바로가기
Design pattern/기타

템플릿 메서드 패턴, 전략 패턴, 템플릿/콜백 패턴 비교 분석

by 코딩공장공장장 2024. 2. 17.

프레임워크에 구현한 코드들은 프레임워크에 의해 실행된다.

 

그렇다면 나의 소스코드는 도대체 어떻게 실행이 되는 것인가

 

스프링 프레임워크는 개발자가 구현한 코드를 프레임워크 알고리즘 뼈대 코드 속에서 동작하도록 한다.

 

이렇게 특정 코드가 다른 코드 속에 침투되어 동작하는 디자인 패턴의 대표적인 예로 템플릿 메서드 패턴과 전략패턴이 있다.

 

템플릿 메서드 패턴과 전략 패턴에 대해 알아보고 실제 스프링에서 어떤식으로 두 디자인 패턴이 적용되어있는지 소스코드를 분석해보자.

 

목차

  • 템플릿 메서드 패턴
  • 전략패턴
  • 템플릿 메서드 패턴과 전략패턴의 차이
  • 템플릿 콜백
  • 템플릿 콜백 패턴 적용 사례 : JdbcTemplate
  • 전략패턴과 템플릿 콜백 패턴의 차이

 

템플릿 메서드 패턴

템플릿 메서드 패턴은 알고리즘의 뼈대를 정해놓고 특정 단계를 상속관계의 하위 클래스에서 재정의하여 사용할 수 있도록 하는 패턴이다.

 

간단히 말하면 우리가 실행해야할 로직에 변하는 부분과 변하지 않는 부분이 있다면 변하지 않는 부분은 템플릿 메서드로 정의하고 변하는 부분은 따로 메서드로 분리하여 템플릿 메서드 안에서 실행시키는 것이다.

 

이때 변하는 부분에 해당하는 메서드는 하위클래스에서 오버라이딩을 통해 구현로직을 바꿔 실행할 수 있게끔 제공한다.

 

간단한 예제를 통해 알아보자.

 

 

/** 템플릿 메서드 클래스 */
abstract class AbstractTemplateClass(
    private val path: String
) {
    // 템플릿 메서드(변하지 않는 부분)
    fun templateMethod(): Int {
        val resource = ClassPathResource(path)
        val reader = BufferedReader(InputStreamReader(resource.inputStream))
        var result = 0
        var line: String?
        while (reader.readLine().also { line = it } != null) {
            result = calculateResult(result, line!!)
        }
        return result
    }
    
    // 변하는 부분
    protected abstract fun calculateResult(result: Int, line: String): Int
}

/** 덧셈 구현 클래스 */
class SummationNumberFromFile(path: String) : AbstractTemplateClass(path) {
    override fun calculateResult(result: Int, line: String): Int {
        return result + line.toInt()
    }
}

/** 곱셈 구현 클래스 */
class ProductNumberFromFile(path: String) : AbstractTemplateClass(path) {
    override fun calculateResult(result: Int, line: String): Int {
        return if (result == 0) line.toInt() else result * line.toInt()
    }
}

 

위의 AbstractTemplateClass는 파일을 한줄 한줄 읽어 숫자를 추출해와 계산을 하는 목적의 클래스이다.

 

templateMethod라는 구체메서드와 calculateResult라는 추상메서드가 있다.

 

우리의 목적은 파일에서 한줄한줄 값을 읽어와 덧셈을 하거나 곱셈을 하는 목적에 있다.

 

덧셈과 곱셈을 하기 위해서 모두 파일을 읽어오는 로직은 똑같다.

 

따라와 위와 같이 파일에서 값을 읽어오는 변하지 않는 로직을 템플릿 메서드로 정의하고

 

덧셈과 곱셈을 구현하는 로직은 추상메서드로 정의하여 하위 클래스에서 오버라이딩하도록 정의하였다.

 

class AbstractTemplateClassTest {
    @Test
    fun summationTest() {
        val templateClass = SummationNumberFromFile("numberFile.txt")
        val result = templateClass.templateMethod()
        println(result)
    }

    @Test
    fun productTest() {
        val templateClass = ProductNumberFromFile("numberFile.txt")
        val result = templateClass.templateMethod()
        println(result)
    }
}

 

그리고 이를 테스트 코드로 출력해보면 15와 120이 정상적으로 출력된다.

 

템플릿 메서드를 사용하니 OCP를 통해 변하는 부분만 재정의하여 사용할 수 있으니 굉장히 편리하게 느껴진다.

 

하지만 이러한 템플릿 메서드 패턴도 단점이 있다.

 

위의 테스트 코드를 보면 클라이언트인 테스트 클래스가 사용할 구현체의 생성자를 통해 직접 생성한다.

 

의존관계가 컴파일 타임에 정의 된다.

 

컴파일 타임에 의존관계가 결정된다는 것은 컴파일러의 관할 영역인 순수 프로그래밍 코드에 의존관계가 드러나 있다는 것이고

 

의존관계 변경시 코드 수정이 필요로 하고 이는 변경에 유연한 구조를 갖추기 힘들다.

 

또한 상속을 통한 확장이기에 추상 클래스가 추가될 때마다 구현 클래스를 추가적으로 확장시켜야하는 클래스 폭발 문제 등이 있다.

 

(물론 위 구조에서는 하위에서 구현해야하는 메서드가 하나이지만 여러 클래스를 상속하며 메서드를 오버라이딩 하는 경우 조합의 수로 클래스가 늘어날 것이다.) 

 

전략 패턴

전략패턴은 런타임 환경에서 전략을 바꿔가며 실행할 수 있는 디자인 패턴이다.

 

예제를 통해 알아보자.

@Component
class Context(
        val strategy: IStrategy
) {
    fun operation(path: String): Int {
        val resource = ClassPathResource(path)
        val reader = BufferedReader(InputStreamReader(resource.inputStream))
        var result = 0
        var line: String?
        while (reader.readLine().also { line = it } != null) {
            result = strategy.strategy(result, line!!)
        }
        return result
    }
}

interface IStrategy {
    fun strategy(result: Int, line: String) : Int
}

@Qualifier("sum")
@Component
class SummationStrategy: IStrategy {
    override fun strategy(result: Int, line: String): Int {
        return result + line.toInt()
    }
}

@Qualifier("product")
@Component
class ProductStrategy: IStrategy {
    override fun strategy(result: Int, line: String): Int {
        return if (result == 0) line.toInt() else result * line.toInt()
    }
}

 

위와 같이 Context에서 IStrategy라는 전략 인터페이스에 의존하고

 

IStrategy에 구현체를 주입받아 해당 구현체의 메서드를 실행시키게끔 한다.

 

@SpringBootTest
class ContextTest(
        @Autowired
        val context: Context
){
        @Test
        fun `전략 테스트`(){
                val result = context.operation("numberFile.txt")
                println(result)
        }
}

 

위의 테스트 코드를 통해 실행시켜보도록 하자.

 

물론 실행전 Context 클래스의 IStrategy 위에 @Qualifier의 값을 sum과 product로 주며 테스트를 실행하자.

 

각가의 경우에 15와 120이라는 정상적인 결과를 얻게 될 것이다.

 

 

전략 패턴은 위와 같은 구조로 런타임 환경에서 의존성을 결정하여 실행할 수 있다.

 

컴파일 타임이 아닌 런타임에 의존성이 결정되니 보다 변경에 유연한 구조를 갖출 수 있다.

 

의존관계가 변경되더라도 순수 프로그래밍 코드의 변경을 일으키지 않는다.

 

위에서 보더라도 프로그래밍 코드의 변경이 아닌 스프링 프레임워크의 어노테이션 값 변경을 통해 의존성 변경이 이루어짐을 알 수 있다.

 

xml 이나 자바 config를 통해 의존관계를 설정 하더라도 프로그래밍 코드의 변경은 일어나지 않을 것이다.

 

템플릿 메서드나 전략 패턴을 사용하지 않는다면 기능을 확장시켜 나갈 때 똑같은 소스코드를 copy&paste를 통해 중복하여 구현할 것이다.

 

만약 템플릿 메서드 클래스의 템플릿 메서드 전략 패턴의 operation메서드의 구현부에 변경이 있다고 해보자.

 

모든 중복된 소스에 직접 수정을 해줘야한다.

 

하지만 우리는 두 디자인 패턴을 통해 모든 기능에 중복적으로 사용되는 두 메서드에서만 변경을 한다면 모든 기능에 적용시킬 수 있다.

 

템플릿 메서드 패턴과 전략패턴의 차이

 

그렇다면 두 디자인 패턴에서 차이는 무엇일까,

 

두 디자인 패턴의 차이는 상속구조냐 합성구조냐의 차이이다.

 

상속구조는 캡슐화가 깨지고 상위 클래스의 변경이 하위 클래스에 영향을 미치는 반면,

 

합성구조는 캡슐화가 유지되기에 의존주입 받는 객체의 메서드가 변경되더라도 다른 객체에 영향을 미치지 않는다.

 

컴파일 타임에 의존성이 결정되느냐, 런타임에 결정되느냐의 차이도 사실 변경에 영향을 미치느냐 안 미치느냐의 차이이다.

 

두 디자인 패턴은 방식의 차이이므로 자세한 차이점은 상속 보다 합성을 이라는 토픽을 통해 알아보는 것이 좋을 것이다.

 

템플릿/콜백 패턴

템플릿 콜백이란 이전의 템플릿 메서드 패턴과 전략패턴과 마찬가지로 알고리즘의 특정단계를 재정의하여 사용할 수 있는 패턴이다.

동작 방식은 전략패턴과 마찬가지로 인터페이스를 통한 DI 방식에서 익명클래스로 자주 변경되는 메서드를 오버라이딩하여 객체를 전달해주는 방식이다. 이때 익명클래스가 콜백이 되고, 익명 클래스에 구현한 메서드가 콜백 메서드가 된다.

 

 

템플릿/콜백 패턴의 동작 순서는 위와 같다.

 

(1) 사용하는 클라이언트가 구현할 메서드를 오버라이딩하여 정의하므로 클라언트에서 콜백(익명클래스)를 정의한다.

(2) Template에 콜백을 전달하고 템플릿 메서드를 호출한다.

(3) 템플릿 객체의 템플릿 메서드가 실행된다.

(3) 템플릿 메서드가 실행된다.

(4) 템플릿 메서드가 실행되며 생성된 변수들, 즉 구현 로직에 의해 생성되고 콜백에 인자값으로 전달되는 변수 값들이 생성된다.

(5) 위 (4)에서 생성된 변수를 콜백 메서드의 인자값에 주입하고 콜백 메서드를 호출한다.

(6-7) 콜백이 콜백 내에 선언된 변수와 (5)에서 전달받은 변수들을 참조하여 작업을 수행한다.

(8) 콜백 메서드 결과 반환

(9-11) 템플릿 메서드에서 콜백 메서드의 결과를 받았다면 남은 로직을 수행하고 최종적으로 클라이언트에 결과를 반환한다.

 

이전의 전략패턴에서 클라이언트인 테스트 코드를 포함한 클래스 구조가 아래와 같았다면

 

템플릿/콜백 패턴에서는 아래와 같다.

 

전략 인터페이스에서 구현체가 사라졌다는 것 외에 클라이언트를 포함한 클래스 다이어그램 구조는 똑같다.
(뒤에서 설명하겠지만 구현체가 존재하는 전략패턴에서도 템플릿/콜백을 사용할 수 있다.)

 

우리는 구현체를 정의하지 않고 전략 인터페이스의 추상메서드를 오버라이딩한 익명클래스를 전달해 줄 것이다. 

 

이전의 예제를 템플릿/콜백 패턴으로 바꾸어보자.

 

아쉽게도? 또는 다행히도? 수정할 부분이 없다. 

 

단지 클라이언트에서 익명클래스를 통해 메서드를 오버라이딩하여 제공해주기만 하면 된다.

class TemplateCallbackTest {

    @Test
    fun `템플릿 콜백 summation 테스트`() {
        val templateClass = Template(object : IStrategy {
            override fun strategy(result: Int, line: String): Int {
                return result + line.toInt()
            }
        })

        val result = templateClass.operation("numberFile.txt")
        
        assertThat(result).isEqualTo(15)
    }

    @Test
    fun `템플릿 콜백 product 테스트`() {
        val templateClass = Template(object : IStrategy {
            override fun strategy(result: Int, line: String): Int {
                return if (result == 0) line.toInt() else result * line.toInt()
            }
        })

        val result = templateClass.operation("numberFile.txt")

        assertThat(result).isEqualTo(120)
    }
}

 

전략패턴이 이미 구현되어있다면 그 상태에서도 템플릿/콜백 패턴을 그대로 사용할 수 있다.

 

템플릿 콜백 패턴 적용 사례 : JdbcTemplate

자바에서 connection을 통해 직접 DB에 접근해보자.

@Service
class UserService(
    private val dataSource: DataSource,
) {
    fun updateUserEmail(id: Long, newEmail: String): Int {
        var resultCnt = 0
        var connection: Connection? = null
        var preparedStatement: PreparedStatement? = null
        try {
            connection = dataSource.connection
            val sql = "UPDATE users SET email = ? WHERE id = ? "
            preparedStatement = connection.prepareStatement(sql)
            preparedStatement.setString(1, newEmail)
            preparedStatement.setLong(2, id)
            resultCnt = preparedStatement.executeUpdate()
        } catch (e: SQLException) {
            e.printStackTrace()
        } finally {
            try {
                preparedStatement?.close()
                connection?.close()
            } catch (e: SQLException) {
                e.printStackTrace()
            }
        }
        return resultCnt
    }
}

 

위의 코드는 커넥션을 통해 DB에 접근하여 쿼리를 실행시키는 로직이다.

 

프로그래밍단에서 DB에 접근하여 쿼리를 실행시키는 로직은 대다수 위와 같은 구조를 갖추고 있다.

 

위 코드에서 sql에 따라 변경될만한 부분은 

 

 val sql = "UPDATE users SET email = ? WHERE id = ? "
preparedStatement = connection.prepareStatement(sql)
preparedStatement.setString(1, newEmail)
preparedStatement.setLong(2, id)
resultCnt = preparedStatement.executeUpdate()

 

sql과 파라미터를 선언하는 부분이다.

 

변경이 일어나지 않는 영역은 connection을 가져오고 연결을 끊는 부분이다. 

 

나의 개인적인 생각으로 변하는 부분과 변하지 않는 부분이 굉장히 강한 결합이 존재한다고 생각한다.

 

변하지 않는 부분에서  connection을 정의하고 connection을 가져온다.

 

connection을 가져오는 이유는 쿼리를 실행시키는 것 외에는 거의 없다고 생각한다. 

 

즉 변하지 않는 부분만 보더라도 변하는 부분에 어떤 코드가 올지 알 수 있다는 것이다.

  • connection을 통해 sql을 주입한 preparedStatement 객체 생성
  • 파라미터 바인딩
  • 쿼리 실행

update, insert, delete 쿼리 모두 위와 같은 구조를 갖는다.

(select라면 가져온 값을 매핑하는 로직이 존재할 것이다.)

 

sql과 파라미터와 같은 인자값은 다르겠지만 전략마다 코드 구조는 위와 같을 것이다.

 

말이 길었지만 내가 하고 싶은 말은 전략이 다양할 필요가 없고 변하는 부분과 변하지 않는 부분은 이미 강한 결합을 갖추고 있다는 것이다.

 

 

JdbcTemplate은 위 구조를 어떻게 템플릿 콜백으로 적용시켰는지 살펴보자. 

fun updateUserEmail(id: Long, newEmail: String): Int {
    return jdbcTemplate.update(object : PreparedStatementCreator {
        override fun createPreparedStatement(con: Connection): PreparedStatement {
            val preStmt = con.prepareStatement("UPDATE users SET email = ? WHERE id = ?")
            preStmt.setString(1, newEmail)
            preStmt.setLong(2, id)
            return preStmt
        }
    })
}

 

클라이언트 입장에서 위와 같이 PreparedStatementCreator라는 콜백 객체를 정의하여 전달하여 사용할 수 있고

fun simpleUpdateUserEmail(id: Long, newEmail: String): Int {
    return jdbcTemplate.update("UPDATE users SET email = ? WHERE id = ?", newEmail, id)
}

 

또한 위와 같이 sql과 파라미터만 전달하여 사용할 수 있다. 위 경우는 템플릿 내부의 콜백 객체를 사용한 경우이다.

 

JdbcTemplate 내부에서 콜백을 어떻게 정의하고 사용하는 소스코드를 살펴보자.

@Override
public int update(String sql, @Nullable PreparedStatementSetter pss) throws DataAccessException {
    return update(new SimplePreparedStatementCreator(sql), pss);
}

 

jdbcTemplate의 update 메서드에서는 SimplePreparedStatementCreator라는 콜백 객체를 생성하여 메서드의 인자값만 들어온 경우 내부 콜백에 인자값을 넘겨 처리할 수 있다.

 

SimplePreparedStatementCreator는 JdbcTemplate 내부에 정적 중첩클래스로 선언되어있다.

private static class SimplePreparedStatementCreator implements PreparedStatementCreator, SqlProvider {

    private final String sql;

    public SimplePreparedStatementCreator(String sql) {
       Assert.notNull(sql, "SQL must not be null");
       this.sql = sql;
    }

    @Override
    public PreparedStatement createPreparedStatement(Connection con) throws SQLException {
       return con.prepareStatement(this.sql);
    }

    @Override
    public String getSql() {
       return this.sql;
    }
}

 

메서드 내에 콜백 객체가 선언된 경우도 있다.

@Override
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 + "]");
    }

    // Callback to execute the update statement.
    class UpdateStatementCallback implements StatementCallback<Integer>, SqlProvider {
       @Override
       public Integer doInStatement(Statement stmt) throws SQLException {
          int rows = stmt.executeUpdate(sql);
          if (logger.isTraceEnabled()) {
             logger.trace("SQL update affected " + rows + " rows");
          }
          return rows;
       }
       @Override
       public String getSql() {
          return sql;
       }
    }

    return updateCount(execute(new UpdateStatementCallback(), true));
}

 

내부 콜백을 사용하는 경우 클라이언트를 전략과 분리 시킬 수 있다.

 

 

전략에 해당하는 콜백이 템플릿 내부에 존재하므로 클라이언트 입장에서는 전략을 직접 구현하지 않더라도 템플릿 콜백과 주고 받는 정보에만 집중하여 로직을 실행시킬 수 있다.

 

그렇다면 왜 jdbcTemplate은 왜 템플릿 콜백 패턴을 사용하였을까

 

좀전에 나는 DB에 접근하여 쿼리를 실행시키는 로직을 보며

 

전략이 다양할 필요가 없고 변하는 부분과 변하지 않는 부분은 이미 강한 결합을 갖추고 있다는 것이다.

 

라고 말하였다. 

 

jdbcTemplate에는 실제 7개의 내부 콜백 클래스가 정의되어있다. 

 

7개의 전략으로 모든 sql을 처리한다.

 

또한 모두 jdbcTemplate 클래스 내에 중첩 클래스로 존재한다. 

 

이미 강한 결합을 갖추고 있는데 굳이 인터페이스를 통해 DI 시키는 방식으로 분리하여 의존도를 낮춰야 할까??

 

좀전에 얘기한 것 처럼 코드구조가 바뀔일도 거의 일관되지 않나.

 

만약 jdbcTemplate이 템플릿 콜백 패턴이 아닌 전략패턴을 사용했다고 해보자.

 

코드 구조가 거의 비슷한데 인터페이스를 상속받으며 매번 클래스를 만드는것은 굉장히 번거롭다.

 

전략마다 구현부가 거의 다르지 않을 것이다. 

 

콜백을 직접 정의하여 전달해준다고 하더라도 마찬가지이다.

 

 

템플릿 콜백 패턴을 통해 내부 콜백을 사용한다면 클라이언트는 전략이 아닌 주고 받는 정보에 집중하여 로직을 실행 시킬 수 있다.

 

전략패턴과 템플릿/콜백 패턴 비교

  1. 클래스를 통한 캡슐화(결합도)
    전략 패턴의 경우 DI를 통해 템플릿이 전략에 의존하므로 전략 메서드의 구체적인 내용을 알지 못한다.
    템플릿/콜백의 경우 클라이언트에 전략에 해당하는 콜백을 정의하므로 클라이언트가 전략 메서드에 대한 구체적인 내용을 알게되고 이에대한 책임을 갖게 된다. 또한 템플릿 내부에 콜백을 정의한다고 하더라도 템플릿과 콜백의 결합도가 증가할 수 있다.

  2. 전략 결정 시점
    전략 패턴의 경우 런타임 시점에 전략이 결정되고 템플릿/콜백 패턴의 경우 컴파일 타임에 전략이 결정된다.
    템플릿/콜백 패턴의 경우 전략이 메서드를 호출하는 시점에 결정된다고도 하지만 좀 더 큰 범위에서는 클라이언트가 직접 사용할 메서드 내용이 코드로 구현되어있으므로 컴파일 타임에 이미 전략은 결정된다.
    이는 내부 콜백을 사용한다고 하더라도 템플릿 안에 콜백이 존재하므로 컴파일 타임에 결정되는 것은 마찬가지이다.
    따라서 템플릿/콜백 패턴에서 전략이 바뀐다면 전략 패턴 보다 더 위험도가 높을 수 있다.

  3. 재사용성
    전략 패턴의 경우 구체적인 전략에 해당하는 구현체를 클래스를 통해 정의하므로 재사용이 가능하다. 여러 클라이언트에서 재사용이 가능하다. 반면 템플릿/콜백 패턴의 경우 클라이언트에서 메서드를 직접 정의하므로 한번이라도 재사용한다면 메서드 구현부를 중복하여 선언하게 된다.
    내부 콜백을 사용하면 전략을 생성하지 않으니 비교하지 않겠다.

 

결론 : 

전략 변경이 자주 발생한다면 전략패턴을 사용하는 것이 유리하고 전략 변경이 자주 일어나지 않고 이미 전략과 템플릿의 강한 결합이 존재한다면 템플릿 콜뱃을 사용하는 것이 단순하니 유리할 수 있다.

 

템플릿 콜백으로 응집도 높고 단순한 구조를 만들어 주고 받는 정보에만 집중할 수 있다면 사용하는 클라이언트 입장에서 편리하게 사용할 수 있을 거 같다.

반응형