Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 실전 React 개발하기: 컴포넌트, Hooks, 테스트, 리뷰

Claude Code로 React 개발을 안전하게 빠르게 진행하는 컴포넌트 설계, Hooks, 테스트, 접근성 가이드.

Claude Code로 실전 React 개발하기: 컴포넌트, Hooks, 테스트, 리뷰

Claude Code는 React 화면을 빠르게 만들 수 있지만, 운영팀에 필요한 것은 단순한 생성 속도가 아닙니다. 중요한 것은 작고, 리뷰 가능하고, 테스트로 확인할 수 있는 변경입니다. 경계 없이 “관리 화면을 만들어줘”라고 하면 파일은 많아지지만 props와 state가 엉키기 쉽습니다.

이 글은 React + TypeScript에서 Claude Code를 실무에 쓰는 방법을 다룹니다. 컴포넌트 경계, props, state, custom hook, 폼, 데이터 가져오기, Testing Library, 접근성, 성능, 리뷰 prompt까지 연결합니다. 공식 기준은 React 문서, React Testing Library, MDN ARIA, Claude Code 문서를 참고하세요. 관련 글로는 TypeScript 팁, 테스트 전략, 접근성 구현, 성능 최적화가 좋습니다.

먼저 맡길 일을 정의하기

props는 부모가 자식에게 주는 입력, state는 UI가 기억하는 값, custom hook은 상태와 부수 효과 로직을 재사용하는 함수입니다. 부수 효과는 API 요청, 타이머, localStorage처럼 렌더링 외부에 영향을 주는 작업입니다.

입력해야 할 정보효과
화면 책임사용자 목록, 역할 필터, 계정 활성화거대한 컴포넌트를 막음
상태 위치부모, URL, hook, server cacheuseState 난립 방지
데이터 타입User, API 응답, empty stateprops와 테스트가 명확해짐
검증npm test, npm run build, 키보드 확인리뷰 증거가 남음

실제 유스케이스

첫 번째는 내부 어드민 리스트입니다. 사용자, 주문, 청구서, 문의, 권한 화면은 필터, 테이블, 행 액션, 빈 상태, 에러 상태가 반복됩니다. Claude Code에는 기존 API 타입을 바꾸지 말고 필요한 컴포넌트만 나누라고 지시합니다.

두 번째는 폼입니다. 프로필 수정, 상담 예약, 교육 신청, 결제 정보 입력에서는 label, validation, 제출 중 상태, 실패 후 재시도가 중요합니다. 더 복잡한 폼은 React Hook Form 가이드와 연결할 수 있습니다.

세 번째는 데이터 가져오기 화면입니다. 검색 결과, 알림, dashboard에서는 server state와 임시 UI state를 분리해야 합니다. TanStack Query를 쓴다면 TanStack Query 가이드, 작은 클라이언트 상태는 Zustand 상태 관리를 참고하세요.

네 번째는 리뷰입니다. 새 UI 생성을 요청하기보다 diff를 읽고 컴포넌트 경계, 불필요한 Effect, 접근성 누락, 취약한 테스트, 과도한 리렌더링을 찾아 달라고 요청하면 실제 팀 작업에 더 잘 맞습니다.

구조 다이어그램

UserAdminPage
  -> UserFilters: 검색과 역할 필터
  -> UserTable: 표, 정렬 라벨, 행 액션
  -> UserStatusBadge: 상태 표시만 담당
  -> UserEditDialog: 편집 폼
  -> useUsers: fetch, refresh, error state

컴포넌트의 props를 한 문장으로 설명할 수 없다면 책임이 너무 많을 가능성이 큽니다. 반대로 한 번만 쓰이고 행동도 없는 컴포넌트는 아직 분리하지 않아도 됩니다.

타입이 있는 React 컴포넌트

import type { FormEvent } from "react";

export type UserRole = "admin" | "editor" | "viewer";

export type User = {
  id: string;
  name: string;
  email: string;
  role: UserRole;
  active: boolean;
};

type UserTableProps = {
  users: User[];
  selectedRole: "all" | UserRole;
  onRoleChange: (role: "all" | UserRole) => void;
  onToggleActive: (id: string) => void;
};

