Rust의 Option: null 없는 세상에서 값의 부재를 다루는 법

Rust의 Option: null 없는 세상에서 값의 부재를 다루는 법

자바스크립트나 자바를 쓰다 보면 null이나 undefined 때문에 한 번쯤 데어 본 경험이 있으실 겁니다. 멀쩡하게 돌던 코드가 운영 환경에서 갑자기 Cannot read property of null을 뱉으며 죽는 그 순간 말이죠. 💥

Rust는 아예 null이라는 개념을 빼버렸습니다. 대신 “값이 있을 수도, 없을 수도 있다”를 타입으로 표현하는데, 그게 바로 Option<T>입니다. 이 글에서는 Option을 어떻게 만들고, 안에 든 값을 어떻게 안전하게 꺼내며, 실무에서 자주 만나는 패턴을 어떻게 풀어내는지 차근차근 살펴보겠습니다.

Rust의 에러 처리Result 메서드 정리에서 ResultOption의 관계를 짧게 다뤘는데, 이번 글은 Option 자체에 집중합니다.

Some과 None

Option<T>는 표준 라이브러리에 정의된 열거형(enum)입니다.

enum Option<T> {
    Some(T),
    None,
}

값이 있으면 Some(값), 없으면 None이라는 두 갈래로 표현하죠. 다른 언어의 null과 결정적으로 다른 점은 컴파일러가 두 경우를 모두 처리하지 않으면 빌드를 통과시키지 않는다는 것입니다.

fn find_user(id: u64) -> Option<String> {
    if id == 1 {
        Some(String::from("Dale"))
    } else {
        None
    }
}

호출하는 쪽에서는 String을 바로 받아 쓸 수 없습니다. “있을 수도 있고 없을 수도 있는 값”이라는 사실을 타입이 강제하니까요. 그래서 어떻게든 두 경우를 풀어내야 합니다.

값이 있는지 확인하기

가장 단순한 검사는 is_some()is_none()입니다. 값을 꺼내지는 않고 안에 뭔가 들어 있는지만 확인하고 싶을 때 씁니다.

let cache: Option<String> = load_from_disk();

if cache.is_some() {
    println!("캐시 적중");
}

if cache.is_none() {
    println!("캐시 비어 있음");
}

조금 더 정교하게는 is_some_and(|v| ...)로 “값이 있고, 그 값이 조건을 만족하는지”를 한 번에 물어볼 수 있습니다.

let port: Option<u16> = std::env::var("PORT").ok().and_then(|s| s.parse().ok());

if port.is_some_and(|p| p > 1024) {
    println!("사용자 영역 포트 사용");
}

is_some_and는 Rust 1.70부터 표준 라이브러리에 들어왔습니다. 이전에는 port.map(|p| p > 1024).unwrap_or(false)처럼 빙 둘러야 했죠.

match와 if let으로 풀어내기

값을 실제로 쓰려면 Some 안에서 꺼내야 합니다. 가장 정석적인 방법은 match 패턴 매칭입니다.

match find_user(1) {
    Some(name) => println!("환영합니다, {name}님"),
    None => println!("등록되지 않은 사용자입니다"),
}

두 갈래를 빠짐없이 다루기 때문에 안전하지만, 한쪽만 처리하고 싶을 때는 match 블록이 좀 거추장스럽습니다. 이럴 때 if let이 가볍습니다.

if let Some(name) = find_user(1) {
    println!("환영합니다, {name}님");
}

Some일 때만 안쪽 블록이 실행되고, None이면 그냥 지나갑니다. 반대로 “값이 있으면 계속 진행하고, 없으면 함수에서 빠져나간다”는 패턴이 잦은데, Rust 1.65부터 let else 문법이 들어와 깔끔해졌습니다.

fn greet(id: u64) {
    let Some(name) = find_user(id) else {
        eprintln!("사용자를 찾을 수 없습니다");
        return;
    };

    println!("환영합니다, {name}님");
    // 이 아래로는 name을 그냥 String처럼 씁니다.
}

let else의 묘미는 else 블록이 반드시 함수를 빠져나가야(또는 패닉을 일으키거나 loop로 빠져야) 한다는 점입니다. 컴파일러가 이걸 강제하기 때문에 블록 이후로는 name이 항상 유효한 값임이 보장됩니다. 중첩이 줄어들고, 본 흐름이 평평해지는 효과가 있습니다.

map으로 값 변환하기

