고 중급 #3 고루틴과 채널 입문

6 분 소요

#2 에러 처리 패턴 다음, 이제 Go의 가장 큰 강점 — 동시성. 가벼운 고루틴과 채널이 어떻게 동작하는지부터 정리합니다.

고루틴 — 가벼운 동시 실행 단위 #

고루틴 시작
package main

import (
	"fmt"
	"time"
)

func say(s string) {
	for i := 0; i < 3; i++ {
		fmt.Println(s)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go say("hello")    // 고루틴으로 실행
	say("world")        // main 고루틴에서 실행

	time.Sleep(time.Second)
}

go 키워드 하나면 함수가 새 고루틴에서 동시 실행됩니다. main 함수의 흐름과 분리되어 동작합니다.

고루틴은 어떻게 가벼운가? #

OS 스레드 한 개는 보통 메모리 1MB ~ 2MB. 고루틴은 시작 시 약 2KB입니다. 수십만 개의 고루틴을 만들어도 무리 없습니다.

Go 런타임이 — 적은 수의 OS 스레드 위에 많은 고루틴을 자동으로 스케줄링합니다. 사용자는 동시성만 표현하고, 실제로 어떻게 실행될지는 런타임이 처리합니다.

main 끝나면 모든 고루틴 종료 #

자주 만나는 함정.

main 끝 — 고루틴 끝
func main() {
	go say("hello")
	// main이 즉시 끝남 — say가 한 줄도 출력 못 할 수도
}

main 함수가 끝나면 — 진행 중이던 모든 고루틴도 즉시 죽습니다. 위 time.Sleep은 main이 충분히 기다리게 만든 것입니다. 실전에서는 채널이나 WaitGroup 같은 동기화 도구로 기다립니다.

채널 — 고루틴 사이 통신 #

채널은 고루틴 간에 값을 주고받는 도구입니다. 동기화도 함께 해 줍니다.

채널 기본
func main() {
	ch := make(chan int)

	go func() {
		ch <- 42       // 채널로 보내기
	}()

	v := <-ch          // 채널에서 받기
	fmt.Println(v)     // 42
}

<-가 보낼 때는 오른쪽으로, 받을 때는 왼쪽으로 향합니다. 직관적인 화살표.

채널은 동기화 도구 #

기본 채널은 언버퍼(unbuffered) 입니다. 보내기와 받기가 만나야 진행합니다.

  • 보내는 쪽: 받는 쪽이 준비될 때까지 블록
  • 받는 쪽: 보내는 쪽이 준비될 때까지 블록
동기화 의미
ch := make(chan struct{})

go func() {
	doWork()
	ch <- struct{}{}    // "끝났다" 신호
}()

<-ch                    // 신호를 받아야 main이 진행
fmt.Println("작업 끝")

struct{} (빈 struct)가 자주 쓰여요 — 값 자체는 의미 없고, 신호만 필요할 때.

버퍼된 채널 — 큐처럼 #

버퍼된 채널
ch := make(chan int, 3)    // 용량 3

ch <- 1     // 블록 안 됨
ch <- 2     // 블록 안 됨
ch <- 3     // 블록 안 됨
// ch <- 4  ← 여기서 블록 (버퍼 가득 참)

<-ch        // 1
<-ch        // 2
<-ch        // 3
// <-ch    ← 여기서 블록 (비어있음)

용량이 차면 보내기가 블록, 비어있으면 받기가 블록. 큐처럼 동작합니다.

언버퍼 채널 vs 버퍼 채널 — 기본은 언버퍼가 안전합니다. 버퍼는 명확한 이유가 있을 때만(throughput, 일정 수의 작업 묶기 등).

채널 닫기 — close #

송신자가 더 보낼 게 없을 때.

close
ch := make(chan int)

go func() {
	for i := 0; i < 5; i++ {
		ch <- i
	}
	close(ch)    // 더 보낼 거 없음
}()

for v := range ch {    // close 되면 자동 종료
	fmt.Println(v)
}
// 0, 1, 2, 3, 4

for range ch 패턴이 가장 흔합니다. 채널이 close 되면 자동으로 종료합니다.

close의 규칙 #

  • 수신자는 close 안 함 — 보내는 쪽만
  • 이미 닫힌 채널에 send → panic
  • 이미 닫힌 채널에서 receive → 즉시 zero value 반환 (블록 안 함)

close 됐는지 확인 — comma-ok #

close 검사
v, ok := <-ch
if !ok {
	fmt.Println("채널 닫힘")
}

map의 comma-ok와 같은 패턴. ok가 false 면 닫혔고 더 받을 게 없다는 뜻.

WaitGroup — 여러 고루틴 기다리기 #

여러 고루틴이 끝날 때까지 기다리고 싶을 때 — sync.WaitGroup이 표준.

WaitGroup
import (
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			fmt.Printf("고루틴 %d\n", id)
		}(i)
	}

	wg.Wait()    // 모두 끝날 때까지 기다림
	fmt.Println("끝")
}

