아래 글에서 이어지는 내용.

지난번 글에서는 read -n 명령을 while문으로 반복 실행하여 텍스트 문자열을 40글자마다 줄바꿈하여 출력하는 것을 다루었다. 이번에는 같은 작업을 sed를 이용하여 해 보자.

데이터 파일 만들기

우선 지난번 글에서 했던 것 처럼 작업에 사용할 더미 데이터 파일들을 만들자. 아래 명령을 실행하면 된다.

{ xxd -p -l 100 /dev/urandom | tr -d '\n ' | tee 200_wo_newline; echo; } > 200_w_newline
{ xxd -p -l 110 /dev/urandom | tr -d '\n ' | tee 220_wo_newline; echo; } > 220_w_newline

위 명령은 우분투와 안드로이드에서 모두 잘 실행된다. 자세한 설명은 아래 링크를 참고.

실행하면 아래와 같이 4개의 파일이 생성된다.

$ { xxd -p -l 100 /dev/urandom | tr -d '\n ' | tee 200_wo_newline; echo; } > 200_w_newline
$ { xxd -p -l 110 /dev/urandom | tr -d '\n ' | tee 220_wo_newline; echo; } > 220_w_newline
$ ls -l 2*_newline
-rw-r--r-- 1 nooriro nooriro 201 May  6 15:46 200_w_newline
-rw-r--r-- 1 nooriro nooriro 200 May  6 15:46 200_wo_newline
-rw-r--r-- 1 nooriro nooriro 221 May  6 15:46 220_w_newline
-rw-r--r-- 1 nooriro nooriro 220 May  6 15:46 220_wo_newline

파일 이름의 규칙은 다음과 같다. 이름만 봐도 각각이 무슨 파일인지 알 수 있다.

  • 200_로 시작하는 파일: 랜덤한 십육진수 문자 200자리의 데이터
  • 220_로 시작하는 파일: 랜덤한 십육진수 문자 220자리의 데이터
  • _w_newline으로 끝나는 파일: 끝에 개행문자가 덧붙여져 있다
  • _wo_newline으로 끝나는 파일: 끝에 개행문자가 덧붙여져 있지 않다

각 파일의 내용을 확인하려면 cat 200_w_newline 처럼 cat 명령을 실행하면 된다.

sed의 가장 기본적인 s 명령

구글에서 add newline at n bytes in shell 검색어로 방법을 찾아봤더니 Unix Stack Exchange에 올라온 아래 질문글이 먼저 눈에 들어왔다.

첫 번째 문서는 한 줄에 출력할 글자수만 다를 뿐 여기서 하려는 것과 같은 질문이고, 두 번째 문서는 첫 번째 문서에 링크된 글인데 질문이 조금 다르다. 이들 문서에 나온 방법을 참고하여 우선 아래와 같은 명령을 생각해보자.

sed 's/.\{40\}/&\
/g' filename

위 명령에서 작은따옴표(' ')로 둘러싸인 부분이 sed script이다. 이 sed 스크립트를 분석해 보자.
sed의 사용 방법은 구글에서 sed tutorial 혹은 sed manual 혹은 sed 사용 방법 등으로 검색하면 많이 나오는데, 그 중에서 나는 Bruce Barnett이 작성한 sed 튜토리얼을 주로 참고했다.

  • s/REGEXP/REPLACEMENT/[FLAGS]substitute command(치환 명령)이다. REGEXP 패턴을 찾아서 REPLACEMENT로 바꾼다. (참고)

  • 치환 명령의 끝에 붙은 gglobal replacement를 뜻하는 플래그이다. g 플래그가 없으면 각각의 줄에서 첫 번째로 매치된 REGEXPREPLACEMENT로 바뀐다. g가 있어야 나머지도 전부 바뀐다. (참고)

  • .\{40\}개행 문자를 제외한 임의의 문자(.) 40개(\{40\})에 매치되는 패턴이다. 수량 지정자(quantifier)에 쓰이는 {}는 저렇게 백슬래시로 이스케이프를 해야 한다. 우리가 흔히 알고 있는 정규식과 이스케이프 방법이 다르므로 유의. (참고)

  • &REGEXP에 매치된 문자열 전체(즉, 찾은 문자열 전체)를 뜻한다. (참고)
    참고로, REGEXP 안에서 \(\)로 캡처한 그룹을 REPLACEMENT로 가져올 때에는 \1, \2, \3, …, \9를 사용한다. 그룹 지정에 쓰이는 괄호 ( ) 역시 이스케이프를 한다는 것에 유의할 것. (참고)

  • \ 뒤에 바로 줄바꿈이 된 것은 개행 문자를 뜻한다. 원래 셸 명령행에서 작은따옴표(' ')로 둘러싸인 부분은 글자 그대로의 문자열로 취급되며 개행 문자 역시 이스케이프를 필요로 하지 않는다. 하지만 REPLACEMENT 안에서 개행 문자를 나타내려면 그 앞에 \를 붙여서 이스케이프를 해야 한다.
    (\n이 아니라, \ 뒤에 ‘글자 그대로의 개행문자’이므로 혼동하지 않도록 주의.)
    참고로 REGEXP 안에서는 개행 문자를 \n으로 나타내야 한다. 굉장히 혼란스럽다. (참고)

