본문 바로가기

프로젝트/✍️ [전공자들]

[전공자들 13] 좋아요/싫어요 구현하기 (mongodb/react/spring)

이전에 만들었던 스크랩, 댓글공감 기능은 토글형식으로 한 버튼을 끄고 켜고 하는 느낌이었다면, 

좋아요/싫어요는 두 버튼이 서로 연관되어 있으니까 기능을 만들기 전에 생각해 볼 점들이 있었다.

 

왼쪽부터 - 기본상태 / 좋아요를 누른 상태 / 싫어요를 누른 상태

 

 

생각해야할 것들

🤔 좋아요/싫어요를 어떤 형태로 저장할까?

  • 일단 게시글의 db구조는 다음과 같다. 
  • { _id: ObjectId("657d07e7073cbe0ef10edb90"), title: '제목', content: '내용', writer: { 작성자정보객체 }, goods: [], bads: [], ⋮ }​
  • 방법 1 : 게시글의 좋아요 목록을 문자열 리스트로 저장하는 방법.(유저 이메일 리스트) 현재 유저의 이메일을 받아와서 현재 유저가 목록에 있으면 지우고, 없으면 추가하는 방식(완전제거) 
    • 장점 : 구현이 단순하다. 유저 이메일만 받아 저장하기 때문에 구현자체는 간단하다.
    • 단점 : 동시성 문제. 여러 사용자가 같은 게시글에 동시에 접근할 때, 충돌이 일어날 수 있다. 예를 들어 A가 좋아요를 누르고, B는 좋아요를 제거하는 작업이 동시에 일어나게 된다면 데이터베이스는  어떤 작업을 먼저 처리해야 할지 알 수 없게 되고, 결국 의도하지 않은 방향으로 업데이트가 될 수도 있다!!
  • 방법 2 : 게시글의 좋아요/싫어요 목록을 객체 형식 ({유저id:T/F}) 으로 저장하는 방법. 현재 유저가 좋아요/싫어요를 클릭하면 db에서 현재 유저의 상태를 찾아 T/F로 변경하기.
    • 장점 : 동시성 문제 감소. 각 객체가 별도로 동작하기 때문에 1번 방법에 비해 동시성 문제를 줄일 수 있다. 하지만 완전 제거가 아닌 가능성을 줄여주는 정도라 동시성 문제를 완전히 제거하기 위해서는 추가적인 설정을 해주는 것이 좋다.
    • 단점 : 구현의 복잡성. 단순히 유저 이메일의 포함여부만 판단해서 추가/제거하는 1번 방법과 달리, T상태인지 F상태인지를 읽어와서 반대로 설정해줘야 하기 때문에 관리와 구현이 상대적으로 복잡하다. 
  • 나는 2번 방법을 사용해 데이터를 관리하기로 했다. 가장 큰 이유는 2번 방법이 db상에서 읽기 작업이 더 효율적이라고 판단했다. Spring에서 MongoDB로의 데이터 수정작업을 할 때도 두 가지 방법을 사용할 수 있는데 바로 MongoRepository를 사용하거나 MongoTemplate를 사용하는 것이다.
  • MongoRepository : 객체 지향적인 수정 방식. 서비스에서 객체를 꺼낸 후 수정 작업을 거쳐 save()를 통해 업데이트 한다. 기본적인 CRUD 메소드를 제공하여 조작이 쉽고 단순한 작업에 적합. 
  • MongoTemplate : 객체를 로드하지 않고 db상에서 쿼리를 통해 직접 조작할 수 있다. 단순하고 반복적인 수정 작업이 필요한 경우, 대량의 데이터를 처리해야 하는 경우에 사용하기 좋다. 복잡한 쿼리를 다룰 수 있다.

 

💡 모든 사항을 고려했을 때, 좋아요/싫어요 기능의 경우 일반적으로 수많은 사용자가 동시에 접근하고, 상태를 변경할 가능성이 높기 때문에 성능과 동시성을 고려할 때 MongoTemplate의 수정쿼리를 작성해 유저의 T/F상태를 직접적으로 변경하는 방법을 채택했다! 처음엔 

추가적으로, 이걸 생각하는 과정을 통해 어떤 기능에 어떤 db접근방식을 선택할 지의 판단에 도움이 되었다.
게시글 작성/수정이나 유저의 회원가입/정보변경 등의 기능은 객체를 통째로 가져와 작업을 해야하므로 mongoRepository를 사용해 꺼내고 저장하는 것이 좋은 것 같고
사실 어제 스크랩/공감 기능을 mongoRepository로 만들었는데, 전체 게시글을 가져왔다가 다시 넣는 코드를 작성하면서도 이상하게 위화감이 들었다. 오늘 그 이유를 알게 되었고, 반복적이고 단순한 스크랩/공감 기능도 나중에 리팩토링을 하며 db성능을 향상시켜야겠다고 마음먹었다. 

 

 


 

