๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
Puri์˜ ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ๋“ค์„ ํ™œ์šฉํ•˜์—ฌ ๋ณต์žกํ•œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ณ ๊ธ‰ ๊ธฐ๋Šฅ ๊ฐœ์š”

Transaction

ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ ๋ณด์žฅACID ์†์„ฑ

์„œ๋ธŒ์ฟผ๋ฆฌ

์ค‘์ฒฉ ์ฟผ๋ฆฌ๋กœ ๋ณต์žกํ•œ ์กฐ๊ฑด ํ‘œํ˜„FROM, WHERE, SELECT

Upsert

INSERT OR UPDATE ํ•œ๋ฒˆ์—ON CONFLICT

Vector Search

AI ์ž„๋ฒ ๋”ฉ ์œ ์‚ฌ๋„ ๊ฒ€์ƒ‰pgvector

Transaction - ํŠธ๋žœ์žญ์…˜

ํŠธ๋žœ์žญ์…˜์€ ์—ฌ๋Ÿฌ ์ฟผ๋ฆฌ๋ฅผ ํ•˜๋‚˜์˜ ๋‹จ์œ„๋กœ ๋ฌถ์–ด ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ ํŠธ๋žœ์žญ์…˜

await db.transaction(async (trx) => {
  // 1. ์‚ฌ์šฉ์ž ์ƒ์„ฑ
  const [userId] = await trx
    .table("users")
    .insert({
      username: "john",
      email: "[email protected]",
      password: "hashed",
      role: "normal",
    })
    .returning("id");

  // 2. ํ”„๋กœํ•„ ์ƒ์„ฑ
  await trx.table("profiles").insert({
    user_id: userId.id,
    bio: "Hello world",
  });

  // 3. ์ดˆ๊ธฐ ์„ค์ • ์ƒ์„ฑ
  await trx.table("user_settings").insert({
    user_id: userId.id,
    theme: "dark",
    language: "ko",
  });

  // ๋ชจ๋“  ์ž‘์—…์ด ์„ฑ๊ณตํ•˜๋ฉด ์ž๋™ ์ปค๋ฐ‹
  // ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจํ•˜๋ฉด ์ž๋™ ๋กค๋ฐฑ
});
ํŠธ๋žœ์žญ์…˜์˜ ACID ์†์„ฑ:
  • Atomicity: ๋ชจ๋‘ ์„ฑ๊ณต ๋˜๋Š” ๋ชจ๋‘ ์‹คํŒจ
  • Consistency: ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ ์œ ์ง€
  • Isolation: ๋™์‹œ ์‹คํ–‰ ๊ฒฉ๋ฆฌ
  • Durability: ์˜๊ตฌ ์ €์žฅ

๋ช…์‹œ์  ๋กค๋ฐฑ

await db.transaction(async (trx) => {
  // ๋ฐ์ดํ„ฐ ์‚ฝ์ž…
  await trx.table("users").insert({
    username: "test",
    email: "[email protected]",
    password: "pass",
    role: "normal",
  });

  // ์กฐ๊ฑด์— ๋”ฐ๋ผ ๋กค๋ฐฑ
  const existingUser = await trx
    .table("users")
    .where("email", "[email protected]")
    .first();

  if (existingUser) {
    // ์ˆ˜๋™ ๋กค๋ฐฑ
    await trx.rollback();
    return;
  }

  // ๋กค๋ฐฑํ•˜์ง€ ์•Š์œผ๋ฉด ์ž๋™ ์ปค๋ฐ‹
});

ํŠธ๋žœ์žญ์…˜ ์—๋Ÿฌ ์ฒ˜๋ฆฌ

try {
  await db.transaction(async (trx) => {
    await trx.table("users").insert({
      username: "john",
      email: "[email protected]",
      password: "pass",
      role: "normal",
    });

    // ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์ž๋™ ๋กค๋ฐฑ
    throw new Error("Something went wrong");
  });
} catch (error) {
  console.error("Transaction failed:", error);
  // ๋ชจ๋“  ๋ณ€๊ฒฝ์‚ฌํ•ญ์ด ๋กค๋ฐฑ๋จ
}

์„œ๋ธŒ์ฟผ๋ฆฌ

์„œ๋ธŒ์ฟผ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋ณต์žกํ•œ ์ฟผ๋ฆฌ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

