99명

최근 30분간 동시 방문자 수를 표시합니다. (~)

최고 동시 방문자 수 -
어제: 0명 / 오늘: 0명

Claude Code로 React 개발하기

Claude Code로 React 개발하기

Claude Code가 다양한 상황과 환경에서 만든 React 코드는 대체로 동작하지만, 파일마다 다른 React 패턴의 결과가 만들어질 수 있습니다.
어떤 컴포넌트는 React.FC로 선언되고 어떤 컴포넌트는 함수 선언으로 작성될 수 있습니다.
데이터를 가져오는 코드가 useEffect 안에 직접 들어가고, 파생 값을 useEffect와 setState로 동기화할 수 있습니다.
필요하지 않은 곳까지 useMemo와 useCallback이 추가되고, 최신 React에서는 더 이상 사용하지 않는 forwardRef가 등장할 수도 있습니다.
라우팅과 데이터 요청, 전역 상태, 스타일, 양식 처리는 외부 라이브러리를 사용하며, 선택지마다 코드 패턴이 달라집니다.

Claude Code를 사용해서 React 개발을 할 때 이러한 결정들을 어디에 두고 어떻게 검증할지, 버그와 리팩토링, 리뷰, 성능 등의 기준으로 이어 가는 방법을 살펴봅시다.

# 프로젝트 준비

영화를 검색해 목록으로 보여 주는 예제를 기반으로 설명합니다.
Vite, TypeScript, React, ESLint, Prettier, Tailwind CSS 등을 구성한 상태에서 시작하며, React 프로젝트 시작하기 w. Vite와 Tailwind CSS 핵심 패턴의 설치 부분을 참고하세요.

가장 먼저 프로젝트 루트에서 Claude Code를 실행하고 /init 명령을 입력합니다.
현재 프로젝트를 분석해서 CLAUDE.md 파일의 초안을 만듭니다.

PROMPT
1
/init

/init은 프로젝트 구조와 사용 중인 도구 등의 사실 관계를 정의합니다.
Claude가 동작할 때마다 매번 프로젝트 전체를 검토하지 않도록 기본적인 정보를 제공합니다.

다음으로 검증 명령을 확보합니다.
규칙을 잘 정리해도 결과를 확인할 방법이 없으면 어긋난 코드를 걸러 낼 수 없습니다.

/package.json
JSON
1
2
3
4
5
6
7
8
9
10
{ "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", "typecheck": "tsc -b", "lint": "eslint .", "test:unit": "vitest run" } }
검증 스크립트 추가

React JSX는 TypeScript가 직접 이해하는 문법이므로, 별도의 타입 검사 도구 없이 tsc만으로 검사합니다.
-b는 --build의 축약형으로, tsconfig.json의 references에 나열된 프로젝트를 순서대로 검사하는 옵션입니다.
Vite 템플릿은 tsconfig.app.json과 tsconfig.node.json을 프로젝트 참조로 묶고 있어서 tsc -b 하나로 소스와 설정 파일을 모두 검사하고, noEmit 설정 때문에 결과물은 만들지 않습니다.
빌드 스크립트에 이미 tsc -b가 들어 있지만, 빌드 없이 검사만 하도록 typecheck를 따로 관리합니다.

다만 React에서는 타입 검사만으로 충분하지 않습니다.
조건문 안에서 호출한 훅, 의존성 배열에서 빠진 값, 이펙트 안에서 바로 호출한 setState는 타입이 모두 맞아도 통과합니다.
이런 문제는 ESLint로 검사하며, eslint-plugin-react-hooks 플러그인이 v7부터 React Compiler 기반의 검사 규칙을 추천 프리셋에 포함합니다.
Vite 프로젝트를 만들 때 Which linter to use?에서 ESLint를 선택했다면 이 플러그인의 v7이 함께 설치되어 있습니다.
그전에 만든 프로젝트라면 설치된 버전을 확인하고, 7 미만이면 업데이트합니다.

BASH
1
npm i -D eslint-plugin-react-hooks@^7
훅 규칙 플러그인 설치

create-vite 9 버전부터 React 템플릿의 기본 린터는 Oxlint이고, 2026년 8월부터 React Compiler 기반 규칙 22개를 제공하지만, .oxlintrc.json에서 react 플러그인과 correctness 범주를 직접 켜야 합니다.
이 글은 React 프로젝트 시작하기 w. Vite와 같이 ESLint를 선택한 프로젝트를 기준으로 합니다.

/eslint.config.js
JS
1
2
3
4
5
6
7
8
9
10
import { defineConfig } from 'eslint/config' import reactHooks from 'eslint-plugin-react-hooks' // ... export default defineConfig([ { files: ['**/*.{ts,tsx}'], extends: [reactHooks.configs.flat.recommended] } ])
추천 프리셋 구성

이 명령들은 Claude가 승인 없이 실행할 수 있도록 프로젝트 설정에 등록합니다.
매번 권한을 물어보면 검증 루프의 흐름이 끊어집니다.

/.claude/settings.json
JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{ "permissions": { "allow": [ "Bash(npm run typecheck)", "Bash(npm run lint)", "Bash(npm run test:unit)" ], "deny": [ "Read(.env)", "Read(.env.local)", "Read(.env.*.local)" ] } }

deny에 등록한 경로는 Claude가 읽지 못합니다.
API 키가 포함된 파일을 제한하면 값이 대화 기록과 로그에 남지 않습니다.

거부 규칙은 읽기뿐 아니라 같은 경로의 편집과 새 파일 생성도 막습니다.
편집 차단은 Claude Code 2.1.208, 새 파일 생성 차단은 2.1.228 이상에서 동작합니다.
Read(.env*)처럼 넓게 작성하면 이름만 작성하는 견본 파일 .env.example도 만들지 못하므로, 값이 실제로 들어가는 이름만 선택해서 제한합니다.

.claude/settings.json은 팀이 공유하는 설정이므로 Git에 커밋합니다.
개인 설정은 .claude/settings.local.json에 두고 .gitignore에 추가하세요.

# React 규칙 정의

CLAUDE.md는 매 세션 시작 시 자동으로 읽히는 프로젝트 지침 파일입니다.
여기에서 추가할 내용은 프로젝트 소개가 아닌, 선택지가 여러 개인 상황에서 무엇을 선택할 것인지에 관한 내용입니다.

React에서 결정이 필요한 상황은 다음과 같습니다.
앞쪽 절반은 React 자체의 작성 방식이고, 뒤쪽 절반은 React가 결정하지 않는 생태계의 선택입니다.

달라지는 부분 선택지 지침이 없을 때
컴포넌트 선언 함수 선언 / 화살표 함수 + React.FC 파일마다 섞이고 React.FC가 등장함
Props 타입 interface / type / 인라인 같은 성격의 Props가 다르게 선언됨
파생 값 렌더 중 계산 / useEffect + setState 이펙트로 상태를 동기화하는 코드가 나옴
메모이제이션 React Compiler / 수동 useMemo, useCallback, memo 필요 없는 곳까지 메모이제이션이 추가됨
데이터 요청 TanStack Query / useEffect + fetch 로딩, 오류, 경쟁 상태 처리가 컴포넌트마다 다름
전역 상태 Zustand / Context / Redux 서버 데이터가 Context에 복사됨
라우팅 React Router의 Data 모드 / 선언형 <Routes> 두 방식이 한 프로젝트에 섞임
오류 처리 라우트 ErrorBoundary / 컴포넌트마다 isError 분기 / 전역 하나 오류 화면이 있는 곳과 없는 곳이 섞임
로딩 표시 useQuery의 상태 분기 / useSuspenseQuery + Suspense isPending 분기와 Suspense 경계가 섞임
스타일 Tailwind 유틸리티 / CSS Modules / CSS-in-JS 유틸리티 클래스와 인라인 스타일, 별도의 CSS 파일이 섞임
양식 비제어 + FormData / 제어 컴포넌트 / 폼 라이브러리 양식마다 방식이 다름
경로 참조 상대 경로 / 별칭 ../../../가 늘어남

각 항목을 하나씩 결정하고 규칙 문장으로 작성합니다.
이유를 길게 설명하기보다 무엇을 사용하고 무엇을 사용하지 않는지 짧게 작성하는 것이 좋습니다.

/CLAUDE.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
## React 코딩 규칙 - React 19 기준으로 작성한다. `forwardRef`, `defaultProps`, `PropTypes`, 클래스 컴포넌트는 사용하지 않고, `ref`는 일반 Props로 받는다. - 컴포넌트는 `export default function 이름()` 형태의 함수 선언으로 작성한다. `React.FC`는 사용하지 않는다. - Props 타입은 컴포넌트 바로 위에 `interface 이름Props`로 선언하고, 매개변수에서 구조 분해한다. - 이벤트 콜백 Props는 `on` 접두사, 컴포넌트 안의 핸들러 함수는 `handle` 접두사로 짓는다. - 파생 값은 렌더 중에 계산한다. `useEffect`와 `setState`로 다른 상태에서 상태를 만들지 않는다. - `useEffect`는 이벤트 구독, 타이머, 브라우저 API처럼 외부 시스템과 동기화할 때만 사용하고, 정리 함수를 함께 작성한다. - 메모이제이션은 React Compiler에 맡긴다. `useMemo`, `useCallback`, `memo`를 직접 작성하지 않는다. - 서버 데이터는 TanStack Query로 가져온다. `useEffect` 안에서 `fetch`를 호출하지 않는다. - 쿼리는 `/src/queries/도메인.ts`에 `queryOptions`로 정의하고, 데이터 요청과 재사용 로직은 `/src/hooks/useXxx.ts` 커스텀 훅으로 분리한다. - 서버 데이터의 로딩과 오류는 `useQuery`의 `isPending`과 `isError`로 분기한다. `useSuspenseQuery`와 데이터 로딩용 `Suspense` 경계는 사용하지 않는다. - API 응답 타입은 `/src/types/`에 정의하고, 컴포넌트 파일 안에서 다시 선언하지 않는다. - 목록의 `key`는 데이터의 고유 ID를 사용한다. 배열 인덱스는 사용하지 않는다. - `count && <List />`처럼 숫자가 될 수 있는 값을 `&&`로 조건부 렌더링하지 않는다. 삼항 연산자를 사용한다. - 클릭 동작은 `<button>`, 페이지 이동은 `<Link>`로 만든다. `<div onClick>`은 사용하지 않고, `<img>`에는 `alt`를 작성한다. - 양식은 `<form onSubmit>`으로 제출하고, 입력값은 `useState`로 제어한다. 서버로 보내는 변경은 TanStack Query의 `useMutation`으로 처리하고, `useActionState`와 폼 라이브러리는 사용하지 않는다. - 스타일은 Tailwind CSS 유틸리티 클래스로 작성한다. CSS Modules와 인라인 스타일, 별도의 CSS 파일은 사용하지 않는다. - 컴포넌트 파일 이름은 PascalCase로 짓고, 컴포넌트와 테스트 파일은 같은 폴더에 같은 이름으로 둔다. - `import`는 `@/` 별칭을 사용한다. 상대 경로는 같은 폴더 안에서만 허용한다.
React 코딩 규칙 섹션

파생 값 규칙은 특히 자주 어긋나는 부분입니다.
검색 결과에서 평점이 높은 영화만 추리는 값을 useEffect와 setState로 만들면, 원본이 바뀔 때 렌더링이 한 번 더 일어나고 그사이에 이전 값으로 그려진 화면이 잠깐 보입니다.
두 상태를 맞추는 책임도 사람에게 남습니다.
렌더 중에 계산하면 상태가 하나로 줄고 동기화할 것이 없어집니다.
React 문서의 You Might Not Need an Effect가 이 규칙의 근거입니다.

메모이제이션 규칙은 React Compiler가 설치된 프로젝트를 전제합니다.
Vite 템플릿에서 TypeScript + React Compiler를 선택했거나, React Compiler 핵심 정리를 따라 기존 프로젝트에 추가한 상태여야 합니다.
컴파일러가 있으면 수동 메모이제이션은 읽는 비용만 남고, 컴파일러가 없으면 지침이 없을 때 필요한 곳과 필요 없는 곳에 섞여서 추가될 수 있습니다.
어느 쪽이든 결정을 규칙으로 작성해야 파일마다 달라지지 않습니다.
컴파일러가 없는 프로젝트라면 메모이제이션 규칙을 다음과 같이 바꿉니다.

/CLAUDE.md
MD
1
2
- 메모이제이션은 요청에 명시된 곳에만 적용한다. `useMemo`, `useCallback`, `memo`를 스스로 판단해서 추가하지 않는다. - 적용 대상은 큰 목록의 정렬과 필터(`useMemo`), `memo`로 감싼 자식에게 전달하는 콜백(`useCallback`), 큰 목록의 항목 컴포넌트(`memo`)로 한정한다.
React Compiler가 없는 프로젝트의 메모이제이션 규칙

어디에 적용할지를 Claude의 판단에 맡기지 않고 요청에 명시하도록 정하면, 컴파일러가 없어도 메모이제이션이 파일마다 다르게 퍼지지 않습니다.

