
피드백은 큰 힘이 됩니다.
서론
이전 포스팅에서 NestJS 11의 릴리즈 노트 중 부트스트랩 최적화에 대해 다뤘다. 모듈을 식별하는 Opaque Key 생성 알고리즘의 개선으로 모듈을 읽어들이는 속도가 대폭 향상되었다고 나와 있었다.
하지만 최근 NestJS 11로 업데이트한 이후, AppModule 초기화 속도가 급격히 느려지는 이슈가 발생했다. 10버전에서 55ms였던 초기화 시간이 11버전에서는 50초 ~ 80초까지 증가하며 성능 저하 문제가 제기되었다.
저번 포스팅에서는 Opaque Key 최적화로 부트스트래핑 속도를 개선하는 방법을 살펴봤다면, 이번 포스팅에서는 AppModule의 의존성 초기화 과정에서 발생한 성능 이슈와 Nest에서 이를 어떻게 해결하였는지에 대해 분석해봤다
11.0.9
위에서 언급한 의존성 초기화 속도 개선을 위해 Nest에서는 topology tree를 사용하는 방식을 선택했다. 소스 코드 분석과 더불어 간단한 결제 모듈 구조를 통해 어떻게 개선되었는지 알아보고자 했다. 결제 모듈의 구조는 순환 의존과 더불어 글로벌 모듈을 사용했다. (개인적으로는 최대한 단방향 의존을 사용하려고하고 서비스가 확장됨에 따라 양방향 의존이 불가피할 경우 중간 레이어를 하나 더 두는 형태로 개발하고 있다.)
기존 - DFS
기존에는 DFS(깊이 우선 탐색) 기반 재귀 호출 방식을 사용하여 모듈 간의 거리를 계산했다. DFS의 특성상, 이미 방문한 모듈도 다시 탐색하는 경우가 많아지고, 불필요한 연산이 많아지는 문제가 발생했다. 또한, 모듈이 많아질수록 DFS의 호출 스택이 깊어져 스택 오버플로우 위험도 존재했다. 특히 순환 참조가 발생하는 경우, 무한 루프에 빠질 가능성이 높아, 안정적인 부트스트래핑이 어려워지는 문제가 있었다.
Nest의 DependenciesScanner는 의존성 관리를 위한 클래스이다. 모듈 초기화 순서를 결정하기 위해 모듈 간의 거리를 계산한다. 이 과정이 없다면 초기화 순서가 꼬이면서 앱이 실행되지 않을 것이다. 의존성이 있는 모듈을 먼저 초기화해야 이후 다른 모듈에서 정상적으로 주입이 가능한 것이다.
public calculateModulesDistance() {
const modulesGenerator = this.container.getModules().values();
// Skip "InternalCoreModule" from calculating distance
modulesGenerator.next();
const calculateDistance = (
moduleRef: Module,
distance = 1,
modulesStack: Module[] = [],
) => {
const localModulesStack = [...modulesStack]; // 1. 방문한 모듈 추적
if (!moduleRef || localModulesStack.includes(moduleRef)) {
return;
}
localModulesStack.push(moduleRef); // 2. 현재 모듈 추가
const moduleImports = moduleRef.imports; // 3. 의존성 가져옴
moduleImports.forEach(importedModuleRef => {
if (importedModuleRef) {
if (
distance > importedModuleRef.distance &&
!importedModuleRef.isGlobal
) {
importedModuleRef.distance = distance; // 4. 거리 갱신
}
calculateDistance(importedModuleRef, distance + 1, localModulesStack); // 5. DFS
}
});
};
const rootModule = modulesGenerator.next().value;
calculateDistance(rootModule!);
}
코드를 보면 알겠지만, 기존의 모듈의 거리를 계산할 때 calculateDistance라는 내부 함수의 재귀 호출을 통해 거리를 계산한다. 위 코드의 주석처럼, 1 ~ 5의 과정을 모든 모듈을 하나씩 방문하면서 DFS를 돌리는 것이다. 글로벌 모듈에 대한 동작을 수행하지 않는 등의 성능에 신경 쓴 부분도 보이지만, 근본적으로 서비스가 커짐에 따라 DFS 자체가 부담이 된다. 이슈에서처럼 AppModule의 초기화 시간이 80초나 걸린 것만 봐도 알 수 있다.
정리하자면, 기존의 모듈 거리 계산 방식은 DFS를 통한 그래프 형태로 모듈 간의 거리가 계산된다. 중복 방문이 필연적이며 모듈에서 의존하는 것이 많아지고, 서비스가 확장됨에 따라 앱 초기화에 필요한 시간은 훨씬 길어지게 된다. 이런 문제들로 55ms였던 AppModule의 의존성 초기화 시간이 80000ms까지 대폭 상승했었던 것으로 보인다.
트리 구조로의 개선
새로운 방식에서는 TopologyTree를 도입하여 DFS 방식의 재귀 호출을 제거하고, 모듈 간 관계를 트리 구조로 변환하여 반복 탐색하는 방식으로 변경했다.
export class TopologyTree {
private root: TreeNode<Module>;
private links: Map<Module, TreeNode<Module>> = new Map(); // 1.빠른 탐색을 위한 해시 테이블
constructor(moduleRef: Module) {
this.root = new TreeNode<Module>({ value: moduleRef, parent: null });
this.links.set(moduleRef, this.root); // 2.해시 구조로 저장하여 중복 방지
this.traverseAndMapToTree(this.root);
}
public walk(callback: (value: Module, depth: number) => void) {
function walkNode(node: TreeNode<Module>, depth = 1) {
callback(node.value, depth); // 3. 거리(distance) 계산
node.children.forEach(child => walkNode(child, depth + 1));
}
walkNode(this.root);
}
private traverseAndMapToTree(node: TreeNode<Module>, depth = 1) {
if (!node.value.imports) return;
node.value.imports.forEach(child => {
if (!child) return;
if (this.links.has(child)) { // 4.이미 존재하는 모듈인지 확인
const existingSubtree = this.links.get(child)!;
if (node.hasCycleWith(child)) return; // 5.순환 참조 감지 (사이클 방지)
const existingDepth = existingSubtree.getDepth();
if (existingDepth < depth) {
existingSubtree.relink(node); // 6.기존 트리 노드 재연결
}
return;
}
const childNode = new TreeNode<Module>({ value: child, parent: node });
node.addChild(childNode);
this.links.set(child, childNode);
this.traverseAndMapToTree(childNode, depth + 1);
});
}
}
export class TreeNode<T> {
public readonly value: T;
public readonly children = new Set<TreeNode<T>>();
private parent: TreeNode<T> | null;
constructor({ value, parent }: { value: T; parent: TreeNode<T> | null }) {
this.value = value;
this.parent = parent;
}
addChild(child: TreeNode<T>) {
this.children.add(child);
}
removeChild(child: TreeNode<T>) {
this.children.delete(child);
}
relink(parent: TreeNode<T>) {
this.parent?.removeChild(this);
this.parent = parent;
this.parent.addChild(this);
}
getDepth() {
let depth = 0;
let current: TreeNode<T> | null = this;
const visited = new Set<TreeNode<T>>();
while (current) {
depth++;
current = current.parent;
if (visited.has(current!)) return -1; // 1.순환 참조 감지
visited.add(current!);
}
return depth;
}
hasCycleWith(target: T) {
let current: TreeNode<T> | null = this;
const visited = new Set<TreeNode<T>>();
while (current) {
if (current.value === target) return true;
current = current.parent;
if (visited.has(current!)) return false;
visited.add(current!);
}
return false;
}
}
public calculateModulesDistance() {
const modulesGenerator = this.container.getModules().values();
// Skip "InternalCoreModule"
// The second element is the actual root module
modulesGenerator.next();
const rootModule = modulesGenerator.next().value!;
if (!rootModule) {
return;
}
// Convert modules to an acyclic connected graph
const tree = new TopologyTree(rootModule);
tree.walk((moduleRef, depth) => {
if (moduleRef.isGlobal) {
return;
}
moduleRef.distance = depth;
});
}
- TreeNode 클래스를 사용하여 각 모듈을 트리 노드로 변환
- walk()를 활용하여 BFS처럼 반복 탐색
- hasCycleWith() 메서드를 사용하여 순환 참조를 사전에 방지
결과적으로 O(n²)에서 O(n)으로 최적화되었으며, 중복 방문 없이 일정한 성능을 유지할 수 있게 되었다.
정리
이번 업데이트를 통해 대규모 애플리케이션에서도 모듈 간 의존성을 더욱 효율적으로 관리할 수 있게 되었다. 기존 DFS 기반 탐색 방식에서 발생하던 불필요한 중복 방문으로 인한 성능 이슈를 TopologyTree 기반 반복 탐색 방식으로 성능이 대폭 향상된 것으로 보인다.
(실제로 이슈의 코멘트 중 업데이트를 적용하고 35초에서 145ms로 단축되었다.)
기존 방식(DFS) | 새로운 방식 (Topology Tree) | |
탐색 방식 | DFS (깊이 우선 탐색, 재귀 호출) | 트리 기반 반복 탐색 (walk()) |
중복 방문 문제 | 여러 번 방문 가능 | 한 번만 방문 |
순환 참조 감지 | 제한적 (modulesStack 사용) | hasCycleWith() 사용 |
스택 오버플로우 위험 | 있음 (재귀 호출) | 없음 (반복 탐색) |
성능 | O(n²) (중복 탐색 발생) | O(n) (최적화된 탐색) |
거리 계산 방식 | 재귀 호출로 계산 | BFS처럼 walk()로 계산 |
언제나 issue가 있으면 기여해보고자 하는 생각에 레포를 꾸준히 방문하다보니 재밌는 이슈거리가 있어 자연스레 들여다봤던 시간이었다. 오픈 소스의 코드 컨벤션부터 시작해서 개발 방향, 의도 등이 코드에 드러나기 때문에 기여를 할 때도 보다 더 수월하게 할 수 있지 않을까? 다양한 사람들의 여러 관점에서의 이슈 분석을 보고 같이 토론하면서 하드 스킬 뿐 아니라 소프트 스킬도 키우고 겸사겸사 고수들의 코드도 공짜로 볼 수 있고 말이다 ㅋㅋ..
2023.04 ~ 백엔드 개발자의 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!