View

반응형

리액트 Conext API에 대해 배웠던 내용을 기록하고자 하는 포스트입니다. 해당 포스트는 다음과 같은 목차로 진행됩니다.

 

목차

  • 들어가기 전
  • Context API는 상태관리 도구가 아니다?
  • 의존성 주입이란?
  • Context API로 의존성 주입하기
  • 의존성은 교체 가능해야 한다.
  • Context API로 암시적 종속성을 명시적으로 만들자
  • 마무리
  • 레퍼런스

들어가기 전

해당 포스트는 Context API의 기초적인 부분을 다루지 않기 때문에 Context API에 대해 기초적인 지식이 필요합니다. 추가적으로 리액트 쿼리에 대한 내용이 있으므로 리액트 쿼리에 대한 배경 지식도 있으면 더욱 좋습니다.

 

아직 Context API를 모르신다면 아래의 링크를 통해 리액트 공식문서 보며 Context API를 배워보세요🙂

Context API는 상태관리 도구가 아니다?

처음 리액트를 학습하며 Context API를 배울 때 많은 분들이 전역 상태 관리 도구로만 알고 계시는 분들이 상당합니다. Context API 관련 검색만 해봐도 대부분의 블로그 포스트들은 Context API를 전역 상태 관리 도구라고 합니다. 사실 저 또한 그랬습니다. 하지만 Context API에 대해 깊게 파헤쳐보면 전역 상태 관리 도구로 사용하기에는 적절하지 못하다는 것을 알 수 있습니다.

 

그렇다면 왜 Context API는 전역 상태 관리 도구가 아닐까요? 우선 Context API의 가장 큰 문제점은 context로 관리하고 있는 상태가 변경되었을 때 context를 의존하고 있는 모든 컴포넌트들을 리렌더링입니다. 그렇기 때문에 자주 변경되는 상태 값을 context로 관리한다면 애플리케이션의 성능에 지대한 영향을 미칩니다. 따라서 다크모드, 국가 언어, 로그인 유지, 라우팅 등 큰 변화가 없는 경우 Context API를 사용하기에 적절합니다.

 

또한 리액트 공식 문서에서는 Context API는 상태를 멀리 떨어져 있는 자식 컴포넌트에게 전달해주기 위해 사용하라고 합니다.

 

리액트 공식문서 Passing Data Deeply with Context - Use cases for context

• Managing state: As your app grows, you might end up with a lot of state closer to the top of your app. Many distant components below may want to change it. It is common to use a reducer together with context to manage complex state and pass it down to distant components without too much hassle.

 

그리고 상태 관리는 시간이 지남에 따라 상태가 변경되는 방식을 의미합니다. 다음과 같은 경우를 상태 관리라고 합니다.

  • 초기 값을 저장한다.
  • 현재 값을 읽을 수 있다.
  • 값 업데이트가 가능하다.

React의 useState와 useReducer가 이러한 예입니다.

  • Hook을 호출해서 초기 값을 저장한다.
  • Hook을 호출해서 현재 값을 읽는다.
  • 제공된 setState 또는 dispatch 함수를 호출해서 값을 업데이트한다.
  • 구성 요소가 Re-Render 되었기 때문에 값이 업데이트 됐음을 알 수 있다.

반면에 Context는 Props Drilling을 방지하는 차원에서 멀리 떨어져 있는 자식 컴포넌트에게 상태를 전달하기 위해 사용되기 때문에 상태를 관리한다기 보다는 데이터를 공유하는 역할에 더 가깝다고 할 수 있습니다. 즉, Context는 Context Tree 내부에 포함된 다른 컴포너트들과 공유되는 방식이죠.

 

그렇다면 Context API가 전역 상태 관리 도구가 아니라면 어떤 경우에 사용해야 될까요? 우선 앞서 말했던 테마(다크모드), 로그인 유지, 라우팅, 국가 언어 등 상태 변화가 거의 없는 경우 전역으로 사용 가능합니다.

 

