--- url: 'https://union.spartaclub.kr//CHANGELOG.md' --- # @teamsparta/frontend-guide ## 1.1.1 ### Patch Changes * 291a64c: docs: 컴포넌트 분리 문서 내용 개선 및 사이드바 구조 정리 * c1171d0: 사용하지 않는 항목 제거 ## 1.1.0 ### Minor Changes * 1933bac: create template --- --- url: 'https://union.spartaclub.kr//contents/ai-setup.md' --- # AI 연동 팀스파르타 프론트엔드 코드 가이드를 LLM이 읽기 좋은 형식으로 제공합니다. [`vitepress-plugin-llms`](https://github.com/okineadev/vitepress-plugin-llms)를 통해 자동 생성됩니다. ## 생성 파일 | 파일 | 설명 | | ---------------------------------------------- | -------------------------------------- | | [`/llms-code-guide.txt`](/llms-code-guide.txt) | 코드 가이드 전문 (모든 문서 내용 통합) | ## AI 도구별 사용법 ### Claude Code 아래 명령어 한 줄로 자동 설정합니다. ```bash npx @teamsparta/union-mcp@latest llms.txt ``` `~/.claude/rules/frontend-code-guide.md` 파일이 생성되어 모든 프로젝트에 자동 적용됩니다. 이미 설정된 경우 재실행해도 중복 추가되지 않습니다. --- --- url: 'https://union.spartaclub.kr//contents/app-router-directory.md' --- # App Router 디렉토리 구조를 통일하기 라우트(지면)에 필요한 코드를 해당 라우트 폴더 안에 격리하고, 폴더 역할을 명확히 구분합니다. 일관된 디렉토리 구조는 새 기능을 어디에 추가해야 할지 바로 결정하게 해주고, 코드 탐색 비용을 줄여줍니다. ## 1. 디렉토리 구조 개요 `_`, `()`, `@`, `[...]` 등 [Next.js 공식 폴더 컨벤션](https://nextjs.org/docs/app/getting-started/project-structure)을 따릅니다. `_global`, `_shared` 같은 폴더 이름은 우리 팀의 약속입니다. `app/` 아래에는 세 가지 종류의 폴더가 있습니다. * `_global/` — 앱 전역(레이아웃, 프로바이더, 전역 CSS) * `_shared/` — 두 개 이상의 지면에서 가져다 쓰는 공용 코드 * `(some-page)/` — 특정 지면(라우트) 전용 코드 전체 구조는 다음과 같습니다. ```text 📁 app/ ├── 📁 _global/ ⓐ 앱 전역 │ ├── 📁 _components/ │ │ └── 📁 Gnb/ │ │ ├── Gnb.tsx │ │ └── Gnb.css.ts │ ├── 📁 _hooks/ │ ├── 📁 _queries/ ← 모든 서버 쿼리 (queryKey + queryOptions) │ │ └── myCourse.queries.ts │ ├── 📁 _apis/ ← 모든 API 호출 함수 │ │ └── myCourse.api.ts │ ├── 📁 _data/ │ ├── 📁 _providers/ │ └── 📁 _styles/ ├── 📁 _shared/ ⓑ 공용 코드 (도메인 단위) │ ├── 📁 user/ │ │ └── 📁 _components/ │ │ └── 📁 UserCard/ │ │ ├── UserCard.tsx │ │ └── UserCard.css.ts │ └── 📁 payment/ │ ├── 📁 _components/ │ │ └── 📁 PaymentStatusBadge/ │ │ └── PaymentStatusBadge.tsx │ └── 📁 _hooks/ │ └── usePaymentStatus.ts └── 📁 (some-page)/ 라우트 그룹 (URL에 포함되지 않음) └── 📁 (withLogin)/ └── 📁 (withGnb)/ └── 📁 my-course/ /my-course 경로 ├── 📁 _components/ ① 이 지면 전용 컴포넌트 │ └── 📁 CourseCard/ │ ├── CourseCard.tsx │ ├── CourseCard.css.ts │ ├── 📁 CourseSearch/ 내부 depth 허용 │ │ └── CourseSearch.tsx │ └── 📁 CourseModal/ │ ├── CourseModal.tsx │ └── CourseModal.css.ts ├── 📁 _hooks/ ② 이 지면 전용 훅 │ └── useCourseFilter.ts ├── 📁 _services/ ③ 유틸 함수 │ └── formatCourseDate.service.ts ├── 📁 _data/ ④ 클라이언트 상태, 모델, 상수 │ ├── myCourse.store.ts │ ├── myCourse.model.ts │ └── myCourse.constant.ts ├── 📁 _actions/ ⑤ Server Actions │ └── enrollCourse.action.ts ├── 📁 _types/ ⑥ 타입 │ └── myCourse.type.ts ├── 📁 _tests/ ⑦ 테스트 │ └── myCourse.spec.ts ├── layout.tsx ├── layout.css.ts ├── page.tsx └── page.css.ts ``` ## 2. 프라이빗 폴더별 역할 각 라우트 폴더 안에 놓을 수 있는 프라이빗 폴더입니다. | | 폴더 | 담당 | 파일 예시 | |---|---|---|---| | ① | `_components/` | 이 지면에서만 쓰는 UI 컴포넌트 | `CourseCard/CourseCard.tsx` | | ② | `_hooks/` | 이 지면에서만 쓰는 커스텀 훅 | `useCourseFilter.ts` | | ③ | `_services/` | 서비스 함수 및 순수 유틸 함수 | `formatCourseDate.service.ts` | | ④ | `_data/` | 클라이언트 상태(store), 모델, 상수 | `myCourse.store.ts` | | ⑤ | `_actions/` | Next.js Server Actions | `enrollCourse.action.ts` | | ⑥ | `_types/` | TypeScript 타입 정의 | `myCourse.type.ts` | | ⑦ | `_tests/` | 테스트 파일 | `myCourse.spec.ts` | 각 도메인 라우트 안에 파일 기능에 따라 위 프라이빗 폴더를 둘 수 있습니다. 단, **`_components/` 내부의 컴포넌트 폴더 안에 `_hooks/`, `_services/` 등의 프라이빗 폴더를 중첩하는 것은 금지합니다.** 특정 컴포넌트에서만 사용하는 훅이더라도 해당 컴포넌트가 속한 도메인 라우트의 `_hooks/` 폴더에 둡니다. ```text 📁 my-course/ ├── 📁 _components/ │ └── 📁 CourseCard/ │ ├── CourseCard.tsx │ └── 📁 _hooks/ ✗ 금지 — 컴포넌트 내부에 프라이빗 폴더 중첩 │ └── useCourseCard.ts ├── 📁 _hooks/ │ └── useCourseCard.ts ✓ 도메인 라우트의 _hooks/에 배치 ``` ## 3. `_global` vs `_shared` 구분하기 두 폴더 모두 앱 전체에서 쓰인다는 점은 같지만, 성격이 다릅니다. | | `_global/` | `_shared/` | |---|---|---| | 용도 | 앱 부팅 시 무조건 실행하거나 어느 지면에서든 호출 가능 | 특정 지면에서 필요할 때 import | | 주요 내용 | Provider, 전역 CSS, 전역 에러 바운더리, 전역 레이아웃 컴포넌트, **서버 쿼리(`_queries/`), API 호출 함수(`_apis/`)** | 공용 컴포넌트, 공용 훅, 공용 유틸 | | 삭제 시 영향 | 앱 전체가 망가짐 | 해당 파일을 import한 곳만 영향 받음 | **서버 쿼리와 API 호출 함수는 무조건 `_global/`에 둡니다.** 어느 지면에서든 동일한 쿼리·API가 재사용될 수 있고, 호출 위치를 한곳에 모아두면 캐시 키 충돌과 중복 정의를 막을 수 있기 때문입니다. ### 판단 기준 * **두 개 이상의 지면에서 import한다면** → `_shared/` * **앱 루트(`app/layout.tsx`)에 선언되어야 한다면** → `_global/` * **어느 쪽인지 모호하다면** → `_shared/`로 먼저 두고, 전역으로 올릴 필요가 생길 때 이동합니다. Kent C. Dodds가 제시한 "코드를 관련된 위치에 최대한 가까이 배치하라"는 co-location 원칙에 따라, 처음에는 가장 좁은 범위(해당 라우트의 프라이빗 폴더)에 두고, 공유가 필요해질 때 `_shared/`로 올리는 방향이 유지보수에 유리합니다. **공유 코드는 최소 공통 부모가 아닌 `_shared/`로 올립니다.** 예를 들어 `my-course/`와 `payment/`에서 같은 훅을 쓴다면, 두 라우트의 공통 부모 폴더에 있는 `_hooks/`에 두는 것이 아니라 곧바로 `_shared/`로 이동합니다. 라우트 트리에서 최소 공통 부모를 찾는 것은 구조가 복잡해질수록 판단 비용이 커지기 때문입니다. ### `_global/` 예시 ```text 📁 app/_global/ ├── 📁 _components/ │ ├── 📁 Gnb/ │ │ └── Gnb.tsx │ └── 📁 GlobalErrorBoundary/ │ └── GlobalErrorBoundary.tsx ├── 📁 _providers/ │ └── GlobalProviders.tsx ├── 📁 _hooks/ ├── 📁 _queries/ ← 서버 쿼리 정의 (queryKey + queryOptions) │ ├── myCourse.queries.ts │ └── user.queries.ts ├── 📁 _apis/ ← API 호출 함수 │ ├── myCourse.api.ts │ └── user.api.ts ├── 📁 _data/ └── 📁 _styles/ └── global.css ``` ### `_shared/` 예시 ```text 📁 app/_shared/ ├── 📁 user/ 도메인 단위로 폴더 생성 │ ├── 📁 _components/ │ │ └── 📁 UserCard/ │ │ ├── UserCard.tsx │ │ └── UserCard.css.ts │ ├── 📁 _hooks/ │ └── 📁 _data/ └── 📁 payment/ ├── 📁 _components/ │ └── 📁 PaymentStatusBadge/ │ └── PaymentStatusBadge.tsx └── 📁 _hooks/ └── usePaymentStatus.ts ``` `_shared/` 아래에는 도메인별로 폴더를 만들고, 각 도메인 내부에서 라우트 폴더와 동일하게 `_components/`, `_hooks/` 등 기능별 폴더를 둡니다. ## 4. 네이밍 규칙 ### 케이스 규칙 | 대상 | 케이스 | 예시 | |---|---|---| | 컴포넌트 폴더 및 파일 | PascalCase | `CourseCard/CourseCard.tsx` | | 훅, 서비스, 스토어, 쿼리, API 파일 | camelCase | `useCourseFilter.ts`, `myCourse.queries.ts`, `myCourse.api.ts` | | URL 경로 및 API route | kebab-case | `my-course/`, `api/payment-info/` | | 스타일 파일 | PascalCase (컴포넌트 파일과 동일) | `CourseCard.css.ts` | > 케이스 규칙의 상세 근거는 [이름으로 의도 전달하기](/contents/ground-rules/naming/) 가이드를 참고합니다. ### 파일 접미사 규칙 파일의 역할을 접미사로 드러냅니다. | 폴더 | 접미사 | 예시 | |---|---|---| | `_services/` | `.service.ts` | `formatCourseDate.service.ts` | | `_data/` | `.store.ts`, `.model.ts`, `.constant.ts` | `myCourse.store.ts` | | `_global/_queries/` | `.queries.ts` | `myCourse.queries.ts` | | `_global/_apis/` | `.api.ts` | `myCourse.api.ts` | | `_actions/` | `.action.ts` | `enrollCourse.action.ts` | | `_types/` | `.type.ts` | `myCourse.type.ts` | | `_tests/` | `.spec.ts` | `myCourse.spec.ts` | **`_data/`에는 관심사별로 파일을 나눕니다.** 하나의 파일에 store, model, constant를 모두 넣지 않습니다. **서버 쿼리(`.queries.ts`)와 API 호출 함수(`.api.ts`)는 라우트 폴더가 아닌 `_global/_queries/`, `_global/_apis/`에 둡니다.** 어느 지면에서든 재사용될 수 있도록 한곳에 모읍니다. ### 서버 쿼리·API 작성 패턴 `_apis/`는 fetch 호출만 담당하고, `_queries/`는 TanStack Query의 `queryKey`와 `queryOptions`만 정의합니다. **`_queries/` 안에서 `useQuery`를 호출하지 않습니다.** 사용부에서 `useQuery(...)` 또는 `prefetchQuery(...)`로 옵션을 주입해 호출합니다. ```ts // app/_global/_apis/myCourse.api.ts export async function fetchMyCourses() { const res = await fetch('/api/my-course') if (!res.ok) throw new Error('Failed to fetch courses') return res.json() as Promise } ``` ```ts // app/_global/_queries/myCourse.queries.ts import { queryOptions } from '@tanstack/react-query' import { fetchMyCourses } from '@/app/_global/_apis/myCourse.api' export const myCourseQueries = { all: () => ['myCourse'] as const, list: () => queryOptions({ queryKey: [...myCourseQueries.all(), 'list'], queryFn: fetchMyCourses, }), } ``` ```tsx // 사용부 — 어느 지면에서든 동일하게 호출 import { useQuery } from '@tanstack/react-query' import { myCourseQueries } from '@/app/_global/_queries/myCourse.queries' export function CourseList() { const { data } = useQuery(myCourseQueries.list()) // ... } ``` 이 패턴 덕분에 같은 쿼리를 여러 지면에서 재사용해도 `queryKey`가 충돌하지 않고, 서버 컴포넌트의 `prefetchQuery`와 클라이언트 컴포넌트의 `useQuery`가 동일한 정의를 공유합니다. ### 컴포넌트 폴더 구조 **컴포넌트는 PascalCase 폴더로 분리합니다.** 폴더 안에 tsx, 스타일, svg 등 관련 파일을 함께 둡니다. **스타일 파일도 해당 컴포넌트의 일부이므로 컴포넌트 파일과 동일한 PascalCase로 작성합니다.** 컴포넌트 내부에 depth가 생길 수 있습니다. ```text 📁 CourseCard/ ├── CourseCard.tsx ← 컴포넌트 ├── CourseCard.css.ts ← 스타일 ├── 📁 CourseSearch/ ← 하위 UI를 포함하는 경우 내부 depth 허용 │ └── CourseSearch.tsx └── 📁 CourseModal/ ← 오버레이 등도 같은 방식 ├── CourseModal.tsx └── CourseModal.css.ts ``` ## 5. export 및 임포트 규칙 ### export 규칙 **컴포넌트 파일에서는 하나의 컴포넌트만 export합니다.** 파일 이름으로 어떤 컴포넌트인지 바로 파악할 수 있어야 합니다. 내부에서 사용하는 헬퍼 함수나 상수는 같은 파일에 선언할 수 있지만, export하는 컴포넌트는 하나여야 합니다. 서비스, 쿼리, 훅 등 다른 파일은 여러 함수를 함께 선언할 수 있습니다. **default export를 사용하지 않습니다.** named export만 사용합니다. 단, Next.js가 요구하는 특수 파일(`page.tsx`, `layout.tsx`, `error.tsx`, `loading.tsx` 등)은 예외입니다. ```tsx // ✗ default export export default function CourseCard() { ... } // ✓ named export export function CourseCard() { ... } ``` ### 배럴 파일 금지 **`index.ts` / `index.tsx` 배럴 파일은 만들지 않습니다.** #### 좋지 않은 예시 ```tsx // _components/index.ts — 모든 컴포넌트를 re-export export { CourseCard } from './CourseCard/CourseCard' export { CourseFilter } from './CourseFilter/CourseFilter' export { CourseList } from './CourseList/CourseList' // page.tsx — 배럴 파일을 통해 import import { CourseCard, CourseFilter, CourseList } from './_components' ``` #### 문제 배럴 파일은 하나의 export만 필요해도 해당 파일이 re-export하는 모든 모듈을 불러오게 만듭니다. Vercel 공식 블로그에서도 이를 번들 성능의 주요 병목으로 분석하고 있으며, Atlassian은 Jira 프론트엔드에서 배럴 파일을 제거한 후 빌드 시간 75% 감소를 보고했습니다. 번들 성능 외에도 순환 참조 문제를 유발하고, 파일 간 의존성을 추적하기 어렵게 만듭니다. #### 좋은 예시 ```tsx // page.tsx — 실제 파일 경로로 직접 import import { CourseCard } from './_components/CourseCard/CourseCard' import { CourseFilter } from './_components/CourseFilter/CourseFilter' import { CourseList } from './_components/CourseList/CourseList' ``` 번들러가 각 파일의 의존성을 정확히 파악할 수 있고, 사용하지 않는 모듈이 불필요하게 로드되지 않습니다. ### 임포트 경로 배럴 파일이 없으므로 항상 실제 파일 경로로 임포트합니다. ```tsx // page.tsx 또는 layout.tsx에서의 임포트 예시 // 같은 라우트의 코드 → 상대 경로 import { CourseCard } from './_components/CourseCard/CourseCard' import { useCourseFilter } from './_hooks/useCourseFilter' import { formatCourseDate } from './_services/formatCourseDate.service' import { myCourseStore } from './_data/myCourse.store' import type { MyCourse } from './_types/myCourse.type' // 다른 지면과 공유하는 코드 → 절대 경로 (@/ alias) import { UserCard } from '@/app/_shared/user/UserCard/UserCard' import { Gnb } from '@/app/_global/_components/Gnb/Gnb' // 서버 쿼리·API → 항상 _global에서 import import { myCourseQueries } from '@/app/_global/_queries/myCourse.queries' import { fetchMyCourses } from '@/app/_global/_apis/myCourse.api' ``` 같은 라우트 폴더 내부 코드는 상대 경로(`./_components/...`)로, `_shared/`나 `_global/`의 코드는 절대 경로(`@/app/_shared/...`)로 임포트합니다. `@/` alias는 `tsconfig.json`의 `paths` 설정을 통해 사용하며, 절대 경로를 쓰면 파일을 다른 위치로 옮겼을 때 임포트 경로가 깨지지 않습니다. ## 6. 주의사항 및 자동화 ### 주의사항 * **`_shared/`의 경계 판단이 모호할 수 있습니다.** 한 지면에서만 쓰이는 코드를 "나중에 공유될 것 같다"는 이유로 미리 `_shared/`에 넣지 않습니다. 실제로 두 번째 지면에서 import가 필요해지는 시점에 이동합니다. * **배럴 파일 금지는 앱 코드에 적용됩니다.** NPM 패키지로 배포하는 라이브러리에서는 진입점으로서 `index.ts`가 필요할 수 있습니다. * **깊은 중첩 경로의 import가 길어질 수 있습니다.** `@/` alias를 활용하면 `../../../../_shared/...` 같은 상대 경로를 피할 수 있습니다. ### 자동화 이 가이드의 일부 원칙은 린트 룰로 강제할 수 있습니다: * `no-barrel-files` (`eslint-plugin-no-barrel-files`): 배럴 파일(`index.ts`에서 re-export만 하는 파일) 생성을 차단합니다. * `no-barrel-import` (`eslint-plugin-no-barrel-import`): 배럴 파일을 통한 import를 차단합니다. * `no-restricted-imports` (ESLint 내장): 특정 경로 패턴의 import를 금지하는 규칙을 설정할 수 있습니다. --- --- url: 'https://union.spartaclub.kr//contents/community/lounge.md' --- ## 나누고 있는 이야기 --- --- url: >- https://union.spartaclub.kr//contents/ground-rules/package-guide/react-utility.md --- # React 유틸리티: @teamsparta/react > 팀 내부에서 자주 사용하는 React 컴포넌트와 훅을 모아둔 패키지입니다. 매번 레포마다 코드를 재생성할 필요 없이 일관되게 작성할 수 있습니다. ```tsx import { useBoolean, useDebounce, SwitchCase, When, Separated, } from '@teamsparta/react'; , error: , success: , }} />; ; }> {items.map((item) => ( ))} ; const [isOpen, { setTrue: open, setFalse: close, toggle }] = useBoolean(false); const debouncedQuery = useDebounce(query, 300); ``` --- --- url: 'https://union.spartaclub.kr//contents/ground-rules/component-separation.md' --- # 관련 없는 정보를 함께 두지 않기 관련 없는 정보를 함께 두면 코드의 의도가 모호해집니다. 관련 없는 정보는 적절히 분리하여 코드의 의도를 명확하게 합니다. ## 좋지 않은 예시 ```tsx // hook export const useProductSaleSection = (productId: number, userId: number) => { const { data: product } = useQuery(productQuery.detail(productId)); const { data: enrollment } = useQuery(enrollmentQuery.enrollment(userId)); const isOnSale = getIsOnSale(product); const isEnrolled = getIsEnrolled(product, enrollment); const salePrice = getSalePrice(product); return { product, isOnSale, salePrice, isEnrolled }; }; // component interface SaleSectionProps { productId: number; userId: number; } export const SaleSection = ({ productId, userId }: SaleSectionProps) => { const { product, isOnSale, salePrice, isEnrolled } = useProductSaleSection( productId, userId, ); return (

