Skip to content
Published on

Go 완전 가이드 — Goroutine·Channel·Context·실전 마이크로서비스까지 (Season 2 Ep 3, 2025)

Authors

들어가며 — Go는 왜 여전히 쓰이는가

Rust가 "배우기 어렵지만 강력한" 쪽이라면, Go는 "배우기 쉽지만 프로덕션에 즉시 투입 가능한" 쪽이다.

2024~2025 Go의 현재 위치:

  • Kubernetes: 클라우드 네이티브의 중심. Go로 작성됨
  • Docker: 컨테이너 생태계의 원조. Go
  • Terraform: IaC의 표준. Go
  • Prometheus / Grafana: 관측성 표준. Go
  • CockroachDB / InfluxDB / etcd: 분산 DB. Go
  • gRPC: Google 표준 RPC. Go 퍼스트
  • Cloudflare, Uber, Monzo, Square: 마이크로서비스 주력 언어
  • Go 1.23 (2024년 8월): range over func 이터레이터, 개선된 timer
  • Go 1.24 (2025년 2월): Generic Type Alias, 향상된 tool directive

Go는 "쿠버네티스 세상의 공용어"다. DevOps·SRE·Cloud Native 엔지니어에게는 선택이 아니라 필수.


1부 — Go의 철학: Less is More의 진짜 의미

1.1 Go가 의도적으로 제외한 것들

Go는 명시적으로 언어에서 제외한 것이 많다:

  • Generic (1.18까지 없었음) → 의도적으로 늦게 도입
  • 삼항 연산자 → if-else로 통일
  • 예외(try/catch) → Error as value
  • 상속 → Composition only
  • Macro → 코드 생성 도구로 대체
  • 함수 오버로딩 → 이름 다르게

Rob Pike (Go 창시자) 왈: "Go는 Java가 너무 많다고 느끼는 사람들을 위한 언어다."

1.2 Go의 설계 원칙 5가지

  1. Simplicity: 언어 스펙을 주머니에 들고 다닐 수 있을 만큼 작게
  2. Composition over Inheritance: interface + embedding으로 충분
  3. Explicit over Implicit: magic 없음. 모든 동작이 코드에 드러나야
  4. Concurrency as First-class: Goroutine·Channel은 언어 차원
  5. Pragmatism: 순수함보다 현장성

1.3 Go가 잘하는 것 vs 못하는 것

잘함못함
마이크로서비스저수준 메모리 제어
CLI 도구GPU 컴퓨팅
네트워크 프로그램실시간 시스템
DevOps 도구게임 엔진
gRPC 서비스모바일 UI
컨테이너/오케스트레이션임베디드 펌웨어

2부 — Goroutine: Go의 동시성 모델

2.1 Goroutine이란

경량 스레드. OS 스레드 1개가 수천 개의 Goroutine을 스케줄링.

go func() {
    fmt.Println("hello from goroutine")
}()
  • OS 스레드: ~2MB 스택
  • Goroutine: 시작 시 2KB 스택, 필요 시 동적 확장
  • GOMAXPROCS (기본 CPU 코어 수)만큼 OS 스레드 사용

2.2 M:N 스케줄러

  • M: OS 스레드 (Machine)
  • N: Goroutine
  • P: 프로세서 (Goroutine 큐 관리)

Go 런타임이 M개 Goroutine을 N개 스레드 위에 멀티플렉싱. Rust async의 Tokio와 구조적으로 유사.

2.3 Goroutine 누수 — 가장 흔한 프로덕션 버그

// 잘못된 코드: receiver가 없으면 sender는 영원히 대기
func leak() {
    ch := make(chan int)
    go func() {
        ch <- 42 // 받는 사람 없음 → 영원히 블록
    }()
    // 함수 종료, 하지만 goroutine은 살아있음
}

해결 패턴:

  • context.Context로 취소 전파
  • select<-ctx.Done() 포함
  • Buffered channel + timeout

2.4 Goroutine 제어 패턴 3가지

// 1. WaitGroup — N개 작업 완료 대기
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        // work
    }(i)
}
wg.Wait()

