브라우저에서 보고 있는 컴포넌트, IDE에서 바로 열기

김동하·2026년 1월 16일

업무

목록 보기
4/6
post-thumbnail

썸네일은 react-dev-inspector 라이브러리로 참고해보려고 하다가 너무 규모가 커져서 포기했다.

개요

1편에서 IDE에서 파일 찾는 리소스를 줄이고자 현재 렌더링된 컴포넌트의 파일 이름을 UI 상에 표기했다.

덕분에 예전처럼 백엔드분들이 프론트엔드 프로젝트에서 파일을 찾느라 탐색하는 시간을 많이 줄일 수 있었다. 하지만! 뭔가 조금 부족했다. 파일 이름을 복사하고 IDE를 켜고 붙여넣기를 해서 찾는 그 시간도 줄이고 싶었다.

그러니까, 화면에서 특정 버튼을 누르면 IDE가 바로 켜지면서 해당 파일이 나타나는 나름 자동화를 꿈꾸며 개발을 시작했다

목표

목표는 심플하다.

  • 해당 소스 파일까지의 절대 경로를 가져온다
  • 서버에서 IDE CLI로 파일을 연다

대략적으로 프론트엔드에서 파일명을 구하고 백엔드에서 파일을 경로를 합쳐서 실행하면 될 것 같다. 그럼 절대 경로를 구하는 것부터 시작해보자

개발 과정

절대경로 구하기

우선, 베이스 경로 혹은 프로젝트 루트를 구하는 법은 간단하다. 서버에서 __dirname로 현재 디렉토리까지의 경로를 구해서 '..'으로 root까지 올라가면 된다.

const DEFAULT_PROJECT_ROOT = path.join(__dirname, '..', '..', '..'); // 

이렇게 일단 프로젝트 루트를 구했다.

/Users/dongha/Desktop/dongha/project_folder/project/

이제 하위에 파일까지 경로를 찾아서 합쳐주면 되는데 파일의 경로를 포함한 상대 경로를 찾는 방법이 고민되는 지점이었다.

현재는 직접 파일 이름을 넘기는 방식을 사용하고 있는데 자동으로 렌더링이 끝난 가장 최신의 컴포넌트의 이름을 가져오는 방법이 있을 것 같아 여러 시도를 해보았다.

스택 트레이싱

자바스크립트가 콜 스택 방식으로 실행되니까 그 스택을 찾아서 가장 상위에 있는 컴포넌트를 보면 되지 않을까해서 찾아보니 스택 트레이싱이란 것이 있었다

쉽게 말하여 의도적으로 new Error()를 던져서 지금까지 기록한 스택 기록에 접근하게 하는 것이다.

Error
    at MyComponent (MyComponent.tsx:20:15)
    at renderWithHooks (react-dom.development.js:16305:1)
    at updateFunctionComponent (react-dom.development.js:19588:1)
    at beginWork (react-dom.development.js:21601:1)
    at HTMLUnknownElement.callCallback (react-dom.development.js:4164:1)

흔히 브라우저 로그로 보았던 에러 메시지다.

 const error = new Error();
 const stack = error.stack;

이렇게 에러를 던져서 에러 객체 내부에 쌓인 스택을 가져온다.

 ['Error', 
  'at getFilePathFromStackTrace (http://localhost…00/main.cdae9dc450e646d20f3c.hot-update.js:36:19)', 
  'at handleSubmit (http://localhost:3000/main.cdae9dc450e646d20f3c.hot-update.js:93:27)', 
 // ...
]

이제 루프를 순회하면서 파싱하여 src/로 시작하는 라인을 찾으려고 했는데 두 가지 문제에 부딪혔다

  1. 번들링 이후 파일 이름이 static/js/bundle.js로 고정 (개발 기준)
  2. 에러 스택은 렌더링과 관련 없이 그냥 이벤트 발생 순서임

즉, 클릭 → React 이벤트 처리 → handleSubmit → getFilePathFromStackTrace 의 이벤트 실행에 관한 스택일뿐!

만약, 렌더링 도중에 에러가 난다면 에러 스택에 컴포넌트 정보가 보일 수도 있지만 현재는 이벤트 이후 의도적으로 에러를 던진 상황이라 이벤트 정보 밖에 없었다.

생각해보니 리액트는 렌더링을 Fiber 트리로 하는데 스택에 호출 순서가 쌓인다는 것부터 말이 안되었다(너무 단순하게 생각했음)

