Go 루틴 예제 및 실수 정리

본 글은 Cluade4-Sonnet으로 작성한 뒤 수정했습니다.

Go의 동시성 프로그래밍은 goroutine, channel, sync 패키지의 조합으로 이뤄진다. 이 포스트에서는 HTTP 상태 코드를 병렬로 확인하는 예제를 통해 Go 동시성의 핵심 개념들과 주의해야 할 실수들을 살펴본다.

핵심 개념 이해하기

1. Goroutine: 경량 스레드

Goroutine은 Go 런타임에서 관리하는 경량 스레드다. go 키워드로 함수를 호출하면 새로운 goroutine에서 실행된다.

// 일반 함수 호출
processURL("https://example.com")

// goroutine으로 실행
go processURL("https://example.com")

2. Channel: Goroutine 간 통신

Channel은 goroutine 간 데이터를 안전하게 주고받는 통로다. "Don't communicate by sharing memory; share memory by communicating"이라는 Go의 철학을 구현한다.

// 문자열을 전송하는 채널
requests := make(chan string)

// 결과 구조체를 전송하는 채널
results := make(chan Result)

3. WaitGroup: 동기화 도구

WaitGroup은 여러 goroutine의 완료를 기다리는 동기화 도구다.

var wg sync.WaitGroup
wg.Add(3)    // 3개의 goroutine을 기다림
wg.Done()    // 작업 완료 신호
wg.Wait()    // 모든 작업 완료까지 대기

실제 구현: HTTP 상태 확인기

여러 URL의 HTTP 상태 코드를 병렬로 확인하는 프로그램을 구현해보자.

기본 구조

type Result struct {
    URL        string
    StatusCode int
    Err        error
}

func getStatus(wg *sync.WaitGroup, requests <-chan string, results chan<- Result) {
    defer wg.Done()
    for url := range requests {
        resp, err := http.Get(url)
        if err != nil {
            results <- Result{URL: url, Err: err}
            continue
        }
        results <- Result{URL: url, StatusCode: resp.StatusCode}
        resp.Body.Close()
    }
}

발생한 실수와 해결법

gemini-2.5-pro가 필자의 코드를 리뷰한 뒤 해결책을 제시해 주었다.

실수 1: defer의 잘못된 사용

문제가 있는 코드:

func getStatus(wg *sync.WaitGroup, requests <-chan string, results chan<- Result) {
    defer wg.Done()
    for url := range requests {
        resp, err := http.Get(url)
        if err != nil {
            continue
        }
        defer resp.Body.Close() // 🚨 위험!
        results <- Result{URL: url, StatusCode: resp.StatusCode}
    }
}

문제점:

  • defer는 함수가 종료될 때 실행된다
  • 루프에서 defer를 사용하면 모든 response body가 함수 종료까지 열려있다
  • 메모리 누수와 파일 디스크립터 고갈을 일으킨다

해결책:

func getStatus(wg *sync.WaitGroup, requests <-chan string, results chan<- Result) {
    defer wg.Done()
    for url := range requests {
        resp, err := http.Get(url)
        if err != nil {
            results <- Result{URL: url, Err: err}
            continue
        }
        results <- Result{URL: url, StatusCode: resp.StatusCode}
        resp.Body.Close() // ✅ 즉시 닫기
    }
}

실수 2: 과도한 Goroutine 생성

문제가 있는 코드:

// URL 개수만큼 goroutine 생성
for i := 0; i < len(urls); i++ {
    wg.Add(1)
    go getStatus(&wg, requests, results)
}

문제점:

  • URL이 1000개면 1000개의 goroutine이 생성된다
  • 시스템 리소스를 과도하게 사용한다
  • 컨텍스트 스위칭 비용이 증가한다

해결책: 워커 풀 패턴

// 워커 수를 제한
const numWorkers = 3
for i := 0; i < numWorkers; i++ {
    wg.Add(1)
    go getStatus(&wg, requests, results)
}

실수 3: 비효율적인 결과 수집

문제가 있는 코드:

// URL 개수만큼 결과를 받음
for i := 0; i < len(urls); i++ {
    result := <-results
    // 처리...
}

문제점:

  • 결과 개수를 하드코딩한다
  • 일부 작업이 실패해 결과를 보내지 않으면 데드락이 발생할 수 있다
  • 유연성이 떨어진다

해결책: 채널 닫기와 range 사용

// 별도 goroutine에서 모든 작업 완료 후 채널 닫기
go func() {
    wg.Wait()
    close(results)
}()

// range로 채널이 닫힐 때까지 결과 수신
for result := range results {
    if result.Err != nil {
        fmt.Printf("Error checking %s: %s\n", result.URL, result.Err)
        continue
    }
    fmt.Printf("URL: %s - StatusCode: %d\n", result.URL, result.StatusCode)
}

완성된 코드

모든 개선사항을 적용한 최종 코드다:

package main

import (
    "fmt"
    "net/http"
    "sync"
)

type Result struct {
    URL        string
    StatusCode int
    Err        error
}

func getStatus(wg *sync.WaitGroup, requests <-chan string, results chan<- Result) {
    defer wg.Done()
    for url := range requests {
        resp, err := http.Get(url)
        if err != nil {
            results <- Result{URL: url, Err: err}
            continue
        }
        results <- Result{URL: url, StatusCode: resp.StatusCode}
        resp.Body.Close()
    }
}

func main() {
    var wg sync.WaitGroup
    
    urls := []string{
        "https://www.google.com",
        "https://www.facebook.com",
        "https://www.youtube.com",
        "https://www.reddit.com",
        "https://invalid-url-for-testing.com",
    }
    
    requests := make(chan string, len(urls))
    results := make(chan Result, len(urls))
    
    // 워커 풀 생성
    const numWorkers = 3
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go getStatus(&wg, requests, results)
    }
    
    // 작업 전송
    for _, url := range urls {
        requests <- url
    }
    close(requests)
    
    // 결과 수집 설정
    go func() {
        wg.Wait()
        close(results)
    }()
    
    // 결과 출력
    for result := range results {
        if result.Err != nil {
            fmt.Printf("Error checking %s: %s\n", result.URL, result.Err)
            continue
        }
        fmt.Printf("URL: %s - StatusCode: %d\n", result.URL, result.StatusCode)
    }
}

핵심 패턴: Fan-out/Fan-in

이 예제는 Go의 대표적인 동시성 패턴인 Fan-out/Fan-in을 구현한다:

  • Fan-out: 하나의 채널(requests)에서 여러 goroutine으로 작업을 분배
  • Fan-in: 여러 goroutine의 결과를 하나의 채널(results)로 수집

마무리

Go의 동시성 프로그래밍에서 기억해야 할 핵심 원칙들:

  1. 리소스 관리: defer 사용 시 위치에 주의하고, 즉시 닫을 수 있는 건 바로 닫는다
  2. 적절한 워커 수: 무한정 goroutine을 생성하지 않고 워커 풀을 활용한다
  3. 안전한 채널 사용: 채널을 적절히 닫고 range를 활용해 안전하게 수신한다
  4. 동기화: WaitGroup으로 모든 작업의 완료를 보장한다

이러한 패턴들을 익히면 효율적이고 안전한 Go 동시성 프로그램을 작성할 수 있다.