메인 콘텐츠로 건너뛰기
@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({ ... });

  // 여기서 커밋하지 않음 (외부 트랜잭션이 커밋)
}
동작 원리:
  1. outerMethod 실행 → 트랜잭션 A 시작
  2. innerMethod 호출 → 기존 트랜잭션 A 감지
  3. innerMethod는 새 트랜잭션을 시작하지 않고 A를 재사용
  4. innerMethod 종료 → 커밋하지 않음
  5. 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자동 생성 안됨 (수동 관리 필요)
커밋최상위 메서드만 커밋
롤백어디서든 에러 발생 시 전체 롤백
중첩 레벨제한 없음
중첩 트랜잭션 주의사항:
  1. 전체 롤백: 중첩된 메서드에서 에러 발생 시 전체 트랜잭션이 롤백됨
  2. Savepoint 없음: 부분 롤백이 필요하면 수동으로 Savepoint 관리
  3. 커밋 시점: 중첩된 메서드 종료 시 커밋되지 않음 (최상위만 커밋)
  4. 에러 처리: 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;
  });
}

주의사항

반드시 지켜야 할 사항:
  1. 메서드는 async 함수여야 함
  2. this.getPuri()로 DB 접근
  3. 에러는 throw로 전파 (자동 롤백)
  4. 중첩 트랜잭션은 같은 컨텍스트 공유

흔한 실수

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;
  }
}

다음 단계