회사에서 처음으로 웨이팅이라는 프러덕트를 개발하게 되었다. 웨이팅은 모노레포로 되어있는데 총 6개의 레포가 묶여있었다.
yarn workspace로 구성이 되어있는데 명령어를 치는게 너무 어려웠다. 예를 들어 admin을 실행해야한다면
cli 명령어를 매우 길게 작성해야했다.
1yarn workspace @admin/A start:dev
다른 프러덕트도 모노레포로 되어있는게 있어서 처음에는 package.json에 단축어를 만드려고 했다.
1{
2 "scripts": {
3 "A": "yarn workspace @A"
4 }
5}
하지만 구조상 이렇게 만들 수가 없었다. 왜냐하면 위 명령어는 partner라는 공통 프러덕트를 묶어서 사용하기 때문에 workspace의 이름이 A와 B로 나뉜다.
웨이팅은 A, B 안에 각각 admin, client, check가 있기 때문에 workspace 이름이 client/A 로 되어있었다.
그래서 고민을 하다가 yarn create vite를 했을 때, shell에서 원하는 옵션을 선택할 수 있다는 것에 착안해서 cli 툴을 만들어보기로 했다.
node도 cli를 만드는게 가능했는데 GPT 덕분에 쉽게 만들 수 있었다.
1const readline = require("readline");
2const { spawn } = require("child_process");
3
4const rl = readline.createInterface({
5 input: process.stdin,
6 output: process.stdout
7});
8
9const packages = ["A", "B"];
10const workspace = ["admin", "client", "check"];
11const environments = ["dev", "staging", "production"];
12
13const askQuestion = (question, choices) => {
14 return new Promise((resolve) => {
15 console.log(question);
16 choices.forEach((choice, index) => {
17 console.log(`${index + 1}: ${choice}`);
18 });
19 rl.question("Enter your choice (number): ", (answer) => {
20 const choiceIndex = parseInt(answer, 10) - 1;
21 if (choiceIndex >= 0 && choiceIndex < choices.length) {
22 resolve(choices[choiceIndex]);
23 } else {
24 console.log("Invalid choice, please try again.");
25 resolve(askQuestion(question, choices));
26 }
27 });
28 });
29};
30
31const runCommand = (workspace, package, env) => {
32 const scriptMap = {
33 dev: "start:dev",
34 staging: "start:staging",
35 production: "start:prod"
36 };
37 const args = [`workspace`, `@${workspace}/${package}`, `run`, `${scriptMap[env]}`];
38
39 console.log(`Executing: yarn ${args.join(" ")}`);
40
41 const proc = exec("yarn", args, { stdio: "inherit" });
42
43 proc.on("error", (error) => {
44 console.error(`Error: ${error}`);
45 });
46
47 proc.on("close", (code) => {
48 console.log(`Process exited with code ${code}`);
49 rl.close(); // Make sure to close the readline interface
50 });
51};
52
53const main = async () => {
54 const workspace = await askQuestion("Select a workspace:", workspaces);
55 const package = await askQuestion("Select a package:", packages);
56 const env = await askQuestion("Select an environment:", environments);
57 runCommand(workspace, package, env);
58};
59
60main();
만들긴 했는데 webpack 상태가 터미널에 표시가 안된다!?
하지만 명령어 실행 후 webpack에서 hot reload가 되는 것을 볼 수 가 없었다. 즉 상태창 없이 개발해야하는 상황과 똑같았다.
GPT는 exec를 spawn으로 변경할 것을 제안해줬다.
exec를 사용해 다른 명령을 실행할 때, 자식프로세스의 stdout와 stderr은 부모의 프로세스(node.js)에 의해서 관리된다.
그래서 webpack과 같은 도구에서 발생하는 출력이 터미널에 표시되지 않았다. spawn으로 변경하면 새로운 프로세스를 생성할 때
스트림을 통해 실시간으로 데이터를 전송할 수 있게 해준다고 한다. 코드를 exec에서 spawn으로 변경했다.
이렇게 변경하니까 Webpack의 출력 데이터가 터미널에 표시됐다.
1- const proc = exec("yarn", args, { stdio: "inherit" });
2+ const proc = spawn("yarn", args, { stdio: "inherit" });
종료를 했는데 왜 터미널에 아무것도 입력이 안되지?
ctrl + c를 사용해서 프로세스를 종료하면 터미널에서 다른 명령어를 입력할 수 있어야한다. 하지만 백그라운드에서 뭔가가 종료가 되지 않은 듯 한 느낌이었다.
왜냐하면 나는 git을 사용할 때마다 암호를 물어보는데 암호 입력이 되지 않았고 종료도 안됐다. GPT는 프로세스가 종료되기 전에 표준 입력 스트림이 닫히지 않았기 때문이라고 했다.
그래서 종료하는 로직을 다시 생성했다.
1const readline = require("readline");
2const { spawn } = require("child_process");
3
4const rl = readline.createInterface({
5 input: process.stdin,
6 output: process.stdout,
7});
8
9const packages= ["A", "B"];
10const workspaces = ["admin", "client", "check"];
11const environments = ["dev", "staging", "production"];
12
13const askQuestion = (question, choices) => {
14 return new Promise((resolve) => {
15 console.log(question);
16 choices.forEach((choice, index) => {
17 console.log(`${index + 1}: ${choice}`);
18 });
19 rl.question("Enter your choice (number): ", (answer) => {
20 const choiceIndex = parseInt(answer, 10) - 1;
21 if (choiceIndex >= 0 && choiceIndex < choices.length) {
22 resolve(choices[choiceIndex]);
23 } else {
24 console.log("Invalid choice, please try again.");
25 resolve(askQuestion(question, choices));
26 }
27 });
28 });
29};
30
31const runCommand = (workspace, package, env) => {
32 const scriptMap = {
33 dev: "start:dev",
34 staging: "start:stg",
35 production: "start:prd",
36 };
37 const args = [
38 `workspace`,
39 `@${package}/${workspace}`,
40 `run`,
41 `${scriptMap[env]}`,
42 ];
43
44 console.log(`Executing: yarn ${args.join(" ")}`);
45
46 childProcess = spawn("yarn", args, { stdio: "inherit" });
47
48 childProcess.on("error", (error) => {
49 console.error(`Error: ${error}`);
50 });
51
52 childProcess.on("close", (code) => {
53 console.log(`Process exited with code ${code}`);
54 });
55};
56
57process.on("SIGINT", () => {
58 console.log("Received SIGINT. Cleaning up...");
59
60 // readline 인터페이스 닫기
61 rl.close();
62
63 // 표준 입력 스트림을 수동으로 종료
64 process.stdin.destroy();
65
66 if (childProcess) {
67 childProcess.on('exit', function () {
68 // 자식 프로세스가 종료되면, Node 프로세스를 종료합니다.
69 process.exit();
70 });
71 // 자식 프로세스에게 SIGINT 시그널을 전달하여 종료를 시도합니다.
72 childProcess.kill('SIGINT');
73 } else {
74 // 자식 프로세스가 없으면 바로 종료합니다.
75 process.exit();
76 }
77});
78
79const main = async () => {
80 const workspace = await askQuestion("Select a workspace:", workspaces);
81 const package = await askQuestion("Select a package:", packages);
82 const env = await askQuestion("Select an environment:", environments);
83 runCommand(workspace, package, env);
84};
85
86main();
이 코드를 실행하면 내가 원하는 결과를 얻을 수 있다.
마무리
개발을 하다보면 불편한게 많다. 그럴때면 항상 '이거 불편한데 어떻게 해야 조금 더 편해질 수 있을까'를 생각하게 된다.
'이렇게 고쳐보면 더 편해지겠다'라고 생각하는 단계로 나가기는 생각보다 쉽지 않다.
선험적 지식이 없기 때문이거나 무엇을 고쳐야할지 모르거나 고쳤지만 더 악화되거나 하는 상황들을 자주 맞주하기 때문이다.
하지만 나 혼자 개발을 하는 것이 아니기 때문에 충분히 개선할 수 있다. 고치고 PR 올리고 리뷰 받고 또 고치고 하는 일을 반복하면서 고민하고 배운다.
동료들은 많은 시간을 들여서 나에게 좋은 조언들을 해주었다.
node.js로 cli를 만드는 것은 생각치 못했던 일이다. C++을 배울 때는 처음에 std 라이브러리를 사용하면서 cli에 입, 출력을 해보는것이 자연스럽지만
node.js는 바로 npm package를 구성하고 난 뒤 express를 설치해서 서버를 만드는 일로 넘어가기 때문에 cli 입출력이 가능하다는 것을 처음 알았던 것 같다.
'node.js는 서버가 아닙니다. 런타임 환경입니다.'를 들어봤지만 그렇게 활용한 사례는 없었던 것 같다. 하지만 분명 나는 처음 nodejs를 만질 때 편하게 package.json을 만들었었다.
지금 당장 터미널에서 아래 명령어를 입력해보자.
그럼 터미널 입출력이 나오면서 사용자 입력을 받고 저장해두었다가 나중에 package.json을 작성해주는 경험을 이미 했었다.
또 vite나 nextjs를 설치할 때 선택한 환경을 구성해주는 것도 경험했었다. 다행이 이런 것들을 미리 경험했기 때문에 이번에 cli를 만들어 볼 수 있었던 것 같다.
게다가 chat GPT가 있다. 경험을 글로 풀어서 설명하면 그 경험을 쉽게 찾을 수 있고 쉽게 재현도 할 수 있다.
GPT가 언젠가는 내 일자리를 위협하는 날이 오겠지만 지금은 잘 활용하면 개발을 빠르게 잘 할 수 있는 시대가 온 것 같다.
install / uninstall package
패키지 설치, 제거 할 때 어떤 workspace에서 설치, 제거 할 것인지 기능을 만들었다. 대체적인 흐름은 비슷하다.
input-text.js 만들기
add인지 remove인지 선택하면 좋겠지만 사용자에게 자유도를 주는 것이 좋겠다고 판단해서 workspace까지만 만들고 나머지는 사용자 값을 받아서 명령어를 실행시키도록 스크립트를 작성하기로 했다.
구체적인 흐름은 choose-workspace.js와 같다.
1const readline = require("readline");
2const { spawn } = require("child_process");
3
4const rl = readline.createInterface({
5 input: process.stdin,
6 output: process.stdout
7});
8
9const packages = ["A", "B"];
10const workspaces = ["admin", "client", "check"];
11
12const askForPackageToInstall = async () => {
13 const customCommandInput = await new Promise((resolve) => {
14 rl.question("Enter the custom command (e.g., add react): ", resolve);
15 });
16 return customCommandInput;
17};
18
19const askQuestion = (question, choices) => {
20 return new Promise((resolve) => {
21 console.log(question);
22 choices.forEach((choice, index) => {
23 console.log(`${index + 1}: ${choice}`);
24 });
25 rl.question("Enter your choice (number): ", (answer) => {
26 const choiceIndex = parseInt(answer, 10) - 1;
27 if (choiceIndex >= 0 && choiceIndex < choices.length) {
28 resolve(choices[choiceIndex]);
29 } else {
30 console.log("Invalid choice, please try again.");
31 resolve(askQuestion(question, choices));
32 }
33 });
34 });
35};
36
37const runCommand = (workspace, package, command) => {
38 const args = [`workspace`, `@${package}/${workspace}`, `${command}`];
39
40 console.log(`Executing: yarn ${args.join(" ")}`);
41
42 childProcess = spawn("yarn", args, { stdio: "inherit" });
43
44 childProcess.on("error", (error) => {
45 console.error(`Error: ${error}`);
46 });
47
48 childProcess.on("close", (code) => {
49 console.log(`Process exited with code ${code}`);
50 });
51};
52
53process.on("SIGINT", () => {
54 console.log("Received SIGINT. Cleaning up...");
55
56 rl.close();
57
58 process.stdin.destroy();
59
60 if (childProcess) {
61 childProcess.on("exit", function () {
62 process.exit();
63 });
64
65 childProcess.kill("SIGINT");
66 } else {
67 process.exit();
68 }
69});
70
71const main = async () => {
72 const workspace = await askQuestion("Select a workspace:", workspaces);
73 const package = await askQuestion("Select a package:", packages);
74 const command = await askForPackageToInstall();
75 runCommand(workspace, package, command);
76};
77
78main();
1{
2 "scripts": {
3 "start": "node ./choose-workspace.js",
4 "etc": "node ./choose-workspace.js"
이렇게 해서 etc를 실행하면 command를 입력받아서 실행 시킬 수 있다.
하지만 설치가 안되는걸?
스크립트 작성 후에 etc를 실행해보자.
1yarn etc
2Select a workspace:
31: admin
42: client
53: check
6Enter your choice (number): 1
7Select a package:
81: A
92: B
10Enter your choice (number): 1
11Enter the custom command (e.g., add @radix-ui/react-radio-group): add react
12Executing: yarn workspace @A/admin add react
이 스크립트를 실행하면 입력한 값을 받아서 실행은 시키지만 입력한 command가 없는 command라는 에러를 받게 된다.
spawn 함수를 사용해서 실행하고 있기 때문인데 spawn은 배열로 커맨드를 전달하기 때문에 string은 하나의 커맨드로 인식하기 때문이다.
간단하게 해당 부분을 수정했다.
1const args = [`workspace`, `@${package}/${workspace}`, ...command.split(' ')];
이렇게 수정한 뒤에 etc 명령어를 다시 실행하면 command를 받아서 실행할 수 있게 된다.
이 코드는 monorepo-cli에 올려 놓았다.
부록 - node cli를 만들 때 도움이 될만한 자료
cli를 package로 만들 수 있는 방법이 없을까 찾아보았다. 몇개의 자료가 있어서 공유하려고 한다.
Ahmand라는 게스트가 Jason라는 개발자에게 자신의 nodejs cli 팁을 알려주면서 배우는 컨텐츠(강추)