@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 자동 생성 안됨 (수동 관리 필요) 커밋 최상위 메서드만 커밋 롤백 어디서든 에러 발생 시 전체 롤백 중첩 레벨 제한 없음
중첩 트랜잭션 주의사항 : 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 ;
}
}
다음 단계
수동 트랜잭션 transaction() 직접 사용하기
UpsertBuilder 트랜잭션 내 데이터 저장