[꼭꼭] Context API 잘 사용해보기

NinjaJuunzzi·2022년 9월 26일
5

우아한테크코스

목록 보기
19/21
post-thumbnail
post-custom-banner

우아한테크코스 내부의 쿠폰 문화 (커피챗과 유사한)를 온라인으로 가져가고 있는 꼭꼭 프로젝트는 코치와 크루, 크루와 크루 간의 대면 만남을 유도하고자 쉬운 쿠폰의 관리, 사용을 목적으로 프로덕트를 개발하고 있습니다.

안녕하세요. 우아한테크코스에서 프론트엔드 개발자로 꼭꼭 프로젝트에 참여중인 준찌라고 합니다. 저희는 이번에 특수한 목적과 방법으로 Loading과 Toast 컴포넌트에Context API를 활용해보았습니다. 포스팅은 이 목적과 방법을 정리하고 있으니 재미있게 봐주세요!

(👻 Context API를 사용하는 기본적인 룰에 대해서는 언급하지 않습니다. 참고해주세요! 👻)

[why & background knowledge]

저희는 이번에 React의 Context API를 조금은 색다른 목적으로 사용해보았습니다. 그렇다면 Context API 사용의 기본 목적은 무엇일까요 ?

React가 말하길 Context API 란

일반적인 React 애플리케이션에서 데이터는 위에서 아래로 (즉, 부모로부터 자식에게) props를 통해 전달되지만, 애플리케이션 안의 여러 컴포넌트들에 전해줘야 하는 props의 경우 (예를 들면 선호 로케일, UI 테마) 이 과정이 번거로울 수 있습니다. context를 이용하면, 트리 단계마다 명시적으로 props를 넘겨주지 않아도 많은 컴포넌트가 이러한 값을 공유하도록 할 수 있습니다.

In a typical React application, data is passed top-down (parent to child) via props, but such usage can be cumbersome for certain types of props (e.g. locale preference, UI theme) that are required by many components within an application. Context provides a way to share values like these between components without having to explicitly pass a prop through every level of the tree.

이 말을 이해한대로 풀어보자면, 상태의 공유는 상위 컴포넌트에서 하위 컴포넌트로 상태를 전달함으로써 이뤄낼 수 있다. 하지만 여러 컴포넌트가 공유해야하는 상태의 경우 이를 일일히 상위에서 전달해주기에는 불편함이 너무 크다. (props를 모든 컴포넌트에 뚫어주어야하고, depth가 깊은 컴포넌트에 상태를 전달하고자 한다면 drilling이 생겨 유지보수에 힘든 코드가 될 수 있다.) 이러한 불편함을 상쇄할 수 있도록 상태를 여러 컴포넌트가 공유할 수 있게 해주는 것이 React.ContextAPI인 것이다.

Context API는 위와 같습니다. 그럼 저희는 우선 왜 Context API를 사용하고자 했을까요?

왜 Context API가 필요했나요?

전역으로 동작해야하는 Loading 컴포넌트를 예시로 설명드리겠습니다. 내용은 다음과 같습니다.

// Loading.tsx
// ...

function Loading() {
  return ReactDOM.createPortal(
    <Dimmed>
      <Styled.Root>
        <img src={logoImage} alt='로고' width={36} height={36} />
      </Styled.Root>
    </Dimmed>,
    document.querySelector('#root') as Element
  );
}

export default Loading;

이 로딩 컴포넌트는 화면 전체를 dimmed 처리 하고 로고 이미지를 보여주는 역할을 합니다. 사용자에게 로딩 중이라는 경험을 주는 UI라는 것이죠. 결국 아래 이미지와 같습니다. 페이지 UI의 전체 영역을 감싸기에 전역 UI라고도 부를 수 있을 것 같아요.

그럼 이 UI를 페이지 컴포넌트 단에서 보여주고자 한다면 어떻게 코드가 작성되어야 할까요? 이는 다음과 같습니다.

