728x90
반응형
SMALL
반응형
SMALL

Part 3에서 조회의 응답 속도를 더 개선하기 위해 내장(Nesting)방법을 이용해서 조회 속도를 더 개선했었다.

그러니까 지금까지 배운 방법은 populate, virtual, nesting 이렇게 크게 3가지를 배워서 조회 속도를 개선해 봤는데 데이터가 정말 무수히 많아지면(몇백만개 또는 몇천만개 혹은 그 이상) 데이터를 내장시켜서 가져올 때 속도가 느려질 수 밖에 없다. 

 

이는 내장시킨 데이터 때문이 아니라 스캔하는 방법에 더 가까운데 기본적인 데이터 스캔 방식은 처음부터 가지고 있는 데이터의 마지막까지 하나씩 가져와 데이터를 찾아내는 COLLSCAN(컬렉션 스캔) 이라는 스캔 방식을 취한다. 이게 데이터가 적을땐 거의 아무런 영향을 끼치지 않는다 (컴퓨터의 연산 속도는 정말 빠르기 때문에) 그러나 데이터가 커지면 커질수록 이 차이가 발생하는데 여기서 개선할 수 있는 방법은 IXSCAN(인덱스 스캔)을 사용하는 것이다.

 

위처럼 특정 Collection에서 age라는 필드에 index를 오름차순으로 걸었을 때를 가정해보자. 만약 그 Collection의 모든 document 중 age가 53인 데이터만 가져오고 싶다는 쿼리를 작성해서 날리면 인덱스 스캔은 위 그림에서 하단 인덱스의 오름차순 데이터를 가지고 데이터를 찾는다. 최초의 시작은 처음부터가 아니라 중간부터 시작해서 중간인 데이터 age:30이 원하는 53인지 판단 후 53보다 작기 때문에 왼쪽 부분은 더 이상 보지 않는다. 그럼 벌써 모든 데이터의 절반이 필터링 된 것이다. 이게 인덱싱 스캔이다. 그리고 찾은 30이 53보다 작기 때문에 우측으로 넘어가서 우측 끝과 30 사이인 53으로 포인터가 넘어간다. 53은 원하는 값이기 때문에 단 두번만에 원하는 데이터를 찾게된다. 이런 방법을 인덱스 스캔이라고 한다. 

 

실제로 데이터가 한 10만개 정도 있을 때 컬렉션 스캔과 인덱스 스캔은 응답의 속도 차이를 보인다. 아래 예시를 보자.

 

COLLSCAN Vs. IXSCAN

우선 내 DB에 Users라는 컬렉션에는 데이터가 10만개정도가 있다.

 

이 상태에서 최초에는 컬렉션 스캔으로 데이터를 가져와보자.

다음과 같이 필터링 조건을 age가 20보다 큰 녀석들로 설정하고, Sort를 age가 오름차순(1) Sort한다는 조건을 걸고 우측 Explain 버튼을 클릭해보자.

 

그러면 화면창이 하나 뜨는데 거기에는 어떤 데이터를 어떻게 가져와서 얼마나 걸렸는지 자세하게 보여준다.

사진에서 보면 우측 "211ms execution time"라고 보인다. 즉, 모든 처리를 211ms만에 실행했다는 얘기이다. 이제 인덱싱을 걸어서 데이터를 조회해보자.

상단에 보면 Indexes라는 탭이 있다.

인덱스를 새로 생성해보자. 

이제 같은 조건으로 쿼리를 실행해보면 다음과 같은 결과를 도출해낸다.

우측 끝에 139ms만에 끝났다는 결과를 얻을 수 있다. 고작 10만개 정도인데도 이런 차이를 보이면 데이터가 많아지면 많아질수록 그 효율성은 더 커질것이다. 이런식으로 인덱싱을 사용해서도 데이터 조회에 응답 속도를 줄여줄 수 있다.

 

 

복합 인덱스

이번에는 단일 인덱스가 아니라 복합(여러개) 인덱스를 걸어서 조회해보자.

위에서는 age 하나만 가지고 인덱스를 만들어 스캔했다면 이번엔 username과 같이 인덱스를 걸어서 스캔했을 때 어떤 영향을 끼치는지 알아보자.

우선, 인덱스를 만들지 않고 username을 오름차순, age를 오름차순으로 정렬하는 쿼리를 실행했을 때 응답 속도를 확인해보자.

위처럼 쿼리를 날려보면 아래와 같은 결과를 얻는다.

컬렉션 스캔으로 가져온 데이터를 솔팅하는데 총 109ms의 처리 시간이 발생했다. 여기서 인덱스를 걸어보자.

username 오름차순, age 오름차순에 대한 인덱스를 만들어보자.

이렇게 인덱스를 만들고 다시 조회해보자.

인덱스를 만들어 조회해보면 IXSCAN이라고 나온다. 제대로 인덱스를 이용해 스캔하고 있다.

