Previewlocked to source

NAR.GG - 롤 프로 경기 챔피언 조합 분석

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

SCR-20260108-uaar.png

1. 프로젝트 소개

배경 및 해결하고자 하는 문제

  • 롤 프로 경기 데이터가 분산되어 있어 메타 분석이 어려움
  • 챔피언 조합별 승률, 포지션별 매치업 정보를 한눈에 보기 힘듦
  • 기존 CRA 기반 프로젝트의 SEO 한계 및 타입 안전성 부재

주요 기능

  • 챔피언 조합 분석 (5인 조합 승률 통계)
  • 1v1 매치업 분석 (포지션별 상대 전적)
  • 프로 경기 일정 및 결과 조회
  • 경기 상세 기록 (타임라인, 골드 차이, 오브젝티브)
  • 유튜브 스토리 커뮤니티

2. 기술 스택

분류기술버전
FrameworkNext.js (App Router)16.1.0
LanguageTypeScript5.9.3
UI LibraryReact19.2.3
ComponentMantine8.3.10
StylingTailwind CSS4.1.18
State/DataTanStack React Query5.90.12
HTTP ClientAxios1.13.2
ChartRecharts3.6.0
DateDayjs1.11.19
Package Managerpnpm-

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 매치업, 승률 통계를 분석하는 웹 서비스
![SCR-20260108-uaar.png](https://lfmgqfrdtvadgjczglzk.supabase.co/storage/v1/object/public/blog-uploads/blog-images/1767881538540-8uiu166.png)
## 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] ⚡