NestJs 와 Spring에서 @Transactional 의 차이를 비교해보자

Bellmin·3일 전
post-thumbnail

들어가며

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은 다음 흐름으로 동작한다.

  1. 프록시가 호출을 가로챈다 (TransactionInterceptor)
  2. 트랜잭션 매니저가 커넥션을 얻고 auto commit을 끈 뒤, 커넥션을 ThreadLocal에 바인딩한다
  3. Repository는 ThreadLocal에서 커넥션을 꺼내 쓴다 (서비스 코드는 커넥션을 몰라도 된다)
  4. 결과에 따라 커밋 또는 롤백하고 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 는 프로토타입이라는 개념을 이용해서, 클래스라는 문법을 지원하기 때문이다.
각 언어가 객체지향 패러다임을 지원하기 위해서, 클래스라는 개념을 동일하게 지원하지만, 내부 구현은 다르다.

3-3. TransactionHost: 전파 속성 처리

withTransaction()은 옵션을 정리한 뒤 decidePropagationAndRun()으로 넘긴다. 전파 속성 이름이 Spring과 똑같다.

// nestjs-cls: transaction-host.ts
withTransaction<R>(firstParam: any, secondParam?: any, thirdParam?: any) {
  // ... (중략: 인자 파싱)
  propagation ??= Propagation.Required; // 기본값은 Spring과 동일하게 REQUIRED
  options = { ...this._options.defaultTxOptions, ...options };
  return this.decidePropagationAndRun(propagation, options, fn);
}

private decidePropagationAndRun(propagation: string, options: any, fn: (...args: any[]) => Promise<any>) {
  switch (propagation) {
    case Propagation.Required:
      if (this.isTransactionActive()) {
        // 이미 트랜잭션이 있으면 그냥 그 안에서 실행 (참여)
        return this.cls.run({ ifNested: 'inherit' }, fn);
      } else {
        // 없으면 새로 시작
        return this.runWithTransaction(options, fn);
      }
    case Propagation.RequiresNew:
      // 항상 새 트랜잭션
      return this.runWithTransaction(options, fn);

    // ... (중략: NotSupported, Mandatory, Never, Supports, Nested)
  }
}

3-4. 트랜잭션 시작과 AsyncLocalStorage 바인딩

// nestjs-cls: transaction-host.ts
private runWithTransaction(options: any, fn: (...args: any[]) => Promise<any>) {
  // 새 CLS 컨텍스트(AsyncLocalStorage store)를 열고
  return this.cls.run({ ifNested: 'inherit' }, () =>
    this._options
      // 어댑터에게 실제 트랜잭션 시작을 위임
      .wrapWithTransaction(options, fn, this.setTxInstance.bind(this))
      .finally(() => this.setTxInstance(undefined)),
  );
}

private setTxInstance(txInstance?: TTxFromAdapter<TAdapter>) {
  // 트랜잭션 객체(EntityManager)를 CLS에 저장
  this.cls.set(this.transactionInstanceSymbol, txInstance);
  // ... (중략)
}

get tx(): TTxFromAdapter<TAdapter> {
  // CLS에 저장된 트랜잭션 객체를 꺼낸다. 없으면 기본 인스턴스
  if (!this.cls.isActive()) {
    return this._options.getFallbackInstance();
  }
  return (this.cls.get(this.transactionInstanceSymbol) ??
    this._options.getFallbackInstance()) as TTxFromAdapter<TAdapter>;
}

cls.run()은 내부적으로 Node.js의 AsyncLocalStorage.run()을 호출한다.

// nestjs-cls: cls.service.ts
run(optionsOrCallback: any, maybeCallback?: any) {
  // ... (중략: 인자 파싱)
  if (!this.isActive()) return this.runWith({} as S, callback);
  switch (options.ifNested) {
    case 'inherit':
      // 부모 컨텍스트 값을 복사한 새 store로 실행
      return this.runWith({ ...this.get() }, callback);
    // ... (중략)
  }
}

runWith<T = any>(store: S, callback: () => T) {
  return this.als.run(store ?? {}, callback); // AsyncLocalStorage.run
}

3-5. TypeORM 어댑터: 결국 dataSource.transaction()

마지막으로 실제 트랜잭션은 앞에서 본 TypeORM의 dataSource.transaction()이 연다. 어댑터는 콜백으로 받은 트랜잭션용 EntityManager를 setClient로 CLS에 넣어줄 뿐이다.

// nestjs-cls: transactional-adapter-typeorm.ts
optionsFactory = (dataSource: DataSource) => ({
  wrapWithTransaction: async (options, fn, setClient) => {
    return dataSource.transaction(options?.isolationLevel, (trx) => {
      setClient(trx); // 트랜잭션용 EntityManager를 CLS에 저장
      return fn();    // 원래 서비스 메서드 실행
    });
  },
  // ... (중략: wrapWithNestedTransaction)

  // 트랜잭션 밖에서 txHost.tx를 호출하면 기본 EntityManager를 반환
  getFallbackInstance: () => dataSource.manager,
});

