카테고리 보관물: Programming

Rust의 Sized / ?Sized (Marker Trait)

간단한 Rust Template 함수하나를 생각해 보자. Argument ‘T’를 받아서 화면에 출력하는 코드이다.

fn print_item<T: Debug>(item: &T) {
    println!("{:?}", item);
}

Template의 취지에 맞게 넘겨지는 argument의 type을 달리하면 그에 해당하는 함수들이 자동으로 만들어지고 해당 코드가 호출된다.

fn main() {
    let t1: i32 = 42;
    let t2: i64 = 42;
    let t3: f32 = 42.;
    let t4: f64 = 42.;
    let t5: String = "hello".to_string();
    print_item(&t1);
    print_item(&t2);
    print_item(&t3);
    print_item(&t4);
    print_item(&t5);
}

objdump를 이용해서 이 template에 의해 실제로 생성된 함수들을 살펴보면 다음과 같이 각 데이터 타입에 대해 생성된 것들을 확인할 수 있다.

$> objdump -d -C ./print_item|grep print_item
./print_item:	file format mach-o arm64
00000001000008c0 <print_item::print_item::<alloc::string::String>>:
0000000100000908 <print_item::print_item::<f64>>:
0000000100000950 <print_item::print_item::<f32>>:
0000000100000998 <print_item::print_item::<i32>>:
00000001000009e0 <print_item::print_item::<i64>>:
0000000100000ce8 <print_item::main>:
...

문자열 슬라이스 template

이번에는 print_item() 함수에 &str type을 한번 넘겨 보자

let t6: &str = "hello";
print_Item(t6);

Rust 컴파일러 특유의 길고 자세한 오류 보고인데 요약하자면:

`t6`의 크기를 컴파일 타임에 알 수 없어서 컴파일에 실패 했습니다. 왜냐하면 str 타입에 대해서 Sized trait 이 구현되어 있지 않거든요. 함수 정의 쪽에 ?Sized를 추가해 보세요.

Rust는 “Zero 비용 추상화“를 지향하기 때문에 이를 지원하기 위한 수단으로 컴파일 시간에 모든 데이터의 크기를 “알고” 있는 것을 전제로 한다. 그런데 &str 같은 슬라이스를 print_item(&T) 함수에 넘기면 T가 str이 되므로 원래 할당된 공간의 "hello"를 가리키게 된다. 현재는 그 크기가 다섯 글자짜리 문자열이지만, 추후에 받아들이는 문자열의 실제 길이에 따라 데이터의 크기가 변할 수 있는 상황이 되는 것이다.

이 경우에는 컴파일 오류에서 안내된 것 처럼 ?Sized를 template의 type에 추가해서 type 확인에 대한 제한을 완화해 주면 해당 오류가 해결되면서 str type의 함수 template도 잘 동작한다.

$> objdump -d -C ./print_item|grep print_item
./print_item:	file format mach-o arm64
00000001000008c0 <print_item::print_item::<alloc::string::String>>:
0000000100000908 <print_item::print_item::<f64>>:
0000000100000950 <print_item::print_item::<str>>:
000000010000099c <print_item::print_item::<f32>>:
00000001000009e4 <print_item::print_item::<i32>>:
0000000100000a2c <print_item::print_item::<i64>>:
0000000100000d58 <print_item::main>:
...

크기를 알아야 된다매?

대부분의 경우에는 Rust compiler가 암묵적으로 Sized Marker Trait을 자동으로 붙여주어서 그 크기를 알고 있다고 알린다. 하지만 사용자가 ?Sized를 붙이는 경우에는 크기 확인에 대한 제약을 완화해 주어서 컴파일을 가능하게 해준다. 위 예제의 경우 &str fat pointer를 넘기게 되므로 실제 데이터 “hello”의 크기는 늘거나 줄어들 수 있어도 이것을 가리키고 있는 fat pointer의 크기 자체는 변하지 않으므로 ?Sized 로 조건을 완화해도 문제가 없는 것이다.

다시 말해서, 컴파일 타임에 “모든” 데이터 타입의 크기를 알고 있어야 한다는 것은 Rust에서 엄밀하게 말해 정확하지 않다.

