mongoDB로 좋아요/싫어요 구현하기

정영훈·2021년 8월 18일
1
post-thumbnail

시작하기에 앞서, 그동안 한일 정리

  • promise와 async, await 을 공부했다. 처음에는 콜백으로도 충분했지만, 코드가 많아지면서 '콜백 지옥이 이런 것이구나' 라는 걸 뼈저리게 느꼈다. 예외처리 하기도 정말 힘들었고.

공부한 내용을 코드에 적용하니 보기에도 깔끔하고, 나중에 기능을 추가할때도 수월할것 같다.

  • github 레포지토리를 단체 계정으로 옮기고, 자동화된 kanban 프로젝트를 만들어 효율적으로 협업이 가능하도록 했다.

좋아요/싫어요 구현해 보기

생각보다 단순하지 않은 '좋아요' 기능

'좋아요' 기능은 SNS에서 너무 흔하게 접하는 기능이라 단순하게 동작할 것이라고 착각하고 있었는데, 막상 구현해보려니 여러가지 생각해야할 점이 많았다.

생각해야할 점들과 결론

1. 좋아요 중복 막기

  • 다른 매치에서 같은 사람을 다시 만났을 때는 어떻게 처리할 것인가
    =>매치 ID를 같이 저장하여 다시 평가를 할 수 있게 하는 방법과, 그냥 개인별로 한번씩만 평가가 가능하게 하는 방법이 있다. 하지만 듀오나 다인큐를 하는 상황에서 평가의 신뢰도를 위해서는 개인당 평가가 한번만 가능하게 하는 방법을 채택하기로 했다. 이에 따라, 전체 판수대비 매너 점수를 고려하는 방식은 재고해야할 필요가 있을 것 같다. 일반적인 유저 사이에서는 한번 만난 사람을 다시 만날 확률이 적지만, 천상계 유저들은 인원수가 제한되어있어 만났던 유저를 다시 만날 확률이 높기 때문이다.
  • 내가 전에 그사람에게 좋아요를 했다는 사실을 어떻게 체크할 것인가
    =>좋아요/ 싫어요 요청을 할때, 요청을 보낸 사람의 puuid도 포함하여 저장한다.

2. 좋아요/싫어요 취소와 상호작용

좋아요와 싫어요의 동작은 '좋아요를 추가한다', '좋아요를 취소한다', '좋아요를 눌렀는데 싫어요가 있으면 싫어요를 취소하고 좋아요를 추가한다'. 등등 다양하게 일어나야한다. 그런데 이 각각의 상황을 코딩하지 않고 간단하게 할 수 없을까? 라는 생각이 들었다.

다음은 '좋아요 버튼 누름', '싫어요 버튼 누름'의 두 가지 상황에 따라 '좋아요'와 '싫어요'의 상태를 있으면 'T' 있지않으면 'F' 로 나타낸다.

1.좋아요 버튼을 누른다
좋아요T 싫어요F=>좋아요F 싫어요F
좋아요F 싫어요F=>좋아요T 싫어요F
좋아요F 싫어요T=>좋아요T 싫어요F
(좋아요와 싫어요가 모두 T 인 상황은 없다)
2.싫어요 버튼을 누른다
=>(좋아요 버튼의 반대이므로 생략)

위의 정리에서 보면 '좋아요' 버튼을 누르면 '싫어요'가 무조건 F가 되며, 좋아요 버튼은 반전되어야 한다는 사실을 알수있다. 그렇다면 좋아요를 '추가한다/삭제한다'의 두가지 요청을 mongoDB의 $not 연산자를 통해 '좋아요 버튼을 누른다'는 하나의 요청으로 통합할 수 있겠다는 생각이 들었다.
(현업에서는 하나의 요청으로 통합하는게 안좋은 방법일 수도 있다. 내 뇌피셜로는 좋을거 같아서 해보는거임)

