Go 동시성 프로그래밍 Part 1 - 동시성의 기초
들어가며
현대 서비스는 수천 개의 요청을 동시에 처리해야 한다. 동시성을 어떻게 다루느냐가 서비스의 성능과 안정성을 결정한다.
Java나 Python에서는 OS 스레드를 직접 생성하거나 스레드 풀을 관리하는 것이 일반적이다. 반면 Go는 언어 차원에서 경량 스레드인 고루틴과 채널을 제공하여 복잡한 동시성 문제를 간결하게 다룰 수 있다.
이 글에서는 동시성과 병렬성의 차이부터 시작해서, Go 런타임의 GMP 스케줄링 모델, 그리고 고루틴 간 동기화와 생명주기 관리까지 다룬다.
동시성과 병렬성
동시성은 구조다
카페를 예로 들어보자. 한 명의 직원이 주문, 커피 추출, 서빙을 모두 순서대로 처리한다고 하자.

여기서 동시성을 추가한다는 것은 작업을 독립적인 단위로 분리하는 것이다. 직원이 한 명이더라도 주문을 받아두고, 커피가 추출되는 동안 다음 주문을 받는 식으로 작업을 번갈아 처리할 수 있다. 직원을 한 명 더 두고 한 명은 주문만, 다른 한 명은 커피 추출과 서빙을 담당하게 할 수도 있다. 핵심은 작업을 독립 단위로 나누어 번갈아 실행할 수 있는 구조를 만드는 것이다.
이를 실제 프로그래밍에 대입해보자. 커머스 서비스에서 유저에게 상품을 보여줄 때 상품 정보, 판매자 정보, 리뷰 정보를 함께 보여줘야 한다. 상품 정보 200ms, 판매자 정보 150ms, 리뷰 정보 300ms가 각각 걸린다면, 순차 처리 시 650ms가 소요된다. 하지만 세 조회를 독립적인 작업으로 분리해서 동시에 처리하면 가장 느린 작업 기준인 약 300ms에 완료할 수 있다.
병렬성은 실행이다
다시 카페 예시로 돌아가자. 카페를 통째로 복사해서 직원을 한 명씩 두면 병렬로 처리하는 것이다. 프로그래밍에서는 동일한 작업을 여러 CPU 코어에서 물리적으로 동시에 실행하는 것에 해당한다.
좀 더 구체적으로, 주문은 빠르게 쌓이는데 커피 추출에서 병목이 발생한다고 해보자. 커피 추출 직원을 한 명 더 뽑아서 둘이 나눠서 추출한다면? 이것이 병렬 처리다.
Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once. - Rob Pike
동시성은 여러 일을 동시에 처리(dealing)하는 것이고, 병렬성은 여러 일을 동시에 실행(doing)하는 것이다.
정리하면, 동시성은 구조에 대한 것이고 병렬성은 실행에 대한 것이다. 동시성은 병렬성을 가능하게 한다. 작업이 독립적인 단위로 분리되어 있어야(동시성) 이를 여러 코어에서 동시에 실행(병렬성)할 수 있기 때문이다.
| 구분 | 동시성 | 병렬성 |
|---|---|---|
| 관점 | 구조(설계) | 실행(런타임) |
| 핵심 | 작업을 독립 단위로 분리 | 분리된 작업을 물리적으로 동시에 실행 |
| CPU 코어 | 단일 코어에서도 가능 | 멀티 코어 필요 |
| 카페 비유 | 역할 분리 | 같은 역할 직원 추가 |
Go에서의 동시성
지금까지 동시성과 병렬성의 개념을 살펴봤다. 그렇다면 Go는 이를 어떻게 지원할까?
Go 표준 라이브러리는 OS 스레드를 직접 생성하는 API를 제공하지 않는다. 대신 go 키워드로 고루틴을 생성한다.
func main() {
go sayHello() // 고루틴 생성
time.Sleep(1 * time.Second)
}
func sayHello() {
fmt.Println("Hello from goroutine")
}
고루틴은 유저 스페이스 스레드(그린 스레드)다. OS 스레드는 커널이 CPU 코어에 대해 컨텍스트 스위칭과 스케줄링을 담당한다. 반면 고루틴은 Go 런타임이 유저 스페이스에서 이를 담당한다. Java도 JDK 21에서 Virtual Thread(Project Loom)로 비슷한 접근을 도입했는데, Go는 언어 탄생 시점부터 이를 지원해왔다.
고루틴의 초기 스택 메모리는 약 2KB로, OS 스레드의 2MB에 비해 훨씬 작다. 스택은 필요에 따라 동적으로 늘어난다(growable stack). 컨텍스트 스위칭 비용도 낮은데, 이는 유저 스페이스에서 스위칭이 일어나 커널 모드 전환이 필요 없고, 저장/복원해야 할 레지스터 수가 적기 때문이다.
GMP 모델
Go 런타임의 스케줄러는 고루틴을 스레드에 할당할 때 GMP 모델을 사용한다. GMP 모델은 OS 스레드를 최대한 효율적으로 사용하기 위한 자원 관리 전략이다.
Go 1.0에서는 P가 없이 G와 M만 존재했다. 글로벌 뮤텍스로 고루틴 큐를 관리했기 때문에 스레드 간 경합이 심했다. Go 1.1에서 P를 도입하면서 각 P가 로컬 큐를 갖게 되었고, 글로벌 락 경합이 대폭 줄었다.

