๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
@transactional ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋Š” ๋ฉ”์„œ๋“œ๋ฅผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๊ฐ์‹ธ์„œ ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.

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

import { BaseModelClass, api, transactional } from "sonamu";

class UserModelClass extends BaseModelClass<
  UserSubsetKey,
  UserSubsetMapping,
  UserSubsetQueries
> {
  @api({ httpMethod: "POST" })
  @transactional()
  async create(data: UserCreateParams) {
    const wdb = this.getDB("w");
    
    // ๋ชจ๋“  ์ž‘์—…์ด ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ์‹คํ–‰๋จ
    const user = await this.insert(wdb, data);
    await this.createProfile(user.id);
    await this.sendWelcomeEmail(user.id);
    
    // ๋ชจ๋‘ ์„ฑ๊ณตํ•˜๋ฉด commit, ํ•˜๋‚˜๋ผ๋„ ์‹คํŒจํ•˜๋ฉด rollback
    return user;
  }

  @api({ httpMethod: "PUT" })
  @transactional()
  async update(id: number, data: UserUpdateParams) {
    const wdb = this.getDB("w");
    
    // ํŠธ๋žœ์žญ์…˜ ๋ณด์žฅ
    await this.upsert(wdb, { id, ...data });
    await this.updateRelatedData(id);
    
    return this.findById(id);
  }
}

์˜ต์…˜

isolation

ํŠธ๋žœ์žญ์…˜ ๊ฒฉ๋ฆฌ ์ˆ˜์ค€์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค.
type IsolationLevel =
  | "read uncommitted"
  | "read committed"
  | "repeatable read"
  | "serializable";
๊ธฐ๋ณธ๊ฐ’: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ธฐ๋ณธ ๊ฒฉ๋ฆฌ ์ˆ˜์ค€ (PostgreSQL: read committed)
@transactional({
  isolation: "serializable"
})
async processPayment(orderId: number) {
  // ๊ฐ€์žฅ ์—„๊ฒฉํ•œ ๊ฒฉ๋ฆฌ ์ˆ˜์ค€
  // ๋™์‹œ์„ฑ ๋ฌธ์ œ ์™„์ „ ๋ฐฉ์ง€
}

@transactional({
  isolation: "read committed"
})
async updateStatus(id: number) {
  // ์ผ๋ฐ˜์ ์ธ ๊ฒฉ๋ฆฌ ์ˆ˜์ค€
  // ์ปค๋ฐ‹๋œ ๋ฐ์ดํ„ฐ๋งŒ ์ฝ์Œ
}
read uncommitted (๊ฐ€์žฅ ๋‚ฎ์Œ)
  • ์ปค๋ฐ‹๋˜์ง€ ์•Š์€ ๋ฐ์ดํ„ฐ๋„ ์ฝ์„ ์ˆ˜ ์žˆ์Œ
  • Dirty Read ๋ฐœ์ƒ ๊ฐ€๋Šฅ
  • ๊ฐ€์žฅ ๋น ๋ฅด์ง€๋งŒ ์œ„ํ—˜ํ•จ
read committed (๊ธฐ๋ณธ๊ฐ’)
  • ์ปค๋ฐ‹๋œ ๋ฐ์ดํ„ฐ๋งŒ ์ฝ์Œ
  • Dirty Read ๋ฐฉ์ง€
  • Non-repeatable Read ๋ฐœ์ƒ ๊ฐ€๋Šฅ
repeatable read
  • ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ๊ฐ™์€ ๋ฐ์ดํ„ฐ๋Š” ํ•ญ์ƒ ๊ฐ™์€ ๊ฐ’
  • Non-repeatable Read ๋ฐฉ์ง€
  • Phantom Read ๋ฐœ์ƒ ๊ฐ€๋Šฅ
