Go 웹 서버에서의 고루틴 누수 이해 및 디버깅
Emily Parker
Product Engineer · Leapcell

소개
Go 동시성 세계에서 고루틴은 가볍고 비용이 적으며 근본적인 요소입니다. 특히 웹 서버와 같이 매우 동시적이고 확장 가능한 애플리케이션을 구축하는 강력한 추상화 기능입니다. 그러나 이러한 강력함에는 책임이 따릅니다. 바로 수명 주기를 관리하는 것입니다. "고루틴 누수"라고도 하는 관리되지 않는 고루틴은 메모리 소비 증가, CPU 고갈, 궁극적으로 서버 불안정 또는 충돌을 포함한 다양한 문제로 이어질 수 있습니다. 웹 서버와 같이 장기 실행되는 애플리케이션의 경우 이러한 누수는 시간이 지남에 따라 성능을 서서히 저하시키다가 치명적인 장애가 발생할 때까지 특히 탐지하기 어려울 수 있습니다. 이러한 누수가 발생하는 방식, 그리고 더 중요하게는 이를 식별하고 수정하는 방법을 이해하는 것은 강력하고 성능이 뛰어난 Go 웹 서비스를 유지하는 데 중요합니다. 이 블로그 게시물에서는 웹 서버에서 일반적인 고루틴 누수 시나리오를 살펴보고 이를 효과적으로 디버깅하는 데 필요한 실용적인 도구와 기술을 제공합니다.
고루틴 누수 이해
일반적인 누수 시나리오를 자세히 살펴보기 전에 몇 가지 주요 개념에 대한 기반 이해를 확립해 보겠습니다.
- 고루틴: Go 런타임에서 관리하는 가볍고 독립적으로 실행되는 함수입니다. 고루틴은 더 적은 수의 OS 스레드로 다중화됩니다.
- 고루틴 누수: 고루틴이 시작되었지만 결코 종료되지 않을 때 발생합니다. 계속해서 메모리(스택 공간, 참조하는 힙 할당)를 소비하며, 적극적으로 CPU 명령을 실행하지는 않지만 메모리에 남아 프로세스의 전반적인 리소스 지문에 기여합니다. 시간이 지남에 따라 누수된 고루틴이 축적되면 시스템 리소스가 고갈될 수 있습니다.
- 컨텍스트: Go에서
context.Context는 API 경계 및 고루틴을 가로질러 마감일, 취소 신호 및 기타 요청 범위 값을 전달하는 데 사용됩니다. 특히 HTTP 서버에서 작업 취소를 신호하는 중요한 메커니즘입니다.
고루틴 누수는 일반적으로 고루틴이 결코 발생하지 않는 이벤트를 무기한 기다리거나, 영원히 실행되도록 설계되었지만 부모( go로 시작한 것)가 종료를 보장하지 않을 때 발생합니다. 웹 서버에서는 들어오는 HTTP 요청이 종종 고루틴을 트리거합니다. 이러한 요청 처리 고루틴 또는 자신이 생성한 모든 고루틴이 작업을 완료하고 종료되지 않으면 누수가 됩니다.
일반적인 고루틴 누수 시나리오
몇 가지 일반적인 고루틴 누수 원인과 함께 예시 코드 스니펫을 살펴보겠습니다.
1. 해당 리더 없이 무제한 채널 쓰기
가장 고전적인 누수 시나리오 중 하나는 아무도 채널에서 읽지 않거나(또는 충분한 리더가 없음) 채널에 쓰는 고루틴과 관련이 있습니다. 채널이 버퍼링되지 않았다면 쓰기는 무기한 차단될 것입니다. 버퍼링되어 채워진다면 쓰기는 차단될 것입니다. 쓰기가 요청당 시작되는 고루틴이라면 누수될 것입니다.
가상 비동기 로깅 서비스를 고려해 보세요.
package main import ( "fmt" "log" "net/http" time "time" ) // 이 버퍼링된 채널은 신중하게 처리하지 않으면 누수로 이어질 수 있습니다. var logCh = make(chan string, 100) func init() { // 차단될 수 있는 "누수" 로거 고루틴 go func() { for { select { case entry := <-logCh: // 로깅 작업 시뮬레이션 time.Sleep(50 * time.Millisecond) // I/O 또는 처리 시간 시뮬레이션 fmt.Printf("Logged: %s\n", entry) // 이 고루틴에 대한 명시적인 종료 메커니즘 없음 } } }() } func logMessage(msg string) { // 채널에 쓰는 고루틴. logCh가 채워지고 아무도 읽지 않으면 // 이 송신 고루틴은 여기서 차단됩니다. logCh <- msg } func leakyHandler(w http.ResponseWriter, r *http.Request) { go func() { // 이 고루틴은 각 요청마다 실행됩니다. // logCh가 채워지고 전역 로그 소비자가 느리거나 멈추면 // 이 고루틴은 `logMessage`에서 무기한 차단되어 // 결코 종료되지 않습니다. logMessage(fmt.Sprintf("Request received from %s", r.RemoteAddr)) }() time.Sleep(10 * time.Millisecond) // 일부 빠른 처리 시뮬레이션 w.WriteHeader(http.StatusOK) w.Write([]byte("Request processed (potentially leaking a goroutine)")) } func main() { http.HandleFunc("/leaky", leakyHandler) log.Println("Server starting on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
leakyHandler에서 비동기적으로 로그 메시지를 보냅니다. logCh 버퍼가 init 고루틴이 메시지를 처리하는 속도보다 빠르게 채워지면 logMessage 호출(따라서 go func() {...}로 생성된 고루틴)이 무기한 차단됩니다. 이는 요청마다 발생하므로 반복되는 요청은 점점 더 많은 차단된 고루틴을 생성합니다.
해결책: 비차단 송신 또는 정상 종료를 위해 default 케이스 또는 context.Done() 신호와 함께 select 문을 사용합니다.
func logMessageSafe(msg string, ctx context.Context) { select { case logCh <- msg: // 메시지 성공적으로 전송됨 case <-ctx.Done(): // 컨텍스트가 취소되었으므로 송신자는 포기해야 합니다. fmt.Printf("Log message '%s' canceled: %v\n", msg, ctx.Err()) case <-time.After(50 * time.Millisecond): // 송신 타임아웃 fmt.Printf("Log message '%s' timed out after 50ms\n", msg) } } func safeHandler(w http.ResponseWriter, r *http.Request) { go func() { // HTTP 요청 컨텍스트를 사용하여 로그 고루틴이 요청 취소를 존중하도록 합니다. logMessageSafe(fmt.Sprintf("Request received from %s", r.RemoteAddr), r.Context()) }() w.WriteHeader(http.StatusOK) w.Write([]byte("Request processed (safely)")) }
2. 닫혔거나 응답 없는 네트워크 연결을 기다리는 고루틴
HTTP 서버는 종종 외부 서비스(데이터베이스, 다른 마이크로 서비스, 캐시)와 상호 작용합니다. I/O 작업을 수행하기 위해(예: 타사 API에서 데이터 가져오기) 고루틴이 생성되고 해당 연결이 느려지거나, 너무 오래 타임 아웃되거나, 원격 서버가 응답하지 않으면, I/O를 수행하는 고루틴이 차단됩니다. 주변 코드가 타임 아웃 또는 컨텍스트 취소 메커니즘을 갖추고 있지 않으면 누수될 것입니다.
package main import ( "context" "fmt" "io/ioutil" "log" "net/http" time "time" ) func externalAPICall(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", "http://unresponsive-third-party-api.com/data", nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } client := &http.Client{ // 명시적인 타임아웃은 클라이언트에서 설정되지 않았고, 컨텍스트나 // 기본값을 사용하며, 이는 응답 없는 서버에 대해 너무 길 수 있습니다. // 예를 들어 API가 오랫동안 응답하지 않으면, // 고루틴은 Do()에서 차단될 것입니다. } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("API call failed: %w", err) } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response body: %w", err) } return string(body), nil } func leakyExternalCallHandler(w http.ResponseWriter, r *http.Request) { responseCh := make(chan string) var cancel context.CancelFunc // 외부 API 호출에 대한 타임아웃이 있는 컨텍스트 생성 ctx, cancel := context.WithTimeout(r.Context(), 5 * time.Second) defer cancel() // 함수 종료 시 취소가 호출되도록 하기 go func() { // externalAPICall이 5초 이상 느려지면 이 고루틴은 // 요청 컨텍스트가 취소되더라도 여전히 client.Do(req)에서 차단될 수 있습니다. // `main` 고루틴은 이 고루틴이 아직 살아있는 동안 클라이언트에게 이미 응답했을 수 있습니다. data, err := externalAPICall(ctx) // externalAPICall은 ctx를 존중해야 하지만, 종종 불충분합니다. if err != nil { responseCh <- fmt.Sprintf("Error fetching data: %v", err) } else { responseCh <- fmt.Sprintf("Data: %s", data) } }() select { case result := <-responseCh: w.Write([]byte(result)) case <-ctx.Done(): w.WriteHeader(http.StatusGatewayTimeout) w.Write([]byte("External API call timed out")) } } func main() { http.HandleFunc("/external", leakyExternalCallHandler) log.Println("Server starting on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
위 예시는 일반적인 함정을 보여줍니다. http.NewRequestWithContext가 사용되더라도 http.Client 자체는 특정 네트워크 조건(예: 연결 설정, TLS 핸드셰이크의 특정 단계)에서 무기한 차단을 방지하기 위해 Timeout 필드가 필요할 수 있습니다. context.WithTimeout은 요청을 취소하지만, http.Client.Timeout이 설정되지 않았거나 컨텍스트 타임아웃보다 훨씬 길 경우 client.Do(req)를 수행하는 고루틴은 내부적으로 여전히 차단될 수 있습니다.
해결책: 전체 요청(연결, 쓰기, 읽기)을 포함하는 합리적인 http.Client.Timeout을 항상 설정하십시오. context.Done()을 통해 취소 가능한 모든 장기 실행 작업(특히 I/O)을 보장하십시오.
// 올바른 http.Client 설정 var httpClient = &http.Client{ Timeout: 3 * time.Second, // 전체 요청에 대한 타임아웃 } func externalAPICallSafe(ctx context.Context) (string, error) { req, err := http.NewRequestWithContext(ctx, "GET", "http://unresponsive-third-party-api.com/data", nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } resp, err := httpClient.Do(req) // 타임아웃이 있는 클라이언트 사용 if err != nil { return "", fmt.Errorf("API call failed: %w", err) } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read response body: %w", err) } return string(body), nil } func safeExternalCallHandler(w http.ResponseWriter, r *http.Request) { responseCh := make(chan string) ctx, cancel := context.WithTimeout(r.Context(), 5 * time.Second) defer cancel() go func() { // 이 고루틴은 externalAPICallSafe가 반환되면 종료됩니다. // 데이터 또는 오류( httpClient에서 타임 아웃 오류 포함)와 함께. data, err := externalAPICallSafe(ctx) if err != nil { // 비차단 송신: 메인 고루틴이 // 이미 반환된 경우(예: 컨텍스트 타임아웃으로 인해), // 이 송신은 건너뜁니다. select { case responseCh <- fmt.Sprintf("Error fetching data: %v", err): case <-ctx.Done(): fmt.Printf("Goroutine finished, but parent context done: %v\n", ctx.Err()) } } else { select { case responseCh <- fmt.Sprintf("Data: %s", data): case <-ctx.Done(): fmt.Printf("Goroutine finished, but parent context done: %v\n", ctx.Err()) } } }() select { case result := <-responseCh: w.Write([]byte(result)) case <-ctx.Done(): w.WriteHeader(http.StatusGatewayTimeout) w.Write([]byte("External API call timed out")) } }
safeExternalCallHandler의 responseCh로 보내는 select 문은 중요합니다. 메인 요청 핸들러 고루틴이 컨텍스트를 취소하고 클라이언트에게 반환하는 경우, 비동기 고루틴이 더 이상 아무도 듣지 않는 채널에 값을 보내기 위해 영원히 차단되지 않도록 보장합니다.
3. 종료 조건 없이 무기한 루프하는 고루틴
경우에 따라 작업자 고루틴은 for {} 루프에서 채널 작업을 처리하도록 설계될 수 있습니다. 애플리케이션이 종료되거나 작업 소스가 고갈되면 이 고루틴은 더 이상 필요하지 않더라도 채널에서 계속 기다릴 수 있습니다.
package main import ( "fmt" "log" "net/http" "sync" time "time" ) var ( taskQueue = make(chan string) wg sync.WaitGroup ) func worker() { defer wg.Done() for { task := <-taskQueue // taskQueue가 닫히지 않고 더 이상 작업이 없으면 여기서 무기한 차단됩니다. log.Printf("Processing task: %s", task) time.Sleep(100 * time.Millisecond) // 작업 시뮬레이션 } } func init() { // 두 명의 작업자 시작 wg.Add(2) go worker() go worker() } func queueTaskHandler(w http.ResponseWriter, r *http.Request) { task := r.URL.Query().Get("task") if task == "" { task = "default-task" } taskQueue <- task // 이 송신자도 작업자가 느리면 차단될 수 있습니다. w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf("Task '%s' queued", task))) } func main() { http.HandleFunc("/queue", queueTaskHandler) log.Println("Server starting on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) // 실제 애플리케이션에서는 작업자를 정상적으로 종료하고 싶을 것입니다. // taskQueue를 닫고 여기서 wg.Wait()를 기다립니다. // 서버가 이 없이 종료되면 작업자가 차단된 상태로 남을 수 있습니다. // 예를 들어, 더 이상 작업이 전송되지 않지만 서버가 계속 실행 중인 경우. }
이 예에서는 애플리케이션이 taskQueue에 작업을 제출하는 것을 중지했지만 채널을 닫지 않으면 worker 고루틴은 <-taskQueue에서 무기한 차단됩니다. 서버가 정상적으로 종료되는 경우, worker 고루틴이 장기 실행되고 명시적으로 종료되지 않으면 누수가 됩니다.
해결책: 취소를 위해 context.Context를 사용하거나 채널을 명시적으로 닫고 for range를 사용하여 반복합니다.
var ( taskQueueSafe = make(chan string) stopWorkers = make(chan struct{}) // 작업자를 중지할 신호 채널 wgSafe sync.WaitGroup ) func workerSafe(workerID int) { defer wgSafe.Done() for { select { case task, ok := <-taskQueueSafe: if !ok { log.Printf("Worker %d: Task queue closed, exiting.", workerID) return // 채널 닫힘, 고루틴 종료 } log.Printf("Worker %d processing task: %s", workerID, task) time.Sleep(100 * time.Millisecond) case <-stopWorkers: log.Printf("Worker %d: Stop signal received, exiting.", workerID) return // 정상 종료 } } } func init() { wgSafe.Add(2) go workerSafe(1) go workerSafe(2) } // main 또는 종료 후크에서: func shutdownWorkers() { // 작업자에게 중지 신호 보내기 close(stopWorkers) // 더 이상 프로듀서가 보내지 않으면 taskQueueSafe도 닫을 수 있습니다. // close(taskQueueSafe) wgSafe.Wait() // 모든 작업자가 현재 작업을 완료하고 종료될 때까지 기다립니다. log.Println("All workers shut down.") }
고루틴 누수 디버깅
Go는 고루틴 누수를 식별하고 디버깅하는 데 탁월한 도구를 제공합니다.
1. net/http/pprof
net/http/pprof 패키지는 주요 도구입니다. 이를 가져오면 /debug/pprof/goroutine을 포함한 여러 엔드포인트를 노출하며, 이는 활성 고루틴의 스냅샷을 제공합니다.
package main import ( "log" "net/http" _ "net/http/pprof" // pprof 엔드포인트를 위해 이것을 가져옵니다. ) func main() { http.HandleFunc("/leak", func(w http.ResponseWriter, r *http.Request) { go func() { time.Sleep(10 * time.Minute) // 장기 실행, 잠재적으로 누수되는 고루틴 시뮬레이션 }() w.Write([]byte("Leaking a goroutine...")) }) log.Println("Server starting on :8080, pprof available at /debug/pprof") log.Fatal(http.ListenAndServe(":8080", nil)) }
이제 /leak을 여러 번 호출한 다음 /debug/pprof/goroutine을 방문하십시오. 활성 고루틴의 스택 추적을 볼 수 있습니다. 차단된(chan receive, time.Sleep, select, 네트워크 I/O) 고루틴과 누수가 발생할 수 있는 코드의 스택 추적에 주목하십시오.
이것을 분석하는 더 효과적인 방법은 go tool pprof 명령을 사용하는 것입니다.
# 고루틴 프로필 가져오기 go tool pprof http://localhost:8080/debug/pprof/goroutine # 이는 대화형 프로파일링 세션을 시작합니다. # 'top'을 사용하여 가장 많은 고루틴을 소비하는 함수를 봅니다. # 'list <function_name>'을 사용하여 의심스러운 함수의 소스 코드를 봅니다. # 'web'을 사용하여 SVG 시각화 생성(Graphviz 필요).
특정 코드 경로에 대한 증가하는 고루틴 수를 식별하기 위해 다른 시점에 찍은 프로필을 비교할 수 있습니다.
# 프로필을 파일로 저장 curl -o goroutine_profile_initial.gz http://localhost:8080/debug/pprof/goroutine?debug=1 # 일부 로드 후 curl -o goroutine_profile_after_load.gz http://localhost:8080/debug/pprof/goroutine?debug=1 # 비교 go tool pprof -http=:8000 --diff_base goroutine_profile_initial.gz goroutine_profile_after_load.gz
이 차이점 분석 기능은 새 고루틴이 생성되어 결코 종료되지 않는 위치를 정확히 찾아내는 데 매우 유용합니다.
2. 런타임 메트릭
runtime.NumGoroutine()을 사용하여 활성 고루틴 수를 프로그래밍 방식으로 확인할 수도 있습니다.
package main import ( "fmt" "net/http" "runtime" "time" ) func handler(w http.ResponseWriter, r *http.Request) { go func() { // 결국 누수될 고루틴 time.Sleep(5 * time.Minute) }() fmt.Fprintf(w, "Goroutines: %d", runtime.NumGoroutine()) } func main() { http.HandleFunc("/", handler) http.ListenAndServe(":8080", nil) }
이것 자체로 디버깅 도구는 아니지만, 시간이 지남에 따라 runtime.NumGoroutine()을 모니터링하는 것(예: Prometheus 메트릭 사용)은 지속적으로 증가하는 추세를 밝혀 리소스 고갈을 나타낼 수 있습니다.
3. 동시성 패턴에 대한 코드 주의 깊게 검토
사전 예방적 접근 방식은 go 문, 채널 및 select 블록을 포함하는 섹션을 정기적으로 검토하는 것입니다. 스스로에게 질문해 보세요.
- 모든
go문에 명확한 종료 조건이 있습니까? - 모든 채널 작업(송신 및 수신)이 타임아웃 또는
context.Done()신호로 보호됩니까? - 더 이상 필요하지 않은 채널이 닫히고 있습니까?
- 오류 처리가 네트워크 또는 I/O 작업에서 무기한 차단을 방지할 만큼 강력합니까?
- 작업자 고루틴 관리에
sync.WaitGroup또는context.Context가 올바르게 사용되고 있습니까?
결론
고루틴 누수는 동시 Go 프로그래밍에서 일반적인 함정이지만, 신중한 설계와 체계적인 디버깅을 통해 완전히 피할 수 있습니다. 일반적인 시나리오(무제한 채널 작업, 응답 없는 I/O, 누락된 종료 조건)를 이해하고 Go의 강력한 pprof 도구를 활용하면 이러한 문제를 효과적으로 식별하고 해결할 수 있습니다. 사전 예방적 코드 검토와 지속적인 고루틴 수 모니터링은 리소스 고갈에 대한 강력한 방어 체계를 구성하고 Go 웹 서버가 안정적이고 성능을 유지하도록 보장합니다. 누수 없는 Go 애플리케이션 구축은 동시성 관리에 대한 규율 있는 접근 방식에 달려 있으며, 항상 각 고루틴이 어떻게 그리고 언제 실행을 완료할 것인지 고려해야 합니다.