[Go 2주 완성 #06] Day 10~11: 고루틴과 채널 - 동시성 프로그래밍의 혁명

이 글의 핵심

C++에서는 스레드를 수백 개만 띄워도 메모리 부담이 커지지만 Go는 고루틴 수천 개를 쉽게 다룹니다. 이 글은 시리즈 여섯 번째 편으로 데이터를 공유하는 대신 채널로 전달하는 사고방식, 버퍼 유무에 따른 동작 차이, 동시성과 병렬성의 구분을 설명하고 병렬 다운로드와 레이트 리미터 실습으로 이어 갑니다.

시리즈 안내

📚 Go 2주 완성 시리즈 #06 | 전체 목차 보기

이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 10~11 내용입니다.

이전: #05 에러 처리 ← | → 다음: #07 테스팅


💡 초보자를 위한 한 줄: 고루틴은 go 함수()로 시작하는 초경량 스레드(수 KB)입니다. C++ std::thread(~8MB)와 달리 수만 개를 띄워도 괜찮습니다. 채널은 고루틴 사이에 데이터를 안전하게 주고받는 파이프입니다. ch <- value (보내기), value := <-ch (받기).

들어가며: “스레드 1만 개를 띄워도 괜찮다고요?”

C++에서 std::thread로 스레드를 만들면 운영체제가 스레드마다 스택 영역을 예약합니다. Linux의 기본값은 대개 8MB, Windows는 1MB입니다:

// C++: 스레드 1만 개 = 스택 주소 공간만 수십 GB 예약
std::vector<std::thread> threads;
for (int i = 0; i < 10000; i++) {
    threads.emplace_back(worker, i);  // 각 스레드 스택 ~8MB 예약 (Linux 기본)
}

정확히 말하면 이 8MB는 가상 주소 공간 예약이고, 실제 물리 메모리는 스택을 쓰는 만큼만 할당됩니다. 그래서 64비트 시스템에서 스레드 1만 개가 곧바로 80GB의 RAM을 먹지는 않습니다. 진짜 비용은 다른 곳에 있습니다. 스레드 생성과 종료가 시스템 호출이라 비싸고, 스레드가 많아질수록 커널의 컨텍스트 스위칭 비용과 스케줄러 부담이 커지며, 프로세스당 스레드 수 제한(ulimit -u, /proc/sys/kernel/threads-max)에 걸리기도 합니다. 요청마다 스레드를 하나씩 만드는 C++ 서버가 수천 개의 동시 연결에서 무너지는 이유가 이것입니다.

Go는 완전히 다릅니다. 고루틴은 수 KB로 시작해 필요할 때만 스택이 늘어나고, 생성과 전환이 커널을 거치지 않고 Go 런타임 안에서 일어나므로, 1만 개를 띄워도 메모리는 수십 MB 수준입니다:

for i := 0; i < 10000; i++ {
    go worker(i)  // 각 고루틴 ~2KB 시작
}

채널(channel)은 고루틴 사이에서 값을 주고받는 파이프입니다. 공유 메모리를 직접 건드리지 않으며, 파이프로 데이터를 흘려보내며 동기화하는 방식이 Go의 동시성 철학입니다. Rust의 채널·Arc/Mutex와 자주 비교되고, Kotlin 코루틴은 스레드 풀·Channel API로 비슷한 목표를 다른 문법으로 풉니다. 전통적인 OS 스레드 API는 Java Thread 글과 나란히 보면 “무겁다”는 느낌의 기준이 잡힙니다.

이 글에서 배울 내용:

  • 고루틴: 경량 작업자로 동시 작업 나누기
  • 채널: 파이프로 안전하게 통신하기
  • select: 여러 채널 중 준비된 쪽 처리하기
  • 동시성 패턴: 워커 풀, 파이프라인

고루틴이 가벼운 이유: Go 런타임 스케줄러

Go 런타임은 G(고루틴), M(OS 스레드), P(논리 프로세서)라는 세 요소로 스케줄링합니다. P의 개수는 기본적으로 CPU 코어 수(GOMAXPROCS)와 같고, 각 P는 실행 대기 중인 고루틴 큐를 가집니다. OS 스레드 M은 P 하나를 붙잡고 그 큐의 고루틴을 차례로 실행합니다. 고루틴이 채널 수신이나 뮤텍스에서 블록되면 런타임은 그 고루틴을 큐에서 빼고 같은 스레드에서 다른 고루틴을 실행하므로, 커널의 컨텍스트 스위칭 없이 사용자 공간에서 전환이 일어납니다. 네트워크 I/O도 내부적으로 epoll이나 kqueue 같은 이벤트 알림을 쓰기 때문에, 코드는 블로킹 호출처럼 보여도 스레드를 점유하지 않습니다.

C++ 개발자가 처음 헷갈리는 부분은 “그럼 스레드를 막는 작업은 어떻게 되는가”입니다. 파일 I/O나 cgo 호출처럼 OS 스레드 자체가 블록되는 경우에는 런타임이 P를 다른 스레드에 넘겨 나머지 고루틴이 계속 돌게 합니다. 그래서 cgo로 C 라이브러리를 호출하는 고루틴을 수천 개 동시에 돌리면 OS 스레드도 그만큼 늘어나고, 기본 한도(10,000개)를 넘으면 runtime: program exceeds 10000-thread limit와 함께 프로세스가 종료됩니다. 고루틴이 가볍다는 말은 Go 런타임이 관리하는 대기에 한해서라는 점을 기억해 두면 좋습니다.


고루틴: 경량 스레드

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"
)
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++ 스레드도 수천 개까지는 만들 수 있지만 전환 비용과 OS 한도가 부담이 되고, Go는 수만~수십만 개도 일상적으로 다룹니다

