Computer >> 컴퓨터 >  >> 프로그래밍 >> Bash 프로그래밍

Bash 프로그래밍 입문: 기본 문법부터 변수와 제어 연산자까지

셸(shell)은 운영체제의 명령 해석기입니다. 필자는 Bash를 가장 좋아하는 셸로 사용하고 있는데, 사실 모든 리눅스 셸은 사용자나 시스템 관리자가 입력한 명령을 운영체제가 이해할 수 있는 형태로 변환하는 역할을 합니다. 명령의 실행 결과가 셸로 반환되면, 셸은 이를 STDOUT으로 전송하며 기본적으로 터미널 화면에 출력됩니다. 흥미로운 점은 대부분의 셸이 단순한 명령 해석기를 넘어 하나의 프로그래밍 언어이기도 하다는 것입니다.

탭 자동 완성, 명령어 기록 불러오기와 편집, 별칭(alias) 같은 단축 기능들은 Bash를 강력한 셸로 만들어주는 요소들입니다. Bash의 기본 명령줄 편집 모드는 Emacs 방식이지만, 개인적으로 가장 마음에 드는 기능은 Vi 모드로 전환하여 이미 손에 익은 Vi 편집 명령어를 그대로 사용할 수 있다는 점입니다.

그러나 Bash를 단순히 '셸' 정도로만 생각한다면 그 진정한 강력함의 상당 부분을 놓치게 됩니다. 필자가 세 권으로 구성된 리눅스 독학 강좌(이 글의 원형이 되는 시리즈)를 연구하면서, 20년 넘게 리눅스를 다뤄왔음에도 몰랐던 Bash의 숨겨진 면모들을 발견하게 되었습니다. 그중 상당수는 Bash를 프로그래밍 언어로 활용하는 것과 관련된 내용이었습니다. Bash는 명령줄과 셸 스크립트 환경에서 사용하기에 완벽하게 설계된 강력한 프로그래밍 언어입니다.

이 3부작 시리즈는 Bash를 명령줄 인터페이스(CLI) 프로그래밍 언어로 활용하는 방법을 다룹니다. 첫 번째 글에서는 간단한 명령줄 프로그래밍, 변수, 제어 연산자를 살펴봅니다. 이후 글에서는 Bash 파일의 종류, 문자열·숫자·논리 연산자를 통한 실행 흐름 제어, 다양한 셸 확장(expansion), 반복 작업을 가능하게 하는 for, while, until 루프 등을 다루고, 이런 도구들을 더욱 편리하게 사용할 수 있게 해주는 명령어들도 함께 소개합니다.

셸이란 무엇인가

셸은 운영체제의 명령 해석기입니다. Bash는 필자가 가장 선호하는 셸이지만, 모든 리눅스 셸은 사용자나 시스템 관리자가 입력한 명령을 운영체제가 처리할 수 있는 형태로 해석합니다. 결과가 셸 프로그램으로 반환되면 터미널에 표시되며, 필자가 아는 한 모든 셸은 동시에 프로그래밍 언어이기도 합니다.

Bash는 'Bourne Again Shell'의 약자로, 1977년 Steven Bourne이 작성한 오래된 Bourne 셸을 기반으로 만들어졌습니다. 사용 가능한 셸은 매우 다양하지만, 실무에서 가장 자주 접하게 되는 네 가지는 다음과 같습니다.

  • csh: C 언어 문법을 선호하는 프로그래머를 위한 C 셸
  • ksh: David Korn이 작성했으며 유닉스 사용자들에게 인기가 많은 Korn 셸
  • tcsh: csh에 사용 편의성 기능을 추가한 버전
  • zsh: 여러 인기 셸의 장점을 결합한 Z 셸

모든 셸에는 코어 유틸리티가 제공하는 명령어를 보완하거나 대체하는 내장 명령어들이 있습니다. 셸의 맨 페이지(man page)를 열어 "BUILT-INS" 섹션을 찾아보면 해당 셸이 제공하는 내장 명령어 목록을 확인할 수 있습니다.

