PHpullh

C++ · 심층 가이드

C++ 패턴 완전 정리

전략과 옵저버, 데코레이터를 포함한 20개 설계 패턴 예제를 스마트 포인터 소유권과 정적 다형성이라는 C++ 고유의 관점에서 처음부터 다시 씁니다.

주제 20개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

고전 디자인 패턴 책의 예제를 C++로 그대로 옮기면 대개 과하게 무거워집니다. 인터페이스와 가상 호출로만 풀던 문제 중 상당수를 이 언어는 템플릿과 std::function으로 더 얇게 해결하기 때문입니다. 전략을 클래스 계층으로 만들 것인지 호출 가능 객체 하나로 받을 것인지, 다형성을 런타임에 둘 것인지 컴파일 타임에 둘 것인지가 매번 선택지로 남습니다. 여기에 C++만의 축이 하나 더 붙는데, 바로 각 참여 객체를 누가 소유하느냐입니다.

전략 패턴과 커맨드는 결국 호출 가능 객체를 저장하는 같은 뼈대라 나란히 보면 차이가 선명하고, 데코레이터와 미들웨어 체인도 감싸는 방향만 다른 한 쌍입니다. 캐시 래퍼와 retry 유틸은 데코레이터의 실용적인 사례이므로 그 뒤에 이어 두는 편이 자연스럽습니다. 저장소 패턴과 의존성 주입 컨테이너, 파사드는 앞의 객체지향 편에서 만든 인터페이스 설계를 그대로 확장한 것이니 함께 읽으면 반복이 줄어듭니다.

옵저버는 C++에서 수명 관리로 무너지는 대표적인 패턴입니다. 구독자를 원시 포인터로 담아 둔 채 그 객체가 먼저 소멸하면, 다음 알림에서 이미 없는 대상을 호출하게 됩니다. 구독 해지를 잊지 않는 규율에 기대는 대신 std::weak_ptr로 보관하고 알림 시점에 lock()으로 살아 있는지 확인하는 편이 현실적입니다. 반대로 부모와 자식이 서로 std::shared_ptr을 쥐면 참조 횟수가 0이 되지 않아 조용히 새므로, 역방향 링크는 weak_ptr로 끊어 두어야 합니다.

01설정 객체

동일한 시그니처를 가진 percent와 flat 중 하나를 골라 strategy에 담고 100을 넘겨 90을 얻습니다. 할인 규칙처럼 값 몇 개로 동작이 갈릴 때 이렇게 함수를 값으로 다룹니다. auto가 추론한 타입은 int(*)(int)이므로 나중에 캡처가 있는 람다를 대입하려 하면 그 자리에서 막힙니다.

C++ code

// 설정 객체
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

함수 포인터 타입에 using으로 이름을 붙여 두면 선언이 짧아지고, 그 동작을 저장하는 자리가 코드에 분명히 드러납니다.

자주 하는 실수

타입이 함수 포인터로 굳으면 나중에 캡처가 있는 람다를 넣을 수 없어 std::function으로 바꿔야 하고, 그 시점에 호출 비용도 함께 바뀝니다.

02전략 패턴

std::map에 키 2가 없을 때만 문자열을 넣고 곧바로 꺼내 출력하는, 조회 후 채우기 흐름입니다. 계산이 비싼 결과를 키로 갈무리해 둘 때 이 골격을 씁니다. count로 확인하고 operator[]로 다시 접근하면 트리를 두 번 타므로, find나 try_emplace 한 번이면 충분합니다.

C++ code

// 전략 패턴
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(2)) cache[2] = "user";
    std::cout << cache[2] << "\n";
}
알아두면 좋은 점

try_emplace는 키가 있으면 그대로 두고 없을 때만 넣으며, 삽입 여부와 위치를 한 번에 돌려주어 조회를 두 번 하지 않아도 됩니다.

자주 하는 실수

확인과 삽입 사이에 다른 스레드가 끼어들 수 있으므로, 공유 캐시라면 이 두 단계를 하나의 잠금으로 묶어야 합니다.

03옵저버

두 함수 중 하나를 변수에 담아 호출하는 이 구조는 알림 대상 목록도, 통지 루프도 갖고 있지 않습니다. 콜백을 여러 개 모아 두면 그때부터 관찰자 형태가 됩니다. 캡처 없는 람다는 함수 포인터로 암시적 변환되므로, 핸들러 목록을 std::vector<int(*)(int)>로 만들어도 등록이 가능합니다.

