실무 패턴 · 7장 UI 컴포넌트

7장 · UI 컴포넌트

화면의 "모양"을 만드는 도구들: Tailwind? 로 스타일을 입히고, Shadcn UI? 로 기본 컴포넌트? 를 복사해 쓰고, 그 위에 우리 프로젝트가 만든 공통 컴포넌트를 조립합니다.

이 장의 코드는 "보고 익히는" 코드입니다

Tailwind·Shadcn은 빌드 도구가 있어야 동작해서 이 사이트의 실행창(플레이그라운드)에서는 돌릴 수 없어요. 그래서 거의 모든 예제는 실제 프로젝트 코드(TSX) 그대로 읽기용입니다. 진짜 동작은 pnpm dev 로 띄운 프로젝트에서 확인하세요. 맨 아래 한 곳에서만, props 가 모양을 바꾸는 원리를 순수 React로 체험합니다.

A. Tailwind CSS 기초

Tailwind? 는 CSS 파일을 따로 쓰지 않습니다. classNameflex, p-4, text-lg 같은 유틸리티 클래스를 조합해서 스타일을 입혀요.

<div className="flex gap-2 p-4 rounded-lg bg-white">...</div>
//        └ flex 배치  └ 간격 8px └ 여백 16px └ 둥근 모서리 └ 흰 배경
Tailwind 클래스는 레고 조각. flex(배치) + gap-2(간격) + p-4(여백)처럼 작은 조각을 끼워 맞춰 모양을 완성합니다.

A-1. Flexbox — 가로/세로 한 줄 배치

flex 는 "한 줄로 늘어선 책장". 아이템들이 가로(기본) 또는 세로로 정렬됩니다.

// 가로 배치 (기본)
<div className="flex items-center gap-4">
  <Button>저장</Button>
  <Button>취소</Button>
</div>

// 세로 배치
<div className="flex flex-col gap-2">
  <Input />
  <Input />
</div>

// 양쪽 끝 정렬 (제목 왼쪽, 버튼 오른쪽)
<div className="flex justify-between items-center">
  <h1>제목</h1>
  <Button>액션</Button>
</div>
클래스효과CSS 대응
flexFlexbox 켜기display: flex
flex-col세로 방향flex-direction: column
flex-1남은 공간 채우기flex: 1
items-center수직 가운데align-items: center
justify-between양쪽 끝 정렬justify-content: space-between
gap-4아이템 간격 16pxgap: 1rem

A-2. Grid — 바둑판 배치

grid 는 "바둑판". 행과 열로 배치합니다.

<div className="grid grid-cols-12 gap-4">
  <div className="col-span-6">절반 차지</div>
  <div className="col-span-3">1/4 차지</div>
  <div className="col-span-3">1/4 차지</div>
</div>

A-3. 간격 (Spacing)

Tailwind의 간격 단위는 1 = 4px (0.25rem)입니다.

<div className="p-4">      {/* padding 전체 16px */}
<div className="px-6">     {/* 좌우 24px */}
<div className="py-2">     {/* 상하 8px */}
<div className="mt-2">     {/* margin 위 8px */}
<div className="ml-auto">  {/* 왼쪽 자동 → 오른쪽으로 밀기 */}
<div className="space-y-4"> {/* 자식들 세로 간격 16px */}
pxrem
14px0.25rem
28px0.5rem
416px1rem
624px1.5rem
832px2rem

A-4. 반응형 디자인 (md:)

Tailwind는 모바일 우선이에요. 접두사가 없으면 모든 화면, 접두사가 붙으면 그 크기 이상에서 적용됩니다.

// 모바일: 1열, 태블릿: 2열, 데스크탑: 3열
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">

// 모바일에서 숨김, 데스크탑에서 표시
<div className="hidden lg:block">데스크탑에서만 보임</div>
접두사최소 너비대상
(없음)0px모든 화면
sm:640px작은 태블릿
md:768px태블릿
lg:1024px데스크탑
xl:1280px넓은 화면

A-5. cn() 헬퍼 — 조건부 클래스 합치기

cn() 은 여러 클래스 문자열을 합치고, 충돌하는 Tailwind 클래스를 정리해주는 유틸입니다(clsx + tailwind-merge).

import { cn } from "@/lib/utils"