양식과 로딩 표시 규칙은 React 19 전후로 선택지가 늘어난 부분입니다.
<form action>과 useActionState로 제출을 처리하거나 useSuspenseQuery와 Suspense 경계로 로딩을 처리하는 방식도 동작합니다.
다만 이 예제는 서버 데이터를 TanStack Query가 소유하므로, 변경도 같은 라이브러리의 useMutation으로 처리해서 캐시 무효화까지 한곳에서 다룹니다.
로딩과 오류도 useQuery가 반환하는 상태로 분기하는 한 가지 방식만 사용합니다.
다른 방식을 선택해도 되지만, 한 프로젝트 안에서 두 방식이 섞이지 않도록 어느 쪽인지 규칙에 작성합니다.

규칙이 결과를 실제로 바꾸는지 확인하기 위해, 같은 요청을 규칙이 없는 프로젝트와 위 규칙을 넣은 프로젝트에 각각 보냈습니다.
두 프로젝트는 TanStack Query와 React Router, Zustand, Tailwind CSS가 설치된 같은 상태이고, /src/types/movie.ts에 Movie 타입만 있습니다.

PROMPT
1
2
3
4
영화 검색 화면을 만들어 줘. 검색 입력, 로딩 표시, 결과 목록, 영화 카드, 빈 결과 처리를 포함하고, 평점이 높은 영화만 보는 필터 토글도 넣어 줘. OMDb API(https://omdbapi.com?apikey=KEY&s=검색어, 상세는 &i=imdbID로 imdbRating 제공)를 사용한다. 컴포넌트는 src/components/ 아래에 만들고 App.tsx에 연결해 줘.
두 프로젝트에 같이 보낸 요청

규칙이 없는 프로젝트에서는 요청 함수를 /src/api/omdb.ts에 만들고, 컴포넌트 안에서 useQuery에 쿼리 키와 요청 함수를 직접 작성했습니다.
OMDb 응답 타입은 요청 함수 파일 안에 선언되었습니다.

/src/components/MovieSearch.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { HIGH_RATING, searchMovies } from '@/api/omdb' import EmptyResult from '@/components/EmptyResult' import Loader from '@/components/Loader' import MovieList from '@/components/MovieList' import RatingFilterToggle from '@/components/RatingFilterToggle' import SearchInput from '@/components/SearchInput' export default function MovieSearch() { const [title, setTitle] = useState('') const [highRatedOnly, setHighRatedOnly] = useState(false) const { data, isFetching, isError, error } = useQuery({ queryKey: ['movies', title], queryFn: ({ signal }) => searchMovies(title, signal), enabled: title !== '', staleTime: 1000 * 60 * 5 }) const movies = data ?? [] const visibleMovies = highRatedOnly ? movies.filter(movie => movie.imdbRating !== null && movie.imdbRating >= HIGH_RATING) : movies // ... }
규칙이 없을 때 나온 코드

규칙을 넣은 프로젝트에서는 쿼리 정의가 /src/queries/movies.ts의 queryOptions로, 데이터 요청은 /src/hooks/의 커스텀 훅으로 분리되었고, 응답 타입은 /src/types/movie.ts에 추가되었습니다.
컴포넌트는 훅이 반환한 값을 받아서 화면만 구성합니다.

/src/queries/movies.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { queryOptions } from '@tanstack/react-query' import type { Movie, MovieDetail, MovieDetailResponse, MovieSearchResponse } from '@/types/movie' // ... export function moviesSearchQueryOptions(title: string) { return queryOptions({ queryKey: ['movies', 'search', title], queryFn: async ({ signal }): Promise<Movie[]> => { const data = await requestOmdb<MovieSearchResponse>({ s: title }, signal) if (data.Response === 'False') { if (data.Error === NOT_FOUND_ERROR) return [] throw new Error(data.Error) } return data.Search }, enabled: title.length > 0, staleTime: 1000 * 60 * 5 }) } // ...
규칙을 넣은 뒤 나온 쿼리 정의
/src/hooks/useMovieSearch.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
import { useQuery } from '@tanstack/react-query' import { moviesSearchQueryOptions } from '@/queries/movies' export function useMovieSearch(title: string) { const { data, isFetching, isError, error } = useQuery(moviesSearchQueryOptions(title)) return { movies: data ?? [], isFetching, isError, errorMessage: error?.message ?? '' } }
규칙을 넣은 뒤 나온 커스텀 훅
/src/components/MovieSearchScreen.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState } from 'react' import { useDebouncedValue } from '@/hooks/useDebouncedValue' import { useMovieRatings } from '@/hooks/useMovieRatings' import { useMovieSearch } from '@/hooks/useMovieSearch' // ... const HIGH_RATING_THRESHOLD = 7 export default function MovieSearchScreen() { const [keyword, setKeyword] = useState('') const [highRatedOnly, setHighRatedOnly] = useState(false) const searchTerm = useDebouncedValue(keyword.trim(), 400) const { movies, isFetching, isError, errorMessage } = useMovieSearch(searchTerm) const { ratings, isFetching: isRatingFetching } = useMovieRatings(movies) const visibleMovies = highRatedOnly ? movies.filter(movie => (ratings.get(movie.imdbID) ?? -1) >= HIGH_RATING_THRESHOLD) : movies function handleKeywordChange(value: string) { setKeyword(value) } // ... }
규칙을 넣은 뒤 나온 컴포넌트

두 결과에서 달라진 부분은 다음과 같습니다.

항목 규칙 없음 규칙 적용
쿼리 정의 컴포넌트 안의 useQuery에 키와 요청 함수를 직접 작성 /src/queries/movies.ts의 queryOptions
요청 함수 위치 /src/api/omdb.ts 쿼리 정의 파일 안
데이터 훅 없음, 컴포넌트가 직접 호출 /src/hooks/useMovieSearch.ts, useMovieRatings.ts
응답 타입 요청 함수 파일 안에 선언 /src/types/movie.ts
핸들러 인라인 화살표 함수 handle 접두사의 함수
조건부 렌더링 && 삼항 연산자
이펙트 없음 디바운스 타이머에만 사용, 정리 함수 포함

이 실행에서는 규칙이 없어도 컴포넌트 선언과 파생 값, 메모이제이션 규칙은 지켜졌습니다.
차이는 데이터 요청 코드의 위치와 분리 단위, 타입의 위치, 이름 규칙처럼 프로젝트 구조에 관한 부분에서 나타났습니다.
규칙이 없을 때 무엇이 어긋나는지는 실행마다, 모델과 프로젝트 상태에 따라 다르지만, 규칙이 있으면 어느 실행에서든 같은 위치에 같은 형태로 나옵니다.
다음 작업에서 Claude가 참고할 파일이 이 결과이므로, 첫 결과의 구조가 그대로 프로젝트의 구조가 됩니다.

서로 모순되는 지침이 있으면 Claude는 둘 중 하나를 임의로 선택합니다.
"useMemo를 직접 작성하지 않는다" 와 "비용이 큰 계산은 useMemo로 감싼다" 가 같이 있으면 규칙이 없는 것과 같습니다.

규칙이 늘어나서 CLAUDE.md가 길어지면, React 규칙과 상태 규칙, 라우팅 규칙처럼 성격이 다른 묶음을 .claude/rules/ 폴더로 나눕니다.
이 폴더의 마크다운 파일은 CLAUDE.md와 같은 프로젝트 범위 지침으로 자동 로드되므로, CLAUDE.md에서 @경로 방식으로 따로 참조할 필요가 없습니다.

1
2
3
4
5
6
7
├─.claude/ │ ├─rules/ │ │ ├─react.md │ │ ├─store.md │ │ └─router.md │ └─settings.json └─CLAUDE.md
지침 파일 배치

CLAUDE.md에는 프로젝트 개요와 명령만 남깁니다.

/CLAUDE.md
MD
1
2
3
4
5
6
7
8
9
10
11
# 영화 검색 앱 Vite + React 19 + TypeScript 기반의 영화 검색 애플리케이션입니다. React, 상태, 라우팅 규칙은 `.claude/rules/` 폴더에 있습니다. ## 자주 사용하는 명령 - `npm run dev`: 개발 서버 실행 - `npm run typecheck`: 타입 검사 - `npm run lint`: ESLint 검사 (훅 규칙, 컴파일러 규칙 포함) - `npm run test:unit`: 단위 테스트

규칙 파일은 paths 프론트매터로 적용 범위를 좁힐 수 있습니다.
React 규칙은 컴포넌트와 훅을 작업할 때만 필요하므로, 조건을 지정하면 다른 작업에서는 로드되지 않습니다.

/.claude/rules/react.md
MD
1
2
3
4
5
6
7
8
9
10
--- paths: - "src/**/*.tsx" - "src/hooks/**/*.ts" --- # React 코딩 규칙 - 컴포넌트는 `export default function 이름()` 형태의 함수 선언으로 작성한다. - 파생 값은 렌더 중에 계산한다.
조건부로 로드되는 규칙

paths가 없는 규칙 파일은 조건 없이 매 세션 로드되며, .claude/CLAUDE.md와 같은 우선순위를 가집니다.

CLAUDE.md 파일 하나는 100~200줄 이하로 유지하는 것이 좋습니다.
길어질수록 컨텍스트를 더 많이 사용하고, 지침을 지키는 수준도 떨어집니다.

포매팅 규칙은 CLAUDE.md에 작성하지 않습니다.
세미콜론이나 따옴표 스타일은 Prettier로 이미 결정한 내용이므로, 중복해서 작성하면 토큰만 더 사용하고 충돌 여지가 생깁니다.
같은 이유로 any 금지나 타입 전용 import type 같은 규칙도 작성하지 않습니다.
typescript-eslint 추천 프리셋의 no-explicit-any 규칙과 Vite 템플릿의 verbatimModuleSyntax 설정이 이미 검사합니다.
도구로 강제할 수 있는 것은 도구에게 맡기고, 지침에는 도구가 판단할 수 없는 설계 결정만 작성합니다.

React에서는 이 원칙을 포매팅보다 훨씬 넓게 적용할 수 있습니다.
ESLint 생태계가 크기 때문에, 앞에서 작성한 규칙 중 상당수를 린트 규칙으로 옮길 수 있습니다.
forwardRef와 React.FC를 금지하는 규칙이 대표적입니다.

/eslint.config.js
JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default defineConfig([ { files: ['**/*.{ts,tsx}'], extends: [reactHooks.configs.flat.recommended], rules: { 'no-restricted-imports': [ 'error', { paths: [{ name: 'react', importNames: ['forwardRef'], message: 'ref는 일반 Props로 받습니다.' }] } ], '@typescript-eslint/no-restricted-types': [ 'error', { types: { FC: '컴포넌트는 함수 선언으로 작성합니다.', 'React.FC': '컴포넌트는 함수 선언으로 작성합니다.' } } ] } } ])
지침을 린트 규칙으로 옮기기

forwardRef는 함수이므로 가져오는 것 자체를 no-restricted-imports로 막습니다.
FC는 타입이므로 Vite 템플릿의 ESLint 구성에 포함된 typescript-eslint의 no-restricted-types 규칙으로 막으며, import { FC }로 가져온 FC와 네임스페이스로 접근하는 React.FC를 각각 등록합니다.

규칙에 걸린 코드는 편집기에서 바로 확인할 수 있습니다.
ESLint 확장이 설치되어 있으면 message에 작성한 문장이 문제 패널에 그대로 표시됩니다.

린트 규칙에 걸린 코드와 문제 패널의 메시지

이렇게 옮긴 규칙은 지침에서 지웁니다.
같은 내용이 두 곳에 있으면 한쪽만 고쳐지는 순간 모순이 됩니다.
message에는 왜 막았는지가 아니라 대신 무엇을 사용할지를 작성해야, Claude가 오류를 읽고 바로 고칩니다.

결정을 고정하는 수단은 세 단계로 나뉩니다.

수단 강제 수준 담을 규칙
지침 (CLAUDE.md, .claude/rules/) 권고, 어길 수 있음 상태를 어디에 둘지 같은 설계 결정
린트 (ESLint) 검사에서 걸림, 훅으로 즉시 되돌림 금지한 API, 훅 규칙, 이펙트 사용
타입 (TypeScript) 컴파일되지 않음 Props 형태, 경로 상수, 쿼리 반환 타입

위로 갈수록 적용이 쉽고 아래로 갈수록 어길 수 없습니다.
새 규칙은 지침으로 시작하되, 린트나 타입으로 옮길 수 있는 것은 옮기고 지침에서는 지웁니다.
뒤에서 다루는 경로 상수와 쿼리 정의가 타입 단계의 예입니다.

# 컴포넌트 작업 지시

React 컴포넌트는 하나의 파일에 담기지 않습니다.
컴포넌트 하나의 맥락이 MovieCard.tsx와 타입 파일, 커스텀 훅, 쿼리 정의로 나뉘어 있습니다.
어디까지 읽고 어디까지 수정할지 명시하지 않으면 필요 이상으로 넓게 탐색하거나, 클래스 하나만 바꾸면 되는 요청에 훅과 타입 파일까지 다시 작성할 수 있습니다.

관련 파일을 같은 폴더에 같은 이름으로 두는 규칙이 이 비용을 줄입니다.
MovieCard라는 이름만 알면 테스트 파일의 위치가 정해지고, 스타일은 Tailwind 클래스로 같은 파일 안에 있으므로, Claude가 폴더 전체를 검색하지 않아도 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
├─src/ │ ├─components/ │ │ ├─MovieCard.test.tsx │ │ └─MovieCard.tsx │ ├─hooks/ │ │ └─useMovieSearch.ts │ ├─index.css │ ├─queries/ │ │ └─movies.ts │ ├─routes/ │ ├─stores/ │ ├─test/ │ │ ├─render.tsx │ │ └─setup.ts │ └─types/ │ └─movie.ts
컴포넌트 단위로 모은 폴더 구조

요청에는 수정할 파일과 읽지 않아도 되는 파일을 함께 작성합니다.

PROMPT
1
MovieCard 컴포넌트에 개봉 연도를 표시해 줘.
범위를 지정하지 않은 요청
PROMPT
1
2
@/components/MovieCard.tsx의 JSX만 수정해서 제목 아래에 개봉 연도를 표시해 줘. Tailwind 클래스와 Props 타입은 그대로 두고, 다른 파일은 읽지 않아도 돼.
범위를 지정한 요청

새 컴포넌트를 만들 때는 인터페이스를 먼저 정해서 전달하는 게 좋습니다.
Props의 형태가 정해져 있으면 구현의 자유도가 줄어들고, 나중에 상위 컴포넌트와 일치시키는 수정이 최소화됩니다.
React는 이벤트를 따로 선언하는 문법이 없고 콜백 Props가 그 역할을 하므로, 콜백 Props의 이름과 시그니처까지 함께 작성합니다.

PROMPT
1
2
3
4
5
6
7
8
9
10
11
12
13
@/components/MovieCard.tsx를 만들어 줘. ```ts interface MovieCardProps { movie: { imdbID: string; Title: string; Poster: string; Year: string } onSelect: (imdbID: string) => void } ``` - 포스터 이미지, 제목, 연도를 표시한다. - 카드를 클릭하면 onSelect로 imdbID를 전달한다. - Poster 값이 'N/A'이면 대체 이미지를 표시한다. - CLAUDE.md의 React 코딩 규칙을 따른다.
인터페이스를 먼저 주는 요청

색이나 간격 등의 스타일을 프롬프트로 매번 설명하면 컴포넌트마다 다른 값이 나올 수 있고, 화면이 쌓였을 때 디자인 조화가 깨집니다.
Tailwind는 클래스 이름 자체가 값을 고정하지만, 색은 기본 팔레트가 넓어서 bg-blue-500과 bg-sky-600처럼 비슷한 색이 컴포넌트마다 다르게 선택될 수 있습니다.
프로젝트가 사용하는 색을 @theme 토큰으로 한 곳에 정의하고 그 토큰만 사용하도록 규칙에 작성하면 스타일도 대부분 고정됩니다.
@theme에 정의한 --color-* 변수는 bg-primary, text-ink처럼 유틸리티 클래스로 바로 사용할 수 있으며, 문법은 Tailwind CSS 핵심 패턴을 참고하세요.

/src/index.css
CSS
1
2
3
4
5
6
7
8
@import 'tailwindcss'; @theme { --color-canvas: #ffffff; --color-ink: #1f2328; --color-primary: #087ea4; --color-line: #d0d7de; }
색 토큰 정의
/.claude/rules/react.md
MD
1
2
3
4
5
6
7
8
- 스타일은 Tailwind 유틸리티 클래스로만 작성한다. CSS Modules, 인라인 `style`, `@apply`는 사용하지 않는다. - 색은 `/src/index.css`의 `@theme`에 정의한 토큰(`bg-canvas`, `text-ink`, `bg-primary`, `border-line`)만 사용한다. `bg-blue-500` 같은 기본 팔레트와 `bg-[#087ea4]` 같은 임의 값은 사용하지 않는다. - 필요한 색이 없으면 `@theme`에 토큰을 먼저 추가하고 사용한다. - 간격과 모서리는 기본 스케일(`p-2`, `gap-4`, `rounded-lg`)만 사용한다. `p-[13px]` 같은 임의 값은 사용하지 않는다. - 조건부 클래스는 `tailwind-merge`의 `twJoin`으로 조합한다. `w-[${width}px]`처럼 실행 중에 클래스 이름을 만들지 않는다. - 여러 화면에서 사용하는 컴포넌트는 `className` Props를 받아서 `twMerge`로 기본 클래스와 병합한다. - 컴포넌트 안에서 바깥 여백(`m-*`)을 사용하지 않는다. 컴포넌트 사이 간격은 부모가 `gap`으로 정한다. - 반응형은 모바일 기준으로 작성하고 `md:`, `lg:` 접두사로 넓힌다. `max-md:` 같은 역방향 접두사는 사용하지 않는다.
스타일 규칙

기본 팔레트 금지는 도구로도 강제할 수 있습니다.
@theme에 --color-*: initial;을 먼저 작성하면 기본 색 유틸리티가 모두 제거되어서, bg-blue-500 같은 클래스는 아무 스타일도 만들지 않습니다.
클래스 순서는 규칙에 작성하지 않고 prettier-plugin-tailwindcss 플러그인에 맡깁니다.

이러한 규칙이 있으면 영화 카드 컴포넌트를 만들어 줘라는 짧은 요청만으로도 나머지 화면과 같은 스타일을 유지할 수 있습니다.
디자인 시안이 있다면 매번 첨부하지 말고, 시안에서 추출한 값(디자인 토큰)을 @theme에 먼저 정의하는 것이 좋습니다.

여러 파일이 함께 바뀌는 작업은 Plan 모드로 시작합니다.
Shift + Tab이나 /plan 명령을 사용해 Plan 모드에 들어가면 파일을 확인하고 계획만 세울 뿐 편집하지는 않습니다.
검색 입력과 목록, 카드, 커스텀 훅을 한꺼번에 만드는 작업이라면, 파일 목록과 데이터 흐름을 먼저 확인하는 것이 안전합니다.

PROMPT
1
2
3
영화 검색 화면을 만들려고 해. 검색 입력, 로딩 표시, 결과 목록, 빈 결과 처리까지 포함해서 어떤 파일을 만들고 각각 어떤 역할을 맡을지 계획만 세워 줘.
Plan 모드에서의 요청

계획에서 확인할 것은 코드가 아니라 경계입니다.
검색어 상태를 어느 컴포넌트가 소유하는지, 데이터 요청이 컴포넌트와 훅 중 어디에 있는지만 맞으면 나머지 구현은 대체로 규칙대로 나옵니다.
React는 상태를 둔 위치가 곧 리렌더링 범위이므로, 상태를 너무 위에 올리면 화면 전체가 다시 그려지고 너무 아래에 두면 형제 컴포넌트와 공유하지 못합니다.
이 판단은 규칙으로 고정하기 어려우므로 계획 단계에서 사람이 확인합니다.

데이터를 가져오는 로직은 쿼리 정의와 커스텀 훅으로 분리하도록 규칙에 추가하면, 컴포넌트가 비대해지는 것을 막을 수 있습니다.
쿼리 정의는 TanStack Query의 queryOptions로 작성합니다.
쿼리 키와 요청 함수, 반환 타입이 한 곳에 묶이고, 뒤에서 살펴볼 라우터 로더나 미리 가져오기에서도 같은 정의를 재사용할 수 있습니다.

/src/queries/movies.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { queryOptions } from '@tanstack/react-query' import type { Movie } from '@/types/movie' export function movieSearchOptions(searchText: string) { return queryOptions({ queryKey: ['movies', searchText], queryFn: async () => { const res = await fetch(`https://omdbapi.com?apikey=7035c60c&s=${searchText}`) const { Search: movies = [] } = await res.json() return movies as Movie[] }, enabled: !!searchText }) }
쿼리 정의
/src/hooks/useMovieSearch.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { movieSearchOptions } from '@/queries/movies' export function useMovieSearch() { const [searchText, setSearchText] = useState('') const { data: movies = [], isFetching } = useQuery(movieSearchOptions(searchText)) function search(text: string) { setSearchText(text.trim()) } return { movies, isFetching, search } }
커스텀 훅으로 분리한 검색 로직

쿼리 옵션을 따로 반응형으로 만들 필요는 없습니다.
검색어가 바뀌면 훅이 다시 실행되고 queryKey가 달라지므로 새 요청이 시작됩니다.
이전 검색어의 응답이 늦게 도착해도 키가 다르기 때문에 현재 화면을 덮어쓰지 않습니다.
useEffect와 fetch로 직접 작성하면 이 경쟁 상태(Race Condition)를 AbortController나 취소 플래그로 매번 처리해야 합니다.
훅 작성 패턴은 React Hooks 핵심 정리를, 쿼리 옵션은 TanStack Query 핵심 정리를 참고하세요.

queryOptions로 만든 정의는 useQuery뿐 아니라 queryClient.prefetchQuery, ensureQueryData, getQueryData에 그대로 전달할 수 있고, 반환 타입이 모두 같은 Movie[]로 추론됩니다.
이런 라이브러리별 사용 방식은 규칙 파일에 한 줄로 작성하면 매번 설명하지 않아도 됩니다.

# 상태와 라우터 규칙

React에서 상태 관련 결정은 어떤 라이브러리를 사용할지보다 상태를 어디에 둘지가 먼저입니다.
같은 데이터가 TanStack Query 캐시와 Zustand 스토어에 동시에 있으면 어느 쪽이 최신인지 판단해야 하고, 지침이 없으면 응답을 스토어에 복사하는 코드가 나올 수 있습니다.

상태 위치 예
서버에서 가져온 데이터 TanStack Query 캐시 영화 목록, 상세 정보
공유하거나 복원해야 하는 화면 상태 URL 검색 매개변수 검색어, 페이지 번호, 정렬
여러 화면이 공유하는 클라이언트 상태 Zustand 스토어 로그인 토큰, 테마, 최근 본 영화
한 화면 안의 상태 useState 입력 중인 텍스트, 열림과 닫힘

이 표를 규칙 파일로 옮깁니다.
각각의 문법은 Zustand 핵심 정리와 React Context API 핵심 정리를 참고하세요.

/.claude/rules/store.md
MD
1
2
3
4
5
6
7
8
9
10
# 상태 규칙 - 서버 데이터는 TanStack Query가 소유한다. 응답을 Zustand 스토어나 Context에 복사하지 않는다. - 새로고침이나 링크 공유 후에도 유지되어야 하는 화면 상태(검색어, 페이지, 정렬)는 URL 검색 매개변수에 둔다. - 여러 화면이 공유하는 클라이언트 상태만 Zustand 스토어로 만든다. 한 화면에서만 사용하는 상태는 `useState`로 둔다. - 스토어는 `/src/stores/이름.ts`에 `create<State>()(...)` 형태로 정의하고, 훅 이름은 `use이름Store`로 한다. - 상태를 바꾸는 함수는 스토어 안에 액션으로 정의한다. 컴포넌트에서 `setState`를 직접 호출하지 않는다. - `persist` 미들웨어는 새로고침 후에도 유지해야 하는 상태(테마, 최근 본 영화)에만 적용한다. - 컴포넌트는 선택자로 필요한 값만 구독하고, 여러 값이 필요하면 `useShallow`로 묶는다. `useAuthStore()`처럼 스토어 전체를 구독하지 않는다. - Context는 거의 바뀌지 않는 값(테마, 의존성 주입)에만 사용한다. 자주 바뀌는 상태를 Context에 두지 않는다.

마지막 두 줄은 리렌더링 범위에 관한 규칙입니다.
스토어 전체를 구독하면 어떤 값이 바뀌어도 컴포넌트가 다시 렌더링되고, Context는 값이 바뀔 때 소비하는 모든 컴포넌트가 다시 렌더링됩니다.
둘 다 동작에는 문제가 없어서 지침이 없으면 걸러지지 않습니다.

URL 규칙을 적용하면 앞에서 만든 검색 훅은 검색어를 useState 대신 라우터의 검색 매개변수에서 읽습니다.
새로고침해도 결과가 유지되고, 주소를 그대로 공유할 수 있습니다.

/src/hooks/useMovieSearch.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { useSearchParams } from 'react-router' import { useQuery } from '@tanstack/react-query' import { movieSearchOptions } from '@/queries/movies' export function useMovieSearch() { const [searchParams, setSearchParams] = useSearchParams() const searchText = searchParams.get('q') ?? '' const { data: movies = [], isFetching } = useQuery(movieSearchOptions(searchText)) function search(text: string) { setSearchParams({ q: text.trim() }) } return { movies, isFetching, search } }
검색어를 URL에 두는 훅

스토어 생성은 슬래시 명령으로 만들 수 있습니다.
.claude/skills/ 아래에 폴더를 만들고 SKILL.md를 작성하면 /스킬명으로 호출할 수 있습니다.

/.claude/skills/new-store/SKILL.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
--- name: new-store description: | 프로젝트 규칙에 맞는 Zustand 스토어 파일을 생성합니다. 새 스토어를 추가하거나 전역 클라이언트 상태가 필요할 때 사용하세요. zustand, store, state. argument-hint: 스토어 이름 (예: auth) disable-model-invocation: true --- # Zustand 스토어 생성 `$ARGUMENTS`를 스토어 이름으로 사용해 다음을 수행합니다. 1. `/src/stores/$ARGUMENTS.ts` 파일을 생성한다. 1. `.claude/rules/store.md`의 규칙을 그대로 따른다. 1. 상태 인터페이스와 액션의 뼈대만 만들고 비즈니스 로직은 비워 둔다. 1. 필요한 타입은 `/src/types/`에서 가져오고, 없으면 새로 정의한다. 1. 생성 후 `npm run typecheck`를 실행해 통과를 확인한다.
PROMPT
1
/new-store auth
슬래시 명령으로 스토어 생성

$ARGUMENTS에는 명령 뒤에 입력한 값이 그대로 치환되므로, 위 예제에서는 auth가 들어갑니다.
argument-hint는 슬래시 명령을 입력할 때 보이는 안내 문구일 뿐이고 값을 전달하지는 않습니다.

disable-model-invocation: true는 자동 호출을 끄고 슬래시 명령으로만 실행되게 하는 옵션입니다.
스토어 생성처럼 사용자가 의도한 시점에만 실행되어야 하는 작업에 적합합니다.

라우터도 같은 방식으로 규칙을 작성합니다.
이 프로젝트는 React Router 8의 Data 모드를 사용하며, 문법은 React Router 핵심 정리 (Data Mode)를 참고하세요.
링크한 글은 7.5 버전을 기준으로 작성되었지만, 라우트 정의와 loader, lazy 같은 Data 모드 API는 8 버전에서도 같습니다.
학습 데이터에는 <Routes>와 <Route>로 작성한 선언형 코드와 react-router-dom 패키지가 훨씬 많아서, 지침이 없으면 두 방식이 한 프로젝트에 섞일 수 있습니다.
특히 react-router-dom 패키지는 2026년 6월에 출시된 8 버전에서 제거되어서, 이 패키지에서 가져오는 코드는 설치 단계에서 실패합니다.
RouterProvider만 react-router/dom에서 가져오고, 나머지는 모두 react-router에서 가져옵니다.

/.claude/rules/router.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
# 라우팅 규칙 - 패키지는 `react-router`에서 가져오고, `RouterProvider`만 `react-router/dom`에서 가져온다. `react-router-dom`은 사용하지 않는다. - 라우트는 `/src/routes/index.tsx`의 `createBrowserRouter` 배열에서만 정의한다. `<BrowserRouter>`, `<Routes>`, `<Route>`는 사용하지 않는다. - 페이지 컴포넌트는 `/src/routes/pages/`, 레이아웃은 `/src/routes/layouts/`에 두고, 라우트 객체의 `lazy` 속성으로 가져온다. `React.lazy`와 `Suspense`는 사용하지 않는다. - 라우트 컴포넌트는 `element` 대신 `Component` 속성으로 지정한다. - 루트 라우트에 `ErrorBoundary`와 `HydrateFallback`을 지정하고, 정의하지 않은 경로는 `path: '*'` 라우트로 처리한다. - 인증이 필요한 라우트는 `loader`에 `requiresAuth`를 연결한다. 컴포넌트 안에서 `useEffect`와 `navigate`로 리디렉션하지 않는다. - 경로 문자열은 `/src/routes/paths.ts`의 `paths` 객체에서만 가져온다. `<Link to="/movies">`처럼 문자열을 직접 작성하지 않는다. - 페이지 이동은 `<Link>`로 하고, 이벤트 핸들러 안에서 이동할 때만 `useNavigate`를 사용한다. `<a href>`와 `window.location`은 사용하지 않는다. - 라우트 매개변수는 `useParams`, 검색 매개변수는 `useSearchParams`로 읽는다. - 테스트에서 라우터 컨텍스트가 필요하면 `createRoutesStub`을 사용한다. `<MemoryRouter>`는 사용하지 않는다.

Component와 lazy 규칙은 Data 모드의 작성 방식을 하나로 고정합니다.
Data 모드 문서는 라우트 컴포넌트를 Component 속성으로 지정하고, 지연 로딩도 React.lazy 대신 라우트 객체의 lazy 속성을 사용합니다.
React.lazy는 렌더링 중에 모듈을 가져오므로 Suspense의 대체 화면이 먼저 보이지만, 라우트의 lazy는 이동이 시작될 때 모듈을 가져오고 다 받은 뒤에 화면을 바꿉니다.
이동하는 동안 이전 화면이 유지되고 진행 상태는 useNavigation으로 읽으므로, Suspense와 대체 화면이 필요 없습니다.
지침이 없으면 React.lazy와 Suspense, element로 작성한 코드가 함께 나올 수 있습니다.

/src/routes/index.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { createBrowserRouter } from 'react-router' import { paths } from '@/routes/paths' import { requiresAuth } from '@/routes/loaders/requiresAuth' import RouteError from '@/routes/RouteError' import NotFound from '@/routes/pages/NotFound' export const router = createBrowserRouter([ { path: paths.home, ErrorBoundary: RouteError, HydrateFallback: () => null, lazy: { Component: async () => (await import('@/routes/layouts/Default')).default }, children: [ { index: true, lazy: { Component: async () => (await import('@/routes/pages/Home')).default } }, { path: paths.movies, lazy: { Component: async () => (await import('@/routes/pages/Movies')).default } }, { path: paths.movie(':imdbID'), loader: requiresAuth, lazy: { Component: async () => (await import('@/routes/pages/Movie')).default } }, { path: '*', Component: NotFound } ] } ])
라우트 객체의 lazy로 페이지 모듈 분리

lazy에는 함수 형태와 객체 형태가 있습니다.
lazy: () => import('@/routes/pages/Movies') 같은 함수 형태가 더 짧지만, 모듈이 Component나 loader처럼 라우트 속성 이름으로 내보내야 하고 default 내보내기는 무시됩니다.
객체 형태는 속성마다 가져올 함수를 지정하므로, 페이지 컴포넌트를 export default로 내보낸 파일을 그대로 연결할 수 있어서 컴포넌트 규칙과 충돌하지 않습니다.
ErrorBoundary에 지정한 RouteError는 useRouteError로 로더와 렌더링의 오류를 읽어서 표시하는 컴포넌트이고, path: '*' 라우트는 정의하지 않은 주소를 처리합니다.
HydrateFallback은 처음 접속했을 때만 사용됩니다.
첫 로드에는 유지할 이전 화면이 없어서, 레이아웃과 페이지 모듈을 받는 동안 표시할 화면을 루트 라우트에 지정하지 않으면 브라우저 콘솔에 경고가 출력됩니다.
null을 반환하면 아무것도 표시하지 않고, 필요하면 로딩 표시 컴포넌트를 지정합니다.
빌드하면 페이지와 레이아웃이 각각 별도의 청크로 나뉩니다.

BASH
1
2
3
4
5
6
7
npm run build ## dist/assets/Movie-CboR9MYG.js 0.22 kB ## dist/assets/Home-BZI4tka8.js 0.34 kB ## dist/assets/Default-Ds2XrfV-.js 0.77 kB ## dist/assets/Movies-BBG-RfvI.js 10.48 kB ## dist/assets/index-DHixG5Y1.js 310.41 kB
페이지와 레이아웃이 별도의 청크로 나뉜 빌드 결과

loader 규칙은 인증 확인의 시점을 고정합니다.
컴포넌트 안에서 useEffect로 토큰을 확인하고 navigate를 호출하면 보호된 화면이 한 번 렌더링된 뒤에 이동합니다.
loader는 렌더링 전에 실행되므로 화면이 잠깐 노출되는 문제가 없고, 인증 로직이 라우트 정의 한 곳에 모입니다.
로더는 컴포넌트가 아니라서 훅을 호출할 수 없으므로, /new-store auth로 만든 스토어의 accessToken은 getState()로 읽습니다.

/src/routes/loaders/requiresAuth.ts
TS
1
2
3
4
5
6
7
8
import { redirect, type LoaderFunctionArgs } from 'react-router' import { paths } from '@/routes/paths' import { useAuthStore } from '@/stores/auth' export function requiresAuth({ request }: LoaderFunctionArgs) { if (useAuthStore.getState().accessToken) return null return redirect(`${paths.signIn}?redirectTo=${new URL(request.url).pathname}`) }
렌더링 전에 실행되는 인증 확인

React Router 8부터는 Data 모드에서도 라우트 객체의 middleware 배열을 사용할 수 있고, loader보다 먼저 실행됩니다.
인증 확인을 middleware로 옮길 수도 있지만, loader와 middleware 중 하나로 정해서 규칙에 작성해야 두 방식이 섞이지 않습니다.

경로 문자열을 직접 작성하지 않게 하는 규칙은 특히 효과가 큽니다.
/movies/:imdbID 같은 경로가 <Link>와 navigate, 로더에 문자열로 흩어져 있으면 경로를 바꿀 때마다 전부 찾아야 합니다.
한 곳에 상수로 모으면 변경 지점이 하나가 됩니다.

/src/routes/paths.ts
TS
1
2
3
4
5
6
export const paths = { home: '/', signIn: '/signin', movies: '/movies', movie: (imdbID: string) => `/movies/${imdbID}` } as const
경로 상수

이 상수는 규칙을 지침이 아니라 타입으로 강제합니다.
paths.movieDetail처럼 존재하지 않는 이름을 사용하거나 paths.movie()에 매개변수를 빠뜨리면 npm run typecheck에서 걸립니다.

BASH
1
2
3
4
npm run typecheck ## src/components/MovieCard.tsx:12:26 - error TS2339: ## Property 'movieDetail' does not exist on type '{ readonly home: "/"; ... }'.
정의하지 않은 경로를 사용했을 때

지침으로만 작성한 규칙과 달리, 타입은 어길 수 없습니다.
앞에서 만든 queryOptions 정의도 같은 역할을 합니다.
쿼리 키를 문자열 배열로 직접 작성하면 오타가 캐시 미스로만 나타나지만, 정의 함수를 사용하면 반환 타입까지 함께 따라옵니다.

# 검증 루프

지금까지 지시하는 개념을 살펴봤는데, 이제는 결과를 확인하는 개념에 대해서 살펴봅시다.
AI가 만든 React 코드에서 다음과 같이 타입, 규칙, 동작을 검증할 수 있습니다.

검증 명령 잡아내는 것
타입 npm run typecheck Props 형태, JSX 표현식, 경로 상수, 쿼리 반환 타입
규칙 npm run lint 훅 호출 순서, 의존성 누락, 이펙트 안의 setState, 렌더 중 ref 접근, 금지한 API
동작 npm run test:unit 렌더링 결과, 사용자 이벤트, 상태 변화

React에서는 타입 검사 못지않게 린트의 비중이 큽니다.
훅 규칙을 어긴 코드는 타입이 모두 맞아도 런타임에서만 드러나기 때문입니다.
조건부로 호출한 훅, 이펙트 안에서 바로 호출한 setState, 렌더 중에 읽은 ref.current가 모두 여기서 걸립니다.

BASH
1
2
3
4
5
6
7
npm run lint ## src/components/MovieList.tsx ## 14:19 error React Hook "useQuery" is called conditionally. React Hooks must be ## called in the exact same order in every component render react-hooks/rules-of-hooks ## 22:5 error Calling setState synchronously within an effect can trigger ## cascading renders react-hooks/set-state-in-effect
훅 규칙 위반이 드러나는 지점

단위 테스트는 Vitest와 Testing Library로 작성합니다.
render가 DOM을 필요로 하므로 패키지 설치와 함께 테스트 환경도 지정해야 합니다.
전체 구성은 React Unit/E2E 테스트 자동화 w. Vite/Next를 참고하세요.

BASH
1
npm i -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
테스트 패키지 설치
/vite.config.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/// <reference types="vitest/config" /> import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], resolve: { alias: [{ find: '@', replacement: '/src' }] }, test: { globals: true, environment: 'jsdom', setupFiles: ['src/test/setup.ts'] } })
테스트 환경 지정
/src/test/setup.ts
TS
1
import '@testing-library/jest-dom/vitest'
DOM 검증 매처 등록

globals: true는 Testing Library의 자동 정리 때문에 필요합니다.
Testing Library는 전역 afterEach가 있을 때만 테스트마다 렌더링한 DOM을 정리하므로, 이 옵션이 없으면 같은 파일의 두 번째 테스트부터 이전 테스트의 요소가 남아서 getByText가 여러 요소를 찾았다는 오류로 실패합니다.
설정 파일을 src/ 아래에 두는 것은 타입 검사 때문입니다.
tsconfig.app.json이 src만 포함하므로, 프로젝트 루트에 두면 toBeInTheDocument 같은 매처의 타입이 등록되지 않아서 npm run typecheck가 테스트 파일에서 실패합니다.

컴포넌트의 렌더링 결과와 콜백 호출을 확인하는 수준이면 충분하고, 이 정도는 AI에게 맡겨도 결과가 안정적인 편입니다.

/src/components/MovieCard.test.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, it, expect, vi } from 'vitest' import MovieCard from './MovieCard' const movie = { imdbID: 'tt0111161', Title: 'The Shawshank Redemption', Poster: 'N/A', Year: '1994' } describe('MovieCard', () => { it('제목과 연도를 표시한다', () => { render(<MovieCard movie={movie} onSelect={() => {}} />) expect(screen.getByRole('heading', { name: 'The Shawshank Redemption' })).toBeInTheDocument() expect(screen.getByText('1994')).toBeInTheDocument() }) it('클릭하면 imdbID와 함께 onSelect를 호출한다', async () => { const onSelect = vi.fn() render(<MovieCard movie={movie} onSelect={onSelect} />) await userEvent.click(screen.getByRole('button', { name: /The Shawshank Redemption/ })) expect(onSelect).toHaveBeenCalledWith('tt0111161') }) })
Testing Library 기반 단위 테스트

TanStack Query를 사용하는 훅이나 컴포넌트는 QueryClientProvider 없이 렌더링하면 오류가 발생하고, 라우터 훅을 사용하는 컴포넌트는 라우터 컨텍스트가 필요합니다.
라우터 컨텍스트는 Data 모드의 테스트 API인 createRoutesStub으로 만듭니다.
라우트 객체 배열을 받아서 loader와 action까지 대체할 수 있고, initialEntries로 시작 주소를 지정합니다.
이 감싸는 코드를 테스트마다 다시 작성하지 않도록 헬퍼 파일 하나로 만듭니다.

/src/test/render.tsx
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import type { ReactElement, ReactNode } from 'react' import { render } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoutesStub } from 'react-router' export function renderWithProviders(ui: ReactElement, { route = '/' } = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const Stub = createRoutesStub([{ path: '*', Component: () => ui }]) function Wrapper({ children }: { children: ReactNode }) { return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> } return render(<Stub initialEntries={[route]} />, { wrapper: Wrapper }) }
공급자를 감싸는 렌더 헬퍼