Go에는 std::thread의 join()에 해당하는 것이 없습니다. go 문은 핸들을 돌려주지 않으므로, 고루틴이 끝나기를 기다리려면 WaitGroup이나 채널을 직접 써야 합니다. C++의 std::thread는 join()이나 detach()를 빠뜨리면 소멸자에서 std::terminate가 호출되지만, Go는 main 함수가 끝나면 실행 중인 고루틴을 기다리지 않고 프로세스를 종료합니다. 위 예제에서 wg.Wait()을 지우면 아무것도 출력되지 않고 끝나는 경우가 많은데, 에러도 경고도 없어서 처음에는 원인을 찾기 어렵습니다.

익명 함수에 i를 인자로 넘기는 형태(go func(id int) {...}(i))도 이유가 있습니다. Go 1.21까지는 for 루프 변수가 모든 반복에서 하나의 변수를 공유했기 때문에, 클로저가 i를 직접 참조하면 고루틴이 실행될 즈음 이미 바뀐 값(대개 마지막 값)을 읽는 버그가 흔했습니다. Go 1.22부터는 반복마다 새 변수가 만들어지도록 언어가 바뀌어 이 문제가 사라졌지만, go.mod의 go 버전이 1.22 미만인 모듈에서는 여전히 옛 동작을 따르므로 인자로 넘기는 습관이 안전합니다.

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")
}

wg.Add(1)은 반드시 go 문 앞에서, 고루틴을 시작하는 쪽이 호출해야 합니다. 고루틴 안에서 Add를 호출하면 고루틴이 스케줄되기 전에 메인이 Wait()에 도달해 카운터가 0인 것을 보고 바로 통과할 수 있습니다. 또 WaitGroup은 값으로 복사하면 안 됩니다. task(i, wg)처럼 포인터가 아닌 값으로 넘기면 고루틴은 복사본의 카운터를 줄이고, 원본은 영원히 0이 되지 않아 Wait()에서 멈춥니다. go vet은 이 실수를 “passes lock by value” 경고로 잡아 주므로 빌드 과정에 넣어 두는 것이 좋습니다.


채널: 고루틴 간 통신

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.

