Rust의 let else: 패턴 매칭 실패 시 깔끔하게 조기 반환하기
Option이나 Result를 다루다 보면 비슷한 코드를 자꾸 반복해서 쓰게 되는 순간이 있습니다. 값이 들어 있으면 꺼내서 계속 진행하고, 그렇지 않으면 일찍 함수를 빠져나가는 패턴이죠.
fn parse_port(input: &str) -> Result<u16, String> {
let port = if let Ok(n) = input.parse::<u16>() {
n
} else {
return Err(format!("'{input}'은 유효한 포트가 아닙니다"));
};
// 이후 port를 가지고 작업을 이어감
Ok(port)
}
값을 꺼내려는 게 코드의 본 의도인데, 정작 화면에서 가장 눈에 띄는 건 if let과 그 뒤에 따라붙는 두 갈래의 분기죠. Rust 1.65에서 안정화된 let ... else 구문은 바로 이 패턴을 한 줄로 정리해주는 도구입니다. 이번 글에서는 let else가 어떤 모양이고, if let이나 match와 어떻게 다른지, 그리고 어떤 상황에서 가장 빛을 발하는지 살펴보겠습니다.
let else란
let else는 패턴 매칭과 조기 반환을 하나의 구문으로 묶은 문법입니다. 기본 형태는 다음과 같아요.
let PATTERN = EXPRESSION else {
// 다이버전스(diverging) 블록
};
패턴이 매칭에 성공하면 그 안에서 묶인 변수가 바깥 스코프 에 그대로 바인딩됩니다. 매칭에 실패하면 else 블록이 실행되고, 이 블록은 반드시 함수를 빠져나가거나(return), 루프를 빠져나가거나(break, continue), 패닉(panic!)을 일으키는 식으로 흐름을 끝내야 합니다. 컴파일러는 이 다이버전스를 !(never) 타입으로 검증해줘서, else만 제대로 작성하면 그 뒤로는 패턴이 성공한 경우의 변수만 안전하게 사용할 수 있죠.
위에서 봤던 코드를 let else로 다시 써보면 이렇게 줄어듭니다.
fn parse_port(input: &str) -> Result<u16, String> {
let Ok(port) = input.parse::<u16>() else {
return Err(format!("'{input}'은 유효한 포트가 아닙니다"));
};
Ok(port)
}
port라는 변수가 함수 본문 어디서든 그냥 쓸 수 있는 평범한 로컬 변수처럼 자리 잡았습니다. “값이 있으면 계속 진행한다”는 흐름이 코드의 형태에 그대로 반영된 셈이죠.
if let과 무엇이 다른가
if let도 패턴 매칭으로 값을 꺼내는 도구이긴 합니다. 다만 if let으로 묶인 변수는 그 블록 안에서만 살아 있어요.
fn print_length(input: Option<&str>) {
if let Some(s) = input {
println!("길이: {}", s.len());
}
// 여기서는 s를 쓸 수 없음
}
여기서 s를 함수 끝까지 가져가고 싶으면 흔히 if let ... else { return } 패턴을 쓰는데, 이렇게 하면 들여쓰기가 깊어지고 본문 흐름이 분기 안으로 빨려 들어가버립니다.
fn print_length(input: Option<&str>) {
if let Some(s) = input {
// 본문이 여기 안쪽으로 다 들어감
println!("길이: {}", s.len());
// ...
} else {
return;
}
}
let else는 이 관계를 뒤집습니다. 정상 흐름은 바깥 스코프에 그대로 두고, 비정상 흐름만 else 안에 넣는 거죠.
fn print_length(input: Option<&str>) {
let Some(s) = input else {
return;
};
println!("길이: {}", s.len());
// 이후 코드도 같은 들여쓰기 깊이로 이어짐
}
함수의 핵심 로직이 한 들여쓰기 단계에 머물러 있어서, 코드를 위에서 아래로 읽기가 훨씬 편합니다. “전제 조건을 검증하고, 만족하면 본 작업을 진행한다”는 가드 절(guard clause) 스타일이 자연스럽게 따라오죠.
match와 비교
match로도 같은 일을 할 수 있긴 합니다.
fn parse_port(input: &str) -> Result<u16, String> {
let port = match input.parse::<u16>() {
Ok(n) => n,
Err(_) => return Err(format!("'{input}'은 유효한 포트가 아닙니다")),
};
Ok(port)
}
배리언트가 두 개뿐이라면 match도 충분히 깔끔합니다. 다만 의도가 “성공한 값만 꺼내고 나머지는 빠져나간다”인 경우에는 match가 다소 과한 느낌이 들 수 있어요. 모든 배리언트를 똑같은 비중으로 나열하니까요.
let else는 “성공 케이스가 본류, 나머지는 탈출”이라는 의도를 문법 자체로 드러냅니다. 분기가 두 개를 넘어가는 진짜 분류 작업이라면 match가 여전히 적절하고, 단순히 한 패턴만 꺼내고 싶다면 let else가 더 잘 맞는다고 생각하시면 됩니다.
? 연산자와의 역할 차이
값을 꺼내고 싶을 때 ? 연산자를 떠올리는 분들도 계실 텐데요. ?는 Result나 Option을 함수의 반환 타입에 맞춰 자동으로 전파해주는 도구라서, 호출하는 함수의 반환 타입과 형이 맞을 때만 쓸 수 있습니다.
fn parse_port(input: &str) -> Result<u16, std::num::ParseIntError> {
let port = input.parse::<u16>()?;
Ok(port)
}
깔끔하지만 에러 타입을 자유롭게 바꾸거나, 에러를 로그로 남기고 다른 값으로 대체하거나, Option을 받았는데 함수는 Result를 반환해야 하는 식으로 변환이 끼어들면 한계가 보이기 시작합니다. let else는 이 사이를 메워줍니다. else 블록 안에서 임의의 코드를 실행할 수 있으니까요.
fn handle_request(raw: Option<&str>) -> Result<String, String> {
let Some(body) = raw else {
tracing::warn!("요청 본문이 비어 있습니다");
return Err("empty body".to_string());
};
Ok(body.to_uppercase())
}
대략 정리하면, 단순히 같은 에러 타입으로 그대로 전파만 한다면 ?, 변환이나 부수 효과가 끼어든다면 let else가 어울립니다.
활용 사례
가장 흔히 쓰는 곳은 역시 Option과 Result를 풀어내는 자리지만, 구조 분해가 가능한 패턴이라면 어디든 쓸 수 있습니다. enum 배리언트를 골라낼 때도 잘 어울려요.
enum Command {
Run { script: String },
Stop,
}
fn extract_script(cmd: Command) -> Option<String> {
let Command::Run { script } = cmd else {
return None;
};
Some(script)
}
이런 코드는 예전 같으면 if let이나 match로 풀어야 했지만, let else 덕분에 “이 함수는 Run 명령에만 의미가 있다”는 사전 조건을 첫 줄에 못 박아두는 모양이 됩니다. 함수가 길어질수록 이 차이가 가독성에 큰 영향을 주죠.
루프 안에서 다음 항목을 건너뛰는 용도로도 자주 보입니다.
fn collect_even_squares(values: &[Option<i32>]) -> Vec<i32> {
let mut out = Vec::new();
for v in values {
let Some(n) = v else { continue };
if n % 2 != 0 {
continue;
}
out.push(n * n);
}
out
}
continue로 매끄럽게 다음 반복으로 넘어가기 때문에, if let Some(n) = v { ... }로 본문 전체를 감싸는 것보다 흐름이 평탄해집니다.
다양한 패턴과 변수 바인딩
let else의 패턴은 단순히 Some이나 Ok 한 단계를 푸는 데 그치지 않습니다. 보통의 let이 받아들이는 모든 패턴을 그대로 쓸 수 있어서, 한 번에 여러 단계의 구조 분해를 끝낼 수도 있어요.
struct Order {
status: Status,
item: Option<String>,
}
enum Status {
Confirmed,
Pending,
}
fn confirmed_item(order: Order) -> Option<String> {
let Order {
status: Status::Confirmed,
item: Some(item),
} = order
else {
return None;
};
Some(item)
}
status는 Confirmed 배리언트여야 하고 item은 Some이어야 한다는 두 조건을 한 줄에 묶어서 검증한 뒤, 통과한 경우에만 item이라는 이름이 바깥 스코프에 살아남습니다. if let이나 match로 같은 코드를 짜면 중첩이 두 단계로 늘어나서 본문이 한참 안쪽으로 들어가게 되죠.
또 하나 알아두면 좋은 점은 묶인 변수에 그대로 mut을 붙일 수 있다는 사실입니다.
fn capitalize_first(input: Option<String>) -> Option<String> {
let Some(mut s) = input else {
return None;
};
if let Some(first) = s.get_mut(0..1) {
first.make_ascii_uppercase();
}
Some(s)
}
Some(mut s)처럼 패턴 안에서 가변성을 선언하면 이후 코드에서 s를 자유롭게 수정할 수 있습니다. let else로 추출한 변수도 결국은 평범한 로컬 변수이기 때문에, 우리가 익숙한 let mut의 모든 규칙이 그대로 적용된다고 보시면 됩니다.
주의할 점
let else를 쓸 때 가장 자주 막히는 지점은 else 블록의 다이버전스 요구입니다. else 안에서 어떤 식으로든 흐름이 끝나야지, 그렇지 않으면 컴파일 에러가 납니다.
fn parse_port(input: &str) -> Result<u16, String> {
let Ok(port) = input.parse::<u16>() else {
// 컴파일 에러: 이 블록은 반드시 다이버전스해야 함
0
};
Ok(port)
}
기본값으로 대체하고 싶다면 let else가 아니라 unwrap_or나 unwrap_or_else가 맞는 도구입니다.
fn parse_port(input: &str) -> u16 {
input.parse::<u16>().unwrap_or(8080)
}
또 한 가지, 패턴은 반드시 반증 가능(refutable) 해야 합니다. let x = expr else { ... } 처럼 반드시 매칭에 성공하는 패턴은 else가 의미가 없어서 컴파일러가 거부합니다. let else는 어디까지나 “실패할 수도 있는 패턴”을 위한 문법이라는 점만 기억해두시면 됩니다.
마치며
let else는 새로운 능력을 추가해주는 기능은 아닙니다. if let이나 match로 짤 수 있는 코드를 더 짧고 평탄하게 표현해주는 문법적 도구죠. 그런데도 한 번 익숙해지면 다시 돌아가기 싫을 만큼 일상 코드의 모양이 달라집니다. 보일러플레이트가 줄고 가드 절 스타일이 자연스러워지면서, 함수가 진짜로 하려는 일이 코드 위쪽에 또렷하게 드러나게 되거든요.
패턴 매칭이나 에러 처리에 익숙하시다면 별도의 학습 곡선 없이 바로 일상 코드에 끼워 넣을 수 있는 기능이니, 다음에 if let ... else { return }을 쓰게 되는 순간이 오면 한 번 떠올려보시기 바랍니다.
더 자세한 사양은 Rust Reference의 let-else 항목을 참고하세요.
This work is licensed under
CC BY 4.0