๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
ํŠธ๋žœ์žญ์…˜์„ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์‚ฌ์šฉํ•˜๋ฉด ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ์„ ๋ณด์žฅํ•˜๋ฉด์„œ๋„ ์„ฑ๋Šฅ์„ ์ตœ์ ํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ์›์น™

ACID ๋ณด์žฅ

์›์ž์„ฑ, ์ผ๊ด€์„ฑ, ๊ฒฉ๋ฆฌ์„ฑ, ์ง€์†์„ฑ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ์œ ์ง€

์งง์€ ํŠธ๋žœ์žญ์…˜

ํ•„์š”ํ•œ ๋งŒํผ๋งŒ๋ฝ ๋Œ€๊ธฐ ์ตœ์†Œํ™”

์ ์ ˆํ•œ ๊ฒฉ๋ฆฌ ์ˆ˜์ค€

์š”๊ตฌ์‚ฌํ•ญ์— ๋งž๊ฒŒ์„ฑ๋Šฅ๊ณผ ์ผ๊ด€์„ฑ ๊ท ํ˜•

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

๋ช…ํ™•ํ•œ ๋กค๋ฐฑ ์ „๋žต์ผ๊ด€๋œ ์ƒํƒœ ์œ ์ง€

ํŠธ๋žœ์žญ์…˜ ๋ฒ”์œ„

โœ… ์ข‹์€ ํŒจํ„ด: ์งง๊ณ  ๋ช…ํ™•ํ•œ ๋ฒ”์œ„

class OrderModel extends BaseModelClass {
  @transactional()
  async createOrder(
    userId: number,
    items: OrderItem[]
  ): Promise<number> {
    const wdb = this.getPuri("w");
    
    // ํŠธ๋žœ์žญ์…˜ ๋‚ด: ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ๋งŒ
    const orderRef = wdb.ubRegister("orders", {
      user_id: userId,
      status: "pending",
      total_amount: this.calculateTotal(items),
    });
    
    items.forEach((item) => {
      wdb.ubRegister("order_items", {
        order_id: orderRef,
        product_id: item.productId,
        quantity: item.quantity,
        price: item.price,
      });
    });
    
    const [orderId] = await wdb.ubUpsert("orders");
    await wdb.ubUpsert("order_items");
    
    return orderId;
  }
  
  async placeOrder(
    userId: number,
    items: OrderItem[]
  ): Promise<number> {
    // 1. ๊ฒ€์ฆ (ํŠธ๋žœ์žญ์…˜ ๋ฐ–)
    await this.validateUser(userId);
    await this.checkInventory(items);
    
    // 2. ์ฃผ๋ฌธ ์ƒ์„ฑ (ํŠธ๋žœ์žญ์…˜)
    const orderId = await this.createOrder(userId, items);
    
    // 3. ํ›„์† ์ž‘์—… (ํŠธ๋žœ์žญ์…˜ ๋ฐ–)
    await this.sendOrderConfirmation(orderId);
    
    return orderId;
  }
}

โŒ ๋‚˜์œ ํŒจํ„ด: ๊ธด ํŠธ๋žœ์žญ์…˜

class OrderModel extends BaseModelClass {
  @transactional()
  async placeOrderBad(
    userId: number,
    items: OrderItem[]
  ): Promise<number> {
    const wdb = this.getPuri("w");
    
    // โŒ ํŠธ๋žœ์žญ์…˜ ์•ˆ์—์„œ ์™ธ๋ถ€ API ํ˜ธ์ถœ
    const user = await this.fetchUserFromExternalAPI(userId);
    
    // โŒ ํŠธ๋žœ์žญ์…˜ ์•ˆ์—์„œ ๋ณต์žกํ•œ ๊ณ„์‚ฐ
    await this.calculateComplexPricing(items);
    
    // โŒ ํŠธ๋žœ์žญ์…˜ ์•ˆ์—์„œ ์ด๋ฉ”์ผ ๋ฐœ์†ก
    await this.sendEmail(user.email);
    
    // ์‹ค์ œ ๋ฐ์ดํ„ฐ ์ €์žฅ
    const [orderId] = await wdb
      .table("orders")
      .insert({ user_id: userId })
      .returning({ id: "id" });
    
    return orderId.id;
  }
}
ํŠธ๋žœ์žญ์…˜ ์•ˆ์— ํฌํ•จํ•˜์ง€ ๋ง์•„์•ผ ํ•  ๊ฒƒ:
  • ์™ธ๋ถ€ API ํ˜ธ์ถœ
  • ํŒŒ์ผ I/O
  • ์ด๋ฉ”์ผ/SMS ๋ฐœ์†ก
  • ๋ณต์žกํ•œ ๊ณ„์‚ฐ
  • ๋А๋ฆฐ ์ฟผ๋ฆฌ

๊ฒฉ๋ฆฌ ์ˆ˜์ค€ ์„ ํƒ

์šฉ๋„๋ณ„ ๊ถŒ์žฅ ๊ฒฉ๋ฆฌ ์ˆ˜์ค€

class ExampleModel extends BaseModelClass {
  // ์ผ๋ฐ˜ ์กฐํšŒ/์ˆ˜์ • - READ COMMITTED
  @transactional({ isolation: "read committed" })
  async updateProfile(userId: number, bio: string): Promise<void> {
    const wdb = this.getPuri("w");
    await wdb.table("users").where("id", userId).update({ bio });
  }
  
