Rust로 SCSS 의존성 분석기 만들기
Source: Dev.to

소개
Rust를 배우는 개발자로서, 실용적이면서도 언어에 대한 이해를 높여줄 만큼 도전적인 프로젝트에 도전하고 싶었습니다. 그 결과: sass‑dep, SCSS/Sass 코드베이스를 분석하고 의존성 그래프를 시각화하는 CLI 도구가 탄생했습니다.
문제
대규모 SCSS 코드베이스를 작업해 본 적이 있다면, @use, @forward, @import 구문이 얽힌 복잡한 웹처럼 빨리 변할 수 있다는 것을 알 것입니다. “이 파일에 어떤 것이 의존하고 있나요?” 혹은 “순환 의존성이 있나요?” 같은 질문은 놀라울 정도로 답하기 어려워집니다.
sass‑dep가 이를 해결합니다: 진입점 SCSS 파일을 지정하면, 메트릭, 순환 탐지 및 인터랙티브 시각화 도구가 포함된 완전한 의존성 그래프를 구축합니다.
내가 만든 것
CLI
# Analyze and open the interactive visualizer
sass-dep analyze src/main.scss --web
# CI/CD checks – fail on cycles or depth violations
sass-dep check --no-cycles --max-depth 10 src/main.scss
# Export to Graphviz DOT format
sass-dep export analysis.json --format dot | dot -Tpng -o graph.png
시각화 도구
React + TypeScript 로 만든 웹 UI는 다음을 가능하게 합니다:
- 팬/줌을 이용해 의존성 그래프를 인터랙티브하게 탐색
- 노드 플래그(엔트리 포인트, 리프, 고아, 사이클)로 필터링
/키보드 단축키로 파일 검색- Shift‑클릭 두 노드를 선택해 그 사이 경로 강조
- PNG, SVG, 혹은 필터링된 JSON 형태로 내보내기
- 라이트/다크 테마 전환
기술 스택
Rust 백엔드
clap– 파생 매크로를 이용한 CLI 인자 파싱nom– SCSS 지시문 파싱을 위한 파서 콤비네이터petgraph– 그래프 데이터 구조 및 알고리즘serde+serde_json– JSON 직렬화axum+tokio– 비동기 웹 서버rust-embed– React 빌드를 바이너리에 포함walkdir– 고아 파일 탐지를 위한 파일 시스템 순회indexmap– 출력에서 결정론적 순서 보장anyhow+thiserror– 오류 처리
React 프론트엔드
- React 19 with TypeScript
@xyflow/react– 그래프 시각화 (React Flow)@dagrejs/dagre– 그래프 레이아웃 알고리즘html-to-image– PNG/SVG 내보내기- Vite – 빌드 도구
- SCSS modules – 스타일링
Rust Lessons Learned
1. Parser Combinators with nom
이것은 파서 콤비네이터를 처음 사용해 본 경험이며, nom 덕분에 접근하기 쉬웠습니다. 전통적인 lexer/parser를 직접 작성하는 대신, 작은 파싱 함수를 조합합니다. 디렉티브 파싱의 간단한 예시:
use nom::{
branch::alt,
bytes::complete::tag_no_case,
character::complete::multispace1,
// …
};
/// Try to parse `@use`, `@forward`, or `@import`
fn parse_directive(input: &'a str, location: &Location) -> IResult {
alt((
|i| parse_use_directive(i, location),
|i| parse_forward_directive(i, location),
|i| parse_import_directive(i, location),
))(input)
}
파서는 @ 기호를 스캔하고 디렉티브와 매치하려 시도하며, 오류 보고를 위해 라인/컬럼 위치를 추적합니다. 가능한 경우 제로‑복사(zero‑copy) 방식으로 원본 문자열의 슬라이스를 사용합니다.
2. Graph Algorithms with petgraph
petgraph는 그래프 연산에 아주 유용합니다. 사이클 탐지는 Tarjan 알고리즘을 사용해 강하게 연결된 컴포넌트를 찾습니다:
use petgraph::algo::tarjan_scc;
pub fn detect_cycles(graph: &DependencyGraph) -> Vec> {
let sccs = tarjan_scc(graph.inner());
// Keep only SCCs with more than one node (actual cycles)
sccs.into_iter()
.filter(|scc| scc.len() > 1)
.map(|scc| {
// Convert indices to file IDs
scc.iter()
.map(|idx| graph.node_weight(*idx).unwrap().file_path.clone())
.collect()
})
.collect()
}
3. Recursive Graph Building
의존성 그래프를 구축하려면 import를 재귀적으로 따라가면서 무한 루프를 피해야 합니다. 핵심 인사이트는 처리된 파일을 그래프 내부에 직접 추적하는 것이었습니다:
fn process_directive(&mut self, directive: &Directive, /* … */) -> Result {
for target in directive.paths() {
// Skip Sass built‑ins like `sass:math`
if target.starts_with("sass:") {
continue;
}
// Resolve and add to graph
let resolved = resolver.resolve(from_path, target)?;
let to_id = self.add_file(&resolved, root)?;
// Has this file already been processed?
let already_processed = self.node_index.contains_key(&to_id)
&& self.get_node(&to_id).map(|n| n.visited).unwrap_or(false);
self.add_edge(from_id, &to_id, edge);
if !already_processed {
self.process_file(&resolved, resolver, root)?;
}
}
Ok(())
}
already_processed를 재귀 호출 전에 확인함으로써 순환 @use/@forward 체인으로 인한 무한 루프를 방지합니다.
4. Error Handling with anyhow and thiserror
JavaScript에서 온 입장에서는 Rust의 오류 처리 방식이 처음엔 다소 장황하게 느껴졌습니다. 하지만 애플리케이션 코드에는 anyhow를, 라이브러리 오류에는 thiserror를 사용하면 ergonomically 사용할 수 있습니다:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ParseError {
#[error("Failed to parse directive at {location}")]
InvalidDirective { location: Location },
#[error("IO error reading file: {0}")]
Io(#[from] std::io::Error),
}
anyhow::Context 트레이트는 오류가 전파될 때 컨텍스트를 추가하기에 매우 유용합니다:
let entry = entry
.canonicalize()
.context("Failed to canonicalize entry path")?;
5. Embedding Assets with rust-embed
제가 가장 좋아하게 된 발견 중 하나는 rust-embed였습니다. 이 라이브러리는 전체 React 빌드를 컴파일 타임에 바이너리 안으로 포함시킵니다:
#[derive(RustEmbed)]
#[folder = "web/dist/"]
struct Assets;
그 결과? 외부 의존성이 전혀 없는 단일 바이너리입니다. 사용자는 시각화 도구를 실행하기 위해 Node.js를 설치할 필요가 없습니다.
Source: …
아키텍처 결정
왜 파일‑레벨만?
sass-dep는 의도적으로 파일 의존성만 추적하고 변수, 믹스인, 함수는 추적하지 않습니다. 이렇게 하면 도구가 집중되고 빠르게 동작합니다. README에는 이것이 목표가 아님을 명시하고 있습니다:
This tool intentionally does not:
- Track variables, mixins, or functions
- Analyze CSS output
- Support watch mode
Sass‑호환 경로 해석
경로 해석을 정확히 구현하는 것이 까다로웠습니다. Sass는 파일을 찾는 데 다음과 같은 규칙을 가지고 있습니다:
.scss와.sass확장자를 가진 정확한 경로 시도- 언더스코어 접두사(
_partial.scss)를 사용해 시도 - 디렉터리로 간주하고
index.scss또는_index.scss시도 - 각 로드 경로마다 위 과정을 반복
src/resolver/path.rs에 있는 리졸버는 이 사양을 신중히 구현하고 있습니다.
결정적 출력
HashMap 대신 IndexMap을 사용하면 JSON 출력이 실행마다 동일하게(같은 입력에 대해 동일한 출력) 보장됩니다. 이는 CI 캐시와 결과 차이 비교에 중요합니다.
JSON 스키마
출력은 버전이 지정된 스키마(v1.0.0)를 따르며 다음을 포함합니다:
- Nodes – 각 파일에 대한 메트릭(팬‑인, 팬‑아웃, 깊이, 전이적 의존성) 및 플래그
- Edges – 의존성에 대한 지시자 타입, 위치, 네임스페이스 정보
- Analysis – 감지된 사이클 및 집계 통계
플래그는 자동으로 할당됩니다:
entry_point– 명시적으로 지정된 진입점leaf– 의존성이 없는 파일(fan‑out = 0)orphan– 진입점에서 도달할 수 없는 파일high_fan_in/high_fan_out– 임계값 초과in_cycle– 순환 의존성에 포함된 경우
내가 다르게 할 점
- 테스트를 더 일찍 시작 – 초기 구현 후에 고정 파일을 이용한 통합 테스트를 작성했지만, 처음부터 테스트를 갖추었다면 엣지 케이스를 더 빨리 잡을 수 있었을 것입니다.
- 처음부터 async 고려 – 웹 서버는 Tokio/Axum을 사용하지만 핵심 분석은 동기식입니다. 실제로는 큰 문제가 없지만, 처음부터 비동기를 고민해 보는 것도 좋겠습니다.
Try It Out
이 프로젝트는 오픈 소스입니다:
# Clone and build
git clone https://github.com/emiliodominguez/sass-dep.git
cd sass-dep
cargo install --path .
# Analyze your SCSS project
sass-dep analyze your-project/src/main.scss --web
결론
sass-dep를 만들면서 어떤 튜토리얼보다도 Rust에 대해 더 많이 배웠습니다. Rust를 배우고 있다면, 다음과 같은 프로젝트를 선택하는 것을 강력히 추천합니다:
- 당신이 실제로 겪는 문제를 해결한다
- 파싱을 포함한다 (
nom은 함수형 Rust 패턴을 배우기에 훌륭한 교재이다) - 테스트할 수 있는 명확한 성공 기준이 있다
Rust 생태계는 훌륭합니다. petgraph, nom, clap 같은 라이브러리는 문서가 잘 갖춰져 있고 사용하기 매우 쉽습니다.

