[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #19] 템플릿 메서드(Template Method) 패턴: 알고리즘 골격을 Concepts와 람다로 선언적으로 표현하기

이전 글에서는 메멘토(Memento) 패턴을 모던 C++ 관점에서 재해석하며, 상속 없이 값 기반 상태 스냅샷과 std::expected, coroutine, Ranges 등을 활용해 Undo/Redo, 비동기 복원, 로깅 등의 요구사항에 대응할 수 있음을 확인했습니다. 이번에는 행동(Behavioral) 패턴 중 템플릿 메서드(Template Method) 패턴을 다룹니다.

템플릿 메서드 패턴은 상위 클래스에서 알고리즘의 골격(템플릿)을 정의하고, 하위 클래스에서 일부 단계를 오버라이드하여 구체화하는 패턴입니다. 그러나 전통적 접근은 상속 기반 구조로, 알고리즘 변형 시 하위 클래스 증가, 유지보수 어려움 등이 발생합니다. C++20 이상에서는 Concepts, 람다, std::expected, std::format, coroutine 등을 활용해 상속 없이도 알고리즘 골격을 선언적으로 정의하고, 구체 단계를 함수나 람다로 주입할 수 있습니다. 이를 통해 타입 안전성과 확장성을 모두 개선할 수 있습니다.

패턴 소개: 템플릿 메서드(Template Method)

의도:

  • 상위 클래스에서 알고리즘 골격(템플릿)을 정의하고, 일부 단계를 하위 클래스에서 오버라이드하여 알고리즘 변형 가능.
  • 예: 문서 처리 알고리즘(로딩, 파싱, 렌더링), 특정 단계(파싱, 렌더링)만 하위 클래스가 결정.

전통적 구현 문제점:

  • 추상 클래스에 templateMethod() 정의, 알고리즘 단계용 가상 함수 필요.
  • 하위 클래스 오버라이드 증가 → 클래스 수 증가, 유지보수 어려움.
  • 에러 처리나 비동기 처리, 조건부 단계 스킵, 로깅 등 고급 기능 대응 제한.

기존 C++ 스타일 구현 (전통적 방식)

예를 들어, 문서 처리 알고리즘 템플릿:

#include <iostream>

struct DocumentProcessor {
    virtual ~DocumentProcessor() = default;
    void process() {
        load();
        parse();
        render();
    }

    virtual void load() = 0;
    virtual void parse() = 0;
    virtual void render() = 0;
};

struct XMLProcessor : DocumentProcessor {
    void load() override {
        std::cout << "Loading XML\n";
    }
    void parse() override {
        std::cout << "Parsing XML\n";
    }
    void render() override {
        std::cout << "Rendering XML\n";
    }
};

int main() {
    XMLProcessor processor;
    processor.process(); // Loading XML -> Parsing XML -> Rendering XML
}

문제점:

  • 상속 기반, 하위 클래스 증가
  • 알고리즘 변형 시 클래스 추가 필요
  • 에러 처리나 로깅, 비동기 단계 구현 어려움

모던 C++20 이상의 개선: Concepts, 람다로 알고리즘 단계 주입

1. Concepts로 알고리즘 단계 요구사항 정의

알고리즘 골격: load(), parse(), render() 단계 필요하다고 가정하고, 각 단계가 std::expected<void,std::string> 반환. Concepts로 단계 정의:

template<typename L, typename P, typename R>
concept TemplateMethodSteps = requires(L& loadFunc, P& parseFunc, R& renderFunc) {
    { loadFunc() } -> std::convertible_to<std::expected<void,std::string>>;
    { parseFunc() } -> std::convertible_to<std::expected<void,std::string>>;
    { renderFunc() } -> std::convertible_to<std::expected<void,std::string>>;
};

2. 템플릿 메서드 골격을 함수 합성으로 구현

알고리즘 골격(템플릿 메서드): load → parse → render 순서 실행.

auto templateMethod = [](TemplateMethodSteps auto loadFunc, auto parseFunc, auto renderFunc) {
    return [=]() -> std::expected<void,std::string> {
        // load
        auto lr = loadFunc();
        if(!lr) return std::unexpected(lr.error());
        // parse
        auto pr = parseFunc();
        if(!pr) return std::unexpected(pr.error());
        // render
        auto rr = renderFunc();
        if(!rr) return std::unexpected(rr.error());
        return {};
    };
};

비교:

  • 전통적: 상위 클래스에서 templateMethod(), 가상 함수 단계 호출
  • C++11/14/17: 람다 가능하지만 Concepts로 단계 제약 불가, 에러 처리나 비동기 대응 제한
  • C++20: Concepts로 단계 요구사항 정의, 람다 합성으로 상속 없이 알고리즘 골격 구현

3. 구체 단계를 람다로 정의

auto loadXML = []() -> std::expected<void,std::string> {
    std::cout << "Loading XML\n";
    return {};
};

auto parseXML = []() -> std::expected<void,std::string> {
    std::cout << "Parsing XML\n";
    return {};
};

auto renderXML = []() -> std::expected<void,std::string> {
    std::cout << "Rendering XML\n";
    return {};
};

static_assert(TemplateMethodSteps<decltype(loadXML),decltype(parseXML),decltype(renderXML)>);

사용 예

int main() {
    auto xmlProcess = templateMethod(loadXML, parseXML, renderXML);
    auto res = xmlProcess();
    if(!res) std::cerr << "Error: " << res.error() << "\n";
    else std::cout << "Processing succeeded.\n";
}

출력:

Loading XML
Parsing XML
Rendering XML
Processing succeeded.

비교:

  • 전통적: XML 처리 위해 XMLProcessor 하위 클래스 필요
  • C++20: 람다 세 개와 templateMethod로 구성, 상속 없이 구현

Undo/Redo, 조건부 단계 스킵, 파이프라인 적용

특정 단계 건너뛰거나 추가 로직 넣을 때 람다 장식:

auto conditionalParse = [&](auto baseParse) {
    return [=]() -> std::expected<void,std::string> {
        // 특정 조건에서 parse 생략 가능
        bool skipParse = false;
        if (skipParse) return {}; // parse 건너뛰기
        return baseParse();
    };
};

auto xmlProcessWithSkip = templateMethod(loadXML, conditionalParse(parseXML), renderXML);

비교:

  • 전통적: 특정 단계 조건부 생략 시 하위 클래스 추가 필요
  • C++20: 함수 합성으로 조건부 단계 쉽게 추가

비동기 단계: coroutine

각 단계가 coroutine일 수도 있음. 예: load 단계에서 네트워크 co_await, parse 단계에서 비동기 I/O:

// 가상의 예: co_load()에서 co_await 비동기 I/O, 완성 후 std::expected 반환

비교:

  • 전통적: 비동기 처리 어렵고 콜백/쓰레드 관리 필요
  • C++20: coroutine으로 자연스럽게 비동기 단계 구현, Concepts로 비동기 가능성 제약

로깅 추가: std::format

auto loggingStage = [&](auto stageFunc, std::string stageName) {
    return [=]() -> std::expected<void,std::string> {
        std::cout << std::format("[LOG] {} stage start\n", stageName);
        auto res = stageFunc();
        if(!res) std::cout << std::format("[LOG] {} stage error: {}\n", stageName, res.error());
        else std::cout << std::format("[LOG] {} stage success\n", stageName);
        return res;
    };
};

auto xmlProcessLogged = templateMethod(
    loggingStage(loadXML, "Load"),
    loggingStage(parseXML, "Parse"),
    loggingStage(renderXML, "Render")
);

비교:

  • 전통적: 로깅 추가 시 템플릿 메서드 상속 + Decorator 필요
  • C++20: 람다 장식으로 로깅 추가, 상속 없음

Ranges로 다양한 알고리즘 단계 조합

여러 단계 함수를 벡터나 컨테이너에 담아 Ranges로 처리 가능:

std::vector<std::function<std::expected<void,std::string>()>> steps = {
    loadXML, parseXML, renderXML
};

auto pipeline = [&]() -> std::expected<void,std::string> {
    for (auto& step : steps) {
        auto r = step();
        if(!r) return r;
    }
    return {};
};

// pipeline 호출로 templateMethod 유사 효과

비교:

  • 전통적: templateMethod 고정된 알고리즘 구조, 변경 시 상속 필요
  • C++20: steps 벡터와 Ranges로 동적 알고리즘 구성 가능

전통적 구현 vs C++11/14/17 vs C++20 이상 비교

전통적 (C++98/03):

  • 상위 클래스에 templateMethod(), 하위 클래스에서 단계 오버라이드
  • 클래스 증가, 알고리즘 변형 시 하위 클래스 추가 필요
  • 조건부 단계, 비동기 처리, 로깅, Undo/Redo 등 고급 요구사항 구현 번거로움

C++11/14/17:

  • 람다, std::function 일부 활용 가능
  • Concepts, coroutine, std::expected, Ranges 부재로 타입 안전한 인터페이스 제약, 비동기/에러 처리 선언적 표현 한계

C++20 이상(모던 C++):

  • Concepts로 단계 요구사항 정의, 상속 없이 알고리즘 골격 표현
  • 람다/함수 합성으로 단계 주입, std::expected로 에러 처리 명확, std::format으로 로깅 용이
  • coroutine으로 비동기 단계, Ranges로 동적 파이프라인 구성, Undo/Redo 시 상태 관리 가능
  • 유지보수성, 확장성, 타입 안전성, 선언적 표현력 모두 향상
  • 전통적 구현 대비 훨씬 유연하고 강력한 template method 구현 가능

결론

템플릿 메서드(Template Method) 패턴은 상위 클래스에서 알고리즘 골격을 정의하고 하위 클래스가 일부 단계를 오버라이드하는 패턴이었으나, 모던 C++20 이상에서는 상속 없이 람다, Concepts, std::expected, std::format, coroutine, Ranges 등을 활용해 알고리즘 골격을 선언적이고 타입 안전하게 표현할 수 있습니다. 이를 통해 전통적 구현 대비 훨씬 적은 코드와 더 나은 유지보수성으로 Undo/Redo, 비동기 처리, 로깅, 조건부 단계 실행 등 다양한 요구사항을 처리할 수 있습니다.

반응형