product: {product.name}

isOnSale: {isOnSale}

salePrice: {salePrice}

{isEnrolled &&

이미 수강중인 상품입니다

}
); }; ``` ## 문제 useProductSaleSection은 SaleSection에서 사용할 정보들을 모아서 반환해주고 있습니다. 즉, useProductSaleSection 목적은 UI에 의존적입니다. UI가 빠르게 변하는 프론트엔드 환경에서는 이 함수 또한 자주 변경이 필요해질 것이고 이 함수를 호출하는 모든 곳이 동시에 변경되어야 합니다. 따라서 유지보수가 어렵고 재사용이 어려워집니다. 또한, product와 enrollment 데이터를 동시에 관리하고 있기 때문에 각 데이터에 변경이\ 있을 때 모두 하나의 훅을 수정하게 됩니다. 이는 서로 관계없는 데이터가 한 곳에 모여 있다는\ 뜻으로, enrollment 로직을 수정할 때 product 관련 코드에 의도치 않은 영향을 줄 수 있습니다. ## 좋은 예시 ```tsx // hook const useProductSaleInfo = (productId: number) => { const { data: product } = useQuery(productQuery.detail(productId)); const isOnSale = getIsOnSale(product); const salePrice = getSalePrice(product); return { isOnSale, salePrice }; }; const useIsEnrolled = (userId: number) => { const { data: enrollment } = useQuery(enrollmentQuery.enrollment(userId)); const isEnrolled = getIsEnrolled(product, enrollment); return isEnrolled; }; // component interface SaleSectionProps { productId: number; userId: number; } export const SaleSection = ({ productId, userId }: SaleSectionProps) => { const { data: product } = useQuery(productQuery.detail(productId)); const { isOnSale, salePrice } = useProductSaleInfo(productId); const isEnrolled = useIsEnrolled(userId); return (

