본문 바로가기

nestjs

[nestjs] transaction 라이브러리 분석하기 - 1

이 시리즈는 typeorm-transactional 라이브러리를 분석하게 된 계기와 분석한 내용이 담겨있습니다.

 

이전 글에서는 typeorm에서 트랜잭션을 관리하는 방법을 간단히 설명하였습니다.

그리고 typeorm에서 트랜잭션을 관리할 때 단점으로 중복이 많이 발생함을 지적하였습니다.

마지막으로 그에 대한 해결책 설계와 구현 실패, 그리고 typeorm-transactional 라이브러리를 소개했습니다.

 

이번 글에서는 중복을 제거하겠다는 우리의 목적을 구체화하고, 이 목적을 달성할 수 있는 방법에 대해 알아보겠습니다.


typeorm에서 트랜잭션을 관리할 때의 단점으로 2가지를 제시했었습니다.

  • 비지니스 로직을 위한 코드가 아닌, 트랜잭션을 위한 코드가 추가됩니다. 그리고 여러 군데에 걸쳐서 중복이 발생하게 됩니다.
  • Repository 객체를 사용하려면 현재 트랜잭션에서 사용하는 entityManager 혹은 queryRunner를 통하여 새로 생성해야만 합니다. 이러한 작업으로 코드가 길어지게 됩니다.

typeorm-transactional 을 통해 이러한 문제를 해결할 수 있습니다.

 우리는 다음과 같이 가독성 좋은 코드를 작성할 수 있습니다.

export class UserService {
  constructor(readonly repository: UserRepository)

  @Transactional()
  async addUser() {
    const user = this.repository.create()
    user.name = "hunjin"
    this.repository.save(user)
  }
}

 

이러한 목적을 달성하기 위한 핵심적인 키워드는 2가지인데, wrapper와 cls 입니다.

WRAPPER

이전 글에서 나왔던 얘기입니다. '트랜잭션 관리를 위한 코드'는 wrapper가 수행하고, UserService는 비지니스 로직만 수행하면 됩니다.

(비지니스 로직이 시작되기 전과 종료된 후에 동작하는) 비지니스 로직 이외의 로직들을 아래처럼 작성합니다.

(아래는 실제 typeorm-transactional의 코드는 아닙니다)

('이런 식으로 wrapper 패턴을 써서 중복을 제거할 수 있겠구나' 정도로 이해하시면 좋겠습니다)

 

@Injectable()
export class CustomTransactional implements NestInterceptor {
  constructor(private connection: Connection) {}
  // 컨트롤러 실행 전, 실행 후에 할 작업들을 정의합니다
  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    const ctx = context.switchToHttp();
    const req = ctx.getRequest();
    const queryRunner = this.connection.createQueryRunner();
    // 트랜잭션 관리를 위한 코드 작성
    await queryRunner.startTransaction();
    req.manager = queryRunner.manager;
    return next.handle().pipe(
      // controller에서 에러가 발생하면 여기로 옵니다.
      catchError(async (err) => {
        await queryRunner.rollbackTransaction();
        await queryRunner.release();
        if (err instanceof HttpException) {
          throw new HttpException(err.getResponse(), err.getStatus());
        } else {
          throw new InternalServerErrorException(SEVER_ERROR);
        }
      }),
      // controller에서 에러 없이 작업이 끝나면 여기로 옵니다.
      tap(async () => {
        await queryRunner.commitTransaction();
        await queryRunner.release();
      }),
    );
  }
}

 

이렇게 만든 Interceptor는 controller 계층에서 사용하기로 합시다.

nestjs Interceptor

왜냐하면 (위 그림에서 알 수 있듯) nestjs에서 Interceptor는 controller 계층에서 사용하기 위한 목적입니다.

따라서 service 계층에서 사용하는 것은 부적절해 보입니다.

 

export const EntityManager = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.manager;
  },
);

 

Intercepter에서는 httpContext에 entityManager 세팅해줬습니다.

반대로, controller에서는 이 값을 가져다 쓰기 위해 위에 처럼 parameter 데코레이터를 정의해서 사용합니다. ↑

 

