1. 프로토타입 체인과 클래스
1.1 프로토타입 체인
프로토타입이란
JavaScript의 모든 객체는 자신을 만들어준 부모 객체를 하나 갖는다. 이 부모 객체를 프로토타입(prototype) 이라고 부르며, 모든 객체 내부에는 부모를 가리키는 숨겨진 링크 [[Prototype]]이 존재한다. 이 링크는 __proto__ 또는 Object.getPrototypeOf(obj)로 접근할 수 있다.
만약 Person이라는 생성자 함수가 있다고 가정할 때, 이 Person의 프로토타입은 Person.prototype이다.
하지만 각 프로토타입들은 부모가 있다. Person.prototype의 부모역할은 Object.prototype이고, 함수 객체의 프로토타입인 Function.prototype의 부모 역할도 Object.prototype이다.
그리고 지역 변수에서 전역 변수로 순차적으로 검색해가듯이 프로토타입에 프로퍼티가 없다면 부모 역할을 하는 프로토타입으로 순차적으로 검색한다. 이것은 곧 프로토타입 체인이다.
곧, 모든 체인을 올라가면 Object.prototype이 존재한다.
프로토타입 체인의 검색 과정
객체의 프로퍼티에 접근하면 엔진은 다음 순서로 검색한다.
- 객체 자신에게 해당 프로퍼티가 있는지 본다.
- 없으면 __proto__를 따라 부모로 올라간다.
- 부모에도 없으면 또 그 부모로 올라간다.
- 최종적으로 Object.prototype까지 갔는데도 없으면 undefined를 반환한다.
- Object.prototype의 __proto__는 null이므로 거기서 체인이 끝난다.
const animal = { eats: true };
const rabbit = { jumps: true };
Object.setPrototypeOf(rabbit, animal);
// → rabbit.__proto__ = animal 과 같은 의미
// → 이제 rabbit은 animal을 부모로 가진다
console.log(rabbit.jumps); // true (자기 자신)
console.log(rabbit.eats); // true (animal에서 찾음)
console.log(rabbit.flies); // undefined (체인 끝까지 없음)
1.2 class와 프로토 타입
class는 Java, C# 같은 언어를 쓰던 사람들이 좀 더 익숙하게 쓸 수 있도록 만든 편한 문법이다.
class User {
constructor(name) { this.name = name; }
greet() { console.log(`Hi, ${this.name}`); }
}
// 위 코드는 사실상 아래와 동일
function User(name) { this.name = name; }
User.prototype.greet = function() { console.log(`Hi, ${this.name}`); };
class 키워드는 프로토타입 기반 상속을 다른 언어 사용자에게 익숙한 문법으로 감싼 것일 뿐, 본질은 그대로 프로토타입이다. 다만 class에는 실질적 차이가 있다:
- 반드시 new로만 호출 가능 (함수처럼 못 씀)
- 메서드는 열거 불가능(non-enumerable)
- strict mode가 자동 적용
- 호이스팅은 되지만 TDZ에 갇힘 → 코드 실행 전에 선언이 위로 끌어당겨지긴 하지만 선언문에 도달하기 전까지는 결국 사용할 수 없다. 고로 TDZ에 갇힌다.
2. 이벤트 루프
JavaScript는 싱글 스레드다. 그런데 어떻게 네트워크 요청, 타이머, 이벤트를 동시에 처리하는 것처럼 보일까?
답은 이벤트 루프와 큐에 있다.
2.1 구성 요소

- Call Stack: 현재 실행 중인 함수.
- Task Queue (Macrotask): setTimeout, setInterval, setImmediate(Node), I/O, DOM 이벤트 콜백.
- Microtask Queue: Promise .then/.catch/.finally, queueMicrotask, MutationObserver.
- Web APIs / Host: 타이머, fetch 등은 브라우저/Node가 처리하고, 완료되면 해당 큐에 콜백을 넣는다.
이벤트 루프 시각화 : `console.log`, `setTimeout`, `Promise.then`, `queueMicrotask`를 섞어 실행 순서를 예측하고 실제 결과와 비교
예시 코드
console.log('1: script start');
setTimeout(() => {
console.log('2: setTimeout');
}, 0);
Promise.resolve().then(() => {
console.log('3: promise.then');
});
queueMicrotask(() => {
console.log('4: queueMicrotask');
});
console.log('5: script end');
예상 : 1 → 5 → 3 → 4 → 2 (그리고 1 → 5 / 3 → 4 / 2 로 구분된다고 생각한다.)
결과 : 1 → 5 → 3 → 4 → 2

