본문 바로가기
Note/개념

객체 모델과 비동기 실행

by sosomaneya 2026. 5. 25.
728x90

1. 프로토타입 체인과 클래스

1.1 프로토타입 체인

프로토타입이란

JavaScript의 모든 객체는 자신을 만들어준 부모 객체를 하나 갖는다. 이 부모 객체를 프로토타입(prototype) 이라고 부르며, 모든 객체 내부에는 부모를 가리키는 숨겨진 링크 [[Prototype]]이 존재한다. 이 링크는 __proto__ 또는 Object.getPrototypeOf(obj)로 접근할 수 있다.

 

만약 Person이라는 생성자 함수가 있다고 가정할 때, 이 Person의 프로토타입은 Person.prototype이다.

하지만 각 프로토타입들은 부모가 있다. Person.prototype의 부모역할은 Object.prototype이고, 함수 객체의 프로토타입인 Function.prototype의 부모 역할도 Object.prototype이다.

그리고 지역 변수에서 전역 변수로 순차적으로 검색해가듯이 프로토타입에 프로퍼티가 없다면 부모 역할을 하는 프로토타입으로 순차적으로 검색한다. 이것은 곧 프로토타입 체인이다.
곧, 모든 체인을 올라가면 Object.prototype이 존재한다.

 

프로토타입 체인의 검색 과정

객체의 프로퍼티에 접근하면 엔진은 다음 순서로 검색한다.

  1. 객체 자신에게 해당 프로퍼티가 있는지 본다.
  2. 없으면 __proto__를 따라 부모로 올라간다.
  3. 부모에도 없으면 또 그 부모로 올라간다.
  4. 최종적으로 Object.prototype까지 갔는데도 없으면 undefined를 반환한다.
  5. 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 이벤트 루프의 규칙

매 틱마다 엔진은 다음을 반복한다:

  1. Call Stack에 실행 중인 것이 있으면 끝까지 실행
  2. Stack이 비면, Microtask Queue를 완전히 비울 때까지 전부 처리
  3. 그 다음 Task Queue에서 하나를 꺼내 Stack으로
  4. (브라우저) 필요하면 렌더링
  5. 반복

핵심: 매크로태스크 하나 끝나면 마이크로태스크를 싹 비운다. 마이크로태스크가 매크로태스크보다 항상 먼저 나간다


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 변수는 그대로 남아있다. 고로 비동기 함수가 멈췄다 재개되어도 지역변수가 유지된다. → 비동기와 클로저가 함께 동작하는 모습

반응형

'Note > 개념' 카테고리의 다른 글

변수와 함수의 동작 원리  (0) 2026.05.10
SQLite  (0) 2026.04.19
BATCH  (0) 2026.04.06
JOIN  (0) 2026.04.06
N+1  (1) 2026.04.05