그리고 controller에서 manager를 받아와서 userService의 메서드의 인자로 전달해 줍니다. ↓ 

@Injectable()
export class UserController {
    constructor(private readonly userService: UserService) {}
    
    @Post('/')
    @UseInterceptors(CustomTransactinal) // constroller의 메서드에서 인터셉터 등록
    public async testMethod(@EntityManager() manager): Promise<void> {
        await this.userService.addUser(manager); // manager를 전달
    }
}

 

그리고 아래처럼 UserService의 메서드가 manager를 인자로 받아서 사용합니다. ↓

export class UserService {
  constructor() {}

  // controller에서 제공해준 manager를 사용하여 비지니스 로직을 구현
  async addUser(manager: EntityManager) {
    const userRepository = this.manager.getRepository(User);
    const user = userRepository.create();
    user.name = "hunjin";
    userRepository.save(user);
  }
}

 

이렇게 트랜잭션 관리를 위한 코드는 밖으로 위임하여, 서비스 계층에서는 비지니스 로직만 처리하게 되었습니다.

이렇게 wrapper를 통해 2가지 중복 중 하나를 제거할 수 있을 거 같습니다.

 

이제 남은건 서비스 계층에서 repository 객체를 매번 일일이 생성해줘야 하는 중복작업을 제거하는 것입니다.


잠시 TYPEORM 의 주요 객체들의 의존관계에 대해 먼저 설명하겠습니다.

아래는 typeorm의 주요 객체들의 의존관계입니다.

 

typeorm의 주요 객체들의 의존관계

 

사실, 실제 의존관계는 이보다 더 복잡합니다. driver 객체도 나오고 client 객체도 나옵니다. 그리고 양뱡향 연관관계도 나옵니다.

하지만 설명의 편의를 위해 일단은 위 그림의 연관관계만 생각하겠습니다.

 

여기서 QueryRunner는 DB와 연결된 하나의 session이라고 볼 수 있습니다. (하나의 QueryRunner = 하나의 session)

중요한 것은 하나의 트랜잭션은 오직 하나의 session에서 이루어져야 한다는 것입니다.

 

그럼 typeorm에서 하나의 트랜잭션을 잘 관리하려면 어떻게 해야할까요?

아래는 트랜잭션 관리를 위한 원칙 입니다.

하나의 QueryRunner 객체만을 사용해야 합니다.
이 QueryRunner 객체에 의존하는 EntityManager 객체를 사용해야합니다.
이 EntityManager객체에 의존하는 DataSource, Repository객체를 사용해야 합니다.

 

이제 아래 예시코드를 봅시다.

 

export class UserService {
  constructor(
    @InjectDataSource()
    private readonly dataSource: DataSource,
    @InjectRepository(User)
    private readonly userRepository: Repository<User>
  ) {}

  @CustomTransactional()
  async countAndClear(manager: EntityManager) {
    // manager를 사용 안하고 DI 받은 객체들을 사용
    const count = dataSource.query("select count(*) from user");
    userRepository.clear(user);
    const countByClear = dataSource.query("select count(*) from user");
    console.log("user count is changed ${count} to ${countByClear}");
  }
}

 

위 코드는 우리가 지향하는 가독성 좋은 코드입니다. DI 받은 객체들을 그대로 사용하고 있습니다.

편리해 보이지만, 이 코드는 트랜잭션 관리를 위한 원칙을 위반합니다.

 

이유는 dataSource의 메서드가 실행될 때 실질적으로 사용되는 QueryRunner와 

repository의 메서드가 실행될 때 실질적으로 사용되는 QueryRunner가 서로 다르기 때문입니다.

 

(참고: 트랜잭션 관리를 위한 원칙을 위반하는 이유를 자세히 설명하면 아래와 같습니다. 코드가 많으므로 복잡합니다.)

더보기

 

dataSource, repository 두 객체를 각각 보겠습니다. 

DataSource

dataSource.query() 메서드는 내부적으로 새 QueryRunner 객체를 생성하여 기능을 수행합니다.

