Skip to content

Conversation

@rlacodud
Copy link

@rlacodud rlacodud commented Nov 23, 2025

Chapter3-1. UI 컴포넌트 모듈화와 디자인 시스템

배포 링크

과제 목표

레거시 코드베이스를 현대적인 디자인 시스템으로 개편하는 실무 경험

  1. 정리되지 않은 레거시 코드의 문제점 식별 및 분석
  2. TailwindCSS, shadcn/ui, CVA 등의 현대 도구 활용
  3. 일관된 디자인 토큰과 컴포넌트 API 구축
  4. UI와 비즈니스 로직이 적절한 분리된 리팩토링

Before 패키지 분석 후 After 패키지 개편

개편 목표

디자인 시스템

  • TailwindCSS 기반 일관된 디자인 토큰 정의
  • 하드코딩 제거, 재사용 가능한 스타일 시스템 구축
  • dark mode, 반응형 등 확장 가능한 구조

컴포넌트 아키텍처

  • UI 컴포넌트는 순수하게 UI만 담당
  • 도메인 로직은 적절히 분리
  • 일관된 컴포넌트 API 설계

사용할 도구

TailwindCSS 4.x

  • 디자인 토큰 기반 스타일링
  • 유틸리티 클래스 활용
  • dark mode, 반응형 내장 지원

shadcn/ui

  • Radix UI 기반, 접근성 내장
  • 복사 가능한 컴포넌트 (라이브러리가 아닌 소스코드)
  • 자유로운 커스터마이징

CVA (Class Variance Authority)

  • 선언적 variants 패턴
  • 타입 안전한 스타일 조합
  • 조건부 스타일링 처리

React Hook Form + Zod

  • 선언적 폼 검증
  • 타입 안전한 스키마
  • 최소 리렌더링 최적화

필수 과제

1. 디자인 시스템 구축

  • TailwindCSS 설정 및 디자인 토큰 정의
  • shadcn/ui 컴포넌트 설치 (Button, Input, Select, Card, Table 등)
  • CVA를 활용한 variants 패턴 적용
  • 일관된 스타일 시스템 구축

2. Before 패키지 분석

  • Before 패키지 실행 및 전체 코드 탐색
  • 스타일링, 컴포넌트 설계, 폼 관리 측면에서 문제점 파악
  • 개선이 필요한 부분과 그 이유 정리

3. 컴포넌트 개편

  • UI와 비즈니스 로직 분리
  • 순수한 UI 컴포넌트로 재구성
  • 일관된 컴포넌트 API 설계
  • 적절한 컴포넌트 구조 설계

심화 과제

  • Dark Mode 완전 지원 (CSS Variables + Tailwind)
  • Design Token 시스템 고도화 (색상 팔레트, 타이포그래피 스케일)
  • 뷰와 비즈니스로직이 분리되도록

과제 회고

과제를 진행하면서 느낀 점, 배운 점을 자유롭게 작성해주세요.

Before 패키지에서 발견한 문제점

(1) 접근성

(1-1) ESC키 지원 부재
ESC키로 팝업이 안 닫힌다. => 접근성 위배
image

(1-2) 닫기 버튼 순서 오류
모달의 닫기 버튼은 모달의 가장 마지막 요소로 마크업되어야 하나, 헤더 영역에 위치한다.
image
참고 문서

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className={modalClasses} onClick={(e) => e.stopPropagation()}>
        {title && (
          <div className="modal-header">
            <h3 className="modal-title">{title}</h3>
            <button className="modal-close" onClick={onClose}>
              ×
            </button>
          </div>
        )}
        <div className="modal-body">
          {children}
        </div>
        {showFooter && footerContent && (
          <div className="modal-footer">
            {footerContent}
          </div>
        )}
      </div>
    </div>
  );

(1-3) ARIA 속성 부재
1️⃣ 모달 트리거 버튼
모달을 트리거하는 버튼에는 data-target 속성으로 모달 id가 부여되어야 한다.
image

2️⃣ 모달 영역
모달 영역에는 트리거 버튼과 동일한 이름의 id와 aria-labelledby를 지정해야 하고 role="dialog"로 해당 영역의 role을 명시해야 한다.

