Programming/Node.js

[NestJS] mongoose에서 @Transactional() 사용하기

SpiralMoon 2025. 5. 26. 22:17
반응형

mongoose에서 @Transactional() 사용하기

NestJS 환경에서 mongoose 트랜잭션을 처리할 때 겪는 문제점과 그 해결 방법인 @Transactional()로 트랜잭션을 다루는 방법에 대해 알아보자.

작성 환경

Node.js 16.4 이상

MongoDB 4.2 이상 및 분산 환경(Replica Set or Sharded Cluster), 스토리지 엔진이 WiredTiger 일 것


사전 지식

AsyncLocalStorage

 

[Node.js] AsyncLocalStorage

AsyncLocalStorage비동기 작업의 context를 유지할 수 있도록 해주는 AsyncLocalStorage API에 대해 알아보자.작성 환경Node.js 16.4 (stable)AsyncLocalStorage란?AsyncLocalStorage(이하 als)는 Node.js 16.4에서 정식 추가된 API

blog.spiralmoon.dev

트랜잭션의 정의

 

데이터베이스 트랜잭션 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 데이터베이스 트랜잭션(database transaction)은 DBMS 또는 유사한 시스템에서 상호작용의 단위 또는 일련의 연산이다. 여기서 유사한 시스템이란 트랜잭션의 성공과

ko.wikipedia.org


@Transactional()이란?

@Transactional() 데코레이터는 특정 함수에 작성된 DB를 조작하는 쿼리 작업들을 일련의 단위로 묶어서 원자성을 자동으로 부여하는 기능이다.

 

class Service {

  @Transactional()
  run() {
    // DB 쿼리 1
    // DB 쿼리 2
    // DB 쿼리 3
  }
}

 

위 코드에서 @Transactional()이 적용된 함수 run()은 1, 2, 3가 모두 성공해야 반영, 하나라도 실패하면 모두 롤백되는 원자성이 부여된다.

 

Spring의 repository와 @Transactional 적용 모습

 

사실 Spring + RDB 진영에서는 이미 @Transactional() 없이는 개발이 불가능할 정도로 편리한 기능이며 이미 표준이 된지 오래되었다. (2006년에 등장)

물론 Spring에만 존재하는 기능은 아니다.

 

NestJS의 typeorm과 @Transactional() 적용 모습

NestJS에서도 RDB를 사용할 때 TypeORM을 쓰면 @Transactional()을 사용해 Spring과 똑같이 원자성을 부여할 수 있다.

 

그러나 mongoose에서는...


mongoose에는 @Transactional()이 없다

안타깝게도 mongoose는 타 RDB 라이브러리에서 지원하는 @Transactional()를 기본 제공하지 않기 때문에 session을 열고 명시적으로 commit을 하는 방식으로 트랜잭션을 처리해야 한다.

 

import { connection } from 'mongoose';

...

const session = await connection.startSession();

try {
  session.startTransaction();
  // DB 명령 작업에 session 전달
  const docA = new model({ name: 'A' });
  await docA.save({ session });

  // 적용
  await session.commitTransaction();
} catch (e) {
  // 취소
  await session.abortTransaction();
} finally {
  await session.endSession();
}

 

mongoose에서의 트랜잭션은

  1. 현재 mongoose connection에 대한 session을 시작하고
  2. 트랜잭션을 시작하고
  3. 트랜잭션이 적용될 DB 명령에 session을 전달하고
  4. commit 또는 abort를 적용하고
  5. 마지막으로 session을 닫는

순서대로 진행된다.

 

매번 commit과 abort를 명시해야하는 불편함이 있기 때문에 더 간소화된 버전도 있다.

 

import { connection } from 'mongoose';

...

const session = await connection.startSession();

try {
  await session.withTransaction(async () => {
    // DB 명령 작업에 session 전달
    const docA = new model({ name: 'A' });
    await docA.save({ session });
  });
} catch (e) 
  // TODO
} finally {
  await session.endSession();
}

 

withTransaction() 을 사용하면 트랜잭션이 바로 시작되며 callback이 종료된 후에 자동으로 commit 처리되고 오류가 발생하면 abort가 처리된다. 코드가 줄어들긴 했지만 깔끔하지 못한 것은 여전하다.

 

