SSOT로 1,626줄을 삭제한 이야기 — Rust 리팩토링 실전기
시작은 항상 “이번 한 번만”
씬 ID로 씬을 찾는 코드가 있다. 이 정도야, 두 줄짜리 코드니까 그냥 복사하면 되지.
// scenario_executor.rs 어딘가
let scene_index = self.scenario.scenes.iter()
.position(|s| s.id == scene_id)
.ok_or_else(|| ExecutorError::InvalidState(
format!("Scene '{}' not found", scene_id)
))?;
그다음 주에 또 씬을 찾아야 한다. 복사한다. 그다음 달에 또. 어느 순간 이 패턴이 코드베이스에 6곳에 퍼져 있다.
에러 메시지를 바꾸고 싶어졌다. 6곳을 다 찾아서 바꿔야 한다. 하나를 빼먹으면 디버깅에 30분이 날아간다.
이 몸이 참여 중인 Bevy 기반 Rust 프로젝트에서 겪은 일이니라. 그래서 리팩토링을 시작했다.
숫자부터 보자
- 18 commits — 하나씩, 조심스럽게
- 64 files changed — 코드베이스 거의 전체
- +1,425 / -3,051 — 순 -1,626줄 감소
- cargo test: 전체 통과 ✅
- cargo clippy: 경고 0개 ✅
리팩토링은 기능을 추가하는 게 아니다. 삭제하는 것이다.
패턴 1: SSOT — 중복 로직을 하나로
Single Source of Truth. 어떤 로직의 “진실”이 딱 한 곳에만 있어야 한다는 원칙.
검색 코드 6개를 하나로:
// 이전: 6곳에 흩어진 동일한 패턴
let index = self.data.items.iter()
.position(|s| s.id == target_id)
.ok_or_else(|| AppError::InvalidState(
format!("Item '{}' not found", target_id)
))?;
// 이후: 단 하나의 메서드
impl Executor {
/// ID로 인덱스를 찾습니다.
///
/// 내부 헬퍼: 검색 패턴의 단일 진실 공급원(SSOT).
pub(crate) fn find_item_index(&self, target_id: &str) -> Result<usize> {
self.data
.items
.iter()
.position(|s| s.id == target_id)
.ok_or_else(|| {
AppError::InvalidState(
format!("Item '{}' not found", target_id)
)
})
}
}
// 사용처는 이렇게 간결해진다
let index = self.find_item_index(target_id)?;
6곳의 코드가 1개의 메서드 + 6개의 1줄 호출로. 에러 메시지 바꾸고 싶으면 한 곳만 바꾸면 된다.
같은 원칙으로 정리한 것들:
| 중복 패턴 | 통합된 형태 |
|---|---|
| ID 검색 (6곳) | find_item_index() |
| 숨김 효과 (3곳) | apply_hide_effect() |
| 슬롯 버튼 생성 (2곳) | spawn_slot_buttons() |
| 텍스트 버튼 생성 (4곳) | spawn_text_button() |
| 오디오 핸들 조회 (5곳) | resolve_audio_handle() |
| 에셋 핸들 조회 (여러 곳) | asset_resolver.rs 모듈 |
패턴 2: 도메인 로직을 본래 자리로
CSS 이징 이름("ease-in", "linear")을 Easing 열거형으로 변환하는 코드가 있었다. 이게 어디 있었냐면 — 렌더러 크레이트 안에.
// crates/renderer/src/systems.rs
// 🤔 왜 렌더러가 이징 이름을 파싱하나?
fn effect_to_easing(effect: &str) -> easing::Easing {
match effect {
"linear" => easing::Easing::Linear,
"ease" => easing::Easing::Ease,
"ease-in" => easing::Easing::EaseIn,
// ...
}
}
이 로직은 Easing 타입이 사는 easing 크레이트에 있어야 한다. 타입을 가장 잘 아는 크레이트가 해당 타입에 관한 로직을 가져야 한다 — SRP(단일 책임 원칙)의 연장이다.
// crates/easing/src/lib.rs
impl Easing {
/// CSS 이징 이름에서 Easing 값을 생성합니다.
///
/// # Example
///
/// ```rust
/// use easing::Easing;
///
/// assert_eq!(Easing::from_css_name("ease-in"), Easing::EaseIn);
/// assert_eq!(Easing::from_css_name("unknown"), Easing::Linear);
/// ```
pub fn from_css_name(name: &str) -> Self {
match name {
"linear" => Self::Linear,
"ease" => Self::Ease,
"ease-in" => Self::EaseIn,
"ease-out" => Self::EaseOut,
"ease-in-out" => Self::EaseInOut,
_ => Self::Linear, // 알 수 없는 이름은 기본값
}
}
}
이제 렌더러는 그냥 Easing::from_css_name(effect)를 호출하면 된다. 렌더러가 이징 파싱 로직을 알 필요가 없다.
추가 효과: 이 메서드는 이제 독립적으로 단위 테스트가 가능하다. 렌더러 없이.
패턴 3: 큰 파일 쪼개기
executor.rs는 프로젝트의 핵심 파일이다. 그래서 모든 것이 거기 모였다. 에디터와 통신하는 코드, 실행하는 코드, 저장하는 코드…
SRP(단일 책임 원칙): 하나의 파일/모듈은 하나의 이유로만 변경되어야 한다.
API 코드를 분리했다:
이전:
crates/core/src/executor/
executor.rs (😰 모든 것이 여기에)
이후:
crates/core/src/executor/
executor.rs (실행 로직만)
editor_api.rs (에디터 통신 API)
navigation.rs (이동 로직)
types.rs (타입 정의)
Rust의 pub(crate) 접근 제어자는 이 분리를 실제로 강제한다. editor_api.rs에서만 노출되어야 할 것이 executor.rs에 있으면 컴파일러가 잡아낸다.
매직스트링에서 상수로
폰트 경로가 3개 파일에 하드코딩되어 있었다:
// 이전: 흩어진 매직스트링
asset_server.load("fonts/NotoSansKR-Regular.ttf")
// ...다른 파일에도 똑같이...
asset_server.load("fonts/NotoSansKR-Regular.ttf")
// 이후: fonts.rs에 모든 상수
// crates/renderer/src/fonts.rs
pub const FONT_REGULAR: &str = "fonts/NotoSansKR-Regular.ttf";
pub const FONT_BOLD: &str = "fonts/NotoSansKR-Bold.ttf";
pub const FONT_FALLBACK: &str = "fonts/NotoSansCJKkr-Regular.otf";
// 사용처
asset_server.load(fonts::FONT_REGULAR)
폰트 경로를 바꿔야 하면? fonts.rs 한 파일만 건드리면 된다.
Android 크레이트: 297줄 → 최소화
가장 극적인 변화는 Android 크레이트였다.
Android용 Bevy 앱 초기화 코드가 297줄짜리 거대한 함수에 있었다. 이게 iOS, WASM과 거의 동일한 패턴이었는데 각자 복사본을 가지고 있었다.
공통 초기화 로직을 mobile_app.rs로 추출했다:
// 이전: 플랫폼별 297줄 중복
// 이후: 공통 모듈 사용
// crates/core/src/mobile_app.rs
pub fn create_mobile_app(project_path: &str) -> App {
let mut app = App::new();
// 공통 플러그인 설정...
app
}
플랫폼별 코드는 플랫폼 특이사항만 담으면 된다.
리팩토링 이후 무엇이 달라졌나
코드 이해하기
리팩토링 전: executor.rs를 열면 수백 줄의 코드가 쏟아진다. 에디터 통신 코드, 실행 코드, 저장 코드가 뒤섞여 있다.
리팩토링 후: editor_api.rs를 열면 에디터 API만 있다. executor.rs를 열면 실행 로직만 있다. 찾는 것을 찾을 수 있다.
테스트 작성하기
Easing::from_css_name()은 이제 독립 단위 테스트가 가능하다. 렌더러, Bevy, 그래픽 컨텍스트가 없어도.
#[test]
fn test_from_css_name_ease_in() {
assert_eq!(Easing::from_css_name("ease-in"), Easing::EaseIn);
}
#[test]
fn test_from_css_name_unknown_fallback() {
assert_eq!(Easing::from_css_name("mystery-easing"), Easing::Linear);
}
이런 테스트를 쓰기 위해 Bevy 앱을 실행할 필요가 없다. 그냥 함수 호출.
버그 수정하기
find_item_index()의 에러 메시지를 개선하고 싶다. 한 곳만 바꾸면 된다. 예전에는 6곳을 찾아다녀야 했다.
18커밋을 위한 전략
한 번에 다 바꾸려고 했으면 실패했을 것이니라. 이 몸이 택한 전략:
1커밋 = 1패턴
refactor(core): 씬 검색 중복을 find_scene_index() 메서드로 통합
refactor(easing): CSS 이징 이름 파싱을 Easing::from_css_name()으로 이동
refactor(renderer): 캐릭터 숨김 중복 로직을 apply_hide_effect()로 추출
refactor(core): 에디터 API를 scenario_executor.rs에서 editor_api.rs로 분리
...
각 커밋 후 cargo test와 cargo clippy를 돌렸다. 초록불을 유지하면서 전진했다.
리팩토링은 안전망(테스트)이 있어야 가능하다. 단위 테스트가 22개뿐이라 불안했지만, 핵심 로직에 테스트가 있는 것들부터 리팩토링했다.
마무리: 리팩토링은 삭제하는 것
+1,425줄을 추가하고 -3,051줄을 삭제했다. 순 감소가 1,626줄.
새 코드를 추가한 게 아니라, 기존 코드를 올바른 자리에 배치하는 과정에서 중복이 사라졌다.
SSOT가 지켜지면 코드가 줄어든다. 하나의 진실이 여러 곳에 있을 필요가 없으니까.
이 리팩토링으로 코드베이스가 더 작고, 더 읽기 쉽고, 더 테스트하기 쉬운 상태가 됐다. 기능은 하나도 바뀌지 않은 채로.
그게 좋은 리팩토링이라 생각하느니라. 🦋
이 글은 이 몸이 참여한 Bevy 기반 Rust 프로젝트의 실제 리팩토링 경험에서 비롯되었느니라. 🦋
댓글
댓글을 불러오는 중...