본문 바로가기
React

React에서 셋(Set)으로 체크박스 재정의하기

by Vintz 2023. 5. 14.
반응형

체크박스 기능 구현, 이게 이렇게나 복잡한 퍼즐이 될 줄은 몰랐다. 그래도 두 개의 구현 방법을 알아내긴 했다. 그 중 하나인 '셋(Set)' 자료구조를 활용한 방식은 신기하고 인상 깊었다. 셋은 알고리즘 공부할 때나 가끔 봤었는데, 예전에 면접에서 써봤다고 괜히 얘기 꺼냈다가 라이브로 관련 문제까지 풀었었던 아찔한 기억이 있다.(물론 떨어졌다.)

 

그래서 이 글에서는 제대로 이해하고자 체크박스 기능 구현을 통해 알아보려고 한다. 우선, 배열을 사용하여 구현한 방식을 살펴보고, 이를 개선하기 위해 셋을 이용한 방식까지 소개한다. 따라서, 이 두 가지 방식을 비교하고, 셋을 이용한 체크박스 기능 구현이 어떻게 기존의 배열을 사용한 방식을 개선할 수 있는지에 대해 살펴보자.

잠깐, 체크박스 UI란?

체크박스 UI는 메모앱, 설문 조사, 배달앱, 투두 리스트, 어드민 페이지 등 다양한 곳에서 활용된다. 배달앱이라면 메인, 사이드 메뉴 선택, 소스, 토핑 선택 등에 사용되며, 이를 통해 사용자는 다양한 선택지 중 추가적인 선택을 할 수 있다. 투두 리스트는 어떨까? 오늘의 할 일 목록을 체크리스트로 만들어 체계적으로, 차분하게 일처리를 할 수 있도록 도와준다. 어드민 페이지에서는 테이블의 항목을 사용자가 직관적으로 선택하고, 여러 항목을 동시에 선택하여 일괄 처리를 하도록 도울 수 있다. 이처럼 체크박스는 정확한 선택 제어, 다중 선택 등의 사용자 편의성을 제공해준다.

구현하기

구현할 체크박스 기능은 다음과 같다:

  1. 최상단 체크박스로 전체 선택 및 취소
  2. 개별 항목 선택 취소 시 최상단 체크박스 해제
  3. 모든 항목 선택 시 최상단 체크박스 활성화
  4. 선택한 개수 표시

첫 번째 방법: 배열로 구현

먼저, 초기 상태가 빈 배열인 체크 목록을 만든다. 각 항목의 체크박스는 해당 항목의 ID가 체크 목록에 있는지에 따라 체크 상태가 결정된다. 따라서 사용자가 클릭할 때마다 다음과 같은 기능을 하는 함수를 호출한다:

  • 체크박스의 ID를 인자로 받아, 해당 ID가 이미 선택된 ID인지 아닌지를 확인한다.
  • 만약 이미 선택된 ID라면, 해당 ID를 체크 목록에서 제거한다.
  • 선택되지 않은 ID라면, 체크 목록에 추가한다.

다음은 모든 항목의 선택/취소 기능이다. 이는 최상단 체크박스의 변경 사항에 해당한다:

  • 체크박스의 checked 속성을 인자로 받아, 체크 여부를 확인한다.
  • 체크되면, 모든 항목의 ID를 체크 목록에 추가한다.
  • 체크 해제되면, 체크 목록 상태를 빈 배열로 만든다.

마지막으로, 선택한 개수 표시는 배열의 길이로 제어한다. 코드로 표현하면 다음과 같다:

const [checkedListById, setCheckedListById] = useState([]);
const numChecked = checkedListById.length;

const handleOnChange = (id) => {
  const isChecked = checkedListById.includes(id);

  if (isChecked) {
    setCheckedListById((prev) => prev.filter((el) => el !== id));
  } else {
    setCheckedListById((prev) => [...prev, id]);
  }
};

const toggleAllCheckedById = ({ target: { checked } }) => {
  if (checked) {
    setCheckedListById(rows.map((row) => row.id));
  } else {
    setCheckedListById([]);
  }
};

다음으로 테이블에 함수들을 연결하면:

