이전 포스트에서는 Nest.js의 injector 구현을 살펴보았다.
injector가 특정 인스턴스의 의존성 타입을 알아내는 방법에 대한 글이었는데, 이번에는 injector를 구현하면서 발생했던 이슈에 대해 살펴보려고 한다.
순환 참조 의존성
순환 참조란 두 개 이상의 요소가 끝이 없는 순환을 이루는 참조 관계를 말한다.
즉 A, B 두 요소가 있을 때 A가 B를 참조하고, B가 A를 참조하는 상황을 말한다.
Nest.js에서 순환 참조는 module과 provider 사이에서 발생한다.
이번 포스트에서는 provider 사이에서 발생하는 순환 참조에 대해 살펴보려고 한다.
다음과 같은 두 개의 provider가 있다고 가정해보자.
@Injectable()
export class A {
constructor(private readonly b: B) {}
}
@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
를 검색해보면 다음과 같은 코드를 찾을 수 있다.
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
메서드에서 parma
이 undefined
인 경우 에러를 발생시키는 것을 확인할 수 있다.
resolveSingleParam
메서드는 wrapper
의 생성자 파라미터를 하나씩 순회하면서 의존성을 해결하는 메서드이다.
에러 발생 시점에서 wrapper
는 B
였지만 param
에는 A
가 아닌 undefined
가 전달되었다는 것을 알 수 있다.
이전 포스트에서 injector는 design:paramtypes
를 통해 생성자 파라미터의 타입을 알아내는 것을 살펴보았다.
따라서 순환 참조가 발생하는 경우 B
의 design:paramtypes
에는 undefined
가 들어갔다고 의심해볼 수 있다.
실제로 그런지 확인해보기 위해 두 클래스의 트랜스파일 결과물을 살펴보자.
tsconfig의 "module"을 "commonjs"로 설정하였고 일부 코드는 생략하였다.
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;
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;
코드만 본다면 B
의 design:paramtypes
에는 A
가 올바르게 설정되어있는 것처럼 보인다.
하지만 런타임에서 해당값을 확인해보면 undefined
가 출력된다.
console.log(Reflect.getMetadata("design:paramtypes", B.prototype)); // undefined
Node.js의 순환 참조
위와 같은 결과가 나오는 이유는 Node.js의 모듈 로딩 방식 때문이다.
Node.js는 두 파일간에 서로 require하는 상황에서 에러가 발생하는 대신 무한루프가 발생하지 않도록 특별한 동작을 한다.
예를 들면,앞선 예제에서 AppModule
에서 A
를 를 먼저 import 하고 B
를 import는 상황에서 다음과 같이 동작한다.
a.service.js
를 실행한다.- 중간에
require("./b.service")
를 만나고 실행을 멈추고b.service.js
를 실행한다. b.service.js
를 실행하면서require("./a.service")
를 만나고 실행을 멈추고a.service.js
를 실행한다.a.service.js
는 기존에 이미 실행 중 멈춘 파일인데 이 경우 Node.js는 일단{}
를 반환한다.b.service.js
의a_service_1
에는{}
가 할당된다.__metadata("design:paramtypes", [a_service_1.A])
을 실행한다.a_service_1.A
는{}
이므로design:paramtypes
에는undefined
가 할당된다.b.service.js
실행이 끝나고a.service.js
로 돌아간다.__metadata("design:paramtypes", [b_service_1.B])
을 실행한다.b_service_1.B
는 정상적으로 로드된 값이므로design:paramtypes
에는B
가 할당된다.
위와 같은 작업을 완료한 이후 만약 다른 파일에서 a.server
을 require하면, 그 때에는 정상적으로 로드된 모듈을 반환한다.
이를 통해 b.service.js
가 실행한 시점에 a.service.js
가 아직 완전히 로드가 되지 않았기에 발생하는 문제라는 것을 알 수 있다.
또한 import 순서에 따라 design:paramtypes
가 undefined
가 되는 클래스가 달라진다는 것도 알 수 있다.
지연 참조
위와 같은 이슈는 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
데코레이터의 구현부를 살펴보면 다음과 같은 코드를 볼 수 있다.
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 }
가 할당된다.
이제 이전 포스트에서 다룬 Injector
의 reflectConstructorParams
메서드를 다시 살펴보자.
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
의 두 번째 인자로 전달한다.
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
메서드가 있다.
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는 순환참조 여부에 따라 인스턴스를 생성하는 방식이 다르다.