캐시

Drizzle은 기본적으로 모든 쿼리를 데이터베이스로 직접 전송합니다. 숨겨진 동작이나 자동 캐싱 또는 무효화가 없어 실행되는 내용을 정확히 확인할 수 있습니다. 캐싱을 원한다면 명시적으로 선택해야 합니다.

기본적으로 Drizzle은 explicit 캐싱 전략(즉, global: false)을 사용하므로 요청하지 않는 한 아무것도 캐시되지 않습니다. 이는 애플리케이션에서 예상치 못한 상황이나 숨겨진 성능 함정을 방지합니다. 또는 all 캐싱(global: true)을 활성화하여 모든 select가 먼저 캐시를 확인하도록 할 수 있습니다.

빠른 시작

Upstash 통합

Drizzle은 기본적으로 upstashCache() 헬퍼를 제공합니다. 환경 변수가 설정되어 있으면 Upstash Redis를 자동 구성과 함께 사용합니다.

import { upstashCache } from "drizzle-orm/cache/upstash";
import { drizzle } from "drizzle-orm/...";

const db = drizzle(process.env.DB_URL!, {
  cache: upstashCache(),
});

Upstash 자격 증명을 명시적으로 정의하거나, 모든 쿼리에 대해 기본적으로 전역 캐싱을 활성화하거나, 사용자 정의 캐싱 옵션을 전달할 수도 있습니다:

import { upstashCache } from "drizzle-orm/cache/upstash";
import { drizzle } from "drizzle-orm/...";

const db = drizzle(process.env.DB_URL!, {
  cache: upstashCache({
    // 👇 Redis credentials (optional — can also be pulled from env vars)
    url: '<UPSTASH_URL>',
    token: '<UPSTASH_TOKEN>',

    // 👇 Enable caching for all queries by default (optional)
    global: true,

    // 👇 Default cache behavior (optional)
    config: { ex: 60 }
  })
});

캐시 설정 레퍼런스

Drizzle은 Upstash에 대해 다음 캐시 설정 옵션을 지원합니다:

export type CacheConfig = {
  /**
   * Expiration in seconds (positive integer)
   */
  ex?: number;
  /**
   * Set an expiration (TTL or time to live) on one or more fields of a given hash key.
   * Used for HEXPIRE command
   */
  hexOptions?: "NX" | "nx" | "XX" | "xx" | "GT" | "gt" | "LT" | "lt";
};

캐시 사용 예제

캐싱을 구성하면 캐시가 다음과 같이 작동합니다:

케이스 1: global: false를 사용하는 Drizzle (기본값, 선택적 캐싱)

import { upstashCache } from "drizzle-orm/cache/upstash";
import { drizzle } from "drizzle-orm/...";

const db = drizzle(process.env.DB_URL!, {
  // 👇 `global: true` is not passed, false by default
  cache: upstashCache({ url: "", token: "" }),
});

이 경우 다음 쿼리는 캐시에서 읽지 않습니다

const res = await db.select().from(users);

// Any mutate operation will still trigger the cache's onMutate handler
// and attempt to invalidate any cached queries that involved the affected tables
await db.insert(users).value({ email: "cacheman@upstash.com" });

이 쿼리가 캐시에서 읽도록 하려면 .$withCache()를 호출합니다

const res = await db.select().from(users).$withCache();

.$withCache는 이 특정 쿼리 전략을 관리하고 구성하는 데 사용할 수 있는 옵션 세트가 있습니다

// rewrite the config for this specific query
.$withCache({ config: {} })

// give this query a custom cache key (instead of hashing query+params under the hood)
.$withCache({ tag: 'custom_key' })

// turn off auto-invalidation for this query
// note: this leads to eventual consistency (explained below)
.$withCache({ autoInvalidate: false })

최종 일관성 예제

이 예제는 autoInvalidate: false를 수동으로 설정한 경우에만 관련이 있습니다. 기본적으로 autoInvalidate는 활성화되어 있습니다.

다음과 같은 경우 autoInvalidate를 끄고 싶을 수 있습니다:

  • 데이터가 자주 변경되지 않고 약간의 오래된 정보가 허용되는 경우 (예: 제품 목록, 블로그 게시물)
  • 캐시 무효화를 수동으로 처리하는 경우

이러한 경우 이 옵션을 끄면 불필요한 캐시 무효화를 줄일 수 있습니다. 그러나 대부분의 경우 기본값을 유지하는 것을 권장합니다.

예제: 3초 TTL로 usersTable에 대해 다음 쿼리를 캐시한다고 가정합니다:

const recent = await db
  .select().from(usersTable)
  .$withCache({ config: { ex: 3 }, autoInvalidate: false });

누군가가 db.insert(usersTable)...을 실행하면 캐시가 즉시 무효화되지 않습니다. 최대 3초 동안 이전 데이터가 계속 표시되며, 최종적으로 일관성이 확보됩니다.

케이스 2: global: true 옵션을 사용하는 Drizzle

import { upstashCache } from "drizzle-orm/cache/upstash";
import { drizzle } from "drizzle-orm/...";

const db = drizzle(process.env.DB_URL!, {
  cache: upstashCache({ url: "", token: "", global: true }),
});

이 경우 다음 쿼리는 캐시에서 읽습니다

const res = await db.select().from(users);

이 특정 쿼리에 대해 캐시를 비활성화하려면 .$withCache(false)를 호출합니다

// disable cache for this query
const res = await db.select().from(users).$withCache(false);

