본문 바로가기
Programming Language/Go

[1] Golang 개념 및 기초

by Riverandeye 2020. 8. 20.

해당 게시물은 이 책공식 블로그를 참고하여 작성되었습니다. 

개인 학습 목적으로 작성되어 제가 필요하고 되짚어야 할 부분만 기록했기 때문에 생략이 많습니다.

모든 것을 다 정리하지는 않고, 정말 기초가 되는 부분들만 작성하였습니다. 

 

The Go Project



Go Project는 소프트웨어의 복잡도를 극복하기 위해 설계된 언어이다. 소프트웨어의 복잡도는 배로 늘어나서, 문제를 해결하기 위해 시스템의 복잡도를 높이면 다른 영역에도 영향을 미치게 된다. 문제를 해결하고 설정을 더하는 동안 단순성을 간과할 수 있는데, 길게 보면 단순한 것 만큼 소프트웨어의 좋은 해결책이 없다.

 

소프트웨어가 단순해지려면 작업들이 프로젝트의 핵심 아이디어에 집중해야 하며, 프로젝트가 변모하는 방향성을 올바르게 파악해야 한다. 좋은 변화는 개념적으로 완전한 설계를 기반하며, 설계의 단순함이 안정적이고 일관성 있는 소프트웨어를 제공한다. 

 

Golang은 그 자체로 simplicity를 추구하며 왠만하면 편의를 제공하는 것들이 있을 건 다 있다. 

- 가비지 컬렉션

- 패키징 시스템

- 일급 함수 (first-class function)

- 정적 스코프

- 시스템 콜 인터페이스

- 불변 스트링 (UTF-8 encoding)

 

대신 없는 것들도 있다. (쓰고보니 없는게 정말 많다)

- 암시적 숫자 형변환 (implicit numeric conversion)

- 생성자, 소멸자

- 연산자 오버로딩

- 기본값 매개변수 (!)

- 상속 

- 제네릭 (은 2021년에 추가될 것으로 보인다)

- 익셉션

- 매크로

- 함수 어노테이션

- 쓰레드 로컬 스토리지 

 

Go의 타입 시스템은 동적 언어의 단점들을 보강하면서, C++과 같은 일반적인 강타입 언어보단 단순하여

덜 복잡한 방식으로 safety와 run-time performance 를 제공한다. 

Go는 locality의 중요성을 강조하여, built-in data type과 library data structure들이 명시적인 초기화와 생성자를 통해 작동하지 않기 때문에, 메모리 할당과 입력 작업이 상대적으로 적다. Aggregate type (구조체, 배열)들은 element를 직접 가지고 있어 allocation이 덜하고, CSP를 기반한 동시성 관리를 해준다

Go의 라이브러리는 I/O, 문자열 처리, 그래픽스, 암호, 네트워킹, 분산 어플리케이션, 프로토콜 및 파일 포맷에 관해 방대하게 그 기능들이 내장되어있다. 

 

1. Tutorial

이 책을 읽으면서 다른 프로그래밍 언어를 사용할때의 방식이 아닌, 좋은 Go 프로그램을 작성하는 방법을 따르는 걸 권장함.

 

1.1 Hello world

package main

import "fmt"

func main(){
	fmt.Println("Hello, 世界")
}

Go Toolchain은 코드를 native machine language로 변환해주고, cli의 go subcommand들로 접근이 쉽게 가능하다. 

go run main.go 를 통해 main.go 파일에 작성된 코드를 라이브러리와 링크해주고 컴파일 후 executable을 실행해준다. 

go는 내부적으로 Unicode를 적용해주기 때문에 전 세계의 모든 언어를 처리할 수 있다.

 

Go code는 패키지로 구조화 되어있으며, 하나의 폴더 내 go 프로그램들이 패키지를 정의한다. 

모든 소스코드는 먼저 패키지를 선언하고, 다른 패키지를 import 한 후 프로그램이 실행된다. 

 

Package main은 좀 특별하다. 프로그램 실행 지점을 의미하며, 라이브러리가 아니다. 

Package main안의 main함수가 프로그램이 시작하는 지점이 된다. 

 

사용되지 않는 패키지를 import할 시 컴파일 되지 않는다. (오..)

이 부분에 있어서 import 선언을 확인하고 변경해주는 goimports 라는 도구를 사용하면 편의성이 높아진다. 

 

언어에 대한 포메터가 내장되어 있어 이를 이용해서 자동으로 포메팅할 수 있다. 

gofmt -w main.go

언어에 지정된 포메터가 있다는 것은, 불필요한 언쟁으로 시간을 소모할 필요가 없다는 뜻이 된다. (너무 좋다...)

관련하여 자세한 사항은 이 블로그에 잘 나와있어, 적용하였더니 정말 편하다. 

 

1.2 Command Line Arguments

Command Line Arguments는 os 패키지의 os.Args를 이용하여 전달받는다. 

 

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println("Hello, 世界")

	var s, sep string
	sep = " "
	for i := 1; i < len(os.Args); i++ {
		s += os.Args[i] + sep
	}
	fmt.Println(s)
}

 

단순한 Cli를 입력받는 프로그램이다. 결과는 다음과 같다.

 

 

빌드 후 executable에 인자를 넣으면 쉽게 구성할 수 있다. 

 

연산자를 이용해서 string concatenation을 할 수 있다. 그치만 string은 immutable이기 때문에 매번 새로 생성하는건 비효율적이니, 가급적이면 strings 패키지의 Join 메소드를 이용해서 여러 string을 연결하는 것이 바람직하다. 

 

기한건 loop를 제공하는 문법이 for문 뿐이라는 것인데, 이 for 하나로 다 해먹는다. 

 

이런 방식으로 작성하면 전형적인 for문이고,

for 뒤에 condition을 붙이면 while문이 되고

 



아무것도 안 붙이면 infinite loop이 된다

 

배열을 iterate하는 또다른 방법은 range를 이용하는 것인데, 파이썬의 enumerate와 비슷하게 동작한다. 

func main() {
	s, sep := "", ""
	for _, arg := range os.Args[1:] {
		s += sep + arg
		sep = " "
	}
	fmt.Println(s)
}

앞의 값에 index가 주어지는데, 당장 필요는 없으니 blank identifier를 적용한다. 

 

1.3 Finding Duplicate Lines

func main() {
	counts := make(map[string]int)
	for _, filename := range os.Args[1:] {
		data, err := ioutil.ReadFile(filename)
		if err != nil {
			fmt.Fprintf(os.Stderr, "dup3: %v\n", err)
			continue
		}
		for _, line := range strings.Split(string(data), "\n") {
			counts[line]++
		}
	}
	for line, n := range counts {
		if n > 1 {
			fmt.Printf("%d\t%s\n", n, line)
		}
	}
}

파일을 입력받아 중복된 줄을 찾는 프로그램이다.

파일명을 Argument로 받고 (여러개 가능), 파일을 읽은 뒤 줄마다 count를 추가하여, 해당 줄이 몇개인지를 판단해준다. 

내장 메소드들 (ioutil, range, strings.Split) 을 이용해서 쉽게 구현이 가능하다. 

 

map은 map 자료구조의 레퍼런스 타입인데, make를 통해 객체화되어 그 reference가 반환된다.  

함수의 인자로 넘기게 되면, 레퍼런스가 전달되게 될 것이다. 

 

1.6 Fetching URL Concurrently

Go의 Concurrent Programming Support 가 뚜렷히 드러나는 영역이다. 예시를 먼저 살펴보자. 

 

