Node.js에서의 Prop-Drilling 공식 대안 AsyncLocalStorage 공개
Emily Parker
Product Engineer · Leapcell

소개
Node.js의 비동기 세계에서는 여러 함수 호출 및 비동기 작업 전반에 걸쳐 컨텍스트를 관리하는 것이 종종 상당한 과제가 될 수 있습니다. 개발자들은 종종 "prop-drilling"이라고 하는 패턴에 직면하게 되는데, 이는 중간 계층에서 해당 데이터를 직접 사용하지 않더라도 데이터가 컴포넌트 또는 함수의 여러 계층을 통해 전달되어야 하는 경우입니다. 이러한 관행은 장황하고, 긴밀하게 결합되어 있으며, 유지보수가 어려운 코드베이스로 이어질 수 있습니다. 사용자 ID나 요청 컨텍스트가 데이터베이스 쿼리, API 호출 및 기타 비동기 작업을 포함할 수 있는 호출 스택 깊숙이 접근 가능해야 한다고 상상해 보세요. 이 정보를 각 함수 시그니처를 통해 명시적으로 전달하는 것은 빠르게 번거롭고 오류가 발생하기 쉽습니다. 이 정확한 문제는 Node.js 생태계가 해결하려고 노력해 온 일반적인 문제점을 강조합니다. 다행히도 Node.js는 강력하고 공식적인 솔루션을 제공합니다: AsyncLocalStorage. 이 글에서는 AsyncLocalStorage가 prop-drilling에 대한 우아한 대안을 제공하여 컨텍스트 관리를 단순화하고 Node.js 애플리케이션의 명확성과 유지보수성을 향상시키는 방법을 자세히 살펴보겠습니다.
AsyncLocalStorage 상세 분석
AsyncLocalStorage의 복잡성을 탐색하기 전에 해당 기능을 뒷받침하는 몇 가지 핵심 개념을 명확히 해 봅시다.
핵심 용어
- 컨텍스트(Context): 프로그래밍에서 컨텍스트는 특정 시점에 코드 조각에 사용할 수 있는 변수, 값 및 상태 집합을 의미합니다.
AsyncLocalStorage의 맥락에서는 명시적으로 전달되지 않고 특정 비동기 흐름 내에서 전역적으로 접근 가능해야 하는 데이터를 구체적으로 의미합니다. - 비동기 작업(Asynchronous Operations): 이러한 작업은 결과가 나올 때까지 실행 스레드를 차단하지 않습니다. 예로는 파일 I/O, 네트워크 요청 및 타이머가 있습니다. Node.js는 본질적으로 비동기적이므로 이러한 작업 전반에 걸쳐 컨텍스트를 관리하는 것이 중요합니다.
- 이벤트 루프(Event Loop): Node.js 이벤트 루프는 비동기 콜백을 처리하는 기본적인 메커니즘입니다. 지속적으로 이벤트를 확인하고 연결된 콜백을 실행하여 필요한 대로 호출 스택으로 작업을 디스패치합니다. 이러한 단속적인 작업 전반에서
AsyncLocalStorage가 컨텍스트를 유지하는 방법을 이해하려면 이벤트 루프를 이해하는 것이 중요합니다. - Prop-Drilling: 앞서 언급했듯이, 이는 깊이 중첩된 컴포넌트 또는 함수에만 사용 가능하도록 하기 위해 실제로 필요하지 않은 컴포넌트 또는 함수의 여러 계층을 통해 데이터를 전달하는 안티 패턴입니다.
Prop-Drilling 문제점
웹 서버에서 해당 요청 처리 중에 생성된 모든 로그 메시지에 대해 요청 ID를 기록하려는 시나리오를 고려해 봅시다.
AsyncLocalStorage (Prop-Drilling) 없이:
// logger.js function log(level, message, requestId) { console.log(`[${new Date().toISOString()}] [${level}] [RequestID: ${requestId}] ${message}`); } // service.js function getUserData(userId, requestId) { log('INFO', `Fetching user data for ${userId}`, requestId); // 비동기 작업 시뮬레이션 return new Promise(resolve => setTimeout(() => { log('DEBUG', `User data fetched for ${userId}`, requestId); resolve({ id: userId, name: 'John Doe' }); }, 100)); } // controller.js async function handleUserRequest(req, res) { const requestId = req.headers['x-request-id'] || 'N/A'; log('INFO', `Handling user request`, requestId); try { const user = await getUserData(req.params.id, requestId); log('INFO', `User data retrieved successfully`, requestId); res.json(user); } catch (error) { log('ERROR', `Error handling user request: ${error.message}`, requestId); res.status(500).send('Internal Server Error'); } } // app.js (snippet) // app.get('/users/:id', handleUserRequest);
이 예제에서는 requestId가 handleUserRequest에서 getUserData로, 그리고 log로 명시적으로 전달됩니다. getUserData가 다른 함수를 호출했다면 requestId를 다시 전달해야 했고, 이는 prop-drilling으로 이어졌을 것입니다.
AsyncLocalStorage 작동 방식
AsyncLocalStorage는 비동기 실행 컨텍스트에 로컬인 데이터를 저장하는 방법을 제공합니다. 이는 AsyncLocalStorage를 사용하여 값을 설정하면 해당 값은 setTimeout, Promise.then, await와 같은 비동기 점프가 몇 번 발생하든 동일한 실행 흐름에서 시작된 모든 후속 비동기 작업 전반에 걸쳐 접근 가능하게 유지된다는 것을 의미합니다. 이는 Node.js의 내부 메커니즘을 활용하여 비동기 작업을 추적함으로써 달성됩니다. asyncLocalStorage.run()을 사용하여 새 실행 컨텍스트를 시작하면 해당 실행 블록 내에서 설정하는 모든 값은 해당 블록 내에서 시작된 모든 후속 비동기 작업과 자동으로 연결됩니다. 이러한 작업이 결국 실행될 때 AsyncLocalStorage는 올바른 컨텍스트가 복원되도록 합니다. 이는 멀티스레드 환경의 스레드 로컬 스토리지와 개념적으로 유사하지만 Node.js의 단일 스레드, 이벤트 기반 특성에 맞게 조정되었습니다.
AsyncLocalStorage로 구현하기
이전 예제를 AsyncLocalStorage를 사용하여 리팩토링해 봅시다.
const { AsyncLocalStorage } = require('async_hooks'); // AsyncLocalStorage 인스턴스 초기화 const als = new AsyncLocalStorage(); // logger.js - 이제 로거는 requestId를 인수로 필요로 하지 않습니다 function log(level, message) { const store = als.getStore(); // 현재 스토어를 가져옵니다. 여기에는 컨텍스트가 포함됩니다. const requestId = store ? store.requestId : 'N/A'; console.log(`[${new Date().toISOString()}] [${level}] [RequestID: ${requestId}] ${message}`); } // service.js - 더 이상 requestId 인수가 없습니다 function getUserData(userId) { log('INFO', `Fetching user data for ${userId}`); return new Promise(resolve => setTimeout(() => { log('DEBUG', `User data fetched for ${userId}`); resolve({ id: userId, name: 'John Doe' }); }, 100)); } // controller.js - 이제 컨텍스트를 설정하기 위해 als.run을 사용합니다 async function handleUserRequest(req, res) { const requestId = req.headers['x-request-id'] || `REQ-${Date.now()}`; // 제공되지 않은 경우 고유 ID 생성 // 모든 요청 처리를 AsyncLocalStorage 컨텍스트 내에서 실행 als.run({ requestId }, async () => { log('INFO', `Handling user request`); try { const user = await getUserData(req.params.id); log('INFO', `User data retrieved successfully`); res.json(user); } catch (error) { log('ERROR', `Error handling user request: ${error.message}`); res.status(500).send('Internal Server Error'); } }); } // app.js - Express 앱과의 예제 사용 const express = require('express'); const app = express(); app.get('/users/:id', handleUserRequest); const PORT = 3000; app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });
이 수정된 예제에서는 다음과 같습니다.
AsyncLocalStorage인스턴스를 만듭니다:const als = new AsyncLocalStorage();.handleUserRequest에서requestId를 아래로 전달하는 대신,als.run({ requestId }, async () => { ... });를 사용하여 새 비동기 컨텍스트를 시작합니다. 첫 번째 인수는 이 컨텍스트 내에서 접근 가능한 객체(store)입니다.async () => { ... }블록 내에서 직접 또는 간접적으로 호출되는 모든 함수는als.getStore()를 사용하여 이 컨텍스트를 검색할 수 있습니다.log함수는 이제als.getStore()에서 직접log를 가져와requestId를 인수로 전달할 필요가 없습니다.
이는 함수 시그니처를 크게 정리하고 코드를 더 모듈화합니다. 컨텍스트(예: requestId)는 명시적인 배관 없이도 필요할 때 필요할 때 암묵적으로 사용할 수 있습니다.
일반적인 애플리케이션 시나리오
AsyncLocalStorage는 비동기 작업 전반에 걸쳐 컨텍스트 정보를 전파해야 하는 시나리오에서 매우 유용합니다.
- 요청 추적/로깅: 시연한 것처럼 특정 요청에 대한 모든 로그 메시지에 요청 ID를 연결합니다.
- 인증/권한 부여: 명시적으로 전달하지 않고도 다양한 서비스 또는 데이터 액세스 계층에서 액세스 가능해야 하는 사용자 정보(예: 사용자 ID, 역할)를 저장합니다.
- 데이터베이스 트랜잭션: 트랜잭션 컨텍스트를 관리하여 특정 흐름 내의 모든 데이터베이스 작업이 동일한 트랜잭션의 일부인지 확인합니다.
- 멀티테넌시: 현재 테넌트 ID를 저장하여 데이터 액세스가 다른 테넌트에 대해 올바르게 범위가 지정되었는지 확인합니다.
- 성능 모니터링: 요청 흐름과 관련된 시작 시간 또는 특정 메트릭을 기록합니다.
고려 사항 및 모범 사례
- 과용: 강력하지만 모든 데이터에
AsyncLocalStorage를 사용하지 않도록 하십시오. 비동기 흐름 내에서 진정한 "횡단적" 관심사에 대해서만 예약하는 것이 가장 좋습니다. 지역 데이터에 대한 합법적인 파라미터 전달을 대체하지 마십시오. - 스토어의 불변성:
als.run()에 전달된 객체는als.getStore()를 통해 직접 접근 가능합니다. 이 객체의 속성을 직접 수정하면(예:als.getStore().someProp = 'newValue') 해당 변경 사항은 해당 컨텍스트 내에서 전역적으로 반영됩니다. 복잡한 상태의 경우 불변 데이터를 저장하거나 수정이 필요하고 부작용을 피하고 싶은 경우 스토어를 복제하는 것을 고려하십시오. - 오류 처리:
als.run()블록 내에서 오류가 발생하면 컨텍스트는 자연스럽게 정리됩니다. 그러나als.run()외부에서 발생할 수 있는 동기 부분에 대해 적절하게 처리되었는지 확인하십시오. - 범위:
als.run()에서 설정한 컨텍스트는 해당run호출에서 시작된 비동기 실행 흐름에 엄격하게 연결됩니다. 관련 없는 비동기 작업이나 새 최상위 실행 컨텍스트에서는 마법처럼 사용 가능하지 않습니다.
결론
AsyncLocalStorage는 Node.js에서 비동기 작업 전반에 걸쳐 컨텍스트를 우아하게 관리하기 위한 중요하고 공식적인 솔루션으로 자리 잡고 있습니다. 데이터를 전파하는 깔끔하고 암묵적인 메커니즘을 제공함으로써 번거로운 prop-drilling의 필요성을 효과적으로 제거하여 더 유지보수 가능하고 읽기 쉬우며 오류가 적은 코드로 이어집니다. 컨텍스트 관리를 중앙 집중화하여 개발자가 더 강력하고 확장 가능한 애플리케이션을 구축할 수 있도록 지원하며, 궁극적으로 Node.js의 비동기 환경에서 더 나은 아키텍처 패턴을 육성합니다. AsyncLocalStorage를 채택하여 컨텍스트 처리에 대한 더 깔끔한 접근 방식을 활용하십시오. 진정한 패러다임 전환은 명시적인 컨텍스트 전달의 복잡성에서 벗어나는 것입니다.

