The Ultimate Go Study Guide

Hoanh An

서문

서문

2019년 8월, 1년쯤 전 Ultimate Go Study Guide 라는 프로젝트를 GitHub 에 공유 하였습니다. 그리고 놀랍게도, 커뮤니티의 많은 관심을 받았으며 2020년 8월 기준 12K star, 900 fork 를 넘어섰습니다. 20 여분의 contributor 분들 덕분입니다.

프로젝트는 Ardan Lab’s Ultimate Go course 를 공부하며 정리한 것 입니다. Bill Kennedy와 Ardan Labs team이 이처럼 멋진 코스를 오픈소스화 한 것에 한 없는 감사를 드립니다. 지식과 통찰을 코스에 녹여내고, 모두에게 나누어 준 엄청난 작업이었습니다.

사람마다 나름의 학습 방법이 있겠지만, 저는 예제를 따라해보고 실행하며 배웁니다. 신중히 노트하고, 소스코드에 바로 코멘트하여 코드 한 줄, 한 줄을 확실히 이해하고 코드 뒤에 숨어있는 이론까지 신경씁니다.

Ultimate Go Study Guide가 성장하며 많은 분들이 전자책 버전을 요청하셨습니다. 이어서 읽을 수 있고, 좀더 편하게 읽을 수 있기 때문입니다.

그래서 이렇게 The Ultimate Go Study Guide eBook version을 만들었습니다. 지난 3개월 여 제 여유시간 대부분을 Ultimate Go Study Guide를 200 페이지의 책으로 만드는데 쏟아부었습니다. Ultimate Go 의 모든 좋은 점에 더하여, 전자책에서는 두 가지 장점이 더해졌습니다.

전자책 버전을 통해 Go를 좀더 쉽게 배우셨으면 합니다. 다시 한 번 모든 분들의 지원과 성원에 감사합니다. 정말 감사합니다.

즐겁게 읽으십시오!

Go 언어의 역학적 고찰(Language Mechanics)

문법

변수

빌트인 타입

타입은 두 가지 질문을 통해 완전성과 가독성을 제공한다

타입은 int32, int64처럼 명확한 이름도 있다. 예를 들어

uintint 처럼 메모리 크기가 명확하지 않은 타입을 선언하면, 아키텍처에 따라 크기가 달라진다. 64-bit OS라면, intint64와 같은 크기가 되고, 32-bit OS 라면 int32와 같은 크기가 된다.

워드 크기

워드의 크기는 워드가 몇 바이트인지를 말하며, 이는 메모리 주소의 크기와 같다. 예를 들어 64 비트 아키텍처에서 워드 사이즈는 64 비트(8 바이트)이고, 메모리 주소의 크기도 64 비트이다. 따라서 int 는 64 비트이다.

제로값 개념

모든 변수는 초기화되어야 한다. 어떤 값으로 초기화할지를 명시하지 않으면, 제로값으로 초기화 된다. 할당하는 메모리의 모든 비트는 0으로 리셋된다.

Type Zero value
Boolean false
Integer 0
Floating Point 0
Complex 0i
String ""
Pointer nil

선언과 초기화

var 로 변수를 선언하면 타입의 제로값으로 초기화된다.

var a int
var b string
var c float64
var d bool
fmt.Printf("var a int \t %T [%v]\n", a, a)
fmt.Printf("var b string \t %T [%v]\n", b, b)
fmt.Printf("var c float64 \t %T [%v]\n", c, c)
fmt.Printf("var d bool \t %T [%v]\n\n", d, d)
var a int     int [0]
var b string  string []
var c float64 float64 [0]
var d bool    bool [false]

문자열은 uint8 타입의 연속이다

문자열은 두 개의 워드로 된 데이터 구조체이다. 첫 번째 워드는 뒤에 숨겨져 있는 배열을 가리키는 포인터이고, 두 번째 워드는 문자열의 길이이다. 문자열의 제로값은 첫 번째 워드는 nil, 두 번째 워드는 0이다.

짧은 변수 선언(short variable declaration) 연산자를 사용하면 선언과 동시에 초기화 할 수 있다. (역자 주. 제로 값이 아닌 특정한 값으로 초기화 하려 할때 자주 쓴다.)

aa := 10
bb := "hello" // 첫 번째 워드는 문자들의 배열을 기리키는 포인터이고, 두 번째 워드는 5이다.
cc := 3.14159
dd := true

fmt.Printf("aa := 10 \t %T [%v]\n", aa, aa)
fmt.Printf("bb := \"hello\" \t %T [%v]\n", bb, bb)
fmt.Printf("cc := 3.14159 \t %T [%v]\n", cc, cc)
fmt.Printf("dd := true \t %T [%v]\n\n", dd, dd)
aa := 10      int [10]
bb := "hello" string [hello]
cc := 3.14159 float64 [3.14159]
dd := true    bool [true]

변환과 타입 변경(Conversion vs casting)

Go 는 casting을 지원하지 않고 conversion을 지원한다. 컴파일러가 컴파일 할때에 메모리가 더 있는 듯 처리하기보다 실제로 메모리를 더 할당한다.

aaa := int32(10)
fmt.Printf("aaa := int32(10) %T [%v]\n", aaa, aaa)
aaa := int32(10) int32 [10]

구조체

example 구조체 타입은 다른 타입의 필드들을 가지고 있다.

type example struct {
    flag    bool
    counter int16
    pi      float32
}

선언과 초기화(Declare and initialize)

example 구조체 타입의 변수를 선언하면, 구조체의 필드들은 제로값으로 초기화된다.

var e1 example

fmt.Printf("%+v\n", e1)
{flag:false counter:0 pi:0}

