Prig: like AWK, but uses Go for "scripting"

Ben Hoyt

Prig: AWK와 비슷하지만 Go로 "스크립팅"한다

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

요약: 이 글에서는 Go를 스크립트 언어로 사용하는 AWK 유사 도구인 Prig를 소개한다. Prig와 AWK를 비교하고, Prig의 동작 방식을 살펴본 뒤, Prig의 SortSortMap 내장 함수(Go 1.18 이상에서는 Go의 새로운 제네릭을 사용한다)에 대해 간략히 알아본다.

최근 Hacker News 댓글에서 Charles Blake가 만든 rp라는 작은 텍스트 처리 도구에 대해 알게 됐다. 이 도구는 일종의 AWK 같은 도구이지만 스크립트 언어로 Nim을 사용한다. Nim 컴파일러가 빠르고 Nim 언어가 간결하기 때문에 가능한 일로, 일회성 스크립트에 활용할 수 있다.

Go는 이 두 가지 중 하나를 갖추고 있다. 바로 빠른 빌드 시간이다. 그리 간결한 언어는 아니어서 한 줄짜리 스크립트에는 그다지 이상적이지 않다. 그렇다고 형편없는 수준도 아니다. Prig 스크립트는 AWK 버전보다 문자 수가 약 두 배 정도다.

Charles는 Nim이나 Go처럼 컴파일 속도가 빠른 언어가 이런 종류의 도구에 이상적이라고 제안했다. 구현 코드는 거의 자명할 정도로 간단하다. Prig는 약 200줄의 직관적인 Go 코드로 이루어져 있으며, 사용자가 커맨드라인에 입력한 “스크립트”를 Go 소스 코드 템플릿에 삽입한 뒤 컴파일하고, 생성된 실행 파일을 실행한다.

내 Linux 머신에서 go build는 표준 라이브러리만 사용하는 프로그램을 약 200밀리초 만에 빌드할 수 있어 시작 시간이 매우 합리적이다 — Enter 키에서 손을 떼기도 전에 Go가 프로그램을 컴파일하고 실행할 수 있을 정도다. 비교하자면 내 시스템에서 Nim은 rp의 시작 시간을 약 1.4초(tcc 백엔드를 사용하면 0.8초) 정도로 만든다.

그래서 나는 Go로 rp에 해당하는 도구를 만들어 보기로 했고, 그 결과 Prig가 탄생했다. 이름은 물론 Processing Records In Go의 약자다. Prig는 AWK와 비슷하지만 코가 높은 — 동적 타이핑을 깔보는 — 버전이라고 할 수 있다.

Prig와 AWK 비교

먼저 AWK를 써 본 적이 없다면 한 문단으로 설명해 보겠다. AWK는 입력을 한 줄씩 처리하는 언어 인터프리터다. 먼저 선택적인 BEGIN 블록을 실행한다. 그 다음 입력의 각 줄에 대해 pattern { action } 블록을 실행한다. 해당 줄이 패턴과 일치하면 AWK는 액션을 실행한다. 패턴을 지정하지 않으면 모든 줄이 일치하고, 액션을 지정하지 않으면 기본 동작은 해당 줄을 출력하는 것이다. 입력 처리가 끝나면 선택적인 END 블록을 실행한다. 곧 몇 가지 예제를 살펴볼 것이다.

그렇다면 Prig는 어떻게 생겼고, AWK와 어떻게 다른가? 몇 가지 예제 스크립트를 살펴보자. HTTP 요청 라인이 담긴 로그 파일이 있다고 해보자. 예를 들면 다음과 같다.

$ cat logs.txt
GET /robots.txt HTTP/1.1
HEAD /README.md HTTP/1.1
GET /wp-admin/ HTTP/1.0

두 번째 필드(상대 URL)를 추출해 각 요청마다 사이트의 전체 URL을 출력하고 싶다고 하자. Prig로는 이렇게 할 수 있다.

$ prig 'Println("https://example.com" + S(2))' <logs.txt
https://example.com/robots.txt
https://example.com/README.md
https://example.com/wp-admin/

Println 함수는 Go의 fmt.Println과 같지만 효율성을 위해 버퍼링된 writer를 사용한다. AWK의 print 문과 동일하다. S(i) 함수는 i번째 필드를 문자열로 반환하므로, S(2)는 AWK의 $2처럼 두 번째 필드를 반환한다. 나머지 의미는 모두 일반적인 Go의 의미 그대로다.

