그래픽 에디터를 만든다고 하자. 왼쪽 팔레트에는 도형 도구들이 있다 — 사각형, 원, 선, 그리고 사용자가 직접 그려 저장한 별표 하나. 사용자가 도구를 고르고 캔버스를 클릭하면, 그 종류의 도형이 새로 하나 생겨나 자리를 잡는다. 이 “새로 하나 생겨난다”를 코드로 옮기는 순간 문제가 시작된다. 에디터의 핵심 로직(도구를 관리하고, 클릭을 받아 도형을 배치하는 코드)은 도형이 사각형인지 원인지 알고 싶지 않다. 그저 “네가 무엇이든, 하나 더 만들어라”라고 말하고 싶을 뿐이다.
그런데 자바에서 객체를 만드는 유일한 정식 통로는 new 사각형(), new 원()처럼 클래스 이름을 코드에 박는 것이다. 만들 대상의 클래스를 지목하지 않고서는 인스턴스를 낳을 수 없다. 에디터가 도형 종류를 몰라도 새 인스턴스를 얻으려면, 이 “클래스를 지목한다”는 결박을 어떻게든 풀어야 한다. 프로토타입(Prototype) 패턴은 그 결박을 이렇게 푼다 — 새로 조립하지 말고, 이미 있는 견본을 복제하라.
무엇이 문제인가 — 동기
팔레트에 도구를 추가하는 코드를 소박하게 짜 보면 아픔이 드러난다. 도구 하나는 “무엇을 만들지”를 알아야 하므로, 흔히 도구마다 서브클래스를 하나씩 판다.
Tool (추상)
├─ RectangleTool → new Rectangle()
├─ CircleTool → new Circle()
└─ LineTool → new Line()
도형 종류가 하나 늘 때마다 도구 서브클래스가 하나 는다. RectangleTool이 하는 일이라곤 new Rectangle() 한 줄이 전부인데도, 종류의 수만큼 거의 똑같은 팩토리 클래스가 우수수 생긴다. 이것이 팩토리 클래스 폭발이다 — 만드는 방법이 아니라 오직 “만드는 대상”만 다른 클래스들이 병렬로 쌓인다.
더 아픈 건 두 번째 요구다. 사용자가 “테두리 두껍고 빨간 원”을 하나 그려 두고 그걸 팔레트에 새 도구로 등록하고 싶다고 하자. 이 원은 Circle이라는 클래스가 아니라, 반지름·색·테두리 굵기가 특정 값으로 채워진 어떤 인스턴스의 상태다. 클래스 지목 방식으로는 이걸 도구로 만들 수 없다. new 빨간두꺼운원() 같은 클래스는 존재하지 않고, 만들 수도 없다 — 종류가 런타임에 태어났기 때문이다. 컴파일 시점에 클래스로 존재하지 않는 종류를, 어떻게 도구로 등록하고 계속 찍어낼 것인가.
세 번째, 각 도형은 미리 설정된 상태를 품고 태어나야 한다. 팔레트의 “굵은 파란 선” 도구를 클릭하면 나오는 선은 매번 굵고 파래야 한다. 클래스만 지목하는 new Line()은 텅 빈 기본 선을 줄 뿐이라, 만든 뒤에 굵기와 색을 다시 세팅하는 코드가 도구마다 붙는다. 만드는 곳과 설정하는 곳이 갈라진다.
판단 기준: 생성할 종류가 런타임에 정해지거나 늘어나고, 각 종류가 클래스가 아니라 “특정하게 설정된 인스턴스”로 식별될 때 프로토타입을 의심한다. 함정: 종류가 컴파일 시점에 다 알려져 있고 앞으로 늘 일도 없다면, 프로토타입은 clone의 복잡성만 떠안는다 — 그냥 new가 정답이다.
패턴의 구조 — 누가 무엇을 하나
프로토타입의 발상은 한 문장이다. 만들고 싶은 종류의 완성된 인스턴스를 하나 미리 만들어 두고(=프로토타입), 새것이 필요하면 그 견본에게 “너 자신을 복제해라”라고 요청한다. 등장인물은 셋이다.
Prototype(프로토타입 인터페이스). “나는 나 자신을 복제할 수 있다”는 계약 하나. Shape clone() 이 전부다. 이 인터페이스가 있으면 클라이언트는 상대가 사각형인지 원인지 몰라도 shape.clone()으로 같은 종류의 새 인스턴스를 얻는다. 클래스 이름 대신 복제 능력이 생성의 통로가 된다.
ConcretePrototype(구체 프로토타입들). clone()을 각자 구현한 실제 도형들 — Rectangle, Circle, Line. 여기서 결정적인 것은, 이들이 복제하는 것은 클래스가 아니라 자기 자신의 현재 상태라는 점이다. 반지름 5에 빨간색인 Circle이 clone()되면, 반지름 5에 빨간 새 Circle이 나온다. 그래서 “빨간 두꺼운 원”은 별도 클래스가 아니라 그런 상태로 채워진 Circle 인스턴스 하나면 족하다.
Client(클라이언트). 프로토타입을 쥐고 있다가 복제를 요청하는 쪽 — 에디터의 도구. 도구는 이제 new Circle()을 부르는 서브클래스일 필요가 없다. 어떤 프로토타입을 참조로 들고 있느냐가 그 도구의 정체를 정한다. RectangleTool, CircleTool이 각기 다른 클래스일 이유가 사라진다 — 프로토타입만 다른, 같은 Tool 인스턴스면 된다.
이 마지막 지점이 폭발을 끄는 자리다. 서브클래스가 하던 “무엇을 만들지”의 앎이 클래스 계층에서 **필드값(어떤 프로토타입을 가리키는가)**으로 내려온다. 종류를 클래스로 표현하던 것을, 인스턴스로 표현하게 바꾼 것이다.
판단 기준: “종류의 차이”가 서로 다른 동작이 아니라 서로 다른 초기 상태뿐이라면, 그 차이는 클래스가 아니라 인스턴스로 표현하는 게 맞다 — 프로토타입의 핵심 신호다. 함정: 종류마다 동작(메서드 구현)까지 다르다면 프로토타입만으로는 부족하다 — clone은 상태를 베낄 뿐 새 행동을 만들어 주지 않는다.
코드로 — 패턴이 자라나는 과정
도구 서브클래스가 종류마다 하나씩 늘어나는 원본에서 출발해, clone으로 폭발을 끄고, 프로토타입 레지스트리로 런타임 등록까지 여는 과정을 따라간다.
public abstract class Tool { // "무엇을 만들지"를 서브클래스가 안다. 이 한 줄을 위해 클래스가 하나씩 는다. public abstract Shape createShape();}public class RectangleTool extends Tool { public Shape createShape() { return new Rectangle(); }}public class CircleTool extends Tool { public Shape createShape() { return new Circle(); }}public class LineTool extends Tool { public Shape createShape() { return new Line(); }}// 도형이 하나 늘면 Tool 서브클래스가 하나 는다. createShape() 한 줄만 다른,// 거의 똑같은 팩토리 클래스가 종류의 수만큼 병렬로 쌓인다 — 팩토리 폭발.// 게다가 "빨간 두꺼운 원"처럼 런타임에 만들어진 종류는 클래스가 없어 도구로 못 만든다.
첫 스텝의 세 도구 클래스가 마지막에서 하나의 Tool과 하나의 레지스트리로 접혔다는 점이 핵심이다. 프로토타입은 “무엇을 만들지”의 앎을 없앤 게 아니다. 그 앎을 클래스 계층에서 인스턴스의 상태로 옮겼을 뿐이다. 그 대신 종류를 값처럼 다루게 되어, 컴파일 시점에 없던 종류(myStar)조차 등록하고 찍어낼 수 있게 됐다 — 전략 패턴이 알고리즘을 값으로 만들었듯, 프로토타입은 “만들 종류”를 값으로 만든다.
판단 기준: 리팩터링 후 “새 종류 추가 = 레지스트리에 견본 인스턴스 하나 등록, 새 클래스 0개”가 성립하면 프로토타입이 제대로 앉은 것이다. 함정: clone()을 구현했는데 여전히 클라이언트가 if (type) new Circle() else ...로 고르고 있다면, 클래스 폭발을 인스턴스 폭발로 옮겼을 뿐이다 — 선택을 레지스트리로 밀어내야 분기가 진짜 사라진다.
얕은 복사와 깊은 복사 — clone의 진짜 함정
프로토타입의 모든 무게는 clone() 한 메서드에 실린다. 그리고 여기가 이 패턴이 가장 자주, 가장 조용히 무너지는 자리다. 문제는 복제가 어디까지 파고드느냐다.
도형이 원시값(반지름, 색 문자열)만 들고 있으면 복제는 쉽다. 값을 그대로 베끼면 원본과 사본이 완전히 독립적이다. 그러나 도형이 다른 객체를 참조로 들고 있으면 갈림길이 생긴다. 그룹 도형이 자식 도형들의 리스트를 품는다고 하자.
public class Group implements Shape {
private List<Shape> children;
// 얕은 복사 — 리스트 '참조'만 베낀다. 원본과 사본이 같은 리스트를 공유한다.
public Shape clone() {
Group copy = new Group();
copy.children = this.children; // 위험: 같은 객체를 가리킨다
return copy;
}
}이렇게 하면 사본 그룹에 자식을 하나 추가하는 순간 원본 그룹에도 그 자식이 나타난다. 둘이 같은 리스트를 공유하기 때문이다. 프로토타입으로 찍어낸 도형들이 서로의 상태를 몰래 오염시키는, 재현하기 까다로운 버그가 여기서 태어난다. Object.clone()이 주는 기본 복제가 바로 이 얕은 복사라, 무심코 기대면 정확히 이 함정에 빠진다.
깊은 복사는 참조가 가리키는 객체까지 재귀적으로 복제한다.
// 깊은 복사 — 리스트를 새로 만들고, 자식들도 각자 clone 한다.
public Shape clone() {
Group copy = new Group();
copy.children = new ArrayList<>();
for (Shape child : this.children) {
copy.children.add(child.clone()); // 자식까지 독립 복제
}
return copy;
}이제 사본은 원본과 완전히 분리된다. 대신 대가가 있다. 복제가 객체 그래프 전체로 번지므로 비용이 크고, 순환 참조(A가 B를, B가 A를 가리킴)가 있으면 무한 재귀에 빠진다. 프로토타입을 쓰기로 했다면, 각 필드가 얕게 베껴도 되는 값인지 깊게 복제해야 하는 공유 객체인지를 필드마다 판단해야 한다 — 이 판단을 clone 안에 손으로 새기는 것이 프로토타입의 실제 구현 부담의 대부분이다.
판단 기준: 필드가 불변값이거나 의도적으로 공유해도 되는 것이면 얕게, 사본이 독립적으로 바뀌어야 하는 가변 객체면 깊게 복제한다 — 필드별로 갈라 판단한다. 함정: Object.clone() 하나에 다 맡기고 “복제됐다”고 안심하는 것. 그것은 얕은 복사라 가변 참조를 공유한 채로 두고, 오염은 한참 뒤 엉뚱한 곳에서 터진다.
결과 — 무엇을 얻고 무엇을 내주나
프로토타입이 사는 값은 런타임에 종류를 다루는 유연성이다. 세 가지가 따라온다. 첫째, 클라이언트가 구체 클래스에서 풀려난다 — 에디터는 Shape와 clone()만 알 뿐 사각형·원의 이름을 코드에 담지 않으므로, 도형 종류가 늘어도 에디터 코드는 그대로다. 둘째, 팩토리 서브클래스가 증발한다. 종류마다 팩토리 클래스를 파던 병렬 계층이, 프로토타입 인스턴스를 담은 레지스트리 하나로 접힌다. 셋째, 컴파일 시점에 없던 종류를 런타임에 등록하고 제거할 수 있다 — 사용자가 만든 도형, 플러그인이 실어 온 도형이 그냥 견본으로 등록되어 1급 종류가 된다. 종류가 값이 되었기 때문이다.
대가는 clone에 집중된다. 첫째, 모든 구체 클래스가 clone()을 옳게 구현해야 한다. 이게 말처럼 쉽지 않다. 앞 절에서 봤듯 얕은/깊은 복사를 필드마다 판단해야 하고, 순환 참조를 다뤄야 하며, 클래스에 새 가변 필드가 하나 추가될 때마다 clone도 함께 고쳐야 한다 — clone과 클래스 구조가 손으로 동기화되어야 하는 결합이 새로 생긴다. 이 동기화를 한 번 빠뜨리면, 새 필드가 얕게 공유된 채 오염 버그로 돌아온다. 둘째, 복제가 생성만큼 저렴하지 않을 수 있다. 깊은 복사는 객체 그래프 전체를 훑으므로, 무거운 객체라면 new로 새로 만드는 것보다 clone이 오히려 비쌀 수 있다. 셋째, 각 프로토타입은 복제 가능한 완성 상태로 미리 만들어져 유지되어야 하므로, 견본 자체를 초기화하고 관리하는 코드가 필요하다.
운영비용 렌즈로 보면 손익의 조건이 분명하다. 종류가 자주 추가·제거되고 런타임에 정해지는 축이라면, clone 구현 부담을 치르고도 “종류를 값처럼 다루는” 이득이 크다. 반대로 종류가 컴파일 시점에 고정되어 있다면, clone의 복잡성과 오염 위험만 떠안고 유연성은 쓰이지 않는다.
판단 기준: 얻는 것(구체 클래스 탈결합·팩토리 소멸·런타임 종류 등록)과 내주는 것(clone 구현·동기화 부담·복제 비용)을 세어 순이득이 양수일 때만 프로토타입을 앉힌다. 함정: “new를 없애면 좋다”는 막연한 믿음으로 clone을 도입하면, 정확히 값만 든 단순 객체에까지 깊은 복사 로직을 손으로 새기고 그 유지비를 영원히 문다.
언제 쓰고, 언제 과용인가
프로토타입이 정확히 맞는 자리는 이렇다. 생성할 종류가 런타임에 정해지거나 늘어날 때(플러그인으로 실려 오는 도형, 사용자가 저장한 커스텀 종류). 종류의 차이가 동작이 아니라 초기 상태뿐이라, 클래스를 새로 파는 대신 설정된 인스턴스 하나로 표현하는 게 자연스러울 때. 객체를 처음부터 조립하는 비용이 크고, 잘 세팅된 견본을 복제하는 편이 값쌀 때. 자바의 Object.clone()과 Cloneable, 그리고 직렬화를 통한 깊은 복사가 이 패턴의 언어 차원 지원이다.
과용의 냄새는 반대편에서 난다. 종류가 서넛으로 고정되어 있고 앞으로 늘 일이 없는데 “확장성”을 위해 미리 clone() 계층을 파는 것은, 오지 않을 변경에 거는 보험이면서 동시에 오염 버그의 씨앗을 심는 일이다. 만들 종류가 컴파일 시점에 다 알려져 있다면 팩토리 메서드 한두 개가 clone 계층보다 훨씬 싸고 안전하다. clone이 하는 일이 결국 new 한 줄과 필드 몇 개 복사뿐이라면, 그냥 생성자를 부르는 게 정직하다.
여기서 이웃 패턴과의 경계를 그어 둘 필요가 있다. 추상팩토리(Abstract Factory)와 프로토타입은 같은 문제 — “구체 클래스를 지목하지 않고 객체를 만든다” — 를 다른 수단으로 푸는 대안 관계다. 추상 팩토리는 제품군마다 팩토리 클래스를 두어 종류를 클래스 계층으로 표현하고, 프로토타입은 종류마다 견본 인스턴스를 두어 종류를 값으로 표현한다. 그래서 둘은 자주 함께 쓰인다 — 추상 팩토리의 구체 팩토리가 내부에 프로토타입 레지스트리를 품고, createProduct()가 견본을 복제해 돌려주는 식이다. 이러면 제품이 하나 늘 때 팩토리 서브클래스를 파는 대신 레지스트리에 견본만 등록하면 되어, 추상 팩토리의 고질병인 “새 제품 추가의 어려움”이 완화된다. 선택의 축은 이렇다 — 종류가 컴파일 시점에 고정이고 제품군 교체가 관심사면 추상 팩토리, 종류가 런타임에 늘고 각 종류가 설정된 상태로 식별되면 프로토타입이다.
판단 기준: 만들 종류가 컴파일 시점에 닫혀 있으면 팩토리 계열(팩토리 메서드·추상 팩토리), 종류가 런타임에 열려 있고 상태로 구별되면 프로토타입을 고른다. 함정: 프로토타입을 골라 놓고 clone을 얕게 구현해 견본과 사본이 상태를 공유하면, 복제한 모든 인스턴스가 하나의 견본을 통해 은밀히 연결된다 — 팩토리라면 아예 없었을 종류의 버그를 스스로 불러들인 것이다.
다음으로 싱글턴