<button type="button" class="krds-btn large open-modal" data-target="modal_sample_03">class 및 data-target 호출</button>
<!-- modal -->
<section id="modal_sample_03" class="krds-modal fade" role="dialog" aria-labelledby="tit_modal_sample_03">
  <div class="modal-dialog">
    <div class="modal-content">
      <!-- modal title -->
      <div class="modal-header">
        <h2 id="tit_modal_sample_03" class="modal-title">
          모달 제목
        </h2>
      </div>
      <!-- //modal title -->
      <!-- modal contents -->
      <div class="modal-conts">
        <div class="conts-area">
          시작 <br>
          대화 상자는 사용자에게 작업에 대해 알리고 중요한 정보를 포함하거나 결정이 필요하거나 여러 작업을 포함할 수 있습니다.
        </div>
      </div>
      <!-- //modal contents -->
      <!-- modal btn -->
      <div class="modal-btn btn-wrap">
        <button type="button" class="krds-btn medium tertiary close-modal">아니요</button>
        <button type="button" class="krds-btn medium primary close-modal">예</button>
      </div>
      <!-- //modal btn -->
      <!-- close button -->
      <button type="button" class="krds-btn medium icon btn-close close-modal">
        <span class="sr-only">닫기</span>
        <i class="svg-icon ico-popup-close"></i>
      </button>
      <!-- //close button -->
    </div>
  </div>
  <div class="modal-back"></div>
</section>
<!-- //modal -->

(2) 스타일

(2-1) 토큰없이 중복 사용되는 색상값
image
image
image

(2-2) 단위값 혼재
image

(2-3) 미흡한 반응형 처리
image

(2-4) 케이스 명세 미흡
한번에 각 컴포넌트의 props에 따른 케이스를 확인하기 어렵다.

(3) 컴포넌트

(3-1) 카테고리 미지정 시 대응 미흡
카테고리 미지정 후 생성 시 해당 영역 분기 처리가 미흡하다.
image

(3-2) 텍스트 분기 처리 미흡
옳은 조사가 와야 하는데 해당 영역에 대한 분기 처리가 미흡하다.
image
image

(4) 폴더 구조

(4-1) 비효율적인 폴더 구조
image

(4-2) 분리되어있지 않은 UI와 기능 로직
image

개편 과정에서 집중한 부분

단순히 UI를 예쁘게 만드는 것이 아니라 재사용성확장성을 고려한 UI 설계가 핵심이었습니다.

[핵심 개선 원칙]
관심사 분리: UI, 비즈니스 로직, 데이터 로직을 명확히 분리
재사용성: 컴포넌트와 hook을 독립적으로 사용 가능하도록 설계
타입 안전성: TypeScript와 CVA로 컴파일 타임에 오류 방지
접근성: Radix UI로 WCAG 가이드라인 준수
일관성: 디자인 토큰 시스템으로 일관된 디자인 유지
문서화: Storybook으로 컴포넌트 사용법 명확히

(1) before 패키지 분석
before 패키지를 빌드하고 분석하며 해당 내용을 블로그에 정리했습니다.

(1-1) 분석 과정

  • before 패키지를 빌드하고 실행하여 실제 동작 확인
  • 각 컴포넌트의 코드를 분석하여 문제점 도출
  • 접근성, 스타일, 구조적 문제점을 카테고리별로 정리
  • 블로그에 분석 결과 정리

(1-2) 발견된 주요 문제점

  • 접근성: ESC키 미지원, ARIA 속성 부재, 닫기 버튼 위치 오류
  • 스타일: 하드코딩된 색상, 단위 혼재, 반응형 미흡
  • 구조: Atomic Design 폴더 구조의 복잡성, UI/로직 혼재

(2) tailiwind 설정 + 디자인 토큰 생성
문제점 파악 후 일관된 디자인 시스템 구축을 위해 하드코딩된 색상 값은 제거하고 디자인 토큰을 생성했습니다.

(2-1) 원시 토큰 정의 (primitive.css)

