2차 공부/TIL

24.08.28 React Concurrent Feature / useSyncExternalStore

공대탈출 2024. 8. 28. 23:57

const Comp = () => {
	const [count, setCount] = useState(0)
    const expensiveHandle = () = > {
    	//엄청나게 오래걸리는 명령코드
        setCount(prev => prev+1)
    }
  return (
    <button onClick={expensiveHandle}>action</button>
  )
}

export default Comp

action버튼을 누르면 리렌더링이 유발되고, 기존의 렌더링 방식에서는 한번에 하나씩 렌더링이 처리가 되었다. 이는 렌더링 연산에 들어가면 중지할 수 없었음을 의미한다.

앞 연산이 매우 길어질 때 문제가 발생하는 것이다.

 

이러한 문제를 해결하고자 디바운싱, 쓰로틀링같은 방식들이 사용되었었다.

함수실행에 제약을 거는 것인데, 이 두가지 방식이 블로킹 렌더링의 근본적인 문제를 해결하지 못한다.

  1. 성능이 나쁜 기기에서 주기를 짧게 가지면 버벅거림
  2. 성능이 좋은 기기에서 설정한 주기로 인해 불필요한 지연시간이 발생함
  3. 성능과 상관없이 무조건 일정시간 대기
  4. 사용자 입력중에는 작업의 처리가 이뤄지지 않음

이러한 문제들을 해결하여 UX를 향상시키기위해 동시성이 나왔다.

동시성은 개발자의 의도에 따른 지연시간에 의존하지않고, 사용자 기기 성능에 좌우되도록 만들어준다.

 

해결해야하는 문제

  • 한번에 하나씩만 렌더링 연산되는 상황(블로킹 렌더링)
  • 매우 큰 연산 시 UX 떨어짐
  • 디바운싱/쓰로틀링으로 문제를 완전해결하지 못함

 

리액트 팀에서 무거운 렌더링 작업중에도 사용자 인터랙션이 발생했을 때 즉각 반응할 수 있도록 React 18부터 startTransition, useDeferredValue를 지원했으며, 특정 상황에서 이 동시성 기능을 적용할 수 있다.

import React, { useState, useTransition } from "react";

const PostsTab = React.memo(function PostsTab() {
  console.log("[SLOW RENDER] Rendering 100 <SlowPost /> components");

  const items = Array.from({ length: 1000 }, (_, i) => (
    <SlowPost key={i} index={i} />
  ));

  return <ul className="items">{items}</ul>;
});

function SlowPost({ index }) {
  const startTime = performance.now();
  while (performance.now() - startTime < 2) {
    // 의도적으로 시간을 소비하는 작업
  }

  return <li className="item">Post #{index + 1}</li>;
}

function App() {
  const [isPending, setIsPending] = useState(true);
  const [showPosts, setShowPosts] = useState(false);

  const handleClick = () => {
	  setShowPosts((prev) => !prev);
      setIsPending(prev => !prev)
  };

  return (
    <div>
      <button onClick={handleClick}>
        {isPending ? "Loading posts..." : "Toggle Posts"}
      </button>
      {showPosts && <PostsTab />}
    </div>
  );
}

export default App;

이렇게하면 렌더링작업이 오래걸려 블로킹렌더링이 발생한다. 

import React, { useState, useTransition } from "react";

const PostsTab = React.memo(function PostsTab() {
  console.log("[SLOW RENDER] Rendering 100 <SlowPost /> components");

  const items = Array.from({ length: 1000 }, (_, i) => (
    <SlowPost key={i} index={i} />
  ));

  return <ul className="items">{items}</ul>;
});

function SlowPost({ index }) {
  const startTime = performance.now();
  while (performance.now() - startTime < 2) {
    // 의도적으로 시간을 소비하는 작업
  }

  return <li className="item">Post #{index + 1}</li>;
}

function App() {
  const [isPending, startTransition] = useTransition();
  const [showPosts, setShowPosts] = useState(false);

  const handleClick = () => {
    startTransition(() => {
      setShowPosts((prev) => !prev);
    });
  };

  return (
    <div>
      <button onClick={handleClick}>
        {isPending ? "Loading posts..." : "Toggle Posts"}
      </button>
      {showPosts && <PostsTab />}
    </div>
  );
}