위에서 설명한 문제들은 공통적인 문제점을 가지고 있다.

중복 코드 : 트랜잭션이 적용되어야 할 로직이 늘어날 경우 매번 session과 트랜잭션을 열고 닫고 DB 명령에 session을 전달하는 코드를 작성하게 된다. 이는 유지보수 비용 증가로 직결된다.

높은 결합도 : 로직과 트랜잭션이 강하게 결합되어 분리되지 않는다.

휴먼 에러 : 개발자가 DB 명령에 session 전달을 누락할 가능성이 있다.


트랜잭션과 AOP

mongoose 기본 트랜잭션의 문제점 - 더러운 트랜잭션 제어

이번에는 mongoose의 비효율적인 기본 트랜잭션 제어 방식에 대해 설명해보겠다. 우선 mongoose의 트랜잭션은 전혀 AOP하지 않다.

import { ClientSession } from 'mongodb';

/**
 * 트랜잭션을 적용한 로직
 */
function logicA() {
  const session = await connection.startSession();
  session.startTransaction();
  
  // 어떤 DB 작업들 1
  ...
  
  // 분리 선언된 DB 작업. 현재 session을 파라미터로 전달
  subLogic(session);
  
  // 어떤 DB 작업들 2
  ...
  
  await session.commitTransaction();
  await session.endSession();
}

/**
 * 트랜잭션을 적용하지 않은 로직
 */
function logicB() {
  // 어떤 DB 작업들 1
  ...

  // session이 없으므로 전달하지 않음
  subLogic();
  
  // 어떤 DB 작업들 2
  ...
}

/**
 * 어떤 Document를 수정하는 로직
 */
function subLogic(session?: ClientSession) {
  // 파라미터로 받은 session을 DB 명령에 전달
  model.updateOne({}, {}, { session });
}

 

 

쉽게 이해할 수 있는 예시 코드를 작성해봤다.

 

두 함수 logicA()logicB()는 내부 로직에서 subLogic()이라는 다른 함수를 호출하여 DB 명령을 수행한다. subLogic()은 현재 context의 트랜잭션에 합류할 수 있도록 상위 call stack에서 전달한 session을 DB 명령에 포함시킨다.

logicA()subLogic()을 트랜잭션으로 묶어 처리하는 반면, logicB()는 트랜잭션으로 묶지 않았기 때문에 명령이 별개로 적용된다.

 

각 함수간 호출 경로와 depth가 변경될 경우 session을 매번 제어해야하는 문제가 있다. logicA()만이 트랜잭션을 다루고 있지만, 문제 재연을 위해 함수 logicC()를 추가로 정의해보겠다.

 

import { ClientSession } from 'mongodb';

/**
 * 트랜잭션을 적용한 로직
 */
function logicA() {
  const session = await connection.startSession();
  session.startTransaction();
  
  // 어떤 DB 작업들 1
  ...
  
  // 분리 선언된 DB 작업. 현재 session을 파라미터로 전달
  subLogic(session);
  
  // 어떤 DB 작업들 2
  ...
  
  await session.commitTransaction();
  await session.endSession();
}

function logicB() {
  ...
}

/**
 * 트랜잭션을 적용한 로직
 */
function logicC() {
  const session = await connection.startSession();
  session.startTransaction();
  
  // 어떤 DB 작업들 1
  ...
  
  // 분리 선언된 DB 작업. 현재 session을 파라미터로 전달
  subLogic(session);
  
  // 어떤 DB 작업들 2
  ...
  
  await session.commitTransaction();
  await session.endSession();
}

/**
 * 어떤 Document를 수정하는 로직
 */
function subLogic(session?: ClientSession) {
  // 파라미터로 받은 session을 DB 명령에 전달
  model.updateOne({}, {}, { session });
}

 

logicA()의 코드를 그대로 복사해서 logicC()를 만들었다. logicC() 역시 트랜잭션이 적용되어 있다.

 

logicA()와 logicC()의 call stack

그런데 요구사항이 변경되어 logicA()logicC()를 호출해야하는 상황이 발생했다고 가정해보자.

 

