본문으로 건너뛰기

Builder와 폼·필터·쿼리 빌더 UX - 디자인 패턴, 객체 수준 편 ep.02

생성자 인자가 다섯 개를 넘기 시작하면 호출 코드가 어지러워집니다. 인자가 열 개가 되면 어떤 자리에 무엇을 넣었는지 다시 확인해야 합니다. Builder는 이 어지러움에 답하는 패턴입니다.


한 번에 결정할 수 없을 때

객체를 만든다는 것은 그 객체의 상태를 한 번에 결정한다는 뜻입니다. 모든 인자가 생성자에 들어가고, new 한 번에 완성된 객체가 돌아옵니다.

이 방식이 잘 작동하려면 두 가지 조건이 필요합니다. 첫째, 인자의 수가 충분히 적어야 합니다. 둘째, 그 모든 인자가 객체를 만드는 시점에 이미 정해져 있어야 합니다.

현실의 객체는 두 조건을 모두 어깁니다. 예를 들어 HTTP 요청 객체에는 메서드, 헤더, 쿼리스트링, 바디, 타임아웃, 인증, 재시도 정책이 있습니다. SQL 쿼리 객체에는 테이블, 컬럼, 조건, 정렬, 페이지네이션, 조인이 있습니다. 사용자 폼은 더 깁니다.

이 인자들이 모두 한 자리에서 결정되지는 않습니다. 어떤 것은 함수의 입력으로, 어떤 것은 사용자의 클릭으로, 어떤 것은 조건문 안에서 결정됩니다.

Builder는 한 번에 모든 것을 결정할 수 없는 상황에서 객체를 어떻게 만들 것인가에 답하는 패턴입니다.

답은 단순합니다. 객체를 한 번에 만들지 말고, 단계로 쌓아 올리면 됩니다.


Builder의 정석

Telescoping Constructor의 문제

Builder를 이야기하기 전에, Builder가 없을 때의 모습을 봅니다. Telescoping Constructor라는 안티패턴입니다.

class HttpRequest {
  constructor(
    public url: string,
    public method: string = 'GET',
    public headers: Record<string, string> = {},
    public body: string | null = null,
    public timeout: number = 5000,
    public retries: number = 0,
    public withCredentials: boolean = false,
  ) {}
}

// 호출 측
const req = new HttpRequest(
  '/api/users',
  'POST',
  { 'Content-Type': 'application/json' },
  JSON.stringify({ name: 'Pi' }),
  3000,
  2,
  true,
)

호출 측 코드에서 3000이 timeout인지 retries인지, true가 무엇을 의미하는지는 한눈에 보이지 않습니다. 인자의 순서가 바뀌면 컴파일러는 잡지 못합니다(같은 타입일 때). 새 인자가 늘면 모든 호출부를 함께 고쳐야 합니다.

Telescoping Constructor와 Builder를 비교한 다이어그램. 좌측에는 길게 늘어선 생성자 인자 목록이 있고, 호출 측에서 인자의 의미를 알기 어려운 상황을 보여줍니다. 우측에는 Builder를 통해 각 인자가 메서드 이름으로 호출되는 모습이 표시되어 있으며, 어떤 값이 무엇을 의미하는지 명료하게 드러납니다.

Builder 패턴

Builder는 객체 생성을 메서드 호출의 연속으로 바꿉니다. 각 메서드는 한 가지 속성을 받습니다. 모든 속성이 준비되면 build() 메서드가 최종 객체를 돌려줍니다.

class HttpRequest {
  constructor(
    public readonly url: string,
    public readonly method: string,
    public readonly headers: Record<string, string>,
    public readonly body: string | null,
    public readonly timeout: number,
    public readonly retries: number,
    public readonly withCredentials: boolean,
  ) {}
}

class HttpRequestBuilder {
  private url: string = ''
  private method: string = 'GET'
  private headers: Record<string, string> = {}
  private body: string | null = null
  private timeout: number = 5000
  private retries: number = 0
  private withCredentials: boolean = false

  setUrl(url: string): this { this.url = url; return this }
  setMethod(method: string): this { this.method = method; return this }
  setHeader(key: string, value: string): this {
    this.headers[key] = value
    return this
  }
  setBody(body: string): this { this.body = body; return this }
  setTimeout(ms: number): this { this.timeout = ms; return this }
  setRetries(n: number): this { this.retries = n; return this }
  withAuth(): this { this.withCredentials = true; return this }

  build(): HttpRequest {
    if (!this.url) throw new Error('URL is required')
    return new HttpRequest(
      this.url, this.method, this.headers, this.body,
      this.timeout, this.retries, this.withCredentials,
    )
  }
}

// 호출 측
const req = new HttpRequestBuilder()
  .setUrl('/api/users')
  .setMethod('POST')
  .setHeader('Content-Type', 'application/json')
  .setBody(JSON.stringify({ name: 'Pi' }))
  .setTimeout(3000)
  .setRetries(2)
  .withAuth()
  .build()

호출 코드는 길어졌지만, 각 줄이 무엇을 의미하는지가 메서드 이름으로 드러납니다. 인자의 순서는 더 이상 중요하지 않습니다. 어떤 단계는 조건문 안에서 결정할 수 있습니다. 새 속성이 늘어도 기존 호출부를 깨뜨리지 않습니다.

