그냥 빌어먹을 Go를 써라

4 days ago 11
  • Go는 백엔드 개발의 과도한 복잡성을 줄이는 선택지이며, 빠른 컴파일단일 바이너리 배포, 안정적인 의존성 관리가 핵심 장점임
  • Go는 데코레이터, 메타클래스, 매크로, trait, monad 같은 복잡한 추상화 대신 struct, 함수, 인터페이스, goroutine, channel 중심의 단순한 언어 설계를 택함
  • embed, html/template, net/http, database/sql, encoding/json, go test, pprof 등 표준 라이브러리와 기본 도구만으로 웹 앱, 데이터베이스, 테스트, 벤치마크, 프로파일링까지 처리 가능함
  • goroutine은 약 2KB 비용의 stackful 실행 단위이며, channel, sync.Mutex, race detector, context.Context를 통해 동시성 처리와 취소 전파를 단순하게 다룰 수 있음
  • go mod init, go build, scp, systemctl restart로 이어지는 흐름은 node_modules, 복잡한 Docker·Kubernetes 구성, 과도한 마이크로서비스보다 하나의 Go 바이너리와 Postgres 중심의 단순 배포를 권함

Go를 선택해야 하는 이유

  • Go는 백엔드 개발의 과도한 복잡성을 줄이는 선택지이며, 빠른 컴파일, 단일 바이너리 배포, 안정적인 의존성 관리가 핵심 장점임
  • 프론트엔드에서 HTML이 과도한 복잡화의 대안으로 남아 있었듯, Go도 10년 넘게 백엔드 단순화를 위한 선택지로 존재해 왔음
  • 단순한 폼 제공이나 초당 약 40개 요청 수준의 CRUD 앱에 Node 패키지 다수, TypeScript 빌드 도구, Kubernetes, Rails 플랫폼 팀, Rust 재작성까지 동원하는 것은 과함
  • Go의 지향점은 “영리한 추상화”보다 읽기 쉬운 코드, 배포 가능한 결과물, 작은 운영 부담에 있음

지루함을 의도한 언어 설계

  • Go가 지루하게 느껴지는 이유는 의도적인 설계에 있으며, 데코레이터, 메타클래스, 매크로, trait, monad 같은 복잡한 추상화를 제공하지 않음
  • 핵심 구성 요소는 struct, 함수, 인터페이스, goroutine, channel 정도로 제한됨
  • 사양을 짧은 시간 안에 읽고 같은 날 생산적으로 코드를 작성할 수 있을 정도의 단순함을 목표로 함
  • 지루함은 팀 코드베이스에서 장점으로 작동함
    • 지난달 입사한 주니어도 2년 전 principal이 작성한 코드를 읽을 수 있음
    • gofmt가 하나의 포맷을 강제하므로 코드 스타일 논쟁이 줄어듦
    • 언어 자체가 지나치게 복잡한 추상화를 코드베이스에 끼워 넣기 어렵게 만듦

표준 라이브러리가 프레임워크 역할을 함

  • Go는 별도 웹 프레임워크 없이도 표준 라이브러리만으로 웹 앱을 만들 수 있음
  • embed, html/template, net/http를 사용하면 HTML 템플릿을 바이너리에 포함하고 HTTP 핸들러로 렌더링하는 앱을 구성할 수 있음
package main import ( "embed" "html/template" "net/http" ) //go:embed templates/*.html var files embed.FS var tmpl = template.Must(template.ParseFS(files, "templates/*.html")) func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { tmpl.ExecuteTemplate(w, "index.html", map[string]string{ "Name": "asshole", }) }) http.ListenAndServe(":8080", nil) }
  • 이 예시는 동작하는 웹 앱이며, HTML 템플릿이 바이너리에 컴파일되어 포함됨
  • webpack, Vite, 개발 서버, 거대한 node_modules 없이 go build 후 파일 하나를 배포할 수 있음
  • 표준 라이브러리와 기본 도구만으로 주요 백엔드 작업을 처리할 수 있음
    • 데이터베이스: database/sql
    • JSON: encoding/json
    • 다른 서비스 호출: net/http 클라이언트
    • 동시 실행: go 키워드
    • 테스트: go test
    • 벤치마크: go test -bench
    • 프로파일링: pprof

