본문 바로가기
React

[React] Context API와 의존성 주입

by LasBe 2023. 5. 8.
반응형

⚡ Context API란 무엇인가?

리액트 공식문서에서는 Context를 다음과 같이 소개하고 있습니다.

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

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

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

리액트에서는 컴포넌트 간 prop drilling을 방지하기 위해 context를 내놓았습니다.

 

context를 이용한다면 부모 컴포넌트와 자식 컴포넌트를 뛰어넘어 값을 전달할 수 있습니다.

 

그럼 사용법 먼저 알아보겠습니다.

 

📌 Context Api 사용법

🔎 React.createContext

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

우선 React.createContext를 통해 context 객체를 만들어줍니다.

 

defaultValue는 트리 안에서 적절한 Provider를 찾지 못할 때만 쓰이는 기본값입니다.

 

Provider에 value가 undefined이여도 defaultValue를 제공하지는 않습니다.

 

🔎 React.Provider

const DisplayContext = React.createContext<{mode:'light'|'dark'}>({mode:'light'});
...
return (
    <DisplayContext.Provider value={mode:'light'}>
        <App />
    </DisplayContextProvider>
)

Provider는 받은 value 값을 하위에 위치한 컴포넌트에게 전달하는 역할을 합니다.

 

Provider안에 Provider를 선언할 수 있으며 이럴 경우 가장 가까운 부모 Provider의 값이 우선됩니다.

 

그리고 context를 구독하고 있는 하위 컴포넌트들은 Provider의 값이 변경되면 전부 렌더링됩니다.

 

그렇기에 한 context 안에 연관성이 없는 값들을 함께 넣을 경우 성능 이슈가 발생할 수 있기 때문에
연관성 있는 값들끼리 분리하여 다른 context로 분리해줄 수도 있습니다.

 

🔎 React.useContext

const App = () => {
    const { mode } = React.useContext(DisplayContext);

    return (
        <div style={{background : mode === 'light' ? 'white' : 'black'}}>
            Hello
        </div>
    )
}

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

return (
    <DisplayContext.Consumer>
        {value => (
            <div style={{background : value.mode === 'light' ? 'white' : 'black'}}>
                Hello
            </div>
        )}
    </DisplayContext.Consumer>
)

기존에는 Consumer를 이용해 값을 꺼내왔는데,
이는 주로 클래스형 컴포넌트에서 사용하던 render props 방식으로써
함수형 컴포넌트에서는 훅을 이용하는 것이 훨씬 간결하고 사용하기에 편리합니다.

부모에게서 props를 내려받지 않고 그 과정을 건너 뛰어 값을 공유한다는 것을 보아하니
전역으로 상태를 관리한다는 말과도 비슷하게 들립니다.

하지만 이러한 말이 무색하게도 Context는 상태 관리 툴이 아닙니다.

 

📌 Context Api는 왜 상태 관리 툴이 아닌가?

🔎 많은 Context 값을 사용한다면?

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

우선 context를 일반적인 상태 관리도구로써 사용한다면
위와 같이 불어나는 Provider들은 계속해서 중첩되어 쌓여갈 것이고,
특정 컴포넌트에 사용하는 값을 위해 Provider를 여기저기 흩뿌려 놓으면 변경이 일어났을 때
그것을 대응하는 난이도는 늘어나는 context 만큼 어려워질 겁니다.

 

그렇기에 Context는 Redux나 Recoil 같은 상태 관리 라이브러리의 사용을 배제하지 않고,
실제로도 전역 상태를 관리하기 위해서는 여타 라이브러리를 사용하는 것을 권장합니다.

 

🔎 상태 관리 도구의 조건

상태를 관리하는 도구가 되려면 다음과 같은 조건이 필요합니다.

  • 값 저장
  • 값 읽기
  • 값 변경

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

 

그저 Provider에 넣어준 value를 하위 Children 아무곳에서나
useContext를 사용해 꺼내쓰기만 하는 겁니다.

 

즉, Context는 아무것도 관리하지 않고 중간다리의 역할만 한다는 것을 의미합니다.

 

그렇기에 특정 라이브러리나 기능을 인터페이스로 추상화한 후
인터페이스를 확장한 객체의 인스턴스를 Context에 주입하고,
이를 사용할 때는 Context를 사용하며 그 기능을 직접적으로 사용하는 것이 아닌
추상화한 인터페이스를 가르키게 하여 쉽게 교체 가능하게함으로써
의존성 주입의 개념을 가졌다고 할 수 있습니다.

 

⚡️ Context와 의존성 주입

Context를 계층 중간에 배치한다면 계층간의 결합을 끊어낼 수 있습니다.

예제로 확인해 보겠습니다.

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

기존에는 서버와의 통신을 할 때 특정 라이브러리를 직접 호출하는 방식으로 사용하곤 했습니다.

 

이런 경우에는 서버에서 데이터를 받아오는 모든 코드에는 axios를 직접적으로 호출할 것이고,
만약 axios에 문제가 있었다거나, 혹은 다른 기술의 메리트가 커보여 라이브러리를 교체하려고 한다면
기존에 작성했던 모든 코드를 수정해야 하는 문제가 발생합니다.

 

이러한 문제를 해결하기 위해 특정 기능 자체를 context에 주입하여 사용해보겠습니다.

 

🔎 타입 정의

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, patch, update, delete가 사용됩니다.

 

편의를 위해 get과 post의 기능만 추상화해 타입으로 정의했습니다.

 

🔎 객체 생성

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;
  }
}
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 정의

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

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

// useContext를 custom hook 형태로 한번 더 wrapping 해서
// 컴포넌트에선 이 훅만 호출하여 사용할 수 있다.
export const useFetchContext = () => React.useContext(FetchContext);
// Provider의 value 교체로 기술 자체를 변경
return (
  <FetchContext.Provider value={new Fetch()}>
    {children}
  </FetchContext.Provider>
)

이제 context를 사용할 수 있게 준비해줍니다.

 

여기서 중요한 부분은 Provider의 value에 위에서 생성한 객체의 인스턴스만 넣어준다면
다른 부분의 수정 없이 기술의 교체가 이뤄진다는 부분입니다.

 

🔎 Context 호출

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만 제공하기만 하면 된다는 장점 또한 존재합니다.

 

전체 예제 코드는 아래에서 확인해주세요.

 

 

⚡️ 마치며

생각보다 이해하기 쉽지 않은 내용이지만 모달 같은 기능이나 Data Fetching 같은 기술들을
context를 통해 사용한다면 추후 기반 기술을 변경할 일이 있어도 재사용 하기 쉬워지겠다고 생각했습니다.

 

아직까진 프로젝트에 적용하기엔 이해도가 부족하다고 느끼지만 언젠간 꼭 적용해보려 합니다.

반응형

댓글


오픈 채팅