현대의 컴퓨터는 멀티코어 프로세서를 기반으로 작동하며, 동시성(Concurrency)은 더 이상 선택이 아닌 필수적인 프로그래밍 패러다임이 되었습니다. Go 언어는 설계 초기부터 동시성을 핵심 기능으로 채택했으며, 전통적인 스레드-락(Thread-Lock) 모델의 복잡성을 해결하기 위한 명확한 철학을 제시합니다.

이 글에서는 Go의 동시성 모델이 어떠한 철학 위에서 탄생했는지 살펴보고, 다른 주류 언어들과의 접근 방식을 비교 분석합니다. 이를 통해 Go의 동시성이 갖는 장점과 현실적인 트레이드오프를 가감 없이 설명하고자 합니다.

(이 글은 Gemini 2.5 Pro 모델에 의해 작성되었으며, 커버하고 있는 세부 항목들과 글의 톤/매너에 대한 요구사항들은 제가 정리해서 Gemini 에 요청했습니다. 내용에 잘못된 부분이 있을 수 있는데, 그런 경우 잘못된 정보에 대한 댓글을 남겨주시면 감사하겠습니다.)


Go의 동시성 철학: “메모리를 공유하여 소통하지 말고, 소통하여 메모리를 공유하라”#

Go의 동시성 모델은 롭 파이크(Rob Pike)의 유명한 격언으로 요약됩니다.

Don’t communicate by sharing memory; instead, share memory by communicating.

이는 전통적인 동시성 모델에 대한 직접적인 반박입니다. 다수의 언어는 여러 스레드가 공유 메모리(Shared Memory)에 접근하고, 이 과정에서 데이터의 일관성을 지키기 위해 뮤텍스(Mutex)나 세마포어(Semaphore) 같은 잠금(Locking) 메커니즘을 사용합니다. 이 방식은 매우 강력하지만, 데드락(Deadlock)이나 경쟁 상태(Race Condition)와 같은 복잡하고 미묘한 버그를 유발하기 쉽습니다.

Go는 정반대의 접근법을 제안합니다. 채널(Channels) 이라는 통로를 만들어 독립적으로 실행되는 고루틴(Goroutines) 간에 데이터를 안전하게 전달하는 방식을 기본으로 삼습니다. 데이터의 소유권이 채널을 통해 명시적으로 전달되므로, 여러 고루틴이 동시에 같은 메모리에 접근할 필요성 자체가 줄어듭니다.

  • 고루틴 (Goroutines): Go 런타임이 직접 관리하는 극도로 가벼운 스레드입니다. OS 스레드보다 훨씬 적은 메모리(초기 스택 크기 약 2KB)로 시작하며, 수십만, 수백만 개를 생성해도 시스템에 큰 부담을 주지 않습니다. go 키워드 하나로 함수를 즉시 비동기적으로 실행할 수 있습니다.

  • 채널 (Channels): 특정 타입을 가진 데이터를 주고받을 수 있는 통로입니다. 채널은 데이터 전송뿐만 아니라, 데이터를 받을 준비가 될 때까지 보내는 쪽이 기다리고, 보낼 데이터가 있을 때까지 받는 쪽이 기다리게 하는 동기화 기능도 내장하고 있습니다.


타 언어와의 동시성 모델 비교#

동일한 작업을 각 언어의 동시성 모델로 구현하며 차이점을 살펴보겠습니다. 작업은 간단합니다: 숫자 리스트를 여러 워커(Worker)에 분배하여 각각 제곱한 뒤, 그 결과를 다시 모으는 것입니다.

Go#

고루틴과 채널을 사용하여 직관적으로 구현합니다.

package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := make(chan int, 5)
	results := make(chan int, 5)
	var wg sync.WaitGroup

	// 3개의 워커 고루틴 생성
	for w := 1; w <= 3; w++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := range jobs {
				fmt.Printf("워커 %d, 작업 %d 시작\n", id, j)
				results <- j * j // 결과를 results 채널에 보냄
			}
		}(w)
	}

	// 5개의 작업을 jobs 채널에 보냄
	for j := 1; j <= 5; j++ {
		jobs <- j
	}
	close(jobs) // 작업 전송이 끝났음을 알림

	wg.Wait() // 모든 워커가 끝날 때까지 대기
	close(results)

	// 결과 수집
	for r := range results {
		fmt.Printf("결과: %d\n", r)
	}
}

jobsresults라는 두 개의 채널이 작업 분배와 결과 수집을 명확히 담당하며, 별도의 잠금 없이 동기화가 이루어집니다.

Java#

Java는 성숙하고 강력한 java.util.concurrent 패키지를 제공합니다. ExecutorServiceFuture를 사용하는 것이 일반적입니다.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class ConcurrencyTest {
    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(3);
        List<Future<Integer>> futures = new ArrayList<>();
        List<Integer> inputs = List.of(1, 2, 3, 4, 5);

        for (int input : inputs) {
            Callable<Integer> task = () -> {
                System.out.println("워커 " + Thread.currentThread().getName() + ", 작업 " + input + " 시작");
                return input * input;
            };
            futures.add(executor.submit(task));
        }

        for (Future<Integer> future : futures) {
            System.out.println("결과: " + future.get()); // 결과가 준비될 때까지 블로킹
        }

        executor.shutdown();
    }
}