/* packages/after/src/tokens/primitive.css */
:root {
  /* 중복 없는 고유 색상 값만 정의 */
  --blue-50: #e3f2fd;
  --blue-400: #1976d2;
  --blue-500: #1565c0;
  /* ... */
  
  /* 간격 값 정의 */
  --spacing-4: 4px;
  --spacing-8: 8px;
  --spacing-16: 16px;
  /* ... */
}

(2-2) 의미론적 토큰 매핑 (index.css)

/* packages/after/src/styles/index.css */
@theme inline {
  /* 색상 토큰 */
  --color-primary: var(--blue-400);
  --color-primary-hover: var(--blue-500);
  --color-success: var(--green-500);
  --color-danger: var(--red-500);
  
  /* 간격 토큰 */
  --spacing-1: var(--spacing-4);
  --spacing-2: var(--spacing-8);
  --spacing-4: var(--spacing-16);
}

(3) shadCN으로 컴포넌트 생성 및 CVA 작업
기본적인 디자인 토큰 설정이 완료됐으면 shadCN으로 재사용 가능하고 타입 안전한 UI 컴포넌트를 생성합니다.

(4) Storybook 생성
구현한 컴포넌트를 문서화하기 위해 스토리북을 생성합니다.

// packages/after/src/stories/Button.stories.tsx
export default {
  title: "UI/Button",
  component: Button,
  argTypes: {
    variant: {
      control: "select",
      options: variantOptions,
    },
  },
};

이 때 컴포넌트 외에도 토큰값도 문서화하여 협업 시 파악이 쉽도록 합니다.

(5) PostManagement 마이그레이션
before의 복잡한 컴포넌트를 after의 깔끔한 구조로 전환합니다.

(5-1) 비즈니스 로직 분리.

// Before: 모든 로직이 컴포넌트 내부 (647줄)
export const ManagementPage = () => {
  const [entityType, setEntityType] = useState('post');
  const [data, setData] = useState([]);
  // ... 20개 이상의 상태
  // ... 모든 비즈니스 로직
  return <div>{/* 400줄 JSX */}</div>;
};
// After: 로직을 hook으로 분리
export const ManagementPage = () => {
  const {
    entityType,
    data,
    columns,
    statsCards,
    // ... 모든 로직
  } = useManagementPage();
  return (
    <div>
      <EntityStats cards={statsCards} />
      <EntityTable columns={columns} data={data} />
    </div>
  );
};

(5-2) 커스텀 훅 분리

// packages/after/src/hooks/useManagementPage.ts
export const useManagementPage = () => {
  // 상태 관리
  const [entityType, setEntityType] = useState<EntityType>("post");
  const [data, setData] = useState<Entity[]>([]);
  // 페이지네이션 로직 분리
  const {
    paginatedData,
    currentPage,
    totalPages,
    goToPrevPage,
    goToNextPage,
  } = useManagementPagination(data, { pageSize: 10 });
  // 통계 계산 로직 분리
  const statsCards = useManagementStats(entityType, data);
  // API 호출
  const loadData = useCallback(async () => {
    // ...
  }, [entityType]);
  return {
    entityType,
    setEntityType,
    data: paginatedData,
    columns,
    statsCards,
    // ...
  };
};

(5-3) 도메인 로직 분리

// packages/after/src/hooks/management/form-utils.ts
export const getInitialFormData = (entityType: EntityType): ManagementFormData => {
  if (entityType === "user") {
    return {
      username: "",
      email: "",
      role: "user",
      status: "active",
    };
  }
  return {
    title: "",
    content: "",
    author: "",
    category: "development",
    status: "draft",
  };
};
// packages/after/src/constants/management.ts
export const USER_ROLES = {
  user: { value: "user", label: "사용자" },
  moderator: { value: "moderator", label: "운영자" },
  admin: { value: "admin", label: "관리자" },
};

(5-4) 컴포넌트 분리

// packages/after/src/components/management/EntityTable.tsx
// 순수 UI 컴포넌트 - props로 데이터만 받음
export const EntityTable: React.FC<EntityTableProps> = ({
  columns,
  data,
  entityType,
  onEdit,
  onDelete,
}) => {
  // 렌더링만 담당
};
// packages/after/src/components/management/EntityStats.tsx
// 통계 카드 컴포넌트
export const EntityStats: React.FC<EntityStatsProps> = ({ cards }) => {
  return (
    <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
      {cards.map((card) => (
        <InfoCard key={card.label} {...card} />
      ))}
    </div>
  );
};

