next.js로 CRUD API 서버 만들기 - js버전

odada·2024년 12월 19일
1

node.js

목록 보기
8/11

Next.js로 CRUD API 서버 만들기

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: 요청 URL
  • body: 요청 본문 (단, 직접 접근은 불가능하며 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 요청 테스트

  1. Thunder Client 아이콘 클릭
  2. 'New Request' 클릭
  3. GET http://localhost:3000/api/posts 입력
  4. Send 버튼 클릭
  5. 응답으로 게시글 목록이 표시됩니다

3. POST 요청 테스트

  1. 'New Request' 클릭
  2. POST http://localhost:3000/api/posts 입력
  3. Body 탭 선택 후 JSON 형식 선택
  4. 아래 내용 입력:
{
  "title": "세 번째 게시글",
  "content": "반가워요!"
}
  1. Send 버튼 클릭
  2. 새로운 게시글이 생성되고 응답으로 반환됩니다

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], { 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 }
    );
  }
}

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);

  1. params의 상태
// pages/posts/[id]/page.jsx 파일에서
// URL이 /posts/1 이라면

// params는 이런 형태의 Promise
params = Promise.resolve({ id: '1' }) 
// 바로 params.id 접근 불가
  1. use() 훅의 역할
// use() 훅이 Promise를 풀어서(unwrap) 일반 객체로 변환
const resolvedParams = use(params);

// resolvedParams는 이제 일반 객체가 됨
resolvedParams = { id: '1' }
// 이제 resolvedParams.id 접근 가능
  1. 코드 예시
// ❌ 잘못된 방법
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>
  );
}

5-4. 글 수정 페이지 (/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

0개의 댓글