그럼 혹시 런타임 시점에 파이버 트리에 접근할 수 있는 방법이 있는지 찾아보았다.

파이버 트리 접근

찾아보니 공식적으로는 런타임에 파이버 트리에 접근할 수는 없고 비공식적인 키로 가능하다고 했다

이벤트가 발생하면 e.target.__reactFiber$ 이런 식으로 파이버 접근이 가능하다.

__reactFiber$ 달러 sign 뒤에는 버전이나 빌드 정보가 붙어서 루프를 돌면서 startsWith로 찾아주면 된다.


// Fiber 객체
{
 'actualDuration': 0.2999999523162842
 'actualStartTime': 1136646.1000000238
 'alternate': null
 'child': FiberNode {tag: 5, key: null, elementType: 'div', type: 'div', stateNode: div, …}
 'childLanes': 0
 'deletions': null
  // ...
 'return': FiberNode {tag: 0, key: null, stateNode: null, elementType: ƒ, type: ƒ, …}
}

이렇게 나온 Fiber 객체에서 링크드리스트 탐색하듯 쭉 탐색해서 경로를 얻으면 된다.

   let fiber = key ? dom[key] : null;
    
    const path: string[] = [];

    while (fiber) {
      if (fiber.type?.name) {
        path.push(fiber.type.name);
      }
      fiber = fiber.return; 
    }

    return path.reverse();

그럼 아래와 같은 path가 얻어진다.

근데 막상 여기까지 와보니 내가 생각했던 것과 조금 달랐다. 가장 최신 렌더링은 OpenEditor가 아닌데, OpenEditor가 클릭 이벤트의 시작점이라 OpenEditor부터 경로가 탐색된 것이다...

그렇다면 쭉 Outlet혹은 App까지 올라가서 dfs로 최하단에 있는 leaf까지 가야하는데 뭔가 산으로 간다는 느낌이었고 굳이 리액트 팀에서 막아놓은 히든 키에 접근해서 작업한다는 것 자체가 찝찝하여 포기했다.

몇 가지 더 시도해봤는데 결국 돌고 돌아 그냥 개발자가 컴포넌트에 파일 이름을 명시해서 넘기는 게 제일 간단하고 깔끔하다는 결론에 이르렀다.

재귀로 경로 구하기

이제 미니 데브툴에서 열기 버튼을 누르면 해당 컴포넌트의 파일 이름을 넘긴다.

   const res = await fetch('http://localhost:4000/open-editor', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          fileName,
        }),
      });

파일 서버로 넘기고

