블로그 ISR 적용 트러블 슈팅기(2)

2025-09-20

front

이전편(블로그 ISR 적용 트러블 슈팅기(1))을 보지 않았다면 꼭 보고 오길 바란다!!
 
이전편에서 mode: ‘no-cors’로 요청을 보냈을때 ISR이 제대로 이루어지지 않는 문제가 발생했다.
 
맨 처음에는 no-cors로 인해 응답을 확인할 수 없어 무한정 기다리는 거라고 생각했지만, no-cors는 서버 간 통신에서는 적용되지 않으며, fetch api로 요청을 보내보니 정상적으로 응답이 오는 것을 확인할 수 있었다.
즉, ky에 문제가 있다는걸 확인했다.
 

원인

사실 응답이 오지않는게 아니라 내부적으로 에러가 발생하였고, 이 에러가 상위로 전파되지 않았기에 멈추는 현상이 발생하고 있는 것이다.

request body와 ‘no-cors’의 관계

이 에러는 요청에 body가 포함되고, mode: ‘no-cors’이기에 발생하는 에러이다.
뜬금없이 request body가 나와서 당황할 수 있다. 그러나 해당 내용은 다음 스펙에서 확인할 수 있다. 39번을 보면 다음과 같이 적혀있다.
If inputOrInitBody is non-null and inputOrInitBody’s source is null, then: - If initBody is non-null and init["duplex"] does not exist, then throw a TypeError. - If this’s request’s mode is neither "same-origin" nor "cors", then throw a TypeError. - Set this’s request’s use-CORS-preflight flag.
이를 요약하자면 request body가 비어있지 않은 상태에서 mode가 ‘same-origin’ 또는 ‘cors’가 아니라면(’no-cors’, ‘websocket’, ‘navigate’인 경우들) TypeError가 발생한다는 이야기다.
 
그렇다면 왜 request body가 비어있지 않은 상태에서 ‘same-origin’ 또는 ‘cors’가 아닌 상황에선 TypeError가 발생하는 걸까?
 
그 이유는 body를 request에 포함하여 보낸다는 의미는 그 응답에도 관심이 있다는 뜻인데, 다른 모드들(’no-cors’, ‘navigate’, ‘websocket’)은 응답에 관심이 없거나(’no-cors’) 또는 그 목적이 다르기 때문에(’navigate’, ‘websocket’) 표준에서는 이를 TypeError로 정의하고 있는 것이다.
 
따라서 ky 라이브러리 내부에 이 TypeError에 대한 전파가 제대로 이루어지지 않고 있기에 해당 멈춤 현상이 발생한 것이다.

ky를 파헤쳐보자

