Programming

[Next.js] 라우트 핸들러(Router handlers)를 사용해 api 통신하기

보간 2025. 3. 16. 21:57

Next.js에서 앱 라우터를 사용할 때, 요청(Request)응답(Response) API를 사용하여 특정 라우트(Route)*에 대한 커스텀 요청 핸들러(request handler)를 작성할 수 있다. 이를 라우트 핸들러(Route handlers) 라고 부른다.

* API(Application Programming Interface): 애플리케이션 프로그래밍 인터페이스를 뜻하며, 소프트웨어 간에 데이터를 교환할 수 있도록 하는 규칙이나 프로토콜  

* 라우트(Route) : 웹 애플리케이션에서 특정 URL 경로. 이 글에서는 주로 통신할 서버 URL(슈퍼베이스 등)


라우트 핸들러 사용 규칙

 

1. 앱(app) 폴더 안에서만 라우트 핸들러가 작동되며 라우트(route) 파일로 정의할 수 있다. 

 

2. 라우트 핸들러가 지원하는 HTTP 메서드GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS 다. 지원되지 않는 메서드가 호출되면 Next.js에서 405 Method Not Allowed 응답을 반환한다.

 

3. 라우트 핸들러는 레이아웃(layout), 페이지(page)와 마찬가지로 앱(app) 폴더 안 어디에서나 중첩 가능하다. 하지만 동일 라우트 세그먼트 레벨에 레이아웃, 페이지 파일이 함께 위치해서는 안 된다. 

 

허용/비허용 예시

라우트(route) 또는 페이지(page) 파일은 해당 라우트에 대한 모든 HTTP 메서드를 인수하기 때문이다. 즉, 라우트마다 라우트(route)또는 페이지(page) 파일을 가질 수 있는데, 이 파일은 해당 라우트로 들어오는 모든 요청을 받아서 처리한다. app/page.js 파일과 app/route.js 파일이 있는 첫  번째 예시(conflict)의 경우, page.js와 route.js에서 해당 route의 HTTP 메서드를 전부 인수할 수 있을 것이다. 그럼 api 통신 route를 분리한 의미가 없다. 

app/page.tsx

export default function Page() {
  return (
	  <section>
		  <h1>안녕! Next.js</h1>
		</section>
  )
}

// ❌ `app/route.ts` 파일과 충돌!
export async function POST(request: Request) { ... }

라우트 핸들러 사용하기

Next.js에서 통신하는 방법은 React+javascript에서 사용하는 방법과 크게 다르지 않다.

React+javascript

Javascript에서 지원하는 메소드(fetch()등)를 통해 데이터를 불러오며, React의 외부 시스템에서 데이터를 가져와 React 앱에 동기화하려면 이펙트를 사용해야 한다. 반면 사용자의 액션에 따라 서버에 요청해 데이터를 수정하려면 이벤트 핸들러를 사용한다. 

React+javascript+Next.js

기존 방법을 그대로 사용한다. 다만 이펙트를 사용해 서버에 데이터를 요청/응답하는 부분은 클라이언트 컴포넌트에서, 외부 시스템에서 데이터를 읽고, 수정하는 기능들은 라우트 핸들러를 통해 역할을 명확하게 분리하고 성능 개선에 도움을 줄 수 있다.

 

외부 route에서 게시글 하나를 읽어오는(get) 예시 

app/api/post-detail.ts

라우트에서 게시글 하나를 읽어오는 라우트 핸들러

