문단을 화면 폭에 맞춰 줄을 바꾸는 방법은 여러 가지다. 빠르고 단순한 탐욕 방식이 있고, 문단 전체를 보고 줄 끝 여백을 고르게 만드는 방식도 있다. 이 알고리즘을 문단 클래스 안에 if로 넣어 두면 새 알고리즘을 추가할 때마다 문단 클래스를 고쳐야 한다. 전략(Strategy)은 알고리즘군을 각각 캡슐화해서 서로 교체할 수 있게 만들고, 알고리즘을 쓰는 쪽(Context)과 독립적으로 바꿀 수 있게 한다. 이 글에서는 GoF가 든 줄 바꿈 예제로 전략을 구현하고, 전략을 넘기는 세 방식(가상 함수, std::function, 템플릿)의 호출 비용을 실측한 뒤, C의 해시 함수 전략을 비교한다.
전략과 템플릿 메서드의 차이는 설계 기법 (6) 입력 검증에서, 템플릿 인자로 전략을 넘기는 정책 기반 설계는 설계 기법 (7)에서 다뤘다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (18) — 옵저버(Observer)
- C/C++ 디자인 패턴 (19) — 상태(State)
- C/C++ 디자인 패턴 (21) — 템플릿 메서드(Template Method)
- C/C++ 디자인 패턴 (22) — 방문자(Visitor)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Strategy | 알고리즘 공통 인터페이스 | LineBreaker, hash_fn |
| ConcreteStrategy | 알고리즘 구현 | Greedy, Balanced, hash_fnv1a |
| Context | 전략을 보관하고 필요할 때 호출 | Paragraph, struct table |
줄 바꿈 전략: 탐욕 vs 균형
탐욕 방식은 한 줄에 들어갈 만큼 단어를 채운다. 균형 방식은 동적 계획법으로 마지막 줄을 뺀 줄 끝 여백의 제곱합(raggedness)이 최소가 되는 분할을 찾는다.
// linebreak.cpp — 같은 문단, 다른 줄 바꿈 알고리즘(전략): 탐욕 vs 균형(DP)
#include <climits>
#include <iostream>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
using Words = std::vector<std::string>;
using Lines = std::vector<std::string>;
struct LineBreaker { // Strategy
virtual ~LineBreaker() = default;
virtual Lines wrap(const Words &w, std::size_t width) const = 0;
virtual const char *name() const = 0;
};
struct Greedy : LineBreaker { // 들어갈 수 있는 만큼 채운다
const char *name() const override { return "greedy"; }
Lines wrap(const Words &w, std::size_t width) const override
{
Lines out{""};
for (const auto &x : w) {
if (!out.back().empty() && out.back().size() + 1 + x.size() > width)
out.push_back("");
out.back() += (out.back().empty() ? "" : " ") + x;
}
return out;
}
};
struct Balanced : LineBreaker { // 줄 끝 여백 제곱합 최소(마지막 줄 제외)
const char *name() const override { return "balanced"; }
Lines wrap(const Words &w, std::size_t width) const override
{
std::size_t n = w.size();
std::vector<long> cost(n + 1, LONG_MAX);
std::vector<std::size_t> brk(n + 1);
cost[n] = 0;
for (std::size_t i = n; i-- > 0;) {
std::size_t len = 0;
for (std::size_t j = i; j < n; ++j) {
len += w[j].size() + (j > i);
if (len > width) break;
long slack = long(width - len);
long c = (j + 1 == n ? 0 : slack * slack) + cost[j + 1];
if (c < cost[i]) { cost[i] = c; brk[i] = j + 1; }
}
}
Lines out;
for (std::size_t i = 0; i < n; i = brk[i]) {
std::string line;
for (std::size_t j = i; j < brk[i]; ++j)
line += (j > i ? " " : "") + w[j];
out.push_back(line);
}
return out;
}
};
class Paragraph { // Context
public:
Paragraph(const std::string &text, const LineBreaker &b) : breaker_(&b)
{ std::istringstream in(text); for (std::string x; in >> x;) words_.push_back(x); }
void setBreaker(const LineBreaker &b) { breaker_ = &b; }
void render(std::size_t width) const
{
long raggedness = 0;
auto lines = breaker_->wrap(words_, width);
std::cout << breaker_->name() << ":\n";
for (std::size_t i = 0; i < lines.size(); ++i) {
std::cout << " |" << lines[i] << std::string(width - lines[i].size(), ' ') << "|\n";
if (i + 1 < lines.size()) raggedness += long(width - lines[i].size()) * long(width - lines[i].size());
}
std::cout << " raggedness=" << raggedness << '\n';
}
private:
const LineBreaker *breaker_;
Words words_;
};
int main()
{
Greedy greedy;
Balanced balanced;
Paragraph p("aaa bb cc ddddd", greedy);
p.render(6);
p.setBreaker(balanced); // 실행 중 전략 교체
p.render(6);
Paragraph q("Strategy lets the algorithm vary independently from clients that use it", greedy);
q.render(16);
q.setBreaker(balanced);
q.render(16);
}$ g++ -std=c++20 -Wall -Wextra -O2 -o linebreak linebreak.cpp && ./linebreak
greedy:
|aaa bb|
|cc |
|ddddd |
raggedness=16
balanced:
|aaa |
|bb cc |
|ddddd |
raggedness=10
greedy:
|Strategy lets |
|the algorithm |
|vary |
|independently |
|from clients |
|that use it |
raggedness=187
balanced:
|Strategy |
|lets the |
|algorithm vary |
|independently |
|from clients |
|that use it |
raggedness=157
같은 Paragraph 객체에서 전략만 바꿨는데 줄 나눔이 달라졌다. 균형 방식은 첫 줄을 덜 채우는 대신 전체 여백을 고르게 만들어 raggedness를 16 → 10, 187 → 157로 줄였다.
전략을 넘기는 세 방식의 비용
C++에서는 전략을 가상 함수 객체, std::function, 템플릿 인자 중 하나로 넘긴다. 같은 계산을 2억 번 호출하는 루프로 비교했다. noipa는 컴파일러가 함수 사이의 분석으로 측정 대상 호출을 합치지 못하게 한다.
// bench.cpp — 전략을 넘기는 세 방식의 호출 비용: 가상 함수, std::function, 템플릿
// 메모리 대역폭에 가려지지 않도록 루프 인덱스만 넘기는 계산 위주 루프로 잰다
#include <chrono>
#include <cstdint>
#include <functional>
#include <iostream>
constexpr std::uint64_t N = 200'000'000;
struct Op { virtual ~Op() = default; virtual std::uint64_t apply(std::uint64_t x) const = 0; };
struct Mix : Op { std::uint64_t apply(std::uint64_t x) const override { return x * 0x9E3779B97F4A7C15ull + 1; } };
// noipa: 컴파일러가 함수 간 분석으로 호출을 합치거나 상수로 접지 못하게 한다
__attribute__((noipa)) std::uint64_t run_virtual(const Op &op)
{ std::uint64_t s = 0; for (std::uint64_t i = 0; i < N; ++i) s += op.apply(i); return s; }
__attribute__((noipa)) std::uint64_t run_function(const std::function<std::uint64_t(std::uint64_t)> &f)
{ std::uint64_t s = 0; for (std::uint64_t i = 0; i < N; ++i) s += f(i); return s; }
template <class F>
__attribute__((noipa)) std::uint64_t run_template(F f)
{ std::uint64_t s = 0; for (std::uint64_t i = 0; i < N; ++i) s += f(i); return s; }
template <class F>
void measure(const char *name, F body)
{
auto t0 = std::chrono::steady_clock::now();
std::uint64_t r = body();
auto ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
std::cout << name << ms << " ms (" << ms * 1e6 / N << " ns/call, checksum " << (r & 0xffff) << ")\n";
}
int main()
{
Mix mix;
const Op &op = mix;
auto lam = [](std::uint64_t x) { return x * 0x9E3779B97F4A7C15ull + 1; };
std::function<std::uint64_t(std::uint64_t)> fn = lam;
measure("virtual : ", [&] { return run_virtual(op); });
measure("std::function : ", [&] { return run_function(fn); });
measure("template : ", [&] { return run_template(lam); });
}$ g++ -std=c++20 -Wall -Wextra -O2 -o bench bench.cpp && ./bench
virtual : 58.8001 ms (0.294 ns/call, checksum 19712)
std::function : 256.041 ms (1.2802 ns/call, checksum 19712)
template : 2e-05 ms (1e-07 ns/call, checksum 19712)
템플릿 버전은 시간이 0에 가깝다. 람다가 인라인되자 컴파일러가 루프 전체를 계산해 상수 하나로 바꿨다.
$ objdump -d --no-show-raw-insn -C bench | awk 'index($0,"<unsigned long run_template")&&/:$/{p=1} p&&/^$/{exit} p' | cut -c1-60
00000000000014e0 <unsigned long run_template<main::{lambda(u
14e0: movabs $0x168bca07b96e4d00,%rax
14ea: ret
14eb: nopl 0x0(%rax,%rax,1)
가상 함수 버전도 간접 호출치고는 빠른데, GCC가 “아마 Mix::apply일 것”이라고 추측해 vtable 슬롯을 비교한 뒤 맞으면 인라인 경로로 가도록 만들었기 때문이다(speculative devirtualization).
$ L=$(objdump -d --no-show-raw-insn -C bench | grep -n 'run_virtual(Op const&)>:' | cut -d: -f1); objdump -d --no-show-raw-insn -C bench | sed -n "$((L+3))p;$((L+22)),$((L+30))p" | cut -c1-70
1546: lea 0x103(%rip),%r15 # 1650 <Mix::apply(unsigned l
158b: mov 0x0(%r13),%rax
158f: mov 0x10(%rax),%rdx
1593: mov %rbp,%rax
1596: cmp %r15,%rdx
1599: je 1578 <run_virtual(Op const&)+0x38>
159b: mov %rbx,%rsi
159e: mov %r13,%rdi
15a1: call *%rdx
15a3: jmp 1578 <run_virtual(Op const&)+0x38>
cmp %r15,%rdx에서 vtable의 함수 주소가 Mix::apply와 같으면 호출 없이 계산만 하고, 다를 때만 call *%rdx를 한다. 이 최적화를 끄면 진짜 간접 호출 비용이 드러난다.
$ g++ -std=c++20 -Wall -Wextra -O2 -fno-devirtualize-speculatively -o bench_nospec bench.cpp && ./bench_nospec | head -1
virtual : 212.803 ms (1.06402 ns/call, checksum 19712)
| 방식 | 호출당(이 측정) | 실행 중 교체 | 이유 |
|---|---|---|---|
| 템플릿 | ~0 (루프 접힘) | 불가 | 인라인 → 전체 최적화 |
| 가상 함수(추측 성공) | 약 0.29ns | 가능 | vtable 비교 후 인라인 경로 |
| 가상 함수(추측 끔) | 약 1.06ns | 가능 | 매번 간접 호출 |
std::function | 약 1.27ns | 가능 | 타입 소거 + 간접 호출 |
측정 환경은 Ryzen 5 7600, g++ 13.3, -O2다. 절대값보다 순서와 “인라인이 되는가”가 중요하다. 전략 하나가 하는 일이 크다면 1ns 남짓의 차이는 의미가 없다.
C 구현: 해시 함수 전략
C에서는 함수 포인터가 그대로 전략이다. 해시 테이블에 해시 함수를 꽂아 두고 같은 키 4096개를 버킷 1024개에 넣어 분포를 비교했다.
/* hash_strategy.c — C 전략: 해시 테이블에 해시 함수를 함수 포인터로 꽂는다 */
#include <stdio.h>
#include <string.h>
#define NBUCKET 1024
#define NKEY 4096
typedef unsigned (*hash_fn)(const char *s);
static unsigned hash_sum(const char *s) /* 문자 코드 합: 흔한 나쁜 예 */
{
unsigned h = 0;
while (*s)
h += (unsigned char)*s++;
return h;
}
static unsigned hash_djb2(const char *s)
{
unsigned h = 5381;
while (*s)
h = h * 33 + (unsigned char)*s++;
return h;
}
static unsigned hash_fnv1a(const char *s)
{
unsigned h = 2166136261u;
while (*s)
h = (h ^ (unsigned char)*s++) * 16777619u;
return h;
}
struct table { /* Context */
hash_fn hash; /* Strategy */
int count[NBUCKET];
};
static void table_stats(struct table *t, const char *name)
{
char key[32];
int used = 0, longest = 0;
memset(t->count, 0, sizeof(t->count));
for (int i = 0; i < NKEY; i++) {
snprintf(key, sizeof(key), "user%04d", i);
t->count[t->hash(key) % NBUCKET]++;
}
for (int b = 0; b < NBUCKET; b++) {
used += t->count[b] > 0;
if (t->count[b] > longest)
longest = t->count[b];
}
printf("%-6s used buckets=%4d/%d longest chain=%4d\n", name, used, NBUCKET, longest);
}
int main(void)
{
struct { const char *name; hash_fn fn; } strategies[] = {
{ "sum", hash_sum }, { "djb2", hash_djb2 }, { "fnv1a", hash_fnv1a },
};
struct table t;
for (size_t i = 0; i < sizeof(strategies) / sizeof(strategies[0]); i++) {
t.hash = strategies[i].fn; /* 실행 중 전략 교체 */
table_stats(&t, strategies[i].name);
}
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -o hash_strategy hash_strategy.c && ./hash_strategy
sum used buckets= 31/1024 longest chain= 304
djb2 used buckets= 518/1024 longest chain= 21
fnv1a used buckets=1004/1024 longest chain= 9
문자 코드 합은 user0001과 user0010처럼 같은 문자의 다른 배열을 구분하지 못해 버킷 31개에 몰렸고, 최장 체인이 304라 조회가 사실상 선형 탐색이 된다. 테이블 코드는 그대로 두고 전략만 FNV-1a로 바꾸자 최장 체인이 9로 줄었다.
주의사항
- 전략이 필요한 데이터: 전략마다 필요한 입력이 다르면 인터페이스가 가장 많은 데이터를 요구하는 쪽에 맞춰진다. Context 참조를 넘겨 전략이 필요한 것만 꺼내게 하거나, 입력을 구조체로 묶는다.
- 클라이언트가 전략을 알아야 한다: 어떤 전략을 고를지는 클라이언트 몫이라, 전략 간 차이(속도, 품질)를 알아야 한다. 기본 전략을 두고 필요할 때만 바꾸게 한다.
- 벤치마크를 믿기 전에 어셈블리를 본다:
noinline만 붙이면 GCC가 순수 함수로 판단한 반복 호출을 하나로 합칠 수 있고, 큰 배열을 순회하는 루프는 메모리 대역폭이 병목이 되어 호출 방식의 차이가 사라진다. 최적화가 무엇을 지웠는지 확인한다. - 상태를 가진 전략: 전략 객체가 내부 상태(캐시 등)를 가지면 여러 Context가 공유할 때 경쟁 조건이 생긴다. 상태 없는 전략은 정적 인스턴스 하나로 공유해도 된다.
- 해시 함수는 입력 분포에 맞게: FNV-1a도 공격자가 키를 고를 수 있는 환경(해시 플러딩)에서는 안전하지 않다. 외부 입력을 키로 쓴다면 SipHash처럼 키를 가진 해시를 고려한다.
마무리
전략은 “무엇을 할지”는 그대로 두고 “어떻게 할지”를 바꿔 끼우게 한다. C++에서는 교체 시점(컴파일 시점이냐 실행 시점이냐)에 따라 템플릿과 가상 함수·std::function 중에서 고르고, C에서는 함수 포인터 하나로 같은 효과를 낸다. 다음 글에서는 알고리즘의 뼈대를 부모가 정하고 단계만 자식이 채우는 템플릿 메서드(Template Method)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Strategy
- GCC — Optimize Options (-fdevirtualize-speculatively)
- FNV Hash