각 셸마다 고유한 특성과 문법이 있으며, 어떤 셸이 본인에게 더 잘 맞을지는 직접 사용해봐야 알 수 있습니다. 필자 역시 C 셸, Korn 셸, Z 셸을 모두 사용해 보았지만 그중에서도 여전히 Bash를 가장 선호합니다. 다행히도 셸을 변경하는 일은 매우 간단하므로, 여러 셸을 직접 경험해보고 자신에게 가장 적합한 것을 선택하시길 권합니다.

이 모든 셸은 명령 해석기일 뿐만 아니라 프로그래밍 언어이기도 합니다. 지금부터 Bash의 핵심을 이루는 프로그래밍 구조와 도구들을 간단히 둘러보겠습니다.

프로그래밍 언어로서의 Bash

대부분의 시스템 관리자는 비교적 단순하고 직관적인 명령을 입력하는 용도로 Bash를 사용해 왔습니다. 하지만 Bash의 활용 범위는 단일 명령 입력을 훨씬 넘어서며, 많은 시스템 관리자가 여러 작업을 연속으로 수행하는 간단한 명령줄 프로그램을 만들어 사용합니다. 이러한 프로그램은 시간과 노력을 절약해 주는 강력한 도구가 됩니다.

필자가 CLI 프로그램을 작성하는 목표는 바로 시간과 노력의 절약, 즉 '게으른 시스템 관리자'가 되는 것입니다. CLI 프로그램은 여러 명령을 지정된 순서대로 나열하여 차례로 실행하기 때문에, 한 명령의 진행 상황을 지켜보다가 끝나면 다음 명령을 입력하는 수고를 덜 수 있습니다. 프로그램이 알아서 작업을 진행하는 동안 다른 일을 할 수 있다는 뜻입니다.

'프로그램'이란 무엇인가

FOLDOC(Free On-line Dictionary of Computing)은 프로그램을 "컴퓨터가 실행하는 명령어들의 집합으로, 물리적 장치 자체가 아닌 소프트웨어적인 실행 지침"이라고 정의합니다. 프린스턴 대학교의 WordNet은 프로그램을 "컴퓨터가 해석하고 실행할 수 있는 명령어의 나열"로 설명하며, 위키백과에도 컴퓨터 프로그램에 대한 훌륭한 항목이 있습니다.

따라서 프로그램은 특정하고 서로 연관된 작업을 수행하는 하나 이상의 명령어(명령문)로 구성될 수 있습니다. 컴퓨터 프로그램의 명령어를 '프로그램 문(statement)'이라고 부르기도 합니다. 시스템 관리자에게 프로그램이란 일반적으로 셸 명령어의 나열을 의미합니다. 리눅스에서 사용할 수 있는 모든 셸(최소한 필자가 아는 셸들)은 최소한의 기본 프로그래밍 기능을 갖추고 있으며, 대부분의 리눅스 배포판 기본 셸인 Bash도 예외가 아닙니다.

이 시리즈는 Bash의 압도적인 보급률 때문에 Bash를 기준으로 설명하지만, 다른 셸을 사용하더라도 일반적인 프로그래밍 개념은 동일합니다. 다만 구조와 문법이 조금씩 다를 수 있습니다. 어떤 셸은 다른 셸이 지원하지 않는 기능을 제공하기도 하지만, 어느 셸이든 기본적인 프로그래밍 능력은 갖추고 있습니다. 셸 프로그램은 반복 사용을 위해 파일로 저장할 수도 있고, 필요할 때마다 명령줄에서 즉석으로 만들 수도 있습니다.

간단한 CLI 프로그램

가장 간단한 형태의 명령줄 프로그램은 Enter 키를 누르기 전에 명령줄에 입력하는 한두 개의 연속된 프로그램 문입니다. 두 문장이 서로 관련되어 있을 수도 있고 없을 수도 있습니다. 두 번째 문장이 있다면 첫 번째 문장의 실행 결과에 의존할 수도 있지만, 반드시 그래야 하는 것은 아닙니다.

여기서 짚고 넘어가야 할 중요한 문법적 요소가 있습니다. 명령줄에 단일 명령을 입력하고 Enter 키를 누르면 명령 끝에 암묵적으로 세미콜론(;)이 붙은 것처럼 명령이 종료됩니다. 반면 CLI 셸 프로그램을 명령줄의 한 줄로 입력할 때는 세미콜론(;)을 사용해 각 문장을 명시적으로 종료하고 다음 문장과 구분해야 합니다. 프로그램의 마지막 문장에는 명시적 또는 암묵적 세미콜론을 모두 사용할 수 있습니다.

