blog.coinsect.io
🇰🇷
원문
마크다운
2024-10-11
145
공유

내가 golang을 사랑하는 이유

커리어 초창기에 일했던 회사에서 나는 golang으로 백엔드에 입문했다. 물론 그 이전에도 PHP + Codeigniter 조합으로~~틀~~ SSR을 했던 경험이 있긴 한데, 본격적으로 백엔드 + 프론트엔드가 분리된 웹앱을 만들어본 경험에서 첫 도구는 golang이었다. 그리고 이 때 받았던 인상은 너무나 긍정적이었어서, 지금도 최애인 nodejs와 병용하고 있다. ## 마스코트 ![](https://go.dev/blog/gopher/header.jpg) ## 누가 쓰나? ![](https://d1085v6s0hknp1.cloudfront.net/boards/coinsect_blog/16c6bdd9-b509-4f79-913d-06d377396a78_image.png) 이렇게 이름만 대도 알 만한 굴지의 기업들~~이띄리움~~이 golang을 사용하고 있다. ## 웹 프레임워크들 - `net/http` => golang 자체 패키지. - [gin](https://github.com/gin-gonic/gin) - [beego](https://github.com/beego/beego): 서두의 회사를 다닐 때는 사장님이 ORM만 여기서 가져와 조립해 제작하신 자체 프레임워크를 사용했었음. - [echo](https://github.com/labstack/echo) ## 장점 - **쉽다.** 일단 예약어부터가 불과 아래의 25개에 불과하다. ![](https://d1085v6s0hknp1.cloudfront.net/boards/coinsect_blog/0acf67e3-085e-41ec-95ee-054c07ce696c_image.png) 앞서 말한 회사에서의 경험을 돌이켜보면, 이미 잘 짜여진 코드베이스를 보며 학습하면서 내가 처음으로 API를 추가해보기까지 고작 일주일이 걸리지 않았던 것으로 기억한다. 당시 나의 전반적인 SW 개발 경험이 매우 적었던 것을 고려하면 매우 놀라운 학습곡선을 지닌 언어라는 생각이 든다. - **강력한 타입 추론/체크** => 런타임에서 nil pointer를 참조하는 에러를 제외하면, 대부분의 실수가 컴파일러를 통해 미리 방지된다. - 인터프리터처럼 쓸 수 있을 만큼 **컴파일 속도가 매우 빠르다.** 소스코드를 고치고 `go build && [컴파일된 바이너리명]`를 실행하면 불과 몇 초 만에 변경사항이 반영된 바이너리가 실행된다. 나는 개인적으로 네이티브 앱 개발을 싫어한다. 컴파일이 오래걸리기 때문이다. 반대급부로 hot-reloading이 지원되는 모던 웹개발을 좋아하는데, 성격이 급한 사람으로서 golang은 너무 만족스러운 도구이다. 어찌 그리 컴파일이 빠른지 ChatGPT에게 물어봤더니 뭐 이렇다고 한다. ``` 간단한 의존성 관리: Go는 복잡한 헤더 파일을 사용하지 않으며, 대신에 패키지 단위로 의존성을 관리합니다. 이는 C/C++처럼 매번 복잡한 의존 관계를 컴파일러가 추적하지 않게 만들고, 필요한 부분만을 컴파일하는 데 도움을 줍니다. 병렬 컴파일: Go는 멀티코어 시스템에서 병렬로 코드를 컴파일할 수 있도록 설계되었습니다. 이를 통해 여러 파일이나 패키지를 동시에 컴파일하여 속도를 극대화할 수 있습니다. 간결한 언어 설계: Go 언어 자체가 복잡한 기능(예: 매크로, 템플릿, 상속 등)을 최소화하여 컴파일러가 더 빠르게 코드를 처리할 수 있도록 설계되었습니다. 고정된 타입 시스템: Go는 타입 추론을 지원하지만, 정적 타입 시스템으로 고정된 타입을 사용하여 컴파일러가 코드를 분석하는 데 드는 시간을 줄입니다. 이것은 특히 대규모 코드베이스에서 큰 이점을 제공합니다. 링크 시간이 짧음: Go는 자체적으로 링크러(linker)를 최적화하여 바이너리를 생성할 때 빠르게 링크 작업을 수행할 수 있습니다. 또한 Go는 자체 런타임을 포함하는 특성 덕분에 다른 외부 라이브러리와의 링크 작업이 복잡하지 않습니다. 단일 바이너리 생성: Go는 모든 종속성 파일을 하나의 정적 바이너리에 포함하는 방식으로 컴파일되기 때문에 실행 파일을 만드는 과정에서 추가적인 동적 링크 작업이 필요하지 않습니다. 이 역시 속도에 기여하는 요소입니다. ``` - **빌드된 파일이 바이너리 하나로 나온다.** 개인적으로 이게 너무나 큰 장점이라고 생각하는데, 파일을 실행할 타겟 머신에서 컴파일만 성공한다면 그걸로 끝이다. 그냥 실행하면 된다. 일반적인 스크립트 언어들처럼 서버에 코드를 노출할 필요도 없고, `systemctl`등 일반적으로 사용되는 리눅스 데몬들과의 궁합도 매우 좋다. 뭐 프로덕션에서 그럴 일은 없겠지만 바이너리이므로 당연히 `nohup`과 사용할 수도 있다. - **강력한 성능.** go는 원래 동시성 처리를 염두에 두고 goroutine이라는 경량 스레드를 사용하도록 설계되었다. 그리고 내장된 http 패키지는 request마다 이 goroutine을 할당해 처리하기 때문에, request마다 프로세스나 스레드를 할당하거나 이벤트 루프로 처리하는 방식에 비해 일반적으로 높은 rps를 보인다. 벤치마크들을 찾아보면 스트레스 테스트에서 대체로 golang 서버의 퍼포먼스가 타 언어 기반 서버들에 비해 상당히 높은 편임을 알 수 있다. - **강력한 비동기 지원.** 자바스크립트를 제외하면 대부분의 언어들은 동기가 기본이다. 윗줄이 완료되어야 다음줄이 실행되며, 이것은 논리적으로 자연스러운 사고의 흐름이기도 하다. golang도 기본적으로는 동일하긴 한데, 함수 실행시 단지 앞에 `go`를 붙이는 것만으로도 javascript에서 Promise 타입의 함수들을 실행하듯이 결과를 기다리지 않고 다음줄로 넘어갈 수 있다. ```go go doNotWaitForThisFunction() nextFunction() ``` 이 코드의 경우, `doNotWaitForThisFunction()`이 종료되기를 기다리지 않고 바로 다음 줄의 `nextFunction()`으로 넘어가는데, 다른 언어들에서는 일반적으로 이런 비동기 실행 구현이 간단하지는 않다. 워커나 스레드풀을 만들어서 함수를 거기 넘기고... golang에서는 이런 복잡한 고민들을 할 필요 자체가 없다. 알아서 적절하게 goroutine들을 할당하고 GC해준다. - **포인터가 존재한다.** 물론 C에서의 그것처럼 복잡하고 정교하게 컨트롤 할 수 있는 수준까지는 아니지만, 현재 내가 함수에 인자로 넘기는 것이 레퍼런스(받는 쪽에서 수정 가능)인지 값(복사되는 값이므로 받는 쪽에서 수정 불가능)인지가 코드에 보다 명확하게 드러나는 장점이 있다. 호불호가 있을 수 있기는 한데, 개인적으로는 좋았다. ```go package main import "fmt" func modifyValue(x int) { x = 10 // 복사된 값을 수정 } func modifyPointer(x *int) { *x = 10 // 원본 값을 수정 } func main() { a := 5 modifyValue(a) // 값에 의한 전달 fmt.Println(a) // 출력: 5 (원본은 그대로) modifyPointer(&a) // 포인터에 의한 전달 fmt.Println(a) // 출력: 10 (원본이 수정됨) } ``` ## 기타 - go 1.18부터 드디어 generic이 추가되었다! 난 generic이 없던 시절부터 go를 사용했기에 `int` `int64` 등 각 자료형별로 똑같은 로직을 복붙하며 괴로워했던 기억이 있다. ```go // no one wants to do this: func add(a int, b int) int { return a + b } func addInt64(a int64, b int64) int64 { return a + b } func addFloat64(a float64, b float64) float64 { return a + b } // generic was introduced in go 1.18 🥹 func add[T int | float32 | float64 | int64](a T, b T) T { return a + b } ``` - 같은 폴더 안의 파일들은 같은 패키지로 묶이며, 변수이든 함수이든 **첫글자가 대문자**이면 자동으로 export된다. 익숙해지면 편리하긴 한데, 모르는 사람 입장에서는 처음에 다소 황당하게 느껴질 수 있다. 이것은 struct를 선언할 때도 마찬가지이다. ```go func nonExportedFoo() {} func ExportedFoo() {} var nonExportedVar = true var ExportedVar = true /* 이 경우 Name 필드는 외부에서 접근 가능하나, age는 무시된다. 따라서 json.Marshal / json.Unmarshal시에도 age필드는 대상에서 제외된다. */ type Person struct { Name string `json:"name"` // 대문자로 시작: 인식 age string `json:"age"` // 소문자로 시작: 무시 } ``` - **매우 호불호가 갈리는 에러처리 방식**을 갖고 있다. 대부분의 언어들처럼 `try <=> catch` 구문으로 에러가 발생할 장소를 적당히 쫙 감싸서 처리하는 것이 아니라, 명확히 에러처리가 필요한 부분들에 하나하나 다음과 같이 명시적으로 `if`문을 넣는다. ```go result, err := fooThatCouldCauseError() if err != nil { return nil, err } return result, nil ``` 개인적으로는 조금 귀찮기는 하더라도 이렇게 하는 것이 더 견고하고 런타임 에러를 줄일 수 있는 방식이라 생각한다. 또 함수를 잘 설계하고 early return을 최대한 활용하며 `else`의 사용을 줄이는 mental framework에 어울리는 방식이라고도 본다. ![](https://www.dolthub.com/blog/static/46fd9e1eaaed072e99d32a35981525f1/65654/one-weird-trick.png) - 아무래도 강타입 컴파일 언어이다 보니 JSON을 다루는 것이 아주 편리하지는 않다. `map[string]interface{}`를 갖고 약간의 꼼수(?)를 쓰면 JavaScript에서 객체 리터럴(`{}`)을 사용하듯 임의 구조의 JSON을 좀 편리하게 다룰 수 있다. ```go type H = map[string]interface{} myObj := H{ "company": "coinsect.io", "devs": []H{ { "name": "Chris", "age": 18, }, { "name": "Bob", "age": 24, "salary": 200000, }, }, } ``` - 연속된 자료형을 표현하는 개념으로 'array'와 'slice'가 따로 존재한다. 차이라면 array는 선언시 길이가 정해져서 변할 수 없는 반면 slice는 길이가 변할 수 있다는 것이다. 사실 내 기준으로 실무에서 array를 쓰는 경우는 거의 못 봤다. - JavaScript 등을 비롯해 forEach, map, filter등의 고차함수를 자체적으로 지원하지는 않아서, 대체로 반복은 `for`문에 의존해야한다. 이 부분은 솔직히 너무 옛날 C 냄새가 강해 불편하긴 하다. 만약 map을 구현한다 치면(~~근데 할 일 없음~~), 콜백함수의 리턴타입(`type ... struct`)을 미리 정해야 한다는 강타입 언어의 제약도 귀찮을 수 있다. 물론 정말 필요하다면 forEach, map 등을 메서드로 갖는 커스텀 slice (또는 array) 구조체를 만들어 사용할 수는 있다.(~~동료들의 멘탈 삭제~~) - 가비지 콜렉션(GC) 기능이 있는 managed 언어이다. [Discord의 경우는 Go를 쓰다가 GC 때문에 latency spike이슈가 있어서 Rust로 옮겼다는 글](https://discord.com/blog/why-discord-is-switching-from-go-to-rust)도 있는데, 그정도로 성능이 크리티컬한 앱이면 당연히 unmanaged 언어를 선택하는게 맞다. 그러나 일반적인 범용 API 서버의 경우 Go는 경쟁군의 도구들 대비 충분히 높은 성능과 가용성을 제공한다. 경쟁군이 대체로 스크립트 언어들이니 컴파일 언어인 Go가 성능상 이기고 들어가는 것은 어찌보면 당연하다. - 위 이야기의 연장선으로, 힙/스택 무엇을 사용할지를 프로그래머가 관리하지 않는다. 예를 들어 go에서 struct를 생성하는 예시를 살펴보자. ```go type Person struct { Name string Age string } // 방법 1 p1 := &Person{name: "Chris", Age: 80} // 방법 2 p2 := new(Person) p2.Name = "Chris" p2.Age = 80 ``` go에서는 새로운 struct 인스턴스를 생성할 때 위와 같은 방법들을 사용하는데, 코드를 보면 마치 Java에서 `new`로 객체를 생성하거나 C 언어에서 `malloc`을 하는 느낌이라 웬지 heap에 할당될 것 같지 않은가? 그러나 go에서는 Escape Analysis라는 과정을 통해 선언된 변수를 힙에 할당할지 스택에 할당할지를 컴파일러가 알아서 결정하고 사용이 끝나면 GC를 통해 자동으로 회수한다. 선언된 변수가 함수 내에서만 사용된다면 **스택**에, 포인터로 리턴된다면 함수가 종료된 후에도 다른 곳에서 사용될 여지가 있으므로 **힙**에 할당된다. ## 마치며 연봉도 높은 언어이고 해외는 물론 국내에서도 일자리를 찾아보면 은근히 있으니 공부해두면 좋다. 채널(`chan`)이라는 문법과 개념이 좀 어려운데, [gorilla websocket 예시](https://github.com/gorilla/websocket/blob/main/examples/chat/hub.go)를 보면 이해하는데 도움이 된다. (코드만 봐서는 어렵고, 실제 로컬에서 실행해보면 확 와닿는다.) 본인이 서버 개발자이고 이미 주력으로 Java, Kotlin, Nodejs, Python, Ruby 등을 익숙하게 다룬다면, go 역시 기술셋에 추가하기를 강력히 권장한다. 작은 서버를 여러개 띄우는 MSA 형태의 서비스를 구성할 때 매우 좋은 선택지일 수 있다.
0