Front/Node.js

next.js로 CRUD API 서버 만들기

oodada 2024. 12. 9. 22:54

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>
  );
}
티스토리 친구하기