기본 문법 익히기

다음 예제들을 통해 이 문법을 명확히 이해할 수 있습니다. 아래 프로그램은 명시적 종결자를 포함한 단일 명령으로 구성되어 있습니다.

[student@studentvm1 ~]$ echo "Hello world." ;
Hello world.

이것이 프로그램이라 부르기엔 미약해 보일 수 있지만, 새로운 프로그래밍 언어를 배울 때마다 처음 마주하게 되는 바로 그 프로그램입니다. 언어마다 문법의 형태는 조금씩 다르지만 결과는 언제나 같습니다.

이 자잘하지만 보편적인 프로그램을 조금 확장해 보겠습니다. 필자는 여러 실험을 거친 홈 디렉터리를 사용하고 있어 출력 결과가 다를 수 있지만, GUI 데스크톱으로 계정에 처음 로그인했을 때 생성되는 기본 디렉터리와 파일만 있는 분들도 이 예제를 충분히 따라 할 수 있습니다.

[student@studentvm1 ~]$ echo "My home directory." ; ls ;
My home directory.
chapter25 TestFile1.Linux dmesg2.txt Downloads newfile.txt softlink1 testdir6
chapter26 TestFile1.mac dmesg3.txt file005 Pictures Templates testdir
TestFile1 Desktop dmesg.txt link3 Public testdir Videos
TestFile1.dos dmesg1.txt Documents Music random.txt testdir1

이번에는 좀 더 의미가 있는 결과가 나왔습니다. 출력 결과들은 서로 관련되어 있지만, 개별 프로그램 문은 서로 독립적입니다. 참고로 필자는 코드 가독성을 높이기 위해 세미콜론 앞뒤에 공백을 두는 습관이 있습니다. 이번에는 명시적 세미콜론 없이 같은 CLI 프로그램을 다시 실행해 보겠습니다.

[student@studentvm1 ~]$ echo "My home directory." ; ls 

실행 결과에는 아무런 차이가 없습니다.

변수의 기초

모든 프로그래밍 언어와 마찬가지로 Bash 셸도 변수를 다룰 수 있습니다. 변수란 어떤 값을 담고 있는 메모리의 특정 위치를 가리키는 기호화된 이름(symbolic name)입니다. 변수의 값은 얼마든지 변경할 수 있으며, 그래서 이름이 '변수(variable)'인 것입니다.

C 언어 계열과 달리 Bash는 변수에 타입(type)을 지정하지 않습니다. 정수형, 부동소수점형, 문자열형으로 구분하지 않고, Bash의 모든 변수는 문자열(string)입니다. 다만 문자열이 정수 형태라면 정수 산술 연산에 사용할 수 있는데, 이것이 Bash가 수행할 수 있는 유일한 수학 연산입니다. 더 복잡한 수학 계산이 필요하다면 CLI 프로그램과 스크립트에서 bc 명령을 활용할 수 있습니다.

변수에 값을 할당하면 CLI 프로그램이나 스크립트에서 그 값을 참조할 수 있습니다. 값을 설정할 때는 변수 이름만 사용하고 $ 기호를 붙이지 않습니다. 예를 들어 VAR=10은 변수 VAR의 값을 10으로 설정하며, 이 값을 출력하려면 echo $VAR 문을 사용합니다. 먼저 텍스트(비숫자) 변수부터 시작해 보겠습니다.

Bash 변수는 unset으로 해제되기 전까지 셸 환경의 일부로 남아 있습니다.

값이 할당되지 않은 변수의 초기값을 확인해 보겠습니다. 초기값은 null(비어 있음)이어야 합니다. 그다음 변수에 값을 할당하고 출력하여 확인합니다. 이 모든 과정을 하나의 CLI 프로그램으로 처리할 수 있습니다.

[student@studentvm1 ~]$ echo $MyVar ; MyVar="Hello World" ; echo $MyVar ;

Hello World
[student@studentvm1 ~]$

참고: 변수 할당 문법은 매우 엄격합니다. 할당문에서 등호(=) 양쪽에는 공백이 있어서는 안 됩니다.

