Hits
NestJS 2023. 6. 3. 오전 3:38:00

NestJS Transactional 데코레이터 만들기

"Prisma Transactional 데코레이터를 만들어보자"
nestjsprimsa

순수한 비즈니스 로직

지금 개발중인 공연 예매 서비스 Parp는 DDD와 Hexagonal Architecture를 적용시켜 개발하고 있습니다. MVP모델 치고는 과도한 아키텍쳐라고 볼 수 있겠으나, 애초에 제가 프로젝트 합류한 목적에서 가장 큰 부분이 학문적 탐구와 보다 복잡한 비즈니스로직의 경험에 있었고, 프론트엔드 파트에서의 작업이 더딜 것으로 예상됐기 때문에 충분히 해봄직한 시도라고 판단했습니다. 첫 테스트 이후 요구사항이 어떻게 바뀔지 모르고, 애자일하게 프로젝트를 진행하려 할 수록 초반 설계를 탄탄히 해놓는 편이 후에 더 빠르게 갈 수 있는 방법이라고 생각하기 때문입니다. 이 글에서 DDD나 아키텍쳐에 대해 상세히 서술하지는 않을 생각입니다. 다만 철학에 맞춰 비즈니스로직을 최대한 순수하게 유지하고 각 레이어의 관심사를 명확하게 분리하고 싶었습니다. 어느정도는 목적한대로 진행되었으나, 트랜잭션의 처리과정에서 한계에 부딪혔습니다.

트랜잭션, Prisma

트랜잭션은 데이터의 정합성을 위해 데이터베이스가 수행하는 기능입니다. 하지만 여러 테이블에 대한 트랜잭션이 필요하거나, 비즈니스로직에서의 검증에 따라 결과가 바뀌는 트랜잭션이라면 비즈니스로직에서 트랜잭션의 처리여부를 결정하는 것이 바람직하다고 생각합니다. 하지만 기존에 사용하던 Prisma의 트랜잭션은 외부에서 컨트롤하기가 힘든 구조로 되어있습니다. Bulk Insert 등, 한번에 데이터를 입력하는 것이 아니면, 공식문서에서 설명하는 트랜잭션의 방법은 두가지가 있습니다. 둘 다 prisma client의 $transactionAPI를 사용하는 것인데요,

const [...results] = await prisma.$transaction([...queries]);

이렇게 인자로 수행하려는 쿼리의 배열을 넣어줌으로써 트랜잭션을 실행하는 방법이 있고, 4.7버전에서 공식지원하기 시작한 Interactive Transaction방법이 있습니다. $transactionAPI의 인자로 트랜잭션 컨텍스트를 주입받는 콜백함수를 실행하고 주입받은 컨텍스트로 쿼리를 수행하는 방법입니다. 간단한 사용법은 다음과 같습니다.

const result = await prisma.$transaction(async (tx) => {
    const value = await tx.[SOME_TABLE].find(findOption);
    if (value < 0)
        throw new Error();
    const result = await tx.[SOME_TABLE].update(updateArgs);
    
    return result
});

이렇게 Prisma Client 대신 트랜잭션 컨텍스트를 활용해야 합니다. 별다른 장치 없이는 서비스 레이어에서 Prisma Client를 주입받아 사용해야하는 상황이었습니다.

Transactional

스프링에는 Transactional 어노테이션이 있습니다. 간단하게 레포지토리 인스턴스를 프록시로 만들어 트랜잭션 컨텍스트로 실행시켜주는 녀석이라고 볼 수 있습니다. 어노테이션이 JPA에 종속되어있기 때문에 비즈니스로직을 구현체에 노출시킨다는 의견도 있으나, 그럴 경우 AspectJ 등 다른 AOP도구를 이용해 해결하는 방법이 있습니다. 어찌됐건 둘 다 비즈니스로직에서 트랜잭션의 수행여부를 컨트롤하고, 영속성의 구현에 대해서는 직접 주입받지 않을 수 있습니다.

AOP