AWK에서는 같은 스크립트가 이렇게 생겼다.

$ awk '{ print "https://example.com" $2 }' <logs.txt
https://example.com/robots.txt
...

고작 3글자 짧을 뿐이다 — 지금까지는 나쁘지 않다.

여기부터 Go에게는 상황이 조금 불리해지기 시작한다. 아래는 마지막 필드의 평균값을 출력하는 스크립트로, 필드를 합산한 뒤 마지막에 레코드 수로 나누는 방식이다. Prig 버전과 AWK 버전을 함께 보여준다.

$ cat average.txt 
a b 400
c d 200
e f 200
g h 200

$ prig -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))' \
  <average.txt
250

$ awk '{ s += $NF } END { print s / NR }' <average.txt
250

스크립트 길이는 Prig가 60자, AWK가 35자로 거의 두 배 차이다. Go(그리고 많은 정적 타입 언어들)는 여기서 불리하다. 먼저 합계를 저장할 변수를 0으로 초기화해야 하는데, AWK에서는 이 과정이 암시적으로 처리된다.

그 다음 AWK의 깔끔한 $NF와 비교하면 F(NF())에는 괄호가 더 들어간다. 나는 초기에 Prig의 모든 내장 요소를 함수로 만들기로 설계상 결정했다 — 처음에는 NFNR을 변수로 두었지만, 모두 함수로 만들면 필요할 때만 필드를 지연(lazy) 분할할 수 있다(간단한 스크립트 중에는 필드 분할이 필요 없는 경우도 있다).

여기에 float64() 변환까지 더해지고, NR()Println()의 괄호까지 겹치면 Prig는 경우에 따라 약간 Lisp처럼 보이게 된다. AWK의 print s / NR가 확실히 눈에 훨씬 편하다!

세 번째 예제는 입력 라인에 GET 또는 HEAD 문자열이 포함된 경우 각 라인의 세 번째 필드에 1000을 곱한 값(즉, 밀리초 단위)을 출력한다. 해당 Prig 스크립트와 이에 대응하는 AWK 스크립트는 다음과 같다.

$ cat millis.txt 
1 GET 3.14159
2 HEAD 4.0
3 GET 1.0

$ prig 'if Match(`GET|HEAD`, S(0)) { Printf("%.0fms\n", F(3)*1000) }' \
  <millis.txt
3142ms
4000ms
1000ms

$ awk '/GET|HEAD/ { printf "%.0fms\n", $3*1000 }' <millis.txt
3142ms
4000ms
1000ms

Prig는 62자, AWK는 43자 — 나쁘지 않다. 여기서 가장 큰 차이는 AWK의 /regex/ 단축 표기다. Prig에도 이를 위한 특수 케이스를 추가할까 고민했지만, 단축 표기보다는 단순하고 일관된 Go를 택하기로 했다 — 그래서 Prig에서는 ifMatch를 명시적으로 써야 한다.

이제 좀 더 긴 예제를 보자. 입력에서 고유 단어들의 빈도를 세어 가장 빈도가 높은 단어부터 단어와 개수를 출력하는 스크립트다.

$ cat words.txt 
The foo barfs
foo the the the

$ prig -b 'freqs := map[string]int{}' \
       'for i := 1; i <= NF(); i++ { freqs[strings.ToLower(S(i))]++ }' \
       -e 'for _, f := range SortMap(freqs, ByValue, Reverse) { ' \
       -e 'Println(f.K, f.V) }' \
       <words.txt 
the 4
foo 2
barfs 1

$ awk '{ for (i = 1; i <= NF; i++) freqs[tolower($i)]++ }
      END { for (k in freqs) print k, freqs[k] | "sort -nr -k2,1" }' \
      <words.txt

특히 Prig 버전은 상당히 길다. 먼저 단어를 키로 하는 빈도 맵을 초기화한다(역시 AWK에서는 암시적이다). 레코드별 코드는 매우 유사하지만, Go에서는 strings 패키지 접두사 때문에 조금 더 장황하다.

정렬 방식은 두 경우에 꽤 다르다. Prig에서는 두 가지 정렬 함수를 정의했다. Sort는 int, float, 문자열 슬라이스를 받아 새로 정렬된 슬라이스를 반환하고, SortMap은 맵의 키-값 쌍을 정렬된 슬라이스로 반환한다(선택적으로 값 기준 정렬, 역순 정렬이 가능하다).

