템플릿 메서드 패턴
개요
보고서를 만들 때 입력 정리, 빈 값 검사, 형식 변환의 순서는 늘 같다고 하자. 형식마다 이 순서를 복사하면 한 구현에서 검사를 빠뜨리거나 순서를 바꾸기 쉽다.
템플릿 메서드(Template Method)는 알고리즘의 뼈대를 메서드 하나에 두고 바뀌는 단계만 하위 클래스에 맡긴다. 여기서 템플릿은 문서 파일이 아니라 실행 순서의 틀이라는 뜻이다.
UML
ReportTemplate.Run()이 입력 정리와 검사를 수행한 뒤 Format()을 부른다. UpperReport와 JsonReport는 Format()만 구현하며 Run()은 재정의하지 않는다.
역사
GoF는 1994년 Design Patterns에서 Template Method를 행위 패턴으로 정리했다. 알고리즘의 구조를 유지한 채 일부 단계를 하위 클래스가 바꾸게 하는 방식이다.1
왜 사용하는가?
반드시 수행해야 할 순서를 한곳에서 읽을 수 있고, 각 구현은 표현 단계에 집중한다. Run()은 virtual이 아니므로 이 호출 경로의 단계 순서를 하위 클래스가 재정의하지 않는다.
상속으로 단계를 바꾸므로 실행 중에 같은 객체의 처리 방식을 바꿔 끼우기에는 제약이 있다. 처리 규칙을 객체나 함수로 전달하는 전략 패턴과 선택 기준이 다르다.
예제
양쪽 공백을 제거한 뒤 대문자 형식으로 보고서를 만든다.
using System;
using System.Collections.Generic;
using System.Linq;
ExportResult result = new UpperReport().Run(" hello ");
Console.WriteLine(result.Success + ": " + result.Text);
sealed record ExportResult(bool Success, string Text);
// 반복되는 순서와 검사 규칙은 기반 클래스 한곳에서 관리한다.
abstract class ReportTemplate
{
public ExportResult Run(string input)
{
string value = input.Trim();
if (value.Length == 0) return new ExportResult(false, "EMPTY_INPUT");
return new ExportResult(true, "REPORT|" + Format(value));
}
protected abstract string Format(string value);
}
// 하위 클래스는 공통 흐름 전체가 아니라 이 변환 단계만 바꾼다.
sealed class UpperReport : ReportTemplate
{
protected override string Format(string value) => value.ToUpperInvariant();
}
sealed class JsonReport : ReportTemplate
{
protected override string Format(string value) => System.Text.Json.JsonSerializer.Serialize(value);
}
True: REPORT|HELLORun()은 정리·검사·변환 순서를 소유하고, UpperReport에는 대문자로 바꾸는 단계만 있다.
범위 넓힌 예제
JSON 형식 구현을 사용하고 빈 입력도 전달한다. 빈 값은 Format()을 부르기 전에 실패 결과로 반환한다.
using System;
using System.Collections.Generic;
using System.Linq;
ReportTemplate report = new JsonReport();
foreach (string input in new[] { " hello ", " " })
{
ExportResult result = report.Run(input);
Console.WriteLine(result.Success + ": " + result.Text);
}
sealed record ExportResult(bool Success, string Text);
// 반복되는 순서와 검사 규칙은 기반 클래스 한곳에서 관리한다.
abstract class ReportTemplate
{
public ExportResult Run(string input)
{
string value = input.Trim();
if (value.Length == 0) return new ExportResult(false, "EMPTY_INPUT");
return new ExportResult(true, "REPORT|" + Format(value));
}
protected abstract string Format(string value);
}
// 하위 클래스는 공통 흐름 전체가 아니라 이 변환 단계만 바꾼다.
sealed class UpperReport : ReportTemplate
{
protected override string Format(string value) => value.ToUpperInvariant();
}
sealed class JsonReport : ReportTemplate
{
protected override string Format(string value) => System.Text.Json.JsonSerializer.Serialize(value);
}
True: REPORT|"hello"
False: EMPTY_INPUT성공 여부와 결과 문자열을 나누어 잘못된 입력을 정상 보고서로 취급하지 않는다. 공통 흐름이 없는데 단순한 함수 하나를 위해 상속 계층을 만드는 것은 피할 수 있다.
TypeScript 예제
입력 정리·빈 값 검사·접두사 추가는 기반 클래스에서 한 번 정하고, 형식마다 달라지는 변환만 format()으로 나눈다. 각 형식이 검사를 복사하다 빠뜨리지 않으면서 변환 규칙만 바꾸도록 한 구조다.
type ExportResult = { success: true; text: string } | { success: false; code: "EMPTY_INPUT" };
// 반복되는 순서와 검사 규칙은 기반 클래스 한곳에서 관리한다.
abstract class ReportTemplate {
run(value: string): ExportResult {
const clean = value.trim();
if (clean.length === 0) return { success: false, code: "EMPTY_INPUT" };
return { success: true, text: "REPORT|" + this.format(clean) };
}
protected abstract format(value: string): string;
}
// 하위 클래스는 공통 흐름 전체가 아니라 이 변환 단계만 바꾼다.
class UpperReport extends ReportTemplate {
protected override format(value: string): string { return value.toUpperCase(); }
}
class JsonReport extends ReportTemplate {
protected override format(value: string): string { return JSON.stringify(value); }
}
for (const report of [new UpperReport(), new JsonReport()]) {
const result = report.run(" hello ");
console.log(result.success ? result.text : result.code);
}
const empty = new UpperReport().run(" ");
console.log(empty.success ? empty.text : empty.code);REPORT|HELLO
REPORT|"hello"
EMPTY_INPUTTypeScript에는 C# 비가상 메서드와 같은 final 제한이 없으므로 여기서는 run()을 재정의하지 않고 format()만 구현한다. 빈 입력은 변환기를 호출하기 전에 실패 결과를 반환한다. 비교에 쓴 입력은 ASCII이며 Unicode 공백·대소문자 규칙은 두 런타임에서 다를 수 있다.
상속 대신 함수와 LINQ로 조합하기
달라지는 단계가 문자열 변환 하나라면 함수를 인자로 받는 편이 간단하다. Run은 입력 정리와 빈 값 검사를 계속 맡고, 호출부가 변환 함수만 전달한다. LINQ의 Select는 각 입력에 같은 흐름을 적용한다.
C#
using System;
using System.Linq;
using System.Text.Json;
ExportResult Run(string input, Func<string, string> format)
{
string value = input.Trim();
if (value.Length == 0) return new ExportResult(false, "EMPTY_INPUT");
return new ExportResult(true, "REPORT|" + format(value));
}
var inputs = new[] { " hello ", " " };
// 형식만 전달하며, 빈 입력은 변환 함수를 호출하기 전에 실패한다.
var results = inputs.Select(input => Run(input, value => JsonSerializer.Serialize(value)))
.ToArray();
foreach (ExportResult result in results) Console.WriteLine(result.Text);
sealed record ExportResult(bool Success, string Text);TypeScript
type ExportResult = { success: true; text: string } | { success: false; code: "EMPTY_INPUT" };
function run(input: string, format: (value: string) => string): ExportResult {
const value = input.trim();
if (value.length === 0) return { success: false, code: "EMPTY_INPUT" };
return { success: true, text: "REPORT|" + format(value) };
}
const inputs = [" hello ", " "];
// 공통 흐름은 한 함수에 두고 각 입력에 적용한다.
const results = inputs.map(input => run(input, value => JSON.stringify(value)));
for (const result of results) console.log(result.success ? result.text : result.code);REPORT|"hello"
EMPTY_INPUT상속형 Template Method는 하위 클래스가 부모의 흐름과 보호 멤버에 묶인다. 훅과 하위 클래스를 계속 늘려야 한다면 단순한 변환도 부모 변경의 영향을 받게 되므로 함수 전달이나 Strategy로 나누는 편이 낫다. 이 대안에서도 정리→검사→변환 순서와 실패 결과는 유지한다.
Microsoft: Introduction to LINQ queries
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.