관리 메뉴

즐겁게, 코드

CLI로 반복 작업 개선하기 본문

👨🏻‍💻 기록

CLI로 반복 작업 개선하기

Chamming2 2024. 11. 14. 10:05

지난 프로젝트를 회고하며 부족했던 점들을 돌아봤을 때, 새로운 서비스를 추가할 때마다 기존 코드를 복사 - 붙여넣기 후 새로운 프로젝트를 시작하는 것에 좋지 않은 경험을 느꼈던 것 같다.

 

어떤 경로로 진입했을 때 어떤 페이지를 렌더할지 설정하는 modules/[모듈명]/route.ts 를 작성하는 상황을 예로 들어보겠다.

import type { RouteRecordRaw } from 'vue-router';

export const accountRoutes: RouteRecordRaw[] = [];

accountRoutes.push({
  path: '/account/signUp',
  name: 'accountSignup',
  component: () => import('@/pages/account/AccountSignUp.vue'),
});

accountRoutes.push({
  path: '/account/signIn',
  name: 'accountSignIn',
  component: () => import('@/pages/account/AccountSignIn.vue'),
});

accountRoutes.push({
  path: '/account/withdraw',
  name: 'accountWithdraw',
  component: () => import('@/pages/account/AccountWithdraw.vue'),
});

그리고 새로운 모듈(또는 서비스)을 추가하는 상황이 되면 관성적으로 위 파일을 복사한 다음, 새로운 폴더에 붙여넣고 불필요한 코드를 잘라내면서 작업을 진행해왔다.

/**
* 예시 : point 라는 새로운 기능을 추가하게 되면
* 복사 - 붙여넣기 후 필요한 만큼 코드를 수정한다.
*/

import type { RouteRecordRaw } from 'vue-router';

export const pointRoutes: RouteRecordRaw[] = [];

pointRoutes.push({
  path: '/point/pointDetail',
  name: 'pointDetail',
  component: () => import('@/pages/point/PointDetail.vue'),
});

그동안은 별 생각이 없었는데, 이걸 수십번을 반복하게 되니 살짝 회의가 들기 시작했다.

 

복사 - 붙여넣기로 코드 뼈대를 생성하는 작업은 절대 '세련된' 방법이 아니었고, 만약 신규 개발자가 합류하게 되었을 때 '새로운 경로를 생성하려면 기존 route.ts를 하나 잡아 복사해 쓰시면 됩니다' 라고 소개해야 한다면 차마 고개를 들기 어려울 것 같았다.

 

그래서 이 문제를 해결하기 위해, GPT의 도움을 받아 스크립트를 생성해 봤다.

"scripts": {
    "new:route": "node scripts/new-route.js",
}
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { select, input } from '@inquirer/prompts';

// 현재 파일 경로를 가져오기
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const modulesPath = path.resolve(__dirname, '../src/modules');
const pagesPath = path.resolve(__dirname, '../src/pages');
const routerIndexPath = path.resolve(__dirname, '../src/router/index.ts');

// 모듈 목록을 가져오는 함수
function getModules() {
  if (!fs.existsSync(modulesPath)) {
    console.error('❌ 모듈 폴더가 존재하지 않습니다.');
    process.exit(1);
  }
  return fs.readdirSync(modulesPath).filter(dir => {
    return fs.statSync(path.join(modulesPath, dir)).isDirectory();
  });
}

// 페이지 컴포넌트 파일 경로 확인 함수
function doesPageComponentExist(moduleName, routeName) {
  const filePath = path.join(pagesPath, moduleName, `${routeName}.vue`);
  return fs.existsSync(filePath);
}

// 페이지 컴포넌트 파일 생성 함수
function createPageComponent(moduleName, routeName) {
  const componentPath = path.join(pagesPath, moduleName);
  const filePath = path.join(componentPath, `${routeName}.vue`);

  // 해당 폴더가 없으면 생성
  if (!fs.existsSync(componentPath)) {
    fs.mkdirSync(componentPath, { recursive: true });
  }

  // 파일이 존재하지 않으면 생성
  if (!fs.existsSync(filePath)) {
    const template = `
<template>
  <div>새로운 페이지</div>
</template>

<script setup lang="ts"></script>

<style scoped></style>
`;
    fs.writeFileSync(filePath, template.trim(), 'utf8');
    console.log(`✅ 새로운 페이지 컴포넌트가 생성되었습니다: ${filePath}`);
  } else {
    console.log(`⚠️ 페이지 컴포넌트가 이미 존재합니다: ${filePath}`);
  }
}

// route.ts 파일을 생성 또는 수정하는 함수
function createOrUpdateRoute(moduleName, routeName, param) {
  const routeFilePath = path.join(modulesPath, moduleName, 'route.ts');
  const routeComponentPath = `@/pages/${moduleName}/${routeName}.vue`;

  // route.ts 파일이 존재하지 않으면 새로 생성
  if (!fs.existsSync(routeFilePath)) {
    fs.writeFileSync(
      routeFilePath,
      `import type { RouteRecordRaw } from "vue-router";\n\nexport const ${moduleName}Routes: Array<RouteRecordRaw> = [];\n`,
      'utf8',
    );
    console.log(`✅ 새로운 route.ts 파일이 생성되었습니다: ${routeFilePath}`);
  }

  // 경로 추가 구문 작성
  const routeConfig = `
${moduleName}Routes.push({
  path: "/${moduleName}/${routeName}${param ? `/:${param}` : ''}",
  name: "${routeName}",
  component: () => import("${routeComponentPath}")}
});\n`;

  // route.ts 파일에 추가
  fs.appendFileSync(routeFilePath, routeConfig, 'utf8');
  console.log(`✅ ${routeName} 경로가 ${moduleName}/route.ts에 추가되었습니다.`);
}