수정된 logicA()의 call stack

logicA()는 session을 시작하고 logicC()에 전달하고 최종적으로 subLogic()에까지 session이 전달되는 형태가 될 것이다.

 

import { ClientSession } from 'mongodb';

/**
 * 트랜잭션을 적용한 로직
 */
function logicA() {
  const session = await connection.startSession();
  session.startTransaction();
  
  // 어떤 DB 작업들 1
  ...
  
  // logicC() 호출. 현재 session을 파라미터로 전달
  logicC(session);
  
  // 어떤 DB 작업들 2
  ...
  
  await session.commitTransaction();
  await session.endSession();
}

function logicB() {
  ...
}

/**
 * 트랜잭션을 적용한 로직
 */
function logicC(currentSession?: ClientSession) {
  const session = currentSession ?? await connection.startSession();
  session.startTransaction();
  
  // 어떤 DB 작업들 1
  ...
  
  // 분리 선언된 DB 작업. 현재 session을 파라미터로 전달
  subLogic(session);
  
  // 어떤 DB 작업들 2
  ...
  
  await session.commitTransaction();
  await session.endSession();
}

/**
 * 어떤 Document를 수정하는 로직
 */
function subLogic(session?: ClientSession) {
  // 파라미터로 받은 session을 DB 명령에 전달
  model.updateOne({}, {}, { session });
}

 

logicA()는 session을 할당하고 트랜잭션을 시작한다. 그리고 logicC()를 호출할 때 트랜잭션에 합류시키기 위해 session을 전달한다. 그러나 이 코드는 의도한대로 동작하지 않는다.

 

    MongoTransactionError: Transaction already in progress
        at ClientSession.startTransaction (/Users/Documents/GitHub/typescript-starter/node_modules/mongodb/src/sessions.ts:399:13)
        at AppService.logicC (/Users/Documents/GitHub/typescript-starter/src/app.service.ts:46:15)
        at processTicksAndRejections (node:internal/process/task_queues:95:5)
        at Object.<anonymous> (/Users/Documents/GitHub/typescript-starter/src/app.service.spec.ts:55:5) {
      errorLabelSet: Set(0) {}
    }

 

  1. session 객체가 트랜잭션을 이미 시작한 경우 startTransaction()을 재호출 하면 오류가 발생한다.
  2. 최상위 로직(logicA)에서만 commit 해야하는데 중간 로직(logicC)에서 commit을 해버린다.

1번 케이스는 session.inTransaction() bool 값을 이용해 트랜잭션이 시작되지 않은 경우에만 session.startTransaction()를 실행하도록 하는 조건문을 추가하면 방어할 수 있다.

2번 케이스는 최상위 로직이 아닌 경우 commit을 하지 않도록하는 별도의 플래그와 분기 처리가 필요하기 때문에 심각한 소스코드 오염을 발생시킨다.

 

code smell을 맡아버린 개발자

 

간단한 서비스를 만드는데에도 로직을 여러개의 작은 단위로 쪼개서 개발하기 때문에 session.startTransaction()을 사용한 방식은 엄청나게 비효율적인 방식임을 보여준다.

 

session.withTransaction() 기반으로 바꾸면 더 깔끔하게 개선할 수 있다.

 

import { ClientSession } from 'mongodb';

function logicA(session?: ClientSession) {
  // 어떤 DB 작업들 1
  ...
  
  // logicC() 호출. 현재 session을 파라미터로 전달
  logicC(session);
  
  // 어떤 DB 작업들 2
  ...
}

function logicB() {
  ...
}

function logicC(session?: ClientSession) {
  // 어떤 DB 작업들 1
  ...
  
  // 분리 선언된 DB 작업. 현재 session을 파라미터로 전달
  subLogic(session);
  
  // 어떤 DB 작업들 2
  ...
}

function subLogic(session?: ClientSession) {
  // 파라미터로 받은 session을 DB 명령에 전달
  model.updateOne({}, {}, { session });
}


// 로직 실행할 때
const session = await mongoose.startSession();

await session.withTransaction(async () => {
  logicA(session);
})

 

