개발 마라톤

[개발] Next.js 13 mongoose API 만들기 (TypeScript) 본문

--- Project ---/CharFlyer : 캐플라이어

[개발] Next.js 13 mongoose API 만들기 (TypeScript)

망__고 2023. 9. 17. 13:01

DB 연결하기

Nextjs MongoDB Atlas 연결하기 mongoose, TypeScript 사용 (tistory.com)

 

Nextjs MongoDB Atlas 연결하기 mongoose, TypeScript 사용

MongoDB Atlas란? 우선 MongoDB는 NoSQL 데이터베이스입니다. 간단히 말하면 MySQL같은 데이터베이스랑은 달리 테이블을 만들고 정규화 시키고 할 필요 없이 그냥 저장하고 싶은대로 저장할 수 있습니다.

supern0va.tistory.com

Next.js의 MongoDB 연결은 윗 글을 참고했다.

 

1. MongoDB를 연결하기 위해 연결 URI를 환경변수 파일에 저장한다.

MONGODB_URI= ...

Next.js는 기본적으로 환경변수 관리를 지원하기 때문에, 따로 모듈을 import할 필요가 없다.

 

2. mongoose의 MongoDB 연결을 관리하기 위해 연결에 대한 type을 정의한다.

// @types/mongodb.ts
import { Mongoose } from 'mongoose';

/* eslint-disable no-var */

declare global {
  var mongoose: {
    promise: Promise<Mongoose> | null;
    connection: Mongoose | null;
  };
}

declare global을 통해 전역에서 해당 타입을 사용할 수 있도록 지정한다.

 

3. dbConnection.ts 파일을 통해 mongoose에 대한 mongoDB 연결을 반환한다.

// @/utils/db/dbConnection.ts

import mongoose from 'mongoose';

const DB_URI = process.env.MONGODB_URI || '';

let cached = global.mongoose;

if (!cached) {
  cached = global.mongoose = { connection: null, promise: null };
}

/**
 * mongoose를 통해 MongoDB와 연결합니다.
 * @returns Mongoose
 */
async function dbConnect() {
  // 이미 연결된 db가 있으면 그 연결을 반환합니다.
  if (cached.connection) return cached.connection;

  if (!cached.promise) {
    cached.promise = mongoose
      .set({ debug: true, strictQuery: false })
      .connect(`${DB_URI}`)
      .then((mongoose) => mongoose);
  }

  cached.connection = await cached.promise;
  return cached.connection;
}

export default dbConnect;

`cached` 의 변수는 이전에 global로 정의하였던 mongoose type으로 초기화하였다.

여기서, TypeScript는 `cached` 를 타입으로 인식하는 것이 아닌,

해당 타입의 형태의 객체로 인식하게 된다.

let cached: {
  connection: Mongoose | null;
  promise: Promise<Mongoose> | null;
};

즉, cached는 해당 형태의 객체로 인식됨을 알자. ( 이는 TypeScript 사용자의 하나의 스킬인 듯 함. )

또한, 코드는 `cached` 가 비어있을 때, 해당 타입의 객체를 가지도록 선언한다.

 

dbConnect() 에서는 `cached` 에 연결이 있으면 해당 연결을 돌려주도록 하고,

promise가 없다면, mongoose.connect()를 통해 MongoDB와 연결한다.

그 후, `aached.connection` 은 `cached.promise` 작업이 끝나기를 기다린(await) 후, 해당 연결을 반환한다.

 

최종적으로 해당코드는 MongoDB를 연결 후, 해당 Connection을 반환하게 된다.

Schemes 및 Model 설정하기

현재 작업중인 Schemes 및 model은 아래 페이지에서 확인할 수 있다.

Schemas · 1004ljy980/CharFlyer Wiki (github.com)

 

Schemas

캐릭터 및 굿즈 홍보 사이트 개인 프로젝트입니다. Contribute to 1004ljy980/CharFlyer development by creating an account on GitHub.

github.com