3. DB처리 속도

  • 좋아요를 취소했을때 좋아요를 눌렀던 사람의 ID를 삭제하는 편이 true를 false로 바꾸는 것보다 DB용량 절약에 좋지 않을까?
    =>그럴것 같다. 그런데 좋아요의 추가와 취소가 반복적으로 일어날 때에는? 만약 '삭제' 방식으로 동작한다고 가정하자. DB는 좋아요 누른 사람의 Id를 '좋아요'를 누른 유저Id 목록에서 검색한다. 이때 이 유저가 이미 좋아요를 눌렀다면 그 값을 삭제하고, 좋아요가 눌려있지 않다면 '좋아요'를 누른 유저 목록을 처음부터 끝까지 탐색한 후에 리스트에 좋아요 누른 사람의 id를 추가할 것이다. 나는 이 부분에서 좋아요가 없다면 무조건 끝까지 검색해본 후에 값을 추가하는 것이 비효율적이지 않을까 라는 생각을 했다. 중간에 false로 처리되어있는 해당 유저id를 발견하면 작업 속도가 단축되지 않을까 라고 생각했다. (물론 엄청나게 쌓여있는 false로 처리된 유저id들 사이에서 해당 값을 찾는게 '삭제처리되어 간소화된 유저 리스트'에서 검색하는 것보다 비 효율적일 수도 있다. 혹은 내가 몽고DB의 동작방식을 완벽히 알지 못하기 때문에 이렇게 생각하는 것일수도 있다.) 일단은 성능 문제가 일어나면 그때 더 생각해 보기로 하고 지금은 true를 false로 바꾸는 방법을 채택하겠다.

$not 연산자는 어떻게 쓰는걸까?

mongoDB manual 에 따르면 $not 연산자는

Evaluates a boolean and returns the opposite boolean value; i.e. when passed an expression that evaluates to true, $not returns false; when passed an expression that evaluates to false, $not returns true.

간단하게 말해서 $not 연산자는 그냥 not 연산자처럼 받은 boolean값에 not 연산을 해준다고 한다.
Toggle a boolean value with mongoDB를 참고하면 더 쉽게 이해할 수 있다.
(db에 이미 존재하는 값을 이용하려면 collection.updateOne()이 아니라 collection.findOneAndUpdate() 를 사용해야 한다는 사실도 이 글을 통해 알았다.)

소소한 문제점과 빠른 해결

근데 우리는 모든 유저에 대해 T or F 값을 저장해놓지 못한다. 그럼 값이 없을 때에는 어떻게 동작할까?
에 대한 해답은 다시 mongoDB manual 에서 찾을 수 있었다.

In addition to the false boolean value, $not evaluates as false the following: null, 0, and undefined values. The $not evaluates all other values as true, including non-zero numeric values and arrays.

null, 0, undefined도 false로 판단하여 연산해 준다고 한다. 참고로 0이아닌 숫자와 배열은 true로 판별한다고 한다.

다행이다. 좋아요와 싫어요 값이 없을 때에도 $not 연산자를 이용하면 true로 추가할 수 있다.

아직 해결하지 못한 문제

좋아요와 싫어요의 count를 어떻게 바꿀 것인가?

일단 이 문제에 대해 설명하기에 앞서 위의 내용을 바탕으로 내가 만들려고 하는 mongoDB 문서의 스키마를 살펴보자

{
_id: <리뷰받는사람puuid>
likeCount: <좋아요수>
disLikeCount: <싫어요수>
like_users:{
	user1:true
	user2:false
	user3:true
	}
dislike_users:{
	user1:false
	user2:true
	user3:false
	}
}

유저 id 리스트는 $not 연산자를 통해 해결했다고 치자. 그런데 가장 중요한 정보인 likeCount를 어떻게 증감시켜야 할까?

  1. 몽고 db에 있을 것 같은(아직내가 모르는) 기가막힌 기능(연산자)을 찾아내서 해낸다
  2. 받아온 결과를 토대로 다시 query를 보낸다
  3. 애초에 count를 이용하지 말고 like_user의 수를 그때그때 계산한다.

일단 2, 3의 방법은 지금의 내가 할 수 있는 방법인데 그다지 효율적이지 않은 것 같다.
1.의 방법을 찾으면 이 글을 다시 업데이트 하러 와야지

++2021-08-19 추가

발상의 전환과 포기

발상의 전환- DB 효율성에 대한 생각