그럼 반대로 age를 먼저 입력해보자. { age: 1, username: 1 }

신기하게 이번에는 컬렉션 스캔을 수행했고. 우측에 이용가능한 인덱스가 없다고 표시된다. 왜 그럴까 ?

복합 인덱스는 인덱스를 만들 때 필드의 순서가 영향이 있게 된다. 따라서 인덱스를 만들 때 필드의 순서가 중요하다.

 

이번에는 하나는 오름차순 하나는 내림차순으로 만들어보자.

이렇게 age를 오름차순 username을 내림차순으로 설정하고 인덱스를 만들었고 이와 같은 조회를 해보자.

당연히 IXSCAN을 한다. 합리적이다. 이번에는 반대로 age를 내림차순 username을 오름차순으로 조회해보자.

이것또한 인덱스 스캔을 했다. 왜 그럴까? 위에서는 필드의 순서가 중요하다고 했는데 이런 인덱스는 만든적이 없지만 인덱스 스캔을 했다.

인덱스는 대칭 구조를 갖는다고 생각하면 된다. 만약 인덱스를 { age: 1, username: -1 }로 만들었을 때 여기에 -1을 곱해보면 { age: -1, username: 1 }이 되는데 이 또한 인덱스 스캔으로 동작한다. 그래서 대칭 구조로도 인덱스를 사용할 수 있다고 알아둬야겠다.

 

그리고 위 결과에서도 알 수 있듯 인덱스 스캔이 더 오래걸리기도 한다. 이는 인덱스를 사용할 때 인덱스가 걸린 필드의 분포도가 영향을 끼치는데 인덱스를 만든 필드의 분포도가 커지면 커질수록 인덱스 스캔은 속도가 오래걸린다. 그래서 인덱스를 남발하는 건 절대 좋은게 아니다. 또한 인덱스를 만들면 인덱스를 생성하는데 들어가는 비용이 꽤나 크기 때문에 메모리를 차지하는데 이 또한 무시할 수 없다. 아래 그림을 보면 

101200개의 Documents의 토탈 사이즈보다, Index 5개의 토탈 사이즈가 더 크다. 이 정도로 인덱스는 크기를 많이 차지하는데 크기만 차지하는 문제를 가지고 있는것이 아니고 이렇게 되면 인덱스를 통해 READ하는 속도는 낮출 수 있을지 몰라도 CREATE하는 속도가 올라간다. 데이터를 만들 때 역시 인덱스를 걸어주기 때문이고 인덱스가 많아지면 많아질수록 구조는 복잡해지기 때문이다.

 

그래서 인덱스를 남발하는건 절대 좋은게 아니라고 할 수 있다. 상황과 처한 상태에 맞게 적절히 사용해야한다.

 

 

참고로 텍스트를 인덱스로 사용할 수 있다. 예를 들어, 블로그의 title 같은 텍스트를 인덱스를 걸고 싶으면 아래처럼 하면 된다.

const blogSchema = new Schema<IBlog>(
  {
    title: { type: String, required: true },
    content: { type: String, required: true },
    isLive: { type: Boolean, required: true, default: false },
    user: {
      _id: { type: Types.ObjectId, required: true, ref: 'user' },
      username: { type: String, required: true },
      name: {
        first: { type: String, required: true },
        last: { type: String, required: true },
      },
    },
    comments: [commentSchema],
  },
  { timestamps: true }
);

blogSchema.index({ title: 'text' });

이렇게 index()를 사용해서 추가해주면 되는데, 만약 blog의 title뿐 아니라 content도 인덱스로 만들고 싶다면 이처럼 동일한 방법으로는 할 수 없다. 왜냐하면 text 기반의 인덱스는 하나만 만들 수 있기 때문이다. 이럴 땐 복합 인덱스를 사용해야한다.

const blogSchema = new Schema<IBlog>(
  {
    title: { type: String, required: true },
    content: { type: String, required: true },
    isLive: { type: Boolean, required: true, default: false },
    user: {
      _id: { type: Types.ObjectId, required: true, ref: 'user' },
      username: { type: String, required: true },
      name: {
        first: { type: String, required: true },
        last: { type: String, required: true },
      },
    },
    comments: [commentSchema],
  },
  { timestamps: true }
);

blogSchema.index({ title: 'text', content: 'text' });

저렇게 title과 content 모두 text로 search할 때 인덱스를 만들어내고 싶으면 복합 인덱스를 사용하면 된다.

 

마무리

