Golang에서의 동시성과 병렬성의 춤
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Golang은 고유한 동시성 지원으로 자주 찬사를 받으며, 동시성과 병렬성이라는 개념을 구분하는 흥미로운 사례 연구를 제시합니다. 이 용어들은 일반적인 대화에서 자주 상호 교환적으로 사용되지만, Go의 설계 철학은 이들을 근본적으로 분리하여 확장 가능하고 반응성이 뛰어난 애플리케이션을 구축하기 위한 강력하면서도 실용적인 접근 방식을 제공합니다. 이 글은 Go의 "동시성 철학"을 깊이 파고들어, 동시 작업 관리에 대한 고유한 관점을 분석하고 진정한 병렬성을 직접적으로 보장하기보다는 어떻게 활용하는지를 살펴봅니다.
동시성 대 병렬성: 근본적인 구분
Go의 접근 방식을 탐구하기 전에 정의를 확고히 하는 것이 중요합니다.
-
동시성: 구조적인 측면에서 여러 가지를 동시에 처리하는 것과 관련이 있습니다. 이는 여러 작업을 중첩된 방식으로 처리하여 동시 실행의 외관을 제공하는 것입니다. 단일 코어 CPU는 작업을 빠르게 전환(슬라이싱)하여 동시성을 달성할 수 있습니다. 여러 개의 공을 공중에 띄우는 저글러를 생각해 보세요. 모든 공이 "날아가고" 있지만, 주어진 순간에 실제로 만져지는 것은 하나뿐입니다.
-
병렬성: 실행 측면에서 여러 가지를 동시에 처리하는 것과 관련이 있습니다. 이는 작업을 진정으로 동시에 실행하기 위해 여러 처리 장치(코어, CPU)가 필요합니다. 각자 자신의 공 세트를 독립적으로 동시에 처리하는 두 명의 저글러를 상상해 보세요.
Go는 디자인 철학으로서 동시성을 옹호합니다. 핵심 기본 요소인 고루틴과 채널은 우아하고 효율적인 동시 프로그래밍을 지원하는 데 기반합니다. 병렬성은 잘 설계된 동시 프로그램이 멀티코어 프로세서에서 실행된 결과가 될 수 있지만, Go의 동시성 모델 자체만으로는 주요 목표나 직접적인 보장이 아닙니다.
Go의 동시성 기본 요소: 고루틴과 채널
Go는 동시성 모델의 기반을 형성하는 두 가지 강력한 내장 기본 요소를 도입합니다.
고루틴: 경량 동시 실행 단위
A goroutine은 Go 런타임에서 관리하는 경량 실행 스레드입니다. 전통적인 운영 체제 스레드와 달리 고루틴은 생성 및 관리가 매우 저렴합니다. 이들은 더 적은 수의 OS 스레드로 다중화되며, Go 스케줄러는 실행을 효율적으로 관리합니다.
간단한 예를 고려해 보겠습니다.
package main import ( "fmt" "time" ) func sayHello(name string) { time.Sleep(100 * time.Millisecond) // 약간의 작업을 시뮬레이션 fmt.Printf("Hello, %s!\n", name) } func main() { fmt.Println("Starting main Goroutine") // sayHello를 고루틴으로 실행 go sayHello("Alice") go sayHello("Bob") go sayHello("Charlie") // 이 슬립이 없으면 다른 고루틴이 완료되기 전에 메인 고루틴이 종료될 수 있습니다. time.Sleep(200 * time.Millisecond) fmt.Println("Main Goroutine finished") }
이 코드를 실행하면 "Hello, Alice!", "Hello, Bob!", "Hello, Charlie!"가 출력되는 것을 볼 수 있지만, 순서는 달라질 수 있습니다. 이는 main
고루틴이 여러 sayHello
고루틴을 실행하고, 이들이 동시적으로 실행되기 때문입니다. main
의 time.Sleep
은 메인 고루틴이 기본적으로 다른 고루틴의 완료를 기다리지 않기 때문에 필요합니다. 자신의 실행 경로가 완료되면 종료됩니다.
여기서 핵심 사항은 go
키워드입니다. 이는 일반 함수 호출을 새 고루틴으로 변환하여 호출하는 고루틴과 동시적으로 실행되도록 합니다.
채널: 동시 통신 프로세스(CSP) 작동 방식
고루틴은 동시 실행을 가능하게 하지만, 이러한 동시 단위 간의 통신 및 동기화 문제를 야기합니다. Go는 Tony Hoare의 동시 통신 프로세스(CSP) 모델에서 영감을 받은 채널로 이를 해결합니다. 채널은 고루틴이 값을 보내고 받을 수 있는 유형화된 통로를 제공합니다.
채널의 철학은 "메모리 공유를 통해 통신하지 말고, 통신을 통해 메모리를 공유하라"입니다. 이 패러다임은 명시적인 통신을 조정의 주요 수단으로 만듦으로써 공유 메모리 동시성(예: 경쟁 상태, 교착 상태)과 관련된 복잡성을 크게 줄입니다.
이전 예제를 채널을 사용하여 완료 신호를 보내도록 수정해 보겠습니다.
package main import ( "fmt" "time" ) func worker(id int, done chan<- bool) { fmt.Printf("Worker %d starting...\n", id) time.Sleep(time.Duration(id) * 100 * time.Millisecond) // 작업 시뮬레이션 fmt.Printf("Worker %d finished.\n", id) done <- true // 완료 시 신호 보내기 } func main() { fmt.Println("Main: Starting workers...") numWorkers := 3 doneChannel := make(chan bool, numWorkers) // 워커 수에 맞춰 버퍼링된 채널 for i := 1; i <= numWorkers; i++ { go worker(i, doneChannel) } // 채널에서 수신하여 모든 워커가 완료될 때까지 기다립니다. for i := 0; i < numWorkers; i++ { <-doneChannel // 신호를 수신할 때까지 차단 } fmt.Println("Main: All workers finished!") }
이 수정된 예제에서 doneChannel
은 조정 지점 역할을 합니다. 각 worker
고루틴은 완료 시 채널에 true
값을 보냅니다. 그런 다음 main
고루틴은 numWorkers
신호를 받기를 기다리며 차단됩니다. 이를 통해 모든 워커가 완료를 보고한 후에만 main
고루틴이 진행됩니다.
채널은 버퍼링되지 않은(동기식) 채널 또는 버퍼링된(제한된 용량의 비동기식) 채널일 수 있습니다. 버퍼 없는 채널은 송신자와 수신자가 동기화하도록 강제하여 랑데부 지점을 제공합니다. 버퍼 있는 채널은 송신자가 차단되지 않고 버퍼 용량까지 값을 보낼 수 있도록 하여 송신자와 수신자를 분리할 수 있습니다.
병렬성 활용
Go의 동시성 모델은 병렬성을 외면하는 것이 아닙니다. 오히려 병렬성을 가능하게 합니다. Go 런타임 스케줄러는 실행 가능한 고루틴을 사용 가능한 CPU 코어에 분산하도록 설계되었습니다. 기본적으로 Go는 GOMAXPROCS
(Go 스케줄러에서 사용 가능한 OS 스레드 수)를 논리적 CPU 수로 설정합니다. 즉, 4코어 프로세서가 있다면 Go 런타임은 일반적으로 4개의 OS 스레드를 사용하여 고루틴을 병렬로 실행합니다.
worker
예제와 같이 실행하면 여러 코어가 있는 머신에서 Go 스케줄러는 모든 작업이 동시에 실행될 준비가 되었다고 가정할 때 worker 1
, worker 2
, worker 3
을 별도의 코어에서 병렬로 실행할 가능성이 높습니다. 각 작업자에서 time.Sleep
은 다른 고루틴을 실행할 수 있도록 일시 중지시킵니다.
하지만 Go가 특정 고루틴 집합에 대한 병렬 실행을 보장하는 것이 아니라, 리소스가 허용하는 경우 병렬로 실행될 수 있다는 것을 이해하는 것이 중요합니다. 스케줄러의 목표는 모든 동시 작업의 엄격한 병렬 처리가 아니라 효율성과 공정성입니다.
Go 중심의 동시성 철학
Go의 디자인은 다음을 강조합니다.
- 복잡성보다 단순성: 고루틴은 이해하고 사용하기 쉽습니다. 명시적인 스레드 관리, 뮤텍스 잠금(엄격히 필요한 경우가 아니면) 또는 복잡한 콜백 지옥이 없습니다.
- 내장 기본 요소: 동시성은 사후 고려 사항이 아니라 일급 시민입니다. 고루틴과 채널은 핵심 언어 기능입니다.
- 공유 메모리보다 통신: CSP 모델은 직접적인 공유 메모리 액세스를 최소화하여 동시성에 대한 더 안전하고 관리하기 쉬운 접근 방식을 장려합니다.
- 확장 가능하고 효율적: 고루틴의 경량 특성과 지능적인 Go 스케줄러를 통해 애플리케이션은 상대적으로 낮은 오버헤드로 엄청난 수의 동시 작업을 처리할 수 있습니다.
- 런타임에 맡기기: 개발자는 동시 작업을 식별하고 통신 패턴을 정의하는 데 집중하고, Go 런타임이 스케줄링 및 리소스 관리의 복잡성을 처리하도록 합니다.
이러한 철학 덕분에 Go는 동시 연결 또는 작업을 효율적으로 처리해야 하는 네트워크 서비스, 분산 시스템 및 I/O 바인딩 애플리케이션에 특히 적합합니다.
결론
Go는 동시 프로그래밍을 위한 기본 요소만 제공하는 것이 아니라, 동시성을 설계 패턴으로 우선시하면서도 암묵적으로 병렬성을 가능하게 하는 깊이 생각한 철학을 구현합니다. 경량 고루틴으로 동시 실행을, 강력한 채널로 안전한 통신을 제공함으로써 Go는 개발자가 확장 가능하고 유지 관리하기 쉬우며 강력한 동시 애플리케이션을 작성할 수 있도록 지원합니다. 동시성(한 번에 많은 작업을 구조화하는 것)과 병렬성(동시에 많은 작업을 수행하는 것)의 구분은 Go의 성공에 기본이 됩니다. 동시적으로 생각하도록 장려하는 언어로서, 가능한 경우 강력한 런타임이 병렬 실행을 처리하도록 하여 현대 컴퓨팅의 복잡한 춤을 놀랍도록 우아하게 느끼게 합니다.