  // ์ผ๊ด€์„ฑ ์ค‘์š” - REPEATABLE READ (๊ธฐ๋ณธ๊ฐ’)
  @transactional({ isolation: "repeatable read" })
  async transferPoints(
    fromUserId: number,
    toUserId: number,
    amount: number
  ): Promise<void> {
    const wdb = this.getPuri("w");
    
    // ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ์ผ๊ด€๋œ ๋ฐ์ดํ„ฐ ๋ณด์žฅ
    const fromUser = await wdb.table("users").where("id", fromUserId).first();
    
    if (fromUser.points < amount) {
      throw new Error("Insufficient points");
    }
    
    await wdb.table("users").where("id", fromUserId).decrement("points", amount);
    await wdb.table("users").where("id", toUserId).increment("points", amount);
  }
  
  // ๊ธˆ์œต ๊ฑฐ๋ž˜ - SERIALIZABLE
  @transactional({ isolation: "serializable" })
  async processPayment(
    orderId: number,
    amount: number
  ): Promise<void> {
    const wdb = this.getPuri("w");
    
    // ์ตœ๊ณ  ์ˆ˜์ค€์˜ ๊ฒฉ๋ฆฌ ๋ณด์žฅ
    const order = await wdb.table("orders").where("id", orderId).first();
    
    if (order.status !== "pending") {
      throw new Error("Order already processed");
    }
    
    await wdb.table("orders").where("id", orderId).update({
      status: "paid",
      paid_amount: amount,
      paid_at: new Date(),
    });
  }
}

์—๋Ÿฌ ์ฒ˜๋ฆฌ ์ „๋žต

ํŒจํ„ด 1: Try-Catch๋กœ ๋ช…ํ™•ํ•œ ์ฒ˜๋ฆฌ

class UserModel extends BaseModelClass {
  async createUserSafe(data: UserSaveParams): Promise<Result<number>> {
    try {
      const userId = await this.createUserTransaction(data);
      return { success: true, data: userId };
    } catch (error) {
      if (error instanceof DuplicateEmailError) {
        return { success: false, error: "Email already exists" };
      }
      
      if (error instanceof ValidationError) {
        return { success: false, error: error.message };
      }
      
      // ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์—๋Ÿฌ๋Š” ๋กœ๊น… ํ›„ ์žฌ์ „ํŒŒ
      console.error("Unexpected error:", error);
      throw error;
    }
  }
  
  @transactional()
  private async createUserTransaction(data: UserSaveParams): Promise<number> {
    const wdb = this.getPuri("w");
    
    // ์ค‘๋ณต ์ฒดํฌ
    const existing = await wdb.table("users").where("email", data.email).first();
    if (existing) {
      throw new DuplicateEmailError();
    }
    
    // ๊ฒ€์ฆ
    if (!this.isValidEmail(data.email)) {
      throw new ValidationError("Invalid email format");
    }
    
    wdb.ubRegister("users", data);
    const [userId] = await wdb.ubUpsert("users");
    return userId;
  }
}

type Result<T> = 
  | { success: true; data: T }
  | { success: false; error: string };

ํŒจํ„ด 2: ๋ถ€๋ถ„ ๋กค๋ฐฑ์œผ๋กœ ๋ณต๊ตฌ

class OrderModel extends BaseModelClass {
  @transactional()
  async createOrderWithRetry(
    userId: number,
    items: OrderItem[]
  ): Promise<number> {
    const wdb = this.getPuri("w");
    
    // 1. ์ฃผ๋ฌธ ์ƒ์„ฑ (๋ฉ”์ธ ํŠธ๋žœ์žญ์…˜)
    const [orderId] = await wdb
      .table("orders")
      .insert({ user_id: userId, status: "pending" })
      .returning({ id: "id" });
    
    // 2. ์ฃผ๋ฌธ ์•„์ดํ…œ ์ƒ์„ฑ (๋ถ€๋ถ„ ํŠธ๋žœ์žญ์…˜)
    for (const item of items) {
      try {
        await wdb.transaction(async (trx) => {
          // ์žฌ๊ณ  ํ™•์ธ
          const product = await trx
            .table("products")
            .where("id", item.productId)
            .first();
          
          if (!product || product.stock < item.quantity) {
            throw new Error("Insufficient stock");
          }
          
          // ์ฃผ๋ฌธ ์•„์ดํ…œ ์ƒ์„ฑ
          await trx.table("order_items").insert({
            order_id: orderId.id,
            product_id: item.productId,
            quantity: item.quantity,
          });
          
          // ์žฌ๊ณ  ์ฐจ๊ฐ
          await trx
            .table("products")
            .where("id", item.productId)
            .decrement("stock", item.quantity);
        });
      } catch (error) {
        console.warn(`Failed to add item ${item.productId}:`, error);
        // ์‹คํŒจํ•œ ์•„์ดํ…œ์€ ๊ฑด๋„ˆ๋œ€ (๋ถ€๋ถ„ ๋กค๋ฐฑ)
        continue;
      }
    }
    
    return orderId.id;
  }
}

๋™์‹œ์„ฑ ์ œ์–ด

๋‚™๊ด€์  ๋ฝ (Optimistic Lock)

class ProductModel extends BaseModelClass {
  @transactional({ isolation: "repeatable read" })
  async updateStockOptimistic(
    productId: number,
    quantity: number
  ): Promise<void> {
    const wdb = this.getPuri("w");
    
    // 1. ํ˜„์žฌ ๋ฒ„์ „ ์กฐํšŒ
    const product = await wdb
      .table("products")
      .select({ id: "id", stock: "stock", version: "version" })
      .where("id", productId)
      .first();
    
    if (!product) {
      throw new Error("Product not found");
    }
    
    if (product.stock < quantity) {
      throw new Error("Insufficient stock");
    }
    
    // 2. ๋ฒ„์ „ ์ฒดํฌ์™€ ํ•จ๊ป˜ ์—…๋ฐ์ดํŠธ
    const updated = await wdb
      .table("products")
      .where("id", productId)
      .where("version", product.version) // ๋‚™๊ด€์  ๋ฝ
      .update({
        stock: product.stock - quantity,
        version: product.version + 1,
      });
    
    // 3. ์—…๋ฐ์ดํŠธ ์‹คํŒจ = ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์ด ๋จผ์ € ๋ณ€๊ฒฝ
    if (updated === 0) {
      throw new Error("Concurrent modification detected. Please retry.");
    }
  }
}

