
Node.js에서 AsyncLocalStorage로 비동기 컨텍스트 관리 간소화하기
Node.js의 비동기 애플리케이션에서 컨텍스트 관리를 어떻게 AsyncLocalStorage가 해결하는지 알아볼까요?
AsyncLocalStorage는 모든 비동기 작업 간에 컨텍스트를 유지할 수 있는 방법을 제공하는데요.
마치 요청을 따라다니는 비밀 저장 상자가 있어서, 코드의 어느 부분에서든 중요한 정보를 접근할 수 있게 해줍니다.
AsyncLocalStorage 없이 작성한 일반적인 Express 애플리케이션
AsyncLocalStorage가 없는 상황에서 어떤 모습이 되는지 살펴볼까요? 여러 함수에 걸쳐 userId를 전달해야 하는 상황입니다.
// App.js
async function handleRequest(req, res) {
const userId = req.headers['user-id'];
await validateUser(userId);
await processOrder(userId);
await sendNotification(userId);
}
async function validateUser(userId) {
// 여기서 userId가 필요합니다.
}
async function processOrder(userId) {
// 여기서도 userId가 필요합니다.
await updateInventory(userId);
}
async function updateInventory(userId) {
// 여기에서도 userId가 필요합니다.
}
async function sendNotification(userId) {
// 그리고 여기에서도 필요합니다.
}
보시다시피 userId를 여기저기서 계속 전달하고 있죠?
만약 requestId, tenantId, locale과 같은 여러 매개변수까지 포함한다면 함수 시그니처는 금방 난잡해질 것입니다.
AsyncLocalStorage로 코드 간소화하기
이제 AsyncLocalStorage를 사용하여 이 코드를 어떻게 간소화할 수 있는지 살펴보겠습니다.
// App.js
const { AsyncLocalStorage } = require('node:async_hooks');
const storage = new AsyncLocalStorage();
const app = express();
app.use((req, res, next) => {
const context = {
userId: req.headers['user-id'],
requestId: crypto.randomUUID(),
startTime: Date.now()
};
storage.run(context, () => {
next();
});
});
async function validateUser() {
const context = storage.getStore();
console.log(`사용자 ${context.userId}를 검증 중입니다.`);
}
async function processOrder() {
const context = storage.getStore();
console.log(`사용자 ${context.userId}의 주문을 처리 중입니다.`);
}
async function sendNotification() {
const context = storage.getStore();
console.log(`사용자 ${context.userId}에게 알림을 보내는 중입니다.`);
}
app.post('/orders', async (req, res) => {
await validateUser();
await processOrder();
await sendNotification();
});
이렇게 하면 요청이 전체 생애 주기 동안 컨텍스트를 따라가게 됩니다. 더 이상 매개변수를 전달할 필요가 없는 거죠.
AsyncLocalStorage 사용 시 주의사항
AsyncLocalStorage는 Node.js 23 이상에서만 사용할 수 있기 때문에, 구버전 사용자들은 --experimental-async-context-frame 플래그를 사용해야 합니다.
AsyncLocalStorage를 언제 사용해야 할까요?
AsyncLocalStorage는 요청이 마이크로서비스를 통해 흐를 때 특히 유용한데요.
요청 ID, 추적 ID 및 기타 메타데이터를 매 함수에 전달하지 않고도 기록할 수 있기 때문입니다.
// App.js
const { AsyncLocalStorage } = require('node:async_hooks');
const storage = new AsyncLocalStorage();
function setupRequestTracing(app) {
app.use((req, res, next) => {
const traceId = req.headers['x-trace-id'] || crypto.randomUUID();
storage.run({ traceId }, () => {
res.setHeader('x-trace-id', traceId);
next();
});
});
}
function log(message) {
const { traceId } = storage.getStore();
console.log(`[${traceId}] ${message}`);
}
AsyncLocalStorage는 데이터베이스 작업 간 현재 트랜잭션을 추적하는 데도 유용한데요.
여러 함수에 걸쳐 트랜잭션 객체를 전달해야 할 때 매우 편리합니다.
// App.js
import { AsyncLocalStorage } from 'node:async_hooks';
const asyncLocalStorage = new AsyncLocalStorage();
class TransactionManager {
constructor() {
this.storage = new AsyncLocalStorage();
}
async runInTransaction(callback) {
const transaction = await db.beginTransaction();
try {
await this.storage.run(transaction, callback);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
getCurrentTransaction() {
return this.storage.getStore();
}
}
// 사용 예시
const tm = new TransactionManager();
await tm.runInTransaction(async () => {
await updateUserProfile();
await updateUserPreferences();
});
async function updateUserProfile() {
const transaction = tm.getCurrentTransaction();
await transaction.query('UPDATE users SET ...');
}
AsyncLocalStorage는 로깅에도 유용한데요.
현재 로그 컨텍스트를 저장하고 이 컨텍스트를 어떤 함수에서든 조회할 수 있습니다.
// App.js
import { AsyncLocalStorage } from 'node:async_hooks';
const logStorage = new AsyncLocalStorage();
// 로그 컨텍스트 생성용 미들웨어 설정
app.use((req, res, next) => {
const logContext = {
requestId: crypto.randomUUID(),
userId: req.headers['user-id'],
path: req.path,
timestamp: new Date().toISOString()
};
logStorage.run(logContext, () => {
next();
});
});
// 컨텍스트를 사용하는 로거 생성
function logger(message, level = 'info') {
const context = logStorage.getStore();
console.log(JSON.stringify({
level,
message,
requestId: context.requestId,
userId: context.userId,
path: context.path,
timestamp: context.timestamp
}));
}
// 앱 어디서나 사용 가능
app.get('/api/users', async (req, res) => {
logger('사용자 목록을 가져오는 중입니다.');
try {
const users = await db.getUsers();
logger('사용자를 성공적으로 가져왔습니다.');
res.json(users);
} catch (error) {
logger('사용자 목록 가져오기 실패', 'error');
res.status(500).send(error.message);
}
});
이 코드를 실행하면 각 로그 메시지에 전체 요청 컨텍스트가 자동으로 포함됩니다.
{
"level": "info",
"message": "사용자 목록을 가져오는 중입니다.",
"requestId": "123e4567-e89b-12d3-a456-426614174000",
"userId": "user123",
"path": "/api/users",
"timestamp": "2024-12-20T10:30:00.000Z"
}
AsyncLocalStorage 사용을 피해야 할 때
AsyncLocalStorage는 강력하지만, 다음과 같은 상황에서는 피하는 것이 좋습니다:
- 컨텍스트가 몇 개의 함수만 통과해야 할 때 - 일반 매개변수 전달이 더 명확합니다.
- 동기 코드 작업 시 - AsyncLocalStorage는 불필요한 복잡성을 추가합니다.
- AsyncLocalStorage를 사용하여 공개 API를 구축할 때, 사용자가 특정한 컨텍스트 관리 방식을 강제하게 됩니다.
예를 들어 결제 처리 API를 생각해 볼까요?
// App.js
import { AsyncLocalStorage } from 'node:async_hooks';
const storage = new AsyncLocalStorage();
export class PaymentProcessor {
async processPayment(amount) {
const context = storage.getStore();
if (!context?.userId) {
throw new Error('사용자 컨텍스트를 찾을 수 없습니다!');
}
// context.userId를 사용하여 결제 처리
}
}
API 소비자는 이제 모든 호출을 storage.run()으로 감싸야 하므로, 그들의 애플리케이션 아키텍처와 맞지 않을 수 있습니다.
단순히 userId로 processPayment()를 호출할 수 없게 되죠.
대신 다음과 같은 복잡한 패턴을 사용해야 합니다.
// App.js
storage.run({ userId: '123' }, async () => {
const processor = new PaymentProcessor();
await processor.processPayment(100);
});
보다 유연한 접근 방식은 컨텍스트를 선택적으로 만들면서 여전히 AsyncLocalStorage를 지원할 수 있습니다.
// App.js
export class PaymentProcessor {
async processPayment(amount, context = storage.getStore()) {
const userId = context?.userId;
if (!userId) {
throw new Error('사용자 컨텍스트가 제공되지 않았습니다!');
}
// userId를 사용하여 결제 처리
}
}
AsyncLocalStorage의 활용과 한계
이 방식은 소비자가 원하는 방식으로 코드를 작성할 수 있도록 하면서도, 필요한 경우 AsyncLocalStorage와 호환성을 유지하는 방법입니다.
이렇게 하면 소비자는 컨텍스트 저장소를 사용할 수도 있고, 매개변수를 직접 전달하는 방법을 선택할 수도 있습니다.
각각의 특정 사용 사례와 아키텍처에 맞게 적절한 방식을 선택할 수 있는 것이죠.
AsyncLocalStorage는 Node.js 이벤트 루프를 사용하여 컨텍스트를 유지합니다.
이는 이벤트 루프 외부에서 실행되는 작업(예: setImmediate 또는 process.nextTick)에서는 저장된 컨텍스트에 접근할 수 없다는 것을 의미합니다.
// App.js
storage.run({ value: 'test' }, () => {
console.log(storage.getStore().value); // 'test'
setImmediate(() => {
console.log(storage.getStore()?.value); // undefined
});
});
AsyncLocalStorage는 처음에는 마법처럼 보일 수 있지만, 그 한계를 이해하면 효과적으로 사용할 수 있습니다.
적절히 사용하면 코드가 상당히 깔끔해지고, 컨텍스트 관리가 매우 간편해지는 장점이 있습니다.
결론
AsyncLocalStorage는 Node.js 애플리케이션에서 비동기 컨텍스트를 관리하는 강력한 도구인데요.
이를 통해 여러 비동기 함수 간에 필요한 데이터를 손쉽게 유지할 수 있습니다.
하지만, 언제 사용해야 할지, 그리고 어떤 상황에서 피해야 할지를 잘 판단하는 것이 중요합니다.
이러한 기능을 통해 코드의 가독성을 높이고, 유지보수성을 향상시킬 수 있는데요.
비동기 프로그래밍을 하는 개발자들에게는 매우 유용한 기능이니, 꼭 활용해 보길 바랍니다.
'Javascript' 카테고리의 다른 글
| ECMAScript 2025에 포함될 Import Attributes, 무엇이 달라질까? (0) | 2025.01.13 |
|---|---|
| Node.js, 이제 TypeScript를 기본 지원한다! (0) | 2025.01.13 |
| 자바스크립트 WeakRef 완벽 가이드: 메모리 관리를 쉽게 이해해볼까요? (0) | 2025.01.08 |
| JavaScript Truthy와 Falsy 완벽 정리: 헷갈리는 타입 변환, 이제 끝내자! (0) | 2025.01.08 |
| 자바스크립트 단축 연산자 끝판왕! 논리 AND 할당 연산자 `&&=` 와 Null 병합 할당 연산자 `??=` 로 코드 깔끔하게 정리하기 (0) | 2025.01.08 |