retry: false는 실패한 요청을 다시 시도하지 않게 해서, 오류 상태를 확인하는 테스트가 재시도 시간만큼 기다리지 않게 합니다.
route 옵션으로 시작 주소를 지정하면, useSearchParams로 검색어를 읽는 훅을 /?q=matrix처럼 주소가 정해진 상태에서 테스트할 수 있습니다.
실제 요청 대신 응답을 모의하는 MSW 구성은 React Unit/E2E 테스트 자동화 w. Vite/Next를 참고하세요.
헬퍼의 위치와 사용 조건은 무엇을 검증할 것인지에 관한 기준과 함께 규칙 파일에 작성해서, Claude가 테스트마다 공급자를 새로 감싸거나 구현 세부를 검증하지 않게 합니다.

/.claude/rules/react.md
MD
1
2
3
4
5
- 테스트는 구현이 아니라 사용자에게 보이는 결과를 검증한다. 상태 값이나 내부 함수의 호출 여부를 확인하지 않는다. - 요소는 `getByRole`로 찾는 것을 우선하고, `getByTestId`는 다른 방법이 없을 때만 사용한다. - 사용자 동작은 `fireEvent` 대신 `userEvent`로 발생시킨다. - TanStack Query나 라우터 훅을 사용하는 컴포넌트는 `@/test/render`의 `renderWithProviders`로 렌더링한다. 테스트 안에서 `QueryClientProvider`나 `createRoutesStub`을 직접 만들지 않는다. - 네트워크 요청은 MSW로 모의한다. `fetch`를 `vi.fn()`으로 대체하지 않는다.
테스트 규칙

