본 글은 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의 동시성 프로그래밍에서 기억해야 할 핵심 원칙들:
- 리소스 관리: defer 사용 시 위치에 주의하고, 즉시 닫을 수 있는 건 바로 닫는다
- 적절한 워커 수: 무한정 goroutine을 생성하지 않고 워커 풀을 활용한다
- 안전한 채널 사용: 채널을 적절히 닫고 range를 활용해 안전하게 수신한다
- 동기화: WaitGroup으로 모든 작업의 완료를 보장한다
이러한 패턴들을 익히면 효율적이고 안전한 Go 동시성 프로그램을 작성할 수 있다.