배경
현재 회사의 거의 모든 파일이 ui 패키지에 집중되어 있다. 너무 많은 파일이 집중되어있어 타입 추론도 느리고 빌드 속도도 오래 걸려서 해당 ui 패키지를 도메인 별로 packages 폴더에 쪼개 넣기로 했다.
하지만 그 양이 너무 방대해서(약 1만 2천 개의 파일) 한꺼번에 분리하는 것은 어렵고 점진적으로 분리하고 있다.
그런 와중에 packages 폴더에 매번 새로운 패키지를 추가할 때마다 아래와 같은 반복 작업이 일어난다.
1.
기존 ui 패키지의 폴더를 통째로 복사해서
2.
각 도메인 패키지에서 필요없는 파일은 지우고
3.
package.json에서 이름 변경하고
4.
해당 앱을 사용하는 곳마다
5.
panda.config.js에 panda.build.config.js 경로를 추가해주고
6.
해당 앱을 의존성으로 설치해주는 등
최근에 회사에서 목적 조직으로 스쿼드들이 새롭게 편성이 되었는데, 나 역시 스쿼드에서 업무를 시작하면서 packages 폴더에 위와 같은 작업을 하다가 다른 사람들도 비슷한 일을 겪게 되지 않을까 싶었다.
개선 작업
개선 방법
sample package라는 템플릿을 만들어서 ui 패키지의 기본 설정만 복사하고, src 내의 모든 파일은 지우고 샘플 코드만 남겨 최대한 단순화했다. 필요한 파일이 있으면 본인이 복사해 넣으면 된다.
그리고 package.json의 이름을 변경하고 서비스 앱에 config 설정이나 의존성 설치는 custom script로 자동화해준다.
솔루션 선택: hygen, plop, yeoman-generator
처음 생각해낸 것이 code generator였는데, 예전에 hygen을 썼던 기억이 있어 그대로 써보려다가 왠지 더 좋은 라이브러리가 나타났을 것 같은 예감에 구글에 ‘hygen vs’라고 쳐보았다.
npm trends, npm compare를 확인해보니 hygen 말고도 plop, yeoman-generator이 있었다.
다운로드 수를 보니 yeoman이 훨씬 많았지만 yeoman같은 경우 여러가지 설정이 있어 러닝 커브가 있고, 템플릿을 통해 코드를 제네레이팅하는 단순한 기능을 넘어 프로젝트를 스캐폴딩할 수 있는 더 많은 기능을 제공하고 있어서 오히려 오버스펙처럼 느껴졌다.
한편, hygen, plop 둘 다 템플릿을 통해 코드를 제네레이팅할 수 있고 간단한 설정으로 빠르게 시작할 수 있다는 점이 좋았다. 둘 다 inquirer를 사용한 prompt를 통해 사용자 input을 받아 템플릿에 적용할 수 있어 유연하게 코드를 생성할 수 있다. 둘 중 원하는 것으로 아무거나 선택해도 되긴 하다.
turbo gen + plop
turbo gen
하지만 찾아본 것이 무색하게, 우리 프로젝트에서 turbo를 사용하고 있었고 워크스페이스에 새로운 패키지를 추가할 때 turbo gen을 사용하면 됐다. turbo gen에서 custom script를 통해 코드를 제네레이팅할 때는 plop을 사용하도록 되어있어서 hygen과 plop 둘 중 고민할 필요 없이 plop을 사용하게 됐다.
빈 패키지를 추가할 때
turbo gen workspace
Shell
복사
기존 패키지를 복사해서 패키지를 생성할 때
turbo gen workspace --copy <existing-package-name> --name <new-package-name>
Shell
복사
단순히 기존 ui 패키지를 복사하는 거라면 turbo gen workspace —copy 를 실행하면 되지만, 위에서 살펴본 것처럼 사용하는 앱 쪽에 의존성 설치, config 설정 등을 해주고 싶었던 터라 custom generator를 작성했다.
Custom Generators: plop 사용
custom generators 스크립트는 turbo/generators 폴더 하위에 위치시키면 된다.
turbo gen을 실행하면 해당 경로에 있는 스크립트를 찾아 어떤 스크립트를 실행시킬 건지 선택하는 프롬프트가 띄워진다.
이런 식으로 어떤 경로에 있는 generator script인지 보여준다.
작성한 config.cjs(config.ts)
turbo/generators/config.cjs 에 plop을 사용해서 스크립트를 작성하면 된다.
plop 인터페이스에 대해서는 아래에서 더 자세히 설명하려고 한다.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
module.exports = function generator(plop) {
plop.setHelper('toPackageName', (input) => `@mildang/${input}`);
plop.setGenerator('create-sample-package', {
description: 'Generate a new package and configure apps',
prompts: [
{
type: 'input',
name: 'packageName',
message: '패키지 이름 입력 >',
validate: (input) => {
const kebabCaseRegex = /^[a-z]+(-[a-z]+)*$/;
if (!kebabCaseRegex.test(input)) {
return '패키지명은 소문자 + kebab-case로 작성해주세요 (ex., "sample-package")';
}
return true;
},
},
{
type: 'checkbox',
name: 'apps',
message: '사용되는 앱 선택(복수 선택 가능) >',
choices: () =>
fs
.readdirSync(path.resolve('apps'))
.filter((file) => fs.statSync(path.join('apps', file)).isDirectory()),
},
],
actions: (data) => {
const actions = [];
// 패키지 폴더 생성 및 템플릿 파일 추가
actions.push({
type: 'addMany',
destination: `packages/{{packageName}}/`,
base: './sample-package/',
templateFiles: ['./sample-package/**', './sample-package/.storybook/**'],
globOptions: {
dot: true,
},
});
// 2. package.json 업데이트
actions.push({
type: 'modify',
path: `packages/{{packageName}}/package.json`,
pattern: /"name": ".*"/,
template: `"name": "{{toPackageName packageName}}"`,
});
// 3. 선택된 앱의 의존성 및 panda.config.ts 업데이트
data.apps.forEach((app) => {
actions.push(
{
type: 'modify',
path: `apps/${app}/package.json`,
pattern: /"dependencies": \{/,
template: `"dependencies": {\n "@mildang/{{packageName}}": "workspace:*",`,
},
{
type: 'modify',
path: `apps/${app}/panda.config.ts`,
pattern: /include: \[([^]]*)/,
template: `include: [ $1 '../../packages/{{packageName}}/dist/panda.buildinfo.json',\n`,
},
);
});
// 4. 패키지 의존성 설치
actions.push(() => {
const packageDir = path.resolve(`packages/${data.packageName}`);
try {
console.log(`Running "pnpm install" in ${packageDir}...`);
execSync('pnpm install', { cwd: packageDir, stdio: 'inherit' });
console.log('pnpm install completed successfully.');
} catch (error) {
console.error('Error during pnpm install:', error.message);
}
return 'Installed dependencies for the new package.';
});
return actions;
},
});
};
JavaScript
복사
plop 배우기
turbo gen이 아니라 plop만 사용한다면 프로젝트 루트에 plopfile.js를 작성하고 turbo/generators/config.js파일과 똑같이 작성해주면 된다.
setHelper
plop.setHelper('toPackageName', (input) => `@mildang/${input}`);
JavaScript
복사
인자를 받아서 원하는 값으로 변환할 수 있다.
toPackageName을 사용해서, input 값으로 받은 packageName을 @mildang/<packageName> 으로 변환한다.
actions.push({
type: 'modify',
path: `packages/{{packageName}}/package.json`,
pattern: /"name": ".*"/,
template: `"name": "{{toPackageName packageName}}"`,
});
JavaScript
복사
setGenerator
setGenerator는 3가지 config가 있다.
description | [String] | short description of what this generator does |
prompts | questions to ask the user | |
actions | actions to perform |
prompts는 inquirer 라이브러리의 question 인터페이스에 맞게 넣어주면 된다. prompt로 들어오는 input 값의 경우 아래처럼 validate 속성을 이용해 유효성 검사를 해줄 수 있다.
validate: (input) => {
const kebabCaseRegex = /^[a-z]+(-[a-z]+)*$/;
if (!kebabCaseRegex.test(input)) {
return '패키지명은 소문자 + kebab-case로 작성해주세요 (ex., "sample-package")';
}
return true;
},
JavaScript
복사
actions같은 경우는 아래처럼 배열로 작성해도 되지만,
module.exports = function generator(plop) {
plop.setGenerator('component', {
// ... 생략
actions: [
{
type: 'add',
path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.jsx',
templateFile: 'plop-templates/component.hbs',
},
{
type: 'add',
path: 'src/components/{{pascalCase name}}/index.js',
template: "export { default } from './{{pascalCase name}}';",
},
],
});
};
JavaScript
복사
이렇게 함수 형태로 작성해 actions 배열을 반환할 수도 있다. 이 경우 data 파라미터는 prompt를 통해 사용자로부터 입력받은 값이 객체 형태로 들어온 값이다.
actions: (data) => {
const actions = [];
actions.push({ ... });
actions.push({ ... });
actions.push({ ... });
return actions;
},
JavaScript
복사
여러 개의 generator 등록하기
config.cjs안에 generator를 여러 개 등록하고 싶은 경우, generator를 여러 파일로 쪼개 함수 형태로 export하고 실행해주면 된다.
component, page, service 파일은 chatgpt로 작성한 예시 코드이니 그냥 참고만 하면 된다.
config.cjs
const componentGenerator = require('./component');
const pageGenerator = require('./page');
const serviceGenerator = require('./service');
module.exports = function generator(plop) {
componentGenerator(plop);
pageGenerator(plop);
serviceGenerator(plop);
};
JavaScript
복사
component.js
module.exports = function generator(plop) {
plop.setGenerator('component', {
description: 'Create a new React component',
prompts: [
{
type: 'input',
name: 'name',
message: 'What is the name of the component?',
},
],
actions: [
{
type: 'add',
path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.jsx',
templateFile: 'plop-templates/component.hbs',
},
{
type: 'add',
path: 'src/components/{{pascalCase name}}/index.js',
template: "export { default } from './{{pascalCase name}}';",
},
],
});
};
JavaScript
복사
page.js
module.exports = function generator(plop) {
plop.setGenerator('page', {
description: 'Create a new page',
prompts: [
{
type: 'input',
name: 'name',
message: 'What is the name of the page?',
},
],
actions: [
{
type: 'add',
path: 'src/pages/{{kebabCase name}}/{{kebabCase name}}.jsx',
templateFile: 'plop-templates/page.hbs',
},
],
});
};
JavaScript
복사
service.js
module.exports = function generator(plop) {
plop.setGenerator('service', {
description: 'Create a new service',
prompts: [
{
type: 'input',
name: 'name',
message: 'What is the name of the service?',
},
],
actions: [
{
type: 'add',
path: 'src/services/{{camelCase name}}.js',
templateFile: 'plop-templates/service.hbs',
},
],
});
};
JavaScript
복사