Skip to content

Appendix B. 사내 표준 UI 라이브러리(Ant Design / shadcn/ui Tailwind) 브릿지 가이드 ​

"우리가 14개 챕터에 걸쳐 완성한 코어 아키텍처(Custom Hook, Zustand Store, Repository)는 사내에서 어떤 UI 라이브러리를 쓰더라도 단 한 줄의 수정 없이 그대로 재사용된다. 엔터프라이즈 백오피스 표준인 Ant Design React(antd) 및 모던 프론트엔드 대세인 shadcn/ui + Tailwind CSS와의 1:1 결합법을 마스터한다."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
헤드리스(Headless) 비즈니스 아키텍처UI 표현 계층과 도메인 상태·네트워크 로직을 완전히 분리하는 설계 원칙B.1
Ant Design React (antd) <Table>, <Popconfirm>엔터프라이즈 관리자 포털에서 가장 널리 쓰이는 올인원 UI 컴포넌트 세트B.2 [코드 B-1]
ColumnsType<Post> (antd/es/table)Ant Design 테이블 컬럼에 7장의 Post DTO 계약을 입히는 제네릭 타입B.2 🆕 박스
Tailwind CSS v4 (@tailwindcss/vite) & shadcn/ui내 프로젝트 소스(src/components/ui)로 직접 복사해 커스터마이징하는 모던 UI 패턴B.3 [코드 B-2]
Tailwind v3 ↔ v4 핵심 차이점 대응표사내 프로젝트 버전에 따라 달라지는 설치 및 CSS 설정 기준B.3 대응표

B.1 왜 비즈니스 로직과 UI 라이브러리를 분리했는가? ​

현업 실무에서는 부서나 프로젝트 성격에 따라 Ant Design (antd), MUI, 또는 Tailwind CSS + shadcn/ui를 표준 디자인 시스템으로 채택합니다.

초보 개발자들은 흔히 UI 라이브러리의 컴포넌트(<Table>, <Modal>, <Form>) 이벤트 핸들러 안에 직접 axios.get 호출과 복잡한 페이징·캐싱 상태를 뒤섞어 버립니다. 그렇게 짜인 코드는 사내 디자인 시스템이 바뀔 때마다 비즈니스 로직까지 통째로 다시 짜야 합니다.

하지만 우리는 앞서 5장, 7장, 9장, 12장에서 도메인 타입(types/board.ts), 페이지네이션·필터 훅(usePagination, usePostFilter), 전역 스토어(usePostStore), 네트워크 계층(postRepository)을 UI로부터 완벽히 독립시켜 두었습니다.
덕분에 기존 비즈니스 코드는 단 한 줄도 건드리지 않고, 겉껍데기 UI 컴포넌트만 레고 블록처럼 갈아 끼워 10분 만에 엔터프라이즈 백오피스 화면을 조립할 수 있습니다!


B.2 실무 케이스 1: Ant Design React (antd v5)와의 1:1 브릿지 ​

사내 관리자 포털(Back-office) 표준으로 가장 많이 쓰이는 Ant Design v5의 <Card>, <Table>, <Tag>, <Popconfirm>, message 토스트에 우리가 만든 usePostStore와 usePagination을 그대로 결합해 보겠습니다.

1) Ant Design 설치 및 한국어 로케일 설정 ​

bash
cd frontend
npm install antd @ant-design/icons

Ant Design v5는 CSS-in-JS가 내장되어 있어 별도의 글로벌 CSS 파일을 import할 필요가 없으며, 최상위를 <ConfigProvider>로 감싸면 사내 브랜드 컬러와 한국어 문구를 일괄 지정할 수 있습니다.

[코드 B-1] Ant Design 게시판 뷰 (frontend/src/views/AntdBoardView.tsx) ​

tsx
// frontend/src/views/AntdBoardView.tsx
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow';
import { Card, Table, Button, Tag, Popconfirm, Space, message, ConfigProvider } from 'antd';
import koKR from 'antd/locale/ko_KR';
import type { ColumnsType } from 'antd/es/table';
import { usePostStore } from '@/stores/usePostStore';
import { usePagination } from '@/hooks/usePagination';
import type { Post } from '@/types/board';

