13장 핵심 패키지

모든 것들을 맨 처음부터 작성하지 않는 대신 현실에서 이뤄지는 대부분의 프로그래밍은 기존 라이브러리와 상호작용하는 능력에 의존한다. 13장에서는 Go에 포함된 가장 널리 사용되는 패키지를 살펴보겠다.

우선 염두에 둘 사항이 하나 있다. 이러한 라이브러리는 상당히 직관적이긴 하지만(또는 이전 장에서 설명했지만) Go에 포함된 다수의 라이브러리를 사용하려면 특화된 도메인 지식(예: 암호화)이 필요하다. 이 같은 기반 기술을 설명하는 것은 이 책의 범위를 벗어난다.

13.1 문자열

Go의 strings 패키지에는 문자열에 이용할 수 있는 함수가 굉장히 많이 포함돼 있다.

package main

import (
    "fmt"
    "strings"
)

func main() {
    fmt.Println(
        // true
        strings.Contains("test", "es"),

        // 2
        strings.Count("test", "t"),

        // true
        strings.HasPrefix("test", "te"),

        // true
        strings.HasSuffix("test", "st"),

        // 1
        strings.Index("test", "e"),

        // "a-b"
        strings.Join([]string{"a","b"}, "-"),

        // == "aaaaa"
        strings.Repeat("a", 5),

        // "bbaa"
        strings.Replace("aaaa", "a", "b", 2),

        // []string{"a","b","c","d","e"}
        strings.Split("a-b-c-d-e", "-"),

        // "test"
        strings.ToLower("TEST"),

        // "TEST"
        strings.ToUpper("test"),
    )
}

때로는 문자열을 바이너리 데이터로 사용해야 할 때가 있다. 문자열을 바이트 슬라이스로 변환하려면(또는 그 반대로) 다음과 같이 하면 된다.

arr := []byte("test")
str := string([]byte{'t','e','s','t'})

13.2 입출력

파일을 살펴보기에 앞서 먼저 Go의 io 패키지를 이해할 필요가 있다. io 패키지는 함수도 일부 들어 있지만 주로 다른 패키지에서 사용되는 인터페이스로 구성돼 있다. 두 가지 주요 인터페이스는 ReaderWriter다. ReaderRead 메서드를 통해 읽기를 지원한다. WriterWrite 메서드를 통해 쓰기를 지원한다. Go에는 ReaderWriter를 인자로 받는 함수가 많다. 예를 들어 io 패키지에는 Reader에서 Writer로 데이터를 복사하는 Copy 함수가 있다.

func Copy(dst Writer, src Reader) (written int64, err error)

[]bytestring을 읽거나 쓰려면 bytes 패키지에 들어 있는 Buffer 구조체를 사용하면 된다.

var buf bytes.Buffer
buf.Write([]byte("test"))

Buffer는 초기화할 필요가 없으며 ReaderWriter를 모두 지원한다. buf.Bytes()를 호출하면 버퍼를 []byte로 변환할 수 있다. 문자열에서 읽기만 하면 된다면 버퍼를 이용하는 것보다 효율적인 strings.NewReader 함수를 이용하면 된다.

13.3 파일과 폴더

Go에서 파일을 열려면 os 패키지에 포함된 Open 함수를 사용하면 된다. 다음은 파일의 내용을 읽어 터미널에 출력하는 예제다.

package main

import (
    "fmt"
    "os"
)

func main() {
    file, err := os.Open("test.txt")
    if err != nil {
        // 오류를 처리
        return
    }
    defer file.Close()

    // 파일의 크기를 구함
    stat, err := file.Stat()
    if err != nil {
        return
    }

    // 파일을 읽음
    bs := make([]byte, stat.Size())
    _, err = file.Read(bs)
    if err != nil {
        return
    }

    str := string(bs)
    fmt.Println(str)
}

