안녕하세요. 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
와 이벤트 핸들러인 handleCheckClick
, isAllChecked
를 계산하는 부분, 체크박스를 그리는 부분, 그리고 그 밖의 것을 그리는 부분입니다. 이 중에서 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
을 구현할 때 정말 관심 있는 로직에만 집중할 수 있게 되었습니다. 이를 활용해서 최대한 간결한 인터페이스를 제공하기 위해서는 컴포넌트를 렌더링하는 함수를 훅의 반환값으로 제공하는 테크닉이 유용합니다.