✅[추가 개선] 탭 레이아웃 구조 개선

Before 패키지의 Dashboard는 탭 버튼과 콘텐츠가 시각적으로 연결되지 않아 사용자가 현재 어떤 패널을 보고 있는지 인지하기 어려울 것 같다고 생각했습니다.

[문제점]

  • 탭과 콘텐츠 영역이 분리되어 있어 시각적 연결성 부족
  • 콘텐츠 패널의 컨테이너가 분리된 듯 보이며 탭 전환 경험이 떨어짐
  • UI 요소 간 위계 구조가 명확하지 않음

image
[개선 방향]

  • 탭과 콘텐츠를 하나의 Card로 묶는 UI 레이아웃 적용
  • 탭 선택 시 콘텐츠와 동일 컨테이너 내부에 표현하여 시각적 그룹화
  • 콘텐츠 영역에 배경 / border / spacing 토큰 적용해 구조적 위계 개선

사용한 기술 스택 경험

1️⃣ TailwindCSS

TailwindCSS는 유지보수하던 프로젝트에 적용되어있어서 수정정도만 해본 경험이 전부였습니다.
그러나 퍼블리셔이다보니 직접 스타일을 짜는 게 더 편하기도 하고 가독성이 안좋아서 선호하지 않았습니다.

처음에는 디자인 토큰을 만드는데 왜 TailwindCSS를 사용하는지 이해가 안갔고 재윤님과 이에 관해 얘기를 나눴습니다.
그러다 TailwindCSS가 유틸리티 클래스 기반이라 토큰 적용을 즉각적으로 할 수 있다는 걸 알게 됐습니다.

일반적으로 TailwindCSS를 많이 사용하는 이유를 깨닫게 되면서 그게 장점이라면, 만약 토큰 적용만 필요한 프로젝트에서는 TailwindCSS가 오히려 용량을 차지할 수도 있겠다는 생각이 들어
토큰 적용을 즉각적으로 할 수 있는 또다른 라이브러리가 있는지 찾아보게 되면서 확장시키는 경험을 할 수 있었습니다.

2️⃣ shadcn/ui

shadcn도 지나가며 아는 정도였는데 우선 CLI로 코드를 적용할 수 있다는 게 신기했습니다.
마치 마트에서 사온 초밥 포장을 뜯고 그릇에 옮겨 직접 만든 것처럼 보이는 형태가 떠올랐습니다.

접근성을 보장하고 compound component를 제공한다는 점에서 react 프로젝트에서 널리 쓰이는 이유를 이해할 수 있었고
무엇보다 구조 잡기 귀찮은 모달이나 테이블에 대해 딸깍 한번으로 생성된다는 것에서 편리함을 몸소 깨달을 수 있었습니다.

여기에 cva라는 개념을 접목해 유형별 스타일링을 타입 정의하듯이 보기 좋게 적용할 수 있다는 점도 인상 깊었습니다.


어려웠던 점과 해결 방법

생각보다 다크모드를 구현할 때 색상 적용에서 애를 먹었습니다.
TailwindCSS의 기본 클래스와 충돌이 나서 그런가 싶어서 유니크한 네이밍으로 변경했음에도 몇몇 토큰은 다크모드가 제대로 적용되지 않아 명시적인 클래스명으로 정의하여 적용했습니다.

리뷰받고 싶거나 질문하고 싶은 내용

  • 위 이슈에 대한 명확한 이유가 궁금합니다.
    어떤 부분에서 세팅이 잘못되어 다크모드일 때, 특정 토큰만 정상 적용되고 그 외에는 적용이 안된 이유가 궁금합니다.
  • 과제 제출 이후 추가 리팩토링 진행하며 궁금해진 내용인데요,
    shadCN의 탭 컴포넌트를 활용하도록 수정하다보니 role을 button으로 찾는 부분에서 테스트가 실패해서 tab으로 변경했습니다!
    => 이 부분을 수정하는 것까지 의도된 부분이 맞을까용?
    태그는 button이 맞지만 접근성 상 role은 tab으로 제공하는 게 맞다보니 테스트를 수정하지 않고 통과하는 방법을 모르겠습니다!

