Rust blanket impl: 여러 타입에 트레이트를 한 번에 구현하기

Rust blanket impl: 여러 타입에 트레이트를 한 번에 구현하기

Rust 표준 라이브러리 문서를 읽다 보면 이런 구현을 자주 마주칩니다.

impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

처음 보면 조금 낯섭니다. impl Into<String> for MyType처럼 특정 타입에 트레이트를 구현하는 건 이해가 되는데, 여기서는 impl<T, U>로 모든 타입을 열어두고 있죠. “모든 T에 대해 Into<U>를 구현한다”는 말처럼 보이는데, 정말 그래도 되는 걸까요?

이런 구현을 보통 blanket implementation, 줄여서 blanket impl이라고 부릅니다. 한국어로는 “포괄 구현” 정도로 옮길 수 있지만, Rust 문맥에서는 원어 그대로 blanket impl이라고 부르는 경우가 많습니다.

이번 글에서는 blanket impl이 무엇인지, 왜 표준 라이브러리에서 자주 쓰이는지, 그리고 직접 라이브러리를 설계할 때 어떤 점을 조심해야 하는지 살펴보겠습니다. 트레이트 기본 문법이 익숙하지 않다면 그 글을 먼저 읽고 오시면 훨씬 편합니다.

blanket impl이란?

일반적인 트레이트 구현은 특정 타입 하나를 대상으로 합니다.

trait Summary {
    fn summary(&self) -> String;
}

struct Article {
    title: String,
}

impl Summary for Article {
    fn summary(&self) -> String {
        self.title.clone()
    }
}

여기서는 Summary 트레이트를 Article 타입에만 구현했습니다. Article 값에서는 summary()를 호출할 수 있지만, 다른 타입에서는 호출할 수 없죠.

반면 blanket impl은 조건을 만족하는 여러 타입에 트레이트를 한 번에 구현합니다.

use std::fmt::Display;

trait Summary {
    fn summary(&self) -> String;
}

impl<T> Summary for T
where
    T: Display,
{
    fn summary(&self) -> String {
        format!("{self}")
    }
}

이 구현은 Display를 구현한 모든 타입에 Summary를 구현합니다. i32, String, &str처럼 이미 Display가 있는 타입은 별도 구현 없이 summary()를 사용할 수 있습니다.

fn main() {
    let n = 42;
    let s = String::from("Rust");

    println!("{}", n.summary());
    println!("{}", s.summary());
}

핵심은 impl<T> Summary for T where T: Display입니다. “모든 T에 대해 구현하되, 그 TDisplay를 구현해야 한다”는 뜻이에요.

그래서 blanket impl은 “모든 타입”에 무조건 구현하는 기능이라기보다, 조건을 만족하는 타입 전체에 구현을 펼치는 기능이라고 보는 게 정확합니다.

왜 필요할까?

blanket impl이 없으면 같은 모양의 구현을 타입마다 반복해야 합니다.

trait Summary {
    fn summary(&self) -> String;
}

impl Summary for i32 {
    fn summary(&self) -> String {
        format!("{self}")
    }
}

impl Summary for String {
    fn summary(&self) -> String {
        format!("{self}")
    }
}

impl Summary for bool {
    fn summary(&self) -> String {
        format!("{self}")
    }
}

구현 내용이 전부 같습니다. 차이는 타입뿐이죠.

이럴 때 blanket impl을 쓰면 “이 트레이트를 구현한 타입이라면 같은 방식으로 처리할 수 있다”는 규칙을 한 번만 작성할 수 있습니다.

use std::fmt::Display;

impl<T> Summary for T
where
    T: Display,
{
    fn summary(&self) -> String {
        format!("{self}")
    }
}

이제 새 타입이 Display만 구현하면 자동으로 Summary도 따라옵니다.

use std::fmt;

struct User {
    name: String,
}

impl fmt::Display for User {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.name)
    }
}

fn main() {
    let user = User {
        name: "Ferris".to_string(),
    };

    println!("{}", user.summary());
}

UserSummary를 직접 구현하지 않았는데도 summary()를 호출할 수 있습니다. User: Display가 되었고, Display 타입 전체에 대한 blanket impl이 이미 있기 때문입니다.

