next.js로 CRUD API 서버 만들기
my-next-server
라는 이름으로 next.js 서버를 만들어보자.
1. next.js 설치
npx create-next-app ./
- 파일 구조
nextjs-server/
├── app/
│ ├── api/
│ │ └── hello/
│ │ └── route.ts # API Route 파일
│ | └── posts/
│ | └── route.ts # API Route 파일
└── package.json
3. Next.js에서 서버 기능 구현하기
3-1. 간단한 서버 만들기
브라우저에서 http://localhost:3000/api/hello로 접속했을 때, 안녕하세요!라는 메시지를 JSON 형식으로 응답하는 서버를 만들어봅시다.
API Route 파일 생성
// src/app/api/hello/route.ts
import { NextResponse } from 'next/server';
// GET /api/hello 주소로 요청이 오면 실행
export async function GET() {
// 클라이언트에게 JSON 응답
return NextResponse.json({ message: '안녕하세요!' });
}
GET()
함수는 HTTP GET 요청을 처리하는 함수NextResponse.json()
함수는 JSON 형식의 응답을 생성
test
http://localhost:3000/api/hello
로 GET 요청을 보내면,{"message":"안녕하세요!"}
가 출력됩니다.
3-2. 사용자를 관리하는 RESTful API 만들기
사용자 데이터를 관리할 수 있는 간단한 API를 만들어보자.
- `GET 요청`: 사용자 목록을 가져옴.
- `POST 요청`: 새로운 사용자를 추가함.
API Route 파일 생성
// src/app/api/posts/route.ts
import { NextResponse } from 'next/server';
// 임시 데이터 저장소
let posts = [
{ id: 1, title: '첫 번째 게시글', content: '안녕하세요!', createdAt: '2024-01-01' },
{ id: 2, title: '두 번째 게시글', content: '반갑습니다!', createdAt: '2024-01-02' }
];
// GET - 전체 게시글 조회
export async function GET() {
return NextResponse.json(posts);
}
// POST - 새 게시글 추가
export async function POST(request: Request) {
// 요청 데이터를 JSON으로 파싱
const data = await request.json();
// 새 게시글 생성
const newPost = {
id: posts.length + 1,
title: data.title,
content: data.content,
createdAt: new Date().toISOString().split('T')[0]
};
// 게시글 목록에 추가
posts.push(newPost);
// 새 게시글 응답
// 상태 코드 201(Created)로 응답
return NextResponse.json(newPost, { status: 201 });
}
- GET: 사용자 배열을 반환
- POST: 클라이언트가 보내준 데이터를 추가하고 새로운 사용자 객체를 반환
test
http://localhost:3000/api/users
로 GET 요청을 보내면, 사용자 목록이 출력됩니다.
3-3. Thunder Client 사용한 테스트
Thunder Client 설치
- vscode의 확장 프로그램인 Thunder Client 설치
GET 요청 테스트
- 왼쪽 번개 아이콘 클릭
- 'New Request' 클릭
GET http://localhost:3000/api/posts
입력- Send 버튼 클릭
POST 요청 테스트
- 'New Request' 클릭
POST http://localhost:3000/api/posts
입력- Body 탭에서 JSON 선택
{"title": "세 번째 게시글", "content": "반가워요!"}
입력- Send 버튼 클릭
- POST 요청을 보내면, 새로운 사용자가 추가되고, 새로운 사용자 객체가 응답됩니다.
4. 에러 처리하기
4-1. 404 Not Found 응답
- 글이 없는 경우, 404 Not Found 응답을 보내도록 수정
// src/app/api/posts/route.ts
import { NextResponse } from 'next/server';
interface Post {
id: number;
title: string;
content: string;
createdAt: string;
}
// 임시 데이터 저장소
let posts: Post[] = [
{ id: 1, title: '첫 번째 게시글', content: '안녕하세요!', createdAt: '2024-01-01' },
{ id: 2, title: '두 번째 게시글', content: '반갑습니다!', createdAt: '2024-01-02' }
];
// GET - 전체 게시글 조회
export async function GET() {
try {
return NextResponse.json(posts);
} catch (error) {
return NextResponse.json(
{ error: '게시글을 불러오는데 실패했습니다.' },
{ status: 500 }
);
}
}
// POST - 새 게시글 추가
export async function POST(request: Request) {
// 요청 데이터를 JSON으로 파싱
try {
const data = await request.json();
// 유효성 검사
if (!data.title) {
return NextResponse.json(
{ error: '제목은 필수입니다.' },
{ status: 400 }
);
}
if (!data.content) {
return NextResponse.json(
{ error: '내용은 필수입니다.' },
{ status: 400 }
);
}
// 제목 길이 검사
if (data.title.length > 100) {
return NextResponse.json(
{ error: '제목은 100자를 초과할 수 없습니다.' },
{ status: 400 }
);
}
// 새 게시글 생성
const newPost: Post = {
id: posts.length + 1,
title: data.title.trim(),
content: data.content.trim(),
createdAt: new Date().toISOString().split('T')[0]
};
// 게시글 목록에 추가
posts.push(newPost);
return NextResponse.json(newPost, { status: 201 });
}
// 게시글 추가 실패
catch (error) {
return NextResponse.json(
{ error: '게시글 작성에 실패했습니다.' },
{ status: 500 }
);
}
}
- GET 요청 시, 게시글을 불러오는데 실패하면 500 Internal Server Error 응답을 보냄
- POST 요청 시, 제목이나 내용이 없거나 제목이 100자를 초과하면 400 Bad Request 응답을 보냄
Thunder Client
를 사용한 테스트
POST http://localhost:3000/api/posts
Body:
{
"content": "내용만 있는 게시글"
}
5. 특정 사용자 조회/수정/삭제 API 추가
// src/app/api/posts/[id]/route.ts
import { NextResponse } from 'next/server';
// 게시글 데이터는 posts/route.ts에서 불러와야 하지만, 예시를 위해 여기서도 정의
interface Post {
id: number;
title: string;
content: string;
createdAt: string;
}
const posts: Post[] = [
{ id: 1, title: '첫 번째 게시글', content: '안녕하세요!', createdAt: '2024-01-01' },
{ id: 2, title: '두 번째 게시글', content: '반갑습니다!', createdAt: '2024-01-02' }
];
// GET - 특정 게시글 조회
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const post = posts.find(p => p.id === parseInt(params.id));
if (!post) {
return NextResponse.json(
{ error: '게시글을 찾을 수 없습니다.' },
{ status: 404 }
);
}
return NextResponse.json(post);
} catch (error) {
return NextResponse.json(
{ error: '게시글을 불러오는데 실패했습니다.' },
{ status: 500 }
);
}
}
// PUT - 게시글 수정
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const data = await request.json();
const index = posts.findIndex(p => p.id === parseInt(params.id));
if (index === -1) {
return NextResponse.json(
{ error: '게시글을 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 유효성 검사
if (data.title && data.title.length > 100) {
return NextResponse.json(
{ error: '제목은 100자를 초과할 수 없습니다.' },
{ status: 400 }
);
}
// 기존 게시글 업데이트
posts[index] = {
...posts[index],
title: data.title?.trim() ?? posts[index].title,
content: data.content?.trim() ?? posts[index].content
};
return NextResponse.json(posts[index]);
} catch (error) {
return NextResponse.json(
{ error: '게시글 수정에 실패했습니다.' },
{ status: 500 }
);
}
}
// DELETE - 게시글 삭제
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const index = posts.findIndex(p => p.id === parseInt(params.id));
if (index === -1) {
return NextResponse.json(
{ error: '게시글을 찾을 수 없습니다.' },
{ status: 404 }
);
}
posts.splice(index, 1);
return NextResponse.json(
{ message: '게시글이 삭제되었습니다.' }
);
} catch (error: unknown) {
return NextResponse.json(
{ error: '게시글 삭제에 실패했습니다.' },
{ status: 500 }
);
}
}
- GET: 특정 사용자 조회
- PUT: 특정 사용자 수정
- DELETE: 특정 사용자 삭제
Thunder Client
를 사용한 테스트
GET 요청
http://localhost:3000/api/posts/1
페이지 확인
PUT 요청
PUT http://localhost:3000/api/posts/1
Body:
{
"title": "수정된 제목",
"content": "수정된 내용"
}
DELETE 요청
PUT http://localhost:3000/api/posts/1
6. 파일 분리하기
- API Route 파일을 분리하여 코드를 정리
파일 구조
src/
├── app/
│ ├── api/
│ │ ├── posts/
│ │ │ ├── route.ts
│ │ │ ├── [id]/
│ │ │ │ └── route.ts
├── types/
│ └── post.ts
├── data/
│ └── posts.ts
├── lib/
│ ├── utils.ts
│ └── postService.ts
type
파일 생성
// src/types/post.ts
export interface Post {
id: number;
title: string;
content: string;
createdAt: string;
}
export interface CreatePostInput {
title: string;
content: string;
}
export interface UpdatePostInput {
title?: string;
content?: string;
}
data
파일 생성
// src/data/posts.ts
import { Post } from '@/types/post';
export const posts: Post[] = [
{ id: 1, title: '첫 번째 게시글', content: '안녕하세요!', createdAt: '2024-01-01' },
{ id: 2, title: '두 번째 게시글', content: '반갑습니다!', createdAt: '2024-01-02' }
];
lib
파일 생성
// src/lib/utils.ts
import { CreatePostInput } from '@/types/post';
export function validatePost({ title, content }: CreatePostInput) {
if (!title) {
return { error: '제목은 필수입니다.' };
}
if (!content) {
return { error: '내용은 필수입니다.' };
}
if (title.length > 100) {
return { error: '제목은 100자를 초과할 수 없습니다.' };
}
return null;
}
// src/lib/postService.ts
import { Post, CreatePostInput, UpdatePostInput } from '@/types/post';
import { posts } from '@/data/posts';
export const postService = {
getAllPosts(): Post[] {
return posts;
},
getPostById(id: number): Post | undefined {
return posts.find(p => p.id === id);
},
createPost({ title, content }: CreatePostInput): Post {
const newPost: Post = {
id: posts.length + 1,
title: title.trim(),
content: content.trim(),
createdAt: new Date().toISOString().split('T')[0]
};
posts.push(newPost);
return newPost;
},
updatePost(id: number, { title, content }: UpdatePostInput): Post | null {
const index = posts.findIndex(p => p.id === id);
if (index === -1) return null;
posts[index] = {
...posts[index],
title: title?.trim() ?? posts[index].title,
content: content?.trim() ?? posts[index].content
};
return posts[index];
},
deletePost(id: number): boolean {
const index = posts.findIndex(p => p.id === id);
if (index === -1) return false;
posts.splice(index, 1);
return true;
}
};
API Route 파일 수정
// src/app/api/posts/route.ts
import { NextResponse } from 'next/server';
import { postService } from '@/lib/postService';
import { validatePost } from '@/lib/utils';
export async function GET() {
try {
const posts = postService.getAllPosts();
return NextResponse.json(posts);
} catch (error) {
return NextResponse.json(
{ error: '게시글을 불러오는데 실패했습니다.' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const data = await request.json();
const validationError = validatePost(data);
if (validationError) {
return NextResponse.json(validationError, { status: 400 });
}
const newPost = postService.createPost(data);
return NextResponse.json(newPost, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: '게시글 작성에 실패했습니다.' },
{ status: 500 }
);
}
}
// src/app/api/posts/[id]/route.ts
import { NextResponse } from 'next/server';
import { postService } from '@/lib/postService';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const post = postService.getPostById(parseInt(params.id));
if (!post) {
return NextResponse.json(
{ error: '게시글을 찾을 수 없습니다.' },
{ status: 404 }
);
}
return NextResponse.json(post);
} catch (error) {
return NextResponse.json(
{ error: '게시글을 불러오는데 실패했습니다.' },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const data = await request.json();
const post = postService.updatePost(parseInt(params.id), data);
if (!post) {
return NextResponse.json(
{ error: '게시글을 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 제목 길이 검사
if (data.title && data.title.length > 100) {
return NextResponse.json(
{ error: '제목은 100자를 초과할 수 없습니다.' },
{ status: 400 }
);
}
return NextResponse.json(post);
} catch (error) {
return NextResponse.json(
{ error: '게시글 수정에 실패했습니다.' },
{ status: 500 }
);
}
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const success = postService.deletePost(parseInt(params.id));
if (!success) {
return NextResponse.json(
{ error: '게시글을 찾을 수 없습니다.' },
{ status: 404 }
);
}
return NextResponse.json(
{ message: '게시글이 삭제되었습니다.' }
);
} catch (error) {
return NextResponse.json(
{ error: '게시글 삭제에 실패했습니다.' },
{ status: 500 }
);
}
}
7. 페이지 구현하기
7-1. 글 목록 페이지 (/posts
)
// src/app/posts/page.tsx
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Post } from '@/types/post';
export default function PostsPage() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPosts = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/posts');
if (!response.ok) {
throw new Error('게시글을 불러오는데 실패했습니다.');
}
const data = await response.json();
setPosts(data);
} catch (err) {
setError(err instanceof Error ? err.message : '오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
fetchPosts();
}, []);
const handleDelete = async (id: number) => {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const response = await fetch(`/api/posts/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('삭제에 실패했습니다.');
}
setPosts(posts.filter(post => post.id !== id));
} catch (err) {
alert(err instanceof Error ? err.message : '오류가 발생했습니다.');
}
};
if (loading) return (
<div className="flex justify-center items-center min-h-screen">
<div className="text-xl">로딩 중...</div>
</div>
);
if (error) return (
<div className="flex justify-center items-center min-h-screen">
<div className="text-red-500">{error}</div>
</div>
);
return (
<div className="max-w-4xl mx-auto p-4">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">게시글 목록</h1>
<Link
href="/posts/write"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
>
글쓰기
</Link>
</div>
{posts.length === 0 ? (
<div className="text-center text-gray-500 py-10">
게시글이 없습니다.
</div>
) : (
<div className="space-y-4">
{posts.map((post) => (
<div key={post.id} className="border p-4 rounded-lg shadow hover:shadow-md transition-shadow">
<Link href={`/posts/${post.id}`}>
<div className="cursor-pointer">
<h2 className="text-xl font-semibold mb-2">{post.title}</h2>
<p className="text-gray-600 mb-4 line-clamp-2">{post.content}</p>
</div>
</Link>
<div className="flex justify-between items-center text-sm text-gray-500">
<span>{post.createdAt}</span>
<div className="space-x-2">
<Link
href={`/posts/${post.id}/edit`}
className="text-blue-500 hover:underline"
>
수정
</Link>
<button
onClick={() => handleDelete(post.id)}
className="text-red-500 hover:underline"
>
삭제
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
7-2. 글쓰기 페이지 (/posts/write
)
// src/app/posts/write/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function WritePage() {
const router = useRouter();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, content }),
});
if (response.ok) {
router.push('/posts');
} else {
const data = await response.json();
alert(data.error || '글 작성에 실패했습니다.');
}
};
return (
<div className="max-w-4xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">글쓰기</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">제목</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-2 border rounded"
required
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium mb-1">내용</label>
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full p-2 border rounded h-40"
required
/>
</div>
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 border rounded"
>
취소
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
등록
</button>
</div>
</form>
</div>
);
}
7-3. 글 상세 페이지 (/posts/[id]
)
// src/app/posts/[id]/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { use } from 'react';
import Link from 'next/link';
import { Post } from '@/types/post';
export default function PostDetailPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const [post, setPost] = useState<Post | null>(null);
const resolvedParams = use(params);
useEffect(() => {
const fetchPost = async () => {
const response = await fetch(`/api/posts/${resolvedParams.id}`);
if (response.ok) {
const data = await response.json();
setPost(data);
} else {
alert('게시글을 불러올 수 없습니다.');
router.push('/posts');
}
};
fetchPost();
}, [resolvedParams.id, router]);
const handleDelete = async () => {
if (confirm('정말 삭제하시겠습니까?')) {
const response = await fetch(`/api/posts/${resolvedParams.id}`, {
method: 'DELETE',
});
if (response.ok) {
router.push('/posts');
} else {
alert('삭제에 실패했습니다.');
}
}
};
if (!post) return <div>로딩 중...</div>;
return (
<div className="max-w-4xl mx-auto p-4">
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-500 mb-4">
작성일: {post.createdAt}
</div>
<div className="prose max-w-none mb-6">
<p className="whitespace-pre-wrap">{post.content}</p>
</div>
<div className="flex justify-end space-x-2">
<Link
href="/posts"
className="px-4 py-2 text-gray-600 border rounded hover:bg-gray-100"
>
목록
</Link>
<Link
href={`/posts/${post.id}/edit`}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
수정
</Link>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
삭제
</button>
</div>
</div>
</div>
);
}
7-4. 글 수정 페이지 (/posts/[id]/edit
)
// src/app/posts/[id]/edit/page.tsx
// src/app/posts/[id]/edit/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { use } from 'react';
export default function EditPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const resolvedParams = use(params);
useEffect(() => {
const fetchPost = async () => {
const response = await fetch(`/api/posts/${resolvedParams.id}`);
if (response.ok) {
const data = await response.json();
setTitle(data.title);
setContent(data.content);
} else {
alert('게시글을 불러올 수 없습니다.');
router.push('/posts');
}
};
fetchPost();
}, [resolvedParams.id, router]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const response = await fetch(`/api/posts/${resolvedParams.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, content }),
});
if (response.ok) {
router.push('/posts');
} else {
const data = await response.json();
alert(data.error || '수정에 실패했습니다.');
}
};
return (
<div className="max-w-4xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">글 수정</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">제목</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-2 border rounded"
required
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium mb-1">내용</label>
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full p-2 border rounded h-40"
required
/>
</div>
<div className="flex justify-end space-x-2">
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 border rounded"
>
취소
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
수정
</button>
</div>
</form>
</div>
);
}
'Front > Node.js' 카테고리의 다른 글
node.js로 API 통신 구현하기 (0) | 2024.12.09 |
---|---|
Express 모듈을 사용하여 서버 만들기 (1) | 2024.12.09 |
node.js로 서버 만들기 (0) | 2024.12.09 |
Node.js 기본 개념과 특징 (0) | 2024.12.09 |
대한민국 공공데이터 포털 사용 가이드 (0) | 2024.11.20 |