본문으로 건너뛰기

Factory와 디자인 시스템의 컴포넌트 팩토리 - 디자인 패턴, 객체 수준 편 ep.01

new를 매번 직접 부르는 코드는 계속 커집니다. 같은 종류의 객체가 늘어날수록 그 호출은 곳곳에 흩어집니다. Factory는 그 흩어짐에 이름을 붙인 패턴입니다.


새 객체를 누가 만드는가

소프트웨어에서 객체를 만드는 일은 사소해 보입니다. new User(), new Notification(), new Button(). 손가락 하나만 움직이면 됩니다.

그러나 같은 객체를 만드는 코드가 둘 이상이 되면 이야기가 달라집니다. 셋이 되고 넷이 되면, 객체의 구성 방식이 바뀔 때마다 그 모든 자리를 함께 찾아 고쳐야 합니다. 종류가 더 많아지면 어디에서 어떤 객체를 만드는지를 추적하는 일조차 어렵습니다.

Factory 패턴은 이 흩어짐에 이름을 붙입니다. 객체를 만드는 책임을 한 곳으로 모읍니다. 코드의 다른 부분은 “이런 종류의 객체가 필요하다”고만 말합니다. 그것이 어떻게 만들어지는지는 묻지 않습니다.

이 결정의 본질은 무엇을 만들지 결정하는 자리어떻게 만들지 결정하는 자리를 나누는 것입니다. 그 분리가 가능해지는 순간, 코드는 변화에 약해지지 않습니다.


세 가지 Factory

Factory라는 말은 한 가지 패턴을 가리키지 않습니다. 부르는 사람에 따라 세 가지를 모두 의미할 수 있습니다. 셋 다 객체 생성의 책임을 옮긴다는 점에서는 같지만, 어디까지 옮기는가가 다릅니다.

Simple Factory

가장 단순한 형태입니다. GoF가 정식으로 정의한 패턴은 아니지만 가장 흔히 만나는 모습입니다. 정적 메서드 하나가 인자에 따라 다른 객체를 만들어 돌려줍니다.

interface Notification {
  send(): void
}

class EmailNotification implements Notification {
  send() { /* 이메일 전송 */ }
}

class SmsNotification implements Notification {
  send() { /* SMS 전송 */ }
}

class PushNotification implements Notification {
  send() { /* 푸시 전송 */ }
}

class NotificationFactory {
  static create(type: 'email' | 'sms' | 'push'): Notification {
    switch (type) {
      case 'email': return new EmailNotification()
      case 'sms':   return new SmsNotification()
      case 'push':  return new PushNotification()
    }
  }
}

// 사용
const n = NotificationFactory.create('email')
n.send()

호출하는 쪽은 new EmailNotification()이 무엇인지 알 필요가 없습니다. 'email'이라는 문자열만 알면 됩니다. 객체가 어떻게 구성되는지 알지 못해도 알림을 보낼 수 있습니다.

그러나 이 형태는 새 종류가 늘어날 때마다 Factory의 switch문이 함께 자랍니다. 종류가 많아지면 Factory 자체가 복잡도의 출처가 됩니다. Open/Closed 원칙에 어긋난다는 비판도 있습니다.

Factory Method

GoF의 정식 Factory 패턴입니다. 객체 생성을 서브클래스에 위임합니다. 추상 클래스가 생성 메서드를 선언하고, 구체 클래스가 그 메서드를 구현합니다.

abstract class NotificationDispatcher {
  // 어떻게 만들지는 서브클래스가 결정
  protected abstract createNotification(): Notification

  // 어떻게 보낼지는 공통
  dispatch(): void {
    const n = this.createNotification()
    n.send()
  }
}

class EmailDispatcher extends NotificationDispatcher {
  protected createNotification(): Notification {
    return new EmailNotification()
  }
}

class SmsDispatcher extends NotificationDispatcher {
  protected createNotification(): Notification {
    return new SmsNotification()
  }
}

// 사용
const dispatcher: NotificationDispatcher = new EmailDispatcher()
dispatcher.dispatch()

호출 쪽은 어떤 서브클래스를 받는지만 결정합니다. 객체 생성은 각 서브클래스의 책임입니다. 새 종류가 늘어도 기존 코드를 고치지 않고 새 서브클래스를 더할 수 있습니다.

Factory Method의 클래스 구조 다이어그램. 상단에 추상 클래스 NotificationDispatcher가 추상 메서드 createNotification과 공통 메서드 dispatch를 가지고 있습니다. 하단에 구체 클래스 EmailDispatcher와 SmsDispatcher가 NotificationDispatcher를 상속받아 각각 createNotification을 구현하며, 이들은 점선 화살표로 각각 EmailNotification, SmsNotification 인스턴스를 생성하는 관계를 보여줍니다. 우측에는 Notification 인터페이스와 그것을 구현하는 EmailNotification, SmsNotification 클래스가 위치합니다.

Abstract Factory

여러 종류의 객체를 묶음으로 만들어야 할 때 사용합니다. 관련된 객체들의 군(family) 을 생성하는 인터페이스를 정의합니다.

interface UIComponentFactory {
  createButton(): Button
  createInput(): Input
  createCard(): Card
}

class LightUIFactory implements UIComponentFactory {
  createButton(): Button { return new LightButton() }
  createInput(): Input { return new LightInput() }
  createCard(): Card { return new LightCard() }
}

class DarkUIFactory implements UIComponentFactory {
  createButton(): Button { return new DarkButton() }
  createInput(): Input { return new DarkInput() }
  createCard(): Card { return new DarkCard() }
}

// 사용
function renderForm(factory: UIComponentFactory) {
  const card = factory.createCard()
  const input = factory.createInput()
  const button = factory.createButton()
  // ...
}