이 문장은 “뮤텍스를 쓰지 말라”는 뜻이 아닙니다. 데이터의 소유권이 넘어가는 상황(작업을 워커에게 넘기고 결과를 돌려받는 것)은 채널로 표현하면 누가 언제 그 데이터를 만질 수 있는지가 코드에 드러난다는 뜻입니다. 반대로 여러 고루틴이 같은 캐시나 카운터를 계속 읽고 쓰는 공유 상태는 sync.Mutex로 보호하는 편이 더 단순하고 빠릅니다. Go 표준 라이브러리도 내부 상태 보호에는 뮤텍스를 많이 씁니다. 위 채널 예제의 결과 순서가 실행할 때마다 달라진다는 점도 알아 둘 만합니다. 고루틴이 끝나는 순서대로 채널에 도착하므로, 순서가 중요하면 결과에 인덱스를 함께 담아 보내야 합니다.

채널 기본 연산

// 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
}

채널을 다룰 때 런타임 에러가 나는 경우는 규칙이 명확합니다. 닫힌 채널에 송신하면 panic: send on closed channel, 이미 닫힌 채널을 다시 닫으면 panic: close of closed channel이 납니다. 닫힌 채널에서 수신하는 것은 안전하며 제로 값과 false가 즉시 돌아옵니다. 그래서 “채널은 송신하는 쪽이 닫는다”가 관례이고, 송신자가 여럿이라면 위의 Fan-in 예제처럼 모든 송신자가 끝난 뒤 별도 고루틴 하나가 닫게 만듭니다. nil 채널(초기화하지 않은 var ch chan int)은 송신도 수신도 영원히 블록된다는 점도 기억해야 합니다. 이 성질은 select에서 특정 케이스를 끄는 용도로 일부러 쓰이기도 합니다.

채널 방향성

// 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)     // 수신 전용으로 전달
}

버퍼 채널

버퍼 없는 채널 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
}

unbuffered()에서 주석 처리한 ch <- 1을 고루틴 없이 실행하면, 메인 고루틴이 받아 줄 상대 없이 멈추고 다른 고루틴도 없으므로 런타임이 fatal error: all goroutines are asleep - deadlock!을 출력하고 프로그램을 종료합니다. 이 검사는 모든 고루틴이 멈췄을 때만 동작한다는 점이 중요합니다. HTTP 서버처럼 다른 고루틴이 하나라도 살아 있으면 데드락에 빠진 고루틴은 아무 에러 없이 조용히 멈춰 있을 뿐이라, 운영 중에는 pprof의 고루틴 덤프(/debug/pprof/goroutine?debug=2)로 어디서 블록됐는지 찾아야 합니다.

버퍼 크기를 정하는 기준도 생각해 볼 만합니다. 버퍼는 생산자와 소비자의 일시적인 속도 차이를 흡수하는 완충 장치일 뿐, 소비자가 꾸준히 느리다면 버퍼가 얼마든 결국 가득 차고 생산자가 블록됩니다. 데드락이 나서 버퍼를 늘렸더니 해결된 것처럼 보이는 경우는 대개 설계 문제를 더 큰 입력에서 터지도록 미뤄 둔 것입니다. 버퍼 없는 채널의 “송신과 수신이 만나는 순간 동기화된다”는 성질은 오히려 동작을 예측하기 쉽게 만들어 주므로, 버퍼는 필요한 이유가 분명할 때만 두는 편이 좋습니다. 버퍼 채널 활용:

// 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)
}

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!")
    }
}

이 예제는 main이 곧 끝나므로 문제가 드러나지 않지만, 같은 패턴을 서버의 요청 핸들러에서 쓰면 고루틴 누수가 생깁니다. 타임아웃으로 select를 빠져나온 뒤에도 송신 고루틴은 2초 후 ch <- "result"를 시도하는데, 버퍼 없는 채널이고 받을 쪽이 사라졌으므로 영원히 블록됩니다. 멈춘 고루틴은 GC로 회수되지 않으므로 타임아웃이 날 때마다 하나씩 쌓이고, 며칠 뒤 메모리 사용량이 계속 오르는 형태로 드러납니다. ch := make(chan string, 1)로 버퍼를 하나 주면 송신자가 결과를 버퍼에 두고 종료할 수 있어 누수가 사라집니다. 실무에서는 한 걸음 더 나아가 context를 넘겨 작업 자체를 중단시키는 편이 좋습니다(패턴 4).

