백오피스 같은 프로젝트는 만들다보면 API 호출 주소만 다른 같은 레이아웃, 코드 구조가 반복됩니다. 사실상 폴더 자체 복붙해서 파일명, 컴포넌트명, API 주소만 바꾸면 되는 격이죠. 지금까지 폴더를 복붙했었지만 어느순간부터 짜친다라는 느낌이 들더라고요. 분명 자동화하는 방법이 있을텐데? 라는 생각이 들면서 "맞다, CLI 툴을 이용하면 되겠구나!" 싶어서 바로 실행해봤습니다.
다양한 CLI 툴이 있는데, 우리는 정말 간단하게 일관성 있는 코드로 page, apis, consts만 생성하면 되는거라서 plop.js를 쓰게되었습니다.
plop.js
Plop: Consistency Made Simple
A little tool that saves you time and helps your team build new files with consistency. Generate code when you want, how you want.
plopjs.com
plop.js는 복잡하지 않고, 사용하기가 쉬우며 경량화된 라이브러리입니다.
$ npm install plop
$ yarn add plop
위 명령어를 통해 패키지를 설치합니다.
그리고 프로젝트 root에 plopfile.cjs 파일을 생성합니다.
module.exports = function (plop) {
plop.setGenerator("controller", {
description: "application controller",
prompts: [],
actions: []
};
여기에 이제 원하는 내용을 작성하면 됩니다
setGenerator는 새로운 코드 생성 명령어를 등록하는 함수입니다
- plop.setGenerator("함수 이름", {})
- description : 함수 설명
- prompts : 사용자 입력 프롬프트
- actions : 파일 생성 액션
그 다음 기준이 될 템플릿 파일을 만들면 됩니다
프로젝트 root에 plop-templates 폴더를 만들어서 함수 이름과 매칭되는 폴더를 만들어줍니다.
(plop-templates 안에 폴더는 여러개가 될 수 있습니다)
my-project/
└── plop-templates/
└── controller/
저는 src/pages와 src/features에 자동으로 폴더 및 파일을 생성하는 명령줄들을 작성했습니다
module.exports = function (plop) {
plop.setGenerator("default", {
description: "create default page, feature (apis, consts)",
prompts: [
{
type: "list",
name: "location",
message: "어디에 생성할까요?",
choices: [
{
name: "페이지 대분류 폴더 하위 (pages/대분류)",
value: "pages/domain",
},
{ name: "페이지 root 폴더 (pages)", value: "pages" },
],
},
{
type: "input",
name: "domain",
message: "대분류명(도메인)을 입력해주세요. (Ex. claim, user 등)",
when: (answers) => answers.location === "pages/domain",
},
{
type: "input",
name: "name",
message: "페이지명을 입력해주세요.",
},
{
type: "input",
name: "hrefName",
message: "페이지 메뉴 링크를 입력해주세요.",
},
{
type: "input",
name: "pageName",
message: "페이지 메뉴 이름을 입력해주세요.",
},
]
/* ... */
};
사용자에게 입력 받을 프롬프트입니다.
- type : 어떤 방식으로 사용자에게 입력 받을 건지
- name : 사용자가 입력한 값의 변수명 같은 것
- message : 사용자에게 보여질 내용
- choices : type이 list인 경우 선택지 목록
- when : 해당 프롬프트를 진행할지 말지의 여부를 결정
module.exports = function (plop) {
plop.setGenerator("default", {
/* ... */
actions: function (data) {
const actions = [];
actions.push({
type: "addMany",
destination:
data.location === "pages"
? `src/pages/{{kebabCase name}}`
: `src/pages/${data.domain}/{{kebabCase name}}`,
base: "plop-templates/default",
templateFiles: "plop-templates/default/*.tsx.hbs",
skipIfExists: true,
});
actions.push({
type: "addMany",
destination:
data.location === "pages"
? `src/features/{{kebabCase name}}/apis`
: `src/features/${data.domain}/apis/{{kebabCase name}}`,
base: "plop-templates/default/features/apis",
templateFiles: "plop-templates/default/features/apis/**/*",
skipIfExists: true,
});
actions.push({
type: "addMany",
destination:
data.location === "pages"
? `src/features/{{kebabCase name}}/consts`
: `src/features/${data.domain}/consts/{{kebabCase name}}`,
base: "plop-templates/default/features/consts",
templateFiles: "plop-templates/default/features/consts/**/*",
skipIfExists: true,
});
/* ... */
return actions;
},
});
};
파일 생성하는 액션 부분입니다.
- type : 어떤 액션을 사용할지? addMany는 여러 파일을 추가할 수 있는 액션
- destination : 파일을 어디에 생성할지 (경로 지정), 어떤 파일명으로 생성할지
- {{??? name}} : 이 부분은 사용자에게 입력받은 값을 어떤 명명 규칙으로 생성할지를 지정
- base : destination 경로에 복사할때 생략할 경로의 시작점. 공식 문서 설명이 좀 복잡한데 결론은 어떤 파일(or 폴더)을 복사할건지 지정한다고 해석하는게 편할듯
- templateFiles : 어떤 파일을 복사할지 지정
- plop-templates/default/*.tsx.hbs : default 폴더의 하위에 있는 tsx.hbs 파일만 복사한다는 의미
- plop-templates/default/features/apis/**/* : 해당 디렉토리 내의 모든 파일을 복사한다는 의미
- skipIfExists : 이미 존재하는 파일(or 폴더)가 있다면 해당 파일을 덮어쓰지도 않고 생성하지도 않는 옵션
module.exports = function (plop) {
plop.setGenerator("default", {
/* ... */
actions: function (data) {
/* ... */
actions.push(function () {
try {
const filePath = path.resolve(__dirname, "src/consts/gnb.tsx");
let file = fs.readFileSync(filePath, "utf-8");
if (data.location === "pages/domain") {
const subMenuRegex = new RegExp(
`(id:\\s*["'\`]${data.domain}["'\`][\\s\\S]*?subMenu:\\s*\\[)([\\s\\S]*?)(\\])`
);
file = file.replace(subMenuRegex, (match, start, middle, end) => {
const newMenu = `\n { href: "${data.hrefName}", name: "${data.pageName}" },`;
return `${start}${middle}${newMenu}${end}`;
});
} else if (data.location === "pages") {
const gnbArrayRegex = /export const gnb = \[((?:.|\n)*?)\];/;
file = file.replace(gnbArrayRegex, (match, body) => {
const newObject = `
{
id: "${data.name}",
name: "${data.pageName}",
icon: <ClipboardIcon className="w-full" />,
isOpen: false,
subMenu: [
{ href: "${data.hrefName}", name: "${data.pageName}" }
]
},`;
return `export const gnb = [${body}${newObject}];`;
});
}
fs.writeFileSync(filePath, file, "utf-8");
return;
} catch (error) {
throw error;
}
});
actions.push({
type: "modify",
path: "src/pages/index.tsx",
pattern: /(\/\/ -- PLOP-IMPORT --)/g,
template: `$1\n${
data.location === "pages/domain"
? `import ${pascalName} from "~/pages/${data.domain}/${kebabName}/${pascalName}Index";
import ${pascalName}Detail from "~/pages/${data.domain}/${kebabName}/${pascalName}Detail";`
: `import ${pascalName} from "~/pages/${kebabName}/${pascalName}Index";
import ${pascalName}Detail from "~/pages/${kebabName}/${pascalName}Detail";`
}`,
});
actions.push({
type: "modify",
path: "src/pages/index.tsx",
pattern: /(\/\/ -- PLOP-ADD-ELEMENT --)/g,
template: `$1\n{
path: "${data.name}",
element: <${pascalName} />,
children: [
{ path: "create", element: <${pascalName}Detail /> },
{ path: ":id", element: <${pascalName}Detail /> },
]
},`,
});
return actions;
},
});
};
저는 여기서 추가로 새로운 페이지를 생성할때 meun에 추가하는 것과 pages index.tsx 파일에 route를 import하는 처리까지 진행하고 싶어서 custom action과 modify를 사용했습니다
- modify : 기존 파일의 문자열을 찾아서 수정하는 액션
- custom action : 함수형 액션으로 좀더 디테일한 제어가 가능
export const gnb = [
{
id: "claim",
name: "클레임",
icon: <ClipboardIcon className="w-full" />,
isOpen: false,
subMenu: [
{
href: "claimList",
name: "클레임 현황",
},
/* ... */
],
},
{
id: "voc",
name: "VOC",
icon: <ChatBubbleLeftRightIcon className="w-full" />,
isOpen: false,
subMenu: [
{
href: "voc",
name: "VOC 관리",
},
{
href: "vocLog",
name: "VOC 히스토리",
},
/* ... */
],
},
/* ... */
];
첫번째 custom action의 프로세스는 아래와 같다
- gnb.tsx 파일을 찾고 읽는다
- 특정 대분류의 하위 메뉴로 속해야할 경우 해당 id를 찾는다 (data.domain)
- subMenu에 추가한다
- 새로 신규 추가되는 메뉴일 경우 gnb 배열의 가장 마지막 index에 새로 추가한다
- 변경된 내용을 기존 파일로 파싱한다
import { RouteObject } from "react-router-dom";
import MainLayout from "../layouts/MainLayout";
/* ... */
import SignIn from "../pages/auth/SignIn";
import VOCDetail from "./voc/VOCDetail";
import VOC from "./voc/VOC";
// -- PLOP-IMPORT --
export const routes: RouteObject[] = [
{
path: "/signIn",
element: <SignIn />,
},
{
path: "/",
element: <MainLayout />,
children: [
// -- PLOP-ADD-ELEMENT --
{
path: "partChangeHistories",
element: <PartChangeHistories />,
children: [
{ path: "create", element: <PartChangeHistoryDetail /> },
{ path: ":id", element: <PartChangeHistoryDetail /> },
],
},
{ path: "licenseStatus", element: <LicenseStatus /> },
/* ... */
],
},
];
그 다음 2, 3번째 modify은 pages index.tsx 파일에 import하고 추가하는 형식인데, 원래 custom action 방식으로 정규식 표현을 이용해 element <MainLayout />을 찾으면 그 아래 children index 가장 마지막에 추가하려고 했었습니다만... 계속 children 하위 메뉴 index 0번째 object의 children에 추가되는 상황이 발생하였습니다.......
아무래도 파일의 문자열을 단순 읽어서 일치하는 문자열을 다 읽는 형태라 그런지 기준점이 계속이 바뀌는듯 했습니다. 그래서 결국 명확히 하기위해 기준이 될 주석문을 넣어줬고 modify 타입을 이용해서 해결했습니다

템플릿 파일명은 위와 같이 만들었습니다. 이렇게 작성해야지 사용자가 입력한 값이 Pascal Case 변환이 되어서 파일이 생성됩니다.
import {{pascalCase name}}Detail from "./{{pascalCase name}}Detail";
export default function {{pascalCase name}}Index() {}
템플릿 내부 코드에도 동적으로 변해야하는 네이밍에는 위와 같이 작성해주면 됩니다.
또한, 파일명에 .hbs는 handlebars 템플릿 엔진에서 사용하는 파일 확장자입니다 {{변수}} 문법을 통해 동적으로 내용을 치환할 수 있는 템플릿 파일을 의미합니다. plop.js에서는 .hbs를 안붙쳐주면 에러가 뜹니다
지금까지 만든걸 실행 시키기위해 그냥 명령어를 치거나 package.json에 추가해서 사용해도 됩니다
$ npx plop
"scripts": {
/* ... */
"plop": "plop"
},

yarn plop 명령어를 실행하면 이렇게 사용자 입력을 받고 조건에 맞게 파일을 생성해줍니다


파일들도 조건에 맞는 위치에 잘 생성이 되었고

gnb 배열에 하위메뉴로 잘 추가가 되었으며


index.tsx에도 import와, route 배열에 잘 추가가 된게 보입니다!
정말정말 간단하고 팀원들끼리 일관된 코드를 작성하기 위한 템플릿용으로 사용하기 매우 좋은 것 같습니다. B2C같은 프로젝트에서는 얼마나 효율성이 있을진 모르겠는데, 같은 구조가 반복되는 백오피스로는 딱이지 않을까 싶습니다. 다만, 팀원들간의 온보딩은 필수인 것 같습니다.
'Frontend' 카테고리의 다른 글
| jest, testing-library를 이용한 TDD 찍먹 해보기 (6) | 2025.06.26 |
|---|---|
| 다국어 라이브러리 비교 | react-i18next, react-intl, Lingui, next-translate (0) | 2025.05.21 |
| CRA 지원 종료. Vite로 마이그레이션 하기 (0) | 2025.05.20 |
| CRA 절대경로로 변경하는 법 (0) | 2025.05.19 |
| onKeyDown, onChange를 사용할때 한글 조합 문제 (5) | 2025.04.03 |
댓글