Rust가 해결하려는 문제, 소유권과 대여 개념을 실습 중심으로 배우고 Rust로 mini grep 클론을 만들어 봅니다.
이 시리즈를 순서대로 읽어보세요:learning-rust
이번 글에서는 Rust가 해결하려는 문제, Rust의 소유권과 대여 개념을 알아보고 Rust로 mini grep 클론을 만들어 보겠습니다. 저도 이 프로젝트가 정말 기대되고 여러분도 그렇길 바랍니다. 이론을 너무 깊게 들어가지는 않고, 실습 위주로 진행하면서 이후 더 많은 글을 통해 이런 개념에 대한 이해를 차근차근 쌓아가겠습니다. 시작해 봅시다.
전체 소스 코드는 여기에서 확인할 수 있습니다.
모든 프로그램은 메모리가 필요하고, 문자열, 숫자, 리스트 등 어떤 데이터든 메모리에 저장합니다. 여기서 우리가 답해야 할 중요한 질문이 하나 있습니다. 바로 작업이 끝났을 때 그 메모리를 해제하는 책임이 누구에게 있는가? 입니다.
전통적으로는 두 가지 답이 있습니다.
C 같은 프로그래밍 언어에서는 메모리를 할당할 때 malloc을 호출하고, 해제할 때 free를 호출합니다. 이 방식의 가장 큰 장점은 속도와 제어권이지만, 문제는 실수할 수 있다는 점입니다. 메모리를 두 번 해제하거나, 해제를 잊거나, 해제한 뒤에도 사용하는 일이 생길 수 있습니다.
이런 버그는 꽤 치명적입니다. 재현하기도 어렵고 디버깅하기도 어렵습니다.
Python 같은 프로그래밍 언어가 그렇습니다. 백그라운드에서 가비지 컬렉터가 실행되어 더 이상 도달할 수 없는 메모리를 찾아 해제합니다. 장점은 이런 위험한 메모리 버그를 사실상 피할 수 있다는 것이지만, 문제는 성능입니다. 가비지 컬렉터는 예측할 수 없는 시점에 실행되고, 프로그램을 잠시 멈추게 하며, 오버헤드를 추가합니다.
Rust는 가비지 컬렉터 없이도 메모리 안전성을 제공합니다. 이를 가능하게 하는 것은 하나의 시스템입니다. 그리고 이 시스템은 세 가지 아이디어 위에 세워져 있습니다. 바로 소유권, 대여, 슬라이스입니다.
핵심 아이디어는 이렇습니다. Rust에서는 모든 데이터 조각이 정확히 하나의 변수에 의해 "소유"됩니다. 그리고 그 변수가 스코프를 벗어나면 데이터는 자동으로 해제됩니다. GC가 필요 없는 이유는, 컴파일러가 코드를 보고 각 데이터가 언제 정리되어야 하는지를 정확히 알아낼 수 있기 때문입니다.
fn main() {
let s = String::from("hello"); // s가 문자열을 소유함
} // 여기서 s가 스코프를 벗어나고 Rust가 문자열을 자동으로 해제함
위 예제가 이해되길 바라며, 이제 좀 더 흥미로운 부분으로 가보겠습니다.
이건 어떻게 될까요?
let s1 = String::from("hello");
let s2 = s1;
대부분의 언어에서는 s1과 s2가 같은 문자열을 가리키거나, s2가 s1의 복사본일 것이라고 생각할 수 있습니다. Rust에서는 둘 다 아닙니다. 소유권이 s1에서 s2로 이동합니다.
두 번째 줄 이후에는 컴파일러 관점에서 s1은 더 이상 존재하지 않습니다. 사용하려고 하면 컴파일 에러가 발생합니다.
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1); // ERROR: move 이후에 여기서 값을 대여함
아마 왜 Rust는 이렇게 동작할까? 라는 생각이 들 수 있습니다. 이유는 String이 힙에 할당되기 때문입니다. 만약 s1과 s2가 둘 다 그것을 소유한다면 누가 메모리를 해제해야 할까요? 둘 다 시도하면 double free 버그가 발생합니다.
그래서 Rust는 소유자를 하나만 유지함으로써 이 문제를 단순하게 만듭니다.
하지만 정수처럼 단순한 타입은 이런 문제가 없습니다. 이런 값들은 스택에 존재하고, 복사도 매우 간단하며, 걱정할 힙 메모리도 없습니다. 그래서 Rust는 이런 타입은 그냥 복사합니다.
let x = 5;
let y = x;
println!("{}", x); // 문제 없음 - 정수는 이동이 아니라 복사됨
정확히 말하면, 정수는 Copy 트레이트를 구현합니다. String은 그렇지 않아서 복사 대신 이동합니다. 트레이트가 무엇인지는 시리즈 뒤쪽에서 이해하게 될 것입니다.
이제 질문이 생깁니다. 데이터를 함수에 전달하면 소유권이 이동한다면, 함수 안에서 데이터를 실제로 어떻게 사용해야 할까요? String을 함수에 넘기면 함수가 그것을 소비해 버리는 걸까요?
fn print_it(s: String) {
println!("{}", s);
} // 여기서 s가 drop됨
fn main() {
let s = String::from("hello");
print_it(s); // 소유권이 함수 안으로 이동함
println!("{}", s); // ERROR: s는 이동되었음
}
이건 문제가 됩니다. 함수를 한 번 호출할 때마다 s를 다시는 사용할 수 없다면 너무 제한적입니다. 모든 함수 호출이 데이터를 영구적으로 소비하게 만들고 싶지는 않을 것입니다. 이런 상황을 해결하기 위해 Rust는 대여를 제공합니다. 즉, 소유권을 넘기는 대신 데이터에 대한 참조 를 빌려주는 것입니다.
fn print_it(s: &String) {
println!("{}", s);
} // 참조는 스코프를 벗어나지만, 원래 문자열은 영향을 받지 않음
fn main() {
let s = String::from("hello");
print_it(&s); // 참조를 빌려주고 소유권은 이동하지 않음
println!("{}", s); // 여전히 동작함, s가 계속 소유자임
}
&는 "~에 대한 참조"를 의미합니다. 문자열 자체를 전달하는 것이 아니라 문자열을 가리키는 포인터를 전달하는 것이고, 컴파일러는 이것을 아주 꼼꼼하게 추적합니다. 함수는 참조를 통해 데이터를 볼 수는 있지만 소유하지는 않기 때문에 해제할 수 없고, 소유권은 원래 변수에 그대로 남아 있습니다.
이것을 공유 참조 또는 불변 참조라고 부릅니다. 이 참조를 통해서는 읽을 수만 있고 수정할 수는 없습니다. 읽기와 읽기는 충돌하지 않기 때문에 이런 참조는 동시에 여러 개 가질 수 있습니다.
데이터를 수정해야 한다면 어떻게 할까요? 그럴 때는 가변 참조가 있습니다.
fn add_world(s: &mut String) {
s.push_str(" world");
}
fn main() {
let mut s = String::from("hello");
add_world(&mut s);
println!("{}", s); // "hello world"
}
가변 접근이 필요하다면 변수를 mut로 가변 가능하게 선언하고, 함수에 가변 참조를 넘겨야 합니다. 하지만 여기엔 조건이 있습니다. 한 번에 하나의 가변 참조만 가질 수 있고, 같은 시점에 불변 참조는 하나도 가질 수 없습니다. 잠깐, 예를 들어 보겠습니다.
let mut s = String::from("hello");
let r1 = &s; // 불변 대여
let r2 = &mut s; // ERROR: 불변 대여가 존재하는 동안 가변으로 대여할 수 없음
그런데 왜 이렇게 하면 안 될까요? 문제가 뭘까요? 읽기와 쓰기를 동시에 허용하면 데이터 레이스가 생길 수 있기 때문입니다. 어떤 쪽에서는 데이터를 읽고 있는데 다른 쪽에서는 동시에 수정하고 있는 상황이죠. 결과는 정의되지 않으며, Rust는 이런 종류의 버그 전체를 컴파일 시점에 불가능하게 만듭니다.
이 대여 개념을 이해하기 위한 직관적인 모델을 하나 드리자면:
여러 명의 읽는 사람 또는 한 명의 쓰는 사람만 둘 수 있고, 둘을 동시에 둘 수는 없습니다. 이것은 런타임이 아니라 코드가 실행되기 전에 컴파일러가 강제합니다.
가끔은 String 전체를 빌리고 싶은 것이 아니라 그 일부만 빌리고 싶을 때가 있습니다. 그럴 때 슬라이스를 사용합니다.
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
여기서 hello의 타입은 &str입니다. 중요한 점은 이것이 새로운 문자열이 아니라, s의 메모리 안쪽을 직접 가리키는 참조라는 것입니다.
슬라이스는 대여입니다. 다른 누군가의 데이터를 가리키고 있으며, 따라서 슬라이스가 살아 있는 동안 borrow checker는 원본을 수정하거나 제거하지 못하게 합니다.
let mut s = String::from("hello world");
let word = &s[0..5]; // word가 s로부터 빌림
s.clear() // ERROR: word가 빌리고 있는 동안 s를 변경할 수 없음
println!("{}", word)
만약 s.clear()가 허용된다면, word가 가리키고 있는 내용을 지워 버리게 되고 dangling reference, 즉 해제된 메모리를 가리키는 포인터가 생기게 됩니다. C에서는 이런 코드가 컴파일되고 실행되며, 예측할 수 없게 크래시가 나거나 데이터가 손상될 수 있습니다. Rust에서는 컴파일조차 되지 않습니다. borrow checker는 word가 여전히 살아 있고 여전히 s 안쪽을 가리키고 있다는 사실을 보고, 그 변경을 거부합니다.
&strvs&StringRust 코드를 보다 보면&str과&String을 모두 보게 되는데, 이 차이는 중요합니다.&String은 힙에 할당된String객체에 대한 참조이고,&str은 UTF-8 바이트 시퀀스에 대한 참조입니다.
실제로 문자열을 읽기만 하는 함수를 작성할 때는 매개변수 타입으로 &str을 선호하는 것이 좋습니다. 문자열 리터럴과 String 값에 대한 참조를 모두 받을 수 있기 때문입니다.
이제 grep 클론 만들기를 시작해 보겠습니다. 하지만 그 전에 mini grep에 대해 조금만 알아봅시다.
grep은 패턴과 일치하는 줄을 텍스트에서 찾아내는 커맨드라인 도구입니다. 딱 그 역할만 합니다. 이름은 Global Regular Expression Print의 약자입니다. 입력을 읽고, 각 줄을 패턴과 비교한 다음, 일치하는 줄을 출력합니다.
터미널을 열고 이것을 실행해 보세요:
echo -e "hello world\ngoodbye world\nhello rust" | grep "hello"
출력:
hello world
hello rust
세 줄을 훑어서 "hello"라는 단어가 포함된 두 줄을 찾고 출력한 것입니다. 세 번째 줄은 일치하지 않았기 때문에 무시되었습니다. 이제 플래그를 하나 써봅시다.
echo -e "hello world\ngoodbye world\nhello rust" | grep -v "hello"
출력:
goodbye world
-v는 invert를 의미하며, 일치하지 않는 줄을 출력합니다.
다른 플래그도 하나 써봅시다.
echo -e "hello world\ngoodbye world\nhello rust" | grep -c "hello"
출력:
2
-c는 count를 의미하며, 줄을 출력하지 않고 몇 줄이 일치했는지만 출력합니다.
main.rs를 건드리기 전에, 우리가 무엇을 만들 것인지 먼저 말씀드리겠습니다. 우리의 프로그램은 다음을 수행할 것입니다.
터미널에서 이것을 실행하세요:
cargo new rgrep
cd rgrep
이제 Cargo.toml 파일을 열고 다음과 같이 의존성을 추가하세요.
[package]
name = "rgrep"
version = "0.1.0"
edition = "2024"
[dependencies]
regex = "1"
walkdir = "2"
두 개의 의존성을 추가했습니다.
regex는 텍스트에 대해 정규 표현식 패턴을 컴파일하고 실행합니다.walkdir는 디렉터리 트리를 재귀적으로 순회하면서 파일을 하나씩 제공합니다.src/main.rs를 열고 모든 내용을 지운 다음 이것을 작성하세요.
use regex::Regex;
use std::env;
use std::fs;
use walkdir::WalkDir;
use regex::Regex는 정규식 작업에 사용됩니다.use std::env는 커맨드라인 인수와 환경 변수 같은 프로세스 환경에 접근하는 데 사용됩니다.use std::fs에서 fs는 file system을 의미하며, 파일 읽기, 파일 쓰기, 경로 존재 여부 확인 등에 사용합니다.use walkdir::WalkDir는 디렉터리를 재귀적으로 순회하는 데 도움을 줍니다. 기본적으로 디렉터리를 넘기면 내부의 모든 항목을 반환합니다.fn main() {
let args: Vec<String> = env::args().collect();
}
여기서 env::args()는 커맨드라인 인수에 대한 이터레이터를 반환합니다. 예를 들어 사용자가 다음처럼 실행하면:
cargo run -- -v "hello" ./src
인수는 ["rgrep", "-v", "hello", "./src"]입니다. 첫 번째 요소는 항상 프로그램 자신의 이름입니다.
이터레이터는 한 번에 하나씩 요소를 꺼내며 지나갈 수 있는 대상입니다.
.collect()는 이터레이터를 끝까지 소모하고 모든 요소를 하나의 컬렉션으로 모읍니다.
타입 표기 Vec<String>은 어떤 종류의 컬렉션을 원하는지 Rust에 알려 줍니다. .collect()는 여러 타입을 만들 수 있기 때문에 명시해 주어야 합니다.
Vec<String>은 소유권을 가진 문자열들의 크기 가변 리스트를 의미합니다. 자세한 내용은 이전 글을 확인해 보세요.
String이고 &str이 아닐까?왜 String을 쓰고 &str은 쓰지 않는지 궁금할 수 있습니다. 이 질문에 답하려면 &str이 실제로 무엇인지 이해해야 합니다. &str은 참조입니다. 데이터를 소유하지 않고, 메모리 어딘가에 이미 존재하는 데이터를 가리키며, 정해진 생명주기를 가집니다. "hello" 같은 문자열 리터럴이 &str로 동작하는 이유는 컴파일러가 그것들을 바이너리에 직접 넣기 때문에 프로그램이 실행되는 전체 동안 살아 있기 때문입니다.
커맨드라인 인수는 다릅니다. 이것들은 런타임에 운영체제로부터 들어옵니다. 프로그램 메모리 안에 미리 존재하는 장소가 있는 것이 아닙니다. Rust는 각 인수 문자열을 읽을 때 그것을 담기 위한 힙 메모리를 할당해야 합니다. String은 힙에 할당된, 소유권이 있는 문자열 데이터의 타입이며, 데이터를 스스로 들고 다니고 스코프를 벗어날 때 해제할 책임도 집니다. &str은 그럴 수 없습니다. 그저 포인터일 뿐이고, 포인터는 반드시 가리킬 대상이 필요하기 때문입니다.
그래서 여기서는 Vec<String>이 유일한 선택입니다. 벡터 안의 각 String이 자신의 인수 데이터를 소유하고, 벡터는 그 모든 String을 소유합니다. args가 스코프를 벗어나면 모든 것이 자동으로 정리됩니다.
if args.len() < 3 {
println!("Usage: rgrep [OPTIONS] <pattern> <path>");
eprintln!("Options: -v -c -l");
std::process::exit(1);
}
args.len()은 인수가 몇 개인지 반환합니다. 유효한 최소 호출은 다음과 같습니다.
rgrep "pattern" ./path
이 경우 ["rgrep", "pattern", "./path"]를 얻게 되고 총 3개의 요소가 됩니다. 세 개보다 적다면 사용자가 충분한 인수를 주지 않은 것이므로 에러를 출력하고 종료합니다.
eprintln!은println!과 비슷하지만 stdout 대신 stderr에 씁니다. 에러와 사용법 메시지는 관례적으로 stderr로 보내야 파이프로 연결할 때 출력 결과를 오염시키지 않습니다.
마지막으로 std::process::exit(1)은 종료 코드 1과 함께 프로그램을 종료합니다.
let mut invert = false;
let mut count_only = false;
let mut files_only = false;
let mut pattern_index = 1;
for i in 1..args.len() {
match args[i].as_str() {
"-v" => invert = true,
"-c" => count_only = true,
"-l" => files_only = true,
_ => {
pattern_index = i;
break;
}
}
}
가변 변수 네 개로 시작합니다. 앞의 세 개는 어떤 플래그가 전달되었는지 추적합니다. pattern_index는 args 안에서 패턴이 어디에 있는지를 추적합니다. 시작값이 1인 이유는 args[0]이 항상 프로그램 이름이기 때문입니다.
모든 인수를 순회하면서 사용자가 어떤 플래그를 넘겼는지 확인하고, 해당 값을 true로 설정합니다. _는 catch-all입니다. 즉 위의 어떤 경우도 아니라면 우리가 패턴에 도달한 것이고, 그 인덱스를 기록한 뒤 break로 반복을 멈춥니다.
그래서 사용자가 rgrep -v -c "hello" ./src를 실행하면, 반복문 이후 invert는 true, count_only는 true, pattern_index는 3이 됩니다. 즉 "hello"를 가리킵니다.
if pattern_index + 1 >= args.len() {
eprintln!("Error: missing pattern or path");
std::process::exit(1);
}
let pattern = &args[pattern_index];
let path = &args[pattern_index + 1];
플래그 반복문 이후 pattern_index는 패턴을 가리키고 있습니다. 경로는 바로 다음인 pattern_index + 1에 옵니다. 만약 그 인덱스가 범위를 벗어난다면 사용자가 둘 중 하나를 빠뜨린 것입니다.
let pattern = &args[pattern_index]는 바로 대여 개념입니다. 패턴의 별도 복사본이 필요한 것이 아니라 읽어서 동작만 하면 되기 때문입니다. 따라서 pattern은 args로부터 빌린 참조인 &String 타입입니다. path도 마찬가지입니다.
let regex = match Regex::new(pattern) {
Ok(r) => r,
Err(e) => {
eprintln!("Invalid pattern: {}", e);
std::process::exit(1);
}
};
Regex::new(pattern)은 패턴 문자열을 받아 Regex 객체로 컴파일합니다. 여기서 "컴파일"이란 패턴 문법을 파싱하고, 문자열을 효율적으로 검사할 수 있는 내부 상태 기계를 구성하는 것을 의미합니다.
Regex::new는 Result<Regex, Error>를 반환합니다. 이것은 두 가지 변형을 가진 enum입니다. Ok(value)는 성공, Err(error)는 실패를 의미합니다. 이것이 실패할 수 있는 작업을 Rust가 처리하는 방식입니다.
Result에 대한 match는 이를 처리하는 표준적인 방법입니다.
Ok(r) - 패턴이 성공적으로 컴파일되었고, r은 Regex 객체입니다.Err(e) - 패턴이 유효하지 않았습니다. 즉 정규식 문법이 잘못되었습니다. e는 에러이며, 우리는 그것을 출력하고 종료합니다.아직 명확하게 이해되지 않아도 괜찮습니다. 이후 글에서 더 자세히 배울 예정이니, 지금은 따라오면서 프로젝트를 마무리해 봅시다.
for entry in WalkDir::new(path).into_iter() {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_file() {
continue;
}
let file_path = entry.path();
}
WalkDir::new(path).into_iter()는 path에서 재귀 순회를 시작하고 항목을 하나씩 제공합니다. 각 항목은 Result<DirEntry>인데, 디렉터리 읽기는 권한 문제, 깨진 symlink 등으로 실패할 수 있기 때문입니다.
각 항목에 대해 match를 수행합니다. 성공하면 DirEntry를 얻고, 실패하면 다음 반복으로 넘어갑니다.
!entry.file_type().is_file() 부분에 대해 말하자면, 이 순회는 파일과 디렉터리를 모두 제공합니다. 우리는 파일만 관심 있으므로 파일이 아닌 것은 continue로 건너뜁니다.
entry.path()는 &Path를 반환합니다. 이것은 파일 경로에 대한 빌린 참조입니다. Path는 파일시스템 경로를 위한 Rust의 타입입니다.
파일이 아니라 디렉터리가 아니라는 것을 확인했으니, 그 파일 경로를 보관하고 이제 그 파일의 내용을 읽어 보겠습니다.
let contents = match fs::read_to_string(file_path) {
Ok(c) => c,
Err(_) => continue,
};
fs::read_to_string(file_path)는 파일 전체를 읽고 Result<String>을 반환합니다. 성공하면 contents에 파일의 텍스트가 들어가고, 실패하면 역시 권한 등의 이유일 수 있으므로 그냥 다음 반복으로 넘어갑니다.
이제 내용에서 검색을 수행하고 일치하는 줄들을 돌려받고 싶습니다. 여기서 물어야 할 질문은 검색 함수가 무엇을 받아야 하고 무엇을 반환해야 하는가입니다.
contents는 읽기만 하면 되므로 소유가 아니라 대여로 받아야 합니다. 즉 &str입니다.
반환값에 대해서는, 각 일치하는 줄은 contents의 슬라이스입니다. 즉 파일의 텍스트 안쪽을 직접 가리키는 &str입니다. 하지만 그런 슬라이스들을 반환하려면 lifetime annotation이 필요하고, 그것은 아직 다루지 않았습니다. 그래서 지금은 각 일치 줄의 소유권 있는 String 복사본을 반환하겠습니다. 물론 효율은 조금 덜하지만, 지금 단계에서는 올바르고 단순합니다.
fn search(contents: &str, regex: &Regex, invert: bool) -> Vec<(usize, String)> {
let mut results: Vec<(usize, String)> = Vec::new();
let mut line_number = 1;
for line in contents.lines() {
let is_match = regex.is_match(line);
let should_include = if invert { !is_match } else { is_match };
if should_include {
results.push((line_number, line.to_string()));
}
line_number += 1;
}
results
}
우리는 contents와 regex를 대여하고 있습니다. 둘 다 읽기만 하면 되기 때문입니다. 함수는 Vec<(uszie, String)>를 반환합니다. 이것은 튜플의 벡터입니다. 각 튜플에는 줄 번호가 들어갑니다. contents.lines()는 각 줄을 contents를 가리키는 &str 슬라이스로 제공합니다.
반복문 안에서는 해당 줄에 대해 정규식을 검사하고, invert 플래그를 확인한 뒤 그 결과에 따라 해당 줄과 줄 번호를 결과에 포함할지 결정합니다.
line.to_string()은 빌린 &str 슬라이스를 소유권 있는 String으로 바꿉니다. 이렇게 복사해 두면 슬라이스가 contents보다 오래 살아남는 문제를 걱정할 필요가 없습니다. 마지막으로 results를 반환합니다.
cargo run -- "fn" ./src
다음과 비슷한 결과가 나와야 합니다.
./src/main.rs:8: fn search(contents: &str, regex: &Regex, invert: bool) -> Vec<(usize, String)> {
./src/main.rs:27: fn main() {
플래그도 이렇게 사용해 볼 수 있습니다.
cargo run -- -c "fn" ./src
다음과 비슷한 결과가 나와야 합니다.
./src/main.rs: 2
이번 글도 꽤 길었지만, 이제 소유권과 대여에 대해 어느 정도 실전 감각이 생겼으리라 생각합니다. 이론을 너무 많이 다루지는 않으려 합니다. 그러면 지루해지기 쉽기 때문입니다. Result에 대해서는 이후 글에서 더 배워보겠습니다. 다음 글에서는 struct, enum, pattern matching을 배우며 JSON Parser를 만들어 볼 예정입니다. 곧 다시 만나요.