// router/index.ts 파일을 업데이트하는 함수
function updateRouterIndex(moduleName) {
  const importStatement = `import { ${moduleName}Routes } from '@/modules/${moduleName}/route';`;
  const routesStatement = `...${moduleName}Routes,`;

  let routerIndexContent = fs.readFileSync(routerIndexPath, 'utf8');

  // 이미 해당 모듈이 import 되어 있는지 확인
  if (routerIndexContent.includes(importStatement)) {
    console.log(`✅ ${moduleName}Routes는 이미 router/index.ts에 추가되어 있습니다.`);
    return;
  }

  // import 구문 추가
  const importPosition = routerIndexContent.indexOf('\n');
  routerIndexContent =
    routerIndexContent.slice(0, importPosition + 1) +
    importStatement +
    '\n' +
    routerIndexContent.slice(importPosition + 1);

  // routes 배열에 추가
  const routesPosition = routerIndexContent.lastIndexOf('routes: [');
  const closingBracketPosition = routerIndexContent.indexOf(']', routesPosition);
  routerIndexContent =
    routerIndexContent.slice(0, closingBracketPosition) +
    `  ${routesStatement}\n` +
    routerIndexContent.slice(closingBracketPosition);

  // 변경된 내용을 router/index.ts에 저장
  fs.writeFileSync(routerIndexPath, routerIndexContent, 'utf8');
  console.log(`✅ ${moduleName}Routes가 router/index.ts에 추가되었습니다.`);
}

// 사용자 입력을 받는 함수
async function promptUser() {
  const modules = getModules();

  try {
    // 모듈 선택
    const moduleName = await select({
      message: '모듈명을 선택하세요',
      choices: modules.map(mod => ({ name: mod, value: mod })),
    });

    let routeName;
    // 경로명 입력 (이미 존재하는 경우 재입력 요청)
    while (true) {
      routeName = await input({
        message: '경로명을 입력하세요 (Ex. NewPage)',
        validate: input => {
          if (input.trim() === '') {
            return '경로명을 입력하세요.';
          }
          if (doesPageComponentExist(moduleName, input.trim())) {
            return `⚠️ 해당 경로명이 이미 존재합니다. 다른 경로명을 입력해주세요.`;
          }
          return true;
        },
      });

      // 중복되지 않은 유효한 경로명을 입력한 경우 루프 종료
      if (!doesPageComponentExist(moduleName, routeName.trim())) break;
    }

    // 동적 파라미터 입력
    const param = await input({
      message: '동적 파라미터명을 입력하세요 (없으면 Enter)',
      default: '',
    });

    return { moduleName, routeName, param };
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
  } catch (err) {
    console.log('⚠️ 입력이 취소되었습니다.');
    process.exit(0);
  }
}

// 실행 함수
async function run() {
  const { moduleName, routeName, param } = await promptUser();

  // 페이지 컴포넌트 파일 생성
  createPageComponent(moduleName, routeName);

  // route.ts 파일 생성 또는 수정
  createOrUpdateRoute(moduleName, routeName, param);

  // router/index.ts 업데이트
  updateRouterIndex(moduleName);
}

run();

대략 다음과 같은 질의를 거치고, 선택한 모듈에 빈 페이지를 생성한 다음 해당 페이지를 라우트 목록에 등록까지 해주는 CLI다.
(실제로 사용하는 스크립트에는 인증, 메타데이터 등을 설정하는 코드가 조금 더 있기는 하다)

아무튼 이제 신규 개발자가 합류했을 때 '이건 여기서 복붙해 쓰시면 됩니다 😅' 처럼 민망한 웃음을 동반한 얼레벌레 온보딩 대신, 일관된 스캐폴딩 경험을 제공할 수 있는 스크립트가 생겼다.

 

예전에는 https://github.com/SBoudrias/Inquirer.js 같은 문서도 한번씩 탐독해야 삽을 뜰 수 있었는데, AI에게 적절한 프롬프트를 넘겨주면 이런 스크립트를 수 분 내에 만들 수 있게 되었다.

 

앞으로 더 많은 '짜치는' 작업들을 CLI로 개선해보려 한다.

 

GitHub - SBoudrias/Inquirer.js: A collection of common interactive command line user interfaces.

A collection of common interactive command line user interfaces. - SBoudrias/Inquirer.js

github.com

반응형

'👨🏻‍💻 기록' 카테고리의 다른 글

토이 프로젝트 스토리지 결정하기  (2) 2022.06.11
Comments
소소한 팁 : 광고를 눌러주시면, 제가 뮤지컬을 마음껏 보러다닐 수 있어요!
와!! 바로 눌러야겠네요! 😆