Previewlocked to source
NAR.GG - 롤 프로 경기 챔피언 조합 분석
LCK, LPL, LEC 프로 경기의 챔피언 조합, 1v1 매치업, 승률 통계를 분석하는 웹 서비스

1. 프로젝트 소개
배경 및 해결하고자 하는 문제
- 롤 프로 경기 데이터가 분산되어 있어 메타 분석이 어려움
- 챔피언 조합별 승률, 포지션별 매치업 정보를 한눈에 보기 힘듦
- 기존 CRA 기반 프로젝트의 SEO 한계 및 타입 안전성 부재
주요 기능
- 챔피언 조합 분석 (5인 조합 승률 통계)
- 1v1 매치업 분석 (포지션별 상대 전적)
- 프로 경기 일정 및 결과 조회
- 경기 상세 기록 (타임라인, 골드 차이, 오브젝티브)
- 유튜브 스토리 커뮤니티
2. 기술 스택
| 분류 | 기술 | 버전 |
|---|---|---|
| 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 | - |
3. 아키텍처
폴더 구조 (Feature-Sliced Design)
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/은 모든 레이어 참조 가능
4. 문제 해결 경험
4-1. 챔피언 이미지 매칭 로직 중복 문제
문제 상황
- 6개 페이지(champions-meta, game-record, pro-matches 등)에서 챔피언 이름으로 이미지 URL을 가져오는 로직이 각각 구현되어 있었음
- 동일한
useQuery+Map생성 로직이 131줄 이상 중복됨
원인 분석
- 초기 개발 시 페이지별로 독립적으로 구현하다 보니 공통 로직 추출을 놓침
- API 응답의 championNameEn과 실제 사용하는 키 값 간의 대소문자 불일치 처리가 각 페이지마다 다르게 구현됨
해결 방법
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 };
}
결과
- 6개 파일에서 중복 코드 131줄 → 28줄로 감소 (79% 감소)
- 대소문자 처리 로직 일원화로 이미지 매칭 오류 0건
4-2. 챔피언 포지션 정렬 불일치 문제
문제 상황
- 경기 기록, 팀 디스플레이 등에서 챔피언 포지션이 API 응답 순서 그대로 표시됨
- TOP-JG-MID-ADC-SUP 표준 순서가 아니라 사용자 가독성 저하
원인 분석
- 백엔드 API가 포지션 정렬 없이 데이터를 반환
- 프론트엔드에서 정렬 로직을 각 컴포넌트에서 개별 구현하거나 누락
해결 방법
sortByPosition제네릭 유틸 함수 생성- TypeScript 제네릭으로 타입 안전성 확보
// 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;
});
};
결과
- 5개 컴포넌트에 일관된 정렬 적용
- 제네릭 타입으로
GameDetailPlayer,TeamPlayer등 다양한 타입에 재사용
4-3. CRA SPA의 SEO 한계
문제 상황
- 기존 CRA(Create React App) 기반 SPA로 검색엔진 크롤링 불가
- Google Search Console에서 페이지 인덱싱 실패
원인 분석
- SPA는 JavaScript 실행 후 콘텐츠가 렌더링되어 크롤러가 빈 페이지로 인식
- 정적 sitemap.xml, robots.txt 부재
해결 방법
- Next.js 16 App Router로 마이그레이션하여 SSR 적용
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 },
];
}
결과
- 3개 주요 페이지 검색엔진 인덱싱 완료
- Google Tag Manager + GA4 연동으로 트래픽 분석 가능
4-4. React Query 패턴 불일치 문제
문제 상황
- 기존 프로젝트에서 10개 커스텀 훅이 각각 다른 방식으로 React Query 사용
- queryKey 네이밍 규칙 없음, 캐싱 설정 불일치
원인 분석
- 개발자마다 다른 패턴으로 구현
- 공식 권장 패턴(queryOptions factory) 미적용
해결 방법
queryOptions()팩토리 패턴으로 통일- 엔티티별
queries.ts파일에서 쿼리 옵션 정의 - Query Key Factory 패턴 적용
// 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,
}),
};
결과
- 6개 엔티티에 동일한 쿼리 패턴 적용
- queryKey 계층 구조로 관련 쿼리 일괄 무효화 가능
- TypeScript 타입 추론 100% 지원
projectsNAR.GG - LOL 프로 경기 분석 서비스.md
010203040506070809101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
# NAR.GG - 롤 프로 경기 챔피언 조합 분석
> LCK, LPL, LEC 프로 경기의 챔피언 조합, 1v1 매치업, 승률 통계를 분석하는 웹 서비스

