Rust에 #[derive(From)] 추가하기

ko생성일: 2025. 9. 5.갱신일: 2025. 9. 8.

Rust의 새타입에 대해 #[derive(From)]을 제안하고 채택·구현하는 과정, 설계 결정과 제약, 이름 해석 이슈와 해결, 사용 방법과 향후 확장 가능성까지 다룹니다. 나이트리에서 이미 시도해볼 수 있습니다.

요약: 이제 나이트리(nightly)에서 #[derive(From)]를 사용할 수 있습니다. 사용 방법은 여기를 참고하세요. 더 많은 업데이트는 이 트래킹 이슈를 따라가면 됩니다.

나는 Rust에서 새타입(newtype) 패턴을 자주 쓰고, 그 주변의 몇몇 사용 사례를 (더) 간단히 표현하고 싶습니다. 새타입으로 흔히 하는 일은 내부 필드에 위임하는 표준 트레이트들을 한꺼번에 구현하는 것입니다. 이를 가장 쉽게 달성하는 방법은 당연히 #[derive(...)]를 사용하는 것이죠:

#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
struct WorkerId(u32);

하지만 이 접근은 현재 표준 라이브러리의 모든 “내장” 트레이트에 사용할 수는 없습니다. 그중 하나가 두 타입의 값 사이를 변환하는 데 쓰이는 From 트레이트입니다. 새타입을 사용할 때 항상 From을 구현하는 것이 좋은 생각은 아닐 때도 있지만(이에 대해서는 뒤에서 더 이야기하겠습니다), 다른 경우에는 꽤 유용합니다. 특히 제네릭 컨텍스트에서 내부 필드의 값에서 새타입의 값으로 쉽게 갈 수 있게 해주기 때문입니다. 이 트레이트를 수동으로 구현하면 보통 다음과 비슷합니다:

impl From<u32> for WorkerId {
    fn from(value: u32) -> Self {
        Self(value)
    }
}

이 코드는 물론 구현이 사소하고 버그가 들어갈 여지도 거의 없지만, 동시에 순전한 보일러플레이트이기도 합니다. 크레이트에 새타입이 많다면 특히 낭비처럼 느껴지죠. 컴파일러가 이런 구현을 손쉽게 생성해줄 수 있음이 명백하니까요.

사실, 이 impl을 계속 반복해서 쓰다 보면 짜증이 나서 결국 새타입을 생성하는 매크로를 만들게 됩니다. 이렇게 하면 이(와 유사한) 코드를 여기저기 복붙하지 않아도 되니까요. 하지만 이런 매크로를 쓰고 만드는 데에도 트레이드오프가 있고(또는 derive_more 같은 서드파티 크레이트를 쓰는 것에도 마찬가지), 그래서 차라리 러스트 컴파일러가 #[derive(From)]을 제공해줬으면 했습니다.

RFC 쓰기

이 기능이 또다시 없어서 막혔을 때, 문득… 내가 Rust에 그냥 추가해버릴 수도 있겠다는 생각이 들었습니다 Image 1: :laughing: 언어 변경은 벅찰 수 있지만, 이 변경은 기능의 동작이 너무나 자명 해서 꽤 단순해 보였습니다. 그래서 시도해보기로 하고 RFC를 쓰기로 했습니다. 분위기를 보려고 먼저 “Pre-RFC”IRLO 포럼에 올렸고, 언어 팀 구성원 한 분의 반응까지 포함해 전반적으로 긍정적이었습니다. 덕분에 실제 RFC로 발전시킬 자신감을 얻었고, 몇 주 에 그렇게 했습니다.

RFC에 대한 반응이 예상보다 긍정적이라 기분 좋은 놀라움이었습니다. 보통은 수백 개 댓글과 논쟁을 부르는 거대한 논쟁성 RFC들을 읽는데 익숙하지만, 이번에는 대부분의 반응이 “그래, 좋은 생각 같아”였죠(몇 가지 예외는 나중에 이야기하겠습니다). 게다가 이 기능을 미래의 RFC에서 더 개선할 수 있는 제안도 몇 가지 받았습니다.

