요청 하나가 아니라 전부가 느려졌다 · 쪼개기와 옮기기 · 이론
쪼갤 것인가 옮길 것인가, 그리고 각각의 값
한 줄 요약
루프를 오래 붙잡는 계산을 다루는 길은 둘뿐이다. 조각으로 나눠 사이사이 차례를
돌려주거나, 다른 스레드로 통째로 옮기거나. 둘 다 공짜가 아니고, 값을 재 두면
"어디에 둘까" 가 취향이 아니라 계산이 된다.
왜 이게 필요했나
막는 코드를 찾았다고 해서 문제가 끝나지는 않는다. 그 계산은 여전히 필요하고,
어딘가에서는 해야 한다. "워커로 보내라" 는 흔한 조언인데, 그대로 따랐다가
되돌리는 경우가 많다. 두 가지를 빠뜨리기 때문이다.
하나는 메모리를 공유하지 않는다는 것이다. 워커 스레드는 이 요청의 객체를
그대로 볼 수 없다. 값을 복사해서 보내고 복사해서 받을 뿐이라, 지금 들고 있는
연결이나 캐시를 건드려야 하는 일은 애초에 옮길 수 없다. 큰 객체를 넘기면
복사 자체가 새로운 비용이 된다.
다른 하나는 띄우는 데 시간이 든다는 것이다. 실습 이미지의 Node 22.11.0 에서
빈 일감 하나로 재 보면 워커 하나를 띄워 답을 받기까지 22ms 에서 31ms 가 걸렸다.
5ms 짜리 계산을 옮기면 다섯 배 손해다. 그래서 실제 서비스는 워커를 미리 몇 개
띄워 두고 재사용한다 — 그 설계가 필요한 이유가 이 숫자다.
쪼개기는 반대의 성질을 가진다. 같은 스레드에 남으니 상태를 그대로 보고 복사도
없다. 대신 총 시간이 조금 늘고, 더 중요하게는 **조각 하나의 시간이 지연의
바닥**이 된다.
어떻게 동작하나
쪼개기는 "나눈다" 가 절반이고 "양보한다" 가 나머지 절반이다. 반복문을 조각으로
나누기만 하고 사이에 아무것도 하지 않으면, 루프에서 보면 한 번에 돈 것과 똑같다.
양보는 한 줄이다.
for (const { from, to } of chunks(total, sliceSize)) { result = hashRange(from, to, result); // 앞 조각의 값을 이어받는다 await new Promise((resolve) => setImmediate(resolve)); // 여기서 차례를 넘긴다}setImmediate 는 체크 단계에서 불리므로, 이 한 줄 사이에 대기 중이던 타이머와
완료된 입출력 콜백이 처리된다. Node 22.11.0 에서 4천만 번 도는 계산을 한 번에
돌리면 루프가 290ms 멈추고, 40조각으로 나누면 8ms 로 떨어진다. 총 시간은
1.03배로 늘었다 — 양보 한 번마다 루프를 한 바퀴 도는 값이다.
여기서 설계 손잡이가 하나 나온다. 조각 하나를 도는 동안에는 아무도 끼어들 수
없으므로, 목표 지연을 정하면 조각 크기가 따라 나온다. 위 실측에서 조각
하나는 7.9ms 였고 잰 지연 최대는 8ms 였다. 50ms 안에 응답하고 싶다면 조각 하나가
50ms 를 넘지 않게 잡으면 된다. "적당히 나눈다" 가 아니라 계산으로 정할 수 있다.
옮기기는 다른 모양이다. worker_threads 의 Worker 는 파일을 하나 받아 새
스레드에서 그 모듈을 실행하고, workerData 로 값을 건네고 postMessage 로
결과를 돌려받는다. 계산이 그쪽에서 도는 동안 이쪽 루프는 완전히 자유롭다 —
같은 4천만 번 계산을 워커로 보내니 이쪽의 지연 최대는 9ms 였다.
const worker = new Worker(new URL("./hash-worker.mjs", import.meta.url), { workerData: { from: 0, to: total } });worker.once("message", (result) => { /* 복사되어 건너온 값 */ });한 가지 덫이 있다. 워커는 부모 프로세스의 실행 인자를 물려받는다. 그래서node --input-type=module -e "..." 로 돌린 프로그램 안에서 파일 기반 워커를
띄우면 ERR_INPUT_TYPE_NOT_ALLOWED 로 죽는다. 워커를 쓰는 프로그램은 파일로
두고 그대로 실행하는 편이 안전하다.
현장에서 만나는 모습
판단 규칙은 생각보다 단순하게 정리된다. 한 번에 1ms 안쪽이면 그냥 둔다 — 재는
값이 고치는 값보다 크다. 그보다 무거운데 지금 요청의 상태를 봐야 하면 옮길 수
없으니 쪼갠다. 상태와 무관한 순수 계산이고 기동 비용을 물 만큼 무거우면 옮긴다.
"무거우면" 의 경계는 기동 비용에서 나오므로, 22ms 가 걸리는 기계에서는 50ms
근처가 합리적이고 다른 기계에서는 다른 값이 된다. **규칙은 측정에 딸린 것이지
외우는 상수가 아니다.**
현장에서 자주 보는 실패는 쪼개기를 흉내만 낸 코드다. 반복문을 for 에서map 으로 바꾸거나 함수를 async 로 선언해 놓고 쪼갰다고 생각하는 것인데,
둘 다 루프에 차례를 돌려주지 않는다. async 함수 안이라도 await 를 만나기
전까지는 동기로 끝까지 돈다. 양보했는지 확인하는 방법은 하나다 — 계산이 도는
동안 짧은 타이머가 몇 번 울렸는지 세어 보면 된다.
다음 실습에서 할 것
같은 계산을 세 가지 방법으로 돌려 봅니다. 한 번에, 조각으로 나눠서, 그리고 워커
스레드에서. 세 벌 모두 답이 같아야 하고(쪼개기는 답을 바꾸지 않습니다), 지연
분포는 크게 달라야 합니다.
그다음 각각의 값을 계산합니다 — 쪼개기의 총 시간 증가와 조각 하나의 시간,
워커의 기동 비용. 마지막으로 그 숫자들을 place(job) 이라는 규칙으로 굳혀,
다음 사람이 같은 판단을 할 수 있게 만듭니다.