<div className={cn(
  "flex h-16 px-4",
  muted && "bg-muted/50",        // muted=true 일 때만
  bordered && "border shadow-xs" // bordered=true 일 때만
)}>
실제로 어디에 쓰나
  • 카드 레이아웃 — 장비/재고 정보 카드: p-4 rounded-lg border bg-white 로 여백·테두리·배경을 한 번에
  • 표 셀 정렬 — 숫자(재고 수량)는 오른쪽 text-right, 상태 배지는 가운데 flex justify-center
  • 반응형 그리드 — 상세 폼 입력칸을 화면 크기에 따라 1·2·3열로: grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4
  • 여백·색 — 비활성 행은 연한 회색 bg-muted/50, 섹션 사이 간격 space-y-4, 보조 설명은 text-sm text-muted-foreground

B. Shadcn UI 컴포넌트

Shadcn? 은 npm 설치형 라이브러리가 아닙니다. 컴포넌트 소스를 src/components/ui/ 폴더로 복사해서 내 코드로 써요. 코드가 내 것이라 자유롭게 고칠 수 있고, 내부적으로 접근성 좋은 Radix UI와 Tailwind를 씁니다.

Shadcn은 "설치"가 아니라 "복사+붙여넣기". 빵집에서 빵을 사 오는 게 아니라, 레시피를 받아 내 주방에서 굽는 셈이라 내 입맛대로 고칠 수 있어요.

B-1. Button

import { Button } from "@/components/ui/button"

<Button>저장</Button>
<Button variant="outline">취소</Button>      {/* 테두리만 */}
<Button variant="destructive">삭제</Button>   {/* 빨간색 */}
<Button variant="ghost">더보기</Button>       {/* 배경 없음 */}
<Button size="sm">작은 버튼</Button>
<Button size="icon"><SearchIcon /></Button>   {/* 아이콘 전용 */}
<Button disabled>저장 불가</Button>
Prop설명
variantdefault / outline / destructive / ghost모양
sizedefault / sm / lg / icon크기
disabledboolean비활성화
실제로 어디에 쓰나
  • 저장 버튼 — 폼 하단의 기본(파란) 버튼: <Button type="submit">저장</Button>
  • 삭제 버튼 — 위험한 동작은 variant="destructive"(빨강)로 눈에 띄게 구분
  • 취소·보조 버튼 — 덜 중요한 동작은 variant="outline"(테두리)이나 "ghost"(회색)로
  • 검색·아이콘 버튼 — 검색창 옆 돋보기처럼 아이콘만 넣을 땐 size="icon"

B-2. Input / Textarea

import { Input } from "@/components/ui/input"

<Input
  placeholder="검색어 입력"
  value={keyword}
  onChange={(e) => setKeyword(e.target.value)}
/>

// 자동 높이 조절 textarea
import { AutosizeTextarea } from "@/components/ui/autosize-textarea"
<AutosizeTextarea placeholder="비고" rows={3} />
제어 컴포넌트

value + onChange 로 값을 React가 쥐고 있는 입력창이 제어 컴포넌트? 예요. 검증·초기화가 쉽습니다.

B-3. Select

여러 조각(Trigger/Content/Item)을 조립해서 씁니다.

import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from "@/components/ui/select"

<Select value={status} onValueChange={setStatus}>
  <SelectTrigger className="w-full">
    <SelectValue placeholder="상태 선택" />
  </SelectTrigger>
  <SelectContent>
    <SelectItem value="ACTIVE">사용중</SelectItem>
    <SelectItem value="INACTIVE">미사용</SelectItem>
  </SelectContent>
</Select>
조각역할
SelectTrigger클릭하는 버튼 영역
SelectValue선택된 값 / placeholder 표시
SelectContent펼쳐지는 목록
SelectItem각 항목(value 필수)

항목이 많아 검색해서 고르고 싶을 때Combobox(입력하며 거르는 셀렉트)를 씁니다. 예: 수백 개의 회사·품목 목록처럼 스크롤로 찾기 힘든 경우.

실제로 어디에 쓰나
  • 공통코드 선택 — 장비 상태, 검수 구분 같은 정해진 코드값 고르기(Select)
  • 회사 선택 — 주문 회사처럼 후보가 많으면 검색형 Combobox
  • 품목 선택 — 재고/주문에서 품목을 이름으로 검색해 고르기(Combobox)
  • 연관 선택 — 대분류 Select를 고르면 중분류 목록이 바뀌는 단계 선택

B-4. Dialog (모달)

import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription
} from "@/components/ui/dialog"

<Dialog open={isOpen} onOpenChange={setIsOpen}>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>장비 등록</DialogTitle>
      <DialogDescription>새 장비를 등록합니다.</DialogDescription>
    </DialogHeader>
    <EquipmentCreateForm onSuccess={() => setIsOpen(false)} />
  </DialogContent>
</Dialog>

open 으로 열림 여부를 상태? 로 제어하고, onOpenChange 로 닫힘을 받습니다.

