[Go 2주 완성 #06] Day 10~11: 고루틴과 채널 - 동시성 프로그래밍의 혁명
이 글의 핵심
고루틴 생성·채널 송수신·버퍼 채널·select·WaitGroup으로 동시성을 구현합니다. C++ 스레드·뮤텍스와 대비해 Go의 통신 기반 모델을 익힙니다. Day 10~11입니다.
시리즈 안내
📚 Go 2주 완성 시리즈 #06 | 전체 목차 보기
이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 10~11 내용입니다.
이전: #05 에러 처리 ← | → 다음: #07 테스팅
들어가며: 경량 동시성의 세계
C++에서 std::thread로 스레드를 만들면 스레드당 1~8MB의 스택이 필요해, 수백 개만 넘어도 부담이 큽니다. Go의 고루틴은 수 KB 수준으로 시작해 필요할 때 스택이 늘어나므로, 경량 작업자를 수만 개까지 띄우기 쉽습니다.
채널은 고루틴 사이에서 값을 주고받는 파이프입니다. 공유 메모리를 직접 만지기보다, 파이프로 데이터를 흘려보내며 동기화하는 방식이 Go의 동시성 모델과 잘 맞습니다.
Rust의 채널·Arc/Mutex와 자주 비교되고, Kotlin 코루틴은 스레드 풀·Channel API로 비슷한 목표를 다른 문법으로 풉니다. 전통적인 OS 스레드 API는 Java Thread 글과 나란히 보면 “무겁다”는 느낌의 기준이 잡힙니다.
이 글에서 배울 내용:
- 고루틴: 경량 작업자로 동시 작업 나누기
- 채널: 파이프로 안전하게 통신하기
- select: 여러 채널 중 준비된 쪽 처리하기
- 동시성 패턴: 워커 풀, 파이프라인
실무에서의 체감
C++ 위주로 서버를 다루던 환경에서 Go를 도입할 때 흔히 드는 인상은 문법과 동시성 모델이 단순해 보인다는 점입니다. 다만 프로덕션에서 쓰면 그 단순함이 빌드·배포·동시성 코드 가독성으로 이어지는 경우가 많습니다.
자주 언급되는 장점:
- 개발 속도: 팀·도메인에 따라 다르지만, 동시성·네트워크 코드를 빠르게 완성하기 쉬운 편입니다.
- 안정성: GC가 있어 수동 할당 해제 부담이 줄어듭니다.
- 배포: 단일 바이너리로 옮기기 쉬운 구조입니다.
위 내용은 시리즈 전체를 구성할 때 참고한 실무 관점의 정리입니다.
목차
1. 고루틴: 경량 스레드
C++ vs Go: 스레드 생성
// C++: std::thread (무거움)
#include <thread>
#include <iostream>
#include <vector>
void worker(int id) {
std::cout << "Worker " << id << " running\n";
}
int main() {
std::vector<std::thread> threads;
// 10개 스레드 (각 1~8MB 스택)
for (int i = 0; i < 10; i++) {
threads.emplace_back(worker, i);
}
// 모든 스레드 대기
for (auto& t : threads) {
t.join();
}
return 0;
}
// Go: 고루틴 (가벼움)
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int) {
fmt.Printf("Worker %d running\n", id)
}
func main() {
var wg sync.WaitGroup
// 10,000개 고루틴도 가볍게 생성 가능!
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
worker(id)
}(i)
}
// 모든 고루틴 대기
wg.Wait()
}
핵심 차이점:
- 생성 비용: 고루틴은 OS 스레드보다 훨씬 저렴
- 스택 크기: 고루틴은 2KB로 시작, 필요 시 자동 확장
- 스케줄링: M:N 스케줄링 (M개 고루틴을 N개 OS 스레드에서 실행)
- 수량: C++는 수백 개 한계, Go는 수만~수십만 개 가능
sync.WaitGroup
// Go: WaitGroup으로 고루틴 대기
package main
import (
"fmt"
"sync"
"time"
)
func task(id int, wg *sync.WaitGroup) {
defer wg.Done() // 완료 시 카운터 감소
fmt.Printf("Task %d starting\n", id)
time.Sleep(time.Second)
fmt.Printf("Task %d done\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // 카운터 증가
go task(i, &wg)
}
wg.Wait() // 모든 고루틴 완료 대기
fmt.Println("All tasks completed")
}
2. 채널: 고루틴 간 통신
C++ vs Go: 데이터 공유
// C++: Mutex로 공유 메모리 보호
#include <thread>
#include <mutex>
#include <vector>
std::mutex mtx;
std::vector<int> results;
void worker(int id) {
int result = id * 2;
std::lock_guard<std::mutex> lock(mtx);
results.push_back(result);
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; i++) {
threads.emplace_back(worker, i);
}
for (auto& t : threads) {
t.join();
}
return 0;
}
// Go: 채널로 통신 (권장 패턴)
package main
import "fmt"
func worker(id int, ch chan int) {
result := id * 2
ch <- result // 채널에 전송
}
func main() {
ch := make(chan int)
// 10개 고루틴 시작
for i := 0; i < 10; i++ {
go worker(i, ch)
}
// 결과 수신
results := make([]int, 0, 10)
for i := 0; i < 10; i++ {
result := <-ch // 채널에서 수신
results = append(results, result)
}
fmt.Println(results)
}
Go의 철학: “공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라”
Do not communicate by sharing memory; instead, share memory by communicating.
채널 기본 연산
// Go: 채널 생성과 사용
package main
import "fmt"
func main() {
// 채널 생성
ch := make(chan int)
// 송신 (고루틴에서)
go func() {
ch <- 42 // 전송 (수신자가 받을 때까지 블록)
}()
// 수신
value := <-ch // 수신 (송신자가 보낼 때까지 블록)
fmt.Println(value)
// 채널 닫기 (송신자가 더 이상 보낼 것이 없을 때)
close(ch)
// 닫힌 채널에서 수신 시 제로 값과 false 반환
v, ok := <-ch
fmt.Println(v, ok) // 0 false
}
채널 방향성
// Go: 채널 방향 지정 (타입 안전성)
package main
import "fmt"
// 송신 전용 채널
func sender(ch chan<- int) {
ch <- 1
ch <- 2
ch <- 3
close(ch)
// v := <-ch // ❌ 컴파일 에러: 수신 불가
}
// 수신 전용 채널
func receiver(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
// ch <- 4 // ❌ 컴파일 에러: 송신 불가
}
func main() {
ch := make(chan int) // 양방향 채널
go sender(ch) // 송신 전용으로 전달
receiver(ch) // 수신 전용으로 전달
}
3. 버퍼 채널
버퍼 없는 채널 vs 버퍼 채널
// Go: 버퍼 없는 채널 (동기)
package main
import "fmt"
func unbuffered() {
ch := make(chan int) // 버퍼 없음
// ❌ 데드락: 수신자 없이 송신 시도
// ch <- 1 // 영원히 블록
// ✅ 고루틴에서 송신
go func() {
ch <- 1 // 수신자가 받을 때까지 대기
}()
fmt.Println(<-ch) // 1
}
func buffered() {
ch := make(chan int, 3) // 버퍼 크기 3
// ✅ 버퍼가 차기 전까지 블록 안 됨
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // 버퍼 가득 참, 블록됨
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
}
버퍼 채널 활용:
// Go: 버퍼 채널로 생산자-소비자 패턴
package main
import (
"fmt"
"time"
)
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
fmt.Printf("Producing %d\n", i)
ch <- i
time.Sleep(100 * time.Millisecond)
}
close(ch)
}
func consumer(ch <-chan int) {
for v := range ch { // 채널이 닫힐 때까지 수신
fmt.Printf("Consuming %d\n", v)
time.Sleep(200 * time.Millisecond)
}
}
func main() {
ch := make(chan int, 5) // 버퍼 크기 5
go producer(ch)
consumer(ch)
}
4. select: 다중 채널 제어
select 기본 사용법
// Go: select로 여러 채널 대기
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "from ch2"
}()
// 먼저 준비된 채널에서 수신
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}
}
}
select with timeout
// Go: 타임아웃 처리
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch <- "result"
}()
select {
case result := <-ch:
fmt.Println("Received:", result)
case <-time.After(1 * time.Second):
fmt.Println("Timeout!")
}
}
select with default (논블로킹)
// Go: 논블로킹 채널 연산
package main
import "fmt"
func main() {
ch := make(chan int, 1)
// 논블로킹 송신
select {
case ch <- 1:
fmt.Println("Sent")
default:
fmt.Println("Channel full")
}
// 논블로킹 수신
select {
case v := <-ch:
fmt.Println("Received:", v)
default:
fmt.Println("No data")
}
}
5. 동시성 패턴
패턴 1: 워커 풀 (Worker Pool)
// C++: 스레드 풀 (복잡)
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
class ThreadPool {
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex mtx;
std::condition_variable cv;
bool stop = false;
public:
ThreadPool(size_t numThreads) {
for (size_t i = 0; i < numThreads; i++) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this] { return stop || !tasks.empty(); });
if (stop && tasks.empty()) return;
task = std::move(tasks.front());
tasks.pop();
}
task();
}
});
}
}
// ... (생략)
};
// Go: 워커 풀 (간단)
package main
import (
"fmt"
"time"
)
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job)
time.Sleep(time.Second)
results <- job * 2
}
}
func main() {
numJobs := 10
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// 3개 워커 시작
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 작업 전송
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// 결과 수집
for a := 1; a <= numJobs; a++ {
result := <-results
fmt.Println("Result:", result)
}
}
패턴 2: 파이프라인 (Pipeline)
// Go: 파이프라인 패턴
package main
import "fmt"
// 단계 1: 숫자 생성
func generator(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
// 단계 2: 제곱
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
}
// 단계 3: 출력
func printer(in <-chan int) {
for n := range in {
fmt.Println(n)
}
}
func main() {
// 파이프라인 구성
nums := generator(1, 2, 3, 4, 5)
squared := square(nums)
printer(squared)
}
패턴 3: Fan-out, Fan-in
// Go: Fan-out (하나의 입력을 여러 워커에 분배)
package main
import (
"fmt"
"sync"
)
func fanOut(in <-chan int, numWorkers int) []<-chan int {
outs := make([]<-chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
out := make(chan int)
outs[i] = out
go func(ch chan int) {
defer close(ch)
for v := range in {
ch <- v * 2 // 처리
}
}(out)
}
return outs
}
// Fan-in (여러 채널을 하나로 병합)
func fanIn(channels ...<-chan int) <-chan int {
out := 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 {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
// 입력 생성
in := make(chan int)
go func() {
defer close(in)
for i := 1; i <= 10; i++ {
in <- i
}
}()
// Fan-out: 3개 워커에 분배
workers := fanOut(in, 3)
// Fan-in: 결과 병합
out := fanIn(workers...)
// 결과 출력
for result := range out {
fmt.Println(result)
}
}
패턴 4: 타임아웃과 취소
// Go: context로 타임아웃과 취소
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done(): // 취소 신호
fmt.Printf("Worker %d cancelled\n", id)
return
default:
fmt.Printf("Worker %d working...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
// 2초 타임아웃
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
for i := 1; i <= 3; i++ {
go worker(ctx, i)
}
// 타임아웃 대기
<-ctx.Done()
fmt.Println("Main: timeout reached")
// 고루틴이 정리될 시간 주기
time.Sleep(time.Second)
}
6. 실습 과제
과제 1: 병렬 다운로드
// Go: 여러 URL 병렬 다운로드
package main
import (
"fmt"
"io"
"net/http"
"sync"
)
func download(url string, wg *sync.WaitGroup, results chan<- string) {
defer wg.Done()
resp, err := http.Get(url)
if err != nil {
results <- fmt.Sprintf("%s: error - %v", url, err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
results <- fmt.Sprintf("%s: read error - %v", url, err)
return
}
results <- fmt.Sprintf("%s: %d bytes", url, len(body))
}
func main() {
urls := []string{
"https://golang.org",
"https://github.com",
"https://stackoverflow.com",
}
var wg sync.WaitGroup
results := make(chan string, len(urls))
for _, url := range urls {
wg.Add(1)
go download(url, &wg, results)
}
// 고루틴 완료 대기 후 채널 닫기
go func() {
wg.Wait()
close(results)
}()
// 결과 출력
for result := range results {
fmt.Println(result)
}
}
과제 2: 레이트 리미터
// Go: 채널로 레이트 리미터 구현
package main
import (
"fmt"
"time"
)
func rateLimiter(requests <-chan int, rate time.Duration) {
ticker := time.NewTicker(rate)
defer ticker.Stop()
for req := range requests {
<-ticker.C // rate마다 하나씩 처리
fmt.Printf("Processing request %d at %v\n", req, time.Now())
}
}
func main() {
requests := make(chan int, 10)
// 레이트 리미터 시작 (500ms마다 하나씩)
go rateLimiter(requests, 500*time.Millisecond)
// 요청 전송
for i := 1; i <= 5; i++ {
requests <- i
}
close(requests)
time.Sleep(3 * time.Second)
}
과제 3: 타임아웃 있는 작업
// Go: 타임아웃 처리
package main
import (
"fmt"
"time"
)
func longRunningTask(result chan<- string) {
time.Sleep(3 * time.Second)
result <- "Task completed"
}
func main() {
result := make(chan string)
go longRunningTask(result)
select {
case res := <-result:
fmt.Println(res)
case <-time.After(2 * time.Second):
fmt.Println("Timeout: task took too long")
}
}
과제 4: 동시 Map 접근
// Go: sync.Mutex vs 채널
package main
import (
"fmt"
"sync"
)
// 방법 1: Mutex 사용
type SafeCounter1 struct {
mu sync.Mutex
count map[string]int
}
func (c *SafeCounter1) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
c.count[key]++
}
func (c *SafeCounter1) Value(key string) int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count[key]
}
// 방법 2: 채널 사용 (더 Go다운 방식)
type SafeCounter2 struct {
ops chan func(map[string]int)
}
func NewSafeCounter2() *SafeCounter2 {
c := &SafeCounter2{
ops: make(chan func(map[string]int)),
}
go func() {
count := make(map[string]int)
for op := range c.ops {
op(count)
}
}()
return c
}
func (c *SafeCounter2) Inc(key string) {
c.ops <- func(count map[string]int) {
count[key]++
}
}
func (c *SafeCounter2) Value(key string) int {
result := make(chan int)
c.ops <- func(count map[string]int) {
result <- count[key]
}
return <-result
}
func main() {
// Mutex 방식
counter1 := &SafeCounter1{count: make(map[string]int)}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter1.Inc("key")
}()
}
wg.Wait()
fmt.Println("Counter1:", counter1.Value("key"))
// 채널 방식
counter2 := NewSafeCounter2()
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter2.Inc("key")
}()
}
wg.Wait()
fmt.Println("Counter2:", counter2.Value("key"))
}
정리: Day 10~11 학습 체크리스트
완료해야 할 항목
-
go키워드로 고루틴 생성 -
sync.WaitGroup으로 고루틴 대기 - 채널 생성 및 송수신 (
<-) - 버퍼 채널과 버퍼 없는 채널 차이 이해
-
select로 다중 채널 제어 - 타임아웃과 논블로킹 연산
- 워커 풀, 파이프라인 패턴 구현
- 실습 과제 4개 완료
C++에서 Go로 전환 포인트
| C++ | Go | 비고 |
|---|---|---|
std::thread | go 키워드 | 훨씬 가벼움 |
thread.join() | sync.WaitGroup | 더 유연 |
std::mutex | sync.Mutex 또는 채널 | 채널 우선 |
std::condition_variable | 채널 또는 sync.Cond | 채널이 더 간단 |
| 공유 메모리 + 락 | 채널 통신 | 패러다임 전환 |
동시성 vs 병렬성
graph TD
A[동시성 Concurrency] --> B[여러 작업을 다루는 구조]
C[병렬성 Parallelism] --> D[여러 작업을 동시에 실행]
B --> E[고루틴으로 구현]
D --> F[멀티코어에서 실행]
E --> G[GOMAXPROCS로 제어]
F --> G
Go의 동시성 모델:
- 동시성: 여러 작업을 구조적으로 다루는 방법 (고루틴, 채널)
- 병렬성: 여러 작업을 물리적으로 동시에 실행 (멀티코어)
- Go는 동시성을 쉽게 만들고, 런타임이 자동으로 병렬성을 처리
다음 단계 예고
Day 10~11에서는 고루틴과 채널을 배웠습니다. 다음 글에서는 의존성 관리와 테스팅을 다룹니다. CMake보다 수백 배 쉬운 Go Modules와 내장 테스트 프레임워크를 배웁니다.
📚 시리즈 네비게이션
| 이전 글 | 목차 | 다음 글 |
|---|---|---|
| ← #05 에러 처리 | 📑 전체 목차 | #07 테스팅 → |
Go 2주 완성 시리즈: 커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료
한 줄 요약: 고루틴은 가볍고, 채널은 안전하며, select는 강력합니다. Go의 동시성 모델은 C++ 스레드보다 훨씬 우아합니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [Go 2주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라
- [Go 심화 #09] context.Context·타임아웃·우아한 종료 — 동시성을 프로덕션 수준으로
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
이 글에서 다루는 키워드 (관련 검색어)
Go goroutine, Go channel, select Go, 동시성 프로그래밍, C++ thread 비교, Golang 고루틴, Go 2주 완성, 채널 버퍼 등으로 검색하시면 이 글이 도움이 됩니다.
실전 팁
실무에서 바로 적용할 수 있는 팁입니다.
디버깅 팁
- 문제가 발생하면 먼저 컴파일러 경고를 확인하세요
- 간단한 테스트 케이스로 문제를 재현하세요
성능 팁
- 프로파일링 없이 최적화하지 마세요
- 측정 가능한 지표를 먼저 설정하세요
코드 리뷰 팁
- 코드 리뷰에서 자주 지적받는 부분을 미리 체크하세요
- 팀의 코딩 컨벤션을 따르세요
실전 체크리스트
실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.
코드 작성 전
- 이 기법이 현재 문제를 해결하는 최선의 방법인가?
- 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
- 성능 요구사항을 만족하는가?
코드 작성 중
- 컴파일러 경고를 모두 해결했는가?
- 엣지 케이스를 고려했는가?
- 에러 처리가 적절한가?
코드 리뷰 시
- 코드의 의도가 명확한가?
- 테스트 케이스가 충분한가?
- 문서화가 되어 있는가?
이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ std::thread의 무거움에서 벗어나 수만 개의 고루틴을 가볍게 생성하세요. 채널로 안전하게 통신하고, select로 다중 채널을 제어하는 Go 동시성 프로그래밍의 핵심을 배웁니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
관련 글
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]
- C++ 개발자의 뇌 구조로 이해하는 Go 언어 [#47-2]
- [Go 2주 완성 #01] Day 1~2: Go 언어의 철학과 기본 문법 - C++ 개발자의 첫인상
- [Go 2주 완성 #02] Day 3~4: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다