Notice
Recent Posts
Recent Comments
Link
«   2026/07   »
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
Tags
more
Archives
Today
Total
관리 메뉴

mimizae 님의 블로그

낙관적 업데이트 적용하기 본문

FE

낙관적 업데이트 적용하기

mimizae 2025. 12. 5. 15:34

 

 

Tanstack Query에 대해 공부하다가 낙관적 업데이트라는 개념을 알게 되었는데, 이 개념이 현재 진행 중인 프로젝트 킥온의의 킥(Kick) 기능에 딱 맞겠다는 생각이 들어 낙관적 업데이트를 적용해 즉각적으로 UI가 변경되도록 리팩토링을 진행해 보면 어떨까 해서 글을 쓴다!

 

여기서 킥온의 킥 기능은 좋아요 기능이라고 생각하면 된다.

게시글 상세 페이지에서 게시글에 킥을 보낼 수 있고, 댓글과 대댓글에도 각각 킥을 보낼 수 있다!

 

게시글 상세페이지

 

게시글 킥 버튼과 댓글 및 대댓글 킥 버튼


💡 낙관적 업데이트란?

낙관적 업데이트(Optimistic Update)는 사용자가 어떤 액션을 수행했을 때, 서버의 응답을 기다리지 않고 UI를 즉시 업데이트하는 방식이다.

즉, "요청이 성공할 것"이라고 낙관적으로 가정하고 먼저 화면에 반영한 뒤, 이후 서버 응답 결과에 따라 성공이면 그대로 유지, 실패면 이전 상태로 롤백하는 전략이다.

 

 

낙관적 업데이트가 아닌, 일반적인 요청-응답 기반 UI 흐름은 다음과 같다!!

1. 버튼 클릭
2. 서버 요청
3. 응답 기다림… 😮‍💨
4. 응답 성공 후 UI 업데이트

 

 

그에 비해 낙관적 업데이트 UI 흐름은 아래와 같다.

1. 버튼 클릭
2. UI 즉시 업데이트 ⭐️
3. 서버 요청 진행
4. 성공 시 그대로 유지
5. 실패 시 롤백!! ⭐️

 

🤔 왜 낙관적 업데이트를 써야 할까?

낙관적 업데이트를 사용하는 가장 큰 이유는 사용자 경험(UX)을 획기적으로 개선할 수 있기 때문이다.

서버의 응답을 기다리지 않고 먼저 UI를 갱신해 주기 때문에, 사용자는 버튼을 눌렀을 때 즉각적인 반응을 보게 되고 인터랙션이 매우 부드럽게 느껴진다. (아래의 영상 예시처럼...!!!!)

 

이 방식은 네트워크 속도나 지연 시간에 의존하지 않기 때문에, 모바일 환경이나 불안정한 외부 네트워크에서도 일관된 사용 경험을 제공할 수 있다는 점에서 특히 효과적이라고 생각이 된다.

결과적으로, 사용자 입장에서는 성능이 좋아진 것처럼 느껴지고 서비스 전반의 높은 만족도로 이어질 것이다!!

장점 설명
빠르고 자연스러운 UX 서버 round-trip 대기 없이 UI가 즉각 반응
사용자 만족도 상승 체감 성능 개선 (특히 좋아요, 팔로우, 카트 등)
네트워크 속도나 지연에 의존하지 않음 모바일/외부 환경에서도 UX 안정적

 

일반적으로 다음과 같이

  • 좋아요 / 북마크 / 팔로우 / 투표
  • 장바구니 담기
  • 게시글 수정 / 삭제
  • 댓글 작성 / 좋아요

즉시 반응해야 사용자가 눌렀다는 느낌, 어떤 요청이 전송된다는 느낌을 받는 모든 기능에 사용된다!!

 

🤔 낙관적 업데이트 적용 전/후 예시

낙관적 업데이트 적용 전 / 후 예시

 

순서대로 낙관적 업데이트를 적용하기 전과 적용 후의 예시 화면이다.

 

낙관적 업데이트를 적용하기 전에는 사용자의 클릭 액션이 있고 난 후, 서버의 성공 응답이 와야 UI가 뒤늦게 변경되어 사용자로 하여금 '렉이 걸렸나? 오류인가?' 싶은 생각이 들게 한다.

그러나 낙관적 업데이트를 적용한 화면에서는 사용자의 클릭 액션이 있자마자 UI가 변경되어 안정적이다.