`example 구조체에 할당하는 메모리의 크기는 얼마일까?

bool은 1 바이트, int16은 2 바이트, float32는 4바이트이다. 모두 7바이트이지만, 실제로는 8바이트를 할당한다. 이를 이해하려면 패딩(padding)정렬(alignment)을 알아야 한다. 패딩 바이트는 boolint16 사이에 위치한다. 정렬 때문이다.

정렬: 하드웨어에게는 정렬 경계(alignment boundary)내의 메모리를 읽게 하는 것이 효율적이다. 하드웨어가 정렬 경계에 맞춰 읽게 소프트웨어에서 챙겨주는 것이 정렬이다.

규칙 1:

특정 값의 메모리 크기에 따라 Go는 어떤 정렬이 필요할지 결정한다. 모든 2 바이트 크기의 값은 2 바이트 경계를 가진다. bool값은 1 바이트라서 주소 0번지에서 시작한다. 그러면 다음 int16은 2번지에서 시작해야 한다. 건너뛰게 되는 1 바이트에 패딩 1 바이트가 들어간다. 만약 int16이 아니라 int32라면 3 바이트의 패딩이 들어간다.

규칙 2:

가장 큰 메모리 사이즈의 필드가 전체 구조체의 패딩을 결정한다. 가능한 패딩이 적을 수록 좋은데 그러려면 큰 필드부터 가장 작은 필드의 순서로 위치시키는 것이 좋다. example 구조체를 아래와 같이 정의하면 전체 구조체의 사이즈는 8 바이트를 따르게 되는데 int64가 8 바이트이기 때문이다.

type example struct {
    counter int64
    pi      float32
    flag    bool
}

example 타입의 변수를 선언하고 구조체 리터럴로 초기화 하였다. 이때 각 라인은 콤마(,)로 끝나야 한다.

e2 := example{
    flag:    true,
    counter: 10,
    pi:      3.141592,
}
fmt.Println("Flag", e2.flag)
fmt.Println("Counter", e2.counter)
fmt.Println("Pi", e2.pi)
Counter 10
Pi 3.141592
Flag true

익명의 타입 변수를 선언하고, 구조체 리터럴로 초기화 할 수 있다. 익명 타입은 재사용할 수 없다.

e3 := struct {
    flag    bool
    counter int16
    pi      float32
}{
    flag:    true,
    counter: 10,
    pi:      3.141592,
}
fmt.Println("Flag", e3.flag)
fmt.Println("Counter", e3.counter)
fmt.Println("Pi", e3.pi)
Flag true
Counter 10
Pi 3.141592

이름이 있는 타입과 익명 타입(Name type vs anonymous type)

두 구조체 타입의 필드가 완전히 같다 해도, 한 타입의 구조체 변수를 다른 타입의 구조체 변수에 대입할 수는 없다. 예를 들어 example1, example2가 동일한 필드를 가지는 구조체 타입이라 할 때에, var ex1 example1, var ex2 example2라고 변수를 선언하더라도 ex1 = ex2라는 대입은 허용되지 않는다. ex1 = example1(ex2) 라고 명시적인 변환(conversion)을 해줘야 한다. 하지만 만약 ex가, 위의 ex3 변수처럼, 동일한 구조의 익명 구조체 타입이라면 ex1 = ex 는 가능하다.

var e4 example
e4 = e3
fmt.Printf("%+v\n", e4)
{flag:true counter:10 pi:3.141592}

포인터

항상 값을 전달한다

포인터는 오직 한가지 목적을 가지고 있다: 공유. 프로그램의 경계를 가로질러 값을 공유하는 것이다. 여러 종류의 프로그램 경계가 있는데, 가장 흔한 것은 함수 호출이다. 고루틴 사이에도 경계가 있을 수 있다. 이에 대해서는 나중에 다루도록 한다.

프로그램이 시작할 때, 런타임은 고루틴을 생성한다. 모든 고루틴은 분리된 수행 경로이며 각각의 수행 경로는 머신이 수행해야 할 명령을 가지고 있다. 고루틴을 경량의 쓰레드라 생각해도 된다. go 키워드로 고루틴을 생성하지 않는 간단한 프로그램도 하나의 고루틴은 가진다: main 고루틴이다.

모든 고루틴은 스택이라 부르는 메모리 블럭을 할당받는데 크기는 2 킬로바이트로 매우 작다. 하지만 크기는 필요에 따라 변할 수 있다. 함수를 호출하면 수행을 위해 스택을 사용한다. 스택은 아래쪽으로 증가한다.

모든 함수는 스택 프레임을 가지는데 함수의 메모리 수행을 의미한다. [재방문] 모든 스택 프레임의 크기는 컴파일을 할 때에 알 수 있다. 컴파일러가 크기를 알 수 없는 값이 스택에 자리잡을 수는 없다. 그건 힙에 저장해야 한다.

제로값(zero value) 덕분에 우리는 모든 스택 프레임을 초기화 할 수 있다. 스택은 알아서 정리(cleaning) 되며, 그 방향은 아래쪽이다. 함수를 만들때마다 제로값으로 스택 프레임을 초기화하며 정리한다. [재방문] 메모리를 떠날때는 다시 필요하게 될지 모르기 때문에 위쪽으로 떠난다.

값의 전달(Pass by value)

int 타입의 변수를 초기값 10으로 선언하면 이 변수는 스택에 저장된다.

count := 10
// 변수의 주소를 얻기 위해 &를 사용한다.
fmt.Println("count:\tValue Of[" , count, "]\tAddr Of[" , &count, "]")

// count의 값을 전달한다.
increment1(count)

// increment1 를 실행한 다음의 count 값을 출력한다. 바뀐 것이 없다.
fmt.Println("count:\tValue Of[" , count, "]\tAddr Of[" , &count, "]")

// count의 주소를 전달한다. 이것 역시 "pass by value", 즉, 값을 전달하는 것이다.
// "pass by reference" 가 아니다. 주소 역시 값인 것이다.
increment2(&count)

// increment2 를 실행한 다음 count 값을 출력한다. 값이 변경되었다.
fmt.Println("count:\tValue Of[" , count, "]\tAddr Of[" , &count, "]")

func increment1(inc int) {
    // inc 의 값을 증가 시킨다.
    inc++
    fmt.Println( "inc1:\tValue Of[" , inc, "]\tAddr Of[" , &inc, "]")
}

// increment2 는 inc를 포인터 변수로 선언했다. 이 변수는 주소값을 가지며, int 타입의 값을 가리킨다.
// *는 연산자가 아니라 타입 이름의 일부이다. 이미 선언된 타입이건, 당신이 선언한 타입이건
// 모든 타입은 선언이 되면 포인터 타입도 가지게 된다.
func increment2(inc *int) {
    // inc 포인터 변수가 가리키고 있는 int 변수의 값을 증가시킨다.
    // 여기서 *는 연산자이며 포인터 변수가 가리키고 있는 값을 의미한다.
    *inc++
    fmt.Println("inc2:\tValue Of[" , inc, "]\tAddr Of[" , &inc, "]\tValue Points To[" , *inc, "]")
}
count: Value Of[ 10 ] Addr Of[ 0xc000050738 ]
inc1:  Value Of[ 11 ] Addr Of[ 0xc000050730 ]
count: Value Of[ 10 ] Addr Of[ 0xc000050738 ]
inc2:  Value Of[ 0xc000050738 ] Addr Of[ 0xc000050748 ] Value Points To[ 11 ]

이스케이프 분석(Escape analysis)

변수 ustayOnStack 함수에서 벗어나지 못한다. 함수 바깥에서 쓸 수 없다는 말이다. 컴파일 할 때에 u의 크기를 알 수 있기에 컴파일러는 u를 스택 프레임에 저장한다.

// user는 시스템의 user를 의미한다.
type user struct {
    name  string
    email string
}

func stayOnStack() user {
    // 스택 프레임에 변수를 생성하고 초기화한다.
    u := user{
      name:  "Hoanh An",
      email: "hoanhan101@gmail.com",
    }

    // 값을 리턴하여 main의 스택 프레임으로 전달한다.
    return u
}

escapeToHeap 에서는 변수가 함수 바깥으로 나온다. 구현상으로는 stayOnStack 함수와 거의 같아 보인다. user 타입의 변수를 생성하고 초기화한다. 하지만 미묘한 차이가 하나 있다. 값을 리턴하는 것이 아니라 값의 주소를 리턴한다. 주소값을 콜 스택으로 전달하는 것이다. 우리는 포인터 개념(pointer semantics)을 사용하고 있다.

main 함수가 호출한 함수의 스택 프레임에 존재하는 변수의 포인터를 리턴 받는 것 처럼 보일 수 있다. 스택 프레임은 재사용이 가능한 메모리이며, 언제든 escapeToHeap 함수를 호출하면 스택 프레임을 재할당하고 초기화한다. 만약 그렇다면 이건 문제다.

제로값에 대해 잠시 생각해보자. 모든 스택 프레임은 함수 호출시에 제로값으로 초기화되고, 스택은 알아서 아래 방향으로 정리된다. 함수를 호출할 때마다 제로값으로 정리되는 것이다. 다시 필요하게 될지 모르기에 메모리를 떠날 때는 위쪽으로 떠난다.

예제로 돌아가보자. 변수 u의 주소값을 main의 콜 스택에 전달하는 것처럼 보이는데 그렇다면 이 주소의 메모리는 언제 지워질 지 모르는 것이다. 하지만 다행이도 실제 작동은 그렇지 않다.

실제로는 이스케이프 분석이 이루어진다. return &u 라인 덕분에 함수를 위한 스택 프레임이 아닌 힙에 저장이 되는 것이다.

이스케이프 분석은 무엇을 스택에 둘지 힙에 둘지를 결정한다. stayOnStack 함수에서는 값의 복사본을 전달하기에 스택 프레임에 두어도 된다. 하지만 우리가 콜 스택 위쪽으로 무언가를 공유할 때는, 이스케이프 분석은 힙에 저장하도록 명령한다. main 함수는 결국 힙 메모리를 가리키는 포인터를 가지게 된다. 사실 힙 메모리 할당은 즉시 이루어진다. escapeToHeap은 힙을 가리키는 포인터를 가지고 있는 것이다. 하지만 u는 값 개념(value semantics)을 기반으로 하게 된다.

func escapeToHeap() *user {
    u := user{
        name:  "Hoanh An",
        email: "hoanhan101@gmail.com",
    }

    return &u
}

스택 공간이 부족해지면 어떻게 될까?

함수 호출을 하면, 가장 먼저 이 프레임을 위한 스택 공간이 충분한가? 를 확인한다. 충분하면 아무 문제가 없지만, 부족하다면 더 큰 스택 프레임을 만든 다음, 값을 복사해야 한다. 스택 공간을 조금만 주어서, 공간이 부족할 때 마다 값을 복사해야 하는 것은 상충관계가 있지만 고루틴에 스택 메모리를 적게 할당하여 얻는 이점이 더 크다.

스택은 커질 수 있기 때문에 하나의 고루틴이 다른 고루틴의 스택 메모리에 대한 포인터를 가질 수 없다. 컴파일러가 모든 포인터를 추적하는 것은 지나친 과부하가 되어 지연 시간이 엄청나게 늘어날 수 있다.

따라서, 하나의 고루틴의 스택은 온전히 그 고루틴만을 위한 것이고, 고루틴 사이에 공유하지 않는다.

가비지 컬렉션

힙에 저장한다는 것은 가비지 컬렉션이 개입한다는 것이다. 가비지 컬렉터(GC)에 있어서 가장 중요한 것은 페이싱 알고리즘(pacing algorithm)이다. 최소한의 가비지 컬렉션 작업시간 t가 소요되도록 어떠한 주기와 페이스로 가비지 컬렉션을 실행할 지를 결정해야 한다.

4 MB 힙을 가진 프로그램이 있다고 할 때에, GC는 라이브 힙(live heap)을 2 MB로 유지하려 한다. 라이브 힙이 4 MB를 넘어서면 더 큰 힙을 할당해줘야 한다. GC의 페이스는 힙이 얼마나 빠르게 커지는지에 달려있다. 그에 맞게 적절하게 라이브 힙을 줄여줘야 하는 것이다.

GC가 작동할 때는 성능이 떨어질 수밖에 없다. 그래야 모든 고루틴이 동시에 작동할 수 있다. GC 역시 가비지 컬렉션 작업을 하는 고루틴들을 실행시키며, 가용 CPU 의 25%를 사용한다. GC와 페이스 알고리즘에 대한 자세한 설명은 링크를 참조바란다: !Go 1.5 concurrent garbage collector pacing

함수

// user 구조체는 user 정보를 담고 있다.
type user struct {
    ID   int
    Name string
}

// updateStats 구조체는 업데이트 정보를 담고 있다.
type updateStats struct {
    Modified int
    Duration float64
    Success  bool
    Message  string
}

func main() {
    // user 프로필을 가져온다.
    u, err := retrieveUser("Hoanh")
    if err != nil {
        fmt.Println(err)
        return
    }

    // user 프로필을 보여준다. `u`는 주소값이기에 *를 사용하여 값을 얻어낸다.
    fmt.Printf("%+v\n" , *u)

    // user 의 name 을 업데이트 한다.
    // _(blank identifier)를 사용하여 리턴된 updateStats는 무시하며
    // if 범위 밖에서 사용할 값은 없으니 간결한 문법을 사용하였다.
    if _, err := updateUser(u); err != nil {
        fmt.Println(err)
        return
    }

    // 업데이트가 성공했다고 출력한다.
    fmt.Println("Updated user record for ID", u.ID)
}

retrieveUser는 특정한 사용자의 문서를 가져온다. 문자열 타입의 name을 넣어주면, user 타입을 가리키는 값과 error 타입의 값을 리턴한다.

func retrieveUser(name string) (*user, error) {
    // getUser 함수를 호출하여 JSON 형식의 user를 전달 받는다.
    r, err := getUser(name)
    if err != nil {
        return nil, err
    }

    // JSON 값을 unmarshal하여 저장할 user 타입인 변수 u를 생성한다.
    var u user

    // 변수 u를 json.Unmarshal 함수에 전달하면, 함수는 r로부터 JSON 을 읽어서 변수 u에 넣어준다.
    err = json.Unmarshal([]byte(r), &u)

    // retrieveUser 함수를 호출한 함수에게 u값을 전달한다. 이처럼 retrieveUser 함수에서
    // 생성한 변수의 주소값을 호출한 함수에게 전달하기에 이 변수는 힙 메모리에 할당된다.
    return &u, err
}

getUser함수는 웹으로 호출하였을때 특정한 사용자에 대한 JSON 으로 응답이 돌아오는 것을 시뮬레이션 한 것이다.

func getUser(name string) (string, error) {
    response := `{"ID":101, "Name":"Hoanh"}`
    return response, nil
}

updateUser 함수는 특정 사용자가 업데이트 되었다는 응답을 시뮬레이션 한 것이다.

func updateUser(u *user) (*updateStats, error) {
    // response 변수는 JSON 응답을 시뮬레이션 한 것이다.
    response := `{"Modified":1, "Duration":0.005, "Success" : true, "Message": "updated"}`

    // JSON 문서를 userStats 구조체 타입의 변수로 unmarshal한다.
    var us updateStats
    if err := json.Unmarshal([]byte(response), &us); err != nil {
        return nil, err
    }

    // update 성공여부를 확인한다.
    if us.Success != true {
        return nil, errors.New(us.Message)
    }

    return &us, nil
}
{ID:101 Name:Hoanh}
Updated user record for ID 101

상수

상수는 변수가 아니며 (변수들의 타입시스템에 상응하는) 상수만을 위한 타입시스템이 있다. 상수의 최소 정밀도(minimum precision)는 265 bit 이며, 이 정도의 정밀도는 수학적으로 정확하다고 간주한다. 상수는 컴파일을 하는 동안만 존재한다.

선언과 초기화

상수는 타입이 있을 수도, 없을 수도 있다. 타입이 없을 때에는(untyped) 이를 kind로 간주한다. 타입이 없는 상수는 컴파일러가 암묵적으로 특정 타입으로 변환한다.

타입이 없는 상수.

const ui = 12345    // kind: integer
const uf = 3.141592 // kind: floating-point

타입이 있는 상수는 여전히 상수 타입 시스템을 사용하지만 그 정밀도는 타입이 없는 상수에 비해 제한적이다.

const ti int = 12345        // type: int
const tf float64 = 3.141592 // type: float64

상수 1000을 uint8 에 대입하려 하면 오버플로우가 발생한다.

const myUint8 uint8 = 1000

상수는 다른 kind를 산술적으로 지원한다. Kind 승급(kind promotion)을 이용해서 어떤 kind 인지를 결정한다. 이 모든 것은 암묵적으로 이루어진다.

answer 변수는 float64 타입이 될 것이다.

var answer = 3 * 0.333 // KindFloat(3) * KindFloat(0.333)
fmt.Println(answer)
0.999

상수 thirdkind는 실수가 될 것이다.

const third = 1 / 3.0 // KindFloat(1) / KindFloat(3.0)
fmt.Println(third)
0.3333333333333333

상수 zerokind는 정수이다.

const zero = 1 / 3 // KindInt(1) / KindInt(3)
fmt.Println(zero)
0

타입이 있는 상수와 타입이 없는 상수의 산술 계산을 보자. 계산을 하려면 둘은 비슷한 타입이어야 한다. 아래 코드에서는 둘 다 정수이다.

const one int8 = 1
const two = 2 * one // int8(2) * int8(1)

fmt.Println(one)
fmt.Println(two)
1
2

상수 maxInt는 64 bit 아키텍처에서 가장 큰 정수이다.

const maxInt = 9223372036854775807
fmt.Println(maxInt)
9223372036854775807

bigger 상수는 int64 타입보다 훨씬 큰 숫자이지만 타입이 없는 상수이기에 컴파일에 문제가 없다. (아키텍처에 따라 다르긴 하지만) 256 bit 는 정말 큰 공간이다.

const bigger = 9223372036854775808543522345

하지만 biggerInt 상수는 int64 타입이기 때문에 컴파일 시에 에러가 난다.

const biggerInt int64 = 9223372036854775808543522345

iota

const (
    A1 = iota // 0 : 0에서 시작한다
    B1 = iota // 1 : 1 증가한다
    C1 = iota // 2 : 1 증가한다
)

fmt.Println("1:", A1, B1, C1)

const (
    A2 = iota // 0 : 0에서 시작한다
    B2        // 1 : 1 증가한다
    C2        // 2 : 1 증가한다
)

fmt.Println("2:", A2, B2, C2)

const (
    A3 = iota + 1 // 1 : 1에서 시작한다
    B3            // 2 : 1 증가한다
    C3            // 3 : 1 증가한다
)

fmt.Println("3:", A3, B3, C3)

const (
    Ldate= 1 << iota //  1 : 오른쪽으로 0번 시프트 된다. 0000 0001
    Ltime            //  2 : 오른쪽으로 1번 시프트 된다. 0000 0010
    Lmicroseconds    //  4 : 오른쪽으로 2번 시프트 된다. 0000 0100
    Llongfile        //  8 : 오른쪽으로 3번 시프트 된다. 0000 1000
    Lshortfile       // 16 : 오른쪽으로 4번 시프트 된다. 0001 0000
    LUTC             // 32 : 오른쪽으로 5번 시프트 된다. 0010 0000
)
fmt.Println("Log:", Ldate, Ltime, Lmicroseconds, Llongfile, Lshortfile, LUTC)
1: 0 1 2
2: 0 1 2
3: 1 2 3
Log: 1 2 4 8 16 32

데이터 구조(Data Structures)

배열(Array)

CPU 캐시(CPU Cache)

코어들은 메인 메모리로 바로 접근하지 않고 로컬 캐시로 접근한다. 캐시에는 데이터와 명령어가 저장되어 있다.

캐시 속도는 L1, L2, L3, 메인 메모리 순으로 빠르다. Scott Meyers에 따르면 “만약 퍼포먼스가 중요하다면 모두 캐시 메모리로 접근해야 한다”고 한다. 메인 메모리 접근은 굉장히 느리다. 사실상 접근이 힘들다고 보면 된다.

그래서 캐시 미스(cache miss)가 발생하지 않거나 잠재적인 문제로부터 최소화하는 캐싱 시스템(caching system)은 어떻게 작성해야 할까?

프로세서(Processor)는 프리페처(Prefetcher)를 가지고 있다. 프리페처는 어떤 데이터가 필요할지 예상한다. 데이터 단위(granularity)는 사용하는 기계에 따라 다르다. 대체로 프로그래밍 모델은 바이트를 사용한다. 한 번에 바이트를 읽고 쓸 수 있다. 그러나, 캐싱 시스템의 관점에서 보면 데이터 단위는 1바이트가 아니다. 캐싱 시스템의 관점에서 데이터의 단위는 64바이트고 이걸 캐시 라인(cache line)이라고 부른다. 모든 메모리는 64바이트의 캐시 라인으로 구성되어 있다. 캐싱 메커니즘(caching mechanism)은 복잡하지만 프리페처는 모든 지연 시간을 없애려 한다. 프리페처가 예측 가능한 데이터 접근 패턴을 파악 할 수 있어야 한다. 즉, 예측 가능한 데이터 접근 패턴을 생성하는 코드를 작성해야 한다.

쉬운 방법의 하나는 메모리의 연속 할당을 만들고 이것을 순회하는 것이다. 배열(Array) 데이터 구조는 연속 할당과 순회를 할 수 있게 해준다. 하드웨어 관점에서 배열은 가장 중요한 데이터 구조이며 Go 관점에서 슬라이스(slice)가 그러하다. C++의 vector와 마찬가지로 슬라이스는 배열 기반 자료구조이다. 사이즈가 어떻든 배열을 할당하면, 모든 원소는 각각 다른 원소로부터 같은 거리를 갖게 된다. 배열을 순회하는 건 캐시 라인을 한줄 한줄 순회하는 것과 같다. 프리페처는 접근 패턴을 고르고 모든 지연 시간을 숨긴다.

예를 들어, 큰 nxn 행렬이 있다고 하자. 연결 리스트 순회(LinkedList Traverse), 열 순회(Column Traverse), 행 순회(Row Traverse)를 했을 때 각 순회의 성능을 측정해보자. 당연하게도 행 순회가 가장 높은 성능을 가진다. 행 순회는 행렬의 캐시 라인을 순회하면서 예측 가능한 접근 패턴을 만든다. 이와 달리 열 순회는 캐시 라인을 순회하지 않는다. 메모리 임의 접근 패턴을 가지고 있다. 이게 성능이 제일 느린 이유다. 연결 리스트 순회의 성능이 왜 중간인지 설명하지 않았다. 단순히 열 순회만큼 성능이 안 좋을 거라고 생각해 볼 수 있다. 자세한 이해를 위해 또 다른 캐시인 TLB - 변환 색인 버퍼(Translation Lookaside Buffer)를 알아보자. 변환 색인 버퍼는 물리적 메모리에 있는 운영 체제의 페이지(page)와 오프셋(offset)을 유지한다.

변환 색인 버퍼(Translation Lookaside Buffer)

캐싱 시스템은 하드웨어로 한 번에 64바이트씩 데이터를 옮긴다. 데이터 단위는 기계 별로 다르듯이, 운영 체제는 4k(운영 체제의 기존 페이지 크기) 바이트씩 페이징 함으로써 메모리를 관리한다.

관리되는 모든 페이지는 가상 메모리 주소(소프트웨어는 가상 주소를 물리적 메모리를 사용하고 공유하는 샌드박스에서 실행한다)를 갖게 되는데, 올바른 페이지에 매핑되고 물리적 메모리로 오프셋 하기 위해 사용된다.

변환 색인 버퍼의 미스는 캐시 미스보다 나쁠 수 있다. 연결 리스트 순회가 중간 성능인 이유는 다수의 노드가 같은 페이지에 있기 때문이다. 캐시 라인은 예측 가능한 거리를 요구하지 않기 때문에 캐시 미스가 발생 할 수 있지만, 많은 변환 색인 버퍼의 미스는 발생하지 않을 수 있다. 열 순회에서는 캐시 미스뿐만 아니라 엑세스할 때마다 변환 색인 버퍼의 캐시 미스가 발생 할 수 있다.

즉, 데이터 지향 설계가 중요하다. 효율적인 알고리즘을 작성하는 것에 그치지 않고, 어떻게 데이터에 접근하는 것이 알고리즘보다 성능에 좋은 영향을 미칠지 고려해야 한다.

선언과 초기화(Declare and initialize)

문자열이 원소이고 길이가 5인 배열을 선언하고, 제로 값(zero value)으로 초기화 해보자. 다시 한 번 더 말하지만, 문자열은 포인터와 길이를 표현하는 두 워드(word)로 이루어진 데이터 구조다. 이 배열을 제로 값(zero value)으로 설정하면, 배열속의 모든 문자열도 제로 값(zero value)이 된다. 각각의 문자열의 첫 번째 워드는 nil을 가리키고 두 번째 워드는 0이 된다.

22-1
var strings [5]string

인덱스 0의 문자열은 이제 바이트들(문자열을 구성하는 문자들)을 실제로 저장하고 있는 배열에 대한 포인터와 길이 정보 5를 가지게 된다.

비용이 얼마나 드는가?(What is the cost?)

할당에는 2 바이트를 복사하는 비용이 발생한다. 두 문자열은 같은 배열을 가리키며, 그래서 할당의 비용은 2 단어에 대한 비용뿐이다.

strings[0] = "Apple"
22-2

슬라이스의 남은 부분에도 값을 할당한다.

strings[1] = "Orange"
strings[2] = "Banana"
strings[3] = "Grape"
strings[4] = "Plum"

문자열 배열 반복(Iterate over the array of strings)

range를 사용하면, 인덱스와 복사된 원소의 값을 얻을 수 있다. for 문 내에서 fruit 변수는 문자열 값을 가지게 된다. 첫 번째 반복에서는 “Apple”을 가진다. 이 문자열 역시 위 이미지의 (1) 배열을 가리키는 워드와 길이 5를 나타내는 두 번째 워드를 가진다. 이제 세 개의 문자열의 같은 배열을 공유하고 있다.

Println 함수에는 무엇을 전달하는가

여기서는 value의 의미로서 사용한다. 문자열 값을 공유하지 않는다. Println은 문자열의 값을 복사해서 가진다. Println을 호출 할 때 같은 배열을 공유하는 4개의 문자열을 가지게 되는 것이다. 문자열의 주소를 함수에 전달하지 않으면 이점이 있다. 문자열의 길이를 알고 있으니 스택에 둘 수 있고, 그 덕분에 힙에 할당하여 GC를 해야하는 부담을 덜게 된다. 문자열은 값을 전달하여 스택에 둘 수 있게 디자인 되어, 가비지가 생성되지 않는다. 그래서 문자열(들)이 가리키는 배열만이 힙에 저장되고 공유된다.

fmt.Printf("\n=> Iterate over array\n")
for i, fruit := range strings {
    fmt.Println(i, fruit)
}
=> Iterate over array
0 Apple
1 Orange
2 Banana
3 Grape
4 Plum

4개의 정수를 가지는 배열을 선언하고 리터럴 표기법(literal syntax)으로 특정 값으로 초기화 한다.

numbers := [4]int{10, 20, 30, 40}

전통적인 방법으로 배열을 반복한다.

fmt.Printf("\n=> Iterate over array using traditional style\n"​)

for i := 0;i < len(numbers);i++ {
    fmt.Println(i, numbers[i])
}
=> Iterate over array using traditional style
0 10
1 20
2 30
3 40

다른 타입의 배열들(Different type arrays)

제로 값으로 초기화 된 길이가 5인 정수 형 배열을 선언하자.

var five [5]int

특정 값으로 초기화 된 길이가 4인 정수 형 배열을 선언하자.

four := [4]int{10, 20, 30, 40}
fmt.Printf("\n=> Different type arrays\n")
fmt.Println(five)
fmt.Println(four)
=> Different type arrays
[0 0 0 0 0]
[10 20 30 40]

five = four와 같이 변수 four를 변수 five에 할당하려고 할 때, 컴파일러는 "cannot use four (type [4]int) as type [5]int in assignment"라는 메세지를 출력한다. 타입(길이와 표현)이 다르기 때문에 할당 할 수 없다. 배열의 크기는 타입명에 표시 된다: [4]int vs [5]int. 이것은 포인터의 표현과 같은 맥락이다. *int의 *은 연산자가 아니고 타입명의 일부이다. 당연하게도 모든 배열은 컴파일 타임(compile time) 때 정해진 크기를 갖게 된다.

연속 메모리 할당(Contiguous memory allocations)

특정 값들로 초기화 된 길이가 6인 문자열 배열을 선언하자.

six := [6]string{"Annie", "Betty", "Charley", "Doug", "Edward", "Hoanh"}

이 배열을 반복하면서 각 원소의 값과 주소를 출력하자. Printf의 결과를 보면, 이 배열은 연속 된 메모리 블록으로 이루어진 것을 알 수 있다. 문자열은 두 워드로 되어 있고, 컴퓨터 아키텍처에 따라 x 바이트를 가지게 된다. 연속 된 두 IndexAddr의 거리는 정확히 x 바이트이다. 변수 v는 스택에 있고 매번 같은 주소를 가진다.

fmt.Printf("\n=> Contiguous memory allocations\n")
for i, v := range six {
    fmt.Printf("Value[%s]\tAddress[%p] IndexAddr[%p]\n", v, &v, &six[i])
}
=> Contiguous memory allocations
Value[Annie] Address[0xc000010250] IndexAddr[0xc000052180]
Value[Betty] Address[0xc000010250] IndexAddr[0xc000052190]
Value[Charley] Address[0xc000010250] IndexAddr[0xc0000521a0]
Value[Doug] Address[0xc000010250] IndexAddr[0xc0000521b0]
Value[Edward] Address[0xc000010250] IndexAddr[0xc0000521c0]
Value[Hoanh] Address[0xc000010250] IndexAddr[0xc0000521d0]

슬라이스 (Slice)

선언과 초기화 (Declare and initialize)

5개의 요소를 갖는 슬라이스(slice)를 생성해보자. make 함수는 슬라이스(slice)맵(map) 그리고 채널(channel) 타입에서 사용하는, 특별한 내장 함수이다. make 함수를 사용하여 5개의 문자열 배열을 갖는 슬라이스를 생성하면, 3개의 워드(word) 데이터 구조가 만들어진다. 첫 번째 워드는 배열을 위치를 가리키고 두 번째 워드는 길이를, 세 번째 워드는 용량을 나타낸다.

25

길이와 용량 (Length vs Capacity)

길이(Length)는, 포인터의 위치에서부터 접근해서 읽고 쓸 수 있는 요소의 수를 의미하며, 용량(Capacity)은 포인터의 위치에서부터 배열에 존재할 수 있는 요소의 총량을 뜻한다.

문법적 설탕(syntactic sugar)을 사용하기에, 슬라이스는 언뜻 배열처럼 보인다. 비용도 배열과 동일하게 발생한다. 하지만, 한 가지 다른 점은 make 함수의 []string의 대괄호 안에 값이 없다는 것이다. 이것으로 배열과 슬라이스를 구분할 수 있다.

slice1 := make([]string, 5)
slice1[0] = "Apple"
slice1[1] = "Orange"
slice1[2] = "Banana"
slice1[3] = "Grape"
slice1[4] = "Plum"

슬라이스의 길이를 넘는 인덱스에는 접근할 수 없다.

Error: panic: runtime error: index out of range slice1[5] = "Runtime error"

슬라이스의 주소가 아닌 값을 전달한다. 따라서, Println함수는 슬라이스의 복사본을 갖게 된다.

fmt.Printf("\n=> Printing a slice\n")
fmt.Println(slice1)
=> Printing a slice
[Apple Orange Banana Grape Plum]

참조 타입 (Reference type)

5개의 요소를 갖고 용량이 8개인 슬라이스를 만들기 위해 make 키워드를 이용할 수 있으며, 이를 통해 초기화 시점에 직접 용량을 정할 수 있다.

결국 우리는, 차례대로 8개의 요소를 갖는 배열을 가르키는 포인터와 길이는 5, 용량은 8을 갖는 3개의 워드(word)형 자료 구조를 갖게된다. 이는 첫 5개의 요소에 대해 읽고 쓸 수 있으며, 필요시 이용 가능한 3개의 용량을 갖는 것을 뜻한다.

27
slice2 := make([]string, 5, 8)
slice2[0] = "Apple"
slice2[1] = "Orange"
slice2[2] = "Banana"
slice2[3] = "Grape"
slice2[4] = "Plum"
fmt.Printf("\n=> Length vs Capacity\n")
inspectSlice(slice2)
// inspectSlice는 리뷰를 위해 슬라이스 헤더를 보여주는 함수이다.
// 파라미터 : 다시 말하지만, []string의 대괄호 속에 값이 없으므로 슬라이스를 사용함을 알 수 있다.
// 배열에서 했던 것과 마찬가지로, 슬라이스를 순회한다.
// `len`이 슬라이스의 길이를 알려주며, `cap`은 슬라이스의 용량을 알려준다.
// 결과를 보면, 예상대로 슬라이스의 주소 값들이 정렬되어 표시되는 것을 볼 수 있다.
func inspectSlice(slice []string) {
    fmt.Printf("Length[%d] Capacity[%d]\n", len(slice), cap(slice))
    for i := range slice {
        fmt.Printf("[%d] %p %s\n", i, &slice[i], slice[i])
    }
}
=> 길이 vs 용량 (Length vs Capacity)
Length[5] Capacity[8]
[0] 0xc00007e000 Apple
[1] 0xc00007e010 Orange
[2] 0xc00007e020 Banana
[3] 0xc00007e030 Grape
[4] 0xc00007e040 Plum

추가에 관한 생각: 슬라이스를 동적 자료구조로 만들기. (Idea of appending: making slice a dynamic data structure)

문자열로 구성될 nil 슬라이스를 선언하고 그 값을 제로 값(zero value)으로 설정한다. 이 때, 첫 번째 워드(word)는 nil을 가르키는 포인터로, 두 번째와 세 번째 값은 0을 나타내는 3개의 워드(word) 자료구조를 갖는다.

var data []string

만약, data := string{}을 하게되면, 이 둘은 서로 같을까?

그렇지 않다. 왜냐하면 이 경우, 데이터는 값이 제로 값(zero value)으로 설정되지 않기 때문이다. 빈 리터럴로 생성되는 모든 타입이 제로 값(zero value)을 반환하지는 않기 때문에, 제로 값(zero value)을 위해 var을 사용하는 이유기도하다. 위의 경우에, 반환 되는 슬라이스는 nil 슬라이스가 아닌 포인터를 갖고 있는 빈 슬라이스가 된다. nil 슬라이스와 빈 슬라이스 간에는 각기 다른 의미가 있는데, 제로 값(zero value)으로 설정된 참조 타입은 nil로 여길 수 있다는 점이다. marshal 함수에 nil 슬라이스를 넘긴다면 null을 반환하고, 빈 슬라이스를 넘긴다면 빈 JSON을 반환하게 된다. 그렇다면 이 때 포인터는 어떤 것을 가르키게 될까? 바로, 나중에 살펴볼 빈 구조체를 가르킨다.

슬라이스의 용량을 가져오자.

    lastCap := cap(data)

슬라이스에 약 10만개의 문자열을 덧붙인다.

    for record := 1;record <= 102400;record++ {

내장 함수인 append를 사용해서 슬라이스를 덧붙일 수 있다. 이 함수를 통해 슬라이스에 값을 추가할 수 있으며 자료 구조를 동적으로 만들 수 있으면서도, 기계적 동정심(mechanical sympathy)을 통해 예측 가능한 접근 패턴을 제공함으로써 여전히 인접한 메모리 블럭을 이용할 수 있게 된다. append 함수는 값 개념(value semantic)으로 동작한다. 슬라이스 자체를 공유하는 것이 아니라, 슬라이스에 값을 덧붙이고 그 복사본을 반환하는 식이다. 따라서 슬라이스는 힙 메모리가 아닌 스택에 위치하게 된다.

    data = append(data, fmt.Sprintf("Rec: %d", record))

append가 동작할 때 마다, 매번 길이와 용량을 확인한다. 만약 두 값이 동일하다면, 더 이상 남은 공간이 없다는 것을 뜻한다. 이 때, append 함수는 기존보다 2배를 늘린 크기를 갖는 새로운 배열을 만들어서 예전 값을 복사한 뒤, 새 값을 추가하게 된다. 그리고 스택 프레임에 존재하는 값을 변경시킨 뒤, 그 복사본을 반환한다. 그렇게 기존의 슬라이스가 새로운 복사본으로 치환된다. 만약 길이와 용량이 같지 않다면, 슬라이스 안에 아직 사용할 수 있는 공간이 남아있다는 것을 뜻하므로, 새 복사본을 만드는 일 없이 값을 추가 할 수 있다. 이것은 굉장히 효율적이다. 출력의 마지막 열을 확인해보자. 배열의 요소가 1000개 혹은 그 이하일 때, 배열의 크기는 2배로 늘어난다. 요소의 개수가 1000개를 넘고 나면, 용량의 변화율은 25%로 변한다. 슬라이스의 용량이 변경될 때, 그 변화를 나타낸다.

    if lastCap != cap(data) {

변화율을 계산한다.

    capChg := float64(cap(data)-lastCap) / float64(lastCap) * 100

lastCap에 새 용량을 저장한다.

    lastCap = cap(data)

결과를 표시한다.

    fmt.Printf("Addr[%p]\tIndex[%d]\t\tCap[%d - %2.f%%]\n", &data[0], record, cap(data), capChg)
=> Idea of appending
Addr[0xc0000102a0] Index[1] Cap[1 - +Inf%]
Addr[0xc00000c0c0] Index[2] Cap[2 - 100%]
Addr[0xc000016080] Index[3] Cap[4 - 100%]
Addr[0xc00007e080] Index[5] Cap[8 - 100%]
Addr[0xc000100000] Index[9] Cap[16 - 100%]
Addr[0xc000102000] Index[17] Cap[32 - 100%]
Addr[0xc00007a400] Index[33] Cap[64 - 100%]
Addr[0xc000104000] Index[65] Cap[128 - 100%]
Addr[0xc000073000] Index[129] Cap[256 - 100%]
Addr[0xc000106000] Index[257] Cap[512 - 100%]
Addr[0xc00010a000] Index[513] Cap[1024 - 100%]
Addr[0xc000110000] Index[1025] Cap[1280 - 25%]
Addr[0xc00011a000] Index[1281] Cap[1704 - 33%]
Addr[0xc000132000] Index[1705] Cap[2560 - 50%]
Addr[0xc000140000] Index[2561] Cap[3584 - 40%]
Addr[0xc000154000] Index[3585] Cap[4608 - 29%]
Addr[0xc000180000] Index[4609] Cap[6144 - 33%]
Addr[0xc000198000] Index[6145] Cap[7680 - 25%]
Addr[0xc0001b6000] Index[7681] Cap[9728 - 27%]
Addr[0xc000200000] Index[9729] Cap[12288 - 26%]
Addr[0xc000230000] Index[12289] Cap[15360 - 25%]
Addr[0xc000280000] Index[15361] Cap[19456 - 27%]
Addr[0xc000300000] Index[19457] Cap[24576 - 26%]
Addr[0xc000360000] Index[24577] Cap[30720 - 25%]
Addr[0xc000400000] Index[30721] Cap[38400 - 25%]
Addr[0xc000300000] Index[38401] Cap[48128 - 25%]
Addr[0xc000600000] Index[48129] Cap[60416 - 26%]
Addr[0xc0006ec000] Index[60417] Cap[75776 - 25%]
Addr[0xc000814000] Index[75777] Cap[94720 - 25%]
Addr[0xc000600000] Index[94721] Cap[118784 - 25%]

슬라이스의 슬라이스

slice2의 인덱스 2, 인덱스 3의 값을 갖는 slice3을 생성하자. slice3의 길이는 2이고 용량은 6이다.

매개변수는 [시작 인덱스:(시작 인덱스 + 길이)] 형태이다.

결과를 통해 두 슬라이스는 같은 배열을 공유하고 있는 것을 알 수 있다. 슬라이스의 헤더는 값의 개념으로 사용 될 때 스택에 존재한다. 오직 공유되는 배열만이 힙에 위치한다.

slice3 := slice2[2:4]
fmt.Printf("\n=> Slice of slice (before)\n")
inspectSlice(slice2)
inspectSlice(slice3)

slice3의 인덱스 0의 값을 바꾸면, 어떤 슬라이스가 변경 될까?

=> Slice of slice (before)
Length[5] Capacity[8]
[0] 0xc00007e000 Apple
[1] 0xc00007e010 Orange
[2] 0xc00007e020 Banana
[3] 0xc00007e030 Grape
[4] 0xc00007e040 Plum
Length[2] Capacity[6]
[0] 0xc00007e020 Banana
[1] 0xc00007e030 Grape
slice3[0] = "CHANGED"

두 슬라이스 모두 변한다. 생성되어 있는 슬라이스를 변경한다는 것을 잊지 말아야 한다. 어디서 이 슬라이스를 사용하는지, 또 배열을 공유하고 있는지를 주의깊게 살펴야 한다.

fmt.Printf("\n=> Slice of slice (after)\n")
inspectSlice(slice2)
inspectSlice(slice3)
=> Slice of slice (after)
Length[5] Capacity[8]
[0] 0xc00007e000 Apple
[1] 0xc00007e010 Orange
[2] 0xc00007e020 CHANGED
[3] 0xc00007e030 Grape
[4] 0xc00007e040 Plum
Length[2] Capacity[6]
[0] 0xc00007e020 CHANGED
[1] 0xc00007e030 Grape

slice3 := append(slice3, "CHANGED")는 어떨까? 슬라이스의 길이와 용량이 다르면, append를 사용 할 때 비슷한 문제가 발생한다. slice3의 0번째 인덱스 값을 변경하는 대신에, append를 호출 해보자. slice3의 길이는 2이고 용량은 6이라서 수정을 위한 여유 공간을 가지고 있다. slice2의 4번째 인덱스와 같은 주소 값을 가지는 slice3의 3번째 인덱스의 원소부터 변경 된다. 이런 상황은 굉장히 위험하다. 그러면 슬라이스의 길이와 용량이 서로 같으면 어떨까? 슬라이싱 구문(slicing syntax)의 또 다른 매개변수를 추가하여, slice3의 용량을 6 대신 2로 만들어보자: slice3 := slice2[2:4:4]

길이와 용량이 같은 슬라이스에 대해 append가 호출 되면, slice2의 4번째 원소를 가지고 오지 않는다. 이것은 분리되어 있다. slice3는 길이가 2이고 용량이 2이면서 여전히 slice2와 같은 배열을 공유하고 있다. append가 호출 되면, 길이와 용량이 달라지게 된다. 주소 또한 달라지게 된다. 길이는 3인 새로운 슬라이스가 된다. 새로운 슬라이스 소유의 배열을 갖게 되고 더 이상 원본 슬라이스의 영향을 받지 않는다.

슬라이스 복사

복사는 문자열과 슬라이스 타입에서만 동작 한다. 원본의 요소들을 담을 수 있을 만큼의 크기로 새로운 슬라이스를 만들고 내장 함수인 copy를 사용해서 값을 복사한다.

slice4 := make([]string, len(slice2))
copy(slice4, slice2)

fmt.Printf("\n=> Copy a slice\n")
inspectSlice(slice4)
=> Copy a slice
Length[5] Capacity[5]
[0] 0xc00005c050 Apple
[1] 0xc00005c060 Orange
[2] 0xc00005c070 CHANGED
[3] 0xc00005c080 Grape
[4] 0xc00005c090 Plum

슬라이스와 참조

길이가 7인 정수형 슬라이스를 선언하자.

x := make([]int, 7)

임의의 값을 넣어준다.

for i := 0; i < 7; i++ {
    x[i] = i * 100
}

슬라이스의 두 번째 원소의 포인터를 변수에 할당한다.

twohundred := &x[1]

슬라이스에 새로운 값을 추가해보자. 이 코드는 위험 하다. x 슬라이스는 길이가 7이고 용량 7이다. 길이와 용량이 같기 때문에 용량이 두 배로 늘어나고 값들이 복사된다. 이제 x 슬라이스는 길이가 8이고 용량이 14이며 다른 메모리 블록을 가르킨다.

x = append(x, 800)

슬라이스의 두 번째 원소의 값을 변경 할 때, twohundred는 변경되지 않는다. 이전의 슬라이스를 가리키기 때문이다. 이 변수를 읽을 때 마다, 잘못된 값을 얻는다.

x[1]++

결과를 출력함으로써, 문제를 확인 할 수 있다.

fmt.Printf("\n=> Slice and reference\n")
fmt.Println("twohundred:", *twohundred, "x[1]:", x[1])
=> Slice and reference
twohundred: 100 x[1]: 101

UTF-8

Go의 모든 것은 UTF-8 문자 집합(chrarcter sets)을 근간으로 한다. 만약 다른 인코딩 구조를 사용한다면 문제가 발생한다.

중국어와 영어로 문자열을 선언하자. 중국 문자는 각각 3 바이트를 사용한다. UTF-8는 바이트, 코드 포인트(code point) 그리고 문자로 3 계층을 이루고 있다. Go 관점에서 문자열은 단지 저장되는 바이트일 뿐이다.

아래 예제에서 첫 번째 3 바이트는 하나의 코드 포인트를 표현한다. 하나의 코드 포인트는 하나의 문자를 표현한다. 1 바이트부터 4 바이트를 가지고 코드 포인트를 표현 할 수 있고(코드 포인트는 32 비트 값이다.) 1 부터 대다수의 코드 포인트는 문자를 표현 할 수 있다. 간단하게, 3 바이트로 1 코드 포인트로 1 문자를 표현한다. 그래서 s 문자열을 3 바이트, 3 바이트, 1 바이트, 1 바이트.. 로 읽는다(앞 부분에 중국 문자가 2개 있고 나머지는 영어이기 때문에)

s := "世界 means world"

UTFMax 상수 4다. – 인코딩 된 룬당 최대 4 바이트 -> 모든 코드 포인트를 표현하기 위해 필요한 최대 바이트 수는 4 바이트다.[재방문] Rune은 자체 타입이다. 이것은 int32 타입의 별칭이다. 우리가 사용하는 byte 타입은 uint8의 별칭이다.

var buf [utf8.UTFMax]byte

위의 문자열을 순회 할 때, 바이트에서 바이트, 코드 포인트에서 코드 포인트, 문자에서 문자로 중 어느 방식으로 순회할까? 정답은 코드 포인트에서 코드 포인트이다. 첫 번째 순회에서 i는 0이다. 그 다음 i는 다음 코드 포인트로 이동되기 때문에 3이다. 그 다음은 6이다.

for i, r := range s {

룬/코드 포인트의 바이트 수를 출력해보자.

    rl := utf8.RuneLen(r)

룬을 표현하는 바이트들의 차이를 계산하자.

    si := i + rl

문자열로부터 룬을 버퍼에 복사하자. 모든 코드 포인트를 순회하며 배열 버퍼에 복사하고 화면에 출력하려는 것이다. Go 에서 “배열은 언제든 슬라이스가 될 준비가 되어있다.” 슬라이싱 구문을 사용하여 buf 배열을 가리키는 슬라이스 헤더를 만든다. 헤더는 스택에 생성되기에 힙에 할당하지 않는다.

    copy(buf[:], s[i:si])

출력해보자.

    fmt.Printf("%2d: %q; codepoint: %#6x; encoded bytes: %#v\n"​, i, r, r, buf[:rl])
0: '世'; codepoint: 0x4e16; encoded bytes: []byte{0xe4, 0xb8, 0x96}
3: '界'; codepoint: 0x754c; encoded bytes: []byte{0xe7, 0x95, 0x8c}
6: ' '; codepoint: 0x20; encoded bytes: []byte{0x20}
7: 'm'; codepoint: 0x6d; encoded bytes: []byte{0x6d}
8: 'e'; codepoint: 0x65; encoded bytes: []byte{0x65}
9: 'a'; codepoint: 0x61; encoded bytes: []byte{0x61}
10: 'n'; codepoint: 0x6e; encoded bytes: []byte{0x6e}
11: 's'; codepoint: 0x73; encoded bytes: []byte{0x73}
12: ' '; codepoint: 0x20; encoded bytes: []byte{0x20}
13: 'w'; codepoint: 0x77; encoded bytes: []byte{0x77}
14: 'o'; codepoint: 0x6f; encoded bytes: []byte{0x6f}
15: 'r'; codepoint: 0x72; encoded bytes: []byte{0x72}
16: 'l'; codepoint: 0x6c; encoded bytes: []byte{0x6c}
17: 'd'; codepoint: 0x64; encoded bytes: []byte{0x64}

맵(Map)

프로그램에서 사용할 user를 정의한다.

type user struct {
    name string
    username string
}

선언과 초기화(Declare and initialize)

string 타입을 키로, user 타입을 값으로 갖는 맵을 선언하고 만든다.

func main() {
    users1 := make(map[string]user)

    // 맵에 키/값 쌍을 추가한다.
    users1["Roy"] = user{"Rob", "Roy"}
    users1["Ford"] = user{"Henry", "Ford"}
    users1["Mouse"] = user{"Mickey", "Mouse"}
    users1["Jackson"] = user{"Michael", "Jackson"}

    // `map`을 순회한다.
    fmt.Printf("\n=> Iterate over map\n")
    for key, value := range users1 {
            fmt.Println(key, value)
}
=> Iterate over map
Roy {Rob Roy}
Ford {Henry Ford}
Mouse {Mickey Mouse}
Jackson {Michael Jackson}

맵 리터럴(Map literals)

초기값을 갖는 맵을 선언하고 초기화한다.

users2 := map[string]user{
    "Roy": {"Rob", "Roy"},
    "Ford": {"Henry", "Ford"},
    "Mouse": {"Mickey", "Mouse"},
    "Jackson": {"Michael", "Jackson"},
}
// 맵을 순회한다.
fmt.Printf("\n=> Map literals\n")
for key, value := range users2 {
    fmt.Println(key, value)
}
=> Map literals
Roy {Rob Roy}
Ford {Henry Ford}
Mouse {Mickey Mouse}
Jackson {Michael Jackson}

키 삭제(Delete key)

delete(users2, "Roy")

키 찾기(Find key)

Roy를 찾아보자. 만약 키 중에 Roy가 존재한다면, 그에 해당하는 값을 가져와 할당한다. 그렇지 않다면 u는 여전히 user 타입의 값을 가지겠지만, 그 값은 제로 값으로 설정된다.

u1, found1 := users2["Roy"]
u2, found2 := users2["Ford"]

값과 키의 존재 여부를 나타낸다.

fmt.Printf("\n=> Find key\n")
fmt.Println("Roy", found1, u1)
fmt.Println("Ford", found2, u2)
=> Find key
Roy false { }
Ford true {Henry Ford}

맵의 키 제한(Map key restrictions)

type users []user

이 구문을 사용하여 users 를 새로 정의할 수 있으며, 이는 users를 정의하는 두 번째 방법이다. 이처럼 이미 존재하는 타입을 통해, 다른 타입의 타입으로 사용할 수 있다. 이 때 두 타입은 서로 연관성이 없다. 하지만 다음의 코드 u := make(map[users]int)와 같이 키로서 사용코자 할 때, 컴파일러는 다음의 오류를 발생시킨다. “맵의 키로써 users 타입은 유효하지 않다.”

그 이유는, 키로 어떤 것을 사용하던지 그 값은 반드시 비교가능해야 하기 때문이다. 맵이 키의 해시 값을 만들 수 있는 지 보여주는 일종의 불리언 표현식을 사용해야한다.

디커플링

메서드(method)

값 리시버와 포인터 리시버를 이용한 호출(Value and Pointer Receiver Call)

type user struct {
    name  string
    email string
}

notify는 값 리시버를 가지는 메서드(method)이다. uuser타입으로, Go에서는 함수가 리시버와 함께 선언된다면 이를 메서드라고 한다. 리시버는 파라미터와 비슷하게 보이지만, 이는 자신만의 역할이 있다. 값 리시버를 사용하면, 메서드는 자신을 호출한 변수를 복사하고, 그 복사본을 가지고 동작한다.

func (u user) notify() {
    fmt.Printf("Sending User Email To %s<%s>\n", u.name, u.email)
}

changeEmail은 포인터 리시버를 가지는 메서드이다: uuser의 포인터 타입으로, 포인터 리시버를 이용하면 메서드를 호출한 변수를 공유하면서 바로 접근이 가능하다.

func (u *user) changeEmail(email string) {
    u.email = email
    fmt.Printf("Changed User Email To %s\n", email)
}

위의 두 메서드들은 값 리시버와 포인터 리시버의 차이를 이해하기 위해서 같이 사용되었다. 하지만 실제 개발에서는 하나의 리시버를 사용하는 것을 권장한다. 이에 대해서는 나중에 다시 살펴볼 것이다.

값 리시버와 포인터 리시버를 이용한 호출

user 타입의 변수는 값 리시버와 포인터 리시버를 사용하는 모든 메서드를 호출할 수 있다.

bill := user{"Bill", "bill@email.com"}
bill.notify()
bill.changeEmail("bill@hotmail.com")
Sending User Email To Bill<bill@email.com>
Changed User Email To bill@hotmail.com

user의 포인터 타입 변수 역시 값 리시버와 포인터 리시버를 사용하는 모든 메서드를 호출할 수 있다.

hoanh := &user{"Hoanh", "hoanhan@email.com"}
hoanh.notify()
hoanh.changeEmail("hoanhan101@gmail.com")
Sending User Email To Hoanh<hoanhan@email.com>
Changed User Email To hoanhan101@gmail.com

이 예제에서 hoanhuser 타입을 가리키는 포인터 변수이다. 하지만 값 리시버로 구현된 notify를 호출할 수 있다. user 타입의 변수로 메서드를 호출하지만, Go는 내부적으로 이를 (*hoanh).notify()로 호출한다. Go는 hoanh이 가리키는 값을 찾고, 이 값을 복사하여 notify를 값에 의한 호출(value semantic)이 가능하도록 한다. 이와 유사하게 billuser 타입의 변수이지만, 포인터 리시버로 구현된 changeEmail을 호출할 수 있다. Go는 bill의 주소를 찾고, 내부적으로 (&bill).changeEmail()을 호출한다.

두 개의 user 타입 원소를 가진 슬라이스를 만들자.

users := []user{
    {"bill", "bill@email.com"},
    {"hoanh", "hoanh@email.com"},
}

이 슬라이스에 for ... range를 사용하면, 각 원소의 복사본을 만들고, notify 호출을 위해 또 다른 복사본을 만들게 된다.

for _, u := range users {
    u.notify()
}
Sending User Email To bill<bill@email.com>
Sending User Email To Hoanh<hoanhan@email.com>

포인터 리시버를 사용하는 changeEmailfor ... range 안에서 사용해보자. 이는 복사본의 값을 변경하는 것으로 이렇게 사용하면 안 된다.

for _, u := range users {
    u.changeEmail("it@wontmatter.com")
}
Changed User Email To it@wontmatter.com
Changed User Email To it@wontmatter.com

값에 의한 호출과 참조에 의한 호출(Value and Pointer Semantics)

숫자, 문자열, 불과 같은 기본 타입을 사용하는 경우, 값에 의한 호출을 사용하는 것을 권장한다. 만약 정수 또는 불 타입 변수의 주소 값을 사용해야 한다면 주의해야 한다. 상황에 따라, 이 방법이 맞을 수도 있고, 아닐 수도 있기 때문이다. 하지만 일반적으로, 메모리 누수의 위험이 있는 힙 메모리 영역에 이 변수들을 만들 필요는 없다. 그래서 이 타입의 변수들은 stack에 생성하는 것을 더 권장한다. 모든 것에는 예외가 있을 수 있지만, 그 예외를 적용하는 것이 적합하다고 판단하기 전에는 규칙을 따를 필요가 있다.

슬라이스, 맵, 채널, 인터페이스와 같이 참조 타입의 변수들 역시 기본적으로 값에 의한 호출을 사용하는 것을 권장한다. 다만 변수의 주소 값을 파라미터로 받는 Unmarshal같은 함수를 사용하기 위한 경우라면, 이 타입들의 주소 값을 사용해야 한다.

아래 예제들은 실제 Go의 표준 라이브러리에서 사용하는 코드들이다. 이들을 공부해보면, 값에 의한 호출과 참조에 의한 호출(pointer semantic) 중 하나를 일관되게 사용하는 것이 얼마나 중요한지 알 수 있다. 따라서 변수의 타입을 정할 때, 다음의 질문에 스스로 답해보자.

가장 중요한 것은 일관성이다. 처음에 한 결정이 잘못되었다고 판단되면, 그때 이를 변경하면 된다.

package main

import (
    "sync/atomic"
    "syscall"
)

값에 의한 호출

Go의 net 패키지는 IPIPMask 타입을 제공하는데, 이들은 바이트 형 슬라이스이다. 아래의 예제들은 이 참조 타입들을 값에 의한 호출을 통해 사용하는 것을 보여준다.

type IP []byte
type IPMask []byte

MaskIP 타입의 값 리시버를 사용하며, IP 타입의 값을 반환한다. 이 메서드는 IP 타입에 대해서 값에 의한 호출을 사용하는 것이다.

func (ip IP) Mask(mask IPMask) IP {
    if len(mask) == IPv6len && len(ip) == IPv4len && allFF(mask[:12]) {
        mask = mask[12:]
    }
    if len(mask) == IPv4len && len(ip) == IPv6len &&
        bytesEqual(ip[:12], v4InV6Prefix) {
        ip = ip[12:]
    }
    n := len(ip)
    if n != len(mask) {
        return nil
    }
    out := make(IP, n)
    for i := 0; i < n; i++ {
        out[i] = ip[i] & mask[i]
    }
    return out
}

ipEmptyStringIP 타입의 값을 파라미터로 받고, 문자열 타입의 값을 반환한다. 이 함수는 IP 타입에 대해서 값에 의한 호출을 사용하는 것이다.

func ipEmptyString(ip IP) string {
    if len(ip) == 0 {
        return ""
    }
    return ip.String()
}

참조에 의한 호출

Time 타입은 값에 의한 호출과 참조에 의한 호출 중 어떤 것을 사용해야 할까? 만약 Time 타입 변수의 값을 변경해야 한다면, 이 값을 직접 변경해야 할까? 아니면 복사본을 만들어서 값을 변경하는 것이 좋을까?

type Time struct {
    sec  int64
    nsec int32
    loc  *Location
}

타입에 대해 어떤 호출을 사용할지를 결정하는 가장 좋은 방법은 타입의 생성 함수를 확인하는 것이다. 이 생성 함수는 어떤 호출을 사용해야 하는지 알려준다. 이 예제에서, Now 함수는 Time 타입의 값을 반환한다. Time 타입의 값은 한번 복사가 이루어지고, 이 복사된 값은 이 함수를 호출한 곳으로 반환된다. 즉, 이 Time 타입의 값은 스택(stack)에 저장된다. 따라서 값에 의한 호출을 사용하는 것이 좋다.

func Now() Time {
    sec, nsec := now()
    return Time{sec + unixToInternal, nsec, Local}
}

Add는 기존의 Time 타입의 값과는 다른 값을 얻기 위한 메서드다. 만약 값을 변경할 때는 무조건 참조에 의한 호출을 사용하고, 그렇지 않을 때는 값에 의한 호출을 사용해야 한다면 이 Add의 구현은 잘못되었다고 생각할 수 있다. 하지만, 타입은 어떤 호출을 사용할지를 책임지는 것이지, 메서드의 구현을 책임지는 것은 아니다. 메서드는 반드시 선택된 호출을 따라야 하고, 그래서 Add의 구현은 틀리지 않았다.

Add는 값 리시버를 사용하고, Time 타입의 값을 반환한다. 즉, 이 메서드는 실제로 Time 타입 변수의 복사본을 변경하며, 완전히 새로운 값을 반환하는 것이다.

func (t Time) Add(d Duration) Time {
    t.sec += int64(d / 1e9)
    nsec := int32(t.nsec) + int32(d%1e9)
    if nsec >= 1e9 {
        t.sec++
        nsec -= 1e9
    } else if nsec <  0  {
        t.sec--
        nsec += 1e9
    }
    t.nsec = nsec
    return t
}

divTime 타입의 파라미터를 받고, 기본 타입의 값들을 반환한다. 이 함수는 Time 타입에 대해 값에 의한 호출을 사용하는 것이다.

func div(t Time, d Duration) (qmod2 int, r Duration) {}

Time 타입에 대한 참조에 의한 호출은, 오직 주어진 데이터를 Time 타입으로 변환해 이 메서드를 호출한 변수를 수정할 할 때만 사용한다:

func (t *Time) UnmarshalBinary(data []byte) error {}
func (t *Time) GobDecode(data []byte) error {}
func (t *Time) UnmarshalJSON(data []byte) error {}
func (t *Time) UnmarshalText(data []byte) error {}

대부분의 구조체(struct) 타입들은 값에 의한 호출을 잘 사용하지 않는다. 이는 다른 코드에서 함께 공유하거나 또는 공유하면 좋은 데이터들이기 때문이다. User 타입이 대표적인 예이다. User 타입의 변수를 복사하는 것은 가능은 하지만, 이는 실제로 좋은 구현이 아니다.

다른 예제를 살펴보자:

앞서 이야기했듯이, 생성 함수는 어떤 호출을 사용해야 하는지를 알려준다. Open 함수는 File 타입 데이터의 주소 값, 즉 File 타입 포인터를 반환한다. 이는 File 타입에 대해, 참조에 의한 호출을 사용해서 이 File 타입의 값을 공유할 수 있다는 것을 뜻한다.

func Open(name string) (file *File, err error) {
    return OpenFile(name, O_RDONLY, 0)
}

ChdirFile 타입의 포인터 리시버를 사용한다. 즉, 이 메서드는 File 타입에 대해 참조에 의한 호출을 사용하는 것이다.

func (f *File) Chdir() error {
    if f == nil {
        return ErrInvalid
    }
    if e := syscall.Fchdir(f.fd); e != nil {
        return &PathError{"chdir", f.name, e}
    }
    return nil
}

epipecheckFile 타입의 포인터를 파라미터로 받는다. 따라서 이 함수는 File 타입에 대해 참조에 의한 호출을 사용하는 것이다.

func epipecheck(file *File, e error) {
    if e == syscall.EPIPE {
        if atomic.AddInt32(&file.nepipe, 1) >= 10 {
            sigpipe()
        }
    } else {
        atomic.StoreInt32(&file.nepipe, 0)
    }
}

메서드는 단지 함수일 뿐이다

메서드는 특수한 기능이 아니라 문법적인 필요에 의해 만들어진 것이다. 메서드는 데이터와 관련된 일부 기능을 외부에서 사용할 수 있는 것처럼 믿게 만든다. 객체지향 프로그래밍에서도 이러한 설계와 기능을 권장한다. 하지만 Go가 객체지향 프로그래밍를 추구하는 것은 아니다. 다만 데이터와 동작이 필요하기 때문에 이들이 있는 것이다.[재방문]

어떤 때는 데이터가 일부 기능을 노출할 수 있지만 특정 목적을 위해 API를 설계하는 것은 아니다. 메서드는 함수와 거의 동일하다.[재방문]

type​ data ​struct​ {
    name ​string
    age ​ int
}

displayNamedata타입 d 변수의 name을 포함한 문자열을 출력한다. 이 메서드는 data를 값 리시버로 사용한다.

func (d data) displayName() {
    fmt.Println("My Name Is", d.name)
}

setAgedata타입 dage를 수정하고, 이 값을 name과 함께 출력한다. 이 메서드는 data를 포인터 리시버로 사용한다.

func (d *data) setAge(age int) {
    d.age = age
    fmt.Println(d.name, "Is Age", d.age)
}

메서드는 단지 함수일 뿐이다

data 타입의 변수를 선언해보자.

d := data{
    name: "Hoanh",
}
fmt.Println("Proper Calls to Methods:")

다음과 같이 메서드를 호출할 수 있다.

d.displayName()
d.setAge(21)

fmt.Println("\nWhat the Compiler is Doing:")

다음 예제를 통해 Go가 내부적으로 어떻게 동작하는지 알 수 있다. d.displayName()을 호출하면, 컴파일러는 data.displayName을 먼저 호출해서 data 타입의 값 리시버를 사용하는 것을 보여주고, d를 첫번째 파라미터로 전달한다. func (d data) displayName()을 다시 자세히 보면, 리시버가 정말 파라미터임을 알 수 있다. 즉, 이 리시버는 displayName의 첫번째 파라미터이다.

d.setAge(21) 역시 이와 비슷하다. Go는 포인터 리시버를 사용하는 함수를 호출하고, d를 함수의 파라미터로 넘긴다. 추가로 d의 주소 값을 이용하기 위하여 약간의 조정이 필요하다.

data.displayName(d)
(*data).setAge(&d, 21)
Proper Calls to Methods:
My Name Is Hoanh
Hoanh Is Age 21

What the Compiler is Doing:
My Name Is Hoanh
Hoanh Is Age 21

함수형 변수

fmt.Println("\nCall Value Receiver Methods with Variable:")

함수형 변수를 선언하고, 이 변수에 d 변수의 메서드를 설정해보자. 이 메서드, displayName,은 값 리시버를 사용하기 때문에, 함수형 변수 f1d의 독립적인 복사본을 가진다. 함수형 변수 f1은 참조 타입으로 포인터, 즉 주소 값을 저장한다. displayName의 뒤에 ()을 붙이지 않았으므로, 이 메서드의 반환 값을 저장한 것이 아니다.

f1 := d.displayName

이 변수를 이용해서 메서드를 호출해보자.

f1은 포인터로, 이는 2개의 워드를 가지는 특별한 자료 구조를 가리킨다. 첫 번째 워드는 실행 대상인 메서드를 가리키는데, 이 예제에서는 displayName이다. 이 displayName는 값 리시버를 사용하므로 실행하기 위해서는 data 타입의 값이 필요하다. 따라서 두 번째 워드는 이 값의 복사본을 가리킨다. displayNamef1에 저장을 하면, 자동으로 d의 복사본이 만들어진다.

만약 d의 멤버 변수인 name의 값을 “Hoanh An”으로 변경하더라도, f1에는 이 변경이 적용되지 않는다.

d.name = "Hoanh An"

f1을 통해서 메서드를 호출해보자. 앞서 이야기했듯이, 결과는 변하지 않았다.

f1()
Call Value Receiver Methods with Variable:
My Name Is Hoanh
My Name Is Hoanh

하지만 setAge 메서드를 저장한 f2d의 값이 변하면 그 자신의 결과도 변한다.

fmt.Println("\nCall Pointer Receiver Method with Variable:")

함수형 변수를 선언하고, 이 변수에 d 변수의 메서드를 설정해보자. 이 메서드, setAge는 포인터 리시버를 사용하므로, 이 함수형 변수는 d의 주소 값을 가지게 된다.

f2 := d.setAge
d.name = "Hoanh An Dinh"

이 함수형 변수를 이용해서 메서드를 호출해보자. f2는 역시 포인터이고, 2개의 워드를 가지는 자료 구조를 가리킨다. 첫 번째 워드는 setAge 메서드를 가리키지만, 두 번째 워드는 더 이상 복사본이 아니라 원본 d를 가리킨다.

f2(21)
Call Pointer Receiver Method with Variable:
Hoanh An Dinh Is Age 21

인터페이스

값이 없는 타입

reader는 데이터를 읽는 동작을 정의하는 인터페이스이다. 인터페이스는 엄밀히 말해서 값이 없는 타입이다. 이 인터페이스는 어떠한 멤버 변수도 가지지 않으며, 오직 행동에 대한 계약만을 정의한다. Go에서는 이 행동에 대한 계약을 통해서, 다형성을 활용할 수 있다. 인터페이스는 두 개의 워드로 이루어진 자료 구조로, 두 워드 모두 포인터이다. 인터페이스는 참조 타입으로, var r reader구문은 nil 값 가지는 인터페이스 r을 만든다.

type reader interface {
    read(b []byte) (int, error) // (1)
}

read 메서드를 다르게 정의할 수도 있다. 예를 들어 read(i int) ([]byte, error) // (2)처럼, 파라미터로 읽을 바이트 수를 받고, 읽은 데이터를 슬라이스에 담아 에러와 함께 반환할 수도 있다.

그럼 왜 (1)을 선택한 것일까?

(2)는 매번 호출할 때마다, 반환할 슬라이스를 메서드 내부에서 만들어야 한다. 이때 슬라이스를 위한 배열을 힙 메모리에 할당하는 비용이 발생한다. 하지만 (1)의 경우, 이 메서드를 호출하는 쪽에서 슬라이스를 만들 책임이 있다. 따라서 슬라이스를 위한 한 번의 메모리 할당은 피할 수 없지만, 반복적인 메서드 호출에 대해 추가적인 메모리 할당은 발생하지 않는다.

구체적 타입 vs 인터페이스 타입

구체적 타입(concrete type)이란 메서드를 가질 수 있는 모든 타입을 말한다. 오직 사용자 정의 타입만이 메서드를 가질 수 있다.

메서드는 데이터가 인터페이스를 기반으로 기능을 외부에서 사용할 수 있도록 해준다. file은 시스템 파일을 위한 구조체이다.

type file struct {
    name string
}

이는 구체적 타입으로, 이후에 설명할 read 메서드를 가지고 있다. 이는 reader 인터페이스에서 선언한 메서드와 동일하다. 따라서, 구체적 타입인 filereader 인터페이스를 값 리시버를 이용해서 구현(implement) 한 것이다.

구현을 위해서 특별한 문법이 존재하는 것은 아니다. 다만 컴파일러가 자동으로 구현임을 인지한다.

관계

인터페이스 변수에는 구체적 타입의 값을 저장할 수 있다.

readfile 타입에 대하여 reader 인터페이스를 구현한다.

func (file) read(b []byte) (int, error) {
    s := "<rss><channel><title>Going Go Programming</title></channel></rss>"
    copy(b, s)
    return len(s), nil
}

pipe는 이름 있는 파이프 네트워크 연결을 위한 구조체이다. 이는 두 번째 구체적 타입으로, 값 리시버를 사용한다. 두 구체적 타입 모두 reader 인터페이스의 행동에 대한 계약을 구현하고 이들을 외부에 제공한다.

type pipe struct {
    name string
}

read는 네트워크 연결을 위한 reader 인터페이스를 구현한다.

func (pipe) read(b []byte) (int, error) {
    s := `{name: "hoanh", title: "developer"}`
    copy(b, s)
    return len(s), nil
}

filepipe 두 타입의 변수를 다음과 같이 만들어보자.

f := file{"data.json"}
p := pipe{"cfg_service"}

이 변수들을 파라미터로 retrieve 함수를 호출해보자. 이 함수는 값을 파라미터로 받으므로, f의 복사본이 함수에서 사용된다.

컴파일러는 다음의 질문을 하게 된다: 이 변수의 타입 filereader 인터페이스를 구현한 것인가? file 타입은 reader 인터페이스의 행동에 대한 계약을 값 리시버를 이용하여 구현하였으므로, 이 질문에 대한 답은 “Yes”이다. 이 예제에서, 인터페이스의 두 번째 워드는 f의 복사본을 가리킨다. 그리고 첫 번째 워드는 iTable이라는 특별한 자료 구조를 가리킨다.

iTable은 다음의 두 가지를 제공한다:

iTable for file

인터페이스를 통해서 read를 호출하면, iTable을 확인해서 이 타입에 맞는 read 함수를 찾고 이를 호출한다. 결과적으로 구체적 타입의 read 메서드를 호출하는 것이다.

retrieve(f)

p도 이와 동일하다. reader 인터페이스의 첫 번째 워드는 pipe 타입을 가리키고, 두번째 워드는 p의 복사본을 가리킨다.

iTable for pipe

데이터가 변경되었기 때문에, 동작도 다르다.

retrieve(p)

앞으로 iTable와 이를 가리키는 포인터를 간략하게 *pipe처럼 나타낼 것이다.

simple representation

다형성 함수

retrieve는 어떤 장치든 다 읽을 수 있고, 어떤 데이터든지 다 처리할 수 있다. 이러한 함수를 다형성 함수라 한다. 이 예제에서 사용된 파라미터는 reader 타입이다. 하지만 이는 인터페이스이므로 값이 없는 타입니다. 즉, 이 함수는 reader 인터페이스의 계약을 구현한 모든 타입을 다 파라미터로 받을 수 있다. 이 함수는 구체적 타입에 대해서는 전혀 알지 못하므로, 이는 완전히 디커플링 되어 있다. 이는 Go에서 할 수 있는 최상위의 디커플링이다. 이러한 구현 방법은 간결하고 효율적이다. 이 방법을 사용하기 위해 필요한 것은 오직 인터페이스를 통한 구체적 타입 데이터로의 간접적 접근뿐이다.[재방문]

func retrieve(r reader) error {
    data := make([]byte, 100)

    len, err := r.read(data)
    if err != nil {
        return err
    }

    fmt.Println(string(data[:len]))
    return nil
}
<rss><channel><title>Going Go Programming</title></channel></rss>
{name: "hoanh", title: "developer"}

포인터 리시버를 이용한 인터페이스

notifier는 인터페이스로 알림을 보내는 타입들의 동작을 정의한다.

type notifier interface {
    notify()
}

printer 역시 인터페이스로, 어떤 정보를 출력하는 동작을 정의한다.

type printer interface {
    print()
}

user는 프로그램 내의 사용자 정보를 담을 타입을 정의한다.

type user struct {
    name  string
    email string
}

printuser 타입의 name과 email 정보를 출력한다.

func (u user) print() {
    fmt.Printf("My name is %s and my email is %s\n", u.name, u.email)
}

notify는 포인터 리시버를 사용해서 notifier 인터페이스를 구현한다.

func (u *user) notify() {
    fmt.Printf("Sending User Email To %s<%s>\n", u.name, u.email)
}

Stringfmt.Stringer 인터페이스를 구현한다. fmt는 지금까지 화면에 정보를 출력하기 위해서 사용한 패키지로, String을 구현하는 데이터가 주어지면 기존 동작이 아닌 새롭게 구현된 동작을 수행한다. 참조에 의한 호출을 사용하기 때문에, 포인터만이 이 인터페이스를 만족한다.

func (u *user) String() string {
    return fmt.Sprintf("My name is %q and my email is %q", u.name, u.email)
}

user 타입의 데이터를 만들어보자.

u := user{"Hoanh", "hoanhan@email.com"}

참조에 의한 호출로 u를 전달하는 다형성 함수를 호출하자: sendNotification(u). 하지만 컴파일러는 이 호출을 허락하지 않고 다음의 에러 메시지를 보여준다: “cannot use u (type user) as type notifier in argument to sendNotification: user does not implement notifier (notify method has pointer receiver)” 이는 무결성을 위반해서 발생한 문제이다.

메서드 집합

메서드 집합의 개념과 관련하여 몇 가지 규칙이 존재한다. 앞서 발생한 에러는 이 규칙을 위반한 것이다.

다음은 그 규칙들이다.

즉, 어떤 타입의 포인터를 사용한다면 선언된 모든 메서드들은 모두 이 포인터를 통해 사용이 가능하다. 만약 어떤 타입의 값을 사용한다면 값에 의한 호출을 사용하는 메서드들만이 사용이 가능하다.

앞서 메서드를 공부할 때, 둘 중 어떤 호출을 사용하든 이러한 문제가 발생하지 않았다. 이는 메서드를 구체적 타입의 값을 통해 호출하였기 때문에 Go가 내부적으로 정상 동작이 가능하게끔 하였다. 하지만 여기서는 인터페이스에 구체적 타입의 값을 저장하고 이 인터페이스를 통해서 메서드를 호출하려고 하였다. 하지만 해당 메서드가 포인터 리시버를 사용하기 때문에 계약을 만족시키지 못하는 것이다.

왜 포인터 리시버로는 값을 통해 호출되는 메서드 집합을 사용할 수 없을까? 타입 T의 값에 대해 참조에 의한 호출을 허락하지 않는 무결성 문제란 무엇인가?

인터페이스를 만족하는 모든 값이 주소를 가진다고 100% 보장할 수 없다. 만약 어떤 값이 주소를 가지고 있지 않다면, 이는 공유할 수 없으므로 포인터 리시버를 사용할 수 없다. 다음의 예를 살펴보자.

int와 동일한 duration이라는 타입을 선언하자.

type duration int

포인터 리시버를 사용해서 durationnotify 메서드를 정의하자. 이제 이 타입은 포인터 리시버를 통해 notifier 인터페이스를 구현한 것이다.

func (d *duration) notify() {
    fmt.Println("Sending Notification in", *d)
}

42를 받아서, 이를 duration 타입으로 변경 후 notify 메서드를 호출해보자. 이때, 컴파일러는 다음과 같은 에러 메시지를 보여준다.

duration(42).notify()

주소 값을 얻을 수 없는 이유는 42가 변수에 저장된 값이 아니기 때문이다. 이는 여전히 리터럴 값으로 타입을 알 수 없다. 하지만 duration은 여전히 notifier 인터페이스를 구현한다.

다시 본 예제로 돌아와서, 이러한 에러가 발생했다는 것은 값에 의한 호출과 참조에 의한 호출이 혼용되었음을 알 수 있다. u는 포인터 리시버를 통해서 notifier 인터페이스를 구현하였지만, u의 복사본을 통해 해당 메서드를 호출하려고 한 것이다. 이는 일관적이지 못하다.

교훈

포인터 리시버를 이용해서 인터페이스를 구현한다면, 반드시 참조에 의한 호출을 사용해야 한다. 만약 값 리시버를 사용해서 인터페이스를 구현한다면, 값에 의한 호출과 참조에 의한 호출 모두를 사용할 수 있다. 하지만, 일관성을 유지하기 위해서 이 경우에도 값에 의한 호출을 사용하는 것을 권장한다. 다만 Unmarshal과 같은 함수를 위해서는 참조에 의한 호출을 사용해야만 할 수도 있다.

이 문제를 해결하기 위해서는, u 대신 u의 주소 값(&u)를 전달해야 한다. user의 값을 만들고 이 값의 주소 값을 전달하면, 인터페이스는 user 타입의 주소 값을 가지게 되고 원본 값을 가리킬 수 있게 된다.

pointer of u
sendNotification(&u)

sendNotification은 다형성 함수이다. 이 함수는 notifier 인터페이스를 구현하는 타입의 값을 받아 알림을 보낸다. 이 함수는 다음과 같이 말하는 것이다: 나는 notifier 인터페이스를 구현하는 타입의 값 또는 포인터를 받을 것이다. 그리고 나는 인터페이스를 통해서 동작을 호출할 것이다.

func sendNotification(n notifier) {
    n.notify()
}

이와 유사하게, u의 값을 Println에게 전달하면, 기존 형태의 출력을 보게 된다. 하지만 만약 u의 주소 값을 전달하면, 앞서 정의한 String으로 기존 동작을 덮어써서 새로운 형태의 출력을 사용하게 된다.

fmt.Println(u)
fmt.Println(&u)
Sending User Email To Hoanh<hoanhan@email.com>
{Hoanh hoanhan@email.com}
My name is "Hoanh" and my email is "hoanhan@email.com"

인터페이스 타입의 슬라이스

인터페이스를 저장할 수 있는 슬라이스를 만들자. 이 슬라이스에는 printer 인터페이스를 구현하는 모든 타입의 값이나 포인터를 저장할 수 있다.

value and pointer
entities := []printer{

이 슬라이스에 값을 저장하면, 인터페이스의 값은 복사본을 가지게 된다. 따라서 원본 데이터에 변경이 발생하더라도, 이를 확인할 수는 없다.

    u,

슬라이스에 포인터를 저장하면, 인터페이스의 값은 원본 데이터를 가리키는 주소 값을 복사하고 이를 가지게 된다. 따라서 원본 데이터의 변경을 확인할 수 있다.

    &u,
}

unameemail을 변경해보자.

u.name = "Hoanh An"
u.email = "hoanhan101@gmail.com"

슬라이스를 순회하면서 복사된 인터페이스의 값을 통해 print를 호출해보자.

for _, e := range entities {
    e.print()
}
My name is Hoanh and my email is hoanhan@email.com
My name is Hoanh An and my email is hoanhan101@gmail.com

55-70

임베딩

임베딩이 아닌 필드로써 선언

프로그램에서 사용할 user 를 정의한다.

type user struct {
    name  string
    email string
}

이벤트가 발생했음을 사용자들에게 알려주는 기능을 하는 notify 메서드를 구현한다.

func (u *user) notify() {
    fmt.Printf("Sending user email To %s<%s>\n", u.name, u.email)
}

admin 은 특정 권한을 가진 관리자를 의미하는데, person user 는 임베딩이 아니다. 단지 person 이라는 필드를 user 라는 타입으로 정의하고 생성한 것이다.

type admin struct {
    person user // 임베딩이 아니다.
    level  string
}

구조체 리터럴로 admin 사용자를 생성한다. admin 을 구성하는 person 필드 또한 구조체 타입이기 때문에 초기화를 위해 또 다른 리터럴을 사용하였다.

func main() {
    ad := admin{
        person: user{
            name:  "Hoanh An",
            email: "hoanhan101@gmail.com",
        },
        level: "superuser",
    }

admin 타입의 값은 person 필드를 이용해 notify 를 호출할 수 있다.

    ad.person.notify()
Sending user email To Hoanh An<hoanhan101@gmail.com>

임베딩 타입

프로그램에서 사용할 user 를 정의한다.

type user struct {
    name  string
    email string
}

이벤트가 발생했음을 사용자들에게 알려주는 기능을 하는 notify 메서드를 구현한다.

func (u *user) notify() {
    fmt.Printf("Sending user email To %s<%s>\n", u.name, u.email)
}

admin 은 특정 권한을 가진 관리자이다. 이번에는 person 필드를 사용하지 않고, admin 타입 내부에 user 타입의 값을 임베딩 해본다. admin 은 외부 타입(outer type)이 되고, user 는 내부 타입(inner type)이 되는 inner-type-outer-type 관계이다.

내부 타입 승격(Inner type promotion)

Go 의 임베딩은 내부 타입 승격 이라는 특별한 메커니즘을 가지고 있다. 이 메커니즘은 내부 타입과 관련된 모든 것들이 외부 타입에서도 사용할 수 있도록 승격된다는 것을 의미한다. 즉, 아래와 같이 구성하면 내부 타입인 user와 관련해서 더 많은 의미를 내포할 수 있게 된다.

type admin struct {
    user  // 임베딩 타입
    level string
}

외부 타입인 admin 과 내부 타입인 user 를 생성해보자. 내부 타입 값인 user 가 필드처럼 보이지만 필드가 아니다. 필드처럼 타입명을 통해 내부 값에 접근할 수는 있다. user 의 구조체 또한 리터럴을 통해 내부 값을 초기화 할 수 있다.

func main() {
    ad := admin{
        user: user{
            name:  "Hoanh An",
            email: "hoanhan101@gmail.com",
        },
        level: "superuser",
    }

    // 내부 타입 메서드를 직접 사용 가능하다.
    ad.user.notify()
    ad.notify()
}

내부 타입 승격으로 외부 타입에서 notify 메서드를 바로 사용할 수 있고, 결과 역시 같다.

Sending user email To Hoanh An<hoanhan101@gmail.com>
Sending user email To Hoanh An<hoanhan101@gmail.com>

임베디드 타입과 인터페이스

notifier 인터페이스는 알림에 대한 행동(behavior)을 정의하고 있다.

type notifier interface {
    notify()
}

프로그램에서 사용할 user 를 정의한다.

type user struct {
    name  string
    email string
}

이벤트가 발생했음을 사용자들에게 알려주는 기능을 하는 notify 메서드를 포인터 리시버를 통해 구현한다.

func (u *user) notify() {
    fmt.Printf("Sending user email To %s<%s>\n", u.name, u.email)
}

admin 은 특정 권한을 가진 관리자를 의미한다.

type admin struct {
    user
    level string
}

func main() {
    // admin user 를 만든다.
    ad := admin{
        user: user{
            name:  "Hoanh An",
            email: "hoanhan101@gmail.com",
        },
        level: "superuser",
    }

관리자에게 알림을 보내보자.

내부 타입 승격에 의해 외부 타입에서도 내부 타입에서 사용하는 것과 같은 계약(contract)이 구현되어 있다면 이를 사용할 수 있기 때문에 단순히 외부 타입값의 주소만을 함수에 전달해주면 된다.

    sendNotification(&ad)

임베딩은 서브 타입 관계를 생성하지는 않는다. user는 여전히 user일 뿐이고 admin은 여전히 admin이다. 외부 타입이 사용할 수 있도록 내부 타입에서 사용하는 행동을 노출시켜 줄 뿐이다. 외부 타입에서도 내부 타입과 같은 인터페이스 혹은 같은 계약을 구현할 수 있다는 것이다.

이를 구현하기 위해 타입을 재사용할 수 있으며 이는 상태(state)를 혼합하거나 공유하지 않고 행동을 외부 타입까지 확장할 수 있게 된다.

아래처럼 sendNotificationnotifier 의 구현체를 받아서 알람을 보내는 다형성 함수이다.

func sendNotification(n notifier) {
    n.notify()
}
Sending user email To Hoanh An<hoanhan101@gmail.com>

동일한 인터페이스를 구현한 외부 타입 및 내부 타입

notifier 인터페이스는 알림에 대한 행동(behavior)을 정의하고 있다.

type notifier interface {
    notify()
}

프로그램에서 사용할 user 를 정의한다.

type user struct {
    name  string
    email string
}

이벤트가 발생했음을 사용자들에게 알려주는 기능을 하는 notify 메서드를 구현한다.

func (u *user) notify() {
    fmt.Printf("Sending user email To %s<%s>\n", u.name, u.email)
}

admin 은 특정 권한을 가진 관리자를 의미한다.

type admin struct {
    user
    level string
}

아래의 notify 메서드는 유저가 아닌 관리자에게 특정 이벤트를 알려준다. 이제 두 가지의 notifier 인터페이스를 구현하였다. 하나는 내부 타입, 다른 하나는 외부 타입으로 구현하였다. 외부 타입에서 인터페이스를 구현하면 내부 타입 승격 메커니즘이 발생하지는 않는다. 내부 타입에 의해서 승격된 것들을 외부 타입이 덮어쓰는 것이다.

func (a *admin) notify() {
    fmt.Printf("Sending admin email To %s<%s>\n", a.name, a.email)
}

admin 사용자를 만들어보자.

func main() {
    ad := admin{
        user: user{
            name:  "Hoanh An",
            email: "hoanhan101@gmail.com",
        },
        level: "superuser",
    }

알람을 관리자에게 전송해보자. 내부 타입에서 구현한 인터페이스 구현체는 외부타입으로 승격되지 않는다.

    sendNotification(&ad)

내부 타입으로 직접적으로 접근해서 메서드를 사용할 수는 있다.

    ad.user.notify()

하지만 아래 처럼 사용했을 경우에는 내부 타입 승격이 일어나지 않고, 외부 타입에서 구현한 notify 를 사용한다.

   ad.notify()
Sending admin email To Hoanh An<hoanhan101@gmail.com>
Sending user email To Hoanh An<hoanhan101@gmail.com>
Sending admin email To Hoanh An<hoanhan101@gmail.com>

내보내기(Exporting)

가이드라인

패키지는 자체적으로 사용 가능한 코드의 단위다. 패키지에 속해있는 모든 것들은 다른 패키지들에서 접근할 수 있도록 내보낸(exported) 형태로 만들어져 있거나 다른 패키지들에서 접근할 수 없도록 내보내지 않은 형태로 만들어져 있다.

내보내기 식별자(Exported identifier)

counters 패키지는 경고 카운터에 관한 기능을 제공한다.

package counters

AlertCounter 는 내보낸(exported) 형태의 정수형 카운터 알람이다. 첫번째 단어가 대문자인 경우 이는 내보낸 형태라 정의할 수 있다.

type AlertCounter int

alertCounter 는 내보내지 않은(unexported) 형태의 정수형 카운터 알람이다. 첫번째 단어가 소문자인 경우 이는 내보내지 않은 형태라 정의할 수 있다. 카운터 패키지 내부가 아닌 다른 패키지에서 접근한 경우 alertCounter 에 접근할 수 없다.

type alertCounter int

아래처럼 main 패키지에서 counters 패키지를 불러온다.

package main

import (
    "fmt"

    "github.com/hoanhan101/counters"
)

내보낸 형태의 변수를 생성하고 10 으로 초기화 할 수 있다.

counter := counters.AlertCounter(10)

아래처럼 내보내지 않은 형태의 변수를 생성하고 10으로 초기화 할 수 없다.

counter := counters.alertCounter(10)

만약 내보내지 않은 형태의 변수를 사용한다면 아래와 같은 컴파일 에러가 발생하게 된다.

내보내지 않은 식별자 값에 접근

counters 패키지는 경고 카운터에 관한 기능을 제공한다.

package counters

``

alertCounter 는 내보내지 않은(unexported) 형태의 정수형 카운터 알람이다.

type alertCounter int

내보내지 않은 형태의 값을 초기화하고 생성하는 역할을 하는 New 함수를 통해 내보내기 함수를 선언할 수 있다. 아래의 New 함수는 내보내지 않은 형태의 alertCounter 값을 반환하고 있다.

func New(value int) alertCounter {
    return alertCounter(value)
}

내보내기 혹은 내보내지 않기는 비공개, 공개 메커니즘과 같은 것이 아니라 식별자 그 자체이기 때문에 위와 같이 사용했을 경우에 코드는 컴파일 될 수 있다. 하지만 이 방식은 캡슐화를 사용하지 않기 때문에 이런 방식으로 사용하지 말고 내보내기 방식을 사용하는 것이 낫다.

내보내지 않은 식별자 값에 접근해보자.

package main

import (
    "fmt"

    "github.com/hoanhan101/counters"
)

counters 패키지에서 내보내고 있는 New 함수를 통해 내보내지 않은 형태의 변수를 생성한다.

func main() {
    counter := counters.New(10)
    fmt.Printf("Counter: %d\n", counter)
}
Counter: 10

내보낸 형태 구조체 내의 내보내지 않은 필드

users 패키지는 사용자 관리에 관한 기능을 제공한다.

package users

내보낸 형태의 User 는 사용자에 대한 정보를 의미한다. UserNameID 의 2개의 내보낸 필드와 password 의 내보내지 않은 1개의 필드를 정의하고 있다.

type User struct {
    Name string
    ID   int

    password string
}
package main

import (
    "fmt"

    "github.com/hoanhan101/users"
)

구조체 리터럴로 users 패키지에 있는 User 타입을 생성한다. 여기서 password 는 내보낸 형태가 아니기 때문에 컴파일 에러가 발생한다.

func​ ​main​() {
    u := users.User{
        Name: ​"Hoanh"​,
        ID: ​101​,
        password: ​"xxxx"​,
    }
    fmt.Printf(​"User: %#v\n"​, u)
}
unknown field 'password' in struct literal of type users.User

내보내지 않은 형태를 임베딩하고 있는 내보낸 형식

users 패키지는 사용자 관리에 관한 기능을 제공한다.

package users

내보내지 않은 형태의 user 는 사용자에 대한 정보를 의미하며, 2개의 내보낸 필드를 정의한다.

type user struct {
    Name string
    ID   int
}

내보낸 형태의 Manager 는 관리자에 대한 정보를 의미하며, 내보내지 않은 형태로 임베딩된 필드 user 를 정의한다.

type Manager struct {
    Title string

    user
}
package main

import (
    "fmt"

    "github.com/hoanhan101/users"
)

user 패키지에 있는 Manager 타입의 값을 생성한다. Manager 타입 값을 생성할 때는 오직 내보낸 필드인 Title 만을 초기화 할 수 있고, 내보내지 않은 형태로 임베딩된 user 타입에는 바로 접근할 수 없다.

func main() {
    u := users.Manager{
        Title: "Dev Manager",
    }

그러나 manager 값을 초기화하고 난 이후에는 내보내지 않은 형태를 내보낸 필드로써 접근 할 수 있다.

However, once we have the manager value, the exported fields from that unexported type are accessible.

    u.Name = "Hoanh"
    u.ID = 101
    fmt.Printf("User: %#v\n", u)
}
User: users.Manager{Title:"Dev Manager", user:users.user{Name:"Hoanh", ID:101}}

다시 한번 이야기하지만 이 방식을 사용하는 것보다는 user 를 내보내는 것이 더 좋은 방식이므로 이를 사용하는 것이 낫다.

소프트웨어 설계

조합

그룹핑 형식

상태에 의한 그룹핑

이번 파트는 OOP 패턴의 유형계층에 대한 예시이다. 이는 Go 에서 자주 사용되는 방식은 아니다. Go 는 서브타이핑이라는 개념이 없기 때문이다. Go 에서 모든 타입은 고유하며 기본 타입, 파생된 타입이라는 개념은 존재하지 않는다. 즉, 이 패턴은 Go 프로그래밍에서는 좋은 설계 원칙이 아니다.

Animal 은 동물에 대한 기본 속성을 정의하고 있다.

type Animal struct {
    Name     string
    IsMammal bool
}

Speak 는 동물들이 말하는 방식에 관한 일반적인 행동을 정의하고 있다. 스스로 말할 수 없는 동물 때문에 이는 무의미한 메서드일 수 있다. Speak 는 실제로 모든 동물이 가진 특성은 아니다.

func (a *Animal) Speak() {
    fmt.Println("UGH!",
        "My name is", a.Name,
        ", it is", a.IsMammal,
        "I am a mammal")
}

DogAnimal 과 관련된 모든것과 PackFactor 라는 Dog 만이 가진 속성을 정의하고 있다.

type Dog struct {
    Animal
    PackFactor int
}

Speak 는 개가 말하는 방식을 의미한다.

func (d *Dog) Speak() {
    fmt.Println("Woof!",
        "My name is", d.Name,
        ", it is", d.IsMammal,
        "I am a mammal with a pack factor of", d.PackFactor)
}

CatAnimal 과 관련된 모든것과 ClimbFactor 라는 Cat 만이 가진 속성을 정의하고 있다.

type Cat struct {
    Animal
    ClimbFactor int
}

Speak 는 고양이가 말하는 방식을 의미한다.

func (c *Cat) Speak() {
    fmt.Println("Meow!",
        "My name is", c.Name,
        ", it is", c.IsMammal,
        "I am a mammal with a climb factor of", c.ClimbFactor)
}

여기까지는 괜찮을 지도 모른다. 하지만 이 코드는 컴파일되지 않는다. Animals 란 요소를 바탕으로 CatDog 를 그루핑했기 때문이다. 즉, Go 는 서브타이핑 개념이 없는데도 불구하고 서브타이핑을 사용했다. Go 는 공통된 DNA(’구조체 내 공통된 필드’에 대한 비유)를 바탕으로 그룹핑하는 것을 권장하지 않는다. 누구인지에만 초점을 맞춘다면 그룹화하는데 큰 제한이 있기 때문에 공통된 DNA를 바탕으로 API 를 설계한다는 생각을 그만해야 한다. 서브타이핑은 다양성에 한계가 있다. 그룹화 가능하도록 서브셋을 작게 구성하면 그 형식과 관련된 것밖에 설계할 수 없지만, 행동에 초점을 맞추면 전체적인 형태로 넓혀서 설계할 수 있다.

Animal 부분을 초기화한 후 Dog 속성을 정의해서 Dog 를 생성한다.

animals := []Animal{
    Dog{
        Animal: Animal{
            Name: "Fido",
            IsMammal: true,
        },
        PackFactor: 5,
    },

Animal 부분을 초기화한 후 Cat 속성을 정의해서 Cat 를 생성한다.

    Cat{
        Animal: Animal{
            Name: "Milo",
            IsMammal: true,
        },
        ClimbFactor: 4,
    },
}

Animal 들이 말하도록 한다.

    for _, animal := range animals {
        animal.Speak()
    }
}

위 방식이 냄새나는 코드인 이유:

행동에 의한 그루핑

이번 파트는 구성과 인터페이스를 활용한 예시이며 이는 Go 에서 사용되면 좋은 방식이다.

공통된 상태가 아닌 공통된 행동으로 그룹화하는 이 패턴은 Go 프로그램에서 좋은 설계 원칙이다. Go 의 뛰어난 특성중 하나는 미리 구성할 필요가 없다는 점이다. 컴파일러는 컴파일 타임에 인터페이스와 행동을 자동으로 식별한다. 이는 현재 또는 미래에 작성한 인터페이스와 호환될 수 있는 코드를 지금 작성할 수 있다는 의미이다. 또한 컴파일러가 즉석에서 행동을 식별하기 때문에 선언된 위치도 중요하지 않다. 대신 어떤 행동을 해야 하는지를 고려해야 한다.

Speaker 는 그룹이 일원이 되기 위해 따라야 할 공통된 행동을 정의하고 있다. Speaker 는 구체적인 타입에 대한 계약이다. Animal 타입은 제거한다.

type Speaker interface {
    Speak()
}

DogDog 가 필요한 모든 것을 정의하고 있다.

type Dog struct {
    Name       string
    IsMammal   bool
    PackFactor int
}

Speak 는 개가 말하는 방식을 의미한다. 말하는 방식이 정의된 Dog 는 구체적인 타입인 Speaker 그룹의 일원이 된다.

func (d Dog) Speak() {
    fmt.Println("Woof!",
        "My name is", d.Name,
        ", it is", d.IsMammal,
        "I am a mammal with a pack factor of", d.PackFactor)
}

CatCat이 필요한 모든 것을 정의하고 있다. 복사 붙여넣기를 하면 약간의 시간이 걸릴지도 모르지만, 대부분의 경우 디커플링은 코드 재사용보다 더 나은 방식이다.

type Cat struct {
    Name        string
    IsMammal    bool
    ClimbFactor int
}

Speak 는 고양이가 말하는 방식을 의미한다. 말하는 방식이 정의된 Cat 는 구체적인 타입인 Speaker 그룹의 일원이 된다.

func (c Cat) Speak() {
    fmt.Println("Meow!",
        "My name is", c.Name,
        ", it is", c.IsMammal,
        "I am a mammal with a climb factor of", c.ClimbFactor)
}

말하는 방식이 정의된 동물들을 생성해보자.

func main() {
    speakers := []Speaker{

Dog 의 속성을 초기화해서 Dog 를 생성한다.

        Dog{
            Name:       "Fido",
            IsMammal:   true,
            PackFactor: 5,
        },

Cat 의 속성을 초기화해서 Cat 을 생성한다.

        Cat{
            Name:       "Milo",
            IsMammal:   true,
            ClimbFactor: 4,
        },
    }

Speaker 들이 말하도록 한다.

Have the Speakers speak.

    for _, spkr := range speakers {
        spkr.Speak()
    }
Woof! My name is Fido , it is true I am a mammal with a pack factor of 5
Meow! My name is Milo , it is true I am a mammal with a climb factor of 4

타입 선언에 대한 지침:

디커플링

구조체 구성 (Struct Composition)

프로토타이핑은 개념 증명(proof of concept) 작성이나 구체적 문제 해결 못지않게 중요하다. 디커플링과 리팩터링을 시작하기 전에 무엇을 바꿀 수 있는지, 어떤 변경을 해줘야 할지를 생각해야 한다. 리팩터링은 개발 주기의 일부가 되어야 한다.

다음과 같은 문제를 풀어보자. Xenia 라는 시스템은 데이테베이스를 가지고 있다. Pillar라는 또 다른 시스템은 프론트엔드를 가진 웹서버이며 Xenia를 이용한다. Pillar 역시 데이터베이스가 있다. Xenia의 데이터를 Pillar에 옮겨보자.

이 작업은 얼마나 걸릴까? 코드 한 부분이 완료 되었고, 다음 코드 구현을 해도 되는지를 어떻게 알 수 있을까? 기술 책임자라면, 작업에 시간을 “지나치게 낭비”한 것인지, “좀 더 시간을 들여야” 하는 지 어떻게 알 수 있을까?

완료 여부는 다음 두 가지를 확인하고 판단하자. 첫 번째는 테스트 커버리지이다. 100%면 더할 나위 없고 80% 이상을 커버하고 있다면 충분하다. 두 번째는 변경 가능 여부이다. 기술적 관점, 그리고 사업적 관점에서 무엇이 변할 수 있는지를 생각해보고, 리팩터링으로 대응이 가능하다면 된 것이다.

예를 들어, 이틀만에 작동하도록 구현을 완료하지만, 변경이 있을 거라는 것, 리팩터링을 통해 2주 정도 대응하면 된다는 것을 알고 있는 것이다. 한 번에 하나씩만 풀어나가면 된다. 모든 것을 완벽하게 할 필요는 없다. 코드와 테스트를 짠 다음에 리팩터링을 하는 것이다. 각 계층이 다음 계층을 위한 단단한 기초라는 것을 알고, 각자의 위에서 동작하는 API 계층을 만들자. [재방문]

세부적인 구현에 너무 신경을 쓸 필요는 없다. 중요한 것은 메커니즘(mechanics)이다. 우리는 올바르게 동작하도록 최적화하는 것이다. 성능은 언제든 개선할 수 있다.

package main

import (
    "errors"
    "fmt"
    "io"
    "math/rand"
    "time"
)

우선, 타이머로 작동하는 소프트웨어가 필요하다. 소프트웨어는 Xenia에 접속해서, 데이터베이스를 읽고, 아직 옮기지 않은 모든 데이터를 찾아내고, 그것을 추출해야 한다.

func init() {
    rand.Seed(time.Now().UnixNano())
}

Data는 우리가 복사하는 데이터 구조이다. 단순하게, 문자열 데이터라고 생각하면 된다.

type Data struct {
    Line string
}

Xenia는 우리가 데이터를 추출 해야하는 시스템이다.

type Xenia struct {
    Host    string
    Timeout time.Duration
}

PullXenia의 데이터를 추출하는 메서드이다. func(*Xenia) Pull()(*Data, error)와 같이 선언하여 데이터와 에러를 반환하도록 할 수도 있다. 하지만 이렇게 하면 호출을 할 때마다 할당 비용이 들 게 된다. 아래와 같이 메서드의 파라미터로 d *Data를 선언하면 Data의 타입과 크기를 미리 알 수 있다. 따라서 스택에 저장할 수 있게 된다.

func (*Xenia) Pull(d *Data) error {
    switch rand.Intn(10) {
    case 1, 9:
        return io.EOF
    case 5:
        return errors.New("Error reading data from Xenia")
    default:
        d.Line = "Data"
        fmt.Println("In:", d.Line)
        return nil
    }
}

Pillar는 데이터를 저장할 시스템이다.

type Pillar struct {
    Host    string
    Timeout time.Duration
}

Store 메서드로 d *DataPiller에 저장할 수 있다. 일관되도록 포인터 *Pillar를 사용하였다.

func (*Pillar) Store(d *Data) error {
    fmt.Println("Out:", d.Line)
    return nil
}

SystemXeniaPiller를 하나의 시스템으로 결합한다. 우리는 XeniaPiller를 기반으로 한 API를 가지고 있다. 여기에 또 다른 API를 구축해 그것을 기반으로 활용하려고 한다. 한 가지 방법은 추출 하거나 저장할 수 있는 행동을 하는 타입을 갖는 것이다. 우리는 구성(composition)을 통해 그것을 할 수 있다. SystemXeniaPiller의 내장된 요소를 기반으로 한다. 그리고 내부 타입 승격(inner type promotion) 때문에 System에서는 추출과 저장하는 방법을 알고 있다.

type System struct {
    Xenia
    Pillar
}

pull은 우리가 구축한 기반을 활용하여 Xenia로 부터 많은 양의 데이터를 추출할 수 있다.

이를 위해 System에 메서드를 추가할 필요는 없다. System 내부에 System이 관리할 상태(state)는 없다. 대신에, System이 무엇을 할 수 있는지를 이해하기만 하면 된다.

함수는 API를 만드는 좋은 방법이다. 함수가 대체로 메서드보다 읽기 쉽기 때문이다. 패키지 레벨에서 함수로 API 를 구현해 보는걸로 구현을 시작해보자.

함수를 작성할 때 모든 입력은 반드시 전달되어야 한다. 메서드를 사용할 때에, 메서드 시그니처(signature)만으로는 우리가 호출할 때에 사용하는 값이 어떤 레벨, 어떤 필드나 상태인지 알 수 없다.

func pull(x *Xenia, data []Data) (int, error) {

데이터 슬라이스를 순회하면서 각각의 원소를 XeniaPull 메서드에 전달하자.

    for i := range data {
        if err := x.Pull(&data[i]); err != nil {
            return i, err
        }
    }

    return len(data), nil
}

store를 이용하여 많은 양의 데이터를 Pillar에 저장할 수 있다. 위의 기능과 유사하다. 효율적인지 의문이 들지도 모른다. 하지만 정확성(correctness)을 목표로 최적화 하고 있다. 성능(performance)은 그 다음 문제이다. 구현이 완료되면 테스트를 할 것이고, 속도가 충분히 빠르지 않다면, 복잡해지더라도 성능을 개선할 것이다.

func store(p *Pillar, data []Data) (int, error) {
    for i := range data {
        if err := p.Store(&data[i]); err != nil {
            return i, err
        }
    }

    return len(data), nil
}

Copy를 이용하여 System의 데이터를 추출할 수 있다. 이제 pullstore 함수를 호출하여 Xenia에서 Piller로 데이터를 전달할 수 있다.

func Copy(sys *System, batch int) error {
    data := make([]Data, batch)

    for {
        i, err := pull(&sys.Xenia, data)
        if i > 0 {
            if _, err := store(&sys.Pillar, data[:i]); err != nil {
                return err
            }
        }

        if err != nil {
            return err
        }
    }
}
func main() {
    sys := System {
        Xenia: Xenia {
            Host:    "localhost:8000",
            Timeout: time.Second,
        },
        Pillar: Pillar {
            Host:    "localhost:9000",
            Timeout: time.Second,
        },
    }

    if err := Copy(&sys, 3); err != io.EOF {
        fmt.Println(err)
    }
}
In: Data
In: Data
In: Data
Out: Data
Out: Data
Out: Data
In: Data
In: Data
Out: Data
Out: Data

인터페이스로 디커플링

API를 보면, API는 구체적인 구현에서 디커플링 할 필요가 있다. 이 디커플링은 초기화 까지 도달 해야 하고 제대로 하기위해선 초기화 코드만 변경하면 된다. 다른 모든것은 이러한 유형이 제공 할 행동에 따라 수행 할 수 있어야 한다.

pull은 구체화 된 것 이며, Xenia에서만 수행 한다. 그러나, 데이터를 추출 하는 pull을 디커플링 할 수 있다면 우리는 가장 높은 수준의 디커플링을 얻을 수 있다. 우리는 이미 효율적인 알고리즘을 가지고 있기 때문에, 다른 레벨에서 일반화를 추가 하거나 구체화에서 이미 했던 작업을 의미 없게 하면 안된다. store도 마찬가지 이다.

구체화에서 부터 올라오는것이 좋다. 이렇게 하면, 문제를 효율적으로 해결하고 기술 부채를 줄일 뿐만 아니라 계약들도 우리에게 오게 된다. 우리는 이미 데이터를 추출/저장 하는 계약이 무엇인지 알고 있다. 이미 그것을 검증했고 우리가 필요한 것이다.

이 2개의 함수를 디커플링하고 인터페이스 2개를 추가하자. Puller는 추출기능, Storer 는 저장기능을 한다.

Xenia는 이미 Puller 인터페이스를 구현했고, PillarStorer 인터페이스를 구현 했다. 이제 pull/store에 들어가서 구체화로 부터 이 함수를 디커플링 해보자

XenialPillar를 넘나드는 것 대신에, 우리는 PullerStorer 사이를 넘나들 수 있다. 알고리즘은 변하지 않는다. 우리가 하고 있는 모든 것들은 인터페이스 값을 통해서 간접적으로 pull/store를 호출 하는 것이다.

package main
import (
    "errors"
    "fmt"
    "io"
    "math/rand"
    "time"
)

func init() {
    rand.Seed(time.Now().UnixNano())
}

Data는 복사 할 데이터의 구조체이다.

 type Data struct {
     Line string
}

Puller 는 데이터 추출 동작을 선언한다.

type Puller interface {
    Pull(d *Data) error
}

Storer 는 데이터 저장 동작을 선언한다.

type Storer interface {
    Store(d *Data) error
}

Xenia는 우리가 데이터를 빼내야 하는 시스템이다.

type Xenia struct {
    Host    string
    Timeout time.Duration
}

PullXenia에서 데이터를 가져오는 방법을 알고 있다.

func (*Xenia) Pull(d *Data) error {
    switch rand.Intn(10) {
    case 1, 9:
        return io.EOF
    case 5:
        return errors.New("Error reading data from Xenia")
    default:
        d.Line = "Data"
        fmt.Println("In:", d.Line)
        return nil
    }
}

Pillar는 우리가 데이터를 저장 해야 하는 시스템이다.

 type Pillar struct {
     Host    string
     Timeout time.Duration
 }

StorePillar에 데이터를 저장하는 방법을 알고 있다.

func (*Pillar) Store(d *Data) error {
    fmt.Println("Out:", d.Line)
    return nil
}

SystemXeniaPiller를 하나의 시스템으로 결합한다.

type System struct {
    Xenia
    Pillar
}

pullPuller로 부터 많은 양의 데이터를 추출하는 방법을 알고 있다.

func pull(p Puller, data []Data) (int, error) {
    for i := range data {
        if err := p.Pull(&data[i]); err != nil {
            return i, err
        }
    }
    return len(data), nil
}

storeStorer로 부터 많은 양의 데이터를 저장하는 방법을 알고 있다.

func store(s Storer, data []Data) (int, error) {
    for i := range data {
        if err := s.Store(&data[i]); err != nil {
            return i, err
        }
    }
    return len(data), nil
}

CopySystem에서 데이터를 추출하고 저장하는 방법을 알고 있다.

func Copy(sys *System, batch int) error {
    data := make([]Data, batch)

    for {
        i, err := pull(&sys.Xenia, data)
        if i > 0 {
            if _, err := store(&sys.Pillar, data[:i]); err != nil {
                return err
            }
        }
        if err != nil {
            return err
        }
    }
}
func main() {
    sys := System{
        Xenia: Xenia{
            Host:    "localhost:8000",
            Timeout: time.Second,
        },
        Pillar: Pillar{
            Host:    "localhost:9000",
            Timeout: time.Second,
        },
    }

    if err := Copy(&sys, 3); err != io.EOF {
        fmt.Println(err)
    }
}
In: Data
In: Data
In: Data
Out: Data
Out: Data
Out: Data
In: Data
In: Data
Out: Data
Out: Data

인터페이스 구성

인터페이스 구성을 사용하여 인터페이스를 추가하자. PullStorer에는 PullerStorer의 두 가지 동작이 있다. 추출과 저장을 모두 구현하는 구체적인 타입은 PullStorer이다. SystemPullStorer인 이유는 XeniaPiller 이 두 가지 타입이 내장되어 있기 때문이다. 이제 다른 코드는 변경할 필요없이 Copy에 들어가서 PullStorer로 시스템 포인터를 교체하면 된다.

Copy를 자세히 보면, 잠재적으로 우리를 혼란스럽게 할 수 있는 것이 있다. 우리는 PullStorer 인터페이스 값을 직접적으로 PullStore에 전달하고 있다.

추출과 저장을 살펴보면, 그것들은 PullStorer가 필요하지 않다. 한 쪽은 Puller, 다른 한 쪽은 Storer만 있으면 된다. 왜 컴파일러는 이전에는 허용하지 않았던 다른 타입의 값 전달을 허용하는 것일까?

Go에는 암시적 인터페이스 변환이라는 기능이 있기 때문이다. 이것은 아래의 이유 때문에 가능하다:

코드를 자세히 살펴보자.

주요 기능에서 우리는 System 타입의 값을 만들고 있다. 알고 있듯이, 우리의 System 타입 값은 두 가지 구체 타입인 XeniaPiller를 내장하고 있는데, 여기서 Xenia는 추출기능, Filler는 저장기능을 알고 있다. 내부 타입 승격(inner type promotion) 때문에 System은 본질적으로 추출하고 저장하는 방법을 알고 있다. 우리는 System 주소를 Copy에 전달하고 있다. 그 다음 CopyPullStorer 인터페이스를 생성한다. 첫 번째는 System의 포인터이고 두 번째는 원래 값을 가리킨다. 이 인터페이스는 이제 추출 및 저장하는 방법을 안다. 우리가 pspull을 호출할 때, 우리는 Systempull을 호출하고, 그것은 결국 Xeniapull을 호출한다.

여기 예기치 못한 것이있다: 암시적 인터페이스 변환

컴파일러가 PullStorer 내부에 저장된 모든 구체적인 타입도 Puller를 구현해야 한다는 것을 알고 있기 때문에 pull 인터페이스 값 ps를 전달할 수 있다. 우리는 결국 Puller라는 또 다른 인터페이스를 갖게 된다. 모든 인터페이스에 대해 메모리 모델이 동일하기 때문에, 우리는 이 두 가지를 복사하여 모두 동일한 인터페이스 타입을 공유한다. 이제 Pullerpull을 호출할 때 Systempull을 호출할 것이다.

Storer와 유사

모두 인터페이스 값에 대한 가치 의미와 공유하기 위한 포인터 의미.

iTable for pipe
package main

import (
    "errors"
    "fmt"
    "io"
    "math/rand"
    "time"
)

func init() {
    rand.Seed(time.Now().UnixNano())
}

Data는 복사 할 데이터의 구조체이다.

type Data struct {
    Line string
}

Puller 는 데이터 추출 동작을 선언한다.

type Puller interface {
    Pull(d *Data) error
}

Storer 는 데이터 저장 동작을 선언한다.

type Storer interface {
    Store(d *Data) error
}

PullStore는 추출 및 저장에 대한 동작을 선언한다.

type PullStorer interface {
    Puller
    Storer
}

Xenia는 우리가 데이터를 빼내야 하는 시스템이다.

type Xenia struct {
    Host    string
    Timeout time.Duration
}

PullXenia에서 데이터를 가져오는 방법을 알고 있다.

func (*Xenia) Pull(d *Data) error {
    switch rand.Intn(10) {
        case 1, 9:
            return io.EOF
        case 5:
            return errors.New("Error reading data from Xenia")
        default:
            d.Line = "Data"
            fmt.Println("In:", d.Line)
            return nil
    }
}

Pillar는 우리가 데이터를 저장 해야 하는 시스템이다.

type Pillar struct {
    Host string
    Timeout time.Duration
}

StorePillar에 데이터를 저장하는 방법을 알고 있다.

func (*Pillar) Store(d *Data) error {
    fmt.Println("Out:", d.Line)
    return nil
}

SystemXeniaPiller를 하나의 시스템으로 결합한다.

type System struct {
    Xenia
    Pillar
}

pullPuller로 부터 많은 양의 데이터를 추출하는 방법을 알고 있다.

func pull(p Puller, data []Data) (int, error) {
    for i := range data {
        if err := p.Pull(&data[i]); err != nil {
            return i, err
        }
    }

    return len(data), nil
}

storeStorer로 부터 많은 양의 데이터를 저장하는 방법을 알고 있다.

func store(s Storer, data []Data) (int, error) {
    for i := range data {
        if err := s.Store(&data[i]); err != nil {
            return i, err
        }
    }

    return len(data), nil
}

CopySystem에서 데이터를 추출하고 저장하는 방법을 알고 있다.

func Copy(ps PullStorer, batch int) error {
    data := make([]Data, batch)

    for {
        i, err := pull(ps, data)
        if i > 0 {
            if _, err := store(ps, data[:i]); err != nil {
                return err
            }
        }

        if err != nil {
            return err
        }
    }
}
func main() {
    sys := System {
        Xenia: Xenia {
            Host:    "localhost:8000",
            Timeout: time.Second,
        },
        Pillar: Pillar {
            Host:    "localhost:9000",
            Timeout: time.Second,
        },
    }

    if err := Copy(&sys, 3); err != io.EOF {
        fmt.Println(err)
    }
}
In: Data
In: Data
In: Data
Out: Data
Out: Data
Out: Data
In: Data
In: Data
Out: Data
Out: Data

인터페이스 구성을 사용한 디커플링

우리는 구체타입 System을 바꾼다. 두 가지 구체타입 XeniaPillar를 사용하는 대신 인터페이스 PullerStorer를 사용한다. 구체적인 동작을 할 수 있는 구체타입 System은 이제 2가지 인터페이스 임베딩을 기반으로 한다. 이것은 공통의 DNA가 아닌, 우리가 필요로 하는 능력과 동작을 제공하는 데이터를 기반으로 어떤 데이터든 주입할 수 있다는 것을 의미한다.

이제 코드가 완전히 분리되었다. 왜냐하면 Puller를 구현하는 모든 값들은 System(Storer와 동일)에 저장할 수 있기 때문이다. 여러 개의 System을 만들 수 있으며 데이터는 Copy로 전달할 수 있다.

여기서 메소드는 필요없다. 단지 데이터를 받는 하나의 함수가 필요하며, 그것의 동작은 입력한 데이터에 따라 달라진다.

이제 System은 더 이상 XeniaPillar를 기반으로 하지 않는다. Xenia를 저장하는 인터페이스와 Pillar를 저장하는 인터페이스 두 개를 기반으로 한다. 우리는 추가적인 디커플링 층을 갖게 되었다.

시스템이 바뀌어도 큰 문제가 되지 않는다. 프로그램 시작에 필요한 대로 시스템을 교체만 하면 된다.

우리는 이 문제를 해결 하고 제품에 반영한다. 우리가 했던 모든 리팩터링은 다음 리팩터링을 하기전에 제품에 반영하였다. 우리는 계속해서 기술부채를 최소화하고 있다.

iTable for pipe
package main
import (
    "errors"
    "fmt"
    "io"
    "math/rand"
    "time"
)

func init() {
    rand.Seed(time.Now().UnixNano())
}

Data는 복사 할 데이터의 구조체이다.

type Data struct {
    Line string
}

Puller 는 데이터 추출 동작을 선언한다.

type Puller interface {
    Pull(d *Data) error
}

Storer 는 데이터 저장 동작을 선언한다.

type Storer interface {
    Store(d *Data) error
}

PullStore는 추출 및 저장에 대한 동작을 선언한다.

type PullStorer interface {
    Puller
    Storer
}

Xenia는 우리가 데이터를 빼내야 하는 시스템이다.

type Xenia struct {
    Host    string
    Timeout time.Duration
}

PullXenia에서 데이터를 가져오는 방법을 알고 있다.

func (*Xenia) Pull(d *Data) error {
    switch rand.Intn(10) {
        case 1, 9:
            return io.EOF
        case 5:
            return errors.New("Error reading data from Xenia")
        default:
            d.Line = "Data"
            fmt.Println("In:", d.Line)
            return nil
    }
}

Pillar는 우리가 데이터를 저장 해야 하는 시스템이다.

type Pillar struct {
    Host    string
    Timeout time.Duration
}

StorePillar에 데이터를 저장하는 방법을 알고 있다.

func (*Pillar) Store(d *Data) error {
    fmt.Println("Out:", d.Line)
    return nil
}

SystemPullersStores를 하나의 시스템으로 결합한다.

type System struct {
    Puller
    Storer
}

pullPuller로 부터 많은 양의 데이터를 추출하는 방법을 알고 있다.

func pull(p Puller, data []Data) (int, error) {
    for i := range data {
        if err := p.Pull(&data[i]); err != nil {
            return i, err
        }
    }

    return len(data), nil
}

storeStorer로 부터 많은 양의 데이터를 저장하는 방법을 알고 있다.

func store(s Storer, data []Data) (int, error) {
    for i := range data {
        if err := s.Store(&data[i]); err != nil {
            return i, err
        }
    }

    return len(data), nil
}

CopySystem에서 데이터를 추출하고 저장하는 방법을 알고 있다.

func Copy(ps PullStorer, batch int) error {
    data := make([]Data, batch)

    for {
        i, err := pull(ps, data)
        if i > 0 {
            if _, err := store(ps, data[:i]); err != nil {
                return err
            }
        }

        if err != nil {
            return err
        }
    }
}

func main() {
    sys := System {
        Puller: &Xenia {
            Host: "localhost:8000",
            Timeout: time.Second,
        },
        Storer: &Pillar {
            Host: "localhost:9000",
            Timeout: time.Second,
        },
    }

    if err := Copy(&sys, 3); err != io.EOF {
        fmt.Println(err)
    }
}
In: Data
In: Data
In: Data
Out: Data
Out: Data
Out: Data
In: Data
In: Data
Out: Data
Out: Data

89-101

변환(Conversion)

인터페이스 변환

Mover는 움직이는 것을 나타내기 위해 다음과 같이 정의합니다.

type Mover interface {
    Move()
}

Locker는 잠그고(locking) 해제(unlocking)할 수 있는 것을 나타냅니다.

type Locker interface {
    Lock()
    Unlock()
}

MoveLocker는 움직이거나 잠글 수 있는 것을 나타냅니다.

type MoveLocker interface {
    Mover
    Locker
}

구체적인 예시를 위해 bike라는 타입을 정의합니다.

type bike struct {}

Movebike를 움직입니다.

func (bike) Move() {
    fmt.Println("Moving the bike")
}

Lockbike가 움직이지 못하게 합니다.

func (bike) Lock() {
    fmt.Println("Locking the bike")
}

Unlock을 하면 bike는 다시 움직일 수 있습니다.

func (bike) Unlock() {
    fmt.Println("Unlocking the bike")
}

func main() {

MoverLockerMover 인터페이스 타입의 변수를 선언합니다. zero value로 초기화 됩니다.

    var ml MoveLocker
    var m Mover

bike 값을 생성하여 MoveLocker 인터페이스 타입 변수에 대입합니다.

    ml = bike{}

MoveLocker 인터페이스 타입 변수는 Mover 인터페이스 타입 변수로 변환할 수 있습니다. 둘 모두 move라는 메쏘드를 정의했기 때문입니다.

    m = ml
MoveLocker에서 Mover로 암묵적 변환

하지만, 아래와 같이 반대로는 불가능합니다.

    ml = m

컴파일을 하면 다음과 같은 에러가 발생합니다.

cannot use m (type Mover) as type MoveLocker in assignment:
Mover does not implement MoveLocker (missing Lock method).

타입 단언(Type assertion)

인터페이스 타입 Moverlockunlock 메서드를 정의하고 있지 않다. 따라서, 컴파일러는 인터페이스 Mover 타입의 변수를 MoveLocker 타입의 변수로 암묵적으로 변환할 수 없다. Mover 인터페이스 변수의 실제 값이 MoveLocker 인터페이스를 구현한 bike 타입의 값이라 해도 변환하지 않는다. 런타임때 타입 단언을 사용하여 명시적으로 변환할 수는 있다.

아래와 같이 Mover 인터페이스의 값을 타입 단언을 사용해 bike 타입의 값으로 변환 후 복사한다. 복사된 값을 MoveLocker 인터페이스 변수에 배정한다. 아래 코드가 타입 단언의 문법이다. 인터페이스 값에 인터페이스값.(bike)처럼 점(.)에 파라미터로 bike 값을 전달한다. mnil이 아닌 bike 타입의 값이 들어있을 경우, 포인터가 아닌 값을 넘겨받았기(value semantics) 때문에 m을 복사한 값을 얻게 된다. 그렇지 않을 경우 panic이 발생하게 된다. 아래 예시에서 bbike의 복사된 값을 가지고 있다.

    b := m.(bike)

타입 단언의 성공 여부를 나타내는 boolean 값을 받아 panic을 예방 할 수도 있다.

    b, ok := m.(bike)
    fmt.Println("Does m has value of bike?:", ok)

    ml = b
Does m has value of bike?: true

타입 단언의 문법을 통해 인터페이스 변수에 실제 저장된 값의 타입이 무엇인지 알 수 있다. 캐스팅을 사용하는 다른 언어에 비해 가독성 관점에서 큰 장점이라고 할 수 있다.

런타임 타입 단언

package main
import (
    "fmt"
    "math/rand"
    "time"
)

car는 무엇가 운전할 수 있는 것을 의미한다.

type car struct{}

Stringfmt.Stringer 인터페이스를 구현한다.

func (car) String() string {
    return "Vroom!"
}

cloud는 정보를 저장해 둘 장소를 의미한다.

type cloud struct{}

String은 마찬가지로 fmt.Stringer 인터페이스를 구현한다.

func (cloud) String() string {
    return "Big Data!"
}

랜덤 함수에 사용될 Seed값을 정한다.

func main() {
    rand.Seed(time.Now().UnixNano())

Stringer 인터페이스를 가지는 슬라이스를 생성한다.

    mvs := []fmt.Stringer{
        car{},
        cloud{},
    }

아래와 같은 코드를 10번 반복해보자.

    for i := 0; i < 10; i++ {
        rn := rand.Intn(2)

아래와 같이 랜덤으로 생성된 숫자를 통해 cloud에 대한 타입 단언을 실행한다. 아래 예시는 타입 단언이 컴파일 때가 아닌 런타임때 실행된다는 것을 알 수 있다.

        if v, ok := mvs[rn].(cloud); ok {
            fmt.Println("Got Lucky:", v)
            continue
        }

x라는 변수가 있으면 x.(T)를 통해 T 타입으로 단언 될 수 있는지 확인해줘야 한다. 아니면 무결성 등의 이유로 panic하길 원할 경우라면 ok 변수를 사용하지 않을 것이다. panic으로부터 회복할 수 없으면 프로그램은 종료될 것이고 재시작해야 한다.

프로그램이 종료된다는 의미는 스택 트레이스가 출력되는 log.Fatal, os.exit 혹은 panic 함수를 호출했다는 것이다. 타입 단언을 사용할 때는, 요청하는 타입의 값이 들어있지 않아도 괜찮은지 확인해야 한다.

타입 단언을 사용해 구체적인 타입의 값을 꺼낼 경우, 주의해서 사용한다. 디커플링의 레벨을 유지하기 위해 인터페이스를 사용했는데 타입 단언을 사용해 다시 이전으로 돌아가기 때문이다.

구체적인 타입을 사용할 경우 연관 있는 많은 코드를 동시에 리팩토링을 해야 될 수도 있다는 것을 알아야 한다. 반대로 인터페이스를 사용할 경우 내부 구현이 변해도 그로 인해 발생하는 변경점들은 최소화 할 수 있다.

        fmt.Println("Got Unlucky")
    }
}
Got Unlucky
Got Unlucky
Got Lucky: Big Data!
Got Unlucky
Got Lucky: Big Data!
Got Lucky: Big Data!
Got Unlucky
Got Lucky: Big Data!
Got Unlucky
Got Unlucky

인터페이스 오염(Interface Pollution)

소프트웨어를 설계할 때, 구체적인 타입이 아닌 인터페이스부터 설계한다. 인터페이스를 사용하는 이유는 무엇일까?

미신 #1: 인터페이스를 사용해야하기 때문에 인터페이스를 사용하고 있다.

답: 아니오. 인터페이스를 사용할 필요가 없다. 합리적이고 실용적일 때 인터페이스를 사용해야 한다.

인터페이스를 사용하는 데는 비용이 든다. 구체적인 타입을 인터페이스 타입으로 사용 될때 잠재적 할당 비용과 추상화 비용이 그것이다. 디커플링에 그만한 비용의 가치가 없다면 인터페이스를 사용해서는 안된다.

미신 #2: 코드를 테스트 하기 위해 인터페이스를 사용해야 한다.

답: 아니오. 테스트가 아니라 개발자를 우선하여 애플리케이션에 사용할 수 있는 API를 설계해야한다.

다음은 필요하지 않은 인터페이스를 사용하여 인터페이스 오염을 생성하는 예이다.

Server는 TCP 서버에 대한 계약을 정의한다. 이것은 약간의 코드 악취에 해당하는데 이것은 사용자에게 노출 될 API이고 하나의 인터페이스에 넣기에 많은 동작이다.

type Server interface {
    Start() error
    Stop() error
    Wait() error
}

serverServer 인터페이스를 구현한다. 이름이 일치하지만 꼭 나쁘다고 할 수 는 없다.

type server struct {
    host string
}

NewServer는 인터페이스 Server 타입을 리턴하는 팩토리 함수이다. 인터페이스를 반환함으로 코드스멜로 볼 수 있다.

함수나 인터페이스가 꼭 인터페이스 값을 반환하지 못하는 건 아니다. 반환해도 된다. 하지만, 보통은 주의해야 한다. 구체적인 타입이 동작을 가지고 있는 데이터이며 인터페이스는 그런 데이터를 받는 인풋으로써 사용되어야 한다.

코드 악취 - Export 되지 않은 타입 포인터를 인터페이스에 저장함

func NewServer(host string) Server {
    return &server{host}
}

Start 는 서버를 시작해 요청을 받기 시작한다. 여기서는 실제 구현이 되있다고 가정한다.

func (s *server) Start() error {
    return nil
}

Stop은 서버를 멈춥니다.

func (s *server) Stop() error {
    return nil
}

Wait은 서버가 새로운 연결을 받지 않고 대기하도록 한다.

func (s *server) Wait() error {
    return nil
}
func main() {

새로운 Server를 생성한다.

    srv := NewServer("localhost")

API를 사용한다.

    srv.Start()
    srv.Stop()
    srv.Wait()
}

위 코드에서 srv가 인터페이스가 아닌 구체적인 타입이었다면 아무 문제도 없을 것이다. 여기서 인터페이스는 디커플링 같은 어떠한 이점도 가져다 주지 않는다. 그저 추상화 수준을 높여 코드를 복잡하게 만들 뿐이다.

위 코드는 문제가 있는데 왜냐하면:

인터페이스 오염 제거

이전에 나온 예시에서 잘못된 인터페이스 사용을 고쳐보도록 하겠다.

Server의 구현이다.

type Server struct {
    host string
}

NewServerServer의 포인터를 반환한다.

func NewServer(host string) *Server {
    return &Server{host}
}

Start가 호출되면 서버가 리퀘스트를 받기 시작한다.

func (s *Server) Start() error {
    return nil
}

Stop는 서버를 멈춘다.

func (s *Server) Stop() error {
    return nil
}

Wait는 새로운 연결이 생성되는것을 막는다.

func (s *Server) Wait() error {
    return nil
}

새로운 Server를 생성한다.

func main() {
    srv := NewServer("localhost")

API를 사용한다.

    srv.Start()
    srv.Stop()
    srv.Wait()
}

인터페이스 오염을 피하기 위한 가이드라인

인터페이스를 다음과 같은 상황에서 사용한다:

다음과 같은 상황에서 인터페이스를 사용할지 다시 한번 생각해본다:

모조품 만들기(Mocking)

Mock 할 패키지

Mocking은 중요하다. 네트워크에서 발생하는 대부분의 것들은 mock할 수 있다. 하지만, 데이터베이스를 mocking하는 것은 매우 복잡하기에 mock하기가 어렵다. 하지만 Docker를 사용하면 테스트를 위한 데이터베이스를 깔끔하게 생성할 수 있다.

모든 API는 테스트에만 집중하여야 한다. 더이상 애플리케이션 유저에 관해 걱정하지 않아도 된다. 이전에는 인터페이스가 없으면 유저 입장에서 테스트를 작성할 수 없었지만 이제는 아니다. 아래 예시가 그 이유를 보여준다.

Go를 사용하기로 결정한 회사에서 일한다고 가정해보자. 사내에는 모든 애플리케이션이 사용하는 pubsub 시스템을 가지고 있다. 아마도 이벤트소싱을 사용 하고 있고 pubsub 플랫폼은 대체되지 않을 것이다. 이런 이벤트소스에 연결하여 서비스를 만들기위해 Go 용 pubsub API가 필요하다.

우선 무엇이 변할 수 있는가? 이벤트소스가 변할 수 있을까?

만약 답이 ’아니오’라면, 인터페이스를 사용할 이유가 없다. 그렇다면 모든 API를 구체적인 타입으로 작성할 것이다. 그리고 테스트를 작성하여 정상적으로 작동하는지 확인할 것이다.

며칠이 지난 후, 사용자들에게 문제가 발생하였다. 테스트를 작성해야 하는데 pubsub 시스템을 직접 호출할 수 없어서 mock을 사용해야 한다는 것이다. 그래서 사용자들은 인터페이스를 제공해주기를 원한다. 하지만, 현재 API는 인터페이스를 필요로 하고 있지 않다. 사용자들이 필요한 것이고 우리가 필요한 것이 아니다. 우리가 아닌 사용자가 pubsub 시스템을 분리시켜야 한다.

Go를 사용하기 때문에 이러한 분리가 가능하다. 다음 파일은 사용자 애플리케이션을 예시로 보여준다. pubsub 패키지는 pubsub 서비스를 시뮬레이션하는 패키지이다.

package  main

import  (
    "fmt"
)

PubSub는 큐(queue) 시스템에 접근할 수 있게 한다.

type PubSub struct {
    host string
}

New는 pubsub을 사용하기 위한 값을 반환한다.

func New(host string) *PubSub {
    ps := PubSub{
        host: host,
    }

    return  &ps
}

Publish는 특정 키에 데이터를 전송한다.

func (ps *PubSub) Publish(key string, v interface{}) error {
    fmt.Println("Actual PubSub: Publish")
    return nil
}

Subscribe는 특정 키값으로부터 메시지를 수신한다.

func (ps *PubSub) Subscribe(key string) error {
    fmt.Println("Actual PubSub: Subscribe")
    return nil
}

클라이언트

아래는 패키지나 테스트를 위해 mock 객체를 어떻게 생성하는지 보여준다.

package  main

import  (
    "fmt"
)

publisher 인터페이스로 pubsub 패키지를 mock을 가능케 한다. 애플리케이션을 작성할 때 필요한 모든 API를 정의하는 인터페이스를 선언한다. 이전 파일에 나온 구체적인 타입들이 이 인터페이스를 이미 구현하고 있다. 이제 여기서 구체적인 구현 없이 mocking을 통하여 애플리케이션 전체를 작성할 수 있다.

type publisher interface {
    Publish(key string, v interface{}) error
    Subscribe(key string) error
}

mockpubsub 패키지를 mocking 하기 위한 구체적인 타입이다.

type mock struct{}

Publish 메쏘드는 publisher 인터페이스를 구현한다.

func (m *mock) Publish(key string, v interface{}) error {
    // ADD YOUR MOCK FOR THE PUBLISH CALL.
    fmt.Println("Mock PubSub: Publish")
    return nil
}

Subscribe 메쏘드는 publisher 인터페이스를 구현한다.

func  (m *mock) Subscribe(key string) error {
    // ADD YOUR MOCK FOR THE SUBSCRIBE CALL.
    fmt.Println("Mock PubSub: Subscribe")
    return nil
}

publisher 인터페이스 슬라이스를 생성한다. 그리고 pubsub의 주소를 부여한다. mock의 주소값도 추가한다.

func main() {
    pubs := []publisher{
        New("localhost"),
        &mock{},
    }

인터페이스 슬라이스를 순회하면서 publisher 인터페이스가 어떻게 디커플링(decoupling)을 하는지 볼 수 있다. pubsub 패키지가 인터페이스를 제공할 필요가 없는 것을 볼 수 있다.

    for _, p := range pubs {
        p.Publish("key", "value")
        p.Subscribe("key")
    }
}

에러 처리

기본 에러 값

무결성은 중요하며 이보다 중요한 것은 없다. 에러 처리는 그러한 무결성의 한 부분이다. 개발자가 매일 챙겨야 하는 부분이며, 작성하는 코드의 일부로 생각해야 한다. 먼저, 언어에서 제공하는 기본 에러 타입 구현의 동작에 대해 살펴보자.

package​ main
import​ ​"fmt"

http://golang.org/pkg/builtin/#error

이것은 언어 자체에 포함되어 있기에, 외부로 노출되지 않는 타입처럼 보인다. Error 라는 문자열 한 개를 반환하는 메서드 하나만 외부에서 접근이 가능하다. 에러 처리는 코드 테스트를 할 때에 항상 errer 인터페이스를 사용하기에 디커플링 되어 있다.

Go에서 에러는 단지 값일 뿐이며, 인터페이스 디커플링을 통해 그 값을 평가한다. 에러 처리를 디커플링 하는 것은 지속적인 변경이 코드 전반에 걸쳐 광범위한 영향을 야기하기 때문이다. 에러를 다룰 때 인터페이스를 최대한 이용하는 것이 중요하다.

type​ error ​interface​ {
    Error() ​string
}

http://golang.org/src/pkg/errors/errors.go

error 패키지에 있는 구체적인 기본 타입이다. 타입도, 그 내부의 필드도 외부로 노출하지 않는다. 이 방법은 사용자의 에러 판정을 구조화 할 수 있는 충분한 컨텍스트를 제공한다.

호출 시 충분한 내용을 담아 에러 처리를 하여, 호출자가 에러 상황에 대해 의사결정을 할 수 있게 한다.

type​ errorString ​struct​ {
    s ​string
}

http://golang.org/src/pkg/errors/errors.go

포인터 리시버를 이용하여 문자열을 반환한다. 사용자는 이 메소드를 호출하여 실패상황에 대한 문자열을 얻어낼 수 있다.

이 메소드는 에러에 대한 정보를 로깅하는 데 사용한다.

func​ (e *errorString) ​Error​() ​string​ {
    return​ e.s
}

http://golang.org/src/pkg/errors/errors.go

New는 주어진 문자열에서 에러 인터페이스를 반환한다. 사용자가 New를 호출하면, text로 넘어온 값을 포함하는 errorString 값이 생성된다. 해당 타입의 실체에 대한 주소를 반환하기 때문에, 사용자는 실제 에러 내용을 가리키는 errorString을 통해 에러 인터페이스 값을 얻을 수 있다. 에러 처리는 이와 같은 방식으로 디커플링될 수 있다.

func​ ​New​(text ​string​) ​error​ {
    return​ &errorString{text}
}

아래는 Go에서 에러를 다루는 전통적인 방식이다. webCall 함수를 호출하고 에러 인터페이스를 변수에 저장하는 예시를 관찰할 것이다.

nil은 Go에서 특별한 값이다. error != nil 은 타입에 대한 실질적 값이 에러 인터페이스에 들어있는가 확인함을 의미한다. 에러가 nil 값이 아니면 실질적인 값이 저장된 것이기 때문이다. 값이 있는 경우에는 에러를 마주한 것이다.

이제 에러를 처리하고, 에러가 호출 스택 상위에서 누군가가 제어할 수 있도록 해야 하지 않겠는가? 이 부분은 나중에 다루기로 한다.

func​ ​main​() {
    if​ err := webCall(); err != ​nil​ {
        fmt.Println(err)
        return
    }
    fmt.Println(​"Life is good"​)
}

webCall은 웹 요청을 처리한다

func​ ​webCall​() ​error​ {
    return​ New(​"Bad Request"​)
}

에러 변수

어떤 에러가 반환 되는지 알기 위해 에러 변수를 사용해본다.

package​ main
import​ (
    "errors"
    "fmt"
)

소스코드 파일의 최상단을 아래와 같이 작성한다. 명명 규칙 : 에러 변수 명명은 Err로 시작하도록 한다. 사용자들이 접근 가능하도록 (대문자로 작성하여) 노출한다.

아래는 지난 예시 파일에서 살펴 본 에러 인터페이스들에, 값을 할당한 것이다. 에러들에 대한 컨텍스트를 이 변수들이 자체적으로 포함한다. 이 방법은 사용자들이 기본 에러 타입과 그것의 필드에 대한 노출 없이 지속적으로 에러 처리를 사용할 수 있도록 디커플링한다.

요청에 문제가 있는 경우 ErrBadRequest 변수가 반환된다. 301/302가 반환되면 ErrPageMoved 변수가 반환된다.

var​ (
    ErrBadRequest = errors.New(​"Bad Request"​)
    ErrPageMoved = errors.New(​"Page Moved"​)
)
func​ ​main​() {
    if​ err := webCall(​true​); err != ​nil​ {
        switch​ err {
        case​ ErrBadRequest:
            fmt.Println(​"Bad Request Occurred"​)
            return
        case​ ErrPageMoved:
            fmt.Println(​"The Page moved"​)
            return
        default​:
            fmt.Println(err)
            return
        }
    }
    fmt.Println(​"Life is good"​)
}

webCall은 웹 요청을 처리한다

func​ ​webCall​(b ​bool​) ​error​ {
    if​ b {
        return​ ErrBadRequest
    }
    return​ ErrPageMoved
}
Bad Request Occurred

타입을 통한 컨텍스트 처리

컨텍스트를 표현하는 데 에러 인터페이스 값보다 더 많은 컨텍스트를 필요로 하는 경우도 있다. 예를 들어, 네트워킹 문제는 복잡할 수 있어, 에러 변수 만으로는 부족하다. 이럴 때에는 사용자 정의 타입을 통해 문제를 해결할 수 있다.

아래 2개 타입은 표준 라이브러리인 JSON 패키지 기반 사용자 정의 에러 정의 방법이다. 컨텍스트를 포함하는 타입이다.

http://golang.org/src/pkg/encoding/json/decode.go

package main
import​ (
    "fmt"
    "reflect"
)

UnmarshalTypeError은 Go의 특정 타입으로 간주하기 어려운 JSON 값을 표현한다.
명명 규칙 : 타입 명명 시에는 접미사를 Error로 한다.

type​ UnmarshalTypeError ​struct​ {
    Value ​string​ ​// JSON value에 대한 설명이다
    Type reflect.Type ​// 미리 선언할 수 없는 타입을 의미한다
}

UnmarshalTypeError은 포인터 시맨틱(pointer semantics)을 이용하여 에러 인터페이스를 구현한다. 구현 할 때, 모든 필드를 에러 메시지에서 사용하는가 검증한다. 그렇지 않다면 문제가 발생할 수 있다. 사용자 정의 에러 타입에 필드를 추가 해두어도 아래 메서드(Error)가 호출될 때 로그가 정상적으로 출력되지 않을 것이기 때문이다. 정말 필요할 때만 이렇게 사용하자.

func​ (e *UnmarshalTypeError) ​Error​() ​string​ {
    return​ ​"json: cannot unmarshal "​ + e.Value + ​" into Go value of type "​ + e.Type.String()
}

InvalidUnmarshalErrorUnmarshal 함수에 유효하지 않은 매개변수가 들어왔음을 알린다. Unmarshal의 매개변수로는 nil이 아닌 포인터가 들어와야 한다. 실제 타입은 Unmarshal 함수가 값의 주소를 받지 않았을 때 반환값에 사용된다.

type​ InvalidUnmarshalError ​struct​ {
    Type reflect.Type
}

InvalidUnmarshalError은 에러 인터페이스를 구현한다.

func​ (e *InvalidUnmarshalError) ​Error​() ​string​ {
    if​ e.Type == ​nil​ {
        return​ ​"json: Unmarshal(nil)"
    }
    if​ e.Type.Kind() != reflect.Ptr {
        return​ ​"json: Unmarshal(non-pointer "​ + e.Type.String() + ")"
    }
    return​ ​"json: Unmarshal(nil "​ + e.Type.String() + ​")"
}

Unmarshal 호출을 위해 user 타입을 사용한다.

type​ user ​struct​ {
    Name ​int
}
func​ ​main​() {
    var​ u user
    err := Unmarshal([]​byte​(​`{"name":"bill"}`​), u) ​// Run with a value and pointer.
    if​ err != ​nil​ {
        This is a special ​type​ assertion that only works on the ​switch​.
​        switch​ e := err.(​type​) {
        case​ *UnmarshalTypeError:
            fmt.Printf(​"UnmarshalTypeError: Value[%s] Type[%v]\n"​, e.Value, e.Type)
        case​ *InvalidUnmarshalError:
            fmt.Printf(​"InvalidUnmarshalError: Type[%v]\n"​, e.Type)
        default​:
            fmt.Println(err)
        }
        return
    }
    fmt.Println(​"Name:"​, u.Name)
}

Unmarshal 함수는 항상 실패할 언마셜을 실험한다. 매개변수 영역을 보면, 첫번째는 바이트 슬라이스이고, 두번째는 빈 인터페이스이다. 빈 인터페이스는 기본적으로 어떤 것도 의미하지 않으며, 함수를 통해 어떤 값이든 받을 수 있다. 아래에서는 리플렉션을 이용해 인터페이스에 저장된 값의 실제 타입을 알아내고, 그것이 포인터가 아니거나 nil이 아닌지 확인할 것이다. 결과에 기초하여 다른 에러 타입을 반환한다.

func​ ​Unmarshal​(data []​byte​, v ​interface​{}) ​error​ {
    rv := reflect.ValueOf(v)
    if​ rv.Kind() != reflect.Ptr || rv.IsNil() {
        return​ &InvalidUnmarshalError{reflect.TypeOf(v)}
    }
    return​ &UnmarshalTypeError{​"string"​, reflect.TypeOf(v)}
}

타입을 통해 컨텍스트를 처리할 때의 한 가지 흠이 있다. 이 경우에는, 구현된 타입에 접근하여 디커플링(decoupling)에서 멀어지게 된다. json 패키지를 작성한 개발자가 구현된 타입들을 변경하면, 예제 코드에도 연쇄적인 영향을 미치게 된다. 에러 인터페이스 디커플링의 보호를 받지 못한다.

이런 문제는 가끔 발생할 수 있다. 디커플링을 유지하는 다른 방법은 없을까? 기능을 통한 컨텍스트 처리를 살펴보자.

기능을 통한 컨텍스트 처리(Behavior as context)

기능을 통한 컨텍스트 처리는 사용자 정의 오류를 마치 컨텍스트처럼 다룰 수 있게 해준다. 그리고 구현된 타입으로 단언하는 걸 막는다. 이를 통해 디커플링(decoupling)의 레벨에서 코드를 유지보수할 수 있게 된다.

package​ main
import​ (
    "bufio"
    "fmt"
    "io"
    "log"
    "net"
)

client는 하나의 연결성을 가진다.

type​ client ​struct​ {
    name ​string
    reader *bufio.Reader
}

TypeAsContextnet 패키지가 반환하는 여러 사용자 정의 오류들을 확인하는 방법을 보여준다.

func​ (c *client) ​TypeAsContext​() {
    for​ {

reader 인터페이스를 이용하여 네트워크를 읽는 것에서 코드를 분리할 수 있다.

        line, err := c.reader.ReadString(​'\n'​)
        if​ err != ​nil​ {

이 예제는 이전 예제와 마찬가지로 타입을 통해 컨텍스트를 처리한다. 여기서는 Temporary라는 메서드가 중요하다. 이 메서드가 정상적으로 작동한다면 계속 작업을 수행하고 그렇지 않다면 멈춘 후에 다시 시작한다. 아래 모든 케이스는 오직 Temporary만을 위한 것이다. 이게 왜 중요한가? 만약 타입 단언을 한다거나 구현된 타입의 내재된 기능만 요구한다면, 이것을 타입이 아니라 기능을 통해 처리하는 방식으로 바꿀 수 있다. 그렇기에 아래의 temporary라는 사용자 정의 인터페이스를 만들 수 있다.

            switch​ e := err.(​type​) {
            case​ *net.OpError:
                if​ !e.Temporary() {
                    log.Println(​"Temporary: Client leaving chat"​)
                    return
                }
            case​ *net.AddrError:
                if​ !e.Temporary() {
                    log.Println(​"Temporary: Client leaving chat"​)
                    return
                }
            case​ *net.DNSConfigError:
                if​ !e.Temporary() {
                    log.Println(​"Temporary: Client leaving return chat")
                    return
                }
            default​:
                if​ err == io.EOF {
                    log.Println(​"EOF: Client leaving chat"​)
                    return
                }
            log.Println(​"read-routine"​, err)
            }
        }
    fmt.Println(line)
    }
}

temporary는 net 패키지에서 Temporary라는 메서드가 반환되는 지 확인한다. 왜냐하면 그 중 Temporary라는 메서드를 가진 구조체만 있으면 되기때문이다. 그러면 여전히 디커플링 단계에 있으며 계속 인터페이스 레벨에서 작업할 수 있다.

type​ temporary ​interface​ {
    Temporary() ​bool
}

BehaviorAsContextnet 패키지가 반환할 지도 모르는 인터페이스를 어떻게 확인하는 지 보여준다.

func​ (c *client) ​BehaviorAsContext​() {
    for​ {
        line, err := c.reader.ReadString(​'\n'​)
        if​ err != ​nil​ {
            switch​ e := err.(​type​) {

타입 단언을 통해 세가지 경우를 한가지로 줄일 수 있다: 이 구현 타입은 해당 인터페이스를 구현하고 있는 error 인터페이스를 가지고 있으며 해당 인터페이스를 정의하고 이용할 수 있다.

            case​ temporary:
                if​ !e.Temporary() {
                    log.Println(​"Temporary: Client leaving return chat")
                    return
                }
            default​:
                if​ err == io.EOF {
                    log.Println(​"EOF: Client leaving chat"​)
                    return
                }
                log.Println(​"read-routine"​, err)
            }
        }
        fmt.Println(line)
    }
}

Lesson:

Go의 암시적 형변환 덕분에, 원하는 메서드나 기능을 가진 인터페이스를 구현함으로 디커플링 단계에서 유지보수할 수 있고 타입 단언을 이용하는 switch 문법에서 구현 타입 대신에 사용할 수 있다.

버그 사냥(Finding the bug)

package​ main
import​ ​"log"

customError는 빈 구조체이다.

type​ customError ​struct​{}

Errorerror 인터페이스를 구현한다.

func​ (c *customError) ​Error​() ​string​ {
    return​ ​"Find the bug."
}

fail 함수는 둘 다 nil 값을 반환한다.

func​ ​fail​() ([]​byte​, *customError) {
    return​ ​nil​, ​nil
}
func​ ​main​() {
    var​ err error

fail을 호출하면 nil 값을 반환할 것이다. 하지만 error 인터페이스로써 반환하고 싶지만 customError 타입의 nil 값을 반환할 뿐이다. customError 타입은 이 소스 코드 안에서 만들어진 타입에 불과하다. 그렇기에 사용자 정의 타입을 직접 반환해서는 안되고 func fail() ([]byte, error) 처럼 인터페이스를 반환해야한다.

    if​ _, err = fail(); err != ​nil​ {
        log.Fatal(​"Why did this fail?"​)
    }
    log.Println(​"No Error"​)
}

에러 포장(Wrapping Errors)

오류 처리는 코드의 일부이며, 로깅으로 까지 이어진다. 로깅의 주목적은 디버그를 위한 것이다. 로그를 보고 대응이 가능한 것이라면 로그로 남긴다. 어떻게 실행되고 있는지 상황을 알려주는 것을 로그로 남긴다. 그 외의 것들은 노이즈나 다름 없으며, 대시보드의 지표로나 사용하면 된다. 예를 들자면, 소켓의 연결과 끊어짐을 로그로 남길 수는 있지만 딱히 대응을 해야 하거나 챙겨서 볼 필요는 없는 것이다.

여기 Dave Cheney가 작성한 errors라는 패키지가 있다. 이 패키지는 오류를 간단하게 처리할 수 있게 도와주고 동시에 로그를 기록해준다. 아래 코드는 이 패키지가 코드를 어떻게 단순하게 만들어 주는 지 보여준다. 로깅의 양을 줄임으로, 힙(주로 Garbage Collection)에 대한 부담을 줄일 수 있다.

import (
    "fmt"

    "github.com/pkg/errors"
)

AppError는 사용자 정의 에러 타입이다.

type​ AppError ​struct​ {
    State ​int
}

AppErrorerror 인터페이스를 구현한다.

func​ (c *AppError) ​Error​() ​string​ {
    return​ fmt.Sprintf(​"App Error, State: %d"​, c.State)
}
func​ ​main​() {

함수를 호출하고 오류를 검증한다. firstCallsecondCall를 호출하고 secondCallthirdCall을 호출하면 결과로 AppError가 반환된다. 호출 스택을 내려가다가 오류가 발생하는 thirdCall에 도달한다. 이 곳이 오류가 발생한 근원지이다. 이 오류는 error 인터페이스에 담겨서 호출 스택을 거슬러 올라간다.

secondCall로 돌아오면 error 인터페이스가 반환되며 그 내부엔 구현된 타입이 값으로 존재한다. secondCall은 오류를 처리할 수 없으면 올려보내거나 직접 처리할 지 결정해야한다. secondCall에서 오류를 처리하기로 결정한다면 로그로 남겨야할 책임이 주어지고 그렇지 않다면 이 책임은 호출 스택을 따라 거슬러 올라가게 된다. 하지만 호출 스택을 밀어 올린다더라도 컨텍스트를 잃지는 않는다. error 패키지가 들어올 시점이다. 이러한 오류를 새로운 문맥으로 래핑하거나 추가하여 새로운 인터페이스 값을 만든다. 이것은 코드 상의 호출 스택을 보여준다.

firstCall은 오류를 처리하지는 않지만 래핑해서 위로 올려준다. main에서 해당 오류를 처리하고 로그를 남기게 된다.

이 오류를 적절히 처리하기 위해서는 처음 발생한 오류, 래핑되지 않은 원시 오류에 대해 알 필요가 있다. Cause 메서드는 오류를 이러한 래핑으로부터 끌어내어 사용 가능한 모든 언어적 기술을 이용할 수 있게 해준다.

State에 접근하는 것뿐만 아니라, 구현 타입으로 단언했더라도 %+v를 이용하여 전체 스택 트레이스(stack trace)를 추적할 수 있다.

타입을 통한 컨텍스트의 처리를 이용하여 사용자 정의 오류로 밝혀낸다.

    if​ err := firstCall(​10​); err != ​nil​ {
        switch​ v := errors.Cause(err).(​type​) {
        case​ *AppError:
            fmt.Println(​"Custom App Error:"​, v.State)

오류의 스택 트레이스(stack trace)를 보여준다.

            fmt.Println(​"\nStack Trace\n********************************"​)
            fmt.Printf(​"%+v\n"​, err)
            fmt.Println(​"\nNo Trace\n********************************"​)
            fmt.Printf(​"%v\n"​, err)
        }
    }
}

firstCallsecondCall을 호출하고 오류를 래핑하여 반환한다.

func​ ​firstCall​(i ​int​) ​error​ {
    if​ err := secondCall(i); err != ​nil​ {
        return​ errors.Wrapf(err, ​"firstCall->secondCall(%d)"​, i)
    }
    return​ ​nil
}

secondCallthirdCall을 호출하고 오류를 래핑하여 반환한다.

func​ ​secondCall​(i ​int​) ​error​ {
    if​ err := thirdCall(); err != ​nil​ {
        return​ errors.Wrap(err, ​"secondCall->thirdCall()"​)
    }
    return​ ​nil
}

thirdCall 함수는 검사될 오류를 만든다.

func​ ​thirdCall​() ​error​ {
    return​ &AppError{​99​}
}
Custom App Error: 99
Stack Trace
********************************
App Error, State: 99
secondCall->thirdCall()
main.secondCall
    /tmp/sandbox880380539/prog.go:74
main.firstCall
    /tmp/sandbox880380539/prog.go:65
main.main
    /tmp/sandbox880380539/prog.go:43
runtime.main
    /usr/local/go-faketime/src/runtime/proc.go:203
runtime.goexit
    /usr/local/go-faketime/src/runtime/asm_amd64.s:1373
firstCall->secondCall(10)
main.firstCall
    /tmp/sandbox880380539/prog.go:66
main.main
    /tmp/sandbox880380539/prog.go:43
runtime.main
    /usr/local/go-faketime/src/runtime/proc.go:203
runtime.goexit
    /usr/local/go-faketime/src/runtime/asm_amd64.s:1373

No Trace
********************************
firstCall->secondCall(10): secondCall->thirdCall(): App Error, State: 99

동시성

Mechanics [재방문: 다른 번역자분들과 의견 맞추기]

고루틴(Goroutine)

Go 스케줄러의 내부

Go 프로그램이 시작할 때, 사용 가능한 코어의 갯수를 확인한다. 그리고 논리 프로세서를 생성한다.

OS(Operating System) 스케줄러는 선점 스케줄러로 간주되고 커널에서 실행 된다. OS 스케줄러는 runnable state에 있는 스레드를 실행(run)할 수 있도록 한다. 이 알고리즘은 매우 복잡하다(대기, 스레드 바운싱, 스레드 메모리 유지, 캐싱 등). 이 모든 작업을 OS가 수행하며, 멀티코어 프로세서(multicore processor)에서도 잘 동작한다. Go는 이를 잘 활용하기 위해 OS의 최상단에 위치한다.

여전히 OS는 OS 스레드에 대한 책임이 있고, 이를 효율적으로 스케줄링한다. 2개의 코어가 있는 머신에서 수천 개의 스레드를 스케줄링하는 것은 힘든 일이다. 어떤 작업을 하는지 모르는 일부 OS 스레드를 문맥교환(Context switch)하는 것은 비용이 큰 작업이다.

또한 가능한 모든 상태를 저장해야 그대로 스레드를 복원할 수 있다. 스레드 수가 적을 경우, 다시 스케줄링 되기 전까지 더 많은 실행 시간을 가질 수 있다. 스레드 수가 많을 경우, 각 스레드는 상대적으로 적은 실행 시간을 갖는다.

“Less is more”는 동시성 소프트웨어를 작성할 때 매우 중요한 컨셉이다. 선점형 스케줄러에 영향을 끼치기 위해 Go의 스케줄러의 논리 프로세서는 일반적으로 어플리케이션이 동작하고 있는 유저 모드(user mode)에서 실행한다. 그래서 Go의 스케줄러는 비선점 스케줄러라고 해야 한다. 유저 랜드(user land)에서는 여전히 선점형 스케줄러로 동작하는 것처럼 보인다. 여기서 훌륭한 것은 작업을 조정하는 런타임이다. “Less is more” 컨셉이 가져온 현재의 모습과 앞으로 해야 할 더 많은 작업을 적은 자원으로 실행하는 모습을 확인할 수 있을 것이다. 적은 쓰레드 수로 얼마나 많은 작업을 하는가가 관점이다.

프로세서는 하이퍼스레딩, 코어 마다 할당된 다수의 스레드, 클럭 주기 등의 이유로 복잡하기 때문에 쉽게 생각해보자. 코어에 대해 OS 스레드는 한번에 하나만 실행될 수 있다. 만약 하나의 코어만 있다면, 한번에 하나의 스레드만을 실행 할 수 있다. 실행 가능한 상태의 스레드가 소유한 코어보다 더 많으면 항상 부하, 실행 지연이 발생하며 우리가 원하는 것보다 더 많은 작업을 수행 하게 된다. 모든 스레드가 반드시 동시에 활성화 되어야 하는 것은 아니기 때문에 작업에 균형을 잡는 것이 필요하다. 이러한 모든 것은 우리가 작성하고 있는 소프트웨어의 작업량을 알아내고, 이해하는 것으로 귀결된다.

Go 프로그램이 시작되고 사용 가능한 코어수는 1개라고 가정하자. 그러면 해당 코어에 논리 프로세스 P가 생성된다.

다시 말하지만, OS는 OS 스레드와 관련된 일을 스케줄링 한다. 프로세서 P는, OS가 스케쥴링 하고 코드가 실행될 수 있도록 하는 OS 스레드 m을 할당받는다.

리눅스 스케줄러에는 실행 대기열이 존재한다. 스레드는 특정 코어 또는 코어군의 실행 대기열에 배치되며 스레드가 실행될 때 지속적으로 바운드된다[재방문: family of cores, bound]. Go는 이와 동일하다. Go는 Global Run Queue(GRQ)의 실행 대기열이 존재하며, 모든 P에는 Local Run Queue(LRQ)가 존재한다.

고루틴(Goroutine) [재방문: 번역 확인]

고루틴이란, 실행의 경로, 스레드의 실행경로, 스케줄링 되어야 하는 실행의 경로이다. Go에서는 모든 함수와 메소드는 고루틴으로 생성 될 수 있으며 특정 코어, 특정 OS 스레드에 의해 독립적 실행이 되도록 스케줄링이 가능하다.

Go 프로그램을 시작할 때 런타임은 고루틴을 생성한다. 그리고 특정 프로세서P의 LRQ에 넣는다. 우리는 1개의 프로세서 P가 있다고 가정하고, 생성된 고루틴은 프로세서 P에 포함될 것이다.

고루틴은 스레드와 같이 sleeping, executing 그리고 하드웨어에 의해 실행될때까지 대기하는 실행 가능한 상태인 runnable 중 하나가 될 것이다. 런타임에 고루틴이 생성되면, 프로세서 P에 위치하며 해당 스레드 위에서 다중화(multiplex) 된다. 스레드를 스케줄링하고 코어에 배치하고 실행 하는 것은 OS의 역할이며, 따라서 Go 스케줄러는 고루틴의 실행 경로와 관련된 모든 코드를 가져 와서 스레드에 배치하고 OS에 대상 스레드가 runnable 상태이며 실행할 수 있음을 요청한다. 요청이 가능하다면, 특정 코어에서 이를 실행한다.

메인 고루틴이 실행중일 때, 더 많은 실행 경로를 생성, 더 많은 고루틴을 생성하고자 할 수 있다.

만약 그렇다면, 해당 고루틴을 GRQ에서 찾을 수 있을 것이다. 이들은 runnable 상태지만, 아직 프로세서 P에 할당되지 않은 상태이다. 이후에 LRQ에 할당되고, 실행 요청을 한다.

이 대기열은 FIFO(First-In-First-Out)을 꼭 따르진 않는다. 모든것은 OS 스케줄러처럼 결정적이지 않다. 모든 조건이 동일해도 스케줄러가 무엇을 수행할지 예측할 수 없다. 우리가 이러한 고루틴의 실행에 대한 조정하는 방법을 배워, 오케스트레이션 할 수 있을 때 까지는 예측할 수 없다.

아래의 도표는 이에 대한 예제를 표현한 맨탈모델이다.

117-1

프로세서 Pm을 위한 Gm이 실행하고, 2개의 G1G2 고루틴을 생성한다. 이것은 협조적 스케줄러(cooperating scheduler)이기에, 고루틴은 비선점적으로 스케줄링되고 운영체제 스레드인 m은 문맥교환(context switch)이 생기는 것을 의미한다.

스케줄러가 스케줄링을 하게 되는 4가지 상황이 있다.[재방문]

다시 예제로 돌아와, 스케줄러는 Gm이 실행되기까지 충분한 시간이 남았을때, Gm을 실행 대기열(run queue)에 넣고 G1이 해당 m에서 실행되도록 혀용한다(문맥교환).

117-2

고루틴 G1에서 파일을 연다고 해보자. 파일을 여는 작업에 걸리는 시간이 얼마나 소요될지는 알 수 없다. 만약 파일을 열 때 이 고루틴(G1)이 OS 스레드를 블록(block) 시킨다면, 더 이상의 다른 작업을 완료할 수가 없다. 이 예제는 하나의 프로세서(P)와 싱글 스레드(m)로 동작하는 어플리케이션이다. 모든 고루틴은 프로세서(P)에 할당된 스레드(m)에서만 동작한다. 만약 고루틴이 프로세서(P)에 할당된 스레드(m)을 오랫동안 블록(block)시킨다면 어떻게 될까? 작업이 완료될 때까지 아무것도 할 수 없다. 이런일이 발생하지 않도록 하기 위해 스케줄러는 mG1을 분리한다. 새로운 mm2를 가져오고, 실행 대기열(run queue)에서 다음에 실행할 G, 즉 G2를 결정한다.

118-1

이제 싱글 스레드로 작성된 프로그램에 2개의 스레드가 있다. 우리의 관점에서 여전히 싱글 스레드 인데, 모든 고루틴과 관련된 코드는 프로세서(P)와 OS 스레드(m)에 대해서만 실행할 수 있기 때문이다. 하지만 어떤 m이 처리되고 있는지 알 수는 없다. 스레드(M)은 교체될 수 있고, 여전히 싱글 스레드로 이다.

G1이 파일 열기 작업을 끝냈을 때, 스케줄러는 G1을 실행 대기열(run queue)에 넣고나서 특정 스레드, 예제에서는 m2를 다시 실행할 수 있다. m은 다시 사용하기 위해 남겨지고, 여전히 2개의 스레드를 유지하고 있다. 전체 과정은 다시 일어날 수 있다.

119-1

이렇듯 하나의 스레드 상에서 더 많은 작업을 수행함으로써, 해당 스레드로부터 최대의 가용성을 끌어 낼 수 있는 훌륭한 방법이다. 따라서 추가적인 스레드없이도 충분한 작업을 할 수 있다.

네트워크 폴러가 있고, 모든 로우 레벨(low level)의 비동기 네트워킹 작업을 수행한다. 고루틴은 이러한 작업을 수행할 경우, 네트워크 풀러로 이동한 다음 다시 대기열 뒤로 가져온다. 명심할 것은 작성된 코드는 프로세서(P)에 대응한 스레드(m)에서 실행된다는 것이다. 얼마나 많은 쓰레드를 실행하는 지는 프로세서(P)를 얼마나 가지고 있는지에 달려있다.

동시성이란 이런 많은 작업들을 한번에 관리할 수 있는 것을 의미하고, 이것이 스케줄러의 역할이다. 하나의 OS 스레드(m)에 의해 3개의 고루틴은 오직 한번에 하나의 고루틴만 실행될 수 있기 때문에, 하나의 프로세서(P), 하나의 스레드(m) 그리고 3개의 고루틴 실행을 관리한다. 만약 한번에 많은 일들을 동시에 처리하고 싶다면, 다시 말해서 병렬(parallel)처리하고 싶다면 또 다른 스레드(m3)를 처리할 수 있는 프로세서(P)가 하나 더 필요하다.

119-2

멀티 프로세서는 OS에 의해 스케줄링 된다. 이제 2개의 고루틴을 병렬(parallel)로 처리할 수 있다.

2개의 스레드로 실행되는 다중 스레드 소프트웨어를 가정해보자. 이제 2개의 고루틴을 병렬(parallel)로 처리할 수 있다. 프로그램은 2개의 스레드를 실행하고, 두 스레드가 동일한 코어에 있는 경우에도 서로에게 메세지를 전달하려고 한다. OS의 관점에서는 어떤 일이 일어나는지 알아보자.

먼저 첫번째 스레드가 스케줄링 되고 특정 코어에 할당될 때까지 기다려야 한다(문맥교환 발생). 이때, 아직 스레드는 대기 상태이므로 어떤 것도 실행 상태가 아니다. 첫번째 스레드에서 메세지를 보내고, 이에 대한 응답을 받기를 기다린다. 응답을 받기 위해서, 해당 코어에 다른 스레드를 배치할 수 있고 이를 통해서 또 다른 문맥교환이 발생한다. OS가 두번째 스레드를 스케줄링하기를 기다리며 또 다른 문맥교환이 발생한다. 대기 상태의 스레드를 깨우고 실행시켜 메세지를 처리한다. 메세지 전달 과정을 통해, 스레드는 실행 가능 상태(excutable state)에서 실행 대기 상태(runnable state)로 전환되며 대기 상태(asleep state) 순으로 바뀐다. 이러한 문맥교환들은 많은 비용(cost)이 발생한다

단일 코어에서 고루틴을 사용하면 어떤지 살펴보자. G1G2에게 메세지를 보내려 하고 문맥 교환이 일어난다. 하지만 이 문맥(context)은 사용자의 공간 전환이다. 스레드에서 실행 중인 G1G2로 전환할 수 있다. OS의 관점에서 이 스레드는 sleep 상태가 되지 않는다. 이 스레드는 항상 실행중이며, 문맥교환을 할 필요가 없다. Go 스케줄러는 고루틴을 계속해서 문맥교환 시켜준다.

프로세서(P)에 할당된 특정 스레드(m)가 처리할 고루틴(G)이 없다면, 런타임 스케줄러는 해당 스레드 코어 속에서 일정 시간 유효(hot status)할 수 있도록 스핀(spin) 상태로 만들어준다. 왜냐하면, 스레드가 더 이상 유효하지 않은 상태(cold status)라면 OS는 해당 스레드를 코어에서 빼내고 다른 스레드로 교체하기 때문이다. 따라서 비어 있는 스레드에 할당되어 처리할 고루틴(G)이 있을지 확인하기 위해, 잠시 스핀(spin) 상태가 되는 것이다.

이것이 스케줄러가 작동하는 방식이다. 프로세서(P)와 스레드(m)이 있고, OS는 스케줄링 작업을 할 것이다. 코어의 갯수보다 더 많은 것을 필요로 하지 않는데, 코어의 갯수보다 더 많은 OS 스레드가 필요하지 않다. 코어의 갯수보다 더 많은 스레드가 있다는 것은 OS에 적재하는 방법 뿐인데, Go의 스케줄러는 고루틴에 대해 최소한으로 필요한 스레드 수를 유지하고 작업을 계속해서 수행할 수 있도록 한다. Go의 스케줄러는 비선점적인 스케줄링으로 호출되더라도 선점된 것처럼 보인다.

하지만 개발을 쉽게 하기 위해서 스케줄링의 작동에 대해 잊고, 모든 고루틴(G)에 대해 runnable state에 있는 고루틴들은 모두 동시에 실행이 가능하다고 이해하자.

Language Mechanics [재방문: 다른 번역자분들과 의견 맞추기, 의견: 언어 구조학]

소프트웨어가 깔끔하게 시작, 종료되도록 코드를 작성하는 것은 매우 중요하다.

package main

import (
    "fmt"
    "runtime"
    "sync"
)

‘init’ 함수는 런타임 패키지에서 GOMAXPROCS을 호출한다. 환경 변수이기 때문에 대문자로 표기된다.

Go 1.5 이전에서는 코어 수의 관계 없이 하나의 프로세서(P)만 제공 되었다. 가비지 콜렉터와 스케줄러의 개선으로 모든것이 개선되었다.

스케줄러에게 하나의 논리 프로세서만 할당할 것을 명시한다.

func init() {
    runtime.GOMAXPROCS(1)
}

func main() {

wg는 동시성을 관리하는데 사용된다. wg는 제로값으로 설정된다. 또한 제로값 상태에서 사용할 수 있는 Go의 매우 특별한 타입이다. 그리고 비동기 계산 세마포어(Asynchronous Counting Semaphore)로 불린다.

세개의 Add, Done, Wait 메소드를 갖는다. n개의 고루틴은 이 메소드를 동시에 호출 할 수 있고, 모두 직렬화(serialized)되어 있다.

    var wg sync.WaitGroup

2개의 고루틴을 생성하자. 반대로 Add(1)을 호출하고, 1씩 증가하기 위해 반복한다. 만약 얼마나 많은 고루틴이 생성될지 모른다면, 그것은 코드 스멜(smell)이다.

    wg.Add(2)

    fmt.Println("Start Goroutines")

익명함수를 사용해 uppercase 함수에서 고루틴을 생성한다. 익명함수의 끝에는 ()을 사용해 호출한다. main 함수 내부에 익명함수가 있고, 그 앞에 go 키워드에 주목하자. 지금은 실행하지 않고, Go 스케줄러는 해당 함수를 G로 예약한다. 이를 G1이라고 하자. 그리고 P에 대해 일부 LRQ를 로드한다. 이것이 첫 G이다. 기억할것은, 모든 G에 대해서 runnable state라면, 동시에 실행할 수 있다. 싱글 프로세서(P)일지라도, 싱글 스레드일지라도 전혀 상관없다. 2개의 고루틴을 동시에 실행(main 그리고 G1)한다.

    go func() {
        lowercase()
        wg.Done()
    }()

lowercase 이후에 고루틴을 하나 더 생성한다. 따라서, 이제는 3개의 고루틴이 동시에 실행된다.

go func() {
    uppercase()
    wg.Done()
}()

고루틴이 끝날때까지 기다려보자. 메인이 종료되는 것을 대기(holding)시키는데, 메인이 종료될 때, 우리의 프로그램은 종료되고, 다른 고루틴을 신경쓰지 않기 때문이다.

여기서 중요한 것은 언제, 어떻게 고루틴이 종료되는지 모른다면 고루틴을 만들 수도 없다는 것이다. Wait는 두 개의 고루틴이 Done이 될때까지 대기(hold)하도록 한다. 2에서 0이 될 때까지 카운트하고, 0에 도달했을 때, 스케줄러는 메인 고루틴을 마저 실행하고, 종료 될 수 있도록 한다.

    fmt.Println("Waiting To Finish") wg.Wait()
    wg.Wait()

    fmt.Println("\nTerminating Program")
}

lowercase 함수는 알파벳 소문자를 세번 반복 출력한다.

func lowercase() {
    for count := 0; count < 3; count++ {
        for r := 'a'; r <= 'z'; r++ {
            fmt.Printf("%c ", r)
        }
    }
}

uppercase 함수는 알파벳 대문자를 세번 반복 출력한다.

func uppercase() {
    for count := 0; count < 3; count++ {
        for r := 'A'; r <= 'Z'; r++ {
            fmt.Printf("%c ", r)
        }
    }
}
Start Goroutines
Waiting To Finish
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J
K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T
U V W X Y Z a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d
e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m n
o p q r s t u v w x y z
Terminating Program

순서

lowercase 함수를 호출하고, uppercase 함수를 호출했지만, Go 스케줄러는 lowercase 함수를 먼저 호출했다. 싱글 스레드에서 동작하기에 순간에 1개의 고루틴만 실행할 수 있는 점을 기억하자. 동시성을 지키며 실행되는지 알 수 없는데, uppercase 함수가 lowercase 함수보다 먼저 실행되는지 알 수 없다. 시작, 종료에 문제가 없다.

대기를 위해 Wait하지 않으면 어떻게 될까?

uppercase 함수와 lowercase 함수의 결과를 볼 수 없다. Go 스케줄러가 프로그램 종료를 막고 새로운 고루틴을 만들어 작업을 할당하기 전에 프로그램이 종료되는 일종의 경쟁으로 보인다. 기다리지 않기 때문에 고루틴은 실행할 기회가 전혀 없다.

Done을 호출하지 않으면 어떻게 될까?

교착상태(Deadlock)가 발생한다. Go의 특별한 부분이며, 런타임에서 고루틴이 존재하지만 더 이상 진행할 수 없을 때 패닉(panic)상태가 된다.

고루틴 시분할(time slicing)

Go의 스케줄러는 선점 스케줄러(preemptive)가 아닌 협력 스케줄러(cooperating scheduler)에도 선점된 것처럼 생각되는 이유는 런타임 스케줄러가 프로그래머에게 인식되기 전에 모든 처리를 하기 때문이다.

아래의 코드는 문맥교환을 보여주고, 언제 문맥교환이 발생하는지 예상할 수 있도록 보여준다. 위 코드와 같은 패턴이지만 printPrime 함수가 새로 추가된다.

package main

import (
    "fmt"
    "runtime"
    "sync"
)

하나의 논리 프로세서를 스케줄러에게 할당한다.

func init() {
    runtime.GOMAXPROCS(1)
}

wg는 동시성을 관리하기 위해 사용한다.

func main() {
    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("Create Goroutines")

첫번째 고루틴을 생성하고, 생명주기를 관리한다.

    go func() {
        printPrime("A")
        wg.Done()
    }()

두번째 고루틴을 생성하고, 생명주기를 관리한다.

    go func() {
        printPrime("B")
        wg.Done()
    }()

고루틴이 종료될 때까지 대기한다.

    fmt.Println("Waiting To Finish")
    wg.Wait()

    fmt.Println("Terminating Program")
}

printPrime 함수는 5000보다 작은 소수를 출력한다. 특별한 함수는 아니지만, 완료하기 위해 약간의 시간이 필요하다. 프로그램을 실행하면 특정 소수에서 문맥교환이 일어나는 것을 볼 수 있다. 하지만 문맥교환이 언제 일어날 지는 예측할 수 없기에 Go의 스케줄러가 협력 스케줄러임에도 불구하고 선점 스케줄러처럼 보인다고 말하는 이유이다.

func printPrime(prefix string) {
next:
    for outer := 2; outer < 5000; outer++ {
        for inner := 2; inner < outer; inner++ {
            if outer%inner == 0 {
                continue next
            }
        }

        fmt.Printf("%s:%d\n", prefix, outer)
    }

    fmt.Println("Completed", prefix)
}
Create Goroutines
Waiting To Finish
B:2
B:3
B:5
B:7
B:11
B:13
B:17
B:19
...
B:4999
Completed B
A:2
A:3
A:5
A:7
A:11
A:13
A:17
A:19
...
A:4999
Completed A
Terminating Program

고루틴과 병렬

이 프로그램은 고루틴이 병렬처리 되는 것을 보여준다. 2개의 프로세서(P), 2개의 스레드(m) 그리고 2개의 고루틴이 각각의 스레드(m)에서 병렬 처리 된다. 이전 프로그램과 비슷하지만 lowercase 함수와 uppercase 함수를 없애고, 익명 함수로 처리한다.

package main
import (
    "fmt"
    "runtime"
    "sync"
)

func init() {

스케줄러에게 2개의 논리 프로세서를 할당한다.

    runtime.GOMAXPROCS(2)
}

func main() {

wg는 프로그램이 종료될때까지 기다리는데 사용한다. Add에 2를 추가함으로, 2개의 고루틴이 종료될 때까지 대기한다.

    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("Start Goroutines")

소문자 알파벳을 3번 출력하는 익명 함수를 선언하고, 고루틴을 생성한다.

    go func() {
        for count := 0; count < 3; count++ {
            for r := 'a'; r <= 'z'; r++ {
                fmt.Printf("%c ", r)
            }
        }
        wg.Done() //메인(main)에게 작업이 끝났음을 알린다.
    }

대문자 알파벳을 3번 출력하는 익명 함수를 선언하고, 고루틴을 생성한다.

    go func() {
        for count := 0; count < 3; count++ {
            for r := 'A'; r <= 'Z'; r++ {
                fmt.Printf("%c ", r)
            }
        }
        wg.Done() //메인(main)에게 작업이 끝났음을 알린다.
    }()

고루틴이 끝나기를 기다린다.

    fmt.Println("Waiting To Finish")
    wg.Wait()

    fmt.Println("\nTerminating Program")
}

소문자와 대문자가 섞여서 출력되는 것을 확인할 수 있다.

Start Goroutines
Waiting To Finish
a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j
k l m n o p A B C D E F G H I J K L M N O P Q R S q r s t u v w x y z a
b c d e f g h i j k l m n o p q r s t u v w x y z T U V W X Y Z A B C D
E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N
O P Q R S T U V W X Y Z
Terminating Program

자원 경쟁(Data race)

경쟁 감지(Race Detection)

프로그램에 고루틴을 추가하면 복잡도가 엄청나게 올라간다. 고루틴을 언제나 상태없이(stateless) 실행할 수는 없기에 조율이 필요하다. 멀티 스레드 소프트웨어를 작성할 때는 사실상 두 가지 선택지가 있다.

채널이 없었을 때는, 아토믹 함수나 mutex를 사용하여 앞서 언급한 두 가지 선택지를 구현하였다. 채널은 간단한 제어 방법을 제공하지만, 대부분의 경우는 아토믹 함수와 mutex를 사용하여 공유 자원에 대한 액세스 동기화를 사용하는 것이 가장 좋은 방법이다. atomic 연산은 Go에서 가장 빠른 방법이다. Go는 메모리에서 한번에 4-8 바이트씩 동기화를 하기 때문이다.

Mutex는 다음으로 빠르다. 채널은 매우 느린데, mutex일 뿐만 아니라 모든 데이터 구조와 로직이 함께 있기 때문이다. 여러개의 고루틴이 같은 자원에 접근하려할 때 자원 경쟁이 발생한다. 예를 들어, 2개의 고루틴이 int 타입의 counter라는 변수에 같은 시각에 접근하길 원하는 상황을 가정해 본다. 만약 실제로 같은 시간에 접근한다면 읽고 쓰기 위해 상호배제 할 것이다. 그렇기 때문에 공유하는 자원에 대해서 이런 접근이 필요할 때는 조정이 필요하다.

진짜 문제는 이러한 자원 경쟁이 항상 예상치 못하게 나타난다는 것이다. 예시 프로그램을 통해, 우리가 원하지 않는 자원 경쟁 상태를 만들어서 확인한다.

package main

import (
    "fmt"
    "runtime"
    "sync"
)

counter는 모든 고루틴에 의해 증가되는 변수이다.

var counter int

func main() {

사용할 고루틴의 수.

    const grs = 2

wg는 동시성을 관리하는데 사용된다.

    var wg sync.WaitGroup
    wg.Add(grs)

2개의 고루틴을 만들어준다.

두번 반복: local counter에 읽기를 수행하고 1 씩 증가한 다음 공유 상태에 다시 쓴다. 프로그램을 실행할 때마다 출력은 4가되어야한다. 여기서 발생하는 자원 경쟁: 주어진 시간동안 두개의 고루틴은 동시에 읽고, 쓸 수 있다. 그러나 우리는 운이 좋게도 각각의 고루틴이 3번의 실행을 모두 atomic하게 실행하고있다는 것을 볼 수 있다.

만약 runtime.Goshed()라는 줄을 추가하게되면, 다른 고루틴에게 CPU를 양보하게된다. 이때, 공유 자원을 읽게되면, 강제로 context switch가 일어나게되고, 자원 경쟁이 발생할 수 있다. 그렇게 되면 다시 돌아왔을 때 4라는 결과값을 얻지 못할수도 있다.

    for i := 0; i < grs; i++ {
        go func() {
            for count := 0; count < 2; count++ {

이 때의 counter 값을 저장해 둔다.

                value := counter

다른 고루틴에게 스레드를 양보하고, 다시 대기열에 들어간다.

FOR TESTING ONLY! DO NOT USE IN PRODUCTION CODE!

                runtime.Gosched()

counter의 값을 늘린다.

                value++

값을 counter에 다시 저장한다.

                counter = value
            }
            wg.Done()
        }()
    }

고루틴이 끝날 때까지 기다린다.

    wg.Wait()
    fmt.Println("Final Counter:", counter)
}

To identify race condition : go run -race .

==================
WARNING: DATA RACE
Read at 0x000001228340 by goroutine 8:
main.main.func1()

/Users/hoanhan/work/hoanhan101/ultimate-go/go/concurrency/data_race_1.go :65 +0x47
Previous write at 0x000001228340 by goroutine 7: main.main.func1()
/Users/hoanhan/work/hoanhan101/ultimate-go/go/concurrency/data_race_1.go

:75 +0x68
Goroutine 8 (running) created at: main.main()
/Users/hoanhan/work/hoanhan101/ultimate-go/go/concurrency/data_race_1.go :62 +0xab
Goroutine 7 (finished) created at: main.main()
/Users/hoanhan/work/hoanhan101/ultimate-go/go/concurrency/data_race_1.go :62 +0xab
==================
Final Counter: 4
Found 1 data race(s)
exit status 66

아토믹 함수(Atomic Functions)

package main

import (
    "fmt"
    "runtime"
    "sync"
    "sync/atomic"
)

counter는 모든 고루틴에 의해 증가되는 변수이다. 해당 변수가 int가 아닌 int64 타입이라는 것에 유의해야한다. 아토믹 함수의 경우 정확성을 요구하기 때문에 구체적인 타입을 명시해야한다.

var counter int64

func main() {

grs에 사용할 고루틴의 수를 지정.

    const grs = 2

wg는 동시성을 관리하는데 사용된다.

    var wg sync.WaitGroup
    wg.Add(grs)

2개의 고루틴들을 생성한다.

    for i := 0; i < grs; i++ {
        go func() {
            for count := 0; count < 2; count++ {

counter에 동시성에 안전하게 1을 더해준다. 동기화 보장을 원하는 대상의 주소를 첫 번째 매개변수로 하는 원지적 연산 함수를 사용한다. 같은 주소에 대해 이러한 함수들을 사용하면, 이것들은 직렬화된다. 이것이 직렬화 할 수 있는 가장 빠른 방법이다.

우리는 이 프로그램을 하루 종일 실행하더라도 매번 4라는 값을 얻을 수 있다.

                atomic.AddInt64(&counter, 1)

이 호출은 AddInt64 함수 호출이 완료됐을 때 counter가 이미 증가했으므로 큰 의미가 없다.

                runtime.Gosched()
            }
        wg.Done()
        }()
    }

고루틴이 끝날 때까지 기다린다.

    wg.Wait()

최종 값을 보여준다.

    fmt.Println("Final Counter:", counter)
}
Final Counter: 4

뮤텍스(Mutexes)

일반적으로 데이터 공유를 하기 위해 매번 4-8바이트를 할당할 만큼 메모리가 여유롭지 않다. 이럴 때 뮤텍스를 사용하면 좋다. 뮤텍스를 사용하면 모든 고루틴이 한번에 하나씩 실행할 수 있는 WaitGroup(Add, Done and Wait)과 같은 API를 사용할 수 있다.

package main

import (
    "fmt"
    "sync"
)

var (

counter는 모든 고루틴들에 의해 증가되는 변수이다.

    counter int

mutex는 코드의 임계 구역을 정의하는데 사용된다. 모든 고루틴들이 통과해야하는 방으로 mutex를 상상해보자. 그러나 한번에 하나의 고루틴만이 이동할 수 있다. 스케줄러는 누가 들어갈지, 그리고 누가 다음이될지를 정한다. 우리는 스케줄러가 무엇을 할 지 결정할 수 없다. 바라건대, 그것은 공정할 것이다. 한 고루틴이 다른 고루틴들보다 먼저 문에 도착했다고해서 먼저 끝난다는 것을 의미하지는 않는다. 여기는 예측할 수 있는것이 없다.

여기서 핵심은 들어오도록 허용된 고루틴이, 나갈 때 보고해야 한다는 것이다. 모든 고루틴들은 다른 고루틴이 들어오도록 나갈때, 잠금과 해제를 요청한다. 두 개의 다른 함수가 동일한 mutex를 사용할 수 있으므로 한번에 하나의 고루틴만 주어진 함수를 실행할 수 있다.

    mutex sync.Mutex
)
func main() {

grs에 사용할 고루틴의 수를 지정.

    const grs = 2

wg는 동시성을 관리하는데 사용된다.

    var wg sync.WaitGroup
    wg.Add(grs)

2개의 고루틴들을 생성한다.

    for i := 0; i < grs; i++ {
        go func() {
            for count := 0; count < 2; count++ {

Only allow one Goroutine through this critical section at a time. Creating these artificial curly brackets gives readability. We don’t have to do this but it is highly recommended. The Lock and Unlock function must always be together in line of sight. 한번에 오직 하나의 고루틴만이 임계 구역에 들어올 수 있도록 허용된다. 이렇게 중괄호를 만들어주면 가독성이 높아진다. 이 작업을 꼭 할 필요는 없지만 적극적으로 권장한다.. 잠금과 해제 함수는 항상 같은 맥락에 있도록 해야한다.

                mutex.Lock()
                {

counter의 값을 저장한다.

                    value := counter

counter의 로컬 값을 늘린다.

                    value++

값을 다시 counter에 저장해준다.

                    counter = value
                }
                mutex.Unlock()

잠금을 해제하고, 대기중인 고루틴들이 들어올 수 있도록 허용한다.

            }

            wg.Done()
        }()
    }

고루틴이 끝날 때까지 기다린다.

    wg.Wait()
    fmt.Printf("Final Counter: %d\n", counter)
}
Final Counter: 4

읽기/쓰기 뮤텍스(Read/Write Mutex)

많은 고루틴이 읽기 원하는 공유 자원이 있다고 하자.

때때로, 하나의 고루틴이 들어와서 리소스를 바꿀 수 있다. 그렇게 되면, 모두 읽는 것을 중단해야한다. 아무런 이유 없이 소프트웨어에 대기시간을 추가하기 때문에 이러한 유형의 사나리오에서 읽기를 동기화하는 것은 의미가 없다.

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "sync/atomic"
    "time"
)

data는 공유될 slice 이다.

var (
    data []string

rwMutex는 코드의 임계 구역을 정의하는데 사용된다. 그것은 뮤텍스보다 살짝 느리지만 먼저 정확성을 최적화하고 있으므로 지금은 신경쓰지 않는다.

    rwMutex sync.RWMutex

조회하는 시간에 시도된 읽기 수를 의미한다. 여기서 int64를 보자마자 아토믹 명령어 사용에 대해 생각해야한다.

    readCount int64
)

initmain보다 먼저 호출된다.

func init() {
    rand.Seed(time.Now().UnixNano())
}

func main() {

wg는 동시성을 관리하는데 사용된다.

    var wg sync.WaitGroup
    wg.Add(1)

10개의 서로 다른 쓰기를 수행하는 쓰기용 고루틴을 만든다.

    go func() {
        for i := 0; i < 10; i++ {
            time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
            writer(i)
        }
        wg.Done()
    }()

영원히 실행되는 8개의 읽기용 고루틴을 만든다.

    for i := 0; i < 8; i ++ {
        go func(i int) {
            for {
                reader(i)
            }
        }(i)
    }

쓰기 고루틴이 끝날 때까지 기다린다.

    wg.Wait()
    fmt.Println("Program Complete")
}

쓰기용 고루틴은 임의의 간격으로 슬라이스에 새 문자열을 추가한다.

func writer(i int) {

한번에 오직 하나의 고루틴만이 슬라이스에 읽기/쓰기를 하도록 허용된다.

    rwMutex.Lock()
    {

현재 readCount를 캡쳐한다. 이 호출 없이 수행할 수 있지만 안전하게 처리하는 것이 좋다. 다른 고루틴이 읽기를 수행하지 않는 것을 보장해야 한다. 이 코드가 실행될 때 rc의 값은 항상 0 이어야한다.

        rc := atomic.LoadInt64(&readCount)

전체 잠금이 있으므로 작업을 수행한다.

        fmt.Printf("****> : Performing Write : RCount[%d]\n", rc)
        data = append(data, fmt.Sprintf("String: %d", i))
    }
    rwMutex.Unlock()
}

reader가 수행되고 데이터 슬라이스를 반복한다.

func reader(id int) {

모든 고루틴은 쓰기 작업이 일어나지 않을 때 읽을 수 있다. RLock에는 그에 해당하는 RUnlock이 있다.

    rwMutex.RLock()
    {

readCount를 1씩 증가시킨다.

        rc := atomic.AddInt64(&readCount, 1)

읽기 작업을 수행하고 값을 표시한다.

        time.Sleep(time.Duration(rand.Intn(10)) * time.Millisecond)
        fmt.Printf("%d : Performing Read : Length[%d] RCount[%d]\n", id, len(data), rc)

readCount를 1씩 감소시킨다.

        atomic.AddInt64(&readCount, -1)
    }
    rwMutex.RUnlock()
}

출력은 이와 유사하게 잠긴다.

0 : Performing Read : Length[0] RCount[1]
4 : Performing Read : Length[0] RCount[5]
5 : Performing Read : Length[0] RCount[6]
7 : Performing Read : Length[0] RCount[7]
3 : Performing Read : Length[0] RCount[4]
6 : Performing Read : Length[0] RCount[8]
4 : Performing Read : Length[0] RCount[8]
1 : Performing Read : Length[0] RCount[2]
2 : Performing Read : Length[0] RCount[3]
5 : Performing Read : Length[0] RCount[8]
0 : Performing Read : Length[0] RCount[8]
7 : Performing Read : Length[0] RCount[8]
7 : Performing Read : Length[0] RCount[8]
2 : Performing Read : Length[0] RCount[8]
...
1 : Performing Read : Length[10] RCount[8]
5 : Performing Read : Length[10] RCount[8]
3 : Performing Read : Length[10] RCount[8]
4 : Performing Read : Length[10] RCount[8]
6 : Performing Read : Length[10] RCount[8]
7 : Performing Read : Length[10] RCount[8]
2 : Performing Read : Length[10] RCount[8]
2 : Performing Read : Length[10] RCount[8]

Lesson:

아토믹 함수와 뮤텍스는 사용자의 소프트웨어에 대기시간을 만든다. 대기시간은 여러 고루틴 사이에 자원 접근에 대한 조율이 필요할 때 유용하다. Read/Write 뮤텍스는 대기시간을 줄이는 데 유용하다.

뮤텍스를 사용하는 경우, 잠금 이후 최대한 빨리 잠금을 해제해야 한다. 다른 불필요한 행위는 하지 않는 것이 좋다. 때로는 공유 자원을 읽기를 위해 로컬 변수만 사용하는 것으로도 충분하다. 뮤텍스를 적게 사용할수록 좋다. 이를 통해 대기시간을 최소한으로 줄일 수 있다.

채널

언어 작동 원리

채널은 오케스트레이션(orchestration)을 위해 존재한다. 채널을 쓰면 2개의 고루틴(Goroutine)이 특정 워크플로우를 함께 처리하도록 할 수 있으며 또한 우리가 원하는 대로 워크플로우를 관리할 수 있다. 채널이 큐(queue)처럼 선입선출(first-in-first-out)로 구현된 듯 하지만 우리가 생각해야 하는 점은 채널이 큐라는 것이 아니다. 채널을 큐라고 생각하면 개념을 이해하기 어려워진다. 대신에 채널은 또다른 고루틴에 이벤트(event)가 발생했다는 신호를 주는 방식이라고 생각해야 한다. 멋지게도, 데이터가 있을 때는 물론 데이터 없이도 이벤트 신호를 줄 수 있다.

우리가 무슨 일을 하든 신호 주기(signaling)를 염두에 둔다면 채널을 적절한 방식으로 쓸 수 있다. 고(Go)에는 두 종류의 채널이 있다. 버퍼 없는(unbuffered) 채널과 버퍼 있는(buffered) 채널이다. 두 종류 중 어떤 것을 쓰든 신호를 주며 데이터를 보낼 수 있다. 중요한 차이점은 버퍼 없는 채널을 쓰면 신호를 줄 때 그 신호가 전달되었다는 보장을 받을 수 있다는 점이다. 고루틴이 우리가 할당한 작업을 끝냈는지 아닌지에 대해서는 알 수 없지만 적어도 그 보장은 받을 수 있다. 대신 신호가 전달되었다는 보장을 받는 대가로 더 긴 지연 시간(latency)을 감수해야 한다. 왜냐하면 버퍼 없는 채널의 반대편에 있는 고루틴이 데이터를 받았다는 것을 확실히 하기 위해 그 시점까지 기다려야 하기 때문이다.

버퍼 없는 채널이 작동하는 방식은 다음과 같다. 채널에 신호를 주려는 고루틴이 있다고 해보자. 채널은 신호와 함께 데이터도 보내려고 한다. 고루틴은 채널에 바로 데이터를 넣을 것이다. 하지만 채널은 반대편에 신호를 받을 고루틴이 있는지 알아야 하기 때문에 그때까지 데이터는 잠기고(locked) 움직일 수 없게 된다. 두 고루틴 모두 채널에 관여하고 있지 않고 있다. 마침내 고루틴이 와서 데이터를 받겠다고 말할 때, 데이터를 전송할 수 있다.

여기에 버퍼 없는 채널이 신호가 전달되었다는 보장을 해줄 수 있는 이유가 있다. 신호 받기가 먼저 일어난다. 신호 받기가 일어나면 신호가 전달되었다는 것을 알 수 있고, 이제야 다른 일을 하러 갈 수 있다.

139

버퍼 없는 채널은 매우 강력한 채널이다. 이 보장을 최대한 활용하면 좋다. 하지만 다시 한번 말하건대 보장의 대가는 기다림 때문에 일어나는 긴 지연 시간이다.

버퍼 있는 채널은 약간 다르다. 우리는 신호가 전달되었다는 보장을 얻지 못하는 반면 신호 주기와 받기 모두에서 지연 시간을 줄일 수 있다.

앞의 예시로 되돌아가보자. 버퍼 없는 채널을 버퍼 있는 채널로 바꿨다고 해보자. 버퍼가 1밖에 안되는 채널을 쓴다고 가정할 것이다. 이는 채널 안에 1개의 데이터 조각을 위한 공간이 있다는 뜻이며 반대편이 데이터를 받을 때까지 기다릴 필요가 없다는 뜻이다. 따라서 이제 어떤 고루틴이 와서 데이터를 채널에 넣고 바로 가버릴 수 있다. 다시 말하면 신호 보내기가 신호 받기보다 먼저 일어난다. 고루틴이 신호 주기에 대해 아는 전부는 신호를 보냈고, 데이터를 넣었다는 것 뿐이며 신호가 언제 전달될지에 대해서는 아는 것이 없다. 이제 다른 고루틴이 와서 데이터가 있는 것을 보고 그걸 받아 가기를 바랄 뿐이다.

140

버퍼가 1개 있는 채널은 이런 종류의 지연 시간을 다룰 때 쓴다. 더 큰 버퍼가 필요할 때도 있지만 1개 이상의 버퍼를 쓸 때 적용되는 설계 규칙을 나중에 배우게 될 것이다. 하지만 신호 주기가 들어오고 있고 그 신호들이 잠길(locked) 가능성이 있는 상황이라면 우리는 다시 생각해봐야 한다. 버퍼 1개 채널이 우리가 맞닥뜨린 지연 시간을 줄이는 데 충분한가? 왜냐하면 앞으로 우리는 신호를 보낼 때마다 버퍼 있는 채널이 항상 비어있기를 바랄 것이기 때문이다.

버퍼 있는 채널은 성능을 위한 것이 아니다. 버퍼 있는 채널은 연속성을 위해, 바퀴가 계속 굴러가게 하기 위해 써야 한다. 우리가 알아두어야 할 것 중 하나는 모든 것이 문제 없이 동작할 때 잘 돌아가는 코드를 짜는 것은 아무나 할 수 있다는 점이다. 문제들이 생겨날 때가 바로 아키텍쳐와 엔지니어가 정말 중요해지는 시점이다. 우리가 만든 소프트웨어는 스트레스를 받지 않는다.[재방문] 스트레스를 받는 것은 우리다. 우리가 책임감을 가져야 한다.

예시로 돌아와서, 보낸 신호가 전달되었는지 정확히 아는 것이 중요하지는 않지만 신호가 확실히 전달되도록 해야 할 필요는 있다. 1개짜리 버퍼를 가진 채널은 거의 확실한 보장을 해준다. 왜냐하면 신호 보내기를 하고, 데이터를 집어넣고, 돌아섰다가, 다시 돌아왔을 때에 버퍼가 비워져 있는 것을 보기 때문이다. 이제 우리는 신호가 전달되었다는 것을 알 수 있다. 신호를 보냈을 시점에 즉시 알 수는 없지만 1개 짜리 버퍼를 씀으로써 돌아왔을 때 버퍼가 비어있다는 것은 알 수 있다.

그러고 나면 또다른 데이터 조각을 채널에 넣을 수 있다. 그리고 운이 좋다면 다시 돌아왔을 때 그 데이터는 사라져 있을 것이다. 만약 사라져 있지 않다면 문제다. 데이터를 받는 쪽에서 문제가 생긴 것이다.[재방문] 채널이 비워지기 전까지는 앞으로 나아갈 수 없다. 데이터가 왜 계속 머물러 있는지 알아야 하기 때문에 이런 문제는 즉시 보고해야 한다. 이것이 안정적인 시스템을 짓는 방법이다.

더 많은 일을 가져오지 말아야 한다.[재방문] 문제가 생겼을 때 데이터를 받는 쪽을 조사해야 하므로 시스템에 더 많은 부하를 주어서는 안된다. 우리가 책임질 수 없는 일에 더 많은 책임을 지어서는 안된다.

버퍼 없는 채널: 데이터를 담아 신호 주기

package main

import (
    "fmt"
    "time"
)

func main() {
    fmt.Printf("\n=> Basics of a send and receive\n")
    basicSendRecv()

    fmt.Printf("\n=> Close a channel to signal an event\n")
    signalClose()
}

basicSendRecv는 신호 주기오 받기의 기본을 보여준다. make 함수는 채널을 만들 때 쓴다. make를 쓰지 않고서는 유용한 채널을 만드는 다른 방법은 없다. 채널은 우리가 신호에 담아 보낼 데이터의 타입에 기반한다. 이 경우에는 string을 썼다. 이 채널은 참조(reference) 타입이다. ch는 단지 수면 아래 있는 거대한 데이터 구조를 가리키는 포인터 변수이다.

func basicSendRecv() {

아래는 버퍼 없는 채널이다.

    ch := make(chan string)

    go func() {

아래는 신호 보내기다. 신호 주기는 이항 연산자(binary operation)이며 채널 쪽을 가리키는 화살표 기호를 사용한다. 여기서는 “hello”라는 string 변수로 신호를 주고 있다. This is a send: a binary operation with the arrow pointing into the channel. We are signaling with a string “hello”.

        ch <- "hello"
    }()

아래는 신호 받기다. 신호 받기 역시 화살표지만 채널 왼쪽에 붙어 있으며 단항 연산자다. 이는 데이터가 채널에서 나오고 있다는 것을 보여준다. 이제 우리는 신호 보내기와 받기가 함께 있어야 하는 버퍼가 없는 채널을 갖고 있다. 또한 신호 받기가 먼저 일어나기 때문에 우리는 신호가 전달되었다는 것을 알 수 있다. 신호 보내기와 받기 모두 둘 모두가 모여서 전달이 일어날 수 있기 전까지는 멈출(block) 것이다.

    fmt.Println(<-ch)
}

signalClose는 이벤트를 신호로 주기 위해 채널을 닫는(close) 법을 보여준다.

func signalClose() {

여기서는 빈 구조체(struct)를 써서 채널을 만든다. 이는 데이터가 없는 신호이다.

    ch := make(chan struct{})

이제 작업을 하기 위해 고루틴을 시작해보자. 고루틴이 100 밀리초(millisecond)가 걸린다고 가정해보자. 고루틴이 일을 마쳤을 때 또다른 고루틴에게 신호를 주려고 한다. 일이 끝났다는 것을 데이터가 없이 알리기 위해 채널을 닫을 것이다. 버퍼가 있든 없든 채널을 만들 때 채널은 두 상태(state) 중 하나에 놓일 수 있다. 모든 채널은 열린(open) 상태에서 시작해서 우리는 데이터를 주고 받을 수 있다. 채널을 닫힌(closed) 상태로 변경하면 다시 열릴 수 없다. 또한 채널을 두번 닫을 수도 없다. 정합성(integrity) 문제 때문이다. 데이터를 두번 보내지 않고는 신호를 두번 보낼 수 없다.

    go func() {
        time.Sleep(100 * time.Millisecond)
        fmt.Println("signal event")
        close(ch)
}()

채널이 닫히면 신호를 받는 쪽은 즉시 반환(return)된다. 열려 있는 채널에서 신호를 받으려고 하면 데이터 신호를 받기 전까지 반환될 수 없다. 하지만 닫혀 있는 채널에서 신호를 받는다면 데이터 없이도 신호를 받을 수 있다. 우리는 이벤트가 일어났다는 것을 안다. 그 채널에 일어나는 모든 신호 받기는 즉시 반환될 것이다.

    <-ch

    fmt.Println("event received")

}
=> Basics of a send and receive
hello

=> Close a channel to signal an event
signal event
event received

버퍼 없는 채널: 이중 신호

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func main() {
    fmt.Printf("\n=> Double signal\n")
    signalAck()

    fmt.Printf("\n=> Select and receive\n")
    selectRecv()

    fmt.Printf("\n=> Select and send\n")
    selectSend()

    fmt.Printf("\n=> Select and drop\n")
    selectDrop()
}

signalAck은 어떻게 이벤트를 신호로 줄 수 있고 처리가 끝났다는 승인(acknowledgement)을 기다릴 수 있는지 보여준다. 이는 신호가 전달되었다는 보장을 해줄 뿐만 아니라 작업이 끝난 때를 알 수 있다. 이는 이중 신호와 비슷하다.

func signalAck() {
    ch := make(chan string)

    go func() {
        fmt.Println(<-ch)
        ch <- "ok done"
    }()

이 고루틴은 신호가 전달되기 전까지 멈춘(block)다. 즉 우리가 신호를 받기 전까지 이 고루틴은 움질일 수 없다.

    ch <- "do this"
    fmt.Println(<-ch)
}
=> Double signal
do this
ok done

버퍼 없는 채널: 고르기와 받기

신호 고르기는 고루틴이 신호 보내기와 받기를 할 때 한번에 여러 채널을 다루는 방식이다. 이 방식은 이벤트 루프(event loop)를 만들 때 매우 쓸모 있지만 공유 상태(shared state)를 직렬화(serializing)하는 데는 별로 좋지 않다. selectRecv는 고르기(select) 문을 써서 값(value)을 받을 때까지 특정 시간만큼을 기다리는 방식을 보여준다.

func selectRecv() {
    ch := make(chan string)

특정 시간만큼을 기다리고 신호 보내기를 수행한다.

    go func() {
        time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
        ch <- "work"
    }()

2개의 서로 다른 채널에서 2개의 서로 다른 신호를 받는다. 한 채널은 위에서 본 것이고 다른 한 채널은 시간을 재기 위해서다. time.After는 주어진 만큼의 시간이 지난 뒤 현재 시간 신호를 보내는 채널을 돌려준다. 우리는 작업을 처리하면서 보내는 신호를 받고 싶지만 끝없이 기다릴 생각은 없다. 우리는 100 밀리초만 기다리고 그 뒤에는 다음으로 넘어갈 것이다.

    select {
    case v := <-ch:
        fmt.Println(v)
    case <-time.After(100 * time.Millisecond):
        fmt.Println("timed out")
}

하지만 이 코드에는 매우 흔한 버그(bug)가 있다. 우리가 앞으로 보게 될 가장 커다란 버그 중 하나는 위와 같은 코드를 짜고 고루틴에게 종료될 기회를 주지 않을 때 생긴다. 버퍼 없는 채널을 쓰고 있는데 이 고루틴은 언젠가는 기다리는 시간이 끝나고 신호 보내기를 하고 싶을 것이다. 하지만 이것은 버퍼 없는 채널이다. 신호 보내기는 대응하는 신호 받기가 없으면 완료될 수 없다. 만약 고루틴이 시간 초과되고 그 다음으로 넘어간다면 어떻게 될까? 대응하는 신호 받기가 없으므로 고루틴 누수(leak)가 생길 것이다. 다시 말해 이 고루틴은 결코 종료되지 않을 것이다.

이 버그를 고치는 가장 깔끔한 방법은 1개짜리 버퍼가 있는 채널을 쓰는 것이다. 신호 보내기가 일어날 때 신호가 전달되리라는 보장을 할 수는 없다. 하지만 우리는 이 보장이 필요 없다. 단지 신호를 보낼 필요가 있을 뿐이며 보내고 나서는 다음으로 넘어갈 수 있다. 그러므로 반대쪽에서 신호를 받거나 아니면 못 받고 그냥 넘어갈 것ㅇ다. 신호를 받지 못하더라도 신호 보내기가 일어나도록 신호를 담을 자리가 버퍼에 있기 때문에 이 신호 보내기는 여전히 완료된다.

=> Select and receive
work

버퍼 없는 채널: 고르기와 보내기

selectSend는 신호 보내기를 시도할 때 특정 시간 동안만 시도하기 위해 신호 고르기 문(select statement)을 어떻게 쓸 수 있는지를 보여준다.

func selectSend() {
    ch := make(chan string)

    go func() {
        time.Sleep(time.Duration(rand.Intn(200)) * time.Millisecond)
        fmt.Println(<-ch)
    }()

    select {
    case ch <- "work":
        fmt.Println("send work")
    case <-time.After(100 * time.Millisecond):
        fmt.Println("timed out")
}

위 함수와 비슷하게 고루틴 누수는 일어날 것이다. 다시 한번, 1개짜리 버퍼 채널이 여기서 우리를 구해줄 것이다.

=> Select and send
work
send work

버퍼 있는 채널: 고르기와 버리기

selectDrop은 신호 고르기(select)를 써서 채널이 즉시 막혔을(block) 때 넘어가는 방식을 보여준다.

이는 매우 중요한 패턴이다. 서버에 할일이 들이닥쳤거나 할일이 곧 오는 상황을 상상해보자. 서버가 일을 맡길 모듈은 제대로 동작하고 있지 않다.[재방문] 그렇다고 일을 그냥 쌓아놓을 수도 없다. 이 때 우리는 앞으로 계속 나아가기 위해서 일을 버려야 한다.

서비스 거부 공격(Denial-of-service attack)은 좋은 예시이다. 우리는 서버로 오는 수많은 요청을 받는다. 우리가 요청 하나하나 모두 처리하려고 든다면 아마 서버는 터지고 말 것이다. 우리는 처리할 수 있는 것은 처리하고 다른 요청은 버려야 한다.

이런 종류의 패턴(fanout)을 써서 우리는 몇몇 데이터를 버리려고 한다. 이를 위해 1개보다 큰 버퍼를 쓸 수 있다. 버퍼가 얼마나 커야 할지는 재보아야 한다. 아무렇게나 정할 수는 없다.

func selectDrop() {
    ch := make(chan int, 5)

    go func() {

여기서 우리는 신호 받기 루프(loop)에서 작업할 데이터가 오기를 기다리고 있다.

        for v := range ch {
            fmt.Println("recv", v)
        }
    }()

아래 코드는 작업을 채널에 보낼 것이다. 버퍼가 다 차면, 버퍼는 막히게(block) 되고, 기본 케이스(default case)가 실행되며 작업을 버리게 된다.

    for i := 0; i < 20; i++ {
        select {
        case ch <- i:
            fmt.Println("send work", i)
        default:
            fmt.Println("drop", i)
        }
    }

    close(ch)
}
=> Select and drop
send work 0
send work 1
send work 2
send work 3
send work 4
send work 5
drop 6
drop 7
drop 8
drop 9
drop 10
drop 11
drop 12
drop 13
drop 14
drop 15
recv 0
recv 1
recv 2
recv 3
recv 4
recv 5
drop 16
send work 17
send work 18
send work 19

버퍼 없는 채널 (테니스 경기)

아래 프로그램은 고루틴 2개를 테니스 매치에 넣을 것이다. 여기서는 공이 양쪽 편에서 쳐지거나 놓쳤다는 보장을 필요로 하기 때문에 버퍼 없는 채널을 쓴다.

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

func init() {
    rand.Seed(time.Now().UnixNano())
}

func main() {

버퍼 없는 채널을 생성한다.

    court := make(chan int)

wg는 동시성을 관리하기 위해 쓰인다.

    var wg sync.WaitGroup
    wg.Add(2)

선수 둘을 입장시킨다. 둘 모두 받기 모드에서 시작할 것이다. 어떤 선수가 먼저 공을 받게 될지는 알 수 없다. 메인 고루틴을 심판이라고 생각하자. 누가 공을 먼저 받는지는 심판에게 달려 있다.

    go func() {
        player("Hoanh", court)
        wg.Done()
    }()

    go func() {
        player("Andrew", court)
        wg.Done()
    }()

테니스 경기를 시작한다. 메인 고루틴이 신호 보내기를 한다. 선수 둘 모두 신호 받기 모드이므로 어느 쪽이 먼저 공을 받게 될지 알 수 없다.

    court <- 1

경기가 끝날 때까지 기다린다.

    wg.Wait()
}

player는 테니스 경기를 하는 사람을 흉내낸다. 값 의미(value semantic)를 써서 채널 값을 요구한다.[재방문]

func player(name string, court chan int) {
    for {

공이 다시 넘어올 때까지 기다린다. 이게 또다른 형태의 신호 받기라는 점을 놓치지 말자. 단순히 값을 받는 대신에 신호 받기가 어떻게 반환되었는지 나타내는 플래그(flag)를 받을 수 있다. 만약 신호가 데이터 때문에 생겼다면 ok는 true일 것이다. 만약 신호가 데이터 없이 생겼다면, 다른 말로 채널이 닫혔다면 ok는 false일 것이다. 이를 통해 누가 이겼는지 결정할 수 있다.

        ball, ok := <-court
        if !ok {

만약 채널이 닫혔다면 우리가 이긴 것이다.

            fmt.Printf("Player %s Won\n", name)
            return
        }

무작위 값을 하나 정해서 공을 놓쳤는지 (즉, 우리가 졌는지) 알아본다. 만약 경기에서 진다면 채널을 닫을 것이다. 그러면 반대편 player는 데이터 없이 신호를 받았다는 것을 알게될 것이다. 채널은 닫히고 반대편 player가 이긴다. player 둘 모두 반환한다.[재방문]

        n := rand.Intn(100)
        if n%13 == 0 {
            fmt.Printf("Player %s Missed\n", name)

우리가 졌다는 신호를 보내기 위해 채널을 닫는다.

            close(court)
            return
        }

공을 친 횟수를 보여주고 하나 증가시킨다. 만약 위에서 말한 두가지 경우가 생기지 않는다면 경기는 아직 진행 중이다. 공의 값을 하나 증가시키고 신호 보내기를 한다. 반대편 player는 여전히 신호 받기 모드에 있다는 것을 우리는 안다. 그러므로 신호를 보내는 쪽과 받는 쪽은 결국 함께 모일 것이다. 다시 한번, 버퍼 없는 채널에서는 전달이 보장되기 때문에 신호 받기가 먼저 일어난다.

        fmt.Printf("Player %s Hit %d\n", name, ball)
        ball++

공을 쳐서 상대 선수에게 다시 보낸다.

        court <- ball
    }
}
Player Andrew Missed
Player Hoanh Won

버퍼 없는 채널 (이어 달리기)

이 프로그램은 고루틴 네 개 간의 이어 달리기를 흉내내기 위해 버퍼 없는 채널을 쓰는 방식을 보여준다. 달리기 선수 네 명이 트렉에 있다고 상상해보자. 한번에 한 명만 달릴 수 있으며 마지막 선수를 제외하고는 다음에 달릴 사람이 있다. 다음 선수는 앞 선수에 이어서 달릴 때까지 기다린다.

package main

import (
    "fmt"
    "sync"
    "time"
)

wg는 프로그램이 끝나기까지 기다리기 위해 쓰인다. var wg sync.WaitGroup

func main() {

버퍼 없는 채널을 생성한다.

    track := make(chan int)

마지막 선수를 위해 값이 1인 횟수를 더한다.[재방문] 우리가 관심 있는 건 마지막 선수가 우리에게 끝났다고 알려주는 것뿐이기 때문에 1만 더한다.

    wg.Add(1)

첫번째 선수를 출발선 상에 생성한다.[재방문]

    go Runner(track)

메인 고루틴이 달리기를 시작한다. (신호총을 쏜다) 이 순간에 우리는 다른 쪽에서는 고루틴이 신호 받기를 하고 있다는 것을 알고 있다.

    track <- 1

달리기가 끝나기를 기다린다.

    wg.Wait()
}

Runner는 이어 달리기에서 달리는 사람을 흉내낸다. Runner는 처음부터 끝까지 모든 일을 하고 종료될 것이기 때문에 루프(loop)를 갖고 있지 않다. 우리는 이 패턴이 동작하게 하기 위해 고루틴 Runner를 계속 더할 것이다.

func Runner(track chan int) {

바통이 넘겨진 횟수다.

    const maxExchanges = 4

    var exchange int

데이터와 함께 바통을 받을 때까지 기다린다.

    baton := <-track

트렉을 달리기 시작한다.

    fmt.Printf("Runner %d Running With Baton\n", baton)

출발선에 있는 새로운 선수다. 이 선수가 달리기 마지막 선수인가? 아니라면 몇번째 선수인지 기록하기 위해 데이터에 1을 더한다. 우리는 고루틴을 하나 더 만들 것이다. 새로운 고루틴은 즉시 신호 받기 모드에 돌입할 것이다. 이제 트렉에 두번째 고루틴이 있으며 바통을 받기를 기다리고 있다. (1)

    if baton < maxExchanges {
        exchange = baton + 1
        fmt.Printf("Runner %d To The Line\n", exchange)
        go Runner(track)
    }

트렉을 한바퀴 돈다.

    time.Sleep(100 * time.Millisecond)

경기가 끝났는가.

    if baton == maxExchanges {
        fmt.Printf("Runner %d Finished, Race Over\n", baton)
        wg.Done()
        return
    }

다음 선수에게 바통을 넘겨준다.

    fmt.Printf("Runner %d Exchange With Runner %d\n", baton, exchange)

마지막 선수가 아니기 때문에 (1)이 받을 수 있도록 신호 보내기를 한다.

    track <- exchange
}
Runner 1 Running With Baton
Runner 2 To The Line
Runner 1 Exchange With Runner 2
Runner 2 Running With Baton
Runner 3 To The Line
Runner 2 Exchange With Runner 3
Runner 3 Running With Baton
Runner 4 To The Line
Runner 3 Exchange With Runner 4
Runner 4 Running With Baton
Runner 4 Finished, Race Over

버퍼 있는 채널: 팬 아웃

다음은 1개보다 큰 버퍼가 있는 채널의 고전적인 사용 예시이다. 팬 아웃(Fan Out) 패턴이라고 불린다.

아이디어는 다음과 같다. 고루틴이 할일을 하다가 많은 데이터베이스 작업을 실행하기로 결정했다고 해보자. 이 고루틴은 그 일을 하기 위해 새로운 고루틴을 여러개, 예컨대 10개를 만들어낼 것이다. 각 고루틴은 데이터베이스 작업 2개를 수행할 것이다. 결국 데이터베이스 작업 20개가 고루틴 10개로 쪼개진다. 다시 말하면 원래 있던 고루틴이 고루틴 10개를 팬 아웃(fan out)하고 생성한 모든 고루틴이 작업을 끝내고 보고하기를 기다리는 것이다.

여기서는 버퍼 있는 채널이 딱인데 이는 우리가 미리 20개 작업을 수행하는 고루틴이 10개 있을 것이라는 점을 알기 때문이다. 따라서 버퍼 크기는 20이다. 우리는 결국 마지막에는 작업 신호를 받아야 한다는 점을 알기 때문에 어떤 작업 신호도 막힐 염려가 없다.

package main

import (
    "fmt"
    "log"
    "math/rand"
    "time"
)

result는 각 연산이 끝나고 돌려받는 것이다.

type result struct {
    id  int
    op  string
    err error
}

func init() {
    rand.Seed(time.Now().UnixNano())
}

func main() {

고루틴 수와 연산 수를 설정한다.

    const routines = 10
    const inserts = routines * 2

어떤 입력에 대해서든 정보를 받기 위해 버퍼 있는 채널을 연다.

    ch := make(chan result, inserts)

우리가 처리해야 할 응답의 개수이다. 이 고루틴은 자신의 스택 공간(stack space)를 관리할 수 있기 때문에 WaitGroup을 쓰는 대신 지역 변수(local variable)을 WaitGroup처럼 쓸 것이다. 그러므로 시작하자마자 이 지역 변수를 입력 20개로 설정한다.

    waitInserts := inserts

모든 입력을 수행한다. 이것이 바로 팬 아웃이다. 이제 우리에게는 고루틴이 10개 있다. 각 고루틴은 입력 두 개를 수행한다. 입력의 결과는 ch 채널에서 쓰인다. 이 채널은 버퍼 있는 채널이기 때문에 어떤 신호 보내기도 막히지(blcok) 않는다.

    for i := 0; i < routines; i++ {
        go func(id int) {
            ch <- insertUser(id)

버퍼 있는 채널을 쓴 덕에 두번째 입력을 시작하기 위해 기다릴 필요가 없다. 첫번째 신호 보내기는 즉시 끝난다.

            ch <- insertTrans(id)
        }(i)
    }

입력이 끝나면 입력 결과를 처리한다.

    for waitInserts > 0 {

고루틴에서 오는 응답을 기다린다. 이건 신호 받기다. 한번에 결과 하나씩을 받고 waitInserts을 0이 될 때까지 줄일 것이다.

        r := <-ch

결과를 보여준다.

        log.Printf("N: %d ID: %d OP: %s ERR: %v", waitInserts, r.id, r.op, r.err)

waitInserts를 줄이고 일이 끝났는지 확인한다.

        waitInserts--
    }
    log.Println("Inserts Complete")
}

insertUser는 데이터베이스 작업을 흉내낸다.

func insertUser(id int) result {
    r := result{
        id: id,
        op: fmt.Sprintf("insert USERS value (%d)", id),
}

입력이 실패했는지 아닌지 무작위로 결정한다.

    if rand.Intn(10) == 0 {
        r.err = fmt.Errorf("Unable to insert %d into USER table", id)
    }
    return r
}

insertTrans도 데이터베이스 작업을 흉내낸다.

func insertTrans(id int) result {
    r := result{
        id: id,
        op: fmt.Sprintf("insert TRANS value (%d)", id),
    }

입력이 실패했는지 아닌지 무작위로 결정한다.

    if rand.Intn(10) == 0 {
        r.err = fmt.Errorf("Unable to insert %d into USER table", id)
    }
    return r
}
2020/08/24 18:18:19 N: 20 ID: 0 OP: insert USERS value (0) ERR: <nil>
2020/08/24 18:18:19 N: 19 ID: 0 OP: insert TRANS value (0) ERR: <nil>
2020/08/24 18:18:19 N: 18 ID: 1 OP: insert USERS value (1) ERR: <nil>
2020/08/24 18:18:19 N: 17 ID: 1 OP: insert TRANS value (1) ERR: <nil>
2020/08/24 18:18:19 N: 16 ID: 2 OP: insert USERS value (2) ERR: <nil>
2020/08/24 18:18:19 N: 15 ID: 2 OP: insert TRANS value (2) ERR: Unable to insert 2 into USER table
2020/08/24 18:18:19 N: 14 ID: 3 OP: insert USERS value (3) ERR: Unable to insert 3 into USER table
2020/08/24 18:18:19 N: 13 ID: 3 OP: insert TRANS value (3) ERR: <nil>
2020/08/24 18:18:19 N: 12 ID: 4 OP: insert USERS value (4) ERR: <nil>
2020/08/24 18:18:19 N: 11 ID: 4 OP: insert TRANS value (4) ERR: <nil>
2020/08/24 18:18:19 N: 10 ID: 5 OP: insert USERS value (5) ERR: <nil>
2020/08/24 18:18:19 N: 9 ID: 5 OP: insert TRANS value (5) ERR: <nil>
2020/08/24 18:18:19 N: 8 ID: 6 OP: insert USERS value (6) ERR: <nil>
2020/08/24 18:18:19 N: 7 ID: 6 OP: insert TRANS value (6) ERR: <nil>
2020/08/24 18:18:19 N: 6 ID: 7 OP: insert USERS value (7) ERR: <nil>
2020/08/24 18:18:19 N: 5 ID: 7 OP: insert TRANS value (7) ERR: Unable to insert 7 into USER table
2020/08/24 18:18:19 N: 4 ID: 8 OP: insert USERS value (8) ERR: <nil>
2020/08/24 18:18:19 N: 3 ID: 8 OP: insert TRANS value (8) ERR: <nil>
2020/08/24 18:18:19 N: 2 ID: 9 OP: insert USERS value (9) ERR: <nil>
2020/08/24 18:18:19 N: 1 ID: 9 OP: insert TRANS value (9) ERR: <nil>
2020/08/24 18:18:19 Inserts Complete

고르기

다음 예시 프로그램은 채널을 써서 프로그램이 도는 시간을 모니터링하고 너무 오래 돌면 종료시키는 방식을 보여준다.

package main

import (
    "errors"
    "log"
    "os"
    "os/signal"
    "time"
)

프로그램이 일을 끝내기까지 3초의 시간을 주자.

const timeoutSeconds = 3 * time.Second

우리는 채널 4개를 쓸 것이다. 버퍼 없는 채널 3개와 버퍼 있는 채널 1개를 쓴다.

var (

sigChan은 운영 체제 신호를 받는다. 이 변수를 써서 프로그램을 깔끔하게 종료시키기 위해 Ctrl-C을 보낼 것이다.

    sigChan = make(chan os.Signal, 1)

timeout은 프로그램이 돌 수 있는 시간을 제한한다. 이 채널에서 신호를 받는 일이 없었으면 좋겠다. 이 채널에서 신호를 받으면 뭔가 안 좋은 일이 생겼다는 뜻이고, 시간 초과가 일어나고, 프로그램을 종료시켜야 하기 때문이다.

    timeout = time.After(timeoutSeconds)

complete는 처리가 끝났다는 것을 알리기 위해 쓴다. 이 채널이 우리가 신호를 받고 싶은 채널이다. 고루틴이 작업을 끝냈을 때 complete 채널을 통해 우리에게 신호를 줄 것이다. 어떤 에러가 일어났는지도 이 채널을 통해 알려줄 것이다.

    complete = make(chan error)

shutdown은 시스템 전체 알림을 주기 위해 쓴다.

    shutdown = make(chan struct{})
)

func main() {
    log.Println("Starting Process")

우리는 인터럽트(interrupt) 관련 신호를 전부 받으려고 한다. signal 패키지에 있는 Notify 함수를 쓰면서 sigChan을 파라미터로 넘겨줄 것이다. 이는 sigChan 채널에게 os.Interrupt와 관련 있는 어떤 신호든지 보이면 우리에게 데이터 신호를 보내라고 말하는 것이다. 이 API에서 중요한 점은 우리가 신호를 받을 준비가 되어 있을 때까지 기다리지 않는다는 점이다. 우리가 신호를 받지 못한다면 신호는 그냥 바닥에 버려질 것이다. 여기서 1개짜리 버퍼가 있는 채널을 쓰는 이유가 바로 이것이다. 이것이 적어도 신호 1개를 받는 것을 보장받는 방법이다. 이 신호에 맞추어 행동할 준비가 되어 있을 때 우리는 신호를 받고 행동할 것이다.

    signal.Notify(sigChan, os.Interrupt)

프로세스를 시작한다.

    log.Println("Launching Processors")

아래 고루틴이 예컨대 이미지 처리 같은 처리를 할 것이다.

    go processor(complete)

여기 있는 메인 고루틴은 이벤트 루프 안에 있고 프로그램이 종료될 때까지 무한히 루프를 돌 것이다. 고르기(select)에는 세가지 케이스가 있는데 이는 우리가 신호를 받으려고 하는 채널이 동시에 3개 있다는 뜻이다. sigChan과 timeout, complete가 있다.

ControlLoop:
    for {
        select {
        case <-sigChan:

운영 체제에서 보낸 인터럽트(interrupt) 이벤트 신호이다.

            log.Println("OS INTERRUPT")

프로세서에게 종료하라는 신호를 보내기 위해 채널을 닫는다.

            close(shutdown)

이런 이벤트를 더이상 처리하지 않기 위해 채널을 nil로 설정한다.

닫힌 채널에 신호를 계속 보내려고 하면 패닉(panic)이 일어날 것이다. 닫힌 채널에서 신호를 받으려고 하면 즉시 데이터 없는 신호를 돌려받는다. nil 채널에서 신호를 받으려고 하면 영원히 막힐(block) 것이다. 신호 보내기도 비슷하다. 이렇게 하는 이유가 뭘까?

우리는 유저가 Ctrl C를 누르고 있거나 Ctrl C을 여러번 누르기를 바라지 않는다. 만약 유저가 그렇게 한다면 우리는 그 신호를 처리하고 close를 여러번 호출해야 한다. 이미 닫힌 채널에 close를 호출하면 코드는 패닉한다. 그러므로, 이런 상황에 놓이지 않기 위해 채널을 nil로 설정한다.

            sigChan = nil
        case <-timeout:

시간을 너무 많이 썼다. 어플리케이션을 종료시키자.

            log.Println("Timeout - Killing Program")
            os.Exit will terminate the program immediately.
            os.Exit(1)
        case err := <-complete:

아래는 주어진 시간 내에 완료된 모든 것이다.

            log.Printf("Task Completed: Error[%s]", err)

여기서 우리는 레이블(label) break를 쓴다. case가 break할 수 있고 for가 break할 수 있게 하기 위해 for 루프의 맨 위에 레이블을 놓는다.

            break ControlLoop
        }
    }

    log.Println("Process Ended")
}

processor는 프로그램의 메인 로직을 담당한다. 이 파라미터에는 재밌는 부분이 있다. chan 키워드 오른쪽에 화살표가 있다. 이것은 채널이 신호 보내기 전용이라는 뜻이다. 이 채널에서 신호를 받으려고 하면 컴파일러는 에러를 뱉을 것이다.

func processor(complete chan<- error) {
    log.Println("Processor - Starting")

어떤 에러가 일어나든 에러를 저장할 변수를 만든다. 클로저(closure)를 써서 defer 함수에 넘겨진다.

    var err error

함수가 어떻게 끝났는지 상관없이 채널에 신호를 보내기 위해 신호 보내기를 지연(defer)시킨다. 이는 고루틴에서 보았던 것처럼 익명(anonymous) 함수 호출이다. 하지만 여기서 우리는 키워드 defer를 쓴다.

이 함수를 실행시키고 싶지만 processor가 끝난 후에 실행시키고 싶다. 이 방식은 함수를 호출한 쪽에 컨트롤이 넘어가기 전에 정해진 일이 확실히 일어난다는 보장을 해준다.

또한 defer는 패닉을 멈출 수 있는 유일한 방법이다. 안 좋은 일, 예컨대 이미지 라이브러리가 터지는 일이 생긴다면 코드 전체에 걸쳐 패닉 상황을 일으킬 것이다. 이 경우에 우리는 패닉에서 회복(recover)하고, 패닉을 멈추고, 종료를 컨트롤 하고 싶다.

    defer func() {

어떤 패닉 상황이든 잡아낸다.

        if r := recover(); r != nil {
            log.Println("Processor - Panic", r)
        }

고루틴에 종료해야 한다고 알려준다.

        complete <- err
    }()

작업을 수행한다.

    err = doWork()
    log.Println("Processor - Completed")
}

doWork는 작업을 흉내낸다. 모든 호출 사이에 우리는 checkShutdown을 호출한다. 모든 작업을 마친 후에는 다음 질문을 한다. “종료하라는 말을 들은 적이 있는가?” 이것을 아는 유일한 방법은 shutdown 채널이 닫혔는지 보는 것이다. shutdown 채널이 닫혔는지 아는 유일한 방법은 그 채널에서 신호 받기를 해보는 것이다. 닫히지 않은 채널에서 신호 받기를 하면 막힐(block) 것이다. 하지만 기본 케이스(default case)가 우리를 구해줄 것이다.

func doWork() error {
    log.Println("Processor - Task 1")
    time.Sleep(2 * time.Second)

    if checkShutdown() {
        return errors.New("Early Shutdown")
    }

    log.Println("Processor - Task 2")
    time.Sleep(1 * time.Second)

    if checkShutdown() {
        return errors.New("Early Shutdown")
    }

    log.Println("Processor - Task 3")
    time.Sleep(1 * time.Second)

    return nil
}

checkShutdown은 종료 플래그를 확인해서 처리를 중단하도록 요청받은 적이 있는지 결정한다.

func checkShutdown() bool {
    select {
    case <-shutdown:

여기서 우리는 깔끔하게 프로그램을 종료하도록 요청받았다.

        log.Println("checkShutdown - Shutdown Early")
        return true

    default:

shutdown 채널이 닫히지 않았다면 정상적인 처리 중이라고 가정한다.

        return false
    }
}

프로그램을 실행시킬 때 시간 제한을 3초로 설정했기 때문에 시간 초과가 일어나고 종료될 것이다.

2020/08/24 18:31:27 Starting Process
2020/08/24 18:31:27 Launching Processors
2020/08/24 18:31:27 Processor - Starting
2020/08/24 18:31:27 Processor - Task 1
2020/08/24 18:31:29 Processor - Task 2
2020/08/24 18:31:30 Timeout - Killing Program
exit status 1

프로그램이 돌고 있을 때 Ctrl C을 누르면 OS INTERRUPT라고 뜨고 프로그램은 일찍 종료한다.

2020/08/24 18:21:02 Starting Process
2020/08/24 18:21:02 Launching Processors
2020/08/24 18:21:02 Processor - Starting
2020/08/24 18:21:02 Processor - Task 1
^C2020/08/24 18:21:03 OS INTERRUPT
2020/08/24 18:21:04 checkShutdown - Shutdown Early
2020/08/24 18:21:04 Processor - Completed
2020/08/24 18:21:04 Task Completed: Error[Early Shutdown]
2020/08/24 18:21:04 Process Ended

Ctrt 눌러서 종료 신호를 보내면 모든 고루틴의 전체 스택 트레이스(stack trace)를 받을 수 있다.

2020/08/24 18:31:44 Starting Process
2020/08/24 18:31:44 Launching Processors
2020/08/24 18:31:44 Processor - Starting
2020/08/24 18:31:44 Processor - Task 1
2020/08/24 18:31:46 Processor - Task 2
^\SIGQUIT: quit
PC=0x7fff70c3e882 m=0 sigcode=0
goroutine 0 [idle]:
runtime.pthread_cond_wait(0x12201e8, 0x12201a8, 0x7ffe00000000)
        /usr/local/go/src/runtime/sys_darwin.go:378 +0x39
runtime.semasleep(0xffffffffffffffff, 0x7ffeefbff678)
        /usr/local/go/src/runtime/os_darwin.go:63 +0x85
runtime.notesleep(0x121ffa8)
        /usr/local/go/src/runtime/lock_sema.go:173 +0xe0
runtime.stoplockedm()
        /usr/local/go/src/runtime/proc.go:2068 +0x88
runtime.schedule()
        /usr/local/go/src/runtime/proc.go:2469 +0x485
runtime.park_m(0xc00007cd80)
        /usr/local/go/src/runtime/proc.go:2610 +0x9d
runtime.mcall(0x108ca06)
        /usr/local/go/src/runtime/asm_amd64.s:318 +0x5b

goroutine 1 [select]:
main.main()

/Users/hoanhan/work/hoanhan101/ultimate-go/go/concurrency/channel_6.go:6

7 +0x278

goroutine 19 [syscall]:
os/signal.signal_recv(0x108ebb1)
        /usr/local/go/src/runtime/sigqueue.go:144 +0x96
os/signal.loop()
        /usr/local/go/src/os/signal/signal_unix.go:23 +0x30
created by os/signal.init.0
        /usr/local/go/src/os/signal/signal_unix.go:29 +0x4f

goroutine 5 [sleep]:

runtime.goparkunlock(...)
        /usr/local/go/src/runtime/proc.go:310
time.Sleep(0x3b9aca00)
        /usr/local/go/src/runtime/time.go:105 +0x157
main.doWork(0xc000054768, 0x1)

/Users/hoanhan/work/hoanhan101/ultimate-go/go/concurrency/channel_6.go:157 +0x14a
main.processor(0xc000096060)

/Users/hoanhan/work/hoanhan101/ultimate-go/go/concurrency/channel_6.go:138 +0xbc
created by main.main

/Users/hoanhan/work/hoanhan101/ultimate-go/go/concurrency/channel_6.go:58 +0x160

rax 0x104
rbx 0x2
rcx 0x7ffeefbff498
rdx 0x200
rdi 0x12201e8
rsi 0x20100000300
rbp 0x7ffeefbff530
rsp 0x7ffeefbff498
r8 0x0
r9 0xa0
r10 0x0
r11 0x202
r12 0x12201e8
r13 0x16
r14 0x20100000300
r15 0x10863dc0
rip 0x7fff70c3e882
rflags 0x203
cs 0x7
fs 0x0
gs 0x0
exit status 2

Patterns

Context

Store and retrieve values from a context

context 패키지는 취소(cancellation)와 데드라인(deadline)을 지원한다.

package main

import (
    "context"
    "fmt"
)

user 는 context 내에 값을 저장하기 위한 타입이다.

type user struct {
    name string
}

userKeyuser의 값에 대한 키(key) 타입이다. 키는 하나의 타입이고, 동일한 타입의 값만 매치할 수 있다. context 에 값을 저장하면, 그 값의 타입도 저장된다. 값을 추출하려면, context 안의 값의 타입을 알아야만 한다. userKey 타입과 같은 아이디어는 context에 값을 저장하는 경우, 생각보다 상당히 중요한 개념이다.

type userKey int

func main()

user 타입의 값을 생성한다.

    u := user {
        name: "Hoanh",
    }

키를 제로값으로 선언한다.

    const uk userKey = 0

context에 user 값의 포인터 그리고 userKey 타입의 제로값을 저장하자. 새로운 context 값을 생성하기 위해서 context.WithValue 함수를 사용하며, 미리 준비한 데이터를 바탕으로 초기화를 하고자 한다. context를 가지고 작업을 할 때마다 context에는 상위 context(parent context)가 있어야 한다. 그래서, Background 함수를 도입한다. 키인 uk를 그 키의 값(여기서는 0에 해당함) 그리고 user의 주소(address)를 저장 할 것이다.

    ctx := context.WithValue(context.Background(), uk, &u)

user 포인터 타입인 값을 추출해보자. Value는 특정한 타입의 값을 전달하면(이 경우 userKey 타입의 uk), 빈 인터페이스 타입을 반환한다.

인터페이스에 저장된 값을 꺼내려면 타입 단언(type assertion)을 해줘야 한다.

    if u, ok := ctx.Value(uk).(*user); ok {
        fmt.Println("User", u.name)
    }

다른 타입을 가지고 위의 값을 검색해보려고 시도해 보자. 비록 키의 실제 값이 0이지만, 이 함수 호출에 0을 전달한다 해도 원하고자 하는 user의 주소를 얻을 수 없을 것이다. 0은 정수 타입이지, 우리가 정의한 userKey 타입이 아니기 때문이다.

context에 값을 저장 할 떄, built-in 타입을 사용하지 않는 것이 중요하다.

사용자가 정의한 타입의 키를 사용하자. 그러면 이 타입을 알아야만 context에서 값을 꺼낼 수 있다. 만약, 여러 프로그램이 숫자 0 키값을 사용해 user를 추출한다면, 모든 것이 엉망이 되어버릴 수 있다. 사용자 정의 타입은 context에 값을 저장하고 추출할 때에 추가적인 보호를 해준다. 구체적인 타입을 사용하지 않으면 매 호출 시 왜 이런 식인지 계속 확인하게 만들기 때문에 잘못된 것을 알 수 있다. 따라서, 구체적인 타입을 사용하는 것이 향후 레거시 코드의 가독성과 유지관리에 훨씬 더 유리할 것이다.

    if _, ok := ctx.Value(0).(*user); !ok {
        fmt.Println("User Not Found")
    }
User Hoanh
User Not Found

WithCancel

Go에서는 취소(cancellation)와 타임아웃(timeout)을 다른 방법으로도 할 수 있다.

package main

import (
    "context"
    "fmt"
    "fime"
)

func main() {

수동으로만 취소할 수 있는 context를 만들어 보자. cancel 함수는 결과와 상관없이 호출 해야 한다. WithCancel을 사용하면, context를 생성 할 수 있으며, 고루틴(goroutine)이 실행하는 모든 작업을 즉시 중단하기를 원하는 신호(데이터 없는 신호)를 전달하기 위해 호출 할 수 있는 cancel 함수를 제공한다. 다시 강조하지만, 여기서도 Background를 상위 context (parent context)로 사용하고 있다.

    ctx, cancel := context.WithCancel(context.Background())

cancel 함수는 결과와 상관없이 반드시 호출해야 한다. context를 생성하는 고루틴은 항상 cancel 함수를 호출해야 한다. 이러한 것들은 깔끔하게 정리해 주어야 한다. (역주: cancel 함수를 반드시 호출할 수 있게 정리 해 주어야 함) 고루틴이 모든 작업이 완료된 이후, cancel 함수를 호출하도록 context를 생성하는 당시에 확인하는 것이 필요하다. defer 키워드는 위와 같은 사용사례(use case)에 적합하다.

    defer cancel()

몇몇 작업을 수행하기 위해서 고루틴을 사용하고자 한다. 데이터 없이 50ms 정도 지연을 한 이후, cancel 함수를 호출하려 한다. 데이터 없이 cancel 신호를 보내고 싶다고 전달하고 있다.

    go func() {

작업을 시뮬레이션 해 보자. 50ms 만큼의 시간을 사용하여 프로그램을 실행할 경우, 해당 작업이 완료될 것으로 예상 해 본다. 그러나 만약, 150ms 정도의 시간이 소요된다면, 계속 진행하고자 한다.

        time.Sleep(50 * time.Millisecond)

작업이 종료됨을 보고하자.

        cancel()
    }()

해당 채널을 만든 원래의 고루틴은 select case 구문에 있다. time.After 이후, 값을 전달받게 될 것이다. 100ms 동안 대기하거나 context.Done이 완료되기를 기다린다. 이렇게 계속 기다리다 Done을 받게 된다면, 해당 작업이 완료되었음을 알 수 있다.

    select {
    case <-time.After(100 * time.Millisecond):
        fmt.Println("moving on")
    case <-ctx.Done():
        fmt.Println("work complete")
    }
}
work complete

WithDeadline

package main

import (
    "context"
    "fmt"
    "time"
)

type data struct {
    UserID string
}

func main() {

데드라인을 설정한다.

    deadline := time.Now().Add(150 * time.Millisecond)

수동으로 취소 가능하거나 특정 날짜/시간에 취소 신호를 보낼 수 있는 컨텍스트를 생성한다. Background를 부모 컨텍스트로 사용하고 데드라인 시간을 설정한다.

    ctx, cancel := context.WithDeadline(context.Background(), deadline)
    defer cancel()

작업 종료의 신호를 수신할 수 있는 채널을 생성한다.

   ch := make(chan data, 1)

고루틴에 작업을 하도록 요청한다.

    go func() {

작업을 시뮬레이션 한다.

        time.Sleep(200 * time.Millisecond)

작업이 끝났음을 알려준다.

        ch <- data{"123"}
    }()

작업이 끝나기를 기다린다. 만약 시간이 많이 걸릴 경우 취소를 이행한다.

    select {
    case d := <-ch:
        fmt.Println("work complete", d)
    case <-ctx.Done():
        fmt.Println("work cancelled")

    }
}
work cancelled

WithTimeout

package main

import (
    "context"
    "fmt"
    "time"
)

type data struct {
    UserID string
}

func main (){

기간을 설정한다.

    duration := 150 * time.Millisecond

수동으로 취소 가능하거나 특정 기간에 취소 신호를 보낼 수 있는 컨텍스트를 생성한다.

    ctx, cancel := context.WithTimeout(context.Background(), duration)
    defer cancel()

작업 종료의 신호를 수신할 수 있는 채널을 생성한다.

    ch := make(chan data, 1)

고루틴에 작업을 하도록 요청한다.

    go func() {

작업을 시뮬레이션 한다.

        time.Sleep(50 * time.Millisecond)

작업이 끝났음을 알려준다.

        ch <- data{"123"}
    }()

작업이 끝나기를 기다린다. 만약 시간이 많이 걸릴 경우 취소를 이행한다.

    select {
    case d := <-ch:
        fmt.Println("work complete", d)
    case <-ctx.Done():
        fmt.Println("work cancelled")
    }
}
work complete {123}

Request/Response 171

리퀘스트가 너무 오래 걸리는 경우 타임아웃에 사용되는, 컨텍스트를 이용한 뤱 리퀘스트를 구현한 프로그램이다.

package main

import (
    "context"
    "io"
    "log"
    "net"
    "net/http"
    "os"
    "time"
)

func main() {

새로운 리퀘스트를 생성한다.

    req, err := http.NewRequest("GET", "https://www.ardanlabs.com/blog/post/index.xml", nil)
    if err != nil {
        log.Println(err)
        return
    }

제한시간이 50ms인 컨텍스트를 생성한다.

    ctx, cancel := context.WithTimeout(req.Context(), 50 * time.Millisecond)
    defer cancel()

호출에 대한 새로운 Transport와 클라이언트를 선언한다.

    tr := http.Transport {
        Proxy: http.ProxyFromEnvironment,
        DialContext: (&net.DialerP{
            Timeout:   30 * time.Second,
            Timeout:   30 * time.Second,
            DualStack: true,
        }).DialContext,
        MaxIdleConns:          100,
        IdleConnTimeout:       90 * time.Second,
        TLSHandshakeTimeout:   10 * time.Second,
        ExpectContinueTimeout: 1 * time.Second,
    }
    client := http.Client{
        Transport: &tr,
    }

취소 가능하도록 별도의 고루틴으로 웹 호출을 생성한다.

    ch := make(chan error, 1)
    go func() {
        log.Println("Starting Request")

웹 호출을 수행하고 error.Client.Do에서 얻는 것을 리턴하고 리퀘스트 URL을 호출하려고 해본다. 전체 문서가 돌아올 때 까지 대기해야 하기 때문에 지금 당장은 블록될 것이다.

        resp, err := client.Do(req)

오류가 발생하면, 채널에서 작업이 완료되었음을 보고한다. 어떤 시점에서 어떤 일이 일어났는지 보고하기 위해서 채널을 사용할 것이다.

        if err != nil {
            ch <- err
            return
        }

실패하지 않는다면, 리턴할 때에 respose body를 닫는다.

        defer resp.Body.Close()

stdout에 respose를 작성한다.

        io.Copy(os.Stdout, resp.Body)

그리고, error대신에 nil을 돌려보낸다.

        ch <- nil
    }()

리퀘스트 혹은 타임아웃을 기다린다. ctx.Done()에 대한 수신을 수행해서 위의 전체 프로세스가 일어날 때 까지 50ms을 기다린다. 그렇지 않은 경우, 리퀘스트 취소 신호를 고루틴으로 보낸다. 이것이 필요로 하지 않기 때문에, 자원을 소비하도록 내버려 둘 필요가 없다. CancelRequest를 호풀 할 수 있으며, 바로 아래에서 커넥션을 종료할 수 있다.

    select {
    case <-ctx.Done():
        log.Println("timeout, cancel work...")
        tr.CancelRequest(req)
        log.Println(<-ch)
    case err := <-ch:
        if err != nil {
            log.Println(err)
        }
    }
}
2020/08/24 18:37:18 Starting Request
2020/08/24 18:37:18 timeout, cancel work...
2020/08/24 18:37:18 Get https://wwww.ardanlabs.com/blog/post/index.xml:
net/http: request canceled while waiting for connection

테스팅 & 프로파일링

테스팅

기본 유닛 테스트

테스트 파일명은 <filename>_test.go 형식이어야 하며, 형식이 맞지 않으면 테스팅 도구가 테스트를 찾지 못한다. 테스트 파일은 최종 실행 파일로 컴파일 되지 않는다. 테스트 파일은 코드와 같은 폴더에 있어야 하지만, 통합 테스트 같이 유닛 테스트 이상을 진행시 test라는 폴더가 필요할 수 있다. 패키지 이름은 <package name> 또는 <package name>_test 를 쓴다.

테스트 파일의 패키지 이름으로 name_test를 사용하면, 해당 패키지와 테스트 한다는 것을 명확히 보여준다. 다만, 패키지 내에 외부로 노출하지 않는 함수나 메서드가 있고, 그 함수들을 테스트해야 하는 경우에는 쓸 수 없다.

<package name>_test 형식을 사용한다면, 외부로 노출되지 않은 API를 외부 노출된 API로 테스트 할 수 없을 경우 테스트 커버리지에 대한 경고를 할 것이고, 코드에서 놓친 부분이 있다는 것을 알게 된다. 따라서 열에 아홉은 <package name>_test 형식을 쓰는 것이 맞다.

package main

import (
    "net/http"
    "testing" // This is Go testing package.
)

테스트 성공여부를 체크박스 특수문자로 시각화해 보았다.

const (
    succeed = "\u2713"
    failed = "\u2717"
)

TestBasichttp.Get 함수가 콘텐츠를 다운로드 할 수 있는지 확인한다. 모든 테스트는 테스트 함수를 이용하며, 테스트 함수의 이름은 Test로 시작하고 Test 다음의 첫 번째 문자는 대문자를 쓴다. 그리고 testing.T 포인터를 매개변수로 사용한다. 테스트를 작성할 때에는 사용성에 중점을 둔다. 실제 제품에서 사용되는 것과 동일하게 테스트를 작성한다는 뜻이다. 테스트는 세 함수를 이용하여 출력할 수 있는데, Log 또는 Logf, Fatal 또는 Fatalf, Error 또는 Errorf 이다. 이것이 테스트를 위한 핵심 API이다.

Log: 로그에 정보를 출력함.

Error: 정보를 출력하고 테스트 실패를 알리지만, 테스트 코드는 계속 실행함.

Fatal: 테스트 실패를 알리는 것은 같지만 해당 테스트 함수는 종료함. 다음 테스트 함수를 실행함

Given, When, Should 형식.

Given: 테스트의 목적이 무엇인가?

When: 어떤 데이터가 테스트에 사용되는가?

Should: 언제 원하는 함수가 실행되기를 기대하는가?

또한 인위적인 블럭을 긴 로그 사이에 사용하여 가독성을 높인다.

func TestBasic(t *testing.T) {
    url := "https://www.google.com/"
    statusCode := 200

    t.Log("Given the need to test downloading content.")
    {
        t.Logf("\tTest 0:\tWhen checking %q for status code %d", url, statusCode)
        {
            resp, err := http.Get(url)
            if err != nil {
                t.Fatalf("\t%s\tShould be able to make the Get call : %v", failed, err)
            }
            t.Logf("\t%s\tShould be able to make the Get call.", succeed)

            defer resp.Body.Close()

            if resp.StatusCode == statusCode {
                t.Logf("\t%s\tShould receive a %d status code.", succeed, statusCode)
            } else {
                t.Errorf("\t%s\tShould receive a %d status code : %d", failed, statusCode, resp.StatusCode)
            }
        }
    }
}

go test 명령어를 사용하면 테스팅 도구가 관련된 함수를 찾는다. go test -v 명령어로 로그의 전체 출력 결과를 확인할 수 있다. 만약 테스트 함수가 많은데 TestBasic 함수만 테스트하고 싶다면, go test -run TestBasic 명령어를 사용하면 된다.

=== RUN TestBasic
--- PASS: TestBasic (0.24s)
    basic_test.go:58:   Given the need to test downloading content.
    basic_test.go:60:   Test 0: When checking "https://www.google.com/" for status code 200
    basic_test.go:66:   ✓      Should be able to make the Get call.
    basic_test.go:71:   ✓      Should receive a 200 status code.
PASS
ok     command-line-arguments 0.316s

Table Test

Set up a data structure of input to expected output. This way we don’t need a separate function for each one of these. We just have 1 test function. As we go along, we just add more to the table.

package main

import (
    "net/http"
    "testing"
)

TestTable validates the http Get function can download content and handles different status conditions properly

func TestTable(t *testing.T) {

This table is a slice of anonymous struct type. It is the URL we are gonna call and statusCode is what we expect.

    tests := []struct {
        url         string
        statusCode  int
    }{
        {"https://www.google.com", http.StatusOK},
        {"http://rss.cnn.com/rss/cnn_topstorie.rss", http:StatusNotFound},
    }

    t.Log("Given the need to test downloading different content.")
    {
        for i, tt := ragne tests {
            t.Logf("\tTest: %d\tWhen checking %q for status code %d", i, tt.url, tt.statusCode)
            {
                resp, err := http.Get(tt.url)
                if err != nil{
                    t.Fatalf("\t%s\tShould be able to make the Get call : %v", failed, err)
                }
                t.Logf("\t%s\tShould be able to make the Get call.", succeed)

                defer resp.Body.Close()

                if resp.StatusCode == tt.statusCode {
                    t.Logf("\t%s\tSould receive a %d status code.", succeed, tt.statusCode)
                } else {
                    t.Errorf("\t%s\tShould receive a %d status code : %v", failed, tt.statusCode, resp.StatusCode)
                }
            }
        }
    }
}
=== RUN TestTable
--- PASS: TestTable (0.31s)
    table_test.go:35:   Given the need to test downloading different content.
    table_test.go:38:   Test: 0 When checking "https://www.google.com/" for status code 200
    table_test.go:44:   ✓      Should be able to make the Get call.
    table_test.go:49:   ✓      Should receive a 200 status code.
    table_test.go:38:   Test: 1 When checking "http://rss.cnn.com/rss/cnn_topstorie.rss" for status code 404
    table_test.go:44:   ✓      Should be able to make the Get call.
    table_test.go:49:   ✓      Should receive a 404 status code.
PASS
ok      command-line-arguments 0.472s

Sub Test

Sub test helps us streamline our test functions, filters out command-line level big tests into smaller sub tests.

package main

import (
    "net/http"
    "testing"
)

TestSub validates the http Get function can download content and handles different status conditions properly.

func TestSub(t *testing.T) {
    tests := []struct {
        name string
        url string
        statusCode int
    }{
        {"statusok", "https://www.google.com/", http.StatusOK},
        {"statusnotfound", "http://rss.cnn.com/rss/cnn_topstorie.rss", http.StatusNotFound},
    }
    t.Log("Given the need to test downloading different content.")
    {

Range over our table but this time, create an anonymous function that takes a testing T parameter. This is a test function inside a test function. What’s nice about it is that we are gonna have a new function for each set of data that we have in our table. Therefore, we will end up with 2 different functions here.

        for i, tt := range tests {
            tf := func(t *testing.T) {
                t.Logf("\tTest: %d\tWhen checking %q for status code %d", i, tt.url, tt.statusCode)
                {
                    resp, err := http.Get(tt.url)
                    if err != nil {
                        t.Fatalf("\t%s\tShould be able to make the Get call : %v", failed, err)
                    }
                    t.Logf("\t%s\tShould be able to make the Get call.", succeed)

                    defer resp.Body.Close()

                    if resp.StatusCode == tt.statusCode {
                        t.Logf("\t%s\tShould receive a %d status code.", succeed, tt.statusCode)
                    } else {
                        t.Errorf("\t%s\tShould receive a %d status code : %v", failed, tt.statusCode, resp.StatusCode)
                    }
                }
            }

Once we declare this function, we tell the testing tool to register it as a sub test under the test name.

            t.Run(tt.name, tf)
        }
    }
}

TestParallelize validates the http Get function can download content and handles different status conditions properly but runs the tests in parallel.

func TestParallelize(t *testing.T) {
    tests := []struct {
        name string
        url string
        statusCode int
    }{
        {"statusok", "https://www.goinggo.net/post/index.xml", http.StatusOK},
        {"statusnotfound", "http://rss.cnn.com/rss/cnn_topstorie.rss", http.StatusNotFound},
    }

    t.Log("Given the need to test downloading different content.")
    {
        for i, tt := range tests {
            tf := func(t *testing.T) {

The only difference here is that we call Parallel function inside each of these individual sub test functions.

                t.Parallel()

                t.Logf("\tTest: %d\tWhen checking %q for status code %d", i, tt.url, tt.statusCode)
                {
                    resp, err := http.Get(tt.url)
                    if err != nil {
                        t.Fatalf("\t%s\tShould be able to make the Get call : %v", failed, err)
                    }
                    t.Logf("\t%s\tShould be able to make the Get call.", succeed)

                    defer resp.Body.Close()

                    if resp.StatusCode == tt.statusCode {
                        t.Logf("\t%s\tShould receive a %d status code.", succeed, tt.statusCode)
                    } else {
                        t.Errorf("\t%s\tShould receive a %d status code : %v", failed, tt.statusCode, resp.StatusCode)
                    }
                }
            }

            t.Run(tt.name, tf)
        }
    }
}

Because we have sub tests, we can run the following to separate them: “go test -run TestSub -v” “go test -run TestSub/statusok -v” “go test -run TestSub/statusnotfound -v” “go test -run TestParallelize -v”

=== RUN TestSub
=== RUN TestSub/statusok
=== RUN TestSub/statusnotfound
--- PASS: TestSub (0.32s)
    sub_test.go:32: Given the need to test downloading different content.
    --- PASS: TestSub/statusok (0.24s)
        sub_test.go:40:     Test: 0 When checking "https://www.google.com/" for status code 200
        sub_test.go:46:     ✓      Should be able to make the Get call.
        sub_test.go:51:     ✓      Should receive a 200 status code.
    --- PASS: TestSub/statusnotfound (0.08s)
        sub_test.go:40:     Test: 1 When checking "http://rss.cnn.com/rss/cnn_topstorie.rss" for status code 404
        sub_test.go:46:     ✓      Should be able to make the Get call.
        sub_test.go:51:     ✓      Should receive a 404 status code.
=== RUN TestParallelize
=== RUN TestParallelize/statusok
=== PAUSE TestParallelize/statusok
=== RUN TestParallelize/statusnotfound
=== PAUSE TestParallelize/statusnotfound
=== CONT TestParallelize/statusok
=== CONT TestParallelize/statusnotfound
--- PASS: TestParallelize (0.00s)
    sub_test.go:77: Given the need to test downloading different content.
    --- PASS: TestParallelize/statusok (0.09s)
        sub_test.go:85:     Test: 1 When checking "http://rss.cnn.com/rss/cnn_topstorie.rss" for status code 404
        sub_test.go:91:     ✓      Should be able to make the Get call.
        sub_test.go:96:     ✓      Should receive a 404 status code.
    --- PASS: TestParallelize/statusnotfound (0.09s)
        sub_test.go:85:     Test: 1 When checking "http://rss.cnn.com/rss/cnn_topstorie.rss" for status code 404
        sub_test.go:91:     ✓      Should be able to make the Get call.
        sub_test.go:96:     ✓      Should receive a 404 status code.
PASS
ok      command-line-arguments 0.618s

Web Server

Web Server

If we write our own web server, we would like to test it as well without manually having to stand up a server. The Go standard library also supports this.

Below is our simple web server.

package main

import (
    "log"
    "net/http"

Import handler package that has a set of routes that we are gonna work with.

    "github.com/hoanhan101/ultimate-go/go/testing/web_server/handlers"
)

func main() {
    handlers.Routes()

    log.Println("listener : Started : Listening on: http://localhost:4000")
    http.ListenAndServe(":4000", nil)
}

Handlers

Package handlers provides the endpoints for the web service.

package handlers

import (
    "encoding/json"
    "net/http"
)

Routes sets the routes for the web service. It has 1 route call /sendjson. When that route is executed, it wil lcall the SendJSON function

func Routes() {
    http.HandleFunc("/sendjson", SendJSON)
}

SendJSON returns a simple JSON document. This has the same signature that we had before using ResponseWriter and Request. We create an anonymous struct, initialize it and unmarshall it into JSON and pass it down the line.

func SendJSON(rw http.ResponseWriter, r *http.Request) {
    u := struct {
        Name string
        Email string
    }{
        Name: "Hoanh An",
        Email: "hoanhan101@gmail.com",
    }

    rw.Header().Set("Content-Type", "application/json")
    rw.WriteHeader(200)
    json.NewEncoder(rw).Encode(&u)
}

Example Test

This is another type of test in Go. Examples are both tests and documentations. If we execute “godoc -http :3000”, Go will generate for us a server that presents the documentation of our code. The interface will look like the official golang interface, but then inside the Packages section are our local packages.

Example functions are a little bit more concrete in terms of showing people how to use our API. More interestingly, Examples are not only for documentation but they can also be tests.

For them to be tested, we need to add a comment at the end of the functions: one is Output and one is expected output. If we change the expected output to be something wrong then, the compiler will tell us when we run the test. Below is an example.

Example tests are really powerful. They give users examples how to use the API and validate that the APIs and examples are working.

package handlers_test

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "net/http/httptest"
)

ExampleSendJSON provides a basic example. Notice that we are binding that Example to our SendJSON function.

func ExampleSendJSON() {
    r := httptest.NewRequest("GET", "/sendjson", nil)
    w := httptest.NewRecorder()
    http.DefaultServeMux.ServeHTTP(w, r)

    var u struct {
        Name string
        Email string
    }

    if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
        log.Println("ERROR:", err)
    }

    fmt.Println(u)
    // Output:
    // {Hoanh An hoanhan101@gmail.com}
}
=== RUN ExampleSendJSON
--- PASS: ExampleSendJSON (0.00s)
PASS
ok      github.com/hoanhan101/ultimate-go/go/testing/web_server/handlers 0.096s

Internal Test

Below is how to test the execution of an internal endpoint without having to stand up the server. Run test using “go test -v -run TestSendJSON”

We are using handlers_test for package name because we want to make sure we only touch the exported API.

package handlers_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    "github.com/hoanhan101/ultimate-go/go/testing/web_server/handlers"
)

const (
    succeed = "\u2713"
    failed  = "\u2717"
)

This is very critical. If we forget to do this then nothing will work.

func init() {
    handlers.Routes()
}

TestSendJSON testing the sendjson internal endpoint. In order to mock this call, we don’t need the network. What we need to do is create a request and run it through the Mux so we are gonna bypass the network call together, run the request directly through the Mux to test the route and the handler.

func TestSendJSON(t *testing.T) {
    url := "/sendjson"
    statusCode := 200

    t.Log("Given the need to test the SendJSON endpoint.")
    {

Create a nil request GET for the URL.

        r := httptest.NewRequest("GET", url, nil)

NewRecorder gives us a pointer to its concrete type called ResponseRecorder that already implements the ResponseWriter interface.

        w := httptest.NewRecorder()

ServerHTTP asks for a ResonseWriter and a Request. This call will perform the Mux and call that handler to test it without network. When his call comes back, the recorder value w has the result of the entire execution. Now we can use that to validate.

        http.DefaultServeMux.ServeHTTP(w, r)

        t.Logf("\tTest 0:\tWhen checking %q for status code %d", url, statusCode)
        {
            if w.Code != 200 {
                t.Fatalf("\t%s\tShould receive a status code of %d for the response. Received[%d]", failed, statusCode, w.Code)
            }
            t.Logf("\t%s\tShould receive a status code of %d for the response.", succeed, statusCode)

If we got the 200, we try to unmarshal and validate it.

            var u struct {
                Name  string
                Email string
            }

            if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
                t.Fatalf("\t%s\tShould be able to decode the response.", failed)
            }
            t.Logf("\t%s\tShould be able to decode the response.", succeed)

            if u.Name == "Hoanh An" {
                t.Logf("\t%s\tShould have \"Hoanh An\" for Name in the response.", succeed)
            } else {
                t.Errorf("\t%s\tShould have \"Hoanh An\" for Name in the response : %q", failed, u.Name)
            }

            if u.Email == "hoanhan101@gmail.com" {
                t.Logf("\t%s\tShould have \"hoanhan101@gmail.com\" for Email in the response.", succeed)
            } else {
                t.Errorf("\t%s\tShould have \"hoanhan101@gmail.com\" for Email in the response : %q", failed, u.Email)
            }
        }
    }
}
=== RUN TestSendJSON
--- PASS: TestSendJSON (0.00s)
    handlers_test.go:41:    Given the need to test the SendJSON endpoint.
    handlers_test.go:55:    Test 0: When checking "/sendjson" for status code 200
    handlers_test.go:60:    ✓     Should receive a status code of 200 for the response.
    handlers_test.go:71:    ✓     Should be able to decode the response.
    handlers_test.go:74:    ✓     Should have "Hoanh An" for Name in the response.
    handlers_test.go:80:    ✓     Should have "hoanhan@bennington.edu" for Email in the response.
PASS
ok      github.com/hoanhan101/ultimate-go/go/testing/web_server/handlers
0.151s

Mock Server

Those basic tests that we just went through were cool but had a flaw: they require the use of the Internet. We cannot assume that we always have access to the resources we need. Therefore, mocking becomes an important part of testing in many cases. (Mocking databases if not the case here because it is hard to do so but other networking related things, we surely can do that).

The standard library already has the http test package that let us mock different http stuff right out of the box. Below is how to mock an http GET call internally.

package main

import (
    "encoding/xml"
    "fmt"
    "net/http"
    "net/http/httptest"
    "testing"
)

feed is mocking the XML document we expect to receive. Noteice that we are using backtick ` instead of double quotes " so we can reservce special characters.

