[React] useOptimistic훅을 통한 사용자 경험 최적화

Muru·2025년 7월 29일

[React] 지식 저장소

목록 보기
29/30

useOptimistic

React19 버전에 업데이트된 useOptimistic훅은 UI를 낙관적으로 업데이트해준다.

해당 훅은 앱이 더 반응적으로 느껴지도록 도와준다 사용자가 폼을 제출할 때, 서버의 응답을 기다리는 대신 인터페이스는 기대하는 결과로 즉시 업데이트되는것.

채팅어플을 생각해보면 조금 더 와닿을 수 있다. 카카오톡이나 디스코드를 보면 다음 두 가지중 어떤 흐름인지 생각해보면 된다.

  1. 사용자 채팅 타이핑 => 엔터 => 채팅창에 일단 올라감 (동시에 채팅 서버요청중)
  1. 사용자 채팅 타이핑 => 엔터 => 서버 요청중 => 채팅창에 올라감

거의 99.9%에 가깝게 모든 채팅어플은 첫번째 흐름을 가진다. 두번째 흐름을 가진다면 네트워크가 느린 사용자는 사용자 경험이 좋지 않을것이다. 즉 내가 어떠한 상호작용을 한다면 즉각적으로 선반응을 해줘야 사용자경험에도 좋다.

이때 첫번째를 구현하기에 조금더 쉽게 해주는 훅인 useOptimistic을 사용하면 된다.

사용 예시 : const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);

매개변수
state: 기본 상태값을 의미
updateFn(currentState, optimisticValue) :
currentState는 현재 상태를 의미
optimisticValue는 addOptimistic에 전달된 낙관적인 값을 취하는 리듀서 함수를 가진다.

리듀서 함수 : 이전 상태와 동작을 받아 새 상태를 리턴하는 순수 함수

반환 값
optimisticState : 결과적인 낙관적인 상태. 작업을 하지 않을경우 기존 state와 동일.
addOptimistic: 낙관적인 업데이트가 있을 때 호출하는 dispatch 함수. 어떠한 타입의 optimisticValue라는 하나의 인자를 취하며, state와 optimisticValue로 updateFn을 호출한다.

dispatch는 어떤 액션(action)을 발생시켜서 상태(state)를 바꾸게 하는 함수


useOptimistic을 사용하지 않은 예시

봤던 영화를 메모하는 프로젝트를 통해 사용해보자. 아래는 useOptimistic을 사용하지 않았을경우 흐름을 보여준다.

메모 작성을 누른 뒤 2초간 어떠한것도 보여주지 않다가 서버에서 요청이 완료된 뒤 메모가 완료된 모습을 볼 수 있다. 물론 스피닝 이미지를 보여줄 수는 있지만 네트워크가 좋지 않은 사용자가 10초동안 스피닝 이미지를 본다면 마찬가지로 사용자 경험에는 좋지 않을 수 있다.

useOptimistic을 사용한 예시

useOptimistic을 사용하여 사용자경험을 개선시킨 버전.
메모 작성을 누르면 일단 서버에서 성공한다고 가정하고(낙관적) UI를 선반영 시키는 모습을 볼 수 있다.
주의할점으로 정말 서버에서 완료된것인지 사용자에게 분리하여 알려주어야한다. 이러한 부분은 회색배경을 준다던지, Sending... 메시지를 보여주면 좋다.
또한 에러처리에도 신경써야한다. 실제 서버에서는 실패한것임에도 불구하고 UI에 나타나면 매우 좋지않은 사용자 경험이 될 것.

MemoFormList.js

"use client";

import { useEffect, useState, useOptimistic, useTransition } from "react";

