Go 동시성: 고루틴과 채널 - 완벽 가이드

고루틴과 채널을 사용해 Go 동시성을 마스터하세요. 고급 패턴, 동기화, select 문, 모범 사례를 자세한 코드 예제와 함께 다룹니다.

Go 동시성 - 고루틴과 채널의 작동

동시성은 Go의 가장 큰 강점 중 하나입니다. 멀티스레딩이 복잡하게 남아있는 다른 언어와 달리, Go는 고루틴과 채널을 기반으로 한 우아한 모델을 제공하여 동시성 애플리케이션 개발을 크게 단순화합니다.

Go 철학

"메모리를 공유하여 통신하지 말고, 통신하여 메모리를 공유하세요." 이 근본 원칙은 Go의 모든 동시성 설계를 안내합니다.

고루틴 이해하기

고루틴은 Go 런타임이 관리하는 경량 스레드입니다. 약 2 KB의 스택을 소비하며(OS 스레드의 수 MB와 비교) 시스템 과부하 없이 수천 개의 동시 작업을 실행할 수 있습니다.

고루틴을 시작하려면 함수 호출 앞에 go 키워드를 두기만 하면 됩니다. 런타임이 사용 가능한 스레드 간의 스케줄링과 분배를 처리합니다.

goroutines_basic.gogo
package main

import (
    "fmt"
    "time"
)

// fetchData simulates a network request
func fetchData(id int) {
    // Simulates network delay
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Data %d fetched\n", id)
}

func main() {
    // Sequential execution - 500ms total
    start := time.Now()
    for i := 1; i <= 5; i++ {
        fetchData(i)
    }
    fmt.Printf("Sequential: %v\n", time.Since(start))

    // Concurrent execution - ~100ms total
    start = time.Now()
    for i := 1; i <= 5; i++ {
        go fetchData(i) // Execute as goroutine
    }
    time.Sleep(150 * time.Millisecond) // Wait for completion
    fmt.Printf("Concurrent: %v\n", time.Since(start))
}

동시 실행은 총 시간을 500ms에서 약 100ms로 줄입니다. 그러나 고루틴 동기화에 time.Sleep을 사용하는 것은 모범 사례가 아닙니다. 채널은 우아한 해결책을 제공합니다.

채널: 고루틴 간 통신

채널은 고루틴 간에 값을 보내고 받기 위한 타입이 지정된 도관입니다. 채널은 동기화를 보장합니다. 보내는 고루틴은 다른 고루틴이 받을 때까지 기다리며, 그 반대도 마찬가지입니다.

채널 생성에는 make 함수를 사용합니다. <- 연산자는 채널에 대한 위치에 따라 데이터를 보내고 받습니다.

channels_basic.gogo
package main

import "fmt"

// worker performs computation and returns result via channel
func worker(id int, jobs <-chan int, results chan<- int) {
    // Receives jobs until channel closes
    for job := range jobs {
        result := job * 2 // Processing
        results <- result // Send result
    }
}

func main() {
    // Create channels
    jobs := make(chan int, 10)    // Buffered channel
    results := make(chan int, 10)

    // Start 3 workers
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // Send 5 jobs
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs) // Signal end of jobs

    // Collect results
    for r := 1; r <= 5; r++ {
        result := <-results
        fmt.Printf("Result: %d\n", result)
    }
}

방향성 채널(수신용 <-chan, 송신용 chan<-)은 가능한 작업을 제한하여 코드 안전성을 강화합니다.

버퍼드 vs 언버퍼드 채널

이 채널 유형 간의 구분은 고루틴 간의 동기화 동작에 직접적인 영향을 미칩니다.

언버퍼드 채널은 수신자가 준비될 때까지 송신자를 차단합니다. 버퍼드 채널은 N이 버퍼 용량을 나타내는 경우 차단 없이 최대 N개의 값을 보낼 수 있게 합니다.

buffered_channels.gogo
package main

import "fmt"

func main() {
    // Unbuffered channel - strict synchronization
    unbuffered := make(chan string)

    go func() {
        unbuffered <- "message" // Blocks until received
    }()

    msg := <-unbuffered // Unblocks the send
    fmt.Println(msg)

    // Buffered channel - capacity of 2
    buffered := make(chan int, 2)

    // These sends don't block
    buffered <- 1
    buffered <- 2
    // buffered <- 3 // Would block because buffer is full

    fmt.Println(<-buffered) // 1
    fmt.Println(<-buffered) // 2

    // Check capacity
    fmt.Printf("Length: %d, Capacity: %d\n",
        len(buffered), cap(buffered))
}

