From 0f0723e58d8e6dc66f34774017ce18ab4df699c8 Mon Sep 17 00:00:00 2001 From: seoyeonjiin Date: Mon, 8 Dec 2025 01:35:34 +0900 Subject: [PATCH] chapter11 --- chapter11/seoyeonjin/readme.md | 373 +++++++++++++++++++++++++++++++++ chapter11/seoyeonjin/study.md | 117 +++++++++++ 2 files changed, 490 insertions(+) create mode 100644 chapter11/seoyeonjin/readme.md create mode 100644 chapter11/seoyeonjin/study.md diff --git a/chapter11/seoyeonjin/readme.md b/chapter11/seoyeonjin/readme.md new file mode 100644 index 0000000..66d55bd --- /dev/null +++ b/chapter11/seoyeonjin/readme.md @@ -0,0 +1,373 @@ +## [11-1 비동기적으로 초기화되는 컴포넌트 다루기] + +초기화가 빠르게 끝나는 경우 → 동기 API 사용해도 괜찮다. + +초기화에 네트워크가 필요하거나 시간이 오래 걸리는 경우 → 반드시 비동기 API 사용해야 한다. + +- Node.js는 싱글 스레드라 동기식 네트워크 호출을 하면 전체 스레드가 멈춘다. +- 데이터베이스 드라이버(RabbitMQ, DB 드라이버 등)는 초기화를 비동기로 제공한다. + +### 11-1-1 비동기적으로 초기화된 컴포넌트의 문제 + +DB처럼 초기화가 비동기인 컴포넌트는 초기화가 완료되기 전에는 사용하면 안 되므로 특별한 처리가 필요하다. → 로컬 초기화 확인, 지연 시작 + +- 로컬 초기화 확인 + +API가 호출되기 전에 모듈이 초기화되었는지 확인, 그렇지 않으면 초기화 기다린다. + +비동기 모듈에서 작업을 호출할 때마다 수행해야 한다. + +- 지연 시작 + +초기화가 완료될 때까지 비동기적으로 초기화된 컴포넌트에 의존하는 코드의 실행을 지연시킨다. + +모든 비동기 서비스가 초기화될 때까지 전체 프로그램 시작을 지연시킬 수도 있다. + +→ 지연 문제가 발생할 수 있다. + +### 11-1-2 사전 초기화 큐 + +`큐와 명령 패턴`: 아직 초기화되지 않았다면 큐에 넣은 다음 모든 초기화 단계가 완료되는 즉시 실행한다. + +- **QueuingState (초기화 전 상태)** + - DB가 아직 연결 안 됐을 때 + - `query()` 호출 시 **바로 실행하지 않고 큐에 저장** + - 나중에 초기화가 완료되면 큐에 쌓인 명령을 실행함 +- **InitializedState (초기화 완료 상태)** + - DB가 완전히 준비된 후 + - `query()`를 즉시 실행 + +```jsx +const METHODS_REQUIRING_CONNECTION = ['query'] +const deactivate = Symbol('deactivate') + +class QueuingState { + constructor(db) { + this.db = db + this.commandsQueue = [] + } + + // query 메서드를 동적으로 만든다 + METHODS_REQUIRING_CONNECTION.forEach(methodName => { + this[methodName] = function (...args) { + console.log('Command queued:', methodName, args) + + return new Promise((resolve, reject) => { + + const command = () => { + db[methodName](...args) + .then(resolve, reject) + } + + this.commandsQueue.push(command) + }) + } + }) + + [deactivate]() { + this.commandsQueue.forEach(command => command()) + this.commandsQueue = [] + } +} + +``` + +## [11-2 비동기식 요청 일괄 처리 및 캐싱] + +### 11-2-1 비동기식 요청 일괄 처리란? + +같은 API에 대한 비동기 요청이 동시에 여러 개 들어오면, 새로운 요청을 또 실행하지 않고 이미 실행 중인 요청에 “얹어서(piggyback)” 같이 처리한다. + +### 11-2-2 최적의 비동기 요청 캐싱 + +요청 완료 → 결과 캐시에 저장 → API가 호출될 때 캐시에서 결과 검색 + +### 11-2-3 캐싱 혹은 일괄 처리가 없는 API 서버 + +예제 + +- DB 전체(100,000개)를 처음부터 끝까지 스캔 +- product가 일치하면 amount 더함 +- 결과 반환 + +→ 요청이 한 번 올 때마다 DB를 전체 스캔함 (비효율적) + +```jsx +export async function totalSales(product) { + const now = Date.now(); + let sum = 0; + + for await (const transaction of salesDb.createValueStream()) { + if (!product || transaction.product === product) { + sum += transaction.amount; + } + } + + console.log(`totalSales() took: ${Date.now() - now}ms`); + return sum; +} +``` + +### 11-2-4 Promise를 사용한 일괄 처리 및 캐싱 + +예제 - 일괄 처리 + +- 요청(product)을 key로 저장하는 Map 준비 +- 이미 진행 중인 요청이 있으면 → 그 Promise 그대로 반환 +- 없다면 → 새로 totalSalesRaw(product)를 실행하고 Map에 저장 +- Promise가 끝나면 Map에서 제거 + +```jsx +if (runningRequests.has(product)) { + return runningRequests.get(product); // 이미 진행 중인 작업에 얹기 +} + +const resultPromise = totalSalesRaw(product); +runningRequests.set(product, resultPromise); + +resultPromise.finally(() => { + runningRequests.delete(product); // 끝나면 제거 +}); + +return resultPromise; +``` + +예제 - 캐싱 + +- `cache.has(product)` → 캐시 히트라면 바로 반환 +- 없다면 totalSalesRaw 실행 후 Promise를 캐시에 저장 +- TTL이 지나면 캐시 삭제 + +```jsx +if (cache.has(product)) { + return cache.get(product); +} + +const resultPromise = totalSalesRaw(product); +cache.set(product, resultPromise); + +resultPromise.then(() => { + setTimeout(() => cache.delete(product), CACHE_TTL); +}); +``` + +## [11-3 비동기 작업 취소] + +JavaScript/Node.js는 단일 스레드이며, 이미 실행 중인 비동기 작업은 **중간에 강제로 끊을 수 없다.** + +**→ 취소 작업을 위해서는 취소 요청이 들어왔는지 확인하고, 들어왔다면 멈추는 로직을 추가해야 한다.** + +### 11-3-1 취소 가능한 함수를 만들기 위한 기본 레시피 + +함수 실행 취소: 작업 취소가 요청되었는지 확인하고, 작업을 조기 종료한다. + +```jsx +async function cancelable(cancelObj) { + const resA = await asyncRoutine("A"); + + if (cancelObj.cancelRequested) throw new CancelError(); + + const resB = await asyncRoutine("B"); + + if (cancelObj.cancelRequested) throw new CancelError(); + + const resC = await asyncRoutine("C"); +} +``` + +- 비동기 구간마다 **cancelRequested**를 검사 +- 호출부에서 `cancelObj.cancelRequested = true` 하면 함수는 다음 체크 시점에 멈춘다. + +### 11-3-2 비동기 호출 래핑 + +취소 로직을 wrapping하여 코드 중복을 제거한다. + +```jsx +const { cancelWrapper, cancel } = createCancelWrapper(); +``` + +- 실제 async 함수 호출보다 **앞에서 취소되었는지 체크** +- 취소되었다면 Promise.reject(CancelError) + +### 11-3-3 제너레이터를 사용한 취소 가능한 비동기 함수 + +제너레이터는 실행을 'yield 단위'로 끊을 수 있다. + +이 특성을 활용하면 비동기 작업을 단계별로 멈추거나 다시 진행시킬 수 있고, 각 단계 사이에서 취소 요청이 들어왔는지를 검사해 흐름을 안전하게 중단할 수 있다. + +```jsx +function* task() { + const a = yield asyncRoutine("A"); + const b = yield asyncRoutine("B"); + const c = yield asyncRoutine("C"); + return c; +} +``` + +- 제너레이터 호출 & nextStep 호출 + +```jsx +generatorObject.next(); + +nextStep(generatorObject.next(await prev.value)); +``` + +- 취소 시 + +```jsx +if (cancelRequested) { + return reject(new CancelError()); +} +``` + +## [11-4 CPU 바운드 작업 실행] + +CPU 바운딩: 프로그램의 실행 속도를 결정하는 가장 큰 요인이 **CPU 연산량**일 때 + +### 11-4-1 부분집합 합계 문제 풀기 + +Node.js 이벤트루프는 단일 스레드에서 실행되며, CPU 연산량이 많은 작업을 처리하는 중에 다른 요청을 처리하지 못한다. + +### 11-4-2 setImmediate를 사용한 인터리빙 + +- CPU 바인딩 알고리즘은 일련의 단계로 이루어져 있다. +- 각 단계가 완료된 후에 이벤트 루프에 제어권을 되돌린다. + +→ 보류중인 I/O 요청 후 실행되도록 알고리즘의 다음 단계를 예약한다. + +`before` + +```jsx +_combine(set, subset) { + for (...) { + const newSubset = ... + this._combine(...) + this._processSubset(...) + } +} + +``` + +`after` + +```jsx +_combineInterleaved(set, subset) { + this.runningCombine++ + + setImmediate(() => { + this._combine(set, subset) + if (--this.runningCombine === 0) { + this.emit('end') + } + }) +} +``` + +- `_combineInterleaved()`가 먼저 실행됨 +- 즉시 `_combine()`을 실행하지 않음 +- 대신 `setImmediate()`로 예약 +- 이벤트 루프가 다른 요청 처리 가능 +- 다시 돌아오면 `_combine()` 한 덩어리 처리 +- 또 setImmediate() 예약 +- 반복… + +- 고려사항 + - 각 단계를 실행하는데 오랜 시간이 걸리면 잘 작동하지 않는다. + +### 11-4-3 외부 프로세스의 사용 + +`자식 프로세스 사용`: fork()를 통해 새로운 Node.js 프로세스(자식 프로세스)를 띄워서 CPU 바운드 연산을 대신 처리하게 만드는 방법. + +- 전체 구조 + + 1. 메인 프로세스 + - HTTP 요청을 받는다 + - 부분집합 합계(subset sum) 작업을 자식 프로세스에게 위임한다 + - 자식 프로세스로부터 도착하는 `{ event: 'match' | 'end', data }` 메시지를 받아 클라이언트에 전송한다 + 2. ProcessPool (프로세스 풀) + - 자식 프로세스를 여러 개 띄워놓고 재사용하는 구조 + +- acquire() 함수 + - **pool에 놀고 있는 프로세스가 있으면 바로 빌려줌** + - **현재 active가 max라면 → waiting 큐에 resolve/reject 저장** + - **새 프로세스를 fork()해서 준비되면(resolve) 빌려줌** +- release() 함수 + - 작업 끝난 worker를 원래 자리로 돌려놓는 함수. +- SubsetSumFork 클래스 + - 실제 작업을 넘기는 클래스 +- 자식 프로세스(SubsetSumProcessWorker) + - 부모로부터 `{ sum, set }` 메시지를 받는다 + - 메시지를 받으면 실제 SubsetSum 알고리즘 실행 + - match가 나오면 `{ event: 'match', data }` 메시지 전송 + - 끝나면 `{ event: 'end', data }` 전송 + - 준비되면 `process.send('ready')` + +### 11-4-4 작업자 스레드(worker threads) 사용 + +- 작업자 스레드 + - Node 10.5.0 부터 도입 가능 + - 프로세스에 비해 메모리 공간이 더 작고 시작 시간이 빠르다. + - 같은 프로세스 안에 있지만, 서로 다른 스레드에서 실행 + - 각 워커 스레드는 + - 자기만의 **이벤트 루프** + - 자기만의 **V8 인스턴스 & 힙을** 가짐 + - 메인 스레드와는 메시지(postMessage) 기반으로 통신 + - 필요하면 `SharedArrayBuffer + Atomics`로 메모리를 공유할 수도 있음 + +```jsx +import { Worker } from "worker_threads"; + +export class ThreadPool { + constructor(file, poolMax) { + this.file = file; // 워커 스크립트 경로 + this.poolMax = poolMax; // 최대 워커 수 + this.pool = []; // 놀고 있는 워커들 + this.active = []; // 일하는 워커들 + this.waiting = []; // 워커 없어서 기다리는 요청들 + } + + acquire() { + return new Promise((resolve, reject) => { + let worker; + + // 1) 놀고 있는 워커 있으면 꺼내서 바로 줌 + if (this.pool.length > 0) { + worker = this.pool.pop(); + this.active.push(worker); + return resolve(worker); + } + + // 2) 이미 풀 최대치만큼 바쁘면 대기열에 넣음 + if (this.active.length >= this.poolMax) { + return this.waiting.push({ resolve, reject }); + } + + // 3) 새 워커 생성 + worker = new Worker(this.file); + worker.once("online", () => { + // 워커 준비 완료 + this.active.push(worker); + resolve(worker); + }); + + worker.once("exit", (code) => { + console.log(`Worker exited with code ${code}`); + this.active = this.active.filter((w) => w !== worker); + this.pool = this.pool.filter((w) => w !== worker); + }); + }); + } + + release(worker) { + // 대기중인 요청이 있으면 걔부터 깨워줌 + if (this.waiting.length > 0) { + const { resolve } = this.waiting.shift(); + return resolve(worker); + } + + // 아니면 워커를 active에서 빼고 pool에 넣어 재사용 + this.active = this.active.filter((w) => w !== worker); + this.pool.push(worker); + } +} +``` diff --git a/chapter11/seoyeonjin/study.md b/chapter11/seoyeonjin/study.md new file mode 100644 index 0000000..51f97c8 --- /dev/null +++ b/chapter11/seoyeonjin/study.md @@ -0,0 +1,117 @@ +# pg, mongoose, redis의 초기화 전략 비교 + +## 1. 패키지의 초기화 전략 + +| 패키지 | 실행 시점 | 초기화 전 실행 | 실패 처리 | 큐 전략 | lazy 의미 | +| ------------------ | ----------------------------- | -------------- | ---------------------- | ---------------------- | -------------------------- | +| **pg** | 즉시 실행 | 불가 | 실패 | 개발자가 직접 큐 | lazy 없음 | +| **mongoose** | 개발자가 실행시킴(exec/await) | 가능 | reconnect 지원 | 초기화 안 된 경우만 큐 | 진짜 lazy | +| **redis(ioredis)** | **즉시 실행(시도)** | 가능 | 자동 reconnect + retry | 자동 internal buffer | lazy 아님 → auto-buffering | + +## 2. pg의 초기화 전략 + +- pg 특징 + - **모든 쿼리는 DB connection이 반드시 필요** + - `pool.query()` 호출 = 바로 네트워크 요청 + - 모든 작업이 DB connection에 100% 의존 + +→ 모든 쿼리를 큐에 넣고, 초기화 끝나면 실행 + +- 무조건 모든 쿼리를 큐로 보낸다. + - 초기화 전에 실행하면 **항상 실패** + - 연결 실패가 자주 발생할 수 있어 **안전성을 보장**해야 함 + - pg는 **lazy model이 아니라 eager model** + +```jsx +if (!initialized) { + queue.push(() => pool.query(sql)); +} else { + return pool.query(sql); +} +``` + +**→ pg에서는 초기화 여부와 관계없이 무조건 큐잉이 필요하다.** + +## 3. mongoose의 초기화 전략 + +- mongoose 특징 + - ODM 모델 → 스키마 정의, 미들웨어 등록, 모델 생성 등 연결 없이도 가능한 작업이 매우 많다 + - `.find()`, `.save()`는 즉시 실행이 아니라 “query 객체 생성” + → 실제 실행은 `.exec()` 또는 `await` 시점에 이루어진다. +- lazy execution 모델 + +```jsx +const q = User.find({ name: "A" }); // 아직 실행 X +await q.exec(); // 이때 연결 필요 +``` + +→ 연결이 안 돼도 실행 시점 이전이므로 문제가 없음. + +- 헷갈릴 수 있는 부분 - exec 이 없어도 실행되는 경우가 있음 (but, lazy execution 모델 맞음) + +```jsx +await User.find({ name: "A" }).exec(); + +await User.find({ name: "A" }); + +User.find({ name: "A" }).then(...); +``` + +→ mongoose는 쿼리를 Promise처럼 사용 가능하도록 만들었기 때문에 await 또는 then을 쓰면 exec()가 자동으로 호출된다. + +- 초기화되지 않았을 때만 큐로 보낸다. + - 연결 이전에도 수행 가능한 작업이 많다 + - 실제 네트워크 요청은 **나중에 실행되기 때문에** 초기화 미완료여도 괜찮다 + - mongoose는 자동 reconnect 기능을 내장해 탄력적인 연결 모델을 갖는다 + +```jsx +if (!connected) { + queue.push(() => executeQuery()); +} else { + return executeQuery(); +} +``` + +**→ mongoose에서는 굳이 모든 요청을 큐에 넣을 필요가 없다.** + +## 4. redis 초기화 전략 + +- redis 특징 + - 명령을 호출하는 순간 바로 실행을 시도한다. + → 하지만 연결이 아직 준비되지 않았으면 내부 버퍼에 저장해둔다. + - 즉, 실행 자체는 eager지만 전달은 lazy하게 이루어질 수 있다. + - 연결 실패·네트워크 지연·재연결 상황을 기본적으로 가정하고 설계됨 + - 자동 reconnect + retry/backoff 기능을 내장하고 있어 + 개발자가 오류 처리를 직접 하지 않아도 된다. + +→ 연결 전이라도 명령을 편하게 호출할 수 있게 내부에서 자동 큐잉이 수행됨. + +--- + +- 연결되기 전에도 명령 호출이 가능하다: + +```jsx +redis.set("a", "1"); +// 아직 연결되기 전이어도 OK +// → ioredis 내부 명령 버퍼에 저장됨 +// → 연결 완료 시 자동 실행됨 +``` + +## 5. 결론 + +- 연결이 필요한 시점이 다르다 + - pg: query() 순간 즉시 네트워크 요청 + - mongoose: exec() 순간에 네트워크 요청 + - redis: 명령을 호출하면 실행을 시도하지만, 연결이 안 되어 있으면 내부 버퍼에 저장했다가 연결되면 전송 +- lazy vs eager + - pg는 eager execution + - mongoose는 lazy execution + - redis는 eager 실행 시도 + lazy 전송(auto buffering) +- 연결 없이 가능한 작업의 유무 + - pg: 연결 없이는 아무 것도 못함 + - mongoose: 연결 전에도 가능한 작업 많음 + - redis: 연결 없어도 명령 호출 자체는 가능 (내부에서 버퍼링되어 보존됨) +- 큐잉 전략의 차이 + - pg: 연결 전 쿼리는 항상 실패 → 개발자가 직접 모든 요청을 큐잉해야 안전 + - mongoose: 실행 시점이 늦춰지므로 초기화 전에도 안전 → _초기화 안 된 경우에만_ 큐잉 + - redis: 네트워크 불안정성을 가정 → 모든 명령을 내부적으로 자동 큐잉 & 재전송