Rust는 명령형, 객체지향, 함수형을 포괄하는 다중 패러다임 언어입니다. 이 글은 작은 예제부터 실제 파일 필터링까지, 상황에 맞는 스타일을 고르는 원칙과 실용적 트레이드오프를 다루고, 함수형 코어·명령형 셸·OOP 캡슐화 전략을 제안합니다.
Rust는 다중 패러다임 프로그래밍 언어로, 명령형, 객체지향, 함수형 프로그래밍 스타일을 모두 수용합니다. 어떤 스타일을 택할지는 종종 개발자의 배경과 해결하려는 문제에 달려 있습니다.
C++, Java, Python, Haskell 등 다양한 배경의 개발자들이 Rust로 모이면서, Rust는 자신만의 독특한 스타일과 관용구를 형성해 왔습니다. 이러한 다양성은 큰 강점이지만, 동시에 어떤 상황에서 어떤 스타일을 선택해야 하는지에 대한 불확실성도 낳습니다.
Rust 책의 설명에 따르면:
객체지향 프로그래밍이 무엇인지에 대한 여러 상충되는 정의가 존재하며, 그중 일부 정의에 따르면 Rust는 객체지향적입니다.
하지만 이 또한 밝힙니다:
Rust의 설계는 많은 기존 언어와 기법에서 영감을 받았으며, 그중 하나의 중요한 영향은 함수형 프로그래밍입니다.
이 두 진술은 모순되지 않지만, 해석과 개인적 취향의 여지를 많이 남깁니다.
Rust는 확실히 객체지향 프로그래밍 개념의 영향을 받았습니다. Rust를 다른 객체지향 언어와 구분 짓는 요소 중 하나는 상속 기반이 아닌 합성 기반이라는 점입니다. 트레이트 시스템은 이러한 객체지향 설계의 핵심 구성 요소이며, C++와 Java 같은 언어에는 없는 개념입니다.
마찬가지로, Rust의 설계는 함수형 프로그래밍의 원칙과 잘 맞아떨어지는 패턴을 장려합니다. 예를 들어 불변성, 이터레이터 패턴, 대수적 데이터 타입(ADT), 패턴 매칭 등이 있습니다.
Rust가 순수 객체지향 언어가 아니면서도 일부 객체지향 원칙을 채택한 것처럼, 순수 함수형 언어가 아니면서도 함수형 프로그래밍 개념을 받아들입니다. 어디서든 부수 효과를 허용하며, 표현식을 그 값으로 대체해도 프로그램의 동작이 바뀌지 않는 성질인 참조 투명성을 엄격히 강제하지도 않습니다.
결론적으로, 특히 다른 언어에서 넘어오는 개발자에게는 다양한 패러다임을 Rust에서 어떻게 사용할지에 대한 몇 가지 안내가 도움이 될 수 있습니다. 이 글은 제가 Rust에서 서로 다른 패러다임 사이에서 선택할 때 사용하는 개인적 의사결정 과정을 탐구합니다. 이제는 거의 몸에 밴 과정이 되었죠.
Rust에서 단순한 for 루프는 아무 문제가 없습니다.
let mut sum = 0;
for i in 0..10 {
sum += i;
}
하지만 이렇게 짧은 예제에서도, 우리가 풀려는 문제와 작성하는 코드 사이의 불일치를 볼 수 있습니다. sum의 중간 값들은 중요하지 않습니다! 우리가 관심 있는 것은 최종 결과뿐이죠.
보다 함수형에 가까운 버전과 비교해 보세요:
let sum: u32 = (0..10).sum();
작은 예제에서는 큰 차이가 없어 보일 수 있지만, 중첩 루프를 사용하기 시작하면 명령형 접근에서는 실제 문제보다 장부 처리에 더 많은 줄을 소비하는 경향이 있음을 보게 됩니다. 이는 코드의 우발적 복잡도(우리가 스스로 도입하는 불필요한 복잡도)를 증가시킵니다. 복잡도는, 아무리 작아도, 주의를 소모합니다.
조금 더 큰 예제를 생각해 봅시다. 프로그래밍 언어 목록과 각 언어가 지원하는 패러다임, 그리고 프로덕션 사용자 수가 있다고 합시다. 함수형 프로그래밍을 지원하면서 사용자 수가 가장 많은 상위 다섯 개 언어를 찾는 것이 과제입니다.
// 이 예제를 위해 만든 임의의 데이터입니다! Haskell, 사랑해요.
let languages = vec![
Language::new("Rust", vec![Paradigm::Functional, Paradigm::ObjectOriented], 100_000),
Language::new("Go", vec![Paradigm::ObjectOriented], 200_000),
Language::new("Haskell", vec![Paradigm::Functional], 5_000),
Language::new("Java", vec![Paradigm::ObjectOriented], 1_000_000),
Language::new("C++", vec![Paradigm::ObjectOriented], 1_000_000),
Language::new("Python", vec![Paradigm::ObjectOriented, Paradigm::Functional], 1_000_000),
];
여기 중첩 for 루프를 사용한, 매우 노골적인 해법이 있습니다:
// 함수형 언어만 남기도록 필터링
let mut functional_languages = vec![];
for language in languages {
if language.paradigms.contains(&Paradigm::Functional) {
functional_languages.push(language);
}
}
// 사용자 수 기준으로 내림차순 정렬
for i in 1..functional_languages.len() {
let mut j = i;
while j > 0 && functional_languages[j].users > functional_languages[j - 1].users {
functional_languages.swap(j, j - 1);
j -= 1;
}
}
// 상위 5개만 남기기
while functional_languages.len() > 5 {
functional_languages.pop();
}
이는 매우 장황한 명령형 해법입니다. 우리는 벡터를 제자리에서 변경하고 그 과정에서 중간 결과들을 파기합니다. 틀렸다고 할 수는 없지만, 가장 관용적인 Rust 코드라고 보기도 어렵습니다.
실무에서는 아마 표준 라이브러리의 몇 가지 보조 메서드를 더 사용할 것입니다:
let mut top_languages = vec![];
for language in languages {
if language.paradigms.contains(&Paradigm::Functional) {
top_languages.push(language);
}
}
// 인기순 내림차순 정렬
// 이 한 줄은 이미 다소 함수형적입니다.
top_languages.sort_by_key(|lang| std::cmp::Reverse(lang.users));
top_languages.truncate(5);
어차피 languages를 소모할 거라면, 필터링을 좀 더 간결하게 쓸 수 있습니다:
let mut top_languages = languages;
top_languages.retain(|language| language.paradigms.contains(&Paradigm::Functional));
여전히 가변 변수를 사용하지만, 코드가 더 간결해졌습니다. retain은 클로저를 인자로 받는 고차 메서드이므로, 코드가 자연스레 좀 더 함수형적으로 변했습니다. 이 길을 계속 따라가면 어디에 도달할지 살펴봅시다.
let mut top_languages = languages.clone();
top_languages.sort();
let top_languages: Vec<Language> = top_languages
.into_iter()
// 함수형 언어만 남기기
.filter(|language| language.paradigms.contains(&Paradigm::Functional))
// 상위 5개만 남기기
.take(5)
// 결과를 벡터로 수집
.collect();
혹은 외부 크레이트를 사용할 수 있다면, itertools의 sorted_by_key를 사용해 모든 중간 연산을 체이닝할 수 있습니다:
let top_languages: Vec<Language> = languages
.iter()
// 함수형 언어만 남기기
.filter(|language| language.paradigms.contains(&Paradigm::Functional))
// 인기순 내림차순 정렬
.sorted_by_key(|lang| Reverse(lang.users))
// 상위 5개만 남기기
.take(5)
// 새 벡터로 수집
.collect();
상위 5개만 뽑아낼 거라면(필터링 이후라 해도) 전체를 정렬하는 것은 다소 비효율적으로 보입니다. 이는 Rust가 C++에 비해 갖는 한계를 보여주는데, C++ 표준 라이브러리는 partial_sort를 제공합니다. Rust의 std에는 동등한 것이 없지만, 서드파티 크레이트들이 있고, 대안으로 BinaryHeap을 사용할 수도 있습니다.
저에게는 이 해법이 더 이해하기 쉽습니다. 연산들이 가지런히 아래로 정렬되어 있고, 코드가 우리가 달성하려는 목표의 설명처럼 읽힙니다. 다만 함수형 프로그래밍 패턴에 익숙하지 않다면 약간의 적응이 필요하다는 점은 인정합니다.
저는 함수형 프로그래밍에 잘 맞는 문제를 골라 보여주었다고 말할 수도 있습니다. 물론 사실입니다. 하지만 불변 자료구조에 대한 즉석 변환을 할 때는 이런 메서드 체이닝 방식이 시간이 지나면 자연스럽게 느껴집니다.
그 이유는 몇 가지가 있습니다:
map과 filter 같은 메서드는 이전 이터레이터 위에서 동작하는 새 이터레이터를 만들며, 어떤 할당도 발생시키지 않습니다. 실제 계산(예: 1을 더하거나 짝수만 거르기)은 최종 이터레이터가 소비될 때, 이 경우 collect에 의해 수행됩니다. collect는 결과를 새 벡터에 담기 위해 한 번만 할당합니다. 상위 수준의 추상화는 런타임 오버헤드를 유발하지 않습니다.그 결과는 깔끔하고 읽기 쉬우며 효율적인 코드입니다. 그래서 이런 패턴을 자주 보게 됩니다.
어떤 언어로 작업하든, 함수형 스타일로 프로그래밍하면 이점이 있습니다. 편리할 때는 언제든 그렇게 하세요. 편리하지 않을 때는 그 결정을 신중히 고민해야 합니다. — John Carmack
여기서 Carmack은 편의성에 대해 이야기합니다. 함수형 프로그래밍이 불편해지는 전환점은 어디일까요? 좀 더 현실적인 예제로 살펴봅시다.
작은 Rust 연습 문제입니다: 디렉터리의 모든 XML 파일을 어떻게 나열하시겠습니까? 계속하기 전에 직접 시도해 보셔도 좋습니다. 자연스럽게 어떤 스타일에 마음이 가는지 보세요. 다른 접근을 시도해 보고, 어떤 방식이 더 마음에 드는지도 비교해 보세요.
제가 작성한 명령형 해법은 다음과 같습니다:
fn xml_files(p: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for f in fs::read_dir(p)? {
let f = f?;
if f.path().extension() == Some(OsStr::new("xml")) {
files.push(f.path());
}
}
Ok(files)
}
나쁘지 않지만, 뛰어나지도 않습니다.
약간의 장부처리가 필요하고, let f = f?; 같은 자잘한 불편함과 OsStr::to_str 관련 부분이 있습니다. 하지만 전체적으로는 괜찮습니다. 이러한 자잘한 불편함은 문제의 본질적 복잡도에서 비롯됩니다. 즉, 오류 가능성을 다루어야 하고, 플랫폼에 따라 파일 확장자가 항상 유효한 UTF-8이 아닐 수 있다는 점입니다.
OsStr의 문서에 따르면:
눈치 빠른 독자는 우리가 확장자를 확인하기 전에 경로가 실제 파일인지 확인하지 않는다는 점을 알아챘을 것입니다. 간결함을 위해 그렇게 했습니다.
이 문제를 좀 더 함수형 스타일로 어떻게 풀 수 있을지 봅시다:
fn xml_files(p: &Path) -> Result<Vec<PathBuf>> {
let entries = fs::read_dir(p)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension() == Some(OsStr::new("xml")))
.collect();
Ok(entries)
}
이 구현은 더 매끈하다고 볼 수 있습니다. 디렉터리 엔트리를 경로로 매핑하고, XML이 아닌 것을 걸러내고, 결과를 수집합니다. 가변 변수나 조건 분기 없이요.
그렇다고 해서 단점이 없는 것은 아닙니다. 가장 중요한 것은, 이 버전은 명령형 버전과 동등하지 않다는 점입니다. filter_map(Result::ok)이 모든 오류를 걸러내기 때문입니다.
Rust에서 filter는 bool을 반환하는 클로저를 받아 해당 요소를 결과 이터레이터에 포함할지 결정합니다. 반면 filter_map은 Option<T>를 반환하는 클로저를 받습니다.
filter_map의 경우, 클로저가 Some(value)를 반환하면 그 값이 새 이터레이터에 포함되고, None을 반환하면 해당 요소는 제외됩니다. 본질적으로 filter_map은 필터링과 매핑을 한 단계로 수행할 수 있게 해 줍니다.
오류를 무시할지 여부는 사용 사례에 따라 다릅니다. 정확성과 인체공학(사용 편의성) 사이의 절충이죠. 프로덕션 코드에서는 최소한 모든 오류를 로깅해야 합니다. 이를 위해 inspect를 사용할 수 있습니다:
fn xml_files(p: &Path) -> Result<Vec<PathBuf>> {
let entries = fs::read_dir(p)?
디버깅을 위해 이터레이터의 각 요소를 표준 오류로 기록한 뒤, 값을 그대로 전달합니다.
.inspect(|entry| {
if let Err(e) = entry {
eprintln!("Error: {}", e);
}
})
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension() == Some(OsStr::new("xml")))
.collect();
Ok(entries)
}
지금까지는 여전히 함수형 버전에 손을 들어주고 싶지만, 복잡도를 더해가며 두 접근이 어떻게 버티는지 보겠습니다.
임의의 파일 속성을 기준으로 필터링하려면 어떻게 할까요? 예를 들어, 특정 접두사나 확장자를 가진 모든 파일을 찾고 싶을 수 있습니다.
Path를 받아 bool을 반환하는 함수 valid라는 새 매개변수를 도입할 수 있습니다. (함수형 프로그래밍에서 이는 프레디케이트라고도 합니다.)
fn filter_files<F>(p: &Path, valid: &F) -> Result<Vec<PathBuf>>
where
F: Fn(&Path) -> bool,
{
Ok(fs::read_dir(p)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| valid(path))
.collect())
}
이는 다양한 용도에 사용할 수 있는 제네릭 함수입니다. 이와 같은 고차 함수는 함수형 프로그래밍에서 전형적인 패턴이며, Rust에서도 사용할 수 있습니다.
간결했던 명령형 버전도 이제 고차 함수를 품게 되었는데, 이를 통해 함수형과 명령형 사이의 경계가 얼마나 흐릿할 수 있는지 드러납니다:
fn filter_files<F>(p: &Path, valid: &F) -> Result<Vec<PathBuf>>
where
F: Fn(&Path) -> bool,
{
let mut files = Vec::new();
for f in fs::read_dir(p)? {
let f = f?;
if valid(&f.path()) {
files.push(f.path());
}
}
Ok(files)
}
한 걸음 더 나아가 봅시다.
지금까지의 해법은 단일 디렉터리에만 동작했습니다. 디렉터리와 그 하위 디렉터리를 재귀적으로 순회하면서 파일을 필터링하려면 어떻게 할까요?
먼저, (대체로) 가변 상태를 쓰는 명령형 버전입니다:
fn filter_files<F>(p: &Path, valid: &F) -> Result<Vec<PathBuf>>
where
F: Fn(&Path) -> bool,
{
let mut files = Vec::new();
for f in fs::read_dir(p)? {
let f = f?;
if f.path().is_dir() {
files.extend(filter_files(&f.path(), valid)?);
} else if valid(&f.path()) {
files.push(f.path());
}
}
Ok(files)
}
중첩이 한 단계 더 늘었지만, 명령형 버전도 꽤 잘 버텨줍니다.
다음은 함수형 버전입니다:
fn filter_files<F>(p: &Path, valid: &F) -> Result<Vec<PathBuf>>
where
F: Fn(&Path) -> bool,
{
Ok(fs::read_dir(p)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.flat_map(|path| match path {
p if p.is_dir() => filter_files(&p, valid).unwrap_or_default(),
p if valid(&p) => vec![p],
_ => vec![],
})
.collect())
}
여기서는 이터레이터의 이터레이터를 다루므로, flat_map을 사용해 경로의 단일 이터레이터로 평탄화해야 합니다. 하지만 이는 모든 경우에, 비어 있더라도, 경로의 벡터를 반환해야 함을 의미하기도 합니다. unwrap_or_default는 이러한 증상의 일환입니다.
어떤 버전을 선호할지는 여러분께 맡기겠습니다.
어느 쪽이든, 이제는 논리의 흐름을 더 개선할 필요가 있다고 느낍니다. 제가 원하는 것은 더 나은 캡슐화와 모듈화이며, 이를 통해 복잡도를 통제하고 싶습니다. Rust는 이를 위해 객체지향 스타일로 매끄럽게 전환할 수 있게 해 줍니다.
앞서 논의한 함수형·명령형 예제와는 달리, 파일 필터링 로직과 파일 순회를 캡슐화하는 새로운 구조체 FileFilter를 도입해 봅시다.
pub struct FileFilter {
predicates: Vec<Box<Predicate>>,
start: Option<PathBuf>,
stack: Vec<fs::ReadDir>,
}
각 FileFilter 객체는 자신의 상태를 지닙니다. 필터링을 위한 프레디케이트 모음, 시작 경로, 그리고 순회를 위한 디렉터리 스택입니다.
프레디케이트는 다음과 같이 정의합니다:
type Predicate = dyn Fn(&Path) -> bool;
여기서 dyn을 보고 놀랄 수도 있습니다. Rust에서는 두 개의 클로저가 모양이 동일하더라도 동일한 타입을 갖지 않습니다!
클로저 식은 기록할 수 없는 고유하고 익명인 타입의 클로저 값을 생성합니다. - The Rust Reference
이를 Vec 같은 컬렉션에 담기 위해, 우리는 동적 디스패치를 사용하는 트레잇 객체를 사용합니다. 이러한 클로저들을 박싱함으로써, Box<Predicate>(사실상 Box<dyn Fn(&Path) -> bool>)를 만들 수 있고, 고유한 타입을 가지는 서로 다른 프레디케이트 클로저들을 동일한 Vec에 담을 수 있습니다.
함수형 프로그래밍에서는 이터레이터와 클로저의 힘으로 파일을 필터링했습니다. 명령형 스타일에서는 루프와 조건문으로 벡터를 직접 조작했죠. 그러나 FileFilter는 이러한 세부 구현을 추상화합니다.
add_filter 메서드를 보세요:
pub fn add_filter(mut self, predicate: impl Fn(&Path) -> bool + 'static) -> Self {
self.predicates.push(Box::new(predicate));
self
}
이렇게 하면 이전에는 순회 로직과 밀접히 결합되어 있던 작업을, 호출 체이닝으로 손쉽게 여러 필터를 추가할 수 있습니다.
let filter = FileFilter::new()
.add_filter(|path| {
path.file_name()
.and_then(OsStr::to_str)
.map(|name| name.starts_with("foo"))
.unwrap_or(false)
})
.add_filter(|path| path.extension() == Some(OsStr::new("xml")));
Rust에서 OOP 접근을 진정으로 보여주는 부분은 FileFilter에 대한 Iterator 트레잇 구현입니다:
impl Iterator for FileFilter {
type Item = Result<PathBuf>;
fn next(&mut self) -> Option<Self::Item> {
}
}
이로써 FileFilter는 Rust의 강력한 이터레이터 생태계에 말끔히 통합되는 구성 요소가 되며, 다른 어떤 이터레이터와 동일한 자리에서 사용할 수 있습니다. 이 설계는 복잡한 순회 로직을 객체 내부에 캡슐화하여 사용자로부터 세부 구현을 숨길 수 있게 합니다.
FileFilter의 전체 구현은 GitHub 또는 Rust Playground에서 확인할 수 있습니다. 코드는 프로덕션 사용을 권장하는 훌륭한 크레이트 Walkdir을 밀접하게 참고하여 작성했습니다.
FileFilter 예제는 Rust에서의 OOP가 얼마나 탄탄한 캡슐화와 모듈성을 가져오는지 보여줍니다. 이전 예제들에서 파일 필터링 로직은 순회와 밀접히 결합되어 있었지만, 이제는 무엇을(프레디케이트)과 어떻게(순회 및 필터링 로직)를 분리합니다. 트레이트 시스템 덕분에 사용자 정의 이터레이터를 생태계의 나머지 부분과 손쉽게 통합할 수 있습니다. 이러한 도구들이 있으면 코드는 더 조합 가능하고 재사용성도 높아집니다.
Rust에서는 서로 다른 스타일을 섞어 쓰는 것이 가능할 뿐 아니라 권장됩니다! 이는 Rust의 언어 설계에 영향을 준 핵심 요소들을 봐도 알 수 있습니다. C++, Haskell, OCaml, Erlang처럼 다양한 영향이 Rust의 설계를 빚었습니다.
초기 Rust는 더 함수형에 가까웠지만, 이후 다양한 스타일을 지원하는 보다 균형 잡힌 언어로 진화했습니다. 질문은 서로 다른 프로그래밍 패러다임 사이에 선을 어디에 그을지입니다.
저는 흔히 함수형 코어에 명령형 셸을 얹는 방식을 씁니다. 코어는 데이터를 변환하는 작고 조합 가능한 함수들로 구성하고, 셸은 필요한 제어 흐름을 제공합니다. 또한 애플리케이션의 더 큰 부분을 조직화할 때는 객체지향 구성 요소를 자주 사용하여 관련 데이터와 함수를 캡슐화합니다.
저의 개인적인 경험칙은 다음과 같습니다:
마지막으로, 특정 패러다임에 대한 편향을 경계하세요. 가끔씩 자신의 가정을 점검하면 더 나은 코드를 작성할 수 있습니다.