그리고 주식, 비트코인의 데이터를 받아와 차트 형태로 화면에 뿌려주는 컴포넌트가 있다고 가정하겠습니다. 데이터를 받아온 후 데이터가 변경됨에 따라 해당 컴포넌트를 의존하고 있는 자식 컴포넌트들이 리렌더링이 되어야하는 경우가 있을 때 Context를 해당 컴포넌트를 감싸줘서 부분 전역(?)의 느낌으로 사용할 수 있습니다.

 

이처럼 Context를 사용해야 하는 범위를 최대한 좁혀서 사용한다면 안전하게 사용할 수 있습니다.

 

마지막으로 의존성을 주입할 때 Context API를 많이 사용합니다.

의존성 주입이란?

의존성 주입이라는 단어가 함수형 프로그래밍을 지향하는 리액트와는 전혀 연관이 없을 줄 알았습니다. 주로 객체 지향 프로그래밍(OOP)에서 많이 사용되는 용어라고 생각했거든요.

 

의존성 주입은 말 그대로 의존성을 주입하는 것을 말합니다. 조금 더 구체적으로 말하면 클래스 A, B, C가 있습니다. A가 B에 의존 중일 때, B의 변경은 A에 영향을 미칩니다. 더군다나 B가 C에 의존 중이라면 B가 변경되었을 때 A와 B 모두 변경해야 될 가능성이 있습니다.

이처럼 서로 상호 간의 의존 관계에 놓여 있으면 결합도가 증가되며 재사용성이 떨어지게 됩니다. 그래서 의존성 주입을 통해 모듈을 분리하고 재사용성을 높일 수 있습니다.

 

그래서 어떻게 모듈을 분리하고 재사용성을 높일 수 있는 걸까요? 의존성 주입은 필요한 객체를 직접 생성하지 않고 외부에서 넣어주는 방식입니다. 즉, 객체의 의존 관계를 내부에서 결정하는 것이 아니라 객체 외부에서, 런타임 시점에서 결정하는 방식입니다.

 

예를 들어 데이터를 페칭하는 상황을 가정해봅시다. 개발자의 개발 환경이 브라우저일수도 있고, 노드 환경일수도 있습니다. 또한 개발자의 성향에 따라 fetch를 사용할 수도 있고, axios를 사용할 수도 있죠.

 

이러한 상황일 때 의존성을 주입은 정말 유용하게 사용할 수 있습니다.

export const createHttp = (fetch = window.fetch) => {
  return (url, config) => fetch(url, config);
};

const axiosClient = createHttp(axios);
axiosClient('/todo', {
  method: 'get',
  headers: {},
  data: {},
});

위의 코드 스니펫을 보면 createHttp 함수는 fetch라는 매개변수를 가지고 있으며 이 매개변수의 기본 값은 window.fetch입니다. 그리고 url와 config 매개변수를 가지고 fetch를 수행하는 함수를 반환하는 클로저 형태의 함수입니다.

 

createHttp 함수에 axios 인스턴스를 전달해주면 axios를 사용해 데이터를 페칭할 수 있습니다. 노드 환경일 때는 노드 fetch 라이브러리의 인스턴스를 전달해주면 되겠죠?

 

이렇듯 createHttp 함수는 상황에 따라 외부에서 인스턴스를 주입받아 사용하므로써 axios로 데이터 페칭을 했다가, 노드 환경에서 데이터를 페치를 할 수 있는 재사용성이 뛰어난 함수가 되었습니다.

 

예제를 살펴보았듯 의존성 주입을 활용하면 결합도를 낮추고 재사용성을 높일 수 있습니다.

Context API로 의존성 주입하기

그렇다면 Context API로 의존성을 어떻게 주입할까요? Context API로 의존성을 주입하는 경우 대부분 다음과 같은 코드 패턴으로 이루어집니다.

// Contexts/DependencyProvider.jsx
import React, { useContext } from 'react';

const DependencyContext = React.createContext();