컬렉션 스캔과 인덱스 스캔을 비교해 보았는데 무조건적으로 인덱스 스캔이 좋은것은 아니다. 만약 데이터가 적은 경우, 오히려 인덱스 스캔이 컬렉션 스캔보다 오래걸릴 수 있다. 위에서 언급했던 것처럼 컴퓨터의 연산 속도는 굉장히 빠르기 때문에 백개, 천개정도는 네트워크 속도를 제외하면 거의 차이가 없는데 여기서 인덱싱을 사용하면 오히려 불필요한 과정을 거칠 수 있어진다. 즉, 항상 그렇듯 상황과 상태를 고려해서 구조를 깔아야 한다는 것을 또 한번 느끼고 마무리.

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

전 게시글에서 조회하는 데 걸리는 시간과 비용을 줄이기 위해 populate, virtual을 사용했었다.

 

또 다른 방법으로는 블로그 내 있는 유저와 코멘트 데이터를 처음부터 nested 하는 것이다.

Blog Schema

 위 이미지를 보면 Blog안에 User와 Comments 정보를 가지고 있는데 현재 내 Blog Schema에는 Comments는 아예 포함되어 있지 않고 User는 ObjectId만을 가지는 상태다.

const blogSchema = new Schema<IBlog>(
  {
    title: { type: String, required: true },
    content: { type: String, required: true },
    isLive: { type: Boolean, required: true, default: false },
    user: { type: Types.ObjectId, required: true, ref: 'user' }
  },
  { timestamps: true }
);

 

이런 스키마를 가지고 있는 경우 블로그의 유저와 코멘트 정보들을 가져오고 싶어 populate()과 virtual()을 사용했는데, 스키마 자체를 원하는 유저와 코멘트 정보 모두 포함시켜 보자.

const blogSchema = new Schema<IBlog>(
  {
    title: { type: String, required: true },
    content: { type: String, required: true },
    isLive: { type: Boolean, required: true, default: false },
    user: {
      _id: { type: Types.ObjectId, required: true, ref: 'user' },
      username: { type: String, required: true },
      name: {
        first: { type: String, required: true },
        last: { type: String, required: true },
      },
    },
    comments: [commentSchema],
  },
  { timestamps: true }
);

바뀐 블로그 스키마는 유저 정보에 필요한 데이터를 더 추가해 주고 코멘트 같은 경우는 코멘트 스키마 자체를 배열에 넣어 블로그를 읽어올 때 원하는 모든 데이터를 한 번에 가져올 수 있게 했다. 이렇게 변경했으니 virtual과 populate은 더 이상 사용할 필요가 없다.

 

다만, 블로그 스키마를 변경했으니 특정 블로그를 생성하거나 변경할 때 추가적인 작업이 필요해진다. 그러니까 모든 게 다 장단점이 발생하는데 이렇게 블로그 스키마에 원하는 데이터 자체를 Nested 하면 조회는 더 적은 비용이 들지만 생성과 수정에 더 많은 비용이 들어간다. 그러니까 서비스에서 조회를 더 많이 하는지 생성과 수정을 더 많이 하는지 판단하여 적절하게 구조를 만들어 내는 게 중요하다.

 

확실히 조회 시 응답 속도는 현저히 줄었다. 왜냐하면 populate을 사용해 추가적인 유저나 코멘트 조회를 하지 않아도 되니 한 번의 조회만으로 원하는 모든 데이터를 가져올 수 있기 때문이다. 

 

그럼 이제 생성과 수정 API를 수정해 보자.

 

Comment Create/Edit API

사실 생성 API는 변경할 많지 않다. Comment를 만들 때 필요한 작업은 그대로 변경할 필요가 없고 코멘트를 만들 때 블로그에 방금 생성한 Comment를 추가만 해주면 된다. 왜냐하면 이제 블로그 스키마에도 코멘트가 존재하니까.

 

// Create comment by blog id
blogRouter.post('/:blogId/comments', async (req, res) => {
  try {
    const { blogId } = req.params;
    if (!mongoose.isValidObjectId(blogId))
      return res.status(400).send({ error: 'Invalid blog id' });
    const { content, userId } = req.body;
    if (!mongoose.isValidObjectId(userId))
      return res.status(400).send({ error: 'Invalid user id' });

    if (!content)
      return res.status(400).send({ error: 'content must be required' });

    const [blog, user] = await Promise.all([
      Blog.findOne({ _id: blogId }),
      User.findOne({ _id: userId }),
    ]);

    if (!blog || !user)
      return res.status(400).send({ error: 'User or Blog does not exist' });
    if (!blog.isLive)
      return res.status(400).send({ error: 'Blog is not available' });

    const comment = new Comment({ content, user, blog });
	
    // 이 부분에서 Blog 업데이트하는 부분이 추가!
    await Promise.all([
      comment.save(),
      Blog.updateOne({ _id: blogId }, { $push: { comments: comment } }),
    ]);
    return res.status(201).send({ comment });
  } catch (e: any) {
    return res.status(500).send({ error: e.message });
  }
});

위 코드를 보면 Promise.all() 안에 처리하는 부분 중 이 부분이 추가되었다.

