초보처럼 Go 쓰지 마세요 — 성능 100배 향상 실화

sangjinsu·2025년 4월 14일

📌 원문 참고: "Stop Writing Go Like a Beginner — How I Got 100x Faster"


Go는 기본적으로 빠른 언어입니다. 하지만 단지 Go를 쓴다고 해서 성능이 보장되진 않죠. 저도 이걸 몸소 겪었습니다.

Kafka 메시지를 소비하고 데이터를 변환해서 Redis에 캐싱하는, 단순한 사이드 프로젝트에서 시작했는데… 실제 트래픽에서는 병목 투성이의 악몽으로 바뀌었습니다. 초보처럼 Go를 짜는 걸 멈췄을 때 비로소 100배 성능 향상이라는 결과를 얻을 수 있었습니다.

이 글에서는 제가 어떤 식으로 코드를 개선했는지를 공유합니다.


1. 추측 말고 프로파일링부터!

초보 실수 1번: 측정 없이 최적화 시도하기.

Go에는 pprof라는 기본 내장 성능 분석 도구가 있습니다. 이걸 붙여보니 CPU 사용량 급증, 메모리 소모가 눈에 띄게 보였습니다.

import _ "net/http/pprof"

log.Println(http.ListenAndServe("localhost:6060", nil))

🔥 : Flame Graph 안 보고 있다면, 아마 잘못된 부분을 최적화 중일 수도 있어요.


2. 고루틴을 캔디처럼 남발하지 말기

초보일수록 고루틴을 남용하곤 합니다. 쉽고 빠르니까요. 하지만 너무 많이 쓰면 오히려 병목을 만듭니다.

// ❌ 안 좋은 예시
for msg := range kafkaMessages {
    go process(msg)
}

수천 개의 고루틴을 띄운 후엔 CPU 경쟁, 메모리 부족으로 디버깅 지옥이 펼쳐졌습니다. 대신 고정된 워커 풀로 바꿨습니다.

pool := make(chan struct{}, 20)

for msg := range kafkaMessages {
    pool <- struct{}{}
    go func(m Message) {
        defer func() { <-pool }()
        process(m)
    }(msg)
}

✔️ 통제된 동시성 = 예측 가능한 성능.


3. 메모리 재사용하자 — 쓸 때마다 새로 만들지 말기

이런 코드를 쓰고 있었습니다:

for _, line := range input {
    parts := strings.Split(line, ":")
}

Split할 때마다 새 slice가 생성됩니다. 수백만 줄 처리하면 GC 압박이 심각해집니다.

그래서 버퍼를 재활용했습니다:

var buffer []string
for _, line := range input {
    buffer = strings.Split(line, ":")
}

작은 변화로 큰 성과!


4. 전역 락(Mutex)은 쓰지 말자

전역 sync.Mutex를 썼던 적이 있습니다. 그리고 성능이 바닥을 쳤죠.

var mu sync.Mutex

func save(k, v string) {
    mu.Lock()
    cache[k] = v
    mu.Unlock()
}

부하가 몰리면 mutex가 주차장처럼 변합니다. 샤딩된 락(sharded locks)으로 전환했더니 성능이 확 올랐습니다. (예: sync.Map, key 해시 기반 분산)


5. 리플렉션 말고 코드 생성 도구를 쓰자

Go의 interface{}나 리플렉션은 유연하지만 느립니다.

var m map[string]interface{}
json.Unmarshal(data, &m)

➡️ 대신 명시적 구조체 + easyjson 같은 코드 생성기 사용:

type Payload struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

🎯 빠르고, 안전하고, Go스러운 방식!


6. 가능한 모든 작업은 배치 처리하자

아래처럼 Redis에 하나씩 쓰는 건 초보적인 패턴입니다:

for _, msg := range messages {
    redis.Set(msg.Key, msg.Value)
}

➡️ 배치 처리하면 네트워크 I/O가 확 줄어듭니다:

pipe := redis.Pipeline()
for _, msg := range messages {
    pipe.Set(msg.Key, msg.Value, 0)
}
pipe.Exec()

DB, HTTP 등도 마찬가지!


7. 인터페이스 타입 변환은 핫패스에서 피하자

Go의 interface{}는 편하지만, 핫루프빈번한 호출 경로에선 성능 문제를 야기합니다.

func handle(i interface{}) {
    str := i.(string)
}

이런 타입 단언 비용이 누적되면 큽니다. 타입을 명시적으로 유지하세요.


8. 무식하게 캐시 말고 똑똑하게 캐시하자

같은 계산을 수백 번 반복하고 있었다면? 아래처럼 간단한 캐시를 써보세요:

var cache sync.Map

val, ok := cache.Load(key)
if !ok {
    val = expensiveFunction(key)
    cache.Store(key, val)
}

요청당 몇 ms만 줄여도 합치면 어마어마한 시간 절약!


9. 자료구조는 성능의 핵심

map[string]string만 쓰고 있었다면, 성능 병목의 원인일 수도 있습니다. 가능한 경우 map[int]string, []string 등의 대안을 고려하세요.

경우에 따라선 배열이 해시맵보다 훨씬 빠릅니다.


10. 컴파일 시 성능도 고려하자

빌드 플래그도 성능에 영향을 줍니다.

go build -ldflags="-s -w"

또한 불필요한 의존성, 인터페이스, 과한 추상화는 제거하세요. 코드가 느려지는 원인입니다.


결과: 120초 → 1.2초

위 최적화들을 적용하고 벤치마크를 다시 돌려보니, 120초 걸리던 작업이 1.2초 만에 끝났습니다.

단지 빠르기만 한 게 아니라, 깔끔하고 유지보수 가능한 코드로 개선되었습니다.


마무리

모든 줄을 마이크로 최적화할 필요는 없습니다. 하지만 Go 애플리케이션이 느리다면, 아마 초보처럼 작성하고 있을 가능성이 높습니다.

  • 프로파일링을 시작하세요
  • 메모리를 이해하세요
  • 고루틴은 신중하게 쓰세요
  • 자료구조를 잘 고르세요

Go는 빠른 언어입니다 — 당신이 현명하게 쓴다면요.

profile
개발에 집중할 수 있는, 이슈를 줄이는 환경을 만들고 싶습니다.

0개의 댓글