CSV, JSON, 엑셀에서 데이터를 가져오는 기능은 “읽기 → 걸러내기 → 저장 → 알리기”라는 같은 순서를 따르고, 읽는 방법만 다르다. 형식마다 이 순서를 복사해 두면 단계 하나를 추가할 때 모든 복사본을 고쳐야 하고, 어느 한 곳에서 순서가 어긋나도 알아채기 어렵다. 템플릿 메서드(Template Method)는 알고리즘의 뼈대를 부모 클래스의 메서드 하나에 정의하고, 일부 단계만 서브클래스가 재정의하게 한다. 이 글에서는 필수 단계와 선택적 훅, 부모 호출을 잊는 “call super” 안티패턴과 이를 막는 NVI(Non-Virtual Interface) 관용구, CRTP 변형, 그리고 C의 테스트 러너를 다룬다.
템플릿 메서드와 전략의 비교는 설계 기법 (6) 입력 검증에서, 스레드 클래스의 실행 함수를 템플릿 메서드로 만드는 예는 설계 기법 (1) 스레드 클래스에서 다뤘다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (19) — 상태(State)
- C/C++ 디자인 패턴 (20) — 전략(Strategy)
- C/C++ 디자인 패턴 (22) — 방문자(Visitor)
- C/C++ 디자인 패턴 (23) — 인터프리터(Interpreter)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| AbstractClass | 템플릿 메서드(뼈대)와 단계 연산 선언 | Importer::run() |
| Primitive operation | 서브클래스가 반드시 구현하는 단계 | parse(), save() |
| Hook | 기본 구현이 있어 필요할 때만 재정의 | accept(), afterSave() |
| ConcreteClass | 단계 구현 | CsvImporter, PassingOnlyCsv |
필수 단계와 훅
// importer.cpp — 알고리즘 뼈대(run)는 부모가, 단계는 자식이. 필수 단계와 선택적 훅
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
struct Record { std::string name; int score; };
class Importer {
public:
virtual ~Importer() = default;
int run(const std::string &raw) // 템플릿 메서드: 순서는 고정
{
auto rows = parse(raw); // 필수 단계
std::vector<Record> kept;
for (const auto &r : rows)
if (accept(r)) // 훅: 기본은 전부 통과
kept.push_back(r);
save(kept); // 필수 단계
afterSave(kept.size()); // 훅: 기본은 아무것도 안 함
return static_cast<int>(kept.size());
}
protected:
virtual std::vector<Record> parse(const std::string &raw) = 0;
virtual void save(const std::vector<Record> &rows) = 0;
virtual bool accept(const Record &) { return true; }
virtual void afterSave(std::size_t) {}
};
class CsvImporter : public Importer {
protected:
std::vector<Record> parse(const std::string &raw) override
{
std::vector<Record> out;
std::istringstream in(raw);
for (std::string line; std::getline(in, line);) {
auto c = line.find(',');
out.push_back({line.substr(0, c), std::stoi(line.substr(c + 1))});
}
return out;
}
void save(const std::vector<Record> &rows) override
{ for (auto &r : rows) std::cout << " INSERT " << r.name << ' ' << r.score << '\n'; }
};
class PassingOnlyCsv : public CsvImporter { // 훅만 재정의
protected:
bool accept(const Record &r) override { return r.score >= 60; }
void afterSave(std::size_t n) override { std::cout << " notify: " << n << " rows imported\n"; }
};
int main()
{
const std::string data = "kim,90\nlee,45\npark,72\n";
std::cout << "CsvImporter:\n";
CsvImporter a;
a.run(data);
std::cout << "PassingOnlyCsv:\n";
PassingOnlyCsv b;
b.run(data);
}$ g++ -std=c++20 -Wall -Wextra -O2 -o importer importer.cpp && ./importer
CsvImporter:
INSERT kim 90
INSERT lee 45
INSERT park 72
PassingOnlyCsv:
INSERT kim 90
INSERT park 72
notify: 2 rows imported
PassingOnlyCsv는 파싱과 저장을 다시 쓰지 않고 훅 두 개만 재정의해서 필터와 알림을 추가했다. 단계 순서는 여전히 run() 한 곳에만 있다.
call super 안티패턴과 NVI
공개 가상 함수를 재정의하게 하고 “부모 것도 꼭 먼저 불러라”를 규칙으로 두면, 누군가는 반드시 잊는다. NVI는 공개 함수를 비가상으로 두어 부모가 순서와 사전·사후 처리를 쥐고, 서브클래스는 private 가상 함수만 재정의하게 한다.
// call_super.cpp — "재정의할 때 부모 것도 꼭 불러라" 규칙은 잊히기 쉽다. NVI로 강제한다
#include <iostream>
#include <mutex>
#include <string>
namespace call_super { // 안티패턴
class Service {
public:
virtual ~Service() = default;
virtual void start() { std::cout << " base: open log, register metrics\n"; started = true; }
bool started = false;
};
struct Good : Service {
void start() override { Service::start(); std::cout << " good: bind port\n"; }
};
struct Forgetful : Service {
void start() override { std::cout << " forgetful: bind port\n"; } // Service::start() 누락
};
}
namespace nvi { // Non-Virtual Interface
class Service {
public:
virtual ~Service() = default;
void start() // 비가상 공개 함수: 순서를 부모가 쥔다
{
std::lock_guard lk(m_);
std::cout << " base: open log, register metrics\n";
doStart(); // 자식이 채우는 단계
started = true;
std::cout << " base: started\n";
}
bool started = false;
private:
virtual void doStart() = 0; // private 가상: 재정의만 가능, 직접 호출 불가
std::mutex m_;
};
struct Http : Service {
private:
void doStart() override { std::cout << " http: bind port\n"; }
};
}
int main()
{
std::cout << std::boolalpha << "call-super:\n";
call_super::Good g; g.start(); std::cout << " good.started=" << g.started << '\n';
call_super::Forgetful f; f.start(); std::cout << " forgetful.started=" << f.started << '\n';
std::cout << "NVI:\n";
nvi::Http h; h.start(); std::cout << " http.started=" << h.started << '\n';
#ifdef SHOW_ERROR
h.doStart(); // 사전·사후 처리를 건너뛰는 직접 호출
#endif
}$ g++ -std=c++20 -Wall -Wextra -O2 -o call_super call_super.cpp && ./call_super
call-super:
base: open log, register metrics
good: bind port
good.started=true
forgetful: bind port
forgetful.started=false
NVI:
base: open log, register metrics
http: bind port
base: started
http.started=true
$ g++ -std=c++20 -DSHOW_ERROR -c call_super.cpp 2>&1 | grep error
call_super.cpp:53:18: error: ‘virtual void nvi::Http::doStart()’ is private within this context
Forgetful는 컴파일도 실행도 되지만 로그와 메트릭 등록이 빠졌고 started도 거짓이다. NVI 버전에서는 서브클래스가 doStart()만 채우므로 잠금, 로그, 상태 갱신을 빠뜨릴 수 없고, 외부에서 doStart()를 직접 불러 우회하는 것도 막힌다.
CRTP: 가상 함수 없는 템플릿 메서드
단계의 구현이 컴파일 시점에 정해진다면 CRTP로 뼈대를 만들 수 있다. 빠진 단계는 런타임이 아니라 컴파일 에러로 드러난다.
// crtp_tm.cpp — 가상 함수 없는 템플릿 메서드(CRTP): 단계가 없으면 컴파일 에러
#include <iostream>
#include <string>
template <class Derived>
class Report {
public:
std::string render() // 뼈대
{
auto &d = static_cast<Derived &>(*this);
return d.header() + d.body() + footer();
}
private:
std::string footer() { return "-- end --\n"; }
};
struct Daily : Report<Daily> {
std::string header() { return "[daily]\n"; }
std::string body() { return " 42 jobs ok\n"; }
};
struct Weekly : Report<Weekly> {
std::string header() { return "[weekly]\n"; }
std::string body() { return " 290 jobs ok, 3 failed\n"; }
};
#ifdef SHOW_ERROR
struct Broken : Report<Broken> {
std::string header() { return "[broken]\n"; } // body() 누락
};
#endif
int main()
{
std::cout << Daily{}.render() << Weekly{}.render();
#ifdef SHOW_ERROR
std::cout << Broken{}.render();
#endif
}$ g++ -std=c++20 -Wall -Wextra -O2 -o crtp_tm crtp_tm.cpp && ./crtp_tm
[daily]
42 jobs ok
-- end --
[weekly]
290 jobs ok, 3 failed
-- end --
$ g++ -std=c++20 -DSHOW_ERROR -c crtp_tm.cpp 2>&1 | grep error | head -1
crtp_tm.cpp:11:39: error: ‘struct Broken’ has no member named ‘body’
C 구현: 테스트 러너
단위 테스트 프레임워크는 템플릿 메서드의 대표적인 예다. 러너가 “준비 → 실행 → 정리” 순서와 결과 집계를 맡고, 각 테스트는 단계 함수만 제공한다. C에서는 함수 포인터 구조체로 표현하고, NULL인 단계는 훅처럼 건너뛴다.
/* runner.c — C 템플릿 메서드: 실행 순서(setup → run → teardown)는 러너가, 단계는 테스트가 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct test_case {
const char *name;
void *(*setup)(void); /* 선택: NULL이면 생략(훅) */
int (*run)(void *fixture); /* 필수: 0이면 성공 */
void (*teardown)(void *fixture); /* 선택 */
};
static int run_all(const struct test_case *tc, size_t n) /* 템플릿 메서드 */
{
int failed = 0;
for (size_t i = 0; i < n; i++) {
void *fx = tc[i].setup ? tc[i].setup() : NULL;
int rc = tc[i].run(fx);
if (tc[i].teardown)
tc[i].teardown(fx); /* 실패해도 정리는 항상 */
printf("[%s] %s\n", rc == 0 ? " OK " : "FAIL", tc[i].name);
failed += rc != 0;
}
printf("%zu tests, %d failed\n", n, failed);
return failed;
}
static void *buf_setup(void) { char *b = malloc(32); if (b) strcpy(b, "hello"); return b; }
static void buf_teardown(void *fx) { free(fx); }
static int test_len(void *fx) { return strlen(fx) == 5 ? 0 : 1; }
static int test_upper(void *fx) { return ((char *)fx)[0] == 'H' ? 0 : 1; } /* 일부러 실패 */
static int test_math(void *fx) { (void)fx; return 2 + 2 == 4 ? 0 : 1; }
int main(void)
{
const struct test_case tests[] = {
{ "buffer length", buf_setup, test_len, buf_teardown },
{ "buffer upper", buf_setup, test_upper, buf_teardown },
{ "arithmetic", NULL, test_math, NULL },
};
return run_all(tests, sizeof(tests) / sizeof(tests[0])) ? 1 : 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -fsanitize=address -o runner runner.c && ./runner; echo "exit=$?"
[ OK ] buffer length
[FAIL] buffer upper
[ OK ] arithmetic
3 tests, 1 failed
exit=1
두 번째 테스트가 실패해도 정리 단계는 실행됐고, AddressSanitizer가 누수를 보고하지 않았다. “실패해도 정리는 반드시”라는 규칙이 러너 한 곳에 있기 때문에 개별 테스트가 지킬 필요가 없다.
주의사항
- 훅은 적게: 훅이 많을수록 서브클래스가 무엇을 재정의해야 하는지 알기 어렵다. 필수 단계는 순수 가상으로, 정말 선택적인 것만 훅으로 둔다.
- 템플릿 메서드 자체는 재정의되지 않게:
run()을 가상 함수로 두면 뼈대를 통째로 바꿀 수 있어 패턴이 무너진다. 비가상으로 두거나final을 붙인다. - 생성자·소멸자에서 부르지 않는다: 템플릿 메서드가 부르는 단계는 가상 함수라, 부모 생성자 안에서는 서브클래스 구현이 불리지 않는다(팩토리 메서드 편의 실측 참고).
- 상속 계층이 깊어지면: 손자 클래스가 부모의 훅 재정의를 다시 재정의하면 어떤 구현이 불리는지 추적하기 어렵다. 단계가 독립적으로 바뀌어야 한다면 상속 대신 전략(합성)으로 넘긴다.
- NVI와 테스트: private 가상 함수도 테스트용 서브클래스에서 재정의할 수 있으므로, 사전·사후 처리를 포함한 공개 함수로 테스트한다.
마무리
템플릿 메서드는 “할리우드 원칙(Don’t call us, we’ll call you)”을 구조로 만든 패턴이다. 부모가 흐름을 쥐고 자식은 불릴 때 자기 몫만 한다. C++에서는 NVI로 그 흐름을 강제할 수 있다. 다음 글에서는 객체 구조를 바꾸지 않고 새 연산을 추가하는 방문자(Visitor)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Template Method
- Herb Sutter — Virtuality (NVI)