지금까지 React 프로젝트를 시작할 때는 항상 CRA(create-react-app)을 사용하여 시작했습니다.
CRA를 설치한 후, 각 프로젝트마다 필요하거나 요구되는 조건에 맞게 각종 의존성을 설치하고... 설치되는 의존성에 따라 필요한 설정 파일들과 내용을 상황에 맞게 구성하고... 의존성 간 버전 충돌이 생기면 열심히 브라우저 검색해서 해결하고... 그러다보면 어느새 몇 시간이 사라지는 반복되는 개발 일상을 살아왔습니다. 혹시 여러분도 그런 경험 있으신가요?
매번 답답함을 느끼던 어느 날, 개발 동료로부터 'boilerplate'라는 단어를 들었는데, 찾아보니 boilerplate code가 새로운 프로젝트를 시작할 때 환경 설정 시간을 크게 단축시켜줄 것 같아 boilerplate 설치 패키지를 제작하기로 결심했습니다.
boilerplate code가 뭘까?
보일러플레이트 코드(boilerplate code)란 최소한의 변경으로 여러곳에서 재사용되며, 반복적으로 비슷한 형태를 띄는 코드를 말합니다. 여기 참고!
여러 블로그 글들과 사례들을 검색한 끝에 React + TypeScript boilerplate를 만드는 방식을 3가지로 추려봤습니다.
npm install
명령어로 지정된 의존성을 설치한다.npx create-react-den my-app
실행한다.npx create-react-den my-app
명령어로 실행한다.각각의 장, 단점을 정리해보면.
첫 번째와 두 번째 방법에 비해 세 번째 방법이 개발이 더 어려울 수 있지만 세 번째 방법으로 프로젝트를 구현하면, 사용자는 프로젝트 상황에 맞게 npx create-react-app
명령어 하나만으로 프로젝트 환경구성을 완료할 수 있어서 범용성도 좋다고 판단되어 세 번째 방법으로 boilerplate 프로젝트를 제작하기로 했습니다.
그럼 같이 개발해볼까요? 글에 부정확한 정보가 있을 수 있으니, 피드백은 언제나 환영입니다.
우선 본격적인 개발을 시작하기 전에, 위에서 언급한 세 번째 방법의 내용을 토대로 개발 feature를 정리해봅시다.
사실 대부분의 라이브러리들은 라이브러리를 사용하지 않고도 여러 언어들을 이용해서 직접 구현할 수 있습니다.(말은 쉽지만...)
하지만, 라이브러리들이 제공하는 기능들을 직접 구현을 하게 되면 당연히 그만큼 개발 리소스를 많이 투입해야 됩니다.
시간적 여유가 있다면, 라이브러리를 사용하지 않고 직접 구현해서 사용 해보면 좋지만, 현실적인 한계(시간 여유 등)가 있어 여러 라이브러리의 도움을 받기로 결정했습니다.
사용자가 입력한 명령어 파싱하기를 어떻게 구현할까 고민하던 중, create-react-app 소스코드를 뜯어보다가 힌트를 얻었습니다.
사용자가 npm create-react-app my-app
명령어를 입력 했을 때 처리되는 로직을 보면,
(...생략)
const chalk = require('chalk');
const commander = require('commander');
(...)
const fs = require('fs-extra');
(...)
const path = require('path');
(...)
const packageJson = require('./package.json');
function isUsingYarn() {
return (process.env.npm_config_user_agent || '').indexOf('yarn') === 0;
}
let projectName;
function init() {
const program = new commander.Command(packageJson.name)
.version(packageJson.version)
.arguments('<project-directory>')
.usage(`${chalk.green('<project-directory>')} [options]`)
.action(name => {
projectName = name;
})
.option('--verbose', 'print additional logs')
(...)
우선 'commander' 라는 라이브러리의 commander를 이용해서,
npm create-react-app my-app
를 입력했을 때, packageJson.name
(create-react-app 내 package.json 파일의 객체 "name"의 "value"값은 create-react-app)과 동일한 이름의 패키지를 Command 객체에 넘겨준다..arguments('<project-directory>')
가 사용자의 명령어 중 3번째 인자인 프로젝트 디렉토리명을 받는다..action(name => (생략) )
에서 프로젝트 디렉토리명을 name으로 받아서, projectName에 할당하고 사용자가 원하는 디렉토리에 프로젝트가 생성되도록 구현되어 있다.이번 프로젝트에서 필요로 하는 기능들을 commander 라이브러리가 제공하고 있다는 것을 알 수 있습니다.
사용자에게 지정된 기술스택만을 제공할까 했지만, eslint -init
명령어를 입력했을 때 출력되는 대화형 프롬프트를 보고 좀 더 유연한 프로젝트를 개발해보고 싶다는 생각을 하게 됐습니다.
// eslint -init 입력시 나오는 질문들
# CRA로 리액트 프로젝트 시작
yarn create react-app . --template typescript
# eslint --init 으로 eslint 세팅
yarn eslint --init
√ How would you like to use ESLint? · style
√ What type of modules does your project use? · esm
√ Which framework does your project use? · react
√ Does your project use TypeScript? · Yes
√ Where does your code run? · browser
√ How would you like to define a style for your project? · guide
√ Which style guide do you want to follow? · airbnb
√ What format do you want your config file to be in? · JSON
√ Would you like to install them now with npm? · No
위와 같이 CLI에서 대화형 프롬프트를 생성해주는 라이버러리를 찾다가, inquirer를 발견했습니다. inquirer는 Node.js 환경에서 사용자와 상호작용하는 명령줄 인터페이스를 생성하는데 사용되는 라이브러리입니다.
inquirer 기능들,
대화형 프롬프트를 직접 구현 안해도 되고, 대화형 프롬프트를 구현하기 위해 필요한 다양한 기능들을 제공해서 이 라이브러리를 사용하기로 결정했습니다.
개발 feature 2-1을 보면 사용자가 질문에 답변한 내용을 바탕으로 동적으로 필요한 의존성들을 설치하는 로직을 구현해야 됩니다. 이를 구현하기 위해 쉘 명령어를 Node.js에서 실행하기 위해서 Node.js에 내장되어 있는 child_process
, spawn
등을 사용해도 됩니다. 하지만 이 모듈들은 개인적으로 가독성이 떨어지는 등의 이유로 배제했습니다.(exec
, echo
등등)(핑계..)
그래서 선택한게 shelljs 입니다. shelljs는 외부 의존성 없이 Node.js 자체의 모듈만으로 작동되서 추가적인 설치나 설정 없이 사용할 수 있습니다.
그리고 제공하는 메소드들의 가독성 또한 좋다고 판단(네,.?)해서, 사용하기로 결정했습니다.(shelljs에도 exec 메서드가 있는건 비밀)
chalk의 경우 혹은 CLI을 꾸미고 싶을 때, 사용할 수 있는 라이브러리 입니다.
chalk를 사용해 "Your App is ready" 메시지를 초록초록하게 만들었다.
CLI 꾸미기는 개발 feature에 포함되어 있지 않아서 chalk를 굳이 사용할 필요가 없지만, 사용자에게 터미널에서 중요한 메시지나 강조하고 싶은 메시지를 보여주고 싶을 때 유용하게 사용할 수 있을 것 같아서 사용하기로 했습니다.
그럼 이제 프로젝트 환경 구성을 해보겠습니다.
우선 프로젝트를 시작해야 되니 프로젝트를 시작할 디렉토리로 이동하고 아래 명령어 입력!
npm init -y
npm install commander shelljs chalk@4.1.1 inquirer@8.2.4
├── bin
| └── create-custom-app.js
├── node_modules
├── package-lock.json
├── package.json
└── README.md
폴더 구조를 보면, 스크립트를 bin 폴더 하위에 생성했다.
npm 배포를 위해 필요한 내용들로 package.json을 구성했습니다. 구성 방법은 여기 글 참고!
{
"name": "create-custom-app",
"version": "1.0.0",
"description": "Create custom app based on command line tool with Node.js",
"bin": {
"create-custom-app": "./bin/create-custom-app.js"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"cli",
"command",
"line",
"tool",
"cra",
"create",
"react",
"app",
"custom",
"custom-app"
],
"homepage": "https://github.com/sjuhan123/create-custom-app",
"bugs": {
"url": "https://github.com/sjuhan123/create-custom-app",
"email": "sjuhan123@gmail.com"
},
"license": "MIT",
"author": {
"name": "SeungJu Han",
"email": "sjuhan123@gmail.com",
"url": "https://github.com/sjuhan123"
},
"repository": {
"type": "git",
"url": "https://github.com/sjuhan123/create-custom-app.git"
},
"dependencies": {
"chalk": "^4.1.1",
"commander": "^11.0.0",
"inquirer": "^8.2.4",
"shelljs": "^0.8.5"
}
}
package.json
파일의 bin 속성을 사용해서 프로젝트 내부의 스크립트 파일을 글로벌 명령어로 사용할 수 있게 한다.npx create-custom-app my-app
명령어를 실행하면 npx
는 create-cumstom-app
패키지를 찾고, 해당 패키지의 package.json에 정의된 bin 필드의 스크립트 파일을 실행하게 된다.(bin과 npx에 대한 참고 문서)드이어 환경 구성이 끝났습니다. 이제 라이브러리를 활용해서 구현을 시작해보아요!
const fs = require("fs");
const path = require("path");
const commander = require("commander");
const chalk = require("chalk");
const shell = require("shelljs");
const inquirer = require("inquirer");
const packageJson = require("../package.json");
create-react-app
에서 착안해서, 사용자가 3가지 명령어를 입력했을 때 대응할 수 있도록 구현해보겠습니다.
// 1. working directory 명시하지 않으면 현재 위치에 프로젝트를 생성한다.
> npx create-custom-app
// 2. working directory를 "."으로 입력하면 현재 위치에 프로젝트를 생성한다.
> npx create-custom-app
// 3. working directory를 명시하면, 명시한 이름으로 폴더명을 생성하고 하위에 프로젝트를 생성한다.
> npx create-custom-app my-app
여기서 부터 코드들은 전부 이어집니다. 빠른 구현을 위해 하드코딩된 부분이 많고, 코드 분리가 되어 있지 않아서 가독성이 떨어집니다. (전체 코드) 이후 버전업을 통해서 리팩토링을 진행할 예정입니다.
const program = new commander.Command();
program
.description(packageJson.name) // 프로젝트 이름
.version(packageJson.version) // 프로젝트 버전
.arguments("[folderName]") // 명령어의 인자를 설정.
.usage(`${chalk.green("[folderName]")} [options]`) // 도구의 사용법
.action((folderName = ".") => {
// 사용자가 입력한 폴더 이름 반영
const projectName = folderName;
const projectPath = path.join(process.cwd(), projectName);
commander
라이브러리를 사용해서 program
객체를 생성하고, 이 객체에 CLI 도구의 기본 정보를 설정합니다.folderName
을 선택적으로 입력받는다..action
콜백함수의 매개변수 folderName
의 기본값을 "."으로 설정해서, 사용자가 폴더명을 명시하지 않았거나, "."으로 입력 했을때 대응한다. const projectName = folderName;
const projectPath = path.join(process.cwd(), projectName);
if (projectName !== ".") {
if (!fs.existsSync(projectPath)) {
fs.mkdirSync(projectPath);
} else {
console.log(
`The folder ${chalk.red(
folderName
)} already exist in the current directory, please give it another name.`
);
process.exit(1);
}
}
process.chdir(projectPath);
folderName
이 "." 이 아니고 폴더명을 명시했을 때,folderName
이 "." 이거나 아예 명시하지 않았을 때, 현재 작업 디렉토리를 projectPath
로 변경합니다.process.chdir(projectPath)
: 현재 작업 디렉토리를 projectPath
로 변경대화형 프롬프트를 구현하기 전에, 사용자에게 어떤 질문을 하면 좋을지 고민해보면... 사용자가 다양한 기술스택을 선택할 있도록 하면 좋겠지만, 이 프로젝트의 취지는 create-custom-app 을 설치하고 실행하면 바로 프로젝트 개발을 시작할 수 있게 환경을 세팅해주는 것입니다.
그러면 기술스택의 config들도 동적으로 생성해줘야 되는데, 제가 사용해보지 않았거나 거의 사용해본적 없는 기술스택의 경우 구현하기 어렵다고 판단해서 익숙한 기술스택을 질문 List에 포함했습니다.
사용자가 다양한 기술스택들을 선택할 수 있도록 버전업을 할 예정
위 질문 List를 바탕으로, inquirer 라이브러리를 사용해서 구현해보면,
inquirer
.prompt([
{
type: "list",
name: "reactEnvironment",
message: "Select the react environment:",
choices: ["React + JavaScript", "React + TypeScript"],
},
{
type: "list",
name: "useReactRouterDom",
message: "Use React Router Dom?",
choices: ["Yes", "No"],
},
{
type: "list",
name: "styleLibrary",
message: "Select a style library:",
choices: ["styled-components", "emotion", "none"],
},
{
type: "list",
name: "usePrettierEslint",
message: "Use Prettier and ESLint?",
choices: ["Yes", "No"],
},
])
코드 가독성도 좋고 정말 쉽습니다. 코드를 보면 type에 list만 사용했는데, list 뿐만 아니라 input, confirm, checkbox, rawlist, password 등 정말 다양한 type을 제공하니까 상황에 맞게 적용하면 됩니다.
inquirer
.prompt([{
type: "list",
name: "reactEnvironment",
message: "Select the react environment:",
choices: ["React + JavaScript", "React + TypeScript"],
},
(...생략)
])
.then((answers) => {
// 사용자의 답변이 answers에 담겨있다.
const {
reactEnvironment,
useReactRouterDom,
styleLibrary,
usePrettierEslint,
} = answers;
코드를 큰 틀에서 보면 inquirer.prompt().then((answer) => {})
의 순서로 동작된 다는 것을 알 수 있습니다.
fetch.then()
과 정말 유사해보이는데, 실제로도 동일하게 동작합니다.
prompt()
를 실행해서 받은 사용자의 응답을 비동기로 받아서 then 콜백함수의 매개변수로 전달해줍니다.
answer
매개변수는 prompt
배열 객체의 name
값과 동일한 변수를 가지고 있는데, 이 변수에 사용자의 응답이 할당되어 있습니다.
.then((answers) => {
// 사용자가 입력한 값에 따라 dependency 및 devDependency 설치
const {
reactEnvironment,
useReactRouterDom,
styleLibrary,
usePrettierEslint,
} = answers;
const installPackages = ["react", "react-dom", "react-scripts"];
const devPackages = [
"@babel/plugin-proposal-private-property-in-object",
];
if (reactEnvironment === "React + TypeScript") {
installPackages.push("typescript");
devPackages.push("@types/react", "@types/react-dom");
}
(...생략...) // 반복 로직
shell.exec(`npm init -y`);
console.log(chalk.green("Downloading files and packages..."));
shell.exec(`npm install ${installPackages.join(" ")}`);
shell.exec(`npm install ${devPackages.join(" ")} --save-dev`);
dependency 패키지들을 담은 배열 변수와 devdependency를 담은 배열 변수를 각각 선언합니다.
그리고 answers
객체에 있는 각각의 변수 값(사용자의 답변)들을 검사하면서, 검사 결과에 맞는 의존성들을 각각의 배열에 맞게 넣어줍니다.
다 넣어주고 나서, shell 라이브러리를 이용해서 의존성들을 설치해줍니다.
// 사용자가 입력한 값에 따라 config 파일 생성
// 원하는 설정을 넣어주면 됩니다.
if (reactEnvironment === "React + TypeScript") {
const tsconfig = {
compilerOptions: {
target: "ES6",
lib: ["dom", "dom.iterable", "esnext"],
allowJs: true,
skipLibCheck: true,
esModuleInterop: true,
allowSyntheticDefaultImports: true,
strict: true,
forceConsistentCasingInFileNames: true,
noFallthroughCasesInSwitch: true,
module: "ESNext",
moduleResolution: "node",
resolveJsonModule: true,
isolatedModules: true,
noEmit: true,
jsx: "react",
outDir: "./dist",
},
include: ["src"],
};
fs.writeFileSync("tsconfig.json", JSON.stringify(tsconfig, null, 2));
}
(...생략...) // 반복되는 config 생성 로직 생략
사용자가 "React + TypeScript"를 선택했다면, fs 모듈을 이용해 "tsconfig.json" 파일을 생성하고, option 값들을 가진 객체를 생성해서 넣어줍니다. config가 필요한 다른 의존성들도 동일하게 필요한 파일을 생성하고 원하는 옵션들을 넣어서 구현해주세요!
const packageJsonPath = "package.json";
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8");
const packageJson = JSON.parse(packageJsonContent);
packageJson.name = projectName;
packageJson.scripts = {
start: "react-scripts start",
build: "react-scripts build",
test: "react-scripts test",
eject: "react-scripts eject",
};
packageJson.eslintConfig = {
extends: ["react-app"],
};
packageJson.browserslist = {
production: [">0.2%", "not dead", "not op_mini all"],
development: [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version",
],
};
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
JSON 객체의 각 속성에서 수정 혹은 추가해야 되는 부분들을 동적으로 실행해주는 로직을 구현합니다. 따로 package.json의 내용을 수정하지 않고 바로 프로젝트를 시작할 수 있도록 내용을 구성해줍니다.
cra를 실행하면 생성되는 폴더 및 파일들이 있습니다.
예를 들어서, public, src 폴더 및 하위 파일들을 생성해줘야 됩니다.
그리고 필수는 아니지만, .gitignore와 src 하위에 폴더 구조 및 파일 등등 다양한 폴더와 파일들을 각자의 입맛에 맞게 동적으로 생성되도록 로직을 구현해주면 됩니다.
(...생략...)
// 하드코딩...! 리팩토링 해야지~
fs.mkdirSync("src");
fs.mkdirSync("src/components");
fs.mkdirSync("src/pages");
fs.mkdirSync("src/hooks");
fs.mkdirSync("src/utils");
fs.mkdirSync("src/types");
fs.mkdirSync("src/constants");
fs.mkdirSync("src/context");
fs.mkdirSync("src/styles");
if (
reactEnvironment === "React + JavaScript" ||
reactEnvironment === "React + TypeScript"
) {
const indexContent = `import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
`;
const appContent = `import React from 'react';
const App = () => {
return <div>Hello!</div>;
};
export default App;
`;
if (reactEnvironment === "React + JavaScript") {
fs.writeFileSync("src/index.jsx", indexContent);
fs.writeFileSync("src/App.jsx", appContent);
} else {
fs.writeFileSync("src/index.tsx", indexContent);
fs.writeFileSync("src/App.tsx", appContent);
}
}
(...생략...)
혹시 눈치채셨을까요?! 지금까지의 코드들을 보면 비동기로 처리되는 로직들이 병렬로 처리가 되도록 구현되어 있지 않습니다. 이로 인해서 패키지 실행시 완료되기까지 긴 시간이 소요 됩니다. 그래서 다음 버전업 때 Promise.All 등을 활용해서 리팩토링을 해 성능 개선을 시도해볼 예정입니다.
(...생략...)
console.log(chalk.green("Your App is ready!"));
});
});
program.parse(process.argv);
program.parse(process.argv)
는 commander
를 사용하여 정의한 명령어와 옵션을 process.argv를 기반으로 파싱하고 실행하는 역할을 해줍니다.
이제 로컬에서 npx create-custom-app
을 입력하면
그럼 이제 마지막 개별 feature인 npm에 배포를 해봅시다!
npm login // npm 아이디로 로그인
npm version // 프로젝트 버전 관리
npm publish --access public // npm에 최초 배포
npm publish // 최초 배포이후에 배포시
다음 글은 이 프로젝트를 진행하면서 만났던 에러에 어떻게 대응했는지를 정리한 글을 포스팅하겠습니다.
감사합니다.
npx create-custumized-app 폴더이름
을 입력하면 실행가능합니다.참고 글
- npx와 bin 관련 블로그 글: https://gist.github.com/casamia918/aa8c986504d942223379e0af0c15644f
- boilerplate 제작기 참고 블로그: https://velog.io/@jjunyjjuny/React-TS-boilerplate-%EC%A0%9C%EC%9E%91%EA%B8%B0-%ED%99%98%EA%B2%BD-%EA%B5%AC%EC%84%B1
- npm배포 관련 글: https://heropy.blog/2019/01/31/node-js-npm-module-publish/
- CRA 소스코드 : https://github.dev/facebook/create-react-app/blob/main/packages/react-scripts/scripts/init.js
- Eslint 소스코드: https://github.dev/eslint/eslint
- Eslint -init 관련 글: https://chinsun9.github.io/2021/11/20/eslint-init/