환영합니다!
**‘100개의 연습 문제로 배우는 Rust’**에 오신 것을 환영합니다!
이 과정은 연습 문제를 하나씩 풀어나가며 Rust의 핵심 개념을 자연스럽게 익힐 수 있도록 설계되었습니다. Rust의 문법부터 타입 시스템, 표준 라이브러리와 생태계까지 폭넓게 다룰 예정입니다.
Rust를 전혀 모르셔도 괜찮습니다. 다만, 최소한 하나 이상의 다른 프로그래밍 언어를 접해본 경험이 있다고 가정하고 내용을 구성했습니다. 시스템 프로그래밍이나 메모리 관리에 대한 지식이 없어도 걱정하지 마세요. 과정 진행 중에 차근차근 배우게 될 것입니다.
한마디로, 우리는 기초부터 시작합니다! 작고 관리하기 쉬운 단계부터 차근차근 Rust 지식을 쌓아나갈 것입니다. 과정을 마칠 때쯤이면 약 100개의 연습 문제를 풀게 되며, 이는 중소 규모의 Rust 프로젝트를 자신 있게 다룰 수 있는 실력이 될 것입니다.
학습 방법
이 과정은 **“직접 해보며 배운다”**는 원칙을 따릅니다. 단순히 읽는 것이 아니라, 대화형 방식으로 직접 코드를 짜보며 체득하도록 설계되었습니다.
Mainmatter는 이 과정을 4일간의 오프라인 강의용으로 개발했습니다. 원래는 각자가 자신의 속도에 맞춰 진행하되, 숙련된 강사가 곁에서 가이드를 주고 질문에 답하며 필요에 따라 주제를 깊이 있게 설명하는 방식입니다. 저희 웹사이트에서 다음 교육 세션 일정을 확인하고 등록할 수 있습니다. 기업용 맞춤형 세션이 필요하시다면 별도로 문의해 주세요.
혼자서도 충분히 수강할 수 있지만, 혹시 막히는 부분이 있다면 주변 동료나 멘토에게 도움을 받는 것도 좋은 방법입니다. 모든 연습 문제의 정답은 GitHub 저장소의 solutions 브랜치에서 확인하실 수 있습니다.
제공 형식
과정 자료는 웹 브라우저에서 바로 보실 수 있습니다. 종이책으로 소장하고 싶다면 Amazon에서 페이퍼백 버전을 구매하실 수도 있습니다.
과정 구성
화면 왼쪽 메뉴를 보면 과정이 여러 섹션으로 나뉘어 있는 것을 알 수 있습니다. 각 섹션에서는 Rust의 새로운 개념이나 기능을 소개하며, 이해도를 확인할 수 있는 연습 문제가 포함되어 있습니다.
연습 문제는 함께 제공되는 GitHub 저장소에서 내려받을 수 있습니다. 시작하기 전에 먼저 저장소를 로컬 컴퓨터로 복제(Clone)해 주세요.
# GitHub에 SSH 키가 설정되어 있는 경우 git clone git@github.com:partrita/100-exercises-to-learn-rust.git
# 그렇지 않다면 HTTPS URL을 사용하세요.
# https://github.com/partrita/100-exercises-to-learn-rust.git
# GitHub CLI를 사용하는 경우 gh repo clone partrita/100-exercises-to-learn-rust
진행 상황을 관리하고 나중에 원본 저장소의 업데이트를 쉽게 반영할 수 있도록, 별도의 브랜치를 만들어 작업하시는 것을 추천합니다.
cd 100-exercises-to-learn-rust
git checkout -b my-solutions
모든 연습 문제는 exercises 폴더 안에 있습니다. 각 문제는 하나의 Rust 패키지로 구성되어 있으며, src/lib.rs 파일에 문제 설명과 지침이 들어 있습니다. 코드를 작성한 후 함께 포함된 테스트 스위트를 실행하여 정답 여부를 자동으로 확인할 수 있습니다.
준비물
과정을 진행하려면 다음 도구들이 필요합니다.
- Rust
이미
rustup이 설치되어 있다면rustup update를 실행해 최신 안정 버전(stable)인지 확인해 주세요. - IDE (권장)
코드 자동 완성 기능을 지원하는 편집기를 사용하면 훨씬 편합니다. 다음 중 하나를 추천합니다.
- RustRover
rust-analyzer확장 프로그램이 설치된 Visual Studio Code
워크숍 러너 wr
연습 문제 풀이를 돕고 정답을 확인해 주는 전용 도구인 wr CLI(Workshop Runner)를 제공합니다.
웹사이트의 안내에 따라 wr을 설치해 주세요.
설치가 끝났다면 터미널에서 저장소 폴더로 이동한 뒤, 다음 명령어를 실행해 과정을 시작합니다.
wr
wr은 현재 풀고 있는 연습 문제의 정답 여부를 확인해 줍니다. 현재 섹션의 문제를 완벽히 이해하고 해결한 뒤에 다음 섹션으로 넘어가시는 것이 좋습니다.
팁: 문제를 풀 때마다 Git에 커밋해 두는 습관을 들여보세요. 진행 상황을 한눈에 볼 수 있고, 나중에 특정 지점부터 다시 시작하고 싶을 때 유용합니다.
자, 이제 Rust의 세계를 즐겨보세요!
저자 소개
이 과정은 Mainmatter의 수석 엔지니어링 컨설턴트인 Luca Palmieri가 만들었습니다.
Luca는 2018년부터 TrueLayer와 AWS 등에서 Rust 전문가로 활동해 왔습니다. 또한 Rust 백엔드 개발의 필독서로 꼽히는 “Zero to Production in Rust”의 저자이기도 합니다. 오픈 소스 생태계에서도 cargo-chef, Pavex, wiremock 등 다양한 프로젝트를 만들고 유지 관리하고 있습니다.
Exercise
The exercise for this section is located in 01_intro/00_welcome
구문 (Syntax)
잠깐만요!
이 섹션을 시작하기 전에 이전 섹션의 연습 문제를 먼저 완료해 주세요.
과정 GitHub 저장소의 exercises/01_intro/00_welcome 폴더에 있습니다.
wr 도구를 실행하여 과정을 시작하고 정답을 확인하시기 바랍니다.
방금 푼 과제는 연습 문제라기엔 아주 간단했지만, 여러분은 이미 Rust 구문의 기초적인 부분들을 접해보셨습니다.
이전 연습 문제에서 쓰인 모든 문법적 세부 사항을 지금 당장 완벽히 파헤치지는 않을 것입니다. 너무 세세한 내용에 얽매여 지치기보다는, 일단 학습을 계속 이어나갈 수 있을 정도로만 핵심 위주로 짚어보고 넘어가겠습니다. 한 걸음씩 차근차근 나아가 봅시다!
주석 (Comments)
한 줄 주석을 작성할 때는 //를 사용합니다.
// 이것은 한 줄 주석입니다.
// 다음 줄에도 주석을 이어 달 수 있습니다.
함수 (Functions)
Rust에서 함수는 fn 키워드로 정의합니다. 그 뒤로 함수 이름, 입력 매개변수(Parameter), 그리고 반환 타입(Return type)이 차례로 옵니다. 함수의 실제 동작을 담은 본문은 중괄호 {}로 감쌉니다.
이전 연습 문제에서 보았던 greeting 함수를 다시 살펴볼까요?
// `fn` <함수_이름> ( <입력_매개변수> ) -> <반환_타입> { <본문> }
fn greeting() -> &'static str {
// TODO: 여기를 수정하세요 👇
"I'm ready to __!"
}
greeting 함수는 입력 매개변수가 없으며, 문자열 슬라이스에 대한 참조(&'static str)를 반환합니다.
반환 타입 (Return type)
함수가 아무런 값도 반환하지 않는 경우(정확히는 Rust의 ’유닛 타입’인 ()를 반환하는 경우), 함수 선언부에서 반환 타입을 생략할 수 있습니다. test_welcome 함수가 바로 그런 예입니다.
fn test_welcome() {
assert_eq!(greeting(), "I'm ready to learn Rust!");
}
위 코드는 사실 아래 코드와 똑같은 의미입니다.
// 유닛 반환 타입을 명시적으로 적어준 경우
// 👇
fn test_welcome() -> () {
assert_eq!(greeting(), "I'm ready to learn Rust!");
}
값 반환하기
Rust 함수는 본문의 **마지막 표현식(Expression)**의 값을 자동으로 반환합니다. return 키워드를 따로 쓰지 않아도 됩니다.
fn greeting() -> &'static str {
// 이것이 함수의 마지막 표현식입니다.
// 따라서 이 값이 `greeting` 함수의 결과로 반환됩니다.
"I'm ready to learn Rust!"
}
물론 return 키워드를 사용해 함수 중간에 값을 즉시 반환할 수도 있습니다.
fn greeting() -> &'static str {
// return을 사용할 때는 줄 끝에 세미콜론(;)을 붙여야 함에 주의하세요!
return "I'm ready to learn Rust!";
}
Rust에서는 꼭 필요한 경우가 아니라면 return 키워드를 생략하는 것을 권장(Idiomatic)합니다.
입력 매개변수 (Input parameters)
함수 이름 뒤의 괄호 () 안에 입력 매개변수를 선언합니다. 각 매개변수는 이름: 타입 순서로 적습니다.
예를 들어, 아래의 greet 함수는 &str 타입(“문자열 슬라이스”)의 name 매개변수를 받습니다.
// 입력 매개변수 선언
// 👇
fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
매개변수가 여러 개라면 쉼표(,)로 구분하여 나열합니다.
타입 명시 (Type annotations)
앞서 ’타입’이라는 말을 여러 번 언급했는데요, 중요하니까 짚고 넘어갑시다. Rust는 **정적 타입 언어(Statically typed language)**입니다. 즉, Rust의 모든 값은 타입을 가지며, 컴파일러는 빌드 시점에 그 타입이 무엇인지 정확히 알고 있어야 합니다.
타입은 정적 분석의 핵심 도구입니다. 컴파일러가 프로그램의 모든 값에 붙이는 ’이름표’라고 생각하면 이해하기 쉽습니다. 이 이름표 덕분에 컴파일러는 “문자열에 숫자를 더할 수는 없지만, 숫자끼리는 더할 수 있다“와 같은 규칙을 엄격하게 적용할 수 있습니다. 이를 잘 활용하면 프로그램 실행 중에 발생할 수 있는 수많은 버그를 미리 잡아낼 수 있습니다.
Exercise
The exercise for this section is located in 01_intro/01_syntax
기본 계산기 만들기
이 장에서는 Rust를 계산기처럼 활용하는 방법을 배웁니다. 단순해 보일 수 있지만, 이 과정을 통해 다음과 같은 Rust의 핵심 기초를 탄탄히 다질 수 있습니다.
- 함수를 정의하고 호출하는 방법
- 변수를 선언하고 사용하는 방법
- 기본 데이터 타입 (정수 및 불리언)
- 산술 연산자 (오버플로우 및 언더플로우 동작 포함)
- 비교 연산자
- 제어 흐름(Control Flow)
- 패닉(Panics)
몇 가지 연습 문제를 직접 풀어보며 Rust의 기초를 몸소 익히게 될 것입니다. 나중에 특성(Traits)이나 소유권(Ownership) 같은 더 복잡한 주제로 넘어갔을 때, 기본적인 구문이나 세부 사항에 얽매이지 않고 새로운 개념에만 집중할 수 있는 든든한 밑거름이 되어줄 것입니다.
Exercise
The exercise for this section is located in 02_basic_calculator/00_intro
정수(Integers) - 1부
“구문” 섹션에서 보았던 compute 함수의 입력 매개변수 타입은 u32였습니다. 과연 이것이 무엇을 _의미_하는지 자세히 알아봅시다.
원시 타입 (Primitive types)
u32는 Rust의 원시 타입 중 하나입니다. 원시 타입은 언어가 제공하는 가장 기본적인 구성 요소로, 다른 타입을 조합해서 만드는 것이 아니라 언어 자체에 내장되어 있습니다.
이러한 원시 타입들을 조합하면 더 복잡하고 유용한 타입을 만들 수 있습니다. 그 방법은 곧 배우게 될 것입니다.
정수 (Integers)
u32는 정확히 말하면 부호 없는(Unsigned) 32비트 정수입니다. 정수는 소수점 이하 부분이 없는 숫자를 말합니다. 예를 들어 1은 정수이지만, 1.2는 정수가 아닙니다.
부호 있는 정수 vs 부호 없는 정수
정수는 부호가 있을 수도(Signed), 없을 수도(Unsigned) 있습니다. 부호 없는 정수는 0 이상의 양수만 나타낼 수 있습니다. 반면 부호 있는 정수는 -1이나 12처럼 양수와 음수를 모두 나타낼 수 있습니다.
u32의 u는 **unsigned(부호 없는)**를 뜻합니다. 부호 있는 정수 타입은 i32라고 쓰는데, 여기서 i는 **integer(정수)**를 의미하며 양수와 음수 모두 포함할 수 있다는 뜻입니다.
비트 너비 (Bit width)
u32의 32는 메모리에서 숫자를 표현하는 데 사용하는 비트(bit)1 수를 나타냅니다. 비트가 많을수록 더 넓은 범위의 숫자를 담을 수 있습니다.
Rust는 정수에 대해 8, 16, 32, 64, 128비트의 다양한 비트 너비를 지원합니다.
32비트를 사용하면 u32는 0부터 2^32 - 1(u32::MAX라고도 함)까지의 숫자를 나타낼 수 있습니다. 같은 32비트를 사용하는 부호 있는 정수(i32)는 -2^31부터 2^31 - 1(i32::MIN에서 i32::MAX까지)의 숫자를 표현합니다.
i32의 최댓값이 u32보다 작은 이유는 한 비트를 숫자의 부호를 표시하는 데 사용하기 때문입니다. 부호 있는 정수가 메모리에서 어떻게 저장되는지 궁금하다면 2의 보수(Two’s complement) 표현 방식을 찾아보세요.
요약
부호 유무와 비트 너비를 조합하면 다음과 같은 정수 타입들이 만들어집니다.
| 비트 너비 | 부호 있음 (Signed) | 부호 없음 (Unsigned) |
|---|---|---|
| 8비트 | i8 | u8 |
| 16비트 | i16 | u16 |
| 32비트 | i32 | u32 |
| 64비트 | i64 | u64 |
| 128비트 | i128 | u128 |
리터럴 (Literals)
리터럴은 소스 코드에 직접 적은 고정된 값을 말합니다. 예를 들어 42는 숫자 42를 나타내는 Rust 리터럴입니다.
리터럴의 타입 추론
Rust의 모든 값은 반드시 타입을 가져야 합니다. 그렇다면 우리가 적은 42의 타입은 무엇일까요?
Rust 컴파일러는 리터럴이 어떻게 사용되는지 보고 타입을 추론하려고 노력합니다. 만약 별다른 힌트가 없다면, 컴파일러는 정수 리터럴의 기본 타입을 i32로 정합니다. 만약 특정 타입을 지정하고 싶다면 숫자 뒤에 접미사를 붙이면 됩니다. 예를 들어 2u64는 u64 타입으로 명시된 숫자 2를 의미합니다.
가독성을 위한 밑줄
큰 숫자를 적을 때 가독성을 높이기 위해 밑줄(_)을 넣을 수 있습니다. 예를 들어 1_000_000은 1000000과 똑같이 인식되지만 훨씬 읽기 편합니다.
산술 연산자
Rust는 정수 데이터에 대해 다음과 같은 기본적인 산술 연산2을 지원합니다.
+더하기-빼기*곱하기/나누기%나머지
연산의 우선순위와 결합 규칙은 우리가 수학 시간에 배운 것과 같습니다. 괄호를 사용하면 2 * (3 + 4)처럼 계산 순서를 직접 정할 수 있습니다.
⚠️ 주의
정수끼리
/연산자를 사용하면 정수 나눗셈을 수행합니다. 즉, 소수점 이하는 버리고 0에 가까운 쪽으로 결과를 잘라냅니다. 예를 들어5 / 2의 결과는2.5가 아니라2입니다.
자동 타입 변환 없음
앞서 살펴봤듯이 Rust는 타입에 매우 엄격한 언어입니다. 특히 Rust는 값이 손실되지 않는 안전한 경우라도 한 타입을 다른 타입으로 자동으로 변환(Coercion)해주지 않습니다3. 모든 타입 변환은 명시적으로 작성해야 합니다.
예를 들어, 모든 u8 값은 당연히 u32 범위에 포함되지만, 다음과 같이 u8 값을 u32 변수에 그냥 대입할 수는 없습니다.
let b: u8 = 100;
let a: u32 = b;
이 코드를 실행하면 다음과 같은 컴파일 오류가 발생합니다.
error[E0308]: mismatched types
|
3 | let a: u32 = b;
| --- ^ expected `u32`, found `u8`
| |
| expected due to this
|
타입을 안전하게 변환하는 방법은 과정 뒷부분에서 자세히 다룰 예정입니다.
더 읽어보기
- 공식 Rust 가이드의 정수 타입 섹션 (영문)
Exercise
The exercise for this section is located in 02_basic_calculator/01_integers
-
비트는 컴퓨터에서 데이터를 다루는 가장 작은 단위입니다.
0또는1의 두 가지 값만 가질 수 있습니다. ↩ -
Rust에서는 사용자가 임의로 연산자를 새로 정의할 수는 없지만, 내장 연산자가 어떻게 작동할지 제어할 수는 있습니다. 연산자 오버로딩(Operator overloading)에 대해서는 트레이트 섹션에서 설명하겠습니다. ↩
-
이 규칙에도 몇 가지 예외는 있습니다. 주로 참조, 스마트 포인터와 관련된 편의 기능들인데, 이는 나중에 배우게 됩니다. 하지만 지금은 “모든 변환은 명시적이어야 한다“는 원칙을 기억하는 것이 좋습니다. ↩
변수(Variables)
Rust에서는 let 키워드를 사용하여 변수를 선언합니다. 예를 들어 다음과 같이 작성할 수 있습니다.
let x = 42;
위 코드는 x라는 이름의 변수를 정의하고, 여기에 42라는 값을 할당한 것입니다.
타입(Type)
Rust의 모든 변수는 타입을 가져야 합니다. 컴파일러가 상황을 보고 타입을 알아서 **추론(Inference)**하거나, 개발자가 직접 **명시(Annotation)**할 수 있습니다.
명시적 타입 어노테이션(Explicit Type Annotation)
변수 이름 뒤에 콜론 :과 타입을 추가하여 변수의 타입을 직접 지정할 수 있습니다.
// let <변수_이름>: <타입> = <표현식>;
let x: u32 = 42;
위 예제에서는 x의 타입을 u32로 명시했습니다.
타입 추론(Type Inference)
타입을 직접 적지 않으면, 컴파일러는 변수가 어떻게 사용되는지 보고 타입을 추론합니다.
let x = 42;
let y: u32 = x;
위의 예에서 x에는 타입을 지정하지 않았지만, 나중에 u32 타입인 y에 할당됩니다. Rust는 타입을 자동으로 바꿔주지(Implicit coercion) 않으므로, 컴파일러는 x의 타입을 u32로 추론하게 됩니다. 이것이 프로그램이 오류 없이 컴파일될 수 있는 유일한 길이기 때문입니다.
추론의 한계
컴파일러가 주변 맥락만으로는 타입을 정확히 알아내지 못할 때도 있습니다. 이럴 때는 컴파일 오류가 발생하며, 컴파일러는 개발자에게 명시적으로 타입 힌트를 달아달라고 요청하게 됩니다.
함수 매개변수(Function parameters)도 변수입니다
모든 변수가 let으로만 선언되는 것은 아닙니다. 함수 매개변수도 결국 변수와 같습니다!
fn add_one(x: u32) -> u32 {
x + 1
}
위 예제에서 x는 u32 타입의 변수입니다. let 변수와 한 가지 다른 점이 있다면, 함수 매개변수는 타입을 반드시 명시해야 한다는 것입니다. 컴파일러가 대신 추론해주지 않거든요. 덕분에 컴파일러(와 우리 인간!)는 함수 안의 코드를 일일이 들여다보지 않고도 함수의 시그니처만 보고 어떤 역할을 하는지 바로 이해할 수 있습니다. 이는 컴파일 속도를 높이는 데에도 큰 도움이 됩니다1!
초기화(Initialization)
변수를 선언할 때 꼭 바로 값을 넣어줄 필요는 없습니다.
let x: u32;
이렇게 선언만 하는 것도 가능합니다. 다만, 해당 변수를 실제로 사용하기 전에는 반드시 초기화해야 합니다. 그렇지 않으면 컴파일러가 다음과 같은 친절한(?) 오류를 띄울 것입니다.
let x: u32;
let y = x + 1;
컴파일 결과:
error[E0381]: used binding `x` isn't initialized
--> src/main.rs:3:9
|
2 | let x: u32;
| - binding declared here but left uninitialized
3 | let y = x + 1;
| ^ `x` used here but it isn't initialized
|
help: consider assigning a value
|
2 | let x: u32 = 0;
| +++
Exercise
The exercise for this section is located in 02_basic_calculator/02_variables
-
Rust 컴파일러의 속도를 조금이라도 높이기 위해서는 가능한 모든 힌트가 필요합니다. ↩
제어 흐름(Control flow) - 1부
지금까지 우리가 작성한 프로그램은 아주 단순했습니다. 명령어들이 위에서 아래로 순서대로 실행되고 나면 끝이었죠.
이제 조건에 따라 다른 코드를 실행하는 **분기(Branching)**를 배워볼 시간입니다.
if 문
if 키워드는 특정 조건이 참(True)일 때만 코드 블록을 실행하고 싶을 때 사용합니다.
간단한 예를 살펴볼까요?
let number = 3;
if number < 5 {
println!("`number`가 5보다 작습니다.");
}
이 프로그램은 number < 5라는 조건이 참이므로, “number가 5보다 작습니다.“라는 메시지를 출력합니다.
else 절
대부분의 프로그래밍 언어와 마찬가지로, Rust도 if 조건이 거짓일 때 실행할 else 분기를 지원합니다.
let number = 3;
if number < 5 {
println!("`number`가 5보다 작습니다.");
} else {
println!("`number`가 5보다 크거나 같습니다.");
}
else if 절
여러 조건을 검사해야 할 때 if를 계속 중첩해서 쓰면, 코드가 점점 오른쪽으로 밀려나 보기 힘들어집니다.
let number = 3;
if number < 5 {
println!("`number`가 5보다 작습니다.");
} else {
if number >= 3 {
println!("`number`가 3 이상 5 미만입니다.");
} else {
println!("`number`가 3보다 작습니다.");
}
}
이럴 때는 else if 키워드를 사용하여 여러 조건을 깔끔하게 하나로 묶을 수 있습니다.
let number = 3;
if number < 5 {
println!("`number`가 5보다 작습니다.");
} else if number >= 3 {
println!("`number`가 3 이상 5 미만입니다.");
} else {
println!("`number`가 3보다 작습니다.");
}
불리언(Booleans)
if 문 뒤에 오는 조건은 반드시 bool 타입, 즉 불리언이어야 합니다.
불리언은 정수와 마찬가지로 Rust의 기본 데이터 타입 중 하나입니다. 값은 오직 true 또는 false 중 하나만 가질 수 있습니다.
“참 같은 값(Truthy)“이나 “거짓 같은 값(Falsy)“은 없습니다
Rust는 if 문의 조건으로 불리언이 아닌 값을 넣으면 컴파일 오류를 냅니다.
예를 들어, 다음과 같은 코드는 컴파일되지 않습니다.
let number = 3;
if number {
println!("`number`는 0이 아닙니다.");
}
컴파일 시 다음과 같은 오류가 발생합니다.
error[E0308]: mismatched types
--> src/main.rs:3:8
|
3 | if number {
| ^^^^^^ expected `bool`, found integer
이는 타입 변환에 대한 Rust의 엄격한 철학 때문입니다. 불리언이 아닌 타입을 불리언으로 자동 변환해주지 않거든요. Rust에는 JavaScript나 Python에 있는 참 같은(Truthy) 또는 거짓 같은(Falsy) 값이라는 개념이 아예 없습니다. 따라서 우리가 확인하려는 조건을 명확하게 불리언 형식으로 작성해야 합니다.
비교 연산자(Comparison operators)
if 문에서 조건을 만들 때는 보통 비교 연산자를 많이 사용합니다. 정수 데이터에서 사용할 수 있는 비교 연산자는 다음과 같습니다.
==: 같음!=: 같지 않음<: 작음>: 큼<=: 작거나 같음>=: 크거나 같음
if/else는 표현식(Expression)입니다
Rust에서 if는 단순한 명령(Statement)이 아니라 값을 반환하는 표현식입니다. 즉, if의 결과값을 변수에 바로 할당하거나 다른 계산에 활용할 수 있습니다.
let number = 3;
let message = if number < 5 {
"5보다 작음"
} else {
"5보다 크거나 같음"
};
위 예제에서 if와 else 각 분기의 결과값이 message 변수에 할당됩니다. 여기서 주의할 점은, if와 else의 각 분기가 반드시 같은 타입의 값을 반환해야 한다는 점입니다.
Exercise
The exercise for this section is located in 02_basic_calculator/03_if_else
패닉(Panics)
“변수” 섹션에서 구현한 speed 함수를 다시 한 번 살펴볼까요? 아마 코드는 다음과 같을 것입니다.
fn speed(start: u32, end: u32, time_elapsed: u32) -> u32 {
let distance = end - start;
distance / time_elapsed
}
혹시 눈썰미가 좋으신 분이라면 한 가지 문제점1을 발견하셨을지도 모릅니다. 만약 time_elapsed가 0이라면 어떻게 될까요?
Rust 플레이그라운드에서 직접 실행해 보세요! 그러면 프로그램은 다음과 같은 오류 메시지를 남기며 즉시 종료됩니다.
thread 'main' panicked at src/main.rs:3:5:
attempt to divide by zero
이것을 바로 **패닉(Panic)**이라고 부릅니다. 패닉은 프로그램이 더 이상 정상적으로 실행될 수 없을 정도로 심각한 상황이 발생했음을 알리는 Rust의 신호이자, 복구 불가능한 오류2를 처리하는 방식입니다. ’0으로 나누기’가 바로 이런 오류에 해당하죠.
panic! 매크로
우리가 직접 panic! 매크로3를 호출해서 의도적으로 패닉을 일으킬 수도 있습니다.
fn main() {
panic!("여기는 패닉 지점입니다!");
// 아래 줄은 절대 실행되지 않습니다.
let x = 1 + 2;
}
Rust에는 복구 가능한 오류를 세련되게 처리하는 별도의 메커니즘이 있으며, 이는 나중에 자세히 다룰 예정입니다. 지금은 조금 거칠더라도 확실한 오류 알림 수단인 패닉을 사용해 보겠습니다.
더 읽어보기
Exercise
The exercise for this section is located in 02_basic_calculator/04_panics
-
사실
speed함수에는 아직 다루지 않은 또 다른 잠재적인 문제가 있습니다. 무엇인지 눈치채셨나요? ↩ -
패닉을 잡아내어 복구하려고 시도할 수도 있지만, 이는 매우 특수한 상황에서만 제한적으로 사용되는 최후의 수단입니다. ↩
-
이름 뒤에 느낌표(
!)가 붙으면 함수가 아닌 매크로 호출을 의미합니다. 지금은 매크로를 아주 똑똑하고 특별한 함수 정도로 이해하셔도 좋습니다. 과정 뒷부분에서 더 자세히 배울 것입니다. ↩
팩토리얼(Factorial)
지금까지 우리는 다음 내용들을 살펴보았습니다.
- 함수를 정의하는 방법
- 함수를 호출하는 방법
- Rust에서 사용할 수 있는 정수 타입들
- 정수 데이터에 쓸 수 있는 산술 연산자
- 비교 연산자와
if/else를 활용한 조건부 로직
자, 이제 배운 내용을 토대로 직접 팩토리얼을 구현해 볼 준비가 되었습니다!
Exercise
The exercise for this section is located in 02_basic_calculator/05_factorial
반복문(Loops) - 1부: while
이전 연습 문제에서 factorial 함수를 구현할 때 재귀(Recursion)를 사용했었죠. 함수형 프로그래밍에 익숙한 분들에게는 자연스러운 방식이겠지만, C나 Python 같은 명령형(Imperative) 언어를 주로 써오신 분들에게는 조금 낯설게 느껴졌을 수도 있습니다.
이번에는 재귀 대신 **반복문(Loops)**을 사용해서 똑같은 기능을 구현하는 방법을 알아보겠습니다.
while 루프
while 루프는 특정 조건이 참인 동안 코드 블록을 반복해서 실행하는 방식입니다. 기본적인 형태는 다음과 같습니다.
while <조건> {
// 반복 실행할 코드
}
예를 들어, 1부터 5까지의 숫자를 모두 더하고 싶다면 다음과 같이 작성할 수 있습니다.
let sum = 0;
let i = 1;
// "i가 5보다 작거나 같은 동안 반복"
while i <= 5 {
// `+=`는 `sum = sum + i`를 줄여 쓴 것입니다.
sum += i;
i += 1;
}
이 코드는 i가 5보다 커질 때까지 i를 1씩 증가시키며 sum에 더하는 과정을 반복합니다.
mut 키워드
하지만 위에서 작성한 예제 코드를 그대로 실행하면 컴파일 오류가 발생합니다.
error[E0384]: cannot assign twice to immutable variable `sum`
--> src/main.rs:7:9
|
2 | let sum = 0;
| ---
| |
| first assignment to `sum`
| help: consider making this binding mutable: `mut sum`
...
7 | sum += i;
| ^^^^^^^^ cannot assign twice to immutable variable
error[E0384]: cannot assign twice to immutable variable `i`
--> src/main.rs:8:9
...
그 이유는 Rust의 변수가 기본적으로 **불변(Immutable)**이기 때문입니다. 한 번 값을 할당하고 나면, 나중에 그 값을 마음대로 바꿀 수 없죠.
변수의 값을 수정하고 싶다면 선언할 때 mut 키워드를 붙여서 해당 변수가 **가변(Mutable)**임을 명시해야 합니다.
// 이제 `sum`과 `i`의 값을 마음껏 바꿀 수 있습니다!
let mut sum = 0;
let mut i = 1;
while i <= 5 {
sum += i;
i += 1;
}
이렇게 수정하면 오류 없이 정상적으로 컴파일되고 실행됩니다.
더 읽어보기
Exercise
The exercise for this section is located in 02_basic_calculator/06_while
반복문(Loops) - 2부: for
매번 카운터 변수를 직접 만들고 1씩 증가시키는 과정은 조금 번거로울 수 있습니다. 사실 이런 작업은 프로그래밍에서 매우 자주 일어나는 일이죠. Rust에서는 값의 범위를 따라 더 간결하게 반복할 수 있는 for 루프를 제공합니다.
for 루프
for 루프는 반복자(Iterator)1에 들어있는 각 요소에 대해 코드 블록을 실행하는 방식입니다. 일반적인 형태는 다음과 같습니다.
for <요소> in <반복자> {
// 반복 실행할 코드
}
범위(Ranges)
Rust 표준 라이브러리는 일련의 숫자들을 차례대로 훑을 때 사용할 수 있는 범위(Range) 타입을 제공합니다2.
예를 들어, 1부터 5까지의 숫자를 모두 더하고 싶다면 다음과 같이 작성할 수 있습니다.
let mut sum = 0;
for i in 1..=5 {
sum += i;
}
루프가 돌 때마다 i에는 범위에 정의된 다음 값이 차례로 들어가며 본문 코드가 실행됩니다.
Rust에는 다섯 가지 종류의 범위 표현이 있습니다.
1..5: 1부터 4까지 포함하는 범위입니다. 마지막 값인 5는 포함되지 않습니다. (반열린 범위)1..=5: 1부터 5까지 모두 포함하는 범위입니다. 마지막 값인 5도 포함됩니다. (닫힌 범위)1..: 1부터 해당 데이터 타입의 최대값까지 포함하는 무한 범위입니다...5: 해당 데이터 타입의 최소값부터 시작하여 4까지 포함하는 범위입니다. 5는 포함되지 않습니다...=5: 해당 데이터 타입의 최소값부터 시작하여 5까지 모두 포함하는 범위입니다.
시작값이 명시된 앞의 세 가지 범위는 for 루프에서 바로 사용할 수 있습니다. 나머지 두 가지는 나중에 다른 상황에서 쓰이는 것을 보게 될 것입니다.
참고로, 범위의 경계값에는 꼭 숫자 리터럴만 쓸 수 있는 것은 아닙니다. 변수나 표현식을 사용할 수도 있습니다.
let end = 5;
let mut sum = 0;
for i in 1..(end + 1) {
sum += i;
}
더 읽어보기
Exercise
The exercise for this section is located in 02_basic_calculator/07_for
-
과정의 뒷부분에서 “반복자“가 정확히 무엇인지 배우게 될 것입니다. 지금은 반복할 수 있는 값들이 모여 있는 것으로 생각하셔도 충분합니다. ↩
-
정수뿐만 아니라 문자(Characters)나 IP 주소 등 다른 타입들과 함께 범위를 쓸 수도 있습니다. 하지만 일상적인 프로그래밍에서는 정수 범위가 가장 흔하게 쓰입니다. ↩
오버플로우(Overflow)
팩토리얼 값은 숫자가 커질수록 아주 빠르게 증가합니다. 예를 들어, 20의 팩토리얼은 무려 2,432,902,008,176,640,000이나 되죠. 이 숫자는 이미 32비트 정수(i32)가 담을 수 있는 최대값인 2,147,483,647을 훌쩍 넘어버립니다.
산술 연산 결과가 해당 정수 타입이 담을 수 있는 최대값보다 커지는 상황을 **정수 오버플로우(Integer overflow)**라고 부릅니다.
정수 오버플로우는 프로그래밍 규칙을 위반하는 문제이기 때문에 주의해야 합니다. 컴퓨터 입장에서 어떤 정수 타입끼리 연산한 결과는 반드시 그 타입에 맞는 값이어야 하는데, 수학적으로는 맞는 답이 정해진 크기를 초과해서 담을 수 없게 되기 때문입니다.
반대로 결과가 해당 정수 타입의 최소값보다 작아지는 상황은 **정수 언더플로우(Integer underflow)**라고 합니다. 이 섹션에서는 편의상 오버플로우를 위주로 설명하겠지만, 모든 내용은 언더플로우에도 똑같이 적용된다는 사실을 기억해 주세요.
이전에 “변수” 섹션에서 작성했던
speed함수도 사실 일부 입력값에서 언더플로우가 발생할 가능성이 있었습니다. 만약end값이start보다 작다면end - start는 음수가 되어야 하는데, 우리가 썼던u32타입은 음수를 담을 수 없으므로 언더플로우가 발생하게 되죠.
자동 타입 승격은 없습니다
오버플로우 문제에 대응하는 한 가지 방법은 결과를 더 큰 타입으로 자동으로 바꿔주는 것입니다. 예를 들어 u8 타입 두 개를 더했는데 결과가 256(u8::MAX + 1)이 나왔다면, Rust가 알아서 더 큰 타입인 u16으로 결과를 처리해 줄 수도 있겠죠.
하지만 이미 살펴보았듯이, Rust는 타입 변환에 매우 엄격합니다. 따라서 이런 자동 승격은 Rust가 오버플로우 문제를 해결하는 방식이 아닙니다.
어떻게 처리할까요?
자동 승격을 하지 않는다면, 오버플로우가 났을 때 어떤 선택을 할 수 있을까요? 크게 두 가지 방향이 있습니다.
- 연산을 거부하기 (프로그램 중단)
- 어떻게든 정해진 타입 범위 안에서 “합리적인” 값을 내놓기
연산 거부 (패닉)
가장 보수적이고 안전한 방식입니다. 오버플로우가 발생하면 즉시 프로그램을 멈추는 것이죠. 앞서 “패닉” 섹션에서 보았던 패닉(Panic) 메커니즘이 바로 이럴 때 쓰입니다.
“합리적인” 값 내놓기 (래핑)
산술 연산 결과가 최대값을 넘었을 때, 마치 원형 궤도처럼 다시 최소값부터 시작하게 만드는 방식입니다. 이를 **래핑(Wrapping around)**이라고 합니다.
예를 들어, u8 타입에서 1과 255(u8::MAX)를 래핑 덧셈하면 결과는 0(u8::MIN)이 됩니다. 부호 있는 정수도 마찬가지입니다. i8 타입에서 127(i8::MAX)에 1을 더하면 -128(i8::MIN)이 됩니다.
overflow-checks 설정
Rust는 오버플로우 상황에서 어떤 방식으로 동작할지 개발자가 선택할 수 있게 해줍니다. 이 동작은 overflow-checks라는 프로필(Profile) 설정에 의해 결정됩니다.
overflow-checks가true이면, 오버플로우 발생 시 런타임에 패닉을 일으킵니다.overflow-checks가false이면, 오버플로우 발생 시 값을 래핑합니다.
그런데 ’프로필’이 무엇인지 궁금하시죠? 한번 자세히 알아볼까요?
프로필(Profiles)
프로필은 Rust 코드가 컴파일되는 방식을 결정하는 여러 옵션들의 모음입니다.
Cargo에는 크게 4가지 내장 프로필이 있습니다.
dev프로필:cargo build,cargo run,cargo test를 실행할 때 기본적으로 사용됩니다. 로컬 개발을 위한 용도이며, 런타임 성능보다는 컴파일 속도와 풍부한 디버깅 정보를 우선시합니다.release프로필: 실제 배포용(Production) 빌드를 위해 런타임 성능을 극한으로 최적화합니다. 대신 컴파일 시간은 훨씬 오래 걸립니다.--release플래그를 붙여야만 활성화됩니다. (예:cargo build --release)test프로필:cargo test에서 사용되며dev프로필의 설정을 그대로 물려받습니다.bench프로필: 성능 측정용인cargo bench에서 사용되며release프로필 설정을 물려받습니다.
Rust 커뮤니티에는 “릴리스 모드로 빌드하셨나요?“라는 유명한 밈(Meme)이 있습니다. Rust에 입문한 지 얼마 안 된 개발자가 소셜 미디어 등에 “Rust 성능이 왜 이렇게 안 나오죠?“라며 투덜대는데, 알고 보니
--release플래그 없이 빌드했던 상황을 비꼬는 표현이죠.
물론 기본 프로필 외에 나만의 설정을 추가하거나 기존 프로필 내용을 직접 수정할 수도 있습니다.
프로필별 오버플로우 체크 설정
기본적으로 overflow-checks는 다음과 같이 설정되어 있습니다.
dev프로필:truerelease프로필:false
각 프로필의 목적에 맞게 합리적으로 정해진 것이죠. dev는 개발 중에 문제를 빨리 찾을 수 있게 패닉을 일으키고, release는 오버플로우 체크로 인해 속도가 느려지는 것을 방지하기 위해 래핑을 허용합니다.
하지만 두 프로필의 동작이 서로 다르면, 개발할 때는 몰랐던 버그가 실제 서비스 중에 튀어나올 수도 있습니다. 그래서 저희는 가급적 두 프로필 모두에서 overflow-checks를 활성화(True)하는 것을 추천합니다. 잘못된 계산 결과를 조용히 남기는 것보다는, 차라리 프로그램이 죽는 것이 문제 해결에 훨씬 도움이 되기 때문입니다. 오버플로우 체크로 인한 성능 저하는 대부분의 경우 무시할 수 있는 수준이며, 정말 성능이 중요한 부분이라면 직접 벤치마크를 해보고 결정하면 됩니다.
더 읽어보기
- Rust의 정수 오버플로우에 대해 더 깊이 알고 싶다면 “Rust의 정수 오버플로우에 대한 신화와 전설”을 읽어보세요.
Exercise
The exercise for this section is located in 02_basic_calculator/08_overflow
상황에 따른 세밀한 제어
앞서 살펴본 overflow-checks 설정은 프로그램 전체에 영향을 미치는 다소 투박한 도구입니다. 하지만 실제 개발을 하다 보면, 어떤 상황에서는 래핑(Wrapping)이 정답이고, 또 어떤 때는 패닉(Panic)이 더 적절할 수 있습니다. 즉, 문맥에 따라 오버플로우를 다르게 처리하고 싶을 때가 많죠.
wrapping_ 메서드
특정 연산에서 오직 래핑 방식만 사용하고 싶다면 wrapping_ 메서드1를 쓰면 됩니다. 예를 들어 wrapping_add를 사용하면 다음과 같이 래핑 덧셈을 수행할 수 있습니다.
let x = 255u8;
let y = 1u8;
let sum = x.wrapping_add(y);
assert_eq!(sum, 0);
saturating_ 메서드
혹은 **포화 연산(Saturating arithmetic)**을 선택할 수도 있습니다. saturating_ 메서드를 사용하면 되는데요,
포화 연산은 값이 한계를 넘어섰을 때 다시 처음으로 돌아가는(래핑) 대신, 해당 타입의 최대값이나 최소값에서 멈추는 방식입니다.
예를 들어 다음과 같이 동작합니다.
let x = 255u8;
let y = 1u8;
let sum = x.saturating_add(y);
assert_eq!(sum, 255);
255 + 1은 원래 256이지만, u8 타입의 최대값인 255를 넘을 수 없으므로 결과는 255가 됩니다. 언더플로우의 경우도 마찬가지입니다. 0 - 1의 결과는 u8의 최소값인 0에서 멈추게 됩니다.
포화 연산은 프로필 설정(overflow-checks)으로는 지정할 수 없습니다. 따라서 포화 연산이 필요한 곳마다 직접 메서드를 호출해서 명시적으로 선택해 주어야 합니다.
Exercise
The exercise for this section is located in 02_basic_calculator/09_saturating
-
메서드(Method)는 특정 타입에 “연결된” 함수라고 생각하시면 됩니다. 메서드와 그 정의 방법은 다음 장에서 자세히 다룰 예정입니다. ↩
타입 변환(Type conversion) - 1부
지금까지 Rust가 정수 타입에 대해 암시적인 자동 변환을 해주지 않는다는 점을 여러 번 강조해 왔습니다. 그렇다면 개발자가 직접 하는 명시적 변환은 어떻게 할 수 있을까요?
as 연산자
as 키워드를 사용하여 정수 타입 간의 타입을 변환할 수 있습니다. 이 as 변환은 절대로 실패하지 않습니다.
예를 들어 다음과 같습니다.
let a: u32 = 10;
// `a`를 `u64` 타입으로 변환 let b = a as u64;
// 컴파일러가 타입을 충분히 추론할 수 있는 상황이라면
// 대상 타입 자리에 `_`를 쓸 수도 있습니다.
let c: u64 = a as _;
이 변환은 예상한 대로 안전하게 동작합니다. 모든 u32 값은 문제없이 u64의 범위 안에 포함되기 때문이죠.
값 잘림(Truncation)
하지만 변환 방향을 반대로 바꾸면 재미있는 상황이 벌어집니다.
// `u8`에 담기에는 너무 큰 숫자 let a: u16 = 255 + 1; // 256
let b = a as u8;
앞서 말했듯 as 변환은 실패하지 않으므로, 이 코드는 문제없이 실행됩니다. 그렇다면 b의 값은 무엇이 될까요? 더 큰 타입에서 작은 타입으로 변환할 때, Rust 컴파일러는 **값 잘림(Truncation)**을 수행합니다.
이게 무슨 뜻인지 이해하기 위해 256u16이 메모리에서 비트 시퀀스로 어떻게 저장되는지 봅시다.
0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0
| | |
+---------------+---------------+
상위 8비트 하위 8비트
이를 u8로 변환할 때, Rust 컴파일러는 상위 비트들을 버리고 마지막 하위 8비트만 남깁니다.
0 0 0 0 0 0 0 0
| |
+---------------+
하위 8비트
따라서 256 as u8은 0이 됩니다. 대부분의 경우 이런 결과는 우리가 의도한 상황이 아니겠죠. 실제로 Rust 컴파일러는 리터럴 값을 캐스팅할 때 명백한 잘림이 발생할 것 같으면 다음과 같이 경고하며 막으려고 노력합니다.
error: literal out of range for `i8`
|
4 | let a = 255 as i8;
| ^^^
|
= note: the literal `255` does not fit into the type `i8`
whose range is `-128..=127`
= help: consider using the type `u8` instead
= note: `#[deny(overflowing_literals)]` on by default
권장 사항
일반적으로 as 캐스팅은 매우 주의해서 사용해야 합니다. 가급적 더 작은 타입에서 큰 타입으로 변환할 때만 사용하세요. 만약 더 큰 타입에서 작은 타입으로 변환해야 한다면, 나중에 살펴볼 실패 가능한 변환 메커니즘을 활용하는 것이 훨씬 안전합니다.
한계
as 캐스팅의 단점은 예상치 못한 동작뿐만이 아닙니다. 이 방식은 상당히 제한적이어서 기본 타입이나 아주 특수한 몇몇 경우에만 사용할 수 있습니다. 앞으로 다룰 복합 타입이나 사용자 정의 타입을 다룰 때는 실패 가능한 변환이나 안전한 변환과 같은 다른 메커니즘을 주로 사용하게 될 것입니다.
더 읽어보기
- 각 타입 조합에 따른
as캐스팅의 상세 동작과 허용되는 모든 변환 목록은 Rust 공식 레퍼런스에서 확인할 수 있습니다.
Exercise
The exercise for this section is located in 02_basic_calculator/10_as_casting
티켓 모델링하기
1장에서는 Rust의 기초라고 할 수 있는 기본 타입과 연산자, 그리고 제어 흐름 구조를 살펴보았습니다. 이제 기초를 탄탄히 다지셨을 거예요.
이번 장에서는 한 걸음 더 나아가 Rust를 가장 특별하게 만드는 핵심 개념인 **소유권(Ownership)**을 본격적으로 다뤄보려 합니다. 소유권은 Rust가 가비지 컬렉터(Garbage Collector) 없이도 메모리 안전성과 뛰어난 성능을 동시에 확보할 수 있게 해주는 핵심 원리입니다.
학습을 위한 예제로는 소프트웨어 프로젝트에서 버그나 기능, 작업 등을 관리할 때 흔히 사용하는 ‘티켓(Ticket)’ 시스템을 만들어 볼 거예요. JIRA 같은 도구를 떠올리면 이해하기 쉬울 겁니다.
우리는 이 티켓 시스템을 Rust로 직접 모델링해 볼 텐데요, 이번이 그 첫 번째 단계입니다. 이 장을 마칠 때쯤의 코드가 완벽하거나 가장 Rust다운(Idiomatic) 방식은 아닐 수도 있지만, 핵심 개념들을 익히기에는 충분히 흥미로운 도전이 될 거예요!
이번 장에서 함께 배울 새로운 개념들은 다음과 같습니다:
struct(구조체): Rust에서 사용자 정의 타입을 정의하는 방법- 소유권(Ownership), 참조(Reference) 및 빌림(Borrowing)
- 메모리 관리: 스택(Stack), 힙(Heap), 포인터(Pointer), 데이터 레이아웃, 소멸자(Destructor)
- 모듈(Module) 및 가시성(Visibility)
- 문자열(String)
Exercise
The exercise for this section is located in 03_ticket_v1/00_intro
구조체(Struct)
각 티켓에 대해서는 세 가지 정보를 관리해야 합니다:
- 제목
- 설명
- 상태
이 정보들을 표현하기 위해 가장 먼저 String 타입을 떠올릴 수 있습니다. String은 Rust 표준 라이브러리에 정의된 타입으로, UTF-8 방식으로 인코딩된 텍스트를 담는 데 사용됩니다.
그런데 이 세 가지 정보를 하나의 덩어리, 즉 ’단일 엔티티(Entity)’로 묶으려면 어떻게 해야 할까요?
struct 정의하기
struct(구조체)를 사용하면 Rust에서 새로운 타입을 직접 정의할 수 있습니다.
struct Ticket {
title: String,
description: String,
status: String
}
구조체는 다른 프로그래밍 언어에서 흔히 접하는 클래스(Class)나 객체(Object)와 매우 비슷한 개념이라고 생각하시면 됩니다.
필드(Field) 정의하기
새로운 타입은 여러 다른 타입들을 **필드(Field)**로 결합하여 만들어집니다. 각 필드는 이름과 타입을 가져야 하며, 그 사이를 콜론 :으로 구분합니다. 필드가 여러 개라면 쉼표 ,로 각각을 나열합니다.
아래 Configuration 구조체 예시처럼, 모든 필드가 같은 타입일 필요는 없습니다:
struct Configuration {
version: u32,
active: bool
}
인스턴스화(Instantiation)
각 필드에 구체적인 값을 지정해 주면 구조체의 인스턴스(Instance)를 생성할 수 있습니다:
// 구문: <구조체이름> { <필드_이름>: <값>, ... }
let ticket = Ticket {
title: "Build a ticket system".into(),
description: "A Kanban board".into(),
status: "Open".into()
};
필드에 접근하기
마침표 . 연산자를 이용하면 구조체의 특정 필드 값에 접근할 수 있습니다:
// 필드 접근 let x = ticket.description;
메서드(Method)
**메서드(Method)**를 정의하면 구조체에 특정한 동작을 부여할 수 있습니다. Ticket 구조체를 예로 들어 보겠습니다:
impl Ticket {
fn is_open(self) -> bool {
self.status == "Open"
}
}
// 구문:
// impl <구조체이름> {
// fn <메서드_이름>(<매개변수>) -> <반환_타입> {
// // 메서드 본문
// }
// }
메서드는 함수와 매우 비슷하지만, 다음과 같은 두 가지 큰 차이점이 있습니다:
- 메서드는 반드시
impl블록 안에 정의해야 합니다. - 메서드는 첫 번째 매개변수로
self를 사용할 수 있습니다.self는 예약어(Keyword)로, 해당 메서드가 호출되는 구조체의 인스턴스 자신을 가리킵니다.
self
메서드가 첫 번째 매개변수로 self를 사용하는 경우, 메서드 호출 구문을 사용하여 호출할 수 있습니다:
// 메서드 호출 구문: <인스턴스>.<메서드_이름>(<매개변수>)
let is_open = ticket.is_open();
이것은 이전 장에서 u32 값에 대해 포화 산술 연산을 수행할 때 사용했던 것과 동일한 호출 방식입니다.
정적 메서드(Static Method)
만약 메서드의 첫 번째 매개변수가 self가 아니라면, 이를 **정적 메서드(Static Method)**라고 부릅니다.
struct Configuration {
version: u32,
active: bool
}
impl Configuration {
// `default`는 `Configuration`의 정적 메서드입니다.
fn default() -> Configuration {
Configuration { version: 0, active: false }
}
}
정적 메서드는 함수 호출 구문을 통해서만 호출할 수 있습니다:
// 함수 호출 구문: <구조체이름>::<메서드_이름>(<매개변수>)
let default_config = Configuration::default();
호출 방식의 유연성
첫 번째 매개변수로 self를 사용하는 일반 메서드 역시 함수 호출 구문으로 호출할 수 있습니다:
// 함수 호출 구문:
// <구조체이름>::<메서드_이름>(<인스턴스>, <매개변수>)
let is_open = Ticket::is_open(ticket);
함수 호출 구문을 쓰면 ticket이 메서드의 첫 번째 매개변수인 self로 전달된다는 점이 명확히 드러나지만, 코드가 다소 길어집니다. 따라서 특별한 이유가 없다면 메서드 호출 구문을 사용하는 것이 일반적입니다.
Exercise
The exercise for this section is located in 03_ticket_v1/01_struct
유효성 검사(Validation)
앞서 정의했던 티켓 구조체를 다시 살펴봅시다:
struct Ticket {
title: String,
description: String,
status: String,
}
현재 Ticket 구조체의 필드들은 모두 ‘기본(Raw)’ 타입을 사용하고 있습니다. 이대로라면 누군가 제목을 비워두거나, 설명이 감당할 수 없을 정도로 길어지거나, 혹은 “Funny“처럼 티켓 시스템에 어울리지 않는 상태값을 넣는 것을 막을 수 없습니다.
우리는 이보다 훨씬 더 꼼꼼하게 데이터를 관리할 수 있습니다!
참고 자료
String공식 문서에서 제공하는 다양한 메서드들을 꼼꼼히 살펴보세요. 이어지는 연습 문제를 풀 때 큰 도움이 될 겁니다!
Exercise
The exercise for this section is located in 03_ticket_v1/02_validation
모듈(Module)
방금 정의한 new 메서드는 Ticket의 필드 값들에 몇 가지 제약 조건을 걸려고 합니다. 하지만 이 규칙(Invariants)이 정말로 엄격하게 지켜질까요? 다른 개발자가 Ticket::new를 통하지 않고 마음대로 Ticket 인스턴스를 만드는 걸 어떻게 막을 수 있을까요?
데이터를 안전하게 보호하는 **캡슐화(Encapsulation)**를 제대로 구현하려면, **가시성(Visibility)**과 **모듈(Module)**이라는 두 가지 핵심 개념을 꼭 알아야 합니다. 먼저 모듈부터 시작해 봅시다.
모듈(Module)이란 무엇인가요?
Rust에서 모듈은 서로 관련된 코드들을 하나의 이름표(네임스페이스, Namespace) 아래로 묶어 관리하는 방법입니다. 이미 모듈을 사용해 본 적이 있을 거예요. 코드의 동작을 확인하는 단위 테스트들을 tests라는 별도의 모듈에 담아두었으니까요.
#[cfg(test)]
mod tests {
// [...]
}
인라인 모듈(Inline Module)
위의 tests 모듈은 인라인 모듈의 예시입니다. 모듈 선언(mod tests)과 모듈의 내용({ ... } 안의 코드)이 한 파일에 나란히 붙어 있죠.
모듈 트리(Module Tree)
모듈은 서로 중첩되어 트리(Tree) 구조를 형성할 수 있습니다. 트리의 뿌리(Root)는 크레이트(Crate) 그 자체이며, 다른 모든 모듈을 품고 있는 최상위 모듈이 됩니다. 라이브러리 크레이트라면 보통 src/lib.rs 파일이 루트 모듈(혹은 크레이트 루트) 역할을 합니다.
크레이트 루트는 하위 모듈을 가질 수 있고, 그 하위 모듈은 또 자신만의 하위 모듈을 가질 수 있습니다.
외부 모듈과 파일 시스템
인라인 모듈은 코드가 짧을 때는 편하지만, 프로젝트가 커지면 코드를 여러 파일로 나누어 관리하는 것이 좋습니다. 이때 부모 모듈에서 mod 키워드를 사용해 하위 모듈이 존재함을 선언합니다.
mod dog;
Rust의 빌드 도구인 cargo는 선언된 모듈의 실제 코드가 어디 있는지 찾아내는 역할을 합니다. 만약 모듈이 크레이트 루트(src/lib.rs나 src/main.rs)에 선언되었다면, cargo는 다음 위치에서 파일을 찾습니다:
src/<모듈_이름>.rssrc/<모듈_이름>/mod.rs
만약 다른 모듈 안에 속한 하위 모듈이라면 경로는 다음과 같아집니다:
[..]/<부모_모듈>/<모듈_이름>.rs[..]/<부모_모듈>/<모듈_이름>/mod.rs
예를 들어 dog가 animals의 하위 모듈이라면, 파일 위치는 src/animals/dog.rs 또는 src/animals/dog/mod.rs가 됩니다. 요즘 IDE들은 mod 키워드로 새 모듈을 선언할 때 이런 파일들을 자동으로 만들어주기도 해서 아주 편리합니다.
아이템 경로(Item Path)와 use 문
같은 모듈 안에 있는 아이템(구조체, 함수 등)은 복잡한 주소 없이 이름만으로 바로 부를 수 있습니다.
struct Ticket {
// [...]
}
// 같은 모듈 안에 있으므로 `Ticket` 앞에 아무것도 붙일 필요가 없습니다.
fn mark_ticket_as_done(ticket: Ticket) {
// [...]
}
하지만 다른 모듈에 있는 엔티티에 접근할 때는 그 엔티티가 어디에 있는지 알려주는 **경로(Path)**를 명시해야 합니다. 경로를 지정하는 방법은 몇 가지가 있습니다:
- 현재 크레이트의 루트부터 시작: 예)
crate::module_1::MyStruct - 부모 모듈부터 시작: 예)
super::my_function - 현재 모듈의 하위 모듈부터 시작: 예)
sub_module_1::MyStruct
여기서 crate와 super는 예약된 키워드입니다. crate는 현재 크레이트의 뿌리를, super는 현재 모듈의 부모를 가리킵니다.
매번 이렇게 긴 경로를 다 적으려면 참 번거롭겠죠? 이럴 때 use 문을 사용하면 특정 엔티티를 현재 범위(Scope)로 가져와서 이름만으로 편하게 쓸 수 있습니다.
// `MyStruct`를 현재 범위로 가져옵니다.
use crate::module_1::module_2::MyStruct;
// 이제 `MyStruct`를 직접 부를 수 있습니다.
fn a_function(s: MyStruct) {
// [...]
}
와일드카드(Wildcard) 가져오기
use 문 하나로 모듈 안의 모든 항목을 한꺼번에 가져올 수도 있습니다.
use crate::module_1::module_2::*;
이를 와일드카드 가져오기(Glob Import) 또는 별표 가져오기라고 부릅니다. 하지만 이 방식은 현재 네임스페이스를 지저분하게 만들고, 이름이 어디서 왔는지 헷갈리게 하며, 이름 충돌을 일으킬 위험이 있어 꼭 필요한 경우가 아니라면 피하는 것이 좋습니다. 다만 테스트 모듈에서 부모 모듈의 모든 내용을 가져오기 위해 use super::*;를 사용하는 것은 흔히 볼 수 있는 패턴입니다.
모듈 트리 시각화하기
내 프로젝트의 모듈 구조가 한눈에 들어오지 않는다면, cargo-modules 같은 도구를 사용해 시각화해 보는 것도 좋은 방법입니다! 설치 및 사용 방법은 해당 도구의 문서를 참고해 보세요.
Exercise
The exercise for this section is located in 03_ticket_v1/03_modules
가시성(Visibility)
코드를 여러 모듈로 나누어 관리하기 시작하면, 자연스럽게 **가시성(Visibility)**이라는 개념을 고민하게 됩니다. 가시성이란 구조체, 함수, 필드 같은 요소들에 누가, 어디까지 접근할 수 있는지를 결정하는 규칙입니다.
기본 설정은 ‘비공개(Private)’
Rust에서는 기본적으로 모든 것이 비공개(Private) 상태입니다. 비공개 상태인 요소들은 오직 다음 범위에서만 접근할 수 있습니다:
- 해당 요소가 정의된 모듈 내부
- 해당 모듈의 하위 모듈들
우리는 이미 이전 연습 문제들을 통해 이 규칙을 경험해 보았습니다.
create_todo_ticket함수가 작동했던 이유는, 이 함수가 포함된helpers모듈이Ticket이 정의된 크레이트 루트의 하위 모듈이었기 때문입니다. 그래서Ticket이 비공개였음에도 불구하고 문제없이 접근할 수 있었던 거죠.- 마찬가지로 단위 테스트들도 보통 테스트하려는 코드의 하위 모듈로 정의되기 때문에, 제약 없이 모든 요소에 접근할 수 있습니다.
가시성 한정자(Visibility Modifier)
**가시성 한정자(Visibility Modifier)**를 사용하면 이러한 기본 비공개 설정을 바꿀 수 있습니다. 자주 쓰이는 한정자들은 다음과 같습니다:
pub: 해당 요소를 **공개(Public)**로 설정합니다. 모듈 밖은 물론, 다른 크레이트에서도 접근할 수 있게 됩니다.pub(crate): 같은 크레이트 안에서는 자유롭게 접근할 수 있지만, 크레이트 밖으로는 노출하지 않습니다.pub(super): 부모 모듈 안에서만 접근할 수 있도록 공개 범위를 제한합니다.pub(in path::to::module): 지정한 특정 모듈 내부에서만 접근할 수 있게 합니다.
이 한정자들은 모듈, 구조체, 함수, 필드 등 어디에나 붙일 수 있습니다. 예를 하나 볼까요?
pub struct Configuration {
pub(crate) version: u32,
active: bool,
}
여기서 Configuration 구조체 자체는 공개(pub)입니다. 하지만 version 필드는 같은 크레이트 안에서만 볼 수 있고(pub(crate)), active 필드는 한정자가 없으므로 비공개입니다. 따라서 active 필드는 이 구조체가 정의된 모듈 혹은 그 하위 모듈 안에서만 접근할 수 있습니다.
Exercise
The exercise for this section is located in 03_ticket_v1/04_visibility
캡슐화(Encapsulation)
모듈과 가시성에 대해 기본적으로 이해했으니, 이제 다시 캡슐화(Encapsulation) 이야기로 돌아와 봅시다. 캡슐화란 객체의 내부 구현이나 데이터를 외부로부터 숨기는 기법입니다. 주로 객체의 상태가 항상 올바른 규칙(불변성, Invariants)을 따르도록 강제할 때 사용하죠.
우리의 Ticket 구조체를 다시 봅시다:
struct Ticket {
title: String,
description: String,
status: String,
}
만약 모든 필드가 공개(pub)되어 있다면 캡슐화가 전혀 이루어지지 않은 상태입니다. 외부에서 언제든 필드 값을 바꿀 수 있고, 그 타입이 허용하는 어떤 값(예: 빈 문자열)이든 들어올 수 있다고 가정해야 하니까요. 결국 제목이 비어 있거나 상태값이 엉망인 티켓이 만들어지는 걸 막을 길이 없습니다.
더 엄격한 규칙을 적용하고 싶다면 필드를 **비공개(Private)**로 유지해야 합니다1. 대신 Ticket 인스턴스와 상호작용할 수 있는 공개 메서드를 제공하는 거죠. 이 메서드들은 “제목은 절대 비워둘 수 없다” 같은 규칙(불변성)을 철저히 검증하고 지켜낼 책임이 있습니다.
필드 중 하나라도 비공개라면, 외부 모듈에서는 구조체 인스턴스화 구문을 사용해 직접 Ticket을 만들 수 없게 됩니다:
// 외부 모듈에서는 작동하지 않습니다!
let ticket = Ticket {
title: "Build a ticket system".into(),
description: "A Kanban board".into(),
status: "Open".into()
};
우리는 이제 하나 이상의 **공개 생성자(Constructor)**를 제공해야 합니다. 모듈 외부에서 구조체의 새 인스턴스를 안전하게 생성할 수 있도록 도와주는 정적 메서드나 함수를 말하죠. 다행히 우리는 이미 하나 준비해 두었습니다. 바로 앞선 연습 문제에서 구현했던 Ticket::new 메서드입니다.
접근자 메서드(Getter)
정리하자면 다음과 같습니다:
Ticket의 모든 필드는 비공개입니다.- 객체 생성 시 유효성 검사를 수행하는 공개 생성자
Ticket::new를 제공합니다.
좋은 시작이네요! 하지만 이것만으로는 부족합니다. 티켓을 만들기만 하는 게 아니라, 그 안에 든 내용을 읽거나 활용해야 하니까요. 그런데 필드가 비공개라면 어떻게 그 값에 접근할 수 있을까요?
이럴 때 바로 접근자 메서드(Accessor Method), 흔히 말하는 **게터(Getter)**가 필요합니다. 게터는 비공개 필드의 값을 외부에서 읽을 수 있게 해주는 공개 메서드입니다. Rust에는 다른 언어들처럼 게터를 자동으로 만들어주는 내장 기능은 없습니다. 하지만 걱정 마세요, 그냥 평범한 메서드를 직접 작성하면 되니까요.
Exercise
The exercise for this section is located in 03_ticket_v1/05_encapsulation
소유권(Ownership)
지금까지 배운 내용을 바탕으로 이전 연습 문제를 풀었다면, 아마도 접근자 메서드를 다음과 같이 작성하셨을 겁니다:
impl Ticket {
pub fn title(self) -> String {
self.title
}
pub fn description(self) -> String {
self.description
}
pub fn status(self) -> String {
self.status
}
}
이 코드는 컴파일도 잘 되고 테스트도 통과하겠지만, 실제 상황에서 쓰기에는 큰 문제가 하나 있습니다. 다음 코드를 한번 볼까요?
if ticket.status() == "To-Do" {
// `println!` 매크로는 아직 정식으로 배우지 않았지만,
// 지금은 콘솔 창에 메시지를 출력하는 도구라고만 이해하셔도 충분합니다.
println!("Your next task is: {}", ticket.title());
}
이 코드를 컴파일하려고 하면 다음과 같은 에러가 발생할 거예요:
error[E0382]: use of moved value: `ticket`
--> src/main.rs:30:43
|
25 | let ticket = Ticket::new(/* */);
| ------ move occurs because `ticket` has type `Ticket`,
| which does not implement the `Copy` trait
26 | if ticket.status() == "To-Do" {
| -------- `ticket` moved due to this method call
...
30 | println!("Your next task is: {}", ticket.title());
| ^^^^^^
| value used here after move
|
note: `Ticket::status` takes ownership of the receiver `self`,
which moves `ticket`
--> src/main.rs:12:23
|
12 | pub fn status(self) -> String {
| ^^^^
축하드립니다! 여러분의 첫 번째 빌림 검사기(Borrow Checker) 에러를 마주하셨네요.
Rust 소유권 시스템의 강력함
Rust의 소유권(Ownership) 시스템은 다음 세 가지를 철저히 보장하도록 설계되었습니다:
- 데이터를 읽는 중에는 절대 값이 변하지 않습니다.
- 데이터를 변경하는 중에는 절대 값을 읽을 수 없습니다.
- 데이터가 메모리에서 사라진 후에는 절대 접근할 수 없습니다.
이 엄격한 규칙들은 Rust 컴파일러의 핵심 부품인 **빌림 검사기(Borrow Checker)**가 감시합니다. 가끔은 너무 깐깐해서 Rust 개발자들 사이에서 농담이나 밈의 소재가 되기도 하죠.
소유권은 Rust를 다른 언어와 차별화하는 가장 핵심적인 개념입니다. 덕분에 Rust는 성능을 희생하지 않으면서도 메모리 안전성을 확보할 수 있습니다. Rust에서는 다음 사항들이 모두 동시에 가능합니다:
- 런타임에 작동하는 가비지 컬렉터(Garbage Collector)가 필요 없습니다.
- 개발자가 직접 메모리를 할당하고 해제하는 수고를 덜어줍니다.
- 댕글링 포인터(Dangling Pointer)나 이중 해제(Double Free) 같은 고질적인 메모리 버그를 원천 차단합니다.
Python, JavaScript, Java 같은 언어는 2번과 3번의 편의성을 주지만 1번(성능)을 포기해야 합니다. 반면 C나 C++은 1번의 성능은 챙겼지만 2번과 3번의 위험을 개발자가 온전히 떠안아야 하죠.
혹시 3번에 나온 용어들이 낯설게 느껴지시나요? ’댕글링 포인터’가 뭐지? ’이중 해제’는 왜 위험할까? 걱정 마세요. 앞으로 차근차근 자세히 배우게 될 테니까요. 지금은 우선 Rust의 소유권 시스템 안에서 어떻게 코드를 짜는지 익히는 데 집중해 봅시다.
소유자(Owner)
Rust에서 모든 값에는 컴파일 시점에 결정되는 **소유자(Owner)**가 반드시 존재합니다. 그리고 어떤 순간에도 소유자는 오직 단 한 명뿐입니다.
이동 의미론(Move Semantics)
소유권은 다른 곳으로 넘겨줄 수 있습니다. 예를 들어, 내가 가진 값의 소유권을 다른 변수에게 넘겨줄 수 있죠:
let a = "hello, world".to_string(); // <- `a`가 String의 소유자입니다.
let b = a; // <- 이제 `b`가 String의 소유자입니다.
Rust의 소유권 시스템은 타입 시스템에 녹아 있습니다. 모든 함수는 자신이 전달받은 인자와 어떻게 상호작용할지 시그니처를 통해 명시해야 합니다.
지금까지 우리가 작성한 메서드와 함수들은 인자를 **소비(Consume)**해 왔습니다. 즉, 인자의 소유권을 완전히 가져가 버린 거죠. 예를 들어 볼까요?
impl Ticket {
pub fn description(self) -> String {
self.description
}
}
Ticket::description은 호출된 Ticket 인스턴스의 소유권을 가져갑니다. 이를 **이동 의미론(Move Semantics)**이라고 부릅니다. 값(self)의 소유권이 호출한 쪽에서 함수 안으로 **이동(Move)**하기 때문에, 함수를 부른 쪽에서는 더 이상 그 값을 쓸 수 없게 됩니다.
앞서 보았던 에러 메시지가 바로 이 상황을 설명하고 있습니다:
error[E0382]: use of moved value: `ticket`
--> src/main.rs:30:43
|
25 | let ticket = Ticket::new(/* */);
| ------ move occurs because `ticket` has type `Ticket`,
| which does not implement the `Copy` trait
26 | if ticket.status() == "To-Do" {
| -------- `ticket` moved due to this method call
...
30 | println!("Your next task is: {}", ticket.title());
| ^^^^^^
| value used here after move
|
note: `Ticket::status` takes ownership of the receiver `self`,
which moves `ticket`
--> src/main.rs:12:23
|
12 | pub fn status(self) -> String {
| ^^^^
구체적으로 ticket.status()를 호출할 때 어떤 일이 일어나는지 살펴볼까요?
Ticket::status가Ticket인스턴스의 소유권을 가져갑니다.Ticket::status는self에서status필드만 쏙 빼내어 그 소유권을 다시 호출한 쪽으로 돌려줍니다.- 이때 남겨진
Ticket인스턴스의 나머지 부분(title,description)은 메모리에서 사라지게(버려지게) 됩니다.
그래서 그 다음에 ticket.title()을 불러 ticket을 다시 쓰려고 하면 컴파일러가 화를 내는 겁니다. ticket이라는 값은 이미 분해되어 사라졌고, 소유권도 없으니 더 이상 쓸 수 없다는 거죠.
정말로 쓸모 있는 접근자 메서드를 만들려면, 이제 **참조(Reference)**를 배워야 합니다.
빌림(Borrowing)
매번 소유권을 뺏어가는 게 아니라, 잠시 값을 빌려서 읽기만 할 수 있다면 얼마나 좋을까요? 그렇지 않으면 코딩하기가 너무 힘들겠죠. Rust에서는 이를 **빌림(Borrowing)**이라고 부릅니다.
값을 빌려올 때마다 우리는 그 값에 대한 **참조(Reference)**를 얻게 됩니다. 참조에는 두 가지 종류의 권한이 따라붙습니다1:
- 불변 참조(
&): 값을 읽을 수는 있지만, 수정할 수는 없습니다. - 가변 참조(
&mut): 값을 읽을 수도 있고, 수정할 수도 있습니다.
Rust 소유권 시스템의 대원칙을 다시 떠올려 봅시다:
- 데이터를 읽는 중에는 절대 값이 변하지 않습니다.
- 데이터를 변경하는 중에는 절대 값을 읽을 수 없습니다.
이 두 가지 원칙을 지키기 위해 Rust는 참조 사용에 몇 가지 제약을 둡니다:
- 한 값에 대해 가변 참조와 불변 참조를 동시에 가질 수 없습니다.
- 한 값에 대해 가변 참조를 두 개 이상 동시에 가질 수 없습니다.
- 누군가 값을 빌려 가 쓰고 있는 동안(참조가 활성 상태일 때) 소유자는 값을 변경할 수 없습니다.
- 가변 참조가 없다면, 불변 참조는 원하는 만큼 마음껏 만들 수 있습니다.
불변 참조는 ‘읽기 전용(Read-only) 잠금’, 가변 참조는 ’읽기-쓰기(Read-Write) 잠금’과 비슷하다고 생각하면 이해가 빠르실 겁니다. 이 모든 제약 사항은 컴파일 시점에 빌림 검사기에 의해 엄격히 관리됩니다.
구문(Syntax)
그럼 실제로 어떻게 빌려올까요? 방법은 간단합니다. 변수 이름 앞에 &나 &mut를 붙여주면 됩니다. 여기서 주의할 점! 타입 이름 앞에 붙는 &와 &mut는 조금 다른 의미를 가집니다. 이는 ’어떤 타입에 대한 참조’라는 새로운 타입을 나타내는 표시거든요.
예를 들어 보겠습니다:
struct Configuration {
version: u32,
active: bool,
}
fn main() {
let config = Configuration {
version: 1,
active: true,
};
// `b`는 `config`의 `version` 필드에 대한 참조입니다.
// `b`의 타입은 `&u32`가 됩니다. `u32` 값에 대한 참조를 담고 있기 때문이죠.
// `&` 연산자를 변수 앞에 붙여서 `config.version`을 빌려와 참조를 만듭니다.
// 똑같은 `&` 기호지만, 문맥에 따라 의미가 달라진다는 점에 유의하세요!
let b: &u32 = &config.version;
}
이 개념은 함수의 인자나 반환 타입에도 똑같이 적용됩니다:
// `f` 함수는 `number`라는 이름의 `u32` 타입에 대한 '가변 참조'를 인자로 받습니다.
fn f(number: &mut u32) -> &u32 {
// [...]
}
한숨 돌리고 가실까요?
Rust의 소유권 시스템을 처음 접하면 조금 당황스러울 수 있습니다. 하지만 너무 걱정하지 마세요! 연습하다 보면 어느새 숨 쉬듯 자연스럽게 익숙해질 테니까요. 이번 장의 나머지 부분과 전체 과정에서 충분히 연습할 기회를 드릴 겁니다.
이 장의 마지막 부분에서 왜 Rust가 이렇게 설계되었는지 그 이유를 설명해 드릴게요. 지금은 우선 ’어떻게 사용하는지’에 집중해 봅시다. 컴파일 에러가 날 때마다 실력이 쑥쑥 늘어날 거예요!
Exercise
The exercise for this section is located in 03_ticket_v1/06_ownership
설정자 메서드(Setter)
이제 접근자 메서드는 아마 이런 모습이겠죠:
impl Ticket {
pub fn title(&self) -> &String {
&self.title
}
pub fn description(&self) -> &String {
&self.description
}
pub fn status(&self) -> &String {
&self.status
}
}
코드 곳곳에 참조 기호(&)를 더해주니 문제가 깔끔하게 해결되었습니다! 이제 Ticket 인스턴스를 통째로 써버리지 않고도 안전하게 필드 값에 접근할 수 있게 되었네요.
그럼 이제 한 걸음 더 나아가, Ticket 구조체에 값을 수정하는 **설정자 메서드(Setter)**를 추가해 볼까요?
설정자 메서드(Setter)
설정자 메서드(Setter)를 이용하면, 비공개 필드 값을 외부에서 수정할 수 있게 하면서도 우리가 정한 규칙(불변성)을 엄격히 지킬 수 있습니다. 예를 들어, 제목을 빈 문자열로 바꾸려는 시도를 막을 수 있죠.
Rust에서 설정자(Setter)를 구현하는 데는 보통 두 가지 방법이 쓰입니다:
self를 직접 인자로 받기&mut self(가변 참조)를 인자로 받기
1. self를 직접 인자로 받기
첫 번째 방식은 다음과 같습니다:
impl Ticket {
pub fn set_title(mut self, new_title: String) -> Self {
// 새 제목 유효성 검사 [...]
self.title = new_title;
self
}
}
이 방식은 self의 소유권을 완전히 가져와서 값을 수정한 뒤, 다시 수정된 Ticket 인스턴스를 반환합니다. 실제 사용법은 이렇습니다:
let ticket = Ticket::new(
"Title".into(),
"Description".into(),
"To-Do".into()
);
let ticket = ticket.set_title("New title".into());
set_title 메서드가 호출될 때 self의 소유권을 가져가기(소비하기) 때문에, 결과물을 반드시 변수에 다시 대입해 줘야 합니다. 위 예시에서는 변수 섀도잉(Variable Shadowing) 기법을 썼네요. 기존 변수와 똑같은 이름으로 새 변수를 선언해서 이전 변수를 ‘가리는’ 방식인데, Rust에서는 아주 흔히 볼 수 있는 패턴입니다.
self를 사용하는 설정자는 여러 필드를 한꺼번에 바꿀 때 빛을 발합니다. 메서드 호출을 줄줄이 엮는 **메서드 체이닝(Method Chaining)**이 가능하거든요!
let ticket = ticket
.set_title("New title".into())
.set_description("New description".into())
.set_status("In Progress".into());
2. &mut self(가변 참조)를 인자로 받기
두 번째 방식인 &mut self를 사용하는 경우를 볼까요?
impl Ticket {
pub fn set_title(&mut self, new_title: String) {
// 새 제목 유효성 검사 [...]
self.title = new_title;
}
}
이번에는 메서드가 self에 대한 가변 참조를 받아서 내부 값을 직접 수정합니다. 별도의 반환 값은 없죠.
사용 방법은 이렇습니다:
let mut ticket = Ticket::new(
"Title".into(),
"Description".into(),
"To-Do".into()
);
ticket.set_title("New title".into());
// 수정된 티켓을 그대로 사용합니다.
소유권이 여전히 호출한 쪽에 남아있기 때문에 원래 ticket 변수를 그대로 쓸 수 있습니다. 결과를 다시 대입할 필요도 없고요. 다만, ticket 내부를 수정해야 하므로 변수를 선언할 때 let mut으로 가변임을 명시해야 합니다.
&mut self를 쓰는 방식에도 아쉬운 점은 있습니다. 바로 메서드 체이닝이 안 된다는 거죠. 수정된 인스턴스를 반환하지 않기 때문에, 아래처럼 각 메서드를 하나씩 따로 불러줘야 합니다:
ticket.set_title("New title".into());
ticket.set_description("New description".into());
ticket.set_status("In Progress".into());
Exercise
The exercise for this section is located in 03_ticket_v1/07_setters
메모리 레이아웃: 스택(Stack)
지금까지는 소유권과 참조가 겉으로 어떻게 작동하는지, 즉 ’무엇을 할 수 있고 없는지’를 중심으로 살펴보았습니다. 이제는 한 걸음 더 들어가 속을 들여다볼 차례입니다. 바로 메모리(Memory) 이야기입니다.
스택(Stack)과 힙(Heap)
컴퓨터 구조나 프로그래밍을 공부하다 보면 **스택(Stack)**과 **힙(Heap)**이라는 말을 자주 듣게 됩니다. 이들은 프로그램이 데이터를 저장하기 위해 사용하는 서로 다른 성격의 메모리 공간입니다.
먼저 스택부터 알아봅시다.
스택(Stack)
스택은 LIFO(Last In, First Out, 후입선출) 구조를 가진 데이터 저장소입니다. 함수를 호출하면 스택 맨 위에 새로운 **스택 프레임(Stack Frame)**이 쌓이는데, 여기에는 함수의 인자, 지역 변수, 그리고 관리를 위한 정보들이 담깁니다. 함수 실행이 끝나고 값을 반환하면, 해당 스택 프레임은 스택에서 깔끔하게 제거됩니다1.
+-----------------+
| func1용 프레임 |
+-----------------+
|
| func2가
| 호출됨
v
+-----------------+
| func2용 프레임 |
+-----------------+
| func1용 프레임 |
+-----------------+
|
| func2가
| 반환됨
v
+-----------------+
| func1용 프레임 |
+-----------------+
성능 면에서 스택의 메모리 할당과 해제는 매우 빠릅니다. 항상 스택의 맨 위에서 데이터를 넣고(Push) 빼기(Pop) 때문에, 빈 메모리 공간을 찾아 헤맬 필요가 없거든요. 또한 메모리가 조각조각 나뉘는 ‘단편화(Fragmentation)’ 걱정도 없습니다. 스택은 하나의 연속된 메모리 덩어리니까요.
Rust에서의 스택 활용
Rust는 데이터를 스택에 자주 할당합니다. 함수 인자로 u32를 쓰시나요? 그 32비트는 스택에 들어갑니다. i64 타입의 지역 변수를 만드셨나요? 그 64비트 역시 스택 차지입니다. 이런 타입들은 크기가 미리 정해져 있어서, 컴파일 시점에 스택 공간을 얼마나 준비해야 할지 정확히 알 수 있기 때문에 아주 효율적으로 작동합니다.
std::mem::size_of 함수
std::mem::size_of 함수를 사용하면 특정 타입이 스택에서 차지하는 실제 크기를 바이트 단위로 확인할 수 있습니다.
예를 들어 u8 타입의 경우:
// 이 생소한 모양의 구문(`::<u8>`)은 나중에 자세히 설명해 드릴게요.
// 지금은 '아, 이런 게 있구나' 하고 넘어가셔도 괜찮습니다.
assert_eq!(std::mem::size_of::<u8>(), 1);
u8은 8비트, 즉 1바이트이므로 결과값이 1이 나오는 것이 당연하겠죠?
Exercise
The exercise for this section is located in 03_ticket_v1/08_stack
-
함수가 너무 깊게 중첩되어 호출되면, 스택 프레임이 계속 쌓이다가 결국 준비된 메모리 공간을 넘쳐버릴 수 있습니다. 이를 스택 오버플로(Stack Overflow)라고 부릅니다. ↩
힙 (Heap)
스택은 매우 효율적이지만 모든 상황을 해결해 주지는 못합니다. 컴파일 시점에 데이터의 크기를 미리 알 수 없는 경우는 어떻게 해야 할까요? 컬렉션, 문자열, 그리고 실행 중에 크기가 변하는 동적 데이터들은 스택에 통째로 할당할 수 없습니다. 바로 이때 **힙(Heap)**이 등장합니다.
힙 할당 (Heap Allocation)
힙은 아주 커다란 메모리 덩어리, 혹은 거대한 배열이라고 생각하면 이해하기 쉽습니다. 힙에 데이터를 저장하고 싶을 때는 **할당자(Allocator)**라는 특별한 프로그램에 필요한 만큼의 공간을 예약해달라고 요청해야 합니다. 이 과정을 힙 할당이라고 부릅니다. 할당이 성공하면 할당자는 예약된 메모리 블록의 시작 위치를 가리키는 **포인터(Pointer)**를 돌려줍니다.
수동 관리의 필요성
힙은 스택과 달리 데이터가 차곡차곡 쌓이지 않습니다. 대신 빈 공간이라면 어디든 위치할 수 있죠.
+---+---+---+---+---+---+-...-+-...-+---+---+---+---+---+---+---+
| 할당 1 | 여유 | ... | ... | 할당 N | 여유 |
+---+---+---+---+---+---+ ... + ... +---+---+---+---+---+---+---+
힙의 어느 부분이 사용 중이고 어디가 비어 있는지 관리하는 것은 할당자의 역할입니다. 하지만 할당자가 메모리를 다 썼다고 해서 자동으로 치워주지는 않습니다. 더 이상 필요 없는 메모리는 할당자를 다시 호출하여 명시적으로 **해제(Free)**해야 합니다.
성능 (Performance)
힙의 유연함에는 대가가 따릅니다. 힙 할당은 스택 할당보다 훨씬 느립니다. 메모리를 관리하는 데 드는 부수적인 작업(오버헤드)이 훨씬 많기 때문이죠. 성능 최적화 가이드에서 “힙 할당을 최소화하고 가능하면 스택을 활용하라“는 조언을 자주 보게 되는 이유가 바로 이것입니다.
String의 메모리 레이아웃
String 타입의 변수를 만들 때 Rust는 이를 힙에 할당합니다1. 어떤 텍스트가 들어올지 미리 알 수 없으므로 스택에 딱 맞는 공간을 비워둘 수 없기 때문입니다. 흥미로운 점은 String의 데이터 전체가 힙에 있는 것이 아니라, 일부 정보는 스택에도 저장된다는 사실입니다. 구체적으로는 다음 세 가지 정보를 스택에 유지합니다.
- 데이터가 저장된 힙 영역을 가리키는 포인터(Pointer)
- 문자열의 길이(Length): 현재 담고 있는 텍스트의 바이트 수
- 문자열의 용량(Capacity): 할당자로부터 예약받은 전체 바이트 수
예를 들어 다음 코드를 실행해 봅시다.
let mut s = String::with_capacity(5);
이때 메모리는 다음과 같이 구성됩니다.
+---------+--------+----------+
스택 | 포인터 | 길이 | 용량 |
| | | 0 | 5 |
+--|------+--------+----------+
|
|
v
+---+---+---+---+---+
힙: | ? | ? | ? | ? | ? |
+---+---+---+---+---+
최대 5바이트를 담을 수 있는 공간을 요청했으므로, 할당자는 힙에 5바이트를 예약하고 그 시작 주소를 알려줍니다. 하지만 아직 아무 글자도 넣지 않았으므로 길이는 0이고 용량은 5가 됩니다.
여기에 텍스트를 추가해 볼까요?
s.push_str("Hey");
+---------+--------+----------+
스택 | 포인터 | 길이 | 용량 |
| | | 3 | 5 |
+--| ----+--------+----------+
|
|
v
+---+---+---+---+---+
힙: | H | e | y | ? | ? |
+---+---+---+---+---+
이제 s는 3바이트의 텍스트를 담고 있습니다. 길이는 3으로 업데이트되지만, 예약해둔 공간(용량)은 여전히 5입니다.
usize 타입
스택에 포인터, 길이, 용량을 저장하려면 공간이 얼마나 필요할까요? 이는 여러분이 사용하는 컴퓨터의 아키텍처에 따라 달라집니다.
메모리의 모든 위치는 숫자로 된 주소(Address)를 가집니다. 컴퓨터가 한 번에 다룰 수 있는 메모리 양에 따라 이 주소값의 크기가 달라지는데, 요즘 대부분의 컴퓨터는 32비트나 64비트 시스템을 사용합니다.
Rust는 이러한 차이를 추상화하기 위해 usize라는 타입을 제공합니다.
usize는 해당 시스템에서 메모리 주소를 표현하는 데 필요한 만큼의 크기를 갖는 부호 없는 정수입니다. 32비트 컴퓨터에서는 u32와 같고, 64비트 컴퓨터에서는 u64와 크기가 같습니다. 포인터, 길이, 용량 모두 Rust에서는 이 usize 타입으로 표현됩니다2.
힙 메모리와 std::mem::size_of
std::mem::size_of 함수는 특정 타입이 스택에서 차지하는 공간의 크기(타입 크기)를 알려줍니다.
그렇다면
String이 힙에 들고 있는 실제 데이터는size_of결과에 포함되지 않나요?
네, 포함되지 않습니다! 힙에 할당된 메모리는 String이 관리하는 외부 리소스일 뿐, 컴파일러는 이를 String 타입 자체의 크기로 보지 않습니다.
std::mem::size_of는 포인터를 통해 연결된 힙 메모리의 크기까지는 알지 못하며 관심도 없습니다. 안타깝게도 특정 값이 런타임에 사용하는 전체 힙 메모리 양을 측정하는 표준 함수는 없습니다. String::capacity()처럼 개별 타입이 제공하는 메서드를 쓰거나, DHAT 같은 메모리 프로파일링 도구를 사용하여 확인해야 합니다.
Exercise
The exercise for this section is located in 03_ticket_v1/09_heap
-
표준 라이브러리는
String::new()처럼 빈 문자열을 만들 때는 바로 힙을 할당하지 않습니다. 실제로 데이터가 처음 들어올 때 비로소 메모리를 예약합니다. ↩ -
엄밀히 말하면 포인터 크기가 주소값보다 큰 특수한 환경도 존재하지만, 우리가 접하는 일반적인 현대적 시스템에서는 주소값과 포인터의 크기가 같다고 가정해도 무방합니다. ↩
메모리에서의 참조
&String이나 &mut String 같은 참조는 메모리에서 어떻게 표현될까요?
Rust에서 대부분의 참조1는 가리키는 대상의 메모리 주소를 담은 **포인터(Pointer)**로 표현됩니다. 따라서 참조의 크기는 시스템의 포인터 크기인 usize와 같습니다.
std::mem::size_of를 통해 이를 직접 확인할 수 있습니다.
assert_eq!(std::mem::size_of::<&String>(), 8); // 64비트 시스템 기준 assert_eq!(std::mem::size_of::<&mut String>(), 8);
특히 &String은 String의 메타데이터(포인터, 길이, 용량)가 저장된 스택 위치를 가리키는 포인터입니다. 예를 들어 다음 코드를 실행해 볼까요?
let s = String::from("Hey");
let r = &s;
이때 메모리 구조는 다음과 같아집니다.
--------------------------------------
| |
+----v----+--------+----------+ +----|----+
스택 | 포인터 | 길이 | 용량 | | 포인터 |
| | | 3 | 5 | | |
+--| ----+--------+----------+ +---------+
| s r
|
v
+---+---+---+---+---+
힙 | H | e | y | ? | ? |
+---+---+---+---+---+
그림에서 보듯 r은 힙을 직접 가리키는 것이 아니라, 힙을 관리하는 스택의 s를 가리킵니다. 즉, ’힙 데이터를 가리키는 포인터’를 가리키는 포인터인 셈이죠. &mut String 역시 이와 동일한 방식으로 작동합니다.
모든 포인터가 힙을 가리키지는 않습니다
여기서 중요한 점 한 가지는, 모든 포인터가 반드시 힙을 가리키는 것은 아니라는 사실입니다. 포인터는 그저 메모리의 특정 위치를 가리킬 뿐이며, 그 위치는 힙일 수도 있고 스택일 수도 있습니다.
Exercise
The exercise for this section is located in 03_ticket_v1/10_references_in_memory
-
나중에 추가적인 메타데이터를 포함하는 **팻 포인터(Fat pointer)**에 대해 배우게 될 것입니다. 이름에서 짐작할 수 있듯, 팻 포인터는 여기서 다룬 일반적인 포인터(씬 포인터, Thin pointer)보다 더 큽니다. ↩
소멸자 (Destructor)
앞서 힙 메모리를 설명할 때, 할당받은 메모리는 반드시 직접 해제해야 할 책임이 있다고 말씀드렸습니다. 그런데 차용 검사기(Borrow checker)를 소개할 때는 Rust에서 메모리를 직접 관리할 일이 거의 없다고도 했죠.
얼핏 들으면 모순처럼 느껴질 수 있는 이 두 이야기가 어떻게 양립할 수 있는지, **스코프(Scope)**와 소멸자(Destructor) 개념을 통해 알아보겠습니다.
스코프 (Scope)
변수의 스코프란 해당 변수가 프로그램 내에서 유효하게 살아있는 코드 영역을 말합니다.
변수의 스코프는 선언되는 시점부터 시작됩니다. 그리고 다음 두 가지 경우 중 하나에 해당할 때 끝이 납니다.
-
변수가 선언된 블록(즉,
{}로 감싸진 영역)이 끝날 때fn main() { // `x`는 아직 태어나지 않았습니다. let y = "Hello".to_string(); let x = "World".to_string(); // <-- x의 스코프 시작... let h = "!".to_string(); // | } // <------------------------------- ...그리고 여기서 끝납니다. -
변수의 소유권이 다른 곳(함수나 다른 변수)으로 옮겨갈 때
fn compute(t: String) { // 무언가 처리 중... } fn main() { let s = "Hello".to_string(); // <-- s의 스코프 시작... // | compute(s); // <------------------- ...그리고 여기서 끝납니다. // 소유권이 `compute` 함수로 넘어갔기 때문입니다. }
소멸자 (Destructor)
값의 소유권을 가진 변수가 스코프를 벗어나면, Rust는 자동으로 해당 값의 소멸자를 호출합니다. 소멸자는 그 값이 점유하고 있던 리소스, 특히 할당받았던 힙 메모리를 깔끔하게 정리해 줍니다.
우리는 std::mem::drop 함수를 사용하여 소멸자를 수동으로 호출할 수도 있습니다. 그래서 Rust 개발자들은 어떤 값이 스코프를 벗어나 소멸자가 실행되는 것을 흔히 “해당 값이 **드롭(Drop)**되었다“라고 표현합니다.
드롭 시점 시각화하기
컴파일러가 우리 대신 해주는 작업을 눈으로 확인하기 위해, 명시적으로 drop을 호출하는 코드로 바꿔보겠습니다. 앞선 예제를 다시 볼까요?
fn main() {
let y = "Hello".to_string();
let x = "World".to_string();
let h = "!".to_string();
}
이 코드는 컴파일 시점에 사실상 다음과 같이 바뀝니다.
fn main() {
let y = "Hello".to_string();
let x = "World".to_string();
let h = "!".to_string();
// 변수는 선언된 순서의 '역순'으로 드롭됩니다.
drop(h);
drop(x);
drop(y);
}
소유권이 이동하는 두 번째 예제도 살펴봅시다.
fn compute(s: String) {
// 무언가 처리 중...
}
fn main() {
let s = "Hello".to_string();
compute(s);
}
이 코드는 내부적으로 다음과 같이 처리됩니다.
fn compute(t: String) {
// 무언가 처리 중...
drop(t); // <-- 함수가 끝날 때 t가 드롭됩니다.
}
fn main() {
let s = "Hello".to_string();
compute(s);
// 여기서 s를 드롭하지 않습니다! 소유권이 이미 넘어갔기 때문이죠.
}
중요한 차이점을 발견하셨나요? main 함수가 끝날 때 s가 드롭되지 않습니다. 소유권을 함수로 넘긴다는 것은 메모리 정리 책임까지 함께 넘긴다는 뜻이기 때문입니다.
이러한 메커니즘 덕분에 소멸자는 항상 **단 한 번1**만 호출됩니다. 결과적으로 C/C++에서 흔히 발생하는 이중 해제(Double free) 버그를 언어 차원에서 원천적으로 방지합니다.
드롭된 값 다시 사용하기
이미 드롭된 값을 다시 사용하려고 하면 어떻게 될까요?
let x = "Hello".to_string();
drop(x);
println!("{}", x);
이 코드를 컴파일하면 다음과 같은 오류를 만나게 됩니다.
error[E0382]: use of moved value: `x`
drop 함수는 값을 소비합니다. 즉, 호출된 직후 그 값은 더 이상 유효하지 않게 됩니다. 컴파일러는 이미 정리된 메모리에 접근하는 해제 후 사용(Use-after-free) 버그를 미리 차단합니다.
참조와 드롭
만약 변수가 소유권이 없는 참조를 담고 있다면 어떻게 될까요?
let x = 42i32;
let y = &x;
drop(y);
이 경우 drop(y)를 호출해도 아무런 일도 일어나지 않습니다. 컴파일러는 다음과 같은 경고를 띄워줍니다.
warning: calls to `std::mem::drop` with a reference instead of an owned value does nothing
이는 소멸자가 단 한 번만 호출되어야 한다는 원칙 때문입니다. 같은 데이터에 대해 여러 개의 참조가 있을 수 있는데, 그중 하나가 범위를 벗어난다고 데이터를 지워버리면 다른 참조들은 순식간에 유효하지 않은 메모리를 가리키게 되겠죠? 이를 댕글링 포인터(Dangling pointer)라고 합니다. Rust의 소유권 시스템은 이러한 치명적인 버그를 설계 단계에서부터 배제합니다.
Exercise
The exercise for this section is located in 03_ticket_v1/11_destructor
-
Rust는 소멸자 실행을 100% 보장하지는 않습니다. 예를 들어 의도적으로 메모리 누수(Memory leak)를 발생시키는 경우에는 실행되지 않을 수 있습니다. ↩
마무리하며
이번 장에서는 Rust의 아주 기초적이면서도 중요한 개념들을 두루 살펴보았습니다.
다음 장으로 넘어가기 전에, 지금까지 배운 내용을 복습하고 내 것으로 만들기 위해 마지막 연습 문제를 하나 더 풀어보겠습니다. 이번 문제는 가이드가 거의 없습니다. 문제 설명과 테스트 코드만 보고 스스로 해결해 보세요. 행운을 빕니다!
Exercise
The exercise for this section is located in 03_ticket_v1/12_outro
트레이트 (Traits)
이전 장에서는 Rust의 타입 시스템과 소유권(Ownership)의 기초를 살펴보았습니다. 이제 한 단계 더 깊이 들어가 볼 시간입니다. 이번 장에서는 Rust에서 인터페이스를 다루는 방식인 **트레이트(Traits)**에 대해 알아보겠습니다.
트레이트를 배우고 나면 Rust 코드 곳곳에서 그 흔적을 발견하게 될 것입니다. 사실 여러분은 이미 이전 장에서 .into() 호출이나 ==, + 같은 연산자를 사용하며 트레이트가 작동하는 모습을 간접적으로 경험했습니다.
이번 장에서는 트레이트의 기본 개념뿐만 아니라, Rust 표준 라이브러리에 정의된 핵심 트레이트들도 함께 다룰 예정입니다.
- 연산자 트레이트:
Add,Sub,PartialEq등 - 변환 트레이트: 오류 없는 타입 변환을 위한
From및Into - 복사 트레이트: 값의 복제를 위한
Clone및Copy - 역참조 트레이트:
Deref와 역참조 강제 변환(Deref coercion) - 크기 트레이트: 컴파일 타임에 크기가 알려진 타입을 나타내는
Sized - 정리 트레이트: 사용자 정의 리소스 정리 로직을 위한
Drop
타입 변환에 대해 배우면서 그동안 미뤄두었던 “지식의 빈틈“도 채워볼 것입니다. 예를 들어, "A title" 같은 문자열 리터럴의 정체는 무엇일까요? 이제 **슬라이스(Slices)**에 대해서도 더 자세히 알아볼 시간입니다!
Exercise
The exercise for this section is located in 04_traits/00_intro
트레이트 (Traits)
Ticket 타입을 다시 한번 살펴봅시다:
pub struct Ticket {
title: String,
description: String,
status: String,
}
지금까지 우리는 Ticket의 각 필드에 접근해서 테스트를 수행해 왔습니다.
assert_eq!(ticket.title(), "A new title");
그런데 만약 두 개의 Ticket 인스턴스를 직접 비교하고 싶다면 어떻게 해야 할까요?
let ticket1 = Ticket::new(/* ... */);
let ticket2 = Ticket::new(/* ... */);
ticket1 == ticket2
이 코드를 실행하면 컴파일러가 다음과 같은 오류를 냅니다.
error[E0369]: binary operation `==` cannot be applied to type `Ticket`
--> src/main.rs:18:13
|
18 | ticket1 == ticket2
| ------- ^^ ------- Ticket
| |
| Ticket
|
note: an implementation of `PartialEq` might be missing for `Ticket`
Ticket은 우리가 새롭게 정의한 타입입니다. 기본적으로 어떠한 동작도 정의되어 있지 않죠.
내부에 String 필드가 있다고 해서 Rust가 마법처럼 두 Ticket 인스턴스를 비교하는 법을 알아서 추측해 주지는 않습니다.
하지만 컴파일러의 힌트를 보면 PartialEq를 구현해보라고 제안하고 있습니다. 바로 이 PartialEq가 **트레이트(Trait)**입니다!
트레이트란 무엇인가요?
트레이트는 Rust에서 **인터페이스(Interfaces)**를 정의하는 방식입니다. 트레이트는 특정 타입이 가져야 할 기능을 명세하며, 해당 트레이트를 구현하고자 하는 타입은 명세된 메서드들을 반드시 정의해야 합니다.
트레이트 정의하기
트레이트를 정의하는 구문은 다음과 같습니다:
trait <TraitName> {
fn <method_name>(<parameters>) -> <return_type>;
}
예를 들어, 어떤 값이 0인지 확인하는 기능을 요구하는 MaybeZero 트레이트를 만들어 봅시다:
trait MaybeZero {
fn is_zero(self) -> bool;
}
트레이트 구현하기
특정 타입에 대해 트레이트를 구현할 때는 impl 키워드를 사용합니다. 구문은 일반적인 메서드 구현1과 약간 다릅니다:
impl <TraitName> for <TypeName> {
fn <method_name>(<parameters>) -> <return_type> {
// 메서드 본문
}
}
사용자 정의 타입인 WrappingU32에 MaybeZero 트레이트를 구현하면 다음과 같습니다:
pub struct WrappingU32 {
inner: u32,
}
impl MaybeZero for WrappingU32 {
fn is_zero(self) -> bool {
self.inner == 0
}
}
트레이트 메서드 호출하기
트레이트 메서드도 일반 메서드와 마찬가지로 . 연산자를 사용하여 호출합니다.
let x = WrappingU32 { inner: 5 };
assert!(!x.is_zero());
트레이트 메서드를 사용하려면 다음 두 가지 조건이 충족되어야 합니다.
- 타입이 해당 트레이트를 구현하고 있어야 함.
- 해당 트레이트가 현재 범위(Scope) 안에 있어야 함.
두 번째 조건을 만족시키기 위해 use 문이 필요할 수도 있습니다:
use crate::MaybeZero;
단, 다음과 같은 경우에는 use가 필요 없습니다.
- 트레이트가 메서드를 호출하는 모듈 안에 정의된 경우
- 트레이트가 표준 라이브러리의 **프렐류드(Prelude)**에 포함된 경우
- 프렐류드는 모든 Rust 프로그램에 자동으로 로드되는 트레이트와 타입들의 집합입니다. 마치 모든 Rust 파일 시작 부분에
use std::prelude::*;가 적혀있는 것과 같습니다.
- 프렐류드는 모든 Rust 프로그램에 자동으로 로드되는 트레이트와 타입들의 집합입니다. 마치 모든 Rust 파일 시작 부분에
프렐류드에 포함된 항목들은 Rust 공식 문서에서 확인할 수 있습니다.
Exercise
The exercise for this section is located in 04_traits/01_trait
-
트레이트 없이 타입 자체에 직접 정의된 메서드는 **고유 메서드(Inherent methods)**라고 부릅니다. ↩
트레이트 구현하기 (Implementing Traits)
Rust의 표준 라이브러리에 정의된 타입(예: u32)에 직접 새로운 메서드를 정의하려고 하면 어떻게 될까요?
impl u32 {
fn is_even(&self) -> bool {
self % 2 == 0
}
}
안타깝게도 컴파일러는 이를 허용하지 않습니다.
error[E0390]: cannot define inherent `impl` for primitive types
|
1 | impl u32 {
| ^^^^^^^^
|
= help: consider using an extension trait instead
확장 트레이트 (Extension Traits)
**확장 트레이트(Extension Traits)**는 외부 타입에 새로운 메서드를 추가할 때 사용하는 패턴입니다. 이전 연습 문제에서 보았던 것처럼, IsEven 트레이트를 정의하고 i32와 u32에 구현한 뒤, 해당 트레이트가 스코프 안에 있다면 해당 타입들에서 is_even 메서드를 자유롭게 호출할 수 있습니다.
// 트레이트를 스코프로 가져옵니다.
use my_library::IsEven;
fn main() {
// 이제 해당 타입에서 메서드를 호출할 수 있습니다.
if 4.is_even() {
// [...]
}
}
유일한 구현 (One Implementation)
트레이트를 구현할 때는 한 가지 제약이 있습니다. 동일한 크레이트 내에서 특정 타입에 대해 같은 트레이트를 두 번 구현할 수 없다는 점입니다.
trait IsEven {
fn is_even(&self) -> bool;
}
impl IsEven for u32 {
fn is_even(&self) -> bool {
true
}
}
impl IsEven for u32 {
fn is_even(&self) -> bool {
false
}
}
컴파일러는 이를 다음과 같이 거절합니다.
error[E0119]: conflicting implementations of trait `IsEven` for type `u32`
|
5 | impl IsEven for u32 {
| ------------------- first implementation here
...
11 | impl IsEven for u32 {
| ^^^^^^^^^^^^^^^^^^^ conflicting implementation for `u32`
메서드 호출 시 어떤 구현체를 사용해야 할지 모호함이 없어야 하기 때문입니다.
고아 규칙 (Orphan Rule)
여러 크레이트가 얽혀 있는 상황에서는 구현 규칙이 조금 더 복잡해집니다. 이를 **고아 규칙(Orphan Rule)**이라고 부르며, 다음 중 하나라도 충족되어야 트레이트 구현이 가능합니다.
- 구현할 트레이트가 현재 크레이트에 정의되어 있음.
- 구현할 타입이 현재 크레이트에 정의되어 있음.
이 규칙의 목적은 메서드 탐색 과정을 명확하게 보장하는 것입니다. 만약 이런 규칙이 없다면 어떤 문제가 생길까요?
- 크레이트
A가IsEven트레이트를 정의함. - 크레이트
B가u32에 대해IsEven을 구현함. - 크레이트
C가u32에 대해 (서로 다른)IsEven을 구현함. - 크레이트
D가B와C를 모두 참조하고1.is_even()을 호출함.
이때 어떤 구현을 사용해야 할까요? 정답이 없기 때문에 Rust는 이러한 시나리오 자체가 발생하지 않도록 고아 규칙을 적용합니다. 결과적으로 크레이트 B와 C는 컴파일되지 않습니다.
추가 자료 (Further Reading)
- 고아 규칙에는 몇 가지 예외 사항이 있습니다. 더 깊이 있는 내용이 궁금하시다면 Rust 레퍼런스를 참고해 보세요.
Exercise
The exercise for this section is located in 04_traits/02_orphan_rule
연산자 오버로딩 (Operator Overloading)
이제 트레이트에 대한 기본적인 이해가 생겼으니, 다시 **연산자 오버로딩(Operator Overloading)**에 대해 이야기해 봅시다. 연산자 오버로딩은 +, -, *, /, ==, != 등의 연산자에 대해 사용자 정의 동작을 부여하는 기능입니다.
연산자는 트레이트입니다 (Operators are Traits)
Rust에서 모든 연산자는 사실 트레이트입니다. 각 연산자의 동작은 특정 트레이트에 정의되어 있으며, 우리가 만든 타입에 해당 트레이트를 구현하면 연산자 사용을 **해제(unlock)**할 수 있습니다.
예를 들어, PartialEq 트레이트는 ==와 != 연산자의 동작을 정의합니다.
// Rust 표준 라이브러리의 `PartialEq` 트레이트 정의(단순화됨)
pub trait PartialEq {
// 필수 메서드
// `Self`는 "해당 트레이트를 구현하는 타입"을 의미하는 키워드입니다.
fn eq(&self, other: &Self) -> bool;
// 기본 제공 메서드
fn ne(&self, other: &Self) -> bool { ... }
}
여러분이 코드에 x == y라고 적으면, 컴파일러는 x와 y의 타입에 대해 PartialEq 트레이트 구현을 찾습니다. 그리고 x == y를 x.eq(y)로 변환해 줍니다. 일종의 **구문 설탕(Syntactic sugar)**인 셈이죠!
주요 연산자와 그에 대응하는 트레이트는 다음과 같습니다:
산술 연산자는 std::ops 모듈에, 비교 연산자는 std::cmp 모듈에 위치해 있습니다.
기본 구현 (Default Implementation)
PartialEq::ne 메서드 정의를 보면 { ... } 블록이 이미 있는 것을 볼 수 있습니다. 이는 PartialEq 트레이트가 ne에 대한 **기본 구현(Default Implementation)**을 제공한다는 뜻입니다. 생략된 블록을 확장해 보면 다음과 같습니다.
pub trait PartialEq {
fn eq(&self, other: &Self) -> bool;
fn ne(&self, other: &Self) -> bool {
!self.eq(other)
}
}
우리가 예상할 수 있듯이, ne는 단순히 eq 결과를 반전시킨 것입니다. 이렇게 기본 구현이 제공되는 경우, 타입에 대해 트레이트를 구현할 때 해당 메서드를 직접 구현하지 않아도 됩니다. eq만 구현해도 충분하죠:
struct WrappingU8 {
inner: u8,
}
impl PartialEq for WrappingU8 {
fn eq(&self, other: &WrappingU8) -> bool {
self.inner == other.inner
}
// `ne` 구현은 생략 가능!
}
물론 원한다면 기본 구현을 재정의(Override)할 수도 있습니다.
struct MyType;
impl PartialEq for MyType {
fn eq(&self, other: &MyType) -> bool {
// 사용자 정의 eq 구현
}
fn ne(&self, other: &MyType) -> bool {
// 사용자 정의 ne 구현
}
}
Exercise
The exercise for this section is located in 04_traits/03_operator_overloading
Derive 매크로 (Derive Macros)
Ticket 타입에 PartialEq를 직접 구현하는 것이 조금 번거롭지 않으셨나요? 구조체의 모든 필드를 일일이 비교해야 했죠.
구조 분해 구문 (Destructuring Syntax)
게다가 수동으로 구현하면 코드가 취약해집니다. 구조체 정의가 바뀌어 필드가 추가될 때마다 PartialEq 구현도 잊지 않고 업데이트해 주어야 하거든요.
이런 위험을 줄이기 위해 구조체를 필드로 나누는 **구조 분해(Destructuring)**를 사용할 수 있습니다:
impl PartialEq for Ticket {
fn eq(&self, other: &Self) -> bool {
let Ticket {
title,
description,
status,
} = self;
// [...]
}
}
이렇게 하면 Ticket 정의가 바뀌었을 때 컴파일러가 구조 분해가 불완전하다며 오류를 내주기 때문에 실수를 방지할 수 있습니다. 변수 이름 충돌(Shadowing)을 피하기 위해 필드 이름을 바꿀 수도 있죠:
impl PartialEq for Ticket {
fn eq(&self, other: &Self) -> bool {
let Ticket {
title,
description,
status,
} = self;
let Ticket {
title: other_title,
description: other_description,
status: other_status,
} = other;
// [...]
}
}
구조 분해는 알아두면 매우 유용한 패턴이지만, 사실 이보다 훨씬 더 간편한 방법이 있습니다. 바로 **derive 매크로(Derive macros)**입니다.
매크로 (Macros)
이전 연습 문제에서도 이미 몇 가지 매크로를 접해보셨을 겁니다:
- 테스트 케이스의
assert_eq!,assert! - 콘솔 출력을 위한
println!
Rust 매크로는 **코드 생성기(Code generators)**입니다. 입력받은 내용을 바탕으로 새로운 Rust 코드를 생성하고, 이렇게 생성된 코드는 나머지 프로그램과 함께 컴파일됩니다. 일부 매크로는 표준 라이브러리에 내장되어 있으며 직접 만들 수도 있습니다. 이 과정에서는 매크로를 직접 만들지는 않지만, 관심 있는 분들은 “추가 자료” 섹션을 참고해 보세요.
매크로 검사
일부 IDE에서는 매크로가 생성한 코드를 직접 확인할 수 있습니다. 만약 불가능하다면 cargo-expand라는 도구를 사용할 수도 있습니다.
Derive 매크로
Derive 매크로는 특수한 종류의 Rust 매크로로, 구조체나 열거형 위에 **속성(Attributes)**으로 붙여 사용합니다.
#[derive(PartialEq)]
struct Ticket {
title: String,
description: String,
status: String
}
이 매크로는 사용자 정의 타입에 대해 자주 쓰이는(그리고 구현이 뻔한) 트레이트들의 구현을 자동화해 줍니다. 위 예시에서는 Ticket에 대해 PartialEq 트레이트가 자동으로 구현됩니다. 매크로를 펼쳐보면 수동으로 짠 것과 기능적으로 동일하지만, 조금 복잡해 보이는 코드를 볼 수 있습니다:
#[automatically_derived]
impl ::core::cmp::PartialEq for Ticket {
#[inline]
fn eq(&self, other: &Ticket) -> bool {
self.title == other.title
&& self.description == other.description
&& self.status == other.status
}
}
컴파일러가 가능한 경우 트레이트를 직접 구현하지 말고 derive하라고 유도할 것입니다.
추가 자료 (Further Reading)
Exercise
The exercise for this section is located in 04_traits/04_derive
트레이트 바운드 (Trait Bounds)
지금까지 우리는 트레이트의 두 가지 쓰임새를 살펴보았습니다:
- 연산자 오버로딩처럼 “내장된” 기능을 활성화하는 용도
- 확장 트레이트처럼 기존 타입에 새로운 기능을 추가하는 용도
세 번째 중요한 쓰임새는 바로 **제네릭 프로그래밍(Generic Programming)**입니다.
문제 상황
지금까지 우리가 작성한 함수나 메서드는 모두 **구체적인 타입(Concrete type)**을 대상으로 했습니다. 구체적인 타입으로 작업하면 이해하기 쉽지만 재사용성은 떨어집니다.
예를 들어, 어떤 숫자가 짝수인지 확인하는 함수를 만든다고 해봅시다. 구체적인 타입만 사용한다면, 지원하고 싶은 모든 정수 타입마다 별도의 함수를 만들어야 합니다:
fn is_even_i32(n: i32) -> bool {
n % 2 == 0
}
fn is_even_i64(n: i64) -> bool {
n % 2 == 0
}
// ... 계속 반복 ...
혹은 확장 트레이트를 만들고 타입마다 각기 다른 구현을 작성할 수도 있겠죠:
trait IsEven {
fn is_even(&self) -> bool;
}
impl IsEven for i32 {
fn is_even(&self) -> bool {
self % 2 == 0
}
}
impl IsEven for i64 {
fn is_even(&self) -> bool {
self % 2 == 0
}
}
하지만 코드가 중복된다는 사실은 변하지 않습니다.
제네릭 프로그래밍 (Generic Programming)
**제네릭(Generics)**을 사용하면 이 문제를 멋지게 해결할 수 있습니다. 구체적인 타입 대신 **타입 매개변수(Type parameters)**를 사용하는 코드를 작성할 수 있거든요:
fn print_if_even<T>(n: T)
where
T: IsEven + Debug
{
if n.is_even() {
println!("{n:?} is even");
}
}
여기서 print_if_even은 제네릭 함수입니다. 특정 입력 타입에 고정되지 않고, 다음 두 조건을 만족하는 모든 타입 T와 함께 사용할 수 있습니다:
IsEven트레이트를 구현함.Debug트레이트를 구현함.
이러한 약속을 **트레이트 바운드(Trait Bounds)**라고 부르며, T: IsEven + Debug와 같이 표현합니다. + 기호는 T가 여러 트레이트를 한꺼번에 구현해야 함을 의미합니다.
트레이트 바운드가 필요한 이유
print_if_even 함수에서 트레이트 바운드를 지우면 어떻게 될까요?
fn print_if_even<T>(n: T) {
if n.is_even() {
println!("{n:?} is even");
}
}
이 코드는 컴파일되지 않습니다. 컴파일러는 타입 매개변수 T가 무엇을 할 수 있는지 알지 못하기 때문입니다. T에 is_even 메서드가 있는지, T를 출력용으로 포맷팅할 수 있는지 알 길이 없죠.
컴파일러 입장에서 아무런 제약이 없는 T는 어떠한 기능도 보장되지 않는 “빈 상자“와 같습니다. 트레이트 바운드는 함수 안에서 필요한 특정 동작들이 실제로 존재함을 보장함으로써, 해당 함수가 받아들일 수 있는 타입의 범위를 안전하게 제한하는 역할을 합니다.
구문: 인라인 트레이트 바운드 (Inline Trait Bounds)
위 예제에서는 **where 절(Clause)**을 사용하여 트레이트 바운드를 명시했습니다.
fn print_if_even<T>(n: T)
where
T: IsEven + Debug
// ^^^^^^^^^^^^^^^^^ 이 부분이 `where` 절입니다.
{
// [...]
}
트레이트 바운드가 간단하다면 타입 매개변수 바로 옆에 **인라인(Inline)**으로 적을 수도 있습니다:
fn print_if_even<T: IsEven + Debug>(n: T) {
// ^^^^^^^^^^^^^^^^^ 인라인 트레이트 바운드
// [...]
}
구문: 의미 있는 이름 사용하기
보통 타입 매개변수 이름으로 T를 많이 쓰지만, 좀 더 의미 있는 이름을 사용해도 좋습니다:
fn print_if_even<Number: IsEven + Debug>(n: Number) {
// [...]
}
타입 매개변수가 여러 개이거나 T라는 이름만으로 역할이 분명하지 않을 때, 가독성을 위해 의미 있는 이름을 사용하는 것이 바람직합니다. 다만 관례에 따라 **파스칼 케이스(PascalCase, 또는 UpperCamelCase)**를 사용해 주세요.
함수 시그니처가 전부입니다 (Signature is King)
컴파일러가 함수 본문을 보고 어떤 트레이트가 필요한지 알아서 추론해 줄 순 없을까요? 기술적으로는 가능할지도 모르지만, Rust는 의도적으로 그렇게 하지 않습니다.
함수 매개변수에 명시적 타입을 적는 것과 마찬가지로, 함수 시그니처는 호출하는 쪽과 함수 간의 명확한 약속입니다. 이 규칙 덕분에 우리는 훨씬 정확한 오류 메시지를 받을 수 있고, 문서를 보기가 쉬워지며, 코드 변경 시 의도치 않은 파급 효과를 줄이고 컴파일 속도를 높일 수 있습니다.
Exercise
The exercise for this section is located in 04_traits/05_trait_bounds
문자열 슬라이스 (String Slices)
이전 장에서 "To-Do"나 "A ticket description"처럼 코드 안에 적힌 **문자열 리터럴(String literals)**을 많이 보셨을 겁니다. 그리고 항상 그 뒤에 .to_string()이나 .into()가 따라왔었죠. 이제 그 이유를 알아볼 때입니다!
문자열 리터럴 (String Literals)
따옴표로 감싸서 텍스트를 정의하면 그것이 문자열 리터럴입니다.
let s = "Hello, world!";
여기서 s의 타입은 &str이며, 이는 문자열 슬라이스에 대한 참조입니다.
메모리 레이아웃 (Memory Layout)
&str과 String은 서로 다른 타입이며 마음대로 바꿔 쓸 수 없습니다. String의 메모리 구조를 다시 떠올려 봅시다.
let mut s = String::with_capacity(5);
s.push_str("Hello");
위 코드를 실행하면 메모리 구조는 다음과 같아집니다:
+---------+--------+----------+
스택 | 포인터 | 길이 | 용량 |
| | | 5 | 5 |
+--|------+--------+----------+
|
|
v
+---+---+---+---+---+
힙: | H | e | l | l | o |
+---+---+---+---+---+
그리고 &String은 String의 메타데이터가 저장된 스택 주소를 가리키는 포인터였죠. 그렇다면 문자열 전체가 아니라 Hello의 ello처럼 일부만 가리키고 싶을 때는 어떻게 해야 할까요?
문자열 슬라이스 (String Slices)
&str은 문자열의 특정 부분을 비추는 **뷰(View)**이자, 어딘가에 저장된 UTF-8 바이트 시퀀스를 가리키는 **참조(Reference)**입니다.
let mut s = String::with_capacity(5);
s.push_str("Hello");
// 첫 번째 바이트를 건너뛰고 문자열 슬라이스를 만듭니다.
let slice: &str = &s[1..];
메모리에서는 이렇게 표현됩니다:
s slice
+---------+--------+----------+ +---------+--------+
스택 | 포인터 | 길이 | 용량 | | 포인터 | 길이 |
| | | 5 | 5 | | | | 4 |
+----|----+--------+----------+ +----|----+--------+
| s |
| |
v |
+---+---+---+---+---+ |
힙: | H | e | l | l | o | |
+---+---+---+---+---+ |
^ |
| |
+--------------------------------+
slice는 스택에 두 가지 정보를 담습니다:
- 슬라이스의 시작점을 가리키는 포인터
- 슬라이스의 길이
slice는 데이터를 소유하지 않고 그저 가리킬 뿐입니다. slice가 사라져도 힙에 있는 실제 데이터는 여전히 s가 소유하고 있으므로 해제되지 않습니다. 그래서 slice에는 capacity(용량) 필드가 없습니다. 자기가 데이터를 소유한 것이 아니니 용량을 알 필요가 없는 것이죠.
&str vs &String
함수를 만들 때 텍스트 데이터를 참조로 받고 싶다면, 특별한 이유가 없는 한 &String보다는 &str을 쓰시는 것이 좋습니다. &str이 훨씬 유연하고 관용적인 표현(Idiomatic)이기 때문입니다.
메서드가 &String을 반환한다면, 어딘가에 정확히 그 내용과 일치하는 힙 할당 UTF-8 데이터가 있음을 보장하는 셈입니다. 반면 &str을 반환하는 것은 “어딘가에 데이터 뭉치가 있고, 그중에서 당신이 필요한 부분이 여기 있으니 보세요“라고 말하는 것과 같아서 훨씬 자유롭습니다.
Exercise
The exercise for this section is located in 04_traits/06_str_slice
Deref 트레이트 (Deref Trait)
이전 연습 문제에서 코드를 조금 수정하셨을 텐데요:
impl Ticket {
pub fn title(&self) -> &String {
&self.title
}
}
위 코드를 아래처럼 고쳤더니:
impl Ticket {
pub fn title(&self) -> &str {
&self.title
}
}
별다른 문제 없이 컴파일이 되고 테스트도 통과했습니다. 그런데 곰곰이 생각해보면 조금 이상하지 않나요?
왜 작동하는 걸까요?
상황을 한 번 짚어봅시다:
self.title은String입니다.- 그러므로
&self.title은&String이 됩니다. - 하지만 새로 바꾼
title메서드의 반환 타입은&str입니다.
원래라면 Expected &String, found &str 같은 컴파일 오류가 나야 할 것 같은데 말이죠. 대체 왜 문제없이 작동하는 걸까요?
Deref가 도와줍니다 (Deref to the Rescue)
비결은 Deref 트레이트에 있습니다. 이 트레이트는 역참조 강제 변환(Deref coercion)이라는 멋진 언어 기능을 가능케 하는 핵심 메커니즘입니다. 표준 라이브러리의 std::ops 모듈에 다음과 같이 정의되어 있습니다:
// 구성을 조금 단순화한 모습입니다.
// 실제 정의는 조금 뒤에서 살펴볼 거예요.
pub trait Deref {
type Target;
fn deref(&self) -> &Self::Target;
}
여기서 type Target은 **연관 타입(Associated types)**입니다. 나중에 트레이트를 실제로 구현할 때 어떤 타입으로 바꿀지 미리 표시해두는 자리 표시자(Placeholder)라고 생각하시면 됩니다.
역참조 강제 변환 (Deref Coercion)
타입 T에 대해 Deref<Target = U>가 구현되어 있으면, 컴파일러에게 &T와 &U가 어느 정도 서로 호환된다는 신호를 줍니다. 구체적으로는 다음과 같은 효과가 생깁니다:
T의 참조가 필요할 때 자동으로U의 참조로 바뀝니다 (즉,&T가&U로 변신합니다).&T에서&self를 인자로 받는U타입의 모든 메서드를 직접 호출할 수 있습니다.
역참조 연산자 *와 관련된 기능도 하나 더 있지만, 지금은 당장 몰라도 괜찮습니다. (궁금하신 분은 공식 문서를 참고해 보세요!)
String은 Deref를 구현하고 있습니다
실제로 String은 Target = str로 Deref 트레이트를 구현하고 있습니다:
impl Deref for String {
type Target = str;
fn deref(&self) -> &str {
// [...]
}
}
이 구현 덕분에 &String은 필요한 곳에서 자동으로 &str로 변환됩니다. 그래서 아까 우리 예제 코드가 성공적으로 컴파일되었던 것입니다!
주의: 남용은 금물입니다 (Don’t Overdo It)
역참조 강제 변환은 매우 편리하지만, 남용하면 코드가 읽기 힘들어질 수 있습니다. 타입이 자동으로 변환되면 실제로 어떤 일이 벌어지는지 한눈에 파악하기 어렵기 때문이죠. 특히 T와 U에 똑같은 이름의 메서드가 있다면 어떤 것이 호출될지 헷갈릴 수도 있습니다.
나중에 우리는 이 기능의 가장 대표적이고 안전한 사례인 **스마트 포인터(Smart pointers)**에 대해 배워볼 것입니다.
Exercise
The exercise for this section is located in 04_traits/07_deref
Sized (크기가 정해진 타입)
역참조 강제 변환(Deref coercion)을 살펴본 뒤에도 &str에는 아직 우리가 모르는 비밀이 더 숨겨져 있습니다.
메모리 레이아웃에 대한 이전 논의를 떠올려 보면, &str 역시 스택에서 단일 usize 크기의 포인터로만 표현될 것이라고 생각하기 쉽습니다. 하지만 실제로는 그렇지 않습니다. &str은 포인터와 함께 **메타데이터(metadata)**를 추가로 저장하는데, 바로 가리키고 있는 슬라이스의 길이 정보입니다. 이전 섹션에서 보았던 예시를 다시 살펴봅시다.
let mut s = String::with_capacity(5);
s.push_str("Hello");
// `String`에서 문자열 슬라이스 참조를 만듭니다.
// 첫 번째 바이트를 건너뜁니다.
let slice: &str = &s[1..];
메모리 구조는 다음과 같습니다.
s slice
+---------+--------+----------+ +---------+--------+
스택 | 포인터 | 길이 | 용량 | | 포인터 | 길이 |
| | | 5 | 5 | | | | 4 |
+----|----+--------+----------+ +----|----+--------+
| s |
| |
v |
+---+---+---+---+---+ |
힙(Heap): | H | e | l | l | o | |
+---+---+---+---+---+ |
^ |
| |
+--------------------------------+
어떻게 이런 일이 가능한 걸까요?
동적 크기 타입 (Dynamically Sized Types)
str은 **동적 크기 타입(Dynamically Sized Types, DST)**입니다.
DST는 컴파일 시점에 그 크기를 미리 알 수 없는 타입을 말합니다. &str처럼 DST를 참조할 때는 가리키는 데이터에 대한 추가 정보가 반드시 필요합니다. 이런 참조를 **팻 포인터(fat pointer)**라고 부릅니다.
&str의 경우에는 슬라이스의 길이를 함께 저장하는 것이죠. 앞으로 학습하면서 DST의 다른 예시들도 더 만나보게 될 것입니다.
Sized 트레이트
Rust의 표준 라이브러리(std)에는 Sized라는 트레이트가 정의되어 있습니다.
pub trait Sized {
// 이 트레이트는 비어 있으며, 구현해야 할 메서드가 없습니다.
}
어떤 타입의 크기를 컴파일 시점에 알 수 있다면, 그 타입은 Sized 트레이트를 구현한다고 봅니다. 즉, DST가 아니라는 뜻이죠.
마커 트레이트 (Marker Traits)
Sized는 우리가 처음으로 접하는 **마커 트레이트(marker trait)**의 예시입니다. 마커 트레이트는 별도의 메서드를 구현할 필요가 없는 트레이트입니다. 즉, 어떤 동작(behavior)을 정의하기 위한 것이 아닙니다. 그저 해당 타입이 특정 속성을 가지고 있음을 컴파일러에게 알려주는(marking) 역할만 합니다. 컴파일러는 이 정보를 바탕으로 특정 기능을 활성화하거나 최적화를 수행합니다.
자동 트레이트 (Auto Traits)
특히 Sized는 **자동 트레이트(auto trait)**이기도 합니다. 개발자가 직접 구현할 필요 없이, 컴파일러가 타입 정의를 보고 자동으로 구현해 줍니다.
예제
우리가 지금까지 다뤘던 대부분의 타입은 Sized입니다. u32, String, bool 등이 그 예입니다.
반면 str은 방금 살펴본 것처럼 Sized가 아닙니다. 하지만 &str 자체는 Sized입니다! 컴파일 시점에 그 크기를 확실히 알 수 있기 때문이죠. 포인터용 usize 하나와 길이용 usize 하나, 총 두 개의 usize 크기를 가집니다.
Exercise
The exercise for this section is located in 04_traits/08_sized
From 및 Into (변환 트레이트)
우리가 처음 문자열을 다루기 시작했던 코드로 다시 돌아가 봅시다.
let ticket = Ticket::new(
"A title".into(),
"A description".into(),
"To-Do".into()
);
이제 우리는 여기서 .into()가 정확히 어떤 역할을 하는지 분석할 수 있는 충분한 지식을 갖추었습니다.
문제의 발단
new 메서드의 시그니처는 다음과 같이 정의되어 있습니다.
impl Ticket {
pub fn new(
title: String,
description: String,
status: String
) -> Self {
// [...]
}
}
우리는 앞서 "A title"과 같은 문자열 리터럴이 &str 타입이라는 것을 배웠습니다. 여기서 타입 불일치가 발생합니다. 메서드는 String을 요구하는데, 우리는 &str을 전달하고 있기 때문입니다. 이번에는 컴파일러가 알아서 해주는 마법 같은 강제 변환이 일어나지 않습니다. 즉, 우리가 직접 타입 변환을 수행해야 합니다.
From 및 Into
Rust 표준 라이브러리의 std::convert 모듈에는 실패할 가능성이 없는 변환을 처리하기 위한 두 가지 트레이트인 From과 Into가 정의되어 있습니다.
pub trait From<T>: Sized {
fn from(value: T) -> Self;
}
pub trait Into<T>: Sized {
fn into(self) -> T;
}
이 트레이트 정의에는 지금까지 보지 못했던 새로운 개념인 **슈퍼트레이트(supertrait)**와 **암시적 트레이트 바운드(implicit trait bound)**가 포함되어 있습니다. 하나씩 살펴봅시다.
슈퍼트레이트(Supertrait)와 서브트레이트(Subtrait)
From: Sized 구문은 From이 Sized의 서브트레이트임을 나타냅니다. 즉, From을 구현하는 모든 타입은 반드시 Sized도 구현해야 합니다. 반대로 Sized는 From의 슈퍼트레이트라고 부릅니다.
암시적 트레이트 바운드
Rust 컴파일러는 제네릭 타입 매개변수가 사용될 때마다, 별도의 명시가 없으면 해당 타입이 Sized일 것이라고 암시적으로 가정합니다.
예를 들어, 다음과 같은 구조체 정의는
pub struct Foo<T> {
inner: T,
}
실제로는 다음과 동일하게 취급됩니다.
pub struct Foo<T: Sized>
{
inner: T,
}
따라서 From<T>의 정의 역시 실제로는 다음과 같습니다.
pub trait From<T: Sized>: Sized {
fn from(value: T) -> Self;
}
이는 T와 From<T>를 구현하는 타입 모두 반드시 Sized여야 함을 의미합니다. (T에 대한 바운드는 명시하지 않아도 자동으로 적용됩니다.)
물음표 트레이트 바운드 (Question Mark Trait Bound)
만약 암시적인 Sized 제약을 해제하고 싶다면, 물음표 트레이트 바운드를 사용하면 됩니다.
pub struct Foo<T: ?Sized> {
// ^^^^^^^
// 이것이 Sized 제약을 해제하는 구문입니다.
inner: T,
}
이 구문은 “T는 Sized일 수도 있고 아닐 수도 있다“는 뜻으로 해석됩니다. 이를 통해 T에 str과 같은 DST를 대입할 수 있게 됩니다(예: Foo<str>). 단, 이 특수한 구문은 Sized 트레이트에만 사용할 수 있습니다.
&str에서 String으로의 변환
std 문서를 보면 어떤 타입들이 From 트레이트를 구현하고 있는지 확인할 수 있습니다. 거기서 String이 From<&str>을 구현하고 있다는 사실을 찾을 수 있죠. 따라서 우리는 다음과 같이 쓸 수 있습니다.
let title = String::from("A title");
그런데 우리는 그동안 주로 .into()를 사용해 왔습니다.
Into 구현 목록을 찾아봐도 &str에 대해 Into<String>이 직접 구현된 것은 보이지 않습니다. 어떻게 된 걸까요?
사실 From과 Into는 서로 짝을 이루는 대칭적인 트레이트입니다. 특히 Into는 **블랭킷 구현(blanket implementation)**을 통해, From을 구현하는 모든 타입에 대해 자동으로 구현됩니다.
impl<T, U> Into<U> for T
where
U: From<T>,
{
fn into(self) -> U {
U::from(self)
}
}
즉, 어떤 타입 U가 From<T>를 구현하면, 타입 T에 대해 Into<U>가 자동으로 구현됩니다. 이것이 우리가 let title = "A title".into();라고 자연스럽게 쓸 수 있었던 이유입니다.
.into() 활용하기
코드에서 .into()를 본다면, 그것은 한 타입에서 다른 타입으로의 변환이 일어나고 있다는 신호입니다. 그렇다면 변환될 목적지 타입은 어떻게 결정될까요?
대부분의 경우 목적지 타입은 다음 중 하나를 통해 결정됩니다.
- 함수나 메서드의 시그니처 (예: 앞서 본
Ticket::new의 매개변수 타입) - 타입 명시가 포함된 변수 선언 (예:
let title: String = "A title".into();)
컴파일러가 주변 맥락을 통해 목적지 타입을 명확히 추론할 수 있다면, .into()는 별도의 추가 설정 없이 바로 작동합니다.
Exercise
The exercise for this section is located in 04_traits/09_from
제네릭(Generics)과 연관 타입(Associated Types)
지금까지 살펴본 두 트레이트, From과 Deref의 정의를 다시 한번 비교해 봅시다.
pub trait From<T> {
fn from(value: T) -> Self;
}
pub trait Deref {
type Target;
fn deref(&self) -> &Self::Target;
}
두 트레이트 모두 타입을 매개변수화하여 사용하고 있습니다.
From의 경우 제네릭 매개변수인 T를 사용하고, Deref는 연관 타입인 Target을 사용합니다.
이 둘의 차이점은 무엇일까요? 그리고 왜 상황에 따라 다른 방식을 선택하는 걸까요?
오직 하나의 구현만 허용할 때
역참조 강제 변환(Deref coercion)이 작동하는 방식 때문에, 특정 타입에 대한 “대상(target)” 타입은 오직 하나만 존재해야 합니다. 예를 들어, String은 오직 str로만 역참조될 수 있습니다. 이는 모호함을 방지하기 위해서입니다. 만약 하나의 타입에 대해 Deref를 여러 번 구현할 수 있다면, 컴파일러는 &self 메서드를 호출할 때 어떤 Target 타입을 선택해야 할지 알 수 없게 됩니다.
이것이 바로 Deref가 연관 타입인 Target을 사용하는 이유입니다. 연관 타입은 트레이트 구현 시점에 고유하게 결정됩니다. 하나의 타입에 대해 트레이트를 두 번 이상 구현할 수 없으므로(연관 타입만 다르게 해서 구현하는 것은 불가능합니다), 주어진 타입에 대해 Target은 단 하나만 지정될 수 있고 모호함이 사라집니다.
제네릭 트레이트: 여러 구현을 허용할 때
반면, 입력 타입 T가 서로 다르다면 하나의 타입에 대해 From을 여러 번 구현할 수 있습니다. 예를 들어 WrappingU32라는 타입에 대해 u32와 u16을 각각 입력으로 받는 From을 모두 구현할 수 있습니다.
impl From<u32> for WrappingU32 {
fn from(value: u32) -> Self {
WrappingU32 { inner: value }
}
}
impl From<u16> for WrappingU32 {
fn from(value: u16) -> Self {
WrappingU32 { inner: value.into() }
}
}
이것이 가능한 이유는 From<u16>과 From<u32>가 서로 다른 트레이트로 간주되기 때문입니다. 컴파일러는 변환하려는 값의 타입이 무엇인지 보고 어떤 구현체를 사용할지 명확히 판단할 수 있으므로 모호함이 없습니다.
사례 연구: Add 트레이트
마지막으로 표준 라이브러리의 Add 트레이트를 살펴봅시다.
pub trait Add<RHS = Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}
Add 트레이트는 두 가지 메커니즘을 모두 활용합니다.
- 제네릭 매개변수
RHS: 더할 오른쪽 피연산자의 타입을 나타내며, 기본값은Self입니다. - 연관 타입
Output: 덧셈 결과의 타입을 나타냅니다.
왜 RHS는 제네릭일까요?
RHS가 제네릭인 덕분에 서로 다른 타입끼리 더하는 것이 가능해집니다. 예를 들어 u32 타입에 대해서 다음과 같은 두 가지 구현이 있을 수 있습니다.
impl Add<u32> for u32 {
type Output = u32;
fn add(self, rhs: u32) -> u32 {
// ^^^
// 이 부분은 `Self::Output`으로 써도 무방합니다.
// 컴파일러는 위에서 정의한 `Output` 타입과 실제 반환 타입이
// 일치하는지만 확인합니다.
}
}
impl Add<&u32> for u32 {
type Output = u32;
fn add(self, rhs: &u32) -> u32 {
// [...]
}
}
덕분에 u32가 Add<u32>와 Add<&u32>를 모두 구현하고 있으므로 다음과 같은 코드가 가능해집니다.
let x = 5u32 + &5u32 + 6u32;
왜 Output은 연관 타입일까요?
Output은 덧셈 연산의 결과 타입을 정의합니다.
그냥 항상 Self를 반환하면 안 될까요? 그럴 수도 있겠지만, 그러면 트레이트의 유연성이 크게 떨어집니다. 예를 들어 다음과 같은 경우를 보시죠.
impl Add<&u32> for &u32 {
type Output = u32;
fn add(self, rhs: &u32) -> u32 {
// [...]
}
}
여기서 트레이트를 구현하는 타입(Self)은 &u32이지만, 덧셈의 결과는 u32여야 합니다. 만약 add 메서드가 반드시 Self 타입(여기서는 &u32)을 반환해야 했다면, 이런 구현은 불가능했을 것입니다1. Output을 연관 타입으로 분리함으로써 결과 타입을 자유롭게 지정할 수 있게 된 것이죠.
하지만 Output을 제네릭 매개변수로 만들 수는 없습니다. 피연산자들의 타입이 결정되었다면, 그 연산의 결과 타입은 반드시 하나로 고정되어야 하기 때문입니다. 이것이 바로 Output이 연관 타입인 이유입니다.
요약
- 특정 트레이트 구현에 대해 결과 타입이 고유하게 결정되어야 한다면 연관 타입을 사용하세요.
- 하나의 타입에 대해 여러 가지 서로 다른 입력 타입을 허용하고 싶다면 제네릭 매개변수를 사용하세요.
Exercise
The exercise for this section is located in 04_traits/10_assoc_vs_generic
-
유연함에는 대가가 따릅니다.
Output을 사용하면 트레이트 정의가 복잡해지고 구현 시 고려할 사항도 늘어납니다. 이 유연함이 정말로 필요한 경우에만 이런 구조를 설계하는 것이 좋습니다. 여러분만의 트레이트를 만들 때 이 점을 꼭 기억하세요. ↩
값 복제하기, 1부: Clone
이전 챕터에서 우리는 소유권(ownership)과 빌림(borrowing)에 대해 배웠습니다. 특히 다음 두 가지 중요한 원칙을 기억하실 겁니다.
- Rust의 모든 값은 특정 시점에 단 하나의 소유자만 가집니다.
- 함수가 값의 소유권을 가져가면(소비하면), 호출한 쪽에서는 더 이상 그 값을 사용할 수 없습니다.
하지만 실제 코드를 짜다 보면 이 규칙이 너무 까다롭게 느껴질 때가 있습니다. 가끔은 함수에 값을 넘겨주면서도, 그 함수가 끝난 뒤에 같은 값을 계속 써야 할 일이 생기기 때문이죠.
fn consumer(s: String) { /* 값을 소비하는 함수 */ }
fn example() {
let mut s = String::from("hello");
consumer(s);
s.push_str(", world!"); // 오류: 소유권이 이동(move)했으므로 여기서 사용할 수 없습니다.
}
바로 이런 상황에서 Clone 트레이트가 구원 투수로 등장합니다.
Clone 트레이트
Clone은 Rust 표준 라이브러리에 정의된 트레이트입니다.
pub trait Clone {
fn clone(&self) -> Self;
}
clone 메서드는 self에 대한 참조를 인자로 받아, 동일한 타입을 가진 완전히 새로운 소유권 있는 인스턴스를 만들어 반환합니다.
실제 사용 예시
위의 예제에서 consumer를 호출하기 전에 clone을 사용해 새로운 String 인스턴스를 만들면 문제를 해결할 수 있습니다.
fn consumer(s: String) { /* */ }
fn example() {
let mut s = String::from("hello");
let t = s.clone(); // s를 복제하여 새로운 인스턴스 t를 만듭니다.
consumer(t); // t의 소유권을 넘겨줍니다.
s.push_str(", world!"); // s는 여전히 살아있으므로 오류가 발생하지 않습니다!
}
s의 소유권을 직접 넘겨주는 대신, 똑같이 생긴 복사본(t)을 만들어 넘겨주는 방식입니다. 덕분에 s는 consumer 호출이 끝난 뒤에도 아무런 문제 없이 사용할 수 있습니다.
메모리에서는 어떤 일이 벌어질까요?
방금 본 예제에서 메모리가 어떻게 변하는지 살펴봅시다. 먼저 let mut s = String::from("hello");가 실행된 상태입니다.
s
+---------+--------+----------+
스택 | 포인터 | 길이 | 용량 |
| | | 5 | 5 |
+--|------+--------+----------+
|
|
v
+---+---+---+---+---+
힙(Heap):| H | e | l | l | o |
+---+---+---+---+---+
여기서 let t = s.clone()이 호출되면, 데이터를 똑같이 복사하기 위해 힙 영역에 완전히 새로운 메모리 공간을 할당합니다.
s t
+---------+--------+----------+ +---------+--------+----------+
스택 | 포인터 | 길이 | 용량 | | 포인터 | 길이 | 용량 |
| | | 5 | 5 | | | | 5 | 5 |
+--|------+--------+----------+ +--|------+--------+----------+
| |
| |
v v
+---+---+---+---+---+ +---+---+---+---+---+
힙(Heap):| H | e | l | l | o | | H | e | l | l | o |
+---+---+---+---+---+ +---+---+---+---+---+
Java와 같은 언어를 써보셨다면, clone을 객체의 **깊은 복사(deep copy)**를 수행하는 도구로 이해하시면 쉽습니다.
Clone 구현하기
여러분이 만든 타입이 복제 가능하게 하려면 Clone 트레이트를 구현해야 합니다. 다행히 직접 코드를 짤 필요 없이 대부분의 경우 derive 매크로를 사용해 간단히 해결할 수 있습니다.
#[derive(Clone)]
struct MyType {
// 필드들
}
이렇게 하면 컴파일러가 알아서 MyType의 모든 필드를 하나씩 복제한 뒤, 이를 조합해 새로운 인스턴스를 만드는 Clone 구현체를 생성해 줍니다. 이 자동 생성된 코드가 궁금하다면 cargo expand 명령어나 IDE의 기능을 활용해 확인해 볼 수 있습니다.
Exercise
The exercise for this section is located in 04_traits/11_clone
값 복제하기, 2부: Copy
앞서 보았던 것과 비슷한 예제를 다시 살펴봅시다. 이번에는 String 대신 u32 타입을 사용해 보겠습니다.
fn consumer(s: u32) { /* 값을 소비하는 함수 */ }
fn example() {
let s: u32 = 5;
consumer(s); // s의 소유권이 넘어가나요?
let t = s + 1; // 여기서 s를 다시 써도 오류가 나지 않습니다!
}
놀랍게도 이 코드는 아무런 오류 없이 컴파일됩니다. String을 쓸 때는 .clone()을 직접 호출해야만 했는데, u32는 왜 그냥 되는 걸까요? 그 비밀은 바로 Copy 트레이트에 있습니다.
Copy 트레이트
Copy는 Rust 표준 라이브러리에 정의된 또 다른 중요한 트레이트입니다.
pub trait Copy: Clone { }
앞서 배운 Sized와 마찬가지로, Copy 역시 메서드가 없는 마커 트레이트입니다.
어떤 타입이 Copy를 구현하고 있다면, 명시적으로 .clone()을 호출하지 않아도 Rust가 암시적으로 값을 복제해 줍니다. u32가 바로 그 예입니다. consumer(s)가 호출될 때, Rust는 s의 데이터를 **비트 단위로 복사(bitwise copy)**하여 새로운 u32 인스턴스를 만들고 이를 함수에 전달합니다. 이 모든 과정이 자동으로 일어나기 때문에 개발자는 소유권 이동을 걱정할 필요가 없습니다.
어떤 타입이 Copy가 될 수 있을까요?
모든 타입을 Copy로 만들 수 있는 것은 아닙니다. Copy를 구현하려면 몇 가지 엄격한 조건을 만족해야 합니다.
우선 Copy는 Clone의 서브트레이트입니다. 즉, 암시적으로 복제될 수 있는 타입이라면 당연히 .clone()을 통한 명시적인 복제도 가능해야 한다는 뜻입니다.
하지만 그보다 더 중요한 조건들이 있습니다.
- 타입이 스택 메모리에 저장된 데이터(
std::mem::size_of로 계산되는 크기) 외에 추가적인 리소스(힙 메모리, 파일 핸들 등)를 관리하지 않아야 합니다. - 타입이 가변 참조(
&mut T)가 아니어야 합니다.
이 조건들이 충족되면, Rust는 데이터를 단순히 복사(메모리의 memcpy 연산과 유사)하는 것만으로도 안전하게 새로운 인스턴스를 만들 수 있다고 판단합니다.
사례 1: String은 왜 Copy가 아닐까요?
String은 Copy를 구현하지 않습니다. 왜냐하면 문자열 데이터를 저장하기 위해 힙(Heap) 메모리라는 추가 리소스를 관리하기 때문입니다.
만약 String이 Copy라면 어떤 일이 벌어질지 상상해 봅시다. String 변수를 복사하면 비트 단위 복사가 일어나고, 원본과 복사본 모두 동일한 힙 메모리 주소를 가리키게 됩니다.
s 복사된 s
+---------+--------+----------+ +---------+--------+----------+
| 포인터 | 길이 | 용량 | | 포인터 | 길이 | 용량 |
| | | 5 | 5 | | | | 5 | 5 |
+--|------+--------+----------+ +--|------+--------+----------+
| |
| |
v |
+---+---+---+---+---+ |
| H | e | l | l | o | |
+---+---+---+---+---+ |
^ |
| |
+------------------------------------+
이런 상태에서 두 변수가 범위를 벗어나 소멸될 때, 똑같은 메모리 공간을 두 번 해제하려고 시도하게 되어 이중 해제(double-free) 오류가 발생합니다. 또한 같은 데이터를 가리키는 두 개의 가변 참조가 생길 수 있어 Rust의 빌림 규칙에도 어긋나게 됩니다.
사례 2: u32와 정수 타입
u32를 포함한 모든 정수 타입은 Copy를 구현합니다. 정수는 그 자체로 메모리에 저장된 숫자일 뿐, 별도로 관리하는 외부 메모리나 리소스가 없기 때문입니다. 그저 바이트를 복사하는 것만으로 완벽하게 독립적인 새로운 정수 값을 얻을 수 있으므로 아무런 위험이 없습니다.
사례 3: &mut u32는 왜 Copy가 아닐까요?
우리는 가변 빌림에 대해 배울 때 “특정 시점에 가변 참조는 오직 하나만 존재해야 한다“는 규칙을 배웠습니다. 만약 &mut u32가 Copy라면, 복사를 통해 동일한 데이터를 가리키는 여러 개의 가변 참조를 마구 만들어낼 수 있게 됩니다. 이는 Rust의 안전성 원칙을 근본적으로 뒤흔드는 일입니다. 따라서 T가 무엇이든 &mut T는 결코 Copy가 될 수 없습니다.
Copy 구현하기
여러분이 만든 구조체가 위의 조건들을 만족한다면, 다음과 같이 derive 매크로를 사용해 간단히 Copy를 구현할 수 있습니다.
#[derive(Copy, Clone)]
struct MyStruct {
field: u32,
}
Exercise
The exercise for this section is located in 04_traits/12_copy
Drop 트레이트 (정리 트레이트)
우리는 앞서 소멸자(Destructor)에 대해 배우면서, 변수가 수명을 다해 사라질 때 drop 함수가 두 가지 일을 한다고 배웠습니다.
- 타입이 차지하고 있던 스택 메모리(즉,
std::mem::size_of바이트)를 회수합니다. - 해당 값이 관리하던 추가 리소스(예:
String의 힙 버퍼)를 정리합니다.
이 중 두 번째 단계인 ’추가 리소스 정리’를 담당하는 것이 바로 Drop 트레이트입니다.
pub trait Drop {
fn drop(&mut self);
}
Drop 트레이트는 컴파일러가 기본적으로 처리하는 메모리 회수 작업 외에, 우리가 직접 추가적인 정리 로직을 정의할 수 있게 해주는 도구입니다. drop 메서드 안에 작성한 코드는 해당 값이 범위를 벗어나 소멸되는 순간 자동으로 실행됩니다.
Drop과 Copy는 함께할 수 없습니다
앞서 Copy 트레이트를 공부할 때, 스택 메모리 이상의 추가 리소스를 관리하는 타입은 Copy를 구현할 수 없다고 배웠습니다.
그런데 컴파일러는 특정 타입이 추가 리소스를 관리하는지 아닌지 어떻게 알 수 있을까요? 바로 Drop 트레이트의 구현 여부를 보고 판단합니다. 만약 어떤 타입에 명시적인 Drop 구현이 있다면, 컴파일러는 “아, 이 타입은 정리가 필요한 무거운 리소스를 들고 있구나“라고 생각합니다. 그래서 이런 타입에는 Copy를 구현하는 것을 허용하지 않습니다.
// 필드가 없는 빈 구조체(단위 구조체)입니다.
#[derive(Clone, Copy)]
struct MyType;
impl Drop for MyType {
fn drop(&mut self) {
// 아무 내용이 없는 빈 구현이라도 상관없습니다.
}
}
위의 코드를 컴파일하려고 하면, 컴파일러는 다음과 같은 오류를 띄우며 거절할 것입니다.
error[E0184]: the trait `Copy` cannot be implemented for this type;
the type has a destructor
--> src/lib.rs:2:17
|
2 | #[derive(Clone, Copy)]
| ^^^^ 소멸자가 있는 타입에는 `Copy`를 구현할 수 없습니다.
Exercise
The exercise for this section is located in 04_traits/13_drop
마치며
이번 챕터에서 정말 많은 트레이트들을 살펴보았습니다. 하지만 이건 시작에 불과합니다! 공부해야 할 양이 너무 많아 보여도 너무 걱정하지 마세요. Rust로 코딩하다 보면 이 트레이트들을 워낙 자주 쓰게 되어서, 조만간 숨 쉬는 것처럼 자연스럽게 느껴지실 겁니다.
트레이트 사용을 위한 조언
트레이트는 강력한 도구이지만, 과하면 독이 될 수 있습니다. 다음은 트레이트를 다룰 때 기억하면 좋은 지침들입니다.
- 필요할 때만 제네릭을 쓰세요: 함수가 항상 특정한 한 가지 타입만 다룬다면 굳이 제네릭으로 만들 필요가 없습니다. 불필요한 추상화는 코드를 이해하고 관리하기만 어렵게 만듭니다.
- 실체가 있는 트레이트를 만드세요: 구현체가 단 하나뿐이라면 트레이트를 만들지 마세요. 트레이트가 불필요하다는 강력한 신호입니다.
- 표준 트레이트를 적극 활용하세요:
Debug,PartialEq같은 표준 트레이트들을 구현하면 여러분의 코드가 훨씬 Rust답게(Idiomatic) 보이고, 다른 라이브러리들과도 잘 어우러집니다. - 외부 트레이트 구현에 주저하지 마세요: 다른 크레이트나 에코시스템의 기능을 활용하기 위해 필요한 트레이트가 있다면 적극적으로 구현해 보세요.
- 테스트를 위한 과도한 제네릭은 피하세요: 모킹(Mocking)을 위해 코드를 제네릭으로 도배하는 것은 나중에 큰 유지보수 비용으로 돌아올 수 있습니다. 더 나은 테스트 전략들이 많이 있으니, 고퀄리티 테스트에 관심이 있다면 테스팅 마스터클래스를 참고해 보세요.
지식 확인하기
다음 섹션으로 넘어가기 전에, 지금까지 배운 내용을 총정리하는 마지막 연습 문제를 풀어봅시다. 이번 연습 문제는 힌트가 거의 없습니다. 오직 문제 설명과 테스트 코드에만 의존해서 스스로 길을 찾아보세요!
Exercise
The exercise for this section is located in 04_traits/14_outro
티켓 모델링 (2부)
이전 챕터에서 만든 Ticket 구조체는 기초를 다지기에 좋았지만, 아직은 “Rust 초보자입니다!“라고 외치는 듯한 느낌이 있습니다.
이번 챕터에서는 Rust의 도메인 모델링 기술을 한 단계 더 정교하게 다듬어 보겠습니다. 이 과정에서 다음과 같은 핵심 개념들을 배우게 됩니다:
- 열거형(Enums): 데이터 모델링을 위한 Rust의 가장 강력한 기능 중 하나입니다.
- Option 타입: 널 허용성(Nullability)을 안전하게 다루기 위해 사용합니다.
- Result 타입: 실패 가능성(Fallibility)이 있는, 즉 복구 가능한 오류를 모델링할 때 사용합니다.
- Debug 및 Display 트레이트(Traits): 데이터를 출력하고 형식을 지정하는 방법을 배웁니다.
- Error 트레이트: 사용자 정의 오류 타입을 만드는 방법을 익힙니다.
- TryFrom 및 TryInto 트레이트: 실패할 수도 있는 타입 변환을 안전하게 처리합니다.
- Rust의 패키지 시스템: 라이브러리와 바이너리의 차이점, 그리고 외부 크레이트(Crate)를 사용하는 방법을 알아봅니다.
Exercise
The exercise for this section is located in 05_ticket_v2/00_intro
열거형(Enums)
이전 챕터에서 작성한 유효성 검사 로직을 떠올려 보세요. 티켓의 상태는 To-Do, InProgress, Done 중 하나여야만 유효합니다. 하지만 현재 Ticket 구조체의 status 필드나 new 메서드의 매개변수 타입을 보면 이 점이 명확히 드러나지 않습니다.
#[derive(Debug, PartialEq)]
pub struct Ticket {
title: String,
description: String,
status: String,
}
impl Ticket {
pub fn new(
title: String,
description: String,
status: String
) -> Self {
// [...]
}
}
두 곳 모두 상태를 나타내기 위해 String 타입을 사용하고 있습니다. String은 너무 범용적인 타입이라, status가 가질 수 있는 값이 제한되어 있다는 정보를 전달하지 못합니다. 더 큰 문제는, Ticket::new를 호출하는 쪽에서 입력한 상태값이 유효한지 여부를 오직 **실행 시점(Runtime)**에만 알 수 있다는 점입니다.
**열거형(Enums)**을 사용하면 이 문제를 훨씬 더 깔끔하게 해결할 수 있습니다.
enum
열거형(Enums)은 **베리언트(Variants)**라고 부르는 고정된 값들의 집합을 정의할 수 있는 타입입니다. Rust에서는 enum 키워드를 사용해 열거형을 정의합니다.
enum Status {
ToDo,
InProgress,
Done,
}
struct와 마찬가지로, enum을 정의하면 새로운 Rust 타입이 만들어집니다.
Exercise
The exercise for this section is located in 05_ticket_v2/01_enum
패턴 매칭(Match)
“열거형을 정의했으니, 이제 이걸로 뭘 할 수 있을까?“라는 의문이 생길 수 있습니다. 열거형을 활용하는 가장 대표적인 방법은 바로 **패턴 매칭(Matching)**입니다.
enum Status {
ToDo,
InProgress,
Done
}
impl Status {
fn is_done(&self) -> bool {
match self {
Status::Done => true,
// `|` 연산자를 사용하면 여러 패턴을 한꺼번에 매칭할 수 있습니다.
// 여기서는 "`Status::ToDo` 또는 `Status::InProgress`인 경우"로 해석합니다.
Status::InProgress | Status::ToDo => false
}
}
}
match 문을 사용하면 Rust의 값을 여러 **패턴(Patterns)**과 비교할 수 있습니다. 타입 수준에서 작동하는 if 문이라고 생각하면 이해하기 쉽습니다. 예를 들어 status가 Done 베리언트라면 첫 번째 블록을 실행하고, InProgress나 ToDo 베리언트라면 두 번째 블록을 실행합니다.
완전성(Exhaustiveness)
여기서 꼭 기억해야 할 핵심은 match 문이 **완전(Exhaustive)**해야 한다는 점입니다. 즉, 열거형의 모든 베리언트를 빠짐없이 처리해야 합니다. 만약 하나라도 빠뜨린다면 Rust는 컴파일 시점에 에러를 발생시켜 여러분을 도와줄 것입니다.
예를 들어, ToDo 베리언트 처리를 깜빡했다면 다음과 같이 작성하게 될 텐데요.
match self {
Status::Done => true,
Status::InProgress => false,
}
이 경우 컴파일러는 다음과 같은 에러 메시지를 보여주며 경고합니다.
error[E0004]: non-exhaustive patterns: `ToDo` not covered
--> src/main.rs:5:9
|
5 | match status {
| ^^^^^^^^^^^^ pattern `ToDo` not covered
이것은 매우 강력한 기능입니다! 프로젝트가 커지면서 새로운 상태(예: Blocked)를 추가해야 할 때가 올 것입니다. 이때 Rust 컴파일러는 새로운 상태에 대한 처리가 누락된 모든 match 문을 찾아내 에러를 발생시킵니다.
Rust 개발자들이 “컴파일러 주도 리팩토링“을 선호하는 이유가 바로 여기에 있습니다. 컴파일러가 다음에 무엇을 고쳐야 할지 정확히 짚어주므로, 여러분은 그 보고를 따라 수정하기만 하면 됩니다.
나머지 패턴 처리(Catch-all)
모든 베리언트를 개별적으로 처리할 필요가 없을 때는 _ 패턴을 사용하여 나머지를 한꺼번에 처리할 수 있습니다.
match status {
Status::Done => true,
_ => false
}
_ 패턴은 앞의 패턴들과 매칭되지 않은 모든 경우를 의미합니다.
코드의 정확성이 중요하다면 가급적 나머지 패턴 사용을 피하세요. 대신 모든 베리언트를 명시적으로 매칭하여, 새로운 베리언트가 추가될 때마다 컴파일러의 도움을 받아 관련 로직을 검토하는 것이 좋습니다.
Exercise
The exercise for this section is located in 05_ticket_v2/02_match
데이터를 가질 수 있는 베리언트(Variants)
enum Status {
ToDo,
InProgress,
Done,
}
우리가 만든 Status 열거형은 흔히 C 스타일 열거형이라고 부르는 형태입니다. 각 베리언트는 이름이 붙은 상수처럼 단순한 레이블 역할을 하죠. C, C++, Java, Python 등 많은 언어에서 볼 수 있는 방식입니다.
하지만 Rust의 열거형은 여기서 한 걸음 더 나아갑니다. 바로 각 베리언트에 데이터를 담을 수 있다는 점입니다.
베리언트 데이터(Variant Data)
예를 들어, 현재 티켓을 담당하고 있는 사람의 이름을 저장하고 싶다고 가정해 봅시다. 이 정보는 티켓이 ’진행 중’일 때만 필요하며, ’할 일’이나 ‘완료’ 상태일 때는 필요하지 않습니다. 이때 InProgress 베리언트에 String 필드를 추가하여 이를 모델링할 수 있습니다.
enum Status {
ToDo,
InProgress {
assigned_to: String,
},
Done,
}
이제 InProgress는 **구조체 형태의 베리언트(Struct-like variant)**가 되었습니다. 문법도 구조체를 정의할 때와 비슷하죠? 단지 열거형 내부에 ’인라인’으로 정의되었다는 점만 다릅니다.
베리언트 데이터 접근하기
Status 인스턴스에서 바로 assigned_to 필드에 접근하려고 하면 어떻게 될까요?
let status: Status = /* */;
// 이 코드는 컴파일되지 않습니다.
println!("담당자: {}", status.assigned_to);
컴파일러는 다음과 같은 에러를 발생시킵니다.
error[E0609]: no field `assigned_to` on type `Status`
--> src/main.rs:5:40
|
5 | println!("Assigned to: {}", status.assigned_to);
| ^^^^^^^^^^^ unknown field
assigned_to는 **특정 베리언트(InProgress)**에만 속한 필드이기 때문에, 모든 Status 인스턴스에서 이 필드가 존재한다고 보장할 수 없기 때문입니다. 따라서 이 데이터에 접근하려면 반드시 패턴 매칭을 사용해야 합니다.
match status {
Status::InProgress { assigned_to } => {
println!("담당자: {}", assigned_to);
},
Status::ToDo | Status::Done => {
println!("할 일 또는 완료된 상태입니다.");
}
}
바인딩(Binding)
매치 패턴 Status::InProgress { assigned_to }에서 assigned_to는 바인딩(Binding) 역할을 합니다. Status::InProgress 베리언트를 **구조 분해(Destructuring)**하여, 그 안의 필드 값을 assigned_to라는 이름의 새로운 변수에 담는 것이죠. 원한다면 필드 이름을 다른 변수 이름으로 바꿀 수도 있습니다.
match status {
Status::InProgress { assigned_to: person } => {
println!("담당자: {}", person);
},
Status::ToDo | Status::Done => {
println!("할 일 또는 완료된 상태입니다.");
}
}
Exercise
The exercise for this section is located in 05_ticket_v2/03_variants_with_data
간결한 제어 흐름
이전 연습 문제에서 작성한 코드는 아마 다음과 비슷한 모습일 것입니다.
impl Ticket {
pub fn assigned_to(&self) -> &str {
match &self.status {
Status::InProgress { assigned_to } => assigned_to,
Status::Done | Status::ToDo => {
panic!(
"진행 중(`In-Progress`)인 티켓만 담당자를 가질 수 있습니다."
)
}
}
}
}
여기서 우리는 오직 Status::InProgress 베리언트에만 관심이 있습니다. 그런데도 나머지 모든 베리언트를 일일이 매칭해야 할까요?
이런 상황을 위해 Rust는 더 간결한 구문을 제공합니다.
if let
if let 구문을 사용하면 다른 베리언트들을 일일이 처리할 필요 없이, 관심 있는 단 하나의 베리언트에 대해서만 패턴 매칭을 수행할 수 있습니다.
if let을 사용하여 assigned_to 메서드를 단순하게 고쳐보겠습니다.
impl Ticket {
pub fn assigned_to(&self) -> &str {
if let Status::InProgress { assigned_to } = &self.status {
assigned_to
} else {
panic!(
"진행 중(`In-Progress`)인 티켓만 담당자를 가질 수 있습니다."
);
}
}
}
let-else
만약 else 분기에서 조기 반환(Early return)을 해야 하는 경우라면(패닉도 조기 반환의 일종입니다), let-else 구문을 사용하는 것이 가장 깔끔합니다.
impl Ticket {
pub fn assigned_to(&self) -> &str {
let Status::InProgress { assigned_to } = &self.status else {
panic!(
"진행 중(`In-Progress`)인 티켓만 담당자를 가질 수 있습니다."
);
};
assigned_to
}
}
let-else를 사용하면 구조 분해된 변수를 들여쓰기 수준의 변화 없이(오른쪽으로 밀리지 않고) 그대로 사용할 수 있어 가독성이 좋아집니다.
스타일 팁
if let과 let-else는 모두 관용적인(Idiomatic) Rust 표현입니다. 코드의 가독성을 높이기 위해 상황에 맞게 적절히 사용하세요. 다만, 로직이 복잡해진다면 언제나 우리에겐 든든한 match가 있다는 사실을 잊지 마세요!
Exercise
The exercise for this section is located in 05_ticket_v2/04_if_let
널 허용성(Nullability)
지금까지의 assigned_to 구현은 조금 거칠었습니다. ’할 일’이나 ‘완료’ 상태인 티켓에 대해 무조건 패닉을 일으키는 것은 그리 좋은 방법이 아니죠. 이때 Rust의 Option 타입을 활용하면 훨씬 세련되게 처리할 수 있습니다.
Option
Option은 값이 존재할 수도 있고 없을 수도 있는 **널 허용 값(Nullable value)**을 나타내는 Rust의 기본 타입입니다. 표준 라이브러리에는 다음과 같이 열거형으로 정의되어 있습니다.
enum Option<T> {
Some(T),
None,
}
Option은 값이 있으면 Some(T), 없으면 None이라는 정보를 명확하게 담고 있습니다. 또한 Rust는 개발자가 두 가지 경우를 모두 명시적으로 처리하도록 강제합니다. 만약 None인 경우를 깜빡하고 처리하지 않으면 컴파일 에러가 발생하죠. 이는 ‘암시적으로’ 널을 허용하는 다른 언어들에 비해 엄청난 장점입니다. 널 체크를 잊어서 발생하는 런타임 에러를 사전에 방지할 수 있기 때문입니다.
Option의 정의 살펴보기
Option의 정의를 보면 이전에 보지 못했던 새로운 구문이 등장합니다. 바로 **튜플 형태의 베리언트(Tuple-like variants)**입니다.
튜플 형태의 베리언트(Tuple-like variants)
Option에는 Some(T)와 None이라는 두 베리언트가 있습니다. 여기서 Some은 이름 없는 필드를 가지는 튜플 형태의 베리언트입니다.
이런 형태는 필드가 하나뿐이거나, Option처럼 무언가를 감싸는 ‘래퍼(Wrapper)’ 타입에서 자주 사용됩니다.
튜플 형태의 구조체(Tuple-like structs)
열거형뿐만 아니라 구조체도 튜플 형태로 정의할 수 있습니다.
struct Point(i32, i32);
이렇게 정의한 Point 인스턴스의 필드에는 인덱스를 사용해 접근할 수 있습니다.
let point = Point(3, 4);
let x = point.0;
let y = point.1;
튜플(Tuples)
방금 ’튜플 형태’라는 말을 썼는데, 정작 튜플이 무엇인지 아직 배우지 않았네요! 튜플은 여러 가지 타입의 값들을 하나의 단위로 묶어주는 Rust의 기본 타입입니다.
// 같은 타입의 값 두 개를 묶은 튜플 let first: (i32, i32) = (3, 4);
// 서로 다른 타입의 값 세 개를 묶은 튜플 let second: (i32, u32, u8) = (-42, 3, 8);
문법은 아주 간단합니다. 괄호 안에 값의 타입들을 쉼표로 구분해 적어주면 됩니다. 각 값에 접근할 때는 점(.) 표기법과 인덱스를 사용합니다.
assert_eq!(second.0, -42);
assert_eq!(second.1, 3);
assert_eq!(second.2, 8);
튜플은 굳이 전용 구조체 타입을 따로 정의하기에는 번거로운, 간단한 데이터 묶음을 만들 때 매우 유용합니다.
Exercise
The exercise for this section is located in 05_ticket_v2/05_nullability
실패 가능성(Fallibility)
이전 연습 문제에서 작성한 Ticket::new 함수를 다시 한번 살펴봅시다.
impl Ticket {
pub fn new(
title: String,
description: String,
status: Status
) -> Ticket {
if title.is_empty() {
panic!("제목은 비어 있을 수 없습니다.");
}
if title.len() > 50 {
panic!("제목은 50바이트를 초과할 수 없습니다.");
}
if description.is_empty() {
panic!("설명은 비어 있을 수 없습니다.");
}
if description.len() > 500 {
panic!("설명은 500바이트를 초과할 수 없습니다.");
}
Ticket {
title,
description,
status,
}
}
}
이 함수는 유효성 검사 중 하나라도 통과하지 못하면 바로 패닉을 일으킵니다. 이는 호출한 쪽에서 오류를 직접 처리할 기회를 주지 않기 때문에 그리 바람직한 방식은 아닙니다.
이제 Rust의 핵심 오류 처리 메커니즘인 Result 타입을 배워볼 차례입니다.
Result 타입
Result 타입은 표준 라이브러리에 정의된 열거형으로, 다음과 같은 구조를 가집니다.
enum Result<T, E> {
Ok(T),
Err(E),
}
두 가지 베리언트가 있습니다.
Ok(T): 작업이 성공했음을 나타내며, 결과값인T를 담고 있습니다.Err(E): 작업이 실패했음을 나타내며, 발생한 오류 정보인E를 담고 있습니다.
Ok와 Err 모두 제네릭(Generic) 타입을 사용하므로, 성공했을 때와 실패했을 때 반환할 타입을 자유롭게 지정할 수 있습니다.
예외(Exceptions)는 없습니다
Rust에서 복구 가능한 오류는 **값(Value)**으로 취급됩니다. 즉, 오류도 다른 값들과 마찬가지로 함수의 인자로 전달되거나 변수에 저장될 수 있는 평범한 타입의 인스턴스일 뿐입니다.
이는 Python이나 C# 같은 언어들과는 매우 큰 차이점입니다. 그런 언어들은 오류를 알리기 위해 **예외(Exceptions)**를 던지는 방식을 사용하죠. 하지만 예외 방식은 몇 가지 단점이 있습니다.
- 함수의 선언부(Signature)만 보고는 이 함수가 예외를 던지는지 알 수 없습니다.
- 예외를 던진다면 정확히 어떤 종류의 예외가 발생하는지도 알기 어렵습니다.
- 이를 확인하려면 문서를 일일이 읽거나 함수 내부의 구현 코드를 직접 확인해야 합니다.
- 또한 예외 처리 로직이 예외 발생 지점과 멀리 떨어져 있는 경우가 많아 코드의 흐름을 파악하기 어렵게 만듭니다.
실패 가능성을 타입 시스템에 담다
반면 Rust는 Result를 통해 함수의 선언부에 실패 가능성을 명시하도록 강제합니다. 함수가 실패할 가능성이 있고 호출자가 이를 처리해야 한다면, 반드시 Result를 반환해야 합니다.
// 선언부만 보고도 이 함수가 실패할 수 있다는 것을 즉시 알 수 있습니다.
// 또한 에러 타입인 `ParseIntError`를 보고 어떤 실패 상황이 발생할지 예측할 수 있죠.
fn parse_int(s: &str) -> Result<i32, ParseIntError> {
// ...
}
이것이 바로 Result의 가장 큰 장점입니다. 실패 가능성을 ’명시적’으로 만들어 줍니다.
물론 Rust에도 패닉(Panic)은 존재합니다. 다른 언어의 예외처럼 타입 시스템이 추적해주지 않죠. 하지만 패닉은 오직 복구 불가능한 치명적인 오류를 위해서만 아껴서 사용해야 합니다.
Exercise
The exercise for this section is located in 05_ticket_v2/06_fallibility
언래핑(Unwrap)
이제 Ticket::new는 잘못된 입력이 들어와도 패닉을 일으키는 대신 Result를 반환합니다. 그렇다면 이 함수를 호출하는 쪽에서는 어떻게 대응해야 할까요?
실패를 무시할 수 없습니다
예외와 달리 Rust의 Result는 호출하는 쪽에서 반드시 오류를 처리하도록 강제합니다. Result를 반환하는 함수를 호출할 때, 오류 발생 가능성을 은근슬쩍 무시하고 지나가는 것은 허용되지 않습니다.
fn parse_int(s: &str) -> Result<i32, ParseIntError> {
// ...
}
// 이 코드는 컴파일되지 않습니다. 오류 처리를 빠뜨렸기 때문입니다.
// 성공한 값을 꺼내거나(Unwrap) 오류를 처리하려면, `match` 문을 사용하거나
// `Result`가 제공하는 유용한 메서드들을 활용해야 합니다.
let number = parse_int("42") + 2;
Result를 처리하는 두 가지 방법
Result를 반환하는 함수를 만났을 때, 주로 다음과 같은 두 가지 방법을 선택합니다.
-
실패하면 바로 패닉 일으키기: 작업이 실패했을 때 더 이상 진행할 의미가 없다면
unwrap이나expect메서드를 사용합니다.// `parse_int`가 `Err`를 반환하면 즉시 패닉이 발생합니다. let number = parse_int("42").unwrap(); // `expect`를 사용하면 패닉이 발생했을 때 보여줄 커스텀 메시지를 지정할 수 있습니다. let number = parse_int("42").expect("정수를 파싱하는 데 실패했습니다."); -
명시적으로 오류 처리하기:
match표현식을 사용해Result를 분해하고, 성공(Ok)과 실패(Err) 케이스를 각각 나누어 처리합니다.match parse_int("42") { Ok(number) => println!("파싱된 숫자: {}", number), Err(err) => eprintln!("에러 발생: {}", err), }
Exercise
The exercise for this section is located in 05_ticket_v2/07_unwrap
오류 열거형 (Error Enums)
이전 연습 문제에서 작성한 해결책이 조금 어색하게 느껴졌을 수도 있습니다. 문자열을 패턴 매칭(Pattern matching)에 직접 사용하는 것은 좋은 방법이 아니기 때문이죠! 만약 동료가 가독성을 높이기 위해 Ticket::new가 반환하는 오류 메시지를 수정한다면, 그 함수를 호출하던 여러분의 코드는 갑자기 작동하지 않게 될 것입니다(Breaking changes).
이 문제를 해결할 수 있는 도구는 이미 우리가 배운 것에 있습니다. 바로 **열거형(Enum)**입니다!
오류에 대처하기
발생한 특정 오류 상황에 따라 호출자가 다르게 동작하도록 만들고 싶다면, 열거형을 사용해 다양한 오류 케이스를 정의할 수 있습니다.
// 문자열에서 `u32`를 파싱할 때 발생할 수 있는
// 다양한 오류 상황을 나타내는 오류 열거형.
enum U32ParseError {
NotANumber,
TooLarge,
Negative,
}
오류 열거형을 사용하면 발생 가능한 오류 상황들을 타입 시스템에 직접 인코딩하게 됩니다. 이는 실패할 가능성이 있는 함수의 **시그니처(Signature)**의 일부가 되죠. 이제 호출하는 쪽에서는 match 표현식을 사용해 각각의 오류 케이스에 맞게 대응할 수 있어, 오류 처리가 훨씬 명확하고 간결해집니다.
match s.parse_u32() {
Ok(n) => n,
Err(U32ParseError::Negative) => 0,
Err(U32ParseError::TooLarge) => u32::MAX,
Err(U32ParseError::NotANumber) => {
panic!("숫자가 아닙니다: {}", s);
}
}
Exercise
The exercise for this section is located in 05_ticket_v2/08_error_enums
Error 트레이트 (Error Trait)
오류 보고 (Error Reporting)
이전 연습 문제에서는 TitleError 베리언트(Variant)를 구조 분해(Destructuring)하여 오류 메시지를 추출한 뒤 panic! 매크로에 전달해야 했습니다. 이것이 바로 **오류 보고(Error reporting)**의 아주 기초적인 예입니다. 즉, 오류 타입을 사용자와 개발자, 혹은 시스템 관리자가 이해할 수 있는 형태의 메시지로 변환하는 것이죠.
하지만 Rust의 모든 개발자가 저마다 다른 방식으로 오류를 보고한다면 어떨까요? 시간 낭비일 뿐만 아니라, 프로젝트마다 방식이 달라 코드의 일관성이 크게 떨어질 것입니다. 이것이 바로 Rust가 표준 라이브러리(std)에서 std::error::Error 트레이트를 제공하는 이유입니다.
Error 트레이트
Result의 Err 베리언트 타입 자체에는 특별한 제약이 없지만, 보통은 Error 트레이트를 구현한 타입을 사용하는 것이 좋습니다.
Error 트레이트는 Rust 오류 처리 체계의 핵심입니다.
// `Error` 트레이트의 정의 (약간 단순화됨)
pub trait Error: Debug + Display {}
From 트레이트를 다룰 때 보았던 : 구문이 기억나시나요? 이는 **상위 트레이트(Supertrait)**를 지정할 때 사용합니다.
Error 트레이트의 경우, Debug와 Display라는 두 개의 상위 트레이트가 있습니다. 즉, 어떤 타입이 Error 트레이트를 구현하려면 반드시 Debug와 Display 트레이트도 함께 구현해야 한다는 뜻입니다.
Display와 Debug 트레이트
이미 이전 연습 문제에서 Debug 트레이트를 보신 적이 있을 겁니다. assert_eq!에서 단언(Assertion)이 실패했을 때 변수의 값을 개발자가 보기 좋게 출력해주던 그 트레이트 말이죠.
기능적인 측면에서 Display와 Debug는 비슷합니다. 두 트레이트 모두 해당 타입을 문자열과 같은 형태로 변환하는 방법을 정의합니다.
// `Debug` 정의 pub trait Debug {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}
// `Display` 정의 pub trait Display {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error>;
}
결정적인 차이는 그 목적에 있습니다.
Display: 일반 사용자에게 보여줄 최종 메시지를 위한 것입니다.Debug: 개발자나 운영자에게 유용한, 시스템 내부의 저수준 정보(Low-level representation)를 보여주기 위한 것입니다.
그렇기 때문에 Debug 트레이트는 #[derive(Debug)] 어트리뷰트를 사용해 자동으로 생성할 수 있지만, Display 트레이트는 어떤 메시지를 보여줄지 여러분이 직접 수동으로 구현해야 합니다.
Exercise
The exercise for this section is located in 05_ticket_v2/09_error_trait
라이브러리(Library)와 바이너리(Binary)
이전 연습 문제에서 TicketNewError에 Error 트레이트를 구현하려니 코드가 꽤 많았죠?
Display를 직접 구현하고, Error를 위한 impl 블록도 작성해야 했습니다.
이런 번거로움을 덜어주는 도구가 있습니다. 바로 thiserror라는 Rust 크레이트입니다. thiserror는 **절차적 매크로(Procedural macro)**를 사용해 사용자 정의 오류 타입 생성을 훨씬 간편하게 만들어 줍니다.
하지만 잠시만요! thiserror는 외부 라이브러리이며, 우리가 처음으로 프로젝트에 추가하게 될 **의존성(Dependency)**입니다. 외부 라이브러리를 사용하기 전에, 먼저 Rust의 패키징 시스템에 대해 간단히 알아봅시다.
패키지(Package)란 무엇인가요?
Rust **패키지(Package)**는 Cargo.toml 파일에 의해 정의됩니다. 이 파일을 **매니페스트(Manifest)**라고도 부르죠.
[package] 섹션에서는 패키지의 이름, 버전과 같은 메타데이터를 설정할 수 있습니다.
지금 풀고 있는 연습 문제 디렉토리의 Cargo.toml 파일을 한번 확인해 보세요!
크레이트(Crate)란 무엇인가요?
하나의 패키지 안에는 **타겟(Target)**이라고도 불리는 하나 이상의 **크레이트(Crate)**가 있을 수 있습니다. 가장 흔히 볼 수 있는 두 종류는 **바이너리 크레이트(Binary crate)**와 **라이브러리 크레이트(Library crate)**입니다.
바이너리(Binary)
바이너리는 실제로 실행할 수 있는 프로그램입니다. 컴파일하면 **실행 파일(Executable file)**이 만들어지죠. 바이너리 크레이트에는 반드시 main이라는 이름의 함수가 있어야 합니다. 이 함수가 프로그램이 시작되는 **진입점(Entry point)**이 됩니다.
라이브러리(Library)
반면 라이브러리는 그 자체로는 실행할 수 없습니다. 대신, 다른 패키지에서 해당 라이브러리의 코드를 가져와서 사용할 수 있습니다. 라이브러리는 함수, 타입 등을 하나로 묶어 다른 패키지가 사용할 수 있는 의존성의 역할을 합니다.
지금까지 여러분이 해결한 모든 연습 문제들은 테스트 세트(Test suite)가 포함된 라이브러리 형태였습니다.
몇 가지 규칙(Conventions)
Rust 패키지에는 기억해두면 좋은 몇 가지 관습적인 규칙이 있습니다.
- 소스 코드는 보통
src디렉토리에 둡니다. - 만약
src/lib.rs파일이 있다면,cargo는 이 패키지에 라이브러리 크레이트가 포함되어 있다고 판단(Infer)합니다. - 만약
src/main.rs파일이 있다면,cargo는 이 패키지에 바이너리 크레이트가 포함되어 있다고 판단합니다.
물론 Cargo.toml 파일에서 직접 타겟을 선언해 이 규칙을 바꿀 수도 있습니다. 더 자세한 내용은 cargo 문서를 참고하세요.
마지막으로, 하나의 패키지에는 여러 개의 바이너리 크레이트가 있을 수 있지만, 라이브러리 크레이트는 단 하나만 존재할 수 있다는 점을 꼭 기억하세요!
Exercise
The exercise for this section is located in 05_ticket_v2/10_packages
의존성 (Dependencies)
Rust 프로젝트에서는 Cargo.toml 파일의 [dependencies] 섹션에 다른 패키지를 나열하여 도움을 받을 수 있습니다. 가장 일반적인 방법은 아래와 같이 이름과 버전을 적어주는 것입니다.
[dependencies]
thiserror = "1"
위의 예시는 thiserror 크레이트의 최소 1.0.0 버전을 프로젝트의 의존성(Dependency)으로 추가하겠다는 뜻입니다.
Rust는 기본적으로 공식 패키지 저장소인 crates.io에서 이 크레이트들을 가져옵니다.
cargo build를 실행하면 cargo는 다음과 같은 과정을 거칩니다.
- 의존성 해결(Dependency Resolution): 필요한 크레이트들의 버전을 맞추는 작업
- 다운로드: 크레이트들을 가져오는 작업
- 컴파일: 여러분의 코드와 가져온 의존성들을 함께 빌드하는 작업
만약 Cargo.lock 파일이 있고 Cargo.toml 파일의 의존성 정보가 바뀌지 않았다면, cargo는 의존성 해결 단계를 건너뜁니다.
**락파일(Lockfile)**인 Cargo.lock은 의존성 해결이 성공적으로 끝난 뒤 자동으로 생성됩니다. 여기에는 여러분의 프로젝트에서 사용되는 모든 의존성의 정확한 버전이 기록되어 있죠. 덕분에 다른 환경(예: 다른 개발자의 컴퓨터나 CI 서버)에서도 항상 똑같은 버전의 라이브러리를 사용해 빌드할 수 있게 됩니다. 따라서 협업할 때는 Cargo.lock 파일을 버전 관리 시스템(Git 등)에 꼭 포함시켜야 합니다.
의존성들을 최신 버전(호환 가능한 범위 내)으로 업데이트하고 싶다면 cargo update 명령어를 사용하세요.
경로 의존성 (Path Dependencies)
같은 컴퓨터에 있는 다른 로컬 패키지를 의존성으로 추가할 수도 있습니다. 여러 패키지로 구성된 프로젝트를 진행할 때 유용합니다.
[dependencies]
my-library = { path = "../my-library" }
여기서 경로는 Cargo.toml 파일의 위치를 기준으로 합니다.
기타 소스
의존성을 가져올 수 있는 더 다양한 방법은 Cargo 공식 문서에서 확인해 보세요.
개발 의존성 (Dev Dependencies)
실제 프로그램 실행에는 필요 없지만, 개발 단계(특히 테스트)에서만 필요한 라이브러리들도 있습니다. 이런 것들은 Cargo.toml의 [dev-dependencies] 섹션에 적어줍니다.
[dev-dependencies]
static_assertions = "1.1.0"
이 섹션에 선언된 의존성들은 cargo test를 실행할 때만 불러옵니다. 이 책에서도 테스트 코드를 더 깔끔하게 작성하기 위해 일부 개발 의존성을 사용하고 있답니다.
Exercise
The exercise for this section is located in 05_ticket_v2/11_dependencies
thiserror
조금 돌아가는 길이었나요? 하지만 꼭 거쳐야 하는 과정이었습니다! 이제 다시 본론으로 돌아가서 **사용자 정의 오류 타입(Custom Error Type)**과 thiserror 라이브러리에 대해 이야기해 봅시다.
사용자 정의 오류 타입
우리는 사용자 정의 오류 타입에 대해 Error 트레이트를 “수동으로” 구현하는 방법을 배웠습니다. 하지만 프로젝트가 커지고 관리해야 할 오류 타입이 수십 개로 늘어난다면 어떨까요? 매번 똑같은 코드를 반복해서 작성하는 일은 무척 번거로울 것입니다.
이런 반복적인 코드, 즉 **상용구(Boilerplate)**를 줄여주는 도구가 바로 thiserror입니다. thiserror는 **절차적 매크로(Procedural macro)**를 통해 아주 간단하게 사용자 정의 오류 타입을 만들 수 있게 도와줍니다.
#[derive(thiserror::Error, Debug)]
enum TicketNewError {
#[error("{0}")]
TitleError(String),
#[error("{0}")]
DescriptionError(String),
}
여러분도 직접 매크로를 만들 수 있습니다
지금까지 우리가 사용한 derive 매크로들은 대부분 Rust 표준 라이브러리에서 제공한 것들이었습니다.
thiserror::Error는 타사(Third-party) 라이브러리에서 가져온 derive 매크로를 경험하는 첫 사례가 되겠네요.
derive 매크로는 컴파일 단계에서 Rust 코드를 생성해주는 **절차적 매크로(Procedural macro)**의 한 종류입니다. 이 책에서는 매크로를 직접 작성하는 복잡한 방법까지 다루지는 않지만, 원한다면 여러분도 자신만의 매크로를 만들 수 있다는 점을 기억해 두세요! 이건 나중에 중급 이상의 과정에서 만나게 될 흥미로운 주제입니다.
사용자 정의 구문 (Custom Syntax)
절차적 매크로는 저마다 독특한 구문을 사용할 수 있으며, 보통 라이브러리 문서에 자세히 설명되어 있습니다.
thiserror에서 자주 사용하는 구문은 다음과 같습니다.
#[derive(thiserror::Error)]:thiserror의 기능을 빌려 내가 만든 타입에Error트레이트를 자동으로 부여하겠다는 선언입니다.#[error("{0}")]: 각 오류 케이스(베리언트)에 대한Display구현 내용을 적는 곳입니다.{0}은 오류가 출력될 때 해당 베리언트의 첫 번째 필드(여기서는String)의 값으로 바뀌게 됩니다.
Exercise
The exercise for this section is located in 05_ticket_v2/12_thiserror
TryFrom 및 TryInto 트레이트
이전 장에서 우리는 실패할 리 없는 타입 변환을 위한 Rust의 표준 인터페이스인 From 및 Into 트레이트를 살펴보았습니다. 하지만 만약 변환이 항상 성공하리라는 보장이 없다면 어떻게 해야 할까요?
이제 우리는 오류(Error) 처리에 대해 충분히 알고 있으니, From과 Into에 대응하는 실패 가능성이 있는(Fallible) 변환 도구인 TryFrom과 TryInto에 대해 이야기해 볼 수 있습니다.
TryFrom 및 TryInto
TryFrom과 TryInto 역시 From/Into와 마찬가지로 std::convert 모듈에 정의되어 있습니다.
pub trait TryFrom<T>: Sized {
type Error;
fn try_from(value: T) -> Result<Self, Self::Error>;
}
pub trait TryInto<T>: Sized {
type Error;
fn try_into(self) -> Result<T, Self::Error>;
}
이 트레이트들과 From/Into의 가장 큰 차이점은 반환 타입이 Result라는 점입니다. 덕분에 변환에 실패하더라도 프로그램이 패닉(Panic)에 빠지지 않고, 대신 무엇이 잘못되었는지 알려주는 오류를 안전하게 반환할 수 있습니다.
Self::Error
TryFrom과 TryInto는 모두 **연관 타입(Associated type)**인 Error를 가지고 있습니다. 이것을 통해 트레이트를 구현하는 쪽에서는 해당 변환 상황에 가장 적절한 오류 타입을 자유롭게 지정할 수 있습니다.
Self::Error는 트레이트 내부에서 정의된 Error 연관 타입을 가리키는 방식입니다.
상호 관계(Duality)
From 및 Into와 마찬가지로, TryFrom과 TryInto도 긴밀하게 연결되어 있습니다. 특정 타입에 대해 TryFrom을 구현하면, Rust는 TryInto 구현체를 자동으로 제공해 줍니다. 그러니 보통은 TryFrom만 구현하면 된답니다!
Exercise
The exercise for this section is located in 05_ticket_v2/13_try_from
Error::source 메서드
Error 트레이트에 대한 설명을 마무리하기 위해 꼭 알아야 할 것이 하나 더 있습니다. 바로 source 메서드입니다.
// `Error` 트레이트의 전체 정의입니다!
pub trait Error: Debug + Display {
fn source(&self) -> Option<&(dyn Error + 'static)> {
None
}
}
source 메서드는 **오류의 원인(Error source)**이 있는 경우, 그 원인에 접근할 수 있게 해주는 방법입니다. 오류는 종종 연쇄적으로 발생하곤 합니다. 예를 들어, “데이터베이스 호스트 이름을 확인할 수 없음“이라는 저수준 오류 때문에 “데이터베이스에 연결할 수 없음“이라는 고수준 오류가 발생할 수 있죠. 이럴 때 source 메서드를 사용하면 오류의 연결 고리를 “추적“할 수 있으며, 로그에서 오류의 맥락을 정확히 파악하고 싶을 때 무척 유용합니다.
source 구현하기
Error 트레이트는 기본적으로 항상 None(원인 없음)을 반환하도록 설정되어 있습니다. 그래서 이전 연습 문제에서는 굳이 source를 신경 쓰지 않아도 됐던 것이죠. 하지만 필요하다면 직접 이 메서드를 재정의해 여러분이 만든 오류 타입에 원인을 포함시킬 수 있습니다.
use std::error::Error;
#[derive(Debug)]
struct DatabaseError {
source: std::io::Error
}
impl std::fmt::Display for DatabaseError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "데이터베이스 연결에 실패했습니다")
}
}
impl std::error::Error for DatabaseError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
위 예시에서 DatabaseError는 std::io::Error를 원인으로 품고 있습니다. 그다음 source 메서드를 직접 구현하여, 호출될 때 이 내부의 소스를 반환하도록 만든 것이죠.
&(dyn Error + 'static)
여기서 &(dyn Error + 'static)이라는 타입이 무척 낯설게 느껴지실 텐데, 하나씩 분해해 보겠습니다.
dyn Error: **트레이트 객체(Trait object)**라고 부릅니다.Error트레이트를 구현한 어떤 타입이든 가리킬 수 있는 유연한 방식이죠.'static: 라이프타임(Lifetime) 중에서도 특별한 녀석입니다.'static은 해당 참조가 프로그램이 실행되는 내내 유효하다는 것을 보장해 줍니다.
이를 합쳐보면, &(dyn Error + 'static)은 Error 트레이트를 구현했고, 프로그램이 끝날 때까지 유효한 어떤 객체를 가리키는 참조를 뜻합니다. 지금 당장 이 개념들을 완벽히 이해하지 못해도 괜찮습니다! 나중에 더 자세히 다룰 기회가 있을 거예요.
thiserror로 source 구현하기
thiserror를 사용하면 source를 수동으로 구현할 필요 없이 자동으로 처리할 수 있는 세 가지 영리한 방법을 제공합니다.
-
source라는 이름의 필드: 해당 필드를 자동으로 오류의 원인으로 사용합니다.use thiserror::Error; #[derive(Error, Debug)] pub enum MyError { #[error("데이터베이스 연결 실패")] DatabaseError { source: std::io::Error } } -
#[source]어트리뷰트: 특정 필드를 원인으로 지정합니다.use thiserror::Error; #[derive(Error, Debug)] pub enum MyError { #[error("데이터베이스 연결 실패")] DatabaseError { #[source] inner: std::io::Error } } -
#[from]어트리뷰트: 필드를 원인으로 지정함과 동시에, 해당 소스 타입을 내 오류 타입으로 쉽게 변환해주는From트레이트까지 자동으로 구현해 줍니다.use thiserror::Error; #[derive(Error, Debug)] pub enum MyError { #[error("데이터베이스 연결 실패")] DatabaseError { #[from] inner: std::io::Error } }
? 연산자 (Question Mark Operator)
? 연산자는 오류를 상위 함수로 **전파(Propagate)**할 때 사용하는 아주 편리한 도구입니다.
Result를 반환하는 함수 안에서 사용하면, 만약 그 결과가 Err일 때 바로 함수를 끝내고 해당 오류를 밖으로 던져줍니다.
예를 들어 볼까요?
use std::fs::File;
use std::io::Read;
fn read_file() -> Result<String, std::io::Error> {
let mut file = File::open("file.txt")?; // 실패 시 즉시 반환
let mut contents = String::new();
file.read_to_string(&mut contents)?; // 실패 시 즉시 반환
Ok(contents)
}
이 코드는 실제로는 아래의 긴 코드와 똑같은 일을 합니다.
use std::fs::File;
use std::io::Read;
fn read_file() -> Result<String, std::io::Error> {
let mut file = match File::open("file.txt") {
Ok(file) => file,
Err(e) => {
return Err(e);
}
};
let mut contents = String::new();
match file.read_to_string(&mut contents) {
Ok(_) => (),
Err(e) => {
return Err(e);
}
}
Ok(contents)
}
? 연산자를 쓰면 오류 처리 코드가 몰라보게 깔끔해집니다. 특히, ? 연산자는 필요한 경우(즉, 적절한 From 구현이 있을 때) 발생한 오류를 함수가 반환하는 오류 타입으로 자동으로 변환해주기도 합니다.
Exercise
The exercise for this section is located in 05_ticket_v2/14_source
마무리
도메인 모델링(Domain Modeling)에 있어서는 아주 작은 세부 사항이 큰 차이를 만듭니다. Rust는 여러분이 설계한 도메인의 제약 조건을 타입 시스템에 직접 표현할 수 있는 강력한 도구들을 제공합니다. 다만, 이를 올바르고 관용적(Idiomatic)으로 사용하려면 약간의 연습이 필요하죠.
Ticket 모델을 마지막으로 한 번 더 개선하며 이번 챕터를 마칩니다. 우리는 Ticket의 각 필드에 새로운 타입을 도입하여 각각의 제약 조건을 **캡슐화(Encapsulation)**할 것입니다. 이제 누군가 Ticket의 필드에 접근할 때마다, 단순한 String이 아니라 이미 유효성이 검증된 TicketTitle과 같은 타입을 받게 됩니다. 덕분에 코드의 다른 곳에서 “제목이 비어 있으면 어쩌지?” 같은 걱정을 할 필요가 없죠. 여러분의 손에 TicketTitle이 있다는 것 자체가 이미 구조상 유효하다는 증거니까요.
이것은 Rust의 타입 시스템을 활용해 코드를 더 안전하고 표현력 있게 만드는 아주 좋은 예시입니다.
더 읽어보기 (영문 자료)
- 유효성 검사하지 말고 파싱하세요 (Parse, don’t validate)
- 타입을 사용하여 도메인 불변성 보장하기 (Guaranteeing domain invariants with types)
Exercise
The exercise for this section is located in 05_ticket_v2/15_outro
소개
이전 장에서 우리는 Ticket 구조체를 개별적으로 모델링해 보았습니다. 필드와 제약 조건을 정의하고 Rust에서 이를 가장 잘 표현하는 방법은 배웠지만, Ticket이 더 큰 시스템 내에서 어떻게 상호작용하는지는 아직 살펴보지 않았습니다.
이번 장에서는 Ticket을 중심으로 간단한 워크플로우를 구축해 보겠습니다. 티켓을 저장하고 검색하는 기초적인 관리 시스템을 구현하며 Rust의 다양한 기능들을 익혀볼 것입니다.
이 과정을 통해 다음과 같은 새로운 Rust 개념들을 탐색하게 됩니다:
- 배열(Arrays): 스택에 할당되는 고정 크기 컬렉션
- 벡터(Vec): 크기 조정이 가능한 유연한 배열 타입
- 반복자(Iterators) 및
IntoIterator: 컬렉션의 요소를 순회하는 방법 - 슬라이스(Slices,
&[T]): 컬렉션의 일부분을 다루는 방법 - 라이프타임(Lifetimes): 참조가 유효한 기간을 명시하는 방법
- 해시 맵(HashMap) 및 B-트리 맵(BTreeMap): 대표적인 키-값(key-value) 데이터 구조
Eq및Hash:HashMap의 키를 비교하기 위한 트레이트Ord및PartialOrd:BTreeMap을 다루기 위한 정렬 트레이트Index및IndexMut: 컬렉션의 요소에 접근하는 연산자 오버로딩
Exercise
The exercise for this section is located in 06_ticket_management/00_intro
배열(Arrays)
“티켓 관리“에 대해 논의하려면 먼저 여러 개의 티켓을 한꺼번에 저장하는 방법부터 고민해야 합니다. 즉, 컬렉션(Collections), 그중에서도 특히 같은 타입의 여러 인스턴스를 저장하는 **동종 컬렉션(Homogeneous collection)**이 필요합니다.
이와 관련하여 Rust에서는 어떤 기능을 제공할까요?
배열(Arrays)
가장 먼저 떠올릴 수 있는 방법은 바로 **배열(Arrays)**입니다. Rust의 배열은 동일한 타입의 요소들로 구성된 고정 크기(fixed-size) 컬렉션입니다.
배열은 다음과 같이 정의합니다:
// 배열 타입 구문: [ <타입> ; <요소 수> ]
let numbers: [u32; 3] = [1, 2, 3];
이 코드는 1, 2, 3이라는 값으로 초기화된 3개의 정수 배열을 만듭니다. 배열의 타입은 [u32; 3]이며, “길이가 3인 u32 배열“이라고 읽습니다.
만약 모든 배열 요소를 동일한 값으로 채우고 싶다면, 조금 더 짧은 구문을 사용할 수 있습니다:
// [ <초깃값> ; <요소 수> ]
let numbers: [u32; 3] = [1; 3];
[1; 3]은 모두 1이라는 값을 가진 세 개의 요소로 구성된 배열을 생성합니다.
요소 접근
대괄호([])를 사용하여 배열의 개별 요소에 접근할 수 있습니다:
let first = numbers[0];
let second = numbers[1];
let third = numbers[2];
인덱스는 반드시 usize 타입이어야 합니다. 또한 Rust의 배열은 다른 언어와 마찬가지로 0부터 시작합니다. 문자열 슬라이스나 튜플에서 인덱스를 사용했던 방식과 같으니 익숙하실 것입니다.
범위 초과 접근
배열의 범위를 벗어난 인덱스로 요소에 접근하려고 하면 Rust는 **패닉(panic)**을 일으킵니다:
let numbers: [u32; 3] = [1, 2, 3];
let fourth = numbers[3]; // 이 코드는 런타임에 패닉을 일으킵니다!
Rust는 런타임에 **경계 검사(Bounds checking)**를 수행하여 이를 감시합니다. 약간의 성능 오버헤드가 발생하지만, 이를 통해 버퍼 오버플로우와 같은 치명적인 메모리 오류를 방지할 수 있습니다. 일부 상황에서는 Rust 컴파일러가 이 경계 검사를 최적화하여 없애기도 하는데, 나중에 반복자(Iterators)를 다룰 때 더 자세히 알아보겠습니다.
패닉을 일으키지 않고 안전하게 값을 가져오고 싶다면, Option<&T>를 반환하는 get 메서드를 사용하면 됩니다:
let numbers: [u32; 3] = [1, 2, 3];
assert_eq!(numbers.get(0), Some(&1));
// 범위를 벗어난 인덱스에 접근해도 패닉 대신 `None`을 얻습니다.
assert_eq!(numbers.get(3), None);
성능
배열의 크기는 컴파일 시점에 이미 결정되므로, 컴파일러는 배열을 스택(Stack) 메모리에 할당할 수 있습니다. 다음 코드를 실행할 때의 메모리 구조는 아래와 같습니다:
let numbers: [u32; 3] = [1, 2, 3];
+---+---+---+
스택: | 1 | 2 | 3 |
+---+---+---+
배열이 차지하는 크기는 std::mem::size_of::<T>() * N입니다. (여기서 T는 요소의 타입, N은 요소의 개수입니다.)
덕분에 각 요소에 접근하거나 값을 바꾸는 작업은 매우 빠르게(O(1) 시간 복잡도) 이루어집니다.
Exercise
The exercise for this section is located in 06_ticket_management/01_arrays
벡터(Vectors)
앞서 본 배열의 가장 큰 장점은 곧 단점이 되기도 합니다. 바로 크기가 컴파일 시점에 미리 결정되어야 한다는 점입니다. 런타임에 결정되는 크기로 배열을 만들려고 하면 컴파일 에러가 발생합니다.
let n = 10;
let numbers: [u32; n];
error[E0435]: attempt to use a non-constant value in a constant
--> src/main.rs:3:20
|
2 | let n = 10;
3 | let numbers: [u32; n];
| ^ non-constant value
우리가 만들 티켓 관리 시스템에서는 얼마나 많은 티켓이 저장될지 미리 알 수 없으므로, 배열은 적합하지 않습니다. 이때 필요한 것이 바로 Vec입니다.
벡터(Vec)
Vec은 표준 라이브러리에서 제공하는 확장 가능한(Growable) 배열 타입입니다.
Vec::new 함수를 사용하여 비어 있는 벡터를 만들 수 있습니다.
let mut numbers: Vec<u32> = Vec::new();
그다음 push 메서드를 사용하여 요소를 추가할 수 있습니다:
numbers.push(1);
numbers.push(2);
numbers.push(3);
새로 추가된 값은 벡터의 끝에 붙습니다. 만약 생성 시점에 이미 들어갈 값을 알고 있다면 vec! 매크로를 사용하는 것이 편리합니다:
let numbers = vec![1, 2, 3];
요소 접근
요소에 접근하는 방식은 배열과 동일합니다:
let numbers = vec![1, 2, 3];
let first = numbers[0];
let second = numbers[1];
let third = numbers[2];
인덱스는 usize 타입이어야 합니다. 배열과 마찬가지로 Option<&T>를 반환하는 get 메서드를 사용할 수도 있습니다:
let numbers = vec![1, 2, 3];
assert_eq!(numbers.get(0), Some(&1));
// 범위를 벗어나면 패닉 대신 `None`을 반환합니다.
assert_eq!(numbers.get(3), None);
벡터의 요소 접근 역시 경계 검사(Bounds checking)를 수행하며, 시간 복잡도는 O(1)입니다.
메모리 구조
Vec은 힙 할당(Heap-allocated) 데이터 구조입니다. Vec을 생성하면 요소들을 담기 위해 힙 메모리를 할당받습니다.
다음 코드를 실행했을 때:
let mut numbers = Vec::with_capacity(3);
numbers.push(1);
numbers.push(2);
메모리 레이아웃은 대략 다음과 같은 모습입니다:
+---------+--------+----------+
스택 | 포인터 | 길이 | 용량 |
| | | 2 | 3 |
+--|------+--------+----------+
|
|
v
+---+---+---+
힙: | 1 | 2 | ? |
+---+---+---+
벡터(Vec)는 내부적으로 다음 세 가지 정보를 관리합니다:
- 포인터(Pointer): 요소들이 저장된 힙 메모리 영역의 주소입니다.
- 길이(Length): 현재 벡터에 들어 있는 요소의 개수입니다.
- 용량(Capacity): 힙에 예약된 공간에 최대로 들어갈 수 있는 요소의 개수입니다.
이 구조, 어딘가 익숙하지 않나요? 맞습니다. 우리가 앞에서 본 String과 완전히 동일합니다! 사실 String은 내부적으로 바이트 벡터(Vec<u8>)를 감싸서 구현되어 있습니다.
pub struct String {
vec: Vec<u8>,
}
Exercise
The exercise for this section is located in 06_ticket_management/02_vec
크기 조정(Resizing)
Vec이 “확장 가능한” 타입이라고 말씀드렸는데, 실제로 어떻게 늘어나는 것일까요? 만약 최대 용량(Capacity)에 도달한 Vec에 요소를 추가로 넣으려 하면 어떻게 될까요?
let mut numbers = Vec::with_capacity(3);
numbers.push(1);
numbers.push(2);
numbers.push(3); // 용량이 꽉 찼습니다!
numbers.push(4); // 이때 무슨 일이 일어날까요?
이런 경우 Vec은 **자체적으로 크기를 조정(Resizing)**합니다. 더 구체적으로는 메모리 할당자(Allocator)에게 더 큰 새로운 힙 메모리 공간을 요청한 뒤, 기존 요소들을 새 공간으로 모두 복사하고, 이전에 사용하던 메모리는 해제합니다. 이 과정을 **재할당(Reallocation)**이라고 부릅니다.
이 작업은 새로운 메모리 할당과 데이터 복사를 동반하므로, 요소가 많을수록 비용이 꽤 발생할 수 있습니다.
Vec::with_capacity
만약 벡터에 담을 요소의 대략적인 개수를 미리 알고 있다면, Vec::with_capacity 메서드를 사용하는 것이 좋습니다. 처음부터 충분한 메모리를 미리 확보해 두면, 벡터가 커질 때 발생하는 불필요한 재할당을 피할 수 있습니다.
다만, 너무 많은 공간을 미리 잡아두면 메모리가 낭비될 수 있으니 상황에 맞춰 적절히 선택하는 것이 중요합니다.
Exercise
The exercise for this section is located in 06_ticket_management/03_resizing
반복(Iteration)
가장 첫 번째 연습 문제에서 우리는 for 루프를 사용하여 컬렉션을 반복하는 방법을 배웠습니다. 그때는 0..5와 같은 범위를 다뤘지만, 배열이나 벡터와 같은 컬렉션도 똑같은 방식으로 다룰 수 있습니다.
// `Vec`에서도 잘 작동합니다 let v = vec![1, 2, 3];
for n in v {
println!("{}", n);
}
// 배열에서도 마찬가지입니다 let a: [u32; 3] = [1, 2, 3];
for n in a {
println!("{}", n);
}
이제 이 코드가 내부적으로 어떻게 돌아가는지 살펴볼까요?
for 루프의 구문 설탕 제거(Desugaring)
Rust에서 for 루프를 작성하면 컴파일러는 이를 내부적으로 다음과 같은 코드로 변환(Desugaring)합니다:
let mut iter = IntoIterator::into_iter(v);
loop {
match iter.next() {
Some(n) => {
println!("{}", n);
}
None => break,
}
}
loop는 for나 while과 달리, 명시적으로 break를 호출하지 않는 한 영원히 반복되는 구문입니다.
Iterator 트레이트
위 코드에서 사용한 next 메서드는 바로 Iterator 트레이트에서 정의된 것입니다. Rust 표준 라이브러리에 들어 있는 이 트레이트는 일련의 값을 차례대로 생성할 수 있는 모든 타입의 공통 인터페이스 역할을 합니다.
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
Item: 반복자가 생성하는 값의 타입을 나타내는 **연관 타입(Associated type)**입니다.next: 시퀀스의 다음 값을 반환합니다. 더 줄 값이 있으면Some(value)를, 끝에 도달하면None을 반환합니다.
참고로, next가 None을 반환했다고 해서 반드시 모든 반복이 끝났다고 보장할 수 없는 경우도 있습니다. 다만 FusedIterator라는 특별한 트레이트를 구현한 경우에는 None 이후에도 계속 None이 나옴을 보장합니다.
IntoIterator 트레이트
모든 타입이 그 자체로 Iterator인 것은 아니지만, 많은 타입이 Iterator로 변환될 수 있습니다. 이때 사용되는 것이 IntoIterator 트레이트입니다.
trait IntoIterator {
type Item;
type IntoIter: Iterator<Item = Self::Item>;
fn into_iter(self) -> Self::IntoIter;
}
into_iter 메서드는 원본 값을 소비하여 해당 요소들을 훑을 수 있는 반복자를 반환합니다. 각 타입은 IntoIterator를 오직 한 가지 방식으로만 구현할 수 있으므로, for 루프가 어떻게 동작해야 하는지 헷갈릴 일이 없습니다.
흥미로운 점은, Iterator를 구현하는 모든 타입은 자동으로 IntoIterator도 구현된다는 것입니다. 그냥 into_iter를 호출했을 때 자기 자신을 반환할 뿐이죠!
성능과 경계 검사
반복자를 사용하면 아주 좋은 장점이 하나 더 있습니다. 바로 설계상 범위를 벗어날 수 없다는 점입니다. 덕분에 Rust 컴파일러는 런타임에 수행하는 경계 검사(Bounds checking)를 과감히 생략하여 코드를 더 빠르게 만듭니다.
즉, 아래 코드는:
let v = vec![1, 2, 3];
for n in v {
println!("{}", n);
}
보통 다음과 같은 인덱스 기반 코드보다 더 빠릅니다:
let v = vec![1, 2, 3];
for i in 0..v.len() {
println!("{}", v[i]);
}
물론 컴파일러가 아주 똑똑해서 수동 인덱싱 코드가 안전함을 증명하고 경계 검사를 직접 제거해 줄 때도 있지만, 되도록 인덱싱보다는 반복자를 사용하는 것이 Rust다운 방식입니다.
Exercise
The exercise for this section is located in 06_ticket_management/04_iterators
.iter() 메서드
IntoIterator는 반복자를 만들기 위해 self를 **소비(Consume)**합니다.
이는 한편으로 장점이 됩니다. 반복자로부터 소유권이 있는(Owned) 값을 직접 얻을 수 있기 때문입니다. 예를 들어, Vec<Ticket>에서 .into_iter()를 호출하면 각 티켓의 값을 직접 반환하는 반복자를 얻게 됩니다.
하지만 동시에 단점도 있습니다. into_iter()를 호출하고 나면 원본 컬렉션을 더 이상 사용할 수 없게 되기 때문입니다. 하지만 실제로는 컬렉션을 없애지 않고, 그 안에 담긴 값들을 **참조(Reference)**만 하면서 순회하고 싶은 경우가 훨씬 많습니다. Vec<Ticket>이라면 &Ticket을 얻고 싶은 것이죠.
그래서 대부분의 컬렉션은 요소에 대한 참조를 반환하는 .iter() 메서드를 따로 제공합니다.
let numbers: Vec<u32> = vec![1, 2];
// 여기서 `n`은 `&u32` 타입입니다.
for n in numbers.iter() {
// [...]
}
이 패턴은 컬렉션의 참조 자체에 대해 IntoIterator를 구현함으로써 더 단순하게 만들 수 있습니다. 위 예시라면 &Vec<u32>에 대해 IntoIterator를 구현하는 식입니다. 표준 라이브러리에는 이미 이 구현이 되어 있기 때문에 아래와 같은 코드가 가능합니다.
let numbers: Vec<u32> = vec![1, 2];
// 여기서 `n`은 `&u32` 타입입니다.
// 굳이 `.iter()`를 명시적으로 호출할 필요 없이,
// `for` 루프에 `&numbers`를 넘겨주는 것만으로 충분합니다.
for n in &numbers {
// [...]
}
Rust에서는 다음과 같이 두 가지 옵션을 모두 제공하는 것이 **관용적(Idiomatic)**입니다.
- 컬렉션의 참조(
&T)에 대한IntoIterator구현 - 컬렉션 요소의 참조를 반환하는
.iter()메서드 제공
첫 번째 방식은 for 루프에서 쓰기 아주 편리하고, 두 번째 방식은 의도가 더 명확하게 드러나며 다른 여러 상황에서도 유연하게 사용할 수 있습니다.
Exercise
The exercise for this section is located in 06_ticket_management/05_iter
라이프타임(Lifetimes)
for 루프에서 최대한의 편의를 위해 &TicketStore에 대한 IntoIterator 구현을 추가해 보겠습니다.
구현에서 가장 “명백한” 부분부터 채워 넣어 볼까요?
impl IntoIterator for &TicketStore {
type Item = &Ticket;
type IntoIter = // 여기에 무엇이 들어갈까요?
fn into_iter(self) -> Self::IntoIter {
self.tickets.iter()
}
}
type IntoIter에는 무엇을 넣어야 할까요? 직관적으로 생각하면 self.tickets.iter()가 반환하는 타입, 즉 Vec::iter()가 반환하는 타입이 되어야 할 것입니다. 표준 라이브러리 문서를 확인해 보면 Vec::iter()는 std::slice::Iter를 반환함을 알 수 있습니다. Iter의 정의는 다음과 같습니다.
pub struct Iter<'a, T> { /* 필드 생략 */ }
여기서 'a는 바로 **라이프타임 매개변수(Lifetime parameter)**입니다.
라이프타임 매개변수
**라이프타임(Lifetimes)**은 Rust 컴파일러가 참조(가변 혹은 불변 참조 모두 해당)가 유효한 기간을 추적하기 위해 사용하는 **레이블(Label)**입니다. 참조의 수명은 그 참조가 가리키는 실제 값의 **스코프(Scope)**에 의해 결정됩니다. Rust는 참조가 가리키는 값이 이미 메모리에서 사라졌는데도 그 참조를 사용하는 일, 즉 **댕글링 포인터(Dangling pointer)**나 해제 후 사용(Use-after-free) 버그를 방지하기 위해 컴파일 시점에 라이프타임을 검사합니다.
어딘가 익숙하시죠? 소유권과 빌림을 배울 때 이미 실제로 겪어본 개념들입니다. 라이프타임은 그저 특정 참조가 유효한 기간을 명시적으로 이름 붙이는 방법일 뿐입니다.
보통은 라이프타임을 명시하지 않아도 되지만, 여러 참조가 얽혀 있고 이들이 서로 어떻게 관련되어 있는지 명확히 해야 할 때 이름을 붙여야 하는 경우가 생깁니다. Vec::iter()의 시그니처를 예로 들어보겠습니다.
impl <T> Vec<T> {
// 설명을 위해 약간 단순화했습니다.
pub fn iter<'a>(&'a self) -> Iter<'a, T> {
// [...]
}
}
Vec::iter()는 'a라는 라이프타임 매개변수를 사용합니다. 이 'a는 Vec의 수명과 iter()가 반환하는 Iter의 수명을 함께 묶어주는 역할을 합니다. 쉽게 말해, iter()가 반환한 Iter는 이 Iter를 만든 Vec 참조(&self)보다 더 오래 살아남을 수 없다는 뜻입니다.
이는 매우 중요합니다. Vec::iter는 이름 그대로 Vec 안의 요소들에 대한 참조를 반환하기 때문입니다. 만약 Vec 자체가 메모리에서 사라지면 반복자가 반환한 참조들도 모두 무효화됩니다. Rust는 이런 안전하지 않은 상황이 발생하지 않도록 라이프타임이라는 도구를 사용하여 이 규칙을 강제합니다.
라이프타임 생략(Lifetime Elision)
사실 Rust에는 **라이프타임 생략 규칙(Lifetime elision rules)**이라는 것이 있어서, 많은 경우에 라이프타임을 일일이 적어주지 않아도 됩니다. 예를 들어 실제 std 소스 코드에서 Vec::iter는 다음과 같이 정의되어 있습니다.
impl <T> Vec<T> {
pub fn iter(&self) -> Iter<'_, T> {
// [...]
}
}
보시다시피 명시적인 라이프타임 매개변수가 보이지 않습니다. 생략 규칙 덕분에 컴파일러는 iter()가 반환하는 Iter의 수명이 &self의 수명과 연결되어 있음을 자동으로 알아챕니다. 여기서 '_는 &self의 수명을 가리키는 자리 표시자로 생각하시면 됩니다.
더 자세한 내용은 참조 섹션의 공식 문서를 확인해 보세요. 대부분의 경우 라이프타임을 명시해야 할 때는 친절하게 컴파일러가 알려주니 너무 걱정하지 않으셔도 됩니다!
참조
Exercise
The exercise for this section is located in 06_ticket_management/06_lifetimes
조합자(Combinators)
반복자(Iterator)는 단순한 for 루프 그 이상의 능력을 갖추고 있습니다! Iterator 트레이트 문서를 살펴보면 반복자를 변환하고, 필터링하고, 결합하는 데 사용할 수 있는 엄청나게 많은 메서드들을 발견할 수 있습니다.
자주 사용되는 몇 가지를 살펴볼까요?
map: 반복자의 각 요소에 함수를 적용하여 변환합니다.filter: 특정 조건을 만족하는 요소만 남깁니다.filter_map:filter와map을 한 번에 수행합니다.cloned: 참조를 반복하는 반복자를 값을 직접 다루는 반복자로 바꾸며 각 요소를 복제합니다.enumerate:(인덱스, 값)쌍을 만들어 주는 새로운 반복자를 반환합니다.skip: 처음n개의 요소를 건너뜁니다.take:n개의 요소만 가져오고 반복을 멈춥니다.chain: 두 개의 반복자를 하나로 이어 붙입니다.
이런 메서드들을 **조합자(Combinators)**라고 부릅니다. 이들은 보통 여러 개를 **연결(Chaining)**하여 복잡한 데이터 변환 과정을 아주 간결하고 읽기 좋게 만들어 줍니다.
let numbers = vec![1, 2, 3, 4, 5];
// 짝수만 골라내어 그 제곱의 합을 구합니다.
let outcome: u32 = numbers.iter()
.filter(|&n| n % 2 == 0)
.map(|&n| n * n)
.sum();
클로저(Closures)
위 예제의 filter나 map에서 사용한 |...| 구문은 무엇일까요? 이들은 인수로 **클로저(Closures)**를 전달받습니다.
클로저는 익명 함수(Anonymous functions), 즉 이름이 없는 함수입니다. 우리가 흔히 쓰는 fn 구문 대신 |매개변수| 본문 형식을 사용해 정의합니다. 본문은 코드 블록({})일 수도 있고 단일 표현식일 수도 있습니다.
// 매개변수에 1을 더하는 익명 함수 let add_one = |x| x + 1;
// 중괄호를 써서 블록으로 작성할 수도 있습니다.
let add_one = |x| { x + 1 };
클로저는 여러 개의 매개변수를 받을 수 있습니다:
let add = |x, y| x + y;
let sum = add(1, 2);
또한 클로저만의 특별한 능력은 주변 환경의 변수를 가둘(Capture variables from the environment) 수 있다는 점입니다.
let x = 42;
let add_x = |y| x + y; // 주변 변수인 x를 사용합니다.
let sum = add_x(1);
필요하다면 매개변수나 반환 타입을 직접 명시해 줄 수도 있습니다:
// 입력 타입만 지정 let add_one = |x: i32| x + 1;
// 입력과 출력 타입을 모두 지정 (fn 타입 문법 사용)
let add_one: fn(i32) -> i32 = |x| x + 1;
collect
조합자를 써서 반복자를 변환했다면, 그다음엔 어떻게 해야 할까요? 변환된 값을 for 루프로 다시 돌릴 수도 있지만, 새로운 컬렉션으로 모으고 싶을 때가 많습니다.
이때 사용하는 메서드가 바로 collect입니다. collect는 반복자를 끝까지 실행하고 그 결과물들을 우리가 원하는 컬렉션(예: Vec)에 차곡차곡 담아줍니다.
let numbers = vec![1, 2, 3, 4, 5];
let squares_of_evens: Vec<u32> = numbers.iter()
.filter(|&n| n % 2 == 0)
.map(|&n| n * n)
.collect();
collect 메서드는 매우 강력하지만, 어떤 타입의 컬렉션으로 만들지 정해줘야 하는 제네릭(Generic) 메서드입니다. 그래서 보통 위 예시처럼 변수 타입(Vec<u32>)을 명시해 주거나, 아래처럼 **터보피시 구문(Turbofish syntax)**을 사용하여 타입을 지정해 줍니다.
let squares_of_evens = numbers.iter()
.filter(|&n| n % 2 == 0)
.map(|&n| n * n)
// 터보피시 구문: `::<타입>()`
// `::<>` 모양이 물고기처럼 생겨서 붙여진 이름입니다!
.collect::<Vec<u32>>();
더 읽어보기
Iterator문서: 표준 라이브러리에서 반복자에 사용할 수 있는 다양한 메서드들을 확인할 수 있습니다.itertools크레이트: 기본 제공 기능보다 더 많은 강력한 조합자들을 제공하는 유명한 라이브러리입니다.
Exercise
The exercise for this section is located in 06_ticket_management/07_combinators
impl Trait
TicketStore::to_dos는 Vec<&Ticket>을 반환합니다. 하지만 이 시그니처는 to_dos가 호출될 때마다 새로운 힙 할당을 발생시키는데, 호출자가 결과물을 가지고 무엇을 하느냐에 따라 이는 불필요한 낭비가 될 수 있습니다. 만약 to_dos가 Vec 대신 반복자(Iterator)를 직접 반환한다면, 호출자가 이를 다시 Vec으로 모을지 아니면 그저 루프를 돌며 소비할지 스스로 결정할 수 있어 더 효율적일 것입니다.
하지만 여기엔 한 가지 까다로운 점이 있습니다. 아래처럼 구현했을 때, to_dos의 반환 타입은 정확히 무엇이 되어야 할까요?
impl TicketStore {
pub fn to_dos(&self) -> ??? {
self.tickets.iter().filter(|t| t.status == Status::ToDo)
}
}
이름 없는 타입
filter 메서드는 std::iter::Filter 구조체의 인스턴스를 반환하며, 그 정의는 다음과 같습니다.
pub struct Filter<I, P> { /* 필드 생략 */ }
여기서 I는 필터링할 반복자의 타입이고, P는 필터링 조건으로 사용되는 조건자(Predicate)입니다. 이 경우 I가 std::slice::Iter<'_, Ticket>이라는 것은 알 수 있지만, P는 무엇일까요? P는 우리가 전달한 클로저, 즉 익명 함수입니다. 이름 그대로 “이름이 없는” 함수이기 때문에, 우리가 직접 코드에 그 타입 이름을 적어줄 방법이 없습니다.
Rust는 이 문제를 해결하기 위해 **impl Trait**라는 기능을 제공합니다.
impl Trait
impl Trait는 구체적인 타입 이름을 명시하지 않고도 특정 트레이트를 구현한 타입을 반환할 수 있게 해주는 기능입니다. 반환할 타입이 어떤 트레이트를 만족하는지만 선언하면, 나머지는 Rust 컴파일러가 알아서 처리합니다.
우리 예제에서는 Ticket에 대한 참조를 내뱉는 반복자를 반환하고 싶으므로 다음과 같이 작성할 수 있습니다.
impl TicketStore {
pub fn to_dos(&self) -> impl Iterator<Item = &Ticket> {
self.tickets.iter().filter(|t| t.status == Status::ToDo)
}
}
간단하죠!
제네릭과 다른 점
반환 위치에 쓰인 impl Trait는 제네릭 매개변수가 아닙니다.
제네릭은 함수를 호출하는 쪽에서 타입을 결정하는 자리 표시자입니다. 따라서 제네릭 매개변수가 있는 함수는 **다형적(Polymorphic)**입니다. 즉, 서로 다른 타입으로 호출될 수 있으며 컴파일러는 각 타입에 맞는 구현을 각각 생성합니다.
반면 impl Trait는 그렇지 않습니다. impl Trait를 사용하는 함수의 반환 타입은 컴파일 시점에 단 하나의 타입으로 고정되며, 컴파일러는 그에 대한 단일 구현만을 만듭니다. 호출하는 쪽에서는 반환되는 값의 구체적인 타입을 알 수 없고 단지 지정된 트레이트를 구현하고 있다는 사실만 알 수 있기 때문에, 이를 **불투명 반환 타입(Opaque return type)**이라고도 부릅니다. 비록 호출자에게는 불투명하지만 컴파일러는 실제 타입을 정확히 알고 있으므로 다형성과는 거리가 있습니다.
RPIT
Rust 관련 기술 문서나 RFC를 읽다 보면 RPIT라는 용어를 마주칠 수 있습니다. 이는 **“Return Position Impl Trait”**의 약자로, 말 그대로 반환 위치에서 사용된 impl Trait를 의미합니다.
Exercise
The exercise for this section is located in 06_ticket_management/08_impl_trait
인자(Argument) 위치에서의 impl Trait
이전 섹션에서 impl Trait를 사용해 타입 이름을 명시하지 않고도 특정 트레이트를 구현하는 타입을 반환하는 방법을 살펴보았습니다. 동일한 구문을 인자(argument) 위치에서도 사용할 수 있습니다.
fn print_iter(iter: impl Iterator<Item = i32>) {
for i in iter {
println!("{}", i);
}
}
위의 print_iter 함수는 i32 타입 요소를 생성하는 반복자(iterator)를 인자로 받아 각 요소를 출력합니다.
인자 위치에서 사용되는 impl Trait는 트레이트 바운드(trait bound)가 지정된 제네릭(generic) 매개변수와 동일하게 작동합니다.
fn print_iter<T>(iter: T)
where
T: Iterator<Item = i32>
{
for i in iter {
println!("{}", i);
}
}
단점
경험상, 인자 위치에서는 impl Trait보다 제네릭을 사용하는 것이 더 좋습니다. 제네릭을 사용하면 호출하는 쪽에서 터보피시(turbofish) 구문(::<>)을 통해 인자의 타입을 명시적으로 지정할 수 있어, 타입 모호성을 해결하는 데 유리하기 때문입니다. 반면 impl Trait는 이러한 명시적 타입 지정이 불가능합니다.
Exercise
The exercise for this section is located in 06_ticket_management/09_impl_trait_2
슬라이스(Slices)
Vec의 메모리 레이아웃을 다시 한 번 떠올려 볼까요?
let mut numbers = Vec::with_capacity(3);
numbers.push(1);
numbers.push(2);
+---------+--------+----------+
스택 | 포인터 | 길이 | 용량 |
| | | 2 | 3 |
+--|------+--------+----------+
|
|
v
+---+---+---+
힙: | 1 | 2 | ? |
+---+---+---+
이미 앞에서 String이 실제로는 Vec<u8>을 감싼 형태라는 것을 언급했었죠. 그렇다면 자연스럽게 이런 궁금증이 생길 수 있습니다. “Vec에 대응하는 &str 같은 타입은 없을까?”
&[T]
[T]는 타입 T의 연속된 요소 시퀀스를 나타내는 **슬라이스(Slice)**입니다. 주로 빌려온 형태인 &[T]를 가장 많이 사용하게 됩니다.
Vec에서 슬라이스 참조를 생성하는 방법은 여러 가지가 있습니다.
let numbers = vec![1, 2, 3];
// 인덱싱 구문을 사용하여 전체 슬라이스 참조 let slice: &[i32] = &numbers[..];
// 전용 메소드를 사용하여 슬라이스 참조 let slice: &[i32] = numbers.as_slice();
// 요소 중 일부만을 포함하는 슬라이스 참조 let slice: &[i32] = &numbers[1..];
Vec은 [T] 타입을 대상으로 Deref 트레이트를 구현하고 있습니다. 따라서 역참조 강제 변환(Deref coercion) 덕분에 Vec에서 슬라이스 전용 메소드를 직접 호출할 수 있습니다.
let numbers = vec![1, 2, 3];
// 놀랍게도, `iter`는 `Vec`의 메소드가 아닙니다!
// `&[T]`의 메소드이지만 역참조 강제 변환 덕분에 `Vec`에서도 바로 사용할 수 있죠.
let sum: i32 = numbers.iter().sum();
메모리 레이아웃(Memory Layout)
&[T]는 &str과 마찬가지로 **팻 포인터(fat pointer)**입니다. 슬라이스의 첫 번째 요소를 가리키는 포인터와 슬라이스의 길이라는 두 가지 정보로 구성됩니다.
요소가 세 개인 Vec이 있다고 가정해 봅시다.
let numbers = vec![1, 2, 3];
이 Vec의 일부분에 대한 슬라이스 참조를 만들면 다음과 같습니다.
let slice: &[i32] = &numbers[1..];
메모리 레이아웃은 다음과 같은 구조를 가집니다.
numbers slice
+---------+--------+----------+ +---------+--------+
스택 | 포인터 | 길이 | 용량 | | 포인터 | 길이 |
| | | 3 | 4 | | | | 2 |
+----|----+--------+----------+ +----|----+--------+
| |
| |
v |
+---+---+---+---+ |
힙: | 1 | 2 | 3 | ? | |
+---+---+---+---+ |
^ |
| |
+--------------------------------+
&Vec<T> 대 &[T]
함수 인자로 Vec에 대한 불변 참조를 넘겨야 할 때, 가급적 &Vec<T>보다는 &[T]를 사용하는 것이 좋습니다.
&[T]를 사용하면 Vec뿐만 아니라 모든 종류의 슬라이스를 인자로 받을 수 있어 함수의 범용성이 높아지기 때문입니다.
예를 들어, Vec의 일부 요소를 전달하거나 **배열(array)**의 일부를 슬라이스로 전달할 수도 있습니다.
let array = [1, 2, 3];
let slice: &[i32] = &array;
배열의 슬라이스와 Vec의 슬라이스는 동일한 타입입니다. 둘 다 메모리상에 연속적으로 나열된 요소를 가리키는 팻 포인터일 뿐입니다. 배열의 경우 포인터가 힙(heap)이 아닌 스택(stack) 영역을 가리키게 되지만, 슬라이스를 사용하는 방식에는 아무런 차이가 없습니다.
Exercise
The exercise for this section is located in 06_ticket_management/10_slices
가변 슬라이스(Mutable Slices)
지금까지 슬라이스 타입(str 및 [T])에 대해 이야기할 때는 주로 불변 빌림 형태인 &str과 &[T]를 예로 들었습니다. 하지만 슬라이스도 당연히 가변(mutable)일 수 있습니다!
가변 슬라이스를 생성하는 방법은 다음과 같습니다.
let mut numbers = vec![1, 2, 3];
let slice: &mut [i32] = &mut numbers;
가변 슬라이스를 얻었다면 이제 슬라이스 내부의 요소를 수정할 수 있습니다.
slice[0] = 42;
이렇게 하면 원본 Vec의 첫 번째 요소가 42로 변경됩니다.
제한 사항(Limitations)
불변 빌림을 다룰 때, 소유권을 가진 타입에 대한 참조보다는 슬라이스 참조를 선호하라고 권장했었습니다(예: &Vec<T>보다는 &[T]). 하지만 가변 빌림의 경우에는 이 규칙이 그대로 적용되지 않습니다.
다음과 같은 상황을 생각해 봅시다.
let mut numbers = Vec::with_capacity(2);
let mut slice: &mut [i32] = &mut numbers;
slice.push(1);
위 코드는 컴파일되지 않습니다!
push는 슬라이스의 메소드가 아니라 Vec의 메소드이기 때문입니다. 이는 Rust의 중요한 원칙 중 하나를 보여줍니다. 즉, 슬라이스에서는 요소를 추가하거나 제거할 수 없으며, 오직 이미 존재하는 요소를 수정하거나 교체하는 것만 가능합니다.
이런 관점에서 보면, &mut Vec이나 &mut String은 &mut [T]나 &mut str보다 기능적으로 더 강력하다고 할 수 있습니다. 따라서 여러분이 수행하려는 작업이 무엇인지에 따라 가장 적합한 타입을 선택해야 합니다.
Exercise
The exercise for this section is located in 06_ticket_management/11_mutable_slices
티켓 ID(Ticket ID)
티켓 관리 시스템을 다시 한 번 설계해 봅시다. 현재 티켓 모델은 다음과 같은 구조로 되어 있습니다.
pub struct Ticket {
pub title: TicketTitle,
pub description: TicketDescription,
pub status: Status
}
여기에 한 가지 빠진 중요한 정보가 있죠. 바로 티켓을 고유하게 식별할 수 있는 **식별자(ID)**입니다. 각 티켓은 고유한 ID를 가져야 하며, 이는 보통 새로운 티켓이 생성될 때 자동으로 할당되어 중복을 방지합니다.
모델 개선
그렇다면 이 ID는 어디에 저장하는 것이 좋을까요?
Ticket 구조체에 새로운 필드를 추가해 봅시다.
pub struct Ticket {
pub id: TicketId,
pub title: TicketTitle,
pub description: TicketDescription,
pub status: Status
}
그런데 문제가 하나 있습니다. 티켓이 생성되기 전에는 아직 ID를 알 수 없다는 것이죠. 따라서 처음부터 이 필드를 채울 수는 없습니다. 그렇다고 필드를 옵셔널(Optional)로 만드는 건 어떨까요?
pub struct Ticket {
pub id: Option<TicketId>,
pub title: TicketTitle,
pub description: TicketDescription,
pub status: Status
}
이 방법도 완벽하진 않습니다. 티켓이 일단 생성되고 나면 반드시 ID가 존재한다는 것을 알고 있음에도 불구하고, 저장소에서 티켓을 꺼내 올 때마다 매번 None 케이스를 처리해야 하는 번거로움이 생기기 때문입니다.
가장 깔끔한 해결책은 티켓의 두 가지 **상태(States)**를 서로 다른 타입으로 정의하는 것입니다. 바로 TicketDraft와 Ticket으로 나누는 것이죠.
pub struct TicketDraft {
pub title: TicketTitle,
pub description: TicketDescription
}
pub struct Ticket {
pub id: TicketId,
pub title: TicketTitle,
pub description: TicketDescription,
pub status: Status
}
TicketDraft: 아직 데이터베이스 등에 저장되지 않은 임시 상태의 티켓입니다. ID와 상태(Status) 값이 아직 할당되지 않았습니다.Ticket: 성공적으로 생성되어 식별 가능한 상태의 티켓입니다. 고유한 ID와 현재 상태 값을 가집니다.
이렇게 타입을 분리하면 각 필드가 자체적인 검증 로직을 포함하고 있으므로, 두 타입 사이에 중복된 검증 로직을 작성할 필요가 없습니다. 아주 효율적이죠!
Exercise
The exercise for this section is located in 06_ticket_management/12_two_states
인덱싱(Indexing)
지금까지의 TicketStore::get 메소드는 주어진 TicketId에 대해 Option<&Ticket>을 반환해 주었습니다. 그런데 이미 여러분은 Rust에서 배열이나 벡터의 요소에 접근할 때 흔히 쓰이는 인덱싱(Indexing) 구문을 익히 알고 계실 거예요.
let v = vec![0, 1, 2];
assert_eq!(v[0], 0);
이와 동일한 사용 경험을 TicketStore에서도 제공할 순 없을까요? 맞습니다! 바로 Index 트레이트를 구현하면 가능합니다.
Index 트레이트
Index 트레이트는 Rust 표준 라이브러리에 다음과 같이 정의되어 있습니다.
// 약간 단순화된 정의입니다.
pub trait Index<Idx>
{
type Output;
// 필수 메소드
fn index(&self, index: Idx) -> &Self::Output;
}
이 트레이트에는 두 가지 핵심 요소가 있습니다.
Idx: 인덱스로 사용할 타입을 나타내는 제네릭(Generic) 매개변수입니다.Output: 인덱스를 통해 찾은 값의 타입을 나타내는 연관 타입(Associated type)입니다.
여기서 주의할 점은 index 메소드가 Option을 반환하지 않는다는 것입니다. 이는 배열이나 벡터 인덱싱과 마찬가지로, 존재하지 않는 인덱스에 접근하려고 하면 index 메소드가 **패닉(Panic)**을 일으킨다는 것을 전제로 하기 때문입니다. 이 점을 꼭 기억해 두세요!
Exercise
The exercise for this section is located in 06_ticket_management/13_index
가변 인덱싱(Mutable Indexing)
앞서 살펴본 Index 트레이트는 오직 읽기 전용 접근만을 허용합니다. 즉, 인덱싱을 통해 가져온 값을 직접 수정할 수는 없죠.
IndexMut 트레이트
가변적으로 인덱싱한 값을 수정하고 싶다면 IndexMut 트레이트를 구현해야 합니다.
// 약간 단순화된 정의입니다.
pub trait IndexMut<Idx>: Index<Idx>
{
// 필수 메소드
fn index_mut(&mut self, index: Idx) -> &mut Self::Output;
}
한 가지 눈여겨볼 점은 IndexMut이 Index 트레이트를 상속받는다는 것입니다. 따라서 IndexMut을 구현하려면 먼저 Index 트레이트가 구현되어 있어야 합니다. 이는 가변 접근이라는 추가적인 기능을 제공하기 위한 것이기 때문입니다.
Exercise
The exercise for this section is located in 06_ticket_management/14_index_mut
해시맵(HashMap)
지금까지 Index 및 IndexMut을 구현해 보았지만, 성능 관점에서 보면 아직 한계가 있습니다. 특정 ID로 티켓을 찾기 위해 매번 Vec 전체를 훑어야 하기 때문이죠. 이 경우의 시간 복잡도는 저장소에 있는 티켓의 수 n에 비례하는 **O(n)**입니다.
이보다 더 효율적인 방법은 없을까요? 바로 HashMap<K, V>라는 새로운 데이터 구조를 사용하면 이 문제를 멋지게 해결할 수 있습니다.
use std::collections::HashMap;
// 타입 추론 덕분에 아래 예제처럼 명시적인 타입 표기를 생략할 수 있습니다.
// 여기서는 `HashMap<String, String>` 타입이 됩니다.
let mut book_reviews = HashMap::new();
book_reviews.insert(
"Adventures of Huckleberry Finn".to_string(),
"My favorite book.".to_string(),
);
해시맵은 **키(Key)**와 값(Value) 쌍으로 데이터를 저장합니다. 제네릭 매개변수 K는 키의 타입을, V는 값의 타입을 나타내죠.
해시맵을 쓰면 데이터를 추가하거나 찾고 삭제하는 데 걸리는 예상 시간(expected cost)이 상수에 가까운 **O(1)**에 불과합니다. 우리의 티켓 관리 시스템에 딱 맞는 선택이 아닐까요?
키(Key)의 요구 조건
HashMap 구조체 자체의 정의에는 트레이트 바운드(Trait bound)가 명시되어 있지 않지만, insert 같은 실제 메소드를 보면 제약 사항이 있습니다.
// 약간 단순화된 시그니처입니다.
impl<K, V> HashMap<K, V>
where
K: Eq + Hash,
{
pub fn insert(&mut self, k: K, v: V) -> Option<V> {
// [...]
}
}
위에서 볼 수 있듯이, 키로 사용할 타입은 반드시 Eq와 Hash 트레이트를 구현하고 있어야 합니다. 이 두 가지가 무엇인지 자세히 알아보도록 하죠.
Hash 트레이트
해싱(Hashing) 함수(또는 해셔, Hasher)는 이론적으로 무한한 값의 집합(예: 모든 가능한 문자열)을 유한한 범위(예: u64 값)로 매핑해 줍니다. 시중에는 속도나 충돌 위험, 가역성(reversibility) 등 각기 다른 특성을 가진 다양한 해싱 함수가 존재합니다.
HashMap은 이름 그대로 내부에서 해싱 함수를 사용합니다. 키 값을 해싱한 다음, 그 해시 값을 인덱스로 활용해 해당 데이터를 저장하거나 찾아냅니다. 키 타입이 반드시 해싱 가능해야 하므로 K에 Hash 트레이트 바운드가 필요한 것이죠.
Hash 트레이트는 std::hash 모듈에서 찾아볼 수 있습니다.
pub trait Hash {
// 필수 메소드
fn hash<H>(&self, state: &mut H)
where H: Hasher;
}
보통 Hash 트레이트를 직접 구현할 일은 거의 없습니다. 대부분 아래처럼 derive 매크로를 통해 자동으로 생성하게 됩니다.
#[derive(Hash)]
struct Person {
id: u32,
name: String,
}
Eq 트레이트
해시맵은 키들을 서로 비교할 수 있어야 합니다. 특히 해시 충돌(hash collision), 즉 서로 다른 두 키가 동일한 해시 값을 가질 때 이를 구분하기 위해 꼭 필요합니다.
“그건 PartialEq 트레이트로 해결되는 거 아닌가요?“라고 질문하실 수도 있습니다. 거의 그렇긴 합니다! 하지만 해시맵 입장에서는 a == a가 항상 참이라는 **반사성(reflexivity)**이 보장되어야 하기에 PartialEq만으로는 부족합니다. 예를 들어, 부동 소수점 타입인 f32나 f64는 PartialEq를 구현하고 있지만, f32::NAN == f32::NAN이 **false**이므로 반사성을 만족하지 못합니다. 이런 타입을 해시맵의 키로 쓰면 나중에 똑같은 키를 가져와도 값을 찾지 못하는 대참사가 일어날 수 있습니다.
Eq 트레이트는 바로 이 반사성까지 보장하도록 PartialEq를 확장한 것입니다.
pub trait Eq: PartialEq {
// 추가되는 메소드는 없습니다.
}
이는 단순히 컴파일러에게 “이 타입의 동등성 로직은 반사적이다“라고 알려주는 **마커 트레이트(Marker trait)**일 뿐입니다. PartialEq를 derive할 때 Eq도 함께 추가하면 간편합니다.
#[derive(PartialEq, Eq)]
struct Person {
id: u32,
name: String,
}
Eq와 Hash의 불가분 관계
Eq와 Hash 트레이트 사이에는 아주 중요한 약속이 하나 있습니다. 바로 **“두 키가 같다면(Eq), 그 해시 값(Hash)도 반드시 같아야 한다”**는 것입니다. 이 약속을 어기면 해시맵이 제대로 동작하지 않아 도무지 이해할 수 없는 결과를 마주하게 될 것입니다. 꼭 기억해 두세요!
Exercise
The exercise for this section is located in 06_ticket_management/15_hashmap
정렬과 BTreeMap
Vec 대신 HashMap을 사용하면서 티켓 관리 시스템의 성능도 좋아지고 코드도 훨씬 간결해졌습니다. 하지만 세상에 공짜는 없죠. Vec 기반 저장소에서는 티켓이 추가된 순서대로 반환된다는 보장이 있었지만, HashMap은 그렇지 않습니다. 해시맵은 요소들을 반복(iterate)할 수는 있지만, 그 순서는 무작위로 결정됩니다.
만약 일관된 순서가 필요하다면 HashMap 대신 BTreeMap을 사용하는 것이 좋은 대안이 될 수 있습니다.
BTreeMap
BTreeMap은 데이터가 키(Key)를 기준으로 항상 정렬된 상태를 유지하도록 보장합니다. 따라서 특정 순서대로 데이터를 훑어보거나, 범위 쿼리(예: “ID가 10번부터 20번 사이인 티켓을 모두 보여줘”)를 수행할 때 매우 유용합니다.
HashMap과 마찬가지로 BTreeMap 구조체 정의 자체에는 특별한 제약이 없지만, 데이터를 넣는 insert 같은 메소드에는 조건이 붙습니다.
// `K`와 `V`는 각각 키와 값의 타입을 나타냅니다.
impl<K, V> BTreeMap<K, V> {
pub fn insert(&mut self, key: K, value: V) -> Option<V>
where
K: Ord,
{
// 구현부
}
}
재미있는 점은 BTreeMap에서는 Hash 트레이트가 필요하지 않다는 것입니다. 대신 키 타입이 반드시 Ord 트레이트를 구현하고 있어야 합니다.
Ord 트레이트
Ord 트레이트는 값들 사이의 선후 관계를 비교하는 데 사용됩니다. PartialEq가 ’동등성’을 따진다면, Ord는 ’순서’를 따지는 것이죠.
이 트레이트는 std::cmp 모듈에 정의되어 있습니다.
pub trait Ord: Eq + PartialOrd {
fn cmp(&self, other: &Self) -> Ordering;
}
cmp 메소드는 Less(작음), Equal(같음), Greater(큼) 중 하나의 값을 가지는 Ordering 열거형을 반환합니다. 또한 Ord를 구현하려면 반드시 Eq와 PartialOrd 트레이트도 함께 구현되어 있어야 합니다.
PartialOrd 트레이트
PartialOrd는 PartialEq가 Eq보다 느슨한 조건인 것처럼, Ord보다 조금 더 완화된 형태의 순서 비교 트레이트입니다. 정의를 살펴보면 그 이유를 알 수 있습니다.
pub trait PartialOrd: PartialEq {
fn partial_cmp(&self, other: &Self) -> Option<Ordering>;
}
PartialOrd::partial_cmp는 Option을 반환합니다. 즉, 두 값을 비교할 수 없는 경우가 있을 수도 있다는 뜻입니다. 예를 들어 f32 타입은 NaN(Not a Number) 값이 포함될 수 있는데, NaN은 다른 값과 크기를 비교할 수 없기 때문에 Ord를 구현하지 못하고 PartialOrd만 구현합니다.
Ord 및 PartialOrd 구현하기
Ord와 PartialOrd 역시 대부분의 경우 derive를 통해 간편하게 구현할 수 있습니다.
// `Ord`가 `Eq`를 요구하므로 `Eq`와 `PartialEq`도 함께 추가해야 합니다.
#[derive(Eq, PartialEq, Ord, PartialOrd)]
struct TicketId(u64);
만약 직접(수동으로) 구현해야 한다면 다음 사항에 주의해야 합니다.
Ord와PartialOrd는 반드시Eq및PartialEq와 일관된 결과를 내야 합니다.Ord와PartialOrd사이에도 서로 모순이 없어야 합니다.
Exercise
The exercise for this section is located in 06_ticket_management/16_btreemap
들어가며
Rust가 내세우는 가장 큰 약속 중 하나는 바로 **두려움 없는 동시성(Fearless Concurrency)**입니다. 이는 안전하고 효율적인 동시성 프로그램을 훨씬 더 쉽게 작성할 수 있게 해준다는 의미죠. 지금까지 우리는 주로 단일 스레드 환경에서만 작업해 왔는데요. 이제 그 경계를 넘어설 시간입니다!
이 챕터에서는 우리가 만든 티켓 저장소를 다중 스레드 환경에서 동작하도록 개선해 볼 것입니다. 그 과정에서 다음과 같은 Rust의 핵심 동시성 기능들을 깊이 있게 다루게 됩니다:
std::thread모듈을 활용한 스레드(Threads) 생성과 관리- **채널(Channels)**을 이용한 안전한 메시지 전달 방식
Arc,Mutex,RwLock을 활용한 상태 공유와 동기화- Rust의 동시성 보장을 뒷받침하는 핵심 트레이트인
Send와Sync
또한, 다중 스레드 시스템을 설계할 때 자주 쓰이는 다양한 패턴과 각 방식의 장단점에 대해서도 함께 살펴볼 예정입니다. 준비되셨나요? 시작해 봅시다!
Exercise
The exercise for this section is located in 07_threads/00_intro
스레드(Threads)
다중 스레드 코드를 직접 작성해 보기 전에, **스레드(Threads)**란 무엇인지 그리고 왜 사용하는지 먼저 가볍게 알아보겠습니다.
스레드란 무엇인가요?
스레드는 운영 체제(OS)에서 관리하는 가장 기본적인 실행 단위입니다. 각 스레드는 고유한 **스택(Stack)**과 **명령어 포인터(Instruction pointer)**를 가집니다.
하나의 프로세스(Process) 안에서는 여러 개의 스레드가 존재할 수 있습니다. 이 스레드들은 프로세스가 할당받은 동일한 메모리 공간을 공유하므로 서로 같은 데이터에 접근할 수 있다는 장점이 있습니다.
사실 스레드는 논리적인 개념입니다. 실제 물리적인 실행 단위인 **CPU 코어(CPU core)**는 한 번에 하나의 명령어 세트만 처리할 수 있죠. 하지만 시스템에는 CPU 코어 수보다 훨씬 많은 스레드가 돌아가는 경우가 많습니다. 이때 운영 체제의 **스케줄러(Scheduler)**가 CPU 시간을 아주 잘게 나누어 여러 스레드에 할당함으로써, 우리 눈에는 마치 여러 스레드가 동시에 실행되는 것처럼 보이게 만듭니다(이를 처리량과 응답성을 최대화하는 방식으로 관리합니다).
main 함수와 메인 스레드
Rust 프로그램이 시작되면 가장 먼저 **메인 스레드(Main thread)**라는 단일 스레드 위에서 코드가 실행됩니다. 이 스레드는 운영 체제가 생성하며, 우리 프로그램의 시작점인 main 함수를 실행하는 중책을 맡습니다.
use std::thread;
use std::time::Duration;
fn main() {
loop {
thread::sleep(Duration::from_secs(2));
println!("Hello from the main thread!");
}
}
std::thread 모듈
Rust의 표준 라이브러리는 스레드를 손쉽게 생성하고 관리할 수 있는 std::thread 모듈을 제공합니다.
spawn 함수
새로운 스레드를 만들고 그 안에서 특정 코드를 실행하고 싶다면 std::thread::spawn 함수를 사용합니다.
아래 예시를 볼까요?
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
loop {
thread::sleep(Duration::from_secs(1));
println!("Hello from a thread!");
}
});
loop {
thread::sleep(Duration::from_secs(2));
println!("Hello from the main thread!");
}
}
Rust 플레이그라운드에서 이 코드를 실행해 보세요. 메인 스레드와 우리가 방금 만든 새 스레드가 각자 독립적으로 메시지를 출력하는 것을 볼 수 있습니다.
프로세스 종료(Process termination)
중요한 점이 하나 있습니다. 메인 스레드가 작업을 마치면 전체 프로세스가 즉시 종료된다는 사실입니다. 만약 메인 스레드가 끝나버리면, 생성된 다른 스레드들이 아직 작업을 수행 중이더라도 강제로 함께 종료됩니다.
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
loop {
thread::sleep(Duration::from_secs(1));
println!("Hello from a thread!");
}
});
thread::sleep(Duration::from_secs(5));
}
위의 예제에서는 약 5초 동안만 “Hello from a thread!” 메시지가 출력될 것입니다. 메인 스레드가 5초간의 sleep을 마치고 끝나면 프로그램 자체가 종료되기 때문이죠.
join 메서드
생성한 스레드가 작업을 끝낼 때까지 메인 스레드가 기다려야 한다면 어떻게 할까요? spawn 함수가 반환하는 JoinHandle의 join 메서드를 사용하면 됩니다.
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("Hello from a thread!");
});
// 스레드가 끝날 때까지 기다립니다.
handle.join().unwrap();
}
이렇게 join을 호출하면 메인 스레드는 생성된 스레드가 완료될 때까지 대기 상태에 들어갑니다. 이는 두 스레드 간의 기본적인 동기화(Synchronization) 방식 중 하나로, 프로그램이 성급하게 종료되어 중요한 작업을 놓치는 것을 방지해 줍니다.
Exercise
The exercise for this section is located in 07_threads/01_threads
'static 라이프타임(’static lifetime)
이전 연습 문제에서 벡터의 슬라이스를 빌려 스레드로 넘기려 했을 때, 아마 다음과 같은 컴파일 오류를 마주쳤을 겁니다.
error[E0597]: `v` does not live long enough
|
11 | pub fn sum(v: Vec<i32>) -> i32 {
| - binding `v` declared here
...
15 | let right = &v[split_point..];
| ^ borrowed value does not live long enough
16 | let left_handle = spawn(move || left.iter().sum::<i32>());
| --------------------------------
argument requires that `v` is borrowed for `'static`
19 | }
| - `v` dropped here while still borrowed
여기서 argument requires that v is borrowed for 'static이라는 문구는 정확히 무엇을 의미할까요?
'static 라이프타임은 Rust의 아주 특별한 라이프타임입니다. 어떤 값이 프로그램이 실행되는 전체 기간 동안 유효함을 보장한다는 뜻입니다.
분리된 스레드(Detached threads)
thread::spawn으로 생성된 스레드는 부모 스레드보다 더 오래 살아남을 수 있습니다. 이런 스레드를 부모로부터 독립했다는 의미에서 **분리된 스레드(Detached threads)**라고 부르기도 합니다.
예를 들어볼까요?
use std::thread;
fn f() {
thread::spawn(|| {
thread::spawn(|| {
loop {
thread::sleep(std::time::Duration::from_secs(1));
println!("Hello from the detached thread!");
}
});
});
}
이 예제에서 첫 번째로 생성된 스레드는 자식 스레드를 하나 더 만들고 곧바로 종료됩니다. 하지만 그 자식 스레드는 부모(첫 번째 스레드)가 죽어도 상관없이 프로세스가 끝날 때까지 계속해서 메시지를 출력합니다. Rust에서는 이를 자식 스레드가 부모 스레드보다 **오래 살았다(Outlived)**고 표현합니다.
왜 'static이 필요한가요?
스레드는 언제든 부모보다 오래 살 수 있기 때문에, 프로그램 도중에 메모리에서 사라질 가능성이 있는 데이터를 빌려 써서는 안 됩니다. 만약 이미 해제된 메모리를 참조하게 된다면 해제 후 사용(Use-after-free) 버그가 발생할 수 있기 때문이죠.
이것이 바로 std::thread::spawn이 인자로 받는 클로저에 'static 라이프타임을 요구하는 이유입니다.
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static
{
// [..]
}
'static은 참조에만 해당되는 것이 아닙니다
흔히 오해하기 쉽지만, Rust에서 라이프타임은 참조뿐만 아니라 모든 값이 가집니다.
특히 Vec이나 String처럼 데이터를 직접 **소유하는 타입(Owned types)**은 기본적으로 'static 제약 조건을 만족합니다. 데이터를 소유하고 있다면, 그 데이터를 만든 함수가 끝난 뒤에도 여러분이 원하는 만큼 데이터를 유지하고 사용할 수 있기 때문입니다.
따라서 'static 요구사항을 충족하려면 다음 두 가지 중 하나를 선택해야 합니다:
- 데이터를 직접 **소유한 값(Owned value)**을 넘긴다.
- 프로그램 종료 시까지 유효한
'static참조를 넘긴다.
우리가 이전 연습 문제에서 문제를 해결한 방식이 바로 1번입니다. 슬라이스를 빌리는 대신 새로운 벡터를 할당해 데이터를 소유하게 만든 뒤, 이를 move 키워드로 스레드에 넘겨준 것이죠.
'static 참조
이제 2번 경우인 프로그램 전체 기간 동안 유효한 참조에 대해 알아봅시다.
정적 데이터(Static data)
가장 대표적인 예는 문자열 리터럴과 같은 **정적 데이터(Static data)**에 대한 참조입니다.
let s: &'static str = "Hello world!";
문자열 리터럴은 컴파일 시점에 이미 값이 정해져 있으며, 실행 파일 내부의 **읽기 전용 데이터 세그먼트(Read-only data segment)**라는 영역에 저장됩니다. 프로그램이 실행되는 동안 이 영역은 항상 존재하므로, 이를 가리키는 모든 참조는 안전하게 'static 규칙을 따르게 됩니다.
더 읽어보기 (영문)
Exercise
The exercise for this section is located in 07_threads/02_static
메모리 누수(Memory leaks)
생성된 스레드에 참조를 전달할 때 가장 큰 걸림돌은 앞서 언급한 해제 후 사용(Use-after-free) 버그입니다. 이미 해제된 메모리 영역을 가리키는 포인터로 데이터에 접근하려 할 때 발생하죠.
만약 **힙 할당 데이터(Heap-allocated data)**를 다루고 있다면, Rust에게 “이 메모리는 다시 회수하지 않아도 돼“라고 알려줌으로써 이 문제를 회피할 수 있습니다. 즉, 의도적으로 **메모리 누수(Memory leaks)**를 발생시키는 것입니다.
Rust 표준 라이브러리의 Box::leak 메서드를 사용하면 이 작업을 수행할 수 있습니다.
// `u32` 값을 `Box`로 감싸 힙에 할당합니다.
let x = Box::new(41u32);
// `Box::leak`을 사용하여 이 힙 메모리를 절대 해제하지 않겠다고 Rust에게 알립니다.
// 그 결과로 프로그램 종료 시까지 유효한 `'static` 참조를 얻게 됩니다.
let static_ref: &'static mut u32 = Box::leak(x);
메모리 누수는 프로세스 단위로 관리됩니다
물론 메모리 누수는 기본적으로 위험한 일입니다. 메모리를 끊임없이 누수하다 보면 결국 시스템의 메모리가 바닥나 메모리 부족(Out of Memory, OOM) 오류로 프로그램이 강제 종료될 수 있습니다.
// 이 코드를 실행하면 결국 시스템의 사용 가능한 모든 메모리를 다 써버리게 됩니다.
fn oom_trigger() {
loop {
let v: Vec<usize> = Vec::with_capacity(1024);
// 의도적으로 누수시킵니다.
v.leak();
}
}
하지만 leak 메서드로 누수시킨 메모리가 영원히 사라지는 것은 아닙니다. 운영 체제는 각 메모리 영역을 해당 프로세스에 매핑하여 관리하므로, 프로세스가 종료되는 순간 운영 체제는 해당 프로세스가 점유했던 모든 메모리를 자동으로 회수합니다.
이러한 점을 이용해 다음과 같은 상황에서는 메모리 누수를 전략적으로 활용하기도 합니다:
- 누수되는 메모리의 양이 미리 정해져 있거나 충분히 작은 경우
- 프로그램이 아주 짧은 시간만 실행되고 종료될 것이 확실한 경우
“운영 체제가 알아서 정리하게 둔다“는 전략은 상황에 따라 매우 효과적이고 유효한 메모리 관리 기법이 될 수 있습니다.
Exercise
The exercise for this section is located in 07_threads/03_leak
스코프 스레드(Scoped threads)
지금까지 우리가 겪었던 모든 라이프타임 문제의 원인은 결국 하나였습니다. 바로 생성된 스레드가 부모 스레드보다 더 오래 살 가능성이 있다는 점이죠.
이를 해결해 주는 마법 같은 기능이 바로 **스코프 스레드(Scoped threads)**입니다.
let v = vec![1, 2, 3];
let midpoint = v.len() / 2;
std::thread::scope(|scope| {
scope.spawn(|| {
let first = &v[..midpoint];
println!("벡터 v의 전반부: {first:?}");
});
scope.spawn(|| {
let second = &v[midpoint..];
println!("벡터 v의 후반부: {second:?}");
});
});
println!("원본 벡터 v: {v:?}");
코드가 어떻게 동작하는지 자세히 들여다볼까요?
scope 함수
std::thread::scope 함수는 새로운 **스코프(Scope)**를 생성합니다. 이 함수는 인자로 클로저를 받으며, 그 클로저 내부에서 Scope 인스턴스를 활용할 수 있게 해줍니다.
자동 조인(Auto-join)
Scope 인스턴스는 spawn 메서드를 제공합니다. 일반적인 std::thread::spawn과 결정적으로 다른 점은, 스코프 안에서 생성된 모든 스레드는 스코프가 끝나는 시점에 자동으로 조인(Join)된다는 것입니다.
위 예제 코드를 std::thread::spawn을 사용하는 방식으로 “번역“해 보면 다음과 같은 형태가 됩니다.
let v = vec![1, 2, 3];
let midpoint = v.len() / 2;
let handle1 = std::thread::spawn(|| {
let first = &v[..midpoint];
println!("벡터 v의 전반부: {first:?}");
});
let handle2 = std::thread::spawn(|| {
let second = &v[midpoint..];
println!("벡터 v의 후반부: {second:?}");
});
// 명시적으로 조인합니다.
handle1.join().unwrap();
handle2.join().unwrap();
println!("원본 벡터 v: {v:?}");
환경에서 빌려오기(Borrowing from the environment)
하지만 위와 같이 번역한 코드는 컴파일되지 않습니다! 컴파일러는 &v의 라이프타임이 'static이 아니므로 다른 스레드에서 안전하게 사용할 수 없다고 경고할 것입니다.
반면, std::thread::scope는 다릅니다. 이 안에서는 주변 환경의 데이터를 안전하게 빌려올 수 있습니다.
우리 예제에서 v는 scope가 만들어지기 전에 생성되었습니다. 그리고 v가 메모리에서 사라지는(Drop) 시점은 scope가 실행을 마치고 반환된 이후입니다. 동시에 scope 내에서 생성된 모든 스레드는 scope가 끝나기 전에 반드시 완료됨이 보장됩니다. 따라서 **댕글링 참조(Dangling reference)**가 발생할 위험이 원천적으로 차단되는 것이죠.
컴파일러는 이러한 안전성을 이해하고 기꺼이 통과시켜 줍니다!
Exercise
The exercise for this section is located in 07_threads/04_scoped_threads
채널(Channels)
지금까지 우리가 만들었던 스레드들은 수명이 상당히 짧았습니다. 입력을 받고, 계산을 마친 뒤, 결과를 반환하고 곧바로 종료되는 식이었죠.
하지만 우리가 만들 티켓 관리 시스템은 조금 다른 방식이 필요합니다. 바로 **클라이언트-서버 아키텍처(Client-server architecture)**입니다.
우리는 티켓 데이터를 관리하는 **장기 실행 서버 스레드(Server thread)**를 하나 두고, 여러 개의 **클라이언트 스레드(Client threads)**가 이 서버에 접근하도록 할 것입니다. 클라이언트는 새로운 티켓을 추가하라는 **명령(Command)**이나 티켓 상태를 보여달라는 **쿼리(Query)**를 서버에 보낼 수 있어야 합니다. 물론 이 클라이언트 스레드들은 동시에 실행될 것입니다.
통신 방식의 변화
지금까지 살펴본 부모-자식 간의 통신 방식은 매우 제한적이었습니다.
- 생성된 스레드가 부모의 데이터를 빌려오거나 소유권을 가져옴
- 스레드가 종료(Join)될 때 부모에게 결과를 반환함
이 방식만으로는 클라이언트-서버 구조를 구현하기 어렵습니다. 서버 스레드가 이미 실행 중인 상태에서 클라이언트가 언제든 데이터를 주고받을 수 있어야 하기 때문이죠.
이런 상황에서 구원투수로 등장하는 것이 바로 **채널(Channels)**입니다.
채널(Channels)이란?
Rust 표준 라이브러리는 std::sync::mpsc 모듈을 통해 다중 생산자, 단일 소비자(Multiple Producer Single Consumer, MPSC) 채널을 제공합니다. 채널은 데이터를 담는 용량이 정해진 바운드(Bounded) 방식과 제한이 없는 언바운드(Unbounded) 방식이 있는데, 일단은 언바운드 방식을 먼저 살펴보겠습니다.
채널은 다음과 같이 생성합니다.
use std::sync::mpsc::channel;
// 송신자(sender)와 수신자(receiver) 한 쌍이 반환됩니다.
let (sender, receiver) = channel();
채널은 데이터를 보내는 **송신자(Sender)**와 데이터를 받는 **수신자(Receiver)**로 구성됩니다. 송신자에서 send를 호출해 데이터를 넣고, 수신자에서 recv를 호출해 데이터를 꺼냅니다.
다중 생산자(Multiple Producers)
Sender는 복제가 가능합니다. 즉, 여러 개의 송신자를 만들어 각 클라이언트 스레드에 하나씩 나눠줄 수 있고, 이들은 모두 동일한 채널로 데이터를 보낼 수 있습니다.
반면, Receiver는 복제할 수 없습니다. 하나의 채널에는 오직 하나의 수신자만 존재할 수 있죠.
이것이 바로 **MPSC(다중 생산자, 단일 소비자)**라는 이름이 붙은 이유입니다!
메시지 타입(Message Type)
Sender와 Receiver는 제네릭 타입 T를 사용합니다. 채널을 통해 주고받을 메시지의 타입을 자유롭게 정할 수 있다는 뜻입니다. u64 같은 기본 타입부터 복잡한 구조체나 열거형(Enum)까지 무엇이든 가능합니다.
채널의 오류 처리
send와 recv 메서드는 실패할 가능성이 있습니다.
- 수신자(
Receiver)가 메모리에서 사라지면(Drop), 송신자가send를 할 때 오류가 발생합니다. - 모든 송신자(
Sender)가 사라지고 채널이 비어 있다면, 수신자가recv를 할 때 오류가 발생합니다.
간단히 말해, 채널의 한쪽 끝이 닫히면 더 이상 정상적인 통신을 할 수 없다는 뜻입니다.
Exercise
The exercise for this section is located in 07_threads/05_channels
내부 가변성(Interior mutability)
Sender의 send 시그니처를 잠시 살펴봅시다.
impl<T> Sender<T> {
pub fn send(&self, t: T) -> Result<(), SendError<T>> {
// [...]
}
}
send 메서드는 인자로 &self를 받습니다. 그런데 이 메서드는 분명히 채널에 새로운 메시지를 추가하는 변이(Mutation) 작업을 수행합니다. 게다가 더 흥미로운 사실은 Sender가 복제 가능하다는 것입니다. 즉, 여러 개의 Sender 인스턴스가 각기 다른 스레드에서 동시에 채널의 상태를 수정하려 할 수도 있습니다.
이것이 우리가 클라이언트-서버 아키텍처를 구현할 수 있는 핵심 원리입니다. 하지만 대체 어떻게 이게 가능한 걸까요? Rust의 엄격한 빌림 규칙을 위반하고 있는 건 아닐까요? 어떻게 불변 참조를 통해 데이터를 수정할 수 있는 걸까요?
불변 참조 대신 공유 참조(Shared reference)
이전 챕터에서 빌림 검사기를 처음 소개할 때, Rust에는 두 가지 종류의 참조가 있다고 말씀드렸습니다.
- 불변 참조(
&T) - 가변 참조(
&mut T)
사실 이들을 다음과 같이 부르는 것이 더 정확한 표현입니다.
- 공유 참조(Shared reference,
&T) - 배타적 참조(Exclusive reference,
&mut T)
“불변/가변“이라는 용어는 입문자가 Rust의 개념을 잡는 데는 아주 좋지만, 사실 전체 이야기의 일부일 뿐입니다. &T가 가리키는 데이터가 반드시 불변이라는 보장은 없거든요. 하지만 걱정하지 마세요! Rust가 약속하는 안전성은 여전히 굳건합니다. 단지 용어가 겉으로 보이는 것보다 조금 더 깊은 의미를 담고 있을 뿐입니다.
UnsafeCell
타입이 공유 참조를 통해 내부 데이터를 변경할 수 있도록 설계된 경우, 이를 **내부 가변성(Interior mutability)**을 가졌다고 말합니다.
기본적으로 Rust 컴파일러는 공유 참조가 가리키는 값은 변하지 않는다고 가정하고 코드 최적화를 수행합니다. 값을 캐싱하거나 실행 순서를 바꾸는 등 성능을 높이기 위한 온갖 작업을 처리하죠.
데이터를 UnsafeCell로 감싸면 컴파일러에게 “잠깐, 이 공유 참조는 실제로 값을 바꿀 수 있으니까 너무 마음대로 최적화하지 마!“라고 알려주는 효과가 있습니다. 내부 가변성을 허용하는 모든 타입(예: Mutex, RefCell 등)은 직간접적으로 이 UnsafeCell을 사용하고 있습니다.
하지만 오해는 금물입니다! UnsafeCell이 빌림 검사기를 무시하게 해주는 마법 지팡이는 아닙니다. unsafe 코드 역시 Rust의 엄격한 빌림 및 별칭 규칙을 따라야 합니다. 이는 단지 Rust의 타입 시스템만으로는 표현하기 어려운 **안전한 추상화(Safe abstraction)**를 구축하기 위해 제공되는 고급 도구일 뿐입니다. unsafe 키워드를 쓴다는 건 개발자가 컴파일러에게 “내가 이 코드의 불변성을 책임질 테니 믿어줘“라고 말하는 것과 같습니다.
우리는 이 과정에서 UnsafeCell을 직접 만지거나 unsafe 코드를 작성하지는 않을 겁니다. 하지만 Rust에서 매일 사용하는 타입들이 어떻게 돌아가는지 이해하는 것은 매우 중요합니다.
대표적인 예시
내부 가변성을 활용하는 표준 라이브러리의 주요 타입들을 살펴봅시다.
참조 카운팅(Rc)
Rc는 참조 횟수를 세는 포인터입니다. 값을 Rc로 감싸면 여러 곳에서 데이터를 공유할 수 있고, 마지막 참조가 사라질 때 비로소 데이터가 해제됩니다. Rc에 담긴 값 자체는 불변이지만, Rc는 내부적으로 UnsafeCell을 사용해 공유 참조를 통해서도 참조 카운트를 올리고 내릴 수 있게 구현되어 있습니다.
use std::rc::Rc;
let a: Rc<String> = Rc::new("내 문자열".to_string());
// 참조 카운트가 1입니다.
assert_eq!(Rc::strong_count(&a), 1);
// `clone`을 해도 문자열 자체가 복사되지는 않습니다!
// 대신 참조 카운트가 올라갑니다.
let b = Rc::clone(&a);
assert_eq!(Rc::strong_count(&a), 2);
RefCell
RefCell은 Rust에서 내부 가변성을 가장 잘 보여주는 예입니다. RefCell에 대한 공유 참조만 가지고 있어도 내부의 값을 바꿀 수 있습니다.
이는 **런타임 빌림 검사(Runtime borrow checking)**를 통해 실현됩니다. 컴파일 시점이 아닌 프로그램이 실행되는 동안 참조 규칙을 검사하는 것이죠. 만약 규칙을 어기면 프로그램은 패닉(Panic)을 일으켜 안전성을 보장합니다.
use std::cell::RefCell;
let x = RefCell::new(42);
let y = x.borrow(); // 불변 빌림 (성공)
let z = x.borrow_mut(); // 패닉! 이미 빌려진 상태에서 가변 빌림을 시도했습니다.
Exercise
The exercise for this section is located in 07_threads/06_interior_mutability
양방향 통신(Bi-directional communication)
현재 우리가 구현한 클라이언트-서버 구조에서는 통신이 클라이언트에서 서버로만 흐르는 일방통행 방식입니다. 클라이언트 입장에서는 서버가 메시지를 잘 받았는지, 요청한 작업을 성공적으로 마쳤는지 알 길이 없죠.
이런 답답한 상황을 해결하려면 서로 데이터를 주고받을 수 있는 양방향 통신(Bi-directional communication) 시스템이 필요합니다.
응답(Ack) 패턴(Ack pattern)
서버가 클라이언트에게 응답을 돌려줄 수 있는 가장 간단하고 명확한 방법은, 클라이언트가 서버에 메시지를 보낼 때 **답장을 받을 수 있는 송신자(Sender)**를 함께 실어 보내는 것입니다.
이를 응답(Ack) 패턴(Ack pattern) 또는 응답 채널 방식이라고 부릅니다. 서버는 요청받은 작업을 처리한 뒤, 메시지에 포함된 이 응답용 채널을 통해 클라이언트에게 결과를 다시 보내주면 됩니다.
이는 **메시지 전달 프리미티브(Message passing primitives)**를 활용하는 Rust 애플리케이션에서 아주 흔하게 볼 수 있는 설계 패턴입니다.
Exercise
The exercise for this section is located in 07_threads/07_ack
전용 클라이언트(Client) 타입
지금까지 클라이언트(Client) 측에서 이루어진 모든 상호작용은 꽤 저수준(low-level)이었습니다. 응답 채널을 직접 만들고, 명령(command)을 구성해서 서버로 보낸 다음, 다시 응답 채널에서 recv를 호출해 결과를 기다려야 했죠.
이런 과정은 추상화하기 딱 좋은 **보일러플레이트(boilerplate, 반복되는 표준 코드)**입니다. 이번 연습 문제에서는 바로 이 부분을 깔끔하게 정리해 볼 것입니다.
Exercise
The exercise for this section is located in 07_threads/08_client
유한 채널(Bounded channels) vs 무한 채널(Unbounded channels)
지금까지 우리는 **무한 채널(Unbounded channels)**을 사용해 왔습니다. 메시지를 보내고 싶은 만큼 계속 보낼 수 있고, 채널은 그 모든 메시지를 담기 위해 알아서 공간을 늘렸죠.
하지만 다중 생산자 단일 소비자(MPMC) 환경에서는 이게 문제가 될 수 있습니다. 만약 생산자(Producer)가 소비자(Consumer)가 처리하는 속도보다 더 빠르게 메시지를 큐(queue)에 넣으면 어떻게 될까요? 채널은 멈추지 않고 늘어나 결국 시스템의 모든 메모리를 다 써버릴 수도 있습니다.
실제 상용 시스템(Production)에서는 무한 채널을 거의 사용하지 않는 것이 원칙입니다. 항상 **유한 채널(Bounded channels)**을 사용하여 큐에 쌓일 수 있는 메시지 개수에 상한선을 두어야 합니다.
유한 채널(Bounded channels)
유한 채널은 정해진 용량(capacity)을 가집니다. sync_channel 함수를 호출할 때 0보다 큰 용량 값을 전달하여 만들 수 있습니다.
use std::sync::mpsc::sync_channel;
// 용량이 10인 유한 채널 생성 let (sender, receiver) = sync_channel(10);
이때 receiver의 타입은 이전과 같은 Receiver<T>이지만, sender는 SyncSender<T>라는 새로운 인스턴스가 됩니다.
메시지 보내기
SyncSender를 통해 메시지를 보낼 때는 상황에 따라 두 가지 메서드를 선택할 수 있습니다.
send: 채널에 빈 공간이 있다면 메시지를 큐에 넣고Ok(())를 반환합니다. 만약 채널이 가득 찼다면, 공간이 생길 때까지 현재 스레드를 차단(block)하고 기다립니다.try_send: 채널에 공간이 있으면 메시지를 넣고Ok(())를 반환합니다. 하지만 채널이 가득 찼다면 기다리지 않고 즉시Err(TrySendError::Full(value))를 반환합니다. 이때value는 보내려고 했던 메시지입니다.
역압력(Backpressure)
유한 채널을 사용하는 가장 큰 장점은 바로 **역압력(Backpressure)**을 제공한다는 점입니다. 소비자가 처리 속도를 따라가지 못할 때 생산자의 속도를 강제로 늦추게 만드는 것이죠.
역압력은 시스템 전체로 퍼져나가 전체 아키텍처에 안정성을 더해줍니다. 이를 통해 사용자의 갑작스러운 요청 폭주로 시스템 전체가 마비되는 것을 방지할 수 있습니다.
Exercise
The exercise for this section is located in 07_threads/09_bounded
업데이트 작업
지금까지 우리는 데이터를 삽입하고 검색하는 기능만 구현했습니다. 이제 업데이트 기능을 추가하며 시스템을 어떻게 확장할 수 있을지 알아봅시다.
단일 스레드 시절의 업데이트
스레드를 사용하지 않던 초기 버전에서는 업데이트가 꽤 간단했습니다. TicketStore는 호출자가 티켓에 대한 가변 참조(&mut Ticket)를 받아 직접 수정할 수 있게 해주는 get_mut 메서드를 제공했죠.
다중 스레드(Multi-threaded) 업데이트
하지만 지금의 다중 스레드 환경에서는 같은 전략이 통하지 않습니다. 바로 **빌림 검사기(Borrow Checker)**가 앞길을 막아서기 때문입니다. SyncSender<&mut Ticket>을 통해 가변 참조를 보내려 해도, 이 참조는 'static 라이프타임(lifetime)을 만족하지 않습니다. 따라서 std::thread::spawn으로 생성된 클로저 안으로 안전하게 넘겨질(capture) 수 없는 것이죠.
이런 제한을 해결하기 위해 여러 방법이 존재하는데, 다음 연습 문제에서 몇 가지를 살펴보겠습니다.
패치(Patch)
채널을 통해 가변 참조(&mut Ticket)를 보낼 수 없으니 클라이언트 측에서 직접 수정하는 것은 불가능합니다. 그렇다면 서버 측에서 대신 수정해 줄 수 없을까요?
서버에게 어떤 부분을 바꿔야 하는지 알려주면 가능합니다. 즉, 서버에 **패치(Patch)**를 보내는 것이죠.
struct TicketPatch {
id: TicketId,
title: Option<TicketTitle>,
description: Option<TicketDescription>,
status: Option<TicketStatus>,
}
업데이트할 티켓을 찾아야 하므로 id 필드는 필수입니다. 다른 필드들은 선택 사항(Option)으로 만듭니다.
- 필드 값이
None이면 해당 필드는 수정하지 않겠다는 뜻입니다. - 필드 값이
Some(value)이면 해당 필드를value로 변경하겠다는 뜻입니다.
Exercise
The exercise for this section is located in 07_threads/10_patch
잠금(Locks), Send 그리고 Arc
우리가 방금 구현한 패치(Patch) 전략에는 큰 약점이 있습니다. 바로 **경쟁 상태(Race condition)**죠. 두 클라이언트가 거의 동시에 같은 티켓에 패치를 보내면, 서버는 요청이 들어온 임의의 순서대로 이를 처리할 것입니다. 결국 나중에 패치를 보낸 클라이언트가 이전 클라이언트의 수정을 덮어쓰게 됩니다.
버전 번호
이 문제를 해결하는 한 가지 방법은 버전 번호를 사용하는 것입니다. 티켓이 처음 생성될 때 버전 번호 0을 부여하고, 클라이언트가 패치를 보낼 때마다 현재 알고 있는 버전 번호를 같이 보내게 합니다. 서버는 버전 번호가 일치할 때만 패치를 적용하고 버전 번호를 올립니다.
이런 방식은 분산 시스템에서 매우 흔하며, **낙관적 동시성 제어(Optimistic Concurrency Control)**라고 부릅니다. “대부분의 상황에서 충돌이 없을 것“이라고 가정하고 일반적인 상황에 최적화하는 전략이죠. 관심 있다면 나중에 보너스 문제로 직접 구현해 보셔도 좋습니다.
잠금(Locks)
또 다른 방법은 **잠금(Locks)**을 도입하는 것입니다. 클라이언트가 티켓을 수정하기 전에 먼저 티켓에 대한 잠금을 획득하게 만드는 것이죠. 잠금이 활성화된 동안에는 다른 클라이언트가 해당 티켓을 수정할 수 없습니다.
Rust의 표준 라이브러리는 두 가지 대표적인 잠금 기본형(primitive)을 제공합니다. 바로 Mutex<T>와 RwLock<T>입니다. 먼저 **뮤텍스(Mutex)**부터 시작해 봅시다. 뮤텍스는 **상호 배제(Mutual Exclusion)**의 줄임말로, 읽기든 쓰기든 상관없이 오직 하나의 스레드만 데이터에 접근할 수 있게 보장합니다.
Mutex<T>는 보호할 데이터를 감싸는(wrapping) 형태이며 제네릭 타입입니다. 데이터에 직접 접근하는 것은 불가능하며, Mutex::lock이나 Mutex::try_lock 메서드를 통해 먼저 잠금을 얻어야만 합니다. lock은 잠금을 얻을 때까지 스레드를 대기시키고, try_lock은 잠금을 얻을 수 없으면 바로 에러를 반환합니다.
두 메서드 모두 데이터에 접근할 수 있게 해주는 가드(guard) 객체를 반환하며, 이 가드 객체가 스코프를 벗어나 삭제(drop)될 때 잠금이 자동으로 해제됩니다.
use std::sync::Mutex;
// 뮤텍스로 보호되는 정수형 변수 생성 let lock = Mutex::new(0);
// 뮤텍스 잠금 획득 let mut guard = lock.lock().unwrap();
// 가드를 통해 데이터 수정 (Deref 트레이트 활용)
*guard += 1;
// guard가 범위를 벗어날 때 잠금이 자동으로 해제됩니다.
// drop(guard)를 호출해 명시적으로 해제할 수도 있습니다.
잠금의 입도(Lock granularity)
뮤텍스로 무엇을 감싸야 할까요? 가장 간단한 방법은 전체 TicketStore를 하나의 뮤텍스로 감싸는 것입니다. 하지만 이 방법은 성능을 크게 저해합니다. 단순 읽기조차 잠금이 풀리기를 기다려야 해서 여러 티켓을 동시에 읽는 병렬 처리가 불가능해지기 때문입니다. 이를 **굵은 입자의 잠금(Coarse-grained locking)**이라고 부릅니다.
그보다 좋은 방법은 각 티켓이 저마다의 잠금을 가지는 **세밀한 입자의 잠금(Fine-grained locking)**을 사용하는 것입니다. 이렇게 하면 클라이언트들이 서로 다른 티켓에 접근할 때 아무런 방해 없이 병렬로 작업할 수 있습니다.
struct TicketStore {
// 각 티켓마다 개별 뮤텍스를 둡니다.
tickets: BTreeMap<TicketId, Mutex<Ticket>>,
}
성능은 좋아지겠지만, 이제 TicketStore 자체가 다중 스레드 환경임을 의식해야 합니다. 이전까지 TicketStore는 스레드가 존재하는지조차 몰랐지만, 이제는 구조 자체가 변했죠.
누가 잠금을 소유하나?
전체 계획이 작동하려면 잠금(Lock)을 티켓을 수정하려는 클라이언트에게 전달할 수 있어야 합니다. 하지만 여기서 문제가 생깁니다. Mutex는 Clone이 아니며 TicketStore에서 마음대로 꺼내 옮길 수도 없습니다. 채널을 통해 Mutex<Ticket>을 보내는 것은 불가능하죠. 그렇다면 잠금을 획득한 결과인 MutexGuard를 보내면 어떨까요?
간단한 예시로 확인해 봅시다.
use std::thread::spawn;
use std::sync::Mutex;
use std::sync::mpsc::sync_channel;
fn main() {
let lock = Mutex::new(0);
let (sender, receiver) = sync_channel(1);
let guard = lock.lock().unwrap();
spawn(move || {
receiver.recv().unwrap();
});
// 가드(guard)를 다른 스레드로 보내려 시도합니다.
sender.send(guard);
}
이 코드는 컴파일되지 않습니다. Rust 컴파일러가 MutexGuard는 스레드 간에 안전하게 보내질 수 없다고 경고하기 때문입니다.
Send 트레이트
Send는 특정 타입이 한 스레드에서 다른 스레드로 안전하게 이동할 수 있음을 나타내는 **마커 트레이트(Marker trait)**입니다. Sized와 마찬가지로 컴파일러가 자동으로 구현해 주죠. 만약 수동으로 구현하려면 해당 타입이 스레드 간 이동 시 정말 안전하다는 것을 개발자가 보장해야 하므로 unsafe 키워드가 필요합니다.
채널의 요구사항
Sender<T>나 Receiver<T>는 전송하려는 데이터 T가 Send 트레이트를 구현하고 있을 때만 자신도 Send가 됩니다. 스레드 사이를 넘나들어야 하는데, 담긴 물건 자체가 위험하다면 보낼 수 없는 것과 같죠.
MutexGuard는 왜 Send가 아닐까?
일부 운영체제 환경에서는 잠금을 획득한 스레드가 반드시 그 잠금을 해제해야 한다는 규칙이 있습니다. 만약 MutexGuard를 다른 스레드로 보내버리면 잠금이 다른 곳에서 해제될 텐데, 이는 정의되지 않은 동작(undefined behavior)으로 이어질 수 있습니다. 그래서 MutexGuard는 Send가 아니도록 설계되었습니다.
우리의 도전 과제
정리해 봅시다.
- 채널로
MutexGuard를 보낼 수 없으므로, 서버가 잠금을 걸고 클라이언트가 수정하는 방식은 불가능합니다. Mutex자체는 데이터가Send라면Send이므로 채널로 보낼 수 있지만,TicketStore에 담긴 뮤텍스를 복제하거나 꺼내올 수도 없습니다.
이 딜레마를 어떻게 풀까요? 관점을 조금 바꿔 봅시다. 뮤텍스 잠금을 얻기 위해 반드시 소유권(ownership)이 필요한 것은 아닙니다. 뮤텍스는 **내부 가변성(Interior mutability)**을 사용하기 때문에 **공유 참조(&self)**만으로도 잠금을 얻을 수 있죠.
impl<T> Mutex<T> {
// self가 아니라 &self를 받습니다!
pub fn lock(&self) -> LockResult<MutexGuard<'_, T>> {
// ...
}
}
따라서 클라이언트에게 공유 참조만 넘겨줄 수 있다면 해결됩니다. 하지만 일반적인 참조는 수명(lifetime) 문제로 스레드에 넘길 수 없죠. 우리에게 필요한 건 **“소유 가능한 공유 참조”**입니다. 바로 여기서 Arc가 등장합니다.
Arc가 구원합니다
Arc는 **원자적 참조 카운팅(Atomic Reference Counting)**의 줄임말입니다. 데이터를 Arc로 감싸면 참조 횟수를 추적하며, 마지막 참조가 사라질 때 비로소 메모리에서 해제됩니다. Arc 내부의 데이터는 불변이며 공유 참조만 얻을 수 있습니다.
use std::sync::Arc;
let data: Arc<u32> = Arc::new(0);
// 참조 카운트를 올리며 복제합니다.
let data_clone = Arc::clone(&data);
// Arc<T>는 Deref<T>를 구현하므로 &T처럼 쓸 수 있습니다.
let data_ref: &u32 = &data;
어디서 본 것 같나요? 네, 이전에 배운 Rc와 매우 비슷합니다. 결정적인 차이점은 스레드 안전성입니다. Rc는 스레드 간에 공유할 수 없지만(Not Send), Arc는 원자적(Atomic) 연산으로 참조 카운트를 관리하므로 스레드 간에 안전하게 공유할 수 있습니다.
Arc<Mutex<T>> 조합
Arc와 Mutex를 조합하면 우리가 원하던 모든 조건을 만족하게 됩니다.
- 스레드 간에 보낼 수 있습니다 (
Arc와Mutex모두 데이터가Send라면Send이므로). - 복제가 가능합니다 (
Arc는 항상Clone이며, 복제 시 데이터가 아닌 참조 카운트만 늘립니다). - 내부 데이터를 수정할 수 있습니다 (
Arc를 통해 얻은Mutex의 공유 참조로 잠금을 얻을 수 있으므로).
이제 티켓 저장소의 잠금 전략을 구현할 준비가 모두 끝났습니다!
Exercise
The exercise for this section is located in 07_threads/11_locks
읽기 및 쓰기(RwLock)
개선된 TicketStore는 잘 작동하지만, 읽기 성능 면에서 아쉬움이 있습니다. Mutex<T>는 읽기 작업(Reader)과 쓰기 작업(Writer)을 구분하지 않기 때문에, 누군가 티켓을 읽고만 있어도 다른 클라이언트들은 그 티켓을 읽기 위해 줄을 서야 하죠.
이런 상황에서는 **읽기-쓰기 잠금(RwLock, Read-Write Lock)**인 RwLock<T>가 훌륭한 대안이 됩니다. RwLock<T>는 여러 명의 읽기 작업이 동시에 데이터에 접근하는 것을 허용하되, 쓰기 작업은 오직 한 명만 가능하도록 제어합니다.
RwLock<T>에는 잠금을 얻는 두 가지 메서드 read와 write가 있습니다. read는 데이터를 읽을 수 있는 가드를, write는 데이터를 수정할 수 있는 가드를 반환합니다.
use std::sync::RwLock;
// 읽기-쓰기 잠금으로 보호되는 정수형 변수 let lock = RwLock::new(0);
// 첫 번째 읽기 잠금 획득 let guard1 = lock.read().unwrap();
// 첫 번째 잠금이 유지되는 동안에도
// **두 번째** 읽기 잠금을 동시에 얻을 수 있습니다!
let guard2 = lock.read().unwrap();
장단점
얼핏 보면 RwLock<T>가 Mutex<T>의 모든 기능을 포함하면서 더 좋아 보입니다. 그렇다면 왜 항상 RwLock<T>만 쓰지 않고 Mutex<T>를 함께 사용할까요?
여기에는 두 가지 주요 이유가 있습니다.
- 성능 비용:
RwLock<T>는Mutex<T>보다 잠금을 거는 비용이 더 큽니다. 현재 읽기 작업과 쓰기 작업이 각각 몇 개인지 계속 추적해야 하기 때문이죠. 만약 쓰기 작업이 읽기 작업만큼 많다면 오히려Mutex<T>가 더 효율적일 수 있습니다. - 쓰기 작업 기아 현상(Writer Starvation): 끊임없이 읽기 요청이 들어온다면 쓰기 작업은 잠금을 얻을 기회를 영영 잡지 못할 수도 있습니다. 이를 ’기아 현상’이라고 부릅니다.
RwLock<T>은 잠금 획득 순서를 보장하지 않으며, 이는 운영체제의 정책에 따라 달라질 수 있습니다.
우리의 티켓 시스템처럼 대다수의 클라이언트가 조회를 주로 하고 수정은 가끔 하는 읽기 위주의 환경에서는 RwLock<T>가 아주 탁월한 선택이 될 것입니다.
Exercise
The exercise for this section is located in 07_threads/12_rw_lock
설계 검토: 채널 없이 구현하기
지금까지 우리가 걸어온 여정을 잠시 돌아봅시다.
채널 직렬화(Serialization)를 통한 동기화
첫 번째 다중 스레드 티켓 저장소는 다음과 같은 방식이었습니다.
- 공유 데이터를 관리하며 항상 실행 중인 서버(Server) 스레드 한 개
- 채널을 통해 요청을 보내는 여러 클라이언트(Client)
서버 스레드만이 데이터에 직접 접근할 수 있었기 때문에, 별도의 잠금이 필요 없었죠. 채널이 들어오는 모든 요청을 **직렬화(Serialization)**하여 한 줄로 세워주었기 때문입니다. 하지만 서버가 한 번에 하나의 요청만 처리할 수 있어 조회의 효율성이 떨어진다는 단점이 있었습니다.
세분화된 잠금(Fine-grained Locking)
그다음 우리는 각 티켓이 개별 잠금을 가지는 더 정교한 구조로 이동했습니다. 클라이언트가 직접 잠금을 획득해 데이터를 읽거나 원자적으로 수정할 수 있게 되었죠.
이 방식은 병렬 처리가 가능해졌지만, 여전히 서버 스레드가 명령을 하나씩 꺼내 잠금을 할당하는 직렬적인 방식에 의존하고 있었습니다. 그렇다면 채널을 아예 없애고 클라이언트가 TicketStore에 직접 접근하게 만들 수는 없을까요?
채널(Channel) 제거하기
채널을 제거하려면 두 가지 숙제를 해결해야 합니다.
- 여러 스레드 사이에서
TicketStore공유하기 - 데이터 접근 동기화하기
스레드 간 TicketStore 공유하기
모든 스레드가 동일한 상태를 바라보게 만들어야 합니다. 우리는 이미 스레드 간에 무언가를 공유할 때 어떤 해결책이 있는지 알고 있죠. 바로 **Arc**를 활용하는 것입니다.
접근 동기화하기
채널이 사라지면 TicketStore 자체에 대한 동기화 문제도 새로 생깁니다. 예전에는 채널 덕분에 티켓을 새로 추가하거나 삭제하는 과정이 자동으로 줄을 섰지만(직렬화), 이제는 저장소 자체에 대한 잠금도 필요하게 된 것이죠.
전체 저장소를 Mutex로 보호한다면 각 티켓마다 RwLock을 두는 것은 큰 의미가 없게 됩니다. 어차피 저장소 잠금이 풀릴 때까지 기다려야 하니까요. 반면에 저장소 전체를 RwLock으로 보호한다면, 티켓 조회는 병렬로 처리하면서 티켓의 삽입/삭제 시에만 모든 작업을 잠시 멈추게 할 수 있습니다.
이제 이 방향으로 코드를 작성하면 어떤 결과가 나오는지 살펴봅시다!
Exercise
The exercise for this section is located in 07_threads/13_without_channels
Sync 트레이트
이 챕터를 마무리하기 전, Rust 표준 라이브러리의 또 다른 핵심 트레이트인 **Sync**에 대해 알아보겠습니다.
Sync는 Send와 마찬가지로 컴파일러가 자동으로 구현해 주는 **마커 트레이트(Marker trait)**입니다. 여러 스레드 사이에서 안전하게 **공유(Share)**될 수 있는 모든 타입은 Sync를 가집니다.
더 정확한 정의는 다음과 같습니다.
&T(공유 참조)가Send라면,T는Sync입니다.
T: Sync라고 해서 T: Send인 것은 아닙니다
어떤 타입은 스레드 간에 **이동(Move)**하는 것은 불가능해도 **공유(Share)**하는 것은 가능할 수 있습니다. 예를 들어, MutexGuard는 Send가 아니지만 Sync입니다.
MutexGuard가 Send가 아닌 이유는 잠금을 얻은 바로 그 스레드에서만 해제되어야 하기 때문입니다. 하지만 다른 스레드에게 &MutexGuard를 보여주는 것은 잠금이 해제되는 위치와 상관없으므로 Sync가 될 수 있는 것이죠.
T: Send라고 해서 T: Sync인 것은 아닙니다
반대의 경우도 가능합니다. 한 스레드에서 다른 스레드로 완전히 이동시키는 것은 안전하지만, 여러 스레드가 동시에 공유하는 것은 위험한 경우죠. 대표적인 예가 **RefCell<T>**입니다.
RefCell<T>는 런타임에 빌림 검사를 수행하는데, 이때 사용하는 카운터가 스레드 간 동기화를 보장하지 않습니다. 만약 여러 스레드가 동시에 &RefCell을 가지고 데이터를 수정하려 하면 **데이터 경합(Data race)**이 발생하고 말 것입니다. 그래서 RefCell은 Sync가 아닙니다. 하지만 소유권을 다른 스레드로 완전히 넘겨버리면(Send), 기존 스레드에는 아무런 참조가 남지 않으므로 안전합니다.
이처럼 Send와 Sync는 Rust가 다중 스레드 환경에서도 메모리 안전성을 보장하기 위해 사용하는 아주 강력한 도구입니다.
Exercise
The exercise for this section is located in 07_threads/14_sync
비동기 Rust (Asynchronous Rust)
Rust에서 동시성(Concurrency) 프로그램을 작성하는 방법이 스레드(Threads)만 있는 것은 아닙니다. 이번 챕터에서는 또 다른 접근 방식인 **비동기 프로그래밍(Asynchronous Programming)**을 탐색해 보겠습니다.
특히 다음과 같은 핵심 개념들을 소개합니다:
- 비동기 코드를 손쉽게 작성할 수 있게 해주는
async/.await키워드 - 아직 완료되지 않았을 수 있는 작업을 나타내는
Future트레이트 - 비동기 코드를 실행하기 위한 가장 인기 있는 **런타임(Runtime)**인
tokio - Rust 비동기 모델의 협력적(Cooperative) 특성과 이것이 여러분의 코드에 미치는 영향
Exercise
The exercise for this section is located in 08_futures/00_intro
비동기 함수 (Async Functions)
지금까지 작성한 모든 함수와 메서드는 호출 시 즉시 실행되었습니다. 호출 전에는 아무 일도 일어나지 않지만, 일단 호출되면 작업이 완료될 때까지 실행을 멈추지 않았죠. 즉, 모든 작업을 수행한 뒤 결과를 반환했습니다.
하지만 때로는 이런 방식이 효율적이지 않을 수 있습니다. 예를 들어 HTTP 서버를 만든다고 할 때, 많은 대기(Waiting) 시간이 발생할 수 있습니다. 요청 본문이 도착하길 기다리거나, 데이터베이스의 응답을 기다리고, 외부 서비스가 대답해 주기를 기다리는 것처럼 말이죠.
만약 기다리는 동안 다른 유용한 작업을 할 수 있다면 어떨까요? 작업 도중에 잠시 멈추고 나중에 다시 시작할 수 있다면? 혹은 현재 작업보다 더 급한 다른 일에 우선순위를 둘 수 있다면?
이런 요구를 충족시켜 주는 것이 바로 **비동기 함수(Async functions)**입니다.
async fn
async 키워드를 사용하면 비동기 함수를 정의할 수 있습니다:
use tokio::net::TcpListener;
// 이 함수는 비동기로 동작합니다 async fn bind_random() -> TcpListener {
// [...]
}
만약 bind_random을 일반 함수처럼 그냥 호출하면 어떻게 될까요?
fn run() {
// `bind_random` 호출
let listener = bind_random();
// 이제 무엇을 해야 할까요?
}
답은 “아무 일도 일어나지 않는다“입니다!
Rust는 bind_random을 호출하는 즉시 실행을 시작하지 않습니다. 다른 언어들의 경험에 비추어 백그라운드 작업이 시작될 거라 기대할 수도 있지만, 그렇지 않습니다.
Rust의 비동기 함수는 게으릅니다(Lazy). 명시적으로 요청하기 전까지는 아무런 일도 하지 않죠.
Rust 용어로는 bind_random이 **퓨처(Future)**를 반환한다고 말합니다. 퓨처는 나중에 완료될 수 있는 계산 과정을 나타내는 타입입니다. 이들은 Future 트레이트를 구현하고 있기 때문에 퓨처라고 불리며, 이번 챕터에서 이 인터페이스에 대해 자세히 알아볼 것입니다.
.await
비동기 함수에게 실제 작업을 시키는 가장 일반적인 방법은 .await 키워드를 사용하는 것입니다:
use tokio::net::TcpListener;
async fn bind_random() -> TcpListener {
// [...]
}
async fn run() {
// `bind_random`을 호출하고 완료될 때까지 기다립니다
let listener = bind_random().await;
// 이제 `listener`가 준비되었습니다
}
.await는 비동기 함수가 완료되어 결과(예제에서는 TcpListener)를 얻을 때까지 호출자에게 제어권을 넘겨주지 않습니다.
런타임 (Runtime)
여기서 조금 혼란스러울 수 있습니다! 방금 전까지 비동기 함수의 장점은 모든 작업을 한꺼번에 하지 않는 것이라고 해놓고, 바로 다음에 작업이 끝날 때까지 멈춰 있는 .await를 소개했으니까요. “결국 똑같은 거 아닌가?” 싶으실 겁니다.
하지만 사실 그렇지 않습니다! .await를 호출할 때 내부적으로는 훨씬 많은 일이 일어납니다. 바로 비동기 런타임(Asynchronous Runtime), 다른 말로 **비동기 실행기(Asynchronous Executor)**에게 제어권을 양보(Yield)하는 것이죠. 실행기는 마법 같은 일을 수행합니다. 현재 진행 중인 모든 비동기 **태스크(Task)**를 관리하며, 다음 두 가지 목표 사이의 균형을 맞춥니다:
- 진행(Progress): 태스크가 수행 가능한 상태라면 최대한 빨리 진행되도록 합니다.
- 효율성(Efficiency): 어떤 태스크가 무언가를 기다리고 있다면, 그동안 다른 태스크를 실행하여 리소스를 낭비하지 않도록 합니다.
기본 런타임의 부재
Rust는 비동기 프로그래밍 방식에서 꽤 독특한 점이 있습니다. 바로 기본 런타임이 없다는 것입니다. 표준 라이브러리에도 런타임이 포함되어 있지 않아, 사용자가 직접 가져와야 합니다!
대부분의 경우 생태계에 이미 구현된 라이브러리 중 하나를 선택하게 됩니다. 범용적으로 가장 널리 쓰이는 탄탄한 옵션으로는 tokio와 async-std가 있고, 임베디드 시스템처럼 특정 목적에 최적화된 embassy 같은 런타임도 있습니다.
우리는 이번 과정에서 Rust에서 가장 인기 있는 범용 비동기 런타임인 **tokio**를 사용할 것입니다.
#[tokio::main]
실행 파일의 진입점인 main 함수는 기본적으로 동기 함수여야 합니다. 따라서 거기서 여러분이 선택한 비동기 런타임을 설정하고 가동해 주어야 하죠.
많은 런타임이 이 과정을 쉽게 해주는 매크로를 제공합니다. tokio의 경우 #[tokio::main]을 사용합니다:
#[tokio::main]
async fn main() {
// 이제 여기서 비동기 코드를 작성할 수 있습니다
}
이 코드는 내부적으로 다음과 같이 확장됩니다:
fn main() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(
// 비동기 함수가 여기서 실행됩니다
// [...]
);
}
#[tokio::test]
테스트도 마찬가지입니다. 기본적으로는 동기 함수로 작동하죠. 각 테스트 함수는 독립된 스레드에서 실행되므로, 비동기 코드를 테스트하려면 직접 런타임을 구성해야 합니다.
tokio는 이를 간편하게 도와주는 #[tokio::test] 매크로를 제공합니다:
#[tokio::test]
async fn my_test() {
// 비동기 테스트 코드를 여기서 작성하세요
}
Exercise
The exercise for this section is located in 08_futures/01_async_fn
태스크 스폰 (Task Spawn)
이전 연습 문제에 대한 여러분의 해결책은 아마 다음과 비슷할 것입니다:
pub async fn echo(listener: TcpListener) -> Result<(), anyhow::Error> {
loop {
let (mut socket, _) = listener.accept().await?;
let (mut reader, mut writer) = socket.split();
tokio::io::copy(&mut reader, &mut writer).await?;
}
}
나쁘지 않은 코드입니다! 두 연결 사이에 공백 시간이 길어지면 echo 함수는 유휴(Idle) 상태가 됩니다. TcpListener::accept가 비동기 함수이기 때문에, 기다리는 동안 실행기가 다른 태스크를 수행할 수 있도록 제어권을 넘겨주죠.
하지만 실제로 여러 태스크를 동시에 실행하려면 어떻게 해야 할까요? 위 코드처럼 비동기 함수를 항상 완료될 때까지 기다리면(.await), 한 번에 하나의 태스크밖에 처리할 수 없습니다.
이때 필요한 것이 바로 **태스크 스폰(Spawn)**입니다.
tokio::spawn
tokio::spawn을 사용하면 태스크를 실행기에 넘겨줄 수 있으며, 그 태스크가 완료될 때까지 기다리지 않아도 됩니다.
tokio::spawn을 호출하는 것은 tokio에게 “이 태스크를 백그라운드에서 실행해 줘. 그리고 원래 하던 일(태스크를 만든 태스크)과 동시에 진행해 줘“라고 부탁하는 것과 같습니다.
여러 연결을 동시에 처리하기 위해 사용하는 방법은 다음과 같습니다:
use tokio::net::TcpListener;
pub async fn echo(listener: TcpListener) -> Result<(), anyhow::Error> {
loop {
let (mut socket, _) = listener.accept().await?;
// 연결을 처리하기 위해 백그라운드 태스크를 생성(Spawn)합니다.
// 덕분에 메인 태스크는 즉시 다음 연결을 수락할 준비를 할 수 있습니다.
tokio::spawn(async move {
let (mut reader, mut writer) = socket.split();
tokio::io::copy(&mut reader, &mut writer).await?;
});
}
}
비동기 블록 (Async Blocks)
위 예제에서는 tokio::spawn에 비동기 블록을 전달했습니다: async move { /* */ }
비동기 블록은 별도의 비동기 함수를 정의하지 않고도 특정 코드 영역을 비동기로 표시할 수 있는 간편한 방법입니다.
JoinHandle
tokio::spawn은 JoinHandle을 반환합니다. 스레드에서 join을 사용해 결과를 기다리는 것처럼, JoinHandle을 사용해 백그라운드 태스크의 종료를 기다릴(.await) 수 있습니다.
pub async fn run() {
// 원격 서버로 텔레메트리 데이터를 보내는 백그라운드 태스크를 생성합니다.
let handle = tokio::spawn(emit_telemetry());
// 그동안 다른 유용한 작업을 수행합니다.
do_work().await;
// 하지만 텔레메트리 데이터가 성공적으로 전달될 때까지는
// 호출자에게 결과를 반환하지 않고 기다립니다.
handle.await;
}
pub async fn emit_telemetry() {
// [...]
}
pub async fn do_work() {
// [...]
}
패닉 경계 (Panic Boundary)
tokio::spawn으로 생성된 태스크에서 패닉이 발생하면, 그 패닉은 실행기가 잡아냅니다(Catch). 만약 해당 JoinHandle을 .await하지 않는다면 패닉은 생성자(Parent task)에게 전파되지 않습니다.
JoinHandle을 기다리더라도 패닉이 자동으로 전파되는 것은 아닙니다.
JoinHandle을 기다리면 Result를 반환하며, 오류 발생 시 타입은 JoinError입니다. JoinError::is_panic을 사용해 태스크가 패닉으로 종료되었는지 확인하고,
패닉을 어떻게 처리할지(로그 기록, 무시, 혹은 다시 전파) 결정할 수 있습니다.
use tokio::task::JoinError;
pub async fn run() {
let handle = tokio::spawn(work());
if let Err(e) = handle.await {
if let Ok(reason) = e.try_into_panic() {
// 태스크가 패닉을 일으켰습니다.
// 패닉 상태를 현재 태스크로 전파하여 다시 발생시킵니다.
panic::resume_unwind(reason);
}
}
}
pub async fn work() {
// [...]
}
std::thread::spawn vs tokio::spawn
tokio::spawn은 std::thread::spawn의 비동기 버전이라고 생각하면 이해하기 쉽습니다.
하지만 중요한 차이점이 하나 있습니다. std::thread::spawn을 사용하면 제어권이 OS 스케줄러에게 넘어갑니다. 스레드가 어떻게 스케줄링되는지 우리가 직접 제어할 수 없죠.
반면 tokio::spawn은 사용자 공간(User space)에서 완전히 돌아가는 비동기 실행기에게 제어권을 넘깁니다. 이제 어떤 태스크를 다음에 실행할지는 OS 스케줄러가 아니라 우리가 선택한 실행기가 결정하게 됩니다.
Exercise
The exercise for this section is located in 08_futures/02_spawn
런타임 아키텍처 (Runtime Architecture)
지금까지 비동기 런타임을 추상적인 개념으로만 이야기해 왔습니다. 이제 런타임이 실제로 어떻게 구현되어 있는지 좀 더 깊이 파고들어 봅시다. 곧 보게 되겠지만, 런타임의 구조는 우리가 작성하는 코드에도 직접적인 영향을 미칩니다.
종류
tokio는 크게 두 가지 종류의 런타임을 제공합니다.
tokio::runtime::Builder를 통해 런타임을 구성할 수 있습니다:
Builder::new_multi_thread: 다중 스레드(Multi-thread)tokio런타임을 생성합니다.Builder::new_current_thread: 실행을 위해 현재 스레드(Current thread) 하나에만 의존하는 런타임을 생성합니다.
참고로 #[tokio::main]은 기본적으로 다중 스레드 런타임을 사용하고, #[tokio::test]는 기본적으로 현재 스레드 런타임을 사용합니다.
현재 스레드 런타임 (Current-thread Runtime)
이름에서 알 수 있듯이, 태스크를 스케줄링하고 실행하기 위해 런타임이 시작된 하나의 OS 스레드만 사용합니다. 이 런타임을 사용하면 **동시성(Concurrency)**은 있지만 **병렬성(Parallelism)**은 없습니다. 여러 비동기 태스크가 번갈아 가며 실행(Interleaving)되지만, 특정 시점에 실행 중인 태스크는 언제나 최대 하나입니다.
다중 스레드 런타임 (Multi-thread Runtime)
반면 다중 스레드 런타임을 사용하면 특정 시점에 최대 N개의 태스크가 병렬로 실행될 수 있습니다. 여기서 N은 런타임이 사용하는 스레드 수이며, 기본적으로 시스템의 CPU 코어 수와 일치합니다.
또한 tokio는 **작업 훔치기(Work-stealing)**라는 기법을 사용합니다. 어떤 스레드가 할 일이 없어 유휴 상태가 되면 그냥 놀지 않고, 전역 큐나 다른 스레드의 로컬 큐에서 실행 대기 중인 태스크를 ‘훔쳐와서’ 대신 실행합니다. 이 방식은 특히 스레드 간에 작업량이 불균형할 때 성능을 크게 향상시키며, 응답 지연 시간(Latency)을 줄이는 데 큰 도움이 됩니다.
영향
tokio::spawn은 어떤 종류의 런타임에서 실행하든 상관없이 잘 작동합니다. 하지만 그 대가로 함수 시그니처가 ‘최악의 경우’(즉, 다중 스레드 환경)를 가정하고 제약이 걸려 있습니다:
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{ /* */ }
여기서 Future 트레이트는 잠시 제쳐두고 나머지 제약 조건들을 살펴봅시다.
spawn은 모든 입력값이 Send여야 하며 'static 라이프타임을 가져야 한다고 요구합니다.
'static 제약 조건은 std::thread::spawn과 같은 이유로 존재합니다. 생성된 태스크가 자신을 만든 컨텍스트보다 오래 살아남을 수 있기 때문에, 컨텍스트가 사라지면 같이 해제될 수 있는 로컬 데이터에 의존해서는 안 되기 때문입니다.
fn spawner() {
let v = vec![1, 2, 3];
// `&v`가 태스크보다 오래 산다는 보장이 없으므로 컴파일되지 않습니다.
tokio::spawn(async {
for x in &v {
println!("{x}")
}
})
}
반면 Send 제약 조건은 tokio의 작업 훔치기 전략 때문에 필요합니다. 스레드 A에서 생성된 태스크가 나중에 스레드 B로 이동되어 실행될 수 있기 때문에, 태스크 자체가 스레드 경계를 넘나들 수 있어야(Send) 하는 것이죠.
fn spawner(input: Rc<u64>) {
// `Rc`는 `Send`가 아니므로 이 코드는 작동하지 않습니다.
tokio::spawn(async move {
println!("{}", input);
})
}
Exercise
The exercise for this section is located in 08_futures/03_runtime
Future 트레이트
로컬 Rc의 문제
tokio::spawn의 시그니처를 다시 살펴봅시다:
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{ /* */ }
F가 Send여야 한다는 것은 실제로 무엇을 의미할까요? 이전 섹션에서 보았듯이, 생성 환경에서 캡처(Capture)하는 모든 값이 Send여야 한다는 뜻입니다. 하지만 그게 전부가 아닙니다.
.await 지점을 거쳐서 유지되는 모든 값 역시 Send여야 합니다. 다음 예제를 보겠습니다:
use std::rc::Rc;
use tokio::task::yield_now;
fn spawner() {
tokio::spawn(example());
}
async fn example() {
// 비동기 함수 *내부*에서 생성된 `Send`가 아닌 값
let non_send = Rc::new(1);
// 아무 일도 하지 않고 제어권만 넘기는 `.await` 지점
yield_now().await;
// 로컬의 `Send`가 아닌 값이 `.await` 이후에도 여전히 사용됩니다.
println!("{}", non_send);
}
컴파일러는 이 코드를 거절할 것입니다:
error: future cannot be sent between threads safely
|
5 | tokio::spawn(example());
| ^^^^^^^^^
| `example`가 반환하는 퓨처는 `Send`가 아닙니다
|
note: 이 값이 await를 넘어 사용되므로 퓨처는 `Send`가 아닙니다
|
11 | let non_send = Rc::new(1);
| -------- `Rc<i32>` 타입이며 `Send`가 아닙니다
12 | // `.await` 지점
13 | yield_now().await;
| ^^^^^
| `non_send`가 나중에 사용될 수 있으므로 여기서 await가 발생합니다 note: `tokio::spawn`의 바운드에 의해 요구됨
|
164 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
| ----- 이 함수에서 바운드에 의해 요구됨
165 | where
166 | F: Future + Send + 'static,
| ^^^^ `spawn`의 이 바운드에 의해 요구됨
이 현상을 이해하려면 Rust의 비동기 모델에 대한 지식을 좀 더 다듬어야 합니다.
Future 트레이트
비동기 함수는 Future 트레이트를 구현하는 타입인 **퓨처(Future)**를 반환한다고 앞서 언급했습니다. 퓨처는 일종의 **상태 머신(State Machine)**으로 생각할 수 있습니다. 퓨처는 항상 다음 두 상태 중 하나에 놓여 있습니다:
- 대기 중 (Pending): 계산이 아직 완료되지 않았습니다.
- 준비 완료 (Ready): 계산이 완료되었으며 결과물이 준비되었습니다.
이 구조는 트레이트 정의에 그대로 녹아 있습니다:
trait Future {
type Output;
// 지금은 `Pin`과 `Context`는 무시하셔도 좋습니다.
fn poll(
self: Pin<&mut Self>,
cx: &mut Context<'_>
) -> Poll<Self::Output>;
}
poll 메서드
poll 메서드는 Future 트레이트의 핵심입니다. 퓨처는 스스로 아무것도 하지 않습니다. 진행을 하려면 누군가 **폴링(Polling, 상태 확인)**을 해줘야 하죠.
poll을 호출하는 것은 퓨처에게 “작업을 좀 진행해 봐“라고 요청하는 것과 같습니다.
poll은 작업을 진행한 뒤 다음 중 하나를 반환합니다:
Poll::Pending: 퓨처가 아직 준비되지 않았습니다. 나중에 다시poll을 호출해야 합니다.Poll::Ready(value): 퓨처가 작업을 완료했습니다.value는Self::Output타입의 계산 결과입니다.
중요한 점은 Future::poll이 Poll::Ready를 반환했다면, 그 퓨처를 다시 폴링해서는 안 된다는 것입니다. 이미 끝난 작업이니까요.
런타임의 역할
여러분이 poll을 직접 호출할 일은 거의 없습니다. 그건 비동기 런타임의 몫입니다. 런타임은 poll 시그니처에 있는 Context를 통해 퓨처를 언제 다시 진행시킬 수 있을지에 대한 정보를 모두 관리합니다.
async fn과 퓨처
우리는 지금까지 비동기 함수라는 고수준 인터페이스를 사용해 왔고, 방금 Future 트레이트라는 저수준 프리미티브를 살펴보았습니다.
이 둘은 어떤 관계일까요?
함수를 async로 표시하면, 컴파일러는 그 함수가 퓨처를 반환하도록 만듭니다. 동시에 함수의 본문을 상태 머신으로 변환하죠. 각 .await 지점이 상태 머신의 한 단계가 됩니다.
Rc 예제로 다시 돌아가 봅시다:
use std::rc::Rc;
use tokio::task::yield_now;
async fn example() {
let non_send = Rc::new(1);
yield_now().await;
println!("{}", non_send);
}
컴파일러는 대략 다음과 같은 열거형(Enum)으로 이를 변환합니다:
pub enum ExampleFuture {
NotStarted,
YieldNow(Rc<i32>),
Terminated,
}
example이 호출되면 처음엔 ExampleFuture::NotStarted 상태의 퓨처를 반환합니다. 아직 폴링되지 않았으므로 아무 일도 일어나지 않죠. 런타임이 이를 처음 폴링하면, 퓨처는 다음 .await 지점까지 코드를 실행합니다. 그리고 ExampleFuture::YieldNow(Rc<i32>) 상태에서 멈춘 뒤 Poll::Pending을 반환합니다. 다시 폴링되면 나머지 코드(println!)를 실행하고 Poll::Ready(())를 반환하며 종료됩니다.
이렇게 상태 머신으로 변환된 ExampleFuture를 보면, 왜 example이 Send가 될 수 없는지 명확해집니다. 상태 머신이 Rc를 내부 데이터로 들고 있기 때문에 전체 퓨처 타입 자체가 Send가 아니게 되는 것이죠.
양보 지점 (Yield Points)
예제에서 보았듯이, 모든 .await 지점은 퓨처의 생애 주기에서 새로운 중간 상태를 만들어냅니다. 그래서 .await 지점을 **양보 지점(Yield Points)**이라고도 부릅니다. 퓨처가 자신을 폴링하던 런타임에게 제어권을 ’양보’하여, 런타임이 잠시 이 태스크를 멈추고 다른 급한 일을 처리할 수 있게 해주기 때문입니다.
양보가 왜 중요한지에 대해서는 다음 섹션에서 더 자세히 다루겠습니다.
Exercise
The exercise for this section is located in 08_futures/04_future
런타임을 차단하지 마세요 (Don’t Block the Runtime)
다시 양보 지점(Yield points) 이야기로 돌아가 봅시다. 스레드와 달리, Rust의 태스크는 선점(Preemption)될 수 없습니다.
tokio 런타임이 임의로 태스크를 중단시키고 다른 태스크를 실행하도록 결정할 수 없다는 뜻입니다. 제어권은 태스크가 명시적으로 양보할 때만 실행기에게 돌아갑니다. 즉, Future::poll이 Poll::Pending을 반환하거나, 비동기 함수 내에서 .await를 호출할 때만 제어권이 넘어갑니다.
이런 구조는 런타임에 위험 요인이 될 수 있습니다. 만약 어떤 태스크가 절대 양보하지 않는다면, 런타임은 영영 다른 태스크를 실행할 기회를 얻지 못할 것입니다. 이를 **런타임 차단(Blocking the runtime)**이라고 부릅니다.
’차단(Blocking)’이란 무엇일까요?
얼마나 오래 걸려야 ’너무 길다’고 할 수 있을까요? 태스크가 양보 없이 어느 정도의 시간을 보내면 문제가 될까요?
이는 런타임 설정, 애플리케이션의 성격, 진행 중인 태스크의 수 등 많은 요인에 따라 달라집니다. 하지만 일반적인 가이드라인으로는, 양보 지점 사이의 실행 시간이 **100 마이크로초(μs)**를 넘지 않도록 노력하는 것이 좋습니다.
차단 시 발생하는 문제
런타임이 차단되면 다음과 같은 심각한 문제가 발생할 수 있습니다:
- 교착 상태 (Deadlock): 양보하지 않는 태스크 A가 태스크 B의 완료를 기다리고 있고, 태스크 B는 태스크 A가 제어권을 양보하기를 기다리고 있다면 전체 시스템이 멈춰버립니다. 다른 스레드에서 태스크를 실행할 수 없는 상황이라면 영구적인 교착 상태에 빠집니다.
- 기아 현상 (Starvation): 다른 태스크들이 실행 기회를 잡지 못해 응답이 지연됩니다. 이는 전체적인 성능 저하와 높은 응답 지연 시간(Tail latency)으로 이어집니다.
차단은 생각보다 교묘합니다
어떤 작업들은 비동기 코드에서 특히 주의해야 합니다. 대표적으로 다음과 같은 것들이 있습니다:
- 동기 I/O: 작업 완료까지 얼마나 걸릴지 예측할 수 없으며, 100 마이크로초를 훌쩍 넘길 가능성이 매우 높습니다.
- 고비용 CPU 연산: 계산량이 많은 작업들입니다.
CPU 연산의 경우 판단이 쉽지 않을 때가 있습니다. 예를 들어 몇 개의 요소를 가진 벡터를 정렬하는 것은 문제가 없지만, 수십억 개의 데이터를 정렬한다면 이야기가 완전히 달라지죠.
차단을 피하는 방법
그렇다면 차단이 발생할 위험이 있는 작업을 꼭 수행해야 할 때는 어떻게 해야 할까요? 그 작업을 다른 스레드로 옮겨야 합니다. 이때 tokio가 비동기 태스크를 실행하는 데 사용하는 소위 ’런타임 스레드’는 건드리지 않는 것이 좋습니다.
대신 tokio는 이런 목적을 위해 **차단 풀(Blocking pool)**이라는 전용 스레드 풀을 제공합니다.
tokio::task::spawn_blocking 함수를 사용하여 차단 풀에서 동기 작업을 실행할 수 있습니다. spawn_blocking은 작업이 완료되었을 때 그 결과를 담은 퓨처를 반환합니다.
use tokio::task;
fn expensive_computation() -> u64 {
// [...]
}
async fn run() {
// 무거운 작업을 차단 풀로 보냅니다.
let handle = task::spawn_blocking(expensive_computation);
// 그동안 비동기 런타임에서는 다른 작업을 수행할 수 있습니다.
let result = handle.await.unwrap();
}
차단 풀은 스레드를 재사용하도록 설계되었습니다. 따라서 매번 std::thread::spawn으로 새 스레드를 만드는 것보다 효율적입니다. 스레드 생성 비용을 여러 번의 호출에 걸쳐 분담하기 때문입니다.
더 읽어보기
- 이 주제에 대해 더 자세히 알고 싶다면 Alice Ryhl의 블로그 포스트를 참고해 보세요.
Exercise
The exercise for this section is located in 08_futures/05_blocking
비동기 인식 프리미티브 (Async-aware Primitives)
tokio 문서를 살펴보면 표준 라이브러리의 타입들과 이름은 같지만 비동기적인 특성을 가진 타입들을 많이 발견할 수 있습니다. 예를 들어 잠금(Locks), 채널(Channels), 타이머(Timers) 등이 있죠.
비동기 컨텍스트에서 작업할 때는 동기 방식의 대응물보다 이런 비동기 대안들을 우선적으로 사용해야 합니다.
왜 그런지 이해하기 위해, 이전 챕터에서 다뤘던 상호 배제 잠금인 Mutex를 예로 들어보겠습니다.
사례 연구: Mutex
간단한 예제 코드를 보겠습니다:
use std::sync::{Arc, Mutex};
async fn run(m: Arc<Mutex<Vec<u64>>>) {
let guard = m.lock().unwrap();
http_call(&guard).await;
println!("Sent {:?} to the server", &guard);
// `guard`는 여기서 드롭(Drop)됩니다.
}
/// `v`를 HTTP 호출의 본문으로 사용합니다.
async fn http_call(v: &[u64]) {
// [...]
}
std::sync::MutexGuard와 양보 지점
이 코드는 컴파일은 되지만 매우 위험합니다.
비동기 컨텍스트 내에서 std 라이브러리의 Mutex 잠금을 획득하고 있습니다. 그리고 그 결과물인 MutexGuard를 유지한 채로 양보 지점(http_call의 .await)을 넘어갑니다.
단일 스레드 런타임에서 두 개의 태스크가 동시에 run을 실행한다고 가정해 봅시다. 스케줄링 순서는 다음과 같을 수 있습니다:
태스크 A 태스크 B
|
잠금 획득
런타임에 양보
|
+--------------+
|
잠금 획득 시도 (차단됨!)
여기서 **교착 상태(Deadlock)**가 발생합니다. 태스크 B는 잠금을 얻으려 하지만, 이미 태스크 A가 잠금을 쥐고 있는 상태입니다. 태스크 A는 잠금을 해제하기 전 런타임에 제어권을 양보했는데, 런타임은 태스크 B를 강제로 중단(선점)할 수 없으므로 태스크 A가 다시 실행될 기회를 얻지 못하게 됩니다.
tokio::sync::Mutex
이 문제는 tokio::sync::Mutex로 바꾸면 깔끔하게 해결됩니다:
use std::sync::Arc;
use tokio::sync::Mutex;
async fn run(m: Arc<Mutex<Vec<u64>>>) {
let guard = m.lock().await;
http_call(&guard).await;
println!("Sent {:?} to the server", &guard);
// `guard`는 여기서 드롭됩니다.
}
이제 잠금을 획득하는 과정 자체가 비동기 작업이 됩니다. 만약 잠금을 즉시 얻을 수 없다면 런타임에 제어권을 양보하죠. 다시 이전 시나리오를 적용해 보면 다음과 같이 동작합니다:
태스크 A 태스크 B
|
잠금 획득
`http_call` 시작
런타임에 양보
|
+--------------+
|
잠금 획득 시도
(획득 불가 -> 런타임에 양보)
|
+--------------+
|
`http_call` 완료
잠금 해제
런타임에 양보
|
+--------------+
|
잠금 획득 성공!
[...]
모든 것이 정상적으로 동작하네요!
다중 스레드라도 안전하지 않습니다
위 예제에서는 단일 스레드 런타임을 가정했지만, 다중 스레드 런타임에서도 위험은 여전합니다. 차이가 있다면 교착 상태를 일으키는 데 필요한 태스크의 수입니다. 단일 스레드에서는 2개면 충분하지만, 다중 스레드에서는 실행 스레드 수 N에 대해 N+1개의 태스크가 필요할 뿐입니다.
트레이드오프
비동기 인식 Mutex를 사용하는 것은 약간의 성능 오버헤드가 따릅니다. 만약 잠금 경합이 거의 발생하지 않고, .await 지점을 넘어서 잠금을 유지하지 않는다는 확신이 있다면 비동기 컨텍스트에서도 std::sync::Mutex를 계속 사용할 수 있습니다.
하지만 얻을 수 있는 성능 이점이 잠재적인 교착 상태의 위험보다 큰지 항상 신중하게 고민해야 합니다.
다른 프리미티브들
Mutex 외에도 RwLock, 세마포어(Semaphore) 등 다른 도구들에도 동일한 원리가 적용됩니다. 문제를 미연에 방지하려면 비동기 컨텍스트에서 작업할 때는 언제나 비동기 인식 버전을 우선적으로 고려하세요.
Exercise
The exercise for this section is located in 08_futures/06_async_aware_primitives
취소 (Cancellation)
대기 중이던 퓨처가 드롭(Drop)되면 어떻게 될까요? 런타임은 더 이상 그 퓨처를 폴링하지 않게 되고, 따라서 작업도 더 이상 진행되지 않습니다. 즉, 실행이 **취소(Cancellation)**된 것입니다.
실제로 이런 일은 타임아웃(Timeout) 기능을 사용할 때 자주 일어납니다. 예를 들어 보겠습니다:
use tokio::time::timeout;
use tokio::sync::oneshot;
use std::time::Duration;
async fn http_call() {
// [...]
}
async fn run() {
// 퓨처를 10밀리초 후에 만료되도록 설정된 `timeout`으로 감쌉니다.
let duration = Duration::from_millis(10);
if let Err(_) = timeout(duration, http_call()).await {
println!("10ms 이내에 응답을 받지 못했습니다.");
}
}
타임아웃 시간이 지나면 http_call이 반환한 퓨처는 취소됩니다. 만약 http_call의 본문이 다음과 같다고 상상해 봅시다:
use std::net::TcpStream;
async fn http_call() {
let (stream, _) = TcpStream::connect(/* */).await.unwrap();
let request: Vec<u8> = /* */;
stream.write_all(&request).await.unwrap();
}
비동기 함수 내의 모든 양보 지점(Yield point)은 곧 **취소 지점(Cancellation point)**이 됩니다.
http_call은 런타임에 의해 강제로 중단될 수 없으므로, 오직 .await를 통해 실행기에게 제어권을 넘긴 시점에서만 폐기될 수 있습니다. 이 원리는 재귀적으로 적용됩니다. 예를 들어 stream.write_all(&request) 내부에도 여러 양보 지점이 있을 가능성이 높죠. 따라서 http_call이 취소될 때, 요청 본문 중 일부만 전송된 채로 연결이 끊어져 버릴 수도 있다는 점을 유의해야 합니다.
정리 작업
Rust의 취소 메커니즘은 매우 강력합니다. 호출자가 태스크의 협력 없이도 진행 중인 작업을 중단시킬 수 있게 해주니까요. 하지만 동시에 매우 위험할 수도 있습니다. 작업을 멈추기 전에 뒷정리를 해야 하는 **우아한 취소(Graceful cancellation)**가 필요할 때가 있기 때문입니다.
예를 들어 SQL 트랜잭션을 처리하는 가상의 API를 생각해 봅시다:
async fn transfer_money(
connection: SqlConnection,
payer_id: u64,
payee_id: u64,
amount: u64
) -> Result<(), anyhow::Error> {
let transaction = connection.begin_transaction().await?;
update_balance(payer_id, amount, &transaction).await?;
decrease_balance(payee_id, amount, &transaction).await?;
transaction.commit().await?;
}
만약 중간에 취소된다면, 보류 중인 트랜잭션을 그대로 두는 대신 명시적으로 롤백(Aborting)하는 것이 이상적일 것입니다. 안타깝게도 Rust는 현재 이러한 비동기 정리 작업을 위한 완벽한 메커니즘을 제공하지 않습니다.
가장 흔한 전략은 Drop 트레이트를 활용해 정리 작업을 예약하는 것입니다. 예를 들어 다음과 같은 방법을 씁니다:
- 런타임에 새로운 태스크를 스폰하여 정리 수행
- 채널을 통해 정리 메시지 전송
- 백그라운드 스레드 활용
상황에 맞는 최선의 선택을 해야 합니다.
스폰된 태스크의 취소
tokio::spawn으로 생성된 태스크는 드롭한다고 해서 취소되지 않습니다. 이제 런타임의 소유가 되었기 때문이죠. 그럼에도 불구하고 필요하다면 JoinHandle을 사용해 강제로 취소할 수 있습니다:
async fn run() {
let handle = tokio::spawn(/* 어떤 비동기 태스크 */);
// 스폰된 태스크를 취소합니다.
handle.abort();
}
더 읽어보기
tokio의select!매크로를 사용해 두 퓨처를 경쟁(Race)시킬 때는 극도로 주의해야 합니다. **취소 안전성(Cancellation safety)**이 보장되지 않는다면 루프 내에서 동일한 태스크를 재시도하는 것이 위험할 수 있습니다. 자세한 내용은select!공식 문서를 참고하세요. 두 비동기 데이터 스트림(예: 소켓과 채널)을 하나로 합쳐야 한다면 대신StreamExt::merge를 사용하는 것이 좋습니다.- 어떤 경우에는
JoinHandle::abort보다CancellationToken을 사용하는 것이 더 나은 대안이 될 수 있습니다.
Exercise
The exercise for this section is located in 08_futures/07_cancellation
마무리
Rust의 비동기 모델은 매우 강력하지만, 그만큼 복잡하기도 합니다. 도구의 특성을 잘 파악하는 데 시간을 투자하세요. 특히 tokio 문서를 깊이 읽어보고 제공되는 프리미티브들에 익숙해진다면 비동기 프로그래밍의 잠재력을 최대한 끌어낼 수 있을 것입니다.
또한 Rust의 비동기 관련 기능들은 현재도 언어 차원과 표준 라이브러리(std) 수준에서 “완성“을 향해 계속 다듬어지고 있는 과정에 있다는 점을 기억해 두세요. 이 과정에서 가끔은 매끄럽지 않은 부분을 마주할 수도 있습니다.
즐겁고 고통 없는 비동기 프로그래밍을 위한 몇 가지 권장 사항을 전해드립니다:
- 런타임을 하나 정했다면 일관성 있게 사용하세요. 타이머나 I/O 같은 일부 프리미티브들은 런타임 간에 서로 호환되지 않습니다. 여러 런타임을 섞어서 쓰려고 하면 예상치 못한 문제로 고생할 확률이 높습니다. 런타임에 구애받지 않는(Runtime-agnostic) 코드를 짜는 것은 매우 복잡한 작업이므로, 꼭 필요한 경우가 아니라면 피하는 것이 좋습니다.
- 아직 안정화된
Stream/AsyncIterator인터페이스가 없습니다.AsyncIterator는 개념적으로 새로운 항목을 비동기적으로 생성해 내는 반복자입니다. 현재 설계가 진행 중이지만 아직 표준으로 확정된 바는 없습니다.tokio를 사용 중이라면tokio_stream을 기본 인터페이스로 활용하세요. - 버퍼링(Buffering)에 주의하세요. 버퍼링은 때때로 미묘한 버그의 원인이 됩니다. 관심이 있다면 “Barbara battles buffered streams” 문서를 읽어보시기 바랍니다.
- 비동기 태스크에는 아직 ’스코프 스레드(Scoped threads)’와 같은 기능이 없습니다. 이 문제에 대한 더 깊은 내용은 “The scoped task trilemma” 포스트를 참고하세요.
이런 주의 사항들 때문에 너무 겁먹을 필요는 없습니다. 비동기 Rust는 이미 AWS나 Meta와 같은 대형 기업에서 대규모 인프라 서비스를 구축하는 데 매우 효과적으로 사용되고 있습니다. 만약 여러분이 Rust로 네트워크 애플리케이션을 만들 계획이라면, 비동기 프로그래밍을 마스터하는 것은 분명 가치 있는 도전이 될 것입니다.
Exercise
The exercise for this section is located in 08_futures/08_outro
에필로그
Rust를 배우기 위한 긴 여정이 여기서 마무리됩니다. 지금까지 꽤 많은 내용을 다루었지만, 그렇다고 Rust의 모든 것을 살펴본 것은 아닙니다. Rust는 정말 방대한 기능과 거대한 생태계를 가진 매력적인 언어이니까요!
하지만 너무 겁먹을 필요는 없습니다. 모든 것을 한꺼번에 다 배울 필요는 없거든요. 백엔드, 임베디드, CLI, GUI 등 여러분이 관심 있는 분야의 프로젝트를 직접 진행해 보면서 필요한 지식을 하나씩 채워나가면 됩니다.
결국, 실력을 쌓는 데 지름길은 없습니다. 무언가에 익숙해지고 싶다면 꾸준히 반복하는 것이 가장 중요합니다. 이 과정을 거치며 여러분은 수많은 Rust 코드를 작성해 보았고, 이제는 문법이 손에 어느 정도 익었을 것입니다. Rust가 정말 “내 언어“처럼 느껴지려면 앞으로 더 많은 코드를 짜봐야 하겠지만, 포기하지 않고 연습한다면 머지않아 그 순간이 반드시 올 것입니다.
더 나아가기
Rust와 함께하는 여정에 도움이 될 만한 추가 자료들을 몇 가지 소개해 드리며 마치겠습니다.
연습 문제
rustlings: 작은 연습 문제들을 통해 Rust의 특징을 익힐 수 있는 훌륭한 프로젝트입니다.- Exercism Rust Track: 다양한 난이도의 문제를 풀며 멘토링도 받을 수 있습니다.
입문 자료
이 과정에서 배운 개념들을 다른 시각에서 복습해 보고 싶다면 다음 자료를 추천합니다.
- Rust 프로그래밍 언어(The Rust Programming Language): 일명 ’The Book’이라 불리는 공식 가이드입니다. (한국어 번역본도 있습니다.)
- “Programming Rust”: 언어의 내부 원리까지 깊이 있게 다루는 명서입니다.
고급 자료
언어를 더 깊게 파고들고 싶다면 다음 자료들이 도움이 될 것입니다.
- Rustonomicon: ’어둠의 마법’이라 불리는 Unsafe Rust와 그 이면을 다룹니다.
- “Rust for Rustaceans”: 중급 이상의 개발자가 Rust 전문가로 거듭나기 위해 읽어야 할 책입니다.
- “Decrusted” 시리즈: 유명한 Rust 라이브러리의 내부 구현을 낱낱이 파헤치는 영상 강의입니다.
분야별 자료
특정 도메인에 관심이 있다면 다음 자료를 참고해 보세요.
- 백엔드: “Zero to Production in Rust” (본 과정의 저자 Luca Palmieri의 저서)
- 임베디드: Embedded Rust 책
마스터클래스
핵심 주제에 대한 심화 학습이 필요하다면 다음 워크숍 자료를 확인해 보세요.