DB 효율에 대해 계속 생각하다보니 like_users과 dislike_users 필드를 나누어서 저장하는 것은 심각한 비효율이라는것을 깨달았다. 쿼리마다 리뷰어ID를 두개의 필드에서 검색을 해야하고, 심지어 리뷰어ID정보도 중복된다. 그래서 새롭게 생각해낸 방법은

{
_id: <리뷰받는사람puuid>
likeCount: <좋아요수>
disLikeCount: <싫어요수>
ratings:{
	user1:true	
    //user2의 값은 삭제됨(좋아요 혹은 싫어요 취소)
	user3:false
	}
}

의 양식으로 문서를 구성하는 것이다. like_users과 dislike_users 필드를 ratings 필드 하나로 통합했다. 그리고 각 유저 id는 true 혹은 false 값을 갖는다.
true는 좋아요, false는 싫어요로 판정한다.
바뀐 방식에따라 '좋아요' 버튼의 동작은 다음과 같다.

1.좋아요 버튼을 누른다
T->(필드 삭제)
F->T
(값 없음)->T

이전 방법에 비해 훨씬 간단하다.

포기

이제 위의 방식을 이용하여 DB에 단일 쿼리를 날려 '좋아요' 버튼을 눌렀을 때의 동작을 구현하려고 했다. 그런데 내가 모르는 것이 너무 많은 탓인지 위의 방식으로 말끔하게 동작하는 쿼리를 작성하기가 너무 어려웠다. $cond 연산자를 이용하면 쉽게 되겠다고 착각했는데, $cond연산자는
$set연산자 안에서만 동작하는 등 내가 공부한 범위 내에서는 구현이 너무 복잡했다.
($cond 연산자안에서 $set, $unset등을 이용할 수 있는 방법이 있으면 한 번의 쿼리로도 수행이 가능하다... 이 방법을 알아내면 수정해야지)
그래서 하루동안의 삽질 끝에, 그냥 현재 상태를 받아온 뒤에 그 값에 따라 다시 쿼리를 보내기로 했다.

++2021-12-18

그냥 남들 하는대로 할까?

만들고 보니 코드가 너무 비효율적인것 같았다. 현재 좋아요/싫어요 상태를 받아오고, 그에 따라 또 update를 하고.. 그리고 그 결과를 다시 받아와서 클라이언트에 전해주고.. 현재 좋아요나 싫어요 상태에 상관없이 작동하면 좋겠다는 생각을 했다. 다른 사이트들은 어떻게 좋아요/싫어요를 구현했는지 궁금해서 유튜브 좋아요/싫어요 기능을 본 결과 (like, dislike, removelike, removedislike) 의 4가지 요청으로 동작하고 있다는 것을 알아냈다. 유튜브가 이렇게 하는 것에는 이유가 있을 것이라고 생각했고, 이를 구현하기 위해 간략한 표를 몇개 그렸다.

위에서부터 순서대로 되어야 하는 '결과', 위의 결과를 위해 DB가 해야하는 '동작', 그 동작을 위해 내가 주어야 하는 '명령' 순으로 간소화 해 보았다. 이렇게 정리를 하고 나니 필요한 필드가 달라졌다. like를 누른 유저 아이디를 저장하고 있는 array와 dislike를 누른 유저 아이디를 저장하고 있는 array 두개만 있으면 전체 좋아요/ 싫어요 수는 배열의 길이로 판별하고, 유저 아이디를 추가/삭제할때는 그냥 배열 안에서 탐색한 뒤 값을 업데이트하면 되었다. 위의 08/19 일에는 likecount의 증감 때문에 새로운 방식을 생각해 냈던 것이었는데 like 와 dislike를 배열로 가지고 있으면 그냥 배열의 길이를 사용하면 되기 때문에 likecount를 증감할 필요가 없었다.

그 다음 mongoDB에서 배열 업데이트를 어떻게 다루어야 하는지 찾아보았다.

배열의 요소를 제거할때는 $pull, 추가할때는 $push 연산자를 사용하면 된다고 한다. 그런데 $push를 이용하면 이미 좋아요 상태더라도 중복해서 유저 아이디가 입력되기 때문에 고민이었는데 조금 더 찾아보니 $addToSet연산자를 이용하면 upsert처럼 작동한다고 해서 적용해 보았다. 처음에는 non-array type에 addtoset을 사용할 수 없다는 오류가 출력되었는데 어떻게 하다 보니까 오류없이 동작했다. 아직은 정상작동중이라서 왜 오류가 났었는지는 모르겠다.