검증을 매번 사람이 요청하지 않도록 Hooks로 자동화할 수 있습니다.
PostToolUse 훅은 도구 호출이 성공한 뒤에 실행되므로, Claude가 컴포넌트 파일을 편집한 직후 검사를 실행하기에 적합합니다.

/.claude/settings.json
JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-react.mjs"] } ] } ] } }

CLAUDE_PROJECT_DIR는 훅을 실행할 때 Claude Code가 세션을 시작한 프로젝트 루트 경로를 넣어 주는 환경 변수이므로, 따로 지정하지 않습니다.
command에 실행 파일만 작성하고 args에 인수를 배열로 나열하면, 셸을 거치지 않고 실행 파일을 직접 호출합니다.

훅은 stdin으로 JSON을 받습니다.
아래 스크립트는 그 JSON에서 편집된 파일 경로를 추출해서 확장자에 따라 ESLint를 실행하고, 규칙 위반이 있으면 그 출력을 stderr에 작성한 뒤 종료 코드 2로 끝냅니다.
PostToolUse 훅에서 Claude가 읽는 것은 종료 코드 2일 때의 stderr뿐입니다.
1 같은 다른 종료 코드는 사용자에게 오류 한 줄만 표시하고 Claude에게는 전달되지 않으므로, ESLint의 종료 코드를 그대로 넘기면 검사 결과가 대화에 들어오지 않습니다.
앞에서 린트로 옮긴 forwardRef와 React.FC 금지 규칙, 훅 규칙이 파일을 편집할 때마다 적용됩니다.

