Result와 복구 가능한 오류
Result의 성공·실패 값을 match로 처리하고 unwrap·expect의 위험과 물음표 연산자를 이용한 에러 전파를 익힙니다.
오류 대부분은 프로그램을 통째로 멈춰야 할 만큼 심각하지 않습니다. 함수가 실패해도 그 까닭을 쉽게 알고 대응할 수 있는 경우가 많습니다. 예를 들어 파일을 열려고 했는데 파일이 없어서 실패했다면, 프로그램을 끝내기보다 그 파일을 새로 만들고 싶을 수 있습니다. 이 글에서는 실패할 수 있는 연산이 돌려주는 Result를 다루는 방법, unwrap과 expect로 짧게 쓰는 방법, 그리고 오류를 부른 쪽으로 넘기는 전파와 ? 연산자를 살펴봅니다.
Result로 실패 다루기
추리 게임에서 본 것처럼, Result 열거형은 Ok와 Err 두 배리언트로 정의되어 있습니다. 아래는 표준 라이브러리에 이미 있는 정의의 모양을 보인 것이어서 직접 적을 필요가 없고, 프렐류드에 들어 있으므로 use 없이 Result, Ok, Err를 쓸 수 있습니다.
enum Result<T, E> {
Ok(T),
Err(E),
}T와 E는 제네릭 타입 매개변수로, 제네릭 데이터 타입에서 자세히 다룹니다. 지금은 T가 성공했을 때 Ok 안에 담길 값의 타입이고, E가 실패했을 때 Err 안에 담길 오류의 타입이라는 것만 알면 됩니다. Result가 이런 제네릭 매개변수를 가지므로, 성공 값과 오류 값의 타입이 서로 다른 여러 상황에 Result와 그 메서드를 쓸 수 있습니다. 실패할 수 있어서 Result를 돌려주는 함수를 불러 봅니다.
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
}File::open의 반환 타입은 Result<T, E>입니다. File::open의 구현에서 T는 성공 값인 파일 핸들 std::fs::File로, E는 오류 값 std::io::Error로 채워져 있습니다. 이 반환 타입은 File::open이 성공해 읽거나 쓸 수 있는 파일 핸들을 돌려줄 수도 있지만, 파일이 없거나 접근 권한이 없어서 실패할 수도 있다는 뜻입니다. File::open은 성공했는지 실패했는지 알려 주면서 파일 핸들이나 오류 정보도 함께 돌려줘야 하는데, 이것이 바로 Result가 하는 일입니다. 성공하면 greeting_file_result는 파일 핸들을 담은 Ok가 되고, 실패하면 어떤 오류인지에 대한 정보를 담은 Err가 됩니다.
File::open이 돌려준 값에 따라 다르게 동작하도록, 5장에서 본 match로 Result를 다뤄 봅니다.
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {error:?}"),
};
}Option처럼 Result와 그 배리언트도 프렐류드에 있으므로, match 갈래의 Ok와 Err 앞에 Result::를 적지 않아도 됩니다. 결과가 Ok이면 Ok 안의 file을 돌려주어 greeting_file에 넣으므로, match 뒤에 그 파일 핸들로 읽거나 쓸 수 있습니다. 다른 갈래는 File::open이 Err를 돌려준 경우로, 여기서는 panic!을 부르기로 했습니다. 지금 폴더에 hello.txt가 없는 상태로 실행하면 다음과 같이 나옵니다.
thread 'main' (6017887) panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace출력은 무엇이 잘못되었는지 알려 줍니다. 괄호 안의 숫자는 스레드의 ID로 실행할 때마다 다르며, 오류의 message는 운영체제가 정하므로 Windows에서는 The system cannot find the file specified. 같은 다른 문구가 나옵니다.
서로 다른 에러에 대해 매칭하기
앞의 코드는 File::open이 왜 실패했든 panic!을 냅니다. 하지만 실패한 까닭에 따라 다르게 하고 싶을 수 있습니다. 파일이 없어서 실패했다면 파일을 새로 만들어 그 핸들을 돌려주고, 파일을 열 권한이 없는 것처럼 다른 까닭으로 실패했다면 앞의 코드처럼 panic!을 내기로 합니다.
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {e:?}"),
},
_ => {
panic!("Problem opening the file: {error:?}");
}
},
};
}File::open이 돌려주는 Err 안의 값은 표준 라이브러리의 구조체 io::Error입니다. 이 구조체의 kind 메서드를 부르면 io::ErrorKind 값을 얻는데, io::ErrorKind는 입출력 연산에서 일어날 수 있는 여러 종류의 오류를 배리언트로 가진 열거형입니다. 여기서 쓰는 배리언트는 열려는 파일이 아직 없다는 뜻의 ErrorKind::NotFound입니다. 그래서 greeting_file_result에 대한 match 안에 error.kind()에 대한 match가 하나 더 들어 있습니다.
안쪽 match는 error.kind()가 NotFound인지 확인하고, 그렇다면 File::create로 파일을 만듭니다. 그런데 File::create도 실패할 수 있으므로 안쪽 match에 그 경우의 갈래도 두었습니다. 파일을 만들지 못하면 다른 오류 메시지를 출력하며, 바깥 match의 둘째 갈래도 그대로이므로 파일이 없는 경우 말고는 모두 패닉을 냅니다. File::create는 파일이 이미 있으면 그 내용을 지운다는 점에도 주의합니다. open이 NotFound를 돌려준 뒤 create를 부르기 전에 다른 프로그램이 같은 파일을 만들 수도 있으므로, 이런 경우까지 막아야 한다면 OpenOptions의 create_new처럼 없을 때만 만드는 방법을 씁니다.
match가 정말 많습니다. match는 쓸모가 많지만 아주 기본적인 도구이기도 합니다. 클로저: 자신의 환경을 캡처하는 익명 함수에서 배울 클로저를 쓰는 Result의 메서드를 쓰면 같은 일을 match 없이 더 짧게 쓸 수 있습니다. 예를 들어 다음 코드는 위와 같은 로직을 unwrap_or_else와 클로저로 씁니다.
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
if error.kind() == ErrorKind::NotFound {
File::create("hello.txt").unwrap_or_else(|error| {
panic!("Problem creating the file: {error:?}");
})
} else {
panic!("Problem opening the file: {error:?}");
}
});
}이 코드는 위의 코드와 같은 동작을 하지만 match가 하나도 없어 더 깔끔하게 읽힙니다. 12장을 읽은 뒤 이 예제로 돌아와 표준 라이브러리 문서에서 unwrap_or_else를 찾아보세요. 오류를 다룰 때 이런 메서드를 쓰면 깊게 중첩된 match를 많이 줄일 수 있습니다.
에러 발생 시 패닉을 위한 숏컷: unwrap과 expect
match로도 잘 동작하지만 조금 장황하고, 의도가 늘 잘 드러나지도 않습니다. Result<T, E>에는 여러 일을 하는 도우미 메서드가 많이 정의되어 있습니다. unwrap 메서드는 앞에서 쓴 match와 같은 일을 짧게 해 줍니다. Result가 Ok이면 Ok 안의 값을 돌려주고, Err이면 panic!을 불러 줍니다.
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt").unwrap();
}hello.txt가 없는 상태로 실행하면 unwrap이 부른 panic!의 메시지가 나옵니다. 메시지의 앞부분은 unwrap이 정해 둔 문구이고, 뒷부분에는 Err에 담긴 io::Error 값이 Debug 형식으로 붙습니다. Os { ... } 안의 번호와 문구는 운영체제가 알려 준 것이어서, 운영체제에 따라 다르게 나올 수 있습니다.
thread 'main' (6017887) panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }비슷한 expect 메서드는 panic!의 오류 메시지도 정하게 해 줍니다. unwrap 대신 expect를 쓰고 좋은 메시지를 주면 의도를 전할 수 있고, 패닉이 어디서 왔는지 찾기도 쉬워집니다.
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")
.expect("hello.txt should be included in this project");
}expect도 unwrap처럼 파일 핸들을 돌려주거나 panic!을 부릅니다. 다만 unwrap은 기본 메시지를 쓰지만, expect는 우리가 넘긴 메시지에 원래의 오류 정보를 덧붙여 출력합니다.
thread 'main' (6017887) panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }실제 서비스에 쓰는 코드에서는 대부분 unwrap보다 expect를 골라, 그 연산이 언제나 성공하리라 보는 까닭을 메시지에 적습니다. 그러면 그 가정이 틀렸을 때 디버깅에 쓸 정보가 더 많아집니다. 다만 파일이 없는 것처럼 정상적으로 일어날 수 있는 실패라면 둘 다 쓰지 않고, Result를 직접 처리하거나 부른 쪽으로 넘기는 편이 맞습니다. 아래 표는 Result를 이 자리에서 처리할지, 패닉으로 끝낼지, 부른 쪽으로 넘길지 고르는 기준을 정리합니다.
RECOVER HERE · ASSERT INVARIANT · PROPAGATE
Result 처리에서 실패 책임을 정한다
현재 함수가 복구 정책을 알면 match로 처리하고, 더 높은 맥락이 필요하면 ?로 호출자에게 돌려줍니다. unwrap과 expect는 복구 전략이 아니라 Err에서 패닉하는 숏컷입니다.
NotFound에만 새 파일이라는 대안이 있다
File::open이 Ok(file)이면 사용하고, Err가 NotFound일 때만 File::create를 시도합니다. 생성도 실패할 수 있으므로 그 Result까지 처리해야 하며, 권한 부족 같은 다른 오류는 별도 정책으로 보냅니다.
현재 함수에 복구 정보가 없으면 실패를 호환되는 값으로 전파한다
?는 성공 값만 다음 식으로 넘기고 실패는 필요하면 변환해 조기 반환합니다. 오류를 처리하거나 기록하는 연산이 아니며, 호출자가 재시도·대체·보고 같은 정책을 선택합니다.
File::create는 “없을 때만 만들기”를 보장하지 않는다
File::create는 호출 시점에 파일이 이미 있으면 내용을 잘라냅니다. open의 NotFound 확인 뒤 다른 실행 주체가 파일을 만들 수 있으므로, 원자적인 비존재 조건과 덮어쓰기 방지가 필요하면 File::create_new를 사용하거나 OpenOptions에 쓰기와 create_new(true)를 함께 설정합니다.
방식과 Ok·Err 동작 | 맞는 사용 조건 |
|---|---|
match
| 현재 함수가 각 분기의 의미와 복구·변환·반환 대안을 알고 있습니다. |
unwrap
| 짧은 예제나 실패가 버그인 검증된 불변식에만 제한합니다. |
expect
| 복구 가능한 입력 실패가 아니라, 깨지면 버그인 가정을 검사합니다. |
?
| 호출자가 실패 정책을 더 잘 알고 반환 타입이 호환됩니다. |
현재 위치에서 분기
Ok 값은 쓰고, Err 종류마다 복구·변환·반환 정책을 직접 실행합니다.
Err에서 패닉
복구 가능한 운영 실패에는 쓰지 않고, 짧은 예제나 검증된 불변식에만 제한합니다.
깨진 기대 조건과 함께 패닉
왜 Ok여야 하는지를 적되, 사용자 입력 같은 정상 실패를 불변식으로 가장하지 않습니다.
실패 값을 호출자에게 전파
성공 값은 이어 쓰고 실패는 변환 가능한 형태로 조기 반환합니다.
패닉은 현재 스레드에서 시작하고 빌드 설정에 따라 스택을 되감거나 프로세스를 중단할 수 있습니다. 다른 스레드의 패닉이 항상 같은 방식으로 전체 프로세스를 끝낸다고 가정하지 않으며, 패닉 문구와 위치 형식도 러스트 버전·플랫폼·빌드에 따라 달라질 수 있습니다.
지금 함수가 실패에 대처할 방법을 안다면, 예를 들어 NotFound일 때 새 파일을 만들면 된다면 match로 이 자리에서 처리합니다. 이미 확인했기 때문에 실패할 수 없다고 믿을 만한 경우에만 expect에 그 까닭을 적어 쓰며, 이는 복구 방법이 아니라 Err에서 패닉을 내는 지름길입니다. 실패에 어떻게 대처할지 더 높은 쪽에서 정해야 한다면 오류를 부른 쪽으로 넘깁니다.
에러 전파하기
실패할 수 있는 무언가를 부르는 함수를 만들 때, 그 함수 안에서 오류를 처리하는 대신 부른 코드에 오류를 돌려주어 그쪽에서 어떻게 할지 정하게 할 수 있습니다. 이를 오류 전파(propagating)라고 하며, 부른 코드 쪽에 오류를 어떻게 처리할지 알려 주는 정보나 로직이 우리 코드보다 더 많을 수 있으므로 그쪽에 결정을 맡기는 것입니다. 다음 함수는 파일에서 사용자 이름을 읽으며, 파일이 없거나 읽을 수 없으면 그 오류를 부른 코드에 돌려줍니다.
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let username_file_result = File::open("hello.txt");
let mut username_file = match username_file_result {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut username = String::new();
match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}이 함수는 훨씬 짧게 쓸 수 있지만, 오류 처리를 배우기 위해 먼저 하나하나 직접 써 보고 짧은 버전은 뒤에서 봅니다. 먼저 반환 타입 Result<String, io::Error>를 봅니다. 이 함수는 Result<T, E>를 돌려주며, T는 String, E는 io::Error로 채워져 있습니다. 문제없이 성공하면 부른 코드는 파일에서 읽은 사용자 이름을 담은 Ok를 받고, 문제가 생기면 무엇이 잘못되었는지 담은 io::Error를 Err로 받습니다. 반환 타입의 오류를 io::Error로 고른 까닭은, 함수 안에서 부르는 실패할 수 있는 두 연산 File::open과 read_to_string이 모두 io::Error 타입의 오류를 돌려주기 때문입니다.
함수는 File::open을 부르며 시작하고, 앞에서 본 것과 비슷한 match로 Result를 다룹니다. File::open이 성공하면 패턴 변수 file의 파일 핸들이 가변 변수 username_file의 값이 되고 함수가 계속됩니다. Err이면 panic!을 부르는 대신 return으로 함수를 일찍 끝내고, File::open에서 받은 오류를 그대로 이 함수의 오류로 부른 코드에 넘깁니다.
username_file에 파일 핸들을 얻으면, 새 String을 username에 만들고 파일 핸들의 read_to_string으로 파일의 내용을 username에 읽어 들입니다. File::open이 성공해도 read_to_string이 실패할 수 있으므로 이 메서드도 Result를 돌려주며, 그래서 match가 하나 더 필요합니다. read_to_string이 성공하면 함수가 성공한 것이므로 username을 Ok로 감싸 돌려주고, 실패하면 앞의 match와 같은 방식으로 오류를 돌려줍니다. 이것이 함수의 마지막 식이므로 return을 적을 필요는 없습니다.
이 함수를 부른 코드는 사용자 이름을 담은 Ok나 io::Error를 담은 Err를 받아 처리합니다. 받은 값으로 무엇을 할지는 부른 코드가 정합니다. Err를 받았다면 panic!으로 프로그램을 끝낼 수도, 기본 사용자 이름을 쓸 수도, 파일이 아닌 다른 곳에서 사용자 이름을 찾을 수도 있습니다. 우리는 부른 코드가 실제로 무엇을 하려는지 알지 못하므로, 성공과 오류 정보를 모두 위로 넘겨 그쪽이 알맞게 처리하게 합니다. 오류를 전파하는 일은 러스트에서 아주 흔하므로, 이를 쉽게 해 주는 물음표 연산자 ?가 있습니다.
에러를 전파하기 위한 숏컷: ?
다음 코드는 앞의 함수와 같은 일을 하는 read_username_from_file을 ? 연산자로 씁니다. 앞의 함수에서 File::open과 read_to_string의 결과마다 붙어 있던 match가, 아래 코드에서는 두 줄 끝의 ?로 바뀌었습니다.
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("hello.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}Result 값 뒤에 붙인 ?는 앞에서 match로 쓴 것과 거의 같게 동작합니다. Result가 Ok이면 Ok 안의 값이 이 식의 값이 되어 프로그램이 계속되고, Err이면 return을 쓴 것처럼 함수 전체에서 Err를 돌려주어 오류가 부른 코드로 전파됩니다. 아래 그림은 두 ?가 각각 실패했을 때 어디서 함수를 빠져나가는지 보여 줍니다.
Rust 물음표 연산자의 조기 반환과 축약 과정
File::open? 성공만 read_to_string?으로 넘어가고 두 성공 뒤 Ok(username)을 반환하는 실제 조기 반환 경로다. open Err 때 읽기 미실행, read Err 때 마지막 Ok 미실행을 상태와 산출물로 확인할 수 있다.
UNWRAP OK · CONVERT ERR · EARLY RETURN
?는 성공 값만 이어 쓰고 실패는 호출자에게 돌려준다
?는 오류를 숨기거나 처리하는 문법이 아닙니다. Ok의 내부 값으로 현재 함수를 계속하고, Err에서는 호환되는 실패 값으로 변환한 뒤 현재 함수에서 즉시 반환합니다.
보조 설명 · 아래 원래 코드와 실패 조건도 함께 확인합니다.
expression?의 핵심 동작
let value = match expression {
Ok(value) => value,
Err(error) => {
return Err(From::from(error));
}
};
내부 값을 식의 결과로 사용
함수 실행은 계속됩니다. File::open(path)?라면 성공한 파일 핸들이 다음 대입이나 메서드 호출로 이어집니다.
변환 후 현재 함수에서 조기 반환
일반적인 Result에서는 From::from으로 현재 에러 타입에 맞춘 뒤 Err를 반환합니다. 그 지점 아래의 코드는 실행하지 않습니다.
둘러싼 반환 문맥이 실패 잔여값과 호환되어야 한다
Result의 ?는 보통 오류 변환이 가능한 Result 반환 문맥에서, Option의 ?는 Option 반환 문맥에서 사용합니다. 둘을 자동으로 섞지는 않으며 필요하면 ok, ok_or 같은 메서드로 의도를 드러내 변환합니다. 표준 라이브러리의 ControlFlow 등도 지원하지만 사용자 타입에 Try·FromResidual을 직접 구현하는 기능은 아직 불안정합니다.
파일 읽기는 네 단계로 보일러플레이트를 줄인다
-
수동
matchFile::open과read_to_string의Ok·Err를 각각 분기해 실패를 직접 반환합니다. -
각 연산 뒤에
?성공 값만 지역 변수로 받고 오류 변환과 조기 반환을 문법에 맡깁니다.
-
메서드 연결
File::open(path)?가 만든 파일 핸들에 곧바로read_to_string을 호출해 중간 변수를 없앱니다. -
fs::read_to_string(path)표준 라이브러리가 같은 성공 값과
io::Result<String>반환 계약을 더 짧게 제공합니다.
네 구현의 성공 결과와 반환 타입 계약은 같지만, 내부 연산 순서와 실패 지점은 달라질 수 있습니다. 따라서 모든 오류 정보가 완전히 동일하다고 가정하지 말고, 필요한 제어와 진단 수준에 맞는 가장 단순한 구현을 고릅니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | File::open이 Err이면 read_to_string을 실행하지 않고 함수가 Err를 반환한다. open이 Ok여야 읽기 결과를 확인한다. 읽기가 Err이면 마지막 Ok를 실행하지 않고, 두 연산 모두 Ok여야 Ok(username)을 반환한다. 좌우나 위아래의 위치는 실행 시간의 비례 축이 아니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| open 경계 File::open?의 Result를 확인한다. |
|
|
| 읽기 경계 open Ok일 때만 UTF-8 내용을 읽는다. |
|
|
| 최종 반환 두 연산이 성공하면 username을 반환한다. |
|
|
Rust 물음표 연산자의 조기 반환과 축약 과정
File::open? 성공만 read_to_string?으로 넘어가고 두 성공 뒤 Ok(username)을 반환하는 실제 조기 반환 경로다. open Err 때 읽기 미실행, read Err 때 마지막 Ok 미실행을 상태와 산출물로 확인할 수 있다.
UNWRAP OK · CONVERT ERR · EARLY RETURN
?는 성공 값만 이어 쓰고 실패는 호출자에게 돌려준다
?는 오류를 숨기거나 처리하는 문법이 아닙니다. Ok의 내부 값으로 현재 함수를 계속하고, Err에서는 호환되는 실패 값으로 변환한 뒤 현재 함수에서 즉시 반환합니다.
보조 설명 · 아래 원래 코드와 실패 조건도 함께 확인합니다.
expression?의 핵심 동작
let value = match expression {
Ok(value) => value,
Err(error) => {
return Err(From::from(error));
}
};
내부 값을 식의 결과로 사용
함수 실행은 계속됩니다. File::open(path)?라면 성공한 파일 핸들이 다음 대입이나 메서드 호출로 이어집니다.
변환 후 현재 함수에서 조기 반환
일반적인 Result에서는 From::from으로 현재 에러 타입에 맞춘 뒤 Err를 반환합니다. 그 지점 아래의 코드는 실행하지 않습니다.
둘러싼 반환 문맥이 실패 잔여값과 호환되어야 한다
Result의 ?는 보통 오류 변환이 가능한 Result 반환 문맥에서, Option의 ?는 Option 반환 문맥에서 사용합니다. 둘을 자동으로 섞지는 않으며 필요하면 ok, ok_or 같은 메서드로 의도를 드러내 변환합니다. 표준 라이브러리의 ControlFlow 등도 지원하지만 사용자 타입에 Try·FromResidual을 직접 구현하는 기능은 아직 불안정합니다.
파일 읽기는 네 단계로 보일러플레이트를 줄인다
-
수동
matchFile::open과read_to_string의Ok·Err를 각각 분기해 실패를 직접 반환합니다. -
각 연산 뒤에
?성공 값만 지역 변수로 받고 오류 변환과 조기 반환을 문법에 맡깁니다.
-
메서드 연결
File::open(path)?가 만든 파일 핸들에 곧바로read_to_string을 호출해 중간 변수를 없앱니다. -
fs::read_to_string(path)표준 라이브러리가 같은 성공 값과
io::Result<String>반환 계약을 더 짧게 제공합니다.
네 구현의 성공 결과와 반환 타입 계약은 같지만, 내부 연산 순서와 실패 지점은 달라질 수 있습니다. 따라서 모든 오류 정보가 완전히 동일하다고 가정하지 말고, 필요한 제어와 진단 수준에 맞는 가장 단순한 구현을 고릅니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | File::open이 Err이면 read_to_string을 실행하지 않고 함수가 Err를 반환한다. open이 Ok여야 읽기 결과를 확인한다. 읽기가 Err이면 마지막 Ok를 실행하지 않고, 두 연산 모두 Ok여야 Ok(username)을 반환한다. 좌우나 위아래의 위치는 실행 시간의 비례 축이 아니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| open 경계 File::open?의 Result를 확인한다. |
|
|
| 읽기 경계 open Ok일 때만 UTF-8 내용을 읽는다. |
|
|
| 최종 반환 두 연산이 성공하면 username을 반환한다. |
|
|
File::open("hello.txt")?의 Result가 Ok일 때만 다음 줄로 가서 파일의 내용을 읽고, 두 연산이 모두 성공하면 Ok(username)을 돌려줍니다. 첫 ?에서 실패하면 read_to_string까지 가지 않고, 둘째 ?에서 실패하면 마지막 Ok에 이르지 않습니다.
match와 ?에는 다른 점이 하나 있습니다. ?를 붙인 오류 값은 표준 라이브러리의 From 트레이트에 정의된 from 함수를 거치는데, 이 함수는 값을 한 타입에서 다른 타입으로 바꿉니다. ?가 from을 부르면 받은 오류 타입이 지금 함수의 반환 타입에 정의된 오류 타입으로 바뀝니다. 이는 함수가 여러 까닭으로 실패하더라도 모든 오류를 하나의 오류 타입으로 돌려줄 때 쓸모가 있습니다. 예를 들어 read_username_from_file이 직접 정의한 OurError라는 오류 타입을 돌려주게 하고 impl From<io::Error> for OurError로 io::Error에서 OurError를 만드는 방법을 정의하면, 함수 본문의 ?는 코드를 더 고치지 않아도 from을 불러 오류 타입을 바꿔 줍니다.
?를 쓰면 반복되는 코드가 크게 줄고 함수가 단순해집니다. ? 바로 뒤에 메서드 호출을 이어 쓰면 더 줄일 수도 있습니다.
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();
File::open("hello.txt")?.read_to_string(&mut username)?;
Ok(username)
}username에 새 String을 만드는 줄을 함수의 처음으로 옮겼고, username_file 변수를 만드는 대신 File::open("hello.txt")?의 결과에 바로 read_to_string을 이어 불렀습니다. read_to_string 끝에도 여전히 ?가 있으며, 두 연산이 모두 성공하면 username을 담은 Ok를 돌려줍니다. 기능은 앞의 두 함수와 같고 표현만 다릅니다. 파일을 문자열로 읽는 일은 아주 흔하므로, 표준 라이브러리에는 파일을 열어 새 String을 만들고 내용을 읽어 그 String을 돌려주는 fs::read_to_string 함수가 있습니다.
use std::fs;
use std::io;
fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("hello.txt")
}물론 이렇게 쓰면 오류 처리를 설명할 수 없으므로 앞에서 긴 방법을 먼저 보았습니다. 이 함수들은 main이 없으므로, 확인하려면 하나를 고르고 그 함수의 use와 함께 다음 main을 붙입니다.
fn main() {
match read_username_from_file() {
Ok(username) => println!("read: {username:?}"),
Err(error) => println!("failed: {:?}", error.kind()),
}
}프로젝트 폴더에 Jisu와 줄바꿈 하나가 든 hello.txt를 두고 실행하면 read: "Jisu\n"이 출력됩니다. read_to_string은 줄바꿈까지 그대로 읽기 때문입니다. hello.txt가 없으면 failed: NotFound가 출력됩니다.
? 연산자가 사용될 수 있는 곳
?는 ?를 붙인 값과 반환 타입이 맞는 함수 안에서만 쓸 수 있습니다. ?는 앞의 match처럼 함수에서 값을 일찍 돌려주는 일을 하기 때문입니다. 앞의 match는 Result를 다루며 일찍 돌려주는 갈래에서 Err(e)를 돌려주었으므로, 그 함수의 반환 타입이 Result여야 이 return과 맞습니다. 반환 타입이 ()인 main에서 ?를 쓰면 어떻게 되는지 봅니다.
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")?;
}error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:48
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greeting_file = File::open("hello.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`이 코드는 실패할 수 있는 파일 열기에 ?를 붙였지만, main의 반환 타입은 Result가 아니라 ()입니다. 오류는 ?를 Result나 Option, 또는 FromResidual을 구현한 다른 타입을 돌려주는 함수에서만 쓸 수 있다고 알려 줍니다. 고치는 방법은 두 가지입니다. 막는 사정이 없다면 함수의 반환 타입을 ?를 붙인 값과 맞게 바꾸거나, match나 Result의 메서드로 그 자리에서 Result를 처리합니다.
오류 메시지에 나왔듯이 ?는 Option<T>에도 쓸 수 있습니다. Result처럼 Option을 돌려주는 함수에서만 Option에 ?를 쓸 수 있으며, 동작도 비슷합니다. 값이 None이면 그 자리에서 함수가 None을 돌려주고, Some이면 안의 값이 식의 값이 되어 함수가 계속됩니다. 다음 함수는 문자열의 첫 줄의 마지막 글자를 찾습니다.
fn last_char_of_first_line(text: &str) -> Option<char> {
text.lines().next()?.chars().last()
}글자가 있을 수도 없을 수도 있으므로 이 함수는 Option<char>를 돌려줍니다. lines는 문자열의 줄을 하나씩 돌려주는 반복자를 만들고, 첫 줄을 보려고 next를 부릅니다. text가 빈 문자열이면 next가 None을 돌려주므로, ?가 그 자리에서 함수를 끝내고 None을 돌려줍니다. 빈 문자열이 아니면 next는 첫 줄의 문자열 슬라이스를 담은 Some을 돌려주고, ?가 그 슬라이스를 꺼냅니다. 그다음 chars로 글자의 반복자를 얻어 last로 마지막 글자를 꺼냅니다. "\nhi"처럼 첫 줄이 빈 줄일 수도 있으므로 last의 결과도 Option이며, 첫 줄에 글자가 있으면 Some, 없으면 None입니다.
가운데의 ? 덕분에 이 로직을 한 줄로 쓸 수 있으며, ?가 없었다면 메서드를 더 부르거나 match를 써야 했을 것입니다. Result를 돌려주는 함수에서는 Result에, Option을 돌려주는 함수에서는 Option에 ?를 쓸 수 있지만, 둘을 섞을 수는 없습니다. ?는 Result를 Option으로, 또는 그 반대로 저절로 바꾸지 않으므로, 그때는 Result의 ok나 Option의 ok_or 같은 메서드로 직접 바꿉니다.
지금까지의 main은 모두 ()를 돌려주었습니다. main은 실행 프로그램의 시작이자 끝이어서 특별하며, 프로그램이 기대대로 동작하려면 반환 타입에 제약이 있습니다. 다행히 main은 Result<(), E>도 돌려줄 수 있습니다. 다음 코드는 앞의 코드에서 main의 반환 타입을 Result<(), Box<dyn Error>>로 바꾸고 끝에 Ok(())를 더한 것이며, 이제 컴파일됩니다.
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let greeting_file = File::open("hello.txt")?;
Ok(())
}Box<dyn Error>는 트레이트 객체(trait object)로, 트레이트 객체와 다형성에서 다룹니다. 지금은 어떤 종류의 오류든 담을 수 있는 타입이라고 읽으면 됩니다. 그래서 오류 타입이 Box<dyn Error>인 main에서는 어떤 Err 값이든 ?로 일찍 돌려줄 수 있습니다. 이 main의 본문은 std::io::Error만 돌려주지만, 다른 오류를 돌려주는 코드를 더해도 이 시그니처는 그대로 맞습니다.
main이 Result<(), E>를 돌려주면, Ok(())를 돌려줄 때 실행 파일은 0으로, Err를 돌려줄 때 0이 아닌 값으로 끝납니다. C로 만든 실행 파일은 끝날 때 정수를 돌려주어, 성공하면 0을, 오류가 있으면 0이 아닌 정수를 돌려주는데, 러스트도 이 관례에 맞춰 정수를 돌려줍니다. main은 std::process::Termination 트레이트를 구현한 타입이면 무엇이든 돌려줄 수 있으며, 이 트레이트에는 종료 코드 ExitCode를 돌려주는 report 함수가 있습니다. 직접 만든 타입에 이 트레이트를 구현하는 방법은 표준 라이브러리 문서에 있습니다.
더 깊이: main이 돌려준 Err는 어떻게 출력될까
main이 Result<T, E>를 돌려줄 때 쓰이는 Termination 구현은 표준 라이브러리 소스에서 몇 줄밖에 되지 않습니다. Ok(val)이면 val의 report 결과를 쓰고, Err(err)이면 표준 에러에 Error: 와 함께 오류를 {err:?}, 곧 Debug 형식으로 출력한 뒤 ExitCode::FAILURE를 돌려줍니다. 그래서 이 구현을 쓰려면 오류 타입 E가 Debug를 구현해야 하며, 출력은 사용자가 읽기 좋은 문장이 아니라 개발자용 모양일 수 있습니다. 예를 들어 io::Error라면 kind: NotFound 같은 필드가 든 구조체 모양으로 출력됩니다.
사용자에게 보여 줄 메시지와 종료 코드를 직접 정하고 싶다면, main에서 오류를 Display 형식으로 출력하고 ExitCode를 돌려줍니다. ExitCode::FAILURE의 문서는 성공과 실패 두 가지만 돌려준다면 Ok(())와 Err(_)를 돌려주는 것이 같은 코드가 되며, 차이는 Err 쪽이 오류를 출력한다는 것뿐이라고 설명합니다. FAILURE의 실제 숫자는 플랫폼에 따라 정해지므로, 특정 숫자가 필요하면 ExitCode::from(2)처럼 직접 만듭니다. std::process::exit로 끝낼 수도 있지만, 이 함수는 현재 스레드와 다른 스레드의 스택에 남은 값의 소멸자를 실행하지 않습니다.
정리
- 실패할 수 있는 연산은 성공하면
Ok(T), 실패하면Err(E)인Result<T, E>를 돌려주며,match로 두 경우를 처리합니다. io::Error의kind()로 오류의 종류를 가려NotFound처럼 대처할 수 있는 경우만 따로 처리할 수 있습니다.unwrap과expect는Err일 때 패닉을 내는 지름길이며,expect에는 성공을 기대하는 까닭을 적습니다.- 오류를 부른 쪽으로 돌려주는 것을 전파라고 하며,
?는Ok이면 값을 꺼내고Err이면From으로 오류 타입을 바꿔 일찍 돌려줍니다. ?는 반환 타입이 맞는 함수에서만 쓸 수 있고,Option에도 쓸 수 있지만Result와Option을 저절로 바꾸지는 않습니다.main이Result<(), Box<dyn Error>>를 돌려주면main에서도?를 쓸 수 있고,Err이면 0이 아닌 종료 코드로 끝납니다.
연습 문제
1. 어디서 돌아가나
?를 쓴 read_username_from_file에서 다음 세 경우는 각각 어떻게 되나요? (가) File::open이 NotFound를 돌려준다. (나) 열기는 성공하지만 파일의 내용이 올바른 UTF-8이 아니어서 read_to_string이 InvalidData를 돌려준다. (다) 두 연산이 모두 성공하고 파일에는 Jisu만 들어 있다.
해설 보기
(가)는 첫 ?에서 Err를 돌려주므로 read_to_string과 마지막 Ok는 실행되지 않고, 부른 쪽은 NotFound 종류의 오류를 받습니다. (나)는 둘째 ?에서 InvalidData 종류의 Err를 돌려주므로 마지막 Ok에 이르지 않습니다. (다)는 두 ?를 모두 통과해 Ok("Jisu")를 돌려줍니다.
2. 첫 줄의 마지막 글자
last_char_of_first_line에 (가) "\nhi", (나) "é\r\nend", (다) ""를 넘기면 각각 무엇을 돌려주나요? é는 U+00E9 하나라고 합니다.
해설 보기
(가)는 첫 줄이 빈 줄이므로 ?는 통과하지만 last가 None을 돌려줍니다. (나)는 lines가 \r\n을 줄 끝으로 다루므로 첫 줄이 "é"이고, 그 마지막 글자인 Some('é')를 돌려줍니다. (다)는 next가 None이므로 ?에서 바로 None을 돌려줍니다. (가)와 (다)는 결과가 같은 None이지만 함수가 끝나는 곳이 다릅니다.
3. 처리할까 넘길까
설정 파일을 읽는 라이브러리 함수가 있습니다. 파일이 없을 때 기본 설정을 쓸지, 오류를 알리고 끝낼지는 이 라이브러리를 쓰는 프로그램마다 다릅니다. 이 함수 안에서 expect를 써야 하나요, 오류를 전파해야 하나요?
해설 보기
오류를 전파해야 합니다. 파일이 없을 때 어떻게 할지는 라이브러리를 쓰는 쪽이 알고 있으므로, 함수는 Result를 돌려주고 ?로 오류를 넘겨 그쪽이 기본 설정을 쓸지 끝낼지 정하게 합니다. 라이브러리 안에서 expect를 쓰면 파일이 없는 정상적인 상황에서도 프로그램 전체가 패닉으로 끝나 버립니다.
다음 글 panic! 사용 기준에서는 언제 panic!을 쓰고 언제 Result를 돌려주어야 하는지, 그리고 타입으로 값의 유효성을 지키는 방법을 살펴봅니다.