이 방법의 장점

  1. 현재 좋아요/ 싫어요 상태에 관련없이 바로 db에 명령을 보내기 때문에 속도가 빠름

  2. 클라이언트의 현재 좋아요,싫어요 상태와 db에 있는 값이 오류로 인해 다르더라도 사용자가 의도한 대로(좋아요 버튼을 눌렀다면 이전상태와 관련없이 좋아요 추가, 싫어요 취소를 눌렀다면 이전상태와 관련없이 싫어요 취소 등) 작동함

  3. 2.의 장점으로 인해 좋아요/싫어요 업데이트 이후 서버에서 최신 상태를 다시 알려주지 않아도 됨, 덕분에 좋아요 싫어요 수정이 빨라져서 사용성 개선

최종 코드

좋아요/싫어요 수 조회 api

router.get('/like/state', verifyToken, async (req, res) => {
        const reviewer = req.decoded.puuid;
        const state = { like: false, dislike: false };
        try {
            const result = await db.collection('like').findOne({
                _id: req.query.puuid,
            });
            if (!result) return res.json(state);
            result.like ? (state.like = result.like.includes(reviewer)) : null;
            result.dislike
                ? (state.dislike = result.dislike.includes(reviewer))
                : null;

            return res.json(state);
        } catch (err) {
            console.log(err);
        }
    });

좋아요/싫어요 입력 api

  router.post('/like', verifyToken, relationCheck, async (req, res) => {
        const reviewer = req.decoded.puuid;
        const receiver = req.body.receiverPuuid;
        try {
            option = { upsert: true };
            update = {
                $addToSet: { like: reviewer },
                $pull: { dislike: reviewer },
            };
            result = await db
                .collection('like')
                .updateOne({ _id: receiver }, update, option);

            console.log(
                `like added, { receiver : ${receiver}  reviewer : ${reviewer} }`,
            );
            res.send('ok');
        } catch (err) {
            console.log(err);
            res.send('에러발생');
        }
    });
    router.post('/dislike', verifyToken, relationCheck, async (req, res) => {
        const reviewer = req.decoded.puuid;
        const receiver = req.body.receiverPuuid;
        try {
            option = { upsert: true };
            update = {
                $addToSet: { dislike: reviewer },
                $pull: { like: reviewer },
            };
            result = await db
                .collection('like')
                .updateOne({ _id: receiver }, update, option);
            console.log(
                `dislike added, { receiver : ${receiver}  reviewer : ${reviewer} }`,
            );
            res.send('ok');
        } catch (err) {
            console.log(err);
            res.send('에러발생');
        }
    });

    router.post('/removeLike', verifyToken, relationCheck, async (req, res) => {
        const reviewer = req.decoded.puuid;
        const receiver = req.body.receiverPuuid;
        try {
            update = { $pull: { like: reviewer } };
            result = await db
                .collection('like')
                .updateOne({ _id: receiver }, update);
            console.log(
                `like removed, { receiver : ${receiver}  reviewer : ${reviewer} }`,
            );
            res.send('ok');
        } catch (err) {
            console.log(err);
            res.send('에러발생');
        }
    });
    router.post(
        '/removeDislike',
        verifyToken,
        relationCheck,
        async (req, res) => {
            const reviewer = req.decoded.puuid;
            const receiver = req.body.receiverPuuid;
            try {
                update = { $pull: { dislike: reviewer } };
                result = await db
                    .collection('like')
                    .updateOne({ _id: receiver }, update);
                console.log(
                    `dislike removed, { receiver : ${receiver}  reviewer : ${reviewer} }`,
                );
                res.send('ok');
            } catch (err) {
                console.log(err);
                res.send('에러발생');
            }
        },
    );
profile
한줄로 소개할 수 없는 사람. 어 소개되네?

1개의 댓글

comment-user-thumbnail
2022년 11월 6일

와 저랑 고민을 똑같은 흐름으로 하셔서 진짜 공감하면서 읽었습니다. 결국 api 4개로 처리한 것까지 동일하네요. 재밌게 읽었습니다.

답글 달기