Go 개발자의 눈으로 본 Rust (3/4): 제네릭, 테스트, 모듈 - 재사용성과 구조화의 차이
2편 복습 및 이번 편 미리보기
지난 2편에서는 Go와 Rust의 문법적 특징, 타입 시스템의 표현력, 그리고 추상화 방식(interface vs. trait)의 차이를 살펴보았습니다.
Rust가 제공하는 풍부한 문법과 강력한 타입 안전성 기능들이 Go의 간결함과는 또 다른 매력을 가지고 있음을 확인했는데요.
이번 3편에서는 한 걸음 더 나아가, 코드의 재사용성을 높이고 프로젝트를 효과적으로 구조화하는 데 필수적인 요소들을 비교 분석합니다.
바로 제네릭(Generics), 테스팅(Testing) 프레임워크, 그리고 모듈 시스템(Module System) 입니다.
두 언어가 이 중요한 영역들에서 어떤 철학적, 기술적 차이를 보이는지, Go 개발자의 관점에서 자세히 들여다보겠습니다.
1. 제네릭: 타입 추상화의 다른 접근법
Go는 1.18 버전에서 제네릭을 도입하여 타입에 구애받지 않는 코드를 작성할 수 있게 되었고, Rust는 초기부터 강력한 제네릭 시스템을 갖추고 있었습니다.
두 언어의 제네릭은 목적은 같지만, 제약조건의 표현 방식과 내부 구현 메커니즘에서 차이를 보입니다.
- 제약조건 (Constraints): 인터페이스/타입 vs. 트레이트
제네릭 타입 파라미터가 어떤 조건을 만족해야 하는지를 명시하는 제약조건에서 두 언어는 다른 접근 방식을 취합니다.- Go: 제약조건으로 인터페이스뿐만 아니라 구체적인 타입의 유니온(
int | string
)이나 기본 타입 기반 집합(~string
) 등을 사용할 수 있습니다. 이는 특정 타입 집합에만 적용되는 제네릭을 비교적 직관적으로 정의할 수 있게 합니다. 하지만, 인터페이스에 정의된 메서드 자체에 타입 파라미터를 사용하는 것은 불가능합니다.
- Go: 제약조건으로 인터페이스뿐만 아니라 구체적인 타입의 유니온(
// Go: 타입 유니온 제약조건
func processBytesOrString[T []byte | string](data T) { /* ... */ }
// Go: 인터페이스 제약조건
type ReadWriter interface { Read([]byte) (int, error); Write([]byte) (int, error) }
func handleReadWriter[T ReadWriter](rw T) { /* ... */ }
// Go: 메서드에 타입 파라미터 불가
// type Processor interface {
// Process[T any](data T) error // 컴파일 에러!
// }
Rust: 제약조건은 기본적으로 트레이트(Trait) 를 사용합니다.
특정 타입이나 타입 집합을 직접 제약조건으로 명시할 수는 없습니다. 대신, 필요한 동작을 트레이트로 정의하고 해당 트레이트를 제약조건으로 사용합니다.
흥미롭게도, 트레이트의 메서드에는 타입 파라미터를 사용할 수 있어 더욱 유연한 제네릭 인터페이스 설계가 가능합니다.
다중 트레이트 제약조건은 +
기호로 간결하게 표현합니다.
// Rust: 트레이트 제약조건
use std::fmt::Debug;
fn print_debug<T: Debug>(item: T) { println!("{:?}", item); }
// Rust: 다중 트레이트 제약조건
use std::fmt::Display;
fn print_debug_and_display<T: Debug + Display>(item: T) { /* ... */ }
// Rust: 트레이트 메서드에 제네릭 사용 가능
trait Processor {
fn process<T>(&self, data: T) -> Result<T, String>; // OK!
}
Go는 타입 자체를 제약하는 데 유연하고, Rust는 트레이트를 통한 행위(behavior) 기반 제약과 메서드 레벨 제네릭에서 강점을 보입니다.
실제 사용에서는 Rust의 트레이트를 활용하여 Go의 타입 기반 제약과 유사한 효과를 내는 경우가 많습니다.
- 구현 방식: 딕셔너리/GC Shape vs. 단형성화(Monomorphization)
두 언어는 제네릭 코드를 실제 실행 가능한 코드로 변환하는 방식이 다릅니다. (2편에서 잠시 언급)- Go: 컴파일 시 제네릭 함수/타입의 단일 코드를 생성하고, 런타임에 타입별 정보를 담은 딕셔너리를 참조하거나 GC Shape가 같은 타입끼리 코드를 공유하는 방식을 사용합니다. 이는 바이너리 크기를 작게 유지하지만, 약간의 런타임 오버헤드를 발생시킬 수 있습니다.
- Rust: 컴파일 시 제네릭 코드가 사용된 각 구체적인 타입에 대해 별도의 코드를 생성합니다 (단형성화). 이는 런타임 오버헤드가 거의 없는 높은 실행 속도를 보장하지만, 바이너리 크기가 커질 수 있다는 단점이 있습니다.
2. 테스팅: 내장 기능과 생태계의 조화
소프트웨어의 품질을 보장하기 위한 테스팅 환경에서도 Go와 Rust는 각각의 특징을 가지고 있습니다.
- Go:
- 내장 테스팅 프레임워크:
go test
명령과testing
패키지를 통해 별도의 라이브러리 없이 단위 테스트, 벤치마크 테스트, 예제 테스트를 표준화된 방식으로 작성하고 실행할 수 있습니다. - 파일 규칙: 테스트 코드는
*_test.go
라는 이름 규칙을 따르는 파일에 작성합니다. 테스트 함수는TestXxx(t *testing.T)
시그니처를 가집니다. - 실행 방식: 기본적으로 패키지 내 테스트는 직렬(serial) 로 실행되지만, 패키지 간에는 병렬(parallel) 로 실행됩니다.
t.Parallel()
메서드를 사용하여 테스트 함수나 서브 테스트(t.Run
)를 명시적으로 병렬 실행하도록 지정할 수 있습니다. - 테이블 기반 테스트: 여러 입력과 기대값을 구조체 슬라이스 형태로 정의하여 테스트하는 테이블 기반 테스트(Table-Driven Tests) 패턴이 널리 사용됩니다.
- 내장 테스팅 프레임워크:
// main_test.go
package main
import "testing"
func Add(a, b int) int { return a + b } // 테스트 대상 함수
func TestAdd(t *testing.T) {
t.Parallel() // 이 테스트를 다른 테스트와 병렬 실행
testCases := []struct {
name string
a, b int
want int
}{
{"simple case", 1, 2, 3},
{"negative case", -1, -2, -3},
{"zero case", 0, 0, 0},
}
for _, tc := range testCases {
tc := tc // 루프 변수 캡처 (병렬 실행 시 중요)
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // 서브 테스트도 병렬 실행
got := Add(tc.a, tc.b)
if got != tc.want {
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.want)
}
})
}
}
- Rust:
- 내장 테스팅 기능 및
cargo test
: Rust 역시 별도 라이브러리 없이cargo test
명령을 통해 테스트를 실행할 수 있습니다. 테스트 함수는#[test]
어트리뷰트를 붙여 표시합니다. - 테스트 위치:
- 단위 테스트(Unit Tests): 일반적으로 테스트 대상 코드가 있는 파일 내부에
#[cfg(test)]
모듈을 만들어 작성합니다. 이를 통해 비공개(private) 함수나 모듈도 테스트할 수 있다는 장점이 있습니다. - 통합 테스트(Integration Tests): 프로젝트 루트에
tests
디렉토리를 만들고 그 안에 파일을 작성합니다. 통합 테스트는 라이브러리 사용자 관점에서 공개된 API만을 사용하여 테스트합니다. Go에서는 워크스페이스 기능 등을 활용하여 유사한 테스트를 구성할 수 있습니다.
- 단위 테스트(Unit Tests): 일반적으로 테스트 대상 코드가 있는 파일 내부에
- 실행 방식: 기본적으로 테스트는 스레드를 사용하여 병렬(parallel) 로 실행됩니다. 테스트 간 독립성을 확인하는 데 유리하며, 테스트 시간을 단축할 수 있습니다. 직렬 실행을 원하면
cargo test -- --test-threads=1
옵션을 사용합니다. assert!
매크로: 테스트 결과를 검증하기 위해assert!
,assert_eq!
,assert_ne!
등의 매크로를 표준 라이브러리에서 제공합니다.- 문서 테스트 (Doc Tests): Rust의 독특하고 강력한 기능입니다. 소스 코드 주석(
///
또는//!
) 내에 작성된 코드 예제를cargo test
실행 시 실제로 컴파일하고 실행하여 검증합니다. 이를 통해 문서의 예제 코드가 항상 최신 상태로 정확하게 작동함을 보장할 수 있습니다.
- 내장 테스팅 기능 및
// src/lib.rs (단위 테스트 예시)
pub fn add(left: usize, right: usize) -> usize {
left + right
}
/// Adds two numbers.
///
/// # Examples // 문서 테스트 시작
///
/// ```
/// let result = my_library::add(2, 2); // 실제 컴파일 및 실행됨
/// assert_eq!(result, 4);
/// ``` // 문서 테스트 끝
pub fn add_documented(left: usize, right: usize) -> usize {
left + right
}
#[cfg(test)] // 이 모듈은 테스트 시에만 컴파일됨
mod tests {
use super::*; // 부모 모듈(여기서는 lib.rs)의 항목들을 가져옴
#[test] // 이 함수가 테스트 함수임을 알림
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4); // 값이 같은지 검증
}
#[test]
#[ignore] // 이 테스트는 기본적으로 실행하지 않음 (cargo test -- --ignored 로 실행 가능)
fn expensive_test() {
// ... 시간이 오래 걸리는 테스트 ...
}
#[test]
#[should_panic] // 이 테스트는 패닉이 발생해야 성공
fn test_panic() {
panic!("Expected panic");
}
}
// tests/integration_test.rs (통합 테스트 예시)
use my_library; // 라이브러리 사용자처럼 import
#[test]
fn test_add_integration() {
assert_eq!(my_library::add(5, 5), 10);
assert_eq!(my_library::add_documented(1, 1), 2);
}
Go는 표준화되고 간결한 테스트 방식을 제공하며, Rust는 테스트 위치의 유연성(비공개 함수 테스트 가능), 기본 병렬 실행, 그리고 강력한 문서 테스트 기능을 제공한다는 점에서 차이가 있습니다.
특히 Rust의 문서 테스트는 라이브러리 품질 관리에 매우 유용한 기능이라고 생각합니다.
3. 모듈 시스템: 패키지와 워크스페이스 vs. 크레이트와 모듈
프로젝트 규모가 커짐에 따라 코드를 구성하고 의존성을 관리하는 모듈 시스템의 중요성도 커집니다. Go와 Rust는 용어와 구조에서 차이를 보입니다.
- 개념 비교:
Go 용어 설명 Rust 용어 설명 패키지 같은 디렉토리에 있는 .go
파일들의 집합모듈 (Module) mod
키워드로 정의되는 코드 범위 (파일 또는 파일 내 블록)모듈 관련된 패키지들의 모음 ( go.mod
파일 기준)크레이트 (Crate) 컴파일 단위. 라이브러리 또는 바이너리. 모듈들의 트리 구조. 워크스페이스 여러 Go 모듈을 함께 관리하는 단위 패키지 (Package) 하나 이상의 크레이트를 포함하며 빌드 정보를 가짐 ( Cargo.toml
기준)워크스페이스 여러 Rust 패키지를 함께 관리하는 단위 - 의존성 관리 및 설정 파일:
- Go:
go.mod
파일을 사용하여 모듈 정보와 의존성을 관리합니다.go get
명령으로 의존성을 추가/관리합니다. 빌드 관련 설정은 주로 환경 변수나 빌드 플래그를 사용합니다. - Rust:
Cargo.toml
(매니페스트 파일)을 사용하여 패키지 정보, 의존성, 빌드 프로파일, 워크스페이스 설정, 피처 플래그 등 프로젝트에 관한 거의 모든 메타 정보를 관리합니다.cargo add
명령이나 직접 파일 수정으로 의존성을 추가합니다.Cargo.toml
은 Go의go.mod
보다 훨씬 많은 설정 옵션을 제공하여 프로젝트 빌드 및 구성을 세밀하게 제어할 수 있습니다.
- Go:
- 모듈 정의 및 구조:
- Go: 패키지는 디렉토리 구조와 일치합니다. 같은 디렉토리 내 파일들은 모두 같은 패키지에 속합니다.
- Rust: 모듈은
mod
키워드를 사용하여 정의합니다. 파일 자체가 하나의 모듈이 될 수도 있고(foo.rs
는foo
모듈), 파일 내에mod my_module { ... }
형태로 하위 모듈을 정의할 수도 있습니다. 이는 Go보다 더 유연하게 코드 네임스페이스를 구성할 수 있게 합니다. (예: 테스트 코드를 위한mod tests { ... }
)
- 가시성 제어 (Visibility):
- Go: 이름의 첫 글자가 대문자이면 공개(Public), 소문자이면 패키지 비공개(Package Private) 로 결정됩니다. 단순하고 명확합니다.
- Rust: 기본적으로 모든 항목(함수, 구조체, 모듈 등)은 비공개입니다.
pub
키워드를 사용하여 명시적으로 공개해야 합니다. 더 나아가pub(crate)
(크레이트 내에서만 공개),pub(super)
(부모 모듈에서만 공개),pub(in path::to::module)
(특정 경로 내에서만 공개) 등 매우 세분화된 가시성 제어가 가능합니다. Go보다 복잡하지만, 내부 구현을 숨기고 공개 API를 정교하게 설계하는 데 유리합니다.
- 피처 플래그 (Feature Flags):
- Rust:
Cargo.toml
에서 피처 플래그를 정의하여 특정 기능을 조건부로 컴파일에 포함하거나 제외할 수 있습니다. 예를 들어, 특정 운영체제에서만 필요한 코드나, 선택적인 의존성을 관리하는 데 유용합니다.cargo build --features "my_feature"
와 같이 빌드 시 활성화할 피처를 지정할 수 있습니다. Tokio와 같은 대형 라이브러리는 다양한 피처를 제공하여 사용자가 필요한 기능만 선택적으로 포함하도록 합니다. - Go: 빌드 태그(Build Tags) 를 사용하여 유사한 조건부 컴파일을 구현합니다. 파일 상단에
//go:build mytag
와 같은 주석을 추가하고,go build -tags mytag
로 빌드합니다. Rust 피처 플래그에 비해 파일 단위로 적용되며,Cargo.toml
처럼 중앙에서 피처 간의 관계를 정의하는 기능은 부족합니다.
- Rust:
전반적으로 Rust의 Cargo와 모듈 시스템은 Go에 비해 더 많은 기능과 세밀한 제어 옵션을 제공합니다.
이는 복잡한 프로젝트 구성이나 조건부 컴파일 요구사항이 있을 때 강력한 장점이 될 수 있지만, Go의 단순함에 익숙한 개발자에게는 초기 학습 곡선이 존재할 수 있습니다.
4. 3편을 마치며: 재사용성, 테스트, 구조화에 대한 다른 시각
이번 편에서는 제네릭, 테스팅, 모듈 시스템이라는 세 가지 중요한 영역에서 Go와 Rust의 차이점을 살펴보았습니다.
제네릭 구현 방식의 트레이드오프, 테스트 철학과 기능의 차이, 프로젝트 구조화 및 의존성 관리 방식의 상이점 등을 확인할 수 있었는데요.
Go는 표준화되고 간결한 접근 방식을 통해 개발자가 핵심 로직에 집중하도록 돕는 반면, Rust는 풍부한 기능과 세밀한 제어 옵션을 제공하여 더 높은 수준의 추상화, 엄격한 테스트 검증, 유연한 프로젝트 구성 가능성을 열어줍니다.
다음 마지막 4편에서는 개발 경험에 직접적인 영향을 미치는 비동기 처리(Asynchronous Processing) 의 구현 방식과 성능, 두 언어의 생태계(Ecosystem) 와 빌드 시스템(Build System), 그리고 각각 주로 활용되는 개발 영역(Domain) 에 대해 비교하며 이 시리즈를 마무리하겠습니다.
Go의 고루틴과 Rust의 async/await
는 어떤 차이가 있을지, 라이브러리 생태계는 어떠한지 등 흥미로운 주제들이 기다리고 있습니다.
'Rust' 카테고리의 다른 글
Rust (러스트)의 매크로와 함수: 언제 무엇을 사용해야 할까요? (0) | 2025.05.17 |
---|---|
Go 개발자의 눈으로 본 Rust (4/4): 동시성부터 생태계, 빌드까지 최종 비교 (0) | 2025.04.27 |
Go 개발자의 눈으로 본 Rust (2/4): 문법부터 타입 시스템까지 - 표현력과 안전성의 균형 (0) | 2025.04.27 |
Go 개발자의 눈으로 본 Rust (1/4): 다른 철학, 새로운 규칙 - 소유권과 생명주기 (0) | 2025.04.27 |
인터뷰 번역) Tauri vs 다른 Rust GUI 프레임워크: Arboretum 개발자의 이야기 (1) | 2025.02.09 |