반응형

React에서 렌더링 최적화를 하다 보면 React.memo와 Context를 함께 사용하는 경우가 많습니다. 그런데 분명 memo로 컴포넌트를 감쌌는데도 다시 렌더링되는 상황을 만날 때가 있습니다.

핵심은 간단합니다. React.memo는 props 비교를 통해 렌더링을 건너뛰는 도구이고, useContext는 Provider의 value 변경을 구독합니다. 즉 props가 그대로여도 컴포넌트 내부에서 Context를 읽고 있다면 Context 값 변경에 따라 다시 렌더링될 수 있습니다.

React.memo가 해주는 일

React.memo는 부모 컴포넌트가 다시 렌더링될 때 자식 컴포넌트의 props가 이전과 같은지 비교합니다. props가 같다면 React는 해당 컴포넌트의 렌더링을 건너뛸 수 있습니다.

const Profile = memo(function Profile({ name }: { name: string }) {
  return <div>{name}</div>;
});

이 경우 부모가 다시 렌더링되더라도 name 값이 같다면 Profile은 다시 렌더링되지 않을 수 있습니다. 하지만 이 최적화는 어디까지나 props 기준입니다.

useContext를 쓰면 이야기가 달라진다

컴포넌트 내부에서 useContext()를 사용하면 해당 컴포넌트는 Context Provider의 value 변경에 반응합니다.

const Profile = memo(function Profile() {
  const { user } = useContext(AuthContext);

  return <div>{user.name}</div>;
});

이 컴포넌트는 memo로 감싸져 있지만, AuthContext.Provider의 value가 바뀌면 다시 렌더링됩니다. memo는 props 비교를 최적화하는 도구이지, Context 구독 자체를 막는 도구가 아니기 때문입니다.

Provider value에 객체를 직접 넣을 때 주의할 점

실무에서 자주 나오는 패턴이 있습니다.

const value = { user, logout };

return (
  <AuthContext.Provider value={value}>
    {children}
  </AuthContext.Provider>
);

겉으로 보기에는 문제가 없어 보입니다. 하지만 부모 컴포넌트가 다시 렌더링될 때마다 { user, logout }는 새로운 객체로 만들어집니다.

객체 안의 값이 같아 보여도 참조가 달라지면 React는 Context 값이 바뀐 것으로 판단할 수 있습니다. 그 결과 Context를 읽는 하위 컴포넌트들이 불필요하게 다시 렌더링될 수 있습니다.

useMemo와 useCallback으로 value 안정화하기

Provider에 전달하는 값을 안정화하려면 useMemo와 useCallback을 사용할 수 있습니다.

const logout = useCallback(() => {
  // logout logic
}, []);

const value = useMemo(() => {
  return { user, logout };
}, [user, logout]);

return (
  <AuthContext.Provider value={value}>
    {children}
  </AuthContext.Provider>
);

이렇게 하면 user나 logout이 바뀌지 않는 한 Provider의 value 객체도 불필요하게 새로 만들어지지 않습니다.

물론 useMemo를 모든 곳에 붙이라는 뜻은 아닙니다. Context를 읽는 컴포넌트가 많고, Provider 주변에서 렌더링이 자주 일어나는 경우에 효과가 큽니다.

Context를 읽는 컴포넌트와 UI 컴포넌트 분리하기

Context 변경으로 인한 렌더링 범위를 줄이고 싶다면 컴포넌트를 나누는 방식도 좋습니다.

function ProfileContainer() {
  const { user } = useContext(AuthContext);

  return <ProfileView name={user.name} />;
}

const ProfileView = memo(function ProfileView({ name }: { name: string }) {
  return <div>{name}</div>;
});

이 구조에서는 ProfileContainer가 Context 변경에 반응합니다. 하지만 실제 UI를 그리는 ProfileView는 name prop이 같으면 렌더링을 건너뛸 수 있습니다.

Context를 읽는 부분과 화면을 그리는 부분을 분리하면 최적화 지점이 더 명확해집니다.

실무에서는 memo보다 Context 설계를 먼저 본다

렌더링 문제가 생기면 memo를 먼저 붙이고 싶어집니다. 하지만 Context가 원인인 경우에는 다음 질문을 먼저 해보는 편이 좋습니다.

  • 이 Context 값은 얼마나 자주 바뀌는가?
  • 이 값을 읽는 컴포넌트는 얼마나 많은가?
  • 하나의 Context에 너무 많은 상태가 섞여 있지는 않은가?
  • state와 action을 분리할 수 있는가?
  • Context를 읽는 컴포넌트와 무거운 UI 컴포넌트를 분리할 수 있는가?

Context는 인증 정보, 테마, 언어처럼 앱 전역에서 필요한 값에 잘 어울립니다. 하지만 자주 바뀌는 입력값이나 화면 단위 상태를 큰 Context 하나에 넣으면 렌더링 영향 범위가 커질 수 있습니다.

정리

React.memo는 props 비교를 통해 렌더링을 줄이는 도구입니다. 하지만 컴포넌트가 useContext로 Context 값을 읽고 있다면 Provider의 value 변경에 따라 다시 렌더링됩니다.

따라서 Context와 memo를 함께 사용할 때는 다음을 기억하면 좋습니다.

  • memo는 Context 변경을 막지 않는다.
  • Provider의 value에 객체를 직접 만들면 참조가 매번 바뀔 수 있다.
  • useMemo, useCallback으로 Provider 값을 안정화할 수 있다.
  • Context consumer와 memoized view 컴포넌트를 분리하면 렌더링 범위를 줄일 수 있다.
  • 최적화보다 먼저 Context의 변경 범위와 책임을 점검해야 한다.

연습할 때는 Provider value를 직접 넣은 경우와 useMemo로 감싼 경우를 비교해보면 이해가 빠릅니다. console.log로 consumer 렌더링 횟수를 확인해보면 memo와 Context의 관계가 훨씬 분명하게 보입니다.

반응형