본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout
TechProjectsBooksAbout
© 2026 Kreath. All rights reserved.
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 3장: 고루틴과 채널 - Go의 동시성 모델
2026년 8월 13일·프로그래밍·

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

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

15분621자8개 섹션
concurrencyperformancedesign-patternstestinginfrastructure
공유
go-backend3 / 11
1234567891011
이전2장: Go 타입 시스템과 인터페이스다음4장: 동시성 패턴 심화 - select, context, errgroup

학습 목표

  • 고루틴과 OS 스레드의 차이점을 이해한다
  • 버퍼드 채널과 언버퍼드 채널의 동작 방식을 구분한다
  • sync 패키지의 WaitGroup, Mutex를 활용할 수 있다
  • 고루틴 누수(Goroutine Leak)를 방지하는 방법을 익힌다

고루틴의 내부 구조

고루틴 vs OS 스레드

고루틴(Goroutine)은 Go 런타임이 관리하는 경량 실행 단위입니다. OS 스레드와 비교했을 때 근본적인 차이가 있습니다.

특성OS 스레드고루틴
초기 스택 크기1-8MB2KB
생성 비용높음 (커널 호출)낮음 (사용자 공간)
컨텍스트 스위칭커널 모드 전환사용자 공간에서 처리
동시 실행 가능 수수천 개수십만 개
스케줄링OS 스케줄러Go 런타임 스케줄러

GMP 스케줄링 모델

Go 런타임은 GMP 모델로 고루틴을 스케줄링합니다.

  • G(Goroutine): 실행할 고루틴
  • M(Machine): OS 스레드. 실제 CPU에서 실행되는 단위
  • P(Processor): 논리 프로세서. GOMAXPROCS로 개수를 설정하며 기본값은 CPU 코어 수

P는 로컬 실행 큐(Local Run Queue)를 가지고 있으며, 고루틴이 생성되면 현재 P의 로컬 큐에 추가됩니다. 한 P의 큐가 비면 다른 P의 큐에서 고루틴을 가져오는 워크 스틸링(Work Stealing) 메커니즘이 동작합니다.

고루틴 생성

고루틴 생성은 go 키워드 하나로 이루어집니다.

goroutine-creation.go
go
func main() {
    // 익명 함수로 고루틴 생성
    go func() {
        fmt.Println("고루틴에서 실행")
    }()
 
    // 기명 함수로 고루틴 생성
    go processData(data)
 
    // 메서드 호출도 가능
    go server.HandleRequest(req)
}
Warning

main 함수가 종료되면 실행 중인 모든 고루틴이 즉시 종료됩니다. 고루틴의 완료를 기다리는 메커니즘(채널, WaitGroup 등)이 반드시 필요합니다.


채널 -- 고루틴 간 통신

Go의 동시성 모델은 CSP(Communicating Sequential Processes) 이론에 기반합니다. "메모리를 공유하여 통신하지 말고, 통신하여 메모리를 공유하라"가 핵심 원칙입니다.

언버퍼드 채널

언버퍼드 채널(Unbuffered Channel)은 송신자와 수신자가 동시에 준비되어야 데이터가 전달됩니다. 동기적인 핸드셰이크와 같습니다.

unbuffered-channel.go
go
ch := make(chan string) // 버퍼 없는 채널
 
go func() {
    ch <- "hello" // 수신자가 준비될 때까지 블로킹
}()
 
msg := <-ch // 송신자가 보낼 때까지 블로킹
fmt.Println(msg)

버퍼드 채널

버퍼드 채널(Buffered Channel)은 지정된 용량만큼 데이터를 버퍼에 저장할 수 있습니다. 버퍼가 가득 차면 송신이 블로킹되고, 버퍼가 비면 수신이 블로킹됩니다.

buffered-channel.go
go
ch := make(chan int, 3) // 버퍼 크기 3
 
ch <- 1 // 블로킹 없음
ch <- 2 // 블로킹 없음
ch <- 3 // 블로킹 없음
// ch <- 4 // 여기서 블로킹! 버퍼 가득 참
 
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2

채널 방향 제한

함수 매개변수에서 채널의 방향을 제한하면 안전성이 높아집니다.

channel-direction.go
go
// 송신 전용 채널
func produce(ch chan<- int) {
    for i := 0; i < 10; i++ {
        ch <- i
    }
    close(ch) // 송신 완료 후 채널 닫기
}
 