여러 정규 표현식의 종류

sed가 디폴트로 받는 정규식은 POSIX basic regular expression (BRE)라는 것인데, 여러 명령행 도구에서 이 BRE가 디폴트로 사용된다. (참고)
하지만 앞서 말했듯이 BRE에서는 자주 사용되는 그룹 지정자 ( )와 수량 지정자 { } 앞에 \를 붙여 이스케이프를 해야 한다. 이는 흔히 사용되는 정규표현식의 문법과 달라서 오류를 내기 쉬우며 또한 가독성도 떨어진다.
뿐만 아니라 BRE는 매우 기본적인 기능만 지원하며 ? + | 조차 지원되지 않는다. ?\{0,1\}로, +\{1,\}로 대신할 수 있지만 |가 지원되지 않는 것은 치명적이다. (참고1, 참고2) GNU grep과 GNU sed에서는 BRE mode에서 확장 문법으로 \? \+ \|를 지원하지만 이는 GNU grep/sed에서만 되는 확장 문법일 뿐이다. (참고)

POSIX extended regular expression (ERE)라는 것도 있다. ERE는 메타 문자로 사용된 ( ) { }를 이스케이프하지 않으며 BRE보다 많은 기능을 지원한다. 특히, ? + |를 지원한다. grep에서는 -E 옵션을, sed에서는 -E 또는 -r 옵션을 지정하면 ERE를 사용할 수 있다. (참고)
이 Unix Stack Exchange 답변에 의하면, awk에서는 디폴트로 ERE를 사용한다고 한다.

하지만 오래 전부터 사실상의 표준으로 통용되어 온 정규표현식은 BRE도 ERE도 아닌 Perl의 regular expression이다. (여기서 Perl은 PHP나 Python같은 스크립트 언어이다.) Perl 정규식은 ERE보다도 기능이 더 방대하다. 상당수의 프로그래밍 언어가 이 Perl 정규식을 지원한다고는 하는데, 모든 기능을 동일하게 구현한 것은 아마 없을테고 Perl 정규식의 일부 기능만을 구현했을 것으로 생각된다. (원래 방대한 기능을 100% 동일하게 구현한다는 것은 엄청나게 어려운 일이다.)

Perl compatible regular expression (PCRE)라는 것도 있다. 이름만 들으면 정규식의 한 종류인 것처럼 들리지만, 사실 이건 Perl의 정규식을 다른 곳에서도 쓸 수 있게 구현한 라이브러리이다. 이것 역시 Perl의 정규식과 100% 같지는 않지만, 아파치, PHP같은 대형 프로젝트에서도 이 PCRE 라이브러리를 사용하고 있다고 하니, 꽤 유명하고 널리 사용되는 라이브러리인 듯 하다.

grep/sed/awk 등의 표준 명령행 도구가 ERE(까지)만 지원한다는 것은 매우 아쉬운 일인데, BRE/ERE에서는 ‘긍정적 룩어헤드 / 부정적 룩어헤드’ 같은 lookaround 기능이 지원되지 않기 때문이다. 이를테면, 줄 끝에 있는 특정 패턴을 매치시키는 것은 BRE/ERE로도 가능하지만 줄 끝에 있지 않은 특정 패턴을 매치시키는 것은 부정적 룩어헤드를 사용해야 하므로 BRE/ERE로는 불가능하다. (참고) (참고2)
sed가 정규표현식만으로 모든 것을 처리하지는 않으므로 어떻게 해서든 대체 방법이 있겠지만, 그렇다고 해서 lookaround를 지원하지 않는 BRE/ERE의 기능이 충분해지는 것은 아니다.

