프로젝트/Next+TypeScript

[맛길] 동적 라우팅(Dynamic Routing) 적용

dev-hpk 2025. 2. 5. 18:16

🚨 문제 상황

프로젝트를 진행하면서 Youtube 채널을 추가하면서 점점 불편함을 느끼게 되었어요.

폴더 구조

 

위의 이미지처럼 채널이 추가될 때마다 /app 폴더 하위에 폴더가 점점 늘어나 구조가 너무 복잡해지게 되더라구요😭

 

왜 채널(라우트 페이지)마다 폴더를 만드는지 궁금하실 수 있겠네요🤔

Next.js가 파일 시스템 기반의 라우터를 사용하여 폴더를 경로 정의에 사용하기 때문이에요.

next.js 폴더 구조에 따른 경로

 

위 예시를 보시면 /app 폴더 하위의 폴더 이름이 경로로 사용되고 있어요.

 

/{channel}/page.tsx

import axios from '@/app/lib/instance';
import ListContainer from '@/app/components/lists/ListContainer';

async function page() {
  const {
    data: { lists, hasNext },
  } = await axios.get('bjw');

  return <ListContainer channel="bjw" hasNext={hasNext} lists={lists} />;
}

export default page;

 

위 코드는 /bjw 페이지에 대한 page.tsx 파일인데요.

똑같은 코드를 각 채널(ssg, pungja, hsc...)의 page.tsx에 작업해줘야 하니 불쾌한 개발 경험인거죠..💣

 

여기서 끝이면 그냥 채널마다 폴더를 생성할까도 고민했지만... 맛길은 Serverless Function을 이용한 프로젝트에요.

API Route도 채널마다 작업해줘야 하니 불쾌한 개발 경험이 2배에요💣💣

api 폴더 구조

import path from 'path';
import { promises as fs } from 'fs';
import { RawYoutubeData, YoutubeData } from '@/app/types/youtube';