(실제로 콘솔창을 열어 서버의 응답 타이밍을 보면 응답 타이밍과 UI 변경 타이밍이 안 맞는 걸 볼 수 있다...!!!!!!!! 신기...)


💡 킥온에 적용하기

현재 댓글, 게시글 킥 기능이 구현된 로직들을 살펴보고 이를 분석해보자.

 

1. 댓글 킥 (React-Query가 적용된 상태)

  • useCreateCommentKickMutation.ts
// 댓글 킥
export const useCreateCommentKickMutation = (type: PostType) => {
	const queryClient = useQueryClient();
	const mutationFn = type === 'news' ? createNewsCommentKick : createBoardCommentKick;

	return useMutation<SuccessResponse<unknown>, unknown, number>({
		mutationFn,
		onSuccess: async () => await queryClient.invalidateQueries({ queryKey: commentKeys.all }),
	});
};

 

  • CommentItem.tsx
const createCommentKickMutation = useCreateCommentKickMutation(postType);

	const toggleCommentLike = async () => {
		if (!currentUserInfo) {
			openLoginModal();
			return;
	}
	await createCommentKickMutation.mutateAsync(comment.pk);
};

 

{!isEditing && (
	<button onClick={() => void toggleCommentLike()} className="ml-auto flex items-center gap-2">
		<Image src={comment.kicked ? '/kick/red.svg' : '/kick/gray.svg'} alt="kick" width={16} height={16} />
		<span className={comment.kicked ? 'text-black-900' : 'text-gray-500'}>{comment.kickCount}</span>
	</button>
)}

 

 

댓글 킥 기능은 React Query의 useMutation 을 사용하여 서버에 요청을 보낸 뒤,

요청이 성공하면 invalidateQueries 를 실행하여 댓글 리스트 전체 데이터를 다시 가져오는 방식으로 동작한다.

 

즉... 댓글 킥 동작 방식을 요약하자면!!

  1. 사용자가 킥 버튼 클릭
  2. createCommentKickMutation.mutateAsync 호출 → 서버에 요청
  3. 요청 성공 후 invalidateQueries(commentKeys.all) 실행
  4. 서버에서 최신 댓글 데이터를 다시 받아옴
  5. 새로 받아온 데이터에 따라 comment.kicked, comment.kickCount 값 업데이트 → UI 반영

즉, 서버 round-trip 이후에만 UI가 업데이트되는 방식이다.

 

2. 게시글 킥

 

게시글 킥은 React Query가 아닌 로컬 컴포넌트 state (useState) 기반으로 상태를 관리되고 있다.

  • DetailContent.tsx
const DetailContent = ({ commonDetailData, type, isCommentAllowed }: DetailContentProps) => {
  const [isLiked, setIsLiked] = useState(commonDetailData.isKicked);
  
  ...
  ...

  const handleLikeButtonClick = async () => {
    // 비회원인 경우 클릭 차단 & 알림 표시
    if (!currentUserInfo) {
      setIsLoginModalOpen(true);
      return;
    }

    try {
      const success = isNews
        ? await createNewsKick(commonDetailData.pk)
        : await createBoardKick(commonDetailData.pk);

      if (success) {
        // API 응답이 성공하면 UI 업데이트
        setIsLiked((prev) => !prev);
        setLikes((prev) => (isLiked ? prev - 1 : prev + 1));
      } else {
        alert('좋아요 요청에 실패했습니다. 다시 시도해주세요.');
      }
    } catch (error) {
      console.error('좋아요 요청 중 오류 발생:', error);
      alert('네트워크 오류가 발생했습니다. 다시 시도해주세요.');
    }
  };
  
  ...

  return (
    ...
    
    <button onClick={handleLikeButtonClick}>
      <Image src={'/kick/black.svg'} alt="축구공" width={18} height={18} />
      <span className="mr-0.5">킥</span>
      <span className={`${isLiked ? 'text-white' : 'group-hover:text-[#D91920]'}`}>
        {likes}
      </span>
    </button>
  );
};

 

게시글 킥 동작 방식 요약

  1. 사용자가 킥 버튼 클릭
  2. 서버에 요청 (createNewsKick)
  3. 응답이 성공하면 setIsLiked 와 setLikes 를 호출하여 UI 업데이트
  4. 실패하면 alert만 띄우고 변화 없음
  5. React Query 캐시 동기화는 없음 → 다른 컴포넌트와 데이터 일관성 보장 불가

즉, 이 또한 서버 응답 성공 후에만 로컬 상태를 바꾸는 형태이다.