C++ code

// 옵저버
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

관찰자 목록을 벡터로 둘 때는 통지 도중에 등록이나 해제가 일어날 수 있으므로, 목록의 사본을 만들어 돌리는 편이 안전합니다.

자주 하는 실수

관찰자가 자신을 해제하지 않고 사라지면 목록에 죽은 대상이 남아, 다음 통지에서 이미 없어진 객체를 호출하게 됩니다.

04데코레이터

여기서 실제로 벌어지는 일은 맵에 값이 없으면 채우고 있으면 그대로 쓰는 것으로, 원래 동작을 감싸는 층이 하나 생긴 셈입니다. 로깅이나 캐싱처럼 본체를 건드리지 않고 덧붙일 때 이 모양이 나옵니다. operator[]는 키가 없으면 기본 생성한 값을 조용히 삽입해 버리므로, 조회만 할 의도라면 위험한 선택입니다.

C++ code

// 데코레이터
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(4)) cache[4] = "user";
    std::cout << cache[4] << "\n";
}
알아두면 좋은 점

본체를 감싸는 층은 같은 인터페이스를 유지해야, 감싼 것을 다시 감싸 여러 겹으로 쌓아도 호출부가 그대로 남습니다.

자주 하는 실수

첨자 연산은 조회만 하려는 자리에서도 기본값을 만들어 넣으므로, 캐시 크기가 조회 횟수만큼 늘어나는 원인이 됩니다.

05어댑터

percent와 flat은 인자와 반환 타입이 같아 서로 자리를 바꿔 끼울 수 있고, 코드는 앞쪽을 골라 실행합니다. 서로 다른 구현을 같은 형태로 맞출 때 이 시그니처 통일이 출발점입니다. 반환에 쓰인 static_cast<int>는 반올림이 아니라 0 방향 절삭이라, 부동소수 오차로 0.9 배가 89.999...가 되면 결과가 하나 줄어듭니다.

C++ code

// 어댑터
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

정확한 반올림이 필요하면 std::lround를 쓰거나 정수 연산으로 계산해 부동소수를 아예 거치지 않는 방법도 있습니다.

자주 하는 실수

어댑터가 시그니처만 맞추고 예외나 오류 처리 방식이 다르면, 구현을 바꿔 끼운 뒤에 실패가 예전과 다른 형태로 새어 나옵니다.

06커맨드

요청을 객체로 감싸는 대신, 키 존재 여부를 보고 삽입 여부를 결정하는 조건 한 줄이 실행 단위 전부입니다. 실행을 미루거나 기록해 두어야 할 때 이 자리를 함수 객체로 바꾸게 됩니다. std::map은 삽입해도 기존 원소의 참조와 반복자가 그대로 유효해서, 저장해 둔 위치를 계속 들고 있어도 됩니다.

C++ code

// 커맨드
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(6)) cache[6] = "user";
    std::cout << cache[6] << "\n";
}
알아두면 좋은 점

실행을 미루려면 인자까지 함께 캡처한 함수 객체를 큐에 담고, 취소를 지원하려면 되돌리는 동작도 같은 객체에 넣어 둡니다.

자주 하는 실수

명령 객체가 대상을 포인터로만 들고 있으면 실행 시점에 그 대상이 이미 사라져 있을 수 있으므로, 수명 관계를 먼저 정해야 합니다.

07상태 패턴

상태 전이표 대신, 두 동작 중 어느 것을 가리키느냐가 곧 현재 동작을 정하는 단순한 구조입니다. 상태마다 처리 방식이 다를 때 포인터를 갈아 끼우는 식으로 확장할 수 있습니다. 함수 포인터는 널일 수 있으므로 상태 전이 후 호출 전에 유효성을 확인하지 않으면 정의되지 않은 동작으로 이어집니다.

C++ code

// 상태 패턴
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

상태를 enum class로 두고 전이표를 배열로 만들면, 허용되지 않은 전이를 한곳에서 검사할 수 있습니다.

자주 하는 실수

상태 전이 도중에 이벤트를 다시 발생시키면 아직 전이가 끝나지 않은 상태로 재진입해, 두 전이가 서로를 덮어쓸 수 있습니다.

08팩터리 메서드

