Fork me on GitHub

쉘 스크립트

목표

  • 고정된 파일 집합에 하나의 명령어 혹은 일련의 명령어를 실행하는 쉘 스크립트를 작성하기
  • 명령 라인에서 쉘 스크립트 실행하기
  • 명령 라인에서 사용자가 정의한 파일 집합에 동작하는 쉘 스크립트 작성하기
  • 사용자가 작성한 쉘 스크립트를 포함하는 파이프라인 생성하기

마침내 무엇이 쉘을 그토록 강력한 프로그래밍 환경이 되도록 만드는지 볼 준비가 되었다. 자주 반복적으로 사용하는 명령어를 파일에 저장할 것이고, 단 하나의 명령어를 타이핑함으써 나중에 이 모든 연산을 다시 실행할 수 있다. 역사적 이유로 파일에 저장된 명령어 꾸러미는 통상 쉘 스크립트(shell script)라고 부르지만 실수로 그렇게 부르는 것은 아니다. 실제로 작은 프로그램이다.

molecules/ 디렉토리로 돌아가서 middle.sh 파일에 다음 행을 추가해서 시작해 봅시다.

$ cd molecules
$ nano middle.sh
head -15 octane.pdb | tail -5

앞서 작성한 파이프에 변형으로 octane.pdb 파일에서 11-15 행을 선택한다. 기억할 것은 명령어로서 실행하지 않고, 명령어를 파일에 넣는다.

파일을 저장하면, 쉘로 하여금 파일이 담고 있는 명령어를 실행하게 할 수 있다. 지금 쉘은 bash이고, 다음과 같이 명령어를 실행한다.

$ bash middle.sh
ATOM      9  H           1      -4.502   0.681   0.785  1.00  0.00
ATOM     10  H           1      -5.254  -0.243  -0.537  1.00  0.00
ATOM     11  H           1      -4.357   1.252  -0.895  1.00  0.00
ATOM     12  H           1      -3.009  -0.741  -1.467  1.00  0.00
ATOM     13  H           1      -3.172  -1.337   0.206  1.00  0.00

과연, 스크립트의 출력은 정확하게 파이프라인을 직접적으로 실행한 것과 동일하다.

텍스트 대 텍스트가 아닌 것 아무거나

종종 마이크로소프트 워드 혹은 리브르오피스 Writer 프로그램을 "텍스트 편집기"로 호출한다. 하지만, 프로그램에 대해서는 조금더 주의를 할 필요가 있다. 디폴트로, 마이크로소프트 워드는 .docx 파일을 사용해서 텍스트 를 저장할 뿐만 아니라 글꼴, 제목, 등등의 형식 정보도 함께 저장한다. 이렇나 추가 정보는 문자로 저장되지 않아서 head 같은 도구에게는 무의미하다. head 같은 툴은 입력 파일이 문자, 숫자, 표준 컴퓨터 키보드의 특수문자만을 포함하기를 바란다. 그러므로 프로그램을 편집할 때, 일반 텍스트 편집기를 사용하거나, 혹은 일반 텍스트로 파일을 저장하도록 주의한다.

만약 임의의 파일에서 행을 선택하고자 한다면 어떨까요? 파일 이름을 바꾸기 위해서 매번 middle.sh을 편집할 수 있지만, 단순히 명령어를 다시 타이핑하는 것보다 아마 오래 걸릴 것이다. 대신에 middle.sh을 편집해서 octane.pdb$1으로 불리는 특수 변수로 변경하자.

$ cat middle.sh
head -20 $1 | tail -5

쉘 스크립트 내부에서, $1은 "명령 라인에 첫 파일 이름(혹은 다른 매개변수)"을 의미한다. 스크립트를 다음과 같이 이제 실행할 수 있다.

$ bash middle.sh octane.pdb
ATOM      9  H           1      -4.502   0.681   0.785  1.00  0.00
ATOM     10  H           1      -5.254  -0.243  -0.537  1.00  0.00
ATOM     11  H           1      -4.357   1.252  -0.895  1.00  0.00
ATOM     12  H           1      -3.009  -0.741  -1.467  1.00  0.00
ATOM     13  H           1      -3.172  -1.337   0.206  1.00  0.00

