[mongoDB] 관계된 데이터 효율적으로 읽기

9rganizedChaos·2021년 7월 17일
0
post-thumbnail

🙌🏻 해당 글은 김시훈님의 mongoDB 기초부터 실무까까지의 강의 노트입니다.

지금까지 어떻게 node.js에서 mongoDB를 어떻게 사용하는지에 대한 기본을 다루었다. 앞으로 다룰 것은 mongoose, mongoDB의 심화기능을 통해 어떻게 퍼포먼스를 개선하는지에 대한 것이다. 이번 레슨에서는 CRUD에서 Read에 해당하는 부분을 다룰 것인가. 어떻게 client 단에서 사용하기 편한 데이터를 만들 것인가에 대한 부분이다.

faker로 데이터 생성하기

faker.js는 테스트 할 때 가상데이터를 만드는 용도로 쓰이는 npm 모듈이다. 이름, 회사이름, 전화번호 등 많이 사용하는 정보를 랜덤으로 생성해준다.

axios를 이용해서 client 코드 만들기!

axios 패키지를 다운받아 클라이언트 코드 작성.

const test = async () => {
  let {
    data: { blogs },
  } = await axios.get("http://localhost:3000/blog");
  blogs = await Promise.all(
    blogs.map(async (blog) => {
      const res1 = await axios.get(`http://localhost:3000/user/${blog.user}`);
      const res2 = await axios.get(
        `http://localhost:3000/blog/${blog._id}/comment`
      );
      blog.user = res1.data.user;
      blog.comments = res2.data.comments;
      return blog;
    })
  );
  console.log(blogs[0]);
};

클라이언트 코드 실행 시 nodemon 때문에 서버가 잠깐 함께 꺼졌다가 켜지는 일이 발생한다.
이 때 발생하는 에러를 방지해주기 위해서 package.json에서 "dev": "nodemon --ignore client.js src/server.js와 같이 설정해준다.

클라이언트 코드 리팩토링과 마무리

  • 반복되는 uri 부분을 변수로 빼주기
  • promise.all을 한 번 더 사용한다.
  • comment에도 user 정보 추가
const URI = "http://localhost:3000";

const test = async () => {
  let {
    data: { blogs },
  } = await axios.get(`${URI}/blog`);
  blogs = await Promise.all(
    blogs.map(async (blog) => {
      const [res1, res2] = await Promise.all([
        axios.get(`${URI}/user/${blog.user}`),
        axios.get(`${URI}/blog/${blog._id}/comment`),
      ]);
      blog.user = res1.data.user;
      blog.comments = await Promise.all(
        res2.data.comments.map(async (comment) => {
          const {
            data: { user },
          } = await axios.get(`${URI}/user/${comment.user}`);
          comment.user = user;
          return comment;
        })
      );
      return blog;
    })
  );
  console.dir(blogs[0], { depth: 10 });
};

console.log 사용할 때 depth가 3-4보다 깊어지면 [Object]와 같은 식으로 압축되어 표시된다. 그럴 때, console.dir을 사용하도록 한다. (위 코드 참고)

N+1 problem

위에서 작성한 코드의 흐름을 한 번 살펴보자.

1) 먼저 blogs를 요청해 받아온다.
2) blog를 하나씩 map을 통해 조회한다.
2-1) blog에 해당하는 user를 요청해 받아온다.
2-2) blog에 해당하는 comment를 요청해 받아온다.
3) comments를 하나씩 map을 통해 조회한다.
3-1) comment에 해당하는 user를 요청해 받아온다.

만약 blog가 10개, comment가 10개 있다고 하면, 얼마나 많은 요청을 보내게 될까?

1) 1번
2-1) 10번
2-2) 10번
3-1) 100번

총 121번의 요청을 보내게 되는데, 이것은 매우 비효율적인 서버와의 소통방식이다.
그나마 promise.all로 묶었기 때문에 성능이 조금은 개선되었을 것이다.
그러나 클라이언트가 백에 보내는 요청은 최소화하는 것이 효율적이다.
현재 작성한 코드는 절대 운영에 적합한 방식의 코드가 아니다.

위와 같이, 연관관계가 설정된 엔티티를 조회할 때 조회된 데이터 갯수(n)만큼 연관관계의 조회 쿼리가 추가로 발생하는 것을 N+1 문제라고 한다.

성능 측정하기

console.time을 이용해 앞으로 개선할 코드의 성능을 측정해보도록 한다.
console.time은 console.timeEnd와 함께 사용되어, 두 코드 사이의 경과시간을 출력해준다.
프로덕션에서는 하나의 클라이언트 요청에 대해 200ms ~ 500ms 정도의 성능이 나오는 것이 평균적!

위에서 작성한 현재의 코드를 검토하면, 3500ms 정도가 출력되고 있다.

populate로 자식문서 효율적으로 불러오기

이제 우리는 get API를 수정해서 클라이언트와 백의 소통을 대폭 줄이고자 한다. 클라이언트는 단 한 번만 요청을 보내고 백이 데이터베이스와 소통한 후에 한 번에 응답을 돌려주록 한다. 여기에 사용되는 메서드가 바로 populate()이다! 아래와 같이 코드를 작성한다.

// 블로그 전체 읽어오기
blogRouter.get("/", async (req, res) => {
  try {
    let blogs = await Blog.find({})
      .limit(200)
      .populate([
        { path: "user" },
        { path: "comments", populate: { path: "user" } },
      ]);
    return res.send({ blogs });
  } catch (err) {
    console.log(err);
    res.status(500).send({ err: err.message });
  }
});

populate의 역할을 좀 더 단순하게 요약해보면, 모델에서 설정해놓은 ref 부분의 정보를 가져와서 채워라! 라는 뜻인 것이다. 실제로 mongoose를 디버깅해보면 아래와 같이 $in이라는 몽고디비 문법이 발견된다. 이는 $in에 해당하는 id가 있으면 그 정보를 전부 가져오는 명령어이다.

또한 위 디버깅된 것을 살펴보면 중복된 Id들이 제외되고 있다는 것도 알 수 있다.

virtual populate

그러나 key가 없는 경우에 대해서는 어떻게 populate 메서드를 사용해줄 수 있을까?
예를 들면 위 코드에서 블로그 모델에 user라는 키는 있지만 comments라는 키는 없다!
이럴 때는 아래와 같이 코드를 작성해준다!

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

BlogSchema.set("toObject", { virtuals: true });
BlogSchema.set("toJSON", { virtuals: true });

그리고 또 하나 알 수 있는 것은 우리는 populate 안에서 또 다시 populate를 불러올 수 있다는 것!

모쪼록 우리는 성능이 대폭 개선된 것을 확인할 수 있다!

profile
부정확한 정보나 잘못된 정보는 댓글로 알려주시면 빠르게 수정토록 하겠습니다, 감사합니다!

0개의 댓글