export default function AntdBoardView() {
  const navigate = useNavigate();

  // 🌟 1. 9~10장에서 구축한 Zustand 스토어 주입 (캐싱, 낙관적 좋아요, 삭제 액션 그대로 재사용!)
  const { posts, isLoading, totalCount } = usePostStore(
    useShallow((s) => ({
      posts: s.posts,
      isLoading: s.isLoading,
      totalCount: s.totalCount
    }))
  );
  const fetchPosts = usePostStore((s) => s.fetchPosts);
  const toggleLikeOptimistic = usePostStore((s) => s.toggleLikeOptimistic);
  const removePost = usePostStore((s) => s.removePost);

  // 🌟 2. 5장에서 분리한 usePagination 훅과 Zustand fetchPosts 연결
  const { currentPage, pageSize, setPage } = usePagination({
    totalCount,
    initialPageSize: 10
  });

  useEffect(() => {
    fetchPosts({ page: currentPage, size: pageSize }).catch(() => {
      message.error('게시글 목록을 불러오지 못했습니다.');
    });
  }, [currentPage, pageSize, fetchPosts]);

  // 10장의 낙관적 업데이트 + Ant Design 토스트 알림 결합
  async function handleLike(id: number) {
    try {
      await toggleLikeOptimistic(id);
    } catch {
      message.error('네트워크 장애로 좋아요 처리가 롤백되었습니다.');
    }
  }

  // 12장 원칙: 복구 가능한 API 에러는 컴포넌트에서 catch하여 토스트로 안내
  async function handleRemove(id: number) {
    try {
      await removePost(id);
      message.success('게시글이 삭제되었습니다.');
    } catch {
      message.error('삭제에 실패했습니다. 잠시 후 다시 시도해 주세요.');
    }
  }

  // 🌟 3. Ant Design 테이블 컬럼 명세 (7장의 Post DTO와 엄격한 제네릭 계약 체결)
  const columns: ColumnsType<Post> = [
    {
      title: 'ID',
      dataIndex: 'id',
      key: 'id',
      width: 80,
      align: 'center'
    },
    {
      title: '분류',
      dataIndex: 'category',
      key: 'category',
      width: 100,
      align: 'center',
      render: (cat: Post['category']) => (
        <Tag color={cat === 'NOTICE' ? 'gold' : cat === 'DEV' ? 'blue' : 'default'}>
          {cat}
        </Tag>
      )
    },
    {
      title: '제목',
      dataIndex: 'title',
      key: 'title',
      ellipsis: true,
      render: (_value, record) => (
        <Space>
          <span style={{ fontWeight: 600, color: '#1677ff', cursor: 'pointer' }}>
            {record.title}
          </span>
          {record.likeCount >= 5 && <Tag color="error">🔥 인기</Tag>}
        </Space>
      )
    },
    {
      title: '작성자',
      dataIndex: 'author',
      key: 'author',
      width: 130,
      align: 'center'
    },
    {
      title: '작성일',
      dataIndex: 'createdAt',
      key: 'createdAt',
      width: 130,
      align: 'center'
    },
    {
      title: '좋아요',
      dataIndex: 'likeCount',
      key: 'likeCount',
      width: 110,
      align: 'center',
      render: (_value, record) => (
        <Button type="text" size="small" onClick={() => handleLike(record.id)}>
          ❤️ {record.likeCount}
        </Button>
      )
    },
    {
      title: '관리',
      key: 'action',
      width: 100,
      align: 'center',
      render: (_value, record) => (
        <Popconfirm
          title="게시글 삭제"
          description="정말 이 게시글을 삭제하시겠습니까?"
          okText="삭제"
          cancelText="취소"
          okButtonProps={{ danger: true }}
          onConfirm={() => handleRemove(record.id)}
        >
          <Button type="link" danger size="small">
            삭제
          </Button>
        </Popconfirm>
      )
    }
  ];

  return (
    <ConfigProvider locale={koKR} theme={{ token: { colorPrimary: '#0f172a' } }}>
      <div style={{ maxWidth: 1100, margin: '32px auto', padding: '0 16px' }}>
        <Card
          title="💼 사내 기술 Q&A 게시판 (Ant Design React 버전)"
          extra={
            <Button type="primary" onClick={() => navigate('/write')}>
              ✏️ 새 글 작성
            </Button>
          }
        >
          <Table<Post>
            rowKey="id"
            columns={columns}
            dataSource={posts}
            loading={isLoading}
            size="middle"
            pagination={{
              current: currentPage,
              pageSize,
              total: totalCount,
              showSizeChanger: false,
              showTotal: (total) => `총 ${total}건`,
              onChange: (page) => setPage(page)
            }}
          />
        </Card>
      </div>
    </ConfigProvider>
  );
}

🆕 처음 보는 문법: Ant Design <Table<Post>>와 ColumnsType<Post>

  • render: (value, record) => ReactNode: 8장에서 우리가 직접 만든 BoardTable의 renderCell: (row, value) => ReactNode와 인자 순서만 반대일 뿐 100% 똑같은 렌더 프롭 패턴입니다! 8장에서 원리를 이해했기 때문에 상용 라이브러리의 API도 단숨에 읽힙니다.
  • message.success(...) / message.error(...): 브라우저를 멈추는 투박한 window.alert 대신 화면 상단에 부드럽게 떴다가 사라지는 토스트 알림을 띄웁니다.

B.3 실무 케이스 2: Tailwind CSS v4 + shadcn/ui 스타일 브릿지 ​