serializable (๊ฐ€์žฅ ๋†’์Œ)
  • ํŠธ๋žœ์žญ์…˜์ด ์ง๋ ฌ๋กœ ์‹คํ–‰๋˜๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ๋™์ž‘
  • ๋ชจ๋“  ์ด์ƒ ํ˜„์ƒ ๋ฐฉ์ง€
  • ๊ฐ€์žฅ ์•ˆ์ „ํ•˜์ง€๋งŒ ๋А๋ฆผ

readOnly

์ฝ๊ธฐ ์ „์šฉ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’: false
@transactional({ readOnly: true })
async generateReport(startDate: Date, endDate: Date) {
  const rdb = this.getPuri("r");
  
  // ์—ฌ๋Ÿฌ ํ…Œ์ด๋ธ”์„ ์กฐํšŒํ•˜์ง€๋งŒ ์ˆ˜์ •ํ•˜์ง€ ์•Š์Œ
  // ์ผ๊ด€๋œ ์Šค๋ƒ…์ƒท ๋ณด์žฅ
  const users = await rdb.table("users").select();
  const orders = await rdb.table("orders")
    .whereBetween("created_at", [startDate, endDate])
    .select();
  
  return this.calculateReport(users, orders);
}
readOnly: true๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค๊ฐ€ ์ตœ์ ํ™”๋ฅผ ์ˆ˜ํ–‰ํ•˜์—ฌ ์„ฑ๋Šฅ์ด ํ–ฅ์ƒ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

dbPreset

์‚ฌ์šฉํ•  ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํ”„๋ฆฌ์…‹์„ ์ง€์ •ํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’: "w" (์“ฐ๊ธฐ์šฉ DB)
@transactional({ dbPreset: "w" })
async saveData(data: SaveParams) {
  // ์“ฐ๊ธฐ์šฉ DB ์‚ฌ์šฉ (๊ธฐ๋ณธ๊ฐ’)
  const wdb = this.getDB("w");
  return this.insert(wdb, data);
}

@transactional({ dbPreset: "r", readOnly: true })
async complexQuery() {
  // ์ฝ๊ธฐ์šฉ DB์—์„œ ํŠธ๋žœ์žญ์…˜
  const rdb = this.getPuri("r");
  return rdb.table("users").select();
}

์ „์ฒด ์˜ต์…˜ ์˜ˆ์‹œ

@api({ httpMethod: "POST" })
@transactional({
  isolation: "serializable",
  readOnly: false,
  dbPreset: "w"
})
async criticalOperation(data: OperationParams) {
  // ์ตœ๊ณ  ์ˆ˜์ค€์˜ ๊ฒฉ๋ฆฌ๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ์‹คํ–‰
}

ํŠธ๋žœ์žญ์…˜ ๋™์ž‘ ๋ฐฉ์‹

์ž๋™ ์ปค๋ฐ‹/๋กค๋ฐฑ

@transactional()
async transfer(fromId: number, toId: number, amount: number) {
  const wdb = this.getDB("w");
  
  // 1. ์ถœ๊ธˆ
  await wdb.table("accounts")
    .where("id", fromId)
    .decrement("balance", amount);
  
  // 2. ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์ž๋™ ๋กค๋ฐฑ
  if (amount > 1000000) {
    throw new Error("Amount too large");
    // ์œ„์˜ decrement๋„ ๋กค๋ฐฑ๋จ
  }
  
  // 3. ์ž…๊ธˆ
  await wdb.table("accounts")
    .where("id", toId)
    .increment("balance", amount);
  
  // 4. ๋ชจ๋‘ ์„ฑ๊ณตํ•˜๋ฉด ์ž๋™ ์ปค๋ฐ‹
}

ํŠธ๋žœ์žญ์…˜ ์ค‘์ฒฉ