Blog.updateOne({ _id: blogId }, { $push: { comments: comment } }),

$push를 사용해서 기존 코멘트들에 새로운(방금 만든) 코멘트를 추가한다.

 

 

중요한 변경 사항은 수정이다. 수정 API는 마찬가지로 변경 사항이 많은 것이 아니라 처리하는 방법에 대해서 알아볼 필요가 있다.

우선 변경 사항은 이와 같다.

blogRouter.patch('/:blogId/comments/:commentId', async (req, res) => {
  const { commentId } = req.params;
  const { content } = req.body;
  if (!mongoose.isValidObjectId(commentId))
    return res.status(400).send({ error: 'Invalid comment id' });
  if (typeof content !== 'string')
    return res.status(400).send({ error: 'content is required' });

  const [comment] = await Promise.all([
    Comment.findOneAndUpdate({ _id: commentId }, { content }, { new: true }),
    Blog.updateOne(
      { 'comments._id': commentId },
      { 'comments.$.content': content }
    ),
  ]);

  return res.send({ comment });
});

여기서 코멘트 id를 이용해 코멘트를 찾아서 원하는 값으로 수정한다. 그 후 수정한 코멘트를 블로그에도 반영시켜줘야 한다.

그때 updateOne() 메서드를 사용하는데 첫 번째 인자는 블로그의 어떤 커멘트를 수정할지에 대한 쿼리, 두 번째 인자는 업데이트 사항이다. 

첫 번째 인자로 { 'comments._id': commentId }로 들어오는데 comments._id는 MongDB 문법이다. 가지고 있는 코멘트들 중 id가 주어진 commentId와 같은 코멘트를 찾는다. 두 번째 인자로 { 'comments.$.content': content } 이 부분이 중요한데 여기서 '$'가 의미하는 건 첫 번째 인자의 쿼리문으로 찾은 객체를 담고 있다. 

 

공식 문서가 말하기를, Array Update Operators 중 하나인 $는 배열의 특정 원소를 나타내며 명시적으로 배열의 인덱스를 나타내지 않고 업데이트시킬 수 있다. 그래서 db.collection.updateOne() 또는 db.collection.findAndModify() operation을 할 때 사용할 수 있으며 이 $가 나타내는 건 쿼리에 매치되는 첫 번째 원소를 대체한다.

 

이렇게 수정한 후 실제로 테스트해보자.

Comment Update

우선 특정 블로그의 코멘트가 무엇이 있는지 확인해 보자.

Blog id가 651c10200db66148ab97a146인 blog의 comments가 2개 존재하는 걸 확인했고 그중 하나인652111988dfe0933fdd2265b 이 녀석을 수정해 보자.

 

 위 사진과 같이 request.body 값에 content 데이터를 추가하고 요청해 보면 다음과 같은 결과를 응답받는다.

정상적으로 코멘트가 수정되었고 우리가 원하는 코멘트만 수정된 건지 확인해 보자. 

MongoDB Compass를 통해 확인한 결과 우리가 수정하고자 하는 코멘트가 잘 수정된 모습이다. 

그리고 조회를 해보자. 조회를 했을 때 populate, virtual을 이용하는 것보다 더 빠른 속도로 응답하는 것을 확인할 수 있다. 

 

 

 

마무리

이번 포스팅에서는 MongoDB를 사용할 때 원하는 데이터를 nested 하여 가져오는 방법을 알아보았다.

이는 조회(Read)할 때 비용을 줄여주는 대신 생성과 수정하는 Create/Update 부분에서 조금 더 비용이 발생한다.

이를 통해 모든 것을 다 만족시키는 코드는 존재하지 않는 것을 또 한 번 느꼈으며 언제나 trade-off가 발생할 수 있다는 것을 배웠다.

 

그러니 서비스의 특성과 상태를 잘 고려해서 어떻게 구조를 만들어 나갈지 검토하는 게 중요한듯하다.

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

모든 데이터 조회 시 중요하게 생각할 것은 요청의 수를 줄일 수 있다면 최대한 줄이는 게 좋다.

다음 그림을 보자.

이런 구조를 가지고 있는 Blog Collection이 있다고 할 때, 블로그 전체를 조회하는 무식한 경우는 당연히 없겠지만 그런 경우가 있다고 가정해보고 전체 블로그를 가져오게 될 때, 만약 해당 블로그의 유저 정보와 커멘트들도 담아와야 한다면 블로그마다 유저와 커멘트들을 조회하는 비용이 발생한다.

 

아래 코드를 보자.

import { IBlog } from '../models/blog/blog';
import axios from 'axios';

const URI = 'http://localhost:3000';

