프로토타입 패턴, 객체를 복제할 때 얕은 복사의 함정
프로토타입 패턴은 GoF의 생성 디자인 패턴으로, 생성자로 새로 만드는 대신 기존 객체를 복제(clone)해 객체를 생성한다. 얕은 복사는 내부 참조를 공유해 원본까지 오염되므로, 중첩 구조는 structuredClone 같은 깊은 복사로 떼어내야 한다.
프로토타입 패턴은 생성자로 객체를 새로 찍어내는 대신, 미리 만들어 둔 표본 객체(프로토타입)를 복제해 새 객체를 만드는 생성 방식이다. 객체지향 설계의 고전 『Design Patterns』를 쓴 저자 4인을 가리키는 GoF(Gang of Four)가 정리한 23개 디자인 패턴에서 생성(creational) 패턴으로 분류된다. 이름이 곧 핵심이라 원리는 단순하지만, 실무에서 발목을 잡는 지점은 따로 있다. 복제가 얕은 복사냐 깊은 복사냐다. 이 글은 Java와 JavaScript 예시를 기준으로 패턴의 동작 원리와 이 복사 깊이 문제만 다룬다. 직렬화 라이브러리 비교나 성능 측정은 범위 밖이다.
왜 new 대신 복제하는가
객체를 처음부터 구성하는 비용이 클 때가 첫 번째 이유다. 데이터베이스를 조회하거나 설정 파일을 파싱해 필드를 채워야 하는 객체라면, 같은 형태가 여러 개 필요할 때마다 그 과정을 반복하는 건 낭비다. 예를 들어 원격 API 응답 여러 개를 병합해 만든 기본 설정 객체가 있다고 하자. 요청마다 이 병합을 다시 하는 대신, 완성된 설정 하나를 프로토타입으로 두고 복제한 뒤 달라지는 값만 덮어쓰면 된다. 한 번 완성한 객체를 원본으로 두고 복제하면 무거운 초기화를 건너뛰는 셈이다.
두 번째는 런타임 유연성이다. 어떤 구체 클래스를 만들지 컴파일 시점에 못 박지 않고, 실행 중 확보한 객체를 복제하면 팩터리 계층을 서브클래싱으로 늘리지 않고도 새 종류를 추가할 수 있다. GoF가 프로토타입을 팩터리 메서드·추상 팩터리와 같은 생성 패턴으로 묶은 이유가 여기 있다. 셋 다 new를 직접 호출하는 코드를 걷어내는 문제를 푼다. 세 패턴이 어떻게 갈라지는지는 Refactoring.Guru의 프로토타입 패턴 문서가 UML 구조와 함께 비교해 두었다.
얕은 복사와 깊은 복사는 무엇이 다른가
얕은 복사는 객체의 필드 값만 그대로 복사한다. 필드가 참조, 즉 다른 객체의 주소라면 복제본과 원본이 같은 내부 객체를 가리킨다. 반면 깊은 복사는 참조가 가리키는 객체까지 새로 만들어 원본과 완전히 떼어낸다. 말로는 사소해 보여도 코드에서는 바로 사고로 이어진다.
const original = { name: "설정", tags: ["dev", "test"] };
const shallow = { ...original }; // 얕은 복사
shallow.tags.push("prod"); // original.tags까지 바뀐다여기서 tags는 배열, 곧 참조 타입이다. 스프레드 연산자(...)는 최상위 필드만 복사하므로 shallow.tags와 original.tags는 같은 배열을 가리킨다. 복제본에 push하면 원본까지 오염된다. 프로토타입 패턴을 얕은 복사로만 구현했을 때 가장 흔히 터지는 버그가 바로 이것이다. 중첩된 구조를 원본과 독립적으로 떼어내려면 깊은 복사가 필요하다. 포인터로 이어진 연결 리스트 같은 자료구조를 복제할 때도 노드를 하나하나 새로 만들어 이어붙여야 원본과 별개인 사본이 된다.

언어별 복제 관용구: Java와 JavaScript
복제를 지원하는 방식은 언어마다 다르다. Java는 Object 클래스의 clone() 메서드와 Cloneable 인터페이스를 쓴다. Cloneable은 메서드가 하나도 없는 마커 인터페이스로, 이 클래스가 복제를 허용한다는 표식 역할만 한다. Cloneable을 구현하지 않은 객체에서 clone()을 호출하면 CloneNotSupportedException이 발생한다. 그리고 Object.clone()이 기본으로 수행하는 동작은 얕은 복사다. 필드를 값 그대로 옮길 뿐이라, 참조 필드까지 분리하려면 clone() 안에서 직접 깊은 복사를 해줘야 한다.
class Config implements Cloneable {
@Override
public Config clone() throws CloneNotSupportedException {
return (Config) super.clone(); // 필드 단위 얕은 복사
}
}JavaScript에는 깊은 복사를 위한 전역 함수 structuredClone()이 있다. MDN의 structuredClone 문서에 따르면 이 함수는 최신 브라우저와 Node.js 17 이상 런타임에서 전역으로 제공되며, 객체 그래프를 재귀적으로 복제한다. 다만 함수는 복제하지 못하고 이때 DataCloneError를 던진다. 메서드를 품은 객체를 통째로 복제하려는 용도에는 맞지 않는다는 뜻이다.
const deep = structuredClone(original);
deep.tags.push("prod"); // original.tags는 그대로도입 전에 따져볼 것
복제가 실제로 이득인 경우는 둘로 좁혀진다. 초기화가 무겁고 같은 형태가 반복해서 필요할 때, 그리고 런타임에 정해지는 여러 변형을 하나의 원본에서 파생시켜야 할 때다. 게임에서 같은 몬스터 수백 마리를 조금씩 다르게 찍어내거나, 그래픽 편집기에서 도형 하나를 복사해 여러 개로 늘리는 상황이 대표적인 적용 사례다. 반대로 필드가 몇 개뿐이고 생성자가 가벼우면 그냥 new로 만드는 편이 읽기 쉽고 얕은·깊은 복사 실수도 없다.
패턴을 넣기 전에 이 객체의 복제 규칙을 한 곳에서 관리할 필요가 있는지부터 묻는 게 순서다. 복사 로직이 여러 clone() 구현에 흩어지면 필드를 추가할 때마다 그 로직을 함께 손봐야 하는 유지보수 부담이 따라온다. 원리는 단순한 패턴이지만, 복사 깊이를 통제하지 못하면 오히려 찾기 힘든 버그의 원천이 된다.





