이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.

Go의 동시성 모델은 Java와 근본적으로 다르다. Thread, synchronized, ExecutorService에 익숙한 개발자라면 처음엔 낯설겠지만, Go의 방식이 얼마나 우아한지 금방 알게 될 것이다. “공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라"는 Go의 철학을 이해해보자.


1. Goroutine: 경량 스레드#

go 키워드 하나로 시작하는 동시성#

Java에서 새 스레드를 만들려면:

new Thread(() -> {
    System.out.println("Hello from thread");
}).start();

// 또는 ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
    System.out.println("Hello from thread pool");
});

Go에서는 go 키워드 하나면 된다:

go func() {
    fmt.Println("Hello from goroutine")
}()

// 또는 기존 함수 호출
go processData(data)

Goroutine vs Thread#

메모리 효율:

  • Java Thread: 약 1MB 스택 (기본값)
  • Java Virtual Thread (Loom): 수 KB
  • Go Goroutine: 약 2KB 초기 스택 (필요에 따라 증가)

생성 비용:

  • Java Thread: OS 스레드와 1:1 매핑, 생성 비용 높음
  • Java Virtual Thread: 경량화되었지만 여전히 오버헤드 있음
  • Goroutine: Go 런타임이 관리, 매우 저렴
// 10만 개의 goroutine도 가볍게 생성 가능
for i := 0; i < 100000; i++ {
    go func(n int) {
        time.Sleep(time.Second)
        fmt.Println(n)
    }(i)
}

Java에서 10만 개의 Thread를 생성하면 OOM이 발생할 것이다. Virtual Thread로도 이 정도의 가벼움을 얻기 어렵다.

M:N 스케줄링#

Go 런타임은 M개의 goroutine을 N개의 OS 스레드에 다중화한다.

Goroutines:  G1  G2  G3  G4  G5  G6  G7  G8  ...
              \  |  /    \  |  /    \  |  /
OS Threads:    T1         T2         T3

개발자는 goroutine만 신경 쓰면 된다. 스레드 풀 크기나 스케줄링은 Go 런타임이 알아서 처리한다.

주의사항: Goroutine 누수#

Goroutine은 가볍지만, 종료되지 않으면 메모리 누수가 발생한다.

// 누수 예시: 영원히 대기하는 goroutine
func leak() {
    ch := make(chan int)
    go func() {
        val := <-ch  // 아무도 보내지 않으면 영원히 대기
        fmt.Println(val)
    }()
    // 함수 종료, goroutine은 계속 살아있음
}

해결책: context로 취소 시그널을 보내거나, 채널을 닫아서 goroutine을 종료시킨다 (뒤에서 설명).


2. Channel: 고루틴 간 통신#

기본 개념#

Channel은 goroutine 간에 데이터를 주고받는 파이프다.

// 채널 생성
ch := make(chan int)

// 보내기
ch <- 42

// 받기
value := <-ch

Java 비교:

// BlockingQueue와 유사
BlockingQueue<Integer> queue = new LinkedBlockingQueue<>();
queue.put(42);      // 보내기
int value = queue.take();  // 받기

하지만 Channel은 언어 수준에서 지원되어 문법이 간결하고, select와 결합해 강력한 패턴을 만들 수 있다.

Unbuffered vs Buffered Channel#

Unbuffered Channel (기본):

ch := make(chan int)  // 버퍼 없음
  • 송신자는 수신자가 받을 때까지 블록
  • 수신자는 송신자가 보낼 때까지 블록
  • 동기화 지점 역할
ch := make(chan int)

go func() {
    ch <- 42  // 수신자가 받을 때까지 여기서 대기
    fmt.Println("Sent")
}()

time.Sleep(time.Second)
val := <-ch  // 이 시점에 송신자가 해제됨
fmt.Println("Received:", val)

Buffered Channel:

ch := make(chan int, 3)  // 버퍼 크기 3
  • 버퍼가 가득 찰 때까지 송신 블록 없음
  • 버퍼가 빌 때까지 수신 블록 없음
ch := make(chan int, 2)

ch <- 1  // 블록 없음
ch <- 2  // 블록 없음
// ch <- 3  // 버퍼 가득, 여기서 블록됨

fmt.Println(<-ch)  // 1
fmt.Println(<-ch)  // 2

언제 무엇을 쓰는가:

  • Unbuffered: 동기화가 필요할 때, goroutine 간 핸드셰이크
  • Buffered: 생산자/소비자 속도가 다를 때, 백프레셔 제어

채널 닫기#

더 이상 보낼 값이 없으면 채널을 닫는다.

ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)  // 송신 완료
}()

// 방법 1: range로 순회 (채널이 닫히면 자동 종료)
for val := range ch {
    fmt.Println(val)
}

// 방법 2: 닫힘 확인
val, ok := <-ch
if !ok {
    fmt.Println("Channel closed")
}

주의:

  • 닫힌 채널에 송신하면 panic
  • 닫힌 채널에서 수신하면 zero value 반환 (블록 없음)
  • 채널은 한 번만 닫을 수 있음 (중복 close는 panic)