๊ฐ™์€ dbPreset์˜ ํŠธ๋žœ์žญ์…˜์€ ์žฌ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค:
class UserModelClass extends BaseModelClass {
  @transactional()
  async createUser(data: UserCreateParams) {
    const wdb = this.getDB("w");
    const user = await this.insert(wdb, data);
    
    // ์ด ๋ฉ”์„œ๋“œ๋„ @transactional์ด์ง€๋งŒ
    // ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜์„ ์žฌ์‚ฌ์šฉํ•จ
    await this.createProfile(user.id);
    
    return user;
  }

  @transactional()
  async createProfile(userId: number) {
    const wdb = this.getDB("w");
    // createUser์˜ ํŠธ๋žœ์žญ์…˜ ์žฌ์‚ฌ์šฉ
    return wdb.table("profiles").insert({ user_id: userId });
  }
}
๋กœ๊ทธ ์ถœ๋ ฅ:
[DEBUG] transactional: UserModel.createUser
[DEBUG] new transaction context: w
[DEBUG] transactional: UserModel.createProfile
[DEBUG] reuse transaction context: w
[DEBUG] delete transaction context: w

๋‹ค๋ฅธ Preset์˜ ํŠธ๋žœ์žญ์…˜

๋‹ค๋ฅธ dbPreset์€ ๋ณ„๋„์˜ ํŠธ๋žœ์žญ์…˜์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค:
@transactional({ dbPreset: "w" })
async saveData(data: SaveParams) {
  const wdb = this.getDB("w");
  await wdb.table("logs").insert(data);
  
  // ๋‹ค๋ฅธ preset์€ ๋ณ„๋„ ํŠธ๋žœ์žญ์…˜
  await this.saveToReadReplica(data);
}

@transactional({ dbPreset: "r" })
async saveToReadReplica(data: SaveParams) {
  const rdb = this.getPuri("r");
  // ๋ณ„๋„์˜ ํŠธ๋žœ์žญ์…˜ ์ƒ์„ฑ
  return rdb.table("cache").insert(data);
}

๋‹ค๋ฅธ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉ

@api์™€ ํ•จ๊ป˜

@api({ httpMethod: "POST" })
@transactional()
async save(data: SaveParams) {
  // API ์—”๋“œํฌ์ธํŠธ์ด๋ฉด์„œ ํŠธ๋žœ์žญ์…˜
}
์ˆœ์„œ ์ค‘์š”: @api๋ฅผ ๋จผ์ €, @transactional์„ ๋‚˜์ค‘์— ์ž‘์„ฑํ•˜์„ธ์š”.

@cache์™€ ํ•จ๊ป˜

@api({ httpMethod: "GET" })
@cache({ ttl: "10m" })
@transactional({ readOnly: true })
async findWithRelations(id: number) {
  // ์ฝ๊ธฐ ์ „์šฉ ํŠธ๋žœ์žญ์…˜ + ์บ์‹ฑ
  const rdb = this.getPuri("r");
  
  const user = await rdb.table("users").where("id", id).first();
  const posts = await rdb.table("posts").where("user_id", id).select();
  
  return { user, posts };
}

@upload์™€ ํ•จ๊ป˜

@api({ httpMethod: "POST" })
@upload({ mode: "single" })
@transactional()
async uploadAvatar() {
  const { file } = Sonamu.getUploadContext();
  const wdb = this.getDB("w");
  
  // ํŒŒ์ผ ์ €์žฅ + DB ์—…๋ฐ์ดํŠธ๋ฅผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ
  const url = await this.saveFile(file);
  await wdb.table("users")
    .where("id", userId)
    .update({ avatar_url: url });
  
  return { url };
}

AsyncLocalStorage

@transactional์€ Node.js์˜ AsyncLocalStorage๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํŠธ๋žœ์žญ์…˜ ์ปจํ…์ŠคํŠธ๋ฅผ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

์ปจํ…์ŠคํŠธ ์ƒ์„ฑ