๋น„๊ด€์  ๋ฝ (Pessimistic Lock)

class AccountModel extends BaseModelClass {
  @transactional({ isolation: "serializable" })
  async transferMoney(
    fromAccountId: number,
    toAccountId: number,
    amount: number
  ): Promise<void> {
    const wdb = this.getPuri("w");
    
    // FOR UPDATE: ํ–‰ ๋ฝ ํš๋“
    const fromAccount = await wdb
      .table("accounts")
      .where("id", fromAccountId)
      .forUpdate() // ๋น„๊ด€์  ๋ฝ
      .first();
    
    const toAccount = await wdb
      .table("accounts")
      .where("id", toAccountId)
      .forUpdate() // ๋น„๊ด€์  ๋ฝ
      .first();
    
    if (!fromAccount || !toAccount) {
      throw new Error("Account not found");
    }
    
    if (fromAccount.balance < amount) {
      throw new Error("Insufficient balance");
    }
    
    // ๋ฝ์ด ๊ฑธ๋ฆฐ ์ƒํƒœ์—์„œ ์•ˆ์ „ํ•˜๊ฒŒ ์—…๋ฐ์ดํŠธ
    await wdb
      .table("accounts")
      .where("id", fromAccountId)
      .decrement("balance", amount);
    
    await wdb
      .table("accounts")
      .where("id", toAccountId)
      .increment("balance", amount);
  }
}

๋ฐ๋“œ๋ฝ ๋ฐฉ์ง€

์ผ๊ด€๋œ ์ˆœ์„œ๋กœ ๋ฝ ํš๋“

class TransferModel extends BaseModelClass {
  @transactional()
  async transferBetweenAccounts(
    accountId1: number,
    accountId2: number,
    amount: number
  ): Promise<void> {
    const wdb = this.getPuri("w");
    
    // โœ… ์ข‹์Œ: ID ์ˆœ์„œ๋Œ€๋กœ ๋ฝ ํš๋“ (๋ฐ๋“œ๋ฝ ๋ฐฉ์ง€)
    const [fromId, toId] = accountId1 < accountId2 
      ? [accountId1, accountId2] 
      : [accountId2, accountId1];
    
    const fromAccount = await wdb
      .table("accounts")
      .where("id", fromId)
      .forUpdate()
      .first();
    
    const toAccount = await wdb
      .table("accounts")
      .where("id", toId)
      .forUpdate()
      .first();
    
    // ์ž”์•ก ์ด์ฒด ๋กœ์ง
    // ...
  }
}

ํƒ€์ž„์•„์›ƒ ์„ค์ •

class OrderModel extends BaseModelClass {
  async processOrderWithTimeout(orderId: number): Promise<void> {
    const wdb = this.getPuri("w");
    
    // ํƒ€์ž„์•„์›ƒ ์„ค์ • (5์ดˆ)
    const timeoutPromise = new Promise((_, reject) =>
      setTimeout(() => reject(new Error("Transaction timeout")), 5000)
    );
    
    const transactionPromise = wdb.transaction(async (trx) => {
      // ํŠธ๋žœ์žญ์…˜ ๋กœ์ง
      await trx.table("orders").where("id", orderId).update({ status: "processing" });
      // ...
    });
    
    try {
      await Promise.race([transactionPromise, timeoutPromise]);
    } catch (error) {
      console.error("Transaction failed or timed out:", error);
      throw error;
    }
  }
}

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

๋ฐฐ์น˜ ์ฒ˜๋ฆฌ

class UserModel extends BaseModelClass {
  // โŒ ๋‚˜์จ: ๊ฐ User๋งˆ๋‹ค ํŠธ๋žœ์žญ์…˜
  async createUsersBad(users: UserSaveParams[]): Promise<number[]> {
    const ids: number[] = [];
    
    for (const user of users) {
      const id = await this.createUser(user); // ๊ฐ๊ฐ ํŠธ๋žœ์žญ์…˜
      ids.push(id);
    }
    
    return ids;
  }
  
  // โœ… ์ข‹์Œ: ๋‹จ์ผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ
  @transactional()
  async createUsersGood(users: UserSaveParams[]): Promise<number[]> {
    const wdb = this.getPuri("w");
    
    users.forEach((user) => {
      wdb.ubRegister("users", user);
    });
    
    const ids = await wdb.ubUpsert("users");
    return ids;
  }
}

์ฝ๊ธฐ/์“ฐ๊ธฐ ๋ถ„๋ฆฌ

class ProductModel extends BaseModelClass {
  async updateProductWithReadReplica(
    productId: number,
    data: Partial<Product>
  ): Promise<void> {
    // 1. ์กฐํšŒ๋Š” READ DB (ํŠธ๋žœ์žญ์…˜ ๋ถˆํ•„์š”)
    const rdb = this.getPuri("r");
    const product = await rdb
      .table("products")
      .where("id", productId)
      .first();
    
    if (!product) {
      throw new Error("Product not found");
    }
    
    // 2. ๊ฒ€์ฆ ๋กœ์ง (ํŠธ๋žœ์žญ์…˜ ๋ฐ–)
    this.validateProductData(data);
    
    // 3. ์“ฐ๊ธฐ๋Š” WRITE DB (์งง์€ ํŠธ๋žœ์žญ์…˜)
    const wdb = this.getPuri("w");
    await wdb.transaction(async (trx) => {
      await trx
        .table("products")
        .where("id", productId)
        .update(data);
    });
  }
}