func main() {
	start := time.Now()
	ch := make(chan string)

	for _, url := range os.Args[1:] {
		go fetch(url, ch) // start a goroutine
	}

	for range os.Args[1:] {
		fmt.Println(<-ch) // receive from channel ch
	}

	fmt.Printf("%.2fs elapsed\n", time.Since(start).Seconds())
}

func fetch(url string, ch chan<- string) {
	start := time.Now()
	resp, err := http.Get(url)

	if err != nil {
		ch <- fmt.Sprint(err) // send to channel ch
		return
	}

	nbytes, err := io.Copy(ioutil.Discard, resp.Body)
	resp.Body.Close() // don't leak resources

	if err != nil {
		ch <- fmt.Sprintf("while reading %s: %v", url, err)
		return
	}

	secs := time.Since(start).Seconds()
	ch <- fmt.Sprintf("%.2fs %7d %s", secs, nbytes, url)
}

goroutine은 동시에 수행되는 함수를 실행시킨다. 

channel은 하나의 goroutine이 다른 goroutine에게 특정 타입의 값을 전달하는 매개이다. 

main 함수는 하나의 고루틴이고, 해당 고루틴에서 다른 고루틴이 생성된다. 

 

메인함수는 make을 통해 string channel을 생성하고,

매 command-line argument에 대해 비동기적으로 http.Get을 수행하는 고루틴을 생성한다. 

io.Copy를 통해 resp.Body 를 ioutil.Discard에 write하여 길이를 구한다. (내용은 필요없으니 버림)

결과가 나올 때 마다 channel로 그 결과를 전송해준다. 

 

한 고루틴이 채널로부터 값을 전송하거나 받으려고 하면, 다른 고루틴이 전송하거나 받기 전까지 그 지점에서 block 한다.

여기서는 각 fetch가 channel에 값을 전달하고, main이 모든 값을 전달받는다.

여기서는 main이 모든 코루틴의 값을 전달받으니, 두개의 고루틴이 리턴한 값에 동시에 접근하는 경우가 없을 것이다. 

C언어에서 fork api와 비슷하지만, 사용방식이 훨씬 간단하고 명료하다. 

 

2. Program Structure

Go는 다른 언어들과 같이 기본 골격들로부터 큰 프로그램을 빌드한다. 이 단원에서는 Go program의 기초 구조에 대해서 깊이 파악할 것이다. 

 

2.1 Names

변수, 상수, 타입, label, 패키지 이름은 일반적인 언어와 비슷하다. 

 

keywords

익숙한 것들 - break func interface select map struct else goto package switch const if range type continue for import return var

안 익숙한 것들 - default defer go fallthrough chan 

 

keyword 외에 built-in으로 지정된 변수들이 있다. 

 

Constants

true false iota nil

 

Types

int int8 int16 int32 int64
uint uint8 uint16 uint32 uint64 uintptr

float32 float64 complex128 complex64

bool byte rune string error

 

Functions

make len cap new append copy close delete complex real imag
panic recover

 

재선언해도 되지만, 오해의 여지가 있으니 권장하지는 않음.

중요한 건, 패키지 내 메소드의 visibility는 대소문자로 구분하는데,

패키지의 밖에서 참조할 수 잇는 변수 혹은 함수는 대문자로 시작한다. fmt.Printf 와 같이 말이다.

패키지 자체는 항상 소문자로 구성한다.

 

Golang 에서는 일반적으로 Camelcase를 쓴다고 한다.

ASCII 나 HTML 와 같이 앞글자를 따서 만든 단어들은 항상 대문자를 쓴다.

 

2.2 Declarations

선언은 4가지가 있다. var, const, type, func

모든 go 프로그램은 파일명이 .go로 끝나고, 항상 패키지 명시를 먼저 하고, 그 다음에 import하며, 그 다음에 함수가 정의된다. 

 

package main

import "fmt"

const boilingF = 212.0

func main() {
	var f = boilingF
	var c = (f - 32) * 5 / 9
	fmt.Printf("boiling point = %g°F or %g°C\n", f, c) // Output:
	// boiling point = 212°F or 100°C
}

위 예시에서 boilingF 변수는 package-level variable 이고, var f는 main함수의 local variable이다.

package-level entity는 모든 패키지에서 참조가 가능하다. 

 

2.3 Variables

변수 선언은 var 를 이용해서 수행하며, 타입 또한 선언할 수 있다.

var name type = expression

expression이 생략될 시 zero value가 들어가며, aggregate type의 경우엔 모든 element 혹은 field에 zero가 들어간다. 

zero value를 설정한다는 건, 항상 변수가 해당 타입의 어떤 값을 가지고 있음을 의미한다. 초기화되지 않은 변수가 없다는 말이다. 

그 외엔 multiple declaration 과 unpacking, type inference를 지원한다.

 

2.3.1 Short Variable Declaration

amin : gif.GIF{LoopCount : nframes} 에서 := 는 declaration 과 initialize 를 동시에 수행시켜준다.

굳이 일일이 var anim = gif.GIF.. 이렇게 안해도 := 이렇게만 해도 되니 편하다. 

 

근데 또, 이미 선언된 변수에 대해서는 := 가 그냥 assign으로 동작한다. 대신, 최소 1개 이상의 새로운 변수가 있어야 한다.

예를 들어, 

in, err := os.Open(infile)
// ...
out, err := os.Create(outfile)

이러면, in과 out 각각이 새로 선언되기 때문에, 3번째 줄에서 에러가 나지 않는데 

 

f, err := os.Open(infile)
// ...
f, err := os.Create(outfile) // compile error: no new variables

이러면, 둘 다 다시 선언하는것이기 때문에 컴파일 에러가 발생한다.

 

2.3.2 Pointers

 

pointer 값은 벼수의 주소이며, 변수가 어디에 저장되어 있는지를 가르킨다. 

var x int 로 선언된 변수의 포인터는 &x로 표현된다. 

해당 포인터의 값은 *x로 참조할 수 있다. 

 

2.3.3 The new Function

 

new 함수를 이용해서 변수를 선언할 수 있다. new 함수로 선언한 경우 값을 zero value로 초기화하며 해당 값의 주소가 반환된다.

 

2.3.4 Lifetime of Variables

 

package-level variable은 프로그램이 끝날때 까지이고, local variable은 매우 동적이다. 

declaration statement가 실행될 때 새로운 인스턴스가 생성되고, 참조될 수 없을 때 까지 보존된다.

for t := 0.0; t < cycles*2*math.Pi; t += res {
  x := math.Sin(t)
  y := math.Sin(t*freq + phase)
  img.SetColorIndex(size+int(x*size+0.5), size+int(y*size+0.5),
  blackIndex)
}

위 코드에서 변수 t는 for문이 시작할 때 생성되고, x와 y는 매 iteration 마다 새로 생성된다. 

garbage collection은 매 collection 시도 마다 변수를 탐색하여, 탐색 불가능한 변수들을 모두 제거한다. 

이는, garbage collection 을 수행하기 전 까지는 살아있다는 것을 의미한다. 

 

메모리를 heap에 할당하느냐, stack에 할당하느냐는 함수가 종료되고 나서 해당 변수를 참조할 수 있느냐 아니냐의 여부에 달려있다. 

왼쪽은 함수 바깥 변수 global에 x의 주소를 전달한 예시이고

오른쪽은 new 함수를 이용해 변수 y를 선언하고 값을 할당한 예시이다 

