요청 하나가 아니라 전부가 느려졌다 · 무엇이 막고 무엇이 안 막는가 · 이론
이름 끝의 네 글자가 사고를 가른다
한 줄 요약
crypto.pbkdf2 와 crypto.pbkdf2Sync 는 같은 계산을 같은 시간 동안 한다.
다른 것은 어디서 도는가 뿐이고, 그 차이가 "이 요청만 느리다" 와 "전부
느리다" 를 가른다. 그리고 비동기라고 해서 전부 같은 자리에 있는 것도 아니다.
왜 이게 필요했나
"비동기로 바꾸면 된다" 는 말은 너무 뭉뚱그려져 있다. 실제로는 세 자리가 있고,
셋의 성질이 다르다.
첫째, 이벤트 루프 위에서 도는 일. JSON.parse, 정규식, 큰 배열 정렬,
그리고 이름이 Sync 로 끝나는 모든 것이 여기 있다. 이 일들은 도는 동안 그
프로세스의 다른 모든 것을 세운다.
둘째, libuv 스레드 풀. 파일 읽기와 압축, 비동기 암호 연산이 여기로 간다.
루프는 자유롭지만 칸이 정해져 있다. 기본이 네 칸이라, 무거운 작업 다섯 개를
동시에 보내면 다섯 번째는 앞의 하나가 끝날 때까지 시작조차 못 한다.
셋째, 커널. 소켓 읽기와 쓰기, 그리고 dns.resolve*() 가 여기다. 운영체제가
"준비됐다" 고 알려 줄 때까지 루프는 그냥 기다린다. 스레드도 CPU 도 쓰지 않는다.
이 세 자리를 구분하지 못하면 이상한 일이 생긴다. 예를 들어 이미지 압축을
비동기로 바꿔서 루프는 살렸는데, 그 뒤로 파일 읽기가 느려진다. 둘이 같은 네
칸을 나눠 쓰기 때문이다. 코드만 보면 두 기능은 아무 관계가 없다.
어떻게 동작하나
어느 API 가 스레드 풀을 쓰는지는 외울 것이 아니라 문서에 적혀 있다. Node 의
[명령줄 옵션 문서](https://nodejs.org/docs/latest-v22.x/api/cli.html) 의UV_THREADPOOL_SIZE 절은 목록을 직접 적어 둔다 — 파일 감시와 명시적 동기
함수를 뺀 모든 fs API, crypto.pbkdf2()·crypto.scrypt()·crypto.randomBytes()·crypto.randomFill()·crypto.generateKeyPair() 같은
비동기 암호 API, dns.lookup(), 그리고 명시적 동기판을 뺀 모든 zlib API 다.
같은 문서가 기본 크기를 4 라고 적고, libuv 쪽 문서는 절대 상한이
1024 라고 적는다([libuv 스레드 풀](https://docs.libuv.org/en/v1.x/threadpool.html)).
dns.lookup 과 dns.resolve4 가 갈리는 것이 이 표에서 가장 헷갈리는 자리다.
둘 다 이름을 주소로 바꾸는데, 앞은 운영체제의 getaddrinfo(3) 를 스레드 풀에서
부르고, 뒤는 네트워크로 직접 질의한다. Node 의
[DNS 문서](https://nodejs.org/docs/latest-v22.x/api/dns.html) 는 후자에 대해
"이 네트워크 통신은 언제나 비동기로 이루어지며 libuv 의 스레드 풀을 쓰지
않는다" 고 못 박는다. 그래서 이름 조회가 많은 서비스에서 파일 읽기가 느려지는
일이 실제로 일어난다.
칸이 네 개라는 사실은 재 보면 바로 보인다. 같은 무게의 비동기 작업을 네 개
보내면 넷이 거의 같은 시각에 끝나고, 여덟 개를 보내면 두 덩어리로 갈라진다.
실습 이미지의 Node 22.11.0 에서 CPU 2코어로 재 보면 이렇게 나온다.
4개: 298 301 302 307 ms — 함께 끝난다8개: 315 317 321 325 | 700 703 703 704 ms — 뒤 네 개는 한 바퀴를 기다린다그러면 칸을 늘리면 될까. UV_THREADPOOL_SIZE=8 로 다시 재면 여덟 개가 모두
710ms 부근에서 끝난다. 물결은 사라졌는데 전체가 끝나는 시각은 그대로다.
늘어난 것은 동시에 시작할 수 있는 일감의 수이지 계산해 주는 CPU 가 아니기
때문이다. 같은 문서가 덧붙이는 주의도 중요하다 — 이 값을 프로그램 안에서process.env 로 바꾸는 것은 보장되지 않는다. 스레드 풀은 사용자 코드가 돌기
훨씬 전에 만들어지기 때문이다.
현장에서 만나는 모습
가장 흔한 것은 비동기판이 아예 없는 일이다. JSON.parse 가 대표적이다.
파일을 fs.readFile 로 비동기로 읽어도 그 문자열을 객체로 바꾸는 일은 루프
위에서 일어난다. Node 22.11.0 에서 12.9MB 짜리 JSON 을 파싱해 보면 76ms 가
걸렸고, 그동안 루프는 58ms 멈췄다. 옮길 곳이 없으니 남는 선택은 **본문 크기를
제한하는 것**뿐이다. 요청 본문 한도가 곧 지연 예산이 되는 이유가 여기 있다.
두 번째는 이름 끝 네 글자다. 같은 이미지에서 8MB 를 zlib.gzipSync 로 누르면
루프가 183ms 멈추고, zlib.gzip 으로 누르면 9ms 다. 일 자체는 각각 178ms 와
219ms 로 비슷하다. 코드 리뷰에서 Sync 를 찾아보는 것이 성능 개선의 첫 줄인
이유가 이 숫자다. 특히 시작할 때 설정 파일을 읽는 readFileSync 는 문제가 없고
요청 처리 경로의 readFileSync 는 사고라는 점 — 같은 함수라도 어디서 부르는가
가 판단을 바꾼다.
다음 실습에서 할 것
공식 문서의 목록을 표로 옮겨 놓고, 그 표가 맞는지 직접 재서 확인합니다.
같은 계산을 동기와 비동기로 한 번씩 돌리며 루프 지연을 재고, 비동기판이 없는JSON.parse 로 옮길 곳이 없는 일을 확인하고, 같은 일감 여덟 개를 보내 스레드
풀의 칸을 세어 봅니다. UV_THREADPOOL_SIZE 를 올려 물결이 사라지는 것과 전체
시간이 그대로인 것을 함께 재고, 마지막으로 zlib 에 같은 방법을 적용합니다.