Option 안의 값을 다른 값으로 바꾸고 싶을 때마다 match를 쓰면 코드가 길어집니다. map은 “값이 있으면 변환하고, 없으면 그대로 None”을 한 줄로 표현해 줍니다.

let name: Option<String> = find_user(1);
let length: Option<usize> = name.map(|s| s.len());

Some("Dale")Some(4)가 되고, None은 그대로 None이 되죠. 체이닝도 자연스럽습니다.

let upper: Option<String> = find_user(1)
    .map(|s| s.to_uppercase())
    .map(|s| format!(">> {s} <<"));

mapResult에서도 Ok 쪽을 변환하는 것과 똑같은 모양입니다. 다만 클로저가 또 다른 Option을 반환할 때는 map을 쓰면 Option<Option<T>>가 돼버려서 어색해집니다. 그럴 때 등장하는 게 and_then입니다.

and_then으로 체이닝하기

and_then은 다른 언어의 flatMap이나 bind에 해당합니다. “값이 있으면 또 다른 Option을 반환하는 함수에 넘기고, 없으면 None”이라는 뜻이죠.

fn parse_port(s: &str) -> Option<u16> {
    s.parse().ok()
}

let port: Option<u16> = std::env::var("PORT")
    .ok()
    .and_then(|s| parse_port(&s));

여기서 std::env::var("PORT").ok()Option<String>을 돌려주고, parse_portOption<u16>을 돌려줍니다. map을 썼다면 Option<Option<u16>>이 됐을 텐데, and_then이 한 겹을 풀어 평평하게 이어 줍니다.

Result? 연산자가 Option에서도 똑같이 동작하기 때문에 함수 반환 타입이 Option이라면 더 짧게 쓸 수 있습니다.

fn get_port() -> Option<u16> {
    let raw = std::env::var("PORT").ok()?;
    let port = raw.parse().ok()?;
    Some(port)
}

?None을 만나면 함수 전체를 None으로 끝내고, Some 안의 값만 꺼내 다음 줄로 넘깁니다.

기본값 다루기

Option을 풀어 쓸 때 절반 정도는 “값이 없으면 기본값을 쓰자”는 상황입니다. 가장 단순한 건 unwrap_or입니다.

let port: u16 = std::env::var("PORT")
    .ok()
    .and_then(|s| s.parse().ok())
    .unwrap_or(8080);

기본값을 만드는 비용이 크다면 unwrap_or_else로 지연 평가하는 편이 낫습니다.

let config: Config = load_config()
    .unwrap_or_else(|| build_default_config());

unwrap_or_default()는 타입에 Default 구현이 있을 때 한층 짧아집니다.

let count: u32 = cached_count().unwrap_or_default(); // 0

oror_else도 자주 만납니다. unwrap_or 계열이 T를 돌려준다면, or 계열은 Option<T>를 돌려준다는 차이가 있습니다. “이 값이 비었으면 다른 후보를 시도해 보자”는 흐름에 어울립니다.

let theme: Option<String> = user_pref()
    .or_else(|| system_default())
    .or(Some(String::from("light")));

마지막으로 unwrapexpectNone을 만나면 그대로 panic!을 일으킵니다. 프로토타입이나 테스트에서는 편하지만, 운영 코드에 두면 사용자 입력 하나로 서버가 죽어 버릴 수 있어 조심해야 합니다.

as_ref와 as_deref로 빌려 쓰기

Option은 보통 값을 소유합니다. 그런데 안의 값을 빌려서만 보고 싶을 때, 즉 &Option<T>를 받았는데 안의 &T가 필요할 때가 있습니다. 이때 as_ref가 등장합니다.

fn print_length(opt: &Option<String>) {
    if let Some(s) = opt.as_ref() {
        println!("길이: {}", s.len());
    }
}

as_ref&Option<T>Option<&T>로 바꿔 줍니다. 원본의 소유권을 건드리지 않고도 안쪽 값을 들여다볼 수 있게 되죠.

Option<String>을 다룰 때 더 자주 마주치는 건 as_deref입니다. String이 아니라 &str이 필요한 함수에 넘기고 싶을 때 유용합니다.

fn process(name: Option<&str>) {
    println!("처리 중: {name:?}");
}

let name: Option<String> = Some(String::from("Dale"));
process(name.as_deref());

as_derefOption<String>Option<&str>로, Option<Vec<T>>Option<&[T]>로 바꿔 줍니다. Rust의 as_ref와 as_deref에 더 깊은 이야기를 풀어 두었습니다.

