최근 Platformatic의 커뮤니티에서 좋은 글을 발견하여, 번역 겸 해당 내용을 공유하면 좋을 듯 하여, 포스팅하게 되었다.
1. Do not block the event loop
Node.Js의 이벤트 기반 아키텍처는 성능과 확장성의 핵심입니다. 이 아키텍처의 중심에는 이벤트 루프가 있으며, 이 메커니즘은 비동기 작업을 처리하고 애플리케이션이 응답성을 유지하도록 합니다.
Event Loop 는 Node.js가 기본적으로 하나의 JavaScript 스레드를 사용하더라도, 시스템 커널에 작업을 가능한 한 많이 오프로드하여 논블로킹 I/O 작업을 수행할 수 있도록 해주는 메커니즘입니다.
Node.js의 이벤트 루프는 각기 다른 유형의 이벤트를 처리하는 여러 단계를 통해 동작합니다. 이러한 단계들은 특정한 순서로 순환하며, 각 단계마다 특정 유형의 콜백을 처리합니다.
1. Timers Phase:
이 단계에서는 setTimeout() 및 setInterval()로 예약된 콜백들이 실행됩니다. 주로 타이머를 통해 일정 시간 후에 실행되어야 하는 함수들이 여기에서 처리됩니다.
2. Pending Callbacks Phase: executes I/O callbacks deferred to the next loop iteration.
이 페이즈는 pending_queue에 담기는 콜백들을 관리합니다. 이 큐에 담기는 콜백들은 이전 이벤트 루프 반복에서 수행되지 못했던 I/O 콜백들입니다. 예를 들어, 특정 작업이 완료되지 않았거나 지연된 콜백들이 여기에 대기하다가 실행됩니다.
3. Idle, Prepare Phase: only used internally.
이 단계는 Node.js 내부에서만 사용되며, JavaScript 코드를 직접 실행하지 않습니다. 공식 문서에서도 별다른 설명이 없는 만큼, 코드 실행의 직접적인 영향을 미치지 않는 관리용 단계입니다.
4. Poll Phase:
이 페이즈는 새로운 I/O 이벤트를 다루며 watcher_queue의 콜백들을 실행합니다. 이 큐에는 거의 모든 I/O 관련 콜백들이 담겨 있으며, setTimeout, setImmediate, close 콜백을 제외한 대부분의 콜백이 여기서 실행됩니다. 예를 들어 다음과 같은 경우의 콜백이 이 페이즈에서 처리됩니다:
- 데이터베이스 쿼리 결과가 왔을 때 실행되는 콜백
- HTTP 요청에 대한 응답이 도착했을 때 실행되는 콜백
- 파일을 비동기로 읽고 완료되었을 때 실행되는 콜백
5. Check Phase:
이 페이즈는 setImmediate 콜백만을 위한 단계입니다. setImmediate가 호출되면 이 큐에 담기고, Node.js가 Check Phase에 진입하면 콜백이 차례대로 실행됩니다.
공식 문서에 따르면, setImmediate와 process.nextTick의 차이점을 주목할 필요가 있습니다.
- process.nextTick은 같은 페이즈에서 호출되면 즉시 실행됩니다.
- setImmediate는 다음 이벤트 루프 틱에서 실행되며, Check Phase에 진입하면 실행됩니다.
6. Close Callbacks Phase:
이 단계에서는 close 이벤트 타입의 핸들러, 예를 들어 socket.on('close', ...)와 같은 콜백들이 처리됩니다. 이 단계는 uv_close()가 호출되면서 종료된 핸들러의 콜백들을 관리합니다.
이벤트 루프의 페이즈 전환 순서, 페이즈 전환 순서는 아래와 같이 진행됩니다:
- Timers Phase
- Pending Callbacks Phase
- Idle, Prepare Phase
- Poll Phase
- Check Phase
- Close Callbacks Phase
이러한 순환을 한 번 도는 것을 틱(Tick) 이라고 부릅니다. 이벤트 루프는 이러한 단계를 반복하며 특정 단계에서 처리할 이벤트가 없다면 다음 단계로 넘어가며, 더 이상 처리할 이벤트가 없을 때까지 계속 순환합니다.
> 이벤트 루프 차단(Blocking the event loop)
이벤트 루프는 순환 방식으로 동작하며, 지속적으로 새로운 이벤트를 확인하고 해당 콜백을 실행합니다. 차단되는 작업이 발생하면 이벤트 루프는 해당 작업이 완료될 때까지 다른 이벤트를 처리할 수 없게 되며, 그 결과로 대기 중인 작업들이 쌓이게 됩니다. 비효율적인 코드는 이벤트 루프를 의도치 않게 차단할 수 있으며, 이는 성능 저하와 잠재적인 애플리케이션 불안정성을 초래할 수 있습니다.
이벤트 루프 차단하게 되면 초래될 수도 있는 결과(Consequences of blocking the event loop)
- 성능 저하(Reduced performance): 이벤트 루프가 정지하면 애플리케이션이 동시 요청을 처리하는 능력이 크게 감소하여 응답 시간이 느려집니다.
- 지연 시간 증가(Increased latency): 이벤트 루프가 차단된 동안 요청들이 대기하게 되면서 사용자들은 응답 지연을 경험하게 됩니다.
- 응답 없음(Unresponsiveness): 심각한 경우에는 애플리케이션이 멈추거나 응답하지 않는 것처럼 보일 수 있습니다.
> Best Practices
이벤트 루프 차단을 방지하고 최적의 성능을 유지하려면 다음 방법을 고려하세요
복잡한 작업 분해(Decompose complex tasks): 복잡한 작업을 작은 비동기 단계로 나누어 장시간 실행되는 작업이 이벤트 루프를 차단하지 않도록 하세요. 이를 위해 Promise, async/await, 또는 콜백을 효과적으로 사용할 수 있습니다.
CPU 집약적인 작업 오프로드(Offload CPU-intensive tasks): 계산량이 많은 작업의 경우, 워커 스레드를 사용해 이를 메인 이벤트 루프에서 분리하는 것이 좋습니다. 이는 하나의 요청만 처리하는 프로세스라도 해당 요청을 순차적으로 처리하는 동안 중요한 역할을 할 수 있습니다. 예를 들어, Kubernetes 환경에서 실행되는 경우, 이벤트 루프가 장기 실행 요청에 의해 차단되면 liveness 체크가 실패할 수 있습니다. piscina와 같은 라이브러리는 CPU 집약적인 작업을 워커 스레드로 오프로드하는 과정을 간소화할 수 있습니다.
캐싱 및 중복 제거 구현(Implement caching and deduplication): I/O 작업(예: 데이터베이스 또는 API 호출) 이후에 CPU 바운드 작업이 자주 발생합니다. 처리된 결과를 캐싱하거나 API 호출을 줄여 이러한 작업을 최소화하세요. async-cache-dedupe와 같은 라이브러리는 중복 작업을 줄이는 데 도움을 줄 수 있습니다.
이벤트 루프 활용도 모니터링(Monitor event loop utilization): perf_hooks 또는 under-pressure 플러그인과 같은 도구를 사용해 이벤트 루프 성능을 추적하고 잠재적인 병목 현상을 파악하세요.
//이벤트 루프 활용도 모니터링(Monitor event loop utilization)
const { performance, PerformanceObserver } = require('perf_hooks');
// perf_hooks 모듈에서 performance와 PerformanceObserver를 가져옵니다.
const obs = new PerformanceObserver((list) => {
console.log(list.getEntries()[0].duration); // 측정한 시간의 차이를 출력합니다.
});
obs.observe({ entryTypes: ['measure'] });
// PerformanceObserver를 통해 'measure' 이벤트를 감지하도록 설정합니다.
performance.mark('A'); // 'A'라는 시작 지점을 표시합니다.
setTimeout(() => {
performance.mark('B'); // 1초 후에 'B'라는 종료 지점을 표시합니다.
performance.measure('A to B', 'A', 'B'); // 'A'에서 'B'까지의 시간을 측정합니다.
}, 1000);
> 작업이 실행되는 위치 이해(Understand where certain operations occur)
Node.js에서 특정 작업이 어디에서 수행되는지 이해하는 것은 성능 최적화에 중요합니다. 예를 들어, JSON 파싱은 메인 스레드에서 이루어지며, 비동기 파일 읽기는 별도의 스레드에서 이루어집니다. 이러한 차이를 이해하면 코드를 최적화하여 이벤트 루프 차단을 방지할 수 있습니다.
2. Monitor Node specific metrics and act on them
Node.js 애플리케이션의 성능 모니터링에는 일반적인 지표만으로는 효과적인 문제 해결을 위한 충분한 맥락을 제공하지 못하는 경우가 많습니다. 따라서 Node.js 성능 모니터링에는 여러 도구와 사용자 정의 대시보드를 결합해 CPU 사용량, 메모리 소비, 지연 시간 등의 지표를 종합적으로 관리해야 합니다.
그러나 이러한 지표가 단편적으로 분리되어 있으면 성능 저하의 원인을 정확히 파악하기 어려울 수 있습니다. 예를 들어, 수많은 마이크로서비스를 사용하는 플랫폼에서 높은 CPU 사용량이 관찰되었을 때, 이를 유발한 원인을 알 수 없으면 성능 저하가 장기화될 수 있고, 이는 기업에 큰 손실을 초래할 수 있습니다.
그래서 어떤 항목을 모니터링해야 할까요?
- 메모리 사용량:
- Heap used vs heap total: 사용 중인 힙 메모리와 전체 힙 메모리의 비율을 추적하여 메모리 누수나 비효율적인 데이터 구조를 파악합니다.
- RSS (Resident Set Size): 프로세스가 점유하고 있는 실제 물리 메모리의 양을 측정합니다. - CPU 사용량: CPU 사용량을 모니터링하되, 이를 이벤트 루프 활용도(ELU)와 같은 다른 지표와 함께 분석하여 잘못된 확장 결정을 방지합니다.
- 이벤트 루프 활용도 (Event Loop Utilization, ELU): 이벤트 루프가 총 시간 중 얼마나 많은 비율을 이벤트 처리에 사용했는지 측정하는 지표입니다. 이 수치는 이벤트 루프의 바쁨 정도를 나타냅니다.
> Acting on metrics
There are two keys to ensure that you are acting in the best interest of your application based on metrics:
1. 메모리 사용량만으로 인스턴스를 스케일링하거나 종료하지 마십시오: Node.js는 프로세스에 할당된 모든 메모리를 사용합니다. 따라서 메모리 사용량이 60-80%에 도달했다고 해서 인스턴스를 종료하거나 확장하는 것은 자원의 낭비입니다
2. CPU 지표로 확장할 때는 반드시 ELU를 고려하십시오: CPU 사용량이 100%에 도달했다고 해서 프로세스가 응답하지 않는 것은 아닙니다. 이벤트 루프가 여전히 효율적으로 처리되고 있을 수 있기 때문에, CPU 사용량만으로 성급하게 확장 결정을 내리지 않도록 합니다.
> 프로덕션에서 비상 계획 수립(Have a contingency plan in production)
모범 사례가 있더라도 예상치 못한 문제는 발생할 수 있으며 발생할 것입니다. 잘 고안된 비상 계획은 다운타임을 최소화하고 사고 발생 시 원활한 복구를 보장하는 데 필수적입니다. 비상 계획의 기준으로 노드별 모니터링 시스템을 갖추어 문제를 조기에 감지하고 관련 팀에 알리는 알림을 설정해야 합니다. 이에 따라 계획에는 다음이 포함되어야 합니다.
1. Automated rollbacks and canary deployments: 문제가 발생할 경우 자동으로 이전의 안정적인 버전으로 롤백할 수 있는 배포 파이프라인을 구성합니다. 새로운 버전을 전체 사용자에게 배포하기 전에 일부 사용자에게만 배포하여 문제를 조기에 파악합니다
2. Containerization and orchestration: 애플리케이션과 그 의존성을 컨테이너에 패키징하여 이식성을 높입니다. Kubernetes와 같은 도구를 사용하면 컨테이너 관리 및 확장이 간편해지며, 장애 발생 시 신속한 복구가 가능합니다.
3. Incident response procedures: 사건 발생 시 대응할 수 있는 명확한 지침을 마련합니다. 여기에는 역할과 책임, 커뮤니케이션 프로토콜, 문제 해결의 우선 순위 및 경로를 포함해야 합니다.
3. Use Node LTS versions in production
Node.JS LTS (Long-Term Support) 릴리스 라인은 프로덕션 환경의 안정성과 예측 가능성을 제공하기 위해 오랜 기간 유지 관리됩니다. LTS 버전은 중요 버그 수정 및 보안 패치를 3년간 지원하며, 일반 릴리스는 7개월 동안만 지원됩니다.
LTS 버전 사용의 장점:
변경 사항으로 인한 위험 감소: LTS 버전은 기존 패키지 및 모듈과의 호환성을 우선시하여 예기치 않은 문제 발생 가능성을 최소화합니다.
보안 강화: LTS 버전을 사용하면 적시에 제공되는 보안 업데이트를 통해 애플리케이션을 잠재적인 위협으로부터 보호할 수 있습니다.
안정성 향상: LTS 버전은 엄격한 테스트와 유지 보수를 거쳐, 보다 예측 가능하고 신뢰할 수 있는 런타임 환경을 제공합니다.
LTS 버전의 동작 방식 Node.js는 LTS 버전에 대해 엄격한 릴리스 일정을 유지하며, 각 버전은 정해진 기간 동안 활성 지원을 받고 그 이후에는 유지 관리 지원을 받습니다. 이러한 구조적 접근 방식은 안정성을 저해하지 않으면서 새로운 LTS 버전으로 애플리케이션을 업데이트할 충분한 시간을 보장합니다.
*LTS 버전 사용을 위한 모범 사례
1. LTS 릴리스 일정 모니터링: 다가오는 LTS 릴리스에 대해 정보를 수집하고, 이에 맞춰 마이그레이션 전략을 세워야 합니다.
2. 철저한 테스트: 새로운 LTS 버전으로 마이그레이션하기 전에, 애플리케이션과 의존성의 호환성을 확인하기 위해 철저한 테스트를 진행합니다.
3. 의존성 관리 도구 사용: npm audit 또는 yarn audit과 같은 도구를 사용하여 의존성의 취약점을 식별하고 해결하여, LTS 버전과의 호환성을 유지합니다.
지원 종료 버전 사용의 위험성 다이어그램에서 알 수 있듯이, Node.js v14와 v16은 각각 2년 및 1년 동안 지원이 종료되었지만 여전히 많은 사용자가 있습니다. 이는 많은 기업들이 Node.js 런타임을 업데이트하지 않고 있다는 문제를 시사합니
4. Automate testing, code review and conformance as much as possible
자동화된 테스트는 신뢰할 수 있고 유지 관리가 용이한 Node.js 애플리케이션을 구축하는 데 필수적일 뿐만 아니라 개발 속도를 높이는 데에도 중요합니다. 종합적인 테스트 전략을 수립함으로써 조직은 자신 있게 변경을 수행하고 결함 발생 위험을 줄이며 궁극적으로 더 빠르게 진행할 수 있습니다.
> Testing best practices
● 테스트 케이스 정의(Define test cases): 테스트 케이스가 정의되면 개발자는 테스트를 작성하기 시작할 수 있습니다. 테스트는 개별 테스트 또는 대규모 환경에 필요한 구성이나 픽스처가 필요할 수 있으며, 여기에는 데이터 및 네트워크 인프라가 포함됩니다.
● 라이브 테스트 우선(Prioritize live testing): 가능한 한 모의 구성 요소 대신 실제 시스템과 상호작용하여 잠재적인 통합 문제를 발견합니다.
● 행동에 집중(Focus on behavior): 테스트는 주로 코드의 외부 동작을 검증하고, 구성 요소를 블랙 박스로 취급해야 합니다.
● 테스트 불안정성 제거(Eliminate test flakiness): 테스트의 신뢰성을 유지하기 위해 실패하는 테스트를 꾸준히 조사하고 해결합니다. CI를 항상 녹색 상태로 유지하십시오.
● 글로벌 상태 피하기(Avoid global state): 글로벌 상태를 줄이면 테스트 불안정성이 줄어들고, 격리를 통해 테스트 안정성이 향상됩니다.
● 심층 방어 테스트(Focus on defensive in-depth testing): 코드 커버리지는 중요한 지표이지만, Node.js 애플리케이션의 신뢰성을 보장하기 위해 심층 방어 테스트에 집중하는 것이 중요합니다. 여기에는 엣지 케이스 테스트, 부정 테스트 및 보안 테스트가 포함됩니다.
● 프로덕션과 최대한 유사하게 테스트(Test as close as possible to production): 애플리케이션의 실세계 조건에서의 동작을 정확하게 평가하려면 가능한 한 프로덕션 환경에 가까운 환경에서 테스트하는 것이 중요합니다. 이를 위해 스테이징 환경을 설정하고, 성능 테스트 및 통합 테스트를 수행하십시오.
> 포괄적인 테스트 커버리지(Comprehensive test coverage)
잘 구성된 테스트 전략은 여러 유형의 테스트를 포함합니다:
● 단위 테스트: 개별 코드 단위의 올바른 동작을 격리된 상태에서 검증합니다.
● 통합 테스트: 서로 다른 구성 요소가 어떻게 상호작용하는지 검증합니다.
● 엔드 투 엔드 테스트: 실제 사용자 시나리오를 시뮬레이션하여 애플리케이션이 예상대로 작동하는지 확인합니다.
일반적으로 데이터베이스는 단위의 일부로 간주됩니다. 따라서 데이터베이스 드라이버를 모킹하기보다는 최소한 "해피 패스"에서는 데이터베이스에 직접 접근하는 것을 권장합니다. 데이터베이스 드라이버를 모킹하는 것은 오류 조건을 테스트하는 데 매우 유용할 수 있습니다.
> 적절한 테스트 프레임워크 선택
테스트 프레임워크의 선택은 매우 중요합니다. Jest는 인기를 끌고 있지만, 글로벌 상태 관리와 오류 처리 측면에서 한계가 있어 Node.js 테스트에는 덜 적합합니다. 특히 Jest는 Node.js의 글로벌 객체를 오버라이드하여 네트워크 및 데이터베이스와의 작업에서 문제를 일으킬 수 있습니다. 만약 대규모 Jest 테스트 스위트를 보유하고 있다면, 글로벌 패칭을 피하는 jest-light-runner로 전환하는 것을 권장합니다.
Vitest, Node-tap, jest-light-runner, import('node:test'), tape, Mocha와 같은 Node.js 특정 테스트 요구사항을 더 잘 지원하는 대안을 고려 해보 세요. 브라우저를 포함하는 E2E 테스트의 경우 Playwright를 추천합니다.
> 타입스크립트와 테스트
TypeScript는 유용한 정적 타입 검사를 제공하지만 철저한 테스트의 필요성을 대체하지는 않습니다. 테스트는 런타임의 올바름을 보장하고 TypeScript의 타입 시스템이 놓칠 수 있는 잠재적 문제를 잡아내기 위해 필수적입니다. 만약 JavaScript와 별도의 타입을 작성하고 있다면, 해당 타입들도 테스트하는 것을 잊지 마십시오. 이를 위해 tsd를 사용하는 것을 권장합니다.
> 모킹(Mocking)
모킹은 실제 종속성을 시뮬레이션된 버전으로 교체하여 구성 요소를 격리하고 테스트를 단순화하는 것을 말합니다. 또한 테스트가 동일한 조건 하에서 항상 재현 가능하도록 하는 데 유용합니다. 예를 들어, 외부 서버에서 리소스를 다운로드하는 구성 요소와 같이 제어할 수 없는 경우가 이에 해당합니다.
일반적인 모킹 라이브러리
일부 테스트 프레임워크(Jest 또는 Node 등)는 통합된 모킹 기능을 제공합니다.
코드 품질 및 보안
Node.js 애플리케이션의 품질과 보안을 보장하는 것은 신뢰할 수 있고 안정적인 소프트웨어를 제공하기 위한 핵심 요소입니다. 강력한 테스트 관행을 도입하고 정적 분석 도구를 활용하며 정기적인 취약점 스캔을 수행함으로써 코드베이스의 전반적인 건강을 크게 향상시킬 수 있습니다.
Key practices:
정기적인 취약점 스캔: Snyk, SonarQube, npm audit와 같은 도구를 사용하여 잠재적인 보안 위협을 사전에 식별하고 해결합니다.
정적 분석 및 린팅: ESLint와 같은 도구를 사용하여 문법 오류, 코드 스타일 위반, 잠재적인 보안 취약점을 개발 초기 단계에서 감지합니다.
By engaging in these practices, you stand to benefit through:
코드 품질 향상: 가독성, 유지 관리성, 모범 사례 준수 향상.
보안 위험 감소: 취약점을 사전에 식별하고 완화.
빠른 개발: 결함을 조기에 감지하여 프로덕션에 도달하는 결함 수와 재작업 필요성을 줄임으로써 팀의 속도를 효과적으로 높입니다.
내용이 길어 2편까지 작성하겠습니다!
출처
'NodeJS' 카테고리의 다른 글
Redoc 적용하기 - NodeJs (0) | 2025.02.12 |
---|---|
[TypeScript] Interface 와 Type 의 차이 (0) | 2024.11.14 |