Node.js와 zx 라이브러리로 쉘 스크립트 쉽게 짜는 법

반복적인 터미널 명령어, 매번 똑같이 치기 귀찮을 때가 많아요. 이럴 때 쉘 스크립트를 만들어두면 아주 편해요. 하지만 Bash 스크립트 문법은 조금 낯설게 느껴질 수 있어요. JavaScript에 익숙하다면 Google의 zx 라이브러리로 Node.js 환경에서 쉘 스크립트를 작성할 수 있어요.


Google zx 공식 로고, 핑크-옐로우 그라데이션 X자 형태 아이콘


zx가 뭐고 왜 써야 하나요?


Google이 만든 zx는 JavaScript로 쉘 스크립트를 작성할 수 있게 해주는 도구예요. Bash 스크립트의 복잡한 변수 처리나 에러 핸들링을 JavaScript의 친숙한 async/await와 Promise로 깔끔하게 처리할 수 있죠.


Bash 대신 zx를 쓰면 좋은 점:


  • 익숙한 JavaScript 문법 사용
  • try-catch로 간단한 에러 처리
  • npm 패키지 활용 가능
  • JSON 파싱이 기본 내장
  • 비동기 처리가 자연스러움


zx 설치하고 첫 스크립트 만들기


zx를 사용하려면 먼저 Node.js가 설치되어 있어야 해요. v16 이상 버전을 권장해요. macOS 터미널을 열고 버전을 확인해보세요.


node --version  # v16.0.0 이상이어야 해요


이제 npm으로 zx를 전역에 설치해요.


# npm을 사용해 zx를 컴퓨터 전체에서 쓸 수 있도록 설치해요.
npm install -g zx


설치가 끝났다면, 스크립트 파일을 만들 차례에요. 파일 이름은 자유롭지만, 확장자는 .mjs를 사용해요. ES 모듈 문법을 사용하기 위함이에요.


my-script.mjs 라는 이름으로 파일을 만들고 아래 두 줄을 가장 먼저 추가해 주세요.


#!/usr/bin/env zx
import 'zx/globals';

// 앞으로 이 아래에 코드를 작성할 거예요.


첫 번째 줄 #!/usr/bin/env zx는 이 파일을 zx로 실행하라고 알려주는 약속(Shebang )이에요. 두 번째 줄 import 'zx/globals';는 $나 cd같은 zx의 편리한 기능들을 파일 어디서든 바로 쓸 수 있게 해줘요.


쉘 명령어 실행, $ 하나면 충분해요


