이전편(블로그 ISR 트러블 슈팅기(1))을 보지 않았다면 꼭 보고 오길 바란다!
이전편에서 mode: ‘no-cors’로 요청을 보냈을때 그 응답이 제대로 처리되지 않아 무한정 기다리는 문제가 발생했었다. 이로 인해 ISR이 제대로 이루어지지 않고 있었다.
맨 처음에는 no-cors로 인해 응답을 확인할 수 없어 무한정 기다리는 거라고 생각했지만, no-cors는 서버 간 통신에서는 적용되지 않으며, fetch api로 요청을 보내보니 정상적으로 응답이 오는 것을 확인할 수 있었다.
즉, ky에 문제가 있다는걸 확인했다.
위 문제는 fetch 표준과 ky의 구현으로 인해 발생한다.
사실 응답이 오지않는게 아니라 에러가 발생하고 있던 거였다. 이 에러는 요청에 body가 포함되고, mode: ‘no-cors’이기에 발생하는 에러이다.
request body와 ‘no-cors’의 관계
뜬금없이 request body가 나와서 당황할 수 있다. 그러나 해당 내용은 다음 스펙(
Fetch Standard![Fetch Standard]()
)에서 확인할 수 있다. 39번을 보면 다음과 같이 적혀있다.
Fetch Standard
The Fetch standard defines requests, responses, and the process that binds them: fetching.
이를 요약하자면 request body가 비어있지 않은 상태에서 mode가 ‘same-origin’ 또는 ‘cors’가 아니라면(’no-cors’, ‘websocket’, ‘navigate’인 경우들) TypeError가 발생한다는 이야기다.
즉, request body가 존재하며, mode: ‘no-cors’인 우리의 상황에선 ‘TypeError’가 발생한다는 것이다.
그렇다면 왜 request body가 비어있지 않은 상태에서 ‘same-origin’ 또는 ‘cors’가 아닌 상황에선 TypeError가 발생하는 걸까?
그 이유는 body를 request에 포함하여 보낸다는 의미는 그 응답에도 관심이 있다는 뜻인데, 다른 모드들(’no-cors’, ‘navigate’, ‘websocket’)은 응답에 관심이 없거나(’no-cors’) 또는 그 목적이 다르기 때문에(’navigate’, ‘websocket’) 표준에서는 이를 TypeError로 정의하고 있는 것이다.
따라서 ky 라이브러리 내부에 이 TypeError에 대한 처리가 제대로 이루어지지 않고 있어 계속 대기하는 문제가 발생하고 있다고 유추할 수 있다.
ky를 파헤쳐보자
그렇다면 왜 Ky 라이브러리가 멈추는 걸까?
그 이유는 간단하다. 에러가 발생했을때 이를 최상위까지 throw하지 않고 있기 때문이다.
ky.post()를 호출하면 내부적으로 어떻게 함수를 호출하는지 따라가보자. 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);
constructor(input: Input, options: Options = {}) { // ... this.request = new globalThis.Request(this._input, this._options); }
생성자 함수에서 각종 세팅과 request 객체를 생성한다. 다시 create함수로 넘어와서 function_() 함수를 실행하게 된다. 이 function_()함수 내부에서 실제로 요청을 보내는 _fetch()함수를 호출한다.
const function_ = async (): Promise<Response> => { // ... let response = await ky._fetch(); // ... };
_fetch는 timer가 설정되어 있다면 바로 fetch를 호출하고, timer가 설정되어 있다면 timeout이라는 유틸을 호출한다.
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); }
timeout도 Promise를 반환한다. 즉, _fetch()함수는 async로 선언된 함수이므로 항상 Promise를 반환한다. 이후 다시 function()_ 호출이 마무리 되고 create() 함수로 넘어온다.
const result = function_() // ... return result;
fetch_()한 결과물들을 result에 저장하고 이에 대한 여러가지 작업을 거쳐 최종적으로 반환한다. 이렇게 긴 과정을 거쳐 우리가 ky.post()를 한 결과물을 확인할 수 있게 된다.
그렇다면 왜? body와 함께 mode: ‘no-cors’를 설정했을때 에러를 호출하지 않고 계속 멈춰있는 걸까??
그 이유는 상위로 Error를 throw하는 코드가 없기 때문이다.
실제로 fetch를 진행하는 함수인 _fetch()에서 TypeError 에러가 발생해서 던져진다. 그러나 이 TypeError를 받아주는 코드가 존재하지 않기 때문에 여기서 제대로 처리가 되지 않고 멈춰있게 되는 것이다.(
WikipediaError hiding![Error hiding]()
)