왼쪽 함수의 변수 x는 var로 선언되었음에도 불구, 함수가 종료되어도 참조가 가능하기 때문에 heap 영역에 할당되고

오른쪽 함수의 변수 y는 new 함수를 이용해 선언되었어도, 함수가 종료되었을 때 y를 참조할 수 없기 때문에 stack 에 할당된다. 

 

garbage collection 이 있으면 편한건 사실이지만, 메모리에 대해 고민하지 않아도 되는 것은 아니니

변수의 lifetime에 대해서 고민해보아야 한다. 

 

2.4 Assignment

일반적인 assignment랑 동일하다.

tuple assignment는, 모든 오른쪽 값들이 evaluate 된 후에 수행된다. 

함수가 여러 값을 반환하는 경우에도 unpacking이 매우 간단하다. 

 

2.5 Type Declaration

값에 따라 타입이 다르고, 그 역할 또한 다르다. int는 loop index, timestamp, file descriptor 등을 표현하는 타입이고, float는 시간, 온도 등.. string은 비밀번호나 색깔.. 

기본 제공하는 타입 외에 새로운 타입을 정의할 수 있는데, 기존 타입 혹은 타입들의 조합으로 구성된다. c언어의 typedef와 동일하게

type name underlying-type 순으로 정의해준다. 

name이 대문자이면 Export 되는 타입으로, 외부 패키지에서도 접근하여 사용할 수 있다. 

 

2.6 Packages and Files

Go의 패키지는 다른 언어의 라이브러리 혹은 모듈과 동일한 목적을 가지며, 코드를 모듈화 캡슐화 분리와 재사용 할 수 있게끔 해준다. 

하나의 패키지에 해당하는 소스 코드는 1개 이상의 .go 파일로 구성되며, 일반적으로 import path 와 동일한 이름으로 패키지가 지정된다. 

예를 들면 math/fourier 패키지는 $GOPATH/src/math/fourier 폴더에 지정되어있는 go 파일들에 명시되어 있을 것이다. 

 

일반적으로 하나의 패키지는 하나의 폴더 안에 구성된다. 

다음과 같이 한 폴더에 서로 다른 패키지를 정의하면 다음과 같은 오류가 발생한다. 

 

동일한 폴더 내에 서로 다른 패키지를 정의하였을 떄

 

각 패키지는 namespace로 구분되며, 대문자로 구성된 identifier만 패키지 외부에서 사용할 수 있다.

import 될 때, 이름이 겹치는 것을 막기 위해 별칭을 적용할 수 있다. 

 

2.6.2 Package Initialization

Go의 패키지 초기화는 좀 특이한데, 일반적으로는 선언된 순서이지만, Package-level variable은 조금 다르다. 

다음 예시는 main함수 내 local variable 을 선언한 예시이다. 

package main

import "fmt"

func main() {
	var (
		a = b + c
		b = 1
		c = 2
	)

	fmt.Println(a)
}

해당 함수를 실행하면 a = b + c 영역에서 오류가 발생한다.

어찌보면 당연하다. 변수 b와 c의 합을 a에 할당하는 시점이, b와 c가 초기화되기 전이니까. 

Package level variable은 함수 밖에서 정의되는 변수로, 해당 변수의 dependency가 먼저 resolve 된 후에 값이 지정된다. 

 

package main

import "fmt"

var (
	a = b + c
	b = 1
	c = 2
)

func main() {
	fmt.Println(a)
}

이 경우엔 a가 3으로 초기화 되어 출력되는 것을 확인할 수 있다. a의 dependency인 b와 c가 먼저 resolve 된 후에 a가 정의되기 때문이다. 

실제로 변수가 초기화되는 순서는 b c a 순서라고 볼 수 있다. 

 

개별 패키지 변수들의 초깃값을 설정할 때 단순 할당만으로는 힘든 경우, init 함수를 1개 이상 정의하여 사용할 수 있다.

init 함수는 reference거나 호출되지 않는 일반 함수이며, 프로그램이 시작될 때 해당 패키지에서 선언된 순서대로 자동적으로 호출된다. 

명확한 Execution 순서는 Package-level variable 선언 및 할당, init 함수 수행, main 함수 수행 순서이다. 

하나의 패키지에서 변수 선언과 init 함수 실행은 파일명 순서대로 이루어진다고 한다. [참고]

 

2.7 Scope

Scope과 lifetime을 헷갈리면 안된다. Scope은 compile-time property고 lifetime은 run-time property 이다. 

block 은 function 혹은 loop을 감싸는 brace를 말하며, syntactic block 내 선언된 변수는 그 밖의 변수가 참조할 수 없다. 

 

Package-level Declaration은 패키지 내 어떤 파일에서든 접근 가능하며, 그렇지 않은 경우는 lexical scope에 제한된다.

import 된 패키지는 file-level 에서만 접근 가능하다.  

 

if f, err := os.Open(fname); err != nil {
  return err
} else {
  // f and err are visible here too f.ReadByte()
  f.Close()
}

위 코드에서 변수 f와 err의 scope는 if - else 문 내부라서, if문이 끝난 후에 변수를 참조하면 오류가 발생한다.

다음과 같이 if-else 로 코드를 작성하는것보다, 에러를 발생시킨 경우에 indent를 주어 리턴하는 것이 normal practice 이다. 

 

3. Basic Types

Basic Types엔 Integer, Float, Complex Number, Boolean, Strings, Constant 가 있다. 

4. Composite Types, 

4.1 Arrays

Array는 특정 타입의 배열이며, 길이가 고정되어있다. (길이가 유동적인 것은 Slice)

var name [length]type 형식으로 선언하며, 개별 값에 대해 index로 접근한다. 

range를 이용하여 개별 element를 iterate 할 수 있고, len을 이용해서 길이를 얻을 수 있다. (이런 점 보면 파이썬같음)

 

q := [...]int{1, 2, 3}

...는 길이를 생략한건데, 뒤에 초기화된 값의 갯수가 그 길이로 지정된다. 

Array의 길이가 타입에 포함되기 때문에, [4]int 와 [3]int가 다른 타입이다. 

 

4.2 Slices

Slice는 가변 길이 배열로 모든 요소가 같은 타입을 지닌다. []T로 정의되며, 사이즈를 정의하지 않으면 Slice가 된다. 

Array와 Slice는 매우 큰 연관이 있는데, Slice는 Array의 Subsequence를 표현하는 값으로, Pointer, Length, Capacity로 구성되어 있다.

Array와 다르게 deep-equality test가 불가능하다. 

 

4.2.1 append Function

append는 c++의 vector와 비슷하게 append될 갯수보다 더 많이 메모리를 할당한다. 

 

4.3 Maps

해시 테이블 구현체이며, O(1) 로 get remove insert 가 가능하다. 

map[K]V 로 타입을 지정하며 (K = Key, V = Value), Key의 타입은 == 로 비교할 수 있어야 한다. (slice같은건 안된다는 뜻)

Floats를 K로 사용하는 것은 매우 바람직하지 않다. (NaN이 Positive Value 일 때 특히..)

 

func main() {
	var height = make(map[string]int)
	height["john"] = 180
	a, b := height["john"] // 180, true
}

map에 삽입은 단순하고, 뺄 때 2번째 인자로 해당 값이 존재하는지 여부를 알려준다. 

go에서는 set type을 지원하지 않지만, map의 key가 distinct 하기 때문에 set 의 용도로 사용할 수 있다. 

