본문으로 건너뛰기

Nest.js는 어떻게 순환 참조 문제를 해결할까?

· 약 24분
Jake Son

이전 포스트에서는 Nest.js의 injector 구현을 살펴보았다.
injector가 특정 인스턴스의 의존성 타입을 알아내는 방법에 대한 글이었는데, 이번에는 injector를 구현하면서 발생했던 이슈에 대해 살펴보려고 한다.

순환 참조 의존성

순환 참조란 두 개 이상의 요소가 끝이 없는 순환을 이루는 참조 관계를 말한다.
즉 A, B 두 요소가 있을 때 A가 B를 참조하고, B가 A를 참조하는 상황을 말한다.
Nest.js에서 순환 참조는 module과 provider 사이에서 발생한다.

이번 포스트에서는 provider 사이에서 발생하는 순환 참조에 대해 살펴보려고 한다.

다음과 같은 두 개의 provider가 있다고 가정해보자.

a.service.ts
@Injectable()
export class A {
constructor(private readonly b: B) {}
}
b.service.ts
@Injectable()
export class B {
constructor(private readonly a: A) {}
}

두 provider를 하나의 module에 등록하고 Nest.js 애플리케이션을 실행하면 다음과 같은 에러가 발생한다.

import { A } from "./a.service";
import { B } from "./b.service";

@Module({
providers: [A, B],
})
export class AppModule {}
  LOG [InjectorLogger] Nest encountered an undefined dependency. This may be due to a circular import or a missing dependency declaration.
ERROR [ExceptionHandler] Nest can't resolve dependencies of the B (?). Please make sure that the argument dependency at index [0] is available in the AppModule context.
정보

AppModule 파일에서 A와 B의 import 순서에 따라 에러 메시지가 달라진다.
A를 먼저 import하면 B에 대한 의존성 에러 메시지가 나오고, B를 먼저 import하면 A에 대한 의존성 에러 메시지가 나온다.

위와 같은 순환 참조를 만들지 않는 것이 좋은 설계이지만, 피할 수 없는 경우가 있을 수 있으므로 Nest.js는 이를 해결할 수 있는 방법을 제공한다.

forwardRef

공식문서를 보면 forwardRef를 사용해 순환 참조 문제를 해결할 수 있다고 나와있다.

@Injectable()
export class A {
constructor(@Inject(forwardRef(() => B)) private readonly b: B) {}
}

@Injectable()
export class B {
constructor(@Inject(forwardRef(() => A)) private readonly a: A) {}
}

forwardRef에 뭔가 특별한 기능이 있는 것처럼 보이지만, 실제 코드를 살펴보면 굉장히 단순한 로직을 가지고있다.

export interface ForwardReference<T = any> {
forwardRef: T;
}

export const forwardRef = (fn: () => any): ForwardReference => ({
forwardRef: fn,
});

지금까지 살펴본 내용을 통해 다음과 같은 의문점이 생길 수 있다.

  • forwardRef는 왜 인자로 의존성을 반환하는 함수를 받는지
  • forwardRef의 구현은 단순한데, Nest.js는 이를 사용해 어떻게 순환 참조 문제를 해결하는가

에러가 발생하는 이유

forwardRef를 살펴보기 전에 순환 참조가 발생하면 Nest.js에서 에러가 발생하는지 알아보자.
Nest.js 저장소에서 에러 메시지로 출력된 This may be due to a circular import를 검색해보면 다음과 같은 코드를 찾을 수 있다.

packages/core/injector/injector.ts
  public async resolveSingleParam<T>(
wrapper: InstanceWrapper<T>,
param: Type<any> | string | symbol | any,
dependencyContext: InjectorDependencyContext,
moduleRef: Module,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
keyOrIndex?: symbol | string | number,
) {
if (isUndefined(param)) {
this.logger.log(
'Nest encountered an undefined dependency. This may be due to a circular import or a missing dependency declaration.',
);
throw new UndefinedDependencyException(
wrapper.name,
dependencyContext,
moduleRef,
);
}
// ...생략
}

resolveSingleParam 메서드에서 parmaundefined인 경우 에러를 발생시키는 것을 확인할 수 있다.