모든 함수가 session을 파라미터로 받고 트랜잭션에 대한 제어는 함수를 호출하는 쪽에서만 session.withTransaction()로 감싸서 사용하도록 하면 앞서 말한 1, 2 케이스 모두 해결할 수 있다.

하지만 여전히 session과 트랜잭션을 명시적으로 선언해야한다는 문제는 해결되지 않는다.

mongoose 기본 트랜잭션의 문제점 - session을 알고싶지 않아

function subLogic(session?: ClientSession) {
  // 파라미터로 받은 session을 DB 명령에 전달
  model.updateOne({}, {}, { session });
}

 

위에서 정의한 함수들은 자신의 작업을 트랜잭션에 포함시키기 위해 파라미터로 session을 전달받고 그대로 DB에 전달한다. 여기서도 문제점을 발견할 수 있다.

 

함수가 늘어날때마다 session을 받고 전달하는 작업을 매번 작성해야한다.

함수의 본래 역할은 그 함수만의 고유한 작업을 처리하는 것이지 session을 전달하는 일이 아니다.

 

즉, session이라는 것에 발목이 묶여 함수 선언과 내부 로직에 영향을 주고 있는 것이 문제다.

AOP로 간단하게 문제 해결하기

관점 지향 프로그래밍 (AOP)

앞서 말한 문제 상황들을 간단하게 정리하면 비즈니스 로직과 트랜잭션 관련 코드가 완벽하게 분리되지 않아서 생기는 문제라고 할 수 있다.

 

class Service {

  @Transactional()
  logicA() {
    logicC();
  }
  
  logicC() {
    subLogic();
  }
  
  subLogic() {
    model.updateOne({}, {});
  }
}

 

AOP를 적용하면 "로직과 트랜잭션을 완벽하게 분리"하고 "로직이 session을 알 필요가 없도록" 구현할 수 있다. 다음 단계에서는 관심사를 분리하는 과정을 알아볼 것이다.


@nest-cls/transactional 패키지로 @Transactional() 사용하기

다행스럽게도 NestJS + mongoose 환경에서 @Transactional()을 지원하는 패키지가 있다.

AsyncLocalStorage 기반의 비동기 context 관리 모듈인 ClsModule과 mongoose hook을 사용해 비즈니스 로직에서 트랜잭션 제어와 session을 분리할 수 있는데 몇 가지 설정이 필요하다.

 

(구글링을 해보면 비슷한 아이디어로 @Transactional()을 직접 구현해보려는 몇몇 좋은 시도들을 구경할 수 있다. 하지만 트랜잭션 전파와 격리 수준까지 설정 가능한 완성도 있는 사례를 발견하지 못했기 때문에 이번 글을 쓰게 되었다.)

1. ClsModule과 Plugin 설정

npm install nestjs-cls
npm install @nestjs-cls/transactional

 

우선 위 패키지 목록을 설치한다.

 

그리고 공식 문서에서는 @nestjs-cls/transactional-adapter-mongoose 도 같이 설치하라고 되어있는데, 코드를 수정해서 써야하므로 그냥 github에서 소스파일을 하나 복사해오자. TransactionalAdapterMongoose 클래스가 필요하다.

 

import { TransactionalAdapter } from '@nestjs-cls/transactional';
import { ClientSession, Connection } from 'mongoose';

type MongooseTransactionOptions = Parameters<Connection['transaction']>[1];

export interface MongoDBTransactionalAdapterOptions {
  /**
   * The injection token for the mongoose Connection instance.
   */
  mongooseConnectionToken: any;

  /**
   * Default options for the transaction. These will be merged with any transaction-specific options
   * passed to the `@Transactional` decorator or the `TransactionHost#withTransaction` method.
   */
  defaultTxOptions?: Partial<MongooseTransactionOptions>;
}

export class TransactionalAdapterMongoose
  implements
    TransactionalAdapter<
      Connection,
      ClientSession | null,
      MongooseTransactionOptions
    >
{
  connectionToken: any;

  defaultTxOptions?: Partial<MongooseTransactionOptions>;

  constructor(options: MongoDBTransactionalAdapterOptions) {
    this.connectionToken = options.mongooseConnectionToken;
    this.defaultTxOptions = options.defaultTxOptions;
  }

  supportsTransactionProxy = false;

  optionsFactory(connection: Connection) {
    return {
      wrapWithTransaction: async (
        options: MongooseTransactionOptions,
        fn: (...args: any[]) => Promise<any>,
        setTx: (tx?: ClientSession) => void,
      ) => {
        return connection.transaction((session) => {
          setTx(session);
          return fn();
        }, options);
      },
      getFallbackInstance: () => null,
    };
  }
}

 

