← 글 목록

개발 기록

Panda CSS 기반 멀티 테마 디자인 시스템 구축

여러 서비스에서 같은 디자인 시스템을 사용하면서 서비스마다 색상과 폰트는 다르게 적용해야 했습니다.

공통 UI를 서비스별로 따로 구현하면 중복 코드가 늘어나고, 하나의 스타일을 수정할 때 여러 패키지를 함께 변경해야 하는 문제가 생깁니다.

이번 글에서는 Panda CSS의 프리셋과 시맨틱 토큰을 이용해 공통 디자인 시스템과 서비스별 테마를 분리한 과정을 정리해 보려 합니다.

같은 UI를 쓰면서 색상과 폰트만 바꾸기

공통 디자인 토큰은 한 곳에서 관리하고, 서비스마다 달라지는 색상과 폰트만 따로 두고 싶었습니다. 공통 스타일을 고쳤을 때는 모든 서비스에 함께 반영되도록 했습니다.

각 서비스에는 필요한 스타일 코드만 생성하고, 테마별로 어떻게 보이는지는 Storybook에서 확인했습니다.

전체 구조는 다음과 같습니다.

공통 Preset
     ↓
서비스별 Preset
     ↓
서비스 애플리케이션
     ↓
생성된 스타일 코드

1. 컴포넌트에서 색상을 직접 관리할 때의 문제

초기에는 각 UI에서 색상을 직접 지정하는 방식으로 구현했습니다.

css({
  backgroundColor: 'blue.500',
  color: 'white',
})

이 방식은 하나의 서비스만 운영할 때는 단순합니다.

하지만 같은 UI를 여러 서비스에서 사용하기 시작하면 문제가 발생합니다.

  • 서비스마다 사용하는 색상이 다름
  • UI 코드에 서비스별 조건이 추가됨
  • 색상 변경 시 여러 파일을 수정해야 함
  • 실제 색상의 역할을 코드만 보고 파악하기 어려움

예를 들어 버튼 배경색을 blue.500으로 지정하면 이 값이 주요 액션 색상인지, 단순한 장식 색상인지 알기 어렵습니다.

그래서 실제 색상 대신 색상의 역할을 나타내는 시맨틱 토큰을 사용했습니다.

css({
  backgroundColor: 'action.primary',
  color: 'text.onPrimary',
})

UI에서는 색상의 역할만 사용하고, 실제 색상은 각 서비스의 테마가 결정하도록 변경했습니다.

2. Panda CSS를 선택한 이유

Panda CSS는 프리셋을 조합할 수 있어 공통 스타일과 서비스별 스타일을 분리하기 편했습니다.

또한 다음 기능을 함께 사용할 수 있었습니다.

  • 디자인 토큰
  • 시맨틱 토큰
  • 공통 스타일 규칙
  • 정적 CSS 생성
  • TypeScript 타입 생성

런타임에서 테마를 판단하는 대신 빌드 시점에 서비스별 스타일을 생성할 수 있다는 점도 현재 구조에 적합했습니다.

애플리케이션에서는 필요한 프리셋만 조합합니다.

export default defineConfig({
  presets: [basePreset, servicePreset],
})

공통 규칙은 basePreset에서 가져오고, 서비스별 색상과 폰트는 servicePreset에서 가져오는 구조입니다.

3. 공통 토큰 구성

서비스와 관계없이 사용하는 값은 공통 프리셋에서 관리했습니다.

export const basePreset = definePreset({
  theme: {
    tokens: {
      spacing: {
        small: { value: '0.5rem' },
        medium: { value: '1rem' },
        large: { value: '1.5rem' },
      },
      radii: {
        small: { value: '0.25rem' },
        medium: { value: '0.5rem' },
        large: { value: '1rem' },
      },
    },
  },
})

공통 프리셋에는 다음과 같은 값을 포함했습니다.

  • 여백
  • 크기
  • 모서리
  • 그림자
  • 타이포그래피 크기
  • 공통 스타일 규칙

색상과 폰트는 서비스마다 달라 공통 프리셋에 넣지 않았습니다. 공통 영역에 두면 다른 서비스에서도 써도 되는 값인지 구분하기 어려웠습니다.

4. 서비스별 시맨틱 토큰 분리

각 서비스의 프리셋에서는 실제 색상과 시맨틱 토큰의 관계를 정의했습니다.

export const servicePreset = definePreset({
  theme: {
    tokens: {
      colors: {
        primary: {
          500: { value: 'var(--service-primary)' },
        },
      },
    },
    semanticTokens: {
      colors: {
        action: {
          primary: { value: '{colors.primary.500}' },
        },
        text: {
          onPrimary: { value: '{colors.neutral.0}' },
        },
      },
    },
  },
})

다른 서비스에서는 같은 시맨틱 토큰을 다른 색상에 연결할 수 있습니다.

semanticTokens: {
  colors: {
    action: {
      primary: { value: '{colors.accent.500}' },
    },
  },
}

UI는 계속 action.primary만 사용합니다.