/.claude/hooks/check-react.mjs
JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { spawnSync } from 'node:child_process' let raw = '' for await (const chunk of process.stdin) raw += chunk const filePath = JSON.parse(raw).tool_input?.file_path ?? '' if (!/\.tsx?$/.test(filePath)) process.exit(0) const { status, stdout, stderr } = spawnSync(`npx eslint "${filePath}"`, { shell: true, encoding: 'utf8' }) if (status === 0) process.exit(0) process.stderr.write(stdout + stderr) process.exit(2)

셸 스크립트(.sh)가 아니라 Node.js 스크립트를 사용했습니다.
Node.js는 여러 운영체제에서 같은 방식으로 동작하므로 팀원의 운영체제가 섞여 있어도 그대로 사용할 수 있습니다.

파일을 편집할 때마다 전체 타입 검사를 실행하면 대기 시간이 길어집니다.
훅에서는 편집된 파일만 대상으로 하는 빠른 검사를 실행하고, 타입 검사는 작업이 끝나는 시점에 한 번 실행하도록 지침에 작성하는 것이 낫습니다.

화면 동작까지 확인하려면 브라우저를 연결합니다.
검색어를 입력했을 때 목록이 갱신되는지, 빈 결과에서 안내 문구가 나오는지는 코드만 봐서는 판단하기 어렵습니다.

브라우저를 연결하는 방법은 두 가지입니다.
Claude in Chrome은 Chrome 확장 프로그램으로 사용자의 브라우저를 그대로 제어하므로, 개발 중인 화면을 사람이 보는 그대로 확인하고 로그인 상태도 공유합니다.
확장 프로그램을 설치한 뒤 claude --chrome으로 시작하면 되며, 구성 방법과 요구 사항은 Claude Code 핵심 정리를 참고하세요.

BASH
1
claude --chrome
Claude in Chrome으로 시작

격리된 브라우저가 필요하면 MCP 서버를 사용합니다.
헤드리스 자동화에는 Playwright MCP를, 성능 지표나 네트워크 진단에는 Chrome DevTools MCP를 사용합니다.
둘 다 Anthropic 공식 마켓플레이스에 플러그인으로 올라와 있어서 슬래시 명령으로 설치할 수 있습니다.

PROMPT
1
2
/plugin install playwright@claude-plugins-official /plugin install chrome-devtools-mcp@claude-plugins-official
플러그인으로 설치

패키지 이름이나 실행 옵션을 직접 지정하려면 claude mcp add 명령을 사용합니다.
-- 뒤에 실행할 명령을 작성하며, 구분자를 생략하면 뒤따르는 옵션을 Claude Code 자신의 옵션으로 해석해서 등록에 실패합니다.

BASH
1
2
claude mcp add playwright -- npx @playwright/mcp@latest claude mcp add chrome-devtools -- npx chrome-devtools-mcp@latest
MCP 서버를 직접 등록

등록 결과는 /mcp 명령으로 확인할 수 있고, 개발 서버는 MCP가 대신 실행해 주지 않으므로 npm run dev를 미리 실행합니다.

PROMPT
1
2
3
개발 서버가 5173 포트에서 실행 중이야. 브라우저로 접속해서 검색창에 "matrix"를 입력하고, 목록이 렌더링되는지와 콘솔 오류가 없는지 확인해 줘.
브라우저로 결과 확인

같은 확인을 /verify 내장 명령으로 요청할 수도 있습니다.
현재 변경 사항이 드러나는 화면을 직접 조작하고 스크린샷과 출력을 증거로 모아 통과 여부를 판정하며, 사용법은 Claude Code 내장 슬래시 명령어 핵심 정리를 참고하세요.

# 공식 스킬

스킬을 직접 만들기 전에 사용 중인 라이브러리가 공식 스킬을 배포하는지 확인합니다.
API는 만든 쪽이 정리한 것이 가장 정확하고, 문서가 바뀌면 스킬도 함께 갱신됩니다.

React 생태계에서 2026년 9월 기준으로 라이브러리가 직접 배포하는 스킬은 React Router 하나입니다.
React 팀과 TanStack Query, Zustand는 공식 스킬이 없고, facebook/react와 TanStack/query, pmndrs/zustand 저장소에는 SKILL.md 파일이 없습니다.
대신 Vercel이 vercel-labs/agent-skills 저장소에 React와 Next.js 모범 사례 스킬을 모아서 배포하고, 이 블로그도 글을 기준으로 만든 라이브러리 사용 스킬을 배포합니다.

# React Router 스킬

React Router는 스킬을 저장소의 .agents/skills/react-router/ 폴더에 두고 패키지와 함께 관리합니다.
별도 저장소였던 remix-run/agent-skills는 보관 처리되었으므로, 검색 결과에 나오는 react-router-data-mode 같은 모드별 스킬 대신 저장소 안의 스킬을 설치합니다.

BASH
1
npx skills add remix-run/react-router --skill react-router
React Router 공식 스킬 설치

npx skills add는 프로젝트 루트에 skills-lock.json을 만들어 설치한 스킬의 출처와 해시를 기록합니다.
팀원 모두가 같은 스킬을 사용하도록 이 파일은 Git에 커밋합니다.

이 스킬의 본문은 짧습니다.
프로젝트 코드에서 Framework, Data, Declarative 중 어느 모드인지 판별하고, 모드에 맞는 참조 파일을 읽은 뒤 node_modules/react-router/docs/에 패키지와 함께 설치되는 문서를 읽도록 지시합니다.
문서마다 [MODES: data] 같은 표식이 있어서 다른 모드의 설명을 적용하지 않고, 설치된 버전의 문서를 읽으므로 웹 문서와 버전이 어긋나지 않습니다.
createBrowserRouter와 RouterProvider가 있는 이 프로젝트는 Data 모드로 판별되며, 앞에서 작성한 라우팅 규칙이 이 판별을 고정하는 역할을 합니다.

Data 모드 참조 파일에는 규칙 파일과 어긋나는 내용도 있습니다.
서버 데이터를 loader로 가져오고 검색 폼은 <Form method="get">으로 제출하도록 권하는데, 이 프로젝트는 서버 데이터를 TanStack Query가 소유하고 검색어를 useSearchParams로 다룹니다.
어느 쪽을 따를지는 뒤에서 CLAUDE.md에 함께 작성합니다.

# Vercel 스킬

Vercel 스킬은 프레임워크 사용법이 아니라 성능과 설계 관점의 모범 사례를 다룹니다.

스킬 내용
react-best-practices Vercel 엔지니어링의 React와 Next.js 성능 규칙 70개를 영향력 순으로 8개 범주로 정리
composition-patterns 불리언 Props 대신 합성, 컴파운드 컴포넌트, React 19 API로의 전환 패턴
web-design-guidelines UI 코드를 웹 인터페이스 지침 100여 개와 대조해서 검토
BASH
1
2
npx skills add vercel-labs/agent-skills --list npx skills add vercel-labs/agent-skills --skill react-best-practices --skill composition-patterns --skill web-design-guidelines
React 스킬 설치

--list는 설치하지 않고 저장소의 스킬 목록만 표시하고, --skill은 여러 번 지정할 수 있습니다.
같은 저장소의 react-view-transitions 스킬은 <ViewTransition> 컴포넌트를 다루지만, 이 컴포넌트는 아직 React canary 채널에만 있어서 stable React 19.2를 사용하는 이 프로젝트에는 해당하지 않습니다.

react-best-practices 스킬은 규칙마다 마크다운 파일 하나를 두고, 나쁜 예와 좋은 예를 코드로 대조합니다.
스킬이 없을 때와 있을 때의 차이는 데이터 요청 코드에서 바로 보입니다.

PROMPT
1
영화 상세 화면에서 영화 정보와 비슷한 영화 목록을 함께 가져와 줘.
스킬이 설치된 상태의 요청

이 요청에서 두 요청을 await로 순서대로 처리하는 대신 Promise.all이나 병렬 쿼리로 동시에 시작하도록 유도하는 것이 스킬의 역할입니다.
스킬은 이런 순차 요청을 워터폴(Waterfall)이라는 이름으로 첫 번째 범주에 두고 가장 높은 영향력으로 분류합니다.

다만 이 스킬은 Next.js 프로젝트를 함께 대상으로 삼습니다.
서버 컴포넌트와 React.cache(), after()를 다루는 서버 사이드 범주는 Vite 프로젝트에 해당하지 않고, 클라이언트 데이터 요청 범주는 SWR을 전제하며, 리렌더링 범주에는 수동 메모이제이션을 권하는 항목이 있어서 앞에서 정한 규칙과 충돌합니다.
외부 스킬은 프로젝트 규칙과 이렇게 어긋날 수 있으므로, 설치 후에는 스킬 본문을 한 번 확인하고 무엇을 따를 것인지 CLAUDE.md에 작성합니다.
React Router 스킬에서 확인한 항목도 여기에 함께 작성합니다.

