실무 패턴 · 6장 화면 패턴

6장 · 화면 패턴

앞 장에서 익힌 도구들 — 상태?, store?(Zustand?), React Query? — 를 실제 화면에 어떻게 조립하는지 6가지 유형으로 배웁니다. "내 화면은 어떤 유형일까?"를 떠올리며 읽으세요.

이 장의 코드는 대부분 "읽는 코드"예요

이 장 코드는 프로젝트 실무 코드(DataTable·useQuery·Zustand store 등)라 오른쪽 연습장에서 실행할 수 없어요. 그래서 실무 코드는 전부 정적 코드블록으로 보여 줍니다. 직접 실행하는 플레이그라운드는 맨 끝 유형 E(탭 전환)에서 순수 React로 핵심 아이디어만 한 번 시연합니다.

2장이 "망치·드라이버(훅)", 4장이 "전동공구(라이브러리)"였다면, 6장은 "이 공구들로 식탁·책장을 짜는 법(화면)"입니다.

유형 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 에

검색 조건을 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 갱신 → 표 다시 그림
queryKey = useEffect 의존성 배열과 같은 멘탈모델

2장에서 "의존성 배열? 의 값이 바뀌면 useEffect 가 다시 실행된다"고 했죠. queryKey 도 똑같습니다: queryKey 안의 값이 바뀌면 쿼리? 가 다시 요청해요. 그래서 filterqueryKey 에 넣어 두면, 검색 조건이 바뀔 때마다 자동으로 새 목록을 받아 옵니다.

상세 흐름 — 행을 클릭하면 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() 인스턴스
접근직접 importContext 로
개수앱 전체 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)
  // ...
}
왜 useRef 로 store 를 보관하나

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} />
}
왜 여기서 useMemo 인가

컬럼 배열을 매 렌더마다 새로 만들면 표가 불필요하게 다시 그려집니다. 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-1212칸 격자
col-span-66칸 차지 (절반)
col-span-33칸 차지 (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 화면으로 막음

내 화면은 어떤 유형?

만들려는 화면추천 유형핵심 도구
목록 + 검색 + 상세보기ADataTable, useQuery, store
하위 컴포넌트가 상태 공유 / 페이지별 독립 storeBcreateStore + Context
데이터에 따라 컬럼이 바뀜CuseMemo
통계 카드 나열DTailwind grid, Card, recharts
같은 데이터를 다른 관점으로EviewMode + 조건부 렌더링
권한별 버튼 노출/비활성FusePermission, 권한 store
한 유형만 골라야 하는 건 아니다

실제 화면은 여러 유형을 조합합니다. 예를 들어 장비 목록(유형 A)도 권한 체크(유형 F)를 같이 써요. "목록은 A로 시작하고, 권한이 필요하면 F를 얹는다"처럼 레고처럼 합쳐 쓰세요.