export default App;

이렇게 startTransition을 사용하면 해당 렌더링 중 다시 버튼을 클릭했을 때 렌더링 작업을 중단한다.

 

 

Tearing

Tearing이란 렌더링 도중 UI가 불일치하게 보이는 문제를 말한다. Concurrent Feature에서는 우선순위가 높은 작업이 진행중인 작업을 중단시키면서 이러한 문제가 발생할 수 있다. 이러한 문제를 해결하기위해 useSyncExternalStore와 같은 API가 도입되었다.

이 훅은 외부 상태가 변경될 때 tearing을 방지하기 위해서 렌더링을 다시 실행해준다.

 

Tearing을 해결하는 방법

  • 안고치기 : UI불일치를 감수하고, 기존 동기적 렌더링 방식을 유지한다.
  • useSyncExternalStore 사용하기 : useSyncExternalStore을 사용해 렌더링 중 발생하는 외부 상태 변경을 감지하고, 이를 기반으로 다시 렌더링을 실행해 tearing을 방지한다.
    • 내부 상태보다 느리다.
  • 내부 상태 사용하기 : useState, useReducer 등 을 사용하면 리액트에게 제어권이 있어 Tearing이 발생하지 않는다.

useSyncExternalStore은 세가지 인자를 받는다. useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)

여기서 리액트에서의 서버사이드 렌더링은 아직 공부하지않았으니 제외하고, subscribe와 getSnapshot을 알아보자.

  • subscribe : 외부 저장소의 상태가 변경될 때 실행되는 함수이다. 이 함수가 실행되면 React는 해당 상태를 기반으로 다시 렌더링한다.
  • getSnapshot: 현재 외부 Store의 상태를 가져오는 함수이다. 이 상태를 React가 UI에 반영한다.
const externalStore = (initialState) => {
  let state = initialState;
  const callbacks = new Set();

  const subscribe = (callback) => {
    callbacks.add(callback);
    return () => callbacks.delete(callback);
  };

  const getState = () => state;

  const setState = (newState) => {
    state = typeof newState === "function" ? newState(state) : newState;
    callbacks.forEach((callback) => callback());
  };

  return {
    getState,
    setState,
    subscribe,
  };
};

const useStore = (store) => {
  const state = useSyncExternalStore(
    store.subscribe,
    store.getState,
    store.getState
  );
  return [state, store.setState];
};

subscribe을 관리하기위해 Set을 사용했고, getState로 getter기능을 제공하였다. 여기선 getSnapshot의 기능이다.

const todoStore = externalStore([]);

function useTodos() {
  const [todos, setTodos] = useStore(todoStore);

  function addTodo(todo) {
    setTodos((prev) => [...prev, todo]);
  }

  function deleteTodo(todoId) {
    setTodos((prev) => prev.filter((todo) => todo.id !== todoId));
  }

  return {
    todos,
    addTodo,
    deleteTodo,
  };
}
export default function App() {
  const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();
  const [newTodo, setNewTodo] = useState("");

  const handleSubmit = (event) => {
    event.preventDefault();
    const todo = { id: Date.now(), text: newTodo, completed: false };
    addTodo(todo);
    setNewTodo("");
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={newTodo}
          onChange={(event) => setNewTodo(event.target.value)}
        />
        <button type="submit">Add</button>
      </form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
            {todo.text}{" "}
            <button onClick={() => toggleTodo(todo.id)}>
              {todo.completed ? "Undo" : "Complete"}
            </button>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

 

세션을 들으며 다시한번 느낀점은 리액트에는 알아서 해주는 마법은 없다는 것이다. 리덕스에서 자동으로 해주었던 subscribe와 getSnapshot도 저러한 기능들 덕분에 이뤄지는 것이다.

리덕스를 써야하나? useSyncExternalStore을 사용해야하나? 왜? 를 잘 고민하고 선택해야할 것 같다.