🤔 좋아요/싫어요를 한 필드에 저장할 순 없을까?

처음 언급했듯 처음엔 Article 안에 goods, bads 두 개의 배열로 좋아요/싫어요를 만들려고 했다. 

거기에 객체 형식의 상태로 관리하고자 했으니 이 구조는 다음과 같을 것이다.

 

{ // Article Collection 구조
  _id: ObjectId("657d07e7073cbe0ef10edb90"), 
  ⋮
  goods: [
    {유저1 이메일 : T},
    {유저2 이메일 : T},
    {유저3 이메일 : F},
    ],
  bads: [
    {유저1 이메일 : F}, // goods와 동일하면 안됨
    {유저2 이메일 : F},
    {유저3 이메일 : T},
    ],
}​

 

좋아요와 싫어요 기능의 기본적인 이벤트를 생각해봤다. 

 

- 좋아요 활성화        - 좋아요 비활성화

- 싫어요 활성화        - 싫어요 비활성화

 

각 클릭 이벤트가 발생할 때마다 각각 다른 엔드포인트를 호출하는 것은 뭐.. 코드의 가독성과 클린코드 작성 면에서는 도움이 될 수 있겠으나.. 좀 비효율적인 것 같았다. 

그래서 활성화 / 비활성화를 하나의 메소드로 묶어 일단 클릭했을 때 동일한 엔드포인트를 호출하고, 

db를 까서 좋아요가 되어있으면 비활성화, 좋아요가 되어있지 않으면 활성화 시키려고 했다. 

 

여기까지는 괜찮았는데 

좋아요와 싫어요는 둘 다 true인 상태가 될 수 없기 때문에

하나를 눌렀을 때 다른 하나가 true인지 확인하고, true이면 false로 변경해야 함을 깨달았다.  

만약 다른 하나를 조회했을 때 false상태이면 추가적인 수정은 필요하지 않겠지만 일단 조회를 무조건 해야하기 때문에 쓸데없이 리소스를 잡아먹는다는 느낌을 지울 수가 없었다. 현업에선 어떻게 하고 있는지 모르겠지만 뭔가 비효율적인 것 같은 위화감이 들었다.

 

그래서 goods, bads 를 reactions라는 하나의 필드로 통합하면 어떨까에 대해 새롭게 고민해봤다. 

 

예상되는 장점

  • 데이터베이스 용량 감소 : 두 개의 속성을 하나로 합쳐 관리하기 때문에 방대한 Article을 저장함에 있어서 용량을 줄일 수 있을 것이다.
  • 데이터베이스 성능 향상 : 좋아요를 클릭하든, 싫어요를 클릭하든 결과적으로는 두 개의 필드를 모두 조회/수정해야했는데 하나의 필드로 통합하여 데이터베이스 성능을 향상시킬 수 있을 것이다.
  • 불필요한 코드 제거 : 이 기능을 구현하는데 있어서 초반 설계에서는 4개의 엔드포인트를 생각했는데, 최종적으로 무엇을 눌렀는지를 저장할 변수 하나만 만들면 1개의 엔드포인트만으로 기능을 구현할 수 있다!!
{ // goods와 bads를 통합한 Article Collection 구조
  _id: ObjectId("657d07e7073cbe0ef10edb90"), 
  ⋮
  likes:1,
  dislikies:2,
  ⋮
  reactions: [
    {유저1 이메일 : true}, //좋아요
    {유저2 이메일 : false}, //싫어요
    {유저3 이메일 : null}, //둘 다 아님
    ], 
}​

 

 

좋아요와 싫어요를 어떻게 누르든, 어차피 상태는 3개이므로 각각 null/ true/ false 를 통해 상태를 관리하기로 결정.

상태 goods, bads 로 관리할 때 reactions 하나로 관리
1. 아무것도 누르지 않은 상태 좋아요 F, 싫어요 F  null
2. 좋아요를 누른 상태 좋아요 T, 싫어요 F  true
3. 싫어요를 누른 상태   좋아요 F, 싫어요 T   false

 

또한, 좋아요 수와 싫어요 수 표시에 있어서도

매번 요청시마다 전체를 돌며 각 개수를 센다면 데이터양이 많아질수록 성능에 부하가 걸릴 수 있기 때문에 

상태가 업데이트 될 때마다 좋아요와 싫어요의 개수는 따로 변수에 담아 필요시 빠르게 읽어올 수 있도록 했다. 

 


 

코드흐름