이런 패턴은 표준 라이브러리에서도 널리 쓰입니다. 어떤 기본 트레이트 하나를 구현하면 그 위에 얹힌 다른 편의 트레이트나 메서드가 자동으로 따라오는 구조를 만들 수 있거든요.

From을 구현하면 Into가 따라오는 이유

blanket impl을 이해하면 From과 Into 트레이트의 관계가 훨씬 선명해집니다.

우리는 보통 From<T> for U를 구현합니다.

struct UserId(u64);

impl From<u64> for UserId {
    fn from(value: u64) -> Self {
        Self(value)
    }
}

그러면 UserId::from(42)뿐만 아니라 42.into()도 사용할 수 있습니다.

fn main() {
    let id1 = UserId::from(42);
    let id2: UserId = 42.into();
}

우리가 Into<UserId> for u64를 직접 구현하지 않았는데도 동작합니다. 그 이유가 바로 표준 라이브러리의 blanket impl입니다.

impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

읽어보면 뜻은 단순합니다.

UT로부터 만들어질 수 있다면, 즉 U: From<T>라면, TU로 변환될 수 있다.”

그래서 From만 구현하면 반대 방향 관점인 Into가 자동으로 생깁니다. 이건 컴파일러의 특별한 마법이라기보다, 표준 라이브러리에 작성된 blanket impl 덕분입니다.

TryFromTryInto도 같은 패턴입니다. 실패할 수 있는 변환에서는 TryFrom을 구현하면 TryInto가 따라옵니다. 그래서 보통 IntoTryInto를 직접 구현하지 말고, From이나 TryFrom을 구현하라는 조언이 나오는 겁니다.

ToString과 Display도 같은 패턴

to_string()도 자주 쓰지만, 이 메서드가 어디서 오는지 헷갈릴 때가 있습니다. String에만 있는 메서드처럼 보이지만 실제로는 훨씬 넓게 사용할 수 있죠.

fn main() {
    let a = 42.to_string();
    let b = true.to_string();
    let c = "rust".to_string();
}

i32, bool, &str은 모두 to_string()을 사용할 수 있습니다. 그 이유도 blanket impl입니다. 표준 라이브러리는 개념적으로 이런 구현을 제공합니다.

use std::fmt::Display;

impl<T> ToString for T
where
    T: Display + ?Sized,
{
    fn to_string(&self) -> String {
        // 내부적으로 Display 포맷팅을 사용
        format!("{self}")
    }
}

정확한 구현 세부사항은 표준 라이브러리 내부 코드와 조금 다르지만, 핵심 아이디어는 이렇습니다. Display로 출력할 수 있는 타입이라면 문자열로도 바꿀 수 있다는 거죠.

그래서 직접 만든 타입에도 to_string()을 주고 싶다면 ToString을 직접 구현하기보다 Display를 구현하는 편이 일반적입니다.

use std::fmt;

struct Email(String);

impl fmt::Display for Email {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

fn main() {
    let email = Email("hello@example.com".to_string());
    let text = email.to_string();

    println!("{text}");
}

EmailToString을 직접 구현하지 않았습니다. 그런데 Display를 구현했기 때문에 blanket impl을 통해 ToString도 사용할 수 있게 됩니다.

이런 설계는 Rust API를 읽을 때 꽤 중요합니다. “내가 구현하지 않은 메서드가 왜 보이지?”라는 질문의 답이 blanket impl인 경우가 많거든요.

ToOwned의 blanket impl

Borrow와 ToOwned 트레이트에서도 blanket impl이 등장합니다. ToOwned는 빌린 값에서 소유한 값을 만들어내는 트레이트입니다.

pub trait ToOwned {
    type Owned;

