본문 바로가기

Design Pattern

Builder + Prototype

헤드 퍼스트 디자인 패턴에서 빌더, 프로토타입, 싱글턴 패턴을 정리한 글이다.

 

싱글턴 패턴은 분량이 좀 있어서 다음 블로그에 작성했다.
 
세 패턴은 모두 '객체를 어떻게 만들 것인가'를 다루는 생성 패턴이다. 

처음엔 각각 따로 외웠는데, 하나로 묶어서 보니 차이가 훨씬 잘 보였다.
 
· 빌더 : 단계별로 조립해서 만든다
· 프로토타입 : 이미 있는 걸 복사해서 만든다
· 싱글턴 : 딱 하나만 만든다

 


 
 
■ 1. 빌더 패턴 : 단계별로 조립한다
 
▶ 한 줄 요약
 
복잡한 객체를 한 번에 만들지 않고, 여러 단계로 나눠 조립한 다음 마지막에 완성품을 받는 패턴이다.
 
서브웨이 주문을 떠올리면 쉽다. 빵을 고르고, 치즈를 고르고, 야채를 넣거나 빼고, 소스를 고른 다음 마지막에 샌드위치를 받는다. 사람마다 조합은 다 다르지만, 단계별로 고르고 마지막에 받는다는 방식은 똑같다.
 
여기서 손님이 클라이언트, 샌드위치를 만들어 주는 직원이 빌더다.
 
 
▶ 문제 상황 : 휴가 계획표
 
책의 시나리오는 테마파크에서 고객에게 휴가 계획표를 만들어 주는 프로그램이다.
 
계획표는 날짜별로 나뉘고, 날마다 호텔, 입장권, 식사, 특별 이벤트를 자유롭게 조합할 수 있다.
 
· 근처에 사는 손님 : 호텔 없이 저녁 식사와 이벤트만
· 멀리서 온 관광객 : 호텔, 입장권, 식사까지 전부
 
이걸 생성자 하나로 만들려고 하면 이런 코드가 나온다. (책에는 없는 코드인데, 문제를 보여 주려고 만들어 봤다)
 

new Vacation(day1, "호텔A", null, "디너", null, day2, null, "입장권", ...);


문제는 세 가지다.
 
· 어떤 값이 어떤 자리인지 알아보기 어렵다
· 안 쓰는 항목은 null로 채워야 한다
· 일정이 3일, 5일로 늘어나면 대응할 수가 없다
 

 
▶ 해결 : 만드는 일을 빌더에게 맡긴

 

책에서 던지는 질문도 결국 이거다.
복잡한 계획표 자체와, 그걸 만드는 과정이 섞이지 않게 하려면 어떻게 해야 할까?

 


계획표를 만드는 과정을 빌더 객체에 캡슐화하고, 클라이언트는 빌더에게 단계별로 요청만 한다.
 
먼저 만드는 단계를 정의한 추상 빌더다.

public abstract class AbstractBuilder {
    abstract void buildDay(LocalDate date);
    abstract void addHotel(LocalDate date, String hotel);
    abstract void addTickets(String event);
    abstract void addSpecialEvent(String event);
    abstract Planner getVacationPlanner();
  }

 

 


실제로 조립하는 구체 빌더다. 계획표를 어떤 구조로 저장할지는 이 클래스만 안다.

public class VacationBuilder extends AbstractBuilder {
    private Vacation vacation = new Vacation();
 
    void buildDay(LocalDate date) {
      vacation.addDay(date);
    }
 
    void addHotel(LocalDate date, String hotel) {
      vacation.getDay(date).add(new Hotel(hotel));
    }
 
    // addTickets(), addSpecialEvent()도 같은 방식
 
    Planner getVacationPlanner() {
      return new Planner(vacation);
    }
  }


 
클라이언트는 이렇게 쓴다.

builder.buildDay(date);
  builder.addHotel(date, "Grand Facadian");
  builder.addTickets("Patterns on Ice");
  // 필요한 만큼 단계를 반복
 
  Planner yourPlanner = builder.getVacationPlanner();


 
아까의 긴 생성자와 비교하면 차이가 확 보인다. 각 줄이 무슨 일을 하는지 바로 읽히고,

필요 없는 항목은 그냥 호출하지 않으면 된다. 일정이 길어지면 buildDay()를 더 부르면 그만이다.


 
 
▶ 등장인물
 
· Client : 빌더에게 단계별로 만들어 달라고 요청하는 쪽
· AbstractBuilder : 어떤 단계가 있는지 정해 둔 인터페이스
· VacationBuilder : 실제로 제품을 조립하는 구체 빌더
· Vacation(Planner) : 완성될 복합 객체, 즉 제품
 
