const Comp = () => {
const [count, setCount] = useState(0)
const expensiveHandle = () = > {
//엄청나게 오래걸리는 명령코드
setCount(prev => prev+1)
}
return (
<button onClick={expensiveHandle}>action</button>
)
}
export default Comp
action버튼을 누르면 리렌더링이 유발되고, 기존의 렌더링 방식에서는 한번에 하나씩 렌더링이 처리가 되었다. 이는 렌더링 연산에 들어가면 중지할 수 없었음을 의미한다.
앞 연산이 매우 길어질 때 문제가 발생하는 것이다.
이러한 문제를 해결하고자 디바운싱, 쓰로틀링같은 방식들이 사용되었었다.
함수실행에 제약을 거는 것인데, 이 두가지 방식이 블로킹 렌더링의 근본적인 문제를 해결하지 못한다.
- 성능이 나쁜 기기에서 주기를 짧게 가지면 버벅거림
- 성능이 좋은 기기에서 설정한 주기로 인해 불필요한 지연시간이 발생함
- 성능과 상관없이 무조건 일정시간 대기
- 사용자 입력중에는 작업의 처리가 이뤄지지 않음
이러한 문제들을 해결하여 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을 사용해야하나? 왜? 를 잘 고민하고 선택해야할 것 같다.
'2차 공부 > TIL' 카테고리의 다른 글
24.08.30 팀프로젝트 Startify 진행상황 (0) | 2024.08.31 |
---|---|
24.08.29 팀프로젝트 진행 - Startify (0) | 2024.08.29 |
24.08.27 react의 useSyncExternalStore 훅 알아보기 (0) | 2024.08.27 |
24.08.26 챌린지반 아티클 -3 / 자바스크립트의 History API (0) | 2024.08.27 |
24.08.26 Next JS CSS (0) | 2024.08.26 |