그리고 ClsModule을 초기화를 진행한다.

 

import { Module } from '@nestjs/common';
import { getConnectionToken, MongooseModule } from '@nestjs/mongoose';
import { ClsModule } from 'nestjs-cls';
import { ClsPluginTransactional } from '@nestjs-cls/transactional';
import { TransactionalAdapterMongoose } from './mongoose.transactional.adapter';

@Module({
  imports: [
    // mongoose 모듈 설정
    MongooseModule.forRoot({
       ... // mongoose 설정
    }),
    // CLS 모듈과 플러그인 설정
    ClsModule.forRoot({
      global: true,
      plugins: [
        new ClsPluginTransactional({
          imports: [MongooseModule],
          adapter: new TransactionalAdapterMongoose({
            mongooseConnectionToken: getConnectionToken(),
          }),
        }),
      ],
    }),
  ],
})
export class AppModule {}

 

앞서 가지고 온 TransactionalAdapterMongoose를 Plugin으로 등록한다.

2. TransactionalAdapterMongoose 수정

// Key 선언
export const TRANSACTION_CONTEXT_KEY = 'tx';

 

CLS에 저장할 트랜잭션 session을 식별할 KEY를 선언한다.

 

import { TransactionalAdapter } from '@nestjs-cls/transactional';
import { ClientSession, Connection } from 'mongoose';
import { ClsServiceManager } from 'nestjs-cls';
import { TRANSACTION_CONTEXT_KEY } from './transaction.constants';

type MongooseTransactionOptions = Parameters<Connection['transaction']>[1];

export interface MongoDBTransactionalAdapterOptions {
  mongooseConnectionToken: any;
  defaultTxOptions?: Partial<MongooseTransactionOptions>;
}

export class TransactionalAdapterMongoose
  implements
    TransactionalAdapter<
      Connection,
      ClientSession | null,
      MongooseTransactionOptions
    >
{
  connectionToken: any;
  defaultTxOptions?: Partial<MongooseTransactionOptions>;

  constructor(options: MongoDBTransactionalAdapterOptions) {
    this.connectionToken = options.mongooseConnectionToken;
    this.defaultTxOptions = options.defaultTxOptions;
  }

  supportsTransactionProxy = false;

  optionsFactory(connection: Connection) {
    return {
      wrapWithTransaction: async (
        options: MongooseTransactionOptions,
        fn: (...args: any[]) => Promise<any>,
        setTx: (tx?: ClientSession) => void,
      ) => {
        // CLS 저장소 획득
        const cls = ClsServiceManager.getClsService();
        // store context를 유지하기 위해 run()으로 래핑
        return cls.run(() => {
          return connection.transaction((session) => {
            setTx(session);
            // 현재 session을 store에 저장
            cls.set(TRANSACTION_CONTEXT_KEY, session);
            return fn();
          }, options);
        });
      },
      getFallbackInstance: () => null,
    };
  }
}

 

트랜잭션을 실행할 때 session을 CLS에 저장하도록 TransactionalAdapterMongoose의 내용을 수정한다. 이 설정들은 @Transactional()이 붙은 함수의 실행 context에서 동일한 session 객체를 유지하도록 한다.

3. mongoose pre hook 설정

이제 context에서 유지된 session을 mongoose에 자동으로 넣어주는 pre hook 플러그인을 작성해야한다. 먼저 플러그인 기능을 담당할 MongooseHelperModuleMongooseHelperService를 만들자.

 

import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { ClientSession, Schema } from 'mongoose';
import { TRANSACTION_CONTEXT_KEY } from './transaction.constants';

@Injectable()
export class MongooseHelperService {
  constructor(private readonly clsService: ClsService) {}

