Rust 기초부터 마스터까지 — 10화
안전한 경계와 테스트로 Rust 설계 정리하기
본 블로그 작성 글들은, 강의를 목표로 하고 제작되었습니다. 또한 작성된 글의 정보도 틀렸을 수 있으니, 주의해주세요.
Rust를 잘 사용한다는 것은 모든 수명과 스마트 포인터를 복잡하게 쓰는 일이 아니다. 소유권 경계, 오류, 동시 작업, unsafe 영역을 작게 유지하고 동작을 테스트할 수 있게 만드는 일이다.
상태의 소유자 정하기
struct GameState {
players: Vec<Player>,
monsters: Vec<Monster>,
}
impl GameState {
fn attack(&mut self, player_index: usize, monster_index: usize) -> bool {
let Some(player) = self.players.get(player_index) else {
return false;
};
let Some(monster) = self.monsters.get_mut(monster_index) else {
return false;
};
monster.take_damage(player.attack);
true
}
}
GameState가 전투 상태를 소유하고 메서드는 잠시 필요한 필드만 빌린다. 전역 변경 상태를 줄이면 참조 수명과 동시성도 단순해진다.
외부 기능을 trait 뒤에 두기
trait SaveStore {
fn save(&self, state: &GameState) -> Result<(), SaveError>;
fn load(&self) -> Result<Option<GameState>, SaveError>;
}
fn save_game(store: &impl SaveStore, state: &GameState) -> Result<(), SaveError> {
store.save(state)
}
파일과 네트워크 세부 구현이 규칙 코드로 들어오지 않는다. 테스트에서는 메모리 저장소를 구현해 실패와 성공을 원하는 대로 만들 수 있다.
unsafe 경계 좁히기
fn value_at(values: &[i32], index: usize) -> Option<i32> {
values.get(index).copied()
}
안전한 API로 해결할 수 있다면 unsafe를 사용할 이유가 없다. FFI나 저수준 최적화로 unsafe가 필요하면 작은 함수 안에 감추고, 호출자가 지켜야 할 조건을 안전한 타입과 검사로 보장해야 한다.
unsafe는 컴파일러의 모든 검사를 끄는 기능이 아니다. 포인터 역참조 같은 일부 책임을 개발자가 직접 증명하겠다는 표시다.
계약 테스트하기
#[test]
fn invalid_target_does_not_change_state() {
let mut state = sample_state();
let before = state.monsters[0].health;
assert!(!state.attack(0, 99));
assert_eq!(state.monsters[0].health, before);
}
테스트는 내부 구현보다 실패했을 때 상태가 유지되는지 같은 외부 계약을 확인한다. Rust의 강점은 오류가 절대 없다는 것이 아니라 많은 잘못된 상태를 타입과 컴파일 검사로 실행 전에 줄이는 데 있다.