export default function MemoFormAndList({ userEmail }) {
  // 서버에서 받아온 진짜 메모들
  const [memos, setMemos] = useState([]);
  
  // 폼 입력 상태들
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  
  // 서버 통신 중인지 알려주는 상태 
  const [isPending, startTransition] = useTransition();

  // useOptimistic훅 사용
  const [optimisticMemos, addOptimisticMemo] = useOptimistic(
    memos, // 기준이 되는 실제 데이터 
    (state, action) => { // 어떻게 업데이트 할지 정하는 함수
      // state: 현재 화면에 보이는 메모들 (실제 + 임시)
      // action: 어떤 변경을 할지 알려주는 명령서
      
      if (action.type === "add") {
        // 새 메모를 맨 앞에 추가 (최신 순으로)
        return [action.memo, ...state];
      }
      return state; // 다른 타입이면 그대로 반환
    }
  );

  // 컴포넌트가 처음 로드될 때 서버에서 메모들 가져오기
  useEffect(() => {
    const fetchMemos = async () => {
      const res = await fetch("/api/memo");
      const data = await res.json();
      // 내가 작성한 메모만 필터링
      const filtered = data.filter((memo) => memo.userEmail === userEmail);
      console.log(filtered);
      setMemos(filtered); // 실제 데이터 업데이트
    };
    fetchMemos();
  }, [userEmail]); // userEmail이 바뀔 때마다 다시 실행

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    // 임시 메모 생성 
    const tempMemo = {
      _id: `temp-${Date.now()}`, // 임시 ID (나중에 서버가 진짜 ID 줄 예정)
      title,
      content,
      userEmail,
      createdAt: new Date().toISOString(),
      isPending: true, // 저장중...
    };

    // 낙관적 업데이트
    addOptimisticMemo({ type: "add", memo: tempMemo });

    // 폼 비우기 (사용자가 계속 작성할 수 있게)
    setTitle("");
    setContent("");

    // 이제 서버에 저장 (백그라운드)
    startTransition(async () => {
      try {
        const res = await fetch("/api/memo", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ title, content }),
        });

        if (res.ok) {
          const newMemo = await res.json(); // 서버에서 진짜 ID와 함께 돌려줌
          // 성공
          setMemos((prev) => [newMemo, ...prev]);
        } else {
          // 실패
          alert("메모 저장에 실패했습니다.");
          // 서버에서 최신 데이터 다시 가져와서 동기화
          const res = await fetch("/api/memo");
          const data = await res.json();
          const filtered = data.filter((memo) => memo.userEmail === userEmail);
          setMemos(filtered); 
        }
      } catch (error) {
        console.error("메모 저장 오류:", error);
        alert("네트워크 오류가 발생했습니다.");
        const res = await fetch("/api/memo");
        const data = await res.json();
        const filtered = data.filter((memo) => memo.userEmail === userEmail);
        setMemos(filtered);
      }
    });
  };

  return (
    <div>
      {/* 📝 메모 작성 폼 */}
      <form onSubmit={handleSubmit} style={{ marginBottom: "2rem" }}>
        <input
          type="text"
          placeholder="메모 제목"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          required
          style={{ width: "100%", padding: "0.5rem", marginBottom: "0.5rem" }}
        />
        <textarea
          placeholder="메모 내용"
          value={content}
          onChange={(e) => setContent(e.target.value)}
          style={{ width: "100%", padding: "0.5rem", height: "100px" }}
        />
        <button 
          type="submit" 
          disabled={isPending} // 저장 중일 때 버튼 비활성화
          style={{ 
            marginTop: "1rem", 
            padding: "0.5rem 1rem",
            opacity: isPending ? 0.7 : 1  // 저장 중일 때 흐리게
          }}
        >
          {isPending ? "저장 중..." : "메모 작성"}
        </button>
      </form>

      {/* 메모 목록 - optimisticMemos 사용*/}
      <div>
        {optimisticMemos.map((memo) => (
          <div 
            key={memo._id} 
            style={{ 
              border: "1px solid #ccc", 
              padding: "1rem", 
              marginBottom: "1rem",
              // 임시 메모는 회색 배경으로 "저장 중"임을 표시
              backgroundColor: memo.isPending ? "#f0f0f0" : "white", 
              opacity: memo.isPending ? 0.8 : 1, // 임시 메모는 살짝 투명
              transition: "all 0.3s ease" 
            }}
          >
            <h3>
              {memo.title}
              {/* 임시 메모에만 "저장 중..." 텍스트 표시 */}
              {memo.isPending && <span style={{ color: "#666", fontSize: "0.8em" }}> (저장 중...)</span>}
            </h3>
            <p>{memo.content}</p>
            <small>{new Date(memo.createdAt).toLocaleString()}</small>
          </div>
        ))}
      </div>
    </div>
  );
}

참고로 useTransition훅과 함께 사용하면 isPending상태(서버에 데이터 요청작업)일때 회색 배경이라던지 저장 중... 메시지라던지 UI를 띄어 줄 수 있으므로 좋은 시너지를 낼 수 있다.
원래는 비동기 작업에는 useTransition을 사용하지 못했지만 React18버전부터는 사용할 수 있으므로 비동기작업에도 useTransition을 적극적으로 사용해보자.

useOptimistic을 사용하면서..

useOptimistic은 사용자 경험에 매우 좋은 최적화 방식임을 학습했다.

그러나 모든 상황에서 좋은것은 아닌것같다.
결제관리, 중요한 데이터 삭제 작업, 계정 삭제 등 신뢰성이 높은 작업에는 선반응이 필요할까? 이러한 작업에는 시간이 조금 걸리더라도 정확하게 처리하는 로직이 더 중요할것이다.
그러므로 인스타그램의 좋아요라던지, 채팅이라던지 비교적 가벼운 작업에 쓰도록 해야겠다.

참고: React 공식 문서

profile
Developer

0개의 댓글