Go의 동시성 핵심인 고루틴의 내부 구조, 버퍼드/언버퍼드 채널, sync 패키지의 WaitGroup과 Mutex, 고루틴 생명주기 관리와 일반적인 동시성 패턴을 다룹니다.
고루틴(Goroutine)은 Go 런타임이 관리하는 경량 실행 단위입니다. OS 스레드와 비교했을 때 근본적인 차이가 있습니다.
| 특성 | OS 스레드 | 고루틴 |
|---|---|---|
| 초기 스택 크기 | 1-8MB | 2KB |
| 생성 비용 | 높음 (커널 호출) | 낮음 (사용자 공간) |
| 컨텍스트 스위칭 | 커널 모드 전환 | 사용자 공간에서 처리 |
| 동시 실행 가능 수 | 수천 개 | 수십만 개 |
| 스케줄링 | OS 스케줄러 | Go 런타임 스케줄러 |
Go 런타임은 GMP 모델로 고루틴을 스케줄링합니다.
GOMAXPROCS로 개수를 설정하며 기본값은 CPU 코어 수P는 로컬 실행 큐(Local Run Queue)를 가지고 있으며, 고루틴이 생성되면 현재 P의 로컬 큐에 추가됩니다. 한 P의 큐가 비면 다른 P의 큐에서 고루틴을 가져오는 워크 스틸링(Work Stealing) 메커니즘이 동작합니다.
고루틴 생성은 go 키워드 하나로 이루어집니다.
func main() {
// 익명 함수로 고루틴 생성
go func() {
fmt.Println("고루틴에서 실행")
}()
// 기명 함수로 고루틴 생성
go processData(data)
// 메서드 호출도 가능
go server.HandleRequest(req)
}main 함수가 종료되면 실행 중인 모든 고루틴이 즉시 종료됩니다. 고루틴의 완료를 기다리는 메커니즘(채널, WaitGroup 등)이 반드시 필요합니다.
Go의 동시성 모델은 CSP(Communicating Sequential Processes) 이론에 기반합니다. "메모리를 공유하여 통신하지 말고, 통신하여 메모리를 공유하라"가 핵심 원칙입니다.
언버퍼드 채널(Unbuffered Channel)은 송신자와 수신자가 동시에 준비되어야 데이터가 전달됩니다. 동기적인 핸드셰이크와 같습니다.
ch := make(chan string) // 버퍼 없는 채널
go func() {
ch <- "hello" // 수신자가 준비될 때까지 블로킹
}()
msg := <-ch // 송신자가 보낼 때까지 블로킹
fmt.Println(msg)버퍼드 채널(Buffered Channel)은 지정된 용량만큼 데이터를 버퍼에 저장할 수 있습니다. 버퍼가 가득 차면 송신이 블로킹되고, 버퍼가 비면 수신이 블로킹됩니다.
ch := make(chan int, 3) // 버퍼 크기 3
ch <- 1 // 블로킹 없음
ch <- 2 // 블로킹 없음
ch <- 3 // 블로킹 없음
// ch <- 4 // 여기서 블로킹! 버퍼 가득 참
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2함수 매개변수에서 채널의 방향을 제한하면 안전성이 높아집니다.
// 송신 전용 채널
func produce(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch) // 송신 완료 후 채널 닫기
}
// 수신 전용 채널
func consume(ch <-chan int) {
for v := range ch { // 채널이 닫힐 때까지 수신
fmt.Println(v)
}
}
func main() {
ch := make(chan int, 5)
go produce(ch)
consume(ch)
}// 완료 신호 패턴
func worker(done chan<- struct{}) {
defer func() { done <- struct{}{} }()
// 작업 수행...
}
func main() {
done := make(chan struct{})
go worker(done)
<-done // 작업 완료 대기
}// 제너레이터 패턴
func fibonacci(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
a, b := 0, 1
for i := 0; i < n; i++ {
ch <- a
a, b = b, a+b
}
}()
return ch
}
for v := range fibonacci(10) {
fmt.Println(v)
}채널을 반환하는 함수는 채널의 생성과 닫기를 모두 함수 내부에서 관리합니다. 이렇게 하면 채널의 생명주기가 명확해지고 누수를 방지할 수 있습니다.
채널이 Go 동시성의 주요 도구이지만, 모든 상황에 적합한 것은 아닙니다. 공유 상태에 대한 접근 제어가 필요할 때는 sync 패키지를 사용합니다.
WaitGroup은 여러 고루틴의 완료를 기다리는 가장 간단한 방법입니다.
func fetchAll(urls []string) []Response {
var (
wg sync.WaitGroup
mu sync.Mutex
results []Response
)
for _, url := range urls {
wg.Add(1) // 카운터 증가
go func(u string) {
defer wg.Done() // 카운터 감소
resp, err := http.Get(u)
mu.Lock()
results = append(results, Response{URL: u, Resp: resp, Err: err})
mu.Unlock()
}(url)
}
wg.Wait() // 모든 고루틴 완료 대기
return results
}wg.Add(1)는 반드시 고루틴을 시작하기 전에 호출해야 합니다. 고루틴 내부에서 호출하면 wg.Wait()가 모든 고루틴이 시작되기 전에 반환될 수 있습니다.
뮤텍스(Mutex, Mutual Exclusion)는 공유 자원에 대한 동시 접근을 제어합니다.
type SafeCounter struct {
mu sync.Mutex
count map[string]int
}
func (c *SafeCounter) Increment(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.count[key]++
}
func (c *SafeCounter) Get(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count[key]
}읽기가 쓰기보다 훨씬 빈번한 경우, RWMutex(Read-Write Mutex)를 사용하면 성능을 개선할 수 있습니다. 여러 고루틴이 동시에 읽기를 수행할 수 있으며, 쓰기 시에만 배타적 잠금을 획득합니다.
type ConfigStore struct {
mu sync.RWMutex
config map[string]string
}
func (cs *ConfigStore) Get(key string) (string, bool) {
cs.mu.RLock() // 읽기 잠금 -- 동시 읽기 허용
defer cs.mu.RUnlock()
v, ok := cs.config[key]
return v, ok
}
func (cs *ConfigStore) Set(key, value string) {
cs.mu.Lock() // 쓰기 잠금 -- 배타적 접근
defer cs.mu.Unlock()
cs.config[key] = value
}sync.Once는 특정 작업을 정확히 한 번만 실행하도록 보장합니다. 싱글턴 패턴이나 지연 초기화에 유용합니다.
var (
dbOnce sync.Once
db *sql.DB
)
func GetDB() *sql.DB {
dbOnce.Do(func() {
var err error
db, err = sql.Open("postgres", connStr)
if err != nil {
log.Fatal(err)
}
})
return db
}**고루틴 누수(Goroutine Leak)**는 고루틴이 영원히 블로킹 상태에 머무르는 현상입니다. 이는 메모리 누수로 이어지며, 장시간 운영되는 서버에서 심각한 문제를 야기합니다.
// 누수 발생 -- ch에서 읽는 소비자가 없으면 고루틴이 영원히 블로킹
func leakyFunction() {
ch := make(chan int)
go func() {
result := heavyComputation()
ch <- result // 수신자가 없으면 영원히 블로킹
}()
// ch를 읽지 않고 함수 종료 -- 고루틴 누수!
}고루틴 누수를 방지하는 핵심 전략은 모든 고루틴이 종료 조건을 가지도록 설계하는 것입니다.
func safeFunction(ctx context.Context) (int, error) {
ch := make(chan int, 1) // 버퍼드 채널로 블로킹 방지
go func() {
result := heavyComputation()
ch <- result
}()
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
return 0, ctx.Err() // 타임아웃 또는 취소
}
}고루틴 누수는 런타임에 에러를 발생시키지 않기 때문에 발견하기 어렵습니다. runtime.NumGoroutine()을 모니터링하거나, Uber의 goleak 라이브러리를 테스트에서 사용하여 누수를 감지할 수 있습니다.
여러 외부 API를 동시에 호출하고 결과를 합산하는 실전적인 예제입니다.
type APIResponse struct {
Source string
Data json.RawMessage
Err error
}
func FetchFromMultipleSources(ctx context.Context, urls map[string]string) []APIResponse {
var wg sync.WaitGroup
results := make(chan APIResponse, len(urls))
for source, url := range urls {
wg.Add(1)
go func(src, u string) {
defer wg.Done()
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
results <- APIResponse{Source: src, Err: err}
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
results <- APIResponse{Source: src, Err: err}
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
results <- APIResponse{
Source: src,
Data: json.RawMessage(body),
Err: err,
}
}(source, url)
}
// 별도 고루틴에서 모든 작업 완료 후 채널 닫기
go func() {
wg.Wait()
close(results)
}()
var responses []APIResponse
for r := range results {
responses = append(responses, r)
}
return responses
}이 패턴은 WaitGroup과 채널을 조합하여 동시 작업의 결과를 안전하게 수집합니다. context를 전달하여 타임아웃이나 취소도 지원합니다.
이번 장에서 살펴본 핵심 내용을 정리합니다.
4장에서는 동시성 패턴 심화를 다룹니다. select 문, context.Context를 활용한 취소와 타임아웃, errgroup, 팬아웃/팬인, 파이프라인, 워커 풀 패턴 등 실전에서 필수적인 고급 동시성 패턴을 살펴봅니다.
이 글이 도움이 되셨나요?
Go의 고급 동시성 패턴인 select 문, context.Context를 활용한 취소/타임아웃, errgroup, 팬아웃/팬인, 파이프라인, 워커 풀, 레이트 리미팅 패턴을 다룹니다.
Go의 구조체, 메서드, 암묵적 인터페이스 만족, 제네릭(타입 파라미터), 타입 제약 조건, 임베딩을 통한 합성 패턴을 체계적으로 다룹니다.
Go의 에러 값 철학, errors.Is/As를 활용한 에러 검사, fmt.Errorf와 %w를 사용한 에러 래핑, 커스텀 에러 타입, 센티널 에러, panic/recover의 올바른 사용법을 다룹니다.