RefCell/Rc로 내부 가변성을 쓰지 않고도, 테스트에서 인메모리 파일시스템을 모킹하기 위한 Filesystem 트레이트를 더 러스트답게 설계·구현하는 방법을 정리한다.
URL: https://pyk.sh/blog/2025-12-15-writing-mockable-fs-in-rust-without-refcell
2025년 12월 15일
저는 스마트 컨트랙트 퍼저의 CLI를 만들고 있습니다. 이 CLI의 주된 역할은 컨트랙트 파일을 찾고, 컴파일하고, Rust 바인딩을 생성하는 것입니다. 이 과정에는 디스크에 대한 읽기/쓰기가 많이 들어갑니다.
이걸 테스트하는 건 꽤 성가십니다. 임시 파일을 건드리거나, 매 테스트마다 디렉터리를 정리하고 싶지 않았습니다. 파일시스템을 모킹해서 모든 것을 메모리에서 돌리고 싶었습니다. 첫 시도는 동작하긴 했지만 코드가 읽기 어려웠습니다. 아래는 더 깔끔하게 리팩터링한 과정입니다.
이를 위해 Filesystem 트레이트를 정의했습니다. 실제 앱에서는 std::fs를 사용하는 RealFilesystem을 쓰고, 테스트에서는 HashMap을 사용하는 TestFilesystem으로 바꿔 끼울 수 있습니다.
첫 번째 추상화는 동작했지만 뭔가 잘못된 느낌이었습니다. 코드를 이해하기 어렵게 강요하는 형태였기 때문입니다.
아래는 처음에 정의했던 트레이트입니다.
rustpub trait Filesystem { fn read_to_string(&self, path: &Path) -> std::io::Result<String>; fn write_string(&self, path: &Path, content: &str) -> std::io::Result<()>; }
겉으로 보면 괜찮아 보입니다. 하지만 write_string을 자세히 보면 &self를 받습니다. 즉 이 메서드는 객체의 상태를 바꾸지 않겠다고 약속하는 셈입니다.
RealFilesystem에 대해서는 이 말이 성립합니다. 구조체 관점에서 std::fs는 상태가 없고, 단지 시스템 콜을 호출할 뿐이기 때문입니다. 하지만 TestFilesystem에서는 문제가 됐습니다. 파일을 쓸 때 내부의 HashMap을 업데이트해야 했습니다.
이를 가능하게 하려면 “내부 가변성(interior mutability)”이 필요했습니다. 데이터를 Rc와 RefCell로 감쌌습니다.
rust#[derive(Debug, Default, Clone)] pub struct TestFilesystem { files: Rc<RefCell<HashMap<PathBuf, String>>>, }
RefCell 덕분에 불변 참조만 있어도 맵을 변경할 수 있었고, Rc 덕분에 파일시스템을 클론해서 빌더의 여러 부분에 넘길 수 있었습니다.
하지만 이 방식은 코드를 지저분하게 만들었습니다. 빌드 커맨드에서 파일시스템을 계속 클론해야 했습니다.
rustpub fn run_with_fs<F: Filesystem + Clone>(args: BuildArgs, fs: F) -> Result<()> { // ... // 빌더에 넘기기 위해 여기서 fs를 clone 해야 했다 let builder = SolidityBuilder::new(args.files, fs.clone()); // ... // 그리고 여기서도 출력 파일을 쓰기 위해 fs가 필요했다 fs.write_string(&args.output, &generated)?; Ok(()) }
여기에는 큰 문제가 두 가지 있습니다.
첫째는 fs.clone()의 명확성입니다. Rust에서 clone은 보통 깊은 복사를 의미합니다. 하지만 여기서는 단지 참조 카운터를 하나 올리는 것뿐이었습니다. builder와 run_with_fs 함수는 정확히 같은 데이터를 공유하게 됩니다. 만약 빌더가 파일시스템을 수정하기로 결정하면 바깥 스코프에도 영향을 미칩니다. 이런 “멀리서 일어나는 으스스한 작용(spooky action at a distance)”은 종종 버그의 원인이 됩니다.
둘째는 타입 시스템이 거짓말을 하고 있다는 점입니다. 파일에 쓰는 것은 변경(mutation)입니다. 세계의 상태를 바꿉니다. 그런데 &self를 사용함으로써 그 사실을 숨기고 있었습니다.
그래서 더 관용적인(idiomatic) 형태로 리팩터링하기로 했습니다. 트레이트가 변경을 솔직하게 표현하도록 바꿨습니다.
rustpub trait Filesystem { fn read_to_string(&self, path: &Path) -> std::io::Result<String>; // 이제 가변 참조가 필요하다 fn write_string(&mut self, path: &Path, content: &str) -> std::io::Result<()>; }
이 작은 변경이 구현 전반의 대대적인 정리를 촉발했습니다.
TestFilesystem에서 Rc와 RefCell을 완전히 제거할 수 있었습니다. 이제는 HashMap을 감싼 단순한 래퍼일 뿐입니다.
rust#[derive(Debug, Default, Clone)] pub struct TestFilesystem { files: HashMap<PathBuf, String>, }
빌드 커맨드에서의 사용도 훨씬 명확해졌습니다. 더 이상 빌더에 파일시스템의 소유권을 넘기지 않습니다. 대신 참조를 넘깁니다.
rustpub struct SolidityBuilder<'a, F: Filesystem> { files: Vec<PathBuf>, fs: &'a F, // 빌더가 FS를 빌린다 } impl<'a, F: Filesystem> Builder for SolidityBuilder<'a, F> { fn build(&self) -> Result<String> { // 빌더는 읽을 수는 있지만, 컴파일러가 쓰기는 막아준다! let src = self.fs.read_to_string(&path)?; // ... } }
이게 바로 빌림 검사기(borrow checker)의 힘입니다. 빌더에 불변 참조 &F를 주면, 컴파일 타임에 빌더가 디스크에 쓸 수 없음을 보장할 수 있습니다. 소스 파일을 읽을 수만 있습니다.
메인 실행 함수는 이제 이렇게 됩니다.
rustpub fn run_with_fs<F: Filesystem>(args: BuildArgs, fs: &mut F) -> Result<()> { // 빌더에는 불변 참조를 넘긴다 let generated = SolidityBuilder::new(args.files, fs).build()?; // 가변 참조를 사용해 출력 파일을 쓴다 fs.write_string(&args.output, &generated)?; Ok(()) }
테스트에서는 더 이상 복잡한 준비가 필요 없습니다. 구조체를 만들고 가변 참조를 넘기기만 하면 됩니다.
rust#[test] fn accepts_contract_and_writes_output() { let mut fs = TestFilesystem::new(); fs.add_file(&p1, "contract A {}"); // 가변 참조를 전달 run_with_fs(args, &mut fs).unwrap(); // 결과 확인 let s = fs.read_to_string(&outp).unwrap(); assert!(s.contains("Generated by hades")); }
이번 리팩터링은 Rust의 소유권 모델을 신뢰하는 것에 대해 많은 걸 가르쳐줬습니다. 첫 시도에서는 객체를 공유하기가 더 쉬울 거라고 생각해서 RefCell로 규칙을 우회하려 했습니다. 하지만 그 결과 코드는 더 혼란스러워졌습니다.
규칙을 받아들이고 &mut self를 사용하자, Rust 타입 시스템을 통해 “진실”을 얻을 수 있었습니다. 이제 함수 시그니처만 봐도 정확히 무슨 일이 일어나는지 알 수 있습니다. 빌더는 읽고, 커맨드는 씁니다. 숨은 상태도 없고 참조 카운팅도 필요 없습니다. 단순하고, 안전하고, 빠릅니다.