문서 하나를 여러 포맷으로 내보내는 변환기를 짠다고 하자. 입력은 하나의 문서 트리(문단, 글자, 이미지, 표)이고, 출력은 RTF일 수도 평문일 수도 조판용 TeX일 수도 있다. 문서를 처음부터 끝까지 훑는 순회 절차는 어느 포맷이든 똑같다 — 문단을 만나면 문단을 처리하고, 글자를 만나면 글자를 처리하고, 표를 만나면 표를 처리한다. 달라지는 것은 각 요소를 만났을 때 무엇을 찍어 내느냐뿐이다. RTF는 제어 문자를 두른 마크업을, 평문은 글자만, TeX는 조판 명령을 뱉는다.
가장 단순하게 짜면 이 순회 루프 안에 if (format == RTF) ... else if (format == PLAIN) ...를 요소마다 심게 된다. 포맷이 하나 늘 때마다 순회 코드 전체를 다시 열어 분기를 흩뿌려야 하고, 순회 로직과 출력 로직이 한 덩어리로 엉겨 어느 쪽도 홀로 못 바뀐다. 빌더(Builder) 패턴은 이 “훑는 일”과 “찍어 내는 일”을 갈라놓는다. 순회는 한 곳(Director)에 고정하고, 요소마다 무엇을 만들지는 밖에서 갈아 끼우는 객체(Builder)에게 맡긴다. 같은 순회로 다른 표현이 나온다.
무엇이 문제인가 — 동기
빌더가 답하는 아픔은 사실 두 얼굴이다. 원서가 든 문서 변환이 하나고, 현장에서 더 자주 만나는 거대 생성자가 다른 하나다. 둘은 같은 병의 다른 증상이다 — 복합 객체를 조립하는 절차가 조립 결과와 뒤엉켜 있다.
첫째 얼굴, 표현이 순회에 눌어붙는다. 문서 변환에서 순회 절차는 불변인데, 출력 포맷이 그 절차 안에 하드코딩되면 포맷을 하나 더 지원할 때마다 잘 돌던 순회 코드를 다시 건드려야 한다. 순회와 표현이 한 몸이라 포맷별로 코드를 통째 복제하거나, 요소마다 분기를 심어 순회 루프를 누더기로 만든다. “무엇을 어떤 순서로 만드는가”와 “각 부분을 어떤 재료로 만드는가”가 분리되지 않은 탓이다.
둘째 얼굴, 생성자 인자가 폭발한다. Pizza(dough, sauce, cheese, pepperoni, mushroom, onion, olive, ...) 처럼 선택적 필드가 많은 객체를 만들 때, 자바에는 이름 있는 인자가 없으니 흔히 텔레스코핑 생성자(Telescoping Constructor)로 도망친다 — 인자 두 개짜리, 네 개짜리, 여섯 개짜리 생성자를 줄줄이 오버로딩하는 것이다. 호출부는 new Pizza(true, false, true, false, true)가 되어 다섯 번째 true가 무슨 뜻인지 아무도 모르고, 안 쓰는 자리마다 null과 기본값을 채워 넣어야 한다. 필드가 하나 늘면 생성자 계단이 하나 더 자란다.
두 얼굴을 관통하는 진짜 문제는 이것이다. 여러 부분으로 이뤄진 객체를 만드는 과정에는 순서와 조건이 있는데, 그 과정 전체가 클라이언트 코드나 하나의 거대 생성자에 노출되어 있다. 조립 절차가 밖으로 새면, 절차를 바꾸는 일과 결과물의 종류를 바꾸는 일이 같은 자리에서 충돌한다.
판단 기준: 객체가 여러 부분으로 구성되고 그 조립에 순서·조건이 있으며, 같은 조립으로 여러 표현을 뽑거나 선택적 필드가 많다면 빌더를 의심한다. 함정: 필드 두세 개짜리 단순한 값 객체까지 빌더로 감싸면, 생성자 한 줄이면 될 것을 클래스 하나·메서드 여러 개로 부풀린 것이다.
패턴의 구조 — 누가 무엇을 하나
빌더는 네 배역으로 이뤄진다. 핵심은 “만드는 순서를 아는 자”와 “부분을 만드는 자”의 분리다.
Builder(빌더 인터페이스). 복합 객체의 각 부분을 만드는 단계들을 계약으로 선언한다. 문서 변환이라면 buildParagraph(), buildCharacter(char), buildTable() 같은 것들. 이 인터페이스는 “무엇을 만들 수 있는가”의 목록일 뿐, 어떤 표현으로 만드는지는 말하지 않는다.
ConcreteBuilder(구체 빌더들). 인터페이스의 각 단계를 특정 표현으로 구현한다. RtfBuilder는 buildParagraph()에서 RTF 마크업을, PlainBuilder는 글자만, TexBuilder는 조판 명령을 쌓는다. 각 빌더는 자기가 쌓아 온 결과물을 내부에 들고 있다가 getResult()로 내준다. 결정적으로, 구체 빌더는 자기 부분을 만드는 법만 알 뿐 전체 조립 순서는 모른다.
Director(감독). 조립 절차를 아는 쪽이다. 문서를 순회하며 요소를 만날 때마다 빌더의 알맞은 단계를 순서대로 호출한다 — builder.buildParagraph(), 그다음 builder.buildCharacter(c)… Director는 자기가 어떤 구체 빌더를 부리는지 모른다. 인터페이스 뒤에 가려져 있기 때문이다. 그래서 같은 Director에 다른 빌더를 넣으면, 같은 순회 절차가 다른 표현을 낳는다.
Product(결과물). 조립이 끝나 나오는 복합 객체. 여기서 미묘한 점 하나 — 여러 구체 빌더의 결과물은 서로 공통 타입일 필요가 없다. RTF 텍스트(String)와 화면에 렌더된 위젯 트리(객체)는 타입이 딴판이다. 그래서 getResult()는 Builder 인터페이스가 아니라 구체 빌더에만 두는 게 보통이다 — 결과물의 타입이 제각각이라 공통 인터페이스로 묶을 수 없기 때문이다.
협력의 핵심 지점은 Director가 순서를 쥐고, 빌더가 부분을 쥔다는 분업이다. 순회 절차를 바꾸고 싶으면 Director를, 표현을 바꾸고 싶으면 빌더를 건드린다. 두 변경 축이 두 자리로 갈라졌다.
판단 기준: 조립 순서와 부분 표현이 서로 다른 속도로 변하면 Director-Builder로 축을 가른다. 함정: 순서와 표현이 늘 같이 바뀐다면 둘을 가른 대가만 치를 뿐이다 — 억지로 Director를 뽑지 말 것.
코드로 — 패턴이 자라나는 과정
거대 생성자에서 출발해 빌더로 단계 조립을 만들고, 마지막에 Director로 조립 절차까지 뽑아내는 과정을 따라간다.
public class Report { private final String title; private final String author; // 선택 private final String header; // 선택 private final String footer; // 선택 private final boolean pageNumber;// 선택 private final boolean toc; // 선택 (목차) public Report(String title) { this(title, null); } public Report(String title, String author) { this(title, author, null, null); } public Report(String title, String author, String header, String footer) { this(title, author, header, footer, false, false); } public Report(String title, String author, String header, String footer, boolean pageNumber, boolean toc) { this.title = title; this.author = author; this.header = header; this.footer = footer; this.pageNumber = pageNumber; this.toc = toc; }}// 호출부: new Report("결산", null, null, "ⓒ2026", true, false)// 세 번째 null이 header인지 footer인지, 다섯째 true가 무엇인지 세어 봐야 안다.// 필드가 하나 늘면 생성자 계단이 하나 더 자란다.
두 번째 스텝의 fluent Builder와 네 번째 스텝의 Director-Builder는 다른 문제를 푼다는 점이 중요하다. fluent Builder는 “인자 폭발”만 겨냥한다 — 결과 표현은 하나(Report)이고, 여러 값을 이름 붙여 안전하게 조립하는 게 목적이다. Director는 없다. 반면 GoF가 그린 원형은 “같은 절차로 다른 표현”을 겨냥한다 — 조립 순서를 Director에 가두고, 빌더를 갈아 끼워 표현을 바꾼다. 현장에서 “빌더 패턴”이라 부르는 것의 대부분은 앞쪽(fluent)이고, GoF의 Director-Builder는 문서 변환·파서 출력처럼 동일 순회에서 여러 산출물이 나올 때라야 값을 한다.
판단 기준: 표현이 하나면 fluent Builder로 족하다(Director 불필요). 같은 조립 절차로 여러 표현을 뽑아야 할 때만 Director를 세운다. 함정: 표현이 하나뿐인데 Director까지 세우면, 순회를 한 번 감싼 빈 껍데기만 늘어난다 — Director는 “순서를 재사용할 때” 비로소 값이 생긴다.
결과 — 무엇을 얻고 무엇을 내주나
빌더가 사는 값은 표현과 절차의 독립적 교체다. 구체 빌더가 표현을 통째로 캡슐화하니, 새 표현은 빌더 클래스 하나 추가로 끝난다 — Director도 기존 빌더도 안 열어도 된다. 반대로 조립 순서를 바꾸고 싶으면 Director만 고치면 되고, 모든 표현이 그 변경을 함께 받는다. 두 변경 축이 두 자리로 갈라진 덕이다. fluent 쪽에서 보면 값은 완결성 통제다 — build()가 불릴 때까지 객체는 미완성이고, 이 한 지점에서 필수값 검증과 불변식을 몰아 검사한 뒤 완성된 객체를 내놓는다. 텔레스코핑 생성자의 정체불명 인자 나열이 이름 있는 단계로 바뀌어 호출부가 곧 문서가 된다.
대가는 세 가지다. 첫째, 클래스가 는다. 표현마다 구체 빌더 하나에, 인터페이스와 (필요하면) Director까지 새로 생긴다. 생성자 한 줄로 끝나던 것이 여러 파일로 흩어져, 만드는 과정을 좇으려면 Director와 빌더를 오가야 한다. 둘째, 결과물 타입이 제각각이라 인터페이스로 못 묶인다. getResult()를 공통 인터페이스에 둘 수 없어 구체 빌더 타입을 클라이언트가 알아야 하고, 이 지점에서 추상화가 한 겹 새어 나온다. 셋째, 미완성 상태가 노출된다. 빌더는 단계가 다 불리기 전까지 반쯤 지어진 객체를 들고 있다. build()를 부르기 전에 결과를 꺼내거나, 필수 단계를 빠뜨린 채 build()를 부르는 실수가 가능하며, 이를 막는 방어가 빌더의 몫으로 얹힌다.
운영비용 렌즈로 보면 순이득의 조건이 분명하다. 표현이 여럿이고 앞으로도 추가되거나, 선택적 필드가 많아 생성자가 이미 아프다면, 늘어난 클래스의 대가보다 “새 표현·새 필드를 기존 코드 손 안 대고 얹는” 이득이 크다. 반대로 표현이 하나로 굳어 있고 필드가 서넛뿐이라면, 빌더는 간접성만 더하고 이득은 놀고 있다.
판단 기준: 얻는 것(표현 교체·절차 통제·완결성 검증)과 내주는 것(클래스 증가·타입 불일치·미완성 노출)을 세어 순이득이 양수일 때만 빌더를 앉힌다. 함정: “생성자가 길다”는 이유만으로 빌더를 두르면, 필수 인자 몇 개짜리 객체가 build() 호출을 강요당하며 오히려 장황해진다 — 인자가 정말 많고 대부분 선택적일 때라야 값한다.
언제 쓰고, 언제 과용인가
빌더가 정확히 맞는 자리는 이렇다. 복합 객체를 만드는 알고리즘(순서·조건)이 부분들의 표현과 독립이어야 할 때(문서 변환기, AST를 여러 타깃 코드로 내리는 컴파일러 백엔드, 같은 질의 트리에서 SQL·몽고 쿼리를 뽑는 쿼리 빌더). 그리고 선택적 필드가 많아 생성자가 텔레스코핑으로 번질 때(설정 객체, 요청 DTO, HTTP 요청 조립). 자바 표준의 StringBuilder, Stream.collect에 넘기는 컬렉터, HttpRequest.newBuilder()...build()가 모두 빌더의 흔적이다.
과용의 냄새는 반대편에서 난다. 필드가 두셋뿐인 값 객체를 빌더로 감싸면, 생성자 한 줄이면 될 것을 클래스 하나·메서드 여럿·build() 호출로 부풀린 것이다. 표현이 하나뿐인데 Director까지 세우면, 순회를 한 번 감싼 빈 껍데기가 남는다. 자바 record나 이름 있는 정적 팩토리로 충분한 자리에 반사적으로 빌더를 두르는 것도 마찬가지다 — 빌더는 “조립에 절차가 있거나 선택적 조합이 폭발할 때” 값하지, 단순 데이터 묶음에는 무게만 된다.
이웃 패턴과 경계를 그어 둘 필요가 있다. 추상팩토리(Abstract Factory)와 빌더는 둘 다 생성 패턴이고 구체 클래스를 클라이언트에서 숨기지만, 무엇을 감추느냐가 다르다. 추상 팩토리는 제품군을 감춘다 — 서로 관련된 여러 객체를 한 번의 호출로 즉시 만들어 돌려주며, 초점은 “어느 군(群)이냐”다. 빌더는 조립 절차를 감춘다 — 하나의 복합 객체를 여러 단계에 걸쳐 점진적으로 짓고, 마지막 단계에서야 완성품을 내준다. 팩토리가 “한 방에 낳는다”면 빌더는 “단계로 짓는다”. 실제로 빌더가 각 부분을 만들 때 그 부분을 추상 팩토리로 뽑는 식으로 둘이 함께 쓰이기도 한다. [컴포지트]와는 결과물에서 만난다 — 빌더가 짓는 복합 객체가 부분-전체 트리, 즉 컴포지트 구조인 경우가 많다. 문서 트리가 그렇다. 빌더는 짓는 절차를, 컴포지트는 지어진 구조를 맡는다.
판단 기준: 관련된 객체 군을 통째로 골라 즉시 받고 싶으면 추상 팩토리, 하나의 객체를 단계별로 조립하며 절차를 재사용·교체하고 싶으면 빌더를 고른다. 함정: 빌더의 목적이 “표현/절차 분리”임을 잊고 단지 생성자 회피용으로만 쓰면, 텔레스코핑을 피한 것만으로 만족해 Director가 주는 절차 재사용의 값을 흘려보낸다 — 표현이 여럿일 때 그 값이 나온다.
다음으로 팩토리메서드