❗️과제 제출 이후 조금 더 리팩토링을 진행하고 싶어서 추가 커밋을 남겼습니다❗️

자세한 구현 과정과 회고는 아래 블로그에 정리했습니다! 😊
6주차_디자인 시스템 발전기
WIL 6주차_Chapter3-1. UI 컴포넌트 모듈화와 디자인 시스템

- primitive.css: 색상 팔레트와 간격 정의
- index.css: @theme inline으로 Tailwind 유틸리티 매핑
- Storybook 설정
- Button, Badge 컴포넌트 스토리 추가
- 다크 모드 테마 토글 지원
- Button, Badge, Input, Select, Card, Table, Form 컴포넌트 추가
- CVA를 활용한 variant 패턴 적용
- 토큰 기반 스타일링으로 일관성 확보
- Badge variant: default, success, danger, warning, info, muted
- Button variant: primary, secondary, danger, success
- Tailwind CSS 추가
- Storybook 추가
- postcss.config.js 제거
- Alert/Card 컴포넌트 variant별 색상 토큰 추가
- Card 컴포넌트 variant별 텍스트 색상 자동 적용
- Alert 컴포넌트 variant 스타일 적용
- Storybook 컬러 팔레트 문서 추가
- 사용되지 않는 토큰 정리
- atoms/molecules/organisms 폴더 구조 제거
- shadcn/ui 기반의 단순한 폴더 구조로 통합
  - components/ui/ - 순수 UI 컴포넌트
  - components/management/ - 도메인 특화 컴포넌트
- 기존 컴포넌트를 shadcn/ui 스타일로 마이그레이션
- 컴포넌트 구조 단순화로 유지보수성 향상
- ManagementPage를 순수 뷰 컴포넌트로 전환
- 비즈니스 로직을 커스텀 훅으로 분리
  - useManagementPage: 페이지 상태 관리 및 API 호출
  - useManagementPagination: 페이지네이션 로직
  - useManagementStats: 통계 계산 로직
  - form-utils: 폼 데이터 변환 유틸리티
- 도메인 상수 및 타입 정의 추가
  - constants/management.ts: 역할, 상태, 카테고리 등
  - type/management.ts: 타입 정의
- 관심사 분리로 코드 가독성 및 테스트 용이성 향상
- Dialog 컴포넌트 추가
- Pagination 컴포넌트 추가
- Switch 컴포넌트 추가
- Textarea 컴포넌트 추가
- NativeSelect 컴포넌트 추가
- 모든 컴포넌트에 CVA 기반 variants 적용
- Header 컴포넌트에 다크모드 토글 기능 구현
- localStorage를 통한 테마 설정 저장
- 시스템 설정 감지 지원
- styles/index.css에 다크모드 토큰 정의
- 모든 색상 토큰이 라이트/다크 모드 지원
- 기존 .ts 스토리 파일을 .tsx로 전환
- 새로운 컴포넌트 스토리 추가
  - Dialog.stories.tsx
  - EntityTable.stories.tsx
  - InputField.stories.tsx
  - NativeSelect.stories.tsx
- 기존 스토리 파일 업데이트
  - Alert.stories.tsx
  - Badge.stories.tsx
  - Button.stories.tsx
  - Card.stories.tsx
  - Colors.stories.tsx
- styles/index.css: 디자인 토큰 시스템 개선
- InfoAlert 컴포넌트 스타일 업데이트
- main.tsx: 앱 초기화 로직 개선
- package.json: 의존성 업데이트
- shadCN Tabs 컴포넌트 추가 및 ManagementPage에 적용
- 탭 스타일을 버튼의 primary/secondary 스타일로 변경
- EntityTabContent 컴포넌트 분리로 코드 구조 개선
- 사용자/게시글 생성 폼에 필드별 에러 메시지 표시 기능 추가
- Required 필드 클라이언트 사이드 검증 추가
- 테스트 코드 수정 (role='tab'으로 변경)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant