Rust

Go 개발자의 눈으로 본 Rust (4/4): 동시성부터 생태계, 빌드까지 최종 비교

드리프트2 2025. 4. 27. 23:00

Go 개발자의 눈으로 본 Rust (4/4): 동시성부터 생태계, 빌드까지 최종 비교

3편 복습 및 시리즈 마무리 예고

 

지난 3편까지 우리는 Go와 Rust의 언어 철학, 메모리 관리, 문법, 타입 시스템, 추상화 방식, 제네릭, 테스팅, 모듈 시스템 등 다양한 측면에서 두 언어의 차이점을 심층적으로 비교 분석했습니다.

 

Go의 간결함과 생산성, Rust의 안전성과 제어권이라는 각기 다른 매력을 확인할 수 있었는데요.

 

이번 마지막 4편에서는 현대적인 애플리케이션 개발에서 빼놓을 수 없는 비동기 처리(동시성) 모델, 개발 생산성과 직결되는 생태계(Ecosystem), 그리고 실제 개발 과정에 영향을 미치는 빌드 시스템(Build System)주요 활용 영역을 비교하며 이 긴 여정을 마무리하고자 합니다.

 

과연 Go의 고루틴과 Rust의 async/await는 어떤 근본적인 차이가 있으며, 각 언어의 생태계와 개발 환경은 어떤 특징을 가질까요? 마지막까지 함께 깊이 있는 탐구를 이어가 보겠습니다.

1. 비동기 처리: 고루틴의 마법 vs. async/await의 명시성

동시에 여러 작업을 효율적으로 처리하는 능력은 현대 프로그래밍 언어의 핵심 경쟁력입니다.

 