실제로 어디에 쓰나
  • 삭제 확인창 — "정말 삭제할까요?" 묻고 [확인]/[취소] 받기(실무에선 이걸 감싼 DeleteButton을 씀)
  • 간단 입력 팝업 — 장비/사용자 빠른 등록처럼 화면 이동 없이 작은 폼 띄우기
  • 경고·안내 — 저장 전 필수값 누락 알림 같은 짧은 메시지

B-5. Tabs

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

<Tabs defaultValue="equipment" onValueChange={setSelectedTab}>
  <TabsList>
    <TabsTrigger value="equipment">장비</TabsTrigger>
    <TabsTrigger value="methods">실험방법</TabsTrigger>
  </TabsList>

  <TabsContent value="equipment"><EquipmentTab /></TabsContent>
  <TabsContent value="methods"><MethodsTab /></TabsContent>
</Tabs>
value 가 짝

TabsTrigger valueTabsContent value같은 값이어야 탭과 내용이 연결됩니다.

실제로 어디에 쓰나
  • 상세 화면의 여러 탭 — 프로젝트 상세를 [기본정보]·[장비]·[실험방법] 탭으로 나누기
  • 주문 상세 — [주문 정보]·[검수 결과]·[첨부파일]을 한 화면에서 탭으로 전환
  • 설정 화면 — [내 정보]·[권한]·[알림 설정] 같은 범주 구분

B-6. Form (react-hook-form 연동)

import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"

<Form {...form}>
  <FormField
    control={form.control}
    name="name"
    render={({ field }) => (
      <FormItem>
        <FormLabel>장비명</FormLabel>
        <FormControl>
          <Input {...field} />
        </FormControl>
        <FormMessage />  {/* 유효성 에러 메시지 자동 표시 */}
      </FormItem>
    )}
  />
</Form>

이 패턴은 매번 쓰기 번거로워요. 그래서 우리 프로젝트는 이걸 한 줄로 줄인 래퍼(C-4)를 따로 만들어 둡니다.

실제로 어디에 쓰나
  • 생성 폼 — 새 장비/사용자/재고 등록 화면의 입력칸과 검증
  • 수정 폼 — 상세 Sheet 안에서 기존 값을 불러와 고치는 폼
  • 에러 표시 — 필수값 누락·형식 오류를 FormMessage가 입력칸 아래 자동 표시

C. 프로젝트 공통 컴포넌트

Shadcn 위에 우리 프로젝트가 직접 만든 재사용 컴포넌트들입니다. 화면을 만들 때 거의 항상 씁니다.

C-1. Page / PageHeader / PageBody / PageActions

파일: src/components/page.tsx — 모든 페이지의 기본 뼈대. 권한 체크도 포함합니다.

import { Page, PageHeader, PageBody, PageTitle, PageActions } from "@/components/page"

<Page padding menuCode="masters.equipments">
  {/* 상단 고정 헤더 (높이 64px) */}
  <PageHeader>
    <PageTitle desc="보조 설명">실험장비</PageTitle>
    <PageActions>
      <Button>등록</Button>
    </PageActions>
  </PageHeader>

  {/* 스크롤 가능한 본문 */}
  <PageBody padding>
    <ListView />
  </PageBody>
</Page>
┌── Page ──────────────────────────────┐
│ ┌── PageHeader ────────────────────┐ │  ← 고정, h-16, border-b
│ │ PageTitle      │   PageActions    │ │
│ └──────────────────────────────────┘ │
│ ┌── PageBody ──────────────────────┐ │  ← overflow-auto, flex-1
│ │  (스크롤 가능 컨텐츠)              │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────┘
컴포넌트Prop설명
PagemenuCode권한 체크용 (canRead=false면 403)
Pagepadding내부 패딩
PageTitledesc부제(작은 회색 텍스트)
PageBodypadding본문 패딩(p-4 md:px-6)

C-2. DataTable

파일: src/components/data-table.tsx — TanStack React Table 기반 테이블. 정렬, 행 선택, 드래그 정렬을 지원합니다.

import { DataTable } from "@/components/data-table"

<DataTable
  data={items}                         // 데이터 배열
  columns={columns}                    // 컬럼 정의
  loading={isLoading}                  // 로딩 스피너
  emptyMessage="데이터가 없습니다"
  activeItem={activeItem}              // 현재 선택 행
  onActiveItemChanged={setActiveItem}  // 행 클릭 시
  sorting={sorting}
  onSortChange={onSortChange}
/>

컬럼 정의:

import { ColumnDef } from "@tanstack/react-table"
import { SortableHeader } from "@/components/data-table"