좀 더 정확하게 말하자면:

“스택에 직접 올라가는 값이나 변수의 크기는 컴파일 타임에 반드시 알아야 하지만, 포인터나 참조자로 간접 참조하는 데이터는 그렇지 않아도 된다.”

여기에서 “그렇지 않아도 되는” 조건에 &str template에 해당하는 것이어서 ?Sized로 이것을 풀어 줄 수 있었던 것이다.

Sized / ?Sized Marker Trait

Sized는 Rust 표준 라이브러리(std::marker::Sized)에 정의된 마커 트레이트(marker trait)이다. Sized marker trait은 다음과 같은 특징을 가진다.

  • 메소드가 없음: 실행해야 할 함수가 없고, 컴파일러에게 “이 타입은 크기를 컴파일 타임에 알 수 있다”고 알려주는 표식 역할만 수행.
  • 기본값(Default): Rust에서 제네릭 타입 파라미터를 만들 때, 우리가 따로 적지 않아도 컴파일러는 기본적으로 T: Sized가 붙어 있는 것으로 간주.

이런게 왜 필요할까?

만약, Rust가 Sized / ?Sized 같은 번접한 marker trait을 빼고 “모든 타입의 크기를 몰라도 된다”라고 기본 설정을 했다면 컴파일 타임에 ‘모든’ 데이터 크기를 알고 Zero cost abstraction 정책으로 stack을 중심으로 하는 효율적 데이터 할당을 통한 성능 최적화 추구를 위한 설계 철학 자체가 이뤄질 수 없었을 것이다. 모든 데이터가 동적으로 할당되는 heap에 있었어야 했을 테니 말이다.

  • 스택(Stack): CPU의 스택 포인터 레스트터와 변경하면 되는 매우 빠른 메모리 할당, 데이터의 크기를 미리 알고 있는 경우(Sized가 선언된 경우) 이 영역을 사용할 수 있음.
  • 힙(Heap): 빈 공간을 검색하고, 단편화를 관리하며 운영체제 시스템 콜을 부를 수도 있는 상대적으로 느린 동적 할당.

Rust의 모든 generic이 기본적으로 Sized를 보장 함으로써, 데이터를 스택에 올려 둘 수 있었던 것이고 이것이 Rust의 빠른 성능의 바탕이 된다. 결론적으로 ?Sized는 이 철학을 깨는 것은 아니고 fat pointer의 고정된 크기는 알고 있으니 그것이 가리키고 있는 영역에 대한 검사 조건을 완화 해도 문제가 없다는 표시인 것이다.

Rust unittest를 위한 의존성 격리

다행히도 우리는 unittest의 유용함 잘 알려진 시대에 살고 있기에 더 이상 unittest를 지원하는 프로그래밍 언어를 특별하게 받아들이지 않는다. Rust는 비교적 최신 언어인 만큼 이러한 요구사항들을 적극적으로 받아들여 부가적인 프레임워크의 도움 없이도 언어 자체에서 기본적인 unittest가 잘 지원된다.

다만, Rust는 상속을 지원하지 않기 때문에 Python, Java, C++ 같은 객체지향 언어에서 의존성을 격리 할 때 사용되는 method overriding이나 object patching을 사용할 수는 없다. 일반적인 방법으로 mock injection을 수행할 수 없다면 매번 모든 의존성을 물려 놓고 test를 수행해야만 할까?

다행히도 pytest의 mocker patch 만큼 유연하게는 아니지만, Rust에서도 trait + automock의 조합으로 의존성을 격리해서 unittest를 작성할 수 있다.

Trait

Trait은 객체간의 계약을 정의한다는 점에서 다른 언어의 interface와 닮은 점이 많다. 예를 들어 데이터 베이스에 접근해서 계좌의 잔고를 확인하고 업데이트하는 다음과 같은 간단한 Database trait을 생각해 보자.

pub trait Database {
    // 사용자의 잔고를 반환, 오류가 발생하면 에러 메세지 반환.
    fn get_user_balance(&self, user_id: u64) -> Result<u64, String>;

    // 사용자의 잔고를 amount로 업데이트.
    fn update_user_balance(&mut self, user_id: u64, amount: u64) -> Result<(), String>;
}