아래는 TypeOrm의 DataSource.ts 코드 중 일부입니다.

async query<T = any>(
        query: string,
        parameters?: any[],
        queryRunner?: QueryRunner,
    ): Promise<T> {
        if (InstanceChecker.isMongoEntityManager(this.manager))
            throw new TypeORMError(`Queries aren't supported by MongoDB.`)

        if (queryRunner && queryRunner.isReleased)
            throw new QueryRunnerProviderAlreadyReleasedError()

        const usedQueryRunner = queryRunner || this.createQueryRunner() <<<<

        try {
            return await usedQueryRunner.query(query, parameters) // await is needed here because we are using finally
        } finally {
            if (!queryRunner) await usedQueryRunner.release()
        }
    }

인자로 queryRunner를 주지 않으면, 내부적으로 새로운 queryRunner를 생성하여 사용합니다.


Repository

먼저, DI 받는 repository는 다음과 같은 과정으로 생성됩니다. (이 과정은 주제와 밀접한 관련은 없습니다. 가볍게 보셔도 됩니다.)

아래는 nestjs에서 typeorm 모듈을 생성할 때 사용하는 forFeature 의 정의입니다. 
여기서 인자로 entity(ex. User)가 들어옵니다.

static forFeature(
    entities: EntityClassOrSchema[] = [],
    dataSource:
      | DataSource
      | DataSourceOptions
      | string = DEFAULT_DATA_SOURCE_NAME,
  ): DynamicModule {
    const providers = createTypeOrmProviders(entities, dataSource); <<<<
    EntitiesMetadataStorage.addEntitiesByDataSource(dataSource, [...entities]);
    return {
      module: TypeOrmModule,
      providers: providers,
      exports: providers,
    };
  }

 

아래는 createTypeOrmProviders 함수의 정의입니다.
entity(ex.User)에 대한 repository 객체(ex.Repository<User>)를 생성합니다.

export function createTypeOrmProviders(
  entities?: EntityClassOrSchema[],
  dataSource?: DataSource | DataSourceOptions | string,
): Provider[] {
  return (entities || []).map((entity) => ({
    provide: getRepositoryToken(entity, dataSource),
    useFactory: (dataSource: DataSource) => {
      const enitityMetadata = dataSource.entityMetadatas.find((meta) => meta.target === entity)
      const isTreeEntity = typeof enitityMetadata?.treeType !== 'undefined'
      return isTreeEntity 
        ? dataSource.getTreeRepository(entity)
        : dataSource.options.type === 'mongodb'
          ? dataSource.getMongoRepository(entity)
          : dataSource.getRepository(entity); <<<<
    },
    inject: [getDataSourceToken(dataSource)],
    /**
     * Extra property to workaround dynamic modules serialisation issue
     * that occurs when "TypeOrm#forFeature()" method is called with the same number
     * of arguments and all entities share the same class names.
     */
    targetEntitySchema: getMetadataArgsStorage().tables.find(
      (item) => item.target === entity,
    ),
  }));
}

 

아래는 DataSource의 getRepository 메서드 정의입니다.

getRepository<Entity extends ObjectLiteral>(
        target: EntityTarget<Entity>,
    ): Repository<Entity> {
        return this.manager.getRepository(target) <<<<
    }

 

아래는 EntityManager의 getRepository 메서드 정의입니다.

getRepository<Entity extends ObjectLiteral>(
        target: EntityTarget<Entity>,
    ): Repository<Entity> {
        // find already created repository instance and return it if found
        const repoFromMap = this.repositories.get(target)
        if (repoFromMap) return repoFromMap

        // if repository was not found then create it, store its instance and return it
        if (this.connection.driver.options.type === "mongodb") {
            const newRepository = new MongoRepository(
                target,
                this,
                this.queryRunner,
            )
            this.repositories.set(target, newRepository)
            return newRepository
        } else {
            const newRepository = new Repository<any>( 
                target,
                this,
                this.queryRunner,
            ) <<<<
            this.repositories.set(target, newRepository)
            return newRepository
        }
    }

 