새 객체를 만들어 주는 함수는 없고, 맵에 키가 비어 있을 때 문자열 하나를 만들어 넣는 것이 생성에 해당합니다. 만드는 규칙이 여러 갈래로 나뉠 때 그 부분을 함수로 떼어냅니다. try_emplace를 쓰면 키가 이미 있을 때 인자를 아예 구성하지 않아, 생성 비용이 큰 값에서 차이가 납니다.

C++ code

// 팩터리 메서드
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(8)) cache[8] = "user";
    std::cout << cache[8] << "\n";
}
알아두면 좋은 점

만드는 규칙을 키와 생성 함수의 맵으로 두면, 새 종류를 추가할 때 분기문을 고치지 않고 등록만 하면 됩니다.

자주 하는 실수

첨자 연산에 대입하는 방식은 값을 기본 생성한 뒤 다시 대입하므로, 생성이 무거운 값에서는 두 단계 비용이 그대로 붙습니다.

09빌더 패턴

단계마다 값을 쌓아 올리는 대신 함수 하나를 골라 즉시 호출해 결과를 냅니다. 조립 단계가 없을 만큼 입력이 단순하다면 이 정도로 충분합니다. 조립 단계를 도입하더라도 함수 포인터를 템플릿 인자로 넘기면 컴파일러가 호출 대상을 알아 인라인할 수 있고, std::function은 타입 소거 때문에 그 여지가 사라집니다.

C++ code

// 빌더 패턴
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

호출 대상을 템플릿 인자로 받으면 컴파일러가 본문을 인라인할 수 있지만, 서로 다른 구현마다 별도의 코드가 생성됩니다.

자주 하는 실수

빌더 단계마다 임시 객체를 값으로 돌려주면 이동이 반복되므로, 큰 객체에서는 자기 참조를 돌려주는 편이 낫습니다.

10캐시 래퍼

cache.count(10)으로 적중 여부를 판단하고, 빠져 있으면 값을 채운 다음 출력합니다. 계산 결과를 다시 쓰고 싶을 때 가장 얇게 만들 수 있는 감싸기 형태입니다. 이 코드에는 제거 정책이 없어 키가 늘어나는 만큼 메모리도 계속 늘어나므로, 실제 서비스에서는 크기 제한이 함께 필요합니다.

C++ code

// 캐시 래퍼
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(10)) cache[10] = "user";
    std::cout << cache[10] << "\n";
}
알아두면 좋은 점

크기 제한을 두려면 사용 순서를 담는 리스트와 키에서 그 위치를 찾는 맵을 함께 두어야, 삭제 대상 선택까지 상수 시간에 끝납니다.

자주 하는 실수

무효화 규칙 없이 캐시를 넣으면 원본이 바뀐 뒤에도 옛 값을 계속 돌려주게 되어, 문제가 드러날 때는 원인을 짚기 어려워집니다.

11retry 유틸

재시도 루프나 대기 시간은 없고, 고른 함수를 한 번 호출하는 것으로 끝납니다. 실패를 다시 시도해야 하는 작업이라면 이 호출을 루프로 감싸게 됩니다. 반복 대상이 함수 포인터라면 어떤 구현이 들어와도 감싸는 쪽 코드를 고칠 필요가 없다는 점이 이 형태의 이점입니다.

C++ code

// retry 유틸
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

재시도 대상은 같은 요청을 두 번 처리해도 결과가 같은 작업으로 한정해야 하며, 그렇지 않으면 이미 성공한 작업이 중복 실행됩니다.

자주 하는 실수

여러 클라이언트가 같은 간격으로 재시도하면 실패 시점마다 요청이 한꺼번에 몰리므로, 대기 시간에 임의의 흔들림을 섞어야 합니다.

12미들웨어 체인

여러 처리 단계를 잇는 대신, 조건 검사 하나와 출력 하나가 순서대로 실행됩니다. 단계가 늘어나면 각 단계가 다음 단계를 호출하는 구조로 넓혀 갑니다. std::map은 노드마다 개별 할당이라 원소가 메모리에 흩어지고, 순회가 잦은 체인에서는 연속 배열보다 캐시 효율이 떨어집니다.

C++ code

// 미들웨어 체인
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(12)) cache[12] = "user";
    std::cout << cache[12] << "\n";
}
알아두면 좋은 점

각 단계가 다음 단계를 인자로 받도록 하면 특정 단계에서 체인을 끊고 즉시 결과를 돌려주는 흐름도 자연스럽게 표현됩니다.

