hyunsu blog

홈강좌Rust 실무 16편 중 11편째

AI 에이전트로 Rust 쓰기 — 컴파일러·테스트·clippy 를 되먹임으로 삼는다

목차 · 4
  1. 01왜 Rust 는 AI 와 잘 맞나
  2. 02되먹임 고리 — 테스트 먼저
  3. 03테스트를 통과해도 끝이 아니다
  4. 04실습 — 프로젝트 규칙 파일

AI 코딩 에이전트(Claude Code·Cursor·GitHub Copilot 같은 것)에게 Rust 를 맡길 때 가장 큰 무기는 엄격한 컴파일러다. 테스트를 먼저 정하고, cargo check·clippy·test 를 에이전트가 스스로 돌려 통과할 때까지 고치게 한다. 이 편에서는 정렬을 빠뜨린 중앙값 함수가 테스트와 clippy 에 차례로 걸리는 과정을 실제로 돌려 본다.

01 왜 Rust 는 AI 와 잘 맞나

에이전트는 그럴듯한 코드를 빨리 쓰지만, 그것이 맞는지는 스스로 확인해야 안다. 확인 수단이 많을수록 사람이 볼 일이 준다.

  • 컴파일러 — 타입·소유권·빌림이 틀리면 실행하기 전에 막는다. 그것도 무엇·어디·고치는 법(입문 2편)을 글로 주니, 에이전트가 읽고 고치기 좋다.
  • clippy — 컴파일은 되지만 어색한 코드를 짚는다(9편).
  • cargo test — 뜻(동작)이 맞는지 확인한다(입문 42편).

Claude Code 공식 문서의 모범 사례 장도 첫머리에서 "Claude 가 볼 수 없는 것을 알려 주고, 돌려 볼 검사를 주고, 예외가 없어야 할 규칙은 강제하라" 고 정리한다. Rust 는 그 "돌려 볼 검사" 가 언어와 도구에 기본으로 들어 있다.

02 되먹임 고리 — 테스트 먼저

에이전트에게 "중앙값 함수를 만들어 줘" 라고만 하면, 맞았는지 판정할 기준이 없다. 테스트를 먼저 적어 준다. 빈 입력, 정렬되지 않은 홀수 개, 짝수 개(가운데 둘의 평균)다.

아래는 에이전트가 흔히 내놓는 첫 판을 흉내 낸 것이다. 정렬을 빠뜨렸고 짝수 개를 생각하지 않았다.

rust
/// Returns the median of the numbers. Empty input gives None.
pub fn median(numbers: &[i32]) -> Option<f64> {
    if numbers.is_empty() {
        return None;
    }
    let mid = numbers.len() / 2;
    Some(numbers[mid] as f64)
}

fn main() {
    println!("{:?}", median(&[3, 1, 2]));
}

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

    #[test]
    fn empty_is_none() {
        assert_eq!(median(&[]), None);
    }

    #[test]
    fn odd_count_unsorted() {
        assert_eq!(median(&[3, 1, 2]), Some(2.0));
    }

    #[test]
    fn even_count_takes_average() {
        assert_eq!(median(&[4, 1, 3, 2]), Some(2.5));
    }
}
text
running 3 tests
test tests::empty_is_none ... ok
test tests::odd_count_unsorted ... FAILED
test tests::even_count_takes_average ... FAILED

failures:

---- tests::odd_count_unsorted stdout ----

thread 'tests::odd_count_unsorted' (213394076) panicked at src/main.rs:25:9:
assertion `left == right` failed
  left: Some(1.0)
 right: Some(2.0)

---- tests::even_count_takes_average stdout ----

thread 'tests::even_count_takes_average' (213394075) panicked at src/main.rs:30:9:
assertion `left == right` failed
  left: Some(3.0)
 right: Some(2.5)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::even_count_takes_average
    tests::odd_count_unsorted

test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

테스트 셋 중 둘이 실패했다. left(실제)와 right(기대)가 나란히 찍히니, 에이전트가 이 출력만 읽고도 "정렬을 안 했다", "짝수 개는 평균이어야 한다" 를 알아챈다. 사람에게 묻지 않고 고치는 단계로 갈 수 있다.