깊이 있는 표준 라이브러리 구성

  • io.Reader와 io.Writer

    • io.Reader와 io.Writer는 각각 메서드 하나만 가진 인터페이스지만, Go 생태계 전반의 중요한 기반으로 작동함
    • HTTP 응답 본문을 gzip writer로 연결하고 다시 디스크 파일로 이어 붙이는 식의 조합을 적은 코드로 처리할 수 있음
    • 주요 패키지들이 이 두 인터페이스를 공유하므로, 같은 패턴을 여러 곳에서 반복적으로 활용할 수 있음
  • context.Context

    • context.Context는 취소 전파를 위한 표준 방식임
    • 사용자가 브라우저 탭을 닫으면 요청 context가 취소되고, 이어서 데이터베이스 쿼리와 하위 HTTP 호출까지 취소될 수 있음
    • goroutine 누수나 연결 풀을 소모하는 좀비 쿼리를 피하려면 context를 첫 번째 인자로 전달하고 존중해야 함
  • 인코딩 패키지

    • encoding/json, encoding/xml, encoding/csv, encoding/binary가 모두 표준 라이브러리에 포함됨
    • struct tag 패턴과 포인터로 디코딩하는 사용감이 유사하므로 하나를 익히면 다른 패키지도 쉽게 사용할 수 있음

고통을 줄이는 동시성 모델

  • goroutine은 OS thread 자체가 아니며, 런타임이 OS thread 위에 다중화하는 stackful 실행 단위임
  • goroutine은 시작 비용이 약 2KB이며, 노트북에서도 10만 개를 생성할 수 있음
  • channel은 goroutine 사이의 타입 있는 파이프로 동작하며, 한쪽에서 보내고 다른 쪽에서 받으면 런타임이 동기화를 처리함
  • 공유 상태가 필요할 때는 sync.Mutex를 사용할 수 있고, race detector가 데이터 레이스를 찾아줌
  • 병렬 HTTP fetcher도 별도 라이브러리나 프레임워크, async/await 의식 없이 작성 가능함
results := make(chan string, len(urls)) for _, url := range urls { go func(u string) { resp, _ := http.Get(u) results <- resp.Status }(url) } for range urls { fmt.Println(<-results) }

실제 CRUD 라우트 예시

  • Postgres에서 게시글을 읽고 HTML을 렌더링하는 CRUD 성격의 라우트도 한 화면에 들어갈 정도로 단순하게 구성됨
//go:embed templates/*.html var tmplFS embed.FS var tmpl = template.Must(template.ParseFS(tmplFS, "templates/*.html")) type Post struct { ID int Title string Body string } func postsHandler(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rows, err := db.QueryContext(r.Context(), "SELECT id, title, body FROM posts ORDER BY id DESC LIMIT 50") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() var posts []Post for rows.Next() { var p Post if err := rows.Scan(&p.ID, &p.Title, &p.Body); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } posts = append(posts, p) } tmpl.ExecuteTemplate(w, "posts.html", posts) } }
  • 이 예시는 데이터베이스, 템플릿, HTTP 핸들러를 한곳에서 보여줌
  • r.Context()가 SQL 쿼리에 전달되므로 연결이 닫히면 쿼리도 취소될 수 있음
  • ORM, DI 컨테이너, 서비스 계층, 추상 기반 클래스가 많은 controllers/ 디렉터리 없이 위에서 아래로 읽으며 동작을 파악할 수 있음

주말을 망치지 않는 의존성 관리

  • go mod init으로 모듈을 시작하면 의존성은 go.mod와 go.sum에 기록됨
  • go.sum은 실제로 받은 항목에 대한 암호학적 기록으로, 기대한 것과 다른 의존성이 들어오는 상황을 확인할 수 있게 함
  • node_modules 디렉터리, 개발 환경과 CI 사이의 lockfile drift, peer dependencies, optional dependencies, devDependencies, peerDependenciesMeta 같은 복잡성이 없음
  • 오프라인 빌드가 필요하면 go mod vendor가 의존성을 vendor/ 디렉터리에 내려받고, 툴체인이 이를 자동으로 사용함
  • 프로젝트 전체와 의존성을 tarball 하나에 담을 수 있어 운영과 보안 검토 측면에서 유리함

