30장. 디버깅

Bash 쉘에는 디버거도 없고 디버깅용 명령어도 없습니다. 스크립트의 문법 에러나 명백한 오자(typos)등이 만들어 내는 암호같은 에러 메세지들은 제대로 동작하지 않는 스크립트를 디버깅하는데 아무 도움도 되지 않습니다.

예 30-1. 버그 있는 스크립트

#!/bin/bash
# ex74.sh

# 버그 있는 스크립트.

a=37

if [$a -gt 27 ]
then
  echo $a
fi  

exit 0

스크립트의 출력:
./test23: [37: command not found

위의 스크립트는 뭐가 잘못된 걸까요?(힌트: if 다음을 잘 살펴보세요)

스크립트가 실행은 되지만 생각했던대로 동작하지 않는다면 어떻게 할까요? 이런 것을 보통, 로직 에러라고 합니다.

예 30-2. test24, 버그가 있는 다른 스크립트

#!/bin/bash

#  현재 디렉토리에서 파일 이름에 빈 칸이 들어간 모든 파일들을
#+ 지우려고 하는데 안 됩니다. 왜 그럴까요?


badname=`ls | grep ' '`

# echo "$badname"

rm "$badname"

exit 0

예 30-2에서 뭐가 잘못 됐는지 알아보려면 echo "$badname"이 있는 줄의 주석을 풀어 보세요. echo 문은 여러분이 바라던 값을 제대로 얻었는지 아는데 유용하게 쓰입니다.

이렇게 특별한 경우에, rm "$badname"이라고 하면 원하는 결과를 얻지 못하는데 왜냐하면 여기서는 $badname이 쿼우트 되면 안 되기 때문입니다. $badname을 쿼우트 해주면 rm이 단지 한 개의 인자(하나의 파일명과 일치)를 갖도록 해 줍니다. 부분적인 해결책은 $badname의 쿼우트를 없애고, $IFS가 뉴라인 문자만을 갖도록 IFS=$'\n'라고 해주면 됩니다. 하지만, 더 간단하게 하려면 이렇게 하면 됩니다.
# 빈 칸이 들어 있는 파일명을 지우는 알맞은 방법.
rm *\ *
rm *" "*
rm *' '*
# Thank you. S.C.

버그 있는 스크립트의 증상을 요약해 보면,

  1. syntax error 메세지를 내면서 죽는다

  2. 죽지는 않지만 생각했던 대로 동작하지 않는다(로직 에러, logic error).

  3. 죽지도 않고 생각했던 대로 동작하지만, 처리하기 까다로운 부효과(side effect)가 있다(로직 폭탄, logic bomb).

제대로 동작하지 않는 스크립트 디버깅에 쓸 수 있는 방법으로는

  1. 중요한 부분에 echo 문으로 변수값을 찍어서 어떻게 돌아가고 있는지 살펴 본다.

  2. 중요한 부분에 tee 필터를 걸어서 프로세스나 데이타 흐름을 확인해 본다.

  3. -n -v -x 옵션을 건다.

    sh -n scriptname는 스크립트를 돌리지는 않고 단순히 문법 에러(syntax error)만 확인합니다. 스크립트 안에서 set -n이나 set -o noexec이라고 해도 같은 동작을 합니다. 조심할 점은 이 옵션에 걸리지 않고 그냥 넘어가는 문법 에러도 있다는 것입니다.

    sh -v scriptname는 각 명령어를 실행하기 전에 그 명령어를 echo 해 줍니다. 스크립트 안에서 set -v이나 set -o verbose라고 해도 같은 동작을 합니다.

    -n-v 플래그를 같이 쓰면 아주 좋습니다. sh -nv scriptname 이라고 하면 문법 체크를 아주 자세하게 해줍니다.

    sh -x scriptname는 각 명령어의 결과를 간단한 형태로 echo 시켜 줍니다. 스크립트 안에서 set -xset -o xtrace라고 해도 똑같습니다.

    스크립트에 set -uset -o nounset을 넣어두면, 선언 없이 쓰이는 변수들에 대해서 unbound variable 에러 메세지를 출력해 줄 것입니다.

  4. 스크립트의 아주 중요한 지점에서 변수나 조건을 테스트 하기 위해서 "assert" 함수 쓰기.(이 아이디어는 C 에서 빌려왔습니다.)

    예 30-3. "assert"로 조건을 테스트하기

    #!/bin/bash
    # assert.sh
    
    assert ()                 #  조건이 거짓이라면,
    {                         #+ 에러 메세지를 내고 스크립트를 종료.
      E_PARAM_ERR=98
      E_ASSERT_FAILED=99
    
    
      if [ -z "$2" ]          # 매개변수가 맞게 넘어오지 않았음.
      then
        return $E_PARAM_ERR   # 그냥 넘어감.
      fi
    
      lineno=$2
    
      if [ ! $1 ] 
      then
        echo "Assertion failed:  \"$1\""
        echo "File $0, line $lineno"
        exit $E_ASSERT_FAILED
      # else
      #   return
      #   스크립트를 계속 실행시킴.
      fi  
    }    
    
    
    a=5
    b=4
    condition="$a -lt $b"     # 에러 메세지를 내고 스크립트를 종료.
                              #  "condition"을 다른 것으로 바꿔보고 
                              #+ 어떻게 되는지 살펴보세요.
    
    assert "$condition" $LINENO
    # "assert"가 실패하지 않을 경우에만 다음 부분이 실행됩니다.
    
    
    # 다른 명령어들.
    # ...
    
    exit 0
  5. exit 잡아채기(trapping at exit).

    스크립트에서 exit를 쓰면 프로세스 종료를 의미하는 0번 시그널을 날려서 자기 자신을 종료시킵니다. [1] exit를 잡아채서(trap) 강제로 변수값을 "출력"하도록 하는 등의 유용한 작업을 할 수 있습니다. 이렇게 하려면 trap 명령어가 스크립트의 첫 번째 명령어여야 합니다.

시그널 잡아채기(Trapping signals)

trap

시그널을 받았을 때의 동작을 지정해주는데, 디버깅에 유용하게 쓸 수 있습니다.

참고: 시그널이란 간단히 말해서 프로세스에게 보내는 메세지입니다. 커널이 보내든 다른 프로세스가 보내든지간에 주어진 동작(보통은 종료)을 하라고 말해 주는 것입니다. 예를 들면, 실행중인 프로그램에 Control-C를 눌러서 사용자 인터럽트(INT 시그널)를 보낼 수 있습니다.

trap '' 2
# 아무 동작도 지정하지 않고 단순히 2번 인터럽트(Control-C)를 무시합니다.

trap 'echo "Control-C는 무시됩니다."' 2
# Control-C가 눌렸을 때의 메세지.

예 30-4. exit 잡아채기(Trapping at exit)

#!/bin/bash

trap 'echo Variable Listing --- a = $a  b = $b' EXIT
# EXIT 는 스크립트가 종료될 때 발생하는 시그널의 이름입니다.

a=39

b=36

exit 0
#  스크립트 파일에 들어 있는 모든 명령어를 다 실행하고 나면
#+ 어쨌든 스크립트가 종료되기 때문에, 'exit' 명령을 주석 처리해도 
#+ 결과가 같음에 주의하기 바랍니다.

예 30-5. Control-C 가 눌렸을 때 깨끗이 청소하기

#!/bin/bash
# logon.sh: 여러분이 아직도 로그인해 있는지를 확인해 주는 아주 간단한 스크립트.


TRUE=1
LOGFILE=/var/log/messages
# $LOGFILE 은 읽을 수 있어야 됩니다(chmod 644 /var/log/messages).
TEMPFILE=temp.$$
# 이 스크립트의 프로세스 ID로 "유일한" 임시 파일 이름을 만듭니다.
KEYWORD=address
#  로그인을 하게 되면 /var/log/messages 에 
#+ "remote IP address xxx.xxx.xxx.xxx" 란 줄이 덧붙여 집니다.
ONLINE=22
USER_INTERRUPT=13

trap 'rm -f $TEMPFILE; exit $USER_INTERRUPT' TERM INT
#  스크립트가 Control-C 에 의해 인터럽트를 받았을 경우에 임시 파일을 지워줍니다.

echo

while [ $TRUE ]  # 무한 루프.
do
  tail -1 $LOGFILE> $TEMPFILE
  # 시스템 로그 파일의 마지막 줄을 임시 파일로 저장.
  search=`grep $KEYWORD $TEMPFILE`
  # 성공적인 로그인을 나타내는 "IP address"란 문구가 들어 있는지 확인.

  if [ ! -z "$search" ] # 빈 칸이 들어 있을 수 있기 때문에 쿼우트 필요.
  then
     echo "On-line"
     rm -f $TEMPFILE    # 임시 파일 지우기.
     exit $ONLINE
  else
     echo -n "."        #  연속적인 점들이 찍히도록 -n 옵션으로 
	                    #+ echo 가 뉴라인을 무시하도록 함.
  fi

  sleep 1  
done  


#  주의: KEYWORD 변수를 "Exit" 로 바꾸면 로그인 상태에서 갑작스럽게 로그아웃이
#+ 됐는지를 확인해 볼 수 있습니다.

# 연습문제: 위의 주의사항대로 스크립트를 바꾸고 예쁘게 다듬어 보세요.

exit 0


# Nick Drage 가 다른 방법을 제안해 주었습니다:

while true
  do ifconfig ppp0 | grep UP 1> /dev/null && echo "connected" && exit 0
  echo -n "."   # 연결될 때까지 점(.....)을 출력.
  sleep 2
done

# 문제점: 이 스크립트를 끝내기에는 Control-C 를 누르는 것만으로 부족합니다.
#          (점이 계속 에코됩니다.)
# 연습문제: 이 문제를 해결해 보세요.



# Stephane Chazelas 도 다른 방법을 제안해 주었습니다:

CHECK_INTERVAL=1

while ! tail -1 "$LOGFILE" | grep -q "$KEYWORD"
do echo -n .
   sleep $CHECK_INTERVAL
done
echo "On-line"

# 연습문제: 위에서 설명한 각 방법들의 장점과 단점을 생각해 보세요.

참고: trap 명령어에 DEBUG 인자를 주면 스크립트의 모든 명령어 다음에 주어진 동작을 수행하도록 합니다. 이는 변수를 추적하는 등의 일을 가능케 합니다.

예 30-6. 변수 추적하기

#!/bin/bash
# 옮긴이: 이 스크립트가 제대로 동작하려면 #!/bin/bash2 로 바꿔줘야 됩니다.

trap 'echo "추적할 변수> \$variable = \"$variable\""' DEBUG
# 매 명령어마다 $variable 의 값을 에코해 줍니다.

variable=29

echo "\"\$variable\" 은 $variable 로 초기화됨."

let "variable *= 3"
echo "\"\$variable\" 은 3이 곱해짐."

#  "echo $variable" 을 많이 써서 스크립트가 꼴사나와지고 
#+ 시간을 많이 잡아먹는 복잡한 스크립트에
#+ "trap 'commands' DEBUG" 를 쓰면 아주 유용하겠죠.

# 이 사실을 알려준 Stephane Chazelas 에게 감사를 표합니다.

exit 0

참고: trap '' SIGNAL(두 개의 붙어 있는 작은 따옴표)이라고 하면 스크립트 나머지 부분에서 SIGNAL을 못 쓰게 합니다. trap SIGNAL이라고 하면 SIGNAL을 다시 받을 수 있게 해 줍니다. 이는 달갑지 않은 인터럽트로부터 스크립트의 중요한 부분을 보호해 줍니다.

    trap '' 2  # 2번 시그널은 Control-C 인데 이제 안 먹힙니다.
    command
    command
    command
    trap 2     # Control-C 가 다시 먹게 합니다.
    

주석

[1]

관습적으로 0번 시그널exit로 할당돼 있습니다.