크롬 확장 프로그램을 가지고 Angular 전용 LocatorJs를 만드는 것이 목표였다.
잘 만들면 부자가 될 수 있을 줄 알았다. 아직은 월급쟁이로 살아야 하나보다.
컴포넌트의 파일 위치를 알아내는데 실패했다..
"런타임에 컴포넌트 Tree만 보고 컴포넌트의 파일 위치를 얻어내기"는 사실상 Angular 자체만으로는 불가능했다. 이러니 아무도 안 만들었지..
정확히는 모든 "앵귤러 프로젝트"로 범위를 설정하니 너무 막히는 부분이 많았다.
이를 사내 프로젝트로만 한정한다면?
80억 앵귤러 개발자들의 찬사를 포기한다면?
먼저 사내용으로 개발 후에, 지금 당장이 아닌 나중에라도 부자가 되기로 마음먹는다면?
LocatorJs For Angular, 그리 불가능해보이지만은 않는다
크롬 익스텐션은 때려친다. 어차피 우리만 쓸 것이다. (일단은..)
사내 라이브러리로 만들까도 고민해봤지만 어차피 사내 앱 프로젝트는 여기저기 분리되어있지 않고, 메인 APP들이 모노레포로 구성되어 있기 때문에 레포에 직접 구성하는 것이 낫겠다고 판단했다.
이게 가장 힘든 점이었는데, 사내 프로젝트에 코드를 작성하게 되면서 파일을 돌며 component map을 하나 생성하는게 제일 간단했다.
이것저것 다 시도해봐도 이게 가장 정확도가 높았다 (당연히도).
생각보다 얼마 안 걸린다. 추가로 캐싱 최적화를 통해 최초 1회를 제외하고는 빠르게 작업을 수행할 수 있었다. (최초 실행 시. 1-2초, 이후 500ms 내외)
컴포넌트 파일이 천 개가 넘어가도 이 정도니까..적당한 규모의 프로젝트에는 대부분 대응 가능해 보인다.
전체적인 구조는 다음과 같이 잡아준다.
devtools/
├── cmp.scan.ts # 컴포넌트 트리 스캔 로직
├── angular-locator.ts # 클라이언트
├── file-opener.js # 서버
└── README.md # 설명
LocatorJS의 경우 컴포넌트를 클릭하면 해당 컴포넌트 파일(e.g. LoginComponent.ts)로 이동한다. 반면 앵귤러에서는 크게 두 가지 선택지가 있다.
cmp.scan.ts에는 ts 파일을 스캔하여 해당 파일의 메타데이터를 저장하기 위한 로직이 담겨있으며. 최초 실행 및 변화 감지 시에 실행된다.
앵귤러는 MVW (Model-View-Whatever) 패턴이라고 보통 불리우고, 우리는 대부분 MVVM 패턴으로 구성되어 있다. 쉽게 말하면 컴포넌트 별로 HTML 파일 하나, TS 파일 하나씩 알아내면 된다는 것이다.
코드를 왕창 적어놓으면 별로 읽고 싶어지지 않으니 workflow를 적어보겠따
open-in-editor.config.json 설정ts-morph 이용)open-in-editor.config.json에는 다음과 같은 내용을 적어준다.
// open-in-editor.config.json
{
"port": 4123,
"editor": "cursor",
"fallbackEditor": "webstorm",
"scan": {
"includeGlobs": ["projects/**/*.{ts,tsx}"],
"excludeGlobs": ["**/node_modules/**", "**/dist/**", "**/.angular/**"]
}
}
ts-morph 라이브러리를 통해 컴포넌트 스캔 시에 구문 트리(Abstract Syntax Tree)를 만들어 클래스, 함수, 인터페이스 같은 걸 코드에서 직접 찾아낼 수 있다. TS 코드 접근 및 조작이 용이해진다는 말.
예시
import { Project } from 'ts-morph';
const project = new Project();
project.addSourceFilesAtPaths(includeGlobs); // 스캔 범위 설정
const sourceFiles = project
.getSourceFiles()
.filter(
(sf) =>
!excludeGlobs.some((x) =>
sf.getFilePath().includes(x.replace('**/', '')),
), // config 설정에 따라 제외할 파일들은 제외시켜준다
);
컴포넌트 스캔은 최초 프로젝트 실행 시, 그리고 런타임 변경 감지 시에 실행한다.
변경이 감지되었을 때 항상 모두 다시 스캔하면 짜치니까.. node fs에서 제공하는 mtimeMs(마지막 파일 수정 시간)을 가지고 다시 스캔할지 말지를 결정한다. 혹시 파일이 삭제된 경우 에러 케이스도 처리해준다.
function getFileStats(filePaths: string[]): Record<string, number> {
const stats: Record<string, number> = {};
for (const filePath of filePaths) {
try {
const stat = fs.statSync(filePath);
stats[filePath] = stat.mtimeMs;
} catch {
// 파일이 삭제된 경우
}
}
return stats;
}
const filePaths = sourceFiles.map((sf) => sf.getFilePath());
const currentStats = getFileStats(filePaths);
const previousCache = loadCache();
// 변경된 파일 확인
const hasChanges = filePaths.some(
(filePath) =>
!previousCache[filePath] ||
previousCache[filePath] !== currentStats[filePath],
);
// 새로운 파일, 삭제된 파일 확인
const cachedPaths = Object.keys(previousCache);
const hasNewOrDeletedFiles =
filePaths.length !== cachedPaths.length ||
filePaths.some((path) => !previousCache[path]) ||
cachedPaths.some((path) => !currentStats[path]);
if (!hasChanges && !hasNewOrDeletedFiles && fs.existsSync(outFile)) {
process.exit(0);
}
이 뒤로는 별 거 없다. 앵귤러의 @Component 데코레이터를 확인하고, 같이 작성되어 있는 메타데이터(selector, templateUrl, styleUrls) 등을 절대 경로로 변환하여 저장한다.
결과는 component-map.json에 고이 저장해둔다. 이 때 캐시도 같이 업데이트한다.
아래와 같은 메타데이터들이 저장되어, 에디터에서 파일을 열 때 사용하게 된다.
{
"generatedAt": "2025-01-15T10:30:00.000Z",
"byClass": {
"HeaderComponent": "/abs/path/header.component.ts",
"FooterComponent": "/abs/path/footer.component.ts"
},
"bySelector": {
"app-header": "/abs/path/header.component.ts",
"app-footer": "/abs/path/footer.component.ts"
},
"detail": {
"HeaderComponent": {
"className": "HeaderComponent", // 클라이언트에 노출할 정보
"selector": "app-header", // 이거 가지고 찾을 거다
"filePath": "/abs/path/header.component.ts",
"templateUrl": "/abs/path/header.component.html",
"styleUrls": ["/abs/path/header.component.scss"],
"isStandalone": false
}
}
}
클라이언트와 에디터를 연결하는 브릿지 역할 express 서버다.
클라이언트에서 http 요청을 날리면, 해당 요청에 맞게 에디터에서 파일을 연다.
에디터 설치 여부 등을 확인하고, 클라이언트에서 요청한 파일을 여는 로직이 작성되어 있다. 파일 열기만 하면 아쉬우니까 html 템플릿 파일을 여는 경우에 해당 요소가 있는 line을 탐색, 해당 라인을 열어주도록 로직을 추가했다.
어차피 regex가 대부분이라 구체적인 로직은 생략.
function findBestLineInFile(filePath, searchTerms) {
try {
const content = fs.readFileSync(filePath, "utf8");
const lines = content.split("\n");
const scores = new Array(lines.length).fill(0);
// 각 검색어에 대해 점수 계산
searchTerms.forEach((term, termIndex) => {
const weight = Math.max(1, searchTerms.length - termIndex); // 앞쪽 검색어에 더 높은 가중치 부여
lines.forEach((line, lineIndex) => {
const lowerLine = line.toLowerCase();
const lowerTerm = term.toLowerCase();
if (lowerLine.includes(lowerTerm)) {
// term에 대해 exact 매칭,단어 경계 매칭, 시작 부분 매칭을 통해 가중치 부여
// searchTerm이 "header"일때
// "header"가 포함된 줄에 가장 높은 점수,
// "my-header"와 같은 경우 그 다음으로 높은 점수...
}
});
});
// 가장 높은 점수의 라인 찾아 반환
let bestLine = 1;
let bestScore = 0;
scores.forEach((score, index) => {
if (score > bestScore) {
bestScore = score;
bestLine = index + 1; // 1-based
}
});
return bestScore > 0 ? bestLine : 1; // 없으면 첫 줄에서 열기
} catch (e) {
console.warn(`[file-opener] Failed to search in file: ${e.message}`);
return 1;
}
}
컴포넌트를 찾아 file-opener.js에 에디터를 열어달라고 요청을 보낸다.
제공되는 옵션은 아래 세 가지다.
다음과 같은 로직들이 작성되어 있다.
1. file-opener.js 서버에서 컴포넌트 맵 미리 받아오기
2. EventListner 등록 + 각 로직 작성 (호버 시 효과, 클릭 시 피드백 등)
3. 요소 클릭 시 컴포넌트 selector를 찾아 file-opener.js에 http 요청
Angular 컴포넌트는
main-header와 같이 "-" 가 포함되어 있는 선택자 패턴을 사용한다. selector가main-header면 컴포넌트 트리에도<main-header>와 같이 노출되어 있다. 이걸 가지고 앵귤러 컴포넌트인지 아닌지를 알아낸다.
<div>, <h1>, ...-> x<main-header>, <my-app>-> o