관점지향프로그래밍, 복잡한 비즈니스 로직을 추상화하여 도메인 별로 패키지가 분리되어 있는 상태에서 어떤 로직은 도메인을 가리지 않고 영향력을 행사하기 때문에 관리가 까다롭습니다. 주로, 캐싱, 로깅 등이 해당하며 이 글에서 다루고자 하는 데이터베이스의 트랜잭션도 포함됩니다. 관심사를 분리하고 변화를 최소화하기 위해 도메인 별로 서비스를 분리했는데 이러한 기능들은 오히려 더 넓게 흩어져 관리하기가 어려워지는 모순적인 상황에 빠지게 됩니다. 이 때, 해당 기능들을 특정 관심사(Aspect)로 보고 관심사들을 한 곳에 묶어서 관리하는 방법으로 상황을 해결하려는 시도가 바로 Aspect Oriented Programming, 관점 지향 프로그래밍입니다. 자바 진영에서는 어노테이션이라는 문법이 있고, 파이썬과 타입스크립트는 데코레이터라는 문법이 있어 이 문법들을 이용해 구현하는 것이 보편적입니다. 해당 기능이 필요한 로직 앞에 관심사를 표현하는 한 줄을 등록하면, 함수가 실행될 때 해당 관심사를 파악해 로직 실행 전 후에 필요한 작업을 실행시키는 방식입니다.

NestJS의 AOP

NestJS 공식문서에 의하면, AOP를 위해 지원하는 기능으로 Interceptor를 소개합니다. 다만 Interceptor는 Express.js의 미들웨어나 다름없습니다. 클라이언트의 요청이 서버로 들어와 라우팅되어 컨트롤러 계층을 실행하기 전, 실행 후 응답을 보내기 전에 해당 기능을 실행합니다.

이 때의 문제는 그러한 관심사들이 비즈니스 로직에서 선언되어있기 때문에 컨트롤러가 포함된 표현계층은 알 수가 없습니다. 그렇다고 이 로직을 표현 계층으로 꺼내자니 비즈니스 로직이 노출되는 것이나 다름없습니다. 즉, Interceptor만으로는 비즈니스 로직들이 확실하게 응집된 상태에서 관심사만 별도로 분리하는 AOP가 불가능합니다.

Decorator

최근에야 공식적으로 지원하는 Decorator는 javascript가 아니라 typescript가 지원하는 문법입니다. 얼마 전까지만해도 실험적으로 도입된 문법이었기 때문에 reflect-metadata라는 라이브러리를 의존성에 포함시켜야 사용할 수 있었습니다.

javascript의 Proxy, Reflect 객체를 이용해 클래스나 메소드, 함수에 입력하는 인자에 적용할 수 있는데, metadata를 이용해 대상의 prototype을 변조시키는 방식입니다. 일반적으로는 기존 함수를 래핑해 사용하는 방법을 많이 취하고 저도 그 방식을 활용했습니다. 데코레이터 관련된 자세한 내용은 토스 블로그에 잘 나와있기 때문에 후략하겠습니다.

한계

다만, Decorator를 사용하더라도 한계가 있었는데, 앞서 서술했듯 레포지토리가 Prisma 클라이언트를 래핑한 서비스를 의존성으로 지정하여 등록된 상황에서 트랜잭션 메소드의 콜백 인자로 생성된 트랜잭션 매니저로 변경해야 트랜잭션의 처리가 가능하기 때문입니다. 서버가 동작하는 와중에 의존성을 변경해야 했습니다.

NestJS 서버를 실행시켜 보셨다면 로그에서 보셨을테지만 실행과 동시에 모듈을 실행시킵니다. 이렇게 초기화 된 모듈을 중앙 컨테이너에 담아 의존하는 모듈에서 호출할 때 등록된 모듈을 사용하는 방식으로 로직이 실행됩니다. 별도의 설정을 하지 않으면 하나의 인스턴스를 사용하게 됩니다. Injection Scope를 설정해 인스턴스 생성 시점을 변경할 수도 있는데, NestJS에서는 3가지 방법이 있습니다.

기본으로 하나의 인스턴스를 생성하는 것, 요청 별로 인스턴스를 생성하는 것, 프로바이더를 사용할 때마다 생성하는 것, 이렇게 세가지의 방법이 있습니다. 모듈에서의 의존 관계에 따라 하위 모듈에 영향을 받기 때문에, 호출 순서 상 가장 마지막에 있는 데이터베이스 클라이언트를 REQUEST 혹은 TRANSIENT로 하면 모든 프로바이더가 영향을 받아 불필요한 클래스들도 여러번 인스턴스화 시키기 때문에 굉장한 리소스 낭비를 초래합니다.

시도

이러한 문제를 위해 여러가지 해결 방법을 시도해 봤습니다. Prisma에서 설명하는 방법 외에 임의로 여러 시도를 해본 것도 있습니다만 그런 것들은 생략하겠습니다.

