프롤로그 - 검은 화면이 무섭다
개발 커뮤니티 모임에서 배포를 할 때였다. 경험 많은 개발자가 검은 터미널 창을 열더니 주문을 외우듯 뭔가를 입력했다. cd /var/www && git pull && npm run build && pm2 reload all 같은 주문이었다. 그러자 화면에 초록색, 빨간색 글자가 쏟아지더니 "배포 완료됐어요"라는 말과 함께 창이 닫혔다.
나는 마우스 클릭 한 번 없이 배포가 끝난 걸 보고 경악했다. "터미널 명령어 외우면 되는 건가요?" 물었더니, "아니요, 그냥 쉘 스크립트 짜놓으면 돼요"라는 답이 돌아왔다. 그때까지 나는 터미널을 그저 "개발자가 멋있어 보이려고 쓰는 검은 화면"쯤으로 생각했다. 진짜 쓰임새를 모르고 있었던 것이다.
고군분투 - 명령어 덩어리를 외우려 했던 시절
처음엔 쉘 명령어를 외우는 게 답인 줄 알았다. ls, cd, mkdir, rm... 사전을 만들어 외웠다. 근데 명령어는 끝도 없었다. 옵션도 다 달랐다. ls -l, ls -al, ls -alh... 이게 뭐가 다른지도 모르겠고, 대체 왜 이렇게 옵션이 많은 건지 이해가 안 갔다.
그러다 PATH 때문에 삽질을 했다. Python3를 설치했는데 터미널에서 python3를 쳐도 "command not found"라고 떴다. 분명히 설치했는데 왜 안 되는 거야? 구글링을 하다 보니 "환경변수 PATH에 추가하세요"라는 답변만 나왔다. .bashrc에 뭔가를 추가하라는데, .bashrc가 뭔지도 몰랐다. 왜 파일 이름 앞에 점이 붙어 있지? 이게 숨김 파일이란 것도 나중에 알았다.
더 황당했던 건, 맥북을 새로 사고 나서 예전에 쓰던 스크립트가 안 돌아가는 거였다. bash 문법으로 짠 스크립트였는데, macOS가 이제 zsh를 기본 쉘로 쓰면서 일부 문법이 안 맞았던 것이다. "쉘이 여러 개 있다고? 대체 뭐가 뭔데?" 이때부터 쉘 자체에 대해 파고들기 시작했다.
아하 모멘트 - 쉘은 그냥 프로그램이다
진짜 깨달음은 "쉘 자체가 하나의 프로그램"이란 걸 알았을 때였다. 나는 터미널과 쉘을 같은 거라고 생각했다. 그런데 아니었다. 터미널은 창문이고, 쉘은 그 안에서 돌아가는 프로그램이었다. 마치 크롬 브라우저(터미널)에서 유튜브(쉘)를 실행하는 것과 비슷한 개념이었다.
더 놀라운 건, 쉘도 결국 하나의 실행 파일이라는 거였다. /bin/bash, /bin/zsh 같은 파일이 실제로 디스크에 존재했다. 이 파일들을 실행하면 쉘이 켜지는 것이다. 그러니까 내가 터미널에서 입력하는 모든 명령어는 쉘이라는 프로그램이 읽고, 해석하고, 실행하는 과정이었던 거다. 마치 브라우저의 개발자 콘솔에서 자바스크립트를 입력하면 V8 엔진이 돌리는 것처럼.
그리고 깨달았다. "쉘이 바뀌면 문법이 바뀔 수 있구나." bash든 zsh든 결국 다른 프로그램이니까. Python2와 Python3가 다르듯이.
깊이 파고들기 - 쉘이 명령어를 실행하는 과정
커널 vs 쉘 - 알맹이와 껍데기
운영체제의 중심에는 커널(Kernel)이 있다. 커널은 CPU, 메모리, 디스크, 네트워크 같은 하드웨어 자원을 실제로 관리하는 녀석이다. 진짜 권력자다. 우리가 프로그램을 실행하거나 파일을 읽거나 네트워크 요청을 보낼 때, 결국엔 커널이 처리한다.
근데 커널한테 직접 말을 걸 수는 없다. 커널은 시스템 콜(system call)이라는 인터페이스로만 소통할 수 있는데, 이게 사람이 쓰기엔 너무 복잡하다. 0과 1의 세계다. 그래서 중간에 통역자가 필요한데, 그게 바로 쉘(Shell)이다.
쉘은 말 그대로 껍데기(Shell)다. 조개껍데기처럼 커널을 감싸고 있는 외피 역할을 한다. 우리가 인간의 언어로 명령을 내리면, 쉘이 이걸 받아서 커널이 알아들을 수 있는 시스템 콜로 바꿔준다. 쉘은 번역가이자 비서다.
예를 들어, 내가 mkdir project라고 치면:
- 쉘이 "사용자가 디렉토리를 만들고 싶어 하는구나" 하고 파악한다.
- 커널한테
mkdir()시스템 콜을 보낸다. - 커널이 파일 시스템에 디렉토리를 생성한다.
- 결과를 쉘에 돌려준다.
- 쉘이 화면에 아무것도 안 뜨게 하거나 에러 메시지를 띄운다.
이 과정이 순식간에 일어나기 때문에 우리는 "명령어를 입력하면 바로 실행되는구나"라고 느낀다.
명령어 해석 과정: Read → Parse → Execute
쉘은 결국 무한 루프를 도는 인터프리터다. REPL(Read-Eval-Print Loop)이라고도 한다. 쉘이 하는 일은 이렇게 세 단계로 나뉜다.
1단계 - Read (입력 읽기)
터미널에 ls -al /Users라고 치고 엔터를 누르면, 쉘은 이 문자열을 통째로 읽는다. 이때 쉘은 아직 아무것도 모른다. 그냥 "사용자가 뭔가 입력했네" 정도만 안다.
2단계 - Parse (명령어 해석)
이제 쉘이 입력받은 문자열을 토큰(Token)으로 쪼갠다. 공백을 기준으로 나눠서 ls, -al, /Users 세 덩어리로 만든다. 그러고 나서 첫 번째 토큰인 ls가 무엇인지 알아내야 한다.
쉘은 이렇게 찾는다:
- 내장 명령어(Built-in Command)인가?
cd,echo,alias같은 건 쉘에 내장되어 있다. 따로 실행 파일이 없다. - 함수(Function)로 정의된 명령어인가?
.bashrc나.zshrc에서 내가 직접 만든 함수일 수도 있다. - 외부 실행 파일인가?
PATH환경변수에 등록된 디렉토리들을 순서대로 뒤지면서ls라는 파일을 찾는다.
PATH는 쉘이 명령어를 찾을 때 참고하는 경로 목록이다. 보통 이런 식으로 되어 있다:
/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
쉘은 이 경로들을 왼쪽부터 하나씩 탐색한다. /usr/local/bin/ls가 있나? 없으면 /usr/bin/ls는? 이런 식으로 찾다가 /bin/ls를 발견하면 "아하, 이게 실행할 파일이구나" 하고 확정한다.
3단계 - Execute (실행)
이제 쉘은 커널에게 "이 프로그램 좀 실행해줘"라고 부탁한다. 구체적으로는:
fork()시스템 콜을 호출해서 쉘 프로세스를 복사한다. 자식 프로세스가 생긴다.- 자식 프로세스가
exec()시스템 콜을 호출해서/bin/ls프로그램으로 갈아타버린다. - 자식 프로세스가
ls프로그램을 실행한다. - 실행 결과(파일 목록)가 표준 출력(stdout)으로 나온다.
- 부모 프로세스(쉘)는
wait()시스템 콜로 자식이 끝날 때까지 기다린다. - 자식이 끝나면 쉘은 다시 프롬프트(
$)를 띄우고 다음 명령을 기다린다.
이 모든 과정이 1초도 안 걸린다. 쉘이 무한 루프를 돌며 입력을 기다리고, 입력이 들어오면 파싱하고, 실행하고, 다시 대기하는 것이다.
쉘의 종류: sh, bash, zsh, fish
쉘은 하나가 아니다. 여러 구현체가 있다. 마치 브라우저에 크롬, 사파리, 파이어폭스가 있듯이.
sh (Bourne Shell)
모든 쉘의 할아버지. 1977년에 만들어졌다. 기능이 별로 없지만, 거의 모든 유닉스 시스템에 기본으로 깔려 있다. 쉘 스크립트를 짤 때 #!/bin/sh로 시작하면 거의 어디서든 돌아간다. 호환성이 강점이다.
bash (Bourne Again Shell)
리눅스의 표준 쉘. GNU 프로젝트에서 만들었다. sh의 기능을 확장했다. 명령어 히스토리, 자동 완성, 변수 확장 같은 기능이 추가됐다. 대부분의 리눅스 배포판에서 기본 쉘로 쓴다. 내가 처음 배운 쉘도 bash였다.
zsh (Z Shell)
bash의 진화형. bash의 모든 기능을 가지고 있으면서, 더 강력한 자동 완성, 테마, 플러그인 시스템을 제공한다. Oh My Zsh 같은 프레임워크 덕분에 예쁘고 편하게 꾸밀 수 있다. macOS는 Catalina부터 zsh를 기본 쉘로 쓴다. 나도 지금 zsh 쓴다.
fish (Friendly Interactive Shell)
문법이 다른 신세대 쉘. bash, zsh와 다르게 POSIX 표준을 따르지 않는다. 그 대신 사용자 친화적인 문법을 제공한다. 자동 완성이 진짜 좋다. 명령어를 치면 회색으로 자동 완성 제안을 보여준다. 근데 기존 bash 스크립트가 안 돌아가는 게 단점이다.
어떤 쉘을 쓸까? 나는 zsh를 추천한다. bash 스크립트 호환성도 거의 있고, 편의 기능도 많다. 굳이 sh나 bash를 고집할 이유는 없다. 물론 취향 차이지만.
환경변수 - 쉘의 설정값 저장소
쉘은 환경변수(Environment Variable)라는 개념을 쓴다. 이건 쉘이 실행 중에 참고하는 설정값 모음이다. 마치 게임의 환경 설정 같은 거다.
대표적인 환경변수들:
PATH
명령어를 찾을 경로 목록. 앞에서 설명했다. 새로운 프로그램을 설치했는데 터미널에서 안 보인다면, PATH에 그 프로그램이 있는 디렉토리를 추가해야 한다.
export PATH="/usr/local/bin:$PATH"
이렇게 하면 /usr/local/bin이 맨 앞에 추가된다. 그러면 쉘이 명령어를 찾을 때 거기를 먼저 본다.
HOME
현재 사용자의 홈 디렉토리. cd ~를 치면 $HOME으로 이동한다. /Users/ratia 같은 경로가 담겨 있다.
SHELL
현재 쓰고 있는 쉘의 경로. /bin/zsh 같은 값이 들어 있다.
USER
현재 사용자 이름. whoami 명령어가 이걸 출력한다.
환경변수는 export 명령어로 설정한다. 그리고 쉘 설정 파일에 넣어두면 쉘이 켜질 때마다 자동으로 설정된다.
쉘 설정 파일: .bashrc, .zshrc, .profile
쉘은 시작할 때 설정 파일을 읽는다. 이 파일에 alias, 환경변수, 함수 같은 걸 정의해두면 쉘이 켜질 때마다 자동으로 적용된다.
.bashrc
bash를 위한 설정 파일. 홈 디렉토리(~)에 있다. bash가 시작할 때 이 파일을 읽어서 실행한다. 여기에 alias나 함수를 정의해두면 된다.
# .bashrc 예시
alias ll='ls -alh'
export PATH="/opt/homebrew/bin:$PATH"
function mkcd() {
mkdir -p "$1" && cd "$1"
}
.zshrc
zsh를 위한 설정 파일. 구조는 .bashrc와 비슷하다. zsh를 쓴다면 여기에 설정을 넣으면 된다.
.profile 또는 .bash_profile
로그인할 때 한 번만 실행되는 설정 파일. 주로 환경변수 같은 걸 여기 넣는다. .bashrc는 새 터미널 창을 열 때마다 실행되지만, .profile은 로그인 세션당 한 번만 실행된다.
이 파일들은 이름 앞에 점(.)이 붙어서 숨김 파일이다. ls로는 안 보이고 ls -a로 봐야 한다. 나도 처음엔 이 파일들을 못 찾아서 헤맸다.
파이핑과 리다이렉션 - 명령어를 조립하는 기술
쉘의 진짜 강력함은 파이프(Pipe)와 리다이렉션(Redirection)에서 나온다. 이걸 알면 명령어를 레고 블록처럼 조립할 수 있다.
파이프 (|)
파이프는 한 명령어의 출력을 다음 명령어의 입력으로 연결한다. 물이 파이프를 통해 흐르듯이 데이터가 흐른다.
ls -al | grep ".js" | wc -l
이 명령어는:
ls -al: 현재 디렉토리의 모든 파일을 나열한다.grep ".js": 그 중에서.js가 포함된 줄만 필터링한다.wc -l: 필터링된 줄의 개수를 센다.
결과적으로 "현재 디렉토리에 .js 파일이 몇 개 있나?"를 알려준다. 세 개의 명령어를 조합해서 새로운 기능을 만든 것이다.
리다이렉션 (>, >>, <)
리다이렉션은 입출력을 파일로 보내거나 파일에서 읽어온다.
# 출력을 파일로 저장 (덮어쓰기)
echo "Hello World" > hello.txt
# 출력을 파일에 추가 (이어쓰기)
echo "Second Line" >> hello.txt
# 파일에서 입력 읽기
wc -l < hello.txt
>는 파일을 덮어쓰고, >>는 파일 끝에 추가한다. <는 파일을 입력으로 쓴다.
에러 리다이렉션 (2>&1)
표준 출력(stdout)은 1번, 표준 에러(stderr)는 2번 스트림이다. 에러도 파일로 저장하고 싶으면 이렇게 한다:
npm run build > build.log 2>&1
이렇게 하면 표준 출력과 표준 에러가 모두 build.log 파일로 들어간다. 배포 스크립트를 짤 때 로그를 남기려고 자주 쓴다.
쉘 확장 - 글로빙과 브레이스 확장
쉘은 명령어를 실행하기 전에 확장(Expansion)을 먼저 한다. 우리가 입력한 문자열을 쉘이 알아서 변형시키는 것이다.
글로빙 (Globbing)
*나 ? 같은 와일드카드를 실제 파일 이름으로 바꿔준다.
rm *.log
이 명령어는 쉘이 먼저 *.log를 현재 디렉토리의 모든 .log 파일 목록으로 바꾼다. 그러고 나서 rm 명령어에 그 목록을 넘긴다. 실제로 실행되는 명령은 이런 식이다:
rm error.log debug.log access.log
쉘이 자동으로 확장해준 것이다.
브레이스 확장 (Brace Expansion)
중괄호를 써서 여러 개의 문자열을 생성한다.
mkdir -p project/{src,tests,docs}
이건 이렇게 확장된다:
mkdir -p project/src project/tests project/docs
세 개의 디렉토리를 한 번에 만든다. 엄청 편하다.
서브쉘 (Subshell)
괄호로 명령어를 묶으면 서브쉘에서 실행된다. 서브쉘은 현재 쉘의 복사본이다. 서브쉘에서 변경한 내용(변수, 디렉토리 이동)은 부모 쉘에 영향을 주지 않는다.
(cd /tmp && ls)
pwd # 여전히 원래 디렉토리
괄호 안에서 /tmp로 이동했지만, 괄호가 끝나면 다시 원래 위치로 돌아온다. 서브쉘이 종료되면서 변경사항이 사라지기 때문이다.
명령어 치환(Command Substitution)도 서브쉘을 쓴다:
echo "오늘은 $(date) 입니다"
$(date) 부분이 서브쉘에서 실행되고, 그 결과가 문자열로 들어간다.
적용 - 쉘 스크립트로 배포 자동화
이론은 여기까지고, 이제 진짜 쓸모 있는 스크립트를 짜보자. 실제로 쓸 수 있는 배포 스크립트다.
기본 배포 스크립트
#!/bin/bash
# deploy.sh - 프론트엔드 배포 자동화 스크립트
set -e # 에러 발생 시 즉시 종료
echo "🚀 배포를 시작합니다..."
# 1. Git 최신 코드 받기
echo "📦 최신 코드를 가져옵니다..."
git pull origin main
# 2. 의존성 설치
echo "📚 의존성을 설치합니다..."
npm ci # package-lock.json 기반으로 정확히 설치
# 3. 빌드
echo "🔨 프로젝트를 빌드합니다..."
npm run build
# 4. 이전 빌드 백업
echo "💾 이전 빌드를 백업합니다..."
if [ -d "/var/www/html/backup" ]; then
rm -rf /var/www/html/backup
fi
if [ -d "/var/www/html" ]; then
mv /var/www/html /var/www/html/backup
fi
# 5. 새 빌드 배포
echo "📤 새 빌드를 배포합니다..."
cp -r dist /var/www/html
# 6. 프로세스 재시작 (pm2 사용 시)
if command -v pm2 &> /dev/null; then
echo "🔄 PM2 프로세스를 재시작합니다..."
pm2 reload all
fi
echo "✅ 배포 완료!"
이 스크립트는 set -e로 시작한다. 이건 에러가 생기면 즉시 중단하라는 명령이다. 배포 중에 뭔가 잘못되면 멈춰야 하니까.
그리고 command -v pm2 &> /dev/null로 pm2가 설치되어 있는지 확인한다. 없으면 그냥 건너뛴다. 에러를 내지 않는다.
조금 더 실전적인 배포 스크립트
#!/bin/bash
# deploy_advanced.sh - 실패 시 롤백 기능이 있는 배포 스크립트
set -e
DEPLOY_DIR="/var/www/html"
BACKUP_DIR="/var/www/html_backup_$(date +%Y%m%d_%H%M%S)"
BUILD_DIR="dist"
# 색상 코드
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 로그 함수
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 롤백 함수
rollback() {
log_error "배포 실패! 롤백을 시작합니다..."
if [ -d "$BACKUP_DIR" ]; then
rm -rf "$DEPLOY_DIR"
mv "$BACKUP_DIR" "$DEPLOY_DIR"
log_info "롤백 완료!"
else
log_error "백업 디렉토리를 찾을 수 없습니다."
fi
exit 1
}
# 에러 발생 시 rollback 함수 실행
trap rollback ERR
log_info "배포를 시작합니다..."
# Git pull
log_info "최신 코드를 가져옵니다..."
git fetch origin main
git reset --hard origin/main
# 의존성 설치
log_info "의존성을 설치합니다..."
npm ci
# 린트 체크
log_info "코드 품질을 검사합니다..."
npm run lint || {
log_warn "린트 경고가 있습니다. 계속 진행합니다."
}
# 빌드
log_info "프로젝트를 빌드합니다..."
npm run build
# 빌드 결과 확인
if [ ! -d "$BUILD_DIR" ]; then
log_error "빌드 디렉토리가 생성되지 않았습니다!"
exit 1
fi
# 현재 배포본 백업
if [ -d "$DEPLOY_DIR" ]; then
log_info "현재 배포본을 백업합니다..."
cp -r "$DEPLOY_DIR" "$BACKUP_DIR"
fi
# 새 빌드 배포
log_info "새 빌드를 배포합니다..."
rm -rf "$DEPLOY_DIR"
cp -r "$BUILD_DIR" "$DEPLOY_DIR"
# 프로세스 재시작
if command -v pm2 &> /dev/null; then
log_info "PM2 프로세스를 재시작합니다..."
pm2 reload all
fi
# 헬스체크
log_info "서비스 헬스체크를 수행합니다..."
sleep 3
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 || echo "000")
if [ "$HTTP_STATUS" != "200" ]; then
log_error "헬스체크 실패! (HTTP $HTTP_STATUS)"
rollback
fi
# 성공 시 백업 정리 (7일 이상 된 백업 삭제)
log_info "오래된 백업을 정리합니다..."
find /var/www -maxdepth 1 -name "html_backup_*" -mtime +7 -exec rm -rf {} \;
log_info "✅ 배포 성공!"
log_info "백업: $BACKUP_DIR"
이 스크립트는 실제로 쓸 수 있다. 핵심은 trap rollback ERR 부분이다. 에러가 발생하면 자동으로 rollback 함수를 실행한다. 그래서 배포가 실패하면 자동으로 이전 버전으로 되돌아간다.
헬스체크도 있다. 배포 후 3초 기다렸다가 curl로 서버에 요청을 보낸다. HTTP 200이 안 오면 실패로 간주하고 롤백한다.
크론잡으로 정기 작업 스케줄링
쉘 스크립트는 크론(Cron)과 조합하면 정기 작업을 자동화할 수 있다. 크론은 유닉스의 스케줄러다. "매일 새벽 3시에 이 스크립트 실행해줘" 같은 걸 설정할 수 있다.
# 크론 설정 편집
crontab -e
# 예시 - 매일 새벽 3시에 백업 스크립트 실행
0 3 * * * /home/user/scripts/backup.sh >> /var/log/backup.log 2>&1
# 예시 - 매주 월요일 오전 9시에 리포트 생성
0 9 * * 1 /home/user/scripts/generate_report.sh
크론 문법은 이렇다: 분 시 일 월 요일 명령어
0 3 * * *: 매일 새벽 3시 0분0 9 * * 1: 매주 월요일 오전 9시 0분*/5 * * * *: 5분마다
나는 크론으로 DB 백업, 로그 정리, 통계 리포트 생성 같은 걸 자동화한다. 한 번 설정해두면 알아서 돌아간다.
Alias로 자주 쓰는 명령어 단축키 만들기
Alias는 명령어에 별명을 붙이는 기능이다. 길고 복잡한 명령어를 짧게 줄일 수 있다.
# .zshrc 또는 .bashrc에 추가
alias ll='ls -alh'
alias gs='git status'
alias gp='git pull'
alias gps='git push'
alias dc='docker-compose'
alias k='kubectl'
# 함수로 만들면 인자도 받을 수 있다
gco() {
git checkout "$1"
}
mkcd() {
mkdir -p "$1" && cd "$1"
}
# 위험한 명령어는 확인 프롬프트 추가
alias rm='rm -i'
alias mv='mv -i'
alias cp='cp -i'
나는 ll, gs, gp 같은 alias를 매일 수십 번 친다. 손가락 수고를 엄청 줄여준다.
마치며
쉘을 처음 봤을 땐 "명령어를 외워야 하는 건가?"라고 생각했다. 근데 쉘은 외우는 게 아니라 이해하는 거였다. 쉘이 어떻게 명령어를 파싱하고, 어디서 실행 파일을 찾고, 어떻게 프로세스를 실행하는지 알고 나니 명령어가 자연스럽게 이해됐다.
쉘 스크립트를 짜기 시작하면서 개발 생산성이 확 올랐다. 매번 손으로 치던 배포 과정을 스크립트로 만들고, 크론으로 자동화하고, alias로 단축키를 만들었다. 반복 작업은 컴퓨터한테 시키는 게 맞다.
쉘은 결국 도구다. 망치 쓰는 법을 배우듯이 쉘 쓰는 법을 배우면 된다. 처음엔 낯설지만, 익숙해지면 마우스보다 훨씬 빠르고 정확하다. 그리고 멋있다. 검은 화면에 초록색 글자가 쏟아지면서 배포가 되는 모습은 여전히 멋있다.