리액트를 사용하다 보면 성능 최적화나 상태 전파, DOM 조작과 같은 복잡한 시점 제어가 필요해지는 순간이 자주 찾아옵니다. 이번 글에서는 리액트 훅 중에서도 특히 ‘시점 제어’, ‘상태 공유’, ‘함수 메모이제이션’에 중요한 역할을 하는 세 가지 훅에 대해 정리해보겠습니다.
리액트를 처음 배울 때 가장 많이 쓰는 훅은 useEffect입니다. 그런데 분명 코드가 맞는데 “화면 깜빡임”, “스타일이 늦게 반영됨”, "스크롤 위치가 순간 튀는 현상" 등의 문제가 있을 때가 있습니다.
이럴 때 필요한 훅이 바로 useLayoutEffect입니다.
먼저 두 훅의 실행 흐름을 비교해볼게요.
React 렌더링 →
(1) DOM 업데이트 →
(2) useLayoutEffect 실행 →
브라우저가 화면에 그림 →
(3) useEffect 실행
즉,
| 훅 | 실행 시점 |
|---|---|
| useLayoutEffect | DOM이 “업데이트된 직후”, 화면에 그려지기 이전 |
| useEffect | 브라우저가 화면을 그린 이후 |
useLayoutEffect는 렌더링을 블로킹합니다.
즉, 해당 콜백이 끝날 때까지 화면이 그려지지 않습니다.
그래서 잘 사용하면 깜빡임 없이 자연스러운 UI를 만들 수 있지만, 남용하면 성능이 나빠질 수 있습니다.
useEffect(() => {
console.log('useEffect', count)
}, [count])
useLayoutEffect(() => {
console.log('useLayoutEffect', count)
}, [count])
버튼을 클릭해 count가 변경되면 실행 순서는 항상 아래와 같습니다.
useLayoutEffect → useEffect
DOM은 계산됐지만 브라우저에 그려지기 전
“그리기 전에 꼭 해야 하는 작업”이 있을 때
예를 들어:
즉, 화면에 깜빡임 없이 자연스럽게 초기 상태를 맞추고 싶을 때입니다.
리액트 컴포넌트 트리 구조에서는 부모가 가진 데이터를 자식, 손자, 증손자에게 전달해야 하는 경우가 많습니다.이걸 전부 props로 내려보내면 아래처럼 지옥 같은 코드가 됩니다.
<A props={value}>
<B props={value}>
<C props={value}>
<D props={value} />
</C>
</B>
</A>
이걸 props drilling이라고 합니다.
Context는 리액트에서 “전역 데이터”를 공유하는 메커니즘입니다.
const MyContext = createContext();
function Parent() {
return (
<MyContext.Provider value={{ hello: 'react' }}>
<Child />
</MyContext.Provider>
)
}
function Child() {
const value = useContext(MyContext);
return <div>{value.hello}</div>
}
function ParentComponent() {
const [text, setText] = useState('');
return (
<ContextProvider text={text}>
<input value={text} onChange={handleChange} />
<ChildComponent />
</ContextProvider>
);
}
이때 text가 바뀌면 화면에 출력하는 ChildComponent만 리렌더링될 것 같지만…
👉 ContextProvider 하위 트리는 모두 리렌더링됩니다.
콘솔 출력:
렌더링 GrandChildComponent
렌더링 ChildComponent
렌더링 ParentComponent
자식 컴포넌트를 memo로 감싸면 props가 변하지 않는 한 리렌더를 막을 수 있습니다.
const ChildComponent = memo(() => {
return <GrandChildComponent />;
});
하지만 근본적인 해결책은:
React에서 함수는 매 렌더링마다 새로 만들어집니다.
이게 문제되는 이유는 자식 컴포넌트 메모이제이션(memo)과 연관되기 때문입니다.
const Child = memo(({ value, onChange }) => {
useEffect(() => console.log('렌더링', value));
return <button onClick={onChange}>toggle</button>
});
부모:
function App() {
const [status1, setStatus1] = useState(false);
const [status2, setStatus2] = useState(false);
const toggle1 = () => setStatus1(!status1);
const toggle2 = () => setStatus2(!status2);
return (
<>
<Child value={status1} onChange={toggle1} />
<Child value={status2} onChange={toggle2} />
</>
);
}
버튼을 누를 때마다 두 Child 컴포넌트가 모두 리렌더링됩니다.
왜?
👉 onChange 함수가 매 렌더링마다 새로 생성되기 때문
const toggle1 = useCallback(() => {
setStatus1(!status1)
}, [status1])
이제 함수는 의존 배열이 변경될 때만 새로 생성됩니다.
다시 렌더링을 확인하면:
| 목적 | 사용 예 |
|---|---|
| useMemo | 값을 메모이제이션 |
| useCallback | 함수를 메모이제이션 |
둘은 사실 거의 똑같은 훅이며, useCallback은 다음과 같은 sugar syntax일 뿐입니다.
useCallback(fn, deps) === useMemo(() => fn, deps)