zx의 핵심은 $ 함수예요. 백틱(```)으로 감싼 문자열 안에 쉘 명령어를 그대로 넣으면 돼요. await 키워드를 붙여주면, 명령어 실행이 끝날 때까지 기다렸다가 결과를 반환해요.

예를 들어, 현재 폴더의 파일 목록을 보고 싶다면 이렇게 작성해요.


#!/usr/bin/env zx
import 'zx/globals';

// 'ls -a' 명령어를 실행하고 결과를 기다려요.
const result = await $`ls -a`;

// 실행 결과 중에서 표준 출력(stdout) 내용을 화면에 보여줘요.
console.log(result.stdout);


$ 함수는 Promise를 반환하기 때문에 async/await 문법과 아주 잘 맞아요. 명령어 실행 결과는 객체 형태이고, stdout 프로퍼티로 결과 문자열에 접근할 수 있어요.


변수를 포함한 동적 명령어 실행


JavaScript 변수를 쉘 명령어에 자연스럽게 넣을 수 있어요. 따옴표 처리나 이스케이프를 신경 쓸 필요가 없어요.


#!/usr/bin/env zx
import 'zx/globals';

const fileName = 'myfile.txt';
const content = 'Hello from zx!';

// 파일 생성하고 내용 쓰기 - 변수가 자동으로 안전하게 처리돼요
await $`echo ${content} > ${fileName}`;

// 생성된 파일 확인
const result = await $`cat ${fileName}`;
console.log(`파일 내용: ${result.stdout}`);

// Git 브랜치 정보 가져오기
const branch = await $`git branch --show-current`;
console.log(`현재 브랜치: ${branch.stdout.trim()}`);


조금 더 실용적인 스크립트 만들어보기


이제 zx의 여러 기능을 조합해서 간단한 프로젝트 초기 설정 스크립트를 만들어 볼게요. 이 스크립트는 사용자에게 프로젝트 이름을 물어본 뒤, 해당 이름으로 폴더를 만들고 git을 초기화하는 작업을 자동화해요.


#!/usr/bin/env zx
import 'zx/globals';

// $.verbose를 false로 설정하면 실행되는 명령어를 일일이 보여주지 않아요.
// 스크립트가 더 깔끔하게 실행돼요.
$.verbose = false;

try {
  // 1. 사용자에게 프로젝트 이름을 물어봐요.
  const projectName = await question('새 프로젝트의 이름을 알려주세요: ');

  // 만약 사용자가 아무것도 입력하지 않았다면 오류를 발생시켜요.
  if (!projectName) {
    // chalk 라이브러리가 기본 내장되어 있어 색상있는 글씨를 쉽게 쓸 수 있어요.
    console.error(chalk.red('프로젝트 이름은 필수예요.'));
    // process.exit(1)은 스크립트가 비정상적으로 종료되었음을 의미해요.
    process.exit(1);
  }

  // 2. 입력받은 이름으로 폴더를 만들어요.
  await $`mkdir ${projectName}`;
  console.log(chalk.green(`'${projectName}' 폴더를 만들었어요.`));

  // 3. 새로 만든 폴더로 이동해요.
  cd(projectName);
  console.log(chalk.blue(`'${projectName}' 폴더로 이동했어요.`));

  // 4. git 저장소를 초기화해요.
  await $`git init`;
  console.log(chalk.green('git 저장소를 초기화했어요.'));

  // 5. README.md 파일을 만들고 내용을 채워요.
  const readmeContent = `# ${projectName}\n\n새로운 프로젝트예요.`;
  // fs.writeFile로 파일에 내용을 써요. fs도 기본으로 내장되어 있어요.
  await fs.writeFile('README.md', readmeContent);
  console.log(chalk.green('README.md 파일을 만들었어요.'));

  // 6. 프레임워크 선택 옵션 추가
  const useFramework = await question('프레임워크를 사용하시겠어요? (y/n): ');
  
  if (useFramework.toLowerCase() === 'y') {
    const framework = await question('사용할 프레임워크? (react/vue/next): ');
    
    if (framework === 'react') {
      await $`npx create-react-app .`;
    } else if (framework === 'vue') {
      await $`npm create vue@latest .`;
    } else if (framework === 'next') {
      await $`npx create-next-app@latest . --ts --tailwind --app`;
    }
  } else {
    // package.json 초기화
    await $`npm init -y`;
  }

  console.log(chalk.yellow('\n✨ 프로젝트 설정이 끝났어요! ✨'));

} catch (error) {
  // 스크립트 실행 중 어떤 단계에서든 오류가 발생하면 여기로 와요.
  console.error(chalk.red('\n오류가 발생했어요:'));
  console.error(error.stderr || error.message);
  process.exit(1);
}


디렉터리 이동과 환경 변수 다루기


zx는 cd() 함수로 디렉터리를 쉽게 이동할 수 있어요. Bash와 달리 스크립트 실행이 끝나도 원래 위치로 돌아가지 않아요.


#!/usr/bin/env zx
import 'zx/globals';

// 홈 디렉터리로 이동
cd('~');
console.log(`현재 위치: ${process.cwd()}`);

// 프로젝트 폴더로 이동 후 작업
cd('~/Documents/my-project');
await $`npm install`;

// 환경 변수 설정하기
process.env.NODE_ENV = 'production';
await $`npm run build`;

// 환경 변수를 읽어서 조건부 실행
if (process.env.CI === 'true') {
  console.log('CI 환경에서 실행 중이에요');
  await $`npm run test:ci`;
} else {
  await $`npm test`;
}


에러 처리와 조건부 실행


