React + TypeScript로 HOC 사용해보기

2023. 2. 6. 17:42React/고민거리들

React에 HOC 라는 컴포넌트가 실무에서 auth 용도로 사용된다는 걸 얼핏 봤었다.

HOC의 구조가 특이하다보니 자료를 찾아보면서도 이렇게 쓰면 되겠다 싶은 느낌이 잘 안오기도 했지만, 언제 사용하면 좋겠다 정도의 느낌만 있었다. 이번에 프로젝트를 하면서 HOC를 써보기 좋은 구조가 나와서 직접 구현해보고 공유해보려고 한다.


 

1. HOC가 뭐고 언제 유용한가요?

공식 문서에는 아래와 같이 나와있다.

 

고차 컴포넌트(HOC, Higher Order Component)는 컴포넌트 로직을 재사용하기 위한 React의 고급 기술입니다. 고차 컴포넌트(HOC)는 React API의 일부가 아니며, React의 구성적 특성에서 나오는 패턴입니다.
구체적으로, 고차 컴포넌트는 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수입니다.

 

HOC 관련 예제를 찾아보면 100에 90은 인증관련된 내용이 많다.

내 경우에는 지금 진행 중인 프로젝트인 리그오브레전드의 전적검색과 모의 밴픽 드래프트 서비스에서 좀 더 직관적으로 다가오는 HOC 관련 예시여서 찾아보시는 분들이 좀 더 이해가 편할 것으로 예상된다.

드래프트에서 의외로 비즈니스 로직상 가장 중요한게 바로 현재 패치버전이다.

 

https://www.youtube.com/watch?v=4V0uRig71TY

 

롤은 특성상 특정 패치에 신 챔피언이 출시 되기도 하고 각 챔피언의 패치별 스펙도 다르기 때문에, 패치버전이 매우 중요하다. 지금 패치버전을 UI 상으로 나타내주기도 하고, 내부 호출하는 API에서도 패치 버전을 기입해줘야 챔피언 리소스들을 불러올 수 있다.

 

결과적으로는 와이어 프레임 상에서 어느 시점에서 <Draft />를 호출해도 패치버전을 알 수 있어야 한다.

이런 경우에는 1) 패치버전을 전역상태관리한다. 2) 내부 로직에서 챔피언을 호출하기 전에 패치 버전을 한 번 더 받는다.

이렇게 두 개를 쓰는게 일반적이긴 한데...

 

전역 상태관리는 패치버전 업데이트가 한 달에 한 번 정도 일어나기 때문에 일반적으로는 상태 변경이 될 일이 없다. 그래서 이 상황에서 적용하기에는 적합하지 않다.

두 번 fetch하기 방법은 fetch해와도 문제가 없지만 모든 챔피언 정보를 받아오는 API가 패치버전에 의존하기 때문에 컴포넌트가 마운트되고 나서도 항상 두 번의 상태변화가 일어난다.

 

이럴 때 유용하게 사용할 수 있는 솔루션이 바로 HOC이다.

 

장점 1. 컴포넌트 렌더링 없이 props로 비즈니스 로직 처리 가능.

주로 많이 사용되는 인증된 사용자만 접근 가능한 컴포넌트의 경우, 컴포넌트가 렌더링 되고 나서 유저의 auth를 체크하지 않고도, props로 유저의 권한을 체크해서 렌더링 전에 바로 리다이렉트시킬 수도 있다.

우리 서비스에서도 결과적으로 props에서 바로 최신 패치버전을 받아볼 수 있다.

 

장점 2. 라우터 최상단에서 props를 내려주는 것처럼 보이는 편리한 기능.

HOC는 인수로 받아오는 컴포넌트를 수정하지도 않고 상속하지도 않는다. 단지 그냥 Wrapping하여 조합하기 대문에 사이드이펙트가 전혀없는 순수 함수이다.

이게 가장 유용할 때는 react-router-dom의 최상단에서의 사용이다.

최상단에서 비즈니스 로직을 처리한다거나 불필요하게 전역 상태 관리 할 필요 없이 HOC를 만들어서 대응하면 깔끔한 최상단을 만들 수 있다.

 

// App.tsx