여기서는 파일을 연 직후에 defer file.Close()를 사용해 함수 실행이 완료된 후에 곧바로 파일이 닫히게 만들었다. 파일을 읽는 것은 굉장히 자주 하는 작업이므로 파일을 읽는 더 짧은 방법이 있다.

package main

import (
    "fmt"
    "io/ioutil"
)

func main() {
    bs, err := ioutil.ReadFile("test.txt")
    if err != nil {
        return
    }
    str := string(bs)
    fmt.Println(str)
}

다음은 파일을 생성하는 예제다.

package main

import (
    "os"
)

func main() {
    file, err := os.Create("test.txt")
    if err != nil {
        // 오류를 처리
        return
    }
    defer file.Close()

    file.WriteString("test")
}

디렉터리의 내용을 구할 때도 똑같이 os.Open 함수를 사용하지만 파일명 대신 디렉터리 경로를 지정한다. 그런 다음 Readdir 메서드를 호출하면 된다.

package main

import (
    "fmt"
    "os"
)

func main() {
    dir, err := os.Open(".")
    if err != nil {
        return
    }
    defer dir.Close()

    fileInfos, err := dir.Readdir(-1)
    if err != nil {
        return
    }
    for _, fi := range fileInfos {
        fmt.Println(fi.Name())
    }
}

폴더를 재귀적으로 열람하고 싶을 때가 있다(즉, 폴더의 내용과 모든 하위 폴더, 모든 하위 폴더의 하위 폴더, ...를 읽는다). 이러한 작업을 손쉽게 할 수 있게 path/filepath 패키지에는 Walk 함수가 제공된다.

package main

import (
    "fmt"
    "os"
    "path/filepath"
)

func main() {
    filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
        fmt.Println(path)
        return nil
    })
}

Walk에 전달하는 함수는 루트 폴더 안에 들어 있는 모든 파일과 폴더에 대해 호출된다(이 경우 루트 폴더는 .이다).

13.4 오류

Go에는 이미 앞에서 본 적이 있는 오류에 대한 내장 타입(error 타입)을 제공한다. errors 패키지에 포함된 New 함수를 이용하면 자체적인 오류를 만들어낼 수 있다.

package main

import "errors"

func main() {
    err := errors.New("error message")
}

13.5 컨테이너와 정렬

리스트와 맵과 더불어 Go에서는 container 패키지를 통해 이용 가능한 다양한 컬렉션을 제공한다. 한 예로 container/list 패키지를 살펴보자.

리스트

container/list 패키지에는 이중 연결 리스트(doubly-linked list)가 구현돼 있다. 연결 리스트는 다음과 같은 형태의 자료구조다.

이중 연결 리스트

리스트의 각 노드에는 값(이 경우 1, 2, 3)과 다음 노드를 가리키는 포인터가 담긴다. 이것은 이중 연결 리스트라서 각 노드에는 이전 노드를 가리키는 포인터도 담겨 있다. 이 리스트는 다음과 같은 프로그램으로 만들 수 있다.

package main

import ("fmt" ; "container/list")

func main() {
    var x list.List
    x.PushBack(1)
    x.PushBack(2)
    x.PushBack(3)

    for e := x.Front(); e != nil; e=e.Next() {
        fmt.Println(e.Value.(int))
    }
}

List에 대한 기본값은 빈 리스트(*Listlist.New로도 생성할 수 있다)다. 값은 PushBack을 이용해 추가된다. 첫 번째 원소를 구한 다음 nil을 만날 때까지 링크를 따라감으로써 각 항목을 순회한다.

정렬

sort 패키지에는 임의의 데이터를 정렬하는 데 사용할 수 있는 함수가 들어 있다. 이곳엔 기정의된 정렬 함수(정수 및 부동 소수점 수로 구성된 슬라이스에 쓸 수 있는)가 여럿 포함돼 있다. 다음은 직접 정의한 데이터를 정렬하는 예제다.

package main

import ("fmt" ; "sort")

type Person struct {
    Name string
    Age int
}

type ByName []Person