// 2. errgroup — 에러 전파 + 취소
import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
    url := url
    g.Go(func() error {
        return fetch(ctx, url)
    })
}
if err := g.Wait(); err != nil { /* ... */ }

// 3. Semaphore — 동시 실행 수 제한
import "golang.org/x/sync/semaphore"
sem := semaphore.NewWeighted(10)
for _, task := range tasks {
    task := task
    sem.Acquire(ctx, 1)
    go func() {
        defer sem.Release(1)
        do(task)
    }()
}

3부 — Channel: "Don't communicate by sharing memory. Share memory by communicating."

3.1 Channel 기본

ch := make(chan int)      // Unbuffered
ch := make(chan int, 10)  // Buffered

ch <- 42           // 송신
val := <-ch        // 수신
close(ch)          // 닫기
val, ok := <-ch    // ok=false면 닫혔고 비었음

for v := range ch { // 닫힐 때까지 반복
    fmt.Println(v)
}

3.2 Unbuffered vs Buffered

  • Unbuffered: 송신자와 수신자가 동기화. Sender는 Receiver가 준비될 때까지 대기 (Rendezvous)
  • Buffered: 버퍼가 차기 전까지 Sender는 블록되지 않음

3.3 select — 멀티플렉싱

select {
case msg := <-ch1:
    fmt.Println("from ch1", msg)
case msg := <-ch2:
    fmt.Println("from ch2", msg)
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
case <-ctx.Done():
    fmt.Println("canceled")
}

select가 Go 동시성의 진짜 무기. Channel을 조합해 복잡한 패턴 구축.

3.4 주요 Channel 패턴 5가지

  1. Fan-out: 하나의 Channel을 여러 Goroutine이 소비
  2. Fan-in: 여러 Channel을 하나로 합침
  3. Pipeline: 단계별 Channel 체인
  4. Worker Pool: N개 Worker + Job Channel
  5. Done Channel: 취소 신호 전파 (Context의 전신)
// Worker Pool
jobs := make(chan Job, 100)
results := make(chan Result, 100)

for w := 1; w <= 10; w++ {
    go worker(jobs, results)
}

for _, j := range allJobs {
    jobs <- j
}
close(jobs)

for a := 1; a <= len(allJobs); a++ {
    <-results
}

3.5 Channel 함정 3가지

  1. 닫힌 Channel에 쓰기: panic. sync.Once로 한 번만 close 보장
  2. nil Channel 송수신: 영원히 블록. select에서 비활성화 트릭으로 사용
  3. Over-synchronization: Channel이 Mutex보다 항상 좋은 건 아님

4부 — Context: Go 생태계의 혈류

4.1 Context란

요청 범위의 취소·데드라인·값을 전파하는 표준 인터페이스.

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

4.2 4가지 기본 생성 함수

ctx := context.Background()            // 루트
ctx := context.TODO()                   // 나중에 정하겠다
ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
ctx, cancel := context.WithDeadline(parent, time.Now().Add(1*time.Minute))
ctx := context.WithValue(parent, key, val) // 값 전파

반드시 defer cancel() — 리소스 누수 방지.

4.3 Context 전파 원칙 5가지

  1. Context는 항상 첫 번째 인자: func DoWork(ctx context.Context, arg T) error
  2. Struct에 저장하지 말 것: 요청마다 새로 생성
  3. nil 전달 금지: 모호하면 context.TODO()
  4. Value는 요청 메타데이터용: request ID, user ID, 추적 정보. 비즈니스 파라미터는 ❌
  5. 모든 블로킹 호출이 ctx를 받아야: DB, HTTP, gRPC 전부

4.4 Context Value의 안티패턴

// 나쁨: 비즈니스 파라미터를 Value로 전달
ctx = context.WithValue(ctx, "userID", uid)

// 좋음: 타입 안전한 key 정의
type ctxKey string
const userIDKey ctxKey = "userID"

ctx = context.WithValue(ctx, userIDKey, uid)
func GetUserID(ctx context.Context) (string, bool) {
    uid, ok := ctx.Value(userIDKey).(string)
    return uid, ok
}

5부 — Error Handling: Error as Value

5.1 Go의 에러 철학

