낙관적 업데이트, 정말 낙관해도 될까

작성일: Sun Oct 06 2024

들어가며

낙관적이다 라는 말은 ''상황을 긍정적으로 본다'' 는 사전적 의미를 갖고 있습니다.

텍스트만 보면 좋은 의미로만 쓰이는 것 같지만, 맥락에 따라 가끔은 부정적인 의미로 쓰이기도 합니다.

이와 비슷하게, UX 향상을 위해 리액트에서 사용되는 테크닉 중 하나로 ''낙관적 업데이트'' 라는 것이 있는데요.

이번 글에서는 낙관적 업데이트를 마냥 낙관적으로 쓸 수 만은 없는 이유와, 해결 방법에 대한 방법을 소개해보고자 합니다.


대상 독자

이 글은 다음과 같은 사전지식이 있는 독자를 대상으로 합니다.

  • 리액트 상태관리에 대한 이해
  • HTTP 메서드 (POST, FETCH)에 대한 이해

이 글에서는 다음과 같은 내용을 다룹니다.

  • 낙관적 업데이트를 사용하는 이유와 방법에 대한 소개
  • 낙관적 업데이트를 구현하며 했던 고민들과 나름의 결론

이 글에서 다음과 같은 내용은 다루지 않습니다.

  • react-query 등 특정 라이브러리에 종속된 낙관적 업데이트 구현 방법

TL:DR

  • 낙관적 업데이트는 데이터 수정의 성공 결과와 상관없이 클라이언트의 상태를 먼저 바꾸는 테크닉입니다.

  • 따라서, 클라이언트의 상태와 백엔드 데이터의 싱크가 맞지 않을 수 있다는 문제가 있습니다.

  • 데이터 수정에 실패했을 때에는 롤백을 하거나, 더 정확하게 하고 싶다면 해당 데이터를 재호출합니다.


비관적 업데이트

게시물의 좋아요 버튼을 눌러 좋아요 갯수를 늘리는 경우를 리액트 코드로 간단히 나타내보면 다음과 같습니다.

type Post = {
  likeCount: number,
  // ...
}

const PostLikeButton = () => {
  const [post, setPost] = useState<Post | null>(null);

  // 게시글 좋아요
  const likePost = async (postId: string) => {
    const { isSuccess } = await axios.post('your.server.endpoint.of.like.post', { postId });
    return isSuccess
  }

  // 게시글 조회
  const fetchPost = async (postId: string) => {
    const { post } = await axios.get('your.server.endpoint.of.fetch.post', { postId });
    setPost(post);
  }

  // 좋아요 버튼 클릭 핸들러
  const handleClickPostLike = async (postId: string) => {
    
    // 좋아요 요청을 보낸 뒤, 해당 요청이 성공하면 게시글을 조회하여 싱크를 맞춥니다.
      const { isSuccess } = await likePost(postId);
    if(isSuccess) {
        await fetchPost(postId);      
    }
  }

  return (
      <button onClick={handleClickPostLike}>좋아요 : {post.likeCount} </button>
  )
}

게시물에 좋아요를 누르면 서버에 해당 게시물의 좋아요 카운트를 올리는 요청을 보내고, 해당 요청이 성공하면 백엔드 데이터와의 싱크를 맞추기 위해 게시글을 조회하는 요청을 보냅니다.

로직 자체는 동작하기는 하나, UX 차원에서 문제가 있습니다. 좋아요 버튼을 클릭했을 때, 최종 결과가 화면에 반영되는 속도가 느릴 수 있다는 점인데요. 좋아요 카운트를 올리는 요청과 게시글 조회 요청이 모두 끝나야 클라이언트의 좋아요 카운트가 올라가기 때문입니다.


매우 낙관적인 낙관적 업데이트

위의 코드를 낙관적인 상황만을 고려한 낙관적 업데이트로 구현한 코드를 봅시다.

const PostLikeButton = () => {
  // ...

  // 좋아요 버튼 클릭 핸들러
  const handleClickPostLike = async (postId: string) => {
    setPost(post => ({
      ...post,
      likeCount: post.likeCount + 1
    }));
    // 백엔드에 네트워크 요청을 보내기도 전에 먼저 상태를 업데이트 합니다.
   
    await likePost(postId);
  }

  return (
      <button onClick={handleClickPostLike}>좋아요 : {post.likeCount} </button>
  )
}