select에서 여러 케이스가 동시에 준비돼 있으면 Go는 그중 하나를 무작위로 고릅니다. 코드에 적은 순서대로 우선순위가 매겨지지 않으므로, “종료 신호를 데이터보다 먼저 처리하고 싶다”면 종료 채널을 먼저 확인하는 별도의 select를 두어야 합니다. 또 for 루프 안에서 time.After를 매번 호출하면 반복마다 새 타이머가 만들어집니다. Go 1.23부터는 참조가 사라진 타이머가 바로 회수되지만, 이전 버전에서는 타이머가 만료될 때까지 메모리에 남았으므로 긴 루프에서는 time.NewTimer를 만들어 재사용하는 방식이 권장됐습니다.

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")
    }
}

동시성 패턴

패턴 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)
    }
}

C++ 스레드 풀과 비교하면 Go 버전이 짧은 이유는 큐, 조건 변수, 종료 플래그의 역할을 채널 하나가 모두 맡기 때문입니다. jobs 채널이 작업 큐이고, 비어 있으면 워커가 알아서 블록되며, close(jobs)가 “더 이상 작업 없음”이라는 종료 신호가 되어 for job := range jobs가 자연스럽게 끝납니다. 워커 수는 작업의 성격에 따라 정합니다. CPU를 쓰는 작업이라면 runtime.NumCPU() 정도가 적당하고, 외부 API 호출처럼 대기가 대부분인 작업이라면 더 많이 두되 상대 서버가 감당할 수 있는 동시 요청 수에 맞춥니다.

이 예제는 결과를 정확히 numJobs개 받는다는 사실을 알고 있어서 동작합니다. 실제로는 작업 수를 미리 모르거나, 일부 작업이 결과를 보내지 않는 경우(에러로 건너뛰는 경우)가 있습니다. 그러면 메인이 오지 않을 결과를 기다리며 멈추므로, 워커들을 WaitGroup으로 묶고 모두 끝나면 results를 닫는 고루틴을 두는 과제 1의 구조가 더 견고합니다.

패턴 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)
    }
}

Fan-out은 여러 워커가 같은 입력 채널을 range로 읽는 것만으로 구현됩니다. 채널은 값 하나를 정확히 한 수신자에게만 전달하므로 작업이 중복 처리되지 않고, 한가한 워커가 먼저 다음 값을 가져가는 자연스러운 부하 분산이 됩니다. 대신 출력 순서는 입력 순서와 달라집니다. 2, 4, 6… 순서를 기대했다면 실행할 때마다 결과가 섞여 나오는 것을 보게 됩니다.

파이프라인에서 주의할 점은 중간에 그만두는 경우입니다. printer가 처음 세 개만 읽고 반환하면, square는 네 번째 값을 보내려다 블록되고 generator도 연쇄적으로 멈춰 두 고루틴이 누수됩니다. Go 공식 블로그의 파이프라인 글이 모든 단계에 done 채널이나 context를 넘겨, 소비자가 떠나면 상류 단계들이 select로 이를 감지하고 종료하게 만드는 이유입니다.

패턴 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)
}

