ISR를 이용한 실시간 포스팅 내용 수정 기능은 블로그에 있어서 정말 핵심 기능이었다.
따라서 해당 이슈를 꼭 해결하고 싶었고 어찌저찌 3일차에 mode: ‘no-cors’옵션이 문제라는 것을 파악했지만 왜 그게 문제를 일으키는지를 몰랐을때 2차 멘붕이 왔다.
진짜 원인을 파악하기 위해 ky 라이브러리를 클론하여 이것저것 테스트해보았다. 네트워크 관련 라이브러리다 보니 모르는 개념 투성이고 그 구조를 이해하는데 시간이 오래 걸렸다.
제미나이, 코파일럿과 함께 문제의 원인을 정확히 파악하기 위해 3일을 보냈다. 정말 뭐가 문제인지를 모르겠어서 힘들었다. 하지만 여기서 포기하면 찝찝하기도 하고, ky라는 유명한 라이브러리에 기여를 할 수 있는 기회를 놓치고 싶지 않았던 것 같았다. (자그마치 주간 다운로드 수가 350만…!!)

문제해결 흐름 따라가기
내가 문제를 해결한 흐름을 같이 따라가보자.
우선 ky라이브러리에 디버깅 용 코드를 추가하여 문제가 되는 부분을 파악했고 해당 코드를 추가한 PR을 한번 살펴보았다.


