Go 고루틴(gorutine) – 업무에 사용하는 Go 언어 18

Go 고루틴(gorutine)
Go 고루틴(gorutine)

Go 언어를 처음 접하는 분들에게 “고루틴(goroutine)”이라는 개념은 다소 생소할 수 있습니다.
하지만 고루틴은 Go 언어를 Go 언어답게 만들어주는 핵심 기능 중 하나입니다.
이번 글에서는 Go gorutine에 대해 친절하게 풀어 설명하고, 다양한 실습 예제와 함께 이해를 돕고자 합니다.


스레드(thread)

Go 언어의 고루틴(goroutine)을 이해하고 사용하려면 스레드(thread)라는 것을 알아야만 합니다.

컴퓨터는 한 번에 여러 가지 일을 하려고 노력할 때 ‘스레드(thread)’라는 개념을 사용합니다.
쉽게 말해, 스레드는 프로그램 안에서 각각 따로 움직이는 작은 작업 흐름이라고 할 수 있습니다.
예를 들어, 음악을 들으면서 문서를 편집할 때, 각각의 작업이 별도의 스레드에서 돌아가는 것과 비슷합니다.
스레드는 운영체제가 관리하며, 하나의 프로그램 안에서도 여러 스레드가 동시에 독립적으로 작업을 처리할 수 있습니다.

전통적인 스레드는 생성과 관리에 많은 비용이 들기 때문에, 대량의 동시 작업을 처리해야 할 때는 성능 문제가 발생할 수 있습니다. Go 언어는 이러한 문제를 해결하기 위해 고루틴이라는 가벼운 실행 단위를 도입했습니다.


고루틴(gorutine)

고루틴은 Go 언어가 제공하는 매우 가벼운 단위입니다.
고루틴은 메모리 사용량이 적고 생성 비용이 낮아, 수십만 개의 고루틴도 무리 없이 실행할 수 있습니다. 고루틴을 사용하면 동시성 프로그래밍이 훨씬 쉽고 직관적으로 가능합니다.

고루틴은 go 키워드를 함수 호출 앞에 붙이는 것만으로 아주 간편하게 시작할 수 있습니다.

Go
package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello from goroutine!")
}

func main() {
    go sayHello()
    time.Sleep(time.Second) // 고루틴이 끝날 때까지 대기
}

위 코드에서 sayHello 함수는 go 키워드를 통해 고루틴으로 실행됩니다.
주의할 점은 main 함수가 종료되면 프로그램이 끝나기 때문에, 고루틴이 완료될 시간을 주기 위해 Sleep을 사용했습니다.

필자는 처음 고루틴을 접했을 때, 이렇게 간단한 문법으로 비동기 처리가 가능하다는 점에 감탄했다.

고루틴의 동작 원리

고루틴은 Go 런타임 스케줄러에 의해 관리됩니다.
운영체제의 스레드 위에 여러 고루틴이 매핑되어 동작하는 구조입니다.
Go는 내부적으로 M:N 스케줄링을 사용하여, M개의 고루틴을 N개의 스레드 위에서 유연하게 실행합니다.

  • M: 고루틴의 개수
  • N: 운영체제 스레드의 개수

스케줄러는 고루틴의 상태를 지속적으로 감시하며, 블로킹이 발생하면 다른 고루틴을 활성화해 효율적인 실행을 보장합니다.
이 덕분에 Go 프로그램은 고성능을 유지하면서도 복잡한 동시성 문제를 상대적으로 쉽게 다룰 수 있습니다.

약간 돌려막기 한다고 생각하면 이해가 편할 수 있다.

동시성 프로그래밍의 문제점

고루틴을 사용할 때 흔히 겪는 문제 중 하나는 공유 데이터(같은 자원)에 대한 접근입니다.
여러 고루틴이 동시에 같은 변수(같은 자원)를 수정하면 예상치 못한 결과가 발생할 수 있습니다.

예를 들어, 다음 코드를 보겠습니다.

Go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var counter int
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            counter++
            wg.Done()
        }()
    }

    wg.Wait()
    fmt.Println("Counter:", counter)
}

이 프로그램은 counter 변수를 1000번 증가시키는 것을 목표로 하고 있습니다. 하지만 실제로 여러 번 실행해보면 결과가 매번 1000으로 나오지 않습니다. 이는 여러 고루틴이 동시에 counter에 접근하여 값을 변경하는 과정에서 충돌이 발생하기 때문입니다. 이러한 현상을 경쟁 상태(race condition)라고 부릅니다.


뮤텍스(mutex)를 활용한 접근

공유 자원에 대한 동시 접근 문제를 해결하려면 뮤텍스(mutex)를 사용할 수 있습니다.
뮤텍스는 한 번에 하나의 고루틴만 공유 자원에 접근하도록 제한합니다.

Go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var counter int
    var mu sync.Mutex
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            mu.Lock()
            counter++
            mu.Unlock()
            wg.Done()
        }()
    }

    wg.Wait()
    fmt.Println("Counter:", counter)
}

이렇게 하면 counter 변수에 대한 동시 접근을 안전하게 제어할 수 있습니다.


뮤텍스와 데드락(deadlock)

뮤텍스를 사용할 때 가장 조심해야 할 문제 중 하나는 데드락(deadlock)입니다.
데드락은 여러 고루틴이 서로 락을 해제하지 않고 기다리면서 프로그램이 멈춰버리는 현상입니다.

예를 들어, 다음과 같은 상황이 데드락을 유발할 수 있습니다.

Go
mu1.Lock()
mu2.Lock()
...
mu2.Unlock()
mu1.Unlock()

고루틴 두 개가 각각 mu1mu2를 동시에 요청하면, 서로 락이 풀리기를 기다리면서 영원히 진행되지 않을 수 있습니다.

필자는 데드락을 피하기 위해 항상 락 획득과 해제 순서를 일정하게 유지하고,
복잡한 락 구조를 피하는 것을 습관화해야 한다고 생각한다.

쉽게 생각해서 고루틴1이 A를 하려면 B를 해야하고 고루틴2가 B를 하려면 A를 해야한다는 식의 코드를 작성해서는 안 된다.

생각보다 실제 작업을 하면서 뮤텍스를 다룰 일이 많이 없기 때문에 익숙하지 않은 데드락 문제가 발생하면 찾아내기 힘들 수 있기에 조심해야 한다.

고루틴을 안전하게 사용하는 또 다른 방법들

Go는 뮤텍스 외에도 다양한 방법으로 동시성 문제를 해결할 수 있습니다.
대표적으로는 채널(channel) 을 사용하는 방법이 있습니다.

채널은 고루틴 간 통신을 위한 파이프 역할을 하며, 데이터를 주고받는 동시에 동기화를 지원합니다.

Go
package main

import "fmt"

func worker(jobs <-chan int, results chan<- int) {
    for j := range jobs {
        results <- j * 2
    }
}

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

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

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

    for a := 0; a < 5; a++ {
        fmt.Println(<-results)
    }
}
이 부분에 대해서는 다음 글에 더 자세하게 다루어 볼 예정이다.

이번 글에서는 Go gorutine에 대해 기본 개념부터 실습 예제까지 폭넓게 살펴보았습니다.
고루틴은 Go의 강력한 동시성 처리 능력을 뒷받침하는 핵심 기능입니다.
다양한 방법으로 안전하게 동시성 프로그래밍을 구현할 수 있으니, 작은 예제부터 시작해 점차 복잡한 구조에도 도전해보시기를 추천합니다.