개인과제

Riot API를 활용하여 리그 오브 레전드 정보 앱 만들기⭐︎

choijming21 2024. 10. 8. 18:06

오늘은 제가 최근에 완성한 리그 오브 레전드 정보 앱 프로젝트에 대해 소개해드리려고 합니다. Riot 개발자 사이트에서 제공하는 API를 활용하여 만든 이 앱은 Next.js와 TypeScript를 기반으로 하고 있습니다. 프로젝트를 진행하면서 겪은 경험과 구현 과정을 공유하고자 합니다.

 

 

 

 

 

# 시연 영상 📹︎

리그 오브 레전드 정보 앱

 

 

 

 

 

 

 

 

 

1.  프로젝트 셋업 및 기본 구조 🌿

프로젝트를 시작하기 위해 Next.jsTypeScript를 사용하여 기본 구조를 잡았습니다. 여기에 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정보를 핸들링하였는데 과제 해설 영상을 보고 깨달은 점이 있었습니다.⭐︎⭐︎⭐︎⭐︎⭐︎

  1. rioApi.ts 파일에 getChampionRotation() 같은 데이터를 가져오는 함수를 만들고,
  2. route.ts 파일에서 이 getChampionRotation() 함수를 불러와서 NextResponse로 데이터를 wrapping해서,
  3. 필요한 페이지에서 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.  성능 최적화 및 추가 기능 🛠️

  1. Next.js의 <Image> 컴포넌트를 사용하여 이미지 최적화
  2. Tailwind CSS를 활용한 반응형 디자인 구현
  3. TypeScript의 타입을 적극 활용하여 타입 안정성 강화

 

 

 

 

 

.

.

.

 

 

 

 

 

6. 소감 및 느낀점 🙇

이번 프로젝트를 통해 Next.js의 다양한 렌더링 방식과 TypeScript의 강력한 타입 시스템, 그리고 Riot API의 활용법을 익힐 수 있었습니다. 앞으로도 이러한 경험을 바탕으로 더 나은 웹 애플리케이션을 만들어 나가고 싶습니다. 다음 프로젝트에서는 아까 위에서 말한 것처럼 각 로직을 정확한 위치에 작성해보겠습니다!