Ky 1.8.0 hang
Github
Ky 1.8.0 hang
Updated
Apr 19, 2025이 코멘트(
GitHubKy 1.8.0 hang · Issue #690 · sindresorhus/ky
)에서 귀중한 힌트를 얻을 수 있었다.
Ky 1.8.0 hang · Issue #690 · sindresorhus/ky
We are experiencing a hang in Ky 1.8.0 that is a first for us. We have a pretty straightforward ky.post(url, {json: someObj}) that works fine in 1.7.5. We tested under node 18 and node 22. We can r...
다음 코드가 Node.js 환경에서만 멈춘다는 것이다.
(async function () { let r = new Request('https://example.org', { method: 'POST', body: 'body', }) let clone = r.clone() // Consuming the body of the cloned request allows the original request to be cloned // await clone.text() if (!r.bodyUsed) { await r.body?.cancel() // The promise never completes if the request was cloned but the original body never consumed console.log('done') } })()
이후 이 대화는 Node.js 자체의 버그로 결론을 짓고 Closed되었다.
그렇다면 이 문제를 어떻게 해결할 수 있을까?
아무리 Node.js 자체의 버그로 인한 문제라고 하여도, ky라이브러리가 멈추는 버그는 막아야 한다고 생각했다. 그래서 다음과 같이 이에 대한 우회 방법을 PR(
)로 작성하였다.
Fix hang on stream request cleanup in Node.js
Github
Fix hang on stream request cleanup in Node.js
Updated
Sep 27, 2025우회 방법은 cancel() 메서드 대신, arrayBuffer()를 사용하는 것이다. 두 메서드의 차이는 목적에 있다.
arrayBuffuer(): 버퍼를 모두 “소비”하는 것.cancel(): 버퍼를 모두 “제거”하는 것.
arrayBuffer()를 사용하게 되면, 아주 큰 용량의 스트림이 모두 소비가 되어야 하므로 순간적으로 메모리 사용량이 급증할 수 있다는 리스크가 존재했다. 그러나 ky가 멈추는 버그를 해결하는게 더 급선무라 생각했기에 이에 대한 PR을 작성하였다.
PR 리뷰 반영
리뷰는 굉장히 빠르게(10시간 이내) 달렸다.
Node.js의 문제인 것 같으므로, Node.js에서 연관 이슈가 존재하는지 확인해보고, 없으면 추가해서 이에 대한 링크를 첨부해달란 내용이었다. 그리고 arrayBuffer()를 사용하여 우회하는건 Node.js에만 적용되어야 한다는 내용도 있었다.

내가 할 일은 2가지였다.
- Node.js에서 연관 이슈 찾고 없으면 만들기.
- arrayBuffer()를 Node.js환경에서만 적용시키기.
Node.js에서 연관 이슈 찾기
Node.js깃허브 레포에 들어가보았는데, 이슈 개수가 1,700개를 넘어가고 있었다…… 이 상황에서 어떻게 연관 이슈를 찾아야 할까??

나는 우선 제미나이의 Deep Research와 GPT에게 위 맥락을 공유한뒤 연관 이슈를 검색해달라고 요청했다.


둘 다 정확히 동일한 증상에 대한 Node.js 이슈를 찾지 못했다. 다만, 제미나이에서 다음과 같은 단서를 제시했다.
Node.js 런타임에서ReadableStream본문을 가진Request객체가 복제되고(내부적으로tee()를 통해 두 개의 스트림 분기 생성), 이 두 분기 중 어느 쪽도 소비되지 않은 상태(즉, 둘 다 읽지 않은 원시 상태)에서 한 분기에 대해await request.body.cancel()을 호출하면 절대 이행되지 않는 프로미스가 반환됩니다. 이 무기한 대기 상태는 스트림 사양을 위반하며ky라이브러리에서 관찰된 멈춤(hang) 현상의 직접적인 원인입니다.

이 말은 ky라이브러리에서 cancel()해주는 로직이 잘못 작성되어 있다는 뜻이다.
이 가설을 위해 내가 Fact 체크 해야하는 부분은 2가지다.
- body.clone() 메서드가 내부적으로 정말 tee()를 호출하고 있는지
- tee()로 분기된 스트림들을 모두 cancel()을 해줘야 하는지
nodejs/undici
Node.js에서 fetch와 같이 http 통신을 담당하는 라이브러리는 undici이다. 따라서 우리가 찾고자 하는 clone의 구현부분은 undici에 있을것이라고 짐작했다.
실제로 undici/lib/web/fetch/request.js에서 clone에 대한 메서드 정의코드를 확인할 수 있었다.

cloneRequest가 실질적인 요청을 복제하는 것 같으니 이를 확인해보자. makeRequest로 새로운 요청을 만들고 cloneBody로 새로 만든 요청객체의 body를 덮어쓰고 있다.

그렇다면 cloneBody를 확인해보자. 우리가 찾던 tee()를 여기서 확인할 수 있었다. 즉, 이로써 clone()는 내부적으로 tee()를 호출한다는 말은 사실이다.

WHATWG
이제 “2. tee()로 분기된 스트림들을 모두 cancel()을 해줘야 하는지”만 확인해주면 우리의 가설 검증은 완료된다.
해당 부분은 DOM, Fetch, HTML 등 웹 표준을 개발하고 유지하는 단체인 WHATWG(Web Hypertext Application Technology Working Group)에서 확인할 수 있었다.
그 중에서도 Stream과 관련된 스펙인
Streams Standard![Streams Standard]()
에서 tee() 메서드의 내용을 살펴보았다.
Streams Standard
This specification provides APIs for creating, composing, and consuming streams of data that map efficiently to low-level I/O primitives.

해당 내용을 한국어로 번역하면 다음과 같다.
이 읽기 가능한 스트림을 tee 처리하여, 두 개의 새로운ReadableStream인스턴스로 구성된 브랜치를 담은 2요소 배열을 반환합니다.스트림을 tee 처리하면 해당 스트림이 잠금(lock) 상태가 되어, 다른 소비자가 리더(reader)를 획득할 수 없게 됩니다. 스트림을 취소(cancel)하려면 두 브랜치를 모두 취소해야 합니다. 그러면 조합된 취소 이유가 스트림의 기본 소스(underlying source)로 전파됩니다.만약 이 스트림이 읽기 가능한 바이트 스트림(readable byte stream)이라면, 각 브랜치는 각 청크(chunk)의 개별 복사본을 받게 됩니다. 그렇지 않은 경우, 각 브랜치에서 보이는 청크는 동일한 객체입니다. 만약 청크가 불변(immutable)이 아니라면, 이는 두 브랜치 간에 간섭을 일으킬 수 있습니다.
밑줄 친 부분에서 답을 얻을 수 있었다. 이로써 우리는 ky라이브러리에서 cancel()메서드를 잘못 사용하고 있음을 확인했다.
진짜 문제 제시 및 반영
이후 위 내용을 바탕으로 진짜 문제의 원인을 제시하는 PR 코멘트를 달았고, 수정 사항을 반영한 커밋을 진행했다.

내가 고친 방안은 분기된 2개의 스트림이 모두 사용되지 않았을 경우, 이 2개를 모두 cancel()해주는 것이다.
.finally(async () => { const originalRequest = ky._originalRequest; // Cancel both the original and cloned request bodies to prevent hanging. if (originalRequest && !originalRequest.bodyUsed && !ky.request.bodyUsed) { await Promise.all([ originalRequest.body?.cancel(), ky.request.body?.cancel(), ]); } }) as ResponsePromise;
그러나 메인테이너 분이 “만약, 하나의 스트림은 소비되고, 나머지 하나의 스트림만 cancel()해줘야 하는 경우, 위 로직은 제대로 작동되지 않으며 메모리 누수가 발생할 것”이라고 리뷰를 남겨주셨다.

맞다.. 이 부분은 간과했었다… 그래서 급히 다음 수정 방안을 공유했다. 이 방안은 스트림을 개별에 대해서 상태를 체크하고 만약 사용되지 않았다면 cleanupPromises 배열에 Promise를 넣은 뒤, Promise.all()로 한번에 병렬적으로 Promise를 실행시킨다. 이로써 나머지 하나만 cancel()해야할 때도 정확히 메모리 누수를 방지할 수 있다.
.finally(async () => { const originalRequest = ky._originalRequest; const cleanupPromises = []; // Add the original request's stream to the cleanup list if it was never used. if (originalRequest && !originalRequest.bodyUsed) { cleanupPromises.push(originalRequest.body?.cancel()); } // Add the cloned request's stream to the cleanup list if it was never used. if (!ky.request.bodyUsed) { cleanupPromises.push(ky.request.body?.cancel()); } // Await all cancellations concurrently. if (cleanupPromises.length > 0) { await Promise.all(cleanupPromises); } })
이 코멘트던 시간이 새벽 3시였다.. 그래서 답장을 확인하지 못한채 바로 잠들었다.
다음날 일어나보니 메인테이너분의 고군분투 흔적을 확인할 수 있었다.
내가 자는 사이 메인테이너분이 해당 방안을 수용하여 반영한 뒤, 테스트 코드도 수정을 했는데 테스트가 통과되지 않는 이슈가 있었다. 이에 대해서 나에게 의견을 물었지만, 그 당시 나는 자고 있었기에 답변을 못했다..
나의 답장이 없자 메인테이너분이 직접 테스트 실패 원인이 현재 사용중인 Jest버젼에서의 메모리 누수 감지에 오류가 있는 버그가 있었다는 것을 확인했고, Jest버젼을 최신으로 업그레이드하여 테스트 통과도 확인했다.
나는 일어나서 이 사항들을 모두 확인했으며, 문제없다는 코멘트를 남겼다. 이후 해당 PR은 머지됐다.
후기
후.. 정말 오랜 기간 동안 싸워온 것 같다. 맨 처음에는 내가 ISR을 잘못 적용했나? 싶었지만, 파고들면 들수록 정말 기묘한 버그였어서 원인을 파악하고 PR이 머지되기까지 약 2주 정도 소요된 것 같다.
정말 정말 고생했지만 그만큼 뿌듯하기도 하다. AI를 활용하여 함께 가설을 증명해 나가는 것이 재밌기도 했다. 한가지 확실한건 AI의 논리를 최종 점검하는 것은 사람의 몫이다. AI의 논리를 최종적으로 점검하기 위해서는 나의 역량도 중요한 것 같다. 잘못하면 AI의 논리에 휘말려 오답을 맞다고 생각할 수도 있기 때문이다.
AI시대에 살아남는 개발자가 되기 위해선, AI와 파트너로서 본인의 역량을 기르는 방법을 탐구 해야하지 않을까?