css({
  backgroundColor: 'action.primary',
})

서비스가 변경돼도 UI 코드는 수정할 필요가 없습니다.

폰트도 같은 방식으로 분리했습니다.

semanticTokens: {
  fonts: {
    body: {
      value: '{fonts.serviceBody}',
    },
  },
}

애플리케이션은 어떤 폰트를 사용하는지 알 필요 없이 body라는 역할만 사용하게 됩니다.

5. 서비스별 UI 패키지를 분리했던 문제

처음에는 서비스마다 별도의 UI 패키지를 구성했습니다.

service-a/ui
service-b/ui

각 패키지에서 공통 UI를 다시 감싸 서비스별 스타일을 적용하는 방식이었습니다.

하지만 실제 코드를 비교해 보니 대부분 다음과 같은 형태였습니다.

export { SharedUI } from '@workspace/shared-ui'

패키지를 나눴지만 실제로 추가하는 동작은 없고, 가져오는 경로만 달랐습니다. 그러다 보니 불필요하게 관리할 파일만 늘어났습니다.

  • 동일한 파일이 서비스 수만큼 증가
  • 공통 UI 변경 시 여러 패키지를 함께 확인
  • 서비스별 차이가 있는 것처럼 구조가 복잡해짐
  • 새로운 서비스를 추가할 때 불필요한 파일 생성

다시 보니 서비스마다 다른 것은 UI 동작이 아니라 색상과 폰트였습니다. 이 차이 때문에 UI 패키지까지 나눌 필요는 없었습니다.

그래서 UI는 공통 패키지 하나로 합치고, 색상과 폰트만 프리셋에서 관리하도록 바꿨습니다.

공통 UI
공통 Preset
서비스별 Preset

이후 서비스별 UI 패키지를 거치지 않고 공통 UI를 직접 사용하게 됐습니다.

6. 애플리케이션에서 프리셋 조합

각 애플리케이션은 공통 프리셋과 자신에게 필요한 서비스 프리셋을 조합합니다.

import { defineConfig } from '@pandacss/dev'
import { basePreset } from '@workspace/base-preset'
import { servicePreset } from '@workspace/service-preset'

export default defineConfig({
  presets: [basePreset, servicePreset],
  outdir: 'styled-system',
})

공통 설정을 복사하지 않고 필요한 프리셋만 선택할 수 있습니다.

서비스별 설정 파일에는 다음 내용만 남았습니다.

  • 사용할 프리셋
  • 스타일을 탐색할 경로
  • 생성 파일 위치
  • 서비스에서 필요한 추가 설정

공통 토큰이 변경되면 모든 서비스에 반영되고, 서비스별 색상이 변경되면 해당 프리셋만 수정하면 됩니다.

7. Storybook에서 테마 확인

디자인 시스템은 실제 애플리케이션에 적용하기 전에 Storybook에서 확인했습니다.

Storybook에서는 다음 내용을 중점적으로 확인했습니다.

  • 시맨틱 토큰이 올바른 색상으로 연결되는지
  • 서비스별 폰트가 정상적으로 적용되는지
  • 공통 스타일 변경이 모든 테마에 반영되는지
  • 특정 서비스의 프리셋이 다른 서비스에 영향을 주지 않는지

특히 시맨틱 토큰 이름은 코드만 보고 정하기보다 실제 화면에서 역할이 명확한지 함께 확인하는 것이 중요했습니다.

primary, secondary처럼 범위가 넓은 이름보다 다음과 같이 용도를 표현하는 이름이 관리하기 편했습니다.

action.primary
action.disabled
surface.default
surface.elevated
text.primary
text.secondary
border.default

생성 파일보다 먼저 확인한 것

Panda CSS는 설정을 기준으로 styled-system 코드를 생성합니다.

이 파일들은 직접 작성하는 소스가 아니라 빌드 결과물에 가깝습니다.

생성 파일이 많이 바뀌었다고 해서 설정도 그만큼 많이 바꾼 것은 아닙니다. 그래서 리뷰할 때는 변경량보다 어떤 설정을 바꿨는지 먼저 확인하는 편이 좋습니다.

  • 프리셋 설정
  • 토큰 정의
  • 시맨틱 토큰 연결
  • 스타일 규칙
  • Panda CSS 설정

생성 파일에서는 바꾼 설정이 실제로 반영됐는지 확인했습니다.

정리

처음에는 서비스별 차이를 UI 패키지로 나눴지만, 실제로 달랐던 것은 색상과 폰트였습니다. 공통 동작과 구조는 UI에 남기고, 서비스와 관계없는 값은 공통 프리셋에, 색상과 폰트는 서비스별 프리셋에 두는 것으로 충분했습니다.

각 앱은 필요한 프리셋을 조합하고 생성된 스타일 코드를 관리합니다. 새로운 서비스를 추가할 때도 UI를 다시 만들지 않고 해당 서비스의 프리셋을 정의해 같은 UI를 사용할 수 있게 됐습니다.