awk는 텍스트를 한 줄씩 읽으면서 칸을 나누고, 조건에 맞는 줄만 골라 계산하거나 다시 출력하는 프로그램이다. 이름은 Alfred Aho, Peter Weinberger, Brian Kernighan의 성을 따서 붙였다. 리눅스에서 `awk`를 실행하면 대부분 GNU awk, 즉 gawk가 돈다. `gawk`로 직접 실행해도 같은 프로그램이다. `awk --version`으로 확인할 수 있다.

grep은 줄을 고르고, cut은 칸을 자르고, sed는 줄을 바꾼다. awk는 이 일을 한곳에서 한다. 로그의 상태 코드를 세거나, 숫자 열을 더하거나, 설정 파일에서 특정 칸만 뽑을 때 파이프를 여러 개 잇는 대신 awk 하나로 끝내는 경우가 많다.

프로그램의 형태

awk 프로그램은 패턴과 동작의 쌍이다. 입력의 각 줄에 패턴을 검사하고, 맞으면 중괄호 안의 동작을 실행한다.

awk
패턴 { 동작 }

패턴을 생략하면 모든 줄에 동작한다. 동작을 생략하면 패턴이 맞은 줄 전체를 출력한다.

Bash
awk '{ print }' /etc/passwd
awk '/root/' /etc/passwd

첫 명령은 모든 줄을 그대로 출력한다. 둘째 명령은 `root`가 들어 있는 줄만 출력한다. 동작이 없을 때의 기본 동작은 그 줄 전체인 `$0`을 출력하는 것이다.

프로그램은 셸이 `$1` 같은 문자를 가져가지 않도록 작은따옴표로 감싼다. 큰따옴표로 감싸면 `$1`이 셸 변수가 되어 awk까지 도달하지 못한다.

Bash
awk -F: '{ print $1 }' /etc/passwd
awk -f prog.awk file1 file2

`-F`는 필드 구분자다. `-f`는 프로그램이 들어 있는 파일을 읽는다. 데이터 파일이 여러 개면 적힌 순서대로 읽는다.

줄과 필드

awk는 한 줄을 레코드로 본다. 기본 레코드 구분자 RS는 줄바꿈이다. 줄 안은 필드로 나뉜다. 기본 필드 구분자 FS는 공백이며, 스페이스와 탭이 여러 개 이어져도 하나의 구분자로 취급한다. 줄 앞뒤의 공백은 필드에 넣지 않는다.

- `$0`은 줄 전체다. - `$1`, `$2`는 첫 번째, 두 번째 필드다. - `NF`는 그 줄의 필드 개수다. `$NF`는 마지막 필드다. - `NR`은 지금까지 읽은 줄 번호다. 파일을 여러 개 읽으면 이어서 증가한다. - `FNR`은 현재 파일 안에서의 줄 번호다. 파일이 바뀌면 1부터 다시 센다. - `FILENAME`은 지금 읽고 있는 파일 이름이다.

Bash
awk -F: '{ print NR, $1, $NF }' /etc/passwd

`/etc/passwd`의 칸은 콜론으로 나뉜다. 공백이 아니므로 `-F:`가 없으면 한 줄이 필드 하나다. 위 명령은 줄 번호, 사용자 이름, 마지막 칸인 로그인 셸을 출력한다.

필드를 바꾸면 `$0`도 다시 조립된다. 이때 칸 사이에는 출력 필드 구분자 OFS가 들어간다. 기본값은 스페이스 하나다.

awk
BEGIN { OFS = "\t" }
{ $1 = $1; print }

`$1 = $1`은 값을 바꾸지 않지만 줄을 다시 조립하게 만든다. 연속된 공백이 탭 하나로 바뀐다.

FS에 정규식을 넣으면 기본 공백 규칙과 달라진다. `FS=","`이거나 `FS="[ ]"`처럼 공백 한 칸이 아닌 구분자는 줄 앞의 구분자도 빈 필드로 남긴다. 기본 FS인 스페이스 하나만 앞쪽 공백을 무시한다.

BEGIN과 END

`BEGIN`은 첫 줄을 읽기 전에 한 번 실행된다. `END`는 입력이 끝난 뒤에 한 번 실행된다. 파일을 주지 않아도 BEGIN은 실행된다. 헤더를 붙이거나 합계를 마지막에 찍을 때 쓴다.