export function useDependency() {
  return useContext(DependencyContext);
}

export default function DependencyProvider({ myService, children }: Props) {
  return (
    <DependencyContext.Provider value={{ myService }}>
      {children}
    </DependencyContext.Provider>
  );
}

위와 같이 Provider를 만들어주고, context를 사용할 최상위 컴포넌트에 Provider를 감싸주겠습니다.

// App.jsx
import { useState } from 'react';
import MyComponent from './Components/MyComponent';
import DependencyProvider from './Contexts/DependencyProvider';
import './App.css';

function App() {
  const myService = {
    callMe: () => {
      alert('hello!');
    },
  };

  return (
    <DependencyProvider myService={myService}>
      <MyComponent />
    </DependencyProvider>
  );
}

export default App;

Provider를 감싸주면서 <DependencyProvider /> 컴포넌트에 myService라는 속성으로 반환문 위에 선언한 myService 객체를 넣어주었습니다. 만약 myService 객체 말고 다른 객체를 넣어주고 싶을 때 속성의 값을 교체해주면 되겠죠? 이렇듯 의존성 주입을 사용하면 <DependencyProvider /> 컴포넌트의 내부 코드를 전혀 수정하지 않고도, 외부 객체에 따라 사용할 수 있는 속성 및 메서드가 달라지게 되죠.

 

그리고 context를 사용할 컴포넌트에서 불러줍니다.

// Components/MyComponent.jsx
import { useDependency } from '../Contexts/DependencyProvider';

export default function MyComponent() {
  const { myService } = useDependency();
  myService.callMe();

  return <div>MyComponent</div>;
}

리액트를 많이 사용해보신 분들이시라면 위의 예제 코드들을 보면서 무언가 익숙한 느낌이 들지 않으신가요? 리액트 쿼리를 사용해본적이 있으신 분들이라면 분명 익숙한 느낌을 받으셨을 겁니다.

// react-query 예시
import React from 'react';
import './App.css';
import MainProducts from './components/MainProducts';

import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactQueryDevtools } from 'react-query/devtools';

// Create a client
const queryClient = new QueryClient();

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MainProducts />
      <ReactQueryDevtools initialIsOpen={true} />
    </QueryClientProvider>
  );
}

리액트 쿼리 코드 예제를 살펴보면 queryClient를 <QueryClinetProvider /> 컴포넌트의 client 속성으로 주입해서 내려주고 있죠?

실제로 리액트 쿼리 뿐만 아니라 상태 관리 라이브러리인 Redux, Recoil, Mobx 등과 같은 유명한 라이브러리들에서도 Context API를 의존성 주입 용도로 많이 사용하고 있습니다.

의존성은 교체 가능해야 한다.

개발 단계에서 실제 데이터 요청과 Mock 데이터 요청을 따로 만들어 두면 네트워크 비용을 줄일 수 있습니다. 유튜브 API를 통해 데이터를 받아오는 상황을 가정하겠습니다. 다음과 같이 실제 데이터를 Postman 등과 같은 툴로 요청한 후 받아온 데이터를 리액트의 최상위 경로(’/’) public폴더에 ‘/data/videos’ 디렉토리를 생성한 후 ‘related.json’, ‘serach.json’, ‘popular.json’, ‘channel.json’ 파일을 생성 해 저장해두겠습니다.

 

그리고 이렇게 미리 저장한 파일들의 데이터를 가져올 Mock 데이터 요청을 하는 class를 아래와 같이 정의하겠습니다.

import axios from 'axios';

export default class FakeYoutubeClient {
  async search({ params }) {
    return params.relatedToVideoId
      ? axios.get('/data/videos/related.json')
      : axios.get('/data/videos/search.json');
  }
  async videos() {
    return axios.get('/data/videos/popular.json');
  }
  async channels() {
    return axios.get('/data/videos/channel.json');
  }
}

그리고 실제 데이터를 요청하는 클래스도 정의합니다.

import axios from 'axios';