우리는 실제 구현이 없는 이 trait을 이용해서 unittest를 위한 mock inject을 수행할 것이다. 즉, 이 trait을 받아들이는 “사용자”를 정의한 다음, 테스트를 위해서는 mock으로 구성된 객체를 넘겨주어서 테스트를 수행하고, 실제로 Database trait을 정의하는 프로덕션 코드는 나중에 정의해서 넘겨주려는 것이다.

WalletService – Database Trait 사용자

이 trait을 받아들이는 “사용자”인 WalletService객체는 이 database trait에 interface를 통해 접근하게 된다. 다음의 코드에서 처럼 출금 기능 구현을 위해 database를 접근한다.

pub struct WalletService<DB: Database> {
    db: DB,
}

impl<DB: Database> WalletService<DB> {
    pub fn new(db: DB) -> Self {
        Self { db }
    }
    // 출금
    pub fn withdraw(&mut self, user_id: u64, amount: u64) -> Result<u64, String> {
        let current_balance = self.db.get_user_balance(user_id)?;
        if current_balance < amount {
            return Err(String::from("잔액이 부족합니다."));
        }
        let new_balance = current_balance - amount;
        self.db.update_user_balance(user_id, new_balance)?;
        Ok(new_balance)
    }
}

fn new(db: DB)에서 정의된 것 처럼, 이 객체는 Database trait을 구현하는 모든 객체를 받을 수 있다. 이제 우리는 Database trait을 구현하는 mock 객체를 이 함수에 넘겨주면 된다.

Mockall Crate – Automock

우리가 WalletService의 database 접근에 대한 동작을 테스트하기 위해 직접 Database trait을 구현하는 mock 객체를 구현할 수도 있겠지만, Mockall crate의 Automock은 이 과정을 편리하게 수행할 수 있도록 도와 준다.

먼저 Mockall crate을 의존성 목록에 추가해 준다.

cargo add mockall

앞서 만들었던 Database trait에 unittest 환경에서만 mock 객체를 생성하도록 #[cfg_attr(test, automock)] 매크로를 선언해준다.

use mockall::automock;


#[cfg_attr(test, automock)]
pub trait Database {
    // 사용자의 잔고를 반환, 오류가 발생하면 에러 메세지 반환.
    fn get_user_balance(&self, user_id: u64) -> Result<u64, String>;

    // 사용자의 잔고를 amount로 업데이트.
    fn update_user_balance(&mut self, user_id: u64, amount: u64) -> Result<(), String>;
}

Mock Object를 이용한 Unittest작성

먼저 출금을 시도 할 때, 예금 조회에서 충분한 금액이 반환되어 성공하는 경우를 테스트 해보자.

#[test]
fn test_withdraw_success() {
    // mockall이 자동 생성한 MockDatabase 구조체 사용
    let mut mock_db = MockDatabase::new();

    // Expectation(기대치) 설정
    // get_user_balance는 인자로 사용자 id인 1을 받아 잔액 1000000을 반환, 1번 호출되어야 함.
    mock_db.expect_get_user_balance()
        .with(mockall::predicate::eq(1))
        .times(1)
        .returning(|_| Ok(1000000));

    // update_user_balance는 인자로 사용자 id '1'과 잔액 '500000'을 받아 성공(())을 반환, 1번 호출되어야 함.
    mock_db.expect_update_user_balance()
        .with(mockall::predicate::eq(1), mockall::predicate::eq(500000))
        .times(1)
        .returning(|_, _| Ok(()));

    // Database trait 사용자인 WalletService에 mock injection
    let mut service = WalletService::new(mock_db);

    // 실행 및 검증
    let result = service.withdraw(1, 500000);
    assert!(result.is_ok());
    assert_eq!(result.unwrap(), 500000);
}