const columns: ColumnDef<EquipmentListItem>[] = [
  { accessorKey: "name", header: "장비명" },
  {
    accessorKey: "id",
    header: ({ column }) => <SortableHeader column={column}>ID</SortableHeader>,
    meta: { width: 80, align: "center" },
  },
  {
    accessorKey: "status",
    header: "상태",
    cell: ({ row }) => (
      <StatusBadge status={row.original.active ? "success" : "error"} />
    ),
  },
]

리스트를 .map 으로 그릴 때 React Table이 내부적으로 행마다 key? 를 관리합니다.

실제로 어디에 쓰나
  • 사용자 목록 — 이름·아이디·권한·상태 컬럼, 행 클릭 시 상세 Sheet 열기
  • 장비 목록 — 장비명·구분·구매일 정렬, 상태는 StatusBadge 셀로 표시
  • 재고 목록 — 품목명·수량(오른쪽 정렬)·단위, 부족분은 빨강으로 강조
  • 거의 모든 목록 화면 — 주문·프로젝트 등 표 형태는 전부 DataTable

C-3. Sheet (우측 상세 패널)

파일: src/components/sheet.tsx — 화면 오른쪽에서 슬라이드로 나타나는 상세보기 패널. 헤더 고정 + 본문 스크롤 + 하단 툴바 고정 구조입니다.

import {
  Sheet, SheetContent, SheetHeader, SheetBody,
  SheetToolbar, SheetToolbarSpacer, SheetTitle
} from "@/components/sheet"

<Sheet open={!!activeItem} onOpenChange={(open) => { if (!open) clearActiveItem() }}>
  <SheetContent>
    <SheetHeader>
      <SheetTitle>장비 상세</SheetTitle>
    </SheetHeader>

    <SheetBody>
      <EquipmentEditForm detail={detail} />
    </SheetBody>

    <SheetToolbar>
      <DeleteButton />
      <SheetToolbarSpacer />  {/* 왼쪽: 삭제, 오른쪽: 저장 */}
      <SaveButton />
    </SheetToolbar>
  </SheetContent>
</Sheet>
열기/닫기 패턴

activeItem 이 있으면 열림, 없으면 닫힘. open={!!activeItem} 로 제어합니다.

실제로 어디에 쓰나
  • 목록에서 행 클릭 → 상세 — 사용자/장비 목록에서 행을 누르면 오른쪽에서 상세 패널이 슬라이드
  • 옆에서 바로 수정 — 화면 이동 없이 Sheet 안에서 값 고치고 하단 [저장]
  • 삭제 + 저장 툴바 — 하단 고정 툴바에 왼쪽 [삭제]·오른쪽 [저장]을 SheetToolbarSpacer로 양끝 배치

C-4. FormField 래퍼

파일: src/components/form-fields.tsxB-6FormField 보일러플레이트를 한 줄로 줄인 래퍼들입니다.

import { TextField, TextArea, SelectField, DateField, CheckboxField } from "@/components/form-fields"

<TextField control={form.control} name="name" label="장비명" required />
<TextArea control={form.control} name="note" label="비고" rows={3} />

<SelectField
  control={form.control} name="status" label="상태"
  items={[
    { label: "사용중", value: "ACTIVE" },
    { label: "미사용", value: "INACTIVE" },
  ]}
/>

<DateField
  control={form.control} name="purchaseDate" label="구매일" required
  disabledDate={(date) => date > new Date()}  // 미래 날짜 금지
/>

<CheckboxField control={form.control} name="active" label="사용여부" fieldLabel="상태" />

공통 props?: control(useForm의 control), name(defaultValues의 키와 일치), label, required, disabled.

실제로 어디에 쓰나
  • 장비 등록·수정 폼 — 장비명은 TextField, 비고는 TextArea, 구매일은 DateField
  • 상태·구분 선택 — 공통코드 목록을 SelectFielditems로 넘겨 드롭다운 생성
  • 사용여부 토글 — on/off 값은 CheckboxField
  • 날짜 제약 — 구매일에 미래 날짜 금지 등 disabledDate로 입력 제한

C-5. SearchForm

파일: src/components/form/SearchForm.tsx — 검색 조건 폼 + 상태 관리 Hook(useSearchForm).

import { SearchForm, useSearchForm } from "@/components/form/SearchForm"

const { filter, submit, reset, setValue, setValueAndSubmit, touched } =
  useSearchForm<EquipmentFilter>(
    onSubmit,         // 검색 실행 콜백
    { keyword: "" },  // 기본값 (reset 시 돌아갈 값)
    initialFilter     // 초기값 (URL 복원 등)
  )