    fn to_owned(&self) -> Self::Owned;
}

표준 라이브러리는 Clone을 구현한 타입에 대해 ToOwned를 자동으로 구현합니다.

impl<T> ToOwned for T
where
    T: Clone,
{
    type Owned = T;

    fn to_owned(&self) -> T {
        self.clone()
    }
}

그래서 i32, String, Vec<T>처럼 Clone을 구현한 타입은 to_owned()를 바로 사용할 수 있습니다. 이 경우 to_owned()는 사실상 clone()과 같은 일을 합니다.

fn main() {
    let name = String::from("Rust");
    let owned = name.to_owned();

    println!("{owned}");
}

다만 ToOwned가 진짜 흥미로운 지점은 str에서 String, [T]에서 Vec<T>처럼 빌린 타입과 소유 타입이 달라질 때입니다. 그런 특수 구현과 Clone 타입 전체에 대한 blanket impl이 함께 존재하면서 Cow 같은 타입이 유연하게 동작할 수 있습니다.

blanket impl과 충돌

blanket impl은 편리하지만 한 번 작성하면 굉장히 넓은 영역을 차지합니다. 그래서 다른 구현과 충돌하기 쉽습니다.

예를 들어 우리 트레이트를 Display 타입 전체에 blanket impl했다고 해보겠습니다.

use std::fmt::Display;

trait Label {
    fn label(&self) -> String;
}

impl<T> Label for T
where
    T: Display,
{
    fn label(&self) -> String {
        format!("{self}")
    }
}

이제 Display를 구현한 타입은 모두 Label을 자동으로 갖습니다. 그런데 특정 타입에 대해 다른 방식으로 Label을 구현하고 싶어질 수 있습니다.

struct User {
    name: String,
}

impl std::fmt::Display for User {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.name)
    }
}

// 컴파일 오류: 이미 blanket impl과 겹침
impl Label for User {
    fn label(&self) -> String {
        format!("user: {}", self.name)
    }
}

UserDisplay를 구현했기 때문에 이미 blanket impl을 통해 Label을 구현한 상태입니다. 여기에 impl Label for User를 다시 쓰면 같은 타입에 같은 트레이트 구현이 두 개가 됩니다. Rust는 어떤 구현을 골라야 할지 애매한 상황을 허용하지 않습니다.

이 규칙을 coherence라고 부릅니다. 프로그램 전체에서 어떤 타입과 트레이트 조합에 대한 구현은 하나로 명확해야 한다는 뜻입니다.

그래서 blanket impl은 API 설계에서 꽤 무거운 결정입니다. “일단 전부 구현해두면 편하겠지”라고 생각하기 쉽지만, 나중에 특정 타입만 다르게 처리하고 싶어질 때 길을 막을 수 있습니다.

orphan rule과의 관계

blanket impl을 이해할 때 같이 알아둬야 할 규칙이 orphan rule입니다. Rust에서는 트레이트 구현을 아무 데서나 할 수 없습니다. 대략적으로 말하면, 구현하려는 트레이트나 타입 둘 중 하나는 현재 크레이트가 소유해야 합니다.

예를 들어 우리가 만든 트레이트라면 외부 타입 전체에 blanket impl할 수 있습니다.

use std::fmt::Display;

trait Label {
    fn label(&self) -> String;
}

impl<T> Label for T
where
    T: Display,
{
    fn label(&self) -> String {
        format!("{self}")
    }
}

Label은 우리 크레이트에서 정의한 트레이트입니다. 그래서 Ti32, String, 외부 크레이트 타입이더라도 구현이 가능합니다.

반대로 외부 트레이트를 외부 타입 전체에 구현하려고 하면 막힙니다.

use std::fmt;

// 컴파일 오류: Display도 외부 트레이트이고 T도 우리가 소유한 타입이 아님
impl<T> fmt::Display for T {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "...")
    }
}

이런 구현이 허용되면 어떤 크레이트든 표준 라이브러리 트레이트를 모든 타입에 마음대로 붙일 수 있게 됩니다. 그러면 서로 다른 크레이트의 구현이 충돌하기 쉽고, 컴파일러가 어떤 구현을 써야 할지 판단할 수 없습니다.

orphan rule은 이런 충돌을 미리 막습니다. blanket impl은 구현 범위가 넓기 때문에 이 규칙의 영향을 특히 자주 받습니다.

라이브러리 설계에서 조심할 점

애플리케이션 코드에서는 blanket impl을 직접 작성할 일이 많지 않습니다. 하지만 라이브러리를 만들거나 공용 트레이트를 설계한다면 조심해서 써야 합니다.

