
"Do not communicate by sharing memory; share memory by communicating."
— Go Concurrency Design Principle (https://go.dev/blog/codelab-share)
Go 언어는 단순히 "멀티스레드를 쉽게 쓰는 언어"가 아닙니다. 그보다 더 근본적인 목표는 안전하고 명시적인 동시성(Concurrency) 제어입니다. 이를 위해 Go는 goroutine과 channel이라는 두 축을 제공하며, 이는 CSP(Communicating Sequential Processes) 이론을 기반으로 한 혁신적인 동시성 모델입니다.
목차
- Go의 동시성 철학과 CSP 모델
- Goroutine: 경량 논리 스레드
- 익명함수와 Goroutine 활용
- 다중 CPU 병렬 처리와 GOMAXPROCS
- Channel: 안전한 통신 파이프
- Channel의 송수신 메커니즘
- Buffered vs Unbuffered Channels
- Select 문을 활용한 다중 채널 처리
- 실무 예제: Worker Pool 패턴
- 결론: CSP가 만든 단순하고 강력한 동시성
Go의 동시성 철학과 CSP 모델
Go의 공식 문서에는 다음과 같은 핵심 원칙이 자주 등장합니다:
"Do not communicate by sharing memory; share memory by communicating."
즉, 공유 메모리를 통해 데이터를 주고받지 말고, 통신을 통해 메모리를 공유하라는 것입니다. 이는 Go의 동시성 모델이 CSP(Communicating Sequential Processes) 이론을 기반으로 설계되었음을 의미합니다.
CSP 모델의 핵심 개념
CSP는 Tony Hoare가 1978년에 제안한 동시성 모델로, 다음과 같은 특징을 가집니다:
- 프로세스들이 독립적으로 실행
- 명시적 통신(channel)을 통해서만 데이터 교환
- 공유 메모리 없이 안전한 병행 처리
Go는 이 모델을 현실적인 프로그래밍 언어 수준으로 단순화하여 구현했습니다.
전통적인 동시성 vs Go의 동시성
전통적인 접근법 (공유 메모리 + 락)
// C/C++ - 뮤텍스를 이용한 공유 메모리 보호
pthread_mutex_t mutex;
int shared_data = 0;
void* worker(void* arg) {
pthread_mutex_lock(&mutex);
shared_data++; // 공유 데이터 수정
pthread_mutex_unlock(&mutex);
return NULL;
}
Go의 접근법 (채널을 통한 통신)
// Go - 채널을 통한 안전한 데이터 전달
func worker(data chan int, result chan int) {
value := <-data // 채널에서 데이터 수신
result <- value + 1 // 처리 후 결과 전송
}
Goroutine: 경량 논리 스레드
Goroutine의 정의
Goroutine은 Go 런타임이 관리하는 경량 논리 스레드(lightweight thread)입니다. go 키워드를 사용하여 함수를 호출하면, Go 런타임은 그 함수를 비동기적으로 실행합니다.
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 10; i++ {
fmt.Println(s, "***", i)
time.Sleep(time.Millisecond * 100)
}
}
func main() {
// 함수를 동기적으로 실행
say("Sync")
// 함수를 비동기적으로 실행
go say("Async1")
go say("Async2")
go say("Async3")
// 3초 대기 (goroutine이 완료될 시간 확보)
time.Sleep(time.Second * 3)
}
Goroutine vs OS Thread 비교
| 구분 | Goroutine | OS Thread |
|---|---|---|
| 관리 주체 | Go 런타임 | 운영체제 |
| 스택 크기 | 2KB (동적 확장) | 8MB (고정) |
| 생성 비용 | 매우 저렴 (~1μs) | 상대적으로 비쌈 (~17μs) |
| 매핑 방식 | M:N (멀티플렉싱) | 1:1 |
| 최대 수량 | 수백만 개 가능 | 수천 개 한계 |
| 컨텍스트 스위칭 | 사용자 모드 | 커널 모드 |
Goroutine의 장점
1. 메모리 효율성
// 100만 개의 goroutine을 생성해도 메모리 사용량이 적음
func main() {
for i := 0; i < 1000000; i++ {
go func(id int) {
time.Sleep(time.Hour) // 1시간 대기
}(i)
}
fmt.Println("모든 goroutine 생성 완료")
time.Sleep(time.Hour)
}
2. 빠른 생성 속도
func benchmarkGoroutineCreation() {
start := time.Now()
for i := 0; i < 100000; i++ {
go func() {
// 빈 goroutine
}()
}
fmt.Printf("100,000 goroutine 생성 시간: %v\n", time.Since(start))
}
익명함수와 Goroutine 활용
go 키워드 뒤에는 익명 함수(anonymous function)도 올 수 있습니다. 이 방식은 간단한 비동기 로직을 구현할 때 매우 유용합니다.
기본 사용법
package main
import (
"fmt"
"sync"
)
func main() {
// WaitGroup 생성: 2개의 goroutine을 기다림
var wg sync.WaitGroup
wg.Add(2)
// 익명함수를 사용한 goroutine
go func() {
defer wg.Done()
fmt.Println("Hello from anonymous goroutine")
}()
// 익명함수에 파라미터 전달
go func(msg string) {
defer wg.Done()
fmt.Println(msg)
}("Hi from parameterized goroutine")
wg.Wait() // 모든 goroutine 종료 대기
}
클로저를 활용한 Goroutine
func closureExample() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
// 잘못된 방법: 클로저가 변수 i를 참조
go func() {
defer wg.Done()
fmt.Printf("Wrong: %d\n", i) // 예상과 다른 결과
}()
// 올바른 방법: 파라미터로 값 전달
go func(id int) {
defer wg.Done()
fmt.Printf("Correct: %d\n", id)
}(i)
}
wg.Wait()
}
sync.WaitGroup 활용 패턴
| 메서드 | 용도 | 설명 |
|---|---|---|
Add(n) |
대기할 goroutine 수 지정 | 양수면 추가, 음수면 감소 |
Done() |
작업 완료 신호 | Add(-1)과 동일 |
Wait() |
모든 작업 완료까지 대기 | 카운터가 0이 될 때까지 블록 |
다중 CPU 병렬 처리와 GOMAXPROCS
Go는 기본적으로 사용 가능한 모든 CPU 코어를 활용하지만, GOMAXPROCS를 통해 사용할 CPU 개수를 제어할 수 있습니다.
Concurrency vs Parallelism
| 구분 | Concurrency | Parallelism |
|---|---|---|
| 의미 | 여러 작업을 동시에 다루는 것 | 여러 작업을 동시에 실행하는 것 |
| CPU 수 | 1개여도 가능 | 2개 이상 필요 |
| Go에서 | goroutine | GOMAXPROCS + goroutine |
| 처리 방식 | 시분할(time-slicing) | 실제 동시 실행 |
GOMAXPROCS 설정
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
// 현재 설정된 GOMAXPROCS 확인
fmt.Printf("Current GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0))
fmt.Printf("Available CPUs: %d\n", runtime.NumCPU())
// CPU 집약적 작업을 위한 goroutine
var wg sync.WaitGroup
numTasks := 8
wg.Add(numTasks)
start := time.Now()
for i := 0; i < numTasks; i++ {
go func(id int) {
defer wg.Done()
// CPU 집약적 작업 시뮬레이션
count := 0
for j := 0; j < 100000000; j++ {
count++
}
fmt.Printf("Task %d completed: %d\n", id, count)
}(i)
}
wg.Wait()
fmt.Printf("Total time: %v\n", time.Since(start))
}
CPU 성능 측정
func benchmarkParallelism() {
runtime.GOMAXPROCS(1) // 단일 CPU 사용
start := time.Now()
runCPUIntensiveTasks()
singleCPUTime := time.Since(start)
runtime.GOMAXPROCS(runtime.NumCPU()) // 모든 CPU 사용
start = time.Now()
runCPUIntensiveTasks()
multiCPUTime := time.Since(start)
fmt.Printf("Single CPU: %v\n", singleCPUTime)
fmt.Printf("Multi CPU: %v\n", multiCPUTime)
fmt.Printf("Speedup: %.2fx\n",
float64(singleCPUTime)/float64(multiCPUTime))
}
Channel: 안전한 통신 파이프
Goroutine들이 서로 데이터를 교환하려면 Channel(채널)을 사용합니다. Channel은 두 goroutine 간의 타입 안전한 통신 파이프입니다.
Channel의 기본 개념
// 채널 생성
ch := make(chan string) // string 타입 채널
numbers := make(chan int) // int 타입 채널
// 양방향 채널
bidirectional := make(chan string)
// 송신 전용 채널
var sendOnly chan<- string = bidirectional
// 수신 전용 채널
var recvOnly <-chan string = bidirectional
Channel의 특징
1. 타입 안전성
func typeExample() {
stringCh := make(chan string)
intCh := make(chan int)
go func() {
stringCh <- "Hello" // OK
intCh <- 42 // OK
// stringCh <- 42 // 컴파일 에러!
}()
msg := <-stringCh
num := <-intCh
fmt.Println(msg, num)
}
2. 동기화 기능
func synchronizationExample() {
done := make(chan bool)
go func() {
fmt.Println("작업 수행 중...")
time.Sleep(2 * time.Second)
fmt.Println("작업 완료!")
done <- true // 완료 신호
}()
fmt.Println("작업 시작...")
<-done // 작업 완료까지 대기
fmt.Println("프로그램 종료")
}
Channel의 송수신 메커니즘
기본 송수신
package main
import "fmt"
func main() {
messages := make(chan string)
// 송신 goroutine
go func() {
messages <- "Hello from Goroutine"
}()
// 수신
msg := <-messages
fmt.Println(msg)
}
Channel Direction (방향성)
// 송신 전용 채널을 받는 함수
func send(ch chan<- string, message string) {
ch <- message
}
// 수신 전용 채널을 받는 함수
func receive(ch <-chan string) string {
return <-ch
}
func directionExample() {
ch := make(chan string, 1)
send(ch, "Hello")
message := receive(ch)
fmt.Println(message)
}
Channel 닫기
func closeExample() {
ch := make(chan int, 3)
// 데이터 전송
go func() {
defer close(ch) // 채널 닫기
for i := 1; i <= 3; i++ {
ch <- i
}
}()
// range를 사용한 수신
for num := range ch {
fmt.Printf("Received: %d\n", num)
}
// 채널이 닫혔는지 확인
value, ok := <-ch
if !ok {
fmt.Println("Channel is closed")
} else {
fmt.Printf("Received: %d\n", value)
}
}
Buffered vs Unbuffered Channels
Unbuffered Channel (기본)
func unbufferedExample() {
ch := make(chan string) // 버퍼 크기 0
go func() {
fmt.Println("Sending...")
ch <- "Hello"
fmt.Println("Sent!")
}()
time.Sleep(1 * time.Second) // 수신자 지연
fmt.Println("Receiving...")
msg := <-ch
fmt.Println("Received:", msg)
}
Buffered Channel
func bufferedExample() {
ch := make(chan string, 2) // 버퍼 크기 2
// 버퍼가 가득 찰 때까지 블록되지 않음
ch <- "First"
ch <- "Second"
fmt.Println("Both messages sent")
// 수신
fmt.Println(<-ch)
fmt.Println(<-ch)
}
버퍼 크기별 특성
| 채널 타입 | 특징 | 송신 블록 조건 | 수신 블록 조건 |
|---|---|---|---|
| Unbuffered | 동기적 통신 | 수신자 없으면 즉시 | 송신자 없으면 즉시 |
| Buffered | 비동기적 통신 | 버퍼 가득 참 | 버퍼 비어 있음 |
버퍼 상태 확인
func bufferStatusExample() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
fmt.Printf("Buffer length: %d\n", len(ch)) // 2
fmt.Printf("Buffer capacity: %d\n", cap(ch)) // 3
fmt.Printf("Available space: %d\n", cap(ch) - len(ch)) // 1
}
Select 문을 활용한 다중 채널 처리
select 구문은 여러 채널의 송수신 중 준비된 하나를 선택하여 실행합니다.
기본 Select 사용법
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "from channel 1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "from channel 2"
}()
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println("Received:", msg1)
case msg2 := <-ch2:
fmt.Println("Received:", msg2)
}
}
}
Default Case를 활용한 Non-blocking 처리
func nonBlockingExample() {
ch := make(chan string, 1)
// Non-blocking send
select {
case ch <- "Hello":
fmt.Println("Sent successfully")
default:
fmt.Println("Channel is full")
}
// Non-blocking receive
select {
case msg := <-ch:
fmt.Println("Received:", msg)
default:
fmt.Println("No message available")
}
}
Timeout 패턴
func timeoutExample() {
ch := make(chan string)
go func() {
time.Sleep(3 * time.Second)
ch <- "Delayed message"
}()
select {
case msg := <-ch:
fmt.Println("Received:", msg)
case <-time.After(2 * time.Second):
fmt.Println("Timeout!")
}
}
Fan-in 패턴 (여러 채널을 하나로 합치기)
func fanIn(ch1, ch2 <-chan string) <-chan string {
out := make(chan string)
go func() {
for {
select {
case msg := <-ch1:
out <- msg
case msg := <-ch2:
out <- msg
}
}
}()
return out
}
func fanInExample() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
for i := 0; i < 5; i++ {
ch1 <- fmt.Sprintf("Channel 1: %d", i)
time.Sleep(time.Millisecond * 500)
}
}()
go func() {
for i := 0; i < 5; i++ {
ch2 <- fmt.Sprintf("Channel 2: %d", i)
time.Sleep(time.Millisecond * 700)
}
}()
merged := fanIn(ch1, ch2)
for i := 0; i < 10; i++ {
fmt.Println(<-merged)
}
}
실무 예제: Worker Pool 패턴
기본 Worker Pool
package main
import (
"fmt"
"sync"
"time"
)
// Job represents work to be done
type Job struct {
ID int
Data string
}
// Result represents the result of a job
type Result struct {
Job Job
Output string
Error error
}
// Worker function that processes jobs
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
fmt.Printf("Worker %d started job %d\n", id, job.ID)
// Simulate work
time.Sleep(time.Millisecond * 100)
result := Result{
Job: job,
Output: fmt.Sprintf("Processed %s by worker %d", job.Data, id),
}
results <- result
fmt.Printf("Worker %d finished job %d\n", id, job.ID)
}
}
func workerPoolExample() {
const numWorkers = 3
const numJobs = 10
jobs := make(chan Job, numJobs)
results := make(chan Result, numJobs)
var wg sync.WaitGroup
// Start workers
for i := 1; i <= numWorkers; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
// Send jobs
for i := 1; i <= numJobs; i++ {
jobs <- Job{
ID: i,
Data: fmt.Sprintf("task-%d", i),
}
}
close(jobs)
// Close results channel when all workers are done
go func() {
wg.Wait()
close(results)
}()
// Collect results
for result := range results {
if result.Error != nil {
fmt.Printf("Job %d failed: %v\n", result.Job.ID, result.Error)
} else {
fmt.Printf("Job %d result: %s\n", result.Job.ID, result.Output)
}
}
}
func main() {
workerPoolExample()
}
동적 Worker Pool
type WorkerPool struct {
jobs chan Job
results chan Result
workers []chan struct{}
workerCount int
wg sync.WaitGroup
}
func NewWorkerPool(workerCount int) *WorkerPool {
return &WorkerPool{
jobs: make(chan Job, 100),
results: make(chan Result, 100),
workers: make([]chan struct{}, workerCount),
workerCount: workerCount,
}
}
func (wp *WorkerPool) Start() {
for i := 0; i < wp.workerCount; i++ {
wp.workers[i] = make(chan struct{})
wp.wg.Add(1)
go func(workerID int, quit chan struct{}) {
defer wp.wg.Done()
for {
select {
case job := <-wp.jobs:
// Process job
time.Sleep(time.Millisecond * 100)
wp.results <- Result{
Job: job,
Output: fmt.Sprintf("Processed by worker %d", workerID),
}
case <-quit:
fmt.Printf("Worker %d stopping\n", workerID)
return
}
}
}(i, wp.workers[i])
}
}
func (wp *WorkerPool) Submit(job Job) {
wp.jobs <- job
}
func (wp *WorkerPool) Stop() {
close(wp.jobs)
for i := range wp.workers {
close(wp.workers[i])
}
wp.wg.Wait()
close(wp.results)
}
func (wp *WorkerPool) Results() <-chan Result {
return wp.results
}
Pipeline 패턴
func pipeline() {
// Stage 1: Generate numbers
numbers := make(chan int)
go func() {
defer close(numbers)
for i := 1; i <= 10; i++ {
numbers <- i
}
}()
// Stage 2: Square numbers
squares := make(chan int)
go func() {
defer close(squares)
for num := range numbers {
squares <- num * num
}
}()
// Stage 3: Filter even numbers
evens := make(chan int)
go func() {
defer close(evens)
for square := range squares {
if square%2 == 0 {
evens <- square
}
}
}()
// Final stage: Print results
for even := range evens {
fmt.Printf("Even square: %d\n", even)
}
}
결론: CSP가 만든 단순하고 강력한 동시성
Go의 동시성 모델은 "스레드를 관리하지 않아도 된다"는 점에서 혁신적입니다. goroutine은 가볍고, channel은 안전하며, CSP는 이 둘을 명확하게 연결해 줍니다.
Go의 동시성 설계가 주는 이점
1. 안전성 (Safety)
// 공유하지 말고, 소통하라
func safeExample() {
data := make(chan int, 1)
// 여러 goroutine이 안전하게 데이터 교환
go func() { data <- 42 }()
go func() { result := <-data; fmt.Println(result) }()
}
2. 명확성 (Clarity)
- 복잡한 락(lock) 없이 안전한 데이터 교환 가능
- 명시적 통신 구조로 디버깅 용이
- 데이터 흐름이 코드에 명확히 표현됨
3. 확장성 (Scalability)
- 가볍운 goroutine으로 높은 동시성 달성
- M:N 스케줄링으로 효율적인 자원 활용
- 병렬 처리 확장성 내장
4. 조합 가능성 (Composability)
// 채널은 일급 객체로 조합 가능
func compose() {
ch1 := producer()
ch2 := transformer(ch1)
ch3 := consumer(ch2)
// 파이프라인 구성
handleResults(ch3)
}
모범 사례 요약
Goroutine 사용 시:
sync.WaitGroup으로 생명주기 관리- 컨텍스트로 취소 및 타임아웃 처리
- 패닉이 발생하지 않도록 에러 처리 철저히
Channel 사용 시:
- 채널 소유자가 채널을 닫는 책임
- 송신 전용/수신 전용 타입으로 인터페이스 명확화
- 적절한 버퍼 크기로 성능 최적화
Select 사용 시:
- Default case로 non-blocking 처리
- 타임아웃으로 데드락 방지
- 컨텍스트와 조합하여 우아한 종료
Go의 동시성은 단순히 기술적 기능이 아니라, "명확하고 협력적인 코드 작성"을 위한 철학입니다. CSP 모델을 통해 복잡한 동시성 문제를 단순하고 우아하게 해결할 수 있습니다.
참고 자료:
'Golang' 카테고리의 다른 글
| [Golang] Go의 defer, panic, recover: 예외 없는 오류 제어 메커니즘 (0) | 2025.11.09 |
|---|---|
| [Golang] Go에 삼항연산자(?:)가 없는 이유: 언어 설계 철학과 가독성 우선주의 (0) | 2025.11.09 |