var feed = `<?xml version="1.0" encoding="UTF-8"?>
<rss>
<channel>
    <title>Going Go Programming</title>
    <description>Golang : https://github.com/goinggo</description>
    <link>http://www.goinggo.net/</link>
    <item>
        <pubDate>Sun, 15 Mar 2015 15:04:00 +0000</pubDate>
        <title>Object Oriented Programming Mechanics</title>
        <description>Go is an object oriented language.</description>
        <link>http://www.goinggo.net/2015/03/object-oriented</link>
    </item>
    </channel>
</rss>`

Item defines the fields associated with the item tag in the mock RSS document.

type Item struct {
    XMLName     xml.Name `xml:"item"`
    Title       string   `xml:"title"`
    Description string   `xml:"description"`
    Link        string   `xml:"link"`
}

Channel defince the fields associated with the channel tag in the mock RSS document

type Channel struct {
    XMLName     xml.Name `xml:"channel"`
    Title       string   `xml:"title"`
    Description string   `xml:"description"`
    Link        string   `xml:"link"`
    PubDate     string   `xml:"pubDate"`
    Items       []Item   `xml:"item"`
}

Document defines the fields associated with teh mock RSS document.

type Document struct {
    XMLName xml.Name `xml:"rss"`
    Channel Channel  `xml:"channel"`
    URI     string
}

