Iterable과 Iterator 프로토콜
for...of, spread, 구조 분해가 작동하는 원리 —Symbol.iterator와next()프로토콜을 이해하면 커스텀 순회 객체를 직접 만들 수 있다.
개요
for...of 루프로 배열을 순회할 수 있는 이유는 배열이 iterable이기 때문이다. 이 문서에서는 iterable과 iterator의 동작 원리를 살펴본다. 이를 이해하면 커스텀 iterable을 만들거나, matchAll 같은 메서드가 왜 for...of와 함께 쓰이는지 알 수 있다.
Iterable이란
배열, 문자열, Map, Set은 모두 데이터를 담는 자료구조지만, 내부 구조는 전부 다르다. 만약 자료구조마다 순회 방법이 달라야 한다면 코드가 복잡해진다. JavaScript는 이 문제를 iterable 프로토콜로 해결한다. 자료구조가 정해진 규칙(프로토콜)만 따르면, for...of 하나로 어떤 자료구조든 순회할 수 있다. 직접 만든 자료구조도 이 프로토콜만 구현하면 for...of, spread, 구조 분해와 바로 호환된다.
Iterable은 이 프로토콜을 구현한, 반복 가능한 객체를 말한다. JavaScript에서 for...of 루프로 순회할 수 있으려면 해당 객체가 iterable 프로토콜을 구현해야 한다.
기본적으로 iterable인 객체들이 있다:
- Array
- String
- Map
- Set
- TypedArray
- arguments
- NodeList
배열과 문자열을 for...of로 순회할 수 있는 이유가 바로 이것이다.
const arr = [1, 2, 3];
for (const item of arr) {
console.log(item);
}
위 코드를 실행하면 1, 2, 3이 차례로 출력된다. 배열이 iterable이기 때문에 for...of가 동작한다.
문자열도 마찬가지다:
const str = "hello";
for (const char of str) {
console.log(char);
}
문자열의 각 문자가 순회된다. 그런데 이게 어떻게 가능한 걸까?
Iterable 프로토콜의 동작 원리
객체가 iterable이 되려면 Symbol.iterator 메서드를 구현해야 한다. 이 메서드는 iterator 객체를 반환한다. 여기서 Symbol을 쓰는 이유는 기존 프로퍼티와의 이름 충돌을 피하기 위해서다. 일반 문자열 키 "iterator"를 쓰면 객체에 이미 같은 이름의 프로퍼티가 있을 수 있지만, Symbol은 유일한 값이므로 충돌이 발생하지 않는다.
for...of가 실행되면 내부적으로 다음 과정이 일어난다:
- 객체의
[Symbol.iterator]()메서드를 호출한다 - 반환된 iterator 객체의
next()메서드를 반복 호출한다 next()가{ done: true }를 반환하면 순회를 종료한다
직접 iterator를 꺼내서 확인해보자:
const arr = [1, 2, 3];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next()); // ?
console.log(iterator.next()); // ?
console.log(iterator.next()); // ?
console.log(iterator.next()); // ?
결과는 아래와 같다.
for...of는 이 과정을 자동으로 수행한다. 마지막에 done: true가 반환되면 루프가 종료된다.
Iterator 프로토콜
Iterator는 next() 메서드를 가진 객체다. next()는 항상 { value, done } 형태의 객체를 반환해야 한다.
| 속성 | 설명 |
|---|---|
value | 현재 순회 값 |
done | 순회 완료 여부. true면 더 이상 값이 없다 |
이 규칙만 따르면 어떤 객체든 iterable로 만들 수 있다.
커스텀 Iterable 만들기
1부터 3까지 순회하는 커스텀 iterable을 만들어보자:
const myIterable = {
[Symbol.iterator]() {
let count = 0;
return {
next() {
count++;
if (count <= 3) {
return { value: count, done: false };
}
return { value: undefined, done: true };
},
};
},
};
for (const num of myIterable) {
console.log(num);
}
위 코드를 실행하면 1, 2, 3이 출력된다.
[Symbol.iterator]() 메서드가 iterator 객체를 반환한다. 이 iterator의 next() 메서드가 호출될 때마다 count를 증가시키고, 3을 초과하면 done: true를 반환해서 순회를 종료한다.
Generator로 더 간단하게
Generator 함수를 사용하면 iterator를 더 쉽게 만들 수 있다. yield 키워드가 next() 호출마다 값을 반환하고 일시 정지한다.
const myIterable = {
*[Symbol.iterator]() {
yield 1;
yield 2;
yield 3;
},
};
for (const num of myIterable) {
console.log(num);
}
앞서 작성한 코드와 동일하게 동작하지만, 코드가 훨씬 간결하다. function* 문법과 yield가 iterator 프로토콜을 자동으로 구현해준다.
Iterable을 소비하는 문법들
for...of만 iterable을 사용하는 게 아니다. 다음 문법들은 모두 내부적으로 Symbol.iterator를 호출한다.
| 문법 | 동작 | 예시 |
|---|---|---|
for...of | 값을 하나씩 순회 | for (const x of arr) |
Spread (...) | iterable을 펼침 | [...arr], fn(...arr) |
| 구조 분해 할당 | 순서대로 변수에 할당 | const [a, b] = arr |
Array.from() | iterable을 배열로 변환 | Array.from(set) |
new Map() / new Set() | iterable로 초기화 | new Set([1, 2, 3]) |
Promise.all() | iterable의 Promise를 대기 | Promise.all(promises) |
각각 실행 결과를 확인해보자.
Spread 연산자
const arr = [1, 2, 3];
console.log([...arr]); // [1, 2, 3]
const str = "hello";
console.log([...str]); // ['h', 'e', 'l', 'l', 'o']
구조 분해 할당
const [a, b, c] = [1, 2, 3];
console.log(a, b, c); // 1 2 3
Array.from
const set = new Set([1, 2, 3]);
const arr = Array.from(set);
console.log(arr); // [1, 2, 3]
일반 객체는 Iterable이 아니다
주의할 점이 있다. 일반 객체는 기본적으로 iterable이 아니다.
const obj = { a: 1, b: 2 };
for (const item of obj) {
console.log(item);
}
// TypeError: obj is not iterable
객체를 순회하려면 Object.keys(), Object.values(), Object.entries()를 사용해야 한다. 이 메서드들은 배열을 반환하고, 배열은 iterable이다.
const obj = { a: 1, b: 2 };
for (const key of Object.keys(obj)) {
console.log(key);
}
// 'a', 'b'
for (const [key, value] of Object.entries(obj)) {
console.log(key, value);
}
// 'a' 1
// 'b' 2
Iterable과 Array-like는 다르다
비슷해 보이지만 다른 개념이 하나 더 있다. Array-like 객체는 length 속성과 인덱스 접근(obj[0])이 가능한 객체를 말한다. 하지만 Symbol.iterator가 없으면 iterable은 아니다.
const arrayLike = { 0: "a", 1: "b", length: 2 };
// 인덱스 접근은 가능하다
console.log(arrayLike[0]); // 'a'
// 하지만 for...of는 안 된다
for (const item of arrayLike) {
console.log(item);
}
// TypeError: arrayLike is not iterable
Array.from()은 iterable뿐 아니라 array-like 객체도 배열로 변환할 수 있다. 그래서 DOM의 document.querySelectorAll()이 반환하는 NodeList처럼 array-like이면서 iterable인 객체도, 순수 array-like 객체도 모두 Array.from()으로 처리할 수 있다.
for...in과 혼동하지 말 것
for...of와 비슷하게 생긴 for...in이 있다. 둘은 완전히 다르다.
| 구분 | for...in | for...of |
|---|---|---|
| 순회 대상 | 객체의 열거 가능한 속성 키 | iterable의 값 |
| 배열에 사용 시 | 인덱스(문자열)를 반환 | 요소 값을 반환 |
| 일반 객체 | 사용 가능 | TypeError |
배열에 for...in을 쓰면 값이 아닌 인덱스가 문자열로 나온다:
const arr = ["a", "b", "c"];
for (const item in arr) {
console.log(item, typeof item);
}
// '0' string
// '1' string
// '2' string
for...in은 프로토타입 체인의 속성까지 열거할 수 있어서 배열 순회에는 적합하지 않다. 배열을 순회할 때는 for...of를 사용한다.
정리
- Iterable은
Symbol.iterator메서드를 구현한 객체다 - Iterator는
next()메서드를 가진 객체이며,{ value, done }을 반환한다 for...of, spread, 구조 분해 등은 모두 iterable 프로토콜을 사용한다- Generator를 사용하면 iterator를 간단하게 구현할 수 있다
- 일반 객체는 iterable이 아니므로
Object.entries()등을 활용해야 한다