러스트가 어렵게 느껴지는 ‘평범한’ 프로그래머를 위해, 소유권과 빌림을 피하는 요령, 과감한 clone 사용, derive 매크로의 활용, 문자열 인자 타입(AsRef/Into) 패턴, 프로젝트 전역 Error enum과 thiserror, From 트레이트와 ? 연산자로 map_err를 최소화하는 방법 등을 실전 예제로 정리했다.
Posted: 2024년 5월 1일 수요일 | permalink | 댓글 2개
나: “안녕하세요, 다들. 제 이름은 Matt이고, 저는 평범한 프로그래머입니다.”
모두: “안녕하세요, Matt.”
진행자: “Matt, 당신은 알코올중독자인가요?”
나: “아니요, 트위터 읽는 걸 그만둔 이후로는요.”
진행자: “그럼 방을 잘못 찾아오신 것 같네요.”
그래요, 그게 제 작은 비밀입니다 — 저는 평범한 프로그래머예요. 제가 가장 공감하는 “해커”의 정의는 “도끼로 가구를 만드는 사람”입니다. 저는 단순하고 직선적인 코드를 씁니다. 복잡한 걸 이해하려 들면 머리가 아프거든요.
그래서 저는 늘 OCaml, Haskell, Clojure 같은 더 “학술적”인 언어들을 피해 왔습니다. 그 언어들이 훌륭하다는 건 알아요 — 저보다 훨씬 똑똑한 사람들이 그걸로 대단한 걸 만들고 있으니까요 — 하지만 “엔도펑터(endofunctor)”라는 말을 듣는 순간 집중력이 증발합니다(그리고 삶의 의지도 절반쯤요). 제가 선호하는 언어는 지적 과부하가 덜한 C, PHP, Python, Ruby 같은 것들입니다.
그런데도 저는 러스트를 꽤 열정적으로 받아들였습니다. 지금까지 제가 “진지하게” 써도 그럭저럭 편안함을 느끼는 언어 중에서 러스트가 단연 가장 “복잡한” 편이죠. 그 이유 중 하나는, 악명 높은 borrow checker(대여 검사기), 라이프타임, 그리고 언어의 어둡고 무서운 구석들을 거의 완전히 피하게 해 주는 원칙 묶음을 제가 만들어냈기 때문입니다. 또 하나는, 러스트가 제가 더 나은 소프트웨어를 쓰도록 도와주고, 제가 그 도움을 (거의) 언제나 _체감_하기 때문이라 생각합니다.
저와 같은 평범한 프로그래머들이 러스트를 포용할 수 있도록, 지금까지 제가 모아온 원칙들을 소개합니다.
러스트를 조금이라도 아신다면, 아마도 “무시무시한” borrow checker에 대해 들어보셨을 겁니다. 동시에 같은 데이터를 수정하려 드는 두 코드 조각이 있거나, 더 이상 유효하지 않은 값을 사용하려 할 때 그걸 막아주는 녀석이죠.
러스트의 빌림(차용) 의미론은 안전성을 해치지 않으면서도 훌륭한 성능을 가능하게 합니다. 하지만 우리 같은 평범한 프로그래머에겐 이게 금세 아주 복잡해집니다. 그래서 컴파일러가 “명시적 라이프타임(explicit lifetimes)”을 운운하려 들면, 저는 그냥 “소유(owned)” 값을 써서 입을 다물게 합니다.
물론 절대 빌리지 않는 건 아닙니다. 평범한 프로그래머에게도 “빌려도 안전한” 몇 가지 상황이 있다는 걸 알고 있어요(뒤에서 다룹니다). 하지만 어떻게 흘러갈지 확신이 서지 않는 순간엔 곧장 소유 값을 택합니다.
예를 들어, struct나 enum에 어떤 텍스트를 저장해야 한다면, 무조건 String에 넣습니다. 라이프타임이나 &'a str는 생각조차 하지 않아요 — 그건 더 똑똑한 분들께 맡깁니다. 비슷하게, 뭔가의 리스트가 필요하면 매번 Vec<T>입니다 — 제 struct 안에 &'b [T] 같은 건 사양합니다.
위에서 이어서, 저는 이제 .clone()을 두려워하지 않습니다. 밭에 씨 뿌리듯 코드 여기저기에 뿌립니다. 누가 누구에게서 무엇을 빌리는지 따지느라 시간을 쓸 바엔, 모두에게 자기 걸 하나씩 주면 되죠.
러스트 북(그리고 여기저기)에는 clone이 “비쌀” 수 있다는 경고가 있습니다. 데이터 구조를 복제하면 CPU 사이클과 메모리를 쓰는 건 사실이니까요. 하지만 실제로 그게 문제되는 경우는 드뭅니다. CPU 사이클은 (대개) 넉넉하고, RAM도 (대개) 비교적 저렴하죠. 평범한 프로그래머의 정신 에너지는 비싸며, 조기 최적화에 낭비해선 안 됩니다. 게다가 다른 현대 언어 대부분에서 넘어왔다면, 러스트가 이미 엄청난 성능을 주고 있어서, 눈에 보이는 것마다 .clone()을 해도 대체로 이득일 가능성이 큽니다.
만약 기적적으로, 제가 쓴 무언가가 너무 인기 있어져서 그 모든 쓸데없는 클론의 “비용”이 문제로 떠오른다면, 그때 가서 저보다 훨씬 똑똑한 분께 비용을 드리고 제 프로그램을 제로-카피의 효율적 걸작으로 바꿔달라고 하면 됩니다. 그때까지는… 저는 “빠르게, 자주 복제하라!” 주의입니다.
여기저기 .clone()을 쓰기 시작하면, 곧 이런 오류를 맞닥뜨릴 겁니다:
error[E0599]: no method named clonefound for structFoo in the current scope
모든 것이 클론 가능하진 않기 때문에, 당신의 것을 클론하려면 직접 메서드를 구현해야 하죠. 음… 꼭 그런 건 아닙니다.
러스트에서 제가 특히 감탄하는 것 중 하나가 “derive 매크로”입니다. struct나 enum에 작은 표시를 달아두면, 컴파일러가 알아서 잔뜩 코드를 써 줍니다! Clone은 소위 “파생 가능 트레이트” 중 하나라서, struct 위에 #[derive(Clone)]만 붙이면 뿅! 마음껏 .clone()을 쓸 수 있게 됩니다.
그 외에도 흔히 유용한 것들이 있어서, 저는 사실상 모든 데이터 구조에 다음 트레이트 집합을 파생시키곤 합니다:
#[derive(Clone, Debug, Default)]
struct Foo {
// ...
}
struct나 enum 정의를 쓸 때마다, #[derive(Clone, Debug, Default)] 한 줄이 맨 위에 들어갑니다.
Debug 트레이트는 dbg!() 매크로나 format!() 매크로의 {:?} 포맷(그리고 포맷 문자열을 받는 그 어디서든)으로 데이터 구조의 “디버그” 표현을 출력하게 해 줍니다. “저게 정확히 뭐지?”라고 말할 수 있는 능력은 너무 자주 필요해서, Debug 구현이 없으면 에어론 의자에 한 팔 묶고 코딩하는 기분이죠.
한편, Default 트레이트는 각 필드가 자신의 기본값으로 설정된 “빈” 인스턴스를 만들게 해 줍니다. 모든 필드가 Default를 구현해야 작동하긴 하지만, 표준 타입들이 꽤 많이 지원하기 때문에, 자동 파생 Default를 못 쓰는 구조를 정의하는 일은 드뭅니다. enum도 쉽습니다. 기본 변형 하나에 표시만 달면 됩니다:
#[derive(Clone, Debug, Default)]
enum Bar {
Something(String),
SomethingElse(i32),
#[default] // <== 장난 끝
Nothing,
}
앞서 저는 보통 소유 값을 선호하고 사용한다고 했지만, borrow checker 신들을 노하게 만들지 않고 _빌려도 된다_는 걸 아는 몇 가지 상황이 있어서, 그럴 땐 편히 빌립니다.
첫째, 함수에 어떤 값을 넘기되, 그 함수가 그 값을 잠깐 훑어보고 무엇을 할지 결정하기만 하면 되는 경우입니다. 예를 들어, Vec<u32> 안에 짝수가 있는지 알고 싶다고 합시다. 이럴 때는 다음처럼 Vec 자체를 넘길 수도 있겠죠:
fn main() {
let numbers = vec![0u32, 1, 2, 3, 4, 5];
if has_evens(numbers) {
println!("EVENS!");
}
}
fn has_evens(numbers: Vec<u32>) -> bool {
numbers.iter().any(|n| n % 2 == 0)
}
하지만 이렇게 하면 나중에 numbers를 또 쓰려 할 때 꼬입니다. 예를 들면:
fn main() {
let numbers = vec![0u32, 1, 2, 3, 4, 5];
if has_evens(numbers) {
println!("EVENS!");
}
// 컴파일러가 "value borrowed here after move"라고 불평함
println!("Sum: {}", numbers.iter().sum::<u32>());
}
fn has_evens(numbers: Vec<u32>) -> bool {
numbers.iter().any(|n| n % 2 == 0)
}
친절하게도 컴파일러는 이 문제를 해결하려고 제 오랜 단짝 .clone()을 쓰라고 제안할 겁니다. 하지만 저는 Vec<u32>를 has_evens()에 빌린 슬라이스 &[u32]로 빌려주는 건 borrow checker가 문제 삼지 않을 거라는 걸 압니다. 이렇게요:
fn main() {
let numbers = vec![0u32, 1, 2, 3, 4, 5];
if has_evens(&numbers) {
println!("EVENS!");
}
}
fn has_evens(numbers: &[u32]) -> bool {
numbers.iter().any(|n| n % 2 == 0)
}
제가 세운 일반 규칙은, 라이프타임 생략(lifetime elision)(“컴파일러가 알아서 추론한다”는 멋있는 말)을 활용할 수 있으면 아마도 괜찮다는 겁니다. 덜 멋있게 말하면, 컴파일러가 'a 같은 걸 어디에 적으라고 하지 않는 한, 저는 안심입니다. 반대로, 컴파일러가 “명시적 라이프타임” 같은 말을 꺼내는 순간, 저는 거기서 손을 딱 떼고 보이는 것마다 복제하기 시작합니다.
라이프타임 생략을 쓰는 또 다른 예는 struct나 enum의 필드 값을 반환할 때입니다. 이 경우 보통은 빌린 값을 반환해도 괜찮습니다. 호출자가 그 값을 잠깐 들여다만 보고, 그 struct 자체가 스코프를 벗어나기 전에 버릴 거라 기대할 수 있으니까요. 예를 들면:
struct Foo {
id: u32,
desc: String,
}
impl Foo {
fn description(&self) -> &str {
&self.desc
}
}
_함수_에서 참조를 반환하는 건 평범한 프로그래머에게 거의 항상 치명적인 죄지만, struct 메서드에서 반환하는 건 종종 괜찮습니다. 드물게 호출자가 제가 돌려준 참조의 수명을 더 길게 가져가고 싶다면, .to_owned()를 호출해 자기 소유의 값으로 바꿀 수 있습니다.
러스트에는 문자열을 표현하는 타입이 몇 가지 있습니다 — 자주 보게 되는 건 String과 &str이죠. 그럴 만한 이유가 있지만, 그냥 “텍스트 한 덩어리”를 받고 싶을 뿐일 때(그리고 지저분한 세부사항은 신경 쓰고 싶지 않을 때) 메서드 시그니처가 복잡해집니다.
예를 들어, 문자열 길이가 짝수인지 확인하는 함수가 있다고 해봅시다. 넘긴 값을 잠깐 들여다보기만 하니, 문자열 참조 &str을 인자로 받으면 될 것 같죠. 이렇게요:
fn is_even_length(s: &str) -> bool {
s.len() % 2 == 0
}
그럴싸해 보이지만, 누군가 포매팅된 문자열을 검사하려고 하면 문제가 생깁니다:
fn main() {
// 컴파일러가 "expected `&str`, found `String`"라고 말함
if is_even_length(format!("my string is {}", std::env::args().next().unwrap())) {
println!("Even length string");
}
}
format!은 문자열 참조 &str가 아니라 소유 문자열 String을 반환하므로 문제가 생깁니다. 물론 format!()이 반환한 String을 &str로 바꾸는 건 간단합니다(앞에 &만 붙이면 되죠). 하지만 평범한 프로그래머인 우리는 함수마다 어떤 종류의 문자열을 받는지 일일이 기억하고 필요한 곳마다 &를 붙일 수 없습니다. 그리고 컴파일러가 투덜댈 때마다 고치는 건 몹시 귀찮습니다.
반대 경우도 있습니다. 메서드는 소유 String을 원하지만, 우리 손엔 &str만 있는 상황(예: 문자열 리터럴인 "Hello, world!"를 넘기는 경우). 이럴 땐 값을 넘기기 전에 .to_string(), .to_owned(), String::from() 등 수많은 “이걸 String으로 바꿔줘” 메서드 중 하나를 써야 하고, 금세 지저분해집니다.
그래서 저는 인자로 String 이나 &str을 받지 않습니다. 대신 트레이트의 힘을 빌려, 문자열이거나 문자열로 바꿀 수 있는 무엇이든 받게 합니다. 예시를 보시죠.
먼저, 보통이라면 &str을 타입으로 쓸 곳에 impl AsRef<str>을 씁니다:
fn is_even_length(s: impl AsRef<str>) -> bool {
s.as_ref().len() % 2 == 0
}
as_ref()를 한 번 더 호출해야 하지만, 이제는 String이든 &str이든 모두 넘겨서 결과를 얻을 수 있습니다.
반대로, 제가 String을 받고 싶을 때(대개 그 값을 소유하려는 이유, 예를 들어 새 struct 인스턴스를 만들기 때문이겠죠), 타입으로 impl Into<String>을 씁니다:
struct Foo {
id: u32,
desc: String,
}
impl Foo {
fn new(id: u32, desc: impl Into<String>) -> Self {
Self { id, desc: desc.into() }
}
}
인자 desc에 .into()를 호출해야 해서 struct를 만드는 코드가 살짝 못생겨지긴 하지만, Foo::new(1, "this is a thing")과 Foo::new(2, format!("This is a thing named {name}"))를 문자열 종류에 상관없이 호출할 수 있다는 점을 생각하면 충분히 지불할 만한 대가라고 봅니다.
Error enum을 두자러스트의 오류 처리(Result가… 사방천지)와 그 주변의 삶의 질 개선 설탕(예: 단락 연산자 ?)은 매우 손에 잘 붙는 접근입니다. 평범한 프로그래머의 삶을 쉽게 하려면, 모든 프로젝트를 thiserror::Error를 파생한 Error enum 하나로 시작하고, Result를 반환하는 모든 함수/메서드에서 그걸 쓰길 권합니다.
그다음 Error 타입을 어떻게 구성할지는 천편일률적이진 않지만, 보통 저는 다른 설명이 필요한 오류 유형마다 별도의 enum 변형을 만듭니다. thiserror를 쓰면 설명을 붙이기도 쉽습니다:
#[derive(Clone, Debug, thiserror::Error)]
enum Error {
#[error("{0} caught fire")]
Combustion(String),
#[error("{0} exploded")]
Explosion(String),
}
저는 각 오류 변형을 만드는 함수도 구현합니다. 그러면 앞서의 Into<String> 요령을 쓸 수 있고, 때로는 .map_err()로 다른 곳에서 오류를 만들 때도 편리합니다(뒤에서 더 이야기합니다). 위 Error에 대한 impl은 아마 이렇게 될 겁니다:
impl Error {
fn combustion(desc: impl Into<String>) -> Self {
Self::Combustion(desc.into())
}
fn explosion(desc: impl Into<String>) -> Self {
Self::Explosion(desc.into())
}
}
약간 지루한 보일러플레이트이긴 한데, 원한다면 thiserror-ext 크레이트의 thiserror_ext::Construct 파생 매크로가 힘든 일을 대신해 줍니다. 이것도 Into<String> 요령을 잘 압니다.
map_err를 추방하라(대부분)이제 막 러스트에 발을 담근 평범한 프로그래머라면, 파일 처리 코드를 대략 이렇게 쓸지도 모릅니다:
fn read_u32_from_file(name: impl AsRef<str>) -> Result<u32, Error> {
let mut f = File::open(name.as_ref())
.map_err(|e| Error::FileOpenError(name.as_ref().to_string(), e))?;
let mut buf = vec![0u8; 30];
f.read(&mut buf)
.map_err(|e| Error::ReadError(e))?;
String::from_utf8(buf)
.map_err(|e| Error::EncodingError(e))?
.parse::<u32>()
.map_err(|e| Error::ParseError(e))
}
이 코드도 잘 동작합니다(아마도요, 제가 실제로 돌려보진 않았습니다). 하지만 .map_err() 호출이 엄청 많습니다. 함수의 절반이 넘는 분량을 차지하죠. From 트레이트의 힘과 ? 연산자의 마법을 쓰면 훨씬 깔끔해집니다.
우선, 오류 생성 보일러플레이트 함수를 이미 만들어뒀다고 가정합시다(아니면 thiserror_ext::Construct가 대신 만들어줬거나요). 그러면 파일 처리 부분을 조금 단순화할 수 있습니다:
fn read_u32_from_file(name: impl AsRef<str>) -> Result<u32, Error> {
let mut f = File::open(name.as_ref())
// 여기서 `.to_string()`을 없앴고…
.map_err(|e| Error::file_open_error(name.as_ref(), e))?;
let mut buf = vec![0u8; 30];
f.read(&mut buf)
// …여기선 명시적 파라미터 전달을 뺐습니다
.map_err(Error::read_error)?;
// ...
뒤쪽 .map_err()가 |e| 없이 좀 낯설게 보일 수 있는데, 함수 자체를 클로저처럼 넘기는 표현입니다. 몇 글자 타이핑을 줄인 셈이죠. 우리가 평범하다고 해서 게으르지 말란 법은 없습니다.
다음으로, 나머지 두 오류에 대해 From 트레이트를 구현하면, 문자열 처리 줄들이 확 깔끔해집니다. 먼저 트레이트 구현부터:
impl From<std::string::FromUtf8Error> for Error {
fn from(e: std::string::FromUtf8Error) -> Self {
Self::EncodingError(e)
}
}
impl From<std::num::ParseIntError> for Error {
fn from(e: std::num::ParseIntError) -> Self {
Self::ParseError(e)
}
}
(이것도 보일러플레이트이고, 이번엔 변형에 #[from] 태그만 달면 thiserror가 From 구현을 자동으로 만들어 줍니다)
어쨌든, 어떤 방식으로든 From 구현을 갖추고 나면, 문자열 처리 코드는 사실상 오류 처리에서 해방됩니다:
Ok(
String::from_utf8(buf)?
.parse::<u32>()?
)
? 연산자는 각 메서드가 반환하는 오류 타입을 반환 타입의 오류로 From을 통해 자동으로 변환해 줍니다. 아주 작은 단점이라면, 마지막의 ?가 Result를 벗겨내므로, 되돌려줄 값을 Ok()로 감싸 다시 Result로 만들어야 한다는 점입니다. 하지만 그 정도 대가로 .map_err() 호출들을 싹 없앨 수 있다면 충분히 남는 장사입니다.
저는 보통 코딩하면서 Result를 반환하는 호출 뒤에 죄다 ?를 붙여봅니다. 그리고 컴파일러가 어떤 새로운 오류 타입으로 변환을 못 하겠다고 투덜대면 그때 Error 변형을 하나 더 추가하죠. 사실상 제로 노력 — 평범한 프로그래머에게 최상의 결과입니다.
마무리로, 평범함이 졸작을 의미하지도, 배움을 멈추고 장인을 향한 길을 포기해야 한다는 뜻도 아니라고 말하고 싶습니다. 제가 최근에 매우 유익하게 읽은 책으로 Effective Rust(저자: David Drysdale)가 있습니다. 저자는 온라인 열람을 아주 친절히 허락했지만, (종이책이나 전자책으로) 구매해 주시면 고마워할 겁니다.
이 책이 제게 특히 좋았던 점은, 우리 같은 평범한 프로그래머도 아주 잘 읽힌다는 겁니다. 각 섹션이 제게 정말 “딱” 와닿는 방식으로 쓰여 있어요. 라이프타임과 borrow checker, 특히 라이프타임 생략처럼 제가 오랫동안 어려워했던 러스트의 측면들이 해당 부분을 읽고 나서야 비로소 이해가 되었습니다.
저는 현재 낯선 분들의 친절에 기대어 살고 있습니다. 이 글에서 유용한 것(혹은 재미있었던 것)을 찾으셨다면, 시원한 음료 한 잔 사 주시는 건 어떨까요? 여러분이 제 일을 좋아한다는 걸 알게 되면 큰 힘이 되고, 제 영혼을 사모펀드에 팔지 않도록 버티는 데도 도움이 됩니다.