쉘 명령어가 실패할 수도 있으니 try-catch로 안전하게 처리해요. Bash 스크립트보다 훨씬 직관적이죠.


#!/usr/bin/env zx
import 'zx/globals';

try {
  // Git 상태 확인
  const status = await $`git status --porcelain`;
  
  if (status.stdout) {
    console.log(chalk.yellow('커밋되지 않은 변경사항이 있어요'));
    
    // 사용자에게 확인받기
    const shouldCommit = await question('자동으로 커밋할까요? (y/n): ');
    
    if (shouldCommit.toLowerCase() === 'y') {
      // 변경사항 자동 커밋
      await $`git add .`;
      await $`git commit -m "자동 커밋: ${new Date().toLocaleString()}"`;
      console.log(chalk.green('✓ 자동 커밋 완료!'));
    }
  } else {
    console.log(chalk.green('모든 파일이 최신 상태예요'));
  }
  
  // 원격 저장소로 푸시
  await $`git push origin main`;
  console.log(chalk.green('✓ 푸시 완료!'));
  
} catch (error) {
  console.log(chalk.red('Git 작업 중 오류가 발생했어요:'), error.message);
  
  // 오류 발생시 대체 작업
  await $`git stash`;
  console.log(chalk.blue('변경사항을 임시 저장했어요 (stash)'));
}


병렬 처리로 여러 작업 동시 실행하기


JavaScript의 Promise.all을 활용하면 여러 명령어를 동시에 실행할 수 있어요. 시간이 오래 걸리는 작업들을 병렬로 처리하면 전체 실행 시간을 크게 단축할 수 있죠.


#!/usr/bin/env zx
import 'zx/globals';

console.log(chalk.blue('여러 프로젝트 동시 빌드 시작...'));

// 시작 시간 기록
const startTime = Date.now();

// 병렬로 여러 작업 실행
const results = await Promise.all([
  $`cd ~/project1 && npm run build`,
  $`cd ~/project2 && npm run build`,
  $`cd ~/project3 && npm run build`
]);

// 소요 시간 계산
const elapsed = (Date.now() - startTime) / 1000;
console.log(chalk.green(`모든 빌드 완료! (${elapsed}초 소요)`));

// 각 결과 확인
results.forEach((result, index) => {
  console.log(`프로젝트${index + 1} 결과:`, result.stdout.slice(0, 100));
});


파일 작업과 JSON 처리


zx는 fs-extra와 같은 유용한 모듈을 기본 제공해요. JSON 파일을 읽고 쓰는 것도 아주 간단해요.


#!/usr/bin/env zx
import 'zx/globals';

// package.json 읽기
const packageJson = await fs.readJson('package.json');
console.log(`프로젝트 이름: ${packageJson.name}`);
console.log(`현재 버전: ${packageJson.version}`);

// 버전 업데이트 (예: 1.0.0 -> 1.0.1)
const versionParts = packageJson.version.split('.');
versionParts[2] = String(Number(versionParts[2]) + 1);
packageJson.version = versionParts.join('.');

// 업데이트된 내용 저장
await fs.writeJson('package.json', packageJson, { spaces: 2 });
console.log(chalk.green(`버전 업데이트 완료: ${packageJson.version}`));

// 백업 파일 생성
await $`cp package.json package.backup.json`;
console.log(chalk.blue('package.json 백업 완료'));

// 여러 JSON 파일 한번에 처리
const configs = await glob('configs/*.json');
for (const configFile of configs) {
  const config = await fs.readJson(configFile);
  console.log(`${configFile}: ${config.name || 'unnamed'}`);
}


fetch로 API 호출하고 스크립트에 활용하기


zx는 fetch도 기본 제공해서 외부 API와 연동하는 스크립트도 쉽게 만들 수 있어요.


#!/usr/bin/env zx
import 'zx/globals';

// GitHub API로 레포지토리 정보 가져오기
const response = await fetch('https://api.github.com/repos/google/zx');
const repo = await response.json();

console.log(chalk.yellow(`zx 스타 개수: ${repo.stargazers_count} ⭐`));
console.log(chalk.blue(`최근 업데이트: ${repo.updated_at}`));