컴파일러와 함께 제공되는 도구

  • Go의 기본 도구들은 서드파티 플러그인이나 별도 설정 파일 없이 제공됨
  • gofmt는 코드 포맷을 표준화하며, 포맷 논쟁과 공백 변경으로 인한 diff 증가를 줄임
  • go vet은 명백한 실수를 잡는 데 사용됨
  • go test는 테스트를 실행함
  • go test -race는 race detector와 함께 테스트를 실행해 데이터 레이스를 찾음
  • go test -bench는 벤치마크를 실행함
  • go test -cover는 테스트 커버리지를 확인함
  • go tool pprof는 실행 중인 프로덕션 서비스의 HTTP 엔드포인트를 통해 CPU와 메모리 사용량 flame graph를 얻을 수 있게 함

배포는 복사 명령으로 끝남

  • Go 배포의 핵심 흐름은 바이너리를 빌드하고 서버에 복사한 뒤 실행하는 것임
GOOS=linux GOARCH=amd64 go build -o myapp ./cmd/myapp scp myapp user@server:/usr/local/bin/ ssh user@server 'systemctl restart myapp'
  • 이 흐름은 Dockerfile, multi-stage build, base image CVE 알림, Kubernetes manifest, Helm chart, ArgoCD, service mesh, sidecar 없이 배포 가능함
  • 12MB의 정적 링크 바이너리와 20줄짜리 systemd unit 파일만으로 프로덕션 배포가 가능함
  • Docker가 꼭 필요하다면 Go 바이너리를 FROM scratch 이미지에 넣는 방식으로 충분함

프레임워크와의 대비

  • Rails, Django, Express, Next.js 같은 프레임워크에는 각자의 배포 절차, ORM, admin, middleware, npm 경고, 라우팅 관례 변화 같은 부담이 있음
  • Go 바이너리는 컴파일되어 실행되며, 5년 뒤에도 실행될 수 있는 안정성을 장점으로 가짐
  • 프레임워크가 더 빨리 폐기되거나 유지보수자가 번아웃을 호소할 수 있다는 대비 속에서, Go의 단순한 실행 모델이 두드러짐

마이크로서비스보다 단일 Go 바이너리

  • 마이크로서비스가 기본 선택지가 되어서는 안 되며, 먼저 모놀리스를 작성하는 편이 좋음
  • 권장 구성은 하나의 Go 바이너리, 하나의 Postgres, 꼭 필요할 때만 하나의 Redis임
  • HTML과 JSON API를 같은 포트에서 제공하고, 단일 VPS에서 실행하는 구성이 가능함
  • Go는 goroutine 비용이 낮고 동시성 처리가 강하므로, 초당 1만 요청까지도 무리 없이 확장할 수 있음
  • 실제로 분리할 필요가 생기면 Go 모놀리스에서 패키지를 별도 저장소로 옮기는 방식으로 나눌 수 있음
  • 인터페이스가 이미 존재하므로, 언어가 자연스럽게 분리를 고려한 구조를 만들게 함

제네릭과 에러 처리

  • if err != nil은 버그가 아니라 기능임
  • 각 실패 지점에서 무엇을 할지 직접 판단하게 만들어 에러를 숨기지 않음
  • try/catch 중첩은 에러를 없애는 것이 아니라 프로덕션 장애 시점까지 숨길 수 있음
  • 제네릭은 Go 1.18에 도입되었고, 필요할 때 사용하면 됨

결론

  • 프레임워크, 마이크로서비스, Rust 재작성, 새 JavaScript 메타프레임워크가 꼭 필요한 것은 아님
  • go mod init을 실행하고, main.go를 작성하고, 템플릿을 embed한 뒤 컴파일해 배포하는 단순한 흐름을 권함
  • 지루한 선택이 올바른 선택이며, Go가 그 선택지임
Read Entire Article