<table>
  <thead>
    <tr>
      <th>
        <input
          type='checkbox'
          onChange={toggleAllCheckedById}
          checked={numChecked === rows.length}
        />
      </th>
      <th style={{ minWidth: '8rem' }}>
        {numChecked ? `Selected ${numChecked}` : 'None selected'}
      </th>
    </tr>
    <tr>
      <th />
      <th>ID</th>
      <th>Name</th>
    </tr>
  </thead>
  <tbody>
    {rows?.map(({ id, name }) => (
      <tr key={id}>
        <td>
          <input
            type='checkbox'
            onChange={() => handleOnChange(id)}
            checked={checkedListById.includes(id)}
          />
        </td>
        <td>{id}</td>
        <td>{name}</td>
      </tr>
    ))}
  </tbody>
</table>

원하는 기능들을 모두 구현할 수 있게된다.

전체 코드

import './App.css';
import { useState } from 'react';

const rows = [
  {
    id: 'id-1',
    name: 'Row 1',
  },
  {
    id: 'id-2',
    name: 'Row 2',
  },
  {
    id: 'id-3',
    name: 'Row 3',
  },
  {
    id: 'id-4',
    name: 'Row 4',
  },
  {
    id: 'id-5',
    name: 'Row 5',
  },
  {
    id: 'id-6',
    name: 'Row 6',
  },
];

export function App() {
  return <TableUsingArray />;
}