nodemon 라이브러리를 설치, 아래와 같이 package.json에 script를 추가해준다.
"cmp:scan": "ts-node -P tsconfig.tools.json devtools/cmp.scan.ts",
"cmp:watch": "nodemon --delay 2.5 -e ts,html -w projects --ignore '**/node_modules/**' --ignore '**/dist/**' --ignore '**/*.spec.ts' --ignore '**/*.test.ts' -x \"npm run cmp:scan\"",
"dev:opener": "node devtools/file-opener.js",
cmp:watch + cmp:scan
cmp.scan.ts를 실행dev:opener
개발 서버와 포트 번호가 다르기 때문에 CORS 에러가 발생할 수 있다.
개발 서버를 열 때 다음과 같이 proxy 설정도 추가해줬다.
// proxy.config.json
{
"/__open-in-editor": {
"target": "http://localhost:4123",
"secure": false,
"changeOrigin": true
},
"/__open-in-editor-search": {
"target": "http://localhost:4123",
"secure": false,
"changeOrigin": true
},
"/__cmp-map": {
"target": "http://localhost:4123",
"secure": false,
"changeOrigin": true
}
}
전체 workflow는 아무튼 다음과 같다. (고맙다 커서야..)



가끔 Line matching이 제대로 이루어지지 않아 그냥 컴포넌트의 첫줄로 갈 때도 있지만, 대체로 잘 작동한다.
하면서 느낀 점은 충분히 라이브러리화까지는 가능할 것 같다. 그러나 유저가 수행해야할 작업이 좀 있다는게 조금 불편한 것 같다.(config 파일 작성, 프로젝트 root 지정, script 추가 등등..) 그래도 쓸 사람이 있으려나
다음에는 라이브러리로 만들어서 npm 배포를 한 번 해보자