FROM ์ ˆ ์„œ๋ธŒ์ฟผ๋ฆฌ

// ์„œ๋ธŒ์ฟผ๋ฆฌ ์ •์˜
const activeUsers = db
  .table("users")
  .where("is_active", true)
  .select({
    id: "id",
    username: "username",
    email: "email",
  });

// ์„œ๋ธŒ์ฟผ๋ฆฌ๋ฅผ ํ…Œ์ด๋ธ”์ฒ˜๋Ÿผ ์‚ฌ์šฉ
const results = await db
  .table({ active: activeUsers })
  .join("posts", "active.id", "posts.user_id")
  .select({
    userId: "active.id",
    username: "active.username",
    postCount: Puri.count("posts.id"),
  })
  .groupBy("active.id", "active.username");

WHERE ์ ˆ ์„œ๋ธŒ์ฟผ๋ฆฌ

// ํ‰๊ท  ๊ธ‰์—ฌ๋ณด๋‹ค ๋†’์€ ์ง์›
const results = await db
  .table("employees")
  .select({
    id: "id",
    name: "username",
    salary: "salary",
  })
  .whereRaw(`
    salary > (SELECT AVG(salary) FROM employees)
  `);

SELECT ์ ˆ ์„œ๋ธŒ์ฟผ๋ฆฌ

const results = await db
  .table("users")
  .select({
    id: "id",
    username: "username",
    postCount: Puri.rawNumber(`
      (SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id)
    `),
    commentCount: Puri.rawNumber(`
      (SELECT COUNT(*) FROM comments WHERE comments.user_id = users.id)
    `),
  });

WHERE ๊ทธ๋ฃน - ๋ณต์žกํ•œ ์กฐ๊ฑด

whereGroup - AND/OR ์กฐํ•ฉ

const results = await db
  .table("users")
  .select({ id: "id", name: "username" })
  .whereGroup((g) =>
    g
      .where("role", "admin")
      .orWhere("role", "moderator")
  )
  .where("is_active", true);

// SQL: WHERE (role = 'admin' OR role = 'moderator') AND is_active = true

์ค‘์ฒฉ ๊ทธ๋ฃน

const results = await db
  .table("employees")
  .select({ id: "id", name: "username" })
  .whereGroup((g) =>
    g
      .whereGroup((g2) =>
        g2
          .where("department_id", 1)
          .where("salary", ">", 50000)
      )
      .orWhereGroup((g2) =>
        g2
          .where("department_id", 2)
          .where("salary", ">", 60000)
      )
  );

// SQL: WHERE (
//   (department_id = 1 AND salary > 50000)
//   OR
//   (department_id = 2 AND salary > 60000)
// )

์‹ค์ „ ์˜ˆ์ œ - ๊ฒ€์ƒ‰ ํ•„ํ„ฐ

async searchUsers(params: {
  search?: string;
  role?: string[];
  isActive?: boolean;
  minAge?: number;
}) {
  let query = this.getPuri("r")
    .table("users")
    .select({
      id: "id",
      username: "username",
      email: "email",
    });

  // ๊ฒ€์ƒ‰์–ด (์—ฌ๋Ÿฌ ํ•„๋“œ)
  if (params.search) {
    query = query.whereGroup((g) =>
      g
        .where("username", "like", `%${params.search}%`)
        .orWhere("email", "like", `%${params.search}%`)
        .orWhere("bio", "like", `%${params.search}%`)
    );
  }

  // ์—ญํ•  ํ•„ํ„ฐ
  if (params.role && params.role.length > 0) {
    query = query.whereIn("role", params.role);
  }

  // ํ™œ์„ฑ ์ƒํƒœ
  if (params.isActive !== undefined) {
    query = query.where("is_active", params.isActive);
  }

  // ๋‚˜์ด ํ•„ํ„ฐ
  if (params.minAge !== undefined) {
    query = query.whereRaw(
      "EXTRACT(YEAR FROM AGE(birth_date)) >= ?",
      [params.minAge]
    );
  }

  return await query.orderBy("created_at", "desc");
}

UPSERT - ON CONFLICT

INSERT์™€ UPDATE๋ฅผ ํ•œ ๋ฒˆ์— ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ UPSERT

await db
  .table("users")
  .insert({
    email: "[email protected]",
    username: "john",
    password: "hashed",
    role: "normal",
  })
  .onConflict("email", {
    update: ["username", "password"],
  });