mockServer returns a pointer of type httptest.Server to handle the mock get call. This mock function calls NewServer function that is gonna stand up a web server for us automatically. All we have to give NewServer is a function of the Handler type, which is f. f creates an anonymous function with the signature of ResponseWriter and Request. This is the core signature of everything related to http in Go. ResponseWriter is an interface that allows us to write the response out. Normally when we get this interface value, there is already a concrete type value stored inside of it that supports what we are doing.

Request is a concrete type that we are gonna get with the request. This is how it’s gonna work. We are gonna get a mock server started by making a NewServer call. When the request comes into it, execute f. Therefore, f is doing the entire mock. We are gonna send 200 down the line, set the header to XML and use Fprintln to take the Response Writer interface value and feed it with the raw string we defined above.

func mockServer() *httptest.Server {
    f := func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(200)
        w.Header().Set("Content-Type", "application/xml")
        fmt.Fprintln(w, feed)
    }
    return httptest.NewServer(http.HandlerFunc(f))
}

TestWeb validates the http Get function can download content and the content can be unmarshalled and clean.

func TestWeb(t *testing.T) {
    statusCode := http.StatusOK

Call the mock server and defer close to shut it down cleanly

    server := mockServer()
    defer server.Close()

Now, it’s just the matter of using server value to know what URL we need to use to run this mock. From the http.Get point of view, it is making an URL call. It has no idea that it’s hitting the mock server. We have mocked out a perfect response.

    t.Log("Given the need to test downloading content.")
    {
        t.Logf("\tTest 0:\tWhen checking %q for status code %d", server.URL, statusCode)
        {
            resp, err := http.Get(server.URL)
            if err != nil {
                t.Fatalf("\t%s\tShould be able to make the Get call : %v", failed, err)
            }
            t.Logf("\t%s\tShould be able to make the Get call.", succeed)

            defer resp.Body.Close()

            if resp.StatusCode != statusCode {
                t.Fatalf("\t%s\tShould receive a %d status code : %v", failed, statusCode, resp.StatusCode)
            }
            t.Logf("\t%s\tShould receive a %d status code.", succeed, statusCode)

When we get the response back, we are unmarshaling it from XML to our struct type and do some extra validation with that as we go.

            var d Document
            if err := xml.NewDecoder(resp.Body).Decode(&d); err != nil {
                t.Fatalf("\t%s\tShould be able to unmarshal the response : %v", failed, err)
            }
            t.Logf("\t%s\tShould be able to unmarshal the response.", succeed)

            if len(d.Channel.Items) == 1 {
                t.Logf("\t%s\tShould have 1 item in the feed.", succeed)
            } else {
                t.Errorf("\t%s\tShould have 1 item in the feed : %d", failed, len(d.Channel.Items))
            }
        }
    }
}
=== RUN TestWeb
--- PASS: TestWeb (0.00s)
    web_test.go:109:    Given the need to test downloading content.
    web_test.go:111:    Test 0: When checking "http://127.0.0.1:58548" for status code 200
    web_test.go:117:    ✓     Should be able to make the Get call.
    web_test.go:124:    ✓     Should receive a 200 status code.
    web_test.go:132:    ✓     Should be able to unmarshal the response.
    web_test.go:135:    ✓     Should have 1 item in the feed.