export async function GET(request: Request): Promise<Response> {
  try {
    // JSON 파일 경로 설정
    const filePath = path.join(process.cwd(), 'data', 'baekjongwon.json');

    // 파일 읽기
    const fileContents = await fs.readFile(filePath, 'utf-8');

    // JSON 파싱
    const rawData: RawYoutubeData[] = JSON.parse(fileContents);

    // 필요한 데이터만 추출
    const data: YoutubeData[] = rawData.map((item) => ({
      id: item.id,
      position: item.snippet.position,
      title: item.snippet.title,
      thumbnailUrl: item.snippet.thumbnails.high.url,
    }));

    const url = new URL(request.url);
    const limitParam = url.searchParams.get('limit');
    const cursorParam = url.searchParams.get('cursor');

    const limit = limitParam ? parseInt(limitParam, 10) : 10;
    const cursor = cursorParam ? parseInt(cursorParam, 10) : null;

    // cursor가 있으면 해당 position 이후 데이터 필터링
    const filteredData =
      cursor !== null ? data.filter((item) => item.position > cursor) : data;

    // 제한된 개수만큼 데이터 가져오기
    const limitedData = filteredData.slice(0, limit);

    // hasNext 설정 (더 가져올 데이터가 있는지 확인)
    const hasNext = filteredData.length > limit;

    return new Response(
      JSON.stringify({ success: true, lists: limitedData, hasNext }),
      {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  } catch (error) {
    console.error('데이터를 받아오는 중 에러가 발생했습니다:', error);
    return new Response(
      JSON.stringify({ success: false, message: '데이터 요청 실패' }),
      {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }
}

 

Next.js 공식 문서를 찾아보다가 해결할 수 있는 방법을 찾은 것 같아요❗

Dynamic routes
Dynamic routes 예시

 

Next.js에서 폴더 이름을 [ ]로 감싸서 Dynamic routes(동적 라우팅)를 할 수 있다고 하네요.

프로젝트에 적용해 볼게요😀

🌈 문제 해결

1️⃣ 채널(페이지) 폴더 구조 수정 

Dynamic routes 적용한 폴더 구조

 

맛길 서비스는 아래 두 가지 페이지로 나뉠 예정이라 폴더 구조를 위 이미지처럼 수정했어요.

  • /{channel} : 해당 채널의 목록을 보여주는 페이지
  • /{channel}/{id} : 채널 목록의 id에 해당하는 상세 페이지

2️⃣ /app/[channel]/page.ts 파일 수정

공식 문서의 예시를 보면 params에 폴더 이름에 [ ]로 감싼  동적 세그먼트를 params prop으로 사용할 수 있네요.

 

동적 세그먼트(channel)를 params prop으로 받아서 서버 데이터를 요청하고 화면에 보여주도록 수정할게요❗ 

import axios from '@/app/lib/instance';
import ListContainer from '@/app/components/lists/ListContainer';

async function ChannelHome({ params }: { params: { channel: string } }) {
  const { channel } = params;

  const {
    data: { lists, hasNext },
  } = await axios.get(channel);

  return <ListContainer channel={channel} hasNext={hasNext} lists={lists} />;
}

export default ChannelHome;

 

결과를 확인해 볼까요

👀

👀

Dynamic routes 적용 결과
Dynamic routes 적용 후 SSR(Server Side Rendering) 결과

 

동적 라우팅이 잘 동작하네요👏👏👏

3️⃣ API Route 폴더 구조 수정

Dynamic routes 적용한 폴더 구조

 

채널(페이지)과 마찬가지로 리스트 페이지와 세부 페이지를 구분하기 위해 폴더 구조를 위 이미지처럼 수정했어요.

  • /{channel} : 해당 채널의 목록 데이터를 요청하는 엔드 포인트(end point)
  • /{channel}/{id} : 채널 목록의 id에 해당하는 데이터를 요청하는 엔드 포인트(end point)

4️⃣ /api/[channel]/route.ts 파일 수정

기존 api 호출 함수에서는 const filePath = path.join(process.cwd(), 'data', 'baekjongwon.json'); 형식으로 json 파일을 직접 입력해서 사용하는데요.

 

이 부분도 동적 세그먼트(channel)를 이용하도록 수정해 볼게요.

import path from 'path';
import { promises as fs } from 'fs';
import { RawYoutubeData, YoutubeData } from '@/app/types/youtube';

export async function GET(request: Request): Promise<Response> {
  try {
    // URL에서 파일 이름 추출 (예: /api/pungja -> pungja.json)
    const url = new URL(request.url);
    const pathname = url.pathname; // /api/{filename}
    const filename = pathname.split('/').pop(); // {filename}
    const jsonFileName = `${filename}.json`;

    // JSON 파일 경로 설정
    const filePath = path.join(process.cwd(), 'data', jsonFileName);

    // 파일 읽기
    const fileContents = await fs.readFile(filePath, 'utf-8');

    // JSON 파싱
    const rawData: RawYoutubeData[] = JSON.parse(fileContents);

    // 필요한 데이터만 추출
    const data: YoutubeData[] = rawData.map((item) => ({
      id: item.id,
      position: item.snippet.position,
      title: item.snippet.title,
      thumbnailUrl: item.snippet.thumbnails.high.url,
    }));

    const limitParam = url.searchParams.get('limit');
    const cursorParam = url.searchParams.get('cursor');

    const limit = limitParam ? parseInt(limitParam, 10) : 10;
    const cursor = cursorParam ? parseInt(cursorParam, 10) : null;

    // cursor가 있으면 해당 position 이후 데이터 필터링
    const filteredData =
      cursor !== null ? data.filter((item) => item.position > cursor) : data;

    // 제한된 개수만큼 데이터 가져오기
    const limitedData = filteredData.slice(0, limit);

    // hasNext 설정 (더 가져올 데이터가 있는지 확인)
    const hasNext = filteredData.length > limit;

    return new Response(
      JSON.stringify({ success: true, lists: limitedData, hasNext }),
      {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  } catch (error) {
    console.error('데이터를 받아오는 중 에러가 발생했습니다:', error);
    return new Response(
      JSON.stringify({ success: false, message: '데이터 요청 실패' }),
      {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }
}
  • const filename = pathname.split('/').pop();
    Dynamic route에서 url의 형태가 /서버주소/{channel}일 것이기 때문에 / 기준으로 나눈 배열의 마지막 요소를 filename으로 사용했어요.

결과를 확인해 볼까요

👀

👀

API Route에 Dynamic routes 적용한 결과
API Route에 Dynamic routes 적용한 결과

5️⃣ /api/[channel]/[id]/route.ts 파일 수정

import path from 'path';
import { promises as fs } from 'fs';
import { RawYoutubeData, YoutubeData } from '@/app/types/youtube';

export async function GET(request: Request): Promise<Response> {
  try {
    const url = new URL(request.url);

    const pathname = url.pathname.split('/');
    const channel = pathname[pathname.length - 2]; // channel 부분
    const id = pathname[pathname.length - 1]; // id 부분

    if (!channel || !id) {
      return new Response(
        JSON.stringify({
          success: false,
          message: 'Invalid request parameters.',
        }),
        { status: 400, headers: { 'Content-Type': 'application/json' } },
      );
    }

    // JSON 파일 경로 설정
    const filePath = path.join(process.cwd(), 'data', `${channel}.json`);

    // 파일 읽기
    const fileContents = await fs.readFile(filePath, 'utf-8');

    // JSON 파싱 및 데이터 매핑
    const rawData: RawYoutubeData[] = JSON.parse(fileContents);

    const data: YoutubeData[] = rawData.map((item) => ({
      id: item.id,
      position: item.snippet.position,
      title: item.snippet.title,
      thumbnailUrl: item.snippet.thumbnails.high.url,
    }));

    // id에 해당하는 데이터 찾기
    const target = data.find((item) => item.position === Number(id));

    if (!target) {
      return new Response(
        JSON.stringify({ success: false, message: 'Item not found.' }),
        { status: 404, headers: { 'Content-Type': 'application/json' } },
      );
    }

    return new Response(JSON.stringify({ success: true, list: target }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });
  } catch (error) {
    console.error('데이터를 받아오는 중 에러가 발생했습니다:', error);
    return new Response(
      JSON.stringify({ success: false, message: '데이터 요청 실패' }),
      {
        status: 500,
        headers: { 'Content-Type': 'application/json' },
      },
    );
  }
}
  • const channel = pathname[pathname.length - 2];
    Dynamic route에서 url의 형태가 /서버주소/{channel}/{id}일 것이기 때문에 / 기준으로 나눈 배열의 마지막에서 두 번째 요소를 channel로 사용했어요.
  • const id = pathname[pathname.length - 1];
    / 기준으로 나눈 배열의 마지막 요소를 id로 사용했어요.

결과를 확인해 볼까요

👀

👀

/{channel}/{id} 확인 결과

import axios from '@/app/lib/instance';

async function DetailPage({
  params,
}: {
  params: { channel: string; id: string };
}) {
  const { channel, id } = params;

  const {
    data: { list },
  } = await axios.get(`${channel}/${id}`);

  return (
    <div>
      <div className="text-white">{list.title}</div>
    </div>
  );
}

export default DetailPage;

 

확인을 위해 임시로 title만 추가해 둔 상태라 빈약하지만 잘 동작하네요👏👏 👏

 

 

동적 라우팅(Dynamic Routing) 적용은 모두 끝났지만 한 가지 문제가 더 생겼어요🤔🤔

에러 메시지
에러 메시지

 

메시지를 읽어보니 params의 properties를 사용하려면 await을 사용해야 한다는 내용 같아요.

자세한 내용을 알아보라고 공식 문서의 url을 줬으니 확인해 봐야겠죠🤔

 

Next.js 공식 문서

에러 발생 이유

 

Next 15 버전으로 업데이트되면서 paramssearchParams를 비동기적으로 접근해야 한다고 하네요.

가장 아래쪽 설명을 살펴보니 여전히 동기적으로도 접근이 가능하지만 warning을 발생시킨다고 해요.

console warning 로그

 

프로젝트로 돌아와 다시 확인해 보니 Error가 아니라 Warning이을 발생시키고 있네요.

 

수정하지 않아도 정상적으로 동작하니 그냥 넘어갈까도 생각했지만 가장 마지막 문구가 조금 신경 쓰이네요🤔

추후 업데이트 될 버전들에서 아래 코드 같은  동기적인 접근이 예상대로 동작하지 않을 수 있다고 해요.

const Page = ({ params }: { params: { channel: string } }) => {
  const {channel} = params;
}

 

해결 방법은 두 가지가 있네요👀

에러 해결 방법

 

저는 Server Component에서 발생한 에러이기 때문에 await을 선언해 해결했어요😊

 

 

✨ 동적 라우팅(Dynamic Routing)을 적용하면서 느낀 점

1️⃣ 확장성과 재사용성

동적 라우팅의 적용이 추후 channel을 추가하는 확장성과 재사용성 측면에서 큰 강점을 가져왔어요.

동적 라우팅을 적용하기 전이었다면 매번 추가적인 코드를 작성해야 했지만, 동적 라우팅을 적용함으로써 별도의 코드 변경 없이 기존 구조만으로 데이터를 channel을 추가할 수 있게 되었어요.

2️⃣ 유지보수 효율성 증가

하나의 공통된 라우팅 파일(route.ts)에서 동적으로 파일 경로를 결정하고 데이터를 처리함으로써 코드 중복을 크게 줄일 수 있게 되었어요. JSON 파일 이름이나 id 같은 가변적인 요소를 동적으로 처리함으로써, 기존에 각각 작성해야 했던 반복적인 로직을 통합할 수 있게 되어 유지보수 효율성을 증가시킬 수 있게 되었어요.

3️⃣ 지속적인 학습의 중요성

Next.js 뿐만 아니라 여러 라이브러리와 프레임워크들이 빠르게 발전하며 웹 개발의 새로운 표준을 제시하고 있어요.

업데이트 내용을 알지 못해 에러를 경험하고 나니 개발자로서 최신 기술 트렌드를 꾸준히 학습하고, 변화에 유연하게 대응하는 자세를 가져야겠다는 생각을 하게 되네요.

 

프론트엔드 개발자로서 성장해 나가기 위해 공식 문서와 참고 자료들을 바탕으로 꾸준히 학습해야겠습니다🔥🔥🔥