본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout
TechProjectsBooksAbout
© 2026 Kreath. All rights reserved.
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 4장: 동시성 패턴 심화 - select, context, errgroup
2026년 8월 15일·프로그래밍·

4장: 동시성 패턴 심화 - select, context, errgroup

Go의 고급 동시성 패턴인 select 문, context.Context를 활용한 취소/타임아웃, errgroup, 팬아웃/팬인, 파이프라인, 워커 풀, 레이트 리미팅 패턴을 다룹니다.

14분869자10개 섹션
concurrencyperformancedesign-patternstestinginfrastructure
공유
go-backend4 / 11
1234567891011
이전3장: 고루틴과 채널 - Go의 동시성 모델다음5장: 에러 처리 패턴과 베스트 프랙티스

학습 목표

  • select 문을 활용한 다중 채널 처리를 이해한다
  • context.Context의 취소, 타임아웃, 값 전달 메커니즘을 활용할 수 있다
  • errgroup을 사용한 에러 전파 패턴을 적용할 수 있다
  • 팬아웃/팬인, 파이프라인, 워커 풀 패턴을 구현할 수 있다

select 문

기본 동작

select 문은 여러 채널 연산을 동시에 대기하며, 준비된 케이스 중 하나를 실행합니다. switch와 비슷하지만 채널 전용으로 설계되었습니다.

select-basic.go
go
func process(ctx context.Context, dataCh <-chan Data, errorCh <-chan error) {
    for {
        select {
        case data := <-dataCh:
            handleData(data)
        case err := <-errorCh:
            handleError(err)
        case <-ctx.Done():
            fmt.Println("작업 취소됨:", ctx.Err())
            return
        }
    }
}

비블로킹 채널 연산

default 케이스를 추가하면 모든 채널이 준비되지 않았을 때 블로킹 없이 진행할 수 있습니다.

select-non-blocking.go
go
select {
case msg := <-ch:
    fmt.Println("수신:", msg)
default:
    fmt.Println("채널에 데이터 없음, 다른 작업 수행")
}

타임아웃 패턴

select-timeout.go
go
func fetchWithTimeout(url string, timeout time.Duration) ([]byte, error) {
    result := make(chan []byte, 1)
    errCh := make(chan error, 1)
 
    go func() {
        resp, err := http.Get(url)
        if err != nil {
            errCh <- err
            return
        }
        defer resp.Body.Close()
        body, err := io.ReadAll(resp.Body)
        if err != nil {
            errCh <- err
            return
        }
        result <- body
    }()
 
    select {
    case data := <-result:
        return data, nil
    case err := <-errCh:
        return nil, err
    case <-time.After(timeout):
        return nil, fmt.Errorf("요청 타임아웃: %s", timeout)
    }
}
Warning

time.After는 호출될 때마다 새로운 타이머를 생성합니다. 루프 내에서 반복 사용하면 타이머가 GC 될 때까지 메모리를 점유하므로, 빈번한 타임아웃 처리에는 time.NewTimer를 직접 관리하거나 context.WithTimeout을 사용하세요.


context.Context

컨텍스트의 역할

context.Context는 Go 동시성 프로그래밍의 핵심 메커니즘으로, 세 가지 주요 기능을 제공합니다.

취소 전파

부모 컨텍스트가 취소되면 모든 자식 컨텍스트도 함께 취소됩니다. 이를 통해 고루틴 트리 전체에 취소 신호를 전파할 수 있습니다.

context-cancellation.go
go
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 함수 종료 시 모든 하위 작업 취소
 
    go worker(ctx, "worker-1")
    go worker(ctx, "worker-2")
    go worker(ctx, "worker-3")
 
    time.Sleep(3 * time.Second)
    cancel() // 모든 워커에 취소 신호 전파
    time.Sleep(time.Second) // 워커들이 정리할 시간 확보
}
 
func worker(ctx context.Context, name string) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("%s: 종료 (사유: %v)\n", name, ctx.Err())
            return
        default:
            fmt.Printf("%s: 작업 수행 중\n", name)
            time.Sleep(time.Second)
        }
    }
}

타임아웃과 데드라인

context-timeout.go
go
// 3초 타임아웃
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
 
// 특정 시각까지
deadline := time.Now().Add(10 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

실전에서는 HTTP 핸들러에서 요청 처리 타임아웃을 설정하거나, 데이터베이스 쿼리에 타임아웃을 부여할 때 널리 사용됩니다.

context-db-timeout.go
go
func (r *UserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
    // 쿼리 타임아웃을 별도로 설정
    queryCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()
 
    var user User
    err := r.db.QueryRowContext(queryCtx,
        "SELECT id, name, email FROM users WHERE id = $1", id,
    ).Scan(&user.ID, &user.Name, &user.Email)
 
    if err != nil {
        return nil, fmt.Errorf("사용자 조회 실패: %w", err)
    }
    return &user, nil
}

값 전달

context.WithValue를 통해 요청 범위의 값을 전달할 수 있습니다. 단, 이 기능은 남용되기 쉬우므로 제한적으로 사용해야 합니다.

context-values.go
go
// 타입 안전한 키 정의
type contextKey string
 
const (
    requestIDKey contextKey = "requestID"
    userIDKey    contextKey = "userID"
)
 
// 미들웨어에서 값 설정
func RequestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), requestIDKey, uuid.New().String())
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
 
