이 문서는 독서기록 앱 빼곡의 필수 코딩 표준과 아키텍처 원칙을 정의한다.
현재 코드베이스는 빼곡의 구 버전으로 확장성과 유지보수성을 저해하는 고도로 기술적인 부채가 많은 레거시 코드로 구성있으므로, 완전히 새로운 디자인으로 리팩토링을 진행하려고 한다.
- 모든 응답은 반드시 한국어로 작성한다.
- 모든 Plan은 반드시 한국어로 작성한다.
- 사용자가 플래닝(Plan)을 요청한 경우, 반드시 전체 작업을 여러 개의 단계로 나누어 단계별로 계획을 작성해야 한다.
- 한 번에 모든 단계를 수행하거나 여러 단계를 동시에 수행해서는 안 된다.
- 반드시 현재 단계 하나만 수행해야 한다.
- 각 단계가 완료되면, 다음 단계를 수행하기 전에 반드시 사용자에게 확인을 요청해야 한다.
- 사용자의 명시적인 승인 없이 다음 단계로 진행해서는 절대 안 된다.
- 사용자의 승인 없이 새로운 파일 생성, 기존 파일 수정, 리팩토링, 또는 추가 작업을 수행해서는 절대 안 된다.
- 항상 "다음 단계로 진행할까요?" 또는 이에 준하는 명확한 승인 요청으로 단계 종료를 표시해야 한다.
- Figma API에는 요청 횟수 제한(rate limit)이 존재하므로, 동일한 리소스에 대한 반복 요청을 반드시 캐싱하여 사용한다.
- Figma API 요청은 반드시 최소화한다.
- React (Functional Components only)
- TypeScript (strict mode)
- Tanstack Query (server state)
- Zustand (client global state, 최소 사용)
- Tailwind
- React Hook Form
- Zod
- 기본 구조는 page-based로 구성한다.
- 모든 페이지는 반드시 폴더로 생성한다.
- 하위 라우트(sub-route)는 부모 페이지 폴더 내부에 동일한 구조의 폴더로 생성한다.
- 라우팅 구조와 폴더 구조는 항상 일치해야 한다.
- 페이지가 아니더라도, UI/기능 단위가 명확하면 폴더로 만들 수 있다. (예: 섹션, 플로우, 스텝, 탭 등)
- 폴더 이름은 컴포넌트(페이지) 이름을 따르며 PascalCase를 사용한다.
- 폴더의 엔트리 컴포넌트 파일은 반드시
index.tsx로 한다.- 예:
src/pages/OrderDetail/index.tsx
- 예:
- 특정 페이지(폴더) 내부에서만 재사용되는 컴포넌트/로직은 전역으로 빼지 않는다.
- 해당 폴더 내부에
shared/디렉터리를 만들고 그 안에서 관리한다.- 예:
src/pages/OrderDetail/shared/*
- 예:
shared/는 “그 폴더 스코프에서만 공유”되는 것만 허용한다.
- 전역 상태:
src/stores - 유틸:
src/utils - 전역 훅:
src/hooks - 전역 컴포넌트:
src/components
src/components아래의 모든 글로벌 컴포넌트는 스토리북 파일을 포함해야 한다.- 스토리북 파일은 컴포넌트와 같은 폴더에 생성한다.
src/
- pages/
- OrderDetail/
- index.tsx
- useOrderDetailState.ts
- shared/
- Title.tsx
- Content.tsx
- components/
- Button/
- index.tsx
- index.stories.tsx
- hooks/
- stores/
- utils/- 모든 컴포넌트는 반드시 arrow function으로 선언한다.
- 허용:
export const BookCard = () => { return <div />; };
-
컴포넌트 이름은 PascalCase를 사용한다.
-
컴포넌트는 파일이 아닌 폴더 단위로 구성하며, 폴더 이름을 컴포넌트 이름으로 사용하고 내부에 index.tsx를 사용한다.
-
허용:
BookCard / index.tsx; BookList / index.tsx;
-
props 타입은 반드시 type으로 선언한다.
-
타입 이름은 반드시 "컴포넌트이름 + Props" 형태로 작성한다.
-
컴포넌트 파라미터는 반드시 props 이름으로 받고, 컴포넌트 내부 최상단에서 구조분해한다.
-
optional props의 기본값은 컴포넌트 내부에서 별도로 처리하지 않고, 반드시 구조 분해 시점에서 정의한다.
-
허용:
type BookCardProps = { title: string; author: string; }; export const BookCard = (props: BookCardProps) => { const { title, author } = props; return <div>{title}</div>; };
-
컴포넌트 전체를 렌더링하지 않을 경우 반드시 early return으로 null을 반환한다.
-
JSX 내부의 조건부 렌더링은 반드시 logical AND (&&)를 사용한다.
-
ternary operator는 두 개의 명확한 분기가 있을 때만 사용한다.
-
허용:
export const BookCard = (props: BookCardProps) => { const { book, isLoading, isSelected, isVisibleTitle } = props; if (!book) return null; return ( <div className="book-card"> {isVisibleTitle && <div className="book-title">{title}</div>} <span className="book-status">{isSelected ? '선택됨' : '선택되지 않음'}</span> </div> ); };
-
모든 event handler는 반드시 분리하여 선언한다.
-
한 줄 로직일 경우 함수 선언 대신 직접 참조를 사용한다.
-
허용:
const handleClick = doSomething; return <button onClick={handleClick} />; or; const handleClick = () => { doSomething(); logEvent(); }; return <button onClick={handleClick} />;
-
모든 컴포넌트는 named export를 사용한다.
-
허용:
export const BookCard = () => { return <div />; }; import { BookCard } from '@/components/BookCard';
-
컴포넌트는 UI 역할만 담당한다.
-
데이터 처리 및 비즈니스 로직은 hook으로 분리한다.
-
허용
const useBookCard = () => { return { title: 'React' }; }; export const BookCard = (props: BookCardProps) => { const { title } = useBookCard(); return <div>{title}</div>; };
-
금지:
export const BookCard = () => { const data = fetch('/api/book'); const parsed = process(data); return <div>{parsed.title}</div>; };
-
서버 데이터 패칭은 useEffect + fetch를 직접 호출하지 않고, 반드시 TanStack Query(React Query)를 사용한다.
-
query 선언은 컴포넌트 내부가 아닌, 같은 레벨의 custom hook으로 분리하여 정의한다.
-
허용
export const useBooksQuery = (page: number) => { return useQuery({ queryKey: bookKeys.list({ page }), queryFn: () => getBooks({ page }), }); }; export const Books = () => { const { data, isLoading, error } = useBooksQuery(1); if (isLoading) return null; if (error) return null; return <div>{data?.items?.length ?? 0}</div>; };
- queryKey는 반드시 배열(Array) 형태로 작성한다.
- queryKey는 반드시 리소스명 + 식별자 또는 파라미터 구조로 작성한다.
- queryKey는 항상 정적 문자열 → 동적 값 순서로 작성한다.
- 문자열 단일 값 형태는 허용하지 않는다.
- 허용:
export const useBookDetailQuery = (bookId: string) => { return useQuery({ queryKey: ['books', 'detail', bookId], queryFn: () => getBook(bookId), enabled: Boolean(bookId), }); };
-
서버 상태 변경은 useMutation을 사용한다.
-
mutation 선언은 컴포넌트 내부가 아닌, 같은 레벨의 custom hook으로 분리하여 정의한다.
-
성공 시 invalidateQueries로 캐시를 동기화한다.
-
허용
export const useUpdateBookMutation = () => { return useMutation({ mutationFn: (payload: UpdateBookPayload) => updateBook(payload), onSuccess: () => { queryClient.invalidateQueries({ queryKey: bookKeys.all, }); }, }); }; export const BookEdit = () => { const mutation = useUpdateBookMutation(); const handleSave = () => { mutation.mutate({ id: bookId, title, }); }; return <button onClick={handleSave} />; };
- Zustand 사용은 최소화한다.
- Zustand 사용이 필요해 보일 경우, 반드시 사용자에게 사용 여부를 먼저 확인한다.
- Zustand는 전역 UI 상태에만 사용한다.
-
컴포넌트 내부 상태는 local state로 관리한다.
-
local state 선언 시 반드시 제네릭으로 타입을 지정한다.
-
허용
const [count, setCount] = useState<number>(0); const [title, setTitle] = useState<string>(''); type Book = { id: string; title: string; }; const [books, setBooks] = useState<Book[]>([]);
- useMemo, useCallback 사용이 필요해 보일 경우, 반드시 사용자에게 사용 여부를 먼저 확인한다.
- 불필요한 memoization을 기본적으로 생성하지 않는다.
-
폼 상태 관리(React Hook Form(RHF) + Zod)가 필요해 보일 경우, 반드시 사용자에게 사용 여부를 먼저 확인한다.
-
허용
const schema = z.object({ title: z.string(), author: z.string(), }); type FormValues = z.infer<typeof schema>; const form = useForm<FormValues>({ resolver: zodResolver(schema), }); const onSubmit = (values: FormValues) => { console.log(values); }; return ( <form onSubmit={form.handleSubmit(onSubmit)}> <input {...form.register('title')} /> </form> );
- 상수 이름은
MSG_{DOMAIN}_{CONTEXT}형식을 따른다.DOMAIN: 메시지가 속한 기능 또는 도메인 (예: AUTH, BOOK, USER, SEARCH)CONTEXT: 메시지가 사용되는 상황 또는 목적 (예: PLACEHOLDER, TITLE, DESCRIPTION, SUCCESS, ERROR, EMPTY, CONFIRM)
- 컴포넌트 내부 코드는 다음과 같이 배치한다.
- State & Ref : useState, useReducer, useRef, useContext
- Data fetching, Side Effect hook : useQuery, useMutation, useEffect, useLayoutEffect
- useMemo, useCallback
- 이벤트 핸들러, 내부 헬퍼 함수
- Render 반환부
- 컴포넌트를 포함한 파일 내부는 다음과 같이 배치한다.
- import
- type 정의
- 컴포넌트 전용 상수(추후 다국어 지원을 고려할 것)
- 컴포넌트
-
컴포넌트의 조건부 스타일 클래스는 JSX 내부에서 직접 작성하지 않고, 반드시 상수로 분리하여 선언한다.
-
스타일 관련 변수는 의미 기반 이름을 사용한다.
-
조건부 스타일이 아닌경우, inline으로 작성한다.
-
허용
const sizeClass = size === 'small' ? 'h-8 px-3 text-sm' : 'h-12 px-5 text-base'; return <button className={`${sizeClass} inline-flex items-center justify-center`}>Click</button>;
legacy 디렉토리는 보호된 영역이며, 에이전트는 이 영역의 코드를 수정해서는 안 된다.
legacy 코드는 기존 시스템과의 호환성을 위해 유지되는 코드이며, 현재 리팩토링 범위에 포함되지 않는다. legacy 코드를 수정하면 예기치 않은 사이드 이펙트와 안정성 문제가 발생할 수 있다.
다음 경로는 모두 보호 대상이다:
- src/legacy/**
- legacy/**
이 경로 아래의 모든 파일은 legacy 코드로 간주한다.
다음 조건에서도 legacy 코드를 수정해서는 안 된다:
- 새로운 컴포넌트 변경으로 인해 import 에러가 발생하는 경우
- props 타입이 변경된 경우
- 타입 에러가 발생하는 경우
- legacy 코드가 현재 컴포넌트를 import 하고 있는 경우
- 자동 리팩토링이 가능한 경우
legacy 코드는 read-only 영역으로 취급해야 한다.
에이전트는 다음 작업만 수행할 수 있다:
- legacy 코드를 읽는 것 (분석 목적)
- legacy 코드를 참조하는 것
에이전트는 다음 작업을 수행해서는 안 된다:
- legacy 파일 수정
- legacy 파일 포맷 변경
- legacy 파일 import 변경
- legacy 파일 props 변경
- legacy 파일 자동 리팩토링
legacy 코드와 충돌이 발생하는 경우:
- legacy 코드를 수정하지 않는다.
- 대신 새로운 코드에서 호환 레이어(wrapper, adapter 등)를 만든다.
- 또는 사용자에게 명시적으로 legacy 수정 허용 여부를 요청한다.
Legacy code is strictly read-only unless explicitly instructed by the user.