React 컴포넌트를 커스텀 훅으로 제공하기

안녕하세요. LINE 증권의 프런트 엔드를 담당하고 있는 파이낸셜 개발 센터의 Suzuki입니다. LINE 증권에서는 React를 사용하고 있습니다(참고(일본어)). React의 16.8 버전에서 도입된 훅(hook) 기능은 매우 혁신적인데요. 특히 커스텀 훅이라는 개념 덕분에 React에서 컴포넌트를 설계하는 방식이 크게 변화했습니다. 저희 팀에서도 훅의 시대에 컴포넌트를 설계하면서 시행착오를 겪고 있는데요. 이번 글에서는 그중에서도 최근에 저희가 열중하고 있는 ‘커스텀 훅으로 컴포넌트 제공하기’, ‘render hooks’라고 할 수 있는 설계 패턴을 소개하겠습니다.

 

문제 정의

‘몇 가지 체크박스가 있고, 전부 체크하면 다음으로 넘어간다’는 전형적인 패턴을 예로 들어 설명하겠습니다. 아래 이미지와 같이 3개의 체크박스와 ‘다음’ 버튼이 나열되어 있다고 생각해 봅시다.

현재 2개의 체크박스만 체크되었기 때문에 ‘다음’ 버튼은 비활성화되어 있습니다. 이 버튼은 체크박스를 모두 체크하면 누를 수 있게 활성화됩니다. 이를 React를 이용해서 최대한 있는 그대로 구현하면 다음과 같은 코드가 됩니다. 

const labels = ['check 1', 'check 2', 'check 3']
 
const App: React.FunctionComponent = () => {
  const [checkList, setCheckList] = useState([false, false, false])
 
  // index 번째 체크 상태를 반전시킨다
  const handleCheckClick = (index: number) => {
    setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
  }
 
  const isAllChecked = checkList.every((x) => x)
 
  return (
    <div>
      <ul>
        {labels.map((label, idx) => (
          <li key={idx}>
            <label>
              <input
                type='checkbox'
                checked={checkList[idx]}
                onClick={() => handleCheckClick(idx)}
              />
              {label}
            </label>
          </li>
        ))}
      </ul>
      <p>
        <button disabled={!isAllChecked}>다음</button>
      </p>
    </div>
  )
} 

useState를 이용해서 checkList라는 상태 변수를 선언했습니다. 이것은 boolean 배열입니다. 이 배열의 요소 하나는 체크박스 하나에 대응합니다. 여기서 스타일을 재사용하는 것과 같은 목적으로, 체크박스가 나열된 부분을 컴포넌트로 분할해서 재사용할 수 있게 만들고 싶다면 어떻게 해야 할까요? 이 문제가 이번 글에서 해결할 문제입니다.

 

일반적인 컴포넌트 분할 방법

보통 컴포넌트를 분할한다면 아래와 같이 <Checks /> 컴포넌트를 만듭니다. 이 컴포넌트는 레이블 목록인 labels와 현재 체크 상태 목록인 checkList, 그리고 체크 상태가 변경된 경우의 핸들러인 onCheck로 구성됩니다.

type Props = {
  checkList: readonly boolean[]
  labels: readonly string[]
  onCheck: (index: number) => void
}
 
export const Checks: React.FunctionComponent<Props> = ({
  checkList,
  labels,
  onCheck
}) => {
  return (
    <ul>
      {labels.map((label, idx) => (
        <li key={idx}>
          <label>
            <input
              type='checkbox'
              checked={checkList[idx]}
              onClick={() => onCheck(idx)}
            />
            {label}
          </label>
        </li>
      ))}
    </ul>
  )
} 

이 Checks 컴포넌트를 사용하는 측은 아래와 같은 코드가 됩니다.

const labels = ['check 1', 'check 2', 'check 3']
 
const App: React.FunctionComponent = () => {
  const [checkList, setCheckList] = useState([false, false, false])
 
  // index 번째 체크 상태를 반전시킨다
  const handleCheckClick = (index: number) => {
    setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
  }
 
  const isAllChecked = checkList.every((x) => x)
 
  return (
    <div>
      <Checks
        checkList={checkList}
        labels={labels}
        onCheck={handleCheckClick}
      />
      <p>
        <button disabled={!isAllChecked}>다음</button>
      </p>
    </div>
  )
} 

이렇게 하면 확실히 컴포넌트를 분할할 수 있지만, 아직 이상적인 상황은 아닙니다. 지금 상황을 그림으로 나타내면 다음과 같습니다. 

원래 App 컴포넌트는 5개의 구성 요소를 가지고 있습니다. 상태 변수인 checkList와 이벤트 핸들러인 handleCheckClickisAllChecked를 계산하는 부분, 체크박스를 그리는 부분, 그리고 그 밖의 것을 그리는 부분입니다. 이 중에서 Checks 컴포넌트를 분리하는 과정에서 App에서 빠진 것은 체크박스를 그리는 기능뿐입니다. 나머지 4개의 기능은 여전히 App에 남아 있고, 재사용할 수 없습니다.

