콘텐츠로 이동

Rust 타입 변환 트레이트: From, Into, TryFrom, FromStr, AsRef

프로그램은 늘 무언가를 변환해요. 입력 폼의 텍스트가 숫자가 되고, 숫자 세 개짜리 튜플이 Color가 되고, 한 에러 타입이 다른 에러 타입이 돼요. Rust에는 이런 일을 위한 작은 표준 트레이트 가족이 있어요. 한번 알아 두면 실제 코드 곳곳에서 보이기 시작할 거예요.

이 트레이트들은 해외여행 갈 때 챙기는 변환 어댑터라고 생각하면 돼요. 어댑터마다 맡은 일이 하나씩 있어요. 언제나 맞는 것(From), 안 맞을 수도 있고 안 맞으면 알려 주는 것(TryFrom), 라벨을 읽고 물건을 만들어 내는 것(FromStr), 그리고 가진 걸 그냥 꽂아 쓰게 해 주는 것(AsRef)이 있어요. 이 페이지에서 하나씩 천천히 살펴볼게요. as는 타입 변환 페이지에서, From은 자주 쓰는 트레이트 페이지에서 이미 잠깐 봤으니 거기서부터 이어 갈게요.

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 사용: 44
try_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 -> String
let 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 부분에서 다시 할게요.

다음 코드에서는 빨강, 초록, 파랑 값으로 이루어진 튜플로 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(&note)); // &String
println!("{}", word_count(note)); // String
}

실행 결과:

3
4
4
▶ 직접 실행해 보기

함수 안에서 text.as_ref()를 호출하면, 호출한 쪽이 무엇을 넘겼든 평범한 &str를 얻을 수 있어요.

사실 이미 모르는 사이에 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"));
}

실행 결과:

txt
jpeg
gz
(없음)
▶ 직접 실행해 보기

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 this
13 | 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도 이 경우에 경고를 줘요.)

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 기법과 같은데, 이번에는 표준 타입이 해 주는 거예요.

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:05
▶ 직접 실행해 보기

From<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