const test = async () => {
  console.time('loading time: ');
  let {
    data: { blogs },
  } = await axios.get(`${URI}/blog`);

  blogs = await Promise.all(
    blogs.map(async (blog: IBlog) => {
      const {
        data: { user },
      } = await axios.get(`${URI}/users/${blog.user}`);
      const {
        data: { comments },
      } = await axios.get(`${URI}/blog/${blog._id}/comments`);

      blog.user = userRes.data.user;
      blog.comments = commentsRes.data.comments;
      return blog;
    })
  );
  console.log('blogs:', blogs[0]);
  console.timeEnd('loading time: ');
};

test();

코드의 내용은 대략 블로그 전체를 가져온 후 각 블로그마다 블로그의 유저 아이디를 가져와서 해당 아이디를 통해 유저와 커멘트를 가져온다. 이는 모든 블로그를 가져오는 요청을 한 번, 블로그를 가져오는 요청에서 처리하는 데이터베이스 조회, 조회된 데이터의 유저 및 커멘트를 가져오기 위한 요청 한 번, 각 요청에서 처리하는 데이터베이스 조회 등 상당히 많은 비용이 발생한다.

 

이것을 해결할 수 있는 방법 중 하나가 MongoDB의 'populate'이다.

populate은 특정 컬렉션의 도큐멘트에서 가져올 다른 객체가 있다면 (블로그의 유저와 커멘트) 해당 객체도 같이 조회하여 조회된 데이터를 추가해주는 방법이다.

 

코드는 다음과 같이 아주 간단히 작성할 수 있다.

// Get blogs
blogRouter.get('/', async (req, res) => {
  try {
    const blogs = await Blog.find().populate([
      { path: 'user' },
      { path: 'comments' },
    ]);
    return res.status(200).send({ blogs });
  } catch (e: any) {
    console.log(e);
    res.status(500).send({ error: e.message });
  }
});

위 코드처럼 원하는 콜렉션 정보를 populate으로 넣어주면 MongoDB는 해당 정보도 함께 가져와준다. 

그리고 이렇게 "블로그를 조회할 때 유저 정보와 커멘트들도 포함해서 가져와줘!" 라고 MongoDB에게 요청하면 위에 코드도 아래처럼 더 간결해진다.

import { IBlog } from '../models/blog/blog';
import axios from 'axios';

const URI = 'http://localhost:3000';

const test = async () => {
  console.time('loading time: ');
  let {
    data: { blogs },
  } = await axios.get(`${URI}/blog`);

  console.log('blogs:', blogs[0]);
  console.timeEnd('loading time: ');
};

test();

응답 속도도 데이터가 많아지면 많아질수록 꽤 의미있는 정도로 줄어들고 코드 또한 간단해지는 장점이 있다.

허나 여기서 한 가지 더 알고 넘어가야 할 게 있다. 'virtual'이다.

 

내 Blog Schema는 comments는 포함하지 않는다. 실제로 코드를 보면 다음과 같다.

// 블로그 스키마
const blogSchema = new Schema<IBlog>(
  {
    title: { type: String, required: true },
    content: { type: String, required: true },
    isLive: { type: Boolean, required: true, default: false },
    user: { type: Types.ObjectId, required: true, ref: 'user' },
  },
  { timestamps: true }
);

// 커멘트 스키마
const commentSchema = new Schema<IComment>(
  {
    content: { type: String, required: true },
    user: { type: Types.ObjectId, required: true, ref: 'user' },
    blog: { type: Types.ObjectId, required: true, ref: 'blog' },
  },
  { timestamps: true }
);

 

이게 나의 블로그 스키마인데 커멘트는 없다. 블로그에는 커멘트라는 데이터는 없고 커멘트에만 블로그라는 데이터를 참조한다. 그렇다면 당연히 blog 전체를 가져오는 find()에서 populate를 사용할 때 comments를 담을 수 없게 된다. 그럼 커멘트를 담고 싶을 때 방법은 두가지인데 첫번째는 스키마에 커멘트를 유저처럼 추가하거나, virtual을 사용하는 것이다. 난 후자를 선택했다.

 

blogSchema.virtual('comments', {
  ref: 'comment',
  localField: '_id',
  foreignField: 'blog',
});

blogSchema.set('toObject', { virtuals: true });
blogSchema.set('toJSON', { virtuals: true });

위처럼 virtual()을 사용하면 실제 데이터베이스에 해당 필드가 생성되는 건 아니고 가상의 데이터를 블로그 객체안에 추가해주는 것이다. 이렇게 하고 나면 블로그 전체를 가져오는 쿼리를 populate과 같이 수행할 때 커멘트 역시 가져올 수 있게 된다.

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

RDBS를 사용해 개발하던 중에 칼럼 형식으로부터의 자유로움의 필요성을 느끼고 MongoDB를 공부하기 시작했다.

MongoDB와 잘 맞는 Javascript를 사용해 간단한 Blog Service를 구축해 보면서 MongoDB와 친해져 보자.

 

Getting Started

npm init -y

package.json 파일을 우선 만들고, dependencies를 추가하자.

 

