[Node.js] "Promise.all()"은 진짜 병렬로 실행될까?
Node.js 환경에서 개발을 진행할 때, 비동기 작업을 동시에 진행하고 싶다면 먼저 떠올리는 것이 "Promise.all()" 일 것이다.
Promise.all()은 Node.js 환경에서 다수의 비동기 작업을 병렬로 처리하고자 할 때 가장 자주 사용되는 도구 중 하나이다. 실제 서비스 코드에서도 외부 API 호출, 데이터베이스 조회, 파일 시스템 접근 등을 병렬로 처리하기 위해 Promise.all()이 널리 사용되니다.
Promise에 대해서는 자세하게 다루지 않겠습니다.
하지만 아래와 같은 의문을 가져본 적 있을 것 같다.
"Promise.all()" 이니까 비동기 작업이 진짜로 병렬로 실행되는 건가?
Node.js 는 싱글 스레드로 동작을 하는데, 어떻게 "병렬"이 존재할까?
나도 Promise에 대한 이해의 부족, Node.js의 이벤트 루프 구조, libuv 기잔의 I/O 처리 방식 등의 Javascript의 실행 콘텍스트에 대한 이해가 부족할 때는 선뜻 답 할 수 없는 질문이었다.
이번 글에서는 긴단 하면서 완벽하게 알고 사용하지 않았던 Promise.all()의 동작 원리를 파악하고, 실무에서의 오해를 잡아보려고 한다.
"병렬 실행"과 "동시성"의 차이, 그리고 Node.js 런타임의 병렬 처리에 어떤 방식으로 관여하는지를 설명하려고 한다.
먼저 이 글의 주제를 가볍게 요약하고 들어가자면, 아래와 같다.
1. Promise.all() 은 실행을 병렬로 만드는 도구가 아니다.
2. Node.js의 비동기 I/O는 libuv 및 OS에 위임되며, 병렬처럼 작동할 수 있다.
3. CPU 바운드 작업은 병렬 처리되지 않는 다.
4. 실무에서 Promise.all()이 효과적으로 동작하려면 "실행 시점"이 중요하다.
1. Promise.all()의 진짜 역할
Promise.all() 은 작업들을 병렬로 실행되도록 보장하지 않는다. Promise.all()은 주어진 모든 작업들이 완료될 때까지 기다리는 것만 보장할 수 있다. 즉, 실행이 아니라 대기를 병렬화 한 것이다.
아래는 간단한 예를 들어보겠다.
const a = fetchUser();
const b = fetchPosts();
const c = fetchComments();
await Promise.all([a, b, c]);
실눈 뜨고 봤을 때는 Promise.all()이 세 개의 fetch 작업을 병렬로 실행한다고 생각할 수 있다.
하지만 이는 오해이다.
Promise.all()은 이미 시작된 Promise 객체들을 전달받아, 모든 작업이 완료될 때까지 동시에 기다리는 역할을 한다.
위 코드가 Node.js 런타임상에서 어떻게 내부적으로 동작하는지 실행 흐름 전체를 간단하게 요약하자면 아래와 같다.
1. Javascript 엔진이 위 세 fetch 함수들을 순차적으로 실행한다. (완료가 된 것이 아니다.)
2. 이 함수들은 각각 "응답이 오면 값을 넘겨줄게!"라는 Promise 객체를 반환한다.
3. 내부적으로 Node.js 는 비동기 작업을 libuv에게 위임한다.
4. Promise.all([...]) 은 전달된 3개의 Promise 적업을 기다린다.
5. 각 요청이 완료되면 libuv가 ".then()" 핸들러를 microtask queue에 등록한다.
6. 모든 작업이 완료되면 "Promise.all()"의 ".then()"이 실행된다. (await가 결과를 반환받는다.)
즉, 병렬 처리는 "Promise.all()"이 아니라, fetch 함수들의 개별 비동기 함수의 실행 시점에서 결정된다.
만약 아래와 같이 코드를 작성하여 실행한다면 어떻게 될까?
await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments()
]);
실제로 fetch 함수들 모두 순차적으로 호출되지만, 각 함수가 즉시 Promise를 반환하며 비동기 처리를 시작하기 때문에 결과적으로 병렬과 유사하게 동작한다.
2. Node.js에서 "병렬"은 어떻게 구현되는가?
Node.js는 싱글 스레드 기반의 Event Loop 위에서 동작한다. 그럼에도 다음과 같은 작업은 병렬처럼 수행된다.
- 네트워크 요청 (TCP, HTTP 등)
- 파일 시스템 접근
- DNS 조회
- 일부 암호화 연산
위와 같은 작업들은 Node.js 내부의 libuv가 OS 레벨의 비동기 API 또는 자체 스레드 풀을 통해 처리하도록 위임하기 때문이다.
즉, Javascript 코드는 단일 스레드에서 실행되지만, 비동기 I/O 작업은 libuv와 OS 커널이 병렬로 처리한다.
"Promise.all()"은 이 비동기 작업들을 동시에 대기할 수 있도록 해주는 추상화 도구에 불과하다.
3. (주의할 점) 병렬 처리가 불가능한 상환은? - CPU 바운드 작업
Node.js에서 병렬 처리되지 않는 대표적인 예는 CPU 바운드 연산이다.
- 이미지 리사이징
- 압축
- 암호화
- 대용량 JSON 파싱 등
아래 예시 코드를 보자.
await Promise.all([
heavyComputationA(), // CPU 점유
heavyComputationB(), // CPU 점유
heavyComputationC() // CPU 점유
]);
위와 같은 경우는 Promise.all()을 사용하는 의미가 없다.
모든 연산이 메인 스레드에서 순차적으로 실행되므로 성능 이점은 없다.
진정한 병렬 처리를 원한다면 Worker Threads, 마이크로 서비스 등 별도의 방법을 사용해야 된다.
Javascript 를 이용하여 개발을 진행할 때는 항상 마음속에 "싱글스레드"라는 단어를 품고 작업을 한다.
Event Loop, libuv, microtask queue, callback queue 등에 대한 이해가 컸다면 이번 주제의 질문에 쉽게 대답할 수 있었을 것이다.
이제 아래와 같이 대답할 수 있을 것 같다.
" Promise.all()"은 병렬 실행을 보장하지 않습니다. 단지, 병렬로 실행 중인 작업들을 동시에 기다리는 도구일 뿐입니다요."
참고
- http://anotherdev.xyz/promise-all-runs-in-parallel/ (개인블로그)
- https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick (Event Loop 설명)
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all (MDN Promise.all)