자주 하는 실수

단계마다 결과 객체를 값으로 복사해 넘기면 체인이 길어질수록 비용이 선형으로 늘어나므로, 참조로 넘기고 소유권 이동은 마지막에 해야 합니다.

13검증기 조합

검사 규칙을 모으는 대신 키 유무를 확인하는 if 한 줄이 판단 전부이며, 통과하면 값을 채웁니다. 규칙이 여러 개로 늘 때 각각을 함수로 나눠 차례로 적용하게 됩니다. std::map에서 count는 키가 중복될 수 없으니 항상 0 또는 1이고, C++20부터는 의도가 분명한 contains를 쓸 수 있습니다.

C++ code

// 검증기 조합
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

contains는 C++20에 들어왔고, 그 전에는 find(key) != end()가 개수를 세는 표현보다 의도를 분명히 드러냅니다.

자주 하는 실수

검증 함수를 순서대로 부르며 첫 실패에서 멈추면 사용자는 오류를 하나씩만 보게 되므로, 입력 폼 검증에서는 전부 모아 돌려주는 편이 낫습니다.

14포매터 파이프

출력은 std::cout에 문자열을 그대로 흘려보내는 한 단계뿐이고, 변환 단계는 따로 없습니다. 표시 형식이 여러 벌 필요해지면 그 부분을 함수로 분리합니다. 맵의 값은 std::string이라 꺼낼 때 참조로 받지 않으면 복사와 힙 할당이 함께 일어납니다.

C++ code

// 포매터 파이프
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(14)) cache[14] = "user";
    std::cout << cache[14] << "\n";
}
알아두면 좋은 점

맵에서 값을 꺼낼 때 const auto&로 받으면 문자열 복사를 피할 수 있고, 원소를 옮겨 갈 생각이라면 extract로 노드째 꺼낼 수도 있습니다.

자주 하는 실수

출력 형식을 코드 곳곳에 흩어 두면 표기를 바꿀 때 일부만 고쳐져, 같은 데이터가 화면마다 다르게 보이는 상태가 됩니다.

15페이지네이션

잘라 내기나 오프셋 계산은 없고, 함수 하나에 값 하나를 넘겨 변환 결과를 얻습니다. 페이지를 나누려면 시작 위치와 개수를 인자로 받는 형태로 바꿔야 합니다. 인자와 반환이 모두 int라 큰 오프셋을 다루기 시작하면 std::size_t와의 부호 비교 경고를 먼저 만나게 됩니다.

C++ code

// 페이지네이션
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

개수와 오프셋은 std::size_t로 받아야 큰 값에서 안전하고, 뺄셈을 하기 전에 전체 크기를 넘지 않는지 먼저 확인해야 합니다.

자주 하는 실수

부호 없는 값끼리 빼서 음수가 되면 거대한 양수로 감기므로, 오프셋이 전체보다 클 때 페이지 크기 계산이 통째로 망가집니다.

16데이터 매퍼

정수 키를 문자열 값에 대응시켜 저장하고 같은 키로 되찾는, 식별자와 데이터를 잇는 최소 형태입니다. 행을 객체로 옮겨 담는 작업의 뼈대가 이 대응 관계입니다. std::map은 키를 정렬해 보관하므로 순회하면 항상 오름차순이고, 순서가 필요 없다면 std::unordered_map이 평균적으로 더 빠릅니다.

C++ code

// 데이터 매퍼
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(16)) cache[16] = "user";
    std::cout << cache[16] << "\n";
}
알아두면 좋은 점

키가 정수이고 값 범위가 촘촘하다면 맵 대신 인덱스가 곧 키인 벡터를 쓰는 편이 훨씬 빠르고 메모리도 덜 씁니다.

자주 하는 실수

맵에 담긴 값을 참조로 들고 있다가 그 키를 erase하면 참조가 곧바로 매달린 상태가 되므로, 삭제 뒤에는 다시 조회해야 합니다.

17저장소 패턴

저장소 인터페이스 대신, 함수 포인터 하나가 요청을 받아 결과를 계산하는 구조입니다. 데이터 접근 방법을 나중에 갈아 끼우고 싶을 때 이런 간접층을 둡니다. 다만 함수 포인터에는 상태를 담을 수 없어 연결 정보 같은 문맥이 필요해지면 함수 객체나 std::function으로 옮겨야 합니다.