// 핸들러에서 값 추출
func handler(w http.ResponseWriter, r *http.Request) {
    requestID, _ := r.Context().Value(requestIDKey).(string)
    slog.Info("요청 처리", "requestID", requestID)
}
Tip

context.WithValue는 요청 ID, 인증 정보 등 요청 범위의 메타데이터에만 사용하세요. 함수의 입력 파라미터를 context로 전달하는 것은 안티패턴입니다. 비즈니스 로직에 필요한 값은 명시적인 함수 매개변수로 전달하는 것이 올바릅니다.


errgroup

golang.org/x/sync/errgroup 패키지는 고루틴 그룹의 에러를 자동으로 전파하고, 첫 번째 에러 발생 시 나머지 고루틴을 취소하는 기능을 제공합니다.

errgroup-example.go
go
import "golang.org/x/sync/errgroup"
 
func FetchAllData(ctx context.Context) (*Dashboard, error) {
    g, ctx := errgroup.WithContext(ctx)
 
    var users []User
    var orders []Order
    var metrics Metrics
 
    g.Go(func() error {
        var err error
        users, err = fetchUsers(ctx)
        return err
    })
 
    g.Go(func() error {
        var err error
        orders, err = fetchOrders(ctx)
        return err
    })
 
    g.Go(func() error {
        var err error
        metrics, err = fetchMetrics(ctx)
        return err
    })
 
    // 모든 고루틴 완료 대기, 첫 번째 에러 반환
    if err := g.Wait(); err != nil {
        return nil, fmt.Errorf("대시보드 데이터 로드 실패: %w", err)
    }
 
    return &Dashboard{Users: users, Orders: orders, Metrics: metrics}, nil
}

errgroup.WithContext로 생성하면, 하나의 고루틴이 에러를 반환했을 때 ctx가 자동으로 취소되어 다른 고루틴도 즉시 종료할 수 있습니다.

errgroup과 동시성 제한

errgroup-limit.go
go
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // 동시에 최대 10개 고루틴만 실행
 
for _, url := range urls {
    url := url // Go 1.22 미만에서 필요
    g.Go(func() error {
        return processURL(ctx, url)
    })
}
 
if err := g.Wait(); err != nil {
    return err
}

팬아웃/팬인 패턴

팬아웃(Fan-out)은 하나의 입력을 여러 고루틴으로 분배하는 것이고, 팬인(Fan-in)은 여러 고루틴의 결과를 하나로 합치는 것입니다.

fan-out-fan-in.go
go
// 팬인: 여러 채널을 하나로 합침
func merge[T any](ctx context.Context, channels ...<-chan T) <-chan T {
    var wg sync.WaitGroup
    merged := make(chan T)
 
    output := func(ch <-chan T) {
        defer wg.Done()
        for v := range ch {
            select {
            case merged <- v:
            case <-ctx.Done():
                return
            }
        }
    }
 
    wg.Add(len(channels))
    for _, ch := range channels {
        go output(ch)
    }
 
    go func() {
        wg.Wait()
        close(merged)
    }()
 
    return merged
}

파이프라인 패턴

파이프라인(Pipeline)은 데이터 처리를 여러 단계로 나누어 각 단계를 고루틴으로 연결하는 패턴입니다.

pipeline-pattern.go
go
// 1단계: 데이터 생성
func generate(ctx context.Context, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            select {
            case out <- n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}
 
// 2단계: 제곱 계산
func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            select {
            case out <- n * n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}
 
// 3단계: 필터링
func filter(ctx context.Context, in <-chan int, predicate func(int) bool) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            if predicate(n) {
                select {
                case out <- n:
                case <-ctx.Done():
                    return
                }
            }
        }
    }()
    return out
}
 
// 파이프라인 조립
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
 
    // generate -> square -> filter(짝수만) 파이프라인
    nums := generate(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    squared := square(ctx, nums)
    even := filter(ctx, squared, func(n int) bool { return n%2 == 0 })
 
    for result := range even {
        fmt.Println(result) // 4, 16, 36, 64, 100
    }
}

워커 풀 패턴

워커 풀(Worker Pool)은 고정된 수의 고루틴이 작업 큐에서 작업을 가져와 처리하는 패턴입니다. 리소스 사용량을 제어하면서 동시성을 활용할 수 있습니다.