function TableUsingArray() {
  const [checkedListById, setCheckedListById] = useState([]);
  const numChecked = checkedListById.length;

  const handleOnChange = (id) => {
    const isChecked = checkedListById.includes(id);

    if (isChecked) {
      setCheckedListById((prev) => prev.filter((el) => el !== id));
    } else {
      setCheckedListById((prev) => [...prev, id]);
    }
  };

  const toggleAllCheckedById = ({ target: { checked } }) => {
    if (checked) {
      setCheckedListById(rows.map((row) => row.id));
    } else {
      setCheckedListById([]);
    }
  };

  return (
    <table>
      <thead>
        <tr>
          <th>
            <input
              type='checkbox'
              onChange={toggleAllCheckedById}
              checked={numChecked === rows.length}
            />
          </th>
          <th style={{ minWidth: '8rem' }}>
            {numChecked ? `Selected ${numChecked}` : 'None selected'}
          </th>
        </tr>
        <tr>
          <th />
          <th>ID</th>
          <th>Name</th>
        </tr>
      </thead>
      <tbody>
        {rows?.map(({ id, name }) => (
          <tr key={id}>
            <td>
              <input
                type='checkbox'
                onChange={() => handleOnChange(id)}
                checked={checkedListById.includes(id)}
              />
            </td>
            <td>{id}</td>
            <td>{name}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

두 번째 방법: 셋(Set)으로 구현

첫 번째 방법의 코드도 이미 잘 작성되어 있지만, 좀 더 깔끔하고 직관적으로 개선할 수 있다.

const [checkedIdsSet, setCheckedIdsSet] = useState(new Set());
const numChecked = checkedIdsSet.size;

const updateSet = (set, id) => {
  const updatedSet = new Set(set);
  
  if (updatedSet.has(id)) {
    updatedSet.delete(id);
  } else {
    updatedSet.add(id);
  }
  
  return updatedSet;
};

const handleOnChange = (id) => {
  setCheckedIdsSet((prevSet) => updateSet(prevSet, id));
};

const toggleAllCheckedById = ({ target: { checked } }) => {
  if (checked) {
    const allChecked = new Set(rows.map(({ id }) => id));
    setCheckedIdsSet(allChecked);
  } else {
    setCheckedIdsSet(new Set());
  }
};

셋을 사용하니까 특정 요소가 존재하는지 확인하거나, 해당 요소를 추가 및 제거하는 메서드가 직관적이다 보니 코드가 간결하고 읽기 편해졌다. 또한 셋은 내부적으로 해시 테이블 또는 비슷한 데이터 구조를 사용하여 요소를 저장한다. 따라서 시간 복잡도가 O(n)보다 좋다. 예시로 특정 요소의 존재 여부를 확인하는 has 메서드는 배열의 길이가 셋과 같을 때, includes 메서드보다 평균적으로 빠르다. 셋을 사용하는 것이 더 효율적으로 처리되고, 간결하기까지하다.

전체 코드

import './App.css';
import { useState } from 'react';

const rows = [
  // 동일
];

export function App() {
  return <TableUsingSet />;
}

function TableUsingSet() {
  const [checkedIdsSet, setCheckedIdsSet] = useState(new Set());
  const numChecked = checkedIdsSet.size;

  const updateSet = (set, id) => {
    const updatedSet = new Set(set);
    
    if (updatedSet.has(id)) {
      updatedSet.delete(id);
    } else {
      updatedSet.add(id);
    }
    
    return updatedSet;
  };

  const handleOnChange = (id) => {
    setCheckedIdsSet((prevSet) => updateSet(prevSet, id));
  };

  const toggleAllCheckedById = ({ target: { checked } }) => {
    if (checked) {
      const allChecked = new Set(rows.map(({ id }) => id));
      setCheckedIdsSet(allChecked);
    } else {
      setCheckedIdsSet(new Set());
    }
  };

  return (
    <table>
      <thead>
        <tr>
          <th>
            <input
              type='checkbox'
              onChange={toggleAllCheckedById}
              checked={numChecked === rows.length}
            />
          </th>
          <th style={{ minWidth: '8rem' }}>
            {numChecked ? `Selected ${numChecked}` : 'None selected'}
          </th>
        </tr>
        <tr>
          <th />
          <th>ID</th>
          <th>Name</th>
        </tr>
      </thead>

      <tbody>
        {rows.map(({ id, name }) => (
          <tr key={id}>
            <td>
              <input
                type='checkbox'
                checked={checkedIdsSet.has(id)}
                onChange={() => handleOnChange(id)}
              />
            </td>
            <td>{id}</td>
            <td>{name}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

마치며

이렇게 체크박스 기능을 두 가지 방식으로 구현해봤다. 셋의 모든 값은 고유하다는 점, 그리고 직관적인 메서드의 사용으로 코드를 개선할 수 있었다.

 

셋은 특히 배열의 중복 값 제거에 용이하다. 주로 원시 값(숫자, 문자열 등)에 대해 중복을 제거할 때 좋지만 객체 배열의 경우, 객체는 고유한 메모리 주소를 갖기 때문에 셋만으로는 중복 제거를 하지 못한다. 이럴 땐 맵을 사용해 중복을 제거할 수 있다.

const arr = [
  {id: 1, name: 'A'},
  {id: 2, name: 'B'},
  {id: 1, name: 'A'},
  {id: 3, name: 'C'}
];

위 배열에서 id 값을 기준으로 중복을 제거하고 싶다면 아래와 같이 할 수 있다:

const uniqueArr = Array.from(new Map(arr.map(item => [item.id, item])).values());
  1. arr.map(item => [item.id, item])은 각 객체의 id를 키로, 객체 자체를 값으로 하는 배열로 변환한다.
  2. 만든 배열을 Map 생성자에 전달하면, id를 키로 하는 Map 객체가 생성된다.
  3. Map은 같은 키를 가진 항목을 오직 하나만 유지하기 때문에 이 과정에서 중복이 제거된다.
  4. Map.values()를 사용하여 새로운 이터러블 객체를 반환한다.
  5. Array.from()으로 이터러블 객체를 배열로 변환한다.

참고로, 중복 제거를 할 때 name 값이 다르다면 가장 마지막 값이 반환된다.

 

객체는 키가 있는 컬렉션을 저장하고, 배열은 순서가 있는 컬렉션을 저장한다. 맵은 객체와 비슷하지만 키에 다양한 자료형을 허용한다는 점에서 차이가 있다. 그리고 셋은 배열과 비슷하지만 값의 중복을 허용하지 않는다는 점에서 차이가 있다. 맵과 셋은 반복 작업 시, 해당 컬렉션에 요소나 값을 추가한 순서대로 반복 작업이 수행되지만 컬렉션 내 요소나 값을 재정렬할 수 없으며, 배열처럼 인덱스를 이용해 요소를 가져오는, 즉 숫자를 이용해 특정 요소나 값을 가지고 오는 것도 불가능하다. 이러한 특징들을 알고 있으면 다양한 상황에서 적절한 자료구조를 선택하여 사용할 수 있을 것 같다!


*컬렉션: 여러 값을 묶어서 관리하는 데이터 구조를 가리킨다. 일반적으로 배열, 객체, 셋(Set), 맵(Map) 등의 데이터 타입을 포함한다.

참고

React 체크박스 전체 선택/해제 기능 구현하기 - 헬로코딩
43% Less Code With A Better Data Structure - Johannes Kettmann
[번역] 현실 세계 프런트엔드에서 사용되는 자바스크립트 자료구조: 리액트 코드 예시와 함께 - eunbinn
Set - MDN
맵과 셋 - 모던 JavaScript 튜토리얼
반응형