예시로, 현 상태의 스키마 코드를 봐보도록하자.

// @/utils/schemas/introductionPosts.model.ts

import { Schema, models, model } from 'mongoose';

const IntroductionPostsSchema = new Schema(
  {
    introductionPostId: {
      type: Number,
      required: true,
      unique: true,
    },
    author: {
      type: Schema.Types.ObjectId,
      ref: 'User',
      required: true,
    },
    title: {
      type: String,
      required: true,
      maxlength: 50,
    },
    thumbnail: {
      type: String,
      required: true,
    },
    content: {
      type: String,
      required: true,
    },
    summary: {
      type: String,
      maxlength: 100,
    },
    category: {
      type: String,
      enum: ['character', 'goods'],
      required: true,
    },
    tags: {
      type: Array<String>,
    },
    views: {
      type: Number,
      default: 0,
    },
  },
  {
    timestamps: true,
  }
);

export type TypeIntroductionPost = {
  _id: String;
  introductionPostId: String;
  author: {
    _id: String;
    name: String;
    profileImage: String;
  };
  title: String;
  thumbnail: String;
  content: String;
  summary: String;
  category: String;
  tags: String[];
  views: Number;
  createdAt: String;
};

// models에서 IntroductionPost가 이미 있는지 확인합니다.
// 확인 후 생성되지 않았다면, model을 통해 IntroductionPost 모델을 생성합니다.
const IntroductionPost =
  models?.IntroductionPost ||
  model<TypeIntroductionPost>('IntroductionPost', IntroductionPostsSchema);

export default IntroductionPost;

1. 우선 `new Scheme` 를 통해 mongoose schemes를 정의한다.

참고사항은 다음과 같다.

속성 의미
type 타입을 지정한다.
required 값이 필수인지 지정한다.
unique 값이 유일해야 하는지 지정한다.
maxlength 최대 길이를 제한한다.
enum 해당 배열의 값으로만 제한한다.
default 기본 값을 지정한다.

추가적으로, `author`의 타입은 Schema.Types.ObjectId 로, 다른 Model을 참고하기 위한 Document이다.

 

2. mongoDB에서 응답받을 내용의 Type을 지정해준다.

해당 내용은 아래 부분과 같다.

export type TypeIntroductionPost = {
  _id: String;
  introductionPostId: String;
  author: {
    _id: String;
    name: String;
    profileImage: String;
  };
  title: String;
  thumbnail: String;
  content: String;
  summary: String;
  category: String;
  tags: String[];
  views: Number;
  createdAt: String;
};

`author` 의 ObejctId와 ref를 통해 User Model의 내용을 populate하여 참고한다. ( 해당 내용은 밑 부분에서 설명 )

그 값은 author라는 속성의 객체로 받아들어와지며, 결론적으로 윗 구조의 객체를 MongoDB에서 응답받는다.

 

3. 이 Model이 존재하는지 확인 후에 존재하지 않는다면 새로 생성한다.

// models에서 IntroductionPost가 이미 있는지 확인합니다.
// 확인 후 생성되지 않았다면, model을 통해 IntroductionPost 모델을 생성합니다.
const IntroductionPost =
  models?.IntroductionPost ||
  model<TypeIntroductionPost>('IntroductionPost', IntroductionPostsSchema);

export default IntroductionPost;

mongoose의 `models`를 통해, 참조(.introductionPost) 한 모델이 존재하는지 확인할 수 있다.

존재하지 않는다면( || ), mongoose의 `model`을 통해 해당 Model을 생성한다.

또한 model<T>의 T를 통해 타입을 지정할 수도 있다.

API 만들기

// @/app/api/introduction-posts/route.ts

import dbConnect from '@/utils/db/dbConnection';
import { AdjustTypes, adjustSequenceValue } from '@/schemas/counter.model';
import IntroductionPost, {
  TypeIntroductionPost,
} from '@/schemas/introductionPosts.model';
import User from '@/schemas/users.model';
import { NextResponse } from 'next/server';

