시작하며;
현재 이집트, 베트남, 인도 등의 국가들을 대상으로 치과 플랫폼에서 백엔드 개발자로 일하고 있다. 치과 플랫폼은 의사가 진료를 보면서도 병원 운영을 쉽고 정확하게 수행할 수 있도록 도움을 줄 수 있다. 치과 플랫폼이 제공하는 주요 기능은 환자 생성부터 진료 예약, 접수, 수납, 그리고 직원 급여 지급, 재무 관리 등이 있다. TypeORM 을 이용한 트랜잭션 데코레이터 예제는 많다. 하지만 회사에서는 mongoose 를 쓰고 있었고 적절한 예제가 없었다. 그래서 더욱 좋았던 것 같다.
한편, 기존 코드는 데이터 정합성을 보장하기 위해 하나 이상의 논리적인 작업을 모두 완벽하게 적용하거나 원상태로 복구하기 위해 아래처럼 트랜잭션을 사용하고 있다. 개발자는 트랜잭션이 필요한 지점마다 startSession, abortTransaction, 그리고 endSession 까지 반복적으로 코드를 추가해야한다. 특히 매번 try-catch-finally 를 추가하고 싶지 않았다. 불필요한 반복 코드를 줄이고 개발자가 오로지 비즈니스 로직에 집중할 수 있는 환경에 대해서 고민하고 운영에 적용했던 경험을 적어본다.
{
const transactionSession = await this.connection.startSession();
transactionSession.startTransaction();
try {
const file = await this.create(createFileInput, {
session: transactionSession,
}); // repository 영역
// ....
// ....
// ....
await transactionSession.commitTransaction();
return { file, chart };
} catch (e) {
await transactionSession.abortTransaction();
throw new CustomException('ERROR_CODE.COMMON.INTERNAL_SERVER_ERROR');
} finally {
await transactionSession.endSession();
}
}
코드를 좀 더 살펴보면;
내가 생각한 아쉬운 점은 transactionSession 이라는 매개변수이다. 트랜잭션을 처리하기 위해 동일한 세션을 가져야 하기 때문에 서비스에서 생성한 transactionSession 을 repository 마다 지속해서 동일한 세션을 전달해야한다.
예를 들어 진료 건에 대해서 수납이 발생했다고 가정해보자.
진료 건이 발생하고 1) 환자가 수납을 한다. 2) 이어서 수납 데이터는 병원 수입으로 간주하고 수입을 관리하는 저장소에 저장되어야 한다.
1) 과 2) 프로세스는 동시에 성공하거나 실패해야한다. 레거시 코드로는 1) 과 2) 서로 같은 세션이 공유가 되어야 한다. 반드시 한땀한땀 세션을 각 레포지토리에 전달해야한다. 개발자의 실수가 생기기 쉽다.
작업 단위 모듈 구현하기;
도메인 개발자가 데이터의 추가, 수정, 삭제 등 작업을 수행한 후 개발자가 직접 커밋할지 롤백할지 등 결정할 필요가 없도록 구현했다. 커밋할 시점이 되면 무엇을 해야하는지 UnitOfWorkSerivce (이하: uow) 가 결정하도록 했다.
내가 정의한 uow 의 역할은 다음과 같다
- getClientSession : 현재 수행 중인 contextId 로 AsyncLocalStorage 에 동일한 contextId 에 맵핑된 세션값을 획득하는 함수
- doTransactional : 아래 설명 참조 (서비스 단위에서 트랜잭션을 수행할 수 있도록 구현한 함수)
- doTransactionalCallHandler : 요청에 대한 라우트 핸들러의 전과 후에 호출되어 트랜잭션을 수행할 수 있도록 구현한 함수
추상화 클래스에 (a.k.a. abstract.repository) uow 의존성 연결;
아래처럼 await this.uow.getClientSession 을 통해 현재 나의 contextId 에 맵핑된 transaction session 값을 획득한다.
이제 모든 준비는 끝이다.
async create(
document: Omit<T, '_id'>,
options?: SaveOptions,
): Promise<T & Document> {
const createdDocument = new this.model({
...document,
_id: new Types.ObjectId(),
});
const session = await this.uow.getClientSession();
createdDocument.$session(session);
return (await createdDocument.save(options)).toJSON() as unknown as T &
Document;
}
async updateOne(
filter: FilterQuery<T>,
update: UpdateQuery<T & Document>,
): Promise<UpdateResult> {
const session = await this.uow.getClientSession();
return this.model
.updateOne(filter, update, {
session,
new: true,
})
.exec();
}
AsyncLocalStorage 를 이용하여 컨텍스트당 고유한 세션 유지 / 저장하기;
Node.js 는 싱글 쓰레드로 동작한다. 즉 모든 리퀘스트가 하나의 쓰레드에서 처리되며 요청별 쓰레드 컨텍스트가 없다. NestJS 에서 제공하는 @Injectable({ scope: Scope.REQUEST }) 속성이 있다. 도입하기 전 공식 문서를 찾아보니 아래와 같은 멘션이 있었다.
Using request-scoped providers will have an impact on application performance. While Nest tries to cache as much metadata as possible, it will still have to create an instance of your class on each request. Hence, it will slow down your average response time and overall benchmarking result. Unless a provider must be request-scoped, it is strongly recommended that you use the default singleton scope.
기본적으로 싱글턴으로 생성되는데 해당 속성을 사용하면 클라이언트의 요청마다 컨트롤러나 서비스, 레포지토리 계층 등에 의존하는 많은 개체들이 생성되어 성능면에서 좋지 않다고 한다. 블로그, 공식 문서 서핑을 통해 AsyncLocalStorage 를 찾게 되었고 실무에 적용해보면 좋을 것 같아서 바로 착수했다.
아래 함수를 이용해서 method 데코레이터를 만들었고 이름은 자바 스프링과 동일하게 @Transactional 이라고 지었다. 데코레이터가 달린 부모 함수의 자식 자ㅏㅏㅏㅏ식 함수가 수행될 때 트랜잭션 세션이 전달 인자로 이어서 전달되게끔 구현했다. 해당 데코레이터를 내가 원하는 메소드에 달아두면 최초 서비스가 호출될 때 ContextIdFactory 를 통해 유일한 아이디를 (contextId) 생성한다. 그리고 트랜잭션 세션을 본격적으로 시작한다. 그리고 위에서 언급한 AsyncLocalStorage 에 contextId 와 세션을 키-밸류 형태로 저장/호출해서 사용한다.
async doTransactional<T>(
cb: (session: ClientSession) => Promise<T>,
): Promise<T> {
let response!: T;
const session = await this.connection.startSession();
const contextId = ContextIdFactory.create();
try {
await session.withTransaction(async (session) => {
await this.als.run(new Map<string, ClientSession>(), async () => {
this.als.getStore().set(contextId, session);
response = await cb(session);
});
});
} finally {
await session.endSession();
}
return response;
}
추상화 레포지토리 클래스에서 데이터의 추가, 수정, 그리고 삭제 시 UoW 를 통해 기존에 오픈된 세션이면 해당 세션으로 커밋하도록 구현했다. 레거시 구조는 레포지토리 계층이 없다. 서비스 계층에서 mongoose 모델을 호출하여 로직마다 필요한 인터페이스를 직접 호출하고 있다. (findOne, find, delete ... 등 이런 사례가 너무 많아서 mongoose npm 모듈을 최신 버전으로 업데이트할 수 없는 상황이다.)
그래서 내가 이 곳에 와서 가장 먼저 작업한 것이 서비스와 영속성 계층 간의 명확한 분리였다. 서비스 계층은 데이터 계층에 바로 접근하지 않는다. 레거시 구조와 엮인 곳들은 여전히 세션을 직접 개발자가 하나 하나 챙기고 있다. 갈 길이 멀다.
서비스가 중첩되어도 같은 트랜잭션 세션으로 묶여 동시에 성공하거나 실패하도록 구현해두었다. 지금까지 아주 만족스럽다.


return;
'NestJS' 카테고리의 다른 글
| Exception Filter: 거미줄처럼 촘촘한 에러 처리 만들기 🕸️ (0) | 2024.12.28 |
|---|