my-next-server
라는 이름으로 next.js 서버를 만들어보자.
github 주소 : https://github.com/odada-o/-template-next-js-crud
npx create-next-app ./
브라우저에서 http://localhost:3000/api/hello로 접속했을 때, 안녕하세요!라는 메시지를 JSON 형식으로 응답하는 서버를 만들어봅시다.
app/api/hello/route.ts
파일을 생성합니다.GET()
, POST()
, PUT()
, DELETE()
함수를 내보내는 파일입니다.my-next-server/
├── app/
│ └── api/
│ └── hello/
│ └── route.js
│ └── hello/
│ └── page.js
GET()
함수는 HTTP GET 요청을 처리하는 함수입니다.NextResponse
는 Next.js에서 응답을 생성하는 함수입니다.NextResponse.json()
함수는 JSON 형식의 응답을 생성하는 함수입니다.// src/app/api/hello/route.ts
import { NextResponse } from 'next/server';
export const helloPosts = [
{ id: 1, title: "안녕1" },
{ id: 2, title: "안녕2" }
];
// GET /api/hello 주소로 요청이 오면 실행되는 함수
// async 키워드를 사용하여 비동기 함수로 만듭니다
export async function GET() {
// 클라이언트에게 JSON 응답을 반환합니다
// return NextResponse.json({ message: '안녕하세요!' });
return NextResponse.json(helloPosts);
}
http://localhost:3000/api/hello
로 GET 요청을 보내면, {"message":"안녕하세요!"}
가 출력됩니다.my-next-server/
├── app/
│ ├── api/
│ │ ├── posts/
│ │ │ ├── route.js # 전체 게시글 API
│ │ │ └── [id]/
│ │ │ └── route.js # 개별 게시글 API
│ ├── posts/
│ │ ├── page.js # 게시글 목록
│ │ ├── write/
│ │ │ └── page.js # 글쓰기 페이지
│ │ └── [id]/
│ │ ├── page.js # 상세 페이지
│ │ └── edit/
│ │ └── page.js # 수정 페이지
└── data/
└── posts.js # 임시 데이터 저장소
먼저 axios를 설치합니다:
npm install axios
// data/posts.js
export const posts = [
{ id: 1, title: '첫 번째 글', content: '안녕하세요!', createdAt: '2024-01-01' },
{ id: 2, title: '두 번째 글', content: '반갑습니다!', createdAt: '2024-01-02' }
];
이제 axios를 사용하여 게시글을 관리하는 API를 만들어봅시다.
axios는 fetch보다 더 간단하게 HTTP 요청을 처리할 수 있게 해주는 라이브러리입니다.
// app/api/posts/route.js
import { NextResponse } from 'next/server';
import axios from 'axios';
import posts from '@/data/posts';
// 전체 게시글 조회 - GET 요청 처리
// 게시글 목록 페이지로 이동하면 실행됨
export async function GET() {
try {
// 만약 api 서버로 요청을 보내서 게시글 목록을 가져오고 싶다면
// const response = await axios.get('https://jsonplaceholder.typicode.com/posts');
// const posts = response.data;
// 로컬 데이터를 바로 반환합니다
return NextResponse.json(posts);
} catch (error) {
// 에러가 발생하면 에러 메시지와 함께 500 상태 코드 반환
return NextResponse.json(
{ error: '게시글을 불러오는데 실패했습니다.' },
{ status: 500 }
);
}
}
// 새 게시글 작성 - POST 요청 처리
// 글쓰기 페이지에서 제출하면 실행됨
export async function POST(req) {
// 글을 작성하면 req 객체에는 다음과 같은 정보가 들어있습니다
// {
// headers: Headers { host: 'localhost:3000', 'content-type': 'application/json', ... },
// method: 'POST',
// url: 'http://localhost:3000/api/posts',
// body: { title: '새 글', content: '내용입니다' }
// (단, 직접 접근은 불가능하며 req.json()으로 파싱해야 함)
// }
try {
// 요청 본문에서 데이터 추출
// data = { title: '새 글', content: '새 글 내용입니다' }
const data = await req.json();
// 제목이나 내용이 없으면 400 에러 반환
if (!data.title || !data.content) {
return NextResponse.json(
{ error: '제목과 내용은 필수입니다.' },
{ status: 400 } // 400: Bad Request
);
}
// newPost 객체 생성
const newPost = {
id: posts.length + 1,
title: data.title,
content: data.content,
createdAt: new Date().toLocaleDateString()
};
// 서버의 데이터 베이스(posts)에 새 게시글 추가
posts.push(newPost);
// 클라이언트에게 새 게시글 반환
return NextResponse.json(newPost, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: '게시글 작성에 실패했습니다.' },
{ status: 500 }
);
}
}
Next.js의 req
객체는 다음과 같은 주요 정보를 포함합니다:
headers
: 요청 헤더 정보 (Content-Type 등)method
: HTTP 메서드 (POST)url
: 요청 URLbody
: 요청 본문 (단, 직접 접근은 불가능하며 req.json()으로 파싱해야 함)query
: 쿼리 스트링 정보Request headers: Headers {
host: 'localhost:3000',
'content-type': 'application/json',
...
}
Request method: POST
Request URL: http://localhost:3000/api/posts
Parsed data: { title: '새 글', content: '새 글 내용입니다' }
GET http://localhost:3000/api/posts
입력POST http://localhost:3000/api/posts
입력{
"title": "세 번째 게시글",
"content": "반가워요!"
}
이제 개별 게시글에 대한 조회/수정/삭제 API를 구현해봅시다.
GET /api/posts/[id]
: 특정 게시글 조회PUT /api/posts/[id]
: 게시글 수정DELETE /api/posts/[id]
: 게시글 삭제params
객체params
객체는 URL에서 동적으로 변하는 값을 담는 객체입니다./api/posts/1 → params.id는 "1"
/api/posts/2 → params.id는 "2"
/api/posts/99 → params.id는 "99"
/api/posts/[id]/route.js
여기서 [id]라는 폴더 이름이면 Next.js는 대괄호([]) 안에 있는 이름을 params의 속성으로 만들어줍니다.
export async function GET({ params }) {
console.log(params); // { id: '1' } 이런 식으로 출력됨
console.log(params.id); // '1' 처럼 해당 값만 출력
// 문자열로 오기 때문에 숫자로 변환해서 사용
// posts 배열에서 id와 일치하는 게시글 찾기
const post = posts.find(post => post.id === parseInt(params.id));
...
}
쉽게 말해서 params
는 URL의 변하는 부분을 손쉽게 가져다 쓸 수 있게 해주는 도구입니다.
// app/api/posts/[id]/route.js
import { NextResponse } from 'next/server';
import posts from '@/data/posts';
// 특정 게시글 조회 - GET 요청 처리
// 게시글 상세 페이지로 이동하면 실행됨
// response 인수 대신 params 인수를 사용하여 URL 파라미터를 전달받음
export async function GET(request, { params }) {
// params = { id: '1' }
try {
// URL 파라미터로 전달된 id 값과 일치하는 게시글 찾기
const post = posts.find(post => post.id === parseInt(params.id));
// 게시글이 없을 경우 404 응답
if (!post) {
return NextResponse.json(
{ error: '게시글을 찾을 수 없습니다.' },
{ status: 404 }
);
}
return NextResponse.json(post);
} catch (error) {
return NextResponse.json(
{ error: '게시글을 불러오는데 실패했습니다.' },
{ status: 500 }
);
}
}
// 게시글 수정 - PUT 요청 처리
// 수정할 내용을 입력하고 PUT 요청을 보내면 실행됨
export async function PUT(req, { params }) {
try {
const data = await req.json();
// data = { title: '수정된 제목', content: '수정된 내용' }
// id와 일치하는 게시글의 인덱스 찾기
const index = posts.findIndex(post => post.id === parseInt(params.id));
if (index === -1) {
return NextResponse.json(
{ error: '게시글을 찾을 수 없습니다.' },
{ status: 404 }
);
}
// posts = [
// { id: 1, title: '첫글' }, // p.id === 1 비교 -> true
// { id: 2, title: '둘째글' }, // 여기까지 안 감
// { id: 3, title: '셋째글' } // 여기까지 안 감
// ]
// 첫번째 요소 에서 p.id === 1 비교 -> true 가 되므로
// index = 0 이 됨
posts[index] = {
...posts[index],
title: data.title || posts[index].title,
content: data.content || posts[index].content
};
// 게시글 업데이트 - 제목이나 내용이 없으면 기존 값 유지
// posts[0] =
// {
// id: 1, title: '첫 번째 글', content: '안녕하세요!', createdAt: '2024-01-01',
// title: '수정된 제목',
// content: '수정된 내용'
// }
// 클라이언트에게 수정된 게시글 (post[0]) 반환
return NextResponse.json(posts[index], { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: '게시글 수정에 실패했습니다.' },
{ status: 500 }
);
}
}
// 게시글 삭제 - DELETE 요청 처리
export async function DELETE(req, { params }) {
try {
// id와 일치하는 게시글의 인덱스 찾기
const index = posts.findIndex(p => p.id === parseInt(params.id));
if (index === -1) {
return NextResponse.json(
{ error: '게시글을 찾을 수 없습니다.' },
{ status: 404 }
);
}
// 게시글 삭제
// slice() 함수는 배열의 일부를 추출하여 새로운 배열을 만듭니다
// splice(시작 인덱스, 삭제할 요소 개수) 함수는 배열에서 요소를 삭제합니다
posts.splice(index, 1);
return NextResponse.json({ message: '게시글이 삭제되었습니다.' });
} catch (error) {
return NextResponse.json(
{ error: '게시글 삭제에 실패했습니다.' },
{ status: 500 }
);
}
}
GET http://localhost:3000/api/posts/1
요청으로 특정 게시글 조회PUT http://localhost:3000/api/posts/1
Body:
{
"title": "수정된 제목",
"content": "수정된 내용"
}
DELETE http://localhost:3000/api/posts/1
요청으로 게시글 삭제이제 axios를 사용하여 API와 통신하는 클라이언트 페이지들을 만들어봅시다.
/data/posts.js
로 직접 접근하면/api/posts
API Route 로 접근하면posts.js
데이터를 안전하게 불러와서 클라이언트에 전달합니다// data/posts.js (서버의 데이터 파일)
export const posts = [
{ id: 1, title: "글1" },
{ id: 2, title: "글2" }
];
// api/posts/route.js (API Route)
import { posts } from '@/data/posts';
// 이 파일이 `/api/posts` 엔드포인트로 요청되면
// 데이터 파일에서 데이터를 불러와서 클라이언트에 전달합니다
export async function GET() {
return NextResponse.json(posts);
}
// 클라이언트 컴포넌트
// 브라우저에서 `/api/posts` 로 GET요청을 보냅니다.
// 위 요청이 위의 GET함수로 전달됩니다.
axios.get('/api/posts')
/posts
)axios
로 받은 응답에는 여러 정보가 포함되어 있는데, 실제 데이터는 data
속성에 들어있습니다.{
data: {
// 실제 서버에서 받은 데이터
title: "게시글 제목",
content: "게시글 내용"
},
status: 200, // HTTP 상태 코드
statusText: "OK", // 상태 메시지
headers: {}, // 응답 헤더
config: {} // 요청 설정
}
// app/posts/page.js
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; // 페이지 이동을 위한 라우터
import axios from 'axios';
export default function PostsPage() {
const router = useRouter(); // 라우터 객체
const [posts, setPosts] = useState([]); // 게시글 상태
const [loading, setLoading] = useState(true); // 로딩 상태
useEffect(() => {
// axios.get().then().catch()으로 비동기 처리
axios
.get('/api/posts') // 브라우저에서 /api/posts로 GET 요청을 보냅니다
.then((res) => {
setPosts(res.data); // 데이터를 상태에 저장
setLoading(false); // 로딩 시 false로 변경
})
.catch((error) => {
console.error('Error:', error);
setLoading(false);
});
}, []);
const handleDelete = async (id) => {
// 삭제를 취소하면 함수 종료
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const res = await axios.delete(`/api/posts/${id}`); // 브라우저에서 /api/posts/1로 DELETE 요청을 보냅니다
// 서버에서 응답이 오면
if (res.status === 200) {
setPosts(posts.filter((post) => post.id !== id)); // 삭제된 게시글 제외
} else {
alert('삭제에 실패했습니다.');
}
} catch (error) {
alert('오류가 발생했습니다.');
}
};
// 상세 페이지로 이동하는 함수
const handlePostClick = (id) => {
router.push(`/posts/${id}`);
};
if (loading) return <div>로딩 중...</div>;
return (
<div>
<h1>게시글 목록</h1>
<Link href="/posts/write">글쓰기</Link>
<div>
{posts.map((post) => (
<Link
key={post.id}
href={`/posts/${post.id}`}
className="cursor-pointer block" // block 추가하여 전체 영역 클릭 가능하게
>
<h2>{post.title}</h2>
<p>{post.content}</p>
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
</Link>
))}
</div>
</div>
);
}
/posts/write
)// app/posts/write/page.js
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import axios from 'axios';
export default function WritePage() {
const router = useRouter();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
try {
const res = await axios.post('/api/posts', { title, content });
if (res.status === 201) { // HTTP 201 Created
router.push('/posts');
} else {
alert('글 작성에 실패했습니다.');
}
} catch (error) {
console.error('Error:', error);
alert('오류가 발생했습니다.');
}
};
return (
<div>
<h1>글쓰기</h1>
<form onSubmit={handleSubmit}>
<div>
<label>제목</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div>
<label>내용</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
required
/>
</div>
<button type="button" onClick={() => router.back()}>취소</button>
<button type="submit">등록</button>
</form>
</div>
);
}
/posts/[id]
)params
의 상태// pages/posts/[id]/page.jsx 파일에서
// URL이 /posts/1 이라면
// params는 이런 형태의 Promise
params = Promise.resolve({ id: '1' })
// 바로 params.id 접근 불가
// use() 훅이 Promise를 풀어서(unwrap) 일반 객체로 변환
const resolvedParams = use(params);
// resolvedParams는 이제 일반 객체가 됨
resolvedParams = { id: '1' }
// 이제 resolvedParams.id 접근 가능
// ❌ 잘못된 방법
console.log(params.id) // undefined
// ✅ 올바른 방법
const resolvedParams = use(params);
console.log(resolvedParams.id) // '1'
// API 호출할 때도
axios.get(`/api/posts/${resolvedParams.id}`) // OK!
// app/posts/[id]/page.js
'use client';
import { useState, useEffect, use } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import axios from 'axios';
export default function PostDetailPage({ params }) {
const router = useRouter();
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const resolvedParams = use(params); // params 객체를 풀어서 사용
useEffect(() => {
axios
.get(`/api/posts/${resolvedParams.id}`)
.then((res) => {
setPost(res.data);
setLoading(false);
})
.catch((error) => {
console.error('Error:', error);
setLoading(false);
alert('게시글을 불러올 수 없습니다.');
router.push('/posts');
});
}, [resolvedParams.id, router]);
const handleDelete = async () => {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const res = await axios.delete(`/api/posts/${resolvedParams.id}`);
if (res.status === 200) {
router.push('/posts');
} else {
alert('삭제에 실패했습니다.');
}
} catch (error) {
alert('오류가 발생했습니다.');
}
};
if (loading) return <div>로딩 중...</div>;
if (!post) return <div>게시글을 찾을 수 없습니다.</div>;
return (
<div>
<h1>{post.title}</h1>
<p>작성일: {post.createdAt}</p>
<div>
<p>{post.content}</p>
</div>
<div>
<Link href="/posts">목록</Link>
<Link href={`/posts/${resolvedParams.id}/edit`}>수정</Link>
<button onClick={handleDelete}>삭제</button>
</div>
</div>
);
}
/posts/[id]/edit
)// app/posts/[id]/edit/page.js
"use client"
import React, { use, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import axios from 'axios'
const EditPage = ({params}) => {
const router = useRouter()
const resolvedParams = use(params)
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
useEffect(() => {
// 게시글 불러오기
axios
.get(`/api/posts/${resolvedParams.id}`)
.then((res) => {
// res = { data: { title: '제목', content: '내용' } }
setTitle(res.data.title)
setContent(res.data.content)
})
.catch((error) => {
console.error(error)
router.push('/posts')
})
}, [resolvedParams.id, router])
const handleSubmit = async (e) => {
e.preventDefault()
try {
const res = await axios.put(`/api/posts/${resolvedParams.id}`, {title, content})
if (res.status === 201) {
alert('글수정 완료')
router.push('/posts')
} else {
alert('글수정 실패')
}
} catch (error) {
console.error(error)
alert('오류 발생')
}
}
return (
<div className='container mx-auto'>
<h2 className='sr-only'>포스트 글쓰기</h2>
<form onSubmit={handleSubmit} className='flex flex-col gap-5 h-screen'>
{/* 제목 */}
<div>
<label htmlFor="tit" className='sr-only'>제목</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
name="tit" id="tit"
placeholder='제목을 입력하세요.'
className='text-5xl font-black py-5 border-b-4 border-gray-400 w-full' />
</div>
{/* 본문 */}
<div className='flex-1'>
<label htmlFor="cont" className='sr-only'>내용</label>
<textarea
name="cont" id="cont"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder='당신의 이야기를 적어보세요.'
className='w-full h-full text-2xl'></textarea>
</div>
{/* 확인, 취소 */}
<div className='border-t-2 border-gray-300 flex justify-end'>
<button className='p-7 bg-gray-400'>취소</button>
<button type='submit' className='p-7 bg-purple-400'>등록</button>
</div>
</form>
</div>
)
}
export default EditPage