data life

[React] Context API 사용해보기 (feat.랜더링 최적화) 본문

Front-end/React

[React] Context API 사용해보기 (feat.랜더링 최적화)

주술회전목마 2023. 9. 20. 23:27


리액트에서 일반적으로 컴포넌트 사이에 데이터를 전달해줄 때 Props를 이용합니다.
하지만 여러 컴포넌트를 거치게 된다면??
굉장히 불편하고 실수할 가능성도 높아지게 됩니다.
 
Context란?
리액트 컴포넌트 간에 값을 공유할 수 있게 해주는 기능으로 주로 전역적으로 필요한 값을 다룰 때 사용합니다. 
리액트는 부모 컴포넌트에서 자식 컴포넌트인 하나의 단방향으로 데이터를 보내주는데 깊이가 깊거나 여러 컴포넌트에서 사용해야 한다면 복잡해질 수 있습니다. 따라서 Context API를 사용하기 위해 createContext와 Provider, Consumer의 개념을 알아보도록 합시다!
 
사용방법

import { createContext } from 'react';

const context = createContext();

1. createContext 
context 객체를 생성하여 해당 context를 구독한 컴포넌트가 랜더링될 때 Provider로부터 현재 상태를 읽어옵니다.
defaultValue는 짝이 맞는 Provider를 찾지 못했을 때만 사용되는 값으로, 컴포넌트를 독립적으로 테스트할 때 유용합니다.
 
2. Provider
Context 객체 안에는 Provider라는 컴포넌트가 들어있습니다. 이 Provider는 value prop을 받아 이 값을 context를 구독하고 있는 하위 컴포넌트들에게 context의 변화를 알려주는 역할을 합니다. Provider 하위에 또 다른 Provider를 배치하는 것도 가능하며 이 경우엔 하위의 Provider의 value가 우선시 됩니다.

function App() {
	return (
    	<context.Provider value="hi">
        	<Parent />
        </context.Provider>
    )
}

 
3. Consumer 
context의 변화를 구독하는 컴포넌트로 함수 컴포넌트 안에서 context를 구독할 수 있습니다.
Context.Consumer의 자식 컴포넌트는 함수여야하고 이 함수는 context의 현재 상태를 받아 React 노드를 반환합니다. 이 함수가 받는 value는 context의 Provider 중 상위 트리에서 가장 가까운 Provider의 value prop과 동일합니다. 상위에 적절한 Provider를 찾지 못했다면 value 값은 createContext()에서 보냈던 defaultValue가 됩니다.

<MyContext.Consumer>
  {value => /* context 값을 이용한 렌더링 */}
</MyContext.Consumer>

 
useContext
함수형 컴포넌트에선 Consumer를 대신하여 useContext hook을 사용하여 현재상태를 구독할 수 있습니다.
context를 읽고 상태 변화를 구독하는 것만 가능하기 때문에 Provider를 사용하여 context 상태를 업데이트해줘야합니다.

const value = useContext(MyContext);

 
Context API의 치명적 단점
해당 Context API는 Provider 하위의 모든 Consumer들이 변경될 때마다 랜더링이 되는 것이 사실입니다. 즉, Context를 사용하지 않은 컴포넌트도 리랜더링이 발생한다는 것!

사실 여러 상태관리도구들 중 Recoil을 사용한다면 구독한 컴포넌트만 재랜더링이 일어나서 랜더링 최적화엔 적절할 듯 싶었으나

(또한 자체적으로 selector에서 비동기 처리와 캐싱을 지원까지 해줍니다..)
해당 불필요한 랜더링을 해결하기 위해 최적화하기 위한 기법들에 대해서도 알아보았습니다.

1. memorization
React.memo 함수를 이용한 최적화 기법으로 사용되는 컴포넌트만 리랜더링이 동작하도록 해줍니다.
📌 하지만, 부모가 memo를 사용했더라도 해당 자식 컴포넌트까지 리랜더링을 막진 못함!

 

