Go 루틴과 채널: 현대적 동시성 패턴
Min-jun Kim
Dev Intern · Leapcell

Go가 클라우드 및 현대적 동시성 시스템을 위해 구축된 언어로서의 명성은 주로 우아하고 강력한 동시성 접근 방식인 Goroutine과 Channel 덕분입니다. 애플리케이션이 높은 응답성, 확장성 및 효율적인 리소스 활용을 요구하는 시대에 이러한 기본 요소를 활용하는 방법을 이해하는 것은 '있으면 좋은 것'을 넘어 모든 Go 개발자에게 필수적인 기술입니다. 이 글에서는 Go의 동시성 모델 뒤에 숨겨진 마법을 기본 구성 요소에서 시작하여 Fan-in, Fan-out, Worker Pool과 같은 실용적이고 실제적인 패턴까지 단계별로 풀어낼 것입니다. 이 글을 마치면 복잡한 문제를 단순하고 효과적으로 해결하면서 Go에서 강력한 동시성 애플리케이션을 설계하고 구현하는 방법에 대한 명확한 이해를 갖게 될 것입니다.
Go의 동시성 모델의 핵심에는 상호 보완적인 두 가지 구성 요소가 있습니다: Goroutine과 Channel.
Goroutine: 경량 동시 실행
Goroutine은 동일한 주소 공간 내에서 다른 Goroutine과 동시에 실행되는 경량의 독립적인 실행 함수입니다. 전통적인 OS 스레드와 달리 Goroutine은 Go 런타임에 의해 더 적은 수의 OS 스레드로 멀티플렉싱되어 생성 및 관리 비용이 매우 저렴합니다. 이는 상당한 오버헤드 없이 수천 또는 수백만 개의 Goroutine을 시작할 수 있음을 의미하며, 고도로 동시적인 애플리케이션을 가능하게 합니다.
Goroutine을 시작하려면 go
키워드 뒤에 함수 호출을 사용하면 됩니다:
package main import ( "fmt" "time" ) func sayHello(name string) { time.Sleep(100 * time.Millisecond) // 약간의 작업 시뮬레이션 fmt.Printf("Hello, %s!\n", name) } func main() { go sayHello("Alice") // 고루틴 시작 fmt.Println("Main 함수 실행 계속...") // 메인 함수는 기다려야 하며, 그렇지 않으면 프로그램이 고루틴이 완료되기 전에 종료될 수 있습니다. time.Sleep(200 * time.Millisecond) }
이 예제에서 sayHello("Alice")
는 main
함수와 동시에 실행됩니다. main
에 있는 time.Sleep
에 유의하십시오. 이것이 없으면 main
은 sayHello
가 실행될 기회를 얻기 전에 완료될 수 있으며, 이는 Goroutine이 비차단적임을 보여줍니다.
Channel: 순차적 프로세스 통신
Goroutine이 실행을 처리하는 동안 Channel은 Goroutine 간의 통신을 처리합니다. Go의 철학인 "메모리를 공유하여 통신하지 말고, 통신하여 메모리를 공유하라"는 Channel을 통해 구현됩니다. Channel은 값을 보내고 받을 수 있는 형식화된 통로입니다.
Channel은 버퍼링되지 않거나 버퍼링될 수 있습니다:
- 버퍼링되지 않은 Channel: 버퍼링되지 않은 Channel에 대한 보내기 작업은 받기 작업이 준비될 때까지 차단되며, 그 반대도 마찬가지입니다. 이는 동기식 통신을 보장합니다.
- 버퍼링된 Channel: 버퍼링된 Channel에는 용량이 있습니다. 보내기 작업은 버퍼가 꽉 찼을 때만 차단되고, 받기 작업은 버퍼가 비어 있을 때만 차단됩니다.
Channel 사용 방법은 다음과 같습니다:
package main import ( "fmt" "time" ) func producer(ch chan int) { for i := 0; i < 5; i++ { fmt.Printf("Producer: Sending %d\n", i) ch <- i // 채널에 값 보내기 time.Sleep(50 * time.Millisecond) } close(ch) // 완료되면 채널 닫기 } func consumer(ch chan int) { for val := range ch { // 채널에서 값 받기 fmt.Printf("Consumer: Received %d\n", val) } fmt.Println("Consumer: Channel closed, exiting.") } func main() { // 버퍼링되지 않은 채널 생성 messages := make(chan int) go producer(messages) go consumer(messages) // 고루틴이 완료될 때까지 메인을 유지 time.Sleep(500 * time.Millisecond) }
이 예제에서 producer
Goroutine은 messages
채널에 정수를 보내고, consumer
Goroutine은 이를 받습니다. 채널의 for...range
루프는 채널이 닫힐 때까지 값을 소비합니다.
이제 Goroutine 및 Channel을 기반으로 하는 강력한 동시성 패턴을 살펴보겠습니다.
현대적 동시성 패턴
Fan-out: 작업 분산
Fan-out은 단일 작업 소스가 여러 작업자 Goroutine에 작업을 분산하는 패턴입니다. 이는 CPU 바운드 또는 I/O 바운드 작업을 병렬화하는 데 매우 유용합니다. 일반적으로 단일 입력 채널과 이를 읽는 여러 작업자 Goroutine을 사용합니다.
package main import ( "fmt" "sync" time" ) // worker는 숫자를 처리하며, 약간의 계산을 시뮬레이션합니다. func worker(id int, jobs <-chan int, results chan<- int) { for j := range jobs { fmt.Printf("Worker %d: processing job %d\n", id, j) time.Sleep(100 * time.Millisecond) // 작업 시뮬레이션 results <- j * 2 // 결과 보내기 } } func main() { const numJobs = 10 const numWorkers = 3 jobs := make(chan int, numJobs) results := make(chan int, numJobs) // 작업자 고루틴 시작 for w := 1; w <= numWorkers; w++ { go worker(w, jobs, results) } // 작업 보내기 for j := 1; j <= numJobs; j++ { jobs <- j } close(jobs) // 보낼 작업 없음 // WaitGroup을 사용하여 모든 작업자가 완료되도록 보장하여 결과 수집 var wg sync.WaitGroup wg.Add(numWorkers) // 각 작업자가 결과를 보내는 것을 완료하기 위한 카운트 추가 go func() { for a := 1; a <= numJobs; a++ { <-results // 결과 채널을 그냥 비우기만 합니다. 실제 앱에서는 처리할 것입니다. } // 더 복잡한 시나리오에서는 모든 결과가 수집되었는지 알기 위한 별도의 메커니즘이 있을 수 있습니다. // 단순함을 위해 여기서는 numJobs 개의 결과를 읽습니다. }() // 결과 대기를 위한 더 강력한 방법은 // 별도의 고루틴이나 단순히 고정된 수의 읽기를 기다리는 대신 // 작업자가 완료 신호를 보내는 메커니즘을 사용하는 것일 수 있습니다. // 간단한 시연을 위해 결과가 수집될 시간을 확보합시다. time.Sleep(time.Duration(numJobs/numWorkers) * 150*time.Millisecond + 200*time.Millisecond) fmt.Println("모든 작업 처리 완료.") }
이 Fan-out
예제에서는 main
이 jobs
채널에 작업을 푸시합니다. 여러 worker
Goroutine이 jobs
에서 동시에 읽고, 처리하고, 결과를 results
채널로 다시 보냅니다.
Fan-in: 결과 취합
Fan-in은 Fan-out의 반대로, 여러 소스가 단일 채널에 데이터를 보내 데이터 스트림을 취합합니다. 이는 종종 여러 병렬 계산에서 결과를 수집하는 데 사용됩니다.
package main import ( "fmt" "sync" time" ) // dataSource는 다른 소스에서 데이터를 가져오는 것을 시뮬레이션합니다. func dataSource(id int, out chan<- string, wg *sync.WaitGroup) { defer wg.Done() time.Sleep(time.Duration(100+id*50) * time.Millisecond) // 다양한 가져오기 시간 시뮬레이션 out <- fmt.Sprintf("Data from source %d", id) } func main() { const numSources = 5 results := make(chan string) // 모든 결과에 대한 단일 채널 var wg sync.WaitGroup // 여러 데이터 소스 시작 for i := 1; i <= numSources; i++ { wg.Add(1) go dataSource(i, results, &wg) } // 모든 소스가 완료되면 결과 채널을 닫는 고루틴 go func() { wg.Wait() // 모든 데이터 소스가 완료될 때까지 기다림 close(results) // 채널 닫기 }() // 단일 fan-in 채널에서 결과 수집 fmt.Println("Collecting results:") for r := range results { fmt.Println(r) } fmt.Println("All results collected.") }
여기서 dataSource
Goroutine은 results
채널에 동일하게 데이터를 보냅니다. 별도의 Goroutine은 sync.WaitGroup
을 사용하여 모든 dataSource
Goroutine이 완료될 때까지 기다린 다음 results
채널을 닫아 main
함수에 더 이상 데이터가 도착하지 않음을 알립니다.
Worker Pools: 제어된 동시성
A Worker Pool은 Fan-out 및 Fan-in을 결합하여 공유 큐에서 작업을 처리하는 고정된 수의 Goroutine(작업자)을 만듭니다. 이 패턴은 제어된 동시성을 제공하여 리소스 고갈을 방지하고 효율적인 작업 분산을 보장합니다. 많은 작업이 있지만 동시 작업 수를 제한하려는 시나리오에 이상적입니다.
package main import ( "fmt" "sync" time" ) // Pooled Worker 함수 func workerPoolWorker(id int, jobs <-chan int, results chan<- int) { for j := range jobs { fmt.Printf("Worker %d starting job %d\n", id, j) time.Sleep(time.Duration(j) * 50 * time.Millisecond) // 작업 ID에 따라 작업 시뮬레이션 fmt.Printf("Worker %d finished job %d\n", id, j) results <- j * 2 } } func main() { const numJobs = 10 const numWorkers = 3 // 고정 작업자 수 jobs := make(chan int, numJobs) results := make(chan int, numJobs) // 작업자 풀 시작: 'numWorkers' 개의 고루틴 시작 for w := 1; w <= numWorkers; w++ { go workerPoolWorker(w, jobs, results) } // jobs 채널에 작업 보내기 for j := 1; j <= numJobs; j++ { jobs <- j } close(jobs) // 보낼 작업 없음 // results 채널에서 결과 수집 // numJobs 개의 모든 결과를 기다려야 합니다. var receivedResults []int for a := 1; a <= numJobs; a++ { res := <-results receivedResults = append(receivedResults, res) } fmt.Println("All results collected:", receivedResults) }
Worker Pool 예제에서는 numWorkers
개의 Goroutine이 한 번 시작되어 지속적으로 jobs
채널에서 작업을 가져옵니다. 모든 작업이 전송되고 jobs
가 닫힌 후 작업자는 남은 작업을 처리한 후 결국 종료됩니다. main
함수는 numJobs
개의 결과를 수집하여 모든 작업이 완료되었는지 확인합니다.
Go의 Goroutine과 Channel은 강력하면서도 직관적인 동시성 접근 방식을 제공하여 확장 가능하고 반응성이 뛰어난 애플리케이션을 더 쉽게 구축할 수 있습니다. 기본 개념을 이해하고 Fan-in, Fan-out, Worker Pool과 같은 패턴을 마스터함으로써 복잡한 동시성 흐름을 효과적으로 관리하여 더 강력하고 효율적인 소프트웨어를 만들 수 있습니다. Go의 동시성 모델은 개발자가 성능이 뛰어날 뿐만 아니라 이해하기 쉽고 유지 관리하기 쉬운 동시성 코드를 작성할 수 있도록 진정으로 지원합니다.
이러한 예제는 가능한 것의 극히 일부에 불과합니다. 더 깊이 파고들면서 취소 및 시간 초대를 위한 컨텍스트의 정교한 사용, 오류 전파 패턴 및 기타 고급 동기화 기본 요소를 발견하게 될 것입니다. 이 모든 것은 Goroutine 및 Channel의 강력한 기반 위에 구축됩니다. Go의 동시성을 받아들이십시오. 이는 게임 체인저입니다.