물론 Key 타입이 == 로 비교할 수 없어도, 해당 key를 string으로 매핑해주는 함수를 구성해서 이를 key로 사용하면 가능하다. 

 

4.4 - 4.6 Struct, JSON, Text and HTML Templates

사용하면서 필요한 개념은 추가하도록 하겠음

 

5. Functions

함수 선언, Multiple Return 이런건 생략합니다.

 

5.4 Errors

Go는 다은 언어랑 좀 다른게, 표준 라이브러리의 동작 결과로 에러를 반환한다

일반적으로, 다음과 같은 형태를 지닌다. 

value, ok := cache.Lookup(key)
if !ok {
	// ...cache[key] does not exist...
}

메소드 수행 결과로 2개의 인자가 반환되는데, 하나는 결과와 다른 하나는 에러이다. (말 그대로 에러를 반환)

저기서 이야기하는 에러는 해당 메소드를 사용하여 얻는 결과와는 다른 "예상할 수 있는" 오류가 발생하는 경우 반환되는 에러이다. 

예를 들어 cache.Lookup(key) 면, key가 없는 경우 아무것도 반환이 되지 않을 것이고, 이는 오류 상황에 해당한다. 

 

I/O 상황에서 오류의 원인이 다양하니, 해당 오류에 대한 설명을 에러 객체에 반환한다. 

일반적으로 에러가 발생한 경우, 반환되는 value는 nil인 게 일반적인데, 몇몇 함수들은 특정 경우에 따라 다를 수 있다. 

 

Go에서의 Exception은 진짜로 "예상 불가능한" 오류인 경우에만 발생한다. 프로그램을 개발할 때 충분히 예상 가능한 Routine Error가 아닌 Bug가 발생한 경우 말이다. 그렇게 함으로써, control-flow 매커니즘으로 에러를 핸들링 하여 코드가 간결해지고 명확해 지는 것이다. 

 

5.4.1 Error Handling

에러를 핸들링하는 다양한 방식에 대해 알아보자.

 

1. Propagating

Subroutine 에서 에러가 발생할 경우 그걸 Calling Routine으로 전달한다. 

value, err := cache.Lookup(key)
if err {
	return value, err;
}

전달할 때, Subroutine에서 발생한 에러를 분석하여 새로운 에러를 생성하여 보낼 수도 있다 .

doc, err := html.Parse(resp.Body) 
resp.Body.Close()
if err {
	return value, fmt.Errorf("parsing %s as HTML: %v", url, err);
}

에러 메세지를 생성할 땐, 개별 에러에 대한 상황을 명시하여 의미를 충분히 전달해야 하며, 일관적으로 작성해야 한다. 

os 패키지를 예로 들면, Failure의 이유 뿐만 아니라, 파일명 까지 포함하여 충분히 전달한다. 

 

 

2. Retry

일시적이고, 예상 불가능한 오류의 경우엔, 실패한 경우 딜레이와 함께 재시도를 수행할 수 있다. 예시를 함께 참고하자. 

func WaitForServer(url string) error {
	const timeout = 1 * time.Minute
	deadline := time.Now().Add(timeout)
	for tries := 0; time.Now().Before(deadline); tries++ {
		_, err := http.Head(url)
		if err == nil {
			return nil // success
		}
		log.Printf("server not responding (%s); retrying...", err)
		time.Sleep(time.Second << uint(tries)) // exponential back-off
	}
	return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}

 

다음과 같이, 데드라인 전 까지 수행을 반복한 후 실패시 에러를 반환한다. 

 

3. Exit

오류가 생겼을 때 프로그램을 종료하는 방법도 있는데, 이는 메인 패키지에서 만 지정되어야 한다. 
라이브러리가 오류 발생했다고 자기 맘대로 프로세스를 종료해버리면.. 좀 많이 이상할 것이다. 

 

4. Log and Continue

5. Ignore

 

Go에서 에러 핸들링은 패턴이 있는데, 에러 체커 후 Failure인 case에 대해 먼저 로직을 작성해주고, 때때로 리턴해준다. 

성공 Case에 해당하는 Flow는 최대한 Indent하지 않음으로써 코드의 흐름을 간결하게 만들어준다. 

 

5.4.2 End of File

 

일반적인 에러는 end-user에 관련된 에러여야 하지만, 꼭 그게 아니더라도 특정 상황에 대해 다른 액션을 취해야 하는 경우가 있다.

예를 들어, 파일을 읽는 경우 File이 끝나는 io.EOF 로 해당 에러를 구분해낼 수 있다. 

 

5.5 Function Values

함수는 일급 객체여서 변수로 전달될 수 있다. 

 

5.6 Anonymous Functions

function literal을 이용하여 익명함수를 생성할 수 있다. 

리턴된 익명함수는 상태를 가질 수 있으며, 클로저와 같이 동작한다. 

 

func main() {
	var a = func() func() int {
		var x = 1

		return func() int {
			x++
			return x * x
		}
	}

	var b = a()
	fmt.Println(b())
	fmt.Println(b())
	fmt.Println(b())
	fmt.Println(b())
}

간단한 예시이다. a의 호출로 함수가 리턴되어, 변수 x가 사라질것 처럼 보였지만, 실제로 리턴된 함수에서 해당 변수를 참조하고 있으므로 사라지지 않는다. 물론 b를 통해서 해당 변수에 접근할 수 없다. 

리턴된 함수 b에서는 x가 더해지고 그 제곱이 리턴되기 때문에, 4 9 16 25 가 print될 것이다. 

저런 hidden variable reference 덕분에 함수가 reference type인 것이고, 비교할 수 없는 것이다. 

 

5.6.1 Caveat : Capturing Iteration Variables

Go의 lexical scope rule을 이해하고, 이를 통해 올바른 코드를 작성하자. 

 

func main() {
	var tempdirs []func()

	for _, a := range []int{1, 2, 3, 4, 5} {
		tempdirs = append(tempdirs, func() { fmt.Println(a) })
	}

	for _, fun := range tempdirs {
		fun()
	}
}

가장 해당 문제와 가까운 예시를 작성해보았다. 

단순히 보면, a는 1,2,3,4,5이고, 해당 값을 각각 출력하는 함수를 매번 배열에 추가하였으니, 결과적으로 1,2,3,4,5가 출력되어야 할 것 같이 보인다.

그치만 실제로는, 해당 함수에서 외부 변수를 참조한거고, a라는 변수는 사라지지 않고 내부 scope에서 값이 변경되기 때문에, 

모든 함수가 5를 출력 할 것이다. (여기서 5가 출력된다는 사실로 실제로는 a가 for문을 마치고 scope를 다해서 사라져야 함에도 사라지지 않고 값이 존재함을 알 수 있다)

 

5.7 Variadic Function

가변 함수는 argument를 여러개 넣을 수 있는 함수이다. 

 

func main() {
	var a = func(vals ...int) int {
		total := 0
		for _, val := range vals {
			total += val
		}
		return total
	}

	var b = []int{5, 6, 7, 8}

	fmt.Println(a(1, 2, 3))
	fmt.Println(a(b...))
}

간단한 예시이다. 함수 a는 모든 입력을 받아 다 더해서 그 결과를 리턴한다.

배열을 넣고싶으면, 그 배열을 풀어서 넣어야 한다 (spread). b... 를 통해 b를 풀어서 a에 전달한다. 

 

5.8 Deferred Function Calls

여기서는 defer라는 새로운 키워드에 대해 학습한다. 

defer는 지연실행이다. 일반 함수 호출이나 어떤 행동 앞에 defer라는 키워드를 붙여서 작동시킨다.

