한 줄 핵심: 고 언어(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 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.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.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 언어