async function connectToDatabase() {
  try {
    // 데이터베이스와 연결합니다.
    await dbConnect();

    // 연결을 성공하면 model을 반환합니다.
    return IntroductionPost;
  } catch (error) {
    console.error('DB 연결 에러 : ' + error);
  }
}

export async function GET() {
  try {
    const IntroductionPosts = await connectToDatabase();
    User;

    // 타입스크립트가 유추한 Omit<any, never>타입 대신, 타입 단언 as 사용
    const data = (await IntroductionPosts?.find({})
      .populate('author', 'name profileImage')
      .select(
        'introductionPostId author title thumbnail summary content category tags views timestamps createdAt'
      )
      .exec()) as TypeIntroductionPost[] | undefined;

    // 프론트엔드 인터페이스에 맞게 데이터 가공
    const modifiedData = data?.map((post) => ({
      id: post._id,
      introductionPostId: post.introductionPostId,
      authorId: post.author._id,
      authorName: post.author.name,
      authorImage: post.author.profileImage,
      title: post.title,
      thumbnail: post.thumbnail,
      content: post.content,
      summary: post.summary,
      category: post.category,
      tags: post.tags,
      views: post.views,
      timestamps: post.createdAt,
    }));

    return NextResponse.json(modifiedData);
  } catch (error) {
    console.error(error);
  }
}

export async function POST(request: Request) {
  try {
    const IntroductionPosts = await connectToDatabase();
    const data = await request.json();
    // adjustSequenceValue 함수를 통해 counter를 받아와서 introdutionPostId로 사용합니다.
    const introductionPostId = await adjustSequenceValue(
      'introductionPostId',
      AdjustTypes.Increment
    );

    // 정보를 등록합니다.
    const created = await IntroductionPosts?.create({
      introductionPostId,
      ...data,
    });

    return NextResponse.json(created);
  } catch (error) {
    console.error(error);
    try {
      // 오류가 발생할 시에, 증가시켜 놓았던 id를 다시 감소시킵니다.
      await adjustSequenceValue('introductionPostId', AdjustTypes.Decrement);
    } catch (error) {
      console.log('counter 오류 : ' + error);
    }
  }
}

1. connectToDatabase() 함수를 통해 dbConnect()를 진행 후 Model을 반환받는다.

async function connectToDatabase() {
  try {
    // 데이터베이스와 연결합니다.
    await dbConnect();

    // 연결을 성공하면 model을 반환합니다.
    return IntroductionPost;
  } catch (error) {
    console.error('DB 연결 에러 : ' + error);
  }
}

이전에 작성한 dbConnect() 를 통해 데이터베이스와 연결 한 후,

try-catch에서 Error가 발생하지 않았다면 return IntroductionPost를 통해 Model을 반환한다.

 

2-1. HTTP GET 메소드 요청을 처리한다.

export async function GET() {
  try {
    const IntroductionPosts = await connectToDatabase();
    User;

    // 타입스크립트가 유추한 Omit<any, never>타입 대신, 타입 단언 as 사용
    const data = (await IntroductionPosts?.find({})
      .populate('author', 'name profileImage')
      .select(
        'introductionPostId author title thumbnail summary content category tags views timestamps createdAt'
      )
      .exec()) as TypeIntroductionPost[] | undefined;

    // 프론트엔드 인터페이스에 맞게 데이터 가공
    const modifiedData = data?.map((post) => ({
      id: post._id,
      introductionPostId: post.introductionPostId,
      authorId: post.author._id,
      authorName: post.author.name,
      authorImage: post.author.profileImage,
      title: post.title,
      thumbnail: post.thumbnail,
      content: post.content,
      summary: post.summary,
      category: post.category,
      tags: post.tags,
      views: post.views,
      timestamps: post.createdAt,
    }));

    return NextResponse.json(modifiedData);
  } catch (error) {
    console.error(error);
  }
}

코드 내용은 다음과 같다.

