GO · 심층 가이드
Go 고루틴/채널 완전 정리
고루틴과 채널의 기본기부터 워커 풀, 팬아웃/팬인, errgroup, 취소 전파, sync.Pool, 누수 방지까지 Go 동시성 27가지 주제를 실무 순서로 묶었습니다.
고루틴을 만드는 비용이 워낙 싸다 보니 사람들은 만드는 법부터 배웁니다. 정작 어려운 건 끝내는 법입니다. Go에는 다른 고루틴을 강제로 죽이는 API가 없습니다. 그래서 종료는 언제나 협력적이고, 취소 신호를 받아 스스로 돌아 나오도록 설계해야 합니다. 이 사실을 늦게 알수록 프로그램에는 아무도 읽지 않는 채널을 영원히 기다리는 고루틴이 쌓입니다. 눈에 띄는 에러 없이 메모리만 서서히 늘어나는 형태로요.
27편은 계층으로 읽으십시오. 바닥은 고루틴 & WaitGroup과 채널 — 고루틴 통신, 버퍼 채널, select 심화입니다. 그 위에 워커 풀 패턴, 팬아웃/팬인 패턴, 채널 파이프라인 같은 조립 패턴이 놓입니다. 수명 관리 축은 context 패키지 — 취소 & 타임아웃에서 취소 전파, 타임아웃 패턴, errgroup 패턴으로 이어지고, 마지막 고루틴 누수 방지가 앞의 모든 패턴을 검증하는 체크리스트 역할을 합니다.
가장 자주 겪는 사고는 채널 닫기 주체를 정하지 않은 경우입니다. 닫힌 채널에 보내면 패닉이고, 여러 생산자가 있는 구조에서는 누가 닫아야 할지가 애매해집니다. 원칙은 하나입니다. 보내는 쪽이 닫고, 받는 쪽은 닫지 않습니다. 생산자가 여럿이면 WaitGroup으로 전부 끝난 뒤 별도 고루틴에서 한 번만 닫으십시오. 또 하나, context는 취소를 알릴 뿐 강제로 멈추지 않습니다. 작업 루프 안에서 ctx.Done()을 실제로 확인하지 않으면 타임아웃을 걸어도 일은 계속 돌아갑니다.
01고루틴 & WaitGroup
Go의 경량 스레드인 고루틴과 동기화 도구 WaitGroup으로 병렬 처리를 구현합니다.
Go code
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 완료 시 카운터 감소
fmt.Printf("worker %d 시작
", id)
time.Sleep(time.Duration(id) * 100 * time.Millisecond)
fmt.Printf("worker %d 완료
", id)
}
// Mutex로 경쟁 조건 방지
type SafeCounter struct {
mu sync.Mutex
v map[string]int
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.v[key]++
}
func (c *SafeCounter) Value(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.v[key]
}
func main() {
// WaitGroup 기본 패턴
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("모든 worker 완료")
// RWMutex — 읽기는 동시, 쓰기는 독점
var rwmu sync.RWMutex
data := make(map[string]string)
// 쓰기
rwmu.Lock()
data["key"] = "value"
rwmu.Unlock()
// 읽기 (여러 고루틴 동시 가능)
rwmu.RLock()
fmt.Println(data["key"])
rwmu.RUnlock()
// SafeCounter 사용
counter := SafeCounter{v: make(map[string]int)}
for i := 0; i < 100; i++ {
go counter.Inc("key")
}
time.Sleep(100 * time.Millisecond)
fmt.Println("카운트:", counter.Value("key"))
}go run -race main.go로 race condition을 감지할 수 있습니다. 개발 중에 습관적으로 실행하세요.
wg.Add(1)은 고루틴 시작 전에 호출해야 합니다. 고루틴 내부에서 호출하면 WaitGroup이 이미 0이 될 수 있습니다.
02채널 — 고루틴 통신
"메모리를 공유해서 통신하지 말고, 통신을 통해 메모리를 공유하라" — Go 격언.
Go code
package main
import (
"fmt"
"time"
)
func producer(ch chan<- int, n int) {
for i := 0; i < n; i++ {
ch <- i
time.Sleep(50 * time.Millisecond)
}
close(ch) // 완료 시 채널 닫기
}
func main() {
// Unbuffered channel — 동기적
ch1 := make(chan int)
go func() { ch1 <- 42 }()
fmt.Println(<-ch1) // 42
// Buffered channel — 비동기
ch2 := make(chan string, 3)
ch2 <- "a"
ch2 <- "b"
ch2 <- "c"
fmt.Println(<-ch2, <-ch2, <-ch2)
// for range로 채널 소비
numCh := make(chan int, 10)
go producer(numCh, 5)
for n := range numCh {
fmt.Printf("받음: %d
", n)
}
// select — 여러 채널 대기
ch3 := make(chan string)
ch4 := make(chan string)
go func() { time.Sleep(100*time.Millisecond); ch3 <- "ch3" }()
go func() { time.Sleep(200*time.Millisecond); ch4 <- "ch4" }()
for i := 0; i < 2; i++ {
select {
case msg := <-ch3:
fmt.Println("받음:", msg)
case msg := <-ch4:
fmt.Println("받음:", msg)
case <-time.After(500 * time.Millisecond):
fmt.Println("타임아웃")
}
}
// Done 채널 패턴
done := make(chan struct{})
go func() {
fmt.Println("백그라운드 작업")
close(done)
}()
<-done
}close(ch)는 보내는 쪽에서만 호출하세요. 닫힌 채널에서 받으면 zero value와 false를 반환합니다. 닫힌 채널에 보내면 패닉입니다.
닫힌 채널에 전송(ch )하면 패닉입니다. 채널 닫기는 전송 측에서 한 번만 수행하세요.
03context 패키지 — 취소 & 타임아웃
고루틴의 생명주기를 제어하는 context. 취소, 타임아웃, 값 전달에 사용합니다.
Go code
package main
import (
"context"
"fmt"
"time"
)
// context를 첫 번째 인자로 받는 것이 Go 관례
func fetchData(ctx context.Context, url string) (string, error) {
ch := make(chan string, 1)
go func() {
// 실제 HTTP 요청 시뮬레이션
time.Sleep(200 * time.Millisecond)
ch <- "data from " + url
}()
select {
case data := <-ch:
return data, nil
case <-ctx.Done():
return "", ctx.Err() // context.DeadlineExceeded or Canceled
}
}
func main() {
// 타임아웃 컨텍스트
ctx, cancel := context.WithTimeout(
context.Background(),
150*time.Millisecond,
)
defer cancel() // 반드시 호출
result, err := fetchData(ctx, "https://api.example.com")
if err != nil {
fmt.Println("실패:", err) // context deadline exceeded
} else {
fmt.Println("성공:", result)
}
// 취소 컨텍스트 — 수동 취소
ctx2, cancel2 := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel2() // 취소 신호 전송
}()
select {
case <-ctx2.Done():
fmt.Println("취소됨:", ctx2.Err())
case <-time.After(500 * time.Millisecond):
fmt.Println("타임아웃")
}
// context.WithValue — 요청 범위 값 전달
type keyType string
ctx3 := context.WithValue(context.Background(),
keyType("userID"), "user-123")
if uid, ok := ctx3.Value(keyType("userID")).(string); ok {
fmt.Println("사용자 ID:", uid)
}
}context.WithTimeout 후 반드시 defer cancel()을 호출하세요. 호출하지 않으면 컨텍스트가 완료될 때까지 리소스가 해제되지 않습니다.
context.WithValue의 키는 문자열이 아닌 비공개 타입을 사용하세요. 여러 패키지에서 같은 키를 사용하면 충돌이 생깁니다.
04sync.Once & atomic 패키지
한 번만 실행되는 초기화와 원자적 연산으로 동시성을 안전하게 처리합니다.
Go code
package main
import (
"fmt"
"sync"
"sync/atomic"
)
// sync.Once — 딱 한 번만 실행 (lazy singleton)
type Database struct {
conn string
}
var (
db *Database
once sync.Once
)
func GetDB() *Database {
once.Do(func() {
fmt.Println("DB 초기화 (한 번만)")
db = &Database{conn: "postgres://localhost:5432/mydb"}
})
return db
}
// atomic — 락 없는 원자적 연산
type AtomicCounter struct {
value int64
}
func (c *AtomicCounter) Inc() {
atomic.AddInt64(&c.value, 1)
}
func (c *AtomicCounter) Load() int64 {
return atomic.LoadInt64(&c.value)
}
func (c *AtomicCounter) CompareAndSwap(old, new int64) bool {
return atomic.CompareAndSwapInt64(&c.value, old, new)
}
func main() {
// sync.Once 테스트
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
db := GetDB()
fmt.Println("사용:", db.conn)
}()
}
wg.Wait()
// atomic counter
counter := &AtomicCounter{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Inc()
}()
}
wg.Wait()
fmt.Println("최종 카운트:", counter.Load()) // 1000
// CAS (Compare-And-Swap) 패턴
ok := counter.CompareAndSwap(1000, 0)
fmt.Println("CAS 성공:", ok, "값:", counter.Load())
}sync.Once는 초기화 함수가 패닉을 발생시켜도 다시 호출되지 않습니다. 패닉에서 복구해도 초기화는 "완료된" 상태로 남습니다.
atomic 연산은 단일 변수에 대해서만 원자적입니다. 여러 변수를 함께 업데이트해야 하면 Mutex를 사용하세요.
05fuzzing 테스트 (Go 1.18+)
go test -fuzz로 랜덤 입력 취약점 탐색
Go code
<span class="cm">// fuzzing 테스트 (Go 1.18+) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("fuzzing 테스트 (Go 1.18+)") }GO 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
06컨텍스트(context) 패키지 활용
context 패키지는 고루틴 간 취소 신호, 타임아웃, 값 전달을 위한 표준 메커니즘입니다. API 서버에서 요청 취소, 데이터베이스 쿼리 타임아웃 등 실무에서 필수적으로 사용됩니다.
Go code
package main
import (
"context"
"fmt"
"math/rand"
"time"
)
// 1. 타임아웃 컨텍스트
func fetchData(ctx context.Context, id int) (string, error) {
// 실제 작업을 시뮬레이션하는 채널
ch := make(chan string, 1)
go func() {
time.Sleep(time.Duration(rand.Intn(300)) * time.Millisecond)
ch <- fmt.Sprintf("데이터-%d", id)
}()
select {
case data := <-ch:
return data, nil
case <-ctx.Done():
return "", ctx.Err() // context.DeadlineExceeded 또는 Canceled
}
}
// 2. 취소 전파
func processItems(ctx context.Context, items []int) []string {
results := make([]string, 0, len(items))
for _, item := range items {
select {
case <-ctx.Done():
fmt.Println(" 작업 취소됨:", ctx.Err())
return results
default:
data, err := fetchData(ctx, item)
if err != nil {
fmt.Printf(" 항목 %d 실패: %v\n", item, err)
continue
}
results = append(results, data)
fmt.Printf(" 항목 %d 완료: %s\n", item, data)
}
}
return results
}
// 3. 값 전달 (요청 ID 등)
type contextKey string
const requestIDKey contextKey = "requestID"
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func getRequestID(ctx context.Context) string {
if id, ok := ctx.Value(requestIDKey).(string); ok {
return id
}
return "unknown"
}
func main() {
// 타임아웃 컨텍스트: 200ms 제한
fmt.Println("=== 타임아웃 테스트 ===")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
results := processItems(ctx, []int{1, 2, 3, 4, 5})
fmt.Printf("완료된 항목: %d개\n\n", len(results))
// 수동 취소
fmt.Println("=== 수동 취소 테스트 ===")
ctx2, cancel2 := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel2() // 100ms 후 취소
}()
processItems(ctx2, []int{10, 20, 30})
// 값 전달
fmt.Println("\n=== 요청 ID 전달 ===")
ctx3 := withRequestID(context.Background(), "req-abc-123")
fmt.Println("요청 ID:", getRequestID(ctx3))
}함수의 첫 번째 매개변수로 context.Context를 받는 것이 Go 관례입니다. context.WithValue는 요청 범위 데이터(trace ID 등)에만 사용하고, 함수 매개변수를 대체하지 마세요.
context.WithTimeout이나 context.WithCancel로 생성한 컨텍스트는 반드시 defer cancel()로 리소스를 해제해야 합니다. 안 하면 컨텍스트가 부모의 타이머 만료까지 메모리에 남습니다.
07sync 패키지 — Mutex, WaitGroup, Once
sync 패키지는 고루틴 간 동기화를 위한 저수준 프리미티브를 제공합니다. Mutex로 임계 영역을 보호하고, WaitGroup으로 완료를 기다리며, Once로 일회성 초기화를 보장합니다.
Go code
package main
import (
"fmt"
"sync"
"time"
)
// 1. Mutex: 동시 접근 보호
type SafeCounter struct {
mu sync.RWMutex // 읽기-쓰기 뮤텍스
count map[string]int
}
func NewSafeCounter() *SafeCounter {
return &SafeCounter{count: make(map[string]int)}
}
func (c *SafeCounter) Increment(key string) {
c.mu.Lock() // 쓰기 잠금
defer c.mu.Unlock()
c.count[key]++
}
func (c *SafeCounter) Get(key string) int {
c.mu.RLock() // 읽기 잠금 (여러 고루틴 동시 읽기 가능)
defer c.mu.RUnlock()
return c.count[key]
}
// 2. WaitGroup: 고루틴 완료 대기
func parallelFetch(urls []string) []string {
var wg sync.WaitGroup
results := make([]string, len(urls))
for i, url := range urls {
wg.Add(1)
go func(idx int, u string) {
defer wg.Done()
time.Sleep(50 * time.Millisecond) // 네트워크 시뮬레이션
results[idx] = fmt.Sprintf("[%d] %s 완료", idx, u)
}(i, url)
}
wg.Wait() // 모든 고루틴 완료까지 대기
return results
}
// 3. Once: 정확히 한 번만 실행
type Database struct {
once sync.Once
connPool string
}
func (db *Database) Connect() string {
db.once.Do(func() {
fmt.Println(" DB 연결 풀 초기화 (한 번만 실행)")
time.Sleep(100 * time.Millisecond) // 초기화 시뮬레이션
db.connPool = "PostgreSQL://pool-ready"
})
return db.connPool
}
// 4. Pool: 객체 재사용
var bufferPool = sync.Pool{
New: func() interface{} {
fmt.Println(" 새 버퍼 생성")
return make([]byte, 0, 1024)
},
}
func main() {
// Mutex 테스트
fmt.Println("=== SafeCounter ===")
counter := NewSafeCounter()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment("hits")
}()
}
wg.Wait()
fmt.Printf("hits = %d (기대값: 100)\n", counter.Get("hits"))
// WaitGroup 테스트
fmt.Println("\n=== Parallel Fetch ===")
urls := []string{"api/users", "api/posts", "api/comments"}
results := parallelFetch(urls)
for _, r := range results {
fmt.Println(" ", r)
}
// Once 테스트
fmt.Println("\n=== Singleton DB ===")
db := &Database{}
for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
conn := db.Connect()
fmt.Printf(" 고루틴 %d: %s\n", n, conn)
}(i)
}
wg.Wait()
// Pool 테스트
fmt.Println("\n=== Buffer Pool ===")
buf := bufferPool.Get().([]byte)
buf = append(buf, "hello pool"...)
fmt.Printf(" 사용: %s\n", buf)
bufferPool.Put(buf[:0]) // 초기화 후 반환
buf2 := bufferPool.Get().([]byte) // 재사용 (새로 생성 안 함)
_ = buf2
}sync.RWMutex는 읽기가 빈번하고 쓰기가 드문 경우 sync.Mutex보다 성능이 좋습니다. 하지만 단순한 카운터는 sync/atomic 패키지가 더 효율적입니다.
sync.WaitGroup의 Add()를 고루틴 안에서 호출하면 Wait()이 먼저 반환될 수 있습니다. 반드시 고루틴 시작 전에 Add()를 호출하세요.
08고루틴 패턴
go 키워드로 경량 스레드(고루틴)를 생성합니다. sync.WaitGroup으로 완료를 대기합니다.
Go code
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("워커 %d 시작\n", id)
time.Sleep(100 * time.Millisecond)
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() // 모든 고루틴 완료 대기
fmt.Println("모든 워커 완료")
}wg.Add(1)은 고루틴 시작 전에 호출하세요. 고루틴 내부에서 호출하면 Wait()이 먼저 반환될 수 있습니다.
WaitGroup을 값으로 전달하면 복사되어 동작하지 않습니다. 반드시 포인터(*sync.WaitGroup)로 전달하세요.
09워커 풀 패턴
고정 수의 워커 고루틴이 작업 채널에서 태스크를 가져가 처리합니다. 동시성 제한에 핵심적인 패턴입니다.
Go code
package main
import (
"fmt"
"sync"
)
func workerPool(numWorkers int, jobs <-chan int) <-chan int {
results := make(chan int, len(jobs))
var wg sync.WaitGroup
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for job := range jobs {
result := job * job // 작업 처리
fmt.Printf("워커%d: %d → %d\n", id, job, result)
results <- result
}
}(w)
}
go func() {
wg.Wait()
close(results)
}()
return results
}
func main() {
jobs := make(chan int, 10)
for i := 1; i <= 10; i++ {
jobs <- i
}
close(jobs)
results := workerPool(3, jobs)
var total int
for r := range results {
total += r
}
fmt.Println("합계:", total)
}워커 수를 runtime.NumCPU()로 설정하면 CPU 바운드 작업에 최적입니다. I/O 바운드는 더 많은 워커가 효과적입니다.
jobs 채널을 닫지 않으면 워커들이 영원히 range에서 대기합니다. 모든 작업 전송 후 반드시 close(jobs)를 호출하세요.
10팬아웃/팬인 패턴
하나의 채널에서 여러 고루틴으로 분배(Fan-Out)하고, 결과를 하나의 채널로 합칩니다(Fan-In).
Go code
package main
import (
"fmt"
"sync"
)
// Fan-Out: 여러 고루틴에 작업 분배
func fanOut(input <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
ch := make(chan int)
channels[i] = ch
go func() {
defer close(ch)
for n := range input {
ch <- n * n
}
}()
}
return channels
}
// Fan-In: 여러 채널을 하나로 합침
func fanIn(channels ...<-chan int) <-chan int {
merged := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c { merged <- v }
}(ch)
}
go func() { wg.Wait(); close(merged) }()
return merged
}
func main() {
input := make(chan int, 5)
go func() {
for i := 1; i <= 10; i++ { input <- i }
close(input)
}()
outputs := fanOut(input, 3)
merged := fanIn(outputs...)
for result := range merged {
fmt.Println(result)
}
}팬아웃 수는 CPU 코어 수나 외부 API 병렬 제한에 맞추세요. 과도한 팬아웃은 리소스 경합을 유발합니다.
팬인에서 모든 입력 채널이 닫혀야 출력 채널도 닫힙니다. 하나라도 닫히지 않으면 고루틴 누수가 발생합니다.
11채널 파이프라인
채널로 연결된 단계들이 동시에 실행되는 파이프라인 패턴입니다. 각 단계가 독립 고루틴에서 동작합니다.
Go code
package main
import "fmt"
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums { out <- n }
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in { out <- n * n }
}()
return out
}
func filter(in <-chan int, pred func(int) bool) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
if pred(n) { out <- n }
}
}()
return out
}
func main() {
// 파이프라인: 생성 → 제곱 → 필터(50 이하)
pipeline := filter(
square(generate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)),
func(n int) bool { return n <= 50 },
)
for v := range pipeline {
fmt.Println(v) // 1 4 9 16 25 36 49
}
}각 단계에서 defer close(out)를 사용하면 상류 단계가 완료될 때 하류로 자동 전파됩니다.
파이프라인에서 소비자가 조기 종료하면 생산자가 블로킹됩니다. context로 취소 신호를 전달하세요.
12errgroup 패턴
golang.org/x/sync/errgroup으로 고루틴 그룹의 에러를 수집합니다. 하나라도 실패하면 전체를 취소합니다.
Go code
package main
import (
"context"
"fmt"
"sync"
)
// errgroup 간단 구현
type ErrGroup struct {
wg sync.WaitGroup
mu sync.Mutex
err error
ctx context.Context
cancel context.CancelFunc
}
func WithContext(ctx context.Context) (*ErrGroup, context.Context) {
ctx, cancel := context.WithCancel(ctx)
return &ErrGroup{ctx: ctx, cancel: cancel}, ctx
}
func (g *ErrGroup) Go(fn func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
if err := fn(); err != nil {
g.mu.Lock()
if g.err == nil { g.err = err }
g.mu.Unlock()
g.cancel()
}
}()
}
func (g *ErrGroup) Wait() error {
g.wg.Wait()
return g.err
}
func main() {
g, ctx := WithContext(context.Background())
urls := []string{"url1", "url2", "url3"}
for _, url := range urls {
g.Go(func() error {
select {
case <-ctx.Done():
return ctx.Err()
default:
fmt.Println("처리:", url)
return nil
}
})
}
if err := g.Wait(); err != nil {
fmt.Println("에러:", err)
}
}실무에서는 golang.org/x/sync/errgroup을 직접 사용하세요. SetLimit(n)으로 동시성도 제한할 수 있습니다.
errgroup에서 context를 사용하지 않으면 한 고루틴이 실패해도 나머지가 계속 실행됩니다.
13context 심화
context.Context로 취소, 타임아웃, 값 전달을 관리합니다. Go 동시성의 핵심 제어 메커니즘입니다.
Go code
package main
import (
"context"
"fmt"
"time"
)
func slowOperation(ctx context.Context) error {
select {
case <-time.After(5 * time.Second):
fmt.Println("작업 완료")
return nil
case <-ctx.Done():
fmt.Println("작업 취소됨:", ctx.Err())
return ctx.Err()
}
}
func main() {
// 타임아웃 컨텍스트
ctx, cancel := context.WithTimeout(
context.Background(), 2*time.Second,
)
defer cancel() // 리소스 해제 보장
if err := slowOperation(ctx); err != nil {
fmt.Println("에러:", err)
}
// 값 전달 (요청 ID 등)
type ctxKey string
ctx2 := context.WithValue(context.Background(),
ctxKey("requestID"), "req-123")
if v := ctx2.Value(ctxKey("requestID")); v != nil {
fmt.Println("요청 ID:", v)
}
}context는 항상 함수의 첫 번째 매개변수로 전달하세요: func DoSomething(ctx context.Context, ...)
context.WithValue의 키로 string 같은 내장 타입을 사용하면 다른 패키지와 충돌할 수 있습니다. 비공개 타입을 키로 사용하세요.
14타임아웃 패턴
채널과 time.After, context.WithTimeout으로 작업 타임아웃을 구현합니다.
Go code
package main
import (
"context"
"fmt"
"time"
)
// 채널 기반 타임아웃
func fetchWithTimeout(timeout time.Duration) (string, error) {
ch := make(chan string, 1)
go func() {
time.Sleep(2 * time.Second) // 느린 작업 시뮬레이션
ch <- "데이터"
}()
select {
case result := <-ch:
return result, nil
case <-time.After(timeout):
return "", fmt.Errorf("타임아웃: %v", timeout)
}
}
// context 기반 타임아웃
func fetchWithContext(ctx context.Context) (string, error) {
ch := make(chan string, 1)
go func() {
time.Sleep(500 * time.Millisecond)
ch <- "빠른 응답"
}()
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
// 1초 타임아웃 (2초 작업 → 타임아웃)
_, err := fetchWithTimeout(1 * time.Second)
fmt.Println(err) // 타임아웃
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
result, _ := fetchWithContext(ctx)
fmt.Println(result) // 빠른 응답
}context.WithTimeout은 defer cancel()을 반드시 호출하세요. 타이머 리소스가 누수됩니다.
time.After는 매번 새 타이머를 생성합니다. 루프에서 반복 사용하면 메모리 누수가 발생합니다. time.NewTimer를 재사용하세요.
15취소 전파
컨텍스트 취소가 부모에서 자식으로 전파되는 패턴. 중첩된 고루틴 트리를 안전하게 종료합니다.
Go code
package main
import (
"context"
"fmt"
"time"
)
func parentTask(ctx context.Context) {
childCtx, cancel := context.WithCancel(ctx)
defer cancel()
go childTask(childCtx, "A")
go childTask(childCtx, "B")
select {
case <-ctx.Done():
fmt.Println("부모 취소됨 → 자식들도 자동 취소")
case <-time.After(5 * time.Second):
fmt.Println("부모 완료")
}
}
func childTask(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Printf("자식 %s 종료: %v\n", name, ctx.Err())
return
case <-time.After(500 * time.Millisecond):
fmt.Printf("자식 %s 작업 중...\n", name)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(), 2*time.Second,
)
defer cancel()
parentTask(ctx)
time.Sleep(100 * time.Millisecond) // 정리 대기
}부모 context가 취소되면 모든 자식 context도 자동 취소됩니다. 별도의 종료 신호 채널이 필요 없습니다.
자식 context의 cancel을 호출하지 않으면 context가 GC되기 전까지 부모 context에 연결된 상태로 남아 메모리 누수가 발생합니다.
16select 심화
select로 여러 채널 연산을 동시에 대기합니다. default로 논블로킹 동작도 가능합니다.
Go code
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() { time.Sleep(100 * time.Millisecond); ch1 <- "one" }()
go func() { time.Sleep(200 * time.Millisecond); ch2 <- "two" }()
// 먼저 도착하는 것 처리
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
case msg := <-ch2:
fmt.Println("ch2:", msg)
}
}
// 논블로킹 수신
ch := make(chan int, 1)
select {
case v := <-ch:
fmt.Println("수신:", v)
default:
fmt.Println("데이터 없음 (논블로킹)")
}
// nil 채널로 case 비활성화
var active chan int = nil
select {
case <-active: // nil 채널은 영원히 블로킹 → 이 case 무시됨
case <-time.After(100 * time.Millisecond):
fmt.Println("nil 채널은 무시됨")
}
}nil 채널에 대한 select case는 영원히 블로킹되므로, 특정 case를 동적으로 비활성화하는 데 활용할 수 있습니다.
select에서 여러 case가 동시에 준비되면 Go 런타임이 무작위로 하나를 선택합니다. 특정 우선순위가 필요하면 중첩 select를 사용하세요.
17채널 방향과 소유권
채널 방향(chan, )으로 읽기/쓰기 권한을 제한합니다. 데이터 흐름을 명확히 합니다.
Go code
package main
import "fmt"
// 송신 전용: chan<-
func producer(out chan<- int) {
for i := 0; i < 5; i++ {
out <- i
}
close(out) // 생산자가 채널 소유 → 닫기 책임
}
// 수신 전용: <-chan
func consumer(in <-chan int) {
for v := range in {
fmt.Println("수신:", v)
}
}
// 변환: 수신 → 처리 → 송신
func transform(in <-chan int, out chan<- string) {
defer close(out)
for v := range in {
out <- fmt.Sprintf("#%d", v)
}
}
func main() {
nums := make(chan int, 5)
strs := make(chan string, 5)
go producer(nums)
go transform(nums, strs)
for s := range strs {
fmt.Println(s)
}
}채널을 만든 고루틴(생산자)이 닫기 책임을 가집니다. 수신자가 채널을 닫으면 생산자에서 패닉이 발생합니다.
이미 닫힌 채널에 데이터를 보내면 panic: send on closed channel이 발생합니다. 닫기 책임은 반드시 하나의 고루틴에만 부여하세요.
18버퍼 채널
버퍼 채널은 지정된 크기만큼 블로킹 없이 데이터를 저장합니다. 생산자와 소비자의 속도 차이를 완화합니다.
Go code
package main
import "fmt"
func main() {
// 비버퍼: 송수신이 동시에 일어나야 함
unbuf := make(chan int)
go func() { unbuf <- 42 }()
fmt.Println(<-unbuf)
// 버퍼 채널: 버퍼 크기만큼 비동기 전송
buf := make(chan int, 3)
buf <- 1 // 블로킹 없음
buf <- 2
buf <- 3
// buf <- 4 // 버퍼 가득 → 블로킹!
fmt.Println(len(buf), cap(buf)) // 3 3
// 세마포어로 활용
sem := make(chan struct{}, 3) // 최대 3개 동시 실행
for i := 0; i < 10; i++ {
sem <- struct{}{} // 슬롯 획득
go func(id int) {
defer func() { <-sem }() // 슬롯 반환
fmt.Printf("작업 %d 실행\n", id)
}(i)
}
// 모든 슬롯 회수 대기
for i := 0; i < cap(sem); i++ { sem <- struct{}{} }
}버퍼 크기는 생산자/소비자 속도 차이에 맞춰 설정하세요. 너무 크면 메모리 낭비, 너무 작으면 잦은 블로킹입니다.
버퍼 채널이 가득 찬 상태에서 보내면 블로킹됩니다. 고루틴 교착을 방지하려면 select와 default를 사용하세요.
19닫힌 채널 처리
닫힌 채널에서 수신하면 제로 값을 반환합니다. 콤마-ok 패턴으로 닫힘을 감지합니다.
Go code
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 10
ch <- 20
close(ch)
// 콤마-ok 패턴
v, ok := <-ch
fmt.Println(v, ok) // 10 true
v, ok = <-ch
fmt.Println(v, ok) // 20 true
v, ok = <-ch
fmt.Println(v, ok) // 0 false (닫힘)
// range는 자동으로 닫힘 감지
ch2 := make(chan string, 2)
ch2 <- "a"
ch2 <- "b"
close(ch2)
for msg := range ch2 {
fmt.Println(msg)
}
// 닫힌 채널로 브로드캐스트
done := make(chan struct{})
for i := 0; i < 3; i++ {
go func(id int) {
<-done // 모든 고루틴이 동시에 깨어남
fmt.Printf("고루틴 %d 시작\n", id)
}(i)
}
close(done) // 브로드캐스트!
}close(ch)는 대기 중인 모든 수신자를 깨우므로 브로드캐스트 신호로 활용할 수 있습니다.
닫힌 채널에 send하면 패닉, 다시 close해도 패닉입니다. 채널은 한 번만 닫으세요.
20Rate Limiting
time.Ticker와 토큰 버킷으로 요청 속도를 제한합니다.
Go code
package main
import (
"fmt"
"time"
)
// 간단한 레이트 리미터
type RateLimiter struct {
tokens chan struct{}
ticker *time.Ticker
}
func NewRateLimiter(rate int, burst int) *RateLimiter {
rl := &RateLimiter{
tokens: make(chan struct{}, burst),
ticker: time.NewTicker(time.Second / time.Duration(rate)),
}
// 초기 토큰 채우기
for i := 0; i < burst; i++ {
rl.tokens <- struct{}{}
}
// 토큰 보충
go func() {
for range rl.ticker.C {
select {
case rl.tokens <- struct{}{}:
default: // 버킷 가득 참
}
}
}()
return rl
}
func (rl *RateLimiter) Wait() {
<-rl.tokens
}
func (rl *RateLimiter) Stop() {
rl.ticker.Stop()
}
func main() {
limiter := NewRateLimiter(5, 3) // 초당 5개, 버스트 3
defer limiter.Stop()
for i := 0; i < 8; i++ {
limiter.Wait()
fmt.Printf("[%s] 요청 %d 처리\n",
time.Now().Format("15:04:05.000"), i)
}
}실무에서는 golang.org/x/time/rate 패키지의 rate.Limiter를 사용하세요. 토큰 버킷 알고리즘을 정확히 구현합니다.
time.Ticker를 사용 후 Stop()하지 않으면 고루틴이 누수됩니다. 항상 defer ticker.Stop()을 호출하세요.
21세마포어 패턴
버퍼 채널로 세마포어를 구현하여 동시 실행 수를 제한합니다.
Go code
package main
import (
"fmt"
"sync"
"time"
)
type Semaphore struct {
ch chan struct{}
}
func NewSemaphore(max int) *Semaphore {
return &Semaphore{ch: make(chan struct{}, max)}
}
func (s *Semaphore) Acquire() { s.ch <- struct{}{} }
func (s *Semaphore) Release() { <-s.ch }
func main() {
sem := NewSemaphore(3) // 최대 3개 동시 실행
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
sem.Acquire()
defer sem.Release()
fmt.Printf("[%s] 작업 %d 시작\n",
time.Now().Format("05.000"), id)
time.Sleep(500 * time.Millisecond)
}(i)
}
wg.Wait()
fmt.Println("모든 작업 완료")
}세마포어는 데이터베이스 커넥션, API 호출 등 외부 리소스 접근 제한에 효과적입니다.
Acquire와 Release 횟수가 일치하지 않으면 교착 상태가 발생합니다. defer Release()를 습관화하세요.
22뮤텍스 vs 채널
상태 보호에는 뮤텍스, 통신과 조율에는 채널을 사용합니다. 각각의 적합한 사용 사례를 비교합니다.
Go code
package main
import (
"fmt"
"sync"
)
// 뮤텍스: 공유 상태 보호
type MutexCounter struct {
mu sync.Mutex
count int
}
func (c *MutexCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
// 채널: 상태 소유권 전달
type ChannelCounter struct {
inc chan struct{}
get chan int
}
func NewChannelCounter() *ChannelCounter {
c := &ChannelCounter{
inc: make(chan struct{}),
get: make(chan int),
}
go func() { // 단일 고루틴이 상태 소유
count := 0
for {
select {
case <-c.inc:
count++
case c.get <- count:
}
}
}()
return c
}
func main() {
// 뮤텍스 방식
mc := &MutexCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); mc.Inc() }()
}
wg.Wait()
fmt.Println("뮤텍스:", mc.count)
// 채널 방식
cc := NewChannelCounter()
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); cc.inc <- struct{}{} }()
}
wg.Wait()
fmt.Println("채널:", <-cc.get)
}"메모리를 공유하여 통신하지 말고, 통신하여 메모리를 공유하라" — Go 격언. 하지만 단순 카운터에는 뮤텍스가 더 간단합니다.
채널 카운터의 관리 고루틴이 종료되지 않으면 고루틴 누수입니다. 프로그램 생명주기를 고려한 정리 메커니즘을 추가하세요.
23atomic 연산
sync/atomic으로 뮤텍스 없이 원자적 읽기/쓰기를 수행합니다. 단순 카운터에 최적입니다.
Go code
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Add(1)
}()
}
wg.Wait()
fmt.Println("카운터:", counter.Load())
// atomic.Value: 임의 타입 저장
var config atomic.Value
type Config struct {
Debug bool
Level int
}
config.Store(Config{Debug: true, Level: 3})
cfg := config.Load().(Config)
fmt.Printf("설정: %+v\n", cfg)
// CompareAndSwap
var flag atomic.Bool
swapped := flag.CompareAndSwap(false, true)
fmt.Println("CAS 성공:", swapped) // true
swapped = flag.CompareAndSwap(false, true)
fmt.Println("CAS 재시도:", swapped) // false
}Go 1.19+의 atomic.Int64, atomic.Bool 등 타입화된 atomic을 사용하면 타입 안전합니다.
atomic은 단일 변수만 보호합니다. 여러 변수를 함께 업데이트해야 하면 뮤텍스를 사용하세요.
24sync.Once
sync.Once로 초기화 코드를 정확히 한 번만 실행합니다. 싱글턴, 지연 초기화에 사용됩니다.
Go code
package main
import (
"fmt"
"sync"
)
type DB struct {
Name string
}
var (
dbInstance *DB
dbOnce sync.Once
)
func GetDB() *DB {
dbOnce.Do(func() {
fmt.Println("DB 초기화 (한 번만 실행)")
dbInstance = &DB{Name: "PostgreSQL"}
})
return dbInstance
}
func main() {
var wg sync.WaitGroup
// 10개 고루틴이 동시에 호출해도 1번만 초기화
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
db := GetDB()
fmt.Printf("고루틴 %d: %s\n", id, db.Name)
}(i)
}
wg.Wait()
}sync.Once는 Do 내부에서 패닉이 발생해도 "이미 실행됨"으로 간주합니다. Go 1.21+의 sync.OnceFunc은 패닉 시 재시도합니다.
sync.Once를 값으로 복사하면 각 복사본이 독립적으로 동작하여 여러 번 실행됩니다. 포인터로 공유하거나 전역으로 선언하세요.
25sync.Pool
sync.Pool로 임시 객체를 재사용하여 GC 압력을 줄입니다. 빈번한 할당/해제가 있는 곳에 효과적입니다.
Go code
package main
import (
"bytes"
"fmt"
"sync"
)
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func process(data string) string {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // 반드시 초기화 후 반환
bufPool.Put(buf)
}()
buf.WriteString("[처리됨] ")
buf.WriteString(data)
return buf.String()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
result := process(fmt.Sprintf("데이터-%d", id))
fmt.Println(result)
}(i)
}
wg.Wait()
}sync.Pool의 객체는 GC 시 정리될 수 있습니다. 영구 캐시가 아닌 임시 버퍼 재사용에 적합합니다.
Pool에 반환 전 객체를 초기화(Reset)하지 않으면 이전 데이터가 남아 버그가 발생합니다.
26sync.Cond
sync.Cond로 조건 변수를 구현합니다. 특정 조건이 충족될 때까지 고루틴을 대기시킵니다.
Go code
package main
import (
"fmt"
"sync"
"time"
)
type Queue struct {
items []int
cond *sync.Cond
}
func NewQueue() *Queue {
return &Queue{cond: sync.NewCond(&sync.Mutex{})}
}
func (q *Queue) Enqueue(item int) {
q.cond.L.Lock()
defer q.cond.L.Unlock()
q.items = append(q.items, item)
q.cond.Signal() // 대기 중인 하나를 깨움
}
func (q *Queue) Dequeue() int {
q.cond.L.Lock()
defer q.cond.L.Unlock()
for len(q.items) == 0 {
q.cond.Wait() // 조건 대기 (잠금 해제 → 대기 → 잠금 획득)
}
item := q.items[0]
q.items = q.items[1:]
return item
}
func main() {
q := NewQueue()
// 소비자
go func() {
for i := 0; i < 5; i++ {
val := q.Dequeue()
fmt.Println("소비:", val)
}
}()
// 생산자
for i := 1; i <= 5; i++ {
time.Sleep(200 * time.Millisecond)
q.Enqueue(i)
fmt.Println("생산:", i)
}
time.Sleep(100 * time.Millisecond)
}Wait()는 반드시 for 루프 안에서 조건을 재확인하며 호출해야 합니다. Spurious wakeup이 발생할 수 있습니다.
Wait()를 if 문으로 감싸면 spurious wakeup 시 조건이 충족되지 않은 상태에서 진행됩니다. 항상 for를 사용하세요.
27고루틴 누수 방지
고루틴이 종료되지 않고 영원히 대기하는 누수를 방지합니다. context와 done 채널을 활용합니다.
Go code
package main
import (
"context"
"fmt"
"time"
)
// 나쁜 예: 고루틴 누수
func leakyFunc() <-chan int {
ch := make(chan int)
go func() {
for i := 0; ; i++ {
ch <- i // 소비자가 없으면 영원히 블로킹
}
}()
return ch
}
// 좋은 예: context로 취소
func safeFunc(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; ; i++ {
select {
case ch <- i:
case <-ctx.Done():
fmt.Println("고루틴 정상 종료")
return
}
}
}()
return ch
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(), 500*time.Millisecond,
)
defer cancel()
ch := safeFunc(ctx)
for i := 0; i < 3; i++ {
fmt.Println(<-ch)
}
cancel() // 명시적 취소
time.Sleep(100 * time.Millisecond)
fmt.Println("완료")
}모든 고루틴에는 종료 조건이 있어야 합니다. context.Context를 전달하여 취소 가능하게 만드세요.
고루틴 누수는 메모리와 CPU를 지속적으로 소비합니다. runtime.NumGoroutine()으로 모니터링하세요.
정리하며
- 채널은 보내는 쪽이 닫고, 생산자가 여럿이면 WaitGroup 이후 한 번만 닫습니다
- context는 취소 신호일 뿐이라 루프 안에서 Done을 확인해야 실제로 멈춥니다
- 시작한 고루틴마다 종료 조건을 명시해 두어야 누수가 쌓이지 않습니다
- 공유 상태 보호는 뮤텍스, 소유권 이전은 채널로 나눠 판단합니다
더 깊이 들어가고 싶다면 Go 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.