세 메서드:

  • Add(n) — 기다릴 고루틴 수 등록
  • Done() — 한 고루틴 끝남 (보통 defer)
  • Wait() — 모두 Done 될 때까지 기다림

함정 — 클로저 캡처 #

자주 만나는 함정
for i := 0; i < 3; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println(i)    // ✗ 모두 같은 i 캡처 가능 (3, 3, 3)
	}()
}

옛 Go의 함정 — 모든 고루틴이 같은 i 변수를 캡처해서 결과가 예상과 달랐습니다. Go 1.22+ 에서는 매 반복마다 새 i가 되어 해결됐습니다(JS의 let과 비슷한 동작).

옛 코드 호환성을 위해 명시적으로 지역 변수에 복사하는 패턴도 흔히 보입니다.

지역 변수로 캡처 — 옛 코드 호환
for i := 0; i < 3; i++ {
	i := i    // 매 반복 새 변수
	go func() {
		fmt.Println(i)
	}()
}

패턴 — Worker Pool #

자주 등장하는 패턴 — N 개의 고루틴이 작업 큐에서 일을 가져가 처리.

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

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

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

	// job 보내기
	for j := 1; j <= 9; j++ {
		jobs <- j
	}
	close(jobs)    // 더 없음

	// 결과 받기
	for a := 1; a <= 9; a++ {
		fmt.Println("결과:", <-results)
	}
}

<-chan int (수신 전용) / chan<- int (송신 전용) 표기 — 함수 시그니처에서 의도를 명시. 이 경우에는 worker가 jobs를 받기만 하고 results에 보내기만 하는 게 명확합니다.

패턴 — Fan-out / Fan-in #

작업을 여러 고루틴으로 분배하고(fan-out), 결과를 한 채널로 모음(fan-in). #1 동시성 패턴에서 자세히 다룹니다.

채널 == 메일박스 같은 비유 #

OOP의 메시지 패싱과 비슷한 모델입니다. 두 고루틴이 메모리를 공유하지 않고, 채널로 데이터를 주고받음.

“Don’t communicate by sharing memory; share memory by communicating.”
— Go 격언

공유 변수에 lock 거는 모델 대신, 채널로 데이터를 흘리는 모델을 권장합니다. 동시성 코드가 더 읽기 쉽고 버그가 적습니다.

함정 — 데드락 #

채널은 강력하지만 잘못 쓰면 데드락(서로 기다리며 멈춤)이 일어납니다.

데드락
func main() {
	ch := make(chan int)
	ch <- 1     // ✗ 받을 사람 없음 — 영원히 블록
	fmt.Println("never")
}
// fatal error: all goroutines are asleep - deadlock!

Go 런타임이 모든 고루틴이 멈춰 있는 데드락을 감지하면 panic 합니다. 부분 데드락(일부만 멈춤)은 못 잡지만, 전체 데드락은 자동 감지됩니다.

가장 흔한 원인:

  1. 보내고 받는 사람 짝이 안 맞음
  2. close 안 한 채 for range ch가 영원히 기다림
  3. 두 고루틴이 서로의 채널을 기다림 (순환)

함정 — 고루틴 누수 #

고루틴이 끝나지 않고 영원히 살아 있으면 — 메모리 누수와 비슷한 상황이 생깁니다.

고루틴 누수
func leak() {
	ch := make(chan int)

	go func() {
		v := <-ch    // 영원히 기다림
		fmt.Println(v)
	}()

	// ch에 아무도 안 보내고 함수 끝
	// 고루틴은 살아남아 메모리 차지
}

해결 — 고루틴이 항상 종료될 수 있는 경로를 만들어 두십시오. context 또는 done 채널이 표준 도구입니다(다음 #5 context).

채널을 함수 매개변수로 — 방향 명시 #

방향 있는 채널 타입
func produce(out chan<- int) {       // 송신 전용
	for i := 0; i < 5; i++ {
		out <- i
	}
	close(out)
}

func consume(in <-chan int) {        // 수신 전용
	for v := range in {
		fmt.Println(v)
	}
}

func main() {
	ch := make(chan int)
	go produce(ch)
	consume(ch)
}

함수가 채널을 어떻게 쓸지 — 시그니처에서 강제. produce 안에서 받기를 시도하면 컴파일 에러. 의도가 코드로 표현됩니다.

마무리 #

이번 글에서 정리한 내용:

  • go 함수()로 고루틴 시작 — 가벼운 동시 실행 (~2KB)
  • main 끝나면 모든 고루틴 종료 — 동기화 필요
  • 채널 — 고루틴 간 값 전달 + 동기화
  • 언버퍼 채널 — 보내기/받기가 만나야 진행
  • 버퍼 채널 — 큐처럼, 차면 블록
  • close(ch) + for range가 표준 종료 패턴
  • sync.WaitGroup으로 여러 고루틴 기다리기
  • worker pool 패턴 (<-chan / chan<- 방향)
  • “Share memory by communicating” — 격언
  • 데드락과 고루틴 누수 함정

다음 글(#4 select와 타임아웃)에서는 여러 채널을 동시에 다루는 select 문, 그리고 타임아웃과 취소 패턴을 정리합니다.

X