import { NextRequest, NextResponse } from 'next/server'; //(1)
import { createClient } from '@/utils/supabase/client';

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url); //(2) 요청 지점의 url을 생성한다. 
  const postId = searchParams.get('postId'); // 요청 지점 url에서 ?postId=xx중 xx부분(postId)를 불러온다. 

  if (!postId) {
    return NextResponse.json({ error: 'postId가 없습니다.' }, { status: 400 });
  } //postId를 불러오지 못할 때는 return

  const supabase = createClient(); //사용할 라우트를 불러오는 함수

  try {
    const { data, error } = await supabase
      .from('post')
      .select(
        `
        id, title, description, image_url, thumbs, created_at, image_url, other_images, location
        `
      )
      .eq('id', postId)
      .single(); // 하나의 데이터만 가져와서 상세페이지를 출력한다. 

    if (error) {
      console.error('게시글 불러오기 오류:', error);
      return NextResponse.json(
        { error: 'Error fetching post data' },
        { status: 500 }
      );
    }

    return NextResponse.json(data, { status: 200 }); 
  } catch (error) {
    console.error('Unexpected error:', error);
    return NextResponse.json(
      { error: 'Unexpected error occurred' },
      { status: 500 }
    );
  }
}

1. import { NextRequest, NextResponse } from 'next/server'; 

  • NextRequest: Next.js에서 들어오는 HTTP 요청을 나타내는 객체로, 요청의 정보(예: URL, 메서드, 헤더 등)에 접근 가능 
  • NextResponse: Next.js에서 HTTP 응답을 생성하는 객체로, 클라이언트에 보낼 응답을 구성하고 반환 가능

2. searchParams: URL의 쿼리 스트링 부분에 포함된 파라미터들을 다룰 수 있는 객체로, URL에서 쿼리 파라미터를 쉽게 읽고 수정할 수 있게 해준다. 


app/features/post/components/post-detail.tsx

게시글 하나의 내용을 받아 상세페이지를 출력하는 클라이언트 컴포넌트 

'use client';

import NavItems from '@/components/nav-items';
import Profile from '@/components/profile';
import PostContent from '@/features/post-detail/components/post-content';
import PostHeader from '@/features/post-detail/components/post-header';
import PostImageSlider from '@/features/post-detail/components/post-image-slider';
import PostSubsection from '@/features/post-detail/components/post-subsection';
import { PostData } from '@/types/post-data-types';
import { useState, useEffect } from 'react';

export default function PostDetail() {
  const [postId, setPostId] = useState<string | null>(null);
  
  //*통신 부분*

  useEffect(() => {
    const urlParams = new URLSearchParams(window.location.search);
    const postId = urlParams.get('postId'); //현재 페이지의 postId를  상태 설정
    setPostId(postId);
  }, []);

  const [postData, setPostData] = useState<PostData | null>(null);

  useEffect(() => {
    if (postId) {
      const fetchPostData = async () => {
        const response = await fetch(`/api/post-detail?postId=${postId}`); //위의 라우트 핸들러와 통신
        if (response.ok) { //통신 성공 여부 확인
          const data = await response.json();
          setPostData(data); //성공시 게시글 데이터를 상태로 설정
        } else {
          console.error('게시글 불러오기 오류:', await response.text());
        }
      };

      fetchPostData();
    }
  }, [postId]);

  if (!postData) {
    return <div>Loading...</div>; // 데이터가 없으면 로딩 상태 표시
  }
  
  //*통신 부분*

  const images: string[] = [
    postData.image_url,
    ...(postData.other_images?.filter((img): img is string => img != null) ||
      []), // null, undefined 제거하고 string만 필터링
  ];

  return (
    <div className="bg-gray-50 h-screen">
      <Profile />
      <div className="pt-3 pb-4">
        <PostHeader />
      </div>

      <div className="post-detail-card bg-white h-[50%] min-h-[540px] w-[40%] min-w-[288px] rounded-2xl mx-auto">
        <div className="rounded-2xl">
          <PostImageSlider images={images} />
        </div>

        <PostContent title={postData.title} content={postData.description} />

        <div className="pl-3 pb-8 pt-2 rounded-b-2xl border-dashed border-t-1 border-gray-400">
          <PostSubsection
            location={postData.location}
            locationDetail={postData.location}
            thumb={postData.thumbs}
            comment={27}
          />
        </div>
      </div>
      <NavItems />
    </div>
  );
}

 

로딩 성공^^