고루틴과 함께하는 흔한 함정과 안티패턴
Ethan Miller
Product Engineer · Leapcell

소개
고루틴과 채널을 중심으로 하는 Go의 동시성 모델은 가장 매력적인 기능 중 하나입니다. 이를 통해 동시 프로그램을 쉽게 작성할 수 있으며, 다른 많은 언어보다 병렬 처리를 더 쉽게 접근할 수 있습니다. 하지만 큰 힘에는 큰 책임이 따릅니다. 고루틴은 가볍고 생성하기 쉽지만, 잘못 사용하면 미묘한 버그, 성능 병목 현상, 리소스 고갈을 유발할 수 있으며, 이는 디버깅이 매우 어렵다는 악명이 있습니다. 이러한 흔한 함정과 안티패턴을 이해하는 것은 견고하고 효율적이며 유지보수 가능한 동시 애플리케이션을 작성하고자 하는 모든 Go 개발자에게 중요합니다. 이 글은 고루틴을 사용할 때 자주 발생하는 잘못된 사용법에 대해 자세히 살펴보고, 왜 발생하는지, 그리고 어떻게 피할 수 있는지에 대한 통찰력을 제공하여 궁극적으로 Go 동시성 모델의 전체 잠재력을 활용하는 데 도움을 줄 것입니다.
고루틴과 채널 이해하기
안티패턴에 대해 자세히 알아보기 전에 핵심 개념을 간략하게 되짚어 보겠습니다.
- 고루틴: 고루틴은 가벼운 독립 실행 함수입니다. 본질적으로 동일한 주소 공간 내에서 다른 고루틴과 동시에 실행되는 함수입니다. 스레드에 비해 고루틴은 생성 및 관리가 훨씬 저렴하며, 스택 공간이 몇 킬로바이트만 필요하고, 스케줄링은 운영 체제가 아닌 Go 런타임에서 처리합니다.
 - 채널: 채널은 고루틴과 값을 주고받을 수 있는 유형화된 통신 경로입니다. 채널은 고루틴 간의 통신 및 동기화를 용이하게 하도록 구축되어, 한 번에 하나의 고루틴만 공유 데이터에 액세스할 수 있도록 보장하거나 명시적으로 소유권을 이전함으로써 일반적인 동시성 문제(예: 데이터 경쟁)를 방지합니다.
 