sed의 첫 실행과 해결할 문제 확인

다시 원래 하던 얘기로 돌아와서, 일단 위 sed 명령

sed 's/.\{40\}/&\
/g' filename

을 4개 파일에 대하여 각각 실행해보자.
실행해 보면, 4개 중에서 2개만 기대한 대로 실행된다. 각 파일의 처리 결과는 아래와 같다.

$ sed 's/.\{40\}/&\
> /g' 200_w_newline
09908c0b5a22ec99b35467d7aaac50e8b5e0322d
d35404c1602fa63aaf44c1a3bf40942933b41406
1ed87da7f968eba4fdced8ff4894709512ed2f24
70387275aafb21cdd1817557c163927342e50716
1d8305c72b4ba8075ca830e51fd2dda8c3316740

$ sed 's/.\{40\}/&\
> /g' 220_w_newline
f62b844fbde0541e6bf29dcdd313d7396a7161ae
0b9a2afe5fd76557fdaab060d803120baef2ea36
9abb2579e6de10c81803fe83bdf7c84d3d25901d
dec3ea988d5a42ce2443d308080368b3a658eba9
56bf4fcc58d1d8e68564624eca322da797c753d2
0b7beb71e5fc01f0d522
$ sed 's/.\{40\}/&\
> /g' 200_wo_newline
09908c0b5a22ec99b35467d7aaac50e8b5e0322d
d35404c1602fa63aaf44c1a3bf40942933b41406
1ed87da7f968eba4fdced8ff4894709512ed2f24
70387275aafb21cdd1817557c163927342e50716
1d8305c72b4ba8075ca830e51fd2dda8c3316740
$ sed 's/.\{40\}/&\
> /g' 220_wo_newline
f62b844fbde0541e6bf29dcdd313d7396a7161ae
0b9a2afe5fd76557fdaab060d803120baef2ea36
9abb2579e6de10c81803fe83bdf7c84d3d25901d
dec3ea988d5a42ce2443d308080368b3a658eba9
56bf4fcc58d1d8e68564624eca322da797c753d2
0b7beb71e5fc01f0d522$

지난번 글에서 처음에 한 것(while read -n 40 line 루프 안에서 echo $line을 실행)과는 달리, 이번에는 결과가 규칙적이며 예측했던 대로다.

sed 명령은 앞에서부터 40글자마다 개행 문자를 그 끝에 삽입한다. 따라서,

  • _w_newline 쪽은 전체 글자수가 40의 배수가 아닌 경우에는 원하는 결과가 나오지만, 전체 글자수가 40의 배수인 경우에는 맨 끝에 (불필요한) 개행 문자가 삽입되어 끝에 불필요한 빈 줄이 생긴다.
  • _wo_newline 쪽은 반대로 전체 글자수가 40의 배수인 경우에 원하는 결과가 나오고, 전체 글자수가 40의 배수가 아닌 경우에 끝에 줄바꿈이 되지 않는 문제가 생긴다.

넷 중에서 문제가 되는 것은 200_w_newline(끝에 개행문자가 중복됨)과 220_wo_newline(끝에 개행문자가 없음)이며 이제 이들을 디버깅할 차례다. 하지만 그 전에 반드시 짚고 넘어가야 하는 것이 있다.

sed에 대한 매우 중요한 사실

sed script는 여러 개의 명령으로 구성되는데, 입력 파일의 첫 번째 줄에 대하여 명령 1부터 명령 N까지를 연달아서 적용하고 그 결과를 화면(표준출력)에 출력한 다음, 입력 파일의 두 번째 줄을 (같은 식으로) 처리힌다.
이 작업 순서를 정확하게 이해하는 것이 매우 중요하다. 명령 1부터 명령 N까지 처리하는 과정이 안쪽 루프에 해당하며, 입력 파일의 첫번째 줄부터 마지막 줄까지 처리하는 과정은 바깥 루프에 해당한다.

그러므로 sed의 작동 방식을 다음과 같이 설명해서는 안 된다.

입력 파일의 각 줄에 대하여 명령 1부터 명령 N까지를 연달아 적용한 결과를 출력한다.