PR 본문도 이모지를 많이 받았습니다! 이 글을 쓰는 시점에 100개가 넘는 반응을 받았고, 저장소에서 가장 “좋아요”를 많이 받은 RFC 상위 약 60개 안에 들었습니다. 물론 이모지 수가 RFC를 받아들일지 말지를 결정하는 좋은 기준은 아니지만, 많은 사람들이 From impl을 쉽게 파생하지 못하는 데 느끼는 불편을 공유한다는 신호이기도 합니다.

이 RFC의 강점 중 하나는, 엄밀히 말하면 새로운 기능 이라고 부르기도 애매하다는 점입니다. Rust의 빈틈을 “그냥” 메우는 종류이죠. 기존에 따로따로 동작하던 두 기능(From 트레이트와 표준 라이브러리 트레이트 파생)을 결합할 수 있게 해줄 뿐입니다.

다만 RFC에서 조금 불명확했던 점은, 실제로 이 안건의 운명을 결정할 주체가 Rust의 어떤 팀(들)이어야 하는가였습니다. 첫 FCP(Final Comment Period)는 언어 팀에 대해 시작되었습니다. 이어서 사실 라이브러리 API 팀만이 처리해야 한다는 제안이 있었고, 결국 언어 + 라이브러리 API의 듀얼 FCP로 진행했습니다. 누가 무엇을 결정(또는 수행)해야 하는지 자주 불명확해져 진척이 막히는 일은 Rust 프로젝트(그리고 아마 많은 조직 구조에서)에서 반복되는 문제입니다.

다행히 이번에는 그렇지 않았고, 약간의 작은 굴곡을 지나 제 생애 첫 Rust RFC가 승인되었다고 기쁘게 알릴 수 있게 됐습니다 Image 2: :tada:.

기능 설계

이제 RFC의 결말을 스포일러 했으니, 실제 기능 설계에 대해 이야기해야겠죠. 단순해 보이지만 고려할 점이 몇 가지 있습니다.

먼저, #[derive(From)]의 목표를 명확히 합시다. ADT(대수적 데이터 타입), 즉 struct 또는 enum1에 적용되면 해당 타입에 대한 From 트레이트 구현을 생성해야 합니다. struct부터 시작해 봅시다. struct에 #[derive(From)]를 적용하면, 그 struct의 필드 값에서 struct를 만드는 From impl이 생성되어야 합니다. 예를 들면:

#[derive(From)]
struct Foo(u32);

// 생성됨:
impl From<u32> for Foo {
    fn from(value: u32) -> Self {
        Self(value)
    }
}

위 예시를 보면 명백한 문제가 떠오릅니다. struct에 필드가 정확히 하나가 아닌 경우에는 어떻게 할까요?

struct Foo(u32, bool);

떠오르는 해법은 세 가지입니다:

  1. 모든 값을 한꺼번에 받는 impl 생성, 즉 From<(u32, bool)>
  2. 어떤 필드를 From impl에 쓸지 사용자가 지정하도록 강제
  3. 이 경우 #[derive(From)] 사용 금지

1)번은 꽤 비직관적이라고 생각합니다. 가장 흔한 단일 필드 케이스에서 문제가 생기는데, From<FieldType>을 생성해서 다른 필드 개수와의 일관성을 깨야 할까요, 아니면 From<(FieldType,)>(즉, 크기 1의 튜플에서 생성)을 만들어야 할까요? 후자는 불편하고 흔치 않습니다. 이 접근은 명명된 필드 struct에는 잘 맞지도 않습니다.