// email์ด ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด username๊ณผ password๋งŒ ์—…๋ฐ์ดํŠธ
// ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด ์ƒˆ๋กœ ์‚ฝ์ž…

์„ ํƒ์  ์—…๋ฐ์ดํŠธ

await db
  .table("user_stats")
  .insert({
    user_id: 1,
    login_count: 1,
    last_login: new Date(),
  })
  .onConflict("user_id", {
    update: {
      login_count: Puri.rawNumber("user_stats.login_count + 1"),
      last_login: new Date(),
    },
  });

// login_count๋Š” ๊ธฐ์กด ๊ฐ’ + 1
// last_login์€ ์ƒˆ ๊ฐ’์œผ๋กœ ์—…๋ฐ์ดํŠธ

DO NOTHING

await db
  .table("users")
  .insert({
    email: "[email protected]",
    username: "john",
    password: "pass",
    role: "normal",
  })
  .onConflict("email", "nothing");

// email์ด ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด ์•„๋ฌด๊ฒƒ๋„ ํ•˜์ง€ ์•Š์Œ

๋ณต์ˆ˜ ์ปฌ๋Ÿผ UNIQUE

await db
  .table("user_preferences")
  .insert({
    user_id: 1,
    key: "theme",
    value: "dark",
  })
  .onConflict(["user_id", "key"], {
    update: ["value"],
  });

// (user_id, key) ์กฐํ•ฉ์ด ์ด๋ฏธ ์กด์žฌํ•˜๋ฉด value๋งŒ ์—…๋ฐ์ดํŠธ

UpsertBuilder - ๋ณต์žกํ•œ ๊ด€๊ณ„ ์ €์žฅ

์—ฌ๋Ÿฌ ํ…Œ์ด๋ธ”์˜ ๊ด€๊ณ„๋ฅผ ํ•œ ๋ฒˆ์— ์ €์žฅํ•  ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

await db.transaction(async (trx) => {
  // 1. Company ๋“ฑ๋ก
  const companyRef = trx.ubRegister("companies", {
    name: "Tech Corp",
  });

  // 2. Department ๋“ฑ๋ก (Company ์ฐธ์กฐ)
  const deptRef = trx.ubRegister("departments", {
    name: "Engineering",
    company_id: companyRef, // UBRef ์‚ฌ์šฉ
  });

  // 3. User ๋“ฑ๋ก
  const userRef = trx.ubRegister("users", {
    email: "[email protected]",
    username: "developer",
    password: "hashed",
    role: "normal",
  });

  // 4. Employee ๋“ฑ๋ก (User, Department ์ฐธ์กฐ)
  trx.ubRegister("employees", {
    user_id: userRef,
    department_id: deptRef,
    employee_number: "E001",
    salary: "70000",
  });

  // 5. ์ˆœ์„œ๋Œ€๋กœ ์ €์žฅ
  await trx.ubUpsert("companies");
  await trx.ubUpsert("departments");
  await trx.ubUpsert("users");
  await trx.ubUpsert("employees");
});
UBRef์˜ ์žฅ์ :
  • ์™ธ๋ž˜ ํ‚ค๋ฅผ ์‹ ๊ฒฝ ์“ฐ์ง€ ์•Š๊ณ  ๊ด€๊ณ„ ์ •์˜
  • ์ž๋™์œผ๋กœ ์˜์กด ์ˆœ์„œ ํ•ด๊ฒฐ
  • ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ID ์ž๋™ ํ• ๋‹น

Many-to-Many ๊ด€๊ณ„

await db.transaction(async (trx) => {
  // Project ์ƒ์„ฑ
  const projectRef = trx.ubRegister("projects", {
    name: "New Feature",
    status: "in_progress",
  });

  // ์—ฌ๋Ÿฌ Employee ์ƒ์„ฑ
  const emp1Ref = trx.ubRegister("employees", {
    employee_number: "E100",
    salary: "60000",
  });

  const emp2Ref = trx.ubRegister("employees", {
    employee_number: "E101",
    salary: "65000",
  });

  // M:N ๊ด€๊ณ„ ์„ค์ •
  trx.ubRegister("projects__employees", {
    project_id: projectRef,
    employee_id: emp1Ref,
  });

  trx.ubRegister("projects__employees", {
    project_id: projectRef,
    employee_id: emp2Ref,
  });

  // ์ €์žฅ
  await trx.ubUpsert("projects");
  await trx.ubUpsert("employees");
  await trx.ubUpsert("projects__employees");
});

