본문으로 건너뛰기

자바스크립트 자료구조 활용 정리 — Map·Set·Stack·Queue·Tree

·8 min read

코드를 엉망으로 만드는 대부분의 경우는 잘못된 자료구조를 선택했기 때문이라는 말이 머리에 박혔다. 자료구조 선택이 렌더링 로직의 모양이나 데이터 업데이트 방식까지 결정한다는 걸 React 코드를 쓰면서 체감했고, 자주 손이 가는 다섯 가지를 다시 정리했다.

Map — 객체 대신 키 접근을 맡길 곳

키-값으로 이루어진 저장소. 형태만 보면 평범한 객체와 닮았다.

{
  key1: "value 1",
  key2: "value 2",
  key3: "value 3",
}

Map은 Map.size 같은 편의 속성·메서드를 갖고 있고, 키로 값을 꺼내는 작업이 성능 면에서 최적화돼 있다. 더 중요한 건 어떤 타입이든 — 객체까지 — 그대로 키로 쓸 수 있다는 점이다.

객체와 다른 결정적 한 가지

객체의 키는 문자열 또는 Symbol만 가능하다. 숫자나 객체 같은 다른 타입을 키로 넣으면 문자열로 변환된다 (예: {}"[object Object]"). 반면 Map은 객체·함수·NaN을 포함한 어떤 값이든 원래 타입 그대로 키로 쓸 수 있다.

const numericKeys = {
  1: "value-1",
  2: "value-2",
};
 
console.log(Object.keys(numericKeys));
// ["1", "2"]

숫자 1을 넣었는데 꺼낼 땐 문자열 "1"이 돼 있다.

메시지 목록에서 사용자 이름 찾기

각 메시지를 렌더링할 때 그에 대응되는 사용자를 찾는 흔한 패턴. 이 자리에서 자료구조를 바꾸면 어떻게 달라지는지가 가장 잘 보였다.

Before — 매 메시지마다 전체 user 배열을 find로 순회한다.

const messages = [
  {
    id: "message-1",
    text: "Hey folks!",
    userId: "user-1"
  },
  {
    id: "message-2",
    text: "Hi",
    userId: "user-2"
  },
  // ...
];
 
const users = [
  {
    id: "user-1",
    name: "Paul"
  },
  {
    id: "user-2",
    name: "Lisa"
  },
  // ...
];
 
messages.map(({ id, text, userId }) => {
  const name = users.find((user) => user.id === userId).name;
  return null; // return JSX
});

메시지마다 user 배열을 처음부터 훑으니 곱셈으로 비용이 늘어난다. 두 가지 방식으로 풀어봤다.

  1. reduce — 키 접근을 위해 객체로 재구성한다.
function ChatUsingMap() {
  const namesById = users.reduce(
    (prev, user) => ({ ...prev, [user.id]: user.name }),
    {}
  );
 
  return messages.map(({ id, text, userId }) => (
    <div key={id}>
      <div>{text}</div>
      <div>{namesById[userId]}</div>
    </div>
  ));
}
  1. Map — 같은 일을 Map 생성자에게 맡긴다.
function ChatUsingMap() {
  // Map 생성자는 키-값 쌍의 배열을 인자로 받는다.
  const namesById = new Map(users.map(({ id, name }) => [id, name]));
  /*
    [
      ["user-1", "Paul"],
      ["user-2", "Lisa"],
      ...
    ]
  */
 
  return messages.map(({ id, text, userId }) => (
    <div key={id}>
      <div>{text}</div>
      <div>{namesById.get(userId)}</div>
    </div>
  ));
}

둘 다 조회는 키 접근 한 번이라 비용이 일정하다. Map 쪽이 의도(키-값 매핑 자료구조)가 코드에 그대로 드러나서 더 마음에 들었다.

Set — 중복 없는 집합이라는 보장

키를 갖는 집합. Map이 객체에 가깝다면 Set은 배열에 가까운 모양이다.

Set의 모든 값은 고유하다. 값을 두 번 추가할 수 없고, 그래서 배열에서 중복을 제거하는 자리에서 가장 먼저 떠올린다.

const uniqueValues = [...new Set([1, 1, 2, 3, 3])];
// [1, 2, 3]

값이 들어 있는지 확인하는 작업도 배열보다 효율적이다.

선택한 행을 추적할 때

체크박스 테이블에서 어떤 행이 선택됐는지를 들고 다녀야 했다. 처음엔 인덱스로 다뤘다.

Before — 인덱스로 selected 상태를 들고 다닌다.

const [selectedRows, setSelectedRows] = useState(
  rows.map((row) => ({ selected: false }))
);
 
const handleOnChange = (index) => {
  const updatedSelectedRows = [...selectedRows];
  updatedSelectedRows[index] = !updatedSelectedRows[index];
  setSelectedRows(updatedSelectedRows);
};

인덱스로 요소에 접근하는 건 정렬이 바뀌는 순간 문제가 됐다. 행 순서가 달라지면 selectedRows도 같은 방식으로 다시 정렬해줘야 싱크가 맞는다.

After — id를 담는 Set으로 바꾼다.

const rows = [
  {
    id: "id-1",
    name: "Row 1",
  },
  {
    id: "id-2",
    name: "Row 2",
  },
  // ...
];
 
function TableUsingSet() {
  const [selectedIds, setSelectedIds] = useState(new Set());
 
  const handleOnChange = (id) => {
    const updatedIdToSelected = new Set(selectedIds);
    if (updatedIdToSelected.has(id)) {
      updatedIdToSelected.delete(id);
    } else {
      updatedIdToSelected.add(id);
    }
    setSelectedIds(updatedIdToSelected);
  };
 
  return (
    <table>
      <tbody>
        {rows.map(({ id, name }) => (
          <tr key={id}>
            <td>
              <input
                type="checkbox"
                checked={selectedIds.has(id)}
                onChange={() => handleOnChange(id)}
              />
            </td>
            <td>{id}</td>
            <td>{name}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

선택 여부는 "id가 Set에 있는가"로 환원됐다. 행 순서가 바뀌어도 Set은 그대로면 충분하다.

Stack — 후입선출이 자연스러운 자리

Stack은 상위에 아이템을 추가하고, 같은 상위에서부터 제거한다. 후입선출(LIFO).

const stack = [];
 
// "상위"에 item을 추가
stack.push("value");
 
// "상위"에서부터 item을 제거
const topItem = stack.pop();

리액트에서는 상태로 들어가는 배열을 불변으로 다뤄야 하므로, 원본을 변경하는 Array.push·Array.pop 대신 새 배열을 돌려주는 메서드를 쓴다. 추가에는 concat·스프레드, 제거에는 filter·slice.

Queue — 선입선출이 필요한 자리

Queue는 처음 추가된 항목부터 제거된다. 선입선출(FIFO).

const queue = [];
 
// item을 제일 마지막에 추가한다.
queue.push("value");
 
// item을 제일 앞에서부터 제거한다.
const firstItem = queue.shift();

push로 뒤에 붙이고 shift로 앞을 떼는 게 핵심. 같은 배열로 Stack과 Queue 양쪽을 흉내 낼 수 있다는 점이 새삼 신기했다.

Tree — 부모와 자식, 그리고 재귀

중첩된 자료구조. 자식을 가진 부모로부터 출발한다. 종종 재귀 함수와 함께 등장한다.

React의 컴포넌트 트리, DOM, 파일 시스템처럼 익숙한 구조가 전부 Tree였다는 걸 새삼 다시 확인했다.