⚠️ 이 게시물의 내용은 매~우 비효율적인 무한대댓글 구현을 다룬다.
⚠️ 익혀가는 과정을 적은 것일 뿐이고, 참고만 하는 것이 좋다 ! !

구현 결과부터 보여주자면 이렇게 잘 작동한다.

백, 프론트 둘 다 직접 구현해봤다.
예전에 작성했던 https://hwangjiwon1.tistory.com/64, https://hwangjiwon1.tistory.com/66 의 기억을 살려서 구현해보았다. 역시 생각만 해볼 때와 직접 구현해볼 때와의 난이도 차이는 천차만별이다. 링크드 리스트 + 트리 느낌.. 으로 해보았는데 힘들었다.
 

백엔드

가장 먼저 게시물과 댓글의 스키마를 알아야 추후의 내용을 이해하기 쉬울 것 같다.

// 게시물
const Post = new Schema({
  title: String,
  body: String,
  createdAt: {
    type: Date,
    default: Date.now
  },
  account: {
    type: mongoose.Types.ObjectId,
    ref: 'Account'
  },
  comments: [
    {
      type: mongoose.Types.ObjectId,
      ref: 'Comment'
    }
  ]
})

// 댓글
const Comment = new Schema({
  body: String,
  username: String,
  createdAt: {
    type: Date,
    default: Date.now
  },
  account: {
    type: mongoose.Types.ObjectId,
    ref: 'Account'
  },
  parent: {
    type: mongoose.Types.ObjectId,
    ref: 'Comment',
    default: null
  },
  children: [
    {
      type: mongoose.Types.ObjectId,
      ref: 'Comment'
    }
  ],
  depth: {
    type: Number,
    default: 0
  }
})
  1. 게시물에는 바로 달린 댓글(depth 0짜리)을 담는 comments 배열이 있다.
  2. 대댓글의 구현을 위해, 각 댓글에는 자신의 조상 댓글의 id를 담는 parent, 대댓글의 id를 담는 children 배열이 있다.

따라서 댓글이 하나의 게시물로부터 트리처럼 뻗어나갈 수 있다.
댓글과 그 대댓글들은 각자 참조하고 있는 id값을 통해서, 대댓글의 대댓글들을 무한히 확인할 수 있는 방식으로 구현되어있다.
 
이건 Post의 댓글, 대댓글들을 재귀적으로 찾아주는 메소드이다. getRepliesRecursively() 내부의 populate()가 핵심이다. 댓글 1에 달린 대댓글 1-1이 있다면, 그 녀석은 댓글 2보다 상위노출 되어야한다. 따라서 DFS처럼 구현했다.
백엔드에서 주는 _id 값을 통해서 해당 _id에 해당하는 댓글이나 대댓글에 대댓글을 작성할 수 있다.

Post.methods.getComments = async function () {
  const comments = [];

  // 재귀적으로 댓글과 대댓글들을 뽑아내는 함수
  const getRepliesRecursively = async (commentId) => {
    
    const comment = await Comment.findById(commentId)?.populate('children');

    if (!comment) {
      return;
    }

    console.log(comment.body);

    comments.push({
      _id: comment._id,
      body: comment.body,
      username: comment.username,
      createdAt: comment.createdAt,
      depth: comment.depth,
    });

    for (const childId of comment.children) {
      await getRepliesRecursively(childId);
    }
  };

  // Post의 댓글들을 차례대로 순환하며 재귀적으로 대댓글 가져오기
  for (const commentId of this.comments) {
    await getRepliesRecursively(commentId);
  }

  return comments;
};

구현의 한계점

게시물의 댓글 배열, 댓글의 대댓글 배열에 실제 데이터가 아닌 id값만 갖고 있다. 이에 대한 단점은 아래와 같다.

  • Comment.findById(commentId)?.populate('children');에서 모든 댓글을 순환하며 탐색한다. 엄~~~~~~~청 느리다.
    • 모든 게시물의 모든 댓글을 가져올 때 O(모든 댓글 수^2)의 시간복잡도를 갖는다.
    • 댓글이 1만개만 되더라도 1억번의 연산을 거쳐야한다.
    • 훨씬 빠른 방법을 샤워하다가 생각해냈다. (나중에)
  • Model.findOne()은 O(N), Model.findOneById()는 O(1) 짜리 함수인 줄 알았다. 모델의 데이터마다 고유한 id를 가지니까… O(1)로 생각했다. 이는 오해였다.

What is the difference between Model.findOne() & Model.findById() in Mongoose?