저 설명이 틀렸다고는 할 수 없다. 하지만 저렇게 설명하면 두 루프의 중첩이 어떻다는 것인지가 모호해진다. 두 루프의 중첩을 반대로 이해하여 입력 파일의 첫번째 줄부터 마지막 줄까지 각 줄마다 명령 1을 적용한 다음, 그 결과에 명령 2를 역시 각 줄마다 적용하는 식으로 작동한다고 오해할 수도 있다. 만일 그렇게 오해하면 sed에 대한 거의 모든 것이 이해되지 않을 것이며, sed가 그저 어렵게만 느껴질 것이다.

sed에서 .은 개행 문자에도 매치된다

또한 인터넷을 검색하다 보면 정규식의 메타문자 .이 개행 문자를 제외한 모든 문자에 매치된다고 서술된 문서를 간혹 보게 되는데, 이는 틀린 서술이다. 적어도 sed에서는 그렇다.

1개의 명령으로 구성된 sed script만 다룬다면 저렇게 오해해도 문제가 생기지는 않는다. sed script의 첫 번째 명령이 실행되는 단계에서는 대상 문자열이 개행 문자를 포함하지 않기 때문이다. 하지만 sed script가 두 개 이상의 명령으로 구성될 경우, 첫 번째 명령에 의해 대상 문자열이 개행 문자를 포함하게 될 수 있으므로 .이 개행 문자에도 매치되는지 그렇지 않은지를 따지는 것이 중요해진다. 결론만 말하자면 .은 분명히 개행 문자에도 매치된다.

_w_newline 쪽을 먼저 디버깅해보자

만일 BRE/ERE가 부정적 룩어헤드를 지원한다면, 앞서 실행한 s 명령의 정규표현식 .\{40\}을 ‘맨 끝에 있지 않은 40개 문자’에 매치되도록 수정하기만 하면 된다. 하지만 BRE/ERE는 부정적 룩어헤드를 지원하지 않으므로 이 방법을 쓸 수는 없다.

그렇다고 해서 방법이 없는 것은 아니다. 더 좋은 방법이 있는지는 모르겠지만, 40개 문자 끝에 개행문자를 추가하는 명령 1을 수행한 다음, 맨 끝의 (중복된) 개행 문자를 제거하는 명령 2를 추가로 수행하도록 하면 된다. 전체 sed 명령은 아래와 같다.

sed 's/.\{40\}/&\
/g; s/\n$//' _w_newline

앞에서 이야기한 대로 s 명령의 형식 s/REGEXP/REPLACEMENT/[FLAGS]에서 REGEXP 자리에는 개행 문자를 \n으로 나타내야 한다. 한편 정규식 \n$은 2회 이상 매치될 수 없기 때문에 두 번째 s 명령에 g 플래그를 줄 필요는 없다.

sed 명령의 실행 결과는 다음과 같다.

$ sed 's/.\{40\}/&\
> /g; s/\n$//' 200_w_newline
09908c0b5a22ec99b35467d7aaac50e8b5e0322d
d35404c1602fa63aaf44c1a3bf40942933b41406
1ed87da7f968eba4fdced8ff4894709512ed2f24
70387275aafb21cdd1817557c163927342e50716
1d8305c72b4ba8075ca830e51fd2dda8c3316740
$
$ sed 's/.\{40\}/&\
> /g; s/\n$//' 220_w_newline
f62b844fbde0541e6bf29dcdd313d7396a7161ae
0b9a2afe5fd76557fdaab060d803120baef2ea36
9abb2579e6de10c81803fe83bdf7c84d3d25901d
dec3ea988d5a42ce2443d308080368b3a658eba9
56bf4fcc58d1d8e68564624eca322da797c753d2
0b7beb71e5fc01f0d522
$

_wo_newline 쪽도 디버깅해보자

비슷한 방법으로 _wo_newline 쪽도 디버깅할 수 있다.
앞서 _w_newline 쪽은 s/\n$//를 추가로 실행하여 끝에 붙은 개행문자를 제거했다. 하지만 이번에는 오히려, 개행문자로 끝나지 않는 경우 끝에 개행문자를 추가해야 한다.

수정된 전체 sed 명령은 아래와 같다.

sed 's/.\{40\}/&\
/g; s/[^\n]$/&\
/' _wo_newline