ํ…Œ์ŠคํŠธ ์ „๋žต

ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ ํ…Œ์ŠคํŠธ

import { describe, test, expect } from "vitest";

describe("UserModel.createUser", () => {
  test("should rollback on duplicate email", async () => {
    // Given: ๊ธฐ์กด User
    const email = "test@example.com";
    await UserModel.createUser({
      email,
      username: "existing",
      password: "pass",
      role: "normal",
    });
    
    // When: ์ค‘๋ณต ์ด๋ฉ”์ผ๋กœ ์ƒ์„ฑ ์‹œ๋„
    const promise = UserModel.createUser({
      email, // ์ค‘๋ณต
      username: "new",
      password: "pass",
      role: "normal",
    });
    
    // Then: ์—๋Ÿฌ ๋ฐœ์ƒ
    await expect(promise).rejects.toThrow("Email already exists");
    
    // ๋กค๋ฐฑ ํ™•์ธ: ์ƒˆ User๊ฐ€ ์ƒ์„ฑ๋˜์ง€ ์•Š์Œ
    const users = await UserModel.getPuri("r")
      .table("users")
      .where("email", email)
      .select({ username: "username" });
    
    expect(users).toHaveLength(1);
    expect(users[0].username).toBe("existing");
  });
});

๋™์‹œ์„ฑ ํ…Œ์ŠคํŠธ

describe("ProductModel.updateStock", () => {
  test("should handle concurrent updates correctly", async () => {
    // Given: ์žฌ๊ณ  100๊ฐœ
    const productId = await createProduct({ stock: 100 });
    
    // When: ๋™์‹œ์— 10๋ช…์ด 10๊ฐœ์”ฉ ๊ตฌ๋งค
    const promises = Array(10).fill(null).map(() =>
      ProductModel.updateStock(productId, 10)
    );
    
    await Promise.all(promises);
    
    // Then: ์žฌ๊ณ ๋Š” 0์ด์–ด์•ผ ํ•จ
    const product = await ProductModel.getPuri("r")
      .table("products")
      .where("id", productId)
      .first();
    
    expect(product.stock).toBe(0);
  });
});

์ฒดํฌ๋ฆฌ์ŠคํŠธ

ํŠธ๋žœ์žญ์…˜ ์„ค๊ณ„ ์‹œ

  • ํŠธ๋žœ์žญ์…˜ ๋ฒ”์œ„๊ฐ€ ์ตœ์†Œํ™”๋˜์–ด ์žˆ๋Š”๊ฐ€?
  • ์™ธ๋ถ€ API ํ˜ธ์ถœ์ด ํŠธ๋žœ์žญ์…˜ ๋ฐ–์— ์žˆ๋Š”๊ฐ€?
  • ์ ์ ˆํ•œ ๊ฒฉ๋ฆฌ ์ˆ˜์ค€์„ ์„ ํƒํ–ˆ๋Š”๊ฐ€?
  • ์—๋Ÿฌ ์ฒ˜๋ฆฌ๊ฐ€ ๋ช…ํ™•ํ•œ๊ฐ€?
  • ๋ฐ๋“œ๋ฝ ๊ฐ€๋Šฅ์„ฑ์„ ๊ณ ๋ คํ–ˆ๋Š”๊ฐ€?

์ฝ”๋“œ ๋ฆฌ๋ทฐ ์‹œ

  • ์ค‘์ฒฉ ํŠธ๋žœ์žญ์…˜์ด ํ•„์š”ํ•œ๊ฐ€?
  • ๋ฝ ์ˆœ์„œ๊ฐ€ ์ผ๊ด€๋˜๋Š”๊ฐ€?
  • ๋กค๋ฐฑ ์‹œ๋‚˜๋ฆฌ์˜ค๊ฐ€ ๋ช…ํ™•ํ•œ๊ฐ€?
  • ์„ฑ๋Šฅ ์˜ํ–ฅ์„ ๊ณ ๋ คํ–ˆ๋Š”๊ฐ€?
  • ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ์žˆ๋Š”๊ฐ€?

์•ˆํ‹ฐํŒจํ„ด

1. ํŠธ๋žœ์žญ์…˜ ๋‚จ์šฉ

// โŒ ๋‚˜์จ: ๋‹จ์ˆœ ์กฐํšŒ์— ํŠธ๋žœ์žญ์…˜
@transactional()
async getUser(userId: number): Promise<User> {
  const wdb = this.getPuri("w");
  return wdb.table("users").where("id", userId).first();
}

// โœ… ์ข‹์Œ: ์กฐํšŒ๋Š” ํŠธ๋žœ์žญ์…˜ ๋ถˆํ•„์š”
async getUser(userId: number): Promise<User> {
  const rdb = this.getPuri("r");
  return rdb.table("users").where("id", userId).first();
}

2. ์—๋Ÿฌ ๋ฌด์‹œ

// โŒ ๋‚˜์จ: ์—๋Ÿฌ๋ฅผ catch๋กœ ์ˆจ๊น€
@transactional()
async createUserBad(data: UserSaveParams): Promise<number | null> {
  try {
    const wdb = this.getPuri("w");
    // ...
    return userId;
  } catch (error) {
    console.error(error);
    return null; // ์—๋Ÿฌ ์ˆจ๊น€ โ†’ ๋กค๋ฐฑ ์•ˆ ๋จ
  }
}

