티스토리 뷰

1. 개요

 

 위 움짤처럼 좋아요 버튼을 클릭할 경우 ui가 바로바로 반응하는 것이 가장 이상적일 것이다. 하지만 모종의 이유로 서버의 응답이 매우 늦어질 경우 반응이 매우 느려질 수도 있다.

 

// 좋아요 버튼 컴포넌트
"use client";

import { dislikePost, likePost } from "@/app/posts/[id]/actions";
import { HandThumbUpIcon } from "@heroicons/react/24/solid";
import { HandThumbUpIcon as OutlineHandThumbUpIcon } from "@heroicons/react/24/outline";

interface LikeButtonProps {
  isLiked: boolean;
  likeCount: number;
  postId: number;
}

export default function LikeButton({
  isLiked,
  likeCount,
  postId,
}: LikeButtonProps) {

  const onClick = async () => {
    if (isLiked) {
      await dislikePost(postId);
    } else {
      await likePost(postId);
    }
  };

  return (
    <button
      onClick={onClick}
      className={`flex items-center gap-2 text-neutral-400 text-sm border border-neutral-400 rounded-full p-2  transition-colors ${
        isLiked
          ? "bg-orange-500 text-white border-orange-500"
          : "hover:bg-neutral-800"
      }`}
    >
      {isLiked ? (
        <HandThumbUpIcon className="size-5" />
      ) : (
        <OutlineHandThumbUpIcon className="size-5" />
      )}
      <span>{`좋아요! ${likeCount}`}</span>
    </button>
  );
}

 

"use server";

import db from "@/lib/db";
import getSession from "@/lib/session";
import { revalidateTag } from "next/cache";

// 좋아요 추가
export const likePost = async (postId: number) => {
  await new Promise((r) => setTimeout(r, 5000)); // 5초 지연

  const session = await getSession();

  try {
    await db.like.create({
      data: {
        postId,
        userId: session.id!,
      },
    });

    revalidateTag(`like-status-${postId}`);
    revalidateTag("post-detail-like-count");
  } catch (e) {
    console.error(e);
  }
};

// 좋아요 제거
export const dislikePost = async (postId: number) => {
  await new Promise((r) => setTimeout(r, 5000)); // 5초 지연

  const session = await getSession();

  try {
    await db.like.delete({
      where: {
        id: {
          postId,
          userId: session.id!,
        },
      },
    });

    revalidateTag(`like-status-${postId}`);
    revalidateTag("post-detail-like-count");
  } catch (e) {
    console.error(e);
  }
};

 

 

이런 상황을 구현해보기 위해 우선 좋아요를 추가하는 함수와 좋아요를 제거하는 함수에 5초정도 지연되게 하는 부분을 추가하였다. 그리고 좋아요 버튼을 클릭해보면 반응이 매우 느려진 것을 확인할 수 있다. 이는 서비스 이용자들에게 있어서 매우 불쾌한 경험을 안겨준다. 그렇기에 이런 일을 방지하기 위한 방식으로 Optimistic update라는 방식이 있다.

 

2. Optimistic Update

 Optimistic Update는 사용자 인터페이스의 반응성을 높이기 위해 서버의 응답을 기다리기 전에 UI를 먼저 업데이트하는 방식이다. Optimistic이라는 단어의 뜻에서 대략 유추할 수 있듯, 사용자는 작업이 즉시 에러 없이 성공적으로 반영된 것처럼 느끼게 되어 UX를 크게 개선할 수 있다. '에러 없이 성공적으로 반영된 것처럼'이라고 하였는데, 만약 서버 요청이 실패하는 경우는 롤백을 통해 원래 상태로 복구하게 된다.

 

3. useOptimistic Hook

 Next.js에서는 Optimistic Update를 쉽게 구현하기 위해 useOptimistic을 제공한다. 이 훅을 통해 상태와 업데이트 로직을 정의하고 서버 요청에 따라 UI 상태를 적절히 롤백할 수 있다.

 

