๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
BaseModelClass๋Š” ๋ชจ๋“  Model์ด ์ƒ์†๋ฐ›๋Š” ๊ธฐ๋ณธ ํด๋ž˜์Šค๋กœ, ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ ‘๊ทผ, ์ฟผ๋ฆฌ ์‹คํ–‰, ํŠธ๋žœ์žญ์…˜ ๊ด€๋ฆฌ ๋“ฑ์˜ ํ•ต์‹ฌ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ ‘๊ทผ ๋ฉ”์„œ๋“œ

getDB(preset)

Knex ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
getDB(preset: DBPreset): Knex
ํŒŒ๋ผ๋ฏธํ„ฐ:
  • preset: "r" (์ฝ๊ธฐ) ๋˜๋Š” "w" (์“ฐ๊ธฐ)
์‚ฌ์šฉ ์˜ˆ์‹œ:
async findCustomQuery(): Promise<User[]> {
  const rdb = this.getDB("r");
  
  return rdb("users")
    .select("*")
    .where("is_active", true);
}
DBPreset ์ข…๋ฅ˜:
  • "r": Read - ์ฝ๊ธฐ ์ „์šฉ (SELECT)
  • "w": Write - ์“ฐ๊ธฐ ๊ฐ€๋Šฅ (INSERT, UPDATE, DELETE)
์ฝ๊ธฐ/์“ฐ๊ธฐ๋ฅผ ๋ถ„๋ฆฌํ•˜๋ฉด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ฆฌํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์ฝ๊ธฐ ๋ถ€ํ•˜๋ฅผ ๋ถ„์‚ฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

getPuri(preset)

Puri ์ฟผ๋ฆฌ ๋นŒ๋”๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ํŠธ๋žœ์žญ์…˜์ด ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ ํŠธ๋žœ์žญ์…˜ ์—ฐ๊ฒฐ์„ ์ž๋™์œผ๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
getPuri(preset: DBPreset): PuriWrapper
ํŒŒ๋ผ๋ฏธํ„ฐ:
  • preset: "r" (์ฝ๊ธฐ) ๋˜๋Š” "w" (์“ฐ๊ธฐ)
์‚ฌ์šฉ ์˜ˆ์‹œ:
async findActive(): Promise<User[]> {
  const rdb = this.getPuri("r");
  
  return rdb
    .table("users")
    .where("is_active", true)
    .orderBy("created_at", "desc")
    .many();
}
getPuri()๋Š” ํŠธ๋žœ์žญ์…˜ ์ปจํ…์ŠคํŠธ๋ฅผ ์ž๋™์œผ๋กœ ๊ฐ์ง€ํ•ฉ๋‹ˆ๋‹ค. @transactional() ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ ๋‚ด์—์„œ ํ˜ธ์ถœํ•˜๋ฉด ํŠธ๋žœ์žญ์…˜ ์—ฐ๊ฒฐ์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

Subset ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ

getSubsetQueries(subset)

ํŠน์ • Subset์— ๋Œ€ํ•œ ์ฟผ๋ฆฌ ๋นŒ๋”๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
getSubsetQueries<T extends TSubsetKey>(
  subset: T
): {
  qb: Puri;
  onSubset: <S>(subset: S) => Puri;
}
๋ฐ˜ํ™˜๊ฐ’:
  • qb: ์ฟผ๋ฆฌ ๋นŒ๋” (์กฐ๊ฑด ์ถ”๊ฐ€์šฉ)
  • onSubset: Subset๋ณ„ ํƒ€์ž… ์บ์ŠคํŒ… ํ•จ์ˆ˜
