@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 };
}
}
class OrderModelClass extends BaseModelClass {
@api({ httpMethod: "POST" })
@transactional()
async createOrder(data: OrderCreateParams) {
const wdb = this.getDB("w");
// 1. ์ฃผ๋ฌธ ์์ฑ
const order = await this.insert(wdb, {
user_id: data.userId,
total_amount: data.totalAmount,
status: "pending"
});
// 2. ์ฃผ๋ฌธ ์ํ ์ ์ฅ
await wdb.table("order_items").insert(
data.items.map(item => ({
order_id: order.id,
product_id: item.productId,
quantity: item.quantity,
price: item.price
}))
);
// 3. ์ฌ๊ณ ๊ฐ์
for (const item of data.items) {
const updated = await wdb.table("products")
.where("id", item.productId)
.where("stock", ">=", item.quantity)
.decrement("stock", item.quantity);
if (updated === 0) {
throw new Error(`Product ${item.productId} out of stock`);
}
}
// 4. ํฌ์ธํธ ์ฌ์ฉ
if (data.usePoints > 0) {
await wdb.table("users")
.where("id", data.userId)
.decrement("points", data.usePoints);
}
return order;
}
}
class ReportModelClass extends BaseModelClass {
@transactional({
readOnly: true,
isolation: "repeatable read"
})
async generateMonthlyReport(month: string) {
const rdb = this.getPuri("r");
// ๋ชจ๋ ์ฟผ๋ฆฌ๊ฐ ์ผ๊ด๋ ์ค๋
์ท์์ ์คํ๋จ
// 1. ๋งค์ถ ์ง๊ณ
const sales = await rdb.table("orders")
.whereRaw("DATE_FORMAT(created_at, '%Y-%m') = ?", [month])
.sum("total_amount as total");
// 2. ์ ๊ท ํ์ ์
const newUsers = await rdb.table("users")
.whereRaw("DATE_FORMAT(created_at, '%Y-%m') = ?", [month])
.count("* as count");
// 3. ์ํ๋ณ ํ๋งค๋
const productSales = await rdb.table("order_items")
.join("orders", "orders.id", "order_items.order_id")
.whereRaw("DATE_FORMAT(orders.created_at, '%Y-%m') = ?", [month])
.groupBy("order_items.product_id")
.select(
"order_items.product_id",
rdb.raw("SUM(order_items.quantity) as total_quantity"),
rdb.raw("SUM(order_items.price * order_items.quantity) as total_amount")
);
return {
month,
sales: sales[0].total,
newUsers: newUsers[0].count,
productSales
};
}
}
class DataModelClass extends BaseModelClass {
// ์ ์ฒด ์ฒ๋ฆฌ (ํธ๋์ญ์
์์)
async processBulkData(items: Item[]) {
const batches = this.createBatches(items, 100);
const results = [];
for (const batch of batches) {
try {
const result = await this.processBatch(batch);
results.push(result);
} catch (error) {
console.error("Batch failed:", error);
// ์คํจํ ๋ฐฐ์น๋ง ๋กค๋ฐฑ๋จ
}
}
return results;
}
// ๋ฐฐ์น๋ณ ํธ๋์ญ์
@transactional()
async processBatch(batch: Item[]) {
const wdb = this.getDB("w");
// ๋ฐฐ์น ๋จ์๋ก ํธ๋์ญ์
const processed = [];
for (const item of batch) {
const result = await this.processItem(wdb, item);
processed.push(result);
}
return processed;
}
private createBatches<T>(items: T[], size: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += size) {
batches.push(items.slice(i, i + size));
}
return batches;
}
}
๋ค์ ๋จ๊ณ