리액트 훅 깊게 살펴보기: useLayoutEffect, useContext, useCallback

Yujin Jung·2025년 11월 29일

리액트를 사용하다 보면 성능 최적화나 상태 전파, DOM 조작과 같은 복잡한 시점 제어가 필요해지는 순간이 자주 찾아옵니다. 이번 글에서는 리액트 훅 중에서도 특히 ‘시점 제어’, ‘상태 공유’, ‘함수 메모이제이션’에 중요한 역할을 하는 세 가지 훅에 대해 정리해보겠습니다.


⚛️ useLayoutEffect — DOM 변경 시점까지 제어하고 싶을 때

리액트를 처음 배울 때 가장 많이 쓰는 훅은 useEffect입니다. 그런데 분명 코드가 맞는데 “화면 깜빡임”, “스타일이 늦게 반영됨”, "스크롤 위치가 순간 튀는 현상" 등의 문제가 있을 때가 있습니다.
이럴 때 필요한 훅이 바로 useLayoutEffect입니다.


✔️ useEffect vs useLayoutEffect: 실행 시점의 차이

먼저 두 훅의 실행 흐름을 비교해볼게요.

React 렌더링 →
(1) DOM 업데이트 →
(2) useLayoutEffect 실행 →
브라우저가 화면에 그림 →
(3) useEffect 실행

즉,

실행 시점
useLayoutEffectDOM이 “업데이트된 직후”, 화면에 그려지기 이전
useEffect브라우저가 화면을 그린 이후

👉 중요한 포인트

useLayoutEffect는 렌더링을 블로킹합니다.
즉, 해당 콜백이 끝날 때까지 화면이 그려지지 않습니다.

그래서 잘 사용하면 깜빡임 없이 자연스러운 UI를 만들 수 있지만, 남용하면 성능이 나빠질 수 있습니다.


✔️ 예제: 실행 순서 비교

useEffect(() => {
  console.log('useEffect', count)
}, [count])

useLayoutEffect(() => {
  console.log('useLayoutEffect', count)
}, [count])

버튼을 클릭해 count가 변경되면 실행 순서는 항상 아래와 같습니다.

useLayoutEffect → useEffect

✔️ 언제 useLayoutEffect를 써야 할까?

DOM은 계산됐지만 브라우저에 그려지기 전
“그리기 전에 꼭 해야 하는 작업”이 있을 때

예를 들어:

  • DOM 크기를 재고 해당 값으로 스타일을 조정해야 할 때
  • 스크롤 위치를 정확히 특정 지점으로 이동시켜야 할 때
  • 애니메이션 초기 상태를 DOM 기반으로 세팅해야 할 때
  • 측정 기반 UI(Layout Shift 방지)가 필요할 때

즉, 화면에 깜빡임 없이 자연스럽게 초기 상태를 맞추고 싶을 때입니다.


⚛️ useContext — props drilling을 해결하는 리액트의 ‘전역 전달자’

리액트 컴포넌트 트리 구조에서는 부모가 가진 데이터를 자식, 손자, 증손자에게 전달해야 하는 경우가 많습니다.이걸 전부 props로 내려보내면 아래처럼 지옥 같은 코드가 됩니다.

<A props={value}>
  <B props={value}>
    <C props={value}>
      <D props={value} />
    </C>
  </B>
</A>

이걸 props drilling이라고 합니다.


✔️ useContext란?

Context는 리액트에서 “전역 데이터”를 공유하는 메커니즘입니다.

  • Provider가 값을 제공하고
  • 하위의 모든 Consumer(useContext 사용 컴포넌트)가 값을 사용할 수 있음

✔️ 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>
}

✔️ 그러나! useContext는 성능적으로 “독이 될 수 있다”

❗ Provider 하위의 컴포넌트는 값이 변경되면 전부 리렌더링된다

function ParentComponent() {
  const [text, setText] = useState('');

  return (
    <ContextProvider text={text}>
      <input value={text} onChange={handleChange} />
      <ChildComponent />
    </ContextProvider>
  );
}

이때 text가 바뀌면 화면에 출력하는 ChildComponent만 리렌더링될 것 같지만…

👉 ContextProvider 하위 트리는 모두 리렌더링됩니다.

콘솔 출력:

렌더링 GrandChildComponent
렌더링 ChildComponent
렌더링 ParentComponent

✔️ 해결: memo + context 분리

자식 컴포넌트를 memo로 감싸면 props가 변하지 않는 한 리렌더를 막을 수 있습니다.

const ChildComponent = memo(() => {
  return <GrandChildComponent />;
});

하지만 근본적인 해결책은:

  • Context를 너무 많은 데이터 저장소로 사용하지 말 것
  • Context를 역할별로 세분화해 분리할 것
  • Recoil/Zustand/Jotai 같은 상태 라이브러리 고려

⚛️ useCallback — 함수를 재생성하지 않도록 메모이제이션하기

React에서 함수는 매 렌더링마다 새로 만들어집니다.
이게 문제되는 이유는 자식 컴포넌트 메모이제이션(memo)과 연관되기 때문입니다.


✔️ 문제 상황: 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 함수가 매 렌더링마다 새로 생성되기 때문


✔️ 해결: useCallback 적용

const toggle1 = useCallback(() => {
  setStatus1(!status1)
}, [status1])

이제 함수는 의존 배열이 변경될 때만 새로 생성됩니다.

다시 렌더링을 확인하면:

  • onChange가 변경된 컴포넌트만 리렌더링됨
  • memo + useCallback 조합으로 최적화 성공

✔️ useCallback vs useMemo 차이

목적사용 예
useMemo값을 메모이제이션
useCallback함수를 메모이제이션

둘은 사실 거의 똑같은 훅이며, useCallback은 다음과 같은 sugar syntax일 뿐입니다.

useCallback(fn, deps) === useMemo(() => fn, deps)
profile
매일매일 조금씩 성장하려 노력하는 프론트엔드 개발자입니다!

0개의 댓글