Template Method 패턴
여러 종류의 데이터를 파싱해야 하는 상황을 생각해보자. RSS 2.0, Atom 1.0 같은 XML 피드 포맷이 대표적이다. 각 포맷마다 XML 구조가 다르지만, 파싱의 전체 흐름은 동일하다.
- XML을 파싱해서 원시 데이터를 추출한다
- 시간 기준으로 필터링한다
- 최종 결과 형태로 변환한다
이 흐름 자체는 포맷이 바뀌어도 변하지 않는다. 변하는 건 1번 단계에서 "XML의 어느 필드를 꺼내는가"뿐이다. 이런 상황에서 각 포맷별로 전체 로직을 복사해서 쓰면 중복이 생기고, 흐름이 변경될 때 모든 구현체를 수정해야 한다.
Template Method 패턴은 이 문제를 해결한다. 알고리즘의 골격(흐름)을 상위 클래스에 고정하고, 변하는 부분만 하위 클래스에서 구현하게 위임하는 패턴이다.
패턴의 구조
Template Method 패턴은 두 가지 역할로 구성된다.
- Abstract Class (추상 클래스): 알고리즘의 전체 흐름을 정의하는 "템플릿 메서드"를 가진다. 이 메서드 안에서 고정된 단계와 추상 메서드(변하는 단계)를 조합한다.
- Concrete Class (구현 클래스): 추상 메서드를 오버라이드해서 각 변형에 맞는 구체적인 로직을 제공한다.
핵심은 **템플릿 메서드가 public이고 추상 메서드가 protected**라는 점이다. 외부에서는 템플릿 메서드만 호출하고, 내부 단계의 구현 방식은 알 필요가 없다. 이게 일반적인 상속과 다른 점이다. 단순 상속은 부모의 메서드를 자식이 자유롭게 오버라이드하지만, Template Method는 "이 순서대로 실행해야 한다"는 프로토콜을 강제한다.
기본 구현
TypeScript로 XML 피드 파서를 예로 들어보자.
interface RawItem {
title: string;
link: string;
pubDate: string;
description: string;
}
abstract class BaseFeedParser {
// 템플릿 메서드: 알고리즘의 골격을 정의
async parse(xmlData: string, since: Date): Promise<ParsedItem[]> {
const rawItems = this.extractRawItems(xmlData); // 변하는 부분
const filtered = this.filterByTime(rawItems, since); // 고정된 부분
const result = await this.convertToResult(filtered); // 고정된 부분
return result;
}
// 하위 클래스가 구현해야 하는 추상 메서드
abstract canHandle(xmlData: string): boolean;
protected abstract extractRawItems(xmlData: string): RawItem[];
// 공통 로직: 시간 기준 필터링
private filterByTime(items: RawItem[], since: Date): RawItem[] {
return items.filter((item) => {
const pubDate = new Date(item.pubDate);
return pubDate >= since;
});
}
// 공통 로직: 최종 변환
private async convertToResult(items: RawItem[]): Promise<ParsedItem[]> {
return Promise.all(
items.map(async (item) => ({
title: item.title,
link: decodeURIComponent(item.link),
publishedAt: new Date(item.pubDate),
content: stripHtml(item.description),
}))
);
}
}
parse()가 템플릿 메서드다. 전체 흐름(추출 → 필터 → 변환)은 여기서 고정되고, extractRawItems()만 하위 클래스에 위임한다. filterByTime()과 convertToResult()는 private이라 하위 클래스가 건드릴 수 없다.
구현 클래스
이제 RSS 2.0과 Atom 1.0 각각의 구현체를 만든다.
import { XMLParser } from 'fast-xml-parser';
class Rss20Parser extends BaseFeedParser {
private xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
});
canHandle(xmlData: string): boolean {
const parsed = this.xmlParser.parse(xmlData);
return !!parsed.rss?.channel?.item;
}
protected extractRawItems(xmlData: string): RawItem[] {
const parsed = this.xmlParser.parse(xmlData);
let items = parsed.rss.channel.item;
// item이 하나뿐이면 배열이 아닌 객체로 파싱됨
if (!Array.isArray(items)) {
items = [items];
}
return items.map((feed: any) => ({
title: feed.title,
link: feed.link,
pubDate: feed.pubDate,
description: feed.description ?? '',
}));
}
}
class Atom10Parser extends BaseFeedParser {
private xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
});
canHandle(xmlData: string): boolean {
const parsed = this.xmlParser.parse(xmlData);
return !!parsed.feed?.entry;
}
protected extractRawItems(xmlData: string): RawItem[] {
const parsed = this.xmlParser.parse(xmlData);
let entries = parsed.feed.entry;
if (!Array.isArray(entries)) {
entries = [entries];
}
return entries.map((entry: any) => ({
title: entry.title,
link: this.extractLink(entry.link),
pubDate: entry.published || entry.updated,
description: entry.summary || entry.content || '',
}));
}
private extractLink(linkData: any): string {
if (typeof linkData === 'string') return linkData;
// Atom에서는 link가 배열일 수 있음 (alternate, self 등)
if (Array.isArray(linkData)) {
const alternate = linkData.find((l) => l['@_rel'] === 'alternate');
return alternate?.['@_href'] || '';
}
return linkData['@_href'] || '';
}
}
두 클래스 모두 extractRawItems()만 구현하면 된다. RSS 2.0은 rss.channel.item에서, Atom 1.0은 feed.entry에서 데이터를 꺼내는 것만 다르고, 나머지 흐름은 부모 클래스가 관리한다.
매니저 패턴과의 조합
Template Method 패턴만으로도 유용하지만, 여러 구현체를 동적으로 선택해야 하는 경우 매니저(디스패처) 클래스와 조합하면 더 강력해진다.
class FeedParserManager {
private parsers: BaseFeedParser[];
constructor(parsers: BaseFeedParser[]) {
this.parsers = parsers;
}
async parse(xmlData: string, since: Date): Promise<ParsedItem[]> {
// 데이터를 처리할 수 있는 파서를 자동으로 찾아 실행
const parser = this.parsers.find((p) => p.canHandle(xmlData));
if (!parser) {
throw new Error('지원하지 않는 피드 포맷입니다');
}
return parser.parse(xmlData, since);
}
}
// 사용
const manager = new FeedParserManager([
new Rss20Parser(),
new Atom10Parser(),
]);
const items = await manager.parse(xmlData, new Date('2025-07-01'));
canHandle()이 각 구현체에 있기 때문에 매니저는 포맷에 대해 아무것도 몰라도 된다. 새로운 포맷(예: JSON Feed)이 추가되면 BaseFeedParser를 상속한 클래스를 하나 만들어서 배열에 추가하기만 하면 된다. 기존 코드 수정이 필요 없다(OCP, 개방-폐쇄 원칙).
Template Method vs Strategy 패턴
두 패턴 모두 "변하는 부분을 분리한다"는 목적은 같지만 접근 방식이 다르다.
Template Method (상속 기반)
abstract class Sorter {
sort(data: number[]): number[] {
const prepared = this.prepare(data); // 고정
const sorted = this.doSort(prepared); // 변하는 부분
return this.finalize(sorted); // 고정
}
protected abstract doSort(data: number[]): number[];
private prepare(data: number[]): number[] {
return [...data]; // 원본 보호
}
private finalize(data: number[]): number[] {
return data;
}
}
class QuickSorter extends Sorter {
protected doSort(data: number[]): number[] {
// 퀵소트 구현
return data.sort((a, b) => a - b);
}
}
Strategy (합성 기반)
interface SortStrategy {
sort(data: number[]): number[];
}
class Sorter {
constructor(private strategy: SortStrategy) {}
sort(data: number[]): number[] {
const prepared = [...data];
return this.strategy.sort(prepared);
}
// 런타임에 전략 교체 가능
setStrategy(strategy: SortStrategy) {
this.strategy = strategy;
}
}
| 비교 항목 | Template Method | Strategy |
|---|---|---|
| 관계 | 상속 (is-a) | 합성 (has-a) |
| 변하는 단위 | 알고리즘의 특정 단계 | 알고리즘 전체 |
| 흐름 제어 | 부모 클래스가 강제 | 클라이언트가 자유롭게 조합 |
| 런타임 교체 | 불가능 (컴파일 타임 결정) | 가능 |
| 적합한 경우 | 전체 흐름은 같고 일부만 다를 때 | 알고리즘 자체가 통째로 교체될 때 |
피드 파서의 경우 "추출 → 필터 → 변환"이라는 흐름이 고정되어 있고, 추출 방식만 다르므로 Template Method가 적합하다. 만약 필터링 방식이나 변환 방식도 각각 독립적으로 바뀌어야 한다면 Strategy 패턴이 더 나을 수 있다.
메서드 접근 제어의 중요성
Template Method 패턴에서 접근 제어자를 어떻게 쓰는지가 설계의 핵심이다.
abstract class BaseProcessor {
// public: 외부에서 호출하는 유일한 진입점
public process(input: string): Result {
const validated = this.validate(input);
const transformed = this.transform(validated);
return this.format(transformed);
}
// protected abstract: 하위 클래스가 반드시 구현
protected abstract transform(data: ValidData): TransformedData;
// protected: 하위 클래스가 선택적으로 오버라이드 가능 (hook)
protected validate(input: string): ValidData {
// 기본 유효성 검사
if (!input) throw new Error('입력값이 비어있습니다');
return { raw: input };
}
// private: 하위 클래스가 절대 변경 불가
private format(data: TransformedData): Result {
return { data, timestamp: new Date() };
}
}
public템플릿 메서드: 외부 진입점이자 흐름의 주인. 하위 클래스가 오버라이드하면 안 된다.protected abstract: 하위 클래스가 반드시 채워야 하는 빈칸.protected(hook): 기본 구현이 있지만 필요하면 오버라이드할 수 있는 확장점. 이걸 "hook 메서드"라고 부른다.private: 절대 변경할 수 없는 고정 로직.
이 네 가지 접근 제어의 조합이 Template Method 패턴의 유연성과 안정성을 동시에 보장한다. hook이 너무 많으면 Strategy 패턴으로 전환하는 게 낫고, 모든 단계가 abstract이면 사실상 인터페이스와 다를 바 없으므로 인터페이스를 쓰는 게 낫다.
주의점
Template Method 패턴이 만능은 아니다. 몇 가지 주의할 점이 있다.
상속 계층이 깊어지는 문제: 추상 클래스를 상속한 클래스가 또 추상 클래스가 되고, 그걸 또 상속하는 식으로 계층이 깊어지면 코드를 추적하기 어렵다. 보통 2단계(추상 → 구현)까지만 유지하는 게 좋다.
강한 결합: 하위 클래스가 상위 클래스의 내부 구현에 의존하게 된다. 상위 클래스의 private 메서드 시그니처를 바꾸면 하위 클래스에는 영향이 없지만, protected 메서드의 시그니처가 바뀌면 모든 하위 클래스를 수정해야 한다.
리스코프 치환 원칙(LSP) 위반 가능성: 하위 클래스가 추상 메서드를 구현할 때 상위 클래스가 기대하는 계약(반환 타입, 부수 효과 등)을 지키지 않으면 전체 흐름이 깨진다. TypeScript의 타입 시스템이 반환 타입은 강제하지만, "빈 배열을 반환하면 안 된다"같은 의미적 계약은 강제할 수 없으므로 문서화와 테스트로 보완해야 한다.
실전에서 쓰이는 예시
Template Method 패턴은 프레임워크에서 특히 많이 쓰인다.
- React 클래스 컴포넌트:
render(),componentDidMount()등의 생명주기 메서드가 Template Method의 변형이다. React가 호출 순서를 제어하고, 개발자는 각 단계를 구현한다. - NestJS Pipe/Guard/Interceptor: 프레임워크가 요청 처리 파이프라인의 흐름을 정의하고, 개발자가
transform(),canActivate(),intercept()등을 구현한다. - Express middleware:
(req, res, next)패턴도 넓은 의미에서 Template Method다. 프레임워크가 미들웨어 체인의 실행 순서를 정하고, 각 미들웨어가 처리 로직을 구현한다. - 테스트 프레임워크: Jest/Vitest의
beforeEach(),afterEach(),test()가 테스트 실행 흐름의 각 단계를 개발자에게 위임한다.
핵심은 항상 같다. 프레임워크가 흐름을 소유하고, 개발자가 빈칸을 채운다. 이것이 "Hollywood Principle"(Don't call us, we'll call you)이라고도 불리는 제어 역전(IoC)의 구체적인 구현 방식이다.