03 테스트를 통과해도 끝이 아니다

정렬하고 짝수 개를 처리한 판은 테스트를 통과한다.

text
running 3 tests
test tests::even_count_takes_average ... ok
test tests::empty_is_none ... ok
test tests::odd_count_unsorted ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

여기서 끝내면 안 된다. 9편의 두 줄 가운데 clippy 를 -D warnings 로 돌리면 이렇게 막힌다.

bash
cargo clippy --all-targets -- -D warnings
text
error: manual implementation of `.is_multiple_of()`
 --> src/main.rs:9:8
  |
9 |     if sorted.len() % 2 == 0 {
  |        ^^^^^^^^^^^^^^^^^^^^^ help: replace with: `sorted.len().is_multiple_of(2)`
  |
  = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.95.0/index.html#manual_is_multiple_of
  = note: `-D clippy::manual-is-multiple-of` implied by `-D warnings`
  = help: to override `-D warnings` add `#[allow(clippy::manual_is_multiple_of)]`

len() % 2 == 0 대신 새 메서드 is_multiple_of(2) 를 쓰라는 것이다. 에이전트는 학습한 시점의 코드 습관을 따르기 쉬워서, 이렇게 최근 Rust 에 들어온 쓰는 법을 모를 수 있다. clippy 는 설치된 툴체인의 최신 습관으로 판정하니, 사람이 일일이 알 필요가 없다. help 대로 고친 판이 둘 다 통과한다.

rust
/// Returns the median of the numbers. Empty input gives None.
pub fn median(numbers: &[i32]) -> Option<f64> {
    if numbers.is_empty() {
        return None;
    }
    let mut sorted = numbers.to_vec();
    sorted.sort();
    let mid = sorted.len() / 2;
    if sorted.len().is_multiple_of(2) {
        Some((sorted[mid - 1] + sorted[mid]) as f64 / 2.0)
    } else {
        Some(sorted[mid] as f64)
    }
}

fn main() {
    println!("{:?}", median(&[3, 1, 2]));
}

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

    #[test]
    fn empty_is_none() {
        assert_eq!(median(&[]), None);
    }

    #[test]
    fn odd_count_unsorted() {
        assert_eq!(median(&[3, 1, 2]), Some(2.0));
    }

    #[test]
    fn even_count_takes_average() {
        assert_eq!(median(&[4, 1, 3, 2]), Some(2.5));
    }
}
text
    Checking stats v0.1.0 (~/projects/stats)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.11s
   Compiling stats v0.1.0 (~/projects/stats)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running unittests src/main.rs (target/debug/deps/stats-d66f33ab1a38efd6)

running 3 tests
test tests::empty_is_none ... ok
test tests::odd_count_unsorted ... ok
test tests::even_count_takes_average ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

04 실습 — 프로젝트 규칙 파일

에이전트는 세션마다 프로젝트를 새로 읽는다. 매번 말할 규칙은 저장소의 규칙 파일(Claude Code 는 CLAUDE.md, 여러 도구가 함께 읽는 AGENTS.md 등)에 적어 둔다. Rust 프로젝트라면 이런 내용이다.

markdown
# Rust 규칙
- edition 2024, 툴체인은 rust-toolchain.toml 을 따른다
- 다 됐다고 말하기 전에 셋을 모두 통과시킨다:
  cargo fmt --check
  cargo clippy --all-targets -- -D warnings
  cargo test
- 라이브러리 코드에서 unwrap()·expect()·unsafe 를 쓰지 않는다. 쓰려면 먼저 묻는다
- 새 크레이트를 더하지 않는다. 필요하면 이름·버전·이유를 먼저 말한다
- 테스트를 지우거나 기대값을 바꿔 통과시키지 않는다

마지막 두 줄이 중요하다. 에이전트는 테스트를 통과시키려고 테스트 쪽을 고치거나, 일을 쉽게 하려고 크레이트를 늘리는 쪽으로 가기 쉽다. 사람이 리뷰할 때도 이 둘(테스트 변경·Cargo.toml 변경)부터 본다. 다음 편은 이 고리로도 잡히지 않는 실수, 바이브 코딩의 주의점이다.