take와 replace: Option만의 트릭

OptionResult와 가장 다른 부분은 바로 takereplace입니다. 이 둘은 빌린 참조(&mut Option<T>)에서 안의 값만 빼내거나 다른 값으로 바꿔치우는 도구인데, Rust의 소유권 시스템과 맞물려서 의외로 자주 쓰입니다.

struct Job {
    payload: Option<String>,
}

impl Job {
    fn consume_payload(&mut self) -> Option<String> {
        self.payload.take()
    }
}

take()Option에서 값을 꺼내 돌려주고, 원래 자리에는 None을 채워 둡니다. self.payload를 그냥 옮기려고 하면 “borrowed value를 옮길 수 없다”며 컴파일러가 막는데, take는 이 제약을 깔끔하게 비껴갑니다. 구조체 필드의 소유권을 부분적으로 옮기고 싶을 때 거의 유일한 해결책이죠.

연결 리스트나 트리처럼 Option<Box<Node>> 형태로 자식을 들고 있는 자료구조를 만들 때도 take가 단골손님입니다.

struct Node {
    value: i32,
    next: Option<Box<Node>>,
}

fn pop_front(head: &mut Option<Box<Node>>) -> Option<i32> {
    let mut node = head.take()?;
    *head = node.next.take();
    Some(node.value)
}

head.take()로 첫 노드의 소유권을 빼오고, node.next.take()로 두 번째 노드를 떼어내 새로운 head로 옮겨 심습니다. 참조만으로는 표현하기 까다로운 동작인데, take 덕분에 짧은 코드로 정리됩니다.

replace(new)take와 비슷하지만 빈자리를 None 대신 우리가 원하는 값으로 채워 둡니다.

let mut current: Option<String> = Some(String::from("v1"));
let previous = current.replace(String::from("v2"));

assert_eq!(previous, Some(String::from("v1")));
assert_eq!(current, Some(String::from("v2")));

이전 값을 돌려받으면서 새 값을 그 자리에 꽂아 두는 셈이라, 캐시 갱신이나 상태 전환에서 깔끔하게 들어맞습니다.

get_or_insert(default)get_or_insert_with(|| ...)도 같이 알아두면 좋습니다. Option이 비어 있으면 기본값을 채워 넣고, 어떤 경우든 가변 참조를 돌려주는 메서드입니다.

let mut cache: Option<Vec<String>> = None;
let list = cache.get_or_insert_with(Vec::new);
list.push(String::from("hello"));

이 한 줄이면 “처음이면 비어 있는 벡터를 만들고, 그 위에 항목을 쌓는다”가 끝납니다.

Option과 Result 사이 오가기

마지막으로 OptionResult를 엮는 패턴을 한 번 더 짚어 두겠습니다. OptionResult로 바꿀 때는 ok_orok_or_else를 씁니다.

fn config_port(map: &std::collections::HashMap<String, String>) -> Result<u16, String> {
    let raw = map
        .get("port")
        .ok_or_else(|| String::from("port가 비어 있습니다"))?;
    raw.parse().map_err(|_| String::from("port 형식이 잘못됐습니다"))
}

반대로 Result에서 에러를 버리고 Option만 받고 싶을 때는 .ok()를 씁니다.

let port: Option<u16> = std::env::var("PORT").ok().and_then(|s| s.parse().ok());

.ok()가 두 번 등장한 게 보이실 텐데, 첫 번째는 Result<String, _>Option<String>으로 바꾸고, 두 번째는 Result<u16, _>Option<u16>으로 바꾸는 역할을 합니다.

마치며

Option은 처음에는 “그냥 null 자리에 들어간 래퍼” 정도로 보일 수 있지만, 한참 쓰다 보면 Rust의 소유권 시스템과 가장 깊이 맞물려 있는 타입이라는 게 느껴집니다. mapand_then으로 값을 변환하는 흐름, if letlet else로 평평하게 펴내는 모양에 익숙해지면 자료구조와 상태 머신을 다룰 때 Rust가 한결 편해집니다. 특히 takereplace처럼 소유권을 부분적으로 옮기는 도구는 다른 언어에서는 보기 힘든 Rust만의 무기예요.

Option 안의 값을 빌리는 더 깊은 이야기는 Rust의 as_ref와 as_deref 차이에서 다뤘고, Rust 관련 글에서 다른 주제도 둘러보실 수 있습니다.

더 자세한 내용은 Rust 표준 라이브러리의 std::option 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord