TechFeedTechFeed
Programming Languages

Go 동시성 심층 가이드 — 고루틴·채널·컨텍스트, 제대로 쓰는 법

고 언어(Go) 동시성 핵심을 5000자 이상 심층 분석. 고루틴이 스레드와 어떻게 다른지, 언버퍼드·버퍼드 채널 차이, select로 타임아웃 구현, context.WithCancel·WithTimeout으로 취소 전파, sync.Mutex·RWMutex·WaitGroup·Once 실전 패턴, 워커 풀 구현, 클로저 캡처·고루틴 누수·닫힌 채널 패닉 3대 함정, 레이스 컨디션 감지(-race 플래그)까지 정리했습니다.

by

한 줄 핵심: 고 언어(Go)의 동시성은 스레드가 아니라 고루틴(goroutine)과 채널(channel)로 다룬다. 운영체제 스레드보다 수천 배 가볍고, 공유 메모리 대신 메시지 전달로 데이터를 교환한다. 이 원칙을 이해하면 고 언어의 동시성 코드가 왜 그렇게 작성되는지 보이기 시작한다.


이 글이 필요한 사람
  • 고 언어를 막 시작했는데 고루틴·채널을 어떻게 써야 할지 감이 안 잡히는 개발자
  • 파이썬·자바·타입스크립트에서 고 언어로 넘어오면서 동시성 모델 차이가 헷갈리는 분
  • 데드락, 채널 블록, 고루틴 누수 같은 동시성 버그를 실전에서 겪어본 분

※ 코드 예시는 고 언어 공식 사양(go.dev)과 Go Blog 글을 기반으로 작성했습니다. 실제 동작하는 코드만 담았습니다.


고루틴이란 — 스레드가 아니다

고루틴은 고 언어 런타임이 관리하는 경량 실행 단위다. 운영체제(OS) 스레드 위에서 동작하지만 스레드 자체는 아니다. 운영체제 스레드 하나가 수십만~수백만 개의 고루틴을 스케줄링할 수 있다. 생성 비용이 매우 낮기 때문에 함수 호출 앞에 `go` 키워드 하나만 붙이면 바로 새 고루틴이 시작된다.


스레드와 비교했을 때 고루틴의 특징은 두 가지다. 첫째, 스택 크기가 동적으로 늘어난다. 운영체제 스레드는 보통 스택 크기를 고정으로 할당(1~8MB)하는 반면, 고루틴은 2KB 정도로 시작해서 필요에 따라 커진다. 둘째, 고 런타임의 스케줄러가 고루틴을 운영체제 스레드에 직접 매핑해서 돌린다. 이 방식을 M:N 스케줄링이라고 부른다. 수백만 개의 고루틴이 소수의 운영체제 스레드 위에서 돌아갈 수 있는 이유가 이 구조 때문이다.


고루틴을 만드는 것은 간단하다. `go 함수명()` 한 줄이면 된다. 다만 메인 함수가 먼저 종료되면 고루틴도 함께 사라지므로, 고루틴이 끝나기를 기다리는 방법을 함께 알아야 한다.