์‚ฌ์šฉ ์˜ˆ์‹œ:
async findMany<T extends UserSubsetKey>(
  subset: T,
  params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
  const { qb } = this.getSubsetQueries(subset);
  
  // ์กฐ๊ฑด ์ถ”๊ฐ€
  if (params.keyword) {
    qb.whereLike("users.email", `%${params.keyword}%`);
  }
  
  // ์‹คํ–‰
  return this.executeSubsetQuery({ subset, qb, params });
}
onSubset()์€ ํƒ€์ž… ์ฒดํฌ์šฉ์ž…๋‹ˆ๋‹ค. ์‹ค์ œ๋กœ๋Š” ๊ฐ™์€ qb ๊ฐ์ฒด๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋ฏ€๋กœ, ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
// ์ด ๋‘ ์ฝ”๋“œ๋Š” ๋™์ผํ•ฉ๋‹ˆ๋‹ค
qb.where("users.id", 1);
onSubset("A").where("users.id", 1);

executeSubsetQuery(params)

Subset ์ฟผ๋ฆฌ๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ํŽ˜์ด์ง€๋„ค์ด์…˜, Loader, Hydration, Enhancer๋ฅผ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
executeSubsetQuery<T extends TSubsetKey>(
  params: {
    subset: T;
    qb: Puri;
    params: {
      num: number;
      page: number;
      queryMode?: "list" | "count" | "both";
    };
    enhancers?: EnhancerMap;
    debug?: boolean;
    optimizeCountQuery?: boolean;
  }
): Promise<ListResult<TSubsetMapping[T]>>
ํŒŒ๋ผ๋ฏธํ„ฐ:
ํŒŒ๋ผ๋ฏธํ„ฐํƒ€์ž…์„ค๋ช…๊ธฐ๋ณธ๊ฐ’
subsetstringSubset ํ‚คํ•„์ˆ˜
qbPuri์ฟผ๋ฆฌ ๋นŒ๋”ํ•„์ˆ˜
params.numnumberํŽ˜์ด์ง€ ํฌ๊ธฐํ•„์ˆ˜
params.pagenumberํŽ˜์ด์ง€ ๋ฒˆํ˜ธ (1๋ถ€ํ„ฐ ์‹œ์ž‘)ํ•„์ˆ˜
params.queryModestring์ฟผ๋ฆฌ ๋ชจ๋“œ"both"
enhancersobjectVirtual ํ•„๋“œ ๊ณ„์‚ฐ ํ•จ์ˆ˜-
debugboolean์ฟผ๋ฆฌ ๋””๋ฒ„๊น… ์ถœ๋ ฅfalse
optimizeCountQuerybooleanCOUNT ์ฟผ๋ฆฌ ์ตœ์ ํ™”false
queryMode ์˜ต์…˜:
๋ชจ๋“œ๋ฐ˜ํ™˜๊ฐ’์‚ฌ์šฉ ์˜ˆ์‹œ
"both"{ rows, total }์ผ๋ฐ˜ ๋ชฉ๋ก ์กฐํšŒ (๊ธฐ๋ณธ๊ฐ’)
"list"{ rows }total ๋ถˆํ•„์š”ํ•œ ๊ฒฝ์šฐ
"count"{ total }๊ฐœ์ˆ˜๋งŒ ํ•„์š”ํ•œ ๊ฒฝ์šฐ
์‚ฌ์šฉ ์˜ˆ์‹œ:
async findMany<T extends UserSubsetKey>(
  subset: T,
  params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
  const { qb } = this.getSubsetQueries(subset);
  
  if (params.keyword) {
    qb.whereLike("users.email", `%${params.keyword}%`);
  }
  
  const enhancers = this.createEnhancers({
    A: (row) => row,
    SS: (row) => row,
  });
  
  return this.executeSubsetQuery({
    subset,
    qb,
    params: {
      num: params.num ?? 24,
      page: params.page ?? 1,
    },
    enhancers,
  });
}
์‹คํ–‰ ์ˆœ์„œ:
  1. COUNT ์ฟผ๋ฆฌ ์‹คํ–‰ (total ๊ณ„์‚ฐ)
  2. LIST ์ฟผ๋ฆฌ ์‹คํ–‰ (ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ ์šฉ)
  3. Loader ์‹คํ–‰ (HasMany, ManyToMany ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ)
  4. Hydrate (flat ๊ฐ์ฒด โ†’ ์ค‘์ฒฉ ๊ฐ์ฒด ๋ณ€ํ™˜)
  5. Enhancer ์ ์šฉ (virtual ํ•„๋“œ ๊ณ„์‚ฐ)
  6. Internal ํ•„๋“œ ์ œ๊ฑฐ