product: {product.name}

isOnSale: {isOnSale}

salePrice: {salePrice}

{isEnrolled &&

이미 수강중인 상품입니다

}
); }; ``` 상품의 판매 정보와 관련 있는 데이터와 수강권과 관련 있는 데이터를 분리해서 관리합니다. 이제 product 데이터 구조에 변경이 생겼을 때 어느 곳을 수정해야 할 지 명확해집니다. 그 변화에 enrollment는 더 이상 영향 받지 않습니다. UI가 아닌 데이터의 성격을 기준으로 함수를 구성했기 때문에 함수의 책임과 의도가 더 명확해졌습니다. --- --- url: 'https://union.spartaclub.kr//contents/ground-rules/package-guide/date.md' --- # 날짜 관리: date-fns > 불변성을 보장하고 날짜 포맷팅, 비교, 계산을 순수 함수로 처리합니다. ```ts import { format, addDays, isAfter, isBefore, differenceInDays } from 'date-fns'; format(new Date(), 'yyyy-MM-dd'); format(new Date(), 'yyyy년 M월 d일'); const today = new Date(); const deadline = addDays(today, 7); isAfter(deadline, today); isBefore(today, deadline); differenceInDays(deadline, today); ``` --- --- url: 'https://union.spartaclub.kr//contents/community/detail.md' --- --- --- url: 'https://union.spartaclub.kr//contents/ground-rules/logging-function.md' --- # 로깅 함수 분리하기 로깅 함수는 훅이나 다른 함수에 같은 위계로 추상화하지 않습니다. ## 좋지 않은 예시 ```tsx // hook export const usePurchaseProduct = ({ productId }: { productId: number }) => { const { mutate } = useMutation(productQuery.purchase()); const product = useProductDetail(productId); const purchaseProduct = () => { sendCPLog('scc_button_click', { productId }); mutate(product); }; return purchaseProduct; }; // component export const PurchaseButton = ({ productId }: { productId: number }) => { const purchaseProduct = usePurchaseProduct(productId); const handlePurchaseButtonClick = () => { purchaseProduct(); }; return ; }; ``` ## 문제 #### 로깅과 비즈니스 로직의 혼재 `usePurchaseProduct`는 상품을 구매하는 비즈니스 로직을 담당하는 훅으로, 로깅과는 다른 책임을 가집니다. 즉, `usePurchaseProduct` 비즈니스적 맥락을 담고 있고 로깅은 사용자의 인터랙션에 따른 로그 수집에 대한 책임을 가집니다. 이 구조에서는 다음과 같은 문제가 생깁니다. * **필요한 데이터가 다름**: 구매 로직은 `product` 전체가 필요하지만, 로깅은 `productId`만 있으면 됩니다. 관심사가 다르면 필요한 데이터도 다르고, 변경 이유도 달라집니다. 로깅 스펙이 바뀔 때마다 비즈니스 로직 훅을 수정해야 하는 상황이 생깁니다 * **공통 로직 사용의 오류 가능성**: 만약 다른 지면에서 구매 로직이 필요하다면, 그때도 동일한 로깅이 필요할지 알 수 없습니다. 다른 로그가 필요할 경우 해당 훅을 수정해야 합니다. 이는 훅의 재사용성을 떨어뜨립니다. ## 좋은 예시 ```tsx // hook export const usePurchaseProduct = ({ productId }: { productId: number }) => { const { mutate } = useMutation(productQuery.purchase()); const purchaseProduct = () => { mutate(product); }; return purchaseProduct; }; // component export const PurchaseButton = ({ productId }: { productId: number }) => { const purchaseProduct = usePurchaseProduct(productId); const handlePurchaseButtonClick = () => { purchaseProduct(productId); sendCPLog('scc_button_click', { productId }); }; return ; }; ``` 훅은 비즈니스 로직만 담당하고, 로깅은 컴포넌트의 이벤트 핸들러에서 호출합니다. --- --- url: >- https://union.spartaclub.kr//contents/ground-rules/image-guide/responsive-image.md --- # 반응형 이미지를 CSS-first로 처리하기 모바일과 PC에서 다른 이미지를 보여줄 때, JavaScript 대신 브라우저 네이티브 방식을 사용하면 레이아웃 깜빡임 없이 올바른 이미지를 즉시 렌더링할 수 있습니다. ## 1. 모바일/PC 이미지 분기에 JS를 사용하지 않습니다. ### 좋지 않은 예시 ```tsx // useIsMobile 결과에 따라 이미지 src를 분기 import Image from 'next/image'; import { useIsMobile } from '@/hooks/device'; export default function Hero() { const isMobile = useIsMobile(); return (
{isMobile !== null && ( hero )}
); } ``` ### 문제 `useIsMobile()`은 클라이언트 사이드에서만 동작하므로 SSR 시점에는 `null`을 반환합니다. 그 결과 서버에서는 이미지가 렌더링되지 않고, hydration이 완료된 뒤에야 이미지가 나타나면서 CLS(Cumulative Layout Shift)가 발생합니다. CLS 점수 0.1 이하가 "Good" 기준인데, JS 기반 이미지 분기는 이 기준을 넘기기 어렵습니다. 또한 JS 번들이 로드되고 실행되어야 비로소 이미지 요청이 시작되기 때문에, 브라우저가 HTML 파싱 단계에서 미리 이미지를 가져올 수 있는 기회를 잃습니다. HTML 기반 프리로드와 비교하면 이미지 표시가 1초 이상 늦어질 수 있습니다. `unoptimized`와 `quality={100}`을 함께 사용하면 Next.js의 이미지 최적화 파이프라인도 완전히 우회되어, 원본 용량 그대로 사용자에게 전달됩니다. ### 좋은 예시 ```tsx // + 로 브라우저가 직접 분기 import { getImageProps } from 'next/image'; export default function Hero() { const { props: pcProps } = getImageProps({ src: `${CDN_PATH}/hero_pc.png`, width: 960, height: 400, alt: 'hero', }); const { props: moProps } = getImageProps({ src: `${CDN_PATH}/hero_mo.png`, width: 328, height: 200, alt: 'hero', }); return (
hero
); } ``` ``와 ``는 CSS 미디어쿼리와 동일한 방식으로 동작하므로, JS 실행 없이 HTML 파싱 시점에 브라우저가 올바른 이미지를 선택합니다. 조건에 맞지 않는 이미지는 아예 요청하지 않기 때문에 대역폭 낭비도 없습니다. SSR에서도 마크업이 그대로 내려가기 때문에 CLS가 발생하지 않으며, `getImageProps`를 통해 Next.js의 이미지 최적화(리사이즈, 포맷 변환, srcSet 생성)도 그대로 유지됩니다. Next.js 공식 문서에서도 art direction(모바일/PC 이미지 분기) 시 이 패턴을 권장합니다. ## 2. 미최적화 이미지는 getImageProps로 최적화합니다. ### 좋지 않은 예시 ```tsx export default function Banner() { return ( <> {/* 이미 CDN에서 최적화된 WebP 이미지 */} banner {/* 최적화되지 않은 PNG 이미지도 동일하게 네이티브 picture만 사용 */} feature ); } ``` ### 문제 이미 WebP로 최적화된 이미지와 미최적화 PNG 원본을 동일한 방식으로 다루면, PNG 이미지는 원본 용량 그대로 사용자에게 전달됩니다. 예를 들어, PNG 원본이 488kB인 이미지를 `getImageProps`로 처리하면 약 83kB까지 줄일 수 있지만, 네이티브 ``만 사용하면 이 최적화 기회를 놓치게 됩니다. 반대로 이미 WebP로 최적화된 이미지에 `getImageProps`를 적용하면 용량 절감 없이 Next.js 이미지 서버를 거치는 지연만 추가됩니다. ### 좋은 예시 ```tsx import { getImageProps } from 'next/image'; export default function Banner() { // 미최적화 PNG — getImageProps로 최적화 파이프라인 활용 const { props: featurePcProps } = getImageProps({ src: `${CDN_PATH}/feature_pc.png`, width: 581, height: 509, alt: 'feature', }); const { props: featureMoProps } = getImageProps({ src: `${CDN_PATH}/feature_mo.png`, width: 321, height: 455, alt: 'feature', }); return ( <> {/* 이미 최적화된 WebP — 네이티브 picture로 직접 서빙 */} banner {/* 미최적화 PNG — getImageProps로 최적화 */} feature ); } ``` 이미 최적화된 이미지(WebP 등)는 CDN에서 직접 서빙하고, 미최적화 이미지(PNG 원본 등)에만 `getImageProps`를 적용합니다. 이렇게 구분하면 불필요한 이미지 서버 경유 없이, 최적화가 필요한 이미지에서만 용량을 대폭 절감할 수 있습니다. ## 주의사항 * **해상도만 다른 경우에는 ``가 과도합니다.** 동일 이미지의 해상도만 다른 경우(resolution switching)에는 ``가 더 적합합니다. ``는 모바일/PC에서 완전히 다른 이미지를 보여주는 art direction 용도로 사용합니다. * **`` 내부 ``에는 `next/image`의 blur placeholder, lazy loading 제어 등 편의 기능이 자동 적용되지 않습니다.** 필요하다면 `loading`, `decoding` 등의 속성을 직접 지정해야 합니다. * **이미지 버전 관리 복잡성이 증가합니다.** 모바일/PC 각각의 이미지를 별도로 준비하고 관리해야 하므로, 디자인 변경 시 양쪽 모두 업데이트해야 합니다. ## 자동화 * **`@next/next/no-img-element`**: Next.js ESLint 규칙이 `` 직접 사용을 경고하지만, `` 내부의 ``는 의도적으로 예외 처리되어 경고가 발생하지 않습니다. * **Lighthouse / PageSpeed Insights**: CLS 점수를 측정하여 JS 기반 이미지 분기의 레이아웃 시프트 문제를 정량적으로 확인할 수 있습니다. CLS 0.1 이하를 목표로 합니다. --- --- url: 'https://union.spartaclub.kr//contents/ground-rules/side-effects.md' --- # 사이드 이펙트 추출하기 테스트 문서입니다. --- --- url: >- https://union.spartaclub.kr//contents/ground-rules/package-guide/server-state.md --- # 서버 데이터 관리: TanStack Query > 서버 상태를 선언적으로 관리합니다. 캐싱, 재요청, 로딩/에러 상태를 자동으로 처리합니다. 다음은 팀스파르타 FE 챕터에서 Tanstack Query를 사용하는 주된 패턴들입니다. ### queryOptions로 쿼리 정의하기 `queryOptions`로 쿼리 설정을 정의합니다. 이를 통해 쿼리의 재사용성을 높입니다. ```ts import { queryOptions } from '@tanstack/react-query'; const productQueryKeys = { all: ['products'], detail: (id: number) => [...productQueryKeys.all, id], }; const productQuery = (id: number) => queryOptions({ queryKey: productQueryKeys.detail(id), queryFn: () => fetchProduct(id), }); // 사용처 useQuery(productQuery(1)); useSuspenseQuery(productQuery(1)); queryClient.prefetchQuery(productQuery(1)); ``` ### useSuspenseQuery + Suspense `useSuspenseQuery`와 `Suspense`를 함께 사용하여 data가 항상 존재하는 것이 보장된 상태에서 컴포넌트 로직을 작성합니다. ```tsx import { Suspense } from 'react'; import { useSuspenseQuery } from '@tanstack/react-query'; export function Page() { return ( 로딩 중...}> ); } function Content({ id }: { id: number }) { const { data } = useSuspenseQuery(productQuery(id)); return

{data.name}

; } ``` --- --- url: >- https://union.spartaclub.kr//contents/ground-rules/package-guide/error-boundary.md --- # 에러 바운더리: react-error-boundary > 컴포넌트 트리에서 발생하는 렌더링 에러를 잡아 fallback을 표시합니다. Suspense와 함께 사용하여 선언적으로 로딩/에러 상태를 처리합니다. ```tsx import { Suspense } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; function Page() { return ( (

에러: {error.message}

)} > 로딩 중...}>
); } ``` --- --- url: 'https://union.spartaclub.kr//contents/ground-rules/predictable-interface.md' --- # 예측 가능한 인터페이스 설계 테스트 문서입니다. --- --- url: 'https://union.spartaclub.kr//contents/ground-rules/package-guide/utility.md' --- # 유틸리티 함수: es-toolkit > lodash를 대체하는 모던 유틸리티 라이브러리입니다. TypeScript 기반으로 작성되어 타입 안전성이 높고, 번들 크기가 작습니다. ```ts import { debounce, groupBy, chunk, uniqBy } from 'es-toolkit'; const handleSearch = debounce((query: string) => { search(query); }, 300); chunk([1, 2, 3, 4, 5], 2); // [[1, 2], [3, 4], [5]] ``` --- --- url: 'https://union.spartaclub.kr//contents/ground-rules/naming.md' --- # 이름으로 의도 전달하기 이름만 보고 동작을 예측할 수 있어야 합니다. 주체, 방향, 동사가 일관되면 코드를 읽는 비용이 줄어듭니다. ## 1. 이름은 동작의 주체와 맥락을 드러나게 짓습니다. ### 좋지 않은 예시 ```tsx // 타이머에 의해 자동으로 접힌다는 의도였지만, // "타이머가 접힌다"로 읽힙니다. const [isTimerCollapsed, setIsTimerCollapsed] = useState(false); ``` ### 문제 `isTimerCollapsed`는 "타이머가 접힌 상태"로 읽힙니다. 실제 의도는 "타이머에 의해 자동으로 접힌 상태"인데, 이름의 주체가 달라지면 동작 예측이 완전히 달라집니다. ### 좋은 예시 ```tsx // "자동으로 접힌 상태" — 주체가 명확합니다. const [isAutoCollapsed, setIsAutoCollapsed] = useState(false); ``` ## 2. boolean은 같은 방향으로 맞추어야 합니다. ### 좋지 않은 예시 ```tsx const [isAutoCollapsed, setIsAutoCollapsed] = useState(false); const [isGuideExpanded, setIsGuideExpanded] = useState(false); // collapsed는 true = 접힘, expanded는 true = 펼쳐짐 const isCollapsed = isAutoCollapsed && !isGuideExpanded; ``` ### 문제 `isAutoCollapsed`(true=접힘)와 `isGuideExpanded`(true=펼쳐짐)가 섞이면, 조합할 때마다 방향을 뒤집어야 한다는 것을 한 번 더 생각하게 합니다. ### 좋은 예시 ```tsx const [isAutoCollapsed, setIsAutoCollapsed] = useState(false); const [isManuallyCollapsed, setIsManuallyCollapsed] = useState(true); // 둘 다 collapsed 기준 — 바로 읽힌다 const isCollapsed = isAutoCollapsed || isManuallyCollapsed; ``` 같은 기준으로 맞추면 조건문이 자연어로 읽힙니다. ## 3. 부정형 이름을 피합니다. ### 좋지 않은 예시 ```tsx const [isNotVisible, setIsNotVisible] = useState(true); // 이중 부정 — "보이지 않는 게 아닐 때"? if (!isNotVisible) { return ; } const isDisabled = !hasPermission && !isAdmin; // "비활성화가 아닐 때" = "활성화될 때" if (!isDisabled) { handleSubmit(); } ``` ### 문제 부정형 이름은 조건문에서 이중 부정을 만듭니다. `!isNotVisible`은 "보이지 않는 게 아닐 때"로 읽히며, 의도를 파악하려면 두 번 생각해야 합니다. ### 좋은 예시 ```tsx const [isVisible, setIsVisible] = useState(false); if (isVisible) { return ; } const isEnabled = hasPermission || isAdmin; if (isEnabled) { handleSubmit(); } ``` 긍정형으로 쓰면 조건문이 자연어로 읽힙니다. `!isNotVisible`보다 `isVisible`이 의도가 명확합니다. ## 4. 같은 역할의 함수에는 같은 동사를 씁니다. ### 좋지 않은 예시 ```tsx // 전부 API에서 데이터를 가져오는 함수인데, 동사가 제각각 const fetchUserList = async () => { ... }; const getCourseDetail = async () => { ... }; const loadPaymentHistory = async () => { ... }; const retrieveRefundInfo = async () => { ... }; ``` ### 문제 동사가 섞이면 "각각 다른 동작인가?"라는 의문이 생깁니다. `get`은 동기 계산? `fetch`는 네트워크? `load`는 캐시? 같은 역할인데 다른 동사를 쓰면 읽는 사람이 불필요한 구분을 시도하게 됩니다. ### 좋은 예시 ```tsx const getUserList = async () => { ... }; const getCourseDetail = async () => { ... }; const getPaymentHistory = async () => { ... }; const getRefundInfo = async () => { ... }; ``` 동사를 통일하면 "같은 종류의 함수"라는 것이 이름만으로 전달됩니다. --- --- url: 'https://union.spartaclub.kr//contents/ground-rules/package-guide/form.md' --- # 폼 관리: react-hook-form + zod > 비제어 컴포넌트 기반으로 불필요한 리렌더링을 최소화하고, zod로 유효성을 검증합니다. ```tsx import { z } from 'zod'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; const productSchema = z.object({ name: z.string().min(1, '이름을 입력해주세요'), price: z.number().min(0, '0 이상이어야 합니다'), }); type ProductSchema = z.infer; function ProductForm() { const { register, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(productSchema), }); return (
console.log(data))}> {errors.name && {errors.name.message}} {errors.price && {errors.price.message}}
); } ``` --- --- url: 'https://union.spartaclub.kr//contents/ground-rules.md' --- # 프론트엔드 코드 가이드 지속 가능한 개발을 위해 지켜야 할 원칙, 코드의 최종 사용자인 고객의 편의를 위해 지켜야 할 원칙들을 정의했습니다. ## Q. 언제 활용해야 하나요? 이 가이드는 팀스파르타의 모든 프론트엔드 코드에 적용됩니다. 코드를 작성할 때, 코드를 리뷰할 때 모두 활용해주세요.