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을, Owned는 String을 담게 됩니다.
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("<"),
'>' => result.push_str(">"),
'&' => result.push_str("&"),
'"' => result.push_str("""),
'\'' => result.push_str("'"),
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
<script>alert('x')</script>
첫 번째 호출은 힙 할당이 전혀 없습니다.
입력 문자열을 그대로 빌려서 반환하는 것뿐이니까요.
두 번째 호출에서만 실제로 새 String이 만들어집니다.
대부분의 입력이 “이스케이프할 게 없는” 상태라면, 이 패턴은 불필요한 할당을 거의 0에 가깝게 줄일 수 있어요.
From으로 간결하게 만들기
Cow는 From 구현이 잘 갖춰져 있어서 .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
&str은 Cow::Borrowed가 되고, String은 Cow::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