POSIX AWK에는 내장 정렬 기능이 없으며(Gawk에만 있다), 그래서 AWK의 파이프 리다이렉션 문법을 사용해 sort 유틸리티로 넘긴다. Prig에서도 셸 파이프라인을 이용해 같은 기법을 쓸 수 있었겠지만, 여기서는 SortMap 함수의 사용법을 보여주기 위한 것이다.

대부분의 예제에서 AWK가 확실히 더 명확하고 간결하다 — Aho, Weinberger, Kernighan이 C(또는 유사 언어)를 기반 언어로 쓰지 않고 AWK를 위해 새로운 언어를 설계한 데에는 이유가 있었다.

반면에 Go에는 익숙하지만 AWK를 모른다면 Prig가 유용할 수 있다. 또한 Go는 최적화된 머신 코드로 컴파일되는 반면 AWK는 인터프리트되기 때문에 Prig는 훨씬 빠르기도 하다.

간단한 성능 수치를 보자. 위에 나온 “단어 빈도 세기” 예제에서 Prig는 AWK(Gawk 기준)보다 약 세 배 빠르다. Prig는 43MB 파일을 1.1초 만에 세고, Gawk는 3.1초가 걸린다. 물론 이 시점에서는 사실상 Go와 Gawk를 비교하는 셈이다(이 성능 비교에서 훨씬 더 자세한 내용을 볼 수 있다).

숫자를 더하는 같은 CPU 바운드 작업에서는 Go가 당연히 훨씬 빠르다. 이 예제에서는 약 20배 빠르다(그리고 그 274밀리초 중 200밀리초는 Go가 컴파일에 쓴 시간이라는 점을 기억하자).

$ time gawk 'BEGIN { for (i=0; i<100000000; i++) s+=i; print s }'
4999999950000000

real    0m5.698s
...
$ time ./prig -b 's:=0; for i:=0; i<100000000; i++ { s+=i }; Println(s)'
4999999950000000

real    0m0.274s
...

생성되는 Go 프로그램

prig.go 코드 자체는 아주 단순하다. 약 200줄의 Go 코드이며, 그중 약 3분의 1은 커맨드라인 인수를 파싱하는 부분이다. 나머지는 사용자의 스크립트를 Go 소스 템플릿에 넣고, go build를 실행해 컴파일한 뒤 그 결과를 실행할 뿐이다.

생성되는 Go 프로그램의 기본 구조는 예상한 그대로다. 약간의 설정 코드, “begin” 코드, bufio.Scanner 루프 안에 들어간 “per-record” 코드, 그리고 “end” 코드가 이어진다. Prig 내장 함수들도 함께 포함된다.

생성된 Go 소스 코드는 prig -s로 확인할 수 있다. 아래는 위에서 본 “마지막 필드의 평균값” 예제다. 완전히 그대로는 아니고, 간결함을 위해 사용되지 않은 부분은 생략했다.

$ prig -s -b 's := 0.0' 's += F(NF())' -e 'Println(s / float64(NR()))'
// ... package and import ...
var (
    _output *bufio.Writer
    _record string
    _nr     int
    _fields []string
)

func main() {
    _output = bufio.NewWriter(os.Stdout)
    defer _output.Flush()

    // begin
    s := 0.0

    _scanner := bufio.NewScanner(os.Stdin)
    for _scanner.Scan() {
        _record = _scanner.Text()
        _nr++
        _fields = nil

        // per-record
        s += F(NF())
    }
    if _scanner.Err() != nil {
        _errorf("error reading stdin: %v", _scanner.Err())
    }

    // end
    Println(s / float64(NR()))
}

func Println(args ...interface{}) {
    _, err := fmt.Fprintln(_output, args...)
    if err != nil {
        _errorf("error writing output: %v", err)
    }
}

func NR() int {
    return _nr
}

func S(i int) string {
    if i == 0 {
        return _record
    }
    _ensureFields()
    if i < 1 || i > len(_fields) {
        return ""
    }
    return _fields[i-1]
}

func F(i int) float64 {
    s := S(i)
    f, _ := strconv.ParseFloat(s, 64)
    return f
}

func _ensureFields() {
    if _fields != nil {
        return
    }
    _fields = strings.Fields(_record)
}

func NF() int {
    _ensureFields()
    return len(_fields)
}
// ... other Prig builtin functions ...

Prig 내부 이름에는 사용자가 정의한 변수와의 이름 충돌을 피하기 위해 밑줄 접두사를 붙였다는 점에 주목하자. 완벽한 방법은 아니지만 이 사용 사례에는 충분히 좋다.