/CLAUDE.md
MD
1
2
3
4
5
6
## 외부 스킬 우선순위 - 서버 데이터는 상태 규칙대로 TanStack Query로 가져온다. `react-router` 스킬의 `loader` 데이터 로딩과 `<Form method="get">` 검색, `react-best-practices`의 SWR 규칙(`client-swr-dedup`)은 적용하지 않는다. `loader`는 인증 확인과 미리 가져오기에만 사용한다. - `react-best-practices`의 서버 컴포넌트와 Next.js 전용 규칙(`server-*`, `bundle-dynamic-imports`의 `next/dynamic`)은 적용하지 않는다. 지연 로딩은 라우트 객체의 `lazy`로 한다. - 수동 메모이제이션을 권하는 항목(`rerender-memo`)은 무시한다. 이 프로젝트는 React Compiler를 사용한다. - 워터폴 제거(`async-*`), 번들 크기(`bundle-*`), 렌더 중 파생 값 계산과 `useDeferredValue` 같은 리렌더링 범주는 그대로 따른다.
외부 스킬과 규칙이 충돌할 때

composition-patterns 스킬은 컴포넌트 설계에 관한 것입니다.
isCompact, showFooter처럼 동작을 바꾸는 불리언 Props가 늘어나는 대신 children으로 합성하고, 서로 묶여 동작하는 컴포넌트는 컴파운드 컴포넌트(Compound Component)로 만들도록 유도합니다.
forwardRef를 ref Props로, useContext를 use로 바꾸는 React 19 전환 규칙도 들어 있어서 앞에서 정한 규칙과 방향이 같습니다.

web-design-guidelines 스킬은 검토용입니다.
파일 경로와 함께 호출하면 최신 지침을 가져와서 접근성, 포커스 상태, 양식, 이미지 크기 같은 항목을 대조하고 위반을 파일:줄번호 형식으로 보고합니다.

PROMPT
1
/web-design-guidelines src/components
UI 지침 검토

같은 react-best-practices 스킬은 공식 마켓플레이스의 vercel 플러그인에도 포함되어 있습니다.
다만 이 플러그인은 Next.js와 Vercel 배포 중심의 스킬 35개를 함께 설치하므로, Vite 프로젝트에서는 필요한 스킬만 npx skills add로 설치하는 것이 컨텍스트를 아낍니다.

조직 계정 이름이 공식처럼 보여도 실제로는 아닌 경우가 있습니다.
tanstack-skills 계정의 TanStack 스킬 모음은 저장소 설명에 비공식(UNOFFICIAL)이라고 밝히고 있습니다.
설치 전에 README에서 공식 여부와 마지막 갱신 시점을 확인하세요.

# 라이브러리 사용 스킬

공식 스킬이 없는 TanStack Query와 Zustand는 이 블로그의 글을 기준으로 만든 스킬을 사용합니다.
글의 예제와 같은 패턴을 따르므로, 글을 읽고 작성한 규칙과 크게 어긋나지 않습니다.

스킬 기준 글 내용
tanstack-react-query-use TanStack Query 핵심 정리 상황별 훅 선택, staleTime과 gcTime, 변경과 낙관적 업데이트, 무한 스크롤
zustand-use Zustand 핵심 정리 스토어 정의 패턴, 선택자와 useShallow, 미들웨어 순서, 스토어 파일 생성 절차
react-router-use React Router 핵심 정리 (Data Mode) 기존 Vite 프로젝트에 Data 모드 라우터 도입, 레이아웃, 보호 라우트, 지연 로딩
BASH
1
npx skills add ParkYoungWoong/skills --skill tanstack-react-query-use --skill zustand-use
라이브러리 사용 스킬 설치

react-router-use는 공식 스킬과 역할이 다릅니다.
공식 스킬이 문서를 읽고 판단하게 하는 안내라면, 이 스킬은 라우터 파일과 레이아웃, 보호 라우트를 실제로 만드는 절차형 스킬이라서 둘을 함께 설치해도 됩니다.
다만 zustand-use는 새 스토어에 combine 미들웨어 템플릿을 기본으로 검토하므로, 앞에서 정한 create<State>() 형태를 유지하려면 CLAUDE.md의 우선순위 절에 한 줄을 추가합니다.

공식 스킬이 없는 라이브러리는 문서 MCP로도 보완할 수 있습니다.
Context7 같은 서버를 연결하면 TanStack Query와 Zustand의 최신 문서를 참고해서 답변하도록 할 수 있습니다.
버전에 따라 권장 방식이 바뀌는 API는 기억에 의존한 답변보다 문서를 참고한 답변이 정확합니다.

PROMPT
1
/plugin install context7@claude-plugins-official
Context7 플러그인 설치

# 스킬 작성

공식 스킬이 채우지 못하는 프로젝트 고유의 규칙과 절차는 직접 만듭니다.

스킬은 폴더 하나와 SKILL.md 파일 하나로 만듭니다.
프론트매터에 name과 description만 있으면 동작하고, 본문에는 수행할 절차를 작성합니다.

/.claude/skills/check/SKILL.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
--- name: check description: 타입 검사와 린트, 단위 테스트를 순서대로 실행합니다. --- # 검증 실행 다음 명령을 순서대로 실행하고, 실패한 명령이 있으면 그 출력을 그대로 보고합니다. 1. `npm run typecheck` 1. `npm run lint` 1. `npm run test:unit`
가장 단순한 형태의 스킬

파일을 저장하면 폴더 이름이 그대로 슬래시 명령이 됩니다.

PROMPT
1
/check
슬래시 명령으로 실행

매번 "검증해 줘"라고 설명하고 결과를 확인하는 대신, 같은 절차를 같은 순서로 실행할 수 있습니다.

규칙과 명령이 늘어나면 어떤 내용을 어디에 배치할 것인지 정해야 합니다.
다음 세 가지 위치는 성격이 다릅니다.

위치 로드 시점 담을 내용
CLAUDE.md 항상 프로젝트 전체에 적용되는 짧은 결정
.claude/rules/ 항상, paths 지정 시 해당 파일 작업할 때만 영역별 규칙 묶음
.claude/skills/ 이름과 설명은 항상, 본문은 사용할 때 절차가 있는 작업, 슬래시 명령

CLAUDE.md와 규칙 파일은 매 세션 컨텍스트를 차지하므로 짧게 유지합니다.
반면 스킬은 본문이 실제로 사용할 때만 로드되므로 길어도 부담이 적습니다.
절차가 여러 단계이거나 참조 문서가 필요한 작업은 스킬로 분리합니다.

React 프로젝트에서 스킬로 만들 만한 후보는 다음과 같습니다.

  • 프로젝트 초기 구성: React 프로젝트 시작하기 w. Vite의 react-vite-scaffold 스킬처럼 Vite 생성부터 ESLint, Prettier, Tailwind CSS, 경로 별칭, 폴더 구조까지 한 번에
  • 페이지 추가: 페이지 컴포넌트 생성, paths 상수 추가, 라우트 등록, lazy 연결까지 한 묶음
  • 컴포넌트 리뷰: 규칙 위반 여부를 읽기만 하고 지적

이 중 리뷰 스킬은 사용할 도구를 제한해, 검토 중에 파일이 바뀌지 않도록 만들 수 있습니다.

/.claude/skills/react-review/SKILL.md
MD
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
--- name: react-review description: | React 컴포넌트와 훅이 프로젝트 규칙을 지키는지 파일 수정 없이 검토합니다. 컴포넌트 리뷰를 요청하거나 규칙 위반을 확인할 때 사용하세요. react review, hooks, 코드 리뷰. argument-hint: 검토할 경로 (예: src/components) allowed-tools: Read, Grep, Glob disallowed-tools: Edit, Write --- # React 컴포넌트 리뷰 인수로 경로를 받으면 그 경로 아래의 `*.tsx`, `*.ts` 파일만 검토하고, 인수가 없으면 `/src` 전체를 검토합니다. 파일을 읽기만 하고 수정하지 않습니다. 다음 항목을 순서대로 확인하고 위반 사항만 보고합니다. 1. `useEffect` 안에서 `fetch`를 호출하거나, 다른 상태에서 파생한 값을 `setState`로 동기화하지 않는가. 1. 외부 시스템과 동기화하는 용도가 아닌 `useEffect`가 없는가. 1. `useMemo`, `useCallback`, `memo`를 직접 작성하지 않는가. 1. 서버 데이터를 스토어나 Context에 복사하지 않는가. 1. 스토어 전체를 구독하는 `useXxxStore()` 호출이 없는가. 1. 경로 문자열을 직접 작성하지 않고 `paths` 상수를 사용하는가. 1. 목록의 `key`에 배열 인덱스를 사용하지 않는가. 1. `<div onClick>`처럼 버튼과 링크의 역할을 `div`로 만든 곳이 없는가. 1. 인라인 `style`이나 `bg-[#087ea4]` 같은 임의 값, 기본 팔레트 색 클래스를 사용하지 않는가. 1. `import`에 `@/` 별칭 대신 상위 폴더 상대 경로를 사용하지 않는가. 각 위반은 `파일:줄번호` 형식으로 위치를 함께 제시합니다.

린트로 이미 잡는 항목은 검토 목록에 넣지 않았습니다.
forwardRef와 React.FC, 훅 규칙은 npm run lint에서 걸리므로, 리뷰 스킬에는 도구가 판단하지 못하는 설계 규칙만 남깁니다.
목록 중 어떤 항목을 린트 규칙으로 옮기면 그때 여기서 지웁니다.

allowed-tools는 승인 없이 바로 사용할 수 있는 도구를 미리 지정하는 목록이지, 나머지 도구를 제한하는 설정이 아닙니다.
편집을 실제로 차단하려면 disallowed-tools나 권한 설정의 deny 규칙을 사용해야 합니다.

리뷰처럼 파일을 많이 읽는 작업은 Sub Agent에 맡기는 방법도 있습니다.
Sub Agent는 별도의 컨텍스트에서 실행되고 결과만 메인 세션에 전달하므로, 컴포넌트 20개를 확인해도 메인 대화에는 요약만 남습니다.
이때 확인할 항목은 프롬프트에 직접 작성해서 전달합니다.

PROMPT
1
2
3
4
5
6
7
8
9
Explore 에이전트로 src/components 아래 모든 컴포넌트를 훑어 줘. 확인할 항목은 다음 네 가지야. - useEffect 안에서 fetch를 호출하는 파일 - useEffect와 setState로 파생 값을 만드는 파일 - 스토어 전체를 구독하는 파일 - 경로 문자열을 직접 작성한 파일 위반한 파일만 `파일:줄번호` 형식으로 정리해 줘.
Sub Agent에 리뷰 위임

# 버그 원인 파악

검색이 안 돼라고만 전달하면 코드를 확인하고 원인을 추측합니다.
맞을 때도 있지만, 틀리면 문제가 없던 코드까지 함께 바뀌고 무엇이 원인이었는지도 남지 않습니다.
원인을 좁히려면 관찰한 사실을 그대로 전달해야 합니다.

전달할 것은 네 가지입니다.

항목 내용
재현 절차 어떤 화면에서 무엇을 했을 때 발생하는지
기대와 실제 무엇을 기대했고 실제로는 무엇이 나왔는지
원문 콘솔 오류, 터미널 출력, 네트워크 응답을 요약하지 않고 그대로
범위 관련 파일 경로와 최근에 바뀐 부분

이 중 에러 메시지의 원문을 요약하지 않는 것이 특히 중요합니다.
React의 경고는 어느 컴포넌트에서 발생했는지 컴포넌트 이름을 함께 출력하므로, key 경고가 나와보다 원문이 훨씬 정확하게 문제를 파악하게 합니다.

PROMPT
1
검색이 안 돼. 고쳐 줘.
증상만 전달한 요청
PROMPT
1
2
3
4
5
6
7
8
@/routes/pages/Movies.tsx에서 검색창에 "matrix"를 입력하고 엔터를 눌렀을 때, 목록이 갱신되지 않고 이전 결과가 그대로 남아 있어. 콘솔에는 다음 경고가 출력돼. Each child in a list should have a unique "key" prop. Check the render method of `MovieList`. 네트워크 탭에서는 omdbapi.com 요청이 200으로 성공했고 응답에 Search 배열이 들어 있어. 관련 파일은 @/hooks/useMovieSearch.ts와 @/components/MovieList.tsx야.
관찰한 사실을 전달한 요청

React에서 반복해서 나오는 증상은 원인의 후보가 정해져 있습니다.
이 목록을 규칙 파일에 작성하면 매번 설명하지 않아도 탐색 범위가 좁아집니다.

증상 확인할 곳
값을 바꿨는데 화면이 갱신되지 않음 배열이나 객체를 직접 변경한 뒤 같은 참조로 setState
이벤트나 이펙트에서 이전 값이 사용됨 오래된 클로저, 의존성 배열에서 빠진 값
개발 서버에서 요청이 두 번 감 StrictMode의 이펙트 이중 실행, 정상 동작
Maximum update depth exceeded 렌더 중 setState, 의존성 배열에 매번 새로 만드는 객체
목록을 정렬하거나 삭제하면 입력값이 섞임 key에 배열 인덱스 사용
빠르게 검색하면 이전 검색 결과가 나중에 표시됨 useEffect와 fetch의 경쟁 상태, 응답 순서가 보장되지 않음
Cannot update a component while rendering a different component 렌더 중에 부모의 상태를 변경
다른 항목으로 이동해도 화면이 그대로 같은 컴포넌트를 재사용하는 경로에서 useState 초기값에만 의존

수정보다 진단을 먼저 요구하는 것이 낫습니다.
원인 후보를 몇 가지로 좁히고 각각을 어떻게 확인할 것인지 물어보면, 코드를 바꾸지 않고도 범위를 확인할 수 있습니다.