// 수신 전용 채널
func consume(ch <-chan int) {
    for v := range ch { // 채널이 닫힐 때까지 수신
        fmt.Println(v)
    }
}
 
func main() {
    ch := make(chan int, 5)
    go produce(ch)
    consume(ch)
}

채널 사용 패턴

done-channel-pattern.go
go
// 완료 신호 패턴
func worker(done chan<- struct{}) {
    defer func() { done <- struct{}{} }()
    // 작업 수행...
}
 
func main() {
    done := make(chan struct{})
    go worker(done)
    <-done // 작업 완료 대기
}
generator-pattern.go
go
// 제너레이터 패턴
func fibonacci(n int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        a, b := 0, 1
        for i := 0; i < n; i++ {
            ch <- a
            a, b = b, a+b
        }
    }()
    return ch
}
 
for v := range fibonacci(10) {
    fmt.Println(v)
}
Tip

채널을 반환하는 함수는 채널의 생성과 닫기를 모두 함수 내부에서 관리합니다. 이렇게 하면 채널의 생명주기가 명확해지고 누수를 방지할 수 있습니다.


sync 패키지

채널이 Go 동시성의 주요 도구이지만, 모든 상황에 적합한 것은 아닙니다. 공유 상태에 대한 접근 제어가 필요할 때는 sync 패키지를 사용합니다.

WaitGroup

WaitGroup은 여러 고루틴의 완료를 기다리는 가장 간단한 방법입니다.

waitgroup-example.go
go
func fetchAll(urls []string) []Response {
    var (
        wg      sync.WaitGroup
        mu      sync.Mutex
        results []Response
    )
 
    for _, url := range urls {
        wg.Add(1) // 카운터 증가
        go func(u string) {
            defer wg.Done() // 카운터 감소
 
            resp, err := http.Get(u)
            mu.Lock()
            results = append(results, Response{URL: u, Resp: resp, Err: err})
            mu.Unlock()
        }(url)
    }
 
    wg.Wait() // 모든 고루틴 완료 대기
    return results
}
Warning

wg.Add(1)는 반드시 고루틴을 시작하기 전에 호출해야 합니다. 고루틴 내부에서 호출하면 wg.Wait()가 모든 고루틴이 시작되기 전에 반환될 수 있습니다.

Mutex

뮤텍스(Mutex, Mutual Exclusion)는 공유 자원에 대한 동시 접근을 제어합니다.

mutex-example.go
go
type SafeCounter struct {
    mu    sync.Mutex
    count 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.Lock()
    defer c.mu.Unlock()
    return c.count[key]
}

RWMutex

읽기가 쓰기보다 훨씬 빈번한 경우, RWMutex(Read-Write Mutex)를 사용하면 성능을 개선할 수 있습니다. 여러 고루틴이 동시에 읽기를 수행할 수 있으며, 쓰기 시에만 배타적 잠금을 획득합니다.

rwmutex-example.go
go
type ConfigStore struct {
    mu     sync.RWMutex
    config map[string]string
}
 
func (cs *ConfigStore) Get(key string) (string, bool) {
    cs.mu.RLock()         // 읽기 잠금 -- 동시 읽기 허용
    defer cs.mu.RUnlock()
    v, ok := cs.config[key]
    return v, ok
}
 
func (cs *ConfigStore) Set(key, value string) {
    cs.mu.Lock()          // 쓰기 잠금 -- 배타적 접근
    defer cs.mu.Unlock()
    cs.config[key] = value
}

Once

sync.Once는 특정 작업을 정확히 한 번만 실행하도록 보장합니다. 싱글턴 패턴이나 지연 초기화에 유용합니다.

once-example.go
go
var (
    dbOnce sync.Once
    db     *sql.DB
)
 
func GetDB() *sql.DB {
    dbOnce.Do(func() {
        var err error
        db, err = sql.Open("postgres", connStr)
        if err != nil {
            log.Fatal(err)
        }
    })
    return db
}

고루틴 생명주기 관리

고루틴 누수

**고루틴 누수(Goroutine Leak)**는 고루틴이 영원히 블로킹 상태에 머무르는 현상입니다. 이는 메모리 누수로 이어지며, 장시간 운영되는 서버에서 심각한 문제를 야기합니다.

goroutine-leak.go
go
// 누수 발생 -- ch에서 읽는 소비자가 없으면 고루틴이 영원히 블로킹
func leakyFunction() {
    ch := make(chan int)
    go func() {
        result := heavyComputation()
        ch <- result // 수신자가 없으면 영원히 블로킹
    }()
    // ch를 읽지 않고 함수 종료 -- 고루틴 누수!
}