๋Œ€๋Ÿ‰ ๋“ฑ๋ก

await db.transaction(async (trx) => {
  const companyRef = trx.ubRegister("companies", {
    name: "Startup Inc",
  });

  // 10๋ช…์˜ ์ง์› ๋“ฑ๋ก
  for (let i = 0; i < 10; i++) {
    const userRef = trx.ubRegister("users", {
      email: `user${i}@startup.com`,
      username: `user${i}`,
      password: "pass",
      role: "normal",
    });

    trx.ubRegister("employees", {
      user_id: userRef,
      employee_number: `E${1000 + i}`,
      salary: String(50000 + i * 5000),
    });
  }

  // ์ผ๊ด„ ์ €์žฅ
  await trx.ubUpsert("companies");
  await trx.ubUpsert("users");
  await trx.ubUpsert("employees");
});

Vector Search - ๋ฒกํ„ฐ ์œ ์‚ฌ๋„ ๊ฒ€์ƒ‰

AI ์ž„๋ฒ ๋”ฉ์„ ์‚ฌ์šฉํ•œ ์˜๋ฏธ๋ก ์  ๊ฒ€์ƒ‰์ž…๋‹ˆ๋‹ค.

Cosine Similarity (๊ธฐ๋ณธ)

const queryEmbedding = [0.1, 0.2, 0.3, ...]; // 1536์ฐจ์› ๋ฒกํ„ฐ

const results = await db
  .table("documents")
  .vectorSimilarity("embedding", queryEmbedding, {
    method: "cosine",
    threshold: 0.7, // 70% ์ด์ƒ ์œ ์‚ฌ๋„
  })
  .select({
    id: "id",
    title: "title",
    similarity: "similarity", // ์ž๋™ ์ถ”๊ฐ€๋จ
  })
  .limit(10);

// ๊ฒฐ๊ณผ๋Š” similarity ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ
results[0].similarity; // 0.95 (๋†’์„์ˆ˜๋ก ์œ ์‚ฌ)

L2 Distance

const results = await db
  .table("documents")
  .vectorSimilarity("embedding", queryEmbedding, {
    method: "l2",
    threshold: 1.0, // ๊ฑฐ๋ฆฌ 1.0 ์ดํ•˜
  })
  .select({
    id: "id",
    title: "title",
    similarity: "similarity", // ๊ฑฐ๋ฆฌ๊ฐ’ (๋‚ฎ์„์ˆ˜๋ก ์œ ์‚ฌ)
  })
  .limit(10);

Inner Product

const results = await db
  .table("documents")
  .vectorSimilarity("embedding", queryEmbedding, {
    method: "inner_product",
    threshold: 0.5,
  })
  .select({
    id: "id",
    content: "content",
    similarity: "similarity",
  })
  .limit(20);
Vector Search ๋ฉ”์„œ๋“œ ์„ ํƒ:
  • cosine: ์ผ๋ฐ˜์ ์ธ ์ž„๋ฒ ๋”ฉ ๊ฒ€์ƒ‰ (OpenAI, Cohere ๋“ฑ)
  • l2: ๊ฑฐ๋ฆฌ ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰
  • inner_product: ์ •๊ทœํ™”๋œ ๋ฒกํ„ฐ์— ์ ํ•ฉ

PGroonga (ํ•œ๊ธ€ ์ง€์› ์šฐ์ˆ˜)

// ๋‹จ์ผ ์ปฌ๋Ÿผ ๊ฒ€์ƒ‰
const results = await db
  .table("posts")
  .whereSearch("title", "๊ฒ€์ƒ‰์–ด")
  .select({
    id: "id",
    title: "title",
    score: Puri.score(), // ๊ฒ€์ƒ‰ ์ ์ˆ˜
  })
  .orderBy("score", "desc");

// ๋ณตํ•ฉ ์ปฌ๋Ÿผ ๊ฒ€์ƒ‰ (๊ฐ€์ค‘์น˜)
const results = await db
  .table("posts")
  .whereSearch(["title", "content"], "๊ฒ€์ƒ‰์–ด", {
    weights: [10, 1], // title์ด 10๋ฐฐ ์ค‘์š”
  })
  .select({
    id: "id",
    title: "title",
    highlightedTitle: Puri.highlight("title", "๊ฒ€์ƒ‰์–ด"),
    score: Puri.score(),
  })
  .orderBy("score", "desc");