첫 줄의 빈 출력은 MyVar의 초기값이 null임을 보여줍니다. 변수 값의 변경과 설정은 같은 방식으로 수행되며, 위 예제는 변경 전과 변경 후의 값을 모두 보여줍니다.

앞서 언급했듯이 Bash는 정수 산술 연산을 수행할 수 있으며, 배열 요소 위치를 계산하거나 간단한 수학 문제를 푸는 데 유용합니다. 다만 소수점이 필요한 과학 계산이나 재무 계산에는 적합하지 않으며, 그런 종류의 계산에는 훨씬 더 나은 도구들이 존재합니다.

간단한 계산 예제를 살펴보겠습니다.

[student@studentvm1 ~]$ Var1="7" ; Var2="9" ; echo "Result = $((Var1*Var2))"
Result = 63

그렇다면 연산 결과가 부동소수점 숫자가 되는 경우에는 어떻게 될까요?

[student@studentvm1 ~]$ Var1="7" ; Var2="9" ; echo "Result = $((Var1/Var2))"
Result = 0
[student@studentvm1 ~]$ Var1="7" ; Var2="9" ; echo "Result = $((Var2/Var1))"
Result = 1
[student@studentvm1 ~]$

결과는 가장 가까운 정수로 반올림(내림)됩니다. 눈여겨볼 점은 계산이 echo 문 안에서 수행되었다는 것입니다. Bash의 우선순위 규칙에 따라 수학 연산은 감싸고 있는 echo 명령보다 먼저 실행됩니다. 자세한 내용은 Bash 맨 페이지에서 "precedence"를 검색하면 확인할 수 있습니다.

제어 연산자

셸 제어 연산자는 흥미로운 명령줄 프로그램을 손쉽게 만들 수 있게 해주는 문법적 도구 중 하나입니다. 가장 단순한 형태의 CLI 프로그램은 여러 명령을 명령줄에서 순서대로 나열하는 것입니다.

command1 ; command2 ; command3 ; command4 ; . . . ; etc. ;

이 명령들은 오류가 발생하지 않는 한 문제없이 실행됩니다. 하지만 오류가 발생하면 어떻게 될까요? Bash의 내장 제어 연산자인 &&||를 사용하면 오류를 미리 예측하고 대응할 수 있습니다. 이 두 제어 연산자는 일정 수준의 흐름 제어를 제공하여 코드 실행 순서를 변경할 수 있게 해줍니다. 참고로 세미콜론과 줄바꿈 문자 역시 Bash의 제어 연산자로 간주됩니다.

&& 연산자는 "command1이 성공하면 command2를 실행한다. command1이 어떤 이유로든 실패하면 command2는 건너뛴다."는 의미입니다. 문법은 다음과 같습니다.

command1 && command2

새 디렉터리를 생성하고, 성공하면 해당 디렉터리를 현재 작업 디렉터리(PWD)로 만드는 명령을 살펴보겠습니다. 먼저 홈 디렉터리(~)가 PWD인지 확인한 후, 접근 권한이 없는 /root 디렉터리에서 시도해 봅니다.

[student@studentvm1 ~]$ Dir=/root/testdir ; mkdir $Dir/ && cd $Dir
mkdir: cannot create directory '/root/testdir/': Permission denied
[student@studentvm1 ~]$

오류는 mkdir 명령이 출력했습니다. 디렉터리 생성이 실패했기 때문에 이후 명령이 실행되지 않았고, 추가 오류 메시지는 나타나지 않았습니다. && 제어 연산자가 0이 아닌 반환 코드를 감지하여 후속 명령을 건너뛴 것입니다. 이처럼 제어 연산자를 활용한 명령줄 흐름 제어는 오류가 누적되어 큰 문제로 번지는 것을 방지할 수 있습니다. 이제 조금 더 복잡한 경우를 살펴보겠습니다.

|| 제어 연산자는 첫 번째 프로그램 문이 0보다 큰 반환 코드를 반환했을 때 실행될 또 다른 문장을 추가할 수 있게 해줍니다. 기본 문법은 다음과 같습니다.

command1 || command2 

이 문법은 "command1이 실패하면 command2를 실행한다"로 읽습니다. 즉, command1이 성공하면 command2는 건너뛰어집니다. 새 디렉터리 생성을 시도하며 직접 확인해 보겠습니다.

