부캠/TypeScript&Next

[NEXT]Basic zoom (1) route handler

FS29 2024. 12. 21. 21:15

 

route handler - Next.js에서 백엔드 API를 만들기

 

[주의]
route handler는 client component에서만 요청하세요.
server component에선 route handler를 요청하지 않습니다.
  1. app 폴더 내에서 route.ts 파일을 만든다.
    • 폴더 구조에 따라 api 주소가 바뀐다. 단, page.tsx 의 경로와 같으면 페이지 요청에 문제가 있을 수 있으니 경로를 항상 신경써야 한다.
    • app/route.ts ⇒ fetch("/")
    • ⇒ app/page.tsx 와 주소가 겹칠 수 있음
    • app/api/route.ts ⇒ fetch("/api")
    • app/api/user/route.ts ⇒ fetch("/api/user")
    • app/api/rotation/route.ts ⇒ fetch("/api/rotation")
  2. route.ts 파일에서 메소드 이름 (GET, POST, DELETE, PUT, PATCH)로 함수를 만들고, export 한다.

[GET]

// app/api/posts/route.ts

import { NextResponse } from "next/server";

export function GET() {
  // 자바스크립트 데이터를 JSON으로 변환하여 프론트엔드로 전달하는 코드
  return NextResponse.json("안녕?");

	// 조금 더 복잡한 데이터 예시
  // return NextResponse.json({
  //   message: "success",
  //   data: { name: "수수수수퍼노바" },
  // });
}
"use client";

import { useEffect } from "react";

export default function Home() {
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch("http://localhost:3000/api/posts");
      const data = await response.json();
      console.log(data); // "안녕?"
    };
    fetchData();
  }, []);
  return <div>Hello World</div>;
}

 

 

[예시]

GET

 

console

 

 

 

[POST]

// app/api/posts/route.ts

import { NextResponse } from "next/server";

export function GET() {
  // 자바스크립트 데이터를 JSON으로 변환하여 프론트엔드로 전달하는 코드
  return NextResponse.json("안녕?");

	// 조금 더 복잡한 데이터 예시
  // return NextResponse.json({
  //   message: "success",
  //   data: { name: "수수수수퍼노바" },
  // });
}
"use client";

import { useEffect } from "react";

export default function Home() {
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch("http://localhost:3000/api/posts");
      const data = await response.json();
      console.log(data); // "안녕?"
    };
    fetchData();
  }, []);
  return <div>Hello World</div>;
}

 

[예시]

POST

 

console
터미널

 

 

 

 

 

 

 

route handler를 사용하는 이유는?
  1. 백엔드 코드를 작성할 때
    • supabase, json-server는 이미 백엔드 서비스 → 사실 저희가 필요성을 느끼긴 어렵습니다.
    • 하지만 일반적으로 DB에서 데이터를 생성, 조회, 수정, 삭제하는 코드는 백엔드에서만 작성할 수 있습니다.
// connectDB.ts

import mongoose from "mongoose";

// 몽고 DB 주소
const MONGODB_URI =
  process.env.MONGODB_URI || "mongodb://localhost:27017/your_database";

// 스키마 (테이블 구조) 작성
const PostSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
  },
  content: {
    type: String,
    required: true,
  },
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

// 게시물 관련 모델 생성
export const Post = mongoose.models.Post || mongoose.model("Post", PostSchema);

// 몽고 DB 연결
export async function connectDB() {
  try {
    if (mongoose.connection.readyState === 0) {
      await mongoose.connect(MONGODB_URI);
      console.log("Connected to MongoDB");
    }
  } catch (error) {
    console.error("Error connecting to MongoDB:", error);
  }
}
import { NextResponse } from "next/server";
import { connectDB, Post } from "@/app/lib/connectDB";

// 조회
export async function GET() {
  try {
    await connectDB();
    const posts = await Post.find({}).sort({ createdAt: -1 });
    return NextResponse.json(posts, { status: 200 });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to fetch posts" },
      { status: 500 }
    );
  }
}

// 추가
export async function POST(request: Request) {
  try {
    await connectDB();
    const body = await request.json();
    const newPost = await Post.create(body);
    return NextResponse.json(newPost, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to create post" },
      { status: 500 }
    );
  }
}

// 수정
export async function PUT(request: Request) {
  try {
    await connectDB();
    const body = await request.json();
    const { id, ...updateData } = body;

    const updatedPost = await Post.findByIdAndUpdate(id, updateData, {
      new: true,
    });

    if (!updatedPost) {
      return NextResponse.json({ error: "Post not found" }, { status: 404 });
    }

    return NextResponse.json(updatedPost, { status: 200 });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to update post" },
      { status: 500 }
    );
  }
}

