핵심 원칙
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 = "[email protected]";
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");
// 직접 작업
// ...
}
