mongoose pre hook 에러 추적기 (1)

1. 계기


  • 사내 프로덕트를 운영하면서 배포를 진행한 순간 1년동안 문제없이 작동하던 코드에서 오류가 발생하였습니다.
  • 최근 mongoose 버전을 5에서 8로 올리며 바뀐 기능들을 마이그레이션 하였는데요, 해당 작업으로 인해 문제가 발생한 것으로 파악하고 문제를 한번 분석하기 시작하였습니다.

오류 상황


errorImg

  • 다음과 같이 기존에 사용하던 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 과 유사한 기능을 합니다.
GitHub - dsanel/mongoose-delete: Mongoose Soft Delete Plugin
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


mong3 mong2

2.2.1 lib/helpers/model/applyStaticHooks.js


8.5.0

mongoose/lib/helpers/model/applyStaticHooks.js at 8.5.0 · Automattic/mongoose
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
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
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
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.jscompile 메서드에서 호출되는데요, 해당 메서드에서는 사용자가 정의한 스키마 정보를 토대로 실제 모델을 컴파일(제작)합니다.

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 훅을 붙이면 정적 메서드에 관해서 해당 훅이 적용됩니다.
  • 여기에서 문제가 발생합니다. 정적 메서드 / 기존 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
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 메서드 호출시 호출됩니다.

첫번째 테스트 결과


image

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 - 정적 메서드]

두번째 테스트 결과


image

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을 작성해서 올릴 예정이고 다음 글에서는 이러한 과정에 대해서 기록해보려고 합니다.