XionWCFM의 로고 이미지

제네릭과 콜백을 통해 유연한 리액트 체크박스 커스텀훅 만들기

react
XionWCFM의 로고 이미지
유길종(XionWCFM)
2024.07.09. 14:32
안녕하세요 오늘은 우리 삶에 너무나도 익숙하게 자리잡은 약관 동의 기능을 구현하는 방법에 대하여 이야기 하고자 합니다.
약관 동의는 고객에게 서비스되는 제품이라면 안 들어가는 경우를 찾아보기 힘들 정도로 익숙한 UI 이기도 한데요
사용자의 편의를 위해 모두 동의, 일부만 동의와 같은 기능들을 구현하려해보면 생각보다 구현이 쉽게 되지는 않는 것 같습니다.
다양한 서비스에서 사용되지만 또 한편으로는 다양한 서비스에서 사용된다는 특성으로 인해 공통된 로직을 추출하여 추상화 하는 것이 어렵게 느껴지기도합니다.
토스의 약관 동의 페이지
이런 약관 동의 체크박스는 서비스의 특성에 따라 매우 복잡하기도, 간단하기도 한데요 그래도 통상적인 수준의 약관 동의라고 한다면 추가적인 뎁스 없이 전체 동의 , 필수 체크, 선택 체크로 나뉘어진 것을 떠올릴 것 같습니다.
이번에는 아래와 같이 모두 동의하기, 일부분만 동의하기, 관련된 요소의 상태에 따라 체크 여부가 달라지는 체크박스와 같은 기능들을 구현해보겠습니다.
다음은 완성된 동작만 담은 15초내외의 시연 영상입니다.

체크박스의 핵심 기능만 추상화하기

추상화란 복잡한 자료, 모듈, 시스템 등으로부터 핵심적인 개념 , 기능을 간추려 내는 것을 말합니다.
실제의 체크박스는 구현에 따라 expand 가능한 아코디언같은 형태로 설명을 담고 있을수도, 화려한 인터랙션을 가지고 있을수도 있지만 그것들이 있어야만 체크박스라고 부를 수 있는 것은 아닐 것입니다.
따라서 체크박스로서 기능할 수 있는 최소한도의 기능을 생각해보면 다음과 같이 체크박스의 핵심을 추상화할 수 있을 것입니다.
 
type BaseCheckBox = {
    id: number | string;
    checked: boolean
}
 
식별을 가능하게 만들어줄 id 요소와 현재 check 되었는지, 아닌지를 판별한 요소 이 두가지는 어떤 체크박스를 만들더라도 필요할 것이라고 유추할 수 있습니다.
그러나 우리의 코드는 저런 매우 기본적인 형태의 체크박스만 허용해서는 곤란할 것입니다. 어떻게 해야할까요?

제네릭을 통해 기본 체크박스를 확장하기

 
type BaseCheckBox = { id: number | string; checked: boolean };
type ExtendableCheckBox<T extends BaseCheckBox> = T
 
이렇게 제네릭과 extends 문법을 통하여 BaseCheckBox의 구조를 준수하면서 확장된 타입을 받을 수 있도록 타입을 구성해줄 수 있습니다.
이제 이 BaseCheckBox와 제네릭을 사용하여 코드를 만들어나가기 전에 요구사항을 정의해봅시다.

모두 동의한 상태는 파생 상태라고 볼 수 있지 않을까?

구현을 하기 전 생각해보면 모두 동의하기, 일부만 동의하기와 같은 체크박스는 파생상태와 비슷하다고 볼 수 있습니다.
파생상태란 기본적인 상태를 바탕으로 계산되거나 파생된 상태를 의미하는데요
재료(?)가 되는 상태를 토대로 계산을 해서 얻어낼 수 있는 상태라면 파생상태다. 라고 이해해도 무방할 것 같습니다.
그렇게 생각해보았을때 "모두 동의했다"라는 것은 다른 체크박스들의 상태를 체크했을때 모두 체크되어있다면 모두 동의된것, 그렇지 않다면 모두 동의되지않은것 이라는 관점에서 파생상태로 관리할 수 있어보입니다.
이렇게 모두 동의했는가?와 같이 계산을 통해 얻어낼 수 있는 상태를 별도의 상태로 관리하게되면 하나의 신뢰가능한 출처가 아닌 여러개의 출처를 관리해야하는데에서 오는 복잡도 증가 가 있기에 파생 상태로 관리할 수 있는 것은 파생 상태로 관리하는 것이 일반적으로 좋은 것 같습니다.

