자바스크립트 자료구조 활용 정리 — Map·Set·Stack·Queue·Tree
코드를 엉망으로 만드는 대부분의 경우는 잘못된 자료구조를 선택했기 때문이라는 말이 머리에 박혔다. 자료구조 선택이 렌더링 로직의 모양이나 데이터 업데이트 방식까지 결정한다는 걸 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 배열을 처음부터 훑으니 곱셈으로 비용이 늘어난다. 두 가지 방식으로 풀어봤다.
- 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>
));
}- 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였다는 걸 새삼 다시 확인했다.