PHpullh
심층 가이드/Go/고루틴/채널

GO · 심층 가이드

Go 고루틴/채널 완전 정리

고루틴과 채널의 기본기부터 워커 풀, 팬아웃/팬인, errgroup, 취소 전파, sync.Pool, 누수 방지까지 Go 동시성 27가지 주제를 실무 순서로 묶었습니다.

주제 27개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

고루틴을 만드는 비용이 워낙 싸다 보니 사람들은 만드는 법부터 배웁니다. 정작 어려운 건 끝내는 법입니다. 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 패키지 — 취소 &amp; 타임아웃

고루틴의 생명주기를 제어하는 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 &amp; 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.WaitGroupAdd()를 고루틴 안에서 호출하면 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.WithTimeoutdefer 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{}{} }
}
알아두면 좋은 점

버퍼 크기는 생산자/소비자 속도 차이에 맞춰 설정하세요. 너무 크면 메모리 낭비, 너무 작으면 잦은 블로킹입니다.

자주 하는 실수

버퍼 채널이 가득 찬 상태에서 보내면 블로킹됩니다. 고루틴 교착을 방지하려면 selectdefault를 사용하세요.

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 호출 등 외부 리소스 접근 제한에 효과적입니다.

자주 하는 실수

AcquireRelease 횟수가 일치하지 않으면 교착 상태가 발생합니다. 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.OnceDo 내부에서 패닉이 발생해도 "이미 실행됨"으로 간주합니다. 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고루틴 누수 방지

고루틴이 종료되지 않고 영원히 대기하는 누수를 방지합니다. contextdone 채널을 활용합니다.

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 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.