export default class YoutubeClient {
  constructor() {
    this.httpClient = axios.create({
      baseURL: '<https://www.googleapis.com/youtube/v3>',
      params: { key: process.env.REACT_APP_YOUTUBE_API_KEY },
    });
  }

  async search(params) {
    return this.httpClient.get('search', params);
  }

  async videos(params) {
    return this.httpClient.get('videos', params);
  }

  async channels(params) {
    return this.httpClient.get('channels', params);
  }
}

그리고 실제 구현체가 있는 클래스를 정의합니다.

export default class YoutubeAPI {
  constructor(apiClient) {
    // DI 의존성 주입
    this.apiClient = apiClient;
  }

  async search(keyword) {
    return keyword ? this.#searchByKeyword(keyword) : this.#mostPopular();
  }

  async channelImageURL(id) {
    return this.apiClient
      .channels({ parmas: { part: 'snippet', id } })
      .then((res) => res.data.items[0].snippet.thumbnails.default.url);
  }

  async relatedVideos(id) {
    return this.apiClient
      .search({
        params: {
          part: 'snippet',
          maxResults: 25,
          type: 'video',
          relatedToVideoId: id,
        },
      })
      .then((res) =>
        res.data.items.map((item) => ({ ...item, id: item.id.videoId }))
      );
  }

  async #searchByKeyword(keyword) {
    return this.apiClient
      .search({
        params: {
          part: 'snippet',
          maxResults: 25,
          type: 'video',
          q: keyword,
        },
      })
      .then((res) =>
        res.data.items.map((item) => ({ ...item, id: item.id.videoId }))
      );
  }

  async #mostPopular() {
    return this.apiClient
      .videos({
        params: {
          part: 'snippet',
          maxResults: 25,
          chart: 'mostPopular',
        },
      })
      .then((res) => res.data.items);
  }
}

그리고 의존성을 주입해 줄 Context를 생성합니다.

import { useContext } from 'react';
import { createContext } from 'react';
import FakeyoutubeClient from '../api/fakeYoutubeClient';
import YoutubeAPI from '../api/youtube';
import YoutubeClient from '../api/youtubeClient';

export const YoutubeApiContext = createContext();

const faker = new FakeyoutubeClient();
// const client = new YoutubeClient();
const youtube = new YoutubeAPI(faker);

export function YoutubeApiProvider({ children }) {
  return (
    <YoutubeApiContext.Provider value={{ youtube }}>
      {children}
    </YoutubeApiContext.Provider>
  );
}

export function useYoutubeApi() {
  return useContext(YoutubeApiContext);
}

위의 코드를 보면 faker, 주석 처리된 client 인스턴스가 있습니다. 그리고 구현체의 인스턴스인 youtube가 있습니다. 구현체 YoutubeAPI 클래스 코드 예제를 보면 생성자를 통해 의존성을 주입해주고 있습니다.

 

위의 코드처럼 YoutubeAPI의 인스턴스를 만들 때 생성자로 faker로 주입해 주면 Mock 데이터를 사용해 개발할 수 있습니다. 이렇게 Mock 데이터를 사용하면 실제 유튜브 API를 사용하지 않기 때문에 비용이 발생하지 않고 마음껏 개발할 수 있겠죠?

 

그리고나서 실제 배포하게 되면 faker 인스턴스 대신 client 인스터스를 주입해주면 됩니다. 이처럼 의존성 주입은 교체가 가능해야 합니다.

Context API로 암시적 종속성을 명시적으로 만들자

암시적 종속성이란? 애플리케이션 구조에 대한 개발자의 지식과 머릿속에만 존재할 뿐 코드 자체에는 보이지 않는 종속성을 의미한다고 합니다.

 

무슨 말인지 이해가 안되시나요? 리액트 쿼리 예제로 살펴보면서 설명하겠습니다.

 

리액트 쿼리의 최고의 특징 중 하나는 컴포넌트 트리에서 원하는 곳 어디서나 쿼리를 사용할 수 있다는 것입니다. 리액트 쿼리를 사용하면 아래의 코드와 같이 컴포넌트에서 필요한 데이터를 서버로부터 fetch 할 수 있습니다.