ky가 내부적으로 어떻게 함수를 호출하는지 따라가보자. index.ts에서 ky.create()로 ky 인스턴스를 생성하고 있다.
// index.ts const createInstance = (defaults?: Partial<Options>): KyInstance => { const ky: Partial<Mutable<KyInstance>> = (input: Input, options?: Options) => Ky.create(input, validateAndMerge(defaults, options)); // ... return ky as KyInstance; }; const ky = createInstance(); export default ky;
ky.ts에서 Ky클래스 구현체를 확인할 수 있다. 다음은 주요 흐름을 확인하기 위해 정말 정말 간략한 메서드들만 남겨보았다.
export class Ky { static create(input: Input, options: Options): ResponsePromise { const ky = new Ky(input, options); const function_ = async (): Promise<Response> => { // ... let response = await ky._fetch(); // ... }; // ... const result = function_(); // ... return result; } constructor(input: Input, options: Options = {}) { // ... } protected async _fetch(): Promise<Response> { // ... if (this._options.timeout === false) { return this._options.fetch(mainRequest, nonRequestOptions); } return timeout(mainRequest, nonRequestOptions, this.abortController, this._options as TimeoutOptions); } }
ky.create()를 하게 되면 new 연산자로 Ky인스턴스를 생성하게 되고, 생성자(constructor)가 호출된다.
const ky = new Ky(input, options);
생성자 함수에서 각종 세팅과 request 객체를 생성한다.
constructor(input: Input, options: Options = {}) { // ... this.request = new globalThis.Request(this._input, this._options); }
다시 create함수로 넘어와서 function_() 함수를 실행하게 된다. 이 function_()함수 내부에서 실제로 요청을 보내는 _fetch()함수를 호출한다.
const function_ = async (): Promise<Response> => { // ... let response = await ky._fetch(); // ... };
_fetch는 timer가 설정되어 있지 않다면 바로 fetch를 호출하고, timer가 설정되어 있다면 timeout이라는 유틸을 호출한다. _fetch()호출과 function()_ 호출이 순차적으로 마무리 되고 create() 함수로 넘어온다.
protected async _fetch(): Promise<Response> { // ... if (this._options.timeout === false) { return this._options.fetch(mainRequest, nonRequestOptions); } return timeout(mainRequest, nonRequestOptions, this.abortController, this._options as TimeoutOptions); }
fetch_()한 결과물들을 result에 저장하고 이에 대한 여러가지 작업을 거쳐 최종적으로 반환한다. 이렇게 긴 과정을 거쳐 우리가 ky.post()를 한 결과물을 확인할 수 있게 된다.
static create(input: Input, options: Options): ResponsePromise { const ky = new Ky(input, options); const function_ = async (): Promise<Response> => { // ... let response = await ky._fetch(); // ... }; // ... const result = function_(); // ... return result; }

왜 병목이 생기는거지?

그 이유는 clone된 readableStream을 적절히 cancel()해주지 않고 있기 때문이다.
실제로 fetch를 진행하는 함수인 _fetch()에서 TypeError 에러가 발생해서 던져진다. 그러나 다음 await ky.request 블럭에서 병목이 생겨, 에러 전파가 삼켜지는 현상이 발생하고 있었다.
const result = (isRetriableMethod ? ky._retry(function_) : function_()) .finally(async () => { // Now that we know a retry is not needed, close the ReadableStream of the cloned request. if (!ky.request.bodyUsed) { await ky.request.body?.cancel(); // -> 병목 발생 지점!! } }) as ResponsePromise;
실제로 TypeError는 발생했지만, promise가 reject 되기도 전에 .finally() 블록의 await에서 영원히 멈춰버리기 때문에, 겉으로는 아무 에러도 보이지 않고 멈춘 것처럼 보이게 되는 것이다.
ky.post() -> Ky.create() -> new Ky() [Request 생성 및 TypeError 발생] -> _fetch() -> .finally() [cancel()에서 hang]
그렇다면 finally 블록 코드의 내용이 왜 존재하는지부터 파악해보자. ky에선 내부적으로 fetch를 할 때 나중에 재시도를 하기위해 현재 요청을 clone한다.
protected async _fetch(): Promise<Response> { // ... // Cloning is done here to prepare in advance for retries this.request = mainRequest.clone();
요청을 clone을 하게 되면 readableStream은 내부적으로 tee()를 호출한다.
이때, tee()는 기존 요청을 2개의 브랜치로 나누어 새로운 요청들로 반환한다.
// 실제 구현부분 function cloneBody (body) { // 현재 body의 stream에 대해 tee() 메서드 호출중!! const { 0: out1, 1: out2 } = body.stream.tee() // 현재 body의 stream은 out1로 설정 body.stream = out1 // 복제된 stream과 기타 정보들을 함께 반환 return { stream: out2, length: body.length, source: body.source } }
notion image
이렇게 2개로 파생된 요청들이 사용되지 않아서 cancel하고 싶을때는 2개의 파생 요청들 모두 cancel을 호출해줘야 한다!!!
 
다시 원래의 코드로 살펴보자.
const result = (isRetriableMethod ? ky._retry(function_) : function_()) .finally(async () => { // Now that we know a retry is not needed, close the ReadableStream of the cloned request. if (!ky.request.bodyUsed) { await ky.request.body?.cancel(); } }) as ResponsePromise;
해당 코드의 목적은 미사용한 요청을 해제하는 것이다. 그러나 ky.request에 저장된 요청은 파생 요청 중 일부로서 나머지 파생 요청은 해제해주지 않고 있다.
이로 인해 브라우저는 ‘어, 아직 나머지 한쪽은 Stream을 소비 중인가 보다. 기다려야지~’ 하고 쭉 대기하는 현상이 생기는 것이다.
notion image

해결책

요청을 clone할 때, 원본 요청도 별도의 변수에 저장을 한뒤, 해제할 때 파생된 요청들을 모두 해제해주면 된다!!!
.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;
 
참조
MDN Web DocsMDN Web DocsReadableStream: tee() method - Web APIs | MDN
Request - Fetch Standard
실제 clone 구현코드 -
cancel 사용법 - Streams Standard
 

결론

이번 이슈를 쉽게 설명하자면, 요청을 나중에 재시도할 때 이용하려고 복제한다.
이렇게 복제하면 기존에 하나였던 파이프가 2개로 나뉜다.
그러나 요청을 보내기 전에 에러가 발생해서 열어놓은 파이프를 닫아야 했다. 그러나 2개의 파이프 중 하나의 파이프만 닫았으니, 나머지 한쪽이 닫히길 기다리느라 이런 문제가 생긴 것이다!
정말 어려운 문제를 분석할 때, 원인이 되는 지점에서 사용중인 메서드, 구현체 하나하나 공식 문서를 살펴보는 것이 답이라는 것을 이번 기회에 깨달았다☺️.