각 메서드가 this를 반환하는 Method Chaining(메서드 체이닝)을 적극적으로 사용하는 Fluent Interface이며, Builder를 자연스럽게 읽히게 만드는 시각적 핵심입니다.

Builder 패턴의 구조 다이어그램. 좌측 상단에 HttpRequestBuilder 클래스가 있고 내부에 private 필드들과 setUrl, setMethod, setHeader 등의 메서드가 표시되어 있습니다. 각 메서드는 화살표로 자기 자신(this)을 반환하는 fluent interface 흐름이 그려져 있습니다. 우측에 호출 측 코드의 단계별 호출이 위에서 아래로 나열되어 있고, 마지막 build() 호출이 HttpRequest 객체로 변환되는 흐름이 화살표로 표시되어 있습니다.

Director는 선택사항

GoF의 원본 Builder 패턴에는 Director라는 역할이 추가로 있습니다. Director는 Builder를 받아 정해진 순서로 호출하는 객체입니다. 같은 Builder로 여러 종류의 객체를 만들 때 유용합니다.

class HttpRequestDirector {
  buildJsonPost(builder: HttpRequestBuilder, url: string, payload: unknown) {
    return builder
      .setUrl(url)
      .setMethod('POST')
      .setHeader('Content-Type', 'application/json')
      .setBody(JSON.stringify(payload))
      .build()
  }
}

그러나 현대의 코드, 특히 TypeScript에서는 Director를 굳이 클래스로 만들지 않고 헬퍼 함수로 대체합니다. 호출 패턴이 단순하다면 Director는 과한 추상화입니다.

안티패턴

Builder를 잘못 쓰면 다음과 같은 문제가 생깁니다.

모든 클래스에 Builder를 두는 일. 속성이 두세 개인 객체에 Builder를 두면, new보다 더 많은 코드를 쓰게 됩니다. 인자가 적은 객체는 일반 생성자가 낫습니다.

Mutable Builder를 공유하는 일. 하나의 Builder 인스턴스를 여러 곳에서 호출하면 의도하지 않은 상태가 섞입니다. Builder는 한 번 쓰고 버리는 패턴입니다.

build() 없는 Builder. 메서드 체이닝만 있고 마지막에 검증과 빌드 단계가 없으면, Builder가 그냥 setter 모음이 됩니다. build()에서 필수 인자 검증과 불변 객체 반환이 일어나야 합니다.


같은 결정이 다시 나타나는 자리

Builder가 답하는 질문은 한 번에 결정할 수 없는 것을 단계로 쌓아 올리는 것입니다. 이 질문은 UI 디자인에서 매일 반복됩니다.

검색 필터를 떠올려 보세요. 사용자가 한 번에 모든 조건을 머릿속에 떠올린 채로 검색 버튼을 누르는 경우는 드뭅니다. 카테고리를 고르고, 가격 범위를 정하고, 정렬을 바꾸고, 다시 카테고리를 좁힙니다. 각 단계가 Builder의 set* 메서드와 같습니다. 마지막 “결과 보기” 버튼이 build()입니다.

다단계 폼도 같은 구조입니다. 회원가입 폼이 4단계로 나뉘어 있다면, 각 단계는 Builder에 일부 속성을 채워 넣는 일과 같습니다. 사용자는 한 번에 모든 정보를 떠올리지 못합니다. 단계로 나누는 일은 사용자의 인지 부하를 줄이는 동시에, 입력을 모으는 과정에 이름을 붙이는 일입니다.

쿼리 빌더 UI(Linear, Notion, Airtable의 필터 인터페이스)는 Builder 패턴을 사용자에게 그대로 노출한 형태입니다. 사용자가 만드는 것은 한 번에 완성된 쿼리가 아니라, 단계로 쌓이는 조건입니다. 각 조건이 추가될 때마다 화면 위쪽에 결과가 갱신됩니다.

필터 빌더 UX와 Builder 패턴의 대응 다이어그램. 좌측에는 사용자의 단계적 조작이 위에서 아래로 나열되어 있습니다. 카테고리 선택, 가격 범위 입력, 정렬 변경, 결과 보기 버튼 클릭의 순서로 표시됩니다. 각 단계에서 우측의 query Builder 객체에 해당 속성이 채워지는 모습이 화살표로 연결되어 있으며, 마지막 결과 보기 버튼이 build 호출과 대응되어 최종 쿼리 객체가 만들어지는 흐름이 그려져 있습니다.

디자이너에게 이 패턴이 보이는 자리. 단계를 가시화하는 UI는 Builder를 사용자 인터페이스로 옮긴 결과입니다. 진행도 표시, 단계별 버튼, 누적된 선택의 칩(chip) 표시는 모두 Builder의 내부 상태를 사용자에게 보여주는 방식입니다.

좋은 빌더 UI는 사용자가 지금까지 무엇을 선택했는지, 그리고 그것이 최종 결과에 어떻게 반영되는지를 동시에 보여줍니다. Builder의 build() 직전 상태를 시각화하는 일과 같습니다.


다음 편 예고

ep.03 - Singleton과 전역 상태·테마 컨텍스트

Builder가 단계로 쌓는 패턴이라면, Singleton은 단 하나만 존재해야 한다는 결정의 이름입니다. 그 단순한 결정이 왜 안티패턴 논쟁의 한가운데에 놓이는지를 살펴봅니다.