가장 먼저 생각할 점은 “이 구현이 정말 모든 대상에 대해 항상 맞는가?”입니다. Display 타입이면 모두 ToString이 된다는 건 자연스럽습니다. From<T> for U가 있으면 Into<U> for T도 가능하다는 것도 자연스럽죠. 이런 관계는 예외가 거의 없습니다.

반면 도메인 의미가 강한 트레이트는 blanket impl이 위험할 수 있습니다.

trait Persistable {
    fn save(&self);
}

impl<T> Persistable for T
where
    T: serde::Serialize,
{
    fn save(&self) {
        // 저장 로직
    }
}

겉으로는 편해 보입니다. Serialize만 구현하면 모두 저장 가능해지니까요.

하지만 직렬화할 수 있다는 것과 저장 가능한 도메인 객체라는 것은 다른 이야기일 수 있습니다. 임시 요청 타입, 로그 이벤트, 설정 구조체까지 전부 Persistable이 되어도 괜찮을까요? 나중에 특정 타입만 다른 저장 정책을 적용하고 싶을 때 blanket impl과 충돌할 수도 있습니다.

이럴 때는 blanket impl보다 명시적인 구현이나 새 래퍼 타입을 쓰는 편이 낫습니다.

struct Stored<T>(T);

trait Persistable {
    fn save(&self);
}

impl<T> Persistable for Stored<T>
where
    T: serde::Serialize,
{
    fn save(&self) {
        // 저장 로직
    }
}

이렇게 하면 “저장 가능한 값”이라는 의미를 Stored<T> 타입으로 명시할 수 있습니다. 구현 범위도 모든 T가 아니라 Stored<T>로 좁아집니다.

blanket impl은 강력하지만, 한 번 공개 API에 들어가면 되돌리기 어렵습니다. 라이브러리라면 특히 보수적으로 쓰는 편이 좋습니다.

언제 직접 쓸까?

직접 blanket impl을 작성해도 좋은 경우는 관계가 명확하고 예외가 거의 없을 때입니다.

예를 들어 어떤 트레이트가 다른 트레이트의 편의 메서드 역할을 한다면 blanket impl이 잘 맞습니다.

trait Render {
    fn render(&self) -> String;
}

trait RenderExt: Render {
    fn render_uppercase(&self) -> String {
        self.render().to_uppercase()
    }
}

impl<T> RenderExt for T where T: Render {}

이 패턴은 확장 트레이트(extension trait)에서 자주 쓰입니다. Render를 구현한 타입이라면 RenderExt의 편의 메서드도 자동으로 사용할 수 있게 만드는 거죠.

struct Title(String);

impl Render for Title {
    fn render(&self) -> String {
        self.0.clone()
    }
}

fn main() {
    let title = Title("rust".to_string());
    println!("{}", title.render_uppercase());
}

Title에는 RenderExt를 직접 구현하지 않았습니다. 하지만 Title: Render이므로 blanket impl 덕분에 render_uppercase()를 사용할 수 있습니다.

이런 경우 blanket impl은 꽤 좋은 선택입니다. 확장 트레이트의 목적이 “기존 트레이트를 구현한 모든 타입에 편의 메서드를 붙이는 것”이기 때문입니다.

마치며

blanket impl은 impl<T> Trait for T where ... 형태로 조건을 만족하는 타입 전체에 트레이트를 구현하는 방법입니다. Rust 표준 라이브러리의 From/Into, TryFrom/TryInto, Display/ToString, Clone/ToOwned 관계를 이해하는 데 꼭 필요한 개념이죠.

좋은 blanket impl은 사용자가 직접 구현해야 할 코드를 줄여줍니다. 하나의 핵심 트레이트만 구현하면 관련 편의 기능이 자연스럽게 따라오게 만들 수 있습니다. 반대로 너무 넓은 blanket impl은 나중에 특정 타입에 대한 별도 구현을 막고, API 확장성을 떨어뜨릴 수 있습니다.

직접 작성할 때는 “이 관계가 모든 타입에 대해 정말 성립하는가?”, “나중에 특정 타입만 다르게 구현하고 싶어질 가능성은 없는가?”, “newtype으로 범위를 좁히는 게 더 명확하지 않은가?”를 먼저 생각해보면 좋습니다.

더 자세한 내용은 Rust Reference의 trait implementation coherence와 표준 라이브러리의 std::convert::Into 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

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

Discord