요약
1. MongoDB Transaction 개념 소개
2. Nest.js에서 @Transactional 데코레이터 구현
3. Transaction 사용하면서 겪었던 이슈들 해결 방법 소개
기존에 PostgreSQL을 사용하다가 MongoDB로 마이그레이션하면서 Transaction에 대해서도 고민했었다.
Database의 Transaction이 크게 다르지 않겠지라는 생각으로 시작했지만, 개발 단계에서 세부적인 이슈들이 나오면서 자세히 보기 시작했다.
기본적으로 MongoDB Transaction도 일반적인 Transaction 개념과 동일하다.
startSession 메서드를 통해서 Transaction을 시작하고, commitTransaction 메서드를 통해서 Transaction을 커밋하고, abortTransaction 메서드를 통해서 Transaction을 롤백한다.
다만 MongoDB는 다음 조건을 만족해야만 Transaction을 사용할 수 있다.
기존에 PostgreSQL을 사용할때 ORM으로 MikroORM을 사용했었다.
입사전에 개발자분들이 ORM대신 knex를 사용하고 있었고, 어느정도 익숙해야 자연스러운 마이그레이션이 될 것 같았고 성능적인 면도 우수하다고 해서 선택을 했었다.
하지만 아쉬웠던 점이 @Transactional 데코레이터가 없었고 (슬프게도 최신 버전에서는 지원한다), 해당하는 데코레이터를 만들어서 Transaction이 비지니스 로직에 포함되지 않고 명시적으로 관리할 수 있도록 구성했다.
MongoDB로 마이그레이션을 하면서도 동일하게 생각을 하고 관련해서 존재하나 하고 찾아봤지만 존재하지 않아서 개발을 시작했다.
Nest.js에서는 mongoose를 사용하고 있었고, mongoose의 Transaction 사용 방식은 아래와 같이 2가지 방식이 있다.
const session = await mongoose.startSession();
session.startTransaction();
try {
await User.create([{ name: 'John' }, { name: 'Jane' }], { session });
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
await mongoose.connection.transaction(async (session) => {
await User.create(
[{ name: 'John' }, { name: 'Jane' }],
{ session },
);
});
위 2가지 방식 모두 @Transactional 데코레이터를 구현함에 있어서 session 전달 자체가 불가능하기 때문에 다른 방법을 찾아야만 했다.
.pre 메소드를 활용한 session 전달대부분 ORM에 있는 pre hook이 있을거라고 생각을 하고 찾아보니 다행히 mongoose에도 존재하는 메소드였다.
다만 문제가 존재했는데, SchemaFactory를 통해서 지정을 해야했고, 모든 Schema에 적용되는게 아니라 Schema별로 지정을 해야했다는 점이였다.
또한 Schema별로 지정하는 것뿐만 아니라 findOneAndUpdate랑 aggregate, updateMany와 같은 메소드들 callback 형태가 모두 달라서 따로 지정을 해야하는 문제가 발생했다.
코드가 너무 복잡해지고 유지보수가 과연 될까... 라는 의문이 들어서 다른 방법을 찾아보았지만 결국 찾지 못해서 해당하는 방법으로 구현을 했다.
첫번째로는 기존에 사용하던 SchemaFactory를 매핑해서 MongoHelper class를 만들었다.
export class MongoHelper {
private static schemaMap = new Map<string, Schema<Document>>();
public static createForClass<T extends Document>(schema: Schema<T>) {
const schemaFactory = SchemaFactory.createForClass(schema);
this.schemaMap.set(schema.name, schemaFactory);
return schemaFactory;
}
}
위와 같이 구성해서, 기존과 같이 schemaFactory는 만들돼, 모든 schema에 대한 정보를 관리하는 class를 생성했다.
이후 Mongo Method별로 pre hook을 지정하고 init 함수를 만들어서 서버가 bootstrap될 때 모든 schema에 대한 pre hook을 지정하도록 구성했다.
const SchemaPreMethods = [
['find', 'findOne', 'findOneAndUpdate', 'findOneAndReplace', 'findOneAndDelete'],
['updateOne', 'updateMany', 'deleteOne', 'deleteMany'],
['aggregate'],
['countDocuments'],
];
export class MongoHelper {
private static schemaMap = new Map<string, Schema<Document>>();
public init() {
const getSession = (conn: Connection) => {
// session을 가져오는 로직
}
for (const schema of this.schemaMap.values()) {
for (const methods of SchemaPreMethods) {
schema.pre(methods, function (next) {
const conn = (this as any).?._collection?.collection?.conn; // private 변수 접근 필요
const session = getSession(conn);
if (session) {
this.session(session);
}
next();
})
}
}
schema.pre('bulkWrite', function (next, operations, options) {
const session = getSession(this.collection.conn);
// bulkWrite에서 options가 undefined라면 session을 전달할 방법이 없다.
if (session && options?.session === undefined) {
if (typeof options === 'object' && options !== null) {
options.session = session;
}
}
next();
});
schema.pre('save', function (next) {
const session = getSession(this.collection.conn);
if (session) {
this.$session(session);
}
next();
});
schema.pre('insertMany', function (next, docs, options) {
const session = getSession(this.db);
if (session && options?.session === undefined) {
if (typeof options === 'object' && options !== null) {
options.session = session;
}
}
next();
});
}
}
위 코드를 보면 알 수 있듯이 사실상 억지...로 구현한 수준이라 과연 이 방법이 맞는가라는 의문이 계속 들었다.
특히 bulkWrite에서 options가 undefined라면 session을 전달할 수 있는 방법이 없어서 BaseRepository를 만들어서 해당하는 곳에서 ?? {} 처리를 통해 반드시 넣도록 했다.
mongoose도 나중에는 업데이트가 될거라고 믿는다...
@Transactional 데코레이터 구현이제 남은건 데코레이터만 구현해서 비지니스 로직에 포함되지 않고 명시적으로 Transaction을 관리하는 것이다.
export function Transactional() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const method = descriptor.value;
descriptor.value = async function (...args: any[]) {
const conn = mongoose.connection;
if (conn.isTransaction) {
return method.apply(this, args);
}
let session: Session | null = null;
try {
session = await conn.startSession();
await session.startTransaction();
const result = await method.apply(this, args);
await session.commitTransaction();
return result;
} catch (error) {
await session?.abortTransaction();
throw error;
} finally {
session.endSession();
}
}
}
}
기본적인 구조다. method가 Transaction 상태라면 그대로 넘기고 아니라면 Transaction을 생성해서 실행하고 커밋하고 롤백하고 끝이다.
다만 추가적으로 필요한 로직들이 존재한다.
MongoDB는 RDB와 다르게 Deadlock 상황이 발생할 것 같으면 이후에 들어온 요청에 대해서 오류를 발생하면서 재시도를 하라고 한다.
에러 메세지: WriteConflict error: this operation conflicted with another operation. Please retry your operation or multi-document transaction.
실제로 MongoDB 공식에서도 재시도를 하라고 권장하고 있어서 해당하는 로직을 추가해야한다.
다만 무한 재시도를 하거나, 너무 잦게 재시도를 하면 모두가 경쟁 상태에 들어가서 문제가 발생한다.
따라서 애초에 Deadlock이 걸리지 않도록 설계를 해야한다.
특히 Mongo는 기존 RDB와 다르게 아래에 해당하는 경우도 Deadlock이 발생할 수 있다.
이러한 이유때문에 비지니스 로직에서 여러 컬럼을 수정하는 경우 한번에 하도록 변경하거나, bulkWrite를 활용해서 발생률을 줄였다.
또한 RDB때도 동일하지만 Transaction 범위를 최소화해서 최대한 적은 양의 데이터를 수정하도록 구성했다.
기존 PostgreSQL을 사용하면서 auto_increment 컬럼들이 몇몇 존재했는데 MongoDB에서는 기본적으로 지원하지 않는다.
이에 고민하다가 auto_increment를 관리하는 Collection을 만들고, 해당하는 Collection은 MongoHelper에서 제외해서 Transaction과 별개로 무조건 수정이 되도록 구성해서 사용하고 있다.
RDB에서 데이터를 그대로 넘기다보니 auto_increment를 사용해야하는 부분들이 존재했는데, 이후 개발하면서 추가된 Collection에서는 거의 사용하지 않는다. (애초에 ObjectId를 최대한 사용한다.)
MongoDB에서 Transaction을 사용하는데 있어서 생각보다 다양한 이슈가 존재했다.
이번 구현은 mongoose + Nest.js 환경에서 기존 개발 경험을 최대한 유지하기 위해 구현을 했지만, mongoose가 업데이트가 되거나 놓친 부분이 있으면 이슈가 생길 수 있어서 당당하게 추천할만한 방법인지는 모르겠다... (그래도 몇개월 운영하면서 Transaction 이슈가 발생하지는 않았다.)
Mongo는 RDB랑 다르게 최대한 단일 Document 업데이트를 위주로 설계하는게 좋기 때문에 Transaction을 사용하는 것보다는 단일 Document 업데이트를 위주로 설계하는게 좋다고 생각한다.