콜백을 통해 코드에 유연성 더하기

자바스크립트를 사용하는 개발자라면 편리한 배열메서드들을 사용해보신 경험이 있을 것 같아요 이런 배열 메서드들은 콜백함수를 인자로 받는 것을 통해 다양한 로직을 적용할 수 있도록 해줍니다.
이렇듯 callback을 이용해서 예상할 수 없는 외부의 데이터구조에 대해 외부에서 대응할 수 있도록 제어권을 열어주는 것이 가능합니다.
이러한 패턴은 제네릭을 통해 타입스크립트와도 깔끔하게 통합하는것이 가능한데요 이 점을 유의하며 리듀서와 액션을 정의해보겠습니다.
 
type Action<T extends BaseCheckBox> =
  | {
      type: "TOGGLE_CHECK";
      target: T["id"];
    }
  | {
      type: "CHECK_ALL";
    }
  | {
      type: "UNCHECK_ALL";
    }
  | {
      type: "CHECK_BY";
      checked: boolean;
      callback: (arg: T) => boolean;
    };
 
이제 이 액션을 처리하는 리듀서를 정의해봅시다
 
export const checkBoxReducer = <T extends BaseCheckBox>(itemList: T[], action: Action<T>) => {
  switch (action.type) {
    case "TOGGLE_CHECK":
      return itemList.map((item) => (item.id === action.target ? { ...item, checked: !item.checked } : item));
    case "CHECK_ALL":
      return itemList.map((item) => ({ ...item, checked: true }));
    case "UNCHECK_ALL":
      return itemList.map((item) => ({ ...item, checked: false }));
    case "CHECK_BY":
      return itemList.map((item) => ({ ...item, checked: action.callback(item) ? action.checked : item.checked }));
    default:
      return itemList;
  }
};
 
파생될 수 있는 상태는 파생된 상태로 관리하기로 했으니 우리의 중요한 상태 하나만 잘 관리해주면 됩니다. 하나만 잘 관리하면 되니 코드도 상대적으로 간결합니다.

커스텀 훅을 통해 로직 발라내기

 
type CheckListReturnType<T extends BaseCheckBox> = {
  list: T[];
  dispatch: (action: Action<T>) => void;
  isChecked: (id: T["id"]) => boolean;
  isAllChecked: () => boolean;
  findItem: (id: T["id"]) => T | null;
  findIndex: (id: T["id"]) => number | null;
  checkAll: () => void;
  uncheckAll: () => void;
  toggle: (id: T["id"]) => void;
  getCheckedList: () => T[];
  getCheckedIds: () => T["id"][];
  updateAll: (checked: boolean) => void;
  toggleAll: () => void;
  updateItem: (id: T["id"], checked: boolean) => void;
  checkBy: (toggle: boolean, fn: (arg: T) => boolean) => void;
  isCheckedBy: (fn: (arg: T) => boolean) => boolean;
};
 
먼저 훅의 리턴타입을 정의해주겠습니다. 직접 구현하기는 귀찮지만 필요한 기능들을 미리 정의해두면 사용하는 측에서 편하게 사용할 수 있을 것입니다.
export const useCheckList = <T extends BaseCheckBox>(list: T[]): CheckListReturnType<T> => {
  const [state, dispatch] = useReducer(checkBoxReducer<T>, list);
 
  /// .... 중략
 
  const isCheckedBy = useCallback(
    (fn: (arg: T) => void) => {
      return state.filter((item) => fn(item)).every((item) => item.checked);
    },
    [state],
  );
 
  const checkBy = useCallback((toggle: boolean, fn: (arg: T) => boolean) => {
    dispatch({ type: "CHECK_BY", checked: toggle, callback: fn });
  }, []);
 
  return {
    list: state,
    dispatch,
    isChecked,
    isAllChecked,
    findItem,
    findIndex,
    checkAll,
    uncheckAll,
    toggle,
    getCheckedIds,
    getCheckedList,
    updateAll,
    toggleAll,
    checkBy,
    isCheckedBy,
  } as unknown as CheckListReturnType<T>;
};
 
이제 이렇게 isCheckedBy와 checkBy를 통하여 커스텀훅의 사용자측에서 콜백함수를 통해 동작을 제어할 수 있게되었습니다.
그렇다면 이 커스텀 훅을 이용하여 위에서 보았던 동영상의 구현체를 작성해보겠습니다.

약관 동의 페이지 구현하기