혹은 다음과 같이 다른 파일에 스크립트를 실행한다.

$ bash middle.sh pentane.pdb
ATOM      9  H           1       1.324   0.350  -1.332  1.00  0.00
ATOM     10  H           1       1.271   1.378   0.122  1.00  0.00
ATOM     11  H           1      -0.074  -0.384   1.288  1.00  0.00
ATOM     12  H           1      -0.048  -1.362  -0.205  1.00  0.00
ATOM     13  H           1      -1.183   0.500  -1.412  1.00  0.00

하지만, 여전히 행의 범위를 조절할 때마다 middle.sh을 편집할 필요가 있다. 특수 변수 $2$3을 사용해서 이 문제를 해결하자.

$ cat middle.sh
head $2 $1 | tail $3
$ bash middle.sh pentane.pdb -20 -5
ATOM     14  H           1      -1.259   1.420   0.112  1.00  0.00
ATOM     15  H           1      -2.608  -0.407   1.130  1.00  0.00
ATOM     16  H           1      -2.540  -1.303  -0.404  1.00  0.00
ATOM     17  H           1      -3.393   0.254  -0.321  1.00  0.00
TER      18              1

제대로 동작하지만, middle.sh을 읽는 다음 사람은 잠시 시간을 들여서 스크립트가 무엇을 수행하는지 알아내야 할지 모른다. 스크립트를 상단에 주석(comments)을 추가해서 좀더 낫게 만들 수 있다.

$ cat middle.sh
# Select lines from the middle of a file.
# Usage: middle.sh filename -end_line -num_lines
head $2 $1 | tail $3

주석은 #문자로 시작하고 행의 끝까지 간다. 컴퓨터는 주석을 무시하지만, 사람들이 스크립트를 이행하고 사용하는데 정말 귀중한다.

만약 많은 파일을 단 하나의 파이프라인으로 처리하고자 한다면 어떨까? 예를 들어, .pdb 파일을 길이 순으로 정렬하려면 다음과 같이 타이핑한다.

$ wc -l *.pdb | sort -n

wc -l은 파일에 행수를 출력하고 sort -n은 숫자순으로 파일의 행수를 정렬한다. 파일에 담을 수 있지만, 현재 디렉토리에 .pdb 파일만을 정렬한다. 다른 종류의 파일의 정렬된 목록을 얻으려고 한다면, 스크립트에 이 모든 이름을 얻는 방법이 필요하다. $1, $2 등등을 사용할 수 없는데 왜냐하면 얼마나 많은 파일이 있는지를 알 수 없기 때문이다. 대신에, 특수 변수 $*을 사용한다. $*은 "쉘 스크립트에 모든 명령-라인 매개변수"를 의미한다. 예제가 여기 있다.