resolveSingleParam 메서드는 wrapper의 생성자 파라미터를 하나씩 순회하면서 의존성을 해결하는 메서드이다.
에러 발생 시점에서 wrapperB였지만 param에는 A가 아닌 undefined가 전달되었다는 것을 알 수 있다.

이전 포스트에서 injector는 design:paramtypes를 통해 생성자 파라미터의 타입을 알아내는 것을 살펴보았다.
따라서 순환 참조가 발생하는 경우 Bdesign:paramtypes에는 undefined가 들어갔다고 의심해볼 수 있다. 실제로 그런지 확인해보기 위해 두 클래스의 트랜스파일 결과물을 살펴보자.

tsconfig의 "module"을 "commonjs"로 설정하였고 일부 코드는 생략하였다.

a.service.js
exports.A = void 0;
const common_1 = require("@nestjs/common");
const b_service_1 = require("./b.service");
let A = class A {
b;
constructor(b) {
this.b = b;
}
};
A = __decorate(
[
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [b_service_1.B]),
],
A,
);
exports.A = A;
a.service.js
exports.B = void 0;
const common_1 = require("@nestjs/common");
const a_service_1 = require("./a.service");
let B = class B {
a;
constructor(a) {
this.a = a;
}
};
B = __decorate(
[
(0, common_1.Injectable)(),
// 올바르게 설정된 것이 아닌걸까?
__metadata("design:paramtypes", [a_service_1.A]),
],
B,
);
exports.B = B;

코드만 본다면 Bdesign:paramtypes에는 A가 올바르게 설정되어있는 것처럼 보인다.
하지만 런타임에서 해당값을 확인해보면 undefined가 출력된다.

console.log(Reflect.getMetadata("design:paramtypes", B.prototype)); // undefined

Node.js의 순환 참조

위와 같은 결과가 나오는 이유는 Node.js의 모듈 로딩 방식 때문이다.
Node.js는 두 파일간에 서로 require하는 상황에서 에러가 발생하는 대신 무한루프가 발생하지 않도록 특별한 동작을 한다.
예를 들면,앞선 예제에서 AppModule에서 A를 를 먼저 import 하고 B를 import는 상황에서 다음과 같이 동작한다.

  1. a.service.js를 실행한다.
  2. 중간에 require("./b.service")를 만나고 실행을 멈추고 b.service.js를 실행한다.
  3. b.service.js를 실행하면서 require("./a.service")를 만나고 실행을 멈추고 a.service.js를 실행한다.
  4. a.service.js는 기존에 이미 실행 중 멈춘 파일인데 이 경우 Node.js는 일단 {}를 반환한다.
  5. b.service.jsa_service_1에는 {}가 할당된다.
  6. __metadata("design:paramtypes", [a_service_1.A])을 실행한다.
  7. a_service_1.A{}이므로 design:paramtypes에는 undefined가 할당된다.
  8. b.service.js 실행이 끝나고 a.service.js로 돌아간다.
  9. __metadata("design:paramtypes", [b_service_1.B])을 실행한다.
  10. b_service_1.B는 정상적으로 로드된 값이므로 design:paramtypes에는 B가 할당된다.

위와 같은 작업을 완료한 이후 만약 다른 파일에서 a.server을 require하면, 그 때에는 정상적으로 로드된 모듈을 반환한다.
이를 통해 b.service.js가 실행한 시점에 a.service.js가 아직 완전히 로드가 되지 않았기에 발생하는 문제라는 것을 알 수 있다.
또한 import 순서에 따라 design:paramtypesundefined가 되는 클래스가 달라진다는 것도 알 수 있다.

지연 참조

위와 같은 이슈는 Node.js의 동작방식으로 인한것으므로 Nest.js가 해결할 수 있는 문제는 아니다.
따라서 Nest.js는 design:paramtypes를 통해 의존성을 확인하는 방식 외에 추가로 의존성을 지연 참조하는 방식을 제공한다.

이는 의존성을 Node.js가 파일들을 모두 로드한 이후에 의존성을 참조평가하는 방식으로 보통 의존성을 반환하는 함수를 사용해 구현한다.
즉 의존성을 반환하는 함수 () => A를 선언하고 Node.js가 A, B를 모두 로드한 이후에 해당 함수를 호출하면 A가 정상적으로 반환되는 것을 이용한다.
데코레이터 기반 Node.js 라이브러리를 보면 위와 같은 함수형태로 인자를 받는 경우를 종종 볼 수 있다.

  • TypeORM