createEnhancers(enhancers)

Enhancer ๊ฐ์ฒด๋ฅผ ์ƒ์„ฑํ•˜๋Š” ํ—ฌํผ ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. ํƒ€์ž… ๊ฒ€์ฆ๊ณผ ์ถ”๋ก ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.
createEnhancers<T extends TSubsetKey>(
  enhancers: EnhancerMap<T>
): EnhancerMap<T>
Enhancer๋ž€? Enhancer๋Š” ์ฟผ๋ฆฌ ๊ฒฐ๊ณผ์— ๊ฐ€์ƒ ํ•„๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ€ํ™˜ํ•˜๋Š” ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. ์‚ฌ์šฉ ์˜ˆ์‹œ:
const enhancers = this.createEnhancers({
  A: (row) => ({
    ...row,
    full_name: `${row.first_name} ${row.last_name}`,
    age: calculateAge(row.birth_date),
  }),
  SS: (row) => row,  // ๋ณ€ํ™˜ ์—†์Œ
});

await this.executeSubsetQuery({
  subset: "A",
  qb,
  params,
  enhancers,
});
Enhancer๋Š” ๊ฐ row๋งˆ๋‹ค ํ˜ธ์ถœ๋˜๋ฏ€๋กœ, ๋ฌด๊ฑฐ์šด ์ž‘์—…์€ ํ”ผํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ํ•„์š”ํ•˜๋‹ค๋ฉด Loader๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ ๋ณ„๋„ API๋กœ ๋ถ„๋ฆฌํ•˜์„ธ์š”.

์œ ํ‹ธ๋ฆฌํ‹ฐ ๋ฉ”์„œ๋“œ

getInsertedIds(wdb, rows, tableName, unqKeyFields, chunkSize)

์‚ฝ์ž…๋œ ๋ ˆ์ฝ”๋“œ์˜ ID๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. Unique ํ‚ค ๊ธฐ๋ฐ˜์œผ๋กœ ์กฐํšŒํ•˜๋ฏ€๋กœ upsert ํ›„์— ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.
getInsertedIds(
  wdb: Knex,
  rows: Record<string, unknown>[],
  tableName: string,
  unqKeyFields: string[],
  chunkSize?: number
): Promise<number[]>
ํŒŒ๋ผ๋ฏธํ„ฐ:
ํŒŒ๋ผ๋ฏธํ„ฐํƒ€์ž…์„ค๋ช…๊ธฐ๋ณธ๊ฐ’
wdbKnex๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ-
rowsobject[]์‚ฝ์ž…๋œ ๋ ˆ์ฝ”๋“œ-
tableNamestringํ…Œ์ด๋ธ”๋ช…-
unqKeyFieldsstring[]Unique ํ‚ค ํ•„๋“œ๋ช…-
chunkSizenumberํ•œ๋ฒˆ์— ์กฐํšŒํ•  ๊ฐœ์ˆ˜500
์‚ฌ์šฉ ์˜ˆ์‹œ:
async saveUsers(users: User[]): Promise<number[]> {
  const wdb = this.getDB("w");
  
  // ์ด๋ฉ”์ผ๋กœ upsert
  await wdb("users")
    .insert(users)
    .onConflict("email")
    .merge();
  
  // ์ด๋ฉ”์ผ๋กœ ID ์กฐํšŒ
  const ids = await this.getInsertedIds(
    wdb,
    users,
    "users",
    ["email"]
  );
  
  return ids;
}