function ProductTable() {
  const { data, isError } = useProductQuery();

  if (data) {
    return <table>...</table>;
  }

  if (isError) {
    return <ErrorMessage error={productQuery.error} />;
  }

  return <SkeletonLoader />;
}

위의 코드처럼 컴폰너트 내부에서 데이터를 fetch 한다는 것은 매우 유용합니다. 애플리케이션 어디로든 컴포넌트를 옮길 수 있고 동작하기 때문에 독립적으로 컴포넌트 구성이 가능하죠. 또한 이런 독립적인 컴포넌트는 변화에 매우 유연합니다. 이런 점 때문에 최근 리액트 쿼리가 각광을 받는 이유 중 하나죠.

 

리액트 쿼리는 정말 좋은 도구이지만 결국 사용하는 것은 개발자입니다. 개발자는 사람이죠. 사람은 언제나 실수합니다.

 

다음 코드를 보겠습니다.

export const useUserQuery = (id: number) => {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUserById(id),
  });
};
export const useCurrentUserQuery = () => {
  const id = useCurrentUserId();

  return useUserQuery(id);
};

위의 코드는 로그인한 사용자가 어떤 권한을 가졌는지 확인하고 실제로 페이지를 볼 수 있는지 결정하기 위한 컴포넌트로 페이지의 모든 곳에서 필요한 필수적인 정보입니다.

 

만약 이 쿼리를 사용해 받아온 데이터에서 userName을 보여주길 원하는 컴포넌트가 있을 때 useCurrentUserQuery 훅으로 가져올 수 있다고 가정합시다.

function UserNameDisplay() {
  const { data } = useCurrentUserQuery();
  return <div>User: {data.userName}</div>;
}

이와 같은 코드에서 data가 undefined가 될 가능성이 있으므로 타입스크립트는 이걸 허용하지 않습니다.

 

그럼 여기서 타입스크립트의 불평을 없앨 방법이 무엇이 있을까요? data!.userName을 사용할 수 있고, data?.userName을 사용할 수도 있고, 그냥 타입가드로 if(!data) return null을 추가해줄 수도 있습니다. 아니면 useCurrentUserQuery()를 호출하는 모든 컴포넌트에서 적절한 로딩과 에러를 추가해주는 방법도 있습니다.

 

만약 이 data가 타입스크립트가 불평하는 것과 달리 개발자가 생각했을 때 undefined가 절대 될 수가 없다고 할 경우 개발자는 “절대 일어날 수 없는” 상황에 대해 타입 가드와 같은 처리가 불필요한 코드를 작성한다고 느낄 수 있으며 코드가 길어질 수 있습니다.

 

하지만 타입스크립트는 거의 항상 옳기 때문에 무시하기 쉽지 않습니다. 이러한 문제는 “암시적 종속성” 때문에 발생합니다. 처음에 말했듯이 암시적 종속성이란 개발자의 지식과 머릿속에만 존재하는 종속성입니다.

 

데이터가 정의되었는지 확인하지 않아도 안전하게 useCurrentUserQuery를 호출할 수 있다는 것을 알고 있지만, 정적 분석으로는 이것을 확인할 수 없습니다. 동료들 또한 이 사실을 모를 수 있고 코드를 작성한 개발자도 3개월 후에는 이 사실을 모를 수 있습니다.

 

앞에서 말했죠? 사람은 언제나 실수하고 인간은 망각의 동물입니다. “지금 당장은 이러한 사실을 기억하고 올바르게 코드를 작성했다고 생각할 수 있지만 미래에는 더 이상 아닐 수 있습니다.”라는 것입니다. 캐시에 사용자 데이터가 없을 수도 있고, 이전에 다른 페이지를 방문한 경우와 같이 조건부로 있을 수도 있습니다. 이처럼 예기치 못한 상황이 미래에 발생할 수 있습니다.

 

