Files
frontend/docs/as/03.curriculum-intermediate.md
T

193 lines
7.0 KiB
Markdown

# AS 프로젝트 교육 자료: 중급 과정 (Intermediate Course)
**대상**: FSD 아키텍처와 마크업 설계에 대한 기본 이해를 갖춘 개발자
**목표**: Next.js App Router 연동 방식을 이해하고, TanStack Query로 API 통신을 수행하며, Zustand와 Context를 결합하여 SSR 환경에 최적화된 상태 관리를 다룰 수 있다.
---
## 1. Next.js App Router와 FSD 라우팅 패턴
AS 프로젝트는 Next.js의 `app/` 라우터를 페이지 진입점으로 최소화하고, 모든 비즈니스 로직과 복잡한 뷰(View) 컴포넌트는 `src/pages` 레이어에 담아 관리합니다.
### 라우팅 흐름 예시
* **Next.js 경로 정의**: `app/(with-base-layout)/fnrs-cnsltn/page.tsx`
* **구현 파일**:
```typescript
// app/(with-base-layout)/fnrs-cnsltn/page.tsx
export { FnrsListPage as default } from "@/pages/fnrs-cnsltn";
```
* **실제 뷰 컴포넌트**: `src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx`
* Next.js `app` 폴더 내부에 로직을 구현하지 않고, 단순하게 `src/pages` 슬라이스를 re-export함으로써 라우팅 구조와 비즈니스 모듈의 종속성을 완벽히 분리합니다.
---
## 2. SSR 환경에 안전한 Zustand 상태 관리 패턴
Next.js와 같은 SSR 프레임워크에서 Zustand 스토어를 단순 싱글톤 객체(`export const useStore = create(...)`)로 정의하면, 서버 측 메모리 상에서 **여러 사용자 간의 전역 상태 오염(Store State Leak)**이 발생합니다.
이를 극부하기 위해 AS 프로젝트는 **Vanilla Store + React Context + useRef** 방식을 채택하고 있습니다.
### ① 스토어 정의 (`src/shared/store/user-store.ts`)
```typescript
import { createStore } from "zustand/vanilla";
export interface IUserInfo {
userId: string;
userName: string;
email: string;
}
export type UserState = {
userInfo: IUserInfo | null;
};
export type UserActions = {
setUserInfo: (userInfo: IUserInfo | null) => void;
};
export type UserStore = UserState & UserActions;
export const defaultInitState: UserState = {
userInfo: null,
};
export const createUserStore = (initState: UserState = defaultInitState) => {
return createStore<UserStore>()((set) => ({
...initState,
setUserInfo: (userInfo) => set(() => ({ userInfo })),
}));
};
```
### ② Context 및 Provider 정의 (`src/shared/providers/store-provider.tsx`)
```typescript
"use client";
import { createContext, useRef } from "react";
import { createUserStore } from "../store/user-store";
type StoreContextType = {
userStore: ReturnType<typeof createUserStore>;
};
export const StoreContext = createContext<StoreContextType | undefined>(undefined);
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
// useRef를 사용하여 렌더링마다 스토어가 재성성되는 것을 방지하고 독립적인 인스턴스를 유지
const storeRef = useRef<StoreContextType | null>(null);
if (storeRef.current === null) {
storeRef.current = {
userStore: createUserStore(),
};
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
);
};
```
### ③ Custom Hook을 통한 사용 (`src/shared/lib/use-user-store.ts`)
```typescript
import { useContext } from "react";
import { useStore } from "zustand";
import { StoreContext } from "../providers/store-provider";
import { UserStore } from "../store/user-store";
export function useUserStore<T>(selector: (store: UserStore) => T): T {
const storeContext = useContext(StoreContext);
if (!storeContext) {
throw new Error("useUserStore는 반드시 StoreProvider 내부에서 사용되어야 합니다.");
}
return useStore(storeContext.userStore, selector);
}
```
---
## 3. TanStack Query v5를 활용한 서버 상태(Server State) 관리
AS 프로젝트는 비즈니스 도메인의 API와 데이터를 처리하기 위해 React Query(TanStack Query)를 적극 활용합니다.
### ① API 호출 정의 (`entities/fnrs-cnsltn/api/fetcher.ts`)
```typescript
import { client } from "@/shared/api";
import { FnrsDetailDto } from "../model/types";
// client는 Axios 기반으로 공통 Interceptor가 설정된 인스턴스입니다.
export const fetchFnrsDetail = async (docNo: string): Promise<FnrsDetailDto> => {
const response = await client.get(`/as-fnrs-service/api/v1/fnrs-cnsltn/${docNo}`);
return response.data;
};
```
### ② Custom Query Hook 구현 (`entities/fnrs-cnsltn/api/use-fnrs-detail.ts`)
```typescript
import { useQuery } from "@tanstack/react-query";
import { fetchFnrsDetail } from "./fetcher";
export const fnrsKeys = {
all: ["fnrs"] as const,
detail: (docNo: string) => [...fnrsKeys.all, "detail", docNo] as const,
};
export function useFnrsDetail(docNo: string) {
return useQuery({
queryKey: fnrsKeys.detail(docNo),
queryFn: () => fetchFnrsDetail(docNo),
enabled: !!docNo,
staleTime: 5 * 60 * 1000, // 5분 캐싱 유지
});
}
```
### ③ 컴포넌트에서의 데이터 연동 (`pages/fnrs-cnsltn/ui/detail-page.tsx`)
```typescript
"use client";
import { useFnrsDetail } from "@/entities/fnrs-cnsltn";
import { Skeleton } from "@/shared/ui/skeleton";
interface DetailPageProps {
params: { id: string };
}
export function FnrsDetailPage({ params }: DetailPageProps) {
const { data: detailData, isLoading, isError } = useFnrsDetail(params.id);
if (isLoading) return <Skeleton className="h-64 w-full" />;
if (isError || !detailData) return <div>상세 데이터를 가져오는 중 에러가 발생했습니다.</div>;
return (
<div className="p-6 bg-white shadow rounded-lg">
<h2 className="text-xl font-bold mb-4">{detailData.bizNm}</h2>
<p className="text-gray-600">예산 연도: {detailData.bgtAplyYr}년</p>
<p className="mt-2">{detailData.bizCn}</p>
</div>
);
}
```
---
## 4. Monorepo 공통 모듈 (`@common/*`) 사용법
AS 프로젝트는 모노레포 내부의 다른 앱들과 로직을 공유하기 위해 다음과 같은 패키지를 사용합니다:
* **`@common/network`**:
* 공통 Axios 클라이언트 래퍼(`Client.getInstance()`)를 가져와 공통 헤더 및 환경 변수를 설정하여 프로젝트 API 클라이언트(`client.ts`)를 초기화합니다.
* **`@common/auth`**:
* 로그인 토큰 검증, 세션 연장 기능 등 전역 보안 절차를 제어합니다.
* **`@common/components`**:
* 워크스페이스 공통 UI 컴포넌트(글로벌 레이아웃 프레임, 특수 템플릿 등)를 가져와 사용합니다.
---
## 5. 중급 실습 과제
### 과제 내용
* `entities/fnrs-cnsltn` 아래에 특정 재원협의 목록을 가져오는 `useFnrsList` Query Hook을 작성하세요.
* 가져온 목록 데이터를 바탕으로 `shared/ui/table` 컴포넌트(혹은 TanStack Table)를 활용해 목록 그리드를 출력하는 `pages/fnrs-cnsltn/ui/fnrs-list-page.tsx` 페이지를 만드세요.
* **제약 조건**: 검색 필터링(사업명 검색, 예산 신청 연도)을 React Query의 `queryKey`와 동기화하여 검색 조건 변경 시 자동으로 목록이 갱신되도록 하세요.