func (this ByName) Len() int {
    return len(this)
}
func (this ByName) Less(i, j int) bool {
    return this[i].Name < this[j].Name
}
func (this ByName) Swap(i, j int) {
    this[i], this[j] = this[j], this[i]
}

func main() {
    kids := []Person{
        {"Jill",9},
        {"Jack",10},
    }
    sort.Sort(ByName(kids))
    fmt.Println(kids)
}

sort 패키지의 Sort 함수는 sort.Interface를 전달받아 그것을 정렬한다. sort.Interface는 3개의 메서드를 필요로 하는데, Len, Less, Swap이 여기에 해당한다. 정렬 방식을 직접 정의하려면 새 타입(ByName)을 만들어 정렬하고자 하는 것의 슬라이스에 상응하게끔 만들면 된다. 그런 다음 3개의 메서드를 정의한다.

그러고 나면 인명부를 정렬하는 작업은 리스트를 새로운 타입으로 형변환하는 것만큼이나 쉽다. 다음과 같은 식으로 나이순으로 정렬할 수도 있다.

type ByAge []Person
func (this ByAge) Len() int {
    return len(this)
}
func (this ByAge) Less(i, j int) bool {
    return this[i].Age < this[j].Age
}
func (this ByAge) Swap(i, j int) {
    this[i], this[j] = this[j], this[i]
}

13.6 해시와 암호화

해시 함수는 데이터 집합을 전달받아 그것을 더 작은 고정된 크기로 줄인다. 해시는 데이터를 찾는 것에서부터 변경 여부을 손쉽게 탐지하는 것에 이르기까지 모든 프로그래밍 분야에서 자주 사용된다. Go에서 제공하는 해시 함수는 크게 암호화와 비암호화와 관련된 해시로 나뉜다.

비암호화 해시 함수는 hash 패키지에서 찾을 수 있으며, adler32, crc32, crc64, fnv를 포함한다. 다음은 crc32를 사용하는 예제다.

package main

import (
    "fmt"
    "hash/crc32"
)

func main() {
    h := crc32.NewIEEE()
    h.Write([]byte("test"))
    v := h.Sum32()
    fmt.Println(v)
}

crc32 해시 객체는 Writer 인터페이스를 구현하고 있으므로 다른 Writer와 마찬가지로 바이트를 쓸 수 있다. 모든 것을 쓰고 나면 Sum32()를 호출해 uint32 값을 반환하고 싶을 것이다. crc32의 주된 용도는 두 파일을 비교하는 것이다. 두 파일에 대한 Sum32 값이 동일하면 두 파일이 동일할 확률이 굉장히 높다(100% 확신할 수는 없지만). 값이 다르다면 두 파일이 확실히 같지 않음을 의미한다.

package main

import (
    "fmt"
    "hash/crc32"
    "io/ioutil"
)

func getHash(filename string) (uint32, error) {
    bs, err := ioutil.ReadFile("test1.txt")
    if err != nil {
        return 0, err
    }
    h:= crc32.NewIEEE()
    h.Write(bs)
    return h.Sum32(), nil
}

func main() {
    h1, err := getHash("test1.txt")
    if err != nil {
        return
    }
    h2, err := getHash("test2.txt")
    if err != nil {
        return
    }
    fmt.Println(h1, h2, h1 == h2)
}

암호화 해시 함수는 그것에 대응되는 비암호화 해시 함수와 비슷하지만 역으로 변환하기 힘들다는 특성이 더해졌다. 암호화 해시에 데이터 집합을 부여할 경우 해시가 어떤 과정을 거쳐 만들어졌는지 파악하기가 굉장히 어렵다. 이러한 해시는 보안 애플리케이션에 자주 사용된다.

한 가지 자주 사용되는 암호화 해시 함수로 SHA-1이 있다. 다음은 SHA-1을 사용하는 예제다.

package main

import (
    "fmt"
    "crypto/sha1"
)