아래는 Repository의 constructor의 정의입니다.

constructor(
        target: EntityTarget<Entity>,
        manager: EntityManager,
        queryRunner?: QueryRunner,
    ) {
        this.target = target
        this.manager = manager
        this.queryRunner = queryRunner
    }

위 과정에서 dataSource.manager 가 repository의 생성자에 인자로 들어가게 되고 
결국, dataSource.manager == repository.manager 라는 사실을 알 수 있습니다.

Repository<User>은 위와 같은 과정으로 생성되었습니다.
(그리고 이렇게 생성된 Repository<User>를 UserService가 DI 받는 것입니다.)


이제 다시 돌아와서 UserService의 addUser 메서드에서 repository.clear() 호출하는 부분을 보겠습니다.

repository.clear() 메서드의 정의는 아래와 같습니다.
단순히 멤버변수인 manager의 clear 메서드를 호출합니다.

clear(): Promise<void> {
        return this.manager.clear(this.metadata.target)
    }

 

manager.clear() 메서드의 정의는 아래와 같습니다.

async clear<Entity>(entityClass: EntityTarget<Entity>): Promise<void> {
        const metadata = this.connection.getMetadata(entityClass)
        const queryRunner =
            this.queryRunner || this.connection.createQueryRunner() <<<<
        try {
            return await queryRunner.clearTable(metadata.tablePath) // await is needed here because we are using finally
        } finally {
            if (!this.queryRunner) await queryRunner.release()
        }
    }

 

여기서 manager.queryRunner가 null이면 내부적으로 새 queryRunner를 생성해서 사용합니다.
그런데, repository.manager.queryRunner는 거의 항상 null 입니다.
그러니까 거의 항상 새 queryRunner를 생성해서 사용한다는 것입니다.

 

(조금 부연 설명하자면..)

dataSource는 멤버변수 manager를 갖습니다.
그리고, manager은 멤버변수 private queryRunner? : QueryRunner를 갖습니다.
?의 의미는 manager.queryRunner 값이 null일 수도 있고, 실제 객체일 수도 있다는 것입니다.

코드를 분석하다가 안 사실은 dataSource의 멤버변수 manager의 멤버변수 queryRunner는 항상 null이라는 겁니다.
(= dataSource.manager.queryRunner는 항상 null)

(모든 EntityManager.queryRunner가 항상 null 이라는 얘기는 아닙니다)

위에 한 번 보았듯이, dataSource.manager 객체와 repository.manager는 동일합니다.

따라서 repository.manager.queryRunner 값은 항상 null 이겠구나 라고 판단 내릴 수 있습니다.

 

얘기가 길었습니다. 정리하면 다음과 같습니다.

  • dataSource.query()는 내부에서 새로 생성된 queryRunner1을 사용합니다.
  • 그리고 repository.clear()는 entityManager에 의존하는데,
    이 entityManager은 내부적으로 새로 생성된 queryRunner2를 사용합니다.
     

이제 dataSource의 메서드가 실행될 때 실질적으로 사용되는 QueryRunner
repository의 메서드가 실행될 때 실질적으로 사용되는 QueryRunner가 서로 다르다 라는 말이 이해가 되실 거 같습니다.

따라서 서로 다른 queryRunner가 사용되는 상황입니다.
트랜잭션 관리를 위한 원칙이 지켜지지 않는 상황입니다.

 

 

결론은,

DI 받은 DataSource와 Repository를 그대로 사용하고 싶지만, 그렇게 하면 트랜잭션 관리를 위한 원칙을 지키지 못한다 입니다.

(위 내용에서 이 결론 부분만 기억하셔도 됩니다. 결론 이외의 내용은 그렇구나 하고 넘기셔도 됩니다)

 

목적(= DI 받은 객체를 사용하고 싶다)을 지키면서, 트랜잭션 관리를 위한 원칙도 지키고 싶은데 어떻게 해야 할까요?

일단은 다음의 배경 지식이 필요합니다.

 


CLS