버퍼드 채널은 생산자와 소비자를 분리하는 반면, 언버퍼드는 점대점 동기화를 보장합니다.

데드락 주의

데드락은 모든 고루틴이 대기 상태로 차단될 때 발생합니다. Go 런타임은 이를 감지하고 명시적인 오류 메시지와 함께 프로그램을 종료합니다.

Select: 채널 다중화

select 문은 여러 채널에 대한 동시 작업을 기다립니다. 동시 통신을 위한 switch 문과 유사합니다.

이 구조는 단일 채널에서 무기한 차단되지 않고 타임아웃, 취소 및 여러 통신을 관리하는 데 필수적입니다.

select_example.gogo
package main

import (
    "fmt"
    "time"
)

// fetchAPI simulates an API call with variable delay
func fetchAPI(name string, delay time.Duration, ch chan<- string) {
    time.Sleep(delay)
    ch <- fmt.Sprintf("%s: data received", name)
}

func main() {
    api1 := make(chan string)
    api2 := make(chan string)

    // Launch two API calls in parallel
    go fetchAPI("API-1", 100*time.Millisecond, api1)
    go fetchAPI("API-2", 200*time.Millisecond, api2)

    // Global timeout of 150ms
    timeout := time.After(150 * time.Millisecond)

    // Collect results with timeout
    for i := 0; i < 2; i++ {
        select {
        case result := <-api1:
            fmt.Println(result)
        case result := <-api2:
            fmt.Println(result)
        case <-timeout:
            fmt.Println("Timeout - operation cancelled")
            return
        }
    }
}

select는 첫 번째로 준비된 채널을 선택합니다. 여러 개가 준비된 경우, 기아 상태를 방지하기 위해 선택은 의사 무작위입니다.

Go 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

Worker Pool 패턴

Worker pool 패턴은 여러 워커 간에 작업을 분배하여 동시성을 제한하고 리소스 사용을 최적화합니다. 이 패턴은 대량의 데이터를 처리하는 데 필수적임이 입증되었습니다.

구현은 워커 간에 공유되는 작업 채널과 수집을 위한 결과 채널에 의존합니다.

worker_pool.gogo
package main

import (
    "fmt"
    "sync"
    "time"
)

// Task represents a unit of work
type Task struct {
    ID   int
    Data string
}

// Result contains the processing result
type Result struct {
    TaskID int
    Output string
}

// worker processes received tasks
func worker(id int, tasks <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()

    for task := range tasks {
        // Simulate processing
        time.Sleep(50 * time.Millisecond)

        results <- Result{
            TaskID: task.ID,
            Output: fmt.Sprintf("Worker %d processed: %s", id, task.Data),
        }
    }
}

func main() {
    const numWorkers = 3
    const numTasks = 10

    tasks := make(chan Task, numTasks)
    results := make(chan Result, numTasks)

    var wg sync.WaitGroup

    // Start workers
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, tasks, results, &wg)
    }

    // Send tasks
    for i := 1; i <= numTasks; i++ {
        tasks <- Task{ID: i, Data: fmt.Sprintf("task-%d", i)}
    }
    close(tasks)

    // Close results channel after workers finish
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect results
    for result := range results {
        fmt.Printf("Task %d: %s\n", result.TaskID, result.Output)
    }
}

sync.WaitGroup은 결과 채널을 닫기 전에 모든 워커가 완료되기를 기다리는 것을 조정합니다.

Fan-Out/Fan-In 패턴

이 패턴은 여러 고루틴에 작업을 분배하고(fan-out) 결과를 집계합니다(fan-in). 결과 수집을 단순화하면서 병렬성을 최대화합니다.

fan_out_fan_in.gogo
package main

import (
    "fmt"
    "sync"
)

// generate produces numbers on a channel
func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

// square computes the square of received numbers
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

// merge combines multiple channels into one (fan-in)
func merge(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    // Output function for each channel
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            out <- n
        }
    }

    // Launch a goroutine per channel
    wg.Add(len(channels))
    for _, c := range channels {
        go output(c)
    }

    // Close after all goroutines finish
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

func main() {
    // Generate data
    numbers := generate(1, 2, 3, 4, 5, 6, 7, 8)

    // Fan-out: distribute to 3 workers
    sq1 := square(numbers)
    sq2 := square(numbers)
    sq3 := square(numbers)

    // Fan-in: aggregate results
    for result := range merge(sq1, sq2, sq3) {
        fmt.Println(result)
    }
}

이 패턴은 분배 가능한 CPU 바인딩 작업과 데이터 처리 파이프라인에 탁월합니다.

취소 및 데드라인을 위한 Context