func main() {
    h := sha1.New()
    h.Write([]byte("test"))
    bs := h.Sum([]byte{})
    fmt.Println(bs)
}

이 예제는 crc32 예제와 굉장히 비슷한데, crc32sha1 모두 hash.Hash 인터페이스를 구현하기 때문이다. 주된 차이점으로 crc32는 32비트 해시를 계산하는 반면 sha1은 160비트 해시를 계산한다는 것이다. 160비트 숫자를 표현하는 네티이브 타입은 없으므로 여기서는 20바이트짜리 슬라이스를 대신 사용했다.

13.7 서버

Go에서 네트워크 서버를 작성하는 것은 굉장히 쉽다. 먼저 TCP 서버를 만드는 법을 살펴보자.

package main

import (
    "encoding/gob"
    "fmt"
    "net"
)

func server() {
    // 포트 대기
    ln, err := net.Listen("tcp", ":9999")
    if err != nil {
        fmt.Println(err)
        return
    }
    for {
        // 연결 수락
        c, err := ln.Accept()
        if err != nil {
            fmt.Println(err)
            continue
        }
        // 연결 처리
        go handleServerConnection(c)
    }
}

func handleServerConnection(c net.Conn) {
    // 메시지 수신
    var msg string
    err := gob.NewDecoder(c).Decode(&msg)
    if err != nil {
        fmt.Println(err)
    } else {
        fmt.Println("Received", msg)
    }

    c.Close()
}

func client() {
    // 서버에 연결
    c, err := net.Dial("tcp", "127.0.0.1:9999")
    if err != nil {
        fmt.Println(err)
        return
    }
    // 메시지 송신
    msg := "Hello World"
    fmt.Println("Sending", msg)
    err = gob.NewEncoder(c).Encode(msg)
    if err != nil {
        fmt.Println(err)
    }

    c.Close()
}

func main() {
    go server()
    go client()

    var input string
    fmt.Scanln(&input)
}

이 예제에서는 encoding/gob 패키지를 사용했는데, 이 패키지를 이용하면 다른 Go 프로그램에서(또는 이 경우 동일한 Go 프로그램에서) 값을 읽을 수 있도록 Go 값을 손쉽게 인코딩할 수 있다. 추가적인 인코딩은 encoding 아래의 패키지(encoding/json 같은)나 서드파티 패키지에서 찾아볼 수 있다(가령 bson 지원을 위해 labix.org/v2/mgo/bson 패키지를 사용할 수도 있다).

HTTP

HTTP 서버는 훨씬 더 쉽게 설정해서 사용할 수 있다.

package main

import ("net/http" ; "io")

func hello(res http.ResponseWriter, req *http.Request) {
    res.Header().Set(
        "Content-Type",
        "text/html",
    )
    io.WriteString(
        res,
        `<doctype html>
<html>
    <head>
        <title>Hello World</title>
    </head>
    <body>
        Hello World!
    </body>
</html>`,
    )
}

func main() {
    http.HandleFunc("/hello", hello)
    http.ListenAndServe(":9000", nil)
}

HandleFunc에서는 인자로 전달한 함수를 호출함으로써 URL 라우팅(/hello)를 처리한다. FileServer를 이용하면 정적 파일을 처리할 수도 있다.

http.Handle(
    "/assets/",
    http.StripPrefix(
        "/assets/",
        http.FileServer(http.Dir("assets")),
    ),
)

RPC

net/rpc(원격 프로시저 호출) 패키지와 net/rpc/jsonrpc 패키지에서는 네트워크를 통해 호출할 수 있는(단지 프로그램 내에서 호출하는 것이 아니라) 메서드를 노출하는 손쉬운 수단을 제공한다.

package main

import (
    "fmt"
    "net"
    "net/rpc"
)

type Server struct {}
func (this *Server) Negate(i int64, reply *int64) error {
    *reply = -i
    return nil
}

