3P by GN⁺ | ★ favorite | 댓글 1개
  • Unix 계열에는 이름이 한 글자 기호인 /bin/[ 실행 파일이 있을 수 있으며, 셸 조건식처럼 보이는 문법도 실제로는 명령 실행과 종료 코드 위에 놓여 있음
  • test 는 표현식을 평가해 참이면 0, 거짓이면 1을 반환하고, [로 호출되면 마지막 인자가 ]인지까지 확인함
  • 많은 셸은 test[내장 명령으로도 제공하므로, 외부 /bin/test와 셸 내장 구현의 오류 메시지나 동작이 달라질 수 있음
  • Bash 확장인 [[ 는 외부 명령이 아니라 내장 구문이라, 예시처럼 long*를 글롭 확장하지 않고 리터럴 문자열로 비교하는 등 [와 다른 규칙을 적용함
  • 이식 가능한 스크립트에는 [가 맞고, Bash 전용이라면 [[를 일관되게 쓰는 편이 낫지만 두 방식의 확장 규칙 차이를 알고 선택해야 함

/bin/[/bin/test의 정체

  • Unix 시스템에는 이름이 한 글자 기호인 실행 파일 /bin/[ 가 있을 수 있음
    • 예시 명령 ls /bin/?/bin/[를 보여줌
  • /bin/[/bin/test는 같은 바이너리를 가리킬 수 있음
    • 예시에서는 두 경로가 같은 inode와 크기를 가진 파일로 나타남
    • 다만 모든 시스템에서 하드 링크일 필요는 없음
  • test는 셸에서 표현식을 평가하는 프로그램임
    • 문자열 비교
    • 숫자 비교
    • 파일 조건 확인
  • 평가 결과가 참이면 종료 코드 0, 거짓이면 1을 반환함

[가 명령처럼 동작하는가

  • test a = b는 조건식처럼 보이기 어렵지만, 같은 로직을 [ a = b ]처럼 쓰면 더 익숙한 형태가 됨
  • [는 별도 문법처럼 보이지만 실제로는 명령 호출임
    • if [ a = b ]; then ... fi[ 명령을 실행하고 종료 코드를 확인함
    • [로 호출되면 프로그램은 마지막 인자가 닫는 대괄호 ] 인지 검사함
  • if 문은 조건식을 직접 해석하는 대신, 전달된 명령의 종료 코드를 기준으로 분기함
    • test a = a; echo $?0
    • test a = b; echo $?1
    • [ a = a ]; echo $?0
    • [ a = b ]; echo $?1
  • 같은 맥락에서 truefalse도 종료 코드를 반환하는 보조 바이너리로 볼 수 있음

외부 바이너리와 셸 내장 명령의 차이

  • test[는 셸 스크립트에서 자주 쓰이기 때문에, 대부분의 셸은 이를 내장 명령으로도 구현함
  • 같은 입력이라도 외부 바이너리와 셸 내장 명령의 출력이 다를 수 있음
    • /bin/test a btest: a: unexpected operator
    • test a bdash: 2: test: a: unexpected operator
  • 이런 차이는 test[뿐 아니라 echo처럼 단순해 보이는 명령에서도 발생할 수 있음
  • 셸마다 내장 구현이 달라, 스크립트 동작이 실행 셸에 따라 달라질 여지가 있음

Bash 확장 [[가 적용하는 별도 규칙

  • [[는 Bash 확장이며 [ 사용을 대체할 수 있음
  • 가장 큰 차이는 [[항상 내장 구문이라는 점임
    • 외부 바이너리로 실행될 수 있는 [와 달리, [[에서는 Bash가 표현식 내부의 언어 규칙을 바꿀 수 있음
  • 글롭 예시에서 [[[는 서로 다르게 동작함
    • touch long-name[ long* = long-name ] && echo matchmatch를 출력함
    • [ 명령의 인자에는 일반 셸 확장 규칙이 적용되어 long*가 디렉터리의 long-name으로 확장됨
    • [[ long* = long-name ]] && echo match는 출력하지 않음
    • [[long*를 리터럴 문자열로 다뤄 long-name과 그대로 비교하므로 실패함
  • Bash 전용 스크립트라면 [[로 정규식 매칭 =~ 같은 기능도 사용할 수 있음

스크립트에서 무엇을 선택할지

  • 이식 가능한 셸 스크립트에는 [ 를 사용하는 편이 맞음
  • test도 사용할 수 있지만 일반적인 선택은 아님
  • 스크립트가 Bash 전용이라면 [[를 일관되게 쓰는 편이 나음
  • 셸 자체에도 !, &&, || 같은 표현식 연산자가 있음
    • 이 연산자들은 명령의 종료 상태를 기준으로 동작함
    • grep ^hello$ ... && grep ^bye$ ...는 두 명령이 모두 성공하면 전체 종료 코드가 0이 됨
    • 첫 번째 grep이 실패하면 && 뒤 명령까지 성공하지 않아 전체 종료 코드가 1이 됨
  • 따라서 test/[ 표현식과 셸의 논리 연산자를 한 조건문 안에서 조합할 수 있음
    • 예: [ a = b ] || grep -q ^hello$ /usr/share/dict/words
  • /bin/[/bin/test는 POSIX에서 하드 링크일 것을 요구하지 않음
    • NetBSD에서는 하드 링크였음
    • macOS Catalina는 같은 바이너리의 별도 복사본을 제공함
    • Debian testing은 서로 다른 바이너리를 제공함
    • POSIX 명세는 두 파일이 링크일 것을 요구하지 않음

댓글과 토론

Hacker News 의견들
  • 원문 작성자임. 공유해줘서 고맙고 프런트 페이지까지 올라가서 기쁨. 제목에는 아마 (2020) 이 붙는 게 맞고, "test"는 실제로 명령을 가리키는 말이라 대문자로 쓰지 않는 편이 좋겠음
    2021년에 쓴 관련 글도 있는데, bash의 [[ 연산자까지 다뤄서 이 맥락에서 재미있게 볼 수 있을 것 같음: https://jmmv.dev/2021/08/useless-use-of-gnu.html
    • [[는 엄밀히 말해 내장 명령이 아니라 근본적으로 문법 요소에 가까움. 아마 내부적으로는 거의 접근할 수 없는 내장 명령을 쓰겠지만, 재미있는 점은 ]]도 예약어가 의미 있는 위치에 올 수 없는데도 예약어라는 것임
      일부 비 bash 셸에서는 특정 종류의 함수를 선언하려면 function 키워드가 필요함. make$(shell)은 많은 대상을 빌드할 때 성능 차이가 측정될 수 있음. 그래도 아무 일도 안 하는 경우에는 손해라서, 보통은 재생성을 유도하려면 include를 쓰는 편이 맞음. GNU가 POSIX를 무시하는 건 완전히 타당한데, POSIX는 대부분의 실제 문제를 푸는 데 별로 유용하지 않음
    • https://jmmv.dev/2021/08/useless-use-of-gnu.html에서 다룬 GNU 확장 중 상당수는 대화형 사용에서는 매우 유용함. 현재 디렉터리를 명시적인 . 없이 검색하는 것도 유용하고, 방금 입력한 명령에 옵션을 뒤에 덧붙이는 것도 정말 편함. 그런 걸 지원하지 않는 명령을 보면 늘 짜증남
      스크립트에서는 POSIX sh에 맞추는 게 대체로 합리적임. 적어도 Bash 전용 문법을 쓰고 있는지는 알고 있어야 함
    • 그 글은 --ignore-case, set -o pipefail 같은 GNU 확장을 쓰면 스크립트의 이식성이 떨어진다고 불평함. 그 자체는 맞음
      하지만 Linux 사용자가 왜 이식성을 크게 신경 써야 하는지는 설명하지 않음. OpenBSD와 FreeBSD는 잘 살아 있지만 사용자 수가 너무 적어서 특별히 걱정할 대상은 아닌 것 같음. 공정성 차원에서 그런 운영체제도 고려해야 한다고 할 수는 있겠지만, 그 기준은 어디서 멈춰야 할까? vxWorks처럼 obscure한 것도 고려해야 하나? BusyBox, Alpine 쪽은 더 흥미롭지만 변경점이 워낙 커서 어차피 거의 항상 별도 포팅이 필요함. GNU가 아닌 생태계를 신경 써야 할 다른 설득력 있는 이유가 있을까?
    • if [ a = b ] || grep -q ^hello$ /usr/share/dict/words; then echo "test failed and grep succeeded"; fi 같은 건 그냥 평범한 일상적인 셸 사용법 아닌가?
    • 부차적인 이야기지만, 블로그 소프트웨어가 제목을 망가뜨린 것 같음. 예를 들어 make $(shell …) expansion처럼 제공되는데, 원래는 make $(shell ...) expansion이어야 함
      본문에는 제대로 쓰여 있듯이 점 세 개지 말줄임표 하나가 아니므로, mldr 자체도 맞지 않음. 아마 서로 무관한 버그 두 개가 동시에 영향을 준 듯함
  • 마지막 요점을 한 단계 더 밀어붙이면 if 블록 자체도 없앨 수 있음. if [ a = b ]; then echo "Oops!"; else echo "Expected; phew!"; fi[ a = b ] && echo "Oops!" || echo "Expected; phew!"가 됨
    얼마나 자주 이렇게 해야 하는지는 모르겠지만, [ "$debug" ] && echo "what's going on" >&2처럼 조건부로 디버그 출력을 표준 오류에 찍을 때는 가끔 유용함. if 블록이 일반 명령을 검사한다는 사실 때문에 if grep -q 'debug' /var/log/nginx/access.log; then echo "Debug request found!"; fi 같은 것도 가능함. 아직 알아보지 않은 건 [ $(expr 1 + 1) -eq 2 ] && [ $(expr 2 + 2) -eq 3 ]라고 써야 할지, 아니면 test의 내장 논리곱인 [ $(expr 1 + 1) -eq 2 -a $(expr 2 + 2) -eq 4 ]를 써야 할지임. 성능이 문제가 아니라면 양쪽 모두 비슷한 이유로 타당해 보임
    • [ a = b ] && echo "Oops!" || echo "Expected; phew!"를 일반 규칙처럼 받아들이면 안 됨. 아마 bash는 이 줄을 ([ a = b ] && echo "Oops!") || echo "Expected; phew!"처럼 해석할 것임
      그래서 && 뒤의 명령열이 실패하면 || 뒤의 코드가 어쨌든 실행됨. 예를 들어 >/dev/full echo "strings match"가 쓰기 오류로 실패하면 문자열은 같았는데도 "strings don't match"가 출력됨. 이는 if 블록의 의미와 다름
    • 그런 식의 축약은 피하는 편이 좋음. 써야 하는 set -e 를 쓰고 있다면 if [ a = b ]; then echo "Oops!"; fi는 예상대로 동작하지만, [ a = b ] && echo "Oops!"는 표현식 ab와 같지 않을 때 오류로 종료됨
    • POSIX에 따르면 -a, -o 이항 기본식과 (, ) 연산자는 폐기 예정으로 표시되어 있음. 자세한 건 https://pubs.opengroup.org/onlinepubs/9699919799/utilities/t...의 "Application Usage"를 보면 됨
    • -a를 쓰든 테스트 두 개와 &&를 쓰든, bash의 산술 평가를 쓸 수 있다면 expr를 실행하러 나갈 필요는 없음: [ $((1+1)) -eq 2 ]
  • 몇 년 전부터 [를 안 쓰기 시작했음. test는 이게 문법이 아니라 다른 명령과 같은 명령일 뿐이라는 점을 강화해 줌. 그리고 man bash를 뒤지는 것보다 man test가 훨씬 쾌적함
    • 말이 잘 안 맞음. GNU Coreutils에는 man test뿐 아니라 man [ 매뉴얼 페이지도 있음
      Bash에는 빠른 치트시트로 help test가 있음. [ 명령은 아주 오래됐고, 이미 1979년 Version 7 Unix에 들어 있었음
    • 동의함. [와 bash 전용인 [[는 실제로 무슨 일이 벌어지는지에 관해 많은 혼란을 만들어서, 확신을 갖기가 어려웠음
      다만 [[가 내장 명령임이 보장된다는 점은 셸 스크립트 성능이 의미 있던 시절에는 분명 목적이 있었고, 그리 오래전 일도 아님
    • 이 글을 읽고 나면 그냥 test를 쓸 것 같음. bash 스크립트를 자주 쓰지 않아서 if 문, 특히 공백 규칙에 늘 걸림. 이유를 보고 나니 너무 당연해졌고, test를 쓰면 그저 인자를 넘기는 것뿐이라는 점이 더 잘 드러남
  • [test에서 가장 큰 함정은 인자 하나짜리 동작임. 예를 들어 변수의 비어 있지 않음을 확인하려고 [ -n $FOO ]라고 쓸 수 있음
    그런데 FOO가 설정되어 있지 않으면 빈 문자열이 아니라 아무것도 없는 것으로 확장되어 [ -n ]과 같아짐. POSIX는 [의 인자 하나짜리 형태에서 그 인자, 여기서는 "-n"이 비어 있지 않으면 성공해야 한다고 요구함. 그래서 $FOO가 비어 있지 않다고 잘못 보고함. 변수는 꼭 따옴표로 감싸야 함
    • 마지막 문장을 맨 앞으로 옮겨야 함. 변수는 따옴표로 감싸라. test 내장 명령의 명세 자체에 함정이 있는 게 아니라, 함정은 셸 그 자체에 있음
      언급한 동작은 말이 됨. [ "$FOO" ]는 내용이 무엇이든, 심지어 "-n"이더라도 항상 비어 있지 않은지 검사하는 형태이기 때문임
    • 스크립트에는 ShellCheck를 돌리면 됨
    • $FOO에 공백이 들어 있으면 여러 인자로 확장됨. 그냥 항상 변수를 따옴표로 감싸라
    • [ x"$FOO" != x"" ]
    • 이 경우라면 [ -n "${FOO?}" ]를 써서 $FOO가 널이거나 설정되지 않았을 때 스크립트가 즉시 멈추게 하겠음
  • chubot이 test/[/[[의 더 미묘한 부분을 파고든 흥미로운 문서를 썼음. 그 블로그의 다른 글들도 셸의 이상한 점들을 꽤 흥미롭게 설명함
    ¹ https://www.oilshell.org/blog/2017/08/31.html
    ² https://www.oilshell.org/blog/2016/11/18.html
  • [가 프로그램이라는 걸 전혀 몰랐고, 마지막 인자가 닫는 대괄호인지 확인한다는 사실이 좀 웃김
    그래도 왜 대괄호 양쪽에 공백이 필요한지는 설명이 됨
  • [[는 bash 전용임. bash만 쓸 걸 안다면 쓰면 됨. 자세한 내용은 글이 잘 다룸
    • zsh도 있음 :)
      https://zsh.sourceforge.io/Doc/Release/Conditional-Expressio...
    • 항상 [[를 쓰면 됨
      zsh와 ksh에도 있고, 사실 1988년이나 그 이전의 ksh에서 시작된 걸로 거의 확신함
    • test[는 POSIX에 명시되어 있고 보통 실제 바이너리로 존재함. 다만 셸 내장 명령이 가릴 수도 있음
      반면 [[는 POSIX에 명시되어 있지 않고, 보통 셸 내장으로만 존재함
    • 명시적으로 Fish 같은 완전히 다른 셸을 쓰는 경우가 아니라면 왜 Bash를 안 쓰는지 모르겠음
      최소 공통 셸만 대상으로 삼는 건 완전히 고대적인 걱정처럼 느껴짐
  • 마지막 if 문이 왜 헷갈리는지 잘 이해가 안 됐음. 셸 스크립트를 처음 배울 때 [가 그냥 다른 프로그램이 아니라 bash 스크립트 언어의 일부라고 보통 가정하기 때문이라면 이제 이해가 됨. 아니라면 왜 놀라운지 설명해주면 좋겠음
    • [가 바이너리라는 걸 몰라도 왜 헷갈리는지 잘 모르겠음. 아주 평범한 bash처럼 보임
  • 셸에 대해 강한 취향이 있는데, 세상 대부분과는 잘 맞지 않음
    [는 절대 쓰면 안 되고 test 만 써야 한다고 봄. [는 그 메커니즘이 언어 문법의 일부인 것처럼 착각하게 만들지만, 실제로는 또 하나의 "프로그램"일 뿐임. 여기서 "프로그램"에는 내장 명령과 함수도 포함. if/||/&&는 종료 상태를 보고, 프로그램은 확장된 뒤에는 그냥 문자열인 마법 변수 $?를 보는 것 말고는 다른 것의 종료 상태를 볼 수 없음. case는 문자열을 보지만 종료 상태를 기준으로 동작하지 않고 case ... esac 동작의 일부로 종료 상태를 설정하지도 않음. "프로그램"이 종료 상태를 설정함. 또 [/test는 파일시스템 구조를 평가할 때만 써야 하고, test -f /dev/null 같은 경우가 해당함. 문자열 평가는 case를 써야 한다고 봄. 당연히 대부분의 스크립트를 보면 가렵고, 내가 쓴 스크립트는 남들이 이상하게 여김
    • 농담이 내 쪽으로 돌아왔네. 그게 바로 글의 요지였음
      셸을 쓸 때는 if 다음에 프로그램을 별도 줄에 두고 then으로 넘어가는 형태를 선호함. if 뒤의 것은 "then 앞 마지막 명령의 종료 상태를 본다"는 점을 강조하기 위해서임. 예를 들어 if; ls /tmp/goober; test -d /tmp/goober; echo "I'll always execute the then clause because echo will always return a 0 return code"; then ... fi 같은 구조에서는 if가 결국 echo의 종료 상태만 보기 때문에 항상 then 절이 실행됨
    • 같은 길을 걷는 동지를 만났네. test가 들어간 8년치 스크립트가 내가 동의한다는 증거임. 외로운 길임... Google 셸 스타일 가이드를 탓함
      shbash 모두에서 돌아가야 하는 스크립트를 쓰면서 test를 선호하는 습관이 생겼지만, 문자 [를 명령처럼 다루는 것보다 의미적으로 더 납득돼서 계속 쓰고 있음. ]가 별도 바이너리가 아니라 [의 인자라는 점도 이상함. 기술적인 이유는 이해하지만 해킹처럼 느껴짐
    • 여기 댓글을 읽어보니 어떻게든 test만 쓰는 사람이 수십 명은 되는 것 같음. 수십 명!
  • 정규식 매칭을 하고 싶을 때만 [[를 쓰는 식으로 배웠음. 예: if [[ "${foo}" =~ ^bar$ ]]; then echo Yes; fi
    그 외에는 그냥 "test""["를 씀. bash 85,000줄째 쓰는 중임. bash가 훌륭하다는 말은 아니지만, 아직 많은 일에 내 필요를 충족해 줌
    • 비교적 단순한 패턴 매칭을 POSIX 호환 방식으로 하고 싶다면 expr기본 정규식을 매칭할 수 있고, 캡처 그룹도 반환할 수 있음
      1: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/e...
    • 정규식을 그대로 쓰고 따옴표로 감싸지 않는다는 사실 때문에 한동안 당했음. 뭐든 종교적으로 따옴표를 치는 사람이라, 바보같이 단순한 정규식이 왜 매칭되지 않는지 알아내는 데 시간이 걸렸음
    • 그 예시는 별 의미가 없음. [ "$foo" = bar ] && echo Yes면 됨
      부분 문자열 매칭에는 [* 글롭이면 대체로 충분함. [ "$bar" = extra* ] && echo '$bar began with extra' 같은 식임. Bash의 정규식 방언은 원시적이라 애써 쓸 가치가 거의 없음. 복잡한 일은 grep, awk, perl 같은 다른 도구를 쓰는 게 맞음. 더 높은 재사용성, 모듈성, 내장 타입이 필요한 복잡한 작업까지 모두 bash로 하려고 집착하면 수익이 급격히 줄어듦