@Entity()
export class Photo {
// User를 반환하는 함수를 사용
@ManyToOne(() => User, (user) => user.photos)
user: User;
}
  • TypeGraphQL
@ObjectType()
export class User {
// Post를 반환하는 함수를 사용
@Field(() => Post)
posts: Post;
}
  • Class Transformer
export class Album {
// Photo를 반환하는 함수를 사용
@Type(() => Photo)
photos: Photo[];
}

이처럼 Nest.js가 forwardRef를 제공하는 이유는 사용자가 의존성을 지정할 때 함수 형태로 제공하도록 타입으로 강제하기 위함이다.
또한 Nest.js가 @Inject로 주입받은 의존성이 순환 참조를 가지는지 구분하기 위한 값을 세팅하려는 목적도 있다.

@Inject(forwardRef(B)) // type error

Injector의 지연 참조

forwardRef가 함수를 인자로 받는 이유를 살펴보았으므로, 이제 Injector가 이를 어떻게 활용해 의존성을 지연 참조하는지 살펴보자.
우선 다음과 같은 코드에서 시작해보자.

@Injectable()
export class A {
constructor(@Inject(forwardRef(() => B)) private readonly b: B) {}
}

forwardRef는 json을 반환하는 간단한 함수이므로 위 코드는 다음과 같이 작성해도 동일하게 동작한다.

@Injectable()
export class A {
constructor(@Inject({ forwardRef: () => B }) private readonly b: B) {}
}

이제 @Inject 데코레이터의 구현부를 살펴보면 다음과 같은 코드를 볼 수 있다.

packages/common/decorators/core/inject.decorator.ts
export function Inject<T = any>(token?: T) {
return (target: object, key: string | symbol | undefined, index?: number) => {
const type = token || Reflect.getMetadata("design:type", target, key);

if (!isUndefined(index)) {
let dependencies =
Reflect.getMetadata(SELF_DECLARED_DEPS_METADATA, target) || [];

dependencies = [...dependencies, { index, param: type }];
Reflect.defineMetadata(SELF_DECLARED_DEPS_METADATA, dependencies, target);
return;
}
// ...생략
};
}

@Inject를 생성자의 파라미터에 사용하는 경우에는 index가 파라미터 위치를 나타내므로 if문 안의 코드가 실행된다.
내부 로직은 target 클래스에 SELF_DECLARED_DEPS_METADATA라는 메타데이터키를 사용해 { index, param: 의존성 } 형태의 배열을 저장한다.
@Inject는 생성자에서 여러 번 사용될 수 있으므로 기존 값을 먼저 가져와서 추가하는 방식으로 구현되어 있다.
forwardRef와 함께 사용하는 경우에는 param{ forwardRef: () => B }가 할당된다.

이제 이전 포스트에서 다룬 InjectorreflectConstructorParams 메서드를 다시 살펴보자.

packages/core/injector/injector.ts
  public reflectConstructorParams<T>(type: Type<T>): any[] {
const paramtypes = [
...(Reflect.getMetadata(PARAMTYPES_METADATA, type) || []),
];
const selfParams = this.reflectSelfParams<T>(type);

selfParams.forEach(({ index, param }) => (paramtypes[index] = param));
return paramtypes;
}

public reflectSelfParams<T>(type: Type<T>): any[] {
return Reflect.getMetadata(SELF_DECLARED_DEPS_METADATA, type) || [];
}

먼저 PARAMTYPES_METADATA(design:paramtypes)를 통해 의존성 타입을 확인한 후, SELF_DECLARED_DEPS_METADATA를 통해 추가적인 의존성을 확인한다.

이후 reflectConstructorParams가 반환한 배열의 각 요소를 순회하면서 앞서 소개한 resolveSingleParam의 두 번째 인자로 전달한다.

packages/core/injector/injector.ts
  private resolveSingleParam<T>(
wrapper: InstanceWrapper<T>, // 의존성 주입이 필요한 클래스
param: Type<any> | string | symbol | any, // 주입받아야 하는 의존성 중 하나
// ...생략
) {
const token = this.resolveParamToken(wrapper, param);

// token을 활용해 의존성의 인스턴스를 반환
return this.resolveComponentInstance<T>(
// ...생략
);
}

