본문으로 건너뛰기

서로 다른 저장 방식, 같은 순회 문법

컬렉션은 여러 데이터를 모아 저장하고 관리하는 자료구조다. 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.iteratorIterator를 얻는 방법
next()다음 값과 종료 여부를 받는 방법

1. Map과 Set: 무엇을 저장하고 무엇을 순회할까?​

Map은 키와 값의 쌍을, Set은 중복 없는 값을 저장한다.

구분MapSet
저장 대상키와 값의 쌍중복 없는 값
추가·갱신set(key, value)add(value)
조회get(key)별도의 get() 없음
포함 여부has(key)has(value)
삭제delete(key)delete(value)
항목 수sizesize
기본 순회 결과[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에서의 예하는 일메서드
Iterablenew Set([1, 2])순회할 대상이며, Iterator를 제공한다.[Symbol.iterator]()
IteratorSet에서 얻은 읽기 객체현재 위치를 기억하고 다음 결과를 제공한다.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인 Set
  • skills[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);
}

여기서 하는 일은 다음과 같다.

  1. [Symbol.iterator]()로 Iterator를 얻는다.
  2. next()로 다음 결과를 받는다.
  3. 종료되지 않았다면 value를 사용한다.
  4. 종료될 때까지 반복한다.

이 목적을 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은 같은 순회 규약을 제공하기 때문에 같은 문법으로 순회할 수 있다.


참고​