PASS
ok      command-line-arguments 0.191s

191-199

벤치마킹

기본 벤치마크

벤치마크 함수가 있는 <file_name>_test.go 파일로 벤치마크를 할 수 있다. SprintSprintf 중 어느 것이 더 성능이 좋고, 효율적으로 자원을 할당하는지 벤치마크 해볼 텐데, Sprint가 문자열 포맷을 하는 동안 오버헤드가 없기 때문에 더 나을 것으로 보이지만 그렇지 않다. 추측보다는 실제 확인한 결과로 판단하자.

package main

import (
    "fmt"
    "testing"
)

var gs string

BenchmarkSprintBasicSprint 성능을 테스트한다. 벤치마크 하려는 모든 코드는 b.N 반복문 안에 있어야 한다. 벤치마크 도구가 처음 호출 할 때 b.N은 1이고, 충분히 테스트가 진행될 때 까지 b.N값은 지속적으로 증가한다. 전역변수 gsfmt.Sprint의 값을 반환하기 때문에 dead code 처럼 보이지 않게 된다.

func BenchmarkSprintBasic(b *testing.B) {
    var s string

    for i := 0; i < b.N; i++ {
        s = fmt.Sprint("hello")
    }
    gs = s
}

BenchmarkSprintfBasicSprintf의 성능을 테스트한다.