이 때 우리는 Context API를 사용해 의존성을 명시적으로 만들어 해결할 수 있습니다.

 

앞서 useCurrentUserQuery 훅을 이용해 사용자의 데이터를 가져왔습니다. 그리고 이 데이터를 통해 사용자 인증을 위한 데이터의 사용 가능성을 확인하는 검사를 수행해야 합니다. 근데 만약 이 훅을 사용하는 컴포넌트가 20개 정도 있다면, 이 모든 20개 컴포넌트에서 사용 가능성을 확인해야 합니다.

 

이러한 검사를 Context API를 사용해 아래의 코드와 같이 context에서 사용 가능성을 확인하는 코드를 작성한다면 20개의 모든 컴포넌트에서 확인할 필요 없이 한 번만 작성하면 됩니다.

const CurrentUserContext = React.CreateContext<User | null>(null);

export const useCurrentUserContext = () => {
  return React.useContext(CurrentUserContext);
};

export const CurrentUserContextProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const currentUserQuery = useCurrentUserQuery();

  if (currentUserQuery.isLoading) {
    return <SkeletonLoader />;
  }

  if (currentUserQuery.isError) {
    return <ErrorMessage error={currentUserQuery.error} />;
  }

  return (
    <CurrentUserContext.Provider value={currentUserQuery.data}>
      {children}
    </CurrentUserContext.Provider>
  );
};

이렇게 Context를 만듦으로서 UserNameDisplay 컴포넌트 같은 자식 컴포넌트에서 동료 혹은 다른 누군가가 UserNameDisplay를 볼 때마다 CurrentUserContextProvider에서 제공된 데이터가 필요하다는 것을 명시적으로 알려줄 수 있습니다.

function UserNameDisplay() {
  const data = useCurrentUserContext();
  return <div>User: {data.username}</div>;
}

이렇게 명시적으로 알려줌으로서 암시적 종속성의 문제를 해결할 수 있습니다. Provider가 렌더링되는 위치를 변경하면 해당 context를 사용하는 모든 자식에도 영향을 미치며 어떤 리액트 쿼리의 훅이 어떤 컴포넌트에서 호출되는지 범위도 알 수 있으므로 리팩터링할 떄 유용하게 사용할 수 있습니다.

 

이 외 Context API를 활용한 예시

마무리

Context API의 심화적인 내용을 다뤄봤습니다. 글을 작성하는 현 시점에서 아직 취업도 하지 못한 취업 준비생의 입장에서 최대한 학습하고 배우 내용을 되짚고자 작성하였기 때문에 틀린 내용도 분명 있을 겁니다.

 

만약 글을 읽으시다가 틀린 정보가 있다면 언제든지 댓글로 피드백을 주세요. 또한 이해가 되지 않는 부분이 있거나, 설명이 모호하다던지 추가적인 설명이 더 필요한 부분이 있으면 언제든지 댓글로 남겨주시면 해당 부분에 대해서 학습해 알려드리겠습니다😊

 

이 포스트는 아래의 레퍼런스들을 참고해 작성하였습니다.

레퍼런스

https://react.dev/learn/passing-data-deeply-with-context#use-cases-for-context

https://velog.io/@jay/react-dependency-injection#왜-이게-중요한데요

https://blog.testdouble.com/posts/2021-03-19-react-context-for-dependency-injection-not-state/

https://blog.logrocket.com/dependency-injection-react/

https://itchallenger.tistory.com/370

https://tkdodo.eu/blog/react-query-and-react-context

반응형

'Language > ReactJS' 카테고리의 다른 글

[React] 상태 관리 라이브러리의 필요성 - Redux  (1) 2023.04.24
[React] Context API - useContext  (2) 2023.04.17
[React] 컴포넌트  (0) 2023.04.05
[React] JSX 이해하기  (0) 2023.04.03
[React] 안녕 CRA, 안녕 Vite  (0) 2023.03.28
Share Link
reply
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30