여기서 중요한 건 클라이언트가 계획표의 내부 구조를 전혀 모른다는 점이다. 

실제로 계획표는 '휴가 → 날짜 → 호텔, 입장권, 식사' 같은 트리 구조인데, 클라이언트는 그게 트리인지 리스트인지 몰라도 된다.
 
책에서 빌더를 설명하며 반복자 패턴을 다시 꺼내는 이유도 여기 있다. 반복자가 컬렉션의 내부 구조를 숨기고 반복 작업만 캡슐화했듯이, 빌더는 제품의 내부 구조를 숨기고 만드는 작업만 캡슐화한다.
 
 
▶ 책에서 생략된 부분 : Director
 
공부하다 알게 된 건데, GoF 원전의 빌더 패턴에는 책 그림에 없는 등장인물이 하나 더 있다. Director(감독)다.
 
GoF는 빌더 패턴을 이렇게 정의한다.
 

 

 

"복잡한 객체의 생성 과정과 표현을 분리해서, 같은 생성 과정으로 서로 다른 표현을 만들 수 있게 한다."

 

 

Director는 빌더의 단계를 어떤 순서로 호출할지 정하는 역할이다. 헤드 퍼스트 그림에서는 Client의 constructPlanner()가 이 역할을 겸하고 있다고 보면 된다.
 
처음엔 "같은 생성 과정으로 서로 다른 표현"이라는 말이 와닿지 않았는데, 예를 들어 보니 이해가 됐다. 호출 순서는 그대로 두고 빌더만 바꿔 끼운다고 해 보자.
 
· WebPlannerBuilder를 넣으면 → 웹 화면용 계획표
· PdfPlannerBuilder를 넣으면 → 인쇄용 PDF 계획표
 

 


 
만드는 순서(생성 과정)는 똑같은데 결과물의 형태(표현)만 달라진다. 이게 "생성 과정과 표현을 분리한다"는 말의 의미였다.
 
 
▶ 실무에서 말하는 빌더는 조금 다르다
 
요즘 자바 개발자가 "빌더 패턴"이라고 하면 대부분 이런 코드를 떠올린다.

  User user = User.builder()
      .name("김철수")
      .age(25)
      .email("kim@test.com")
      .build();

 
이건 『이펙티브 자바』에서 널리 알려진 방식이고, Lombok의 @Builder도 이 형태다. 생성자 매개변수가 많을 때 코드를 읽기 쉽게 만드는 게 주목적이다.
 
· GoF 빌더 : 복잡한 생성 과정을 캡슐화하고, 빌더를 교체해 다른 결과물을 만든다
· 이펙티브 자바 빌더 : 매개변수가 많은 객체를 읽기 좋고 안전하게 만든다
 
단계별로 값을 넣고 마지막에 완성품을 받는다는 점은 같지만 목적이 다르다. 

누가 "빌더 패턴 써 봤어?"라고 물으면, 둘 중 어느 쪽 얘기인지 먼저 맞춰 보는 게 좋겠다는 생각이 들었다.
 
 
▶ 장점과 단점
 
장점 (책 기준)
· 복합 객체를 만드는 과정을 캡슐화한다
· 여러 단계와 다양한 절차를 거쳐 객체를 만들 수 있다
· 제품의 내부 구조를 클라이언트로부터 숨길 수 있다
· 클라이언트는 추상 인터페이스만 보기 때문에, 제품을 만드는 구현을 쉽게 바꿀 수 있다
 
단점
· 클라이언트가 단계를 직접 호출하니, 제품 구조를 어느 정도는 알아야 한다
· 호출 순서를 잘못 쓸 수 있다. buildDay() 없이 addHotel()부터 부르면 어떻게 될지는 빌더가 따로 막아 줘야 한다
· 단순한 객체에 쓰면 클래스만 늘어나는 과설계가 된다
 
 
▶ 언제 쓰면 좋을까
 
· 객체를 만드는 과정이 여러 단계로 나뉠 때
· 구성 요소의 조합이 다양할 때 (휴가 계획표, 문서, 주문서 같은 것)
· 같은 과정으로 여러 형태의 결과물을 만들어야 할 때
· 트리처럼 복잡한 구조의 객체를 만들 때
 
반대로 필드가 서너 개뿐인 단순한 객체라면 그냥 생성자로 충분하다. 

결국 "이 객체가 단계적으로 조립해야 할 만큼 복잡한가?"를 먼저 따져 보는 게 맞는 것 같다.

 


 

■ 2. 프로토타입 패턴 : 복사해서 만든다

 


 
▶ 한 줄 요약
 