npm i typescript --save-dev
npm i express --save

이후에 typescript 설정 파일을 생성하기 위해 아래 커맨드를 실행하자.

npx tsc --init

이 커맨드는 tsconfig.json 파일을 작업하고자 하는 프로젝트 경로의 루트에 생성해 주는데, 어떤 식으로 typescript 코드를 javascript 코드로 컴파일할지에 대한 여러 옵션을 지정하고 있다. 언제든지 수정이 가능하지만 기본 옵션으로 우선 진행하자.

 

Setup Express server

이제 차근차근 하나씩 Express 서버를 구축해 보자. 우선 나는 Typescript를 사용할 거니까, express의 type definition file을 내려받자.

npm i @types/express --save-dev

 

프로젝트 루트 경로에 src폴더를 만들고 index.ts파일을 하나 생성하자.

src/index.ts

import express from 'express';
const app = express();

app.listen(3000, function () {
  console.log('server listening on port 3000');
});

Express server는 3000번 포트로 기동 하게끔 설정했고, 실행 후 callback 함수로 간단한 log를 찍어주었다.

일단 서버가 실행이 정상적으로 되는지 확인해 보자.

 

Node가 typescript 코드를 다이렉트로 실행하지 못하기 때문에 javascript로 컴파일해주어야 한다는 것을 다시 한번 인지하고 이를 도와주는 ts-node라는 패키지를 내려받자. ts-node는 Node로 Typescript를 다이렉트로 실행하게 도와주는 패키지인데 나는 어떤 코드의 변경사항이 생길 때 서버를 재기동하게끔 ts-node-dev를 내려받을 거다. ts-node-dev는 그냥 nodemon과 같은 녀석이라고 생각하면 된다.

npm i ts-node-dev --save-dev

이제 package.json 파일에서 script 부분을 수정하자.

ts-node-dev로 src/index.ts 파일을 실행하게끔 "dev"라는 커맨드를 추가했고, 이제 서버를 실행해 보자.

실행하면 정상적으로 아래 같은 로그가 출력되는 걸 확인할 수 있다.

server listening on port 3000

 

Connect to MongoDB

이제, MongoDB를 연동해 보자. MongoDB를 연동하기 위해 mongoose라는 패키지를 내려받자.

그리고 MongoDB Compass로 설치해야 한다. https://www.mongodb.com/products/tools/compass

npm i mongoose
npm i @types/mongoose --save-dev

이제 애플리케이션과 MongoDB를 연동해보자.

import express from 'express';
import mongoose from 'mongoose';
const app = express();

const MONGO_URI =
  'mongodb+srv://<username>:<password>@tutorial.qzgcfiu.mongodb.net/BlogService?retryWrites=true&w=majority&appName=AtlasApp';

const start = async () => {
  try {
    await mongoose.connect(MONGO_URI);
    mongoose.set('debug', true);
    console.log('MongoDB connected !');

    app.listen(3000, function () {
      console.log('server listening on port 3000');
    });
  } catch (error) {
    console.log(error);
  }
};

start();

Mongo Atlas에서 데이터베이스 만들고, Connect > Compass를 누르면 나한테 연결할 URI를 알려준다.

이렇게 연결을 하고 나서, 다시 재실행을 해보면 아래와 같은 로그가 찍히는 걸 확인할 수 있다.

MongoDB connected !
server listening on port 3000

그럼 성공적으로 연결은 다 끝난 상태이다. 이제 라우팅 및 API를 작업해 보자.

 

Setup routes

라우트를 작업하기 앞서, 우선 한 가지 미들웨어를 설정해줘야 하는데 express의 json이라는 녀석이다. 이 녀석은 쉽게 말해 외부의 요청이 들어올 때 전달되는 payload의 데이터가 Json 형식의 데이터가 들어오면 그것을 파싱 해주는 미들웨어라고 생각하면 된다. 즉, Json 데이터를 받아줄 수 있는 미들웨어. 

import express from 'express';
import mongoose from 'mongoose';
const app = express();

const MONGO_URI =
  'mongodb+srv://chiwon99881:VM3j4MQVKrMWW6K3@tutorial.qzgcfiu.mongodb.net/BlogService?retryWrites=true&w=majority&appName=AtlasApp';

const start = async () => {
  try {
    await mongoose.connect(MONGO_URI);
    mongoose.set('debug', true);
    console.log('MongoDB connected !');

    app.use(express.json()); // 이 부분 

    app.listen(3000, function () {
      console.log('server listening on port 3000');
    });
  } catch (error) {
    console.log(error);
  }
};

start();

 

이제 유저의 스키마 작업, 라우트 작업, API를 만들어보자.

우선, src/model/user라는 폴더를 만들고 해당 폴더에서 user.ts파일을 만들자.

src/model/user/user.ts

import { Schema, model } from 'mongoose';