cls-hooked cls 라이브러리에 hook 기능을 추가한 것입니다.

일단 cls 라이브러리에 대해 설명하겠습니다.

 

cls(continious local stroage)는 전역객체도 아니고 지역객체도 아닌 그 사이의 것이라고 볼 수 있습니다.

전역객체처럼 어디서든 접근할 수 있는데, 객체에 들어있는 값은 실행의 context에 따라 다른 것이 특징입니다.

 

추상적인 얘기이니, 이러한 cls의 특징을 예제를 통해 이해해봅시다.

 

const cls = require('cls-hooked');
const ns = cls.createNamespace('sample');

// 실행 context 1
ns.runAndReturn(async () => {
    ns.set('key1', 'val1');
    console.log(ns.active); // { _ns_name: 'sample', id: 22, key1: 'val1' }
})
// 실행 context 2
ns.runAndReturn(async () => {
    ns.set('key2', 'val2');
    console.log(ns.active); // { _ns_name: 'sample', id: 22, key2: 'val2' }
})

 

위 코드에서 context1, context2는 동일하게 (전역객체로 보이는) ns에 접근하고 있습니다. 

ns에는 active라는 멤버변수(객체)가 존재합니다. ns.set 메서드를 호출하면 이 active 멤버변수에 값을 세팅합니다.

 

console.log(ns.active)를 해서 결과를 봅시다. 컨텍스트별로 ns에서 읽어들인 값이 다릅니다.

확실히 전역객체는 아닌 것 같은 결과가 나왔습니다.

 

정리하면, ns(=namespace)는 어디서든 접근할 수 있습니다. 하지만 접근하는 context가 어디냐에 따라 읽어들이는 값이 다릅니다.

 

cls의 또 다른 특징을 예제를 통해 살펴보겠습니다.

const cls = require('cls-hooked');
const ns = cls.createNamespace('sample');

// context 1
ns.runAndReturn(async () => {
    ns.set("key1", "val1");
    console.log(ns.active); // { _ns_name: 'sample', id: 10, key1: 'val1' }
    // context 2
    ns.runAndReturn(async () => {
        ns.set("key2", "val2");
        console.log(ns.active); // { _ns_name: 'sample', id: 10, key2: 'val2' }
        console.log(ns.get("key1")); // val1
    });
})

 

(이전 예제에서는 context1과 context2를 독립적으로 생성하였습니다)

이번에는 context1 안에서 context2를 생성하였습니다.

 

console.log(ns.active) 결과를 보면 context 마다 독립적인 값이 세팅되어 있는 것을 볼 수 있습니다.

하지만, console.log(ns.get("key1")) 결과로 val1이 나오는 부분은 좀 이상합니다.

분명 context2의 active에는 "key1"이라는 속성이 없으니까요. 이 val1은 어디서 어떻게 들고온 것일까요?

 

이는 context1 안에서 다른 context2를 생성할 때, context1의 active에 있는 속성 값들이 context2에게 복사되기 때문입니다.

그 복사(=얕은 복사)된 속성 값들은 context2의 active에는 들어가 있지는 않지만, 분명 context2 어딘가에 들어가 있습니다.

그래서 context2에서 ns.get("key1")을 했을 때 값을 잘 찾아 올 수 있는 것입니다.

(왜 이름이 continious local storage 인지 이해가 갑니다)

 

정리하면, context1(부모)에서 context2(자식)를 생성하면 context1(부모)의 속성 값들이 context2(자식)에 그대로 복사됩니다.

이때 복사는 얕은복사입니다.


위는 cls에 대한 기본 사용 방법이었습니다. typeorm-transactional에서 cls를 어떻게 사용하는지 조금은 복잡한 예시를 보겠습니다.

아래는 typeorm-transactional의 핵심적인 부분을 단순화 시켜놓은 코드 예시입니다.

 

const cls = require('cls-hooked');

const ns = cls.createNamespace('sample');
const nameInContext = "instanceOfB";

function getBInContext() {
    return ns.get(nameInContext);
}
function setBInContext(b) {
    ns.set(nameInContext, b);
}