// 삭제
export async function DELETE(request: Request) {
  try {
    await connectDB();
    const { searchParams } = new URL(request.url);
    const id = searchParams.get("id");

    const deletedPost = await Post.findByIdAndDelete(id);

    if (!deletedPost) {
      return NextResponse.json({ error: "Post not found" }, { status: 404 });
    }

    return NextResponse.json({ message: "Post deleted" }, { status: 200 });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to delete post" },
      { status: 500 }
    );
  }
}
  • 혹은 인증과 같이 보안이 중요한 경우 백엔드 코드에서 작성해야 합니다.

 

 

2. 🔒 보안 - API key 노출을 방지한다.

클라이언트에서 사용 시 API key가 노출된다. 만약 RLS 설정을 제대로 하지 않는다면 문제가 될 수 있다.

코드 예시 → 네트워크 탭을 확인해보세요. (supabase key, supabase url은 수정하세요)

아래와 같은 방식은 권장되지 않습니다.
(server actions와 비교 부분에서 설명 예정)
API KEY 노출을 막을 수 있다는 예시로만 봐주세요.
// app/api/posts/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

const supabaseUrl = "https://gotfpvrpusieiwuktrjg.supabase.co";
const supabaseKey = process.env.SUPABASE_KEY!;
const supabase = createClient(supabaseUrl, supabaseKey);

export async function GET() {
  const { data, error } = await supabase.from("cities").select("*");
  console.log(data);
  return NextResponse.json(data);
}

export async function POST(request: Request) {
  const body = await request.json();
  console.log(body); // { name: "부산", country_id: 1 }
  const { data, error } = await supabase.from("cities").insert({
    name: body.name,
    country_id: body.country_id,
  });
  console.log(data);
  return NextResponse.json({
    message: "success",
    data: `${body.name} 도시 추가 완료`,
  });
}
// app/page.tsx

"use client";

import { useEffect } from "react";

export default function Home() {

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch("/api/posts");
      const data = await response.json();
      console.log(data); // [{ id: 1, name: "Seoul", country_id: 1 }, { id: 2, name: "Washington", country_id: 2 }]
    };
    fetchData();
  }, []);

  const handleClick = async () => {
    const response = await fetch("/api/posts", {
      method: "POST",
      body: JSON.stringify({ name: "Busan", country_id: 1 }),
    });
    const data = await response.json();
    console.log(data); // { message: "success", data: "Busan 도시 추가 완료" }
  };
  return (
    <div>
      <button onClick={handleClick}>부산 추가</button>
    </div>
  );
}

 

 

3. cors 에러 방지

클라이언트 컴포넌트에서 다음과 같이 API 요청 시 CORS 에러 발생

CORS 에러란?

만약 🦹🏻‍♂️ 🏦 악의적인 은행 웹사이트가 있다고 가정해봅시다.

여러분은 🦹🏻‍♂️ 보이스피싱범에게 속아 악의적인 웹사이트에 들어갔습니다.

해당 악의적인 웹사이트에서 🧑🏻‍💻 로그인을 했다고 가정해봅시다. 그리고 해당 로그인 정보를 실제 💽 은행 백엔드 서버에 API를 요청하여 여러분의 개인정보를 뽑아 가져갈 수 있습니다.

그래서 알 수 없는 프론트엔드에서의 요청은 문제가 있다고 생각합니다. 즉, 이때, CORS 에러가 발생하며 API 요청이 불가해집니다.

✅ CORS 에러를 해결하는 방법은 다음과 같습니다.

프론트엔드가 아닌 백엔드 서버를 만들어 해당 API를 요청한다. (백엔드 ↔ 백엔드) 백엔드에서 특정 도메인에선 요청할 수 있게 수정한다. 이 방법은 우리 회사 백엔드라면 가능하나 다른 회사 백엔드라면 불가능하다.

Cors 에러 발생 예시 코드

// app/page.tsx

"use client";

import { useEffect } from "react";

export default function Home() {
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch("https://kr.api.riotgames.com/lol/platform/v3/champion-rotations", {
        headers: {
          "X-Riot-Token": "RGAPI-9fe823e4-4989-4ae9-8866-a399cbfb1889",
        },
      });
      const data = await response.json();
      console.log(data);
    };
    fetchData();
  }, []);
  return <div>Hello World</div>;
}

Cors 에러를 방지하기 위한 수정된 코드

// app/api/rotations/route.ts

import { NextResponse } from "next/server";

export async function GET() {
  const response = await fetch(
    "https://kr.api.riotgames.com/lol/platform/v3/champion-rotations",
    {
      headers: {
        "X-Riot-Token": "RGAPI-9fe823e4-4989-4ae9-8866-a399cbfb1889",
      },
    }
  );
  const data = await response.json();
  return NextResponse.json(data);
}
// app/page.tsx

"use client";

import { useEffect } from "react";