C++ code

// 저장소 패턴
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

데이터 접근을 인터페이스로 감싸 두면 테스트에서 메모리 구현으로 바꿔 끼울 수 있어, 실제 저장소 없이도 상위 로직을 검증할 수 있습니다.

자주 하는 실수

간접층을 두면서 반환 타입까지 저장소 구현에 맞추면 결국 구현을 바꿀 수 없게 되므로, 인터페이스에는 도메인 타입만 노출해야 합니다.

18의존성 주입 컨테이너

이름 대신 정수 키로 값을 찾아 주는 맵은, 등록해 둔 것을 필요할 때 꺼내 쓰는 구조의 축소판입니다. 만들어 둔 인스턴스를 여러 곳이 공유해야 할 때 이런 등록소를 씁니다. 값이 소유권을 갖는 포인터라면 std::shared_ptr가 참조 카운트를 원자적으로 갱신하므로, 소유자가 하나뿐이라면 std::unique_ptr가 더 저렴합니다.

C++ code

// 의존성 주입 컨테이너
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(18)) cache[18] = "user";
    std::cout << cache[18] << "\n";
}
알아두면 좋은 점

등록소가 소유권을 갖는다면 std::unique_ptr를 담고 사용자에게는 참조만 내주는 방식이 관계를 가장 단순하게 만듭니다.

자주 하는 실수

std::shared_ptr로 서로를 참조하는 구성 요소를 등록하면 순환 참조로 참조 수가 0이 되지 않아 끝까지 해제되지 않습니다.

19템플릿 메서드

고정된 뼈대에 세부 단계를 끼우는 대신, 바뀌는 부분인 계산 함수 자체를 변수로 받아 호출합니다. 전체 흐름은 같고 한 지점만 달라질 때 쓰는 방식입니다. 이 자리를 템플릿 매개변수로 바꾸면 호출 대상이 컴파일 시점에 정해져 간접 호출이 사라지지만, 넘기는 구현마다 코드가 따로 생성됩니다.

C++ code

// 템플릿 메서드
#include <iostream>

int percent(int value) { return static_cast<int>(value * 0.9); }
int flat(int value) { return value - 5; }
int main() {
    auto strategy = percent;
    std::cout << strategy(100) << "\n";
}
알아두면 좋은 점

고정된 흐름은 기반 클래스의 비가상 함수에 두고 달라지는 부분만 가상 함수로 열어 두면, 호출 순서가 파생 쪽에서 흐트러지지 않습니다.

자주 하는 실수

달라지는 단계를 공개 가상 함수로 열어 두면 바깥에서 그 단계만 따로 부를 수 있어, 전체 흐름을 건너뛴 호출이 생깁니다.

20파사드

복잡한 내부를 감춘 창구처럼, 호출하는 쪽은 키만 넘기고 값이 이미 있었는지 새로 만들어졌는지는 신경 쓰지 않습니다. 여러 하위 동작을 한 호출로 묶고 싶을 때 이런 얇은 앞면을 둡니다. std::map의 조회는 원소 수에 로그로 비례하므로, 항목이 늘어도 응답 시간이 급격히 나빠지지 않습니다.

C++ code

// 파사드
#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> cache;
    if (!cache.count(20)) cache[20] = "user";
    std::cout << cache[20] << "\n";
}
알아두면 좋은 점

파사드는 자주 쓰는 조합만 얇게 감싸고 원래 구성 요소도 그대로 공개해 두어야, 예외적인 사용이 필요할 때 막다른 길이 되지 않습니다.

자주 하는 실수

노드마다 따로 할당되는 자료 구조는 원소가 많아지면 로그 시간이라도 캐시 실패가 늘어, 실제 조회 시간이 예상보다 훨씬 나빠집니다.

정리하며

  • 옵저버의 구독자 목록은 weak_ptr로 담고 알림 직전 lock으로 생존을 확인합니다
  • shared_ptr 순환 참조는 누수로 이어지므로 역방향 링크는 weak_ptr로 끊습니다
  • 전략과 커맨드는 클래스 계층 없이 std::function 하나로 충분한 경우가 많습니다
  • 다형성을 런타임 가상 호출로 둘지 템플릿으로 컴파일 타임에 둘지 매번 판단합니다

더 깊이 들어가고 싶다면 C++ 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.