오늘은 제가 최근에 완성한 리그 오브 레전드 정보 앱 프로젝트에 대해 소개해드리려고 합니다. Riot 개발자 사이트에서 제공하는 API를 활용하여 만든 이 앱은 Next.js와 TypeScript를 기반으로 하고 있습니다. 프로젝트를 진행하면서 겪은 경험과 구현 과정을 공유하고자 합니다.
# 시연 영상 📹︎
1. 프로젝트 셋업 및 기본 구조 🌿
프로젝트를 시작하기 위해 Next.js와 TypeScript를 사용하여 기본 구조를 잡았습니다. 여기에 Tailwind CSS를 추가하여 스타일링을 손쉽게 할 수 있도록 설정했습니다.
2. 데이터 Fetching 💿
먼저 types/ 폴더를 만들어 그안에 디렉토리에 필요한 타입들을 정의했습니다.
< Champion.ts 파일 >
// 챔피언 목록 전체 응답 구조
export interface ChampionData {
[championName: string]: ChampionInfo;
}
export interface ChampionInfo {
version: string;
id: string;
key: string;
name: string;
title: string;
blurb: string;
info: {
attack: number;
defense: number;
magic: number;
difficulty: number;
};
image: {
full: string;
sprite: string;
group: string;
x: number;
y: number;
w: number;
h: number;
};
tags: string[];
partype: string;
stats: {
hp: number;
hpperlevel: number;
mp: number;
mpperlevel: number;
movespeed: number;
armor: number;
armorperlevel: number;
spellblock: number;
spellblockperlevel: number;
attackrange: number;
hpregen: number;
hpregenperlevel: number;
mpregen: number;
mpregenperlevel: number;
crit: number;
critperlevel: number;
attackdamage: number;
attackdamageperlevel: number;
attackspeedperlevel: number;
attackspeed: number;
};
}
// Champion 타입 (목록에서 사용할 기본 정보)
export interface Champion {
id: string;
key: string;
name: string;
title: string;
image: {
full: string;
};
}
// ChampionDetail 타입 (상세 정보 페이지에서 사용할 정보)
export interface ChampionDetail extends Champion {
blurb: string;
info: {
attack: number;
defense: number;
magic: number;
difficulty: number;
};
}
< ChampionRotation.ts 파일 >
// 챔피언 로테이션 데이터 타입
export interface ChampionRotation {
freeChampionIds: number[];
freeChampionIdsForNewPlayers: number[];
maxNewPlayerLevel: number;
}
< Item.ts 파일 >
// 아이템 목록 전체 응답 구조
export interface ItemData {
[itemId: string]: ItemInfo;
}
export interface ItemInfo {
name: string;
description: string;
colloq: string;
plaintext: string;
into: string[];
from: string[];
image: {
full: string;
sprite: string;
group: string;
x: number;
y: number;
w: number;
h: number;
};
gold: {
base: number;
purchasable: boolean;
total: number;
sell: number;
};
tags: string[];
maps: {
[mapId: string]: boolean;
};
stats: {
[statName: string]: number;
};
depth: number;
}
// 사용할 Item 정보 타입
export interface Item {
id: string;
name: string;
plaintext: string;
image: {
full: string;
};
}
그 다음, Riot API에서 데이터를 가져오기 위해 Server Action을 활용했습니다. 이를 통해 서버 컴포넌트 내에서 직접 데이터를 fetch할 수 있었습니다.
"use server";
import { Champion, ChampionData, ChampionDetail } from "@/types/Champion";
import { Item, ItemData } from "@/types/Item";
// 최신 버전 가져오는 함수
export const getVersion = async (): Promise<string> => {
const versionResponse = await fetch(
"https://ddragon.leagueoflegends.com/api/versions.json"
);
if (!versionResponse.ok) {
throw new Error("Failed to fetch version data");
}
const versions: string[] = await versionResponse.json();
return versions[0];
};
// 챔피언 목록 가져오는 함수
export const getChampionList = async (): Promise<Champion[]> => {
// 최신 버전 가져오기
const version = await getVersion();
// 버전에 맞춰 챔피언 목록 가져오기
const championListResponse = await fetch(
`https://ddragon.leagueoflegends.com/cdn/${version}/data/ko_KR/champion.json`
);
if (!championListResponse.ok) {
throw new Error("Failed to fetch champion list data");
}
const { data }: { data: ChampionData } = await championListResponse.json();
// 필요한 챔피언 데이터만 추출
const championList: Champion[] = Object.values(data).map((champion) => ({
id: champion.id,
key: champion.key,
name: champion.name,
title: champion.title,
image: {
full: champion.image.full,
},
}));
return championList;
};
// 특정 챔피언 상세 정보 가져오기
export const getChampionDetail = async (
id: string
): Promise<ChampionDetail> => {
// 최신 버전 가져오기
const version = await getVersion();
// 버전에 맞춰 특정 챔피언 디테일 데이터 가져오기
const championDetailResponse = await fetch(
`https://ddragon.leagueoflegends.com/cdn/${version}/data/ko_KR/champion/${id}.json`
);
if (!championDetailResponse.ok) {
throw new Error("Failed to fetch champion detail data");
}
const { data }: { data: ChampionData } = await championDetailResponse.json();
// 특정 챔피언 데이터
const champion = data[id];
if (!champion) {
throw new Error(`Champion data for ${id} not found`);
}
const championDetail: ChampionDetail = {
id: champion.id,
key: champion.key,
name: champion.name,
title: champion.title,
image: {
full: champion.image.full,
},
blurb: champion.blurb,
info: {
attack: champion.info.attack,
defense: champion.info.defense,
magic: champion.info.magic,
difficulty: champion.info.difficulty,
},
};
return championDetail;
};
// 아이템 목록 가져오기
export const getItemList = async (): Promise<Item[]> => {
// 최신 버전 가져오기
const version = await getVersion();
// 버전에 맞춰 아이템 불러오기
const itemListResponse = await fetch(
`https://ddragon.leagueoflegends.com/cdn/${version}/data/ko_KR/item.json`
);
if (!itemListResponse.ok) {
throw new Error("Failed to fetch item list");
}
const { data }: { data: ItemData } = await itemListResponse.json();
const itemList: Item[] = Object.entries(data).map(([id, item]) => ({
id: id,
name: item.name,
plaintext: item.plaintext,
image: {
full: item.image.full,
},
}));
return itemList;
};
클라이언트 사이드 렌더링(CSR)이 필요한 경우에는 Route Handlers를 사용했습니다. 예를 들어, 로테이션 정보를 가져오는 API 엔드 포인트를 만들었습니다.
< app/api/rotation/route.ts 파일 >
import { ChampionRotation } from "@/types/ChampionRotation";
import { NextResponse } from "next/server";
export async function GET() {
const apiKey = process.env.RIOT_API_KEY;
console.log("api=======>", apiKey);
if (!apiKey) {
// NextResponse는 Next.js에서 제공하는 API 응답 생성 유틸리티, 서버사이드에서 클라이언트로 응답을 보낼 때 사용
return NextResponse.json({ error: "API key not found" });
}
try {
const response = await fetch(
"https://kr.api.riotgames.com/lol/platform/v3/champion-rotations",
{
headers: {
"X-Riot-Token": apiKey,
},
}
);
// 응답 상태가 성공(200-299범위 상태의 코드)인지 나타내는 불리언
if (!response.ok) {
console.log("오류!!!!!!!", response);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: ChampionRotation = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error("Error fetching champion rotation", error);
return NextResponse.json(
{ error: "Failed to fetch champion rotation" },
{ status: 500 }
);
}
}
< src/utils/rioApi.ts 파일 >
import { ChampionRotation } from "@/types/ChampionRotation";
export async function getChampionRotation(): Promise<ChampionRotation> {
const response = await fetch("/api/rotation");
if (!response.ok) {
throw new Error("Failed to fetch rotation data");
}
return response.json();
}
저는 이런식으로 rotation정보를 핸들링하였는데 과제 해설 영상을 보고 깨달은 점이 있었습니다.⭐︎⭐︎⭐︎⭐︎⭐︎
- rioApi.ts 파일에 getChampionRotation() 같은 데이터를 가져오는 함수를 만들고,
- route.ts 파일에서 이 getChampionRotation() 함수를 불러와서 NextResponse로 데이터를 wrapping해서,
- 필요한 페이지에서 fetch("/api/rotation")으로 데이터를 받아오는 방식으로 했어야 했습니다.
그런데 저는 이걸 반대로 해버렸습니다. 다행히 오류는 없었지만, 이번 경험으로 다음 프로젝트에서는 각 로직을 정확한 위치에 작성해야겠다는 걸 깨달았습니다. 이런 실수도 배움의 과정이라고 생각하니 좋은 경험이 된 것 같습니다.
3. 페이지 구현 및 렌더링 방식 📋
각 페이지마다 다른 렌더링 방식을 적용하여 최적의 성능을 얻고자 했습니다.
챔피언 목록 페이지(/champions)
ISR(Incremental Static Regeneration) 방식을 사용하여 구현했습니다. 이를 통해 정적 페이지의 이점을 누리면서도 주기적으로 데이터를 업데이트할 수 있었습니다.
import ChampionCard from "@/components/ChampionCard";
import { getChampionList, getVersion } from "@/utils/serverApi";
import { Metadata } from "next";
export const revalidate = 86400; // 1일(86400초) 후 재검증(ISR)
// 정적 메타 데이터
export const metadata: Metadata = {
title: "챔피언 목록",
description: "리그 오브 레전드 챔피언 목록을 제공합니다.",
};
async function ChampionPage() {
// 서버 액션 함수 호출해서 챔피언 목록, 최신 버전 가져오기
const [champions, version] = await Promise.all([
getChampionList(),
getVersion(),
]);
return (
<div className="container mx-auto px-3">
<h1 className="text-2xl font-bold my-8">챔피언 목록</h1>
<ChampionCard champions={champions} version={version} />;
</div>
);
}
export default ChampionPage;
챔피언 상세 페이지(/champions/[id])
ISR(Incremental Static Regeneration)과 동적 메타데이터 생성을 결합한 하이브리드 방식으로 구현했습니다. 정적 생성으로 인해 빠른 로딩 시간과 동적 메타데이터 생성으로 인해 SEO를 개선할 수 있었습니다.
import BackButton from "@/components/BackButton";
import { getChampionDetail, getVersion } from "@/utils/serverApi";
import { Metadata } from "next";
import Image from "next/image";
export const revalidate = 86400; // 1일(86400초) 후 재검증(ISR)
// 동적 메타 데이터
export async function generateMetadata({
params,
}: {
params: { id: string };
}): Promise<Metadata> {
const championId = params.id;
const championDetail = await getChampionDetail(championId);
return {
title: `${championDetail.name} - 리그 오브 레전드 챔피언 정보`,
description: championDetail.blurb,
};
}
async function ChampionDetailPage({ params }: { params: { id: string } }) {
const championId = params.id;
const [championDetail, version] = await Promise.all([
getChampionDetail(championId),
getVersion(),
]);
return (
<div className="w-full flex justify-center">
<BackButton />
<div className="w-[80%] flex flex-col items-center gap-10 mt-8 mb-10 lg:mb-5">
<div>
<div className="text-[20px] font-bold text-red-500">
{championDetail.name}
</div>
<div>{championDetail.title}</div>
</div>
<Image
src={`https://ddragon.leagueoflegends.com/cdn/${version}/img/champion/${championDetail.image.full}`}
alt={championDetail.name}
width={300}
height={300}
/>
<div>{championDetail.blurb}</div>
<div className="flex flex-col">
<p className="mb-3 text-blue-500">스탯</p>
<span className="text-blue-500">{`공격력: ${championDetail.info.attack}`}</span>
<span className="text-blue-500">{`방어력: ${championDetail.info.defense}`}</span>
<span className="text-blue-500">{`마법력: ${championDetail.info.magic}`}</span>
<span className="text-blue-500">{`난이도: ${championDetail.info.difficulty}`}</span>
</div>
</div>
</div>
);
}
export default ChampionDetailPage;
아이템 목록 페이지(/items)
SSG(Static Site Generation) 방식을 사용하여 빠른 로딩 속도를 확보했습니다.
import { getItemList, getVersion } from "@/utils/serverApi";
import { Metadata } from "next";
import Image from "next/image";
export const metadata: Metadata = {
title: "아이템 목록",
description: "리그 오브 레전드 아이템 목록을 제공합니다.",
};
async function ItemPage() {
// 서버 액션 함수 호출해서 아이템 목록, 최신 버전 가져오기
const [items, version] = await Promise.all([getItemList(), getVersion()]);
return (
<div className="container mx-auto px-3">
<h1 className="text-2xl font-bold my-8">아이템 목록</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{items.map((item) => {
return (
<div
key={item.id}
className="border border-black rounded-md p-4 bg-gray-200"
>
<Image
src={`https://ddragon.leagueoflegends.com/cdn/${version}/img/item/${item.image.full}`}
alt={item.name}
width={120}
height={120}
className="mx-auto mb-3"
/>
<p className="font-bold text-[20px] text-blue-500">{item.name}</p>
<p className="dark:text-black">{item.plaintext}</p>
</div>
);
})}
</div>
</div>
);
}
export default ItemPage;
챔피언 로테이션 페이지(/rotation)
챔피언 로테이션 페이지는 로테이션 챔피언 정보가 일주일마다 바뀌기 때문에 CSR(Client Side Rendering)로 만들었지만 메타데이터 생성을 위해 챔피언 로테이션 페이지를 컴포넌트로 따로 분리해주었습니다. 왜냐하면 메타데이터는 서버 컴포넌트에서만 사용할 수 있기 때문입니다.
import { Metadata } from "next";
import RotationPage from "./RotationPage";
export const metadata: Metadata = {
title: "금주 로테이션",
description: "리그 오브 레전드 금주 로테이션 챔피언 목록 제공합니다.",
};
function Page() {
return <RotationPage />;
}
export default Page;
"use client";
import ChampionCard from "@/components/ChampionCard";
import { useChampionStore } from "@/store/rotationStore";
import { useEffect } from "react";
function RotationPage() {
const { freeChampions, version, error, fetchChampions } = useChampionStore();
useEffect(() => {
fetchChampions();
}, [fetchChampions]);
if (error) return <div>error: {error}</div>;
// 데이터가 모두 로드되었는지 확인
if (freeChampions.length === 0 || version === null) {
return <div>데이터 로딩 중...</div>;
}
// 데이터가 모두 준비되었을 때만 ChampionCard 컴포넌트 렌더링
return (
<div className="container mx-auto px-3">
<h1 className="text-2xl font-bold my-8">로테이션 챔피언 목록</h1>
<ChampionCard champions={freeChampions} version={version} />
</div>
);
}
export default RotationPage;
챔피언 월드컵 페이지(/worldcup)
마찬가지로 로데티션 챔피언으로 월드컵 기능을 만들었기 때문에 CSR(Client Side Rendering)을 사용하여 구현하고 메타데이터 생성을 위해 월드컵 페이지를 컴포넌트로 따로 분리해주었습니다.
import { Metadata } from "next";
import WorldCupPage from "./WorldcupPage";
export const metadata: Metadata = {
title: "챔피언 월드컵",
description: "챔피언 월드컵 서비스를 제공합니다.",
};
function Page() {
return <WorldCupPage />;
}
export default Page;
"use client";
import Image from "next/image";
import { useChampionStore } from "@/store/rotationStore";
import { Champion } from "@/types/Champion";
import { useEffect, useState } from "react";
function WorldCupPage() {
const { freeChampions, fetchChampions, version } = useChampionStore();
const [currentPair, setCurrentPair] = useState<[Champion, Champion] | null>(
null
);
const [currentRound, setCurrentRound] = useState<Champion[]>([]);
const [nextRound, setNextRound] = useState<Champion[]>([]);
const [winner, setWinner] = useState<Champion | null>(null);
const [roundNumber, setRoundNumber] = useState<number>(1);
// fetchChampions 함수로 freeChampions 배열 가져오기
useEffect(() => {
fetchChampions();
}, [fetchChampions]);
// freeChampions 가져오면 무작위로 섞기
useEffect(() => {
if (freeChampions.length > 0) {
const mixed = shuffleChampions(freeChampions);
setCurrentRound(mixed);
}
}, [freeChampions]);
// 현재 페어 표시
useEffect(() => {
if (currentRound.length >= 2) {
setCurrentPair([currentRound[0], currentRound[1]]);
} else if (currentRound.length === 1) {
// 부전승 처리
setNextRound((prev) => [...prev, currentRound[0]]);
setCurrentRound([]);
} else if (currentRound.length === 0 && nextRound.length > 0) {
if (nextRound.length === 1) {
// 우승자 결정
setWinner(nextRound[0]);
} else {
setCurrentRound(shuffleChampions(nextRound));
setNextRound([]);
setRoundNumber((prev) => prev + 1);
alert(`${roundNumber + 1} 라운드가 시작됩니다!`);
}
}
}, [currentRound, nextRound, roundNumber]);
// 사용자 선택 처리
const handleChoice = (chosen: Champion) => {
setNextRound((prev) => [...prev, chosen]);
setCurrentRound((prev) => prev.slice(2));
};
// 무작위 섞기 함수
const shuffleChampions = (champions: Champion[]) => {
return [...champions].sort(() => Math.random() - 0.5);
};
return (
<div className="w-full mt-10">
<div className="container mx-auto px-3">
{winner ? (
<div className="flex flex-col items-center gap-10">
<h1 className="font-bold text-[30px]">🥇 우승 챔피언 🥇</h1>
<Image
src={`https://ddragon.leagueoflegends.com/cdn/${version}/img/champion/${winner.image.full}`}
alt={winner.name}
width={400}
height={400}
/>
<p className="font-bold text-[20px] text-red-500">{winner.name}</p>
<p>{winner.title}</p>
</div>
) : currentPair ? (
<div className="flex flex-col justify-center items-center gap-5">
<h1 className="font-bold text-[30px]">🏆 챔피언 월드컵 🏆</h1>
<div className="flex flex-row gap-10">
{currentPair.map((champion) => (
<div
key={champion.id}
className="flex flex-col items-center gap-6 cursor-pointer"
onClick={() => handleChoice(champion)}
>
<Image
src={`https://ddragon.leagueoflegends.com/cdn/${version}/img/champion/${champion.image.full}`}
alt={champion.name}
width={400}
height={400}
/>
<p className="font-bold text-[20px] text-red-500">
{champion.name}
</p>
<p>{champion.title}</p>
</div>
))}
</div>
</div>
) : (
<div>로딩 중...</div>
)}
</div>
</div>
);
}
export default WorldCupPage;
4. 페이지 외 기능 🚶♂️
다크 모드 기능 구현 🖤
다크 모드 기능 구현을 위해 tailwind.config.ts 파일에 darkMode: "class"를 추가해주고, DarkModeButton라는 클라이언트 컴포넌트를 만들어주어 임포트해서 사용했습니다.
"use client";
import { useEffect, useState } from "react";
const DarkMode = () => {
const [darkMode, setDarkMode] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
// 컴포넌트가 마운트된 후 localStorage 확인
const storedDarkMode = localStorage.getItem("darkMode") === "true";
setDarkMode(storedDarkMode);
setMounted(true);
}, []);
useEffect(() => {
if (darkMode) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
localStorage.setItem("darkMode", darkMode.toString());
}, [darkMode]);
const toggleDarkMode = () => {
setDarkMode(!darkMode);
};
if (!mounted) {
return <div>로딩 중...</div>;
}
return <button onClick={toggleDarkMode}>{darkMode ? "🌝" : "🌚"}</button>;
};
export default DarkMode;
레이아웃 및 네비게이션 🗺️
글로벌 레이아웃을 설정하고 네비게이션 메뉴를 추가하여 사용자가 쉽게 페이지 간 이동을 할 수 있도록 했습니다.
import type { Metadata } from "next";
import "./globals.css";
import Link from "next/link";
import DarkMode from "../components/DarkMode";
export const metadata: Metadata = {
title: "네비게이션바",
description: "헤더 부분으로 어디든지 이동할 수 있습니다.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<header className="h-10 flex flex-row justify-evenly items-center bg-gray-200">
<Link
href="/"
className="text-black hover:text-blue-800 hover:underline"
>
홈
</Link>
<Link
href="/champions"
className="text-black hover:text-blue-800 hover:underline"
>
챔피언 목록
</Link>
<Link
href="/items"
className="text-black hover:text-blue-800 hover:underline"
>
아이템 목록
</Link>
<Link
href="/rotation"
className="text-black hover:text-blue-800 hover:underline"
>
챔피언 로테이션
</Link>
<Link
href="/worldcup"
className="text-black hover:text-blue-800 hover:underline"
>
챔피언 월드컵
</Link>
<DarkMode />
</header>
<main>{children}</main>
</body>
</html>
);
}
5. 성능 최적화 및 추가 기능 🛠️
- Next.js의 <Image> 컴포넌트를 사용하여 이미지 최적화
- Tailwind CSS를 활용한 반응형 디자인 구현
- TypeScript의 타입을 적극 활용하여 타입 안정성 강화
.
.
.
6. 소감 및 느낀점 🙇
이번 프로젝트를 통해 Next.js의 다양한 렌더링 방식과 TypeScript의 강력한 타입 시스템, 그리고 Riot API의 활용법을 익힐 수 있었습니다. 앞으로도 이러한 경험을 바탕으로 더 나은 웹 애플리케이션을 만들어 나가고 싶습니다. 다음 프로젝트에서는 아까 위에서 말한 것처럼 각 로직을 정확한 위치에 작성해보겠습니다!
'개인과제' 카테고리의 다른 글
리그오브레전드 정보앱(트러블 슈팅2🌟) (0) | 2024.10.01 |
---|---|
리그오브레전드 정보앱(트러블 슈팅1🌟) (0) | 2024.10.01 |
MBTI 과제 마무리 (0) | 2024.09.11 |
MBTI test(트러블 슈팅3🌟) (0) | 2024.09.10 |
MBTI test(트러블 슈팅2🌟) (0) | 2024.09.10 |