정리하면 nestjs-cls의 흐름은 다음과 같다.

  1. 데코레이터가 교체해 둔 메서드가 호출된다
  2. TransactionHost가 전파 속성을 판단한다
  3. AsyncLocalStorage 컨텍스트를 열고, TypeORM dataSource.transaction()으로 트랜잭션을 시작한다
  4. 트랜잭션용 EntityManager를 AsyncLocalStorage에 저장한다
  5. Repository는 txHost.tx로 AsyncLocalStorage에서 EntityManager를 꺼내 쓴다
  6. 콜백이 정상 종료되면 커밋, 에러가 나면 롤백 (TypeORM이 처리)

Spring 흐름과 나란히 놓고 보면 ThreadLocal이 AsyncLocalStorage로, Connection이 EntityManager로 바뀌었을 뿐 구조는 거의 같다.


4. 무엇이 다른가

구조는 비슷하지만, 구현 방식의 차이 때문에 실제로 쓸 때 체감되는 차이가 꽤 있다.

구분Spring @Transactionalnestjs-cls @Transactional
제공 주체프레임워크 내장 (spring-tx)서드파티 라이브러리
적용 방식런타임에 빈을 AOP 프록시로 감쌈데코레이터가 클래스 메서드를 Proxy로 교체
트랜잭션 컨텍스트 저장소ThreadLocalAsyncLocalStorage
같은 클래스 내부 호출적용 안 됨적용됨
기본 롤백 규칙RuntimeException, Error만 롤백던져진 모든 에러에 롤백
rollbackFor / noRollbackFor지원없음
참여 트랜잭션 내부 실패전체 트랜잭션을 rollback-only로 마킹마킹 개념 없음
데이터 접근기존 Repository가 그대로 트랜잭션에 참여txHost.tx를 통해 접근해야 참여
트랜잭션 옵션propagation, isolation, readOnly, timeout 등propagation, isolationLevel (어댑터에 따라 다름)

4-1. Self-invocation: Spring에서는 안 되고, nestjs-cls에서는 된다

Spring에서 자주 겪는 문제다.

@Service
public class OrderService {

    public void order() {
        // this.pay()는 프록시를 거치지 않는 내부 호출
        pay();
    }

    @Transactional
    public void pay() {
        // ... 트랜잭션이 적용되지 않는다
    }
}

Spring의 @Transactional은 프록시가 호출을 가로채야 동작한다.
this.pay()는 프록시가 아닌 실제 객체의 메서드를 바로 호출하므로 TransactionInterceptor를 거치지 않는다.

반면 nestjs-cls는 3-2에서 본 것처럼 클래스 프로토타입의 메서드 자체를 바꿔 놓았다.
this.pay()로 호출해도 이미 교체된 메서드가 실행되므로 트랜잭션이 정상적으로 적용된다.

Spring에서는 별도 빈으로 분리하거나 자기 자신을 주입받는 식으로 우회해야 했던 부분이 nestjs-cls에서는 처음부터 문제가 되지 않는다.

4-2. 롤백 규칙: Checked Exception의 유무

1-2에서 봤듯이 Spring의 기본 롤백 규칙은 RuntimeException과 Error뿐이다.
IOException 같은 Checked Exception이 던져지면 커밋된다. 이를 바꾸려면 rollbackFor를 지정해야 한다.

@Transactional(rollbackFor = Exception.class)
public void process() throws IOException { ... }

TypeScript에는 Checked Exception이라는 개념이 없다. 그리고 TypeORM의 EntityManager.transaction()은 catch (err)에서 에러 종류를 따지지 않고 무조건 롤백한다. nestjs-cls에도 rollbackFor, noRollbackFor 같은 옵션이 없다.

따라서 "특정 예외는 커밋하고 싶다"는 요구사항이 있다면 NestJS에서는 해당 예외를 메서드 안에서 직접 catch해서 처리해야 한다.

4-3. REQUIRED 참여 중 내부 실패: rollback-only 마킹의 유무

가장 주의해야 할 차이다. 바깥 트랜잭션에 참여(REQUIRED)한 안쪽 메서드에서 예외가 발생하고, 바깥 메서드가 그 예외를 catch해서 삼킨 경우를 생각해 보자.

@Transactional
public void outer() {
    userRepository.save(user);
    try {
        innerService.inner(); // @Transactional(REQUIRED), 내부에서 RuntimeException 발생
    } catch (RuntimeException e) {
        // 예외를 삼키고 계속 진행
    }
}

Spring에서는 inner()가 실패하는 순간, 참여 중인 트랜잭션 전체가 rollback-only로 마킹된다.