Bash
awk 'BEGIN { print "name count" } { n++ } END { print "lines", n + 0 }' /etc/passwd

한 줄도 읽지 못하면 `n`은 빈 값이다. `+ 0`을 붙이면 숫자 0으로 출력된다. 빈 파일이어도 END는 실행되고, 그때 NR은 0이다.

Bash
awk '{ s += $1 } END { print s }' numbers.txt

각 줄의 첫 칸을 더한다. 칸이 숫자가 아니면 더할 때 0으로 본다. `10kg`처럼 숫자로 시작하면 앞의 10만 숫자로 읽는다. 천 단위 쉼표나 원 표시가 있으면 더하기 전에 빼야 한다.

패턴

패턴은 정규식, 비교, 논리식, 범위가 될 수 있다.

Bash
awk '$3 > 100' data.txt
awk -F: '$1 == "root"' /etc/passwd
awk '$1 ~ /^r/' names.txt
awk 'NR == 1' file.txt
awk 'NR > 1' file.txt
awk '/ERROR/,/END/' app.log

`~`는 필드가 정규식에 맞는지 본다. `!~`는 그 반대다. 슬래시만 쓴 `/ERROR/`는 줄 전체 `$0`을 검사한다. `pattern1, pattern2`는 첫 패턴이 맞은 줄부터 둘째 패턴이 맞은 줄까지 범위로 포함한다. 둘째 패턴이 끝까지 나오지 않으면 파일 끝까질 포함한다.

비교는 양쪽이 모두 숫자로 보이면 숫자로, 아니면 문자열로 한다. 문자열 비교에서 `"10"`은 `"2"`보다 작다. 첫 글자 `1`이 `2`보다 앞이기 때문이다. 숫자로 고정하려면 `$1 + 0`을 비교한다.

`1`만 패턴으로 쓰면 항상 참이다. 모든 줄을 출력한다. `$1`과는 다르다. `$1`은 첫 필드이고, 그 필드가 비어 있지 않을 때만 참으로 본다.

출력

`print`는 인자를 OFS로 이어 붙이고 마지막에 ORS를 붙인다. ORS의 기본값은 줄바꿈이다. 인자가 없으면 `$0`을 출력한다.

Bash
awk -F: 'BEGIN { OFS = "," } { print $1, $3, $7 }' /etc/passwd

쉼표가 중요하다. `print $1, $2`는 두 칸 사이에 OFS를 넣는다. `print $1 $2`는 연산자 없이 붙여 쓴 것이라 두 필드를 공백 없이 이어 붙인다.

`printf`는 C의 printf와 같은 서식을 쓴다. 줄바꿈을 자동으로 붙이지 않으므로 `\n`을 넣는다.

Bash
awk '{ printf "%4d %-12s %8.2f\n", NR, $1, $2 }' scores.txt

`%d`는 정수, `%f`는 실수, `%s`는 문자열이다. `%8.2f`는 폭 8칸에 소수 둘째 자리까지다. `%-12s`는 왼쪽 정렬이다.

출력 줄을 바꿀 때는 ORS를 바꾼다. 레코드를 읽을 때의 구분자를 바꾸려면 RS를 바꾼다. RS를 빈 문자열로 두면 빈 줄로 구분된 문단이 레코드가 된다. 이 규칙은 gawk와 POSIX awk에 있다.

변수와 연산

변수는 미리 선언하지 않는다. 숫자 계산에 처음 쓰이면 0이고, 문자열로 처음 쓰이면 빈 문자열이다.

연산자는 `+ - * / % ^`, 비교, `&& || !`, 삼항 연산자다. `^`는 거듭제곱이다.

셸 변수를 프로그램 안으로 넣으려면 `-v`를 쓴다.

Bash
awk -v min=80 '($5 + 0) >= min { print }' usage.txt

작은따옴표 안에 `$min`을 적어도 셸은 그것을 확장하지 않는다. `-v`로 넘기는 값이 awk 변수 `min`이 된다.

배열

awk의 배열은 연관 배열이다. 인덱스가 숫자가 아니어도 된다. 없는 원소를 숫자로 읽으면 0이다.

Bash
awk '{ count[$1]++ } END { for (k in count) print count[k], k }' access.log

