Rust 타입 변환 트레이트: From, Into, TryFrom, FromStr, AsRef
프로그램은 늘 무언가를 변환해요. 입력 폼의 텍스트가 숫자가 되고, 숫자 세 개짜리 튜플이 Color가 되고, 한 에러 타입이 다른 에러 타입이 돼요. Rust에는 이런 일을 위한 작은 표준 트레이트 가족이 있어요. 한번 알아 두면 실제 코드 곳곳에서 보이기 시작할 거예요.
이 트레이트들은 해외여행 갈 때 챙기는 변환 어댑터라고 생각하면 돼요. 어댑터마다 맡은 일이 하나씩 있어요. 언제나 맞는 것(From), 안 맞을 수도 있고 안 맞으면 알려 주는 것(TryFrom), 라벨을 읽고 물건을 만들어 내는 것(FromStr), 그리고 가진 걸 그냥 꽂아 쓰게 해 주는 것(AsRef)이 있어요. 이 페이지에서 하나씩 천천히 살펴볼게요. as는 타입 변환 페이지에서, From은 자주 쓰는 트레이트 페이지에서 이미 잠깐 봤으니 거기서부터 이어 갈게요.
그냥 as를 쓰면 안 될까요?
섹션 제목: “그냥 as를 쓰면 안 될까요?”as는 간편하지만 한계가 두 가지 있어요. 숫자, char, bool 같은 단순한 기본 타입 사이에서만 동작해요. 그리고 절대 실패하지 않아요. 값이 들어가지 않으면 아무 말 없이 다른 숫자를 돌려줘요.
fn main() { let items_in_cart: i32 = 300;
// `as`는 300을 u8에 억지로 밀어 넣고, 조용히 틀린 값을 만들어요 let squeezed = items_in_cart as u8; println!("as 사용: {squeezed}");
// `try_from`은 문제를 알아채고 알려 줘요 let checked = u8::try_from(items_in_cart); println!("try_from 사용: {:?}", checked);
// `from`은 데이터가 절대 손실되지 않는 변환에만 있어요 let widened = i64::from(items_in_cart); println!("from 사용: {widened}");}실행 결과:
as 사용: 44try_from 사용: Err(TryFromIntError(PosOverflow))from 사용: 300저 44 같은 버그는 몇 달씩 숨어 있기도 해요. 변환 트레이트는 이 문제를 세 가지 방법으로 해결해요.
- 정직해요.
From은 잘못될 일이 전혀 없을 때만 구현돼요. 잘못될 수 있을 때는Result를 돌려주는TryFrom을 써요. - 직접 만든 타입에도 쓸 수 있어요.
(255, 0, 0) as Color라고 쓸 수는 없지만,From<(u8, u8, u8)> for Color는 구현할 수 있어요. - 다른 코드가 알아들어요.
?연산자,.parse(), 그리고 많은 라이브러리 함수가 이 트레이트들을 바탕으로 만들어져 있어요.
그러니 이렇게 기억해 두면 좋아요. as는 빠르고 안전한 숫자 변환에만 쓰고, 나머지는 모두 트레이트를 쓰세요.
From과 Into: 언제나 성공하는 변환
섹션 제목: “From과 Into: 언제나 성공하는 변환”From은 “나는 저 타입으로부터 나 자신을 만들 줄 알아요”라는 뜻이에요. 이미 써 본 적이 있을 거예요. 많은 표준 타입이 From을 구현해요.
let greeting = String::from("안녕하세요"); // &str -> Stringlet big = i64::from(42_i32); // i32 -> i64, 언제나 들어가요let precise = f64::from(7_u8); // u8 -> f64, 언제나 정확해요let scores = Vec::from([90, 85, 77]); // 배열 -> Vec<i32>여기에 없는 게 있어요. i32::from(some_i64)는 없어요. i64는 i32에 들어가지 않을 수도 있으니, Rust는 언제나 된다고 우기지 않아요. 이 이야기는 TryFrom 부분에서 다시 할게요.
직접 만든 타입에 From 구현하기
섹션 제목: “직접 만든 타입에 From 구현하기”다음 코드에서는 빨강, 초록, 파랑 값으로 이루어진 튜플로 Color를 만들 수 있어요.
#[derive(Debug)]struct Color { red: u8, green: u8, blue: u8,}
impl From<(u8, u8, u8)> for Color { fn from((red, green, blue): (u8, u8, u8)) -> Self { Color { red, green, blue } }}
fn main() { let orange = Color::from((255, 136, 0)); println!("{:?}", orange);
// `Into`는 직접 작성하지 않았지만 공짜로 생겨요 let sky: Color = (135, 206, 235).into(); println!("하늘색은 r={} g={} b={}", sky.red, sky.green, sky.blue);}실행 결과:
Color { red: 255, green: 136, blue: 0 }하늘색은 r=135 g=206 b=235이게 좋은 점이에요. 표준 라이브러리에는 “B가 From<A>를 구현하면, A는 자동으로 Into<B>를 구현한다”라는 규칙이 있어요. 그래서 impl From 하나로 변환하는 방법이 두 가지 생겨요.
Color::from(tuple)은 “이걸로Color를 만들어”라고 읽혀요.tuple.into()는 “이걸 여기서 필요한 타입으로 바꿔”라고 읽혀요.
왜 let sky: Color라고 썼을까요? .into()만으로는 무엇으로 바꿀지 알 수 없기 때문이에요. 튜플은 이론상 여러 타입으로 변환될 수 있어요. 타입 표기가 어떤 타입을 원하는지 Rust에게 알려 줘요. 함수 매개변수처럼 Rust가 이미 목표 타입을 알 수 있는 곳이라면 표기가 필요 없어요.
함수 매개변수에서도 .into()를 자주 보게 될 거예요. fn new(title: impl Into<String>) 같은 함수는 &str와 String을 모두 받고, 안에서 title.into()를 호출해요. 거기서는 Rust가 목표 타입을 알고 있으니 표기가 필요 없어요. 이 패턴은 연습 문제 2에서 직접 써 볼 거예요.
에러 처리 속의 From: ?가 에러를 변환하는 방법
섹션 제목: “에러 처리 속의 From: ?가 에러를 변환하는 방법”From이 남몰래 많은 일을 해 주는 곳이 있어요. ?는 Err를 만나면 그냥 돌려주지 않아요. 먼저 에러에 From::from을 호출해서, 함수가 반환하는 에러 타입으로 바꿔요.
그래서 함수가 직접 만든 AppError를 반환하고 AppError가 From<ParseIntError>를 구현한다면, ?가 파싱 에러를 알아서 AppError로 바꿔 줄 수 있어요.
use std::num::ParseIntError;
#[derive(Debug)]enum AppError { MissingEquals, BadNumber(ParseIntError),}
impl From<ParseIntError> for AppError { fn from(e: ParseIntError) -> Self { AppError::BadNumber(e) }}
// "max_users = 50" 같은 설정 줄을 읽어요fn read_setting(line: &str) -> Result<(String, u32), AppError> { let (key, value) = line.split_once('=').ok_or(AppError::MissingEquals)?; let number = value.trim().parse::<u32>()?; // ParseIntError -> AppError Ok((key.trim().to_string(), number))}
fn main() { println!("{:?}", read_setting("max_users = 50")); println!("{:?}", read_setting("max_users = 많이")); println!("{:?}", read_setting("max_users 50"));}실행 결과:
Ok(("max_users", 50))Err(BadNumber(ParseIntError { kind: InvalidDigit }))Err(MissingEquals)impl From이 없으면 두 번째 ?는 컴파일되지 않아요. Rust가 ParseIntError를 AppError로 바꾸는 방법을 모르기 때문이에요. impl From이 있으면 함수 본문이 깔끔하게 유지돼요. 에러 타입을 설계하는 방법은 사용자 정의 에러 페이지에서 더 깊이 다루고, 이런 From 구현을 대신 써 주는 크레이트는 anyhow와 thiserror 페이지에서 볼 수 있어요.
TryFrom과 TryInto: 실패할 수도 있는 변환
섹션 제목: “TryFrom과 TryInto: 실패할 수도 있는 변환”어떤 변환은 가끔만 성공해요. 300은 u8에 들어가지 않아요. -5는 올바른 퍼센트 값이 아니에요. 이럴 때를 위해 Rust에는 TryFrom이 있어요. From과 비슷하지만 Result를 돌려주기 때문에, “안 돼요”인 경우를 꼭 처리해야 해요. 이건 좋은 일이에요. 실패가 몰래 지나갈 수 없으니까요.
기본 제공 버전은 이 페이지 맨 위에서 이미 봤어요. u8::try_from(300_i32)는 틀린 숫자 대신 Err를 돌려줘요. 그리고 From에 Into가 따라오듯이 TryFrom에는 TryInto가 따라와요. 그래서 let small: Result<u8, _> = big.try_into();도 동작해요. TryInto는 2021 에디션부터 프렐루드(prelude)에 들어 있어서 따로 가져올 필요가 없어요.
직접 만든 타입에 TryFrom 구현하기
섹션 제목: “직접 만든 타입에 TryFrom 구현하기”0부터 100까지만 담을 수 있는 Percentage 타입을 만들어 볼게요. 모든 Percentage가 try_from을 거쳐서 만들어진다면, 프로그램 안의 모든 Percentage는 올바른 값이에요. 입구에서 한 번만 확인하면 다시는 확인할 필요가 없어요.
#[derive(Debug)]struct Percentage(u8);
#[derive(Debug)]struct OutOfRange(i32);
impl TryFrom<i32> for Percentage { type Error = OutOfRange;
fn try_from(value: i32) -> Result<Self, Self::Error> { if (0..=100).contains(&value) { Ok(Percentage(value as u8)) // 안전해요: 방금 범위를 확인했어요 } else { Err(OutOfRange(value)) } }}
fn main() { println!("{:?}", Percentage::try_from(85)); println!("{:?}", Percentage::try_from(150));
let battery: Result<Percentage, _> = (-5).try_into(); println!("{:?}", battery);}실행 결과:
Ok(Percentage(85))Err(OutOfRange(150))Err(OutOfRange(-5))type Error = OutOfRange; 줄은 이 변환이 어떤 종류의 에러를 돌려주는지 Rust에게 알려 줘요. 에러 타입은 직접 정하면 돼요. 여기서는 잘못된 값을 담아 두니까, 에러를 받은 쪽에서 도움이 되는 메시지를 보여 줄 수 있어요.
FromStr과 .parse(): 텍스트로 값 만들기
섹션 제목: “FromStr과 .parse(): 텍스트로 값 만들기”가장 자주 변환하게 되는 건 텍스트예요. 파일, 명령줄 인자, 웹 폼은 모두 문자열을 줘요. FromStr 트레이트는 “&str로부터 이 타입을 만드는 방법”을 나타내고, .parse()는 그걸 편하게 호출하는 방법이에요.
"42".parse::<i32>()라고 쓰면 Rust는 i32의 FromStr 구현을 찾아서 실행해요. ::<i32> 부분(흔히 “터보피시(turbofish)“라고 불러요)은 원하는 타입을 Rust에게 알려 줘요. 변수에 타입 표기를 달아도 돼요.
let age = "42".parse::<i32>(); // Ok(42)let price: Result<f64, _> = "4.99".parse(); // Ok(4.99)let broken = "사십이".parse::<i32>(); // Err(ParseIntError { kind: InvalidDigit })텍스트는 언제든 잘못될 수 있으니까 호출할 때마다 Result가 돌아와요.
직접 만든 타입에 FromStr 구현하기
섹션 제목: “직접 만든 타입에 FromStr 구현하기”손님이 "30x40"처럼 크기를 입력하는 액자 가게를 상상해 보세요. Rust가 이걸 읽을 수 있게 가르쳐 볼게요.
use std::str::FromStr;
#[derive(Debug)]struct Size { width: u32, height: u32,}
#[derive(Debug)]enum SizeError { MissingX, BadNumber,}
impl FromStr for Size { type Err = SizeError;
fn from_str(text: &str) -> Result<Self, Self::Err> { let (w, h) = text.split_once('x').ok_or(SizeError::MissingX)?; let width = w.trim().parse().map_err(|_| SizeError::BadNumber)?; let height = h.trim().parse().map_err(|_| SizeError::BadNumber)?; Ok(Size { width, height }) }}
fn main() { println!("{:?}", "30x40".parse::<Size>());
println!("{:?}", "가로 30 세로 40".parse::<Size>()); println!("{:?}", "30x크게".parse::<Size>());}실행 결과:
Ok(Size { width: 30, height: 40 })Err(MissingX)Err(BadNumber)작은 부분 두 가지를 짚고 갈게요. 첫째, FromStr은 에러 타입 이름이 TryFrom처럼 Error가 아니라 Err예요. 오래된 이름 규칙의 흔적이라서, 처음에 헷갈려도 괜찮아요. 컴파일러가 알려 줄 거예요. 둘째, 트레이트를 구현하려면 use std::str::FromStr;이 필요하지만, .parse()를 호출할 때는 필요 없어요.
.map_err(|_| SizeError::BadNumber)는 숫자 파싱 에러를 우리가 만든 에러로 바꿔 줘요. 한 곳에서만 필요할 때 From을 구현하는 대신 쓸 수 있는 편리한 방법이에요.
AsRef: 빌려 볼 수 있는 건 뭐든 받기
섹션 제목: “AsRef: 빌려 볼 수 있는 건 뭐든 받기”지금까지 본 트레이트들은 새 값을 만들어요. AsRef는 달라요. 아무것도 만들지 않고, “나를 그 타입으로 싸게 빌려 볼 수 있게 해 줄게요”라고 말할 뿐이에요.
이게 왜 필요할까요? 단어 수를 세는 함수를 만든다고 해 볼게요. &str를 받으면, String을 가진 쪽은 &name이라고 써야 해요. String을 받으면, &str를 가진 쪽은 새 String을 할당해야 해요. impl AsRef<str>를 받으면 어느 쪽이든 그냥 동작해요.
fn word_count(text: impl AsRef<str>) -> usize { text.as_ref().split_whitespace().count()}
fn main() { let note = String::from("우유랑 달걀 사 오기");
println!("{}", word_count("안녕 러스트 세상")); // &str println!("{}", word_count(¬e)); // &String println!("{}", word_count(note)); // String}실행 결과:
344함수 안에서 text.as_ref()를 호출하면, 호출한 쪽이 무엇을 넘겼든 평범한 &str를 얻을 수 있어요.
표준 라이브러리의 AsRef: 경로
섹션 제목: “표준 라이브러리의 AsRef: 경로”사실 이미 모르는 사이에 AsRef를 써 봤을 거예요. std::fs의 파일 함수들은 이렇게 생겼어요.
pub fn read_to_string<P: AsRef<Path>>(path: P) -> io::Result<String>그래서 fs::read_to_string("notes.txt"), fs::read_to_string(my_string), fs::read_to_string(my_path_buf)가 모두 동작해요. 같은 방법을 여러분의 코드에서도 쓸 수 있어요. 이 예제는 경로 이름만 살펴보고 실제 파일은 건드리지 않아요.
use std::path::{Path, PathBuf};
fn extension_of(path: impl AsRef<Path>) -> String { match path.as_ref().extension() { Some(ext) => ext.to_string_lossy().to_string(), None => String::from("(없음)"), }}
fn main() { println!("{}", extension_of("notes.txt")); println!("{}", extension_of(String::from("holiday.jpeg"))); println!("{}", extension_of(PathBuf::from("backups/2024/site.tar.gz"))); println!("{}", extension_of("README"));}실행 결과:
txtjpeggz(없음)AsMut도 같은 아이디어인데, 가변 참조로 빌려줘요. 예를 들어 Vec과 배열은 둘 다 자신을 가변 슬라이스로 빌려줄 수 있어요.
fn reset_all(scores: &mut impl AsMut<[u32]>) { for score in scores.as_mut() { *score = 0; }}
// 둘 다 동작해요// reset_all(&mut vec![12, 7, 30]);// reset_all(&mut [3, 1, 4]);AsMut은 AsRef보다 훨씬 덜 쓰게 될 거예요. 보면 알아볼 수 있는 정도면 충분해요.
어떤 걸 써야 할까요?
섹션 제목: “어떤 걸 써야 할까요?”잘 모르겠을 때는 이 표부터 보세요.
| 하고 싶은 일 | 쓸 것 | 예 |
|---|---|---|
| 값이 잘려도 괜찮으니 숫자 타입끼리 빠르게 변환 | as |
x as f64 |
| 절대 실패하지 않는 변환 | From |
i64::from(n), String::from("hi") |
| 위와 같지만 목표 타입을 이미 알 때 | Into |
let s: String = "hi".into(); |
| 실패할 수도 있는 변환 | TryFrom / TryInto |
u8::try_from(300) |
| 텍스트로 값 만들기 | FromStr / .parse() |
"42".parse::<i32>() |
| 문자열이나 경로 비슷한 여러 입력을 싸게 받기 | AsRef |
fn f(p: impl AsRef<Path>) |
| 값을 사람이 읽을 텍스트로 바꾸기 | Display → .to_string() |
42.to_string() |
마지막 줄은 공짜로 따라와요. Display를 구현하면(자주 쓰는 트레이트 참고) 그 타입의 모든 값에 .to_string() 메서드가 생겨요.
자주 하는 실수
섹션 제목: “자주 하는 실수”Rust가 목표 타입을 알 수 없는데 .into() 호출하기.
fn main() { let name = "Ferris".into(); println!("{name}");}error[E0283]: type annotations needed --> src/main.rs:2:9 |2 | let name = "Ferris".into(); | ^^^^ ---- type must be known at this point | = note: the type must implement `From<&str>` = note: required for `&str` to implement `Into<_>`help: consider giving `name` an explicit type |2 | let name: /* Type */ = "Ferris".into(); | ++++++++++++“타입 표기가 필요해요”라는 에러예요. &str는 String, Box<str>, Vec<u8> 등 여러 타입으로 바뀔 수 있어요. Rust는 짐작하지 않아요. let name: String = "Ferris".into();처럼 타입을 적거나, 그냥 String::from("Ferris")라고 쓰세요.
From 대신 Into 구현하기. 같은 거라고 생각하기 쉬워서 빠지기 쉬운 함정이에요.
use std::num::ParseIntError;
#[derive(Debug)]struct AppError(String);
impl Into<AppError> for ParseIntError { fn into(self) -> AppError { AppError(self.to_string()) }}
fn read_port(text: &str) -> Result<u16, AppError> { let port = text.parse::<u16>()?; Ok(port)}error[E0277]: `?` couldn't convert the error to `AppError` --> src/main.rs:13:35 |12 | fn read_port(text: &str) -> Result<u16, AppError> { | --------------------- expected `AppError` because of this13 | let port = text.parse::<u16>()?; | --------------^ the trait `From<ParseIntError>` is not implemented for `AppError` | = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait“?가 에러를 AppError로 변환하지 못했어요. AppError에 From<ParseIntError>가 구현되어 있지 않아요”라는 뜻이에요. 공짜 선물은 한 방향으로만 와요. From을 구현하면 Into가 생기지만, Into를 구현한다고 From이 생기지는 않아요. 그리고 ?를 비롯한 많은 라이브러리 코드는 From을 요구해요. 그러니 항상 From을 구현하고, Into는 알아서 따라오게 두세요. (Rust의 린터인 Clippy도 이 경우에 경고를 줘요.)
연습 문제
섹션 제목: “연습 문제”1. 시각 읽기
섹션 제목: “1. 시각 읽기”hour: u8와 minute: u8를 가진 Time 구조체를 만드세요. "09:45".parse::<Time>()이 동작하도록 FromStr을 구현하세요. :가 없거나, 어느 한쪽이 숫자가 아니거나, 시가 23보다 크거나 분이 59보다 크면 에러를 돌려주세요. "09:45", "25:00", "점심"으로 시험해 보세요. 힌트: split_once(':')를 쓰면 두 부분으로 나눌 수 있어요. 에러 타입은 간단하게 String이면 충분해요.
정답 보기
use std::str::FromStr;
#[derive(Debug)]struct Time { hour: u8, minute: u8,}
impl FromStr for Time { type Err = String;
fn from_str(text: &str) -> Result<Self, Self::Err> { let (h, m) = text.split_once(':').ok_or("':'가 없어요")?; let hour: u8 = h.parse().map_err(|_| "시가 숫자가 아니에요")?; let minute: u8 = m.parse().map_err(|_| "분이 숫자가 아니에요")?;
if hour > 23 || minute > 59 { return Err(format!("{text}은(는) 올바른 시각이 아니에요")); } Ok(Time { hour, minute }) }}
fn main() { for input in ["09:45", "25:00", "점심"] { match input.parse::<Time>() { Ok(t) => println!("{input} -> {}시 {}분", t.hour, t.minute), Err(e) => println!("{input} -> 에러: {e}"), } }}실행 결과:
09:45 -> 9시 45분25:00 -> 에러: 25:00은(는) 올바른 시각이 아니에요점심 -> 에러: ':'가 없어요.ok_or("':'가 없어요") 뒤의 ?가 동작하는 건 String이 From<&str>를 구현하기 때문이에요. 에러 처리 부분에서 본 From 기법과 같은데, 이번에는 표준 타입이 해 주는 거예요.
2. 주방 타이머
섹션 제목: “2. 주방 타이머”minutes: u32와 seconds: u32를 가진 Timer 구조체를 만드세요. 전체 초를 Timer로 바꾸도록 From<u32>를 구현하세요(150은 2분 30초가 돼요). 그다음 시간을 출력하는 fn start(timer: impl Into<Timer>)를 작성하고, 150과 Timer { minutes: 1, seconds: 5 }로 호출해 보세요. 힌트: 분은 total / 60이고 나머지는 total % 60이에요. 모든 타입은 이미 자기 자신으로 변환되니까 Timer를 넘겨도 동작해요.
정답 보기
struct Timer { minutes: u32, seconds: u32,}
impl From<u32> for Timer { fn from(total: u32) -> Self { Timer { minutes: total / 60, seconds: total % 60 } }}
fn start(timer: impl Into<Timer>) { let t = timer.into(); println!("타이머 설정: {}:{:02}", t.minutes, t.seconds);}
fn main() { start(150_u32); start(Timer { minutes: 1, seconds: 5 });}실행 결과:
타이머 설정: 2:30타이머 설정: 1:05From<u32>만 작성했는데도 start는 숫자와 Timer를 모두 받아요. 숫자는 우리가 만든 From 덕분에 동작하고, Timer는 표준 라이브러리에 “모든 타입은 자기 자신으로 변환된다”라는 규칙이 있어서 동작해요.
as는 빠른 숫자 변환용이고 데이터를 조용히 잃을 수 있어요. 변환 트레이트는 명시적이고 안전하며, 직접 만든 타입에도 쓸 수 있어요.- 언제나 성공하는 변환에는
From을 구현하세요. 그러면Into가 공짜로 생겨요..into()는 목표 타입을 알아야 해요. ?연산자는 에러 타입을 바꿀 때From을 호출해요. 그래서impl From<OtherError> for MyError를 구현해 두면 에러 처리가 짧아져요.- 실패할 수도 있는 변환에는
TryFrom/TryInto를 쓰세요. 둘은Result를 돌려줘요. - 텍스트에
.parse::<YourType>()을 쓰고 싶다면FromStr을 구현하세요. impl AsRef<str>나impl AsRef<Path>를 쓰면 복사 없이 여러 입력 타입을 싸게 받을 수 있어요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. From<Celsius> for Fahrenheit를 구현했어요. 자동으로 함께 생기는 것은 무엇일까요?
2. 300일 수도 있는 i32를 u8로 안전하게 바꾸는 호출은 무엇일까요?
3. "30x40".parse::<Size>()가 동작하게 하려면 무엇을 구현해야 할까요?
4. let name = "Ferris".into();가 컴파일되지 않는 이유는 무엇일까요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”std::convert— 표준 라이브러리 문서 (영어):From,Into,TryFrom,TryInto,AsRef,AsMut이 모여 있는 곳이에요. 각각을 언제 구현하면 좋은지 짧은 안내도 있어요.FromStr— 표준 라이브러리 문서 (영어):.parse()뒤에 있는 트레이트로, 직접 구현하는 전체 예제가 있어요.AsRef— 표준 라이브러리 문서 (영어):AsRef가Borrow와 어떻게 다른지, 왜 싼 참조 변환에 쓰이는지 설명해요.- Conversion — Rust by Example (영어):
From/Into,TryFrom/TryInto, 문자열과의 변환을 다루는 짧은 예제를 실행해 볼 수 있어요. - Conversions — Rust API Guidelines (영어): 직접 만든 타입이 어떤 변환 트레이트를 구현해야 하는지에 대한 커뮤니티의 조언이에요.