hydrate(rows)

Flat ๋ ˆ์ฝ”๋“œ๋ฅผ ์ค‘์ฒฉ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. JOIN ๊ฒฐ๊ณผ์˜ table__field ํ˜•์‹์„ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
hydrate<T>(rows: T[]): T[]
๋ณ€ํ™˜ ๊ทœ์น™:
  • user__name โ†’ { user: { name } }
  • user__profile__bio โ†’ { user: { profile: { bio } } }
  • nullable relation์˜ id๊ฐ€ null์ด๋ฉด ๊ฐ์ฒด ์ „์ฒด๋ฅผ null๋กœ
์‚ฌ์šฉ ์˜ˆ์‹œ:
// Flat ๋ฐ์ดํ„ฐ
const flatRows = [
  {
    id: 1,
    name: "John",
    user__id: 10,
    user__email: "[email protected]",
    user__profile__bio: "Hello",
  }
];

// ์ค‘์ฒฉ ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜
const nested = this.hydrate(flatRows);
// [
//   {
//     id: 1,
//     name: "John",
//     user: {
//       id: 10,
//       email: "[email protected]",
//       profile: {
//         bio: "Hello"
//       }
//     }
//   }
// ]
hydrate()๋Š” executeSubsetQuery() ๋‚ด๋ถ€์—์„œ ์ž๋™์œผ๋กœ ํ˜ธ์ถœ๋˜๋ฏ€๋กœ, ์ง์ ‘ ํ˜ธ์ถœํ•  ์ผ์€ ๊ฑฐ์˜ ์—†์Šต๋‹ˆ๋‹ค.

omitInternalFields(row, fields)

Internal ํ•„๋“œ๋ฅผ ๊ฐ์ฒด์—์„œ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. ์ค‘์ฒฉ ํ•„๋“œ์™€ ๋ฐฐ์—ด๋„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
omitInternalFields<T extends object>(
  row: T,
  fields: string[]
): T
์‚ฌ์šฉ ์˜ˆ์‹œ:
const row = {
  id: 1,
  email: "[email protected]",
  password: "hashed_password",
  employee: {
    id: 10,
    salary: 50000,
    department: { name: "IT" }
  }
};

// Internal ํ•„๋“œ ์ œ๊ฑฐ
const cleaned = this.omitInternalFields(row, [
  "password",
  "employee.salary"
]);

// {
//   id: 1,
//   email: "[email protected]",
//   employee: {
//     id: 10,
//     department: { name: "IT" }
//   }
// }
Subset์˜ internal ์˜ต์…˜์œผ๋กœ ์ง€์ •๋œ ํ•„๋“œ๋Š” executeSubsetQuery()์—์„œ ์ž๋™์œผ๋กœ ์ œ๊ฑฐ๋ฉ๋‹ˆ๋‹ค.
{
  "subsets": {
    "A": [
      "id",
      "email",
      { "field": "password", "internal": true },
      { "field": "employee.salary", "internal": true }
    ]
  }
}

destroy()

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ์„ ์ข…๋ฃŒํ•ฉ๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์œผ๋กœ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ข…๋ฃŒ ์‹œ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค.
async destroy(): Promise<void>
์‚ฌ์šฉ ์˜ˆ์‹œ:
// ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ข…๋ฃŒ ์‹œ
process.on("SIGTERM", async () => {
  await UserModel.destroy();
  process.exit(0);
});
destroy()๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ๋ชจ๋“  Model์˜ DB ์—ฐ๊ฒฐ์ด ์ข…๋ฃŒ๋ฉ๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ๋งŒ ์‚ฌ์šฉํ•˜์„ธ์š”.

์‹ค์ „ ์‚ฌ์šฉ ํŒจํ„ด

ํ‘œ์ค€ findMany ํŒจํ„ด