  /**
   * mongoose schema에 transaction을 적용하기 위한 pre hook을 등록 합니다.
   * 현재 context에서 mongoose session이 열려있으면 DB 명령을 session에 포함 시킵니다.
   */
  addSessionPlugin(schema: Schema, options: any) {
    // 현재 async context에서 mongoose session을 가져 옵니다.
    const getContextSession = () =>
      this.clsService.get<ClientSession>(TRANSACTION_CONTEXT_KEY);

    schema.pre(
      [
        'find',
        'findOne',
        'findOneAndUpdate',
        'findOneAndReplace',
        'findOneAndDelete',
      ],
      function (next) {
        const session = getContextSession();
        if (session) {
          this.session(session);
        }
        next();
      },
    );

    schema.pre(
      ['updateOne', 'updateMany', 'deleteOne', 'deleteMany'],
      function (next) {
        const session = getContextSession();
        if (session) {
          this.session(session);
        }
        next();
      },
    );

    schema.pre('save', function (next) {
      const session = getContextSession();
      if (session) {
        this.$session(session);
      }
      next();
    });

    schema.pre('insertMany', function (next, docs) {
      const session = getContextSession();
      if (session) {
        for (const doc of docs) {
          doc.$session?.(session);
        }
      }
      next();
    });
  }
}

 

schema.pre는 mongoose로 DB 명령이 실행되기 이전에 트리거되는 hook이다. mongoose의 write 명령이 실행되기 전에 현재 context에 존재하는 session을 찾아 자동으로 등록하도록 구현해주자.

 

필요한 경우 위에서 hook을 설정한 명령을 일부 제거하거나 목록을 추가해서 사용해도 좋다. (replaceOne 등)

 

import { Module } from '@nestjs/common';
import { MongooseHelperService } from './mongoose.helper.service';

@Module({
  providers: [MongooseHelperService],
  exports: [MongooseHelperService],
})
export class MongooseHelperModule {}

 

모듈까지 만들었다면 이제 MongooseModule이 MongooseHelperModule을 필요로 하도록 수정해야한다.

 

import { Module } from '@nestjs/common';
import { getConnectionToken, MongooseModule } from '@nestjs/mongoose';
import { ClsModule } from 'nestjs-cls';
import { ClsPluginTransactional } from '@nestjs-cls/transactional';
import { TransactionalAdapterMongoose } from './mongoose.transactional.adapter';
import { MongooseHelperService } from './mongoose.helper.service';
import { MongooseHelperModule } from './mongoose.helper.module';

@Module({
  imports: [
    ClsModule.forRoot({
      global: true,
      plugins: [
        new ClsPluginTransactional({
          imports: [MongooseModule],
          adapter: new TransactionalAdapterMongoose({
            mongooseConnectionToken: getConnectionToken(),
          }),
        }),
      ],
    }),
    // Helper 모듈 등록
    MongooseHelperModule,
    // Helper 모듈을 주입하도록 기존 초기화 방식 변경
    MongooseModule.forRootAsync({
      imports: [MongooseHelperModule],
      inject: [MongooseHelperService],
      useFactory: (mongooseHelperService: MongooseHelperService) => {
        return {
          ... // mongoose 설정
          connectionFactory: (connection: Connection) => {
            // 현재 mongoose connection에게 플러그인 등록
            connection.plugin(
              mongooseHelperService.addSessionPlugin.bind(
                mongooseHelperService,
              ),
            );
            return connection;
          },
        };
      },
    }),
  ],
})
export class AppModule {}

 

MongooseModule이 초기화 될 때 플러그인을 등록하도록 기존 설정을 변경한다.

 

이제부터 @Transactional()이 붙어있는 함수의 context에서 실행되는 모든 mongoose 명령에 자동으로 해당 context의 session이 적용된다!


동작 원리

사용 방법

class Service {

  @Transactional()
  logicA() {
    logicC();
  }
  
  logicC() {
    subLogic();
  }
  
  subLogic() {
    model.updateOne({}, {});
  }
}

 

