Bash는 명령줄과 셸 스크립트 환경에서 사용하도록 설계된 강력한 프로그래밍 언어입니다. 3부작으로 구성된 이 시리즈는 필자의 리눅스 독학 과정 교재를 바탕으로, 명령줄 인터페이스(CLI)에서 Bash를 프로그래밍 언어로 활용하는 방법을 다룹니다.
시리즈의 첫 번째 글에서는 변수와 제어 연산자를 활용한 간단한 명령줄 프로그래밍을 소개했습니다. 두 번째 글에서는 실행 흐름을 제어하는 파일, 문자열, 숫자 및 기타 논리 연산자와 다양한 셸 확장 기능을 살펴보았습니다. 이번 세 번째이자 마지막 글에서는 반복 작업을 수행하는 루프(loop)의 사용법과 루프 흐름을 제어하는 방법을 알아봅니다.
루프란 무엇인가?
필자가 지금까지 사용해 온 모든 프로그래밍 언어에는 반복 작업을 수행할 수 있는 루프 구조가 최소 한두 가지씩 존재합니다. 필자는 for 루프를 가장 자주 사용하지만, 상황에 따라 while과 until 루프 역시 매우 유용하게 활용됩니다.
for 루프
Bash의 for 명령은 숫자가 아닌 값도 처리할 수 있어 대부분의 언어보다 유연하다고 평가할 수 있습니다. 예를 들어 표준 C 언어의 for 루프는 오직 숫자 값만 다룰 수 있죠.
Bash 스타일 for 명령의 기본 구조는 다음과 같이 매우 단순합니다:
for Var in list1 ; do list2 ; done이 구문의 의미를 풀어보면 다음과 같습니다. "list1의 각 값을 $Var에 할당한 뒤, 그 값을 사용해 list2의 명령문들을 실행한다. list1의 모든 값을 소진하면 루프를 빠져나간다." 여기서 list1의 값들은 단순히 나열된 문자열일 수도 있고, 명령 치환(command substitution)의 결과물일 수도 있습니다.
직접 실습해 보겠습니다. 먼저 현재 작업 디렉터리(PWD)가 ~/testdir인지 확인하고, 디렉터리를 정리한 후 값 목록을 직접 명시하는 가장 단순한 형태의 for 루프를 실행해 봅니다. 아래 목록은 영문자와 숫자가 섞여 있는데, 셸의 모든 변수는 문자열로 취급된다는 점을 기억하세요.
[student@studentvm1 testdir]$ rm *
[student@studentvm1 testdir]$ for I in a b c d 1 2 3 4 ; do echo $I ; done
a
b
c
d
1
2
3
4
이번에는 좀 더 의미 있는 변수 이름을 사용한 실용적인 예제입니다:
[student@studentvm1 testdir]$ for Dept in "Human Resources" Sales Finance "Information Technology" Engineering Administration Research ; do echo "Department $Dept" ; done
Department Human Resources
Department Sales
Department Finance
Department Information Technology
Department Engineering
Department Administration
Department Research
이번에는 부서별 디렉터리를 생성하면서 진행 상황을 함께 출력해 보겠습니다:
[student@studentvm1 testdir]$ for Dept in "Human Resources" Sales Finance "Information Technology" Engineering Administration Research ; do echo "Working on Department $Dept" ; mkdir "$Dept" ; done
Working on Department Human Resources
Working on Department Sales
Working on Department Finance
Working on Department Information Technology
Working on Department Engineering
Working on Department Administration
Working on Department Research
[student@studentvm1 testdir]$ ll
total 28
drwxrwxr-x 2 student student 4096 Apr 8 15:45 Administration
drwxrwxr-x 2 student student 4096 Apr 8 15:45 Engineering
drwxrwxr-x 2 student student 4096 Apr 8 15:45 Finance
drwxrwxr-x 2 student student 4096 Apr 8 15:45 'Human Resources'
drwxrwxr-x 2 student student 4096 Apr 8 15:45 'Information Technology'
drwxrwxr-x 2 student student 4096 Apr 8 15:45 Research
drwxrwxr-x 2 student student 4096 Apr 8 15:45 Sales
주목할 점은 mkdir 문에서 $Dept 변수를 따옴표로 감쌌다는 것입니다. 그렇지 않으면 "Information Technology"처럼 공백이 포함된 부서 이름이 서로 다른 두 개의 부서로 인식됩니다. 여기서 좋은 관례(best practice) 하나를 얻을 수 있습니다. 파일과 디렉터리 이름은 가능한 한 한 단어로 짓는 것이 좋습니다. 요즘 운영체제 대부분은 이름에 공백을 허용하지만, 시스템 관리자 입장에서는 스크립트와 CLI 프로그램 안에서 이런 특수한 경우를 일일이 처리해야 하는 추가 작업이 발생합니다. 번거롭더라도 어떤 파일이 생길지 알 수 없으므로 항상 고려해야 하지만 말입니다.
그럼 ~/testdir의 내용을 다시 삭제하고, 부서 이름을 하이픈(-)으로 연결한 한 단어 형태로 바꿔 동일한 작업을 수행해 보겠습니다:
[student@studentvm1 testdir]$ rm -rf * ; ll
total 0
[student@studentvm1 testdir]$ for Dept in Human-Resources Sales Finance Information-Technology Engineering Administration Research ; do echo "Working on Department $Dept" ; mkdir "$Dept" ; done
Working on Department Human-Resources
Working on Department Sales
Working on Department Finance
Working on Department Information-Technology
Working on Department Engineering
Working on Department Administration
Working on Department Research
[student@studentvm1 testdir]$ ll
total 28
drwxrwxr-x 2 student student 4096 Apr 8 15:52 Administration
drwxrwxr-x 2 student student 4096 Apr 8 15:52 Engineering
drwxrwxr-x 2 student student 4096 Apr 8 15:52 Finance
drwxrwxr-x 2 student student 4096 Apr 8 15:52 Human-Resources
drwxrwxr-x 2 student student 4096 Apr 8 15:52 Information-Technology
drwxrwxr-x 2 student student 4096 Apr 8 15:52 Research
drwxrwxr-x 2 student student 4096 Apr 8 15:52 Sales
실전 예제: RPM 패키지 목록 자동 추출
특정 리눅스 머신에 설치된 모든 RPM 패키지 목록과 각각의 간단한 설명을 요청받았다고 가정해 봅시다. 필자는 노스캐롤라이나 주 정부에서 근무할 때 실제로 이런 일을 겪었습니다. 당시에는 주 정부 기관에서 오픈소스 사용이 공식 승인되지 않았고, 필자는 데스크톱에서만 리눅스를 사용했기 때문에, 윗선의 관리자들(PHBs)에게 내 컴퓨터에 설치된 모든 소프트웨어 목록을 제출해 예외 승인을 받아야 했습니다.
어떻게 접근해야 할까요? rpm -qa 명령이 소프트웨어 이름과 간단한 요약 정보를 포함한 RPM의 전체 정보를 출력한다는 사실에서 출발해 보겠습니다.
최종 결과물에 도달할 때까지 한 단계씩 차근차근 만들어 갑니다. 먼저 모든 RPM을 나열합니다:
[student@studentvm1 testdir]$ rpm -qa
perl-HTTP-Message-6.18-3.fc29.noarch
perl-IO-1.39-427.fc29.x86_64
perl-Math-Complex-1.59-429.fc29.noarch
lua-5.3.5-2.fc29.x86_64
java-11-openjdk-headless-11.0.ea.28-2.fc29.x86_64
util-linux-2.32.1-1.fc29.x86_64
libreport-fedora-2.9.7-1.fc29.x86_64
rpcbind-1.2.5-0.fc29.x86_64
libsss_sudo-2.0.0-5.fc29.x86_64
libfontenc-1.1.3-9.fc29.x86_64
<snip>
이제 sort와 uniq 명령을 파이프로 연결해 목록을 정렬하고 중복 항목(이름이 같은 RPM이 여러 개 설치되어 있을 수 있음)을 제거합니다:
[student@studentvm1 testdir]$ rpm -qa | sort | uniq
a2ps-4.14-39.fc29.x86_64
aajohan-comfortaa-fonts-3.001-3.fc29.noarch
abattis-cantarell-fonts-0.111-1.fc29.noarch
abiword-3.0.2-13.fc29.x86_64
abrt-2.11.0-1.fc29.x86_64
abrt-addon-ccpp-2.11.0-1.fc29.x86_64
abrt-addon-coredump-helper-2.11.0-1.fc29.x86_64
abrt-addon-kerneloops-2.11.0-1.fc29.x86_64
abrt-addon-pstoreoops-2.11.0-1.fc29.x86_64
abrt-addon-vmcore-2.11.0-1.fc29.x86_64
<snip>
원하는 RPM 목록이 정확하게 얻어졌으므로, 이제 이 목록을 루프의 입력값으로 사용해 각 RPM의 상세 정보를 출력할 수 있습니다:
[student@studentvm1 testdir]$ for RPM in `rpm -qa | sort | uniq` ; do rpm -qi $RPM ; done하지만 이 코드는 원하는 것보다 훨씬 많은 데이터를 출력합니다. 루프 자체는 완성된 상태이므로, 다음 단계는 요청받은 정보만 추출하는 것입니다. 행의 시작(^)을 의미하는 캐럿(caret) 기호와 함께 Name 또는 Summary로 시작하는 줄만 골라내는 egrep 명령을 추가합니다:
[student@studentvm1 testdir]$ for RPM in `rpm -qa | sort | uniq` ; do rpm -qi $RPM ; done | egrep -i "^Name|^Summary"
Name : a2ps
Summary : Converts text and other types of files to PostScript
Name : aajohan-comfortaa-fonts
Summary : Modern style true type font
Name : abattis-cantarell-fonts
Summary : Humanist sans serif font
Name : abiword
Summary : Word processing program
Name : abrt
Summary : Automatic bug detection and reporting tool
<snip>
위 명령에서 egrep 대신 grep을 사용해 볼 수도 있지만, 의도대로 동작하지 않습니다. 또한 출력을 less 필터로 넘겨 결과를 천천히 살펴볼 수도 있습니다. 최종 명령 시퀀스는 다음과 같습니다:
[student@studentvm1 testdir]$ for RPM in `rpm -qa | sort | uniq` ; do rpm -qi $RPM ; done | egrep -i "^Name|^Summary" > RPM-summary.txt이 한 줄짜리 명령줄 프로그램은 파이프, 리디렉션, for 루프를 모두 활용합니다. 출력을 파일로 저장했기 때문에 이메일에 첨부하거나 다른 용도의 입력값으로 활용할 수도 있습니다.
이처럼 프로그램을 한 단계씩 쌓아 올리면 각 단계의 결과를 확인하면서 기대대로 동작하는지 검증할 수 있다는 큰 장점이 있습니다.
참고로 이 실습을 통해 관리자들에게 전달된 RPM 패키지 목록은 무려 1,900개가 넘었습니다. 누군가 그 목록을 끝까지 읽었을지는 의심스럽지만, 필자는 요청받은 것을 정확히 그대로 제출했고, 그 후로는 더 이상 잔소리를 듣지 않았습니다.
while 루프와 until 루프
Bash에는 while과 until이라는 두 가지 루프 구조가 더 있습니다. 이 둘은 문법과 기능 면에서 서로 매우 유사합니다. 기본 문법은 다음과 같이 단순합니다:
while [ expression ] ; do list ; doneuntil [ expression ] ; do list ; donewhile의 논리는 "표현식이 참(true)으로 평가되는 동안 명령문 목록을 계속 실행하고, 거짓(false)이 되면 루프를 종료한다"입니다. 반면 until은 "표현식이 참이 될 때까지 명령문 목록을 실행하고, 참이 되면 루프를 종료한다"는 점에서 차이가 있습니다.
while 루프
while 루프는 논리 표현식이 참인 동안 일련의 명령문을 계속 실행합니다. 여전히 PWD가 ~/testdir라고 가정하겠습니다.
가장 단순한 형태의 while 루프는 영원히 실행되는 루프입니다. 아래 예제는 항상 '참'을 반환하는 true 명령을 사용합니다. 단순히 "1"을 사용해도 결과는 같지만, true 명령의 활용법을 보여주기 위해 이렇게 작성했습니다:
[student@studentvm1 testdir]$ X=0 ; while [ true ] ; do echo $X ; X=$((X+1)) ; done | head
0
1
2
3
4
5
6
7
8
9
[student@studentvm1 testdir]$
구성 요소를 학습한 지금은 이 CLI 프로그램이 훨씬 잘 이해될 것입니다. 먼저 이전 프로그램이나 CLI 명령에서 남은 값이 있을 수 있으므로 $X를 0으로 초기화합니다. 그다음 논리 표현식 [ true ]가 항상 참(1)으로 평가되기 때문에, do와 done 사이의 명령문들이 무한히 실행됩니다. 즉, Ctrl+C를 누르거나 프로그램에 시그널 2(SIGINT)를 보낼 때까지요. 이 명령문들은 $X의 현재 값을 출력한 뒤 1씩 증가시키는 산술 확장입니다.
『시스템 관리자를 위한 리눅스 철학(The Linux Philosophy for Sysadmins)』의 핵심 원칙 중 하나는 우아함(elegance)을 추구하는 것이며, 우아함을 얻는 방법 중 하나는 단순함입니다. 변수 증가 연산자 ++를 사용하면 이 프로그램을 더 단순하게 만들 수 있습니다. 연산자를 변수 뒤에 두면 현재 값이 먼저 출력된 후 변수가 증가합니다:
[student@studentvm1 ~]$ X=0 ; while [ true ] ; do echo $((X++)) ; done | head
0
1
2
3
4
5
6
7
8
9
이제 프로그램 끝의 | head를 삭제하고 다시 실행해 보세요. 무한 루프에 빠졌다면 Ctrl+C로 중단하면 됩니다.
이번에는 ++ 연산자를 변수 앞에 배치해, 값이 출력되기 전에 변수가 먼저 증가하도록 바꿔 보겠습니다. 차이점이 보이시나요?
[student@studentvm1 ~]$ X=0 ; while [ true ] ; do echo $((++X)) ; done | head
1
2
3
4
5
6
7
8
9
이렇게 하면 변수 값을 출력하고 증가시키던 두 개의 명령문을 하나로 줄일 수 있습니다. 감소 연산자 --도 마찬가지 방식으로 사용할 수 있습니다.
루프가 특정 숫자에서 멈추도록 만드는 방법도 필요합니다. 이를 위해 true 표현식을 실제 숫자 비교 표현식으로 변경합니다. 아래 예제에서 프로그램이 5까지 세고 멈추도록 해 보겠습니다. -le는 "작거나 같음(less than or equal to)"을 뜻하는 논리 숫자 연산자입니다. 즉, "$X가 5보다 작거나 같은 동안 루프가 계속되고, $X가 6으로 증가하면 루프가 종료된다"는 의미입니다:
[student@studentvm1 ~]$ X=0 ; while [ $X -le 5 ] ; do echo $((X++)) ; done
0
1
2
3
4
5
[student@studentvm1 ~]$
until 루프
until 명령은 while 명령과 매우 비슷하지만, 논리 표현식이 참이 될 때까지 루프를 계속 실행한다는 점이 다릅니다. 가장 단순한 형태를 살펴보겠습니다:
[student@studentvm1 ~]$ X=0 ; until false ; do echo $((X++)) ; done | head
0
1
2
3
4
5
6
7
8
9
[student@studentvm1 ~]$
논리 비교를 사용해 특정 값까지 카운트할 수도 있습니다:
[student@studentvm1 ~]$ X=0 ; until [ $X -eq 5 ] ; do echo $((X++)) ; done
0
1
2
3
4
[student@studentvm1 ~]$ X=0 ; until [ $X -eq 5 ] ; do echo $((++X)) ; done
1
2
3
4
5
[student@studentvm1 ~]$
마치며
이 시리즈를 통해 Bash 명령줄 프로그램과 셸 스크립트를 만드는 강력한 도구들을 폭넓게 살펴보았습니다. 하지만 이것은 Bash로 할 수 있는 흥미로운 작업들의 극히 일부에 불과합니다. 나머지는 여러분의 몫입니다.
필자가 발견한 Bash 프로그래밍 학습의 최선의 방법은 직접 해보는 것입니다. 여러 Bash 명령이 필요한 간단한 프로젝트를 찾아 이를 CLI 프로그램으로 만들어 보세요. 시스템 관리 업무에는 CLI 프로그래밍으로 자동화하기 좋은 작업이 많으므로, 적합한 과제를 찾는 것은 어렵지 않을 것입니다.
오랜 years 전, 필자는 다른 셸 언어와 Perl에 익숙했음에도 불구하고 모든 시스템 관리 자동화 작업에 Bash를 사용하기로 결정했습니다. 때로는 약간의 검색이 필요했지만, 필요한 모든 것을 Bash로 해결할 수 있었다는 사실을 확인했습니다.