7장 · UI 컴포넌트
화면의 "모양"을 만드는 도구들: Tailwind? 로 스타일을 입히고, Shadcn UI? 로 기본 컴포넌트? 를 복사해 쓰고, 그 위에 우리 프로젝트가 만든 공통 컴포넌트를 조립합니다.
Tailwind·Shadcn은 빌드 도구가 있어야 동작해서 이 사이트의 실행창(플레이그라운드)에서는 돌릴 수 없어요.
그래서 거의 모든 예제는 실제 프로젝트 코드(TSX) 그대로 읽기용입니다. 진짜 동작은 pnpm dev 로 띄운 프로젝트에서 확인하세요.
맨 아래 한 곳에서만, props 가 모양을 바꾸는 원리를 순수 React로 체험합니다.
A. Tailwind CSS 기초
Tailwind? 는 CSS 파일을 따로 쓰지 않습니다.
className 에 flex, p-4, text-lg 같은
유틸리티 클래스를 조합해서 스타일을 입혀요.
<div className="flex gap-2 p-4 rounded-lg bg-white">...</div> // └ flex 배치 └ 간격 8px └ 여백 16px └ 둥근 모서리 └ 흰 배경
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 대응 |
|---|---|---|
flex | Flexbox 켜기 | display: flex |
flex-col | 세로 방향 | flex-direction: column |
flex-1 | 남은 공간 채우기 | flex: 1 |
items-center | 수직 가운데 | align-items: center |
justify-between | 양쪽 끝 정렬 | justify-content: space-between |
gap-4 | 아이템 간격 16px | gap: 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 */}| 값 | px | rem |
|---|---|---|
| 1 | 4px | 0.25rem |
| 2 | 8px | 0.5rem |
| 4 | 16px | 1rem |
| 6 | 24px | 1.5rem |
| 8 | 32px | 2rem |
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를 씁니다.
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 | 값 | 설명 |
|---|---|---|
variant | default / outline / destructive / ghost | 모양 |
size | default / sm / lg / icon | 크기 |
disabled | boolean | 비활성화 |
- 저장 버튼 — 폼 하단의 기본(파란) 버튼:
<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>TabsTrigger value 와 TabsContent 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 | 설명 |
|---|---|---|
| Page | menuCode | 권한 체크용 (canRead=false면 403) |
| Page | padding | 내부 패딩 |
| PageTitle | desc | 부제(작은 회색 텍스트) |
| PageBody | padding | 본문 패딩(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.tsx — B-6의 FormField 보일러플레이트를 한 줄로 줄인 래퍼들입니다.
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 - 상태·구분 선택 — 공통코드 목록을
SelectField의items로 넘겨 드롭다운 생성 - 사용여부 토글 — 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() | 조건부 클래스 합치기 | 동적 클래스 |
| UI | Button/Input/Select | 복사해 쓰는 기본 컴포넌트 | 입력/버튼 |
| UI | Dialog/Tabs | 모달 / 탭 | 팝업, 탭 화면 |
| UI | Form | react-hook-form 연동(저수준) | 폼 |
| 공통 | Page 계열 | 페이지 뼈대 + 권한 | 모든 페이지 |
| 공통 | DataTable | 정렬·선택 테이블 | 목록 |
| 공통 | Sheet | 우측 상세 패널 | 상세/수정 |
| 공통 | FormField 래퍼 | 한 줄 폼 필드(고수준) | 폼 |
| 공통 | SearchForm | 검색 조건 폼+상태 | 목록 상단 |
| 공통 | Pagination | Spring 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 로 띄워 한 화면씩 만들어 보세요.