const IntroductionPosts = await connectToDatabase();
User;

a. connectToDatabase를 통해 Model을 반환받는다.

b. 또한, 참조할 User Model 또한 정의가 필요하므로, User Model을 참조해두었다.

 

해당 내용은 코드가 깔끔하지는 않은 것 같아서,

나중에 dbConnect에 Connect 후, 모든 Model을 따로 한번씩 참조하는 코드를 작성할 예정이다. 

// 타입스크립트가 유추한 Omit<any, never>타입 대신, 타입 단언 as 사용
const data = (await IntroductionPosts?.find({})
   	.populate('author', 'name profileImage')
   	.select(
   	  'introductionPostId author title thumbnail summary content category tags views timestamps createdAt'
  	 )
  	 .exec()) as TypeIntroductionPost[] | undefined;

a. populate()를 통해 `author`로 User Model을 참조하고, `name`과 `profileImage`의 내용만 불러온다.

b. select()를 통해 MongoDB의 Collection에서 불러올 Document명을 지정해준다.

c. exec()를 통해 실행 후, as Type을 통해 타입을 단언한다.

 

해당 내용에서는 타입을 단언하는 부분이 조금 아쉽다.

타입 단언 as는 타입 선언 : 를 사용하는 것과 다르게, 타입 스크립트가 유추하는 타입을 무시하고,

타입 선언 : 이 해당 타입이 아니면 오류를 나타내는 것과 달리,

타입 단언 as 는 해당 값이 단언한 Type이라고 임의로 지정하는 내용이다.

 

따라서, 타입 선언 : 에 맞는 값이 아닐지라도 오류가 나타나지 않고,

단순히 이후 코드를 작성할 때 해당 타입에 맞게 작성할 수 있도록 하는 역할로 우선 작성해두었다. 

해당 내용은 아쉽지만, 더 찾아보면서 수정해야 할 내용이다.

 

결과적으로는 MongoDB에서 받아들인 값을 Schemas에서 지정한 Type값에 맞게 하도록하여,

코드 작성시에 Type 값에 맞게 작성할 수 있도록 하였다.

// 프론트엔드 인터페이스에 맞게 데이터 가공
const modifiedData = data?.map((post) => ({
    id: post._id,
    introductionPostId: post.introductionPostId,
    authorId: post.author._id,
    authorName: post.author.name,
    authorImage: post.author.profileImage,
    title: post.title,
    thumbnail: post.thumbnail,
    content: post.content,
    summary: post.summary,
    category: post.category,
    tags: post.tags,
    views: post.views,
    timestamps: post.createdAt,
}));

return NextResponse.json(modifiedData);

위에서 단언한 TypeIntroductionPost의 타입에 맞게, 타입에 맞는 객체 속성을 참조한다. (post.~)

프론트엔드에서 사용하기 용이하게 하기 위하여,

프론트엔드의 인터페이스에 맞게 데이터를 가공 후 response한다.


+ 여담

나는 이전 프로젝트에서 프론트엔드 부분만 담당했다보니, 백엔드 부분에서 많은 어려움이 있었던 것 같다.

 

그 중 TypeScript의 타입 관리 부분에서 오랫동안 휘청거렸다.

하지만 역시 model.ts 부분에서부터 type을 지정해놓으니

그 model을 활용하는 route부분에서는, 해당 type을 참조해서 천천히 따라 작성할 수 있었다.

 

그 효과로는 실제로 GET 메소드에서 응답하는 구조를 직접 작성하며 확인할 수 있으니,

직관성이 훨씬 올라갔고 속성에 대해 햇갈리거나 오작성 하는 일이 없어졌다. ( 타입스크립트 복잡하지만 역시 유용하다 ! )

 

이제부턴 Next.js의 server component에서 API를 받아서 사용해 볼 예정이다.

오류가 없으면 좋겠는데.. 있어도 많은 걸 또 얻어갈 수 있지 않을까 싶다.

 

 

 

 

 

 

 

 

 

 

 

Comments