public resolveParamToken<T>(
wrapper: InstanceWrapper<T>,
param: Type<any> | string | symbol | any,
) {
// param이 순환 참조(forwardRef)로 선언되어 있는지 확인
if (!param.forwardRef) {
return param;
}
// param이 forwardRef인 경우 대상 클래스의 forwardRef 프로퍼티를 true로 설정
wrapper.forwardRef = true;
// 함수를 호출하여 의존성 타입(token)을 반환
return param.forwardRef();
}

위 코드를 통해 Injector특정 클래스의 의존성이 순환 참조인지 확인하는 기준은 오직 forwardRef라는 key를 가지고 있는지 여부라는 것을 알 수 있다.
만약 forwardRef를 설정했지만 함수형태가 아니라면 에러가 발생할 것이다.

위 코드에서 독특한 점은 의존성 중 하나가 순환 참조인 경우, 해당 클래스의 forwardRef 프로퍼티를 true로 설정한다는 것이다.
내가 누군가를 순환 참조하고 있다면, 누군가 나를 순환 참조 하고 있다는 의미라고 볼 수 있다.
또한 Injector순환 참조를 가지는 클래스와 순환 참조가 없는 일반 클래스의 인스턴스를 만드는 방식이 다르기에 이를 구분하기 위해 forwardRef 프로퍼티를 사용한다.

순환 참조를 가지는 클래스의 인스턴스 생성

지금까지 알아본 내용은 순환참조를 가지는 클래스가 어떤것인지 알아내는 과정이었다.
이제 순환 참조를 가지는 클래스의 인스턴스를 생성하는 과정을 살펴보자.

지금까지 살펴본 예제코드에서 A, B 클래스가 서로 순환 참조를 가지고 있다고 가정하자.
두 클래스 중 A의 인스턴스를 생성하려고 했지만 바로 문제가 발생한다.

  • A의 인스턴스를 생성하려면 B의 인스턴스가 필요하다.
  • B의 인스턴스가 아직 없으므로 생성해야 한다.
  • B의 인스턴스를 생성하려면 A의 인스턴스가 필요하다.
  • A의 인스턴스가 아직 없으므로 생성해야 한다.

위와 같이 서로가 서로를 생성해야 하는 상황이 발생해 둘 중 하나를 먼저 생성할 수 없다.
이를 해결하기 위해 Nest.js의 내부 클래스 중 하나인 InstanceWrapper에는 createPrototype 메서드가 있다.

packages/core/injector/instance-wrapper.ts
  public createPrototype(contextId: ContextId) {
const host = this.getInstanceByContextId(contextId);
if (!this.isNewable() || host.isResolved) {
return;
}
return Object.create(this.metatype.prototype);
}

InstanceWrapper는 Nest.js가 내부적으로 각 의존성에 대한 메타데이터를 저장하는 클래스이다.
Nest.js가 실행되면 모든 의존성에 대한 InstanceWrapper 인스턴스가 생성된다.
InstanceWrapper에는 instance 프로퍼티가 있는데, 이는 해당 의존성의 인스턴스를 가리킨다.
해당 프로퍼티는 초기에 의존성의 유형이 클래스인 경우(isNewable가 true) createPrototype 메서드의 반환값으로 설정된다.

createPrototype 메서드를 보면 마지막에 Object.create를 활용했는데 클래스의 생성자 호출없이 인스턴스를 생성하는 기능을 제공한다.
이를 통해 순환 참조가 있는 경우라고 하더라도 서로의 인스턴스 없이 자신의 인스턴스를 생성할 수 있다.
물론 생성자를 호출하지 않았기 때문에 내부 프로퍼티는 모두 undefined가 되며 Injector가 이후 두 인스턴스간의 연결작업을 수행해 문제를 해결한다.

순환 참조를 가진 인스턴스끼리 연결

내부 프로퍼티가 없는 빈 인스턴스를 생성하였으니 이제 두 인스턴스를 연결해야 한다.
이전 포스트에서 살펴본 loadInstance를 다시 살펴보자.

  public async loadInstance<T>(
wrapper: InstanceWrapper<T>,
collection: Map<InstanceToken, InstanceWrapper>,
moduleRef: Module,
contextId = STATIC_CONTEXT,
inquirer?: InstanceWrapper,
)

