@transactional 데코레이터는 메서드를 자동으로 트랜잭션으로 감싸주어 보일러플레이트 코드를 줄여줍니다.
데코레이터 개요
자동 트랜잭션
메서드 실행을 자동으로 트랜잭션으로 감쌈성공 시 커밋, 실패 시 롤백
코드 간결화
transaction() 호출 불필요가독성 향상
Isolation Level
트랜잭션 격리 수준 설정동시성 제어
중첩 트랜잭션
자동 컨텍스트 공유Savepoint 지원
기본 사용법
Before: 수동 트랜잭션
class UserModel extends BaseModelClass {
async createUser(data: UserSaveParams): Promise<number> {
const wdb = this.getPuri("w");
// ❌ 매번 transaction() 호출 필요
return wdb.transaction(async (trx) => {
trx.ubRegister("users", data);
const [userId] = await trx.ubUpsert("users");
return userId;
});
}
}
After: @transactional 데코레이터
class UserModel extends BaseModelClass {
@transactional()
async createUser(data: UserSaveParams): Promise<number> {
const wdb = this.getPuri("w");
// ✅ transaction() 호출 없이 바로 작업
wdb.ubRegister("users", data);
const [userId] = await wdb.ubUpsert("users");
return userId;
}
}
@transactional() 데코레이터가 적용된 메서드는 자동으로 트랜잭션 내에서 실행됩니다.
작동 원리
데코레이터 옵션
dbPreset 설정
class UserModel extends BaseModelClass {
// 기본값: dbPreset = "w" (write DB)
@transactional()
async method1() {
const wdb = this.getPuri("w"); // write DB
// ...
}
// 명시적 지정
@transactional({ dbPreset: "w" })
async method2() {
const wdb = this.getPuri("w");
// ...
}
}
Isolation Level 설정
class UserModel extends BaseModelClass {
// READ UNCOMMITTED
@transactional({ isolation: "read uncommitted" })
async readUncommitted() {
const wdb = this.getPuri("w");
// 커밋되지 않은 데이터 읽기 가능 (Dirty Read)
}
// READ COMMITTED
@transactional({ isolation: "read committed" })
async readCommitted() {
const wdb = this.getPuri("w");
// 커밋된 데이터만 읽기 (Non-repeatable Read 가능)
}
// REPEATABLE READ (기본값)
@transactional({ isolation: "repeatable read" })
async repeatableRead() {
const wdb = this.getPuri("w");
// 같은 트랜잭션 내에서 동일한 결과 보장
}
// SERIALIZABLE
@transactional({ isolation: "serializable" })
async serializable() {
const wdb = this.getPuri("w");
// 가장 엄격한 격리 수준 (Phantom Read 방지)
}
}
Isolation Level 주의사항:
- 높은 격리 수준은 동시성 감소
- SERIALIZABLE은 성능 영향 큼
- 대부분의 경우 REPEATABLE READ가 적절
실전 예제
예제 1: 단순 트랜잭션
class UserModel extends BaseModelClass {
@transactional()
async createMultipleUsers(
users: UserSaveParams[]
): Promise<number[]> {
const wdb = this.getPuri("w");
// 여러 User 등록
users.forEach((user) => {
wdb.ubRegister("users", user);
});
// 일괄 저장
const ids = await wdb.ubUpsert("users");
return ids;
}
}
// 사용
const ids = await UserModel.createMultipleUsers([
{ email: "user1@test.com", username: "user1", ... },
{ email: "user2@test.com", username: "user2", ... },
]);
예제 2: 자동 롤백
class UserModel extends BaseModelClass {
@transactional()
async createUserWithValidation(
data: UserSaveParams
): Promise<number> {
const wdb = this.getPuri("w");
// 중복 체크
const existing = await wdb
.table("users")
.where("email", data.email)
.first();
if (existing) {
// 에러 발생 → 자동 롤백
throw new Error("Email already exists");
}
// User 생성
wdb.ubRegister("users", data);
const [userId] = await wdb.ubUpsert("users");
return userId;
}
}
예제 3: 복잡한 트랜잭션 (Company → Dept → Employee)
class CompanyModel extends BaseModelClass {
@transactional({ isolation: "serializable" })
async createOrganization(data: {
companyName: string;
departmentName: string;
employees: Array<{
email: string;
username: string;
salary: string;
}>;
}): Promise<{
companyId: number;
departmentId: number;
employeeIds: number[];
}> {
const wdb = this.getPuri("w");
// 1. Company
const companyRef = wdb.ubRegister("companies", {
name: data.companyName,
});
// 2. Department
const deptRef = wdb.ubRegister("departments", {
name: data.departmentName,
company_id: companyRef,
});
// 3. Users & Employees
data.employees.forEach((emp) => {
const userRef = wdb.ubRegister("users", {
email: emp.email,
username: emp.username,
password: "hashed",
role: "normal",
});
wdb.ubRegister("employees", {
user_id: userRef,
department_id: deptRef,
employee_number: `E${Date.now()}`,
salary: emp.salary,
});
});
// 4. 순서대로 저장
const [companyId] = await wdb.ubUpsert("companies");
const [departmentId] = await wdb.ubUpsert("departments");
await wdb.ubUpsert("users");
const employeeIds = await wdb.ubUpsert("employees");
return { companyId, departmentId, employeeIds };
}
}
예제 4: 동시성 제어
class UserModel extends BaseModelClass {
@transactional({ isolation: "repeatable read" })
async updateLastLogin(userId: number): Promise<void> {
const wdb = this.getPuri("w");
// 1. 현재 로그인 시간 조회
const user = await wdb
.table("users")
.select({ last_login: "last_login_at" })
.where("id", userId)
.first();
if (!user) {
throw new Error("User not found");
}
// 2. 로그인 시간 업데이트
await wdb
.table("users")
.where("id", userId)
.update({
last_login_at: new Date(),
});
// Repeatable Read: 다른 트랜잭션의 변경 영향 없음
}
}
중첩 트랜잭션
자동 컨텍스트 공유
class UserModel extends BaseModelClass {
@transactional()
async createUserWithProfile(
userData: UserSaveParams,
bio: string
): Promise<number> {
const wdb = this.getPuri("w");
// User 생성
const [userId] = await wdb
.table("users")
.insert(userData)
.returning("id");
// 다른 @transactional 메서드 호출
// 같은 트랜잭션 컨텍스트 공유
await this.updateBio(userId, bio);
return userId;
}
@transactional()
async updateBio(userId: number, bio: string): Promise<void> {
const wdb = this.getPuri("w");
await wdb
.table("users")
.where("id", userId)
.update({ bio });
}
}
중첩 트랜잭션의 이점:
- 코드 재사용성 향상
- 메서드 조합 자유로움
- 트랜잭션 경계 자동 관리
중첩 트랜잭션 메커니즘 상세
Sonamu의 중첩 트랜잭션은 트랜잭션 컨텍스트 공유 방식으로 동작합니다.
트랜잭션 컨텍스트 공유
// 외부 트랜잭션
@transactional()
async outerMethod() {
const wdb = this.getPuri("w");
// 여기서 트랜잭션 A가 시작됨
await wdb.table("users").insert({ ... });
// 내부 메서드 호출
await this.innerMethod(); // ← 트랜잭션 A를 공유
await wdb.table("logs").insert({ ... });
// 여기서 트랜잭션 A가 커밋됨
}
@transactional()
async innerMethod() {
const wdb = this.getPuri("w");
// 새 트랜잭션을 시작하지 않고, 외부 트랜잭션 A를 재사용
await wdb.table("profiles").insert({ ... });
// 여기서 커밋하지 않음 (외부 트랜잭션이 커밋)
}
동작 원리:
outerMethod 실행 → 트랜잭션 A 시작
innerMethod 호출 → 기존 트랜잭션 A 감지
innerMethod는 새 트랜잭션을 시작하지 않고 A를 재사용
innerMethod 종료 → 커밋하지 않음
outerMethod 종료 → 트랜잭션 A 커밋
Savepoint는 생성되지 않음
Sonamu는 Savepoint를 자동으로 생성하지 않습니다. 모든 중첩된 메서드가 같은 트랜잭션을 공유하므로:
@transactional()
async methodA() {
const wdb = this.getPuri("w");
await wdb.table("users").insert({ id: 1 });
try {
await this.methodB(); // ← 실패하면?
} catch (error) {
// ⚠️ methodB가 실패해도 이미 전체 트랜잭션이 롤백됨
// Savepoint가 없으므로 users INSERT도 함께 롤백
console.error("Failed:", error);
}
// ❌ 여기는 실행되지 않음 (트랜잭션이 이미 롤백됨)
await wdb.table("logs").insert({ ... });
}
@transactional()
async methodB() {
const wdb = this.getPuri("w");
throw new Error("Failed!"); // ← 전체 트랜잭션 롤백
}
결과:
methodB에서 에러 발생 → 전체 트랜잭션 롤백
methodA의 users INSERT도 롤백됨
- Savepoint가 없으므로 부분 롤백 불가
Savepoint가 필요한 경우
부분 롤백이 필요하면 수동으로 Savepoint를 관리해야 합니다:
@transactional()
async methodWithSavepoint() {
const wdb = this.getPuri("w");
// 기본 데이터 삽입
await wdb.table("users").insert({ id: 1, name: "John" });
// Savepoint 생성
await wdb.raw("SAVEPOINT sp1");
try {
// 위험한 작업
await wdb.table("profiles").insert({ user_id: 1, bio: "..." });
// 성공 시 Savepoint 해제
await wdb.raw("RELEASE SAVEPOINT sp1");
} catch (error) {
// 실패 시 Savepoint까지만 롤백
await wdb.raw("ROLLBACK TO SAVEPOINT sp1");
console.error("Profile creation failed, but user is saved");
}
// 로그는 항상 삽입
await wdb.table("logs").insert({ action: "user_created" });
// 전체 커밋
}
Savepoint 사용 시나리오:
- 선택적 작업이 실패해도 메인 작업은 성공시켜야 할 때
- 여러 단계의 작업 중 일부만 롤백하고 싶을 때
- 복잡한 비즈니스 로직에서 부분 실패 처리가 필요할 때
트랜잭션 컨텍스트 확인
현재 실행 중인 트랜잭션이 있는지 확인하는 방법:
import { DB } from "sonamu";
@transactional()
async checkContext() {
const wdb = this.getPuri("w");
// 트랜잭션 컨텍스트 가져오기
const txContext = DB.getTransactionContext();
const transaction = txContext.getTransaction("w");
if (transaction) {
console.log("현재 트랜잭션이 활성화되어 있습니다.");
} else {
console.log("트랜잭션이 없습니다.");
}
}
중첩 레벨 제한 없음
Sonamu는 중첩 레벨에 제한이 없습니다:
@transactional()
async level1() {
await this.level2(); // OK
}
@transactional()
async level2() {
await this.level3(); // OK
}
@transactional()
async level3() {
await this.level4(); // OK
}
// 모두 같은 트랜잭션 컨텍스트 공유
커밋 시점
오직 최상위 트랜잭션만 커밋합니다:
@transactional()
async topLevel() {
// 트랜잭션 시작
await this.middleLevel();
// ✅ 여기서 커밋 (최상위)
}
@transactional()
async middleLevel() {
await this.bottomLevel();
// ❌ 여기서 커밋하지 않음 (중첩)
}
@transactional()
async bottomLevel() {
// 작업 수행
// ❌ 여기서도 커밋하지 않음 (중첩)
}
정리:
| 항목 | 동작 |
|---|
| 트랜잭션 생성 | 최상위 메서드만 생성 |
| 트랜잭션 공유 | 모든 중첩 메서드가 공유 |
| Savepoint | 자동 생성 안됨 (수동 관리 필요) |
| 커밋 | 최상위 메서드만 커밋 |
| 롤백 | 어디서든 에러 발생 시 전체 롤백 |
| 중첩 레벨 | 제한 없음 |
중첩 트랜잭션 주의사항:
- 전체 롤백: 중첩된 메서드에서 에러 발생 시 전체 트랜잭션이 롤백됨
- Savepoint 없음: 부분 롤백이 필요하면 수동으로 Savepoint 관리
- 커밋 시점: 중첩된 메서드 종료 시 커밋되지 않음 (최상위만 커밋)
- 에러 처리: try-catch로 잡아도 트랜잭션은 이미 롤백된 상태
권장 사항:
- 간단한 중첩:
@transactional 자동 공유 사용
- 복잡한 로직: 수동 Savepoint 관리 고려
- 에러 처리: 최상위 메서드에서 처리
@api와 함께 사용
데코레이터 조합
class UserController extends BaseModelClass {
// 두 데코레이터를 함께 사용 가능
@api({ httpMethod: "POST" })
@transactional()
async createUser(
params: UserSaveParams
): Promise<{ userId: number }> {
const wdb = this.getPuri("w");
wdb.ubRegister("users", params);
const [userId] = await wdb.ubUpsert("users");
return { userId };
}
// 순서는 상관없음
@transactional()
@api({ httpMethod: "PUT" })
async updateUser(
id: number,
params: Partial<UserSaveParams>
): Promise<void> {
const wdb = this.getPuri("w");
await wdb
.table("users")
.where("id", id)
.update(params);
}
}
장단점
코드 간결화
transaction() 호출 불필요보일러플레이트 제거
제약사항
메서드 레벨에서만 사용부분 트랜잭션 불가
디버깅
트랜잭션 경계가 숨겨짐문제 추적 어려울 수 있음
사용 가이드
언제 사용하나?
✅ 사용 권장:
- 메서드 전체가 하나의 트랜잭션
- 여러 DB 작업이 원자성 필요
- 코드 재사용이 중요한 경우
- API 핸들러 메서드
❌ 사용 비권장:
- 메서드 내 부분적 트랜잭션
- 복잡한 트랜잭션 제어 필요
- 트랜잭션 경계가 명확해야 하는 경우
패턴 비교
// Pattern 1: @transactional (권장)
@transactional()
async createUser(data: UserSaveParams): Promise<number> {
const wdb = this.getPuri("w");
wdb.ubRegister("users", data);
const [id] = await wdb.ubUpsert("users");
return id;
}
// Pattern 2: 수동 transaction (복잡한 경우)
async createUserComplex(data: UserSaveParams): Promise<number> {
const wdb = this.getPuri("w");
return wdb.transaction(async (trx) => {
// 복잡한 트랜잭션 로직
const [id] = await trx.table("users").insert(data).returning("id");
// 조건부 롤백
if (someCondition) {
await trx.rollback();
}
return id;
});
}
주의사항
반드시 지켜야 할 사항:
- 메서드는
async 함수여야 함
this.getPuri()로 DB 접근
- 에러는 throw로 전파 (자동 롤백)
- 중첩 트랜잭션은 같은 컨텍스트 공유
흔한 실수
class UserModel extends BaseModelClass {
// ❌ 잘못된 사용
@transactional()
async wrongUsage1(data: UserSaveParams): Promise<number> {
// getPuri를 다른 변수에 저장하지 말 것
const db = this.getPuri("w");
// 에러를 catch로 숨기면 롤백 안 됨
try {
db.ubRegister("users", data);
const [id] = await db.ubUpsert("users");
return id;
} catch (error) {
console.error(error);
return 0; // ❌ 에러를 숨김 → 롤백 안 됨
}
}
// ✅ 올바른 사용
@transactional()
async correctUsage(data: UserSaveParams): Promise<number> {
const wdb = this.getPuri("w");
// 에러는 throw로 전파
if (!data.email) {
throw new Error("Email is required");
}
wdb.ubRegister("users", data);
const [id] = await wdb.ubUpsert("users");
return id;
}
}
다음 단계