worker-pool.go
go
type Job struct {
    ID   int
    Data string
}
 
type Result struct {
    JobID int
    Output string
    Err   error
}
 
func WorkerPool(ctx context.Context, numWorkers int, jobs <-chan Job) <-chan Result {
    results := make(chan Result, numWorkers)
 
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(workerID int) {
            defer wg.Done()
            for job := range jobs {
                select {
                case <-ctx.Done():
                    return
                default:
                    result := processJob(job)
                    results <- result
                }
            }
        }(i)
    }
 
    go func() {
        wg.Wait()
        close(results)
    }()
 
    return results
}

레이트 리미팅

레이트 리미팅(Rate Limiting)은 단위 시간당 처리량을 제한하는 패턴입니다. 외부 API 호출이나 리소스 보호에 필수적입니다.

rate-limiting.go
go
// 시간 기반 레이트 리미팅
func rateLimitedProcessor(ctx context.Context, items <-chan Item) {
    // 초당 10개 처리
    limiter := time.NewTicker(100 * time.Millisecond)
    defer limiter.Stop()
 
    for item := range items {
        select {
        case <-limiter.C:
            process(item)
        case <-ctx.Done():
            return
        }
    }
}
 
// 토큰 버킷 -- golang.org/x/time/rate 패키지
func tokenBucketExample(ctx context.Context) {
    // 초당 10개 요청, 버스트 최대 30개
    limiter := rate.NewLimiter(rate.Limit(10), 30)
 
    for i := 0; i < 100; i++ {
        if err := limiter.Wait(ctx); err != nil {
            return // context 취소 시
        }
        makeAPICall(i)
    }
}
Info

golang.org/x/time/rate 패키지는 토큰 버킷(Token Bucket) 알고리즘을 구현합니다. 순간적인 버스트 트래픽을 허용하면서도 평균 처리율을 제한할 수 있어, 실무에서 가장 널리 사용되는 레이트 리미팅 방식입니다.


정리

이번 장에서 살펴본 핵심 내용을 정리합니다.

  • select는 다중 채널 연산을 비결정적으로 처리하며, 타임아웃과 취소 신호 수신에 핵심적입니다
  • context.Context는 취소 전파, 타임아웃, 요청 범위 값 전달의 세 가지 역할을 수행합니다
  • errgroup은 고루틴 그룹의 에러 전파와 동시성 제한을 간결하게 처리합니다
  • 팬아웃/팬인은 작업 분배와 결과 수집, 파이프라인은 단계별 데이터 처리에 적합합니다
  • 워커 풀로 리소스 사용량을 제어하고, 레이트 리미팅으로 처리율을 제한합니다

다음 장 미리보기

5장에서는 에러 처리 패턴과 베스트 프랙티스를 다룹니다. errors.Is/errors.As, 에러 래핑, 커스텀 에러 타입, 센티널 에러, 그리고 panic/recover의 올바른 사용법을 살펴봅니다.

이 글이 도움이 되셨나요?

관련 글

프로그래밍

3장: 고루틴과 채널 - Go의 동시성 모델

Go의 동시성 핵심인 고루틴의 내부 구조, 버퍼드/언버퍼드 채널, sync 패키지의 WaitGroup과 Mutex, 고루틴 생명주기 관리와 일반적인 동시성 패턴을 다룹니다.

2026년 8월 13일·15분
프로그래밍

5장: 에러 처리 패턴과 베스트 프랙티스

Go의 에러 값 철학, errors.Is/As를 활용한 에러 검사, fmt.Errorf와 %w를 사용한 에러 래핑, 커스텀 에러 타입, 센티널 에러, panic/recover의 올바른 사용법을 다룹니다.

2026년 8월 18일·15분
프로그래밍

6장: 웹 프레임워크 비교 - Gin, Chi, Fiber, Echo

Go의 표준 라이브러리 net/http부터 Gin, Chi, Fiber, Echo까지 주요 웹 프레임워크의 아키텍처, 성능 특성, 미들웨어 구조를 비교하고 프로젝트별 선택 기준을 제시합니다.

2026년 8월 20일·12분
이전 글3장: 고루틴과 채널 - Go의 동시성 모델
다음 글5장: 에러 처리 패턴과 베스트 프랙티스

댓글

목차

약 14분 남음
  • 학습 목표
  • select 문
    • 기본 동작
    • 비블로킹 채널 연산
    • 타임아웃 패턴
  • context.Context
    • 컨텍스트의 역할
    • 취소 전파
    • 타임아웃과 데드라인
    • 값 전달
  • errgroup
    • errgroup과 동시성 제한
  • 팬아웃/팬인 패턴
  • 파이프라인 패턴
  • 워커 풀 패턴
  • 레이트 리미팅
  • 정리
  • 다음 장 미리보기