특히 checkList가 여전히 Checks가 아닌 App에 남아있는 것은 주목할 만합니다. App이 ‘다음’ 버튼의 disabled 속성을 계산해야 하기 때문입니다. 이 부분이 그림에서 isAllChecked인데요. 이건 checkList에서 계산합니다. 따라서 필연적으로 checkList도 App에 있어야 합니다. Checks 안에 checkList를 넣어 버리면 모두 체크되었을 때 App이 탐지할 수 없기 때문입니다. 물론 Checks가 onAllChecked와 같은 이벤트 핸들러를 제공할 수는 있지만, 별로 바람직한 해결책이 아닙니다. 그렇게 하면 App에 isAllChecked를 별도 상태 변수를 두고 같은 정보를 이중으로 관리해야 하기 때문입니다.

하지만 App은 원래 ‘모두 체크되었는지’에만 관심이 있습니다. ‘무엇이 체크되었는지’에 관한 정보 관리는 Checks에 맡기고 싶을 겁니다. 지향해야 할 본연의 모습보다 App이 비대해져 버리는 것은 보통 컴포넌트를 분할할 때 발생하는 문제입니다. 

 

커스텀 훅 도입

여기서 등장하는 게 ‘커스텀 훅’입니다. 커스텀 훅은 컴포넌트 분할과는 달리 ‘컴포넌트 로직 자체를 분할하거나 재사용’할 수 있습니다. 이번 예제에서 checkList를 App에서 이동시킬 수는 없지만, 커스텀 훅을 이용하면 App에 속하는 로직을 뽑아낼 수는 있습니다. 이번 글의 핵심은 ‘Checks를 사용한다’는 것 자체를 커스텀 훅 안으로 이동시키는 것입니다. 이를 구현한 커스텀 훅인 useChecks를 어떻게 구현했는지 살펴보겠습니다.

type UseChecksResult = [boolean, () => JSX.Element]
 
export const useChecks = (labels: readonly string[]): UseChecksResult => {
  const [checkList, setCheckList] = useState(() => labels.map(() => false))
 
  const handleCheckClick = (index: number) => {
    setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
  }
 
  const isAllChecked = checkList.every((x) => x)
 
  const renderChecks = () => (
    <Checks checkList={checkList} labels={labels} onCheck={handleCheckClick} />
  )
 
  return [isAllChecked, renderChecks]
} 

이 useChecks라는 커스텀 훅은 체크 박스의 정의(string[])를 받아서 ‘이 정의를 레이블로 하는 체크 박스를 관리한다’라는 로직을 내포합니다. 반환 값으로는 ‘모두 체크되어 있는지’와 ‘체크박스를 렌더링하는 함수’를 반환합니다. 이를 이용하면 App 컴포넌트는 아래와 같이 됩니다.

const App: React.FunctionComponent = () => {
  const [isAllChecked, renderChecks] = useChecks(labels)
 
  return (
    <div>
      {renderChecks()}
      <p>
        <button disabled={!isAllChecked}>다음</button>
      </p>
    </div>
  )
} 

더 간단할 수 없을 정도로 간단합니다. 이 상태를 그림으로 나타내면 아래와 같습니다. 

그림과 같이 checkList와 handleCheckClick 함수가 useChecks 안으로 들어가서 App에서는 전혀 보이지 않게 되었습니다. renderChecks 함수를 useChecks 내부에서 만들었기 때문에 가능했습니다. 또한 isAllChecked 계산도 useChecks 내부로 옮겼습니다.

App은 useChecks 반환 값으로 isAllChecked와 renderChecks를 받습니다. 이것으로 App의 역할이 아주 명확해졌습니다. ‘체크박스 목록을 적절한 곳에 그릴 것’과 ‘체크박스 이외의 부분을 그릴 것’, 그리고 ‘체크박스가 모두 체크되어 있는지에 따라 표시를 바꿀 것’입니다. 이번에 만든 useChecks는 호출자에게 딱 해당 정보만 전달하도록 설계했습니다. 그리고 체크박스를 그릴 때 공통화할 수 있는 부분은 깔끔하게 useChecks 안으로 이동시켰습니다. 

훅이 render 함수를 반환하는 게 낯설 수도 있지만, ‘체크박스를 적당한 위치에 배치한다’라는 것만은 App의 역할로 남아 있으니 이 인터페이스가 합당합니다. 또한 Checks 컴포넌트가 App에서 직접 보이지 않는다는 점도 매력적입니다.

한 가지 주의할 점은, 여기서 샘플로 제시한 useChecks가 조금 엉성하게 구현됐다는 점입니다. 구체적으로 말씀드리자면, 현재 샘플은 나중에 입력 labels가 변하면 대응할 수 없습니다. useChecks를 더욱 잘 재사용할 수 있게 만들기 위해선 이 점에 대해 추가로 대응할 필요가 있습니다만, 이번 글에서는 전달하려는 포인트에 집중하기 위해 생략하겠습니다. 

 

마치며

이번 글에선 컴포넌트(를 렌더링하는 함수)를 제공하는 커스텀 훅을 이용해서 로직을 분할하는 방법을 소개했습니다. 커스텀 훅은 컴포넌트를 분할하지 않고 로직만을 재사용 가능한 형태로 뽑아서 캡슐화할 수 있다는 점이 재미있었습니다. 위 예제에서 checkList는 결국 App에 속한 채로 남았지만, useChecks 안에 은닉되었기 때문에 App을 구현할 때 정말 관심 있는 로직에만 집중할 수 있게 되었습니다. 이를 활용해서 최대한 간결한 인터페이스를 제공하기 위해서는 컴포넌트를 렌더링하는 함수를 훅의 반환값으로 제공하는 테크닉이 유용합니다.