Go와 Rust는 이 문제를 각기 다른 방식으로 접근합니다.

  • Go: 고루틴(Goroutine)과 선점형 스케줄링(Preemptive Scheduling)
    Go의 동시성 모델은 고루틴이라는 경량 스레드를 기반으로 합니다. go 키워드 하나만 붙이면 함수를 비동기적으로 실행할 수 있어 매우 간편합니다. 핵심은 Go 런타임이 선점형 스케줄링을 수행한다는 점인데요. 즉, 개별 고루틴의 협조 없이도 런타임이 알아서 고루틴을 잠시 중단시키고 다른 고루틴에게 실행 기회를 넘겨줍니다. I/O 대기 등의 상황이 발생하면 런타임이 이를 감지하고 해당 고루틴을 잠시 쉬게 하면서 다른 작업을 처리합니다. 개발자는 복잡한 스케줄링 메커니즘을 거의 신경 쓰지 않고 동시성 코드를 작성할 수 있습니다. 동기화는 주로 채널(Channel) 을 통해 메시지를 주고받는 방식으로 이루어집니다.
    package main

    import (
        "fmt"
        "sync"
        "time"
    )

    func worker(id int, wg *sync.WaitGroup) {
        defer wg.Done() // 작업 완료 시 WaitGroup 카운터 감소
        fmt.Printf("Worker %d starting\n", id)
        time.Sleep(time.Second) // 작업 시뮬레이션
        fmt.Printf("Worker %d finished\n", id)
    }

    func main() {
        var wg sync.WaitGroup
        for i := 1; i <= 3; i++ {
            wg.Add(1) // WaitGroup 카운터 증가
            go worker(i, &wg) // 'go' 키워드로 고루틴 생성 및 실행
        }
        wg.Wait() // 모든 고루틴이 Done()을 호출할 때까지 대기
        fmt.Println("All workers finished")
    }
  • Rust: async/await와 협력적 스케줄링(Cooperative Scheduling)
    Rust의 비동기 모델은 async 키워드로 정의되는 비동기 함수와, 비동기 작업의 완료를 기다리는 .await 키워드를 중심으로 구성됩니다. 중요한 차이점은 Rust의 비동기 모델이 협력적(비선점형, Non-preemptive) 이라는 점입니다. 비동기 작업은 스스로 실행을 양보할 지점, 즉 .await를 만날 때까지 계속 실행됩니다. .await 지점에서 현재 작업은 잠시 중단되고, 비동기 런타임(Asynchronous Runtime) 에게 제어권을 넘겨 다른 준비된 작업을 실행할 기회를 줍니다.
  • Rust 표준 라이브러리 자체에는 비동기 런타임이 포함되어 있지 않습니다. 개발자는 Tokio, async-std, smol 등 외부 런타임 라이브러리를 선택하여 사용해야 합니다. async fn은 컴파일 시 Future 트레이트를 구현하는 상태 기계(State Machine)로 변환되며, 런타임의 실행기(Executor)가 이 Futurepoll 메서드를 호출하며 상태를 진행시킵니다.
    // 의존성: tokio = { version = "1", features = ["full"] }
    use tokio::time::{sleep, Duration};

    async fn async_worker(id: i32) {
        println!("Async Worker {} starting", id);
        sleep(Duration::from_secs(1)).await; // .await: 여기서 실행 양보 가능
        println!("Async Worker {} finished", id);
    }

    #[tokio::main] // Tokio 런타임 사용을 위한 매크로
    async fn main() {
        let mut tasks = vec![];
        for i in 1..=3 {
            // tokio::spawn으로 비동기 작업을 런타임에 제출
            let task = tokio::spawn(async_worker(i));
            tasks.push(task);
        }

        // 모든 작업이 완료될 때까지 기다림
        for task in tasks {
            task.await.expect("Task panicked");
        }
        println!("All async workers finished");
    }
  • 비교: 학습 곡선, 안전성, 성능
    • 학습 곡선: Go의 고루틴은 사용법이 매우 간단하여 배우기 쉽습니다. 반면 Rust의 async/awaitFuture 트레이트, 런타임 선택, 그리고 1편에서 다룬 소유권/생명주기 규칙과의 상호작용(예: async move, Arc<Mutex<...>>) 등 이해해야 할 개념이 더 많아 학습 곡선이 가파릅니다.
    • 안전성: Rust의 소유권과 빌림 규칙은 컴파일 시점에 데이터 경쟁(Data Race)을 대부분 방지해줍니다. 예를 들어 여러 비동기 작업에서 공유 데이터를 안전하게 다루려면 Arc<Mutex<T>>와 같은 동기화 타입을 사용해야 하며, 잘못 사용하면 컴파일 에러가 발생합니다. Go는 go run -race 탐지기를 통해 런타임에 데이터 경쟁을 발견할 수 있지만, 컴파일 시점의 보장은 약하며, 뮤텍스 사용 등을 개발자가 직접 신경 써야 합니다. 하지만 Rust 역시 논리적인 오류로 인한 데드락(Deadlock)까지 컴파일러가 완벽히 막아주지는 못하므로 주의가 필요합니다.
    • 성능: 직접적인 비교는 어렵습니다. Go 런타임의 스케줄러는 매우 효율적이지만 GC 오버헤드가 존재할 수 있습니다. Rust는 런타임 오버헤드가 거의 없고 GC가 없지만, 어떤 비동기 런타임을 선택하고 어떻게 코드를 작성하는지에 따라 성능이 크게 달라질 수 있습니다. I/O 중심 작업에는 Tokio가, CPU 중심 병렬 처리에는 Rayon 같은 라이브러리가 각각 강점을 보입니다.

2. 생태계: 중앙 집중 vs. 분산, 표준 라이브러리의 역할