프론트에서는 좋아요, 싫어요 중 어떤 버튼을 눌렀는지 그 값을 reactionType변수에 담아 서버로 전송. 

서버에서는 기존의 상태와 유저가 입력한 버튼을 가지고 조건처리.

기존 상태 유저가 누른 버튼 새로운상태(좋아요, 싫어요 개수 갱신)
T (좋아요상태) T (좋아요버튼) N (좋아요 취소 > 좋아요-1)
T F (싫어요버튼) F (싫어요+1,좋아요-1)
F (싫어요상태) T T(좋아요+1, 싫어요-1)
F N(싫어요 취소 > 싫어요 -1)
N  (아무것도없는상태) T T (좋아요 +1)
N F F (싫어요 +1)

 

위 여섯개의 조건에 따라 새로운 상태와 좋아요/싫어요 개수를 업데이트해서 프론트로 내려준다. 

프론트에서는 받은 응답에 따라 버튼의 색과 숫자를 다르게 보여줌

 

프론트코드

//프론트 코드
const stamp = (e) => {
    if (!isLogIn) {
      alert("로그인 후에 가능합니다.")
    }else{ 
      const reactionType = e.target.value
      axiosURL.put(`/contents/reaction/${id}`,{ reactionType:reactionType},{
        headers: {
          Authorization: `Bearer ${token}`,
        } }).then(res=>{
          setArticle({ 
            ...article,
            goods:res.data.goods,
            bads:res.data.bads, 
          })
          if(res.data.state==="T"){
            setOnGood(true)
            setOnBad(false)
          }else if(res.data.state==="F"){
            setOnBad(true)
            setOnGood(false)
          }else{
            setOnGood(false)
            setOnBad(false)
          }
          console.log(res.data.state)
        }).catch(err=>console.log(err))
      }
  }

 

 

백엔드 코드

//controller
 @PutMapping("/reaction/{id}")
        public ResponseEntity<Map<String, Object>> reaction(@PathVariable("id") String articleId, @RequestBody Map<String,String> body) {
            try {
                String reactionType = body.get("reactionType"); 
                Map<String, Object> res =serveBoardService.reaction(articleId,reactionType); 
                return new ResponseEntity<Map<String, Object>>(res, HttpStatus.OK);
            } catch (Exception e) {
                log.info(e.getMessage());
                return new ResponseEntity<Map<String, Object>>(HttpStatus.BAD_REQUEST);
            }
        }

//service

@Override
    public Map<String, Object> reaction(String articleId, String reactionType) throws Exception {
        Map<String, Object> map = new HashMap<String,Object>(); //응답을 담을 Map생성
        String email = JwtUtil.getCurrentMemberEmail(); //현재로그인된 유저 이메일 조회
        String state= articleTemRepository.updateArticleReaction(articleId,email,reactionType); 좋아요/싫어요 처리메소드 호출
        Article article = articleTemRepository.getArticleById(articleId); //아티클을 다시 찾아옴
      
      	map.put("goods",article.getGoods()); //좋아요 개수, 싫어요 개수, 상태 저장
        map.put("bads",article.getBads());
        map.put("state",state);
        
        return map;
    }

 

 

//repository
 
@Slf4j
@Repository
@RequiredArgsConstructor
public class ArticleTemRepository {
    private final MongoTemplate mongoTemplate;
    public String updateArticleReaction(String articleId,String email, String reactionType){
        Map<String,Object> map = null;
        Query query = new Query(Criteria.where("id").is(articleId).and("reactions.email").is(email));
        Article article = mongoTemplate.findOne(query, Article.class);
        //유저가 없으면 추가
        if(article == null) {
            Update update = new Update().push("reactions", new Reaction(email, reactionType));
            if(reactionType.equals("T")){
                update.inc("goods",1);
            }else{
                update.inc("bads",1);
            }
            mongoTemplate.updateFirst(new Query(Criteria.where("id").is(articleId)),update,Article.class);
            return reactionType;
        }
        //유저가 있으면 업데이트
        else{
            Optional<String> userReaction = article.getReactions().stream()
                    .filter(reaction -> reaction.getEmail().equals(email))
                    .map(Reaction::getState)
                    .findFirst();
            //현재상태와 유저가 누른 리액션에 따라 개수 및 상태 업데이트하는 객체를 반환하는 메소드
            map = updateReaction(reactionType, userReaction.get());
            Update update = (Update)map.get("update");
            mongoTemplate.updateFirst(query,update,Article.class);
            return (String)map.get("state");
        }
    }