Consider we are searching a document from MongoDB based on the _id value. Which one of the following code is efficient ? ModelObj.findById(IdValue).exec(callback); ModelObj.findOne({ '_id': IdValu...

stackoverflow.com

프론트

백엔드에서 가공을 전부 해줬다. 따라서 배열 하나에 모든 댓글이 다 들어있다.
프론트에서는 이런 값을 받는다.

더보기
[   // 댓글이 많이 달린 게시물
    {
      _id: new ObjectId("64ce154c7bbfe751997449fe"),
      body: '이건 2번째',
      username: '1234',
      createdAt: 2023-08-05T09:24:28.499Z,
      depth: 0
    },
    {
      _id: new ObjectId("64ce15607bbfe75199744a04"),
      body: '뎁스는?',
      username: '1234',
      createdAt: 2023-08-05T09:24:48.790Z,
      depth: 1
    },
    {
      _id: new ObjectId("64ce15687bbfe75199744a09"),
      body: '뎁스는?',
      username: '1234',
      createdAt: 2023-08-05T09:24:56.058Z,
      depth: 2
    },
    {
      _id: new ObjectId("64ce15747bbfe75199744a0e"),
      body: '이번에도 2겠죠?',
      username: '1234',
      createdAt: 2023-08-05T09:25:08.121Z,
      depth: 2
    },
    {
      _id: new ObjectId("64ce304a3454d9f34a4b8d48"),
      body: '중간에다가 depth 3 끼워넣기',
      username: '1234',
      createdAt: 2023-08-05T11:19:38.594Z,
      depth: 3
    },
    {
      _id: new ObjectId("64ce305c3454d9f34a4b8d4d"),
      body: 'depth 4 끼워넣기',
      username: '1234',
      createdAt: 2023-08-05T11:19:56.397Z,
      depth: 4
    },
    {
      _id: new ObjectId("64ce30633454d9f34a4b8d52"),
      body: '5 끼워넣기',
      username: '1234',
      createdAt: 2023-08-05T11:20:03.153Z,
      depth: 5
    },
    {
      _id: new ObjectId("64ce19595388b4089868fec8"),
      body: '이번에도 2겠죠?',
      username: '1234',
      createdAt: 2023-08-05T09:41:45.864Z,
      depth: 2
    },
    {
      _id: new ObjectId("64ce268605331dafbbac3928"),
      body: '정렬은 대댓글의 부모가 1순위',
      username: '1234',
      createdAt: 2023-08-05T10:37:58.591Z,
      depth: 1
    },
    {
      _id: new ObjectId("64ce26dd3640bcedce3e976a"),
      body: '그래서 여기에 대댓글 달면 아래보다 위에 생성됨',
      username: '1234',
      createdAt: 2023-08-05T10:39:25.009Z,
      depth: 2
    },
    {
      _id: new ObjectId("64ce269705331dafbbac392c"),
      body: '생성 시각이 2순위',
      username: '1234',
      createdAt: 2023-08-05T10:38:15.386Z,
      depth: 1
    }
]

depth를 토대로 사용자에게 보여주기만 하면 된다.
 
JSX에서 렌더링할 때, 연속된 공백은 하나의 공백으로 치환된다. 따라서 밑의 코드에서 2번 방식으로 작성해줘야 띄어쓰기를 제대로 출력할 수 있다.

// 안 되는 코드
const generateCommentForm = (comment) => {
    const space = '     '.repeat(comment.depth); // 여러개의 공백이 무시된다.

    return (
      <div className='flex justify-between'>
        <div>
          {space} {comment.username} : {comment.body}
        </div>
        <div className='text-xs'>
          {comment.createdAt.slice(5, 16)}
        </div>
      </div>
    )
  }

// 되는 코드
const generateCommentForm = (comment) => {
    const space = '\\u00A0\\u00A0\\u00A0\\u00A0'.repeat(comment.depth);

    return (
      <div className='flex justify-between'>
        <div>
          {space} {comment.username} : {comment.body}
        </div>
        <div className='text-xs'>
          {comment.createdAt.slice(5, 16)}
        </div>
      </div>
    )
  }

 

카카오 테크 캠퍼스에서 트래픽이 몰리는 경우를 대비하기 위한 ‘레디스’라는 개념을 봤다. 몰라서 찾아보았는데, 엄청난 것이었다.

해피쿠 블로그 - [Redis] 인-메모리 데이터베이스 Redis

 

해피쿠 블로그 - [Redis] 인-메모리 데이터베이스 Redis

누구나 손쉽게 운영하는 블로그!

www.happykoo.net

이 블로그의 내용이 첫 이해에 정~~말 많이 도움이 됐다.

 

Redis는 DBMS(DataBase Management System)인데, 메모리 기반 DBMS이다.

 

보통 Session Management, Look aside Cache, Write Bach 목적으로 사용된다고 하는데, 해당 설명은 블로그에 너무 쉽고 잘 설명되어 있어서 다루지 않는다. 나중에 직접 구현할 때 정리해 두면 좋을 것 같긴 하다.


검색하다 보니, O(N) 실행속도의 명령어들을 조심해서 사용하라는 내용을 지속해서 봤다. 따라서 이 부분에 대해 찾아보고 정리할 것이다.

 

알고리즘 문제를 풀어보면서 O(N) 보다 속도가 빠른 것은 O(log n)과 O(1) 밖에 없었다. 전자의 가장 흔한 예시는 이분탐색이 있겠고, 후자의 가장 흔한 예시는 push, pop 정도?

아무튼 내 상식상 O(N)이 걸리는 작업은 결국 O(N)에 걸쳐서 해결해야 하는 것 아닌가 싶었다.

SCAN

O(N)의 속도를 가진 KEYS는 모든 키를 검색하는 명령어이다. 원하는 패턴과 일치하는 모든 키를 찾을 때 사용이 되는데, 결국 하나씩 전부 확인하며 찾아야 해서, O(N)의 속도를 가질 수밖에 없다.

 

Redis는 Single Thread로 동시에 딱!!! 하나의 명령만 처리할 수 있기에, KEYS와 같이 긴 시간이 필요한 명령을 수행하다보면 터진다. 뒤의 명령이 밀리도록 하면 안 된다.

대표적인 O(N) 명령은 KEYS, FLUSHALL, FLUSHDB, Delete Collections, Get All Collections로,

아이템의 N이 적다면, 당연히 문제가 되지 않는다. 근데 아이템이 많다면 위에서 말한 문제가 터지기 시작한다.

 

SCAN 명령은 KEYS와 비슷한데, 짧은 명령을 많이 보내는 것이다.

명령과 명령 사이에 다른 O(1) 짜리 짧은 명령어 수천, 수만개씩을 처리할 수 있기 때문에 터지지 않도록 방지할 수 있다.

 

Collection의 모든 item을 가져와야 하는 상황을 대비하기 위해서는 애초에 Collection을 작은 여러 개의 Collections로 나눠서 저장하면 좋다.

그렇게 하나씩 가져오면 좋다.

 

redis KEYS 공식문서

 

KEYS

Returns all key names that match a pattern.

redis.io

KEYS vs SCAN stackoverflow

 

SCAN vs KEYS performance in Redis

A number of sources, including the official Redis documentation, note that using the KEYS command is a bad idea in production environments due to possible blocking. If the approximate size of the d...

stackoverflow.com

⚠️ 이 글에 틀린 정보가 숨어있을 확률이 높다..

 

 

무한 대댓글을 구현하는게 어렵다고 해서, 어떻게 구현할 수 있을까 생각을 해봤다.

express + Mongo db 의 입장에서만 생각해봤다!!

일단 이런 방식의 계속해서 깊어지는 댓글을 구현하는 방법을 중심으로 떠올려봤다.

 

각 깊이별로 차례차례 생각해보았다.

진짜 이 순서대로 생각해본 거다!

 

Depth 0

post에 직접적으로 달리는 comment는 post._id 를 통해서 삽입이 된다.

post._id를 통해서 기본적인 대댓글 없는 댓글은 구현이 가능하다. (나도 방금 해봤다.)

Depth 1

그러면 댓글에 댓글을 다는, 대댓글은 어떻게 해야할까?

comment._id를 통해서 커멘트에 커멘트를 다는 방식으로 구현하면 될 것 같았다.

child comment 배열을 통해서 해당 댓글에 달린 대댓글을 전부 추적할 수 있다.

Depth 무제한

https://granora2019.tistory.com/entry/NodeJS와-MongoDB로-SNS-만들어보기-userment-댓글대댓글-라우터

 

NodeJS와 MongoDB로 SNS 만들어보기 - user_ment (댓글/대댓글 라우터)

2019/11/14 - [띡딱똑띡 개발자 Hanna] - NodeJS와 MongoDB로 SNS 만들어보기 - user_postingPlus (게시글 라우터2) NodeJS와 MongoDB로 SNS 만들어보기 - user_postingPlus (게시글 라우터2) 2019/11/14 - [띡딱똑띡 개발자 Hanna]

granora2019.tistory.com

https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEQW7z%2FbtqzLEptmPu%2Fl1eTk3YW6uk8YtgWb1C930%2Fimg.png

위 게시물의 그림을 보면서 도움 받았다.

솔직히 그림만 보고 텍스트는 읽지 않았다.

 

깊이 1의 유기적인 데이터들을 생각하다보니,

링크드 리스트 + 트리 느낌으로 구현할 수 있을 것 같았다.

 

Depth는, parent node의 depth에서 1을 더해주면 쉽게 구할 수 있다.

 

게시물부터 depth를 명시해줘서 (포스트는 안 해줘도 되고) 모든 comment는 자신의 parent node의 depth를 따라가면 될 것 같다.

프론트에선 백엔드에서 미리 계산해준 depth를 통해서 들여쓰기와 같은 방법으로 쉽게 무한 대댓글을 구현할 수 있다.

백엔드에서의 구현은 이 정도면 된다..

 

그럼 depth를 명시해주지 않으면 프론트에선 어떻게 해야할까?

발단

const axios = require('axios');
const jwt = require('jsonwebtoken');

// 비밀키를 비동기적으로 얻기 위한 함수 (외부 API 호출)
function getSecretKey(callback) {
  axios.get('<https://example.com/getSecretKey>')
    .then(response => {
      const secretKey = response.data.secretKey;
      callback(null, secretKey); // 에러는 null, 비밀키는 응답 데이터에서 가져온 값
    })
    .catch(error => {
      callback(error, null); // 에러 발생 시 에러 객체를 전달
    });
}

// JWT 생성을 위한 콜백 함수
function generateJwt(payload) {
  return function (err, secret) {
    if (err) {
      console.error('Failed to get secret key:', err);
      return;
    }

    jwt.sign(payload, secret, (err, token) => {
      if (err) {
        console.error('JWT signing error:', err);
        return;
      }

      console.log('Generated JWT:', token);
    });
  };
}

// 비밀키를 얻고 JWT 생성
const payload = { user_id: 123456 };
const jwtCallback = generateJwt(payload); // 클로저를 활용하여 콜백 함수 생성

getSecretKey(jwtCallback);

위의 코드를 이해하기가 힘들었다.

 

return function은 말 그대로 함수를 다시 반환하는 것일텐데.. 뭔가 어떻게 함수를 반환한다는 것인지 이해가 안 갔다.

문제

먼저, 이 코드를 이해하려면 일급 객체와 해당 문서 안에 설명되어 있는 고차함수, 콜백 정도는 제대로 알고 있어야한다고 생각한다.

자바스크립트의 1급 객체의 특성을 이용해서, 함수를 리턴하는 예시를 보여보자면 이런게 있겠다.

function add(num1) {
	return function (num2) {
    return num1 + num2;
	};
}

add(1)(2) // = 3

인자 num1을 그대로 저장해서 리턴된 함수에서 써먹을 수 있는 모습을 볼 수 있다.

 

극단적으로 이런 것도 당연히 가능하다. (다른 파일이라는 가정 하에)

function add(num1) {
  return function (num2) {
    return function (num3) {
      return function (num4) {
        return num1 + num2 + num3 + num4;
      };
    };
  };
}

add(1)(2)(3)(4); // = 10

결론

내가 읽지 못하던 코드는 막 어려운 내용이 아니었다.

내가 return function을 잘 이해하지 못해서 생긴 문제였다. 자바스크립트에 대한 이해도가 낮았다.

 

이해가 가는 입장에서 데이터의 흐름을 번호로 달아서 설명을 해보자면,

const axios = require('axios');
const jwt = require('jsonwebtoken');

function getSecretKey(callback) {
  axios.get('<https://example.com/getSecretKey>')
    .then(response => {
      const secretKey = response.data.secretKey;
      callback(null, secretKey);
    })
    .catch(error => {
      callback(error, null);
    });
}

function generateJwt(payload) {
  return function (err, secret) {
    if (err) {
      console.error('Failed to get secret key:', err);
      return;
    }

    jwt.sign(payload, secret, (err, token) => {
      if (err) {
        console.error('JWT signing error:', err);
        return;
      }

      console.log('Generated JWT:', token);
    });
  };
}

const payload = { user_id: 123456 };
const jwtCallback = generateJwt(payload); // 1. generateJwt에 의해 함수가 생성된다. 이 함수는 jwtCallback에 대입된다.

getSecretKey(jwtCallback); // 2. getSecretKey에 jwtCallback에 대입된 함수가 인자가 들어간다.

// 3. getSecretKey가 작동하면서 'example.com/getSecretKey' 에서 정보를 받아온다.

// 4-1. 정보를 받아오는데에 성공했다면, .then 으로 넘어가서 jwtCallback에 대입되었던 함수로 넘어간다. 첫번째 매개변수인 err이 null 값으로, 토큰 값을 출력한다.
// 4-2. 정보를 받아오는데에 실패했다면, .catch 로 넘어가서 jwtCallback에 대입되었던 함수로 넘어간다. 첫번째 매개변수인 err에 값이 있다. 따라서 에러를 출력하고 리턴된다.

이 정도로 설명할 수 있겠다.

코드가 이해가지 않을 땐 나눠서 이해해보려고 하면 금방 이해가 가는듯 하다.

 

잘못된 부분이 있다면 댓글 부탁드립니다!!

+ Recent posts