개발 생산성은 언어 자체뿐만 아니라 주변 생태계에도 크게 의존합니다.

  • 패키지/모듈 레지스트리:
    • Go: 중앙 집중적인 공식 레지스트리가 없습니다. 주로 GitHub 같은 외부 저장소를 통해 패키지를 공유합니다. go get 명령은 기본적으로 Google이 운영하는 프록시/캐시 서버(proxy.golang.org)를 통해 모듈을 다운로드합니다. 라이브러리 공개 절차가 간편하지만, 공식적인 폐기(yank) 기능은 없습니다.
    • Rust: crates.io 라는 공식 중앙 레지스트리를 운영합니다. cargo add 명령은 여기서 크레이트(라이브러리)를 다운로드합니다. 라이브러리를 공개하려면 cargo publish 등 몇 가지 절차가 필요합니다. cargo yank 명령으로 특정 버전의 사용 중단을 알릴 수 있다는 장점이 있습니다. GitHub 등 직접적인 저장소 의존성 지정도 가능합니다.
  • 툴체인 (Toolchain): 통합 vs. 개별 관리
    • Go: 컴파일(go build), 포맷팅(go fmt), 린팅(go vet), 테스트(go test), 문서 생성(go doc), LSP(gopls) 등 대부분의 핵심 도구가 go 명령의 하위 명령으로 통합 제공됩니다. Go 버전을 업데이트하면 관련 도구도 함께 업데이트되어 관리가 용이합니다.
    • Rust: Cargo가 빌드 시스템이자 패키지 매니저 역할을 하며, 대부분의 도구(cargo build, cargo fmt, cargo test, cargo doc, cargo clippy)를 Cargo 하위 명령으로 실행합니다. 하지만 각 도구(rustc, rustfmt, clippy, rust-analyzer 등)는 개별적으로 개발되며, rustup 이라는 도구를 통해 전체 툴체인(컴파일러 버전 포함)을 관리하고 업데이트합니다. Go보다 도구 관리가 분리되어 있지만, clippy와 같은 강력한 외부 린터가 표준처럼 사용됩니다.
  • 표준 라이브러리:
    • Go: 매우 광범위하고 풍부한 표준 라이브러리를 제공합니다. 네트워크, HTTP, JSON 처리, 암호화, 이미지 처리, 데이터베이스 접근(기본 인터페이스) 등 웹 개발 및 시스템 프로그래밍에 필요한 대부분의 기능을 표준 라이브러리만으로 해결할 수 있는 경우가 많습니다.
    • Rust: 표준 라이브러리(std)는 상대적으로 최소한의 핵심 기능(기본 타입, 컬렉션, I/O 기초, 동시성 기초 등)만 제공합니다. 네트워크 클라이언트, 비동기 런타임, 웹 프레임워크, 직렬화/역직렬화, 데이터베이스 드라이버, 난수 생성 등 많은 기능은 외부 크레이트(라이브러리)에 의존해야 합니다. 이는 유연성을 높이지만, 어떤 라이브러리를 선택하고 관리해야 할지에 대한 고민이 필요합니다.

전반적으로 Go는 통합된 툴체인과 풍부한 표준 라이브러리를 통해 '배터리 포함(Batteries Included)' 철학을 보여주는 반면, Rust는 강력한 핵심 언어와 Cargo를 중심으로 활발한 커뮤니티 기반의 라이브러리 생태계에 더 의존하는 경향을 보입니다.

3. 빌드 시스템과 개발 환경: 단순성 vs. 유연한 제어

코드를 실행 가능한 결과물로 만드는 빌드 과정에서도 두 언어는 차이를 나타냅니다.

  • 컴파일러 백엔드:
    • Go: Google이 자체 개발한 컴파일러와 링커를 사용합니다.
    • Rust: 주로 LLVM을 컴파일러 백엔드로 사용하여 다양한 플랫폼 최적화의 이점을 얻습니다.
  • 빌드 프로파일 (Build Profiles):
    • Rust: Cargo.toml을 통해 빌드 프로파일(dev, release, test, bench 등)을 정의하고 각 프로파일별로 최적화 수준, 디버깅 정보 포함 여부 등 다양한 컴파일 옵션을 세밀하게 설정할 수 있습니다. 이는 개발 중 빠른 컴파일과 릴리스 시 최적화된 성능을 양립하게 해줍니다.
    • Go: 별도의 빌드 프로파일 개념은 없습니다. 빌드 플래그(-ldflags, -gcflags 등)를 통해 일부 컴파일/링킹 옵션을 제어합니다.
  • 크로스 컴파일 (Cross-compilation):
    • Go: 매우 간단합니다. GOOSGOARCH 환경 변수만 설정하면 별도의 설정 없이 다른 운영체제와 아키텍처용 바이너리를 쉽게 빌드할 수 있습니다.
    • Rust: 대상 아키텍처에 맞는 툴체인 타겟(rustup target add ...)과 크로스 링커 설치 등 사전 준비가 필요합니다. cross와 같은 외부 도구를 사용하면 이 과정을 단순화할 수 있지만, Go에 비해서는 다소 번거롭습니다.
  • 빌드 결과물 및 속도:
    • Go: 일반적으로 빌드 속도가 매우 빠릅니다. 빌드 결과물은 보통 실행 경로에 직접 생성됩니다.
    • Rust: Go에 비해 빌드 속도가 느린 경향이 있습니다. 특히 의존성이 많거나 복잡한 매크로, 제네릭을 사용할 경우 컴파일 시간이 길어질 수 있습니다. 빌드 결과물과 중간 파일들은 target/ 디렉토리에 프로파일과 타겟별로 구분되어 저장됩니다. 증분 컴파일(Incremental Compilation) 을 지원하여 변경된 부분만 다시 컴파일함으로써 후속 빌드 시간을 단축합니다. 하지만 이로 인해 target 디렉토리 크기가 커질 수 있어 주기적인 정리가 필요할 수 있습니다 (cargo clean).

