Skip to content
ioob.dev
Go back

Go 입문 9편 — 고루틴과 채널

· 5분 읽기

Table of contents

동시성이 쉬워지는 언어

대부분의 언어에서 동시성은 고급 주제다. 스레드를 만들고, 락을 걸고, 데드락을 피하려고 머리를 싸매야 한다. Go는 이 문제를 언어 차원에서 풀었다. 고루틴(goroutine)과 채널(channel)이라는 두 가지 도구만으로 동시성 프로그래밍이 가능하고, 그 문법이 놀라울 정도로 간결하다.

Go의 창시자들이 좋아하는 격언이 있다. “메모리를 공유해서 통신하지 말고, 통신으로 메모리를 공유하라.” 이 철학이 채널에 고스란히 녹아 있다.

고루틴 — 가벼운 동시 실행

고루틴은 Go 런타임이 관리하는 경량 스레드다. OS 스레드보다 훨씬 가볍고(초기 스택 크기가 수 KB에 불과), 수천 개를 동시에 띄워도 문제없다.

시작하는 방법은 함수 호출 앞에 go를 붙이는 게 전부다.

package main

import (
    "fmt"
    "time"
)

func sayHello(name string) {
    for i := 0; i < 3; i++ {
        fmt.Printf("%s: 안녕! (%d)\n", name, i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go sayHello("고루틴A")
    go sayHello("고루틴B")

    // main이 끝나면 모든 고루틴도 종료되므로 기다려야 한다
    time.Sleep(500 * time.Millisecond)
    fmt.Println("")
}

go sayHello("고루틴A")sayHello를 새로운 고루틴에서 실행한다. main 함수는 기다리지 않고 바로 다음 줄로 넘어가고, 두 고루틴은 동시에 실행된다. 출력 순서가 매번 달라질 수 있는 것도 동시성의 특징이다.

time.Sleep으로 기다리는 건 임시방편이다. 실제 코드에서는 이렇게 하면 안 되고, 곧 나올 채널이나 WaitGroup을 써야 한다.

채널 — 고루틴 간 통신

채널은 고루틴끼리 값을 주고받는 파이프다. 한쪽에서 보내고(<-), 다른 쪽에서 받는다.

여러 고루틴이 채널을 통해 데이터를 주고받는 구조를 그림으로 보자.

sequenceDiagram
    participant Main as main 고루틴
    participant Ch as chan int
    participant G1 as 고루틴 A<br/>(sum 전반부)
    participant G2 as 고루틴 B<br/>(sum 후반부)

    Main->>Ch: make(chan int)
    Main->>G1: go sum(numbers[:5], ch)
    Main->>G2: go sum(numbers[5:], ch)

    G1-->>G1: 1+2+3+4+5 = 15
    G2-->>G2: 6+7+8+9+10 = 40

    G1->>Ch: ch <- 15
    Ch->>Main: a := <-ch (15)
    G2->>Ch: ch <- 40
    Ch->>Main: b := <-ch (40)

    Main-->>Main: a + b = 55
package main

import "fmt"

func sum(numbers []int, ch chan int) {
    total := 0
    for _, n := range numbers {
        total += n
    }
    ch <- total // 결과를 채널로 보냄
}

func main() {
    numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

    ch := make(chan int)

    // 반씩 나눠서 동시에 계산
    go sum(numbers[:5], ch)
    go sum(numbers[5:], ch)

    a := <-ch // 첫 번째 결과 수신
    b := <-ch // 두 번째 결과 수신
    fmt.Println(a, b, a+b) // 15 40 55 (순서는 바뀔 수 있음)
}

make(chan int)로 int 값을 주고받는 채널을 만든다. ch <- total로 값을 보내고, <-ch로 값을 받는다. 중요한 건, 받는 쪽이 준비될 때까지 보내는 쪽이 블록되고, 보내는 쪽이 없으면 받는 쪽도 블록된다는 점이다. 이 동기화 특성 덕분에 별도의 락 없이도 안전한 통신이 가능하다.

방향 지정 채널

함수의 파라미터에서 채널의 방향을 제한할 수 있다. 보내기만 하는 채널, 받기만 하는 채널을 구분하면 실수를 컴파일 타임에 잡을 수 있다.

package main

import "fmt"

// 보내기 전용 채널
func produce(ch chan<- string) {
    ch <- "데이터 1"
    ch <- "데이터 2"
    close(ch)
}

// 받기 전용 채널
func consume(ch <-chan string) {
    for msg := range ch {
        fmt.Println("수신:", msg)
    }
}

func main() {
    ch := make(chan string)
    go produce(ch)
    consume(ch) // main 고루틴에서 직접 소비
}

chan<- string은 보내기만 가능하고, <-chan string은 받기만 가능하다. produce 안에서 실수로 <-ch를 쓰면 컴파일 에러가 뜬다. 방향을 제한하는 건 필수는 아니지만, 코드의 의도를 명확하게 드러내고 버그를 예방하는 좋은 습관이다.

close(ch)로 채널을 닫으면 더 이상 보낼 값이 없다는 신호를 보내는 것이다. range는 채널이 닫힐 때까지 값을 계속 받아온다.

버퍼 채널

기본 채널은 버퍼가 없어서, 보내는 쪽과 받는 쪽이 동시에 준비되어야 통신이 일어난다. 버퍼 채널을 쓰면 일정 개수까지는 받는 쪽 없이도 값을 보낼 수 있다.

package main

import "fmt"

func main() {
    // 버퍼 크기 3
    ch := make(chan string, 3)

    ch <- "첫째" // 블록 안 됨
    ch <- "둘째" // 블록 안 됨
    ch <- "셋째" // 블록 안 됨
    // ch <- "넷째" // 여기서 블록됨 (버퍼 꽉 참)

    fmt.Println(<-ch) // 첫째
    fmt.Println(<-ch) // 둘째
    fmt.Println(<-ch) // 셋째
}

make(chan string, 3)의 두 번째 인자가 버퍼 크기다. 버퍼가 가득 차면 비워질 때까지 보내기가 블록되고, 비어 있으면 받기가 블록된다.

언제 버퍼 채널을 쓸까? 생산자와 소비자의 속도 차이를 완충하고 싶을 때 유용하다. 로그를 모아서 배치 처리한다든지, 요청을 큐에 넣어두고 순서대로 처리하는 식이다. 다만 버퍼 크기를 무작정 크게 잡으면 메모리를 낭비하고, 문제가 발생해도 한참 뒤에야 드러날 수 있으니 적절한 크기를 고민해야 한다.

select — 여러 채널 동시 대기

select는 여러 채널 연산 중 준비된 것을 먼저 처리한다. switch와 비슷하게 생겼지만, 각 case가 채널 연산이라는 점이 다르다.

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(100 * time.Millisecond)
        ch1 <- "채널1 완료"
    }()

    go func() {
        time.Sleep(200 * time.Millisecond)
        ch2 <- "채널2 완료"
    }()

    // 두 채널 중 먼저 도착하는 걸 처리
    for i := 0; i < 2; i++ {
        select {
        case msg := <-ch1:
            fmt.Println(msg)
        case msg := <-ch2:
            fmt.Println(msg)
        }
    }
}