고루틴 기본 사용
package main import ( "fmt" "sync" ) func printMessage(msg string, wg *sync.WaitGroup) { defer wg.Done() // 함수 종료 시 WaitGroup 카운터 감소 fmt.Println(msg) } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) // 고루틴 시작 전 카운터 증가 go printMessage(fmt.Sprintf("고루틴 %d 실행", i), &wg) } wg.Wait() // 모든 고루틴이 Done()을 호출할 때까지 대기 fmt.Println("모든 고루틴 완료") }

채널 — 고루틴 간 데이터 교환의 기본

고 언어의 동시성 철학은 한 마디로 요약된다. "메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유해라." 채널(channel)이 이 철학을 구현하는 도구다.


채널은 고루틴 간에 데이터를 안전하게 주고받는 통로다. 한 고루틴이 채널에 데이터를 보내고, 다른 고루틴이 그 데이터를 받는다. 채널 자체가 동기화를 처리하기 때문에 별도의 락(lock)이 필요 없다.


채널에는 두 종류가 있다. 언버퍼드 채널(unbuffered channel)은 송신자와 수신자가 동시에 준비되어야 통신이 이루어진다. 한쪽이 먼저 도착하면 상대방이 나타날 때까지 기다린다. 이 특성을 이용해 두 고루틴을 동기화하는 용도로 쓸 수 있다. 버퍼드 채널(buffered channel)은 버퍼 크기만큼 데이터를 미리 넣어둘 수 있다. 버퍼가 꽉 찰 때까지는 수신자가 없어도 송신자가 블록되지 않는다. 버퍼가 꽉 찬 상태에서 또 보내려 하면 그때 블록된다.


언버퍼드·버퍼드 채널 비교
package main import "fmt" func sum(s []int, c chan int) { total := 0 for _, v := range s { total += v } c <- total // 결과를 채널로 전송 } func main() { s := []int{7, 2, 8, -9, 4, 0} // 언버퍼드 채널: 수신자가 받을 때까지 송신자 블록 c := make(chan int) go sum(s[:len(s)/2], c) // [-9, 4, 0] 합계 → -5 go sum(s[len(s)/2:], c) // [7, 2, 8] 합계 → 17 x, y := <-c, <-c // 두 고루틴 결과 수신 fmt.Println(x, y, x+y) // 순서는 비결정적 // 버퍼드 채널: 버퍼 크기 안에서 수신자 없이도 전송 가능 buf := make(chan int, 3) buf <- 1 buf <- 2 buf <- 3 // buf <- 4 // 버퍼 꽉 참 → 데드락 fmt.Println(<-buf) // 1 fmt.Println(<-buf) // 2 }
Go 언어 고루틴 채널 동시성 다이어그램
ⓒ Go Blog / go.dev

select 문 — 여러 채널을 동시에 처리하기

`select` 문은 여러 채널 작업 가운데 준비된 것을 골라 실행하는 구문이다. 스위치(switch) 문과 비슷하게 생겼지만 각 케이스(case)가 채널 송신 또는 수신 연산이라는 점이 다르다. 여러 케이스 중 동시에 준비된 것이 있으면 하나를 무작위로 선택해 실행한다.


`select`에서 중요한 두 가지 패턴이 있다. 첫째는 타임아웃 패턴이다. `time.After`로 만든 채널과 결과 채널을 `select`로 동시에 기다리면 타임아웃을 구현할 수 있다. 둘째는 기본값(default) 패턴이다. `default` 케이스를 넣으면 모든 채널이 블록 상태일 때 기다리지 않고 `default`를 실행한다. 논블로킹 채널 조회에 유용하다.


select로 타임아웃 구현
package main import ( "fmt" "time" ) func slowOperation() <-chan string { result := make(chan string) go func() { time.Sleep(2 * time.Second) // 느린 작업 시뮬레이션 result <- "작업 완료" }() return result } func main() { resultCh := slowOperation() select { case msg := <-resultCh: fmt.Println("결과:", msg) case <-time.After(1 * time.Second): // 1초 후 타임아웃 채널 fmt.Println("타임아웃: 작업이 너무 오래 걸립니다") } // default 케이스 — 논블로킹 조회 ch := make(chan int, 1) ch <- 42 select { case v := <-ch: fmt.Println("값 수신:", v) default: fmt.Println("채널이 비어 있음") } }

컨텍스트 — 취소와 타임아웃의 공식 방법

`context` 패키지는 고 언어에서 취소(cancellation), 타임아웃(timeout), 데드라인(deadline), 요청 범위 값 전달을 다루는 공식 방법이다. HTTP 서버나 데이터베이스 쿼리처럼 여러 단계로 이루어진 작업에서, 상위 작업이 취소될 때 하위 작업들도 함께 정리할 수 있게 한다.


컨텍스트 사용의 핵심 원칙 세 가지가 있다. 첫째, 컨텍스트는 함수의 첫 번째 매개변수로 전달한다. 관례적으로 변수명은 `ctx`를 쓴다. 둘째, 절대로 구조체 필드로 저장하지 않는다. 요청의 생명주기에 묶여 있어야 하기 때문이다. 셋째, `context.Background()`는 최상위 루트 컨텍스트이고, `context.TODO()`는 아직 어떤 컨텍스트를 써야 할지 모를 때 임시로 쓴다. 실제 코드에서 `TODO()`가 남아 있으면 나중에 반드시 교체해야 한다는 신호다.


가장 많이 쓰는 세 가지 컨텍스트 생성 함수는 다음과 같다. `context.WithCancel`은 수동으로 취소할 수 있는 컨텍스트를 만든다. `context.WithTimeout`은 지정한 시간이 지나면 자동 취소된다. `context.WithDeadline`은 특정 시각이 되면 자동 취소된다.


컨텍스트로 취소·타임아웃 제어
package main import ( "context" "fmt" "time" ) func fetchData(ctx context.Context, id int) (string, error) { // 실제로는 DB 쿼리나 HTTP 요청이 들어가는 자리 select { case <-time.After(500 * time.Millisecond): // 작업 완료 시뮬레이션 return fmt.Sprintf("데이터 %d", id), nil case <-ctx.Done(): // 컨텍스트 취소/타임아웃 감지 return "", ctx.Err() // context.DeadlineExceeded 또는 context.Canceled } } func main() { // 타임아웃 컨텍스트 — 1초 안에 완료하지 않으면 취소 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() // 반드시 cancel 호출해 리소스 해제 result, err := fetchData(ctx, 42) if err != nil { fmt.Println("오류:", err) // 타임아웃이면 context.DeadlineExceeded return } fmt.Println("결과:", result) // 수동 취소 컨텍스트 — 조건 충족 시 직접 cancel() ctx2, cancel2 := context.WithCancel(context.Background()) go func() { time.Sleep(200 * time.Millisecond) cancel2() // 200ms 후 수동 취소 }() _, err2 := fetchData(ctx2, 99) fmt.Println("취소 결과:", err2) // context.Canceled }
Go 언어 context 취소 타임아웃 패턴
ⓒ go.dev

sync 패키지 — Mutex, RWMutex, WaitGroup, Once

채널로 모든 동시성 문제를 해결할 수 있지만, 때로는 전통적인 방식의 뮤텍스(Mutex)가 더 간결하다. `sync` 패키지가 이를 제공한다.


sync.WaitGroup은 앞서 고루틴 기본 예시에서 봤던 것처럼 여러 고루틴이 모두 끝날 때까지 기다릴 때 쓴다. `Add(n)`으로 카운터를 늘리고, 각 고루틴이 끝날 때 `Done()`을 호출하며, 메인 고루틴은 `Wait()`으로 모두 완료될 때까지 블록한다.


sync.Mutex는 공유 자원에 대한 단독 접근을 보장한다. `Lock()`으로 잠그고 작업 후 `Unlock()`으로 해제한다. `defer mu.Unlock()` 패턴을 쓰면 함수가 어떻게 반환되든 반드시 잠금이 해제된다.


sync.RWMutex는 읽기 작업이 쓰기보다 훨씬 많을 때 성능상 유리하다. 여러 고루틴이 동시에 읽기 잠금(`RLock`)을 잡을 수 있지만, 쓰기 잠금(`Lock`)은 한 번에 하나만 허용된다. 읽기 빈도가 높은 캐시나 설정 데이터 관리에 적합하다.


sync.Once는 여러 고루틴이 동시에 실행되더라도 특정 코드를 딱 한 번만 실행되게 보장한다. 싱글턴 초기화, 플래그 등록, 지연 초기화 패턴에 자주 쓰인다.


sync.Mutex, RWMutex, Once 실전 예시
package main import ( "fmt" "sync" ) // sync.Mutex — 안전한 카운터 type SafeCounter struct { mu sync.Mutex v map[string]int } func (c *SafeCounter) Inc(key string) { c.mu.Lock() defer c.mu.Unlock() c.v[key]++ } // sync.RWMutex — 읽기 많은 캐시 type Cache struct { mu sync.RWMutex data map[string]string } func (c *Cache) Get(key string) (string, bool) { c.mu.RLock() // 여러 고루틴이 동시에 읽기 가능 defer c.mu.RUnlock() v, ok := c.data[key] return v, ok } func (c *Cache) Set(key, value string) { c.mu.Lock() // 쓰기는 단독 잠금 defer c.mu.Unlock() c.data[key] = value } // sync.Once — 한 번만 실행 var ( instance *Cache once sync.Once ) func GetInstance() *Cache { once.Do(func() { instance = &Cache{data: make(map[string]string)} fmt.Println("캐시 초기화 (딱 한 번)") }) return instance } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() c := GetInstance() // once.Do 덕분에 초기화는 한 번만 c.Set("key", "value") }() } wg.Wait() }

자주 실수하는 동시성 함정 3가지

고 언어 동시성 코드를 짤 때 빠지기 쉬운 함정 세 가지를 정리한다. 이 실수들은 컴파일 단계에서 잡히지 않고 런타임에서 데드락이나 예상치 못한 동작으로 나타나기 때문에 특히 주의해야 한다.


함정 1 — 클로저 변수 캡처 문제


루프에서 고루틴을 만들 때 루프 변수를 클로저로 캡처하면 모든 고루틴이 루프가 끝난 시점의 변수 값을 참조하는 문제가 생긴다. 고 언어 1.22 이전에서 `for i := range n` 구문에서 특히 자주 발생했다. 해결책은 루프 변수를 고루틴 함수의 인자로 직접 넘기는 것이다.


함정 2 — 닫힌 채널에 보내기


이미 `close()`된 채널에 값을 보내면 패닉(panic)이 발생한다. 반대로 닫힌 채널에서 받기는 안전하다. 채널이 닫혀 있으면 버퍼에 남은 값들을 모두 받은 후 해당 타입의 제로 값을 반환한다. 채널을 닫을 책임은 일반적으로 송신자에게 있다.


함정 3 — 고루틴 누수


고루틴이 시작됐는데 종료되지 않고 계속 대기 상태로 쌓이면 고루틴 누수가 발생한다. 블록 상태의 채널 수신, 취소되지 않는 컨텍스트 기다리기 등이 원인이다. `pprof`의 goroutine 프로파일로 누수를 감지할 수 있다. 컨텍스트를 항상 함께 전달하고 `defer cancel()`로 반드시 정리하는 습관이 누수를 막는 가장 효과적인 방법이다.


⚠️ 레이스 컨디션 감지
고 언어는 내장 레이스 디텍터가 있습니다. go run -race main.go 또는 go test -race ./...로 실행하면 레이스 컨디션이 발생할 때 경고를 출력합니다. 프로덕션 배포 전 반드시 레이스 감지 모드로 테스트하세요.

실전 패턴 — 워커 풀 구현

워커 풀(worker pool)은 고 언어 동시성의 가장 대표적인 실전 패턴이다. 제한된 수의 고루틴(워커)이 공통 작업 큐에서 일을 꺼내 처리하는 구조다. 고루틴을 무제한 생성하면 메모리와 스케줄링 오버헤드가 커지므로, 워커 풀로 동시 실행 수를 제어한다.


기본 구조는 세 가지 채널로 이루어진다. 작업을 담는 `jobs` 채널, 결과를 담는 `results` 채널, 그리고 종료 신호를 전달하는 컨텍스트다. 메인 고루틴이 `jobs`에 작업을 넣으면 워커 고루틴들이 경쟁적으로 작업을 꺼내 처리하고 `results`에 결과를 넣는다.


워커 풀 패턴 구현
package main import ( "context" "fmt" "time" ) type Job struct { ID int } type Result struct { JobID int Output string } // 워커: 작업 채널에서 일을 꺼내 결과 채널에 넣음 func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result) { for { select { case job, ok := <-jobs: if !ok { return // 작업 채널이 닫히면 워커 종료 } // 실제 작업 처리 time.Sleep(50 * time.Millisecond) results <- Result{ JobID: job.ID, Output: fmt.Sprintf("워커%d가 작업%d 처리 완료", id, job.ID), } case <-ctx.Done(): return // 컨텍스트 취소 시 워커 종료 } } } func main() { const numWorkers = 3 const numJobs = 10 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() jobs := make(chan Job, numJobs) results := make(chan Result, numJobs) // 워커 풀 시작 for w := 1; w <= numWorkers; w++ { go worker(ctx, w, jobs, results) } // 작업 투입 for j := 1; j <= numJobs; j++ { jobs <- Job{ID: j} } close(jobs) // 작업 채널 닫기 → 워커에게 종료 신호 // 결과 수집 for r := 0; r < numJobs; r++ { res := <-results fmt.Println(res.Output) } }
Go 워커 풀 동시성 패턴 백엔드
ⓒ go.dev

참고 자료

이 글의 코드 예시와 개념 설명은 다음 공식 자료를 기반으로 했습니다. 더 깊은 내용은 아래 원문에서 직접 확인하세요.



자주 묻는 질문

고루틴과 운영체제 스레드의 차이가 뭔가요?

운영체제 스레드는 커널이 관리하고 생성 비용이 높습니다(스택 메모리 1~8MB, 컨텍스트 스위칭 오버헤드). 고루틴은 고 런타임이 관리하는 경량 실행 단위로 초기 스택이 약 2KB이며 동적으로 증가합니다. 수백만 개의 고루틴이 소수의 운영체제 스레드 위에서 돌아갑니다(M:N 스케줄링). 실용적으로는 "거의 무료로 만들 수 있는 경량 비동기 실행 단위"라고 이해하면 됩니다.


채널 대신 Mutex를 쓰는 게 나은 경우는 언제인가요?

상태를 여러 고루틴이 공유하고 단순히 읽기·쓰기 보호가 필요한 경우에는 Mutex가 더 간결합니다. 채널은 데이터를 이동(ownership transfer)하거나 고루틴 간에 신호를 보낼 때 더 자연스럽습니다. 공유 카운터, 캐시, 설정값처럼 단순한 공유 상태는 Mutex, 생산자-소비자나 파이프라인 구조는 채널이 어울립니다. 고 언어 공식 문서는 "채널을 기본으로 하되 공유 상태가 더 명확하면 Mutex를 써라"고 안내합니다.


컨텍스트를 왜 구조체 필드에 저장하면 안 되나요?

컨텍스트는 특정 요청이나 작업의 생명주기에 묶여 있습니다. 구조체 필드에 저장하면 그 구조체가 여러 요청에 재사용될 때 다른 요청의 취소 신호가 섞이거나, 이미 취소된 컨텍스트를 새 요청에 사용하는 문제가 생깁니다. 컨텍스트는 항상 함수 호출 시 첫 번째 인자로 전달해 각 호출 체인에 고유하게 유지해야 합니다.


고루틴 누수를 어떻게 감지하나요?

런타임의 runtime.NumGoroutine()으로 현재 고루틴 수를 모니터링할 수 있습니다. 계속 늘어나면 누수 신호입니다. 더 정밀하게는 go tool pprof의 goroutine 프로파일로 어디서 고루틴이 블록되어 있는지 확인합니다. 테스트 코드에서는 goleak 라이브러리를 쓰면 테스트 종료 후 남아 있는 고루틴을 자동으로 감지해 실패 처리합니다.


채널을 닫으면 어떻게 되나요?

닫힌 채널에서 값을 받으면 버퍼에 남은 값들을 순서대로 다 받고, 그 뒤에는 해당 타입의 제로 값이 계속 반환됩니다. 받기 연산의 두 번째 반환값(ok)으로 채널이 닫혔는지 알 수 있습니다(v, ok := <-ch, ok가 false면 닫힘). 닫힌 채널에 값을 보내면 패닉이 발생합니다. 채널 닫기의 책임은 송신자에게 있으며, 수신자가 닫으면 송신자가 패닉을 겪을 수 있습니다.


파이썬·자바의 async/await와 고 언어 고루틴의 가장 큰 차이는 무엇인가요?

파이썬·자바스크립트의 async/await는 단일 스레드 이벤트 루프 기반으로, 개발자가 명시적으로 await를 표시한 지점에서만 컨텍스트 스위칭이 일어납니다. 고 언어의 고루틴은 진짜 병렬 실행이 가능합니다. GOMAXPROCS 수만큼 실제 운영체제 스레드에서 동시에 돌아갑니다. 또한 고루틴은 async 키워드 없이 일반 함수에 go 한 단어만 붙이면 되므로 기존 동기 코드를 비동기로 바꿀 때 코드 변경이 거의 없습니다.


Go 동시성고루틴채널컨텍스트golang goroutinesync.Mutex워커 풀Go 언어

관련 포스트