공부한 내용을 코드에 적용하니 보기에도 깔끔하고, 나중에 기능을 추가할때도 수월할것 같다.
'좋아요' 기능은 SNS에서 너무 흔하게 접하는 기능이라 단순하게 동작할 것이라고 착각하고 있었는데, 막상 구현해보려니 여러가지 생각해야할 점이 많았다.
좋아요와 싫어요의 동작은 '좋아요를 추가한다', '좋아요를 취소한다', '좋아요를 눌렀는데 싫어요가 있으면 싫어요를 취소하고 좋아요를 추가한다'. 등등 다양하게 일어나야한다. 그런데 이 각각의 상황을 코딩하지 않고 간단하게 할 수 없을까? 라는 생각이 들었다.
다음은 '좋아요 버튼 누름', '싫어요 버튼 누름'의 두 가지 상황에 따라 '좋아요'와 '싫어요'의 상태를 있으면 'T' 있지않으면 'F' 로 나타낸다.
1.좋아요 버튼을 누른다
좋아요T 싫어요F=>좋아요F 싫어요F
좋아요F 싫어요F=>좋아요T 싫어요F
좋아요F 싫어요T=>좋아요T 싫어요F
(좋아요와 싫어요가 모두 T 인 상황은 없다)
2.싫어요 버튼을 누른다
=>(좋아요 버튼의 반대이므로 생략)
위의 정리에서 보면 '좋아요' 버튼을 누르면 '싫어요'가 무조건 F가 되며, 좋아요 버튼은 반전되어야 한다는 사실을 알수있다. 그렇다면 좋아요를 '추가한다/삭제한다'의 두가지 요청을 mongoDB의 $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로 추가할 수 있다.
일단 이 문제에 대해 설명하기에 앞서 위의 내용을 바탕으로 내가 만들려고 하는 mongoDB 문서의 스키마를 살펴보자
{
_id: <리뷰받는사람puuid>
likeCount: <좋아요수>
disLikeCount: <싫어요수>
like_users:{
user1:true
user2:false
user3:true
}
dislike_users:{
user1:false
user2:true
user3:false
}
}
유저 id 리스트는 $not 연산자를 통해 해결했다고 치자. 그런데 가장 중요한 정보인 likeCount를 어떻게 증감시켜야 할까?
일단 2, 3의 방법은 지금의 내가 할 수 있는 방법인데 그다지 효율적이지 않은 것 같다.
1.의 방법을 찾으면 이 글을 다시 업데이트 하러 와야지
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등을 이용할 수 있는 방법이 있으면 한 번의 쿼리로도 수행이 가능하다... 이 방법을 알아내면 수정해야지)
그래서 하루동안의 삽질 끝에, 그냥 현재 상태를 받아온 뒤에 그 값에 따라 다시 쿼리를 보내기로 했다.
만들고 보니 코드가 너무 비효율적인것 같았다. 현재 좋아요/싫어요 상태를 받아오고, 그에 따라 또 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을 사용할 수 없다는 오류가 출력되었는데 어떻게 하다 보니까 오류없이 동작했다. 아직은 정상작동중이라서 왜 오류가 났었는지는 모르겠다.
현재 좋아요/ 싫어요 상태에 관련없이 바로 db에 명령을 보내기 때문에 속도가 빠름
클라이언트의 현재 좋아요,싫어요 상태와 db에 있는 값이 오류로 인해 다르더라도 사용자가 의도한 대로(좋아요 버튼을 눌렀다면 이전상태와 관련없이 좋아요 추가, 싫어요 취소를 눌렀다면 이전상태와 관련없이 싫어요 취소 등) 작동함
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('에러발생');
}
},
);
와 저랑 고민을 똑같은 흐름으로 하셔서 진짜 공감하면서 읽었습니다. 결국 api 4개로 처리한 것까지 동일하네요. 재밌게 읽었습니다.