export function UserTable({ users, selectedRole, onRoleChange, onToggleActive }: UserTableProps) {
  const filteredUsers = selectedRole === "all" ? users : users.filter((user) => user.role === selectedRole);

  function handleRoleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    onRoleChange(formData.get("role") as "all" | UserRole);
  }

  return (
    <section aria-labelledby="user-table-title">
      <h2 id="user-table-title">Team members</h2>
      <form onSubmit={handleRoleSubmit} style={{ marginBottom: 12 }}>
        <label htmlFor="role">Filter by role </label>
        <select id="role" name="role" defaultValue={selectedRole}>
          <option value="all">All</option>
          <option value="admin">Admin</option>
          <option value="editor">Editor</option>
          <option value="viewer">Viewer</option>
        </select>
        <button type="submit">Apply</button>
      </form>
      {filteredUsers.length === 0 ? (
        <p role="status">No users match this filter.</p>
      ) : (
        <table>
          <thead>
            <tr>
              <th scope="col">Name</th>
              <th scope="col">Email</th>
              <th scope="col">Role</th>
              <th scope="col">Status</th>
              <th scope="col">Action</th>
            </tr>
          </thead>
          <tbody>
            {filteredUsers.map((user) => (
              <tr key={user.id}>
                <td>{user.name}</td>
                <td>{user.email}</td>
                <td>{user.role}</td>
                <td>{user.active ? "Active" : "Paused"}</td>
                <td>
                  <button type="button" onClick={() => onToggleActive(user.id)}>
                    {user.active ? "Pause" : "Activate"}
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </section>
  );
}

custom hook과 테스트

import { useEffect, useState } from "react";
import type { User } from "./UserTable";

type UsersState =
  | { status: "loading"; users: User[]; error: null }
  | { status: "success"; users: User[]; error: null }
  | { status: "error"; users: User[]; error: string };

export function useUsers(endpoint: string) {
  const [state, setState] = useState<UsersState>({ status: "loading", users: [], error: null });

  useEffect(() => {
    const controller = new AbortController();
    async function loadUsers() {
      setState({ status: "loading", users: [], error: null });
      try {
        const response = await fetch(endpoint, { signal: controller.signal });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const users = (await response.json()) as User[];
        setState({ status: "success", users, error: null });
      } catch (error) {
        if (error instanceof DOMException && error.name === "AbortError") return;
        setState({ status: "error", users: [], error: error instanceof Error ? error.message : "Unknown error" });
      }
    }
    void loadUsers();
    return () => controller.abort();
  }, [endpoint]);

  return state;
}
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { UserTable, type User } from "./UserTable";

const users: User[] = [
  { id: "1", name: "Masa", email: "masa@example.com", role: "admin", active: true },
  { id: "2", name: "Aiko", email: "aiko@example.com", role: "viewer", active: false },
];

describe("UserTable", () => {
  it("filters users and toggles active status", async () => {
    const user = userEvent.setup();
    const onRoleChange = vi.fn();
    const onToggleActive = vi.fn();
    render(<UserTable users={users} selectedRole="all" onRoleChange={onRoleChange} onToggleActive={onToggleActive} />);
    await user.selectOptions(screen.getByLabelText(/filter by role/i), "viewer");
    await user.click(screen.getByRole("button", { name: /apply/i }));
    await user.click(screen.getByRole("button", { name: /activate/i }));
    expect(onRoleChange).toHaveBeenCalledWith("viewer");
    expect(onToggleActive).toHaveBeenCalledWith("2");
  });
});

Claude Code 리뷰 prompt

React + TypeScript diff를 리뷰해 주세요.
컴포넌트 경계, props 과다, 불필요한 useEffect, UI state와 server state 혼합, 폼 label과 에러 메시지, Testing Library 테스트, 큰 리스트의 리렌더링 위험을 확인해 주세요.
새 라이브러리는 추가하지 말고 제안만 해 주세요. 마지막에 npm test와 npm run build 확인 목록을 써 주세요.

주의할 실패 패턴

과도한 컴포넌트 생성, 불필요한 useEffect, placeholder만 있는 입력, 이름 없는 아이콘 버튼, 측정 없는 memo 남발이 흔한 문제입니다. Claude Code에는 “필요한 최소 추상화만”, “사용자 관점 테스트 포함”, “접근성 확인 포함”이라고 명확히 적어야 합니다.

CTA와 확인 결과

팀에서 도입한다면 CLAUDE.md에 컴포넌트 규칙, 테스트 명령, 접근성 기준, 금지할 과잉 생성을 적어 두세요. 실제 React 저장소에 맞춰 이 흐름을 정착시키고 싶다면 ClaudeCodeLab 교육과 상담에서 UI 리뷰, Testing Library, 성능 검증, 상담/신청 CTA까지 함께 설계할 수 있습니다.

실제 적용 결과, Masa가 콘텐츠 사이트 관리자 UI를 고칠 때 처음부터 경계와 상태 소유권, 테스트 기대값을 지정하자 나중의 props 정리와 접근성 수정이 줄었습니다. 안정적인 방식은 많이 생성하는 것이 아니라 작고 검증 가능한 diff를 만드는 것입니다.

#Claude Code #React #frontend #components #Hooks
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.