renderForm(new DarkUIFactory())

LightUIFactoryDarkUIFactory는 서로 호환되는 컴포넌트 군을 만들어 냅니다. 한 군 안의 컴포넌트들은 시각적으로 일관됩니다. 군을 바꾸는 일은 Factory 하나만 바꾸면 됩니다.

셋을 비교하면

세 패턴은 모두 객체 생성의 책임을 옮긴다는 점에서 같습니다. 다른 점은 무엇을 옮기느냐입니다.

  • Simple Factory는 어떤 객체를 만들지를 정적 메서드 하나가 결정합니다.
  • Factory Method는 어떤 객체를 만들지를 서브클래스가 결정합니다.
  • Abstract Factory는 어떤 객체 군을 만들지를 Factory 자체가 결정합니다.

세 가지 Factory 패턴의 비교 다이어그램. 가로로 3개의 컬럼이 나란히 배치되어 있습니다. 좌측 Simple Factory 컬럼에는 단일 정적 메서드 박스에서 세 갈래로 분기되어 Email, SMS, Push 객체가 만들어지는 모습이 그려져 있습니다. 중앙 Factory Method 컬럼에는 추상 클래스가 상단에 있고 그 아래 두 개의 구체 서브클래스가 상속 관계로 연결되며 각각 자신의 객체를 생성하는 모습입니다. 우측 Abstract Factory 컬럼에는 Factory 인터페이스가 Button, Input, Card 세 종류를 묶음으로 만들어내고, 그 아래 LightFactory와 DarkFactory 두 구체 클래스가 서로 다른 객체 군을 생성하는 모습이 그려져 있습니다.

안티패턴

Factory가 만능 도구는 아닙니다. 다음과 같은 상황에서는 비용이 효용을 넘어섭니다.

거대한 switch문 Factory. Simple Factory가 비대해지면 Factory 자체가 복잡도의 출처가 됩니다. 종류가 네다섯 개를 넘어가면 Factory Method나 등록(registry) 패턴을 검토해야 합니다.

과한 추상화. 객체 한 종류만 만드는 코드에 Factory를 두는 일은 거의 가치가 없습니다. new를 직접 부르는 것보다 한 단계 더 거치게 만들 뿐입니다.

Factory 안에 비즈니스 로직. Factory는 생성만 책임집니다. 객체의 동작에 관여하는 로직이 Factory 안에 들어가면 책임이 흐려집니다.


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

Factory가 답하는 질문은 무엇을 만들지 결정하는 자리와 어떻게 만들지 결정하는 자리를 분리하는 것입니다. 이 질문은 코드 밖에서도 반복됩니다.

디자인 시스템이 좋은 예입니다. 새 페이지를 만드는 디자이너에게 “Card 컴포넌트가 필요하다”는 결정과 “그 Card가 어떻게 그려지는가”는 분리되어 있어야 합니다. 디자이너는 Card라는 이름만 알면 됩니다. 그것이 어떤 폰트, 어떤 간격, 어떤 색으로 만들어지는지는 디자인 시스템이 결정합니다.

이 분리가 무너지면 페이지마다 다른 Card가 생깁니다. 같은 이름의 컴포넌트가 30개씩 만들어집니다. 디자인 시스템이 작동하지 않을 때 흔히 보이는 풍경입니다.

shadcn/ui나 Radix UI 같은 도구가 하는 일은 본질적으로 Abstract Factory입니다. UIComponentFactory라는 인터페이스가 코드의 형태로 외부에 노출된 것입니다. 디자인 시스템 v1과 v2를 갈아 끼우는 일은 Factory 하나를 교체하는 일과 같습니다.

CVA(class-variance-authority) 같은 작은 라이브러리는 컴포넌트의 변형(variant)을 다루는 작은 Factory입니다. 같은 Button이지만 primary, secondary, ghost로 변형되는 일은 “버튼”이라는 결정과 “어떤 버튼”이라는 결정을 분리한 결과입니다.

디자인 시스템과 Factory 패턴의 대응 관계를 보여주는 다이어그램. 화면이 가로로 두 영역으로 나뉘어 있습니다. 좌측은 CODE 영역으로, UIComponentFactory 인터페이스가 상단에 있고 그 아래 LightUIFactory와 DarkUIFactory 두 구체 클래스가 연결됩니다. 각 Factory는 Button, Input, Card 세 객체를 군으로 생성합니다. 우측은 DESIGN SYSTEM 영역으로, ThemeProvider가 상단에 있고 그 아래 Light Tokens와 Dark Tokens 두 토큰 군이 연결됩니다. 각 토큰 군은 동일한 Button, Input, Card 컴포넌트를 일관된 시각 스타일로 렌더링합니다. 두 영역 사이는 점선으로 대응 관계가 표시되어 있으며, 코드의 Factory와 디자인 시스템의 ThemeProvider, 코드의 객체 군과 디자인 시스템의 토큰 군이 같은 결정 구조임을 시각적으로 드러냅니다.

디자이너에게 이 패턴이 보이는 자리. 디자인 시스템의 컴포넌트 라이브러리는 Factory의 결과물입니다. 같은 Button이라는 이름이 어디서 호출되어도 같은 모양이어야 한다는 약속은, Factory의 원칙을 디자인 언어로 옮긴 것입니다.


다음 편 예고

ep.02 - Builder와 폼·필터·쿼리 빌더 UX

Factory가 한 번의 호출로 완성된 객체를 받는 답이라면, Builder는 단계로 쌓아 올리는 답입니다. 사용자가 한 번에 결정할 수 없는 입력 앞에서 우리는 무엇을 선택해야 하는지를 살펴봅니다.