PROMPT
1
2
3
아직 코드를 고치지 마. 위 증상의 원인 후보를 3개까지 좁히고, 각각을 어떻게 확인할 수 있는지 알려 줘. 확인 방법은 코드를 바꾸지 않고 콘솔 출력이나 React DevTools로 볼 수 있는 것으로 제시해 줘.
수정 없이 원인만 요청

브라우저를 연결하면 이 관찰을 직접 하게 할 수 있습니다.
Claude in Chrome이나 Playwright MCP, Chrome DevTools MCP를 연결하면 화면을 조작하고 콘솔과 네트워크 응답을 확인하므로, 사람이 오류를 복사해서 전달하는 단계가 사라집니다.
다만 React DevTools의 컴포넌트 트리와 Profiler는 어느 방법으로도 읽을 수 없으므로, 어느 컴포넌트가 다시 렌더링되는지는 사람이 확인해서 전달합니다.

PROMPT
1
2
3
개발 서버가 5173 포트에서 실행 중이야. 브라우저로 접속해서 검색창에 "matrix"를 입력하고 엔터를 눌러 줘. 그다음 콘솔 오류와 omdbapi.com 요청의 응답을 읽고, 목록이 나오지 않는 원인을 알려 줘.
브라우저로 직접 재현

원인을 확인했으면 고치기 전에 테스트를 먼저 만듭니다.
그 조건에서 실패하는 테스트가 있어야 수정이 실제로 그 문제를 해결했는지 확인할 수 있고, 같은 버그가 다시 들어오는 것도 막을 수 있습니다.

PROMPT
1
2
원인을 확인했으면 그 조건에서 실패하는 테스트를 먼저 작성해 줘. 테스트가 실패하는 것을 확인한 다음에 수정하고, 마지막으로 단위 테스트를 실행해 줘.
재현 테스트를 먼저 작성

증상이 여러 개일 때 한 번에 모두 전달하지 마세요.
원인이 서로 다르면 수정이 뒤섞여서 어떤 변경이 무엇을 고쳤는지 알 수 없게 됩니다.

# 리팩토링

AI와 함께 작업하면 코드가 빠르게 쌓이고, 그중 일부는 규칙에서 조금씩 벗어난 상태로 남습니다.
어긋난 코드를 그대로 두면 다음 작업에서 참고 대상이 되어 같은 방식이 복제되므로, 쌓이기 전에 되돌리는 것이 비용이 적습니다.
리팩토링은 이 어긋남을 주기적으로 정리하는 작업입니다.

리팩토링은 동작을 바꾸지 않는 변경입니다.
동작이 그대로인지 확인할 수단이 없으면 리팩토링과 기능 변경을 구분할 수 없으므로, 순서가 정해져 있습니다.

  1. 대상의 현재 동작을 테스트로 고정합니다.
  2. Plan 모드로 무엇을 어디까지 바꿀지 확정합니다.
  3. 변경합니다.
  4. typecheck, lint, test:unit을 모두 통과시킵니다.

첫 단계를 건너뛰면 리팩토링 후에 화면을 눈으로 확인하는 것 말고는 방법이 없습니다.

PROMPT
1
2
3
@/components/MovieCard.tsx를 리팩토링하기 전에, 지금 동작을 확인하는 테스트를 먼저 작성해 줘. 렌더링 결과와 onSelect 호출까지 확인하면 충분해. 테스트가 통과하는 것을 확인한 다음에 멈추고, 리팩토링은 아직 하지 마.
현재 동작을 테스트로 고정

무엇을 고칠 것인지는 이미 규칙 파일에 작성되어 있습니다.
.claude/rules/의 각 줄이 그대로 검사 항목이 되므로, 앞에서 만든 리뷰 스킬로 위반 목록을 추출하고 그 목록을 작업 목록으로 사용하면 됩니다.

PROMPT
1
2
3
4
/react-review src/components 보고된 위반 중 useEffect 안의 fetch와 파생 상태 동기화만 먼저 고쳐 줘. 파일 하나를 고칠 때마다 npm run lint와 npm run typecheck를 실행하고, 통과하면 다음 파일로 넘어가.
위반 목록을 작업 목록으로

규칙 파일에 없는 항목은 react-doctor로 보완할 수 있습니다.
npx react-doctor@latest를 실행하면 상태와 이펙트, 성능, 접근성, 구조 문제를 정해진 규칙으로 스캔해서 파일:줄번호로 보고합니다.
AI가 판단하는 리뷰 스킬과 달리 같은 코드에는 항상 같은 결과가 나오므로, 리뷰 스킬이 놓친 항목을 확인하는 용도로 적합합니다.

BASH
1
2
3
4
5
6
7
8
npx react-doctor@latest ## Derived value copied into state ## react-doctor/no-derived-state ## src/components/MovieList.tsx:14 ## Click handler missing keyboard handler ## react-doctor/click-events-have-key-events ## src/components/MovieCard.tsx:9
react-doctor 실행 결과의 일부

npx react-doctor@latest install을 실행하면 Claude가 보고된 문제를 읽고 고치는 스킬과 doctor 스크립트가 추가됩니다.
PR마다 새로 생긴 문제만 보고하는 GitHub Actions 워크플로도 함께 생성되므로, 필요하지 않으면 그 파일은 되돌립니다.

React 프로젝트에서 반복해서 나오는 리팩토링은 대체로 다음 여섯 가지입니다.

대상 바꾸는 이유
useEffect로 동기화한 파생 상태 렌더링이 한 번 더 일어나고, 두 상태를 맞추는 책임이 사람에게 넘어옴
useEffect 안의 fetch 로딩, 오류, 취소, 캐시를 컴포넌트마다 직접 구현해야 함
곳곳의 useMemo, useCallback, memo 컴파일러가 처리하므로 읽는 비용만 남음
세 단계 이상 내려가는 Props 중간 컴포넌트가 사용하지 않는 값을 받고, 값을 바꿀 때마다 경로 전체를 수정함
상위 폴더로 올라가는 상대 경로 파일을 옮길 때마다 경로가 깨짐
forwardRef, defaultProps, 클래스 컴포넌트 React 19에서 대체 방식이 있고, forwardRef는 이후 버전에서 제거될 예정

Props가 여러 단계를 내려가는 문제는 스토어로 옮기는 것이 항상 답은 아닙니다.
중간 컴포넌트가 값을 전달만 한다면, 그 값을 사용하는 컴포넌트를 children으로 넘겨서 중간 단계를 없애는 합성이 먼저입니다.
어느 쪽을 선택할지는 사람이 정하고 지시에 포함합니다.

이 중 커진 컴포넌트를 나누는 작업은 판단이 필요합니다.
줄 수를 기준으로 나누면 의미 없는 조각이 생기므로, 어디를 분리할 것인지는 사람이 정하고 그 경계를 지시에 포함합니다.

PROMPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@/routes/pages/Movies.tsx가 300줄을 넘었어. 검색 입력 부분만 SearchBar 컴포넌트로 분리해 줘. ```ts interface SearchBarProps { value: string onChange: (value: string) => void onSubmit: () => void } ``` - 검색어 상태는 Movies.tsx가 계속 소유한다. - SearchBar는 입력과 제출만 담당하고 useMovieSearch를 호출하지 않는다. - 분리 후 기존 테스트가 그대로 통과해야 한다.
경계를 정해 분리 요청

전체적으로 리팩토링해 줘 같은 요청은 피하세요.
한 번에 많은 파일이 바뀌면 검토가 어려워지고, 문제가 생겼을 때 되돌릴 위치도 사라집니다.
최소 단위로 수정하고 검증한 뒤 커밋하는 단위를 유지하는 것이 좋습니다.

# PR 리뷰

리뷰 스킬이 파일 하나가 규칙을 지키는지 본다면, PR 리뷰는 기준이 다릅니다.
변경된 파일들이 서로 어떻게 연결되는지, 상태를 소유한 위치와 컴포넌트 경계가 이번 변경으로 흐트러지지 않았는지를 봅니다.
파일마다 규칙을 지키면서도 전체 구조가 어긋나는 경우가 있기 때문입니다.

브랜치와 PR, 병합의 일반적인 흐름은 사례로 이해하는 GitHub Flow를 참고하세요.
여기서는 React 프로젝트의 변경분을 무엇을 기준으로 볼지에 집중합니다.

PR을 올리기 전에 로컬에서 변경분만 먼저 확인합니다.
작업 브랜치와 main의 차이를 대상으로 삼으면 이번에 건드리지 않은 파일은 리뷰에서 빠집니다.
React에서는 새로 추가된 useEffect를 하나씩 확인하는 항목이 특히 중요합니다.
이펙트는 외부 시스템과 동기화할 때만 필요하므로, 그 용도가 아닌 이펙트는 대부분 파생 상태나 이벤트 핸들러로 옮길 수 있습니다.

PROMPT
1
2
3
4
5
6
7
main과 현재 브랜치의 차이를 확인하고 이번 변경만 리뷰해 줘. 파일은 수정하지 말고 다음 네 가지를 순서대로 정리해 줘. 1. 이번 변경으로 추가되거나 이동한 상태가 어디에 있는지 2. 새로 추가된 useEffect가 있는지, 있다면 외부 시스템과 동기화하는 용도인지 3. .claude/rules/의 규칙을 어긴 부분 4. 테스트가 없는 새 컴포넌트나 훅
변경분 리뷰

버그를 찾는 목적이라면 내장 명령 /code-review를 함께 사용합니다.
현재 변경 사항에서 정확성 문제와 단순화할 부분을 찾아 주며, 위 프롬프트는 상태 위치와 규칙처럼 프로젝트가 정한 기준을 확인하는 용도로 구분해서 사용합니다.

지적 사항을 반영한 뒤에는 검증 명령을 다시 실행합니다.
반영 과정에서 새로운 타입 오류나 린트 오류가 생길 수 있으므로, 반영과 검증을 한 번의 지시로 묶는 것이 안전합니다.
반영하지 않을 항목은 이유와 함께 남겨 두면 리뷰를 다시 받을 때 같은 지적이 반복되지 않습니다.

PROMPT
1
2
3
1번과 3번 지적을 반영해 줘. 2번은 이번 작업 범위가 아니니 그대로 두고, 왜 남겨 두는지 PR 본문에 한 줄로 적어 줘. 반영이 끝나면 typecheck, lint, test:unit을 순서대로 실행해서 통과를 확인해 줘.
반영과 재검증

검증까지 통과하면 PR을 만듭니다.
Claude가 gh 명령을 사용하므로 GitHub CLI를 먼저 설치하고 로그인해야 합니다.

BASH
1
2
3
4
5
6
7
8
## macOS brew install gh ## Windows winget install --id GitHub.cli --source winget gh auth login gh auth status
GitHub CLI 설치와 로그인

gh auth login은 브라우저와 대화형 입력을 사용하므로 Claude에게 맡기지 말고 직접 실행합니다.
로그인하지 않으면 gh pr create가 요청을 보내기 전에 실패합니다.
매번 승인을 묻지 않도록 gh 명령도 프로젝트 설정에 등록합니다.

/.claude/settings.json
JSON
1
2
3
4
5
{ "permissions": { "allow": ["Bash(gh:*)"] } }
gh 명령 허용

프로젝트 준비와 검증 루프에서 나눠서 추가한 설정까지 합치면 .claude/settings.json은 다음과 같습니다.

/.claude/settings.json
JSON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{ "permissions": { "allow": [ "Bash(npm run typecheck)", "Bash(npm run lint)", "Bash(npm run test:unit)", "Bash(gh:*)" ], "deny": [ "Read(.env)", "Read(.env.local)", "Read(.env.*.local)" ] }, "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-react.mjs"] } ] } ] } }
지금까지 추가한 설정을 합친 파일
PROMPT
1
2
현재 브랜치의 커밋을 정리해서 PR을 만들어 줘. 제목은 한 줄로 쓰고, 본문에는 바뀐 화면과 새로 생긴 파일의 역할, 검증 명령의 실행 결과를 적어 줘.
PR 생성

PR 설명은 업로드 전에 꼭 직접 읽어야 합니다.
커밋 내역만 확인해서 작성하므로, 실제로 하지 않은 작업이 설명에 들어가는 경우가 발생할 수 있습니다.

# 성능 측정

화면이 완성되면 성능을 숫자로 확인합니다.
Lighthouse는 페이지를 실제로 한 번 열어 보고 성능, 접근성, 권장 사항, SEO를 점수로 매기는 도구입니다.

개발 서버(npm run dev)에서 측정한 점수는 의미가 없습니다.
소스맵과 HMR 코드가 함께 로드되고 번들도 압축되지 않은 상태이기 때문입니다.
npm run build > npm run preview 결과에서 측정하세요!

성능 점수는 몇 가지 지표를 합산한 값입니다.
React 프로젝트에서 실제로 손댈 수 있는 지표는 다음 세 가지입니다.

지표 의미 자주 나오는 원인
LCP 가장 큰 콘텐츠가 표시되기까지의 시간 첫 화면 번들 크기, 포스터 이미지 용량
CLS 화면이 밀리는 정도 이미지에 크기를 지정하지 않음, 로딩 후 목록이 끼어듦
INP 입력에 화면이 반응하기까지의 시간 입력마다 큰 목록을 동기적으로 다시 렌더링

