Rust 학습일지 (2) - Rust의 Compiler는 과할만큼 친절하다

Rust 학습일지 (2) - Rust의 Compiler는 과할만큼 친절하다

태그
Rust
최종 수정일
Last updated May 6, 2026
Slug
rust-study-2
Date
May 3, 2026
Published
Published
notion image
이 글은 Rust 실습을 하다보니, 컴파일러를 "까다롭다"가 아니라 "친절하다"고까지 느끼게 된 과정을 정리합니다. 그 친절함이 구체적으로 어떤 장치들을 통해 구현되는지, Option<T>, Result<T, E>, match 세 가지 기능을 통해 살펴봅니다. Rust 학습 시리즈의 두 번째 글입니다.
처음 Rust를 접했을 때 컴파일러는 하나의 벽처럼 느껴졌었습니다. 변수 하나를 선언해도 트집을 잡았고, 함수 하나를 호출해도 처리하지 않은 경우가 있다며, 막아섰습니다. TypeScript 컴파일러가 적당히 넘어가던 일들이 Rust에서는 도무지 허락되지 않았습니다. 점차 그러나, 알면 알 수록 까다롭다가 아니라, 친절하다고 느끼기 시작했습니다.

잔소리의 시작

처음 들은 잔소리는 사소했습니다. 실습 중에 let mut으로 변수를 선언해두고, 막상 그 뒤에서 값을 바꾸지 않았더니 컴파일러가 이렇게 알려주었습니다.
다음은 Rust 컴파일러가 보여준 경고 메시지입니다.
let mut count = 0; println!("{}", count);
warning: variable does not need to be mutable --> src/main.rs:2:9 | 2 | let mut count = 0; | ----^^^^^ | | | help: remove this `mut`
처음에는 신기했습니다. 그냥 mut으로 선언했다고 못 본 척 넘어가도 될 일을 굳이 짚어주는가. 그런데 다시 읽어보니 메시지의 결이 달랐습니다. "틀렸다"가 아니라 "필요 없다"였습니다. 무엇이 틀렸는지가 아니라, 무엇을 의도했는지를 짚어줍니다. 아차 싶었죠.
mut은 "이 변수는 바뀔 것이다"라는 의도의 선언입니다. 그런데 실제로는 바뀌지 않았다면, 코드와 의도가 어긋난 상태입니다. 컴파일러는 그 어긋남을 잡아내고, 의도를 코드에 맞추거나 코드를 의도에 맞추라고 권합니다.
이 정도는 시작입니다. 진짜 친절함은 이제 시작입니다.

null이 새어나오지 않는 함수