// โœ… ์ข‹์Œ: ์—๋Ÿฌ ์žฌ์ „ํŒŒ
@transactional()
async createUserGood(data: UserSaveParams): Promise<number> {
  const wdb = this.getPuri("w");
  // ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์ž๋™ ๋กค๋ฐฑ
  // ...
  return userId;
}

3. ๋ถˆํ•„์š”ํ•œ ์ค‘์ฒฉ

// โŒ ๋‚˜์จ: ๋ถˆํ•„์š”ํ•œ ์ค‘์ฒฉ ํŠธ๋žœ์žญ์…˜
@transactional()
async outerTransaction(): Promise<void> {
  const wdb = this.getPuri("w");
  
  await wdb.transaction(async (trx) => {
    // ์ด๋ฏธ @transactional ์•ˆ์ด๋ฏ€๋กœ ๋ถˆํ•„์š”
    // ...
  });
}

// โœ… ์ข‹์Œ: ๋‹จ์ˆœํ•˜๊ฒŒ
@transactional()
async simpleTransaction(): Promise<void> {
  const wdb = this.getPuri("w");
  // ์ง์ ‘ ์ž‘์—…
  // ...
}

์‹ค์ „ ๋ณต์žกํ•œ ์‹œ๋‚˜๋ฆฌ์˜ค

์‹ค๋ฌด์—์„œ ์ž์ฃผ ๋งˆ์ฃผ์น˜๋Š” ๋ณต์žกํ•œ ํŠธ๋žœ์žญ์…˜ ํŒจํ„ด๊ณผ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค.

์‹œ๋‚˜๋ฆฌ์˜ค 1: ๋‹ค์ค‘ Model ๊ฐ„ ํŠธ๋žœ์žญ์…˜ ๊ณต์œ 

์—ฌ๋Ÿฌ Model์˜ ๋ฉ”์„œ๋“œ๋ฅผ ํ•˜๋‚˜์˜ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ๋ฌถ์–ด์•ผ ํ•  ๋•Œ
class OrderModelClass extends BaseModelClass {
  @transactional()
  async createOrderWithPayment(
    userId: number,
    items: OrderItem[],
    paymentInfo: PaymentInfo
  ): Promise<{ orderId: number; paymentId: number }> {
    const wdb = this.getPuri("w");

    // 1. ์žฌ๊ณ  ํ™•์ธ ๋ฐ ์ฐจ๊ฐ (InventoryModel ํ˜ธ์ถœ)
    //    @transactional์ด ์žˆ์œผ๋ฏ€๋กœ ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜ ์ปจํ…์ŠคํŠธ ๊ณต์œ 
    await InventoryModel.reserveStock(items);

    // 2. ์ฃผ๋ฌธ ์ƒ์„ฑ
    const orderRef = wdb.ubRegister("orders", {
      user_id: userId,
      status: "pending",
      total_amount: this.calculateTotal(items),
    });

    items.forEach((item) => {
      wdb.ubRegister("order_items", {
        order_id: orderRef,
        product_id: item.productId,
        quantity: item.quantity,
        price: item.price,
      });
    });

    const [orderId] = await wdb.ubUpsert("orders");
    await wdb.ubUpsert("order_items");

    // 3. ๊ฒฐ์ œ ์ฒ˜๋ฆฌ (PaymentModel ํ˜ธ์ถœ)
    //    ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜ ์ปจํ…์ŠคํŠธ ๊ณต์œ 
    const paymentId = await PaymentModel.processPayment({
      orderId,
      userId,
      ...paymentInfo,
    });

    // 4. ์ฃผ๋ฌธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
    await wdb
      .table("orders")
      .where("id", orderId)
      .update({ status: "paid", payment_id: paymentId });

    return { orderId, paymentId };
  }
}

class InventoryModelClass extends BaseModelClass {
  @transactional()
  async reserveStock(items: OrderItem[]): Promise<void> {
    const wdb = this.getPuri("w");

    for (const item of items) {
      // ํ˜„์žฌ ์žฌ๊ณ  ํ™•์ธ
      const product = await wdb
        .table("products")
        .where("id", item.productId)
        .first();

      if (!product || product.stock < item.quantity) {
        throw new BadRequestError(
          `์ƒํ’ˆ ${item.productId}์˜ ์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.`
        );
      }

      // ์žฌ๊ณ  ์ฐจ๊ฐ
      await wdb
        .table("products")
        .where("id", item.productId)
        .decrement("stock", item.quantity);
    }
  }
}

class PaymentModelClass extends BaseModelClass {
  @transactional()
  async processPayment(params: ProcessPaymentParams): Promise<number> {
    const wdb = this.getPuri("w");

    // ์™ธ๋ถ€ ๊ฒฐ์ œ API ํ˜ธ์ถœ (ํŠธ๋žœ์žญ์…˜ ๋ฐ–์—์„œ ํ•ด์•ผ ํ•˜์ง€๋งŒ ์—ฌ๊ธฐ์„  ์˜ˆ์‹œ)
    const paymentResult = await this.callPaymentGateway(params);

    // ๊ฒฐ์ œ ๊ธฐ๋ก ์ €์žฅ
    const [paymentId] = await wdb
      .table("payments")
      .insert({
        order_id: params.orderId,
        user_id: params.userId,
        amount: params.amount,
        status: paymentResult.status,
        transaction_id: paymentResult.transactionId,
      })
      .returning("id");

    return paymentId.id;
  }
}
ํŠธ๋žœ์žญ์…˜ ์ปจํ…์ŠคํŠธ ๊ณต์œ :
  • createOrderWithPayment๊ฐ€ ์ตœ์ƒ์œ„ ํŠธ๋žœ์žญ์…˜ ์‹œ์ž‘
  • InventoryModel.reserveStock, PaymentModel.processPayment๋Š” ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜ ์ปจํ…์ŠคํŠธ ๊ณต์œ 
  • ์–ด๋А ๋‹จ๊ณ„์—์„œ๋“  ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์ „์ฒด ๋กค๋ฐฑ (์žฌ๊ณ  ์ฐจ๊ฐ๋„ ์ทจ์†Œ๋จ)