2.2 이벤트 루프의 규칙
매 틱마다 엔진은 다음을 반복한다:
- Call Stack에 실행 중인 것이 있으면 끝까지 실행
- Stack이 비면, Microtask Queue를 완전히 비울 때까지 전부 처리
- 그 다음 Task Queue에서 하나를 꺼내 Stack으로
- (브라우저) 필요하면 렌더링
- 반복
핵심: 매크로태스크 하나 끝나면 마이크로태스크를 싹 비운다. 마이크로태스크가 매크로태스크보다 항상 먼저 나간다
3. 심화 주제
3.1 setTimeout(fn, 0)와 Promise.resolve().then(fn)의 실행 순서 차이
둘 다 다음 틱에 실행처럼 보이지만 들어가는 큐가 다르다.
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('end');
// 출력: start → end → promise → timeout
원리
- setTimeout(fn, 0) → Task Queue (Macrotask) 에 등록
- Promise.resolve().then(fn) → Microtask Queue 에 등록
이벤트 루프 규칙상 Stack이 비면 Microtask Queue를 전부 비운 뒤에야 Task Queue의 하나를 꺼낸다. 그래서 promise가 항상 timeout보다 먼저다.
3.2 Hono 미들웨어 체인과 클로저
Hono는 경량 웹 프레임워크로 미들웨어 체인으로 요청을 처리한다.
이 체인이 클로저로 동작하며, 이를 통해 next()가 왜 다음 미들웨어를 불러낼 수 있는지 알 수 있다.
사용 모습
import { Hono } from 'hono';
const app = new Hono();
app.use(async (c, next) => {
console.log('A start');
await next();
console.log('A end');
});
app.use(async (c, next) => {
console.log('B start');
await next();
console.log('B end');
});
app.get('/', (c) => {
console.log('handler');
return c.text('hello');
});
// 출력 결과:
// A start
// B start
// handler
// B end
// A end
양파 모델이라고 부르는 이 패턴에서 next()는 다음 미들웨어를 호출한다.
그런데 각 미들웨어 함수는 자기가 체인의 몇 번째인지 모른다.
app.use는 사실 등록하는 함수다
app.use(...)는 미들웨어를 즉시 실행하는 게 아니라, 내부 배열에 순서대로 집어넣는 역할만 한다.
app.use(A); // 등록만 함, 실행 X
app.use(B); // 등록만 함, 실행 X
app.get('/', handler); // 등록만 함, 실행 X
즉, 다음 미들웨어가 누구인가 를 결정하는 건
미들웨어 자신도 아니고, next 함수도 아니고, 개발자가 app.use를 호출한 순서다.
클로저를 사용해야하는 이유
각 next는 다음 인덱스라는 서로 다른 값을 기억해야 한다. 하나의 next 함수가 아니라, 매 호출마다 생성되는 새로운 클로저다. i가 상수처럼 각 next에 박혀있는 효과다.
// i = 0일 때 생성된 next는 runAt(1)을 호출하는 클로저
// i = 1일 때 생성된 next는 runAt(2)를 호출하는 클로저
// 각자 자기만의 i+1을 기억
var로 했다면 i가 공유되어 망가지고, let이라 각 반복에서 별개의 바인딩이 만들어지는 것과 본질적으로 같은 원리다.
next()는
- 다음 미들웨어라는 의미를 가진 특별한 문법이 아니다.
- 프레임워크가 매 미들웨어마다 새로 생성한 함수다.
- 그 함수는 배열의 어느 위치에서 다시 시작할지를 클로저로 들고 있다.
Express의 next(), Redux 미들웨어의 next(action), Koa의 ctx/next까지 전부 같은 패턴이다. 클로저를 모르면 "그냥 그런가 보다" 하고 쓰지만, 알면 직접 구현할 수도 있고 디버깅할 때도 훨씬 명확해진다.
- Hono 미들웨어 : 요청 로깅 미들웨어와 요청-응답 시간 측정 미들웨어 작성해보기
async (c, next) => { ... }
c : Context → 요청/응답 정보가 담긴 객체
next : 다음 미들웨어를 실행하는 함수
1. 들어온 요청에 대한 메서드와 경로를 알려주는 미들웨어 + 요청 횟수도 같이 보여줌
export function requestLogger() {
let count = 0; // 요청 횟수
return async (c, next) => {
count++;
console.log(`[${count}번째 요청] ${c.req.method} ${c.req.path}`);
await next();
};
}
→ requestLogger 함수는 선언 후 끝인데, 내부에 count 함수는 계속 살아있다.
2. 요청을 처리하는데 소요된 시간을 측정해보는 미들웨어
export function responseTime() {
return async (c, next) => {
const start = Date.now();
await next();
const time = Date.now() - start;
console.log(`처리시간: ${time}ms`);
};
}
→ await next()가 일시정지 후 다시 재개되는데 그 사이에도 start 변수는 그대로 남아있다. 고로 비동기 함수가 멈췄다 재개되어도 지역변수가 유지된다. → 비동기와 클로저가 함께 동작하는 모습