// API 정보를 활용한 작업
if (repo.stargazers_count > 40000) {
  console.log(chalk.green('인기 있는 프로젝트네요!'));
  
  // 아직 클론하지 않았다면 로컬에 클론
  const exists = await fs.pathExists('./zx');
  if (!exists) {
    await $`git clone ${repo.clone_url}`;
    console.log('프로젝트를 클론했어요');
  }
}

// 날씨 API 호출 예제
const city = 'Seoul';
const weatherResponse = await fetch(`https://wttr.in/${city}?format=j1`);
const weather = await weatherResponse.json();
console.log(`${city} 현재 온도: ${weather.current_condition[0].temp_C}°C`);


실무에서 바로 쓸 수 있는 배포 스크립트


실제 프로젝트에서 자주 쓰는 배포 자동화 스크립트예요. 테스트, 빌드, 배포까지 한 번에 처리해요.


#!/usr/bin/env zx
import 'zx/globals';

// 배포 전 체크리스트
console.log(chalk.blue('🚀 배포 프로세스 시작...'));

// 컬러풀한 스피너로 진행 상황 표시
const spinner = (message) => {
  console.log(chalk.cyan(`⏳ ${message}...`));
};

try {
  // 1. 테스트 실행
  spinner('테스트 실행 중');
  await $`npm test`;
  console.log(chalk.green('✓ 테스트 통과'));
  
  // 2. 린트 검사
  spinner('코드 스타일 검사 중');
  await $`npm run lint`;
  console.log(chalk.green('✓ 린트 통과'));
  
  // 3. 빌드
  spinner('프로젝트 빌드 중');
  await $`npm run build`;
  console.log(chalk.green('✓ 빌드 완료'));
  
  // 4. Git 상태 확인
  const gitStatus = await $`git status --porcelain`;
  if (gitStatus.stdout) {
    await $`git add .`;
    await $`git commit -m "배포: v${new Date().getTime()}"`;
    console.log(chalk.green('✓ 변경사항 커밋'));
  }
  
  // 5. 태그 생성
  const version = `v${new Date().toISOString().split('T')[0]}`;
  await $`git tag ${version}`;
  await $`git push origin main --tags`;
  console.log(chalk.green(`✓ 태그 생성: ${version}`));
  
  // 6. 서버에 배포 (rsync 사용)
  spinner('서버에 파일 업로드 중');
  await $`rsync -avz ./dist/ user@server:/var/www/html/`;
  
  // 7. 서버 재시작 (SSH 명령)
  await $`ssh user@server "pm2 restart app"`;
  
  console.log(chalk.green.bold(`\n🎉 배포 완료! 버전: ${version}`));
  
  // 8. 배포 알림 (Slack webhook 예제)
  await fetch('https://hooks.slack.com/services/YOUR/WEBHOOK/URL', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `배포 완료! 🚀 버전: ${version}`
    })
  });
  
} catch (error) {
  console.log(chalk.red('✗ 배포 실패!'));
  console.error(error.message);
  
  // 실패 시 롤백
  const rollback = await question('이전 버전으로 롤백할까요? (y/n): ');
  if (rollback.toLowerCase() === 'y') {
    await $`git reset --hard HEAD~1`;
    await $`git push --force`;
    console.log(chalk.yellow('이전 버전으로 롤백했어요'));
  }
  
  process.exit(1);
}


출력 조절과 디버깅 팁


스크립트를 디버깅하거나 출력을 조절하고 싶을 때 유용한 팁들이에요.


#!/usr/bin/env zx
import 'zx/globals';

// verbose 모드 끄기 (명령어 출력 숨김)
$.verbose = false;

// 조용히 실행하고 결과만 처리
const files = await $`ls -la`;
console.log('파일 개수:', files.stdout.split('\n').length);

// 특정 부분만 verbose 켜기
$.verbose = true;
console.log(chalk.yellow('다음 명령어는 실행 과정을 보여줘요:'));
await $`echo "이 명령어는 보여요"`;
$.verbose = false;