2)번은 미래의 가능성으로 남겨두었습니다. 제안 중 하나는 단일 필드에 어트리뷰트(예: #[from])를 달아 해당 필드로부터 만들고, 나머지 필드는 Default impl로 채우자는 것이었습니다:

#[derive(From)]
struct Foo(#[from] u32, bool);

impl From<u32> for Foo {
    fn from(value: u32) -> Self {
        Self(value, Default::default())
    }
}

이 옵션이 매우 마음에 들고, 미래에 꼭 탐색해봐야 한다고 생각합니다. 비슷한 접근은 Default를 파생할 때 #[default] 어트리뷰트로 기본 enum 변형을 선택하는 데에도 쓰이고 있어, 전례가 있습니다. 다만 이번 RFC는 수용 가능성을 높이기 위해 최소한으로 유지하고 “디자인 구덩이”에 빠지지 않도록 하고자 했기에, RFC에는 포함하지 않고 “미래의 개선”으로 남겨두었습니다.

그래서 3)번을 선택했습니다. 즉, 현재로서는 필드가 정확히 하나인 struct에만 #[derive(From)]을 사용할 수 있습니다. 하지만 이는 큰 제약이 아니라고 생각합니다. 대부분 이 파생은 새타입(대개 정확히 하나의 필드를 갖는)에 사용될 것이기 때문입니다. 일반적으로 From 트레이트는 한 타입의 단일 값을 다른 타입의 단일 값으로 변환하는 데 가장 자주 쓰이므로, 초기 설계에서 이런 제약을 두는 것이 타당합니다.

enum의 경우는 더 복잡합니다. 이론적으로는 정확히 하나의 변형(variant)에 정확히 하나의 필드가 있는 enum에 허용할 수도 있지만, 그리 유용해 보이지는 않습니다. 단순함을 위해 지금은 enum에 #[derive(From)]을 사용하는 것도 금지하기로 했습니다.

이 주요 설계 논점 외에도, RFC에서 몇 가지 우려가 제기되었습니다.

From impl의 “방향”이 맞는가?

지적된 우려 중 하나는, #[derive(From)] struct Foo(u32);From<u32> for Foo, From<Foo> for u32, 혹은 둘 다를 생성해야 하느냐는 것이었습니다. 제게 답은 명확합니다(즉 From<u32> for Foo여야 합니다). 다른 모든 derive 매크로가 그렇게 동작하기 때문입니다. 파생 매크로는 언제나 그 매크로가 적용된 타입에 대한 impl을 생성합니다. 이 동작이 가장 직관적이며, 기존 derive 가능 매크로와의 일관성을 유지해야 합니다. 이 방향이 새타입 사용 사례를 만족시키며, 저는 이 기능의 가장 중요한 동인(use-case)이라고 봅니다.

물론 두 번째 방향도 어떻게 지원할 수 있을지 생각해보는 것은 흥미롭습니다. struct의 값에서 다시 내부 필드로 가는 것도 유용할 때가 있으니까요. 제 RFC에는 이 기능을 생략했지만, 제안 중 하나는 #[derive(Into)] 같은 것을 추가해 From<StructType> for FieldType을 생성하자는 것이었습니다. 물론 실제로 Into 트레이트의 impl을 생성하는 것은 아닙니다. Into는 표준 라이브러리의 블랭킷 impl로 자동 제공되거든요. 하지만 이건 다른 RFC에서 논의할 주제입니다.

다른 해법의 가능성을 닫는가?

새 기능을 추가하는 (Rust) RFC에서 늘 있는 걱정입니다. 설계를 올바르게 했는가, 미래에 확장 가능할까, 같은 문제의 더 나은 해법에 대한 문을 닫는 것은 아닐까?

RFC를 최소화했기 때문에 이는 타당한 우려입니다. 반대 방향의 From impl 지원부터 먼저 고민했어야 할까요? 아니면 #[from] 확장을 더 구체화했어야 할까요? 몇몇 리뷰어는 이 기능이 안정화되기 전에(혹은 RFC가 수락되기 전이라도) “큰 그림”을 보고 싶어 했습니다.

저도 너무 이른 기능 수용을 경계하는 편이라 공감합니다. 아직 모든 디테일을 모르는 상태에서요. 하지만 이 경우 제안한 기능은 관련 확장과 완벽히 호환될 것이라고 확신하며, 따라서 더 복잡한 확장 논의를 기다리지 말고 가능한 한 빨리 착륙시켜 점진적 진전을 이루는 편이 낫다고 봅니다.

저의 확신은 #[derive(From)]의 동작이 (적어도 제게는) 그냥 자명 하다는 사실에서 옵니다. #[derive(From)] struct Foo(u32)에 대해 러스트 컴파일러가 다음과 다른 무언가를 생성한다면:

impl From<u32> for Foo {
    fn from(value: u32) -> Self {
        Self(value)
    }
}

그 기능은 잘못되었다고 생각합니다. 다른 확장이나 설정 지점이 있을 수는 있지만, 이 핵심 기능은 바위처럼 굳어 있으며 다르게 동작할 여지가 없습니다. 그래서 최소한의 RFC를 빨리 통과시키고 야생에서 기능을 시험해보며, 무엇보다 Rust 사용자에게 가치를 즉시 제공하길 원했습니다.

이 기능은 앞으로도 제약(예: 다중 필드 struct 허용과 #[from] 어트리뷰트 구현)을 완화하거나, 관련 기능(예: #[derive(Into)])을 추가하는 것을 하위 호환적으로2 수행할 수 있어야 합니다.

새타입에 From을 쓰는 것이 과연 좋은가?

제 RFC에서 논의된 또 하나의(좀 더 철학적인) 우려는, 새타입에 From을 구현하는 것이 애초에 좋은 아이디어인가, 그리고 그렇다면 이를 rustc가 쉽게 해주도록 가르쳐도 되는가였습니다.

이 논의에는 두 가지 각도가 있습니다. 첫째는 새타입이 From 트레이트를 구현해야 하느냐입니다. 이 질문에 답하기 위해 새타입을 사용하는 여러 동기를 조금 더 자세히 살펴보겠습니다.

여기서 동기를 강조하는 이유는 새타입이 패턴 이기 때문입니다. 패턴을 사용할 동기 자체가 그 패턴을 규정하는 요소 중 하나입니다. 동일한 코드라도, 어떤 것은 특정 패턴을 준수하지만, 다른 것은 그렇지 않을 수 있습니다. 서로 다른 동기에서 비롯되었기 때문입니다. 그래서 GoF에 동일한 소스 표현을 가질 수 있는 패턴들이 있지만, 다른 동기 때문에 다른 패턴으로 분류되는 것입니다.

새타입을 사용하는 1차적 동기, 즉 모든 사용에서 성립하는 동기는 타입 시스템에 새로운 타입을 만드는 것입니다(그렇죠). 이를 통해 예를 들어 WorkerIdTaskId를 구분할 수 있습니다. 같은 “메모리 내” 데이터 타입(예: 정수)으로 뒷받침되더라도 서로 다른 도메인 타입의 값을 실수로 섞기 어렵게 해줍니다. 이를 “타입 혼동 방지” 동기라고 부르겠습니다.

또 하나 매우 흔한 동기(사실 첫 번째 동기의 부분집합!)는, 새타입의 값이 내부 타입의 모든 값에 대해 필연적이지는 않은 어떤 불변식을 만족하도록 보장하는 것입니다. 전형적 예는 struct Email(String) 같은 것이죠. EmailString으로 뒷받침되지만, 모든 문자열이 유효한 이메일은 아닙니다! 이를 “불변식 보장” 동기라고 부르겠습니다.

타입 혼동 방지에는 새타입에 From 트레이트를 구현하는 것이 완전히 타당합니다. 감싼 값으로부터 새타입을 초기화하는 표준화된 방식을 주기 때문입니다.

불변식 보장에는 From을 구현하는 것이 당연히 옳지 않습니다! From은 항상 실패 가능성이 없어야 하며, 어떤 타입의 모든 값을 다른 타입의 어떤 값으로 변환할 수 있어야 합니다. 하지만 Email 같은 경우에는 당연히 그렇지 않습니다. 이 경우에는 TryFrom을 구현하는 것이 타당할 수 있지만, 이는 쉽게 derive할 수 있는 것이 아닙니다.

From 구현을 쉽게 만드는 것에 우려를 표한 분들은 주로 새타입을 불변식 보장을 위해 사용하신 것 같았습니다. 그 경우 From을 구현하는 것이 나쁜 생각이라고 보는 것이 이해됩니다! 하지만 저는 개인적으로 새타입을 타입 혼동 방지를 위해 더 자주 사용합니다(제 코드에서는 두 사용 사례의 비율이 대략 75:25로 타입 혼동 방지가 우세합니다). 그래서 제 새타입 사용 사례 대부분에서는 From을 구현하는 것이 진짜로 유용합니다(바로 그래서 이 기능을 Rust에 추가하고 싶었던 것이죠 :) ).

두 번째 각도는 더 일반적이며 새타입과 무관합니다. 저는 Rust가 무엇이 “좋은 프로그램 설계”인지 규정해서는 안 된다고 생각합니다. 물론 건전성(메모리/데이터 경합/등의 안전성)만 보장한다면 말이죠. 분명 Rust는 이미 프로그램 설계에 관해 강한 결정을 많이 내렸습니다. 예를 들어 상속을 허용하지 않으며, 빌림 검사기는 소위 “포인터 스프” 같은 특정 소유권 패턴을 표현하는 것을 귀찮게 만듭니다. 하지만 이 경우에는 어차피 From을 수동으로 구현할 수 있습니다. 기술적으로 가능한 트레이트 파생을 금지하여 사용자가 모범 사례가 아닐 수도 있는 일을 하지 못하게 하는 것은 다소 우스워 보입니다.

rustc에 기능 구현하기

보통 RFC가 수락된 뒤에는 누군가 실제로 구현할 때까지 기다려야 합니다(이건 당연한 일이 아니며, 잊지 말아야 합니다!). 다행히 저는 컴파일러 기여자라서 스스로 도울 수 있었습니다 Image 3: :sweat_smile: 약간의 시행착오 끝에 이 기능을 구현했습니다. From 트레이트는 기존에 derive 가능했던 트레이트들과는 충분히 달라서, 동작하게 만들려면 derive 인프라에 소소한 변경도 필요했죠. 그리고 언제나 멋진 Nick Nethercote와 다시 작업할 기회이기도 했습니다. Nick은 제 초기 PR이 머지된 뒤 기능을 개선하기도 했습니다. 고마워요, Nick!

이 기능을 구현하면서, 컴파일러와 표준 라이브러리 사이에서 파생 매크로가 실제로 어떻게 연결되는지를 이해하게 되었는데, 꽤 흥미롭습니다. 컴파일러는 파생 가능한 트레이트를 정의하고, 파생 코드를 생성하는 실제 로직도 가지고 있습니다. 그런데 여기에 새 From 파생 구현을 추가했는데도, #[derive(From)]가 여전히 동작하지 않았습니다.

작은 Rust 프로그램들로 컴파일러의 동작을 파고들고 추적하며, Rust 이름 해석 최고 전문가™에게 도움을 청한 끝에, 답은 이름 해석(name resolution) 에 있다는 것을 깨달았습니다. 컴파일러가 #[derive(Trait)] 같은 것을 보면, Trait이 무엇인지 이름 해석을 사용합니다. 한 가지 경우는 그 트레이트가 (derive) 프로시저 매크로로 해석되어 rustc가 그것을 호출[3]해 전개된 코드를 생성하는 것입니다. 또 다른 경우는 표준 라이브러리에 #[rustc_builtin_macro]로 표시된 매크로로 해석되는 것입니다. 이 어트리뷰트가 바로 From이라는 이름을 실제 파생 로직을 생성하는 코드에 연결하는 마법입니다. 그래서 여기에 다음을 추가하자:

#[rustc_builtin_macro]
#[unstable(feature = "derive_from", issue = "144889")]
pub macro From($item: item) {
    /* compiler built-in */
}

표준 라이브러리에 들어간 뒤, #[derive(From)]이 마침내 동작하기 시작했습니다 Image 4: :tada:

하지만 이름 해석(러스트뿐 아니라 일반적으로도)은 매우 골칫거리인 괴물입니다. 이를 이겨낸 기쁨은 오래가지 못했죠. 제 PR이 머지된 바로 다음 날, 누군가 나이트리에서 이름 해석 오류 때문에 코드가 깨졌다는 이슈를 올렸습니다. 아침에 딱 보고 싶은 광경이죠 Image 5: :laughing:

문제는(대개 그렇듯) glob 임포트에 있었습니다. #[derive(From)]이 “그냥” 동작하도록 표준 라이브러리 프렐류드에 From 매크로를 추가했는데, 이렇게 하면 암묵적으로 해석되어 사용자가 새 derive를 쓰기 위해 별도 임포트를 할 필요가 없습니다. 예컨대 #[derive(Hash)]std::hash::Hash 트레이트를 임포트하지 않아도 동작하는 이유가 이것입니다.

하지만 프렐류드에 무언가를 추가하는 일은 항상 까다롭습니다. 다음 코드를 보세요:

mod foo {
    pub use derive_more::From;
}

use foo::*;

#[derive(From)] // 오류: `From`이 모호함
struct S(u32);

이 코드는 foo 모듈에서 From이라는 이름의 매크로를 glob 임포트로 가져옵니다. 제 PR이 들어간 뒤, 이 모듈은 표준 라이브러리에서 프렐류드로 glob 임포트된 또 다른 From 매크로를 받게 되며, 이로 인해 모호성이 발생합니다. 달리 말해, 사람들이 새 기능을 쓰지 않더라도 제 변경은 기존(안정) 코드를 깨뜨렸습니다. 이는 명백히 매우 나쁜 일입니다.

이를 고치기 위해 표준 라이브러리 프렐류드에서 From 매크로를 제거했고, 당분간은 명시적으로 임포트해야 합니다. 이 매크로는 std::from 모듈에 추가했습니다. 모듈에 추가해야 했습니다. 이전에 존재하지 않았던 모듈에서 임포트하던 안정 코드란 존재할 수 없으므로, 누구의 코드를 깨뜨리지 않기 위해서입니다. 다만 From 매크로는 std::from에 있고 From 트레이트는 std::convert에 있다는 점이 약간 혼란스럽긴 합니다.

솔직히 말해, 에디션 변경 없이 임포트 모호성을 해결할 방법이 있는지 모르겠습니다. 어쩌면 이 기능은 인체공학적(ergonomic)으로 사용되려면 다음 에디션까지 기다려야 할지도 모르겠네요. 아마 미래에는 #139493이 이런 문제에 도움이 될 수도 있습니다.

흥미로운 점은, 코드가 다음처럼 되어 있었다면:

pub use derive_more::From;

#[derive(From)] // `derive_more`의 `From` 사용
struct S(u32);

모호성이 없었을 것입니다. derive_more에서의 명시적 임포트가 std 프렐류드에서의 From glob 임포트보다 우선시되기 때문입니다. 하지만 두 매크로가 둘 다 glob 임포트(그중 하나는 프렐류드에서 옴)로 들어오면 문제가 됩니다.

사용 방법

이 기능을 실험하려면, 최신 나이트리 컴파일러로 업데이트하고, 원하는 struct에 #[derive(From)]을 사용하세요. 앞서 말했듯 필드가 정확히 하나인 struct에서만 동작한다는 점에 유의하세요! 또한 위에서 설명한 모호성 문제 때문에 매크로를 명시적으로 임포트해야 합니다.

#![feature(derive_from)]

use std::from::From;

#[derive(From)]
struct Foo(u32);

fn main() {
    let f: Foo = 1u32.into();
}

기능을 시도해보시고 잘 동작하는지 알려주세요! 더 이상의 이름 해석 오류만 없다면, 여기가 깨질 만한 부분은 많지 않으리라 봅니다…

미래의 개선점

위에서 이미 논의했듯, #[derive(From)]에는 다양한 개선과 확장이 가능합니다. 더 일반적으로는 가능한 한 많은 표준 라이브러리 트레이트를 derive할 수 있게 해야 한다고 생각합니다. 단일 필드 struct에 한해 AsRef, Deref, 심지어 Iterator 같은 트레이트의 impl도 쉽게 생성할 수 있어야 합니다.

#[derive(From)]이 안정화되면, 보일러플레이트를 더 줄이기 위해 다른 트레이트들을 지원하는 RFC도 써볼 생각입니다.

맺음말

RFC를 쓰고 Rust 기능을 설계·구현한 제 모험기가 즐거우셨길 바랍니다! #[derive(From)]에 대해 어떻게 생각하시는지, 그리고 어떤 다른 트레이트를 #[derive]하고 싶은지 Reddit에서 알려주세요.

  1. 여기서는 의도적으로 union은 생략합니다. 그냥 대체로 무시하고 싶거든요 Image 6: :laughing:

  2. 마지막으로 유명한 말…? Image 7: :laughing:

  3. 그리고 언젠가 캐시되길 바라며…