type AgreeTermsType = {
  id: string;
  checked: boolean;
  label: string;
  group: "required" | "optional";
};
 
const agreeTermsList = [
  {
    id: "1",
    checked: false,
    label: "이용약관동의",
    group: "required",
  },
  {
    id: "2",
    checked: false,
    label: "개인정보 수집 및 이용에 대한 안내",
    group: "required",
  },
  {
    id: "3",
    checked: false,
    label: "이벤트 등 프로모션 알림 메일 수신",
    group: "optional",
  },
] satisfies AgreeTermsType[];
 
 
먼저 우리의 약관 동의 체크박스들의 구조를 정의해주겠습니다. id, checked 뿐만 아니라 추가적으로 필요한 프로퍼티들도 정의해주었어요
제네릭과 콜백을 잘 이용하여 훅을 정의했으니 이 구현에서만 존재하는 추가적인 프로퍼티들도 적절하게 사용할 수 있을거에요
 
export default function Page() {
  const check = useCheckList(agreeTermsList);
  const checkBoxGroup = groupBy(check.list, (item) => item.group);
  const isOptionalChecked = check.isCheckedBy(({ group }) => group === "optional");
  const isRequiredChecked = check.isCheckedBy(({ group }) => group === "required");
  const isAllChecked = check.isAllChecked();
  const handleChange = (type: AgreeTermsType["group"]) => {
    const checked = type === "required" ? isRequiredChecked : isOptionalChecked;
    check.checkBy(!checked, ({ group }) => group === type);
  };
}
 
이렇게 isCheckedBy를 통해 group이 optional이거나 group이 required인 항목들로 한정하여 체크된 여부를 확인할 수 있습니다.
 
 
  return (
    <div className=" px-4 py-2">
      {/* 모두 동의하기 */}
      <div className=" flex gap-x-4">
        <div className="">모두 동의하기</div>
        <CheckBox id="" checked={isAllChecked} onChange={check.toggleAll} />
      </div>
 
      <div className=" border w-full my-4"></div>
 
      {/* 필수 선택 렌더링하기 */}
      <div className=" flex mt-8 gap-x-4">
        <div className="">필수 선택만 모두 동의하기</div>
        <CheckBox id="필수선택모두동의" checked={isRequiredChecked} onChange={() => handleChange("required")} />
      </div>
 
      <div className=" mt-4 flex flex-col gap-y-4">
        {checkBoxGroup.required.map((checkBox) => (
          <div key={checkBox.id} className=" flex gap-x-4">
            <div>{checkBox.label}</div>
            <CheckBox id={checkBox.id} checked={checkBox.checked} onChange={() => check.toggle(checkBox.id)} />
          </div>
        ))}
      </div>
 
      <div className=" border w-full my-4"></div>
 
      {/* 옵셔널 선택 렌더링하기 */}
      <div className=" mt-8 flex gap-x-4">
        <div className=""> 선택 내용 모두 동의하기</div>
        <CheckBox id="선택모두동의" checked={isOptionalChecked} onChange={() => handleChange("optional")} />
      </div>
 
      <div className=" mt-4 flex flex-col gap-y-4">
        {checkBoxGroup.optional.map((checkBox) => (
          <div key={checkBox.id} className=" flex gap-x-4">
            <div>{checkBox.label}</div>
            <CheckBox id={checkBox.id} checked={checkBox.checked} onChange={() => check.toggle(checkBox.id)} />
          </div>
        ))}
      </div>
    </div>
  );
 
이제 복잡한 파생상태 계산은 커스텀훅에게 일임하면서 상대적으로 간단한 로직을 통해 모두 동의와 같은 기능을 구현할 수 있으니 자연스럽게 코드의 가독성도 높아지게 됩니다.

마치며

이번에는 콜백과 제네릭을 이용하여 체크박스 선택과 같이 추상화하기 까다로운 기능을 추상화하는 방법을 다루어보았습니다.
사실 lodash와 같은 라이브러리를 잘 사용하고 계신 분들이라면 너무나도 익숙한 개념일 수도 있을 것 같습니다.
이번 포스트에서 다룬 코드의 전체 구현이 궁금하신 경우에는 xionwcfm-use-checklist.tsxionwcfm-example에 방문해주세요!

감사합니다.
XionWCFM을 나타내는 캐릭터 이미지
유길종(XionWCFM)
무언가를 쉽게 설명 해낼 때 쾌감을 느끼는 사람입니다. 현재는 프론트엔드 개발자로 일하고 있습니다.