ํธ๋์ญ์
์ ์ฌ๋ฐ๋ฅด๊ฒ ์ฌ์ฉํ๋ฉด ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ์ ๋ณด์ฅํ๋ฉด์๋ ์ฑ๋ฅ์ ์ต์ ํํ ์ ์์ต๋๋ค.
ํต์ฌ ์์น
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);
});
});
์ฒดํฌ๋ฆฌ์คํธ
ํธ๋์ญ์
์ค๊ณ ์
์ฝ๋ ๋ฆฌ๋ทฐ ์
์ํฐํจํด
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));
}
}
์ฌ์๋ ์ ๋ต:
- ๋ฐ๋๋ฝ ์๋ฌ๋ง ์ฌ์๋ (๋ค๋ฅธ ์๋ฌ๋ ์ฆ์ ์คํจ)
- ์ง์ ๋ฐฑ์คํ๋ก ์ฌ์๋ ๊ฐ๊ฒฉ ์ฆ๊ฐ (100ms โ 200ms โ 400ms)
- ์ต๋ ์ฌ์๋ ํ์ ์ ํ
์๋๋ฆฌ์ค 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 ํธ์ถ
// ...
}
}
ํจํด ์ค๋ช
:
-
์ธ๋ถ API ํธ์ถ์ ํธ๋์ญ์
๋ฐ์์
- ์ธ๋ถ API๋ ๋๋ฆฌ๊ณ ๋กค๋ฐฑ ๋ถ๊ฐ๋ฅ
- ํธ๋์ญ์
์๊ฐ์ ์ต์ํํด์ผ ํจ
-
๋ณด์ ํธ๋์ญ์
(Saga ํจํด)
- ์ธ๋ถ API ์ฑ๊ณต ํ DB ์
๋ฐ์ดํธ ์คํจ ์
- ์ธ๋ถ API ์ทจ์ ํธ์ถ (๋ณด์ ์์
)
-
์๋ฌ ์ฒ๋ฆฌ
- ๊ฐ ๋จ๊ณ๋ณ๋ก ๋ช
ํํ ์๋ฌ ์ฒ๋ฆฌ
- ์คํจ ์ง์ ์ ๋ฐ๋ผ ๋ค๋ฅธ ๋ณต๊ตฌ ์ ๋ต
๋ถ์ฐ ํธ๋์ญ์
Best Practice: - ์ธ๋ถ ์์คํ
๊ณผ ํต์ ํ ๋๋ Saga ํจํด ์ฌ์ฉ - ๊ฐ ๋จ๊ณ๋ง๋ค ์คํจ ์
๋ณด์ ์์
์ ์ - ๋ฉฑ๋ฑ์ฑ(Idempotency) ๋ณด์ฅ์ผ๋ก ์ฌ์๋ ์์ ์ฑ ํ๋ณด
๋ค์ ๋จ๊ณ
@transactional
๋ฐ์ฝ๋ ์ดํฐ ์ฌ์ฉ๋ฒ
์๋ ํธ๋์ญ์
transaction() ์ง์ ์ฌ์ฉ
UpsertBuilder
ํธ๋์ญ์
๋ด ๋ฐ์ดํฐ ์ ์ฅ
์๋ฌ ์ฒ๋ฆฌ
API ์๋ฌ ์ฒ๋ฆฌํ๊ธฐ