Error hiding
In computer programming, error hiding (or error swallowing) is the practice of catching an error or exception, and then continuing without logging, processing, or reporting the error to other parts of the software. Handling errors in this manner is considered bad practice[1] and an anti-pattern in computer programming. In languages with exception handling support, this practice is called exception swallowing.
static create(input: Input, options: Options): ResponsePromise { const ky = new Ky(input, options); const function_ = async (): Promise<Response> => { let response = await ky._fetch(); // Http 관련 에러는 throw 중이다. if (!response.ok && ky._options.throwHttpErrors) { let error = new HTTPError(response, ky.request, ky._options as NormalizedOptions); for (const hook of ky._options.hooks.beforeError) { // eslint-disable-next-line no-await-in-loop error = await hook(error); } throw error; } // onDownloadProgress 관련 설정 에러도 throw 중이다. if (ky._options.onDownloadProgress) { if (typeof ky._options.onDownloadProgress !== 'function') { throw new TypeError('The `onDownloadProgress` option must be a function'); } if (!supportsResponseStreams) { throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.'); } return streamResponse(response.clone(), ky._options.onDownloadProgress); } // 그 어디에도 TypeError를 다루는 코드가 없다. 따라서 Error Hiding이 발생! return response; };
그렇다면 코드를 어떻게 개선할 수 있을까?
직관적인 방법으로는 try-catch문을 통해 catch에서 throw Error를 하여 상위로 에러를 전파하는 방법을 생각할 수 있다.
그러나 ky는 try-catch로 에러들을 위로 전파하는 방식 보다는 response에 담긴 에러 정보를 판단하여 throw Error를 하는 방식, 그리고 에러를 유발하는 호출 형태는 조기에 throw하는 형태를 갖고있다.
따라서 나는 생성자 함수에서 호출의 구성이 에러를 유발하는 형태라면 조기에 throw하는 형태로 개선할 수 있을 것 같다.
// 생성자에서 this.request가 생성된 후... this.request = new globalThis.Request(this._input, this._options); // 호출 구성을 확인하기 위한 빠른 검사를 여기에 추가할 수 있다. if (this.request.mode === 'no-cors' && this.request.body) { throw new TypeError( 'Request with `mode: "no-cors"` cannot have a body. This is a violation of the Fetch specification.' ); } // ... 생성자의 나머지 부분
여기서 그치지 않고 해당 내용에 대해 실제 이슈(https://github.com/sindresorhus/ky/issues/740)로 작성하였다. 여태 말한 내용과 재현을 한 내용은 해당 이슈에 포함되어 있다.
후기
정말 정말 정말 많은 시간을 쏟아부은 트러블 슈팅이었다. ISR를 이용한 실시간 포스팅 내용 수정 기능은 블로그에 있어서 정말 핵심 기능이었다. 따라서 해당 이슈를 꼭 해결하고 싶었고, 문제의 원인을 파악했지만 왜 그게 문제를 일으키는지를 몰랐을때 2차 멘붕이 왔다.
왜 Timeout이 발생하는지 파악하기 위해 notion-client 라이브러리를 로컬에 클론한뒤, pnpm pack을 이용하여 수정된 라이브러리를 테스트하면서 그 원인을 파악해 나갔다.
제미나이, 코파일럿과 함께 원인을 파악해 나갔지만, LLM에게 모든 컨텍스트를 주입시키고 문제를 파악하는 것은 정말 힘들었다.
어찌저찌 3일차에 mode: ‘no-cors’가 원인이라는 것을 알아냈다. 이때 기분은 정말 좋았다. 그러나 이게 왜 문제를 일으키는 건지 몰랐기에 내 지적 호기심, 그리고 자존심이 나를 자극했다.
그렇게 원인을 파악하기 위해 이번엔 ky 라이브러리를 클론하여 이것저것 테스트해보았다. 네트워크 관련 라이브러리다 보니 모르는 개념 투성이고 그 구조를 이해하는데 시간이 오래 걸렸다.
이후 내 친구 제미나이, 코파일럿과 함께 문제의 원인을 정확히 파악하기 위해 3일을 보냈다. 정말 뭐가 문제인지를 모르겠어서 힘들었다. 하지만 ky라는 유명한 라이브러리에 기여를 할 수 있는 기회를 놓치고 싶지 않았던 것 같았다. (+ 포기하면 찝찝해서 잠을 못잘 것 같았다.)
그러다가 디버깅 용 출력문을 분석하다가 단서를 찾게 됐고, 이를 기반으로 원인을 파악할 수 있었다.
이후 이에 대한 이슈도 등록하고 이렇게 블로그 글로 작성하면서 좀 더 식견을 넓힐 수 있게 됐다. 이번 트러블 슈팅을 하면서 ky 라이브러리가 어떤 식으로 구성이 되어 있는지 대략 파악할 수 있었고, 네트워크가 정말 복잡하다 라는 것을 실감했다. 그리고 평소 프론트 개발을 하면서 UI를 구현하는데 치중을 해오면서 좀 더 깊은 문제를 해결하고 싶다는 욕구가 있었는데, 이번 기회에 제대로 해소한 것 같다. 이렇게 쉽게 파악하기 어려운 문제를 해결하는 경험이 참 좋은 자양분이 됐다.
다음에 또 문제를 마주했을때는 좀 더 빨리 문제를 파악할 수 있을 것 같다!! 중요한 것은 꺾이지 않는 마음.