인스턴스를 처음부터 만드는 게 비싸거나 복잡할 때, 이미 만들어 둔 인스턴스를 복사해서 새 인스턴스를 얻는 패턴이다.
 
문서 양식을 떠올리면 쉽다. 보고서를 쓸 때마다 빈 문서에 글꼴, 여백, 표 서식을 하나하나 잡지 않는다. 잘 만들어 둔 양식 파일을 복사해서 내용만 바꿔 쓴다. 여기서 양식 파일이 프로토타입(원형)이다.
 
 
▶ 문제 상황 : 몬스터 만들기
 
책의 시나리오는 롤플레잉 게임이다. 히어로가 동적으로 만들어지는 지형을 따라 여행하면 끊임없이 몬스터가 나타난다.
 
여기에 두 가지 요구사항이 붙는다.
 
· 주변 환경에 맞는 몬스터가 나와야 한다 (물속인데 새 모양 몬스터가 쫓아오면 이상하니까)
· 헤비 유저가 직접 몬스터를 만들 수 있어야 한다
 
몬스터 종류가 늘어날수록 문제가 생긴다. 몬스터마다 이름, 체력, 공격력, 스킬, 외형 같은 상태가 잔뜩 있는데, 생성자에 이걸 매번 다 넘겨서 만드는 방식은 점점 감당하기 어려워진다. 특히 유저가 만든 몬스터는 코드에 클래스로 미리 정의되어 있지도 않다.
 
책에 나오는 고민도 이거다. 몬스터를 만드는 과정을 처리하는 코드와, 즉석에서 몬스터를 생성하는 코드를 분리하고, 생성 과정을 한곳에 캡슐화할 수 없을까?
 
 
▶ 해결 : 원본을 보관해 두고 복제한다
 
미리 설정이 끝난 몬스터 원본들을 레지스트리에 보관해 두고, 필요할 때 상황에 맞는 원본을 찾아서 복제본을 돌려준다.
 
먼저 모든 몬스터가 따르는 인터페이스다. 자기 자신을 복제하는 메소드를 갖고 있다.
 

  public interface Monster {
    Monster copy();
  }


 
몬스터 원본들을 보관하는 레지스트리다.

  public class MonsterRegistry {
    private static Map<String, Monster> prototypes = new HashMap<>();
 
    public static void register(String terrain, Monster monster) {
      prototypes.put(terrain, monster);
    }
 
    public static Monster getMonster(String terrain) {
      Monster correctMonster = prototypes.get(terrain);   // 적당한 원본을 찾음
      return correctMonster.copy();   // 복제본을 돌려줌
    }
  }


 
클라이언트는 이렇게 쓴다.

 public class MonsterMaker {
    public Monster makeRandomMonster(String terrain) {
      return MonsterRegistry.getMonster(terrain);
    }
  }


 
(책 그림은 getMonster()를 매개변수 없이 간단하게 보여 주는데, 이해를 위해 지형 이름을 넘기는 형태로 바꿔 봤다)

 


 
 
▶ 이 패턴의 핵심
 
책에서 가장 강조하는 특징은 이거다. 

클라이언트가 어떤 클래스의 인스턴스를 만드는지 전혀 모르는 상태에서도 새 인스턴스를 만들 수 있다.
 
MonsterMaker 코드를 다시 보면 new가 하나도 없고, 구체적인 몬스터 클래스 이름도 하나도 안 나온다. 받아 온 게 잘 알려진 몬스터(WellKnownMonster)인지, 유저가 만든 몬스터(DynamicPlayerGeneratedMonster)인지도 모른다. 

그냥 "지금 지형에 맞는 몬스터 줘"라고 할 뿐이다.
 
유저가 새 몬스터를 만들면 그 몬스터를 레지스트리에 등록만 하면 된다. 그 순간부터 게임은 그 몬스터를 다른 몬스터와 똑같이 복제해서 쓸 수 있다. 클래스를 새로 만들 필요가 없다는 게 이 패턴의 큰 장점이다.
 
 
▶ 등장인물
 
· Prototype (Monster) : 자기 자신을 복제하는 메소드를 정의한 인터페이스
· ConcretePrototype (WellKnownMonster, DynamicPlayerGeneratedMonster) : 실제로 자신을 복제하는 클래스
· Registry (MonsterRegistry) : 원본들을 보관하고, 요청이 오면 복제본을 돌려주는 곳
· Client (MonsterMaker) : 복제본을 받아서 쓰는 쪽
 
 
▶ 복사할 때 주의할 점 : 얕은 복사와 깊은 복사
 
책에서는 단점으로 "객체의 복사본을 만드는 일이 매우 복잡할 수도 있다"고만 짧게 말하는데, 그 이유가 바로 이거다.
 
