요청 하나가 아니라 전부가 느려졌다 · 배압과 스트림 · 실습
쌓이는 바이트를 직접 잰다
목표
빠른 생산자와 느린 소비자 사이에서 메모리가 어떻게 늘어나는지를 직접 재고,write() 의 반환값과 drain 이벤트가 무엇을 막아 주는지 확인합니다.
왜 중요한가
"메모리 누수" 로 신고되는 것의 상당수는 새는 것이 아니라 줄이 서 있는
것입니다. 초당 10MB 를 만들어 내는 쪽과 초당 2MB 를 받아 가는 쪽을 그냥 이어
두면, 남는 8MB 는 어딘가에 쌓여야 하고 그 어딘가가 이 프로세스의 힙입니다.
Node 의 스트림은 이것을 막을 손잡이를 하나 줍니다 — write() 의 반환값입니다.
그런데 그 값은 그냥 두면 아무 일도 하지 않습니다. **읽고 멈춰 주는 것은 쓰는
쪽의 몫입니다.** 그 한 줄이 빠지면 코드는 멀쩡히 돌고, 시험도 통과하고, 운영에
나가서 큰 파일 하나에 프로세스가 죽습니다.
그리고 배압을 지키면 느려질 것 같지만, 재 보면 그렇지 않습니다. 소비자가 받는
속도는 어차피 같기 때문입니다. 잃는 것은 거의 없고 막는 것은 큽니다.
단계
1. /root/work/backpressure/flow.mjs 에 makeSink(options) 를 만들고,
이 판의 기본 highWaterMark 를 재어 report.json 의 hwm 에 적습니다.
2. fillUntilFalse(stream, chunk) 로 버퍼가 차는 지점을 셉니다.
3. writeAll(stream, chunks) 로 배압을 지키며 씁니다.
4. floodNoWait(stream, chunks) 로 반환값을 무시하고 부어 runs.flood 에 적습니다.
5. 같은 양을 writeAll 로 보내 runs.paced 에 적고 backpressure 에 비율을 적습니다.
6. pipeThrough(readable, writable) 로 같은 일을 하고 runs.pipeline 에 적습니다.
7. estimateQueueBytes 와 secondsUntil 로 쌓이는 속도를 계산합니다.
참고
makeSink({hwm, delayMs})는 받은 양을receivedBytes·receivedChunks에writeAll과floodNoWait은 둘 다{peakBuffered}를 돌려줍니다.- 4번과 5번은 같은 양으로 재야 비교가 됩니다. 조각을 배열로 미리 만들지 말고
rssDeltaMB는process.memoryUsage().rss의 차이를 MB 로 적은 것입니다.- 흔한 실수:
readable.pipe(writable)로 끝내는 것. 배압은 지켜 주지만
세는 Writable 입니다. delayMs 가 0 이면 setImmediate 로, 0 보다 크면
그만큼 늦게 콜백을 부릅니다.
stream.writableLength 가 그 순간 쌓여 있는 바이트입니다.
보낼 때마다 새로 만드세요 — 미리 만들면 재기도 전에 이미 다 쓴 상태가 되고,
같은 버퍼를 다시 쓰면 참조만 쌓여 메모리가 늘지 않습니다.
오류를 올려 주지 않아서, 소비자가 죽어도 생산자는 계속 읽습니다.
단계 7개
- 이 판의 기본값을 직접 잰다
- write() 가 false 를 내는 지점
- 멈추고, drain 을 기다린다
- 반환값을 무시하면 어디까지 쌓이는가
- 같은 양을 배압을 지켜 보낸다
- 실패했을 때 누가 치우는가
- 몇 초 뒤에 터지는가