defer를 붙이면, 해당 함수가 정상적, 혹은 panik에 의해 종료되는 시점에 defer로 지정한 statement가 동작한다. 

defer는 한 함수에 여러번 호출될 수 있는데, 먼저 명시된 defer가 나중에 실행된다. 

 

func main() {
	defer fmt.Println(1)
	defer fmt.Println(2)
	fmt.Println(3)
	fmt.Println(4)
}

위 예시의 출력 결과는? 3 4 2 1 이다. 

 

defer는 .close() .disconnect() 와 같이 해당 함수를 완료한 후 release 해야 하는 경우에 주로 사용된다

함수의 시작과 끝에 액션을 지정하기 위해서도 defer를 사용하는데, 다음 예시가 매우 적절한 예시이다. 

 

func bigSlowOperation() {
	defer trace("bigSlowOperation")()

	time.Sleep(10 * time.Second) // simulate slow operation by sleeping
}

func trace(msg string) func() {
	start := time.Now()
	log.Printf("enter %s", msg)
	return func() { log.Printf("exit %s (%s)", msg, time.Since(start)) }
}

trace가 어떻게 동작하는지 생각해보자. 

bigSlowOperation을 동작시키는데, 이게 얼마나 걸리는지를 판단해준 것이다.

defer trace("bigSlowOperation")() 에서, 오른쪽 문장 전체가 defer되는 것이 아니다. trace()가 수행되고 리턴된 함수의 호출만 defer 된다. 그렇기 때문에 해당 함수가 함수의 시작지점과 끝지점의 액션을 지정해줄 수 있는 것이다. 

 

이부분이 좀 소름돋는 아이디어인데, func에 리턴 값의 변수를 지정한 다음 이를 defer에서 사용할 수 있다.  

func double(x int) (result int) {
	defer func() { fmt.Printf("double(%d) = %d\n", x, result) }()
	return x + x
}

위 defer 예시는 어떤 값이 리턴되는지를 매 함수 호출마다 명시해준다

디버깅할때 이 기능이 빛을 발하지 않을까 생각된다. 정말 괜찮은.. 기능이라고 생각함. 

 

리턴값을 변형도 할 수 있다. 쏘 어메이징. 

func triple(x int) (result int) {
	defer func() { result += x }()
	return double(x)
}

이렇게.. lexical Scoping 의 장점을 매우 살린 것 같다.

5.9 Panic

compile time에 발견하지 못한 에러가 런타임에 발견되면 Panic 한다. 

panic 하면, execution이 멈추고 해당 고루틴의 defer statement가 수행되며 프로그램이 crash 된다. 

그리고 각 고루틴마다 crash 시점의 stack trace가 로깅된다. (일반적인 프로그램과 동일)

 

built-in panic을 호출하여 사용할 수도 있다. 이는 정말 "불가능한 경우" (Logic Inconsistency)에 사용하는 것이 맞다. 

굳이 런타임에서 해당 문제때문에 자동으로 panic이 발동하는 경우라면, 해줄 필요가 없다. 

 

5.10 Recover

Panic이 발생할 때 Recover 해야 하는 경우가 종종 있다. 예를 들어, 웹 서버면 클라가 연결된 상태에서 요청을 처리하다 Panic이 발생하면, 적어도 일단 클라 연결은 끊어주어야 클라가 기다리지 않는 것처럼. 

built in recover 함수가 defer한 함수 내에 정의되어있으면, panik시 해당 함수에서 처리를 해주기 떄문에 올바르게 리턴될 것이다. 

그래서 패턴은, 일반 함수에서 panik이 발생하면, defer하는 함수에서 이를 recover하고, 그 유형에 따라 다르게 동작시키는 것이다. 

 

근데 실제로 무분별하게 모든 Panic을 Recover하는 것은 많은 부작용을 발생시킨다. 네트워크가 열렸다 닫히지 않은 상태일 수도 있고, 파일이 open되고 닫히지 않은 상태 등의 경우에 recover를 수행하면 큰일나요.. 무엇보다 직접 작성하지 않은 서드 파티 라이브러리의 Panic을 recover하는 것은 매우 위험하다. (safety를 보장 못하니까)

 

6. Methods

OOP는 지배적이고, Go도 예외없이 OOP를 지원한다. 

Go에 object란 method가 있는 변수 혹은 값이고, 메소드는 특정 타입에 대한 함수이다. 

메소드를 어떻게 해야 효율적이게 구성하고, OOP의 encapsulation과 composition에 대해 다루어보자. 

 

6.1 Method Declaration

메소드는 일반적인 함수 선언과 조금 다르게 함수명 이전에 Extra Parameter가 추가되는데, 이 parameter는 해당 함수를 그 패러미터 타입으로 고정시킨다. 예시를 보자.

// Point of Coordinate
type Point struct{ X, Y float64 }

// Distance Function
func Distance(p, q Point) float64 {
	return math.Hypot(q.X-p.X, q.Y-p.Y)
}

// Distance method
func (p Point) Distance(q Point) float64 {
	return math.Hypot(q.X-p.X, q.Y-p.Y)
}

다음과 같이, Point라는 타입에 정의된 두개의 함수가 있다. 위에 정의한 함수는 단순히 해당 패키지에서 Export 되는 함수이고, 밑의 함수는 Point p의 메소드로 정의된 것이다. 

함수 명 앞의 변수는 메소드를 호출하는 객체 자기 자신의 Property를 접근하기 위해 지정된 변수이다. this나 self가 아니라 해당 receiver 변수를 지정해서 사용할 수 있다. 자주 사용하니 짧은 것을 쓰는게 좋다고 한다 (이건 자기 마음인듯)

 

func main() {
	var a = coord.Point{X: 1, Y: 2}
	var b = coord.Point{X: 3, Y: 4}

	var c = coord.Distance(a, b)
	var d = a.Distance(b)

	a.Print()
	b.Print()
	fmt.Println(c, d)
}

다음 예시에서 a.Distance와 coord.Distance는 같은 역할을 하지만, 다른 함수로부터 지정되었음을 알 수 있을 것이다. 

이 예시를 작성하면서 알게 된 것은, 로컬 패키지와 default 패키지가 자동으로 띄어져 린팅된다는 것이다. 참으로 놀랍다.. 

 

6.2 Methods with a Pointer Receiver

함수를 호출하게 되면, 전달되는 argument가 기본적으로 복사된다.

가지고 있던 객체의 값을 변경하려는 경우와

전달하려는 argument가 너무 큰 경우

copy하는 것 보단 reference를 넘기는 것이 바람직하다.

 

위에서 만든 Point 타입에 대해서해당 Point의 값을 변경하는 시나리오를 생각해보자. 

// Move Point
func (p Point) Move(x, y float64) {
	p.X = x
	p.Y = y
}

func main() {
	var a = coord.Point{X: 1, Y: 2}

	a.Move(3, 4)
	a.Print()
}

현재 Point를 변경하는 Move 함수를 만들어서, x와 y좌표를 입력하여 위치를 바꾸려고 한다. 

이렇게 하면 되겠지~ 싱글벙글 웃으며 결과를 확인해본다. 그런데 결과는 1,2로 동일하다. 

엥? 어찌된 일이지? 왜냐면 함수 호출은 기본적으로 argument가 값 복사로 전달되기 때문이다. receiver도 예외는 아니다. 

 

// Move Point
func (p *Point) Move(x, y float64) {
	p.X = x
	p.Y = y
}