<SearchForm onSubmit={submit} onReset={touched ? reset : undefined}>
  <Input
    placeholder="장비명, 시리얼번호"
    value={filter.keyword || ""}
    onChange={(e) => setValue("keyword", e.target.value)}
  />
</SearchForm>
useSearchForm 반환설명
filter현재 검색 조건
submit검색 실행
reset기본값으로 초기화
setValue특정 필드 값 변경
setValueAndSubmit값 변경 후 즉시 검색
touched초기값과 달라졌는지

C-6. Pagination

파일: src/components/pagination.tsx — Spring Boot의 Page 응답과 호환되는 페이지네이션.

import { Pagination, initialPageable } from "@/components/pagination"

const pageable = useSignal<Pageable>(initialPageable())  // { page: 0, size: 20 }

<Pagination
  page={data?.pageInfo}     // { number, totalPages, totalElements, ... }
  onChange={(n) => { pageable.value = { ...pageable.value, page: n } }}        // zero-based
  onPageSizeChange={(size) => { pageable.value = { ...pageable.value, size } }}
/>
전체 1,234건   [<<] [<] [>] [>>]   [1 ▼] / 10 Pages   [20 ▼] Rows / Page

C-7. 기타 유틸리티

// 삭제 확인 다이얼로그 포함 버튼
<DeleteButton onConfirm={async () => { await EquipmentEndpoint.delete(id) }} />

// 상태 점 배지
<StatusBadge status="success" />   {/* 초록 */}
<StatusBadge status="error" />     {/* 빨강 */}

// 검색어 하이라이트
<HighlightKeyword keyword={keyword}>{row.getValue("name")}</HighlightKeyword>

// 엑셀 다운로드
<DownloadButton onClick={() => downloadTable("table-id", "파일명.xlsx")} />

잠깐 체험 — props 가 "모양"을 바꾼다

Shadcn Button의 variant 처럼, props? 하나로 컴포넌트 모양이 바뀌는 원리를 순수 React + 인라인 스타일로 체험해봅시다(Tailwind className은 이 실행창에서 안 먹어서 인라인 style 로 흉내냈어요). variant 값을 "outline" 으로 바꿔 ▶ 실행 해보세요.

핵심

같은 컴포넌트라도 props 하나로 모양·동작이 달라집니다. 실제 Shadcn/공통 컴포넌트도 전부 이 원리예요 — 차이는 스타일을 인라인 대신 Tailwind 클래스로 고른다는 점뿐.

D. 한눈에 정리

구분도구한 줄 요약언제
스타일Tailwind유틸리티 클래스 조합모든 화면
스타일cn()조건부 클래스 합치기동적 클래스
UIButton/Input/Select복사해 쓰는 기본 컴포넌트입력/버튼
UIDialog/Tabs모달 / 탭팝업, 탭 화면
UIFormreact-hook-form 연동(저수준)
공통Page 계열페이지 뼈대 + 권한모든 페이지
공통DataTable정렬·선택 테이블목록
공통Sheet우측 상세 패널상세/수정
공통FormField 래퍼한 줄 폼 필드(고수준)
공통SearchForm검색 조건 폼+상태목록 상단
공통PaginationSpring Page 호환 페이징목록 하단

전체 조합 흐름

<Page padding menuCode="masters.equipments">
  <PageHeader>
    <PageTitle>실험장비</PageTitle>
    <PageActions><AddEquipmentDialog /></PageActions>
  </PageHeader>
  <PageBody padding>
    <SearchForm onSubmit={submit} onReset={reset}>...</SearchForm>
    <DataTable data={data?.content} columns={columns}
      activeItem={activeItem} onActiveItemChanged={setActiveItem} />
    <Pagination page={data?.pageInfo} onChange={handlePageChange} />
  </PageBody>

  <Sheet open={!!activeItem}>
    <SheetContent>
      <SheetHeader><SheetTitle>장비 상세</SheetTitle></SheetHeader>
      <SheetBody>
        <TextField control={form.control} name="name" label="장비명" />
        <DateField control={form.control} name="purchaseDate" label="구매일" />
      </SheetBody>
      <SheetToolbar>
        <DeleteButton onConfirm={handleDelete} />
        <SheetToolbarSpacer />
        <SaveButton onClick={handleSave} />
      </SheetToolbar>
    </SheetContent>
  </Sheet>
</Page>
마지막 장입니다

여기까지 오느라 고생했어요. 이제 Tailwind로 모양을 잡고, Shadcn으로 컴포넌트를 가져와, 공통 컴포넌트로 화면을 조립할 수 있습니다. 직접 pnpm dev 로 띄워 한 화면씩 만들어 보세요.