export default function Home() {
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch("/api/rotations");
      const data = await response.json();
      console.log(data);
    };
    fetchData();
  }, []);
  return <div>Hello World</div>;
}

 

 

4. 다소 복잡한 서버 코드가 필요할 때

아래 코드는 prisma 예시이므로 복사 붙여넣기를 해도 동작하지 않습니다.

DB 조회 예시

// app/api/posts/route.ts

import { prisma } from '@/lib/prisma'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  try {
    // URL 파라미터 처리
    const { searchParams } = new URL(request.url)
    const page = parseInt(searchParams.get('page') || '1')
    const limit = parseInt(searchParams.get('limit') || '10')
    const search = searchParams.get('search') || ''

    const posts = await prisma.post.findMany({
      where: {
        title: { contains: search }
      },
      skip: (page - 1) * limit,
      take: limit,
      include: {
        author: {
          select: {
            name: true,
            email: true
          }
        }
      }
    })

    return NextResponse.json(posts)
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch posts' },
      { status: 500 }
    )
  }
}

 

// app/page.tsx

"use client";

import { useEffect } from "react";

export default function Home() {
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch("http://localhost:3000/api/posts");
      const data = await response.json();
      console.log(data);
    };
    fetchData();
  }, []);
  return <div>Hello World</div>;
}

 

 

 

이미지 업로드 예시

// app/api/upload/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'

export async function POST(request: Request) {
  try {
    const formData = await request.formData()
    const file = formData.get('file') as File
    
    if (!file) {
      return NextResponse.json(
        { error: 'No file provided' },
        { status: 400 }
      )
    }

    // 파일 타입 검증
    if (!file.type.startsWith('image/')) {
      return NextResponse.json(
        { error: 'File must be an image' },
        { status: 400 }
      )
    }

    const buffer = await file.arrayBuffer()
    const s3 = new S3Client({
      region: process.env.AWS_REGION,
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!
      }
    })

    const fileName = `${Date.now()}-${file.name}`
    await s3.send(new PutObjectCommand({
      Bucket: process.env.AWS_BUCKET_NAME,
      Key: fileName,
      Body: Buffer.from(buffer),
      ContentType: file.type
    }))

    return NextResponse.json({
      url: `https://${process.env.AWS_BUCKET_NAME}.s3.amazonaws.com/${fileName}`
    })
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to upload file' },
      { status: 500 }
    )
  }
}
'use client'

import { useState } from 'react'

export default function FileUpload() {
  const [file, setFile] = useState<File | null>(null)
  const [uploading, setUploading] = useState(false)
  const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
  const [error, setError] = useState<string | null>(null)

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files?.[0]) {
      setFile(e.target.files[0])
      setError(null)
    }
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!file) return

    setUploading(true)
    setError(null)

    try {
      const formData = new FormData()
      formData.append('file', file)

      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      })

      const data = await response.json()

      if (!response.ok) {
        throw new Error(data.error || '업로드 실패')
      }

      setUploadedUrl(data.url)
    } catch (err) {
      setError(err instanceof Error ? err.message : '업로드 중 오류가 발생했습니다')
    } finally {
      setUploading(false)
    }
  }

  return (
    <div className="p-4">
      <form onSubmit={handleSubmit}>
        <input 
          type="file" 
          accept="image/*" 
          onChange={handleFileChange}
        />
        <button 
          type="submit" 
          disabled={!file || uploading}
        >
          {uploading ? '업로드 중...' : '업로드'}
        </button>
      </form>

      {error && <p className="text-red-500">{error}</p>}
      {uploadedUrl && (
        <div>
          <p>업로드 완료!</p>
          <img 
            src={uploadedUrl} 
            alt="Uploaded file" 
            className="mt-4 max-w-xs"
          />
        </div>
      )}
    </div>
  )
}

 

 

 

 

Supabase Auth Admin 예시 (supabase 회원 탈퇴)

 

supabase 문서에서 회원 탈퇴 기능은 supabase.auth.admin  을 이용해야 합니다

supabase.auth.admin 은 service_role  key가 필요하다고 합니다

service_role  key는 보안상 노출되면 안됩니다.

프론트엔드 코드에서 해당 기능을 사용할 때 ( anon_key 를 사용할 때)

"use client";

import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";

// TODO: 여러분의 supabase url 과 key를 입력해주세요.
const supabaseUrl = "https://gotfpvrpusieiwuktrjg.supabase.co";
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey!);