메인 루프는 기본적으로 Go로 직접 코드를 작성할 때와 같은 방식이다(다만 보통은 전역 변수 대신 지역 변수를 쓰겠지만). 하지만 일반적인 Go라면 F(NF())를 인라인으로 쓰고 경계 검사를 함께 작성할 텐데, 메인 루프 안에서는 대략 다음과 같이 작성할 것이다.

if len(fields) > 0 {
    last := fields[len(fields)-1]
    f, err := strconv.ParseFloat(last, 64)
    if err == nil {
        s += f
    }
}

이런 맥락에서는 Prig의 F()가 경계 검사를 대신해 주는 것이 편리하다. s += F(NF())는 저 7줄짜리 장황한 코드 덩어리보다 훨씬 간단하다. Go는 장황하지만, 적절히 배치된 몇몇 헬퍼 함수와 함께라면 매우 간결해질 수 있다!

테스트의 재미

Prig의 테스트(prig_test.go에 있음)는 prig 바이너리를 직접 실행한다는 점에서 다소 비관습적이다. 일부 개발자는 이런 방식을 꺼릴 수도 있지만, 덕분에 Prig를 조금 더 단순하게 유지할 수 있다. 주요 테스트는 Go 테스팅의 정석인 “테이블 주도 테스트”이며, 이에 대해서는 다른 곳에서 읽어볼 수 있다.

go build 사이클 때문에 각 테스트는 상대적으로 느리며(약 200밀리초 정도), 그래도 내 시스템에서는 전체 테스트 스위트가 7~8초 만에 실행된다. Windows에서는 새 프로세스 시작 비용이 훨씬 크기 때문에 훨씬 더 느리다.

하지만 내가 했던 깔끔한 시도 중 하나는 prig --help에 표시되는 예제들을 테스트한 것이다. Prig 사용법 메시지를 작성하면서 예제들에 자꾸 사소한 오타가 생겼고, 이를 수동으로 테스트하기 위해 계속 터미널에 복사해 붙여 넣어야 했다.

어느 순간, go test를 이용해 이런 예제들을 자동으로 테스트하면 어떨까 하는 생각이 들었다. 그래서 커맨드라인 예제들을 별도의 문자열로 추출해 TestExamples에서 테스트하도록 했다. 임시로 만든 작은 파서를 이용해 각 예제 커맨드라인을 인수 목록으로 변환한 뒤, 그 결과로 prig를 호출한다.

이는 Go의 훌륭한 testable examples와 유사하지만, Go 코드 예제가 아닌 커맨드라인 예제를 대상으로 한 것이다.

제네릭 실험

Prig를 설계하면서 가장 어려웠던 부분 중 하나는 정렬 헬퍼였고, 지금도 제대로 설계했는지 전혀 확신이 서지 않는다. API 설계는 과학보다 예술에 가깝게 느껴지는 영역이다.

어쨌든 나는 Prig와 함께 사용할 만한 데이터 타입에 유용한 두 함수를 만들게 됐다. 꽤 간결한 사용법 메시지는 다음과 같이 말한다.

Sort[T int|float64|string](s []T) []T
  // return new sorted slice; also Sort(s, Reverse) to sort descending
SortMap[T int|float64|string](m map[string]T) []KV[T]
  // return sorted slice of key-value pairs
  // also Sort(s[, Reverse][, ByValue]) to sort descending or by value

Go 1.18(곧 출시될 예정)에서는 이 함수들이 새로운 제네릭 기능을 활용해 타입 검사를 받고 구체적인 슬라이스 타입을 반환한다. 선택적 매개변수 때문에 실제 Go 시그니처(와 KV 타입)는 다음과 같이 정의된다.

type _sortOption int

const (
    Reverse _sortOption = iota
    ByValue
)

func Sort[T int|float64|string](s []T, options ..._sortOption) []T {
    // ... implementation ...
}

type KV[T int|float64|string] struct {
    K string
    V T
}

func SortMap[T int|float64|string](m map[string]T,
        options ..._sortOption) []KV[T] {
    // ... implementation ...
}

Sort는 꽤 단순하다. 슬라이스를 받아 새로 정렬된 슬라이스를 반환한다. 기본적으로 오름차순으로 정렬되며, Reverse 옵션을 전달하면 내림차순으로 정렬된다. int, float64, string보다 더 넓은 타입 집합을 쓸 수도 있었지만, Prig(그리고 아래에서 살펴볼 비제네릭 버전)를 위해 단순하게 유지했다.

