6장 · 화면 패턴
앞 장에서 익힌 도구들 — 상태?, store?(Zustand?), React Query? — 를 실제 화면에 어떻게 조립하는지 6가지 유형으로 배웁니다. "내 화면은 어떤 유형일까?"를 떠올리며 읽으세요.
이 장 코드는 프로젝트 실무 코드(DataTable·useQuery·Zustand store 등)라 오른쪽 연습장에서 실행할 수 없어요. 그래서 실무 코드는 전부 정적 코드블록으로 보여 줍니다. 직접 실행하는 플레이그라운드는 맨 끝 유형 E(탭 전환)에서 순수 React로 핵심 아이디어만 한 번 시연합니다.
유형 A · CRUD 리스트 (가장 중요)
가장 많이 만드는 화면입니다. 예: 장비 목록, 사용자 목록. 목록 표 + 검색 + 페이지네이션 + 행 클릭 → 상세(Sheet) 로 이루어져요. 이 한 유형만 익혀도 화면의 절반은 만듭니다.
┌─────────────────────────────────────┐ │ 제목 + [등록] 버튼 │ ├─────────────────────────────────────┤ │ 검색폼 (키워드, 상태…) │ ┌──────────────┐ ├─────────────────────────────────────┤ │ Sheet 상세 │ │ DataTable (정렬·행 클릭 가능) ──┼──→ │ 수정 / 삭제 │ ├─────────────────────────────────────┤ └──────────────┘ │ Pagination │ └─────────────────────────────────────┘
무엇을 쓰나
| 도구 | 역할 |
|---|---|
| DataTable | 표를 그려주는 공통 컴포넌트(정렬·로딩·행 클릭 내장) |
| useQuery? | 목록 데이터를 서버에서 받아옴 |
| store?(Zustand) | "지금 어떤 행이 선택됐나"를 보관 → Sheet 열림 제어 |
| Sheet | 오른쪽에서 미끄러져 나오는 상세 패널 |
단계별 빌드업 (7단계)
① 타입 정의 — 서버와 주고받을 데이터의 모양부터 정합니다.
// 목록 한 줄의 모양
export interface EquipmentListItem {
id: number
name: string
serialNo: string
active: boolean
}
// 검색 조건의 모양
export interface EquipmentFilter {
keyword?: string
active?: boolean
}② Endpoint — 서버 호출 함수를 한곳에 모읍니다.
export const EquipmentEndpoint = {
getList: (filter, pageable, sorting) =>
api.get("/api/equipments", { params: { ...filter, ...pageable } }),
getById: (id) => api.get(`/api/equipments/${id}`),
create: (data) => api.post("/api/equipments", data),
update: (id, data) => api.put(`/api/equipments/${id}`, data),
delete: (id) => api.delete(`/api/equipments/${id}`),
}③ Query Hook — 검색 조건을 queryKey 에 담는 게 핵심입니다. 이렇게 해 두면 조건이 바뀔 때 직접 다시 부르지 않아도 표가 알아서 갱신돼요.
export const useEquipmentListQuery = (filter, pageable, sorting) =>
useQuery({
// filter/pageable/sorting 이 바뀌면 queryKey 가 바뀜 → 자동 재조회
queryKey: ["equipment-list", filter, pageable, sorting],
queryFn: () => EquipmentEndpoint.getList(filter, pageable, sorting),
})검색 조건을 queryKey 에 넣어 두면, 조건이 바뀔 때마다
React Query? 가 알아서 다시 요청합니다.
직접 useEffect + fetch 를 짤 필요가 없어요. (자세한 이유는 아래 "데이터 흐름 요약"에서.)
④ 컬럼 정의 — 표의 각 열을 어떻게 그릴지 적습니다.
const columns: ColumnDef<EquipmentListItem>[] = [
{
accessorKey: "id",
header: ({ column }) => <SortableHeader column={column}>ID</SortableHeader>,
meta: { width: 80, align: "center" },
},
{
accessorKey: "name",
header: "장비명",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<StatusBadge status={row.original.active ? "success" : "error"} />
{row.getValue("name")}
</div>
),
},
{ accessorKey: "serialNo", header: "시리얼 번호" },
]| 속성 | 설명 |
|---|---|
accessorKey | 데이터 객체의 필드명 |
header | 헤더 (문자열 또는 함수) |
cell | 셀을 직접 그리고 싶을 때 |
meta.width / meta.align | 너비(px) / 정렬 |
⑤ 검색폼 — 입력값을 모아 onSubmit 으로 넘깁니다.
export const SearchFormView = ({ onSubmit }) => {
const { filter, submit, reset, setValue } = useSearchForm(onSubmit, { keyword: "" })
return (
<SearchForm onSubmit={submit} onReset={reset}>
<Input
placeholder="장비명, 시리얼번호"
value={filter.keyword || ""}
onChange={(e) => setValue("keyword", e.target.value)}
/>
</SearchForm>
)
}⑥ ListView 조립 — 검색폼 + 표 + 페이지네이션을 한 화면으로 묶습니다.
const ListView = () => {
const [filter, setFilter] = useState({})
const [pageable, setPageable] = useState(initialPageable())
const [sorting, setSorting] = useState([{ id: "id", desc: true }])
// 조건이 바뀌면 queryKey 가 바뀌어 자동 재조회됨
const { data, isLoading } = useEquipmentListQuery(filter, pageable, sorting)
// 선택된 행은 store 에 보관 (Sheet 가 이 값을 봄)
const activeItem = useEquipmentPageStore((s) => s.activeItem)
const setActiveItem = useEquipmentPageStore((s) => s.actions.setActiveItem)
const onSearch = (values) => {
setPageable(initialPageable()) // 검색하면 1페이지로
setFilter(values)
}
return (
<div className="space-y-4">
<SearchFormView onSubmit={onSearch} />
<DataTable
loading={isLoading}
data={data?.content ?? []}
columns={columns}
activeItem={activeItem}
onActiveItemChanged={setActiveItem} // 행 클릭 → store 에 저장
sorting={sorting}
onSortChange={setSorting}
/>
<Pagination
page={data?.pageInfo}
onChange={(n) => setPageable({ ...pageable, page: n })}
/>
</div>
)
}⑦ DetailView + 페이지 조립 — store 의 activeItem 으로 Sheet 를 엽니다.
const DetailView = () => {
const activeItem = useEquipmentPageStore((s) => s.activeItem)
const clearActiveItem = useEquipmentPageStore((s) => s.actions.clearActiveItem)
return (
<Sheet open={!!activeItem} onOpenChange={(open) => { if (!open) clearActiveItem() }}>
<SheetContent>{/* 상세 폼 + 저장/삭제 버튼 */}</SheetContent>
</Sheet>
)
}
const EquipmentsPage = () => (
<Page menuCode="masters.equipments">
<PageHeader>
<PageTitle>실험장비</PageTitle>
<PageActions><AddEquipmentDialog /></PageActions>
</PageHeader>
<PageBody><ListView /></PageBody>
<DetailView />
</Page>
)데이터 흐름 요약
검색 흐름 — 조건을 바꾸면 표가 갱신되는 과정:
검색폼 입력 → onSearch(values) → setFilter(values) // filter 상태 변경 → queryKey 가 바뀜 // ["equipment-list", filter, ...] → useQuery 자동 refetch // React Query 가 알아서 다시 요청 → data 갱신 → 표 다시 그림
2장에서 "의존성 배열? 의 값이 바뀌면
useEffect 가 다시 실행된다"고 했죠. queryKey 도 똑같습니다:
queryKey 안의 값이 바뀌면 쿼리? 가 다시 요청해요.
그래서 filter 를 queryKey 에 넣어 두면, 검색 조건이 바뀔 때마다 자동으로 새 목록을 받아 옵니다.
상세 흐름 — 행을 클릭하면 Sheet 가 열리는 과정:
행 클릭 → setActiveItem(item) // store 에 저장
→ activeItem 변경 → store 구독 컴포넌트 리렌더
→ Sheet open={!!activeItem} // Sheet 열림
→ useEquipmentQuery(id) 로 상세 조회 → 폼에 표시- 사용자 관리 — 사용자 목록 + 이름/부서 검색, 행을 누르면 Sheet 에서 수정·삭제
- 장비 목록 — 실험장비를 검색·정렬하고, 한 건을 골라 상세를 보거나 등록
- 재고 현황 — 품목/상품 재고를 키워드로 찾고 페이지를 넘기며 확인
- 회사 목록 — 거래처 회사를 목록으로 보고 새 회사를 등록·수정
유형 B · Context/Store 활용 페이지
복잡한 화면입니다(예: 품목관리, 프로젝트 상세). 여러 하위 컴포넌트(검색폼·표·상세·툴바)가 같은 상태(필터·선택 항목 등)를 봐야 할 때, 그 상태를 페이지 로컬 store? 한곳에 모읍니다. props? 로 계속 내려보내지 않고 store 에서 꺼내 써요.
유형 A 의 store 는 앱 전체에 딱 1개(싱글턴)지만, 유형 B 는 페이지가 열릴 때마다 새 store 를 따로 만들 수 있습니다. 예를 들어 프로젝트 A 상세와 프로젝트 B 상세를 각각 열면, 서로 간섭하지 않도록 store 도 따로 생깁니다.
| 유형 A | 유형 B | |
|---|---|---|
| 만드는 법 | create() 싱글턴 | createStore() 인스턴스 |
| 접근 | 직접 import | Context 로 |
| 개수 | 앱 전체 1개 | Provider 마다 별도 |
// ① store 생성 함수 + Context
export const createMaterialsStore = () =>
createStore((set) => ({
filter: {},
actions: { setFilter: (filter) => set({ filter }) },
}))
export const MaterialsContext = createContext(null)
export const useMaterialsState = (selector) => {
const store = useContext(MaterialsContext)
if (!store) throw new Error("Provider 안에서 써야 합니다")
return useStore(store, selector)
}
// ② Provider — useRef 로 store 를 딱 1번만 생성
export const MaterialsProvider = ({ children }) => {
const store = useRef(null)
if (store.current === null) store.current = createMaterialsStore()
return (
<MaterialsContext.Provider value={store.current}>{children}</MaterialsContext.Provider>
)
}
// ③ 하위 컴포넌트에서 꺼내 쓰기
const ListView = () => {
const filter = useMaterialsState((s) => s.filter)
const { setFilter } = useMaterialsState((s) => s.actions)
// ...
}Provider 가 리렌더? 돼도 store 를 새로 만들지 않기 위해서예요. 2장에서 본 "리렌더와 무관한 값 보관" 용도 그대로입니다.
- 프로젝트 상세(주문서) — 주문 항목표·툴바·속성 패널이 같은 "선택한 주문" 상태를 함께 봐야 함
- 주문 화면 — 검색폼·주문 목록·샘플 항목 패널이 한 화면의 필터/선택값을 공유
- 품목 관리 — 여러 하위 패널이 같은 필터를 보며, 품목마다 독립된 store 가 필요
유형 C · 동적 컬럼 테이블
데이터에 따라 표의 열 개수·내용이 달라지는 화면입니다(예: 주문, 상품 분류). 검수 항목이 3개면 열도 3개, 5개면 5개. 컬럼을 고정으로 적을 수 없으니 데이터를 받아서 useMemo? 로 만들어 냅니다.
const DynamicTable = ({ testItems, data }) => {
const columns = useMemo(() => {
// 항상 보이는 기본 컬럼
const cols = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "sampleName", header: "품목명" },
]
// testItems 개수만큼 컬럼 추가
testItems.forEach((item) => {
cols.push({
id: `test_${item.id}`,
header: item.name,
cell: ({ row }) =>
row.original.results?.find((r) => r.testItemId === item.id)?.value ?? "-",
})
})
return cols
}, [testItems]) // testItems 가 바뀔 때만 다시 생성
return <DataTable columns={columns} data={data} />
}컬럼 배열을 매 렌더마다 새로 만들면 표가 불필요하게 다시 그려집니다.
testItems 가 바뀔 때만 만들도록 캐싱? 해요.
2장에서 "정말 무거울 때만 쓰라"던 useMemo 의 대표 실무 사례가 바로 이것입니다.
응용: 특정 컬럼 숨기기
const columns = useMemo(() => {
const all = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: "이름" },
{ accessorKey: "category", header: "분류" },
]
const excludeCols = ["category"] // 조건에 따라 제외할 열
return all.filter((col) => !excludeCols.includes(col.accessorKey))
}, [excludeCols])- 주문 검수 결과 입력 — 검수 항목 수만큼 입력 컬럼을 만든다 (항목 3개면 3열, 5개면 5열)
- 속성 비교표 — 비교할 샘플 항목/주문 개수만큼 컬럼을 가로로 늘려 나란히 보여줌
- 상품 분류별 결과표 — 선택한 상품 분류 개수에 따라 열이 늘었다 줄었다 함
유형 D · 대시보드
통계·요약 카드를 격자로 나열하는 화면입니다(예: 대시보드). Tailwind? 의 grid 로 카드를 배치하고, 각 카드는 자기 데이터를 useQuery? 로 받아오며, 차트는 recharts 로 그립니다.
export function DashboardPage() {
return (
<Page menuCode="dashboard">
<PageBody className="space-y-6" padding>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-4">
<div className="col-span-12 lg:col-span-6"><HazardousCard /></div>
<div className="col-span-6 lg:col-span-3"><EquipmentCard /></div>
<div className="col-span-6 lg:col-span-3"><RecentCard /></div>
</div>
</PageBody>
</Page>
)
}| 클래스 | 의미 |
|---|---|
grid | 그리드 컨테이너 |
grid-cols-12 | 12칸 격자 |
col-span-6 | 6칸 차지 (절반) |
col-span-3 | 3칸 차지 (1/4) |
gap-4 | 카드 간격 16px |
md: / lg: | 중간/큰 화면용 반응형 |
카드 한 장의 구조
function EquipmentCard() {
const { data, isLoading } = useEquipmentStatsQuery() // 카드마다 자기 데이터
return (
<Card>
<CardHeader><CardTitle>장비 가동률</CardTitle></CardHeader>
<CardContent>
{isLoading
? <LoadingBlock />
: <div className="text-3xl font-bold">{data?.rate}%</div>}
</CardContent>
</Card>
)
}- 메인 대시보드 — 위험물 현황·장비 가동률·최근 등록 등 요약 카드를 격자로 배치
- 상품 분류 분석 — 상품 분류별 통계·추세 차트(recharts)를 카드로 나열
- 월간 실적 요약 — 주문 건수·완료율 같은 KPI 카드를 한눈에 모아 보기
유형 E · 탭 전환
한 화면에서 같은 데이터를 다른 관점으로 보여줄 때 씁니다(예: 검수일지의 "샘플 항목별 ↔ 검수 항목별"). 본질은 단순해요: "현재 어떤 뷰인가"라는 상태? 하나 + 조건부 렌더링.
실무에서는 여러 컴포넌트가 공유하도록 store? 에 두기도 합니다.
const LogbookPage = () => {
const viewMode = useLogbookPageStore((s) => s.viewMode)
const { setViewMode } = useLogbookPageStore((s) => s.actions)
return (
<Page menuCode="lab.logbook">
<PageHeader>
<PageActions>
<Button variant={viewMode === "sample" ? "default" : "outline"}
onClick={() => setViewMode("sample")}>품목별</Button>
<Button variant={viewMode === "test-item" ? "default" : "outline"}
onClick={() => setViewMode("test-item")}>검수항목별</Button>
</PageActions>
</PageHeader>
<PageBody>
{viewMode === "test-item" ? <TestItemView /> : <SampleView />}
</PageBody>
</Page>
)
}
위 실무 코드는 store 를 썼지만, 핵심 아이디어는 useState 하나로도 충분합니다.
아래는 순수 React 로 만든 탭 전환이에요. tab 값을 바꾸면
리렌더? 되며 다른 내용이 나옵니다. ▶ 실행 해 탭을 눌러보세요.
- 장비 상세 — 한 장비 화면에서 "검교정" 탭 ↔ "유지보수" 탭으로 같은 장비를 다른 관점으로 보기
- 품목 상세 — 한 품목의 "기본정보 ↔ 구성 ↔ 검수이력" 탭을 오가며 표시
- 검수일지 — 같은 일지를 "샘플 항목별 ↔ 검수 항목별" 두 뷰로 전환
유형 F · 권한 기반 UI
로그인한 사용자의 권한에 따라 버튼을 보이거나 숨기는 화면입니다(예: 프로젝트 목록).
usePermission 훅으로 메뉴별 권한을 꺼내고, 권한은 로그인 시 서버에서 받아
권한 store? 에 보관해 둡니다(4장에서 본 store 구조).
function ProjectsPage() {
const { canCreate, canExport } = usePermission("projects.list")
return (
<Page menuCode="projects.list">
<PageHeader>
<PageActions>
{canCreate && <CreateProjectDialog />} {/* 등록 권한 있을 때만 */}
{canExport && <DownloadButton />} {/* 엑셀 권한 있을 때만 */}
</PageActions>
</PageHeader>
<PageBody>{/* ... */}</PageBody>
</Page>
)
}권한 체크 2단계
1단계 (접근 차단): <Page menuCode="...">
→ canRead 가 false 면 403 페이지를 띄움 (화면 자체를 못 봄)
2단계 (기능 제어): {canCreate && <AddButton />}
→ 화면은 보되, 개별 버튼만 숨기거나 비활성화버튼을 아예 안 보이게 하려면 {'{'}canCreate && <Button/>{'}'},
보이되 못 누르게 하려면 <Button disabled={'{'}!canCreate{'}'} /> 를 씁니다.
- 역할별 버튼 노출 — 읽기전용 사용자에게는 저장·삭제 버튼을 아예 숨김
- 엑셀 내보내기 제어 — 다운로드 권한이 있는 사용자에게만 [엑셀] 버튼 표시
- 등록 권한 분리 — 조회는 모두 가능하지만 [신규 등록]은 담당자에게만
- 메뉴 접근 차단 — 권한 없는 사용자가 주소로 직접 들어오면 403 화면으로 막음
내 화면은 어떤 유형?
| 만들려는 화면 | 추천 유형 | 핵심 도구 |
|---|---|---|
| 목록 + 검색 + 상세보기 | A | DataTable, useQuery, store |
| 하위 컴포넌트가 상태 공유 / 페이지별 독립 store | B | createStore + Context |
| 데이터에 따라 컬럼이 바뀜 | C | useMemo |
| 통계 카드 나열 | D | Tailwind grid, Card, recharts |
| 같은 데이터를 다른 관점으로 | E | viewMode + 조건부 렌더링 |
| 권한별 버튼 노출/비활성 | F | usePermission, 권한 store |
실제 화면은 여러 유형을 조합합니다. 예를 들어 장비 목록(유형 A)도 권한 체크(유형 F)를 같이 써요. "목록은 A로 시작하고, 권한이 필요하면 F를 얹는다"처럼 레고처럼 합쳐 쓰세요.