์‹œ๋‚˜๋ฆฌ์˜ค 2: ๋ถ€๋ถ„ ๋กค๋ฐฑ with Savepoint

์ผ๋ถ€ ์ž‘์—…๋งŒ ๋กค๋ฐฑํ•˜๊ณ  ๋‚˜๋จธ์ง€๋Š” ์œ ์ง€ํ•˜๊ณ  ์‹ถ์„ ๋•Œ
class OrderModelClass extends BaseModelClass {
  @transactional()
  async createOrderWithOptionalDiscount(
    userId: number,
    items: OrderItem[],
    couponCode?: string
  ): Promise<number> {
    const wdb = this.getPuri("w");

    // 1. ์ฃผ๋ฌธ ์ƒ์„ฑ (๋ฐ˜๋“œ์‹œ ์„ฑ๊ณตํ•ด์•ผ ํ•จ)
    const orderRef = wdb.ubRegister("orders", {
      user_id: userId,
      status: "pending",
      total_amount: this.calculateTotal(items),
      discount_amount: 0,
    });

    items.forEach((item) => {
      wdb.ubRegister("order_items", {
        order_id: orderRef,
        product_id: item.productId,
        quantity: item.quantity,
        price: item.price,
      });
    });

    const [orderId] = await wdb.ubUpsert("orders");
    await wdb.ubUpsert("order_items");

    // 2. ์ฟ ํฐ ์ ์šฉ ์‹œ๋„ (์‹คํŒจํ•ด๋„ ์ฃผ๋ฌธ์€ ์œ ์ง€)
    if (couponCode) {
      try {
        // Savepoint ์ƒ์„ฑ
        await wdb.raw("SAVEPOINT coupon_apply");

        const coupon = await wdb
          .table("coupons")
          .where("code", couponCode)
          .where("used", false)
          .first();

        if (!coupon) {
          throw new Error("์œ ํšจํ•˜์ง€ ์•Š์€ ์ฟ ํฐ");
        }

        // ์ฟ ํฐ ์‚ฌ์šฉ ์ฒ˜๋ฆฌ
        await wdb.table("coupons").where("id", coupon.id).update({ used: true });

        // ํ• ์ธ ๊ธˆ์•ก ์—…๋ฐ์ดํŠธ
        await wdb
          .table("orders")
          .where("id", orderId)
          .update({ discount_amount: coupon.amount });

        // Savepoint ํ•ด์ œ
        await wdb.raw("RELEASE SAVEPOINT coupon_apply");

        this.logger.info("์ฟ ํฐ ์ ์šฉ ์„ฑ๊ณต", { orderId, couponCode });
      } catch (error) {
        // ์ฟ ํฐ ์ ์šฉ ์‹คํŒจ ์‹œ ์ด ๋ถ€๋ถ„๋งŒ ๋กค๋ฐฑ
        await wdb.raw("ROLLBACK TO SAVEPOINT coupon_apply");
        this.logger.warn("์ฟ ํฐ ์ ์šฉ ์‹คํŒจ, ์ฃผ๋ฌธ์€ ์œ ์ง€", { orderId, error });
      }
    }

    return orderId;
  }
}
Savepoint ํ™œ์šฉ:
  • ์ฃผ๋ฌธ ์ƒ์„ฑ์€ ๋ฐ˜๋“œ์‹œ ์„ฑ๊ณต
  • ์ฟ ํฐ ์ ์šฉ ์‹คํŒจ ์‹œ ์ฟ ํฐ ๊ด€๋ จ ์ž‘์—…๋งŒ ๋กค๋ฐฑ
  • ์ „์ฒด ํŠธ๋žœ์žญ์…˜์€ ๊ณ„์† ์ง„ํ–‰
Savepoint ์ฃผ์˜์‚ฌํ•ญ:
  • Savepoint๋Š” ์ˆ˜๋™์œผ๋กœ ์ƒ์„ฑ/๊ด€๋ฆฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค (์ž๋™ ์ƒ์„ฑ ์•ˆ ๋จ)
  • ์ค‘์ฒฉ ๋ ˆ๋ฒจ์ด ๊นŠ์–ด์งˆ์ˆ˜๋ก ๋ณต์žก๋„ ์ฆ๊ฐ€
  • ๋Œ€๋ถ€๋ถ„์˜ ๊ฒฝ์šฐ ์ „์ฒด ๋กค๋ฐฑ์ด ๋” ์•ˆ์ „ํ•˜๊ณ  ๋‹จ์ˆœํ•ฉ๋‹ˆ๋‹ค

์‹œ๋‚˜๋ฆฌ์˜ค 3: ๋™์‹œ์„ฑ ์ œ์–ด - ๋น„๊ด€์  ๋ฝ