## 1. 프로젝트 소개
### 배경 및 해결하고자 하는 문제
- 롤 프로 경기 데이터가 분산되어 있어 메타 분석이 어려움
- 챔피언 조합별 승률, 포지션별 매치업 정보를 한눈에 보기 힘듦
- 기존 CRA 기반 프로젝트의 SEO 한계 및 타입 안전성 부재
### 주요 기능
- 챔피언 조합 분석 (5인 조합 승률 통계)
- 1v1 매치업 분석 (포지션별 상대 전적)
- 프로 경기 일정 및 결과 조회
- 경기 상세 기록 (타임라인, 골드 차이, 오브젝티브)
- 유튜브 스토리 커뮤니티
## 2. 기술 스택
| 분류 | 기술 | 버전 |
|------|------|------|
| **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 | - |
## 3. 아키텍처
### 폴더 구조 (Feature-Sliced Design)
```
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/`은 모든 레이어 참조 가능
## 4. 문제 해결 경험
### 4-1. 챔피언 이미지 매칭 로직 중복 문제
**문제 상황**
- 6개 페이지(champions-meta, game-record, pro-matches 등)에서 챔피언 이름으로 이미지 URL을 가져오는 로직이 각각 구현되어 있었음
- 동일한 `useQuery` + `Map` 생성 로직이 131줄 이상 중복됨
**원인 분석**
- 초기 개발 시 페이지별로 독립적으로 구현하다 보니 공통 로직 추출을 놓침
- API 응답의 championNameEn과 실제 사용하는 키 값 간의 대소문자 불일치 처리가 각 페이지마다 다르게 구현됨
**해결 방법**
- `useChampionImage` 커스텀 훅 생성하여 공용화
- `useMemo`로 Map 캐싱, `useCallback`으로 함수 메모이제이션
```typescript
// 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 };
}
```
**결과**
- 6개 파일에서 중복 코드 131줄 → 28줄로 감소 (79% 감소)
- 대소문자 처리 로직 일원화로 이미지 매칭 오류 0건
### 4-2. 챔피언 포지션 정렬 불일치 문제
**문제 상황**
- 경기 기록, 팀 디스플레이 등에서 챔피언 포지션이 API 응답 순서 그대로 표시됨
- TOP-JG-MID-ADC-SUP 표준 순서가 아니라 사용자 가독성 저하
**원인 분석**
- 백엔드 API가 포지션 정렬 없이 데이터를 반환
- 프론트엔드에서 정렬 로직을 각 컴포넌트에서 개별 구현하거나 누락
**해결 방법**
- `sortByPosition` 제네릭 유틸 함수 생성
- TypeScript 제네릭으로 타입 안전성 확보
```typescript
// 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;
});
};
```
**결과**
- 5개 컴포넌트에 일관된 정렬 적용
- 제네릭 타입으로 `GameDetailPlayer`, `TeamPlayer` 등 다양한 타입에 재사용
### 4-3. CRA SPA의 SEO 한계
**문제 상황**
- 기존 CRA(Create React App) 기반 SPA로 검색엔진 크롤링 불가
- Google Search Console에서 페이지 인덱싱 실패
**원인 분석**
- SPA는 JavaScript 실행 후 콘텐츠가 렌더링되어 크롤러가 빈 페이지로 인식
- 정적 sitemap.xml, robots.txt 부재
**해결 방법**
- Next.js 16 App Router로 마이그레이션하여 SSR 적용
- `sitemap.ts`, `robots.ts` 동적 생성
- `metadata` 객체로 OpenGraph, Twitter Card 설정
```typescript
// 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 },
];
}
```
**결과**
- 3개 주요 페이지 검색엔진 인덱싱 완료
- Google Tag Manager + GA4 연동으로 트래픽 분석 가능
### 4-4. React Query 패턴 불일치 문제
**문제 상황**
- 기존 프로젝트에서 10개 커스텀 훅이 각각 다른 방식으로 React Query 사용
- queryKey 네이밍 규칙 없음, 캐싱 설정 불일치
**원인 분석**
- 개발자마다 다른 패턴으로 구현
- 공식 권장 패턴(queryOptions factory) 미적용
**해결 방법**
- `queryOptions()` 팩토리 패턴으로 통일
- 엔티티별 `queries.ts` 파일에서 쿼리 옵션 정의
- Query Key Factory 패턴 적용
```typescript
// 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,
}),
};
```
**결과**
- 6개 엔티티에 동일한 쿼리 패턴 적용
- queryKey 계층 구조로 관련 쿼리 일괄 무효화 가능
- TypeScript 타입 추론 100% 지원
○ hov_i [main] ⚡