이 메서드의 역할은 첫 번째 파라미터인 wrapper의 인스턴스를 생성하는 것이며 이를 위해 나머지 인자들을 활용한다.
실제 로직은 더 복잡하지만 이번 포스트 주제로 한정해서 설명하면 다음과 같은 로직을 가지고있다.

  • wrapper가 이미 인스턴스가 생성된 상태(resolved)라면 아무런 작업을 하지 않는다.
  • wrapper의 의존성들을 파악한다. 의존성 중에 하나라도 순환 참조를 가지고 있다면 wrapper의 forwardRef 프로퍼티를 true로 설정한다.
  • 각 의존성들을 순회하며 의존성의 인스턴스를 생성한다. 여기서 의존성의 forwardRef 여부에 따라 다르게 동작한다.
    • 의존성이 forwardRef를 가지는 경우 의존성의 InstanceWrapper를 그대로 사용한다.
    • 그 외의 경우 loadInstance 메서드를 재귀호출해 의존성의 인스턴스를 먼저 생성한다.
  • 의존성의 인스턴스를 활용해 wrapper의 인스턴스를 생성한다. (instantiateClass 메서드)

위 로직에서 눈여겨 볼 점은 wapper의 의존성이 forwardRef를 가지는 경우 의존성의 InstanceWrapper를 그대로 사용한다는 점이다.
이는 순환 참조를 가지는 의존성을 만들기 위해 계속 loadInstance 메서드를 호출해 무한루프에 빠지는 것을 방지하기 위함이다.

마지막으로 instantiateClass 메서드에서 인스턴스를 생성하는 코드를 살펴보자.

instanceHost.instance = wrapper.forwardRef
? Object.assign(
instanceHost.instance,
new (metatype as Type<any>)(...instances),
)
: new (metatype as Type<any>)(...instances);

위 코드에서 각 변수의 의미는 다음과 같다.

  • instanceHost.instance: Object.create로 생성한 wrapper의 빈 인스턴스
  • metatype: wrapper의 생성자
  • instances: wrapper의 의존성 인스턴스들

위 코드를 보면 wapper의 forwardRef가 true인 경우 Object.assign을 통해 기존 빈 인스턴스에 신규 인스턴스를 병합하는 것을 볼 수 있다.
이렇게 하는 이유는 순환 참조를 가지는 경우 누군가가 instanceHost.instance를 참조할 가능성이 생기고 이를 위해 기존 인스턴스를 유지해야 하기 때문이다.
이해를 돕기위해 Object.assign을 사용하지 않으면 어떤 문제가 발생하는지 살펴보자.

A, B 두 클래스가 있고 서로를 의존성으로 가지는 상황을 가정하자.
A는 순환 참조가 없는 C 클래스를 추가로 의존성으로 가지고 있다.

초기에는 Object.create를 통해 각각 빈 인스턴스가 생성된다.

만약 A에 대해 loadInstance가 호출되면 다음과 같은 상태가 된다.

A의 의존성 중 B는 forwardRef이므로 빈 인스턴스를 그대로 사용하고 C는 순환 참조가 없으므로 loadInstance를 호출해 인스턴스를 생성한다. 그래서 A와 C만 인스턴스가 만들어진 상태이고(resolved) A는 비어있는 B를 참조하게 된다.

이제 B에 대해 loadInstance가 호출되면 다음과 같은 상태가 된다.

B의 의존성인 A는 이미 인스턴스가 생성된 상태이므로 이를 활용해 B를 생성한다.
하지만 B를 만들 때 A가 참조하고 있던 B의 빈 인스턴스가 아닌 새로운 공간에 인스턴스가 생성되므로 A는 여전히 비어있는 B를 참조하게 된다.

요약

  • provider끼리 순환 참조를 가지는 경우 Nest.js는 forwardRef를 사용하도록 안내한다.
  • 순환 참조를 가지면 design:paramtypes이 제대로 설정되지 않는 문제가 발생한다.
  • 이를 해결하기 위해 Nest.js는 지연 참조를 통해 의존성의 타입을 파악한다.
  • Injector는 순환참조 여부에 따라 인스턴스를 생성하는 방식이 다르다.