4번째 줄을 보면 MockDatabase::new()로 Database trait의 mock 객체를 생성하는데, 이렇게 생성된 객체는 automock 덕분에 어떤 함수가 몇 번 호출 되었는지 여부와 받은 인자 값 검사 그리고 해당 불려진 함수가 반환할 값을 지정해 주는 함수들이 자동으로 포함된다. 위 코드에서 6번째 줄부터 16번째 줄까지의 내용이 이렇게 자동으로 생성된 mock 객체에서 지원해 주는 함수들을 통해 expectation을 설정하는 주는 부분이다.

  • expect_<함수명>(): trait의 멤버인 함수가 호출되지 않으면 테스트가 실패한다.
  • with(): 해당 함수가 호출될 때 넘겨받은 인자 값을 확인한다.
  • times(): 해당 함수가 호출 된 횟수를 확인한다. 이 경우 함수가 정확히 1번 호출되는 경우가 아니라면 테스트가 실패한다.
  • returning(): 해당 함수가 반환할 값을 지정한다.

예금 부족으로 출금에 실패하는 경우도 작성해 보자.

#[test]
fn test_withdraw_insufficient_funds() {
    let mut mock_db = MockDatabase::new();

    // 잔액이 부족한 상황 연출 (현재 잔액 300000)
    mock_db.expect_get_user_balance()
        .with(mockall::predicate::eq(2))
        .times(1)
        .returning(|_| Ok(300000));

    // 잔액이 부족하므로 update_user_balance는 호출되지 않아야 함 (times(0))
    mock_db.expect_update_user_balance()
        .times(0);

    let mut service = WalletService::new(mock_db);

    let result = service.withdraw(2, 500000);
    assert!(result.is_err());
    assert_eq!(result.unwrap_err(), "잔액이 부족합니다.");
}

전체코드

use mockall::automock;

// 데이터베이스 연동을 담당하는 Trait
#[cfg_attr(test, automock)]
pub trait Database {
    fn get_user_balance(&self, user_id: u64) -> Result<u64, String>;
    fn update_user_balance(&mut self, user_id: u64, amount: u64) -> Result<(), String>;
}

// 데이터베이스를 주입받아 비즈니스 로직을 수행하는 서비스 구조체
pub struct WalletService<DB: Database> {
    db: DB,
}

impl<DB: Database> WalletService<DB> {
    pub fn new(db: DB) -> Self {
        Self { db }
    }

    // 출금 로직 예시
    pub fn withdraw(&mut self, user_id: u64, amount: u64) -> Result<u64, String> {
        let current_balance = self.db.get_user_balance(user_id)?;

        if current_balance < amount {
            return Err(String::from("잔액이 부족합니다."));
        }

        let new_balance = current_balance - amount;
        self.db.update_user_balance(user_id, new_balance)?;

        Ok(new_balance)
    }
}

// Unittest
#[cfg(test)]
mod tests {
    use super::*;

   #[test]
    fn test_withdraw_success() {
        // mockall이 자동 생성한 MockDatabase 구조체 사용
        let mut mock_db = MockDatabase::new();

        // Expectation(기대치) 설정
        // get_user_balance는 인자로 사용자 id인 1을 받아 잔액 1000000을 반환, 1번 호출되어야 함.
        mock_db.expect_get_user_balance()
            .with(mockall::predicate::eq(1))
            .times(1)
            .returning(|_| Ok(1000000));

        // update_user_balance는 인자로 사용자 id '1'과 잔액 '500000'을 받아 성공(())을 반환, 1번 호출되어야 함.
        mock_db.expect_update_user_balance()
            .with(mockall::predicate::eq(1), mockall::predicate::eq(500000))
            .times(1)
            .returning(|_, _| Ok(()));

        // Database trait 사용자인 WalletService에 mock injection
        let mut service = WalletService::new(mock_db);

        // 실행 및 검증
        let result = service.withdraw(1, 500000);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), 500000);
    }

    #[test]
    fn test_withdraw_insufficient_funds() {
        let mut mock_db = MockDatabase::new();

        // 잔액이 부족한 상황 연출 (현재 잔액 300000)
        mock_db.expect_get_user_balance()
            .with(mockall::predicate::eq(2))
            .times(1)
            .returning(|_| Ok(300000));

        // 잔액이 부족하므로 update_user_balance는 호출되지 않아야 함 (times(0))
        mock_db.expect_update_user_balance()
            .times(0);

        let mut service = WalletService::new(mock_db);

        let result = service.withdraw(2, 500000);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), "잔액이 부족합니다.");
    }
}

결론