몬스터가 스킬 목록처럼 다른 객체를 필드로 갖고 있다고 해 보자.
 

  public class WellKnownMonster implements Monster {
    String name;
    int hp;
    List<String> skills;
 
    public Monster copy() {
      WellKnownMonster m = new WellKnownMonster();
      m.name = this.name;
      m.hp = this.hp;
      m.skills = this.skills;   // 리스트를 그대로 넘김
      return m;
    }
  }


 
이렇게 복사하면 name, hp는 따로 복사되지만, skills는 리스트 자체가 아니라 리스트를 가리키는 주소만 복사된다. 원본과 복제본이 같은 리스트를 함께 쓰게 되는 것이다. 이걸 얕은 복사(shallow copy)라고 한다.
 
그래서 복제본 몬스터에 스킬 하나를 추가하면, 원본 몬스터와 다른 모든 복제본에도 그 스킬이 생겨 버린다. 레지스트리에 보관한 원본까지 바뀌니, 그 뒤로 나오는 몬스터는 전부 이상해진다.
 
해결하려면 안에 있는 객체까지 새로 만들어서 복사해야 한다. 이걸 깊은 복사(deep copy)라고 한다.
 

m.skills = new ArrayList<>(this.skills);   // 리스트도 새로 만들어서 복사


 
문제는 객체 안에 객체가 있고, 그 안에 또 객체가 있으면 어디까지 깊게 복사해야 할지 하나하나 따져야 한다는 점이다. 책이 "복사본을 만드는 일이 복잡할 수 있다"고 말한 게 이런 의미다.
 
문서 양식으로 비유하면, 양식 파일을 복사했는데 거기 들어 있는 표가 원본 파일과 연결된 '링크'였다면? 

복사본의 표를 고치는 순간 원본 양식의 표도 같이 바뀌어 버린다. 얕은 복사가 딱 이런 상황이다.
 
 
▶ 자바의 clone()은 조심해서 쓰자
 
책에서는 자바에서 복사할 때 clone() 메소드를 쓰거나 역직렬화를 쓰면 된다고 소개한다.
 
다만 자바의 clone()은 쓰기가 생각보다 까다롭다. Cloneable 인터페이스를 구현해야 하고, 기본 동작이 얕은 복사라서 위에서 본 문제가 그대로 생긴다. 예외 처리도 번거롭다. 그래서 『이펙티브 자바』에서는 clone()을 재정의할 때 주의하라고 하면서, 대신 복사 생성자나 복사 팩토리 메소드를 쓰는 방법을 권한다.
 

  public WellKnownMonster(WellKnownMonster original) {   // 복사 생성자
    this.name = original.name;
    this.hp = original.hp;
    this.skills = new ArrayList<>(original.skills);
  }


 
이 글의 예제에서 clone() 대신 copy()라는 메소드를 직접 만든 것도 그래서다. 프로토타입 패턴의 핵심은 "복제해서 만든다"는 아이디어지, clone()이라는 특정 메소드가 아니다.


 
▶ 장점과 단점
 
장점 (책 기준)
· 클라이언트는 새 인스턴스를 만드는 과정을 몰라도 된다
· 클라이언트는 구체적인 형식을 몰라도 객체를 만들 수 있다
· 상황에 따라 객체를 새로 만드는 것보다 복사하는 게 더 효율적일 수 있다
 
단점
· 복사본을 만드는 일이 복잡할 수 있다 (얕은 복사, 깊은 복사 문제)
· 순환 참조처럼 객체끼리 서로를 가리키는 구조라면 복사가 더 까다로워진다
 
 
▶ 언제 쓰면 좋을까
 
· 객체를 처음부터 만드는 비용이 클 때 (DB 조회, 파일 읽기, 복잡한 계산이 필요한 초기화)
· 설정만 조금씩 다른 비슷한 객체를 많이 만들어야 할 때
· 실행 중에 새로운 종류의 객체가 생길 때 (유저가 만든 몬스터처럼 클래스로 미리 정의할 수 없는 경우)
· 복잡한 클래스 계층구조에 묻혀 있는 다양한 객체를 새로 만들어야 할 때
 
반대로 객체가 단순하고 만드는 비용도 적다면 그냥 new로 만드는 게 훨씬 낫다. 복사 로직을 관리하는 부담이 오히려 더 크다.
 
 
▶ 빌더와 비교하면
 
· 빌더 : 빈손에서 시작해서 단계별로 조립한다
· 프로토타입 : 이미 완성된 원본을 통째로 복사한다
 
서브웨이에서 재료를 하나씩 고르는 게 빌더라면, "아까 그 사람이랑 똑같이 주세요"라고 하는 게 프로토타입이다.