트랜잭션 단위로 처리해야하는 함수에 @Transactional()을 꾸며주기만 하면 된다. logicA()logicC() 모두 단독으로 실행 가능하며, logicA()을 실행할 경우 트랜잭션이 설정되어있기 때문에 subLogic()까지 트랜잭션에 포함시키지만, logicC()을 실행할 경우 트랜잭션 설정이 되어있지 않아 subLogic()은 트랜잭션과 무관하게 동작한다.

트랜잭션 전파와 격리 수준 (Propagtion and Isolation)

@nestjs-cls/transactional 패키지의 트랜잭션 전파 옵션

위에서 들었던 예시는 트랜잭션이 하위 call stack으로 전파되는 예시를 들어보았다. 기본 전파 설정은 Propagation.Required 이므로 트랜잭션이 없으면 새로 생성하고 트랜잭션이 있으면 재사용한다.

 

이 외에도 Spring 표준처럼 6가지의 전파 설정을 제공하고 있다. (그러나 직접 확인해보니 NotSupported는 제대로 동작하지 않는 듯...)

 

@nestjs-cls/transactional 패키지의 트랜잭션 격리 수준

그리고 MongoDB의 readConcern, writeConcern 옵션으로 트랜잭션 격리 수준을 설정할 수도 있다.

Write Conflict를 내부적으로 방지

MongoDB 트랜잭션의 기초를 공부해봤다면 @Transactional()을 사용하면서 "Write Conflict 오류를 어떻게 대처하지?" 같은 물음이 떠오를 것이다.

Write Conflict 시나리오 (출처: 올리브영 테크 블로그, https://oliveyoung.tech/2024-12-17/catalog-mongo-transaction-2/)

 

MongoDB는 여러 트랜잭션이 같은 리소스를 동시에 수정하면 RDB처럼 작업이 순서대로 실행되지 않고 쓰기 충돌(Write Conflict) 오류를 발생시킨다. 그러나 개발자들이 @Transactional()에 기대하는 일반적인 동작은 RDB에서 사용할 때 처럼 쓰기 충돌 오류를 발생시키지 않고 작업을 반영하는 것이다.

 

이번에 사용한 @nestjs-cls/transactional 패키지는 @Transactional() 내부에서 Write Conflict이 감지되면 재성공 할 때까지 트랜잭션을 재시도하도록 설계(낙관적 락)되어 있다. Write Conflict을 락으로 역이용하여 마치 Redis에서 스핀락(Spin lock)을 걸듯이 구현한 것이다.

 

이는 RDB 진영의 @Transactional()과 동일한 사용 경험을 제공하려는 의도로 해석된다. 공식 문서에서는 패키지가 제공하는 Adapter 중 원하는 DB가 없거나 동작이 마음에 들지 않은 경우 직접 Custom Adapter를 작성할 수 있도록 안내하고 있다.


참조

 

Introduction | NestJS CLS

A continuation-local\* storage module compatible with NestJS' dependency injection based on Node's AsyncLocalStorage.

papooch.github.io

 

MongoDB Transaction Management - RastaLion.dev

MongoDB의 트랜잭션MongoDB 4.0이 릴리즈 되면서 Replica Set에서 작동하는 다중 도큐먼트 트랜잭션에 대한 지원을 추가되었습니다. 또, MongoDB 4.2의 릴리스와 함께 다중 도큐먼트 트랜잭션

rastalion.dev

 

MongoDB Multi-document transactional Decorator with Nestjs

MongoDB Multi-document transaction을 Nestjs에서 선언적인 Decorator로 만들고 적용하는 방법을 알아봅니다.

spongelog.netlify.app

 

Spring Boot MongoDB 트랜잭션 도입 실전 가이드 | 올리브영 테크블로그

근데 이제 Replica Set 을 곁들인

oliveyoung.tech

 

Chapter 9. Transaction management

It should now be clear how different transaction managers are created, and how they are linked to related resources which need to be synchronized to transactions (i.e. DataSourceTransactionManager to a JDBC DataSource, HibernateTransactionManager to a Hibe

docs.spring.io

 

AsyncLocalStorage를 이용해 Transaction 관심사 분리하기 (lines 40% 감소)

NestJS에서 데코레이터와 AsycnLocalStorage를 활용하여 Service레벨에 트랜잭션 적용하기

velog.io

 

 

반응형