MongoDB 멀티 도큐먼트 트랜잭션을 Node.js에서 안전하게 사용하는 방법
들어가며
MongoDB 4.0부터 지원되는 멀티 도큐먼트 트랜잭션은 여러 컬렉션에 걸친 데이터 일관성을 보장하는 강력한 기능입니다. 하지만 Node.js 환경에서 이를 구현할 때는 세션 관리, 재시도 로직, 에러 처리 등 복잡한 보일러플레이트 코드가 필요합니다.
이번 글에서는 트랜잭션 구현을 위한 여러 접근 방식을 비교 분석하고, 최종적으로 선택한 AsyncLocalStorage + Mongoose 미들웨어 방식의 구현 과정과 고려사항을 자세히 다뤄보겠습니다.
문제 상황과 요구사항
async deleteCourse(courseId: string) { await courseRepository.delete(courseId); // courses 컬렉션 await unitRepository.deleteByCourseId(courseId); // units 컬렉션 await submissionRepository.deleteByCourseId(courseId); // submissions 컬렉션 }
기술적 요구사항
- 자동 세션 관리: 개발자가 수동으로 세션을 전달하지 않아야 함
- 재시도 메커니즘: MongoDB 트랜잭션 충돌 시 자동 재시도
- 에러 처리: 트랜잭션 실패 시 안전한 롤백
- 성능: 최소한의 오버헤드
- 개발자 경험: 기존 코드 변경 최소화
구현 방식 비교
1. 수동 세션 전달 방식(기본)
async deleteCourse(courseId: string) { const session = await connection.startSession(); try { await session.startTransaction(); await courseRepository.delete(courseId, { session }); await unitRepository.deleteByCourseId(courseId, { session }); await submissionRepository.deleteByCourseId(courseId, { session }); await session.commitTransaction(); } catch (error) { await session.abortTransaction(); throw error; } finally { await session.endSession(); } }
장점: 명확하고 직관적
단점:
- 모든 메서드에 세션 파라미터 추가 필요
- 보일러플레이트 코드 많음
- 재시도 로직 구현 복잡
2. Proxy 패턴 방식
// Repository Proxy 구현 예시 class TransactionalRepositoryProxy { constructor(private repository: any, private session?: ClientSession) {} async delete(id: string) { return this.repository.delete(id, { session: this.session }); } async findById(id: string) { return this.repository.findById(id, { session: this.session }); } } // 사용 예시 async deleteCourse(courseId: string) { const session = await connection.startSession(); try { await session.startTransaction(); const courseRepo = new TransactionalRepositoryProxy(courseRepository, session); const unitRepo = new TransactionalRepositoryProxy(unitRepository, session); await courseRepo.delete(courseId); await unitRepo.deleteByCourseId(courseId); await session.commitTransaction(); } catch (error) { await session.abortTransaction(); throw error; } finally { await session.endSession(); } }
장점:
-
타입 안정성 보장
-
명시적인 의존성 주입 단점:
-
모든 Repository에 대한 Proxy 클래스 필요
-
런타임 오버헤드
-
복잡한 상속 구조
3. Context 패턴 (AsyncLocalStorage)
// 세션 스토리지 export const sessionStorage = new AsyncLocalStorage<ClientSession>(); // Mongoose 미들웨어로 자동 세션 적용 export function applySessionPlugin(schema: any) { schema.pre(['save', 'validate', 'init'], function (this: Document) { const session = sessionStorage.getStore(); if (session && typeof this.$session === 'function') { void this.$session(session); } }); schema.pre(['find', 'findOne', 'update', 'delete'], function (this: Query) { const session = sessionStorage.getStore(); if (session && typeof this.session === 'function') { void this.session(session); } }); } // 사용 예시 async deleteCourse(courseId: string) { const session = await connection.startSession(); try { await session.startTransaction(); await sessionStorage.run(session, async () => { // 모든 DB 작업에 자동으로 세션이 적용됨 await courseRepository.delete(courseId); await unitRepository.deleteByCourseId(courseId); await submissionRepository.deleteByCourseId(courseId); }); await session.commitTransaction(); } catch (error) { await session.abortTransaction(); throw error; } finally { await session.endSession(); } }
장점:
-
기존 코드 변경 없음
-
자동 세션 적용
-
성능 오버헤드 최소 단점:
-
AsyncLocalStorage의 동작 방식 이해 필요
-
디버깅 시 컨텍스트 추적 어려움
AsyncLocalStorage 심화 분석
AsyncLocalStorage란?
AsyncLocalStorage는 Node.js의 async_hooks 모듈을 기반으로 한 컨텍스트 관리 도구입니다. 비동기 작업의 전체 생명주기 동안 컨텍스트를 유지할 수 있게 해줍니다.
import { AsyncLocalStorage } from 'async_hooks'; const storage = new AsyncLocalStorage<string>(); // 컨텍스트 설정 storage.run('context-value', () => { // 이 범위 내에서 실행되는 모든 비동기 작업은 컨텍스트에 접근 가능 setTimeout(() => { console.log(storage.getStore()); // 'context-value' 출력 }, 100); Promise.resolve().then(() => { console.log(storage.getStore()); // 'context-value' 출력 }); });
동작 원리
- 컨텍스트 저장: run() 메서드로 컨텍스트 설정
- 비동기 추적: async_hooks로 비동기 작업 추적
- 컨텍스트 복원: 각 비동기 작업에서 원본 컨텍스트 복원
// 내부 동작 원리 (단순화) class AsyncLocalStorage<T> { private _store = new Map<number, T>(); run<R>(store: T, callback: () => R): R { const asyncId = async_hooks.executionAsyncId(); this._store.set(asyncId, store); try { return callback(); } finally { this._store.delete(asyncId); } } getStore(): T | undefined { const asyncId = async_hooks.executionAsyncId(); return this._store.get(asyncId); } }
성능 고려사항
// 성능 테스트 예시 console.time('AsyncLocalStorage'); for (let i = 0; i < 100000; i++) { sessionStorage.run(session, () => { sessionStorage.getStore(); // 컨텍스트 조회 }); } console.timeEnd('AsyncLocalStorage'); // ~50ms console.time('Direct Access'); for (let i = 0; i < 100000; i++) { // 직접 세션 사용 } console.timeEnd('Direct Access'); // ~5ms
결과: AsyncLocalStorage는 약 10배의 오버헤드가 있지만, 실제 애플리케이션에서는 무시할 수 있는 수준이었습니다.
최종 구현: @Transactional 데코레이터
핵심 구현
export function Transactional( connectionName: DatabaseConnectionType, options: TransactionalOptions = {}, ): MethodDecorator { const { maxRetries = 3, retryDelayMs = 1000, timeoutMs = 30000 } = options; return function (_target, _propertyKey, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = async function (...args: unknown[]) { const connection = mongoose.connections.find( (conn) => conn.name === connectionName, ); if (!connection) { throw new Error('Connection을 주입해주어야 합니다.'); } let lastError: Error; // 재시도 로직 for (let attempt = 0; attempt <= maxRetries; attempt++) { const session = await connection.startSession(); try { await session.startTransaction(); // 타임아웃과 함께 실행 const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Transaction timeout')), timeoutMs); }); const result = await Promise.race([ sessionStorage.run(session, async () => { return await originalMethod.apply(this, args); }), timeoutPromise, ]); await session.commitTransaction(); return result; } catch (error) { lastError = error as Error; // 트랜잭션 중단 try { await session.abortTransaction(); } catch (abortError) { console.warn('Failed to abort transaction:', abortError); } // 재시도 가능한 에러인지 확인 if (isRetryableError(error) && attempt < maxRetries) { const delay = retryDelayMs * Math.pow(2, attempt); // 지수 백오프 await new Promise(resolve => setTimeout(resolve, delay)); continue; } throw error; } finally { try { await session.endSession(); } catch (endError) { console.warn('Failed to end session:', endError); } } } throw lastError!; }; return descriptor; }; }
재시도 매커니즘
function isRetryableError(error: any): boolean { const retryableCodes = [ 251, // NoSuchTransaction 112, // WriteConflict 24, // LockTimeout 11600, // InterruptedAtShutdown 11601, // InterruptedDueToReplStateChange 11602, // InterruptedDueToClientReconnect ]; const retryableCodeNames = [ 'TransientTransactionError', 'NoSuchTransaction', 'WriteConflict', 'LockTimeout', ]; // 코드로 확인 if (error.code && retryableCodes.includes(error.code)) { return true; } // 코드명으로 확인 if (error.codeName && retryableCodeNames.includes(error.codeName)) { return true; } // 네트워크 관련 에러 if (error.name === 'MongoNetworkError' || error.name === 'MongoTimeoutError') { return true; } return false; }
실제 사용 예시
1. 스키마에 플러그인 적용
export const connectionFactory = (connection: Connection) => { connection.plugin(applySessionPlugin); return connection; };
2. 서비스에서 데코레이터 사용
@Injectable() export class CourseService { constructor( private readonly courseRepository: CourseRepository, private readonly unitRepository: UnitRepository, private readonly submissionService: SubmissionService, ) {} @Transactional('gem-operation') async deleteCourse({ courseId, deleteUserId }: DeleteCourseParams) { // 모든 작업이 하나의 트랜잭션에서 실행됨 await this.courseRepository.softDeleteOneById({ id: courseId }); await this.unitRepository.softDeleteManyByCourseId({ courseId }); await this.submissionService.deleteSubmissionByCourseId({ courseId }); // 중간에 에러가 발생하면 모든 작업이 롤백됨 } @Transactional('gem-operation', { maxRetries: 5, timeoutMs: 60000 }) async createCourseWithUnits(courseData: CreateCourseDto) { // 복잡한 비즈니스 로직도 안전하게 처리 const course = await this.courseRepository.create(courseData); for (const unitData of courseData.units) { await this.unitRepository.create({ ...unitData, courseId: course.id, }); } return course; } }
실제 동작 과정
1. 트랜잭션 시작
const session = await connection.startSession(); await session.startTransaction();
2. 컨텍스트 설정
await sessionStorage.run(session, async () => { // 이 범위 내의 모든 비동기 작업에서 세션 접근 가능 });
3. 자동 세션 적용
// Mongoose 미들웨어가 자동으로 실행 schema.pre('save', function (this: Document) { const session = sessionStorage.getStore(); // 세션 조회 if (session) { this.$session(session); // 세션 적용 } });
4. 성공 시 커밋
await session.commitTransaction();
5. 실패 시 롤백 및 재시도
catch (error) { await session.abortTransaction(); if (isRetryableError(error) && attempt < maxRetries) { const delay = retryDelayMs * Math.pow(2, attempt); await new Promise(resolve => setTimeout(resolve, delay)); continue; // 재시도 } throw error; }
마무리
이 @Transactional 데코레이터를 통해 MongoDB 멀티 도큐먼트 트랜잭션을 Node.js 환경에서 안전하고 편리하게 사용할 수 있습니다.
AsyncLocalStorage + Mongoose 미들웨어 방식은 다른 접근 방식들(수동 세션 전달, Proxy 패턴)과 비교했을 때 다음과 같은 장점을 제공합니다:
- 최소한의 코드 변경: 기존 비즈니스 로직을 그대로 유지
- 자동화된 세션 관리: 개발자가 신경 쓸 필요 없음
- 견고한 에러 처리: 재시도 메커니즘과 타임아웃 보호
- 성능 최적화: 최소한의 오버헤드로 트랜잭션 기능 제공 실제 프로덕션 환경에서 여러 컬렉션에 걸친 복잡한 비즈니스 로직을 구현할 때 매우 유용한 패턴이니 참고해보시기 바랍니다.
참고 자료
댓글 (0)
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!