$ cat sorted.sh
wc -l $* | sort -n
$ bash sorted.sh *.pdb ../creatures/*.dat
9 methane.pdb
12 ethane.pdb
15 propane.pdb
20 cubane.pdb
21 pentane.pdb
30 octane.pdb
163 ../creatures/basilisk.dat
163 ../creatures/unicorn.dat

왜 쉘 스크립트가 어떤 것도 하지 않을까?

스크립트가 파일 꾸러미를 처리하고 했지만, 어떠한 파일 이름도 주지 않는다면 무슨 일이 발생할까요? 예를 들어, 만약 다음과 같이 타이핑한다면 어떨까요?

$ bash sorted.sh

하지만 *.dat (혹은 다른 어떤 것)을 타이핑하지 않는다면 어떨까요? 이 경우 $*은 아무 것도 전개하지 않아서 스크립트 내부의 파이프라인은 사실상 다음과 같다.

wc -l | sort -n

어떠한 파일이름도 주지 않아서, wc은 표준 입력을 처리하려 한다고 가정해서 단지 앉아서 사용자가 인터랙티브하게 어떤 데이터를 전달하길 기다린다. 하지만, 밖에서는 사용자에게 보이는 것은 스크립트가 거기 앉아서 정지한 것처럼 보여 스크핍트가 아무 일도 수행하지 않는 것처럼 보인다.

간단한 쉘 스크립트로 마치기 전에 두개가 더 있다. 다음과 같은 스크립트를 살펴보면,

wc -l $* | sort -n

아마도 스크립트가 무엇을 하는지 생각해 낼 수 있다. 다른 한편으로 다음 스크립트를 살펴보면,

# List files sorted by number of lines.
wc -l $* | sort -n

사용자가 생각해 낼 필요가 없다. 상단의 주석이 무엇을 수행하는지 자동으로 말해준다. 한줄 혹은 두줄의 이와 같은 문서화는 앞으로 여러분 자신과 다른 사람이 여러분이 작성한 스크립트나 프로그램을 재사용하기 쉽게 한다. 주의할 점은 매번 스크립트를 변경할 때마다, 주석이 여전히 정확한지를 확인해야만 한다. 독자에게 잘못된 방향을 주는 설명은 아무것도 없는 것보다 더 나쁠 수 있다.

둘째로, 유용한 무언가를 수행하는 일련의 명령어를 방금 실행했다고 가정하자. 예를 들어 스크립트가 논문에 사용될 그래프를 생성했다. 필요하면 나중에 그래프를 다시 생성하고자 해서 파일에 명령어를 저장하고자 한다. 명령문을 다시 타이핑(그리고 잠재적으로 잘못 타이핑할 수도 있다)하는 대신에 다음과 같이 할 수도 있다.

$ history | tail -4 > redo-figure-3.sh

redo-figure-3.sh 파일은 이제 다음을 담고 있다.

297 goostats -r NENE01729B.txt stats-NENE01729B.txt
298 goodiff stats-NENE01729B.txt /data/validated/01729.txt > 01729-differences.txt
299 cut -d ',' -f 2-3 01729-differences.txt > 01729-time-series.txt
300 ygraph --format scatter --color bw --borders none 01729-time-series.txt figure-3.png

명령어의 일련 번호를 제거하기 위해서 편집기에서 한동안 작업한 후에 그림을 어떻게 생성하는지에 대한 정말로 정확한 기록을 갖게 된다.

매긴 번호 지우기(Unnumbering)

앞선 명령에 붙은 일련번호를 제거하기 위해서 Nelle은 colrm ("column removal(열 제거)"의 줄임말)를 사용할 수 있다. 매개변수가 입력에서 제거할 문자 범위가 된다.

$ history | tail -5
  173  cd /tmp
  174  ls
  175  mkdir bakup
  176  mv bakup backup
  177  history | tail -5
$ history | tail -5 | colrm 1 7
cd /tmp
ls
mkdir bakup
mv bakup backup
history | tail -5
history | tail -5 | colrm 1 7

실제로, 대부분의 사람들은 쉘 프롬프트에서 몇번 명령어를 실행해서 올바들게 수행되는지를 확인한 다음 재사용을 위해서 파일에 저장한다. 이런 유형의 작업은 데이터와 워크플로어에서 발견한 것을 history를 호출해서 재사용할 수 있게 하고 출력을 깔끔하게 하기 위해 약간의 편집을 하고 쉘 스크립트로 저장한다.

Nelle의 파이프라인: 스크립트 생성하기

지도교수의 즉석 코멘트는 Nelle이 파일을 처리할 때 goostats에 추가 몇개의 매개변수를 주어야 한다는 것을 깨닫게 했다. 수작업으로 모든 분석을 했다면 아마도 재난이었을 것이다. 하지만, 루프덕분에 다시 작업하는데는 몇시간이 소요될 것이다.

만약 무언가가 두번 수행될 필요가 있다면 아마도 세번 혹은 네번도 수행될 필요가 있다는 것을 경험으로 알고 있다. 편집기를 실행하고 다음과 같이 작성한다.

# Calculate reduced stats for data files at J = 100 c/bp.
for datafile in $*
do
    echo $datafile
    goostats -J 100 -r $datafile stats-$datafile
done

(매개변수 -J 100-r은 지도교수가 사용해야 된다고 알려준 것이다.) do-stats.sh 이름으로 파일에 저장해서 다음과 같이 타이핑해서 첫번째 단계 분석을 다시 수행할 수 있다.

$ bash do-stats.sh *[AB].txt

또한 다음과 같이도 할 수 있다.

$ bash do-stats.sh *[AB].txt | wc -l

그렇게 해서 출력은 처리된 파일 이름이 아니라 처리된 파일의 숫자만 출력된다.

Nelle의 스크립트에서 주목할 한가지는 스크립트를 실행하는 사람이 무슨 파일을 처리할지를 결정하게 하는 것이다. 스크립트를 다음과 같이 작성할 수 있다.

# Calculate reduced stats for  A and Site B data files at J = 100 c/bp.
for datafile in *[AB].txt
do
    echo $datafile
    goostats -J 100 -r $datafile stats-$datafile
done

장점은 이 스크립트는 항상 올바른 파일만을 선택한다. 'Z'파일을 제거했는지 기억할 필요가 없다. 단점은 *항상** 이 파일만을 선택한다는 것이다. 모든 파일('Z'를 포함하는 파일), 혹은 남극 동료가 생성한 "G", "H" 파일에 대해서 스크립트를 편집하지 않고는 실행할 수 없다. 좀더 모험적이라면, 스크립트를 변경해서 명령-라인 매개변수를 검증해서 만약 어떠한 매개변수도 제공되지 않았다면 *[AB].txt을 사용한다. 물론, 이런 접근법은 유연성과 복잡성 사이에 서로 대립되는 요소 사이의 균형, 즉 트레이드오프(trade-off)를 야기한다.

주요점

  • 재사용을 위해서 통상 쉘 스크립트로 불리는 명령어를 파일에 저장한다.
  • bash filename은 파일에 저장된 명령어를 실행한다.
  • $* 모든 쉘 스크립트의 명령-라인 매개변수를 말한다.
  • $1, $2, 등등은 특정 명령-라인 매개변수를 말한다.
  • 사용자가 무슨 파일을 처리할지 결정하게 하는 것이 좀더 유연하고 좀더 내장된 유닉스 명령어와 일관성이 있다.

Leah는 수백개의 데이터 파일이 있고, 각각은 다음과 같은 형식을 가지고 있다.

2013-11-05,deer,5
2013-11-05,rabbit,22
2013-11-05,raccoon,7
2013-11-06,rabbit,19
2013-11-06,deer,2
2013-11-06,fox,1
2013-11-07,rabbit,18
2013-11-07,bear,1

임의의 파일이름을 명령-라인 매개변수로 갖는 species.sh 이름의 쉘 스크립트를 작성하라. cut, sort, uniq을 사용해서 각각의 파일별로 나오는 유일한 종의 목록을 화면에 출력하세요.

디렉토리 이름과 파일이름 확장자를 매개변수를 갖는 longest.sh이름의 쉘 스크립트를 작성해서 그 디렉토리에 그 확장자를 가지는 파일 중에 가장 긴 줄을 가진 파일이름을 화면에 출력하세요. 예를 들어, 다음은

$ bash longest.sh /tmp/data pdb

/tmp/data 디렉토리에 .pdb 확장자를 가진 파일 중에 가장 긴 줄을 가진 파일이름을 화면에 출력한다.

다음 명령어를 실행하면,

history | tail -5 > recent.sh

파일의 마지막 명령어는 history 명령어 자체다. 즉, 쉘은 history에 실질적으로 실행하기 전에 명령어 이력(log)을 추가한다. 사실, 쉘은 *항상** 명령어를 실행하기 전에 이력(log)에 명령어를 추가한다. 그렇게 하는 이유가 무엇이라고 생각합니까?

Joel의 data 디렉토리가 fructose.dat, glucose.dat, sucrose.dat 파일 세개를 담고 있다. 다음행을 담고 있는 스크립트로 bash example.sh *.dat로 실행할 때 example.sh 이름의 스크립트가 무엇을 수행하는지 설명하세요.

# Script 1
echo *.*
# Script 2
for filename in $1 $2 $3
do
    cat $filename
done
# Script 3
echo $*.dat