← Posts

GoF · Creational · Prototype

Prototype

처음부터 만들지 않고, 이미 있는 객체를 복제해 새로 만든다. 얕은 복사의 함정과 clone()의 문제까지 다루는 마지막 생성 패턴.

목차
  1. 처음부터 다시 만들기가 아깝다
  2. 프로토타입 = 있는 걸 복제해 만든다
  3. 복제를 객체 자신에게 맡긴다
  4. 자바의 clone()과 Cloneable
  5. 얕은 복사의 함정
  6. 깊은 복사로 끊어낸다
  7. clone()보다 복사 생성자를 권한다
  8. 프로토타입 레지스트리
  9. 실무: 언제 쓰나
  10. 정리

처음부터 다시 만들기가 아깝다

이미 잘 설정된 객체가 하나 있다. 무거운 초기화를 거쳐 만들어진, 옵션이 잔뜩 채워진 문서 템플릿이라고 하자. 이제 이것과 거의 같은 걸 하나 더 만들어야 한다.

처음부터 다시 만들면, 그 무거운 초기화와 설정을 통째로 다시 해야 한다. 게다가 어떤 경우엔 정확한 클래스가 무엇인지 모르는데 예시 인스턴스는 손에 있는 상황도 있다. 이럴 때 “새로 만들기”보다 자연스러운 게 있다.

있는 걸 복제하는 것이다.

프로토타입 = 있는 걸 복제해 만든다

프로토타입 패턴은 이렇게 말한다.

새 객체를 클래스로부터 만들지 말고, 원본(프로토타입)을 복제해서 만들어라.

새로 조립하는 대신, 이미 원하는 상태인 객체를 하나 두고 그걸 복사한다. 복사본은 원본의 상태를 그대로 물려받아 시작하니, 무거운 초기화를 되풀이하지 않는다.

복제를 객체 자신에게 맡긴다

핵심은 복제하는 법을 객체 자신이 안다는 것이다. 밖에서 “이 객체가 무슨 클래스고 필드가 뭔지” 캐물어 베끼는 게 아니라, 객체에게 “너를 복제해줘”라고 하면 자기가 자기 사본을 만들어 준다.

그래서 프로토타입에는 보통 clone() 같은 복제 메서드가 있다. 부르는 쪽은 원본의 구체 타입을 몰라도, 그 복제 메서드만 부르면 같은 종류의 사본을 얻는다.

자바의 clone()과 Cloneable

자바는 이걸 위한 장치를 기본 제공한다. Object.clone()Cloneable 인터페이스다.

java
public class Document implements Cloneable {
    private String title;
    private List<String> tags;

    @Override
    public Document clone() {
        try {
            return (Document) super.clone();   // 필드를 그대로 복사
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
}

super.clone()이 필드를 하나씩 복사해 사본을 만든다. 편해 보인다. 그런데 여기에 함정이 하나 숨어 있다.

얕은 복사의 함정

super.clone()은 필드를 그대로 복사한다. 문제는 tags 같은 참조 필드다. 참조를 복사하면, 원본과 사본이 같은 리스트를 가리킨다.

diagramdiagram

사본의 tags에 값을 하나 추가하면, 같은 리스트를 보던 원본의 tags도 바뀐다. 겉보기엔 복제했는데 속은 이어져 있는 것이다. 이렇게 껍데기만 복사하고 내부 참조는 공유하는 것을 **얕은 복사(shallow copy)**라고 한다.

깊은 복사로 끊어낸다

고침은 내부 객체까지 복제해 연결을 끊는 것이다. 이걸 **깊은 복사(deep copy)**라고 한다.

java
@Override
public Document clone() {
    try {
        Document copy = (Document) super.clone();
        copy.tags = new ArrayList<>(this.tags);   // 내부 리스트도 새로 만든다
        return copy;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

이제 사본은 자기만의 tags 리스트를 가진다. 한쪽을 바꿔도 다른 쪽은 안 흔들린다. 복제할 때 어디까지 복사할지를 정하는 것이 프로토타입 구현의 진짜 일이다. 중첩이 깊으면 그 안까지 다 복제해야 한다.

clone()보다 복사 생성자를 권한다

사실 자바에서 Cloneable/clone()잘 설계됐다고 보지 않는다. Cloneable은 메서드도 없는 표시용 인터페이스인데 Object.clone()의 동작을 바꾸고, 예외 처리도 지저분하며, final 필드와 잘 안 맞는다.

그래서 실무에선 대개 복사 생성자복사 팩토리를 쓴다.

java
public Document(Document other) {         // 복사 생성자
    this.title = other.title;
    this.tags = new ArrayList<>(other.tags);
}

// 사용: Document copy = new Document(original);

프로토타입의 정신(“있는 걸 복제한다”)은 그대로다. 복제 수단만 clone()이 아니라 생성자로 바꾼 것이다. 읽기 쉽고, 무엇을 깊게 복사할지 명시적이며, 예외도 없다.

프로토타입 레지스트리

프로토타입을 여러 종류 쓸 땐, 미리 만들어 둔 견본들을 등록해두고 키로 꺼내 복제하기도 한다. 이걸 프로토타입 레지스트리(또는 매니저)라고 한다.

java
public class ShapeRegistry {
    private final Map<String, Shape> prototypes = new HashMap<>();

    public void register(String key, Shape prototype) {
        prototypes.put(key, prototype);
    }
    public Shape create(String key) {
        return prototypes.get(key).clone();   // 견본을 복제해 새 것을
    }
}

registry.create("circle")을 부르면, 등록해둔 원 견본을 복제한 새 원이 나온다. 새 종류를 더하고 싶으면 견본을 하나 등록하면 되고, 만드는 코드는 안 바뀐다.

실무: 언제 쓰나

프로토타입이 값을 하는 자리는 분명하다.

  • 생성 비용이 클 때 - 무거운 초기화를 되풀이하는 대신 복제한다.
  • 런타임에 정확한 타입을 모를 때 - 예시 인스턴스만 있으면, 그 클래스를 몰라도 복제로 같은 종류를 얻는다.
  • 현재 상태 그대로의 사본이 필요할 때 - 어느 순간의 객체를 복사해 두고 원본은 계속 쓰는 경우.

다만 함정을 잊으면 안 된다. 얕은 복사로 인한 공유는 조용한 버그를 만든다. 그래서 프로토타입을 쓸 땐 늘 “어디까지 깊게 복사해야 하는가”를 먼저 따진다.

정리

프로토타입이란새로 만들지 않고 원본을 복제해 객체를 만든다
생성 비용을 아끼고, 타입을 몰라도 같은 종류를 얻는다
얕은 복사껍데기만 복사, 내부 참조는 공유 - 한쪽을 바꾸면 다른 쪽도
깊은 복사내부 객체까지 복제해 연결을 끊는다
clone() 대신복사 생성자·복사 팩토리가 더 낫다 (Cloneable은 깨진 설계)
레지스트리견본들을 등록해두고 키로 꺼내 복제

처음부터 조립하지 않고, 이미 원하는 모습인 것을 본떠 시작하는 것. 대신 어디까지 진짜로 복제되는지를 늘 확인해야 하는 것 - 그게 프로토타입이다.

관련 글