- G (Goroutine): 고루틴이다.
go func()형태로 실행하면 생성된다. 하나의 Go 프로그램에는 수백~수천 개 이상의 고루틴이 존재할 수 있다. - M (Machine): OS 스레드다. 고루틴을 실제로 실행하는 역할을 한다. P와 달리 고정 개수가 아니라 필요에 따라 동적으로 생성되며, 기본 최대 개수는 10,000개다(
runtime/debug.SetMaxThreads로 조정 가능). - P (Processor): 논리적 프로세서다. 실제 CPU 코어를 의미하는 것이 아니라, 로컬 런 큐와 메모리 캐시(mcache) 등 고루틴 실행에 필요한 리소스를 보유하는 실행 컨텍스트다. M이 G를 실행하려면 반드시 P를 획득해야 한다.
P의 개수는 보통 Go 프로그램이 시작할 때 CPU 코어 수만큼 생성된다. 이 값은 GOMAXPROCS로 설정할 수 있다. 보통은 기본값을 변경할 필요가 없다.
runtime.GOMAXPROCS(4) // P를 4개로 설정
fmt.Println(runtime.GOMAXPROCS(0)) // 현재 값 확인 (0을 넘기면 변경 없이 현재 값만 반환)
작업 실행 과정
- Go 런타임은 앱 실행 시 M(스레드)을 생성한다.
- G(고루틴)가 생성되면 현재 실행 중인 고루틴이 속한 P의 로컬 큐에 적재된다. 로컬 큐가 가득 차면(256개) 절반을 글로벌 큐로 이동시킨다.
- M은 P에 현재 작업 중인 다른 M이 없는지 확인하고, 없으면 P의 로컬 큐에 있는 G를 수행하며 P를 점유한다.
- M이 블로킹 시스템 콜(파일 I/O 등)을 만나면 P를 반납하고 대기 상태로 전환된다. 그러면 다른 M이 해당 P를 점유하며 나머지 로컬 큐의 작업을 실행한다. 반면 네트워크 I/O의 경우 Go는 netpoller(Linux의 epoll, macOS의 kqueue)를 사용해서 M이 블로킹되지 않고 다른 고루틴을 계속 실행할 수 있다.
- P의 로컬 큐에 실행 가능한 고루틴이 없으면 글로벌 큐를 확인하거나, 다른 P의 로컬 큐에서 절반을 가져온다(Work Stealing). 이를 통해 특정 P에 작업이 몰리는 것을 방지하고 모든 스레드가 고르게 일하도록 부하를 분산한다.
또한 Go 런타임에는 별도의 모니터링 스레드인 sysmon이 있다. sysmon은 10ms 이상 P를 점유하고 있는 고루틴을 선점(preempt)하거나, 오래 블로킹된 시스템 콜을 감지해서 P를 회수하는 역할을 한다.
동시성이 무조건 빠를까?
고루틴을 생성하고 스케줄러가 이를 실행하는 데에도 비용이 든다. 메인 고루틴에서 순차적으로 실행하는 것과 비교해서 무엇이 더 빠른지는 상황에 따라 판단해야 한다.
예를 들어 단순 덧셈 같은 아주 작은 작업을 고루틴으로 분산하면, 고루틴 생성과 스케줄링 비용이 작업 자체보다 클 수 있다. CPU 바운드 작업을 단일 코어에서 고루틴으로 나누면 컨텍스트 스위칭 오버헤드만 추가되어 오히려 느려진다.
동시성이 효과적인 경우와 그렇지 않은 경우를 구분하면 다음과 같다.
- I/O 바운드 작업(네트워크 호출, 파일 읽기): 대기 시간 동안 다른 작업을 수행할 수 있으므로 동시성이 효과적이다.
- CPU 바운드 작업: 멀티코어를 활용하는 병렬 실행이 아니면 이점이 없다. 단일 코어에서는 순차 실행이 더 빠를 수 있다.
또한 동시성과 병렬성 각각에서 필요한 것이 다르다.
- 병렬 실행: 동일한 데이터에 접근할 때 mutex 같은 임계 영역(critical section) 제어가 필요하다.
- 동시성 프로그래밍: 구조적으로 동작을 조율해야 하는 경우가 많다. 이때 채널을 사용해서 고루틴 간 통신으로 조율한다.
Go에는 유명한 철학이 있다.
Do not communicate by sharing memory; instead, share memory by communicating.
메모리를 공유해서 통신하지 말고, 통신으로 메모리를 공유하라.
이 철학이 Go가 채널을 일급 시민으로 제공하는 이유이며, 뮤텍스보다 채널을 선호하는 Go의 설계 방향을 보여준다.
고루틴 동기화
WaitGroup
고루틴을 생성했으면 완료를 기다려야 한다. sync.WaitGroup은 여러 고루틴의 완료를 대기하는 가장 기본적인 도구다.
var wg sync.WaitGroup
wg.Add(2) // 대기할 고루틴 수
go func() {
defer wg.Done() // 완료 시 카운터 감소
fmt.Println("goroutine 1")
}()
go func() {
defer wg.Done()
fmt.Println("goroutine 2")
}()
wg.Wait() // 모든 고루틴이 Done()을 호출할 때까지 대기
레이스 컨디션
다음 코드는 두 고루틴이 동시에 같은 변수에 접근하는 레이스 컨디션 문제를 보여준다.
var wg sync.WaitGroup
var i int
wg.Add(2)
go func() {
defer wg.Done()
i++
}()
go func() {
defer wg.Done()
i++
}()
wg.Wait()
fmt.Println(i) // 1? 2?
i++는 단일 연산처럼 보이지만, 실제로는 "읽기 -> 증가 -> 쓰기" 3단계로 실행된다. 두 고루틴이 동시에 i=0을 읽고, 각각 1로 증가시켜 쓰면, 최종 결과가 2가 아닌 1이 될 수 있다.
Go는 빌트인 레이스 디텍터를 제공한다. go run -race main.go로 실행하면 레이스 컨디션을 런타임에 감지해준다. 테스트 시에도 go test -race ./...로 활용할 수 있다.
이 문제를 해결하는 방법은 여러 가지가 있다.
아토믹 연산
가장 간단한 방법은 아토믹 명령어를 사용하는 것이다. 아토믹 연산은 CPU 수준에서 단일 명령어로 실행되므로 중간에 다른 고루틴이 끼어들 수 없다.
var wg sync.WaitGroup
var i int64
wg.Add(2)
go func() {
defer wg.Done()
atomic.AddInt64(&i, 1)
}()
go func() {
defer wg.Done()
atomic.AddInt64(&i, 1)
}()
wg.Wait()
아토믹은 단순한 카운터나 플래그 같은 단일 변수 연산에 적합하다. 여러 변수를 동시에 원자적으로 업데이트하거나, 복잡한 로직이 필요한 경우에는 아토믹만으로 부족하다.
채널
채널을 사용해서 고루틴 간 통신으로 동기화할 수 있다.
var i int
ch := make(chan int)
go func() {
ch <- 1
}()
go func() {
ch <- 1
}()
i += <-ch
i += <-ch
핵심은 고루틴들이 i에 직접 접근하지 않는다는 점이다. 채널을 통해 값을 메인 고루틴에 전달하고, 메인 고루틴만 i를 수정한다. 공유 메모리에 대한 직접 접근을 채널 통신으로 대체한 것이다.
이 예제에서 사용한 make(chan int)는 unbuffered 채널로, 송신자와 수신자가 동시에 준비되어야 통신이 이루어진다. 채널의 심화 내용(buffered 채널, 방향 지정 등)은 Part 2에서 다룬다.
뮤텍스
뮤텍스로 임계 영역에 접근하는 고루틴을 최대 하나로 보장할 수 있다.
var wg sync.WaitGroup
var i int
var mutex sync.Mutex
wg.Add(2)
go func() {
defer wg.Done()
mutex.Lock()
defer mutex.Unlock()
i++
}()
go func() {
defer wg.Done()
mutex.Lock()
defer mutex.Unlock()
i++
}()
wg.Wait()
defer mutex.Unlock()을 사용하면 함수가 어떤 경로로 종료되든 반드시 락이 해제된다. 이는 Go에서 뮤텍스를 다룰 때의 관용적 패턴이다.
뮤텍스를 사용할 때는 데드락에 주의해야 한다. 여러 뮤텍스를 중첩 사용할 때 획득 순서가 다르면 서로 상대방의 락을 기다리며 영원히 멈출 수 있다. 또한 읽기가 많고 쓰기가 드문 경우에는 sync.RWMutex를 사용하면 읽기 간에는 락 경합 없이 동시 접근이 가능하다.
언제 무엇을 사용할까
| 도구 | 적합한 경우 |
|---|---|
| 아토믹 | 단일 변수의 단순 연산 (카운터, 플래그) |
| 채널 | 고루틴 간 데이터 전달, 작업 조율, 파이프라인 |
| 뮤텍스 | 공유 데이터 구조(map, slice 등)의 보호, 복잡한 임계 영역 |
고루틴 생명주기 관리
고루틴은 가볍지만 무한정 생성하면 메모리가 고갈된다. 완료되지 않는 고루틴이 계속 쌓이는 것을 고루틴 누수(goroutine leak)라고 하며, 서비스 장애의 흔한 원인 중 하나다. 이를 방지하기 위해 고루틴의 생명주기를 명확히 관리해야 한다.
Go에서는 context 패키지로 고루틴의 생명주기를 관리한다. context는 단순히 값을 전달하는 역할만 하는 것이 아니다. 주요 함수를 정리하면 다음과 같다.
context.Background(): 루트 컨텍스트. 보통 main이나 최상위에서 사용한다.context.WithCancel(): 수동으로 취소할 수 있는 컨텍스트를 생성한다.context.WithTimeout(): 지정 시간 후 자동으로 취소된다.context.WithDeadline(): 특정 시각에 자동으로 취소된다.context.WithValue(): 값 전달용이다.
타임아웃
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
return callHttp(ctx, params)
1초가 넘는 시간이 걸리면 어떻게 될까?
callHttp에서ctx.Done()을 감시하다가ctx.Err()가context.DeadlineExceeded에러를 반환한다.
1초 미만에 완료되면 어떨까?
cancel()함수로 타이머 자원을 회수한다. timeout이 만료되기 전에 작업이 끝나더라도cancel()을 호출하지 않으면 타이머 고루틴이 timeout 시간까지 계속 살아있게 되어 리소스 누수가 발생한다. 그래서defer cancel()은WithTimeout/WithCancel직후에 반드시 호출하는 것이 관용적 패턴이다.
수동 취소
타임아웃이 아니라 특정 조건에서 수동으로 고루틴을 취소하는 패턴도 흔하다.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for {
select {
case <-ctx.Done():
fmt.Println("cancelled:", ctx.Err())
return
default:
// 작업 수행
}
}
}()
// 어떤 조건이 만족되면 취소
cancel()
여기서 사용한 select는 여러 채널 연산 중 준비된 것을 실행하는 제어문이다. switch와 유사하지만 채널 전용이며, Go 동시성 프로그래밍에서 핵심적인 문법이다.
ctx.Done() 확인의 중요성
Go에서는 자식 함수에서 context가 종료되었는지를 ctx.Done()으로 매번 확인해야 한다. 외부 호출 라이브러리들은 보통 내부적으로 이를 확인하지만, 직접 구현한 내부 함수에서는 명시적으로 확인하지 않으면 타임아웃으로 취소되었는지 알 수 없다.
func doWork(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
// 실제 작업 수행
}
}
}
Go의 이런 명시적 취소 확인 방식은 다른 언어와 비교하면 번거로울 수 있다. 예를 들어 Kotlin의 코루틴에서는 suspend 키워드를 함수에 붙이면 각 중단 함수 호출 시마다 내부적으로 취소 여부를 자동으로 체크한다. 반면 Go의 명시적 방식은 취소 시점을 개발자가 직접 제어할 수 있어 더 세밀한 제어가 가능하다는 장점이 있다.
마무리
이번 글에서는 동시성과 병렬성의 개념적 차이, Go 런타임의 GMP 스케줄링 모델, 그리고 고루틴 간 동기화 방법과 생명주기 관리를 살펴보았다.
핵심을 정리하면 다음과 같다.
- 동시성은 구조이고, 병렬성은 실행이다.
- Go 런타임은 GMP 모델로 고루틴을 효율적으로 스케줄링한다.
- 공유 자원 접근 시 아토믹 연산, 채널, 뮤텍스를 상황에 맞게 선택한다.
- context로 고루틴의 생명주기를 관리하며,
ctx.Done()확인을 빠뜨리지 않아야 한다.
'go' 카테고리의 다른 글
| go 1.25 변경사항 정리 (0) | 2025.10.06 |
|---|