빌드 환경 측면에서는 Go가 단순성과 속도, 쉬운 크로스 컴파일에서 강점을 보이며, Rust는 Cargo를 통한 세밀한 빌드 제어와 프로파일링 기능에서 유연성을 제공합니다.

4. 주요 활용 영역: 각자의 강점을 발휘하는 곳

두 언어 모두 다양한 영역에서 사용되지만, 특성과 생태계에 따라 두각을 나타내는 분야가 다소 다릅니다.

  • 프론트엔드 개발 도구: 놀랍게도 두 언어 모두 이 영역에서 활약하고 있습니다. Go는 esbuild와 같은 초고속 번들러로 유명세를 떨쳤습니다. Rust는 최근 SWC(트랜스파일러), Biome/Oxc(린터/포맷터), Rolldown(번들러) 등 차세대 JavaScript/TypeScript 툴체인 개발 언어로 각광받으며 생태계를 빠르게 확장하고 있습니다. Deno 런타임 역시 Rust로 작성되었습니다.
  • 백엔드/시스템 개발: Go는 간결함, 빠른 컴파일, 쉬운 동시성 모델 덕분에 클라우드 네이티브 환경(Docker, Kubernetes 등), 마이크로서비스, 네트워크 서버, CLI 도구 개발에 매우 널리 사용됩니다. Rust는 메모리 안전성과 높은 성능, 시스템 제어 능력 덕분에 운영체제, 게임 엔진, 웹 브라우저, 고성능 웹 서버(Actix-web, Axum 등), 블록체인, 그리고 Go와 마찬가지로 CLI 도구 등 성능과 안정성이 중요한 시스템 개발에 강점을 보입니다.
  • 임베디드 시스템: Go는 TinyGo라는 별도의 컴파일러를 통해 임베디드 개발을 지원합니다. Rust는 별도 컴파일러 없이 언어 자체적으로 임베디드 개발을 강력하게 지원하며, 안전성과 제어 능력이 필요한 이 영역에서 빠르게 입지를 넓혀가고 있습니다.

5. 시리즈를 마치며: 결국은 트레이드오프, 그리고 목적에 맞는 선택

총 4편에 걸쳐 Go와 Rust를 다양한 측면에서 비교 분석해보았습니다. Go는 간결한 문법, 쉬운 동시성, 빠른 컴파일, 풍부한 표준 라이브러리를 통해 개발 생산성을 극대화하는 데 초점을 맞추고 있습니다.

 

반면 Rust는 소유권 시스템, 강력한 타입 시스템, 제로 비용 추상화를 통해 메모리 안전성, 높은 성능, 정교한 제어를 달성하는 데 집중합니다.

 

어느 한 언어가 절대적으로 우월하다기보다는, 각 언어가 추구하는 가치와 설계 철학이 다르며, 이는 명백한 트레이드오프(Trade-off) 로 나타납니다.

 

Go의 단순함은 때로 표현력의 한계로 느껴질 수 있고, Rust의 강력한 기능과 안전성은 높은 학습 곡선과 느린 빌드 시간이라는 비용을 수반합니다.

 

결국 어떤 언어를 선택할지는 프로젝트의 요구사항, 팀의 경험과 선호도, 개발 속도와 장기적인 유지보수성, 그리고 성능과 안전성에 대한 요구 수준 등을 종합적으로 고려하여 결정해야 할 문제입니다.

 

Go 개발자로서 Rust를 배우고 사용해본 경험은 Go의 장점을 다시 한번 깨닫게 해주었을 뿐만 아니라, Rust가 제공하는 새로운 관점과 가능성을 통해 개발자로서의 시야를 넓히는 계기가 되었습니다.

 

이 시리즈가 Go 개발자 여러분들이 Rust를 이해하고, 나아가 각자의 상황에 맞는 최적의 기술 선택을 하는 데 조금이나마 도움이 되었기를 바랍니다.

 

긴 글 함께해주셔서 감사합니다!