export interface IUser {
  username: string;
  name: {
    first: string;
    last: string;
  };
  age: number;
  email: string;
}

유저 스키마에 적용될 interface를 작성하자. 내가 가질 유저 정보는 username, name, age, email이라는 데이터를 가진다.

import { Schema, model } from 'mongoose';

export interface IUser {
  username: string;
  name: {
    first: string;
    last: string;
  };
  age: number;
  email: string;
}

const userSchema = new Schema<IUser>(
  {
    username: { type: String, required: true, unique: true },
    name: {
      first: { type: String, required: true },
      last: { type: String, required: true },
    },
    age: Number,
    email: String,
  },
  { timestamps: true }
);

const User = model<IUser>('user', userSchema);
export default User;

이제 MongoDB에게 알려줄 User Schema를 만든다. 이 유저 스키마 정보는 곧 나의 데이터베이스의 Collection으로 만들어지게 된다.

username에서는 unique: true라는 옵션을 넣어주어서 같은 username을 가지는 유저를 방지하게 하고, timestamp: true라는 옵션을 넣어서 새로운 user document가 생성될 때마다 createdAt 데이터가 자동으로 추가되고 변경 시 updatedAt 정보를 갱신하게끔 만들어 주었다.

 

이제 mongoose패키지에서 model이라는 모듈을 import 하여 User모델을 mongoDB에게 알려준다. collection이름을 user라고 명시하였는데 이렇게 하면 실제로는 데이터베이스에 users라는 복수명으로 표시되어 만들어진다.

 

생성한 User model을 export default로 내보내자.

이렇게 만든다고 바로 MongoDB Compass에 collection이 보이지는 않는다. collection에 데이터가 실제로 추가가 되면 그때 비로소 collection이 나타나게 된다. 이를 확인하기 위해 router도 만들고 실제로 API를 구현해서 데이터를 생성해 보자.

 

router를 만들기 앞서, /src/model/index.ts파일을 만들고 model의 패키지의 모든 모델들을 한 번에 export 하게끔 해보자.

다른 파일에서 model의 특정 파일을 명시하지 않으면 기본적으로 model패키지의 index파일을 찾게 된다. 이를 이용해서 편리하게 export 해보자. 

 

/src/model/index.ts

import User from './user/user';

export { User };

이제 모델 패키지에 새로운 모델들이 만들어질 때마다 항상 이 파일에서 export 해주면 다른 파일에서 원하는 모델을 불러오고 싶을 때 index.ts파일을 통해 불러올 수 있다. 이는 이후에 새로운 모델이 생성된 후에 더 자세히 알아보자.

 

이것과 유사하게 routes 패키지도 같은 방식으로 처리해 줄 수 있다. 

import { userRouter } from './user/userRoute';

export { userRouter };

이러한 쓰임새는 바로 다음 /src/index.ts 파일에 사용되는 routes 패키지를 import 하는 부분에서 확인할 수 있다.

 

/src/routes/user/userRoute.ts 파일을 생성하자.

import { Router } from 'express';
import mongoose from 'mongoose';
import { User } from '../../models';

export const userRouter = Router();

// Create user
userRouter.post('/', async function (req, res) {
  try {
    const { username, name } = req.body;

    if (!username)
      return res.status(400).send({ error: 'username is required' });
    if (!name || !name.first || !name.last)
      return res
        .status(400)
        .send({ err: 'Both first and last name are required' });

    const user = new User(req.body);
    await user.save();
    return res.send({ success: true, user });
  } catch (e: any) {
    console.log(e);
    return res.status(500).send({ error: e.message });
  }
});

 

위 코드는 새로운 유저를 생성하는 API를 구현한 코드이다. 내가 구현할 유저 관련 API는 생성, 읽기, 수정, 삭제이다. 

우선 POST 메서드로 REST API를 구현하고 Path는 /users이다.

request의 body를 통해 username, name을 받는다. 이 코드에서는 email, age는 따로 검증하지 않았다.

username이 없으면 응답 코드를 400, 에러 메시지를 "username is required"로 반환하기로 했고 name이 없거나 name의 first, last값이 없으면 역시 응답 코드를 400, 에러 메시지를 "Both first and last name are required"로 반환하기로 한다.

 

위 검증을 모두 통과하면 유저를 생성하고 저장한다. user.save()를 실행하면 MongoDB에 해당 데이터가 저장이 된다.

모든 과정을 마치면, res.send()에 생성된 유저 정보를 넣어 반환한다.

 

 위 코드에서는 "/" 이렇게만 설정되어 있지만 Express app을 실행하는 부분에서 (src/index.ts) users라는 context를 설정해 줄 것이다. 아래 코드를 확인해 보자.

 

/src/index.ts

import express from 'express';
import mongoose from 'mongoose';
import { userRouter } from './routes';
const app = express();