result, err := doSomething()
if err != nil {
    return nil, fmt.Errorf("doSomething failed: %w", err)
}

예외 없음. 에러는 값. 명시적으로 처리하거나 전파.

5.2 Error Wrapping (Go 1.13+)

if err != nil {
    return fmt.Errorf("failed to load user %d: %w", id, err)
}

// 체크
var notFoundErr *NotFoundError
if errors.As(err, &notFoundErr) { /* ... */ }
if errors.Is(err, sql.ErrNoRows) { /* ... */ }
  • %w: wrap (언래핑 가능)
  • %v: 문자열 포매팅 (언래핑 불가)

5.3 Sentinel Error vs Typed Error

// Sentinel — 간단한 비교
var ErrNotFound = errors.New("not found")

// Typed — 추가 정보 담기
type NotFoundError struct {
    Resource string
    ID       string
}
func (e *NotFoundError) Error() string { /* ... */ }

5.4 에러 처리 안티패턴 5가지

  1. if err != nil 지우고 무시: _ = err는 지옥으로 가는 길
  2. 에러 로그 + 전파 동시: 중복 로깅. 한 곳에서만 처리
  3. panic() 남발: 복구 불가능한 경우에만
  4. wrap 없이 전파: 스택 추적 불가
  5. 에러 메시지 대문자·구두점: Go 컨벤션 위반 (failed to X, no . at end)

6부 — sync 패키지: 동시성 프리미티브

6.1 핵심 타입 7가지

타입용도
sync.Mutex상호 배제
sync.RWMutex다중 읽기 / 단일 쓰기
sync.WaitGroupN개 Goroutine 완료 대기
sync.Once딱 한 번 실행
sync.Cond조건 변수
sync.Map동시성 안전 맵 (특정 패턴에만 유리)
sync.Pool객체 재사용 (GC 압박 감소)

6.2 atomic 패키지 (Go 1.19+)

import "sync/atomic"

var counter atomic.Int64
counter.Add(1)
v := counter.Load()
counter.Store(100)

var ready atomic.Bool
ready.Store(true)

Mutex보다 빠르지만 단순 연산에만 사용.

6.3 Race Condition 탐지

go test -race ./...
go run -race main.go

프로덕션 배포 전 반드시 -race 통과 확인. CI에 포함 필수.


7부 — Generic (Go 1.18+): 늦게 도착한 구원

7.1 기본 문법

func Map[T, U any](s []T, f func(T) U) []U {
    result := make([]U, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

doubled := Map([]int{1, 2, 3}, func(n int) int { return n * 2 })

7.2 Type Constraint

import "golang.org/x/exp/constraints"

func Max[T constraints.Ordered](a, b T) T {
    if a > b { return a }
    return b
}

// 커스텀 Constraint
type Number interface {
    int | int64 | float64
}

func Sum[T Number](s []T) T {
    var total T
    for _, v := range s { total += v }
    return total
}

7.3 Generic을 쓰지 말아야 할 때

  • Container가 아닐 때: 대부분의 비즈니스 로직엔 불필요
  • interface가 충분할 때: io.Reader 같은 것
  • 컴파일 시간이 더 중요할 때: Generic은 컴파일 시간 증가

Go 팀 공식 조언: "의심되면 쓰지 마라."


8부 — 2024~2025 Go Web Framework 비교

프레임워크특징적합
net/http표준 라이브러리단순 서비스, Go 1.22+ 강력
chi미니멀, 표준 중심마이크로서비스 기본값
gin성숙·인기스타트업·풀스택
echoGin과 유사, 깔끔대안
fiberExpress-like, fasthttp극한 퍼포먼스
humaOpenAPI 퍼스트API 계약 중요

Go 1.22의 강력한 net/http 라우터:

mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("POST /users", createUserHandler)
http.ListenAndServe(":8080", mux)

1.22부터 메서드·경로 변수 기본 지원 → 많은 경우 프레임워크 불필요.

8.1 Chi 예제 (권장)

import "github.com/go-chi/chi/v5"

r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(5 * time.Second))

r.Route("/api/v1", func(r chi.Router) {
    r.Get("/users", listUsers)
    r.Post("/users", createUser)
    r.Get("/users/{id}", getUser)
})

http.ListenAndServe(":8080", r)

9부 — gRPC와 마이크로서비스 실전

9.1 왜 gRPC인가

  • Protocol Buffers: 타입 안전 + 작은 페이로드
  • HTTP/2: 멀티플렉싱, 헤더 압축
  • Streaming: 서버·클라이언트·양방향
  • Code Generation: 여러 언어 간 일관성

9.2 .proto 파일

syntax = "proto3";
package user.v1;
option go_package = "example.com/gen/user/v1;userv1";

service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc ListUsers(ListUsersRequest) returns (stream User);
}

