
안녕하세요, 개발자 여러분! 오늘날 소프트웨어는 점점 더 복잡해지고, 사용자들은 더 빠른 응답과 효율적인 처리를 기대합니다. 이러한 요구사항을 충족시키기 위해 동시성(Concurrency)은 선택이 아닌 필수가 되었습니다. 그리고 Go 언어는 동시성 프로그래밍을 매우 쉽고 안전하게 구현할 수 있도록 설계된 언어입니다. 다른 언어에서 스레드, 락, 세마포어 등으로 골머리를 앓으셨다면, Go의 Goroutine과 Channel은 마치 한 줄기 빛처럼 느껴질 것입니다. 이번 글에서는 Go의 강력한 동시성 모델을 함께 탐험해봅시다!
많은 분들이 동시성(Concurrency)과 병렬성(Parallelism)을 혼동하곤 합니다. 간단히 정리하자면:
Go는 동시성을 통해 시스템의 효율성을 높이고, 여러 코어를 활용하여 병렬 처리까지 자연스럽게 지원합니다.
Go에서 동시성 작업을 시작하는 것은 정말 간단합니다. 함수 호출 앞에 go 키워드만 붙이면 됩니다. 이렇게 실행되는 함수를 Goroutine이라고 합니다. Goroutine은 일반적인 OS 스레드보다 훨씬 가볍습니다. 수십만 개의 Goroutine을 생성하더라도 시스템에 큰 부담을 주지 않습니다. 이는 Go 런타임이 Goroutine들을 효율적으로 스케줄링하기 때문입니다.
예제 코드: 간단한 Goroutine 실행
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 3; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("world") // Goroutine으로 실행
say("hello") // 메인 Goroutine으로 실행
// 메인 Goroutine이 먼저 종료되면 다른 Goroutine도 종료될 수 있으므로 대기
time.Sleep(1 * time.Second)
}
위 코드를 실행하면 "hello"와 "world"가 번갈아 가며 출력되는 것을 볼 수 있습니다. go say("world")는 새로운 Goroutine을 생성하여 say("world") 함수를 비동기적으로 실행합니다. main 함수는 메인 Goroutine으로 실행됩니다.
주의: 메인 Goroutine이 종료되면 다른 모든 Goroutine도 강제로 종료될 수 있습니다. 위 예제에서는
time.Sleep을 사용하여 메인 Goroutine이 충분히 대기하도록 만들었습니다.
Goroutine만으로는 동시성 프로그래밍이 완전하지 않습니다. 여러 Goroutine이 서로 데이터를 주고받으며 협력해야 할 때가 많습니다. 이때 사용하는 것이 바로 Channel입니다. Channel은 Goroutine 간에 데이터를 주고받을 수 있는 파이프 역할을 합니다. Go의 동시성 철학은 "공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라"인데, Channel이 바로 이 철학의 핵심입니다.
Channel 생성 및 사용
package main
import "fmt"
func sum(s []int, c chan int) {
sum := 0
for _, v := range s {
sum += v
}
c <- sum // sum을 channel c로 보냄
}
func main() {
s := []int{7, 2, 8, -9, 4, 0}
c := make(chan int) // int 타입의 channel 생성
go sum(s[:len(s)/2], c)
go sum(s[len(s)/2:], c)
x, y := <-c, <-c // channel c로부터 두 값을 받음
fmt.Println(x, y, x+y)
}
위 예제에서는 두 개의 sum Goroutine이 각자 배열의 절반을 계산한 후, 결과를 c라는 Channel로 보냅니다. main Goroutine은 <-c를 통해 Channel로부터 두 결과를 받아 출력합니다.
make(chan int): int 타입의 값을 주고받을 수 있는 Channel을 생성합니다.c <- value: value를 Channel c로 보냅니다. (송신)value := <-c: Channel c로부터 값을 받습니다. (수신)Channel은 기본적으로 블로킹(Blocking) 특성을 가집니다. 즉, 값을 보낼 준비가 되지 않았거나(수신자가 없을 때), 값을 받을 준비가 되지 않았을 때(송신자가 없을 때) 해당 작업은 멈춰서(블록) 대기합니다. 이 블로킹 특성 덕분에 별도의 락이나 뮤텍스 없이도 안전하게 동기화가 이루어집니다.
앞서 본 Channel은 버퍼가 없는 언버퍼링(unbuffered) Channel입니다. 버퍼링된 Channel은 지정된 개수만큼의 값을 보낼 때까지 블록되지 않습니다.
예제 코드: 버퍼링된 Channel
package main
import "fmt"
func main() {
c := make(chan int, 2) // 버퍼 크기가 2인 channel 생성
c <- 1
c <- 2
// c <- 3 // 이 시점에서 블록됨 (버퍼가 가득 찼기 때문)
fmt.Println(<-c)
fmt.Println(<-c)
// fmt.Println(<-c) // 이 시점에서 블록됨 (버퍼가 비어있기 때문)
}
make(chan int, 2)는 버퍼 크기가 2인 Channel을 만듭니다. 이 Channel은 두 개의 값을 보낼 때까지는 블록되지 않습니다. 세 번째 값을 보내려 할 때 블록됩니다. 버퍼링된 Channel은 특정 시점에 일시적으로 많은 데이터를 처리해야 할 때 유용하게 사용될 수 있습니다.
select 문: 여러 Channel 다루기여러 개의 Channel에서 데이터를 주고받아야 할 때, Go는 select 문을 제공합니다. select는 switch 문과 유사하지만, Channel 작업에 특화되어 있습니다. 여러 case 중 준비된(즉시 송수신 가능한) Channel 작업이 있으면 해당 case를 실행하고, 여러 개가 준비되어 있으면 그 중 하나를 랜덤하게 선택합니다.
예제 코드: select 문
package main
import (
"fmt"
"time"
)
func fibonacci(c, quit chan int) {
x, y := 0, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
fmt.Println("quit")
return
case <-time.After(500 * time.Millisecond): // 500ms 이후에 실행
fmt.Println("timeout")
return
}
}
}
func main() {
c := make(chan int)
quit := make(chan int)
go func() {
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
quit <- 0
}()
fibonacci(c, quit)
}
fibonacci Goroutine은 c Channel로 피보나치 수열을 보내거나, quit Channel로부터 종료 신호를 받거나, 500ms 타임아웃이 발생할 때까지 대기합니다. select 문은 이런 복잡한 동시성 제어를 우아하게 처리할 수 있도록 돕습니다.
Go의 Goroutine과 Channel은 동시성 프로그래밍을 더 이상 어렵고 위험한 영역으로 만들지 않습니다. 가벼운 Goroutine으로 수많은 작업을 시작하고, Channel을 통해 안전하게 데이터를 주고받으며, select 문으로 복잡한 제어를 수행할 수 있습니다. 이러한 Go의 동시성 모델은 현대의 멀티코어 환경에서 고성능, 고확장성 애플리케이션을 개발하는 데 있어 엄청난 이점을 제공합니다.
Go를 사용하여 서버 애플리케이션, 분산 시스템, 백그라운드 작업 처리 등 다양한 분야에서 여러분의 아이디어를 현실로 만들어보세요. 처음에는 조금 낯설 수 있지만, 일단 익숙해지면 Go의 동시성 모델 없이는 개발하기 어렵다고 느끼실 겁니다. 여러분의 다음 프로젝트에서 Go의 강력한 동시성을 경험해보시길 바랍니다!
로그인 후 댓글을 작성할 수 있습니다.