const MONGO_URI =
  'mongodb+srv://chiwon99881:VM3j4MQVKrMWW6K3@tutorial.qzgcfiu.mongodb.net/BlogService?retryWrites=true&w=majority&appName=AtlasApp';

const start = async () => {
  try {
    await mongoose.connect(MONGO_URI);
    mongoose.set('debug', true);
    console.log('MongoDB connected !');

    app.use(express.json());

    app.use('/users', userRouter); // 이 부분

    app.listen(3000, function () {
      console.log('server listening on port 3000');
    });
  } catch (error) {
    console.log(error);
  }
};

start();

 

Test create user 

이제 생성 관련 API를 만들었으니 테스트해보자. 테스트를 해보기 위해 Postman을 사용해 보자.

Method는 POST, URL은 http://localhost:3000/users로 설정한 후 아래와 같이 body payload를 설정한 후 Request 해보자.

응답의 결과로 아래와 같은 화면을 돌려받을 수 있다.

정상적으로 데이터를 만든 API를 통해 주고받았고 데이터베이스에도 해당 데이터가 저장되었는지 확인하기 위해 MongoDB Compass를 실행해서 확인해 보자.

데이터베이스에도 정상적으로 데이터가 저장된 것을 확인할 수 있다. 저 데이터 형식에서 _id는 MongoDB에서 생성해 주는 고유값이다. 해당 id가 곧 RDBS에서 primary key라고 생각할 수 있는데, 우리는 저 key를 통해 각 유저를 조회하는 API도 만들 것이다.

 

한 가지 더 해보고 넘어갈 것은 또 다른 collection을 만들고 collection 사이의 관계를 맺어보는 것이다.

 

Blog collection

이제 새로운 collection하나를 더 만들어보자. 이 blog collection은 데이터로 유저 정보도 가지게 된다. 어떤 유저가 이 블로그의 주인인지를 알기 위해.

 

/src/model/blog/blog.ts

import { Schema, model, Types } from 'mongoose';
import { IUser } from '../user/user';

export interface IBlog {
  title: string;
  content: string;
  isLive: boolean;
  user: IUser;
}

const blogSchema = new Schema<IBlog>(
  {
    title: { type: String, required: true },
    content: { type: String, required: true },
    isLive: { type: Boolean, required: true, default: false },
    user: { type: Types.ObjectId, required: true, ref: 'user' },
  },
  { timestamps: true }
);

const Blog = model('blog', blogSchema);

export default Blog;

위 코드에서 확인할 것은 Blog라는 Schema의 데이터 중 user이다. 이렇게 collection끼리 관계를 갖게 할 수 있는데 그러기 위해선 ref라는 옵션을 사용해서 어떤 collection을 가리키는지 알려주어야 한다. User model을 저장할 때 model이름으로 'user'로 저장한 것과 똑같은 값인 'user'를 ref의 value로 설정해 주면 된다. 그리고 또 한 가지 유저의 type으로 mongoose.Types.ObjectId를 받는다. 이 ObjectId는 아까 MongoDB Compass에서 봤던 것처럼 고유값인 _id를 가리킨다.

 

이제 Blog collection을 생성했으니 연관된 API도 하나만 만들어보자. 

/src/routes/blog/blogRoutes.ts

import { Router } from 'express';
import { Blog, User } from '../../models';
import mongoose from 'mongoose';

export const blogRouter = Router();

// Create blog
blogRouter.post('/', async (req, res) => {
  try {
    const { title, content, isLive, userId } = req.body;
    if (!title) return res.status(400).send({ error: 'title is required' });
    if (!content) return res.status(400).send({ error: 'content is required' });
    if (isLive && typeof isLive !== 'boolean')
      return res.status(400).send({ error: 'isLive must be a boolean value' });
    if (!mongoose.isValidObjectId(userId))
      return res.status(400).send({ error: 'Invalid user id' });

    const user = await User.findOne({ _id: userId });
    if (!user) return res.status(400).send({ error: 'User does not exist' });

    const blog = new Blog({ ...req.body, user });
    await blog.save();

    return res.status(201).send({ blog });
  } catch (e: any) {
    console.log(e);
    res.status(500).send({ error: e.message });
  }
});

 

마무리

이렇게 Typescript와 MongoDB를 연동해 간단히 테스트해봤다. 전체 소스코드는 이 링크를 참조하면 된다. https://github.com/chyoni/mongodb

 

GitHub - chyoni/mongodb: Tutorial MongoDB

Tutorial MongoDB. Contribute to chyoni/mongodb development by creating an account on GitHub.

github.com

Typescript와 MongoDB를 사용하면서 느낀 점은 굉장히 간단하게 사용할 수 있는 반면에 mongoose가 가지고 있는 강력함이 Backend를 구축하기에 굉장히 큰 이점이 있다고 느꼈다. 

728x90
반응형
LIST

+ Recent posts