React Context API 의존성 주입에 관하여..

function_dh·2024년 3월 16일
0
post-thumbnail

Context API가 뭔데?

리액트 공식 문서에 따르면 아래와 같이 설명하고 있습니다.

context를 이용하면 단계마다 일일이 props를 넘겨주지 않고도
컴포넌트 트리 전체에 데이터를 제공할 수 있습니다.

일반적인 React 애플리케이션에서 데이터는 위에서 아래로(, 부모로부터 자식에게)
props를 통해 전달되지만,애플리케이션 안의 여러 컴포넌트들에 전해줘야 하는
props의 경우(예를 들면 선호 로케일, UI 테마) 이 과정이 번거로울 수 있습니다.

context를 이용하면, 트리 단계마다 명시적으로 props를 넘겨주지 않아도
많은 컴포넌트가 이러한 값을 공유하도록 할 수 있습니다.

따라서 props drilling을 방지 할 수 있습니다.
vue랑 비교해본다면 event bus와 비슷한 목적을 가지고 있습니다.

사용법

기본적으로 React.createContext 메소드를 통하여 context 객체를 만들어 사용 합니다.

const NewContext = React.createContext(defaultValue);

// 타입 지정도 가능합니다.
const NewContext2 = React.createContext<{value:string}>({value:'text'});

defaultValue의 경우 컴포넌트 트리 안에서 provider를 찾지 못할 때 사용하는 기본값 입니다.
context 객체를 만든 후에 React.Provider 통하여 전달 받은 값을 하위에 위치하는 컴포넌트들에게 전달 해줄 수 있습니다.
provider 안에 provider를 사용 할 수도 있고 context 객체는 가장 가까운 provider를 통하여 값을 전달 받게 됩니다.

// ui 관련 mode를 전달하는 예시
const DisplayContext = React.createContext<{mode:'light'|'dark'}>({mode:'light'});

return (
  <DisplayContext.Provider value={mode:'light'}>
    <App />
  </DisplayContextProvider>
)

주의 해야 할 점은 context를 바라보고 있는 하위 컴포넌트들은 Provider의 값이 변경되면 전부 렌더링 됩니다.
따라서 context 안에 연관성이 없는 값들을 함께 넣을 경우 성능 이슈가 발생할 수 있습니다.
이제 선언을 했으니 사용을 해야겠죠?

const App = () => {
	// 선언한 context를 불러와서 안에 있는 mode값을 사용
	const { mode } = React.useContext(DisplayContext);
	
	return (
    <div style={{background : mode === 'light' ? 'white' : 'black'}}>
      Hello
    </div>
	)
}

함수형 컴포넌트에서는 useContext를 이용해 context에 넣어준 값을 꺼내올 수 있습니다.

export const useDisplayContext = () => React.useContext(DisplayContext);

또한 useContext를 custom hook 형태로 한번 더 감싸서
컴포넌트에선 해당 훅만 호출하여 사용할 수 있습니다.

너무 간편하죠?

부모에게서 props를 내려받지 않고 건너뛰어 값을 공유한다는 것이
마치 전역으로 상태를 관리하는 것만 같습니다.
하지만 Context는 상태 관리 툴이 아닙니다!!

엥 상태관리 툴이 아니야?

아래와 같이 중첩으로 사용할 경우 값 변경이 발생 했을 때 추적이 어렵고
관리적인 측면에서도 파악하기가 쉽지 않다는 점이 있습니다.

<Context1.Provider value={...}>
  <Context2.Provider value={...}>
    <Context3.Provider value={...}>
      <Context4.Provider value={...}>
          ...
      </Context4.Provider>
    </Context3.Provider>
  </Context2.Provider>
</Context1.Provider>

상태관리 툴 같은 경우 다음과 같은 조건이 필요합니다. 값 저장, 값 읽기, 값 변경

Context는 자체적으로 어딘가에 값을 저장 하지도 않고 useState의 setState처럼 값을 변경하는 기능을 제공하지도 않습니다.

단지 Provider에 넣어준 value를 하위 Children 아무곳에서나 useContext를 사용해 꺼내쓰기만 하기 때문입니다.

따라서 특정 라이브러리나 기능을 인터페이스로 추상화한 후 인터페이스를 확장한 객체의 인스턴스를 Context에 주입합니다.

이를 사용할 때는 Context를 사용하며 그 기능을 직접적으로 사용하는 것이 아닌 추상화한 인터페이스를 가르키게 하여 쉽게 교체 가능 하게함으로써 의존성 주입의 개념을 가졌다고 할 수 있습니다.

의존성을 주입해보자

예시로 보통 서버와 통신을 할 때 Axios 라이브러리를 사용하여 통신을 합니다.

const data = await axios.get("URL");