func server() {
    rpc.Register(new(Server))
    ln, err := net.Listen("tcp", ":9999")
    if err != nil {
        fmt.Println(err)
        return
    }
    for {
        c, err := ln.Accept()
        if err != nil {
            continue
        }
        go rpc.ServeConn(c)
    }
}

func client() {
    c, err := rpc.Dial("tcp", "127.0.0.1:9999")
    if err != nil {
        fmt.Println(err)
        return
    }
    var result int64
    err = c.Call("Server.Negate", int64(999), &result)
    if err != nil {
        fmt.Println(err)
    } else {
        fmt.Println("Server.Negate(999) =", result)
    }
}

func main() {
    go server()
    go client()

    var input string
    fmt.Scanln(&input)
}

이 프로그램은 노출하고자 하는 메서드를 모두 담은 객체를 생성하고 Negate 메서드를 클라이언트에서 호출하고 있다는 점만 제외하면 TCP 예제와 비슷하다. 더 자세한 사항은 net/rpc의 문서를 참고한다.

13.8 명령줄 인자 파싱

터미널에서 명령어를 실행할 때 명령줄 인자를 전달하는 것이 가능하다. go 명령어를 실행할 때도 이렇게 하는 것을 본 적이 있다.

go run myfile.go

runmyfile.go는 인자다. 명령어에 플래그를 전달하는 것도 가능하다.

go run -v myfile.go

flag 패키지를 이용하면 프로그램에 전달된 인자와 플래그를 파싱할 수 있다. 다음은 0과 6 사이의 숫자를 생성하는 예제 프로그램이다. 여기서는 프로그램에 플래그(-max=100)을 전달해 최댓값을 변경할 수 있다.

package main

import ("fmt";"flag";"math/rand")

func main() {
    // 플래그 정의
    maxp := flag.Int("max", 6, "최댓값")
    // 파싱
    flag.Parse()
    // 0과 max 사이의 숫자를 생성
    fmt.Println(rand.Intn(*maxp))
}

플래그가 지정되지 않은 추가 인자는 flag.Args()를 이용해 받을 수 있으며, 이 경우 []string이 반환된다.

13.9 동기화 기능

Go에서 동시성과 동기화를 처리할 때 선호되는 방법은 10장에서 살펴본 것처럼 고루틴과 채널을 이용하는 것이다. 하지만 Go에서는 syncsync/atomic 패키지에서 좀 더 전통적인 다중 스레드 루틴을 제공한다.

뮤텍스

뮤텍스(상호 배타적인 잠금; mutual exclusive lock)은 한 번에 단 하나의 스레드만이 코드 영역을 제어하게 해서 비원자적인 연산으로부터 공유 자원을 보호하는 데 사용된다. 다음은 뮤텍스를 사용하는 예제다.

package main

import (
    "fmt"
    "sync"
    "time"
)
func main() {
    m := new(sync.Mutex)

    for i := 0; i < 10; i++ {
        go func(i int) {
            m.Lock()
            fmt.Println(i, "start")
            time.Sleep(time.Second)
            fmt.Println(i, "end")
            m.Unlock()
        }(i)
    }

    var input string
    fmt.Scanln(&input)
}

뮤텍스(m)가 잠기면 그것이 해제되기 전까지 해당 뮤텍스를 잠그려는 다른 어떤 시도도 차단된다. sync/atomic 패키지에서 제공되는 뮤텍스나 다른 동기화 기능을 사용할 때는 세심한 주의를 기울여야 한다.

전통적인 다중 스레드 프로그래밍은 수월하지 않다. 실수를 저지르기 쉽고 그러한 실수는 찾기가 힘든데, 굉장히 특정한 상황에서, 비교적 드물게 일어나고 발생하는 상황을 재현하기가 어렵기 때문이다. Go의 큰 강점 가운데 하나는 Go에서 제공하는 동시성 기능들이 스레드와 잠금에 비해 이해하고 적절히 사용하기가 훨씬 수월하기 때문이다.

← 이전다음 →