PromiseLike로 비동기 요청 미루기

Supabase로 db를 조회하던 코드를 짜던 중 비동기 요청은 어느 시점에 되는지에 대한 궁금증이 생겼습니다. 평소에 프로미스 객체를 쓸 때와는 다른 경험이었거든요.

궁금증

프로미스 객체가 생기는 시점과 비동기 요청이 이루어지는 시점은 일반적으로 동일합니다. fetchsetTimeout를 사용할 때를 생각해보세요. 예를 들어 setTimeout을 사용해 만든 아래 delay 함수는 프로미스 객체가 생성되는 즉시 비동기 작업이 시작됩니다.

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
delay(1000); // await 유무와 무관하게 여기서 바로 1초를 세기 시작

하지만 supabase를 사용할 때는 await 이전까지는 비동기 요청을 하지 않습니다. 이 특성 덕분에 조건부로 쿼리를 추가한다음 쿼리가 완성되면 await로 비동기 요청을 시작할 수도 있습니다.

let query = supabase.from('...').select('...');
if (tagIds) query = query.in('id', tagIds);

const { data, error } = await query;

하나 더 특이한 점은 await를 여러번 한다고 해도 그때그때의 쿼리에 맞춰 응답이 잘 온다는 점입니다.

let query = supabase.from('...').select('...');
let { data, error } = await query; // ✅

query = query.order('created_at', { ascending: true });
{ data, error } = await query; // ✅

selectorder 메서드들의 타입을 보면 PostgrestTransformBuilder, PostgrestFilterBuilder같이 프로미스가 아닌 커스텀 객체를 반환합니다. 쿼리를 단계별로 만들기위해 빌더 패턴를 활용한건 알겠는데 어떻게 프로미스가 아닌 객체에 await을 할 수 있는걸까요?

리서치

supabase 소스코드를 확인해보면 PostgrestTransformBuilderPostgrestFilterBuilder 모두 PostgrestBuilder를 상속(extends)받은 클래스이며 PostgrestBuidlerPromiseLike를 구현(implements)하고 있습니다.

export default abstract class PostgrestBuilder<
  Result,
  ThrowOnError extends boolean = false,
> implements
    PromiseLike<
      ThrowOnError extends true
        ? PostgrestResponseSuccess<Result>
        : PostgrestSingleResponse<Result>
    > {
  // ...
}

TypeScript 소스코드를 보면 PromiseLikethen 메서드를 구현한 객체를 의미합니다.

interface PromiseLike<T> {
  /**
   * Attaches callbacks for the resolution and/or rejection of the Promise.
   * @param onfulfilled The callback to execute when the Promise is resolved.
   * @param onrejected The callback to execute when the Promise is rejected.
   * @returns A Promise for the completion of which ever callback is executed.
   */
  then<TResult1 = T, TResult2 = never>(
    onfulfilled?:
      | ((value: T) => TResult1 | PromiseLike<TResult1>)
      | undefined
      | null,
    onrejected?:
      | ((reason: any) => TResult2 | PromiseLike<TResult2>)
      | undefined
      | null,
  ): PromiseLike<TResult1 | TResult2>;
}

그렇다면 PostgrestBuilderthen 메서드를 구현했을테니 살펴봅시다.

// 일부 코드 생략
export default abstract class PostgrestBuilder {
  constructor(builder: PostgrestBuilder) {
    //...
    if (builder.fetch) {
      this.fetch = builder.fetch;
    } else if (typeof fetch === 'undefined') {
      this.fetch = nodeFetch;
    } else {
      this.fetch = fetch;
    }
  }

  then(onfulfilled?, onrejected?): PromiseLike<TResult1 | TResult2> {
    // ...
    const _fetch = this.fetch;
    let res = _fetch(this.url.toString(), {
      method: this.method,
      headers: this.headers,
      body: JSON.stringify(this.body),
      signal: this.signal,
    }).then(async (res) => {
      // ...
    });
    // ...
    return res.then(onfulfilled, onrejected);
  }
}

실제 네트워크 요청은 then 메서드에서 이루어짐을 확인할 수 있었습니다. 생성자에서 supabase를 사용하는 환경별로 fetch 함수를 설정해주는 것도 인상깊네요.

결론

await의 대상이 모두 프로미스 객체여야한다는 고정관념을 버려야겠습니다. 아래 코드도 잘 동작합니다.

const obj = {
  then(resolve, reject) {
    resolve('hello');
  },
};

async function test() {
  const res = await obj;
  console.log(res);
}

test();

mdn의 Promise 문서에도 참고할만한 내용이 있어서 가져왔어요:

JavaScript 생태계는 프로미스가 언어의 일부가 되기 훨씬 전부터 여러 가지 프로미스 구현을 만들어왔습니다. 내부적으로 다르게 표현되기는 하지만, 최소한 모든 프로미스와 유사한 객체는 Thenable 인터페이스를 구현합니다. thenable은 두 개의 콜백(하나는 프로미스가 이행될 때, 다른 하나는 거부될 때)과 함께 호출되는 .then() 메서드를 구현합니다. 프로미스 또한 thenable입니다.

기존 프로미스 구현과 상호 운용하기 위해 언어에서는 프로미스 대신 thenables을 사용할 수 있습니다.

댓글을 작성하려면 이 필요합니다.

seongyeol