Rust에는 null이 없습니다. 대신 Option<T>라는 타입이 있습니다. 뭐 그게 그거 아니야? 싶긴 하지만, 조금 다릅니다.
다음 코드는 사용자를 찾는 함수의 두 가지 시그니처입니다. 위는 TypeScript, 아래는 Rust입니다.
function findUser(id: number): User | undefined { // ... }
fn find_user(id: u32) -> Option<User> { // ... }
표면적으로는 비슷합니다. 둘 다 "있을 수도 있고 없을 수도 있다"를 타입에 박았습니다. 하지만 호출하는 쪽에서의 경험이 다릅니다.
TypeScript에서는 findUser(5).name이라고 써도 컴파일러가 막아줍니다. 문제는 막힌 다음입니다. 막혔을 때 빠져나갈 길이 여러 갈래입니다. findUser(5)!.name처럼 non-null assertion으로 우회할 수 있고, as User로 캐스팅해서 통과시킬 수도 있습니다. 우회 자체가 잘못은 아닙니다. 다만 그 우회가 너무 손쉬워서, 검증 없이 통과시킨 코드가 production에서 터지는 일이 일어납니다.
Rust에는 그런 우회가 없습니다. find_user(5).name이라고 쓰면 컴파일이 되지 않습니다. Option<User> 타입에는 name 필드가 없기 때문입니다. 안에 든 User를 꺼내려면 명시적으로 처리해야 합니다.
let user = find_user(5); match user { Some(u) => println!("{}", u.name), None => println!("유저 없음"), }
unwrap()이라는 우회 장치가 있긴 합니다. find_user(5).unwrap().name은 동작합니다. 그러나 이 함수는 None인 경우 panic을 일으킵니다. 그리고 무엇보다, 코드에 unwrap()이라는 단어가 명시적으로 박혀 있습니다. "여기서 나는 검증을 건너뛰고 있다"가 코드에 시각적으로 드러납니다. TypeScript의 !처럼 한 글자로 숨길 수 없습니다.
빠져나갈 구멍이 아예 없는 것은 아닙니다. 다만 그 구멍은 항상 코드에 드러나야 합니다. 묵시적 우회가 없습니다.

실패가 타입이 되는 순간

Option<T>가 부재를 표현한다면, Result<T, E>는 실패를 표현합니다.
다음은 파일을 읽는 함수의 시그니처입니다.
fn read_config(path: &str) -> Result<Config, ConfigError> { // ... }
이 함수는 두 가지 결과를 반환할 수 있습니다. 성공한 경우 Ok(Config), 실패한 경우 Err(ConfigError). 그리고 호출하는 쪽은 두 경우를 모두 처리해야 합니다.
JavaScript에서 같은 일은 try/catch로 처리됩니다.
try { const config = readConfig("./config.json"); // ... } catch (e) { // ... }
try/catch의 문제는 함수 시그니처만 보고는 이 함수가 실패할 수 있는지 알 수 없다는 점입니다. readConfig라는 이름과 매개변수만 봐서는 예외가 던져지는지 모릅니다. 문서나 구현을 들여다봐야 알 수 있습니다.
Rust에서는 함수 시그니처에 실패 가능성이 박혀 있습니다. Result<Config, ConfigError>라는 반환 타입이 "이 함수는 실패할 수 있고, 실패의 종류는 ConfigError다"를 명시합니다. 호출하는 쪽은 시그니처만 봐도 안다고 말할 수 있습니다.
여기에 ? 연산자라는 도구가 더해지면 코드가 짧아지면서도 정직함은 유지됩니다.
fn load_app() -> Result<App, ConfigError> { let config = read_config("./config.json")?; let app = App::new(config); Ok(app) }
?는 "실패하면 그 실패를 그대로 위로 올려보내고(throw), 성공하면 안의 값을 꺼낸다"는 의미입니다. 한 글자로 처리되지만, 우회가 아니라 위임입니다. 실패를 무시하는 것이 아니라, 처리할 책임을 호출자에게 넘긴다는 사실이 코드에 그대로 드러납니다. 즉, config 는 항상 성공했을 시점만 다룰 수 있습니다. Type narrowing 을 이렇게 간단하게 할 수 있습니다.

모든 경우를 다뤘는가

OptionResult만큼 인상적이었던 것이 match였습니다. 정확히는 match가 모든 경우를 빠짐없이 다뤄야 컴파일이 통과한다는 사실, exhaustive checking이라는 규칙이었습니다. 다음 코드는 결제 상태를 처리하는 enum과 match입니다.
enum PaymentStatus { Pending, Completed, Failed, } fn handle(status: PaymentStatus) { match status { PaymentStatus::Pending => println!("처리 중"), PaymentStatus::Completed => println!("완료"), PaymentStatus::Failed => println!("실패"), } }
세 경우를 모두 처리했습니다. 만약 여기서 Failed 분기를 빠뜨리면 컴파일러가 막아섭니다.
error[E0004]: non-exhaustive patterns: `PaymentStatus::Failed` not covered
이 자체로도 안전장치이지만, 진짜 인상적인 것은 그다음입니다. 시간이 흘러 새로운 결제 상태가 추가되었다고 해봅니다.
enum PaymentStatus { Pending, Completed, Failed, Refunded, // 새로 추가 }
이 한 줄이 추가되는 순간, 코드베이스 전체에서 PaymentStatus를 처리하던 모든 match가 컴파일 에러로 떨어집니다. 컴파일러는 일일이 짚어줍니다. "이 파일의 이 함수는 Refunded를 처리하지 않았다." "이 파일의 저 함수도 마찬가지다."
TypeScript의 union type에서도 비슷한 효과를 흉내낼 수 있습니다. never 타입을 활용한 exhaustive check 패턴이 있습니다. 다만, 언어단에서의 규칙이라기보다는 패턴, 즉 기술에 가깝습니다. ‘Typescript 잘 이해하고서 쓰고자 하는 사람’만이 이러한 패턴을 구사하곤 하죠. (exhausted-check pattern 관련 아티클)
이 강제는 변경의 파급 범위를 컴파일러가 직접 보여준다는 의미입니다. 새 변수를 추가했을 때 어디를 고쳐야 하는지 검색할 필요가 없습니다. 컴파일러가 이미 목록을 가지고 있습니다.

친절함의 정체

처음에 까다롭다고 느꼈던 잔소리들이, 어느 순간 친절함으로 바뀐 이유가 여기에 있었습니다.
Rust 컴파일러의 친절함은 사실 빠져나갈 구멍을 막는 정직함입니다. Option<T>는 부재를 무시하지 못하게 하고, Result<T, E>는 실패를 숨기지 못하게 하며, match의 exhaustive checking은 가능성을 빠뜨리지 못하게 합니다. 우회가 막혀 있는 것은 아니지만, 동시에 명시적입니다. unwrap이든 ?든, 코드에 단어로 박힙니다.
이것이 친절함이라고 느낀 이유는, 결국엔 잘못된 코드가 production까지 흘러가지 않기 때문입니다. 컴파일러가 막아준 자리에는, 어쩌면 런타임에 터질 미래의 버그가 있었을 것입니다. 그 버그들을 컴파일 시점에 한 번에 짚어주는 것을, 친절함으로 느끼게 됐습니다.
물론 이 친절함에는 대가가 있습니다. 코드를 짜는 속도가 너무 느려집니다. 너무 너무 느려집니다. 사실 친절함을 느끼기까지엔, 조금 험난했습니다. 한 줄 쓰면 컴파일러가 두 줄을 짚고, 두 줄 쓰면 세 줄을 막습니다. 그러나 익숙해지고 나면, 이런 것도 잡아주네? 라는 생각을 하게 됐습니다.
이전 글에서는 Rust의 정수 타입이 메모리를 어떻게 해석할지를 약속하는 도구라고 정리했습니다. 이 글에서는 Rust의 타입 시스템이 한 단계 더 나아가, 런타임에 일어날 수 있는 가능성 자체를 컴파일 타임으로 끌어올린다는 점을 보았습니다. 부재, 실패, 미처리. 다른 언어에서는 런타임의 영역이었던 것들이 Rust에서는 타입으로 표현됩니다.
다음 학습 노트에서는 Rust가 강제하는 또 다른 정직함으로 넘어가보려 합니다. 이 글에서 본 친절함은 "값이 있는가, 없는가"와 "성공했는가, 실패했는가"라는 가능성에 관한 것이었습니다. Rust에는 그보다 한 층 아래에서 작동하는 또 다른 정직함이 있습니다. "이 값을 지금 누가 쓰고 있는가", "쓰고 난 뒤에 값은 어떻게 되는가"를 컴파일러가 추적하는 영역입니다. 다음 글에서는 그 영역, 즉 소유권으로 넘어가보려 합니다.

참고 자료