// 실행 시간 측정
const start = Date.now();
await $`sleep 2`;
console.log(`실행 시간: ${Date.now() - start}ms`);

// 명령어 실패해도 계속 진행 (nothrow)
const result = await $`exit 1`.nothrow();
if (result.exitCode !== 0) {
  console.log('명령어가 실패했지만 계속 진행해요');
}

// 타임아웃 설정
try {
  await $`sleep 10`.timeout('3s');
} catch {
  console.log('타임아웃! 3초 이상 걸려서 중단했어요');
}


유용한 내장 함수들


zx가 제공하는 다른 유용한 함수들도 알아볼게요.


#!/usr/bin/env zx
import 'zx/globals';

// sleep - 일정 시간 대기
console.log('3초 기다려요...');
await sleep(3000);
console.log('계속 진행!');

// which - 실행 파일 경로 찾기
const nodePath = await which('node');
console.log(`Node.js 경로: ${nodePath}`);

// glob - 파일 패턴 매칭
const jsFiles = await glob('**/*.js');
console.log(`JavaScript 파일 ${jsFiles.length}개 발견`);

// os 정보 활용
console.log(`운영체제: ${os.platform()}`);
console.log(`CPU 코어: ${os.cpus().length}개`);

// 임시 디렉터리 생성
const tmpDir = await $`mktemp -d`;
console.log(`임시 폴더: ${tmpDir.stdout.trim()}`);
cd(tmpDir.stdout.trim());
// 작업 수행...
await $`rm -rf ${tmpDir.stdout.trim()}`;


스크립트 실행하기


작성한 스크립트를 실행하는 방법은 두 가지예요.


방법 1: 실행 권한 주고 직접 실행


가장 정석적인 방법이에요. 파일에 실행 권한을 한 번만 부여하면, 그 다음부터는 파일 이름만으로 실행할 수 있어요.


# my-script.mjs 파일에 실행 권한(+x)을 줘요.
chmod +x my-script.mjs

# 이제 파일을 직접 실행할 수 있어요.
./my-script.mjs


방법 2: zx 명령어로 실행


매번 실행 권한을 주기 번거롭다면 zx 명령어로 바로 실행할 수도 있어요.


zx my-script.mjs


두 방법 모두 동일하게 작동해요. 반복적으로 사용하는 스크립트라면 첫 번째 방법을, 가끔 한 번씩 실행하는 스크립트라면 두 번째 방법이 편할 수 있어요.


프로를 위한 고급 팁


1. 커스텀 에러 메시지


// 에러를 더 친근하게 표시
process.on('uncaughtException', (error) => {
  console.error(chalk.red('앗! 예상치 못한 오류가 발생했어요 😱'));
  console.error(chalk.yellow(error.message));
  process.exit(1);
});


2. 설정 파일 활용


// .zxrc.mjs 파일을 만들어 기본 설정 관리
export default {
  verbose: false,
  shell: '/bin/zsh',
  prefix: 'set -euo pipefail;',
  quote: (s) => `'${s.replace(/'/g, "'\\''")}'`
};


3. 재사용 가능한 함수 모듈화


// utils.mjs
export async function gitCommit(message) {
  await $`git add .`;
  await $`git commit -m ${message}`;
  console.log(chalk.green(`✓ 커밋: ${message}`));
}

// main.mjs
import { gitCommit } from './utils.mjs';
await gitCommit('기능 추가');


마무리


zx를 사용하면 복잡한 따옴표 처리나 변수 전달 문제를 신경 쓰지 않고도 JavaScript의 유연함으로 쉘 스크립트를 만들 수 있어요. Bash의 제한적인 문법에서 벗어나 JavaScript의 모든 기능을 활용할 수 있다는 게 가장 큰 장점이죠.


특히 비동기 처리, 에러 핸들링, JSON 파싱, API 호출 같은 작업이 훨씬 직관적이 되어요. 반복적인 작업을 자동화하고 싶을 때 zx로 나만의 스크립트를 만들어 보세요. 작업 효율이 크게 올라갈 거예요.


iTerm2 단축키로 터미널 작업 자동화하는 방법: 매크로처럼 활용하기