Go 언어 - 동시성 프로그래밍

.


Background

  필자는 작년 SW마에스트로 활동을 하면서 처음으로 Terraform을 접하게 되었다. Terraform이란 클라우드 인프라 관리 도구로 이는 Go 언어로 작성되어 있어, 늘 흥미를 가지고 있던 언어 중 하나였다. 관심만 가지고 있다가 최근에 졸업 작품을 진행하면서 Go 언어에 대한 관심이 더욱 깊어졌고, 그 활용 범위가 클라우드 인프라 관리뿐만 아니라 마이크로서비스, 네트워크 프로그래밍, DevOps 도구 개발 등 다양한 분야로 확장되어 있음을 알게 되었다. 이에 Go 언어의 기초부터 실제 활용까지 체계적으로 학습하고 공유하고자 본 포스트를 작성하게 되었다.


Goroutine

  고루틴(goroutine)은 Go 런타임이 관리하는 경량 스레드다. 고루틴은 OS 스레드보다 훨씬 가볍고, 생성과 관리가 쉽다.

func printNumbers() {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Printf("%d ", i)
    }
}

func printLetters() {
    for char := 'a'; char < 'e'; char++ {
        time.Sleep(150 * time.Millisecond)
        fmt.Printf("%c ", char)
    }
}

func main() {
    go printNumbers()
    go printLetters()
    time.Sleep(2 * time.Second)
    fmt.Println("\n완료")
}

  이 예제에서는 두 개의 고루틴을 생성하여 숫자와 문자를 동시에 출력한다. ‘go’ 키워드를 사용하여 함수를 고루틴으로 실행할 수 있다.

Channel

  채널(Channel)은 고루틴 간의 통신과 동기화를 위한 메커니즘이다. 채널을 통해 데이터를 주고받을 수 있으며, 이는 “통신을 통한 메모리 공유”라는 Go의 철학을 반영한다.

func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i  // 채널에 데이터 전송
    }
    close(ch)
}

func consumer(ch <-chan int) {
    for num := range ch {
        fmt.Printf("받은 숫자: %d\n", num)
    }
}

func main() {
    ch := make(chan int)
    go producer(ch)
    consumer(ch)
}

  이 예제에서는 producer 고루틴이 채널을 통해 숫자를 전송하고, consumer 고루틴이 이를 받아 출력한다.

Concurrency Pattern

  Go에서 자주 사용되는 몇 가지 동시성 패턴을 살펴보자.

Fan-out, Fan-in Pattern

  여러 고루틴에서 작업을 분산 처리하고 결과를 모으는 패턴이다.

func generator(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

func merge(cs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    wg.Add(len(cs))
    for _, c := range cs {
        go func(ch <-chan int) {
            for n := range ch {
                out <- n
            }
            wg.Done()
        }(c)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

func main() {
    in := generator(1, 2, 3, 4)
    c1 := square(in)
    c2 := square(in)
    for n := range merge(c1, c2) {
        fmt.Println(n)
    }
}

Worker Pool 패턴

  작업을 여러 워커에 분산하여 처리하는 패턴이다.

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Printf("worker %d started job %d\n", id, j)
        time.Sleep(time.Second)
        fmt.Printf("worker %d finished job %d\n", id, j)
        results <- j * 2
    }
}

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

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

    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)

    for a := 1; a <= 5; a++ {
        <-results
    }
}

Context를 이용한 취소 패턴

  작업을 취소하거나 타임아웃을 구현할 때 사용하는 패턴이다.

func doWork(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("작업 취소됨")
            return
        default:
            fmt.Println("작업 중...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    go doWork(ctx)

    time.Sleep(3 * time.Second)
    fmt.Println("메인 함수 종료")
}

Summary

  Go의 동시성 프로그래밍 모델은 간단하면서도 강력하다. 고루틴과 채널을 이용하여 복잡한 동시성 문제를 효과적으로 해결할 수 있으며, 다양한 동시성 패턴을 통해 효율적이고 확장 가능한 프로그램을 작성할 수 있다.

  다만, 동시성 프로그래밍에는 항상 주의가 필요하며, 데드락이나 레이스 컨디션과 같은 문제를 방지하기 위해 신중하게 설계해야 한다. Go는 이러한 문제를 해결하기 위한 도구(예: -race 플래그)도 제공하므로, 이를 적극 활용하는 것이 좋다.