"use client";

import { useOptimistic } from "react"; // useOptimistic hook을 import

import { dislikePost, likePost } from "@/app/posts/[id]/actions";
import { HandThumbUpIcon } from "@heroicons/react/24/solid";
import { HandThumbUpIcon as OutlineHandThumbUpIcon } from "@heroicons/react/24/outline";

interface LikeButtonProps {
  isLiked: boolean;
  likeCount: number;
  postId: number;
}

export default function LikeButton({
  isLiked,
  likeCount,
  postId,
}: LikeButtonProps) {
  const [state, reducerFn] = useOptimistic(
    { isLiked, likeCount },

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    (previousState, payload) => {
      console.log(payload);

      return {
        isLiked: !previousState.isLiked,
        likeCount: previousState.isLiked
          ? previousState.likeCount - 1
          : previousState.likeCount + 1,
      };
    }
  );
  const onClick = async () => {
    reducerFn({ value1: "zzz", value2: "hahaha" });

    if (isLiked) {
      await dislikePost(postId);
    } else {
      await likePost(postId);
    }
  };
  return (
    <button
      onClick={onClick}
      className={`flex items-center gap-2 text-neutral-400 text-sm border border-neutral-400 rounded-full p-2  transition-colors ${
        state.isLiked
          ? "bg-orange-500 text-white border-orange-500"
          : "hover:bg-neutral-800"
      }`}
    >
      {state.isLiked ? (
        <HandThumbUpIcon className="size-5" />
      ) : (
        <OutlineHandThumbUpIcon className="size-5" />
      )}
      <span>{`좋아요! ${state.likeCount}`}</span>
    </button>
  );
}

 

 우선 useOptimistic의 첫 번째 인자로는 관리해야 할 값, 즉 상태가 필요하다. 좋아요 버튼에서는 게시물의 좋아요의 수(likeCount)현재 내가 좋아요 버튼을 클릭했는지의 여부(isLiked)가 업데이트되었는지 아닌지가 중요하기에 이를 포함하는 객체(위 코드의 { isLiked, likeCount })를 첫 번째 인자로 넘겨주었다. 그리고 두 번째 인자로는 (실제 업데이트와는 별개의) 업데이트 로직이 필요하다. 위 코드에서는 isLiked와 likeCount의 값을 previousState의 값에 따라 변경하는 함수가 두 번째 인자로 들어가 있으며, 두 번째 인자 함수의 첫 번째 인자에는 업데이트 전의 상태 값이, 두 번째 인자로는 별도로 설정할 수 있는 값이 들어온다.

 

 

 그리고 useOptimistic은 두 개의 요소를 반환한다. 첫 번째는 업데이트된 상태(위 코드의 state)이며 두 번째는 윗 문단에서 언급한 업데이트 로직(위 코드의 reducerFn) 함수이다. onClick 함수를 보면 reduceFn에 value1, value2라는 프로퍼티를 포함하는 객체를 인자로 넣었는데, 이 값이 바로 업데이트 로직의 두 번째 인자로 들어가게 되며, 이는 위 이미지에서 확인할 수 있다. 마지막으로 기존의 값들은 state라는 객체를 통해 이용할 수 있다.

 

 

 useOptimistic 훅을 적용한 후 버튼을 클릭했을 때의 모습이다. 원래대로라면 5초 정도 후 버튼이 업데이트되어야 하지만, useOptimistic 덕분에 optimistic update를 할 수 있게 되어 즉각적인 반응을 보여준다.

 

4. useOptimistic 훅 이용 시 고려사항

useOptimistic 훅을 이용 시 고려해야 할 사항들을 간단하게 정리하자면 다음과 같다.

  • 서버와의 일관성 유지: 서버 요청이 실패할 경우 반드시 원래 상태로 복구하는 로직을 포함해야 함
  • 서버 요청이 성공할 때만 상태를 확정하기 때문에, 요청 실패 시 사용자에게 실패 이유를 안내하는 것 역시 중요
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함