누수 방지 전략

고루틴 누수를 방지하는 핵심 전략은 모든 고루틴이 종료 조건을 가지도록 설계하는 것입니다.

prevent-leak.go
go
func safeFunction(ctx context.Context) (int, error) {
    ch := make(chan int, 1) // 버퍼드 채널로 블로킹 방지
 
    go func() {
        result := heavyComputation()
        ch <- result
    }()
 
    select {
    case result := <-ch:
        return result, nil
    case <-ctx.Done():
        return 0, ctx.Err() // 타임아웃 또는 취소
    }
}
Danger

고루틴 누수는 런타임에 에러를 발생시키지 않기 때문에 발견하기 어렵습니다. runtime.NumGoroutine()을 모니터링하거나, Uber의 goleak 라이브러리를 테스트에서 사용하여 누수를 감지할 수 있습니다.


실전 예제: 동시 HTTP 요청 처리

여러 외부 API를 동시에 호출하고 결과를 합산하는 실전적인 예제입니다.

concurrent-http.go
go
type APIResponse struct {
    Source string
    Data   json.RawMessage
    Err    error
}
 
func FetchFromMultipleSources(ctx context.Context, urls map[string]string) []APIResponse {
    var wg sync.WaitGroup
    results := make(chan APIResponse, len(urls))
 
    for source, url := range urls {
        wg.Add(1)
        go func(src, u string) {
            defer wg.Done()
 
            req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
            if err != nil {
                results <- APIResponse{Source: src, Err: err}
                return
            }
 
            resp, err := http.DefaultClient.Do(req)
            if err != nil {
                results <- APIResponse{Source: src, Err: err}
                return
            }
            defer resp.Body.Close()
 
            body, err := io.ReadAll(resp.Body)
            results <- APIResponse{
                Source: src,
                Data:   json.RawMessage(body),
                Err:    err,
            }
        }(source, url)
    }
 
    // 별도 고루틴에서 모든 작업 완료 후 채널 닫기
    go func() {
        wg.Wait()
        close(results)
    }()
 
    var responses []APIResponse
    for r := range results {
        responses = append(responses, r)
    }
    return responses
}

이 패턴은 WaitGroup과 채널을 조합하여 동시 작업의 결과를 안전하게 수집합니다. context를 전달하여 타임아웃이나 취소도 지원합니다.


정리

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

  • 고루틴은 2KB 스택으로 시작하는 경량 실행 단위이며, GMP 모델로 스케줄링됩니다
  • 언버퍼드 채널은 동기적 통신을, 버퍼드 채널은 비동기적 통신을 제공합니다
  • WaitGroup은 고루틴 완료 대기, Mutex/RWMutex는 공유 자원 보호에 사용됩니다
  • 모든 고루틴에는 명확한 종료 조건이 있어야 하며, 고루틴 누수는 반드시 방지해야 합니다

다음 장 미리보기

4장에서는 동시성 패턴 심화를 다룹니다. select 문, context.Context를 활용한 취소와 타임아웃, errgroup, 팬아웃/팬인, 파이프라인, 워커 풀 패턴 등 실전에서 필수적인 고급 동시성 패턴을 살펴봅니다.

이 글이 도움이 되셨나요?

관련 글

프로그래밍

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

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

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

2장: Go 타입 시스템과 인터페이스

Go의 구조체, 메서드, 암묵적 인터페이스 만족, 제네릭(타입 파라미터), 타입 제약 조건, 임베딩을 통한 합성 패턴을 체계적으로 다룹니다.

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

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

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

2026년 8월 18일·15분
이전 글2장: Go 타입 시스템과 인터페이스
다음 글4장: 동시성 패턴 심화 - select, context, errgroup

댓글

목차

약 15분 남음
  • 학습 목표
  • 고루틴의 내부 구조
    • 고루틴 vs OS 스레드
    • GMP 스케줄링 모델
    • 고루틴 생성
  • 채널 -- 고루틴 간 통신
    • 언버퍼드 채널
    • 버퍼드 채널
    • 채널 방향 제한
    • 채널 사용 패턴
  • sync 패키지
    • WaitGroup
    • Mutex
    • RWMutex
    • Once
  • 고루틴 생명주기 관리
    • 고루틴 누수
    • 누수 방지 전략
  • 실전 예제: 동시 HTTP 요청 처리
  • 정리
  • 다음 장 미리보기