my-next-server
라는 이름으로 next.js 서버를 만들어보자.
github 주소 : https://github.com/odada-o/-template-next-js-crud
1. Next.js 설치
npx create-next-app ./
2. 간단한 서버 만들기
브라우저에서 http://localhost:3000/api/hello로 접속했을 때, 안녕하세요!라는 메시지를 JSON 형식으로 응답하는 서버를 만들어봅시다.
API Route 파일 생성
app/api/hello/route.ts
파일을 생성합니다.- API Route 파일은
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);
}
test
http://localhost:3000/api/hello
로 GET 요청을 보내면,{"message":"안녕하세요!"}
가 출력됩니다.
3. CRUD API 만들기
3-1. 파일 구조
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 # 임시 데이터 저장소
3-2. 게시글 API 만들기
먼저 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' }
];
API Route 파일 생성
이제 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: '새 글 내용입니다' }
3-3. Thunder Client로 API 테스트하기
1. Thunder Client 설치
- VSCode의 확장 프로그램에서 Thunder Client 설치
- 왼쪽 사이드바에 번개 모양 아이콘이 생성됩니다
2. GET 요청 테스트
- Thunder Client 아이콘 클릭
- 'New Request' 클릭
GET http://localhost:3000/api/posts
입력- Send 버튼 클릭
- 응답으로 게시글 목록이 표시됩니다
3. POST 요청 테스트
- 'New Request' 클릭
POST http://localhost:3000/api/posts
입력- Body 탭 선택 후 JSON 형식 선택
- 아래 내용 입력:
{ "title": "세 번째 게시글", "content": "반가워요!" }
- Send 버튼 클릭
- 새로운 게시글이 생성되고 응답으로 반환됩니다
4. 게시글 상세 API 추가
이제 개별 게시글에 대한 조회/수정/삭제 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]);
} 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 }
);
}
}
Thunder Client로 테스트하기
GET 요청
GET http://localhost:3000/api/posts/1
요청으로 특정 게시글 조회
PUT 요청
PUT http://localhost:3000/api/posts/1
Body:
{
"title": "수정된 제목",
"content": "수정된 내용"
}
DELETE 요청
DELETE http://localhost:3000/api/posts/1
요청으로 게시글 삭제
5. 클라이언트 페이지 구현하기
이제 axios를 사용하여 API와 통신하는 클라이언트 페이지들을 만들어봅시다.
5-0. API Route(/api/posts) 와 데이터 파일(/data/posts.js)의 차이점
/data/posts.js
로 직접 접근하면
- 이는 실제 소스 코드 파일입니다
- 보안상 클라이언트에서 직접 접근할 수 없습니다
- 서버의 파일 시스템에 있는 실제 파일입니다
/api/posts
API Route 로 접근하면
- 이는 서버에서 실행되는 엔드포인트입니다
- 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')
5-1. 글 목록 페이지 (/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>
);
}
5-2. 글쓰기 페이지 (/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>
);
}
5-3. 글 상세 페이지 (/posts/[id]
)
const resolvedParams = use(params);
params
의 상태// pages/posts/[id]/page.jsx 파일에서 // URL이 /posts/1 이라면
// params는 이런 형태의 Promise
params = Promise.resolve({ id: '1' })
// 바로 params.id 접근 불가
2. use() 훅의 역할
```javascript
// 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!
```typescript
// 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>
);
}
5-4. 글 수정 페이지 (/posts/[id]/edit
)
// app/posts/[id]/edit/page.js
import EditForm from "./editForm";
export default function EditPage({ params }) {
return <EditForm postId={params.id} />;
}
// app/posts/[id]/edit/EditForm.js
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import axios from 'axios';
export default function EditForm({ postId }) {
const router = useRouter();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
useEffect(() => {
const fetchPost = async () => {
try {
const { data } = await axios.get(`/api/posts/${postId}`);
setTitle(data.title);
setContent(data.content);
} catch (error) {
console.error('Error fetching post:', error);
alert('게시글을 불러올 수 없습니다.');
router.push('/posts');
}
};
fetchPost();
}, [postId, router]);
const handleSubmit = async (e) => {
e.preventDefault();
try {
await axios.put(`/api/posts/${postId}`, { title, content });
router.push('/posts');
} catch (error) {
console.error('Error updating post:', error);
alert('수정에 실패했습니다.');
}
};
return (
<div className="p-4 max-w-xl mx-auto">
<h1 className="text-2xl font-bold mb-4">글 수정</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="block font-medium">제목</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
className="w-full p-2 border rounded"
/>
</div>
<div className="space-y-2">
<label className="block font-medium">내용</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
required
className="w-full p-2 border rounded h-32"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
취소
</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 모듈과 객체 (0) | 2024.12.25 |
---|---|
자바스크립트 비동기 처리 (2) | 2024.12.25 |
node.js로 API 통신 구현하기 (0) | 2024.12.09 |
Express 모듈을 사용하여 서버 만들기 (1) | 2024.12.09 |
node.js로 서버 만들기 (0) | 2024.12.09 |