서로 다른 저장 방식, 같은 순회 문법
컬렉션은 여러 데이터를 모아 저장하고 관리하는 자료구조다. MDN은 JavaScript 컬렉션을 인덱스 기반 컬렉션과 키 기반 컬렉션으로 나누어 소개한다. 여기서 ‘키 기반’은 Map처럼 별도 키에 값을 연결하는 경우뿐 아니라 Set처럼 값의 중복 여부를 기준으로 관리하는 경우도 포함하는 분류명이다.
| 분류 | 예 | 값을 관리하는 방식 | for...of 순회 |
|---|---|---|---|
| 인덱스 기반 | Array, TypedArray | 숫자 인덱스로 위치를 구분 | 가능 |
| 키 기반 | Map | 키에 값을 연결 | 가능 |
| 키 기반 | Set | 별도 키 없이 값의 중복을 구분 | 가능 |
| 키 기반 중 약한 컬렉션 | WeakMap, WeakSet | 객체 등을 키 또는 값으로 저장하되 붙잡아 두지 않음 | 불가능 |
WeakMap과 WeakSet은 항목을 순회할 수 없다. 이 글에서는 순회 가능한 컬렉션인 배열, Map, Set을 살펴본다. 저장 방식은 다르지만 모두 for...of로 값을 하나씩 사용할 수 있다.
WeakMap과 WeakSet은 언제 사용할까?
‘약하게’는 데이터를 저장하지 않는다는 뜻이 아니다. WeakMap도 set(객체, 값)으로 저장하고, 그 객체가 있는 동안 get(객체)으로 조회할 수 있다. WeakMap에서 약한 것은 값이 아니라 키이고, WeakSet에서는 저장한 객체 값이 약하게 참조된다.
Map이나 Set은 자신이 사용되는 동안 저장한 객체도 계속 참조한다. WeakMap의 키와 WeakSet의 값은 다른 곳에서 더 이상 참조하지 않으면 가비지 컬렉션될 수 있다. 언제 사라질지 알 수 없으므로 약한 컬렉션은 항목 순회나 size를 제공하지 않는다.
- WeakMap 예시: 테이블 행 요소를 키로
rowInfo.set(rowElement, { id, height })처럼 행의 부가 정보를 저장한다. 행이 제거되고 다른 곳에서도 참조하지 않으면, 이 정보 때문에 행 요소가 계속 남지 않는다. - WeakSet 예시: 이벤트를 이미 연결한 테이블 행 요소를
initializedRows.add(rowElement)로 표시하고,has(rowElement)로 중복 연결을 막는다.
const skills = ['JavaScript', 'React'];
const users = new Map([['u1', '민수']]);
const selectedIds = new Set([1, 2]);
for (const skill of skills) console.log(skill);
for (const user of users) console.log(user);
for (const id of selectedIds) console.log(id);
여기에는 두 가지 이야기가 함께 있다. Map과 Set은 무엇을 저장하는가? 그리고 서로 다른 컬렉션을 어떻게 같은 문법으로 순회하는가? 먼저 Map과 Set의 저장 방식을 간단히 비교하고, 두 컬렉션이 따르는 순회 규약을 살펴본다.
| 개념 | 확인할 내용 |
|---|---|
| Map / Set | 저장하는 항목과 기본 순회 결과 |
| Iterable / Iterator | 순회를 제공하는 대상과 다음 결과를 제공하는 객체 |
Symbol.iterator | Iterator를 얻는 방법 |
next() | 다음 값과 종료 여부를 받는 방법 |
1. Map과 Set: 무엇을 저장하고 무엇을 순회할까?
Map은 키와 값의 쌍을, Set은 중복 없는 값을 저장한다.
| 구분 | Map | Set |
|---|---|---|
| 저장 대상 | 키와 값의 쌍 | 중복 없는 값 |
| 추가·갱신 | set(key, value) | add(value) |
| 조회 | get(key) | 별도의 get() 없음 |
| 포함 여부 | has(key) | has(value) |
| 삭제 | delete(key) | delete(value) |
| 항목 수 | size | size |
| 기본 순회 결과 | [key, value] | value |
Map: 키에 대응하는 값
const users = new Map();
users.set('u1', '민수');
users.set('u2', '지수');
users.set('u1', '한수');
console.log(users.get('u1')); // '한수'
console.log(users.size); // 2
for (const [id, name] of users) {
console.log(id, name);
}
// u1 한수
// u2 지수
같은 키로 다시 저장하면 기존 값이 바뀐다. Map의 기본 순회 결과는 [키, 값] 배열이다. 키만 필요하면 users.keys(), 값만 필요하면 users.values(), 키와 값이 모두 필요하면 users.entries()를 순회할 수 있다. 기본 순회는 entries()와 같은 항목을 제공한다.
일반 객체의 프로퍼티 키는 문자열이나 Symbol이지만 Map은 객체도 키로 사용할 수 있다. 이때 객체의 내용이 같아 보이는지보다 동일한 객체인지를 기준으로 구분한다.
const user = { id: 1 };
const roles = new Map([[user, 'admin']]);
console.log(roles.get(user)); // 'admin'
console.log(roles.get({ id: 1 })); // undefined
Set: 중복 없는 값
const selectedIds = new Set();
selectedIds.add(1);
selectedIds.add(2);
selectedIds.add(1);
console.log(selectedIds.has(1)); // true
console.log(selectedIds.size); // 2
for (const id of selectedIds) {
console.log(id);
}
// 1
// 2
같은 값을 다시 추가해도 항목 수가 늘어나지 않는다. Set의 기본 순회 결과는 값 자체이며, selectedIds.values()와 같은 항목을 제공한다. 객체 값의 중복도 동일한 객체인지를 기준으로 판단한다.
const user = { id: 1 };
console.log(new Set([user, user]).size); // 1
console.log(new Set([{ id: 1 }, { id: 1 }]).size); // 2
Map과 Set은 삽입 순서대로 순회한다. 기존 Map 키의 값을 갱신하거나 기존 Set 값을 다시 추가하는 것만으로 순서가 바뀌지는 않는다.
저장하는 항목과 순회 결과는 다르지만, 두 컬렉션 모두 for...of를 사용할 수 있다. 이 공통점이 가능한 이유를 살펴보자.
2. 순회 규약: Iterable과 Iterator의 역할
Set에 1, 2가 들어 있다고 생각해보자. Set은 순회할 대상이고, 값을 읽을 때마다 어디까지 읽었는지를 기억할 객체가 따로 필요하다. 그래서 순회를 시작하면 Set에서 Iterator를 얻는다.
| 역할 | Set에서의 예 | 하는 일 | 메서드 |
|---|---|---|---|
| Iterable | new Set([1, 2]) | 순회할 대상이며, Iterator를 제공한다. | [Symbol.iterator]() |
| Iterator | Set에서 얻은 읽기 객체 | 현재 위치를 기억하고 다음 결과를 제공한다. | next() |
Set은 순회를 시작할 때마다 새 Iterator를 제공한다. 따라서 같은 Set을 두 번 for...of로 순회하면 두 번째도 처음 값부터 읽는다.
const ids = new Set([1, 2]);
for (const id of ids) console.log(id);
for (const id of ids) console.log(id);
// 1
// 2
// 1
// 2
이렇게 순회할 대상과 그 순회의 진행 위치를 분리하면 하나의 컬렉션을 다시 읽거나, 서로 다른 위치에서 각각 읽을 수 있다.
JavaScript는 이 역할을 공통으로 사용하기 위해 순회 규약, 즉 이터레이션 프로토콜을 정해두었다. Map과 Set은 저장 방식이 달라도 이 규약을 지키므로 for...of가 같은 방식으로 값을 요청할 수 있다.
Iterable은 Iterator를 제공하고, Iterator는 다음 결과를 제공한다. 각 메서드가 실제로 무엇을 반환하는지는 이어지는 절에서 확인한다.
3. Symbol.iterator: 순회의 시작점
Symbol.iterator는 JavaScript가 미리 정해둔 Symbol 값이다. 이 값을 프로퍼티 키로 사용하는 메서드가 Iterator를 얻는 진입점이 된다.
const skills = new Set(['JavaScript', 'React']);
const iterator = skills[Symbol.iterator]();
대괄호는 Symbol.iterator라는 값을 프로퍼티 키로 사용하기 위한 문법이다. 'Symbol.iterator'라는 문자열 키와는 다르다.
위 코드는 Set의 값을 전부 읽는 코드가 아니다. 값을 하나씩 받을 Iterator를 얻는 코드다.
skills: Iterable인 Setskills[Symbol.iterator](): Iterator를 얻는 호출iterator: 다음 결과를 요청할 객체
배열, 문자열, Map, Set은 이 메서드를 제공하므로 Iterable로 사용할 수 있다. 메서드는 대상 자신의 프로퍼티일 필요는 없고, 프로토타입에서 상속받아도 된다.
4. next(): 다음 값과 종료 여부 받기
이 Set 예제에서는 Iterator를 얻는 것만으로 항목을 하나씩 받지는 않는다. 다음 항목 하나를 요청할 때 iterator.next()를 호출한다. 호출할 때마다 Iterator가 한 단계 진행하고, 값과 종료 여부를 담은 결과 객체를 반환한다. next()는 Set 자체가 아니라 Set에서 얻은 Iterator의 메서드다.
const skills = new Set(['JavaScript', 'React']);
const iterator = skills[Symbol.iterator]();
console.log(iterator.next());
// { value: 'JavaScript', done: false }
console.log(iterator.next());
// { value: 'React', done: false }
console.log(iterator.next());
// { value: undefined, done: true }
결과 객체에는 다음 정보가 담긴다.
| 프로퍼티 | 의미 |
|---|---|
value | 이번에 제공하는 값 |
done | 순회가 끝났는지 여부 |
보통은 next()를 직접 호출하지 않는다. for...of가 Iterator를 얻고 next()를 반복해서 호출한다. 직접 호출하면 한 항목을 읽은 뒤 멈췄다가 나중에 이어서 읽는 식으로 다음 값을 요청하는 시점을 정할 수 있다.
마지막 값을 제공할 때도 done은 false다. 위 예제에서 'React'를 받은 뒤 한 번 더 호출해야 종료 결과를 받는다. done: false가 다음 값의 존재를 보장하는 것은 아니다.
Iterator는 순회 진행 위치를 유지한다. 따라서 같은 Iterator의 next()를 호출하면 처음부터 다시 읽는 것이 아니라 이어서 진행한다.
Set의 처음부터 다시 순회하려면 [Symbol.iterator]()를 다시 호출해 새로운 Iterator를 얻으면 된다.
종료 결과에서는
value를 생략할 수도 있다. 또done: true일 때value가 반드시undefined여야 하는 것은 아니다. 다만for...of는 종료 결과의 값을 반복문 본문에 전달하지 않는다.
5. for...of는 이 과정을 대신한다
앞에서 직접 호출한 과정을 반복문으로 작성해보자.
const skills = new Set(['JavaScript', 'React']);
const iterator = skills[Symbol.iterator]();
while (true) {
const { value, done } = iterator.next();
if (done) break;
console.log(value);
}
여기서 하는 일은 다음과 같다.
[Symbol.iterator]()로 Iterator를 얻는다.next()로 다음 결과를 받는다.- 종료되지 않았다면
value를 사용한다. - 종료될 때까지 반복한다.
이 목적을 for...of로 표현할 수 있다.
for (const skill of skills) {
console.log(skill);
}
실제 for...of에는 조기 종료 시 Iterator에 return()이 있으면 호출하는 정리 처리 등이 추가로 있다. 위 while 예제는 값을 받는 핵심 흐름을 설명하기 위한 코드다.
Map의 기본 Iterator는 [키, 값] 배열을, Set의 기본 Iterator는 값을 제공한다. 결과의 내용은 달라도 소비하는 문법은 같다.
6. 직접 만든 객체도 순회할 수 있을까?
일반 객체는 기본적으로 Iterable이 아니다.
const range = { start: 1, end: 3 };
// for (const number of range) {}
// TypeError: range is not iterable
하지만 [Symbol.iterator]()가 Iterator를 반환하도록 구현하면 for...of를 사용할 수 있다.
const range = {
start: 1,
end: 3,
[Symbol.iterator]() {
let current = this.start;
const end = this.end;
return {
next() {
if (current <= end) {
return { value: current++, done: false };
}
return { done: true };
},
};
},
};
[Symbol.iterator]()는 새로운 Iterator를 만든다. 이때 만든 current는 next()가 호출되어도 유지되며, 현재 진행 위치를 기억한다.
next()는 범위 안에 있으면 값을 제공하고, 범위를 벗어나면 종료를 알린다.
for (const number of range) {
console.log(number);
}
// 1
// 2
// 3
console.log([...range]); // [1, 2, 3]
이 구현은 순회를 시작할 때마다 새로운 Iterator를 만들기 때문에 다시 순회하면 1부터 시작한다. 모든 Iterable이 반드시 반복해서 순회할 수 있는 것은 아니며, 이 동작은 구현에 따라 달라진다.
즉, 순회하려면 반드시 배열로 데이터를 저장해야 하는 것은 아니다. 순회 규약을 제공하면 기존 문법으로 값을 하나씩 사용할 수 있다.
복습 문제
1. Map과 Set은 무엇을 순회할까?
퀴즈. 콘솔에 무엇이 찍힐까?
const prices = new Map([
['apple', 1000],
['banana', 2000],
]);
prices.set('apple', 1500);
const sizes = new Set(['S', 'M', 'S']);
for (const [item, price] of prices) console.log(item, price);
for (const size of sizes) console.log(size);
정답 보기
apple 1500
banana 2000
S
M
Map에서 같은 키의 값을 다시 저장하면 값만 바뀌고 순서는 유지된다. Map의 기본 순회 결과는 [키, 값] 배열이다. Set은 중복된 'S'를 다시 추가하지 않고 값 자체를 순회한다.
2. 새 Iterator를 얻으면 어디서 시작할까?
퀴즈. 네 번의 console.log는 각각 무엇을 출력할까?
const letters = new Set(['A', 'B']);
const first = letters[Symbol.iterator]();
console.log(first.next());
const second = letters[Symbol.iterator]();
console.log(second.next());
console.log(first.next());
console.log(first.next());
정답 보기
{ value: 'A', done: false }
{ value: 'A', done: false }
{ value: 'B', done: false }
{ value: undefined, done: true }
first와 second는 별개의 Iterator다. 새로 얻은 second는 처음 값부터 시작하지만, first는 이미 'A'를 제공했으므로 다음 값인 'B'를 제공한다. first를 한 번 더 호출하면 순회가 끝났다는 결과를 받는다.
3. next()만 있으면 for...of를 사용할 수 있을까?
퀴즈. 다음 코드는 무엇을 출력할까?
const counter = {
current: 0,
next() {
return { value: ++this.current, done: false };
},
};
for (const number of counter) {
console.log(number);
break;
}
정답 보기
값을 출력하기 전에 TypeError가 발생한다. counter에는 next()가 있지만, for...of가 Iterator를 얻기 위해 호출할 [Symbol.iterator]()가 없다.
next()는 Iterator의 메서드이고, [Symbol.iterator]()는 Iterable이 제공해야 하는 메서드다. 반복문 본문에 break가 있어도 순회를 시작하기 전에 오류가 발생한다.
핵심 정리
- Map과 Set은 데이터를 저장하는 컬렉션이며, 저장 방식과 사용 목적이 다르다.
- Iterable은
[Symbol.iterator]()로 Iterator를 제공한다. - Iterator는
next()로 다음 값과 종료 여부를 제공한다. for...of는 이 규약을 사용해 데이터를 하나씩 소비한다.
Map과 Set은 같은 순회 규약을 제공하기 때문에 같은 문법으로 순회할 수 있다.