다음과 같이 *를 통해 Pointer Receiver로 설정해야 올바르게 값이 변경된다. 

 

일반적인 컨벤션은 모든 Method를 Pointer Receiver로 설정하거나 그 반대이다. 

모호함을 피하기 위해 언어 자체적으로 이미 포인터로 지정된 타입들에 대해서는 메소드를 지정할 수 없다. 

 

메소드 호출이 호출한 객체를 그대로 복사하는 점에 대해서 생각해보면, 원본을 보존한다는 점에서 Immutable하게 작업한다는 점에서 Side-Effect 를 최소화 할 수 있다는 장점이 있다. 

 

6.2.1 Nil Is a Valid Receiver Value

 

var b coord.Point

b.Move(2, 4)
b.Print()

위 경우를 생각해보자. 해당 타입에 대한 선언 후 값을 할당하지 않았음에도 불구하고, 메소드를 실행할 수 있다. 

이때 Move 메소드가 Pointer Receiver를 받는데, 초기값이 존재하지 않는 것은 Move 메소드를 사용하는데 전혀 문제가 되지 않는다. 

 

그렇기 때문에 메소드를 제공하는 입장에서, Receiver Value로 Nil이 전달되는 경우에 대한 documentation이 필요하다. 

 

6.3 Composing Types by Struct Embedding

타입 내에 Struct를 추가하여 중첩 타이핑이 가능하다. 물론 해당 타입에 지정된 메소드도 사용 가능하다. 

 

6.4 Method Values and Expressions

객체의 메소드를 다른 변수로 지정 혹은 함수의 Parameter로 전달할 수 있다. (Method가 Value로서 동작함) 

만약 해당 메소드가 Pointer Receiver로 지정되어있는 경우 해당 객체가 Pointer Receiver로 전달된다 (!!)

 

6.6 Encapsulation

메소드를 참조 가능한지 여부는 오직 맨 앞글자의 대문자 소문자로만 지정할 수 있다. 

그렇기 때문에 Object를 Encapsulate 하기 위해선 struct를 생성해서 소문자 필드를 사용해야 한다. 

 

7. Interfaces

인터페이스 타입은 다른 타입의 behavior를 일반화 혹은 추상화한다. 그렇게 함으로써 실제 구현된 구현체에 얽매이지 않은 유연한 함수를 구성할 수 있다. 

수많은 OOP 언어들이 Interface가 있지만, Go는 다른 언어의 인터페이스와 큰 차이점이 있는데, 이는 Interface를 구현한다고 명시할 필요가 없다는 것이다. (암묵적으로 명시됨) (참고)

이게 무슨 의미냐면, 해당 Interface와 동일한 메소드를 가지고 있으면 자연스럽게 그 인터페이스를 구현한 것으로 간주한다는 것이다. 

파이썬에서는 Duck Typing이란 개념이 있는데, 이것도 이와 비슷한 개념이다. (물론 파이썬은 타입 명시가 없지만 Go는 있다)

 

7.1 Interfaces as Contracts

지금까지 사용했던 모든 타입은 concrete type, 즉 내부적으로 가져야 할 value와 밖으로 드러내야 할 operation들이 구체화된 것이다. 

Concrete Type은 메소드를 통해 behavior를 제공하며, 그것으로 무언가를 할 수 있는 것이다. 

Concrete Type과는 다른 interface Type이 존재하는데, 이는 추상 타입으로 내부 값 구조나 operation에 대한 명시 없이 몇몇 method만 드러낸다. (행동을 드러내는 것) 

 

Interface를 함수의 인자로 명시한다는 것은, 해당 메소드를 가지고 있는 어떤 타입이든 입력될 수 있음을 의미한다. 

이를 substitutability 라고 한다. go에서는 구현된 메소드가 있으면 해당 인터페이스의 구현체라고 생각하기 때문에, 대체가 가능하다.  

 

7.2 Interface Types

인터페이스 타입은 해당 인터페이스의 인스턴스가 가져야 할 메소드의 집합을 명시한다. 

golang에서 메소드 한개인 인터페이스 타입의 이름을 짓는 컨벤션은 매우 간단하다 

 

package io

type Reader interface {
	Read(p []byte) (n int, err error)
}
type Writer interface {
	Write(p []byte) (n int, err error)
}

이런 식으로, 행동을 하는 대상 이라는 간단한 컨벤션을 활용한다. 

이제 저 2개의 인터페이스를 combine 하여 새로운 인터페이스를 구성할 수 있다. 

 

type ReadWriter interface {
	Reader
	Writer
}

이 Interface는 자연스럽게 Read와 Write 메소드를 명시하게 된다. 이와 같이 여러 인터페이스를 합치는 것을 Embedding 이라고 한다.

 

7.3 Interface Satisfaction

자연스럽게, Interface에 적합하다는 것, Interface를 만족시킨다는 것은 해당 객체가 Interface에서 명시한 메소드를 가지고 있다는 것을 의미한다. 

type이 메소드를 가진다는 것에 대해 한번 생각해보자. 타입 T의 어떤 메소드는 receiver로 타입 T 자신을 받고, 어떤 것은 *T 포인터를 받을 것이다. *T 메소드를 T의 인자에서 호출하는 것은, 해당 변수의 address를 이용하여 수행하는 것이다. 그치만 이건 syntactic sugar이고, 실제로는 T 타입의 변수는 *T pointer 처럼 모든 메소드를 가지지 못하기 때문에, interface를 덜 만족시키게 된다. 

 

예를 들어, IntSet 타입의 String() 메소드가 pointer receiver를 필요로 하는 경우를 생각해보자. 

// IntSet : Set of Int
type IntSet struct{ ints []int }

func (I *IntSet) String() string {
	return "Hello"
}

다음과 같은 경우이다. 

자연스럽게 String 메소드를 호출하기 위해선 reference가 필요하고, 자연스럽게 non-addressable한 형태의 값에는 적용할 수 없다. 

var c = intset.IntSet{}.String()

이러면 에러난다. 왜냐면 intset.IntSet{} 는 non-addressable 하기 때문이다. 

물론 variable에 해당 값을 넣으면 address를 참조할 수 있기 때문에 메소드를 실행할 수 있다. 

var c = intset.IntSet{}
fmt.Println(c.String())

이런 경우는 가능하다. 

 

type interface{} 는 아무 메소드가 지정되지 않아서, empty interface에 어떤 타입의 값이든 지정할 수 있다. 

var d = make([]interface{}, 0)

d = append(d, "hello")
d = append(d, 123)

for _, b := range d {
	fmt.Println(b)
}

 

7.5 Interface Values

인터페이스 타입의 값은 concrete type과 해당 타입의 값이 있다. 

Go와 같은 정적 타입 언어에서 타입은 compile-time concept이기 때문에, type은 값이 아니다. 

Go에서 값의 집합인 type descriptor는 이름과 메소드 등의 개별 타입에 대한 정보를 제공한다. interface value

 

var w io.Writer
w = os.Stdout
w = new(bytes.Buffer)
w = nil 

어떤 변수든 그 타입과 value를 가지는데, Concrete type은 type에 대한 값이 고정되어있다. 

반면 Interface는 런타임에 Concrete type이 어떻게 변할지 모른다. 

 

다른 여러 타입들은 안전하게 비교 가능하거나, 아예 비교 불가능하거나인데, interface value는 그 안에 담고 있는 값에 따라 그 결과가 달라지기 때문에, panic을 항상 우려해야 한다. 