select는 여러 채널 중 어디서든 데이터가 오면 해당 case를 실행한다. 동시에 여러 개가 준비되면 무작위로 하나를 고른다. 이 특성 덕분에 타임아웃 구현이 깔끔해진다.

select {
case result := <-ch:
    fmt.Println("결과:", result)
case <-time.After(3 * time.Second):
    fmt.Println("타임아웃!")
}

time.After는 지정한 시간이 지나면 값을 보내는 채널을 반환한다. 결과가 3초 안에 안 오면 타임아웃 case가 실행되는 구조다.

WaitGroup — 고루틴 완료 대기

앞에서 time.Sleep으로 고루틴 종료를 기다렸는데, 이건 실제 코드에서 쓰면 안 되는 방식이다. 정확히 언제 끝날지 모르니까. sync.WaitGroup이 이 문제를 해결한다.

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done() // 함수 종료 시 카운트 감소

    fmt.Printf("워커 %d 시작\n", id)
    // 무언가 작업...
    fmt.Printf("워커 %d 완료\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1) // 카운트 증가
        go worker(i, &wg)
    }

    wg.Wait() // 카운트가 0이 될 때까지 블록
    fmt.Println("모든 워커 완료")
}

패턴은 단순하다. Add로 기다릴 고루틴 수를 등록하고, 각 고루틴이 끝날 때 Done을 호출하며, Wait가 모두 끝날 때까지 블록한다. defer wg.Done()으로 쓰면 함수가 어떻게 종료되든(정상이든 panic이든) 카운트가 확실히 줄어든다.

주의할 점 하나. wg.Add(1)은 반드시 go 키워드 전에 호출해야 한다. 고루틴 안에서 Add를 호출하면, Wait가 먼저 실행되어 0인 상태에서 바로 통과해버리는 경쟁 조건이 생길 수 있다.

종합 예제 — 동시 URL 확인기

지금까지 배운 걸 합쳐서, 여러 URL의 응답 상태를 동시에 확인하는 프로그램을 만들어보자.

package main

import (
    "fmt"
    "net/http"
    "sync"
    "time"
)

type Result struct {
    URL    string
    Status string
    Took   time.Duration
}

func checkURL(url string, ch chan<- Result, wg *sync.WaitGroup) {
    defer wg.Done()

    start := time.Now()
    resp, err := http.Get(url)
    took := time.Since(start)

    if err != nil {
        ch <- Result{URL: url, Status: "에러: " + err.Error(), Took: took}
        return
    }
    defer resp.Body.Close()
    ch <- Result{URL: url, Status: resp.Status, Took: took}
}

func main() {
    urls := []string{
        "https://go.dev",
        "https://github.com",
        "https://example.com",
    }

    ch := make(chan Result, len(urls))
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)
        go checkURL(url, ch, &wg)
    }

    // 별도 고루틴에서 모두 끝나면 채널을 닫는다
    go func() {
        wg.Wait()
        close(ch)
    }()

    for result := range ch {
        fmt.Printf("%-30s %s (%v)\n", result.URL, result.Status, result.Took)
    }
}

고루틴으로 URL 요청을 동시에 보내고, 방향 지정 채널로 결과를 전달하며, WaitGroup으로 모든 요청이 끝나는 걸 기다린 뒤 채널을 닫는다. 순차적으로 처리하면 각 URL의 응답 시간을 다 합한 만큼 걸리겠지만, 동시에 보내면 가장 느린 URL의 응답 시간만큼만 걸린다. 이게 동시성의 위력이다.


고루틴과 채널은 Go의 정체성을 규정하는 핵심 기능이다. go 키워드 하나로 동시 실행을 시작하고, 채널을 통해 안전하게 데이터를 주고받으며, select로 여러 경로를 우아하게 분기할 수 있다. 이 기초가 탄탄해야 다음 편에서 다룰 실전 동시성 패턴을 이해할 수 있다.

다음 편에서는 worker pool, fan-out/fan-in, context를 활용한 타임아웃과 취소 처리, 그리고 경쟁 조건과 뮤텍스를 다룬다.

-> 10편: 동시성 패턴


Share this post on:

Comments

Loading comments...


Previous Post
Go 입문 10편 — 동시성 패턴
Next Post
Go 입문 8편 — 포인터