이제 사용자는 좋아요 버튼을 누르자마자 좋아요 카운트가 올라가는 것을 봤으니 UX 면에서는 더 나아졌습니다.

이처럼 데이터 수정 요청이 성공할 것이라는 낙관적인 시야를 갖고 작성한 로직을 낙관적 업데이트 라고 부르는데요.

지금의 로직을 그대로 사용하기 곤란합니다. 왜냐하면, 좋아요 요청이 실패했을 경우에도 좋아요 카운트가 올라간 상태로 유지된다는 문제가 있기 때문이죠.


실패를 고려한 낙관적 업데이트

이번에는 좋아요 요청이 실패했을 경우를 고려하여 코드를 조금 수정해 봅시다.

const PostLikeButton = () => {
  // ...
  const postRef = useRef<Post | undefined>();

  // 좋아요 버튼 클릭 핸들러
  const handleClickPostLike = async (postId: string) => {
    // 좋아요 요청 실패시 롤백을 위해 상태를 미리 저장해둡니다.
    postRef.current = post;
    
    setPost(post => ({
      ...post,
      likeCount: post.likeCount + 1
    }));
    
    try {
        await likePost(postId);      
    }
       // 좋아요 요청이 실패했을 경우, 이전 상태로 롤백합니다.
    catch(e) {
      setPost(postRef.current);      
        }
  }

  return (
      <button onClick={handleClickPostLike}>좋아요 : {post.likeCount} </button>
  )
}

좋아요 버튼을 누르자마자 카운트를 올리고 좋아요 요청을 보냅니다. 만약 좋아요 요청이 실패한 경우 이전 상태로 롤백합니다.

좋아요 요청의 성공과 실패 케이스를 모두 다뤘으니 괜찮아 보입니다만, 롤백한 데이터는 좋아요 버튼을 누르기도 전의, 가장 오래된 데이터라는 점이 찜찜합니다. 실제 백엔드 데이터와는 상관 없이, 클라이언트의 상태값을 계속 바꿈으로 인해 클라이언트 상태값의 신뢰도가 점점 떨어지고 있기 때문입니다.


데이터 싱크를 챙기는 낙관적 업데이트

이제 UX 도 놓치지 않으면서, 백엔드 데이터와의 싱크도 챙기도록 코드를 조금 더 수정해보겠습니다.

const PostLikeButton = () => {
  // ...
  
  // 게시글 조회
  const fetchPost = async (postId: string) => {
    const post = await axios.get('your.server.endpoint.of.fetch.post', { postId });
    setPost(post);
  }

  // 좋아요 버튼 클릭 핸들러
  const handleClickPostLike = async (postId: string) => {
    setPost(post => ({
      ...post,
      likeCount: post.likeCount + 1
    }));
    
    try {
        await likePost(postId);      
    }
    catch(e) {
     // 에러 처리
        } 
    // 좋아요 요청의 성공,실패 여부와 관계없이 좋아요 개수를 최신화합니다.
    finally {
      await fetchPost();
    }
  }

  return (
      <button onClick={handleClickPostLike}>좋아요 : {post.likeCount} </button>
  )
}

좋아요 버튼을 누르면 좋아요 카운트를 먼저 올리는 것은 위의 두 케이스와 동일합니다.

다만, 좋아요 요청이 끝난 뒤 해당 요청의 성공 여부와 관계없이 finally로 게시글을 조회하는 로직을 넣어주었습니다.


요약, 배운 점

모든 기술은 공짜가 아니듯이, UX 향상을 위해 도입한 낙관적 업데이트 역시 데이터 정확성이 떨어진다는 비용이 존재하는데요.

이를 보완하기 위해 데이터 수정 요청 실패시 롤백하는 방법과, 더 나아가서 데이터를 최신화 하는 방법까지 살펴보았습니다.


가끔 프론트엔드 개발이란 백엔드 데이터를 유저에게 정확하게 보여주는 것이 전부가 아닌가라는 생각을 할 때가 있습니다.

그러나 데이터를 정확하게 보여주는 것 외에도, 매끄러운 사용자 경험을 제공하는 것 역시 프론트엔드 개발자가 챙겨야 할 부분 중 하나인 것 같습니다.


더 생각해볼 점

만약 마지막에 finally 로 데이터를 최신화 하는 로직도 실패하면 어떻게 대처할까요?