Next.js와 MongoDB 연동하여 CRUD API 만들기

odada·2024년 12월 25일
0

node.js

목록 보기
11/11

Next.js와 MongoDB 연동하여 CRUD API 만들기

0. 주요 변경사항 정리

  1. MongoDB 연결 설정 추가

    • 환경 변수 (MONGODB_URI) 설정
    • 데이터베이스 연결 유틸리티 생성
    • Mongoose 모델 정의
  2. API 라우트 변경

    • MongoDB CRUD 작업으로 변경
  3. 클라이언트 컴포넌트 수정

    • id 대신 _id 사용
    • 날짜 형식 처리 추가

1. MongoDB 설정

MongoDB Atlas 가입하기

1-1. MongoDB Atlas 설정

1. MongoDB Atlas에서 연결 문자열을 가져오는 방법:

  • "Connect" 버튼을 클릭합니다
  • "Connect your application"을 선택합니다
  • Driver는 "Node.js"를 선택합니다
  • 거기서 보이는 연결 문자열을 복사합니다 (예: mongodb+srv://username:<password>@cluster0...)

2. 프로젝트에 환경 변수 설정:

  • 프로젝트 루트 폴더에 .env 파일을 생성합니다
  • 아래와 같이 작성합니다:
    MONGODB_URI=mongodb+srv://username:<password>@cluster0.xxxxx.mongodb.net/<database>?retryWrites=true&w=majority
  • <password>는 MongoDB Atlas에서 생성한 데이터베이스 사용자의 실제 비밀번호로 교체
  • <database>는 사용하고 싶은 데이터베이스 이름으로 교체 (예: "blog")

3. Network Access 설정:

  • 왼쪽 메뉴에서 "Network Access"를 클릭합니다
  • "Add IP Address" 버튼 클릭
  • 개발 중이므로 "Allow Access from Anywhere"를 선택하고 "0.0.0.0/0" 입력
  • Confirm 버튼 클릭

4. Database Access 설정:

  • 왼쪽 메뉴에서 "Database Access"를 클릭합니다
  • "Add New Database User" 버튼 클릭
  • Username과 Password를 설정합니다 (이 정보는 연결 문자열에 사용됩니다)
  • "Add User" 버튼 클릭

1-2. 프로젝트에 MongoDB 설치

npm install mongodb mongoose

1-3. 환경 변수 설정

.env 파일 생성:

MONGODB_URI=mongodb+srv://<username>:<password>@cluster0.xxxxx.mongodb.net/<database>?retryWrites=true&w=majority

2. MongoDB 연결 설정

  • 연결 유틸리티는 "데이터베이스로 가는 도로" 같은 것
  • 모델은 "데이터를 담을 상자의 설계도" 같은 것

2-1. MongoDB 연결 유틸리티 생성

  • 역할 : 데이터 베이스 연결을 관리
  • 주요 기능
    • MongoDB와의 연결 상태를 관리 : 매 요청마다 새로운 연결을 만들지 않음
    • 연결이 이미 있으면 재사용(캐싱) : 연결을 재사용하여 성능 향상
    • 없으면 새로 연결 : 안정적인 데이터 베이스 연결 유지
  • 작동방식
if (이미_연결되어있나?) {
  기존_연결_재사용();
} else {
  새로운_연결_생성();
}

src/lib/mongodb.js 파일 생성:

import mongoose from 'mongoose';

// MongoDB 연결 문자열을 환경변수에서 가져옴
const MONGODB_URI = process.env.MONGODB_URI;

// 연결 문자열이 없으면 에러 발생
if (!MONGODB_URI) {
  throw new Error('MONGODB_URI must be defined');
}

// 전역 변수에 mongoose 연결 정보를 저장
// 이렇게 하면 서버가 재시작되어도 연결이 유지됨
let cached = global.mongoose;

// 처음 실행될 때는 cached가 없으므로 초기화
if (!cached) {
  cached = global.mongoose = {
    conn: null,     // 현재 연결 객체
    promise: null   // 연결 시도중인 Promise
  };
}

async function connectDB() {
  // 이미 연결되어 있다면 그 연결을 재사용
  if (cached.conn) {
    return cached.conn;
  }

  // 연결 시도중이 아니라면 새로운 연결 시도
  if (!cached.promise) {
    const opts = {
      bufferCommands: false,  // 연결되기 전에 명령어 버퍼링 비활성화
    };

    // MongoDB 연결 시도
    cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
      return mongoose;
    });
  }

  try {
    // 연결이 완료될 때까지 대기
    cached.conn = await cached.promise;
  } catch (e) {
    // 연결 실패시 promise 초기화하고 에러 던지기
    cached.promise = null;
    throw e;
  }

  return cached.conn;
}