NestJS에서 컨테이너에 등록된 모든 provider들은 DiscoveryService에 의해 추적, 관리됩니다. DiscoveryService에 등록된 provider들은 인스턴스 외에도 관리되는 모듈에 대한 객체도 있습니다. 처음 시도한 방법은 이 host에 직접 해당 provider를 대체하는 방법이었습니다. 테스트로 만든 프로젝트에서 처음은 성공한 것 처럼 보였습니다만, 이후에 복잡한 아키텍쳐에서 활용가능한지 검증하기 위해 수정한 이후에는 테스트가 실패했습니다. 첫 시도에서는 PrismaService와 의존하는 provider가 같은 모듈에 의해 관리되고 있었는데, 일반적으로 CustomModule 등 전역 모듈에 등록해 사용하기 때문에 별도의 모듈에서 관리됩니다. 즉, 호출하는 provider와 다른 host에서 관리되기 때문에, 찾고 변경하기가 어렵습니다. 그럼에도 테스트가 성공했던 것은 해당 테스트케이스에 결함이 있기 때문이라고 판단됩니다.

Transactional?

여러 방법을 시도해봤으나 실패했기 때문에 언어의 한계인지가 궁금해 스프링에서의 Transactional 동작방식에 대해 찾아봤습니다. 스프링에서는 의존성 객체를 Proxy로 감싸 런타임에 변경하는 방식을 사용하고 있었습니다. 지금 생각해보면 javascript의 Proxy객체를 이용한 방법도 있겠다 싶긴 한데, 이 때는 그 방법이 떠오르지 않아 TypeORM을 이용해 시도했던 방법을 찾아봤습니다. 해당 블로그는 cls-hooked라는 라이브러리를 이용했는데, 여기서 힌트를 얻을 수 있었습니다.

AsyncLocalStorage

node.js 14버전부터 지원하기 시작한 이 기능은 javascript가 싱글 쓰레드 런타임환경이기에 갖는 단점을 보완하기 위한 기능입니다. node.js는 메인 쓰레드가 V8엔진의 이벤트 루프에 할당되고 여러 비동기작업들을 C언어로 만든 libuv라는 라이브러리를 이용해 다른 쓰레드에 할당합니다. 완료된 작업들은 콜백함수에 의해 다시 이벤트 큐에 등록되고 순서대로 이벤트 루프로 들어갑니다.

이와 반대로 JVM같은 멀티 쓰레드 런타임에서는 하나의 요청에 의해 실행된 작업은 하나의 쓰레드에 할당됩니다. 모든 작업들이 동기적으로 하나의 쓰레드 안에서 실행되기 때문에 작업들 간에 공유되는 컨텍스트는 TLS(ThreadLocalStorage)를 이용해 공유됩니다. 하지만 node.js는 메인 쓰레드가 하나이고 하나의 요청에 의한 여러 비동기 작업을 여러 쓰레드가 나눠 맡을 수 있는 환경이기 때문에 작업간의 컨텍스트 공유가 불가능합니다.

이를 해결하기 위해 지속 가능한 로컬 저장소, CLS(Continuous Local Storage)가 필요한데 node.js에서는 async_hooks라는 라이브러리릉 통해 AsyncLocalStorage라는 기능을 제공하기 시작했습니다. 여러 작업에서 공유가 필요한 값을 넣어 쓰레드 간에 공유할 수 있는 저장소입니다. NestJS에도 해당 기능에 관한 공식문서가 있습니다. 이 공식문서에서 찾은 nestjs-cls라이브러리를 활용했습니다.

해결

기존에 방식대로 PrismaService를 만들되 이를 BasePrismaService로 만들고 한번 더 감싼 PrismaService를 만들었습니다. 그리고 트랜잭션이 필요한 로직의 실행 전에 트랜잭션을 실행시켜 트랜잭션 매니저를 CLS에 넣어두고, PrismaService에서 사용하게끔 하는 방식으로 동작합니다. 처음엔 공부를 하면서 직접 관심사를 관리하는 로직을 사용했는데, 지금은 42 와썹에서 만난 ycha님과 함께 toss-aop라이브러리를 사용해 리팩토링을 해뒀습니다. 코드가 훨씬 깔끔해지고, 관리가 편해졌습니다. 여러 파일에 걸쳐 분산되었기 때문에 예제 코드는 깃허브로 갈음하겠습니다.

발표영상

이 주제를 바탕으로 42서울 수요지식회에서 발표를 했습니다. 준비가 미흡하기도 했고, 청중들의 반응이 미적지근해서 NestJS나 node.js 등을 설명하는데 더 노력하고 메인 주제는 급하게 마무리를 지었습니다. 아쉬움이 많이 남지만 개발자로서 처음으로 대중앞에 서 본 경험이라 같이 남깁니다.

https://www.youtube.com/live/qHsvmbYh-zA?feature=share

참고자료