Go의 고급 동시성 패턴인 select 문, context.Context를 활용한 취소/타임아웃, errgroup, 팬아웃/팬인, 파이프라인, 워커 풀, 레이트 리미팅 패턴을 다룹니다.
select 문은 여러 채널 연산을 동시에 대기하며, 준비된 케이스 중 하나를 실행합니다. switch와 비슷하지만 채널 전용으로 설계되었습니다.
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 {
case msg := <-ch:
fmt.Println("수신:", msg)
default:
fmt.Println("채널에 데이터 없음, 다른 작업 수행")
}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)
}
}time.After는 호출될 때마다 새로운 타이머를 생성합니다. 루프 내에서 반복 사용하면 타이머가 GC 될 때까지 메모리를 점유하므로, 빈번한 타임아웃 처리에는 time.NewTimer를 직접 관리하거나 context.WithTimeout을 사용하세요.
context.Context는 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)
}
}
}// 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 핸들러에서 요청 처리 타임아웃을 설정하거나, 데이터베이스 쿼리에 타임아웃을 부여할 때 널리 사용됩니다.
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를 통해 요청 범위의 값을 전달할 수 있습니다. 단, 이 기능은 남용되기 쉬우므로 제한적으로 사용해야 합니다.
// 타입 안전한 키 정의
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)
}context.WithValue는 요청 ID, 인증 정보 등 요청 범위의 메타데이터에만 사용하세요. 함수의 입력 파라미터를 context로 전달하는 것은 안티패턴입니다. 비즈니스 로직에 필요한 값은 명시적인 함수 매개변수로 전달하는 것이 올바릅니다.
golang.org/x/sync/errgroup 패키지는 고루틴 그룹의 에러를 자동으로 전파하고, 첫 번째 에러 발생 시 나머지 고루틴을 취소하는 기능을 제공합니다.
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가 자동으로 취소되어 다른 고루틴도 즉시 종료할 수 있습니다.
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)은 여러 고루틴의 결과를 하나로 합치는 것입니다.
// 팬인: 여러 채널을 하나로 합침
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)은 데이터 처리를 여러 단계로 나누어 각 단계를 고루틴으로 연결하는 패턴입니다.
// 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)은 고정된 수의 고루틴이 작업 큐에서 작업을 가져와 처리하는 패턴입니다. 리소스 사용량을 제어하면서 동시성을 활용할 수 있습니다.
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 호출이나 리소스 보호에 필수적입니다.
// 시간 기반 레이트 리미팅
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)
}
}golang.org/x/time/rate 패키지는 토큰 버킷(Token Bucket) 알고리즘을 구현합니다. 순간적인 버스트 트래픽을 허용하면서도 평균 처리율을 제한할 수 있어, 실무에서 가장 널리 사용되는 레이트 리미팅 방식입니다.
이번 장에서 살펴본 핵심 내용을 정리합니다.
5장에서는 에러 처리 패턴과 베스트 프랙티스를 다룹니다. errors.Is/errors.As, 에러 래핑, 커스텀 에러 타입, 센티널 에러, 그리고 panic/recover의 올바른 사용법을 살펴봅니다.
이 글이 도움이 되셨나요?
Go의 동시성 핵심인 고루틴의 내부 구조, 버퍼드/언버퍼드 채널, sync 패키지의 WaitGroup과 Mutex, 고루틴 생명주기 관리와 일반적인 동시성 패턴을 다룹니다.
Go의 에러 값 철학, errors.Is/As를 활용한 에러 검사, fmt.Errorf와 %w를 사용한 에러 래핑, 커스텀 에러 타입, 센티널 에러, panic/recover의 올바른 사용법을 다룹니다.
Go의 표준 라이브러리 net/http부터 Gin, Chi, Fiber, Echo까지 주요 웹 프레임워크의 아키텍처, 성능 특성, 미들웨어 구조를 비교하고 프로젝트별 선택 기준을 제시합니다.