export default connectDB;

2-2. Post 모델 생성

  • 역할 : MongoDB의 데이터를 다루는 객체
  • 주요 기능
    • 게시글의 데이터 구조 정의 (제목, 내용, 날짜 등) : 일관된 데이터 구조 유지
    • 필수 입력값 지정 : 데이터 유효성 검사 자동화
    • 데이터 유효성 검사 규칙 설정 : MongoDB 작업을 더 쉽게 수행
  • 예시
{
  title: { // 제목 필드
    type: String,        // 문자열 타입
    required: true,      // 필수 입력
    trim: true          // 앞뒤 공백 제거
  },
  content: { // 내용 필드
    type: String,
    required: true
  }
}

models/Post.js 파일 생성:

import mongoose from 'mongoose';

// 이미 모델이 있다면 그것을 사용, 없다면 새로 생성
const PostSchema = new mongoose.Schema({
  title: {
    type: String,
    required: [true, '제목을 입력해주세요.'], // 필수 입력
    trim: true, // 앞뒤 공백 제거
  },
  content: {
    type: String,
    required: [true, '내용을 입력해주세요.'],
  },
  createdAt: {
    type: Date,
    default: Date.now, // 기본값은 현재 시간
  }
});

// 모델이 이미 있다면 그것을 사용, 없다면 새로 생성
export default mongoose.models.Post || mongoose.model('Post', PostSchema);

3. API 라우트 수정

3-1. 전체 게시글 API (app/api/posts/route.js)

import { NextResponse } from 'next/server';
// MongoDB 연결을 위한 유틸리티 함수 가져오기
import connectDB from '@/lib/mongodb';
// MongoDB의 Post 모델 가져오기 
import Post from '@/models/Post';

export async function GET() {
 try {
   // MongoDB 연결
   await connectDB();
   // Post 모델을 사용해 모든 게시글을 찾고, 생성일 기준 내림차순 정렬
   const posts = await Post.find({}).sort({ createdAt: -1 });
   return NextResponse.json(posts);
 } catch (error) {
   return NextResponse.json(
     { error: '게시글을 불러오는데 실패했습니다.' },
     { status: 500 }
   );
 }
}

export async function POST(req) {
 try {
   // MongoDB 연결
   await connectDB();
   const data = await req.json();

   if (!data.title || !data.content) {
     return NextResponse.json(
       { error: '제목과 내용은 필수입니다.' },
       { status: 400 }
     );
   }

   // Post 모델을 사용해 새 게시글 생성
   const post = await Post.create(data);
   // 클라이언트 응답
   return NextResponse.json(post, { status: 201 });
 } catch (error) {
   return NextResponse.json(
     { error: '게시글 작성에 실패했습니다.' },
     { status: 500 }
   );
 }
}

3-2. 개별 게시글 API (app/api/posts/[id]/route.js)

ObjectId란?

  1. MongoDB의 각 문서(document)를 고유하게 식별하는 12바이트의 식별자

    • 예: 507f1f77bcf86cd799439011
    • 자동 생성되며 유일성이 보장됨
  2. isValidObjectId 함수의 역할

const isValidObjectId = (id) => mongoose.Types.ObjectId.isValid(id);
  • 주어진 id가 MongoDB의 ObjectId 형식에 맞는지 검사

  • 예를 들면 :

    isValidObjectId('507f1f77bcf86cd799439011') // true
    isValidObjectId('invalid-id') // false
  1. 사용 목적
    • DB 조회 전에 id의 형식이 올바른지 미리 검증
    • 잘못된 형식의 id로 인한 불필요한 DB 쿼리 방지
    • 클라이언트에게 즉각적인 피드백 제공 가능
// app/api/posts/[id]/route.js
import { NextResponse } from 'next/server';
import Post from '@/models/Post';
// MongoDB의 ObjectId 검증을 위한 mongoose import
import mongoose from 'mongoose';
// MongoDB 연결을 위한 유틸리티 함수 가져오기
import connectDB from '@/lib/mongodb';