Java의 접근법은 매우 유연하고 다양한 제어 기능을 제공합니다. 하지만 코드가 상대적으로 길고, Future.get()을 통해 결과를 동기적으로 기다리거나 콜백을 사용하는 등 동기화 방식을 개발자가 직접 선택하고 관리해야 합니다.

Kotlin#

Kotlin은 Go의 영향을 받은 코루틴(Coroutines)과 채널(Channel)을 라이브러리 수준에서 제공하며, 구조화된 동시성(Structured Concurrency)이라는 강력한 개념을 도입했습니다.

import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel

suspend fun main() = coroutineScope {
    val jobs = Channel<Int>()
    val results = Channel<Int>()

    // 5개의 작업을 비동기적으로 보냄
    launch {
        for (i in 1..5) {
            jobs.send(i)
        }
        jobs.close()
    }

    // 3개의 워커 코루틴
    repeat(3) { id ->
        launch {
            for (j in jobs) {
                println("워커 $id, 작업 $j 시작")
                results.send(j * j)
            }
        }
    }
    
    // 결과 5개를 수집 후 종료
    repeat(5) {
        println("결과: ${results.receive()}")
    }
    coroutineContext.cancelChildren() // 모든 자식 코루틴 정리
}

Kotlin의 코드는 Go와 매우 유사하지만, coroutineScopelaunch 빌더를 통해 코루틴의 생명주기를 부모 스코프에 종속시키는 구조화된 동시성을 제공하여 ‘코루틴 누수’를 방지하는 데 강점이 있습니다.

JavaScript (Node.js)#

Node.js는 기본적으로 싱글 스레드 이벤트 루프 모델을 사용하며 async/await로 비동기 I/O 작업을 처리합니다. CPU 집약적인 병렬 작업을 위해서는 worker_threads 모듈을 사용해야 합니다.

const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');

if (isMainThread) {
    const inputs = [1, 2, 3, 4, 5];
    const numWorkers = 3;
    let pending = inputs.length;

    console.log(`메인 스레드: ${inputs.length}개의 작업을 ${numWorkers}개의 워커에 분배`);

    for (let i = 0; i < numWorkers; i++) {
        const worker = new Worker(__filename);
        worker.on('message', (result) => {
            console.log(`결과: ${result}`);
            if (--pending === 0) {
                console.log('모든 작업 완료');
            }
        });
        worker.on('error', (err) => console.error(err));
    }
} else {
    // 워커 스레드
    parentPort.on('message', (job) => {
        console.log(`워커, 작업 ${job} 시작`);
        parentPort.postMessage(job * job);
    });
}
// 실제 구현에서는 메인 스레드가 워커에 작업을 분배하는 로직이 필요.
// 이 예제는 worker_threads의 구조를 보여주기 위함.

Node.js의 모델은 비동기 I/O 처리에 매우 효율적이지만, CPU 병렬 처리를 위해서는 스레드 간 메시지 전달을 통해 명시적으로 구현해야 하며, Go나 Kotlin에 비해 코드 구조가 복잡해집니다.

Rust#

Rust는 “두려움 없는 동시성(Fearless Concurrency)“을 모토로, 컴파일 시점에 데이터 경쟁을 원천적으로 차단하는 소유권(Ownership) 시스템을 갖추고 있습니다.

use std::sync::{mpsc, Arc, Mutex};
use std::thread;

