입력창(LineEdit)은 키 입력을 받아 문자열을 만드는 흐름은 늘 같다. 반면 숫자만 받을지, 우편번호 5자리를 받을지 같은 검증 규칙은 쓰는 곳마다 다르다. 이렇게 변하지 않는 코드와 변하는 코드를 어떻게 나누느냐가 설계의 핵심이다. 변하는 부분을 분리하는 대표적인 두 방법이 가상 함수로 빼는 템플릿 메서드(Template Method)와 별도 객체로 빼는 전략(Strategy) 패턴이다. 이 글에서는 입력 검증을 예로 두 방법을 비교하고, std::function으로 검증 정책을 조합하는 방법까지 g++ 13.3으로 확인한다. 키보드 입력은 std::istringstream으로 대신했다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (4) — 싱글톤(Singleton)
- C++ 예제로 배우는 소프트웨어 설계 기법 (5) — 도형 편집기(Shape)
- C++ 예제로 배우는 소프트웨어 설계 기법 (7) — 템플릿 인자로 전달하는 정책(Template Strategy)
- C++ 예제로 배우는 소프트웨어 설계 기법 (8) — 메뉴 시스템과 컴포지트(Menu System)
검증 규칙이 코드에 박힌 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 비용 | 다음 편의 템플릿 인자 정책 |
| 템플릿 메서드에서 규칙 조합 | 다중 상속·클래스 폭발 | 전략으로 전환 |
마무리
- 변하지 않는 흐름과 변하는 정책을 분리하는 것이 출발점이다.
- 템플릿 메서드는 가상 함수로 분리해 간단하지만, 정책을 바꾸거나 다른 클래스에서 재사용할 수 없다.
- 전략은 정책을 객체로 분리해 실행 중 교체와 재사용이 가능하고, 상태 없는 정책은 람다로 조합할 수 있다.