이 두 가지 기본 요소는 Go의 "통신 순차 프로세스"(CSP) 접근 방식의 기반을 형성하며, 공유 메모리보다 커뮤니케이션을 강조합니다.
흔한 잘못된 사용법 및 안티패턴
고루틴은 강력하지만 잘못 사용될 수 없습니다. 다음은 몇 가지 일반적인 안티패턴과 이를 해결하는 방법입니다.
1. 고루틴 누수
고루틴 누수는 고루틴이 시작되었지만 안전하게 종료되지 않아 더 이상 필요하지 않을 때에도 리소스(메모리, CPU)를 계속 소비하는 경우에 발생합니다. 이는 종종 고루틴이 무기한 차단되거나 부모 고루틴이 자식 고루틴의 중지를 기다리거나 신호하지 않고 종료될 때 발생할 수 있습니다.
고루틴 누수 예시:
부모가 취소를 결정했을 때 해당 경우를 처리하지 않는 백그라운드 작업을 수행하는 함수를 고려해 보세요.
package main import ( "fmt" "time" ) func leakyWorker() { for { // 일부 작업 시뮬레이션 time.Sleep(1 * time.Second) fmt.Println("Worker doing work...") } } func main() { go leakyWorker() // 이 고루틴은 영원히 실행될 것입니다 time.Sleep(3 * time.Second) fmt.Println("Main function exiting.") // leakyWorker는 백그라운드에서 계속 실행됩니다 }
이 예시에서 leakyWorker는 main이 종료된 후에도 "Worker doing work..."를 계속 출력하며, 프로그램이 명시적으로 종료될 때까지 리소스를 소비합니다.
해결 방법: 컨텍스트를 사용한 취소:
context 패키지는 API 경계 및 고루틴 트리를 가로질러 취소 및 시간 초과를 처리하는 관용적인 방법입니다.
package main import ( "context" "fmt" "time" ) func nonLeakyWorker(ctx context.Context) { for { select { case <-time.After(1 * time.Second): fmt.Println("Worker doing work...") case <-ctx.Done(): fmt.Println("Worker received cancellation signal. Exiting.") return } } } func main() { ctx, cancel := context.WithCancel(context.Background()) go nonLeakyWorker(ctx) time.Sleep(3 * time.Second) fmt.Println("Main function signaling worker to stop.") cancel() // 작업자에게 중지 신호 time.Sleep(1 * time.Second) // 작업자가 깔끔하게 종료될 시간을 줍니다 fmt.Println("Main function exiting.") }
여기서 nonLeakyWorker는 context의 취소 신호를 수신 대기합니다. main에서 cancel()이 호출되면 ctx.Done() 채널이 닫혀 작업자가 깔끔하게 종료될 수 있습니다.
2. 시간 초과 없이 차단
특히 채널 송신/수신 또는 I/O 작업과 같은 차단 작업은 해당 수신자/송신자 또는 I/O 작업이 발생하지 않으면 무기한 중단될 수 있습니다. 이는 프로그램이 중단되거나, 여러 개의 이러한 고루틴이 있는 경우, 데드락으로 이어질 수 있습니다.
무기한 차단 예시:
package main import ( "fmt" "time" ) func blockingSender(ch chan int) { fmt.Println("Blocking sender attempting to send...") ch <- 1 // 아무도 수신하지 않으면 무기한 차단됩니다 fmt.Println("Blocking sender sent data.") // 이 줄은 도달하지 못할 수 있습니다 } func main() { ch := make(chan int) go blockingSender(ch) time.Sleep(5 * time.Second) fmt.Println("Main function exiting, sender is still blocked.") }
blockingSender 고루틴은 버퍼링되지 않은 채널 ch로 값을 보내려고 시도합니다. main이 ch에서 읽지 않으므로 blockingSender는 영원히 차단되고, 프로그램은 자연스럽게 종료되지 않습니다(main이 명시적으로 종료되어 blockingSender가 누수된 고루틴으로 남는 경우는 제외).
해결 방법: select와 time.After 또는 context.WithTimeout 사용:
package main import ( "context" "fmt" "time" ) func timedSender(ch chan int) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() select { case ch <- 1: fmt.Println("Timed sender sent data successfully.") case <-ctx.Done(): fmt.Println("Timed sender timed out: ", ctx.Err()) } } func main() { ch := make(chan int) go timedSender(ch) // 나중에 또는 다른 고루틴에서 ch에서 수신 // go func() { // time.Sleep(1 * time.Second) // val := <-ch // fmt.Println("Main received:", val) // }() time.Sleep(3 * time.Second) fmt.Println("Main function exiting.") }
context.WithTimeout과 select 문을 사용하면 timedSender는 송신 작업이 너무 오래 걸리는지 감지하고 적절하게 대응하여 무기한 차단을 방지할 수 있습니다.
3. 고루틴 완료 대기 안 함
main 고루틴(또는 모든 부모 고루틴)이 자식 고루틴을 시작할 때, 진행하거나 종료하기 전에 완료될 때까지 기다려야 하는 경우가 많습니다. 이를 수행하지 않으면 결과가 불완전하거나, 경쟁 조건이 발생하거나, 자식 고루틴이 조기에 종료될 수 있습니다.
대기 안 함 예시:
package main import ( "fmt" "time" ) func workInBackground(id int) { fmt.Printf("Worker %d starting...\n", id) time.Sleep(time.Duration(id) * time.Second) // 가변 작업 시뮬레이션 fmt.Printf("Worker %d finished.\n", id) } func main() { for i := 1; i <= 3; i++ { go workInBackground(i) } fmt.Println("Main function exiting...") // 작업자 대기 없이 즉시 인쇄됩니다 }
이 프로그램은 거의 즉시 "Main function exiting..."을 출력하며, 모든 작업자가 완료되기 전에 백그라운드 작업이 불완전하게 실행될 수 있습니다.
해결 방법: sync.WaitGroup 사용:
sync.WaitGroup은 고루틴 컬렉션이 완료될 때까지 기다리는 표준 방법입니다.
package main import ( "fmt" "sync" "time" ) func workWithWaitGroup(id int, wg *sync.WaitGroup) { defer wg.Done() // 고루틴 완료 시 카운터 감소 fmt.Printf("Worker %d starting...\n", id) time.Sleep(time.Duration(id) * time.Second) fmt.Printf("Worker %d finished.\n", id) } func main() { var wg sync.WaitGroup for i := 1; i <= 3; i++ { wg.Add(1) // 각 고루틴에 대해 카운터 증가 go workWithWaitGroup(i, &wg) } fmt.Println("Main function waiting for workers...") wg.Wait() // 카운터가 0이 될 때까지 차단 fmt.Println("All workers finished. Main function exiting.") }
sync.WaitGroup을 사용하면 main 고루틴은 자체 실행을 계속하기 전에 모든 자식 고루틴이 완료 신호를 보내기를 효과적으로 기다립니다.
4. 버퍼링되지 않은 채널로 과도한 최적화
버퍼링되지 않은 채널은 엄격한 동기화를 보장하지만, 성능을 위해 모든 곳에 사용하는 것은 오해의 소지가 있을 수 있습니다. 버퍼링되지 않은 채널은 송신자와 수신자가 모두 준비될 때까지 모두 차단하므로, 주의해서 관리하지 않으면 불필요하게 작업을 순차화하고 성능 저하를 유발할 수 있습니다.
과도한 최적화(또는 오용) 예시:
package main import ( "fmt" "time" ) func processData(data int, out chan<- int) { time.Sleep(100 * time.Millisecond) // 작업 시뮬레이션 out <- data * 2 } func main() { data := []int{1, 2, 3, 4, 5} results := make(chan int) // 버퍼링되지 않은 채널 for _, d := range data { go processData(d, results) // 각 고루틴은 'results'로 보냅니다 } // 이 루프는 수신하지만, 각 `processData`는 이 루프가 준비될 때까지 보내는 것을 차단합니다. // 처리가 수신보다 오래 걸리면 사실상 순차적으로 됩니다. for range data { result := <-results fmt.Println("Received:", result) } }
processData가 빠르지만 메인 루프의 result 처리 속도가 느리면, 버퍼링되지 않은 채널은 전체 시스템을 병목 현상으로 만들 수 있습니다. 각 processData 고루틴은 main 고루틴이 수신 준비가 될 때까지 차단되어 사실상 동시성을 제한합니다.
해결 방법: 적절한 버퍼링 채널 사용:
버퍼링 채널은 메시지에 대한 큐를 제공하여, 버퍼가 꽉 찰 때까지 송신자가 차단 없이 계속 진행할 수 있도록 합니다.
package main import ( "fmt" "sync" "time" ) func processDataBuffered(data int, out chan<- int, wg *sync.WaitGroup) { defer wg.Done() time.Sleep(100 * time.Millisecond) // 작업 시뮬레이션 out <- data * 2 } func main() { data := []int{1, 2, 3, 4, 5} // 버퍼링 채널 - 용량이 송신자가 즉시 수신자 없이 진행할 수 있도록 합니다 results := make(chan int, len(data)) var wg sync.WaitGroup for _, d := range data { wg.Add(1) go processDataBuffered(d, results, &wg) } wg.Wait() // 모든 처리 고루틴이 완료될 때까지 기다립니다 close(results) // 더 이상 데이터가 전송되지 않음을 알리기 위해 채널을 닫습니다 // 지금 모든 결과를 한 번에 소비합니다 for result := range results { fmt.Println("Received:", result) } }
버퍼링 채널(또는 적절한 조정이 있는 버퍼링되지 않은 채널)을 사용하면 생산자가 버퍼 크기까지 소비자보다 앞서 실행될 수 있어 실제 동시성과 처리량을 향상시킵니다.
5. 공유 메모리를 통한 데이터 레이스(Data Races)
채널은 통신을 위한 것이지만, 적절한 동기화 없이 여러 고루틴에서 공유 변수를 직접 액세스하고 수정하여 데이터 레이스를 발생시킬 수 있습니다.
데이터 레이스 예시:
package main import ( "fmt" "runtime" "sync" "time" ) var counter int func increment() { counter++ // 데이터 레이스! } func main() { runtime.GOMAXPROCS(1) // 레이스 탐지를 쉽게 하기 위해 논리 프로세서를 하나만 사용하도록 보장 var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() increment() }() } wg.Wait() fmt.Println("Final counter (race condition):", counter) // 1000이 아닐 가능성이 높습니다 }
이 코드를 go run -race main.go로 실행하면 데이터 레이스가 즉시 감지됩니다. counter++ 연산은 원자적이지 않습니다. 여러 고루틴에서 이를 가로챌 수 있는 읽기, 증가, 쓰기 작업을 포함합니다.
해결 방법: sync.Mutex 또는 sync/atomic 사용:
package main import ( "fmt" "sync" "sync/atomic" // 원자 연산을 위한 것 ) var safeCounter int32 // 원자 연산을 위해 int32 사용 var mu sync.Mutex // 공유 리소스 보호를 위한 뮤텍스 func incrementWithMutex() { mu.Lock() // 잠금 획득 ssafeCounter++ // 임계 섹션 mu.Unlock() // 잠금 해제 } func incrementWithAtomic() { atomic.AddInt32(&safeCounter, 1) // safeCounter에 1을 원자적으로 더합니다 } func main() { var wg sync.WaitGroup // 뮤텍스 사용 ssafeCounter = 0 // 카운터 재설정 for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() incrementWithMutex() }() } wg.Wait() fmt.Println("Final counter (with Mutex):", safeCounter) // 1000이 될 것입니다 // 원자 연산 사용 ssafeCounter = 0 // 카운터 재설정 for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() incrementWithAtomic() }() } wg.Wait() fmt.Println("Final counter (with Atomic):", safeCounter) // 1000이 될 것입니다 }
sync.Mutex는 상호 배제를 제공하여 한 번에 하나의 고루틴만 임계 섹션에 액세스하도록 보장합니다. sync/atomic은 스칼라 유형에 대한 간단한 변수 업데이트에 대한 낮은 수준의 고도로 최적화된 원자 연산을 제공하며, 이는 종종 뮤텍스보다 효율적입니다.
결론
Go의 고루틴과 채널은 동시 프로그래밍을 크게 단순화합니다. 그러나 그 힘은 고루틴 누수, 무기한 차단, 조정되지 않은 종료, 데이터 레이스와 같은 일반적인 함정을 피하기 위해 그 동작을 신중하게 이해해야 합니다. 취소를 위한 context, 동기화를 위한 sync.WaitGroup, 채널에 대한 적절한 버퍼링, 공유 메모리에 대한 sync.Mutex 또는 sync/atomic과 같은 관용적인 Go 관행을 채택함으로써 성능뿐만 아니라 견고하고 디버깅하기 쉬운 동시 Go 애플리케이션을 작성할 수 있습니다. 항상 Go 격언을 기억하세요: "공유 메모리로 통신하지 마십시오. 통신하여 메모리를 공유하십시오."