context 패키지는 고루틴 간의 취소, 데드라인 및 값 관리를 표준화합니다. 장기 실행 고루틴은 첫 번째 매개변수로 context를 받아들여야 합니다.

context_example.gogo
package main

import (
    "context"
    "fmt"
    "time"
)

// fetchWithContext simulates a cancellable request
func fetchWithContext(ctx context.Context, url string) (string, error) {
    // Simulates a long operation
    select {
    case <-time.After(2 * time.Second):
        return fmt.Sprintf("Data from %s", url), nil
    case <-ctx.Done():
        return "", ctx.Err() // context.Canceled or context.DeadlineExceeded
    }
}

func main() {
    // Context with 500ms timeout
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel() // Release resources

    result, err := fetchWithContext(ctx, "https://api.example.com")
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        return
    }
    fmt.Println(result)

    // Context with manual cancellation
    ctx2, cancel2 := context.WithCancel(context.Background())

    go func() {
        time.Sleep(100 * time.Millisecond)
        cancel2() // Explicit cancellation
    }()

    result, err = fetchWithContext(ctx2, "https://api2.example.com")
    if err != nil {
        fmt.Printf("Request cancelled: %v\n", err)
    }
}
모범 사례

리소스 누수를 방지하기 위해 context를 생성한 직후에 항상 defer cancel()을 호출하십시오.

sync.Mutex를 사용한 동기화

채널이 통신에 선호되지만, 공유 데이터 구조에 대한 동시 접근을 보호하기 위해 sync 패키지가 여전히 필요합니다.

mutex_example.gogo
package main

import (
    "fmt"
    "sync"
)

// SafeCounter is a thread-safe counter
type SafeCounter struct {
    mu    sync.Mutex
    value map[string]int
}

// Increment increments the value for a given key
func (c *SafeCounter) Increment(key string) {
    c.mu.Lock()         // Exclusive lock
    defer c.mu.Unlock() // Guaranteed unlock
    c.value[key]++
}

// Value returns the current value
func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value[key]
}

func main() {
    counter := SafeCounter{value: make(map[string]int)}

    var wg sync.WaitGroup

    // 1000 concurrent increments
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment("visits")
        }()
    }

    wg.Wait()
    fmt.Printf("Total: %d\n", counter.Value("visits")) // 1000
}

sync.RWMutex는 읽기 전용 작업을 위한 RLock()/RUnlock()로 동시 읽기를 최적화합니다.

일반적인 실수와 해결책

Go 동시성에는 고전적인 함정이 있습니다. 가장 일반적인 오류와 이를 피하는 방법은 다음과 같습니다.

common_mistakes.gogo
package main

import (
    "fmt"
    "sync"
)

func main() {
    // ERROR: Loop variable capture
    // All goroutines would print the same value
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i) // Capture by reference - BUG
        }()
    }

    // SOLUTION: Pass value as parameter
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Println(n) // Local copy - CORRECT
        }(i)
    }
    wg.Wait()

    // ERROR: Send on nil channel
    var ch chan int
    // ch <- 1 // Blocks forever

    // SOLUTION: Always initialize with make
    ch = make(chan int, 1)
    ch <- 1
    fmt.Println(<-ch)

    // ERROR: Send on closed channel
    done := make(chan bool)
    close(done)
    // done <- true // Panic!

    // SOLUTION: Check before send or use sync.Once
    select {
    case done <- true:
        fmt.Println("Sent")
    default:
        fmt.Println("Channel closed or full")
    }
}

데이터 레이스 감지는 컴파일이나 테스트 중에 -race 플래그를 사용합니다: go test -race ./....

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

결론

Go 동시성을 마스터하는 것은 잘 이해되면 고성능 애플리케이션을 구축할 수 있는 몇 가지 핵심 개념에 의존합니다.

핵심 사항:

✅ 고루틴은 가볍고 저렴합니다 - 수천 개를 생성해도 허용 가능합니다

✅ 채널은 고루틴 간에 데이터를 동기화하고 전송합니다

select 문은 여러 통신과 타임아웃을 관리합니다

✅ Worker pool 패턴은 동시성을 제한하고 리소스를 최적화합니다

context 패키지는 취소와 데드라인을 표준화합니다

✅ 채널이 부족할 때 뮤텍스는 공유 데이터를 보호합니다

-race 플래그는 테스트 중에 데이터 레이스를 감지합니다

"통신하여 메모리를 공유하라"는 철학은 잠금이 있는 전통적인 멀티스레딩보다 더 안전하고 유지 관리하기 쉬운 설계로 이끕니다.

태그

#go
#golang
#concurrency
#goroutines
#channels

공유

관련 기사