function App() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [user, setUser] = useState(null);

  const onChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  const onChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  };

  useEffect(() => {
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      if (session) {
        setUser(session.user);
      } else {
        setUser(null);
      }
    });

    return () => subscription.unsubscribe();
  }, []);

  const signUpNewUser = async (e) => {
    e.preventDefault();
    const { data, error } = await supabase.auth.signUp({
      email,
      password,
    });
    console.log("signup: ", { data, error });
    setUser(data.user);
  };

  const signInUser = async (e) => {
    e.preventDefault();
    const { data, error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });
    console.log("signin: ", { data, error });
    setUser(data.user);
  };

  const signOutUser = async (e) => {
    e.preventDefault();
    const { data, error } = await supabase.auth.signOut();
    console.log("signout: ", { data, error });
    setUser(null);
  };

  const deleteUser = async (e) => {
    e.preventDefault();
    const { data, error } = await supabase.auth.admin.deleteUser(user.id);
    console.log("deleteUser: ", { data, error });
    setUser(null);
  };

  if (!user) {
    return (
      <form>
        <input
          type="text"
          placeholder="이메일"
          value={email}
          onChange={onChangeEmail}
        />
        <input
          type="password"
          placeholder="비밀번호"
          value={password}
          onChange={onChangePassword}
        />
        <button onClick={signInUser}>로그인</button>
        <button onClick={signUpNewUser}>회원가입</button>
      </form>
    );
  } else {
    return (
      <div>
        <p>{user.email}</p>
        <button onClick={signOutUser}>로그아웃</button>
        <button onClick={deleteUser}>회원탈퇴</button>
      </div>
    );
  }
}

export default App;

 

route handlers에서 처리하기

// src/app/api/users/route.ts

import { createClient } from "@supabase/supabase-js";

const supabase_url = "https://gotfpvrpusieiwuktrjg.supabase.co";
const service_role_key = process.env.SUPABASE_SERVICE_ROLE_KEY;
const supabase = createClient(supabase_url, service_role_key!);

export async function POST(req: Request) {
  const { userId } = await req.json();
  console.log({ userId });
  const { data, error } = await supabase.auth.admin.deleteUser(userId);
  console.log({ data, error });
  if (error) {
    return Response.json({ message: "회원탈퇴에 실패했습니다.", status: 500 });
  }
  return Response.json({ message: "회원탈퇴 완료", status: 200 });
}
// src/app/page.tsx

"use client";

import { useEffect, useState } from "react";
import { createClient } from "@supabase/supabase-js";

// 여러분의 supabase url 과 key를 입력해주세요.
const supabaseUrl = "https://gotfpvrpusieiwuktrjg.supabase.co";
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey!);

function App() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [user, setUser] = useState(null);

  const onChangeEmail = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  const onChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
    setPassword(e.target.value);
  };

  useEffect(() => {
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      if (session) {
        setUser(session.user);
      } else {
        setUser(null);
      }
    });

    return () => subscription.unsubscribe();
  }, []);

  const signUpNewUser = async (e) => {
    e.preventDefault();
    const { data, error } = await supabase.auth.signUp({
      email,
      password,
    });
    console.log("signup: ", { data, error });
    setUser(data.user);
  };

  const signInUser = async (e) => {
    e.preventDefault();
    const { data, error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });
    console.log("signin: ", { data, error });
    setUser(data.user);
  };

  const signOutUser = async (e) => {
    e.preventDefault();
    const { data, error } = await supabase.auth.signOut();
    console.log("signout: ", { data, error });
    setUser(null);
  };

  const deleteUser = async (e) => {
    e.preventDefault();
    // 얘는 public users table 에서 users를 삭제해야 해서 있음.
    const { error } = await supabase.from("users").delete().eq("id", user?.id);
    // 회원탈퇴 전 강제 로그아웃
    await supabase.auth.signOut();

    // 이 코드만 보시면 됩니다.
    const res = await fetch("/api/users", {
      method: "POST",
      body: JSON.stringify({ userId: user.id }),
    });

    console.log("deleteUser: ", res);
    setUser(null);
  };

  if (!user) {
    return (
      <form>
        <input
          type="text"
          placeholder="이메일"
          value={email}
          onChange={onChangeEmail}
        />
        <input
          type="password"
          placeholder="비밀번호"
          value={password}
          onChange={onChangePassword}
        />
        <button onClick={signInUser}>로그인</button>
        <button onClick={signUpNewUser}>회원가입</button>
      </form>
    );
  } else {
    return (
      <div>
        <p>{user.email}</p>
        <button onClick={signOutUser}>로그아웃</button>
        <button onClick={deleteUser}>회원탈퇴</button>
      </div>
    );
  }
}

export default App;

 

 


HTTP 메소드의 흐름 예시

  1. GET: 데이터를 읽음 → 사용자 정보를 화면에 표시.
  2. POST: 데이터를 만듦 → 새로운 사용자 등록.
  3. PUT: 데이터를 수정 → 사용자 정보 전체 변경.
  4. PATCH: 데이터를 일부 수정 → 사용자 이메일만 변경.
  5. DELETE: 데이터를 삭제 → 사용자 계정 삭제.