What is tearing
리액트의 토론 커뮤니티에 다음과 같은 글이 올라온 적이 있다.
간단하게 요약하자면
React 18의
Suspense
와startTransition
같은 동시적 렌더링을 사용할 때 다른 작업을 수행하기 위해 렌더링은 일시 중지할 수 있다. 이러한 일시 중지 사이에 다른 상태값의 업데이트가 몰래 삽입되어 렌더링에 사용되는 데이터가 변경될 수 있으며, 이로 인해 UI에 동일한 데이터에 대해 두 개의 다른 값이 표시될 수 있다.라는 내용이였다.
이 글을 통해 tearing
이 무엇인지, 그리고 동시성 렌더링이 왜 tearing
을 유발하는지, 마지막으로 이를 해결하기 위해 React v18은 무엇을 노력했는지 정리해보려고 한다. 🔥🔥🔥
Tearing
이란 일반적으로 비디오에서 여러 프레임이 표시되어 비디오가 '찢어진 것'처럼 보이는 현상이다.
위의 이미지를 보면 Tear Point
가 2개 있는데 찢어진 것 처럼 한 화면 다른 프레임이 보이는 것을 확인해 볼 수 있다.
일반적으로 UI 상에서 Tearing
이라고 하면 동일한 상태값 상에서 다른 값들이 표현되는 것을 말한다.
좀 더 React 적으로 말하면 tearing
은 애플리케이션의 상태가 비동기적으로 업데이트될 때 UI의 다른 부분이 일관성 없는 상태를 보이는 현상을 말한다.
즉, 애플리케이션의 상태가 변경되고 있지만, 이 변화가 동시에 모든 UI 컴포넌트에 반영되지 않아 일부 컴포넌트가 구식 상태를 표시하는 문제이다.
[이미지 필요]
이러한 현상은 React에만 국한된 문제가 아니라 동시성의 필연적인 결과이다.
그렇다면 React v18 이전의 버전에서는 발생하지 않을까??
React v18 이전의 동기적 렌더링의 동기적 렌더링은 다음과 같은 순서로 진행된다.
Start Rendering
React 트리 렌더링을 시작한다.
이 단계에서 External Store v1(repository layer)
에 요청하여 색상 값을 가져와서 blue
를 전달한다. 따라서 해당 컴포넌트가 파랑색으로 렌더링된다.
Continue Rendering
이때 동시에 렌더링되지 않기 때문에 React의 다른 컴포넌트들도 중간에 렌더링을 멈추지 않고 계속 렌더링한다. 또한 멈추지 않았기 때문에 External Store v1(repository layer)
의 상태값은 계속 blue
를 가지고 있다. 따라서 다른 모든 컴포넌트는 동일한 상태값을 전달받게 된다.
Finish Rendering
모든 컴포넌트가 파란색으로 렌더링되고 모두 동일하게 보이는 것을 볼 수 있다. UI는 표시되는 모든 것이 화면의 모든 곳에서 동일한 값으로 렌더링되기 때문에 항상 일관된 상태로 표시된다.
After render, the external store can update
마지막으로 External Store
가 v2로 업데이트 될 수 있다.
이는 React가 완료되고 다른 작업이 발생할 수 있도록 했기 때문이다.
External Store
가 v2로 업데이트되었기 떄문에 다시 첫번째 단계로 돌아가서 1 ~ 3번 단계를 실행하게 된다.
다음과 같은 렌더링 과정을 보았을 때 Tearing
이 발생할 여지가 보이지 않는다.
그렇다면 React v18의 동시성 렌더링은 어떻게 진행될까?
Start Rendering
위의 단계와 마찬가지로 React 트리 렌더링을 시작한다.
이 단계에서 External Store v1(repository layer)
에 요청하여 색상 값을 가져와서 blue
를 전달한다. 따라서 해당 컴포넌트가 파랑색으로 렌더링된다.
React yields store updated Data changes to red
문제는 이 단계에서 발생할 수 있다.
동시성 렌더링에서는 React가 렌더링을 차단하지 않고 페이지와 상호 작용할 수 있다.
이 경우 User의 클릭 이벤트로 인해 갑자기
External Store v1(repository layer)
가External Store v2(repository layer)
로 변경되어 값이Red
로 변경될 수가 있다.
Continue Rendering
다른 컴포넌트가 렌더링될 때 External Store v2
로 변경되었기 때문에 값은 red
이다. 따라서 첫번째 컴포넌트가 Blue
로 렌더링된 것과 다르게 다른 컴포넌트들은 Red
로 렌더링이 된다.
UI is inconsistent
최종적으로 렌더링된 결과를 보면 tearing
즉, 같은 상태값임에도 불과하고 화면 찢어짐 현상이 발생하는 것을 확인해 볼 수 있다.
주로
Tearing
이 발생할 수 있는 상황은 주로 여러 컴포넌트가 동시에 상태를 공유할 때 발생한다.
const SharedCounter = createContext(0);
function App() {
const [count, setCount] = useState(0);
return (
<SharedCounter.Provider value={{ count, setCount }}>
<CounterDisplay />
<CounterButton />
</SharedCounter.Provider>
);
}
function CounterDisplay() {
const { count } = useContext(SharedCounter);
return <div>Count: {count}</div>;
}
function CounterButton() {
const { setCount } = useContext(SharedCounter);
return <button onClick={() => setCount(c => c + 1)}>증가</button>;
}
이 예제에서는 SharedCounter
라는 Context
를 사용하여 count
상태를 공유하고 있다.
여러 컴포넌트가 이 상태를 동시에 참조하고 수정할 수 있으므로, 상태 업데이트가 비동기적으로 발생할 때 tearing
이 발생할 수 있다.
function App() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchData().then(data => setUser(data));
}, []);
return (
<div>
<UserProfile user={user} />
<UserSettings user={user} setUser={setUser} />
</div>
);
}
function UserProfile({ user }) {
if (!user) return <div>로딩...</div>;
return <div>{user.name}</div>;
}
function UserSettings({ user, setUser }) {
if (!user) return <div>로딩...</div>;
const updateUser = () => {
// ... Update user logic
};
return <button onClick={updateUser}>Update User</button>;
}
이 예제에서는 비동기적으로 사용자 데이터를 패치하고 있다.
데이터가 로드되는 동안 UserProfile
과 UserSettings
컴포넌트는 일관성 없는 상태를 보일 수 있다. 예를 들어, 하나의 컴포넌트는 아직 데이터가 로드되지 않았지만, 다른 컴포넌트는 이미 데이터를 받았을 수 있다.
또한
useEffect
를 통해 데이터 패칭을 진행하고 있다.useEffect
는 대표적인 부수효과 기능이고 이 스코프 안에서 데이터 패칭을 진행할 경우 불필요한 데이터 패칭을 추가적으로 진행할 여지가 있다.
function App() {
const [state, setState] = useState({ count: 0, text: '' });
return (
<div>
<Counter state={state} setState={setState} />
<TextInput state={state} setState={setState} />
</div>
);
}
function Counter({ state, setState }) {
return <button onClick={() => setState({ ...state, count: state.count + 1 })}>Increment</button>;
}
function TextInput({ state, setState }) {
return <input value={state.text} onChange={e => setState({ ...state, text: e.target.value })} />;
}
이 예제에서는 state 객체를 여러 컴포넌트에 걸쳐 공유하고 있다.
Counter
컴포넌트와 TextInput
컴포넌트가 동시에 state
를 업데이트하려 할 때, 서로의 업데이트를 덮어쓸 수 있으며, 이는 Tearing
으로 이어질 수 있다.
그렇다면 사용자 인터럽트가 가능한 렌더링은 무엇일까? 🤨
인터럽트 가능한 렌더링(Interruptible Rendering)은 React 18의 동시성 모델의 핵심 기능 중 하나로 React가 렌더링 작업을 중단하고, 더 중요한 작업에 우선적으로 자원을 할당할 수 있게 한다. 이를 통해 애플리케이션의 반응성을 향상시킬 수 있다.
Tearing
과 같은 동시성의 문제점을 해결하기 위한 방안 중 하나로 Auto Batching
이 있다고 생각했는데 정확히 말하면 Auto Batching
는 렌더링 최적화를 위한 방안일 뿐이지 Tearing
과는 상관이 없다.
왜그럴까 ?!?!
Auto Batching
이란 React가 더 나은 성능을 위해 여러 개의 state 업데이트를 하나의 리렌더링 (re-render)로 묶는 것을 의미한다.
예를 들어서 다음과 같은 예제가 있다고 하자.
코드 출처
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
fetchSomething().then(() => {
setCount(c => c + 1);
setFlag(f => !f);
});
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
해당 코드의 React 17과 React 18의 렌더링 과정을 그림으로 표현하면 다음과 같다.
setCount
호출로 인해 State가 변경됨. -> 1차 렌더링 발생 !!!setFlag
호출 -> 2차 렌더링 발생 !!총 2번의 렌더링이 발생한다.
setCount
호출 -> 아직 렌더링 미발생setFlag
호출 -> 렌더링 발생 !!총 한 번의 렌더링이 발생한다.
단, 문제가 있는데 Auto Batching같은 경우 같은 스코프 내에서만 가능하다.
즉, 동시성 렌더링에서는 각각의 업데이트가 서로 독립적으로 실행될 수 있고, 이로 인해 여러 업데이트가 동시에 일어날 수 있는데, 동시성 렌더링 환경에서는 auto batching
이 여전히 존재하지만, 각각의 업데이트가 병렬로 처리될 수 있어서 모든 업데이트가 한 번에 묶여서 처리되는 것은 아니다.
동시성 렌더링은 매우 좋은 기능이긴 하지만 Tearing
같은 현상이 발생할 수 있음을 유의하자 !!
끝 !!