mongoose pre hook 에러 추적기 (1)
1. 계기
- 사내 프로덕트를 운영하면서 배포를 진행한 순간 1년동안 문제없이 작동하던 코드에서 오류가 발생하였습니다.
- 최근 mongoose 버전을 5에서 8로 올리며 바뀐 기능들을 마이그레이션 하였는데요, 해당 작업으로 인해 문제가 발생한 것으로 파악하고 문제를 한번 분석하기 시작하였습니다.
오류 상황
- 다음과 같이 기존에 사용하던 aggregate 메서드에 대한 pre 훅에서 에러가 발생하고 있었습니다.
- pipeline() 이라는 메서드를 찾지 못하고 있는데, this가 가르키는 클래스가 달라진 것으로 보였습니다.
- 기존에는 Aggregate 객체의 pipeline() 메서드를 통해 pipeline을 확인할 수 있었습니다.
문제가 되는 코드
// schema는 mongoose schema 객체입니다.
schema.pre('aggregate', function (next) {
const firstMatchIndex = this.pipeline().findIndex((item) => item.$match);
// $match가 존재하면 $match에 deleted: { $ne: true }를 추가한다.
if (firstMatchIndex !== -1) {
this.pipeline()[firstMatchIndex].$match.deleted = {
$ne: true,
};
} else {
// 없으면 $match를 추가한다.
this._pipeline = [{ $match: { deleted: { $ne: true } } }].concat(
this.pipeline()
);
}
this.append({ $project: { deleted: 0 } });
// $lookup에서 softDelete된 document를 제외한다.
this.pipeline().forEach((_item, index) => {
const item = { ..._item };
const isLookup = item.$lookup;
if (isLookup) {
const updatedLookup = {
...item.$lookup,
pipeline: [{ $match: { deleted: { $ne: true } } }]
.concat(item.$lookup.pipeline || [])
.concat([{ $project: { deleted: 0 } }]),
};
this.pipeline()[index].$lookup = updatedLookup;
}
});
next();
});
- 해당 코드는 간단하게 aggregate 메서드에 soft-delete 처리를 위한 pre 훅을 달아둔 것입니다.
- aggregate 메서드를 사용하였을 때 soft-deleted 된 데이터들을 제외하는 기능을 합니다.
- $match 단계에서 deleted가 없거나 false인 데이터들만 가져옵니다.
- $lookup 단계에서 $match를 추가해서 deleted가 없거나 false인 데이터들만 가져옵니다.
- mongoose soft delete 플러그인, dsanel/mongoose-delete 과 유사한 기능을 합니다.
- aggregate 메서드를 사용하였을 때 soft-deleted 된 데이터들을 제외하는 기능을 합니다.
GitHub - dsanel/mongoose-delete: Mongoose Soft Delete Plugin
Mongoose Soft Delete Plugin. Contribute to dsanel/mongoose-delete development by creating an account on GitHub.
2. 원인 파악하기
- mongoose 버전을 5에서 8로 올리며 지원하지 않는 기능들에 대해서 마이그레이션을 진행하는 작업이 있었습니다.
- 해당 작업과 버전업에 따른 누락사항들이 영향이 될 것이라고 확신하고 원인 파악을 시작하였습니다.
- 두가지 가설을 세우고 접근하였습니다.
- 첫번째, mongoose 내부 구현의 변경이나 오류
- 두번째, 애플리케이션 수준에서의 잘못된 사용
- 두가지 경우의 수를 고려하며 애플리케이션 코드와 mongoose 코드를 살펴보았습니다.
2.1 mongoose 버전에 따른 애플리케이션 작동 방식 비교
- 두가지 버전을 기준으로 비교하였습니다.
- 마이그레이션 후 버전 - mongoose v8.5.0
- 마이그레이션 전 버전 - mongoose v5.13.14
2.1.1 Aggregate 훅
기준 코드
// schema는 mongoose schema 객체입니다.
schema.pre('aggregate', function (next) {
// 버전에 따른 this가 무엇인지 알아보겠습니다.
console.log("1. this: ", this);
console.log("2. this pipeline: ", this.pipeline());
// $match에서 softDelete된 document를 제외한다.
...
// $lookup에서 softDelete된 document를 제외한다.
...
next();
});
- 기준 코드는 위에서 서술한 코드와 동일합니다.
- 해당 코드에서 우선 this가 무엇을 가르키는지 확인하며 분석을 시작해보려고 합니다.
this?
- 버전에 따른 실행 결과는 아래와 같습니다.
mongoose v8.5.0
1. this: Model { lecture_class }
TypeError: this.pipeline is not a function
- this의 경우 Model 클래스입니다.
- Model 클래스이기 때문에 pipeline 메서드가 없어서 에러가 발생합니다.
mongoose v5.13.14
1. this:
Aggregate {
_pipeline: [
{ '$match': [Object] },
{ '$lookup': [Object] },
{ '$addFields': [Object] },
{ '$sort': [Object] },
{ '$project': [Object] }
],
_model: Model { lecture_class },
options: {}
}
2. this pipeline:
[
{ '$match': { lectureIndex: 'lec_ASDI_1248472223', deleted: [Object] } },
{
'$lookup': {
from: 'manual_attendances',
localField: 'index',
foreignField: 'lectureClassIndex',
as: 'manualAttendances',
pipeline: [Array]
}
},
{ '$addFields': { attendanceCount: [Object], sortField: [Object] } },
{ '$sort': { sortField: 1 } },
{
'$project': { _id: 0, __v: 0, manualAttendances: 0, sortField: 0 }
}
]
- this의 경우 Aggregate 클래스입니다.
- Aggregate를 호출하면서 전달받은 pipeline을 출력합니다.
2.1.2 Query 훅
- 버전에 따라 다른 부분을 확인해보았는데요, aggregate 메서드에 대해서만 그런지 확인해보기 위해서 query 훅에서도 테스트를 해보겠습니다.
기준 코드
// schema는 mongoose schema 객체입니다.
schema.pre(
[
'count',
'countDocuments',
'find',
'findOne',
'findOneAndUpdate',
'updateOne',
'updateMany',
'distinct',
],
function skipDeleted(next) {
console.log("1.this: ", this);
this.where('deleted').ne(true);
next();
}
);
- 해당 코드에서도 우선 this가 무엇을 가르키는지 확인하며 분석을 해보려고 합니다.
this?
- 버전에 따른 실행 결과는 아래와 같습니다.
mongoose v8.5.0
1. this:
Query {
_mongooseOptions: { lean: true },
_transforms: [],
_hooks: Kareem { _pres: Map(0) {}, _posts: Map(0) {} },
_executionCount: 0,
mongooseCollection: NativeCollection {
...
},
model: Model { lecture_class },
schema: Schema {
...
statics: {},
...
s: { hooks: [Kareem] },
},
...
}
- this의 경우 Query 클래스입니다.
mongoose v5.13.14
1. this:
Query {
_mongooseOptions: { lean: true },
_transforms: [],
_hooks: Kareem { _pres: Map(0) {}, _posts: Map(0) {} },
_executionCount: 0,
mongooseCollection: NativeCollection {
...
},
model: Model { lecture_class },
schema: Schema {
...
statics: {},
...
s: { hooks: [Kareem] },
},
...
}
- this의 경우 Query 클래스입니다.
중간점검
- 해당 버전 비교를 통해서 Aggergate 메서드에 대한 훅에서 this가 다르게 동작하는 것을 확인할 수 있었습니다.
- 8버전에서는 Model 클래스, 5버전에서는 Aggregate 클래스입니다.
2.2 mongoose 버전별 소스코드 비교
- 이제 mongoose 구현체를 살펴보며 버전에 따라서 어떤것이 달라졌는가를 살펴보겠습니다.
- mongoose repo를 보면서 모든 코드를 살펴보는 것은 가능하나 시간이 너무 오래 걸리기 때문에 필요한 부분만 찾아서 확인해보려고 확인하려고 합니다.
- 살펴보아야할 부분이 어떤 것인지 선정하는 기준은 APM에 찍힌 Exception stack trace 정보를 바탕으로 정하였습니다.
Exception stack trace
2.2.1 lib/helpers/model/applyStaticHooks.js
8.5.0
mongoose/lib/helpers/model/applyStaticHooks.js at 8.5.0 · Automattic/mongoose
MongoDB object modeling designed to work in an asynchronous environment. - Automattic/mongoose
5.13.14
mongoose/lib/helpers/model/applyStaticHooks.js at 5.13.14 · Automattic/mongoose
MongoDB object modeling designed to work in an asynchronous environment. - Automattic/mongoose
- 코드의 변경점이 없습니다.
2.2.2 lib/helpers/promiseOrCallback.js
8.5.0
mongoose/lib/helpers/promiseOrCallback.js at 8.5.0 · Automattic/mongoose
MongoDB object modeling designed to work in an asynchronous environment. - Automattic/mongoose
5.13.14
mongoose/lib/helpers/promiseOrCallback.js at 5.13.14 · Automattic/mongoose
MongoDB object modeling designed to work in an asynchronous environment. - Automattic/mongoose
- 에러처리 제외하고는 특별하게 다른점 없습니다.
중간점검
- 이처럼 소스코드의 유의미한 변화가 없는데도 작동방식이 다르다는 점은 애플리케이션 코드에서 잘못 사용 했을 가능성이 높습니다.
- 우선 가장 살펴보아야 할 파일, 즉 hook을 등록하는 applyStaticHooks.js가 어떻게 작동하는지 먼저 알아보겠습니다.
2.3 applyStaticHooks.js
applyStaticHooks.js
'use strict';
const middlewareFunctions = require('../query/applyQueryMiddleware').middlewareFunctions;
const promiseOrCallback = require('../promiseOrCallback');
module.exports = function applyStaticHooks(model, hooks, statics) {
const kareemOptions = {
useErrorHandlers: true,
numCallbackParams: 1
};
hooks = hooks.filter(hook => {
// If the custom static overwrites an existing query middleware, don't apply
// middleware to it by default. This avoids a potential backwards breaking
// change with plugins like `mongoose-delete` that use statics to overwrite
// built-in Mongoose functions.
if (middlewareFunctions.indexOf(hook.name) !== -1) {
return !!hook.model;
}
return hook.model !== false;
});
model.$__insertMany = hooks.createWrapper('insertMany',
model.$__insertMany, model, kareemOptions);
for (const key of Object.keys(statics)) {
if (hooks.hasHooks(key)) {
const original = model[key];
model[key] = function() {
const numArgs = arguments.length;
const lastArg = numArgs > 0 ? arguments[numArgs - 1] : null;
const cb = typeof lastArg === 'function' ? lastArg : null;
const args = Array.prototype.slice.
call(arguments, 0, cb == null ? numArgs : numArgs - 1);
// Special case: can't use `Kareem#wrap()` because it doesn't currently
// support wrapped functions that return a promise.
return promiseOrCallback(cb, callback => {
hooks.execPre(key, model, args, function(err) {
if (err != null) {
return callback(err);
}
let postCalled = 0;
const ret = original.apply(model, args.concat(post));
if (ret != null && typeof ret.then === 'function') {
ret.then(res => post(null, res), err => post(err));
}
function post(error, res) {
if (postCalled++ > 0) {
return;
}
if (error != null) {
return callback(error);
}
hooks.execPost(key, model, [res], function(error) {
if (error != null) {
return callback(error);
}
callback(null, res);
});
}
});
}, model.events);
};
}
}
};
- applyStaticHooks 메서드는 모델의 스키마에 정의된 static 메서드의 hook을 적용하는 메서드입니다.
- static 메서드란 스키마에서 사용자 커스텀으로 사용할 수 있는 메서드 입니다.
- mongoose에서 기본적으로 지원하는 메서드들이 있습니다. ex) user.findOne()
- 커스텀해서 선언해서 자유롭게 사용할 수 도 있습니다. ex) user.findOneByEmailAndName()
- 이러한 applyStaticHooks 메서드는 model.js의 compile 메서드에서 호출되는데요, 해당 메서드에서는 사용자가 정의한 스키마 정보를 토대로 실제 모델을 컴파일(제작)합니다.
model.js
Model.compile = function compile(name, schema, collectionName, connection, base) {
const versioningEnabled = schema.options.versionKey !== false;
...
model.hooks = schema.s.hooks.clone();
model.base = base;
model.modelName = name;
...
model.prototype.$__setSchema(schema);
// apply methods and statics
applyMethods(model, schema);
applyStatics(model, schema);
applyHooks(model, schema);
applyStaticHooks(model, schema.s.hooks, schema.statics);
model.schema = model.prototype.$__schema;
model.collection = model.prototype.collection;
model.$__collection = model.collection;
// Create custom query constructor
model.Query = function() {
Query.apply(this, arguments);
};
model.Query.prototype = Object.create(Query.prototype);
model.Query.base = Query.base;
applyQueryMiddleware(model.Query, model);
applyQueryMethods(model, schema.query);
return model;
};
- 해당 compile 메서드에서는 주어진 모델을 컴파일하는데요, 이번 과정에서는 스키마와 관련된 아래 메서드 호출들을 중점적으로 보겠습니다.
// apply methods and statics
applyMethods(model, schema); -> 1번
applyStatics(model, schema); -> 2번
applyHooks(model, schema); -> 3번
applyStaticHooks(model, schema.s.hooks, schema.statics); -> 4번
- 1번 -> 모델의 스키마에 정의된 메서드를 적용합니다.
- 2번 -> 모델의 스키마에 정의된 static 메서드를 적용합니다.
- 3번 -> 모델의 스키마에 정의된 훅들을 적용합니다.
- 4번 -> 모델의 스키마에 정의된 static 메서드의 훅들을 적용합니다.
applyStaticHooks 분석
- applyStaticHooks 메서드를 크게 두 부분으로 나누어서 분석하였습니다.
module.exports = function applyStaticHooks(model, hooks, statics) {
const kareemOptions = {
useErrorHandlers: true,
numCallbackParams: 1
};
hooks = hooks.filter(hook => {
// If the custom static overwrites an existing query middleware, don't apply
// middleware to it by default. This avoids a potential backwards breaking
// change with plugins like `mongoose-delete` that use statics to overwrite
// built-in Mongoose functions.
if (middlewareFunctions.indexOf(hook.name) !== -1) {
return !!hook.model;
}
return hook.model !== false;
});
model.$__insertMany = hooks.createWrapper('insertMany',
model.$__insertMany, model, kareemOptions);
};
- 첫번째로, hooks 매개변수는 schema.s.hooks로 스키마에 정의된 훅들입니다.
- 스키마에서 pre/post 훅으로 걸었던 훅들이 모두 저장됩니다.
- 두번째로, statics 매개변수는 schema.statics로 스키마에 정의된 static 메서드들입니다.
- 스키마에서 사용자 정의로 정의한 static 메서드들이 모두 저장됩니다.
- hooks.filter 하는 부분이 있는데요, 해당 부분을 통해 사용자 정의로 정의한 static 메서드들 중에서 middlewareFunctions과 이름이 동일하면 제외합니다.
- middlewareFunctions의 종류는 아래와 같습니다.
[ // Read 'countDocuments', 'distinct', 'estimatedDocumentCount', 'find', 'findOne', // Update 'findOneAndReplace', 'findOneAndUpdate', 'replaceOne', 'updateMany', 'updateOne', // Delete 'deleteMany', 'deleteOne', 'findOneAndDelete', // etc 'validate', ]
필터를 하는 이유
- 주석을 읽어보면 이유는 다음과 같습니다.
- "사용자가 정의한 정적 메서드가 기존의 쿼리 미들웨어를 덮어쓰는 경우, 기본적으로 그 미들웨어를 적용하지 않는다. mongoose-delete와 같은 플러그인이 정적 메서드를 사용하여 Mongoose의 기본 함수를 덮어쓸 때 발생할 수 있는 호환성 문제를 피하기 위한 것"
- 즉, 커스터마이징된 정적 메서드가 기존의 미들웨어와 충돌하지 않도록 하기 위한 조치입니다.
- 현재 추적하려는 에러의 가장 큰 이유로 보입니다.
- query 메서드의 경우 필터링을 해서 덮어쓰여지지 않지만, aggregate에 대해서는 필터가 되지 않아서 덮어쓰여지는 이슈일 가능성이 높아졌습니다.
module.exports = function applyStaticHooks(model, hooks, statics) {
...
for (const key of Object.keys(statics)) {
if (hooks.hasHooks(key)) {
const original = model[key];
model[key] = function() {
const numArgs = arguments.length;
const lastArg = numArgs > 0 ? arguments[numArgs - 1] : null;
const cb = typeof lastArg === 'function' ? lastArg : null;
const args = Array.prototype.slice.
call(arguments, 0, cb == null ? numArgs : numArgs - 1);
// Special case: can't use `Kareem#wrap()` because it doesn't currently
// support wrapped functions that return a promise.
return promiseOrCallback(cb, callback => {
hooks.execPre(key, model, args, function(err) {
if (err != null) {
return callback(err);
}
let postCalled = 0;
const ret = original.apply(model, args.concat(post));
if (ret != null && typeof ret.then === 'function') {
ret.then(res => post(null, res), err => post(err));
}
function post(error, res) {
if (postCalled++ > 0) {
return;
}
if (error != null) {
return callback(error);
}
hooks.execPost(key, model, [res], function(error) {
if (error != null) {
return callback(error);
}
callback(null, res);
});
}
});
}, model.events);
};
}
}
};
- 해당 부분에서는 사용자 정의 정적 메서드에 일치하는 hook이 있다면 해당 정적 메서드에 훅을 적용합니다.
- 가령 아래와 같이 스키마를 작성한다면 새로 적용됩니다.
schema.statics.test = function() {
console.log('test static method');
};
schema1.pre('test', function(next) {
console.log('test static method pre hook');
next();
});
applyStaticHooks 테스트 - mongoose 버전에 따른 애플리케이션 작동 방식
-
"query 메서드의 경우 필터링을 해서 덮어쓰여지지 않지만, aggregate에 대해서는 필터가 되지 않아서 덮어쓰여지는 이슈일 가능성이 높아졌다" 라고 위에서 언급 해두었는데요, 해당 필터링이 애플리케이션에서 어떻게 동작하는지 버전별로 확인해보려고 합니다.
-
applyStaticHooks의 매개변수로 들어오는 스키마의 hooks와 statics를 살펴보려고 합니다. 특히 hooks는 필터 로직 이후에 어떻게 구성되는지 알아보려고 합니다.
-
가장 위에 있는 기준코드를 살펴보면 알수 있듯이 현재 기준 스키마에는 aggregate에 대한 pre 훅이 걸려있는 상태이며, 따로 정적 메서드는 정의하지 않은 상황입니다.
-
버전에 따른 실행 결과는 아래와 같습니다.
mongoose v8.5.0
{
hooks: Kareem {
_pres: Map(4) {
'save' => [Array],
'count' => [Array],
'aggregate' => [Array],
'remove' => [Array]
},
_posts: Map(2) { 'save' => [Array], 'init' => [Array] }
}
}
{
statics: {
find: [Function: find],
findOne: [Function: findOne],
updateOne: [Function: updateOne],
updateMany: [Function: updateMany],
deleteOne: [Function: deleteOne],
deleteMany: [Function: deleteMany],
findOneAndUpdate: [Function: findOneAndUpdate],
findOneAndDelete: [Function: findOneAndDelete],
distinct: [Function: distinct],
create: [Function: create],
insertMany: [Function: insertMany],
countDocuments: [Function: countDocuments],
aggregate: [Function: aggregate]
}
}
- aggregate는 필터 대상이 아니기 때문에 aggregate 이름으로 된 정적 메서드의 훅의 경우는 커스텀으로 취급된다. 따라서 해당 정적 메서드에 대해서 pre/post 훅이 적용됩니다.
- 따라서 해당 버전에서는 aggregate라는 이름을 가진 정적 메서드에 대한 pre/post 훅이 적용되기 때문에, pre 훅 컨텍스트에서 쓰이는 this 클래스가 Aggregate가 아닌 Model 클래스가 됩니다.
- statics의 경우 스키마에 정의한 정적 메서드들 입니다. 기준 코드에서는 정적 메서드를 작성하지 않았기 때문에 아무런 statics가 있으면 안됩니다.
- 이는 애플리케이션 단에서 정의된 다른 전역 플러그인 때문에 발생한 현상으로, 아래에 추후 서술하도록 하겠습니다.
mongoose v5.13.14
{
hooks: Kareem {
_pres: Map(2) { 'save' => [Array], 'aggregate' => [Array] },
_posts: Map(2) { 'save' => [Array], 'init' => [Array] }
}
}
{ statics: {} }
- aggregate는 필터 대상이 아니기 때문에 aggregate 이름으로 된 정적 메서드의 훅의 경우는 커스텀으로 취급된다.
- 다만 statics는 비어있기 때문에, hook이 따로 정적 메서드에 적용되지는 않는다.
- 따라서 해당 버전에서는 aggregate라는 이름을 가진 정적 메서드에 대한 pre/post 훅이 적용되지 않기 때문에, pre 훅 컨텍스트에서 쓰이는 this 클래스가 Aggregate가 됩니다.
- statics가 비어있는데, 이는 애플리케이션 단에서 정의된 다른 전역 플러그인 때문에 발생한 현상으로 이 또한 아래에 추후 서술하도록 하겠습니다.
버전에 따라서 왜 static이 추가되었는가?
- statics가 왜 다르게 추가되는지에 대한 의문을 가지고 mongoose 코드와 애플리케이션 코드를 모두 살펴보았습니다.
- 해당 이유는 결국 애플리케이션단에서 작성한 코드에 있었습니다. mongoose를 5에서 8로 마이그레이션 할 당시 팀원분께서 작성해주신 플러그인의 영향이 컸습니다.
- 마이그레이션 당시 5버전 이상일 경우 콜백을 지원하지 않기 때문에, 모든 mongoose 메서드에 대해서 정적 메서드로 콜백 아닌 형태로 재정의하는 플러그인이 작성되었습니다.
- 코드는 아래와 같습니다.
const mongoose = require('mongoose');
const { deprecate } = require('util');
const { Model, Query, Aggregate } = mongoose;
const mongooseMajorVersion = +mongoose.version[0]; // 4, 5...
class StaticMethod {
// find 메서드에 대한 재정의
static find(conditions, projection, options, callback) {
let _callback;
let _projection;
let _options;
const modelName = this.modelName;
try {
if (typeof projection === 'function') {
_callback = projection;
_projection = undefined;
_options = undefined;
} else if (typeof options === 'function') {
_callback = options;
_projection = projection;
_options = undefined;
} else if (typeof callback === 'function') {
_callback = callback;
_projection = projection;
_options = options;
} else {
_callback = undefined;
_projection = projection;
_options = options;
}
const query = Model.find.apply(this, [conditions, _projection, _options]);
if (_callback) {
return query
.then((result) =>
myDeprecate(_callback, modelName, 'find')(null, result),
)
.catch((err) => myDeprecate(_callback, modelName, 'find')(err, null));
}
return query;
} catch (err) {
if (_callback) {
return myDeprecate(_callback, modelName, 'find')(err, null);
}
throw err;
}
}
...
// aggregate 메서드에 대한 재정의
static aggregate(pipeline, options, callback) {
let _callback;
let _options;
const modelName = this.modelName;
try {
if (typeof options === 'function') {
_callback = options;
_options = undefined;
} else if (typeof callback === 'function') {
_callback = callback;
_options = options;
} else {
_callback = undefined;
_options = options;
}
const query = Model.aggregate.apply(this, [pipeline, _options]);
if (_callback) {
return query
.then((result) =>
myDeprecate(_callback, modelName, 'aggregate')(null, result),
)
.catch((err) =>
myDeprecate(_callback, modelName, 'aggregate')(err, null),
);
}
return query;
} catch (err) {
if (_callback) {
return myDeprecate(_callback, modelName, 'aggregate')(err, null);
}
throw err;
}
}
}
module.exports = function (schema) {
// mongoose 6버전 이후 부터 적용
if (mongooseMajorVersion >= 6) {
schema.loadClass(StaticMethod);
...
}
};
- mongoose 6버전 이상을 사용할 경우 해당 플러그인이 활성화됩니다.
- mongoose에서 제공하는 기본 메서드들인 find, findOne, aggregate...등을 정적 메서드(static method)로 재정의합니다.
- 재정의 되었지만, 래핑되었을 뿐 기능은 같습니다. (정적 메서드에서 다시 기존 메서드를 호출)
- 따라서 예를 들어 애플리케이션 단에서 aggregate 메서드를 사용한다면 mongoose 메서드가 아닌 여기서 정의된 정적 메서드가 호출됩니다.
- 해당 정적 메서드가 다시 mongoose aggregate 메서드를 호출하여 기능하도록 설계되어있습니다.
중간정리
- mongoose 버전 차이(소스 코드)의 차이점으로 인해 작동방식이 다른 것이 아니였습니다.
- 마이그레이션을 위해 작성한 전역 플러그인으로 인해 6버전 이상부터 모든 모델(스키마)에 기존 mongoose 기본 메서드들의 이름을 가진 정적 메서드들이 정의되었습니다.
- 따라서 이제부터는 mongoose 버전의 차이에 대한 가설은 기각하고, 정적 메서드 적용에 따른 작동 방식에 집중해서 알아보겠습니다.
간단한 코드 예시로 확인하는 문제점
- 위에서 명시한 플러그인을 적용하면서, 8버전 부터 applyStaticHooks 메서드로 인해서 다음과 같은 현상이 발생하였습니다.
- 해당 플러그인으로 인해서 기준코드는 사실상 아래와 같이 선언된 것으로 보아야합니다.
작성한 기준코드
schema.pre('aggregate', function (next) {
const firstMatchIndex = this.pipeline().findIndex((item) => item.$match);
if (firstMatchIndex !== -1) {
this.pipeline()[firstMatchIndex].$match.deleted = {
$ne: true,
};
} else {
this._pipeline = [{ $match: { deleted: { $ne: true } } }].concat(
this.pipeline()
);
}
this.append({ $project: { deleted: 0 } });
this.pipeline().forEach((_item, index) => {
const item = { ..._item };
const isLookup = item.$lookup;
if (isLookup) {
const updatedLookup = {
...item.$lookup,
pipeline: [{ $match: { deleted: { $ne: true } } }]
.concat(item.$lookup.pipeline || [])
.concat([{ $project: { deleted: 0 } }]),
};
this.pipeline()[index].$lookup = updatedLookup;
}
});
next();
});
schema.pre(
[
'count',
'countDocuments',
'find',
'findOne',
'findOneAndUpdate',
'updateOne',
'updateMany',
'distinct',
],
function skipDeleted(next) {
console.log("1.this: ", this);
this.where('deleted').ne(true);
next();
}
);
작성한 기준코드 (실제 작동)
// aggregate라는 이름을 가진 정적 메서드
// 기존 aggregate 메서드를 래핑함
schema.statics.aggregate = function(pipeline) {
...
const query = Model.aggregate.apply(this, [pipeline]);
return query;
};
// findOne이라는 이름을 가진 정적 메서드
// 기존 findOne 메서드를 래핑함
schema.statics.findOne = function () {
const query = Model['findOne'].apply(this, arguments);
return query;
};
schema.pre('aggregate', function (next) {
const firstMatchIndex = this.pipeline().findIndex((item) => item.$match);
if (firstMatchIndex !== -1) {
this.pipeline()[firstMatchIndex].$match.deleted = {
$ne: true,
};
} else {
this._pipeline = [{ $match: { deleted: { $ne: true } } }].concat(
this.pipeline()
);
}
this.append({ $project: { deleted: 0 } });
this.pipeline().forEach((_item, index) => {
const item = { ..._item };
const isLookup = item.$lookup;
if (isLookup) {
const updatedLookup = {
...item.$lookup,
pipeline: [{ $match: { deleted: { $ne: true } } }]
.concat(item.$lookup.pipeline || [])
.concat([{ $project: { deleted: 0 } }]),
};
this.pipeline()[index].$lookup = updatedLookup;
}
});
next();
});
schema.pre(
[
'count',
'countDocuments',
'find',
'findOne',
'findOneAndUpdate',
'updateOne',
'updateMany',
'distinct',
],
function skipDeleted(next) {
console.log("1.this: ", this);
this.where('deleted').ne(true);
next();
}
);
- 해당 스키마에서 applyStaticHooks가 어떻게 작동하는지에 대해서 생각해보면 다음과 같습니다.
- filter 로직으로 인해서 아래와 같은 결과가 발생합니다.
- query middleware 이름을 가진 - 예를 들어 find, findOne과 같은 이름을 가진 정적 메서드들의 경우 필터가 되어서 pre/post 훅을 붙여도 정적 메서드에 관해서는 해당 훅이 적용되지 않습니다.
- 이외의 이름을 가진 - 예를 들어 aggregate과 같은 이름을 가진 정적 메서드의 경우 필터가 되지 않아서 pre/post 훅을 붙이면 정적 메서드에 관해서 해당 훅이 적용됩니다.
- filter 로직으로 인해서 아래와 같은 결과가 발생합니다.
- 여기에서 문제가 발생합니다. 정적 메서드 / 기존 mongoose 메서드에 대해서 둘다 hook이 호출된다는 점입니다.
schema.statics.aggregate = function(pipeline) {
...
const query = Model.aggregate.apply(this, [pipeline]);
return query;
};
schema.pre('aggregate', function (next) {});
- 위에서 정의된 것과 같이 정적 메서드를 작성하면 어떻게 될까요?
- pre훅에 정의된 aggregate는 이것이 정적 메서드를 호출을 의도하는 것인지 / 기존 메서드를 호출을 의도하는 것인지 구분할 수 없습니다.
- 애플리케이션 단에서 aggregate 메서드를 사용하면 정의한 정적 메서드가 호출될 것입니다.
- 이때 첫번째로 pre 훅이 호출됩니다. 정적 메서드에 대한 pre 훅이 호출되어서 pre 훅 context내 this는 Aggregate 클래스가 아닌 Model 클래스입니다.
- 이제 정적 메서드가 호출되면서 다시 기존 메서드가 호출됩니다. (Model.aggregate.apply(this, [pipeline]))
- 이때 두번째로 pre 훅이 다시 호출됩니다. 기존 메서드에 대한 pre 호출되어서 pre 훅 context내 this는 Aggregate 클래스 입니다.
schema.statics.findOne = function () {
const query = Model['findOne'].apply(this, arguments);
return query;
};
schema.pre('findOne', function (next) {});
- 이에 반해서 위에서 정의된 것과 같이 정적 메서드를 작성하면 어떻게 될까요?
- 동일하게 pre훅에 정의된 findOne는 이것이 정적 메서드를 호출을 의도하는 것인지 / 기존 메서드를 호출을 의도하는 것인지 구분할 수 없습니다.
- 애플리케이션 단에서 findOne 메서드를 사용하면 정의한 정적 메서드가 호출될 것입니다.
- 이때는 첫번째로 pre 훅이 호출되지 않습니다. 왜냐하면 applyStaticHooks 메서드에서 query middleware에 대해서는 필터링을 하기 때문입니다.
- 이제 정적 메서드가 호출되면서 다시 기존 메서드가 호출됩니다. (Model['findOne'].apply(this, arguments))
- 이때 두번째로 pre 훅이 다시 호출됩니다. 기존 메서드에 대한 pre 호출되어서 pre 훅 context내 this는 Query 클래스 입니다.
결론
- applyStaticHooks 메서드에서 query middleware의 이름을 가진 정적 메서드들에 대해서만 필터링을 진행하고, 나머지 mongoose 기본 메서드에 대해서는 필터링을 하지 않습니다.
- 따라서 해당 메서드들에 한해서 statics를 활용해서 재정의하게 된다면 pre/post 훅이 의도와는 다르게 작동할 수 있습니다.
고민점 및 해결방안
- mongoose 메서드를 재정의 하는 방식이 잘못된 사용 방식이 아닐까? 라는 고민을 하였습니다.
- 다만 mongoose-soft-delete 등 플러그인에서 자주 사용하는 방식이고, 이미 코드단에서 이를 고려하고 query middleware 이름을 가진 정적 메서드에 관해서는 예외적으로 충돌이 나지 않게 훅이 등록되지 않도록 필터링을 하고 있습니다.
- 실제로 mongoose-soft-delete 다른 메서드들은 재정의합니다. 하지만 aggregate에 대해서는 필터링을 하지 않고 우회하는 방식으로 사용하고 있습니다.
- aggregate 또한 충돌이 나지 않게 조치가 되면 해당 플러그인에서도 똑같이 재정의해서 사용할 수 있습니다.
- 따라서, 모든 mongoose 메서드와 동일한 이름을 가진 정적 메서드에 대해서 훅을 등록하는데 있어서 충돌이 나지 않도록 필터링을 해주는 기능이 필요하다고 생각합니다.
- 그래야 안정적으로 기존 메서드들에게 영향을 끼치지 않으면서 정적 메서드들을 자유롭게 사용할 수 있기 때문입니다.
3. 재현 코드 작성하기, 확인해보기
- 설명과 과정이 너무 방대하고 길었습니다.
- 해당 동작을 재현하기 위해서 간단한 프로젝트를 하나 작성하였습니다.
GitHub - dragontaek-lee/mongoose-staticHooks-overwrite-support-more-cases: Minimum reproduction code for how Mongoose handles static methods that overwrite built-in functions triggers mongoose pre / post hooks
Minimum reproduction code for how Mongoose handles static methods that overwrite built-in functions triggers mongoose pre / post hooks - dragontaek-lee/mongoose-staticHooks-overwrite-support-more-c...
index.js - 첫번째 재현 테스트
console.log(colors.blue.bold('Running Test 1: Both the custom static method named aggregate and the built-in Mongoose aggregate function hooks are triggered.'));
const schema1 = new Schema({ name: String, deleted: Boolean });
let calledPre1 = 0;
let calledPost1 = 0;
/*
The custom static method (aggregate) overwrites an existing aggregate function.
mocked implementation of 'mongoose-delete' plugin which overwrites built-in Mongoose's functions.
*/
schema1.statics.aggregate = function(pipeline) {
let match = { $match: { deleted: { '$ne': false } } };
if (pipeline.length && pipeline[0].$match) {
pipeline[0].$match.deleted = { '$ne': false };
} else {
pipeline.unshift(match);
}
const query = Model.aggregate.apply(this, [pipeline]);
return query;
};
schema1.pre('aggregate', function(next) {
calledPre1++;
next();
});
schema1.post('aggregate', function() {
calledPost1++;
});
const Model1 = mongoose.model('Test_aggregate', schema1);
await Model1.create({ name: 'foo', deleted: true });
const res1 = await Model1.aggregate([
{
$match: {
name: 'foo'
}
}
]);
assert.ok(res1);
/*
The static hooks for the static method named 'aggregate' are not filtered.
because, in the [mongoose] lib/helpers/model/applyStaticHooks.js file,
it only filters hooks with names matching the queryMiddlewareFunctions.
Thus, both the custom static method named aggregate and the built-in Mongoose aggregate hooks are triggered.
*/
assert.equal(calledPre1, 2, 'Expected pre-aggregate hook to be called twice');
assert.equal(calledPost1, 2, 'Expected post-aggregate hook to be called twice');
console.log(colors.green.bold('Test 1 completed ✔️'));
- aggregate 메서드를 정적 메서드로 래핑해두었습니다.
- 이때 pre/post 훅이 어떻게 작동하는지 확인합니다.
- 분석한대로, pre/post 훅은 각각 두번씩 호출됩니다.
- 첫번째는 정적 메서드로 정의된 aggregate 메서드 호출시 호출됩니다.
- 두번째는 정적 메서드 안에서 정의된 기존 aggregate 메서드 호출시 호출됩니다.
첫번째 테스트 결과
index.js - 두번째 재현 테스트
console.log(colors.blue.bold('Running Test 2: Both the custom static method named aggregate and the built-in Mongoose aggregate function hooks are triggered, and their this contexts are different.'));
const schema2 = new Schema({ name: String, deleted: Boolean });
let calledPre2 = 0;
let calledPost2 = 0;
schema2.statics.aggregate = function(pipeline) {
let match = { $match: { deleted: { '$ne': false } } };
if (pipeline.length && pipeline[0].$match) {
pipeline[0].$match.deleted = { '$ne': false };
} else {
pipeline.unshift(match);
}
const query = Model.aggregate.apply(this, [pipeline]);
return query;
};
/*
The static hooks for the static method named 'aggregate' are not filtered,
so it is called twice, as shown in Test 1.
As a result, when the static 'aggregate' method is called the this context is not Aggregate.
However, since the static 'aggregate' method overwrites the built-in aggregate function and calls it internally,
the this context is Aggregate the second time it is invoked.
[Call Sequence]
pre('aggregate' (static)) → pre('aggregate' (built-in)) → post('aggregate' (built-in)) → pre('aggregate' (static))
*/
schema2.pre('aggregate', function(next) {
calledPre2++;
if (calledPre2 === 1) {
assert.equal(this instanceof Aggregate, false, colors.red('Expected this to not be an Aggregate instance in first pre hook'));
} else if (calledPre2 === 2) {
assert.equal(this instanceof Aggregate, true, colors.red('Expected this to be an Aggregate instance in second pre hook'));
}
next();
});
schema2.post('aggregate', function() {
calledPost2++;
if (calledPost2 === 1) {
assert.equal(this instanceof Aggregate, true, colors.red('Expected this to be an Aggregate instance in first post hook'));
} else if (calledPost2 === 2) {
assert.equal(this instanceof Aggregate, false, colors.red('Expected this to not be an Aggregate instance in second post hook'));
}
});
const Model2 = mongoose.model('Test_aggregate_2', schema2);
await Model2.create({ name: 'foo', deleted: true });
const res2 = await Model2.aggregate([
{
$match: {
name: 'foo'
}
}
]);
assert.ok(res2);
console.log(colors.green.bold('Test 2 completed ✔️'));
- 동일하게 aggregate 메서드를 정적 메서드로 래핑해두으며, 이때 pre/post 훅이 어떻게 작동하는지 확인합니다.
- 분석한대로, pre/post 훅은 각각 두번씩 호출됩니다.
- pre 훅의 경우
- 첫번째는 정적 메서드로 정의된 aggregate 메서드 호출시 호출됩니다. 따라서 this context는 Aggregate가 아닌 Model입니다.
- 두번째는 정적 메서드 안에서 정의된 기존 aggregate 메서드 호출시 호출됩니다. 따라서 this context는 Aggregate입니다.
- post 훅의 경우
- 첫번째는 정적 메서드 안에서 정의된 기존 aggregate 메서드 호출시 호출됩니다. 따라서 this context는 Aggregate입니다.
- 두번째는 정적 메서드로 정의된 aggregate 메서드 호출시 호출됩니다. 따라서 this context는 Aggregate가 아닌 Model입니다.
flowchart TD
A{정적 메서드 호출} --> E[pre - 정적 메서드]
E --> F{기존 메서드 호출}
F --> B[pre - 기존 메서드]
B --> G{기존 메서드 호출 종료}
G --> C[post - 기존 메서드]
C --> H{정적 메서드 호출 종료}
H --> D[post - 정적 메서드]
두번째 테스트 결과
4. 코드 수정
- 재현 코드를 작성하였으니 문제 해결을 위해서 코드를 작성해보겠습니다.
- 코드는 간단합니다. applyStaticHooks에서 필터링하는 범위를 넓히는 코드를 추가하였습니다.
constants.js
코드 수정 전
'use strict';
/*!
* ignore
*/
const queryOperations = Object.freeze([
// Read
'countDocuments',
'distinct',
'estimatedDocumentCount',
'find',
'findOne',
// Update
'findOneAndReplace',
'findOneAndUpdate',
'replaceOne',
'updateMany',
'updateOne',
// Delete
'deleteMany',
'deleteOne',
'findOneAndDelete'
]);
exports.queryOperations = queryOperations;
/*!
* ignore
*/
const queryMiddlewareFunctions = queryOperations.concat([
'validate'
]);
exports.queryMiddlewareFunctions = queryMiddlewareFunctions;
코드 수정 후
'use strict';
/*!
* ignore
*/
const queryOperations = Object.freeze([
// Read
'countDocuments',
'distinct',
'estimatedDocumentCount',
'find',
'findOne',
// Update
'findOneAndReplace',
'findOneAndUpdate',
'replaceOne',
'updateMany',
'updateOne',
// Delete
'deleteMany',
'deleteOne',
'findOneAndDelete'
]);
exports.queryOperations = queryOperations;
/*!
* ignore
*/
const queryMiddlewareFunctions = queryOperations.concat([
'validate'
]);
exports.queryMiddlewareFunctions = queryMiddlewareFunctions;
/*!
* ignore
*/
const aggregateMiddlewareFunctions = [
'aggregate'
];
exports.aggregateMiddlewareFunctions = aggregateMiddlewareFunctions;
/*!
* ignore
*/
const modelMiddlewareFunctions = [
'bulkWrite',
'createCollection',
'insertMany'
];
exports.modelMiddlewareFunctions = modelMiddlewareFunctions;
- query middleware 뿐만 아니라 다른 middleware도 적용할 수 있도록 상수를 추가하였습니다.
- 추가한 middleware는 aggregate middleware / modelMiddleware입니다.
- query middleware는 이미 존재하고, document middleware은 다른 파일에 명시되어 있습니다.
applyStaticHooks.js
코드 수정 전
'use strict';
const middlewareFunctions = require('../../constants').queryMiddlewareFunctions;
const promiseOrCallback = require('../promiseOrCallback');
module.exports = function applyStaticHooks(model, hooks, statics) {
const kareemOptions = {
useErrorHandlers: true,
numCallbackParams: 1
};
hooks = hooks.filter(hook => {
// If the custom static overwrites an existing query middleware, don't apply
// middleware to it by default. This avoids a potential backwards breaking
// change with plugins like `mongoose-delete` that use statics to overwrite
// built-in Mongoose functions.
if (middlewareFunctions.indexOf(hook.name) !== -1) {
return !!hook.model;
}
return hook.model !== false;
});
model.$__insertMany = hooks.createWrapper('insertMany',
model.$__insertMany, model, kareemOptions);
for (const key of Object.keys(statics)) {
if (hooks.hasHooks(key)) {
const original = model[key];
...
}
};
코드 수정 후
'use strict';
const promiseOrCallback = require('../promiseOrCallback');
const { queryMiddlewareFunctions, aggregateMiddlewareFunctions, modelMiddlewareFunctions } = require('../../constants');
const documentMiddlewareFunctions = require('../model/applyHooks').middlewareFunctions;
const middlewareFunctions = [
...[
...queryMiddlewareFunctions,
...aggregateMiddlewareFunctions,
...modelMiddlewareFunctions,
...documentMiddlewareFunctions
].reduce((s, hook) => s.add(hook), new Set())
];
module.exports = function applyStaticHooks(model, hooks, statics) {
const kareemOptions = {
useErrorHandlers: true,
numCallbackParams: 1
};
model.$__insertMany = hooks.createWrapper('insertMany',
model.$__insertMany, model, kareemOptions);
hooks = hooks.filter(hook => {
// If the custom static overwrites an existing query middleware, don't apply
// middleware to it by default. This avoids a potential backwards breaking
// change with plugins like `mongoose-delete` that use statics to overwrite
// built-in Mongoose functions.
if (middlewareFunctions.indexOf(hook.name) !== -1) {
return !!hook.model;
}
return hook.model !== false;
});
for (const key of Object.keys(statics)) {
if (hooks.hasHooks(key)) {
const original = model[key];
...
}
};
- 모든 mongoose middleware에 대해 필터링하도록 로직을 수정하였습니다.
const middlewareFunctions = [
...[
...queryMiddlewareFunctions,
...aggregateMiddlewareFunctions,
...modelMiddlewareFunctions,
...documentMiddlewareFunctions
].reduce((s, hook) => s.add(hook), new Set())
];
- middleware중에서 validate과 같이 중복되는 값이 있을 수 있기 때문에 Set을 두었습니다.
5. 이어가며
- 해당 수정 이후 애플리케이션 단에서 테스트를 진행해보았을 때 해당 이슈가 사라지는 것을 확인하였습니다.
- 따라서 해당 수정에 맞게 테스트 케이스를 작성하려고 합니다.
- 해당 수정이 기존 기능에 영향을 미치지는 않습니다. (테스트 케이스 모두 통과)
- 따라서 Issue, PR을 작성해서 올릴 예정이고 다음 글에서는 이러한 과정에 대해서 기록해보려고 합니다.