동적 타입을 조회할 때 fmt.Printf 의 %T 를 이용하여 타입을 조회할 수 있다.

 

var d = make([]interface{}, 0)

d = append(d, "hello")
d = append(d, 123)

for _, b := range d {
	fmt.Printf("(%v, %T)\n", b, b)
}

 

7.10 Type Assertions

interface value에 적용되는 operation으로, x.(T) 의 형태로 수행된다. 

체크하고 싶은 value가 x이고, 타입은 T이다. x.(T)가 true를 반환하면, x의 타입이 T라는 뜻이다. 

만약 check를 실패하면 panic 한다. 

 

만약 체크하고 싶은 타입이 interface type인 경우, x의 dynamic type이 interface T를 만족하는지를 확인한다. 

 

7.13 Type Switch

Interface가 담는 Concrete type에 따라 분기를 구성할 수 있는데, 이를 Type Switch 라고 한다. 

Ocaml에서 match with 처럼 사용되는데, 이게 사실 매우 좋은 기능이다. 

 

7.15 A Few Word Of Advice

개발할 때, 인터페이스부터 만들지 마라. 구현체가 하나 있는 인터페이스는 불필요하다. 

2개 이상의 Concrete Type이 동일한 방식으로 다루어져야 할 때 Interface를 사용하는 것이 바람직하다. 

그렇게 하면 적은 인터페이스, 작은 인터페이스이 구성될 것이고, 새로운 타입이 등장할 때 대응이 쉬울 것이다. 

인터페이스 구성에서의 rule of thumb은 해당 인터페이스에 필요한 것만 요청하는 것이다. 

 

8. Goroutine and Channels

동시 프로그래밍이 참 중요해진 시대이다. 웹 서버도 여러 요청을 한 서버로 다루고, UI를 렌더링하면서 그 뒤에서 computation과 네트워크 요청을 병행한다. 전형적인 batch problem에서도 I/O latency 문제를 해소하기 위해 동시성 프로그래밍을 다룬다. 

Go에서는 동시성 프로그래밍을 2가지로 수행할 수 있다.

챕터 8에서는 goroutine과 channel로, 개별 goroutine이라는 액티비티간 값이 전달되며, 변수는 single activity에 confined(국한) 된다.

챕터 9에서는 전통적인 Shared Memory Multithreading에 대해 다룬다. 

 

Go의 concurrency 지원이 강력하긴 하지만, 기본적으로 sequential 보다 위험한건 사실이고, 직관성이 떨어질 수 있다. 

 

8.1 Goroutine

Go에서는 각각 독립적으로 수행되는 액티비티를 고루틴이라고 한다. 2개의 함수가 있으면, sequential

program에서는 두 함수를 각각 순서대로 실행하지만, concurrent program 에서는 두 함수를 동시에 호출한다. 

다른 언어에서 쓰레드를 써봤다면 고루틴은 Thread랑 비슷한 거라고 보면 된다. 쓰레드랑 고루틴의 차이는 근본적으로 quantitative한 차이이다. (의미는 아직 모르겠음)

 

프로그램의 시작점인 main함수도 하나의 고루틴이다. (Main goroutine). 새로운 고루틴은 go statement에 의해 생성된다. 

go f() 를 통해 함수 f를 새로운 고루틴에서 수행하게 만든다. 

메인함수가 리턴하게 되면, 그전에 생성된 모든 고루틴이 종료료되고 프로그램이 종료된다. 

다른 고루틴을 stop하게 강제하는 방법은 없지만, goroutine간 communication을 통해 스스로 종료하게끔 할 수 있다. 

 

8.2, 8.3 은 goroutine을 이용하여 여러 요청을 동시에 수행하는 예씨이다. 

 

8.4 Channels

goroutine이 현재 고 프로그램의 activity 라면, channel은 goroutine간 소통 채널이다. 

채널은 하나의 고루틴이 다른 고루틴에게 메세지를 보내는 매커니즘이다. 

int 타입의 채널은 chan int 로 선언하고, make 함수를 이용하여 생성한다. 

ch := make(chan int)

채널은 make로 생성되는 레퍼런스 타입이다. (그래야 할 것 같긴 하다. 공유된 메모리 주소가 있어야 그쪽을 통해 넘기니까)

 

채널에 사용되는 연산자는 send와 receive close 3가지가 있는데, 이는 communication 용 연산자이다. 

send와 receive 모두 <- 연산자를 사용하는데, 

ch <- x 이거는 보내는거고

x = <-ch 이거는 받는거다. 

<-ch는 받되 그 결과를 버리는 것 

 

close(ch) 는 해당 채널을 닫는다. 닫힌 채널에 보내면 panic한다. 

 

채널을 만들 때 make를 이용하는데, 2번째 인자로 capacity를 지정할 수 있다. 

capacity가 있는 channel은 buffered channel 이라고 하고, 없는 건 unbuffered channel 이라고 한다. 



8.4.1 Unbuffered Channels

Unbuffered Channel에 메시지를 보내면, 해당 메시지를 보낸 고루틴은 다른 고루틴이 그 메시지를 받을 때 까지 Block한다. 

그 반대로, 메시지를 받는 연산을 먼저 수행하면, 메시지를 받을 때 까지 기다리게 된다. 

 

unbuffered channel 에서는 sending과 receiving이 synchronous하게 이루어진다. 이는, 메세지를 받는 행위가, 메세지를 전달한 고루틴이 흐름을 재개하는 것 보다 먼저 일어난다는 것이다. 

concurrent system에선 어떤 고루틴이 어떤 것에 선행할지를 예측할 수 없어서, 공유하는 변수가 있다면 event에 order를 부여하는 것은 매우 중요하다. 

background goroutine이 종료될 때 까지 Main goroutine이 기다리게끔 하기 위해 Unbuffered Channel을 이용해서 기다린다. 

저런 "기다리는" 용도의 channel은 element type을 struct{} 로 구성한다. (일종의 convention) boolean과 -1은 어떤 의미를 가진다고 여겨질 수 있으니, 아무것도 담지 않는 struct를 이용한다. 

 

8.4.2 Pipelines

하나의 고루틴의 output을 다른 고루틴의 output으로 설정할 수 있는데, 이를 파이프라이닝 이라고 한다. 

Unbuffered Channel 에서는 다른 고루틴이 입력을 넣는 것을 기다릴 수 있다고 하니, 이 점에서 어떻게 파이프라이닝이 되는지 자연스럽게 유추할 수 있을 것으로 보인다. 

 

Receiver 측에선 채널이 닫혔는지 아닌지를 확인할 수 있는데, 예측했을 수도 있지만 그 여부는 자연스럽게 2번째 인자로 전달 될 것이다. 

모든 채널을 다 닫을 필요는 없고, 수신하는 고루틴 쪽에서 닫혀있는 것을 파악하는 것이 필요할 때 (모든 데이터를 다 보냈을 때) 만 닫으면 된다. 어짜피 GC에서 도달하지 못하는 channel은 닫힐 것이기 때문이다. (file과 다름)

이미 닫힌 채널을 또 닫는것도 panic을 발생시킨다. 

 

8.4.3 Unidirectional Channel Types

프로그램이 커질수록 함수를 작은 단위로 쪼개게 되면, 함수의 인자로 channel을 전달해야 할 경우가 있다.

그냥 channel 타입으로 인자를 전달하면 이 함수가 전달용 함수인지 수신용 함수인지 잘 모르게 된다.

그럼 당연히 파악도 어려워지고 코드가 오히려 복잡하게 느껴질 것이다. 