async findMany<T extends UserSubsetKey, LP extends UserListParams>(
  subset: T,
  rawParams?: LP
): Promise<ListResult<LP, UserSubsetMapping[T]>> {
  // 1. ๊ธฐ๋ณธ๊ฐ’ ์„ค์ •
  const params = {
    num: 24,
    page: 1,
    search: "id" as const,
    orderBy: "id-desc" as const,
    ...rawParams,
  };

  // 2. Subset ์ฟผ๋ฆฌ ํš๋“
  const { qb, onSubset } = this.getSubsetQueries(subset);

  // 3. ํ•„ํ„ฐ๋ง ์กฐ๊ฑด ์ถ”๊ฐ€
  if (params.id) {
    qb.whereIn("users.id", asArray(params.id));
  }

  if (params.keyword && params.search) {
    if (params.search === "email") {
      qb.whereLike("users.email", `%${params.keyword}%`);
    } else if (params.search === "username") {
      qb.whereLike("users.username", `%${params.keyword}%`);
    }
  }

  // 4. ์ •๋ ฌ
  if (params.orderBy === "id-desc") {
    qb.orderBy("users.id", "desc");
  } else if (params.orderBy === "created_at-desc") {
    qb.orderBy("users.created_at", "desc");
  }

  // 5. Enhancer ์ •์˜
  const enhancers = this.createEnhancers({
    A: (row) => ({
      ...row,
      full_name: `${row.first_name} ${row.last_name}`,
    }),
    SS: (row) => row,
  });

  // 6. ์ฟผ๋ฆฌ ์‹คํ–‰
  return this.executeSubsetQuery({
    subset,
    qb,
    params,
    enhancers,
  });
}

๋ณต์žกํ•œ ํ•„ํ„ฐ๋ง ํŒจํ„ด

async findMany<T extends UserSubsetKey>(
  subset: T,
  params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
  const { qb } = this.getSubsetQueries(subset);

  // ๋‚ ์งœ ๋ฒ”์œ„ ํ•„ํ„ฐ
  if (params.created_after || params.created_before) {
    if (params.created_after) {
      qb.where("users.created_at", ">=", params.created_after);
    }
    if (params.created_before) {
      qb.where("users.created_at", "<=", params.created_before);
    }
  }

  // ๋‹ค์ค‘ ๊ฐ’ ํ•„ํ„ฐ
  if (params.roles && params.roles.length > 0) {
    qb.whereIn("users.role", params.roles);
  }

  // OR ์กฐ๊ฑด
  if (params.keyword) {
    qb.where((builder) => {
      builder
        .whereLike("users.email", `%${params.keyword}%`)
        .orWhereLike("users.username", `%${params.keyword}%`)
        .orWhereLike("users.phone", `%${params.keyword}%`);
    });
  }

  // ๋ณต์žกํ•œ ์กฐ๊ฑด
  if (params.is_premium_or_admin) {
    qb.where((builder) => {
      builder
        .where("users.role", "admin")
        .orWhere("users.subscription_level", "premium");
    });
  }

  return this.executeSubsetQuery({
    subset,
    qb,
    params: { num: params.num ?? 24, page: params.page ?? 1 },
  });
}

์ปค์Šคํ…€ ์ง‘๊ณ„ ์ฟผ๋ฆฌ

async getStatistics(): Promise<UserStatistics> {
  const rdb = this.getDB("r");

  const [stats] = await rdb("users")
    .select([
      rdb.raw("COUNT(*) as total_users"),
      rdb.raw("COUNT(CASE WHEN is_active THEN 1 END) as active_users"),
      rdb.raw("COUNT(CASE WHEN role = 'admin' THEN 1 END) as admin_count"),
      rdb.raw("AVG(age) as average_age"),
    ])
    .first();

  return {
    total_users: Number(stats.total_users),
    active_users: Number(stats.active_users),
    admin_count: Number(stats.admin_count),
    average_age: Number(stats.average_age),
  };
}

๋‹ค์Œ ๋‹จ๊ณ„