최신 프론트엔드 팀들은 무거운 외부 컴포넌트 패키지(node_modules에 갇힌 블랙박스) 대신, Radix UI(접근성 프리미티브) + Tailwind CSS 기반 코드를 내 프로젝트의 src/components/ui/ 폴더로 직접 복사해 소유하는 shadcn/ui 방식을 선호합니다.

8장에서 우리가 직접 제작한 제네릭 BoardTable.tsx는 그 자체로 훌륭한 shadcn/ui 스타일 헤드리스 컴포넌트입니다. 여기에 Tailwind CSS 유틸리티 클래스를 입혀 모던 대시보드 UI로 변신시켜 봅시다.

1) Tailwind CSS v4 설치 (Vite 플러그인 방식) ​

bash
cd frontend
npm install -D tailwindcss @tailwindcss/vite
typescript
// frontend/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  plugins: [react(), tailwindcss()] // 🌟 Tailwind v4 Vite 플러그인 등록
});
css
/* frontend/src/index.css */
@import "tailwindcss";

2) Tailwind v3 ↔ v4 대응표 (사내 기존 프로젝트가 v3인 경우 필독) ​

항목Tailwind v3Tailwind v4 (본 부록)
설치 패키지npm i -D tailwindcss@3 postcss autoprefixer 후 npx tailwindcss init -pnpm i -D tailwindcss @tailwindcss/vite (init 설정 파일 불필요)
CSS 진입점@tailwind base; @tailwind components; @tailwind utilities;@import "tailwindcss"; 단 한 줄
파일 스캔 경로tailwind.config.js의 content: ['./index.html', './src/**/*.{ts,tsx}'] 필수자동 감지 (.gitignore 제외 모든 소스 파일 자동 스캔)
테마 확장tailwind.config.js의 theme.extendCSS 파일 내부 @theme { --color-brand: #0f172a; }

[코드 B-2] 8장의 제네릭 BoardTable<Post>에 Tailwind CSS / shadcn/ui 배지·버튼 결합 ​

tsx
// frontend/src/views/TailwindBoardView.tsx
import React from 'react';
import { BoardTable, type TableColumn } from '@/components/common/BoardTable';
import { LikeButton } from '@/components/LikeButton';
import { usePostStore } from '@/stores/usePostStore';
import type { Post } from '@/types/board';

export function TailwindBoardView() {
  const posts = usePostStore((s) => s.posts);
  const isLoading = usePostStore((s) => s.isLoading);
  const removePost = usePostStore((s) => s.removePost);

  const columns: TableColumn<Post>[] = [
    { key: 'id', label: 'ID', width: '64px', align: 'center' },
    {
      key: 'category',
      label: '카테고리',
      width: '100px',
      align: 'center',
      renderCell: (row) => (
        <span className="inline-flex items-center rounded-md bg-slate-100 px-2 py-0.5 text-xs font-semibold text-slate-700">
          {row.category}
        </span>
      )
    },
    {
      key: 'title',
      label: '제목',
      renderCell: (row) => (
        <div className="flex items-center gap-2">
          <span className="font-semibold text-slate-900 hover:text-indigo-600 cursor-pointer transition-colors">
            {row.title}
          </span>
          {row.likeCount >= 5 && (
            <span className="px-2 py-0.5 text-xs font-bold bg-rose-100 text-rose-600 rounded-full">
              🔥 인기
            </span>
          )}
        </div>
      )
    },
    { key: 'author', label: '작성자', width: '120px', align: 'center' },
    {
      key: 'likeCount',
      label: '좋아요',
      width: '100px',
      align: 'center',
      renderCell: (row) => <LikeButton postId={row.id} likeCount={row.likeCount} />
    }
  ];

  return (
    <div className="max-w-5xl mx-auto my-8 p-6 bg-white rounded-xl shadow-sm border border-slate-200">
      <h2 className="text-xl font-bold text-slate-900 mb-4">
        ✨ 사내 기술 Q&A (Tailwind CSS &amp; 헤드리스 아키텍처 버전)
      </h2>

      <BoardTable
        items={posts}
        columns={columns}
        loading={isLoading}
        rowKeyField="id"
        renderActions={(row) => (
          <button
            type="button"
            className="px-2.5 py-1 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-md border border-red-200 transition"
            onClick={() => removePost(row.id)}
          >
            삭제
          </button>
        )}
      />
    </div>
  );
}

B.4 현장 투입을 앞둔 개발자를 위한 최종 요약 ​

  • 컴포넌트 껍데기는 유행에 따라 바뀌지만, 아키텍처의 알맹이는 변하지 않습니다.
  • 사내 프로젝트에 Ant Design, MUI, shadcn/ui, Tailwind CSS 중 어떤 UI 체계가 도입되더라도 여러분이 1~14장을 통해 구축한 TypeScript DTO 계약, Custom Hook, Zustand 스토어, Repository 계층은 수정 없이 그대로 이식됩니다.

Moon HomeLab Engineering Knowledge Hub