복잡함을 줄이는 가장 쉬운 방법은 그것을 보이지 않게 만드는 일입니다. 보이지 않게 만드는 일과 사라지게 만드는 일은 다릅니다.
표면을 결정한다는 것
서브시스템이 자랍니다. 결제 처리는 인증, 금액 검증, 게이트웨이 호출, 결과 저장, 알림 발송, 리포팅 트리거로 나뉘어 있습니다. 각 단계가 별도의 객체로 잘 나뉘어 있다면 그 자체로 좋습니다. 그러나 이것을 사용하는 클라이언트 코드 입장에서는 다릅니다.
“주문을 결제한다”는 단순한 행동을 위해 클라이언트는 여섯 개의 객체를 알아야 합니다. 호출 순서를 알아야 합니다. 어떤 단계에서 어떤 예외가 나는지 알아야 합니다. 변경이 생기면 클라이언트도 함께 고쳐야 합니다.
Facade는 이 비대칭에 답합니다. 서브시스템 앞에 한 객체를 두고, 클라이언트는 그 객체만 봅니다. 안쪽의 복잡함은 사라지지 않습니다. 다만 한 겹의 단순함 뒤에 정리됩니다.
핵심은 두 가지입니다. 자주 쓰이는 사용 시나리오를 단순한 메서드로 노출한다. 그리고 드물게 디테일하게 사용할 때가 있을 땐 서브시스템에 직접 접근할 수 있게 남겨 둔다. Facade는 가리는 것이 아니라 정리하는 것입니다.
Facade의 정석
흩어진 서브시스템
결제 처리에 필요한 객체들이 있습니다.
class AuthService {
verify(userId: string): boolean { /* ... */ return true }
}
class PaymentValidator {
validate(amount: number, currency: string): void { /* ... */ }
}
class PaymentGateway {
charge(req: ChargeRequest): Promise<ChargeResult> { /* ... */ return null! }
}
class PaymentRepository {
save(record: PaymentRecord): Promise<void> { /* ... */ return null! }
}
class NotificationService {
notify(userId: string, message: string): Promise<void> { /* ... */ return null! }
}
각 클래스가 한 가지 책임을 잘 가집니다. 그러나 클라이언트가 결제 한 건을 처리하려면 여섯 줄 이상이 필요하고, 그 흐름은 모든 클라이언트에 반복됩니다.
Facade 객체
서브시스템 앞에 단일 진입점을 둡니다.
class PaymentFacade {
constructor(
private auth: AuthService,
private validator: PaymentValidator,
private gateway: PaymentGateway,
private repository: PaymentRepository,
private notifier: NotificationService,
) {}
async processOrder(order: Order): Promise<PaymentResult> {
if (!this.auth.verify(order.userId)) {
throw new Error('Unauthorized')
}
this.validator.validate(order.amount, order.currency)
const result = await this.gateway.charge({
amount: order.amount,
currency: order.currency,
customerId: order.userId,
})
await this.repository.save({
orderId: order.id,
transactionId: result.id,
status: result.status,
})
if (result.status === 'succeeded') {
await this.notifier.notify(order.userId, '결제가 완료되었습니다.')
}
return result
}
}
클라이언트 코드는 paymentFacade.processOrder(order) 한 줄을 부릅니다. 안쪽의 협업은 Facade가 책임집니다.
Facade는 막지 않습니다
Facade의 흔한 오해는 “서브시스템을 가린다”는 것입니다. 정확히 말하면 자주 쓰이는 길을 단순화한다입니다. 고급 사용자, 특수한 시나리오, 새 기능 개발은 여전히 서브시스템에 직접 접근할 수 있어야 합니다.
// 일반 사용
await paymentFacade.processOrder(order)
// 고급 사용 (Facade를 우회)
const customGateway = new PaymentGateway(/* 특수 설정 */)
await customGateway.charge(specialRequest)
이 분리가 무너지면, Facade는 서브시스템의 모든 메서드를 다시 노출하는 비대한 객체가 됩니다. 또는 반대로, 서브시스템에 접근할 길을 모두 막아서 유연성이 사라집니다. Facade는 가장 자주 다니는 길을 포장하는 일이지, 다른 길을 막는 일이 아닙니다.
Adapter와의 차이
Facade와 Adapter는 모양이 비슷해 보일 수 있습니다. 둘 다 한 객체가 다른 객체(들) 앞에 놓입니다. 그러나 답하는 질문이 다릅니다.
- Adapter: 인터페이스가 맞지 않는 두 세계를 잇는다. 변환이 주된 일.
- Facade: 여러 객체의 협업을 한 메서드로 단순화한다. 정리가 주된 일.
Adapter는 거의 항상 일대일입니다(우리 인터페이스 ↔ 외부 인터페이스). Facade는 일대다입니다(클라이언트 ↔ 여러 서브시스템). 한 코드가 둘 다 할 수도 있습니다. 그러나 의도를 구분하면 코드의 책임이 명확해집니다.
안티패턴
비대한 Facade. Facade가 서브시스템의 모든 메서드를 위임 형태로 노출하면, 그것은 정리가 아니라 단순한 위임 레이어입니다. 클라이언트가 알아야 할 메서드 수가 줄지 않으면 Facade의 의미가 없습니다.
비즈니스 로직이 끼어든 Facade. Facade는 협업을 정리하는 곳입니다. 도메인 로직을 결정하는 곳은 아닙니다. “VIP 사용자는 할인” 같은 정책이 Facade에 들어가면, 그 정책의 변경이 Facade와 클라이언트 양쪽에 파급됩니다. 정책은 별도의 객체에 두고 Facade는 호출만 합니다.
상태를 가지는 Facade. Facade는 보통 stateless로 만듭니다. 상태가 필요하다면 서브시스템 안에 두는 것이 일반적입니다. Facade에 상태가 쌓이면 그것은 또 다른 서브시스템이 됩니다.
같은 결정이 다시 나타나는 자리
Facade가 답하는 질문은 무엇을 표면으로 노출하고 무엇을 안쪽에 정리할 것인가입니다. 이 질문은 디자인 시스템 컴포넌트 라이브러리의 가장 중요한 결정입니다.
<Button> 컴포넌트의 props를 생각해 봅니다. 가능한 모든 옵션을 노출하면 다음과 같이 됩니다.
<Button
variant="primary"
size="md"
color="blue"
textColor="white"
borderRadius={4}
fontWeight={600}
paddingX={16}
paddingY={8}
hoverColor="darkblue"
activeColor="navy"
disabledOpacity={0.5}
fontFamily="Inter"
lineHeight={1.5}
letterSpacing={0}
shadow="md"
// ...
>
결제하기
</Button>
이것은 자유도가 최대인 API입니다. 그리고 디자인 시스템이 작동하지 않는 API입니다. 매번 다른 조합이 가능하면, 일관성이 없습니다.
다른 극단으로 갑니다. 모든 결정을 가리고 최소한만 노출합니다.
<Button variant="primary">결제하기</Button>
이것은 표면이 가장 작은 API입니다. 일관성은 보장되지만, 예외적 상황(마케팅 페이지의 특수 버튼, A/B 테스트용 변형)을 다룰 수 없습니다.
좋은 디자인 시스템은 그 사이에 답을 둡니다. 자주 쓰이는 결정은 prop으로 노출하고, 드문 결정은 가리거나 별도의 진입점을 둡니다.
// Facade 역할의 일반 컴포넌트
<Button variant="primary" size="md">결제하기</Button>
// 고급 진입점: 직접 토큰을 조합할 수 있는 primitive
<ButtonPrimitive
className={cn(buttonTokens.primary, customClass)}
>
결제하기
</ButtonPrimitive>
shadcn/ui, Radix UI 같은 라이브러리가 채택한 구조입니다. 일반 사용자는 <Button>만 알면 됩니다. 디자인 시스템을 확장하거나 변형해야 하는 사용자는 primitive에 직접 접근합니다. Facade가 가장 자주 다니는 길을 포장하고, 다른 길을 막지 않는 원칙과 정확히 일치합니다.
디자이너에게 이 패턴이 보이는 자리. 디자인 시스템 컴포넌트의 props를 결정할 때마다 Facade의 질문이 반복됩니다. 무엇을 자주 쓰일 결정으로 보고 표면에 올릴 것인가. 무엇을 안쪽으로 감춰서 일관성을 지킬 것인가. 무엇은 막지 않고 별도의 길로 남겨 둘 것인가.
이 결정은 컴포넌트 수만큼 반복됩니다. 일관된 답을 가진 디자인 시스템과 매번 다른 답을 가진 디자인 시스템이 갈라지는 자리입니다.
다음 편 예고
Facade가 표면을 결정하는 패턴이라면, Composite는 안쪽의 계층을 결정하는 패턴입니다. 디자인 토큰이 primitive에서 semantic으로, semantic에서 component로 쌓여 올라가는 그 구조가 어떻게 같은 결정의 다른 이름인지를 살펴봅니다.