🚨 현재 방식으로 구현된 두 킥 기능의 문제점들

처음 두 킥 기능을 구현할 때는 서버 요청의 응답 속도가 보장되지 않는 환경에서도 UI를 빠르게 반응시키기 위해, 게시글과 댓글 모두를 로컬 state 기반으로 관리하는 방식(프론트 단에서 처리)을 사용했다.

 

즉, 사용자가 킥 버튼을 누르면 서버 요청이 성공한 뒤 setState로 직접 좋아요 상태와 숫자를 변경하는 구조였다. 이런 방식은 구현이 간단하고 빠르게 결과를 눈으로 확인할 수 있다는 장점이 있었다.

 

하지만 실제 서비스 관점에서는 몇 가지 문제점이 확실히 있다.

 

먼저, 버튼 클릭 후 서버 응답이 돌아올 때까지 UI 변경이 발생하지 않기 때문에, 네트워크 지연이 있는 환경에서는 화면이 멈춰 보이거나 눌리지 않은 것처럼 느껴질 수 있다.

특히 좋아요/킥 기능처럼 즉각적인 피드백이 중요한 영역에서는 사용자 경험이 크게 저하될 수밖에 없다!!!

 

두 번째로, 게시글 킥처럼 로컬 state만 변경하는 방식은 전체 화면의 데이터 일관성을 유지하기 어렵다는 문제가 있다.

예를 들면, 상세 페이지에서 킥을 눌렀을 때 현재 화면에서는 좋아요 숫자가 바뀌지만, 목록 페이지나 다른 컴포넌트에서는 여전히 이전 값이 보여서 화면 간 데이터가 서로 맞지 않는 상황이 발생할 수 있다.

React Query 캐시를 활용하지 않기 때문에 데이터 동기화가 자동으로 보장되지 않는다. (→ 추후 적용이 필요할 듯 싶다!)

 

댓글 킥 기능은 React Query 기반으로 전환되어, 서버 요청 후 invalidateQueries를 통해 최신 댓글 데이터를 다시 불러오는 방식으로 개선되었다. (프론트 팀원... 만세!! ♡)

 

이전의 게시글 킥처럼 로컬 state만 변경하는 방식에 비해 데이터 일관성이 보장되고, 다른 컴포넌트에서도 최신 상태를 공유할 수 있다는 면에서는 확실한 발전이다.

 

하지만 이 방식 역시 서버 요청 완료 후에야 UI가 업데이트되고, 매번 전체 데이터를 다시 받아오기 때문에 불필요한 네트워크 트래픽이 발생한다는 한계가 남아 있다.

 

즉, 게시글 킥보다는 구조적으로 개선되었지만, 여전히 사용자 반응 속도 측면에서는 아쉬움이 존재하며, 버튼 클릭 시 즉시 UI가 반영되는 경험을 제공하기 위해서는 낙관적 업데이트 적용이 필요한 상황이라는 점은 명확하다.

💡 여기에 낙관적 업데이트를 적용한다면?

1. 댓글 킥

  • 댓글 킥 Mutation → Optimistic Update 적용
export const useCreateCommentKickMutation = (type: PostType) => {
  const queryClient = useQueryClient();
  const mutationFn = type === 'news' ? createNewsCommentKick : createBoardCommentKick;

  return useMutation({
    mutationFn,

    // 낙관적 업데이트
    onMutate: async (pk: number) => {
      await queryClient.cancelQueries({ queryKey: commentKeys.all });

      const previousComments = queryClient.getQueryData<CommonCommentDto[]>(commentKeys.all);

      // UI 즉시 업데이트
      if (previousComments) {
        queryClient.setQueryData(commentKeys.all, (old: CommonCommentDto[] | undefined) =>
          old?.map((comment) =>
            comment.pk === pk
              ? {
                  ...comment,
                  kicked: !comment.kicked,
                  kickCount: comment.kicked ? comment.kickCount - 1 : comment.kickCount + 1,
                }
              : comment
          )
        );
      }

      return { previousComments };
    },

    // 서버 요청 실패 → 롤백
    onError: (err, variables, context) => {
      if (context?.previousComments) {
        queryClient.setQueryData(commentKeys.all, context.previousComments);
      }
      alert('킥 요청에 실패했습니다. 다시 시도해주세요.');
    },

    // 성공/실패 관계없이 refetch (정확한 데이터 보정)
    onSettled: async () => {
      await queryClient.invalidateQueries({ queryKey: commentKeys.all });
    },
  });
};

 

