Loading the catalog…
Loading the catalog…
들어가며 Spring으로 개발하다 보면 @Transactional 은 거의 공기 같은 존재다. 서비스 메서드 위에 한 줄 붙이면 커밋과 롤백은 프레임워크가 알아서 처리해 준다. 그런데 NestJS로 넘어오면 이야기가 달라진다. NestJS 코어에는 @Transactional 이 없다. 공식 문서에서 안내하는 방법은 TypeORM의 DataSource.transaction() 이나 QueryRunner 를 직접 다루는 것이고, 선언적 트랜잭션을 쓰고 싶다면 서드파티 라이브러리를 골라야 한다. 이 글에서는 NestJS 진영에서 가장 많이 쓰이는 @nestjs-cls/transactional (+ TypeORM 어댑터)을 기준으로, Spring의 @Transactional 과 어떻게 다르게 동작하는지 실제 소스 코드를 따라가며 비교해 본다. 1. Spring의 @Transactional은 어떻게 동작하는가 1-1. 프록시와 TransactionInterceptor Spring은 @Transactional 이 붙은 빈을 그대로 등록하지 않고, AOP 프록시로 감싸서 등록한다. 외부에서 메서드를 호출하면 프록시가 먼저 호출을 받고, 그 안의 TransactionInterceptor 가 트랜잭션 처리를 담당한다. // spring-tx: TransactionInterceptor.java public class TransactionInterceptor extends TransactionAspectSupport implements MethodInterceptor, Serializable { @Override public @Nullable Object invoke(MethodInvocation invocation) throws Throwable { // 프록시 뒤에 있는 실제 대상 클래스 Class<?> targetClass = (invocation.getThis() != null ? AopUtils.getTargetClass(invocation.getThis()) : null); // 실제 트랜잭션 처리는 부모 클래스인 TransactionAspectSupport에 위임 return invokeWithinTransaction(invocation.getMethod(), targetClass, new InvocationCallback() { @Override public @Nullable Object proceedWithInvocation() throws Throwable { return invocation.proceed(); // 원래 서비스 메서드 호출 } // ... (중략) }); } } 1-2. invokeWithinTransaction: 시작, 커밋, 롤백 핵심은 TransactionAspectSupport.invokeWithinTransaction() 이다. 구조만 보면 우리가 손으로 짜던 try-catch-finally 트랜잭션 코드와 같다. // spring-tx: TransactionAspectSupport.java protected @Nullable Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass, final InvocationCallback invocation) throws Throwable { // @Transactional에 적힌 속성(propagation, isolation, rollbackFor 등)을 읽어온다 TransactionAttributeSource tas = getTransactionAttributeSource(); final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null); final TransactionManager tm = determineTransactionManager(txAttr, targetClass); // ... (중략: Reactive 트랜잭션 분기) PlatformTransactionManager ptm = asPlatformTransactionManager(tm); final String joinpointIdentification = methodIdentification(method, targetClass, txAttr); if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager cpptm)) { // 1. 트랜잭션 시작 (필요하다면) TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification); Object retVal; try { // 2. 실제 비즈니스 로직 실행 retVal = invocation.proceedWithInvocation(); } catch (Throwable ex) { // 3-1. 예외 발생 시 롤백 (또는 롤백 규칙에 따라 커밋) completeTransactionAfterThrowing(txInfo, invocation, ex); throw ex; } finally { cleanupTransactionInfo(txInfo); } // ... (중략: Future, Vavr Try 반환값 처리) // 3-2. 정상 종료 시 커밋 commitTransactionAfterReturning(txInfo); return retVal; } // ... (중략: CallbackPreferringPlatformTransactionManager 분기) } 여기서 눈여겨볼 부분은 예외 처리다. completeTransactionAfterThrowing() 은 모든 예외에 롤백하지 않는다. // spring-tx: TransactionAspectSupport.java protected void completeTransactionAfterThrowing( @Nullable TransactionInfo txInfo, InvocationCallback invocation, Throwable ex) { if (txInfo != null && txInfo.getTransactionStatus() != null) { // 롤백 대상 예외인지 확인 if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) { txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus()); // ... (중략) } else { // 롤백 대상이 아니면 커밋한다 txInfo.getTransactionManager().commit(txInfo.getTransactionStatus()); // ... (중략) } } } // spring-tx: DefaultTransactionAttribute.java public boolean rollbackOn(Throwable ex) { // 기본 규칙: Unchecked 예외(RuntimeException)와 Error만 롤백 return (ex instanceof RuntimeException || ex instanceof Error); } 즉 Spring은 기본적으로 Checked Exception이 발생하면 롤백하지 않고 커밋한다. 이 부분은 뒤에서 NestJS와 비교할 때 다시 등장한다. 1-3. 커넥션은 어디에 보관되는가: ThreadLocal 트랜잭션을 시작했다면, 같은 트랜잭션 안의 Repository들은 모두 같은 커넥션을 써야 한다. 그런데 Spring에서는 서비스가 Repository에 커넥션을 넘겨주지 않는다. 그럼 Repository는 커넥션을 어떻게 찾을까? 답은 TransactionSynchronizationManager 의 ThreadLocal 이다. // spring-jdbc: DataSourceTransactionManager.java protected void doBegin(Object transaction, TransactionDefinition definition) { DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction; Connection con = null; try { if (!txObject.hasConnectionHolder() || txObject.getConnectionHolder().isSynchronizedWithTransaction()) { // 커넥션 풀에서 커넥션 획득 Connection newCon = obtainDataSource().getConnection(); // ... (중략) txObject.setConnectionHolder(new ConnectionHolder(newCon), true); } txObject.getConnectionHolder().setSynchronizedWithTransaction(true); con = txObject.getConnectionHolder().getConnection(); // ... (중략: 격리 수준, readOnly 설정) // auto commit 해제 = 트랜잭션 시작 if (con.getAutoCommit()) { txObject.setMustRestoreAutoCommit(true); con.setAutoCommit(false); } // ... (중략: timeout 설정) // 현재 스레드에 커넥션을 바인딩 if (txObject.isNewConnectionHolder()) { TransactionSynchronizationManager.bindResource(obtainDataSource(), txObject.getConnectionHolder()); } } // ... (중략) } // spring-tx: TransactionSynchronizationManager.java public abstract class TransactionSynchronizationManager { // 트랜잭션 리소스(커넥션 등)를 스레드별로 보관하는 저장소 private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources"); // ... (중략) } 그리고 JdbcTemplate 같은 데이터 접근 계층은 커넥션이 필요할 때 이 ThreadLocal부터 확인한다. // spring-jdbc: DataSourceUtils.java public static Connection doGetConnection(DataSource dataSource) throws SQLException { // 현재 스레드에 바인딩된 커넥션이 있으면 그것을 사용 ConnectionHolder conHolder = (ConnectionHolder) TransactionSynchronizationManager.getResource(dataSource); if (conHolder != null && (conHolder.hasConnection() || conHolder.isSynchronizedWithTransaction())) { conHolder.requested(); // ... (중략) return conHolder.getConnection(); } // 없으면 새 커넥션을 가져온다 // ... (중략) } 정리하면 Spring의 @Transactional 은 다음 흐름으로 동작한다. 프록시가 호출을 가로챈다 ( TransactionInterceptor ) 트랜잭션 매니저가 커넥션을 얻고 auto commit을 끈 뒤, 커넥션을 ThreadLocal에 바인딩 한다 Repository는 ThreadLocal에서 커넥션을 꺼내 쓴다 (서비스 코드는 커넥션을 몰라도 된다) 결과에 따라 커밋 또는 롤백하고 ThreadLocal을 정리한다 Spring MVC는 요청 하나를 스레드 하나가 처리하는 모델이기 때문에, "트랜잭션 = 현재 스레드"라는 가정이 자연스럽게 성립한다. 2. NestJS에는 왜 @Transactional이 없을까 2-1. TypeORM의 트랜잭션은 콜백 방식이다 NestJS에서 TypeORM을 쓰면 트랜잭션은 보통 이렇게 작성한다. await this.dataSource.transaction(async (manager) => { // 반드시 콜백으로 전달받은 manager를 사용해야 같은 트랜잭션으로 묶인다 await manager.save(user); await manager.save(account); }); DataSource.transaction() 은 내부적으로 EntityManager.transaction() 을 호출하고, 그 구현은 다음과 같다. // typeorm: EntityManager.ts async transaction<T>( isolationOrRunInTransaction: IsolationLevel | ((entityManager: EntityManager) => Promise<T>), runInTransactionParam?: (entityManager: EntityManager) => Promise<T>, ): Promise<T> { // ... (중략: 인자 파싱) // 트랜잭션 전용 커넥션(QueryRunner) 생성 const queryRunner = this.queryRunner ?? this.dataSource.createQueryRunner(); try { await queryRunner.startTransaction(isolation); // 이 QueryRunner에 묶인 EntityManager를 콜백에 넘겨준다 const result = await runInTransaction(queryRunner.manager); await queryRunner.commitTransaction(); return result; } catch (err) { try { // 어떤 에러든 롤백 await queryRunner.rollbackTransaction(); } catch (rollbackError) {} throw err; } finally { if (!this.queryRunner) await queryRunner.release(); } } 흐름 자체는 Spring의 invokeWithinTransaction() 과 똑같다. 차이는 트랜잭션에 묶인 커넥션( queryRunner.manager )을 콜백 인자로 넘긴다 는 점이다. 같은 트랜잭션으로 묶고 싶은 모든 코드가 이 manager 를 직접 받아서 써야 한다. 서비스가 여러 Repository를 호출하는 구조라면 manager 를 계속 파라미터로 내려보내야 하고, Repository 메서드 시그니처가 트랜잭션 때문에 오염된다. 2-2. ThreadLocal을 쓸 수 없는 이유 "Spring처럼 현재 실행 흐름에 커넥션을 붙여두면 되지 않나?"라는 생각이 들 수 있다. 그런데 Node.js는 싱글 스레드 이벤트 루프 위에서 여러 요청이 await 단위로 번갈아 실행된다. 요청 A가 await 로 DB 응답을 기다리는 동안 같은 스레드에서 요청 B가 실행되기 때문에, "현재 스레드"에 무언가를 저장하는 방식은 요청 간에 값이 섞여 버린다. 그래서 Node.js에서는 ThreadLocal 대신 AsyncLocalStorage 를 쓴다. AsyncLocalStorage는 스레드가 아니라 비동기 호출 체인 을 따라 값을 전파한다. als.run(store, callback) 안에서 시작된 모든 비동기 작업(await, Promise, setTimeout 등)은 같은 store를 보게 된다. @nestjs-cls/transactional 은 바로 이 AsyncLocalStorage 위에 Spring 스타일의 @Transactional 을 구현한 라이브러리다. 3. @nestjs-cls/transactional은 어떻게 동작하는가 3-1. 사용법 먼저 사용하는 모습부터 보자. (공식 문서의 TypeORM 어댑터 예제) // app.module.ts ClsModule.forRoot({ plugins: [ new ClsPluginTransactional({ imports: [TypeOrmModule], adapter: new TransactionalAdapterTypeOrm({ dataSourceToken: getDataSourceToken(), }), }), ], }), // user.service.ts @Injectable() class UserService { constructor(private readonly userRepository: UserRepository) {} @Transactional() async runTransaction() { // 두 메서드가 같은 트랜잭션에서 실행된다 const user = await this.userRepository.createUser('John'); const foundUser = await this.userRepository.getUserById(user.id); } } // user.repository.ts @Injectable() class UserRepository { constructor( private readonly txHost: TransactionHost<TransactionalAdapterTypeOrm>, ) {} async getUserById(id: number) { // txHost.tx는 EntityManager 타입 // 트랜잭션 중이면 트랜잭션용 EntityManager, 아니면 기본 EntityManager return await this.txHost.tx.getRepository(User).findOneBy({ id }); } // ... (중략) } 서비스 코드는 Spring과 거의 같아졌다. 다만 Repository가 txHost.tx 를 통해 EntityManager를 꺼내 쓴다는 점이 다르다. Spring의 DataSourceUtils.getConnection() 이 ThreadLocal에서 커넥션을 꺼내던 역할을 txHost.tx 가 대신한다고 보면 된다. 3-2. @Transactional 데코레이터: 프록시가 아니라 메서드 교체 // nestjs-cls: transactional.decorator.ts export function Transactional(firstParam?: any, secondParam?: any, thirdParam?: any): MethodDecorator { // ... (중략: connectionName, propagation, options 파싱) return ((target, propertyKey, descriptor) => { const original = descriptor.value; // 원래 메서드 // ... (중략: 함수가 아니면 에러) // 클래스 정의 시점에 메서드 자체를 Proxy로 교체한다 descriptor.value = new Proxy(original, { apply: function (_, outerThis, args: any[]) { const transactionHost = TransactionHost.getInstance(connectionName); // 원래 메서드를 트랜잭션 안에서 실행 return transactionHost.withTransaction( propagation as Propagation, options as never, original.bind(outerThis, ...args), ); }, }); copyMethodMetadata(original, descriptor.value); }) as MethodDecorator; } Spring은 빈 객체를 프록시로 감싸는 방식이고, nestjs-cls는 데코레이터가 클래스 프로토타입의 메서드 자체를 바꿔치기 하는 방식이다. 이 차이가 뒤에서 설명할 self-invocation 문제의 유무로 이어진다. javascript 에서 클래스는 Java에서의 클래스와는 조금 다르다. javascript 는 프로토타입이
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
NestJs 와 Spring에서 @Transactional 의 차이를 비교해보자. 들어가며 Spring으로 개발하다 보면 @Transactional 은 거의 공기 같은 존재다. 서비스 메서드 위에 한 줄 붙이면 커밋과 롤백은 프레임워크가 알아서 처리해 준다. 그런데 NestJS로 넘어오면 이야기가 달라진다. NestJS 코어에는 @Transactional 이 없다. 공식 문서에서 안내하는 방법은 TypeORM의 DataSource.transaction() 이나 QueryRunner 를 직접 다루는 것이고, 선언적 트랜잭션을 쓰고 싶다면 서드파티 라이브러리를 골라야 한다. 이 글에서는 NestJS 진영에서 가장 많이 쓰이는 @nestjs-cls/transactional (+ TypeORM 어댑터)을…
Open source