import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './routes/Home';
import User from './routes/User';
import Champion from './routes/Champion';
import ChampionDetail from './routes/ChampionDetail';
import Draft from './routes/Draft';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route index element={<Home />} />
        <Route path={"/champions"} element={<Champion />} />
        <Route path={"/champion/:name/version/:version"} element={<ChampionDetail />} />
        <Route path={"/user"} element={<User />}>
          <Route path={"/user/:id"} element={<User />} />
        </Route>
        <Route path={"/draft"} element={<Draft />} />
        /*<Route path={"/draft"} element={<Draft version={"13.1.1"} />} />*/
      </Routes>
    </BrowserRouter>
  )
}

export default App;

 

 

 

2. 최신 패치 버전을 받아오는 HOC의 구현

HOC를 정말 직관적으로 풀면, 내가 호출한 컴포넌트를 children에 담아둔 컴포넌트이다.

익숙하지 않은 기법이라 처음엔 생소하지만 핵심은 의외로 단순하다.

 

// utils > withLatestVersion.tsx

import React, { useEffect, useState } from 'react';
import { DataState, LtsVersionType } from '../type/type';
import { useQuery } from '@apollo/client';
import { LOL_PATCH_VERSIONS } from '../type/api';

interface WithSelectedVersionProps {
  // @result: { data: T, error?: ApolloError, loading?: boolean };
  result: DataState<string> | null;
}

const withLatestVersion = <P extends WithSelectedVersionProps>(Component: React.ComponentType<P>) => {

  return function ComponentWithVersion(props : Omit<P, keyof WithSelectedVersionProps>) {
    // GraphQL hooks
    const { data, error, loading } = useQuery<LtsVersionType>(LOL_PATCH_VERSIONS);
    const [result, setResult] = useState<DataState<string> | null>(null);

    useEffect(() => {
      setResult({ data: (data && data.latestVersion) ? data.latestVersion[0] : '', error, loading });
    }, [data, error, loading]);
    
    // 인자로 받아둔 컴포넌트의 props에 result를 같이 내려준다.
    return <Component {...result} {...props as P} />;
  }
}

export default withLatestVersion;

 

// Draft.tsx

const Draft = ({...props}) => {
  console.log(props);
  return (
    //..마크업
  )
}

// redux의 그것과 매우 유사하다
export default withLatestVersion(Draft);

 

 

HOC와는 별개의 이야기로TypeScript를 사용하다보면 궁금한 몇 가지 사항이 있는데

1) 제네릭을 왜 써야하는지? 2) 조심해야하는 틀린 사용법

이렇게 두 가지로 정리하면 좀 더 명확하게 이해가 가능하다.

 

1. 제네릭은 왜 쓰는 건가요?

만약 패치버전 이외에 팀 정보를 사용자 이벤트를 받아와야고 한다고 하면, 따로 props를 만들어서 HOC에 전해줘야한다.

이 HOC가 draft에만 의존하지 않기 때문에 필요한 props를 그대로 내려주는 용도로 사용된다.

// Draft의 선언
<Draft blue={"T1"} red={"DK"} />


// HOC : P에 red랑 blue도 추가됨.
const withLatestVersion = <P extends WithSelectedVersionProps>(Component: React.ComponentType<P>} => {
  //..
  // Draft에 result, blue, red 전달함
  return <Component {...result} {...props as P} />
}

 

2. 인스턴스를 넘겨주지 않도록 조심하기

withLatestVersion이 컴포넌트를 return하지만, 그 컴포넌트가 인스턴스는 아니라는 걸 명심하자.

(지금보니 부끄러운 실수지만 처음에 이렇게 생각했었다. 나와 같은 실수는 하지 않기 바라는 마음에서 공개합니다.)

const withLatestVersion = <P extends WithSelectedVersionProps>(Component: React.ComponentType<P>) => {

    const ComponentWithVersion = (props : Omit<P, keyof WithSelectedVersionProps>) => {
      const { data, error, loading } = useQuery<LtsVersionType>(LOL_PATCH_VERSIONS);
      const [result, setResult] = useState<DataState<string> | null>(null);

      useEffect(() => {
        setResult({ data: (data && data.latestVersion) ? data.latestVersion[0] : '', error, loading });
      }, [data, error, loading]);
      return <Component {...result} {...props as P} />;
  }
  
  return <ComponentWithVersion />
}

 

bracket으로 컴포넌트로 호출하게 되면 항상 instance를 받게 되는데

withLatestVersion이 Draft의 return 단에서 호출되는 것이 아니라 export default 에서 호출됨을 기억하자.