    //현재상태와 유저가누른 리액션에 따라 개수 및 상태 업데이트하는 객체를 반환하는 메소드
    private Map<String,Object> updateReaction(String now, String old){
        Map<String,Object> map = new HashMap<>();
        Update update = null;
        String state = "";
          if(now.equals("T")&&old.equals("T")){ //좋아요 상태일 때 좋아요 누르면 N상태, 좋아요 개수 -1
            update = new Update().inc("goods",-1).set("reactions.$.state","N");
            state = "N";
        }else if(now.equals("T")&&old.equals("F")){ // 싫어요 상태일 때 좋아요 누르면 좋아요 활성화(T), 좋아요 +1, 싫어요 -1
            update = new Update().inc("goods",1).inc("bads",-1).set("reactions.$.state","T");
            state = "T";
        }else if(now.equals("T")&&old.equals("N")){ //모두 비활성화(N)일 때 좋아요 누르면 좋아요 활성화(T), 좋아요 +1
            update = new Update().inc("goods",1).set("reactions.$.state","T");
            state = "T";
        }else if(now.equals("F")&&old.equals("F")){//싫어요 상태일 때 싫어요 누르면 N상태, 싫어요 개수 -1
            update = new Update().inc("bads",-1).set("reactions.$.state","N");
            state = "N";
        }else if(now.equals("F")&&old.equals("T")){//좋아요 상태일 때 싫어요 누르면 싫어요 활성화(F), 좋아요 -1, 싫어요 +1
            update = new Update().inc("bads",1).inc("goods",-1).set("reactions.$.state","F");
            state = "F";
        }else if(now.equals("F")&&old.equals("N")){//모두 비활성화(N)일 때 싫어요 누르면 싫어요 활성화, 싫어요 +1
            update = new Update().inc("bads",1).set("reactions.$.state","F");
            state = "F";
        }

        map.put("state",state);
        map.put("update",update);
        return map;
    }

	//게시글찾기. 없으면 자동으로 null 반환.
    public Article getArticleById(String articleId){
        return mongoTemplate.findById(articleId,Article.class);
    }
}

 

mongoTemplate를 처음 사용해봐서 많이 헤멨다. 

사용법은 JDBC를 사용할 때와 비슷했는데, 수정할 때 Update객체를 만들어서 수정할 조건들을 설정하고 updateFirst()를 통해 실행시키는 구조였다. 

 

위에 표에 나와있듯이, 현재  6개의 조건에 해당하는 update객체를 만들어 수정을 시켰다. 


 

💣 이슈발생..

완벽하게 조건을 짰다고 생각했고, 코드상에 아무런 문제를 발견하지 못했는데 내가 원하는대로 동작하지 않았다. 

좋아요 상태에서 좋아요를 다시 눌렀을 때 좋아요가 취소되어야 하는데, 

좋아요가 계속해서 눌리는 현상이 일어났다.

싫어요를 했을 때도 마찬가지고, 뚜렷한 원인을 알 수 없고 랜덤해 보이는 동작오류가 자꾸 발생했다.

 

해결과정

거의 반나절을 오류의 원인을 찾는데만 소모했다. 

처음엔 프론트 상의 오류인지 백엔드 처리의 문제인지 파악하기 위해서 응답받은 state값을 콘솔에 찍었다. 

프론트는 응답받은대로 잘 출력하는 것을 확인.

 

그렇다면 문제는 데이터 처리과정인데, 아무리봐도 저 복잡해보이는 6개의 조건처리가 마음에 걸렸다. 

한줄한줄 로그를 다 찍어봤는데 콘솔창에서는 정상적으로 동작하는 것을 확인했다. 

그런데 문제는, 콘솔에 찍힌 state값대로 db에 업데이트가 안되는 것이었다..

좋아요와 싫어요 개수는 업데이트가 잘 되었고, state가 저장이 안되는 것이었다.

 

mongoTemplate에서 $set을 사용하는 과정에서  

 update = new Update().set("reaction.$.state","N");

 

이런식으로 update를 생성하는 코드를 작성했는데 문제는 reaction이었다.

내 article객체는 reaction을 속성으로 가지는 것이 아니고 List<Reaction> reactions 를 가지고 있기 때문에 

 update = new Update().set("reactions.$.state","N");

 

이렇게 작성해야 하는 것이었음,,, 허탈,, 

Reaction객체의 state속성을 바꾸는 것이라고 생각했기 때문에 "reaction.$.state"라고 했는데

사실 $의 사용법을 정확하게 알지 못해서 생긴 문제였다..

 

$는 배열의 특정 요소를 딱 지정해서 업데이트를 진행하는 위치 지정 연산자 였다.

그래서 reaction이 아닌 reactions가 들어가야 했던 것이고,,

 

아무튼 s하나씩 더 붙여서 결국 기능 완성에 성공했다...!