// ExampleComponent.js
// ...
const ExampleComponent = () => {
  const [isLoading,setIsLoading] = useState(false);
  
  const showLoading = () => {/*isLoading의 상태값을 조작*/}
  const hideLoading = () => {/*isLoading의 상태값을 조작*/}

  // Loading 컴포넌트는 페이지 전체를 Dimmed 처리한 후 로딩 이모지를 보여준다.
  return <div>{isLoading && <Loading />}</div>  
}
// ...

모든 컴포넌트 마다 isLoading 을 표현하는 상태가 필요하고, Loading 컴포넌트를 분기 처리하여 렌더링하는 로직이 작성되어야 합니다. 이러한 상황에서 발생할 수 있는 문제는 다음과 같습니다.

  • Loading이 중복으로 보여질 수 있음.
  • ui 로직이 중복으로 작성됨 (커스텀 훅으로 개선가능 하지만..)
  • Loading을 최상위에서 작성해둔 경우 이를 트리거하는 함수를 전달하는 과정에서 props drilling이 발생할 수 있다.

페이지 전체에서 보여지는 Loading의 경우 중복으로 보여질 필요가 없는 녀석이고 페이지 전체에서 하나만 사용되면 되기에 페이지 별로 Loading을 선언하는 구조는 그리 달갑지 않습니다. 그렇다면 최상위에서 Loading을 렌더링하고 상태로직을 내려주면 되지 않냐고 물어보실 수 있지만, 이 경우 실제 트리거를 하는 기능이 최하위 컴포넌트에서 작성되어야 하는 경우 props drilling이 발생할 수 있습니다.

결국 저희는 이러한 여러가지 문제들을 해결할 수 있는 방법으로 Context API의 사용을 고려하게 된 것입니다!!

[how]

React에 의하면 여러 컴포넌트 간의 상태 공유를 목적으로 Context API를 활용해볼 수 있습니다. 하지만 저희는 조금은 다른 관점으로 Context API를 사용해보았습니다. 우리 팀의 당초 목적은 모든 페이지에서 활동하는 Loading 컴포넌트를 최상위 한 곳에서만 선언해두고 이를 렌더링 하는 것이었습니다. 하지만 앞서 말씀드린 바와 같이 이 구조라면 상태를 조작하는 함수를 컴포넌트 단으로 내려주어야하기에 인자를 전달만 하는 컴포넌트가 생기게 되고, 이는 유지보수에 치명적이게 됩니다.

그렇기에 저희는 이 상태를 조작하는 함수를 전역에서 이용할 수 있게 하고자 Context API를 사용해보았습니다. 코드는 다음과 같습니다.

첫 번째 친구, LoadingProvider

// LoadingProvider.ts

import { createContext, useState } from 'react';

import Loading from '@/@components/@shared/Loading';

export const LoadingContext = createContext({
  showLoading: () => {},
  hideLoading: () => {},
});

const LoadingProvider = (props: React.PropsWithChildren) => {
  const { children } = props;

  const [isLoading, setIsLoading] = useState(false);

  const showLoading = () => {
    setIsLoading(true);
  };

  const hideLoading = () => {
    setIsLoading(false);
  };

  return (
    <LoadingContext.Provider value={{ showLoading, hideLoading }}>
      {children}
      {isLoading && <Loading />}
    </LoadingContext.Provider>
  );
};

export default LoadingProvider;
  • 역할 1: 외부에서 모듈로서 불러올 수 있는 Context 객체를 선언한다.
  • 역할 2: ContextProvider를 렌더링 함으로써 하위 컴포넌트(children 에서 훅을 통해 조회할 수 있도록 한다)에게 isLoading 상태를 조작하는 로직을 공유한다.
  • 역할 3: Loading 컴포넌트를 렌더링한다.