message User {
  string id = 1;
  string name = 2;
  int32 age = 3;
}

message GetUserRequest { string id = 1; }
message ListUsersRequest { int32 page_size = 1; }

9.3 buf + ConnectRPC (2025 표준)

  • buf: .proto 린트·빌드·브레이킹 체인지 감지
  • ConnectRPC: gRPC + REST + gRPC-Web을 한 번에 지원
import "connectrpc.com/connect"

type server struct{}

func (s *server) GetUser(
    ctx context.Context,
    req *connect.Request[userv1.GetUserRequest],
) (*connect.Response[userv1.User], error) {
    return connect.NewResponse(&userv1.User{Id: req.Msg.Id}), nil
}

9.4 마이크로서비스 체크리스트

  1. 관측성 3축: metrics (Prometheus), logs (structured), traces (OpenTelemetry)
  2. Context 전파: 모든 RPC에 ctx 전달
  3. Retry with backoff: google.golang.org/grpc/retry
  4. Circuit Breaker: sony/gobreaker
  5. Timeout: 각 호출마다 명시
  6. Health Check: grpc-health-probe
  7. Graceful Shutdown: SIGTERM 받으면 기존 요청 완료 후 종료
  8. Rate Limit: golang.org/x/time/rate
  9. Request ID: 요청 추적
  10. Deadline Propagation: 상위 Deadline을 하위 호출에 전달

10부 — Go 생태계 2024~2025 핵심 20

카테고리패키지
Webchi, gin, echo, fiber
gRPCgrpc-go, buf, connect-go
DBdatabase/sql + pgx, sqlc (compile-time queries), ent, gorm
로깅log/slog (표준, 1.21+), zerolog
테스트testify, gomock, testcontainers-go
CLIcobra, viper, spf13/pflag
관측성prometheus/client_golang, opentelemetry-go
동시성golang.org/x/sync/errgroup, semaphore
HTTP 클라이언트net/http + retryablehttp
JSONencoding/json + json-iterator/go (속도)

10.1 Go 1.23~1.24 주요 변화

  • Go 1.23 (2024/08): range over func 이터레이터 안정화, 개선된 time.Timer
  • Go 1.24 (2025/02): Generic Type Alias, tool directive (도구 의존성 관리)

11부 — Rust vs Go 선택 가이드

11.1 결정 트리

Q1. 메모리·레이턴시가 극한인가?
  YesRust
  NoQ2

Q2.  규모가 크고 빠른 온보딩이 중요한가?
  YesGo
  NoQ3

Q3. 시스템 프로그래밍(커널·DB·런타임)인가?
  YesRust
  NoQ4

Q4. Kubernetes/DevOps 생태계인가?
  YesGo
  NoQ5

Q5. GC 일시정지가 허용 불가한가?
  YesRust
  NoGo (대부분의 경우)

11.2 냉정한 비교표

항목RustGo
학습 곡선3~6개월2~4주
컴파일 속도느림매우 빠름
런타임 성능최상위높음
메모리 사용매우 적음GC 오버헤드
동시성 안전성컴파일 타임 검증런타임 (-race)
생태계 성숙도빠르게 성장매우 성숙
도큐먼트훌륭훌륭
타입 시스템강력·복잡단순

일상 의사결정:

  • 백엔드 API 서버 → Go (10분 만에 만들 수 있음)
  • DB·런타임·프록시 → Rust (극한 성능)
  • 둘 다 되는 상황 → 팀의 언어

12부 — Go 마스터 로드맵 6개월