func BenchmarkSprintfBasic(b *testing.B) {
    var s string
    for i := 0; i < b.N; i++ {
        s = fmt.Sprintf("hello")
    }
    gs = s
}

go test -run none -bench . -benchtime 3s -benchmem

goos: darwin
goarch: amd64
BenchmarkSprintBasic-16     52997451    56.9 ns/op    5 B/op    1 allocs/op
BenchmarkSprintfBasic-16    72737234    45.7 ns/op    5 B/op    1 allocs/op
PASS
ok  command-line-arguments  6.637s

하위 벤치마크

하위 테스트를 하듯이, 하위 벤치마크도 가능하다.

package main

import (
    "fmt"
    "testing"
)

BenchmarkSprintSubSprint와 관련한 모든 하위 벤치마크를 한다.

func BenchmarkSprintSub(b *testing.B) {
    b.Run("none", benchSprint)
    b.Run("format", benchSprintf)
}

benchSprintSprint의 성능을 테스트한다.

func benchSprint(b *testing.B) {
    var s string

    for i := 0; i < b.N; i++ {
        s = fmt.Sprint("hello")
    }
    gs = s
}

benchSprintfSprintf의 성능을 테스트한다.

func benchSprintf(b *testing.B) {
    var s string

    for i := 0; i < b.N; i++ {
        s = fmt.Sprintf("hello")
    }
    gs = s
}