켄트 벡의 TDD 입문서인 “테스트 주도 개발“을 막 읽고나서 이것을 내 개발 규칙에 도입하려고 노력하던 초기에 gmock에서 제공한다는 함수 호출 횟수를 검사하는 기능을 봤을 때는 “이게 유용하기는 할까?” 하는 의구심을 가졌던 적이 있었다. 그 이후로 이 기능이 mock injection을 할 때 caller의 expectation을 점검하는 무척 유용할 기능이라는 것을 깨닫게 되면서 Java의 unittest에서도 mockito를 선호하게 되었는데, Rust에서도 이러한 기능을 제공하는 crate이 있다는 점이 무척 다행스럽다.

라이브러리 확장 기능이 Rust에서 처음 도입된것도 유일한 것도 아니지만, 현대 프로그래밍 언어가 지향하는 바를 적극적으로 받아들이면서도 고성능을 유지하는 Rust가 점점 더 매력있게 느껴진다.

Rust: pbjson crate에서 “redundant reference” 경고

로컬에서 lint 검사 까지 마치고 서버로 push 했는데, 로컬에서 보이지 않았던 "redundant referece" 경고가 뜨면서 clippy를 수행하는 Rust용 CI가 실패 했다. 현재 프로젝트에서 Rust용 CI는 경고만 떠도 실패로 간주하는 -D warnings 파라미터를 설정하고 있는데 이것 때문에 CI 전체가 실패하는 문제가 생겼다.

로컬과의 차이점을 살펴 보니, local에서는 Cargo 1.94.1 버전을 사용하는데 반해, CI 서버에는 stable latest를 사용하도록 설정해 두어서 2026년 7월 현재 가장 최신 안정버전인 1.97.1을 사용하고 있었다.

이 버전에는 useless_borrow_in_formatting이라는 새로운 lint 항목이 추가 되었는데, 이것은 이미 reference인 변수에 중복해서 borrowing을 시도하는 경우 경고를 띄우는 검사항목이다.

문제가 발생하는 코드는 proto 파일로 부터 자동으로 serde Serialize / Deserialize 항목을 생성해 주는 pbjson이 만든 것으로, clippy는 원래 다른 모듈에 의해 생성되는 코드는 검사하지 않아야 하지만, 이 결과물을 include!() 매크로에 의해 현재 코드에 포함하기 때문에 clippy의 눈에 띄어서 문제가 되고 있는 것이다.

pub(crate) mod <모듈 이름> {
    // Include gRPC proto file.
    tonic::include_proto!("<proto 패키지 이름>");

    // Include pbjson-generated Serialize/Deserialize impls for the same type.
    include!(concat!(env!("OUT_DIR"), "/<생성된 proto 패키지 serde 파일>"));
} 

혹시나 이 문제에 대한 pbjon의 해결 패치가 있을까 해서 찾아 봤더니 2025년 12월 이후로는 버전 태그가 없었다.

결국 이 문제는 해결 하려면 3가지 정도의 해결 방법 있을 텐데,

  1. CI의 버전을 stable latest가 이닌 로컬 버전과 같은 1.94.1로 고정한다.
  2. 경고를 출력하더라도 실패로 보고 하지 않도록 clippy의 -D warning 파라미터를 제거한다.
  3. 경고를 출력하지 않도록 surpress한다.

이 중 3번을 적용하되 전체 범위가 아닌 include!() 구문에만 적용되도록 했다.

#[allow(unknown_lints)]
#[allow(clippy::useless_borrows_in_formatting)]
pub(crate) mod <모듈 이름> {
    // Include gRPC proto file.
    tonic::include_proto!("<proto 패키지 이름>");

    // Include pbjson-generated Serialize/Deserialize impls for the same type.
    include!(concat!(env!("OUT_DIR"), "/<생성된 proto 패키지 serde 파일>"));
}

#[allow(unknown_lints)]는 현재 버전의 clippy가 이해하지 못하는 lint feature가 있더라도 경고를 출력하지 말라는 의미이고, #[allow(clippy::useless_borrows_in_formatting)]가 바로 중복된 referrence를 발견하면 경고를 띄우는 해당 lint를 무시하도록 하는 디렉티브이다.