์—ฌ๋Ÿฌ ์‚ฌ์šฉ์ž๊ฐ€ ๋™์‹œ์— ๊ฐ™์€ ๋ฐ์ดํ„ฐ๋ฅผ ์ˆ˜์ •ํ•  ๋•Œ
class ProductModelClass extends BaseModelClass {
  @transactional()
  async purchaseProduct(
    productId: number,
    quantity: number,
    userId: number
  ): Promise<number> {
    const wdb = this.getPuri("w");

    // ๋น„๊ด€์  ๋ฝ: SELECT ... FOR UPDATE
    const product = await wdb
      .table("products")
      .where("id", productId)
      .forUpdate() // โ† ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์ด ์ด ํ–‰์„ ์ˆ˜์ •ํ•˜์ง€ ๋ชปํ•˜๋„๋ก ๋ฝ
      .first();

    if (!product) {
      throw new NotFoundError("์ƒํ’ˆ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค");
    }

    if (product.stock < quantity) {
      throw new BadRequestError("์žฌ๊ณ ๊ฐ€ ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค");
    }

    // ์žฌ๊ณ  ์ฐจ๊ฐ
    await wdb
      .table("products")
      .where("id", productId)
      .decrement("stock", quantity);

    // ์ฃผ๋ฌธ ์ƒ์„ฑ
    const [orderId] = await wdb
      .table("orders")
      .insert({
        user_id: userId,
        product_id: productId,
        quantity,
        amount: product.price * quantity,
      })
      .returning("id");

    return orderId.id;
  }
}
๋™์‹œ์„ฑ ์ œ์–ด ๋น„๊ต:
๋ฐฉ์‹์‚ฌ์šฉ ์‹œ์ ์žฅ์ ๋‹จ์ 
๋น„๊ด€์  ๋ฝ (FOR UPDATE)์ถฉ๋Œ์ด ์ž์ฃผ ๋ฐœ์ƒํ•˜๋Š” ๊ฒฝ์šฐ๋ฐ์ดํ„ฐ ์ผ๊ด€์„ฑ ๋ณด์žฅ ํ™•์‹ค๋Œ€๊ธฐ ์‹œ๊ฐ„ ์ฆ๊ฐ€, ์ฒ˜๋ฆฌ๋Ÿ‰ ๊ฐ์†Œ
๋‚™๊ด€์  ๋ฝ (version ์ปฌ๋Ÿผ)์ถฉ๋Œ์ด ๋“œ๋ฌธ ๊ฒฝ์šฐ๋†’์€ ์ฒ˜๋ฆฌ๋Ÿ‰์ถฉ๋Œ ์‹œ ์žฌ์‹œ๋„ ํ•„์š”

์‹œ๋‚˜๋ฆฌ์˜ค 4: ๋ฐ๋“œ๋ฝ ์žฌ์‹œ๋„ ํŒจํ„ด

๋ฐ๋“œ๋ฝ ๋ฐœ์ƒ ์‹œ ์ž๋™ ์žฌ์‹œ๋„
class OrderModelClass extends BaseModelClass {
  async createOrderWithRetry(
    userId: number,
    items: OrderItem[],
    maxRetries: number = 3
  ): Promise<number> {
    let lastError: Error | null = null;

    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        // ํŠธ๋žœ์žญ์…˜ ์‹œ๋„
        return await this.createOrder(userId, items);
      } catch (error) {
        // ๋ฐ๋“œ๋ฝ ์—๋Ÿฌ ์ฒดํฌ
        if (this.isDeadlockError(error)) {
          lastError = error as Error;
          this.logger.warn(`๋ฐ๋“œ๋ฝ ๋ฐœ์ƒ, ์žฌ์‹œ๋„ ${attempt}/${maxRetries}`, {
            userId,
            error,
          });

          // ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„: 100ms, 200ms, 400ms...
          await this.sleep(100 * Math.pow(2, attempt - 1));
          continue;
        }

        // ๋ฐ๋“œ๋ฝ์ด ์•„๋‹Œ ๋‹ค๋ฅธ ์—๋Ÿฌ๋Š” ์ฆ‰์‹œ throw
        throw error;
      }
    }

    // ์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜ ์ดˆ๊ณผ
    throw new Error(
      `์ฃผ๋ฌธ ์ƒ์„ฑ ์‹คํŒจ: ์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜ ์ดˆ๊ณผ (${maxRetries}ํšŒ)`,
      { cause: lastError }
    );
  }

  @transactional()
  private async createOrder(
    userId: number,
    items: OrderItem[]
  ): Promise<number> {
    // ์‹ค์ œ ์ฃผ๋ฌธ ์ƒ์„ฑ ๋กœ์ง
    // ...
  }

  private isDeadlockError(error: unknown): boolean {
    if (error instanceof Error) {
      // PostgreSQL ๋ฐ๋“œ๋ฝ ์—๋Ÿฌ ์ฝ”๋“œ: 40P01
      return error.message.includes("deadlock") ||
             error.message.includes("40P01");
    }
    return false;
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}
์žฌ์‹œ๋„ ์ „๋žต:
  1. ๋ฐ๋“œ๋ฝ ์—๋Ÿฌ๋งŒ ์žฌ์‹œ๋„ (๋‹ค๋ฅธ ์—๋Ÿฌ๋Š” ์ฆ‰์‹œ ์‹คํŒจ)
  2. ์ง€์ˆ˜ ๋ฐฑ์˜คํ”„๋กœ ์žฌ์‹œ๋„ ๊ฐ„๊ฒฉ ์ฆ๊ฐ€ (100ms โ†’ 200ms โ†’ 400ms)
  3. ์ตœ๋Œ€ ์žฌ์‹œ๋„ ํšŸ์ˆ˜ ์ œํ•œ

์‹œ๋‚˜๋ฆฌ์˜ค 5: ์™ธ๋ถ€ API ํ˜ธ์ถœ๊ณผ ํŠธ๋žœ์žญ์…˜ ๋ถ„๋ฆฌ

