문서 업데이트: 2024-09-18

목차

  1. 기능적 개요
  2. 배경
  3. 원인
  4. 해결 방안
  5. 결과 및 느낀 점

진행 중인 프로젝트에는 댓글 좋아요 기능이 있습니다.

기능적 개요

해당 기능의 기능적 개요는 다음과 같습니다.

  1. 댓글 좋아요 조회
    • 게시물 조회 페이지에서
      • 해당 게시물에 대한 댓글이 존재한다면 해당 댓글의 정보를 불러와 렌더링합니다.
        • 해당 댓글에 대한 사용자의 좋아요 이력 기반으로, 좋아요 버튼의 이미지를 선택해 렌더링합니다.

  2. 댓글 좋아요 등록 및 삭제
    • 좋아요 버튼 클릭 시, 이미지, 좋아요 수의 변화를 통해 기능 작동의 피드백을 사용자에게 제공합니다.
      • 이 때 서버로 요청을 보내지 않고, 자바스크립트 객체에 좋아요 등록, 삭제 대상을 저장해둡니다.
    • 페이지를 떠날 때, 사용자가 변경한 좋아요 상태를 서버에 반영하도록 일괄적으로 요청합니다.

diagram

댓글 좋아요 등록 및 삭제 기능의 특징은 서버로의 요청을 ‘사용자가 페이지를 떠날 때’로 미루고 있는 것이 특징입니다.

요청을 미룬 이유는 서버의 자원을 아끼기 위해서입니다.

  • 사용자는 해당 버튼을 클릭함으로써 반응하는 UI를 통해 해당 기능의 작동 여부를 확인할 수 있습니다.
  • 사용자는 해당 기능의 작동여부 확인, 우연적 클릭 등의 사용 사례를 통해 좋아요 기능을 사용할 수도 있습니다.
    • 하지만 이 경우에는 사용자가 실제로 해당 댓글을 좋아한다고 볼 수 없다고 판단했습니다.
    • 나아가 이런 사용 사례의 경우, 버튼 클릭 이벤트마다 서버로 요청한다면, 서버의 자원이 무의미하게 사용됩니다. 이러한 자원의 사용을 제한하고자, 실제 서버로 요청 하는 시점을 페이지를 떠날 때로 미뤘습니다.

위와 같이 좋아요 기능을 구현해,

  • 결과적으로 댓글에 대한 좋아요 등록 삭제 여부를 서버에 정확히 반영하고,
  • 예외 사용 사례에 의한 요청을 줄일 수 있습니다.

이와 같은 기능을 구현하며 마주친 문제를 해결한 기록을 다음과 같이 남깁니다.

목차로 돌아가기

배경

댓글 좋아요 기능 추가 후, 사용자가 기대하는 좋아요 상태와 서버의 좋아요 상태가 불일치하는 버그를 발견했습니다.

버그가 일어나는 사용 사례는

  • 특정 댓글에 좋아요 기능 사용 후,
  • 브라우저의 ‘뒤로 가기’, ‘앞으로 가기’, 기존 좋아요 기록 수정

하는 경우입니다.

문제가 발생한 기존의 코드와 동작은 다음과 같습니다.

client history affects making requests

  • CommentLike.toggleLike() 는 각 댓글이 DOM 조작을 통해 렌더링할 때, 좋아요 이력을 불러오는 메서드로 조회한 사용자의 해당 댓글 좋아요 여부를 인자로 넘깁니다.
    • 이 좋아요 여부는 좋아요 버튼 클릭 이벤트에 고정됩니다.
  • 기존 상태와 달라진 좋아요 등록, 삭제 대상 idpendingCommentLikes 인스턴스의 idsToRequest: Set에 담아둡니다.
  • 페이지를 떠날 때, 일괄적으로 pendingCommentLikes 인스턴스의 request()를 통해 서버로 요청을 보냅니다.
class PendingCommentLikes {
    constructor(feat, ajax) {
        this.feat = feat;
        this.ajax = ajax;
        this.idsToRequest = new Set();
    }

    // ... idsToRequest 조작 메서드 ...

    request() {
        this.idsToRequest.forEach((id) => {
            this.ajax(id);
        });
    }
}

export class CommentLike {
    static pendingLikesToRegister = new PendingCommentLikes("register", this.registerCommentLike);
    static pendingLikesToDelete = new PendingCommentLikes("delete", this.removeCommentLike);

    static toggleLike(commentId, hasLiked) {
        const commentLikeButton = document.getElementById(`comment-${commentId}-like-button`);
        const commentLikeImage = commentLikeButton.querySelector("img");
        const commentLikeCount = commentLikeButton.nextElementSibling;

        // 해당 댓글에 좋아요를 하지 않은 상태라면
        if (commentLikeImage.src.includes("unchecked")) {
            
            // 
            commentLikeImage.src = "/img/icons/checked.png";
            commentLikeCount.textContent = parseInt(commentLikeCount.textContent) + 1;

            // 좋아요 기록이 없으면 등록할 대상으로 추가.
            if (!hasLiked) {
                this.pendingLikesToRegister.add(commentId);
            }

            // 좋아요 삭제 대상이라면 삭제 대상에서 제거.
            if (this.pendingLikesToDelete.contains(commentId)) {
                this.pendingLikesToDelete.remove(commentId);
            }

            return;
        }

        commentLikeImage.src = "/img/icons/unchecked.png";
        commentLikeCount.textContent = parseInt(commentLikeCount.textContent) - 1;

        if (hasLiked) {
            this.pendingLikesToDelete.add(commentId);
        }

        if (this.pendingLikesToRegister.contains(commentId)) {
            this.pendingLikesToRegister.remove(commentId);
        }
    }
    
    // ... methods ...
}
목차로 돌아가기