✓ useMemo()란?
리액트의 랜더링 성능 최적화를 위한 hook으로, 이미 수행된 연산에 대한 결과를 기억해두었다가 동일한 연산이 다시 수행되면 그 값을 반환하는 방법입니다. 사용방법은 useEffect()와 비슷합니다.
두 번째 인자인 의존성 배열에 data.length나 변하는 경우를 넣어 해당 콜백함수를 수행하도록 사용해주세요!
React.memo란?
리액트의 고차 컴포넌트로 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수입니다.
따라서, props가 변하지 않으면 반환되지 않는 강화된 컴포넌트를 반환합니다. 

 

 

🧐 그래서 둘의 차이점은?

- 한마디로 쉽게 설명하면 useMemo는 리액트 hook이고, React.memo는 고차 컴포넌트라는 차이점이 있습니다.

- 따라서, useMemo는 컴포넌트 내부에서 사용이 가능하다라는 정도?


🧐 그렇다면, 모든 컴포넌트에서 사용해야 되는건가??
같은 props로 랜더링이 자주 일어나는 경우라면 적절하겠으나 자주 바뀌는 props일 경우엔 memo()의 이점을 보기엔 힘들 것으로 보입니다.
=> props 비교 함수가 매번 동작하기 때문에 성능상 좋지 않아 보이기 때문
 
2. Selector 함수 사용

context에서 얻은 데이터를 변환하고, 이를 하위 컴포넌트에 전달할 수 있습니다.

 

 

실전

그렇다면 Context API를 이용해서 로그인을 구현해보자

 

먼저 기본적인 로그인 배경은 다음과 같습니다.

- jwt 토큰을 통한 로그인

- react-query 사용


react-query의 캐싱 기능 덕분에 사실 로그인 상태 여부는 로컬스토리지에 저장된 액세스토큰의 여부로 파악하도록 하였으나 더 간단하게 코드구현이 가능해졌습니다.

1. 유저 정보를 호출하는 useQuery를 이용하여 로그인 여부를 판단하고,

2. 해당 유저의 정보 또한 전역으로 관리할 수 있도록 구현
 

또한, 하위 컴포넌트가 매번 리렌더링되는 것을 방지하기 위해 useMemo hook을 사용하여 연산작업을 최소화해주었습니다.

//context>index.tsx
import React, { createContext, useContext, ReactNode, useMemo } from 'react';
import { useUserInfo } from '../hooks/useAuth';

type User = {
  email: string;
  nickName: string;
  profileUrl: string;
};

export const UserContext = createContext<{
  isLogin: boolean;
  user: User | null;
}>({
  isLogin: false,
  user: null,
});

export function useUserContext() {
  return useContext(UserContext);
}

type UserProviderProps = {
  children: ReactNode;
};

export function UserProvider({ children }: UserProviderProps) {
  const { userInfo, userInfoLoading, userInfoError } = useUserInfo();
  const contextValue = useMemo(() => {
    return { isLogin: !!userInfo, user: userInfo || null };
  }, [userInfo]);

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

해당 contextValue는 userInfo 값이 변경될 때만 업데이트가 가능하고, 그 외에는 메모이제이션된 값이 사용됩니다.

또한 useUserInfo() 함수는 다음과 같습니다.

//hooks>useAuth.ts
export const useUserInfo = () => {
  const {
    data: userInfo,
    isLoading: userInfoLoading,
    isError: userInfoError,
  } = useQuery(['userInfo'], () => getUserInfoFn(), {
    staleTime: 6 * 60 * 60 * 1000,
  });
  return { userInfo, userInfoLoading, userInfoError };
};

staleTime을 지정해주면서 데이터가 캐시된 후, 지정된 시간동안은 새로고침하지 않고 이전 데이터를 사용하게 됩니다.

이때, 액세스 토큰 만료시간과 staleTime을 유사하게 설정하여 액세스토큰을 갱실할 때에도 staleTime을 같이 업데이트해주어 최신상태로 유지시켜주었습니다. 

(또한, 유저가 프로필을 수정할 때에도 업데이트가 필요!!)

 

 

✅ 정리하자면, Context API는 

Provider에 제공한 값이 변하게 되면 모든 컴포넌트가 리랜더링된다는 점에 유의하면서 적절하게 사용하시길 바랍니다!

자주 바뀌는 것들은 별도의 Context로 묶어주거나 자식 컴포넌트들을 적절히 분리하여 위와 같이 소개해드린 방법으로 불필요한 리랜더링을 방지하도록 사용해주세요ㅎㅎ