C++ 예제로 배우는 소프트웨어 설계 기법 (6) — 입력 검증으로 보는 템플릿 메서드와 전략(LineEdit)

입력창(LineEdit)은 키 입력을 받아 문자열을 만드는 흐름은 늘 같다. 반면 숫자만 받을지, 우편번호 5자리를 받을지 같은 검증 규칙은 쓰는 곳마다 다르다. 이렇게 변하지 않는 코드와 변하는 코드를 어떻게 나누느냐가 설계의 핵심이다. 변하는 부분을 분리하는 대표적인 두 방법이 가상 함수로 빼는 템플릿 메서드(Template Method)와 별도 객체로 빼는 전략(Strategy) 패턴이다. 이 글에서는 입력 검증을 예로 두 방법을 비교하고, std::function으로 검증 정책을 조합하는 방법까지 g++ 13.3으로 확인한다. 키보드 입력은 std::istringstream으로 대신했다.

이 시리즈의 다른 글

검증 규칙이 코드에 박힌 LineEdit

#include <cctype>
#include <cstdio>
#include <sstream>
#include <string>

class LineEdit {
    std::string data_;
public:
    std::string get_data(std::istream& in)        // 키 입력을 한 글자씩 받는다고 가정
    {
        data_.clear();
        for (char c; in.get(c) && c != '\n'; ) {
            if (std::isdigit((unsigned char)c))   // 숫자만 허용: 정책이 코드에 박혀 있다
                data_ += c;
            else
                std::printf("  reject '%c'\n", c);
        }
        return data_;
    }
};

int main()
{
    std::istringstream keys("12a3-4\n");
    LineEdit e;
    std::printf("  data=%s\n", e.get_data(keys).c_str());
}
$ g++ -std=c++20 -Wall -o hc hardcoded.cpp
$ ./hc
  reject 'a'
  reject '-'
  data=1234

숫자만 받는 정책이 get_data 안에 들어 있어 다른 규칙이 필요하면 클래스를 복사해야 한다.

템플릿 메서드: 가상 함수로 분리

#include <cctype>
#include <cstdio>
#include <sstream>
#include <string>

class LineEdit {
    std::string data_;
protected:
    virtual bool validate(char c) { return std::isprint((unsigned char)c); }   // 변하는 부분
public:
    virtual ~LineEdit() = default;
    std::string get_data(std::istream& in)       // 변하지 않는 흐름
    {
        data_.clear();
        for (char c; in.get(c) && c != '\n'; )
            if (validate(c)) data_ += c; else std::printf("  reject '%c'\n", c);
        return data_;
    }
};

class NumEdit : public LineEdit {
    bool validate(char c) override { return std::isdigit((unsigned char)c); }
};

class UpperEdit : public LineEdit {
    bool validate(char c) override { return std::isupper((unsigned char)c); }
};

int main()
{
    std::istringstream k1("12a3\n"), k2("ABcD\n");
    NumEdit n;
    UpperEdit u;
    std::printf("  num=%s\n", n.get_data(k1).c_str());
    std::printf("  upper=%s\n", u.get_data(k2).c_str());
}
$ g++ -std=c++20 -Wall -o tm template_method.cpp
$ ./tm
  reject 'a'
  num=123
  reject 'c'
  upper=ABD

입력 흐름은 기반 클래스가, 글자 검증은 파생 클래스가 맡는다. 규칙마다 파생 클래스가 생기고, 객체를 만든 뒤에는 규칙을 바꿀 수 없다.

전략 패턴: 검증 객체로 분리

#include <cctype>
#include <cstdio>
#include <sstream>
#include <string>

struct IValidator {                               // 검증 정책을 별도 객체로
    virtual ~IValidator() = default;
    virtual bool validate(const std::string& cur, char c) = 0;   // 글자 단위 검사
    virtual bool is_complete(const std::string&) { return true; } // 입력 종료 시 검사
};

class LineEdit {
    std::string data_;
    IValidator* val_ = nullptr;
public:
    void set_validator(IValidator* v) { val_ = v; }   // 실행 중 교체 가능
    std::string get_data(std::istream& in)
    {
        for (;;) {
            data_.clear();
            for (char c; in.get(c) && c != '\n'; )
                if (!val_ || val_->validate(data_, c)) data_ += c;
                else std::printf("  reject '%c'\n", c);
            if (!val_ || val_->is_complete(data_)) return data_;
            std::printf("  incomplete '%s', retry\n", data_.c_str());
        }
    }
};

class LimitDigitValidator : public IValidator {   // 숫자 n자리까지, 정확히 n자리여야 완료
    std::size_t n_;
public:
    explicit LimitDigitValidator(std::size_t n) : n_(n) {}
    bool validate(const std::string& cur, char c) override { return cur.size() < n_ && std::isdigit((unsigned char)c); }
    bool is_complete(const std::string& s) override { return s.size() == n_; }
};

class HexValidator : public IValidator {
    bool validate(const std::string&, char c) override { return std::isxdigit((unsigned char)c); }
};

