원문 : An intro to Go for non-Go developers ( https://benhoyt.com/writings/go-intro/?fbclid=IwAR2dqFtMFep66auHbx0tDTAQvSvac7huECOieFV5BZYZa5L0w4bC0pD3_YY )
괜찮은 내용이 있어, 원문 중 일부를 번역했습니다. 전체 내용은 원문에서 확인하실 수 있으며, 또 내용 중 일부 오역이 포함되어 있을 수 있으니, 가능하면 원문을 참조하시는 걸 추천드립니다.
왜 Go 인가?
구글 트렌트에 따르면, Go는 지난 몇년간 인기가 가파르게 상승했습니다. 언어의 간략함이 일부 이유겠지만, 아마도 더 큰 이유는 훌륭한 도구때문이 아닐까 생각됩니다.
제가 Go로 프로그래밍하는 것을 좋아하는 이유는 아래와 같습니다. ( 아마 여러분도 좋아할 거예요. )
- 작고 간편한 언어. Go는 50페이지에 불과한 언어 사양을 가지며, C와 비슷합니다. ( 자바의 언어사양은 770페이지입니다. ) 이런 점이 배우거나 다른 사람을 가르칠 때 좋습니다.
- 고품질의 표준 라이브러리, 특히 서버와 네트워크 쪽 라이브러리가 좋습니다.
- 동시성 고루틴( 스레드와 비슷하지만 더 가볍습니다), goroutine을 시작하기 위한 Go 키워드, goroutine 간 통신을 위한 채널, 이 것들을 조화롭게 스케쥴링해주는 런타임.
- 원시 코드로 컴파일된다. 주요 플랫폼에 맞추어 쉽게 바이너리 코드를 만듭니다.
- 가비지 컬렉션,별도로 조작할 필요가 없습니다. ( 빠른 응답을 위해 최적화되어 있습니다. )
- 정적 유형 하지만, 유형 추론기능이 있습니다.
- 훌륭한 문서. 간결하고 실행 가능한 많은 예제가 있습니다.
- 우수한 도구. 빌드 go build, 테스트 go test 같이 간단한 명령 등등. CPU와 메모리 프로파일링, 코드 커버리지, 크로스 컴파일까지 이 모든 게 외부의 도구가 필요 없습니다.
- 빠른 컴파일. 처음부터 파른 컴파일을 위해 설계된 언어입니다. Rob Pike(공동 개발자)는 우스개소리로 "Go는 C++가 컴파일되는 동안 만들어졌다"라고 했습니다.
- 매우 안정적. 강력한 호환성 규약을 가진 라이브러리와 언어이기 때문에 첫번째 버전에서 작성된 모든 Go 프로그램은 최신의 Go에서도 수정 없이 동작합니다.
- 인기 많음. 2019년 스택오버플로의 설문에 따르면 프로그래밍 언어 중 가장 사용하고 싶은 언어이며, 개발자를 구하기도 쉬웠습니다.
- 클라우드에서 사용량이 많음. Docker와 Kubernetes가 Go로 작성되었고, Dropbox, Digital Ocena, CloudFlare 외에 많은 기업들에서 광범위하게 사용됩니다.
표준 라이브러리.
Go의 표준 라이브러리는 광범위하고, 플랫폼에 영향을 받지 않고, 문서화가 잘 되어있습니다. 파이썬과 유사하게 Go 내장기능이 있으므로, 다른 외부 라이브러리 없이도 서버나 CLI 애플리케이션을 만들 수 있습니다. 아래 몇몇 주요 라이브러리를 적었습니다.(제가 써본 것 위주로)
- 입출력: OS calls, 파일과 디렉터리,buffered I/O.
- HTTP: 바로 서비스할 수 있는 client and server, TLS, HTTP/2, 간단한 라우팅, URL and cookie 분석.
- 문자열: 문자열 기본 기능, raw bytes, unicode conversions.
- 인코딩: JSON, XML, CSV, base64, hex, binary, 그 외 다수.
- 템플릿: 간단하지만 강력한 text 템플릿과 자동으로 특수문자가 대체되는 HTML 템플릿.
- 시간: 잘 설계되었지만 간단한 date and time API.
- 정규식: a non-backtracking regexp library.
- 정렬: 정렬기능.
- 데이터베이스: database/sql외부 라이브러리에서 특정 부분을 구현하기 위하 남겨둔 인터페이스
- 암호화 라이브러리: AES, block ciphers, cryptographic hashes 등등이 안전하고 빠르게 구현되어 있습니다.
- 이미지: JPEG, PNG, and GIF를 읽고 쓸 수 있으며, 기본적인 합성도 가능합니다.
- Big numbers: 임의의 정확도를 지정할 수 있는 int and float.
- 아카이빙과 압축: tar, zip, gzip, bzip2, 기타 등등.
- 간단한 CLI flag 라이브러리.
- Go 소스코드 도구: parser, AST, code formatting.
- 리플렉션: 실행 중에reflection 지원.
외부 패키지라는 용어는 Go의 철학에서만 보자면 자바스크립트에서 npm 패키지를 통해 가져오는 방식과는 정반대입니다. Russ Cox(구글 Go팀의 기술 리더)는 소프트웨어 의존성 문제를 얘기했고, Go의 공동 개발자 Rob Pike도 "작은 카피가 약간의 의존성을 가지는 것보다 낫다"라고 비슷한 어조로 얘기했습니다. 그래서 대부분의 Go 개발자들은 외부 라이브러리를 사용한데 매우 보수적이라고 얘기합니다.
언어의 특징.
Hello world
Go는 C와 비슷한 문법을 사용합니다. 그리고 중괄호를 반드시 써야 하고, 세미콜론은 사용하지 않습니다. 프로젝트는 import와 package를 통해서 구성하고, 컴파일 단위는 하나 이상의. go 파일이 포함된 디렉터리입니다. "Hello world"는 다음과 같습니다.
package main
import "fmt"
func main() {
fmt.Println("Hello, world!")
}
논란이 될만한 특징.
Go를 처음 접한 일부 사람들에 혼란스러운 점이 있지만, 익숙해지면 꽤 괜찮은 기능이 있습니다. 한 가지는 코드 포맷팅입니다. "go fmt"로 실행할 수 있고, 중괄호와 공백을 go의 표준 형식대로 위치를 변경합니다. 코딩 스타일에 대한 논쟁을 피할 수 있는 좋은 방법이고 항상 정돈된 코드를 유지할 수 있습니다.
대문자로 된 이름은 public입니다. 소문자로 된 이름은 private입니다. 맨 처음 볼 때 매우 어색하지만, 이해하기 쉬운 규칙이고 java처럼 public static void 키워드를 늘어놓지 않아도 됩니다. 아래에서 보는 것처럼 public 키워드는 전혀 필요 없습니다.
package people
type Person struct {
Name string // fields Name and Age are exported ("public")
Age int
hairColor color // hairColor is not exported ("private")
}
func New() *Person { ... } // New is exported
func doThing() { ... } // doThing is not exported
다른 하나는 warning과 error입니다. 다른 컴파일러에선 warning을 낼지라도 Go에서는 에러입니다. 그래서 사용하지 않는 변수나, 사용하지 않는 import 구문이 있으면 컴파일 에러가 납니다. - 개발 중에 살짝 짜증 이나기도 합니다. 하지만, 코드를 깔끔하게 유지하고 개발자가 컴파일러의 warning을 피할 수 있습니다.
더 논란이 될만한 특징.
더욱 논란거리가 될만한 몇 가지 특징(기능이 없는 건가 싶은)이 있습니다. 이름하여, 예외처리의 부재와 사용자 정의 제너릭의 부재입니다.
Go는 기존의 하던 방식의 예외처리가 없습니다. 애초부터, 에러는 값이고 명시적으로 전달, 반환되는 다른 값과 마찬가지로 처리되어야 합니다. 따라서 FileNotFound 예외를 일으키는 대신 에러 값을 확인해야 합니다.
f, err := os.Open("filename.ext")
if err != nil {
log.Fatal(err)
}
// do something with the open file f
이것은 코드를 좀 더 길게 만듭니다. 하지만, 각 단계에서 에러 처리를 좀 더 명확하게 하는 이점이 있습니다. 내용을 추가할지, 에러를 기록할지 또는 상위 레벨의 에러로 변환할지 또는 무시할지를 선택할 수 있습니다.
두 번째는 Go는 사용자 정의 제너릭이 없다는 비판을 받습니다. 그래서 유형에 안전하도록 OrderedMap <int> 같은 것을 사용할 수 없습니다. 하지만 정적 유형을 가지고 있기 때문에 slice와 map 유형에 대해 문제없이 사용할 수 있습니다.
간결한 타입 추론.
Go는 변수를 선언할 때 := 연산자를 통한 간결한 타입 추론이 있습니다. 타입 추론은 타이핑을 줄여주기 때문에, 스크립트 언어를 사용하는 것 같은 느낌이 듭니다.
package main
import "fmt"
// Output: 3 4 hello 5
func main() {
var i int = 3
j := 4 // j is an int
s := "hello" // s is a string
a := add(2, 3) // a is an int
fmt.Println(i, j, s, a)
}
func add(x, y int) int {
return x + y
}
반면에, Go는 자동 형 변환이 없습니다. ( 정확도가 다른 interger 사이에서도 변환되지 않습니다. )
For 반복과 범위.
Go는 반복을 위해 for 하나의 키워드만 있습니다. for를 while 반목을 위해 사용하고 예전 c 스타일의 반복에도 사용하고, 범위 반복에도 사용합니다. slice나 map을 통해 범위가 주어지면, Go는 index와 객체의 값을 전달해 줍니다.
몇 가지 반복 예제가 있습니다.
// C style "for"
for i := 0; i < 10; i++ {
fmt.Println(i)
}
// Like "while"
for safe.IsLocked() {
time.Sleep(5 * time.Second)
}
// Loop through elements of array or slice
for index, person := range people {
fmt.Println(index, person)
}
// If you don't care about the index
for _, person := range people { ... }
// Loop through keys/values of a map
for word, count := range counts { ... }
slice가 매우 좋습니다.
Go에서 slice는 배열의 일부분을 참조하는 것입니다. 내부적으로 데이터 포인터, 길이, 용량을 사용하여 간단히 표현합니다. slice는 제너릭입니다. 그래서 float64의 slice는 [] float64로 Persion 구조체의 slice는 [] Persion으로 표기합니다.
파이썬 문법과 비슷하게 slice 할 수 있습니다. 예를 들어 slice [:5]는 처음 5개의 요소를 반환합니다. 생성된 slice도 원래 배열을 참조하므로 포인터처럼 효율적이지만, 런타임에서 슬라이스의 범위를 벗어나거나 다른 의도치 않은 행동을 막을 수 있다는 점에서 메모리가 안전합니다.
slice에 내장된 append() 함수를 통해서 요소를 추가할 수 있습니다. 만약 원본 배열의 크기가 충분치 않다면, 두배 크기의 배열을 생성하고 모든 요소를 복사합니다.
slice의 예제입니다.
// Create array and slice pointing into it
nums := []int{3, 4, 5, 6}
// Slice the slice
fmt.Println(nums[1:3]) // Output: [4 5]
fmt.Println(nums[:2]) // Output: [3 4]
fmt.Println(nums[2:]) // Output: [5 6]
// Append: may reallocate underlying array
nums = append(nums, 7, 8)
fmt.Println(nums) // Output: [3 4 5 6 7 8]
Maps
Go의 map은 key, value 쌍으로 이루어진 순서가 없는 hash table입니다. slice와 마찬가지로 generic이고 유형에 안전합니다. 그래서 map [string] int, 는 string을 key로 가지고 int value를 가진 map임을 뜻합니다.
map 유형은 get, set, delete, existence test, 그리고 반복 기능이 있습니다. slice와 마찬가지로 make로 메모리를 할 당할 수 있습니다.
map에 대해서 더 많은 내용이 있고 map의 구현에 대한 내용도 있지만, 여기서는 코드의 맛만 보여드리겠습니다.
phrase := "the foo foo bar the foo"
counts := make(map[string]int)
for _, word := range strings.Fields(phrase) {
counts[word]++
}
fmt.Println(counts)
// Output: map[foo:3 bar:1 the:2]
// map literal
maths := map[string]float64{
"pi": 3.14,
"tau": 6.28,
}
안전한 포인터
Go는 C, C++ 와는 달리 안전한 포인터를 가지고 있습니다. 존재하지 않는 메모리를 가리킬 수 없고 실행 중에 비어있는 포인터를 역참조 하는 것을 방지합니다. 사실 포인터 연산은 전혀 없습니다. 만약 인덱스로 뭘 하고 싶다면 slice를 사용하거나 하위 레벨의 안전하지 않은 패키지를 사용해야 합니다.
포인터는 C와 마찬가지로 '*'과 '&'를 사용합니다. '*'는 포인터가 가리키는 값을 가져오기 위해 사용하고, '&'는 변수의 주소를 가져옵니다.
문법적으로 좋은 점 중 하나는 C 스타일의 '->' 연산자(역참조를 하거나, 값을 가져오기 위해 사용)가 없다는 것입니다. 다음은 예제입니다.
p := new(Person) // p is a "pointer to Person"
p.Name = "Joe Bloggs"
p.Age = 42
pers := *p // dereference p back to Person
// More succinct alternatives
p = &Person{"Joe Bloggs", 42}
p = &Person{Name: "Joe Bloggs", Age: 42}
pers = Person{"Joe Bloggs", 42}
p = &pers
Defer
Go는 defer는 바로 이전에 실행한 기능이 끝날 때, 실행하라는 키워드입니다. defer가 여러 번 쓰이면 기능은 가장 마지막에 호출된 것부터 순차적으로 실행합니다. 파이썬의 with 구문과 C++ RAII처럼 다 사용한 리소스를 정리할 때 사용할 수 있습니다.
f, err := os.Open("file")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// read from f
Goroutine
goroutine은 Go의 동시성 제어 메커니즘입니다. 스레드와 비슷하지만, 훨씬 가볍습니다. Go 런타임은 goroutine을 OS의 스레드에서 스케쥴링합니다.
Go 동시성 모델이 멋진 점 중 하나는 모든 기본 라이브러리에 간단하고 동기 API가 있으며 동시성이 필요한 경우, goroutine을 명시적으로 사용한다는 점입니다. 이 때문에 몇몇 언어에 있는 동시 비동기 함수를 가지는 문제가 없습니다.
goroutine을 시작하기 위해서는 'go' 키워드를 함수 앞에 적어주면 됩니다. 그러면 Go 런타임이 함수를 goruotine에서 실행합니다. 회원가입을 처리하고 백그라운드로 이메일을 보내는 함수의 예를 들어보면 다음과 같습니다.
func ProcessSignup(u *User) {
u.SignedUpAt = time.Now()
u.Save(db)
go SendEmail(u.email, "Thanks for signing up!", "signup.html")
}
채널
goroutine을 시작한다고 실행 결과나, groutine ID를 반환하지는 않습니다. 만약 goroutine 사이에서 통신하거나, 완료되었다는 신호를 받고 싶다면 명시적으로 채널을 사용해야 합니다. 채널은 goroutine사이의 주요 통신 메커니즘이며, Go의 속담에는 "메모리를 공유해서 통신하지 말라, 통신함으로써 메모리를 공유하라"라는 말이 있습니다.
채널은 유형에 안전하고 스레드에 안전한 큐이고 데이터를 주고받을 수 있습니다. 또한 데이터를 동기화할 수 있습니다. 채널에서 데이터를 받으려는 goroutine은 다른 goroutine이 데이터를 입력할 때까지 기다립니다.
병렬로 실행하는 "배열 합" 예제가 있습니다. 이 예제는 goroutine을 사용해서 좋은 점은 없지만, 어떻게 동작하는지 잘 이해할 수 있습니다.
func main() {
s := []int{7, 2, 8, -9, 4, 0}
c := make(chan int)
go sum(s[:len(s)/2], c) // first half
go sum(s[len(s)/2:], c) // second half
// Receive both results from channel
x, y := <-c, <-c
fmt.Println(x, y, x+y)
}
func sum(s []int, c chan int) {
sum := 0
for _, v := range s {
sum += v
}
c <- sum // Send sum back to main
}
채널은 강력한 구조물이며, 설명할 것이 더 많지만, 여기서는 설명하지 않습니다.
유형과 메서드
Go는 사용자 정의 유형을 지원합니다. 그리고 각 유형은 메서드를 가질 수 있습니다. 하지만 클래스는 없습니다. ( 그래서 어떤 사람은 Go는 고급 언어가 아니라고 합니다. ) 그리고 구조체와 인터페이스가 있습니다. 하지만 상속은 없습니다. 모든 객체지향 장점은 합성으로 완성됩니다. 하지만 Go는 다르게 접근할 수 있는 도구를 제공합니다.
메서드는 "수신자" 인자를 가지는 유형으로 정의합니다. 그것은 파이썬의 self와 유사하며 다른 언어에서 this와 유사합니다. 하지만 메서드는 몇 가지 특징이 있습니다. ( 예를 들어, 수신자는 포인터나 값이 될 수 있습니다. ) 수신자의 이름은 원하는 대로 지을 수 있지만, 해당 유형의 첫 글자로 짓는 것이 일반적입니다.
두 개의 멤버를 갖는 구조체와 문자열을 반환하는 메서드 예제입니다.
type Person struct {
Name string
Age int
}
func (p *Person) String() string {
return fmt.Sprintf("%s (%d years)", p.Name, p.Age)
}
// Output: Bob (42 years)
func main() {
p := &Person{"Bob", 42}
fmt.Println(p.String()) // but .String() is optional; see below
}
인터페이스
인터페이스는 java 같은 언어와는 조금 다릅니다. class MyThings implements ThatInterface라고 정확히 적어주는 대신 Go에서는 유형의 모든 메서드를 정의한다면, 그 유형은 암시적으로 인터페이스를 구현하고 인터페이스를 부르는 곳 어디서든 사용할 수 있습니다. implements 키워드는 필요 없습니다.
이러한 Go의 접근 방식은 "정적 덕 타이핑"이라고 부릅니다. 그리고 그것은 구조적 유형의 형식입니다. ( TypeScript는 구조적 유형을 사용하는 또 다른 언어입니다. )
인터페이스는 Go에서 표준 라이브러리 어디든 사용됩니다. 가장 일반적인 두 가지 예는 Printf 가 문자열 버전의 값을 생성할 수 있는 Stringer 인터페이스와 파일, HTTP 서버, gzipped 파일, 문자열 버퍼를 처리 할 수있는 io.Reader 및 io.Writer 인터페이스입니다.
아래는 Stirnger와 Writer 인터페이스의 정의입니다. 모두 단순한 단일 메서드 인터페이스입니다. ( Go에서 작은 인터페이스는 일반적입니다. ) 이 인터페이스를 실제로 구현하지 않아도 되지만, 문법을 알 수 있습니다.
// Defined in package "fmt"
type Stringer interface {
String() string
}
// Defined in package "io"
type Writer interface {
Write(p []byte) (n int, err error)
}
// ...
func main() {
p := &Person{"Bob", 42}
fmt.Println(p.String())
// Equivalent (Person implements Stringer, which Println looks for)
fmt.Println(p)
}
HTTP 서버 예제.
시작하기 전에, Go에서 HTTP 서버를 구현하는 것이 얼마나 쉬운지 보여주는 몇 가지 작은 프로그램이 있습니다. 그리고 단순한 토이 프로젝트가 아니라 실제로 서비스 가능합니다. ( 다른 많은 언어들이 내장 웹서버를 내장하지만, 서비스를 위해서는 사용하지 마세요라고 합니다. )
사용자의 요청에 따라 응답을 보내는 기본적인 HTTP 서버가 있습니다. fmt.Fprintf에 전달된 io.Writer로 http.ResponseWrite를 사용하는 것을 잘 보세요.
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", handler)
fmt.Println("listening on port 8080")
http.ListenAndServe(":8080", nil)
}
func handler(w http.ResponseWriter, r *http.Request) {
user := r.URL.Query().Get("user")
if user == "" {
user = "world"
}
fmt.Fprintf(w, "Hello, %s", user)
}
조금 더 발전시켜서, 코드를 몇 줄 추가해서 정규식 기반의 라우팅을 가진 서버를 만듭니다.
package main
import (
"fmt"
"net/http"
"regexp"
)
type route struct {
pattern *regexp.Regexp
handler func(w http.ResponseWriter, r *http.Request, matches []string)
}
func home(w http.ResponseWriter, r *http.Request, matches []string) {
fmt.Fprintf(w, "Home")
}
func user(w http.ResponseWriter, r *http.Request, matches []string) {
user := matches[1]
fmt.Fprintf(w, "User ID: %s", user)
}
func main() {
routes := []route{
{regexp.MustCompile(`^/$`), home},
{regexp.MustCompile(`^/user/(\w+)$`), user},
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
for _, route := range routes {
matches := route.pattern.FindStringSubmatch(r.URL.Path)
if len(matches) >= 1 {
route.handler(w, r, matches)
return
}
}
http.NotFound(w, r)
})
fmt.Println("listening on port 8080")
http.ListenAndServe(":8080", nil)
}
Go 도구.
최근에 가장 좋아하는 개발 툴에 대한 질문을 받았습니다. 맨 처음 대답은 "sublime text"였지만, 마음을 바꿨습니다. 가장 좋아하는 개발 툴은 go 명령입니다. Makefile을 만들지 않고, 아래 일들을 할 수 있습니다. 그것도 아주 빠르게.
go build # 프로젝트 빌드, 결과물로 실행가능한 파일이 만들어집니다.
go run # 개발에 사용하기 위해서 빌드하고 실행합니다.
go fmt # .go 파일을 표현 형식에 맞게 변경합니다.
go test # 테스트를 실행합니다.
go test -bench=. # 테스트와 벤치마크를 동시에 실행합니다.
go mod init # "Go modules" 프로젝트를 초기화합니다.
go get github.com/foo/bar # "bar" 패키지를 다운로드하여 설치합니다.
더 많은 명령이 있으니 전체 문서를 읽어보세요.
그리고 더 놀라운 것은 GOOS, GOARCH환경변수를 설정했다면, go build를 실행할 때, Go는 주어진 OS에 맞게 크로스 컴파일을 해줍니다. MacOS나 Windows에서 Linux 바이너리를 만드는 한 줄짜리 예제입니다.
GOOS=linux GOARCH=amd64 go build
멋지지 않나요? 파스칼 이래로 개발이 이렇게 쉬웠던 적이 없었습니다.