측정은 스킬로 자동화할 수 있습니다.
다음 스킬은 프로젝트 설정 파일에서 프레임워크를 감지하고, 개발 서버와 프로덕션 빌드 중 무엇을 측정할지 물어본 뒤 빌드와 프리뷰 서버 실행까지 처리합니다.

BASH
1
npx skills add ParkYoungWoong/skills --skill lighthouse
Lighthouse 스킬 설치
PROMPT
1
/lighthouse
슬래시 명령으로 측정

측정 결과를 그대로 전달하면 개선 항목을 정리해 줍니다.
이때 무엇을 바꿔도 되는지 범위를 함께 정해 주는 것이 좋습니다.

PROMPT
1
2
Lighthouse 결과에서 성능 점수를 떨어뜨리는 항목 세 가지를 고르고, 각각의 원인을 코드에서 찾아 줘. 고치기 전에 무엇을 바꿀지 먼저 알려 주고, 화면에 보이는 동작은 바꾸지 마.
측정 결과로 개선 요청

React 프로젝트에서 자주 적용하는 개선은 다음과 같습니다.
대부분은 규칙 파일에 미리 추가할 수 있는 내용이라, 나중에 고치기보다 처음부터 규칙으로 작성하는 것이 낫습니다.

개선 방법
첫 화면 번들 축소 라우트 객체의 lazy로 페이지와 레이아웃 모듈을 분리
이미지로 인한 밀림 제거 <img>에 width와 height 지정
화면 밖 이미지 지연 <img>에 loading="lazy" 지정
입력 중 화면 반응 유지 useDeferredValue로 목록 갱신을 입력보다 뒤로 미룸
입력마다 발생하는 요청 축소 검색어 디바운스, TanStack Query의 캐시 재사용
불필요한 리렌더링 제거 React Compiler 적용, 스토어는 선택자로 구독
긴 목록 @tanstack/react-virtual로 보이는 항목만 렌더링
커진 번들의 원인 확인 rollup-plugin-visualizer로 빌드 결과를 트리맵으로 확인

번들 분석은 rollup-plugin-visualizer를 Vite 플러그인으로 추가해서 확인합니다.
Vite 8은 번들러가 Rollup에서 Rolldown으로 바뀌었는데, 이 플러그인은 7 버전부터 Rolldown을 지원합니다.
빌드가 끝나면 프로젝트 루트에 stats.html이 생성되고, open 옵션을 켜면 브라우저에서 바로 열립니다.

BASH
1
npm i -D rollup-plugin-visualizer
번들 분석 플러그인 설치
/vite.config.ts
TS
1
2
3
4
5
6
7
8
9
10
11
12
/// <reference types="vitest/config" /> import { defineConfig, type PluginOption } from 'vite' import react from '@vitejs/plugin-react' import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [ react(), visualizer({ open: true }) as PluginOption ], // ... })
빌드할 때 stats.html 생성

INP 개선은 React 고유의 방법이 있습니다.
검색어를 입력할 때마다 큰 목록을 동기적으로 다시 렌더링하면 입력 자체가 느려지는데, useDeferredValue로 목록에 전달하는 값을 지연시키면 입력은 즉시 반영되고 목록은 여유가 있을 때 갱신됩니다.
useDeferredValue를 어디에 적용할지는 useMemo나 memo와 달리 컴파일러가 대신 결정하지 못하므로, 규칙 파일에 사용 조건을 작성합니다.

단순히 점수만 목표로 하면 화면 동작을 수정하는 경우가 발생할 수 있습니다.
성능 점수를 90점 이상으로 올려 줘보다 이 항목의 원인을 찾아서 개선해 줘가 훨씬 안전합니다.
개선 후에는 단위 테스트나 E2E 테스트로 동작이 변화가 없는지 확인해야 합니다.

# 배포

배포 단계에서는 주소를 직접 입력하거나 새로고침했을 때 404가 발생하지 않는지, 그리고 환경 변수가 빌드에 제대로 포함되었는지 등을 확인해야 합니다.

먼저 API Key처럼 코드에 직접 작성했던 값을 환경 변수로 분리해야 합니다.
Vite는 VITE_ 접두사가 붙은 변수만 클라이언트 코드에 노출하므로, 이름 규칙을 지시에 함께 작성합니다.

PROMPT
1
2
3
4
OMDb API Key를 환경 변수로 분리해 줘. 변수 이름은 VITE_OMDB_API_KEY로 하고 .env.local에 두되, .gitignore에 추가해 줘. 값은 내가 직접 넣을 테니 .env.example에는 이름만 적어 줘. import.meta.env의 타입도 함께 정의해 줘.
환경 변수 분리 요청

프로젝트 준비에서 등록한 deny 규칙 때문에 Claude는 .env.local을 읽지 못합니다.
그래서 값이 아니라 변수 이름을 지시에 작성해야 합니다.

거부 규칙은 Claude가 파일을 직접 열거나 고치는 것을 제한합니다.
반대로 Claude가 실행시킨 프로그램이 그 파일을 읽는 것까지는 제한하지 못합니다.

제한됨 제한되지 않음
Claude가 직접 읽기: Read, Grep, @파일 멘션, cat 같은 명령 실행한 스크립트가 읽기: node script.js
Claude가 직접 고치기: 같은 경로의 Edit, Write 빌드 도구가 값을 읽어 결과물에 포함: npm run build

거부 규칙은 Claude Code가 실행하는 도구와 명령을 대상으로 합니다.
프로세스 수준까지 제한하려면 샌드박스를 활성화해야 합니다.

/src/vite-env.d.ts
TS
1
2
3
4
5
6
7
8
9
/// <reference types="vite/client" /> interface ViteTypeOptions { strictImportMetaEnv: unknown } interface ImportMetaEnv { readonly VITE_OMDB_API_KEY: string }
환경 변수 타입 정의

ImportMetaEnv에는 기본적으로 모든 이름을 받아들이는 인덱스 시그니처가 있습니다.
그래서 타입을 선언해도 import.meta.env.VITE_OMDB_KEY처럼 이름을 잘못 작성한 코드가 any로 통과합니다.
ViteTypeOptions에 strictImportMetaEnv를 선언하면 이 시그니처가 사라지고, 정의하지 않은 이름을 사용하는 순간 타입 검사에서 걸립니다.

BASH
1
2
3
4
npm run typecheck ## error TS2551: Property 'VITE_OMDB_KEY' does not exist on type 'ImportMetaEnv'. ## Did you mean 'VITE_OMDB_API_KEY'?
이름을 잘못 썼을 때

ViteTypeOptions를 통한 엄격 검사는 Vite 6.3 버전부터 사용할 수 있습니다.

VITE_ 접두사가 붙은 변수는 빌드 결과물에 문자열 그대로 포함됩니다.
브라우저에서 그대로 확인할 수 있으므로 비밀 키를 작성하면 안 됩니다.

다음은 새로고침 문제입니다.
createBrowserRouter는 브라우저의 History API를 사용하므로, 모든 요청을 index.html로 전달하는 서버 설정이 필요합니다.
이 설정이 없으면 목록에서 상세 페이지로 이동하는 것은 되지만, 상세 페이지 주소를 직접 열면 404가 납니다.

/vercel.json
JSON
1
2
3
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

빌드 설정도 배포 전에 확인합니다.
Vercel에서 프레임워크를 감지해 빌드 명령과 출력 폴더를 자동으로 입력하지만, package.json의 스크립트를 바꿨다면 어긋날 수 있습니다.

PROMPT
1
2
3
배포 전에 빌드가 정상인지 확인해 줘. npm run build를 실행하고 출력 폴더 이름과 결과물 크기, 경고가 있으면 알려 줘. 그리고 이 프로젝트에 맞는 Vercel 빌드 명령과 출력 폴더 값을 알려 줘.
빌드 설정 확인

배포는 GitHub 저장소를 Vercel 프로젝트에 연결하면 푸시할 때마다 자동으로 진행됩니다.

  1. Vercel에 GitHub 계정으로 로그인하고 Add New > Project를 선택합니다.
  2. 배포할 저장소를 가져오고, 감지된 프레임워크와 빌드 명령, 출력 폴더(dist)를 확인합니다.
  3. Environment Variables에 VITE_OMDB_API_KEY와 값을 등록합니다.
  4. Deploy를 눌러 첫 배포를 진행합니다.

main 브랜치는 프로덕션으로, 나머지 브랜치와 PR은 프리뷰로 배포되므로 앞에서 만든 PR을 그대로 확인용 주소로 사용할 수 있습니다.
환경 변수는 프로덕션과 프리뷰, 로컬 개발에 각각 다른 값을 지정할 수 있습니다.
로컬의 .env.local은 저장소에 업로드되지 않으므로, 따로 등록하지 않으면 배포 환경에는 값이 존재하지 않습니다.

Vite의 환경 변수는 빌드 시점에 문자열로 치환됩니다.
Vercel에서 값을 바꿔도 다시 배포하기 전까지는 반영되지 않습니다.

배포 상태는 Vercel 대시보드의 Deployments에서 확인합니다.
각 배포에는 빌드 로그가 남으므로, 실패했을 때는 로그의 마지막 오류를 그대로 복사해서 전달하는 것이 가장 빠릅니다.
로컬에서는 통과하는데 배포에서만 실패한다면 원인은 대개 정해져 있습니다.

증상 원인
타입 오류로 빌드 실패 로컬에서 typecheck 없이 dev만 실행
대소문자가 다른 파일을 찾지 못함 macOS는 대소문자를 구분하지 않지만 배포 환경은 구분
환경 변수가 undefined Vercel에 값을 등록하지 않았거나 등록 후 재배포하지 않음
상세 경로에서만 404 vercel.json의 재작성 설정 누락

배포가 끝나면 확인도 맡길 수 있습니다.

PROMPT
1
2
3
배포된 주소로 접속해서 검색이 동작하는지 확인해 줘. 그리고 /movies/tt0111161 같은 상세 경로를 주소창에 직접 입력했을 때 404가 나지 않는지도 확인해 줘. 콘솔 오류가 있으면 요약하지 말고 원문 그대로 알려 줘.
배포 결과 확인

# 핵심 정리

  1. Vite로 React + TypeScript 프로젝트를 만들고 ESLint, Prettier, Tailwind CSS, 경로 별칭을 구성합니다.
  2. typecheck, lint 스크립트를 추가하고, eslint-plugin-react-hooks v7의 추천 프리셋과 .claude/settings.json의 실행 권한을 등록합니다.
  3. /init으로 CLAUDE.md 초안을 만든 뒤, React 규칙을 직접 작성합니다.
  4. 규칙 없이 컴포넌트를 만들어 보고, 규칙을 넣은 뒤 같은 요청을 반복해 결과 차이를 확인합니다.
  5. forwardRef와 React.FC 금지 규칙을 ESLint로 옮기고, 지침에서는 지웁니다.
  6. Plan 모드로 영화 검색 화면의 파일 구성을 계획하고, 인터페이스를 먼저 정해 컴포넌트를 만듭니다.
  7. 검색 로직을 queryOptions 정의와 커스텀 훅으로 분리하고, 검색어를 URL 검색 매개변수로 옮깁니다.
  8. 규칙이 늘어나면 .claude/rules/로 나누고, 상태 규칙과 라우팅 규칙을 작성합니다.
  9. /new-store 슬래시 명령으로 스토어를 만들고, 라우트 객체의 lazy로 페이지를 분리하고, paths 상수로 경로 규칙을 타입에 반영합니다.
  10. 공급자를 감싸는 렌더 헬퍼를 만들고, Testing Library로 단위 테스트를 작성합니다.
  11. PostToolUse 훅으로 린트를 자동화하고, Claude in Chrome이나 Playwright MCP를 연결해 검색 화면의 동작을 브라우저에서 확인합니다.
  12. React Router 공식 스킬과 react-best-practices 스킬을 설치하고, 규칙과 어긋나는 항목을 CLAUDE.md의 우선순위 절에 작성합니다.
  13. 리뷰 스킬을 만들어 지금까지 만든 컴포넌트 전체를 검토합니다.
  14. 버그를 하나 심어 두고, 증상만 전달했을 때와 관찰한 사실을 전달했을 때 응답이 어떻게 달라지는지 비교합니다.
  15. 리뷰 스킬이 뽑은 위반 목록을 작업 목록으로 삼아 리팩토링하고, 테스트가 그대로 통과하는지 확인합니다.
  16. 브랜치를 만들어 PR을 올리고, 새로 추가된 useEffect를 확인하는 변경분 리뷰와 반영 루프를 한 번 돌립니다.
  17. Lighthouse 스킬로 프로덕션 빌드를 측정하고, 지적된 항목 하나를 고쳐 다시 측정합니다.
  18. API 키를 환경 변수로 분리하고, Claude에게 .env.local을 읽어 달라고 시켜 거부 규칙이 실제로 동작을 제한하는지 확인합니다.
  19. Vercel에 배포한 뒤, 상세 경로를 주소창에 직접 입력해 확인합니다.

사람이 결정하고, 그 결정을 지침과 린트, 타입 중 어길 수 없는 쪽에 가깝게 고정하고, 도구로 지켜졌는지 검사합니다.
결정과 검증 없이 지시만 늘어나면 코드는 빠르게 쌓이겠지만 방향은 달라지기 쉽습니다.