ํต์ฌ ์์น
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๋ ์๋์ผ๋ก ์์ฑ/๊ด๋ฆฌํด์ผ ํฉ๋๋ค (์๋ ์์ฑ ์ ๋จ)
- ์ค์ฒฉ ๋ ๋ฒจ์ด ๊น์ด์ง์๋ก ๋ณต์ก๋ ์ฆ๊ฐ
- ๋๋ถ๋ถ์ ๊ฒฝ์ฐ ์ ์ฒด ๋กค๋ฐฑ์ด ๋ ์์ ํ๊ณ ๋จ์ํฉ๋๋ค
์๋๋ฆฌ์ค 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) ๋ณด์ฅ์ผ๋ก ์ฌ์๋ ์์ ์ฑ ํ๋ณด