route handler - Next.js에서 백엔드 API를 만들기
[주의]
route handler는 client component에서만 요청하세요.
server component에선 route handler를 요청하지 않습니다.
- 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")
- 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>;
}
[예시]
[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>;
}
[예시]
route handler를 사용하는 이유는?
- 백엔드 코드를 작성할 때
- 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 메소드의 흐름 예시
- GET: 데이터를 읽음 → 사용자 정보를 화면에 표시.
- POST: 데이터를 만듦 → 새로운 사용자 등록.
- PUT: 데이터를 수정 → 사용자 정보 전체 변경.
- PATCH: 데이터를 일부 수정 → 사용자 이메일만 변경.
- DELETE: 데이터를 삭제 → 사용자 계정 삭제.
'부캠 > TypeScript&Next' 카테고리의 다른 글
[NEXT] - STANDARD플러스주차 복습 #6 (0) | 2024.12.23 |
---|---|
[NEXT]Basic zoom (2) middleware & supabase ssr (0) | 2024.12.21 |
[NEXT] 마지막 갠 과제에 쓴 거 정리 (2) (1) | 2024.12.19 |
[NEXT] 마지막 갠 과제에 쓴 거 정리 (1) (1) | 2024.12.18 |
[NEXT]주요렌더링 (4) | 2024.12.09 |