직접적으로 axios 호출하기 때문에 라이브러리에 문제가 생기거나 추후 라이브러리가 교체될 경우 관련된 코드를 모두 찾아서 수정해야 하는 문제가 발생하게 됩니다.

따라서 해당 문제를 context api에 의존성을 주입하여 해결해보겠습니다.

export interface FetchInterface {
  get<T>(url: string, params?: any | any[]): Promise<T>;
  post<T>(url: string, params?: any | any[]): Promise<T>;
}

rest api를 통하여 서버와 통신할 때 사용하는 get, post의 기능을 추상화하여 interface를 만들어 줍니다.

interface를 만든 후에 implements 통하여 class가 interface에 맞는지 체크를 합니다.

// axios 사용시
export class Axios implements FetchInterface {
  async get<T>(url: string, params?: any | any[]): Promise<T> {
    const { data } = await axios.get<T>(url, { params });
    return data;
  }
  async post<T>(url: string, params?: any | any[]): Promise<T> {
    const { data } = await axios.post(url, { params });
    return data;
  }
}

axios 라이브러리가 사용 불가능할 때 대체 할 기본 값인 Fetch도 준비해줍니다.

// fetch 사용시
export class Fetch implements FetchInterface {
  async get<T>(url: string, params?: any | any[]): Promise<T> {
    const data = await fetch(url, {
      method: "GET",
      body: JSON.stringify(params)
    }).then((response) => response.json());
    return data;
  }
  async post<T>(url: string, params?: any | any[]): Promise<T> {
    const data = await fetch(url, {
      method: "POST",
      body: JSON.stringify(params)
    }).then((response) => response.json());
    return data;
  }
}

자! 실제로 사용해봅시다.

// context 객체 생성
const FetchContext = React.createContext<FetchInterface>(new Fetch());

기본 default 값으로 사용할 Fetch 클래스의 인스턴스를 전달 해줍니다.

export const FetchContextProvider = ({
  children
}: {
  children: JSX.Element;
}) => {
  return (
		// 추후 fetching 라이브러리 변경 시 인터페이스 정의대로 클래스를 생성 후,
		// value에 인스턴스만 갈아 끼워주면 된다.
    <FetchContext.Provider value={new Axios()}>
      {children}
    </FetchContext.Provider>
  );
};

provider쪽 value에는 클래스 인스턴스를 value 값으로 넣어줍니다. 따라서 위에서 선언한 Axios 클래스의 인스턴스를 전달 해줍니다.

export default function App() {
  const [state, setState] = useState<any>();
  const { get } = useFetchContext();

  const getServerData = useCallback(async () => {
    const data = await get<any>("https://jsonplaceholder.typicode.com/posts");
    return data;
  }, [get]);

  useEffect(() => {
    getServerData().then((response) => setState(response));
  }, [getServerData]);

  return (
    <FetchContextProvider>
      <div>{state && JSON.stringify(state)}</div>
    </FetchContextProvider>
  );
}

사용하는 곳에서는 특정 기술을 직접 호출하지 않고 context로 정의한 함수를 이용하기 때문에 로직과 특정 기술의 분리가 가능해집니다.

이렇게 특정 기능이나 기술을 정해진 형식대로 정의해 놓으면 사용하는 컴포넌트는 불필요한 변경에 자유로워질 뿐만 아니라 테스트할 때는 상위에서 mocking Provider만 제공 하기만 하면 된다는 장점이 있습니다.

context를 사용시 리렌더링이 발생하는 이유는?

렌더링이 되는 이유는 context는 “보이지 않는 props” 또는 “내부 props”와 같기 때문입니다.

const App = React.memo(() => {
	const { mode } = React.useContext(DisplayContext);
	
	return (
    <div style={{background : mode === 'light' ? 'white' : 'black'}}>
      Hello
    </div>
	)
});

// 위에 코드와 동일하다
const App = React.memo(({mode}) => {
	return (
    <div style={{background : mode === 'light' ? 'white' : 'black'}}>
      Hello
    </div>
	)
});

App 은 props가 없는 순수한 컴포넌트이기 때문에 부모 컴포넌트의 상태값이 변경되어도 리렌더링이 발생하지 않아야 하지만 context를 통한 내부 종속성이 존재하게 됩니다.
mode는 context를 통해 리액트 상태값으로 저장되고 전달됩니다.

해당 mode 변수가 변경되면 리렌더링이 발생하고 App 은 이전 스냅샷에 의존하지 않고 새 스냅샷을 생성합니다.
리액트는 이 컴포넌트가 UserContext 를 사용하고 있는걸 알기 때문에 mode를 props인 것처럼 취급합니다.

참고 문헌

https://lasbe.tistory.com/166
https://velog.io/@cnsrn1874/react-query-and-react-context

profile
🍄 성장형 괴물..

0개의 댓글