PostgreSQL tsvector

// ๊ธฐ๋ณธ ๊ฒ€์ƒ‰
const results = await db
  .table("documents")
  .whereTsSearch("title", "search query", "simple")
  .select({
    id: "id",
    title: "title",
    highlighted: Puri.tsHighlight("title", "search query"),
    rank: Puri.tsRank("to_tsvector('simple', title)", "search query"),
  })
  .orderBy("rank", "desc");

// ๊ณ ๊ธ‰ ๊ฒ€์ƒ‰ ์˜ต์…˜
const results = await db
  .table("documents")
  .whereTsSearch("content", "important keywords", {
    config: "english",
    parser: "plainto_tsquery",
  })
  .select({
    id: "id",
    content: Puri.tsHighlight("content", "important keywords", {
      startSel: "<mark>",
      stopSel: "</mark>",
      maxFragments: 3,
    }),
    rank: Puri.tsRankCd("to_tsvector('english', content)", "important keywords"),
  })
  .orderBy("rank", "desc");

๋ฐฐ์น˜ ์ž‘์—…

Batch Insert

const users = [
  { username: "user1", email: "[email protected]", password: "pass", role: "normal" },
  { username: "user2", email: "[email protected]", password: "pass", role: "normal" },
  { username: "user3", email: "[email protected]", password: "pass", role: "normal" },
];

await db.table("users").insert(users);

Batch Update

await db.transaction(async (trx) => {
  for (const userId of userIds) {
    await trx
      .table("users")
      .where("id", userId)
      .update({ is_active: true });
  }
});

Batch Delete

// IN ์‚ฌ์šฉ
await db
  .table("users")
  .whereIn("id", [1, 2, 3, 4, 5])
  .delete();

// ์กฐ๊ฑด
await db
  .table("users")
  .where("last_login", "<", "2023-01-01")
  .delete();

์„ฑ๋Šฅ ์ตœ์ ํ™”

์ธ๋ฑ์Šค ํ™œ์šฉ

-- ์ž์ฃผ ์กฐํšŒํ•˜๋Š” ์ปฌ๋Ÿผ์— ์ธ๋ฑ์Šค
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_user_id ON posts(user_id);

-- ๋ณตํ•ฉ ์ธ๋ฑ์Šค
CREATE INDEX idx_users_role_active ON users(role, is_active);

-- ๋ฒกํ„ฐ ์ธ๋ฑ์Šค (HNSW)
CREATE INDEX idx_documents_embedding ON documents 
USING hnsw (embedding vector_cosine_ops);

-- Full-text ์ธ๋ฑ์Šค
CREATE INDEX idx_posts_content_pgroonga ON posts 
USING pgroonga (content);

SELECT ์ตœ์ ํ™”

// โŒ ๋‚˜์จ: ๋ถˆํ•„์š”ํ•œ ์ปฌ๋Ÿผ ์กฐํšŒ
await db.table("users").selectAll();

// โœ… ์ข‹์Œ: ํ•„์š”ํ•œ ์ปฌ๋Ÿผ๋งŒ
await db
  .table("users")
  .select({
    id: "id",
    name: "username",
  });

JOIN ์ตœ์ ํ™”

// โœ… ์ž‘์€ ํ…Œ์ด๋ธ” โ†’ ํฐ ํ…Œ์ด๋ธ” ์ˆœ์„œ
await db
  .table("departments")      // 10๊ฐœ
  .join("employees", ...)    // 100๊ฐœ
  .join("user_logs", ...);   // 10000๊ฐœ

ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ตœ์ ํ™”

// โœ… Cursor ๊ธฐ๋ฐ˜ ํŽ˜์ด์ง€๋„ค์ด์…˜ (๋Œ€๋Ÿ‰ ๋ฐ์ดํ„ฐ)
async function getCursorPage(lastId: number, limit: number) {
  return await db
    .table("posts")
    .select({ id: "id", title: "title" })
    .where("id", ">", lastId)
    .orderBy("id", "asc")
    .limit(limit);
}

// ์‚ฌ์šฉ
const page1 = await getCursorPage(0, 20);
const page2 = await getCursorPage(page1[page1.length - 1].id, 20);

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