app.post('/open-editor', async (req, res) => {
  try {
    const { fileName } = req.body as {
      fileName: string;
    };

    if (!fileName) {
      return res.status(400).json({ error: '파일명은 필수입니다.' });
    }

    const projectRoot = process.env.PROJECT_ROOT || DEFAULT_PROJECT_ROOT;

    const foundPath = await findFileByName(projectRoot, fileName);
    // ...
 }

아까 만들었던 프로젝트 루트 경로와 받은 파일이름을 findFileByName에 넘겨 전체 경로를 구한다. 지금 주어진 건

/Users/dongha/Desktop/XXX/XXX/XXX/ 이 프로젝트 루트와 IntergatedUser라는 소스 파일 내 파일명이다.

/Users/dongha/XX/[src/pages/..]/파일명

이렇게 상대 경로 부분을 구해야 하는데, 파일 시스템을 다루는 건 처음이라 그런지 딱히 좋은 방법이 생각나지 않았고(검색해보니 glob을 사용한다고 했지만 일단 손수 만들어보기로)

코딩테스트 파일경로 찾는 문제도 어차피 재귀로 돌면 다 찾을 수 있었으니 재귀로 풀어보기로 했다.

async function findFileByName(
  projectRoot: string,
  fileName: string
): Promise<string | null> {
  const pwd = path.join(projectRoot, 'src');

  const findDirWithRecusive = (pwd) => {...}
  
  findDirWithRecusive(pwd)
}

일단 해당 파일은 src 하위에 있을 테니 src를 붙여서 탐색 범위를 좁힌다. findDirWithRecusive 라는 실제 재귀를 실행하는 함수에 pwd를 넘기고

async function findDirWithRecusive(
  ...
): Promise<string | null> {
    // ...
	const directories = await fs.readdir(pwd, { withFileTypes: true });
}

이제 모든 디렉토리를 긁어온다. 그러면 directories는 아래처럼 배열 형태로 나온다

[
 Dirent {
   name: 'App.css',
   parentPath: '/Users/dongha/Desktop/dongha/XXX/src',
   Symbol(type): 1
 },
 Dirent {
   name: 'App.test.tsx',
   parentPath: '/Users/dongha/Desktop/dongha/XXX/src',
   Symbol(type): 1
 },
 //...
 ]

이제 배열을 순회하면서 하나씩 하위 디렉토리를 붙여가면서 filename이 들어있는 경로인지 확인하고 아니라면 재귀를 돌면 된다.

async function findDirWithRecusive(
 ...
): Promise<string | null> {
   // ...
	for (const directory of directories) {
        const fullPath = path.join(pwd, directory.name); // 하위 붙이기
     
        if (
         directory.name === "node_modules" ||
         directory.name === "build" ||
         directory.name === "dist"
       ) {
         continue;
       }
     
   	if (directory.isDirectory()) {
         const found = await findDirWithRecusive(fullPath);
         if(found) return found     
         
     } else {
       // ...
     }
 }
}

현재 디렉토리라면 바로 재귀를 돌고

      if (directory.isDirectory()) {
          // ...
      } else {
         const base = path.parse(directory.name).name; // 확장자 제거
        if (base === filename) {
          return fullPath;
        }
      }

파일이라면 현재 타겟되어 있는 fileName과 맞는지 확인하고 맞다면 fullPath를 반환한다. 위 과정을 간단하게 해주는 것이 glob이 glob을 사용해서 절대경로를 구해도 된다.

아무튼 이제 드디어 절대 경로를 얻었다!!

IDE 실행

우리가 필요한 건 VScode를 실행시킬 CLI다. 생각해보면 터미널로 VScode 프로젝트를 실행시킬 때 code . 라는 커맨드를 사용했다. 저렇게 커맨드를 이용하면 서버에서 VScode를 실행시킬 수 있지 않을까?

vscode 공홈을 보니 -g로 원하는 파일에 이동할 수 있다고 나와있다.

const command = `code -g "${filePath}"`;

이렇게 커맨드를 지정하고 exec로 실행보니

IDE가 실행되긴했다! 하지만 프로젝트 전체가 아닌 파일 하나만 달랑 실행되어버렸다. 내가 원하는 건 전체 프로젝트가 열리고 그 중에서 해당 파일이 똭 열리는 것이기에 더 찾아보았다.

내가 찾던 것과 거의 일치하는 것 같다. 다시 시도해보자.

const command = `code --reuse-window "${projectRoot}" -g "${filePath}"`; 로 실행시키면

정확히 내가 원하는 기능이 작동하였다. 위 과정을 영상으로 보자면

보고 있는 화면 컴포넌트의 소스코드 IDE로 열기 완성했다! (화질이 왜 깨지는지 모르겠다)

자동으로 IDE 종류 탐색하기

지금 만든 IDE 런치 기능(뭐라고 불러야 할지는 모르겠다)은 VScode로 IDE 종류가 고정되어있다

물론 VScode가 대중적이긴 하지만 나의 경우도에도 Cursor를 쓰고 있고 예전엔 Webstorm을 쓴 적도 있고, VScode만 한정하기엔 쓰임새에 제한적이다.

또한 사용자가 OS도 MAC이냐 WIN이냐에 따라 달라질 수 있다. 그럼 엣지 케이스를 보완해보자.

마지막으로 지금은 사용자가 IDE CLI가 설치되어 있다고 가정하고 개발한 것이다. CLI가 없을 수도 있으니 그것도 보완해줘야 한다

OS 탐색

OS는 process.platform를 통해 알 수 있다. MAC인 경우 darwin, WINDOW는 win32다


function guessEditor(): EditorType {
  try {
      // MacOS
      if (process.platform === 'darwin') {
         const command = 'ps x';
     	 const output = execSync(command).toString();


      } else if(process.platform === 'win32') {
      // Windows          
      
      }         
}

이제 실행중인 모든 프로세스 중에서 맥인 경우 커서, VSCode, 웹스톰이 있는지 찾으면 된다.

ps x를 통해 프로세스들을 확인할 수 있다. 이제 미리 만들어둔 프로세스 경로와 에디터 이름 객체로 실행 중인 프로세스를 찾으면 된다.

const EDITOR_PROCESS_MAP_MACOS: Record<string, EditorType> = {
  '/Applications/Visual Studio Code.app/Contents/MacOS/Electron': 'vscode',
  '/Applications/Visual Studio Code - Insiders.app/Contents/MacOS/Electron':
    'vscode',
  '/Applications/Cursor.app/Contents/MacOS/Cursor': 'cursor',
  '/Applications/WebStorm.app/Contents/MacOS/webstorm': 'webstorm',
};

// ...

function guessEditor(): EditorType {
  try {
    // MacOS
    if (process.platform === 'darwin') {
      // ...
      const output = execSync(command).toString();

      for (const [processPath, editorType] of Object.entries(EDITOR_PROCESS_MAP_MACOS)) {
        if (output.includes(processPath)) {
          return editorType;
        }
      }
      return null;

    } else if (...) {}
}

이제 에디터에 따라서 다른 커맨드를 줘야 한다. 앞서 말했던 것처럼 CLI가 설치되지 않은 사용자일수도 있으니 그 부분도 고려해야 한다

커맨드 찾기

사용자가 IDE의 CLI가 설치되었는지 안되었는지 어떻게 알 수 있을까? 간단하게 which나 where로 먼저 찍어보는 것이다

function isCliInstalled(cmd: string): boolean {
  try {
    const command =
      process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`;
    execSync(command, { stdio: 'ignore' });
    return true;
  } catch {
    return false;
  }
}

이렇게 CLI 설치 판별함수를 만들고

//...
  case 'cursor':
    if (isCliInstalled('cursor')) {
      return ['cursor'];
    }
    return ['/Applications/Cursor.app/Contents/Resources/app/bin/cursor'];
//...

CLI가 있는 경우 CLI만, 없으면 IDE의 전체 경로를 반환하게 한다. 여기까지 했다면 거의 다 한 것이다!

아무것도 찾지 못 한 경우, VSCode로 열거나 그것도 안되면 에러를 던진다.

//...
  else {
    console.error(`에디터 명령어 실행 실패: ${command}`);
    console.error(
      '해결 방법: 에디터 CLI를 설치하거나, 에디터가 올바른 경로에 설치되어 있는지 확인하세요.'
    );
  }

이렇게 하면 완성이다!

app.post('/open-editor', async (req, res) => {
 try {
   const { fileName } = req.body as {
     fileName: string;
   };

   if (!fileName) {
     return res.status(400).json({ error: '파일명은 필수입니다.' });
   }

   const projectRoot = process.env.PROJECT_ROOT || DEFAULT_PROJECT_ROOT;

   const foundPath = findFileByName(projectRoot, fileName);

   if (!foundPath) {
     return res.status(404).json({
       error: `File not found: ${fileName}`,
       searchedIn: projectRoot,
     });
   }

   launchEditor(projectRoot, foundPath);

   res.status(200).json({ status: 'success', fileName, filepath: foundPath });
 } catch (e: any) {
   console.error('IDE 실행 실패:', e);
   res.status(500).json({
     status: 'error',
     message: e?.message ?? 'IDE 실행 실패',
   });
 }
});

이제 다시 열기 버튼을 누르면

현재 사용 중인 Cursor 잘 탐지하고, CLI가 설치되어 있지 않아 전체 경로를 사용했다.

파일도 잘 열렸다.

마무리

누군가의 불편함을 해결하고자 시작했던 작업이 단순히 UI 상에 소스 파일 이름을 표기하는 것부터, 파일 시스템을 만져서 브라우저에서 IDE를 실행시키는 기능까지 완성되었다.

개발을 처음 배운던 시기에, 리덕스 미들웨어라는 개념을 배우면서 단 몇 줄의 코드 store => next => action => next(action)로 리덕스의 미들웨어 패턴을 탄생시키고 생태계를 확장했다는 점에 꽂혔었다. 무언가를 발전시키는 데는 꼭 복잡하고 어려운 개발이 필요하지 않다는 사상(?)이 자리 잡았고 이번 기회에 조금 실현할 수 있어서 아주 재밌게 개발에 임했다.

사실 코드 자체는 어려운 게 없다. 절대 경로를 찾고 CLI 혹은 IDE 전체 경로로 exec 시키면 끝이다. 불편함과 리소스 낭비를 해소하기 위해선 아이디어와 아주 약간의 수고스러움만 있으면 된다.

profile
프론트엔드 개발

0개의 댓글