db의 캐시 인스턴스를 사용하여 특정 테이블이나 태그를 무효화할 수도 있습니다

// Invalidate all queries that use the `users` table. You can do this with the Drizzle instance.
await db.$cache.invalidate({ tables: users });
// or
await db.$cache.invalidate({ tables: [users, posts] });

// Invalidate all queries that use the `usersTable`. You can do this by using just the table name.
await db.$cache.invalidate({ tables: "usersTable" });
// or
await db.$cache.invalidate({ tables: ["usersTable", "postsTable"] });

// You can also invalidate custom tags defined in any previously executed select queries.
await db.$cache.invalidate({ tags: "custom_key" });
// or
await db.$cache.invalidate({ tags: ["custom_key", "custom_key1"] });

사용자 정의 캐시

이 예제는 Drizzle에 사용자 정의 cache를 연결하는 방법을 보여줍니다. 캐시에서 데이터를 가져오고, 결과를 캐시에 다시 저장하고, 뮤테이션이 실행될 때마다 항목을 무효화하는 함수를 제공합니다.

캐시 확장은 다음 설정 옵션 세트를 제공합니다

export type CacheConfig = {
  /** expire time, in seconds */
  ex?: number;
  /** expire time, in milliseconds */
  px?: number;
  /** Unix time (sec) at which the key will expire */
  exat?: number;
  /** Unix time (ms) at which the key will expire */
  pxat?: number;
  /** retain existing TTL when updating a key */
  keepTtl?: boolean;
  /** options for HEXPIRE (hash-field TTL) */
  hexOptions?: 'NX' | 'XX' | 'GT' | 'LT' | 'nx' | 'xx' | 'gt' | 'lt';
};
const db = drizzle(process.env.DB_URL!, { cache: new TestGlobalCache() });
import Keyv from "keyv";

export class TestGlobalCache extends Cache {
  private globalTtl: number = 1000;
  // This object will be used to store which query keys were used
  // for a specific table, so we can later use it for invalidation.
  private usedTablesPerKey: Record<string, string[]> = {};

  constructor(private kv: Keyv = new Keyv()) {
    super();
  }

  // For the strategy, we have two options:
  // - 'explicit': The cache is used only when .$withCache() is added to a query.
  // - 'all': All queries are cached globally.
  // The default behavior is 'explicit'.
  override strategy(): "explicit" | "all" {
    return "all";
  }

  // This function accepts query and parameters that cached into key param,
  // allowing you to retrieve response values for this query from the cache.
  override async get(key: string): Promise<any[] | undefined> {
    const res = (await this.kv.get(key)) ?? undefined;
    return res;
  }

  // This function accepts several options to define how cached data will be stored:
  // - 'key': A hashed query and parameters.
  // - 'response': An array of values returned by Drizzle from the database.
  // - 'tables': An array of tables involved in the select queries. This information is needed for cache invalidation.
  //
  // For example, if a query uses the "users" and "posts" tables, you can store this information. Later, when the app executes
  // any mutation statements on these tables, you can remove the corresponding key from the cache.
  // If you're okay with eventual consistency for your queries, you can skip this option.
  override async put(
    key: string,
    response: any,
    tables: string[],
    config?: CacheConfig,
  ): Promise<void> {
    const ttl = config?.px ?? (config?.ex ? config.ex * 1000 : this.globalTtl);

    await this.kv.set(key, response, ttl);

    for (const table of tables) {
      const keys = this.usedTablesPerKey[table];
      if (keys === undefined) {
        this.usedTablesPerKey[table] = [key];
      } else {
        keys.push(key);
      }
    }
  }

  // This function is called when insert, update, or delete statements are executed.
  // You can either skip this step or invalidate queries that used the affected tables.
  //
  // The function receives an object with two keys:
  // - 'tags': Used for queries labeled with a specific tag, allowing you to invalidate by that tag.
  // - 'tables': The actual tables affected by the insert, update, or delete statements,
  //   helping you track which tables have changed since the last cache update.
  override async onMutate(params: {
    tags: string | string[];
    tables: string | string[] | Table<any> | Table<any>[];
  }): Promise<void> {
    const tagsArray = params.tags
      ? Array.isArray(params.tags)
        ? params.tags
        : [params.tags]
      : [];
    const tablesArray = params.tables
      ? Array.isArray(params.tables)
        ? params.tables
        : [params.tables]
      : [];

    const keysToDelete = new Set<string>();

    for (const table of tablesArray) {
      const tableName = is(table, Table)
        ? getTableName(table)
        : (table as string);
      const keys = this.usedTablesPerKey[tableName] ?? [];
      for (const key of keys) keysToDelete.add(key);
    }

    if (keysToDelete.size > 0 || tagsArray.length > 0) {
      for (const tag of tagsArray) {
        await this.kv.delete(tag);
      }

      for (const key of keysToDelete) {
        await this.kv.delete(key);
        for (const table of tablesArray) {
          const tableName = is(table, Table)
            ? getTableName(table)
            : (table as string);
          this.usedTablesPerKey[tableName] = [];
        }
      }
    }
  }
}

제한사항

cache 확장에서 처리되지 않는 쿼리:

db.execute(sql`select 1`);
db.batch([
    db.insert(users).values(...),
    db.update(users).set(...).where()
])
await db.transaction(async (tx) => {
  await tx.update(accounts).set(...).where(...);
  await tx.update...
});

일시적인 제한사항으로 곧 처리될 예정:

await db.query.users.findMany();