select: 여러 채널 다루기#

select는 여러 채널 연산 중 준비된 것을 처리한다. Java의 selectorpoll과 개념이 비슷하지만 훨씬 간결하다.

select {
case msg := <-ch1:
    fmt.Println("Received from ch1:", msg)
case msg := <-ch2:
    fmt.Println("Received from ch2:", msg)
case ch3 <- 42:
    fmt.Println("Sent to ch3")
default:
    fmt.Println("No channel ready")
}

타임아웃 구현:

select {
case result := <-ch:
    fmt.Println("Got result:", result)
case <-time.After(3 * time.Second):
    fmt.Println("Timeout!")
}

Java 비교:

// CompletableFuture로 비슷하게
CompletableFuture<String> future = ...;
try {
    String result = future.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    System.out.println("Timeout!");
}

비블록 채널 연산:

select {
case msg := <-ch:
    fmt.Println("Received:", msg)
default:
    fmt.Println("No message available")  // 즉시 실행
}

3. 동시성 패턴#

Worker Pool#

고정된 수의 worker가 작업을 처리하는 패턴. Java의 ThreadPoolExecutor와 유사.

func workerPool() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    // Worker 3개 시작
    for w := 1; w <= 3; w++ {
        go worker(w, jobs, results)
    }

    // 작업 5개 전송
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)

    // 결과 수집
    for a := 1; a <= 5; a++ {
        <-results
    }
}

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, j)
        time.Sleep(time.Second)
        results <- j * 2
    }
}

채널 방향 표시:

  • chan<- int: 송신 전용
  • <-chan int: 수신 전용
  • chan int: 양방향

Fan-out / Fan-in#

Fan-out: 여러 goroutine이 같은 채널에서 읽기

// 하나의 작업 채널, 여러 worker
jobs := make(chan Job)
for i := 0; i < 10; i++ {
    go worker(jobs)  // 10개 worker가 jobs에서 경쟁적으로 읽음
}

Fan-in: 여러 채널의 결과를 하나로 합치기

func fanIn(ch1, ch2 <-chan int) <-chan int {
    merged := make(chan int)
    go func() {
        for {
            select {
            case v := <-ch1:
                merged <- v
            case v := <-ch2:
                merged <- v
            }
        }
    }()
    return merged
}

Pipeline#

단계별로 데이터를 처리하는 패턴.

func main() {
    // 파이프라인: numbers -> square -> print
    numbers := generate(1, 2, 3, 4, 5)
    squared := square(numbers)
    
    for n := range squared {
        fmt.Println(n)
    }
}

func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        for n := range in {
            out <- n * n
        }
        close(out)
    }()
    return out
}

Java의 Stream API 비교:

// 비슷한 패턴이지만 동시성 없음 (parallelStream은 다름)
List.of(1, 2, 3, 4, 5)
    .stream()
    .map(n -> n * n)
    .forEach(System.out::println);

Go 파이프라인은 각 단계가 별도 goroutine에서 동시에 실행된다.


4. sync 패키지#

채널만으로 모든 동시성 문제를 해결할 수 있지만, 때로는 전통적인 동기화 프리미티브가 더 적합하다.

Mutex#

공유 데이터를 보호할 때 사용한다.

type SafeCounter struct {
    mu    sync.Mutex
    count int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

Java 비교:

public class SafeCounter {
    private int count;
    
    public synchronized void increment() {
        count++;
    }
    
    public synchronized int value() {
        return count;
    }
}

RWMutex: 읽기가 많고 쓰기가 적을 때 성능 향상

type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *Cache) Get(key string) string {
    c.mu.RLock()         // 읽기 락 (동시 읽기 허용)
    defer c.mu.RUnlock()
    return c.data[key]
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()          // 쓰기 락 (배타적)
    defer c.mu.Unlock()
    c.data[key] = value
}

WaitGroup#

여러 goroutine의 완료를 기다릴 때 사용한다.

func main() {
    var wg sync.WaitGroup
    
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Printf("Worker %d done\n", n)
        }(i)
    }
    
    wg.Wait()  // 모든 goroutine이 Done() 호출할 때까지 대기
    fmt.Println("All workers completed")
}

Java 비교:

CountDownLatch latch = new CountDownLatch(5);
for (int i = 0; i < 5; i++) {
    executor.submit(() -> {
        // work
        latch.countDown();
    });
}
latch.await();

Once#

초기화를 단 한 번만 실행할 때 사용한다.

var (
    instance *Database
    once     sync.Once
)

func GetDatabase() *Database {
    once.Do(func() {
        instance = &Database{
            // expensive initialization
        }
    })
    return instance
}

Java 비교:

// Double-checked locking 또는
public class Database {
    private static final Database INSTANCE = new Database();
    public static Database getInstance() { return INSTANCE; }
}

Map (sync.Map)#

동시성 안전한 맵. 하지만 대부분의 경우 Mutex + map이 더 나은 선택이다.

var m sync.Map

m.Store("key", "value")
val, ok := m.Load("key")
m.Delete("key")

