핵심 원칙
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");
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");
// 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) 보장으로 재시도 안전성 확보
다음 단계
@transactional
데코레이터 사용법
수동 트랜잭션
transaction() 직접 사용
UpsertBuilder
트랜잭션 내 데이터 저장
에러 처리
API 에러 처리하기