이를 방지하기 위해 unidirectional channel type을 제공하는데, channel 타입에 <- 를 붙여주면

send-only, receivce-only 여부를 지정해줄 수 있다. 

 

func counter(out chan<- int) {
	for x := 0; x < 100; x++ {
		out <- x
	}
	close(out)
}
func squarer(out chan<- int, in <-chan int) {
	for v := range in {
		out <- v * v
	}
	close(out)
}
func printer(in <-chan int) {
	for v := range in {
		fmt.Println(v)
	}
}

위와 같이, chan 타입에 <-를 붙여 send-only 인지 receive-only 인지를 파악할 수 있다.

 

8.4.4 Buffered Channels

Buffered Channels 엔 element의 queue가 있다. queue의 최대 사이즈는 make의 2번째 인자로 지정해준다. 

channel에 입력할 땐, 자리가 비어있으면 기다리지 않고 넣어놓고, 꽉 차있으면 기다린다 (block)

channel에서 값을 받을 땐, 값이 있으면 그냥 꺼내서 수행하고, 값이 없으면 기다린다 (block)

 

channel의 buffer capacity를 알고 싶으면 cap 이라는 함수의 인자로 buffered channel을 입력하면 알 수 있다. 

channel에 지금 담겨있는 갯수의 크기를 알고 싶으면 len 함수를 이용한다. 

 

한 함수에서 하나의 채널을 통해 고루틴을 여러개 생성하는 예시를 들어보자. 

func mirroredQuery() string {
	responses := make(chan string, 3)
	go func() { responses <- request("asia.gopl.io") }()
	go func() { responses <- request("europe.gopl.io") }()
	go func() { responses <- request("americas.gopl.io") }()
	return <-responses // return the quickest response
}

여러 서버에 요청한 결과가 어떤 게 가장 먼저 오는지에 대해 판단하는 경우이다. 

채널 버퍼를 3개 구성했으니, 각 고루틴을 실행하고 가장 먼저 오는 고루틴의 리스폰스를 받는다.

병렬적으로 요청을 보내고 가장 먼저 돌아오는걸 리턴하겠다는 의도는 알겠으나, 저렇게 하면 나머지 2개의 고루틴은 ..

원래 채널을 가지고 있던 함수가 리턴되버리니 나머지 두 고루틴은 리턴을 받을 다른 고루틴이 없게 된다.

이런 경우를 leaked goroutine이라고 하는데, 애는 gc가 collect를 하지 못한다. 그래서 개별 고루틴이 스스로 종료할 수 있게 지정해주어야 한다.

 

2가지 조언들이 있는데,

하나는 버퍼를 충분히 둬야 데드락을 방지할 수 있다는 것이고,

다른 하나는 파이프라이닝을 적절히 해야 Throughput 이 좋아진 다는 이야기이다. 

 

go에서 Leakage를 Detect 하는 게 있네요.. (github.com/uber-go/goleak)

어떻게 구성되어야 하는지 나중에 한번 보는게 좋을 것 같다.

 

8.5 Looping in Parallel

여기서는 병렬로 looping 하는 예시를 들어볼 수 있다. 

여러 예시가 있는데, 사실은 매우 단순하다. 

 

그냥 go 루틴을 통해 함수를 모두 실행시키면, 개별 함수가 올바르게 동작했는지에 대해서 확인할 수 없게 된다. 

이때는 struct{} 타입의 chan을 하나 만들어서 개별 함수가 chan으로 완료 상태를 피드백 할 수 있게 구성할 수 있다. 

 

loop 로 고루틴을 다룰 때 꼭 모든 고루틴을 release할 수 있도록 Buffered Channel을 그 크기만큼 구성해야 한다. 

 

8.6 Web Crawler example

프로그램이 과도하게 Parallel할 때, Counting Semaphore를 두어 고루틴의 수를 제한하는 방법에 대한 이야기이다. 

컴퓨터는 CPU 코어 갯수도 제한적이고, I/O 작업도 제한적이고, 네트워크도.. 두말하면 입아프다. 

동시 수행되는 고루틴의 갯수를 제한하는 것이 옳은데, 

Counting Semaphore를 두어 동시에 작업을 제한하는 방법은 다음과 같다. 

 

var tokens = make(chan struct{}, 20)
doWork = func(url string) []string {
	fmt.Println(url)
	tokens <- struct{}{} // acquire a token
	list, err := links.Extract(url)
	<-tokens // release the token
	if err != nil {
		log.Print(err)
	}
	return list
}

방법은 단순하다. 고루틴을 수행하기 위해, 채널에 빈 struct{}{}를 하나를 넣는거다. 

20개로 제한되어있으면, 채널이 꽉 차있을 때는 진입이 안되니 동시작업을 못하겠지. 

저렇게 구성된 함수를 go루틴으로 생성하면 간단히 해결되는 문제다. 

 

10. Packages and the Go Tool

Go엔 100개 이상의 Standard Package가 있다. 여기에 목록이 있음. (매우 많음..)

Go엔 go tool 이 있는데, go package를 쉽게 다루는 패키지 매니저이다. 해당 도구의 원리와 기능들에 대해 알아보자. 

 

10.1 Introduction

패키징의 목적은 결국 모듈화로 쉽게 코드를 공유하고 재사용하는 것이다. 개별 패키지는 변수들을 구분하는 namespace를 정의하며, 개별 name은 다른 프로그램과 conflict가 발생하지 않게 구성한다. 

Go는 다른 언어보다 빌드가 빠른 이유가 일단 규정상 import를 최상단에서만 할 수 있기 때문에 파일 전체를 읽지 않아도 되며, 사용하지 않는 패키지는 import 할 수 없으며, Dependency가 항상 DAG 이고 (이건 어떻게 보장하는지 잘 모르겠음), Cycle이 없으므로 병렬적으로 빌드가 가능하며, 컴파일된 고 패키지는 패키지 자체 뿐만 아니라 각 dependency도 export할 수 있다. 

 

10.5 Blank Imports

import 시 init 함수에 의해 side-effect가 발생할 수 있다.

이러한 Side-effect 적극적으로 활용하면서, 실제로 import되는 메소드를 사용하지 않을때 blank import 를 사용한다.

 

10.7 Go Tool

Go 코드를 다운로드, 쿼리, 포메팅, 빌드, 테스트 및 설치하기 위한 go에서 제공하는 도구들에 대해 알아보자. 

 

build -> 패키지와 의존성들을 빌드함

clean -> object file을 제거함

doc -> package 혹은 symbol에 대한 documentation을 보여줌

env -> Go의 환경변수를 출력함

fmt -> 패키지에 대해 포메팅을 수행함

get -> 다운로드 및 패키지를 컴파일 및 설치함

install -> 패키지를 컴파일 및 설치함

list -> 패키지를 나열함

run -> Go 프로그램을 실행함

test -> 패키지를 테스트함

version -> Go 버전을 체크함

 

12. Reflection

Go엔 "타입을 몰라도" 런타임에 (주로) 타입을 통해 프로그램의 구조를 판단하는 능력이 있는데, 이를 reflection 이라고 한다. 

애는 좀 개별 포스트에서 깊이 다루어야 할 것으로 보인다. 

 

보강

- 7.4 ~ 7.14

- 8.6 ~ 8.10 (읽고 이해는 했지만, 예제 코드를 직접 구성하면 좋을 것 같음)

- 9단원 전체

Reference

[Tour of Go]

[GOPL]

 

 

댓글