next-project-structure
Next.js 프로젝트에 일관된 폴더 구조와 코드 패턴을 적용하는 스킬.
이 스킬 자체가 패턴의 기준이다 — CLAUDE.md에 의존하지 말고 이 스킬의 패턴을 따른다.
단, CLAUDE.md에 더 구체적인 프로젝트 패턴이 있다면 그것을 우선 참조한다.
워크플로우
Step 1: 프로젝트 환경 파악
bash
1# 모노레포 여부 확인
2ls packages/ 2>/dev/null && echo "Monorepo" || echo "Single app"
3
4# App Router 여부 확인
5ls src/app 2>/dev/null || ls app 2>/dev/null && echo "App Router" || echo "Pages Router"
6
7# 기존 services/queries 패턴 확인 (있으면 그 패턴을 따름)
8ls src/services/api/ 2>/dev/null
9ls src/queries/ 2>/dev/null
판단 결과에 따라 분기:
- 모노레포 +
packages/services 존재 → import Services from '@workspace/services' 사용
- 단일 앱 →
src/services/core/base.ts에 BaseServices 직접 정의
- App Router →
references/app-router.md 참조
- Pages Router →
references/pages-router.md 참조
Step 2: 기존 패턴 탐색 (신규 기능 전 필수)
새 도메인/파일을 만들기 전에 유사한 기존 코드를 먼저 찾는다. 기존 패턴이 있으면 그대로 따른다.
bash
1# 기존 서비스 패턴 참조
2ls src/services/api/
3# 기존 쿼리 패턴 참조
4ls src/queries/
5# 기존 뷰 패턴 참조
6ls src/views/
Step 3: 요청 종류에 따라 생성 대상 결정
| 요청 키워드 | 생성 대상 | 비고 |
|---|
| 서비스, API, service | services/api/{domain}.ts | |
| 쿼리, useQuery, TanStack, queryKeys | queries/{domain}/ | |
| view, 뷰, view+hook 분리 | views/{page}/ | |
| 전체 도메인, 도메인 추가 | services + queries + view + types 모두 | |
| 컴포넌트, 훅 단독 생성 | — | component-creator 스킬 사용 |
Step 4: 파일 생성 + 배럴 index.ts 즉시 업데이트
파일을 만든 즉시 해당 폴더의 index.ts 배럴 export를 업데이트한다.
배럴 미업데이트는 import 에러의 주요 원인이다.
폴더 구조 원칙
src/
├── components/ # 앱 공통 UI 컴포넌트
│ └── {Name}/
│ ├── index.tsx # UI만 (로직 없음)
│ └── use{Name}.ts # 로직이 있으면 반드시 분리
├── hooks/ # 앱 공통 커스텀 훅
├── services/ # API 서비스
│ └── api/
│ ├── {domain}.ts # BaseServices 상속
│ └── index.ts # 배럴 export
├── queries/ # TanStack Query 훅
│ ├── {domain}/
│ │ ├── index.ts # use{Domain}Query / use{Domain}Mutation
│ │ └── queryKeys.ts # 쿼리 키 팩토리 (훅과 반드시 분리)
│ └── index.ts
├── types/
│ └── api/ # API 요청/응답 타입
├── lib/ # 순수 함수 유틸리티
├── utils/ # 앱 유틸리티
└── provider/ # QueryProvider 등 전역 컨텍스트
모노레포 추가 구조:
packages/
├── services/ # BaseServices (axios wrapper) — 앱에서 상속
└── ui/ # shadcn/ui 공통 컴포넌트
apps/
└── {app-name}/src/ # 위 src/ 구조와 동일
핵심 패턴
Services (BaseServices 상속)
모노레포 — @workspace/services 있을 때:
typescript
1// src/services/api/{domain}.ts
2import Services from '@workspace/services';
3import type { {ResponseType} } from '@/types/api/{domain}';
4
5class {Domain}Services extends Services {
6 constructor() {
7 super({ baseURL: '/api/{domain}' });
8 }
9
10 get{Resource}(params: {ParamsType}): Promise<{ResponseType}> {
11 return this.get<{ResponseType}>('', params);
12 }
13}
14
15export default new {Domain}Services(); // 싱글턴
단일 앱 — BaseServices 없을 때:
typescript
1// src/services/api/{domain}.ts
2import axios from 'axios';
3import type { {ResponseType} } from '@/types/api/{domain}';
4
5// 또는 기존 http 클라이언트 패턴 확인 후 따름
6const {domain}Api = axios.create({ baseURL: '/api/{domain}' });
7
8export const {domain}Services = {
9 get{Resource}: (params: {ParamsType}) =>
10 {domain}Api.get<{ResponseType}>('', { params }).then(r => r.data),
11};
QueryKeys (반드시 별도 파일)
typescript
1// src/queries/{domain}/queryKeys.ts
2export const {domain}Keys = {
3 all: ['{domain}'] as const,
4 lists: () => [...{domain}Keys.all, 'list'] as const,
5 list: (param: string) => [...{domain}Keys.lists(), param] as const,
6 detail: (id: string) => [...{domain}Keys.all, 'detail', id] as const,
7};
queryKeys를 별도 파일로 분리하는 이유: 훅 파일이 커지면 쿼리 키만 교체하거나
다른 도메인에서 참조하기 쉽다. 또한 invalidateQueries에서 일관성을 보장한다.
Query Hook
typescript
1// src/queries/{domain}/index.ts
2'use client'; // 반드시 최상단 — Client Component에서만 훅 실행 가능
3
4import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
5import {domain}Services from '@/services/api/{domain}';
6import { {domain}Keys } from './queryKeys';
7
8export function use{Domain}ListQuery(param: string) {
9 return useQuery({
10 queryKey: {domain}Keys.list(param),
11 queryFn: () => {domain}Services.get{Resource}(param),
12 staleTime: 5 * 60 * 1000,
13 enabled: !!param, // param 없으면 실행 안 함
14 });
15}
16
17export function useCreate{Domain}Mutation() {
18 const queryClient = useQueryClient();
19 return useMutation({
20 mutationFn: (body: {CreateType}) => {domain}Services.create{Resource}(body),
21 onSuccess: () => {
22 queryClient.invalidateQueries({ queryKey: {domain}Keys.lists() });
23 },
24 });
25}
View (UI와 로직 분리)
뷰를 UI와 로직으로 분리하는 이유: 로직만 단독으로 테스트할 수 있고,
UI 변경 시 로직을 건드리지 않아도 된다. 또한 여러 UI가 같은 로직을 공유할 수 있다.
typescript
1// src/views/{page}/use{Page}View.ts
2'use client';
3
4import { useState, useCallback } from 'react';
5import { use{Domain}ListQuery } from '@/queries/{domain}';
6
7export function use{Page}View() {
8 const [searchParam, setSearchParam] = useState('');
9 const { data, isLoading, isError } = use{Domain}ListQuery(searchParam);
10
11 const handleSearch = useCallback((value: string) => {
12 setSearchParam(value);
13 }, []);
14
15 return { data, isLoading, isError, searchParam, handleSearch };
16}
typescript
1// src/views/{page}/index.tsx
2'use client';
3
4import { use{Page}View } from './use{Page}View';
5
6export default function {Page}View() {
7 const { data, isLoading, isError, handleSearch } = use{Page}View();
8
9 if (isLoading) return <div>로딩 중...</div>;
10 if (isError) return <div>오류가 발생했습니다.</div>;
11
12 return (
13 <main>
14 {/* UI만 — 비즈니스 로직 없음 */}
15 </main>
16 );
17}
Component (로직 있으면 훅 분리)
typescript
1// src/components/{Name}/use{Name}.ts
2import { useState, useCallback } from 'react';
3
4export function use{Name}() {
5 const [state, setState] = useState(false);
6 const handleAction = useCallback(() => setState(prev => !prev), []);
7 return { state, handleAction };
8}
9
10// src/components/{Name}/index.tsx
11import { use{Name} } from './use{Name}';
12
13interface {Name}Props { /* props */ }
14
15export default function {Name}(props: {Name}Props) {
16 const { state, handleAction } = use{Name}();
17 return <div>{/* UI */}</div>;
18}
주요 금지 패턴
| 금지 | 이유 | 대안 |
|---|
useState(() => localStorage.getItem(...)) | SSR에서 ReferenceError | useEffect에서 로드 |
any 타입 | 런타임 에러 미감지 | 명시적 interface/type |
| 배럴 index.ts 미업데이트 | import 경로 불일치 | 파일 생성 즉시 추가 |
| 뷰에 비즈니스 로직 직접 작성 | UI/로직 책임 혼재 | use{Page}View 훅으로 분리 |
훅 파일 상단에 'use client' 누락 | TanStack Query 서버 실행 오류 | 모든 훅 파일 최상단에 추가 |
| queryKeys를 훅 파일에 인라인 작성 | invalidate/prefetch 재사용 불가 | 별도 queryKeys.ts 파일 |
새 도메인 추가 체크리스트
상세 참조
| 파일 | 내용 |
|---|
references/app-router.md | App Router 전용 패턴 (Server Component, Suspense 등) |
references/pages-router.md | Pages Router 전용 패턴 |
references/boilerplate-templates.md | 복사 즉시 사용 가능한 전체 보일러플레이트 |