`for (k in count)`의 순서는 넣은 순서가 아니다. gawk는 환경과 버전에 따라 해시 순서가 바뀔 수 있다. 사람에게 보여줄 순서가 중요하면 출력을 `sort`에 넘긴다.

Bash
awk '{ count[$1]++ } END { for (k in count) print count[k], k }' access.log | sort -nr

원소를 지울 때는 `delete count[k]`다. gawk에서는 `delete count`로 배열 전체를 지울 수 있다. 이 전체 삭제는 모든 awk에 있는 기능이 아니다.

다차원처럼 보이게 쓰려면 `SUBSEP`으로 인덱스를 잇는다. 기본 구분 문자는 `\034`다.

awk
{ seen[$1, $2] = 1 }

이것은 `seen[$1 SUBSEP $2]`와 같다.

문자열 함수

`length(s)`는 길이이고, 인자가 없으면 `$0`의 길이다. gawk는 로케일이 UTF-8이면 글자 수를 센다. 바이트 수가 필요하면 `LC_ALL=C awk ...`로 실행한다.

`index(s, t)`는 s에서 t가 처음 나오는 위치다. 없으면 0이다. 위치는 1부터 시작한다.

`substr(s, i, n)`은 i번째 글자부터 n글자다. n을 생략하면 끝까지다.

`split(s, a, sep)`는 s를 sep로 나누어 배열 a에 1번부터 넣고, 나눈 개수를 반환한다. sep를 생략하면 FS로 나눈다.

`sub(re, repl)`는 `$0`에서 처음 한 번만 바꾼다. `gsub(re, repl)`는 모두 바꾼다. 세 번째 인자를 주면 그 대상을 바꾼다. 반환값은 바꾼 횟수다.

Bash
awk '{ gsub(/,/, "", $1); s += $1 } END { print s }' money.txt

`sub`와 `gsub`의 치환 문자열에서 `&`는 맞은 문자열 전체다. 리터럴 `&`가 필요하면 `\&`로 쓴다.

`tolower`와 `toupper`는 영문 대소문자를 바꾼다. 한글의 대소문자 변환은 없다.

`match(s, re)`는 맞는 위치를 `RSTART`에, 길이를 `RLENGTH`에 넣는다. 맞지 않으면 `RSTART`는 0이다.

Bash
awk 'match($0, /[0-9]+/) { print substr($0, RSTART, RLENGTH) }' messages.txt

이 명령은 각 줄에서 처음 나오는 숫자 덩어리만 출력한다.

여러 파일

파일마다 첫 줄이 헤더이면 `FNR`로 건너뛴다. `NR > 1`을 쓰면 첫 파일의 헤더만 건너뛰고, 다음 파일의 헤더는 데이터로 읽는다.

Bash
awk 'FNR > 1 { s += $3 } END { print s }' a.csv b.csv

`FILENAME`과 `FNR == 1`을 함께 쓰면 파일이 바뀌는 지점을 알 수 있다.

자주 쓰는 형태

계정 파일에서 로그인 셸별 인원을 센다.

Bash
awk -F: '{ sh[$NF]++ } END { for (s in sh) print sh[s], s }' /etc/passwd | sort -nr

`df -P` 출력에서 사용률이 80% 이상인 파일시스템만 본다. `-P`는 POSIX 출력이라 이름이 길어도 다음 줄로 넘어가지 않는다. 일반 `df`는 이름이 길면 줄이 나뉘어 `$5`가 사용률이 아니게 된다.

Bash
df -P | awk 'NR > 1 { gsub(/%/, "", $5); if ($5 + 0 >= 80) print $5 "%", $6 }'

빈 줄과 `#`으로 시작하는 주석을 뺀다. `NF`가 0이면 비었거나 공백만 있는 줄이다.

Bash
awk 'NF && $1 !~ /^#/' config.txt

단어 빈도를 세어 많은 순으로 본다.

Bash
awk '{ for (i = 1; i <= NF; i++) w[tolower($i)]++ }
     END { for (k in w) print w[k], k }' text.txt | sort -nr

마지막 칸만 뽑는다.

Bash
awk '{ print $NF }' file.txt

줄 수만 센다.

Bash
awk 'END { print NR }' file.txt

`error`가 들어 있는 줄만 센다.