원인

버그가 발생하는 원인은 다음과 같았습니다.

  • 좋아요 버튼 클릭 이벤트 콜백인 toggleLike(commentId, hasLiked) 함수에 사용자의 해당 댓글 좋아요 여부가 고정됩니다.
    • 이것으로 인해, 좋아요 이력을 불러오는 메서드를 새로 호출하기 전까지, 브라우저에서의 사용자의 해당 댓글 좋아요 여부는 바뀌지 않습니다.
      • 좋아요를 한 적이 없는 경우, 좋아요 등록 대상으로 고정되고, 삭제 대상에 포함될 수 없게 됩니다. (반대의 경우도 마찬가지)
  • 서버에 등록 요청을 보낸 후, 사용자는 실제 좋아요 기록이 있지만, 해당 댓글에 좋아요를 취소하더라도 브라우저는 좋아요 기록이 없기 때문에 좋아요 취소 요청을 만들지 못했습니다.
목차로 돌아가기

해결 방안

문제를 해결하기 위해서는 좋아요 등록•삭제 요청에 따라 실제 좋아요 어부를 브라우저에서도 관리하기로 했습니다.

client history affects making requests

  • 좋아요한 댓글의 id를 담을수 있는 필드(likedCommentIds: Set)를 선언.
  • 페이지 최초 로드 시, 사용자가 좋아요한 댓글의idlikedCommentIds에 추가.
    • 댓글에 좋아요 조작 시, likedCommentIds에 해당 댓글의 존재여부를 기반으로 서버에 보낼 좋아요 상태 선별.
  • 댓글 좋아요 등록•삭제 요청을 보낼 때,
    • 등록 요청에 포함된 idlikedCommentIds에 저장.
    • 삭제 요청에 포함된 idlikedCommentIds에서 삭제.
  • 더불어 요청을 보낸 후, 댓글 좋아요 등록•삭제할 id를 담는 PendingCommentLikes 인스턴스 필드 idsToRequest를 비움.

수정 사항은 다음과 같습니다.

class PendingCommentLikes {
    constructor(feat, ajax) {
        this.feat = feat;
        this.ajax = ajax;
        this.idsToRequest = new Set();
    }

    // ... idsToRequest 조작 메서드 ...

    request() {
        this.idsToRequest.forEach((id) => {
        
            // 등록 요청을 보냈다면, 좋아요한 댓글 목록에 추가.
            if (this.ajax.name === "registerCommentLike") {
                CommentLike.likedCommentIds.add(id);
            }

            // 삭제 요청을 보냈다면, 좋아요한 댓글 목록에서 삭제.
            if (this.ajax.name === "removeCommentLike") {
                CommentLike.likedCommentIds.delete(id);
            }

            this.ajax(id);
            this.remove(id); // 등록•삭제 요청한 `id`는 idsToRequest 에서 삭제.
        });
    }
}

export class CommentLike {
    static likedCommentIds = new Set(); // 추가. 브라우저에서 사용자의 좋아요 이력을 관리.
    static pendingLikesToRegister = new PendingCommentLikes("register", this.registerCommentLike);
    static pendingLikesToDelete = new PendingCommentLikes("delete", this.removeCommentLike);

    static toggleLike(commentId) {
        const commentLikeButton = document.getElementById(`comment-${commentId}-like-button`);
        const commentLikeImage = commentLikeButton.querySelector("img");
        const commentLikeCount = commentLikeButton.nextElementSibling;

        if (commentLikeImage.src.includes("unchecked")) {
            commentLikeImage.src = "/img/icons/checked.png";
            commentLikeCount.textContent = parseInt(commentLikeCount.textContent) + 1;

            // 사용자 좋아요 이력에 해당 id가 없다면 등록 대상에 추가
            if (!this.likedCommentIds.has(commentId)) {
                this.pendingLikesToRegister.add(commentId);
            }

            if (this.pendingLikesToDelete.contains(commentId)) {
                this.pendingLikesToDelete.remove(commentId);
            }

            return;
        }

        commentLikeImage.src = "/img/icons/unchecked.png";
        commentLikeCount.textContent = parseInt(commentLikeCount.textContent) - 1;

        // 사용자 좋아요 이력에 해당 id가 있다면 삭제 대상에 추가
        if (this.likedCommentIds.has(commentId)) {
            this.pendingLikesToDelete.add(commentId);
        }

        if (this.pendingLikesToRegister.contains(commentId)) {
            this.pendingLikesToRegister.remove(commentId);
        }
    }
    
    // ... methods ...
}

목차로 돌아가기

결과 및 느낀 점

위처럼 수정을 통해, ‘사용자가 기대하는 좋아요 상태와 서버의 좋아요 상태가 불일치하는 문제’를 해결할 수 있었습니다.

  • 브라우저 히스토리 동작으로 예상치 못한 버그가 발생할 수 있음을 유념해야겠습니다.
  • 하나의 페이지 내에서 서버 자원의 상태를 변경하는 요청을 보낸다면, 해당 자원의 변경된 상태를 반영해야합니다.
  • 서버에 상태 조회 요청하거나, 클라이언트 수준에서 객체를 관리하는 방법 중 선택해, 사용자에게 일관된 정보를 전달할 수 있습니다.
    • 서버 로직이 복잡하거나, 예외 발생 위험이 높은 경우, 실제 서버에 요청을 보내는 것이 좋을 것이고,
    • 로직이 간단하고, 예외 발생 가능성이 적은 경우, 클라이언트 측에서 간단히 이 문제를 해결할 수 있었습니다.
  • 위 사례의 경우, 클라이언트 측 객체 추가로 서버와 클라이언트가 참조하는 자원의 상태 차이를 제거할 수 있었습니다.
목차로 돌아가기