컨텍스트별 명확성: EventEmitter와 AsyncLocalStorage를 사용한 요청 범위 데이터 흐름 구축
Ethan Miller
Product Engineer · Leapcell

소개
특히 Node.js를 사용하는 백엔드 개발의 복잡한 세계에서 단일 요청 내의 비동기 작업 전반에 걸쳐 컨텍스트를 관리하는 것은 영원한 과제입니다. 여러 동시 요청을 처리하는 웹 서버를 상상해 보세요. 각 요청은 수많은 데이터베이스 호출, API 통합 및 비즈니스 로직 실행을 포함할 수 있으며, 종종 비동기적으로 발생합니다. 모든 함수 호출을 통해 명시적으로 매개변수를 전달하지 않고도 의미 있는 로그를 제공하거나, 사용자 작업을 추적하거나, 요청별 구성을 적용하는 것은 악몽이 될 수 있습니다. 이러한 명시적인 매개변수 드릴링은 상용구 코드로 이어지고, 가독성을 떨어뜨리며, 오류 가능성을 높입니다. 이 글에서는 두 가지 강력한 Node.js 기능인 EventEmitter와 AsyncLocalStorage를 결합하여 애플리케이션 수명 주기 전반에 걸쳐 요청 범위의 컨텍스트를 원활하게 전달하고 유지 관리성과 관측 가능성을 향상시키는 강력하고 우아한 솔루션을 만드는 방법을 살펴봅니다.
핵심 개념 살펴보기
솔루션에 대해 자세히 알아보기 전에 접근 방식을 뒷받침하는 기본 개념을 간략하게 소개하겠습니다.
EventEmitter
EventEmitter는 이벤트 기반 프로그래밍을 용이하게 하는 핵심 Node.js 모듈입니다. 명명된 이벤트에 대한 리스너를 등록한 다음 해당 이벤트를 발생하는 EventEmitter 클래스의 인스턴스입니다. 이벤트가 발생하면 해당 이벤트에 등록된 모든 리스너가 동기적으로 호출됩니다. 단일 프로세스 내에서 반응형 프로그래밍에 일반적으로 사용되지만, 그 강점은 관심사 분리에 있습니다. 애플리케이션의 한 부분이 다른 부분이 어떤 부분이 수신하거나 반응할지 알지 못하고 이벤트를 발생시킬 수 있습니다.
AsyncLocalStorage
AsyncLocalStorage는 Node.js의 최신 추가 기능입니다(LTS 사용자의 경우 Node.js v13.10.0 및 v12.17.0부터 사용 가능). 비동기 컨텍스트에 로컬인 데이터를 저장하고 검색하는 방법을 제공합니다. 즉, 비동기 흐름의 한 지점에서 데이터를 "설정"한 다음, 동일한 비동기 실행 체인 내의 어디에서나 나중에 명시적으로 전달하지 않고 해당 데이터를 "가져올" 수 있습니다. Node.js의 기본 비동기 "후크"를 활용하여 데이터가 올바른 논리적 "흐름" 또는 "요청"과 연결되도록 합니다. 이는 콜백 기반 또는 프로미스 기반 비동기 작업에 걸쳐 컨텍스트를 유지하는 데 매우 강력합니다.
요청 범위 데이터 흐름 구축
우리의 목표는 요청이 시작될 때 AsyncLocalStorage 인스턴스에 요청별 데이터를 주입하고 해당 데이터가 EventEmitter 경계를 넘어서도 요청의 전체 비동기 실행 전반에 걸쳐 액세스 가능하도록 하는 것입니다.
기존 이벤트 발생의 문제점
특정 HTTP 요청과 관련된 모든 이벤트에 requestId를 기록하려고 하는 시나리오를 생각해 보세요. 이벤트를 직접 발생시키면 명시적으로 이벤트 인수로 전달되지 않는 한 리스너는 자동으로 requestId에 액세스할 수 없습니다.
// app.js (단순화) const express = require('express'); const EventEmitter = require('events'); const app = express(); const myEmitter = new EventEmitter(); myEmitter.on('userAction', (requestId, action) => { console.log(`[Request: ${requestId}] User performed: ${action}`); }); app.get('/do-something', (req, res) => { const requestId = req.headers['x-request-id'] || 'no-id'; // ... 일부 로직 수행 ... myEmitter.emit('userAction', requestId, 'viewed page'); // requestId를 전달해야 함 res.send('Done'); }); // 이 접근 방식은 requestId가 모든 이벤트 페이로드의 일부가 되도록 강제합니다.
암시적 컨텍스트를 위한 AsyncLocalStorage 활용
Aqui onde AsyncLocalStorage brilha. 요청이 시작될 때 AsyncLocalStorage에 requestId를 저장할 수 있습니다. 그러면 해당 요청의 비동기 컨텍스트 내에서 실행되는 모든 코드는 이를 검색할 수 있습니다.
// app.js const express = require('express'); const EventEmitter = require('events'); const { AsyncLocalStorage } = require('async_hooks'); const app = express(); const myEmitter = new EventEmitter(); const asyncLocalStorage = new AsyncLocalStorage(); // 각 요청에 대해 AsyncLocalStorage를 초기화하는 미들웨어 app.use((req, res, next) => { const requestId = req.headers['x-request-id'] || `req-${Date.now()}`; asyncLocalStorage.run({ requestId }, () => { next(); }); }); // 송신자와 요청 컨텍스트가 필요한 서비스 class MyService { doSomethingComplex() { const store = asyncLocalStorage.getStore(); const requestId = store ? store.requestId : 'unknown'; console.log(`[Service] Performing complex task for request: ${requestId}`); // 잠재적으로 이벤트 발생 myEmitter.emit('serviceAction', 'complex logic executed'); } } const myService = new MyService(); myEmitter.on('serviceAction', (action) => { const store = asyncLocalStorage.getStore(); const requestId = store ? store.requestId : 'unknown'; console.log(`[Request: ${requestId}] Service performed: ${action}`); }); app.get('/perform-service-action', (req, res) => { myService.doSomethingComplex(); res.send('Service action requested'); }); app.listen(3000, () => { console.log('Server listening on port 3000'); });
이 예에서는 다음과 같습니다.
- 미들웨어 설정: 들어오는 모든 요청을 가로채는 미들웨어가 있습니다.
asyncLocalStorage.run(): 이 미들웨어 내부에서asyncLocalStorage.run({ requestId }, () => { next(); })가 중요합니다.next()함수(및 후속 미들웨어 및 라우트 핸들러)를 새 비동기 컨텍스트 내에서 실행하여{ requestId }개체를 연결합니다.MyService의 컨텍스트: 요청 컨텍스트 내에서myService.doSomethingComplex()가 호출될 때asyncLocalStorage.getStore()는 미들웨어에서 설정한requestId를 성공적으로 검색합니다.EventEmitter리스너의 컨텍스트:'serviceAction'과 같은 이벤트가 발생하고 해당 리스너가 호출될 때에도asyncLocalStorage.getStore()는 올바른requestId에 대한 액세스를 제공합니다. 이는AsyncLocalStorage가 이벤트 발생 및 리스너 실행에 의해 도입된 비동기 경계를 넘어 컨텍스트를 유지하는 방법을 보여줍니다.
이 패턴을 사용하면 MyService 또는 EventEmitter 리스너와 같은 구성 요소가 요청별 정보를 명시적으로 인수로 받지 않고도 액세스할 수 있으므로 함수 서명이 크게 정리되고 더 나은 관심사 분리가 촉진됩니다.
고급 적용: 향상된 로깅 또는 추적
더 강력한 로깅 솔루션을 확장하는 것을 고려해 보세요.
// logger.js const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); function getContextualLogger() { const store = asyncLocalStorage.getStore(); const requestId = store ? store.requestId : 'N/A'; const userId = store ? store.userId : 'anonymous'; return (level, message, ...args) => { console.log(`[${new Date().toISOString()}] [${requestId}] [User:${userId}] [${level.toUpperCase()}] ${message}`, ...args); }; } // app.js (수정됨) // ... (express, emitter 및 asyncLocalStorage에 대한 이전 설정) ... // 현재 컨텍스트를 기반으로 사용자 지정 로거 사용 const log = getContextualLogger(); app.use((req, res, next) => { const requestId = req.headers['x-request-id'] || `req-${Date.now()}`; const userId = req.headers['x-user-id'] || 'guest'; // 사용자 식별 예시 asyncLocalStorage.run({ requestId, userId }, () => { log('info', `Incoming request: ${req.method} ${req.url}`); next(); }); }); myEmitter.on('dataProcessed', (data) => { log('debug', `Processed new data:`, data); }); app.post('/process-data', (req, res) => { log('info', 'Starting data processing...'); // 비동기 작업 시뮬레이션 setTimeout(() => { const processedData = { /* ... */ }; myEmitter.emit('dataProcessed', processedData); log('info', 'Data processing complete.'); res.json({ status: 'success', data: processedData }); }, 100); });
이제 getContextualLogger()를 통해 생성된 모든 로그 메시지에는 현재 요청에 특정한 requestId 및 userId가 자동으로 포함되어 디버깅 및 추적이 훨씬 효율적으로 이루어집니다.
결론
Node.js EventEmitter와 AsyncLocalStorage를 결합하면 복잡한 비동기 흐름 전반에 걸쳐 요청 범위의 컨텍스트를 관리하기 위한 강력하고 우아한 패턴을 제공합니다. AsyncLocalStorage는 명시적인 매개변수 전달의 부담에서 우리를 해방시키고, EventEmitter는 계속해서 분리된 이벤트 처리를 위한 유연한 아키텍처를 제공합니다. 이러한 시너지는 애플리케이션 수명 주기 전반에 걸쳐 관찰 가능성과 컨텍스트 인식을 암묵적으로 향상시켜 모든 작업이 올바른 요청 경계 내에서 이해되도록 함으로써 더 깨끗하고 유지 관리하기 쉬운 코드 기반으로 이어집니다.