go test -run none -bench . -benchtime 3s -benchmem

goos: darwin
goarch: amd64
BenchmarkSprintSub/none-16    54088082    60.6 ns/op    5 B/op    1 allocs/op
BenchmarkSprintSub/format-16  67906119    52.3 ns/op    5 B/op    1 allocs/op
PASS
ok command-line-arguments 7.131s

하위 벤치마크를 할 수 있는 다른 방법들:

프로파일링(Profiling)

스택 트레이스(Stack Trace)

스택 트레이스 리뷰(Review Stack Trace)

어떻게 스택 트레이스를 할 수 있을까?

package main

func main() {

이 예제는 배열, 문자열, 정수를 사용한다. 길이 2, 용량 4의 배열을 만들고 그 배열 값을 예제 함수에 전달한다.

    example(make([]string, 2, 4), "hello", 10)
}

예제는 내장 함수 panic을 호출하여 스택 트레이스을 보여준다.

func example(slice []string, str string, i int) {
    panic("Want stack trace")
}

예제를 통해 출력되는 결과:

panic: Want stack trace

goroutine 1 [running]:
main.example(0xc420053f38, 0x2, 0x4, 0x1066c02, 0x5, 0xa)

/Users/hoanhan/go/src/github.com/hoanhan101/ultimate-go/go/profiling/stack_trace.go:18 +0x39
main.main()

