LabHub
배우기 러닝패스 코스

요청 하나가 아니라 전부가 느려졌다 · 배압과 스트림 · 이론

새는 게 아니라 줄이 서 있는 것이다

LabHub 에서 이어서 보기

한 줄 요약

빠른 생산자와 느린 소비자를 그냥 이어 두면 남는 차이는 반드시 어딘가에 쌓이고,
Node 에서 그 어딘가는 이 프로세스의 힙이다. write() 의 반환값은 그것을 막을
손잡이인데, 읽고 멈춰 주는 것은 쓰는 쪽의 몫이다.

왜 이게 필요했나

"메모리 누수가 있는 것 같습니다" 로 시작하는 조사 가운데 상당수는 누수가 아니다.
초당 10MB 를 만들어 내는 쪽과 초당 2MB 를 받아 가는 쪽을 이어 두면 남는 8MB 는
어딘가에 있어야 하고, 그 어딘가가 스트림의 내부 버퍼다. 힙 스냅숏을 떠 보면
Buffer 가 잔뜩 잡혀 있는데 코드에는 그것을 붙잡고 있는 곳이 없다. 아무도 새게
하지 않았고, 그냥 줄이 길어진 것이다.

이 사고가 특히 늦게 발견되는 이유는 작은 입력에서는 절대 안 나기 때문이다.
시험 데이터 2MB 로는 버퍼가 차기도 전에 끝난다. 운영에서 800MB 짜리 파일 하나가
들어오는 날 처음 드러나고, 그날 프로세스는 메모리 한도에 부딪혀 죽는다.
쿠버네티스라면 OOMKilled 로 찍히고, 재시작되고, 같은 요청이 다시 들어온다.

그래서 이 사고는 코드 리뷰에서 잡아야 한다. 잡는 자리는 한 곳이다 —
쓰는 쪽이 write() 의 반환값을 보고 있는가. 보고 있지 않다면 그 코드는
언제 터질지가 입력 크기에만 달려 있고, 그 크기는 우리가 정하지 않는다.

어떻게 동작하나

Node 의 Writable 스트림은 내부에 큐를 두고 highWaterMark 라는 기준선을 갖는다.
write() 는 조각을 큐에 넣은 뒤, **쌓인 양이 기준선보다 적으면 true, 아니면
false** 를 돌려준다. 공식 문서의 표현을 그대로 옮기면 false 는 "이 조각을 받긴
했지만, 더 쓰기 전에 drain 이벤트를 기다려 달라" 는 뜻이다
([스트림 문서](https://nodejs.org/docs/latest-v22.x/api/stream.html)).

중요한 것은 false 가 쓰기를 거부하지 않는다는 점이다. 무시하고 계속 쓰면
스트림은 계속 받아서 큐에 넣는다. 한도는 아무것도 막지 못한다. 멈추는 것은
전적으로 쓰는 쪽의 책임이고, 그것이 이 두 줄이다.

for (const chunk of chunks) {  if (!stream.write(chunk)) {    await new Promise((resolve) => stream.once("drain", resolve));  }}

기준선의 기본값은 판에 따라 달라진 적이 있으니 외우지 말고 재는 편이 좋다.
실습 이미지의 Node 22.11.0 에서 재 보면 Writable 과 Readable 모두 **65536바이트
(64KiB) 이고, 객체 모드는 16개**다. 예전 기억에 있는 16384 를 그대로 들고
계산하면 어긋난다.

차이는 재 보면 극적이다. 같은 이미지에서 64KB 조각 2000개(125MB)를 반환값을
무시한 채 부으면 버퍼에 125MB 가 그대로 쌓이고 RSS 가 131MB 늘었다. 같은 양을
drain 을 기다리며 보내면 쌓인 최대가 65536바이트, 즉 기준선 그대로였다.
수천 배 차이다.

그런데 전체 시간은 어땠을까. 배압을 지킨 쪽이 오히려 조금 빨랐다. 소비자가
받아 가는 속도는 어느 쪽이든 같기 때문이다. "기다리면 느려진다" 는 직관은 여기서
성립하지 않는다 — 기다리지 않으면 그 차이가 시간이 아니라 메모리로 갈 뿐이다.

현장에서 만나는 모습

손으로 쓴 배압에는 빈칸이 하나 더 있다. 실패했을 때 누가 치우는가 다.
readable.pipe(writable) 은 배압은 지켜 주지만 오류를 올려 주지 않는다. 소비자
쪽에서 오류가 나면 생산자는 그 사실을 모른 채 계속 읽고, 오류는 아무도 듣지 않는
곳에서 터진다. 파일 핸들과 메모리가 남는 것은 덤이다.

stream/promisespipeline 은 그 자리를 메운다. 한쪽이 실패하면 나머지를
정리하고 오류를 부른 쪽까지 올려 준다. 실제로 재 보면 실패하는 소비자를 물렸을
pipeline 은 그 오류로 거부되면서 생산자도 함께 정리했고, pipe 로 이어
붙인 쪽은 아무 소식 없이 영원히 기다렸다. 코드 리뷰에서 pipe( 를 찾는 것이
Sync 를 찾는 것만큼 값어치가 있는 이유다.

마지막으로, 이 이야기는 산수로 옮길 수 있다는 점이 실무에서 가장 쓸모 있다.
생산 속도와 소비 속도를 알면 초당 쌓이는 양은 그 차이이고, 한도까지 남은 시간은
한도를 그 차이로 나눈 값이다. "메모리가 좀 올라가는데요" 대신 "지금 속도면 4분
뒤에 한도에 닿습니다" 라고 말할 수 있으면, 대화가 추측에서 계획으로 바뀐다.

다음 실습에서 할 것

이 판의 기본 기준선을 직접 재서 적는 것으로 시작합니다. write() 가 false 를
내는 지점을 세어 보고, drain 을 기다리는 쓰기를 만들고, 같은 양을 두 방식으로
보내 쌓인 바이트와 RSS 와 전체 시간을 나란히 잽니다.

그다음 pipeline 으로 같은 일을 하면서, 일부러 실패하는 소비자를 물려 오류가
올라오고 생산자가 정리되는지 확인합니다. 마지막으로 쌓이는 속도와 한도까지 남은
시간을 계산하는 함수를 만듭니다. 채점기는 적어 낸 비율을 두 기록에서 다시 계산해
대조하고, 만든 함수를 직접 돌려 같은 성질이 나오는지 봅니다.