// spring-tx: AbstractPlatformTransactionManager.java (processRollback)
if (status.hasSavepoint()) {
    // ... (중략: NESTED면 savepoint까지만 롤백)
}
else if (status.isNewTransaction()) {
    // 트랜잭션을 직접 시작한 쪽이면 실제 롤백
    doRollback(status);
}
else {
    // Participating in larger transaction (바깥 트랜잭션에 참여 중인 경우)
    if (status.hasTransaction()) {
        if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure()) {
            // 실제 롤백 대신, 전체 트랜잭션을 rollback-only로 표시만 해둔다
            doSetRollbackOnly(status);
        }
        // ... (중략)
    }
    // ... (중략)
}

그래서 outer()가 예외를 삼키고 정상 종료해도 커밋 시점에 UnexpectedRollbackException이 발생하며 전체가 롤백된다.

nestjs-cls에서는 3-3에서 본 것처럼 REQUIRED 참여 시 fn을 그냥 실행할 뿐이다.

case Propagation.Required:
  if (this.isTransactionActive()) {
    return this.cls.run({ ifNested: 'inherit' }, fn); // 별도 처리 없이 실행만 한다
  }

rollback-only를 마킹하는 단계가 없으므로, 안쪽 에러를 바깥에서 catch하면 TypeORM의 트랜잭션 콜백은 정상 종료로 판단하고 커밋한다. 안쪽 메서드가 에러 직전까지 수행한 쓰기 작업도 함께 커밋된다.

Spring에 익숙한 상태에서 같은 코드를 옮겨 오면 정반대 결과가 나올 수 있는 부분이다. 안쪽 작업만 독립적으로 실패 처리하고 싶다면 nestjs-cls에서는 Propagation.Nested(savepoint)나 Propagation.RequiresNew를 명시적으로 쓰는 편이 안전하다.

4-4. 데이터 접근 방식: 기존 Repository가 그대로 참여하지 않는다

Spring에서는 JdbcTemplate이든 JPA Repository든, 트랜잭션 매니저와 같은 DataSource를 쓰는 한 별다른 코드 변경 없이 트랜잭션에 참여한다.

ThreadLocal을 확인하는 코드가 데이터 접근 계층 안에 이미 들어 있기 때문이다.

nestjs-cls의 TypeORM 어댑터는 의도적으로 TypeORM을 몽키패치하지 않는다.

공식 문서에도 트랜잭션을 전파하는 유일한 방법은 (트랜잭션용) EntityManager를 통하는 것이라고 명시되어 있다.

즉 @InjectRepository(User)로 주입받은 Repository를 그대로 쓰면 트랜잭션에 참여하지 않는다.

@Injectable()
class UserRepository {
  constructor(
    @InjectRepository(User) private readonly repo: Repository<User>,
    private readonly txHost: TransactionHost<TransactionalAdapterTypeOrm>,
  ) {}

  async save(user: User) {
    // 트랜잭션에 참여하지 않는다 (기본 EntityManager 사용)
    // return this.repo.save(user);

    // 트랜잭션에 참여한다
    return this.txHost.tx.getRepository(User).save(user);
  }
}

기존 코드베이스에 도입할 때는 데이터 접근 코드를 모두 txHost.tx 기반으로 바꿔야 한다는 점을 고려해야 한다.

4-5. 트랜잭션 옵션의 폭

Spring의 @Transactional은 propagation, isolation, readOnly, timeout, rollbackFor, noRollbackFor 등 다양한 속성을 지원한다.

nestjs-cls는 propagation은 Spring과 같은 7가지(REQUIRED, REQUIRES_NEW, NOT_SUPPORTED, MANDATORY, NEVER, SUPPORTS, NESTED)를 그대로 지원하지만, 나머지 옵션은 어댑터에 위임한다. TypeORM 어댑터의 경우 트랜잭션 옵션은 isolationLevel 하나다.


마치며

두 구현을 나란히 놓고 보면, 선언적 트랜잭션의 본질은 결국 같다.

트랜잭션을 시작하고, 그 트랜잭션에 묶인 커넥션을 현재 실행 흐름에 숨겨둔 뒤, 데이터 접근 계층이 그것을 꺼내 쓰게 한다.

차이는 "현재 실행 흐름"을 무엇으로 정의하느냐다.

스레드 기반인 Spring은 ThreadLocal을, 이벤트 루프 기반인 Node.js는 AsyncLocalStorage를 쓴다.

그리고 이 차이에서 출발한 구현 방식 (빈 프록시 vs 메서드 교체, 프레임워크 내장 vs 라이브러리)이 self-invocation, 롤백 규칙, rollback-only 마킹 같은 실제 동작 차이로 이어진다.

Spring에서 NestJS로 넘어올 때 @Transactional이라는 이름과 전파 속성 이름이 같다고 해서 같은 동작을 기대하면 안 된다.

특히 4-3의 참여 트랜잭션 실패 케이스는 데이터 정합성과 직결되므로, 도입 전에 꼭 테스트로 확인해 보는 것을 추천한다.

참고

0개의 댓글