// ํŠธ๋žœ์žญ์…˜์ด ์—†์„ ๋•Œ
@transactional()
async save() {
  // 1. ์ƒˆ AsyncLocalStorage ์ปจํ…์ŠคํŠธ ์ƒ์„ฑ
  // 2. ํŠธ๋žœ์žญ์…˜ ์‹œ์ž‘
  // 3. ๋ฉ”์„œ๋“œ ์‹คํ–‰
  // 4. ์ปค๋ฐ‹/๋กค๋ฐฑ
  // 5. ์ปจํ…์ŠคํŠธ ์ •๋ฆฌ
}

์ปจํ…์ŠคํŠธ ์žฌ์‚ฌ์šฉ

@transactional()
async parent() {
  // ์ปจํ…์ŠคํŠธ ์ƒ์„ฑ
  await this.child();  // ๊ฐ™์€ ์ปจํ…์ŠคํŠธ ์‚ฌ์šฉ
}

@transactional()
async child() {
  // ๋ถ€๋ชจ์˜ ์ปจํ…์ŠคํŠธ ์žฌ์‚ฌ์šฉ
}

์—๋Ÿฌ ์ฒ˜๋ฆฌ

์ž๋™ ๋กค๋ฐฑ

@transactional()
async processOrder(orderId: number) {
  const wdb = this.getDB("w");
  
  try {
    await wdb.table("orders")
      .where("id", orderId)
      .update({ status: "processing" });
    
    // ๊ฒฐ์ œ ์‹คํŒจ
    throw new Error("Payment failed");
    
  } catch (error) {
    // ํŠธ๋žœ์žญ์…˜์ด ์ž๋™์œผ๋กœ ๋กค๋ฐฑ๋จ
    throw error;
  }
}

์ˆ˜๋™ ๋กค๋ฐฑ

๋ช…์‹œ์ ์œผ๋กœ ๋กค๋ฐฑ์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ ์—๋Ÿฌ๋ฅผ throwํ•˜์„ธ์š”:
@transactional()
async validateAndSave(data: SaveParams) {
  const wdb = this.getDB("w");
  
  const result = await this.insert(wdb, data);
  
  // ๊ฒ€์ฆ ์‹คํŒจ ์‹œ ๋กค๋ฐฑ
  if (!this.validate(result)) {
    throw new Error("Validation failed");
  }
  
  return result;
}

์ฃผ์˜์‚ฌํ•ญ

1. BaseModelClass์—์„œ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

// โŒ ์ผ๋ฐ˜ ํด๋ž˜์Šค์—์„œ๋Š” ์‚ฌ์šฉ ๋ถˆ๊ฐ€
class UtilClass {
  @transactional()
  async helper() {}
  // ์—๋Ÿฌ: modelName is required
}

// โœ… BaseModelClass ์ƒ์† ํ•„์š”
class UserModelClass extends BaseModelClass {
  @transactional()
  async save() {}
}

2. ๊ธด ํŠธ๋žœ์žญ์…˜ ์ฃผ์˜

// โŒ ๋‚˜์œ ์˜ˆ: ์˜ค๋ž˜ ๊ฑธ๋ฆฌ๋Š” ์ž‘์—…
@transactional()
async processLargeData() {
  const wdb = this.getDB("w");
  
  // 10๋ถ„ ๊ฑธ๋ฆฌ๋Š” ์ž‘์—…
  for (let i = 0; i < 1000000; i++) {
    await wdb.table("logs").insert({ index: i });
  }
  // ํŠธ๋žœ์žญ์…˜์ด ๋„ˆ๋ฌด ์˜ค๋ž˜ ์—ด๋ ค์žˆ์Œ
}

// โœ… ์ข‹์€ ์˜ˆ: ๋ฐฐ์น˜๋กœ ๋‚˜๋ˆ„๊ธฐ
async processLargeData() {
  const batches = this.createBatches(1000);
  
  for (const batch of batches) {
    await this.processBatch(batch);
  }
}