// MongoDB의 ObjectId가 유효한지 검사하는 함수
const isValidObjectId = (id) => mongoose.Types.ObjectId.isValid(id);

export async function GET(req, { params }) {
 try {
   // MongoDB 연결
   await connectDB();

   // Next.js 13에서는 params를 비동기로 처리하도록 변경되었다.
   // 일반 값을 Promise로 변환
   // const regular = 1;
   // const promise = Promise.resolve(regular); // Promise { 1 }
   // Promise의 결과값을 추출
   // const result = await promise; // 1
   const resolvedParams = await Promise.resolve(params);

   // 게시글 ID가 유효한 MongoDB ObjectId 형식인지 검사
   if (!isValidObjectId(resolvedParams.id)) {
     return NextResponse.json(
       { error: '유효하지 않은 게시글 ID입니다.' },
       { status: 400 }
     );
   }

   // Post 모델을 사용해 특정 ID의 게시글 찾기
   const post = await Post.findById(resolvedParams.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(req, { params }) {
 try {
   // MongoDB 연결
   await connectDB();
   const resolvedParams = await Promise.resolve(params);

   // ID 유효성 검사
   if (!isValidObjectId(resolvedParams.id)) {
     return NextResponse.json(
       { error: '유효하지 않은 게시글 ID입니다.' },
       { status: 400 }
     );
   }

   const data = await req.json();
   // findByIdAndUpdate: ID로 게시글을 찾아 업데이트
   // $set: MongoDB 업데이트 연산자, new: true는 업데이트된 문서 반환
   const post = await Post.findByIdAndUpdate(
     resolvedParams.id,
     { $set: data },
     { new: true, runValidators: true }
   );

   if (!post) {
     return NextResponse.json(
       { error: '게시글을 찾을 수 없습니다.' },
       { status: 404 }
     );
   }

   return NextResponse.json(post);
 } catch (error) {
   return NextResponse.json(
     { error: '게시글 수정에 실패했습니다.' },
     { status: 500 }
   );
 }
}

export async function DELETE(req, { params }) {
 try {
   // MongoDB 연결
   await connectDB();
   const resolvedParams = await Promise.resolve(params);

   // ID 유효성 검사
   if (!isValidObjectId(resolvedParams.id)) {
     return NextResponse.json(
       { error: '유효하지 않은 게시글 ID입니다.' },
       { status: 400 }
     );
   }

   // findByIdAndDelete: ID로 게시글을 찾아 삭제
   const post = await Post.findByIdAndDelete(resolvedParams.id);
   if (!post) {
     return NextResponse.json(
       { error: '게시글을 찾을 수 없습니다.' },
       { status: 404 }
     );
   }

   return NextResponse.json({ message: '게시글이 삭제되었습니다.' });
 } catch (error) {
   return NextResponse.json(
     { error: '게시글 삭제에 실패했습니다.' },
     { status: 500 }
   );
 }
}

4. 클라이언트 컴포넌트 수정

기존 클라이언트 컴포넌트의 대부분은 그대로 사용할 수 있습니다. MongoDB의 _id를 사용하도록 수정이 필요한 부분만 변경하면 됩니다.

4-1. 게시글 목록 페이지 수정 (app/posts/page.js)

{posts.map((post) => (
  <Link
      key={post._id} // id 대신 _id 사용
      href={`/posts/${post._id}`} // id 대신 _id 사용
      className="cursor-pointer block" 
    >
    <h2>{post.title}</h2>
    <p>{post.content}</p>
    <span>{new Date(post.createdAt).toLocaleDateString()}</span>
  </Link>
))}

4-2. 날짜 형식 수정

날짜 포맷팅 함수는 여러 컴포넌트에서 재사용할 수 있으므로, utils 폴더에 따로 만드는 것이 좋습니다

// utils/formatDate.js
export const formatDate = (date) => {
  // MongoDB의 Date 객체를 로컬 시간으로 변환
  return new Date(date).toLocaleDateString('ko-KR', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit'
  });
};
// app/posts/page.js 또는 다른 컴포넌트
import { formatDate } from '@/utils/formatDate';

// 컴포넌트 내부에서 사용
<span>{formatDate(post.createdAt)}</span>
))}

0개의 댓글