onMutate

요청이 보내지는 순간 실행되며, 실제 서버 응답이 오기 전에 UI를 먼저 바꿔주는 핵심 로직이다.

await queryClient.cancelQueries({ queryKey: commentKeys.all });

 

  • 지금 진행 중이던 댓글 리스트 관련 쿼리를 잠시 중단한다.
  • 데이터를 갱신 중일 때 충돌이 발생하지 않도록 보호하는 단계!!
const previousComments = queryClient.getQueryData<CommonCommentDto[]>(commentKeys.all);

 

  • 현재 댓글 목록 데이터를 따로 저장해 둔다.
  • 만약 서버 요청이 실패했을 때 롤백하기 위해!!!
queryClient.setQueryData(commentKeys.all, (old) =>
  old?.map((comment) =>
    comment.pk === pk
      ? { ...comment, kicked: !comment.kicked, kickCount: comment.kicked ? comment.kickCount - 1 : comment.kickCount + 1 }
      : comment
  )
);

 

 

 

  • UI를 즉시!!!! 토글한 값으로 변경한다.
  • 네트워크 지연이 있더라도 사용자는 바로 변화된 UI를 보게 되어 터치/클릭에 반응했다는 느낌을 

onError (롤백)

queryClient.setQueryData(commentKeys.all, context.previousComments);

 

  • 서버 요청이 실패하면 onMutate에서 저장해둔 previousComments로 상태를 되돌린다.
  • UI에서 방금 바뀐 내용이 롤백되며 사용자에게 실패 사실을 알리기 위함!!

onSettled (정확한 동기화)

queryClient.invalidateQueries({ queryKey: commentKeys.all });

 

  • 성공/실패 여부 상관없이 refetch 한다.
  • 서버의 실제 최종 데이터와 클라이언트 상태를 정확히 맞춰주기 위함

 

 

 

 

 

2. 게시글 킥

  • useCreatePostKickMutation.ts (예시)
export const useCreatePostKickMutation = (type: PostType) => {
  const queryClient = useQueryClient();
  const mutationFn = type === 'news' ? createNewsKick : createBoardKick;

  return useMutation({
    mutationFn,
    // 낙관적 업데이트
    onMutate: async (pk: number) => {
      await queryClient.cancelQueries({ queryKey: detailKeys.detail(pk) });

      const previousData = queryClient.getQueryData<CommonPostDetailDto>(detailKeys.detail(pk));

      // UI 즉시 반영
      if (previousData) {
        queryClient.setQueryData(detailKeys.detail(pk), {
          ...previousData,
          isKicked: !previousData.isKicked,
          likes: previousData.isKicked
            ? previousData.likes - 1
            : previousData.likes + 1,
        });
      }

      return { previousData };
    },
    // 실패 시 롤백
    onError: (_error, pk, context) => {
      if (context?.previousData) {
        queryClient.setQueryData(detailKeys.detail(pk), context.previousData);
      }
    },

    // 성공 여부와 상관없이 최종 서버 상태 재검증(optional)
    onSettled: (_data, _error, pk) => {
      queryClient.invalidateQueries({ queryKey: detailKeys.detail(pk) });
    },
  });
};

 

  • DetailContent 적용 (예시)
const DetailContent = ({ commonDetailData, type }: DetailContentProps) => {
  const createPostKickMutation = useCreatePostKickMutation(type);

  const handleLikeButtonClick = () => {
    if (!currentUserInfo) {
      setIsLoginModalOpen(true);
      return;
    }

    createPostKickMutation.mutate(commonDetailData.pk);
  };

  return (
    <button onClick={handleLikeButtonClick}>
      <Image src="/kick/black.svg" alt="축구공" width={18} height={18} />
      <span className="mr-0.5">킥</span>
      <span>{commonDetailData.likes}</span>
    </button>
  );
};

 


 

현재 킥온 서버에 문제가 있어 실제 테스트는 못해 본 상태이다!

추후 해결이 되어 리팩토링 적용이 되면 전 / 후 비교를 추가하겠다.

'FE' 카테고리의 다른 글

페이지네이션에 대해서  (0) 2025.12.21
Tanstack Query의 데이터 최신화  (0) 2025.12.14
스크롤 감지 Tab-Bar 구현하기  (0) 2025.11.26
실시간 알림 구현하기  (0) 2025.11.16
Virtual DOM이란?  (0) 2025.11.07