@transactional()
async processBatch(batch: Item[]) {
  const wdb = this.getDB("w");
  // ์ž‘์€ ๋‹จ์œ„๋กœ ํŠธ๋žœ์žญ์…˜ ์‹คํ–‰
  return this.insertBatch(wdb, batch);
}

3. ์™ธ๋ถ€ API ํ˜ธ์ถœ ์ฃผ์˜

// โŒ ๋‚˜์œ ์˜ˆ: ํŠธ๋žœ์žญ์…˜ ์ค‘ ์™ธ๋ถ€ API
@transactional()
async createOrder(data: OrderCreateParams) {
  const wdb = this.getDB("w");
  
  const order = await this.insert(wdb, data);
  
  // ์™ธ๋ถ€ ๊ฒฐ์ œ API ํ˜ธ์ถœ (๋А๋ฆผ)
  await this.paymentService.charge(order.amount);
  // ํŠธ๋žœ์žญ์…˜์ด ์˜ค๋ž˜ ์—ด๋ ค์žˆ์Œ
  
  return order;
}

// โœ… ์ข‹์€ ์˜ˆ: ํŠธ๋žœ์žญ์…˜ ๋ฐ–์—์„œ ์ฒ˜๋ฆฌ
async createOrder(data: OrderCreateParams) {
  // 1. ์ฃผ๋ฌธ ์ƒ์„ฑ (ํŠธ๋žœ์žญ์…˜)
  const order = await this.saveOrder(data);
  
  // 2. ๊ฒฐ์ œ ์ฒ˜๋ฆฌ (ํŠธ๋žœ์žญ์…˜ ๋ฐ–)
  await this.paymentService.charge(order.amount);
  
  // 3. ์ƒํƒœ ์—…๋ฐ์ดํŠธ (ํŠธ๋žœ์žญ์…˜)
  await this.updateOrderStatus(order.id, "paid");
  
  return order;
}

@transactional()
async saveOrder(data: OrderCreateParams) {
  const wdb = this.getDB("w");
  return this.insert(wdb, data);
}

@transactional()
async updateOrderStatus(id: number, status: string) {
  const wdb = this.getDB("w");
  return wdb.table("orders").where("id", id).update({ status });
}

๋กœ๊น…

@transactional ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ๋Š” ์ž๋™์œผ๋กœ ๋กœ๊ทธ๋ฅผ ๋‚จ๊น๋‹ˆ๋‹ค:
@transactional()
async save() {
  // ์ž๋™ ๋กœ๊ทธ:
  // [DEBUG] transactional: UserModel.save
  // [DEBUG] new transaction context: w
  // [DEBUG] delete transaction context: w
}

์˜ˆ์‹œ ๋ชจ์Œ

class AccountModelClass extends BaseModelClass {
  @api({ httpMethod: "POST" })
  @transactional({ isolation: "serializable" })
  async transfer(
    fromAccountId: number,
    toAccountId: number,
    amount: number
  ) {
    const wdb = this.getDB("w");

    // 1. ์ถœ๊ธˆ ๊ณ„์ขŒ ์ž”์•ก ํ™•์ธ
    const fromAccount = await wdb.table("accounts")
      .where("id", fromAccountId)
      .first();

    if (fromAccount.balance < amount) {
      throw new Error("Insufficient balance");
    }

    // 2. ์ถœ๊ธˆ
    await wdb.table("accounts")
      .where("id", fromAccountId)
      .decrement("balance", amount);

    // 3. ์ž…๊ธˆ
    await wdb.table("accounts")
      .where("id", toAccountId)
      .increment("balance", amount);

    // 4. ๊ฑฐ๋ž˜ ๊ธฐ๋ก
    await wdb.table("transactions").insert({
      from_account_id: fromAccountId,
      to_account_id: toAccountId,
      amount,
      type: "transfer",
      created_at: new Date()
    });

    return { success: true };
  }
}

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