디자인 시스템 다시 생각해보기

김민수
2026년 1월 8일

안녕하세요! 토스의 디자인 시스템, TDS를 만들고 있는 Frontend UX Engineer 김민수입니다.

디자인 시스템을 만들고 운영하다 보면 항상 같은 질문과 마주하게 됩니다. "어떻게 하면 더 많은 팀이 우리 시스템을 잘 사용하게 할 수 있을까?" 오늘은 TDS가 이 질문에 답하기 위해 고민했던 과정을 공유하려고 해요.

디자인 시스템은 왜 점점 사용하기 어려워질까

디자인 시스템은 명확한 약속을 가지고 출발합니다. 일관된 UI, 빠른 개발 속도, 효율적인 협업. 잘 설계된 시스템은 디자이너와 개발자 사이의 커뮤니케이션 비용을 줄이고, 반복되는 UI 의사결정에서 팀을 자유롭게 하며, 여러 플랫폼에서 동일한 사용자 경험을 제공하는 기반이 돼요.

하지만 조직이 성장하고 제품이 다양해지면서, 견고해 보이던 시스템에도 균열이 생기기 시작합니다.

"이 버튼 컴포넌트에 배지를 함께 넣을 수 있나요?" "디자인은 업데이트됐는데 개발 패키지는 언제 반영되나요?" "우리 팀 상황에 맞게 살짝만 수정하고 싶은데..."

제품팀은 시스템의 제약 안에서 해결책을 찾다가, 결국 시스템을 우회합니다. Figma 컴포넌트를 detach하고, 디자인 시스템 패키지를 fork해서 로컬에서 수정해요.

문제는 여기서 시작됩니다. 효율을 위해 만들어진 시스템이 어느 순간 새로운 마찰의 원인이 되어버린 것이죠.

이 상황에서 필요한 것은 더 강력한 규칙이 아니라, 디자인 시스템을 바라보는 관점의 전환이었습니다.

디자인 시스템도 제품이다

디자인 시스템 팀과 제품 팀은 종종 이렇게 역할이 나뉘어요.

공급자가 수요자의 니즈를 제때 충족하지 못하면 수요자는 스스로 해결해요. 이건 ‘일탈’이나 시스템의 제약에 어긋나는 잘못된 사용사례가 아닙니다. 우리가 제품을 만들 때 수요에 맞는 공급을 제공해야 하듯, 디자인 시스템도 수요에 맞는 공급으로 제공되어야 해요.

간단한 예시를 준비해 봤어요.

*토스에서는 Figma가 아닌, Deus라는 툴을 사용해요. 이해를 돕기 위해서 Figma를 예시로 작성되었어요.

Figma를 사용하는 프로덕트 디자인에서는 흔히 이런 일이 벌어집니다.

detach는 당장 원하는 결과를 얻는 가장 빠른 선택이에요. 하지만 그 순간부터 해당 인스턴스는 시스템의 업데이트를 더 이상 받지 못하게 됩니다.

개발 단계도 마찬가지예요. 디자인 시스템 컴포넌트가 현재 필요한 형태를 제공하지 않으면, 제품 팀은 패키지를 fork합니다.

-import { Button } from "@tds/mobile"
+import { Button } from "../ui/tds"

그리고 로컬에서 살짝만 고쳐서 사용해요.