context는 취소 신호를 여러 고루틴에 한 번에 전파하는 표준 방법입니다. ctx.Done()은 취소되면 닫히는 채널이라, 닫힌 채널에서의 수신이 즉시 성공한다는 성질을 이용해 모든 워커가 동시에 신호를 받습니다. 다만 취소는 협력적입니다. 워커가 time.Sleep(10 * time.Second) 같은 긴 작업 중이라면 그 작업이 끝날 때까지 ctx.Done()을 확인하지 못하므로, 긴 대기는 select { case <-ctx.Done(): case <-time.After(d): }처럼 취소와 함께 기다리게 만들어야 합니다. 마지막의 time.Sleep(time.Second)은 데모를 위한 것이고, 실제 코드에서는 WaitGroup으로 워커가 모두 끝났는지를 확실히 기다려야 합니다. WithTimeout이 돌려준 cancel을 defer로 호출하지 않으면 go vet이 “the cancel function is not used on all paths” 경고를 내는데, 타이머 자원이 제때 해제되지 않기 때문입니다.


실습 과제

과제 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, 1)  // 버퍼 1: 타임아웃 뒤에도 송신자가 블록되지 않고 종료
    
    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"))
}

두 방식 모두 정확하게 1000을 출력하지만, “채널 방식이 더 Go답다”는 말은 이 경우에는 조심해서 받아들여야 합니다. 채널 방식은 연산마다 클로저를 만들어 채널로 보내고 전담 고루틴이 받아 실행하므로, 뮤텍스 한 번 잠그고 푸는 것보다 할 일이 훨씬 많습니다. 또 전담 고루틴을 멈출 방법이 없어 SafeCounter2를 버리면 그 고루틴이 영원히 남습니다. 단순한 공유 상태에는 sync.Mutex가, 작업 흐름을 표현할 때는 채널이 맞는 도구라고 기억하면 됩니다. 어느 쪽을 택하든 go test -race나 go run -race로 실행해 보면 레이스 디텍터가 보호되지 않은 접근을 WARNING: DATA RACE와 함께 두 접근 위치의 스택으로 보여 줍니다. 맵은 특히 동시 쓰기에 민감해서, 보호 없이 여러 고루틴이 쓰면 레이스 디텍터 없이도 런타임이 fatal error: concurrent map writes로 프로그램을 중단시킵니다.


정리: Day 10~11 학습 체크리스트

완료해야 할 항목

  • go 키워드로 고루틴 생성
  • sync.WaitGroup으로 고루틴 대기
  • 채널 생성 및 송수신 (<-)
  • 버퍼 채널과 버퍼 없는 채널 차이 이해
  • select로 다중 채널 제어
  • 타임아웃과 논블로킹 연산
  • 워커 풀, 파이프라인 패턴 구현
  • 실습 과제 4개 완료

C++에서 Go로 전환 포인트

C++Go비고
std::threadgo 키워드훨씬 가벼움
thread.join()sync.WaitGroup더 유연
std::mutexsync.Mutex 또는 채널공유 상태는 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에서는 고루틴과 채널을 배웠습니다. 다음 글에서는 의존성 관리와 테스팅을 다룹니다. 별도 빌드 시스템 없이 go.mod 하나로 의존성을 관리하는 Go Modules와 내장 테스트 프레임워크를 배웁니다.


📚 시리즈 네비게이션

이전 글목차다음 글
← #05 에러 처리📑 전체 목차#07 테스팅 →

Go 2주 완성 시리즈: 커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료


고루틴은 가볍고 채널은 소유권 이전을 코드로 드러내 주지만, 누가 채널을 닫고 누가 고루틴을 끝내는지를 설계하지 않으면 데드락과 고루틴 누수가 조용히 쌓입니다.

자주 묻는 질문 (FAQ)

Q. select에 default를 넣으면 무엇이 달라지나요?

A. default가 없는 select는 준비된 채널이 생길 때까지 블로킹되지만, default가 있으면 준비된 채널이 없을 때 즉시 default로 빠져나갑니다. 버퍼가 가득 찼으면 값을 버리거나 받을 값이 없으면 다른 일을 하는 논블로킹 송수신에 유용합니다. 다만 for 루프 안에서 default만 계속 도는 구조는 CPU를 바쁘게 소모하므로, 기다려야 하는 상황이라면 time.After를 이용한 타임아웃 케이스를 쓰는 편이 낫습니다.


같이 보면 좋은 글