JavaScript Iterations
for문부터 Generator까지, JavaScript 순회 메커니즘의 체계적 정리
읽는 데 62분
- #javascript
- #iteration
- #iterable
- #iterator
- #generator
- #for-of
- #loops
이 문서의 목차
적용 환경: ECMAScript 2015+ (ES6+)
JavaScript에는 반복을 위한 문법이 유독 많습니다. for, for...in, for...of, forEach, while— 처음 접하면 왜 이렇게 많은지, 각각 언제 써야 하는지 혼란스럽습니다. 여기에 Iterable, Iterator, Generator 같은 용어까지 등장하면 개념이 뒤섞이기 시작합니다.
하지만 이 개념들은 서로 독립적으로 존재하는 것이 아니라, 하나의 흐름 위에 놓여 있습니다. Loop는 반복 실행을 위한 기본 도구이고, Iterable은 "나는 순회할 수 있다"는 약속이며, Iterator는 그 순회를 실제로 수행하는 메커니즘이고, Generator는 Iterator를 간결하게 만들 수 있는 문법입니다. 앞 개념이 다음 개념의 토대가 되는 구조이므로, 순서대로 따라가면 자연스럽게 전체 그림이 그려집니다.
가장 기본적인 반복은 조건 기반의 명령형 루프입니다. for, while, do...while은 개발자가 반복 조건과 증감 로직을 직접 제어합니다.
const arr = ['a', 'b', 'c'];
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}이 방식은 인덱스를 직접 다루기 때문에 유연하지만, 배열의 값 자체에 관심이 있을 때는 불필요한 보일러플레이트가 됩니다. 인덱스 변수 i의 초기화, 비교, 증감을 매번 작성해야 하고, arr[i]로 간접 접근해야 합니다.
for...in은 객체의 열거 가능한(enumerable) 속성 키를 순회합니다. 객체의 프로퍼티를 탐색하기 위해 설계되었습니다.
const user = { name: 'mirunamu', role: 'developer', level: 5 };
for (const key in user) {
console.log(key, user[key]);
}문제는 for...in을 배열에 사용하는 경우입니다. 배열도 객체이므로 문법적으로는 동작하지만, 순회 대상이 값이 아니라 인덱스 문자열이며, 프로토타입 체인의 속성까지 열거될 수 있어 예상치 못한 결과가 발생합니다.
const arr = ['a', 'b', 'c'];
for (const key in arr) {
console.log(typeof key, key);
}인덱스가 숫자가 아니라 문자열 '0', '1', '2'로 나온다는 점을 주목해야 합니다. for...in은 속성 키를 열거하는 것이지 배열의 요소를 순회하는 것이 아니기 때문입니다.
for...of는 ES2015에서 도입되었으며, 값 자체를 순회합니다. 인덱스나 키가 아니라 컬렉션이 담고 있는 값을 하나씩 꺼내줍니다.
const arr = ['a', 'b', 'c'];
for (const value of arr) {
console.log(value);
}배열뿐만 아니라 문자열, Map, Set 등에서도 동작합니다.
for (const char of 'hello') {
console.log(char);
}
const set = new Set([1, 2, 3]);
for (const value of set) {
console.log(value);
}그런데 일반 객체에 for...of를 사용하면 에러가 발생합니다.
const obj = { a: 1, b: 2 };
for (const value of obj) {
// ❌ TypeError: obj is not iterable
}배열, 문자열, Set에서는 되는데 일반 객체에서는 안 되는 이유가 무엇일까요? 이 질문의 답이 바로 다음 섹션에서 다룰 Iterable 개념입니다.
// 객체의 열거 가능한 "속성 키"를 순회
const obj = { x: 10, y: 20, z: 30 };
for (const key in obj) {
console.log(key); // 'x', 'y', 'z' (문자열 키)
}
// 배열에 사용하면 인덱스 "문자열"을 순회
const arr = ['a', 'b'];
for (const key in arr) {
console.log(key); // '0', '1' (문자열!)
}// Iterable의 "값"을 순회
const arr = ['a', 'b', 'c'];
for (const value of arr) {
console.log(value); // 'a', 'b', 'c' (실제 값)
}
// 일반 객체에는 사용 불가
const obj = { x: 10 };
// for (const v of obj) {} // ❌ TypeError정리하면, for...in은 속성 키를 위한 것이고 for...of는 값을 위한 것입니다. 배열이나 컬렉션의 값을 순회할 때는 항상 for...of를 사용하는 것이 올바른 선택입니다.
배열에는 forEach, map, filter, reduce 같은 고차 함수도 있습니다. 이들은 배열 프로토타입에 정의된 메서드이며, Iterable 프로토콜과는 별개의 메커니즘입니다.
const nums = [1, 2, 3, 4, 5];
// forEach — 반환값 없이 부수효과 실행
nums.forEach(n => console.log(n));
// map — 변환된 새 배열 반환
const doubled = nums.map(n => n * 2);
console.log('doubled:', doubled);
// filter — 조건에 맞는 요소만 추출
const evens = nums.filter(n => n % 2 === 0);
console.log('evens:', evens);forEach는 Map과 Set에도 존재하지만, map, filter, reduce는 Array.prototype에만 정의되어 있으므로 배열이 아닌 컬렉션에서는 직접 사용할 수 없습니다. 반면 for...of는 Iterable이기만 하면 어디서든 동작한다는 차이가 있으며, 이 차이를 이해하려면 Iterable이 무엇인지 알아야 합니다.
for...of가 동작하려면 대상 객체가 Iterable 프로토콜을 구현해야 합니다. 이 프로토콜의 조건은 단 하나, Symbol.iterator라는 메서드를 갖고 있는 것입니다.
const arr = [1, 2, 3];
console.log('Array:', typeof arr[Symbol.iterator]); // 'function' ✅
const str = 'hello';
console.log('String:', typeof str[Symbol.iterator]); // 'function' ✅
const set = new Set([1, 2, 3]);
console.log('Set:', typeof set[Symbol.iterator]); // 'function' ✅
const obj = { a: 1 };
console.log('Object:', typeof obj[Symbol.iterator]); // 'undefined' ❌배열, 문자열, Set에는 Symbol.iterator가 존재하고, 일반 객체에는 없습니다. 앞 섹션에서 for...of가 일반 객체에서 에러를 던진 이유가 정확히 이것입니다. for...of는 내부적으로 대상의 Symbol.iterator를 호출하여 순회를 시작하는데, 이 메서드가 없으면 "is not iterable"이라는 TypeError가 발생합니다.
JavaScript에서 기본적으로 Iterable인 타입은 다음과 같습니다.
| 타입 | 순회 대상 |
|---|---|
Array | 배열 요소 |
String | 개별 문자 (유니코드 코드포인트 단위) |
Map | [key, value] 쌍 |
Set | 저장된 값 |
TypedArray | 버퍼의 숫자 요소 |
arguments | 함수에 전달된 인자 |
NodeList | DOM 요소 컬렉션 |
일반 객체({})가 이 목록에 없다는 점이 핵심입니다. 객체의 프로퍼티를 순회하고 싶다면 Object.keys(), Object.values(), Object.entries()를 사용하여 배열(Iterable)로 변환한 뒤 for...of에 넘기는 것이 일반적인 패턴입니다.
const config = { host: 'localhost', port: 3000, debug: true };
// Object.entries()로 [key, value] 배열을 만들면 for...of 사용 가능
for (const [key, value] of Object.entries(config)) {
console.log(`${key}: ${value}`);
}Symbol.iterator를 구현한 객체는 for...of 외에도 다양한 문법에서 사용할 수 있습니다. 이 문법들은 대상이 배열인지 Set인지 신경 쓰지 않으며, Iterable이기만 하면 동작합니다.
const set = new Set([1, 2, 3]);
// 스프레드
const arr = [...set]; // [1, 2, 3]
// 구조 분해
const [first, second] = set; // 1, 2
// Array.from
Array.from(set); // [1, 2, 3]
// Promise.all — 배열이 아닌 Iterable도 수용
const promises = new Set([fetch('/api/a'), fetch('/api/b')]);
Promise.all(promises);
// Map, Set 생성자
new Set('hello'); // Set {'h', 'e', 'l', 'o'} — 문자열은 Iterable이 문법들이 모두 Iterable 프로토콜을 기반으로 한다는 사실을 알면, [...str]이 문자열을 개별 문자 배열로 분해하는 이유나, new Set(arr)이 배열의 값으로 Set을 만드는 이유가 자연스럽게 이해됩니다.
Iterable vs Array-like
이 두 개념은 자주 혼동되지만 별개입니다.
- Iterable:
Symbol.iterator메서드를 가진 객체.for...of, 스프레드 가능. - Array-like:
length프로퍼티와 인덱스(0,1, ...)를 가진 객체.{ 0: 'a', 1: 'b', length: 2 }같은 형태.
arguments나 NodeList는 둘 다에 해당하지만, 직접 만든 { 0: 'x', length: 1 }은 Array-like이면서 Iterable은 아닙니다. Array.from()은 두 경우 모두 배열로 변환할 수 있는 유일한 방법입니다.
Iterable이 "나는 순회할 수 있다"라는 약속이라면, Iterator는 그 순회를 실제로 수행하는 객체입니다. Iterable의 Symbol.iterator() 메서드를 호출하면 Iterator가 반환되며, 이 Iterator는 next()라는 메서드를 가지고 있습니다. next()를 호출할 때마다 { value, done } 형태의 객체가 반환됩니다.
const arr = ['a', 'b', 'c'];
// Iterable에서 Iterator를 꺼냄
const iterator = arr[Symbol.iterator]();
// next()를 반복 호출하여 값을 하나씩 소비
console.log(iterator.next()); // { value: 'a', done: false }
console.log(iterator.next()); // { value: 'b', done: false }
console.log(iterator.next()); // { value: 'c', done: false }
console.log(iterator.next()); // { value: undefined, done: true }done이 true가 될 때까지 next()를 반복 호출하면 모든 값을 순회할 수 있습니다. 그리고 이것이 바로 for...of가 내부적으로 하는 일입니다.
for...of는 편의 문법일 뿐, 실제로는 Iterator 프로토콜을 사용합니다. 다음 두 코드는 완전히 동일한 동작을 합니다.
const arr = ['a', 'b', 'c'];
for (const value of arr) {
console.log(value);
}
// 'a', 'b', 'c'const arr = ['a', 'b', 'c'];
const iterator = arr[Symbol.iterator]();
let result = iterator.next();
while (!result.done) {
console.log(result.value);
result = iterator.next();
}
// 'a', 'b', 'c'for...of는 Symbol.iterator()를 호출하여 Iterator를 얻고, done이 true가 될 때까지 next()를 반복 호출하며, 각 반복에서 value를 변수에 바인딩합니다. 이 과정을 자동으로 처리해주는 것이 for...of의 역할입니다.
Symbol.iterator를 직접 구현하면 어떤 객체든 for...of에서 사용할 수 있습니다. 1부터 5까지의 숫자를 순회하는 range 객체를 만들어 보겠습니다.
const range = {
from: 1,
to: 5,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { done: true };
}
};
}
};
for (const n of range) {
console.log(n);
}
console.log([...range]);Symbol.iterator() 메서드가 next()를 가진 객체를 반환하고, next()가 호출될 때마다 현재 값을 돌려주다가 범위를 벗어나면 { done: true }를 반환합니다. 이 약속만 지키면 for...of, 스프레드, 구조 분해 등 모든 Iterable 소비 문법에서 동작합니다.
다만 직접 구현해보면 느낄 수 있듯이, next() 메서드와 { value, done } 객체를 매번 수동으로 관리하는 것은 상당히 번거롭습니다. 상태 추적 변수(current)를 클로저로 관리해야 하고, 반환 객체의 형태도 일일이 맞춰야 합니다. 이 불편함을 해소하기 위해 등장한 것이 Generator입니다.
Generator 함수는 function* 키워드로 선언하며, 내부에서 yield를 사용하여 값을 하나씩 내보냅니다. Generator 함수를 호출하면 코드가 즉시 실행되지 않고, Iterator 프로토콜을 구현한 Generator 객체가 반환됩니다.
function* genNumbers() {
yield 1;
yield 2;
yield 3;
}
const gen = genNumbers();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { done: true }yield를 만나면 실행이 일시 정지되고, next()가 호출되면 다음 yield까지 실행이 재개됩니다. { value, done } 객체를 직접 만들지 않아도 되고, 상태 변수를 클로저로 관리할 필요도 없습니다. JavaScript 엔진이 이 모든 것을 처리해줍니다.
앞에서 만든 range를 Generator로 다시 구현해보면 차이가 극명합니다.
const range = {
from: 1,
to: 5,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { done: true };
}
};
}
};function* range(from, to) {
for (let i = from; i <= to; i++) {
yield i;
}
}
// 사용법은 동일
for (const n of range(1, 5)) {
console.log(n); // 1, 2, 3, 4, 5
}
console.log([...range(1, 5)]); // [1, 2, 3, 4, 5]Generator 버전은 for 루프 안에 yield만 넣으면 끝입니다. Iterator 프로토콜의 구현 세부사항(next, value, done)이 모두 추상화되어 있으므로, 개발자는 "어떤 값을 어떤 순서로 내보낼지"에만 집중할 수 있습니다.
Generator 함수가 반환하는 객체는 Iterable이면서 동시에 Iterator라는 특별한 성질을 가집니다. Symbol.iterator()를 호출하면 자기 자신을 반환합니다.
function* gen() {
yield 1;
yield 2;
}
const g = gen();
// Iterator이므로 next()를 호출할 수 있음
console.log('typeof g.next:', typeof g.next); // 'function'
// Iterable이므로 Symbol.iterator가 자기 자신을 반환
console.log('g[Symbol.iterator]() === g:', g[Symbol.iterator]() === g); // true이 덕분에 Generator 객체는 for...of, 스프레드, 구조 분해 등 Iterable을 소비하는 모든 문법에서 바로 사용할 수 있습니다.
Generator의 실질적인 강점은 지연 평가(Lazy Evaluation)입니다. 값을 미리 전부 계산해서 메모리에 올려두는 것이 아니라, next()가 호출될 때마다 다음 값을 그때그때 생성합니다. 이 특성 덕분에 무한 시퀀스도 메모리 문제 없이 표현할 수 있습니다.
function* naturals() {
let n = 1;
while (true) {
yield n++;
}
}
// 무한 시퀀스지만 5개만 꺼내면 5번만 실행됨
const iterator = naturals();
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: 4, done: false }
console.log(iterator.next()); // { value: 5, done: false }while (true) 안에 yield가 있으므로 무한 루프처럼 보이지만, 실제로는 next()가 호출될 때만 다음 yield까지 실행됩니다. 소비하는 쪽에서 필요한 만큼만 값을 가져갈 수 있다는 점이 배열과의 근본적인 차이입니다.
이를 활용하면 Generator를 조합하여 데이터 처리 파이프라인을 구성할 수도 있습니다.
function* naturals() {
let n = 1;
while (true) { yield n++; }
}
function* take(iterable, count) {
let i = 0;
for (const value of iterable) {
if (i++ >= count) return;
yield value;
}
}
function* filter(iterable, predicate) {
for (const value of iterable) {
if (predicate(value)) yield value;
}
}
// 무한 자연수 → 짝수만 필터 → 5개만 추출
const result = [...take(filter(naturals(), n => n % 2 === 0), 5)];
console.log(result); // [2, 4, 6, 8, 10]배열의 filter().slice()는 각 단계마다 중간 배열을 생성하지만, Generator 파이프라인은 값 하나가 전체 체인을 통과한 뒤 다음 값으로 넘어가므로 중간 할당이 없습니다.
yield*는 다른 Iterable의 값을 현재 Generator에서 그대로 내보내는 문법입니다. Generator를 중첩하거나 기존 Iterable과 조합할 때 유용합니다.
function* concat(...iterables) {
for (const iterable of iterables) {
yield* iterable;
}
}
const result = [...concat([1, 2], new Set([3, 4]), 'ab')];
console.log(result); // [1, 2, 3, 4, 'a', 'b']yield*가 없었다면 내부에서 다시 for...of로 순회하며 개별 yield를 작성해야 했을 것입니다. yield*는 이 과정을 한 줄로 위임합니다.
네 가지 개념의 관계
- Loop (
for,for...in,for...of,while): 코드를 반복 실행하기 위한 언어 문법입니다. 이 중for...of만 Iterable 프로토콜을 기반으로 동작합니다. - Iterable:
Symbol.iterator메서드를 가진 객체입니다.for...of, 스프레드, 구조 분해 등에서 순회 대상으로 사용될 수 있다는 약속입니다. - Iterator:
next()메서드를 가진 객체로, 호출할 때마다{ value, done }을 반환합니다. 순회를 실제로 수행하는 커서 역할입니다. - Generator:
function*과yield로 Iterator를 선언적으로 만드는 문법입니다. 상태 관리와 프로토콜 구현을 자동화하며, 지연 평가를 가능하게 합니다.
관련 문서
글쓴이 mirunamu00