Bash
awk '/error/ { n++ } END { print n + 0 }' app.log

CSV와 고정 폭

쉼표만으로 나뉘고 칸 안에 쉼표가 없는 파일은 `-F,`로 다룬다.

Bash
awk -F, 'NR > 1 { print $1, $4 }' simple.csv

칸을 큰따옴표로 감싸고 그 안에 쉼표나 줄바꿈이 있으면, awk의 FS는 그 따옴표를 필드의 경계로 이해하지 못한다. 그런 파일은 Python의 csv 모듈처럼 따옴표 규칙을 아는 도구가 맞다. awk로 따옴표 파서를 직접 쓰면 이스케이프와 칸 안의 줄바꿈에서 칸이 어긋나기 쉽다.

폭이 고정된 텍스트는 gawk의 `FIELDWIDTHS`를 쓴다. 이 변수는 POSIX awk의 필수 기능이 아니다.

Bash
awk 'BEGIN { FIELDWIDTHS = "4 8 10" } { print $1, $2 }' fixed.txt

각 숫자는 바이트 폭이다. 한글처럼 한 글자가 여러 바이트인 파일에서는 글자 경계와 맞지 않을 수 있다.

gawk와 다른 awk

리눅스의 `/usr/bin/awk`는 보통 gawk로 연결되어 있다. macOS에 기본으로 있는 awk는 예전 BSD awk인 경우가 많고 `FIELDWIDTHS`, `asort`, `gensub`가 없다. 스크립트를 다른 유닉스로 가져가면 `-F`, 필드, `BEGIN`, `END`, `gsub`, `split`, `printf`처럼 공통으로 있는 기능만 쓰는 편이 안전하다.

gawk에서 배열을 정렬하며 순회하려면 `PROCINFO["sorted_in"]`을 쓴다.

Bash
awk '{ count[$1]++ }
     END {
       PROCINFO["sorted_in"] = "@val_num_desc"
       for (k in count) print count[k], k
     }' access.log

`@val_num_desc`는 값을 숫자로 보아 큰 것부터다. 이 변수는 gawk 전용이다.

설명서는 다음에 있다.

다음 줄 읽기와 외부 명령

`getline`은 다음 레코드를 직접 읽는다. 형태에 따라 바뀌는 변수가 다르다.

인자 없는 `getline`은 다음 줄을 `$0`에 넣고 `NF`, `NR`, `FNR`을 갱신한다. `getline var`는 그 줄의 내용을 `var`에만 넣는다. `$0`과 필드는 그대로이고, `NR`과 `FNR`은 증가한다. 성공하면 1, 입력이 끝나면 0, 오류면 -1을 반환한다. 반환값을 검사하지 않으면 파일 끝 이후에도 이전 값이 남은 것처럼 보일 수 있다.

Bash
awk '{ getline nextline; print NR, nextline }' file.txt

다른 명령의 출력을 읽을 수도 있다. 명령 문자열을 바깥 입력으로 조립하면 그 문자열이 셸에 전달된다. 고정된 명령을 읽을 때만 쓰고, 파일에서 읽은 문자열을 명령으로 만들지 않는 것이 안전하다.

awk의 `system`도 인자 검사 없이 셸 명령을 실행한다. 파일을 지우거나 명령을 조합하는 용도로는 쓰지 않는 편이 낫다. 텍스트를 고르고 집계하는 범위 안에서 쓰는 도구다.

결과가 어긋날 때

구분자를 빼먹고 `/etc/passwd`를 읽으면 콜론이 그대로 한 필드에 남는다. 공백만 기본 구분자이기 때문이다.

배열 순서는 보장되지 않는다. 같은 파일이라도 출력 순서가 바뀔 수 있으니, 순서가 중요하면 `sort`를 붙이거나 gawk의 `PROCINFO["sorted_in"]`을 쓴다.

`print`의 쉼표를 빠뜨리면 칸 사이 공백이 사라진다. 반대로 OFS를 바꿨는데도 원문이 그대로 나오면, 필드를 대입해서 `$0`을 다시 만들게 해야 한다.

로케일이 UTF-8일 때 `length`는 글자 수고, `FIELDWIDTHS`는 바이트 폭이다. 둘을 같은 단위로 보면 한글 줄에서 칸이 밀린다.