JS 동작원리 2 (프로토타입, 이터레이터/제너레이터)
프로토타입 & 프로토타입 체인
JS는 클래스 기반 언어처럼 보이지만, 사실 내부적으로는 프로토타입 기반으로 동작한다. 모든 객체는 자신의 부모 역할을 하는 다른 객체(프로토타입)를 참조하고 있고, 이 참조를 통해 속성과 메서드를 공유한다.
프로토타입 기본 개념
const user = { name: "gori" };
console.log(user.toString); // 함수가 출력됨 (user에는 toString을 직접 정의한 적 없음)user 객체에는 toString을 직접 만든 적이 없는데도 호출할 수 있는 이유는, user의 프로토타입인 Object.prototype에 toString이 정의되어 있고, JS 엔진이 user 자신에게 없는 속성을 찾을 때 프로토타입을 따라 올라가며 찾기 때문이다.
함수와 prototype 속성
함수를 생성자로 사용하면, 그 함수가 만들어내는 인스턴스들이 공유할 메서드를 prototype 속성에 정의할 수 있다.
function User(name) {
this.name = name;
}
User.prototype.greet = function () {
console.log(`안녕하세요, ${this.name}입니다`);
};
const gori = new User("gori");
gori.greet(); // 안녕하세요, gori입니다gori 객체 자신에게는 greet 메서드가 없지만, gori.__proto__(내부적으로 [[Prototype]])가 User.prototype을 가리키고 있어서 그 안에 정의된 greet를 사용할 수 있다.
프로토타입 체인
객체에서 속성을 찾을 때, 해당 객체에 없으면 프로토타입을 따라 계속 위로 올라가며 찾는 연결 구조를 프로토타입 체인이라고 한다. 이 체인은 결국 Object.prototype까지 이어지고, 그 다음은 null이다.
console.log(gori.__proto__ === User.prototype); // true
console.log(User.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__); // null (체인의 끝)gori 객체 → User.prototype → Object.prototype → null클래스 문법은 프로토타입 문법의 편의 도구
“클래스와 모듈” 글에서 본 class 문법은 사실 이 프로토타입 기반 상속을 더 쉽게 쓸 수 있게 해주는 문법일 뿐이다.
class User {
constructor(name) {
this.name = name;
}
greet() {
console.log(`안녕하세요, ${this.name}입니다`);
}
}
const gori = new User("gori");
// 아래는 위 class 문법과 사실상 동일하게 동작한다
console.log(typeof User); // "function" (클래스도 결국 함수)
console.log(gori.__proto__ === User.prototype); // true클래스의 extends로 상속을 구현하는 것도 내부적으로는 프로토타입 체인을 연결하는 작업이다.
hasOwnProperty — 프로토타입 체인 값인지 구분하기
console.log(gori.hasOwnProperty("name")); // true (자신의 속성)
console.log(gori.hasOwnProperty("greet")); // false (프로토타입 체인에서 온 속성)이벤트 루프 (Event Loop) 상세
“비동기” 글에서 마이크로태스크 큐가 태스크 큐보다 먼저 처리된다는 규칙을 다뤘는데, 여기서는 그 규칙이 왜 그렇게 동작하는지 전체 구조를 살펴본다.
이벤트 루프를 구성하는 요소
- 콜 스택: 현재 실행 중인 코드를 관리
- Web API:
setTimeout,fetch, DOM 이벤트 등 브라우저가 제공하는 기능들이 처리되는 공간 - 태스크 큐(매크로태스크 큐):
setTimeout,setInterval, 이벤트 핸들러의 콜백이 대기 - 마이크로태스크 큐: Promise의
then/catch/finally콜백이 대기 - 이벤트 루프: 콜 스택이 비어있는지 계속 확인하고, 비어있으면 큐에서 작업을 꺼내 콜 스택으로 옮기는 역할 동작 순서
- 동기 코드가 콜 스택에서 순서대로 실행된다.
- 비동기 함수(
setTimeout,fetch등)를 만나면, 그 작업은 Web API로 위임되고 콜 스택에서는 바로 제거된다. - Web API에서 작업이 끝나면, 결과 콜백이 태스크 큐 또는 마이크로태스크 큐로 이동한다.
- 콜 스택이 완전히 비면, 이벤트 루프가 마이크로태스크 큐를 모두 처리한다. (하나 처리할 때마다 큐를 다시 확인해서, 처리 도중 새로 추가된 마이크로태스크도 모두 비운다)
- 마이크로태스크 큐가 완전히 빈 뒤에야, 태스크 큐에서 작업을 하나만 꺼내 콜 스택으로 옮겨 실행한다.
- 다시 콜 스택이 비면 4번으로 돌아가 반복한다. 예제로 순서 확인하기
console.log("1: 동기 코드");
setTimeout(() => {
console.log("2: 태스크 큐 (setTimeout)");
}, 0);
Promise.resolve().then(() => {
console.log("3: 마이크로태스크 큐 (Promise)");
}).then(() => {
console.log("4: 마이크로태스크 큐 (Promise 체이닝)");
});
console.log("5: 동기 코드");
// 실행 순서: 1 5 3 4 21,5: 동기 코드이므로 가장 먼저 실행된다.3,4: 마이크로태스크 큐에 쌓인 두 개의then이, 콜 스택이 빈 직후 태스크 큐보다 먼저 모두 처리된다. (체이닝된then이 여러 개여도 태스크 큐 작업 하나보다 전부 우선한다)2: 마이크로태스크 큐가 완전히 빈 뒤에야 태스크 큐의setTimeout콜백이 실행된다. 왜 이렇게 설계됐을까
마이크로태스크(주로 Promise)를 태스크보다 먼저, 그리고 큐가 빌 때까지 모두 처리하도록 만든 건 Promise 체이닝이 최대한 빠르고 예측 가능하게 완료되도록 하기 위해서다. 만약 마이크로태스크 사이사이에 렌더링이나 다른 태스크가 끼어든다면, 연속된 비동기 흐름이 뒤섞여서 디버깅이 훨씬 어려워질 수 있다.
이터레이터 & 제너레이터 (Symbol.iterator, for…of, function*, yield)
이터러블(Iterable)과 이터레이터(Iterator)
“조건문과 반복문” 글에서 for...of는 배열, 문자열처럼 iterable한 객체만 순회할 수 있다고 언급했는데, 여기서 iterable의 정확한 의미를 살펴본다.
어떤 객체가 Symbol.iterator라는 특수한 메서드를 가지고 있고, 그 메서드가 이터레이터 프로토콜을 따르는 객체(next() 메서드를 가진 객체)를 반환하면, 그 객체는 iterable이 된다.
const arr = [10, 20, 30];
const iterator = arr[Symbol.iterator](); // 배열의 이터레이터 얻기
console.log(iterator.next()); // { value: 10, done: false }
console.log(iterator.next()); // { value: 20, done: false }
console.log(iterator.next()); // { value: 30, done: false }
console.log(iterator.next()); // { value: undefined, done: true }for...of는 내부적으로 이 next()를 done이 true가 될 때까지 반복 호출하는 것과 같다.
직접 iterable 객체 만들기
일반 객체({})는 기본적으로 Symbol.iterator가 없어서 for...of로 순회할 수 없다. 직접 구현하면 가능해진다.
const numberRange = {
from: 1,
to: 3,
[Symbol.iterator]() {
let current = this.from;
const last = this.to;
return {
next() {
if (current <= last) {
return { value: current++, done: false };
}
return { value: undefined, done: true };
}
};
}
};
for (const num of numberRange) {
console.log(num); // 1 2 3
}제너레이터 (Generator)
제너레이터는 이터레이터를 훨씬 간결하게 만들 수 있게 해주는 함수다. function*으로 선언하고, yield 키워드로 값을 하나씩 내보낸다.
function* numberGenerator() {
yield 1;
yield 2;
yield 3;
}
const gen = numberGenerator();
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를 만날 때까지 실행되고 거기서 멈춘다.
제너레이터로 위의 numberRange를 다시 구현하면
function* numberRangeGenerator(from, to) {
for (let i = from; i <= to; i++) {
yield i;
}
}
for (const num of numberRangeGenerator(1, 3)) {
console.log(num); // 1 2 3
}Symbol.iterator와 next()를 직접 구현했던 이전 방식보다 훨씬 짧고 읽기 쉽다. 제너레이터 함수 자체가 이터러블 프로토콜을 자동으로 만족시키기 때문이다.
무한 시퀀스 만들기
제너레이터는 필요한 만큼만 값을 꺼내 쓸 수 있어서, 끝나지 않는 값을 표현하는 데도 유용하다.
function* infiniteCounter() {
let count = 1;
while (true) {
yield count++;
}
}
const counter = infiniteCounter();
console.log(counter.next().value); // 1
console.log(counter.next().value); // 2
console.log(counter.next().value); // 3
// 필요한 만큼만 next()를 호출하면 되고, 무한 루프에 빠지지 않는다