[student@studentvm1 ~]$ Dir=/root/testdir ; mkdir $Dir || echo "$Dir was not created."
mkdir: cannot create directory '/root/testdir': Permission denied
/root/testdir was not created.
[student@studentvm1 ~]$

예상한 대로 동작했습니다. 새 디렉터리를 생성할 수 없었기 때문에 첫 번째 명령이 실패했고, 그 결과 두 번째 명령이 실행된 것입니다.

이 두 연산자를 조합하면 두 가지 장점을 모두 누릴 수 있습니다. &&|| 제어 연산자를 함께 사용한 흐름 제어 문법의 일반적인 형태는 다음과 같습니다.

preceding commands ; command1 && command2 || command3 ; following commands

이 문법은 "command1이 반환 코드 0으로 종료되면 command2를 실행하고, 그렇지 않으면 command3를 실행한다"라고 표현할 수 있습니다. 직접 실행해 보겠습니다.

[student@studentvm1 ~]$ Dir=/root/testdir ; mkdir $Dir && cd $Dir || echo "$Dir was not created."
mkdir: cannot create directory '/root/testdir': Permission denied
/root/testdir was not created.
[student@studentvm1 ~]$

이번에는 /root 대신 홈 디렉터리를 사용해 같은 명령을 다시 실행해 보겠습니다. 이번에는 디렉터리 생성 권한이 있습니다.

[student@studentvm1 ~]$ Dir=~/testdir ; mkdir $Dir && cd $Dir || echo "$Dir was not created."
[student@studentvm1 testdir]$

command1 && command2와 같은 제어 연산자 문법이 동작하는 이유는, 모든 명령이 실행 성공 여부를 셸에 알리는 반환 코드(RC, Return Code)를 전송하기 때문입니다. 관례상 RC가 0이면 성공을, 0보다 큰 양수이면 어떤 형태로든 실패했음을 의미합니다. 일부 도구는 실패를 나타낼 때 단순히 1을 반환하지만, 많은 도구들이 실패 유형을 구분하기 위해 다양한 코드를 사용합니다.

Bash 셸 변수 $?에는 마지막으로 실행된 명령의 RC가 저장됩니다. 이 RC는 스크립트, 명령 목록의 다음 명령, 심지어 시스템 관리자가 직접 손쉽게 확인할 수 있습니다. 간단한 명령을 실행한 직후 RC를 확인해 보겠습니다. RC는 항상 확인 시점 직전에 실행된 마지막 명령에 대한 것임을 기억하세요.

[student@studentvm1 testdir]$ ll ; echo "RC = $?"
total 1264
drwxrwxr-x 2 student student 4096 Mar 2 08:21 chapter25
drwxrwxr-x 2 student student 4096 Mar 21 15:27 chapter26
-rwxr-xr-x 1 student student 92 Mar 20 15:53 TestFile1
<snip>
drwxrwxr-x. 2 student student 663552 Feb 21 14:12 testdir
drwxr-xr-x. 2 student student 4096 Dec 22 13:15 Videos
RC = 0
[student@studentvm1 testdir]$

이 경우 RC는 0으로, 명령이 성공적으로 완료되었음을 의미합니다. 이번에는 접근 권한이 없는 root의 홈 디렉터리에 대해 같은 명령을 실행해 보겠습니다.

[student@studentvm1 testdir]$ ll /root ; echo "RC = $?"
ls: cannot open directory '/root': Permission denied
RC = 2
[student@studentvm1 testdir]$

이번 경우 RC는 2입니다. 이는 root가 아닌 사용자가 접근 권한이 없는 디렉터리에 접근하려다 거부되었다는 의미입니다. 제어 연산자는 이러한 RC들을 활용하여 프로그램 실행 순서를 유연하게 변경할 수 있게 해줍니다.

마치며

이 글에서는 프로그래밍 언어로서의 Bash를 살펴보고 기본 문법과 몇 가지 기본 도구들을 알아보았습니다. STDOUT으로 데이터를 출력하는 방법, 변수와 제어 연산자의 활용법도 함께 다루었습니다. 시리즈의 다음 글에서는 명령 실행 흐름을 제어하는 다양한 Bash 논리 연산자를 자세히 살펴보겠습니다.