int main()
{
    std::istringstream keys("12\n1234567\nff0Az\n");
    LimitDigitValidator zip(5);
    HexValidator hex;
    LineEdit e;

    e.set_validator(&zip);
    std::printf("  zip=%s\n", e.get_data(keys).c_str());
    e.set_validator(&hex);                        // 같은 LineEdit, 다른 정책
    std::printf("  hex=%s\n", e.get_data(keys).c_str());
}
$ g++ -std=c++20 -Wall -o st strategy.cpp
$ ./st
  incomplete '12', retry
  reject '6'
  reject '7'
  zip=12345
  reject 'z'
  hex=ff0A

검증 정책을 IValidator 객체로 빼서 같은 LineEdit에 실행 중 다른 정책을 끼웠다. 글자 단위 검사와 입력 종료 시 검사(is_complete)를 한 객체에 담을 수 있어 "정확히 5자리" 같은 규칙도 표현된다.

다른 컨트롤에서 재사용

#include <cctype>
#include <cstdio>
#include <string>
#include <vector>

struct IValidator {
    virtual ~IValidator() = default;
    virtual bool validate(const std::string& cur, char c) = 0;
};

struct DigitValidator : IValidator {
    bool validate(const std::string&, char c) override { return std::isdigit((unsigned char)c); }
};

// LineEdit와 전혀 다른 컨트롤도 같은 검증 객체를 쓴다
class ComboBox {
    std::vector<std::string> items_;
    IValidator* val_;
public:
    explicit ComboBox(IValidator* v) : val_(v) {}
    void add(const std::string& s)
    {
        std::string cur;
        for (char c : s) if (!val_->validate(cur, c)) { std::printf("  combo reject \"%s\"\n", s.c_str()); return; } else cur += c;
        items_.push_back(s);
        std::printf("  combo add \"%s\" (%zu items)\n", s.c_str(), items_.size());
    }
};

int main()
{
    DigitValidator digits;
    ComboBox port(&digits);
    port.add("8080");
    port.add("http");
    port.add("443");
}
$ g++ -std=c++20 -Wall -o ru reuse.cpp
$ ./ru
  combo add "8080" (1 items)
  combo reject "http"
  combo add "443" (2 items)

검증 정책이 LineEdit에 묶여 있지 않으므로 ComboBox 같은 다른 컨트롤도 그대로 쓴다. 템플릿 메서드 방식에서는 NumEdit::validate를 다른 클래스가 가져다 쓸 수 없다.

std::function으로 정책 조합

#include <cctype>
#include <cstdio>
#include <functional>
#include <sstream>
#include <string>

class LineEdit {
    std::string data_;
    std::function<bool(const std::string&, char)> val_ = [](const std::string&, char) { return true; };
public:
    template <typename F> void set_validator(F f) { val_ = std::move(f); }
    std::string get_data(std::istream& in)
    {
        data_.clear();
        for (char c; in.get(c) && c != '\n'; ) if (val_(data_, c)) data_ += c;
        return data_;
    }
};

// 검증 정책을 조합하는 함수
auto all_of(auto... fs) { return [=](const std::string& s, char c) { return (fs(s, c) && ...); }; }
auto max_len(std::size_t n) { return [=](const std::string& s, char) { return s.size() < n; }; }
auto alpha = [](const std::string&, char c) { return std::isalpha((unsigned char)c) != 0; };

int main()
{
    std::istringstream keys("ab1cdefgh\n");
    LineEdit e;
    e.set_validator(all_of(alpha, max_len(4)));   // 영문자 + 최대 4글자
    std::printf("  data=%s\n", e.get_data(keys).c_str());
}
$ g++ -std=c++20 -Wall -o lm lambda.cpp
$ ./lm
  data=abcd

정책이 상태 없는 함수라면 인터페이스 대신 람다로 충분하다. 작은 정책을 all_of로 조합해 "영문자이면서 최대 4글자"를 만들었다.

기준템플릿 메서드전략 (인터페이스)전략 (std::function)
————
분리 수단가상 함수 재정의정책 객체 포인터콜러블
실행 중 정책 교체불가가능가능
다른 클래스에서 재사용불가가능가능
정책이 호스트의 멤버에 접근쉬움 (protected)인자로 넘겨야 함람다 캡처
여러 검사 함수를 하나로 묶기가상 함수 여러 개인터페이스 하나에 여러 함수함수마다 std::function 필요
클래스 수규칙마다 파생 클래스규칙마다 정책 클래스클래스 없음

주의사항

상황문제대응
———
정책 객체를 raw 포인터로 보관정책이 먼저 소멸하면 dangling수명 보장 또는 unique_ptr/shared_ptr
정책이 설정되지 않음null 포인터 호출기본 정책 또는 null 검사
입력 흐름 자체가 변해야 함검증만 분리해서는 해결 안 됨흐름 단계도 전략으로 분리하거나 별도 클래스
정책 교체가 필요 없고 성능이 중요가상 호출·std::function 비용다음 편의 템플릿 인자 정책
템플릿 메서드에서 규칙 조합다중 상속·클래스 폭발전략으로 전환

마무리

  • 변하지 않는 흐름과 변하는 정책을 분리하는 것이 출발점이다.
  • 템플릿 메서드는 가상 함수로 분리해 간단하지만, 정책을 바꾸거나 다른 클래스에서 재사용할 수 없다.
  • 전략은 정책을 객체로 분리해 실행 중 교체와 재사용이 가능하고, 상태 없는 정책은 람다로 조합할 수 있다.

참고

답글 남기기