[^\n]개행 문자를 제외한 모든 문자를 뜻한다. (참고로 이같은 정규식의 문법을 negated character class라고 한다.)
그러므로 정규식 [^\n]$은 맨 끝의 문자가 개행 문자가 아닌 경우 그 문자에 매치되며 위 두 번째 s 명령은 그 매치된 문자 끝에 개행 문자를 추가한다. 역시 앞에서 언급했듯이, s 명령의 형식 s/REGEXP/REPLACEMENT/[FLAGS]에서 REPLACEMENT 자리에는 개행 문자를 \ 뒤에 글자 그대로의 개행문자(즉, 실제 줄바꿈)로 나타내야 한다.

실행 결과는 아래와 같다.

$ sed 's/.\{40\}/&\
> /g; s/[^\n]$/&\
> /' 200_wo_newline
09908c0b5a22ec99b35467d7aaac50e8b5e0322d
d35404c1602fa63aaf44c1a3bf40942933b41406
1ed87da7f968eba4fdced8ff4894709512ed2f24
70387275aafb21cdd1817557c163927342e50716
1d8305c72b4ba8075ca830e51fd2dda8c3316740
$
$ sed 's/.\{40\}/&\
> /g; s/[^\n]$/&\
> /' 220_wo_newline
f62b844fbde0541e6bf29dcdd313d7396a7161ae
0b9a2afe5fd76557fdaab060d803120baef2ea36
9abb2579e6de10c81803fe83bdf7c84d3d25901d
dec3ea988d5a42ce2443d308080368b3a658eba9
56bf4fcc58d1d8e68564624eca322da797c753d2
0b7beb71e5fc01f0d522
$

_wo_newline을 처리하는 다른 방법

# 첫 번째 방법
sed 's/.\{40\}/&\
/g; s/[^\n]$/&\
/' _wo_newline

# 두 번째 방법
sed 's/.\{40\}/&\
/g; s/\n$//; s/$/\
/' _wo_newline

# 세 번째 방법
sed 's/.\{40\}/&\
/g; s/\n\{0,1\}$/\
/' _wo_newline

# 네 번째 방법
sed 's/.\{0,40\}/&\
/g' _wo_newline

첫 번째 방법은 방금 전에 실행해 본 방법이다.

두 번째 방법은 그 전에 실행해 본 _w_newline 파일을 처리하는 방법을 응용한 것이다. 두 번째 s 명령으로 맨 끝의 개행 문자를 제거한 다음, 세 번째 s 명령으로 맨 끝에 개행 문자를 추가한다.

세 번째 방법은 두 번째 방법을 간소화한 것이다. 정규식 \n\{0,1\}$는 맨 끝에 개행문자가 있으면 그 개행문자에 매치되며 그렇지 않으면 맨 끝의 길이가 0인 문자열에 매치되므로 매치된 부분이 개행 문자로 치환되면 두 번째 방법과 같은 결과를 얻는다.

네 번째 방법은 .을 고정된 갯수 40개가 아니라 40개 이하에 매치시켜 그 뒤에 개행 문자를 삽입하도록 수정한 방법이다. 이렇게 하면 마지막에 남는 글자수가 40개 미만이더라도 그 뒤에 개행문자가 삽입되므로 원하는 결과를 얻게 된다. 이 방법은 s 명령을 한 번만 사용하므로 넷 중에서 가장 간결한 방법이다.

마무리하며

사실 sed를 잘 모르는 상태에서 이 글을 쓰기 시작했고, 처음에 문제를 해결하는 과정에서 상당한 시행착오를 겪었다. 어쨌든 문제는 해결되어 그 시행 착오의 과정을 며칠 동안 공을 들여 장문의 글로 풀어놨는데, 이후 sed가 작동하는 방식이 내가 어렴풋이 생각했던 것과는 완전히 다르다는 것을 알게 되어 상당한 충격을 받았다. 이에 대한 것은 아래 링크의 글에서 이야기한 바 대로이다.

따라서 글을 대폭 수정하는 것이 불가피해졌다. 그 수정 작업을 열흘 넘게 미뤄 오다가, 지난번에 쓴 장광설을 다 지우고 그 동안 새로 알게 된 내용들을 반영하여 거의 글을 새로 쓰다시피 했다. (2023.05.27-28) 금방 될 줄 알았던 수정 작업은 예상 외로 오래 걸렸으며 쉽지 않았다. 지금 매우 힘들고, 이 글은 여기서 마무리를 지어야겠다.

Bruce Barnett의 sed 튜토리얼은 절반 정도 읽고 나서 이후 열흘동안 쳐다도 안 봤는데, 생각난 김에 이것부터 마저 읽어야겠다.