문서 편집기에서 “선택한 객체 복제”를 구현한다고 하자. 선택된 것은 Document*로만 알고 있는데, 실제로는 Invoice일 수도 Memo일 수도 있다. 복사 생성자는 정적 타입을 알아야 호출할 수 있어서, 타입마다 if나 dynamic_cast를 늘어놓게 된다. 프로토타입(Prototype)은 객체 스스로 자신을 복제하는 연산(clone())을 제공하게 해서, 구체 타입을 모르는 쪽도 정확한 복사본을 얻게 한다. 이 글에서는 슬라이싱 문제, 가상 clone(), 얕은 복사와 깊은 복사, 프로토타입 관리자, 재정의 누락을 막는 CRTP, 그리고 C에서 memcpy 복제가 만드는 이중 해제를 다룬다.
도형 편집기 예제에서 clone()을 쓰는 방법은 설계 기법 (5) 도형 편집기에서 다뤘다. 여기서는 패턴 자체와 복제의 함정에 집중한다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (2) — 빌더(Builder)
- C/C++ 디자인 패턴 (3) — 팩토리 메서드(Factory Method)
- C/C++ 디자인 패턴 (5) — 싱글톤(Singleton)
- C/C++ 디자인 패턴 (6) — 어댑터(Adapter)
기반 타입으로 복사하면 생기는 일
// slicing.cpp — 기반 클래스 타입으로 복사하면 파생 부분이 잘려 나간다
#include <iostream>
#include <string>
struct Document {
std::string title;
virtual ~Document() = default;
virtual std::string describe() const { return "Document(" + title + ")"; }
};
struct Invoice : Document {
double amount = 0;
std::string describe() const override
{ return "Invoice(" + title + ", " + std::to_string(amount) + ")"; }
};
Document duplicate(const Document &d) { return d; } // 복사본을 만들고 싶었다
int main()
{
Invoice inv;
inv.title = "2026-10";
inv.amount = 1200.5;
const Document &ref = inv;
std::cout << "original : " << ref.describe() << '\n';
std::cout << "copy : " << duplicate(ref).describe() << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o slicing slicing.cpp && ./slicing
original : Invoice(2026-10, 1200.500000)
copy : Document(2026-10)
Document의 복사 생성자가 불려 amount가 잘려 나갔다(object slicing). 원본이 무엇이었는지는 원본만 안다.
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Prototype | 자신을 복제하는 인터페이스 선언 | Document::clone() |
| ConcretePrototype | 자신의 복사본을 반환하도록 구현 | Invoice, Memo |
| Client | 프로토타입에 복제를 요청해 새 객체를 얻음 | 복사 루프, Spawner |
가상 clone()
// clone.cpp — 가상 clone(): 정적 타입을 몰라도 실제 타입 그대로 복제한다
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Document {
std::string title;
virtual ~Document() = default;
virtual std::unique_ptr<Document> clone() const = 0;
virtual std::string describe() const = 0;
};
struct Invoice : Document {
double amount = 0;
std::unique_ptr<Document> clone() const override { return std::make_unique<Invoice>(*this); }
std::string describe() const override { return "Invoice(" + title + ", " + std::to_string(amount) + ")"; }
};
struct Memo : Document {
std::string to;
std::unique_ptr<Document> clone() const override { return std::make_unique<Memo>(*this); }
std::string describe() const override { return "Memo(" + title + " -> " + to + ")"; }
};
int main()
{
std::vector<std::unique_ptr<Document>> docs;
auto inv = std::make_unique<Invoice>(); inv->title = "2026-10"; inv->amount = 1200.5;
auto memo = std::make_unique<Memo>(); memo->title = "회의"; memo->to = "team";
docs.push_back(std::move(inv));
docs.push_back(std::move(memo));
std::vector<std::unique_ptr<Document>> copies;
for (const auto &d : docs)
copies.push_back(d->clone()); // 타입 분기 없음
copies[0]->title = "2026-11"; // 복제본만 변경
for (size_t i = 0; i < docs.size(); ++i)
std::cout << docs[i]->describe() << " | " << copies[i]->describe() << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o clone clone.cpp && ./clone
Invoice(2026-10, 1200.500000) | Invoice(2026-11, 1200.500000)
Memo(회의 -> team) | Memo(회의 -> team)
각 클래스의 clone()이 자기 복사 생성자를 부르므로 동적 타입이 보존된다. 복제본의 title만 바뀐 것으로 두 객체가 독립적임을 확인할 수 있다.
얕은 복사와 깊은 복사
복사 생성자에 기대는 clone()은 멤버의 복사 의미를 그대로 따른다. std::shared_ptr 멤버는 대상이 아니라 소유권을 복사한다.
// shallow.cpp — shared_ptr 멤버는 기본 복사로는 공유된다(얕은 복사)
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Style { std::string font = "Noto Sans"; int size = 11; };
struct Shallow {
std::shared_ptr<Style> style = std::make_shared<Style>();
std::unique_ptr<Shallow> clone() const { return std::make_unique<Shallow>(*this); }
};
struct Deep {
std::shared_ptr<Style> style = std::make_shared<Style>();
std::unique_ptr<Deep> clone() const
{
auto c = std::make_unique<Deep>(*this);
c->style = std::make_shared<Style>(*style); // 가리키는 대상까지 복제
return c;
}
};
int main()
{
Shallow a;
auto b = a.clone();
b->style->size = 20;
std::cout << "shallow: original size=" << a.style->size << " (use_count=" << a.style.use_count() << ")\n";
Deep c;
auto d = c.clone();
d->style->size = 20;
std::cout << "deep : original size=" << c.style->size << " (use_count=" << c.style.use_count() << ")\n";
}$ g++ -std=c++20 -Wall -Wextra -O2 -o shallow shallow.cpp && ./shallow
shallow: original size=20 (use_count=2)
deep : original size=11 (use_count=1)
얕은 복제본의 글꼴 크기를 바꾸자 원본도 20이 됐다. 스타일 공유가 의도라면 얕은 복사가 맞고, 아니라면 clone()에서 가리키는 대상까지 새로 만들어야 한다.
프로토타입 관리자
미리 설정해 둔 원형들을 이름으로 등록하고 복제해 쓴다. 같은 클래스라도 설정만 다른 원형을 여러 개 둘 수 있어서, 서브클래스를 만들지 않고 “종류”를 늘릴 수 있다.
// registry.cpp — 프로토타입 관리자: 미리 설정된 원형을 이름으로 복제한다
#include <iostream>
#include <map>
#include <memory>
#include <string>
struct Enemy {
std::string kind;
int hp, speed;
Enemy(std::string k, int h, int s) : kind(std::move(k)), hp(h), speed(s) {}
virtual ~Enemy() = default;
virtual std::unique_ptr<Enemy> clone() const { return std::make_unique<Enemy>(*this); }
virtual std::string str() const
{ return kind + " hp=" + std::to_string(hp) + " speed=" + std::to_string(speed); }
};
struct Boss : Enemy {
int phase = 1;
Boss(std::string k, int h, int s) : Enemy(std::move(k), h, s) {}
std::unique_ptr<Enemy> clone() const override { return std::make_unique<Boss>(*this); }
std::string str() const override { return Enemy::str() + " phase=" + std::to_string(phase); }
};
class Spawner {
public:
void add(std::string name, std::unique_ptr<Enemy> proto) { protos_[std::move(name)] = std::move(proto); }
std::unique_ptr<Enemy> spawn(const std::string &name) const
{
auto it = protos_.find(name);
return it == protos_.end() ? nullptr : it->second->clone();
}
private:
std::map<std::string, std::unique_ptr<Enemy>> protos_;
};
int main()
{
Spawner s;
s.add("slime", std::make_unique<Enemy>("slime", 10, 1));
s.add("fast-slime", std::make_unique<Enemy>("slime", 10, 4)); // 같은 클래스, 다른 설정
s.add("dragon", std::make_unique<Boss>("dragon", 500, 2));
for (auto n : {"slime", "fast-slime", "dragon"})
std::cout << n << " -> " << s.spawn(n)->str() << '\n';
auto e = s.spawn("dragon");
e->hp -= 120; // 복제본만 피해를 입는다
std::cout << "damaged copy: " << e->str() << "\nnew spawn : " << s.spawn("dragon")->str() << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o registry registry.cpp && ./registry
slime -> slime hp=10 speed=1
fast-slime -> slime hp=10 speed=4
dragon -> dragon hp=500 speed=2 phase=1
damaged copy: dragon hp=380 speed=2 phase=1
new spawn : dragon hp=500 speed=2 phase=1
slime과 fast-slime은 같은 Enemy 클래스다. 복제본이 피해를 입어도 등록된 원형은 그대로라 다음 생성은 다시 hp 500으로 시작한다.
재정의 누락과 CRTP
손자 클래스가 clone()을 재정의하지 않아도 부모의 것이 상속되기 때문에 컴파일은 된다. 결과는 조용한 슬라이싱이다. CRTP 도우미를 상속하면 클래스마다 clone()이 자동으로 생긴다.
// forgot.cpp — 손자 클래스가 clone()을 재정의하지 않으면 조용히 잘린다. CRTP로 막는다
#include <iostream>
#include <memory>
#include <string>
namespace manual {
struct Shape { virtual ~Shape() = default; virtual std::unique_ptr<Shape> clone() const = 0; virtual std::string name() const = 0; };
struct Rect : Shape {
std::unique_ptr<Shape> clone() const override { return std::make_unique<Rect>(*this); }
std::string name() const override { return "Rect"; }
};
struct RoundRect : Rect { // clone() 재정의를 잊었다
std::string name() const override { return "RoundRect"; }
};
}
namespace crtp {
struct Shape { virtual ~Shape() = default; virtual std::unique_ptr<Shape> clone() const = 0; virtual std::string name() const = 0; };
template <class Derived, class Base = Shape>
struct Cloneable : Base {
std::unique_ptr<Shape> clone() const override
{ return std::make_unique<Derived>(static_cast<const Derived &>(*this)); }
};
struct Rect : Cloneable<Rect> { std::string name() const override { return "Rect"; } };
struct RoundRect : Cloneable<RoundRect, Rect> { std::string name() const override { return "RoundRect"; } };
}
int main()
{
manual::RoundRect m;
crtp::RoundRect c;
std::cout << "manual: " << m.name() << " -> clone is " << m.clone()->name() << '\n';
std::cout << "crtp : " << c.name() << " -> clone is " << c.clone()->name() << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o forgot forgot.cpp && ./forgot
manual: RoundRect -> clone is Rect
crtp : RoundRect -> clone is RoundRect
C 구현과 memcpy의 함정
C에서 구조체를 복제하는 가장 쉬운 방법은 memcpy지만, 힙 포인터 멤버까지 그대로 복사된다. 복제 함수를 구조체에 넣고 얕은 버전과 깊은 버전을 비교했다.
/* proto.c — C 프로토타입: memcpy는 얕은 복사라 소유 포인터는 직접 복제해야 한다 */
#define _POSIX_C_SOURCE 200809L /* strdup */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct doc {
char *title; /* 힙에 소유 */
int pages;
struct doc *(*clone)(const struct doc *d);
};
static struct doc *doc_clone_shallow(const struct doc *d)
{
struct doc *c = malloc(sizeof(*c));
if (!c)
abort();
memcpy(c, d, sizeof(*c)); /* title 포인터까지 그대로 복사 */
return c;
}
static struct doc *doc_clone_deep(const struct doc *d)
{
struct doc *c = doc_clone_shallow(d);
c->title = strdup(d->title); /* 소유 자원은 새로 할당 */
if (!c->title)
abort();
return c;
}
static void doc_free(struct doc *d)
{
free(d->title);
free(d);
}
int main(int argc, char **argv)
{
int shallow = argc > 1 && strcmp(argv[1], "shallow") == 0;
struct doc *proto = malloc(sizeof(*proto));
if (!proto)
abort();
*proto = (struct doc){ strdup("template"), 3, shallow ? doc_clone_shallow : doc_clone_deep };
struct doc *copy = proto->clone(proto);
copy->title[0] = 'T';
copy->pages = 5;
printf("proto: %s/%d copy: %s/%d\n", proto->title, proto->pages, copy->title, copy->pages);
fflush(stdout);
doc_free(copy);
doc_free(proto);
return 0;
}$ gcc -std=c11 -Wall -Wextra -O1 -g -fsanitize=address -ffile-prefix-map=$PWD=. -o proto proto.c && ./proto deep
proto: template/3 copy: Template/5
$ ./proto shallow 2>&1 | grep -E 'proto:|ERROR|SUMMARY|#[0-2] |freed by|previously allocated' | head -14
proto: Template/3 copy: Template/5
==33510==ERROR: AddressSanitizer: attempting double-free on 0x502000000010 in thread T0:
#0 0x7be13f8fc4d8 in free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:52
#1 0x5dce18fcc5fe in doc_free proto.c:33
#2 0x5dce18fcc5fe in main proto.c:52
freed by thread T0 here:
#0 0x7be13f8fc4d8 in free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:52
#1 0x5dce18fcc5de in doc_free proto.c:33
#2 0x5dce18fcc5de in main proto.c:51
previously allocated by thread T0 here:
#0 0x7be13f8f74e8 in strdup ../../../../src/libsanitizer/asan/asan_interceptors.cpp:578
#1 0x5dce18fcc472 in main proto.c:44
SUMMARY: AddressSanitizer: double-free ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:52 in free
얕은 복제본의 제목을 바꾸자 원본도 Template이 됐고, 두 객체가 같은 title을 해제하면서 ASan이 이중 해제를 잡았다. 같은 문제를 C++에서는 복사 생성자와 RAII 멤버(std::string)가 막아 준다.
주의사항
- clone()의 복사 의미를 문서화한다: 공유해야 하는 자원(캐시, 연결, 스타일)과 독립해야 하는 상태를 구분하지 않으면, 얕은 복사로 인한 버그가 원본에서 나타난다.
- 복사할 수 없는 멤버:
std::unique_ptr,std::mutex, 파일 핸들을 가진 클래스는 기본 복사 생성자가 없거나 의미가 맞지 않는다. 이런 클래스의clone()은 직접 작성해야 한다. - 순환 참조 구조: 부모·자식이 서로를 가리키는 객체 그래프를 깊게 복제하려면 이미 복제한 노드를 기록하는 맵이 필요하다.
- 복사 생성자는 protected로: 외부에서
Document d = *ptr;처럼 슬라이싱 복사를 하지 못하게 기반 클래스의 복사 연산을 protected로 두면,clone()만이 복제 경로가 된다. - C에서는 소유 포인터마다 깊은 복사:
memcpy는 시작점일 뿐이고, 소유한 버퍼는strdup·malloc+복사로 새로 만든다. ASan(-fsanitize=address)으로 반드시 확인한다.
마무리
프로토타입은 “생성할 타입을 몰라도 복제는 할 수 있다”는 점을 이용한다. 원형을 설정 데이터처럼 다루면 서브클래스 없이 종류를 늘릴 수 있고, 생성 비용이 큰 객체는 한 번 만든 원형을 복제해 비용을 아낄 수 있다. 다음 글에서는 인스턴스를 하나로 제한하는 싱글톤(Singleton)을 GoF 관점에서 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Prototype
- C++ Core Guidelines C.130 — 다형 클래스의 깊은 복사는 가상 clone
- AddressSanitizer