/Users/hoanhan/go/src/github.com/hoanhan101/ultimate-go/go/profiling/stack_trace.go:13 +0x72
exit status 2

컴파일러는 어떤 라인에서 문제가 생겼는지 알려준다. 스택 트레이스를 사용했을 때 더 좋은 점은, 함수에 전달되는 값을 정확히 알 수 있다. 스택 트레이스는 데이터 구조를 펼쳐서 표시한다. slice는 3개의 word로 표시 되는데, 첫 번째 word는 포인터, 두 번째 word는 2(길이), 세 번째 word는 4(용량)이다. string은 2개의 word로 표시 되는데, "hello"는 길이가 5인 5byte의 문자열이고, 첫 번째 word는 포인터, 두 번째 word는 5(길이)이다. 마지막으로 남은 word는 10(정수)이다. 스택 트레이스에서 볼 수 있는 main.example (0xc420053f38, 0x2, 0x4, 0x1066c02, 0x5, 0xa)에서 각각에 해당되는 값은 포인터 주소, 2, 4, 포인터 주소, 5, a(16진수로 표기된 10) 이다.

스택 트레이스를 사용하는 것 만으로도 사용된 값을 확인하거나 필요한 데이터를 얻을 수 있다. Dave의 error 패키지를 사용하고, 몇몇 컨텍스트를 더하거나 log 패키지를 추가하면 디버깅 할 때 문제에 대한 더 많은 정보를 얻을 수 있다.

Packing

값을 채우는 스택 트레이스의 예.

example 함수에 할당되는 값들은 전부 1byte이다.

package main

func main() {
    example(true, false, true, 25)
}

func example(b1, b2, b3 bool, i uint8) {
    panic("Want stack trace")
}

출력 결과:

panic: Want stack trace

goroutine 1 [running]:
main.example(0xc419010001)

/Users/hoanhan/go/src/github.com/hoanhan101/ultimate-go/go/profiling/stack_trace_2.go:12 +0x39
main.main()

/Users/hoanhan/go/src/github.com/hoanhan101/ultimate-go/go/profiling/stack_trace_2.go:8 +0x29
exit status 2

이번에는 스택 트레이스가 1 word 밖에 보여주지 않는데, 이 4바이트는 32비트 환경에선 half-wold로, 64비트 환경에선 full-word로 표시된다. 예제에 사용된 시스템은 리틀 엔디언을 사용하고 있기 때문에 오른쪽에서 왼쪽으로 읽어야 한다. 0xc419010001 을 아래와 같이 나타낼 수 있다:

Bits Binary Hex Value
00-07 0000 0001 01 true
08-15 0000 0000 00 false
16-23 0000 0001 01 true
24-31 0001 1001 19 25

GODEBUG

메모리 트레이싱

메모리 트레이싱은 코드가 실행될 때 GC 및 힙 메모리에 관련해서 잘 작동하는지 분석을 제공한다. 다음은 메모리 릭을 일으키는 예제이다.

package main

import (
    "os"
    "os/signal"
)

func main() {

아래 코드는 고루틴을 생성할 때 메모리 릭을 일으킨다. 코드가 종료될 때 까지 계속 Key-value를 할당한다.

    go func() {
        m := make(map[int]int)
        for i := 0; ; i++ {
            m[i] = i
        }
    }()

Ctrl-C 로 코드를 종료한다.

    sig := make(chan os.Signal, 1)
    signal.Notify(sig)
    <-sig
}

GODEBUG 라는 환경변수를 사용하게 되면 메모리와 스케줄러를 확인할 수 있게 된다. 빌드 및 실행 방법:

빌드 : go build memory_tracing.go

실행 : GODEBUG=gctrace=1 ./memory_tracing

GODEBUG=gctrace=1을 설정하면 가비지 컬렉터는 각 수집마다 수집된 메모리와 실행 시간을 요약하여 한줄씩 출력한다.

문제가 발생한 부분을 찾는 방법:

gc {0} @{1}s {2}%: {3}+...+{4} ms clock, {5}+...+{6} ms cpu, {7}->{8}->{9} MB, {10} MB goal, {11} P

의미: {0} : gc 실행 횟수 {1} : 프로그램이 실행 된 시간. {2} : gc가 차지하는 CPU의 비율. {3} : 프로그램 실행 시간 - 프로그램의 지연시간이나 리소스를 사용할 수 있을 때까지 대기하는 시간을 포함한 실시간 측정 값. {4} : 프로그램 실행 시간. 보기보다 중요한 숫자. {5} : CPU 클럭 {6} : CPU 클럭 {7} : gc가 시작되기 전의 힙 크기. {8} : gc 실행 후 힙 크기. {9} : 라이브 힙의 크기. {10} : gc의 목표, 페이싱 알고리즘. {11} : 프로세스 수.

실행 결과:

gc 1 @0.007s 0%: 0.010+0.13+0.030 ms clock, 0.080+0/0.058/0.15+0.24 ms cpu, 5->5->3 MB, 6 MB goal, 8 P
gc 2 @0.013s 0%: 0.003+0.21+0.034 ms clock, 0.031+0/0.030/0.22+0.27 ms cpu, 9->9->7 MB, 10 MB goal, 8 P
gc 3 @0.029s 0%: 0.003+0.23+0.030 ms clock, 0.029+0.050/0.016/0.25+0.24 ms cpu, 18->18->15 MB, 19 MB goal, 8 P
gc 4 @0.062s 0%: 0.003+0.40+0.040 ms clock, 0.030+0/0.28/0.11+0.32 ms cpu, 36->36->30 MB, 37 MB goal, 8 P
gc 5 @0.135s 0%: 0.003+0.63+0.045 ms clock, 0.027+0/0.026/0.64+0.36 ms cpu, 72->72->60 MB, 73 MB goal, 8 P
gc 6 @0.302s 0%: 0.003+0.98+0.043 ms clock, 0.031+0.078/0.016/0.88+0.34 ms cpu, 65->66->42 MB, 120 MB goal, 8 P
gc 7 @0.317s 0%: 0.003+1.2+0.080 ms clock, 0.026+0/1.1/0.13+0.64 ms cpu, 120->121->120 MB, 121 MB goal, 8 P
gc 8 @0.685s 0%: 0.004+1.6+0.041 ms clock, 0.032+0/1.5/0.72+0.33 ms cpu, 288->288->241 MB, 289 MB goal, 8 P
gc 9 @1.424s 0%: 0.004+4.0+0.081 ms clock, 0.033+0.027/3.8/0.53+0.65 ms cpu, 577->577->482 MB, 578 MB goal, 8 P
gc 10 @2.592s 0%: 0.003+11+0.045 ms clock, 0.031+0/5.9/5.2+0.36 ms cpu, 499->499->317 MB, 964 MB goal, 8 P

처음에는 빠른데, 점점 느려지기 시작한다. GC가 실행될 때 마다 힙 사이즈가 증가하는 부분에서 메모리 릭이 발생함을 알 수 있다.

소통하기

당신이 The Ultimate Go Book을 잘 이해할 수 있었는지 이메일(hoanhan101@gmail.com)로 알려주시길 바랍니다.

만약, 당신이 나의 활동 및 프로젝트, 그리고 더 나은 소프트웨어 엔지니어가 되는데 관심이 있다면, 내 웹사이트 https://hoanhan101.github.io/ 에 자유롭게 방문해주시길 바랍니다.

읽어주셔서 고맙습니다. 행운을 빕니다!