이성열 Seongyeol Yi

Supabase가 비동기 요청을 미루는 법

25.04

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

const delay = (ms) => {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
};

// await 유무와 무관하게 여기서 바로 1초를 세기 시작
delay(1000);

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

// 아직 요청하지 않는다
let query = supabase.from("...").select("...");
if (tagIds) query = query.in("id", tagIds);

// 여기에서 요청한다
const { data, error } = await query;

await를 여러번 한다고 해도 await 당시에 쿼리에 맞춰 응답이 온다는 점도 특이합니다.

let query = supabase.from('...').select('...');

// select까지 적용된 쿼리를 실행
let { data, error } = await query;

query = query.order('created_at', { ascending: true });

// order까지 적용된 쿼리를 실행
{ data, error } = await query;

selectorder 메서드들의 타입을 보면 PostgrestTransformBuilder, PostgrestFilterBuilder같이 프로미스가 아닌 커스텀 객체를 반환합니다. 쿼리를 단계별로 만들기위해 빌더 패턴을 활용한 것으로 보이네요.

하지만 어떻게 프로미스가 아닌 객체에 await을 할 수 있는 걸까요?

리서치

supabase 소스코드를 확인해보면 PostgrestTransformBuilderPostgrestFilterBuilder 모두 PostgrestBuilder를 상속받은 클래스입니다.

PostgrestBuilderPromiseLike를 구현합니다.

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

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

interface PromiseLike<T> {
  then<TResult1 = T, TResult2 = never>(
    onfulfilled?:
      | ((value: T) => TResult1 | PromiseLike<TResult1>)
      | undefined
      | null,
    onrejected?:
      | ((reason: any) => TResult2 | PromiseLike<TResult2>)
      | undefined
      | null,
  ): PromiseLike<TResult1 | TResult2>;
}

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

export default abstract class PostgrestBuilder {
  ...
  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 메서드에서 이루어짐을 확인할 수 있었습니다.

결론

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을 사용할 수 있습니다.