NestJS, MongoDB에서 AsyncLocalStorage로 트랜잭션 관리하기
MongoDB 트랜잭션을 NestJS에서 사용할 때 가장 먼저 부딪히는 문제는 이것이다. “이 ClientSession을 어떻게 레이어 전반에 흘려보낼 것인가?”
- Controller → UseCase → Repository
- 여러 Repository 호출을 하나의 트랜잭션으로 묶고 싶다
- 하지만 매번 session을 파라미터로 전달하고 싶지는 않다 이 글에서는 AsyncLocalStorage를 활용해 MongoDB 트랜잭션을 “요청 스코프처럼” 관리하는 방법과, 그 설계의 장단점을 정리합니다.
1. 핵심 아이디어
Node.js의 AsyncLocalStorage는 비동기 호출 체인 전체에 걸쳐 값을 유지할 수 있는 컨텍스트 저장소입니다. 이 특성을 활용하면 아래와 같은 구조를 만들 수 있습니다.
- 트랜잭션 시작 시 ClientSession을 컨텍스트에 저장
- 하위 레이어에서는 이를 꺼내 사용
- 트랜잭션 경계를 상위에서 제어
2. Session Context 구현
import { AsyncLocalStorage } from "async_hooks";
import { ClientSession } from "mongoose";
const sessionStorage = new AsyncLocalStorage<ClientSession>();
export const MongoSessionContext = {
runWithSession: <T>(session: ClientSession, fn: () => Promise<T>) => {
return sessionStorage.run(session, fn);
},
getSession: (): ClientSession | undefined => {
return sessionStorage.getStore();
},
} as const;
이 컨텍스트는 단순합니다.
- runWithSession → 특정 session을 컨텍스트에 주입
- getSession → 현재 async 체인에서 session 조회 이제 레이어 어디에서든 session을 꺼낼 수 있겠습니다.
3. TransactionRunner
트랜잭션의 생명주기는 한 곳에서만 관리해야 합니다.
@Injectable()
export class TransactionRunner {
constructor(@InjectConnection() private readonly conn: Connection) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
const session = await this.conn.startSession();
try {
return await session.withTransaction(() =>
MongoSessionContext.runWithSession(session, fn),
);
} finally {
await session.endSession();
}
}
}
이 클래스의 책임은 명확합니다.
- 세션 생성
- 트랜잭션 시작
- 컨텍스트 주입
- 커밋/롤백
- 세션 종료 트랜잭션 로직은 여기로 집중 시킵니다.
4. @Transactional 데코레이터
트랜잭션 경계를 메서드 단위로 선언적으로 표현할 수 있습니다.
export function Transactional() {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor,
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const runner: TransactionRunner = this.transactionRunner;
if (runner == null) {
throw new Error("TransactionRunner not found on class");
}
return await runner.run(() => originalMethod.apply(this, args));
};
};
}
이제 UseCase는 다음처럼 작성할 수 있습니다.
export class CreateOrderUseCase {
constructor(
readonly transactionRunner: TransactionRunner,
private readonly orderRepo: OrderRepository,
) {}
@Transactional()
async execute(command: CreateOrderCommand) {
await this.orderRepo.save(...);
await this.orderRepo.updateStock(...);
}
}
트랜잭션 경계가 명확해진 것을 볼 수 있습니다.
5. Repository에서 Session 사용
Repository는 컨텍스트에서 session을 꺼내 적용합니다.
const session = MongoSessionContext.getSession();
await this.model.create([doc], { session });
또는
await this.model.find(query).session(session);
여기서 중요한 점은 다음과 같습니다. 모든 쿼리가 session을 정확히 사용해야 한다. 하나라도 빠지면, 트랜잭션 바깥에서 실행될 위험이 있습니다.
이 설계의 장점
1. Session 전달 지옥 탈출
매번 session을 파라미터로 넘기지 않아도 됩니다.
2. 트랜잭션 경계가 명확
UseCase 단에서 선언적으로 표현 가능합니다.
3. 레이어 분리와 궁합이 좋음
헥사고날 아키텍처에서 inbound에서 경계를 만들고, outbound에서 사용하기에 자연스럽습니다.
반드시 고려해야 할 문제들
1. 중첩 트랜잭션 문제
현재 구현은 항상 새 세션을 생성한다. 만약 @Transactional 메서드가 또 다른 @Transactional 메서드를 호출한다면 다음과 같은 문제가 있습니다.
- 세션이 두 개 생성된다
- 동일 요청에서 하나의 트랜잭션이라는 보장이 깨질 수 있다 현실적인 해결책은 다음 정책을 추가하는 것입니다.
const existing = MongoSessionContext.getSession();
if (existing) return await fn();
이미 세션이 있으면 재사용 합니다. (RDB에서 말하는 REQUIRED 전략과 유사.)
2. 암묵적 컨텍스트의 위험
AsyncLocalStorage는 편리하지만, 컨텍스트가 보이지 않습니다.
- 코드만 보고 트랜잭션 여부를 파악하기 어렵다
- 백그라운드 작업, 이벤트 핸들러 등에서 경계가 모호해질 수 있다 따라서 다음 원칙이 필요합니다 트랜잭션은 “Query, Command 처리 범위 안에서만” 사용한다.
3. 데코레이터의 DI 의존성
현재 데코레이터는 this.transactionRunner에 의존합니다. 이 방식은 다음과 같은 단점이 있습니다.
- 런타임 에러 가능성
- 테스트 더블에서 깨질 수 있음
- 구조적으로 강제되지 않음
어쩌면 아래의 방식이 더 Nest스러운 방식일 수도 있겠습니다.
- Interceptor + Reflector 기반 구현
- 또는 베이스 클래스에서 Runner 강제
결론
- MongoDB 트랜잭션을 깔끔하게 감싸는 실용적인 패턴이다.
- 특히 헥사고날 구조와 잘 어울린다.
- 하지만 중첩 정책과 DI 안정성을 보완해야 된다. AsyncLocalStorage는 마법과 같을 수 있겠습니다만, 적절한 경계와 정책을 함께 설계하면 매우 강력한 도구가 될 수도 있겠습니다.