์™ธ๋ถ€ API๋Š” ํŠธ๋žœ์žญ์…˜ ๋ฐ–์—์„œ ํ˜ธ์ถœ
class PaymentModelClass extends BaseModelClass {
  async processOrderPayment(
    orderId: number,
    paymentInfo: PaymentInfo
  ): Promise<PaymentResult> {
    // 1. ์ฃผ๋ฌธ ์ •๋ณด ์กฐํšŒ (์ฝ๊ธฐ ์ „์šฉ)
    const order = await this.findOrderById(orderId);
    if (!order) {
      throw new NotFoundError("์ฃผ๋ฌธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค");
    }

    // 2. ์™ธ๋ถ€ ๊ฒฐ์ œ API ํ˜ธ์ถœ (ํŠธ๋žœ์žญ์…˜ ๋ฐ–)
    let paymentResult: ExternalPaymentResult;
    try {
      paymentResult = await this.callPaymentGateway({
        orderId,
        amount: order.total_amount,
        ...paymentInfo,
      });
    } catch (error) {
      // ๊ฒฐ์ œ ์‹คํŒจ ์‹œ ์ฃผ๋ฌธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
      await this.updateOrderStatus(orderId, "payment_failed");
      throw new PaymentError("๊ฒฐ์ œ ์ฒ˜๋ฆฌ ์‹คํŒจ", { cause: error });
    }

    // 3. ๊ฒฐ์ œ ์„ฑ๊ณต ์‹œ DB ์—…๋ฐ์ดํŠธ (ํŠธ๋žœ์žญ์…˜)
    try {
      return await this.recordPaymentSuccess(orderId, paymentResult);
    } catch (error) {
      // DB ์—…๋ฐ์ดํŠธ ์‹คํŒจ ์‹œ ๊ฒฐ์ œ ์ทจ์†Œ ์‹œ๋„
      await this.cancelPaymentGateway(paymentResult.transactionId);
      throw error;
    }
  }

  @transactional()
  private async recordPaymentSuccess(
    orderId: number,
    paymentResult: ExternalPaymentResult
  ): Promise<PaymentResult> {
    const wdb = this.getPuri("w");

    // ๊ฒฐ์ œ ๊ธฐ๋ก ์ €์žฅ
    const [paymentId] = await wdb
      .table("payments")
      .insert({
        order_id: orderId,
        status: "success",
        transaction_id: paymentResult.transactionId,
        amount: paymentResult.amount,
      })
      .returning("id");

    // ์ฃผ๋ฌธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
    await wdb
      .table("orders")
      .where("id", orderId)
      .update({
        status: "paid",
        payment_id: paymentId.id,
        paid_at: new Date(),
      });

    return {
      paymentId: paymentId.id,
      transactionId: paymentResult.transactionId,
      status: "success",
    };
  }

  private async callPaymentGateway(
    params: PaymentParams
  ): Promise<ExternalPaymentResult> {
    // ์™ธ๋ถ€ ๊ฒฐ์ œ API ํ˜ธ์ถœ
    // ์ด ์ž‘์—…์€ ํŠธ๋žœ์žญ์…˜ ์•ˆ์—์„œ ํ•˜๋ฉด ์•ˆ ๋จ
    // ...
  }

  private async cancelPaymentGateway(transactionId: string): Promise<void> {
    // ๊ฒฐ์ œ ์ทจ์†Œ API ํ˜ธ์ถœ
    // ...
  }
}
ํŒจํ„ด ์„ค๋ช…:
  1. ์™ธ๋ถ€ API ํ˜ธ์ถœ์€ ํŠธ๋žœ์žญ์…˜ ๋ฐ–์—์„œ
    • ์™ธ๋ถ€ API๋Š” ๋А๋ฆฌ๊ณ  ๋กค๋ฐฑ ๋ถˆ๊ฐ€๋Šฅ
    • ํŠธ๋žœ์žญ์…˜ ์‹œ๊ฐ„์„ ์ตœ์†Œํ™”ํ•ด์•ผ ํ•จ
  2. ๋ณด์ƒ ํŠธ๋žœ์žญ์…˜(Saga ํŒจํ„ด)
    • ์™ธ๋ถ€ API ์„ฑ๊ณต ํ›„ DB ์—…๋ฐ์ดํŠธ ์‹คํŒจ ์‹œ
    • ์™ธ๋ถ€ API ์ทจ์†Œ ํ˜ธ์ถœ (๋ณด์ƒ ์ž‘์—…)
  3. ์—๋Ÿฌ ์ฒ˜๋ฆฌ
    • ๊ฐ ๋‹จ๊ณ„๋ณ„๋กœ ๋ช…ํ™•ํ•œ ์—๋Ÿฌ ์ฒ˜๋ฆฌ
    • ์‹คํŒจ ์ง€์ ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๋ณต๊ตฌ ์ „๋žต
๋ถ„์‚ฐ ํŠธ๋žœ์žญ์…˜ Best Practice:
  • ์™ธ๋ถ€ ์‹œ์Šคํ…œ๊ณผ ํ†ต์‹ ํ•  ๋•Œ๋Š” Saga ํŒจํ„ด ์‚ฌ์šฉ
  • ๊ฐ ๋‹จ๊ณ„๋งˆ๋‹ค ์‹คํŒจ ์‹œ ๋ณด์ƒ ์ž‘์—… ์ •์˜
  • ๋ฉฑ๋“ฑ์„ฑ(Idempotency) ๋ณด์žฅ์œผ๋กœ ์žฌ์‹œ๋„ ์•ˆ์ „์„ฑ ํ™•๋ณด

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