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를 좀더 쉽게 배우셨으면 합니다. 다시 한 번 모든 분들의 지원과 성원에 감사합니다. 정말 감사합니다.
즐겁게 읽으십시오!
빌트인 타입
타입은 두 가지 질문을 통해 완전성과 가독성을 제공한다
타입은 int32
, int64
처럼 명확한 이름도 있다. 예를 들어
uint8
은 1 바이트 메모리에 10진수 숫자를 가지고 있다.int32
는 4 바이트 메모리에 10진수 숫자를 가지고 있다.uint
나 int
처럼 메모리 크기가 명확하지 않은 타입을 선언하면, 아키텍처에 따라 크기가 달라진다. 64-bit OS라면, int
는 int64
와 같은 크기가 되고, 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
"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) fmt.Printf(
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) 연산자를 사용하면 선언과 동시에 초기화 할 수 있다. (역자 주. 제로 값이 아닌 특정한 값으로 초기화 하려 할때 자주 쓴다.)
10
aa := "hello" // 첫 번째 워드는 문자들의 배열을 기리키는 포인터이고, 두 번째 워드는 5이다.
bb := 3.14159
cc := true
dd :=
"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) fmt.Printf(
aa := 10 int [10]
bb := "hello" string [hello]
cc := 3.14159 float64 [3.14159]
dd := true bool [true]
변환과 타입 변경(Conversion vs casting)
Go 는 casting
을 지원하지 않고 conversion
을 지원한다. 컴파일러가 컴파일 할때에 메모리가 더 있는 듯 처리하기보다 실제로 메모리를 더 할당한다.
int32(10)
aaa := "aaa := int32(10) %T [%v]\n", aaa, aaa) fmt.Printf(
aaa := int32(10) int32 [10]
example
구조체 타입은 다른 타입의 필드들을 가지고 있다.
type example struct {
bool
flag int16
counter float32
pi }
선언과 초기화(Declare and initialize)
example
구조체 타입의 변수를 선언하면, 구조체의 필드들은 제로값으로 초기화된다.
var e1 example
"%+v\n", e1) fmt.Printf(
{flag:false counter:0 pi:0}
`example 구조체에 할당하는 메모리의 크기는 얼마일까?
bool
은 1 바이트, int16
은 2 바이트, float32
는 4바이트이다. 모두 7바이트이지만, 실제로는 8바이트를 할당한다. 이를 이해하려면 패딩(padding)
과 정렬(alignment)
을 알아야 한다. 패딩 바이트는 bool
과 int16
사이에 위치한다. 정렬 때문이다.
정렬: 하드웨어에게는 정렬 경계(alignment boundary)내의 메모리를 읽게 하는 것이 효율적이다. 하드웨어가 정렬 경계에 맞춰 읽게 소프트웨어에서 챙겨주는 것이 정렬이다.
규칙 1:
특정 값의 메모리 크기에 따라 Go는 어떤 정렬이 필요할지 결정한다. 모든 2 바이트 크기의 값은 2 바이트 경계를 가진다. bool
값은 1 바이트라서 주소 0번지에서 시작한다. 그러면 다음 int16
은 2번지에서 시작해야 한다. 건너뛰게 되는 1 바이트에 패딩 1 바이트가 들어간다. 만약 int16
이 아니라 int32
라면 3 바이트의 패딩이 들어간다.
규칙 2:
가장 큰 메모리 사이즈의 필드가 전체 구조체의 패딩을 결정한다. 가능한 패딩이 적을 수록 좋은데 그러려면 큰 필드부터 가장 작은 필드의 순서로 위치시키는 것이 좋다. example
구조체를 아래와 같이 정의하면 전체 구조체의 사이즈는 8 바이트를 따르게 되는데 int64
가 8 바이트이기 때문이다.
type example struct {
int64
counter float32
pi bool
flag }
example
타입의 변수를 선언하고 구조체 리터럴로 초기화 하였다. 이때 각 라인은 콤마(,)로 끝나야 한다.
e2 := example{true,
flag: 10,
counter: 3.141592,
pi:
}"Flag", e2.flag)
fmt.Println("Counter", e2.counter)
fmt.Println("Pi", e2.pi) fmt.Println(
Counter 10
Pi 3.141592
Flag true
익명의 타입 변수를 선언하고, 구조체 리터럴로 초기화 할 수 있다. 익명 타입은 재사용할 수 없다.
struct {
e3 := bool
flag int16
counter float32
pi
}{true,
flag: 10,
counter: 3.141592,
pi:
}"Flag", e3.flag)
fmt.Println("Counter", e3.counter)
fmt.Println("Pi", e3.pi) fmt.Println(
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"%+v\n", e4) fmt.Printf(
{flag:true counter:10 pi:3.141592}
항상 값을 전달한다
포인터는 오직 한가지 목적을 가지고 있다: 공유. 프로그램의 경계를 가로질러 값을 공유하는 것이다. 여러 종류의 프로그램 경계가 있는데, 가장 흔한 것은 함수 호출이다. 고루틴 사이에도 경계가 있을 수 있다. 이에 대해서는 나중에 다루도록 한다.
프로그램이 시작할 때, 런타임은 고루틴을 생성한다. 모든 고루틴은 분리된 수행 경로이며 각각의 수행 경로는 머신이 수행해야 할 명령을 가지고 있다. 고루틴을 경량의 쓰레드라 생각해도 된다. go 키워드로 고루틴을 생성하지 않는 간단한 프로그램도 하나의 고루틴은 가진다: main 고루틴이다.
모든 고루틴은 스택이라 부르는 메모리 블럭을 할당받는데 크기는 2 킬로바이트로 매우 작다. 하지만 크기는 필요에 따라 변할 수 있다. 함수를 호출하면 수행을 위해 스택을 사용한다. 스택은 아래쪽으로 증가한다.
모든 함수는 스택 프레임을 가지는데 함수의 메모리 수행을 의미한다. [재방문] 모든 스택 프레임의 크기는 컴파일을 할 때에 알 수 있다. 컴파일러가 크기를 알 수 없는 값이 스택에 자리잡을 수는 없다. 그건 힙에 저장해야 한다.
제로값(zero value) 덕분에 우리는 모든 스택 프레임을 초기화 할 수 있다. 스택은 알아서 정리(cleaning) 되며, 그 방향은 아래쪽이다. 함수를 만들때마다 제로값으로 스택 프레임을 초기화하며 정리한다. [재방문] 메모리를 떠날때는 다시 필요하게 될지 모르기 때문에 위쪽으로 떠난다.
값의 전달(Pass by value)
int 타입의 변수를 초기값 10으로 선언하면 이 변수는 스택에 저장된다.
10
count := // 변수의 주소를 얻기 위해 &를 사용한다.
"count:\tValue Of[" , count, "]\tAddr Of[" , &count, "]")
fmt.Println(
// count의 값을 전달한다.
increment1(count)
// increment1 를 실행한 다음의 count 값을 출력한다. 바뀐 것이 없다.
"count:\tValue Of[" , count, "]\tAddr Of[" , &count, "]")
fmt.Println(
// count의 주소를 전달한다. 이것 역시 "pass by value", 즉, 값을 전달하는 것이다.
// "pass by reference" 가 아니다. 주소 역시 값인 것이다.
increment2(&count)
// increment2 를 실행한 다음 count 값을 출력한다. 값이 변경되었다.
"count:\tValue Of[" , count, "]\tAddr Of[" , &count, "]")
fmt.Println(
func increment1(inc int) {
// inc 의 값을 증가 시킨다.
inc++"inc1:\tValue Of[" , inc, "]\tAddr Of[" , &inc, "]")
fmt.Println(
}
// increment2 는 inc를 포인터 변수로 선언했다. 이 변수는 주소값을 가지며, int 타입의 값을 가리킨다.
// *는 연산자가 아니라 타입 이름의 일부이다. 이미 선언된 타입이건, 당신이 선언한 타입이건
// 모든 타입은 선언이 되면 포인터 타입도 가지게 된다.
func increment2(inc *int) {
// inc 포인터 변수가 가리키고 있는 int 변수의 값을 증가시킨다.
// 여기서 *는 연산자이며 포인터 변수가 가리키고 있는 값을 의미한다.
*inc++"inc2:\tValue Of[" , inc, "]\tAddr Of[" , &inc, "]\tValue Points To[" , *inc, "]")
fmt.Println( }
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 ]
변수 u
는 stayOnStack
함수에서 벗어나지 못한다. 함수 바깥에서 쓸 수 없다는 말이다. 컴파일 할 때에 u
의 크기를 알 수 있기에 컴파일러는 u
를 스택 프레임에 저장한다.
// user는 시스템의 user를 의미한다.
type user struct {
string
name string
email
}
func stayOnStack() user {
// 스택 프레임에 변수를 생성하고 초기화한다.
u := user{"Hoanh An",
name: "hoanhan101@gmail.com",
email:
}
// 값을 리턴하여 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{"Hoanh An",
name: "hoanhan101@gmail.com",
email:
}
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 {
int
ID string
Name
}
// updateStats 구조체는 업데이트 정보를 담고 있다.
type updateStats struct {
int
Modified float64
Duration bool
Success string
Message
}
func main() {
// user 프로필을 가져온다.
"Hoanh")
u, err := retrieveUser(if err != nil {
fmt.Println(err)return
}
// user 프로필을 보여준다. `u`는 주소값이기에 *를 사용하여 값을 얻어낸다.
"%+v\n" , *u)
fmt.Printf(
// user 의 name 을 업데이트 한다.
// _(blank identifier)를 사용하여 리턴된 updateStats는 무시하며
// if 범위 밖에서 사용할 값은 없으니 간결한 문법을 사용하였다.
if _, err := updateUser(u); err != nil {
fmt.Println(err)return
}
// 업데이트가 성공했다고 출력한다.
"Updated user record for ID", u.ID)
fmt.Println( }
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에 넣어준다.
byte(r), &u)
err = json.Unmarshal([]
// retrieveUser 함수를 호출한 함수에게 u값을 전달한다. 이처럼 retrieveUser 함수에서
// 생성한 변수의 주소값을 호출한 함수에게 전달하기에 이 변수는 힙 메모리에 할당된다.
return &u, err
}
getUser
함수는 웹으로 호출하였을때 특정한 사용자에 대한 JSON 으로 응답이 돌아오는 것을 시뮬레이션 한 것이다.
func getUser(name string) (string, error) {
`{"ID":101, "Name":"Hoanh"}`
response := return response, nil
}
updateUser
함수는 특정 사용자가 업데이트 되었다는 응답을 시뮬레이션 한 것이다.
func updateUser(u *user) (*updateStats, error) {
// response 변수는 JSON 응답을 시뮬레이션 한 것이다.
`{"Modified":1, "Duration":0.005, "Success" : true, "Message": "updated"}`
response :=
// 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
상수 third
의 kind
는 실수가 될 것이다.
const third = 1 / 3.0 // KindFloat(1) / KindFloat(3.0)
fmt.Println(third)
0.3333333333333333
상수 zero
의 kind
는 정수이다.
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 (
iota // 0 : 0에서 시작한다
A1 = iota // 1 : 1 증가한다
B1 = iota // 2 : 1 증가한다
C1 =
)
"1:", A1, B1, C1)
fmt.Println(
const (
iota // 0 : 0에서 시작한다
A2 = // 1 : 1 증가한다
B2 // 2 : 1 증가한다
C2
)
"2:", A2, B2, C2)
fmt.Println(
const (
iota + 1 // 1 : 1에서 시작한다
A3 = // 2 : 1 증가한다
B3 // 3 : 1 증가한다
C3
)
"3:", A3, B3, C3)
fmt.Println(
const (
1 << iota // 1 : 오른쪽으로 0번 시프트 된다. 0000 0001
Ldate= // 2 : 오른쪽으로 1번 시프트 된다. 0000 0010
Ltime // 4 : 오른쪽으로 2번 시프트 된다. 0000 0100
Lmicroseconds // 8 : 오른쪽으로 3번 시프트 된다. 0000 1000
Llongfile // 16 : 오른쪽으로 4번 시프트 된다. 0001 0000
Lshortfile // 32 : 오른쪽으로 5번 시프트 된다. 0010 0000
LUTC
)"Log:", Ldate, Ltime, Lmicroseconds, Llongfile, Lshortfile, LUTC) fmt.Println(
1: 0 1 2
2: 0 1 2
3: 1 2 3
Log: 1 2 4 8 16 32
코어들은 메인 메모리로 바로 접근하지 않고 로컬 캐시로 접근한다. 캐시에는 데이터와 명령어가 저장되어 있다.
캐시 속도는 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)을 유지한다.
캐싱 시스템은 하드웨어로 한 번에 64바이트씩 데이터를 옮긴다. 데이터 단위는 기계 별로 다르듯이, 운영 체제는 4k(운영 체제의 기존 페이지 크기) 바이트씩 페이징 함으로써 메모리를 관리한다.
관리되는 모든 페이지는 가상 메모리 주소(소프트웨어는 가상 주소를 물리적 메모리를 사용하고 공유하는 샌드박스에서 실행한다)를 갖게 되는데, 올바른 페이지에 매핑되고 물리적 메모리로 오프셋 하기 위해 사용된다.
변환 색인 버퍼의 미스는 캐시 미스보다 나쁠 수 있다. 연결 리스트 순회가 중간 성능인 이유는 다수의 노드가 같은 페이지에 있기 때문이다. 캐시 라인은 예측 가능한 거리를 요구하지 않기 때문에 캐시 미스가 발생 할 수 있지만, 많은 변환 색인 버퍼의 미스는 발생하지 않을 수 있다. 열 순회에서는 캐시 미스뿐만 아니라 엑세스할 때마다 변환 색인 버퍼의 캐시 미스가 발생 할 수 있다.
즉, 데이터 지향 설계가 중요하다. 효율적인 알고리즘을 작성하는 것에 그치지 않고, 어떻게 데이터에 접근하는 것이 알고리즘보다 성능에 좋은 영향을 미칠지 고려해야 한다.
문자열이 원소이고 길이가 5인 배열을 선언하고, 제로 값(zero value)으로 초기화 해보자. 다시 한 번 더 말하지만, 문자열은 포인터와 길이를 표현하는 두 워드(word)로 이루어진 데이터 구조다. 이 배열을 제로 값(zero value)으로 설정하면, 배열속의 모든 문자열도 제로 값(zero value)이 된다. 각각의 문자열의 첫 번째 워드는 nil을 가리키고 두 번째 워드는 0이 된다.
var strings [5]string
인덱스 0의 문자열은 이제 바이트들(문자열을 구성하는 문자들)을 실제로 저장하고 있는 배열에 대한 포인터와 길이 정보 5를 가지게 된다.
할당에는 2 바이트를 복사하는 비용이 발생한다. 두 문자열은 같은 배열을 가리키며, 그래서 할당의 비용은 2 단어에 대한 비용뿐이다.
0] = "Apple" strings[
슬라이스의 남은 부분에도 값을 할당한다.
1] = "Orange"
strings[2] = "Banana"
strings[3] = "Grape"
strings[4] = "Plum" strings[
range를 사용하면, 인덱스와 복사된 원소의 값을 얻을 수 있다. for 문 내에서 fruit 변수는 문자열 값을 가지게 된다. 첫 번째 반복에서는 “Apple”을 가진다. 이 문자열 역시 위 이미지의 (1) 배열을 가리키는 워드와 길이 5를 나타내는 두 번째 워드를 가진다. 이제 세 개의 문자열의 같은 배열을 공유하고 있다.
Println 함수에는 무엇을 전달하는가
여기서는 value의 의미로서 사용한다. 문자열 값을 공유하지 않는다. Println은 문자열의 값을 복사해서 가진다. Println을 호출 할 때 같은 배열을 공유하는 4개의 문자열을 가지게 되는 것이다. 문자열의 주소를 함수에 전달하지 않으면 이점이 있다. 문자열의 길이를 알고 있으니 스택에 둘 수 있고, 그 덕분에 힙에 할당하여 GC를 해야하는 부담을 덜게 된다. 문자열은 값을 전달하여 스택에 둘 수 있게 디자인 되어, 가비지가 생성되지 않는다. 그래서 문자열(들)이 가리키는 배열만이 힙에 저장되고 공유된다.
"\n=> Iterate over array\n")
fmt.Printf(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)으로 특정 값으로 초기화 한다.
4]int{10, 20, 30, 40} numbers := [
전통적인 방법으로 배열을 반복한다.
"\n=> Iterate over array using traditional style\n")
fmt.Printf(
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
제로 값으로 초기화 된 길이가 5인 정수 형 배열을 선언하자.
var five [5]int
특정 값으로 초기화 된 길이가 4인 정수 형 배열을 선언하자.
4]int{10, 20, 30, 40} four := [
"\n=> Different type arrays\n")
fmt.Printf(
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) 때 정해진 크기를 갖게 된다.
특정 값들로 초기화 된 길이가 6인 문자열 배열을 선언하자.
6]string{"Annie", "Betty", "Charley", "Doug", "Edward", "Hoanh"} six := [
이 배열을 반복하면서 각 원소의 값과 주소를 출력하자. Printf
의 결과를 보면, 이 배열은 연속 된 메모리 블록으로 이루어진 것을 알 수 있다. 문자열은 두 워드로 되어 있고, 컴퓨터 아키텍처에 따라 x 바이트를 가지게 된다. 연속 된 두 IndexAddr
의 거리는 정확히 x 바이트이다. 변수 v
는 스택에 있고 매번 같은 주소를 가진다.
"\n=> Contiguous memory allocations\n")
fmt.Printf(for i, v := range six {
"Value[%s]\tAddress[%p] IndexAddr[%p]\n", v, &v, &six[i])
fmt.Printf( }
=> 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]
5개의 요소를 갖는 슬라이스(slice)
를 생성해보자. make
함수는 슬라이스(slice)
와 맵(map)
그리고 채널(channel)
타입에서 사용하는, 특별한 내장 함수이다. make 함수를 사용하여 5개의 문자열 배열을 갖는 슬라이스를 생성하면, 3개의 워드(word) 데이터 구조가 만들어진다. 첫 번째 워드는 배열을 위치를 가리키고 두 번째 워드는 길이를, 세 번째 워드는 용량을 나타낸다.
길이(Length)
는, 포인터의 위치에서부터 접근해서 읽고 쓸 수 있는 요소의 수를 의미하며, 용량(Capacity)
은 포인터의 위치에서부터 배열에 존재할 수 있는 요소의 총량을 뜻한다.
문법적 설탕(syntactic sugar)을 사용하기에, 슬라이스는 언뜻 배열처럼 보인다. 비용도 배열과 동일하게 발생한다. 하지만, 한 가지 다른 점은 make 함수의 []string
의 대괄호 안에 값이 없다는 것이다. 이것으로 배열과 슬라이스를 구분할 수 있다.
make([]string, 5)
slice1 := 0] = "Apple"
slice1[1] = "Orange"
slice1[2] = "Banana"
slice1[3] = "Grape"
slice1[4] = "Plum" slice1[
슬라이스의 길이를 넘는 인덱스에는 접근할 수 없다.
Error: panic: runtime error: index out of range slice1[5] = "Runtime error"
슬라이스의 주소가 아닌 값을 전달한다. 따라서, Println
함수는 슬라이스의 복사본을 갖게 된다.
"\n=> Printing a slice\n")
fmt.Printf( fmt.Println(slice1)
=> Printing a slice
[Apple Orange Banana Grape Plum]
5개의 요소를 갖고 용량이 8개인 슬라이스를 만들기 위해 make 키워드를 이용할 수 있으며, 이를 통해 초기화 시점에 직접 용량을 정할 수 있다.
결국 우리는, 차례대로 8개의 요소를 갖는 배열을 가르키는 포인터와 길이는 5, 용량은 8을 갖는 3개의 워드(word)형 자료 구조를 갖게된다. 이는 첫 5개의 요소에 대해 읽고 쓸 수 있으며, 필요시 이용 가능한 3개의 용량을 갖는 것을 뜻한다.
make([]string, 5, 8)
slice2 := 0] = "Apple"
slice2[1] = "Orange"
slice2[2] = "Banana"
slice2[3] = "Grape"
slice2[4] = "Plum" slice2[
"\n=> Length vs Capacity\n")
fmt.Printf( inspectSlice(slice2)
// inspectSlice는 리뷰를 위해 슬라이스 헤더를 보여주는 함수이다.
// 파라미터 : 다시 말하지만, []string의 대괄호 속에 값이 없으므로 슬라이스를 사용함을 알 수 있다.
// 배열에서 했던 것과 마찬가지로, 슬라이스를 순회한다.
// `len`이 슬라이스의 길이를 알려주며, `cap`은 슬라이스의 용량을 알려준다.
// 결과를 보면, 예상대로 슬라이스의 주소 값들이 정렬되어 표시되는 것을 볼 수 있다.
func inspectSlice(slice []string) {
"Length[%d] Capacity[%d]\n", len(slice), cap(slice))
fmt.Printf(for i := range slice {
"[%d] %p %s\n", i, &slice[i], slice[i])
fmt.Printf(
} }
=> 길이 vs 용량 (Length vs Capacity)
Length[5] Capacity[8]
[0] 0xc00007e000 Apple
[1] 0xc00007e010 Orange
[2] 0xc00007e020 Banana
[3] 0xc00007e030 Grape
[4] 0xc00007e040 Plum
문자열로 구성될 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을 반환하게 된다. 그렇다면 이 때 포인터는 어떤 것을 가르키게 될까? 바로, 나중에 살펴볼 빈 구조체를 가르킨다.
슬라이스의 용량을 가져오자.
cap(data) lastCap :=
슬라이스에 약 10만개의 문자열을 덧붙인다.
for record := 1;record <= 102400;record++ {
내장 함수인 append
를 사용해서 슬라이스를 덧붙일 수 있다. 이 함수를 통해 슬라이스에 값을 추가할 수 있으며 자료 구조를 동적으로 만들 수 있으면서도, 기계적 동정심(mechanical sympathy)을 통해 예측 가능한 접근 패턴을 제공함으로써 여전히 인접한 메모리 블럭을 이용할 수 있게 된다. append
함수는 값 개념(value semantic)으로 동작한다. 슬라이스 자체를 공유하는 것이 아니라, 슬라이스에 값을 덧붙이고 그 복사본을 반환하는 식이다. 따라서 슬라이스는 힙 메모리가 아닌 스택에 위치하게 된다.
append(data, fmt.Sprintf("Rec: %d", record)) data =
append
가 동작할 때 마다, 매번 길이와 용량을 확인한다. 만약 두 값이 동일하다면, 더 이상 남은 공간이 없다는 것을 뜻한다. 이 때, append
함수는 기존보다 2배를 늘린 크기를 갖는 새로운 배열을 만들어서 예전 값을 복사한 뒤, 새 값을 추가하게 된다. 그리고 스택 프레임에 존재하는 값을 변경시킨 뒤, 그 복사본을 반환한다. 그렇게 기존의 슬라이스가 새로운 복사본으로 치환된다. 만약 길이와 용량이 같지 않다면, 슬라이스 안에 아직 사용할 수 있는 공간이 남아있다는 것을 뜻하므로, 새 복사본을 만드는 일 없이 값을 추가 할 수 있다. 이것은 굉장히 효율적이다. 출력의 마지막 열을 확인해보자. 배열의 요소가 1000개 혹은 그 이하일 때, 배열의 크기는 2배로 늘어난다. 요소의 개수가 1000개를 넘고 나면, 용량의 변화율은 25%로 변한다. 슬라이스의 용량이 변경될 때, 그 변화를 나타낸다.
if lastCap != cap(data) {
변화율을 계산한다.
float64(cap(data)-lastCap) / float64(lastCap) * 100 capChg :=
lastCap
에 새 용량을 저장한다.
cap(data) lastCap =
결과를 표시한다.
"Addr[%p]\tIndex[%d]\t\tCap[%d - %2.f%%]\n", &data[0], record, cap(data), capChg) fmt.Printf(
=> 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이다.
매개변수는 [시작 인덱스:(시작 인덱스 + 길이)]
형태이다.
결과를 통해 두 슬라이스는 같은 배열을 공유하고 있는 것을 알 수 있다. 슬라이스의 헤더는 값의 개념으로 사용 될 때 스택에 존재한다. 오직 공유되는 배열만이 힙에 위치한다.
2:4] slice3 := slice2[
"\n=> Slice of slice (before)\n")
fmt.Printf(
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
0] = "CHANGED" slice3[
두 슬라이스 모두 변한다. 생성되어 있는 슬라이스를 변경한다는 것을 잊지 말아야 한다. 어디서 이 슬라이스를 사용하는지, 또 배열을 공유하고 있는지를 주의깊게 살펴야 한다.
"\n=> Slice of slice (after)\n")
fmt.Printf(
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
를 사용해서 값을 복사한다.
make([]string, len(slice2))
slice4 := copy(slice4, slice2)
"\n=> Copy a slice\n")
fmt.Printf( 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인 정수형 슬라이스를 선언하자.
make([]int, 7) x :=
임의의 값을 넣어준다.
for i := 0; i < 7; i++ {
100
x[i] = i * }
슬라이스의 두 번째 원소의 포인터를 변수에 할당한다.
1] twohundred := &x[
슬라이스에 새로운 값을 추가해보자. 이 코드는 위험 하다. x 슬라이스는 길이가 7이고 용량 7이다. 길이와 용량이 같기 때문에 용량이 두 배로 늘어나고 값들이 복사된다. 이제 x 슬라이스는 길이가 8이고 용량이 14이며 다른 메모리 블록을 가르킨다.
append(x, 800) x =
슬라이스의 두 번째 원소의 값을 변경 할 때, twohundred
는 변경되지 않는다. 이전의 슬라이스를 가리키기 때문이다. 이 변수를 읽을 때 마다, 잘못된 값을 얻는다.
1]++ x[
결과를 출력함으로써, 문제를 확인 할 수 있다.
"\n=> Slice and reference\n")
fmt.Printf("twohundred:", *twohundred, "x[1]:", x[1]) fmt.Println(
=> Slice and reference
twohundred: 100 x[1]: 101
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개 있고 나머지는 영어이기 때문에)
"世界 means world" s :=
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])
출력해보자.
"%2d: %q; codepoint: %#6x; encoded bytes: %#v\n", i, r, r, buf[:rl]) fmt.Printf(
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}
프로그램에서 사용할 user
를 정의한다.
type user struct {
string
name string
username }
string
타입을 키로, user
타입을 값으로 갖는 맵을 선언하고 만든다.
func main() {
make(map[string]user)
users1 :=
// 맵에 키/값 쌍을 추가한다.
"Roy"] = user{"Rob", "Roy"}
users1["Ford"] = user{"Henry", "Ford"}
users1["Mouse"] = user{"Mickey", "Mouse"}
users1["Jackson"] = user{"Michael", "Jackson"}
users1[
// `map`을 순회한다.
"\n=> Iterate over map\n")
fmt.Printf(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[string]user{
users2 := "Roy": {"Rob", "Roy"},
"Ford": {"Henry", "Ford"},
"Mouse": {"Mickey", "Mouse"},
"Jackson": {"Michael", "Jackson"},
}
// 맵을 순회한다.
"\n=> Map literals\n")
fmt.Printf(for key, value := range users2 {
fmt.Println(key, value) }
=> Map literals
Roy {Rob Roy}
Ford {Henry Ford}
Mouse {Mickey Mouse}
Jackson {Michael Jackson}
delete(users2, "Roy")
키 Roy
를 찾아보자. 만약 키 중에 Roy
가 존재한다면, 그에 해당하는 값을 가져와 할당한다. 그렇지 않다면 u
는 여전히 user
타입의 값을 가지겠지만, 그 값은 제로 값으로 설정된다.
"Roy"]
u1, found1 := users2["Ford"] u2, found2 := users2[
값과 키의 존재 여부를 나타낸다.
"\n=> Find key\n")
fmt.Printf("Roy", found1, u1)
fmt.Println("Ford", found2, u2) fmt.Println(
=> Find key
Roy false { }
Ford true {Henry Ford}
type users []user
이 구문을 사용하여 users
를 새로 정의할 수 있으며, 이는 users
를 정의하는 두 번째 방법이다. 이처럼 이미 존재하는 타입을 통해, 다른 타입의 타입으로 사용할 수 있다. 이 때 두 타입은 서로 연관성이 없다. 하지만 다음의 코드 u := make(map[users]int)
와 같이 키로서 사용코자 할 때, 컴파일러는 다음의 오류를 발생시킨다. “맵의 키로써 users
타입은 유효하지 않다.”
그 이유는, 키로 어떤 것을 사용하던지 그 값은 반드시 비교가능해야 하기 때문이다. 맵이 키의 해시 값을 만들 수 있는 지 보여주는 일종의 불리언 표현식을 사용해야한다.
type user struct {
string
name string
email }
notify
는 값 리시버를 가지는 메서드(method)이다. u
는 user
타입으로, Go에서는 함수가 리시버와 함께 선언된다면 이를 메서드라고 한다. 리시버는 파라미터와 비슷하게 보이지만, 이는 자신만의 역할이 있다. 값 리시버를 사용하면, 메서드는 자신을 호출한 변수를 복사하고, 그 복사본을 가지고 동작한다.
func (u user) notify() {
"Sending User Email To %s<%s>\n", u.name, u.email)
fmt.Printf( }
changeEmail
은 포인터 리시버를 가지는 메서드이다: u
는 user
의 포인터 타입으로, 포인터 리시버를 이용하면 메서드를 호출한 변수를 공유하면서 바로 접근이 가능하다.
func (u *user) changeEmail(email string) {
u.email = email"Changed User Email To %s\n", email)
fmt.Printf( }
위의 두 메서드들은 값 리시버와 포인터 리시버의 차이를 이해하기 위해서 같이 사용되었다. 하지만 실제 개발에서는 하나의 리시버를 사용하는 것을 권장한다. 이에 대해서는 나중에 다시 살펴볼 것이다.
값 리시버와 포인터 리시버를 이용한 호출
user
타입의 변수는 값 리시버와 포인터 리시버를 사용하는 모든 메서드를 호출할 수 있다.
"Bill", "bill@email.com"}
bill := user{
bill.notify()"bill@hotmail.com") bill.changeEmail(
Sending User Email To Bill<bill@email.com>
Changed User Email To bill@hotmail.com
user
의 포인터 타입 변수 역시 값 리시버와 포인터 리시버를 사용하는 모든 메서드를 호출할 수 있다.
"Hoanh", "hoanhan@email.com"}
hoanh := &user{
hoanh.notify()"hoanhan101@gmail.com") hoanh.changeEmail(
Sending User Email To Hoanh<hoanhan@email.com>
Changed User Email To hoanhan101@gmail.com
이 예제에서 hoanh
은 user
타입을 가리키는 포인터 변수이다. 하지만 값 리시버로 구현된 notify
를 호출할 수 있다. user
타입의 변수로 메서드를 호출하지만, Go는 내부적으로 이를 (*hoanh).notify()
로 호출한다. Go는 hoanh
이 가리키는 값을 찾고, 이 값을 복사하여 notify
를 값에 의한 호출(value semantic)이 가능하도록 한다. 이와 유사하게 bill
은 user
타입의 변수이지만, 포인터 리시버로 구현된 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>
포인터 리시버를 사용하는 changeEmail
을 for ... range
안에서 사용해보자. 이는 복사본의 값을 변경하는 것으로 이렇게 사용하면 안 된다.
for _, u := range users {
"it@wontmatter.com")
u.changeEmail( }
Changed User Email To it@wontmatter.com
Changed User Email To it@wontmatter.com
숫자, 문자열, 불과 같은 기본 타입을 사용하는 경우, 값에 의한 호출을 사용하는 것을 권장한다. 만약 정수 또는 불 타입 변수의 주소 값을 사용해야 한다면 주의해야 한다. 상황에 따라, 이 방법이 맞을 수도 있고, 아닐 수도 있기 때문이다. 하지만 일반적으로, 메모리 누수의 위험이 있는 힙 메모리 영역에 이 변수들을 만들 필요는 없다. 그래서 이 타입의 변수들은 stack에 생성하는 것을 더 권장한다. 모든 것에는 예외가 있을 수 있지만, 그 예외를 적용하는 것이 적합하다고 판단하기 전에는 규칙을 따를 필요가 있다.
슬라이스, 맵, 채널, 인터페이스와 같이 참조 타입의 변수들 역시 기본적으로 값에 의한 호출을 사용하는 것을 권장한다. 다만 변수의 주소 값을 파라미터로 받는 Unmarshal
같은 함수를 사용하기 위한 경우라면, 이 타입들의 주소 값을 사용해야 한다.
아래 예제들은 실제 Go의 표준 라이브러리에서 사용하는 코드들이다. 이들을 공부해보면, 값에 의한 호출과 참조에 의한 호출(pointer semantic) 중 하나를 일관되게 사용하는 것이 얼마나 중요한지 알 수 있다. 따라서 변수의 타입을 정할 때, 다음의 질문에 스스로 답해보자.
가장 중요한 것은 일관성이다. 처음에 한 결정이 잘못되었다고 판단되면, 그때 이를 변경하면 된다.
package main
import (
"sync/atomic"
"syscall"
)
값에 의한 호출
Go의 net
패키지는 IP
와 IPMask
타입을 제공하는데, 이들은 바이트 형 슬라이스이다. 아래의 예제들은 이 참조 타입들을 값에 의한 호출을 통해 사용하는 것을 보여준다.
type IP []byte
type IPMask []byte
Mask
는 IP
타입의 값 리시버를 사용하며, IP
타입의 값을 반환한다. 이 메서드는 IP
타입에 대해서 값에 의한 호출을 사용하는 것이다.
func (ip IP) Mask(mask IPMask) IP {
if len(mask) == IPv6len && len(ip) == IPv4len && allFF(mask[:12]) {
12:]
mask = mask[
}if len(mask) == IPv4len && len(ip) == IPv6len &&
12], v4InV6Prefix) {
bytesEqual(ip[:12:]
ip = ip[
}len(ip)
n := if n != len(mask) {
return nil
}make(IP, n)
out := for i := 0; i < n; i++ {
out[i] = ip[i] & mask[i]
}return out
}
ipEmptyString
은 IP
타입의 값을 파라미터로 받고, 문자열 타입의 값을 반환한다. 이 함수는 IP
타입에 대해서 값에 의한 호출을 사용하는 것이다.
func ipEmptyString(ip IP) string {
if len(ip) == 0 {
return ""
}return ip.String()
}
참조에 의한 호출
Time
타입은 값에 의한 호출과 참조에 의한 호출 중 어떤 것을 사용해야 할까? 만약 Time
타입 변수의 값을 변경해야 한다면, 이 값을 직접 변경해야 할까? 아니면 복사본을 만들어서 값을 변경하는 것이 좋을까?
type Time struct {
int64
sec int32
nsec
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 {
int64(d / 1e9)
t.sec += int32(t.nsec) + int32(d%1e9)
nsec := if nsec >= 1e9 {
t.sec++1e9
nsec -= else if nsec < 0 {
}
t.sec--1e9
nsec +=
}
t.nsec = nsecreturn t
}
div
는 Time
타입의 파라미터를 받고, 기본 타입의 값들을 반환한다. 이 함수는 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)
}
Chdir
은 File
타입의 포인터 리시버를 사용한다. 즉, 이 메서드는 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
}
epipecheck
는 File
타입의 포인터를 파라미터로 받는다. 따라서 이 함수는 File
타입에 대해 참조에 의한 호출을 사용하는 것이다.
func epipecheck(file *File, e error) {
if e == syscall.EPIPE {
if atomic.AddInt32(&file.nepipe, 1) >= 10 {
sigpipe()
}else {
} 0)
atomic.StoreInt32(&file.nepipe,
} }
메서드는 특수한 기능이 아니라 문법적인 필요에 의해 만들어진 것이다. 메서드는 데이터와 관련된 일부 기능을 외부에서 사용할 수 있는 것처럼 믿게 만든다. 객체지향 프로그래밍에서도 이러한 설계와 기능을 권장한다. 하지만 Go가 객체지향 프로그래밍를 추구하는 것은 아니다. 다만 데이터와 동작이 필요하기 때문에 이들이 있는 것이다.[재방문]
어떤 때는 데이터가 일부 기능을 노출할 수 있지만 특정 목적을 위해 API를 설계하는 것은 아니다. 메서드는 함수와 거의 동일하다.[재방문]
type data struct {
name stringint
age }
displayName
은 data
타입 d
변수의 name
을 포함한 문자열을 출력한다. 이 메서드는 data
를 값 리시버로 사용한다.
func (d data) displayName() {
"My Name Is", d.name)
fmt.Println( }
setAge
는 data
타입 d
의 age
를 수정하고, 이 값을 name
과 함께 출력한다. 이 메서드는 data
를 포인터 리시버로 사용한다.
func (d *data) setAge(age int) {
d.age = age"Is Age", d.age)
fmt.Println(d.name, }
메서드는 단지 함수일 뿐이다
data
타입의 변수를 선언해보자.
d := data{"Hoanh",
name:
}"Proper Calls to Methods:") fmt.Println(
다음과 같이 메서드를 호출할 수 있다.
d.displayName()21)
d.setAge(
"\nWhat the Compiler is Doing:") fmt.Println(
다음 예제를 통해 Go가 내부적으로 어떻게 동작하는지 알 수 있다. d.displayName()
을 호출하면, 컴파일러는 data.displayName
을 먼저 호출해서 data
타입의 값 리시버를 사용하는 것을 보여주고, d
를 첫번째 파라미터로 전달한다. func (d data) displayName()
을 다시 자세히 보면, 리시버가 정말 파라미터임을 알 수 있다. 즉, 이 리시버는 displayName
의 첫번째 파라미터이다.
d.setAge(21)
역시 이와 비슷하다. Go는 포인터 리시버를 사용하는 함수를 호출하고, d
를 함수의 파라미터로 넘긴다. 추가로 d
의 주소 값을 이용하기 위하여 약간의 조정이 필요하다.
data.displayName(d)21) (*data).setAge(&d,
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
함수형 변수
"\nCall Value Receiver Methods with Variable:") fmt.Println(
함수형 변수를 선언하고, 이 변수에 d
변수의 메서드를 설정해보자. 이 메서드, displayName
,은 값 리시버를 사용하기 때문에, 함수형 변수 f1
은 d
의 독립적인 복사본을 가진다. 함수형 변수 f1
은 참조 타입으로 포인터, 즉 주소 값을 저장한다. displayName
의 뒤에 ()
을 붙이지 않았으므로, 이 메서드의 반환 값을 저장한 것이 아니다.
f1 := d.displayName
이 변수를 이용해서 메서드를 호출해보자.
f1
은 포인터로, 이는 2개의 워드를 가지는 특별한 자료 구조를 가리킨다. 첫 번째 워드는 실행 대상인 메서드를 가리키는데, 이 예제에서는 displayName
이다. 이 displayName
는 값 리시버를 사용하므로 실행하기 위해서는 data
타입의 값이 필요하다. 따라서 두 번째 워드는 이 값의 복사본을 가리킨다. displayName
을 f1
에 저장을 하면, 자동으로 d
의 복사본이 만들어진다.
만약 d
의 멤버 변수인 name의 값을 “Hoanh An”으로 변경하더라도, f1
에는 이 변경이 적용되지 않는다.
"Hoanh An" d.name =
f1
을 통해서 메서드를 호출해보자. 앞서 이야기했듯이, 결과는 변하지 않았다.
f1()
Call Value Receiver Methods with Variable:
My Name Is Hoanh
My Name Is Hoanh
하지만 setAge
메서드를 저장한 f2
는 d
의 값이 변하면 그 자신의 결과도 변한다.
"\nCall Pointer Receiver Method with Variable:") fmt.Println(
함수형 변수를 선언하고, 이 변수에 d
변수의 메서드를 설정해보자. 이 메서드, setAge
는 포인터 리시버를 사용하므로, 이 함수형 변수는 d
의 주소 값을 가지게 된다.
f2 := d.setAge"Hoanh An Dinh" d.name =
이 함수형 변수를 이용해서 메서드를 호출해보자. f2
는 역시 포인터이고, 2개의 워드를 가지는 자료 구조를 가리킨다. 첫 번째 워드는 setAge
메서드를 가리키지만, 두 번째 워드는 더 이상 복사본이 아니라 원본 d
를 가리킨다.
21) f2(
Call Pointer Receiver Method with Variable:
Hoanh An Dinh Is Age 21
reader
는 데이터를 읽는 동작을 정의하는 인터페이스이다. 인터페이스는 엄밀히 말해서 값이 없는 타입이다. 이 인터페이스는 어떠한 멤버 변수도 가지지 않으며, 오직 행동에 대한 계약만을 정의한다. Go에서는 이 행동에 대한 계약을 통해서, 다형성을 활용할 수 있다. 인터페이스는 두 개의 워드로 이루어진 자료 구조로, 두 워드 모두 포인터이다. 인터페이스는 참조 타입으로, var r reader
구문은 nil
값 가지는 인터페이스 r
을 만든다.
type reader interface {
byte) (int, error) // (1)
read(b [] }
read
메서드를 다르게 정의할 수도 있다. 예를 들어 read(i int) ([]byte, error) // (2)
처럼, 파라미터로 읽을 바이트 수를 받고, 읽은 데이터를 슬라이스에 담아 에러와 함께 반환할 수도 있다.
그럼 왜 (1)
을 선택한 것일까?
(2)
는 매번 호출할 때마다, 반환할 슬라이스를 메서드 내부에서 만들어야 한다. 이때 슬라이스를 위한 배열을 힙 메모리에 할당하는 비용이 발생한다. 하지만 (1)
의 경우, 이 메서드를 호출하는 쪽에서 슬라이스를 만들 책임이 있다. 따라서 슬라이스를 위한 한 번의 메모리 할당은 피할 수 없지만, 반복적인 메서드 호출에 대해 추가적인 메모리 할당은 발생하지 않는다.
구체적 타입 vs 인터페이스 타입
구체적 타입(concrete type)이란 메서드를 가질 수 있는 모든 타입을 말한다. 오직 사용자 정의 타입만이 메서드를 가질 수 있다.
메서드는 데이터가 인터페이스를 기반으로 기능을 외부에서 사용할 수 있도록 해준다. file
은 시스템 파일을 위한 구조체이다.
type file struct {
string
name }
이는 구체적 타입으로, 이후에 설명할 read
메서드를 가지고 있다. 이는 reader
인터페이스에서 선언한 메서드와 동일하다. 따라서, 구체적 타입인 file
은 reader
인터페이스를 값 리시버를 이용해서 구현(implement) 한 것이다.
구현을 위해서 특별한 문법이 존재하는 것은 아니다. 다만 컴파일러가 자동으로 구현임을 인지한다.
관계
인터페이스 변수에는 구체적 타입의 값을 저장할 수 있다.
read
는 file
타입에 대하여 reader
인터페이스를 구현한다.
func (file) read(b []byte) (int, error) {
"<rss><channel><title>Going Go Programming</title></channel></rss>"
s := copy(b, s)
return len(s), nil
}
pipe
는 이름 있는 파이프 네트워크 연결을 위한 구조체이다. 이는 두 번째 구체적 타입으로, 값 리시버를 사용한다. 두 구체적 타입 모두 reader
인터페이스의 행동에 대한 계약을 구현하고 이들을 외부에 제공한다.
type pipe struct {
string
name }
read
는 네트워크 연결을 위한 reader
인터페이스를 구현한다.
func (pipe) read(b []byte) (int, error) {
`{name: "hoanh", title: "developer"}`
s := copy(b, s)
return len(s), nil
}
file
과 pipe
두 타입의 변수를 다음과 같이 만들어보자.
"data.json"}
f := file{"cfg_service"} p := pipe{
이 변수들을 파라미터로 retrieve
함수를 호출해보자. 이 함수는 값을 파라미터로 받으므로, f
의 복사본이 함수에서 사용된다.
컴파일러는 다음의 질문을 하게 된다: 이 변수의 타입 file
은 reader
인터페이스를 구현한 것인가? file
타입은 reader
인터페이스의 행동에 대한 계약을 값 리시버를 이용하여 구현하였으므로, 이 질문에 대한 답은 “Yes”이다. 이 예제에서, 인터페이스의 두 번째 워드는 f
의 복사본을 가리킨다. 그리고 첫 번째 워드는 iTable
이라는 특별한 자료 구조를 가리킨다.
iTable
은 다음의 두 가지를 제공한다:
file
타입을 말한다.인터페이스를 통해서 read
를 호출하면, iTable
을 확인해서 이 타입에 맞는 read
함수를 찾고 이를 호출한다. 결과적으로 구체적 타입의 read
메서드를 호출하는 것이다.
retrieve(f)
p
도 이와 동일하다. reader
인터페이스의 첫 번째 워드는 pipe
타입을 가리키고, 두번째 워드는 p
의 복사본을 가리킨다.
데이터가 변경되었기 때문에, 동작도 다르다.
retrieve(p)
앞으로 iTable
와 이를 가리키는 포인터를 간략하게 *pipe
처럼 나타낼 것이다.
다형성 함수
retrieve
는 어떤 장치든 다 읽을 수 있고, 어떤 데이터든지 다 처리할 수 있다. 이러한 함수를 다형성 함수라 한다. 이 예제에서 사용된 파라미터는 reader
타입이다. 하지만 이는 인터페이스이므로 값이 없는 타입니다. 즉, 이 함수는 reader
인터페이스의 계약을 구현한 모든 타입을 다 파라미터로 받을 수 있다. 이 함수는 구체적 타입에 대해서는 전혀 알지 못하므로, 이는 완전히 디커플링 되어 있다. 이는 Go에서 할 수 있는 최상위의 디커플링이다. 이러한 구현 방법은 간결하고 효율적이다. 이 방법을 사용하기 위해 필요한 것은 오직 인터페이스를 통한 구체적 타입 데이터로의 간접적 접근뿐이다.[재방문]
func retrieve(r reader) error {
make([]byte, 100)
data :=
len, err := r.read(data)
if err != nil {
return err
}
string(data[:len]))
fmt.Println(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 {
string
name string
email }
print
는 user
타입의 name과 email 정보를 출력한다.
func (u user) print() {
"My name is %s and my email is %s\n", u.name, u.email)
fmt.Printf( }
notify
는 포인터 리시버를 사용해서 notifier
인터페이스를 구현한다.
func (u *user) notify() {
"Sending User Email To %s<%s>\n", u.name, u.email)
fmt.Printf( }
String
은 fmt.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
타입의 데이터를 만들어보자.
"Hoanh", "hoanhan@email.com"} u := user{
참조에 의한 호출로 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
포인터 리시버를 사용해서 duration
에 notify
메서드를 정의하자. 이제 이 타입은 포인터 리시버를 통해 notifier
인터페이스를 구현한 것이다.
func (d *duration) notify() {
"Sending Notification in", *d)
fmt.Println( }
42
를 받아서, 이를 duration
타입으로 변경 후 notify
메서드를 호출해보자. 이때, 컴파일러는 다음과 같은 에러 메시지를 보여준다.
42).notify() duration(
주소 값을 얻을 수 없는 이유는 42
가 변수에 저장된 값이 아니기 때문이다. 이는 여전히 리터럴 값으로 타입을 알 수 없다. 하지만 duration
은 여전히 notifier
인터페이스를 구현한다.
다시 본 예제로 돌아와서, 이러한 에러가 발생했다는 것은 값에 의한 호출과 참조에 의한 호출이 혼용되었음을 알 수 있다. u
는 포인터 리시버를 통해서 notifier
인터페이스를 구현하였지만, u
의 복사본을 통해 해당 메서드를 호출하려고 한 것이다. 이는 일관적이지 못하다.
교훈
포인터 리시버를 이용해서 인터페이스를 구현한다면, 반드시 참조에 의한 호출을 사용해야 한다. 만약 값 리시버를 사용해서 인터페이스를 구현한다면, 값에 의한 호출과 참조에 의한 호출 모두를 사용할 수 있다. 하지만, 일관성을 유지하기 위해서 이 경우에도 값에 의한 호출을 사용하는 것을 권장한다. 다만 Unmarshal과 같은 함수를 위해서는 참조에 의한 호출을 사용해야만 할 수도 있다.
이 문제를 해결하기 위해서는, u
대신 u
의 주소 값(&u)를 전달해야 한다. user
의 값을 만들고 이 값의 주소 값을 전달하면, 인터페이스는 user
타입의 주소 값을 가지게 되고 원본 값을 가리킬 수 있게 된다.
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
인터페이스를 구현하는 모든 타입의 값이나 포인터를 저장할 수 있다.
entities := []printer{
이 슬라이스에 값을 저장하면, 인터페이스의 값은 복사본을 가지게 된다. 따라서 원본 데이터에 변경이 발생하더라도, 이를 확인할 수는 없다.
u,
슬라이스에 포인터를 저장하면, 인터페이스의 값은 원본 데이터를 가리키는 주소 값을 복사하고 이를 가지게 된다. 따라서 원본 데이터의 변경을 확인할 수 있다.
&u, }
u
의 name
과 email
을 변경해보자.
"Hoanh An"
u.name = "hoanhan101@gmail.com" u.email =
슬라이스를 순회하면서 복사된 인터페이스의 값을 통해 print
를 호출해보자.
for _, e := range entities {
print()
e. }
My name is Hoanh and my email is hoanhan@email.com
My name is Hoanh An and my email is hoanhan101@gmail.com
임베딩이 아닌 필드로써 선언
프로그램에서 사용할 user
를 정의한다.
type user struct {
string
name string
email }
이벤트가 발생했음을 사용자들에게 알려주는 기능을 하는 notify
메서드를 구현한다.
func (u *user) notify() {
"Sending user email To %s<%s>\n", u.name, u.email)
fmt.Printf( }
admin
은 특정 권한을 가진 관리자를 의미하는데, person user
는 임베딩이 아니다. 단지 person
이라는 필드를 user
라는 타입으로 정의하고 생성한 것이다.
type admin struct {
// 임베딩이 아니다.
person user string
level }
구조체 리터럴로 admin
사용자를 생성한다. admin
을 구성하는 person
필드 또한 구조체 타입이기 때문에 초기화를 위해 또 다른 리터럴을 사용하였다.
func main() {
ad := admin{
person: user{"Hoanh An",
name: "hoanhan101@gmail.com",
email:
},"superuser",
level: }
admin
타입의 값은 person
필드를 이용해 notify
를 호출할 수 있다.
ad.person.notify()
Sending user email To Hoanh An<hoanhan101@gmail.com>
임베딩 타입
프로그램에서 사용할 user
를 정의한다.
type user struct {
string
name string
email }
이벤트가 발생했음을 사용자들에게 알려주는 기능을 하는 notify
메서드를 구현한다.
func (u *user) notify() {
"Sending user email To %s<%s>\n", u.name, u.email)
fmt.Printf( }
admin
은 특정 권한을 가진 관리자이다. 이번에는 person
필드를 사용하지 않고, admin
타입 내부에 user
타입의 값을 임베딩 해본다. admin
은 외부 타입(outer type)이 되고, user
는 내부 타입(inner type)이 되는 inner-type-outer-type 관계이다.
내부 타입 승격(Inner type promotion)
Go 의 임베딩은 내부 타입 승격 이라는 특별한 메커니즘을 가지고 있다. 이 메커니즘은 내부 타입과 관련된 모든 것들이 외부 타입에서도 사용할 수 있도록 승격된다는 것을 의미한다. 즉, 아래와 같이 구성하면 내부 타입인 user
와 관련해서 더 많은 의미를 내포할 수 있게 된다.
type admin struct {
// 임베딩 타입
user string
level }
외부 타입인 admin
과 내부 타입인 user
를 생성해보자. 내부 타입 값인 user
가 필드처럼 보이지만 필드가 아니다. 필드처럼 타입명을 통해 내부 값에 접근할 수는 있다. user
의 구조체 또한 리터럴을 통해 내부 값을 초기화 할 수 있다.
func main() {
ad := admin{
user: user{"Hoanh An",
name: "hoanhan101@gmail.com",
email:
},"superuser",
level:
}
// 내부 타입 메서드를 직접 사용 가능하다.
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 {
string
name string
email }
이벤트가 발생했음을 사용자들에게 알려주는 기능을 하는 notify
메서드를 포인터 리시버를 통해 구현한다.
func (u *user) notify() {
"Sending user email To %s<%s>\n", u.name, u.email)
fmt.Printf( }
admin
은 특정 권한을 가진 관리자를 의미한다.
type admin struct {
userstring
level
}
func main() {
// admin user 를 만든다.
ad := admin{
user: user{"Hoanh An",
name: "hoanhan101@gmail.com",
email:
},"superuser",
level: }
관리자에게 알림을 보내보자.
내부 타입 승격에 의해 외부 타입에서도 내부 타입에서 사용하는 것과 같은 계약(contract)이 구현되어 있다면 이를 사용할 수 있기 때문에 단순히 외부 타입값의 주소만을 함수에 전달해주면 된다.
sendNotification(&ad)
임베딩은 서브 타입 관계를 생성하지는 않는다. user
는 여전히 user
일 뿐이고 admin
은 여전히 admin
이다. 외부 타입이 사용할 수 있도록 내부 타입에서 사용하는 행동을 노출시켜 줄 뿐이다. 외부 타입에서도 내부 타입과 같은 인터페이스 혹은 같은 계약을 구현할 수 있다는 것이다.
이를 구현하기 위해 타입을 재사용할 수 있으며 이는 상태(state)를 혼합하거나 공유하지 않고 행동을 외부 타입까지 확장할 수 있게 된다.
아래처럼 sendNotification
은 notifier
의 구현체를 받아서 알람을 보내는 다형성 함수이다.
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 {
string
name string
email }
이벤트가 발생했음을 사용자들에게 알려주는 기능을 하는 notify
메서드를 구현한다.
func (u *user) notify() {
"Sending user email To %s<%s>\n", u.name, u.email)
fmt.Printf( }
admin
은 특정 권한을 가진 관리자를 의미한다.
type admin struct {
userstring
level }
아래의 notify
메서드는 유저가 아닌 관리자에게 특정 이벤트를 알려준다. 이제 두 가지의 notifier
인터페이스를 구현하였다. 하나는 내부 타입, 다른 하나는 외부 타입으로 구현하였다. 외부 타입에서 인터페이스를 구현하면 내부 타입 승격 메커니즘이 발생하지는 않는다. 내부 타입에 의해서 승격된 것들을 외부 타입이 덮어쓰는 것이다.
func (a *admin) notify() {
"Sending admin email To %s<%s>\n", a.name, a.email)
fmt.Printf( }
admin
사용자를 만들어보자.
func main() {
ad := admin{
user: user{"Hoanh An",
name: "hoanhan101@gmail.com",
email:
},"superuser",
level: }
알람을 관리자에게 전송해보자. 내부 타입에서 구현한 인터페이스 구현체는 외부타입으로 승격되지 않는다.
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>
가이드라인
패키지는 자체적으로 사용 가능한 코드의 단위다. 패키지에 속해있는 모든 것들은 다른 패키지들에서 접근할 수 있도록 내보낸(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 으로 초기화 할 수 있다.
10) counter := counters.AlertCounter(
아래처럼 내보내지 않은 형태의 변수를 생성하고 10으로 초기화 할 수 없다.
10) counter := counters.alertCounter(
만약 내보내지 않은 형태의 변수를 사용한다면 아래와 같은 컴파일 에러가 발생하게 된다.
내보내지 않은 식별자 값에 접근
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() {
10)
counter := counters.New("Counter: %d\n", counter)
fmt.Printf( }
Counter: 10
내보낸 형태 구조체 내의 내보내지 않은 필드
users
패키지는 사용자 관리에 관한 기능을 제공한다.
package users
내보낸 형태의 User
는 사용자에 대한 정보를 의미한다. User
는 Name
과 ID
의 2개의 내보낸 필드와 password
의 내보내지 않은 1개의 필드를 정의하고 있다.
type User struct {
string
Name int
ID
string
password }
package main
import (
"fmt"
"github.com/hoanhan101/users"
)
구조체 리터럴로 users
패키지에 있는 User
타입을 생성한다. 여기서 password
는 내보낸 형태가 아니기 때문에 컴파일 에러가 발생한다.
func main() {
u := users.User{"Hoanh",
Name:
ID: 101,"xxxx",
password:
}"User: %#v\n", u)
fmt.Printf( }
unknown field 'password' in struct literal of type users.User
내보내지 않은 형태를 임베딩하고 있는 내보낸 형식
users
패키지는 사용자 관리에 관한 기능을 제공한다.
package users
내보내지 않은 형태의 user
는 사용자에 대한 정보를 의미하며, 2개의 내보낸 필드를 정의한다.
type user struct {
string
Name int
ID }
내보낸 형태의 Manager
는 관리자에 대한 정보를 의미하며, 내보내지 않은 형태로 임베딩된 필드 user
를 정의한다.
type Manager struct {
string
Title
user }
package main
import (
"fmt"
"github.com/hoanhan101/users"
)
user
패키지에 있는 Manager
타입의 값을 생성한다. Manager
타입 값을 생성할 때는 오직 내보낸 필드인 Title
만을 초기화 할 수 있고, 내보내지 않은 형태로 임베딩된 user
타입에는 바로 접근할 수 없다.
func main() {
u := users.Manager{"Dev Manager",
Title: }
그러나 manager
값을 초기화하고 난 이후에는 내보내지 않은 형태를 내보낸 필드로써 접근 할 수 있다.
However, once we have the manager value, the exported fields from that unexported type are accessible.
"Hoanh"
u.Name = 101
u.ID = "User: %#v\n", u)
fmt.Printf( }
User: users.Manager{Title:"Dev Manager", user:users.user{Name:"Hoanh", ID:101}}
다시 한번 이야기하지만 이 방식을 사용하는 것보다는 user
를 내보내는 것이 더 좋은 방식이므로 이를 사용하는 것이 낫다.
상태에 의한 그룹핑
이번 파트는 OOP 패턴의 유형계층에 대한 예시이다. 이는 Go 에서 자주 사용되는 방식은 아니다. Go 는 서브타이핑이라는 개념이 없기 때문이다. Go 에서 모든 타입은 고유하며 기본 타입, 파생된 타입이라는 개념은 존재하지 않는다. 즉, 이 패턴은 Go 프로그래밍에서는 좋은 설계 원칙이 아니다.
Animal
은 동물에 대한 기본 속성을 정의하고 있다.
type Animal struct {
string
Name bool
IsMammal }
Speak
는 동물들이 말하는 방식에 관한 일반적인 행동을 정의하고 있다. 스스로 말할 수 없는 동물 때문에 이는 무의미한 메서드일 수 있다. Speak
는 실제로 모든 동물이 가진 특성은 아니다.
func (a *Animal) Speak() {
"UGH!",
fmt.Println("My name is", a.Name,
", it is", a.IsMammal,
"I am a mammal")
}
Dog
는 Animal
과 관련된 모든것과 PackFactor
라는 Dog
만이 가진 속성을 정의하고 있다.
type Dog struct {
Animalint
PackFactor }
Speak
는 개가 말하는 방식을 의미한다.
func (d *Dog) Speak() {
"Woof!",
fmt.Println("My name is", d.Name,
", it is", d.IsMammal,
"I am a mammal with a pack factor of", d.PackFactor)
}
Cat
는 Animal
과 관련된 모든것과 ClimbFactor
라는 Cat
만이 가진 속성을 정의하고 있다.
type Cat struct {
Animalint
ClimbFactor }
Speak
는 고양이가 말하는 방식을 의미한다.
func (c *Cat) Speak() {
"Meow!",
fmt.Println("My name is", c.Name,
", it is", c.IsMammal,
"I am a mammal with a climb factor of", c.ClimbFactor)
}
여기까지는 괜찮을 지도 모른다. 하지만 이 코드는 컴파일되지 않는다. Animals
란 요소를 바탕으로 Cat
과 Dog
를 그루핑했기 때문이다. 즉, Go 는 서브타이핑 개념이 없는데도 불구하고 서브타이핑을 사용했다. Go 는 공통된 DNA(’구조체 내 공통된 필드’에 대한 비유)를 바탕으로 그룹핑하는 것을 권장하지 않는다. 누구인지에만 초점을 맞춘다면 그룹화하는데 큰 제한이 있기 때문에 공통된 DNA를 바탕으로 API 를 설계한다는 생각을 그만해야 한다. 서브타이핑은 다양성에 한계가 있다. 그룹화 가능하도록 서브셋을 작게 구성하면 그 형식과 관련된 것밖에 설계할 수 없지만, 행동에 초점을 맞추면 전체적인 형태로 넓혀서 설계할 수 있다.
Animal
부분을 초기화한 후 Dog
속성을 정의해서 Dog
를 생성한다.
animals := []Animal{
Dog{
Animal: Animal{"Fido",
Name: true,
IsMammal:
},5,
PackFactor: },
Animal
부분을 초기화한 후 Cat
속성을 정의해서 Cat
를 생성한다.
Cat{
Animal: Animal{"Milo",
Name: true,
IsMammal:
},4,
ClimbFactor:
}, }
Animal
들이 말하도록 한다.
for _, animal := range animals {
animal.Speak()
} }
위 방식이 냄새나는 코드인 이유:
Animal
타입은 재사용 가능한 추상화된 계층을 제공한다.Animal
타입으로 값을 만들거나 단독으로 사용할 필요가 전혀 없다.Animal
타입의 Speak
메서드 구현은 일반화이다.Animal
타입에서 정의한 Speak
메서드는 절대 호출되어지지 않는다.행동에 의한 그루핑
이번 파트는 구성과 인터페이스를 활용한 예시이며 이는 Go 에서 사용되면 좋은 방식이다.
공통된 상태가 아닌 공통된 행동으로 그룹화하는 이 패턴은 Go 프로그램에서 좋은 설계 원칙이다. Go 의 뛰어난 특성중 하나는 미리 구성할 필요가 없다는 점이다. 컴파일러는 컴파일 타임에 인터페이스와 행동을 자동으로 식별한다. 이는 현재 또는 미래에 작성한 인터페이스와 호환될 수 있는 코드를 지금 작성할 수 있다는 의미이다. 또한 컴파일러가 즉석에서 행동을 식별하기 때문에 선언된 위치도 중요하지 않다. 대신 어떤 행동을 해야 하는지를 고려해야 한다.
Speaker
는 그룹이 일원이 되기 위해 따라야 할 공통된 행동을 정의하고 있다. Speaker
는 구체적인 타입에 대한 계약이다. Animal
타입은 제거한다.
type Speaker interface {
Speak() }
Dog
는 Dog
가 필요한 모든 것을 정의하고 있다.
type Dog struct {
string
Name bool
IsMammal int
PackFactor }
Speak
는 개가 말하는 방식을 의미한다. 말하는 방식이 정의된 Dog
는 구체적인 타입인 Speaker
그룹의 일원이 된다.
func (d Dog) Speak() {
"Woof!",
fmt.Println("My name is", d.Name,
", it is", d.IsMammal,
"I am a mammal with a pack factor of", d.PackFactor)
}
Cat
는 Cat
이 필요한 모든 것을 정의하고 있다. 복사 붙여넣기를 하면 약간의 시간이 걸릴지도 모르지만, 대부분의 경우 디커플링은 코드 재사용보다 더 나은 방식이다.
type Cat struct {
string
Name bool
IsMammal int
ClimbFactor }
Speak
는 고양이가 말하는 방식을 의미한다. 말하는 방식이 정의된 Cat
는 구체적인 타입인 Speaker
그룹의 일원이 된다.
func (c Cat) Speak() {
"Meow!",
fmt.Println("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{"Fido",
Name: true,
IsMammal: 5,
PackFactor: },
Cat
의 속성을 초기화해서 Cat
을 생성한다.
Cat{"Milo",
Name: true,
IsMammal: 4,
ClimbFactor:
}, }
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
타입 선언에 대한 지침:
프로토타이핑은 개념 증명(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 {
string
Line }
Xenia
는 우리가 데이터를 추출 해야하는 시스템이다.
type Xenia struct {
string
Host
Timeout time.Duration }
Pull
은 Xenia
의 데이터를 추출하는 메서드이다. 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:
"Data"
d.Line = "In:", d.Line)
fmt.Println(return nil
} }
Pillar
는 데이터를 저장할 시스템이다.
type Pillar struct {
string
Host
Timeout time.Duration }
Store
메서드로 d *Data
를 Piller
에 저장할 수 있다. 일관되도록 포인터 *Pillar
를 사용하였다.
func (*Pillar) Store(d *Data) error {
"Out:", d.Line)
fmt.Println(return nil
}
System
은 Xenia
와 Piller
를 하나의 시스템으로 결합한다. 우리는 Xenia
와 Piller
를 기반으로 한 API를 가지고 있다. 여기에 또 다른 API를 구축해 그것을 기반으로 활용하려고 한다. 한 가지 방법은 추출 하거나 저장할 수 있는 행동을 하는 타입을 갖는 것이다. 우리는 구성(composition)을 통해 그것을 할 수 있다. System
은 Xenia
와 Piller
의 내장된 요소를 기반으로 한다. 그리고 내부 타입 승격(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) {
데이터 슬라이스를 순회하면서 각각의 원소를 Xenia
의 Pull
메서드에 전달하자.
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
의 데이터를 추출할 수 있다. 이제 pull
과 store
함수를 호출하여 Xenia
에서 Piller
로 데이터를 전달할 수 있다.
func Copy(sys *System, batch int) error {
make([]Data, batch)
data :=
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 {"localhost:8000",
Host:
Timeout: time.Second,
},
Pillar: Pillar {"localhost:9000",
Host:
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
인터페이스를 구현했고, Pillar
는 Storer
인터페이스를 구현 했다. 이제 pull
/store
에 들어가서 구체화로 부터 이 함수를 디커플링 해보자
Xenial
와 Pillar
를 넘나드는 것 대신에, 우리는 Puller
와 Storer
사이를 넘나들 수 있다. 알고리즘은 변하지 않는다. 우리가 하고 있는 모든 것들은 인터페이스 값을 통해서 간접적으로 pull
/store
를 호출 하는 것이다.
package main
import (
"errors"
"fmt"
"io"
"math/rand"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano()) }
Data
는 복사 할 데이터의 구조체이다.
type Data struct {
string
Line }
Puller
는 데이터 추출 동작을 선언한다.
type Puller interface {
error
Pull(d *Data) }
Storer
는 데이터 저장 동작을 선언한다.
type Storer interface {
error
Store(d *Data) }
Xenia
는 우리가 데이터를 빼내야 하는 시스템이다.
type Xenia struct {
string
Host
Timeout time.Duration }
Pull
은 Xenia
에서 데이터를 가져오는 방법을 알고 있다.
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:
"Data"
d.Line = "In:", d.Line)
fmt.Println(return nil
} }
Pillar
는 우리가 데이터를 저장 해야 하는 시스템이다.
type Pillar struct {
string
Host
Timeout time.Duration }
Store
은 Pillar
에 데이터를 저장하는 방법을 알고 있다.
func (*Pillar) Store(d *Data) error {
"Out:", d.Line)
fmt.Println(return nil
}
System
은 Xenia
와 Piller
를 하나의 시스템으로 결합한다.
type System struct {
Xenia
Pillar }
pull
은 Puller
로 부터 많은 양의 데이터를 추출하는 방법을 알고 있다.
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
}
store
은 Storer
로 부터 많은 양의 데이터를 저장하는 방법을 알고 있다.
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
}
Copy
는 System
에서 데이터를 추출하고 저장하는 방법을 알고 있다.
func Copy(sys *System, batch int) error {
make([]Data, batch)
data :=
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{"localhost:8000",
Host:
Timeout: time.Second,
},
Pillar: Pillar{"localhost:9000",
Host:
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
에는 Puller
와 Storer
의 두 가지 동작이 있다. 추출과 저장을 모두 구현하는 구체적인 타입은 PullStorer
이다. System
이 PullStorer
인 이유는 Xenia
와 Piller
이 두 가지 타입이 내장되어 있기 때문이다. 이제 다른 코드는 변경할 필요없이 Copy
에 들어가서 PullStorer
로 시스템 포인터를 교체하면 된다.
Copy
를 자세히 보면, 잠재적으로 우리를 혼란스럽게 할 수 있는 것이 있다. 우리는 PullStorer
인터페이스 값을 직접적으로 Pull
과 Store
에 전달하고 있다.
추출과 저장을 살펴보면, 그것들은 PullStorer
가 필요하지 않다. 한 쪽은 Puller
, 다른 한 쪽은 Storer
만 있으면 된다. 왜 컴파일러는 이전에는 허용하지 않았던 다른 타입의 값 전달을 허용하는 것일까?
Go에는 암시적 인터페이스 변환이라는 기능이 있기 때문이다. 이것은 아래의 이유 때문에 가능하다:
모든 인터페이스 값이 동일한 모델(구현 세부 정보).
만약 타입의 내용이 명확하다면, 하나의 인터페이스 안에 존재하는 구체적인 타입은 다른 인터페이스에 대해 충분한 동작을 가진다.
PullStorer
내부에 저장된 모든 구체적인 타입은 Storer
및 Puller
도 구현해야 한다.
코드를 자세히 살펴보자.
주요 기능에서 우리는 System
타입의 값을 만들고 있다. 알고 있듯이, 우리의 System
타입 값은 두 가지 구체 타입인 Xenia
와 Piller
를 내장하고 있는데, 여기서 Xenia
는 추출기능, Filler
는 저장기능을 알고 있다. 내부 타입 승격(inner type promotion) 때문에 System
은 본질적으로 추출하고 저장하는 방법을 알고 있다. 우리는 System
주소를 Copy
에 전달하고 있다. 그 다음 Copy
가 PullStorer
인터페이스를 생성한다. 첫 번째는 System
의 포인터이고 두 번째는 원래 값을 가리킨다. 이 인터페이스는 이제 추출 및 저장하는 방법을 안다. 우리가 ps
의 pull
을 호출할 때, 우리는 System
의 pull
을 호출하고, 그것은 결국 Xenia
의 pull
을 호출한다.
여기 예기치 못한 것이있다: 암시적 인터페이스 변환
컴파일러가 PullStorer
내부에 저장된 모든 구체적인 타입도 Puller
를 구현해야 한다는 것을 알고 있기 때문에 pull
인터페이스 값 ps
를 전달할 수 있다. 우리는 결국 Puller
라는 또 다른 인터페이스를 갖게 된다. 모든 인터페이스에 대해 메모리 모델이 동일하기 때문에, 우리는 이 두 가지를 복사하여 모두 동일한 인터페이스 타입을 공유한다. 이제 Puller
의 pull
을 호출할 때 System
의 pull
을 호출할 것이다.
Storer
와 유사
모두 인터페이스 값에 대한 가치 의미와 공유하기 위한 포인터 의미.
package main
import (
"errors"
"fmt"
"io"
"math/rand"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano()) }
Data
는 복사 할 데이터의 구조체이다.
type Data struct {
string
Line }
Puller
는 데이터 추출 동작을 선언한다.
type Puller interface {
error
Pull(d *Data) }
Storer
는 데이터 저장 동작을 선언한다.
type Storer interface {
error
Store(d *Data) }
PullStore
는 추출 및 저장에 대한 동작을 선언한다.
type PullStorer interface {
Puller
Storer }
Xenia
는 우리가 데이터를 빼내야 하는 시스템이다.
type Xenia struct {
string
Host
Timeout time.Duration }
Pull
은 Xenia
에서 데이터를 가져오는 방법을 알고 있다.
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:
"Data"
d.Line = "In:", d.Line)
fmt.Println(return nil
} }
Pillar
는 우리가 데이터를 저장 해야 하는 시스템이다.
type Pillar struct {
string
Host
Timeout time.Duration }
Store
은 Pillar
에 데이터를 저장하는 방법을 알고 있다.
func (*Pillar) Store(d *Data) error {
"Out:", d.Line)
fmt.Println(return nil
}
System
은 Xenia
와 Piller
를 하나의 시스템으로 결합한다.
type System struct {
Xenia
Pillar }
pull
은 Puller
로 부터 많은 양의 데이터를 추출하는 방법을 알고 있다.
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
}
store
은 Storer
로 부터 많은 양의 데이터를 저장하는 방법을 알고 있다.
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
}
Copy
는 System
에서 데이터를 추출하고 저장하는 방법을 알고 있다.
func Copy(ps PullStorer, batch int) error {
make([]Data, batch)
data :=
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 {"localhost:8000",
Host:
Timeout: time.Second,
},
Pillar: Pillar {"localhost:9000",
Host:
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
을 바꾼다. 두 가지 구체타입 Xenia
와 Pillar
를 사용하는 대신 인터페이스 Puller
와 Storer
를 사용한다. 구체적인 동작을 할 수 있는 구체타입 System
은 이제 2가지 인터페이스 임베딩을 기반으로 한다. 이것은 공통의 DNA가 아닌, 우리가 필요로 하는 능력과 동작을 제공하는 데이터를 기반으로 어떤 데이터든 주입할 수 있다는 것을 의미한다.
이제 코드가 완전히 분리되었다. 왜냐하면 Puller
를 구현하는 모든 값들은 System
(Storer
와 동일)에 저장할 수 있기 때문이다. 여러 개의 System
을 만들 수 있으며 데이터는 Copy
로 전달할 수 있다.
여기서 메소드는 필요없다. 단지 데이터를 받는 하나의 함수가 필요하며, 그것의 동작은 입력한 데이터에 따라 달라진다.
이제 System
은 더 이상 Xenia
와 Pillar
를 기반으로 하지 않는다. Xenia
를 저장하는 인터페이스와 Pillar
를 저장하는 인터페이스 두 개를 기반으로 한다. 우리는 추가적인 디커플링 층을 갖게 되었다.
시스템이 바뀌어도 큰 문제가 되지 않는다. 프로그램 시작에 필요한 대로 시스템을 교체만 하면 된다.
우리는 이 문제를 해결 하고 제품에 반영한다. 우리가 했던 모든 리팩터링은 다음 리팩터링을 하기전에 제품에 반영하였다. 우리는 계속해서 기술부채를 최소화하고 있다.
package main
import (
"errors"
"fmt"
"io"
"math/rand"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano()) }
Data
는 복사 할 데이터의 구조체이다.
type Data struct {
string
Line }
Puller
는 데이터 추출 동작을 선언한다.
type Puller interface {
error
Pull(d *Data) }
Storer
는 데이터 저장 동작을 선언한다.
type Storer interface {
error
Store(d *Data) }
PullStore
는 추출 및 저장에 대한 동작을 선언한다.
type PullStorer interface {
Puller
Storer }
Xenia
는 우리가 데이터를 빼내야 하는 시스템이다.
type Xenia struct {
string
Host
Timeout time.Duration }
Pull
은 Xenia
에서 데이터를 가져오는 방법을 알고 있다.
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:
"Data"
d.Line = "In:", d.Line)
fmt.Println(return nil
} }
Pillar
는 우리가 데이터를 저장 해야 하는 시스템이다.
type Pillar struct {
string
Host
Timeout time.Duration }
Store
은 Pillar
에 데이터를 저장하는 방법을 알고 있다.
func (*Pillar) Store(d *Data) error {
"Out:", d.Line)
fmt.Println(return nil
}
System
은 Pullers
와 Stores
를 하나의 시스템으로 결합한다.
type System struct {
Puller
Storer }
pull
은 Puller
로 부터 많은 양의 데이터를 추출하는 방법을 알고 있다.
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
}
store
은 Storer
로 부터 많은 양의 데이터를 저장하는 방법을 알고 있다.
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
}
Copy
는 System
에서 데이터를 추출하고 저장하는 방법을 알고 있다.
func Copy(ps PullStorer, batch int) error {
make([]Data, batch)
data :=
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 {"localhost:8000",
Host:
Timeout: time.Second,
},
Storer: &Pillar {"localhost:9000",
Host:
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
Mover
는 움직이는 것을 나타내기 위해 다음과 같이 정의합니다.
type Mover interface {
Move() }
Locker
는 잠그고(locking) 해제(unlocking)할 수 있는 것을 나타냅니다.
type Locker interface {
Lock()
Unlock() }
MoveLocker
는 움직이거나 잠글 수 있는 것을 나타냅니다.
type MoveLocker interface {
Mover
Locker }
구체적인 예시를 위해 bike
라는 타입을 정의합니다.
type bike struct {}
Move
는 bike
를 움직입니다.
func (bike) Move() {
"Moving the bike")
fmt.Println( }
Lock
은 bike
가 움직이지 못하게 합니다.
func (bike) Lock() {
"Locking the bike")
fmt.Println( }
Unlock
을 하면 bike
는 다시 움직일 수 있습니다.
func (bike) Unlock() {
"Unlocking the bike")
fmt.Println(
}
func main() {
MoverLocker
와 Mover
인터페이스 타입의 변수를 선언합니다. zero value로 초기화 됩니다.
var ml MoveLocker
var m Mover
bike
값을 생성하여 MoveLocker
인터페이스 타입 변수에 대입합니다.
ml = bike{}
MoveLocker
인터페이스 타입 변수는 Mover
인터페이스 타입 변수로 변환할 수 있습니다. 둘 모두 move
라는 메쏘드를 정의했기 때문입니다.
m = ml
하지만, 아래와 같이 반대로는 불가능합니다.
ml = m
컴파일을 하면 다음과 같은 에러가 발생합니다.
cannot use m (type Mover) as type MoveLocker in assignment:
Mover does not implement MoveLocker (missing Lock method).
인터페이스 타입 Mover
는 lock
과 unlock
메서드를 정의하고 있지 않다. 따라서, 컴파일러는 인터페이스 Mover
타입의 변수를 MoveLocker
타입의 변수로 암묵적으로 변환할 수 없다. Mover
인터페이스 변수의 실제 값이 MoveLocker
인터페이스를 구현한 bike
타입의 값이라 해도 변환하지 않는다. 런타임때 타입 단언을 사용하여 명시적으로 변환할 수는 있다.
아래와 같이 Mover
인터페이스의 값을 타입 단언을 사용해 bike
타입의 값으로 변환 후 복사한다. 복사된 값을 MoveLocker
인터페이스 변수에 배정한다. 아래 코드가 타입 단언의 문법이다. 인터페이스 값에 인터페이스값.(bike)
처럼 점(.)에 파라미터로 bike
값을 전달한다. m
이 nil
이 아닌 bike
타입의 값이 들어있을 경우, 포인터가 아닌 값을 넘겨받았기(value semantics) 때문에 m
을 복사한 값을 얻게 된다. 그렇지 않을 경우 panic
이 발생하게 된다. 아래 예시에서 b
는 bike
의 복사된 값을 가지고 있다.
b := m.(bike)
타입 단언의 성공 여부를 나타내는 boolean
값을 받아 panic
을 예방 할 수도 있다.
b, ok := m.(bike)"Does m has value of bike?:", ok)
fmt.Println(
ml = b
Does m has value of bike?: true
타입 단언의 문법을 통해 인터페이스 변수에 실제 저장된 값의 타입이 무엇인지 알 수 있다. 캐스팅을 사용하는 다른 언어에 비해 가독성 관점에서 큰 장점이라고 할 수 있다.
package main
import (
"fmt"
"math/rand"
"time"
)
car
는 무엇가 운전할 수 있는 것을 의미한다.
type car struct{}
String
은 fmt.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++ {
2) rn := rand.Intn(
아래와 같이 랜덤으로 생성된 숫자를 통해 cloud
에 대한 타입 단언을 실행한다. 아래 예시는 타입 단언이 컴파일 때가 아닌 런타임때 실행된다는 것을 알 수 있다.
if v, ok := mvs[rn].(cloud); ok {
"Got Lucky:", v)
fmt.Println(continue
}
x
라는 변수가 있으면 x.(T)
를 통해 T
타입으로 단언 될 수 있는지 확인해줘야 한다. 아니면 무결성 등의 이유로 panic
하길 원할 경우라면 ok
변수를 사용하지 않을 것이다. panic
으로부터 회복할 수 없으면 프로그램은 종료될 것이고 재시작해야 한다.
프로그램이 종료된다는 의미는 스택 트레이스가 출력되는 log.Fatal
, os.exit
혹은 panic
함수를 호출했다는 것이다. 타입 단언을 사용할 때는, 요청하는 타입의 값이 들어있지 않아도 괜찮은지 확인해야 한다.
타입 단언을 사용해 구체적인 타입의 값을 꺼낼 경우, 주의해서 사용한다. 디커플링의 레벨을 유지하기 위해 인터페이스를 사용했는데 타입 단언을 사용해 다시 이전으로 돌아가기 때문이다.
구체적인 타입을 사용할 경우 연관 있는 많은 코드를 동시에 리팩토링을 해야 될 수도 있다는 것을 알아야 한다. 반대로 인터페이스를 사용할 경우 내부 구현이 변해도 그로 인해 발생하는 변경점들은 최소화 할 수 있다.
"Got Unlucky")
fmt.Println(
} }
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
소프트웨어를 설계할 때, 구체적인 타입이 아닌 인터페이스부터 설계한다. 인터페이스를 사용하는 이유는 무엇일까?
미신 #1: 인터페이스를 사용해야하기 때문에 인터페이스를 사용하고 있다.
답: 아니오. 인터페이스를 사용할 필요가 없다. 합리적이고 실용적일 때 인터페이스를 사용해야 한다.
인터페이스를 사용하는 데는 비용이 든다. 구체적인 타입을 인터페이스 타입으로 사용 될때 잠재적 할당 비용과 추상화 비용이 그것이다. 디커플링에 그만한 비용의 가치가 없다면 인터페이스를 사용해서는 안된다.
미신 #2: 코드를 테스트 하기 위해 인터페이스를 사용해야 한다.
답: 아니오. 테스트가 아니라 개발자를 우선하여 애플리케이션에 사용할 수 있는 API를 설계해야한다.
다음은 필요하지 않은 인터페이스를 사용하여 인터페이스 오염을 생성하는 예이다.
Server
는 TCP 서버에 대한 계약을 정의한다. 이것은 약간의 코드 악취에 해당하는데 이것은 사용자에게 노출 될 API이고 하나의 인터페이스에 넣기에 많은 동작이다.
type Server interface {
error
Start() error
Stop() error
Wait() }
server
는 Server
인터페이스를 구현한다. 이름이 일치하지만 꼭 나쁘다고 할 수 는 없다.
type server struct {
string
host }
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
를 생성한다.
"localhost") srv := NewServer(
API를 사용한다.
srv.Start()
srv.Stop()
srv.Wait() }
위 코드에서 srv
가 인터페이스가 아닌 구체적인 타입이었다면 아무 문제도 없을 것이다. 여기서 인터페이스는 디커플링 같은 어떠한 이점도 가져다 주지 않는다. 그저 추상화 수준을 높여 코드를 복잡하게 만들 뿐이다.
위 코드는 문제가 있는데 왜냐하면:
이전에 나온 예시에서 잘못된 인터페이스 사용을 고쳐보도록 하겠다.
Server
의 구현이다.
type Server struct {
string
host }
NewServer
는 Server
의 포인터를 반환한다.
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() {
"localhost") srv := NewServer(
API를 사용한다.
srv.Start()
srv.Stop()
srv.Wait() }
인터페이스 오염을 피하기 위한 가이드라인
인터페이스를 다음과 같은 상황에서 사용한다:
다음과 같은 상황에서 인터페이스를 사용할지 다시 한번 생각해본다:
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 {
string
host }
New
는 pubsub을 사용하기 위한 값을 반환한다.
func New(host string) *PubSub {
ps := PubSub{
host: host,
}
return &ps
}
Publish
는 특정 키에 데이터를 전송한다.
func (ps *PubSub) Publish(key string, v interface{}) error {
"Actual PubSub: Publish")
fmt.Println(return nil
}
Subscribe
는 특정 키값으로부터 메시지를 수신한다.
func (ps *PubSub) Subscribe(key string) error {
"Actual PubSub: Subscribe")
fmt.Println(return nil
}
아래는 패키지나 테스트를 위해 mock 객체를 어떻게 생성하는지 보여준다.
package main
import (
"fmt"
)
publisher
인터페이스로 pubsub
패키지를 mock을 가능케 한다. 애플리케이션을 작성할 때 필요한 모든 API를 정의하는 인터페이스를 선언한다. 이전 파일에 나온 구체적인 타입들이 이 인터페이스를 이미 구현하고 있다. 이제 여기서 구체적인 구현 없이 mocking을 통하여 애플리케이션 전체를 작성할 수 있다.
type publisher interface {
string, v interface{}) error
Publish(key string) error
Subscribe(key }
mock
은 pubsub
패키지를 mocking 하기 위한 구체적인 타입이다.
type mock struct{}
Publish
메쏘드는 publisher
인터페이스를 구현한다.
func (m *mock) Publish(key string, v interface{}) error {
// ADD YOUR MOCK FOR THE PUBLISH CALL.
"Mock PubSub: Publish")
fmt.Println(return nil
}
Subscribe
메쏘드는 publisher
인터페이스를 구현한다.
func (m *mock) Subscribe(key string) error {
// ADD YOUR MOCK FOR THE SUBSCRIBE CALL.
"Mock PubSub: Subscribe")
fmt.Println(return nil
}
publisher
인터페이스 슬라이스를 생성한다. 그리고 pubsub
의 주소를 부여한다. mock
의 주소값도 추가한다.
func main() {
pubs := []publisher{"localhost"),
New(
&mock{}, }
인터페이스 슬라이스를 순회하면서 publisher
인터페이스가 어떻게 디커플링(decoupling)을 하는지 볼 수 있다. pubsub
패키지가 인터페이스를 제공할 필요가 없는 것을 볼 수 있다.
for _, p := range pubs {
"key", "value")
p.Publish("key")
p.Subscribe(
} }
무결성은 중요하며 이보다 중요한 것은 없다. 에러 처리는 그러한 무결성의 한 부분이다. 개발자가 매일 챙겨야 하는 부분이며, 작성하는 코드의 일부로 생각해야 한다. 먼저, 언어에서 제공하는 기본 에러 타입 구현의 동작에 대해 살펴보자.
package main"fmt" import
http://golang.org/pkg/builtin/#error
이것은 언어 자체에 포함되어 있기에, 외부로 노출되지 않는 타입처럼 보인다. Error
라는 문자열 한 개를 반환하는 메서드 하나만 외부에서 접근이 가능하다. 에러 처리는 코드 테스트를 할 때에 항상 errer
인터페이스를 사용하기에 디커플링 되어 있다.
Go에서 에러는 단지 값일 뿐이며, 인터페이스 디커플링을 통해 그 값을 평가한다. 에러 처리를 디커플링 하는 것은 지속적인 변경이 코드 전반에 걸쳐 광범위한 영향을 야기하기 때문이다. 에러를 다룰 때 인터페이스를 최대한 이용하는 것이 중요하다.
error interface {
type
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
}"Life is good")
fmt.Println( }
webCall
은 웹 요청을 처리한다
func webCall() error {"Bad Request")
return New( }
어떤 에러가 반환 되는지 알기 위해 에러 변수를 사용해본다.
package main
import ("errors"
"fmt"
)
소스코드 파일의 최상단을 아래와 같이 작성한다. 명명 규칙 : 에러 변수 명명은 Err로 시작하도록 한다. 사용자들이 접근 가능하도록 (대문자로 작성하여) 노출한다.
아래는 지난 예시 파일에서 살펴 본 에러 인터페이스들에, 값을 할당한 것이다. 에러들에 대한 컨텍스트를 이 변수들이 자체적으로 포함한다. 이 방법은 사용자들이 기본 에러 타입과 그것의 필드에 대한 노출 없이 지속적으로 에러 처리를 사용할 수 있도록 디커플링한다.
요청에 문제가 있는 경우 ErrBadRequest
변수가 반환된다. 301/302가 반환되면 ErrPageMoved
변수가 반환된다.
var ("Bad Request")
ErrBadRequest = errors.New("Page Moved")
ErrPageMoved = errors.New( )
func main() {
if err := webCall(true); err != nil {
switch err {
case ErrBadRequest:"Bad Request Occurred")
fmt.Println(return
case ErrPageMoved:"The Page moved")
fmt.Println(return
default:
fmt.Println(err)return
}
}"Life is good")
fmt.Println( }
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 {// JSON value에 대한 설명이다
Value string // 미리 선언할 수 없는 타입을 의미한다
Type reflect.Type }
UnmarshalTypeError
은 포인터 시맨틱(pointer semantics)을 이용하여 에러 인터페이스를 구현한다. 구현 할 때, 모든 필드를 에러 메시지에서 사용하는가 검증한다. 그렇지 않다면 문제가 발생할 수 있다. 사용자 정의 에러 타입에 필드를 추가 해두어도 아래 메서드(Error
)가 호출될 때 로그가 정상적으로 출력되지 않을 것이기 때문이다. 정말 필요할 때만 이렇게 사용하자.
func (e *UnmarshalTypeError) Error() string {"json: cannot unmarshal " + e.Value + " into Go value of type " + e.Type.String()
return }
InvalidUnmarshalError
는 Unmarshal
함수에 유효하지 않은 매개변수가 들어왔음을 알린다. Unmarshal
의 매개변수로는 nil
이 아닌 포인터가 들어와야 한다. 실제 타입은 Unmarshal
함수가 값의 주소를 받지 않았을 때 반환값에 사용된다.
type InvalidUnmarshalError struct {
Type reflect.Type }
InvalidUnmarshalError
은 에러 인터페이스를 구현한다.
func (e *InvalidUnmarshalError) Error() string {
if e.Type == nil {"json: Unmarshal(nil)"
return
}
if e.Type.Kind() != reflect.Ptr {"json: Unmarshal(non-pointer " + e.Type.String() + ")"
return
}"json: Unmarshal(nil " + e.Type.String() + ")"
return }
Unmarshal
호출을 위해 user
타입을 사용한다.
type user struct {
Name int }
func main() {
var u user`{"name":"bill"}`), u) // Run with a value and pointer.
err := Unmarshal([]byte(
if err != nil {
This is a special type assertion that only works on the switch.
switch e := err.(type) {
case *UnmarshalTypeError:"UnmarshalTypeError: Value[%s] Type[%v]\n", e.Value, e.Type)
fmt.Printf(
case *InvalidUnmarshalError:"InvalidUnmarshalError: Type[%v]\n", e.Type)
fmt.Printf(
default:
fmt.Println(err)
}return
}"Name:", u.Name)
fmt.Println( }
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)}
}"string", reflect.TypeOf(v)}
return &UnmarshalTypeError{ }
타입을 통해 컨텍스트를 처리할 때의 한 가지 흠이 있다. 이 경우에는, 구현된 타입에 접근하여 디커플링(decoupling)에서 멀어지게 된다. json
패키지를 작성한 개발자가 구현된 타입들을 변경하면, 예제 코드에도 연쇄적인 영향을 미치게 된다. 에러 인터페이스 디커플링의 보호를 받지 못한다.
이런 문제는 가끔 발생할 수 있다. 디커플링을 유지하는 다른 방법은 없을까? 기능을 통한 컨텍스트 처리를 살펴보자.
기능을 통한 컨텍스트 처리는 사용자 정의 오류를 마치 컨텍스트처럼 다룰 수 있게 해준다. 그리고 구현된 타입으로 단언하는 걸 막는다. 이를 통해 디커플링(decoupling)의 레벨에서 코드를 유지보수할 수 있게 된다.
package main
import ("bufio"
"fmt"
"io"
"log"
"net"
)
client
는 하나의 연결성을 가진다.
type client struct {
name string
reader *bufio.Reader }
TypeAsContext
는 net
패키지가 반환하는 여러 사용자 정의 오류들을 확인하는 방법을 보여준다.
func (c *client) TypeAsContext() { for {
reader
인터페이스를 이용하여 네트워크를 읽는 것에서 코드를 분리할 수 있다.
'\n')
line, err := c.reader.ReadString( if err != nil {
이 예제는 이전 예제와 마찬가지로 타입을 통해 컨텍스트를 처리한다. 여기서는 Temporary
라는 메서드가 중요하다. 이 메서드가 정상적으로 작동한다면 계속 작업을 수행하고 그렇지 않다면 멈춘 후에 다시 시작한다. 아래 모든 케이스는 오직 Temporary
만을 위한 것이다. 이게 왜 중요한가? 만약 타입 단언을 한다거나 구현된 타입의 내재된 기능만 요구한다면, 이것을 타입이 아니라 기능을 통해 처리하는 방식으로 바꿀 수 있다. 그렇기에 아래의 temporary
라는 사용자 정의 인터페이스를 만들 수 있다.
switch e := err.(type) {
case *net.OpError:
if !e.Temporary() {"Temporary: Client leaving chat")
log.Println(return
}
case *net.AddrError:
if !e.Temporary() {"Temporary: Client leaving chat")
log.Println(return
}
case *net.DNSConfigError:
if !e.Temporary() {"Temporary: Client leaving return chat")
log.Println(return
}
default:
if err == io.EOF {"EOF: Client leaving chat")
log.Println(return
}"read-routine", err)
log.Println(
}
}
fmt.Println(line)
} }
temporary
는 net 패키지에서 Temporary
라는 메서드가 반환되는 지 확인한다. 왜냐하면 그 중 Temporary
라는 메서드를 가진 구조체만 있으면 되기때문이다. 그러면 여전히 디커플링 단계에 있으며 계속 인터페이스 레벨에서 작업할 수 있다.
type temporary interface {
Temporary() bool }
BehaviorAsContext
는 net
패키지가 반환할 지도 모르는 인터페이스를 어떻게 확인하는 지 보여준다.
func (c *client) BehaviorAsContext() {
for {'\n')
line, err := c.reader.ReadString(
if err != nil { switch e := err.(type) {
타입 단언을 통해 세가지 경우를 한가지로 줄일 수 있다: 이 구현 타입은 해당 인터페이스를 구현하고 있는 error
인터페이스를 가지고 있으며 해당 인터페이스를 정의하고 이용할 수 있다.
case temporary:
if !e.Temporary() {"Temporary: Client leaving return chat")
log.Println(return
}
default:
if err == io.EOF {"EOF: Client leaving chat")
log.Println(return
}"read-routine", err)
log.Println(
}
}
fmt.Println(line)
} }
Lesson:
Go의 암시적 형변환 덕분에, 원하는 메서드나 기능을 가진 인터페이스를 구현함으로 디커플링 단계에서 유지보수할 수 있고 타입 단언을 이용하는 switch
문법에서 구현 타입 대신에 사용할 수 있다.
package main"log" import
customError
는 빈 구조체이다.
type customError struct{}
Error
는 error
인터페이스를 구현한다.
func (c *customError) Error() string {"Find the bug."
return }
fail
함수는 둘 다 nil
값을 반환한다.
func fail() ([]byte, *customError) {
return nil, nil }
func main() {error var err
fail
을 호출하면 nil
값을 반환할 것이다. 하지만 error
인터페이스로써 반환하고 싶지만 customError
타입의 nil
값을 반환할 뿐이다. customError
타입은 이 소스 코드 안에서 만들어진 타입에 불과하다. 그렇기에 사용자 정의 타입을 직접 반환해서는 안되고 func fail() ([]byte, error)
처럼 인터페이스를 반환해야한다.
if _, err = fail(); err != nil {"Why did this fail?")
log.Fatal(
}"No Error")
log.Println( }
오류 처리는 코드의 일부이며, 로깅으로 까지 이어진다. 로깅의 주목적은 디버그를 위한 것이다. 로그를 보고 대응이 가능한 것이라면 로그로 남긴다. 어떻게 실행되고 있는지 상황을 알려주는 것을 로그로 남긴다. 그 외의 것들은 노이즈나 다름 없으며, 대시보드의 지표로나 사용하면 된다. 예를 들자면, 소켓의 연결과 끊어짐을 로그로 남길 수는 있지만 딱히 대응을 해야 하거나 챙겨서 볼 필요는 없는 것이다.
여기 Dave Cheney가 작성한 errors
라는 패키지가 있다. 이 패키지는 오류를 간단하게 처리할 수 있게 도와주고 동시에 로그를 기록해준다. 아래 코드는 이 패키지가 코드를 어떻게 단순하게 만들어 주는 지 보여준다. 로깅의 양을 줄임으로, 힙(주로 Garbage Collection)에 대한 부담을 줄일 수 있다.
import (
"fmt"
"github.com/pkg/errors"
)
AppError
는 사용자 정의 에러 타입이다.
type AppError struct {
State int }
AppError
는 error
인터페이스를 구현한다.
func (c *AppError) Error() string {"App Error, State: %d", c.State)
return fmt.Sprintf( }
func main() {
함수를 호출하고 오류를 검증한다. firstCall
은 secondCall
를 호출하고 secondCall
이 thirdCall
을 호출하면 결과로 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:"Custom App Error:", v.State) fmt.Println(
오류의 스택 트레이스(stack trace)를 보여준다.
"\nStack Trace\n********************************")
fmt.Println("%+v\n", err)
fmt.Printf("\nNo Trace\n********************************")
fmt.Println("%v\n", err)
fmt.Printf(
}
} }
firstCall
은 secondCall
을 호출하고 오류를 래핑하여 반환한다.
func firstCall(i int) error {
if err := secondCall(i); err != nil {"firstCall->secondCall(%d)", i)
return errors.Wrapf(err,
}
return nil }
secondCall
은 thirdCall
을 호출하고 오류를 래핑하여 반환한다.
func secondCall(i int) error {
if err := thirdCall(); err != nil {"secondCall->thirdCall()")
return errors.Wrap(err,
}
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
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)가 존재한다.
고루틴이란, 실행의 경로, 스레드의 실행경로, 스케줄링 되어야 하는 실행의 경로이다. 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 스케줄러처럼 결정적이지 않다. 모든 조건이 동일해도 스케줄러가 무엇을 수행할지 예측할 수 없다. 우리가 이러한 고루틴의 실행에 대한 조정하는 방법을 배워, 오케스트레이션 할 수 있을 때 까지는 예측할 수 없다.
아래의 도표는 이에 대한 예제를 표현한 맨탈모델이다.
프로세서 P
에 m
을 위한 Gm
이 실행하고, 2개의 G1
과 G2
고루틴을 생성한다. 이것은 협조적 스케줄러(cooperating scheduler)이기에, 고루틴은 비선점적으로 스케줄링되고 운영체제 스레드인 m
은 문맥교환(context switch)이 생기는 것을 의미한다.
스케줄러가 스케줄링을 하게 되는 4가지 상황이 있다.[재방문]
go
키워드를 통해 고루틴들을 생성할 때. 이는 여러개의 프로세서(P)를 가지고 있을 때, 스케줄러가 균형을 다시 맞출 수 있다.다시 예제로 돌아와, 스케줄러는 Gm
이 실행되기까지 충분한 시간이 남았을때, Gm
을 실행 대기열(run queue)에 넣고 G1
이 해당 m
에서 실행되도록 혀용한다(문맥교환).
고루틴 G1
에서 파일을 연다고 해보자. 파일을 여는 작업에 걸리는 시간이 얼마나 소요될지는 알 수 없다. 만약 파일을 열 때 이 고루틴(G1
)이 OS 스레드를 블록(block) 시킨다면, 더 이상의 다른 작업을 완료할 수가 없다. 이 예제는 하나의 프로세서(P
)와 싱글 스레드(m
)로 동작하는 어플리케이션이다. 모든 고루틴은 프로세서(P
)에 할당된 스레드(m
)에서만 동작한다. 만약 고루틴이 프로세서(P
)에 할당된 스레드(m
)을 오랫동안 블록(block)시킨다면 어떻게 될까? 작업이 완료될 때까지 아무것도 할 수 없다. 이런일이 발생하지 않도록 하기 위해 스케줄러는 m
과 G1
을 분리한다. 새로운 m
인 m2
를 가져오고, 실행 대기열(run queue)에서 다음에 실행할 G
, 즉 G2
를 결정한다.
이제 싱글 스레드로 작성된 프로그램에 2개의 스레드가 있다. 우리의 관점에서 여전히 싱글 스레드 인데, 모든 고루틴과 관련된 코드는 프로세서(P
)와 OS 스레드(m
)에 대해서만 실행할 수 있기 때문이다. 하지만 어떤 m
이 처리되고 있는지 알 수는 없다. 스레드(M
)은 교체될 수 있고, 여전히 싱글 스레드로 이다.
G1
이 파일 열기 작업을 끝냈을 때, 스케줄러는 G1
을 실행 대기열(run queue)에 넣고나서 특정 스레드, 예제에서는 m2
를 다시 실행할 수 있다. m
은 다시 사용하기 위해 남겨지고, 여전히 2개의 스레드를 유지하고 있다. 전체 과정은 다시 일어날 수 있다.
이렇듯 하나의 스레드 상에서 더 많은 작업을 수행함으로써, 해당 스레드로부터 최대의 가용성을 끌어 낼 수 있는 훌륭한 방법이다. 따라서 추가적인 스레드없이도 충분한 작업을 할 수 있다.
네트워크 폴러가 있고, 모든 로우 레벨(low level)의 비동기 네트워킹 작업을 수행한다. 고루틴은 이러한 작업을 수행할 경우, 네트워크 풀러로 이동한 다음 다시 대기열 뒤로 가져온다. 명심할 것은 작성된 코드는 프로세서(P
)에 대응한 스레드(m
)에서 실행된다는 것이다. 얼마나 많은 쓰레드를 실행하는 지는 프로세서(P
)를 얼마나 가지고 있는지에 달려있다.
동시성이란 이런 많은 작업들을 한번에 관리할 수 있는 것을 의미하고, 이것이 스케줄러의 역할이다. 하나의 OS 스레드(m
)에 의해 3개의 고루틴은 오직 한번에 하나의 고루틴만 실행될 수 있기 때문에, 하나의 프로세서(P
), 하나의 스레드(m
) 그리고 3개의 고루틴 실행을 관리한다. 만약 한번에 많은 일들을 동시에 처리하고 싶다면, 다시 말해서 병렬(parallel)처리하고 싶다면 또 다른 스레드(m3
)를 처리할 수 있는 프로세서(P
)가 하나 더 필요하다.
멀티 프로세서는 OS에 의해 스케줄링 된다. 이제 2개의 고루틴을 병렬(parallel)로 처리할 수 있다.
2개의 스레드로 실행되는 다중 스레드 소프트웨어를 가정해보자. 이제 2개의 고루틴을 병렬(parallel)로 처리할 수 있다. 프로그램은 2개의 스레드를 실행하고, 두 스레드가 동일한 코어에 있는 경우에도 서로에게 메세지를 전달하려고 한다. OS의 관점에서는 어떤 일이 일어나는지 알아보자.
먼저 첫번째 스레드가 스케줄링 되고 특정 코어에 할당될 때까지 기다려야 한다(문맥교환 발생). 이때, 아직 스레드는 대기 상태이므로 어떤 것도 실행 상태가 아니다. 첫번째 스레드에서 메세지를 보내고, 이에 대한 응답을 받기를 기다린다. 응답을 받기 위해서, 해당 코어에 다른 스레드를 배치할 수 있고 이를 통해서 또 다른 문맥교환이 발생한다. OS가 두번째 스레드를 스케줄링하기를 기다리며 또 다른 문맥교환이 발생한다. 대기 상태의 스레드를 깨우고 실행시켜 메세지를 처리한다. 메세지 전달 과정을 통해, 스레드는 실행 가능 상태(excutable state)에서 실행 대기 상태(runnable state)로 전환되며 대기 상태(asleep state) 순으로 바뀐다. 이러한 문맥교환들은 많은 비용(cost)이 발생한다
단일 코어에서 고루틴을 사용하면 어떤지 살펴보자. G1
은 G2
에게 메세지를 보내려 하고 문맥 교환이 일어난다. 하지만 이 문맥(context
)은 사용자의 공간 전환이다. 스레드에서 실행 중인 G1
을 G2
로 전환할 수 있다. OS의 관점에서 이 스레드는 sleep
상태가 되지 않는다. 이 스레드는 항상 실행중이며, 문맥교환을 할 필요가 없다. Go 스케줄러는 고루틴을 계속해서 문맥교환 시켜준다.
프로세서(P
)에 할당된 특정 스레드(m
)가 처리할 고루틴(G
)이 없다면, 런타임 스케줄러는 해당 스레드 코어 속에서 일정 시간 유효(hot status
)할 수 있도록 스핀(spin
) 상태로 만들어준다. 왜냐하면, 스레드가 더 이상 유효하지 않은 상태(cold status
)라면 OS는 해당 스레드를 코어에서 빼내고 다른 스레드로 교체하기 때문이다. 따라서 비어 있는 스레드에 할당되어 처리할 고루틴(G
)이 있을지 확인하기 위해, 잠시 스핀(spin
) 상태가 되는 것이다.
이것이 스케줄러가 작동하는 방식이다. 프로세서(P
)와 스레드(m
)이 있고, OS는 스케줄링 작업을 할 것이다. 코어의 갯수보다 더 많은 것을 필요로 하지 않는데, 코어의 갯수보다 더 많은 OS 스레드가 필요하지 않다. 코어의 갯수보다 더 많은 스레드가 있다는 것은 OS에 적재하는 방법 뿐인데, Go의 스케줄러는 고루틴에 대해 최소한으로 필요한 스레드 수를 유지하고 작업을 계속해서 수행할 수 있도록 한다. Go의 스케줄러는 비선점적인 스케줄링으로 호출되더라도 선점된 것처럼 보인다.
하지만 개발을 쉽게 하기 위해서 스케줄링의 작동에 대해 잊고, 모든 고루틴(G
)에 대해 runnable state
에 있는 고루틴들은 모두 동시에 실행이 가능하다고 이해하자.
소프트웨어가 깔끔하게 시작, 종료되도록 코드를 작성하는 것은 매우 중요하다.
package main
import (
"fmt"
"runtime"
"sync"
)
‘init’ 함수는 런타임 패키지에서 GOMAXPROCS
을 호출한다. 환경 변수이기 때문에 대문자로 표기된다.
Go 1.5 이전에서는 코어 수의 관계 없이 하나의 프로세서(P
)만 제공 되었다. 가비지 콜렉터와 스케줄러의 개선으로 모든것이 개선되었다.
스케줄러에게 하나의 논리 프로세서만 할당할 것을 명시한다.
func init() {
1)
runtime.GOMAXPROCS(
}
func main() {
wg
는 동시성을 관리하는데 사용된다. wg
는 제로값으로 설정된다. 또한 제로값 상태에서 사용할 수 있는 Go의 매우 특별한 타입이다. 그리고 비동기 계산 세마포어(Asynchronous Counting Semaphore)로 불린다.
세개의 Add
, Done
, Wait
메소드를 갖는다. n개의 고루틴은 이 메소드를 동시에 호출 할 수 있고, 모두 직렬화(serialized)되어 있다.
Add
: 얼마나 많은 고루틴이 있는지 계산한다.Done
: 일부 고루틴이 종료될 예정이므로 값을 감소시킨다.Wait
: 해당 카운트가 0이 될 때까지 프로그램을 유지한다.var wg sync.WaitGroup
2개의 고루틴을 생성하자. 반대로 Add(1)을 호출하고, 1씩 증가하기 위해 반복한다. 만약 얼마나 많은 고루틴이 생성될지 모른다면, 그것은 코드 스멜(smell)이다.
2)
wg.Add(
"Start Goroutines") fmt.Println(
익명함수를 사용해 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
에 도달했을 때, 스케줄러는 메인 고루틴을 마저 실행하고, 종료 될 수 있도록 한다.
"Waiting To Finish") wg.Wait()
fmt.Println(
wg.Wait()
"\nTerminating Program")
fmt.Println( }
lowercase
함수는 알파벳 소문자를 세번 반복 출력한다.
func lowercase() {
for count := 0; count < 3; count++ {
for r := 'a'; r <= 'z'; r++ {
"%c ", r)
fmt.Printf(
}
} }
uppercase
함수는 알파벳 대문자를 세번 반복 출력한다.
func uppercase() {
for count := 0; count < 3; count++ {
for r := 'A'; r <= 'Z'; r++ {
"%c ", r)
fmt.Printf(
}
} }
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)상태가 된다.
Go의 스케줄러는 선점 스케줄러(preemptive)가 아닌 협력 스케줄러(cooperating scheduler)에도 선점된 것처럼 생각되는 이유는 런타임 스케줄러가 프로그래머에게 인식되기 전에 모든 처리를 하기 때문이다.
아래의 코드는 문맥교환을 보여주고, 언제 문맥교환이 발생하는지 예상할 수 있도록 보여준다. 위 코드와 같은 패턴이지만 printPrime
함수가 새로 추가된다.
package main
import (
"fmt"
"runtime"
"sync"
)
하나의 논리 프로세서를 스케줄러에게 할당한다.
func init() {
1)
runtime.GOMAXPROCS( }
wg
는 동시성을 관리하기 위해 사용한다.
func main() {
var wg sync.WaitGroup
2)
wg.Add(
"Create Goroutines") fmt.Println(
첫번째 고루틴을 생성하고, 생명주기를 관리한다.
go func() {
"A")
printPrime(
wg.Done() }()
두번째 고루틴을 생성하고, 생명주기를 관리한다.
go func() {
"B")
printPrime(
wg.Done() }()
고루틴이 종료될 때까지 대기한다.
"Waiting To Finish")
fmt.Println(
wg.Wait()
"Terminating Program")
fmt.Println( }
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
}
}
"%s:%d\n", prefix, outer)
fmt.Printf(
}
"Completed", prefix)
fmt.Println( }
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개의 논리 프로세서를 할당한다.
2)
runtime.GOMAXPROCS(
}
func main() {
wg
는 프로그램이 종료될때까지 기다리는데 사용한다. Add
에 2를 추가함으로, 2개의 고루틴이 종료될 때까지 대기한다.
var wg sync.WaitGroup
2)
wg.Add(
"Start Goroutines") fmt.Println(
소문자 알파벳을 3번 출력하는 익명 함수를 선언하고, 고루틴을 생성한다.
go func() {
for count := 0; count < 3; count++ {
for r := 'a'; r <= 'z'; r++ {
"%c ", r)
fmt.Printf(
}
}//메인(main)에게 작업이 끝났음을 알린다.
wg.Done() }
대문자 알파벳을 3번 출력하는 익명 함수를 선언하고, 고루틴을 생성한다.
go func() {
for count := 0; count < 3; count++ {
for r := 'A'; r <= 'Z'; r++ {
"%c ", r)
fmt.Printf(
}
}//메인(main)에게 작업이 끝났음을 알린다.
wg.Done() }()
고루틴이 끝나기를 기다린다.
"Waiting To Finish")
fmt.Println(
wg.Wait()
"\nTerminating Program")
fmt.Println( }
소문자와 대문자가 섞여서 출력되는 것을 확인할 수 있다.
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
프로그램에 고루틴을 추가하면 복잡도가 엄청나게 올라간다. 고루틴을 언제나 상태없이(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()"Final Counter:", counter)
fmt.Println( }
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
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라는 값을 얻을 수 있다.
1) atomic.AddInt64(&counter,
이 호출은 AddInt64
함수 호출이 완료됐을 때 counter
가 이미 증가했으므로 큰 의미가 없다.
runtime.Gosched()
}
wg.Done()
}() }
고루틴이 끝날 때까지 기다린다.
wg.Wait()
최종 값을 보여준다.
"Final Counter:", counter)
fmt.Println( }
Final Counter: 4
일반적으로 데이터 공유를 하기 위해 매번 4-8바이트를 할당할 만큼 메모리가 여유롭지 않다. 이럴 때 뮤텍스를 사용하면 좋다. 뮤텍스를 사용하면 모든 고루틴이 한번에 하나씩 실행할 수 있는 WaitGroup(Add, Done and Wait)과 같은 API를 사용할 수 있다.
package main
import (
"fmt"
"sync"
)
var (
counter
는 모든 고루틴들에 의해 증가되는 변수이다.
int counter
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()"Final Counter: %d\n", counter)
fmt.Printf( }
Final Counter: 4
많은 고루틴이 읽기 원하는 공유 자원이 있다고 하자.
때때로, 하나의 고루틴이 들어와서 리소스를 바꿀 수 있다. 그렇게 되면, 모두 읽는 것을 중단해야한다. 아무런 이유 없이 소프트웨어에 대기시간을 추가하기 때문에 이러한 유형의 사나리오에서 읽기를 동기화하는 것은 의미가 없다.
package main
import (
"fmt"
"math/rand"
"sync"
"sync/atomic"
"time"
)
data
는 공유될 slice 이다.
var (
string data []
rwMutex
는 코드의 임계 구역을 정의하는데 사용된다. 그것은 뮤텍스보다 살짝 느리지만 먼저 정확성을 최적화하고 있으므로 지금은 신경쓰지 않는다.
rwMutex sync.RWMutex
조회하는 시간에 시도된 읽기 수를 의미한다. 여기서 int64
를 보자마자 아토믹 명령어 사용에 대해 생각해야한다.
int64
readCount )
init
은 main
보다 먼저 호출된다.
func init() {
rand.Seed(time.Now().UnixNano())
}
func main() {
wg
는 동시성을 관리하는데 사용된다.
var wg sync.WaitGroup
1) wg.Add(
10개의 서로 다른 쓰기를 수행하는 쓰기용 고루틴을 만든다.
go func() {
for i := 0; i < 10; i++ {
100)) * time.Millisecond)
time.Sleep(time.Duration(rand.Intn(
writer(i)
}
wg.Done() }()
영원히 실행되는 8개의 읽기용 고루틴을 만든다.
for i := 0; i < 8; i ++ {
go func(i int) {
for {
reader(i)
}
}(i) }
쓰기 고루틴이 끝날 때까지 기다린다.
wg.Wait()"Program Complete")
fmt.Println( }
쓰기용 고루틴은 임의의 간격으로 슬라이스에 새 문자열을 추가한다.
func writer(i int) {
한번에 오직 하나의 고루틴만이 슬라이스에 읽기/쓰기를 하도록 허용된다.
rwMutex.Lock() {
현재 readCount
를 캡쳐한다. 이 호출 없이 수행할 수 있지만 안전하게 처리하는 것이 좋다. 다른 고루틴이 읽기를 수행하지 않는 것을 보장해야 한다. 이 코드가 실행될 때 rc
의 값은 항상 0 이어야한다.
rc := atomic.LoadInt64(&readCount)
전체 잠금이 있으므로 작업을 수행한다.
"****> : Performing Write : RCount[%d]\n", rc)
fmt.Printf(append(data, fmt.Sprintf("String: %d", i))
data =
}
rwMutex.Unlock() }
reader
가 수행되고 데이터 슬라이스를 반복한다.
func reader(id int) {
모든 고루틴은 쓰기 작업이 일어나지 않을 때 읽을 수 있다. RLock
에는 그에 해당하는 RUnlock
이 있다.
rwMutex.RLock() {
readCount
를 1씩 증가시킨다.
1) rc := atomic.AddInt64(&readCount,
읽기 작업을 수행하고 값을 표시한다.
10)) * time.Millisecond)
time.Sleep(time.Duration(rand.Intn("%d : Performing Read : Length[%d] RCount[%d]\n", id, len(data), rc) fmt.Printf(
readCount
를 1씩 감소시킨다.
-1)
atomic.AddInt64(&readCount,
}
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) 움직일 수 없게 된다. 두 고루틴 모두 채널에 관여하고 있지 않고 있다. 마침내 고루틴이 와서 데이터를 받겠다고 말할 때, 데이터를 전송할 수 있다.
여기에 버퍼 없는 채널이 신호가 전달되었다는 보장을 해줄 수 있는 이유가 있다. 신호 받기가 먼저 일어난다. 신호 받기가 일어나면 신호가 전달되었다는 것을 알 수 있고, 이제야 다른 일을 하러 갈 수 있다.
버퍼 없는 채널은 매우 강력한 채널이다. 이 보장을 최대한 활용하면 좋다. 하지만 다시 한번 말하건대 보장의 대가는 기다림 때문에 일어나는 긴 지연 시간이다.
버퍼 있는 채널은 약간 다르다. 우리는 신호가 전달되었다는 보장을 얻지 못하는 반면 신호 주기와 받기 모두에서 지연 시간을 줄일 수 있다.
앞의 예시로 되돌아가보자. 버퍼 없는 채널을 버퍼 있는 채널로 바꿨다고 해보자. 버퍼가 1밖에 안되는 채널을 쓴다고 가정할 것이다. 이는 채널 안에 1개의 데이터 조각을 위한 공간이 있다는 뜻이며 반대편이 데이터를 받을 때까지 기다릴 필요가 없다는 뜻이다. 따라서 이제 어떤 고루틴이 와서 데이터를 채널에 넣고 바로 가버릴 수 있다. 다시 말하면 신호 보내기가 신호 받기보다 먼저 일어난다. 고루틴이 신호 주기에 대해 아는 전부는 신호를 보냈고, 데이터를 넣었다는 것 뿐이며 신호가 언제 전달될지에 대해서는 아는 것이 없다. 이제 다른 고루틴이 와서 데이터가 있는 것을 보고 그걸 받아 가기를 바랄 뿐이다.
버퍼가 1개 있는 채널은 이런 종류의 지연 시간을 다룰 때 쓴다. 더 큰 버퍼가 필요할 때도 있지만 1개 이상의 버퍼를 쓸 때 적용되는 설계 규칙을 나중에 배우게 될 것이다. 하지만 신호 주기가 들어오고 있고 그 신호들이 잠길(locked) 가능성이 있는 상황이라면 우리는 다시 생각해봐야 한다. 버퍼 1개 채널이 우리가 맞닥뜨린 지연 시간을 줄이는 데 충분한가? 왜냐하면 앞으로 우리는 신호를 보낼 때마다 버퍼 있는 채널이 항상 비어있기를 바랄 것이기 때문이다.
버퍼 있는 채널은 성능을 위한 것이 아니다. 버퍼 있는 채널은 연속성을 위해, 바퀴가 계속 굴러가게 하기 위해 써야 한다. 우리가 알아두어야 할 것 중 하나는 모든 것이 문제 없이 동작할 때 잘 돌아가는 코드를 짜는 것은 아무나 할 수 있다는 점이다. 문제들이 생겨날 때가 바로 아키텍쳐와 엔지니어가 정말 중요해지는 시점이다. 우리가 만든 소프트웨어는 스트레스를 받지 않는다.[재방문] 스트레스를 받는 것은 우리다. 우리가 책임감을 가져야 한다.
예시로 돌아와서, 보낸 신호가 전달되었는지 정확히 아는 것이 중요하지는 않지만 신호가 확실히 전달되도록 해야 할 필요는 있다. 1개짜리 버퍼를 가진 채널은 거의 확실한 보장을 해준다. 왜냐하면 신호 보내기를 하고, 데이터를 집어넣고, 돌아섰다가, 다시 돌아왔을 때에 버퍼가 비워져 있는 것을 보기 때문이다. 이제 우리는 신호가 전달되었다는 것을 알 수 있다. 신호를 보냈을 시점에 즉시 알 수는 없지만 1개 짜리 버퍼를 씀으로써 돌아왔을 때 버퍼가 비어있다는 것은 알 수 있다.
그러고 나면 또다른 데이터 조각을 채널에 넣을 수 있다. 그리고 운이 좋다면 다시 돌아왔을 때 그 데이터는 사라져 있을 것이다. 만약 사라져 있지 않다면 문제다. 데이터를 받는 쪽에서 문제가 생긴 것이다.[재방문] 채널이 비워지기 전까지는 앞으로 나아갈 수 없다. 데이터가 왜 계속 머물러 있는지 알아야 하기 때문에 이런 문제는 즉시 보고해야 한다. 이것이 안정적인 시스템을 짓는 방법이다.
더 많은 일을 가져오지 말아야 한다.[재방문] 문제가 생겼을 때 데이터를 받는 쪽을 조사해야 하므로 시스템에 더 많은 부하를 주어서는 안된다. 우리가 책임질 수 없는 일에 더 많은 책임을 지어서는 안된다.
package main
import (
"fmt"
"time"
)
func main() {
"\n=> Basics of a send and receive\n")
fmt.Printf(
basicSendRecv()
"\n=> Close a channel to signal an event\n")
fmt.Printf(
signalClose() }
basicSendRecv는 신호 주기오 받기의 기본을 보여준다. make 함수는 채널을 만들 때 쓴다. make를 쓰지 않고서는 유용한 채널을 만드는 다른 방법은 없다. 채널은 우리가 신호에 담아 보낼 데이터의 타입에 기반한다. 이 경우에는 string을 썼다. 이 채널은 참조(reference) 타입이다. ch는 단지 수면 아래 있는 거대한 데이터 구조를 가리키는 포인터 변수이다.
func basicSendRecv() {
아래는 버퍼 없는 채널이다.
make(chan string)
ch :=
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”.
"hello"
ch <- }()
아래는 신호 받기다. 신호 받기 역시 화살표지만 채널 왼쪽에 붙어 있으며 단항 연산자다. 이는 데이터가 채널에서 나오고 있다는 것을 보여준다. 이제 우리는 신호 보내기와 받기가 함께 있어야 하는 버퍼가 없는 채널을 갖고 있다. 또한 신호 받기가 먼저 일어나기 때문에 우리는 신호가 전달되었다는 것을 알 수 있다. 신호 보내기와 받기 모두 둘 모두가 모여서 전달이 일어날 수 있기 전까지는 멈출(block) 것이다.
fmt.Println(<-ch) }
signalClose는 이벤트를 신호로 주기 위해 채널을 닫는(close) 법을 보여준다.
func signalClose() {
여기서는 빈 구조체(struct)를 써서 채널을 만든다. 이는 데이터가 없는 신호이다.
make(chan struct{}) ch :=
이제 작업을 하기 위해 고루틴을 시작해보자. 고루틴이 100 밀리초(millisecond)가 걸린다고 가정해보자. 고루틴이 일을 마쳤을 때 또다른 고루틴에게 신호를 주려고 한다. 일이 끝났다는 것을 데이터가 없이 알리기 위해 채널을 닫을 것이다. 버퍼가 있든 없든 채널을 만들 때 채널은 두 상태(state) 중 하나에 놓일 수 있다. 모든 채널은 열린(open) 상태에서 시작해서 우리는 데이터를 주고 받을 수 있다. 채널을 닫힌(closed) 상태로 변경하면 다시 열릴 수 없다. 또한 채널을 두번 닫을 수도 없다. 정합성(integrity) 문제 때문이다. 데이터를 두번 보내지 않고는 신호를 두번 보낼 수 없다.
go func() {
100 * time.Millisecond)
time.Sleep("signal event")
fmt.Println(close(ch)
}()
채널이 닫히면 신호를 받는 쪽은 즉시 반환(return)된다. 열려 있는 채널에서 신호를 받으려고 하면 데이터 신호를 받기 전까지 반환될 수 없다. 하지만 닫혀 있는 채널에서 신호를 받는다면 데이터 없이도 신호를 받을 수 있다. 우리는 이벤트가 일어났다는 것을 안다. 그 채널에 일어나는 모든 신호 받기는 즉시 반환될 것이다.
<-ch
"event received")
fmt.Println(
}
=> 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() {
"\n=> Double signal\n")
fmt.Printf(
signalAck()
"\n=> Select and receive\n")
fmt.Printf(
selectRecv()
"\n=> Select and send\n")
fmt.Printf(
selectSend()
"\n=> Select and drop\n")
fmt.Printf(
selectDrop() }
signalAck은 어떻게 이벤트를 신호로 줄 수 있고 처리가 끝났다는 승인(acknowledgement)을 기다릴 수 있는지 보여준다. 이는 신호가 전달되었다는 보장을 해줄 뿐만 아니라 작업이 끝난 때를 알 수 있다. 이는 이중 신호와 비슷하다.
func signalAck() {
make(chan string)
ch :=
go func() {
fmt.Println(<-ch)"ok done"
ch <- }()
이 고루틴은 신호가 전달되기 전까지 멈춘(block)다. 즉 우리가 신호를 받기 전까지 이 고루틴은 움질일 수 없다.
"do this"
ch <-
fmt.Println(<-ch) }
=> Double signal
do this
ok done
신호 고르기는 고루틴이 신호 보내기와 받기를 할 때 한번에 여러 채널을 다루는 방식이다. 이 방식은 이벤트 루프(event loop)를 만들 때 매우 쓸모 있지만 공유 상태(shared state)를 직렬화(serializing)하는 데는 별로 좋지 않다. selectRecv는 고르기(select) 문을 써서 값(value)을 받을 때까지 특정 시간만큼을 기다리는 방식을 보여준다.
func selectRecv() {
make(chan string) ch :=
특정 시간만큼을 기다리고 신호 보내기를 수행한다.
go func() {
200)) * time.Millisecond)
time.Sleep(time.Duration(rand.Intn("work"
ch <- }()
2개의 서로 다른 채널에서 2개의 서로 다른 신호를 받는다. 한 채널은 위에서 본 것이고 다른 한 채널은 시간을 재기 위해서다. time.After는 주어진 만큼의 시간이 지난 뒤 현재 시간 신호를 보내는 채널을 돌려준다. 우리는 작업을 처리하면서 보내는 신호를 받고 싶지만 끝없이 기다릴 생각은 없다. 우리는 100 밀리초만 기다리고 그 뒤에는 다음으로 넘어갈 것이다.
select {
case v := <-ch:
fmt.Println(v)case <-time.After(100 * time.Millisecond):
"timed out")
fmt.Println( }
하지만 이 코드에는 매우 흔한 버그(bug)가 있다. 우리가 앞으로 보게 될 가장 커다란 버그 중 하나는 위와 같은 코드를 짜고 고루틴에게 종료될 기회를 주지 않을 때 생긴다. 버퍼 없는 채널을 쓰고 있는데 이 고루틴은 언젠가는 기다리는 시간이 끝나고 신호 보내기를 하고 싶을 것이다. 하지만 이것은 버퍼 없는 채널이다. 신호 보내기는 대응하는 신호 받기가 없으면 완료될 수 없다. 만약 고루틴이 시간 초과되고 그 다음으로 넘어간다면 어떻게 될까? 대응하는 신호 받기가 없으므로 고루틴 누수(leak)가 생길 것이다. 다시 말해 이 고루틴은 결코 종료되지 않을 것이다.
이 버그를 고치는 가장 깔끔한 방법은 1개짜리 버퍼가 있는 채널을 쓰는 것이다. 신호 보내기가 일어날 때 신호가 전달되리라는 보장을 할 수는 없다. 하지만 우리는 이 보장이 필요 없다. 단지 신호를 보낼 필요가 있을 뿐이며 보내고 나서는 다음으로 넘어갈 수 있다. 그러므로 반대쪽에서 신호를 받거나 아니면 못 받고 그냥 넘어갈 것ㅇ다. 신호를 받지 못하더라도 신호 보내기가 일어나도록 신호를 담을 자리가 버퍼에 있기 때문에 이 신호 보내기는 여전히 완료된다.
=> Select and receive work
selectSend는 신호 보내기를 시도할 때 특정 시간 동안만 시도하기 위해 신호 고르기 문(select statement)을 어떻게 쓸 수 있는지를 보여준다.
func selectSend() {
make(chan string)
ch :=
go func() {
200)) * time.Millisecond)
time.Sleep(time.Duration(rand.Intn(
fmt.Println(<-ch)
}()
select {
case ch <- "work":
"send work")
fmt.Println(case <-time.After(100 * time.Millisecond):
"timed out")
fmt.Println( }
위 함수와 비슷하게 고루틴 누수는 일어날 것이다. 다시 한번, 1개짜리 버퍼 채널이 여기서 우리를 구해줄 것이다.
=> Select and send
work
send work
selectDrop은 신호 고르기(select)를 써서 채널이 즉시 막혔을(block) 때 넘어가는 방식을 보여준다.
이는 매우 중요한 패턴이다. 서버에 할일이 들이닥쳤거나 할일이 곧 오는 상황을 상상해보자. 서버가 일을 맡길 모듈은 제대로 동작하고 있지 않다.[재방문] 그렇다고 일을 그냥 쌓아놓을 수도 없다. 이 때 우리는 앞으로 계속 나아가기 위해서 일을 버려야 한다.
서비스 거부 공격(Denial-of-service attack)은 좋은 예시이다. 우리는 서버로 오는 수많은 요청을 받는다. 우리가 요청 하나하나 모두 처리하려고 든다면 아마 서버는 터지고 말 것이다. 우리는 처리할 수 있는 것은 처리하고 다른 요청은 버려야 한다.
이런 종류의 패턴(fanout)을 써서 우리는 몇몇 데이터를 버리려고 한다. 이를 위해 1개보다 큰 버퍼를 쓸 수 있다. 버퍼가 얼마나 커야 할지는 재보아야 한다. 아무렇게나 정할 수는 없다.
func selectDrop() {
make(chan int, 5)
ch :=
go func() {
여기서 우리는 신호 받기 루프(loop)에서 작업할 데이터가 오기를 기다리고 있다.
for v := range ch {
"recv", v)
fmt.Println(
} }()
아래 코드는 작업을 채널에 보낼 것이다. 버퍼가 다 차면, 버퍼는 막히게(block) 되고, 기본 케이스(default case)가 실행되며 작업을 버리게 된다.
for i := 0; i < 20; i++ {
select {
case ch <- i:
"send work", i)
fmt.Println(default:
"drop", i)
fmt.Println(
}
}
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() {
버퍼 없는 채널을 생성한다.
make(chan int) court :=
wg는 동시성을 관리하기 위해 쓰인다.
var wg sync.WaitGroup
2) wg.Add(
선수 둘을 입장시킨다. 둘 모두 받기 모드에서 시작할 것이다. 어떤 선수가 먼저 공을 받게 될지는 알 수 없다. 메인 고루틴을 심판이라고 생각하자. 누가 공을 먼저 받는지는 심판에게 달려 있다.
go func() {
"Hoanh", court)
player(
wg.Done()
}()
go func() {
"Andrew", court)
player(
wg.Done() }()
테니스 경기를 시작한다. 메인 고루틴이 신호 보내기를 한다. 선수 둘 모두 신호 받기 모드이므로 어느 쪽이 먼저 공을 받게 될지 알 수 없다.
1 court <-
경기가 끝날 때까지 기다린다.
wg.Wait() }
player는 테니스 경기를 하는 사람을 흉내낸다. 값 의미(value semantic)를 써서 채널 값을 요구한다.[재방문]
func player(name string, court chan int) {
for {
공이 다시 넘어올 때까지 기다린다. 이게 또다른 형태의 신호 받기라는 점을 놓치지 말자. 단순히 값을 받는 대신에 신호 받기가 어떻게 반환되었는지 나타내는 플래그(flag)를 받을 수 있다. 만약 신호가 데이터 때문에 생겼다면 ok는 true일 것이다. 만약 신호가 데이터 없이 생겼다면, 다른 말로 채널이 닫혔다면 ok는 false일 것이다. 이를 통해 누가 이겼는지 결정할 수 있다.
ball, ok := <-courtif !ok {
만약 채널이 닫혔다면 우리가 이긴 것이다.
"Player %s Won\n", name)
fmt.Printf(return
}
무작위 값을 하나 정해서 공을 놓쳤는지 (즉, 우리가 졌는지) 알아본다. 만약 경기에서 진다면 채널을 닫을 것이다. 그러면 반대편 player는 데이터 없이 신호를 받았다는 것을 알게될 것이다. 채널은 닫히고 반대편 player가 이긴다. player 둘 모두 반환한다.[재방문]
100)
n := rand.Intn(if n%13 == 0 {
"Player %s Missed\n", name) fmt.Printf(
우리가 졌다는 신호를 보내기 위해 채널을 닫는다.
close(court)
return
}
공을 친 횟수를 보여주고 하나 증가시킨다. 만약 위에서 말한 두가지 경우가 생기지 않는다면 경기는 아직 진행 중이다. 공의 값을 하나 증가시키고 신호 보내기를 한다. 반대편 player는 여전히 신호 받기 모드에 있다는 것을 우리는 안다. 그러므로 신호를 보내는 쪽과 받는 쪽은 결국 함께 모일 것이다. 다시 한번, 버퍼 없는 채널에서는 전달이 보장되기 때문에 신호 받기가 먼저 일어난다.
"Player %s Hit %d\n", name, ball)
fmt.Printf( ball++
공을 쳐서 상대 선수에게 다시 보낸다.
court <- ball
} }
Player Andrew Missed
Player Hoanh Won
이 프로그램은 고루틴 네 개 간의 이어 달리기를 흉내내기 위해 버퍼 없는 채널을 쓰는 방식을 보여준다. 달리기 선수 네 명이 트렉에 있다고 상상해보자. 한번에 한 명만 달릴 수 있으며 마지막 선수를 제외하고는 다음에 달릴 사람이 있다. 다음 선수는 앞 선수에 이어서 달릴 때까지 기다린다.
package main
import (
"fmt"
"sync"
"time"
)
wg는 프로그램이 끝나기까지 기다리기 위해 쓰인다. var wg sync.WaitGroup
func main() {
버퍼 없는 채널을 생성한다.
make(chan int) track :=
마지막 선수를 위해 값이 1인 횟수를 더한다.[재방문] 우리가 관심 있는 건 마지막 선수가 우리에게 끝났다고 알려주는 것뿐이기 때문에 1만 더한다.
1) wg.Add(
첫번째 선수를 출발선 상에 생성한다.[재방문]
go Runner(track)
메인 고루틴이 달리기를 시작한다. (신호총을 쏜다) 이 순간에 우리는 다른 쪽에서는 고루틴이 신호 받기를 하고 있다는 것을 알고 있다.
1 track <-
달리기가 끝나기를 기다린다.
wg.Wait() }
Runner는 이어 달리기에서 달리는 사람을 흉내낸다. Runner는 처음부터 끝까지 모든 일을 하고 종료될 것이기 때문에 루프(loop)를 갖고 있지 않다. 우리는 이 패턴이 동작하게 하기 위해 고루틴 Runner를 계속 더할 것이다.
func Runner(track chan int) {
바통이 넘겨진 횟수다.
const maxExchanges = 4
var exchange int
데이터와 함께 바통을 받을 때까지 기다린다.
baton := <-track
트렉을 달리기 시작한다.
"Runner %d Running With Baton\n", baton) fmt.Printf(
출발선에 있는 새로운 선수다. 이 선수가 달리기 마지막 선수인가? 아니라면 몇번째 선수인지 기록하기 위해 데이터에 1을 더한다. 우리는 고루틴을 하나 더 만들 것이다. 새로운 고루틴은 즉시 신호 받기 모드에 돌입할 것이다. 이제 트렉에 두번째 고루틴이 있으며 바통을 받기를 기다리고 있다. (1)
if baton < maxExchanges {
1
exchange = baton + "Runner %d To The Line\n", exchange)
fmt.Printf(go Runner(track)
}
트렉을 한바퀴 돈다.
100 * time.Millisecond) time.Sleep(
경기가 끝났는가.
if baton == maxExchanges {
"Runner %d Finished, Race Over\n", baton)
fmt.Printf(
wg.Done()return
}
다음 선수에게 바통을 넘겨준다.
"Runner %d Exchange With Runner %d\n", baton, exchange) fmt.Printf(
마지막 선수가 아니기 때문에 (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 {
int
id string
op error
err
}
func init() {
rand.Seed(time.Now().UnixNano())
}
func main() {
고루틴 수와 연산 수를 설정한다.
const routines = 10
const inserts = routines * 2
어떤 입력에 대해서든 정보를 받기 위해 버퍼 있는 채널을 연다.
make(chan result, inserts) ch :=
우리가 처리해야 할 응답의 개수이다. 이 고루틴은 자신의 스택 공간(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
결과를 보여준다.
"N: %d ID: %d OP: %s ERR: %v", waitInserts, r.id, r.op, r.err) log.Printf(
waitInserts를 줄이고 일이 끝났는지 확인한다.
waitInserts--
}"Inserts Complete")
log.Println( }
insertUser는 데이터베이스 작업을 흉내낸다.
func insertUser(id int) result {
r := result{
id: id,"insert USERS value (%d)", id),
op: fmt.Sprintf( }
입력이 실패했는지 아닌지 무작위로 결정한다.
if rand.Intn(10) == 0 {
"Unable to insert %d into USER table", id)
r.err = fmt.Errorf(
}return r
}
insertTrans도 데이터베이스 작업을 흉내낸다.
func insertTrans(id int) result {
r := result{
id: id,"insert TRANS value (%d)", id),
op: fmt.Sprintf( }
입력이 실패했는지 아닌지 무작위로 결정한다.
if rand.Intn(10) == 0 {
"Unable to insert %d into USER table", id)
r.err = fmt.Errorf(
}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을 보낼 것이다.
make(chan os.Signal, 1) sigChan =
timeout은 프로그램이 돌 수 있는 시간을 제한한다. 이 채널에서 신호를 받는 일이 없었으면 좋겠다. 이 채널에서 신호를 받으면 뭔가 안 좋은 일이 생겼다는 뜻이고, 시간 초과가 일어나고, 프로그램을 종료시켜야 하기 때문이다.
timeout = time.After(timeoutSeconds)
complete는 처리가 끝났다는 것을 알리기 위해 쓴다. 이 채널이 우리가 신호를 받고 싶은 채널이다. 고루틴이 작업을 끝냈을 때 complete 채널을 통해 우리에게 신호를 줄 것이다. 어떤 에러가 일어났는지도 이 채널을 통해 알려줄 것이다.
make(chan error) complete =
shutdown은 시스템 전체 알림을 주기 위해 쓴다.
make(chan struct{})
shutdown =
)
func main() {
"Starting Process") log.Println(
우리는 인터럽트(interrupt) 관련 신호를 전부 받으려고 한다. signal 패키지에 있는 Notify 함수를 쓰면서 sigChan을 파라미터로 넘겨줄 것이다. 이는 sigChan 채널에게 os.Interrupt와 관련 있는 어떤 신호든지 보이면 우리에게 데이터 신호를 보내라고 말하는 것이다. 이 API에서 중요한 점은 우리가 신호를 받을 준비가 되어 있을 때까지 기다리지 않는다는 점이다. 우리가 신호를 받지 못한다면 신호는 그냥 바닥에 버려질 것이다. 여기서 1개짜리 버퍼가 있는 채널을 쓰는 이유가 바로 이것이다. 이것이 적어도 신호 1개를 받는 것을 보장받는 방법이다. 이 신호에 맞추어 행동할 준비가 되어 있을 때 우리는 신호를 받고 행동할 것이다.
signal.Notify(sigChan, os.Interrupt)
프로세스를 시작한다.
"Launching Processors") log.Println(
아래 고루틴이 예컨대 이미지 처리 같은 처리를 할 것이다.
go processor(complete)
여기 있는 메인 고루틴은 이벤트 루프 안에 있고 프로그램이 종료될 때까지 무한히 루프를 돌 것이다. 고르기(select)에는 세가지 케이스가 있는데 이는 우리가 신호를 받으려고 하는 채널이 동시에 3개 있다는 뜻이다. sigChan과 timeout, complete가 있다.
ControlLoop:for {
select {
case <-sigChan:
운영 체제에서 보낸 인터럽트(interrupt) 이벤트 신호이다.
"OS INTERRUPT") log.Println(
프로세서에게 종료하라는 신호를 보내기 위해 채널을 닫는다.
close(shutdown)
이런 이벤트를 더이상 처리하지 않기 위해 채널을 nil로 설정한다.
닫힌 채널에 신호를 계속 보내려고 하면 패닉(panic)이 일어날 것이다. 닫힌 채널에서 신호를 받으려고 하면 즉시 데이터 없는 신호를 돌려받는다. nil 채널에서 신호를 받으려고 하면 영원히 막힐(block) 것이다. 신호 보내기도 비슷하다. 이렇게 하는 이유가 뭘까?
우리는 유저가 Ctrl C를 누르고 있거나 Ctrl C을 여러번 누르기를 바라지 않는다. 만약 유저가 그렇게 한다면 우리는 그 신호를 처리하고 close를 여러번 호출해야 한다. 이미 닫힌 채널에 close를 호출하면 코드는 패닉한다. 그러므로, 이런 상황에 놓이지 않기 위해 채널을 nil로 설정한다.
nil
sigChan = case <-timeout:
시간을 너무 많이 썼다. 어플리케이션을 종료시키자.
"Timeout - Killing Program")
log.Println(
os.Exit will terminate the program immediately.1)
os.Exit(case err := <-complete:
아래는 주어진 시간 내에 완료된 모든 것이다.
"Task Completed: Error[%s]", err) log.Printf(
여기서 우리는 레이블(label) break를 쓴다. case가 break할 수 있고 for가 break할 수 있게 하기 위해 for 루프의 맨 위에 레이블을 놓는다.
break ControlLoop
}
}
"Process Ended")
log.Println( }
processor는 프로그램의 메인 로직을 담당한다. 이 파라미터에는 재밌는 부분이 있다. chan 키워드 오른쪽에 화살표가 있다. 이것은 채널이 신호 보내기 전용이라는 뜻이다. 이 채널에서 신호를 받으려고 하면 컴파일러는 에러를 뱉을 것이다.
func processor(complete chan<- error) {
"Processor - Starting") log.Println(
어떤 에러가 일어나든 에러를 저장할 변수를 만든다. 클로저(closure)를 써서 defer 함수에 넘겨진다.
var err error
함수가 어떻게 끝났는지 상관없이 채널에 신호를 보내기 위해 신호 보내기를 지연(defer)시킨다. 이는 고루틴에서 보았던 것처럼 익명(anonymous) 함수 호출이다. 하지만 여기서 우리는 키워드 defer를 쓴다.
이 함수를 실행시키고 싶지만 processor가 끝난 후에 실행시키고 싶다. 이 방식은 함수를 호출한 쪽에 컨트롤이 넘어가기 전에 정해진 일이 확실히 일어난다는 보장을 해준다.
또한 defer는 패닉을 멈출 수 있는 유일한 방법이다. 안 좋은 일, 예컨대 이미지 라이브러리가 터지는 일이 생긴다면 코드 전체에 걸쳐 패닉 상황을 일으킬 것이다. 이 경우에 우리는 패닉에서 회복(recover)하고, 패닉을 멈추고, 종료를 컨트롤 하고 싶다.
defer func() {
어떤 패닉 상황이든 잡아낸다.
if r := recover(); r != nil {
"Processor - Panic", r)
log.Println( }
고루틴에 종료해야 한다고 알려준다.
complete <- err }()
작업을 수행한다.
err = doWork()"Processor - Completed")
log.Println( }
doWork는 작업을 흉내낸다. 모든 호출 사이에 우리는 checkShutdown을 호출한다. 모든 작업을 마친 후에는 다음 질문을 한다. “종료하라는 말을 들은 적이 있는가?” 이것을 아는 유일한 방법은 shutdown 채널이 닫혔는지 보는 것이다. shutdown 채널이 닫혔는지 아는 유일한 방법은 그 채널에서 신호 받기를 해보는 것이다. 닫히지 않은 채널에서 신호 받기를 하면 막힐(block) 것이다. 하지만 기본 케이스(default case)가 우리를 구해줄 것이다.
func doWork() error {
"Processor - Task 1")
log.Println(2 * time.Second)
time.Sleep(
if checkShutdown() {
return errors.New("Early Shutdown")
}
"Processor - Task 2")
log.Println(1 * time.Second)
time.Sleep(
if checkShutdown() {
return errors.New("Early Shutdown")
}
"Processor - Task 3")
log.Println(1 * time.Second)
time.Sleep(
return nil
}
checkShutdown은 종료 플래그를 확인해서 처리를 중단하도록 요청받은 적이 있는지 결정한다.
func checkShutdown() bool {
select {
case <-shutdown:
여기서 우리는 깔끔하게 프로그램을 종료하도록 요청받았다.
"checkShutdown - Shutdown Early")
log.Println(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
context
패키지는 취소(cancellation)와 데드라인(deadline)을 지원한다.
package main
import (
"context"
"fmt"
)
user
는 context 내에 값을 저장하기 위한 타입이다.
type user struct {
string
name }
userKey
는 user
의 값에 대한 키(key) 타입이다. 키는 하나의 타입이고, 동일한 타입의 값만 매치할 수 있다. context 에 값을 저장하면, 그 값의 타입도 저장된다. 값을 추출하려면, context 안의 값의 타입을 알아야만 한다. userKey
타입과 같은 아이디어는 context에 값을 저장하는 경우, 생각보다 상당히 중요한 개념이다.
type userKey int
func main()
user
타입의 값을 생성한다.
u := user {"Hoanh",
name: }
키를 제로값으로 선언한다.
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 {
"User", u.name)
fmt.Println( }
다른 타입을 가지고 위의 값을 검색해보려고 시도해 보자. 비록 키의 실제 값이 0
이지만, 이 함수 호출에 0
을 전달한다 해도 원하고자 하는 user
의 주소를 얻을 수 없을 것이다. 0
은 정수 타입이지, 우리가 정의한 userKey
타입이 아니기 때문이다.
context에 값을 저장 할 떄, built-in 타입을 사용하지 않는 것이 중요하다.
사용자가 정의한 타입의 키를 사용하자. 그러면 이 타입을 알아야만 context에서 값을 꺼낼 수 있다. 만약, 여러 프로그램이 숫자 0
키값을 사용해 user
를 추출한다면, 모든 것이 엉망이 되어버릴 수 있다. 사용자 정의 타입은 context에 값을 저장하고 추출할 때에 추가적인 보호를 해준다. 구체적인 타입을 사용하지 않으면 매 호출 시 왜 이런 식인지 계속 확인하게 만들기 때문에 잘못된 것을 알 수 있다. 따라서, 구체적인 타입을 사용하는 것이 향후 레거시 코드의 가독성과 유지관리에 훨씬 더 유리할 것이다.
if _, ok := ctx.Value(0).(*user); !ok {
"User Not Found")
fmt.Println( }
User Hoanh
User Not Found
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 정도의 시간이 소요된다면, 계속 진행하고자 한다.
50 * time.Millisecond) time.Sleep(
작업이 종료됨을 보고하자.
cancel() }()
해당 채널을 만든 원래의 고루틴은 select case
구문에 있다. time.After
이후, 값을 전달받게 될 것이다. 100ms 동안 대기하거나 context.Done
이 완료되기를 기다린다. 이렇게 계속 기다리다 Done
을 받게 된다면, 해당 작업이 완료되었음을 알 수 있다.
select {
case <-time.After(100 * time.Millisecond):
"moving on")
fmt.Println(case <-ctx.Done():
"work complete")
fmt.Println(
} }
work complete
package main
import (
"context"
"fmt"
"time"
)
type data struct {
string
UserID
}
func main() {
데드라인을 설정한다.
150 * time.Millisecond) deadline := time.Now().Add(
수동으로 취소 가능하거나 특정 날짜/시간에 취소 신호를 보낼 수 있는 컨텍스트를 생성한다. Background
를 부모 컨텍스트로 사용하고 데드라인 시간을 설정한다.
ctx, cancel := context.WithDeadline(context.Background(), deadline)defer cancel()
작업 종료의 신호를 수신할 수 있는 채널을 생성한다.
make(chan data, 1) ch :=
고루틴에 작업을 하도록 요청한다.
go func() {
작업을 시뮬레이션 한다.
200 * time.Millisecond) time.Sleep(
작업이 끝났음을 알려준다.
"123"}
ch <- data{ }()
작업이 끝나기를 기다린다. 만약 시간이 많이 걸릴 경우 취소를 이행한다.
select {
case d := <-ch:
"work complete", d)
fmt.Println(case <-ctx.Done():
"work cancelled")
fmt.Println(
} }
work cancelled
package main
import (
"context"
"fmt"
"time"
)
type data struct {
string
UserID
}
func main (){
기간을 설정한다.
150 * time.Millisecond duration :=
수동으로 취소 가능하거나 특정 기간에 취소 신호를 보낼 수 있는 컨텍스트를 생성한다.
ctx, cancel := context.WithTimeout(context.Background(), duration)defer cancel()
작업 종료의 신호를 수신할 수 있는 채널을 생성한다.
make(chan data, 1) ch :=
고루틴에 작업을 하도록 요청한다.
go func() {
작업을 시뮬레이션 한다.
50 * time.Millisecond) time.Sleep(
작업이 끝났음을 알려준다.
"123"}
ch <- data{ }()
작업이 끝나기를 기다린다. 만약 시간이 많이 걸릴 경우 취소를 이행한다.
select {
case d := <-ch:
"work complete", d)
fmt.Println(case <-ctx.Done():
"work cancelled")
fmt.Println(
} }
work complete {123}
리퀘스트가 너무 오래 걸리는 경우 타임아웃에 사용되는, 컨텍스트를 이용한 뤱 리퀘스트를 구현한 프로그램이다.
package main
import (
"context"
"io"
"log"
"net"
"net/http"
"os"
"time"
)
func main() {
새로운 리퀘스트를 생성한다.
"GET", "https://www.ardanlabs.com/blog/post/index.xml", nil)
req, err := http.NewRequest(if err != nil {
log.Println(err)return
}
제한시간이 50ms인 컨텍스트를 생성한다.
50 * time.Millisecond)
ctx, cancel := context.WithTimeout(req.Context(), defer cancel()
호출에 대한 새로운 Transport와 클라이언트를 선언한다.
tr := http.Transport {
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.DialerP{30 * time.Second,
Timeout: 30 * time.Second,
Timeout: true,
DualStack:
}).DialContext,100,
MaxIdleConns: 90 * time.Second,
IdleConnTimeout: 10 * time.Second,
TLSHandshakeTimeout: 1 * time.Second,
ExpectContinueTimeout:
}
client := http.Client{
Transport: &tr, }
취소 가능하도록 별도의 고루틴으로 웹 호출을 생성한다.
make(chan error, 1)
ch := go func() {
"Starting Request") log.Println(
웹 호출을 수행하고 error.Client.Do
에서 얻는 것을 리턴하고 리퀘스트 URL을 호출하려고 해본다. 전체 문서가 돌아올 때 까지 대기해야 하기 때문에 지금 당장은 블록될 것이다.
resp, err := client.Do(req)
오류가 발생하면, 채널에서 작업이 완료되었음을 보고한다. 어떤 시점에서 어떤 일이 일어났는지 보고하기 위해서 채널을 사용할 것이다.
if err != nil {
ch <- errreturn
}
실패하지 않는다면, 리턴할 때에 respose body를 닫는다.
defer resp.Body.Close()
stdout
에 respose를 작성한다.
io.Copy(os.Stdout, resp.Body)
그리고, error
대신에 nil
을 돌려보낸다.
nil
ch <- }()
리퀘스트 혹은 타임아웃을 기다린다. ctx.Done()
에 대한 수신을 수행해서 위의 전체 프로세스가 일어날 때 까지 50ms을 기다린다. 그렇지 않은 경우, 리퀘스트 취소 신호를 고루틴으로 보낸다. 이것이 필요로 하지 않기 때문에, 자원을 소비하도록 내버려 둘 필요가 없다. CancelRequest
를 호풀 할 수 있으며, 바로 아래에서 커넥션을 종료할 수 있다.
select {
case <-ctx.Done():
"timeout, cancel work...")
log.Println(
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 (
"\u2713"
succeed = "\u2717"
failed = )
TestBasic
은 http.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) {
"https://www.google.com/"
url := 200
statusCode :=
"Given the need to test downloading content.")
t.Log(
{"\tTest 0:\tWhen checking %q for status code %d", url, statusCode)
t.Logf(
{
resp, err := http.Get(url)if err != nil {
"\t%s\tShould be able to make the Get call : %v", failed, err)
t.Fatalf(
}"\t%s\tShould be able to make the Get call.", succeed)
t.Logf(
defer resp.Body.Close()
if resp.StatusCode == statusCode {
"\t%s\tShould receive a %d status code.", succeed, statusCode)
t.Logf(else {
} "\t%s\tShould receive a %d status code : %d", failed, statusCode, resp.StatusCode)
t.Errorf(
}
}
} }
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
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.
struct {
tests := []string
url int
statusCode
}{"https://www.google.com", http.StatusOK},
{"http://rss.cnn.com/rss/cnn_topstorie.rss", http:StatusNotFound},
{
}
"Given the need to test downloading different content.")
t.Log(
{for i, tt := ragne tests {
"\tTest: %d\tWhen checking %q for status code %d", i, tt.url, tt.statusCode)
t.Logf(
{
resp, err := http.Get(tt.url)if err != nil{
"\t%s\tShould be able to make the Get call : %v", failed, err)
t.Fatalf(
}"\t%s\tShould be able to make the Get call.", succeed)
t.Logf(
defer resp.Body.Close()
if resp.StatusCode == tt.statusCode {
"\t%s\tSould receive a %d status code.", succeed, tt.statusCode)
t.Logf(else {
} "\t%s\tShould receive a %d status code : %v", failed, tt.statusCode, resp.StatusCode)
t.Errorf(
}
}
}
} }
=== 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 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) {
struct {
tests := []string
name string
url int
statusCode
}{"statusok", "https://www.google.com/", http.StatusOK},
{"statusnotfound", "http://rss.cnn.com/rss/cnn_topstorie.rss", http.StatusNotFound},
{
}"Given the need to test downloading different content.")
t.Log( {
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 {
func(t *testing.T) {
tf := "\tTest: %d\tWhen checking %q for status code %d", i, tt.url, tt.statusCode)
t.Logf(
{
resp, err := http.Get(tt.url)if err != nil {
"\t%s\tShould be able to make the Get call : %v", failed, err)
t.Fatalf(
}"\t%s\tShould be able to make the Get call.", succeed)
t.Logf(
defer resp.Body.Close()
if resp.StatusCode == tt.statusCode {
"\t%s\tShould receive a %d status code.", succeed, tt.statusCode)
t.Logf(else {
} "\t%s\tShould receive a %d status code : %v", failed, tt.statusCode, resp.StatusCode)
t.Errorf(
}
} }
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) {
struct {
tests := []string
name string
url int
statusCode
}{"statusok", "https://www.goinggo.net/post/index.xml", http.StatusOK},
{"statusnotfound", "http://rss.cnn.com/rss/cnn_topstorie.rss", http.StatusNotFound},
{
}
"Given the need to test downloading different content.")
t.Log(
{for i, tt := range tests {
func(t *testing.T) { tf :=
The only difference here is that we call Parallel function inside each of these individual sub test functions.
t.Parallel()
"\tTest: %d\tWhen checking %q for status code %d", i, tt.url, tt.statusCode)
t.Logf(
{
resp, err := http.Get(tt.url)if err != nil {
"\t%s\tShould be able to make the Get call : %v", failed, err)
t.Fatalf(
}"\t%s\tShould be able to make the Get call.", succeed)
t.Logf(
defer resp.Body.Close()
if resp.StatusCode == tt.statusCode {
"\t%s\tShould receive a %d status code.", succeed, tt.statusCode)
t.Logf(else {
} "\t%s\tShould receive a %d status code : %v", failed, tt.statusCode, resp.StatusCode)
t.Errorf(
}
}
}
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
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()
"listener : Started : Listening on: http://localhost:4000")
log.Println(":4000", nil)
http.ListenAndServe( }
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() {
"/sendjson", SendJSON)
http.HandleFunc( }
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) {
struct {
u := string
Name string
Email
}{"Hoanh An",
Name: "hoanhan101@gmail.com",
Email:
}
"Content-Type", "application/json")
rw.Header().Set(200)
rw.WriteHeader(
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() {
"GET", "/sendjson", nil)
r := httptest.NewRequest(
w := httptest.NewRecorder()
http.DefaultServeMux.ServeHTTP(w, r)
var u struct {
string
Name string
Email
}
if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
"ERROR:", err)
log.Println(
}
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 (
"\u2713"
succeed = "\u2717"
failed = )
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) {
"/sendjson"
url := 200
statusCode :=
"Given the need to test the SendJSON endpoint.")
t.Log( {
Create a nil request GET for the URL.
"GET", url, nil) r := httptest.NewRequest(
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)
"\tTest 0:\tWhen checking %q for status code %d", url, statusCode)
t.Logf(
{if w.Code != 200 {
"\t%s\tShould receive a status code of %d for the response. Received[%d]", failed, statusCode, w.Code)
t.Fatalf(
}"\t%s\tShould receive a status code of %d for the response.", succeed, statusCode) t.Logf(
If we got the 200, we try to unmarshal and validate it.
var u struct {
string
Name string
Email
}
if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
"\t%s\tShould be able to decode the response.", failed)
t.Fatalf(
}"\t%s\tShould be able to decode the response.", succeed)
t.Logf(
if u.Name == "Hoanh An" {
"\t%s\tShould have \"Hoanh An\" for Name in the response.", succeed)
t.Logf(else {
} "\t%s\tShould have \"Hoanh An\" for Name in the response : %q", failed, u.Name)
t.Errorf(
}
if u.Email == "hoanhan101@gmail.com" {
"\t%s\tShould have \"hoanhan101@gmail.com\" for Email in the response.", succeed)
t.Logf(else {
} "\t%s\tShould have \"hoanhan101@gmail.com\" for Email in the response : %q", failed, u.Email)
t.Errorf(
}
}
} }
=== 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
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 {
`xml:"item"`
XMLName xml.Name string `xml:"title"`
Title string `xml:"description"`
Description string `xml:"link"`
Link }
Channel defince the fields associated with the channel tag in the mock RSS document
type Channel struct {
`xml:"channel"`
XMLName xml.Name string `xml:"title"`
Title string `xml:"description"`
Description string `xml:"link"`
Link string `xml:"pubDate"`
PubDate `xml:"item"`
Items []Item }
Document defines the fields associated with teh mock RSS document.
type Document struct {
`xml:"rss"`
XMLName xml.Name `xml:"channel"`
Channel Channel string
URI }
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 {
func(w http.ResponseWriter, r *http.Request) {
f := 200)
w.WriteHeader("Content-Type", "application/xml")
w.Header().Set(
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.
"Given the need to test downloading content.")
t.Log(
{"\tTest 0:\tWhen checking %q for status code %d", server.URL, statusCode)
t.Logf(
{
resp, err := http.Get(server.URL)if err != nil {
"\t%s\tShould be able to make the Get call : %v", failed, err)
t.Fatalf(
}"\t%s\tShould be able to make the Get call.", succeed)
t.Logf(
defer resp.Body.Close()
if resp.StatusCode != statusCode {
"\t%s\tShould receive a %d status code : %v", failed, statusCode, resp.StatusCode)
t.Fatalf(
}"\t%s\tShould receive a %d status code.", succeed, statusCode) t.Logf(
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%s\tShould be able to unmarshal the response : %v", failed, err)
t.Fatalf(
}"\t%s\tShould be able to unmarshal the response.", succeed)
t.Logf(
if len(d.Channel.Items) == 1 {
"\t%s\tShould have 1 item in the feed.", succeed)
t.Logf(else {
} "\t%s\tShould have 1 item in the feed : %d", failed, len(d.Channel.Items))
t.Errorf(
}
}
} }
=== 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
벤치마크 함수가 있는 <file_name>_test.go
파일로 벤치마크를 할 수 있다. Sprint
와 Sprintf
중 어느 것이 더 성능이 좋고, 효율적으로 자원을 할당하는지 벤치마크 해볼 텐데, Sprint
가 문자열 포맷을 하는 동안 오버헤드가 없기 때문에 더 나을 것으로 보이지만 그렇지 않다. 추측보다는 실제 확인한 결과로 판단하자.
package main
import (
"fmt"
"testing"
)
var gs string
BenchmarkSprintBasic
은 Sprint
성능을 테스트한다. 벤치마크 하려는 모든 코드는 b.N
반복문 안에 있어야 한다. 벤치마크 도구가 처음 호출 할 때 b.N
은 1이고, 충분히 테스트가 진행될 때 까지 b.N
값은 지속적으로 증가한다. 전역변수 gs
에 fmt.Sprint
의 값을 반환하기 때문에 dead code 처럼 보이지 않게 된다.
func BenchmarkSprintBasic(b *testing.B) {
var s string
for i := 0; i < b.N; i++ {
"hello")
s = fmt.Sprint(
}
gs = s }
BenchmarkSprintfBasic
은 Sprintf
의 성능을 테스트한다.
func BenchmarkSprintfBasic(b *testing.B) {
var s string
for i := 0; i < b.N; i++ {
"hello")
s = fmt.Sprintf(
}
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"
)
BenchmarkSprintSub
는 Sprint
와 관련한 모든 하위 벤치마크를 한다.
func BenchmarkSprintSub(b *testing.B) {
"none", benchSprint)
b.Run("format", benchSprintf)
b.Run( }
benchSprint
는 Sprint
의 성능을 테스트한다.
func benchSprint(b *testing.B) {
var s string
for i := 0; i < b.N; i++ {
"hello")
s = fmt.Sprint(
}
gs = s }
benchSprintf
는 Sprintf
의 성능을 테스트한다.
func benchSprintf(b *testing.B) {
var s string
for i := 0; i < b.N; i++ {
"hello")
s = fmt.Sprintf(
}
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
하위 벤치마크를 할 수 있는 다른 방법들:
go test -run none -bench BenchmarkSprintSub/none -benchtime 3s -benchmem
go test -run none -bench BenchmarkSprintSub/format -benchtime 3s -benchmem
스택 트레이스 리뷰(Review Stack Trace)
어떻게 스택 트레이스를 할 수 있을까?
package main
func main() {
이 예제는 배열, 문자열, 정수를 사용한다. 길이 2, 용량 4의 배열을 만들고 그 배열 값을 예제 함수에 전달한다.
make([]string, 2, 4), "hello", 10)
example( }
예제는 내장 함수 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() {
true, false, true, 25)
example(
}
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 |
메모리 트레이싱
메모리 트레이싱은 코드가 실행될 때 GC 및 힙 메모리에 관련해서 잘 작동하는지 분석을 제공한다. 다음은 메모리 릭을 일으키는 예제이다.
package main
import (
"os"
"os/signal"
)
func main() {
아래 코드는 고루틴을 생성할 때 메모리 릭을 일으킨다. 코드가 종료될 때 까지 계속 Key-value를 할당한다.
go func() {
make(map[int]int)
m := for i := 0; ; i++ {
m[i] = i
} }()
Ctrl-C 로 코드를 종료한다.
make(chan os.Signal, 1)
sig :=
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/ 에 자유롭게 방문해주시길 바랍니다.
읽어주셔서 고맙습니다. 행운을 빕니다!