home.jsx
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
FE 1 / BE 1 / Designer 1
LCK, LPL, LEC 프로 경기의 챔피언 조합, 1v1 매치업, 승률 통계를 분석하는 웹 서비스

| 분류 | 기술 | 버전 |
|---|---|---|
| Framework | Next.js (App Router) | 16.1.0 |
| Language | TypeScript | 5.9.3 |
| UI Library | React | 19.2.3 |
| Component | Mantine | 8.3.10 |
| Styling | Tailwind CSS | 4.1.18 |
| State/Data | TanStack React Query | 5.90.12 |
| HTTP Client | Axios | 1.13.2 |
| Chart | Recharts | 3.6.0 |
| Date | Dayjs | 1.11.19 |
| Package Manager | pnpm | - |
nar-front-project/
├── app/ # Next.js App Router (라우팅 레이어)
│ ├── layout.tsx # 루트 레이아웃 + SSR 프리페칭
│ ├── providers.tsx # Client Provider 래퍼
│ ├── sitemap.ts # 동적 사이트맵 생성
│ ├── robots.ts # robots.txt 생성
│ ├── champions-meta/
│ ├── pro-matches/
│ │ ├── schedule/
│ │ ├── list/
│ │ └── [gameId]/record/
│ └── youtube-stories/
│
├── src/
│ ├── entities/ # 비즈니스 엔티티 (도메인 모델)
│ │ ├── champions/
│ │ │ ├── api/
│ │ │ │ ├── champions-endpoint.ts
│ │ │ │ └── champions.api.ts
│ │ │ └── model/
│ │ │ ├── champions.dto.ts
│ │ │ └── champions.queries.ts
│ │ ├── combinations/
│ │ ├── games/
│ │ ├── schedule/
│ │ ├── categories/
│ │ └── story/
│ │
│ ├── pages/ # 페이지 컴포넌트 (UI 레이어)
│ │ ├── champions-meta/ui/
│ │ ├── game-record/ui/
│ │ ├── pro-matches/
│ │ │ ├── schedule/ui/
│ │ │ └── list/ui/
│ │ └── youtube-stories/ui/
│ │
│ └── shared/ # 공유 유틸리티
│ ├── config/ # 설정 (env, query-client)
│ ├── lib/ # 유틸 함수
│ │ ├── api-client.ts
│ │ ├── use-champion-image.ts
│ │ └── sort-by-position.ts
│ ├── types/
│ └── ui/ # 공용 UI 컴포넌트
app/ → pages/ → entities/ → shared/
↓ ↓ ↓
(UI 조합) (데이터) (유틸)
shared/는 다른 레이어에 의존하지 않음entities/는 shared/만 참조 가능pages/는 entities/와 shared/ 참조 가능app/은 모든 레이어 참조 가능문제 상황
useQuery + Map 생성 로직이 131줄 이상 중복됨원인 분석
해결 방법
useChampionImage 커스텀 훅 생성하여 공용화useMemo로 Map 캐싱, useCallback으로 함수 메모이제이션// src/shared/lib/use-champion-image.ts
export function useChampionImage() {
const { data: champions = [] } = useQuery(championsQueries.list());
const championImageMap = useMemo(() => {
return new Map(
champions.map((c) => [c.championNameEn.toLowerCase(), c.imageUrl])
);
}, [champions]);
const getChampionImageUrl = useCallback(
(championName: string): string => {
return championImageMap.get(championName.toLowerCase()) ||
`https://ddragon.leagueoflegends.com/cdn/15.13.1/img/champion/${championName}.png`;
},
[championImageMap]
);
return { getChampionImageUrl, championImageMap };
}
결과
문제 상황
원인 분석
해결 방법
sortByPosition 제네릭 유틸 함수 생성// src/shared/lib/sort-by-position.ts
const POSITION_ORDER = ["top", "jng", "mid", "bot", "sup"];
export const sortByPosition = <T extends { position?: string }>(
players: T[]
): T[] => {
return [...players].sort((a, b) => {
const aIndex = POSITION_ORDER.indexOf(a.position ?? "");
const bIndex = POSITION_ORDER.indexOf(b.position ?? "");
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
};
결과
GameDetailPlayer, TeamPlayer 등 다양한 타입에 재사용문제 상황
원인 분석
해결 방법
sitemap.ts, robots.ts 동적 생성metadata 객체로 OpenGraph, Twitter Card 설정// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: "https://nar.kr", changeFrequency: "daily", priority: 1 },
{ url: "https://nar.kr/champions-meta", changeFrequency: "daily", priority: 0.9 },
{ url: "https://nar.kr/youtube-stories", changeFrequency: "weekly", priority: 0.7 },
];
}
결과
문제 상황
원인 분석
해결 방법
queryOptions() 팩토리 패턴으로 통일queries.ts 파일에서 쿼리 옵션 정의// src/entities/champions/model/champions.queries.ts
export const championsQueries = {
all: () => ["champions"] as const,
lists: () => [...championsQueries.all(), "list"] as const,
list: () =>
queryOptions({
queryKey: championsQueries.lists(),
queryFn: getChampionList,
}),
};
결과