이 컴포넌트의 역할에서 알 수 있듯이 상태 공유가 아닌 로직을 공유 시키고자 Context API를 활용하였습니다. 상태는 이 컴포넌트 스코프에 갇혀 하위 컴포넌트에게 클로징 되고, 하위 컴포넌트는 상태를 조작하는 함수만을 받게됩니다. 이러면 기대할 수 있는 장점은 다음과 같습니다.

  • 상태를 참조할 수 없기에 이로써 파생되는 다른 기능들은 구현되지 않을 것입니다. (예를 들어, isLoading 값에 따라 어떤 UI를 보여주게 한다던지, 또는 어떤 비즈니스 로직을 수행한다던지 하는 다른 기능들이 구현될 수 없습니다. isLoading은 외부에서 참조할 수 없는 값이 됩니다.)

  • 하위 컴포넌트는 선언적으로 Loading 컴포넌트를 다룰 수 있습니다. 정확히 Loading을 킨다는 기능에만 집중하여 어떤 컴포넌트를 어떻게 렌더링 시키고, 어떤 상태들을 선언해 이 컴포넌트를 제어할 지 몰라도 됩니다. (조작하는 함수만을 알고 Loading 컴포넌트를 다룰 수 있게 됩니다.)

두 번째 친구, Custom Hook

// Context에서 로직 함수를 꺼내오는 역할을 수행하는 훅입니다.

export const useLoading = () => {
  const { showLoading, hideLoading } = useContext(LoadingContext);

  return {
    showLoading,
    hideLoading,
  };
};
// 다음은 프로펄을 변경하는 액션을 수행하는 비즈니스 로직입니다.

export const useEditMeMutation = () => {
  const queryClient = useQueryClient();

  // `Loading`이라는 경험을 충족시키기 위해서만 동작합니다. 어떻게 동작 시키게 되는지는 관심이 없습니다.
  const { showLoading, hideLoading } = useLoading();

  return useMutation(editMe, {
    onSuccess() {
      queryClient.invalidateQueries(QUERY_KEY.me);
    },
    onMutate() {
      showLoading();
    },
    onSettled() {
      hideLoading();
    },
  });
};

저희는 컴포넌트가 수행할 기능들을 Custom hook으로 분리하여 개발을 진행하고 있습니다. 이에 대한 내용은 다음 포스팅에서 정리할 예정입니다. 각설하고 이 커스텀 훅이 가진 기능은 컴포넌트에 의해서 호출될 것이므로 컴포넌트의 기능이라 보셔도 무방합니다.

  • 역할 1: 상태를 조작하는 함수를 받아내 실제로 Loading 컴포넌트의 렌더링을 제어합니다.

이러한 역할을 수행하지만 사실 이 컴포넌트(위 비즈니스 로직 훅을 받아내는)는 Loading 이라는 경험을 충족시키기 위해서만 동작합니다. 즉 선언적으로 동작합니다. 어떻게 Loading 컴포넌트를 렌더링하는지는 몰라도 상관없습니다. 로직을 Context API로 제공함으로써 좀 더 선언적으로 렌더링 로직을 제어할 수 있게 됩니다.

정리하자면

저희는 상태 공유가 아닌 로직을 공유하기 위해 Context API를 사용하였습니다. 그러다 보니 어떤 동작을 선언적으로 관리할 수 있게 되었고 뿐만 아니라 prop만을 전달하는 컴포넌트의 존재 자체도 사라져 유지보수가 비교적 수월한 컴포넌트를 작성할 수 있었습니다.

하지만 다음과 같은 단점도 존재합니다. Loading 컴포넌트를 변경하지 못합니다. (개선이 가능한 영역이긴 하다. 컴포넌트 합성을 통해. 하지만 니즈는 아직 없다.)

[Result]

  • Context API는 상태 공유 뿐만 아닌 어떤 기능을 하는 함수를 전역화 할 수 있다. (상태는 클로징한채로)

  • 보다 선언적인 UI 작성법

  • 같은 기능을 구현하더라도 더욱 쉬운 유지보수 (showLoading hideLoading의 사용처를 제한한다면 더욱 유지보수는 편하게 됩니다)

[Commit]

profile
Frontend Ninja
post-custom-banner

0개의 댓글