들어가며
프로그래밍을 하다 보면 데이터를 순회하는 일이 정말 많습니다.
배열이든 객체든 트리든 상관없이 "다음 요소를 던져줘"라는 동일한 방식으로 데이터에 접근할 수 있다면 얼마나 좋을까?
-라는 필요에서 탄생한 것이 바로 반복자 패턴입니다.
오늘은 반복자 패턴이 무엇인지, 그리고 이 패턴이 일급 함수와 만나 어떻게 현대 함수형 프로그래밍의 토대가 되었는지 JavaScript 예시와 함께 살펴보겠습니다.
반복자 패턴이란?
반복자 패턴은 컬렉션의 내부 구조를 노출하지 않으면서 순차적으로 내부 요소들에 접근할 수 있게 해주는 디자인 패턴입니다.
어떤 형식의 자료구조든 "다음 요소"를 알 수 있어서 동일한 방식으로 데이터 순회가 가능하게 만들어줍니다.
컬렉션: 배열, 리스트, 트리 등 여러 데이터를 담는 자료구조
간단한 반복자를 직접 구현해보면 다음과 같습니다.
// 간단한 반복자 구현
class ArrayIterator {
constructor(array) {
this.array = array;
this.index = 0;
}
hasNext() {
return this.index < this.array.length;
}
next() {
if (this.hasNext()) {
return this.array[this.index++];
}
return null;
}
}
// 사용 예시
const numbers = [1, 2, 3, 4, 5];
const iterator = new ArrayIterator(numbers);
while (iterator.hasNext()) {
console.log(iterator.next()); // 1, 2, 3, 4, 5 순서대로 출력
}
하지만 현대 JavaScript에서는 이미 반복자 패턴이 언어 차원에서 지원되고 있습니다.
// JavaScript의 내장 반복자
const numbers = [1, 2, 3, 4, 5];
// for...of 문 (내부적으로 반복자 사용)
for (const num of numbers) {
console.log(num);
}
// 직접 반복자 사용
const iterator = numbers[Symbol.iterator]();
console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
우리가 흔히 사용하는 for...of 문이 내부적으로 반복자를 사용하고 있었던 것입니다.
JavaScript에는 이미 이 패턴이 깊숙이 내장되어 있어서 의식하지 않고도 매일 사용하고 있었던 셈이죠!
제너레이터와 반복자 프로토콜
제너레이터 함수는 반복자를 쉽게 만들 수 있는 JavaScript의 현대적인 방법입니다.
클래스로 반복자를 만들면 코드가 꽤 길어지지만, 제너레이터를 사용하면 훨씬 간단해집니다.
// 제너레이터로 간단하게 구현
function* countUp(start, end) {
for (let i = start; i <= end; i++) {
yield i;
}
}
이게 가능한 이유는 제너레이터 함수가 내부적으로 반복자 프로토콜을 따르는 객체를 자동으로 만들어주기 때문입니다.
JavaScript에서 정의한 반복자 프로토콜을 살펴보면,
// 반복자 객체는 next() 메서드를 가져야 하고
// next()는 {value: 값, done: boolean} 형태의 객체를 반환해야 함
const iterator = {
current: 1,
end: 3,
next() {
if (this.current <= this.end) {
return { value: this.current++, done: false };
} else {
return { value: undefined, done: true };
}
}
};
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: undefined, done: true}
제너레이터는 이 규칙을 자동으로 따라줍니다.
function* simpleGenerator() {
yield 1;
yield 2;
yield 3;
}
const gen = simpleGenerator();
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()); // {value: undefined, done: true}
직접 next() 메서드를 구현하지 않아도 yield 키워드가 알아서 반복자 프로토콜에 맞는 객체를 반환해주는 것입니다.
GoF 디자인 패턴에서의 반복자
반복자 패턴은 GoF(Gang of Four)가 정리한 23개의 디자인 패턴 중 하나입니다.
GoF는 1995년에 "Design Patterns: Elements of Reusable Object-Oriented Software"라는 책을 통해 재사용 가능한 객체지향 디자인 패턴들을 체계화했습니다.
GoF는 디자인 패턴을 3가지 카테고리로 분류했는데, 반복자 패턴은 행동 패턴에 속합니다.
- 생성 패턴: 객체 생성 관련
- 구조 패턴: 객체 구성 관련
- 행동 패턴: 객체 간 상호작용 관련
GoF에서 정의한 반복자 패턴의 핵심 목적은 "집합 객체의 내부 표현을 노출하지 않고 순차적으로 접근할 수 있는 방법을 제공한다"였습니다.
예시를 통해 이 문제를 살펴보겠습니다.
// 문제 상황: 서로 다른 데이터 구조들
class BookList {
constructor() {
this.books = []; // 배열로 저장
}
addBook(book) { this.books.push(book); }
}
class StudentList {
constructor() {
this.students = {}; // 객체로 저장
this.count = 0;
}
addStudent(student) {
this.students[this.count++] = student;
}
}
// 각각 다른 방식으로 순회해야 함
const bookList = new BookList();
for (let i = 0; i < bookList.books.length; i++) {
console.log(bookList.books[i]); // 배열 방식
}
const studentList = new StudentList();
for (let key in studentList.students) {
console.log(studentList.students[key]); // 객체 방식
}
반복자 패턴을 적용하면 이 문제를 해결할 수 있습니다.
// 공통 반복자 인터페이스
class Iterator {
hasNext() { throw new Error("구현 필요"); }
next() { throw new Error("구현 필요"); }
}
// BookList용 반복자
class BookIterator extends Iterator {
constructor(books) {
super();
this.books = books;
this.index = 0;
}
hasNext() {
return this.index < this.books.length;
}
next() {
return this.books[this.index++];
}
}
// StudentList용 반복자
class StudentIterator extends Iterator {
constructor(students) {
super();
this.students = students;
this.keys = Object.keys(students);
this.index = 0;
}
hasNext() {
return this.index < this.keys.length;
}
next() {
const key = this.keys[this.index++];
return this.students[key];
}
}
이제 컬렉션 클래스들을 개선할 수 있습니다.
// 개선된 컬렉션 클래스들
class BookList {
constructor() {
this.books = [];
}
addBook(book) { this.books.push(book); }
createIterator() {
return new BookIterator(this.books);
}
// createIterator()는 '팩토리 메서드 패턴'으로,
// 캡슐화를 유지하면서 적절한 반복자 객체를 생성합니다.
}
class StudentList {
constructor() {
this.students = {};
this.count = 0;
}
addStudent(student) {
this.students[this.count++] = student;
}
createIterator() {
return new StudentIterator(this.students);
}
}
// 이제 동일한 방식으로 순회 가능!
function printAll(collection) {
const iterator = collection.createIterator();
while (iterator.hasNext()) {
console.log(iterator.next());
}
}
const bookList = new BookList();
const studentList = new StudentList();
printAll(bookList); // 동일한 방식
printAll(studentList); // 동일한 방식
이렇게 반복자 패턴은 객체지향의 핵심 원리들을 구현합니다.
- 캡슐화: BookList와 StudentList의 내부 구조(배열 vs 객체)를 숨김
- 추상화: 모든 반복자가 동일한 인터페이스(hasNext, next)를 제공
- 다형성: printAll 함수가 구체적인 타입을 몰라도 동작
반복자 패턴과 일급 함수의 만남
반복자 패턴이 일급 함수와 만나면서 함수형 리스트 프로세싱의 기초가 마련되었습니다.
먼저 일급 함수가 무엇인지 알아보겠습니다.
일급 함수(First-class Function): 함수를 값처럼 다룰 수 있다는 의미
// 함수를 변수에 저장
const double = (x) => x * 2;
// 함수를 인수로 전달
function applyToArray(arr, fn) {
const result = [];
for (let item of arr) {
result.push(fn(item));
}
return result;
}
// 사용
const numbers = [1, 2, 3, 4, 5];
const doubled = applyToArray(numbers, double); // [2, 4, 6, 8, 10]
반복자 패턴과 일급 함수를 조합하면 다음과 같습니다.
// 반복자 + 함수 조합
class FunctionalIterator {
constructor(array) {
this.array = array;
}
// 함수를 받아서 변환하면서 반복
map(fn) { // fn이 일급 함수
const result = [];
for (let i = 0; i < this.array.length; i++) { // 반복자 패턴: 순차적 접근
result.push(fn(this.array[i])); // 함수를 값처럼 사용
}
return result;
}
// 함수를 받아서 필터링하면서 반복
filter(fn) {
const result = [];
for (let i = 0; i < this.array.length; i++) {
const item = this.array[i];
if (fn(item)) {
result.push(item);
}
}
return result;
}
}
반복자의 순회 능력과 일급 함수의 유연성이 합쳐져서 현대 함수형 프로그래밍의 `map`, `filter` 같은 고차 함수들이 탄생하게 된 것입니다.
실제로 JavaScript의 `Array.prototype.map()`, `filter()`, `reduce()` 등이 모두 이런 개념에서 출발한 것이죠.
반복자 패턴으로 데이터를 순회하고, 일급 함수를 매개변수로 받아 다양한 변환 작업을 수행하는 방식입니다.
마치며
JavaScript를 사용하면서 for...of문이나 Array.prototype.map() 같은 메서드들을 자연스럽게 사용하고 있었는데, 이 모든 것들이 반복자 패턴이라는 디자인 패턴에서 출발했다는 점이 흥미롭습니다.
평소에 당연하게 쓰던 기능들 뒤에 이런 설계 철학이 숨어있었다니, 새삼 신기했어요.
앞으로 코드를 작성할 때 이런 패턴들의 의미를 생각해보며 더 깊이 있게 프로그래밍할 수 있을 것 같습니다.