Prototype 패턴: 복제로 객체를 만드는 생성 전략
초기화 비용이 큰 객체를 복제해 새 인스턴스를 만드는 Prototype 패턴의 복제 범위 선택, 레지스트리 구조, Java 구현을 정리한다.
2026-08-13 · 최초 발행 2025-10-14
매번 처음부터 만드는 대신 복제한다
초기화 비용이 큰 객체를 매번 생성자로 처음부터 만드는 대신, 이미 만들어둔 인스턴스를 프로토타입으로 보관하고 이를 복제해 새로운 객체를 생성하는 방식이 Prototype 패턴이다. 생성자 호출 비용을 절감하고 초기화 일관성을 확보하는 것이 목적이며, 런타임 동적 구성이나 템플릿 기반 생성, 대량 객체 생성이 필요한 곳에서 특히 유용하다.
얕은 복제·깊은 복제·부분 복제 중 무엇을 고를까
복제 유형은 얕은 복제(Shallow Copy), 깊은 복제(Deep Copy), 부분 복제(Partial Copy) 중에서 고른다. 선택 기준은 구성 요소의 소유권, 불변성, 외부 리소스 핸들에 미치는 영향이다. 불변 객체 위주이거나 참조를 공유해도 되는 경우라면 얕은 복제로 충분하다. 가변 참조를 포함하고 독립적인 수명 주기를 보장해야 한다면 깊은 복제가 맞다. 일부는 독립시키고 나머지는 공유해도 되는 경우라면 부분 복제 전략을 적용한다.
Prototype 인터페이스와 소유권 규칙
Prototype 인터페이스는 공통 복제 메서드를 제공하며, 복제 전후로 불변식(invariant)을 유지하고 검증하는 로직을 포함해야 한다. equals/hashCode/식별자 재부여 규칙을 명확히 하고, ID·타임스탬프·랜덤 값을 재생성하는 규칙은 별도로 분리하는 것이 좋다.
Concrete Prototype 단계에서는 중첩 객체, 컬렉션, 외부 리소스의 소유권과 라이프사이클을 정의해야 한다. 파일 핸들, 소켓, 쓰레드 풀 같은 자원은 복제 대상에서 제외하는 원칙이 필요하다. 카피온라이트(Copy-on-Write)나 풀링과 조합하면 메모리와 성능의 균형을 맞출 수 있다. 복제 전략은 수작업 복제, 직렬화 기반 복제, 언어가 지원하는 구조적 복제를 상황에 맞게 혼용하며, 도메인 규칙을 반영한 부분 복제 빌더를 병행할 수도 있다. 성능과 정확도 사이의 트레이드오프는 마이크로벤치마크로 검증하는 편이 안전하다.
레지스트리로 프로토타입을 조회하고 복제하는 흐름
레지스트리는 키나 메타데이터로 프로토타입을 조회하고 복제하는 관리 컴포넌트다. 태그나 버전 기반 선택, 멀티테넌트 격리 운영을 지원하며, 시작 시 프리로드하거나 런타임에 핫스왑할 수 있다. 메트릭·로그·트레이스 같은 관찰 가능성도 함께 포함하고, 미등록 키나 버전 불일치는 예외로 처리해야 한다.
입력부터 출력까지: 절차와 예외 처리
입력은 key/version, 컨텍스트 파라미터, 복제 전략(얕은/깊은/부분), 후처리 변환기다. 처리 순서는 레지스트리 조회 → 프로토타입 선택 → 복제 실행 → 도메인 규칙에 따른 식별자·타임스탬프 재설정 → 검증·정규화로 이어진다. 출력은 일관성이 보장된 새 인스턴스이며, 생성 시간·전략·버전 같은 관찰 가능성 데이터도 함께 기록한다. 키 미존재, 버전 불일치, 복제 불가 필드, 외부 리소스 복제 시도, 검증 실패 같은 예외 상황에는 기본값이나 재시도, 폴백 프로토타입 같은 대체 전략을 적용한다.
Java로 구현한 깊은 복제 + 레지스트리
전제조건은 OpenJDK 17+, 표준 라이브러리만 사용한다. 컴파일·실행은 javac PrototypeDemo.java && java PrototypeDemo다.
// PrototypeDemo.java
import java.util.*;
import java.util.function.Consumer;
interface Prototype<T> {
T copy(); // 복제 계약
}
final class Address implements Prototype<Address> {
final String city;
final String street;
Address(String city, String street) {
this.city = city;
this.street = street;
}
@Override public Address copy() { return new Address(this.city, this.street); }
@Override public String toString() { return city + " " + street; }
}
final class Item implements Prototype<Item> {
final String name;
final double price;
Item(String name, double price) { this.name = name; this.price = price; }
@Override public Item copy() { return new Item(this.name, this.price); }
}
final class Order implements Prototype<Order> {
String orderType; // 가변 필드
List<Item> items; // 깊은 복제 대상
Address shippingAddress; // 깊은 복제 대상
Map<String, String> meta; // 깊은 복제 대상
UUID id; // 재생성 대상
Order(String orderType, List<Item> items, Address addr, Map<String, String> meta) {
this.orderType = orderType;
this.items = items;
this.shippingAddress = addr;
this.meta = meta;
this.id = UUID.randomUUID();
}
@Override public Order copy() {
// 깊은 복제 구현
List<Item> newItems = new ArrayList<>();
for (Item it : this.items) newItems.add(it.copy());
Address newAddr = this.shippingAddress == null ? null : this.shippingAddress.copy();
Map<String, String> newMeta = new HashMap<>(this.meta); // 값이 불변 문자열이므로 얕은 복제 허용
Order o = new Order(this.orderType, newItems, newAddr, newMeta);
o.id = UUID.randomUUID(); // 식별자 재부여
return o;
}
@Override public String toString() {
return "Order{id=" + id + ", type=" + orderType + ", items=" + items.size() + ", addr=" + shippingAddress + "}";
}
}
final class PrototypeRegistry<T extends Prototype<T>> {
private final Map<String, T> store = new HashMap<>();
public void register(String key, T proto) { store.put(key, proto); }
public T create(String key, Consumer<T> mutator) {
T proto = store.get(key);
if (proto == null) throw new IllegalArgumentException("Prototype not found: " + key);
T inst = proto.copy();
if (mutator != null) mutator.accept(inst);
return inst;
}
}
public class PrototypeDemo {
public static void main(String[] args) {
// 프로토타입 준비
Order base = new Order(
"STANDARD",
List.of(new Item("A", 10.0), new Item("B", 20.5)),
new Address("Seoul", "Teheran-ro"),
Map.of("currency", "KRW")
);
PrototypeRegistry<Order> registry = new PrototypeRegistry<>();
registry.register("order:standard:v1", base);
// 대량 생성 벤치마크
int N = 10000;
long t0 = System.nanoTime();
List<Order> orders = new ArrayList<>(N);
for (int i = 0; i < N; i++) {
Order o = registry.create("order:standard:v1", inst -> {
inst.orderType = "STANDARD"; // 필요 시 변경
inst.meta.put("seq", Integer.toString(i));
});
orders.add(o);
}
long t1 = System.nanoTime();
System.out.println("Created " + orders.size() + " orders in " + ((t1 - t0) / 1_000_000.0) + " ms");
System.out.println(orders.get(0));
}
}
깊은 복제 대상과 재생성 규칙을 분리하고 외부 리소스는 복제하지 않는다는 원칙을 지켜야 한다. 레지스트리를 핫스왑할 때는 read-write 락을 도입해 멀티스레드 환경에서 안전성을 확보하는 편이 좋다.
게임 엔티티부터 테스트 픽스처까지
게임이나 시뮬레이션에서는 몬스터·NPC·파티클 템플릿을 복제해 대량 생성하고, 구성품과 행동 트리는 부분 복제로 처리한다. UI 컴포넌트나 폼 템플릿에서는 입력 검증, 기본 스타일, 바인딩이 사전 설정된 템플릿을 복제하고 런타임 테마와 조합한다. 워크플로나 정책 템플릿에서는 결재 라우팅, SLA, 알림 정책 템플릿을 두고 테넌트별 파라미터만 바꿔서 쓴다. 테스트 픽스처나 데이터 시드에서는 안정적인 초기 상태를 보장해 테스트 유지보수 비용을 줄이는 데 쓰인다.
복제가 만들어내는 효과
성능 측면에서는 복잡한 초기화 비용(T_init)보다 복제 비용(T_clone)이 짧다는 게 핵심이다. 예를 들어 T_init=5ms, T_clone=0.8ms를 가정하면 1만 개 생성 시 약 42초에서 8초 수준으로 단축될 수 있다 — 다만 실제 수치는 도메인별로 직접 측정해야 한다. 일관성·품질 측면에서는 초기화 누락이나 순서 의존 버그가 줄고, 검증 규칙을 중앙화하면 회귀도 감소한다. 운영 측면에서는 런타임에 템플릿을 교체하고 롤백하기 쉬워지며, 멀티테넌트 커스터마이징 비용도 줄어든다.
다른 생성 패턴과 비교하면
| 패턴 | 성능(대량 생성) | 일관성(초기화) | 확장성(신규 제품군) | 안정성(스레드/불변) | 운영 편의(런타임 구성) |
|---|---|---|---|---|---|
| Prototype | 상 | 상 | 중 | 중 | 상 |
| Factory Method | 중 | 중 | 중 | 상 | 중 |
| Abstract Factory | 중 | 상 | 상 | 상 | 중 |
| Builder | 하 | 상 | 상 | 상 | 중 |
Prototype은 대량 생성과 런타임 구성에 강점이 있는 대신, 깊은 복제의 복잡도와 메모리 사용량 증가라는 트레이드오프를 안고 간다.
도입 전에 따져야 할 것들
프로토타입은 불변화하고 방어적으로 복제하며, ID·시계·난수 재생성 규칙은 따로 분리하는 게 모범사례다. 레지스트리는 버전을 관리하고 호환성을 검사하며, 성능·실패율 같은 관찰 가능성 데이터를 수집한다. 카피온라이트와 풀링을 함께 쓰면 메모리를 절감할 수 있고, 검증기·정규화기 체인을 구성해두면 안전하다.
다만 깊은 복제는 비용과 구현 난이도가 올라가고, 직렬화 기반 복제는 성능·안전성 제약이 따른다. 외부 리소스를 복제하지 않는 원칙을 지키지 않으면 핸들이 공유되는 위험이 생기며, 이 경우 불변 객체 설계를 대안으로 검토할 만하다. 복제 후 참조가 공유돼 사이드이펙트가 생길 위험도 있으므로 불변 컬렉션이나 스냅샷 전략을 쓰는 편이 안전하다. 초기화 비용이 큰 도메인, 런타임 템플릿 변경이 잦은 경우, 대량 객체 생성 워크로드라면 우선 도입을 검토할 만하고, 불변 설계와 함께 쓸 때 효과가 가장 크다.