언제 sync.Map을 쓰는가:

  • 키가 한 번 쓰이고 여러 번 읽힐 때
  • 여러 goroutine이 서로 다른 키를 읽고 쓸 때

대부분의 경우 RWMutex + map이 더 성능이 좋다.


5. context.Context#

취소, 타임아웃, 값 전달#

context는 Go 1.7에서 도입된 핵심 패키지다. API 경계를 넘어 취소 시그널, 타임아웃, 요청 범위 값을 전달한다.

func main() {
    // 3초 타임아웃 컨텍스트
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    
    result, err := fetchData(ctx)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(result)
}

func fetchData(ctx context.Context) (string, error) {
    ch := make(chan string)
    
    go func() {
        // 시뮬레이션: 느린 작업
        time.Sleep(5 * time.Second)
        ch <- "data"
    }()
    
    select {
    case result := <-ch:
        return result, nil
    case <-ctx.Done():
        return "", ctx.Err()  // context.DeadlineExceeded
    }
}

Context 종류#

// 빈 컨텍스트 (루트)
ctx := context.Background()
ctx := context.TODO()  // 아직 어떤 컨텍스트를 쓸지 모를 때

// 취소 가능한 컨텍스트
ctx, cancel := context.WithCancel(parent)

// 타임아웃 컨텍스트
ctx, cancel := context.WithTimeout(parent, 5*time.Second)

// 데드라인 컨텍스트
ctx, cancel := context.WithDeadline(parent, time.Now().Add(5*time.Second))

// 값을 담은 컨텍스트
ctx := context.WithValue(parent, "userID", "123")

Java의 CompletableFuture와 비교#

Java:

CompletableFuture<String> future = CompletableFuture
    .supplyAsync(() -> fetchData())
    .orTimeout(3, TimeUnit.SECONDS);

try {
    String result = future.get();
} catch (TimeoutException e) {
    // 타임아웃
}

Go:

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

result, err := fetchData(ctx)
if errors.Is(err, context.DeadlineExceeded) {
    // 타임아웃
}

차이점:

  • Java: Future가 결과를 감싸고, 예외로 에러 처리
  • Go: Context가 취소 시그널을 전파하고, 에러는 별도 반환

Context 사용 규칙#

  1. 함수의 첫 번째 파라미터로: func DoSomething(ctx context.Context, ...)
  2. struct에 저장하지 말 것: 매번 파라미터로 전달
  3. nil context 전달 금지: 확실치 않으면 context.TODO() 사용
  4. Value는 요청 범위 데이터에만: 함수 파라미터 대체용으로 쓰지 말 것

6. Race Condition 탐지#

go run -race#

Go는 내장 race detector를 제공한다.

// race condition이 있는 코드
var counter int

func main() {
    for i := 0; i < 1000; i++ {
        go func() {
            counter++  // 동시 접근!
        }()
    }
    time.Sleep(time.Second)
    fmt.Println(counter)
}
$ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x... by goroutine 7:
  main.main.func1()
      main.go:10 +0x3a

Previous write at 0x... by goroutine 6:
  main.main.func1()
      main.go:10 +0x4e
==================

실전 팁#

# 테스트에서 race detection
go test -race ./...

# 빌드 시 race detection (성능 저하 있음, 프로덕션 비권장)
go build -race

Race detector는 런타임 오버헤드가 있으므로 CI/CD에서 테스트 시에만 활성화한다.


7. 실전 예제: HTTP 서버의 동시 요청 처리#

func main() {
    http.HandleFunc("/search", searchHandler)
    http.ListenAndServe(":8080", nil)
}

func searchHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()  // 요청의 컨텍스트 (클라이언트 취소 시 취소됨)
    
    query := r.URL.Query().Get("q")
    
    // 세 곳에서 동시에 검색
    results := make(chan string, 3)
    
    go func() { results <- searchGoogle(ctx, query) }()
    go func() { results <- searchBing(ctx, query) }()
    go func() { results <- searchDuckDuckGo(ctx, query) }()
    
    // 가장 빠른 결과 반환
    select {
    case result := <-results:
        fmt.Fprintf(w, "Result: %s", result)
    case <-ctx.Done():
        http.Error(w, "Request cancelled", http.StatusRequestTimeout)
    }
}

func searchGoogle(ctx context.Context, query string) string {
    // 실제로는 HTTP 요청
    select {
    case <-time.After(100 * time.Millisecond):
        return "Google: " + query
    case <-ctx.Done():
        return ""
    }
}

요약: Java → Go 동시성 마인드셋 전환#

Java Go
new Thread() go func()
ExecutorService goroutine (직접 관리 불필요)
BlockingQueue chan
synchronized sync.Mutex
CountDownLatch sync.WaitGroup
CompletableFuture goroutine + channel + context
@Async go 키워드

핵심 원칙:

  1. “공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라”
  2. Goroutine은 가볍다. 필요하면 만들어라.
  3. Channel로 동기화하고 데이터를 전달하라.
  4. Context로 취소와 타임아웃을 전파하라.
  5. Race detector로 동시성 버그를 잡아라.