-export function Button(props) {
+export function Button(props) {
+  // 팀 요구사항에 맞춘 임시 옵션
+  const { tone = "strong", ...rest } = props
   return (
-    <TDSButton {...props} />
+    <TDSButton data-tone={tone} {...rest} />
   )
 }

이 순간 생기는 것은 ‘빠른 해결’이 아니라, 시스템과의 연결이 끊긴 파편입니다.

시스템은 계속 업데이트되지만 fork된 코드는 이를 따라가지 못해요. 결과적으로 사용자에게는 일관되지 않은 경험이 제공되고, 디자인 시스템이 약속한 핵심 가치가 무너집니다.

디자인 시스템이 너무 경직되어 수요자의 문제를 해결하지 못하면 수요자는 자연스럽게 이탈하게 돼요. 그 결과 일관성이라는 가치는 유지되기 어려워집니다.

디자인 시스템의 역할은 팀을 단속하는 게 아니라, 팀이 제품 문제를 풀 수 있게 도와주는 것에 더 가깝다고 생각해요.


통제가 아닌 유연한 확장으로

TDS는 이런 상황에서 디자인 시스템에 대한 관점을 재정립했어요.

디자인 시스템도 하나의 제품이고, 제품은 수요에 맞게 준비 되어야 합니다.

예전의 저는, 디자인 시스템이라면 ‘시스템’에 집중해야 한다고 생각했어요. 일관된 경험을 제공하도록 중앙 관리되어야 하고, 메이커들은 이러한 제약 위에서 디자인 시스템을 활용해 제품을 만들어야 한다고 생각했습니다.

하지만 규모가 커질수록 디자인 시스템을 사용하는 메이커의 니즈와 상황은 예측의 범위를 벗어나게 돼요. 디자인 시스템이 제공하는 규약과 제한은 오히려 속도를 저해하게 되고, 결국 시스템을 우회하고 사용하지 않는 결과를 낳습니다.

이런 상황에서도 ‘시스템’에 집중해 ‘제약’이나 ‘금지’를 강화하는 것은 문제를 근본적으로 해결하는 것이 아니었습니다. 우리가 eslint로 컨벤션을 아무리 만들어도, /* eslint-disable-next-line */ annotation 을 막을 수 없듯이 말이죠.

중요한 것은 우회로를 없애는 통제가 아니라, 우회할 이유를 줄이는 설계였어요.


확장성을 결정짓는 컴포넌트 API

확장성 논의는 결국 컴포넌트 API 설계로 귀결됩니다. 대표적인 두 가지 접근법을 살펴볼게요.

1) Flat 패턴

Flat 컴포넌트는 내부 구조를 감추고, 대부분의 변형을 props로 제공하는 방식이에요.

// Flat API: Card 예시

type CardProps = {
  title: string
  description?: string
  actionLabel?: string
  onActionClick?: () => void
}

function Card({ title, description, actionLabel, onActionClick }: FlatCardProps) {
  return (
    <section className="card">
      <header className="cardHeader">
        <h2>{title}</h2>
        {actionLabel ? (
          <button onClick={onActionClick}>{actionLabel}</button>
        ) : null}
      </header>

      {description ? <p className="cardDesc">{description}</p> : null}
    </section>
  )
}

export { Card }
export type { CardProps }

사용은 직관적입니다. 하지만 Flat API 설계의 단점은 분명해요. 시스템이 상정하지 않은 요구가 등장하면 props는 끝없이 늘어나게 된다는 치명적인 한계가 있어요.

위의 card를 예시로,

  • actionLabel을 hover 했을 때 callback을 전달하고 싶다면 어떨까요?
  • button이 아니라 anchor로 그려 href를 전달하고 싶다면 어떨까요?

위와 같은 상황에서 flat한 설계는 추후 확장과 유지보수를 어렵게 합니다.

2) Compound 패턴

Compound 패턴은 하위 컴포넌트를 제공하고, 제품 팀이 직접 조합해서 쓰도록 제공하는 방식이에요.

// Compound API: Card 예시

function Card({ children }: { children: React.ReactNode }) {
  return <section className="card">{children}</section>
}

Card.Header = function CardHeader({ children }: { children: React.ReactNode }) {
  return <header className="cardHeader">{children}</header>
}

Card.Title = function CardTitle({ children }: { children: React.ReactNode }) {
  return <h2 className="cardTitle">{children}</h2>
}

Card.Body = function CardBody({ children }: { children: React.ReactNode }) {
  return <div className="cardBody">{children}</div>
}

Card.Footer = function CardFooter({ children }: { children: React.ReactNode }) {
  return <footer className="cardFooter">{children}</footer>
}

// Object로 제공하는 것은 tree shaking에 불이익이 있어 namespace import로 제공해야 하지만, 이 글의 주제와는 별개의 내용이므로 간단한 예시를 준비했어요.

export { Card }

사용하는 쪽은 이렇게 원하는 구조를 직접 만들 수 있습니다.

<Card>
  <Card.Header>
    <Card.Title>월간 리포트</Card.Title>
    <button>다운로드</button>
  </Card.Header>

  <Card.Body>
    <ReportSummary />
  </Card.Body>

  <Card.Footer>
    <small>최근 업데이트: 1시간 전</small>
  </Card.Footer>
</Card>

Flat에 비해 Compound 패턴의 장점은 유연함이에요.

  • 시스템이 미리 예측하지 못한 레이아웃도 조합으로 해결이 가능해요.
  • ‘확장 지점’이 코드 레벨에서 자연스럽게 제공돼요.