SortMap은 API 설계가 조금 더 까다로웠다. Go 맵은 직접 정렬할 수 없으므로 키-값 쌍의 슬라이스로 변환해야 하는데, 그게 바로 KV 타입이다. 기본적으로는 키 기준 정렬이며, ByValue 옵션을 전달하면 값 기준으로 정렬할 수 있다.

이 모든 것이 잘 동작했고, Go 1.18 제네릭에 대한 나의 매우 제한적인 경험은 성공적이었다.

하지만 아직 제네릭을 지원하지 않는 1.18 이전 버전의 Go를 사용하는 우리 대부분은 어떻게 해야 할까? 나는 같은 API가 제네릭 없이도 동작하도록 만들었다… 어느 정도는. 비제네릭 버전은 interface{}를 사용하므로 물론 타입 안전하지 않다. 그리고 타입 변환 없이 동작할 수 있는 것도 대개 결과를 출력하기만 하기 때문인데, Print 계열 함수는 이미 어떤 타입의 인수든 받을 수 있다(interface{}를 통해).

그래서 단어 수 세기 예제 코드는 Go 1.18(제네릭 사용)과 Go 1.17(제네릭 미사용) 모두에서 똑같이 잘 동작한다.

for _, f := range SortMap(freqs, ByValue, Reverse) {
    Println(f.K, f.V)
}

Prig는 go version을 실행해 설치된 Go 버전을 감지하고, 1.17 이하라면 비제네릭 버전을 사용한다. 비제네릭 버전의 SortSortMap은 다음과 같이 정의된다.

func Sort(s interface{}, options ..._sortOption) []interface{} {
    // ... implementation ...
}

type KV struct {
    K string
    V interface{}
}

func SortMap(m interface{}, options ..._sortOption) []KV {
    // ... implementation ...
}

미친 짓일까? 아마도 그렇다. 대부분의 라이브러리는 이런 식의 바꿔치기로는 절대 넘어갈 수 없을 것이다. 많은 작업에서 API가 호환되지 않기 때문이다. 하지만 Prig에서의 실험으로는 꽤 잘 동작하는 것 같다.

결론: 해 볼 만했을까?

나는 뼛속까지 거리낌 없는 너드라서, 그렇다, Prig를 만드는 과정이 즐거웠다(대부분 크라이스트처치에서 프랑크푸르트로 가는 비행기 안에서 만들었다). 코드가 얼마나 단순한지가 마음에 든다. 약 200줄의 Go 코드, 300줄의 템플릿 코드… 그리고 400줄의 테스트다. Go와 그 표준 라이브러리가 모든 어려운 일을 해내고 있다!

Prig를 실제로 사용할까? 아마도 큰 파일을 처리하면서 AWK보다 조금 더 성능이 필요할 때 쓸 수도 있을 것이다. 아주 작은 Go 코드 조각을 테스트할 때도 사용할 수 있다 — 예를 들어 “Printf 너비는 어떻게 동작하지? 아 맞다, prig로 한번 해보자” 같은 경우다.

$ prig -b 'Printf("%3.5s\n", "hi")'
 hi
$ prig -b 'Printf("%3.5s\n", "hello world")'
hello

여러분이 Prig를 써야 할까? 말리지는 않겠다! 하지만 솔직히 말하면 어디서나 볼 수 있고(훨씬 더 간결한) AWK 언어를 배우는 것이 더 나을 것이다. AWK는 45년 된 훌륭한 도구로, 2022년 현재에도 텍스트와 데이터 처리에 여전히 널리 쓰이고 있다. A, W, K가 쓴 원서 The AWK Programming Language는 정말 훌륭하다.

예를 들어 awk가 설치되지 않은 가벼운 컨테이너처럼, 데이터 처리를 위한 실행 파일이 필요한 경우에도 Prig를 사용할 수 있다. 이런 경우 prig -s로 소스를 출력하고, go build로 빌드한 뒤 실행 파일을 대상에 복사하면 된다 — 다른 의존성은 필요하지 않다.

AWK를 Go 프로그램에 통합하고 싶거나 AWK 인터프리터가 어떻게 동작하는지 알고 싶다면 내 GoAWK 프로젝트를 확인해 보라.

Prig에 대한 여러분의 피드백을 듣고 싶다. 개선 아이디어가 있거나 다른 언어로 rp나 Prig 변형을 만든다면 꼭 알려 달라!

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글