// Rust의 표준 라이브러리인 mpsc(multiple producer, single consumer) 채널의 특성 반영
fn main() {
    // 작업(jobs)과 결과(results)를 위한 두 개의 채널 생성
    let (jobs_tx, jobs_rx) = mpsc::channel();
    let (results_tx, results_rx) = mpsc::channel();

    // 여러 워커 스레드가 'jobs_rx'를 공유하기 위해 Arc<Mutex<>>로 감싼다.
    let jobs_rx = Arc::new(Mutex::new(jobs_rx));
    let mut handles = vec![];

    // 3개의 워커 스레드를 생성
    for id in 0..3 {
        // 각 스레드가 채널의 소유권을 가질 수 있도록 복제
        let jobs_rx_clone = Arc::clone(&jobs_rx);
        let results_tx_clone = results_tx.clone();

        let handle = thread::spawn(move || {
            loop {
                // Mutex를 잠그고(lock) 채널에서 작업을 하나 수신(recv)한다.
                // Mutex는 한 번에 하나의 스레드만 jobs_rx에 접근하도록 보장한다.
                let job_result = jobs_rx_clone.lock().unwrap().recv();

                match job_result {
                    // 작업 수신에 성공한 경우
                    Ok(job) => {
                        println!("워커 {}, 작업 {} 시작", id, job);
                        let result = job * job; // 작업 수행 (제곱)
                        results_tx_clone.send(result).unwrap(); // 결과를 results 채널로 전송
                    }
                    // 채널이 닫혀 더 이상 받을 작업이 없는 경우
                    Err(_) => {
                        println!("워커 {}, 종료", id);
                        break; // 루프를 종료하여 스레드를 끝낸다.
                    }
                }
            }
        });
        handles.push(handle);
    }

    // 메인 스레드에서 더 이상 results_tx를 사용하지 않으므로 소유권을 포기한다.
    // 모든 워커 스레드의 results_tx_clone이 drop되어야 results_rx가 종료될 수 있다.
    drop(results_tx);

    // 5개의 작업을 jobs 채널에 보낸다.
    let inputs = vec![1, 2, 3, 4, 5];
    for job in inputs {
        jobs_tx.send(job).unwrap();
    }

    // 모든 작업을 보낸 후 jobs_tx의 소유권을 포기한다.
    // 송신자(tx)가 모두 사라지면 수신자(rx)는 에러(Err)를 반환받게 된다.
    // 이것이 워커 스레드에게 작업이 끝났음을 알리는 신호가 된다.
    drop(jobs_tx);

    // 결과 수집
    for received in results_rx {
        println!("결과: {}", received);
    }

    // 모든 워커 스레드가 작업을 마칠 때까지 대기
    for handle in handles {
        handle.join().unwrap();
    }
}

Rust는 Go와 유사하게 채널을 사용하지만, 가장 큰 차이점은 컴파일러(Borrow Checker)가 여러 스레드 간의 데이터 접근 규칙을 엄격하게 강제하여 런타임에 발생할 수 있는 많은 동시성 버그를 예방한다는 것입니다. 이는 최고 수준의 안정성을 제공하지만, 가파른 학습 곡선을 요구합니다.


Go 동시성의 장점과 트레이드오프#

장점#

  1. 단순함과 낮은 인지 부하: go 키워드와 chan이라는 최소한의 도구만으로 동시성 프로그램을 작성할 수 있습니다. 스레드 풀, 퓨처, 콜백 등 복잡한 개념을 먼저 배우지 않아도 됩니다.
  2. 고루틴의 압도적인 효율성: 고루틴은 생성 및 컨텍스트 스위칭 비용이 매우 낮아, 부담 없이 수많은 동시 작업을 생성할 수 있습니다. 이는 특히 I/O 바운드 작업이 많은 네트워크 서버에 최적화되어 있습니다.
  3. 경쟁 상태 방지 용이: 채널을 통한 통신은 자연스럽게 공유 메모리 접근을 줄여 데이터 경쟁의 가능성을 낮춥니다. 또한, go test -race라는 강력한 경쟁 상태 탐지 도구가 내장되어 있습니다.

단점 및 트레이드오프#

  1. 추상화의 한계: Go의 동시성 모델은 상대적으로 저수준입니다. 복잡한 동시성 패턴(예: 복잡한 파이프라인, 동적 라우팅)을 구현하려면 개발자가 직접 채널을 조합하여 상위 수준의 추상화를 구축해야 합니다. Kotlin의 구조화된 동시성 같은 정교한 제어 메커니즘이 부족합니다.
  2. “고루틴 누수(Goroutine Leaks)” 가능성: 채널이 영원히 블로킹되면 해당 채널을 기다리는 고루틴은 영원히 종료되지 않고 메모리를 차지하게 됩니다. 이를 방지하기 위해 context 패키지를 이용한 취소 및 타임아웃 처리를 신중하게 구현해야 합니다.
  3. 오용의 위험: Go는 채널을 권장하지만, 전통적인 뮤텍스(sync.Mutex)도 제공합니다. 개발자가 채널과 뮤텍스를 부적절하게 혼용하거나, 채널의 동작을 완전히 이해하지 못하면 데드락과 같은 미묘한 버그를 만들 수 있습니다. 단순한 도구라고 해서 항상 단순한 결과를 보장하는 것은 아닙니다.

결론#

Go의 동시성 모델은 모든 문제를 해결하는 만병통치약이 아닙니다. 이는 네트워크 서비스와 분산 시스템이라는 특정 목적을 위해 설계된, 매우 실용적이고 효율적인 도구입니다. 복잡한 동시성 이론 대신, gochan이라는 단순한 개념을 통해 ‘충분히 좋은’ 동시성을 매우 낮은 비용으로 구현할 수 있도록 하는 데 초점을 맞춥니다.

최고 수준의 안정성이 필요하다면 Rust의 컴파일 타임 검증이, 풍부한 기능과 성숙한 생태계가 중요하다면 Java나 Kotlin이 더 나은 선택일 수 있습니다. 그러나 빠르고 가벼우며 읽기 쉬운 코드로 수많은 동시 작업을 처리해야 하는 일반적인 서버 애플리케이션 개발에서 Go의 접근 방식은 타의 추종을 불허하는 강력한 생산성을 제공합니다.


References#