하지만 Compound 패턴도 만능은 아니에요.

  • 사용하는 측에서의 구현 난이도와 코드량이 늘어나게 돼요.
  • 변형 여지가 거의 없는 컴포넌트까지 Compound로 만들면 사용하기 오히려 불편해지는 상황이 발생해요.

title만 설정하는 카드 컴포넌트가 필요하다고 가정해 볼게요.

<Card title="월간 리포트">
  <ReportSummary />
</Card>

<Card>
  <Card.Header>
    <Card.Title>월간 리포트</Card.Title>
  </Card.Header>

  <Card.Body>
    <ReportSummary />
  </Card.Body>
</Card>

둘의 차이는 분명합니다. Flat API는 사용 방법 또한 간결하고, Card라는 컴포넌트 외에 알아야 할 것이 많지 않아요. 반면 Compound API는 개발자가 Card의 구조를 이해하고 있어야 합니다. Header 안에 Title이 들어가야 하는지, Body는 필수인지 등 학습해야 할 것들이 많아요.

dot operator로 하위 컴포넌트를 탐색할 수 있지만, 올바른 조합 방법을 찾는 것은 또 다른 러닝커브입니다. 결과적으로 '친절한 도구'보다는 '번거로운 도구'로 경험될 수 있습니다.


Flat과 Compound를 함께 제공하기

그래서 TDS는 ‘어떤 패턴이 옳은가’가 아니라, ‘언제 어떤 선택이 적절한가’에 집중했습니다.

  • 단순하고 자주 쓰는 케이스는 Flat API
  • 복잡하고 변형이 잦은 케이스는 Compound API

다시 말해, 둘 중 하나가 아니라 둘을 함께 제공하는 하이브리드 전략을 택했어요.

사용자는 다음과 같이 상황에 따라 선택할 수 있습니다.

import { Card as FlatCard, Button, Badge } from "@tds/mobile/flat"
import { Card } from "@tds/mobile"

// 1) Flat: 별도의 커스텀이 필요 없다면
<FlatCard
  title="월간 리포트"
  description="이번 달 요약을 확인하세요"
  actionLabel="다운로드"
  onActionClick={download}
/>

// 2) Compound: 필요할 때, 필요한 곳에 커스텀을 사용자가 직접 할 수 있도록
<Card>
  <Card.Header>
    <div style={{ color: 'red' }}>
      <Card.Title>월간 리포트</Card.Title>
      <Badge>Beta</Badge>
    </div>
    <Button onClick={download}>다운로드</Button>
  </Card.Header>
  
  <Card.Body>
    <ReportSummary />
  </Card.Body>
</Card>

디자인 시스템 팀 입장에서는 API를 두 벌 관리해야 한다고 생각할 수 있지만, Flat API를 Compounds로 미리 조립한 케이스라고 생각하면 코드는 하나의 primitive로 관리될 수 있어요.

// Flat API: Card 예시

import { CardRoot, CardHeader, CardTitle, CardBody } from "@tds-primitive/mobile"

type CardProps = {
  title: string
  children: React.ReactNode
}

function Card({ title, children }: CardProps) {
  return (
    <CardRoot>
      <CardHeader>
        <CardTitle>{title}</CardTitle>
      </CardHeader>
      <CardBody>{children}</CardBody>
    </CardRoot>
  )
}

export { Card }
export type { CardProps }

외부에 제공되는 API는 두 가지지만, 내부 구현은 동일한 primitive 레이어를 재사용합니다. 유지보수 부담 없이 사용자 경험을 최적화할 수 있는 구조예요.


디자인 시스템 다시 생각해보기

저는 이 글을 통해 질문을 던지고 싶었습니다.

중앙에서 모든 것을 통제하는 방식이 정말 효율적일까요? 일관성을 지키는 방법은 오직 '금지' 뿐일까요?

수요가 없는 디자인 시스템은 제약을 걸 수도 없고, 일관성이라는 가치도 지킬 수 없습니다. 팀들은 이미 시스템 밖에서 자체적으로 해결하고 있기 때문이죠.

디자인 시스템은 권장하는 규칙을 제공하되 그 규칙에서 벗어난 예외 상황도 지원 가능해야한다고 생각해요.

우리가 만드는 디자인 시스템은 팀을 지키는 가드레일일까요, 아니면 팀을 멈추게 하는 울타리일까요?

지금 여러분의 디자인 시스템을 이런 관점에서 다시 한번 바라보면 어떨까요?

댓글 0댓글 관련 문의: toss-tech@toss.im
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.