본문 바로가기
Golang

[Golang] Go의 Goroutine과 Channel: 공유하지 말고 소통하라

by Tru5tC0der 2025. 11. 10.

"Do not communicate by sharing memory; share memory by communicating."
Go Concurrency Design Principle (https://go.dev/blog/codelab-share)

Go 언어는 단순히 "멀티스레드를 쉽게 쓰는 언어"가 아닙니다. 그보다 더 근본적인 목표는 안전하고 명시적인 동시성(Concurrency) 제어입니다. 이를 위해 Go는 goroutinechannel이라는 두 축을 제공하며, 이는 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 사용 시:

  1. sync.WaitGroup으로 생명주기 관리
  2. 컨텍스트로 취소 및 타임아웃 처리
  3. 패닉이 발생하지 않도록 에러 처리 철저히

Channel 사용 시:

  1. 채널 소유자가 채널을 닫는 책임
  2. 송신 전용/수신 전용 타입으로 인터페이스 명확화
  3. 적절한 버퍼 크기로 성능 최적화

Select 사용 시:

  1. Default case로 non-blocking 처리
  2. 타임아웃으로 데드락 방지
  3. 컨텍스트와 조합하여 우아한 종료

Go의 동시성은 단순히 기술적 기능이 아니라, "명확하고 협력적인 코드 작성"을 위한 철학입니다. CSP 모델을 통해 복잡한 동시성 문제를 단순하고 우아하게 해결할 수 있습니다.


참고 자료: