Rust 기초: Cow로 불필요한 복사 줄이기

Rust 기초: Cow로 불필요한 복사 줄이기

문자열을 정규화하는 함수를 만든다고 해볼게요. 공백을 다듬고 소문자로 바꾸는 작업이 필요한데, 입력이 이미 깔끔할 수도 있습니다.

fn normalize(input: &str) -> String {
    input.trim().to_lowercase()
}

간단하죠. 그런데 한 가지 찜찜한 점이 있습니다. 입력이 이미 정규화된 상태라면 어떻게 될까요? 아무 변화도 없는데 to_lowercase()가 호출되는 순간 새로운 String이 힙에 할당됩니다. 초당 수백만 번 호출되는 코드라면 이 낭비가 꽤 아프게 다가오죠.

“변경이 필요할 때만 복사하고, 그 외엔 빌린 채로 쓰자”는 아이디어를 타입으로 표현한 게 바로 Cow<'a, B>입니다. 이 글에서는 Cow가 어떻게 동작하고, 어떤 상황에서 힘을 발휘하는지 예제로 살펴보겠습니다.

Cow란 무엇인가요?

Cow는 “Clone on Write”의 약자로, 표준 라이브러리의 std::borrow 모듈에 정의되어 있습니다.

pub enum Cow<'a, B: ?Sized + 'a>
where
    B: ToOwned,
{
    Borrowed(&'a B),
    Owned(<B as ToOwned>::Owned),
}

두 가지 상태를 가진 열거형입니다. Borrowed는 빌려온 참조 &'a B를, Owned는 소유권이 있는 값 B::Owned를 담죠. 바운드에 B: ToOwned가 걸려 있는 게 핵심인데요. Borrow와 ToOwned에서 봤듯이 ToOwned가 “빌린 타입에서 소유형 타입을 만드는” 트레이트였으니, Cow는 그 대칭 관계를 하나의 값으로 감싸는 셈입니다.

가장 자주 마주치는 형태는 Cow<'a, str>입니다. 이때 Borrowed&'a str을, OwnedString을 담게 됩니다.

use std::borrow::Cow;

fn main() {
    let borrowed: Cow<str> = Cow::Borrowed("hello");
    let owned: Cow<str> = Cow::Owned(String::from("world"));

    println!("{}", borrowed);
    println!("{}", owned);
}
결과
hello
world

두 경우 모두 &str처럼 다룰 수 있습니다. Cow<B>Deref<Target = B>를 구현하기 때문에 역참조 강제 덕분에 &B가 필요한 자리에 그대로 넘길 수 있어요.

언제 복사가 일어나나요?

Cow의 이름처럼 “쓸 때 복사”는 to_mut() 메서드를 호출하는 순간에 일어납니다.

use std::borrow::Cow;

fn main() {
    let mut cow: Cow<str> = Cow::Borrowed("hello");

    // 아직은 빌린 상태
    println!("변경 전: {:?}", cow);

    // to_mut()을 호출하는 순간 소유권이 있는 String으로 승격
    cow.to_mut().push_str(", world");

    println!("변경 후: {:?}", cow);
}
결과
변경 전: "hello"
변경 후: "hello, world"

to_mut()은 내부 값이 Borrowed이면 to_owned()으로 새 String을 만들어 Owned로 교체하고, 이미 Owned이면 그냥 가변 참조를 돌려줍니다. 즉, 실제로 쓸 일이 없으면 복사도 없는 거죠.

한번 Owned가 되면 그 이후 쓰기는 이미 소유한 String을 직접 조작하므로 추가 복사가 없습니다. 이 흐름이 바로 Cow가 약속하는 “필요할 때 한 번만” 패턴입니다.

실전 예제: 조건부 정규화

앞서 본 정규화 문제로 돌아가 볼게요. Cow로 다시 쓰면 이렇게 됩니다.

use std::borrow::Cow;

fn normalize(input: &str) -> Cow<str> {
    // 이미 정규화된 상태면 빌린 채로 반환
    if input.chars().all(|c| !c.is_uppercase()) && input.trim().len() == input.len() {
        Cow::Borrowed(input)
    } else {
        Cow::Owned(input.trim().to_lowercase())
    }
}

fn main() {
    let a = normalize("rust");            // 빌린 채로
    let b = normalize("  Rust  ");         // 새 String을 생성

    println!("{:?}", a);
    println!("{:?}", b);
}
결과
"rust"
"rust"

반환 타입이 Cow<str>이기 때문에 호출하는 쪽은 결과를 그냥 &str처럼 쓰면 되고, 내부에서는 필요할 때만 할당이 발생합니다. 만약 반환 타입이 String이었다면 첫 번째 케이스에서도 불필요한 String::from이 호출됐을 겁니다.

실전 예제: HTML 이스케이핑

또 다른 전형적인 활용처는 문자열 이스케이핑입니다. 대부분의 텍스트는 이스케이프할 문자가 없고, 가끔 있는 경우에만 치환이 필요하죠.

use std::borrow::Cow;

fn escape_html(input: &str) -> Cow<str> {
    // 이스케이프가 필요한 문자가 없으면 빌린 채로 반환
    if !input.contains(|c| matches!(c, '<' | '>' | '&' | '"' | '\'')) {
        return Cow::Borrowed(input);
    }

    let mut result = String::with_capacity(input.len());
    for c in input.chars() {
        match c {
            '<' => result.push_str("&lt;"),
            '>' => result.push_str("&gt;"),
            '&' => result.push_str("&amp;"),
            '"' => result.push_str("&quot;"),
            '\'' => result.push_str("&#39;"),
            other => result.push(other),
        }
    }
    Cow::Owned(result)
}

fn main() {
    let safe = escape_html("hello world");
    let risky = escape_html("<script>alert('x')</script>");

    println!("{}", safe);
    println!("{}", risky);
}
결과
hello world
&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;

첫 번째 호출은 힙 할당이 전혀 없습니다. 입력 문자열을 그대로 빌려서 반환하는 것뿐이니까요. 두 번째 호출에서만 실제로 새 String이 만들어집니다.

대부분의 입력이 “이스케이프할 게 없는” 상태라면, 이 패턴은 불필요한 할당을 거의 0에 가깝게 줄일 수 있어요.

From으로 간결하게 만들기

CowFrom 구현이 잘 갖춰져 있어서 .into()로 편하게 만들 수 있습니다.

use std::borrow::Cow;

fn main() {
    let a: Cow<str> = "hello".into();              // &str → Cow::Borrowed
    let b: Cow<str> = String::from("world").into(); // String → Cow::Owned

    println!("{} / {}", a, b);
}
결과
hello / world

&strCow::Borrowed가 되고, StringCow::Owned가 됩니다. From과 Into를 알고 계시다면 바로 익숙하실 거예요.

Cow로 함수 매개변수 받기

Cow를 반환값뿐 아니라 매개변수로도 쓸 수 있습니다. “호출하는 쪽이 소유한 값을 주든 빌린 값을 주든, 내가 필요하면 수정하고 아니면 그대로 쓸게”라는 의도를 표현하고 싶을 때 유용해요.

use std::borrow::Cow;

fn greet(name: Cow<str>) {
    if name.starts_with("Dr.") {
        println!("안녕하세요, {}님!", name);
    } else {
        // 필요할 때만 소유권이 있는 String으로 승격
        let mut owned = name.into_owned();
        owned.insert_str(0, "Mr./Ms. ");
        println!("안녕하세요, {}님!", owned);
    }
}

fn main() {
    greet(Cow::Borrowed("Dr. Ferris")); // 수정 없이 그대로
    greet(Cow::Borrowed("Rustacean"));  // 여기서만 새 String 할당
}
결과
안녕하세요, Dr. Ferris님!
안녕하세요, Mr./Ms. Rustacean님!

into_owned()Cow 전체를 소비해 B::Owned를 반환합니다. Borrowed면 복사해서 Owned로 만들고, 이미 Owned면 그 값을 그대로 꺼내 주죠. 이 메서드 덕분에 “소유권이 필요해질 때까지 미루는” 패턴을 함수 내부에서도 자연스럽게 이어갈 수 있습니다.

남용하지 않기

Cow가 좋아 보인다고 아무 데나 쓰면 오히려 코드가 복잡해집니다. 한 번쯤 짚고 넘어가면 좋은 지점이 있어요.

우선 대부분의 경우에 수정이 일어난다면 그냥 String을 반환하는 편이 낫습니다. Cow는 “빌린 채로 끝나는 케이스가 많다”는 전제가 있어야 의미가 있으니까요. 항상 복사할 거면 분기를 둘 이유 자체가 없습니다.

또한 Cow를 받는 API는 호출하는 쪽에서 “이걸 Cow::Borrowed로 감쌀지, Cow::Owned로 감쌀지” 고민하게 만듭니다. 단순히 유연하게 받고 싶은 목적이라면 AsRef 쪽이 더 직관적이에요. Cow는 “빌린 값을 받아서 내부에서 필요할 때만 소유화”한다는 의도가 명확할 때 힘을 발휘합니다.

그리고 Cow의 진짜 가치는 벤치마크로 검증될 때 드러납니다. 대부분의 입력이 수정이 필요 없는 상태인지, 아니면 수정 비율이 높은지 측정해봐야 Cow가 실제로 이득인지 판단할 수 있어요. 감으로 “복사를 줄이는 게 좋을 것 같아서” 넣으면 타입 복잡도만 올라가고 성능 이득은 없을 수 있습니다.

마치며

Cow<'a, B>는 “빌린 값과 소유한 값을 하나의 타입으로 다루면서, 실제로 수정이 필요한 순간에만 복사를 발생시키는” 패턴을 표준 라이브러리가 깔끔하게 추상화한 타입입니다. 정규화, 이스케이핑, 파서의 반환값처럼 “대부분은 그대로, 일부만 변환”이 필요한 상황에서 불필요한 할당을 줄이는 데 아주 잘 맞죠.

내부를 이해하려면 Borrow와 ToOwned의 짝 관계를 먼저 잡아두는 게 좋습니다. B: ToOwned 바운드가 있기 때문에 Cow가 빌린 형태와 소유 형태를 자유롭게 오갈 수 있는 거니까요. 그리고 반환된 Cow&str처럼 쓸 수 있는 건 역참조 강제 덕분입니다.

Rust의 다른 타입들이 궁금하시다면 Rust 관련 글에서 이어서 읽어보세요.

더 자세한 내용은 std::borrow::Cow - Rust 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

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

Discord