고 중급 #3 고루틴과 채널 입문
#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 끝나면 모든 고루틴 종료 #
자주 만나는 함정.
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
#
송신자가 더 보낼 게 없을 때.
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, 4for range ch 패턴이 가장 흔합니다. 채널이 close 되면 자동으로 종료합니다.
close의 규칙 #
- 수신자는 close 안 함 — 보내는 쪽만
- 이미 닫힌 채널에 send → panic
- 이미 닫힌 채널에서 receive → 즉시 zero value 반환 (블록 안 함)
close 됐는지 확인 — comma-ok #
v, ok := <-ch
if !ok {
fmt.Println("채널 닫힘")
}map의 comma-ok와 같은 패턴. ok가 false 면 닫혔고 더 받을 게 없다는 뜻.
WaitGroup — 여러 고루틴 기다리기
#
여러 고루틴이 끝날 때까지 기다리고 싶을 때 — sync.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 개의 고루틴이 작업 큐에서 일을 가져가 처리.
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 합니다. 부분 데드락(일부만 멈춤)은 못 잡지만, 전체 데드락은 자동 감지됩니다.
가장 흔한 원인:
- 보내고 받는 사람 짝이 안 맞음
- close 안 한 채
for range ch가 영원히 기다림 - 두 고루틴이 서로의 채널을 기다림 (순환)
함정 — 고루틴 누수 #
고루틴이 끝나지 않고 영원히 살아 있으면 — 메모리 누수와 비슷한 상황이 생깁니다.
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 문, 그리고 타임아웃과 취소 패턴을 정리합니다.