// 문자열을 가지고 있고, 메서드 호출시 단순히 문자열을 출력
class B {
    constructor (str) {
        this.str = str;
    }
    printStr() {
        console.log(this.str);
    }
}

// B 객체를 멤버변수로 가지고 있고, 메서드 호출시 B의 메서드를 호출
class A {
    constructor (b) {
        this.b = b;
    }
    method() {
        this.b.printStr();
    }
}

Object.defineProperty(A.prototype, 'b', {
    get() {
        return getBInContext();
    },
    set(b) {
        setBInContext(b);
    },
    enumerable: true, // 이 속성을 열거 가능하게 설정 (for...in 루프 등에 포함될 수 있음)
    configurable: true, // 속성 설명자를 나중에 수정할 수 있음
})

ns.run(() => {
    const a = new A(new B("not in context"));

    setBInContext(new B("in context"));

    a.method(); //in context
    a.b.printStr(); //in context

})

 

먼저 cls 관련 부분을 보겠습니다.

  • ns(= 전역객체처럼 접근할 수 있는 객체)를 정의했습니다.
  • ns에 B 객체를 get, set 할 수 있는 간단한 함수를 정의했습니다.

다음으로 클래스 정의를 보겠습니다.

  • A는 멤버변수로 B를 가지고 있고, A의 메서드는 B의 메서드를 호출합니다.
  • 즉, A가 B에 의존하는 구조입니다.

다음으로 Object.defineProperty() 를 보겠습니다.

 

아래 간단한 예시 코드를 보겠습니다.

const dessert = {
  dessertName: 'chocolate cake',
  price: '30$',
};
 
Object.defineProperty(dessert, 'price', {
  get() {
    return '42$'
  },
  set() {
    console.log("don't change price!");
  },
});

// dessert.price가 '$0' 바뀌는 것이 직관적으로 예상되는 결과 
dessert.price = '$0'; // 결과: don't change price! 문자열이 로그에 찍힘

// desseert.price인 '30$'가 로그에 출력되는게 예상되는 결과
console.log(dessert.price); // 결과: 42$ 문자열이 로그에 찍힘

우리는 위 코드를 봤을 때 예상되는 결과가 있습니다. 동작이 미리 정의(define)되어 있으니 그렇게 동작하는게 당연합니다.

하지만, Object.defineProperty()를 통해 이 동작의 정의를 바꿀 수 있습니다.(기존의 동작과 다르게 동작하도록 바꿀 수 있습니다)

 

이렇게 멤버변수에 대한 get, set 작업의 동작을 변경할 수 있습니다. 이상하기도 하고 신기하기도 한 기능입니다.

typeorm-transactional에서는 이 기능을 아주 잘 활용했습니다. 이 기능을 활용하는게 핵심이라고 볼 수 있습니다.

마지막으로 실행 결과를 보겠습니다.

Object.defineProperty()를 사용하지 않았다면 실행 결과로 "not in context"가 나왔을 것입니다.

하지만 멤버변수 b에 대한 get, set 동작의 정의를 변경하였으니, 실행 결과로 "in context"가 나왔습니다.

 

위의 코드 예시는 typeorm-transactional 를 분석하고 이해하는 데에 매우 중요합니다.


이제 배경지식까지 다 알게되었습니다.

 

이제 우리는 '중복을 제거 하겠다'는 목적을 좀 더 구체화 하였습니다.

그리고 이러한 목적을 이룰 수단인 wrapper와 cls에 대해 알게 되었습니다.

 

다음 글에서 부터 이러한 배경지식을 이용하여 (계속 얘기해 왔던) 아래의 목적을 달성해봅시다.

트랜잭션을 위한 코드를 wrapper에게 완전히 위임하고, 서비스 계층에선 비지니스 로직에만 집중하고 싶다.

Service 계층에서 dataSource나 repository 객체들을 DI 받아서 사용하고 싶다.
이렇게 DI 받은 객체들이 실질적으로 사용하는 queryRunner는 모두 동일했으면 좋겠다.

 

다음 글에서 계속...