Month 1: 기본기

  • Tour of Go 완주
  • Effective Go 읽기
  • CLI 도구 1개 만들기

Month 2: 동시성

  • Concurrency in Go (Katherine Cox-Buday)
  • Worker Pool 예제 직접 작성
  • -race로 race condition 탐지 연습

Month 3: 표준 라이브러리 깊이

  • net/http 소스 읽기
  • context 패턴 체화
  • errgroup + semaphore 실전

Month 4: 실전 서버

  • chi + pgx + PostgreSQL로 API 만들기
  • 관측성 3축 (slog + prometheus + otel)
  • testcontainers로 통합 테스트

Month 5: gRPC·마이크로서비스

  • Protocol Buffers
  • grpc-go 또는 connect-go
  • 3개 서비스 간 통신 + 관측성

Month 6: 오픈소스 기여

  • Kubernetes·Istio·Prometheus 이슈 1개 해결
  • 또는 crates.io에 버전 올리기

13부 — Go 체크리스트 12

  1. Goroutine과 OS 스레드 차이를 설명할 수 있다
  2. Channel 3가지 상태(nil, open, closed)에서의 동작을 안다
  3. Context 전파 원칙 5개를 말할 수 있다
  4. errgroup 사용 이유를 안다
  5. Race Condition 탐지 방법을 안다 (-race)
  6. sync.Mutex vs sync.RWMutex 선택 기준을 안다
  7. Error Wrapping (%w) 의미를 안다
  8. Goroutine 누수를 어떻게 예방하는지 안다
  9. net/http 1.22 라우터의 새 기능을 안다
  10. gRPC + buf + connect 스택을 설명할 수 있다
  11. slog 구조화 로깅을 쓸 줄 안다
  12. Generic 남용의 해악을 이해한다

14부 — Go 안티패턴 10

  1. Goroutine을 fire-and-forget: 취소·완료 대기 없음 → 누수
  2. Context를 struct 필드로 저장: 철학 위반
  3. panic() 대신 에러 반환을 해야 할 상황에 panic
  4. interface에 메서드 10개 이상: "Go interface는 작게" 원칙 위반
  5. 빈 interface(any, interface{}) 남용: 타입 안전성 포기
  6. Goroutine 내부에서 공유 변수 읽기·쓰기 without 동기화: race
  7. 에러 fmt.Errorf("%v")로 감싸기: wrap 안 됨. %w 써야
  8. defer 안에서 에러 무시: defer f.Close() → 에러 처리 필요하면 익명 func
  9. map 동시 접근: 런타임 panic. sync.Map 또는 sync.RWMutex
  10. nil interface 비교 실수: var e error = (*MyError)(nil); e != nil은 true

마치며 — Go는 "지루해서 강하다"

Go는 화려하지 않다. 문법은 10분이면 다 배운다. 최신 트렌드도 거의 없다.

하지만 그 "지루함"이 곧 팀의 생산성이다. 새 엔지니어가 2주 만에 프로덕션 코드를 작성하고, 10년 된 코드가 여전히 읽힌다. Kubernetes가 500만 줄로도 버티는 이유.

Rust가 "틀리면 컴파일 안 됨"의 철학이라면, Go는 "많이 쓰면 읽을 수 있다"의 철학이다.

2025년, 시니어 엔지니어의 도구함에는 Rust와 Go가 둘 다 있어야 한다. 상황에 따라 꺼내 쓰는 것.


다음 글 예고 — "TypeScript 완전 가이드: 타입 레벨 프로그래밍·Generics·제네레이티브 AI 생산성"

Season 2 Ep 4는 현대 웹의 사실상 표준, TypeScript. 다음 글은:

  • TypeScript 5.x의 새로운 타입 연산자들
  • Generics와 Conditional Type
  • Template Literal Type의 실전 활용
  • Zod + tRPC + TanStack으로 타입 안전한 풀스택
  • 2024~2025 TS 생태계 (Turborepo, Bun, Deno)
  • AI와 함께 TypeScript 쓰는 법

"JavaScript에 타입을 붙인 언어"라는 오해를 푸는 시간, 다음 글에서 이어진다.