러스트 프로그래밍 언어

Steve Klabnik, Carol Nichols, Chris Krycho 저, 러스트 커뮤니티 공헌

이 문서는 여러분이 러스트 1.85.0(2025년 2월 17일 출시) 이상을 사용하며, 모든 프로젝트의 Cargo.toml 파일에 edition = "2024"를 설정해 러스트 2024 에디션의 관용구를 사용하도록 구성했다고 가정한다. 러스트를 설치하거나 업데이트하려면 1장의 “설치” 섹션을 참고한다.

HTML 형식의 문서는 온라인에서 https://doc.rust-lang.org/stable/book/로 확인할 수 있다. 또한 rustup으로 설치한 경우 오프라인에서도 이용 가능하며, rustup doc --book 명령어를 실행해 열어볼 수 있다.

다양한 커뮤니티 번역본도 제공된다.

이 문서는 No Starch Press에서 페이퍼백과 전자책 형식으로도 구매할 수 있다.

🚨 더 인터랙티브한 학습 경험을 원한다면? 퀴즈, 하이라이트, 시각화 등이 포함된 다른 버전의 러스트 책을 시도해 보세요: https://rust-book.cs.brown.edu

서문

항상 명확하진 않았지만, Rust 프로그래밍 언어는 근본적으로 능력 부여에 관한 것이다. 현재 어떤 종류의 코드를 작성하고 있든, Rust는 여러분이 더 넓은 영역에서 자신 있게 프로그래밍할 수 있도록 돕는다.

예를 들어, 메모리 관리, 데이터 표현, 동시성과 같은 저수준 세부 사항을 다루는 “시스템 수준” 작업을 생각해 보자. 전통적으로 이 영역의 프로그래밍은 오랜 시간을 들여 학습해야만 접근할 수 있는 신비로운 분야로 여겨졌다. 심지어 이 분야를 다루는 사람들도 조심스럽게 작업하며, 코드가 악용되거나 충돌, 손상되지 않도록 주의했다.

Rust는 이러한 장벽을 허물고, 오래된 함정을 없애며, 친절하고 세련된 도구들을 제공하여 여러분을 돕는다. 저수준 제어가 필요한 프로그래머들은 Rust를 사용해 충돌이나 보안 취약점의 전통적인 위험 없이 작업할 수 있다. 또한 까다로운 도구 체인의 세부 사항을 배울 필요도 없다. 더 나아가, Rust는 속도와 메모리 사용 측면에서 효율적인 신뢰할 수 있는 코드를 자연스럽게 작성하도록 안내한다.

이미 저수준 코드를 다루는 프로그래머들은 Rust를 통해 더 큰 목표를 달성할 수 있다. 예를 들어, Rust에서 병렬성을 도입하는 것은 상대적으로 낮은 위험으로 가능하다. 컴파일러가 고전적인 실수를 잡아주기 때문이다. 또한, 코드에서 더 공격적인 최적화를 시도할 때도 충돌이나 취약점을 실수로 도입하지 않을 것이라는 확신을 가지고 작업할 수 있다.

하지만 Rust는 저수준 시스템 프로그래밍에만 국한되지 않는다. Rust는 표현력이 뛰어나고, 사용하기 편리하여 CLI 앱, 웹 서버, 그리고 다양한 종류의 코드를 작성하는 데 적합하다. 이 책의 뒷부분에서 간단한 예제를 확인할 수 있다. Rust를 사용하면 한 영역에서 다른 영역으로 전이할 수 있는 기술을 쌓을 수 있다. 웹 앱을 작성하면서 Rust를 배우고, 그 동일한 기술을 라즈베리 파이를 대상으로 적용할 수도 있다.

이 책은 Rust가 사용자에게 부여할 수 있는 잠재력을 온전히 담아냈다. Rust에 대한 지식을 높이는 것은 물론, 프로그래머로서의 능력과 자신감을 키우는 데 도움을 주는 친근하고 접근하기 쉬운 내용으로 구성되었다. 이제 시작해 보자. 배울 준비를 하고, Rust 커뮤니티에 오신 것을 환영한다!

— Nicholas Matsakis와 Aaron Turon

시작하며

참고: 이 책의 내용은 No Starch Press에서 출판된 The Rust Programming Language의 인쇄본 및 전자책과 동일합니다.

The Rust Programming Language 에 오신 것을 환영합니다. 이 책은 Rust 프로그래밍 언어를 소개하는 입문서입니다. Rust는 더 빠르고 안정적인 소프트웨어를 작성하는 데 도움을 줍니다. 프로그래밍 언어 설계에서 고수준의 편의성과 저수준의 제어는 종종 상충 관계에 있습니다. Rust는 이러한 갈등에 도전합니다. 강력한 기술적 역량과 뛰어난 개발자 경험을 균형 있게 조화시킴으로써, Rust는 전통적으로 저수준 제어와 함께 따라오던 번거로움 없이도 메모리 사용과 같은 세부 사항을 제어할 수 있는 선택지를 제공합니다.

Rust의 적합한 사용자

Rust는 다양한 이유로 많은 사람들에게 이상적인 프로그래밍 언어다. 주요 사용자 그룹을 살펴보자.

개발자 팀 협업

Rust는 다양한 수준의 시스템 프로그래밍 지식을 가진 대규모 개발자 팀 간의 협업에 효과적인 도구로 입증되고 있다. 저수준 코드는 종종 다양한 미묘한 버그에 취약한데, 대부분의 다른 언어에서는 이러한 버그를 잡기 위해 광범위한 테스트와 경험 많은 개발자들의 꼼꼼한 코드 리뷰가 필요하다. Rust에서는 컴파일러가 이러한 잡기 어려운 버그, 특히 동시성 버그를 포함한 코드의 컴파일을 거부함으로써 ‘게이트키퍼’ 역할을 한다. 컴파일러와 함께 작업하면서 팀은 버그를 추적하는 대신 프로그램의 논리에 집중할 수 있다.

Rust는 또한 시스템 프로그래밍 세계에 현대적인 개발자 도구를 제공한다:

  • Cargo: Rust에 포함된 의존성 관리자 및 빌드 도구로, Rust 생태계 전반에서 의존성을 추가하고, 컴파일하고, 관리하는 과정을 쉽고 일관되게 만든다.
  • Rustfmt: 코드 포맷팅 도구로, 개발자들 간에 일관된 코딩 스타일을 유지하도록 돕는다.
  • rust-analyzer: 통합 개발 환경(IDE)과의 연동을 지원하여 코드 완성 기능과 인라인 오류 메시지를 제공한다.

이러한 Rust 생태계의 도구들을 활용하면, 개발자들은 시스템 수준의 코드를 작성하면서도 생산성을 유지할 수 있다.

학생들을 위한 Rust

Rust는 시스템 개념을 배우고자 하는 학생들과 관심 있는 이들에게 적합한 언어이다. Rust를 통해 많은 사람들이 운영체제 개발과 같은 주제를 배울 수 있다. Rust 커뮤니티는 매우 환영하는 분위기이며, 학생들의 질문에 기꺼이 답변해 준다. 이 책과 같은 노력을 통해 Rust 팀은 프로그래밍을 처음 접하는 사람들을 포함해 더 많은 이들이 시스템 개념을 쉽게 이해할 수 있도록 돕고자 한다.

기업들

수백 개의 대기업과 중소기업이 다양한 작업에 Rust를 사용하고 있다. 커맨드라인 도구부터 웹 서비스, DevOps 도구, 임베디드 장치, 오디오 및 비디오 분석과 변환, 암호화폐, 생물정보학, 검색 엔진, 사물인터넷(IoT) 애플리케이션, 머신러닝, 그리고 Firefox 웹 브라우저의 주요 부분까지 광범위한 분야에서 Rust가 활용되고 있다.

오픈소스 개발자를 위한 Rust

Rust는 Rust 프로그래밍 언어, 커뮤니티, 개발 도구, 그리고 라이브러리를 구축하고자 하는 사람들을 위한 언어이다. 여러분이 Rust 언어에 기여하는 것을 환영한다.

속도와 안정성을 중시하는 사람들을 위한 Rust

Rust는 속도와 안정성을 동시에 추구하는 사람들을 위한 프로그래밍 언어다. 여기서 속도란 Rust 코드가 실행되는 속도뿐만 아니라, Rust로 프로그램을 작성하는 속도까지 포함한다. Rust 컴파일러의 검사 기능은 새로운 기능 추가와 리팩토링 과정에서 안정성을 보장한다. 이는 검사 기능이 없는 언어에서 나온 취약한 레거시 코드와 대조적이다. 개발자들은 종종 이런 코드를 수정하기를 꺼린다. Rust는 제로 코스트 추상화를 지향한다. 이는 고수준 기능이 수작업으로 작성한 저수준 코드만큼 빠르게 컴파일된다는 의미다. Rust는 안전한 코드가 동시에 빠른 코드가 되도록 노력한다.

Rust는 이 외에도 다양한 사용자층을 지원하기를 원한다. 여기서 언급한 내용은 단지 가장 큰 이해관계자들 중 일부일 뿐이다. 전반적으로 Rust의 가장 큰 야심은 프로그래머들이 수십 년 동안 받아들여온 타협을 없애는 것이다. 안전성과 생산성, 속도와 사용성을 동시에 제공함으로써 이를 실현하려 한다. Rust를 직접 사용해 보고, 그 선택이 여러분에게 적합한지 확인해 보길 바란다.

이 책의 대상 독자

이 책은 여러분이 이미 다른 프로그래밍 언어로 코드를 작성해 본 경험이 있다고 가정한다. 하지만 특정 언어에 대한 사전 지식은 요구하지 않는다. 다양한 프로그래밍 배경을 가진 독자들이 쉽게 이해할 수 있도록 내용을 구성했다. 프로그래밍이 무엇인지, 혹은 프로그래밍을 어떻게 생각해야 하는지에 대해서는 깊이 다루지 않는다. 프로그래밍을 처음 접하는 독자라면, 프로그래밍 입문서를 먼저 읽는 것이 더 도움이 될 것이다.

이 책의 활용 방법

이 책은 기본적으로 앞에서부터 순서대로 읽는 것을 전제로 구성했다. 뒤에 나오는 장들은 앞선 장에서 다룬 개념을 바탕으로 설명을 이어가며, 앞선 장에서는 특정 주제를 자세히 다루지 않고 뒤에서 다시 다루는 경우가 있다.

이 책은 크게 두 가지 유형의 장으로 구성된다. 개념을 설명하는 장과 프로젝트를 진행하는 장이다. 개념 장에서는 Rust의 특정 측면에 대해 배우고, 프로젝트 장에서는 지금까지 배운 내용을 적용해 작은 프로그램을 함께 만들어본다. 2장, 12장, 21장이 프로젝트 장이며, 나머지는 개념 장이다.

1장에서는 Rust를 설치하는 방법, “Hello, world!” 프로그램을 작성하는 방법, 그리고 Rust의 패키지 관리자이자 빌드 도구인 Cargo를 사용하는 방법을 설명한다. 2장은 Rust로 프로그램을 작성하는 실습을 통해 숫자 맞추기 게임을 만들어보는 과정을 안내한다. 여기서는 개념을 높은 수준에서 다루며, 자세한 내용은 뒤에서 설명한다. 바로 실습을 시작하고 싶다면 2장부터 읽어도 된다. 3장은 다른 프로그래밍 언어와 유사한 Rust의 기능을 다루고, 4장에서는 Rust의 소유권 시스템에 대해 배운다. 모든 세부 사항을 먼저 배우고 싶은 꼼꼼한 학습자라면 2장을 건너뛰고 3장부터 시작한 후, 배운 내용을 적용해보고 싶을 때 2장으로 돌아오는 것도 좋다.

5장에서는 구조체와 메서드를 다루고, 6장에서는 열거형, match 표현식, if let 제어 흐름 구조를 배운다. 구조체와 열거형을 사용해 Rust에서 커스텀 타입을 만드는 방법을 익힌다.

7장에서는 Rust의 모듈 시스템과 코드를 조직화하고 공개 API를 정의할 때 사용하는 접근 제어 규칙에 대해 배운다. 8장은 표준 라이브러리가 제공하는 벡터, 문자열, 해시 맵과 같은 일반적인 컬렉션 데이터 구조를 다룬다. 9장에서는 Rust의 오류 처리 철학과 기법을 탐구한다.

10장에서는 여러 타입에 적용할 수 있는 코드를 정의할 수 있게 해주는 제네릭, 트레이트, 라이프타임에 대해 깊이 있게 다룬다. 11장은 테스트에 관한 내용으로, Rust의 안전성 보장에도 불구하고 프로그램의 논리가 올바른지 확인하는 데 필수적이다. 12장에서는 파일 내에서 텍스트를 검색하는 grep 커맨드라인 도구의 기능 일부를 직접 구현해보며, 앞서 배운 여러 개념을 적용한다.

13장에서는 함수형 프로그래밍 언어에서 유래한 Rust의 클로저와 이터레이터를 탐구한다. 14장에서는 Cargo를 더 깊이 알아보고, 라이브러리를 다른 사람과 공유할 때의 모범 사례를 논의한다. 15장은 표준 라이브러리가 제공하는 스마트 포인터와 그 기능을 가능하게 하는 트레이트에 대해 다룬다.

16장에서는 다양한 동시성 프로그래밍 모델을 살펴보고, Rust가 어떻게 여러 스레드에서 안전하게 프로그래밍할 수 있도록 도와주는지 설명한다. 17장에서는 Rust의 async와 await 문법, 그리고 이를 통해 가능해지는 경량 동시성 모델인 태스크, 퓨처, 스트림에 대해 더 깊이 탐구한다.

18장에서는 Rust의 관용구를 객체 지향 프로그래밍 원칙과 비교해본다. 19장은 패턴과 패턴 매칭에 대한 참고 자료로, Rust 프로그램 전반에 걸쳐 아이디어를 표현하는 강력한 방법을 제공한다. 20장은 unsafe Rust, 매크로, 라이프타임, 트레이트, 타입, 함수, 클로저 등 다양한 고급 주제를 다룬다.

21장에서는 저수준 멀티스레드 웹 서버를 구현하는 프로젝트를 완성한다.

마지막으로, 부록에서는 언어에 대한 유용한 정보를 참고서 형식으로 제공한다. 부록 A는 Rust의 키워드를, 부록 B는 Rust의 연산자와 기호를, 부록 C는 표준 라이브러리가 제공하는 파생 가능한 트레이트를, 부록 D는 유용한 개발 도구를, 부록 E는 Rust 에디션에 대해 설명한다. 부록 F에서는 이 책의 번역본을 찾을 수 있으며, 부록 G에서는 Rust가 어떻게 만들어지는지와 nightly Rust에 대해 다룬다.

이 책을 읽는 데 정해진 방법은 없다. 원하는 장으로 건너뛰어도 괜찮다. 혼란이 생기면 앞선 장으로 돌아가면 된다. 자신에게 맞는 방식으로 읽어나가면 된다.

Rust를 배우는 과정에서 중요한 부분 중 하나는 컴파일러가 표시하는 오류 메시지를 읽는 법을 익히는 것이다. 이 메시지들은 작동하는 코드로 안내해준다. 따라서 컴파일되지 않는 예제와 함께 컴파일러가 표시하는 오류 메시지를 많이 제공할 것이다. 무작위로 예제를 입력하고 실행했을 때 컴파일되지 않을 수 있으니, 주변 텍스트를 꼭 읽어보고 해당 예제가 오류를 내도록 의도된 것인지 확인해야 한다. Ferris도 코드가 작동하지 않을 때 이를 구분하는 데 도움을 줄 것이다:

Ferris의미
Ferris with a question mark이 코드는 컴파일되지 않는다!
Ferris throwing up their hands이 코드는 패닉을 일으킨다!
Ferris with one claw up, shrugging이 코드는 원하는 동작을 수행하지 않는다.

대부분의 경우, 컴파일되지 않는 코드의 올바른 버전으로 안내할 것이다.

소스 코드

이 책을 생성하는 데 사용된 소스 파일은 GitHub에서 확인할 수 있다.

시작하기

러스트 여정을 시작해 보자! 배울 내용이 많지만, 모든 여정은 어디선가 시작된다. 이 장에서는 다음과 같은 내용을 다룬다:

  • Linux, macOS, Windows에서 Rust 설치하기
  • Hello, world!를 출력하는 프로그램 작성하기
  • Rust의 패키지 관리자이자 빌드 시스템인 cargo 사용하기

이 장은 Rust를 처음 접하는 독자들을 위한 기본적인 안내를 제공한다. 각 운영체제별 설치 방법부터 간단한 프로그램 작성, 그리고 Rust 생태계의 핵심 도구인 cargo의 사용법까지 단계별로 설명한다. 이를 통해 Rust 개발 환경을 설정하고 첫 번째 프로젝트를 시작할 수 있다.

설치

첫 번째 단계는 Rust를 설치하는 것이다. Rust 버전과 관련 도구를 관리하는 커맨드라인 도구인 rustup을 통해 Rust를 다운로드한다. 다운로드를 위해 인터넷 연결이 필요하다.

참고: 특정 이유로 rustup을 사용하지 않으려면 다른 Rust 설치 방법 페이지에서 추가 옵션을 확인할 수 있다.

다음 단계는 Rust 컴파일러의 최신 안정 버전을 설치하는 것이다. Rust의 안정성 보장은 이 책의 예제가 새로운 Rust 버전에서도 계속 컴파일될 것임을 의미한다. Rust가 에러 메시지와 경고를 지속적으로 개선하기 때문에 출력 결과가 버전 간에 약간 다를 수 있다. 즉, 이 단계를 통해 설치한 최신 안정 버전의 Rust는 이 책의 내용과 호환될 것이다.

커맨드라인 표기법

이 장과 책 전체에서 터미널에서 사용하는 몇 가지 커맨드를 보여준다. 터미널에 입력해야 하는 줄은 모두 $로 시작한다. $ 문자를 입력할 필요는 없으며, 이는 각 커맨드의 시작을 나타내는 커맨드라인 프롬프트다. $로 시작하지 않는 줄은 일반적으로 이전 커맨드의 출력을 보여준다. 또한 PowerShell 전용 예제는 $ 대신 >를 사용한다.

Linux 또는 macOS에서 rustup 설치하기

Linux나 macOS를 사용 중이라면, 터미널을 열고 다음 커맨드를 입력한다:

$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

이 커맨드는 스크립트를 다운로드하고 rustup 도구의 설치를 시작한다. rustup은 Rust의 최신 안정 버전을 설치한다. 설치 과정에서 비밀번호를 입력하라는 메시지가 나타날 수 있다. 설치가 성공적으로 완료되면 다음과 같은 메시지가 표시된다:

Rust is installed now. Great!

또한 _링커_가 필요하다. 링커는 Rust가 컴파일된 출력물을 하나의 파일로 합치는 데 사용하는 프로그램이다. 대부분의 경우 이미 링커가 설치되어 있을 것이다. 만약 링커 관련 오류가 발생한다면, C 컴파일러를 설치해야 한다. C 컴파일러는 일반적으로 링커를 포함하고 있다. 또한, 일부 일반적인 Rust 패키지가 C 코드에 의존하고 있어 C 컴파일러가 필요할 수 있다.

macOS에서는 다음 커맨드를 실행하여 C 컴파일러를 설치할 수 있다:

$ xcode-select --install

Linux 사용자는 일반적으로 배포판의 문서에 따라 GCC나 Clang을 설치해야 한다. 예를 들어, Ubuntu를 사용한다면 build-essential 패키지를 설치하면 된다.

윈도우에 rustup 설치하기

윈도우에서 Rust를 설치하려면 https://www.rust-lang.org/tools/install로 이동해 설치 안내를 따르면 된다. 설치 과정 중 Visual Studio를 설치하라는 메시지가 나타난다. 이는 프로그램을 컴파일하는 데 필요한 링커와 네이티브 라이브러리를 제공한다. 이 단계에서 더 많은 도움이 필요하다면 https://rust-lang.github.io/rustup/installation/windows-msvc.html를 참고한다.

이 책의 나머지 부분에서는 _cmd.exe_와 PowerShell 모두에서 동작하는 명령어를 사용한다. 만약 특정 차이점이 있다면, 어떤 것을 사용해야 하는지 설명할 것이다.

문제 해결

Rust가 올바르게 설치되었는지 확인하려면 셸을 열고 다음 명령어를 입력한다:

$ rustc --version

최신 안정 버전의 버전 번호, 커밋 해시, 커밋 날짜가 다음과 같은 형식으로 표시된다:

rustc x.y.z (abcabcabc yyyy-mm-dd)

이 정보가 보이면 Rust가 성공적으로 설치된 것이다. 정보가 표시되지 않는다면, Rust가 %PATH% 시스템 변수에 제대로 추가되었는지 확인해야 한다.

Windows CMD에서는 다음 명령어를 사용한다:

> echo %PATH%

PowerShell에서는 다음 명령어를 사용한다:

> echo $env:Path

Linux와 macOS에서는 다음 명령어를 사용한다:

$ echo $PATH

모든 것이 정상인데도 Rust가 작동하지 않는다면, 여러 도움을 받을 수 있는 곳이 있다. 커뮤니티 페이지에서 다른 Rust 사용자(우리가 스스로를 부르는 재미있는 별명)와 연락하는 방법을 찾아볼 수 있다.

업데이트 및 제거

rustup을 통해 Rust를 설치했다면, 새 버전으로 업데이트하는 것은 간단하다. 커맨드라인에서 다음 업데이트 스크립트를 실행하면 된다:

$ rustup update

Rust와 rustup을 제거하려면 커맨드라인에서 다음 제거 스크립트를 실행한다:

$ rustup self uninstall

로컬 문서

Rust를 설치하면 오프라인에서도 문서를 확인할 수 있도록 로컬에 문서 사본이 함께 설치된다. 브라우저에서 로컬 문서를 열려면 rustup doc 명령을 실행한다.

표준 라이브러리에서 제공하는 타입이나 함수의 기능이 궁금하거나 사용법을 모를 때는 API 문서를 참고하면 된다!

텍스트 편집기와 통합 개발 환경(IDE)

이 책은 여러분이 Rust 코드를 작성할 때 어떤 도구를 사용하는지에 대해 특별한 가정을 하지 않는다. 거의 모든 텍스트 편집기로도 충분히 작업을 수행할 수 있다! 하지만 많은 텍스트 편집기와 통합 개발 환경(IDE)은 Rust를 내장 지원한다. Rust 웹사이트의 도구 페이지에서 다양한 편집기와 IDE에 대한 최신 목록을 항상 확인할 수 있다.

오프라인에서 이 책 활용하기

이 책의 여러 예제에서는 표준 라이브러리 외에도 다양한 Rust 패키지를 사용한다. 이러한 예제를 따라 하려면 인터넷 연결이 필요하거나, 미리 해당 의존성을 다운로드해야 한다. 의존성을 미리 다운로드하려면 다음 명령어를 실행한다. (나중에 cargo가 무엇인지, 이 명령어들이 무엇을 하는지 자세히 설명할 것이다.)

$ cargo new get-dependencies
$ cd get-dependencies
$ cargo add rand@0.8.5 trpl@0.2.0

이 명령어를 실행하면 해당 패키지가 캐시에 저장되어 나중에 다시 다운로드할 필요가 없다. 이 명령어를 실행한 후에는 get-dependencies 폴더를 유지할 필요가 없다. 이 명령어를 실행했다면, 책의 나머지 부분에서 모든 cargo 명령어에 --offline 플래그를 사용해 네트워크를 사용하지 않고 캐시된 버전을 활용할 수 있다.

Hello, World!

이제 Rust를 설치했으니 첫 번째 Rust 프로그램을 작성해보자. 새로운 언어를 배울 때 화면에 Hello, world!라는 텍스트를 출력하는 작은 프로그램을 만드는 것은 전통적인 방식이다. 여기서도 그 전통을 따라가보자.

참고: 이 책은 커맨드라인에 대한 기본적인 이해를 전제로 한다. Rust는 코드를 작성하는 도구나 코드가 위치한 장소에 대해 특별한 요구사항을 두지 않는다. 따라서 커맨드라인 대신 통합 개발 환경(IDE)을 사용하고 싶다면, 여러분이 선호하는 IDE를 자유롭게 사용해도 된다. 많은 IDE들이 어느 정도 Rust를 지원하고 있으며, 자세한 내용은 IDE의 문서를 확인하면 된다. Rust 팀은 rust-analyzer를 통해 뛰어난 IDE 지원을 제공하는 데 주력하고 있다. 더 자세한 내용은 부록 D를 참고하자.

프로젝트 디렉토리 생성하기

Rust 코드를 저장할 디렉토리를 먼저 만든다. Rust는 코드가 어디에 있는지 상관하지 않지만, 이 책의 연습 문제와 프로젝트를 위해 홈 디렉토리 안에 projects 디렉토리를 만들고 모든 프로젝트를 그 안에 보관하는 것을 권장한다.

터미널을 열고 다음 명령어를 입력해 projects 디렉토리와 그 안에 “Hello, world!” 프로젝트를 위한 디렉토리를 생성한다.

Linux, macOS, 그리고 Windows의 PowerShell에서는 다음 명령어를 입력한다:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

Windows CMD에서는 다음 명령어를 입력한다:

> mkdir "%USERPROFILE%\projects"
> cd /d "%USERPROFILE%\projects"
> mkdir hello_world
> cd hello_world

Rust 프로그램 작성과 실행

다음으로, 새로운 소스 파일을 만들고 main.rs_라는 이름으로 저장한다. Rust 파일은 항상 .rs 확장자로 끝난다. 파일 이름에 여러 단어를 사용할 경우, 관례적으로 밑줄()로 단어를 구분한다. 예를 들어, helloworld.rs 대신 _hello_world.rs_를 사용한다.

이제 방금 만든 main.rs 파일을 열고, Listing 1-1의 코드를 입력한다.

Filename: main.rs
fn main() {
    println!("Hello, world!");
}
Listing 1-1: Hello, world!를 출력하는 프로그램

파일을 저장한 후, 터미널 창에서 ~/projects/hello_world 디렉토리로 이동한다. Linux나 macOS에서는 다음 명령어를 입력해 파일을 컴파일하고 실행한다:

$ rustc main.rs
$ ./main
Hello, world!

Windows에서는 ./main 대신 .\main.exe 명령어를 입력한다:

> rustc main.rs
> .\main
Hello, world!

운영체제와 상관없이, 터미널에 Hello, world! 문자열이 출력된다. 만약 이 결과가 보이지 않는다면, “문제 해결” 섹션을 참고해 도움을 받을 수 있다.

Hello, world!가 정상적으로 출력되었다면, 축하한다! 여러분은 공식적으로 Rust 프로그램을 작성한 것이다. 이제 여러분은 Rust 프로그래머가 된 것이다. 환영한다!

러스트 프로그램의 구조

“Hello, world!” 프로그램을 자세히 살펴보자. 먼저 다음 코드를 보자:

fn main() {

}

이 코드는 main이라는 함수를 정의한다. main 함수는 특별한데, 모든 실행 가능한 러스트 프로그램에서 가장 먼저 실행되는 코드다. 여기서 첫 번째 줄은 매개변수가 없고 반환 값도 없는 main 함수를 선언한다. 만약 매개변수가 있다면, 괄호 () 안에 들어갈 것이다.

함수 본문은 {}로 감싸져 있다. 러스트는 모든 함수 본문을 중괄호로 감싸도록 요구한다. 함수 선언과 여는 중괄호를 같은 줄에 두고, 사이에 한 칸을 띄우는 것이 좋은 스타일이다.

참고: 여러분이 러스트 프로젝트에서 표준 스타일을 유지하고 싶다면, rustfmt라는 자동 포맷터 도구를 사용할 수 있다. 이 도구는 코드를 특정 스타일로 포맷한다 (자세한 내용은 부록 D에서 확인할 수 있다). 러스트 팀은 이 도구를 표준 러스트 배포판에 포함시켰으므로, rustc와 마찬가지로 여러분의 컴퓨터에 이미 설치되어 있을 것이다.

main 함수의 본문에는 다음 코드가 들어 있다:

#![allow(unused)]
fn main() {
println!("Hello, world!");
}

이 한 줄이 이 작은 프로그램의 모든 작업을 수행한다: 화면에 텍스트를 출력한다. 여기서 주목할 세 가지 중요한 세부 사항이 있다.

첫째, println!은 러스트 매크로를 호출한다. 만약 함수를 호출했다면, println (느낌표 없이)으로 입력했을 것이다. 러스트 매크로에 대해서는 20장에서 더 자세히 다룰 것이다. 지금은 !를 사용하면 일반 함수가 아니라 매크로를 호출한다는 것과, 매크로가 항상 함수와 같은 규칙을 따르지는 않는다는 것만 알아두자.

둘째, "Hello, world!" 문자열이 보인다. 이 문자열을 println!에 인자로 전달하면, 화면에 문자열이 출력된다.

셋째, 줄 끝에 세미콜론(;)이 붙어 있다. 이는 이 표현식이 끝났고 다음 표현식이 시작될 준비가 되었다는 것을 나타낸다. 러스트 코드의 대부분은 세미콜론으로 끝난다.

컴파일과 실행은 별개의 단계

새로 만든 프로그램을 실행해봤으니, 이제 각 단계를 자세히 살펴보자.

Rust 프로그램을 실행하기 전에, Rust 컴파일러를 사용해 프로그램을 먼저 컴파일해야 한다. rustc 명령어를 입력하고 소스 파일 이름을 인자로 전달하면 된다. 예를 들어 다음과 같이 실행한다:

$ rustc main.rs

C나 C++에 익숙하다면, 이 과정이 gccclang과 비슷하다는 것을 알 수 있다. 컴파일이 성공적으로 완료되면, Rust는 바이너리 실행 파일을 생성한다.

Linux, macOS, 그리고 Windows의 PowerShell에서는 쉘에서 ls 명령어를 입력해 실행 파일을 확인할 수 있다:

$ ls
main  main.rs

Linux와 macOS에서는 두 개의 파일이 보인다. Windows의 PowerShell에서는 CMD를 사용할 때와 동일한 세 개의 파일이 나타난다. Windows의 CMD에서는 다음과 같이 입력한다:

> dir /B %= /B 옵션은 파일 이름만 보여주라는 의미 =%
main.exe
main.pdb
main.rs

이 명령어를 실행하면 .rs 확장자를 가진 소스 코드 파일, 실행 파일(Windows에서는 main.exe, 다른 플랫폼에서는 main), 그리고 Windows에서는 디버깅 정보를 포함한 .pdb 확장자의 파일이 보인다. 이제 main 또는 main.exe 파일을 실행할 수 있다. 다음과 같이 입력하면 된다:

$ ./main # Windows에서는 .\main.exe

만약 main.rs 파일이 “Hello, world!” 프로그램이라면, 이 명령어를 실행하면 터미널에 Hello, world!가 출력된다.

Ruby, Python, JavaScript와 같은 동적 언어에 익숙하다면, 컴파일과 실행을 별도의 단계로 나누는 것에 익숙하지 않을 수 있다. Rust는 사전 컴파일(ahead-of-time compiled) 언어로, 프로그램을 컴파일한 후 실행 파일을 다른 사람에게 전달하면, 그 사람은 Rust가 설치되어 있지 않아도 프로그램을 실행할 수 있다. 반면 .rb, .py, .js 파일을 전달하면, 각각 Ruby, Python, JavaScript 구현체가 설치되어 있어야 한다. 하지만 이 언어들은 단 하나의 명령어로 컴파일과 실행을 동시에 처리할 수 있다. 모든 언어 설계에는 트레이드오프가 존재한다.

rustc로 컴파일하는 것은 간단한 프로그램에는 적합하지만, 프로젝트가 커지면 모든 옵션을 관리하고 코드를 쉽게 공유할 수 있는 방법이 필요하다. 다음에는 실제 Rust 프로그램을 작성하는 데 도움이 되는 Cargo 도구를 소개한다.

Cargo, 안녕!

Cargo는 Rust의 빌드 시스템이자 패키지 관리자다. 대부분의 Rust 개발자는 이 도구를 사용해 Rust 프로젝트를 관리한다. Cargo는 코드를 빌드하고, 코드가 의존하는 라이브러리를 다운로드하며, 해당 라이브러리를 빌드하는 등 다양한 작업을 대신 처리해준다. (코드가 필요로 하는 라이브러리를 _의존성_이라고 부른다.)

지금까지 작성한 것처럼 가장 간단한 Rust 프로그램은 의존성이 없다. 만약 “Hello, world!” 프로젝트를 Cargo로 빌드했다면, Cargo의 코드 빌드 기능만 사용했을 것이다. 더 복잡한 Rust 프로그램을 작성하게 되면 의존성을 추가하게 되는데, Cargo로 프로젝트를 시작하면 의존성을 추가하는 작업이 훨씬 쉬워진다.

대부분의 Rust 프로젝트가 Cargo를 사용하기 때문에, 이 책의 나머지 부분도 여러분이 Cargo를 사용한다고 가정한다. 설치 섹션에서 설명한 공식 설치 프로그램을 사용했다면, Rust와 함께 Cargo도 설치되어 있을 것이다. 다른 방법으로 Rust를 설치했다면, 터미널에 다음 명령어를 입력해 Cargo가 설치되어 있는지 확인해보자.

$ cargo --version

버전 번호가 표시된다면 Cargo가 설치된 것이다. 만약 command not found 같은 오류가 발생한다면, 설치 방법에 대한 문서를 참고해 Cargo를 별도로 설치해야 한다.

Cargo를 사용해 프로젝트 생성하기

Cargo를 사용해 새로운 프로젝트를 생성하고, 이전에 만든 “Hello, world!” 프로젝트와 어떻게 다른지 살펴보자. 먼저 projects 디렉토리로 이동하거나 코드를 저장할 위치로 이동한다. 그런 다음, 운영체제에 상관없이 다음 명령어를 실행한다:

$ cargo new hello_cargo
$ cd hello_cargo

첫 번째 명령어는 _hello_cargo_라는 이름의 새 디렉토리와 프로젝트를 생성한다. 프로젝트 이름을 _hello_cargo_로 지정했으므로, Cargo는 동일한 이름의 디렉토리에 파일을 생성한다.

hello_cargo 디렉토리로 이동해 파일 목록을 확인해 보면, Cargo가 두 개의 파일과 하나의 디렉토리를 생성한 것을 볼 수 있다. Cargo.toml 파일과 src 디렉토리, 그리고 그 안에 있는 main.rs 파일이 생성된다.

또한 Cargo는 새로운 Git 저장소와 .gitignore 파일도 함께 초기화한다. 이미 Git 저장소 내에서 cargo new를 실행하면 Git 파일은 생성되지 않지만, cargo new --vcs=git 명령어를 사용해 이 동작을 재정의할 수 있다.

참고: Git은 일반적으로 사용되는 버전 관리 시스템이다. --vcs 플래그를 사용해 cargo new가 다른 버전 관리 시스템을 사용하도록 설정하거나 버전 관리를 사용하지 않도록 설정할 수 있다. cargo new --help를 실행해 사용 가능한 옵션을 확인할 수 있다.

선택한 텍스트 편집기로 Cargo.toml 파일을 열어보자. Listing 1-2의 코드와 비슷한 내용이 보일 것이다.

Filename: Cargo.toml
[package]
name = "hello_cargo"
version = "0.1.0"
edition = "2024"

[dependencies]
Listing 1-2: cargo new로 생성된 Cargo.toml 파일 내용

이 파일은 TOML (Tom’s Obvious, Minimal Language) 형식으로 작성되었으며, Cargo의 설정 파일 형식이다.

첫 번째 줄인 [package]는 이어지는 내용이 패키지 설정임을 나타내는 섹션 헤더다. 이 파일에 더 많은 정보를 추가할 때, 다른 섹션도 추가할 것이다.

다음 세 줄은 Cargo가 프로그램을 컴파일하는 데 필요한 설정 정보를 지정한다: 프로젝트 이름, 버전, 사용할 Rust 버전이다. edition 키에 대해서는 부록 E에서 다룰 것이다.

마지막 줄인 [dependencies]는 프로젝트의 의존성을 나열하는 섹션의 시작이다. Rust에서는 코드 패키지를 _크레이트(crate)_라고 부른다. 이 프로젝트에서는 다른 크레이트가 필요하지 않지만, 2장의 첫 번째 프로젝트에서는 필요하므로 그때 이 의존성 섹션을 사용할 것이다.

이제 src/main.rs 파일을 열어보자:

파일명: src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo가 Listing 1-1에서 작성한 것과 동일한 “Hello, world!” 프로그램을 생성했다! 지금까지 우리가 만든 프로젝트와 Cargo가 생성한 프로젝트의 차이점은 Cargo가 코드를 src 디렉토리에 배치하고 최상위 디렉토리에 Cargo.toml 설정 파일을 생성했다는 점이다.

Cargo는 소스 파일이 src 디렉토리에 위치할 것으로 기대한다. 최상위 프로젝트 디렉토리는 README 파일, 라이선스 정보, 설정 파일 등 코드와 직접 관련 없는 파일을 위한 공간이다. Cargo를 사용하면 프로젝트를 체계적으로 관리할 수 있다. 모든 것이 제자리에 있고, 각자 할 일을 분명히 할 수 있다.

Cargo를 사용하지 않고 프로젝트를 시작했다면, “Hello, world!” 프로젝트에서 그랬듯이 Cargo를 사용하는 프로젝트로 변환할 수 있다. 프로젝트 코드를 src 디렉토리로 이동하고 적절한 Cargo.toml 파일을 생성하면 된다. Cargo.toml 파일을 쉽게 생성하려면 cargo init 명령어를 실행하면 자동으로 파일이 생성된다.

Cargo 프로젝트 빌드와 실행

이제 Cargo를 사용해 “Hello, world!” 프로그램을 빌드하고 실행하는 과정을 살펴보자. hello_cargo 디렉터리에서 다음 명령어를 입력해 프로젝트를 빌드한다:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs

이 명령어는 현재 디렉터리가 아닌 target/debug/hello_cargo (또는 윈도우에서는 target\debug\hello_cargo.exe)에 실행 파일을 생성한다. 기본적으로 디버그 빌드를 수행하기 때문에, Cargo는 바이너리 파일을 _debug_라는 디렉터리에 저장한다. 이 실행 파일은 다음 명령어로 실행할 수 있다:

$ ./target/debug/hello_cargo # or .\target\debug\hello_cargo.exe on Windows
Hello, world!

모든 것이 정상적으로 진행되었다면, 터미널에 Hello, world!가 출력된다. cargo build를 처음 실행하면, Cargo는 최상위 디렉터리에 Cargo.lock 파일을 생성한다. 이 파일은 프로젝트의 의존성 버전을 정확히 기록한다. 현재 프로젝트에는 의존성이 없기 때문에 파일 내용이 간단하다. 이 파일은 수동으로 수정할 필요가 없으며, Cargo가 자동으로 관리한다.

지금까지 cargo build로 프로젝트를 빌드하고 ./target/debug/hello_cargo로 실행했지만, cargo run을 사용하면 코드를 컴파일한 후 실행 파일을 한 번에 실행할 수 있다:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/hello_cargo`
Hello, world!

cargo runcargo build를 실행한 후 바이너리 파일의 전체 경로를 기억할 필요 없이 편리하게 사용할 수 있기 때문에, 대부분의 개발자가 이 명령어를 선호한다.

이번에는 Cargo가 hello_cargo를 컴파일했다는 출력이 보이지 않는다. Cargo는 파일이 변경되지 않았다는 것을 감지하고, 다시 빌드하지 않고 바이너리 파일만 실행했다. 만약 소스 코드를 수정했다면, Cargo는 실행 전에 프로젝트를 다시 빌드하고 다음과 같은 출력을 보여줬을 것이다:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.33 secs
     Running `target/debug/hello_cargo`
Hello, world!

Cargo는 cargo check라는 명령어도 제공한다. 이 명령어는 코드가 컴파일되는지 빠르게 확인하지만, 실행 파일은 생성하지 않는다:

$ cargo check
   Checking hello_cargo v0.1.0 (file:///projects/hello_cargo)
    Finished dev [unoptimized + debuginfo] target(s) in 0.32 secs

왜 실행 파일을 생성하지 않을까? cargo check는 실행 파일을 생성하는 단계를 건너뛰기 때문에 cargo build보다 훨씬 빠르다. 코드를 작성하면서 지속적으로 작업을 확인할 때 cargo check를 사용하면 프로젝트가 여전히 컴파일되는지 빠르게 확인할 수 있다. 따라서 많은 Rust 개발자는 코드를 작성하면서 주기적으로 cargo check를 실행하고, 실행 파일을 사용할 준비가 되면 cargo build를 실행한다.

지금까지 Cargo에 대해 배운 내용을 정리해 보자:

  • cargo new로 프로젝트를 생성할 수 있다.
  • cargo build로 프로젝트를 빌드할 수 있다.
  • cargo run으로 한 번에 프로젝트를 빌드하고 실행할 수 있다.
  • cargo check로 바이너리 파일을 생성하지 않고 오류를 확인할 수 있다.
  • 빌드 결과를 코드와 같은 디렉터리에 저장하지 않고 target/debug 디렉터리에 저장한다.

Cargo를 사용하는 또 다른 장점은 운영체제에 상관없이 동일한 명령어를 사용할 수 있다는 것이다. 따라서 이제부터는 리눅스, macOS, 윈도우에 대한 별도의 지침을 제공하지 않는다.

릴리즈 빌드하기

프로젝트가 마침내 릴리즈 준비가 되면, cargo build --release 명령어를 사용해 최적화된 상태로 컴파일할 수 있다. 이 명령어는 target/debug 대신 target/release 디렉터리에 실행 파일을 생성한다. 최적화는 Rust 코드를 더 빠르게 실행할 수 있게 해주지만, 컴파일 시간이 길어지는 단점이 있다. 이 때문에 두 가지 프로파일이 존재한다: 하나는 빠르고 자주 다시 빌드해야 하는 개발 단계를 위한 것이고, 다른 하나는 반복적으로 다시 빌드할 필요가 없고 가능한 한 빠르게 실행되어야 하는 최종 프로그램을 위한 것이다. 코드의 실행 시간을 벤치마킹할 때는 반드시 cargo build --release를 실행하고 target/release 디렉터리의 실행 파일로 벤치마크를 진행해야 한다.

Cargo의 관례

간단한 프로젝트에서는 rustc를 직접 사용하는 것에 비해 Cargo가 제공하는 가치가 크지 않다. 하지만 프로그램이 복잡해지면 Cargo의 진가를 발휘한다. 프로그램이 여러 파일로 구성되거나 의존성이 필요한 경우, Cargo가 빌드를 조율하는 것이 훨씬 편리하다.

hello_cargo 프로젝트는 단순하지만, 앞으로 Rust 개발 과정에서 사용할 실제 도구의 대부분을 이미 활용하고 있다. 기존 프로젝트를 작업하려면 다음 명령어를 사용해 코드를 체크아웃하고, 프로젝트 디렉토리로 이동한 뒤 빌드할 수 있다.

$ git clone example.org/someproject
$ cd someproject
$ cargo build

Cargo에 대한 더 자세한 정보는 공식 문서를 참고한다.

요약

여러분은 이미 러스트 여정에서 좋은 시작을 했다! 이번 장에서 다음 내용을 배웠다:

  • rustup을 사용해 최신 안정 버전의 러스트를 설치하는 방법
  • 더 새로운 러스트 버전으로 업데이트하는 방법
  • 로컬에 설치된 문서를 여는 방법
  • rustc를 직접 사용해 “Hello, world!” 프로그램을 작성하고 실행하는 방법
  • Cargo의 규칙을 따라 새 프로젝트를 만들고 실행하는 방법

이제 러스트 코드를 읽고 쓰는 데 익숙해지기 위해 더 큰 프로그램을 만들어 볼 좋은 시기다. 따라서 2장에서는 숫자 맞추기 게임 프로그램을 만들어 볼 것이다. 만약 일반적인 프로그래밍 개념이 러스트에서 어떻게 동작하는지 먼저 배우고 싶다면 3장을 먼저 보고 2장으로 돌아오길 바란다.

숫자 맞추기 게임 프로그래밍

이제 실습 프로젝트를 통해 Rust 프로그래밍을 시작해보자! 이 장에서는 실제 프로그램을 작성하면서 Rust의 몇 가지 기본 개념을 소개한다. let, match, 메서드, 연관 함수, 외부 크레이트 등을 배우게 될 것이다. 이후 장에서 이 개념들을 더 깊이 있게 다룰 예정이지만, 이 장에서는 기본적인 사용법을 연습한다.

클래식한 초보자용 프로그래밍 문제인 숫자 맞추기 게임을 구현해볼 것이다. 게임의 규칙은 다음과 같다: 프로그램은 1부터 100 사이의 임의의 정수를 생성한다. 그런 다음 플레이어에게 숫자를 입력하라는 메시지를 표시한다. 플레이어가 숫자를 입력하면, 프로그램은 입력한 숫자가 너무 작은지, 너무 큰지, 아니면 정답인지를 알려준다. 정답을 맞추면 축하 메시지를 출력하고 게임을 종료한다.

새 프로젝트 설정하기

새 프로젝트를 설정하려면 1장에서 만든 projects 디렉터리로 이동한 후, Cargo를 사용해 새 프로젝트를 생성한다. 다음과 같이 입력하면 된다:

$ cargo new guessing_game
$ cd guessing_game

첫 번째 명령어인 cargo new는 프로젝트 이름(guessing_game)을 첫 번째 인자로 받는다. 두 번째 명령어는 새 프로젝트의 디렉터리로 이동한다.

생성된 Cargo.toml 파일을 확인해 보자:

Filename: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"

[dependencies]

1장에서 보았듯이, cargo new는 “Hello, world!” 프로그램을 자동으로 생성한다. src/main.rs 파일을 확인해 보자:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");
}

이제 이 “Hello, world!” 프로그램을 컴파일하고 실행해 보자. cargo run 명령어를 사용하면 한 번에 컴파일과 실행을 수행할 수 있다:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/guessing_game`
Hello, world!

run 명령어는 프로젝트를 빠르게 반복적으로 수정하고 테스트할 때 유용하다. 이 게임을 개발하면서 각 단계를 빠르게 테스트할 때 이 명령어를 활용할 것이다.

src/main.rs 파일을 다시 열어 보자. 이 파일에 모든 코드를 작성할 예정이다.

추측값 처리하기

추측 게임 프로그램의 첫 번째 부분은 사용자 입력을 받고, 그 입력을 처리하며, 입력이 예상된 형식인지 확인한다. 먼저 플레이어가 추측값을 입력할 수 있도록 한다. src/main.rs 파일에 리스트 2-1의 코드를 입력한다.

Filename: src/main.rs
use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}
Listing 2-1: 사용자로부터 추측값을 받아 출력하는 코드

이 코드는 많은 정보를 담고 있으므로 한 줄씩 살펴보자. 사용자 입력을 받고 그 결과를 출력하기 위해 io 입출력 라이브러리를 스코프로 가져와야 한다. io 라이브러리는 std로 알려진 표준 라이브러리에서 제공된다:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

기본적으로 Rust는 모든 프로그램의 스코프에 자동으로 포함되는 표준 라이브러리의 항목 집합을 가지고 있다. 이 집합을 프렐루드(prelude) 라고 하며, 표준 라이브러리 문서에서 모든 내용을 확인할 수 있다.

사용하려는 타입이 프렐루드에 포함되지 않았다면, use 문을 사용해 명시적으로 스코프로 가져와야 한다. std::io 라이브러리를 사용하면 사용자 입력을 받는 기능을 포함해 여러 유용한 기능을 활용할 수 있다.

1장에서 보았듯이, main 함수는 프로그램의 진입점이다:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

fn 구문은 새로운 함수를 선언한다. 괄호 ()는 매개변수가 없음을 나타내고, 중괄호 {는 함수의 본문을 시작한다.

1장에서 배웠듯이, println!은 문자열을 화면에 출력하는 매크로이다:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

이 코드는 게임이 무엇인지 설명하는 프롬프트를 출력하고 사용자로부터 입력을 요청한다.

변수를 사용해 값 저장하기

다음으로, 사용자 입력을 저장할 _변수_를 생성한다. 예시는 다음과 같다:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

이제 프로그램이 점점 흥미로워진다! 이 짧은 한 줄에 많은 일이 벌어진다. let 문을 사용해 변수를 생성한다. 또 다른 예시를 살펴보자:

let apples = 5;

이 코드는 apples라는 새 변수를 생성하고 값 5를 바인딩한다. Rust에서 변수는 기본적으로 불변이다. 즉, 변수에 값을 할당하면 그 값은 변경되지 않는다. 이 개념은 3장의 “변수와 가변성” 섹션에서 자세히 다룬다. 변수를 가변으로 만들려면 변수 이름 앞에 mut를 추가한다:

let apples = 5; // 불변
let mut bananas = 5; // 가변

참고: // 구문은 줄 끝까지 주석을 시작한다. Rust는 주석 내의 모든 내용을 무시한다. 주석에 대해 더 자세히 알아보려면 3장을 참고한다.

추측 게임 프로그램으로 돌아가서, let mut guessguess라는 가변 변수를 생성한다는 것을 이제 알 수 있다. 등호(=)는 Rust에게 변수에 무언가를 바인딩하겠다는 것을 알린다. 등호 오른쪽에는 guess가 바인딩될 값이 위치하며, 이 값은 String::new 함수를 호출한 결과다. String::new는 새로운 String 인스턴스를 반환하는 함수다. String은 표준 라이브러리에서 제공하는 문자열 타입으로, UTF-8로 인코딩된 확장 가능한 텍스트다.

::new 줄의 :: 구문은 newString 타입의 연관 함수임을 나타낸다. _연관 함수_는 특정 타입에 구현된 함수를 말하며, 여기서는 String 타입에 해당한다. 이 new 함수는 새로운 빈 문자열을 생성한다. 많은 타입에서 new 함수를 찾을 수 있는데, 이는 특정 종류의 새 값을 만드는 함수의 일반적인 이름이기 때문이다.

종합하면, let mut guess = String::new(); 줄은 현재 새로운 빈 String 인스턴스에 바인딩된 가변 변수를 생성한다. 휴!

사용자 입력 받기

프로그램의 첫 줄에서 use std::io;를 통해 표준 라이브러리의 입출력 기능을 포함했다. 이제 io 모듈의 stdin 함수를 호출해 사용자 입력을 처리할 수 있다:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

만약 프로그램 시작 부분에서 use std::io;io 모듈을 임포트하지 않았다면, 이 함수 호출을 std::io::stdin으로 작성해도 동일하게 사용할 수 있다. stdin 함수는 std::io::Stdin 타입의 인스턴스를 반환한다. 이 타입은 터미널의 표준 입력을 나타내는 핸들이다.

다음으로 .read_line(&mut guess)는 표준 입력 핸들에서 read_line 메서드를 호출해 사용자 입력을 받는다. read_line&mut guess를 인자로 전달해 사용자 입력을 저장할 문자열을 지정한다. read_line의 역할은 사용자가 표준 입력에 입력한 내용을 문자열에 추가하는 것이다(기존 내용을 덮어쓰지 않음). 따라서 문자열을 인자로 전달한다. 이 문자열 인자는 변경 가능해야 하므로 mut 키워드를 사용한다.

&는 이 인자가 _참조_임을 나타낸다. 참조는 코드의 여러 부분이 데이터를 여러 번 복사하지 않고도 동일한 데이터에 접근할 수 있게 해준다. 참조는 복잡한 기능이지만, Rust의 주요 장점 중 하나는 참조를 안전하고 쉽게 사용할 수 있다는 점이다. 이 프로그램을 완성하기 위해 참조에 대한 모든 세부 사항을 알 필요는 없다. 지금은 변수와 마찬가지로 참조도 기본적으로 불변(immutable)이라는 점만 알아두면 된다. 따라서 변경 가능한 참조를 만들기 위해 &guess 대신 &mut guess를 작성해야 한다. (참조에 대한 자세한 내용은 4장에서 다룬다.)

Result를 사용해 실패 가능성 처리하기

여전히 이 코드 라인을 다루고 있다. 세 번째 텍스트 라인에 대해 논의하고 있지만, 여전히 단일 논리적 코드 라인의 일부임을 기억하자. 다음 부분은 이 메서드다:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

이 코드를 다음과 같이 작성할 수도 있다:

io::stdin().read_line(&mut guess).expect("Failed to read line");

하지만 한 줄로 길게 작성하면 가독성이 떨어지므로, 보통은 .method_name() 구문을 사용해 메서드를 호출할 때 긴 라인을 나누기 위해 개행과 공백을 추가하는 것이 좋다. 이제 이 라인이 무엇을 하는지 살펴보자.

앞서 언급했듯이, read_line은 사용자가 입력한 내용을 우리가 전달한 문자열에 저장하고, 동시에 Result 값을 반환한다. Result열거형으로, 여러 가능한 상태 중 하나에 있을 수 있는 타입이다. 각 가능한 상태를 _변형체(variant)_라고 부른다.

6장에서 열거형에 대해 더 자세히 다룰 것이다. 이 Result 타입의 목적은 오류 처리 정보를 인코딩하는 것이다.

Result의 변형체는 OkErr이다. Ok 변형체는 작업이 성공했음을 나타내며, 성공적으로 생성된 값을 포함한다. Err 변형체는 작업이 실패했음을 의미하며, 작업이 실패한 이유나 방식에 대한 정보를 포함한다.

Result 타입의 값은 다른 타입의 값과 마찬가지로 메서드를 정의할 수 있다. Result의 인스턴스는 expect 메서드를 호출할 수 있다. 만약 이 Result 인스턴스가 Err 값이라면, expect는 프로그램을 중단시키고 expect에 전달한 메시지를 출력한다. read_line 메서드가 Err를 반환한다면, 이는 대개 운영체제에서 발생한 오류의 결과일 가능성이 높다. 만약 이 Result 인스턴스가 Ok 값이라면, expectOk가 담고 있는 반환 값을 가져와 그 값을 반환한다. 이 경우, 그 값은 사용자 입력의 바이트 수이다.

만약 expect를 호출하지 않는다면, 프로그램은 컴파일되지만 다음과 같은 경고가 발생한다:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
warning: unused `Result` that must be used
  --> src/main.rs:10:5
   |
10 |     io::stdin().read_line(&mut guess);
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
10 |     let _ = io::stdin().read_line(&mut guess);
   |     +++++++

warning: `guessing_game` (bin "guessing_game") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.59s

Rust는 read_line에서 반환된 Result 값을 사용하지 않았다는 경고를 보여주며, 이는 프로그램이 가능한 오류를 처리하지 않았음을 나타낸다.

이 경고를 제거하는 올바른 방법은 실제로 오류 처리 코드를 작성하는 것이지만, 여기서는 문제가 발생할 때 프로그램을 중단시키기만 하면 되므로 expect를 사용할 수 있다. 9장에서 오류로부터 복구하는 방법에 대해 배울 것이다.

println! 플레이스홀더로 값 출력하기

닫는 중괄호를 제외하고, 지금까지의 코드에서 다뤄야 할 줄은 단 하나뿐이다:

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

이 줄은 사용자의 입력을 포함하는 문자열을 출력한다. {} 중괄호 세트는 플레이스홀더 역할을 한다. {}를 값 하나를 고정하는 작은 게 집게발로 생각하면 된다. 변수의 값을 출력할 때는 변수 이름을 중괄호 안에 넣을 수 있다. 표현식을 평가한 결과를 출력할 때는 형식 문자열에 빈 중괄호를 넣고, 그 뒤에 각 빈 중괄호 플레이스홀더에 출력할 표현식을 쉼표로 구분해 나열한다. 변수 하나와 표현식 하나의 결과를 println! 한 번 호출로 출력하는 코드는 다음과 같다:

#![allow(unused)]
fn main() {
let x = 5;
let y = 10;

println!("x = {x} and y + 2 = {}", y + 2);
}

이 코드는 x = 5 and y + 2 = 12를 출력한다.

첫 번째 단계의 추측 게임을 테스트해 보자. cargo run 명령어를 사용해 실행한다:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.44s
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

이 시점에서 게임의 첫 번째 부분은 완료되었다. 키보드로부터 입력을 받고, 이를 출력한다.

비밀 숫자 생성하기

다음으로 사용자가 맞춰야 할 비밀 숫자를 생성해야 한다. 매번 다른 숫자가 나와야 게임을 여러 번 플레이해도 재미를 느낄 수 있다. 게임이 너무 어렵지 않도록 1부터 100 사이의 임의의 숫자를 사용한다. Rust는 아직 표준 라이브러리에 난수 생성 기능을 포함하지 않는다. 하지만 Rust 팀에서 제공하는 rand 크레이트를 통해 이 기능을 사용할 수 있다.

크레이트를 활용해 기능 확장하기

크레이트는 러스트 소스 코드 파일의 모음이다. 지금까지 우리가 만든 프로젝트는 실행 가능한 _바이너리 크레이트_다. 반면 rand 크레이트는 _라이브러리 크레이트_로, 다른 프로그램에서 사용할 목적으로 작성된 코드를 포함하며 독자적으로 실행할 수 없다.

Cargo가 외부 크레이트를 관리하는 방식은 정말 뛰어나다. rand를 사용하려면 먼저 Cargo.toml 파일을 수정해 rand 크레이트를 의존성으로 추가해야 한다. 파일을 열고 Cargo가 생성한 [dependencies] 섹션 헤더 아래에 다음 줄을 추가한다. 이 튜토리얼의 예제 코드가 동작하려면 rand를 정확히 아래와 같이 버전 번호까지 지정해야 한다:

파일명: Cargo.toml

[dependencies]
rand = "0.8.5"

Cargo.toml 파일에서 헤더 뒤에 오는 모든 내용은 해당 섹션에 속하며, 새로운 섹션이 시작될 때까지 계속된다. [dependencies] 섹션에서는 프로젝트가 의존하는 외부 크레이트와 필요한 버전을 지정한다. 여기서는 rand 크레이트를 시맨틱 버전 0.8.5로 지정했다. Cargo는 시맨틱 버저닝을 이해한다. 0.8.5는 실제로 ^0.8.5의 축약형으로, 0.8.5 이상 0.9.0 미만의 모든 버전을 의미한다.

Cargo는 이 버전들이 0.8.5와 호환되는 공개 API를 가진다고 간주한다. 이렇게 하면 이 장의 코드와 호환되는 최신 패치 버전을 자동으로 사용할 수 있다. 0.9.0 이상 버전은 다음 예제에서 사용하는 API와 동일할 것이라는 보장이 없다.

코드를 변경하지 않고 프로젝트를 빌드해 보자. 아래는 그 결과다.

$ cargo build
  Updating crates.io index
   Locking 15 packages to latest Rust 1.85.0 compatible versions
    Adding rand v0.8.5 (available: v0.9.0)
 Compiling proc-macro2 v1.0.93
 Compiling unicode-ident v1.0.17
 Compiling libc v0.2.170
 Compiling cfg-if v1.0.0
 Compiling byteorder v1.5.0
 Compiling getrandom v0.2.15
 Compiling rand_core v0.6.4
 Compiling quote v1.0.38
 Compiling syn v2.0.98
 Compiling zerocopy-derive v0.7.35
 Compiling zerocopy v0.7.35
 Compiling ppv-lite86 v0.2.20
 Compiling rand_chacha v0.3.1
 Compiling rand v0.8.5
 Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
  Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.48s
Listing 2-2: rand 크레이트를 의존성으로 추가한 후 cargo build를 실행한 결과

버전 번호는 다를 수 있지만(시맨틱 버저닝 덕분에 코드와 호환된다!) 운영체제에 따라 출력되는 줄이 다르거나 순서가 다를 수 있다.

외부 의존성을 추가하면 Cargo는 _레지스트리_에서 해당 의존성이 필요한 모든 것의 최신 버전을 가져온다. 레지스트리는 Crates.io의 데이터 복사본이다. Crates.io는 러스트 생태계에서 사람들이 오픈소스 러스트 프로젝트를 공유하는 곳이다.

레지스트리를 업데이트한 후 Cargo는 [dependencies] 섹션을 확인하고 아직 다운로드하지 않은 크레이트를 다운로드한다. 이 경우 rand만 의존성으로 나열했지만, Cargo는 rand가 동작하기 위해 필요한 다른 크레이트도 함께 가져온다. 크레이트를 다운로드한 후 러스트는 이를 컴파일하고, 의존성을 사용할 수 있게 된 프로젝트를 컴파일한다.

만약 아무것도 변경하지 않고 바로 cargo build를 다시 실행하면 Finished 줄 외에는 아무런 출력이 없다. Cargo는 이미 의존성을 다운로드하고 컴파일했으며, Cargo.toml 파일에서 아무것도 변경하지 않았다는 것을 알고 있다. 또한 코드도 변경하지 않았기 때문에 다시 컴파일하지 않는다. 할 일이 없으므로 그냥 종료한다.

src/main.rs 파일을 열고 사소한 변경을 한 후 저장하고 다시 빌드하면 두 줄의 출력만 볼 수 있다:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s

이 줄들은 Cargo가 src/main.rs 파일의 작은 변경만 반영해 빌드했음을 보여준다. 의존성은 변경되지 않았으므로 Cargo는 이미 다운로드하고 컴파일한 것을 재사용할 수 있다.

Cargo.lock 파일로 재현 가능한 빌드 보장하기

Cargo는 여러분이나 다른 누군가가 코드를 빌드할 때마다 동일한 결과물을 재현할 수 있도록 보장하는 메커니즘을 제공한다. Cargo는 여러분이 명시적으로 변경하지 않는 한, 지정한 버전의 의존성만 사용한다. 예를 들어, 다음 주에 rand 크레이트의 0.8.6 버전이 출시되고, 이 버전에는 중요한 버그 수정이 포함되어 있지만, 동시에 여러분의 코드를 망가뜨리는 회귀 버그도 포함되어 있다고 가정해 보자. 이를 처리하기 위해 Rust는 cargo build를 처음 실행할 때 Cargo.lock 파일을 생성한다. 이제 guessing_game 디렉토리에는 이 파일이 존재하게 된다.

프로젝트를 처음 빌드할 때, Cargo는 지정된 기준에 맞는 모든 의존성의 버전을 찾아내고 이를 Cargo.lock 파일에 기록한다. 이후 프로젝트를 다시 빌드할 때, Cargo는 Cargo.lock 파일이 존재하는지 확인하고, 해당 파일에 명시된 버전을 사용한다. 이렇게 하면 다시 버전을 찾아내는 작업을 반복하지 않아도 되며, 자동으로 재현 가능한 빌드를 보장할 수 있다. 즉, Cargo.lock 파일 덕분에 여러분의 프로젝트는 명시적으로 업그레이드하지 않는 한 0.8.5 버전을 유지하게 된다. Cargo.lock 파일은 재현 가능한 빌드에 중요한 역할을 하기 때문에, 프로젝트의 나머지 코드와 함께 소스 제어 시스템에 체크인되는 경우가 많다.

크레이트를 새로운 버전으로 업데이트하기

크레이트를 업데이트하고 싶을 때, Cargo는 update 커맨드를 제공한다. 이 커맨드는 Cargo.lock 파일을 무시하고 _Cargo.toml_에 명시된 조건에 맞는 최신 버전을 찾아낸다. 그리고 그 버전을 Cargo.lock 파일에 기록한다. 이 경우, Cargo는 0.8.5보다 크고 0.9.0보다 작은 버전만 찾는다. 만약 rand 크레이트가 0.8.6과 0.9.0 두 가지 새로운 버전을 출시했다면, cargo update를 실행하면 다음과 같은 결과를 볼 수 있다:

$ cargo update
    Updating crates.io index
     Locking 1 package to latest Rust 1.85.0 compatible version
    Updating rand v0.8.5 -> v0.8.6 (available: v0.9.0)

Cargo는 0.9.0 버전을 무시한다. 이 시점에서 Cargo.lock 파일에 rand 크레이트의 버전이 0.8.6으로 변경된 것을 확인할 수 있다. rand 버전 0.9.0이나 0.9.x 시리즈의 버전을 사용하려면, Cargo.toml 파일을 다음과 같이 수정해야 한다:

[dependencies]
rand = "0.9.0"

다음으로 cargo build를 실행하면, Cargo는 크레이트 레지스트리를 업데이트하고 지정한 새로운 버전에 따라 rand 요구사항을 다시 평가한다.

Cargo그 생태계에 대해 더 많은 이야기가 있지만, 이는 14장에서 다룰 예정이다. 지금은 이 정도만 알아두면 충분하다. Cargo는 라이브러리 재사용을 매우 쉽게 만들어주기 때문에, Rust 개발자들은 여러 패키지를 조합해 작은 프로젝트를 작성할 수 있다.

랜덤 숫자 생성하기

이제 rand를 사용해 추측할 숫자를 생성해 보자. 다음 단계는 src/main.rs 파일을 예제 2-3과 같이 업데이트하는 것이다.

Filename: src/main.rs
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}
Listing 2-3: 랜덤 숫자 생성을 위한 코드 추가

먼저 use rand::Rng; 라인을 추가한다. Rng 트레이트는 랜덤 숫자 생성기가 구현해야 하는 메서드를 정의하며, 이 트레이트가 스코프 내에 있어야 해당 메서드를 사용할 수 있다. 트레이트에 대해서는 10장에서 자세히 다룰 것이다.

다음으로 중간에 두 줄을 추가한다. 첫 번째 줄에서는 rand::thread_rng 함수를 호출한다. 이 함수는 현재 실행 중인 스레드에 특화된 랜덤 숫자 생성기를 반환하며, 운영체제가 제공하는 시드 값을 사용한다. 그런 다음 랜덤 숫자 생성기에서 gen_range 메서드를 호출한다. 이 메서드는 use rand::Rng; 문으로 스코프에 가져온 Rng 트레이트에 정의되어 있다. gen_range 메서드는 범위 표현식을 인자로 받아 해당 범위 내의 랜덤 숫자를 생성한다. 여기서 사용한 범위 표현식은 start..=end 형태이며, 하한과 상한을 모두 포함한다. 따라서 1부터 100 사이의 숫자를 생성하려면 1..=100을 지정해야 한다.

참고: 어떤 트레이트를 사용하고, 어떤 메서드와 함수를 호출해야 하는지 단번에 알기는 어렵다. 각 크레이트는 사용 방법을 설명하는 문서를 제공한다. Cargo의 유용한 기능 중 하나는 cargo doc --open 명령을 실행하면 모든 의존성의 문서를 로컬에서 빌드하고 브라우저에서 열어준다는 점이다. 예를 들어, rand 크레이트의 다른 기능이 궁금하다면 cargo doc --open을 실행하고 왼쪽 사이드바에서 rand를 클릭하면 된다.

두 번째 새 줄은 비밀 숫자를 출력한다. 프로그램을 개발하면서 테스트할 때 유용하지만, 최종 버전에서는 이 줄을 삭제할 것이다. 프로그램이 시작하자마자 정답을 출력한다면 게임이 되지 않을 테니 말이다.

프로그램을 몇 번 실행해 보자:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

매번 다른 랜덤 숫자가 생성되며, 모든 숫자가 1부터 100 사이여야 한다. 잘 했다!

추측 값과 비밀 숫자 비교하기

이제 사용자 입력과 랜덤 숫자를 비교할 준비가 되었다. 이 과정은 목록 2-4에 나와 있다. 이 코드는 아직 컴파일되지 않음을 주의하자. 이유는 곧 설명할 것이다.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    // --snip--
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}
Listing 2-4: 두 숫자를 비교한 결과값 처리하기

먼저 std::cmp::Ordering 타입을 스코프로 가져오기 위해 use 문을 추가한다. Ordering은 열거형으로, Less, Greater, Equal 세 가지 변형 값을 가진다. 이 값들은 두 값을 비교했을 때 가능한 결과를 나타낸다.

그런 다음 Ordering 타입을 사용하는 다섯 줄의 코드를 추가한다. cmp 메서드는 두 값을 비교하며, 비교 가능한 모든 대상에 대해 호출할 수 있다. 이 메서드는 비교 대상에 대한 참조를 인자로 받는다. 여기서는 guesssecret_number를 비교한다. 그리고 use 문으로 가져온 Ordering 열거형의 변형 값을 반환한다. match 표현식을 사용해 cmp 메서드가 반환한 Ordering 변형 값에 따라 다음 동작을 결정한다.

match 표현식은 로 구성된다. 각 팔은 매칭할 패턴과, match에 주어진 값이 해당 팔의 패턴과 일치할 때 실행할 코드로 이루어진다. Rust는 match에 주어진 값을 순서대로 각 팔의 패턴과 비교한다. 패턴과 match 구조는 Rust의 강력한 기능이다. 이 기능들은 코드가 마주칠 수 있는 다양한 상황을 표현하고, 모든 경우를 처리하도록 보장한다. 이 기능들은 각각 6장과 19장에서 자세히 다룰 것이다.

여기서 사용한 match 표현식의 동작을 예제로 살펴보자. 사용자가 50을 추측했고, 이번에 생성된 비밀 숫자가 38이라고 가정하자.

코드가 50과 38을 비교하면, cmp 메서드는 50이 38보다 크므로 Ordering::Greater를 반환한다. match 표현식은 Ordering::Greater 값을 받아 각 팔의 패턴을 확인한다. 첫 번째 팔의 패턴인 Ordering::Less를 확인하고, Ordering::GreaterOrdering::Less와 일치하지 않으므로 해당 팔의 코드를 무시하고 다음 팔로 넘어간다. 다음 팔의 패턴은 Ordering::Greater로, Ordering::Greater와 일치한다! 따라서 해당 팔의 코드가 실행되어 Too big!을 화면에 출력한다. match 표현식은 첫 번째로 성공한 매칭 이후 종료되므로, 이 시나리오에서는 마지막 팔을 확인하지 않는다.

하지만 목록 2-4의 코드는 아직 컴파일되지 않는다. 실행해 보자:

$ cargo build
   Compiling libc v0.2.86
   Compiling getrandom v0.2.2
   Compiling cfg-if v1.0.0
   Compiling ppv-lite86 v0.2.10
   Compiling rand_core v0.6.2
   Compiling rand_chacha v0.3.0
   Compiling rand v0.8.5
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                 --- ^^^^^^^^^^^^^^ expected `&String`, found `&{integer}`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&String`
              found reference `&{integer}`
note: method defined here
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/cmp.rs:964:8

For more information about this error, try `rustc --explain E0308`.
error: could not compile `guessing_game` (bin "guessing_game") due to 1 previous error

에러의 핵심은 타입 불일치다. Rust는 강력한 정적 타입 시스템을 갖추고 있다. 하지만 타입 추론도 지원한다. let mut guess = String::new()를 작성했을 때, Rust는 guessString 타입이어야 한다고 추론했고, 타입을 명시하도록 요구하지 않았다. 반면 secret_number는 숫자 타입이다. Rust에는 1부터 100 사이의 값을 가질 수 있는 여러 숫자 타입이 있다: 32비트 숫자인 i32, 부호 없는 32비트 숫자인 u32, 64비트 숫자인 i64, 그리고 다른 타입들도 있다. 별도로 지정하지 않으면 Rust는 i32를 기본값으로 사용한다. 따라서 secret_numberi32 타입이다. 에러의 원인은 Rust가 문자열과 숫자 타입을 비교할 수 없기 때문이다.

궁극적으로는 프로그램이 입력으로 받은 String을 숫자 타입으로 변환해 비밀 숫자와 비교할 수 있도록 해야 한다. 이를 위해 main 함수에 다음 줄을 추가한다:

파일명: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    // --snip--

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

추가한 줄은 다음과 같다:

let guess: u32 = guess.trim().parse().expect("Please type a number!");

guess라는 변수를 새로 만든다. 잠깐, 이미 guess라는 변수가 있지 않나? 맞다. 하지만 Rust는 이전 guess 값을 새로운 값으로 가리는 섀도잉을 허용한다. 섀도잉은 guess_strguess처럼 두 개의 고유한 변수를 만들지 않고도 guess 변수명을 재사용할 수 있게 해준다. 이 기능은 3장에서 자세히 다룰 것이다. 지금은 이 기능이 한 타입의 값을 다른 타입으로 변환할 때 자주 사용된다는 점만 알아두자.

새 변수는 guess.trim().parse() 표현식에 바인딩된다. 이 표현식의 guess는 사용자 입력을 담고 있는 원래의 guess 변수를 가리킨다. String 인스턴스의 trim 메서드는 문자열 앞뒤의 공백을 제거한다. 문자열을 u32로 변환하기 전에 이 작업을 수행해야 한다. u32는 숫자 데이터만 포함할 수 있기 때문이다. 사용자는 read_line을 만족시키기 위해 enter를 눌러야 하므로, 문자열에 개행 문자가 추가된다. 예를 들어 사용자가 5를 입력하고 enter를 누르면 guess5\n이 된다. \n은 “개행“을 나타낸다. (Windows에서는 enter를 누르면 캐리지 리턴과 개행 문자인 \r\n이 추가된다.) trim 메서드는 \n이나 \r\n을 제거해 5만 남긴다.

문자열의 parse 메서드는 문자열을 다른 타입으로 변환한다. 여기서는 문자열을 숫자로 변환한다. let guess: u32를 사용해 Rust에게 원하는 정확한 숫자 타입을 알려준다. guess 뒤의 콜론(:)은 변수의 타입을 명시한다는 것을 의미한다. Rust에는 여러 내장 숫자 타입이 있다. 여기서 사용한 u32는 부호 없는 32비트 정수다. 작은 양수에는 적합한 기본 선택지다. 다른 숫자 타입은 3장에서 배울 것이다.

또한 이 예제 프로그램에서 u32 타입을 명시하고 secret_number와 비교하므로, Rust는 secret_numberu32 타입이어야 한다고 추론한다. 이제 두 값은 같은 타입으로 비교된다!

parse 메서드는 논리적으로 숫자로 변환할 수 있는 문자에서만 동작하므로, 쉽게 에러를 발생시킬 수 있다. 예를 들어 문자열에 A👍%가 포함되어 있다면, 이를 숫자로 변환할 방법이 없다. 실패할 가능성이 있으므로, parse 메서드는 Result 타입을 반환한다. 이는 앞서 다룬 read_line 메서드와 비슷하다. 이 Resultexpect 메서드를 사용해 처리한다. parse가 문자열에서 숫자를 만들 수 없어 Err 변형 값을 반환하면, expect 호출은 게임을 종료하고 주어진 메시지를 출력한다. parse가 문자열을 성공적으로 숫자로 변환하면 Ok 변형 값을 반환하고, expectOk 값에서 원하는 숫자를 반환한다.

이제 프로그램을 실행해 보자:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.26s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

잘 작동한다! 추측 값 앞에 공백이 추가되었음에도 프로그램은 사용자가 76을 추측했다는 것을 알아냈다. 프로그램을 여러 번 실행해 다양한 입력에 따른 동작을 확인해 보자: 숫자를 정확히 맞추거나, 너무 높은 숫자를 추측하거나, 너무 낮은 숫자를 추측해 보는 것이다.

이제 게임의 대부분이 작동한다. 하지만 사용자는 한 번만 추측할 수 있다. 이제 반복문을 추가해 이를 바꿔보자!

여러 번 추측할 수 있도록 루프 추가하기

loop 키워드는 무한 루프를 생성한다. 사용자가 여러 번 추측할 수 있도록 루프를 추가해 보자:

파일명: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    // --snip--

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        // --snip--


        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => println!("You win!"),
        }
    }
}

보는 바와 같이, 추측값 입력 프롬프트 이후의 모든 내용을 루프 안으로 옮겼다. 루프 내부의 각 줄을 4칸씩 들여쓰기하고 프로그램을 다시 실행해 보자. 이제 프로그램은 계속해서 추측값을 요청할 것이다. 하지만 이는 새로운 문제를 야기한다. 사용자가 프로그램을 종료할 방법이 없는 것처럼 보인다.

사용자는 언제나 ctrl-c 단축키를 사용해 프로그램을 중단할 수 있다. 하지만 “추측값과 비밀번호 비교하기”에서 parse에 대해 논의할 때 언급했듯이, 이 무한 루프에서 벗어나는 또 다른 방법이 있다. 사용자가 숫자가 아닌 답을 입력하면 프로그램이 충돌할 것이다. 이를 활용해 사용자가 게임을 종료할 수 있도록 할 수 있다:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.23s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit

thread 'main' panicked at src/main.rs:28:47:
Please type a number!: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

quit을 입력하면 게임이 종료되지만, 다른 숫자가 아닌 입력도 동일하게 게임을 종료시킨다. 이는 최적의 방법이 아니다. 정답을 맞췄을 때도 게임이 멈추길 원한다.

정답을 맞췄을 때 종료하기

사용자가 정답을 맞췄을 때 게임을 종료하도록 프로그램을 수정해보자. 이를 위해 break 문을 추가한다:

파일명: src/main.rs

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse().expect("Please type a number!");

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

You win! 메시지 뒤에 break 문을 추가하면 사용자가 비밀번호를 맞췄을 때 루프를 종료한다. 루프가 main 함수의 마지막 부분이므로, 루프를 종료하는 것은 프로그램을 종료하는 것과 같다.

잘못된 입력 처리하기

프로그램이 사용자가 숫자가 아닌 값을 입력했을 때 충돌(crash)하지 않도록, 이제는 그런 입력을 무시하고 계속해서 추측할 수 있게 개선해보자. 이를 위해 guessString에서 u32로 변환하는 부분을 수정하면 된다. Listing 2-5에서 그 방법을 확인할 수 있다.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 2-5: 숫자가 아닌 추측값을 무시하고 프로그램 충돌 대신 다시 추측값을 요청하기

여기서는 expect 호출을 match 표현식으로 바꿔서, 오류 발생 시 프로그램을 종료하는 대신 오류를 처리하도록 했다. parseResult 타입을 반환하고, ResultOkErr 두 가지 변형(variant)을 가진 열거형(enum)이다. 앞서 cmp 메서드의 Ordering 결과를 처리할 때와 마찬가지로 match 표현식을 사용했다.

parse가 문자열을 숫자로 성공적으로 변환할 수 있다면, 결과 숫자를 담은 Ok 값을 반환한다. 이 Ok 값은 match의 첫 번째 패턴과 일치하며, match 표현식은 parse가 생성한 num 값을 반환한다. 이 값은 새로운 guess 변수에 저장된다.

반면 parse가 문자열을 숫자로 변환하지 못하면, 오류 정보를 담은 Err 값을 반환한다. 이 Err 값은 첫 번째 match 패턴인 Ok(num)과 일치하지 않지만, 두 번째 패턴인 Err(_)와는 일치한다. 여기서 언더스코어(_)는 모든 값을 포괄하는 와일드카드 패턴이다. 이 예제에서는 Err 값 내부에 어떤 정보가 있든 상관없이 모든 Err 값을 처리하겠다는 의미다. 따라서 프로그램은 두 번째 패턴의 코드인 continue를 실행한다. 이는 프로그램에게 루프의 다음 반복으로 이동해 다시 추측값을 요청하라는 뜻이다. 결과적으로 프로그램은 parse가 만날 수 있는 모든 오류를 무시하게 된다.

이제 프로그램의 모든 부분이 예상대로 동작할 것이다. 한번 실행해보자:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

훌륭하다! 이제 마지막으로 작은 수정을 하나 더 하면 추측 게임이 완성된다. 현재 프로그램은 여전히 비밀 숫자를 출력하고 있다. 테스트할 때는 유용했지만, 실제 게임에서는 문제가 된다. 비밀 숫자를 출력하는 println!을 삭제해보자. Listing 2-6은 최종 코드를 보여준다.

Filename: src/main.rs
use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 2-6: 완성된 추측 게임 코드

이제 여러분은 성공적으로 추측 게임을 만들었다. 축하한다!

요약

이 프로젝트는 여러분에게 다양한 새로운 Rust 개념을 실제로 경험할 수 있는 기회를 제공한다. let, match, 함수, 외부 크레이트 사용 등을 다뤘다. 다음 몇 장에서는 이러한 개념을 더 자세히 배울 것이다. 3장에서는 변수, 데이터 타입, 함수 등 대부분의 프로그래밍 언어에서 공통적으로 사용되는 개념을 다루고, Rust에서 이를 어떻게 사용하는지 설명한다. 4장에서는 Rust를 다른 언어와 차별화하는 특징인 소유권(ownership)에 대해 탐구한다. 5장에서는 구조체(struct)와 메서드 문법을 논의하고, 6장에서는 열거형(enums)이 어떻게 동작하는지 설명한다.

프로그래밍의 공통 개념

이 장에서는 거의 모든 프로그래밍 언어에서 등장하는 개념과 이를 Rust에서 어떻게 구현하는지 알아본다. 대부분의 프로그래밍 언어는 핵심적으로 많은 공통점을 가지고 있다. 이 장에서 소개하는 개념은 Rust에만 국한된 것이 아니지만, Rust의 문맥에서 이를 설명하고 이러한 개념을 사용할 때의 관례를 다룬다.

특히 변수, 기본 타입, 함수, 주석, 그리고 제어 흐름에 대해 배울 것이다. 이러한 기초 개념은 모든 Rust 프로그램에 포함되며, 이를 일찍 익히면 강력한 기초를 다지는 데 도움이 될 것이다.

키워드

Rust 언어는 다른 언어와 마찬가지로 언어 자체에서만 사용할 수 있는 키워드 집합을 가지고 있다. 이 단어들은 변수나 함수의 이름으로 사용할 수 없다는 점을 명심해야 한다. 대부분의 키워드는 특별한 의미를 가지며, Rust 프로그램에서 다양한 작업을 수행하는 데 사용된다. 몇몇 키워드는 현재 기능과 연결되어 있지 않지만, 향후 Rust에 추가될 수 있는 기능을 위해 예약되어 있다. 키워드 목록은 부록 A에서 확인할 수 있다.

변수와 가변성

“변수로 값 저장하기” 섹션에서 언급했듯이, 기본적으로 변수는 불변(immutable)이다. 이는 Rust가 제공하는 안전성과 쉬운 동시성(concurrency)을 활용해 코드를 작성하도록 유도하는 여러 방법 중 하나다. 그러나 여전히 변수를 가변(mutable)으로 만들 수 있는 옵션이 있다. Rust가 왜 불변성을 장려하는지, 그리고 어떤 경우에 가변성을 선택해야 하는지 알아보자.

변수가 불변일 때, 값이 이름에 바인딩되면 그 값을 변경할 수 없다. 이를 설명하기 위해 cargo new variables 명령을 사용해 projects 디렉터리에 _variables_라는 새 프로젝트를 생성한다.

그런 다음, 새로 생성된 variables 디렉터리에서 src/main.rs 파일을 열고 다음 코드로 바꾼다. 이 코드는 아직 컴파일되지 않는다:

파일명: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

코드를 저장하고 cargo run 명령으로 프로그램을 실행한다. 다음과 같이 불변성 오류와 관련된 에러 메시지를 확인할 수 있다:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {x}");
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable
  |
help: consider making this binding mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0384`.
error: could not compile `variables` (bin "variables") due to 1 previous error

이 예제는 컴파일러가 프로그램에서 오류를 찾는 데 어떻게 도움을 주는지 보여준다. 컴파일러 오류는 실망스러울 수 있지만, 이는 단지 프로그램이 아직 안전하게 동작하지 않는다는 의미일 뿐, 여러분이 나쁜 프로그래머라는 뜻은 아니다! 경험 많은 Rust 개발자도 여전히 컴파일러 오류를 마주한다.

cannot assign twice to immutable variable x`라는 오류 메시지는 불변 변수 x`에 두 번째 값을 할당하려 했기 때문에 발생한다.

불변으로 지정된 값을 변경하려고 할 때 컴파일 타임에 오류가 발생하는 것은 중요하다. 이는 버그로 이어질 수 있는 상황이기 때문이다. 코드의 한 부분이 값이 절대 변경되지 않는다는 가정 하에 동작하고, 다른 부분에서 그 값을 변경하면, 첫 번째 부분이 의도한 대로 동작하지 않을 가능성이 있다. 특히 두 번째 코드가 가끔씩만 값을 변경하는 경우, 이런 종류의 버그의 원인을 추적하기는 더 어려울 수 있다. Rust 컴파일러는 값이 변경되지 않는다고 선언하면 실제로 변경되지 않음을 보장하므로, 이를 직접 추적할 필요가 없다. 따라서 코드를 더 쉽게 이해할 수 있다.

하지만 가변성은 매우 유용할 수 있으며, 코드를 더 편리하게 작성할 수 있게 해준다. 변수는 기본적으로 불변이지만, 2장에서 했던 것처럼 변수 이름 앞에 mut을 추가해 가변으로 만들 수 있다. mut을 추가하면 코드의 다른 부분에서 이 변수의 값을 변경할 것임을 미래의 코드 독자에게 알려줄 수 있다.

예를 들어, src/main.rs 파일을 다음과 같이 변경한다:

파일명: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {x}");
    x = 6;
    println!("The value of x is: {x}");
}

이제 프로그램을 실행하면 다음과 같은 결과를 얻는다:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

mut을 사용하면 x에 바인딩된 값을 5에서 6으로 변경할 수 있다. 최종적으로 가변성을 사용할지 여부는 여러분의 선택에 달려 있으며, 특정 상황에서 무엇이 가장 명확한지에 따라 결정하면 된다.

상수

불변 변수와 마찬가지로, _상수_는 이름에 바인딩된 값이며 변경할 수 없다. 하지만 상수와 변수 사이에는 몇 가지 차이점이 있다.

첫째, 상수에는 mut 키워드를 사용할 수 없다. 상수는 기본적으로 불변일 뿐만 아니라 항상 불변이다. 상수는 let 키워드 대신 const 키워드를 사용해 선언하며, 값의 타입을 반드시 명시해야 한다. 타입과 타입 명시에 대해서는 다음 섹션인 “데이터 타입”에서 자세히 다룰 예정이므로, 지금은 세부 사항에 대해 걱정하지 않아도 된다. 단, 타입을 항상 명시해야 한다는 점만 기억하면 된다.

상수는 전역 스코프를 포함한 모든 스코프에서 선언할 수 있다. 이는 코드의 여러 부분에서 알아야 할 값에 대해 상수를 사용할 때 유용하다.

마지막 차이점은 상수는 상수 표현식으로만 설정할 수 있으며, 런타임에 계산되는 값의 결과로 설정할 수 없다는 것이다.

다음은 상수 선언의 예시다:

#![allow(unused)]
fn main() {
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
}

이 상수의 이름은 THREE_HOURS_IN_SECONDS이며, 값은 60(1분의 초 수)을 60(1시간의 분 수)으로 곱한 후 3(이 프로그램에서 계산하려는 시간 수)을 곱한 결과로 설정된다. Rust에서 상수의 이름은 모든 글자를 대문자로 쓰고 단어 사이에 밑줄을 사용하는 것이 관례다. 컴파일러는 컴파일 시점에 제한된 연산을 평가할 수 있으므로, 상수를 10,800으로 직접 설정하는 대신 이렇게 작성하면 이해하고 검증하기 쉬운 방식으로 값을 표현할 수 있다. 상수 선언 시 사용할 수 있는 연산에 대한 더 자세한 정보는 Rust Reference의 상수 평가 섹션을 참고하면 된다.

상수는 선언된 스코프 내에서 프로그램이 실행되는 동안 유효하다. 이 특성은 프로그램의 여러 부분에서 알아야 할 애플리케이션 도메인의 값, 예를 들어 게임에서 플레이어가 획득할 수 있는 최대 점수나 빛의 속도와 같은 값을 상수로 정의할 때 유용하다.

프로그램 전체에서 사용되는 하드코딩된 값을 상수로 명명하면, 코드를 유지보수할 사람에게 그 값의 의미를 전달하는 데 도움이 된다. 또한, 나중에 하드코딩된 값을 업데이트해야 할 경우 코드 내에서 단 한 곳만 변경하면 된다는 장점도 있다.

쉐도잉(Shadowing)

2장에서 다룬 숫자 맞추기 게임 튜토리얼에서 보았듯이, 이전에 선언한 변수와 같은 이름으로 새로운 변수를 선언할 수 있다. 러스트 프로그래머들은 이를 첫 번째 변수가 두 번째 변수에 의해 쉐도잉(shadowed) 되었다고 표현한다. 이는 변수 이름을 사용할 때 컴파일러가 두 번째 변수를 인식한다는 의미이다. 실제로 두 번째 변수는 첫 번째 변수를 가려버리며, 변수 이름을 사용하는 모든 경우에 두 번째 변수가 적용된다. 이 상태는 두 번째 변수 자체가 쉐도잉되거나 스코프가 종료될 때까지 지속된다. let 키워드를 다시 사용해 동일한 변수 이름으로 쉐도잉을 수행할 수 있다.

파일명: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    {
        let x = x * 2;
        println!("The value of x in the inner scope is: {x}");
    }

    println!("The value of x is: {x}");
}

이 프로그램은 먼저 x5로 바인딩한다. 그런 다음 let x =를 다시 사용해 새로운 변수 x를 생성하고, 기존 값에 1을 더해 x의 값을 6으로 만든다. 이후 중괄호로 생성된 내부 스코프에서 세 번째 let 문이 x를 다시 쉐도잉하고, 이전 값에 2를 곱해 x의 값을 12로 만든다. 이 스코프가 끝나면 내부 쉐도잉이 종료되고 x는 다시 6으로 돌아간다. 이 프로그램을 실행하면 다음과 같은 결과가 출력된다:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/variables`
The value of x in the inner scope is: 12
The value of x is: 6

쉐도잉은 변수를 mut로 표시하는 것과 다르다. let 키워드를 사용하지 않고 변수에 재할당을 시도하면 컴파일 타임 에러가 발생한다. let을 사용하면 값에 몇 가지 변환을 수행할 수 있고, 변환이 완료된 후에는 변수를 불변 상태로 유지할 수 있다.

mut와 쉐도잉의 또 다른 차이점은 let 키워드를 다시 사용해 새로운 변수를 생성할 때 값의 타입을 변경할 수 있다는 점이다. 예를 들어, 프로그램에서 사용자에게 텍스트 사이에 원하는 공백 수를 입력하도록 요청하고, 그 입력을 숫자로 저장하려는 경우를 생각해 보자:

fn main() {
    let spaces = "   ";
    let spaces = spaces.len();
}

첫 번째 spaces 변수는 문자열 타입이고, 두 번째 spaces 변수는 숫자 타입이다. 쉐도잉을 사용하면 spaces_str이나 spaces_num과 같은 다른 이름을 고민할 필요 없이, 단순히 spaces라는 이름을 재사용할 수 있다. 그러나 mut를 사용해 이를 시도하면 다음과 같이 컴파일 타임 에러가 발생한다:

fn main() {
    let mut spaces = "   ";
    spaces = spaces.len();
}

에러 메시지는 변수의 타입을 변경할 수 없다고 알려준다:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
2 |     let mut spaces = "   ";
  |                      ----- expected due to this value
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected `&str`, found `usize`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `variables` (bin "variables") due to 1 previous error

이제 변수가 어떻게 동작하는지 살펴보았으니, 변수가 가질 수 있는 다양한 데이터 타입에 대해 알아보자.

데이터 타입

Rust의 모든 값은 특정 _데이터 타입_을 가지며, 이는 Rust에게 어떤 종류의 데이터인지 알려주어 해당 데이터를 어떻게 처리할지 결정한다. 여기서는 두 가지 데이터 타입 하위 집합인 스칼라(scalar)와 복합(compound) 타입을 살펴본다.

Rust는 정적 타입 언어라는 점을 기억하자. 이는 컴파일 시점에 모든 변수의 타입을 알아야 한다는 의미다. 컴파일러는 일반적으로 값과 사용 방식을 기반으로 우리가 사용하려는 타입을 추론할 수 있다. 하지만 “추리한 값과 비밀번호 비교하기” 섹션에서 parse를 사용해 String을 숫자 타입으로 변환할 때와 같이 여러 타입이 가능한 경우에는 다음과 같이 타입 어노테이션을 추가해야 한다:

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("숫자가 아닙니다!");
}

위 코드에서 : u32 타입 어노테이션을 추가하지 않으면 Rust는 다음과 같은 에러를 표시한다. 이는 컴파일러가 우리가 사용하려는 타입을 알기 위해 더 많은 정보가 필요하다는 의미다:

$ cargo build
   Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0284]: type annotations needed
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^        ----- type must be known at this point
  |
  = note: cannot satisfy `<_ as FromStr>::Err == _`
help: consider giving `guess` an explicit type
  |
2 |     let guess: /* Type */ = "42".parse().expect("Not a number!");
  |              ++++++++++++

For more information about this error, try `rustc --explain E0284`.
error: could not compile `no_type_annotations` (bin "no_type_annotations") due to 1 previous error

다른 데이터 타입에 대해서도 다양한 타입 어노테이션을 확인할 수 있다.

스칼라 타입

스칼라 타입은 단일 값을 나타낸다. Rust는 네 가지 주요 스칼라 타입을 제공한다: 정수, 부동소수점 숫자, 불리언, 그리고 문자. 다른 프로그래밍 언어에서도 이러한 타입을 접해봤을 것이다. 이제 Rust에서 이 타입들이 어떻게 동작하는지 살펴보자.

정수 타입

_정수_는 소수 부분이 없는 숫자를 말한다. 2장에서 u32 타입을 사용했는데, 이 타입 선언은 해당 값이 32비트 공간을 차지하는 부호 없는 정수임을 나타낸다. 부호 있는 정수 타입은 u 대신 i로 시작한다. 표 3-1은 Rust에 내장된 정수 타입을 보여준다. 이 중 어떤 타입을 사용해도 정수 값의 타입을 선언할 수 있다.

표 3-1: Rust의 정수 타입

길이부호 있음부호 없음
8비트i8u8
16비트i16u16
32비트i32u32
64비트i64u64
128비트i128u128
아키텍처isizeusize

각 타입은 부호 있음 또는 부호 없음으로 나뉘며 명시적인 크기를 가진다. _부호 있음_과 _부호 없음_은 숫자가 음수가 될 수 있는지 여부를 나타낸다. 즉, 숫자에 부호가 필요한지(부호 있음) 아니면 항상 양수여서 부호 없이 표현할 수 있는지(부호 없음)를 의미한다. 종이에 숫자를 쓸 때를 생각해보면, 부호가 중요한 경우에는 숫자에 더하기 또는 빼기 기호를 붙이지만, 숫자가 양수임이 명확한 경우에는 부호를 생략한다. 부호 있는 숫자는 2의 보수 표현법을 사용해 저장된다.

각 부호 있는 타입은 −(2n − 1)부터 2n − 1 − 1까지의 숫자를 저장할 수 있으며, 여기서 _n_은 해당 타입이 사용하는 비트 수다. 예를 들어, i8은 −(27)부터 27 − 1까지, 즉 −128부터 127까지의 숫자를 저장할 수 있다. 부호 없는 타입은 0부터 2n − 1까지의 숫자를 저장할 수 있으므로, u8은 0부터 28 − 1까지, 즉 0부터 255까지의 숫자를 저장할 수 있다.

또한, isizeusize 타입은 프로그램이 실행되는 컴퓨터의 아키텍처에 따라 달라지며, 표에서 “아키텍처“로 표시된 부분이다. 64비트 아키텍처에서는 64비트를 사용하고, 32비트 아키텍처에서는 32비트를 사용한다.

정수 리터럴은 표 3-2에 나온 형태 중 어떤 것으로도 작성할 수 있다. 여러 숫자 타입이 가능한 숫자 리터럴의 경우, 57u8과 같이 타입 접미사를 붙여 타입을 지정할 수 있다. 또한, 숫자 리터럴은 _를 시각적 구분자로 사용해 숫자를 더 읽기 쉽게 만들 수 있다. 예를 들어, 1_0001000과 동일한 값을 가진다.

표 3-2: Rust의 정수 리터럴

숫자 리터럴예시
10진수98_222
16진수0xff
8진수0o77
2진수0b1111_0000
바이트 (u8만)b'A'

그렇다면 어떤 정수 타입을 사용해야 할까? 확실하지 않다면 Rust의 기본값을 사용하는 것이 일반적으로 좋은 시작점이다. 정수 타입은 기본적으로 i32를 사용한다. isizeusize를 사용하는 주요 상황은 어떤 종류의 컬렉션을 인덱싱할 때다.

정수 오버플로우

0부터 255까지의 값을 저장할 수 있는 u8 타입의 변수가 있다고 가정해보자. 이 변수를 해당 범위를 벗어나는 값, 예를 들어 256으로 변경하려고 하면 _정수 오버플로우_가 발생하며, 이는 두 가지 동작 중 하나를 초래할 수 있다. 디버그 모드에서 컴파일할 때, Rust는 정수 오버플로우를 검사하며, 이 동작이 발생하면 런타임에 프로그램이 _패닉_을 일으키게 한다. Rust는 프로그램이 오류와 함께 종료될 때 _패닉_이라는 용어를 사용한다. 패닉에 대해서는 9장의 panic!을 사용한 복구 불가능한 오류” 섹션에서 더 자세히 다룬다.

--release 플래그와 함께 릴리스 모드로 컴파일할 때, Rust는 패닉을 일으키는 정수 오버플로우 검사를 포함하지 않는다. 대신, 오버플로우가 발생하면 Rust는 _2의 보수 래핑_을 수행한다. 간단히 말해, 타입이 저장할 수 있는 최대값보다 큰 값은 타입이 저장할 수 있는 최소값으로 “래핑“된다. u8의 경우, 값 256은 0이 되고, 257은 1이 되는 식이다. 프로그램은 패닉을 일으키지 않지만, 변수는 예상치 못한 값을 가질 수 있다. 정수 오버플로우의 래핑 동작에 의존하는 것은 오류로 간주된다.

오버플로우 가능성을 명시적으로 처리하려면, 기본 숫자 타입을 위한 표준 라이브러리에서 제공하는 다음과 같은 메서드 패밀리를 사용할 수 있다:

  • 모든 모드에서 래핑을 수행하는 wrapping_* 메서드, 예를 들어 wrapping_add.
  • 오버플로우가 발생하면 None 값을 반환하는 checked_* 메서드.
  • 값과 오버플로우 발생 여부를 나타내는 불리언 값을 반환하는 overflowing_* 메서드.
  • 값의 최소 또는 최대값에서 포화시키는 saturating_* 메서드.

부동소수점 타입

Rust는 소수점이 있는 숫자인 _부동소수점 숫자_를 표현하기 위해 두 가지 기본 타입을 제공한다. Rust의 부동소수점 타입은 f32f64로, 각각 32비트와 64비트 크기를 가진다. 기본 타입은 f64인데, 현대 CPU에서는 f32와 거의 같은 속도를 내면서 더 높은 정밀도를 제공하기 때문이다. 모든 부동소수점 타입은 부호를 가진다.

다음은 부동소수점 숫자를 사용하는 예제이다:

파일명: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

부동소수점 숫자는 IEEE-754 표준에 따라 표현된다.

숫자 연산

Rust는 모든 숫자 타입에 대해 기본적인 수학 연산을 지원한다. 덧셈, 뺄셈, 곱셈, 나눗셈, 그리고 나머지 연산이 포함된다. 정수 나눗셈의 경우, 0 방향으로 가장 가까운 정수로 버림 처리된다. 아래 코드는 let 문에서 각 숫자 연산을 어떻게 사용하는지 보여준다:

파일명: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;
    let truncated = -5 / 3; // Results in -1

    // remainder
    let remainder = 43 % 5;
}

이 문장의 각 표현식은 수학 연산자를 사용하며, 단일 값으로 평가된 후 변수에 바인딩된다. 부록 B에는 Rust가 제공하는 모든 연산자 목록이 포함되어 있다.

불리언 타입

대부분의 프로그래밍 언어와 마찬가지로, Rust에서 불리언 타입은 두 가지 가능한 값을 가진다: truefalse. 불리언은 1바이트 크기를 차지한다. Rust에서 불리언 타입은 bool로 지정한다. 예를 들어:

Filename: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

불리언 값을 사용하는 주요 방법은 if 표현식과 같은 조건문을 통해서다. Rust에서 if 표현식이 어떻게 동작하는지는 “제어 흐름” 섹션에서 다룬다.

문자 타입

Rust의 char 타입은 언어에서 가장 기본적인 알파벳 타입이다. 다음은 char 값을 선언하는 몇 가지 예제이다:

파일명: src/main.rs

fn main() {
    let c = 'z';
    let z: char = 'ℤ'; // with explicit type annotation
    let heart_eyed_cat = '😻';
}

문자열 리터럴은 쌍따옴표를 사용하지만, char 리터럴은 작은따옴표를 사용한다는 점에 유의한다. Rust의 char 타입은 4바이트 크기이며, 유니코드 스칼라 값을 나타낸다. 이는 단순한 ASCII를 넘어 다양한 문자를 표현할 수 있음을 의미한다. 악센트가 있는 문자, 중국어, 일본어, 한국어 문자, 이모지, 그리고 너비가 없는 공백까지 모두 Rust에서 유효한 char 값이다. 유니코드 스칼라 값은 U+0000부터 U+D7FF, 그리고 U+E000부터 U+10FFFF까지 포함한다. 그러나 유니코드에서 “문자“는 실제로 개념이 아니기 때문에, 여러분이 생각하는 “문자“와 Rust의 char가 일치하지 않을 수 있다. 이 주제에 대해서는 8장의 “문자열에 UTF-8 인코딩 텍스트 저장하기”에서 자세히 다룰 것이다.

복합 타입(Compound Types)

복합 타입은 여러 값을 하나의 타입으로 묶을 수 있다. Rust는 두 가지 기본 복합 타입을 제공한다: 튜플(tuples)과 배열(arrays).

튜플 타입

_튜플_은 다양한 타입의 값을 하나의 복합 타입으로 묶는 일반적인 방법이다. 튜플은 고정된 길이를 가지며, 한번 선언되면 크기를 늘리거나 줄일 수 없다.

튜플을 생성하려면 괄호 안에 쉼표로 구분된 값 목록을 작성한다. 튜플의 각 위치에는 타입이 있으며, 튜플 내의 서로 다른 값들의 타입이 같을 필요는 없다. 다음 예제에서는 선택적 타입 어노테이션을 추가했다:

파일명: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

변수 tup은 튜플 전체에 바인딩된다. 튜플은 단일 복합 엘리먼트로 간주되기 때문이다. 튜플에서 개별 값을 추출하려면 패턴 매칭을 사용해 튜플 값을 구조 분해할 수 있다. 예를 들어 다음과 같다:

파일명: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {y}");
}

이 프로그램은 먼저 튜플을 생성하고 변수 tup에 바인딩한다. 그런 다음 let과 함께 패턴을 사용해 tup을 세 개의 개별 변수 x, y, z로 분해한다. 이를 _구조 분해_라고 부르며, 단일 튜플을 세 부분으로 나누는 과정이다. 마지막으로 프로그램은 y의 값인 6.4를 출력한다.

또한, 마침표(.) 뒤에 접근하려는 값의 인덱스를 사용해 튜플 요소에 직접 접근할 수도 있다. 예를 들어:

파일명: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

이 프로그램은 튜플 x를 생성한 후 각 요소에 해당하는 인덱스를 사용해 튜플의 각 요소에 접근한다. 대부분의 프로그래밍 언어와 마찬가지로 튜플의 첫 번째 인덱스는 0이다.

값이 없는 튜플은 특별한 이름인 _유닛_을 가진다. 이 값과 해당 타입은 모두 ()로 표기되며, 빈 값이나 빈 반환 타입을 나타낸다. 표현식이 다른 값을 반환하지 않으면 암묵적으로 유닛 값을 반환한다.

배열 타입

여러 값을 담는 또 다른 방법은 _배열_을 사용하는 것이다. 튜플과 달리 배열의 모든 요소는 같은 타입이어야 한다. 다른 언어의 배열과는 다르게, Rust의 배열은 길이가 고정되어 있다.

배열의 값은 대괄호 안에 쉼표로 구분된 목록으로 작성한다:

파일명: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

배열은 데이터를 스택에 할당하고 싶을 때 유용하다. 이는 지금까지 살펴본 다른 타입들과 마찬가지로, 힙이 아닌 스택에 데이터를 할당한다는 의미이다(스택과 힙에 대해서는 4장에서 더 자세히 다룬다). 또는 요소의 개수가 항상 고정되어 있어야 할 때도 배열을 사용한다. 하지만 배열은 벡터 타입만큼 유연하지 않다. _벡터_는 표준 라이브러리에서 제공하는 유사한 컬렉션 타입으로, 크기가 늘어나거나 줄어들 수 있다. 배열과 벡터 중 어떤 것을 사용해야 할지 확신이 서지 않는다면, 대부분의 경우 벡터를 사용하는 것이 좋다. 8장에서 벡터에 대해 더 자세히 다룬다.

그러나 요소의 개수가 변하지 않을 것이라고 확신할 때는 배열이 더 유용하다. 예를 들어, 프로그램에서 월 이름을 사용한다면, 항상 12개의 요소를 포함할 것이므로 벡터보다는 배열을 사용할 가능성이 높다:

#![allow(unused)]
fn main() {
let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
}

배열의 타입은 대괄호 안에 각 요소의 타입, 세미콜론, 그리고 배열의 요소 개수를 순서대로 작성하여 표현한다:

#![allow(unused)]
fn main() {
let a: [i32; 5] = [1, 2, 3, 4, 5];
}

여기서 i32는 각 요소의 타입이다. 세미콜론 뒤의 숫자 5는 배열이 5개의 요소를 포함한다는 것을 나타낸다.

또한, 모든 요소를 같은 값으로 초기화할 수도 있다. 초기값을 지정한 뒤, 세미콜론과 배열의 길이를 대괄호 안에 작성하면 된다:

#![allow(unused)]
fn main() {
let a = [3; 5];
}

이렇게 하면 a라는 배열은 초기값으로 3을 가진 5개의 요소를 포함하게 된다. 이는 let a = [3, 3, 3, 3, 3];와 동일하지만, 더 간결한 표현 방식이다.

배열 요소 접근

배열은 고정된 크기의 메모리 덩어리로, 스택에 할당할 수 있다. 배열의 요소는 인덱스를 사용해 접근할 수 있다. 예를 들면 다음과 같다:

파일명: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

이 예제에서 first라는 변수는 배열의 인덱스 [0]에 있는 값 1을 가진다. second라는 변수는 배열의 인덱스 [1]에 있는 값 2를 가진다.

잘못된 배열 요소 접근

배열의 끝을 넘어선 요소에 접근하려고 하면 어떤 일이 발생하는지 살펴보자. 2장의 숫자 맞추기 게임과 유사하게, 사용자로부터 배열의 인덱스를 입력받는 다음 코드를 실행한다고 가정해 보자:

파일명: src/main.rs

use std::io;

fn main() {
    let a = [1, 2, 3, 4, 5];

    println!("Please enter an array index.");

    let mut index = String::new();

    io::stdin()
        .read_line(&mut index)
        .expect("Failed to read line");

    let index: usize = index
        .trim()
        .parse()
        .expect("Index entered was not a number");

    let element = a[index];

    println!("The value of the element at index {index} is: {element}");
}

이 코드는 성공적으로 컴파일된다. cargo run을 사용해 이 코드를 실행하고 0, 1, 2, 3, 또는 4를 입력하면 프로그램은 해당 인덱스에 있는 배열의 값을 출력한다. 그러나 배열의 끝을 넘어선 숫자, 예를 들어 10을 입력하면 다음과 같은 출력을 확인할 수 있다:

thread 'main' panicked at src/main.rs:19:19:
index out of bounds: the len is 5 but the index is 10
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

프로그램은 잘못된 값을 사용해 인덱싱을 시도하는 지점에서 런타임 오류를 발생시켰다. 프로그램은 오류 메시지를 출력하고 마지막 println! 문을 실행하지 않은 채 종료되었다. Rust는 인덱싱을 통해 요소에 접근하려고 할 때, 지정한 인덱스가 배열의 길이보다 작은지 확인한다. 인덱스가 배열의 길이보다 크거나 같으면 Rust는 패닉을 발생시킨다. 이 검사는 런타임에 이루어져야 하는데, 특히 이 경우에는 컴파일러가 사용자가 나중에 코드를 실행할 때 어떤 값을 입력할지 알 수 없기 때문이다.

이는 Rust의 메모리 안전성 원칙이 실제로 작동하는 예시이다. 많은 저수준 언어에서는 이러한 검사를 수행하지 않으며, 잘못된 인덱스를 제공하면 유효하지 않은 메모리에 접근할 수 있다. Rust는 이와 같은 오류를 방지하기 위해 메모리 접근을 허용하고 계속 실행하는 대신 즉시 프로그램을 종료한다. 9장에서는 Rust의 오류 처리에 대해 더 자세히 다루며, 패닉을 발생시키지 않고 유효하지 않은 메모리 접근을 허용하지 않는 읽기 쉽고 안전한 코드를 작성하는 방법을 설명한다.

함수

Rust 코드에서 함수는 매우 흔하게 사용된다. 여러분은 이미 언어에서 가장 중요한 함수 중 하나인 main 함수를 보았다. 이 함수는 많은 프로그램의 진입점이다. 또한 새로운 함수를 선언할 수 있는 fn 키워드도 보았다.

Rust 코드는 함수와 변수 이름에 관례적으로 _스네이크 케이스_를 사용한다. 스네이크 케이스는 모든 글자를 소문자로 쓰고 단어 사이에 밑줄을 넣는 방식이다. 다음은 함수 정의 예제가 포함된 프로그램이다:

파일명: src/main.rs

fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("Another function.");
}

Rust에서 함수를 정의하려면 fn 키워드 뒤에 함수 이름과 괄호를 붙인다. 중괄호는 함수의 시작과 끝을 컴파일러에게 알려준다.

정의한 함수는 이름 뒤에 괄호를 붙여 호출할 수 있다. another_function은 프로그램 내에 정의되어 있으므로 main 함수 안에서 호출할 수 있다. 소스 코드에서 another_functionmain 함수 뒤에 정의했지만, 앞에 정의해도 상관없다. Rust는 함수가 어디에 정의되었는지 신경 쓰지 않는다. 단지 호출자가 볼 수 있는 스코프 안에 정의되어 있기만 하면 된다.

함수를 더 자세히 알아보기 위해 _functions_라는 이름의 새 바이너리 프로젝트를 시작해 보자. another_function 예제를 _src/main.rs_에 넣고 실행한다. 다음과 같은 출력을 볼 수 있다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/functions`
Hello, world!
Another function.

main 함수에 나타나는 순서대로 코드가 실행된다. 먼저 “Hello, world!” 메시지가 출력되고, 그 다음 another_function이 호출되어 메시지가 출력된다.

매개변수

함수는 _매개변수_를 가질 수 있다. 매개변수는 함수 시그니처의 일부인 특별한 변수다. 함수가 매개변수를 가지면, 해당 매개변수에 구체적인 값을 전달할 수 있다. 엄밀히 말하면, 이 구체적인 값은 _인자_라고 부르지만, 일상적인 대화에서는 _매개변수_와 _인자_라는 용어를 혼용해서 사용하기도 한다. 이 용어들은 함수 정의에서의 변수를 가리키거나, 함수를 호출할 때 전달하는 구체적인 값을 의미할 수 있다.

이번 버전의 another_function에서는 매개변수를 추가했다:

파일명: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {x}");
}

이 프로그램을 실행해 보면 다음과 같은 출력을 확인할 수 있다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.21s
     Running `target/debug/functions`
The value of x is: 5

another_function의 선언에는 x라는 이름의 매개변수가 하나 있다. x의 타입은 i32로 지정되어 있다. another_function5를 전달하면, println! 매크로는 형식 문자열에서 x를 포함한 중괄호 쌍을 5로 대체한다.

함수 시그니처에서는 각 매개변수의 타입을 반드시 선언해야 한다. 이는 Rust 설계에서 의도적으로 결정된 부분이다. 함수 정의에서 타입 주석을 요구함으로써, 컴파일러는 코드의 다른 부분에서 타입을 추론하기 위해 주석을 사용할 필요가 거의 없어진다. 또한 컴파일러는 함수가 기대하는 타입을 알고 있기 때문에 더 유용한 에러 메시지를 제공할 수 있다.

여러 매개변수를 정의할 때는 쉼표로 구분하여 매개변수 선언을 나열한다:

파일명: src/main.rs

fn main() {
    print_labeled_measurement(5, 'h');
}

fn print_labeled_measurement(value: i32, unit_label: char) {
    println!("The measurement is: {value}{unit_label}");
}

이 예제는 print_labeled_measurement라는 이름의 함수를 만들고, 두 개의 매개변수를 정의한다. 첫 번째 매개변수는 value라는 이름의 i32 타입이고, 두 번째 매개변수는 unit_label이라는 이름의 char 타입이다. 이 함수는 valueunit_label을 모두 포함한 텍스트를 출력한다.

이 코드를 실행해 보자. 현재 functions 프로젝트의 src/main.rs 파일에 있는 프로그램을 앞의 예제로 교체하고 cargo run을 사용해 실행한다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/functions`
The measurement is: 5h

value5를, unit_label'h'를 전달했기 때문에, 프로그램의 출력에는 이 값들이 포함된다.

구문과 표현식

함수의 본문은 일련의 구문으로 구성되며, 선택적으로 표현식으로 끝난다. 지금까지 다룬 함수들은 마지막에 표현식을 포함하지 않았지만, 구문의 일부로 표현식을 본 적이 있다. Rust는 표현식 기반 언어이기 때문에 이 차이를 이해하는 것이 중요하다. 다른 언어들은 동일한 구분을 가지고 있지 않으므로, 구문과 표현식이 무엇인지 그리고 그 차이가 함수 본문에 어떤 영향을 미치는지 살펴보자.

  • 구문은 어떤 동작을 수행하지만 값을 반환하지 않는 명령이다.
  • 표현식은 결과 값을 평가한다. 몇 가지 예를 살펴보자.

이미 구문과 표현식을 사용해본 적이 있다. let 키워드를 사용해 변수를 생성하고 값을 할당하는 것은 구문이다. 예제 3-1에서 let y = 6;은 구문이다.

Filename: src/main.rs
fn main() {
    let y = 6;
}
Listing 3-1: 하나의 구문을 포함한 main 함수 선언

함수 정의도 구문이다. 앞의 예제 전체가 하나의 구문이다. (아래에서 보게 될 것처럼, 함수를 _호출_하는 것은 구문이 아니다.)

구문은 값을 반환하지 않는다. 따라서 let 구문을 다른 변수에 할당하려고 하면 오류가 발생한다. 다음 코드는 이를 시도했을 때 발생하는 오류를 보여준다:

Filename: src/main.rs

fn main() {
    let x = (let y = 6);
}

이 프로그램을 실행하면 다음과 같은 오류가 발생한다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found `let` statement
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: only supported directly in conditions of `if` and `while` expressions

warning: unnecessary parentheses around assigned value
 --> src/main.rs:2:13
  |
2 |     let x = (let y = 6);
  |             ^         ^
  |
  = note: `#[warn(unused_parens)]` on by default
help: remove these parentheses
  |
2 -     let x = (let y = 6);
2 +     let x = let y = 6;
  |

warning: `functions` (bin "functions") generated 1 warning
error: could not compile `functions` (bin "functions") due to 1 previous error; 1 warning emitted

let y = 6 구문은 값을 반환하지 않기 때문에 x가 바인딩할 값이 없다. 이는 C나 Ruby와 같은 다른 언어와 다르다. 그런 언어에서는 할당이 할당된 값을 반환하기 때문에 x = y = 6과 같이 작성하면 xy 모두 값 6을 가진다. Rust에서는 그렇지 않다.

표현식은 값을 평가하며, Rust에서 작성할 코드의 대부분을 차지한다. 예를 들어 5 + 6과 같은 수학 연산은 값 11로 평가되는 표현식이다. 표현식은 구문의 일부가 될 수 있다. 예제 3-1에서 let y = 6; 구문의 6은 값 6으로 평가되는 표현식이다. 함수 호출은 표현식이다. 매크로 호출도 표현식이다. 중괄호로 생성된 새로운 스코프 블록도 표현식이다. 예를 들어:

Filename: src/main.rs

fn main() {
    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {y}");
}

이 표현식:

{
    let x = 3;
    x + 1
}

은 이 경우 4로 평가되는 블록이다. 이 값은 let 구문의 일부로 y에 바인딩된다. x + 1 줄 끝에 세미콜론이 없다는 점에 주목하자. 지금까지 본 대부분의 줄과 다르다. 표현식은 끝에 세미콜론을 포함하지 않는다. 표현식 끝에 세미콜론을 추가하면 구문으로 바뀌며 더 이상 값을 반환하지 않는다. 다음에 함수 반환 값과 표현식을 탐구할 때 이 점을 기억하자.

값을 반환하는 함수

함수는 호출한 코드에 값을 반환할 수 있다. 반환 값에 이름을 붙이지는 않지만, 화살표(->) 뒤에 반환 값의 타입을 명시해야 한다. Rust에서 함수의 반환 값은 함수 본문의 마지막 표현식의 값과 동일하다. return 키워드를 사용해 함수를 조기에 종료하고 값을 반환할 수도 있지만, 대부분의 함수는 마지막 표현식을 암묵적으로 반환한다. 다음은 값을 반환하는 함수의 예시다:

파일명: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {x}");
}

five 함수에는 함수 호출, 매크로, 심지어 let 문도 없다. 단순히 숫자 5만 존재한다. 이는 Rust에서 완벽하게 유효한 함수다. 함수의 반환 타입이 -> i32로 지정된 것에 주목하라. 이 코드를 실행해 보면 다음과 같은 출력이 나온다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/functions`
The value of x is: 5

five 함수의 5는 함수의 반환 값이며, 반환 타입이 i32인 이유다. 이를 더 자세히 살펴보자. 두 가지 중요한 점이 있다: 첫째, let x = five(); 라인은 함수의 반환 값을 사용해 변수를 초기화하는 것을 보여준다. five 함수가 5를 반환하기 때문에, 이 라인은 다음과 동일하다:

#![allow(unused)]
fn main() {
let x = 5;
}

둘째, five 함수는 매개변수가 없고 반환 값의 타입을 정의하지만, 함수 본문은 세미콜론이 없는 단순한 5다. 이는 반환하고자 하는 값의 표현식이기 때문이다.

다른 예시를 살펴보자:

파일명: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

이 코드를 실행하면 The value of x is: 6이 출력된다. 하지만 x + 1이 포함된 라인 끝에 세미콜론을 추가해 표현식에서 문장으로 변경하면 오류가 발생한다:

파일명: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {x}");
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

이 코드를 컴파일하면 다음과 같은 오류가 발생한다:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error[E0308]: mismatched types
 --> src/main.rs:7:24
  |
7 | fn plus_one(x: i32) -> i32 {
  |    --------            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
8 |     x + 1;
  |          - help: remove this semicolon to return this value

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions` (bin "functions") due to 1 previous error

주요 오류 메시지인 mismatched types는 이 코드의 핵심 문제를 나타낸다. plus_one 함수의 정의는 i32를 반환한다고 명시하지만, 문장은 값으로 평가되지 않으며, 이는 ()(유닛 타입)로 표현된다. 따라서 아무것도 반환되지 않아 함수 정의와 모순되고 오류가 발생한다. 이 출력에서 Rust는 이 문제를 해결하기 위해 세미콜론을 제거하라는 메시지를 제공한다. 이렇게 하면 오류가 수정된다.

주석

모든 프로그래머는 자신의 코드를 이해하기 쉽게 만들기 위해 노력하지만, 때로는 추가적인 설명이 필요하다. 이럴 때 프로그래머는 소스 코드에 _주석_을 남긴다. 주석은 컴파일러는 무시하지만, 소스 코드를 읽는 사람들에게 유용한 정보를 제공한다.

다음은 간단한 주석의 예시다:

#![allow(unused)]
fn main() {
// hello, world
}

Rust에서 관용적으로 사용하는 주석 스타일은 두 개의 슬래시(//)로 시작하며, 주석은 해당 줄의 끝까지 이어진다. 여러 줄에 걸친 주석을 작성하려면 각 줄마다 //를 포함해야 한다:

#![allow(unused)]
fn main() {
// 여기서는 복잡한 작업을 수행하고 있습니다. 이 작업은
// 여러 줄의 주석이 필요할 정도로 길죠! 흠! 이 주석이
// 무슨 일이 벌어지고 있는지 설명해 주길 바랍니다.
}

주석은 코드가 있는 줄의 끝에도 추가할 수 있다:

Filename: src/main.rs

fn main() {
    let lucky_number = 7; // I'm feeling lucky today
}

하지만 주석은 보통 해당 코드 위에 별도의 줄로 작성하는 형식을 더 자주 볼 수 있다:

Filename: src/main.rs

fn main() {
    // I'm feeling lucky today
    let lucky_number = 7;
}

Rust에는 또 다른 종류의 주석인 문서 주석도 있다. 이에 대해서는 14장의 “Publishing a Crate to Crates.io” 섹션에서 다룬다.

제어 흐름

조건이 true인지에 따라 특정 코드를 실행하거나, 조건이 true인 동안 코드를 반복적으로 실행하는 기능은 대부분의 프로그래밍 언어에서 기본적인 구성 요소이다. Rust 코드의 실행 흐름을 제어하는 가장 일반적인 구조는 if 표현식과 반복문(loop)이다.

if 표현식

if 표현식을 사용하면 조건에 따라 코드를 분기할 수 있다. 조건을 지정하고 “이 조건이 충족되면 이 코드 블록을 실행하고, 조건이 충족되지 않으면 이 코드 블록을 실행하지 않는다“고 명시한다.

if 표현식을 살펴보기 위해 projects 디렉토리에 _branches_라는 새 프로젝트를 생성한다. src/main.rs 파일에 다음 코드를 입력한다:

파일명: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

모든 if 표현식은 if 키워드로 시작하며, 그 뒤에 조건이 온다. 이 경우 조건은 변수 number의 값이 5보다 작은지 확인한다. 조건이 true일 때 실행할 코드 블록은 조건 바로 뒤에 중괄호로 감싸서 작성한다. if 표현식에서 조건과 관련된 코드 블록은 때때로 arms(가지)라고 부르며, 이는 2장의 “추측한 값과 비밀번호 비교하기” 섹션에서 다룬 match 표현식의 가지와 유사하다.

선택적으로 else 표현식을 포함할 수 있으며, 여기서는 조건이 false일 때 실행할 대체 코드 블록을 제공하기 위해 else를 사용했다. else 표현식을 제공하지 않고 조건이 false인 경우, 프로그램은 if 블록을 건너뛰고 다음 코드로 이동한다.

이 코드를 실행하면 다음과 같은 출력을 확인할 수 있다:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was true

이제 number의 값을 변경하여 조건이 false가 되도록 해보자:

fn main() {
    let number = 7;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

프로그램을 다시 실행하고 출력을 확인한다:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
condition was false

또한 이 코드에서 조건은 반드시 bool 타입이어야 한다. 조건이 bool 타입이 아닌 경우 오류가 발생한다. 예를 들어, 다음 코드를 실행해보자:

파일명: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

이번에는 if 조건이 3으로 평가되며, Rust는 오류를 발생시킨다:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected `bool`, found integer

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` (bin "branches") due to 1 previous error

오류는 Rust가 bool 타입을 기대했지만 정수를 받았다는 것을 나타낸다. Ruby나 JavaScript와 같은 언어와 달리 Rust는 비불리언 타입을 자동으로 불리언으로 변환하지 않는다. 명시적으로 if 조건에 불리언 값을 제공해야 한다. 예를 들어, if 코드 블록이 숫자가 0이 아닐 때만 실행되도록 하려면 if 표현식을 다음과 같이 변경할 수 있다:

파일명: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

이 코드를 실행하면 number was something other than zero가 출력된다.

여러 조건을 else if로 처리하기

ifelse를 조합하여 else if 표현식으로 여러 조건을 처리할 수 있다. 예를 들어:

파일명: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

이 프로그램은 네 가지 가능한 경로를 가진다. 실행한 후 다음과 같은 출력을 확인할 수 있다:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.31s
     Running `target/debug/branches`
number is divisible by 3

이 프로그램이 실행되면, 각 if 표현식을 순서대로 확인하고 조건이 true로 평가되는 첫 번째 본문을 실행한다. 6은 2로 나누어 떨어지지만, number is divisible by 2라는 출력이 나타나지 않는다. 또한 else 블록의 number is not divisible by 4, 3, or 2라는 텍스트도 보이지 않는다. 이는 Rust가 첫 번째 true 조건에 해당하는 블록만 실행하고, 한 번 조건을 찾으면 나머지는 확인하지 않기 때문이다.

너무 많은 else if 표현식을 사용하면 코드가 복잡해질 수 있다. 따라서 else if가 여러 개 있다면 코드를 리팩토링하는 것이 좋다. 6장에서는 이러한 경우에 유용한 Rust의 강력한 분기 구조인 match를 소개한다.

let 문에서 if 사용하기

if는 표현식이므로, let 문의 오른쪽에 사용하여 결과를 변수에 할당할 수 있다. Listing 3-2에서 이를 확인할 수 있다.

Filename: src/main.rs
fn main() {
    let condition = true;
    let number = if condition { 5 } else { 6 };

    println!("The value of number is: {number}");
}
Listing 3-2: if 표현식의 결과를 변수에 할당하기

number 변수는 if 표현식의 결과에 따라 값이 결정된다. 이 코드를 실행하면 다음과 같은 결과를 볼 수 있다:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.30s
     Running `target/debug/branches`
The value of number is: 5

코드 블록은 마지막 표현식의 값으로 평가되며, 숫자 자체도 표현식이다. 이 경우, 전체 if 표현식의 값은 실행되는 코드 블록에 따라 달라진다. 이는 if의 각 분기에서 반환될 수 있는 값의 타입이 동일해야 한다는 것을 의미한다. Listing 3-2에서는 if 분기와 else 분기의 결과가 모두 i32 정수였다. 만약 타입이 일치하지 않으면, 다음과 같은 예제에서 볼 수 있듯이 오류가 발생한다:

Filename: src/main.rs

fn main() {
    let condition = true;

    let number = if condition { 5 } else { "six" };

    println!("The value of number is: {number}");
}

이 코드를 컴파일하려고 하면 오류가 발생한다. if 분기와 else 분기의 값 타입이 호환되지 않으며, Rust는 프로그램 내에서 문제가 발생한 정확한 위치를 알려준다:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:4:44
  |
4 |     let number = if condition { 5 } else { "six" };
  |                                 -          ^^^^^ expected integer, found `&str`
  |                                 |
  |                                 expected because of this

For more information about this error, try `rustc --explain E0308`.
error: could not compile `branches` (bin "branches") due to 1 previous error

if 블록의 표현식은 정수로 평가되고, else 블록의 표현식은 문자열로 평가된다. 이는 변수가 단일 타입을 가져야 하며, Rust는 컴파일 시점에 number 변수의 타입을 명확히 알아야 하기 때문에 작동하지 않는다. number의 타입을 알면 컴파일러는 number를 사용하는 모든 곳에서 타입이 유효한지 검증할 수 있다. 만약 number의 타입이 런타임에 결정된다면 Rust는 이를 수행할 수 없을 것이다. 컴파일러는 더 복잡해지고, 변수에 대해 여러 가상의 타입을 추적해야 하기 때문에 코드에 대한 보장이 줄어들 것이다.

반복문 활용하기

특정 코드 블록을 여러 번 실행해야 할 때가 있다. 이를 위해 Rust는 여러 종류의 반복문을 제공한다. 반복문은 코드 블록을 처음부터 끝까지 실행한 후, 다시 처음으로 돌아가 반복한다. 반복문을 실험해보기 위해 _loops_라는 새로운 프로젝트를 만들어보자.

Rust는 세 가지 종류의 반복문을 지원한다: loop, while, for. 각각의 반복문을 하나씩 살펴보자.

loop로 코드 반복하기

loop 키워드는 코드 블록을 무한히 반복하거나 명시적으로 중단할 때까지 계속 실행하도록 지시한다.

예를 들어, loops 디렉토리의 src/main.rs 파일을 다음과 같이 수정해본다:

파일명: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

이 프로그램을 실행하면 again!이 계속 출력되며, 수동으로 프로그램을 중단할 때까지 반복된다. 대부분의 터미널에서는 ctrl-c 단축키를 사용해 무한 루프에 빠진 프로그램을 중단할 수 있다. 직접 시도해보자:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

^C 기호는 ctrl-c를 누른 위치를 나타낸다. ^C 이후에 again!이 출력될 수도 있고 그렇지 않을 수도 있는데, 이는 인터럽트 시그널을 받았을 때 코드가 루프의 어느 위치에 있었는지에 따라 달라진다.

다행히 Rust는 코드를 통해 루프를 탈출할 수 있는 방법도 제공한다. 루프 내부에 break 키워드를 사용하면 프로그램이 언제 루프 실행을 멈출지 지정할 수 있다. 이전에 2장의 “정답을 맞힌 후 종료하기” 섹션에서 사용자가 정답을 맞혀 게임에서 승리했을 때 프로그램을 종료하기 위해 이 방법을 사용했던 것을 떠올려보자.

또한, 추측 게임에서 continue를 사용했는데, 이는 루프 내에서 남은 코드를 건너뛰고 다음 반복으로 넘어가도록 지시한다.

루프에서 값 반환하기

loop의 주요 용도 중 하나는 실패할 가능성이 있는 작업을 반복적으로 시도하는 것이다. 예를 들어 스레드가 작업을 완료했는지 확인하는 경우가 이에 해당한다. 이때 작업의 결과를 루프 밖의 코드로 전달해야 할 수도 있다. 이를 위해 루프를 멈추는 break 표현식 뒤에 반환할 값을 추가하면 된다. 이 값은 루프 밖으로 반환되어 코드에서 사용할 수 있다. 다음 예제를 보자:

fn main() {
    let mut counter = 0;

    let result = loop {
        counter += 1;

        if counter == 10 {
            break counter * 2;
        }
    };

    println!("The result is {result}");
}

루프를 시작하기 전에 counter라는 변수를 선언하고 0으로 초기화한다. 그다음 루프에서 반환된 값을 저장할 result 변수를 선언한다. 루프가 반복될 때마다 counter 변수에 1을 더하고, counter10인지 확인한다. counter10이 되면 break 키워드와 함께 counter * 2 값을 반환한다. 루프가 끝난 후 result에 값을 할당하는 문장을 세미콜론으로 마친다. 마지막으로 result의 값을 출력하는데, 이 경우 20이 출력된다.

또한 루프 내부에서 return을 사용할 수도 있다. break는 현재 루프만 종료하지만, return은 현재 함수를 완전히 종료한다.

여러 루프를 구분하기 위한 루프 라벨

루프 안에 또 다른 루프가 중첩된 경우, breakcontinue는 해당 위치에서 가장 안쪽에 있는 루프에 적용된다. 선택적으로 루프에 _루프 라벨_을 지정할 수 있으며, 이 라벨을 breakcontinue와 함께 사용하면 해당 키워드가 가장 안쪽 루프가 아닌 라벨이 지정된 루프에 적용된다. 루프 라벨은 작은따옴표(')로 시작해야 한다. 다음은 두 개의 중첩된 루프를 사용한 예제다:

fn main() {
    let mut count = 0;
    'counting_up: loop {
        println!("count = {count}");
        let mut remaining = 10;

        loop {
            println!("remaining = {remaining}");
            if remaining == 9 {
                break;
            }
            if count == 2 {
                break 'counting_up;
            }
            remaining -= 1;
        }

        count += 1;
    }
    println!("End count = {count}");
}

바깥쪽 루프는 'counting_up이라는 라벨을 가지며, 0부터 2까지 카운트업한다. 라벨이 없는 안쪽 루프는 10부터 9까지 카운트다운한다. 라벨을 지정하지 않은 첫 번째 break는 안쪽 루프만 종료한다. break 'counting_up; 문은 바깥쪽 루프를 종료한다. 이 코드는 다음과 같이 출력된다:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running `target/debug/loops`
count = 0
remaining = 10
remaining = 9
count = 1
remaining = 10
remaining = 9
count = 2
remaining = 10
End count = 2

while을 활용한 조건부 반복

프로그램은 종종 반복문 안에서 조건을 평가해야 한다. 조건이 true인 동안 반복문이 실행된다. 조건이 더 이상 true가 아니면 프로그램은 break를 호출해 반복문을 멈춘다. loop, if, else, break를 조합해 이런 동작을 구현할 수 있다. 원한다면 지금 바로 프로그램에서 시도해 볼 수 있다. 하지만 이 패턴은 너무 흔해서 Rust는 이를 위해 while 루프라는 내장 언어 구문을 제공한다. 예제 3-3에서 while을 사용해 프로그램을 세 번 반복하며 매번 카운트다운을 하고, 반복문이 끝난 후 메시지를 출력하고 종료한다.

Filename: src/main.rs
fn main() {
    let mut number = 3;

    while number != 0 {
        println!("{number}!");

        number -= 1;
    }

    println!("LIFTOFF!!!");
}
Listing 3-3: 조건이 true인 동안 코드를 실행하기 위해 while 루프 사용

이 구문은 loop, if, else, break를 사용할 때 필요한 많은 중첩을 제거하고 코드를 더 명확하게 만든다. 조건이 true인 동안 코드가 실행되고, 그렇지 않으면 반복문을 빠져나온다.

for 루프를 사용해 컬렉션 순회하기

컬렉션의 요소를 순회할 때 while 구문을 사용할 수도 있다. 예를 들어, 아래 코드는 배열 a의 각 요소를 출력한다.

Filename: src/main.rs
fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index += 1;
    }
}
Listing 3-4: while 루프를 사용해 컬렉션의 각 요소 순회하기

이 코드는 배열의 요소를 처음부터 끝까지 순회한다. 인덱스 0에서 시작해 배열의 마지막 인덱스에 도달할 때까지(index < 5true인 동안) 반복한다. 이 코드를 실행하면 배열의 모든 요소가 출력된다:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.32s
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

예상대로 배열의 다섯 값이 모두 터미널에 출력된다. index 값이 5에 도달할 수는 있지만, 배열에서 여섯 번째 값을 가져오려고 시도하기 전에 루프가 종료된다.

하지만 이 방식은 오류가 발생하기 쉽다. 인덱스 값이나 조건이 잘못되면 프로그램이 패닉 상태에 빠질 수 있다. 예를 들어, 배열 a를 네 개의 요소로 변경했는데 조건을 while index < 4로 업데이트하지 않으면 코드가 패닉 상태에 빠진다. 또한 이 방식은 느린데, 컴파일러가 매 반복마다 인덱스가 배열의 범위 내에 있는지 조건을 확인하는 런타임 코드를 추가하기 때문이다.

더 간결한 대안으로 for 루프를 사용해 컬렉션의 각 항목에 대해 코드를 실행할 수 있다. for 루프는 아래 코드와 같다.

Filename: src/main.rs
fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a {
        println!("the value is: {element}");
    }
}
Listing 3-5: for 루프를 사용해 컬렉션의 각 요소 순회하기

이 코드를 실행하면 Listing 3-4와 동일한 결과가 출력된다. 더 중요한 점은 코드의 안전성이 높아졌고, 배열의 끝을 벗어나거나 일부 항목을 놓치는 버그가 발생할 가능성이 사라졌다는 것이다.

for 루프를 사용하면 배열의 값 개수를 변경할 때 다른 코드를 수정할 필요가 없다. Listing 3-4에서 사용한 방식과 달리 for 루프는 이러한 문제를 자동으로 처리한다.

for 루프의 안전성과 간결성 덕분에 Rust에서 가장 흔히 사용되는 루프 구문이다. Listing 3-3에서 while 루프를 사용한 카운트다운 예제처럼 특정 횟수만큼 코드를 실행해야 하는 경우에도 대부분의 Rust 개발자는 for 루프를 사용한다. 이를 위해 표준 라이브러리에서 제공하는 Range를 사용할 수 있다. Range는 시작 숫자부터 다른 숫자 직전까지의 모든 숫자를 순서대로 생성한다.

for 루프와 아직 다루지 않은 rev 메서드를 사용해 범위를 역순으로 바꾸면 카운트다운 코드는 아래와 같다:

Filename: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{number}!");
    }
    println!("LIFTOFF!!!");
}

이 코드가 더 깔끔하지 않은가?

요약

여러분은 이 장을 끝까지 완료했다! 이번 장에서는 변수, 스칼라와 복합 데이터 타입, 함수, 주석, if 표현식, 그리고 반복문에 대해 배웠다. 이 장에서 다룬 개념을 연습하기 위해 다음 프로그램을 만들어 보자:

  • 화씨와 섭씨 온도를 변환한다.
  • n번째 피보나치 수를 생성한다.
  • 크리스마스 캐롤 “The Twelve Days of Christmas“의 가사를 출력한다. 이때 노래의 반복 구조를 활용한다.

이제 다음 단계로 넘어갈 준비가 되었다면, Rust에서 다른 프로그래밍 언어에서는 흔히 볼 수 없는 개념인 ’소유권’에 대해 이야기할 것이다.

소유권 이해하기

소유권은 Rust의 가장 독특한 기능이며, 언어의 다른 부분에도 깊은 영향을 미친다. Rust가 가비지 컬렉터 없이도 메모리 안전성을 보장할 수 있게 해주는 핵심 개념이다. 따라서 소유권이 어떻게 동작하는지 이해하는 것은 매우 중요하다. 이 장에서는 소유권과 함께 관련된 여러 기능들, 즉 빌림, 슬라이스, 그리고 Rust가 메모리에 데이터를 배치하는 방식에 대해 다룬다.

소유권이란 무엇인가?

소유권은 Rust 프로그램이 메모리를 관리하는 방식을 규정하는 일련의 규칙이다. 모든 프로그램은 실행 중에 컴퓨터의 메모리를 어떻게 사용할지 관리해야 한다. 어떤 언어는 가비지 컬렉션을 통해 프로그램이 실행되는 동안 더 이상 사용되지 않는 메모리를 정기적으로 찾아내고, 다른 언어에서는 프로그래머가 명시적으로 메모리를 할당하고 해제해야 한다. Rust는 세 번째 방식을 사용한다. 컴파일러가 확인하는 일련의 규칙을 통해 소유권 시스템으로 메모리를 관리한다. 만약 규칙 중 하나라도 위반되면 프로그램은 컴파일되지 않는다. 소유권의 어떤 기능도 프로그램 실행 속도를 느리게 하지 않는다.

소유권은 많은 프로그래머에게 새로운 개념이기 때문에 익숙해지려면 시간이 필요하다. 다행히 Rust와 소유권 시스템의 규칙에 익숙해질수록 안전하고 효율적인 코드를 자연스럽게 작성하기가 쉬워진다. 꾸준히 노력하자!

소유권을 이해하면 Rust를 독특하게 만드는 기능들을 깊이 있게 이해할 수 있는 기반을 마련하게 된다. 이 장에서는 매우 일반적인 데이터 구조인 문자열을 중심으로 몇 가지 예제를 통해 소유권을 배운다.

스택과 힙

많은 프로그래밍 언어는 스택과 힙에 대해 자주 생각할 필요가 없다. 하지만 Rust와 같은 시스템 프로그래밍 언어에서는 값이 스택에 있는지 힙에 있는지가 언어의 동작 방식과 특정 결정을 내려야 하는 이유에 영향을 미친다. 소유권의 일부는 이 장의 뒷부분에서 스택과 힙과 관련하여 설명할 것이므로, 여기서는 간단히 설명한다.

스택과 힙은 모두 코드가 런타임에 사용할 수 있는 메모리의 일부이지만, 구조가 다르다. 스택은 값을 받은 순서대로 저장하고 반대 순서로 제거한다. 이를 *후입선출(LIFO)*이라고 한다. 접시 더미를 생각해 보자. 접시를 더 추가할 때는 더미 위에 올리고, 접시가 필요할 때는 위에서 하나를 꺼낸다. 중간이나 아래에서 접시를 추가하거나 제거하는 것은 잘 작동하지 않는다! 데이터를 추가하는 것을 스택에 푸시라고 하고, 데이터를 제거하는 것을 스택에서 팝이라고 한다. 스택에 저장된 모든 데이터는 컴파일 시점에 알려진 고정된 크기를 가져야 한다. 컴파일 시점에 크기가 알려지지 않았거나 크기가 변할 수 있는 데이터는 대신 힙에 저장해야 한다.

힙은 덜 정리되어 있다. 데이터를 힙에 넣을 때는 일정량의 공간을 요청한다. 메모리 할당자는 힙에서 충분히 큰 빈 공간을 찾아 사용 중으로 표시하고, 그 위치의 주소인 포인터를 반환한다. 이 과정을 힙에 할당이라고 하며, 때로는 단순히 할당이라고 줄여 부르기도 한다(스택에 값을 푸시하는 것은 할당으로 간주되지 않는다). 힙에 대한 포인터는 알려진 고정된 크기이기 때문에 포인터를 스택에 저장할 수 있지만, 실제 데이터를 원할 때는 포인터를 따라가야 한다. 레스토랑에 앉는 것을 생각해 보자. 들어갈 때 함께 있는 사람 수를 말하면, 호스트가 모두 앉을 수 있는 빈 테이블을 찾아 안내한다. 만약 누군가 늦게 오면, 어디에 앉았는지 물어보고 찾아올 수 있다.

스택에 푸시하는 것은 힙에 할당하는 것보다 빠르다. 할당자는 새로운 데이터를 저장할 장소를 찾을 필요가 없기 때문이다. 그 위치는 항상 스택의 맨 위에 있다. 반면 힙에 공간을 할당하려면 더 많은 작업이 필요하다. 할당자는 먼저 데이터를 담을 수 있는 충분히 큰 공간을 찾은 다음, 다음 할당을 준비하기 위해 기록을 관리해야 한다.

힙의 데이터에 접근하는 것은 스택의 데이터에 접근하는 것보다 느리다. 포인터를 따라가야 하기 때문이다. 현대 프로세서는 메모리에서 덜 이동할수록 더 빠르게 작동한다. 레스토랑의 서버가 여러 테이블에서 주문을 받는 것을 계속 비유로 생각해 보자. 한 테이블에서 모든 주문을 받고 다음 테이블로 이동하는 것이 가장 효율적이다. 테이블 A에서 주문을 받고, 테이블 B에서 주문을 받고, 다시 테이블 A에서 주문을 받고, 다시 테이블 B에서 주문을 받는 것은 훨씬 더 느린 과정이다. 마찬가지로 프로세서는 다른 데이터와 가까이 있는 데이터(스택에 있는 것처럼)를 처리할 때 더 잘 작동한다. 힙에 있는 데이터처럼 멀리 떨어진 데이터를 처리할 때는 그렇지 않다.

코드가 함수를 호출할 때, 함수에 전달된 값(힙에 있는 데이터에 대한 포인터를 포함할 수 있음)과 함수의 지역 변수는 스택에 푸시된다. 함수가 끝나면 그 값들은 스택에서 팝된다.

코드의 어떤 부분이 힙의 어떤 데이터를 사용하고 있는지 추적하고, 힙에 중복된 데이터의 양을 최소화하며, 사용되지 않는 데이터를 정리해 공간이 부족하지 않도록 하는 것은 모두 소유권이 해결하는 문제들이다. 소유권을 이해하면 스택과 힙에 대해 자주 생각할 필요가 없지만, 소유권의 주요 목적이 힙 데이터를 관리하는 것임을 알면 왜 그런 방식으로 작동하는지 이해하는 데 도움이 된다.

소유권 규칙

먼저 Rust의 소유권 규칙에 대해 살펴보자. 이 규칙들을 기억하면서 예제를 통해 자세히 알아보자:

  • Rust에서 모든 값은 _소유자_를 가진다.
  • 한 번에 하나의 소유자만 존재할 수 있다.
  • 소유자가 스코프를 벗어나면 값은 자동으로 해제된다.

변수의 스코프

기본적인 Rust 문법을 배웠으니, 이제부터는 모든 예제에 fn main() { 코드를 포함하지 않을 것이다. 따라서 예제를 따라하려면 직접 main 함수 안에 코드를 넣어야 한다. 이렇게 하면 예제가 더 간결해지고, 보일러플레이트 코드보다 실제 중요한 내용에 집중할 수 있다.

소유권의 첫 번째 예제로 변수의 _스코프_를 살펴보자. 스코프는 프로그램 내에서 항목이 유효한 범위를 의미한다. 다음 변수를 보자:

#![allow(unused)]
fn main() {
let s = "hello";
}

변수 s는 문자열 리터럴을 참조하며, 이 문자열 값은 프로그램 텍스트에 하드코딩되어 있다. 변수는 선언된 시점부터 현재 _스코프_가 끝날 때까지 유효하다. Listing 4-1은 변수 s가 유효한 범위를 주석으로 표시한 프로그램을 보여준다.

fn main() {
    {                      // s is not valid here, it’s not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}
Listing 4-1: 변수와 그 변수가 유효한 스코프

다시 말해, 여기에는 두 가지 중요한 시점이 있다:

  • s가 스코프에 들어오면 유효해진다.
  • 스코프를 벗어날 때까지 유효하다.

이 시점에서 스코프와 변수의 유효성 간의 관계는 다른 프로그래밍 언어와 유사하다. 이제 이 이해를 바탕으로 String 타입을 소개하며 더 깊이 알아볼 것이다.

String 타입

소유권 규칙을 설명하기 위해, 우리는 “데이터 타입” 섹션에서 다룬 것보다 더 복잡한 데이터 타입이 필요하다. 이전에 다룬 타입들은 크기가 고정되어 있으며, 스택에 저장되고 스코프가 끝나면 스택에서 제거된다. 또한, 다른 코드 부분에서 동일한 값을 다른 스코프에서 사용해야 할 때, 빠르고 간단하게 복사하여 독립적인 인스턴스를 만들 수 있다. 하지만 우리는 힙에 저장된 데이터를 살펴보고, Rust가 언제 그 데이터를 정리하는지 알아볼 필요가 있다. String 타입은 이를 설명하기에 적합한 예제다.

우리는 String의 소유권과 관련된 부분에 집중할 것이다. 이러한 측면은 표준 라이브러리에서 제공되거나 여러분이 직접 만든 다른 복잡한 데이터 타입에도 적용된다. String에 대해 더 깊이 다루는 내용은 8장에서 논의할 것이다.

우리는 이미 문자열 리터럴을 보았다. 문자열 리터럴은 프로그램에 하드코딩된 문자열 값이다. 문자열 리터럴은 편리하지만, 모든 상황에 적합하지는 않다. 한 가지 이유는 불변성 때문이다. 또 다른 이유는 코드를 작성할 때 모든 문자열 값을 알 수 없다는 점이다. 예를 들어, 사용자 입력을 받아 저장하려면 어떻게 해야 할까? 이러한 상황을 위해 Rust는 두 번째 문자열 타입인 String을 제공한다. 이 타입은 힙에 할당된 데이터를 관리하며, 컴파일 시점에 알 수 없는 크기의 텍스트를 저장할 수 있다. from 함수를 사용하여 문자열 리터럴로부터 String을 생성할 수 있다. 예를 들면 다음과 같다:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

더블 콜론 :: 연산자를 사용하면 String 타입 아래에 있는 특정 from 함수를 네임스페이스로 지정할 수 있다. 이는 string_from과 같은 이름을 사용하는 것보다 더 명확하다. 이 구문에 대해서는 5장의 “메서드 구문” 섹션과 7장의 “모듈 트리에서 항목을 참조하는 경로”에서 모듈과 함께 네임스페이싱을 논의할 때 더 자세히 설명할 것이다.

이러한 종류의 문자열은 변경할 수 있다:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // This will print `hello, world!`
}

그렇다면 여기서 차이점은 무엇일까? 왜 String은 변경할 수 있지만 리터럴은 변경할 수 없는가? 그 차이는 이 두 타입이 메모리를 다루는 방식에 있다.

메모리와 할당

문자열 리터럴의 경우 컴파일 시점에 내용을 알 수 있으므로, 텍스트가 최종 실행 파일에 직접 하드코딩된다. 이 때문에 문자열 리터럴은 빠르고 효율적이다. 하지만 이러한 특성은 문자열 리터럴의 불변성에서 비롯된다. 안타깝게도, 컴파일 시점에 크기를 알 수 없고 프로그램 실행 중에 크기가 변할 수 있는 텍스트 조각을 바이너리에 포함할 수는 없다.

String 타입은 가변적이고 확장 가능한 텍스트를 지원하기 위해, 컴파일 시점에 크기를 알 수 없는 메모리를 힙에 할당하여 내용을 저장한다. 이는 다음을 의미한다:

  • 프로그램 실행 중에 메모리 할당자로부터 메모리를 요청해야 한다.
  • String 사용이 끝나면 이 메모리를 할당자에게 반환할 방법이 필요하다.

첫 번째 부분은 우리가 직접 처리한다: String::from을 호출하면, 그 구현이 필요한 메모리를 요청한다. 이는 거의 모든 프로그래밍 언어에서 보편적인 방식이다.

하지만 두 번째 부분은 다르다. 가비지 컬렉터(GC)가 있는 언어에서는, GC가 더 이상 사용되지 않는 메모리를 추적하고 정리하므로 우리가 신경 쓸 필요가 없다. GC가 없는 대부분의 언어에서는, 메모리가 더 이상 사용되지 않을 시점을 파악하고 명시적으로 메모리를 해제하는 코드를 호출하는 것이 우리의 책임이다. 이 작업을 정확히 수행하는 것은 역사적으로 어려운 프로그래밍 문제였다. 메모리 해제를 잊으면 메모리가 낭비되고, 너무 일찍 해제하면 유효하지 않은 변수가 생기며, 두 번 해제하면 버그가 발생한다. 정확히 한 번의 allocate와 한 번의 free를 짝지어야 한다.

Rust는 다른 방식을 선택한다: 메모리를 소유한 변수가 스코프를 벗어나면 자동으로 메모리가 반환된다. 다음은 문자열 리터럴 대신 String을 사용한 스코프 예제이다:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

String이 필요로 하는 메모리를 반환할 수 있는 자연스러운 시점은 s가 스코프를 벗어날 때이다. 변수가 스코프를 벗어나면 Rust는 우리를 위해 특별한 함수를 호출한다. 이 함수는 drop이라고 하며, String의 작성자가 메모리를 반환하는 코드를 넣는 곳이다. Rust는 닫는 중괄호에서 drop을 자동으로 호출한다.

참고: C++에서는 아이템의 수명이 끝날 때 리소스를 해제하는 이 패턴을 _리소스 획득은 초기화(RAII)_라고 부르기도 한다. Rust의 drop 함수는 RAII 패턴을 사용해본 사람이라면 익숙할 것이다.

이 패턴은 Rust 코드 작성 방식에 깊은 영향을 미친다. 지금은 단순해 보일 수 있지만, 힙에 할당한 데이터를 여러 변수가 사용하려는 더 복잡한 상황에서는 코드의 동작이 예상치 못한 결과를 초래할 수 있다. 이제 그러한 상황을 몇 가지 살펴보자.

변수와 데이터의 이동(Move) 상호작용

Rust에서는 여러 변수가 동일한 데이터와 다양한 방식으로 상호작용할 수 있다. Listing 4-2에서 정수를 사용한 예제를 통해 이를 살펴보자.

fn main() {
    let x = 5;
    let y = x;
}
Listing 4-2: 변수 x의 정수 값을 y에 할당

이 코드가 무엇을 하는지 짐작할 수 있다: “값 5x에 바인딩한 다음, x의 값을 복사하여 y에 바인딩한다.” 이제 두 변수 xy가 모두 5라는 값을 갖게 된다. 정수는 크기가 고정된 단순한 값이기 때문에 스택에 두 개의 5 값이 저장된다.

이제 String 버전을 살펴보자:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

이 코드는 매우 유사해 보이므로, 동작 방식도 같을 것이라고 추측할 수 있다: 즉, 두 번째 줄에서 s1의 값을 복사하여 s2에 바인딩할 것이라고 생각할 수 있다. 하지만 실제로는 그렇지 않다.

String이 내부적으로 어떻게 동작하는지 이해하기 위해 Figure 4-1을 살펴보자. String은 왼쪽에 표시된 세 부분으로 구성된다: 문자열 내용을 담고 있는 메모리의 포인터, 길이, 그리고 용량. 이 데이터 그룹은 스택에 저장된다. 오른쪽에는 힙에 저장된 문자열 내용이 있다.

두 개의 테이블: 첫 번째 테이블은 스택에 있는 s1의 표현을 포함하며, 길이(5), 용량(5), 그리고 두 번째 테이블의 첫 번째 값을 가리키는 포인터로 구성된다. 두 번째 테이블은 힙에 있는 문자열 데이터를 바이트 단위로 나타낸다.

Figure 4-1: "hello" 값을 가진 Strings1에 바인딩된 메모리 표현

길이는 String의 내용이 현재 사용 중인 메모리 크기(바이트 단위)이고, 용량은 할당자로부터 받은 총 메모리 크기(바이트 단위)이다. 길이와 용량의 차이는 중요하지만, 이 맥락에서는 용량을 무시해도 괜찮다.

s1s2에 할당할 때, String 데이터가 복사된다. 이는 스택에 있는 포인터, 길이, 용량을 복사한다는 의미이다. 포인터가 참조하는 힙의 데이터는 복사하지 않는다. 즉, 메모리 내 데이터 표현은 Figure 4-2와 같다.

세 개의 테이블: 스택에 있는 s1과 s2를 나타내는 테이블과, 둘 다 힙에 있는 동일한 문자열 데이터를 가리키는 테이블.

Figure 4-2: s1의 포인터, 길이, 용량을 복사한 s2 변수의 메모리 표현

이 표현은 Figure 4-3과 같지 않다. Figure 4-3은 Rust가 힙 데이터도 복사한 경우의 메모리 상태를 보여준다. Rust가 이렇게 동작했다면, 힙 데이터가 큰 경우 s2 = s1 연산이 런타임 성능 측면에서 매우 비용이 많이 들었을 것이다.

네 개의 테이블: s1과 s2의 스택 데이터를 나타내는 두 개의 테이블과, 각각 힙에 있는 자신의 문자열 데이터 복사본을 가리키는 테이블.

Figure 4-3: Rust가 힙 데이터도 복사한 경우 s2 = s1이 수행할 수 있는 또 다른 가능성

앞서 변수가 스코프를 벗어나면 Rust가 자동으로 drop 함수를 호출하여 해당 변수의 힙 메모리를 정리한다고 설명했다. 하지만 Figure 4-2는 두 데이터 포인터가 동일한 위치를 가리키는 것을 보여준다. 이는 문제가 된다: s2s1이 스코프를 벗어날 때 둘 다 동일한 메모리를 해제하려고 시도한다. 이를 이중 해제(double free) 오류라고 하며, 이전에 언급한 메모리 안전 버그 중 하나이다. 메모리를 두 번 해제하면 메모리 손상이 발생할 수 있고, 이는 잠재적으로 보안 취약점으로 이어질 수 있다.

메모리 안전을 보장하기 위해, let s2 = s1; 줄 이후 Rust는 s1을 더 이상 유효하지 않은 것으로 간주한다. 따라서 s1이 스코프를 벗어날 때 Rust는 아무것도 해제할 필요가 없다. s2가 생성된 후 s1을 사용하려고 하면 어떤 일이 발생하는지 확인해보자; 작동하지 않을 것이다:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

Rust가 무효화된 참조를 사용하지 못하도록 막기 때문에 다음과 같은 오류가 발생한다:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:15
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |               ^^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

다른 언어를 사용하면서 _얕은 복사(shallow copy)_와 _깊은 복사(deep copy)_라는 용어를 들어본 적이 있다면, 데이터를 복사하지 않고 포인터, 길이, 용량만 복사하는 개념이 얕은 복사처럼 들릴 수 있다. 하지만 Rust는 첫 번째 변수도 무효화하기 때문에 이를 얕은 복사라고 부르지 않고 _이동(move)_이라고 한다. 이 예제에서는 s1s2로 _이동되었다_고 말한다. 따라서 실제로 발생하는 일은 Figure 4-4와 같다.

세 개의 테이블: 스택에 있는 s1과 s2를 나타내는 테이블과, 둘 다 힙에 있는 동일한 문자열 데이터를 가리키는 테이블. s1 테이블은 회색으로 표시되어 있으며, s1이 더 이상 유효하지 않기 때문에 s2만 힙 데이터에 접근할 수 있다.

Figure 4-4: s1이 무효화된 후의 메모리 표현

이렇게 하면 문제가 해결된다! s2만 유효하기 때문에, s2가 스코프를 벗어날 때 메모리를 해제하면 된다.

또한, 이로 인해 암시적으로 내려지는 설계 선택이 있다: Rust는 절대로 데이터의 “깊은” 복사를 자동으로 생성하지 않는다. 따라서 모든 자동 복사는 런타임 성능 측면에서 비용이 적다고 가정할 수 있다.

스코프와 할당

스코프, 소유권, 그리고 drop 함수를 통해 메모리가 해제되는 관계는 이와 반대로 작동한다. 기존 변수에 완전히 새로운 값을 할당하면, Rust는 drop을 호출해 원래 값의 메모리를 즉시 해제한다. 다음 코드를 예로 들어보자:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

우선 변수 s를 선언하고 "hello"라는 값을 가진 String에 바인딩한다. 그런 다음 즉시 "ahoy"라는 값을 가진 새로운 String을 생성하고 s에 할당한다. 이 시점에서 힙에 있는 원래 값은 더 이상 참조되지 않는다.

스택에 있는 문자열 값을 나타내는 테이블 s가 힙에 있는 두 번째 문자열 데이터(ahoy)를 가리키고, 원래의 문자열 데이터(hello)는 더 이상 접근할 수 없어 회색으로 표시됨.

그림 4-5: 원래 값이 완전히 대체된 후의 메모리 표현.

따라서 원래 문자열은 즉시 스코프를 벗어난다. Rust는 drop 함수를 실행해 해당 메모리를 즉시 해제한다. 마지막에 값을 출력하면 "ahoy, world!"가 표시된다.

변수와 데이터의 복제 상호작용

만약 스택 데이터뿐만 아니라 String의 힙 데이터까지 깊은 복사를 하고 싶다면, clone이라는 일반적인 메서드를 사용할 수 있다. 메서드 문법은 5장에서 자세히 다룰 예정이지만, 메서드는 많은 프로그래밍 언어에서 흔히 사용되는 기능이기 때문에 이미 접해본 적이 있을 것이다.

다음은 clone 메서드를 사용한 예제이다:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

이 코드는 정상적으로 동작하며, 그림 4-3에 나온 것처럼 힙 데이터가 복사되는 동작을 명확히 보여준다.

clone 호출을 보면, 어떤 임의의 코드가 실행되고 있으며 그 코드가 비용이 많이 들 수 있다는 것을 알 수 있다. 이는 무언가 다른 일이 일어나고 있다는 시각적 지표 역할을 한다.

스택 전용 데이터: Copy

아직 다루지 않은 또 다른 중요한 개념이 있다. 정수를 사용하는 이 코드는 잘 작동하며 유효하다. 이 코드의 일부는 리스트 4-2에서 보여준 것과 같다.

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

하지만 이 코드는 방금 배운 내용과 모순되는 것처럼 보인다. clone을 호출하지 않았는데도 x는 여전히 유효하며, y로 이동되지 않았다.

그 이유는 컴파일 시점에 크기가 알려진 정수와 같은 타입들은 스택에 완전히 저장되기 때문에 실제 값을 복사하는 것이 빠르기 때문이다. 이는 y 변수를 생성한 후에도 x가 유효한 상태를 유지하는 것을 막을 이유가 없음을 의미한다. 다시 말해, 여기서는 깊은 복사와 얕은 복사 사이에 차이가 없으므로 clone을 호출해도 일반적인 얕은 복사와 다르게 동작하지 않는다. 따라서 clone을 생략할 수 있다.

Rust는 스택에 저장되는 타입에 사용할 수 있는 Copy 트레이트라는 특별한 어노테이션을 제공한다. 정수가 그 예이다. (트레이트에 대해서는 10장에서 더 자세히 다룬다.) 어떤 타입이 Copy 트레이트를 구현하면, 해당 타입을 사용하는 변수는 이동하지 않고 단순히 복사되며, 다른 변수에 할당된 후에도 여전히 유효하다.

만약 타입이나 그 일부가 Drop 트레이트를 구현한 경우, Rust는 해당 타입에 Copy 어노테이션을 추가하는 것을 허용하지 않는다. 값이 스코프를 벗어날 때 특별한 처리가 필요한 타입에 Copy 어노테이션을 추가하면 컴파일 시점에 오류가 발생한다. Copy 트레이트를 구현하기 위해 타입에 Copy 어노테이션을 추가하는 방법은 부록 C의 “파생 가능한 트레이트”를 참고하라.

그렇다면 어떤 타입이 Copy 트레이트를 구현할까? 특정 타입에 대한 문서를 확인하면 확실히 알 수 있지만, 일반적으로 단순한 스칼라 값들로 구성된 그룹은 Copy를 구현할 수 있다. 반면, 할당이 필요하거나 리소스 형태인 타입은 Copy를 구현할 수 없다. 다음은 Copy를 구현하는 타입의 예시이다:

  • u32와 같은 모든 정수 타입
  • truefalse 값을 가지는 불리언 타입 bool
  • f64와 같은 모든 부동소수점 타입
  • 문자 타입 char
  • Copy를 구현하는 타입만 포함하는 튜플. 예를 들어, (i32, i32)Copy를 구현하지만, (i32, String)은 구현하지 않는다.

소유권과 함수

함수에 값을 전달하는 메커니즘은 변수에 값을 할당하는 것과 유사하다. 변수를 함수에 전달하면 할당과 마찬가지로 값이 이동하거나 복사된다. 아래 예제에서는 변수가 스코프에 들어가고 나가는 위치를 주석으로 표시했다.

Filename: src/main.rs
fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // because i32 implements the Copy trait,
                                    // x does NOT move into the function,
    println!("{}", x);              // so it's okay to use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.
Listing 4-3: 소유권과 스코프가 주석 처리된 함수 예제

takes_ownership 함수를 호출한 후에 s를 사용하려고 하면 Rust는 컴파일 타임 오류를 발생시킨다. 이러한 정적 검사는 실수를 방지하는 데 도움이 된다. main 함수에 sx를 사용하는 코드를 추가해 보면, 어디서 사용할 수 있고 어디서 소유권 규칙 때문에 사용할 수 없는지 확인할 수 있다.

반환 값과 스코프

반환 값을 통해 소유권을 이전할 수도 있다. 리스트 4-4는 값을 반환하는 함수 예제를 보여준다. 이 예제는 리스트 4-3과 유사한 주석을 포함한다.

Filename: src/main.rs
fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}
Listing 4-4: 반환 값의 소유권 이전

변수의 소유권은 항상 동일한 패턴을 따른다. 값을 다른 변수에 할당하면 소유권이 이동한다. 힙에 데이터를 포함한 변수가 스코프를 벗어나면, 데이터의 소유권이 다른 변수로 이동하지 않는 한 drop에 의해 값이 정리된다.

이 방식은 동작하지만, 모든 함수에서 소유권을 가져온 뒤 다시 반환하는 것은 다소 번거롭다. 함수가 값을 사용하되 소유권을 가져가지 않게 하려면 어떻게 해야 할까? 함수 내부에서 반환하고 싶은 데이터 외에도, 다시 사용하려면 전달한 모든 것을 반환해야 한다는 점은 상당히 불편하다.

Rust는 튜플을 사용해 여러 값을 반환할 수 있도록 지원한다. 리스트 4-5에서 이를 확인할 수 있다.

Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}
Listing 4-5: 매개변수의 소유권 반환

하지만 이는 너무 많은 절차와 작업을 요구한다. 다행히 Rust에는 소유권을 이전하지 않고 값을 사용할 수 있는 기능이 있다. 이를 _참조_라고 부른다.

참조와 빌림

리스트 4-5의 튜플 코드에서 문제는 calculate_length 함수를 호출한 후에도 String을 계속 사용할 수 있도록 String을 호출 함수로 반환해야 한다는 것이다. 왜냐하면 Stringcalculate_length로 이동했기 때문이다. 대신, String 값에 대한 참조를 제공할 수 있다. 참조는 포인터와 유사하며, 해당 주소에 저장된 데이터에 접근할 수 있는 주소를 의미한다. 그 데이터는 다른 변수가 소유하고 있다. 포인터와 달리, 참조는 해당 참조의 수명 동안 유효한 특정 타입의 값을 가리키도록 보장된다.

다음은 값을 소유하지 않고 객체에 대한 참조를 매개변수로 사용하는 calculate_length 함수를 정의하고 사용하는 방법이다:

Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

먼저, 변수 선언과 함수 반환 값에 있는 모든 튜플 코드가 사라졌다는 점을 확인한다. 두 번째로, &s1calculate_length에 전달하고, 함수 정의에서 String 대신 &String을 사용한다. 이 앰퍼샌드(&)는 참조를 나타내며, 값을 소유하지 않고도 값을 참조할 수 있게 한다. 그림 4-6은 이 개념을 보여준다.

세 개의 테이블: s 테이블은 s1 테이블에 대한 포인터만 포함한다. s1 테이블은 s1의 스택 데이터를 포함하고 힙에 있는 문자열 데이터를 가리킨다.

그림 4-6: &String sString s1을 가리키는 다이어그램

참고: &를 사용한 참조의 반대는 역참조(dereferencing)이며, 역참조 연산자 *를 사용해 수행한다. 역참조 연산자의 사용은 8장에서, 역참조의 세부 사항은 15장에서 다룬다.

이제 함수 호출을 자세히 살펴보자:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

&s1 구문은 s1의 값을 참조하지만 소유하지 않는 참조를 생성한다. 참조가 값을 소유하지 않기 때문에, 참조가 사용을 멈춰도 가리키는 값은 삭제되지 않는다.

마찬가지로, 함수 시그니처는 매개변수 s의 타입이 참조임을 나타내기 위해 &를 사용한다. 설명을 추가해 보자:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because s does not have ownership of what
  // it refers to, the value is not dropped.

변수 s가 유효한 범위는 다른 함수 매개변수의 범위와 동일하지만, 참조가 가리키는 값은 s가 사용을 멈춰도 삭제되지 않는다. 왜냐하면 s는 값을 소유하지 않기 때문이다. 함수가 실제 값 대신 참조를 매개변수로 사용할 때, 값을 반환하여 소유권을 돌려줄 필요가 없다. 왜냐하면 처음부터 소유권을 가지지 않았기 때문이다.

참조를 생성하는 행위를 빌림(borrowing)이라고 한다. 실제 생활에서 누군가가 무언가를 소유하고 있다면, 당신은 그것을 빌릴 수 있다. 사용을 마치면 반환해야 한다. 당신은 그것을 소유하지 않는다.

그렇다면, 빌린 것을 수정하려고 하면 어떻게 될까? 리스트 4-6의 코드를 시도해보자. 스포일러 경고: 작동하지 않는다!

Filename: src/main.rs
fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}
Listing 4-6: 빌린 값을 수정하려는 시도

다음은 오류 메시지다:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |
help: consider changing this to be a mutable reference
  |
7 | fn change(some_string: &mut String) {
  |                         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

변수가 기본적으로 불변인 것처럼, 참조도 기본적으로 불변이다. 참조한 것을 수정하는 것은 허용되지 않는다.

가변 참조

리스트 4-6의 코드를 약간 수정하면 가변 참조(mutable reference)를 사용해 빌린 값을 수정할 수 있다.

Filename: src/main.rs
fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

먼저 smut로 변경한다. 그런 다음 change 함수를 호출할 때 &mut s로 가변 참조를 생성하고, 함수 시그니처를 some_string: &mut String으로 업데이트해 가변 참조를 받도록 한다. 이렇게 하면 change 함수가 빌린 값을 변경한다는 사실을 명확히 알 수 있다.

가변 참조에는 큰 제약이 하나 있다. 어떤 값에 대한 가변 참조가 존재한다면, 그 값에 대한 다른 참조를 가질 수 없다. 다음 코드는 s에 대한 두 개의 가변 참조를 생성하려고 시도하지만 실패한다:

Filename: src/main.rs
fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{}, {}", r1, r2);
}

에러 메시지는 다음과 같다:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> src/main.rs:5:14
  |
4 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{}, {}", r1, r2);
  |                        -- first borrow later used here

For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

이 에러는 s를 동시에 여러 번 가변으로 빌릴 수 없기 때문에 코드가 유효하지 않다는 것을 알려준다. 첫 번째 가변 빌림은 r1에서 발생하며, println!에서 사용될 때까지 유효하다. 하지만 이 가변 참조가 생성된 후 사용되기 전에, r2에서 같은 데이터를 빌리는 또 다른 가변 참조를 생성하려고 시도했다.

동일한 데이터에 대한 여러 가변 참조를 동시에 허용하지 않는 제약은 변이를 허용하지만 매우 제어된 방식으로만 허용한다. 대부분의 언어에서는 원할 때마다 변이를 허용하기 때문에, 새로운 Rust 개발자들은 이 제약에 어려움을 겪는다. 이 제약의 장점은 Rust가 컴파일 타임에 데이터 경쟁(data race)을 방지할 수 있다는 것이다. _데이터 경쟁_은 경쟁 조건과 유사하며, 다음과 같은 세 가지 동작이 발생할 때 일어난다:

  • 두 개 이상의 포인터가 동시에 같은 데이터에 접근한다.
  • 최소 하나의 포인터가 데이터를 쓰기 위해 사용된다.
  • 데이터 접근을 동기화하는 메커니즘이 없다.

데이터 경쟁은 정의되지 않은 동작을 유발하며, 런타임에 이를 추적하고 수정하는 것은 어려울 수 있다. Rust는 데이터 경쟁이 있는 코드를 컴파일하지 않음으로써 이 문제를 방지한다!

언제나 중괄호를 사용해 새로운 스코프를 생성할 수 있으며, 이를 통해 여러 가변 참조를 허용할 수 있다. 단, 동시에 사용할 수는 없다:

fn main() {
    let mut s = String::from("hello");

    {
        let r1 = &mut s;
    } // r1 goes out of scope here, so we can make a new reference with no problems.

    let r2 = &mut s;
}

Rust는 가변 참조와 불변 참조를 결합하는 경우에도 비슷한 규칙을 적용한다. 다음 코드는 에러를 발생시킨다:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    let r3 = &mut s; // BIG PROBLEM

    println!("{}, {}, and {}", r1, r2, r3);
}

에러 메시지는 다음과 같다:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s; // no problem
  |              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |              ^^^^^^ mutable borrow occurs here
7 |
8 |     println!("{}, {}, and {}", r1, r2, r3);
  |                                -- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

휴! 같은 값에 대한 불변 참조가 존재하는 동안에는 가변 참조를 가질 수 없다.

불변 참조를 사용하는 사용자는 값이 갑자기 변경될 것을 예상하지 않는다! 그러나 여러 불변 참조는 허용된다. 데이터를 읽기만 하는 사용자는 다른 사용자의 데이터 읽기에 영향을 미칠 수 없기 때문이다.

참조의 스코프는 참조가 도입된 지점부터 시작해 마지막으로 사용된 지점까지 계속된다. 예를 들어, 다음 코드는 불변 참조의 마지막 사용이 println!에서 이루어진 후 가변 참조가 도입되기 때문에 컴파일된다:

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{r1} and {r2}");
    // Variables r1 and r2 will not be used after this point.

    let r3 = &mut s; // no problem
    println!("{r3}");
}

불변 참조 r1r2의 스코프는 마지막으로 사용된 println! 이후에 끝나며, 이는 가변 참조 r3가 생성되기 전이다. 이 스코프들은 겹치지 않으므로 이 코드는 허용된다. 컴파일러는 스코프가 끝나기 전에 참조가 더 이상 사용되지 않는다는 것을 알 수 있다.

빌림 에러가 때로는 답답할 수 있지만, Rust 컴파일러가 잠재적인 버그를 조기에(런타임이 아닌 컴파일 타임에) 지적하고 문제가 있는 정확한 위치를 보여준다는 점을 기억하자. 그러면 데이터가 예상과 다를 때 그 이유를 추적할 필요가 없다.

댕글링 참조

포인터를 사용하는 언어에서는, 메모리를 해제한 후에도 그 메모리에 대한 포인터를 유지하는 실수를 통해 _댕글링 포인터_를 쉽게 만들 수 있다. 댕글링 포인터는 이미 다른 곳에 할당된 메모리 위치를 참조하는 포인터를 의미한다. 반면에 Rust에서는 컴파일러가 참조가 절대 댕글링 참조가 되지 않도록 보장한다. 즉, 어떤 데이터에 대한 참조가 있다면, 컴파일러는 그 데이터가 참조보다 먼저 스코프를 벗어나지 않도록 한다.

Rust가 어떻게 컴파일 타임 오류를 통해 댕글링 참조를 방지하는지 확인해보자:

Filename: src/main.rs
fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

발생한 오류는 다음과 같다:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
  |
5 | fn dangle() -> &'static String {
  |                 +++++++
help: instead, you are more likely to want to return an owned value
  |
5 - fn dangle() -> &String {
5 + fn dangle() -> String {
  |

error[E0515]: cannot return reference to local variable `s`
 --> src/main.rs:8:5
  |
8 |     &s
  |     ^^ returns a reference to data owned by the current function

Some errors have detailed explanations: E0106, E0515.
For more information about an error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to 2 previous errors

이 오류 메시지는 아직 다루지 않은 기능인 ’라이프타임’을 언급한다. 라이프타임에 대해서는 10장에서 자세히 설명할 것이다. 하지만 라이프타임에 대한 부분을 무시하더라도, 이 메시지는 왜 이 코드가 문제가 되는지에 대한 핵심을 담고 있다:

이 함수의 반환 타입은 빌린 값을 포함하지만, 빌릴 값이 존재하지 않습니다.

이제 dangle 코드의 각 단계에서 정확히 어떤 일이 일어나는지 자세히 살펴보자:

Filename: src/main.rs
fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped, so its memory goes away.
  // Danger!

sdangle 함수 내부에서 생성되므로, dangle의 코드가 실행을 마치면 s는 해제된다. 하지만 우리는 s에 대한 참조를 반환하려고 했다. 이는 이 참조가 유효하지 않은 String을 가리키게 된다는 것을 의미한다. 이는 좋지 않은 상황이다! Rust는 이런 상황을 허용하지 않는다.

여기서 해결책은 String을 직접 반환하는 것이다:

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

이 코드는 아무런 문제 없이 동작한다. 소유권이 이동되고, 아무것도 해제되지 않는다.

참조의 규칙

지금까지 다룬 참조에 대한 내용을 다시 정리해 보자:

  • 특정 시점에 하나의 가변 참조를 가질 수 있거나, 여러 개의 불변 참조를 가질 수 있다.
  • 모든 참조는 항상 유효해야 한다.

다음으로, 다른 종류의 참조인 슬라이스에 대해 알아보자.

슬라이스 타입

슬라이스는 컬렉션 전체가 아닌 연속된 일부 엘리먼트를 참조할 수 있게 해준다. 슬라이스는 참조 타입이기 때문에 소유권을 가지지 않는다.

간단한 프로그래밍 문제를 살펴보자: 공백으로 구분된 단어들로 이루어진 문자열을 입력받아 첫 번째 단어를 반환하는 함수를 작성해 보자. 만약 문자열에 공백이 없다면 전체 문자열이 하나의 단어이므로, 문자열 전체를 반환해야 한다.

슬라이스가 어떤 문제를 해결하는지 이해하기 위해, 먼저 슬라이스를 사용하지 않고 이 함수의 시그니처를 작성하는 방법을 생각해 보자:

fn first_word(s: &String) -> ?

first_word 함수는 &String을 파라미터로 받는다. 소유권이 필요하지 않으므로 이 방식은 적절하다. (관용적인 Rust 코드에서는 필요하지 않은 경우 함수가 인자의 소유권을 가져가지 않는다. 이에 대한 이유는 계속 진행하면서 명확해질 것이다!) 하지만 무엇을 반환해야 할까? 문자열의 일부를 표현할 방법이 없다. 대신 공백으로 표시된 단어의 끝 인덱스를 반환할 수 있다. 이를 시도해 보자. 아래 리스트 4-7에서 확인할 수 있다.

Filename: src/main.rs
fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}
Listing 4-7: String 파라미터의 바이트 인덱스 값을 반환하는 first_word 함수

String을 하나씩 순회하며 공백인지 확인해야 하므로, as_bytes 메서드를 사용해 String을 바이트 배열로 변환한다.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

다음으로, iter 메서드를 사용해 바이트 배열에 대한 이터레이터를 생성한다:

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

이터레이터에 대해서는 13장에서 자세히 다룰 것이다. 지금은 iter가 컬렉션의 각 엘리먼트를 반환하는 메서드이고, enumerateiter의 결과를 감싸서 각 엘리먼트를 튜플의 일부로 반환한다는 점만 알아두자. enumerate가 반환하는 튜플의 첫 번째 요소는 인덱스이고, 두 번째 요소는 엘리먼트에 대한 참조이다. 이는 인덱스를 직접 계산하는 것보다 편리하다.

enumerate 메서드는 튜플을 반환하므로, 패턴을 사용해 튜플을 분해할 수 있다. 패턴에 대해서는 6장에서 더 자세히 설명할 것이다. for 루프에서 튜플의 인덱스에 해당하는 i와 튜플의 단일 바이트에 해당하는 &item 패턴을 지정한다. .iter().enumerate()에서 엘리먼트에 대한 참조를 얻기 때문에 패턴에 &를 사용한다.

for 루프 내부에서는 바이트 리터럴 문법을 사용해 공백을 나타내는 바이트를 찾는다. 공백을 찾으면 해당 위치를 반환한다. 그렇지 않으면 s.len()을 사용해 문자열의 길이를 반환한다.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {}

이제 문자열에서 첫 번째 단어의 끝 인덱스를 찾는 방법을 알게 되었지만, 문제가 있다. usize를 단독으로 반환하지만, 이 값은 &String의 컨텍스트에서만 의미가 있다. 즉, String과 별개의 값이기 때문에 이 값이 나중에도 유효할 것이라는 보장이 없다. 리스트 4-7의 first_word 함수를 사용하는 리스트 4-8의 프로그램을 살펴보자.

Filename: src/main.rs
fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5

    s.clear(); // this empties the String, making it equal to ""

    // `word` still has the value `5` here, but `s` no longer has any content
    // that we could meaningfully use with the value `5`, so `word` is now
    // totally invalid!
}
Listing 4-8: first_word 함수를 호출한 결과를 저장한 후 String 내용을 변경

이 프로그램은 아무런 오류 없이 컴파일되며, s.clear()를 호출한 후에도 word를 사용할 수 있다. words의 상태와 전혀 연결되어 있지 않기 때문에, word는 여전히 값 5를 가지고 있다. 이 값 5를 변수 s와 함께 사용해 첫 번째 단어를 추출하려고 할 수 있지만, 이는 버그이다. 왜냐하면 word5를 저장한 이후로 s의 내용이 변경되었기 때문이다.

word의 인덱스가 s의 데이터와 동기화되지 않을까 걱정하는 것은 지루하고 오류가 발생하기 쉽다! second_word 함수를 작성한다면 이러한 인덱스 관리가 더욱 까다로워질 것이다. 이 함수의 시그니처는 다음과 같을 것이다:

fn second_word(s: &String) -> (usize, usize) {

이제 시작 인덱스와 끝 인덱스를 모두 추적해야 하며, 특정 상태에서 계산되었지만 그 상태와 전혀 연결되지 않은 더 많은 값들이 생긴다. 동기화를 유지해야 하는 서로 관련 없는 세 변수가 존재하게 된다.

다행히 Rust는 이 문제에 대한 해결책을 제공한다: 문자열 슬라이스.

문자열 슬라이스

_문자열 슬라이스_는 String의 일부를 참조하는 것으로, 다음과 같이 생겼다:

fn main() {
    let s = String::from("hello world");

    let hello = &s[0..5];
    let world = &s[6..11];
}

hello는 전체 String을 참조하는 대신, 추가적인 [0..5] 부분으로 지정된 String의 일부를 참조한다. 슬라이스는 대괄호 안에 범위를 지정해 생성하며, [시작_인덱스..종료_인덱스] 형식을 사용한다. 여기서 _시작_인덱스_는 슬라이스의 첫 번째 위치이고, _종료_인덱스_는 슬라이스의 마지막 위치보다 하나 더 큰 값이다. 내부적으로 슬라이스 데이터 구조는 시작 위치와 슬라이스의 길이를 저장하며, 이 길이는 _종료_인덱스_에서 _시작_인덱스_를 뺀 값과 같다. 따라서 let world = &s[6..11];의 경우, worlds의 인덱스 6에 있는 바이트를 가리키는 포인터와 길이 값 5를 갖는 슬라이스가 된다.

그림 4-7은 이를 다이어그램으로 보여준다.

Three tables: a table representing the stack data of s, which points
to the byte at index 0 in a table of the string data "hello world" on
the heap. The third table rep-resents the stack data of the slice world, which
has a length value of 5 and points to byte 6 of the heap data table.

그림 4-7: String의 일부를 참조하는 문자열 슬라이스

Rust의 .. 범위 문법을 사용할 때, 인덱스 0에서 시작하려면 두 점 앞의 값을 생략할 수 있다. 즉, 다음 두 코드는 동일하다:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];
}

마찬가지로, 슬라이스가 String의 마지막 바이트를 포함한다면, 뒤의 숫자를 생략할 수 있다. 따라서 다음 두 코드는 동일하다:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[3..len];
let slice = &s[3..];
}

두 값을 모두 생략하면 전체 문자열의 슬라이스를 가져올 수 있다. 따라서 다음 두 코드는 동일하다:

#![allow(unused)]
fn main() {
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];
}

주의: 문자열 슬라이스의 범위 인덱스는 유효한 UTF-8 문자 경계에서 발생해야 한다. 멀티바이트 문자 중간에 문자열 슬라이스를 만들려고 하면 프로그램이 오류와 함께 종료된다. 문자열 슬라이스를 소개하는 목적상, 이 섹션에서는 ASCII만 가정한다. UTF-8 처리에 대한 더 자세한 내용은 8장의 “Storing UTF-8 Encoded Text with Strings” 섹션에서 다룬다.

이 모든 정보를 염두에 두고, first_word 함수를 슬라이스를 반환하도록 다시 작성해보자. “문자열 슬라이스“를 나타내는 타입은 &str로 작성한다:

Filename: src/main.rs
fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {}

단어의 끝 인덱스는 Listing 4-7에서와 같은 방식으로, 첫 번째 공백을 찾아서 얻는다. 공백을 찾으면 문자열의 시작과 공백의 인덱스를 각각 시작과 종료 인덱스로 사용해 문자열 슬라이스를 반환한다.

이제 first_word를 호출하면, 기본 데이터에 연결된 단일 값을 반환한다. 이 값은 슬라이스의 시작점을 참조하는 포인터와 슬라이스의 요소 수로 구성된다.

슬라이스를 반환하는 방식은 second_word 함수에도 동일하게 적용할 수 있다:

fn second_word(s: &String) -> &str {

이제 컴파일러가 String에 대한 참조가 유효한지 확인해주기 때문에, 실수하기 어려운 직관적인 API를 갖게 되었다. Listing 4-8의 버그를 기억하는가? 첫 번째 단어의 끝 인덱스를 얻은 후 문자열을 비워서 인덱스가 무효화된 상황이었다. 그 코드는 논리적으로 잘못되었지만 즉각적인 오류를 표시하지 않았다. 빈 문자열과 함께 첫 번째 단어 인덱스를 계속 사용하려고 하면 나중에 문제가 발생할 것이다. 슬라이스를 사용하면 이 버그를 불가능하게 만들고, 코드에 문제가 있음을 훨씬 빨리 알 수 있다. 슬라이스 버전의 first_word를 사용하면 컴파일 타임 오류가 발생한다:

Filename: src/main.rs
fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // error!

    println!("the first word is: {word}");
}

컴파일러 오류는 다음과 같다:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:18:5
   |
16 |     let word = first_word(&s);
   |                           -- immutable borrow occurs here
17 |
18 |     s.clear(); // error!
   |     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {word}");
   |                                  ------ immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

빌림 규칙을 떠올려보면, 무언가에 대한 불변 참조가 있는 경우 가변 참조를 동시에 가질 수 없다. clearString을 잘라내야 하므로 가변 참조가 필요하다. clear 호출 후의 println!word의 참조를 사용하므로, 그 시점에 불변 참조가 여전히 활성 상태여야 한다. Rust는 clear의 가변 참조와 word의 불변 참조가 동시에 존재하는 것을 허용하지 않으며, 컴파일이 실패한다. Rust는 우리의 API를 더 쉽게 사용할 수 있게 만들었을 뿐만 아니라, 컴파일 타임에 오류의 전체 클래스를 제거했다!

문자열 리터럴과 슬라이스

이전에 문자열 리터럴이 바이너리 안에 저장된다고 설명했다. 이제 슬라이스에 대해 알았으니 문자열 리터럴을 제대로 이해할 수 있다:

#![allow(unused)]
fn main() {
let s = "Hello, world!";
}

여기서 s의 타입은 &str이다. 이는 바이너리의 특정 지점을 가리키는 슬라이스를 의미한다. 이것이 문자열 리터럴이 불변인 이유이기도 하다. &str은 불변 참조이기 때문이다.

문자열 슬라이스를 파라미터로 사용하기

리터럴과 String 값에서 슬라이스를 추출할 수 있다는 사실을 알면, first_word 함수를 한 단계 더 개선할 수 있다. 현재 함수 시그니처는 다음과 같다:

fn first_word(s: &String) -> &str {

경험이 많은 Rust 개발자라면 리스트 4-9와 같은 시그니처를 작성할 것이다. 이 방식은 &String 값과 &str 값 모두에 동일한 함수를 사용할 수 있게 해준다.

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}
Listing 4-9: first_word 함수 개선: s 파라미터 타입을 문자열 슬라이스로 변경

문자열 슬라이스가 있다면 이를 직접 전달할 수 있다. String이 있다면 String의 슬라이스나 String에 대한 참조를 전달할 수 있다. 이 유연성은 *역참조 강제 변환(deref coercions)*을 활용한 것으로, 이 기능은 15장의 “함수와 메서드에서의 암묵적 역참조 강제 변환” 섹션에서 자세히 다룬다.

String에 대한 참조 대신 문자열 슬라이스를 파라미터로 받도록 함수를 정의하면, 기능을 잃지 않으면서도 API를 더 일반적이고 유용하게 만들 수 있다:

Filename: src/main.rs
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // `first_word` works on slices of `String`s, whether partial or whole.
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    // `first_word` also works on references to `String`s, which are equivalent
    // to whole slices of `String`s.
    let word = first_word(&my_string);

    let my_string_literal = "hello world";

    // `first_word` works on slices of string literals, whether partial or
    // whole.
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

다른 슬라이스

문자열 슬라이스는 문자열에 특화된 개념이다. 하지만 더 일반적인 슬라이스 타입도 존재한다. 다음 배열을 살펴보자:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];
}

문자열의 일부를 참조하듯이, 배열의 일부를 참조할 수도 있다. 이를 다음과 같이 구현할 수 있다:

#![allow(unused)]
fn main() {
let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);
}

이 슬라이스는 &[i32] 타입을 가진다. 문자열 슬라이스와 동일한 방식으로 작동하며, 첫 번째 요소에 대한 참조와 길이를 저장한다. 다양한 컬렉션에서 이와 같은 슬라이스를 사용할 수 있다. 8장에서 벡터를 다룰 때 이러한 컬렉션에 대해 자세히 설명할 것이다.

요약

Rust에서 소유권(ownership), 대여(borrowing), 그리고 슬라이스(slices) 개념은 컴파일 타임에 메모리 안전성을 보장한다. Rust는 다른 시스템 프로그래밍 언어와 마찬가지로 메모리 사용을 제어할 수 있게 해주지만, 데이터의 소유자가 범위를 벗어날 때 자동으로 데이터를 정리하는 기능을 제공한다. 이를 통해 추가 코드를 작성하거나 디버깅하지 않아도 메모리 관리를 효과적으로 할 수 있다.

소유권은 Rust의 여러 기능에 영향을 미치기 때문에, 이 책의 나머지 부분에서 이 개념들을 더 깊이 다룰 것이다. 이제 5장으로 넘어가서 struct를 사용해 데이터를 그룹화하는 방법을 살펴보자.

관련 데이터를 구조화하기 위해 구조체 사용하기

_구조체(struct)_는 여러 개의 관련된 값을 하나의 의미 있는 그룹으로 묶어 이름을 붙일 수 있는 커스텀 데이터 타입이다. 객체 지향 언어에 익숙하다면, 구조체는 객체의 데이터 속성과 비슷하다고 볼 수 있다. 이 장에서는 튜플과 구조체를 비교하며 이미 알고 있는 지식을 바탕으로, 언제 구조체가 데이터를 그룹화하는 더 나은 방법인지 알아본다.

구조체를 정의하고 인스턴스를 생성하는 방법을 살펴본다. 또한, 구조체 타입과 관련된 동작을 정의하는 _메서드(methods)_와 같은 연관 함수를 어떻게 정의하는지 논의한다. 구조체와 열거형(enum, 6장에서 다룸)은 프로그램 도메인에서 새로운 타입을 생성하기 위한 기본 구성 요소로, Rust의 컴파일 타임 타입 검사를 최대한 활용할 수 있게 해준다.

구조체 정의와 인스턴스 생성

구조체는 “튜플 타입” 섹션에서 다룬 튜플과 유사하게, 여러 관련 값을 함께 담는다. 튜플과 마찬가지로 구조체의 각 부분은 서로 다른 타입을 가질 수 있다. 하지만 튜플과 달리, 구조체에서는 각 데이터 조각에 이름을 붙여 값의 의미를 명확히 할 수 있다. 이러한 이름 덕분에 구조체는 튜플보다 더 유연하다. 인스턴스의 값을 지정하거나 접근할 때 데이터의 순서에 의존할 필요가 없다.

구조체를 정의하려면 struct 키워드를 사용하고 구조체 전체에 이름을 붙인다. 구조체의 이름은 함께 그룹화된 데이터 조각의 의미를 잘 설명해야 한다. 그런 다음 중괄호 안에 데이터 조각의 이름과 타입을 정의하는데, 이를 _필드_라고 부른다. 예를 들어, Listing 5-1은 사용자 계정 정보를 저장하는 구조체를 보여준다.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {}
Listing 5-1: User 구조체 정의

구조체를 정의한 후에는 각 필드에 구체적인 값을 지정해 구조체의 _인스턴스_를 생성한다. 구조체의 이름을 명시한 다음 중괄호 안에 키: 값 쌍을 추가해 인스턴스를 만든다. 여기서 키는 필드의 이름이고, 값은 해당 필드에 저장할 데이터이다. 필드는 구조체에서 선언한 순서와 동일하게 지정할 필요는 없다. 즉, 구조체 정의는 타입에 대한 일반적인 템플릿 역할을 하고, 인스턴스는 이 템플릿에 특정 데이터를 채워 타입의 값을 생성한다. 예를 들어, Listing 5-2와 같이 특정 사용자를 선언할 수 있다.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };
}
Listing 5-2: User 구조체의 인스턴스 생성

구조체에서 특정 값을 가져오려면 점 표기법을 사용한다. 예를 들어, 이 사용자의 이메일 주소에 접근하려면 user1.email을 사용한다. 인스턴스가 가변적이라면, 점 표기법을 사용해 특정 필드에 값을 할당하여 값을 변경할 수 있다. Listing 5-3은 가변 User 인스턴스의 email 필드 값을 변경하는 방법을 보여준다.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    let mut user1 = User {
        active: true,
        username: String::from("someusername123"),
        email: String::from("someone@example.com"),
        sign_in_count: 1,
    };

    user1.email = String::from("anotheremail@example.com");
}
Listing 5-3: User 인스턴스의 email 필드 값 변경

전체 인스턴스가 가변적이어야 한다는 점에 유의한다. Rust는 특정 필드만 가변적으로 표시하는 것을 허용하지 않는다. 다른 표현식과 마찬가지로, 함수 본문의 마지막 표현식으로 구조체의 새 인스턴스를 생성해 암묵적으로 새 인스턴스를 반환할 수 있다.

Listing 5-4는 주어진 이메일과 사용자 이름으로 User 인스턴스를 반환하는 build_user 함수를 보여준다. active 필드는 true 값을, sign_in_count 필드는 1 값을 가진다.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username: username,
        email: email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}
Listing 5-4: 이메일과 사용자 이름을 받아 User 인스턴스를 반환하는 build_user 함수

함수 매개변수의 이름을 구조체 필드와 동일하게 짓는 것이 합리적이지만, emailusername 필드 이름과 변수를 반복해야 하는 것은 다소 지루할 수 있다. 구조체에 더 많은 필드가 있다면, 각 이름을 반복하는 것이 더 번거로울 것이다. 다행히 편리한 단축 문법이 있다!

필드 초기화 축약 문법 사용하기

리스트 5-4에서 매개변수 이름과 구조체 필드 이름이 정확히 동일하기 때문에, _필드 초기화 축약 문법_을 사용해 build_user 함수를 다시 작성할 수 있다. 이렇게 하면 동일한 동작을 유지하면서 usernameemail의 반복을 줄일 수 있다. 리스트 5-5에서 이를 확인할 수 있다.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username,
        email,
        sign_in_count: 1,
    }
}

fn main() {
    let user1 = build_user(
        String::from("someone@example.com"),
        String::from("someusername123"),
    );
}
Listing 5-5: usernameemail 매개변수가 구조체 필드와 동일한 이름을 가지고 있어 필드 초기화 축약 문법을 사용한 build_user 함수

여기서는 User 구조체의 새 인스턴스를 생성한다. 이 구조체에는 email이라는 필드가 있다. build_user 함수의 email 매개변수 값을 email 필드에 설정하려고 한다. email 필드와 email 매개변수의 이름이 동일하기 때문에 email: email 대신 단순히 email만 작성하면 된다.

구조체 업데이트 문법을 사용해 다른 인스턴스로부터 인스턴스 생성하기

기존 구조체 인스턴스의 대부분의 값을 그대로 사용하면서 일부만 변경해 새로운 인스턴스를 생성하는 경우가 많다. 이때 구조체 업데이트 문법을 사용할 수 있다.

먼저, 리스팅 5-6에서는 업데이트 문법 없이 user2라는 새로운 User 인스턴스를 생성하는 일반적인 방법을 보여준다. email에 새로운 값을 설정하고, 나머지 필드는 리스팅 5-2에서 생성한 user1의 값을 그대로 사용한다.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        active: user1.active,
        username: user1.username,
        email: String::from("another@example.com"),
        sign_in_count: user1.sign_in_count,
    };
}
Listing 5-6: user1의 값 중 하나를 제외하고 새로운 User 인스턴스 생성

구조체 업데이트 문법을 사용하면 더 적은 코드로 동일한 결과를 얻을 수 있다. 리스팅 5-7에서와 같이 .. 문법은 명시적으로 설정하지 않은 나머지 필드가 주어진 인스턴스의 필드와 동일한 값을 가지도록 지정한다.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {
    // --snip--

    let user1 = User {
        email: String::from("someone@example.com"),
        username: String::from("someusername123"),
        active: true,
        sign_in_count: 1,
    };

    let user2 = User {
        email: String::from("another@example.com"),
        ..user1
    };
}
Listing 5-7: 구조체 업데이트 문법을 사용해 User 인스턴스의 새로운 email 값을 설정하고 나머지 값은 user1에서 가져오기

리스팅 5-7의 코드도 user2 인스턴스를 생성하며, email 값은 다르지만 username, active, sign_in_count 필드는 user1과 동일한 값을 가진다. ..user1은 반드시 마지막에 위치해야 하며, 이는 나머지 필드가 user1의 해당 필드에서 값을 가져오도록 지정한다. 하지만 원하는 순서대로 원하는 만큼의 필드에 값을 지정할 수 있으며, 이는 구조체 정의에서 필드가 정의된 순서와 무관하다.

구조체 업데이트 문법은 할당처럼 =를 사용한다. 이는 데이터를 이동시키기 때문이며, 이전에 “변수와 데이터 상호작용: 이동” 섹션에서 본 것과 동일하다. 이 예제에서는 user2를 생성한 후 user1을 더 이상 사용할 수 없다. 왜냐하면 user1username 필드에 있는 Stringuser2로 이동했기 때문이다. 만약 user2emailusername 모두 새로운 String 값을 주고, activesign_in_count 값만 user1에서 가져왔다면, user2를 생성한 후에도 user1은 여전히 유효할 것이다. activesign_in_countCopy 트레이트를 구현하는 타입이므로, “스택 전용 데이터: 복사” 섹션에서 논의한 동작이 적용된다. 이 예제에서는 user1.email을 여전히 사용할 수 있는데, 그 값이 이동되지 않았기 때문이다.

이름 없는 필드로 구성된 튜플 구조체 사용하기

Rust는 튜플과 유사한 구조체인 _튜플 구조체_를 지원한다. 튜플 구조체는 구조체 이름이 제공하는 의미를 가지지만, 필드에 이름을 붙이지는 않는다. 대신 필드의 타입만 지정한다. 튜플 구조체는 전체 튜플에 이름을 부여하고, 다른 튜플과 구별되는 타입을 만들고 싶을 때 유용하다. 또한 일반 구조체처럼 각 필드에 이름을 붙이는 것이 번거롭거나 불필요한 경우에도 사용할 수 있다.

튜플 구조체를 정의하려면 struct 키워드와 구조체 이름을 쓰고, 그 뒤에 튜플의 타입을 나열한다. 예를 들어, ColorPoint라는 두 개의 튜플 구조체를 정의하고 사용해 보자:

Filename: src/main.rs
struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

fn main() {
    let black = Color(0, 0, 0);
    let origin = Point(0, 0, 0);
}

blackorigin 값은 서로 다른 튜플 구조체의 인스턴스이기 때문에 다른 타입이다. 각 구조체는 고유한 타입이며, 구조체 내부의 필드가 동일한 타입을 가질지라도 서로 다른 타입으로 간주된다. 예를 들어, Color 타입의 매개변수를 받는 함수는 Point 타입의 인자를 받을 수 없다. 두 타입 모두 세 개의 i32 값으로 구성되어 있더라도 말이다. 그 외에는 튜플 구조체 인스턴스는 튜플과 유사하게 동작한다. 개별 요소로 분해할 수 있고, . 뒤에 인덱스를 붙여 개별 값에 접근할 수 있다. 튜플과 달리, 튜플 구조체를 분해할 때는 구조체의 타입을 명시해야 한다. 예를 들어, let Point(x, y, z) = point와 같이 작성한다.

필드가 없는 유닛 구조체

필드가 없는 구조체도 정의할 수 있다! 이를 유닛 구조체라고 부르며, 이는 “튜플 타입” 섹션에서 언급한 () 유닛 타입과 유사하게 동작한다. 유닛 구조체는 어떤 타입에 대해 트레이트를 구현해야 하지만, 그 타입 자체에 저장할 데이터가 없을 때 유용하다. 트레이트에 대해서는 10장에서 자세히 다룰 것이다. 다음은 AlwaysEqual이라는 유닛 구조체를 선언하고 인스턴스를 생성하는 예제이다:

Filename: src/main.rs
struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

AlwaysEqual을 정의하려면 struct 키워드와 원하는 이름을 사용한 뒤 세미콜론을 붙인다. 중괄호나 괄호는 필요 없다! 그런 다음 subject 변수에 AlwaysEqual의 인스턴스를 생성할 수 있다. 이때도 중괄호나 괄호 없이 정의한 이름만 사용한다. 나중에 이 타입에 대해 모든 AlwaysEqual 인스턴스가 다른 타입의 모든 인스턴스와 항상 동일하도록 동작을 구현할 수 있다. 예를 들어 테스트 목적으로 알려진 결과를 얻기 위해 사용할 수 있다. 이러한 동작을 구현하기 위해 데이터는 필요하지 않다! 10장에서는 트레이트를 정의하고 유닛 구조체를 포함한 모든 타입에 이를 구현하는 방법을 배울 것이다.

구조체 데이터의 소유권

5-1 예제의 User 구조체 정의에서 &str 문자열 슬라이스 타입 대신 소유권을 가진 String 타입을 사용했다. 이는 의도적인 선택으로, 이 구조체의 각 인스턴스가 자신의 데이터를 소유하고, 구조체 전체가 유효한 동안 그 데이터도 유효하도록 하기 위함이다.

구조체가 다른 곳에서 소유한 데이터에 대한 참조를 저장할 수도 있지만, 이를 위해서는 라이프타임이라는 Rust 기능을 사용해야 한다. 라이프타임은 구조체가 참조하는 데이터가 구조체가 유효한 동안 유효하도록 보장한다. 라이프타임을 지정하지 않고 구조체에 참조를 저장하려고 하면 다음과 같은 문제가 발생한다:

Filename: src/main.rs
struct User {
    active: bool,
    username: &str,
    email: &str,
    sign_in_count: u64,
}

fn main() {
    let user1 = User {
        active: true,
        username: "someusername123",
        email: "someone@example.com",
        sign_in_count: 1,
    };
}

컴파일러는 라이프타임 지정자가 필요하다고 불평할 것이다:

$ cargo run
   Compiling structs v0.1.0 (file:///projects/structs)
error[E0106]: missing lifetime specifier
 --> src/main.rs:3:15
  |
3 |     username: &str,
  |               ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 ~     username: &'a str,
  |

error[E0106]: missing lifetime specifier
 --> src/main.rs:4:12
  |
4 |     email: &str,
  |            ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct User<'a> {
2 |     active: bool,
3 |     username: &str,
4 ~     email: &'a str,
  |

For more information about this error, try `rustc --explain E0106`.
error: could not compile `structs` (bin "structs") due to 2 previous errors

10장에서는 이러한 오류를 수정해 구조체에 참조를 저장하는 방법을 다룰 것이다. 하지만 지금은 &str 같은 참조 대신 String 같은 소유 타입을 사용해 이러한 오류를 해결할 것이다.

구조체를 사용한 예제 프로그램

구조체를 사용해야 하는 상황을 이해하기 위해, 사각형의 넓이를 계산하는 프로그램을 작성해 보자. 먼저 단일 변수를 사용해 시작한 후, 프로그램을 리팩터링하여 구조체를 사용하도록 변경할 것이다.

_rectangles_라는 이름의 새로운 바이너리 프로젝트를 Cargo로 생성한다. 이 프로젝트는 픽셀 단위로 지정된 사각형의 너비와 높이를 받아 넓이를 계산한다. 리스트 5-8은 프로젝트의 src/main.rs 파일에서 이를 구현한 간단한 프로그램을 보여준다.

Filename: src/main.rs
fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}
Listing 5-8: 분리된 너비와 높이 변수로 지정된 사각형의 넓이 계산

이제 cargo run 명령어로 프로그램을 실행한다:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/rectangles`
The area of the rectangle is 1500 square pixels.

이 코드는 각 차원을 area 함수에 전달하여 사각형의 넓이를 계산하는 데 성공한다. 하지만 이 코드를 더 명확하고 읽기 쉽게 만들 수 있다.

이 코드의 문제점은 area 함수의 시그니처에서 명확히 드러난다:

fn main() {
    let width1 = 30;
    let height1 = 50;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(width1, height1)
    );
}

fn area(width: u32, height: u32) -> u32 {
    width * height
}

area 함수는 하나의 사각형 넓이를 계산해야 하지만, 작성한 함수는 두 개의 매개변수를 가지고 있다. 그리고 프로그램 어디에서도 이 매개변수들이 서로 관련이 있다는 것이 명확하지 않다. 너비와 높이를 함께 묶어서 표현하면 더 읽기 쉽고 관리하기 편할 것이다. 이미 “튜플 타입” 섹션에서 이를 구현할 수 있는 한 가지 방법을 논의했다: 튜플을 사용하는 것이다.

튜플을 활용한 리팩토링

리스트 5-9는 튜플을 사용한 프로그램의 또 다른 버전을 보여준다.

Filename: src/main.rs
fn main() {
    let rect1 = (30, 50);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}
Listing 5-9: 튜플을 사용해 직사각형의 너비와 높이 지정

어떤 면에서는 이 프로그램이 더 나아졌다. 튜플을 사용해 약간의 구조를 추가했고, 이제 단 하나의 인자만 전달한다. 하지만 다른 측면에서는 이 버전이 덜 명확하다. 튜플은 요소에 이름을 붙이지 않기 때문에 튜플의 각 부분에 접근하려면 인덱스를 사용해야 한다. 이로 인해 계산 과정이 덜 직관적이 된다.

너비와 높이를 혼동해도 면적 계산에는 문제가 없지만, 화면에 직사각형을 그리려면 문제가 된다. width가 튜플의 인덱스 0이고 height가 인덱스 1이라는 점을 기억해야 한다. 다른 사람이 이 코드를 사용한다면 이를 파악하고 기억하기가 더 어려울 것이다. 코드에서 데이터의 의미를 명확히 전달하지 않았기 때문에 오류가 발생하기 쉬워진 것이다.

구조체를 사용한 리팩토링: 의미 추가하기

데이터에 의미를 부여하기 위해 구조체를 사용한다. 튜플을 구조체로 변환하면 전체와 각 부분에 이름을 붙일 수 있다. 리스트 5-10에서 이를 확인할 수 있다.

Filename: src/main.rs
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.width * rectangle.height
}
Listing 5-10: Rectangle 구조체 정의

여기서 Rectangle이라는 이름의 구조체를 정의했다. 중괄호 안에 widthheight라는 필드를 정의했으며, 두 필드 모두 u32 타입을 가진다. 그런 다음 main 함수에서 Rectangle의 특정 인스턴스를 생성했는데, 이 인스턴스의 너비는 30, 높이는 50이다.

이제 area 함수는 하나의 파라미터를 받는다. 이 파라미터는 rectangle이라는 이름으로, Rectangle 구조체 인스턴스의 불변 참조 타입이다. 4장에서 언급했듯이, 구조체의 소유권을 가져가는 대신 참조를 사용한다. 이렇게 하면 main 함수가 소유권을 유지하고 rect1을 계속 사용할 수 있다. 그래서 함수 시그니처와 함수 호출 시 &를 사용한다.

area 함수는 Rectangle 인스턴스의 widthheight 필드에 접근한다(참조된 구조체 인스턴스의 필드에 접근할 때 필드 값이 이동하지 않는다는 점을 기억하자. 이 때문에 구조체의 참조를 자주 보게 된다). 이제 area 함수의 시그니처는 정확히 우리가 의도한 바를 나타낸다: Rectanglewidthheight 필드를 사용해 면적을 계산한다. 이는 너비와 높이가 서로 관련이 있음을 전달하며, 튜플의 인덱스 값인 01을 사용하는 대신 설명적인 이름을 제공한다. 이는 코드의 명확성을 높이는 데 도움이 된다.

파생 트레이트를 통한 유용한 기능 추가

프로그램을 디버깅할 때 Rectangle 인스턴스를 출력하고 모든 필드의 값을 확인할 수 있다면 매우 유용할 것이다. 목록 5-11에서는 이전 장에서 사용했던 println! 매크로를 사용해 보았다. 하지만 이 방법은 작동하지 않는다.

Filename: src/main.rs
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {}", rect1);
}
Listing 5-11: Rectangle 인스턴스를 출력하려는 시도

이 코드를 컴파일하면 다음과 같은 오류 메시지가 나타난다:

error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`

println! 매크로는 다양한 형식의 포맷팅을 지원하며, 기본적으로 중괄호는 println!에게 Display 형식을 사용하라고 지시한다. 이 형식은 최종 사용자에게 직접 보여주기 위한 출력을 의미한다. 지금까지 본 기본 타입들은 기본적으로 Display를 구현하고 있다. 왜냐하면 1이나 다른 기본 타입을 사용자에게 보여주는 방법은 한 가지뿐이기 때문이다. 하지만 구조체의 경우 println!이 출력을 어떻게 포맷해야 하는지 명확하지 않다. 쉼표를 사용할지, 중괄호를 출력할지, 모든 필드를 보여줄지 등 다양한 가능성이 있기 때문이다. 이러한 모호성 때문에 Rust는 우리가 원하는 것을 추측하려고 하지 않으며, 구조체는 println!{} 자리 표시자와 함께 사용할 수 있는 Display 구현을 제공하지 않는다.

오류를 계속 읽어보면 다음과 같은 유용한 메시지를 찾을 수 있다:

   = help: the trait `std::fmt::Display` is not implemented for `Rectangle`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead

한번 시도해 보자! 이제 println! 매크로 호출은 println!("rect1 is {rect1:?}");와 같이 보일 것이다. 중괄호 안에 :? 지정자를 넣으면 println!에게 Debug라는 출력 형식을 사용하라고 지시한다. Debug 트레이트는 개발자가 코드를 디버깅할 때 유용하도록 구조체를 출력할 수 있게 해준다.

이 변경 사항으로 코드를 컴파일해 보자. 아직도 오류가 발생한다:

error[E0277]: `Rectangle` doesn't implement `Debug`

하지만 다시 컴파일러가 유용한 메시지를 제공한다:

   = help: the trait `Debug` is not implemented for `Rectangle`
   = note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`

Rust는 디버깅 정보를 출력하는 기능을 포함하고 있지만, 우리의 구조체에 대해 이 기능을 사용하려면 명시적으로 선택해야 한다. 이를 위해 구조체 정의 바로 앞에 #[derive(Debug)] 외부 속성을 추가한다. 목록 5-12에서 이를 확인할 수 있다.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!("rect1 is {rect1:?}");
}
Listing 5-12: Debug 트레이트를 파생하기 위한 속성 추가 및 디버그 포맷팅을 사용해 Rectangle 인스턴스 출력

이제 프로그램을 실행하면 오류가 발생하지 않으며 다음과 같은 출력을 확인할 수 있다:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle { width: 30, height: 50 }

좋다! 가장 예쁜 출력은 아니지만, 이 인스턴스의 모든 필드 값을 보여주므로 디버깅 중에 확실히 도움이 될 것이다. 더 큰 구조체를 다룰 때는 읽기 쉬운 출력이 유용할 수 있다. 이 경우 println! 문자열에서 {:?} 대신 {:#?}를 사용할 수 있다. 이 예제에서 {:#?} 스타일을 사용하면 다음과 같은 출력이 나타난다:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/rectangles`
rect1 is Rectangle {
    width: 30,
    height: 50,
}

Debug 형식으로 값을 출력하는 또 다른 방법은 dbg! 매크로를 사용하는 것이다. 이 매크로는 표현식의 소유권을 가져간다(println!은 참조를 가져가는 반면). 그리고 코드에서 dbg! 매크로 호출이 발생한 파일과 줄 번호를 출력하며, 해당 표현식의 결과 값을 출력한 후 값의 소유권을 반환한다.

참고: dbg! 매크로를 호출하면 표준 오류 콘솔 스트림(stderr)에 출력된다. 반면 println!은 표준 출력 콘솔 스트림(stdout)에 출력한다. stderrstdout에 대해서는 12장의 “표준 출력 대신 표준 오류에 에러 메시지 쓰기” 섹션에서 더 자세히 다룰 것이다.

다음 예제에서는 width 필드에 할당되는 값과 rect1의 전체 구조체 값을 확인하고자 한다:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let scale = 2;
    let rect1 = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    };

    dbg!(&rect1);
}

dbg!30 * scale 표현식 주위에 배치할 수 있다. dbg!가 표현식의 값에 대한 소유권을 반환하기 때문에 width 필드는 dbg! 호출이 없을 때와 동일한 값을 얻는다. dbg!rect1의 소유권을 가져가지 않도록 다음 호출에서는 rect1에 대한 참조를 사용한다. 이 예제의 출력은 다음과 같다:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/rectangles`
[src/main.rs:10:16] 30 * scale = 60
[src/main.rs:14:5] &rect1 = Rectangle {
    width: 60,
    height: 50,
}

출력의 첫 번째 부분은 _src/main.rs_의 10번째 줄에서 30 * scale 표현식을 디버깅한 결과이며, 그 결과 값은 60이다(정수에 대한 Debug 포맷팅은 값만 출력한다). _src/main.rs_의 14번째 줄에서 dbg! 호출은 &rect1의 값을 출력하며, 이는 Rectangle 구조체이다. 이 출력은 Rectangle 타입의 예쁜 Debug 포맷팅을 사용한다. dbg! 매크로는 코드가 무엇을 하는지 파악하려고 할 때 매우 유용할 수 있다!

Debug 트레이트 외에도 Rust는 derive 속성과 함께 사용할 수 있는 여러 트레이트를 제공하여 커스텀 타입에 유용한 동작을 추가할 수 있다. 이러한 트레이트와 그 동작은 부록 C에 나열되어 있다. 10장에서는 커스텀 동작으로 이러한 트레이트를 구현하는 방법과 자신만의 트레이트를 만드는 방법을 다룰 것이다. 또한 derive 외에도 많은 속성이 있다. 더 많은 정보는 Rust Reference의 “Attributes” 섹션을 참조하라.

우리의 area 함수는 매우 구체적이다. 이 함수는 직사각형의 면적만 계산한다. 이 동작을 Rectangle 구조체와 더 밀접하게 연결하는 것이 도움이 될 것이다. 왜냐하면 이 함수는 다른 타입에서는 작동하지 않기 때문이다. 이제 area 함수를 Rectangle 타입에 정의된 area _메서드_로 리팩토링하는 방법을 살펴보자.

메서드 문법

메서드는 함수와 유사하다. fn 키워드와 이름을 사용해 선언하고, 매개변수와 반환 값을 가질 수 있으며, 다른 곳에서 호출될 때 실행될 코드를 포함한다. 함수와 달리 메서드는 구조체의 컨텍스트 내에서 정의된다(또는 열거형이나 트레잇 객체 내에서 정의될 수 있으며, 이에 대해서는 각각 6장18장에서 다룬다). 그리고 메서드의 첫 번째 매개변수는 항상 self이며, 이는 메서드가 호출된 구조체의 인스턴스를 나타낸다.

메서드 정의하기

Rectangle 인스턴스를 매개변수로 받는 area 함수를 변경해 Rectangle 구조체에 정의된 area 메서드로 만들어 보자. 이 내용은 리스트 5-13에 나와 있다.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}
Listing 5-13: Rectangle 구조체에 area 메서드 정의하기

Rectangle의 컨텍스트 내에서 함수를 정의하려면 Rectangle에 대한 impl(구현) 블록을 시작한다. 이 impl 블록 내의 모든 것은 Rectangle 타입과 연관된다. 그런 다음 area 함수를 impl의 중괄호 안으로 이동시키고, 시그니처와 본문 내의 첫 번째(이 경우 유일한) 매개변수를 self로 변경한다. main 함수에서는 area 함수를 호출하고 rect1을 인자로 전달했던 부분을, 이제는 메서드 문법 을 사용해 Rectangle 인스턴스의 area 메서드를 호출하도록 변경할 수 있다. 메서드 문법은 인스턴스 뒤에 온다: 점(.)을 추가한 후 메서드 이름, 괄호, 그리고 필요한 인자를 적는다.

area의 시그니처에서 rectangle: &Rectangle 대신 &self를 사용한다. &self는 실제로 self: &Self의 축약형이다. impl 블록 내에서 Self 타입은 impl 블록이 적용되는 타입의 별칭이다. 메서드는 첫 번째 매개변수로 Self 타입의 self를 가져야 하므로, Rust는 첫 번째 매개변수 위치에 단순히 self만 적어도 되도록 허용한다. 여전히 self 앞에 &를 사용해 이 메서드가 Self 인스턴스를 빌린다는 것을 나타내야 한다. 이는 rectangle: &Rectangle에서와 동일하다. 메서드는 self의 소유권을 가져갈 수도 있고, 여기서처럼 불변으로 빌릴 수도 있으며, 가변으로 빌릴 수도 있다. 이는 다른 매개변수와 동일하다.

여기서 &self를 선택한 이유는 함수 버전에서 &Rectangle을 사용한 이유와 동일하다: 소유권을 가져가고 싶지 않으며, 구조체의 데이터를 읽기만 하고 쓰지는 않기 때문이다. 만약 메서드가 호출된 인스턴스를 변경하고 싶다면 첫 번째 매개변수로 &mut self를 사용한다. 단순히 self를 첫 번째 매개변수로 사용해 인스턴스의 소유권을 가져가는 메서드는 드물다; 이 기법은 보통 메서드가 self를 다른 것으로 변환하고, 변환 후에 원본 인스턴스를 사용하지 못하게 하려는 경우에 사용된다.

메서드를 함수 대신 사용하는 주요 이유는 메서드 문법을 제공하고, 모든 메서드의 시그니처에서 self의 타입을 반복하지 않아도 되기 때문이다. 또한, 코드를 조직화하는 데에도 도움이 된다. 우리는 타입의 인스턴스로 할 수 있는 모든 작업을 하나의 impl 블록에 넣어, 나중에 우리가 제공한 라이브러리에서 Rectangle의 기능을 찾기 위해 여러 곳을 헤매지 않도록 했다.

메서드 이름을 구조체 필드와 동일하게 지을 수도 있다. 예를 들어, Rectanglewidth라는 메서드를 정의할 수 있다:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    if rect1.width() {
        println!("The rectangle has a nonzero width; it is {}", rect1.width);
    }
}

여기서는 width 메서드가 인스턴스의 width 필드 값이 0보다 크면 true를 반환하고, 0이면 false를 반환하도록 했다: 동일한 이름의 필드를 메서드 내에서 어떤 목적으로든 사용할 수 있다. main 함수에서 rect1.width 뒤에 괄호를 붙이면 Rust는 width 메서드를 의미한다고 이해한다. 괄호를 사용하지 않으면 Rust는 width 필드를 의미한다고 이해한다.

종종, 항상은 아니지만, 메서드 이름을 필드와 동일하게 지을 때는 필드의 값을 반환만 하고 다른 작업을 하지 않도록 하는 경우가 많다. 이런 메서드를 getter 라고 부르며, Rust는 다른 언어와 달리 구조체 필드에 대해 자동으로 getter를 구현하지 않는다. Getter는 필드를 private로 만들고 메서드를 public으로 만들어, 타입의 public API의 일부로 해당 필드에 대한 읽기 전용 접근을 허용할 때 유용하다. public과 private이 무엇인지, 그리고 필드나 메서드를 public 또는 private으로 지정하는 방법은 7장에서 다룬다.

-> 연산자는 어디에 있나요?

C와 C++에서는 메서드를 호출할 때 두 가지 다른 연산자를 사용한다: 객체에 직접 메서드를 호출할 때는 .를 사용하고, 객체에 대한 포인터에서 메서드를 호출할 때는 ->를 사용해 포인터를 먼저 역참조한다. 즉, object가 포인터라면 object->something()(*object).something()과 유사하다.

Rust는 -> 연산자에 해당하는 것이 없다; 대신 Rust에는 자동 참조 및 역참조 라는 기능이 있다. 메서드 호출은 Rust에서 이 동작이 적용되는 몇 안 되는 경우 중 하나다.

이 기능은 다음과 같이 동작한다: object.something()으로 메서드를 호출할 때, Rust는 자동으로 &, &mut, 또는 *를 추가해 object가 메서드의 시그니처와 일치하도록 한다. 즉, 다음 두 코드는 동일하다:

#![allow(unused)]
fn main() {
#[derive(Debug,Copy,Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
   fn distance(&self, other: &Point) -> f64 {
       let x_squared = f64::powi(other.x - self.x, 2);
       let y_squared = f64::powi(other.y - self.y, 2);

       f64::sqrt(x_squared + y_squared)
   }
}
let p1 = Point { x: 0.0, y: 0.0 };
let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);
}

첫 번째 코드가 훨씬 깔끔해 보인다. 이 자동 참조 동작은 메서드가 명확한 수신자(self의 타입)를 가지고 있기 때문에 가능하다. 수신자와 메서드 이름이 주어지면 Rust는 메서드가 읽기(&self), 변경(&mut self), 또는 소비(self) 중 어떤 작업을 하는지 명확히 파악할 수 있다. Rust가 메서드 수신자에 대한 빌림을 암묵적으로 처리하는 것은 소유권을 실용적으로 만드는 데 큰 역할을 한다.

추가 매개변수를 가진 메서드

Rectangle 구조체에 두 번째 메서드를 구현하며 메서드 사용법을 연습해 보자. 이번에는 Rectangle 인스턴스가 다른 Rectangle 인스턴스를 받아서 두 번째 Rectangleself(첫 번째 Rectangle) 안에 완전히 들어갈 수 있으면 true를 반환하고, 그렇지 않으면 false를 반환하도록 한다. 즉, can_hold 메서드를 정의한 후에는 아래 예제와 같은 프로그램을 작성할 수 있어야 한다.

Filename: src/main.rs
fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-14: 아직 작성되지 않은 can_hold 메서드 사용 예제

rect2의 두 차원 모두 rect1보다 작지만, rect3rect1보다 넓기 때문에 예상 출력은 다음과 같다:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

메서드를 정의할 것이므로 impl Rectangle 블록 안에 작성한다. 메서드 이름은 can_hold로 정하고, 다른 Rectangle 인스턴스를 불변 참조로 받을 것이다. 메서드를 호출하는 코드를 보면 매개변수의 타입을 알 수 있다: rect1.can_hold(&rect2)에서 &rect2를 전달하며, 이는 Rectangle 인스턴스인 rect2의 불변 참조다. 이는 rect2를 읽기만 하면 되고(쓰기는 필요하지 않으므로 가변 참조가 필요하지 않음), can_hold 메서드 호출 후에도 main 함수가 rect2의 소유권을 유지할 수 있도록 하기 때문이다. can_hold의 반환 값은 불리언 타입이며, 구현에서는 self의 너비와 높이가 다른 Rectangle의 너비와 높이보다 각각 큰지 확인한다. 이제 can_hold 메서드를 impl 블록에 추가해 보자.

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-15: Rectanglecan_hold 메서드 구현

Listing 5-14의 main 함수와 함께 이 코드를 실행하면 원하는 출력을 얻을 수 있다. 메서드는 self 매개변수 뒤에 추가 매개변수를 받을 수 있으며, 이 매개변수들은 함수의 매개변수와 동일하게 동작한다.

연관 함수

impl 블록 내에 정의된 모든 함수는 _연관 함수_라고 한다. 이 함수들은 impl 뒤에 명시된 타입과 연관되어 있기 때문이다. self를 첫 번째 매개변수로 가지지 않는 연관 함수도 정의할 수 있다. 이런 함수는 메서드가 아니며, 타입의 인스턴스가 필요하지 않다. 이미 이런 함수를 사용한 적이 있다. 바로 String 타입에 정의된 String::from 함수가 그 예시다.

메서드가 아닌 연관 함수는 주로 새로운 구조체 인스턴스를 반환하는 생성자로 사용된다. 이런 함수는 보통 new라고 이름 짓지만, new는 특별한 이름이 아니며 언어에 내장된 기능도 아니다. 예를 들어, square라는 연관 함수를 정의할 수 있다. 이 함수는 하나의 차원 매개변수를 받아 너비와 높이로 사용하며, 정사각형 Rectangle을 쉽게 생성할 수 있게 해준다. 이렇게 하면 같은 값을 두 번 지정할 필요가 없다:

파일명: src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

함수의 반환 타입과 본문에서 사용된 Self 키워드는 impl 뒤에 오는 타입의 별칭이다. 이 경우에는 Rectangle이다.

이 연관 함수를 호출하려면 구조체 이름과 :: 문법을 사용한다. 예를 들어 let sq = Rectangle::square(3);와 같이 호출할 수 있다. 이 함수는 구조체에 의해 네임스페이스가 지정된다. :: 문법은 연관 함수와 모듈에 의해 생성된 네임스페이스에 모두 사용된다. 모듈에 대해서는 7장에서 자세히 다룬다.

여러 개의 impl 블록

각 구조체는 여러 개의 impl 블록을 가질 수 있다. 예를 들어, 리스트 5-15는 각 메서드를 별도의 impl 블록에 나눈 리스트 5-16과 동일한 코드다.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}
Listing 5-16: 여러 개의 impl 블록을 사용해 리스트 5-15를 다시 작성

여기서는 이 메서드들을 여러 impl 블록으로 나눌 필요가 없지만, 문법적으로 유효한 방식이다. 제네릭 타입과 트레이트를 다루는 10장에서 여러 impl 블록이 유용한 경우를 살펴볼 것이다.

요약

구조체를 사용하면 특정 도메인에 의미 있는 커스텀 타입을 만들 수 있다. 구조체를 통해 서로 연관된 데이터를 하나로 묶고, 각 데이터에 이름을 붙여 코드의 가독성을 높일 수 있다. impl 블록에서는 해당 타입과 연관된 함수를 정의할 수 있으며, 메서드는 구조체의 인스턴스가 가질 동작을 지정하는 연관 함수의 한 종류이다.

하지만 구조체만이 커스텀 타입을 만드는 유일한 방법은 아니다. 이제 Rust의 열거형(enum) 기능을 살펴보며 도구 상자에 또 하나의 유용한 도구를 추가해 보자.

열거형과 패턴 매칭

이 장에서는 열거형(enumerations), 줄여서 enum에 대해 살펴본다.
열거형은 가능한 변형(variants)을 나열하여 타입을 정의할 수 있게 한다. 먼저 열거형을 정의하고 사용하는 방법을 통해 데이터와 함께 의미를 어떻게 표현할 수 있는지 알아본다. 다음으로, 값이 존재하거나 존재하지 않을 수 있음을 표현하는 Option이라는 유용한 열거형을 살펴본다. 그 후, match 표현식에서 패턴 매칭을 사용해 열거형의 다른 값에 따라 다른 코드를 쉽게 실행하는 방법을 알아본다. 마지막으로, if let 구문을 사용해 열거형을 처리하는 간편하고 간결한 방법을 소개한다.

열거형 정의하기

구조체는 widthheight 같은 관련 필드와 데이터를 그룹화하는 방법을 제공한다. 반면, 열거형은 값이 가능한 집합 중 하나임을 표현하는 방법을 제공한다. 예를 들어, RectangleCircleTriangle도 포함하는 가능한 도형 집합 중 하나라고 말하고 싶을 수 있다. 이를 위해 Rust는 이러한 가능성을 열거형으로 인코딩할 수 있게 한다.

코드로 표현하고 싶은 상황을 살펴보고, 이 경우에 왜 열거형이 유용하고 구조체보다 더 적합한지 알아보자. IP 주소를 다루어야 한다고 가정해보자. 현재 IP 주소에는 두 가지 주요 표준이 사용된다: 버전 4와 버전 6이다. 우리 프로그램이 만날 수 있는 IP 주소의 가능성이 이 두 가지뿐이므로, 모든 가능한 변형을 열거할 수 있다. 이것이 열거형이라는 이름의 유래이다.

어떤 IP 주소도 버전 4나 버전 6 중 하나일 수 있지만, 동시에 둘 다일 수는 없다. IP 주소의 이러한 특성은 열거형 데이터 구조를 적합하게 만든다. 왜냐하면 열거형 값은 오직 하나의 변형만 될 수 있기 때문이다. 버전 4와 버전 6 주소는 근본적으로 여전히 IP 주소이므로, 코드가 어떤 종류의 IP 주소에도 적용되는 상황을 처리할 때 동일한 타입으로 취급되어야 한다.

이 개념을 코드로 표현하기 위해 IpAddrKind 열거형을 정의하고, IP 주소가 될 수 있는 가능한 종류인 V4V6를 나열할 수 있다. 이들은 열거형의 변형이다:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

IpAddrKind는 이제 우리 코드의 다른 곳에서 사용할 수 있는 커스텀 데이터 타입이 된다.

열거형 값

IpAddrKind의 두 가지 변형을 다음과 같이 인스턴스화할 수 있다:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

열거형의 변형은 해당 식별자 아래에 네임스페이스로 구분되며, 두 개의 콜론(::)을 사용해 구분한다. 이는 IpAddrKind::V4IpAddrKind::V6가 모두 동일한 타입인 IpAddrKind로 간주되기 때문에 유용하다. 예를 들어, IpAddrKind 타입을 인자로 받는 함수를 정의할 수 있다:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

그리고 이 함수를 두 변형 중 어느 것으로도 호출할 수 있다:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

열거형을 사용하면 더 많은 장점이 있다. IP 주소 타입에 대해 더 깊이 생각해보면, 현재는 실제 IP 주소 데이터를 저장할 방법이 없다. 단지 어떤 종류인지만 알고 있을 뿐이다. 5장에서 구조체를 배웠기 때문에, 이를 구조체로 해결하려는 생각이 들 수 있다. 리스팅 6-1은 이를 보여준다.

fn main() {
    enum IpAddrKind {
        V4,
        V6,
    }

    struct IpAddr {
        kind: IpAddrKind,
        address: String,
    }

    let home = IpAddr {
        kind: IpAddrKind::V4,
        address: String::from("127.0.0.1"),
    };

    let loopback = IpAddr {
        kind: IpAddrKind::V6,
        address: String::from("::1"),
    };
}
Listing 6-1: 구조체를 사용해 IP 주소의 데이터와 IpAddrKind 변형 저장

여기서 IpAddr 구조체를 정의했으며, 두 개의 필드를 가지고 있다: IpAddrKind 타입의 kind 필드와 String 타입의 address 필드. 이 구조체의 두 인스턴스가 있다. 첫 번째는 home이며, kind 값으로 IpAddrKind::V4를 가지고 있고, 관련 주소 데이터는 127.0.0.1이다. 두 번째 인스턴스는 loopback이며, kind 값으로 IpAddrKind::V6를 가지고 있고, 관련 주소 데이터는 ::1이다. 이렇게 구조체를 사용해 kindaddress 값을 함께 묶었기 때문에, 이제 변형이 값과 연결된다.

그러나 열거형만 사용해 동일한 개념을 표현하는 것이 더 간결하다: 구조체 안에 열거형을 넣는 대신, 데이터를 각 열거형 변형에 직접 넣을 수 있다. 이 새로운 IpAddr 열거형 정의는 V4V6 변형이 모두 String 값을 가질 것이라고 명시한다:

fn main() {
    enum IpAddr {
        V4(String),
        V6(String),
    }

    let home = IpAddr::V4(String::from("127.0.0.1"));

    let loopback = IpAddr::V6(String::from("::1"));
}

각 열거형 변형에 데이터를 직접 첨부했기 때문에, 추가적인 구조체가 필요 없다. 여기서 열거형이 어떻게 동작하는지 또 다른 세부 사항을 더 쉽게 확인할 수 있다: 정의한 각 열거형 변형의 이름은 해당 열거형의 인스턴스를 생성하는 함수가 된다. 즉, IpAddr::V4()String 인자를 받아 IpAddr 타입의 인스턴스를 반환하는 함수 호출이다. 열거형을 정의함으로써 자동으로 이 생성자 함수가 정의된다.

열거형을 사용하는 또 다른 장점은 각 변형이 서로 다른 타입과 양의 데이터를 가질 수 있다는 점이다. 버전 4 IP 주소는 항상 0에서 255 사이의 값을 가진 네 개의 숫자 구성 요소를 가진다. 만약 V4 주소를 네 개의 u8 값으로 저장하고 싶지만, V6 주소는 하나의 String 값으로 표현하고 싶다면, 구조체로는 이를 할 수 없다. 열거형은 이러한 경우를 쉽게 처리한다:

fn main() {
    enum IpAddr {
        V4(u8, u8, u8, u8),
        V6(String),
    }

    let home = IpAddr::V4(127, 0, 0, 1);

    let loopback = IpAddr::V6(String::from("::1"));
}

버전 4와 버전 6 IP 주소를 저장하기 위한 데이터 구조를 정의하는 여러 가지 방법을 보여줬다. 그러나 IP 주소를 저장하고 어떤 종류인지 인코딩하는 것은 매우 일반적인 요구 사항이기 때문에, 표준 라이브러리에서 사용할 수 있는 정의가 있다! 표준 라이브러리가 IpAddr를 어떻게 정의하는지 살펴보자: 우리가 정의하고 사용한 것과 동일한 열거형과 변형을 가지고 있지만, 주소 데이터를 두 개의 다른 구조체 형태로 변형 안에 포함시킨다. 각 변형에 대해 다르게 정의된 구조체이다:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

이 코드는 열거형 변형 안에 어떤 종류의 데이터든 넣을 수 있음을 보여준다: 문자열, 숫자 타입, 또는 구조체 등. 심지어 다른 열거형도 포함할 수 있다! 또한 표준 라이브러리 타입은 종종 여러분이 생각해낸 것보다 훨씬 복잡하지 않다.

표준 라이브러리에 IpAddr 정의가 포함되어 있더라도, 표준 라이브러리의 정의를 우리의 스코프로 가져오지 않았기 때문에 여전히 우리만의 정의를 생성하고 사용할 수 있다. 7장에서 타입을 스코프로 가져오는 방법에 대해 더 자세히 다룰 것이다.

리스팅 6-2에서 또 다른 열거형 예제를 살펴보자: 이 열거형은 다양한 타입의 값을 변형 안에 포함한다.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}
Listing 6-2: 각 변형이 서로 다른 양과 타입의 값을 저장하는 Message 열거형

이 열거형은 네 가지 변형을 가지고 있으며, 각 변형은 서로 다른 타입을 가진다:

  • Quit은 어떤 데이터도 가지고 있지 않다.
  • Move는 구조체처럼 이름이 있는 필드를 가진다.
  • Write는 단일 String을 포함한다.
  • ChangeColor는 세 개의 i32 값을 포함한다.

리스팅 6-2와 같은 변형을 가진 열거형을 정의하는 것은 다양한 종류의 구조체 정의를 만드는 것과 유사하지만, 열거형은 struct 키워드를 사용하지 않고 모든 변형이 Message 타입 아래에 그룹화된다. 다음 구조체들은 앞서 열거형 변형이 가지고 있는 것과 동일한 데이터를 보유할 수 있다:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

fn main() {}

그러나 각각의 구조체가 자신만의 타입을 가지고 있다면, 리스팅 6-2에서 정의한 Message 열거형처럼 단일 타입으로 이러한 종류의 메시지를 받는 함수를 쉽게 정의할 수 없다.

열거형과 구조체 사이에는 또 다른 유사점이 있다: 구조체에 impl을 사용해 메서드를 정의할 수 있는 것처럼, 열거형에도 메서드를 정의할 수 있다. 다음은 Message 열거형에 정의할 수 있는 call이라는 메서드의 예시이다:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // method body would be defined here
        }
    }

    let m = Message::Write(String::from("hello"));
    m.call();
}

메서드의 본문은 self를 사용해 메서드를 호출한 값을 가져온다. 이 예제에서 m이라는 변수를 생성했으며, 이 변수는 Message::Write(String::from("hello")) 값을 가진다. 따라서 m.call()을 실행할 때 call 메서드의 본문에서 self는 이 값이 된다.

표준 라이브러리에서 매우 일반적이고 유용한 또 다른 열거형인 Option을 살펴보자.

Option 열거형과 Null 값 대비 장점

이 섹션에서는 표준 라이브러리에 정의된 또 다른 열거형인 Option에 대한 사례를 살펴본다. Option 타입은 값이 존재할 수도 있고 없을 수도 있는 매우 일반적인 시나리오를 인코딩한다.

예를 들어, 비어 있지 않은 리스트에서 첫 번째 항목을 요청하면 값을 얻을 수 있다. 하지만 빈 리스트에서 첫 번째 항목을 요청하면 아무것도 얻지 못한다. 이 개념을 타입 시스템으로 표현하면 컴파일러가 모든 경우를 처리했는지 확인할 수 있다. 이 기능은 다른 프로그래밍 언어에서 매우 흔히 발생하는 버그를 방지할 수 있다.

프로그래밍 언어 설계는 어떤 기능을 포함할지에 대해 고민하는 경우가 많지만, 어떤 기능을 제외할지도 중요하다. Rust는 다른 많은 언어가 가지고 있는 null 기능을 제공하지 않는다. _Null_은 값이 없음을 의미하는 값이다. null을 지원하는 언어에서는 변수가 항상 두 가지 상태 중 하나일 수 있다: null이거나 null이 아니거나.

2009년 발표에서 null의 발명가인 Tony Hoare는 이렇게 말했다:

나는 이것을 10억 달러짜리 실수라고 부른다. 당시 나는 객체 지향 언어에서 참조를 위한 첫 번째 포괄적인 타입 시스템을 설계하고 있었다. 내 목표는 모든 참조 사용이 절대적으로 안전하도록 하고, 컴파일러가 자동으로 검사하도록 하는 것이었다. 하지만 null 참조를 추가하는 유혹을 이기지 못했고, 단순히 구현하기 쉬웠기 때문이었다. 이것은 셀 수 없이 많은 오류, 취약점, 시스템 충돌을 초래했고, 지난 40년 동안 10억 달러의 고통과 손실을 초래했다.

null 값의 문제는 null 값을 null이 아닌 값처럼 사용하려고 하면 어떤 종류의 오류가 발생한다는 점이다. null 또는 null이 아닌 속성이 광범위하게 적용되기 때문에 이런 종류의 오류를 범하기가 매우 쉽다.

하지만 null이 표현하려는 개념은 여전히 유용하다: null은 어떤 이유로 현재 유효하지 않거나 존재하지 않는 값을 의미한다.

문제는 개념 자체가 아니라 특정 구현에 있다. 따라서 Rust는 null을 제공하지 않지만, 값이 존재하거나 존재하지 않음을 인코딩할 수 있는 열거형을 제공한다. 이 열거형이 바로 Option<T>이며, 표준 라이브러리에 다음과 같이 정의되어 있다:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

Option<T> 열거형은 매우 유용하기 때문에 프리루드에 포함되어 있다. 따라서 명시적으로 스코프로 가져올 필요가 없다. 또한 SomeNone 변형도 프리루드에 포함되어 있어 Option:: 접두사 없이 바로 사용할 수 있다. Option<T> 열거형은 여전히 일반적인 열거형이며, Some(T)NoneOption<T> 타입의 변형이다.

<T> 문법은 아직 다루지 않은 Rust의 기능이다. 이것은 제네릭 타입 매개변수이며, 제네릭에 대해서는 10장에서 더 자세히 다룰 것이다. 지금은 <T>Option 열거형의 Some 변형이 어떤 타입의 데이터도 하나씩 보유할 수 있음을 의미한다는 점만 알면 된다. 그리고 T 대신 사용되는 각 구체적인 타입은 전체 Option<T> 타입을 다른 타입으로 만든다. 다음은 숫자 타입과 문자 타입을 보유하기 위해 Option 값을 사용하는 예제이다:

fn main() {
    let some_number = Some(5);
    let some_char = Some('e');

    let absent_number: Option<i32> = None;
}

some_number의 타입은 Option<i32>이다. some_char의 타입은 Option<char>로, 다른 타입이다. Rust는 Some 변형 안에 값을 지정했기 때문에 이 타입들을 추론할 수 있다. absent_number의 경우 Rust는 전체 Option 타입을 명시하도록 요구한다: 컴파일러는 None 값만 보고 해당 Some 변형이 보유할 타입을 추론할 수 없다. 여기서 우리는 absent_numberOption<i32> 타입이 되도록 명시한다.

Some 값을 가지고 있다면 값이 존재하며 그 값이 Some 안에 보관되어 있음을 알 수 있다. None 값을 가지고 있다면 어떤 의미에서는 null과 동일하다: 유효한 값이 없다. 그렇다면 Option<T>를 사용하는 것이 null을 사용하는 것보다 왜 더 나을까?

간단히 말하면, Option<T>T(여기서 T는 어떤 타입이든 될 수 있음)는 다른 타입이기 때문에, 컴파일러는 Option<T> 값을 마치 확실히 유효한 값인 것처럼 사용하지 못하게 한다. 예를 들어, 이 코드는 i8Option<i8>에 더하려고 하기 때문에 컴파일되지 않는다:

fn main() {
    let x: i8 = 5;
    let y: Option<i8> = Some(5);

    let sum = x + y;
}

이 코드를 실행하면 다음과 같은 오류 메시지가 나타난다:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            `&i8` implements `Add<i8>`
            `&i8` implements `Add`
            `i8` implements `Add<&i8>`
            `i8` implements `Add`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error

이 오류 메시지는 Rust가 i8Option<i8>을 더하는 방법을 이해하지 못한다는 것을 의미한다. 왜냐하면 이들은 다른 타입이기 때문이다. Rust에서 i8과 같은 타입의 값을 가지고 있다면, 컴파일러는 우리가 항상 유효한 값을 가지고 있음을 보장한다. 따라서 그 값을 사용하기 전에 null을 확인할 필요 없이 안심하고 진행할 수 있다. Option<i8>(또는 우리가 작업 중인 어떤 타입의 값)을 가지고 있을 때만 값이 없을 가능성을 고려해야 하며, 컴파일러는 그 값을 사용하기 전에 그 경우를 처리하도록 강제한다.

다시 말해, Option<T>T로 변환해야만 T 연산을 수행할 수 있다. 일반적으로 이는 null과 관련된 가장 흔한 문제 중 하나인, 실제로는 null인데 null이 아니라고 가정하는 경우를 잡아내는 데 도움이 된다.

null이 아니라고 잘못 가정하는 위험을 제거하면 코드에 더 확신을 가질 수 있다. null일 가능성이 있는 값을 가지려면, 그 값의 타입을 Option<T>로 명시적으로 선택해야 한다. 그리고 그 값을 사용할 때는 값이 null인 경우를 명시적으로 처리해야 한다. Option<T>가 아닌 타입의 값은 어디에서나 안전하게 null이 아니라고 가정할 수 있다. 이는 Rust가 null의 광범위한 사용을 제한하고 Rust 코드의 안전성을 높이기 위해 의도적으로 설계한 결정이다.

그렇다면 Option<T> 타입의 값에서 Some 변형 안의 T 값을 어떻게 꺼내서 사용할 수 있을까? Option<T> 열거형에는 다양한 상황에서 유용한 많은 메서드가 있다; 문서에서 확인할 수 있다. Option<T>의 메서드를 익히는 것은 Rust를 사용하는 데 있어 매우 유용할 것이다.

일반적으로 Option<T> 값을 사용하려면 각 변형을 처리할 코드가 필요하다. Some(T) 값을 가지고 있을 때만 실행될 코드가 필요하며, 이 코드는 내부의 T를 사용할 수 있다. None 값을 가지고 있을 때만 실행될 다른 코드도 필요하며, 이 코드는 T 값을 사용할 수 없다. match 표현식은 열거형과 함께 사용할 때 이 작업을 수행하는 제어 흐름 구조이다: 열거형의 어떤 변형을 가지고 있는지에 따라 다른 코드를 실행하며, 해당 코드는 매칭된 값 안의 데이터를 사용할 수 있다.

match 제어 흐름 구조

Rust는 match라는 매우 강력한 제어 흐름 구조를 제공한다. match는 값을 일련의 패턴과 비교한 후, 어떤 패턴이 일치하는지에 따라 코드를 실행한다. 패턴은 리터럴 값, 변수명, 와일드카드 등 다양한 요소로 구성될 수 있다. 19장에서는 모든 종류의 패턴과 그 기능에 대해 다룬다. match의 강점은 패턴의 표현력과 컴파일러가 모든 가능한 경우를 처리하도록 보장한다는 점이다.

match 표현식을 동전 분류기로 생각해보자. 동전은 다양한 크기의 구멍이 있는 트랙을 따라 미끄러지며, 동전이 맞는 첫 번째 구멍으로 떨어진다. 마찬가지로 match에서 값은 각 패턴을 거치며, 값이 ‘맞는’ 첫 번째 패턴에 도달하면 해당 코드 블록으로 들어가 실행된다.

동전을 예로 들어 match를 사용해보자. 알 수 없는 미국 동전을 받아 동전 분류기와 비슷한 방식으로 동전의 종류를 판별하고 센트 단위로 값을 반환하는 함수를 작성할 수 있다. Listing 6-3에서 이를 확인할 수 있다.

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}
Listing 6-3: 열거형과 그 열거형의 변형을 패턴으로 사용하는 match 표현식

value_in_cents 함수의 match를 자세히 살펴보자. 먼저 match 키워드를 작성한 후 표현식을 나열한다. 여기서는 coin 값이 해당한다. 이는 if와 함께 사용되는 조건문과 매우 유사해 보이지만, 큰 차이가 있다. if의 조건은 불리언 값으로 평가되어야 하지만, match는 어떤 타입이든 가능하다. 이 예제에서 coin의 타입은 첫 줄에서 정의한 Coin 열거형이다.

다음은 match의 각 가지(arm)이다. 각 가지는 패턴과 코드 두 부분으로 구성된다. 첫 번째 가지는 Coin::Penny 값을 패턴으로 사용하며, => 연산자를 통해 패턴과 실행할 코드를 구분한다. 여기서 코드는 단순히 1 값이다. 각 가지는 쉼표로 구분된다.

match 표현식이 실행되면, 결과 값을 각 가지의 패턴과 순서대로 비교한다. 패턴이 값과 일치하면 해당 패턴과 연결된 코드가 실행된다. 패턴이 값과 일치하지 않으면 다음 가지로 실행이 계속된다. 동전 분류기와 마찬가지로 필요한 만큼 가지를 추가할 수 있다. Listing 6-3에서는 네 개의 가지가 있다.

각 가지의 코드는 표현식이며, 일치하는 가지의 표현식 결과 값이 전체 match 표현식의 반환 값이 된다.

일반적으로 짧은 코드의 경우 중괄호를 사용하지 않는다. Listing 6-3에서 각 가지는 단순히 값을 반환한다. 만약 한 가지에서 여러 줄의 코드를 실행하고 싶다면 중괄호를 사용해야 하며, 이 경우 가지 뒤의 쉼표는 선택 사항이다. 예를 들어, 다음 코드는 Coin::Penny로 메서드가 호출될 때마다 “Lucky penny!“를 출력하지만, 여전히 블록의 마지막 값인 1을 반환한다:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        }
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

fn main() {}

값에 바인딩하는 패턴

매치 갈래의 또 다른 유용한 기능은 패턴과 일치하는 값의 일부에 바인딩할 수 있다는 점이다. 이를 통해 열거형(enum) 변형에서 값을 추출할 수 있다.

예를 들어, 열거형의 변형 중 하나를 내부에 데이터를 포함하도록 변경해보자. 1999년부터 2008년까지 미국에서는 각 주마다 다른 디자인을 가진 쿼터 동전을 발행했다. 다른 동전에는 주 디자인이 없기 때문에, 쿼터만이 이 추가 값을 가진다. Quarter 변형을 UsState 값을 포함하도록 변경하면 이 정보를 열거형에 추가할 수 있다. 이를 Listing 6-4에서 구현했다.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {}
Listing 6-4: Quarter 변형이 UsState 값을 포함하는 Coin 열거형

친구가 50개 주의 쿼터를 모두 수집하려 한다고 상상해보자. 동전 종류별로 잔돈을 정리하면서, 각 쿼터와 관련된 주의 이름도 함께 알려주면 친구가 아직 가지고 있지 않은 쿼터를 수집할 수 있다.

이 코드의 매치 표현식에서, Coin::Quarter 변형과 일치하는 패턴에 state라는 변수를 추가한다. Coin::Quarter가 매치되면, state 변수는 해당 쿼터의 주 값에 바인딩된다. 그런 다음 이 갈래의 코드에서 state를 사용할 수 있다.

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {state:?}!");
            25
        }
    }
}

fn main() {
    value_in_cents(Coin::Quarter(UsState::Alaska));
}

value_in_cents(Coin::Quarter(UsState::Alaska))를 호출하면, coinCoin::Quarter(UsState::Alaska)가 된다. 이 값을 각 매치 갈래와 비교할 때, Coin::Quarter(state)에 도달할 때까지 아무것도 일치하지 않는다. 그 시점에서 state의 바인딩은 UsState::Alaska 값이 된다. 이 바인딩을 println! 표현식에서 사용하여, Quarter 열거형 변형에서 내부의 주 값을 추출할 수 있다.

Option<T>와 매칭하기

이전 섹션에서 Option<T>를 사용할 때 Some 케이스 안에 있는 T 값을 꺼내고 싶었다. Coin 열거형에서 했던 것처럼 match를 사용해 Option<T>를 처리할 수도 있다. 동전을 비교하는 대신 Option<T>의 변형을 비교하지만, match 표현식의 동작 방식은 동일하다.

예를 들어, Option<i32>를 받아서 안에 값이 있으면 그 값에 1을 더하는 함수를 작성하고 싶다고 해보자. 만약 값이 없다면, 함수는 None을 반환하고 어떤 연산도 시도하지 않아야 한다.

이 함수는 match 덕분에 매우 쉽게 작성할 수 있으며, Listing 6-5와 같다.

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}
Listing 6-5: Option<i32>match 표현식을 사용하는 함수

plus_one 함수의 첫 번째 실행을 자세히 살펴보자. plus_one(five)를 호출하면, plus_one 함수 본문의 변수 xSome(5) 값을 갖게 된다. 그런 다음 이 값을 각 match 갈래와 비교한다:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Some(5) 값은 None 패턴과 일치하지 않으므로 다음 갈래로 넘어간다:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

Some(5)Some(i)와 일치하는가? 일치한다! 동일한 변형이다. iSome 안에 있는 값에 바인딩되므로, i는 값 5를 갖는다. 그런 다음 match 갈래의 코드가 실행되어, i 값에 1을 더하고 결과값 6을 포함한 새로운 Some 값을 생성한다.

이제 Listing 6-5에서 plus_one의 두 번째 호출을 고려해보자. 여기서 xNone이다. match에 들어가서 첫 번째 갈래와 비교한다:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            None => None,
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

일치한다! 더할 값이 없으므로, 프로그램은 멈추고 => 오른쪽의 None 값을 반환한다. 첫 번째 갈래가 일치했기 때문에 다른 갈래는 비교하지 않는다.

match와 열거형을 결합하는 것은 다양한 상황에서 유용하다. Rust 코드에서 이 패턴을 자주 보게 될 것이다: 열거형에 대해 match를 수행하고, 안에 있는 데이터에 변수를 바인딩한 다음, 이를 기반으로 코드를 실행한다. 처음에는 약간 까다로울 수 있지만, 익숙해지면 모든 언어에서 이 기능을 갖고 싶어질 것이다. 이는 꾸준히 사용자들이 좋아하는 기능 중 하나다.

모든 경우를 다뤄야 하는 match의 특징

match에 대해 다뤄야 할 또 다른 특징이 있다. 바로 모든 가능한 경우를 다뤄야 한다는 점이다. 아래는 버그가 있고 컴파일되지 않는 plus_one 함수의 예제다:

fn main() {
    fn plus_one(x: Option<i32>) -> Option<i32> {
        match x {
            Some(i) => Some(i + 1),
        }
    }

    let five = Some(5);
    let six = plus_one(five);
    let none = plus_one(None);
}

이 코드는 None 케이스를 처리하지 않았기 때문에 버그를 일으킨다. 다행히도, 러스트는 이런 버그를 잡아낼 수 있다. 이 코드를 컴파일하려고 하면 다음과 같은 에러가 발생한다:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0004]: non-exhaustive patterns: `None` not covered
 --> src/main.rs:3:15
  |
3 |         match x {
  |               ^ pattern `None` not covered
  |
note: `Option<i32>` defined here
 --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:572:1
 ::: /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/core/src/option.rs:576:5
  |
  = note: not covered
  = note: the matched value is of type `Option<i32>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
  |
4 ~             Some(i) => Some(i + 1),
5 ~             None => todo!(),
  |

For more information about this error, try `rustc --explain E0004`.
error: could not compile `enums` (bin "enums") due to 1 previous error

러스트는 모든 가능한 케이스를 다루지 않았다는 것을 알고 있으며, 심지어 어떤 패턴을 빠뜨렸는지도 정확히 알고 있다! 러스트의 match모든 경우를 다뤄야 한다(exhaustive). 즉, 코드가 유효하려면 모든 가능성을 다뤄야 한다. 특히 Option<T>의 경우, 러스트는 None 케이스를 명시적으로 처리하지 않으면 이를 막아준다. 이는 우리가 값이 있을 것이라고 가정했을 때 실제로는 null이 있을 수 있는 상황을 방지해주며, 이전에 언급했던 ’10억 달러짜리 실수’를 불가능하게 만든다.

모든 경우를 포괄하는 패턴과 _ 플레이스홀더

열거형을 사용하면 특정 값에 대해 특별한 동작을 정의하고, 나머지 모든 값에 대해 기본 동작을 지정할 수 있다. 예를 들어, 주사위를 굴려 3이 나오면 플레이어가 움직이지 않고 멋진 모자를 얻는 게임을 구현한다고 가정해보자. 7이 나오면 플레이어가 모자를 잃는다. 나머지 숫자가 나오면 플레이어는 해당 숫자만큼 게임 보드에서 이동한다. 다음은 이 로직을 구현한 match 표현식이다. 주사위 결과는 랜덤 값이 아닌 하드코딩되었고, 나머지 로직은 실제 구현을 생략한 함수로 표현했다:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        other => move_player(other),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn move_player(num_spaces: u8) {}
}

처음 두 가지 arm은 리터럴 값 37에 해당한다. 나머지 모든 가능한 값을 포괄하는 마지막 armother라는 변수로 패턴을 정의한다. other arm에 해당하는 코드는 이 변수를 move_player 함수에 전달하여 사용한다.

이 코드는 u8 타입이 가질 수 있는 모든 값을 나열하지 않았음에도 컴파일된다. 마지막 패턴이 명시적으로 나열되지 않은 모든 값을 매칭하기 때문이다. 이 모든 경우를 포괄하는 패턴은 match가 모든 가능성을 다뤄야 한다는 요구 사항을 충족한다. 모든 경우를 포괄하는 arm은 반드시 마지막에 위치해야 한다는 점에 주의하자. 패턴은 순서대로 평가되기 때문에, 모든 경우를 포괄하는 arm을 앞에 두면 다른 arm은 절대 실행되지 않는다. 따라서 Rust는 모든 경우를 포괄하는 arm 뒤에 다른 arm을 추가하면 경고를 표시한다.

Rust는 모든 경우를 포괄하지만 해당 값을 사용하지 않을 때 사용할 수 있는 특별한 패턴도 제공한다. _는 어떤 값이든 매칭하지만 그 값에 바인딩하지 않는 패턴이다. 이 패턴은 해당 값을 사용하지 않을 것임을 Rust에게 알려주므로, 사용하지 않는 변수에 대한 경고를 피할 수 있다.

이제 게임 규칙을 변경해보자. 이제 3이나 7이 아닌 값이 나오면 다시 주사위를 굴려야 한다. 모든 경우를 포괄하는 값을 사용할 필요가 없으므로, other 변수 대신 _를 사용하도록 코드를 수정할 수 있다:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => reroll(),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
    fn reroll() {}
}

이 예제도 마지막 arm에서 나머지 모든 값을 명시적으로 무시하므로, 모든 가능성을 다뤘다는 요구 사항을 충족한다.

마지막으로 게임 규칙을 한 번 더 변경해보자. 이제 3이나 7이 아닌 값이 나오면 아무 일도 일어나지 않는다. 이를 표현하기 위해 _ arm에 단위 값(빈 튜플 타입)을 사용할 수 있다:

fn main() {
    let dice_roll = 9;
    match dice_roll {
        3 => add_fancy_hat(),
        7 => remove_fancy_hat(),
        _ => (),
    }

    fn add_fancy_hat() {}
    fn remove_fancy_hat() {}
}

여기서는 이전 arm에서 매칭되지 않은 다른 값을 사용하지 않을 것이며, 이 경우 어떤 코드도 실행하지 않을 것임을 Rust에게 명시적으로 알린다.

패턴과 매칭에 대해 더 자세한 내용은 19장에서 다룬다. 지금은 match 표현식이 다소 장황할 때 유용한 if let 문법으로 넘어가보자.

간결한 제어 흐름: if letlet else

if let 구문은 iflet을 결합하여 더 간결하게 패턴 매칭을 처리할 수 있게 해준다. 특정 패턴에만 관심이 있고 나머지는 무시하려는 경우 유용하다. 예를 들어, Option<u8> 타입의 config_max 변수를 매칭할 때, 값이 Some인 경우에만 코드를 실행하고 싶다면 다음과 같이 작성할 수 있다.

fn main() {
    let config_max = Some(3u8);
    match config_max {
        Some(max) => println!("The maximum is configured to be {max}"),
        _ => (),
    }
}
Listing 6-6: 값이 Some인 경우에만 코드를 실행하는 match 예제

값이 Some인 경우, 패턴에서 max 변수에 값을 바인딩하여 Some 내부의 값을 출력한다. None 값에 대해서는 아무런 동작도 수행하지 않는다. match 표현식을 완성하기 위해 _ => ()를 추가해야 하는데, 이는 불필요한 보일러플레이트 코드다.

이를 더 간단하게 if let을 사용해 작성할 수 있다. 다음 코드는 Listing 6-6의 match와 동일한 동작을 한다:

fn main() {
    let config_max = Some(3u8);
    if let Some(max) = config_max {
        println!("The maximum is configured to be {max}");
    }
}

if let 구문은 패턴과 표현식을 등호로 구분하여 사용한다. 이는 match와 동일하게 동작하며, 표현식은 match에 전달되고 패턴은 첫 번째 매치 갈래가 된다. 여기서 패턴은 Some(max)이며, maxSome 내부의 값에 바인딩된다. 그런 다음 if let 블록 내에서 max를 사용할 수 있으며, 이는 match 갈래에서 max를 사용하는 것과 동일하다. if let 블록의 코드는 값이 패턴과 일치할 때만 실행된다.

if let을 사용하면 타이핑이 줄어들고, 들여쓰기가 감소하며, 보일러플레이트 코드가 사라진다. 하지만 match가 제공하는 철저한 검사 기능을 잃게 된다. matchif let 중 어떤 것을 선택할지는 특정 상황에서 무엇을 하느냐에 달려 있으며, 간결함을 얻는 대신 철저한 검사를 포기하는 것이 적절한지 판단해야 한다.

즉, if let은 특정 패턴과 일치할 때만 코드를 실행하고 나머지 값은 무시하는 match의 축약형이라고 생각할 수 있다.

if let과 함께 else를 사용할 수도 있다. else 블록의 코드는 if let과 동등한 match 표현식에서 _ 케이스에 해당하는 블록과 동일하다. Listing 6-4의 Coin 열거형 정의를 떠올려보자. Quarter 변형은 UsState 값을 포함하고 있다. 만약 쿼터가 아닌 동전의 수를 세면서 동시에 쿼터의 주를 알리고 싶다면, 다음과 같이 match 표현식을 사용할 수 있다:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    match coin {
        Coin::Quarter(state) => println!("State quarter from {state:?}!"),
        _ => count += 1,
    }
}

또는 if letelse 표현식을 사용해 다음과 같이 작성할 수도 있다:

#[derive(Debug)]
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn main() {
    let coin = Coin::Penny;
    let mut count = 0;
    if let Coin::Quarter(state) = coin {
        println!("State quarter from {state:?}!");
    } else {
        count += 1;
    }
}

let...else로 “해피 패스” 유지하기

값이 존재할 때는 특정 계산을 수행하고, 그렇지 않으면 기본값을 반환하는 패턴은 자주 사용된다. UsState 값을 가진 동전 예제를 계속 이어가 보자. 쿼터에 새겨진 주의 연령에 따라 재미있는 말을 하고 싶다면, UsState에 주의 연령을 확인하는 메서드를 추가할 수 있다.

#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}

그런 다음 if let을 사용해 동전 타입을 매칭하고, 조건문 내부에 state 변수를 도입할 수 있다. 이는 Listing 6-7과 같다.

Filename: src/main.rs
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    if let Coin::Quarter(state) = coin {
        if state.existed_in(1900) {
            Some(format!("{state:?} is pretty old, for America!"))
        } else {
            Some(format!("{state:?} is relatively new."))
        }
    } else {
        None
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-7: if let 내부에 조건문을 중첩해 1900년에 존재했던 주를 확인하기.

이 방법은 작업을 if let 문의 본문으로 밀어넣어 처리하기 때문에, 작업이 복잡해지면 최상위 분기와의 관계를 파악하기 어려워질 수 있다. 또한 표현식이 값을 생성한다는 사실을 활용해 if let에서 state를 생성하거나 조기에 반환할 수도 있다. 이는 Listing 6-8과 같다. (match를 사용해 비슷한 작업을 수행할 수도 있다.)

Filename: src/main.rs
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let state = if let Coin::Quarter(state) = coin {
        state
    } else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-8: 값을 생성하거나 조기에 반환하기 위해 if let 사용하기.

하지만 이 방법도 따르기에는 조금 번거롭다! if let의 한 분기는 값을 생성하고, 다른 분기는 함수 전체에서 반환한다.

이런 일반적인 패턴을 더 깔끔하게 표현하기 위해 Rust는 let...else를 제공한다. let...else 구문은 if let과 매우 유사하게 왼쪽에 패턴을, 오른쪽에 표현식을 받지만, if 분기는 없고 else 분기만 있다. 패턴이 매칭되면 패턴에서 값을 외부 스코프에 바인딩한다. 패턴이 매칭되지 않으면 프로그램은 else 분기로 흐르며, 이 분기에서는 반드시 함수에서 반환해야 한다.

Listing 6-9에서는 if let 대신 let...else를 사용해 Listing 6-8을 어떻게 표현하는지 확인할 수 있다. 이 방식은 함수의 주요 본문에서 “해피 패스“를 유지하며, if let처럼 두 분기 간에 크게 다른 제어 흐름을 만들지 않는다.

Filename: src/main.rs
#[derive(Debug)] // so we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // --snip--
}

impl UsState {
    fn existed_in(&self, year: u16) -> bool {
        match self {
            UsState::Alabama => year >= 1819,
            UsState::Alaska => year >= 1959,
            // -- snip --
        }
    }
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

fn describe_state_quarter(coin: Coin) -> Option<String> {
    let Coin::Quarter(state) = coin else {
        return None;
    };

    if state.existed_in(1900) {
        Some(format!("{state:?} is pretty old, for America!"))
    } else {
        Some(format!("{state:?} is relatively new."))
    }
}

fn main() {
    if let Some(desc) = describe_state_quarter(Coin::Quarter(UsState::Alaska)) {
        println!("{desc}");
    }
}
Listing 6-9: 함수의 흐름을 명확히 하기 위해 let...else 사용하기.

만약 match로 표현하기에는 너무 장황한 로직이 있다면, if letlet...else가 Rust 도구 상자에 있다는 것을 기억하라.

요약

지금까지 열거형(enum)을 사용해 특정 값들 중 하나를 선택할 수 있는 커스텀 타입을 만드는 방법을 알아보았다. 또한 표준 라이브러리의 Option<T> 타입이 어떻게 타입 시스템을 활용해 에러를 방지하는지 살펴보았다. 열거형 값 안에 데이터가 포함된 경우, matchif let을 사용해 해당 값을 추출하고 활용할 수 있다. 이때 처리해야 하는 케이스의 수에 따라 적절한 방법을 선택하면 된다.

이제 여러분의 Rust 프로그램은 구조체(struct)와 열거형을 통해 도메인 개념을 표현할 수 있다. API에서 사용할 커스텀 타입을 만들면 타입 안전성을 보장할 수 있다. 컴파일러가 각 함수가 기대하는 타입의 값만 전달되도록 확인해 주기 때문이다.

사용자에게 잘 정리된 API를 제공하고, 사용하기 간편하며, 사용자가 필요한 기능만 정확히 노출하려면 이제 Rust의 모듈 시스템을 살펴볼 차례다.

패키지, 크레이트, 모듈로 프로젝트 관리하기

프로그램의 규모가 커질수록 코드를 체계적으로 관리하는 일은 점점 더 중요해진다. 관련 기능을 그룹화하고 각 기능을 명확히 구분하면, 특정 기능을 구현한 코드를 쉽게 찾을 수 있고 기능의 동작을 수정할 위치도 명확해진다.

지금까지 작성한 프로그램은 하나의 파일에 하나의 모듈로 구성되어 있었다. 프로젝트가 커지면 코드를 여러 모듈로 나누고, 이를 다시 여러 파일로 분리해야 한다. 하나의 패키지는 여러 바이너리 크레이트를 포함할 수 있으며, 선택적으로 하나의 라이브러리 크레이트를 포함할 수 있다. 패키지가 커지면 일부를 분리해 외부 의존성으로 사용할 수 있다. 이 장에서는 이러한 기법을 모두 다룬다. 서로 연관된 패키지 집합으로 구성된 대규모 프로젝트의 경우, Cargo는 워크스페이스 기능을 제공한다. 이에 대해서는 14장 “Cargo 워크스페이스”에서 자세히 설명한다.

구현 세부 사항을 캡슐화하는 방법도 살펴본다. 이를 통해 코드를 더 높은 수준에서 재사용할 수 있다. 한 번 구현한 기능은 공개 인터페이스를 통해 다른 코드에서 호출할 수 있으며, 내부 구현 방식을 알 필요가 없다. 코드를 작성할 때 어떤 부분을 공개하고 어떤 부분을 비공개로 할지 결정할 수 있다. 이는 코드를 이해하고 관리해야 하는 세부 사항의 양을 줄이는 또 다른 방법이다.

관련 개념으로 스코프가 있다. 스코프는 코드가 작성된 중첩된 컨텍스트로, ’스코프 내’에 정의된 이름들의 집합을 의미한다. 코드를 읽고, 쓰고, 컴파일할 때 프로그래머와 컴파일러는 특정 위치의 이름이 변수, 함수, 구조체, 열거형, 모듈, 상수 등을 가리키는지, 그리고 그 의미가 무엇인지 알아야 한다. 스코프를 생성하고 어떤 이름이 스코프 내에 있는지 변경할 수 있다. 같은 스코프 내에서는 두 항목이 같은 이름을 가질 수 없으며, 이름 충돌을 해결하는 도구가 제공된다.

Rust는 코드의 조직화를 관리할 수 있는 여러 기능을 제공한다. 어떤 세부 사항을 공개할지, 어떤 부분을 비공개로 할지, 프로그램의 각 스코프에 어떤 이름이 있는지 등을 제어할 수 있다. 이러한 기능을 통칭하여 모듈 시스템 이라고 부르며, 다음과 같은 요소로 구성된다:

  • 패키지: 크레이트를 빌드, 테스트, 공유할 수 있는 Cargo 기능
  • 크레이트: 라이브러리나 실행 파일을 생성하는 모듈 트리
  • 모듈use: 경로의 조직화, 스코프, 접근 제어를 관리
  • 경로: 구조체, 함수, 모듈 등의 항목을 식별하는 방법

이 장에서는 이러한 기능을 모두 다루고, 각 기능이 어떻게 상호작용하는지 설명하며, 스코프를 관리하는 방법을 알아본다. 이 장을 마치면 모듈 시스템을 충분히 이해하고 스코프를 전문가처럼 다룰 수 있을 것이다.

패키지와 크레이트

모듈 시스템의 첫 번째 부분은 패키지와 크레이트다.

_크레이트_는 러스트 컴파일러가 한 번에 처리하는 가장 작은 코드 단위다. cargo 대신 rustc를 사용해 단일 소스 코드 파일을 컴파일할 때도(1장 “러스트 프로그램 작성 및 실행“에서 다룬 내용처럼), 컴파일러는 그 파일을 하나의 크레이트로 간주한다. 크레이트는 모듈을 포함할 수 있으며, 모듈은 크레이트와 함께 컴파일되는 다른 파일에 정의될 수 있다. 이에 대해서는 이후 섹션에서 자세히 살펴볼 것이다.

크레이트는 두 가지 형태로 존재한다: 바이너리 크레이트와 라이브러리 크레이트. _바이너리 크레이트_는 실행 가능한 프로그램으로 컴파일할 수 있는 크레이트다. 커맨드라인 프로그램이나 서버가 그 예시다. 각 바이너리 크레이트는 실행 파일이 실행될 때 어떤 동작을 할지 정의하는 main 함수를 반드시 포함해야 한다. 지금까지 우리가 만든 모든 크레이트는 바이너리 크레이트였다.

_라이브러리 크레이트_는 main 함수가 없으며, 실행 파일로 컴파일되지 않는다. 대신, 여러 프로젝트에서 공유할 수 있는 기능을 정의한다. 예를 들어, 2장에서 사용한 rand 크레이트는 난수를 생성하는 기능을 제공한다. 러스트 개발자들이 “크레이트“라고 말할 때 대부분 라이브러리 크레이트를 의미하며, “크레이트“라는 용어는 일반적인 프로그래밍 개념인 “라이브러리“와 동일하게 사용된다.

_크레이트 루트_는 러스트 컴파일러가 시작하는 소스 파일로, 크레이트의 루트 모듈을 구성한다(모듈에 대해서는 “모듈 정의로 스코프와 접근 제어 관리하기”에서 자세히 설명한다).

_패키지_는 하나 이상의 크레이트를 묶어 특정 기능을 제공하는 단위다. 패키지는 크레이트를 어떻게 빌드할지 설명하는 Cargo.toml 파일을 포함한다. Cargo는 실제로 커맨드라인 도구의 바이너리 크레이트를 포함하는 패키지다. Cargo 패키지는 바이너리 크레이트가 의존하는 라이브러리 크레이트도 포함한다. 다른 프로젝트는 Cargo 라이브러리 크레이트를 의존해 Cargo 커맨드라인 도구와 동일한 로직을 사용할 수 있다.

패키지는 원하는 만큼 바이너리 크레이트를 포함할 수 있지만, 라이브러리 크레이트는 최대 하나만 포함할 수 있다. 패키지는 반드시 하나 이상의 크레이트를 포함해야 하며, 이는 라이브러리 크레이트나 바이너리 크레이트 중 하나여야 한다.

패키지를 생성할 때 어떤 일이 일어나는지 살펴보자. 먼저 cargo new my-project 명령을 실행한다:

$ cargo new my-project
     Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs

cargo new my-project를 실행한 후, ls 명령으로 Cargo가 생성한 내용을 확인한다. 프로젝트 디렉토리에는 Cargo.toml 파일이 있어 패키지를 구성한다. 또한 src 디렉토리 안에 main.rs 파일이 있다. 텍스트 편집기로 Cargo.toml 파일을 열어보면 _src/main.rs_에 대한 언급이 없다는 것을 알 수 있다. Cargo는 _src/main.rs_가 패키지와 동일한 이름의 바이너리 크레이트의 크레이트 루트라는 관례를 따른다. 마찬가지로, 패키지 디렉토리에 _src/lib.rs_가 포함되어 있으면, 패키지는 패키지와 동일한 이름의 라이브러리 크레이트를 포함하며, _src/lib.rs_가 그 크레이트 루트가 된다. Cargo는 크레이트 루트 파일을 rustc에 전달해 라이브러리나 바이너리를 빌드한다.

여기서는 _src/main.rs_만 포함된 패키지를 가지고 있으므로, my-project라는 이름의 바이너리 크레이트 하나만 포함한다. 만약 패키지가 _src/main.rs_와 _src/lib.rs_를 모두 포함하면, 패키지와 동일한 이름의 바이너리 크레이트와 라이브러리 크레이트 두 개를 갖게 된다. 패키지는 src/bin 디렉토리에 파일을 추가해 여러 바이너리 크레이트를 가질 수 있다: 각 파일은 별도의 바이너리 크레이트가 된다.

모듈을 사용해 스코프와 접근 제어하기

이 섹션에서는 모듈과 모듈 시스템의 주요 요소들에 대해 알아본다. 특히, 아이템에 이름을 붙일 수 있게 해주는 경로(path), 특정 경로를 스코프로 가져오는 use 키워드, 아이템을 공개하는 pub 키워드 등을 다룬다. 또한 as 키워드, 외부 패키지, 그리고 glob 연산자에 대해서도 논의한다.

모듈 요약 정리

모듈과 경로에 대한 자세한 내용을 살펴보기 전에, 모듈, 경로, use 키워드, pub 키워드가 컴파일러에서 어떻게 동작하는지, 그리고 대부분의 개발자가 코드를 어떻게 구성하는지 간단히 정리한다. 이 장에서 각 규칙의 예제를 다룰 예정이지만, 모듈의 동작 방식을 다시 떠올리기 위해 참고하기 좋은 자료다.

  • 크레이트 루트에서 시작: 크레이트를 컴파일할 때, 컴파일러는 먼저 크레이트 루트 파일(보통 라이브러리 크레이트의 경우 src/lib.rs, 바이너리 크레이트의 경우 src/main.rs)에서 컴파일할 코드를 찾는다.
  • 모듈 선언: 크레이트 루트 파일에서 새로운 모듈을 선언할 수 있다. 예를 들어 mod garden;으로 “garden” 모듈을 선언하면, 컴파일러는 다음 위치에서 모듈 코드를 찾는다:
    • mod garden 뒤의 세미콜론을 중괄호로 대체한 인라인 코드
    • src/garden.rs 파일
    • src/garden/mod.rs 파일
  • 서브모듈 선언: 크레이트 루트 파일이 아닌 다른 파일에서 서브모듈을 선언할 수 있다. 예를 들어 _src/garden.rs_에서 mod vegetables;를 선언하면, 컴파일러는 부모 모듈 이름의 디렉토리 내에서 서브모듈 코드를 다음 위치에서 찾는다:
    • mod vegetables 바로 뒤에 중괄호로 대체된 인라인 코드
    • src/garden/vegetables.rs 파일
    • src/garden/vegetables/mod.rs 파일
  • 모듈 내 코드 경로: 모듈이 크레이트의 일부가 되면, 프라이버시 규칙이 허용하는 한 같은 크레이트 내 어디서든 해당 모듈의 코드를 경로를 통해 참조할 수 있다. 예를 들어, garden vegetables 모듈의 Asparagus 타입은 crate::garden::vegetables::Asparagus 경로로 찾을 수 있다.
  • private vs public: 모듈 내 코드는 기본적으로 부모 모듈에서 private이다. 모듈을 public으로 만들려면 mod 대신 pub mod로 선언한다. public 모듈 내의 항목들도 public으로 만들려면, 해당 항목 선언 앞에 pub을 붙인다.
  • use 키워드: use 키워드는 특정 스코프 내에서 항목에 대한 단축 경로를 만들어 긴 경로 반복을 줄인다. 예를 들어 crate::garden::vegetables::Asparagus를 참조할 수 있는 스코프에서 use crate::garden::vegetables::Asparagus;로 단축 경로를 만들면, 이후 해당 스코프 내에서는 Asparagus만으로도 해당 타입을 사용할 수 있다.

여기서는 backyard라는 바이너리 크레이트를 만들어 이 규칙들을 설명한다. backyard라는 이름의 크레이트 디렉토리에는 다음과 같은 파일과 디렉토리가 포함된다:

backyard
├── Cargo.lock
├── Cargo.toml
└── src
    ├── garden
    │   └── vegetables.rs
    ├── garden.rs
    └── main.rs

이 경우 크레이트 루트 파일은 _src/main.rs_이며, 그 내용은 다음과 같다:

Filename: src/main.rs
use crate::garden::vegetables::Asparagus;

pub mod garden;

fn main() {
    let plant = Asparagus {};
    println!("I'm growing {plant:?}!");
}

pub mod garden; 라인은 컴파일러에게 _src/garden.rs_에 있는 코드를 포함하라고 지시한다. src/garden.rs 파일의 내용은 다음과 같다:

Filename: src/garden.rs
pub mod vegetables;

여기서 pub mod vegetables;는 _src/garden/vegetables.rs_에 있는 코드도 포함한다는 의미다. 해당 코드는 다음과 같다:

#[derive(Debug)]
pub struct Asparagus {}

이제 이 규칙들의 세부 사항을 살펴보고 실제로 동작하는 모습을 확인해 보자!

관련 코드를 모듈로 그룹화하기

모듈은 코드를 가독성 있게 정리하고 재사용하기 쉽게 만든다. 또한 모듈 내부의 코드는 기본적으로 비공개이기 때문에, 모듈을 통해 아이템의 접근 제어를 관리할 수 있다. 비공개 아이템은 외부에서 사용할 수 없는 내부 구현 세부 사항이다. 필요에 따라 모듈과 그 안의 아이템을 공개하여 외부 코드가 사용할 수 있게 할 수도 있다.

예를 들어, 레스토랑 기능을 제공하는 라이브러리 크레이트를 작성해 보자. 함수의 시그니처만 정의하고 본문은 비워둘 것이다. 이렇게 하면 레스토랑의 구현보다 코드의 구조에 집중할 수 있다.

레스토랑 업계에서는 레스토랑의 일부를 _프런트 오브 하우스_와 _백 오브 하우스_로 구분한다. 프런트 오브 하우스는 고객이 있는 곳으로, 호스트가 고객을 안내하고, 서버가 주문과 결제를 처리하며, 바텐더가 음료를 만드는 공간이다. 백 오브 하우스는 요리사와 주방 직원이 일하고, 접시를 닦는 직원이 청소를 하며, 관리자가 행정 업무를 처리하는 곳이다.

이 구조를 반영해 크레이트를 구성하기 위해, 함수를 중첩된 모듈로 정리할 수 있다. cargo new restaurant --lib 명령을 실행해 restaurant라는 새 라이브러리를 생성한다. 그런 다음 src/lib.rs 파일에 아래 코드를 입력해 모듈과 함수 시그니처를 정의한다. 이 코드는 프런트 오브 하우스 섹션을 나타낸다.

Filename: src/lib.rs
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}

        fn seat_at_table() {}
    }

    mod serving {
        fn take_order() {}

        fn serve_order() {}

        fn take_payment() {}
    }
}
Listing 7-1: 다른 모듈을 포함하는 front_of_house 모듈과 그 안의 함수들

mod 키워드 뒤에 모듈 이름을 붙여 모듈을 정의한다 (여기서는 front_of_house). 모듈의 본문은 중괄호 안에 위치한다. 모듈 내부에는 다른 모듈을 포함할 수 있다. 여기서는 hostingserving 모듈이 그 예이다. 모듈은 구조체, 열거형, 상수, 트레이트와 같은 다른 아이템의 정의도 포함할 수 있다. Listing 7-1에서는 함수를 포함하고 있다.

모듈을 사용하면 관련 정의를 그룹화하고 그룹화된 이유를 명확히 할 수 있다. 이 코드를 사용하는 프로그래머는 모든 정의를 읽어야 하는 대신 그룹을 기반으로 코드를 탐색할 수 있어, 관련 정의를 더 쉽게 찾을 수 있다. 새로운 기능을 추가하는 프로그래머는 코드를 어디에 배치해야 프로그램이 체계적으로 유지될지 알 수 있다.

앞서 _src/main.rs_와 _src/lib.rs_를 크레이트 루트라고 언급했다. 이 두 파일의 내용은 크레이트의 모듈 구조에서 루트에 위치한 crate라는 모듈을 형성하기 때문에 이런 이름이 붙었다. 이를 _모듈 트리_라고 한다.

Listing 7-2는 Listing 7-1의 구조에 대한 모듈 트리를 보여준다.

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment
Listing 7-2: Listing 7-1의 코드에 대한 모듈 트리

이 트리는 일부 모듈이 다른 모듈 내부에 중첩되는 방식을 보여준다. 예를 들어, hostingfront_of_house 내부에 중첩된다. 또한 일부 모듈은 같은 모듈 내에 정의된 형제 관계이다. hostingservingfront_of_house 내부에 정의된 형제 모듈이다. 모듈 A가 모듈 B 내부에 포함되어 있다면, 모듈 A는 모듈 B의 _자식_이고, 모듈 B는 모듈 A의 _부모_이다. 전체 모듈 트리는 암묵적으로 crate라는 모듈 아래에 루트를 두고 있다.

모듈 트리는 컴퓨터의 파일 시스템 디렉터리 트리를 떠올리게 할 수 있다. 이 비교는 매우 적절하다! 파일 시스템에서 디렉터리를 사용하듯, 모듈을 사용해 코드를 정리한다. 그리고 디렉터리 내의 파일을 찾듯, 모듈을 찾는 방법이 필요하다.

모듈 트리에서 아이템을 참조하는 경로

Rust에서 모듈 트리 내의 아이템을 찾기 위해 경로를 사용한다. 이는 파일 시스템을 탐색할 때 경로를 사용하는 방식과 유사하다. 함수를 호출하려면 해당 함수의 경로를 알아야 한다.

경로는 두 가지 형태를 가진다:

  • **절대 경로**는 크레이트 루트에서 시작하는 전체 경로다. 외부 크레이트의 코드라면 크레이트 이름으로 시작하고, 현재 크레이트의 코드라면 crate 리터럴로 시작한다.
  • **상대 경로**는 현재 모듈에서 시작하며 self, super, 또는 현재 모듈의 식별자를 사용한다.

절대 경로와 상대 경로 모두 이중 콜론(::)으로 구분된 하나 이상의 식별자를 포함한다.

Listing 7-1로 돌아가서, add_to_waitlist 함수를 호출하려 한다고 가정해 보자. 이는 add_to_waitlist 함수의 경로가 무엇인지 묻는 것과 같다. Listing 7-3은 Listing 7-1에서 일부 모듈과 함수를 제거한 내용을 담고 있다.

크레이트 루트에 정의된 새로운 함수 eat_at_restaurant에서 add_to_waitlist 함수를 호출하는 두 가지 방법을 보여준다. 이 경로들은 정확하지만, 이 예제가 그대로 컴파일되지 않게 하는 또 다른 문제가 남아 있다. 이에 대해서는 잠시 후에 설명한다.

eat_at_restaurant 함수는 라이브러리 크레이트의 공개 API의 일부이므로 pub 키워드로 표시한다. pub 키워드로 경로 공개하기” 섹션에서 pub에 대해 더 자세히 다룰 것이다.

Filename: src/lib.rs
mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}

pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-3: 절대 경로와 상대 경로를 사용해 add_to_waitlist 함수 호출하기

eat_at_restaurant에서 add_to_waitlist 함수를 처음 호출할 때 절대 경로를 사용한다. add_to_waitlist 함수는 eat_at_restaurant와 같은 크레이트에 정의되어 있으므로, crate 키워드로 절대 경로를 시작할 수 있다. 그런 다음 add_to_waitlist에 도달할 때까지 각 연속 모듈을 포함한다. 같은 구조의 파일 시스템을 상상해 보면, add_to_waitlist 프로그램을 실행하기 위해 /front_of_house/hosting/add_to_waitlist 경로를 지정하는 것과 같다. crate 이름으로 크레이트 루트에서 시작하는 것은 셸에서 /로 파일 시스템 루트에서 시작하는 것과 유사하다.

eat_at_restaurant에서 add_to_waitlist를 두 번째로 호출할 때는 상대 경로를 사용한다. 이 경로는 eat_at_restaurant와 같은 모듈 트리 레벨에 정의된 front_of_house 모듈 이름으로 시작한다. 여기서 파일 시스템의 동등한 경로는 front_of_house/hosting/add_to_waitlist가 된다. 모듈 이름으로 시작한다는 것은 경로가 상대적임을 의미한다.

상대 경로와 절대 경로 중 어떤 것을 사용할지는 프로젝트에 따라 결정한다. 아이템 정의 코드를 사용하는 코드와 함께 이동할지, 아니면 따로 이동할지에 따라 달라진다. 예를 들어, front_of_house 모듈과 eat_at_restaurant 함수를 customer_experience라는 모듈로 이동한다면, add_to_waitlist의 절대 경로를 업데이트해야 하지만, 상대 경로는 여전히 유효하다. 반면, eat_at_restaurant 함수를 별도로 dining이라는 모듈로 이동한다면, add_to_waitlist 호출의 절대 경로는 동일하게 유지되지만, 상대 경로는 업데이트해야 한다. 일반적으로 절대 경로를 지정하는 것을 선호한다. 코드 정의와 아이템 호출을 서로 독립적으로 이동할 가능성이 더 높기 때문이다.

이제 Listing 7-3을 컴파일해 보자. 왜 아직 컴파일되지 않는지 확인할 수 있다. 발생한 오류는 Listing 7-4에 나와 있다.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: module `hosting` is private
 --> src/lib.rs:9:28
  |
9 |     crate::front_of_house::hosting::add_to_waitlist();
  |                            ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
  |                            |
  |                            private module
  |
note: the module `hosting` is defined here
 --> src/lib.rs:2:5
  |
2 |     mod hosting {
  |     ^^^^^^^^^^^

error[E0603]: module `hosting` is private
  --> src/lib.rs:12:21
   |
12 |     front_of_house::hosting::add_to_waitlist();
   |                     ^^^^^^^  --------------- function `add_to_waitlist` is not publicly re-exported
   |                     |
   |                     private module
   |
note: the module `hosting` is defined here
  --> src/lib.rs:2:5
   |
2  |     mod hosting {
   |     ^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors
Listing 7-4: Listing 7-3의 코드를 빌드할 때 발생한 컴파일러 오류

오류 메시지는 hosting 모듈이 비공개임을 알려준다. 즉, hosting 모듈과 add_to_waitlist 함수에 대한 경로는 정확하지만, Rust는 비공개 영역에 접근할 수 없기 때문에 이를 사용할 수 없다. Rust에서는 모든 아이템(함수, 메서드, 구조체, 열거형, 모듈, 상수)이 기본적으로 부모 모듈에 대해 비공개다. 함수나 구조체 같은 아이템을 비공개로 만들려면 모듈 안에 넣으면 된다.

부모 모듈의 아이템은 자식 모듈의 비공개 아이템을 사용할 수 없지만, 자식 모듈의 아이템은 조상 모듈의 아이템을 사용할 수 있다. 이는 자식 모듈이 구현 세부 사항을 감싸고 숨기지만, 자식 모듈은 자신이 정의된 컨텍스트를 볼 수 있기 때문이다. 비유를 계속하자면, 프라이버시 규칙은 레스토랑의 백오피스와 같다. 백오피스에서 일어나는 일은 레스토랑 고객에게는 비공개이지만, 관리자는 운영하는 레스토랑의 모든 것을 보고 할 수 있다.

Rust는 내부 구현 세부 사항을 숨기는 것이 기본 동작이 되도록 모듈 시스템을 설계했다. 이렇게 하면 외부 코드를 깨뜨리지 않고 내부 코드의 어느 부분을 변경할 수 있는지 알 수 있다. 그러나 Rust는 pub 키워드를 사용해 자식 모듈의 코드 내부를 외부 조상 모듈에 공개할 수 있는 옵션을 제공한다.

pub 키워드로 경로 공개하기

리스트 7-4에서 발생한 오류를 다시 살펴보자. 이 오류는 hosting 모듈이 비공개임을 알려준다. 부모 모듈의 eat_at_restaurant 함수가 자식 모듈의 add_to_waitlist 함수에 접근할 수 있도록 하기 위해, 리스트 7-5와 같이 hosting 모듈에 pub 키워드를 추가한다.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-5: eat_at_restaurant에서 사용할 수 있도록 hosting 모듈을 pub로 선언

하지만 리스트 7-5의 코드는 여전히 컴파일 오류를 발생시킨다. 이 오류는 리스트 7-6에서 확인할 수 있다.

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:10:37
   |
10 |     crate::front_of_house::hosting::add_to_waitlist();
   |                                     ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

error[E0603]: function `add_to_waitlist` is private
  --> src/lib.rs:13:30
   |
13 |     front_of_house::hosting::add_to_waitlist();
   |                              ^^^^^^^^^^^^^^^ private function
   |
note: the function `add_to_waitlist` is defined here
  --> src/lib.rs:3:9
   |
3  |         fn add_to_waitlist() {}
   |         ^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0603`.
error: could not compile `restaurant` (lib) due to 2 previous errors
Listing 7-6: 리스트 7-5의 코드를 빌드했을 때 발생한 컴파일 오류

어떤 일이 발생했을까? mod hosting 앞에 pub 키워드를 추가하면 모듈이 공개된다. 이 변경으로 front_of_house에 접근할 수 있다면 hosting에도 접근할 수 있다. 하지만 hosting내용물 은 여전히 비공개 상태다. 모듈을 공개한다고 해서 그 안의 내용물까지 공개되지는 않는다. 모듈에 pub 키워드를 추가하면 조상 모듈의 코드가 해당 모듈을 참조할 수 있지만, 내부 코드에 접근할 수는 없다. 모듈은 컨테이너이기 때문에 모듈만 공개하는 것으로는 큰 효과를 볼 수 없다. 더 나아가 모듈 내부의 항목 중 하나 이상을 공개해야 한다.

리스트 7-6의 오류는 add_to_waitlist 함수가 비공개임을 알려준다. 비공개 규칙은 구조체, 열거형, 함수, 메서드뿐만 아니라 모듈에도 적용된다.

add_to_waitlist 함수도 공개하기 위해 pub 키워드를 추가해 보자. 리스트 7-7과 같이 수정한다.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

// -- snip --
pub fn eat_at_restaurant() {
    // Absolute path
    crate::front_of_house::hosting::add_to_waitlist();

    // Relative path
    front_of_house::hosting::add_to_waitlist();
}
Listing 7-7: mod hostingfn add_to_waitlistpub 키워드를 추가해 eat_at_restaurant에서 함수를 호출할 수 있게 함

이제 코드가 컴파일된다! pub 키워드를 추가하면 eat_at_restaurant에서 이 경로를 사용할 수 있는 이유를 이해하기 위해 절대 경로와 상대 경로를 살펴보자.

절대 경로는 크레이트의 모듈 트리 루트인 crate로 시작한다. front_of_house 모듈은 크레이트 루트에 정의되어 있다. front_of_house는 공개되지 않았지만, eat_at_restaurant 함수가 front_of_house와 같은 모듈에 정의되어 있기 때문에(즉, eat_at_restaurantfront_of_house는 형제 관계), eat_at_restaurant에서 front_of_house를 참조할 수 있다. 다음으로 pub로 표시된 hosting 모듈이 있다. hosting의 부모 모듈에 접근할 수 있으므로 hosting에도 접근할 수 있다. 마지막으로 add_to_waitlist 함수가 pub로 표시되어 있고, 부모 모듈에 접근할 수 있으므로 이 함수 호출이 가능하다!

상대 경로의 논리는 절대 경로와 동일하지만 첫 단계가 다르다. 크레이트 루트가 아니라 front_of_house로 시작한다. front_of_house 모듈은 eat_at_restaurant와 같은 모듈에 정의되어 있으므로, eat_at_restaurant가 정의된 모듈에서 시작하는 상대 경로가 유효하다. 그 다음, hostingadd_to_waitlistpub로 표시되어 있으므로 경로의 나머지 부분도 유효하고, 이 함수 호출이 가능하다!

라이브러리 크레이트를 공유해 다른 프로젝트에서 코드를 사용할 계획이라면, 공개 API는 크레이트 사용자와의 계약과 같다. 이 API는 사용자가 코드와 상호작용하는 방식을 결정한다. 공개 API를 변경할 때는 사용자가 크레이트에 의존하기 쉽도록 여러 가지 사항을 고려해야 한다. 이 주제는 이 책의 범위를 벗어나지만, 관심이 있다면 The Rust API Guidelines를 참고하자.

바이너리와 라이브러리를 포함하는 패키지의 모범 사례

패키지에는 src/main.rs 바이너리 크레이트 루트와 src/lib.rs 라이브러리 크레이트 루트가 모두 포함될 수 있으며, 두 크레이트는 기본적으로 패키지 이름을 공유한다. 일반적으로 라이브러리와 바이너리 크레이트를 모두 포함하는 패키지는 바이너리 크레이트에 실행 파일을 시작하는 데 필요한 최소한의 코드만 포함하고, 라이브러리 크레이트의 코드를 호출한다. 이렇게 하면 라이브러리 크레이트의 코드를 공유할 수 있으므로 다른 프로젝트도 패키지가 제공하는 대부분의 기능을 활용할 수 있다.

모듈 트리는 _src/lib.rs_에 정의해야 한다. 그러면 패키지 이름으로 시작하는 경로를 통해 바이너리 크레이트에서 공개 항목을 사용할 수 있다. 바이너리 크레이트는 완전히 외부의 크레이트가 라이브러리 크레이트를 사용하는 것과 마찬가지로 라이브러리 크레이트의 사용자가 된다. 즉, 공개 API만 사용할 수 있다. 이렇게 하면 좋은 API를 설계하는 데 도움이 된다. 여러분이 작성자이면서 동시에 클라이언트이기 때문이다!

12장에서는 바이너리 크레이트와 라이브러리 크레이트를 모두 포함하는 커맨드라인 프로그램을 통해 이 조직적 관행을 보여줄 것이다.

super로 시작하는 상대 경로

super를 경로의 시작에 사용하면 현재 모듈이나 크레이트 루트가 아닌 부모 모듈에서 시작하는 상대 경로를 구성할 수 있다. 이는 파일 시스템 경로에서 .. 문법을 사용하는 것과 유사하다. super를 사용하면 부모 모듈에 있는 항목을 참조할 수 있어, 모듈이 부모와 밀접하게 연관되어 있지만 부모가 나중에 모듈 트리의 다른 곳으로 이동할 가능성이 있는 경우, 모듈 트리를 재구성하기가 더 쉬워진다.

리스트 7-8의 코드를 살펴보자. 이 코드는 요리사가 잘못된 주문을 수정하고 직접 고객에게 전달하는 상황을 모델링한다. back_of_house 모듈에 정의된 fix_incorrect_order 함수는 super로 시작하는 경로를 지정하여 부모 모듈에 정의된 deliver_order 함수를 호출한다.

Filename: src/lib.rs
fn deliver_order() {}

mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::deliver_order();
    }

    fn cook_order() {}
}
Listing 7-8: super로 시작하는 상대 경로를 사용해 함수 호출하기

fix_incorrect_order 함수는 back_of_house 모듈에 있으므로, super를 사용해 back_of_house의 부모 모듈로 이동할 수 있다. 이 경우 부모 모듈은 크레이트 루트인 crate이다. 여기서 deliver_order를 찾아 호출한다. 성공! back_of_house 모듈과 deliver_order 함수는 서로 같은 관계를 유지하며 함께 이동할 가능성이 높기 때문에, super를 사용하면 나중에 이 코드가 다른 모듈로 이동하더라도 코드를 수정할 부분이 줄어든다.

구조체와 열거형을 공개로 만들기

pub 키워드를 사용해 구조체와 열거형을 공개로 지정할 수 있다. 하지만 구조체와 열거형에 pub을 사용할 때는 몇 가지 추가적인 세부 사항이 있다. 구조체 정의 앞에 pub을 사용하면 구조체 자체는 공개되지만, 구조체의 필드는 여전히 비공개 상태로 남는다. 각 필드를 개별적으로 공개할지 여부를 결정할 수 있다. 예제 7-9에서는 back_of_house::Breakfast 구조체를 공개로 정의했고, toast 필드는 공개로, seasonal_fruit 필드는 비공개로 설정했다. 이는 레스토랑에서 고객이 식사에 포함된 빵 종류를 선택할 수 있지만, 셰프가 계절과 재고에 따라 어떤 과일을 제공할지 결정하는 상황을 모델링한 것이다. 과일은 빠르게 변동되기 때문에 고객은 과일을 선택하거나 어떤 과일이 제공될지 미리 알 수 없다.

Filename: src/lib.rs
mod back_of_house {
    pub struct Breakfast {
        pub toast: String,
        seasonal_fruit: String,
    }

    impl Breakfast {
        pub fn summer(toast: &str) -> Breakfast {
            Breakfast {
                toast: String::from(toast),
                seasonal_fruit: String::from("peaches"),
            }
        }
    }
}

pub fn eat_at_restaurant() {
    // Order a breakfast in the summer with Rye toast.
    let mut meal = back_of_house::Breakfast::summer("Rye");
    // Change our mind about what bread we'd like.
    meal.toast = String::from("Wheat");
    println!("I'd like {} toast please", meal.toast);

    // The next line won't compile if we uncomment it; we're not allowed
    // to see or modify the seasonal fruit that comes with the meal.
    // meal.seasonal_fruit = String::from("blueberries");
}
Listing 7-9: 일부 필드는 공개, 일부는 비공개인 구조체

back_of_house::Breakfast 구조체의 toast 필드는 공개이기 때문에, eat_at_restaurant 함수에서 점 표기법을 사용해 toast 필드에 읽고 쓸 수 있다. 하지만 seasonal_fruit 필드는 비공개이기 때문에 eat_at_restaurant 함수에서 사용할 수 없다. seasonal_fruit 필드 값을 수정하는 줄의 주석을 해제하면 어떤 에러가 발생하는지 확인해보자!

또한, back_of_house::Breakfast 구조체에 비공개 필드가 있기 때문에, Breakfast 인스턴스를 생성하는 공개 연관 함수를 제공해야 한다(여기서는 summer라는 이름을 사용했다). 만약 Breakfast에 이런 함수가 없다면, eat_at_restaurant 함수에서 Breakfast 인스턴스를 생성할 수 없다. 왜냐하면 비공개 필드인 seasonal_fruit의 값을 설정할 수 없기 때문이다.

반면, 열거형을 공개로 만들면 모든 변형체도 자동으로 공개된다. 열거형 앞에 pub 키워드만 붙이면 된다. 예제 7-10에서 이를 확인할 수 있다.

Filename: src/lib.rs
mod back_of_house {
    pub enum Appetizer {
        Soup,
        Salad,
    }
}

pub fn eat_at_restaurant() {
    let order1 = back_of_house::Appetizer::Soup;
    let order2 = back_of_house::Appetizer::Salad;
}
Listing 7-10: 열거형을 공개로 지정하면 모든 변형체도 공개된다.

Appetizer 열거형을 공개로 만들었기 때문에, eat_at_restaurant 함수에서 SoupSalad 변형체를 사용할 수 있다.

열거형은 변형체가 공개되지 않으면 그다지 유용하지 않다. 모든 열거형 변형체에 pub을 붙이는 것은 번거로우므로, 열거형 변형체는 기본적으로 공개로 설정된다. 반면, 구조체는 필드가 공개되지 않아도 유용한 경우가 많기 때문에, 구조체 필드는 기본적으로 비공개로 설정된다. 단, pub으로 명시적으로 공개할 수 있다.

pub과 관련해 다루지 않은 한 가지 상황이 더 있다. 바로 use 키워드와 관련된 내용이다. 먼저 use 키워드 자체를 살펴본 다음, pubuse를 함께 사용하는 방법을 알아볼 것이다.

use 키워드로 경로를 스코프에 가져오기

함수를 호출할 때마다 경로를 일일이 작성하는 것은 번거롭고 반복적일 수 있다. 예제 7-7에서 add_to_waitlist 함수를 호출할 때마다 front_of_househosting을 함께 지정해야 했다. 다행히 이 과정을 단순화할 방법이 있다. use 키워드를 사용해 경로에 대한 단축키를 한 번 만들면, 스코프 내에서는 더 짧은 이름을 사용할 수 있다.

예제 7-11에서 crate::front_of_house::hosting 모듈을 eat_at_restaurant 함수의 스코프로 가져온다. 이제 eat_at_restaurant 함수 내에서 hosting::add_to_waitlist만 지정해도 add_to_waitlist 함수를 호출할 수 있다.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-11: use로 모듈을 스코프에 가져오기

스코프에 use와 경로를 추가하는 것은 파일 시스템에서 심볼릭 링크를 만드는 것과 유사하다. 크레이트 루트에 use crate::front_of_house::hosting을 추가하면, hosting은 해당 스코프 내에서 유효한 이름이 된다. 마치 hosting 모듈이 크레이트 루트에 정의된 것처럼 작동한다. use로 가져온 경로도 다른 경로와 마찬가지로 프라이버시를 검사한다.

use는 해당 use가 발생한 특정 스코프에 대해서만 단축키를 생성한다는 점에 유의해야 한다. 예제 7-12에서 eat_at_restaurant 함수를 customer라는 새로운 자식 모듈로 이동시켰다. 이제 use 문과는 다른 스코프가 되었기 때문에 함수 본문이 컴파일되지 않는다.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting;

mod customer {
    pub fn eat_at_restaurant() {
        hosting::add_to_waitlist();
    }
}
Listing 7-12: use 문은 해당 스코프 내에서만 적용된다.

컴파일러 오류는 customer 모듈 내에서 단축키가 더 이상 적용되지 않음을 보여준다:

$ cargo build
   Compiling restaurant v0.1.0 (file:///projects/restaurant)
error[E0433]: failed to resolve: use of undeclared crate or module `hosting`
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of undeclared crate or module `hosting`
   |
help: consider importing this module through its public re-export
   |
10 +     use crate::hosting;
   |

warning: unused import: `crate::front_of_house::hosting`
 --> src/lib.rs:7:5
  |
7 | use crate::front_of_house::hosting;
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

For more information about this error, try `rustc --explain E0433`.
warning: `restaurant` (lib) generated 1 warning
error: could not compile `restaurant` (lib) due to 1 previous error; 1 warning emitted

use가 더 이상 스코프 내에서 사용되지 않는다는 경고도 함께 나타난다. 이 문제를 해결하려면 usecustomer 모듈 내로 이동시키거나, 자식 customer 모듈 내에서 super::hosting으로 부모 모듈의 단축키를 참조해야 한다.

관용적인 use 경로 생성하기

리스트 7-11에서 use crate::front_of_house::hosting을 지정한 후 eat_at_restaurant에서 hosting::add_to_waitlist를 호출한 이유가 궁금할 수 있다. 리스트 7-13처럼 add_to_waitlist 함수까지 전체 경로를 지정해도 동일한 결과를 얻을 수 있지만, 리스트 7-11이 관용적인 방식이다.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

use crate::front_of_house::hosting::add_to_waitlist;

pub fn eat_at_restaurant() {
    add_to_waitlist();
}
Listing 7-13: useadd_to_waitlist 함수를 스코프로 가져오기 (비관용적)

리스트 7-11과 리스트 7-13은 동일한 작업을 수행하지만, 리스트 7-11이 use로 함수를 스코프로 가져오는 관용적인 방식이다. 함수의 상위 모듈을 use로 가져오면 함수를 호출할 때 상위 모듈을 지정해야 한다. 이렇게 하면 함수가 로컬에서 정의되지 않았음을 명확히 하면서도 전체 경로의 반복을 최소화할 수 있다. 리스트 7-13의 코드는 add_to_waitlist가 어디서 정의되었는지 불분명하다.

반면, use로 구조체, 열거형 등을 가져올 때는 전체 경로를 지정하는 것이 관용적이다. 리스트 7-14는 바이너리 크레이트에서 표준 라이브러리의 HashMap 구조체를 스코프로 가져오는 관용적인 방식을 보여준다.

Filename: src/main.rs
use std::collections::HashMap;

fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}
Listing 7-14: 관용적인 방식으로 HashMap을 스코프로 가져오기

이 관례에는 특별한 이유가 없다. 단지 이렇게 작성하는 것이 일반적이 되었고, 사람들이 이 방식으로 Rust 코드를 읽고 쓰는 데 익숙해졌을 뿐이다.

이 관례의 예외는 use 문으로 동일한 이름의 두 아이템을 스코프로 가져오는 경우다. Rust는 이를 허용하지 않기 때문이다. 리스트 7-15는 동일한 이름을 가졌지만 상위 모듈이 다른 두 Result 타입을 스코프로 가져오고 참조하는 방법을 보여준다.

Filename: src/lib.rs
use std::fmt;
use std::io;

fn function1() -> fmt::Result {
    // --snip--
    Ok(())
}

fn function2() -> io::Result<()> {
    // --snip--
    Ok(())
}
Listing 7-15: 동일한 이름을 가진 두 타입을 같은 스코프로 가져오려면 상위 모듈을 사용해야 한다.

보는 바와 같이, 상위 모듈을 사용하면 두 Result 타입을 구분할 수 있다. 만약 use std::fmt::Resultuse std::io::Result를 지정하면 같은 스코프에 두 Result 타입이 존재하게 되고, Rust는 Result를 사용할 때 어느 것을 의미하는지 알 수 없다.

as 키워드로 새로운 이름 제공하기

동일한 이름의 두 타입을 같은 스코프로 가져오는 문제를 해결하는 또 다른 방법은 use를 사용할 때 경로 뒤에 as와 함께 새로운 로컬 이름, 즉 _별칭_을 지정하는 것이다. Listing 7-16은 as를 사용해 두 Result 타입 중 하나의 이름을 바꿔 Listing 7-15의 코드를 다시 작성한 예제다.

Filename: src/lib.rs
use std::fmt::Result;
use std::io::Result as IoResult;

fn function1() -> Result {
    // --snip--
    Ok(())
}

fn function2() -> IoResult<()> {
    // --snip--
    Ok(())
}
Listing 7-16: as 키워드를 사용해 스코프로 가져올 때 타입 이름 변경하기

두 번째 use 문에서 std::io::Result 타입에 IoResult라는 새로운 이름을 선택했다. 이렇게 하면 std::fmtResult와 충돌하지 않는다. Listing 7-15와 Listing 7-16은 모두 관용적인 방식으로 간주되므로, 어떤 방식을 선택할지는 여러분의 선택에 달려 있다!

pub use로 이름 다시 내보내기

use 키워드를 사용해 이름을 스코프로 가져오면, 해당 이름은 임포트한 스코프 내에서만 비공개로 사용된다. 이 이름을 외부 스코프에서도 마치 해당 스코프에 정의된 것처럼 참조할 수 있게 하려면 pubuse를 함께 사용한다. 이 기법을 _다시 내보내기(re-exporting)_라고 부르며, 아이템을 스코프로 가져오는 동시에 다른 스코프에서도 사용할 수 있도록 공개하는 역할을 한다.

Listing 7-17은 Listing 7-11의 코드에서 루트 모듈의 usepub use로 변경한 예제를 보여준다.

Filename: src/lib.rs
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-17: pub use를 사용해 새로운 스코프에서 이름을 공개하기

이 변경 전에는 외부 코드에서 add_to_waitlist 함수를 호출하려면 restaurant::front_of_house::hosting::add_to_waitlist()와 같은 경로를 사용해야 했으며, front_of_house 모듈도 pub으로 표시되어야 했다. 이제 pub use를 통해 루트 모듈에서 hosting 모듈을 다시 내보냈으므로, 외부 코드는 restaurant::hosting::add_to_waitlist()와 같은 간단한 경로를 사용할 수 있다.

다시 내보내기는 코드의 내부 구조와 이를 호출하는 프로그래머가 생각하는 도메인 구조가 다를 때 유용하다. 예를 들어, 레스토랑 비유에서 레스토랑을 운영하는 사람들은 “전면(front of house)“과 “후면(back of house)“을 구분해 생각할 수 있다. 하지만 레스토랑을 방문하는 고객은 이러한 용어로 레스토랑의 구조를 생각하지 않을 것이다. pub use를 사용하면 코드는 한 구조로 작성하되, 다른 구조로 공개할 수 있다. 이렇게 하면 라이브러리를 개발하는 프로그래머와 라이브러리를 호출하는 프로그래머 모두에게 잘 조직된 라이브러리를 제공할 수 있다. pub use의 또 다른 예제와 이 기능이 크레이트의 문서에 미치는 영향에 대해서는 14장의 pub use로 편리한 공개 API 내보내기”에서 살펴볼 것이다.

외부 패키지 사용하기

2장에서는 랜덤 숫자를 얻기 위해 rand라는 외부 패키지를 사용하는 추측 게임 프로젝트를 만들었다. 프로젝트에서 rand를 사용하려면 Cargo.toml 파일에 다음 줄을 추가했다:

Filename: Cargo.toml
rand = "0.8.5"

_Cargo.toml_에 rand를 의존성으로 추가하면 Cargo는 crates.io에서 rand 패키지와 필요한 의존성을 다운로드하고 프로젝트에서 사용할 수 있게 한다.

그런 다음, rand의 정의를 패키지 스코프로 가져오기 위해 크레이트 이름인 rand로 시작하는 use 줄을 추가하고 스코프로 가져올 항목을 나열했다. 2장의 “랜덤 숫자 생성하기”에서 Rng 트레이트를 스코프로 가져오고 rand::thread_rng 함수를 호출한 것을 기억할 것이다:

use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");
}

Rust 커뮤니티의 멤버들은 crates.io에 많은 패키지를 제공하고 있으며, 이들 중 어떤 것을 프로젝트에 포함시키려면 동일한 단계를 거친다: 패키지의 Cargo.toml 파일에 나열하고 use를 사용해 해당 크레이트의 항목을 스코프로 가져온다.

표준 std 라이브러리도 프로젝트 외부의 크레이트라는 점에 유의하라. 표준 라이브러리는 Rust 언어와 함께 제공되므로 _Cargo.toml_을 변경해 std를 포함시킬 필요는 없다. 하지만 스코프로 항목을 가져오려면 use를 사용해 참조해야 한다. 예를 들어, HashMap을 사용하려면 다음과 같이 작성한다:

#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

이는 표준 라이브러리 크레이트의 이름인 std로 시작하는 절대 경로이다.

중첩 경로를 사용해 긴 use 목록 정리하기

같은 크레이트나 모듈에서 정의된 여러 항목을 사용할 때, 각 항목을 한 줄씩 나열하면 파일에서 많은 세로 공간을 차지한다. 예를 들어, 2장의 추측 게임에서 사용했던 다음 두 use 문은 std에서 항목을 가져온다:

Filename: src/main.rs
use rand::Rng;
// --snip--
use std::cmp::Ordering;
use std::io;
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}

대신, 중첩 경로를 사용해 한 줄로 같은 항목을 가져올 수 있다. 경로의 공통 부분을 지정한 다음, 두 개의 콜론을 붙이고, 중괄호로 감싸서 경로의 다른 부분을 나열한다. 이는 리스트 7-18에서 보여준다.

Filename: src/main.rs
use rand::Rng;
// --snip--
use std::{cmp::Ordering, io};
// --snip--

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin()
        .read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse().expect("Please type a number!");

    println!("You guessed: {guess}");

    match guess.cmp(&secret_number) {
        Ordering::Less => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal => println!("You win!"),
    }
}
Listing 7-18: 같은 접두사를 가진 여러 항목을 범위로 가져오기 위해 중첩 경로 지정

더 큰 프로그램에서는 중첩 경로를 사용해 같은 크레이트나 모듈에서 많은 항목을 가져오면 필요한 use 문의 수를 크게 줄일 수 있다!

경로의 어떤 수준에서든 중첩 경로를 사용할 수 있으며, 이는 하위 경로를 공유하는 두 개의 use 문을 결합할 때 유용하다. 예를 들어, 리스트 7-19는 두 개의 use 문을 보여준다. 하나는 std::io를 가져오고, 다른 하나는 std::io::Write를 가져온다.

Filename: src/lib.rs
use std::io;
use std::io::Write;
Listing 7-19: 하나가 다른 하나의 하위 경로인 두 개의 use

이 두 경로의 공통 부분은 std::io이며, 이는 첫 번째 경로의 전체이다. 이 두 경로를 하나의 use 문으로 결합하려면, 중첩 경로에서 self를 사용할 수 있다. 이는 리스트 7-20에서 보여준다.

Filename: src/lib.rs
use std::io::{self, Write};
Listing 7-20: 리스트 7-19의 경로를 하나의 use 문으로 결합

이 한 줄은 std::iostd::io::Write를 범위로 가져온다.

Glob 연산자

특정 경로에 정의된 모든 공개 항목을 범위로 가져오려면, 해당 경로 뒤에 * Glob 연산자를 지정한다:

#![allow(unused)]
fn main() {
use std::collections::*;
}

use 문은 std::collections에 정의된 모든 공개 항목을 현재 범위로 가져온다. 하지만 Glob 연산자를 사용할 때는 주의해야 한다. Glob은 어떤 이름이 범위 내에 있는지, 프로그램에서 사용된 이름이 어디에서 정의되었는지 파악하기 어렵게 만들 수 있다.

Glob 연산자는 주로 테스트 시 tests 모듈로 테스트 대상의 모든 항목을 가져올 때 사용한다. 이에 대해서는 11장 “테스트 작성 방법”에서 자세히 다룬다. 또한 Glob 연산자는 prelude 패턴의 일부로 사용되기도 한다. 이 패턴에 대한 자세한 내용은 표준 라이브러리 문서를 참고한다.

모듈을 별도의 파일로 분리하기

지금까지는 이 장의 모든 예제에서 여러 모듈을 하나의 파일에 정의했다. 모듈이 커지면 코드를 더 쉽게 탐색할 수 있도록 모듈 정의를 별도의 파일로 옮기고 싶을 때가 있다.

예를 들어, 레스토랑 관련 모듈이 여러 개 있는 Listing 7-17의 코드를 살펴보자. 모든 모듈을 크레이트 루트 파일에 정의하는 대신, 모듈을 별도의 파일로 추출할 것이다. 이 경우 크레이트 루트 파일은 _src/lib.rs_이지만, 이 절차는 크레이트 루트 파일이 _src/main.rs_인 바이너리 크레이트에서도 동일하게 적용된다.

먼저 front_of_house 모듈을 별도의 파일로 추출한다. front_of_house 모듈의 중괄호 안에 있는 코드를 제거하고, mod front_of_house; 선언만 남긴다. 그러면 _src/lib.rs_는 Listing 7-21에 나온 코드와 같이 된다. 이 코드는 Listing 7-22에서 src/front_of_house.rs 파일을 만들기 전까지 컴파일되지 않는다.

Filename: src/lib.rs
mod front_of_house;

pub use crate::front_of_house::hosting;

pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}
Listing 7-21: front_of_house 모듈을 선언하고, 본문은 src/front_of_house.rs에 위치

다음으로, 중괄호 안에 있던 코드를 _src/front_of_house.rs_라는 새 파일에 넣는다. Listing 7-22와 같이 작성한다. 컴파일러는 크레이트 루트에서 front_of_house라는 모듈 선언을 발견했기 때문에 이 파일을 찾아본다.

Filename: src/front_of_house.rs
pub mod hosting {
    pub fn add_to_waitlist() {}
}
Listing 7-22: front_of_house 모듈의 정의가 src/front_of_house.rs에 위치

모듈 트리에서 mod 선언을 사용해 파일을 로드하는 작업은 한 번만 하면 된다. 컴파일러가 파일이 프로젝트의 일부임을 알고, mod 문을 어디에 넣었는지에 따라 모듈 트리에서 코드가 어디에 위치하는지 알게 되면, 프로젝트의 다른 파일들은 선언된 경로를 사용해 로드된 파일의 코드를 참조해야 한다. 이는 “모듈 트리에서 아이템 참조하기” 섹션에서 다룬 내용이다. 즉, mod는 다른 프로그래밍 언어에서 볼 수 있는 “임포트” 연산이 아니다.

다음으로 hosting 모듈을 별도의 파일로 추출한다. 이 과정은 hosting이 루트 모듈의 자식이 아니라 front_of_house의 자식 모듈이기 때문에 조금 다르다. hosting 파일은 모듈 트리에서 조상 모듈의 이름을 따서 만든 새 디렉터리에 위치한다. 이 경우 src/front_of_house 디렉터리다.

hosting을 옮기기 위해 src/front_of_house.rs 파일을 hosting 모듈의 선언만 포함하도록 변경한다:

Filename: src/front_of_house.rs
pub mod hosting;

그런 다음 src/front_of_house 디렉터리와 hosting.rs 파일을 생성해 hosting 모듈의 정의를 넣는다:

Filename: src/front_of_house/hosting.rs
pub fn add_to_waitlist() {}

만약 hosting.rs 파일을 src 디렉터리에 넣으면, 컴파일러는 hosting.rs 코드가 크레이트 루트에서 선언된 hosting 모듈에 속할 것으로 예상한다. front_of_house 모듈의 자식으로 선언되지 않을 것이다. 컴파일러가 어떤 파일을 어떤 모듈의 코드로 인식하는지에 대한 규칙은 디렉터리와 파일이 모듈 트리와 더 밀접하게 일치하도록 한다.

대체 파일 경로

지금까지는 Rust 컴파일러가 사용하는 가장 일반적인 파일 경로를 다뤘지만, Rust는 이전 스타일의 파일 경로도 지원한다. 크레이트 루트에서 선언된 front_of_house 모듈의 경우, 컴파일러는 모듈의 코드를 다음 위치에서 찾는다:

  • src/front_of_house.rs (지금까지 다룬 방식)
  • src/front_of_house/mod.rs (이전 스타일, 여전히 지원되는 경로)

front_of_house의 하위 모듈인 hosting 모듈의 경우, 컴파일러는 모듈의 코드를 다음 위치에서 찾는다:

  • src/front_of_house/hosting.rs (지금까지 다룬 방식)
  • src/front_of_house/hosting/mod.rs (이전 스타일, 여전히 지원되는 경로)

같은 모듈에 대해 두 스타일을 모두 사용하면 컴파일 오류가 발생한다. 같은 프로젝트에서 다른 모듈에 대해 두 스타일을 혼용하는 것은 허용되지만, 프로젝트를 탐색하는 사람들에게 혼란을 줄 수 있다.

_mod.rs_라는 파일 이름을 사용하는 스타일의 주요 단점은 프로젝트에 mod.rs 파일이 많아질 수 있다는 점이다. 이는 편집기에서 여러 파일을 동시에 열었을 때 혼란을 줄 수 있다.

각 모듈의 코드를 별도의 파일로 옮겼지만, 모듈 트리는 그대로 유지된다. eat_at_restaurant의 함수 호출은 정의가 다른 파일에 있더라도 수정 없이 동작한다. 이 기법을 사용하면 모듈이 커짐에 따라 새 파일로 옮길 수 있다.

_src/lib.rs_의 pub use crate::front_of_house::hosting 문도 변경되지 않았으며, use는 크레이트의 일부로 컴파일되는 파일에 영향을 주지 않는다. mod 키워드는 모듈을 선언하며, Rust는 모듈과 같은 이름의 파일에서 해당 모듈의 코드를 찾는다.

요약

Rust는 패키지를 여러 크레이트(crate)로 나누고, 크레이트를 모듈로 분할할 수 있다. 이를 통해 한 모듈에서 다른 모듈에 정의된 항목을 참조할 수 있다. 항목을 참조할 때는 절대 경로나 상대 경로를 지정하면 된다. 이러한 경로는 use 문을 사용해 스코프 내로 가져올 수 있으며, 해당 스코프에서 항목을 여러 번 사용할 때 더 짧은 경로를 사용할 수 있다. 모듈 코드는 기본적으로 비공개(private)이지만, pub 키워드를 추가해 정의를 공개(public)로 만들 수 있다.

다음 장에서는 표준 라이브러리의 컬렉션 데이터 구조를 살펴본다. 이를 통해 잘 정리된 코드를 작성할 수 있다.

일반적인 컬렉션

Rust의 표준 라이브러리는 _컬렉션_이라고 불리는 매우 유용한 데이터 구조를 제공한다. 대부분의 다른 데이터 타입은 하나의 특정 값을 나타내지만, 컬렉션은 여러 값을 포함할 수 있다. 내장된 배열과 튜플 타입과 달리, 이러한 컬렉션이 가리키는 데이터는 힙에 저장된다. 이는 데이터의 양이 컴파일 타임에 알 필요가 없으며 프로그램 실행 중에 늘어나거나 줄어들 수 있음을 의미한다. 각 컬렉션은 서로 다른 기능과 비용을 가지며, 현재 상황에 적합한 컬렉션을 선택하는 것은 시간이 지남에 따라 개발될 기술이다. 이 장에서는 Rust 프로그램에서 자주 사용되는 세 가지 컬렉션에 대해 논의할 것이다:

  • _벡터_는 서로 연속된 위치에 가변적인 수의 값을 저장할 수 있게 해준다.
  • _문자열_은 문자들의 컬렉션이다. 이전에 String 타입을 언급했지만, 이 장에서는 이를 깊이 있게 다룬다.
  • _해시 맵_은 특정 키와 값을 연결할 수 있게 해준다. 이는 일반적으로 _맵_이라고 불리는 데이터 구조의 특정 구현이다.

표준 라이브러리에서 제공하는 다른 종류의 컬렉션에 대해 알고 싶다면 문서를 참고하라.

이 장에서는 벡터, 문자열, 해시 맵을 생성하고 업데이트하는 방법뿐만 아니라 각각의 특징에 대해서도 논의할 것이다.

값 목록 저장하기: 벡터 사용

첫 번째로 살펴볼 컬렉션 타입은 Vec<T>로, 일반적으로 _벡터_라고 부른다. 벡터는 여러 값을 단일 데이터 구조에 저장할 수 있게 해주며, 모든 값이 메모리 상에서 서로 인접하게 배치된다. 벡터는 동일한 타입의 값만 저장할 수 있다. 파일의 텍스트 줄이나 쇼핑 카트에 담긴 상품의 가격과 같은 목록을 다룰 때 유용하게 사용할 수 있다.

새로운 벡터 생성하기

비어 있는 새로운 벡터를 생성하려면 Vec::new 함수를 호출한다. 아래 예제 8-1에서 이를 확인할 수 있다.

fn main() {
    let v: Vec<i32> = Vec::new();
}
Listing 8-1: i32 타입의 값을 담을 새로운 빈 벡터 생성

여기서 타입 어노테이션을 추가한 점에 주목하자. 벡터에 어떤 값도 삽입하지 않았기 때문에 Rust는 어떤 타입의 요소를 저장할지 알 수 없다. 이는 중요한 포인트다. 벡터는 제네릭을 사용해 구현된다. 제네릭을 사용자 정의 타입과 함께 사용하는 방법은 10장에서 다룬다. 지금은 표준 라이브러리에서 제공하는 Vec<T> 타입이 어떤 타입이든 담을 수 있다는 점만 기억하자. 특정 타입의 값을 담는 벡터를 생성할 때는 꺾쇠 괄호 안에 타입을 명시할 수 있다. 예제 8-1에서는 vVec<T>i32 타입의 요소를 담을 것이라고 Rust에게 알렸다.

대부분의 경우 초기값을 가진 Vec<T>를 생성하면 Rust가 저장하려는 값의 타입을 추론하므로, 타입 어노테이션을 추가할 필요가 거의 없다. Rust는 편리하게 vec! 매크로를 제공한다. 이 매크로는 주어진 값들을 담는 새로운 벡터를 생성한다. 예제 8-2는 1, 2, 3 값을 담는 새로운 Vec<i32>를 생성한다. 정수 타입은 i32로 추론된다. 이는 기본 정수 타입이기 때문이며, 3장의 “데이터 타입” 섹션에서 다룬 바 있다.

fn main() {
    let v = vec![1, 2, 3];
}
Listing 8-2: 값을 포함한 새로운 벡터 생성

초기 i32 값을 제공했기 때문에 Rust는 v의 타입이 Vec<i32>임을 추론할 수 있다. 따라서 타입 어노테이션은 필요하지 않다. 다음으로 벡터를 수정하는 방법을 살펴본다.

벡터 업데이트

벡터를 생성한 후 요소를 추가하려면 push 메서드를 사용할 수 있다. 이를 보여주는 예제는 Listing 8-3과 같다.

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}
Listing 8-3: 벡터에 값을 추가하기 위해 push 메서드 사용

다른 변수와 마찬가지로, 값을 변경할 수 있게 하려면 mut 키워드를 사용해 변수를 가변으로 만들어야 한다. 이는 3장에서 다룬 내용이다. 벡터에 추가하는 숫자들은 모두 i32 타입이며, Rust는 데이터로부터 이를 추론하므로 Vec<i32> 타입을 명시적으로 지정할 필요가 없다.

벡터의 요소 읽기

벡터에 저장된 값에 접근하는 방법은 두 가지가 있다: 인덱싱을 사용하거나 get 메서드를 사용하는 것이다. 다음 예제에서는 각 함수가 반환하는 값의 타입을 명확히 보여주기 위해 주석을 추가했다.

리스트 8-4에서는 인덱싱 문법과 get 메서드를 사용해 벡터의 값에 접근하는 두 가지 방법을 보여준다.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let third: &i32 = &v[2];
    println!("The third element is {third}");

    let third: Option<&i32> = v.get(2);
    match third {
        Some(third) => println!("The third element is {third}"),
        None => println!("There is no third element."),
    }
}
Listing 8-4: 인덱싱 문법과 get 메서드를 사용해 벡터의 항목에 접근하기

여기서 몇 가지 세부 사항을 주목하자. 벡터는 0부터 시작하는 숫자로 인덱싱되기 때문에 세 번째 요소를 가져오기 위해 인덱스 값 2를 사용한다. &[]를 사용하면 해당 인덱스 값에 있는 요소에 대한 참조를 얻는다. get 메서드를 사용하고 인덱스를 인자로 전달하면 Option<&T>를 얻을 수 있으며, 이를 match와 함께 사용할 수 있다.

Rust는 요소를 참조하는 이 두 가지 방법을 제공하므로, 기존 요소의 범위를 벗어난 인덱스 값을 사용하려고 할 때 프로그램이 어떻게 동작할지 선택할 수 있다. 예를 들어, 다섯 개의 요소가 있는 벡터에서 인덱스 100에 있는 요소에 접근하려고 할 때 각 방법으로 어떤 일이 발생하는지 살펴보자. 이는 리스트 8-5에 나와 있다.

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let does_not_exist = &v[100];
    let does_not_exist = v.get(100);
}
Listing 8-5: 다섯 개의 요소가 있는 벡터에서 인덱스 100에 있는 요소에 접근 시도하기

이 코드를 실행하면 첫 번째 [] 메서드는 존재하지 않는 요소를 참조하기 때문에 프로그램이 패닉 상태에 빠진다. 이 메서드는 벡터의 끝을 넘어서는 요소에 접근하려고 할 때 프로그램이 강제로 종료되기를 원할 때 가장 적합하다.

get 메서드에 벡터의 범위를 벗어난 인덱스를 전달하면 패닉 없이 None을 반환한다. 이 메서드는 벡터의 범위를 벗어난 요소에 접근하는 일이 정상적인 상황에서 가끔 발생할 수 있을 때 사용한다. 그러면 코드는 Some(&element) 또는 None을 처리하는 로직을 갖게 된다. 이는 6장에서 다룬 내용이다. 예를 들어, 인덱스가 사용자가 입력한 숫자일 수 있다. 사용자가 실수로 너무 큰 숫자를 입력하고 프로그램이 None 값을 얻으면, 현재 벡터에 몇 개의 항목이 있는지 사용자에게 알려주고 유효한 값을 다시 입력할 기회를 줄 수 있다. 이는 오타로 인해 프로그램이 강제 종료되는 것보다 더 사용자 친화적이다.

프로그램이 유효한 참조를 가지고 있으면, 빌림 검사기는 소유권과 빌림 규칙(4장에서 다룸)을 적용해 이 참조와 벡터의 내용에 대한 다른 참조가 계속 유효한지 확인한다. 동일한 스코프에서 가변 참조와 불변 참조를 동시에 가질 수 없다는 규칙을 기억하자. 이 규칙은 리스트 8-6에 적용된다. 여기서는 벡터의 첫 번째 요소에 대한 불변 참조를 가지고 있는 상태에서 벡터의 끝에 요소를 추가하려고 한다. 이 프로그램은 나중에 함수에서 해당 요소를 참조하려고 하면 작동하지 않는다.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0];

    v.push(6);

    println!("The first element is: {first}");
}
Listing 8-6: 벡터의 항목에 대한 참조를 가지고 있는 상태에서 벡터에 요소 추가 시도하기

이 코드를 컴파일하면 다음과 같은 오류가 발생한다:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:5
  |
4 |     let first = &v[0];
  |                  - immutable borrow occurs here
5 |
6 |     v.push(6);
  |     ^^^^^^^^^ mutable borrow occurs here
7 |
8 |     println!("The first element is: {first}");
  |                                     ------- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error

리스트 8-6의 코드는 작동할 것처럼 보일 수 있다: 첫 번째 요소에 대한 참조가 벡터의 끝에서 발생하는 변경 사항을 왜 신경 써야 할까? 이 오류는 벡터가 작동하는 방식 때문에 발생한다. 벡터는 메모리에 값을 연속적으로 배치하기 때문에, 벡터가 현재 저장된 공간에 모든 요소를 연속적으로 배치할 수 있는 충분한 공간이 없으면, 벡터의 끝에 새로운 요소를 추가하기 위해 새로운 메모리를 할당하고 기존 요소를 새로운 공간으로 복사해야 할 수 있다. 그런 경우, 첫 번째 요소에 대한 참조는 할당 해제된 메모리를 가리키게 된다. 빌림 규칙은 프로그램이 이런 상황에 빠지는 것을 방지한다.

참고: Vec<T> 타입의 구현 세부 사항에 대해 더 알고 싶다면 “The Rustonomicon”을 참고하자.

벡터의 값에 순차적으로 접근하기

벡터의 각 요소에 하나씩 접근하기 위해 인덱스를 사용하는 대신, 모든 요소를 순회하는 방법을 사용할 수 있다. 예제 8-7은 i32 타입의 벡터에서 각 요소에 대한 불변 참조를 얻고 출력하는 방법을 보여준다.

fn main() {
    let v = vec![100, 32, 57];
    for i in &v {
        println!("{i}");
    }
}
Listing 8-7: for 루프를 사용해 벡터의 각 요소를 순회하며 출력하기

또한, 가변 벡터의 각 요소에 대한 가변 참조를 순회하며 모든 요소를 변경할 수도 있다. 예제 8-8의 for 루프는 각 요소에 50을 더한다.

fn main() {
    let mut v = vec![100, 32, 57];
    for i in &mut v {
        *i += 50;
    }
}
Listing 8-8: 벡터의 요소에 대한 가변 참조를 순회하기

가변 참조가 가리키는 값을 변경하려면, += 연산자를 사용하기 전에 * 역참조 연산자를 사용해 i의 값을 얻어야 한다. 역참조 연산자에 대해서는 15장의 “포인터를 따라가 값에 접근하기” 섹션에서 더 자세히 다룬다.

불변이든 가변이든 벡터를 순회하는 것은 빌림 검사기의 규칙 덕분에 안전하다. 예제 8-7과 예제 8-8의 for 루프 본문에서 요소를 삽입하거나 제거하려고 하면, 예제 8-6에서와 유사한 컴파일러 오류가 발생한다. for 루프가 벡터에 대한 참조를 유지하고 있기 때문에, 벡터 전체를 동시에 수정할 수 없다.

여러 타입을 저장하기 위해 열거형 사용하기

벡터는 동일한 타입의 값만 저장할 수 있다. 이는 불편할 수 있으며, 다양한 타입의 항목을 저장해야 하는 경우가 분명히 존재한다. 다행히도, 열거형의 각 변형(variant)은 동일한 열거형 타입 아래에 정의되므로, 서로 다른 타입의 요소를 표현해야 할 때 열거형을 정의하고 사용할 수 있다.

예를 들어, 스프레드시트의 한 행에서 값을 가져오려고 하는데, 해당 행의 일부 컬럼에는 정수, 일부는 부동소수점 숫자, 일부는 문자열이 포함되어 있다고 가정해 보자. 각 변형이 서로 다른 값 타입을 보유할 수 있는 열거형을 정의할 수 있으며, 모든 열거형 변형은 동일한 타입(열거형 타입)으로 간주된다. 그런 다음 해당 열거형을 보유할 벡터를 생성하여 궁극적으로 다양한 타입을 저장할 수 있다. 이를 Listing 8-9에서 보여준다.

fn main() {
    enum SpreadsheetCell {
        Int(i32),
        Float(f64),
        Text(String),
    }

    let row = vec![
        SpreadsheetCell::Int(3),
        SpreadsheetCell::Text(String::from("blue")),
        SpreadsheetCell::Float(10.12),
    ];
}
Listing 8-9: 하나의 벡터에 다양한 타입의 값을 저장하기 위해 enum 정의하기

Rust는 컴파일 타임에 벡터에 어떤 타입이 들어갈지 알아야 각 요소를 저장하기 위해 힙에 얼마나 많은 메모리가 필요한지 정확히 알 수 있다. 또한 이 벡터에 어떤 타입이 허용되는지 명시적으로 지정해야 한다. 만약 Rust가 벡터가 모든 타입을 보유할 수 있도록 허용한다면, 벡터의 요소에 대해 수행되는 연산에서 하나 이상의 타입이 오류를 일으킬 가능성이 있다. 열거형과 match 표현식을 사용하면 Rust가 컴파일 타임에 모든 가능한 경우가 처리되도록 보장한다. 이는 6장에서 논의한 바와 같다.

만약 프로그램이 런타임에 벡터에 저장할 타입의 전체 집합을 알지 못한다면, 열거형 기법은 작동하지 않는다. 대신 트레이트 객체(trait object)를 사용할 수 있으며, 이는 18장에서 다룰 예정이다.

이제 벡터를 사용하는 가장 일반적인 방법 몇 가지를 논의했으니, 표준 라이브러리에서 Vec<T>에 정의된 다양한 유용한 메서드에 대한 API 문서를 꼭 확인해 보자. 예를 들어, push 외에도 pop 메서드는 마지막 요소를 제거하고 반환한다.

벡터가 사라지면 요소들도 함께 사라진다

다른 struct와 마찬가지로, 벡터도 범위를 벗어나면 메모리에서 해제된다. 이 내용은 Listing 8-10에 표시되어 있다.

fn main() {
    {
        let v = vec![1, 2, 3, 4];

        // do stuff with v
    } // <- v goes out of scope and is freed here
}
Listing 8-10: 벡터와 그 요소들이 사라지는 시점을 보여줌

벡터가 메모리에서 해제되면, 그 안에 담긴 모든 요소들도 함께 해제된다. 즉, 벡터가 갖고 있던 정수들도 정리된다. 빌림 검사기는 벡터가 유효한 동안에만 벡터의 요소들에 대한 참조가 사용되도록 보장한다.

이제 다음 컬렉션 타입인 String으로 넘어가보자!

UTF-8 인코딩 텍스트를 문자열로 저장하기

4장에서 문자열에 대해 다뤘지만, 이번에는 더 깊이 알아보겠다. 새로운 Rust 개발자들은 주로 세 가지 이유로 문자열에서 막히곤 한다: Rust가 가능한 오류를 노출하는 경향, 문자열이 많은 프로그래머가 생각하는 것보다 더 복잡한 데이터 구조라는 점, 그리고 UTF-8 때문이다. 이러한 요소들이 합쳐져 다른 프로그래밍 언어에서 온 개발자에게는 어려워 보일 수 있다.

문자열을 컬렉션의 맥락에서 논의하는 이유는 문자열이 바이트의 컬렉션으로 구현되고, 이 바이트를 텍스트로 해석할 때 유용한 기능을 제공하는 메서드가 추가되기 때문이다. 이 섹션에서는 String에 대해 모든 컬렉션 타입이 가지는 연산들, 즉 생성, 업데이트, 읽기 등을 다룬다. 또한 String이 다른 컬렉션과 어떻게 다른지, 특히 사람과 컴퓨터가 String 데이터를 해석하는 방식의 차이로 인해 String 인덱싱이 복잡해지는 점에 대해서도 논의한다.

문자열이란 무엇인가?

먼저 _문자열_이라는 용어가 무엇을 의미하는지 정의해 보자. Rust의 코어 언어에는 단 하나의 문자열 타입만 존재한다. 바로 문자열 슬라이스 str이며, 이는 보통 빌린 형태인 &str로 나타난다. 4장에서 우리는 _문자열 슬라이스_에 대해 다뤘는데, 이는 다른 곳에 저장된 UTF-8로 인코딩된 문자열 데이터에 대한 참조다. 예를 들어, 문자열 리터럴은 프로그램의 바이너리에 저장되므로 문자열 슬라이스다.

Rust의 표준 라이브러리에서 제공하는 String 타입은 코어 언어에 내장된 것이 아니라, 크기가 늘어나고 변경 가능하며 소유권을 가진 UTF-8로 인코딩된 문자열 타입이다. Rust 개발자들이 Rust에서 “문자열“을 언급할 때, 그들은 String이나 문자열 슬라이스 &str 타입 중 하나를 의미할 수 있으며, 단일 타입만을 가리키는 것은 아니다. 이 섹션은 주로 String에 대해 다루지만, 두 타입 모두 Rust의 표준 라이브러리에서 광범위하게 사용되며, String과 문자열 슬라이스 모두 UTF-8로 인코딩된다.

새로운 문자열 생성하기

Vec<T>에서 사용할 수 있는 많은 연산이 String에서도 동일하게 사용 가능하다. 이는 String이 실제로 바이트 벡터를 기반으로 구현된 래퍼이기 때문이다. 다만, 몇 가지 추가적인 보장, 제약, 그리고 기능이 더해져 있다. Vec<T>String에서 동일하게 작동하는 함수의 예로는 인스턴스를 생성하는 new 함수가 있다. 이는 리스트 8-11에서 확인할 수 있다.

fn main() {
    let mut s = String::new();
}
Listing 8-11: 새로운 빈 String 생성하기

이 코드는 s라는 빈 문자열을 생성한다. 이 문자열에 이후 데이터를 로드할 수 있다. 종종 초기 데이터를 가지고 문자열을 시작하고 싶을 때가 있다. 이 경우 to_string 메서드를 사용한다. 이 메서드는 Display 트레잇을 구현한 모든 타입에서 사용할 수 있으며, 문자열 리터럴도 이에 해당한다. 리스트 8-12는 두 가지 예를 보여준다.

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}
Listing 8-12: to_string 메서드를 사용해 문자열 리터럴로부터 String 생성하기

이 코드는 initial contents라는 내용을 가진 문자열을 생성한다.

또한 String::from 함수를 사용해 문자열 리터럴로부터 String을 생성할 수도 있다. 리스트 8-13의 코드는 to_string을 사용한 리스트 8-12의 코드와 동일한 기능을 수행한다.

fn main() {
    let s = String::from("initial contents");
}
Listing 8-13: String::from 함수를 사용해 문자열 리터럴로부터 String 생성하기

문자열은 매우 다양한 용도로 사용되기 때문에, 문자열을 다루는 다양한 일반적인 API를 활용할 수 있다. 이는 우리에게 많은 선택지를 제공한다. 일부 API는 중복되어 보일 수 있지만, 각각의 API는 고유의 용도가 있다! 이 경우 String::fromto_string은 동일한 기능을 수행하므로, 어떤 것을 선택할지는 스타일과 가독성의 문제이다.

문자열은 UTF-8로 인코딩되어 있으므로, 올바르게 인코딩된 모든 데이터를 포함할 수 있다. 이는 리스트 8-14에서 확인할 수 있다.

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}
Listing 8-14: 다양한 언어의 인사말을 문자열에 저장하기

이 모든 값들은 유효한 String 값이다.

문자열 업데이트

StringVec<T>와 마찬가지로 크기가 늘어나고 내용이 변경될 수 있다. 더 많은 데이터를 추가하면 크기가 증가한다. 또한, + 연산자나 format! 매크로를 사용해 String 값을 간편하게 연결할 수 있다.

push_strpush를 사용해 문자열에 추가하기

push_str 메서드를 사용하면 문자열 슬라이스를 추가하여 String을 확장할 수 있다. 이는 Listing 8-15에서 확인할 수 있다.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
Listing 8-15: push_str 메서드를 사용해 문자열 슬라이스를 String에 추가하기

이 두 줄의 코드를 실행한 후, sfoobar를 포함하게 된다. push_str 메서드는 문자열 슬라이스를 인자로 받는데, 이는 매개변수의 소유권을 가져갈 필요가 없기 때문이다. 예를 들어, Listing 8-16의 코드에서는 s2의 내용을 s1에 추가한 후에도 s2를 계속 사용할 수 있다.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}
Listing 8-16: 문자열 슬라이스를 String에 추가한 후에도 사용하기

만약 push_str 메서드가 s2의 소유권을 가져갔다면, 마지막 줄에서 s2의 값을 출력할 수 없었을 것이다. 하지만 이 코드는 예상대로 동작한다!

push 메서드는 단일 문자를 인자로 받아 String에 추가한다. Listing 8-17은 push 메서드를 사용해 문자 lString에 추가하는 예제를 보여준다.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}
Listing 8-17: push 메서드를 사용해 String에 한 문자 추가하기

결과적으로, slol을 포함하게 된다.

+ 연산자와 format! 매크로를 사용한 문자열 결합

두 개의 기존 문자열을 결합해야 하는 경우가 종종 있다. 이를 수행하는 한 가지 방법은 + 연산자를 사용하는 것이다. 이는 리스트 8-18에서 확인할 수 있다.

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
Listing 8-18: 두 개의 String 값을 결합하여 새로운 String 값을 생성하기 위해 + 연산자 사용

문자열 s3Hello, world!를 포함한다. s1이 더 이상 유효하지 않은 이유와 s2에 대한 참조를 사용한 이유는 + 연산자를 사용할 때 호출되는 메서드의 시그니처와 관련이 있다. + 연산자는 add 메서드를 사용하며, 이 메서드의 시그니처는 다음과 같다:

fn add(self, s: &str) -> String {

표준 라이브러리에서 add는 제네릭과 연관 타입을 사용해 정의된다. 여기서는 구체적인 타입을 대입했는데, 이는 String 값으로 이 메서드를 호출할 때 발생한다. 제네릭에 대해서는 10장에서 자세히 다룬다. 이 시그니처는 + 연산자의 복잡한 부분을 이해하기 위한 단서를 제공한다.

먼저, s2에는 &가 붙어있는데, 이는 두 번째 문자열의 _참조_를 첫 번째 문자열에 추가한다는 의미다. 이는 add 함수의 s 매개변수 때문이다. String&str만 추가할 수 있으며, 두 String 값을 함께 추가할 수는 없다. 그런데 &s2의 타입은 add의 두 번째 매개변수로 지정된 &str이 아니라 &String이다. 그렇다면 리스트 8-18이 왜 컴파일될까?

add 호출에서 &s2를 사용할 수 있는 이유는 컴파일러가 &String 인수를 &str로 _강제 변환_할 수 있기 때문이다. add 메서드를 호출할 때 Rust는 _역참조 강제 변환_을 사용하며, 여기서 &s2&s2[..]로 변환한다. 역참조 강제 변환에 대해서는 15장에서 더 깊이 다룬다. adds 매개변수의 소유권을 가지지 않기 때문에, 이 작업 이후에도 s2는 여전히 유효한 String이다.

두 번째로, 시그니처에서 addself의 소유권을 가져간다는 것을 알 수 있다. 이는 self&가 없기 때문이다. 이는 리스트 8-18에서 s1add 호출로 이동되고 이후에는 더 이상 유효하지 않음을 의미한다. 따라서 let s3 = s1 + &s2;는 두 문자열을 복사하여 새로운 문자열을 생성하는 것처럼 보이지만, 실제로는 s1의 소유권을 가져가고 s2의 내용을 복사한 후 결과의 소유권을 반환한다. 즉, 많은 복사가 일어나는 것처럼 보이지만 실제로는 그렇지 않다. 구현은 복사보다 더 효율적이다.

여러 문자열을 결합해야 하는 경우, + 연산자의 동작은 다루기 어려워진다:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

이 시점에서 stic-tac-toe가 된다. 모든 +" 문자 때문에 무슨 일이 일어나고 있는지 파악하기 어렵다. 더 복잡한 방식으로 문자열을 결합해야 한다면, 대신 format! 매크로를 사용할 수 있다:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

이 코드 또한 stic-tac-toe로 설정한다. format! 매크로는 println!과 유사하게 동작하지만, 출력을 화면에 표시하는 대신 내용을 담은 String을 반환한다. format!을 사용한 코드 버전은 훨씬 읽기 쉽고, format! 매크로가 생성하는 코드는 참조를 사용하기 때문에 이 호출은 어떤 매개변수의 소유권도 가져가지 않는다.

문자열 인덱싱

다른 많은 프로그래밍 언어에서는 문자열의 개별 문자를 인덱스를 통해 접근하는 것이 일반적이고 유효한 작업이다. 하지만 Rust에서 문자열의 일부를 인덱싱 문법을 사용해 접근하려고 하면 오류가 발생한다. 다음은 유효하지 않은 코드 예제이다.

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}
Listing 8-19: String에 인덱싱 문법을 사용하려는 시도

이 코드는 다음과 같은 오류를 발생시킨다:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
          but trait `SliceIndex<[_]>` is implemented for `usize`
  = help: for that trait implementation, expected `[_]`, found `str`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

오류 메시지와 설명을 통해 알 수 있듯이, Rust의 문자열은 인덱싱을 지원하지 않는다. 그렇다면 왜 그럴까? 이 질문에 답하려면 Rust가 문자열을 메모리에 어떻게 저장하는지 알아야 한다.

내부 표현

StringVec<u8>을 감싼 래퍼다. 리스트 8-14에서 제대로 인코딩된 UTF-8 예제 문자열을 살펴보자. 먼저 이 예제를 보자:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

이 경우 len4가 되며, 이는 문자열 "Hola"를 저장하는 벡터가 4바이트 길이라는 것을 의미한다. 이 문자열의 각 글자는 UTF-8로 인코딩될 때 1바이트를 차지한다. 그러나 다음 줄은 조금 놀라울 수 있다(이 문자열은 숫자 3이 아니라 키릴 문자 대문자 _Ze_로 시작한다는 점에 주목하라):

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

이 문자열의 길이를 묻는다면, 아마 12라고 답할 것이다. 그러나 Rust의 답은 24다. 이는 “Здравствуйте“를 UTF-8로 인코딩하는 데 필요한 바이트 수이며, 이 문자열의 각 유니코드 스칼라 값이 2바이트를 차지하기 때문이다. 따라서 문자열의 바이트 인덱스가 항상 유효한 유니코드 스칼라 값과 일치하지는 않는다. 이를 확인하기 위해 다음의 유효하지 않은 Rust 코드를 살펴보자:

let hello = "Здравствуйте";
let answer = &hello[0];

이미 알고 있듯이, answer는 첫 번째 글자인 З가 아니다. UTF-8로 인코딩된 З의 첫 번째 바이트는 208이고 두 번째 바이트는 151이므로, answer는 사실 208이어야 할 것 같다. 그러나 208은 단독으로 유효한 문자가 아니다. 이 문자열의 첫 번째 글자를 요청했을 때 208을 반환하는 것은 사용자가 원하는 결과가 아닐 것이다. 하지만 Rust가 바이트 인덱스 0에서 가진 데이터는 이것뿐이다. 사용자는 일반적으로 바이트 값을 원하지 않는다. 문자열이 라틴 문자만 포함하더라도 마찬가지다: &"hi"[0]이 바이트 값을 반환하는 유효한 코드였다면, h가 아니라 104를 반환했을 것이다.

따라서 Rust는 예상치 못한 값을 반환하고 즉시 발견되지 않을 수 있는 버그를 방지하기 위해, 이 코드를 아예 컴파일하지 않고 개발 과정 초기에 오해를 방지한다.

바이트, 스칼라 값, 그리고 그래핌 클러스터! 이해하기

UTF-8과 관련해 Rust에서는 문자열을 세 가지 방식으로 바라볼 수 있다. 바이트, 스칼라 값, 그리고 그래핌 클러스터(일반적으로 ’글자’라고 부르는 것에 가장 가까운 개념)가 그것이다.

힌디어 단어 “नमस्ते“를 데바나가리 문자로 작성할 때, 이 문자열은 다음과 같은 u8 값의 벡터로 저장된다:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

이 데이터는 총 18바이트로, 컴퓨터가 최종적으로 저장하는 방식이다. 이 데이터를 Rust의 char 타입인 유니코드 스칼라 값으로 보면 다음과 같다:

['न', 'म', 'स', '्', 'त', 'े']

여기에는 6개의 char 값이 있지만, 네 번째와 여섯 번째는 글자가 아니다. 이들은 독립적으로는 의미가 없는 발음 구별 기호다. 마지막으로, 이 데이터를 그래핌 클러스터로 보면 힌디어 단어를 구성하는 네 개의 글자로 인식한다:

["न", "म", "स्", "ते"]

Rust는 컴퓨터가 저장한 원시 문자열 데이터를 다양한 방식으로 해석할 수 있도록 지원한다. 이를 통해 각 프로그램은 데이터가 어떤 인간의 언어로 되어 있든 상관없이 필요한 해석 방식을 선택할 수 있다.

Rust가 String에 인덱스를 사용해 문자를 가져오는 것을 허용하지 않는 마지막 이유는, 인덱싱 연산이 항상 일정한 시간(O(1)) 내에 수행될 것으로 기대되기 때문이다. 그러나 String의 경우 이 성능을 보장할 수 없다. Rust는 인덱스까지 유효한 문자가 몇 개인지 확인하기 위해 처음부터 내용을 순회해야 하기 때문이다.

문자열 슬라이싱

문자열을 인덱스로 접근하는 것은 일반적으로 좋은 방법이 아니다. 문자열 인덱싱 연산의 반환 타입이 무엇인지 명확하지 않기 때문이다. 바이트 값, 문자, 그래핀 클러스터, 문자열 슬라이스 중 무엇을 반환해야 할지 애매하다. 따라서 Rust에서는 인덱스를 사용해 문자열 슬라이스를 생성하려면 더 명확하게 지정하도록 요구한다.

단일 숫자를 사용해 []로 인덱싱하는 대신, 범위를 사용해 []로 특정 바이트를 포함하는 문자열 슬라이스를 생성할 수 있다.

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

여기서 s는 문자열의 처음 4바이트를 포함하는 &str 타입이 된다. 앞서 언급했듯이, 이 문자들은 각각 2바이트를 차지하므로 sЗд가 된다.

만약 &hello[0..1]과 같이 문자 바이트의 일부만 슬라이스하려고 하면, Rust는 벡터에서 유효하지 않은 인덱스에 접근할 때와 마찬가지로 런타임에 패닉을 발생시킨다.

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

범위를 사용해 문자열 슬라이스를 생성할 때는 주의해야 한다. 그렇지 않으면 프로그램이 비정상 종료될 수 있다.

문자열을 순회하는 방법

문자열의 일부를 처리할 때는 문자를 다룰지 바이트를 다룰지 명확히 정하는 것이 중요하다. 유니코드 스칼라 값(Unicode scalar value)을 다루려면 chars 메서드를 사용한다. “Зд“에 chars를 호출하면 두 개의 char 타입 값으로 분리되어 반환된다. 그런 다음 결과를 순회하면서 각 요소에 접근할 수 있다:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

이 코드는 다음과 같이 출력한다:

З
д

반면 bytes 메서드는 각 원시 바이트를 반환한다. 이 방법은 특정 도메인에서 적합할 수 있다:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

이 코드는 해당 문자열을 구성하는 네 개의 바이트를 출력한다:

208
151
208
180

하지만 유효한 유니코드 스칼라 값이 하나 이상의 바이트로 구성될 수 있다는 점을 반드시 기억해야 한다.

데바나가리 문자와 같은 그래핌 클러스터(grapheme cluster)를 문자열에서 추출하는 작업은 복잡하다. 따라서 이러한 기능은 표준 라이브러리에서 제공하지 않는다. 만약 이 기능이 필요하다면 crates.io에서 관련 크레이트를 찾을 수 있다.

문자열은 단순하지 않다

요약하자면, 문자열은 복잡하다. 각 프로그래밍 언어는 이 복잡성을 프로그래머에게 어떻게 보여줄지 다른 선택을 한다. Rust는 모든 Rust 프로그램에서 String 데이터를 올바르게 처리하는 것을 기본 동작으로 선택했다. 이는 프로그래머가 UTF-8 데이터를 처음부터 더 신경 써서 처리해야 한다는 것을 의미한다. 이러한 트레이드오프는 다른 프로그래밍 언어에서는 드러나지 않는 문자열의 복잡성을 더 많이 노출시키지만, 개발 주기 후반에 비ASCII 문자와 관련된 오류를 처리해야 하는 상황을 방지한다.

좋은 소식은 표준 라이브러리가 String&str 타입을 기반으로 이러한 복잡한 상황을 올바르게 처리할 수 있는 많은 기능을 제공한다는 것이다. 문자열 내에서 검색을 수행하는 contains나 문자열의 일부를 다른 문자열로 대체하는 replace와 같은 유용한 메서드에 대한 문서를 꼭 확인해 보자.

이제 조금 덜 복잡한 주제인 해시 맵으로 넘어가 보자!

키와 값을 연결하여 저장하는 해시 맵

마지막으로 살펴볼 일반적인 컬렉션은 _해시 맵_이다. HashMap<K, V> 타입은 키 타입 K와 값 타입 V를 연결하여 저장한다. 이때 _해시 함수_를 사용하여 키와 값을 메모리에 배치하는 방식을 결정한다. 많은 프로그래밍 언어가 이러한 데이터 구조를 지원하지만, 종종 해시, , 객체, 해시 테이블, 딕셔너리, 연관 배열 등 다양한 이름으로 불린다.

해시 맵은 벡터처럼 인덱스가 아닌 임의의 타입을 가진 키를 사용해 데이터를 조회하고자 할 때 유용하다. 예를 들어, 게임에서 각 팀의 점수를 해시 맵으로 관리할 수 있다. 이때 키는 팀 이름이고, 값은 각 팀의 점수다. 팀 이름을 알면 해당 팀의 점수를 조회할 수 있다.

이 섹션에서는 해시 맵의 기본 API를 살펴보겠지만, 표준 라이브러리의 HashMap<K, V>에 정의된 함수에는 더 많은 기능이 숨겨져 있다. 항상 그렇듯, 더 많은 정보를 얻으려면 표준 라이브러리 문서를 참고하자.

새로운 해시 맵 생성하기

비어 있는 해시 맵을 만드는 한 가지 방법은 new를 사용하고 insert로 요소를 추가하는 것이다. 예제 8-20에서는 _Blue_와 _Yellow_라는 두 팀의 점수를 추적한다. Blue 팀은 10점으로 시작하고, Yellow 팀은 50점으로 시작한다.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);
}
Listing 8-20: 새로운 해시 맵을 생성하고 키와 값을 추가하기

먼저 표준 라이브러리의 컬렉션 부분에서 HashMapuse로 가져와야 한다. 세 가지 일반적인 컬렉션 중에서 이 해시 맵이 가장 덜 사용되기 때문에, 프리루드에서 자동으로 범위에 포함되지 않는다. 또한 해시 맵은 표준 라이브러리에서 지원이 적다. 예를 들어, 해시 맵을 생성하는 내장 매크로가 없다.

벡터와 마찬가지로, 해시 맵도 데이터를 힙에 저장한다. 이 HashMapString 타입의 키와 i32 타입의 값을 가진다. 벡터처럼 해시 맵도 동일한 타입을 가진다. 모든 키는 같은 타입이어야 하고, 모든 값도 같은 타입이어야 한다.

해시 맵에서 값 접근하기

해시 맵에서 값을 가져오려면 키를 get 메서드에 전달하면 된다. 아래 예제는 Blue 팀의 점수를 가져오는 방법을 보여준다.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    let team_name = String::from("Blue");
    let score = scores.get(&team_name).copied().unwrap_or(0);
}
Listing 8-21: 해시 맵에 저장된 Blue 팀의 점수에 접근하기

이 예제에서 score는 Blue 팀과 연결된 값을 가지며, 결과는 10이 된다. get 메서드는 Option<&V>를 반환한다. 만약 해당 키에 대한 값이 해시 맵에 없다면, getNone을 반환한다. 이 프로그램은 Option을 처리하기 위해 copied를 호출해 Option<&i32> 대신 Option<i32>를 얻고, unwrap_or를 사용해 scores에 키에 대한 항목이 없을 경우 score를 0으로 설정한다.

벡터와 유사한 방식으로 for 루프를 사용해 해시 맵의 각 키-값 쌍을 순회할 수도 있다:

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Yellow"), 50);

    for (key, value) in &scores {
        println!("{key}: {value}");
    }
}

이 코드는 각 쌍을 임의의 순서로 출력한다:

Yellow: 50
Blue: 10

해시 맵과 소유권

i32와 같이 Copy 트레잇을 구현한 타입의 경우, 값이 해시 맵으로 복사된다. String과 같은 소유된 값의 경우, 값이 이동하고 해시 맵이 그 값의 소유자가 된다. 이는 리스트 8-22에서 확인할 수 있다.

fn main() {
    use std::collections::HashMap;

    let field_name = String::from("Favorite color");
    let field_value = String::from("Blue");

    let mut map = HashMap::new();
    map.insert(field_name, field_value);
    // field_name and field_value are invalid at this point, try using them and
    // see what compiler error you get!
}
Listing 8-22: 키와 값이 해시 맵에 삽입된 후 해시 맵의 소유가 됨을 보여줌

insert 호출을 통해 field_namefield_value 변수가 해시 맵으로 이동한 후에는 이 변수들을 더 이상 사용할 수 없다.

만약 해시 맵에 값의 참조를 삽입한다면, 값은 해시 맵으로 이동하지 않는다. 참조가 가리키는 값은 해시 맵이 유효한 동안 최소한 그만큼 유효해야 한다. 이 문제에 대해서는 10장의 “라이프타임을 사용한 참조 유효성 검사”에서 더 자세히 다룰 것이다.

해시 맵 업데이트

키와 값 쌍의 개수는 늘어날 수 있지만, 각 고유 키는 한 번에 하나의 값만 가질 수 있다(반대의 경우는 아니다. 예를 들어 Blue 팀과 Yellow 팀 모두 scores 해시 맵에 10이라는 값을 저장할 수 있다).

해시 맵의 데이터를 변경하려면, 이미 값이 할당된 키를 어떻게 처리할지 결정해야 한다. 기존 값을 완전히 무시하고 새로운 값으로 교체할 수 있다. 아니면 기존 값을 유지하고 새로운 값을 무시하거나, 키에 값이 없을 때만 새로운 값을 추가할 수도 있다. 또는 기존 값과 새로운 값을 결합할 수도 있다. 각각의 방법을 어떻게 구현하는지 살펴보자!

값 덮어쓰기

해시 맵에 키와 값을 추가한 후, 같은 키에 다른 값을 추가하면 해당 키에 연결된 값이 교체된다. Listing 8-23의 코드는 insert를 두 번 호출하지만, 두 번 모두 Blue 팀의 키에 값을 추가하기 때문에 해시 맵에는 하나의 키-값 쌍만 존재하게 된다.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();

    scores.insert(String::from("Blue"), 10);
    scores.insert(String::from("Blue"), 25);

    println!("{scores:?}");
}
Listing 8-23: 특정 키에 저장된 값을 교체하는 예제

이 코드는 {"Blue": 25}를 출력한다. 원래 값인 10은 덮어쓰여졌다.

키와 값을 키가 없는 경우에만 추가하기

특정 키가 이미 해시 맵에 값과 함께 존재하는지 확인한 후, 다음과 같은 동작을 수행하는 경우가 많다: 키가 해시 맵에 존재하면 기존 값을 그대로 유지하고, 키가 존재하지 않으면 해당 키와 값을 삽입한다.

해시 맵은 이를 위해 entry라는 특별한 API를 제공한다. entry는 확인하려는 키를 인자로 받는다. entry 메서드의 반환 값은 Entry라는 열거형으로, 값이 존재할 수도 있고 없을 수도 있는 상태를 나타낸다. 예를 들어, Yellow 팀의 키에 값이 있는지 확인하고 싶다고 가정해 보자. 값이 없다면 50을 삽입하고, Blue 팀에 대해서도 동일한 작업을 수행한다. entry API를 사용하면 다음과 같이 코드를 작성할 수 있다.

fn main() {
    use std::collections::HashMap;

    let mut scores = HashMap::new();
    scores.insert(String::from("Blue"), 10);

    scores.entry(String::from("Yellow")).or_insert(50);
    scores.entry(String::from("Blue")).or_insert(50);

    println!("{scores:?}");
}
Listing 8-24: 키가 이미 값을 가지고 있지 않은 경우에만 삽입하기 위해 entry 메서드 사용

Entryor_insert 메서드는 해당 Entry 키에 대한 값의 가변 참조를 반환하도록 정의되어 있다. 키가 존재하면 그 값의 가변 참조를 반환하고, 키가 존재하지 않으면 인자로 전달된 값을 새 값으로 삽입한 후, 새 값의 가변 참조를 반환한다. 이 기법은 직접 로직을 작성하는 것보다 훨씬 깔끔하며, 빌림 검사기와도 더 잘 동작한다.

Listing 8-24의 코드를 실행하면 {"Yellow": 50, "Blue": 10}가 출력된다. 첫 번째 entry 호출은 Yellow 팀의 키에 50을 삽입한다. Yellow 팀은 이미 값이 없기 때문이다. 두 번째 entry 호출은 해시 맵을 변경하지 않는다. Blue 팀은 이미 10이라는 값을 가지고 있기 때문이다.

기존 값을 기반으로 값 업데이트하기

해시맵의 또 다른 일반적인 사용 사례는 키의 값을 조회한 후 기존 값을 기반으로 업데이트하는 것이다. 예를 들어, 리스트 8-25는 특정 텍스트에서 각 단어가 몇 번 등장하는지 세는 코드를 보여준다. 단어를 키로 사용하고 값을 증가시켜 해당 단어를 몇 번이나 봤는지 추적한다. 만약 단어를 처음 보는 경우, 먼저 값 0을 삽입한다.

fn main() {
    use std::collections::HashMap;

    let text = "hello world wonderful world";

    let mut map = HashMap::new();

    for word in text.split_whitespace() {
        let count = map.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{map:?}");
}
Listing 8-25: 단어와 횟수를 저장하는 해시맵을 사용해 단어의 등장 횟수를 세는 예제

이 코드는 {"world": 2, "hello": 1, "wonderful": 1}를 출력한다. 동일한 키-값 쌍이 다른 순서로 출력될 수도 있다: “해시맵의 값에 접근하기”에서 해시맵을 순회할 때 순서가 임의적이라는 것을 떠올려 보자.

split_whitespace 메서드는 text의 값을 공백으로 구분한 부분 슬라이스의 반복자를 반환한다. or_insert 메서드는 지정된 키에 대한 값의 가변 참조(&mut V)를 반환한다. 여기서는 이 가변 참조를 count 변수에 저장하므로, 값을 할당하려면 먼저 별표(*)를 사용해 count를 역참조해야 한다. 가변 참조는 for 루프가 끝날 때 스코프를 벗어나므로, 이러한 모든 변경은 안전하며 빌림 규칙에 의해 허용된다.

해싱 함수

기본적으로 HashMap은 해시 테이블을 이용한 서비스 거부 공격(DoS)에 대한 내성을 제공하는 _SipHash_라는 해싱 함수를 사용한다. 이 해싱 알고리즘은 가장 빠른 것은 아니지만, 성능 저하를 감수하고 더 나은 보안을 얻는 것은 가치 있는 선택이다. 만약 코드를 프로파일링한 결과 기본 해시 함수가 목적에 비해 너무 느리다고 판단되면, 다른 해시 함수를 사용하도록 설정할 수 있다. _해셔(hasher)_는 BuildHasher 트레이트를 구현한 타입이다. 트레이트와 이를 구현하는 방법에 대해서는 10장에서 자세히 다룬다. 반드시 처음부터 자신만의 해셔를 구현할 필요는 없다. crates.io에는 다른 Rust 사용자들이 공유한 라이브러리가 있으며, 여기서 다양한 일반적인 해싱 알고리즘을 구현한 해셔를 찾을 수 있다.

요약

벡터, 문자열, 해시 맵은 데이터를 저장, 접근, 수정할 때 필요한 다양한 기능을 제공한다. 이제 여러분은 다음과 같은 문제를 해결할 준비가 되었다:

  1. 정수 리스트가 주어졌을 때, 벡터를 사용해 중앙값(정렬했을 때 중간 위치의 값)과 최빈값(가장 자주 나타나는 값; 해시 맵이 유용함)을 반환한다.
  2. 문자열을 피그 라틴으로 변환한다. 각 단어의 첫 번째 자음을 단어 끝으로 옮기고 _ay_를 추가한다. 예를 들어 _first_는 _irst-fay_가 된다. 모음으로 시작하는 단어는 단어 끝에 _hay_를 추가한다(_apple_은 _apple-hay_가 됨). UTF-8 인코딩에 대한 세부 사항을 기억하자!
  3. 해시 맵과 벡터를 사용해 텍스트 인터페이스를 만들어 사용자가 회사의 부서에 직원 이름을 추가할 수 있게 한다. 예를 들어 “Add Sally to Engineering” 또는 “Add Amir to Sales“와 같은 명령을 처리한다. 그런 다음 사용자가 특정 부서의 모든 직원 목록이나 부서별로 정렬된 전체 직원 목록을 조회할 수 있게 한다.

표준 라이브러리 API 문서에는 이러한 문제를 해결하는 데 유용한 벡터, 문자열, 해시 맵의 메서드가 설명되어 있다!

이제 더 복잡한 프로그램을 다루게 되면서 작업이 실패할 가능성이 생긴다. 따라서 오류 처리를 논의하기에 완벽한 시기다. 다음 장에서 이를 자세히 다룰 것이다!

에러 처리

소프트웨어에서 에러는 피할 수 없는 현실이다. Rust는 무언가 잘못되었을 때 대처할 수 있는 다양한 기능을 제공한다. 많은 경우, Rust는 코드가 컴파일되기 전에 에러 가능성을 인지하고 적절한 조치를 취하도록 요구한다. 이 요구사항은 코드를 프로덕션에 배포하기 전에 에러를 발견하고 적절히 처리할 수 있도록 보장함으로써 프로그램을 더 견고하게 만든다.

Rust는 에러를 크게 두 가지 범주로 나눈다: 복구 가능한 에러복구 불가능한 에러. 파일을 찾을 수 없는 경우와 같은 복구 가능한 에러는 사용자에게 문제를 보고하고 작업을 다시 시도하는 것이 일반적이다. 반면, 배열의 끝을 넘어서는 위치에 접근하려는 경우와 같은 복구 불가능한 에러는 버그의 징후이므로 프로그램을 즉시 중단해야 한다.

대부분의 언어는 이 두 가지 에러를 구분하지 않고 예외와 같은 메커니즘을 사용해 동일한 방식으로 처리한다. Rust는 예외를 제공하지 않는다. 대신, 복구 가능한 에러를 처리하기 위해 Result<T, E> 타입을 사용하고, 복구 불가능한 에러가 발생했을 때 프로그램 실행을 중단하기 위해 panic! 매크로를 제공한다. 이 장에서는 먼저 panic!을 호출하는 방법을 다루고, 그다음 Result<T, E> 값을 반환하는 방법에 대해 설명한다. 또한, 에러에서 복구를 시도할지 아니면 실행을 중단할지 결정할 때 고려해야 할 사항을 탐구한다.

panic!로 처리할 수 없는 오류

코드에서 예기치 못한 문제가 발생할 때가 있다. 이런 경우 Rust는 panic! 매크로를 제공한다. 실제로 패닉을 일으키는 방법은 두 가지다: 코드가 패닉을 일으키는 동작을 수행하거나(예: 배열의 끝을 넘어서 접근), panic! 매크로를 직접 호출하는 것이다. 두 경우 모두 프로그램에서 패닉이 발생한다. 기본적으로 패닉은 실패 메시지를 출력하고, 스택을 되감은 뒤 정리하고 프로그램을 종료한다. 환경 변수를 통해 패닉이 발생할 때 Rust가 호출 스택을 표시하도록 설정할 수도 있다. 이렇게 하면 패닉의 원인을 더 쉽게 추적할 수 있다.

스택 되감기와 즉시 종료

기본적으로 패닉이 발생하면 프로그램은 스택을 되감기 시작한다. 이는 Rust가 스택을 거슬러 올라가면서 각 함수에서 사용한 데이터를 정리한다는 의미다. 하지만 이 과정은 많은 작업을 필요로 한다. 따라서 Rust는 즉시 종료하는 방식을 선택할 수도 있다. 이 경우 프로그램은 정리 과정 없이 바로 종료된다.

프로그램이 사용하던 메모리는 운영체제가 정리하게 된다. 프로젝트에서 생성되는 바이너리 파일의 크기를 최대한 작게 만들고 싶다면, Cargo.toml 파일의 [profile] 섹션에 panic = 'abort'를 추가해 패닉 발생 시 스택 되감기 대신 즉시 종료하도록 설정할 수 있다. 예를 들어, 릴리스 모드에서 패닉 발생 시 즉시 종료하려면 다음과 같이 설정한다:

[profile.release]
panic = 'abort'

간단한 프로그램에서 panic!을 호출해 보자:

Filename: src/main.rs
fn main() {
    panic!("crash and burn");
}

프로그램을 실행하면 다음과 같은 결과를 볼 수 있다:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:2:5:
crash and burn
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

panic! 호출은 마지막 두 줄에 있는 오류 메시지를 발생시킨다. 첫 번째 줄은 패닉 메시지와 소스 코드에서 패닉이 발생한 위치를 보여준다: src/main.rs:2:5src/main.rs 파일의 두 번째 줄, 다섯 번째 문자를 가리킨다.

이 경우, 표시된 줄은 우리 코드의 일부이며, 해당 줄을 보면 panic! 매크로 호출을 확인할 수 있다. 다른 경우에는 panic! 호출이 우리 코드가 호출한 다른 코드에서 발생할 수도 있다. 이때 오류 메시지가 표시하는 파일 이름과 줄 번호는 panic! 매크로가 호출된 다른 코드의 위치를 가리키게 된다.

panic! 호출이 발생한 함수의 백트레이스를 사용해 문제의 원인을 찾을 수 있다. panic! 백트레이스를 어떻게 사용하는지 이해하기 위해 또 다른 예제를 살펴보자. 이번에는 우리 코드가 직접 panic! 매크로를 호출하는 대신, 라이브러리에서 버그로 인해 panic!이 발생하는 경우를 살펴본다. 리스트 9-1은 벡터의 유효한 인덱스 범위를 벗어난 위치에 접근하려는 코드다.

Filename: src/main.rs
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}
Listing 9-1: 벡터의 끝을 넘어선 위치에 접근하려는 시도로 인해 panic!이 호출되는 예제

여기서는 벡터의 100번째 요소(인덱스는 0부터 시작하므로 실제로는 99번째)에 접근하려고 하지만, 벡터에는 세 개의 요소만 있다. 이런 상황에서 Rust는 패닉을 발생시킨다. []는 요소를 반환해야 하지만, 유효하지 않은 인덱스를 전달하면 Rust가 반환할 수 있는 올바른 요소가 없다.

C 언어에서는 데이터 구조의 끝을 넘어서 읽으려고 하면 정의되지 않은 동작이 발생한다. 데이터 구조에 속하지 않는 메모리 위치에 있는 값을 반환할 수도 있다. 이를 버퍼 오버리드(buffer overread) 라고 하며, 공격자가 인덱스를 조작해 데이터 구조 뒤에 저장된 접근 권한이 없는 데이터를 읽을 수 있게 되면 보안 취약점으로 이어질 수 있다.

이런 취약점으로부터 프로그램을 보호하기 위해, Rust는 존재하지 않는 인덱스의 요소를 읽으려고 하면 실행을 중단하고 더 이상 진행하지 않는다. 한번 실행해 보자:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/panic`

thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

이 오류는 main.rs 파일의 4번째 줄에서 벡터 v의 인덱스 99에 접근하려는 시도를 가리킨다.

note: 줄은 RUST_BACKTRACE 환경 변수를 설정해 오류의 원인이 된 정확한 백트레이스를 확인할 수 있다고 알려준다. 백트레이스(backtrace) 는 이 지점에 도달하기까지 호출된 모든 함수의 목록이다. Rust의 백트레이스는 다른 언어와 동일하게 동작한다: 백트레이스를 읽는 핵심은 위에서부터 시작해 우리가 작성한 파일을 볼 때까지 읽는 것이다. 그곳이 문제의 시작점이다. 그 위의 줄들은 우리 코드가 호출한 코드고, 아래의 줄들은 우리 코드를 호출한 코드다. 이 앞뒤 줄들은 Rust의 코어 코드, 표준 라이브러리 코드, 또는 사용 중인 크레이트의 코드일 수 있다. RUST_BACKTRACE 환경 변수를 0이 아닌 값으로 설정해 백트레이스를 확인해 보자. 리스트 9-2는 이렇게 했을 때 볼 수 있는 출력 예제를 보여준다.

$ RUST_BACKTRACE=1 cargo run
thread 'main' panicked at src/main.rs:4:6:
index out of bounds: the len is 3 but the index is 99
stack backtrace:
   0: rust_begin_unwind
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/std/src/panicking.rs:692:5
   1: core::panicking::panic_fmt
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:75:14
   2: core::panicking::panic_bounds_check
             at /rustc/4d91de4e48198da2e33413efdcd9cd2cc0c46688/library/core/src/panicking.rs:273:5
   3: <usize as core::slice::index::SliceIndex<[T]>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:274:10
   4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/slice/index.rs:16:9
   5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/alloc/src/vec/mod.rs:3361:9
   6: panic::main
             at ./src/main.rs:4:6
   7: core::ops::function::FnOnce::call_once
             at file:///home/.rustup/toolchains/1.85/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
Listing 9-2: RUST_BACKTRACE 환경 변수가 설정되었을 때 panic! 호출로 생성된 백트레이스

출력이 상당히 많다! 정확한 출력은 운영체제와 Rust 버전에 따라 다를 수 있다. 이 정보를 포함한 백트레이스를 얻으려면 디버그 심볼이 활성화되어 있어야 한다. 디버그 심볼은 cargo buildcargo run--release 플래그 없이 사용할 때 기본적으로 활성화된다.

리스트 9-2의 출력에서 백트레이스의 6번째 줄은 문제를 일으키는 프로젝트의 줄을 가리킨다: src/main.rs 파일의 4번째 줄이다. 프로그램이 패닉을 일으키지 않도록 하려면, 우리가 작성한 파일을 언급하는 첫 번째 줄이 가리키는 위치에서 조사를 시작해야 한다. 리스트 9-1에서 의도적으로 패닉을 일으키는 코드를 작성했으므로, 패닉을 수정하려면 벡터 인덱스 범위를 벗어난 위치에 접근하지 않아야 한다. 앞으로 코드에서 패닉이 발생하면, 어떤 동작을 어떤 값으로 수행했는지, 그리고 코드가 대신 무엇을 해야 하는지 파악해야 한다.

panic!과 이를 사용해야 하는 경우와 사용하지 말아야 하는 경우에 대해서는 이 장의 panic!을 사용할 것인가, 사용하지 말 것인가” 섹션에서 다시 다룬다. 다음으로는 Result를 사용해 오류를 복구하는 방법을 살펴본다.

Result를 사용한 복구 가능한 에러 처리

대부분의 에러는 프로그램 전체를 중단시킬 정도로 심각하지 않다. 때로는 함수가 실패하더라도 그 이유를 쉽게 파악하고 대응할 수 있다. 예를 들어, 파일을 열려고 시도했는데 파일이 존재하지 않아 실패한 경우, 프로그램을 종료하는 대신 파일을 생성하는 방식으로 처리할 수 있다.

2장의 “Result를 사용한 실패 처리”에서 살펴본 것처럼, Result 열거형은 OkErr 두 가지 변형을 가진다.

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

여기서 TE는 제네릭 타입 매개변수다. 제네릭에 대해서는 10장에서 자세히 다룬다. 지금 알아둘 점은 TOk 변형에서 반환될 성공 값의 타입을 나타내고, EErr 변형에서 반환될 에러 값의 타입을 나타낸다는 것이다. Result가 이러한 제네릭 타입 매개변수를 가지기 때문에, 우리가 반환하고자 하는 성공 값과 에러 값이 다양한 상황에서도 Result 타입과 그에 정의된 함수들을 사용할 수 있다.

이제 실패할 가능성이 있는 함수를 호출해 보자. 예제 9-3에서는 파일을 열려고 시도한다.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}
Listing 9-3: 파일 열기

File::open의 반환 타입은 Result<T, E>다. 제네릭 매개변수 TFile::open 구현에서 성공 값의 타입인 std::fs::File(파일 핸들)로 채워진다. 에러 값에 사용되는 E의 타입은 std::io::Error다. 이 반환 타입은 File::open 호출이 성공하여 읽거나 쓸 수 있는 파일 핸들을 반환할 수도 있고, 실패할 수도 있음을 의미한다. 예를 들어 파일이 존재하지 않거나, 파일에 접근할 권한이 없을 수 있다. File::open 함수는 성공 여부를 알려주고, 동시에 파일 핸들이나 에러 정보를 제공할 방법이 필요하다. 이 정보는 바로 Result 열거형이 전달하는 것이다.

File::open이 성공하면, greeting_file_result 변수의 값은 파일 핸들을 포함하는 Ok 인스턴스가 된다. 실패할 경우, greeting_file_result 변수의 값은 발생한 에러의 종류에 대한 정보를 포함하는 Err 인스턴스가 된다.

예제 9-3의 코드에 File::open이 반환하는 값에 따라 다른 동작을 추가해야 한다. 예제 9-4는 6장에서 다룬 기본 도구인 match 표현식을 사용해 Result를 처리하는 한 가지 방법을 보여준다.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {error:?}"),
    };
}
Listing 9-4: match 표현식을 사용해 반환될 수 있는 Result 변형 처리

Option 열거형과 마찬가지로, Result 열거형과 그 변형들은 프렐루드에 의해 스코프로 가져와지기 때문에, match 갈래에서 OkErr 앞에 Result::를 명시할 필요가 없다.

결과가 Ok일 때, 이 코드는 Ok 변형에서 내부의 file 값을 반환하고, 그 파일 핸들 값을 greeting_file 변수에 할당한다. match 이후에는 파일 핸들을 읽거나 쓰는 데 사용할 수 있다.

match의 다른 갈래는 File::open에서 Err 값을 얻은 경우를 처리한다. 이 예제에서는 panic! 매크로를 호출하기로 선택했다. 현재 디렉터리에 hello.txt 파일이 없고 이 코드를 실행하면, panic! 매크로로부터 다음과 같은 출력을 볼 수 있다:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/error-handling`

thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

이 출력은 무엇이 잘못되었는지 정확히 알려준다.

다양한 에러에 대한 처리

리스트 9-4의 코드는 File::open이 실패한 이유와 상관없이 panic!을 일으킨다. 하지만 우리는 실패 이유에 따라 다른 동작을 수행하고 싶다. 만약 File::open이 파일이 존재하지 않아서 실패했다면, 우리는 새 파일을 생성하고 그 파일에 대한 핸들을 반환하려고 한다. 반면, 파일을 열 권한이 없어서 실패한 경우와 같은 다른 이유로 실패했다면, 여전히 리스트 9-4와 동일하게 panic!을 일으키고 싶다. 이를 위해 리스트 9-5와 같이 내부 match 표현식을 추가한다.

Filename: src/main.rs
use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file_result = File::open("hello.txt");

    let greeting_file = match greeting_file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {e:?}"),
            },
            _ => {
                panic!("Problem opening the file: {error:?}");
            }
        },
    };
}
Listing 9-5: 다양한 종류의 에러를 다르게 처리하기

File::open이 반환하는 Err 변수 내부의 값은 표준 라이브러리에서 제공하는 io::Error 구조체이다. 이 구조체에는 io::ErrorKind 값을 얻기 위해 호출할 수 있는 kind 메서드가 있다. io::ErrorKind 열거형은 표준 라이브러리에서 제공되며, io 연산으로 인해 발생할 수 있는 다양한 종류의 에러를 나타내는 변수들을 포함한다. 우리가 사용하려는 변수는 ErrorKind::NotFound로, 열려고 시도한 파일이 아직 존재하지 않음을 나타낸다. 따라서 greeting_file_result에 대해 매칭을 수행하지만, 내부적으로 error.kind()에 대해서도 매칭을 수행한다.

내부 match에서 확인하려는 조건은 error.kind()가 반환한 값이 ErrorKind 열거형의 NotFound 변수인지 여부이다. 만약 그렇다면, File::create를 사용해 파일을 생성하려고 시도한다. 하지만 File::create도 실패할 수 있으므로, 내부 match 표현식에 두 번째 분기를 추가해야 한다. 파일을 생성할 수 없을 때는 다른 에러 메시지를 출력한다. 외부 match의 두 번째 분기는 그대로 유지되므로, 파일이 없어서 발생한 에러 외의 다른 에러가 발생하면 프로그램은 panic!을 일으킨다.

Result<T, E>match 사용의 대안

match가 너무 많다! match 표현식은 매우 유용하지만 동시에 매우 기본적인 도구이다. 13장에서는 Result<T, E>에 정의된 여러 메서드와 함께 사용되는 클로저에 대해 배울 것이다. 이러한 메서드는 코드에서 Result<T, E> 값을 처리할 때 match를 사용하는 것보다 더 간결할 수 있다.

예를 들어, 리스트 9-5와 동일한 로직을 클로저와 unwrap_or_else 메서드를 사용해 작성한 다른 방법은 다음과 같다:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap_or_else(|error| {
        if error.kind() == ErrorKind::NotFound {
            File::create("hello.txt").unwrap_or_else(|error| {
                panic!("Problem creating the file: {error:?}");
            })
        } else {
            panic!("Problem opening the file: {error:?}");
        }
    });
}

이 코드는 리스트 9-5와 동일한 동작을 하지만, match 표현식이 전혀 포함되어 있지 않으며 더 깔끔하게 읽을 수 있다. 13장을 읽은 후 이 예제로 돌아와서, 표준 라이브러리 문서에서 unwrap_or_else 메서드를 찾아보라. 에러를 처리할 때 이러한 메서드들을 사용하면 복잡하게 중첩된 match 표현식을 깔끔하게 정리할 수 있다.

에러 발생 시 패닉을 유발하는 단축키: unwrapexpect

match를 사용하는 방법도 유효하지만, 다소 장황할 수 있고 의도를 명확히 전달하지 못할 때가 있다. Result<T, E> 타입에는 다양한 작업을 수행하기 위한 여러 헬퍼 메서드가 정의되어 있다. unwrap 메서드는 우리가 Listing 9-4에서 작성한 match 표현식과 동일하게 구현된 단축 메서드다. Result 값이 Ok 변형이라면, unwrapOk 내부의 값을 반환한다. ResultErr 변형이라면, unwrappanic! 매크로를 호출한다. 다음은 unwrap을 사용한 예제다:

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt").unwrap();
}

만약 hello.txt 파일이 없는 상태에서 이 코드를 실행하면, unwrap 메서드가 호출한 panic!로 인해 다음과 같은 에러 메시지가 출력된다:

thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }

비슷하게, expect 메서드는 panic! 에러 메시지를 직접 선택할 수 있게 해준다. unwrap 대신 expect를 사용하고 적절한 에러 메시지를 제공하면 의도를 더 명확히 전달할 수 있고, 패닉의 원인을 추적하는 데도 도움이 된다. expect의 문법은 다음과 같다:

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

expectunwrap과 같은 방식으로 사용된다: 파일 핸들을 반환하거나 panic! 매크로를 호출한다. expectpanic!을 호출할 때 사용하는 에러 메시지는 우리가 expect에 전달한 매개변수이며, unwrap이 사용하는 기본 panic! 메시지와는 다르다. 실행 결과는 다음과 같다:

thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }

프로덕션 수준의 코드에서는 대부분의 Rust 개발자들이 unwrap보다 expect를 선택하고, 해당 작업이 항상 성공할 것으로 예상되는 이유에 대한 더 많은 컨텍스트를 제공한다. 이렇게 하면 가정이 틀렸을 때 디버깅에 활용할 수 있는 정보가 더 많아진다.

에러 전파

함수 내부에서 실패할 가능성이 있는 작업을 호출할 때, 해당 에러를 함수 내부에서 처리하는 대신 호출한 코드로 반환할 수 있다. 이를 **에러 전파**라고 하며, 호출한 코드가 더 많은 정보를 가지고 있거나 에러 처리에 대한 로직을 결정할 수 있는 경우에 유용하다.

예를 들어, 아래 예제는 파일에서 사용자 이름을 읽는 함수를 보여준다. 파일이 존재하지 않거나 읽을 수 없는 경우, 이 함수는 해당 에러를 호출한 코드로 반환한다.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let username_file_result = File::open("hello.txt");

    let mut username_file = match username_file_result {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut username = String::new();

    match username_file.read_to_string(&mut username) {
        Ok(_) => Ok(username),
        Err(e) => Err(e),
    }
}
}
Listing 9-6: match를 사용해 에러를 호출한 코드로 반환하는 함수

이 함수는 더 짧게 작성할 수 있지만, 에러 처리를 자세히 이해하기 위해 일부러 수동으로 작성했다. 마지막에는 더 짧은 방법을 보여줄 것이다. 먼저 함수의 반환 타입을 살펴보자: Result<String, io::Error>. 이는 함수가 Result<T, E> 타입의 값을 반환한다는 의미이며, 여기서 제네릭 타입 TString으로, Eio::Error로 구체화되었다.

만약 이 함수가 문제 없이 성공하면, 호출한 코드는 String 타입의 username을 담은 Ok 값을 받는다. 반면 함수가 문제를 겪으면, 호출한 코드는 io::Error 인스턴스를 담은 Err 값을 받는다. 이 함수의 반환 타입으로 io::Error를 선택한 이유는, 함수 내부에서 호출하는 두 작업(File::open 함수와 read_to_string 메서드) 모두 실패 시 io::Error 타입의 에러 값을 반환하기 때문이다.

함수의 본문은 File::open 함수를 호출하는 것으로 시작한다. 그리고 match를 사용해 Result 값을 처리한다. File::open이 성공하면, 패턴 변수 file의 파일 핸들이 username_file 변수의 값이 되고 함수는 계속 실행된다. Err 경우에는 panic!을 호출하는 대신, return 키워드를 사용해 함수를 조기에 종료하고 File::open에서 반환된 에러 값을 호출한 코드로 전달한다.

username_file에 파일 핸들이 있다면, 함수는 username 변수에 새로운 String을 생성하고 username_file의 파일 핸들에서 read_to_string 메서드를 호출해 파일 내용을 username에 읽어온다. read_to_string 메서드도 실패할 수 있으므로 Result를 반환한다. 따라서 이 Result를 처리하기 위해 또 다른 match가 필요하다. read_to_string이 성공하면, 함수는 성공한 것으로 간주하고 username에 있는 파일 내용을 Ok로 감싸 반환한다. read_to_string이 실패하면, File::open의 반환 값을 처리한 match와 동일한 방식으로 에러 값을 반환한다. 단, return을 명시적으로 쓸 필요는 없는데, 이는 함수의 마지막 표현식이기 때문이다.

이 함수를 호출한 코드는 username을 담은 Ok 값이나 io::Error를 담은 Err 값을 받게 된다. 호출한 코드는 이 값을 어떻게 처리할지 결정할 수 있다. 예를 들어, Err 값을 받으면 panic!을 호출해 프로그램을 종료하거나, 기본 사용자 이름을 사용하거나, 파일 이외의 다른 곳에서 사용자 이름을 조회할 수 있다. 호출한 코드가 실제로 무엇을 하려는지 충분한 정보가 없으므로, 모든 성공 또는 에러 정보를 위로 전파해 적절히 처리하도록 한다.

이러한 에러 전파 패턴은 Rust에서 매우 흔하기 때문에, Rust는 이를 더 쉽게 만들기 위해 물음표 연산자 ?를 제공한다.

오류 전파를 위한 단축키: ? 연산자

리스트 9-7은 read_username_from_file 함수를 구현한 코드로, 리스트 9-6과 동일한 기능을 수행하지만 이번에는 ? 연산자를 사용한다.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username_file = File::open("hello.txt")?;
    let mut username = String::new();
    username_file.read_to_string(&mut username)?;
    Ok(username)
}
}
Listing 9-7: ? 연산자를 사용해 오류를 호출 코드로 반환하는 함수

Result 값 뒤에 위치한 ?는 리스트 9-6에서 Result 값을 처리하기 위해 정의한 match 표현식과 거의 동일하게 동작한다. Result 값이 Ok라면, Ok 내부의 값이 반환되고 프로그램은 계속 실행된다. 만약 값이 Err라면, Errreturn 키워드를 사용한 것처럼 전체 함수에서 반환되며, 오류 값이 호출 코드로 전파된다.

리스트 9-6의 match 표현식과 ? 연산자의 차이점이 있다. ? 연산자가 호출된 오류 값은 표준 라이브러리의 From 트레이트에 정의된 from 함수를 거친다. 이 함수는 한 타입의 값을 다른 타입으로 변환하는 데 사용된다. ? 연산자가 from 함수를 호출하면, 받은 오류 타입은 현재 함수의 반환 타입으로 정의된 오류 타입으로 변환된다. 이는 함수가 여러 가지 이유로 실패할 수 있는 경우에도, 하나의 오류 타입을 반환해 모든 실패 사례를 표현할 때 유용하다.

예를 들어, 리스트 9-7의 read_username_from_file 함수를 수정해 OurError라는 커스텀 오류 타입을 반환하도록 할 수 있다. 만약 io::Error에서 OurError 인스턴스를 생성하기 위해 impl From<io::Error> for OurError를 정의한다면, read_username_from_file 함수 본문의 ? 연산자 호출은 from을 호출해 오류 타입을 변환한다. 이때 함수에 추가 코드를 작성할 필요는 없다.

리스트 9-7의 맥락에서, File::open 호출 끝에 있는 ?Ok 내부의 값을 username_file 변수에 반환한다. 만약 오류가 발생하면, ? 연산자는 전체 함수에서 조기에 반환되며, Err 값을 호출 코드에 전달한다. read_to_string 호출 끝에 있는 ?도 동일하게 동작한다.

? 연산자는 많은 보일러플레이트 코드를 제거하고 함수 구현을 더 간단하게 만든다. 메서드 호출을 ? 뒤에 바로 연결해 코드를 더 짧게 만들 수도 있다. 리스트 9-8에서 이를 확인할 수 있다.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();

    File::open("hello.txt")?.read_to_string(&mut username)?;

    Ok(username)
}
}
Listing 9-8: ? 연산자 뒤에 메서드 호출 연결하기

username에 새로운 String을 생성하는 부분을 함수 시작 부분으로 옮겼다. 이 부분은 변경되지 않았다. username_file 변수를 생성하는 대신, read_to_string 호출을 File::open("hello.txt")?의 결과에 바로 연결했다. read_to_string 호출 끝에는 여전히 ?가 있으며, File::openread_to_string이 모두 성공하면 username을 포함한 Ok 값을 반환한다. 이 구현은 리스트 9-6과 리스트 9-7과 동일한 기능을 제공하지만, 더 간결하고 편리한 방식으로 작성되었다.

리스트 9-9는 fs::read_to_string을 사용해 코드를 더 짧게 만드는 방법을 보여준다.

Filename: src/main.rs
#![allow(unused)]
fn main() {
use std::fs;
use std::io;

fn read_username_from_file() -> Result<String, io::Error> {
    fs::read_to_string("hello.txt")
}
}
Listing 9-9: 파일을 열고 읽는 대신 fs::read_to_string 사용하기

파일을 문자열로 읽어오는 작업은 꽤 일반적이기 때문에, 표준 라이브러리는 파일을 열고, 새로운 String을 생성하고, 파일 내용을 읽어 그 내용을 String에 넣어 반환하는 편리한 fs::read_to_string 함수를 제공한다. 물론, fs::read_to_string을 사용하면 모든 오류 처리에 대해 설명할 기회를 제공하지 않기 때문에, 먼저 더 긴 방식으로 설명했다.

? 연산자를 사용할 수 있는 위치

? 연산자는 반환 타입이 ?가 사용된 값과 호환되는 함수에서만 사용할 수 있다. 이는 ? 연산자가 함수에서 값을 조기에 반환하도록 정의되어 있기 때문이다. 이 동작은 리스트 9-6에서 정의한 match 표현식과 유사하다. 리스트 9-6에서 matchResult 값을 사용했고, 조기 반환 분기에서는 Err(e) 값을 반환했다. 함수의 반환 타입은 이 return과 호환되도록 Result여야 한다.

리스트 9-10에서는 반환 타입이 ?를 사용한 값의 타입과 호환되지 않는 main 함수에서 ? 연산자를 사용할 때 발생하는 오류를 살펴본다.

Filename: src/main.rs
use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")?;
}
Listing 9-10: ()를 반환하는 main 함수에서 ?를 사용하려고 하면 컴파일되지 않는다.

이 코드는 파일을 열려고 시도하며, 이 작업은 실패할 수 있다. ? 연산자는 File::open이 반환한 Result 값을 따라가지만, 이 main 함수의 반환 타입은 Result가 아닌 ()이다. 이 코드를 컴파일하면 다음과 같은 오류 메시지가 나타난다:

$ cargo run
   Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> src/main.rs:4:48
  |
3 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
4 |     let greeting_file = File::open("hello.txt")?;
  |                                                ^ cannot use the `?` operator in a function that returns `()`
  |
  = help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
help: consider adding return type
  |
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 |     let greeting_file = File::open("hello.txt")?;
5 +     Ok(())
  |

For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error

이 오류는 ? 연산자를 Result, Option, 또는 FromResidual을 구현한 타입을 반환하는 함수에서만 사용할 수 있음을 지적한다.

이 오류를 해결하려면 두 가지 선택지가 있다. 첫 번째는 함수의 반환 타입을 ? 연산자를 사용한 값과 호환되도록 변경하는 것이다. 두 번째는 match 또는 Result<T, E> 메서드 중 하나를 사용해 Result<T, E>를 적절히 처리하는 것이다.

오류 메시지는 ?Option<T> 값과도 사용될 수 있음을 언급한다. Result에서 ?를 사용하는 것과 마찬가지로, Option에서 ?를 사용하려면 함수가 Option을 반환해야 한다. Option<T>에서 ? 연산자를 호출할 때의 동작은 Result<T, E>에서 호출할 때와 유사하다. 값이 None이면 None이 함수에서 조기 반환된다. 값이 Some이면 Some 내부의 값이 표현식의 결과 값이 되고, 함수는 계속 실행된다. 리스트 9-11은 주어진 텍스트의 첫 번째 줄의 마지막 문자를 찾는 함수의 예제를 보여준다.

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

fn main() {
    assert_eq!(
        last_char_of_first_line("Hello, world\nHow are you today?"),
        Some('d')
    );

    assert_eq!(last_char_of_first_line(""), None);
    assert_eq!(last_char_of_first_line("\nhi"), None);
}
Listing 9-11: Option<T> 값에서 ? 연산자 사용

이 함수는 Option<char>를 반환한다. 문자가 있을 수도 있지만, 없을 수도 있기 때문이다. 이 코드는 text 문자열 슬라이스를 인자로 받아 lines 메서드를 호출한다. 이 메서드는 문자열의 줄에 대한 반복자를 반환한다. 이 함수는 첫 번째 줄을 검사하려고 하므로, 반복자에서 첫 번째 값을 가져오기 위해 next를 호출한다. text가 빈 문자열이면 nextNone을 반환하며, 이 경우 ?를 사용해 last_char_of_first_line에서 None을 반환한다. text가 빈 문자열이 아니면 nexttext의 첫 번째 줄에 대한 문자열 슬라이스를 포함한 Some 값을 반환한다.

?는 문자열 슬라이스를 추출하고, 이 문자열 슬라이스에 대해 chars를 호출해 문자에 대한 반복자를 얻는다. 첫 번째 줄의 마지막 문자에 관심이 있으므로, last를 호출해 반복자의 마지막 항목을 반환한다. 이는 Option인데, 첫 번째 줄이 빈 문자열일 수도 있기 때문이다. 예를 들어, text가 빈 줄로 시작하지만 다른 줄에 문자가 있는 경우("\nhi"와 같이)가 있다. 그러나 첫 번째 줄에 마지막 문자가 있으면 Some 변형으로 반환된다. 중간에 있는 ? 연산자는 이 로직을 간결하게 표현할 수 있게 해주며, 함수를 한 줄로 구현할 수 있게 한다. Option에서 ? 연산자를 사용할 수 없다면, 더 많은 메서드 호출이나 match 표현식을 사용해 이 로직을 구현해야 할 것이다.

Result를 반환하는 함수에서는 Result에 대해 ? 연산자를 사용할 수 있고, Option을 반환하는 함수에서는 Option에 대해 ? 연산자를 사용할 수 있지만, 둘을 혼합해서 사용할 수는 없다. ? 연산자는 ResultOption으로 자동 변환하거나 그 반대로 변환하지 않는다. 이러한 경우에는 Resultok 메서드나 Optionok_or 메서드를 사용해 명시적으로 변환할 수 있다.

지금까지 사용한 모든 main 함수는 ()를 반환했다. main 함수는 실행 파일의 진입점이자 종료점이므로, 프로그램이 예상대로 동작하도록 반환 타입에 제한이 있다.

다행히, main 함수는 Result<(), E>를 반환할 수도 있다. 리스트 9-12는 리스트 9-10의 코드를 보여주지만, main의 반환 타입을 Result<(), Box<dyn Error>>로 변경하고 마지막에 Ok(()) 반환 값을 추가했다. 이제 이 코드는 컴파일된다.

Filename: src/main.rs
use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let greeting_file = File::open("hello.txt")?;

    Ok(())
}
Listing 9-12: mainResult<(), E>를 반환하도록 변경하면 Result 값에서 ? 연산자를 사용할 수 있다.

Box<dyn Error> 타입은 _트레이트 객체_로, 18장의 “다양한 타입의 값을 허용하는 트레이트 객체 사용하기”에서 설명할 것이다. 지금은 Box<dyn Error>를 “어떤 종류의 오류“로 이해하면 된다. main 함수에서 오류 타입이 Box<dyn Error>Result 값에 ?를 사용할 수 있는 이유는, 이 타입이 모든 Err 값을 조기에 반환할 수 있기 때문이다. 이 main 함수의 본문이 std::io::Error 타입의 오류만 반환하더라도, Box<dyn Error>를 지정하면 main의 본문에 다른 오류를 반환하는 코드를 추가해도 이 시그니처는 계속 유효하다.

main 함수가 Result<(), E>를 반환하면, mainOk(())를 반환하면 실행 파일은 0으로 종료되고, Err 값을 반환하면 0이 아닌 값으로 종료된다. C로 작성된 실행 파일은 종료 시 정수를 반환한다. 성공적으로 종료된 프로그램은 정수 0을 반환하고, 오류가 발생한 프로그램은 0이 아닌 정수를 반환한다. Rust도 이 관례와 호환되도록 실행 파일에서 정수를 반환한다.

main 함수는 std::process::Termination 트레이트를 구현한 모든 타입을 반환할 수 있다. 이 트레이트는 ExitCode를 반환하는 report 함수를 포함한다. 자신만의 타입에 대해 Termination 트레이트를 구현하는 방법에 대한 자세한 내용은 표준 라이브러리 문서를 참조하라.

이제 panic!을 호출하거나 Result를 반환하는 방법에 대해 논의했으므로, 어떤 경우에 어떤 것을 사용할지 결정하는 방법으로 돌아가보자.

panic!을 사용할지 말지

panic!을 호출해야 할 때와 Result를 반환해야 할 때를 어떻게 결정할까? 코드가 패닉을 일으키면 복구할 방법이 없다. 어떤 에러 상황에서든 panic!을 호출할 수 있지만, 이는 호출하는 코드를 대신해 해당 상황이 복구 불가능하다고 판단하는 것이다. Result 값을 반환하면 호출하는 코드에 선택권을 준다. 호출하는 코드는 상황에 맞게 복구를 시도하거나, Err 값이 복구 불가능하다고 판단해 panic!을 호출해 복구 가능한 에러를 복구 불가능한 상태로 바꿀 수 있다. 따라서 실패할 가능성이 있는 함수를 정의할 때는 Result를 반환하는 것이 기본적으로 좋은 선택이다.

예제 코드, 프로토타입 코드, 테스트 코드와 같은 상황에서는 Result를 반환하는 대신 패닉을 일으키는 코드를 작성하는 것이 더 적절하다. 왜 그런지 알아보고, 컴파일러가 실패가 불가능하다는 것을 알 수 없지만 사람은 알 수 있는 상황에 대해 논의한다. 이 장의 마지막에는 라이브러리 코드에서 패닉을 일으킬지 말지 결정하는 일반적인 가이드라인을 제공한다.

예제, 프로토타입 코드, 그리고 테스트

어떤 개념을 설명하기 위해 예제를 작성할 때, 강력한 오류 처리 코드를 포함하면 예제의 명확성이 떨어질 수 있다. 예제에서는 unwrap과 같은 패닉을 일으킬 수 있는 메서드 호출이 실제 애플리케이션에서 오류를 처리하는 방식에 대한 자리 표시자로 사용된다는 점을 이해해야 한다. 이 방식은 코드의 나머지 부분이 하는 일에 따라 달라질 수 있다.

마찬가지로, 프로토타이핑 단계에서 오류 처리 방식을 결정하기 전에는 unwrapexpect 메서드가 매우 유용하다. 이 메서드들은 코드에 명확한 표시를 남겨두어, 프로그램을 더 견고하게 만들 준비가 되었을 때 쉽게 찾아낼 수 있게 해준다.

테스트 중에 메서드 호출이 실패하면, 해당 메서드가 테스트 대상 기능이 아니더라도 전체 테스트가 실패하길 원할 것이다. panic!은 테스트가 실패했음을 표시하는 방법이므로, unwrap이나 expect를 호출하는 것이 바로 그런 상황에서 필요한 일이다.

컴파일러보다 더 많은 정보를 알고 있는 경우

ResultOk 값을 가질 것이라는 것을 보장하는 다른 로직이 있지만, 그 로직을 컴파일러가 이해하지 못하는 경우에도 unwrap이나 expect를 호출하는 것이 적절하다. 여전히 Result 값을 처리해야 하지만, 특정 상황에서는 논리적으로 실패할 가능성이 없음에도 불구하고 일반적으로는 실패할 가능성이 있다. 코드를 직접 검토하여 Err 변형이 절대 발생하지 않을 것임을 보장할 수 있다면, unwrap을 호출하는 것이 완전히 허용된다. 더 나아가 expect 텍스트에 Err 변형이 발생하지 않을 이유를 문서화하는 것이 더 좋다. 다음은 예제이다:

fn main() {
    use std::net::IpAddr;

    let home: IpAddr = "127.0.0.1"
        .parse()
        .expect("Hardcoded IP address should be valid");
}

이 예제에서는 하드코딩된 문자열을 파싱하여 IpAddr 인스턴스를 생성한다. 127.0.0.1이 유효한 IP 주소임을 알 수 있으므로, 여기서 expect를 사용하는 것은 적절하다. 그러나 하드코딩된 유효한 문자열이 있다고 해도 parse 메서드의 반환 타입은 바뀌지 않는다. 여전히 Result 값을 반환하며, 컴파일러는 이 문자열이 항상 유효한 IP 주소임을 알아차리지 못하기 때문에 Err 변형이 가능한 것처럼 Result를 처리하도록 요구한다. 만약 IP 주소 문자열이 프로그램에 하드코딩된 것이 아니라 사용자로부터 입력된 것이라면 실패할 가능성이 있으므로, 더 견고한 방식으로 Result를 처리해야 한다. 이 IP 주소가 하드코딩되었다는 가정을 명시하면, 나중에 다른 소스에서 IP 주소를 가져와야 할 때 expect를 더 나은 오류 처리 코드로 변경하도록 상기시킬 수 있다.

에러 처리 가이드라인

코드가 잘못된 상태에 빠질 가능성이 있다면 패닉을 발생시키는 것이 좋다. 여기서 _잘못된 상태_란 어떤 가정, 보장, 계약, 또는 불변 조건이 깨진 상황을 말한다. 예를 들어 유효하지 않은 값, 모순된 값, 누락된 값이 코드에 전달되는 경우가 이에 해당한다. 또한 다음 조건 중 하나 이상이 충족될 때도 마찬가지다:

  • 잘못된 상태가 예상치 못한 상황일 때. 예를 들어 사용자가 잘못된 형식의 데이터를 입력하는 경우처럼 가끔 발생할 수 있는 상황과는 다르다.
  • 코드가 이후에 이 잘못된 상태에 있지 않을 것이라고 가정하고 동작할 때. 즉, 매 단계마다 문제를 확인하지 않아도 된다고 가정할 때.
  • 사용 중인 타입으로 이 정보를 표현할 적절한 방법이 없을 때. 이에 대한 예시는 18장의 “타입으로 상태와 동작 표현하기”에서 다룬다.

누군가가 코드를 호출할 때 의미 없는 값을 전달한다면, 가능한 경우 에러를 반환하는 것이 가장 좋다. 이렇게 하면 라이브러리 사용자가 해당 상황에서 어떻게 처리할지 결정할 수 있다. 그러나 계속 진행하는 것이 보안상 위험하거나 해로울 수 있는 경우, panic!을 호출해 라이브러리 사용자에게 코드의 버그를 알리고 개발 중에 수정할 수 있도록 하는 것이 최선의 선택일 수 있다. 마찬가지로, 외부 코드를 호출했는데 그 코드가 유효하지 않은 상태를 반환하고 이를 수정할 방법이 없는 경우에도 panic!을 호출하는 것이 적절하다.

그러나 실패가 예상되는 상황에서는 panic!을 호출하는 대신 Result를 반환하는 것이 더 적절하다. 예를 들어 파서가 잘못된 형식의 데이터를 받거나, HTTP 요청이 속도 제한에 도달했다는 상태 코드를 반환하는 경우가 이에 해당한다. 이러한 경우 Result를 반환하면 실패가 예상 가능한 상황이며, 호출하는 코드가 이를 어떻게 처리할지 결정해야 함을 나타낸다.

코드가 유효하지 않은 값을 사용해 호출될 경우 사용자에게 위험을 초래할 수 있는 작업을 수행한다면, 코드는 먼저 값이 유효한지 확인하고 유효하지 않으면 패닉을 발생시켜야 한다. 이는 주로 보안상의 이유에서다: 유효하지 않은 데이터를 처리하려고 시도하면 코드가 취약점에 노출될 수 있다. 표준 라이브러리가 메모리 접근이 범위를 벗어났을 때 panic!을 호출하는 주된 이유도 이 때문이다. 현재 데이터 구조에 속하지 않은 메모리에 접근하려고 시도하는 것은 흔한 보안 문제다. 함수는 종종 _계약_을 가진다: 입력이 특정 요구 사항을 충족할 때만 그 동작이 보장된다. 계약이 위반되었을 때 패닉을 발생시키는 것은 합리적이다. 왜냐하면 계약 위반은 항상 호출자 측의 버그를 나타내며, 호출 코드가 명시적으로 처리해야 할 종류의 에러가 아니기 때문이다. 사실, 호출 코드가 이를 복구할 합리적인 방법은 없다. 호출한 _프로그래머_가 코드를 수정해야 한다. 함수의 계약, 특히 위반 시 패닉이 발생하는 경우는 함수의 API 문서에 설명되어야 한다.

그러나 모든 함수에 많은 에러 검사를 추가하는 것은 번거롭고 지루할 수 있다. 다행히 Rust의 타입 시스템(그리고 컴파일러가 수행하는 타입 검사)을 활용해 많은 검사를 대신할 수 있다. 함수가 특정 타입을 매개변수로 받는다면, 컴파일러가 이미 유효한 값임을 보장했기 때문에 코드의 로직을 안전하게 진행할 수 있다. 예를 들어 Option이 아닌 특정 타입을 사용한다면, 프로그램은 _아무것도 없음_이 아니라 _무언가_를 기대한다. 따라서 코드는 SomeNone 두 가지 경우를 처리할 필요 없이, 값이 확실히 있다는 한 가지 경우만 처리하면 된다. 아무것도 전달하지 않으려는 코드는 컴파일조차 되지 않으므로, 런타임에 해당 경우를 확인할 필요가 없다. 또 다른 예로 u32와 같은 부호 없는 정수 타입을 사용하면 매개변수가 절대 음수가 아님을 보장할 수 있다.

유효성 검사를 위한 커스텀 타입 만들기

Rust의 타입 시스템을 활용해 유효한 값을 보장하는 아이디어를 한 단계 더 발전시켜 유효성 검사를 위한 커스텀 타입을 만들어 보자. 2장에서 다룬 숫자 맞추기 게임을 떠올려보자. 코드는 사용자에게 1부터 100 사이의 숫자를 맞춰보라고 요청했다. 그러나 비밀 숫자와 비교하기 전에 사용자의 추측이 해당 범위 내에 있는지 검증하지 않았고, 단순히 양수인지 여부만 확인했다. 이 경우 결과가 크게 심각하지는 않았다. “너무 높음” 또는 “너무 낮음“이라는 출력은 여전히 정확했다. 하지만 사용자가 범위를 벗어난 숫자를 추측했을 때와 문자를 입력했을 때의 동작을 다르게 처리하는 것은 유용한 개선 사항이 될 것이다.

이를 구현하는 한 가지 방법은 추측값을 u32가 아닌 i32로 파싱해 음수도 허용한 다음, 숫자가 범위 내에 있는지 확인하는 것이다. 그런 다음 다음과 같이 범위 검사를 추가할 수 있다:

Filename: src/main.rs
use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        // --snip--

        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        if guess < 1 || guess > 100 {
            println!("The secret number will be between 1 and 100.");
            continue;
        }

        match guess.cmp(&secret_number) {
            // --snip--
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

if 표현식은 값이 범위를 벗어났는지 확인하고, 문제를 사용자에게 알린 다음, continue를 호출해 루프의 다음 반복을 시작하고 다시 추측값을 요청한다. if 표현식 이후에는 guess가 1과 100 사이임을 알 수 있으므로 guess와 비밀 숫자를 비교할 수 있다.

그러나 이 방법은 이상적인 해결책이 아니다. 프로그램이 반드시 1과 100 사이의 값만을 처리해야 하고, 이 요구 사항을 가진 함수가 많다면, 모든 함수에 이런 검사를 추가하는 것은 번거롭고 성능에도 영향을 미칠 수 있다.

대신, 전용 모듈에 새로운 타입을 만들고 유효성 검사를 타입 인스턴스를 생성하는 함수에 넣어서 검사를 반복하지 않도록 할 수 있다. 이렇게 하면 함수가 새로운 타입을 시그니처에서 사용하고 받은 값을 안전하게 사용할 수 있다. 리스팅 9-13은 Guess 타입을 정의하는 한 가지 방법을 보여준다. 이 타입은 new 함수가 1과 100 사이의 값을 받았을 때만 Guess 인스턴스를 생성한다.

Filename: src/guessing_game.rs
#![allow(unused)]
fn main() {
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
}
Listing 9-13: 1과 100 사이의 값만 허용하는 Guess 타입

먼저 guessing_game이라는 새로운 모듈을 만든다. 그런 다음 해당 모듈에 Guess라는 구조체를 정의하고, i32 타입의 value 필드를 갖도록 한다. 이 필드에 숫자가 저장된다.

그 다음 Guessnew라는 연관 함수를 구현해 Guess 인스턴스를 생성한다. new 함수는 i32 타입의 value라는 하나의 매개변수를 받고 Guess를 반환하도록 정의된다. new 함수의 본문은 value가 1과 100 사이인지 테스트한다. 만약 value가 이 테스트를 통과하지 못하면 panic!을 호출해 호출 코드를 작성하는 프로그래머에게 버그가 있음을 알린다. 이 범위를 벗어난 valueGuess를 생성하면 Guess::new가 의존하는 계약을 위반하게 된다. Guess::new가 패닉을 일으킬 수 있는 조건은 공개 API 문서에 논의되어야 한다. 14장에서 API 문서에 패닉 가능성을 표시하는 문서화 규칙을 다룰 것이다. value가 테스트를 통과하면 value 필드를 value 매개변수로 설정한 새로운 Guess를 생성하고 반환한다.

그 다음, self를 빌리고 다른 매개변수가 없으며 i32를 반환하는 value라는 메서드를 구현한다. 이런 종류의 메서드는 필드에서 데이터를 가져와 반환하기 때문에 _getter_라고도 한다. 이 공개 메서드는 Guess 구조체의 value 필드가 비공개이기 때문에 필요하다. value 필드를 비공개로 유지하는 것은 Guess 구조체를 사용하는 코드가 value를 직접 설정할 수 없도록 하기 위함이다. guessing_game 모듈 외부의 코드는 반드시 Guess::new 함수를 사용해 Guess 인스턴스를 생성해야 하므로, Guess::new 함수의 조건으로 검사되지 않은 value를 가진 Guess가 만들어질 수 없다.

1과 100 사이의 숫자를 매개변수로 받거나 반환하는 함수는 시그니처에서 i32 대신 Guess를 받거나 반환한다고 선언할 수 있고, 본문에서 추가 검사를 할 필요가 없다.

요약

Rust의 에러 처리 기능은 더 견고한 코드를 작성하는 데 도움을 준다. panic! 매크로는 프로그램이 처리할 수 없는 상태에 있음을 알리고, 잘못되거나 유효하지 않은 값으로 진행하려는 대신 프로세스를 중단하도록 한다. Result 열거형은 Rust의 타입 시스템을 활용해 작업이 실패할 가능성이 있음을 나타내며, 코드가 이를 복구할 수 있게 한다. Result를 사용하면 호출하는 코드가 성공 또는 실패를 처리해야 함을 알릴 수 있다. 적절한 상황에서 panic!Result를 사용하면 불가피한 문제에 직면했을 때 코드가 더 안정적으로 동작한다.

이제 표준 라이브러리가 OptionResult 열거형을 제네릭과 함께 활용하는 유용한 방법을 살펴봤으니, 제네릭이 어떻게 동작하는지와 이를 코드에서 어떻게 사용할 수 있는지 알아보자.

제네릭 타입, 트레이트, 그리고 라이프타임

모든 프로그래밍 언어는 개념의 중복을 효과적으로 처리하기 위한 도구를 제공한다. Rust에서는 _제네릭_이 그런 도구 중 하나다. 제네릭은 구체적인 타입이나 속성의 추상적인 대체물이다. 코드를 컴파일하고 실행할 때 어떤 값이 들어갈지 알지 못해도, 제네릭의 동작이나 다른 제네릭과의 관계를 표현할 수 있다.

함수는 i32String 같은 구체적인 타입 대신 제네릭 타입의 매개변수를 받을 수 있다. 이는 여러 구체적인 값에 대해 동일한 코드를 실행하기 위해 알려지지 않은 값을 매개변수로 받는 방식과 유사하다. 사실, 우리는 이미 제네릭을 사용해 왔다. 6장의 Option<T>, 8장의 Vec<T>HashMap<K, V>, 그리고 9장의 Result<T, E>가 그 예시다. 이 장에서는 제네릭을 사용해 자신만의 타입, 함수, 메서드를 정의하는 방법을 알아볼 것이다.

먼저, 코드 중복을 줄이기 위해 함수를 추출하는 방법을 복습한다. 그런 다음, 매개변수의 타입만 다른 두 함수에서 제네릭 함수를 만드는 동일한 기법을 사용한다. 또한 구조체와 열거형 정의에서 제네릭 타입을 사용하는 방법도 설명한다.

그 다음, _트레이트_를 사용해 동작을 제네릭 방식으로 정의하는 방법을 배운다. 트레이트와 제네릭 타입을 결합하면, 모든 타입이 아닌 특정 동작을 가진 타입만 허용하도록 제네릭 타입을 제한할 수 있다.

마지막으로, _라이프타임_에 대해 논의한다. 라이프타임은 참조가 서로 어떻게 관련되는지 컴파일러에게 알려주는 일종의 제네릭이다. 라이프타임을 통해 빌린 값에 대한 충분한 정보를 컴파일러에게 제공하면, 우리의 도움 없이도 더 많은 상황에서 참조가 유효할 수 있도록 보장할 수 있다.

중복 코드 제거를 위한 함수 추출

제네릭은 특정 타입을 여러 타입을 대표하는 플레이스홀더로 대체해 코드 중복을 제거할 수 있게 해준다. 제네릭 문법을 살펴보기 전에, 먼저 제네릭 타입을 사용하지 않고 중복 코드를 제거하는 방법을 알아보자. 특정 값을 여러 값을 대표하는 플레이스홀더로 대체하는 함수를 추출하는 방식이다. 그런 다음 동일한 기법을 적용해 제네릭 함수를 추출할 것이다. 함수로 추출할 수 있는 중복 코드를 어떻게 인식하는지 살펴보면, 제네릭을 사용할 수 있는 중복 코드도 자연스럽게 인식할 수 있게 될 것이다.

먼저 리스트에서 가장 큰 숫자를 찾는 짧은 프로그램부터 시작해보자. 아래는 리스트에서 가장 큰 숫자를 찾는 코드이다.

Filename: src/main.rs
fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
    assert_eq!(*largest, 100);
}
Listing 10-1: 숫자 리스트에서 가장 큰 숫자 찾기

number_list 변수에 정수 리스트를 저장하고, largest 변수에는 리스트의 첫 번째 숫자를 참조로 저장한다. 그런 다음 리스트의 모든 숫자를 순회하며, 현재 숫자가 largest에 저장된 숫자보다 크면 해당 변수의 참조를 교체한다. 현재 숫자가 지금까지 본 가장 큰 숫자보다 작거나 같으면 변수는 변경되지 않고 코드는 리스트의 다음 숫자로 이동한다. 리스트의 모든 숫자를 확인한 후, largest는 가장 큰 숫자를 참조하게 되며, 이 경우에는 100이 된다.

이제 두 개의 다른 숫자 리스트에서 가장 큰 숫자를 찾는 작업을 해야 한다. 이를 위해 Listing 10-1의 코드를 복제하고 프로그램의 두 곳에서 동일한 로직을 사용할 수 있다. 아래는 두 리스트에서 가장 큰 숫자를 찾는 코드이다.

Filename: src/main.rs
fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
}
Listing 10-2: 리스트에서 가장 큰 숫자를 찾는 코드

이 코드는 동작하지만, 코드를 복제하는 것은 번거롭고 오류가 발생하기 쉽다. 또한 코드를 변경할 때 여러 곳에서 동시에 업데이트해야 한다는 점도 잊지 말아야 한다.

이 중복을 제거하기 위해, 정수 리스트를 매개변수로 받아 처리하는 함수를 정의해 추상화를 만들어보자. 이 방법은 코드를 더 명확하게 만들고, 리스트에서 가장 큰 숫자를 찾는 개념을 추상적으로 표현할 수 있게 해준다.

Listing 10-3에서는 가장 큰 숫자를 찾는 코드를 largest라는 함수로 추출한다. 그런 다음 Listing 10-2의 두 리스트에서 가장 큰 숫자를 찾기 위해 이 함수를 호출한다. 이 함수는 앞으로 사용할 수 있는 다른 i32 값 리스트에도 사용할 수 있다.

Filename: src/main.rs
fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 6000);
}
Listing 10-3: 두 리스트에서 가장 큰 숫자를 찾기 위한 추상화된 코드

largest 함수는 list라는 매개변수를 가지며, 이는 함수에 전달할 수 있는 i32 값의 구체적인 슬라이스를 나타낸다. 결과적으로 함수를 호출할 때, 코드는 전달한 특정 값에 대해 실행된다.

요약하면, Listing 10-2의 코드를 Listing 10-3으로 변경하기 위해 다음과 같은 단계를 거쳤다:

  1. 중복 코드를 식별한다.
  2. 중복 코드를 함수 본문으로 추출하고, 함수 시그니처에서 해당 코드의 입력과 반환 값을 명시한다.
  3. 중복 코드의 두 인스턴스를 함수 호출로 업데이트한다.

다음으로, 이와 동일한 단계를 제네릭에 적용해 코드 중복을 줄여보자. 함수 본문이 특정 값 대신 추상적인 list에서 동작할 수 있는 것처럼, 제네릭은 코드가 추상적인 타입에서 동작할 수 있게 해준다.

예를 들어, i32 값 슬라이스에서 가장 큰 항목을 찾는 함수와 char 값 슬라이스에서 가장 큰 항목을 찾는 함수가 있다고 가정해보자. 이 중복을 어떻게 제거할 수 있을까? 함께 알아보자!

제네릭 데이터 타입

제네릭을 사용하면 함수 시그니처나 구조체와 같은 항목에 대한 정의를 만들 수 있다. 이렇게 정의한 제네릭은 다양한 구체적인 데이터 타입과 함께 사용할 수 있다. 먼저 제네릭을 사용해 함수, 구조체, 열거형, 메서드를 정의하는 방법을 살펴보자. 그런 다음 제네릭이 코드 성능에 미치는 영향에 대해 논의한다.

함수 정의에서의 제네릭 사용

제네릭을 사용하는 함수를 정의할 때, 일반적으로 매개변수와 반환 값의 데이터 타입을 지정하는 위치에 제네릭을 넣는다. 이렇게 하면 코드가 더 유연해지고, 함수를 호출하는 측에 더 많은 기능을 제공할 수 있으며, 코드 중복을 방지할 수 있다.

앞서 살펴본 largest 함수를 계속해서 예로 들면, 리스트 10-4는 슬라이스에서 가장 큰 값을 찾는 두 함수를 보여준다. 이 두 함수를 하나로 합쳐 제네릭을 사용하는 단일 함수로 만들어 볼 것이다.

Filename: src/main.rs
fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");
    assert_eq!(*result, 100);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
    assert_eq!(*result, 'y');
}
Listing 10-4: 함수명과 시그니처의 타입만 다른 두 함수

largest_i32 함수는 리스트 10-3에서 추출한 것으로, 슬라이스에서 가장 큰 i32 값을 찾는다. largest_char 함수는 슬라이스에서 가장 큰 char 값을 찾는다. 두 함수의 본문은 동일한 코드를 가지고 있으므로, 제네릭 타입 매개변수를 도입해 중복을 제거할 수 있다.

새로운 단일 함수에서 타입을 매개변수화하려면, 함수의 값 매개변수와 마찬가지로 타입 매개변수의 이름을 지정해야 한다. 타입 매개변수 이름으로는 어떤 식별자든 사용할 수 있지만, 관례적으로 Rust에서는 타입 매개변수 이름을 짧게, 보통 한 글자로 짓고 CamelCase를 사용한다. _type_의 약자인 T는 대부분의 Rust 프로그래머가 기본적으로 선택하는 이름이다.

함수 본문에서 매개변수를 사용할 때, 컴파일러가 그 이름의 의미를 알 수 있도록 시그니처에서 매개변수 이름을 선언해야 한다. 마찬가지로, 함수 시그니처에서 타입 매개변수 이름을 사용할 때는 사용하기 전에 타입 매개변수 이름을 선언해야 한다. 제네릭 largest 함수를 정의하기 위해, 함수 이름과 매개변수 목록 사이에 꺾쇠 괄호 <> 안에 타입 이름 선언을 넣는다. 다음과 같이:

fn largest<T>(list: &[T]) -> &T {

이 정의를 읽으면: 함수 largest는 어떤 타입 T에 대해 제네릭이다. 이 함수는 list라는 하나의 매개변수를 가지며, 이 매개변수는 T 타입 값의 슬라이스이다. largest 함수는 동일한 타입 T의 값에 대한 참조를 반환한다.

리스트 10-5는 시그니처에서 제네릭 데이터 타입을 사용한 largest 함수 정의를 보여준다. 이 리스트는 또한 i32 값의 슬라이스나 char 값의 슬라이스로 함수를 호출하는 방법도 보여준다. 이 코드는 아직 컴파일되지 않는다는 점에 유의하라.

Filename: src/main.rs
fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}
Listing 10-5: 제네릭 타입 매개변수를 사용한 largest 함수; 아직 컴파일되지 않음

이 코드를 지금 바로 컴파일하면 다음과 같은 오류가 발생한다:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T` with trait `PartialOrd`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

도움말 텍스트는 std::cmp::PartialOrd를 언급하는데, 이는 _트레이트(trait)_이며, 다음 섹션에서 트레이트에 대해 설명할 것이다. 지금은 이 오류가 largest 함수의 본문이 T가 될 수 있는 모든 가능한 타입에 대해 작동하지 않는다는 것을 알려준다는 점만 기억하라. 본문에서 타입 T의 값을 비교하려면, 값이 순서를 가질 수 있는 타입만 사용할 수 있다. 비교를 가능하게 하기 위해, 표준 라이브러리는 std::cmp::PartialOrd 트레이트를 제공하며, 이 트레이트를 타입에 구현할 수 있다(이 트레이트에 대한 자세한 내용은 부록 C를 참조하라). 위의 예제 코드를 수정하려면, 도움말 텍스트의 제안을 따라 T에 유효한 타입을 PartialOrd를 구현하는 타입으로 제한해야 한다. 그러면 예제가 컴파일될 것이다. 왜냐하면 표준 라이브러리는 i32char 모두에 대해 PartialOrd를 구현하기 때문이다.

구조체 정의에서의 사용

구조체 정의에서도 <> 구문을 사용해 하나 이상의 필드에 제네릭 타입 파라미터를 적용할 수 있다. Listing 10-6은 Point<T> 구조체를 정의하여 어떤 타입이든 xy 좌표 값을 담을 수 있게 한다.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}
Listing 10-6: 타입 Txy 값을 담는 Point<T> 구조체

구조체 정의에서 제네릭을 사용하는 구문은 함수 정의에서 사용하는 것과 유사하다. 먼저 구조체 이름 바로 뒤에 꺾쇠괄호 안에 타입 파라미터의 이름을 선언한다. 그런 다음, 구조체 정의에서 구체적인 데이터 타입을 지정하는 대신 제네릭 타입을 사용한다.

Point<T>를 정의할 때 단 하나의 제네릭 타입만 사용했기 때문에, 이 정의는 Point<T> 구조체가 어떤 타입 T에 대해 제네릭이며, xy 필드가 모두 동일한 타입이라는 것을 의미한다. 만약 Listing 10-7과 같이 서로 다른 타입의 값을 가진 Point<T> 인스턴스를 생성하면 코드가 컴파일되지 않는다.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}
Listing 10-7: xy 필드는 동일한 제네릭 데이터 타입 T를 가지므로 같은 타입이어야 한다.

이 예제에서 x에 정수 값 5를 할당하면, 컴파일러는 이 Point<T> 인스턴스에서 제네릭 타입 T가 정수임을 알게 된다. 그런 다음 y4.0을 지정하면, x와 동일한 타입으로 정의된 y에 타입 불일치 오류가 발생한다:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

xy가 모두 제네릭이지만 서로 다른 타입을 가질 수 있는 Point 구조체를 정의하려면, 여러 개의 제네릭 타입 파라미터를 사용할 수 있다. 예를 들어, Listing 10-8에서는 Point의 정의를 변경하여 x가 타입 T이고 y가 타입 UPoint<T, U>로 만든다.

Filename: src/main.rs
struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}
Listing 10-8: xy가 서로 다른 타입의 값을 가질 수 있도록 두 타입에 대해 제네릭인 Point<T, U>

이제 표시된 모든 Point 인스턴스가 허용된다! 정의에서 원하는 만큼 제네릭 타입 파라미터를 사용할 수 있지만, 몇 개 이상 사용하면 코드를 읽기 어려워진다. 만약 코드에서 많은 제네릭 타입이 필요하다면, 코드를 더 작은 단위로 재구성해야 할 수도 있다는 신호일 수 있다.

열거형 정의에서의 제네릭 사용

구조체와 마찬가지로, 열거형도 제네릭 데이터 타입을 포함하도록 정의할 수 있다. 표준 라이브러리에서 제공하는 Option<T> 열거형을 다시 살펴보자. 이 열거형은 6장에서 사용한 바 있다:

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

이제 이 정의가 더 명확하게 이해될 것이다. Option<T> 열거형은 타입 T에 대해 제네릭으로 정의되며, 두 가지 변형을 가지고 있다: Some은 타입 T의 값을 하나 포함하고, None은 아무 값도 포함하지 않는다. Option<T> 열거형을 사용하면 선택적 값이라는 추상적인 개념을 표현할 수 있다. 그리고 Option<T>가 제네릭이기 때문에, 선택적 값의 타입이 무엇이든 이 추상화를 사용할 수 있다.

열거형은 여러 개의 제네릭 타입을 사용할 수도 있다. 9장에서 사용한 Result 열거형의 정의가 그 예시다:

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Result 열거형은 두 가지 타입 TE에 대해 제네릭으로 정의되며, 두 가지 변형을 가지고 있다: Ok는 타입 T의 값을 포함하고, Err는 타입 E의 값을 포함한다. 이 정의 덕분에 어떤 작업이 성공적으로 완료되어 타입 T의 값을 반환하거나, 실패하여 타입 E의 오류를 반환할 수 있는 상황에서 Result 열거형을 편리하게 사용할 수 있다. 실제로 이 열거형은 9장의 예제 9-3에서 파일을 열 때 사용되었다. 파일이 성공적으로 열리면 Tstd::fs::File 타입으로 채워지고, 파일을 여는 데 문제가 발생하면 Estd::io::Error 타입으로 채워졌다.

코드에서 여러 구조체나 열거형 정의가 포함하는 값의 타입만 다르고 나머지는 동일한 상황을 발견한다면, 제네릭 타입을 사용해 중복을 피할 수 있다.

메서드 정의에서의 제네릭

구조체와 열거형에 메서드를 구현할 때(5장에서 다룬 것처럼) 제네릭 타입을 사용할 수도 있다. 리스팅 10-9는 리스팅 10-6에서 정의한 Point<T> 구조체에 x라는 메서드를 구현한 예제를 보여준다.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listing 10-9: Point<T> 구조체에 x 필드의 참조를 반환하는 x 메서드 구현

여기서는 Point<T>x라는 메서드를 정의했으며, 이 메서드는 x 필드에 있는 데이터의 참조를 반환한다.

impl 바로 뒤에 T를 선언해야 Point<T> 타입에 메서드를 구현할 수 있다. impl 뒤에 T를 제네릭 타입으로 선언하면 Rust는 Point의 꺾쇠 괄호 안에 있는 타입이 구체적인 타입이 아닌 제네릭 타입임을 인식한다. 구조체 정의에서 선언한 제네릭 매개변수와 다른 이름을 사용할 수도 있지만, 일반적으로 같은 이름을 사용한다. impl 블록 내에서 제네릭 타입을 선언한 메서드를 작성하면, 해당 메서드는 어떤 구체적인 타입이 제네릭 타입을 대체하든 상관없이 모든 타입 인스턴스에 정의된다.

메서드를 정의할 때 제네릭 타입에 제약을 걸 수도 있다. 예를 들어, 모든 제네릭 타입 T를 가진 Point<T> 인스턴스가 아닌, Point<f32> 인스턴스에만 메서드를 구현할 수 있다. 리스팅 10-10에서는 구체적인 타입 f32를 사용했기 때문에 impl 뒤에 타입을 선언하지 않았다.

Filename: src/main.rs
struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}
Listing 10-10: 제네릭 타입 매개변수 T에 특정 구체적인 타입을 가진 구조체에만 적용되는 impl 블록

이 코드는 Point<f32> 타입이 distance_from_origin 메서드를 가지도록 한다. Tf32 타입이 아닌 다른 Point<T> 인스턴스에는 이 메서드가 정의되지 않는다. 이 메서드는 점이 좌표 (0.0, 0.0)에서 얼마나 떨어져 있는지 측정하며, 부동소수점 타입에서만 사용 가능한 수학 연산을 활용한다.

구조체 정의에서 사용한 제네릭 타입 매개변수와 메서드 시그니처에서 사용한 제네릭 타입 매개변수가 항상 같을 필요는 없다. 리스팅 10-11에서는 Point 구조체에는 X1Y1이라는 제네릭 타입을 사용하고, mixup 메서드 시그니처에는 X2Y2라는 제네릭 타입을 사용해 예제를 더 명확하게 만들었다. 이 메서드는 self Point(타입 X1)의 x 값과 전달된 Point(타입 Y2)의 y 값을 사용해 새로운 Point 인스턴스를 생성한다.

Filename: src/main.rs
struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Listing 10-11: 구조체 정의와 다른 제네릭 타입을 사용하는 메서드

main 함수에서는 xi32 타입의 값 5를, yf64 타입의 값 10.4를 가진 Point를 정의했다. p2 변수는 x에 문자열 슬라이스 "Hello"를, ychar 타입의 값 c를 가진 Point 구조체다. p1p2를 인수로 전달해 mixup을 호출하면 p3가 생성된다. p3xp1에서 왔기 때문에 i32 타입의 값을 가지고, yp2에서 왔기 때문에 char 타입의 값을 가진다. println! 매크로 호출은 p3.x = 5, p3.y = c를 출력한다.

이 예제의 목적은 impl로 선언한 제네릭 매개변수와 메서드 정의로 선언한 제네릭 매개변수가 함께 사용되는 상황을 보여주기 위함이다. 여기서 X1Y1은 구조체 정의와 관련이 있기 때문에 impl 뒤에 선언되었다. 반면 X2Y2는 메서드와만 관련이 있기 때문에 fn mixup 뒤에 선언되었다.

제네릭을 사용한 코드의 성능

제네릭 타입 매개변수를 사용할 때 런타임 비용이 발생하는지 궁금할 수 있다. 다행히도 제네릭 타입을 사용해도 프로그램이 구체적인 타입을 사용할 때보다 느려지지 않는다.

Rust는 컴파일 타임에 제네릭 코드를 단일화(monomorphization)하는 방식으로 이를 달성한다. 단일화는 컴파일 시 사용된 구체적인 타입을 채워 제네릭 코드를 특정 코드로 변환하는 과정이다. 이 과정에서 컴파일러는 Listing 10-5에서 제네릭 함수를 만들 때 사용한 단계와 반대 작업을 수행한다. 컴파일러는 제네릭 코드가 호출된 모든 위치를 확인하고, 제네릭 코드가 호출된 구체적인 타입에 대한 코드를 생성한다.

표준 라이브러리의 제네릭 Option<T> 열거형을 예로 들어 이 과정이 어떻게 동작하는지 살펴보자:

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

Rust가 이 코드를 컴파일할 때 단일화를 수행한다. 이 과정에서 컴파일러는 Option<T> 인스턴스에서 사용된 값을 읽고 두 가지 타입의 Option<T>를 식별한다: 하나는 i32이고 다른 하나는 f64이다. 따라서 컴파일러는 Option<T>의 제네릭 정의를 i32f64에 특화된 두 가지 정의로 확장하여 제네릭 정의를 구체적인 정의로 대체한다.

단일화된 코드는 다음과 같이 보인다(컴파일러는 설명을 위해 여기서 사용한 것과 다른 이름을 사용한다):

Filename: src/main.rs
enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

제네릭 Option<T>는 컴파일러가 생성한 구체적인 정의로 대체된다. Rust는 제네릭 코드를 각 인스턴스에서 타입을 명시한 코드로 컴파일하기 때문에 제네릭을 사용해도 런타임 비용이 발생하지 않는다. 코드가 실행될 때는 마치 각 정의를 수동으로 복제한 것과 동일한 성능을 보인다. 단일화 과정 덕분에 Rust의 제네릭은 런타임에서 매우 효율적으로 동작한다.

트레이트: 공유 가능한 동작 정의하기

**트레이트(trait)**는 특정 타입이 가진 기능을 정의하고, 이를 다른 타입과 공유할 수 있게 해준다. 트레이트를 사용해 추상적인 방식으로 공유 가능한 동작을 정의할 수 있다. **트레이트 바운드(trait bounds)**를 활용하면 특정 동작을 가진 모든 타입을 일반화된 타입으로 지정할 수 있다.

참고: 트레이트는 다른 언어에서 흔히 **인터페이스(interfaces)**라고 부르는 기능과 유사하지만, 몇 가지 차이점이 있다.

트레이트 정의하기

타입의 동작은 해당 타입에서 호출할 수 있는 메서드들로 구성된다. 여러 타입이 동일한 메서드를 호출할 수 있다면, 그 타입들은 동일한 동작을 공유한다고 볼 수 있다. 트레이트 정의는 메서드 시그니처를 그룹화하여 특정 목적을 달성하는 데 필요한 동작 집합을 정의하는 방법이다.

예를 들어, 다양한 종류와 양의 텍스트를 담고 있는 여러 구조체가 있다고 가정해보자. 특정 지역에서 작성된 뉴스 기사를 담는 NewsArticle 구조체와, 최대 280자의 텍스트와 함께 새 게시물인지, 리포스트인지, 다른 게시물에 대한 답글인지를 나타내는 메타데이터를 포함하는 SocialPost 구조체가 있다.

이제 aggregator라는 이름의 미디어 집계 라이브러리 크레이트를 만들어 NewsArticle이나 SocialPost 인스턴스에 저장된 데이터의 요약을 표시하고 싶다. 이를 위해 각 타입에서 요약 정보를 제공해야 하며, 인스턴스에서 summarize 메서드를 호출해 요약 정보를 요청할 것이다. 아래 코드는 이 동작을 표현하는 공개 Summary 트레이트의 정의를 보여준다.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String;
}
Listing 10-12: summarize 메서드가 제공하는 동작을 포함하는 Summary 트레이트

여기서 trait 키워드를 사용해 트레이트를 선언하고, 이 경우에는 Summary라는 이름을 붙였다. 또한 이 트레이트를 pub으로 선언해 이 크레이트에 의존하는 다른 크레이트들도 이 트레이트를 사용할 수 있도록 했다. 중괄호 안에는 이 트레이트를 구현하는 타입들의 동작을 설명하는 메서드 시그니처를 선언했는데, 이 경우에는 fn summarize(&self) -> String이다.

메서드 시그니처 뒤에는 중괄호 안에 구현을 제공하는 대신 세미콜론을 사용했다. 이 트레이트를 구현하는 각 타입은 메서드 본문에 대해 자신만의 커스텀 동작을 제공해야 한다. 컴파일러는 Summary 트레이트를 가진 모든 타입이 정확히 이 시그니처와 함께 summarize 메서드를 정의하도록 강제한다.

트레이트 본문에는 여러 메서드를 포함할 수 있다. 메서드 시그니처는 한 줄에 하나씩 나열되며, 각 줄은 세미콜론으로 끝난다.

타입에 트레잇 구현하기

이제 Summary 트레잇 메서드의 원하는 시그니처를 정의했으므로, 미디어 애그리게이터의 타입에 이를 구현할 수 있다. 리스트 10-13은 NewsArticle 구조체에 Summary 트레잇을 구현한 예시를 보여준다. 여기서는 헤드라인, 작성자, 위치를 사용해 summarize의 반환 값을 생성한다. SocialPost 구조체의 경우, summarize를 사용자 이름 뒤에 게시물 전체 텍스트가 오도록 정의한다. 이때 게시물 내용이 이미 280자로 제한되어 있다고 가정한다.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String;
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
Listing 10-13: NewsArticleSocialPost 타입에 Summary 트레잇 구현

타입에 트레잇을 구현하는 것은 일반 메서드를 구현하는 것과 유사하다. 차이점은 impl 뒤에 구현하려는 트레잇 이름을 적고, for 키워드를 사용한 다음, 트레잇을 구현할 타입의 이름을 지정한다는 것이다. impl 블록 내부에는 트레잇 정의에서 정의한 메서드 시그니처를 넣는다. 각 시그니처 뒤에 세미콜론을 추가하는 대신, 중괄호를 사용하고 해당 타입에 대해 트레잇 메서드가 가져야 할 특정 동작을 메서드 본문에 작성한다.

이제 라이브러리가 NewsArticleSocialPostSummary 트레잇을 구현했으므로, 크레이트 사용자는 NewsArticleSocialPost 인스턴스에서 일반 메서드를 호출하는 것과 같은 방식으로 트레잇 메서드를 호출할 수 있다. 유일한 차이점은 사용자가 타입뿐만 아니라 트레잇도 스코프로 가져와야 한다는 것이다. 다음은 바이너리 크레이트가 aggregator 라이브러리 크레이트를 사용하는 예시다:

use aggregator::{SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new social post: {}", post.summarize());
}

이 코드는 1 new post: horse_ebooks: of course, as you probably already know, people을 출력한다.

aggregator 크레이트에 의존하는 다른 크레이트도 Summary 트레잇을 스코프로 가져와 자신의 타입에 Summary를 구현할 수 있다. 주의할 점은 트레잇이나 타입 중 하나가 우리 크레이트에 로컬로 정의되어 있을 때만 해당 타입에 트레잇을 구현할 수 있다는 것이다. 예를 들어, SocialPost와 같은 커스텀 타입에 Display와 같은 표준 라이브러리 트레잇을 aggregator 크레이트 기능의 일부로 구현할 수 있다. 이는 SocialPost 타입이 aggregator 크레이트에 로컬로 정의되어 있기 때문이다. 또한 aggregator 크레이트에서 Vec<T>Summary를 구현할 수도 있다. 이는 Summary 트레잇이 aggregator 크레이트에 로컬로 정의되어 있기 때문이다.

하지만 외부 트레잇을 외부 타입에 구현할 수는 없다. 예를 들어, aggregator 크레이트 내에서 Vec<T>Display 트레잇을 구현할 수 없다. 이는 DisplayVec<T> 모두 표준 라이브러리에 정의되어 있고 aggregator 크레이트에 로컬로 정의되어 있지 않기 때문이다. 이 제한은 코히어런스(coherence) 속성의 일부이며, 더 구체적으로는 _오펀 룰(orphan rule)_이라고 한다. 이 규칙은 다른 사람의 코드가 여러분의 코드를 망가뜨리지 않도록 보장한다. 이 규칙이 없다면 두 크레이트가 동일한 타입에 동일한 트레잇을 구현할 수 있고, Rust는 어떤 구현을 사용해야 할지 알 수 없게 된다.

기본 구현

특정 트레이트의 일부 또는 모든 메서드에 대해 기본 동작을 정의하는 것이 유용할 때가 있다. 이렇게 하면 모든 타입에서 모든 메서드를 구현할 필요 없이, 특정 타입에 트레이트를 구현할 때 각 메서드의 기본 동작을 유지하거나 재정의할 수 있다.

리스트 10-14에서는 Summary 트레이트의 summarize 메서드에 대해 기본 문자열을 지정한다. 이전 리스트 10-12에서 메서드 시그니처만 정의했던 것과 달리, 이번에는 기본 구현을 제공한다.

Filename: src/lib.rs
pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}
Listing 10-14: summarize 메서드의 기본 구현을 포함한 Summary 트레이트 정의

NewsArticle 인스턴스를 요약하기 위해 기본 구현을 사용하려면, impl Summary for NewsArticle {}와 같이 빈 impl 블록을 지정한다.

이제 NewsArticle에서 직접 summarize 메서드를 정의하지 않더라도, 기본 구현을 제공했고 NewsArticleSummary 트레이트를 구현한다고 명시했기 때문에, 여전히 NewsArticle 인스턴스에서 summarize 메서드를 호출할 수 있다. 예를 들면 다음과 같다:

use aggregator::{self, NewsArticle, Summary};

fn main() {
    let article = NewsArticle {
        headline: String::from("Penguins win the Stanley Cup Championship!"),
        location: String::from("Pittsburgh, PA, USA"),
        author: String::from("Iceburgh"),
        content: String::from(
            "The Pittsburgh Penguins once again are the best \
             hockey team in the NHL.",
        ),
    };

    println!("New article available! {}", article.summarize());
}

이 코드는 New article available! (Read more...)를 출력한다.

기본 구현을 생성해도 리스트 10-13의 SocialPost에 대한 Summary 구현은 변경할 필요가 없다. 기본 구현을 재정의하는 구문은 기본 구현이 없는 트레이트 메서드를 구현하는 구문과 동일하기 때문이다.

기본 구현은 동일한 트레이트 내의 다른 메서드를 호출할 수 있다. 심지어 해당 메서드에 기본 구현이 없더라도 가능하다. 이렇게 하면 트레이트가 많은 유용한 기능을 제공하면서도 구현자에게는 일부만 구현하도록 요구할 수 있다. 예를 들어, Summary 트레이트에 summarize_author 메서드를 정의하고 이 메서드의 구현을 필수로 요구한 다음, summarize 메서드의 기본 구현에서 summarize_author 메서드를 호출하도록 정의할 수 있다:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

이 버전의 Summary를 사용하려면, 타입에 트레이트를 구현할 때 summarize_author만 정의하면 된다:

pub trait Summary {
    fn summarize_author(&self) -> String;

    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
}

summarize_author를 정의한 후에는 SocialPost 구조체의 인스턴스에서 summarize를 호출할 수 있고, summarize의 기본 구현은 우리가 제공한 summarize_author의 정의를 호출한다. summarize_author를 구현했기 때문에, Summary 트레이트는 추가 코드 없이도 summarize 메서드의 동작을 제공한다. 이렇게 사용할 수 있다:

use aggregator::{self, SocialPost, Summary};

fn main() {
    let post = SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    };

    println!("1 new social post: {}", post.summarize());
}

이 코드는 1 new post: (Read more from @horse_ebooks...)를 출력한다.

동일한 메서드의 재정의 구현에서 기본 구현을 호출할 수 없다는 점에 유의한다.

트레이트를 매개변수로 사용하기

트레이트를 정의하고 구현하는 방법을 배웠으니, 이제 다양한 타입을 받는 함수를 정의할 때 트레이트를 어떻게 활용할 수 있는지 알아보자. 리스트 10-13에서 NewsArticleSocialPost 타입에 구현한 Summary 트레이트를 사용해 notify 함수를 정의할 것이다. 이 함수는 Summary 트레이트를 구현한 어떤 타입의 item 매개변수에서 summarize 메서드를 호출한다. 이를 위해 impl Trait 구문을 사용한다:

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

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

item 매개변수에 구체적인 타입 대신 impl 키워드와 트레이트 이름을 지정한다. 이 매개변수는 지정된 트레이트를 구현한 모든 타입을 받아들인다. notify 함수 본문에서는 item에서 Summary 트레이트의 메서드인 summarize와 같은 메서드를 호출할 수 있다. notify를 호출할 때 NewsArticle이나 SocialPost의 인스턴스를 전달할 수 있다. String이나 i32와 같은 다른 타입으로 함수를 호출하려고 하면 컴파일되지 않는다. 이 타입들은 Summary 트레이트를 구현하지 않았기 때문이다.

트레이트 바운드 문법

impl Trait 문법은 간단한 경우에 유용하지만, 이는 사실 _트레이트 바운드_라고 불리는 더 긴 형태의 문법을 간단히 표현한 것이다. 트레이트 바운드 문법은 다음과 같다:

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

이 더 긴 형태는 이전 섹션의 예제와 동일하지만 더 장황하다. 트레이트 바운드는 제네릭 타입 파라미터 선언 뒤에 콜론을 붙이고 꺾쇠 괄호 안에 위치시킨다.

impl Trait 문법은 편리하며 간단한 경우에 코드를 간결하게 만들어준다. 반면, 더 완전한 트레이트 바운드 문법은 다른 경우에 더 복잡한 표현을 가능하게 한다. 예를 들어, Summary를 구현하는 두 개의 파라미터를 가질 수 있다. impl Trait 문법을 사용하면 다음과 같다:

pub fn notify(item1: &impl Summary, item2: &impl Summary) {

impl Trait를 사용하면 item1item2가 서로 다른 타입을 가질 수 있다(단, 두 타입 모두 Summary를 구현해야 한다). 그러나 두 파라미터가 동일한 타입을 가지도록 강제하려면 트레이트 바운드를 사용해야 한다:

pub fn notify<T: Summary>(item1: &T, item2: &T) {

item1item2 파라미터의 타입으로 지정된 제네릭 타입 T는 함수를 제한하여 item1item2에 전달된 인자의 구체적인 타입이 동일해야 한다.

여러 트레이트 바운드를 + 구문으로 지정하기

여러 개의 트레이트 바운드를 지정할 수도 있다. 예를 들어, notify 함수가 item에 대해 summarize뿐만 아니라 디스플레이 포맷팅도 사용하도록 하고 싶다면, notify 정의에서 itemDisplaySummary 두 트레이트를 모두 구현해야 한다고 지정할 수 있다. 이때 + 구문을 사용한다:

pub fn notify(item: &(impl Summary + Display)) {

+ 구문은 제네릭 타입의 트레이트 바운드에서도 유효하다:

pub fn notify<T: Summary + Display>(item: &T) {

두 트레이트 바운드를 지정하면, notify 함수 본문에서 summarize를 호출하고 {}를 사용해 item을 포맷팅할 수 있다.

where 절을 사용한 명확한 트레이트 바운드

너무 많은 트레이트 바운드를 사용하면 단점이 있다. 각 제네릭 타입은 고유한 트레이트 바운드를 가지므로, 여러 제네릭 타입 파라미터를 가진 함수는 함수 이름과 파라미터 목록 사이에 많은 트레이트 바운드 정보를 포함하게 된다. 이로 인해 함수 시그니처를 읽기 어렵게 만든다. 이러한 이유로 Rust는 함수 시그니처 뒤에 where 절을 사용해 트레이트 바운드를 지정하는 대체 구문을 제공한다. 따라서 다음과 같이 작성하는 대신:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {

where 절을 사용해 다음과 같이 작성할 수 있다:

fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    unimplemented!()
}

이렇게 하면 함수 시그니처가 덜 복잡해진다. 함수 이름, 파라미터 목록, 반환 타입이 서로 가까이 위치하게 되어, 트레이트 바운드가 많지 않은 함수와 비슷한 형태가 된다.

트레이트를 구현한 타입 반환하기

impl Trait 구문을 반환 위치에서 사용하여 특정 트레이트를 구현한 타입의 값을 반환할 수도 있다. 아래 예제를 참고하자:

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

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable() -> impl Summary {
    SocialPost {
        username: String::from("horse_ebooks"),
        content: String::from(
            "of course, as you probably already know, people",
        ),
        reply: false,
        repost: false,
    }
}

impl Summary를 반환 타입으로 지정함으로써, returns_summarizable 함수가 Summary 트레이트를 구현한 어떤 타입을 반환한다는 것을 명시한다. 이 경우 returns_summarizableSocialPost를 반환하지만, 이 함수를 호출하는 코드는 구체적인 타입을 알 필요가 없다.

트레이트를 구현한 타입만으로 반환 타입을 지정할 수 있는 기능은 특히 클로저와 이터레이터와 같은 경우에 유용하다. 이는 13장에서 다룰 내용이다. 클로저와 이터레이터는 컴파일러만 아는 타입이거나 매우 길게 지정해야 하는 타입을 생성한다. impl Trait 구문을 사용하면 Iterator 트레이트를 구현한 어떤 타입을 반환한다는 것을 간결하게 표현할 수 있다.

하지만 impl Trait는 단일 타입을 반환할 때만 사용할 수 있다. 예를 들어, NewsArticle 또는 SocialPost를 반환하는 아래 코드는 impl Summary를 반환 타입으로 지정했기 때문에 동작하지 않는다:

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

pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct SocialPost {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub repost: bool,
}

impl Summary for SocialPost {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

fn returns_summarizable(switch: bool) -> impl Summary {
    if switch {
        NewsArticle {
            headline: String::from(
                "Penguins win the Stanley Cup Championship!",
            ),
            location: String::from("Pittsburgh, PA, USA"),
            author: String::from("Iceburgh"),
            content: String::from(
                "The Pittsburgh Penguins once again are the best \
                 hockey team in the NHL.",
            ),
        }
    } else {
        SocialPost {
            username: String::from("horse_ebooks"),
            content: String::from(
                "of course, as you probably already know, people",
            ),
            reply: false,
            repost: false,
        }
    }
}

NewsArticle 또는 SocialPost를 반환하는 것은 컴파일러에서 impl Trait 구문이 구현된 방식의 제약 때문에 허용되지 않는다. 이러한 동작을 하는 함수를 작성하는 방법은 18장의 “다양한 타입의 값을 허용하는 트레이트 객체 사용하기” 섹션에서 다룰 것이다.

트레이트 바운드를 사용해 조건부 메서드 구현하기

impl 블록에 제네릭 타입 매개변수와 함께 트레이트 바운드를 사용하면, 특정 트레이트를 구현한 타입에 대해서만 메서드를 조건부로 구현할 수 있다. 예를 들어, 리스트 10-15의 Pair<T> 타입은 항상 new 함수를 구현해 Pair<T>의 새 인스턴스를 반환한다. (5장의 “메서드 정의하기” 섹션에서 Selfimpl 블록의 타입 별칭이며, 이 경우에는 Pair<T>임을 상기하자.) 하지만 다음 impl 블록에서는 Pair<T>가 내부 타입 T가 비교를 가능하게 하는 PartialOrd 트레이트와 출력을 가능하게 하는 Display 트레이트를 모두 구현한 경우에만 cmp_display 메서드를 구현한다.

<리스트 번호=“10-15” 파일 이름=“src/lib.rs” 설명=“트레이트 바운드에 따라 제네릭 타입에 조건부로 메서드 구현하기”>

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

</리스트>

또한, 특정 트레이트를 구현한 모든 타입에 대해 다른 트레이트를 조건부로 구현할 수도 있다. 트레이트 바운드를 만족하는 모든 타입에 대한 트레이트 구현을 *일괄 구현(blanket implementation)*이라고 하며, 이는 Rust 표준 라이브러리에서 광범위하게 사용된다. 예를 들어, 표준 라이브러리는 Display 트레이트를 구현한 모든 타입에 대해 ToString 트레이트를 구현한다. 표준 라이브러리의 impl 블록은 다음과 비슷하게 생겼다:

impl<T: Display> ToString for T {
    // --snip--
}

표준 라이브러리에 이 일괄 구현이 있기 때문에, Display 트레이트를 구현한 모든 타입에서 ToString 트레이트에 정의된 to_string 메서드를 호출할 수 있다. 예를 들어, 정수는 Display를 구현하므로 다음과 같이 정수를 해당하는 String 값으로 변환할 수 있다:

#![allow(unused)]
fn main() {
let s = 3.to_string();
}

일괄 구현은 트레이트 문서의 “구현자(Implementors)” 섹션에 나타난다.

트레이트와 트레이트 바운드를 사용하면 제네릭 타입 매개변수를 활용해 코드 중복을 줄이면서도 컴파일러에게 제네릭 타입이 특정 동작을 갖도록 요구할 수 있다. 컴파일러는 트레이트 바운드 정보를 사용해 코드에서 사용되는 모든 구체적 타입이 올바른 동작을 제공하는지 확인할 수 있다. 동적 타입 언어에서는 메서드가 정의되지 않은 타입에서 메서드를 호출할 경우 런타임에 오류가 발생한다. 하지만 Rust는 이러한 오류를 컴파일 타임으로 옮겨서 코드가 실행되기 전에 문제를 해결하도록 강제한다. 또한, 런타임에 동작을 확인하는 코드를 작성할 필요가 없는데, 이미 컴파일 타임에 확인했기 때문이다. 이렇게 하면 제네릭의 유연성을 포기하지 않으면서도 성능을 향상시킬 수 있다.

수명을 이용한 참조 유효성 검증

수명(lifetime)은 이미 사용하고 있는 또 다른 종류의 제네릭이다. 타입이 원하는 동작을 보장하는 것과 달리, 수명은 참조가 필요한 동안 유효한지 확인한다.

4장의 “참조와 빌림” 섹션에서 다루지 않은 한 가지 세부 사항은, Rust의 모든 참조는 _수명_을 가진다는 점이다. 수명은 해당 참조가 유효한 범위를 의미한다. 대부분의 경우, 타입이 추론되듯이 수명도 암묵적으로 추론된다. 여러 타입이 가능한 경우에만 타입을 명시해야 하듯이, 참조의 수명이 서로 여러 방식으로 연관될 가능성이 있는 경우에만 수명을 명시해야 한다. Rust는 제네릭 수명 매개변수를 사용해 이러한 관계를 명시하도록 요구하며, 이는 런타임에 사용되는 실제 참조가 확실히 유효하도록 보장하기 위함이다.

수명을 명시하는 개념은 대부분의 다른 프로그래밍 언어에는 존재하지 않기 때문에 익숙하지 않을 수 있다. 이 장에서 수명의 모든 내용을 다루지는 않지만, 수명 문법을 접할 수 있는 일반적인 경우를 살펴보며 개념에 익숙해질 수 있도록 하겠다.

라이프타임을 통한 댕글링 참조 방지

라이프타임의 주요 목적은 **댕글링 참조(dangling references)**를 방지하는 것이다. 댕글링 참조는 프로그램이 의도한 데이터가 아닌 다른 데이터를 참조하게 만드는 문제를 일으킨다. 아래의 예제 코드를 살펴보자. 이 코드는 외부 스코프와 내부 스코프를 가지고 있다.

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}
Listing 10-16: 스코프를 벗어난 값을 참조하려는 시도

참고: 예제 10-16, 10-17, 10-23에서는 변수를 초기화하지 않고 선언했다. 따라서 변수 이름은 외부 스코프에 존재한다. 언뜻 보면 이는 Rust가 null 값을 허용하지 않는 것과 충돌하는 것처럼 보일 수 있다. 하지만 값을 할당하기 전에 변수를 사용하려고 하면 컴파일 타임 오류가 발생한다. 이는 Rust가 실제로 null 값을 허용하지 않음을 보여준다.

외부 스코프에서는 초기값 없이 r이라는 변수를 선언하고, 내부 스코프에서는 초기값 5를 가진 x라는 변수를 선언한다. 내부 스코프 안에서 r의 값을 x에 대한 참조로 설정하려고 시도한다. 그런 다음 내부 스코프가 종료되고, r의 값을 출력하려고 한다. 이 코드는 컴파일되지 않는다. 왜냐하면 r이 참조하는 값이 사용하려고 할 때 이미 스코프를 벗어났기 때문이다. 아래는 오류 메시지이다:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let x = 5;
  |             - binding `x` declared here
6 |         r = &x;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `x` dropped here while still borrowed
8 |
9 |     println!("r: {r}");
  |                  --- borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

오류 메시지는 변수 x가 “충분히 오래 살지 못했다“고 말한다. 그 이유는 내부 스코프가 7번 줄에서 종료될 때 x가 스코프를 벗어나기 때문이다. 하지만 r은 여전히 외부 스코프에서 유효하다. 외부 스코프가 더 크기 때문에 r은 “더 오래 산다“고 표현할 수 있다. 만약 Rust가 이 코드를 허용했다면, rx가 스코프를 벗어날 때 해제된 메모리를 참조하게 될 것이고, r을 사용해 어떤 작업을 시도해도 제대로 동작하지 않을 것이다. 그렇다면 Rust는 어떻게 이 코드가 유효하지 않다고 판단할까? 바로 **빌림 검사기(borrow checker)**를 사용한다.

대여 검사기

Rust 컴파일러는 _대여 검사기_를 통해 스코프를 비교하여 모든 대여가 유효한지 확인한다. 목록 10-17은 목록 10-16과 동일한 코드이지만, 변수의 라이프타임을 보여주는 주석이 추가되었다.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+
Listing 10-17: rx의 라이프타임을 각각 'a'b로 명시한 예제

여기서 r의 라이프타임은 'a로, x의 라이프타임은 'b로 주석 처리되었다. 보다시피, 내부의 'b 블록은 외부의 'a 라이프타임 블록보다 훨씬 작다. 컴파일 시 Rust는 두 라이프타임의 크기를 비교하고, r'a 라이프타임을 가지고 있지만 'b 라이프타임의 메모리를 참조한다는 것을 확인한다. 'b'a보다 짧기 때문에 프로그램은 거부된다. 참조의 대상이 참조 자체만큼 오래 살아있지 않기 때문이다.

목록 10-18은 이 문제를 해결하여 댕글링 참조를 없애고, 오류 없이 컴파일되도록 수정한 코드이다.

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {r}");   //   |       |
                          // --+       |
}                         // ----------+
Listing 10-18: 참조보다 데이터의 라이프타임이 더 길어 유효한 참조가 된 예제

여기서 x'b 라이프타임을 가지며, 이 경우 'a보다 크다. 이는 rx를 참조할 수 있음을 의미한다. Rust는 x가 유효한 동안 r의 참조가 항상 유효할 것임을 알기 때문이다.

이제 참조의 라이프타임이 어디에 위치하는지, 그리고 Rust가 라이프타임을 분석하여 참조가 항상 유효한지 확인하는 방법을 알았으니, 함수의 매개변수와 반환값의 제네릭 라이프타임을 탐구해보자.

함수에서의 제네릭 라이프타임

두 문자열 슬라이스 중 더 긴 것을 반환하는 함수를 작성해 보자. 이 함수는 두 문자열 슬라이스를 받아서 하나의 문자열 슬라이스를 반환한다. longest 함수를 구현한 후, 리스트 10-19의 코드는 The longest string is abcd를 출력해야 한다.

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}
Listing 10-19: 두 문자열 슬라이스 중 더 긴 것을 찾기 위해 longest 함수를 호출하는 main 함수

여기서 함수가 문자열 슬라이스(참조)를 받도록 하고, 문자열 자체를 받지 않는 이유는 longest 함수가 매개변수의 소유권을 가지지 않게 하기 위해서다. 리스트 10-19에서 사용한 매개변수가 왜 적합한지에 대한 더 자세한 설명은 4장의 “문자열 슬라이스를 매개변수로 사용하기”를 참고하자.

리스트 10-20과 같이 longest 함수를 구현하려고 하면 컴파일되지 않는다.

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-20: 두 문자열 슬라이스 중 더 긴 것을 반환하지만 아직 컴파일되지 않는 longest 함수 구현

대신, 다음과 같은 라이프타임 관련 오류가 발생한다:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:33
  |
9 | fn longest(x: &str, y: &str) -> &str {
  |               ----     ----     ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
  |
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

도움말 텍스트를 보면 반환 타입에 제네릭 라이프타임 매개변수가 필요하다고 나와 있다. Rust는 반환되는 참조가 x를 가리키는지 y를 가리키는지 알 수 없기 때문이다. 사실 우리도 알 수 없다. 왜냐하면 함수 본문의 if 블록은 x에 대한 참조를 반환하고, else 블록은 y에 대한 참조를 반환하기 때문이다!

이 함수를 정의할 때, 어떤 구체적인 값이 함수에 전달될지 알 수 없으므로 if 케이스가 실행될지 else 케이스가 실행될지 알 수 없다. 또한 전달된 참조의 구체적인 라이프타임도 알 수 없으므로 리스트 10-17과 10-18에서 했던 것처럼 스코프를 보고 반환된 참조가 항상 유효한지 확인할 수 없다. 빌림 검사기(borrow checker)도 이를 결정할 수 없다. 왜냐하면 xy의 라이프타임이 반환 값의 라이프타임과 어떻게 관련되는지 알 수 없기 때문이다. 이 오류를 해결하기 위해, 참조 간의 관계를 정의하는 제네릭 라이프타임 매개변수를 추가해 빌림 검사기가 분석을 수행할 수 있도록 해야 한다.

라이프타임 주석 구문

라이프타임 주석은 참조의 수명을 변경하지 않는다. 대신, 여러 참조 간의 라이프타임 관계를 설명하며, 실제 라이프타임에는 영향을 미치지 않는다. 함수가 제네릭 타입 매개변수를 지정할 때 어떤 타입이든 받아들일 수 있는 것처럼, 함수는 제네릭 라이프타임 매개변수를 지정하여 어떤 라이프타임을 가진 참조든 받아들일 수 있다.

라이프타임 주석은 약간 독특한 구문을 가지고 있다. 라이프타임 매개변수의 이름은 반드시 아포스트로피(')로 시작해야 하며, 일반적으로 모두 소문자로 짧게 작성한다. 대부분의 사람들은 첫 번째 라이프타임 주석으로 'a라는 이름을 사용한다. 라이프타임 매개변수 주석은 참조의 & 뒤에 위치하며, 주석과 참조의 타입을 공백으로 구분한다.

다음은 몇 가지 예제이다. 라이프타임 매개변수가 없는 i32에 대한 참조, 'a라는 라이프타임 매개변수가 있는 i32에 대한 참조, 그리고 'a 라이프타임을 가진 i32에 대한 가변 참조이다.

&i32        // 참조
&'a i32     // 명시적 라이프타임이 있는 참조
&'a mut i32 // 명시적 라이프타임이 있는 가변 참조

단일 라이프타임 주석은 그 자체로 큰 의미를 가지지 않는다. 주석은 여러 참조의 제네릭 라이프타임 매개변수가 서로 어떻게 관련되는지 Rust에게 알려주기 위한 것이기 때문이다. 이제 longest 함수의 맥락에서 라이프타임 주석이 서로 어떻게 관련되는지 살펴보자.

함수 시그니처에서의 라이프타임 주석

함수 시그니처에서 라이프타임 주석을 사용하려면, 함수 이름과 매개변수 목록 사이의 꺾쇠 괄호 안에 제네릭 라이프타임 매개변수를 선언해야 한다. 이는 제네릭 타입 매개변수를 선언하는 방식과 동일하다.

우리는 시그니처가 다음과 같은 제약을 표현하길 원한다: 반환된 참조는 두 매개변수가 유효한 동안 유효하다. 이는 매개변수와 반환 값의 라이프타임 간의 관계이다. 라이프타임을 'a로 명명한 후, 각 참조에 이를 추가한다. 이는 리스트 10-21에서 확인할 수 있다.

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-21: 시그니처의 모든 참조가 동일한 라이프타임 'a를 가져야 함을 지정한 longest 함수 정의

이 코드는 리스트 10-19의 main 함수와 함께 사용할 때 컴파일되어 원하는 결과를 생성해야 한다.

이제 함수 시그니처는 Rust에게 특정 라이프타임 'a에 대해, 함수가 두 매개변수를 받으며, 이 두 매개변수는 최소한 라이프타임 'a만큼 살아있는 문자열 슬라이스임을 알려준다. 또한 함수 시그니처는 함수에서 반환된 문자열 슬라이스가 최소한 라이프타임 'a만큼 살아있음을 알려준다. 실제로 이는 longest 함수가 반환한 참조의 라이프타임이 함수 인수로 전달된 값들의 라이프타임 중 더 짧은 것과 동일함을 의미한다. 이러한 관계는 Rust가 이 코드를 분석할 때 사용하길 원하는 것이다.

기억해야 할 점은, 이 함수 시그니처에서 라이프타임 매개변수를 지정할 때, 전달되거나 반환된 값의 라이프타임을 변경하는 것이 아니라는 점이다. 오히려, 이 제약을 따르지 않는 값을 차단하도록 borrow checker에 지시하는 것이다. longest 함수는 xy가 정확히 얼마나 오래 살아있는지 알 필요는 없으며, 이 시그니처를 만족할 수 있는 어떤 스코프가 'a로 대체될 수 있음을 알면 된다.

함수에서 라이프타임을 주석 처리할 때, 주석은 함수 시그니처에 들어가며 함수 본문에는 들어가지 않는다. 라이프타임 주석은 시그니처의 타입과 마찬가지로 함수의 계약의 일부가 된다. 함수 시그니처에 라이프타임 계약이 포함되면 Rust 컴파일러가 수행하는 분석이 더 단순해질 수 있다. 함수가 주석 처리된 방식이나 호출된 방식에 문제가 있다면, 컴파일러 오류가 우리 코드의 특정 부분과 제약을 더 정확히 지적할 수 있다. 만약 Rust 컴파일러가 라이프타임 간의 관계에 대해 더 많은 추론을 했다면, 컴파일러는 문제의 원인에서 멀리 떨어진 코드 사용 부분만을 지적할 수 있었을 것이다.

longest에 구체적인 참조를 전달할 때, 'a로 대체되는 구체적인 라이프타임은 x의 스코프와 y의 스코프가 겹치는 부분이다. 즉, 제네릭 라이프타임 'axy의 라이프타임 중 더 짧은 것과 동일한 구체적인 라이프타임을 얻는다. 반환된 참조에 동일한 라이프타임 매개변수 'a를 주석 처리했기 때문에, 반환된 참조는 xy의 라이프타임 중 더 짧은 동안 유효할 것이다.

라이프타임 주석이 longest 함수를 어떻게 제한하는지, 서로 다른 구체적인 라이프타임을 가진 참조를 전달하여 살펴보자. 리스트 10-22는 간단한 예제이다.

Filename: src/main.rs
fn main() {
    let string1 = String::from("long string is long");

    {
        let string2 = String::from("xyz");
        let result = longest(string1.as_str(), string2.as_str());
        println!("The longest string is {result}");
    }
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-22: 서로 다른 구체적인 라이프타임을 가진 String 값에 대한 참조와 함께 longest 함수 사용

이 예제에서 string1은 외부 스코프가 끝날 때까지 유효하고, string2는 내부 스코프가 끝날 때까지 유효하며, result는 내부 스코프가 끝날 때까지 유효한 무언가를 참조한다. 이 코드를 실행하면 borrow checker가 승인할 것이며, 컴파일되어 The longest string is long string is long을 출력할 것이다.

다음으로, result의 참조 라이프타임이 두 인수의 더 짧은 라이프타임이어야 함을 보여주는 예제를 시도해보자. result 변수의 선언을 내부 스코프 밖으로 옮기지만, result 변수에 값을 할당하는 부분은 string2와 함께 내부 스코프에 남겨둘 것이다. 그런 다음 result를 사용하는 println!을 내부 스코프가 끝난 후 외부 스코프로 옮길 것이다. 리스트 10-23의 코드는 컴파일되지 않을 것이다.

Filename: src/main.rs
fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Listing 10-23: string2가 스코프를 벗어난 후 result를 사용하려는 시도

이 코드를 컴파일하려고 하면 다음과 같은 오류가 발생한다:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
 --> src/main.rs:6:44
  |
5 |         let string2 = String::from("xyz");
  |             ------- binding `string2` declared here
6 |         result = longest(string1.as_str(), string2.as_str());
  |                                            ^^^^^^^ borrowed value does not live long enough
7 |     }
  |     - `string2` dropped here while still borrowed
8 |     println!("The longest string is {result}");
  |                                     -------- borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

오류는 resultprintln! 문에서 유효하려면 string2가 외부 스코프가 끝날 때까지 유효해야 함을 보여준다. Rust는 함수 매개변수와 반환 값의 라이프타임을 동일한 라이프타임 매개변수 'a로 주석 처리했기 때문에 이를 알고 있다.

우리는 이 코드를 보고 string1string2보다 길기 때문에 resultstring1을 참조할 것임을 알 수 있다. string1은 아직 스코프를 벗어나지 않았기 때문에 string1에 대한 참조는 println! 문에서 여전히 유효할 것이다. 그러나 컴파일러는 이 경우 참조가 유효함을 알 수 없다. 우리는 Rust에게 longest 함수가 반환한 참조의 라이프타임이 전달된 참조의 라이프타임 중 더 짧은 것과 동일하다고 알렸다. 따라서 borrow checker는 리스트 10-23의 코드를 유효하지 않은 참조를 가질 가능성이 있다고 판단하여 허용하지 않는다.

longest 함수에 전달된 참조의 값과 라이프타임을 다양하게 변경하고, 반환된 참조가 어떻게 사용되는지에 대한 실험을 더 설계해보자. 컴파일하기 전에 실험이 borrow checker를 통과할지에 대한 가설을 세우고, 그 후에 맞는지 확인해보자!

수명(Lifetime) 개념으로 생각하기

함수에서 수명 매개변수를 어떻게 지정해야 하는지는 함수가 무엇을 하는지에 따라 달라진다. 예를 들어, longest 함수의 구현을 변경해 항상 가장 긴 문자열 슬라이스 대신 첫 번째 매개변수를 반환하도록 하면, y 매개변수에 수명을 지정할 필요가 없다. 다음 코드는 정상적으로 컴파일된다:

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "efghijklmnopqrstuvwxyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

여기서는 매개변수 x와 반환 타입에 대해 수명 매개변수 'a를 지정했지만, y 매개변수에는 지정하지 않았다. 왜냐하면 y의 수명은 x나 반환 값의 수명과 아무런 관련이 없기 때문이다.

함수에서 참조를 반환할 때, 반환 타입의 수명 매개변수는 매개변수 중 하나의 수명 매개변수와 일치해야 한다. 만약 반환된 참조가 매개변수 중 하나를 가리키지 않는다면, 함수 내부에서 생성된 값을 가리켜야 한다. 그러나 이 경우 함수가 끝나면 값이 스코프를 벗어나게 되므로, 이 참조는 댕글링 참조(dangling reference)가 된다. 다음은 컴파일되지 않는 longest 함수의 구현 시도이다:

Filename: src/main.rs
fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &str, y: &str) -> &'a str {
    let result = String::from("really long string");
    result.as_str()
}

여기서는 반환 타입에 대해 수명 매개변수 'a를 지정했지만, 이 구현은 컴파일되지 않는다. 반환 값의 수명이 매개변수의 수명과 전혀 관련이 없기 때문이다. 다음은 발생한 에러 메시지이다:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
  --> src/main.rs:11:5
   |
11 |     result.as_str()
   |     ------^^^^^^^^^
   |     |
   |     returns a value referencing data owned by the current function
   |     `result` is borrowed here

For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error

문제는 resultlongest 함수의 끝에서 스코프를 벗어나고 정리된다는 점이다. 그리고 함수에서 result에 대한 참조를 반환하려고 한다. 댕글링 참조를 변경할 수 있는 수명 매개변수를 지정할 방법이 없으며, Rust는 댕글링 참조를 생성하는 것을 허용하지 않는다. 이 경우, 참조 대신 소유된 데이터 타입을 반환하는 것이 가장 좋은 해결책이다. 그러면 호출 함수가 값을 정리할 책임을 지게 된다.

결국, 수명 구문은 함수의 다양한 매개변수와 반환 값의 수명을 연결하는 것이다. 이들이 연결되면, Rust는 메모리 안전한 작업을 허용하고 댕글링 포인터를 생성하거나 메모리 안전을 위반하는 작업을 금지할 수 있는 충분한 정보를 갖게 된다.

구조체 정의에서의 라이프타임 주석

지금까지 정의한 구조체는 모두 소유권을 가진 타입을 담고 있었다. 하지만 구조체가 참조를 담도록 정의할 수도 있다. 이 경우, 구조체 정의 내의 모든 참조에 라이프타임 주석을 추가해야 한다. 리스트 10-24는 문자열 슬라이스를 담는 ImportantExcerpt라는 구조체를 보여준다.

Filename: src/main.rs
struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}
Listing 10-24: 참조를 담는 구조체로, 라이프타임 주석이 필요함

이 구조체는 문자열 슬라이스인 참조를 담는 part라는 단일 필드를 가지고 있다. 제네릭 데이터 타입과 마찬가지로, 구조체 이름 뒤에 꺾쇠 괄호 안에 제네릭 라이프타임 매개변수의 이름을 선언한다. 이렇게 하면 구조체 정의 본문에서 라이프타임 매개변수를 사용할 수 있다. 이 주석은 ImportantExcerpt의 인스턴스가 part 필드에 담긴 참조보다 오래 살 수 없음을 의미한다.

여기서 main 함수는 novel 변수가 소유한 String의 첫 번째 문장을 참조하는 ImportantExcerpt 구조체의 인스턴스를 생성한다. novel의 데이터는 ImportantExcerpt 인스턴스가 생성되기 전에 존재한다. 또한, novelImportantExcerpt가 스코프를 벗어난 후에야 스코프를 벗어나므로, ImportantExcerpt 인스턴스의 참조는 유효하다.

수명 생략 규칙

참조마다 수명이 있으며, 참조를 사용하는 함수나 구조체에 수명 매개변수를 명시해야 한다는 것을 배웠다. 하지만 리스트 4-9에서 다뤘던 함수는 수명을 명시하지 않았음에도 컴파일되었다. 이 함수를 리스트 10-25에서 다시 살펴보자.

Filename: src/lib.rs
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // Because string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}
Listing 10-25: 리스트 4-9에서 정의한 함수로, 매개변수와 반환 타입이 참조임에도 수명을 명시하지 않았음

이 함수가 수명을 명시하지 않고도 컴파일되는 이유는 역사적 배경 때문이다. Rust 초기 버전(1.0 이전)에서는 모든 참조에 명시적인 수명이 필요했기 때문에 이 코드는 컴파일되지 않았다. 당시에는 함수 시그니처가 다음과 같이 작성되었다:

fn first_word<'a>(s: &'a str) -> &'a str {

Rust 코드를 많이 작성한 후, Rust 팀은 개발자들이 특정 상황에서 동일한 수명을 반복적으로 명시하는 것을 발견했다. 이러한 상황은 예측 가능했고 몇 가지 결정론적인 패턴을 따랐다. 개발자들은 이러한 패턴을 컴파일러 코드에 추가해, 빌린 값 검사기가 이러한 상황에서 수명을 추론하도록 했고, 명시적인 수명이 필요 없게 되었다.

이 Rust 역사는 더 많은 결정론적인 패턴이 발견되고 컴파일러에 추가될 가능성이 있기 때문에 중요하다. 미래에는 더 적은 수명만 명시하면 될 수도 있다.

Rust의 참조 분석에 프로그래밍된 이러한 패턴을 수명 생략 규칙이라고 한다. 이 규칙은 프로그래머가 따라야 하는 것이 아니라, 컴파일러가 고려하는 특정한 경우의 집합이다. 코드가 이 경우에 해당하면 수명을 명시적으로 작성할 필요가 없다.

수명 생략 규칙은 완전한 추론을 제공하지 않는다. Rust가 규칙을 적용한 후에도 참조의 수명이 모호하면, 컴파일러는 남은 참조의 수명을 추측하지 않는다. 대신 컴파일러는 오류를 발생시키고, 수명을 명시하도록 요구한다.

함수나 메서드 매개변수의 수명을 입력 수명이라고 하고, 반환 값의 수명을 출력 수명이라고 한다.

컴파일러는 명시적인 수명이 없을 때 참조의 수명을 알아내기 위해 세 가지 규칙을 사용한다. 첫 번째 규칙은 입력 수명에 적용되고, 두 번째와 세 번째 규칙은 출력 수명에 적용된다. 컴파일러가 세 규칙을 적용한 후에도 수명을 알 수 없는 참조가 남아 있으면 컴파일러는 오류를 발생시킨다. 이 규칙은 fn 정의와 impl 블록에 모두 적용된다.

첫 번째 규칙은 컴파일러가 참조인 각 매개변수에 수명 매개변수를 할당한다는 것이다. 즉, 매개변수가 하나인 함수는 하나의 수명 매개변수를 가진다: fn foo<'a>(x: &'a i32); 매개변수가 두 개인 함수는 두 개의 수명 매개변수를 가진다: fn foo<'a, 'b>(x: &'a i32, y: &'b i32); 등.

두 번째 규칙은 입력 수명 매개변수가 정확히 하나일 때, 그 수명을 모든 출력 수명 매개변수에 할당한다는 것이다: fn foo<'a>(x: &'a i32) -> &'a i32.

세 번째 규칙은 여러 입력 수명 매개변수가 있지만, 그 중 하나가 &self 또는 &mut self인 경우(메서드이기 때문에) self의 수명을 모든 출력 수명 매개변수에 할당한다는 것이다. 이 규칙은 메서드를 읽고 쓰기 훨씬 쉽게 만든다. 왜냐하면 더 적은 기호가 필요하기 때문이다.

이제 컴파일러가 되어 리스트 10-25의 first_word 함수 시그니처에서 참조의 수명을 알아내는 과정을 살펴보자. 시그니처는 참조와 관련된 수명 없이 시작된다:

fn first_word(s: &str) -> &str {

그런 다음 컴파일러는 첫 번째 규칙을 적용해 각 매개변수에 고유한 수명을 할당한다. 일반적으로 'a라고 부르므로, 이제 시그니처는 다음과 같다:

fn first_word<'a>(s: &'a str) -> &str {

두 번째 규칙은 입력 수명이 정확히 하나일 때 적용된다. 두 번째 규칙은 하나의 입력 매개변수의 수명을 출력 수명에 할당하므로, 시그니처는 이제 다음과 같다:

fn first_word<'a>(s: &'a str) -> &'a str {

이제 이 함수 시그니처의 모든 참조에 수명이 할당되었고, 컴파일러는 프로그래머가 수명을 명시하지 않아도 분석을 계속할 수 있다.

이번에는 리스트 10-20에서 다룬 longest 함수를 예로 들어보자. 이 함수는 처음에 수명 매개변수가 없었다:

fn longest(x: &str, y: &str) -> &str {

첫 번째 규칙을 적용해보자: 각 매개변수에 고유한 수명을 할당한다. 이번에는 매개변수가 하나가 아니라 두 개이므로 두 개의 수명이 있다:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

두 번째 규칙은 입력 수명이 하나보다 많기 때문에 적용되지 않는다. 세 번째 규칙도 적용되지 않는데, longest는 메서드가 아니라 함수이기 때문에 어떤 매개변수도 self가 아니다. 세 규칙을 모두 적용한 후에도 반환 타입의 수명을 알 수 없다. 이 때문에 리스트 10-20의 코드를 컴파일하려고 할 때 오류가 발생한 것이다: 컴파일러는 수명 생략 규칙을 적용했지만 여전히 시그니처의 모든 참조의 수명을 알 수 없었다.

세 번째 규칙은 실제로 메서드 시그니처에만 적용되므로, 다음으로는 메서드 시그니처에서 수명을 살펴보고, 세 번째 규칙이 왜 메서드 시그니처에서 수명을 자주 명시하지 않아도 되는지 알아보자.

메서드 정의에서의 수명 주석

구조체에 수명 주석을 사용해 메서드를 구현할 때, 일반적인 타입 매개변수와 동일한 문법을 사용한다. 수명 매개변수를 선언하고 사용하는 위치는 해당 수명이 구조체 필드와 관련이 있는지, 아니면 메서드 매개변수와 반환값과 관련이 있는지에 따라 달라진다.

구조체 필드의 수명 이름은 항상 impl 키워드 뒤에 선언하고, 구조체 이름 뒤에 사용한다. 이는 해당 수명이 구조체 타입의 일부이기 때문이다.

impl 블록 내부의 메서드 시그니처에서 참조는 구조체 필드의 참조 수명에 묶일 수도 있고, 독립적일 수도 있다. 또한, 수명 생략 규칙에 따라 메서드 시그니처에서 수명 주석이 필요하지 않은 경우가 많다. Listing 10-24에서 정의한 ImportantExcerpt 구조체를 사용한 예제를 살펴보자.

먼저, level이라는 메서드를 사용해 보자. 이 메서드는 self에 대한 참조만 매개변수로 받고, 반환값은 i32 타입으로, 어떤 것에 대한 참조도 아니다:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

impl 뒤에 수명 매개변수를 선언하고 타입 이름 뒤에 사용하는 것은 필수적이지만, 첫 번째 생략 규칙 때문에 self 참조의 수명을 주석으로 달 필요는 없다.

다음은 세 번째 수명 생략 규칙이 적용되는 예제다:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

두 개의 입력 수명이 있으므로, Rust는 첫 번째 수명 생략 규칙을 적용해 &selfannouncement에 각각의 수명을 부여한다. 그리고 매개변수 중 하나가 &self이므로, 반환 타입은 &self의 수명을 가지며, 모든 수명이 처리된다.

정적 라이프타임

특별히 다뤄야 할 라이프타임 중 하나는 'static이다. 이 라이프타임은 해당 참조가 프로그램 전체 실행 기간 동안 살아있을 수 있음을 나타낸다. 모든 문자열 리터럴은 'static 라이프타임을 가지며, 다음과 같이 명시할 수 있다:

#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}

이 문자열의 텍스트는 프로그램의 바이너리에 직접 저장되며, 항상 사용 가능하다. 따라서 모든 문자열 리터럴의 라이프타임은 'static이다.

에러 메시지에서 'static 라이프타임을 사용하라는 제안을 볼 수 있다. 하지만 참조에 'static 라이프타임을 지정하기 전에, 해당 참조가 실제로 프로그램 전체 실행 기간 동안 유효한지, 그리고 그렇게 의도한 것인지 고려해야 한다. 대부분의 경우, 'static 라이프타임을 제안하는 에러 메시지는 댕글링 참조를 만들려고 하거나 사용 가능한 라이프타임이 맞지 않을 때 발생한다. 이러한 경우, 해결책은 'static 라이프타임을 지정하는 것이 아니라 문제를 해결하는 것이다.

제네릭 타입 매개변수, 트레이트 바운드, 라이프타임 함께 사용하기

제네릭 타입 매개변수, 트레이트 바운드, 라이프타임을 한 함수에서 모두 지정하는 문법을 간단히 살펴보자!

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {result}");
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {ann}");
    if x.len() > y.len() { x } else { y }
}

이 코드는 리스트 10-21의 longest 함수로, 두 문자열 슬라이스 중 더 긴 것을 반환한다. 하지만 이제는 ann이라는 추가 매개변수가 있는데, 이는 where 절에 지정된 대로 Display 트레이트를 구현한 어떤 타입이든 될 수 있는 제네릭 타입 T이다. 이 추가 매개변수는 {}를 사용해 출력되기 때문에 Display 트레이트 바운드가 필요하다. 라이프타임도 제네릭의 일종이므로, 라이프타임 매개변수 'a와 제네릭 타입 매개변수 T의 선언은 함수 이름 뒤의 꺾쇠 괄호 안에 같은 목록에 들어간다.

요약

이번 장에서는 많은 내용을 다뤘다. 여러분은 이제 제네릭 타입 매개변수, 트레이트와 트레이트 바운드, 제네릭 라이프타임 매개변수에 대해 알게 되었다. 이를 통해 다양한 상황에서 동작하는 반복 없는 코드를 작성할 준비가 되었다. 제네릭 타입 매개변수는 코드를 다양한 타입에 적용할 수 있게 해준다. 트레이트와 트레이트 바운드는 타입이 제네릭임에도 불구하고 코드가 필요로 하는 동작을 보장한다. 또한 라이프타임 어노테이션을 사용해 이 유연한 코드가 댕글링 참조를 발생시키지 않도록 하는 방법도 배웠다. 이 모든 분석은 컴파일 타임에 이루어지기 때문에 런타임 성능에 영향을 미치지 않는다!

믿기 어려울지 모르지만, 이번 장에서 다룬 주제에는 더 많은 내용이 있다. 18장에서는 트레이트를 사용하는 또 다른 방법인 트레이트 객체에 대해 다룬다. 또한 매우 고급 시나리오에서만 필요한 라이프타임 어노테이션과 관련된 더 복잡한 경우도 있다. 이런 경우에는 Rust Reference를 참고해야 한다. 하지만 다음으로는 Rust에서 테스트를 작성하는 방법을 배우게 될 것이다. 이를 통해 코드가 의도한 대로 동작하는지 확인할 수 있다.

자동화된 테스트 작성

1972년 에츠허르 데이크스트라는 “The Humble Programmer“라는 에세이에서 “프로그램 테스트는 버그의 존재를 보여주는 데 매우 효과적이지만, 버그가 없다는 것을 보여주기에는 부적합하다“고 말했다. 하지만 그렇다고 해서 테스트를 최대한 많이 해보지 말라는 뜻은 아니다!

프로그램의 정확성은 코드가 우리가 의도한 대로 동작하는 정도를 의미한다. Rust는 프로그램의 정확성에 대해 높은 관심을 가지고 설계되었지만, 정확성을 증명하는 것은 복잡하고 쉽지 않다. Rust의 타입 시스템은 이러한 부담의 상당 부분을 떠안고 있지만, 타입 시스템만으로는 모든 것을 잡아낼 수 없다. 그래서 Rust는 자동화된 소프트웨어 테스트 작성을 지원한다.

예를 들어, 어떤 숫자에 2를 더하는 add_two 함수를 작성한다고 가정해보자. 이 함수의 시그니처는 정수를 매개변수로 받고 정수를 반환한다. 이 함수를 구현하고 컴파일하면, Rust는 지금까지 배운 타입 검사와 borrow 검사를 모두 수행하여, 예를 들어 String 값이나 잘못된 참조를 이 함수에 전달하지 않도록 보장한다. 하지만 Rust는 이 함수가 정확히 우리가 의도한 대로 동작하는지, 즉 매개변수에 2를 더하는지, 아니면 10을 더하거나 50을 빼는지 확인할 수 없다! 바로 여기서 테스트가 필요하다.

예를 들어, add_two 함수에 3을 전달했을 때 반환 값이 5인지 확인하는 테스트를 작성할 수 있다. 코드를 변경할 때마다 이러한 테스트를 실행하여 기존의 정상 동작이 변경되지 않았는지 확인할 수 있다.

테스트는 복잡한 기술이다. 이 한 장에서 좋은 테스트를 작성하는 방법의 모든 세부 사항을 다룰 수는 없지만, 이 장에서는 Rust의 테스트 기능에 대해 논의할 것이다. 테스트 작성 시 사용할 수 있는 어노테이션과 매크로, 테스트 실행 시 제공되는 기본 동작과 옵션, 그리고 테스트를 단위 테스트와 통합 테스트로 조직화하는 방법에 대해 이야기할 것이다.

테스트 작성 방법

테스트는 러스트 함수로, 테스트 대상 코드가 예상대로 동작하는지 검증한다. 테스트 함수의 본문은 일반적으로 다음 세 가지 작업을 수행한다:

  • 필요한 데이터나 상태를 설정한다.
  • 테스트할 코드를 실행한다.
  • 결과가 예상과 일치하는지 확인한다.

이제 러스트가 제공하는 테스트 작성 기능을 살펴보자. test 속성, 몇 가지 매크로, 그리고 should_panic 속성을 활용해 위의 작업을 수행할 수 있다.

테스트 함수의 구조

Rust에서 테스트는 test 속성(attribute)으로 주석 처리된 함수다. 속성은 Rust 코드에 대한 메타데이터이며, 예를 들어 5장에서 구조체와 함께 사용한 derive 속성이 그 예다. 함수를 테스트 함수로 바꾸려면 fn 앞에 #[test]를 추가한다. cargo test 명령어로 테스트를 실행하면, Rust는 테스트 러너 바이너리를 빌드하고, 주석 처리된 함수를 실행한 뒤 각 테스트 함수의 성공 여부를 보고한다.

Cargo로 새로운 라이브러리 프로젝트를 만들 때마다, 테스트 모듈과 그 안에 테스트 함수가 자동으로 생성된다. 이 모듈은 테스트 작성을 위한 템플릿을 제공하므로, 새로운 프로젝트를 시작할 때마다 정확한 구조와 문법을 찾아볼 필요가 없다. 원하는 만큼 추가 테스트 함수와 테스트 모듈을 추가할 수 있다.

실제 코드를 테스트하기 전에, 템플릿 테스트를 실험하면서 테스트가 어떻게 동작하는지 몇 가지 측면을 살펴본다. 그런 다음, 작성한 코드를 호출하고 그 동작이 올바른지 확인하는 실제 테스트를 작성한다.

두 숫자를 더하는 adder라는 새로운 라이브러리 프로젝트를 만들어보자:

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

adder 라이브러리의 src/lib.rs 파일 내용은 다음과 같다.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-1: cargo new로 자동 생성된 코드

파일은 예시 add 함수로 시작하므로, 테스트할 무언가가 있다.

일단 it_works 함수에 집중해보자. #[test] 주석에 주목한다. 이 속성은 이 함수가 테스트 함수임을 나타내므로, 테스트 러너가 이 함수를 테스트로 취급한다. 테스트 모듈에는 공통 시나리오 설정이나 일반적인 작업 수행을 돕는 비테스트 함수도 있을 수 있으므로, 항상 어떤 함수가 테스트인지 표시해야 한다.

예시 함수 본문은 assert_eq! 매크로를 사용해 result가 2와 2를 더한 결과인 4와 같은지 확인한다. 이 단언은 일반적인 테스트의 형식을 보여주는 예시다. 이 테스트가 통과하는지 확인하기 위해 실행해보자.

cargo test 명령어는 프로젝트의 모든 테스트를 실행하며, 그 결과는 다음과 같다.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
     Running unittests src/lib.rs (target/debug/deps/adder-01ad14159ff659ab)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Listing 11-2: 자동 생성된 테스트 실행 결과

Cargo가 테스트를 컴파일하고 실행했다. running 1 test 줄을 볼 수 있다. 다음 줄은 생성된 테스트 함수 이름인 tests::it_works를 보여주고, 그 테스트 실행 결과가 ok임을 나타낸다. 전체 요약인 test result: ok.는 모든 테스트가 통과했음을 의미하며, 1 passed; 0 failed 부분은 통과하거나 실패한 테스트의 수를 나타낸다.

특정 인스턴스에서 실행되지 않도록 테스트를 무시(ignore)할 수도 있다. 이에 대해서는 이 장의 뒷부분에서 다룬다. 여기서는 그렇게 하지 않았으므로, 요약에 0 ignored가 표시된다. 또한 cargo test 명령어에 인자를 전달해 이름이 특정 문자열과 일치하는 테스트만 실행할 수도 있다. 이를 _필터링_이라고 하며, 이에 대해서도 나중에 다룬다. 여기서는 테스트를 필터링하지 않았으므로, 요약 끝에 0 filtered out이 표시된다.

0 measured 통계는 성능을 측정하는 벤치마크 테스트를 위한 것이다. 이 글을 쓰는 시점에서 벤치마크 테스트는 nightly Rust에서만 사용할 수 있다. 벤치마크 테스트에 대한 자세한 내용은 문서를 참고한다.

테스트 출력의 다음 부분인 Doc-tests adder는 문서 테스트의 결과를 나타낸다. 아직 문서 테스트는 없지만, Rust는 API 문서에 나타나는 모든 코드 예제를 컴파일할 수 있다. 이 기능은 문서와 코드를 동기화하는 데 도움이 된다! 문서 테스트 작성 방법은 14장의 “테스트로서의 문서 주석” 섹션에서 다룬다. 지금은 Doc-tests 출력을 무시한다.

이제 테스트를 우리의 필요에 맞게 커스터마이징해보자. 먼저 it_works 함수의 이름을 exploration과 같이 다른 이름으로 바꾼다:

Filename: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

그런 다음 cargo test를 다시 실행한다. 이제 출력에 it_works 대신 exploration이 표시된다:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.59s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

이제 다른 테스트를 추가하지만, 이번에는 실패하는 테스트를 만들어보자! 테스트 함수 내에서 무언가 패닉(panic)이 발생하면 테스트가 실패한다. 각 테스트는 새로운 스레드에서 실행되며, 메인 스레드가 테스트 스레드가 죽은 것을 발견하면 테스트는 실패한 것으로 표시된다. 9장에서 패닉을 발생시키는 가장 간단한 방법은 panic! 매크로를 호출하는 것이라고 했다. another라는 이름의 함수로 새 테스트를 추가하면 src/lib.rs 파일은 다음과 같다.

Filename: src/lib.rs
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exploration() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}
Listing 11-3: panic! 매크로를 호출하여 실패하도록 만든 두 번째 테스트 추가

cargo test로 테스트를 다시 실행한다. 출력은 다음과 같으며, exploration 테스트는 통과하고 another 테스트는 실패했다.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok

failures:

---- tests::another stdout ----

thread 'tests::another' panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`
Listing 11-4: 하나의 테스트는 통과하고 다른 하나는 실패한 테스트 결과

ok 대신 test tests::another 줄에 FAILED가 표시된다. 개별 결과와 요약 사이에 두 개의 새로운 섹션이 나타난다. 첫 번째 섹션은 각 테스트 실패의 상세한 이유를 보여준다. 이 경우, another 테스트가 src/lib.rs 파일의 17번째 줄에서 panicked at 'Make this test fail'로 인해 실패했다는 상세 정보를 얻을 수 있다. 다음 섹션은 실패한 모든 테스트의 이름만 나열한다. 이는 많은 테스트와 상세한 실패 테스트 출력이 있을 때 유용하다. 실패한 테스트의 이름을 사용해 해당 테스트만 실행하면 디버깅이 더 쉬워진다. 테스트 실행 방법에 대해서는 “테스트 실행 방법 제어” 섹션에서 더 자세히 다룬다.

요약 줄은 끝에 표시된다. 전체적으로 테스트 결과는 FAILED다. 하나의 테스트는 통과했고, 다른 하나는 실패했다.

이제 다양한 시나리오에서 테스트 결과가 어떻게 보이는지 확인했으니, panic! 이외의 테스트에 유용한 다른 매크로를 살펴보자.

assert! 매크로로 결과 확인하기

표준 라이브러리에서 제공하는 assert! 매크로는 테스트에서 특정 조건이 true로 평가되는지 확인할 때 유용하다. assert! 매크로에 부울 값으로 평가되는 인자를 전달한다. 값이 true이면 아무 일도 일어나지 않고 테스트가 통과한다. 값이 false이면 assert! 매크로가 panic!을 호출해 테스트를 실패시킨다. assert! 매크로를 사용하면 코드가 의도한 대로 동작하는지 확인할 수 있다.

5장의 리스팅 5-15에서 Rectangle 구조체와 can_hold 메서드를 사용했는데, 이를 리스팅 11-5에서 다시 보여준다. 이 코드를 src/lib.rs 파일에 넣고, assert! 매크로를 사용해 테스트를 작성해보자.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}
Listing 11-5: 5장의 Rectangle 구조체와 can_hold 메서드

can_hold 메서드는 부울 값을 반환하므로 assert! 매크로를 사용하기에 적합하다. 리스팅 11-6에서는 can_hold 메서드를 테스트하기 위해 너비가 8이고 높이가 7인 Rectangle 인스턴스를 생성하고, 너비가 5이고 높이가 1인 다른 Rectangle 인스턴스를 담을 수 있는지 확인하는 테스트를 작성한다.

Filename: src/lib.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }
}
Listing 11-6: 더 큰 직사각형이 더 작은 직사각형을 담을 수 있는지 확인하는 can_hold 테스트

tests 모듈 안의 use super::*; 줄을 주목하자. tests 모듈은 7장의 “모듈 트리에서 항목을 참조하는 경로” 섹션에서 다룬 일반적인 가시성 규칙을 따른다. tests 모듈은 내부 모듈이므로, 외부 모듈의 테스트 대상 코드를 내부 모듈의 스코프로 가져와야 한다. 여기서는 glob을 사용해 외부 모듈에 정의된 모든 항목을 tests 모듈에서 사용할 수 있도록 한다.

테스트 이름을 larger_can_hold_smaller로 지정하고, 필요한 두 Rectangle 인스턴스를 생성했다. 그런 다음 assert! 매크로를 호출하고 larger.can_hold(&smaller)의 결과를 전달했다. 이 표현식은 true를 반환할 것이므로 테스트가 통과할 것이다. 결과를 확인해보자!

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

테스트가 통과했다! 이번에는 더 작은 직사각형이 더 큰 직사각형을 담을 수 없는지 확인하는 테스트를 추가해보자:

Filename: src/lib.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        // --snip--
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

이 경우 can_hold 함수의 올바른 결과는 false이므로, assert! 매크로에 전달하기 전에 결과를 부정해야 한다. 결과적으로 can_holdfalse를 반환하면 테스트가 통과한다:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests rectangle

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

두 테스트가 모두 통과했다! 이제 코드에 버그를 도입했을 때 테스트 결과가 어떻게 되는지 살펴보자. can_hold 메서드의 구현을 변경해 너비를 비교할 때 ‘보다 큼’ 기호를 ‘보다 작음’ 기호로 바꿔보자:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

// --snip--
impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width < other.width && self.height > other.height
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 8,
            height: 7,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

이제 테스트를 실행하면 다음과 같은 결과가 나온다:

$ cargo test
   Compiling rectangle v0.1.0 (file:///projects/rectangle)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/rectangle-6584c4561e48942e)

running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok

failures:

---- tests::larger_can_hold_smaller stdout ----

thread 'tests::larger_can_hold_smaller' panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

테스트가 버그를 잡았다! larger.width8이고 smaller.width5이므로, can_hold에서 너비를 비교할 때 false를 반환한다: 8은 5보다 작지 않다.

assert_eq!assert_ne! 매크로를 사용한 동등성 테스트

코드의 기능을 검증하는 일반적인 방법은 테스트 중인 코드의 결과와 예상 값을 비교하는 것이다. 이를 위해 assert! 매크로를 사용하고 == 연산자를 포함한 표현식을 전달할 수 있다. 하지만 이는 매우 일반적인 테스트이기 때문에, 표준 라이브러리는 이를 더 편리하게 수행할 수 있는 두 가지 매크로인 assert_eq!assert_ne!를 제공한다. 이 매크로는 각각 두 인자의 동등성 또는 비동등성을 비교한다. 또한, 테스트가 실패할 경우 두 값을 출력하므로, 테스트가 실패한 이유를 쉽게 파악할 수 있다. 반면, assert! 매크로는 == 표현식이 false 값을 반환했음을 알려줄 뿐, 어떤 값 때문에 false가 발생했는지는 출력하지 않는다.

리스트 11-7에서는 add_two라는 함수를 작성하고, 이 함수에 2를 더한 결과를 assert_eq! 매크로를 사용해 테스트한다.

Filename: src/lib.rs
pub fn add_two(a: usize) -> usize {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}
Listing 11-7: assert_eq! 매크로를 사용해 add_two 함수 테스트

테스트가 통과하는지 확인해보자.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

result라는 변수를 생성하고, add_two(2)를 호출한 결과를 저장한다. 그런 다음 result4assert_eq!의 인자로 전달한다. 이 테스트의 출력 줄은 test tests::it_adds_two ... ok이며, ok 텍스트는 테스트가 통과했음을 나타낸다!

이제 코드에 버그를 추가하여 assert_eq!가 실패할 때 어떻게 보이는지 확인해보자. add_two 함수의 구현을 변경하여 3을 더하도록 한다:

pub fn add_two(a: usize) -> usize {
    a + 3
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }
}

테스트를 다시 실행한다:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----

thread 'tests::it_adds_two' panicked at src/lib.rs:12:9:
assertion `left == right` failed
  left: 5
 right: 4
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

테스트가 버그를 잡았다! it_adds_two 테스트가 실패했으며, 실패한 어설션은 assertion `left == right` failed라는 메시지와 함께 leftright 값을 보여준다. 이 메시지는 디버깅을 시작하는 데 도움이 된다: left 인자는 add_two(2)를 호출한 결과인 5였고, right 인자는 4였다. 이는 특히 많은 테스트가 진행 중일 때 매우 유용할 것이다.

일부 언어와 테스트 프레임워크에서는 동등성 어설션 함수의 매개변수를 expectedactual로 부르며, 인자를 지정하는 순서가 중요하다. 그러나 Rust에서는 이를 leftright로 부르며, 예상 값과 코드가 생성한 값을 지정하는 순서는 중요하지 않다. 이 테스트에서 어설션을 assert_eq!(add_two(2), result)로 작성할 수도 있으며, 이는 동일한 실패 메시지를 출력할 것이다.

assert_ne! 매크로는 두 값이 같지 않으면 통과하고, 같으면 실패한다. 이 매크로는 값이 무엇인지 확실히 알 수 없지만, 값이 분명히 무엇이 아니어야 하는 경우에 가장 유용하다. 예를 들어, 입력을 어떤 방식으로든 변경하는 함수를 테스트할 때, 입력이 변경되는 방식이 테스트를 실행하는 요일에 따라 달라질 수 있다면, 함수의 출력이 입력과 같지 않음을 어설션하는 것이 최선일 수 있다.

내부적으로 assert_eq!assert_ne! 매크로는 각각 ==!= 연산자를 사용한다. 어설션이 실패할 경우, 이 매크로는 디버그 포맷팅을 사용해 인자를 출력하므로, 비교되는 값은 PartialEqDebug 트레이트를 구현해야 한다. 모든 기본 타입과 대부분의 표준 라이브러리 타입은 이 트레이트를 구현한다. 직접 정의한 구조체나 열거형의 경우, 이 타입들의 동등성을 어설션하기 위해 PartialEq를 구현해야 한다. 또한 어설션이 실패할 때 값을 출력하기 위해 Debug도 구현해야 한다. 두 트레이트는 모두 파생 가능한 트레이트이므로, 구조체나 열거형 정의에 #[derive(PartialEq, Debug)] 어노테이션을 추가하는 것만으로도 충분하다. 이와 다른 파생 가능한 트레이트에 대한 자세한 내용은 부록 C, “파생 가능한 트레이트”를 참조하라.

커스텀 실패 메시지 추가하기

assert!, assert_eq!, 그리고 assert_ne! 매크로에 선택적 인자로 커스텀 메시지를 추가할 수 있다. 필수 인자 이후에 지정된 모든 인자는 format! 매크로로 전달되므로, {} 자리 표시자를 포함한 포맷 문자열과 해당 자리 표시자에 들어갈 값을 전달할 수 있다. 커스텀 메시지는 테스트가 실패했을 때 문제를 더 잘 이해할 수 있도록 도와준다.

예를 들어, 이름을 받아 사람들에게 인사하는 함수가 있고, 이 함수에 전달한 이름이 출력에 포함되는지 테스트하고 싶다고 가정해 보자.

파일명: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {name}!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

이 프로그램의 요구사항이 아직 확정되지 않았으며, 인사말의 시작 부분에 있는 Hello 텍스트가 변경될 가능성이 높다. 요구사항이 변경될 때마다 테스트를 업데이트하지 않기 위해, greeting 함수가 반환한 값과 정확히 일치하는지 확인하는 대신, 출력에 입력 매개변수의 텍스트가 포함되어 있는지 확인하기로 결정했다.

이제 greeting 함수를 수정하여 name을 제외하고 버그를 도입해 보자. 이렇게 하면 기본 테스트 실패 메시지가 어떻게 나타나는지 확인할 수 있다.

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

이 테스트를 실행하면 다음과 같은 결과가 나타난다.

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
assertion failed: result.contains("Carol")
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

이 결과는 단순히 테스트가 실패했고, 어느 줄에서 실패했는지 알려준다. 더 유용한 실패 메시지는 greeting 함수에서 반환된 값을 출력하는 것이다. greeting 함수에서 실제로 얻은 값을 포맷 문자열에 포함시켜 커스텀 실패 메시지를 추가해 보자.

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(
            result.contains("Carol"),
            "Greeting did not contain name, value was `{result}`"
        );
    }
}

이제 테스트를 실행하면 더 많은 정보를 담은 에러 메시지를 확인할 수 있다.

$ cargo test
   Compiling greeter v0.1.0 (file:///projects/greeter)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.93s
     Running unittests src/lib.rs (target/debug/deps/greeter-170b942eb5bf5e3a)

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----

thread 'tests::greeting_contains_name' panicked at src/lib.rs:12:9:
Greeting did not contain name, value was `Hello!`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::greeting_contains_name

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

테스트 출력에서 실제로 얻은 값을 확인할 수 있으므로, 예상과 다른 상황에서 무엇이 잘못되었는지 디버깅하는 데 도움이 된다.

should_panic으로 패닉 상태 확인하기

반환 값을 확인하는 것 외에도, 코드가 예상대로 에러 상황을 처리하는지 확인하는 것도 중요하다. 예를 들어, 9장의 리스팅 9-13에서 생성한 Guess 타입을 생각해 보자. Guess를 사용하는 다른 코드들은 Guess 인스턴스가 1에서 100 사이의 값만 포함한다는 보장에 의존한다. 이 범위를 벗어난 값으로 Guess 인스턴스를 생성하려고 할 때 패닉이 발생하는지 확인하는 테스트를 작성할 수 있다.

이를 위해 테스트 함수에 should_panic 속성을 추가한다. 함수 내부의 코드가 패닉을 일으키면 테스트는 통과하고, 패닉이 발생하지 않으면 테스트는 실패한다.

리스팅 11-8은 Guess::new의 에러 조건이 예상대로 발생하는지 확인하는 테스트를 보여준다.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-8: panic!을 일으키는 조건 테스트

#[should_panic] 속성은 #[test] 속성 뒤에, 그리고 적용할 테스트 함수 앞에 위치시킨다. 이 테스트가 통과할 때의 결과를 살펴보자:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests guessing_game

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

잘 동작한다! 이제 new 함수가 값이 100보다 클 때 패닉을 일으키는 조건을 제거하여 버그를 만들어 보자:

pub struct Guess {
    value: i32,
}

// --snip--
impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!("Guess value must be between 1 and 100, got {value}.");
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

리스팅 11-8의 테스트를 실행하면 실패한다:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----
note: test did not panic as expected

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

이 경우에는 도움이 되는 메시지를 얻지 못하지만, 테스트 함수를 보면 #[should_panic]으로 주석이 달려 있다. 실패 메시지는 테스트 함수 내의 코드가 패닉을 일으키지 않았다는 것을 의미한다.

should_panic을 사용한 테스트는 정확하지 않을 수 있다. 테스트가 예상과 다른 이유로 패닉을 일으켜도 should_panic 테스트는 통과할 수 있다. should_panic 테스트를 더 정확하게 만들기 위해 should_panic 속성에 expected 매개변수를 추가할 수 있다. 테스트 도구는 실패 메시지에 제공된 텍스트가 포함되어 있는지 확인한다. 예를 들어, 리스팅 11-9에서 new 함수가 값이 너무 작은지 큰지에 따라 다른 메시지로 패닉을 일으키도록 수정된 Guess 코드를 살펴보자.

Filename: src/lib.rs
pub struct Guess {
    value: i32,
}

// --snip--

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}
Listing 11-9: 지정된 부분 문자열을 포함하는 panic! 메시지 테스트

이 테스트는 should_panic 속성의 expected 매개변수에 넣은 값이 Guess::new 함수가 패닉을 일으킬 때의 메시지의 부분 문자열이기 때문에 통과한다. 예상하는 전체 패닉 메시지를 지정할 수도 있는데, 이 경우에는 Guess value must be less than or equal to 100, got 200이 될 것이다. 어떤 부분을 지정할지는 패닉 메시지의 고유하거나 동적인 부분이 얼마나 되는지, 그리고 테스트를 얼마나 정확하게 만들고 싶은지에 따라 달라진다. 이 경우에는 패닉 메시지의 부분 문자열만으로도 테스트 함수가 else if value > 100 경우를 실행하는지 확인하기에 충분하다.

expected 메시지가 있는 should_panic 테스트가 실패할 때 어떤 일이 발생하는지 보기 위해, if value < 1else if value > 100 블록의 본문을 바꾸어 버그를 다시 만들어 보자:

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 {
            panic!(
                "Guess value must be less than or equal to 100, got {value}."
            );
        } else if value > 100 {
            panic!(
                "Guess value must be greater than or equal to 1, got {value}."
            );
        }

        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

이번에 should_panic 테스트를 실행하면 실패한다:

$ cargo test
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.66s
     Running unittests src/lib.rs (target/debug/deps/guessing_game-57d70c3acb738f4d)

running 1 test
test tests::greater_than_100 - should panic ... FAILED

failures:

---- tests::greater_than_100 stdout ----

thread 'tests::greater_than_100' panicked at src/lib.rs:12:13:
Guess value must be greater than or equal to 1, got 200.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
note: panic did not contain expected string
      panic message: `"Guess value must be greater than or equal to 1, got 200."`,
 expected substring: `"less than or equal to 100"`

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

실패 메시지는 이 테스트가 예상대로 패닉을 일으켰지만, 패닉 메시지에 예상한 문자열 less than or equal to 100이 포함되어 있지 않다는 것을 나타낸다. 이 경우에 얻은 패닉 메시지는 Guess value must be greater than or equal to 1, got 200.이다. 이제 버그가 어디에 있는지 파악할 수 있다!

Result<T, E>를 테스트에서 사용하기

지금까지 작성한 테스트는 실패할 때 패닉을 일으켰다. Result<T, E>를 사용하는 테스트도 작성할 수 있다. 리스트 11-1의 테스트를 Result<T, E>를 사용하도록 수정하고, 패닉 대신 Err를 반환하도록 바꿔보자:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() -> Result<(), String> {
        let result = add(2, 2);

        if result == 4 {
            Ok(())
        } else {
            Err(String::from("two plus two does not equal four"))
        }
    }
}

it_works 함수는 이제 Result<(), String> 타입을 반환한다. 함수 본문에서 assert_eq! 매크로를 호출하는 대신, 테스트가 성공하면 Ok(())를 반환하고 실패하면 String이 포함된 Err를 반환한다.

테스트가 Result<T, E>를 반환하도록 작성하면 테스트 본문에서 물음표 연산자(?)를 사용할 수 있다. 이는 테스트 내부의 어떤 연산이 Err를 반환할 경우 테스트가 실패하도록 작성하는 편리한 방법이다.

Result<T, E>를 사용하는 테스트에서는 #[should_panic] 어노테이션을 사용할 수 없다. 연산이 Err를 반환하는지 확인하려면 Result<T, E> 값에 물음표 연산자를 사용하지 말고, 대신 assert!(value.is_err())를 사용한다.

이제 테스트를 작성하는 여러 방법을 알게 되었으니, 테스트를 실행할 때 어떤 일이 일어나는지 살펴보고 cargo test와 함께 사용할 수 있는 다양한 옵션을 탐구해보자.

테스트 실행 방식 제어하기

cargo run이 코드를 컴파일한 후 결과 바이너리를 실행하는 것처럼, cargo test는 코드를 테스트 모드로 컴파일한 후 결과 테스트 바이너리를 실행한다. cargo test로 생성된 바이너리의 기본 동작은 모든 테스트를 병렬로 실행하고, 테스트 실행 중 생성된 출력을 캡처하여 화면에 표시하지 않는다. 이렇게 하면 테스트 결과와 관련된 출력을 더 쉽게 읽을 수 있다. 하지만 커맨드라인 옵션을 지정해 이 기본 동작을 변경할 수 있다.

일부 커맨드라인 옵션은 cargo test에 전달되고, 다른 옵션은 결과 테스트 바이너리에 전달된다. 이 두 가지 유형의 인자를 구분하려면, cargo test에 전달할 인자를 먼저 나열한 후 구분자 --를 넣고, 그 다음에 테스트 바이너리에 전달할 인자를 나열한다. cargo test --help를 실행하면 cargo test와 함께 사용할 수 있는 옵션을 확인할 수 있고, cargo test -- --help를 실행하면 구분자 뒤에 사용할 수 있는 옵션을 확인할 수 있다. 이 옵션들은 rustc 책“Tests” 섹션에 문서화되어 있다.

테스트를 병렬 또는 순차적으로 실행하기

여러 테스트를 실행할 때 기본적으로 스레드를 사용해 병렬로 실행한다. 이렇게 하면 테스트가 더 빨리 완료되고 결과를 빠르게 확인할 수 있다. 하지만 테스트가 동시에 실행되기 때문에, 각 테스트가 서로 의존하거나 공유 상태(예: 현재 작업 디렉토리나 환경 변수와 같은 공유 환경)에 의존하지 않도록 주의해야 한다.

예를 들어, 각 테스트가 디스크에 _test-output.txt_라는 파일을 생성하고 데이터를 쓰는 코드를 실행한다고 가정하자. 그런 다음 각 테스트는 해당 파일의 데이터를 읽고 파일에 특정 값이 포함되어 있는지 확인한다. 이때 각 테스트는 서로 다른 값을 검사한다. 테스트가 동시에 실행되면 한 테스트가 파일을 쓰는 동안 다른 테스트가 파일을 덮어쓸 수 있다. 이 경우 두 번째 테스트는 코드가 잘못된 것이 아니라 병렬 실행 중에 테스트가 서로 간섭했기 때문에 실패할 수 있다. 이를 해결하는 한 가지 방법은 각 테스트가 서로 다른 파일에 쓰도록 하는 것이다. 또 다른 방법은 테스트를 하나씩 순차적으로 실행하는 것이다.

테스트를 병렬로 실행하고 싶지 않거나 사용할 스레드 수를 더 세밀하게 제어하고 싶다면 --test-threads 플래그와 사용할 스레드 수를 테스트 바이너리에 전달할 수 있다. 다음 예제를 살펴보자:

$ cargo test -- --test-threads=1

테스트 스레드 수를 1로 설정해 프로그램이 병렬 처리를 사용하지 않도록 한다. 하나의 스레드로 테스트를 실행하면 병렬로 실행할 때보다 시간이 더 오래 걸리지만, 테스트가 상태를 공유하더라도 서로 간섭하지 않는다.

함수 출력 보여주기

기본적으로 테스트가 성공하면, Rust의 테스트 라이브러리는 표준 출력에 출력된 모든 내용을 캡처한다. 예를 들어, 테스트에서 println!을 호출하고 테스트가 성공하면, 터미널에서 println!의 출력을 볼 수 없다. 대신 테스트가 성공했다는 메시지만 보게 된다. 테스트가 실패하면, 실패 메시지와 함께 표준 출력에 출력된 내용을 확인할 수 있다.

예를 들어, 리스트 11-10에는 매개변수의 값을 출력하고 10을 반환하는 단순한 함수가 있다. 이 함수에 대한 테스트 중 하나는 성공하고, 다른 하나는 실패한다.

Filename: src/lib.rs
fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {a}");
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(value, 10);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(value, 5);
    }
}
Listing 11-10: println!을 호출하는 함수에 대한 테스트

cargo test로 이 테스트를 실행하면 다음과 같은 출력을 볼 수 있다:

$ cargo test
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.58s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

출력 결과에서 I got the value 4라는 메시지를 찾을 수 없다. 이 메시지는 테스트가 성공할 때 출력되지만, 캡처되었다. 반면, 실패한 테스트의 출력인 I got the value 8은 테스트 요약 출력 부분에 나타나며, 테스트 실패의 원인도 함께 표시된다.

성공한 테스트의 출력값도 확인하고 싶다면, --show-output 플래그를 사용해 Rust에게 성공한 테스트의 출력도 보여주도록 지시할 수 있다:

$ cargo test -- --show-output

--show-output 플래그를 사용해 리스트 11-10의 테스트를 다시 실행하면, 다음과 같은 출력을 볼 수 있다:

$ cargo test -- --show-output
   Compiling silly-function v0.1.0 (file:///projects/silly-function)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/silly_function-160869f38cff9166)

running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok

successes:

---- tests::this_test_will_pass stdout ----
I got the value 4


successes:
    tests::this_test_will_pass

failures:

---- tests::this_test_will_fail stdout ----
I got the value 8

thread 'tests::this_test_will_fail' panicked at src/lib.rs:19:9:
assertion `left == right` failed
  left: 10
 right: 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

이름으로 테스트의 일부만 실행하기

전체 테스트 스위트를 실행하면 시간이 오래 걸릴 수 있다. 특정 코드 영역을 작업 중이라면 해당 코드와 관련된 테스트만 실행하고 싶을 수 있다. cargo test에 실행하려는 테스트의 이름을 인자로 전달하면 원하는 테스트만 선택적으로 실행할 수 있다.

테스트의 일부만 실행하는 방법을 보여주기 위해, 먼저 add_two 함수에 대한 세 가지 테스트를 작성한다. Listing 11-11에서 이를 확인할 수 있으며, 어떤 테스트를 실행할지 선택할 수 있다.

Filename: src/lib.rs
pub fn add_two(a: usize) -> usize {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_two_and_two() {
        let result = add_two(2);
        assert_eq!(result, 4);
    }

    #[test]
    fn add_three_and_two() {
        let result = add_two(3);
        assert_eq!(result, 5);
    }

    #[test]
    fn one_hundred() {
        let result = add_two(100);
        assert_eq!(result, 102);
    }
}
Listing 11-11: 서로 다른 이름을 가진 세 가지 테스트

앞서 보았듯이, 아무런 인자 없이 테스트를 실행하면 모든 테스트가 병렬로 실행된다:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.62s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 3 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

단일 테스트 실행하기

cargo test 명령어에 특정 테스트 함수의 이름을 전달하면 해당 테스트만 실행할 수 있다:

$ cargo test one_hundred
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.69s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s

one_hundred라는 이름의 테스트만 실행되었고, 나머지 두 테스트는 이름이 일치하지 않아 실행되지 않았다. 테스트 출력 결과 끝부분에 2 filtered out이라는 메시지가 표시되어 실행되지 않은 테스트가 두 개 있음을 알려준다.

이 방식으로는 여러 테스트의 이름을 동시에 지정할 수 없다. cargo test에 전달된 첫 번째 값만 사용되기 때문이다. 하지만 여러 테스트를 실행할 수 있는 방법이 있다.

여러 테스트를 실행하기 위한 필터링

테스트 이름의 일부를 지정하면 해당 값과 일치하는 이름을 가진 모든 테스트가 실행된다. 예를 들어, 두 테스트의 이름에 add가 포함되어 있으므로 cargo test add를 실행하면 해당 두 테스트만 실행할 수 있다:

$ cargo test add
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::add_three_and_two ... ok
test tests::add_two_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

이 커맨드는 이름에 add가 포함된 모든 테스트를 실행하고 one_hundred라는 이름의 테스트는 제외했다. 또한 테스트가 속한 모듈 이름도 테스트 이름의 일부가 되므로, 모듈 이름으로 필터링하면 해당 모듈의 모든 테스트를 실행할 수 있다.

특별히 요청하지 않는 한 일부 테스트 무시하기

때로는 특정 테스트를 실행하는 데 시간이 오래 걸릴 수 있다. 이 경우 cargo test를 실행할 때 대부분의 경우 해당 테스트를 제외하고 싶을 수 있다. 실행하고 싶은 모든 테스트를 인수로 나열하는 대신, 시간이 오래 걸리는 테스트에 ignore 속성을 추가해 제외할 수 있다.

파일명: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // code that takes an hour to run
    }
}

#[test] 뒤에 제외하고 싶은 테스트에 #[ignore] 줄을 추가한다. 이제 테스트를 실행하면 it_works는 실행되지만 expensive_test는 실행되지 않는다.

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 2 tests
test tests::expensive_test ... ignored
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

expensive_test 함수는 ignored로 표시된다. 만약 무시된 테스트만 실행하고 싶다면 cargo test -- --ignored를 사용할 수 있다.

$ cargo test -- --ignored
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

어떤 테스트를 실행할지 제어함으로써 cargo test 결과를 빠르게 확인할 수 있다. ignored 테스트의 결과를 확인할 필요가 있고, 결과를 기다릴 시간이 있다면 cargo test -- --ignored를 실행하면 된다. 무시된 테스트를 포함해 모든 테스트를 실행하고 싶다면 cargo test -- --include-ignored를 사용할 수 있다.

테스트 조직화

이 장의 시작 부분에서 언급했듯이, 테스트는 복잡한 분야이며 사람마다 사용하는 용어와 조직화 방식이 다르다. Rust 커뮤니티는 테스트를 크게 두 가지 범주로 나누어 생각한다: 단위 테스트와 통합 테스트. 단위 테스트는 작고 집중적이며, 한 번에 하나의 모듈을 독립적으로 테스트하고, 비공개 인터페이스도 테스트할 수 있다. 통합 테스트는 라이브러리 외부에서 이루어지며, 다른 외부 코드가 사용하는 방식과 동일하게 코드를 사용한다. 통합 테스트는 공개 인터페이스만을 사용하며, 하나의 테스트에서 여러 모듈을 동시에 테스트할 수도 있다.

두 종류의 테스트를 모두 작성하는 것은 라이브러리의 각 부분이 개별적으로 그리고 함께 작동할 때 기대한 대로 동작하는지 확인하는 데 중요하다.

유닛 테스트

유닛 테스트의 목적은 코드의 각 단위를 독립적으로 테스트하여 예상대로 작동하는지 여부를 빠르게 파악하는 것이다. 유닛 테스트는 테스트 대상 코드가 있는 파일과 동일한 src 디렉토리에 위치시킨다. 일반적인 관례는 각 파일에 tests라는 모듈을 만들어 테스트 함수를 포함시키고, 이 모듈에 cfg(test)를 어노테이션으로 추가하는 것이다.

테스트 모듈과 #[cfg(test)]

tests 모듈에 있는 #[cfg(test)] 어노테이션은 cargo test를 실행할 때만 테스트 코드를 컴파일하고 실행하도록 Rust에 지시한다. cargo build를 실행할 때는 테스트 코드가 포함되지 않는다. 이렇게 하면 라이브러리만 빌드하고자 할 때 컴파일 시간을 절약할 수 있으며, 테스트 코드가 포함되지 않기 때문에 최종 컴파일 결과물의 크기도 줄어든다. 통합 테스트는 별도의 디렉터리에 위치하기 때문에 #[cfg(test)] 어노테이션이 필요하지 않다. 반면, 단위 테스트는 코드와 같은 파일에 위치하기 때문에 #[cfg(test)]를 사용해 컴파일 결과에 포함되지 않도록 지정해야 한다.

이 장의 첫 번째 섹션에서 새로운 adder 프로젝트를 생성했을 때, Cargo가 자동으로 생성한 코드를 다시 살펴보자:

파일명: src/lib.rs

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

자동으로 생성된 tests 모듈에서 cfg 속성은 _configuration_을 의미하며, 특정 구성 옵션이 주어졌을 때만 다음 항목을 포함하도록 Rust에 지시한다. 이 경우 구성 옵션은 test이며, Rust가 테스트를 컴파일하고 실행하기 위해 제공한다. cfg 속성을 사용하면 cargo test로 테스트를 실행할 때만 테스트 코드를 컴파일한다. 이는 #[test]로 어노테이션된 함수뿐만 아니라 이 모듈 내에 있는 모든 헬퍼 함수도 포함한다.

비공개 함수 테스트

테스트 커뮤니티에서는 비공개 함수를 직접 테스트해야 하는지에 대한 논쟁이 있다. 다른 프로그래밍 언어에서는 비공개 함수를 테스트하기 어렵거나 불가능한 경우가 많다. 그러나 Rust의 접근 제어 규칙은 비공개 함수도 테스트할 수 있도록 허용한다. 비공개 함수 internal_adder를 포함한 Listing 11-12의 코드를 살펴보자.

Filename: src/lib.rs
pub fn add_two(a: usize) -> usize {
    internal_adder(a, 2)
}

fn internal_adder(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-12: 비공개 함수 테스트

internal_adder 함수는 pub으로 표시되지 않았다. 테스트는 단순히 Rust 코드이며, tests 모듈은 또 다른 모듈일 뿐이다. “모듈 트리에서 항목 참조하기”에서 논의한 바와 같이, 자식 모듈의 항목은 상위 모듈의 항목을 사용할 수 있다. 이 테스트에서는 use super::*를 통해 tests 모듈의 상위 모듈 항목을 스코프로 가져오고, internal_adder를 호출할 수 있다. 비공개 함수를 테스트하지 않으려는 경우, Rust는 이를 강제하지 않는다.

통합 테스트

Rust에서 통합 테스트는 라이브러리 외부에 완전히 분리되어 있다. 통합 테스트는 다른 코드와 마찬가지로 라이브러리를 사용하며, 이는 라이브러리의 공개 API에 속한 함수만 호출할 수 있다는 의미다. 통합 테스트의 목적은 라이브러리의 여러 부분이 올바르게 함께 작동하는지 확인하는 것이다. 각각의 코드 단위가 독립적으로는 정상적으로 작동하더라도 통합 시 문제가 발생할 수 있으므로, 통합된 코드에 대한 테스트 커버리지도 중요하다. 통합 테스트를 생성하려면 먼저 tests 디렉토리가 필요하다.

tests 디렉토리

프로젝트 디렉토리의 최상위 레벨에 src 디렉토리 옆에 tests 디렉토리를 만든다. Cargo는 이 디렉토리에서 통합 테스트 파일을 찾는다. 필요한 만큼 테스트 파일을 만들 수 있으며, Cargo는 각 파일을 개별 크레이트로 컴파일한다.

통합 테스트를 만들어 보자. src/lib.rs 파일에 있는 코드를 그대로 유지한 상태에서 tests 디렉토리를 만들고, tests/integration_test.rs 파일을 생성한다. 디렉토리 구조는 다음과 같아야 한다:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

tests/integration_test.rs 파일에 다음 코드를 입력한다.

Filename: tests/integration_test.rs
use adder::add_two;

#[test]
fn it_adds_two() {
    let result = add_two(2);
    assert_eq!(result, 4);
}
Listing 11-13: adder 크레이트의 함수를 테스트하는 통합 테스트

tests 디렉토리의 각 파일은 별도의 크레이트이므로, 라이브러리를 각 테스트 크레이트의 스코프로 가져와야 한다. 그래서 코드 상단에 use adder::add_two;를 추가한다. 이 과정은 단위 테스트에서는 필요하지 않다.

tests/integration_test.rs 파일의 코드에 #[cfg(test)]를 붙일 필요는 없다. Cargo는 tests 디렉토리를 특별히 취급하며, cargo test를 실행할 때만 이 디렉토리의 파일을 컴파일한다. 이제 cargo test를 실행해 보자:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

출력 결과는 세 부분으로 나뉜다: 단위 테스트, 통합 테스트, 문서 테스트. 한 섹션의 테스트가 실패하면 다음 섹션은 실행되지 않는다. 예를 들어, 단위 테스트가 실패하면 통합 테스트와 문서 테스트는 실행되지 않는다.

단위 테스트 섹션의 첫 부분은 이전과 동일하다: 각 단위 테스트에 대한 한 줄(Listing 11-12에서 추가한 internal 테스트)과 단위 테스트 결과 요약 줄이 표시된다.

통합 테스트 섹션은 Running tests/integration_test.rs 줄로 시작한다. 그 다음에는 통합 테스트 파일의 각 테스트 함수에 대한 줄과 통합 테스트 결과 요약 줄이 표시된다. 그 후 Doc-tests adder 섹션이 시작된다.

각 통합 테스트 파일은 별도의 섹션을 가지므로, tests 디렉토리에 더 많은 파일을 추가하면 통합 테스트 섹션도 늘어난다.

특정 통합 테스트 함수만 실행하려면 cargo test에 테스트 함수 이름을 인자로 전달한다. 특정 통합 테스트 파일의 모든 테스트를 실행하려면 cargo test--test 인자 뒤에 파일 이름을 붙인다:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

이 명령어는 tests/integration_test.rs 파일의 테스트만 실행한다.

통합 테스트에서의 서브모듈

통합 테스트를 추가하면서, 테스트를 체계적으로 관리하기 위해 tests 디렉터리에 더 많은 파일을 만들고 싶을 수 있다. 예를 들어, 테스트 기능에 따라 테스트 함수를 그룹화할 수 있다. 앞서 언급했듯이, tests 디렉터리의 각 파일은 별도의 크레이트로 컴파일된다. 이는 엔드 유저가 크레이트를 사용하는 방식을 더 정확히 모방하기 위해 별도의 스코프를 만드는 데 유용하다. 하지만 이는 tests 디렉터리의 파일들이 src 디렉터리의 파일들과 동일한 동작을 공유하지 않는다는 것을 의미한다. 이는 7장에서 코드를 모듈과 파일로 분리하는 방법을 배울 때 이미 다룬 내용이다.

tests 디렉터리 파일들의 다른 동작은 여러 통합 테스트 파일에서 사용할 헬퍼 함수 세트가 있고, 이를 공통 모듈로 추출하기 위해 7장의 “모듈을 다른 파일로 분리하기” 섹션의 단계를 따르려고 할 때 가장 두드러진다. 예를 들어, tests/common.rs 파일을 만들고 그 안에 setup이라는 함수를 추가한다면, 여러 테스트 파일의 여러 테스트 함수에서 호출하고 싶은 코드를 setup에 추가할 수 있다:

파일명: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

테스트를 다시 실행하면, common.rs 파일에 대한 새로운 섹션이 테스트 출력에 나타난다. 이 파일은 테스트 함수를 포함하지도 않았고, setup 함수를 어디에서도 호출하지 않았음에도 불구하고:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

common이 테스트 결과에 나타나고 running 0 tests가 표시되는 것은 원하는 바가 아니다. 단지 다른 통합 테스트 파일과 일부 코드를 공유하고 싶었을 뿐이다. common이 테스트 출력에 나타나지 않도록 하려면, tests/common.rs 파일을 만드는 대신 tests/common/mod.rs 파일을 만든다. 이제 프로젝트 디렉터리는 다음과 같이 보인다:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

이것은 7장의 “대체 파일 경로”에서 언급한 Rust가 이해하는 오래된 명명 규칙이다. 파일을 이렇게 명명하면 Rust는 common 모듈을 통합 테스트 파일로 취급하지 않는다. setup 함수 코드를 _tests/common/mod.rs_로 옮기고 tests/common.rs 파일을 삭제하면, 테스트 출력의 해당 섹션은 더 이상 나타나지 않는다. tests 디렉터리의 하위 디렉터리에 있는 파일들은 별도의 크레이트로 컴파일되지 않으며, 테스트 출력에 섹션이 나타나지 않는다.

tests/common/mod.rs 파일을 생성한 후에는, 이를 모듈로 사용해 어떤 통합 테스트 파일에서든 사용할 수 있다. 다음은 tests/integration_test.rs 파일의 it_adds_two 테스트에서 setup 함수를 호출하는 예제다:

파일명: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

mod common; 선언은 Listing 7-21에서 보여준 모듈 선언과 동일하다. 그런 다음 테스트 함수에서 common::setup() 함수를 호출할 수 있다.

바이너리 크레이트의 통합 테스트

프로젝트가 src/main.rs 파일만 포함하고 있고 src/lib.rs 파일이 없는 바이너리 크레이트라면, tests 디렉토리에서 통합 테스트를 작성하고 src/main.rs 파일에 정의된 함수를 use 문으로 가져올 수 없다. 다른 크레이트가 사용할 수 있는 함수를 노출하는 것은 라이브러리 크레이트뿐이다. 바이너리 크레이트는 독립적으로 실행되도록 설계되었다.

이것이 Rust 프로젝트에서 바이너리를 제공할 때 src/main.rs 파일이 src/lib.rs 파일에 있는 로직을 호출하는 간단한 구조를 사용하는 이유 중 하나이다. 이러한 구조를 사용하면 통합 테스트에서 use를 통해 라이브러리 크레이트를 테스트하고 주요 기능을 사용할 수 있다. 주요 기능이 제대로 동작한다면, src/main.rs 파일에 있는 소량의 코드도 잘 동작할 것이며, 이 작은 코드는 테스트할 필요가 없다.

요약

Rust의 테스트 기능은 코드가 예상대로 동작하는지 확인할 수 있는 방법을 제공한다. 이를 통해 코드를 변경하더라도 기존 기능이 정상적으로 작동하는지 보장할 수 있다. 유닛 테스트는 라이브러리의 각 부분을 개별적으로 검증하며, 비공개 구현 세부 사항도 테스트할 수 있다. 통합 테스트는 라이브러리의 여러 부분이 함께 올바르게 동작하는지 확인하며, 외부 코드에서 사용하는 방식과 동일하게 공개 API를 통해 테스트를 수행한다. Rust의 타입 시스템과 소유권 규칙이 특정 종류의 버그를 방지해 주지만, 코드의 예상 동작과 관련된 논리적 버그를 줄이기 위해 테스트는 여전히 중요하다.

이 장과 이전 장에서 배운 지식을 활용해 프로젝트를 진행해 보자!

I/O 프로젝트: 커맨드라인 프로그램 만들기

이번 장은 지금까지 배운 다양한 기술을 복습하고, 몇 가지 표준 라이브러리 기능을 더 탐구해 보는 시간이다. 파일과 커맨드라인 입출력을 다루는 도구를 만들어 보며, Rust 개념을 실제로 적용해 볼 것이다.

Rust는 빠른 속도, 안전성, 단일 바이너리 출력, 그리고 크로스 플랫폼 지원 덕분에 커맨드라인 도구를 만들기에 이상적인 언어다. 이번 프로젝트에서는 고전적인 검색 도구인 grep(globally search a regular expression and print)의 간단한 버전을 만들어 볼 것이다. 가장 기본적인 사용법에서 grep은 지정된 파일에서 특정 문자열을 찾는다. 이를 위해 grep은 파일 경로와 문자열을 인자로 받는다. 그런 다음 파일을 읽어서 해당 문자열이 포함된 줄을 찾고, 그 줄을 출력한다.

이 과정에서 다른 커맨드라인 도구들이 사용하는 터미널 기능을 어떻게 활용하는지 알아볼 것이다. 사용자가 도구의 동작을 설정할 수 있도록 환경 변수의 값을 읽어오는 방법도 배울 것이다. 또한 오류 메시지를 표준 출력(stdout) 대신 표준 에러 스트림(stderr)에 출력하는 방법을 알아볼 것이다. 이렇게 하면 사용자가 성공적인 출력을 파일로 리다이렉트하면서도 화면에 오류 메시지를 계속 볼 수 있다.

Rust 커뮤니티의 한 멤버인 Andrew Gallant는 이미 ripgrep이라는 완전한 기능을 갖춘 매우 빠른 grep 버전을 만들었다. 우리가 만드는 버전은 상대적으로 단순하지만, 이번 장을 통해 ripgrep 같은 실제 프로젝트를 이해하는 데 필요한 배경 지식을 얻을 수 있을 것이다.

우리의 grep 프로젝트는 지금까지 배운 여러 개념을 종합적으로 활용할 것이다:

  • 코드 조직화 (7장)
  • 벡터와 문자열 사용 (8장)
  • 오류 처리 (9장)
  • 적절한 상황에서 트레이트와 라이프타임 사용 (10장)
  • 테스트 작성 (11장)

또한 클로저, 이터레이터, 트레이트 객체에 대해 간략히 소개할 것이다. 이 주제들은 13장18장에서 자세히 다룰 예정이다.

커맨드라인 인자 받아들이기

항상 그렇듯이 cargo new로 새로운 프로젝트를 생성한다. 이 프로젝트는 시스템에 이미 설치되어 있을 수 있는 grep 도구와 구분하기 위해 minigrep이라고 이름 짓는다.

$ cargo new minigrep
     Created binary (application) `minigrep` project
$ cd minigrep

첫 번째 작업은 minigrep이 두 개의 커맨드라인 인자를 받아들이도록 만드는 것이다. 파일 경로와 검색할 문자열이 바로 그것이다. 즉, 프로그램을 cargo run으로 실행할 때, 두 개의 하이픈을 사용해 뒤따르는 인자가 cargo가 아닌 우리 프로그램을 위한 것임을 나타내고, 검색할 문자열과 검색할 파일의 경로를 다음과 같이 전달할 수 있도록 만드는 것이다:

$ cargo run -- searchstring example-filename.txt

현재 cargo new로 생성된 프로그램은 우리가 전달한 인자를 처리할 수 없다. crates.io에는 커맨드라인 인자를 받아들이는 프로그램을 작성하는 데 도움을 주는 여러 라이브러리가 있지만, 이 개념을 배우는 중이므로 이 기능을 직접 구현해보자.

인자 값 읽기

minigrep이 전달받은 커맨드라인 인자 값을 읽으려면 Rust 표준 라이브러리에서 제공하는 std::env::args 함수를 사용해야 한다. 이 함수는 minigrep에 전달된 커맨드라인 인자들의 이터레이터를 반환한다. 이터레이터에 대해서는 13장에서 자세히 다룬다. 지금은 이터레이터가 일련의 값을 생성하고, 이터레이터에 collect 메서드를 호출해 벡터와 같은 컬렉션으로 변환할 수 있다는 점만 알면 된다.

리스트 12-1의 코드는 minigrep 프로그램이 전달받은 커맨드라인 인자를 읽고, 그 값을 벡터로 수집한다.

Filename: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    dbg!(args);
}
Listing 12-1: 커맨드라인 인자를 벡터로 수집하고 출력하기

먼저 use 문을 사용해 std::env 모듈을 스코프로 가져와 args 함수를 사용할 수 있게 한다. std::env::args 함수는 두 단계의 모듈 안에 중첩되어 있다. 7장에서 논의한 것처럼, 원하는 함수가 여러 모듈에 중첩된 경우, 함수 자체보다는 상위 모듈을 스코프로 가져오는 것이 일반적이다. 이렇게 하면 std::env의 다른 함수도 쉽게 사용할 수 있다. 또한 use std::env::args를 추가하고 args 함수를 호출하는 것보다 모호함이 적다. args는 현재 모듈에 정의된 함수와 혼동될 수 있기 때문이다.

args 함수와 유효하지 않은 유니코드

std::env::args는 인자가 유효하지 않은 유니코드를 포함할 경우 패닉을 일으킨다. 프로그램이 유효하지 않은 유니코드를 포함하는 인자를 받아야 한다면 std::env::args_os를 대신 사용해야 한다. 이 함수는 String 값 대신 OsString 값을 생성하는 이터레이터를 반환한다. 여기서는 간단하게 std::env::args를 사용했는데, OsString 값은 플랫폼마다 다르고 String 값보다 다루기 복잡하기 때문이다.

main 함수의 첫 줄에서 env::args를 호출하고, 바로 collect를 사용해 이터레이터가 생성한 모든 값을 포함하는 벡터로 변환한다. collect 함수는 다양한 종류의 컬렉션을 생성할 수 있으므로, args의 타입을 명시적으로 어노테이션해 문자열 벡터를 원한다는 것을 나타낸다. Rust에서는 타입을 어노테이션할 필요가 거의 없지만, collect는 Rust가 원하는 컬렉션 종류를 추론할 수 없기 때문에 종종 어노테이션이 필요하다.

마지막으로, 디버그 매크로를 사용해 벡터를 출력한다. 이 코드를 인자 없이 실행한 후 두 개의 인자를 전달해 실행해 보자:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.61s
     Running `target/debug/minigrep`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
]
$ cargo run -- needle haystack
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.57s
     Running `target/debug/minigrep needle haystack`
[src/main.rs:5:5] args = [
    "target/debug/minigrep",
    "needle",
    "haystack",
]

벡터의 첫 번째 값은 "target/debug/minigrep"으로, 바이너리의 이름이다. 이는 C 언어에서의 인자 리스트 동작과 일치하며, 프로그램이 실행될 때 호출된 이름을 사용할 수 있게 한다. 메시지에 프로그램 이름을 출력하거나, 프로그램을 호출할 때 사용한 커맨드라인 별칭에 따라 프로그램의 동작을 변경하는 경우에 유용하다. 하지만 이 장에서는 이 값을 무시하고 필요한 두 개의 인자만 저장한다.

인자 값을 변수에 저장하기

현재 프로그램은 커맨드라인 인자로 지정된 값에 접근할 수 있다. 이제 두 인자의 값을 변수에 저장해 프로그램의 나머지 부분에서 활용할 수 있도록 해야 한다. 이를 위해 리스트 12-2와 같이 코드를 작성한다.

Filename: src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");
}
Listing 12-2: 쿼리 인자와 파일 경로 인자를 저장할 변수 생성

벡터를 출력했을 때 확인했듯이, 프로그램 이름은 벡터의 첫 번째 값인 args[0]에 저장된다. 따라서 인자는 인덱스 1부터 시작한다. minigrep이 받는 첫 번째 인자는 검색할 문자열이므로, 첫 번째 인자의 참조를 query 변수에 저장한다. 두 번째 인자는 파일 경로이므로, 두 번째 인자의 참조를 file_path 변수에 저장한다.

코드가 의도한 대로 동작하는지 확인하기 위해 이 변수들의 값을 임시로 출력한다. 이제 testsample.txt 인자를 사용해 프로그램을 다시 실행해 보자:

$ cargo run -- test sample.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt

잘 동작한다! 필요한 인자 값이 올바른 변수에 저장되고 있다. 나중에 사용자가 인자를 제공하지 않는 경우와 같은 잠재적인 오류 상황을 처리하기 위해 에러 핸들링을 추가할 것이다. 지금은 그 상황을 무시하고 파일 읽기 기능을 추가하는 데 집중한다.

파일 읽기

이제 file_path 인자로 지정된 파일을 읽는 기능을 추가한다. 먼저 테스트할 샘플 파일이 필요하다. 여러 줄에 걸쳐 적은 양의 텍스트가 있고, 반복되는 단어가 포함된 파일을 사용할 것이다. 리스팅 12-3은 에밀리 디킨슨의 시로, 테스트에 적합하다! 프로젝트 루트 레벨에 poem.txt 파일을 생성하고 “I’m Nobody! Who are you?“라는 시를 입력한다.

Filename: poem.txt
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
Listing 12-3: 에밀리 디킨슨의 시는 좋은 테스트 케이스가 된다.

텍스트를 입력한 후, src/main.rs 파일을 편집하여 리스팅 12-4와 같이 파일을 읽는 코드를 추가한다.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let file_path = &args[2];

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}
Listing 12-4: 두 번째 인자로 지정된 파일의 내용을 읽는다

먼저 use 문을 사용해 표준 라이브러리의 관련 부분을 가져온다. 파일을 다루기 위해 std::fs가 필요하다.

main 함수에서 fs::read_to_stringfile_path를 받아 해당 파일을 열고, 파일 내용을 포함한 std::io::Result<String> 타입의 값을 반환한다.

그 후, 파일을 읽은 후 contents의 값을 출력하기 위해 임시로 println! 문을 추가한다. 이를 통해 프로그램이 제대로 동작하는지 확인할 수 있다.

아직 검색 기능을 구현하지 않았으므로, 첫 번째 커맨드라인 인자로 아무 문자열을 넣고, 두 번째 인자로 poem.txt 파일을 지정해 이 코드를 실행해 보자:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

잘 동작한다! 코드가 파일의 내용을 읽고 출력했다. 하지만 이 코드에는 몇 가지 문제가 있다. 현재 main 함수는 여러 가지 책임을 지고 있다. 일반적으로 각 함수가 하나의 개념만 담당하면 코드가 더 명확하고 유지보수하기 쉬워진다. 또 다른 문제는 에러 처리가 충분히 잘 이루어지지 않는다는 점이다. 프로그램이 아직 작기 때문에 이 문제가 크게 부각되지는 않지만, 프로그램이 커지면 깔끔하게 수정하기가 더 어려워진다. 프로그램을 개발할 때는 초기부터 리팩토링을 시작하는 것이 좋다. 코드 양이 적을 때 리팩토링하는 것이 훨씬 쉽기 때문이다. 다음 단계에서 이를 진행할 것이다.

모듈성과 에러 처리 개선을 위한 리팩토링

프로그램의 구조와 잠재적 에러 처리를 개선하기 위해 네 가지 문제를 해결한다. 첫째, 현재 main 함수는 인자를 파싱하고 파일을 읽는 두 가지 작업을 동시에 수행한다. 프로그램이 커질수록 main 함수가 처리하는 작업의 수가 늘어난다. 함수의 책임이 많아질수록 이해하기 어려워지고, 테스트하기도 어려워지며, 수정 시 다른 부분에 영향을 미칠 가능성이 커진다. 따라서 각 함수가 하나의 작업만 담당하도록 기능을 분리하는 것이 좋다.

이 문제는 두 번째 문제와도 연결된다. queryfile_path는 프로그램의 설정 변수이지만, contents와 같은 변수는 프로그램의 로직을 수행하는 데 사용된다. main 함수가 길어질수록 더 많은 변수를 범위에 포함해야 하며, 변수가 많아질수록 각 변수의 목적을 추적하기 어려워진다. 따라서 설정 변수를 하나의 구조체로 묶어 목적을 명확히 하는 것이 좋다.

세 번째 문제는 파일 읽기 실패 시 expect를 사용해 에러 메시지를 출력하지만, Should have been able to read the file이라는 일반적인 메시지만 표시한다는 점이다. 파일 읽기는 다양한 이유로 실패할 수 있다. 예를 들어, 파일이 없거나, 파일을 열 권한이 없을 수 있다. 현재는 상황에 관계없이 동일한 에러 메시지를 출력하므로 사용자에게 유용한 정보를 제공하지 못한다.

네 번째 문제는 에러 처리를 위해 expect를 사용하며, 사용자가 충분한 인수를 지정하지 않고 프로그램을 실행하면 Rust에서 index out of bounds 에러가 발생한다. 이는 문제를 명확히 설명하지 못한다. 모든 에러 처리 코드를 한 곳에 모아두면 향후 유지보수 시 에러 처리 로직을 변경할 때 한 곳만 참조하면 된다. 또한, 모든 에러 처리 코드를 한 곳에 모아두면 최종 사용자에게 의미 있는 메시지를 출력할 수 있다.

이제 이 네 가지 문제를 해결하기 위해 프로젝트를 리팩토링해보자.

바이너리 프로젝트의 관심사 분리

많은 바이너리 프로젝트에서 여러 작업의 책임을 main 함수에 할당하는 조직화 문제가 자주 발생한다. 이에 따라 Rust 커뮤니티는 main 함수가 커질 때 바이너리 프로그램의 관심사를 분리하기 위한 가이드라인을 개발했다. 이 과정은 다음과 같은 단계로 진행된다:

  • 프로그램을 main.rs 파일과 lib.rs 파일로 나누고, 프로그램의 로직을 _lib.rs_로 옮긴다.
  • 커맨드라인 파싱 로직이 작은 경우, _main.rs_에 그대로 둘 수 있다.
  • 커맨드라인 파싱 로직이 복잡해지면, _main.rs_에서 추출하여 _lib.rs_로 옮긴다.

이 과정 이후 main 함수에 남은 책임은 다음과 같이 제한된다:

  • 인자 값을 사용해 커맨드라인 파싱 로직을 호출한다.
  • 다른 설정을 구성한다.
  • _lib.rs_에 있는 run 함수를 호출한다.
  • run 함수가 에러를 반환하면 이를 처리한다.

이 패턴은 관심사를 분리하는 것에 관한 것이다: _main.rs_는 프로그램 실행을 처리하고, _lib.rs_는 작업의 모든 로직을 처리한다. main 함수를 직접 테스트할 수 없기 때문에, 이 구조를 통해 프로그램의 모든 로직을 _lib.rs_의 함수로 옮겨 테스트할 수 있다. _main.rs_에 남아 있는 코드는 읽기만 해도 정확성을 확인할 수 있을 만큼 간결해진다. 이제 이 과정을 따라 프로그램을 재구성해 보자.

인자 파서 추출하기

우리는 인자 파싱 기능을 main 함수가 호출할 수 있는 별도의 함수로 추출할 것이다. 이렇게 하면 커맨드라인 파싱 로직을 _src/lib.rs_로 옮길 준비를 할 수 있다. 리스트 12-5는 parse_config라는 새로운 함수를 호출하는 main 함수의 시작 부분을 보여준다. 이 함수는 현재 _src/main.rs_에 정의되어 있다.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, file_path) = parse_config(&args);

    // --snip--

    println!("Searching for {query}");
    println!("In file {file_path}");

    let contents = fs::read_to_string(file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}
Listing 12-5: main에서 parse_config 함수 추출하기

여전히 커맨드라인 인자를 벡터로 수집하지만, main 함수 내에서 인덱스 1의 값을 query 변수에, 인덱스 2의 값을 file_path 변수에 할당하는 대신, 전체 벡터를 parse_config 함수에 전달한다. parse_config 함수는 어떤 인자가 어떤 변수에 해당하는지 결정하는 로직을 담당하고, 값을 main 함수로 반환한다. main 함수에서는 여전히 queryfile_path 변수를 생성하지만, 이제는 커맨드라인 인자와 변수 간의 매핑을 결정할 책임이 없다.

이렇게 리팩토링하는 것이 작은 프로그램에서는 과한 작업처럼 보일 수 있지만, 우리는 작은 단계로 점진적으로 리팩토링을 진행하고 있다. 이 변경을 적용한 후에는 프로그램을 다시 실행해 인자 파싱이 여전히 잘 동작하는지 확인한다. 문제가 발생했을 때 원인을 쉽게 찾을 수 있도록 자주 진행 상황을 확인하는 것이 좋다.

설정 값 그룹화

parse_config 함수를 더 개선할 수 있는 작은 단계를 하나 더 살펴보자. 현재는 튜플을 반환하고 있는데, 반환된 튜플을 다시 개별 부분으로 분리하고 있다. 이는 아직 적절한 추상화를 하지 못했다는 신호일 수 있다.

또 다른 개선의 여지가 있다는 것을 보여주는 지표는 parse_config 함수의 config 부분이다. 이는 우리가 반환하는 두 값이 서로 관련이 있으며 하나의 설정 값의 일부라는 것을 암시한다. 현재는 이 두 값을 튜플로 묶는 것 외에는 데이터 구조에서 이러한 의미를 전달하지 않고 있다. 대신, 이 두 값을 하나의 구조체에 넣고 각 필드에 의미 있는 이름을 부여할 것이다. 이렇게 하면 향후 이 코드를 유지보수하는 사람들이 서로 다른 값들이 어떻게 관련되어 있는지, 그리고 그 목적이 무엇인지 더 쉽게 이해할 수 있다.

Listing 12-6은 parse_config 함수를 개선한 결과를 보여준다.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    // --snip--

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}
Listing 12-6: parse_config 함수를 리팩토링하여 Config 구조체의 인스턴스를 반환

queryfile_path라는 필드를 가진 Config 구조체를 추가했다. 이제 parse_config 함수의 시그니처는 Config 값을 반환한다는 것을 나타낸다. parse_config 함수의 본문에서는 이전에 argsString 값을 참조하는 문자열 슬라이스를 반환했던 부분을 Config가 소유한 String 값을 포함하도록 변경했다. main 함수의 args 변수는 인자 값의 소유자이며, parse_config 함수가 이를 빌려 쓰도록 허용하고 있다. 따라서 Configargs의 값을 소유하려고 하면 Rust의 빌림 규칙을 위반하게 된다.

String 데이터를 관리하는 방법에는 여러 가지가 있지만, 가장 간단한 방법은 값에 clone 메서드를 호출하는 것이다. 이는 데이터의 전체 복사본을 만들어 Config 인스턴스가 소유하도록 하기 때문에, 문자열 데이터에 대한 참조를 저장하는 것보다 더 많은 시간과 메모리를 소비한다. 그러나 데이터를 복제하면 참조의 수명을 관리할 필요가 없기 때문에 코드가 매우 직관적이 된다. 이러한 상황에서는 약간의 성능을 포기하고 단순함을 얻는 것이 가치 있는 절충안이다.

clone 사용의 절충점

많은 Rust 개발자들은 런타임 비용 때문에 clone을 사용하여 소유권 문제를 해결하는 것을 피하려는 경향이 있다. 13장에서는 이러한 상황에서 더 효율적인 방법을 사용하는 법을 배울 것이다. 하지만 지금은 몇 개의 문자열을 복사하여 진행하는 것이 괜찮다. 이러한 복사는 한 번만 이루어지며, 파일 경로와 쿼리 문자열은 매우 작기 때문이다. 처음부터 코드를 과도하게 최적화하려고 하는 것보다, 조금 비효율적이지만 동작하는 프로그램을 갖는 것이 더 낫다. Rust에 더 익숙해지면 가장 효율적인 해결책부터 시작하는 것이 더 쉬워지겠지만, 지금은 clone을 호출하는 것이 완벽하게 허용된다.

main 함수를 업데이트하여 parse_config가 반환한 Config 인스턴스를 config라는 변수에 저장하도록 했다. 이전에 queryfile_path라는 별도의 변수를 사용했던 코드도 이제 Config 구조체의 필드를 사용하도록 업데이트했다.

이제 우리 코드는 queryfile_path가 서로 관련이 있으며, 프로그램이 어떻게 동작할지를 설정하는 데 사용된다는 것을 더 명확히 전달한다. 이 값을 사용하는 모든 코드는 그 목적에 맞게 명명된 필드에서 config 인스턴스 안에 있음을 알 수 있다.

Config 생성자 만들기

지금까지 main 함수에서 커맨드라인 인자를 파싱하는 로직을 추출해 parse_config 함수로 옮겼다. 이를 통해 queryfile_path 값이 서로 관련이 있다는 점을 명확히 확인했고, 코드에서 이 관계를 표현할 필요가 있었다. 이후 Config 구조체를 추가해 queryfile_path의 관련성을 명시하고, parse_config 함수에서 이 값들의 이름을 구조체 필드 이름으로 반환할 수 있게 했다.

이제 parse_config 함수의 목적이 Config 인스턴스를 생성하는 것이므로, parse_config를 일반 함수에서 Config 구조체와 연관된 new 함수로 변경할 수 있다. 이렇게 변경하면 코드가 더 관용적으로 변한다. 표준 라이브러리에서 String 같은 타입의 인스턴스를 생성할 때 String::new를 호출하는 것과 마찬가지로, parse_configConfig와 연관된 new 함수로 변경하면 Config::new를 호출해 Config 인스턴스를 생성할 수 있다. 아래 코드는 필요한 변경 사항을 보여준다.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");

    // --snip--
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}
Listing 12-7: parse_configConfig::new로 변경

이제 main 함수에서 parse_config를 호출하던 부분을 Config::new로 변경했다. parse_config의 이름을 new로 바꾸고, impl 블록 안으로 옮겨 new 함수를 Config와 연관시켰다. 이 코드를 다시 컴파일해 동작을 확인해 보자.

에러 처리 개선

이제 에러 처리를 개선해 보자. args 벡터에 인덱스 1이나 2에 접근하려고 할 때, 벡터에 세 개 미만의 항목이 있으면 프로그램이 패닉 상태에 빠진다. 아무런 인자 없이 프로그램을 실행해 보면 다음과 같은 결과가 나타난다:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:27:21:
index out of bounds: the len is 1 but the index is 1
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

index out of bounds: the len is 1 but the index is 1라는 메시지는 프로그래머를 위한 에러 메시지다. 이는 최종 사용자가 무엇을 해야 하는지 이해하는 데 도움이 되지 않는다. 이제 이를 수정해 보자.

에러 메시지 개선

리스트 12-8에서는 new 함수에 인덱스 1과 2에 접근하기 전에 슬라이스가 충분히 긴지 확인하는 검사를 추가한다. 슬라이스가 충분히 길지 않으면 프로그램이 패닉 상태에 빠지고 더 나은 에러 메시지를 표시한다.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    // --snip--
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("not enough arguments");
        }
        // --snip--

        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}
Listing 12-8: 인수 개수 확인 추가

이 코드는 리스트 9-13에서 작성한 Guess::new 함수와 유사하다. value 인자가 유효한 값 범위를 벗어날 때 panic!을 호출했다. 여기서는 값의 범위를 확인하는 대신 args의 길이가 최소 3인지 확인하고, 함수의 나머지 부분은 이 조건이 충족되었다는 가정 하에 동작한다. args에 세 개 미만의 항목이 있으면 이 조건이 true가 되고, panic! 매크로를 호출해 프로그램을 즉시 종료한다.

new 함수에 이 몇 줄의 코드를 추가한 후, 다시 아무런 인수 없이 프로그램을 실행해 에러가 어떻게 표시되는지 확인해 보자:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep`

thread 'main' panicked at src/main.rs:26:13:
not enough arguments
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

이 출력은 더 나아졌다. 이제는 합리적인 에러 메시지를 제공한다. 하지만 여전히 사용자에게 제공하고 싶지 않은 불필요한 정보도 포함되어 있다. 리스트 9-13에서 사용한 기법이 여기서는 최선의 선택이 아닐 수 있다. panic! 호출은 사용법 문제보다는 프로그래밍 문제에 더 적합하다. 9장에서 논의한 것처럼, 대신 9장에서 배운 다른 기법인 성공 또는 에러를 나타내는 Result 반환을 사용할 것이다.

panic! 호출 대신 Result 반환하기

Config 인스턴스를 성공적으로 생성한 경우에는 Config 인스턴스를 포함하고, 오류가 발생한 경우에는 문제를 설명하는 Result 값을 반환할 수 있다. 또한 함수 이름을 new에서 build로 변경한다. 많은 프로그래머들이 new 함수가 실패하지 않을 것이라고 기대하기 때문이다. Config::buildmain과 통신할 때, Result 타입을 사용해 문제가 발생했음을 알릴 수 있다. 그런 다음 main을 수정해 Err 변형을 사용자에게 더 실용적인 오류로 변환할 수 있다. 이렇게 하면 panic! 호출로 인해 발생하는 thread 'main'RUST_BACKTRACE와 같은 텍스트를 제거할 수 있다.

목록 12-9는 이제 Config::build라고 부르는 함수의 반환 값과 Result를 반환하기 위해 필요한 함수 본문의 변경 사항을 보여준다. 참고로 이 코드는 main을 업데이트할 때까지 컴파일되지 않는다. 이 작업은 다음 목록에서 진행할 것이다.

Filename: src/main.rs
use std::env;
use std::fs;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-9: Config::build에서 Result 반환하기

build 함수는 성공한 경우 Config 인스턴스를, 오류가 발생한 경우 문자열 리터럴을 포함한 Result를 반환한다. 오류 값은 항상 'static 라이프타임을 가진 문자열 리터럴이다.

함수 본문에서 두 가지 변경 사항을 적용했다. 사용자가 충분한 인수를 전달하지 않았을 때 panic!을 호출하는 대신, 이제는 Err 값을 반환한다. 또한 Config 반환 값을 Ok로 감쌌다. 이러한 변경 사항은 함수가 새로운 타입 시그니처를 따르도록 만든다.

Config::build에서 Err 값을 반환하면 main 함수가 build 함수에서 반환된 Result 값을 처리하고, 오류가 발생한 경우 프로세스를 더 깔끔하게 종료할 수 있다.

Config::build 호출 및 에러 처리

에러 케이스를 처리하고 사용자 친화적인 메시지를 출력하려면 main 함수를 업데이트해야 한다. Config::build가 반환하는 Result를 처리하는 방식은 Listing 12-10에서 확인할 수 있다. 또한, panic!을 통해 명령줄 도구를 0이 아닌 에러 코드로 종료하는 책임을 제거하고, 직접 구현할 것이다. 0이 아닌 종료 상태는 프로그램이 에러 상태로 종료되었음을 호출한 프로세스에게 알리는 관례적인 방법이다.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-10: Config 생성 실패 시 에러 코드로 종료

이 예제에서는 아직 자세히 다루지 않은 메서드인 unwrap_or_else를 사용한다. 이 메서드는 표준 라이브러리에서 Result<T, E>에 정의되어 있다. unwrap_or_else를 사용하면 panic!이 아닌 커스텀 에러 처리를 정의할 수 있다. ResultOk 값인 경우, 이 메서드는 unwrap과 유사하게 동작한다. 즉, Ok가 감싸고 있는 내부 값을 반환한다. 그러나 값이 Err인 경우, 이 메서드는 클로저 내의 코드를 호출한다. 클로저는 익명 함수로, unwrap_or_else에 인자로 전달된다. 클로저에 대한 자세한 내용은 13장에서 다룰 것이다. 지금은 unwrap_or_elseErr의 내부 값을 클로저의 인자 err로 전달한다는 점만 이해하면 된다. 여기서 err는 Listing 12-9에서 추가한 정적 문자열 "not enough arguments"가 된다. 클로저 내의 코드는 실행 시 err 값을 사용할 수 있다.

새로운 use 라인을 추가해 표준 라이브러리의 process를 스코프로 가져왔다. 에러 케이스에서 실행될 클로저 내의 코드는 단 두 줄이다. err 값을 출력한 다음 process::exit를 호출한다. process::exit 함수는 프로그램을 즉시 중단하고 전달된 숫자를 종료 상태 코드로 반환한다. 이는 Listing 12-8에서 사용한 panic! 기반의 처리와 유사하지만, 불필요한 출력이 더 이상 발생하지 않는다. 이제 이를 실행해 보자:

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

좋다! 이 출력은 사용자에게 훨씬 더 친절하다.

main 함수에서 로직 분리하기

이제 설정 파싱 부분을 리팩토링했으니, 프로그램의 주요 로직으로 넘어가보자. 앞서 “바이너리 프로젝트의 관심사 분리”에서 언급했듯이, main 함수에서 설정과 오류 처리와 관련 없는 모든 로직을 담을 run 함수를 추출할 것이다. 이 작업을 마치면 main 함수는 간결해지고, 코드를 눈으로 확인하기 쉬워지며, 나머지 로직에 대한 테스트를 작성할 수 있게 된다.

리스트 12-11은 추출된 run 함수를 보여준다. 지금은 함수를 추출하는 작은 단계적 개선만 진행한다. 여전히 이 함수를 src/main.rs 파일에 정의한다.

<리스트 번호=“12-11” 파일명=“src/main.rs” 설명=“프로그램의 나머지 로직을 담은 run 함수 추출”>

use std::env;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) {
    let contents = fs::read_to_string(config.file_path)
        .expect("Should have been able to read the file");

    println!("With text:\n{contents}");
}

// --snip--

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

</리스트>

run 함수는 이제 파일 읽기부터 시작해 main 함수의 나머지 모든 로직을 포함한다. run 함수는 Config 인스턴스를 인자로 받는다.

run 함수에서 에러 반환하기

run 함수로 분리된 나머지 프로그램 로직에서 에러 처리를 개선할 수 있다. 이는 Config::build에서 했던 것과 유사하다. expect를 호출해 프로그램이 패닉 상태에 빠지도록 두는 대신, run 함수는 문제가 발생했을 때 Result<T, E>를 반환한다. 이를 통해 에러 처리 로직을 main 함수에 통합해 사용자 친화적인 방식으로 처리할 수 있다. 목록 12-12는 run 함수의 시그니처와 본문에 필요한 변경 사항을 보여준다.

Filename: src/main.rs
use std::env;
use std::fs;
use std::process;
use std::error::Error;

// --snip--


fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    run(config);
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}
Listing 12-12: run 함수가 Result를 반환하도록 변경

여기서 세 가지 주요 변경 사항을 적용했다. 첫째, run 함수의 반환 타입을 Result<(), Box<dyn Error>>로 변경했다. 이 함수는 이전에 유닛 타입 ()을 반환했으며, Ok 케이스에서도 동일한 값을 반환하도록 유지했다.

에러 타입으로는 트레잇 객체 Box<dyn Error>를 사용했다. (상단에 use 구문을 통해 std::error::Error를 스코프로 가져왔다.) 트레잇 객체는 18장에서 다룬다. 지금은 Box<dyn Error>Error 트레잇을 구현하는 타입을 반환한다는 점만 이해하면 된다. 이렇게 하면 다양한 에러 케이스에서 서로 다른 타입의 에러 값을 반환할 수 있는 유연성을 얻는다. dyn 키워드는 _동적_을 의미한다.

둘째, expect 호출을 제거하고 ? 연산자로 대체했다. 이는 9장에서 다룬 내용이다. 에러가 발생했을 때 panic!을 일으키는 대신, ?는 현재 함수에서 에러 값을 반환해 호출자가 처리하도록 한다.

셋째, run 함수는 이제 성공 시 Ok 값을 반환한다. run 함수의 성공 타입을 시그니처에서 ()로 선언했기 때문에, 유닛 타입 값을 Ok 값으로 감싸야 한다. Ok(()) 구문은 처음 보면 조금 이상해 보일 수 있지만, 이렇게 ()를 사용하는 것은 run 함수를 부수 효과만을 위해 호출한다는 것을 나타내는 관용적인 방법이다. 즉, 반환할 값이 필요하지 않다는 의미다.

이 코드를 실행하면 컴파일은 되지만 경고 메시지가 표시된다:

$ cargo run -- the poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
  --> src/main.rs:19:5
   |
19 |     run(config);
   |     ^^^^^^^^^^^
   |
   = note: this `Result` may be an `Err` variant, which should be handled
   = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
   |
19 |     let _ = run(config);
   |     +++++++

warning: `minigrep` (bin "minigrep") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.71s
     Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

러스트는 코드가 Result 값을 무시했으며, Result 값이 에러가 발생했음을 나타낼 수 있다고 알려준다. 하지만 우리는 에러가 발생했는지 확인하지 않았고, 컴파일러는 여기에 에러 처리 코드가 필요하다고 알려준다. 이제 이 문제를 해결해보자.

run 함수에서 반환된 오류를 main에서 처리하기

run 함수에서 반환된 오류를 확인하고 처리하기 위해, 목록 12-10에서 Config::build를 사용했던 방식과 유사한 기법을 사용한다. 하지만 약간의 차이가 있다:

파일명: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

fn main() {
    // --snip--

    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

여기서는 unwrap_or_else 대신 if let을 사용하여 run 함수가 Err 값을 반환하는지 확인한다. 만약 오류가 발생하면 process::exit(1)을 호출한다. run 함수는 Config::buildConfig 인스턴스를 반환하는 것과 달리, unwrap할 값을 반환하지 않는다. 성공 시 run 함수는 ()를 반환하므로, 오류를 감지하는 것만 중요하다. 따라서 unwrap_or_else를 사용해 ()와 같은 값을 반환할 필요가 없다.

if letunwrap_or_else 함수의 본문은 두 경우 모두 동일하다: 오류를 출력하고 프로그램을 종료한다.

코드를 라이브러리 크레이트로 분리하기

지금까지 minigrep 프로젝트가 잘 진행되고 있다! 이제 src/main.rs 파일을 분리하고 일부 코드를 src/lib.rs 파일로 옮길 것이다. 이렇게 하면 코드를 테스트할 수 있고, src/main.rs 파일의 책임을 줄일 수 있다.

_src/main.rs_에서 main 함수에 속하지 않은 모든 코드를 _src/lib.rs_로 옮겨보자:

  • run 함수 정의
  • 관련된 use
  • Config 정의
  • Config::build 함수 정의

_src/lib.rs_의 내용은 Listing 12-13에 나온 시그니처를 따라야 한다(간결함을 위해 함수 본문은 생략했다). 이 코드는 Listing 12-14에서 _src/main.rs_를 수정할 때까지 컴파일되지 않을 것이다.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --snip--
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{contents}");

    Ok(())
}
Listing 12-13: Configrunsrc/lib.rs로 이동

pub 키워드를 적극적으로 사용했다: Config, 그 필드와 build 메서드, 그리고 run 함수에 적용했다. 이제 테스트할 수 있는 공개 API를 가진 라이브러리 크레이트가 생겼다!

이제 _src/lib.rs_로 옮긴 코드를 _src/main.rs_의 바이너리 크레이트 범위로 가져와야 한다. Listing 12-14에서 보여주는 것처럼 말이다.

Filename: src/main.rs
use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // --snip--
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = minigrep::run(config) {
        // --snip--
        println!("Application error: {e}");
        process::exit(1);
    }
}
Listing 12-14: src/main.rs에서 minigrep 라이브러리 크레이트 사용

use minigrep::Config 라인을 추가해 라이브러리 크레이트의 Config 타입을 바이너리 크레이트의 범위로 가져왔다. 그리고 run 함수 앞에 크레이트 이름을 붙였다. 이제 모든 기능이 연결되어 제대로 작동할 것이다. cargo run으로 프로그램을 실행해 모든 것이 정상적으로 동작하는지 확인하자.

휴! 많은 작업이었지만, 앞으로의 성공을 위한 기반을 다졌다. 이제 에러 처리가 훨씬 쉬워졌고, 코드도 더 모듈화되었다. 이제부터는 거의 모든 작업을 _src/lib.rs_에서 진행할 것이다.

이 새로운 모듈성의 장점을 활용해 보자. 이전 코드로는 어려웠지만, 새로운 코드로는 쉬운 작업을 해보자: 테스트를 작성해 보는 것이다!

테스트 주도 개발로 라이브러리 기능 구현하기

이제 로직을 _src/lib.rs_로 분리하고 인자 수집과 오류 처리는 _src/main.rs_에 남겨두었으므로, 코드의 핵심 기능에 대한 테스트를 작성하기가 훨씬 쉬워졌다. 커맨드라인에서 바이너리를 호출할 필요 없이 다양한 인자를 직접 함수에 전달하고 반환 값을 확인할 수 있다.

이 섹션에서는 테스트 주도 개발(TDD) 프로세스를 통해 minigrep 프로그램에 검색 로직을 추가한다. TDD는 다음과 같은 단계로 진행된다:

  1. 실패하는 테스트를 작성하고, 예상대로 실패하는지 확인한다.
  2. 새로운 테스트를 통과할 만큼의 코드를 작성하거나 수정한다.
  3. 방금 추가하거나 변경한 코드를 리팩토링하고 테스트가 계속 통과하는지 확인한다.
  4. 1단계부터 반복한다!

TDD는 소프트웨어를 작성하는 여러 방법 중 하나이지만, 코드 설계를 이끌어내는 데 도움이 된다. 테스트를 통과시키는 코드를 작성하기 전에 테스트를 먼저 작성하면, 전체 과정에서 높은 테스트 커버리지를 유지할 수 있다.

이제 파일 내용에서 쿼리 문자열을 검색하고 일치하는 줄의 목록을 반환하는 기능을 구현해보자. 이 기능을 search라는 함수에 추가할 것이다.

실패하는 테스트 작성하기

이제 더 이상 필요 없으므로, 프로그램 동작을 확인하기 위해 사용했던 println! 문을 _src/lib.rs_와 _src/main.rs_에서 제거한다. 그런 다음 _src/lib.rs_에 11장에서 했던 것처럼 tests 모듈과 테스트 함수를 추가한다. 테스트 함수는 search 함수가 가져야 할 동작을 정의한다: 쿼리와 검색할 텍스트를 받아서, 쿼리를 포함하는 라인만 반환해야 한다. 아래 목록 12-15는 아직 컴파일되지 않는 이 테스트를 보여준다.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-15: 원하는 search 함수를 위한 실패하는 테스트 작성

이 테스트는 문자열 "duct"를 검색한다. 검색할 텍스트는 세 줄로 구성되어 있으며, 그 중 한 줄만 "duct"를 포함한다(여는 큰따옴표 뒤의 백슬래시는 Rust에게 이 문자열 리터럴의 내용 앞에 개행 문자를 추가하지 말라고 알려준다). search 함수가 반환한 값이 예상한 라인만 포함하는지 확인한다.

아직 이 테스트를 실행해 실패하는 모습을 볼 수는 없다. 왜냐하면 테스트가 컴파일조차 되지 않기 때문이다: search 함수가 아직 존재하지 않는다! TDD 원칙에 따라, 테스트가 컴파일되고 실행될 수 있도록 최소한의 코드를 추가한다. 목록 12-16과 같이 항상 빈 벡터를 반환하는 search 함수를 정의한다. 그러면 테스트는 컴파일되고 실패할 것이다. 빈 벡터는 "safe, fast, productive." 라인을 포함하는 벡터와 일치하지 않기 때문이다.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-16: 테스트가 컴파일되도록 search 함수의 최소한의 정의 추가

search 함수의 시그니처에서 명시적 라이프타임 'a를 정의하고, 이 라이프타임을 contents 인자와 반환 값에 사용한다. 10장에서 라이프타임 매개변수가 반환 값의 라이프타임과 어떤 인자의 라이프타임이 연결되는지 지정한다고 설명했다. 이 경우, 반환된 벡터가 query가 아닌 contents 인자의 슬라이스를 참조하는 문자열 슬라이스를 포함해야 한다고 표시한다.

다시 말해, search 함수가 반환하는 데이터는 contents 인자로 전달된 데이터만큼 오래 유지될 것이라고 Rust에게 알린다. 이는 중요하다! 슬라이스가 참조하는 데이터는 참조가 유효하려면 유효해야 한다. 만약 컴파일러가 contents가 아닌 query의 문자열 슬라이스를 만든다고 가정하면, 안전성 검사를 잘못 수행할 것이다.

만약 라이프타임 어노테이션을 잊어버리고 이 함수를 컴파일하려고 하면, 다음과 같은 오류가 발생한다:

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
   |                      ----            ----         ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
   |
28 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
   |              ++++         ++                 ++              ++

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error

Rust는 두 인자 중 어느 것이 필요한지 알 수 없으므로, 명시적으로 알려야 한다. contents는 모든 텍스트를 포함하는 인자이며, 일치하는 텍스트 부분을 반환하려고 하므로, contents가 라이프타임 구문을 사용해 반환 값과 연결되어야 한다는 것을 알고 있다.

다른 프로그래밍 언어에서는 시그니처에서 인자를 반환 값과 연결할 필요가 없지만, 이 연습은 시간이 지나면 점점 쉬워질 것이다. 이 예제를 10장의 “라이프타임으로 참조 유효성 검사” 섹션의 예제와 비교해보는 것도 좋다.

이제 테스트를 실행해보자:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.97s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... FAILED

failures:

---- tests::one_result stdout ----

thread 'tests::one_result' panicked at src/lib.rs:44:9:
assertion `left == right` failed
  left: ["safe, fast, productive."]
 right: []
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::one_result

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

좋다, 테스트가 예상대로 실패한다. 이제 테스트를 통과시키자!

테스트를 통과하는 코드 작성하기

현재 테스트가 실패하는 이유는 항상 빈 벡터를 반환하기 때문이다. 이를 수정하고 search 함수를 구현하려면 프로그램이 다음 단계를 따라야 한다:

  1. contents의 각 줄을 순회한다.
  2. 해당 줄에 쿼리 문자열이 포함되어 있는지 확인한다.
  3. 포함되어 있다면, 반환할 값 목록에 추가한다.
  4. 포함되어 있지 않다면, 아무 작업도 하지 않는다.
  5. 일치하는 결과 목록을 반환한다.

이제 각 단계를 차례대로 살펴보자. 먼저 줄을 순회하는 것부터 시작하자.

lines 메서드를 사용한 줄 단위 반복

Rust는 문자열을 줄 단위로 반복 처리할 수 있는 유용한 메서드를 제공한다. 이 메서드는 lines라는 이름으로, 리스트 12-17에서와 같이 동작한다. 현재는 컴파일되지 않는다는 점에 유의하자.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-17: contents의 각 줄을 반복 처리

lines 메서드는 반복자(iterator)를 반환한다. 반복자에 대해서는 13장에서 자세히 다룰 예정이지만, 리스트 3-5에서 반복자를 사용한 방식을 떠올려보자. 그때는 for 루프와 반복자를 사용해 컬렉션의 각 항목에 대해 코드를 실행했다.

각 줄에서 쿼리 검색하기

다음으로, 현재 줄에 우리가 찾는 쿼리 문자열이 포함되어 있는지 확인한다. 다행히 문자열에는 이를 처리해주는 contains라는 유용한 메서드가 있다! search 함수에 contains 메서드를 호출하는 코드를 추가한다. Listing 12-18을 참고하면 된다. 아직은 이 코드가 컴파일되지 않는다는 점에 유의한다.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-18: query에 지정된 문자열이 줄에 포함되어 있는지 확인하는 기능 추가

현재는 기능을 점진적으로 구축하고 있다. 코드가 컴파일되려면 함수 시그니처에서 명시한 대로 본문에서 값을 반환해야 한다.

매칭된 라인 저장하기

이 함수를 완성하려면 반환할 매칭된 라인을 저장할 방법이 필요하다. 이를 위해 for 루프 전에 변경 가능한 벡터를 만들고, push 메서드를 호출해 line을 벡터에 저장한다. for 루프가 끝나면 벡터를 반환한다. 아래 리스트 12-19에서 이를 확인할 수 있다.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 12-19: 반환할 매칭된 라인 저장하기

이제 search 함수는 query를 포함하는 라인만 반환하며, 테스트는 통과할 것이다. 테스트를 실행해 보자:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

테스트가 통과했으므로, 이제 이 함수가 잘 작동한다는 것을 알 수 있다!

이 시점에서, 테스트를 통과하면서 동일한 기능을 유지하며 search 함수의 구현을 리팩토링할 기회를 고려할 수 있다. search 함수의 코드는 나쁘지 않지만, 이터레이터의 유용한 기능을 충분히 활용하지는 못한다. 이 예제는 13장에서 다시 다룰 것이다. 그곳에서 이터레이터를 자세히 살펴보고, 이 코드를 어떻게 개선할 수 있는지 알아볼 것이다.

run 함수에서 search 함수 사용하기

이제 search 함수가 정상적으로 동작하고 테스트를 마쳤으니, run 함수에서 search를 호출해야 한다. config.query 값과 run 함수가 파일에서 읽어온 contentssearch 함수에 전달한다. 그리고 search 함수가 반환한 각 줄을 run 함수에서 출력한다.

파일명: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}

search 함수에서 반환된 각 줄을 출력하기 위해 여전히 for 루프를 사용한다.

이제 전체 프로그램이 동작해야 한다! 먼저 Emily Dickinson의 시에서 정확히 한 줄을 반환해야 하는 단어인 _frog_로 테스트해 보자.

$ cargo run -- frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

잘 동작한다! 이번에는 여러 줄과 일치하는 단어인 _body_로 테스트해 보자.

$ cargo run -- body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

마지막으로, 시에 전혀 등장하지 않는 단어인 _monomorphization_으로 검색했을 때 아무런 결과가 나오지 않는지 확인해 보자.

$ cargo run -- monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

훌륭하다! 우리는 고전적인 도구의 미니 버전을 직접 만들었고, 애플리케이션을 구조화하는 방법에 대해 많이 배웠다. 또한 파일 입출력, 라이프타임, 테스트, 커맨드라인 파싱에 대해서도 배웠다.

이 프로젝트를 마무리하기 위해, 환경 변수를 다루는 방법과 표준 에러로 출력하는 방법을 간단히 살펴볼 것이다. 이 두 가지는 커맨드라인 프로그램을 작성할 때 유용하게 사용된다.

환경 변수 활용하기

minigrep에 새로운 기능을 추가해 보자. 사용자가 환경 변수를 통해 대소문자 구분 없는 검색 옵션을 활성화할 수 있도록 하는 기능이다. 이 기능을 커맨드라인 옵션으로 만들 수도 있지만, 환경 변수로 구현하면 사용자가 한 번 설정한 후 해당 터미널 세션에서는 모든 검색이 대소문자 구분 없이 이루어지도록 할 수 있다.

대소문자를 구분하지 않는 search 함수를 위한 실패 테스트 작성

먼저, 환경 변수에 값이 있을 때 호출될 새로운 search_case_insensitive 함수를 추가한다. 우리는 TDD(Test-Driven Development) 프로세스를 계속 따르기 때문에 첫 번째 단계는 다시 실패하는 테스트를 작성하는 것이다. 새로운 search_case_insensitive 함수를 위한 테스트를 추가하고, 기존 테스트의 이름을 one_result에서 case_sensitive로 변경하여 두 테스트의 차이를 명확히 한다. 이는 리스트 12-20에서 확인할 수 있다.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-20: 추가할 대소문자를 구분하지 않는 함수를 위한 새로운 실패 테스트 추가

기존 테스트의 contents도 수정했다. 대문자 _D_를 사용한 "Duct tape."라는 새 줄을 추가했는데, 이는 대소문자를 구분하는 방식으로 검색할 때 "duct"라는 쿼리와 일치하지 않아야 한다. 이렇게 기존 테스트를 변경하면 이미 구현한 대소문자 구분 검색 기능을 실수로 깨뜨리지 않도록 보장할 수 있다. 이 테스트는 지금 통과해야 하며, 대소문자를 구분하지 않는 검색 기능을 작업하는 동안에도 계속 통과해야 한다.

대소문자를 구분하지 않는 검색을 위한 새 테스트는 "rUsT"를 쿼리로 사용한다. 곧 추가할 search_case_insensitive 함수에서 "rUsT"라는 쿼리는 대문자 _R_이 포함된 "Rust:" 줄과 쿼리와 대소문자가 다른 "Trust me." 줄 모두와 일치해야 한다. 이는 실패하는 테스트이며, 아직 search_case_insensitive 함수를 정의하지 않았기 때문에 컴파일되지 않을 것이다. 리스트 12-16에서 search 함수를 위해 했던 것처럼 항상 빈 벡터를 반환하는 기본 구현을 추가하여 테스트가 컴파일되고 실패하는 것을 확인해도 좋다.

search_case_insensitive 함수 구현하기

search_case_insensitive 함수는 search 함수와 거의 동일하다. 유일한 차이점은 query와 각 line을 소문자로 변환한다는 점이다. 이를 통해 입력 인자의 대소문자와 상관없이 동일한 대소문자로 비교할 수 있다.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    for line in search(&config.query, &contents) {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-21: search_case_insensitive 함수를 정의하여 queryline을 소문자로 변환한 후 비교하기

먼저 query 문자열을 소문자로 변환하고, 동일한 이름의 새로운 변수에 저장한다. 이렇게 하면 원본 query가 가려진다. queryto_lowercase를 호출하면 사용자의 쿼리가 "rust", "RUST", "Rust", "rUsT" 중 어떤 것이든 "rust"로 처리되어 대소문자를 구분하지 않는다. to_lowercase는 기본적인 유니코드를 처리하지만 100% 정확하지는 않다. 실제 애플리케이션을 작성한다면 여기에 더 많은 작업이 필요하지만, 이 섹션은 유니코드가 아니라 환경 변수에 관한 내용이므로 여기서는 이 정도로 마무리한다.

query는 이제 문자열 슬라이스가 아니라 String 타입이다. to_lowercase를 호출하면 기존 데이터를 참조하는 대신 새로운 데이터를 생성하기 때문이다. 예를 들어 쿼리가 "rUsT"라면, 이 문자열 슬라이스에는 소문자 ut가 포함되어 있지 않으므로 "rust"를 포함하는 새로운 String을 할당해야 한다. 이제 querycontains 메서드의 인자로 전달할 때 앰퍼샌드(&)를 추가해야 한다. contains 메서드의 시그니처는 문자열 슬라이스를 인자로 받도록 정의되어 있기 때문이다.

다음으로, 각 lineto_lowercase를 호출하여 모든 문자를 소문자로 변환한다. 이제 linequery를 소문자로 변환했으므로, 쿼리의 대소문자와 상관없이 일치하는 항목을 찾을 수 있다.

이 구현이 테스트를 통과하는지 확인해 보자:

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s
     Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

좋다! 테스트를 통과했다. 이제 run 함수에서 새로운 search_case_insensitive 함수를 호출해 보자. 먼저 Config 구조체에 대소문자 구분 여부를 설정할 수 있는 옵션을 추가한다. 이 필드를 추가하면 아직 초기화하지 않았기 때문에 컴파일러 오류가 발생한다:

Filename: src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

ignore_case 필드를 추가하여 Boolean 값을 저장한다. 다음으로, run 함수가 ignore_case 필드의 값을 확인하고, 그에 따라 search 함수를 호출할지 search_case_insensitive 함수를 호출할지 결정하도록 한다. 이 코드는 아직 컴파일되지 않는다.

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-22: config.ignore_case의 값에 따라 search 또는 search_case_insensitive 호출하기

마지막으로, 환경 변수를 확인해야 한다. 환경 변수를 다루는 함수는 표준 라이브러리의 env 모듈에 있으므로, src/lib.rs 파일 상단에서 이 모듈을 가져온다. 그런 다음 env 모듈의 var 함수를 사용하여 IGNORE_CASE라는 환경 변수가 설정되었는지 확인한다.

Filename: src/lib.rs
use std::env;
// --snip--

use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 12-23: IGNORE_CASE라는 환경 변수의 값 확인하기

여기서 ignore_case라는 새로운 변수를 생성한다. 이 변수의 값을 설정하기 위해 env::var 함수를 호출하고 IGNORE_CASE 환경 변수의 이름을 전달한다. env::var 함수는 Result를 반환한다. 환경 변수가 설정되어 있으면 Ok 변형에 환경 변수의 값이 포함된다. 환경 변수가 설정되어 있지 않으면 Err 변형이 반환된다.

Resultis_ok 메서드를 사용하여 환경 변수가 설정되었는지 확인한다. 이는 프로그램이 대소문자를 구분하지 않는 검색을 수행해야 함을 의미한다. IGNORE_CASE 환경 변수가 설정되어 있지 않으면 is_okfalse를 반환하고 프로그램은 대소문자를 구분하는 검색을 수행한다. 환경 변수의 _값_이 아니라 설정 여부만 중요하므로 unwrap, expect 또는 Result에서 본 다른 메서드를 사용하는 대신 is_ok를 확인한다.

ignore_case 변수의 값을 Config 인스턴스에 전달하여 run 함수가 이 값을 읽고 search_case_insensitive를 호출할지 search를 호출할지 결정할 수 있도록 한다.

한번 시도해 보자! 먼저 환경 변수를 설정하지 않고 쿼리 to로 프로그램을 실행한다. 이는 소문자로 _to_가 포함된 모든 줄과 일치해야 한다:

$ cargo run -- to poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!

여전히 잘 작동한다! 이제 IGNORE_CASE1로 설정하고 동일한 쿼리 _to_로 프로그램을 실행해 보자:

$ IGNORE_CASE=1 cargo run -- to poem.txt

PowerShell을 사용하는 경우 환경 변수를 설정하고 프로그램을 별도의 명령어로 실행해야 한다:

PS> $Env:IGNORE_CASE=1; cargo run -- to poem.txt

이렇게 하면 IGNORE_CASE가 현재 셸 세션의 나머지 기간 동안 유지된다. Remove-Item cmdlet으로 이를 해제할 수 있다:

PS> Remove-Item Env:IGNORE_CASE

대문자가 포함된 _to_가 있는 줄도 결과에 포함되어야 한다:

Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

훌륭하다! _To_가 포함된 줄도 결과에 포함되었다. 이제 minigrep 프로그램은 환경 변수로 제어되는 대소문자 구분 없는 검색을 수행할 수 있다. 이제 커맨드라인 인수나 환경 변수를 사용하여 설정된 옵션을 관리하는 방법을 알게 되었다.

일부 프로그램은 동일한 설정에 대해 인수와 환경 변수를 모두 허용한다. 이러한 경우 프로그램은 둘 중 하나가 우선순위를 갖도록 결정한다. 추가 연습으로, 커맨드라인 인수나 환경 변수를 통해 대소문자 구분을 제어해 보자. 프로그램이 대소문자 구분과 무시 중 하나로 실행될 때, 커맨드라인 인수와 환경 변수 중 어느 것이 우선순위를 가져야 할지 결정해 보자.

std::env 모듈에는 환경 변수를 다루기 위한 더 많은 유용한 기능이 있다. 사용 가능한 기능을 확인하려면 해당 문서를 참고하라.

표준 출력 대신 표준 오류로 에러 메시지 작성하기

현재 우리는 println! 매크로를 사용해 모든 출력을 터미널에 표시한다. 대부분의 터미널은 두 가지 종류의 출력을 지원한다: 일반 정보를 위한 표준 출력 (stdout)과 에러 메시지를 위한 표준 오류 (stderr). 이 구분을 통해 사용자는 프로그램의 성공적인 출력을 파일로 보내면서도 에러 메시지는 화면에 출력하도록 선택할 수 있다.

println! 매크로는 표준 출력만 가능하기 때문에, 표준 오류에 출력하려면 다른 방법을 사용해야 한다.

오류가 어디에 기록되는지 확인하기

먼저 minigrep이 출력하는 내용이 어떻게 표준 출력으로 기록되는지 살펴보자. 여기에는 우리가 표준 오류로 출력하고 싶은 오류 메시지도 포함된다. 이를 확인하기 위해 표준 출력 스트림을 파일로 리다이렉트하면서 의도적으로 오류를 발생시킬 것이다. 표준 오류 스트림은 리다이렉트하지 않기 때문에, 표준 오류로 보내진 내용은 여전히 화면에 표시될 것이다.

일반적으로 커맨드라인 프로그램은 오류 메시지를 표준 오류 스트림으로 보내도록 설계된다. 이렇게 하면 표준 출력을 파일로 리다이렉트해도 오류 메시지는 화면에서 확인할 수 있다. 하지만 현재 우리 프로그램은 그렇게 동작하지 않는다. 오류 메시지가 파일에 저장되는 것을 곧 확인하게 될 것이다!

이 동작을 확인하기 위해, 프로그램을 실행하면서 >와 파일 경로인 output.txt를 사용해 표준 출력 스트림을 리다이렉트할 것이다. 이때 아무런 인수를 전달하지 않아 오류가 발생하도록 한다:

$ cargo run > output.txt

> 구문은 셸에게 표준 출력의 내용을 화면 대신 output.txt 파일에 쓰도록 지시한다. 우리가 기대했던 오류 메시지가 화면에 출력되지 않았다는 것은, 그 메시지가 파일에 저장되었다는 의미다. output.txt 파일의 내용은 다음과 같다:

Problem parsing arguments: not enough arguments

확인해보니, 오류 메시지가 표준 출력으로 출력되고 있다. 이런 오류 메시지는 표준 오류로 출력되어야 더 유용하다. 그래야 성공적으로 실행된 경우의 데이터만 파일에 기록된다. 이제 이를 수정해보자.

표준 오류로 에러 메시지 출력하기

이제 리스트 12-24의 코드를 사용해 에러 메시지 출력 방식을 변경한다. 이번 장 초반에 리팩토링을 통해 모든 에러 메시지 출력 코드를 main 함수 하나로 모았다. 표준 라이브러리는 표준 오류 스트림에 출력하는 eprintln! 매크로를 제공하므로, 기존에 println!을 사용했던 두 곳을 eprintln!으로 바꾼다.

Filename: src/main.rs
use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}
Listing 12-24: eprintln!을 사용해 에러 메시지를 표준 출력 대신 표준 오류로 출력하기

이제 다시 프로그램을 실행해본다. 인자 없이 실행하고 표준 출력을 >로 리다이렉트한다:

$ cargo run > output.txt
Problem parsing arguments: not enough arguments

이제 화면에 에러가 출력되고, output.txt 파일은 비어 있다. 이는 커맨드라인 프로그램에서 기대하는 동작이다.

다음으로, 에러를 발생시키지 않는 인자로 프로그램을 실행하면서 표준 출력을 파일로 리다이렉트한다:

$ cargo run -- to poem.txt > output.txt

터미널에는 아무런 출력이 나타나지 않으며, output.txt 파일에 결과가 저장된다:

파일명: output.txt

Are you nobody, too?
How dreary to be somebody!

이를 통해 성공적인 출력은 표준 출력으로, 에러 메시지는 표준 오류로 적절히 분리하여 처리하고 있음을 확인할 수 있다.

요약

이번 장에서는 지금까지 배운 주요 개념을 다시 살펴보고, 러스트에서 일반적인 I/O 작업을 수행하는 방법을 다뤘다. 커맨드라인 인자, 파일, 환경 변수, 그리고 오류 출력을 위한 eprintln! 매크로를 사용함으로써, 이제 여러분은 커맨드라인 애플리케이션을 작성할 준비가 되었다. 이전 장에서 배운 개념과 함께 활용하면 코드를 잘 조직화하고, 적절한 데이터 구조에 데이터를 효과적으로 저장하며, 오류를 깔끔하게 처리하고, 테스트를 철저히 할 수 있다.

다음 장에서는 함수형 언어의 영향을 받은 러스트의 기능인 클로저와 이터레이터를 탐구해 볼 것이다.

함수형 언어 기능: 이터레이터와 클로저

Rust의 디자인은 다양한 기존 언어와 기술에서 영감을 받았다. 특히 _함수형 프로그래밍_의 영향이 크다. 함수형 스타일로 프로그래밍할 때는 함수를 값으로 취급하여 인자로 전달하거나, 다른 함수에서 반환하거나, 변수에 할당해 나중에 실행하는 등의 방식을 자주 사용한다.

이 장에서는 함수형 프로그래밍이 무엇인지에 대한 논의는 하지 않고, 대신 Rust의 몇 가지 기능을 살펴볼 것이다. 이 기능들은 흔히 함수형이라고 불리는 여러 언어에서 볼 수 있는 것들과 유사하다.

구체적으로 다음 내용을 다룬다:

  • 클로저: 변수에 저장할 수 있는 함수와 유사한 구조
  • 이터레이터: 일련의 요소를 처리하는 방법
  • 클로저와 이터레이터를 사용해 12장의 I/O 프로젝트를 개선하는 방법
  • 클로저와 이터레이터의 성능 (스포일러 주의: 생각보다 빠르다!)

이미 패턴 매칭과 열거형(enum)과 같은 Rust의 다른 기능을 다뤘다. 이 기능들도 함수형 스타일의 영향을 받았다. 클로저와 이터레이터를 마스터하는 것은 Rust 코드를 관용적이고 빠르게 작성하는 데 중요한 부분이므로, 이 장 전체를 이 주제에 할애할 것이다.

클로저: 환경을 캡처하는 익명 함수

Rust의 클로저는 변수에 저장하거나 다른 함수에 인자로 전달할 수 있는 익명 함수다. 한 곳에서 클로저를 생성한 후, 다른 컨텍스트에서 이를 호출해 실행할 수 있다. 일반 함수와 달리 클로저는 정의된 스코프의 값을 캡처할 수 있다. 이번 장에서는 클로저의 이러한 특징이 어떻게 코드 재사용과 동작 커스터마이징을 가능하게 하는지 살펴본다.

클로저로 환경 캡처하기

먼저 클로저를 사용해 정의된 환경의 값을 나중에 사용할 수 있도록 캡처하는 방법을 살펴본다. 다음 시나리오를 생각해 보자: 티셔츠 회사는 가끔씩 프로모션으로 메일링 리스트에 있는 사람들에게 한정판 티셔츠를 무료로 제공한다. 메일링 리스트에 있는 사람들은 프로필에 좋아하는 색상을 선택적으로 추가할 수 있다. 무료 티셔츠를 받을 사람이 좋아하는 색상을 설정했다면, 그 색상의 티셔츠를 받는다. 만약 좋아하는 색상을 지정하지 않았다면, 회사에 현재 가장 많이 남아 있는 색상의 티셔츠를 받게 된다.

이를 구현하는 방법은 다양하다. 이 예제에서는 간단히 하기 위해 ShirtColor라는 열거형을 사용하며, RedBlue 두 가지 색상만 사용한다. 회사의 재고를 나타내기 위해 Inventory라는 구조체를 정의한다. 이 구조체에는 현재 재고 상태를 나타내는 shirts 필드가 있으며, 이 필드는 Vec<ShirtColor> 타입으로 티셔츠 색상을 저장한다. Inventory에 정의된 giveaway 메서드는 무료 티셔츠 수령자의 선호 색상 정보를 받아, 그 사람이 받을 티셔츠 색상을 반환한다. 이 설정은 리스트 13-1에 나와 있다:

Filename: src/main.rs
#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory {
        shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
    };

    let user_pref1 = Some(ShirtColor::Red);
    let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
    let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref2, giveaway2
    );
}
Listing 13-1: 티셔츠 회사의 프로모션 상황

main 함수에서 정의된 store는 이 한정판 프로모션을 위해 남아 있는 두 개의 파란색 티셔츠와 하나의 빨간색 티셔츠를 가지고 있다. 빨간색 티셔츠를 선호하는 사용자와 선호 색상이 없는 사용자에 대해 giveaway 메서드를 호출한다.

이 코드는 다양한 방식으로 구현할 수 있지만, 여기서는 클로저에 초점을 맞추기 위해 이미 배운 개념만을 사용한다. 단, giveaway 메서드의 본문은 클로저를 사용한다. giveaway 메서드에서는 사용자 선호 색상을 Option<ShirtColor> 타입의 매개변수로 받고, user_preference에 대해 unwrap_or_else 메서드를 호출한다. 표준 라이브러리에 정의된 Option<T>unwrap_or_else 메서드는 하나의 인수를 받는다: 이 인수는 아무런 인수를 받지 않고 T 타입의 값을 반환하는 클로저이다. (여기서 TOption<T>Some 변형에 저장된 타입과 동일한 ShirtColor이다.) Option<T>Some 변형이라면, unwrap_or_elseSome 내부의 값을 반환한다. Option<T>None 변형이라면, unwrap_or_else는 클로저를 호출하고 클로저가 반환한 값을 반환한다.

unwrap_or_else의 인수로 클로저 표현식 || self.most_stocked()를 지정한다. 이 클로저는 매개변수를 받지 않는다. (만약 클로저가 매개변수를 받는다면, 두 개의 수직 막대 사이에 나타난다.) 클로저의 본문은 self.most_stocked()를 호출한다. 여기서 클로저를 정의하고, unwrap_or_else의 구현은 필요할 때 클로저를 평가한다.

이 코드를 실행하면 다음과 같은 결과가 출력된다:

$ cargo run
   Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue

여기서 흥미로운 점은 현재 Inventory 인스턴스에서 self.most_stocked()를 호출하는 클로저를 전달했다는 것이다. 표준 라이브러리는 우리가 정의한 InventoryShirtColor 타입, 또는 이 시나리오에서 사용하려는 로직에 대해 아무것도 알 필요가 없다. 클로저는 self Inventory 인스턴스에 대한 불변 참조를 캡처하고, 우리가 지정한 코드와 함께 unwrap_or_else 메서드에 전달한다. 반면, 함수는 이런 방식으로 환경을 캡처할 수 없다.

클로저 타입 추론과 타입 명시

함수와 클로저 사이에는 몇 가지 차이점이 더 있다. 클로저는 일반적으로 fn 함수처럼 매개변수나 반환 값의 타입을 명시할 필요가 없다. 함수의 경우 타입 명시가 필수인데, 이는 타입이 사용자에게 노출되는 명시적 인터페이스의 일부이기 때문이다. 이러한 인터페이스를 엄격하게 정의하는 것은 함수가 어떤 타입의 값을 사용하고 반환하는지에 대해 모두가 동의할 수 있도록 보장하는 데 중요하다. 반면 클로저는 이렇게 노출된 인터페이스에서 사용되지 않는다. 클로저는 변수에 저장되며, 이름을 붙이지 않고 라이브러리 사용자에게 노출하지 않은 채로 사용된다.

클로저는 일반적으로 짧고 특정한 맥락에서만 관련이 있으며, 임의의 시나리오에서 사용되지 않는다. 이러한 제한된 맥락에서 컴파일러는 매개변수와 반환 타입을 추론할 수 있다. 이는 컴파일러가 대부분의 변수 타입을 추론할 수 있는 것과 유사하다(드물게 클로저 타입 명시가 필요한 경우도 있긴 하다).

변수와 마찬가지로, 명시성과 명확성을 높이기 위해 타입을 명시할 수도 있다. 다만 이 경우 불필요하게 장황해질 수 있다. 클로저의 타입을 명시하는 방법은 Listing 13-2에서 볼 수 있다. 이 예제에서는 클로저를 정의하고 변수에 저장한다. Listing 13-1에서처럼 클로저를 인자로 전달하는 즉시 정의하지 않는다.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };

    if intensity < 25 {
        println!("Today, do {} pushups!", expensive_closure(intensity));
        println!("Next, do {} situps!", expensive_closure(intensity));
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!(
                "Today, run for {} minutes!",
                expensive_closure(intensity)
            );
        }
    }
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(simulated_user_specified_value, simulated_random_number);
}
Listing 13-2: 클로저의 매개변수와 반환 값 타입을 선택적으로 명시

타입 명시를 추가하면 클로저의 문법이 함수 문법과 더 유사해진다. 여기서는 매개변수에 1을 더하는 함수와 동일한 동작을 하는 클로저를 정의하여 비교한다. 관련 부분을 정렬하기 위해 공백을 추가했다. 이 예제는 클로저 문법이 파이프(|)를 사용하고 선택적 문법이 많다는 점을 제외하면 함수 문법과 유사하다는 것을 보여준다:

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

첫 번째 줄은 함수 정의를 보여주고, 두 번째 줄은 타입이 완전히 명시된 클로저 정의를 보여준다. 세 번째 줄에서는 클로저 정의에서 타입 명시를 제거했다. 네 번째 줄에서는 클로저 본문이 단일 표현식이므로 선택적인 중괄호를 제거했다. 이 모든 정의는 호출 시 동일한 동작을 생성하는 유효한 정의들이다. add_one_v3add_one_v4 줄은 클로저가 평가되어야 컴파일할 수 있는데, 이는 타입이 사용법에서 추론되기 때문이다. 이는 let v = Vec::new();가 타입 명시나 특정 타입의 값을 Vec에 삽입해야 Rust가 타입을 추론할 수 있는 것과 유사하다.

클로저 정의의 경우 컴파일러는 각 매개변수와 반환 값에 대해 하나의 구체적인 타입을 추론한다. 예를 들어, Listing 13-3은 매개변수로 받은 값을 그대로 반환하는 짧은 클로저의 정의를 보여준다. 이 클로저는 예제를 위한 목적 외에는 그다지 유용하지 않다. 정의에 어떤 타입 명시도 추가하지 않았음을 주목하라. 타입 명시가 없기 때문에 클로저를 어떤 타입으로도 호출할 수 있다. 여기서는 처음에 String으로 호출했다. 그런 다음 example_closure를 정수로 호출하려고 하면 오류가 발생한다.

Filename: src/main.rs
fn main() {
    let example_closure = |x| x;

    let s = example_closure(String::from("hello"));
    let n = example_closure(5);
}
Listing 13-3: 추론된 타입의 클로저를 두 가지 다른 타입으로 호출하려고 시도

컴파일러는 다음과 같은 오류를 발생시킨다:

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |             --------------- ^- help: try using a conversion method: `.to_string()`
  |             |               |
  |             |               expected `String`, found integer
  |             arguments to this function are incorrect
  |
note: expected because the closure was earlier called with an argument of type `String`
 --> src/main.rs:4:29
  |
4 |     let s = example_closure(String::from("hello"));
  |             --------------- ^^^^^^^^^^^^^^^^^^^^^ expected because this argument is of type `String`
  |             |
  |             in this closure call
note: closure parameter defined here
 --> src/main.rs:2:28
  |
2 |     let example_closure = |x| x;
  |                            ^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` (bin "closure-example") due to 1 previous error

String 값으로 example_closure를 처음 호출할 때 컴파일러는 x의 타입과 클로저의 반환 타입을 String으로 추론한다. 이 타입은 example_closure의 클로저에 고정되며, 이후 동일한 클로저를 다른 타입으로 사용하려고 하면 타입 오류가 발생한다.

참조 캡처와 소유권 이동

클로저는 환경에서 값을 캡처하는 세 가지 방법을 사용할 수 있다. 이는 함수가 매개변수를 받는 세 가지 방식과 직접적으로 대응한다: 불변 참조, 가변 참조, 그리고 소유권 가져오기. 클로저는 캡처한 값을 함수 본문에서 어떻게 사용하는지에 따라 이 중 어떤 방식을 사용할지 결정한다.

리스트 13-4에서는 list라는 벡터에 대한 불변 참조를 캡처하는 클로저를 정의한다. 이는 단순히 값을 출력하기 위해 불변 참조만 필요하기 때문이다.

Filename: src/main.rs
fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let only_borrows = || println!("From closure: {list:?}");

    println!("Before calling closure: {list:?}");
    only_borrows();
    println!("After calling closure: {list:?}");
}
Listing 13-4: 불변 참조를 캡처하는 클로저 정의 및 호출

이 예제는 변수가 클로저 정의에 바인딩될 수 있고, 나중에 변수 이름과 괄호를 사용해 함수 이름처럼 클로저를 호출할 수 있음을 보여준다.

list에 대한 불변 참조를 동시에 여러 개 가질 수 있기 때문에, 클로저 정의 전, 클로저 정의 후 호출 전, 그리고 클로저 호출 후에도 list는 여전히 접근 가능하다. 이 코드는 컴파일되고 실행되며 다음과 같이 출력된다.

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

다음으로, 리스트 13-5에서는 클로저 본문을 변경해 list 벡터에 요소를 추가한다. 이제 클로저는 가변 참조를 캡처한다.

Filename: src/main.rs
fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {list:?}");
}
Listing 13-5: 가변 참조를 캡처하는 클로저 정의 및 호출

이 코드는 컴파일되고 실행되며 다음과 같이 출력된다.

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

borrows_mutably 클로저의 정의와 호출 사이에 더 이상 println!이 없음을 주목하라. borrows_mutably가 정의될 때, list에 대한 가변 참조를 캡처한다. 클로저를 호출한 후에는 다시 사용하지 않으므로 가변 참조는 종료된다. 클로저 정의와 호출 사이에 불변 참조를 사용해 출력하는 것은 허용되지 않는다. 가변 참조가 있는 동안에는 다른 참조가 허용되지 않기 때문이다. 여기에 println!을 추가해 어떤 에러 메시지가 나오는지 확인해 보라!

클로저 본문이 엄밀히 소유권을 필요로 하지 않더라도, 클로저가 환경에서 사용하는 값의 소유권을 강제로 가져오게 하려면 매개변수 목록 앞에 move 키워드를 사용할 수 있다.

이 기법은 주로 클로저를 새로운 스레드에 전달해 데이터를 이동시켜 새로운 스레드가 소유하도록 할 때 유용하다. 스레드와 이를 사용해야 하는 이유에 대해서는 16장에서 동시성을 다룰 때 자세히 설명하겠지만, 지금은 move 키워드가 필요한 클로저를 사용해 새로운 스레드를 생성하는 방법을 간단히 살펴보자. 리스트 13-6은 리스트 13-4를 수정해 벡터를 메인 스레드가 아닌 새로운 스레드에서 출력하도록 한 예제다.

Filename: src/main.rs
use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {list:?}");

    thread::spawn(move || println!("From thread: {list:?}"))
        .join()
        .unwrap();
}
Listing 13-6: move를 사용해 스레드의 클로저가 list의 소유권을 가져오도록 강제

새로운 스레드를 생성하고, 스레드에 실행할 클로저를 인자로 전달한다. 클로저 본문은 리스트를 출력한다. 리스트 13-4에서는 클로저가 list를 불변 참조로만 캡처했다. 이는 list를 출력하는 데 필요한 최소한의 접근이기 때문이다. 이 예제에서는 클로저 본문이 여전히 불변 참조만 필요하지만, list가 클로저로 이동되어야 함을 나타내기 위해 클로저 정의 시작 부분에 move 키워드를 추가했다. 새로운 스레드는 메인 스레드의 나머지 부분이 완료되기 전에 끝날 수도 있고, 메인 스레드가 먼저 끝날 수도 있다. 만약 메인 스레드가 list의 소유권을 유지한 채로 새로운 스레드보다 먼저 종료되어 list를 드롭한다면, 스레드의 불변 참조는 무효가 된다. 따라서 컴파일러는 list가 새로운 스레드에 전달된 클로저로 이동되어야 함을 요구한다. move 키워드를 제거하거나 클로저 정의 후 메인 스레드에서 list를 사용해 어떤 컴파일러 에러가 발생하는지 확인해 보라!

클로저에서 캡처한 값을 이동시키기와 Fn 트레이트

클로저가 정의된 환경에서 값을 참조로 캡처하거나 소유권을 캡처하면, 클로저가 평가될 때 이 참조나 값에 어떤 일이 발생할지는 클로저 본문의 코드가 결정한다. 클로저 본문은 캡처한 값을 클로저 밖으로 이동시키거나, 캡처한 값을 변경하거나, 값을 이동시키거나 변경하지 않거나, 아무것도 캡처하지 않을 수 있다.

클로저가 환경에서 값을 캡처하고 처리하는 방식은 클로저가 구현하는 트레이트에 영향을 미친다. 트레이트는 함수와 구조체가 어떤 종류의 클로저를 사용할 수 있는지 지정하는 방법이다. 클로저는 본문이 값을 어떻게 처리하는지에 따라 Fn 트레이트 중 하나, 둘, 혹은 모두를 자동으로 구현한다.

  1. FnOnce는 한 번만 호출할 수 있는 클로저에 적용된다. 모든 클로저는 최소한 이 트레이트를 구현한다. 캡처한 값을 본문 밖으로 이동시키는 클로저는 FnOnce만 구현하고 다른 Fn 트레이트는 구현하지 않는다. 이 클로저는 한 번만 호출할 수 있다.
  2. FnMut는 캡처한 값을 본문 밖으로 이동시키지는 않지만, 캡처한 값을 변경할 수 있는 클로저에 적용된다. 이 클로저는 여러 번 호출할 수 있다.
  3. Fn은 캡처한 값을 본문 밖으로 이동시키지 않고, 캡처한 값을 변경하지 않으며, 환경에서 아무것도 캡처하지 않는 클로저에 적용된다. 이 클로저는 환경을 변경하지 않고 여러 번 호출할 수 있다. 이는 클로저를 동시에 여러 번 호출하는 경우에 중요하다.

Option<T>unwrap_or_else 메서드 정의를 살펴보자:

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

TOptionSome 변형에 있는 값의 타입을 나타내는 제네릭 타입이다. 이 타입 Tunwrap_or_else 함수의 반환 타입이기도 하다. 예를 들어, Option<String>에서 unwrap_or_else를 호출하면 String을 얻는다.

unwrap_or_else 함수에는 추가적인 제네릭 타입 매개변수 F가 있다. F 타입은 f라는 이름의 매개변수 타입으로, unwrap_or_else를 호출할 때 제공하는 클로저이다.

제네릭 타입 F에 지정된 트레이트 바운드는 FnOnce() -> T이다. 이는 F가 한 번 호출될 수 있고, 인수를 받지 않으며, T를 반환해야 함을 의미한다. FnOnce를 트레이트 바운드로 사용하면 unwrap_or_elsef를 최대 한 번만 호출한다는 제약을 표현한다. unwrap_or_else의 본문에서 볼 수 있듯이, OptionSome이면 f가 호출되지 않는다. OptionNone이면 f가 한 번 호출된다. 모든 클로저가 FnOnce를 구현하므로, unwrap_or_else는 세 가지 종류의 클로저를 모두 허용하고 가능한 한 유연하다.

참고: 환경에서 값을 캡처할 필요가 없는 경우, 클로저 대신 함수 이름을 사용할 수 있다. 예를 들어, Option<Vec<T>> 값에서 unwrap_or_else(Vec::new)를 호출하면 값이 None일 때 새로운 빈 벡터를 얻을 수 있다. 컴파일러는 함수 정의에 적용 가능한 Fn 트레이트를 자동으로 구현한다.

이제 슬라이스에 정의된 표준 라이브러리 메서드 sort_by_key를 살펴보자. 이 메서드가 unwrap_or_else와 어떻게 다른지, 그리고 왜 FnOnce 대신 FnMut를 트레이트 바운드로 사용하는지 알아보자. 클로저는 슬라이스의 현재 항목에 대한 참조 형태로 하나의 인수를 받고, 정렬할 수 있는 타입 K의 값을 반환한다. 이 함수는 슬라이스를 각 항목의 특정 속성으로 정렬하고 싶을 때 유용하다. 예제 13-7에서는 Rectangle 인스턴스 목록을 width 속성으로 낮은 순서부터 높은 순서로 정렬하기 위해 sort_by_key를 사용한다:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    list.sort_by_key(|r| r.width);
    println!("{list:#?}");
}
Listing 13-7: sort_by_key를 사용해 사각형을 너비로 정렬

이 코드는 다음과 같이 출력한다:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/rectangles`
[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

sort_by_keyFnMut 클로저를 받도록 정의된 이유는 클로저를 여러 번 호출하기 때문이다: 슬라이스의 각 항목에 대해 한 번씩 호출한다. 클로저 |r| r.width는 환경에서 아무것도 캡처하거나 변경하거나 이동시키지 않으므로 트레이트 바운드 요구 사항을 충족한다.

반면, 예제 13-8은 환경에서 값을 이동시키므로 FnOnce 트레이트만 구현하는 클로저를 보여준다. 컴파일러는 이 클로저를 sort_by_key와 함께 사용할 수 없게 한다:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut sort_operations = vec![];
    let value = String::from("closure called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{list:#?}");
}
Listing 13-8: sort_by_key와 함께 FnOnce 클로저 사용 시도

이 코드는 list를 정렬할 때 sort_by_key가 클로저를 몇 번 호출하는지 세려고 하는 복잡하고 인위적인 방법(작동하지 않는)을 보여준다. 이 코드는 클로저의 환경에서 Stringvaluesort_operations 벡터로 푸시하여 이 카운팅을 시도한다. 클로저는 value를 캡처한 후 value의 소유권을 sort_operations 벡터로 이전하여 value를 클로저 밖으로 이동시킨다. 이 클로저는 한 번만 호출할 수 있다; 두 번째로 호출하려고 하면 value가 더 이상 환경에 없으므로 sort_operations에 다시 푸시할 수 없다! 따라서 이 클로저는 FnOnce만 구현한다. 이 코드를 컴파일하려고 하면 클로저가 FnMut를 구현해야 하므로 value를 클로저 밖으로 이동시킬 수 없다는 오류가 발생한다:

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
  --> src/main.rs:18:30
   |
15 |     let value = String::from("closure called");
   |         ----- captured outer variable
16 |
17 |     list.sort_by_key(|r| {
   |                      --- captured by this `FnMut` closure
18 |         sort_operations.push(value);
   |                              ^^^^^ move occurs because `value` has type `String`, which does not implement the `Copy` trait
   |
help: consider cloning the value if the performance cost is acceptable
   |
18 |         sort_operations.push(value.clone());
   |                                   ++++++++

For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` (bin "rectangles") due to 1 previous error

오류는 value를 환경 밖으로 이동시키는 클로저 본문의 라인을 가리킨다. 이 문제를 해결하려면 클로저 본문을 변경하여 값을 환경 밖으로 이동시키지 않아야 한다. 환경에 카운터를 유지하고 클로저 본문에서 그 값을 증가시키는 것이 클로저가 호출된 횟수를 세는 더 직관적인 방법이다. 예제 13-9의 클로저는 num_sort_operations 카운터에 대한 가변 참조만 캡처하므로 sort_by_key와 함께 작동한다:

Filename: src/main.rs
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut num_sort_operations = 0;
    list.sort_by_key(|r| {
        num_sort_operations += 1;
        r.width
    });
    println!("{list:#?}, sorted in {num_sort_operations} operations");
}
Listing 13-9: sort_by_key와 함께 FnMut 클로저 사용

Fn 트레이트는 클로저를 사용하는 함수나 타입을 정의하거나 사용할 때 중요하다. 다음 섹션에서는 이터레이터를 다룰 것이다. 많은 이터레이터 메서드가 클로저 인수를 받으므로, 계속 진행하면서 이 클로저 세부 사항을 기억하자!

아이템 시퀀스 처리와 이터레이터

이터레이터 패턴을 사용하면 아이템 시퀀스에 대해 순차적으로 작업을 수행할 수 있다. 이터레이터는 각 아이템을 순회하고 시퀀스가 끝났는지 판단하는 로직을 담당한다. 이터레이터를 사용하면 이러한 로직을 직접 구현할 필요가 없다.

Rust에서 이터레이터는 게으른(lazy) 특성을 가진다. 이는 이터레이터를 소모하는 메서드를 호출하기 전까지는 아무런 효과가 없다는 의미다. 예를 들어, 리스트 13-10은 Vec<T>에 정의된 iter 메서드를 호출해 벡터 v1의 아이템에 대한 이터레이터를 생성한다. 이 코드 자체로는 유용한 작업을 수행하지 않는다.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}
Listing 13-10: 이터레이터 생성

이터레이터는 v1_iter 변수에 저장된다. 이터레이터를 생성한 후에는 다양한 방식으로 사용할 수 있다. 3장의 리스트 3-5에서는 for 루프를 사용해 배열을 순회하며 각 아이템에 대해 코드를 실행했다. 이때 내부적으로 이터레이터가 암시적으로 생성되고 소모되었지만, 지금까지는 그 동작 원리를 자세히 다루지 않았다.

리스트 13-11의 예제에서는 이터레이터 생성과 for 루프에서의 사용을 분리했다. v1_iter에 있는 이터레이터를 사용해 for 루프가 호출되면, 이터레이터의 각 엘리먼트가 루프의 한 번의 반복에서 사용되며, 각 값을 출력한다.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("Got: {val}");
    }
}
Listing 13-11: for 루프에서 이터레이터 사용

표준 라이브러리에서 이터레이터를 제공하지 않는 언어에서는, 인덱스 0부터 시작하는 변수를 사용해 벡터에서 값을 가져오고, 루프에서 변수 값을 증가시키며 벡터의 전체 아이템 수에 도달할 때까지 반복하는 방식으로 동일한 기능을 구현할 가능성이 높다.

이터레이터는 이러한 모든 로직을 대신 처리해주며, 잠재적으로 실수할 수 있는 반복 코드를 줄여준다. 이터레이터는 벡터처럼 인덱스로 접근할 수 있는 데이터 구조뿐만 아니라 다양한 종류의 시퀀스에 동일한 로직을 사용할 수 있는 유연성을 제공한다. 이터레이터가 어떻게 이를 가능하게 하는지 살펴보자.

Iterator 트레이트와 next 메서드

모든 이터레이터는 표준 라이브러리에 정의된 Iterator라는 트레이트를 구현한다. 이 트레이트의 정의는 다음과 같다:

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // 기본 구현이 있는 메서드들은 생략됨
}
}

이 정의에서 새로운 구문인 type ItemSelf::Item이 사용되었음을 알 수 있다. 이는 이 트레이트와 연관된 타입을 정의하는 것이다. 연관 타입에 대해서는 20장에서 자세히 다룰 예정이다. 지금은 Iterator 트레이트를 구현하려면 Item 타입도 정의해야 하며, 이 Item 타입이 next 메서드의 반환 타입으로 사용된다는 점만 알아두면 된다. 즉, Item 타입은 이터레이터가 반환할 타입이 된다.

Iterator 트레이트는 구현자가 오직 하나의 메서드만 정의하도록 요구한다: 바로 next 메서드다. 이 메서드는 이터레이터에서 한 번에 하나의 아이템을 반환하며, Some으로 감싸져 있다. 이터레이션이 끝나면 None을 반환한다.

이터레이터에서 직접 next 메서드를 호출할 수 있다. 리스트 13-12는 벡터에서 생성된 이터레이터에 next를 반복적으로 호출했을 때 어떤 값이 반환되는지 보여준다.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_demonstration() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}
Listing 13-12: 이터레이터에서 next 메서드 호출

v1_iter를 가변으로 만들어야 한다는 점에 주목하자: 이터레이터에서 next 메서드를 호출하면 이터레이터가 시퀀스 내에서 현재 위치를 추적하기 위해 사용하는 내부 상태가 변경된다. 즉, 이 코드는 이터레이터를 소비하거나 사용한다. next를 호출할 때마다 이터레이터의 아이템을 하나씩 소비한다. for 루프를 사용할 때는 v1_iter를 가변으로 만들 필요가 없었는데, 이는 루프가 v1_iter의 소유권을 가져와서 내부적으로 가변으로 만들었기 때문이다.

또한 next를 호출하여 얻은 값들은 벡터 내 값에 대한 불변 참조라는 점도 주목하자. iter 메서드는 불변 참조에 대한 이터레이터를 생성한다. 만약 v1의 소유권을 가져오고 소유한 값을 반환하는 이터레이터를 만들고 싶다면, iter 대신 into_iter를 호출하면 된다. 마찬가지로, 가변 참조를 순회하고 싶다면 iter 대신 iter_mut를 호출하면 된다.

이터레이터를 소비하는 메서드

Iterator 트레이트는 표준 라이브러리에서 제공하는 기본 구현을 가진 여러 메서드를 포함한다. 이 메서드들에 대한 자세한 정보는 표준 라이브러리 API 문서에서 Iterator 트레이트를 참조하면 된다. 이 메서드들 중 일부는 정의 내부에서 next 메서드를 호출하기 때문에, Iterator 트레이트를 구현할 때 next 메서드를 반드시 구현해야 한다.

next를 호출하는 메서드는 소비 어댑터(consuming adapters) 라고 불린다. 이 메서드들을 호출하면 이터레이터가 소비되기 때문이다. 예를 들어, sum 메서드는 이터레이터의 소유권을 가져와 next를 반복적으로 호출하며 아이템을 순회한다. 이 과정에서 각 아이템을 누적 합계에 더하고, 순회가 완료되면 총합을 반환한다. 리스트 13-13은 sum 메서드를 사용하는 예제를 보여준다.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}
Listing 13-13: sum 메서드를 호출하여 이터레이터의 모든 아이템의 총합을 구하기

sum을 호출한 후에는 v1_iter를 사용할 수 없다. sum이 호출된 이터레이터의 소유권을 가져가기 때문이다.

다른 이터레이터를 생성하는 메서드

_이터레이터 어댑터_는 Iterator 트레이트에 정의된 메서드로, 이터레이터를 소비하지 않는다. 대신, 원래 이터레이터의 일부를 변경하여 새로운 이터레이터를 생성한다.

리스트 13-14는 이터레이터 어댑터 메서드인 map을 호출하는 예제를 보여준다. map은 각 항목을 순회할 때 호출할 클로저를 인자로 받는다. map 메서드는 수정된 항목을 생성하는 새로운 이터레이터를 반환한다. 여기서 클로저는 벡터의 각 항목에 1을 더한 새로운 이터레이터를 생성한다:

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}
Listing 13-14: 새로운 이터레이터를 생성하기 위해 이터레이터 어댑터 map 호출

하지만 이 코드는 다음과 같은 경고를 발생시킨다:

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
4 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++

warning: `iterators` (bin "iterators") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

리스트 13-14의 코드는 아무런 동작을 하지 않는다. 지정한 클로저가 호출되지 않기 때문이다. 이 경고는 이터레이터 어댑터가 지연 평가(lazy)되며, 이터레이터를 소비해야 한다는 사실을 상기시켜 준다.

이 경고를 해결하고 이터레이터를 소비하기 위해, 12장의 리스트 12-1에서 env::args와 함께 사용한 collect 메서드를 사용한다. 이 메서드는 이터레이터를 소비하고 결과 값을 컬렉션 데이터 타입으로 모은다.

리스트 13-15에서는 map 호출로 반환된 이터레이터를 순회한 결과를 벡터로 모은다. 이 벡터는 원래 벡터의 각 항목에 1을 더한 값을 포함하게 된다.

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}
Listing 13-15: 새로운 이터레이터를 생성하기 위해 map 메서드를 호출하고, collect 메서드를 호출하여 새로운 이터레이터를 소비하고 벡터를 생성

map은 클로저를 인자로 받기 때문에, 각 항목에 대해 수행하고 싶은 어떤 연산이든 지정할 수 있다. 이는 클로저가 Iterator 트레이트가 제공하는 반복 동작을 재사용하면서도 특정 동작을 커스터마이즈할 수 있는 좋은 예시이다.

여러 이터레이터 어댑터 호출을 연결하여 복잡한 동작을 가독성 있게 수행할 수 있다. 하지만 모든 이터레이터는 지연 평가되기 때문에, 이터레이터 어댑터 호출의 결과를 얻으려면 소비형 어댑터 메서드 중 하나를 호출해야 한다.

환경을 캡처하는 클로저 사용하기

많은 이터레이터 어댑터는 클로저를 인자로 받는다. 특히 이터레이터 어댑터에 전달하는 클로저는 주변 환경을 캡처하는 경우가 많다.

이 예제에서는 클로저를 인자로 받는 filter 메서드를 사용한다. 이 클로저는 이터레이터에서 아이템을 받아 bool 값을 반환한다. 클로저가 true를 반환하면, filter가 생성한 이터레이션에 해당 값이 포함된다. false를 반환하면 포함되지 않는다.

리스트 13-16에서는 filter를 사용해 주변 환경에서 shoe_size 변수를 캡처하는 클로저를 적용한다. 이를 통해 Shoe 구조체 인스턴스 컬렉션을 순회하며 지정된 크기의 신발만 반환한다.

Filename: src/lib.rs
#[derive(PartialEq, Debug)]
struct Shoe {
    size: u32,
    style: String,
}

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filters_by_size() {
        let shoes = vec![
            Shoe {
                size: 10,
                style: String::from("sneaker"),
            },
            Shoe {
                size: 13,
                style: String::from("sandal"),
            },
            Shoe {
                size: 10,
                style: String::from("boot"),
            },
        ];

        let in_my_size = shoes_in_size(shoes, 10);

        assert_eq!(
            in_my_size,
            vec![
                Shoe {
                    size: 10,
                    style: String::from("sneaker")
                },
                Shoe {
                    size: 10,
                    style: String::from("boot")
                },
            ]
        );
    }
}
Listing 13-16: shoe_size를 캡처하는 클로저와 함께 filter 메서드 사용하기

shoes_in_size 함수는 신발 벡터와 신발 크기를 매개변수로 받아 해당 크기의 신발만 포함된 벡터를 반환한다.

shoes_in_size 함수 본문에서는 into_iter를 호출해 벡터의 소유권을 가진 이터레이터를 생성한다. 그런 다음 filter를 호출해 클로저가 true를 반환하는 요소만 포함된 새로운 이터레이터로 변환한다.

클로저는 환경에서 shoe_size 매개변수를 캡처하고, 각 신발의 크기와 비교해 지정된 크기의 신발만 남긴다. 마지막으로 collect를 호출해 변환된 이터레이터가 반환한 값을 벡터로 모아 함수에서 반환한다.

테스트를 통해 shoes_in_size를 호출하면 지정한 크기와 동일한 신발만 반환되는 것을 확인할 수 있다.

I/O 프로젝트 개선하기

이제 이터레이터에 대해 배운 새로운 지식을 활용해 12장의 I/O 프로젝트를 개선할 수 있다. 이터레이터를 사용하면 코드를 더 명확하고 간결하게 만들 수 있다. 이제 Config::build 함수와 search 함수의 구현을 어떻게 개선할 수 있는지 살펴보자.

clone 제거하기: 이터레이터 활용

리스트 12-6에서는 String 값들의 슬라이스를 가져와 Config 구조체의 인스턴스를 생성했다. 이때 슬라이스의 특정 인덱스에 접근해 값을 복제(clone)하는 방식을 사용했는데, 이는 Config 구조체가 해당 값들의 소유권을 가지기 위함이었다. 리스트 13-17에서는 리스트 12-23의 Config::build 함수 구현을 그대로 재현했다.

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-17: 리스트 12-23의 Config::build 함수 재현

당시에는 비효율적인 clone 호출에 대해 걱정하지 말라고 했는데, 그 이유는 나중에 이를 제거할 계획이었기 때문이다. 이제 그때가 왔다!

clone이 필요했던 이유는 args 파라미터에 String 요소를 가진 슬라이스가 있었지만, build 함수가 args의 소유권을 가지지 않기 때문이다. Config 인스턴스의 소유권을 반환하려면 Configqueryfile_path 필드의 값을 복제해야 했다. 이렇게 해야 Config 인스턴스가 자신의 값을 소유할 수 있었다.

이제 이터레이터에 대한 새로운 지식을 활용해 build 함수를 수정할 수 있다. 슬라이스를 빌려오는 대신 이터레이터를 인자로 받아 소유권을 가지도록 변경한다. 슬라이스의 길이를 확인하고 특정 위치에 접근하는 코드 대신 이터레이터 기능을 사용할 것이다. 이렇게 하면 Config::build 함수가 무엇을 하는지 더 명확해진다. 이터레이터가 값을 직접 접근하기 때문이다.

Config::build가 이터레이터의 소유권을 가지고, 빌려오는 인덱스 연산을 더 이상 사용하지 않으면, clone을 호출해 새로운 메모리를 할당하는 대신 이터레이터에서 String 값을 Config로 바로 이동시킬 수 있다.

반환된 이터레이터 직접 사용하기

I/O 프로젝트의 src/main.rs 파일을 열면 다음과 같은 코드가 보일 것이다:

파일명: src/main.rs

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

먼저 리스팅 12-24에서 작성한 main 함수의 시작 부분을 리스팅 13-18의 코드로 변경한다. 이번에는 이터레이터를 사용한다. Config::build도 업데이트하기 전까지는 컴파일되지 않는다.

Filename: src/main.rs
use std::env;
use std::process;

use minigrep::Config;

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    // --snip--

    if let Err(e) = minigrep::run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}
Listing 13-18: env::args의 반환 값을 Config::build에 전달

env::args 함수는 이터레이터를 반환한다! 이터레이터 값을 벡터로 모아서 슬라이스를 Config::build에 전달하는 대신, 이제는 env::args에서 반환된 이터레이터의 소유권을 직접 Config::build에 전달한다.

다음으로 Config::build의 정의를 업데이트해야 한다. I/O 프로젝트의 src/lib.rs 파일에서 Config::build의 시그니처를 리스팅 13-19와 같이 변경한다. 함수 본문도 업데이트해야 하기 때문에 아직 컴파일되지 않는다.

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        // --snip--
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-19: 이터레이터를 기대하도록 Config::build의 시그니처 업데이트

env::args 함수의 표준 라이브러리 문서를 보면, 이 함수가 반환하는 이터레이터의 타입은 std::env::Args이며, 이 타입은 Iterator 트레잇을 구현하고 String 값을 반환한다.

Config::build 함수의 시그니처를 업데이트하여 args 매개변수가 &[String] 대신 impl Iterator<Item = String> 트레잇 바운드를 가진 제네릭 타입을 갖도록 했다. “트레잇을 매개변수로 사용하기” 섹션에서 논의한 impl Trait 문법을 사용하면, argsIterator 트레잇을 구현하고 String 아이템을 반환하는 어떤 타입이든 될 수 있다.

args의 소유권을 가져오고, 이터레이터를 통해 args를 변경할 것이기 때문에, args 매개변수에 mut 키워드를 추가하여 가변적으로 만들 수 있다.

인덱싱 대신 Iterator 트레이트 메서드 사용하기

이제 Config::build 함수의 본문을 수정한다. argsIterator 트레이트를 구현하기 때문에 next 메서드를 호출할 수 있다. 리스팅 13-20은 리스팅 12-23의 코드를 업데이트하여 next 메서드를 사용한다.

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-20: Config::build의 본문을 iterator 메서드를 사용하도록 변경

env::args의 반환값에서 첫 번째 값은 프로그램 이름이다. 이 값을 무시하고 다음 값으로 넘어가기 위해 먼저 next를 호출하고 반환값은 사용하지 않는다. 그런 다음 next를 다시 호출해 Configquery 필드에 넣을 값을 가져온다. nextSome을 반환하면 match를 사용해 값을 추출한다. None을 반환하면 충분한 인자가 제공되지 않았다는 의미이므로 Err 값을 반환하고 함수를 종료한다. file_path 값에 대해서도 동일한 작업을 수행한다.

이터레이터 어댑터로 코드를 명확하게 만들기

우리는 I/O 프로젝트의 search 함수에서도 이터레이터를 활용할 수 있다. 이 함수는 12장의 리스트 12-19와 동일하게 리스트 13-21에 재현되어 있다:

Filename: src/lib.rs
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
}

impl Config {
    pub fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
}
Listing 13-21: 리스트 12-19의 search 함수 구현

이 코드를 이터레이터 어댑터 메서드를 사용해 더 간결하게 작성할 수 있다. 이렇게 하면 가변적인 중간 results 벡터를 사용하지 않아도 된다. 함수형 프로그래밍 스타일은 코드를 명확하게 만들기 위해 가변 상태를 최소화하는 것을 선호한다. 가변 상태를 제거하면 나중에 검색을 병렬로 수행할 수 있는 개선이 가능해질 수 있다. 왜냐하면 results 벡터에 대한 동시 접근을 관리할 필요가 없기 때문이다. 리스트 13-22는 이 변경 사항을 보여준다:

Filename: src/lib.rs
use std::env;
use std::error::Error;
use std::fs;

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    pub fn build(
        mut args: impl Iterator<Item = String>,
    ) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let file_path = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file path"),
        };

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contents = fs::read_to_string(config.file_path)?;

    let results = if config.ignore_case {
        search_case_insensitive(&config.query, &contents)
    } else {
        search(&config.query, &contents)
    };

    for line in results {
        println!("{line}");
    }

    Ok(())
}

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents
        .lines()
        .filter(|line| line.contains(query))
        .collect()
}

pub fn search_case_insensitive<'a>(
    query: &str,
    contents: &'a str,
) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}
Listing 13-22: search 함수 구현에서 이터레이터 어댑터 메서드 사용

search 함수의 목적은 contents에서 query를 포함하는 모든 라인을 반환하는 것이다. 리스트 13-16의 filter 예제와 유사하게, 이 코드는 filter 어댑터를 사용해 line.contains(query)true를 반환하는 라인만 유지한다. 그런 다음 일치하는 라인들을 collect를 사용해 다른 벡터로 모은다. 훨씬 간단하다! search_case_insensitive 함수에서도 동일한 변경을 적용해 이터레이터 메서드를 사용해 보자.

루프와 이터레이터 중 선택하기

다음으로 자연스럽게 떠오르는 질문은 여러분의 코드에서 어떤 스타일을 선택해야 하는지와 그 이유이다. 리스트 13-21의 원래 구현과 리스트 13-22의 이터레이터를 사용한 버전 중 어떤 것을 선택할 것인가? 대부분의 Rust 프로그래머는 이터레이터 스타일을 선호한다. 처음에는 익숙해지기 어려울 수 있지만, 다양한 이터레이터 어댑터와 그 기능을 이해하면 이터레이터가 더 쉽게 이해될 수 있다. 루프의 다양한 부분을 다루고 새로운 벡터를 만드는 대신, 코드는 루프의 상위 목표에 집중한다. 이는 일반적인 코드를 추상화하여 이터레이터의 각 요소가 통과해야 하는 필터링 조건과 같은 이 코드에만 해당하는 개념을 더 쉽게 파악할 수 있도록 한다.

하지만 두 구현이 정말 동등한가? 직관적으로는 더 낮은 수준의 루프가 더 빠를 것이라고 가정할 수 있다. 이제 성능에 대해 이야기해보자.

성능 비교: 루프 vs 이터레이터

루프와 이터레이터 중 어떤 것을 사용할지 결정하려면, 두 구현 중 어떤 것이 더 빠른지 알아야 한다. 명시적인 for 루프를 사용한 search 함수 버전과 이터레이터를 사용한 버전의 성능을 비교해 보자.

우리는 아서 코난 도일 경의 《셜록 홈즈의 모험》 전체 내용을 String으로 불러온 후, 그 안에서 단어 _the_를 찾는 벤치마크를 실행했다. for 루프를 사용한 search 버전과 이터레이터를 사용한 버전의 벤치마크 결과는 다음과 같다:

test bench_search_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

두 구현의 성능이 거의 비슷하다! 여기서 벤치마크 코드를 자세히 설명하지는 않을 것인데, 그 이유는 두 버전이 동등하다는 것을 증명하려는 것이 아니라, 두 구현의 성능이 어떻게 비교되는지 대략적으로 파악하는 것이 목적이기 때문이다.

더 포괄적인 벤치마크를 위해서는 다양한 크기의 텍스트를 contents로 사용하고, 다양한 단어와 길이의 단어를 query로 사용하며, 다른 여러 변형을 적용해 보는 것이 좋다. 중요한 점은 이터레이터가 높은 수준의 추상화임에도 불구하고, 직접 저수준 코드를 작성한 것과 거의 동일한 코드로 컴파일된다는 것이다. 이터레이터는 Rust의 제로 코스트 추상화 중 하나로, 이 추상화를 사용해도 런타임 오버헤드가 추가되지 않는다. 이는 C++의 원래 설계자이자 구현자인 비야네 스트롭스트룹이 《Foundations of C++》(2012)에서 정의한 _제로 오버헤드_와 유사하다:

일반적으로 C++ 구현은 제로 오버헤드 원칙을 따른다: 사용하지 않는 것에 대해서는 비용을 지불하지 않는다. 그리고 더 나아가: 사용하는 것에 대해서는 손으로 작성한 코드보다 더 나은 코드를 만들 수 없다.

또 다른 예로, 다음 코드는 오디오 디코더에서 가져온 것이다. 디코딩 알고리즘은 선형 예측 수학 연산을 사용해 이전 샘플의 선형 함수를 기반으로 미래 값을 예측한다. 이 코드는 스코프 내의 세 변수인 데이터의 buffer 슬라이스, 12개의 coefficients 배열, 그리고 qlp_shift에서 데이터를 이동시킬 양에 대해 수학 연산을 수행하기 위해 이터레이터 체인을 사용한다. 이 예제에서 변수들을 선언했지만 값을 할당하지는 않았다. 이 코드는 컨텍스트 외부에서는 큰 의미가 없지만, Rust가 높은 수준의 아이디어를 저수준 코드로 어떻게 변환하는지에 대한 간결하고 실제적인 예제이다.

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

prediction 값을 계산하기 위해, 이 코드는 coefficients의 12개 값을 순회하며 zip 메서드를 사용해 계수 값과 buffer의 이전 12개 값을 짝지은 다음, 각 쌍의 값을 곱하고 모든 결과를 합산한 후, 그 합을 qlp_shift 비트만큼 오른쪽으로 이동시킨다.

오디오 디코더와 같은 애플리케이션에서의 계산은 종종 성능을 가장 중요시한다. 여기서는 이터레이터를 생성하고, 두 개의 어댑터를 사용한 다음, 값을 소비한다. 이 Rust 코드가 어떤 어셈블리 코드로 컴파일될까? 현재 시점에서는, 이 코드는 직접 작성한 어셈블리 코드와 동일하게 컴파일된다. coefficients의 값을 순회하는 데 해당하는 루프는 전혀 없다: Rust는 반복 횟수가 12번임을 알고 있기 때문에 루프를 “풀어버린다”. _루프 풀기_는 루프 제어 코드의 오버헤드를 제거하고 대신 각 반복에 대해 반복적인 코드를 생성하는 최적화 기법이다.

모든 계수는 레지스터에 저장되며, 이는 값에 접근하는 속도가 매우 빠르다는 것을 의미한다. 런타임에 배열 접근에 대한 경계 검사도 없다. Rust가 적용할 수 있는 이러한 모든 최적화는 결과 코드를 매우 효율적으로 만든다. 이제 이 사실을 알았으니, 이터레이터와 클로저를 두려움 없이 사용할 수 있다! 이들은 코드를 더 높은 수준으로 보이게 하지만, 그렇게 해도 런타임 성능에 페널티를 주지 않는다.

요약

클로저와 이터레이터는 함수형 프로그래밍 언어에서 아이디어를 얻은 Rust의 기능이다. 이들은 Rust가 높은 수준의 개념을 낮은 수준의 성능으로 명확하게 표현할 수 있도록 돕는다. 클로저와 이터레이터의 구현은 런타임 성능에 영향을 미치지 않도록 설계되었다. 이는 Rust가 추구하는 ‘제로 코스트 추상화’ 목표의 일환이다.

이제 I/O 프로젝트의 표현력을 개선했으니, 프로젝트를 세상과 공유하는 데 도움이 될 cargo의 몇 가지 기능을 살펴보자.

Cargo와 Crates.io에 대한 더 깊은 이해

지금까지 우리는 Cargo의 가장 기본적인 기능만 사용해 코드를 빌드하고 실행하며 테스트해왔다. 하지만 Cargo는 이보다 훨씬 더 많은 기능을 제공한다. 이 장에서는 Cargo의 고급 기능을 살펴보며 다음과 같은 작업을 수행하는 방법을 알아본다:

  • 릴리스 프로필을 통해 빌드를 커스터마이징하는 방법
  • crates.io에 라이브러리를 공개하는 방법
  • 워크스페이스를 사용해 대규모 프로젝트를 구성하는 방법
  • crates.io에서 바이너리를 설치하는 방법
  • 커스텀 커맨드를 사용해 Cargo를 확장하는 방법

이 장에서 다루는 기능 외에도 Cargo는 더 많은 기능을 제공한다. 모든 기능에 대한 자세한 설명은 공식 문서를 참고한다.

릴리스 프로필로 빌드 설정하기

Rust에서 _릴리스 프로필_은 미리 정의된 설정 프로필로, 프로그래머가 코드 컴파일 시 다양한 옵션을 세부적으로 제어할 수 있게 해준다. 각 프로필은 독립적으로 설정된다.

Cargo에는 두 가지 주요 프로필이 있다. cargo build를 실행할 때 사용하는 dev 프로필과 cargo build --release를 실행할 때 사용하는 release 프로필이다. dev 프로필은 개발 환경에 적합한 기본값으로 설정되어 있고, release 프로필은 릴리스 빌드에 적합한 기본값으로 설정되어 있다.

이 프로필 이름은 빌드 출력에서 이미 익숙할 것이다:

$ cargo build
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
$ cargo build --release
    Finished `release` profile [optimized] target(s) in 0.32s

devrelease는 컴파일러가 사용하는 서로 다른 프로필이다.

Cargo는 프로젝트의 Cargo.toml 파일에 [profile.*] 섹션을 명시적으로 추가하지 않았을 때 적용되는 기본 설정을 각 프로필에 대해 가지고 있다. 커스터마이징하고 싶은 프로필에 [profile.*] 섹션을 추가하면 기본 설정의 일부를 재정의할 수 있다. 예를 들어, devrelease 프로필의 opt-level 설정 기본값은 다음과 같다:

파일명: Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

opt-level 설정은 Rust가 코드에 적용할 최적화 수준을 제어하며, 0부터 3까지의 범위를 가진다. 더 많은 최적화를 적용하면 컴파일 시간이 길어진다. 따라서 개발 중에 코드를 자주 컴파일해야 한다면, 결과물이 느리더라도 컴파일 속도를 높이기 위해 최적화 수준을 낮게 유지하는 것이 좋다. 그래서 dev 프로필의 기본 opt-level0이다. 코드를 릴리스할 준비가 되면, 컴파일 시간을 더 투자하는 것이 좋다. 릴리스 모드에서는 한 번만 컴파일하지만, 컴파일된 프로그램을 여러 번 실행하게 되므로, 릴리스 모드는 컴파일 시간을 늘리는 대신 실행 속도를 높인다. 그래서 release 프로필의 기본 opt-level3이다.

_Cargo.toml_에서 다른 값을 추가해 기본 설정을 재정의할 수 있다. 예를 들어, 개발 프로필에서 최적화 수준 1을 사용하고 싶다면 프로젝트의 Cargo.toml 파일에 다음 두 줄을 추가하면 된다:

파일명: Cargo.toml

[profile.dev]
opt-level = 1

이 코드는 기본 설정인 0을 재정의한다. 이제 cargo build를 실행하면, Cargo는 dev 프로필의 기본값에 더해 opt-level에 대한 커스터마이징을 적용한다. opt-level1로 설정했기 때문에, Cargo는 기본값보다는 더 많은 최적화를 적용하지만, 릴리스 빌드만큼은 아니다.

각 프로필의 전체 설정 옵션과 기본값 목록은 Cargo 문서를 참조한다.

크레이트를 Crates.io에 게시하기

우리는 프로젝트의 의존성으로 crates.io의 패키지를 사용해 왔다. 하지만 여러분도 직접 만든 패키지를 다른 사람들과 공유할 수 있다. crates.io의 크레이트 레지스트리는 패키지의 소스 코드를 배포하므로, 주로 오픈 소스 코드를 호스팅한다.

Rust와 Cargo는 여러분이 게시한 패키지를 다른 사람들이 쉽게 찾고 사용할 수 있도록 돕는 다양한 기능을 제공한다. 다음으로 이러한 기능 중 몇 가지를 살펴보고, 패키지를 게시하는 방법을 설명한다.

유용한 문서 주석 작성하기

패키지를 정확하게 문서화하면 다른 사용자가 이를 어떻게, 언제 사용해야 하는지 쉽게 이해할 수 있다. 따라서 문서 작성에 시간을 투자할 가치가 있다. 3장에서는 두 개의 슬래시(//)를 사용해 Rust 코드에 주석을 다는 방법에 대해 설명했다. Rust는 또한 문서 주석이라는 특별한 형태의 주석을 제공한다. 이 주석은 HTML 문서를 생성하며, 패키지의 공개 API 항목에 대한 문서를 프로그래머가 쉽게 이해할 수 있도록 돕는다. 문서 주석은 패키지의 구현 방식이 아니라 사용 방법에 초점을 맞춘다.

문서 주석은 두 개의 슬래시 대신 세 개의 슬래시(///)를 사용하며, Markdown 문법을 지원해 텍스트를 서식화할 수 있다. 문서 주석은 해당 항목 바로 앞에 위치시킨다. 아래 예제는 my_crate라는 크레이트 내 add_one 함수에 대한 문서 주석을 보여준다.

Filename: src/lib.rs
/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}
Listing 14-1: 함수에 대한 문서 주석

여기서는 add_one 함수가 어떤 역할을 하는지 설명하고, Examples라는 섹션을 시작한 후 add_one 함수를 사용하는 방법을 보여주는 예제 코드를 제공한다. 이 문서 주석을 기반으로 HTML 문서를 생성하려면 cargo doc 명령을 실행하면 된다. 이 명령은 Rust와 함께 배포되는 rustdoc 도구를 실행하고, 생성된 HTML 문서를 target/doc 디렉토리에 저장한다.

편의를 위해 cargo doc --open 명령을 실행하면 현재 크레이트의 문서(및 모든 종속성의 문서)에 대한 HTML을 빌드하고 웹 브라우저에서 결과를 열어준다. add_one 함수로 이동하면 문서 주석의 텍스트가 어떻게 렌더링되는지 확인할 수 있다. 아래 그림은 add_one 함수에 대한 HTML 문서를 보여준다.

`my_crate`의 `add_one` 함수에 대한 렌더링된 HTML 문서

그림 14-1: add_one 함수에 대한 HTML 문서

자주 사용되는 섹션

Listing 14-1에서는 # Examples 마크다운 헤딩을 사용해 HTML에서 “Examples“라는 제목의 섹션을 만들었다. 문서화에서 크레이트 작성자들이 자주 사용하는 다른 섹션들은 다음과 같다:

  • Panics: 문서화 중인 함수가 패닉을 일으킬 수 있는 시나리오를 설명한다. 함수를 호출하는 측에서 프로그램이 패닉을 일으키지 않도록 하려면 이러한 상황에서 함수를 호출하지 않도록 주의해야 한다.
  • Errors: 함수가 Result를 반환하는 경우, 어떤 종류의 오류가 발생할 수 있는지와 어떤 조건에서 이러한 오류가 반환될 수 있는지 설명한다. 이를 통해 호출자는 다양한 종류의 오류를 다르게 처리하는 코드를 작성할 수 있다.
  • Safety: 함수가 unsafe로 선언된 경우 (20장에서 안전하지 않은 코드에 대해 다룬다), 왜 이 함수가 안전하지 않은지와 함수가 호출자에게 기대하는 불변 조건(invariants)을 설명하는 섹션을 추가해야 한다.

대부분의 문서화 주석은 이러한 모든 섹션을 필요로 하지는 않지만, 이 목록은 사용자들이 알고 싶어할 코드의 다양한 측면을 상기시키는 데 유용한 체크리스트 역할을 한다.

문서 주석을 테스트로 활용하기

문서 주석에 예제 코드 블록을 추가하면 라이브러리 사용법을 명확히 보여줄 수 있다. 이 방법에는 또 다른 장점이 있다. cargo test를 실행하면 문서에 포함된 예제 코드를 테스트로 실행한다! 예제가 포함된 문서만큼 좋은 것은 없다. 하지만 문서가 작성된 이후 코드가 변경되어 예제가 동작하지 않는다면 그만큼 나쁜 것도 없다. 리스트 14-1의 add_one 함수에 대한 문서를 가지고 cargo test를 실행하면, 테스트 결과에 다음과 같은 섹션이 나타난다:

   Doc-tests my_crate

running 1 test
test src/lib.rs - add_one (line 5) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s

이제 함수나 예제를 변경하여 예제의 assert_eq!가 패닉을 일으키도록 한 후 다시 cargo test를 실행하면, 문서 테스트가 예제와 코드가 서로 일치하지 않음을 감지한다!

포함된 항목에 대한 주석 작성

//! 스타일의 문서 주석은 주석 뒤에 오는 항목이 아니라, 주석을 포함하는 항목에 대한 문서를 추가한다. 일반적으로 이러한 문서 주석은 크레이트 루트 파일(관례적으로 src/lib.rs)이나 모듈 내부에서 크레이트 또는 모듈 전체를 설명하는 데 사용한다.

예를 들어, add_one 함수를 포함하는 my_crate 크레이트의 목적을 설명하는 문서를 추가하려면, src/lib.rs 파일의 시작 부분에 //!로 시작하는 문서 주석을 추가한다. 이는 아래 Listing 14-2와 같다:

Filename: src/lib.rs
//! # My Crate
//!
//! `my_crate` is a collection of utilities to make performing certain
//! calculations more convenient.

/// Adds one to the number given.
// --snip--
///
/// # Examples
///
/// ```
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}
Listing 14-2: my_crate 크레이트 전체에 대한 문서

//!로 시작하는 마지막 줄 뒤에는 어떤 코드도 없다. /// 대신 //!로 주석을 시작했기 때문에, 이 주석은 주석 뒤에 오는 항목이 아니라 주석을 포함하는 항목에 대한 문서를 작성한다. 이 경우, 그 항목은 크레이트 루트인 src/lib.rs 파일이다. 이 주석들은 크레이트 전체를 설명한다.

cargo doc --open 명령을 실행하면, 이러한 주석은 my_crate의 문서 첫 페이지에 크레이트의 공개 항목 목록 위에 표시된다. 이는 Figure 14-2와 같다.

크레이트 전체에 대한 주석이 포함된 렌더링된 HTML 문서

Figure 14-2: my_crate에 대한 렌더링된 문서, 크레이트 전체를 설명하는 주석 포함

항목 내부의 문서 주석은 특히 크레이트와 모듈을 설명하는 데 유용하다. 이를 사용해 컨테이너의 전체 목적을 설명함으로써 사용자가 크레이트의 구조를 이해하는 데 도움을 줄 수 있다.

pub use로 편리한 공개 API 내보내기

크레이트를 공개할 때 공개 API의 구조는 중요한 고려 사항이다. 크레이트를 사용하는 사람들은 개발자보다 모듈 구조에 덜 익숙할 수 있으며, 모듈 계층이 복잡하면 필요한 기능을 찾기 어려울 수 있다.

7장에서는 pub 키워드를 사용해 아이템을 공개하고, use 키워드로 아이템을 스코프로 가져오는 방법을 다뤘다. 하지만 개발 중에 효율적으로 느껴지는 구조가 사용자에게는 불편할 수 있다. 예를 들어, 구조체를 여러 계층으로 구성하면 특정 타입을 찾기 어려울 수 있다. 사용자가 my_crate::some_module::another_module::UsefulType 대신 my_crate::UsefulType처럼 간단하게 사용할 수 있도록 하고 싶을 것이다.

다행히 내부 구조를 재구성하지 않고도 pub use를 사용해 아이템을 재내보내면 공개 구조를 조정할 수 있다. 재내보내기는 한 위치의 공개 아이템을 다른 위치에서도 공개할 수 있게 해준다. 마치 해당 아이템이 다른 위치에 정의된 것처럼 사용할 수 있다.

예를 들어, 예술 개념을 모델링하는 art 라이브러리를 만들었다고 가정하자. 이 라이브러리에는 두 개의 모듈이 있다: PrimaryColorSecondaryColor 열거형을 포함하는 kinds 모듈, 그리고 mix 함수를 포함하는 utils 모듈이다. 아래는 그 예제다:

Filename: src/lib.rs
//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // --snip--
        unimplemented!();
    }
}
Listing 14-3: kindsutils 모듈로 구성된 art 라이브러리

그림 14-3은 cargo doc으로 생성된 이 크레이트의 문서 첫 페이지를 보여준다:

`art` 크레이트의 문서 첫 페이지. `kinds`와 `utils` 모듈이 나열됨

그림 14-3: art 크레이트의 문서 첫 페이지. kindsutils 모듈이 나열됨

PrimaryColorSecondaryColor 타입, 그리고 mix 함수는 첫 페이지에 나열되지 않는다. 이들을 보려면 kindsutils를 클릭해야 한다.

이 라이브러리를 사용하는 다른 크레이트는 art의 아이템을 스코프로 가져오기 위해 현재 정의된 모듈 구조를 지정해야 한다. 아래 예제는 art 크레이트의 PrimaryColormix를 사용하는 크레이트를 보여준다:

Filename: src/main.rs
use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}
Listing 14-4: art 크레이트의 내부 구조를 그대로 사용하는 크레이트

art 크레이트를 사용하는 코드 작성자는 PrimaryColorkinds 모듈에 있고 mixutils 모듈에 있다는 것을 알아내야 했다. art 크레이트의 모듈 구조는 이를 개발하는 사람에게는 적합하지만, 사용자에게는 혼란을 줄 수 있다. 내부 구조는 크레이트를 사용하는 방법을 이해하는 데 도움이 되지 않으며, 사용자가 어디를 찾아야 하는지 알아내고 use 문에서 모듈 이름을 지정해야 하는 불편함을 초래한다.

공개 API에서 내부 구조를 제거하기 위해 art 크레이트 코드를 수정해 pub use 문을 추가하여 아이템을 최상위 수준으로 재내보낼 수 있다. 아래는 그 예제다:

Filename: src/lib.rs
//! # Art
//!
//! A library for modeling artistic concepts.

pub use self::kinds::PrimaryColor;
pub use self::kinds::SecondaryColor;
pub use self::utils::mix;

pub mod kinds {
    // --snip--
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    // --snip--
    use crate::kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        SecondaryColor::Orange
    }
}
Listing 14-5: 아이템을 재내보내기 위해 pub use 문 추가

이제 cargo doc으로 생성된 API 문서는 첫 페이지에 재내보낸 아이템을 나열하고 링크한다. 그림 14-4는 이를 보여준다:

재내보낸 아이템이 첫 페이지에 나열된 `art` 크레이트의 문서

그림 14-4: 재내보낸 아이템이 나열된 art 크레이트의 문서 첫 페이지

art 크레이트 사용자는 여전히 리스팅 14-3의 내부 구조를 사용할 수 있지만, 리스팅 14-5의 더 편리한 구조를 사용할 수도 있다. 아래 예제는 재내보낸 아이템을 사용하는 프로그램을 보여준다:

Filename: src/main.rs
use art::PrimaryColor;
use art::mix;

fn main() {
    // --snip--
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}
Listing 14-6: art 크레이트의 재내보낸 아이템을 사용하는 프로그램

중첩된 모듈이 많은 경우, pub use로 타입을 최상위 수준으로 재내보내면 사용자 경험이 크게 개선될 수 있다. pub use의 또 다른 일반적인 용도는 의존성의 정의를 현재 크레이트에서 재내보내어 해당 크레이트의 정의를 공개 API의 일부로 만드는 것이다.

유용한 공개 API 구조를 만드는 것은 과학보다는 예술에 가깝다. 사용자에게 가장 적합한 API를 찾기 위해 반복 작업을 할 수 있다. pub use를 선택하면 내부 구조를 유연하게 구성할 수 있으며, 내부 구조와 사용자에게 보여지는 구조를 분리할 수 있다. 설치한 크레이트의 코드를 살펴보면 내부 구조와 공개 API가 어떻게 다른지 확인할 수 있다.

Crates.io 계정 설정하기

크레이트를 배포하기 전에, 먼저 crates.io에 계정을 만들고 API 토큰을 발급받아야 한다. 이를 위해 crates.io 홈페이지를 방문해 GitHub 계정으로 로그인한다. (현재는 GitHub 계정이 필수지만, 추후 다른 방식의 계정 생성도 지원될 수 있다.) 로그인한 후, https://crates.io/me/에서 계정 설정 페이지로 이동해 API 키를 확인한다. 그런 다음 cargo login 명령어를 실행하고, 프롬프트가 나타나면 API 키를 붙여넣는다. 예를 들면 다음과 같다:

$ cargo login
abcdefghijklmnopqrstuvwxyz012345

이 명령어는 Cargo에게 API 토큰을 알려주고, 이를 로컬의 ~/.cargo/credentials 파일에 저장한다. 이 토큰은 비밀이기 때문에 절대 다른 사람과 공유하면 안 된다. 만약 어떤 이유로든 토큰을 공유했다면, crates.io에서 토큰을 취소하고 새로운 토큰을 생성해야 한다.

새로운 크레이트에 메타데이터 추가하기

여러분이 배포하려는 크레이트가 있다고 가정해 보자. 배포하기 전에 크레이트의 Cargo.toml 파일에 있는 [package] 섹션에 몇 가지 메타데이터를 추가해야 한다.

크레이트에는 고유한 이름이 필요하다. 로컬에서 작업할 때는 크레이트에 어떤 이름이든 붙일 수 있다. 하지만 crates.io에 올라가는 크레이트 이름은 선착순으로 할당된다. 한 번 사용된 크레이트 이름은 다른 사람이 사용할 수 없다. 크레이트를 배포하기 전에 원하는 이름을 검색해 보자. 이미 사용된 이름이라면 다른 이름을 찾아야 하며, Cargo.toml 파일의 [package] 섹션에 있는 name 필드를 새로운 이름으로 수정해야 한다. 예를 들면 다음과 같다:

파일명: Cargo.toml

[package]
name = "guessing_game"

고유한 이름을 선택했다 하더라도, 이 상태에서 cargo publish를 실행해 크레이트를 배포하려고 하면 경고와 함께 에러가 발생한다:

$ cargo publish
    Updating crates.io index
warning: manifest has no description, license, license-file, documentation, homepage or repository.
See https://doc.rust-lang.org/cargo/reference/manifest.html#package-metadata for more info.
--snip--
error: failed to publish to registry at https://crates.io

Caused by:
  the remote server responded with an error (status 400 Bad Request): missing or empty metadata fields: description, license. Please see https://doc.rust-lang.org/cargo/reference/manifest.html for more information on configuring these fields

이 에러는 중요한 정보가 누락되었기 때문에 발생한다. 크레이트의 기능과 사용 조건을 알려주기 위해 설명과 라이선스가 필요하다. Cargo.toml 파일에 간단한 설명을 추가하자. 이 설명은 검색 결과에서 크레이트와 함께 표시된다. license 필드에는 _라이선스 식별자 값_을 입력해야 한다. Linux Foundation의 Software Package Data Exchange (SPDX)에서 사용할 수 있는 식별자 목록을 확인할 수 있다. 예를 들어, 크레이트에 MIT 라이선스를 적용하려면 MIT 식별자를 추가하면 된다:

파일명: Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

SPDX에 없는 라이선스를 사용하려면 해당 라이선스 텍스트를 파일에 저장하고, 프로젝트에 포함시킨 다음, license 키 대신 license-file을 사용해 파일명을 지정해야 한다.

프로젝트에 적합한 라이선스를 선택하는 방법은 이 책의 범위를 벗어난다. Rust 커뮤니티의 많은 사람들은 Rust와 동일한 방식으로 MIT OR Apache-2.0 듀얼 라이선스를 사용한다. 이 방식은 여러 라이선스를 OR로 구분해 지정할 수 있음을 보여준다.

고유한 이름, 버전, 설명, 라이선스를 추가한 후, 배포 준비가 된 프로젝트의 Cargo.toml 파일은 다음과 같을 수 있다:

파일명: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
edition = "2024"
description = "A fun game where you guess what number the computer has chosen."
license = "MIT OR Apache-2.0"

[dependencies]

Cargo 문서를 참고하면 다른 메타데이터를 지정해 크레이트를 더 쉽게 발견하고 사용할 수 있도록 하는 방법을 확인할 수 있다.

Crates.io에 배포하기

이제 계정을 만들고 API 토큰을 저장했으며, 크레이트 이름을 정하고 필요한 메타데이터를 지정했다면, 배포할 준비가 된 것이다. 크레이트를 배포하면 특정 버전이 crates.io에 업로드되어 다른 사람들이 사용할 수 있게 된다.

주의할 점은, 배포는 영구적이라는 것이다. 버전은 덮어쓸 수 없고, 코드도 삭제할 수 없다. crates.io의 주요 목표 중 하나는 코드의 영구적인 저장소 역할을 하는 것이다. 이를 통해 crates.io의 크레이트에 의존하는 모든 프로젝트의 빌드가 계속 작동할 수 있도록 한다. 버전 삭제를 허용하면 이 목표를 달성할 수 없다. 그러나 배포할 수 있는 크레이트 버전의 수에는 제한이 없다.

다시 cargo publish 명령어를 실행해 보자. 이제 성공할 것이다:

$ cargo publish
    Updating crates.io index
   Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
   Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
   Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
   Uploading guessing_game v0.1.0 (file:///projects/guessing_game)

축하한다! 이제 Rust 커뮤니티와 코드를 공유했으며, 누구나 쉽게 프로젝트의 의존성으로 이 크레이트를 추가할 수 있다.

기존 크레이트의 새 버전 출시하기

크레이트에 변경 사항을 적용하고 새 버전을 출시할 준비가 되면, Cargo.toml 파일에 지정된 version 값을 수정하고 다시 출시한다. 시맨틱 버저닝 규칙을 참고해 변경된 내용에 맞는 적절한 다음 버전 번호를 결정한다. 그런 다음 cargo publish 명령어를 실행해 새 버전을 업로드한다.

cargo yank로 크레이트 버전 사용 중단하기

이전에 출시한 크레이트 버전을 완전히 삭제할 수는 없지만, 새로운 프로젝트가 해당 버전을 의존성으로 추가하지 못하도록 막을 수 있다. 크레이트 버전이 어떤 이유로든 문제가 생겼을 때 유용한 기능이다. 이런 상황에서 Cargo는 크레이트 버전을 사용 중단(yank)하는 기능을 제공한다.

버전을 사용 중단하면 새로운 프로젝트가 해당 버전을 의존성으로 추가하지 못하게 된다. 하지만 이미 해당 버전을 사용 중인 프로젝트는 계속 정상적으로 동작한다. 즉, Cargo.lock 파일이 있는 모든 프로젝트는 문제 없이 동작하며, 앞으로 생성되는 Cargo.lock 파일에서는 사용 중단된 버전을 사용하지 않는다.

크레이트 버전을 사용 중단하려면, 이전에 출시한 크레이트의 디렉터리에서 cargo yank 명령을 실행하고 사용 중단할 버전을 지정하면 된다. 예를 들어, guessing_game 크레이트의 1.0.1 버전을 출시했고 이를 사용 중단하려면, guessing_game 프로젝트 디렉터리에서 다음 명령을 실행한다:

$ cargo yank --vers 1.0.1
    Updating crates.io index
        Yank guessing_game@1.0.1

명령에 --undo 옵션을 추가하면, 사용 중단을 취소하고 프로젝트가 다시 해당 버전을 의존성으로 사용할 수 있게 할 수 있다:

$ cargo yank --vers 1.0.1 --undo
    Updating crates.io index
      Unyank guessing_game@1.0.1

사용 중단은 코드를 삭제하지 않는다. 예를 들어, 실수로 업로드한 비밀키를 삭제할 수 없다. 이런 경우에는 즉시 해당 비밀키를 재설정해야 한다.

Cargo 워크스페이스

12장에서는 바이너리 크레이트와 라이브러리 크레이트를 포함하는 패키지를 만들었다. 프로젝트가 발전하면서 라이브러리 크레이트가 점점 커지고, 이를 여러 개의 라이브러리 크레이트로 나누고 싶을 수 있다. Cargo는 _워크스페이스_라는 기능을 제공하는데, 이는 함께 개발되는 여러 관련 패키지를 관리하는 데 도움을 준다.

워크스페이스 생성하기

_워크스페이스_는 동일한 Cargo.lock 파일과 출력 디렉터리를 공유하는 패키지들의 집합이다. 이제 워크스페이스를 사용해 프로젝트를 만들어 보자. 구조에 집중하기 위해 간단한 코드를 사용할 것이다. 워크스페이스를 구성하는 방법은 여러 가지가 있지만, 여기서는 일반적으로 많이 사용되는 방식 하나를 소개한다. 워크스페이스는 하나의 바이너리와 두 개의 라이브러리로 구성된다. 주요 기능을 제공하는 바이너리는 두 라이브러리에 의존한다. 하나의 라이브러리는 add_one 함수를 제공하고, 다른 라이브러리는 add_two 함수를 제공한다. 이 세 개의 크레이트는 동일한 워크스페이스에 속한다. 먼저 워크스페이스를 위한 새 디렉터리를 생성한다:

$ mkdir add
$ cd add

다음으로, add 디렉터리 안에서 전체 워크스페이스를 설정할 Cargo.toml 파일을 생성한다. 이 파일에는 [package] 섹션이 없다. 대신 [workspace] 섹션으로 시작하며, 이를 통해 워크스페이스에 멤버를 추가할 수 있다. 또한 워크스페이스에서 Cargo의 최신 리졸버 알고리즘을 사용하기 위해 resolver"3"으로 설정한다.

파일명: Cargo.toml

[workspace]
resolver = "3"

이제 add 디렉터리 안에서 cargo new 명령을 실행해 adder 바이너리 크레이트를 생성한다:

$ cargo new adder
    Creating binary (application) `adder` package
      Adding `adder` as member of workspace at `file:///projects/add`

워크스페이스 내부에서 cargo new를 실행하면, 새로 생성된 패키지가 자동으로 워크스페이스 Cargo.toml 파일의 [workspace] 정의에 members 키로 추가된다. 다음과 같이:

[workspace]
resolver = "3"
members = ["adder"]

이 시점에서 cargo build를 실행해 워크스페이스를 빌드할 수 있다. add 디렉터리의 파일 구조는 다음과 같다:

├── Cargo.lock
├── Cargo.toml
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

워크스페이스는 최상위 레벨에 하나의 target 디렉터리를 가지며, 컴파일된 결과물은 이곳에 저장된다. adder 패키지는 자체 target 디렉터리를 갖지 않는다. adder 디렉터리 내부에서 cargo build를 실행하더라도, 컴파일된 결과물은 _add/adder/target_이 아닌 _add/target_에 저장된다. Cargo가 워크스페이스의 target 디렉터리를 이렇게 구성하는 이유는, 워크스페이스 내의 크레이트들이 서로 의존하기 때문이다. 각 크레이트가 자체 target 디렉터리를 갖는다면, 각 크레이트는 워크스페이스 내 다른 크레이트들을 다시 컴파일해 자신의 target 디렉터리에 결과물을 저장해야 한다. 하나의 target 디렉터리를 공유함으로써, 크레이트들은 불필요한 재빌드를 피할 수 있다.

워크스페이스에 두 번째 패키지 생성하기

이제 워크스페이스에 새로운 멤버 패키지를 추가해보자. 이 패키지는 add_one이라는 이름으로 생성할 것이다. 새로운 라이브러리 크레이트를 add_one이라는 이름으로 생성한다:

$ cargo new add_one --lib
    Creating library `add_one` package
      Adding `add_one` as member of workspace at `file:///projects/add`

이제 최상위 Cargo.toml 파일의 members 목록에 add_one 경로가 추가된다:

파일명: Cargo.toml

[workspace]
resolver = "3"
members = ["adder", "add_one"]

add 디렉토리의 구조는 이제 다음과 같다:

├── Cargo.lock
├── Cargo.toml
├── add_one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── adder
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── target

add_one/src/lib.rs 파일에 add_one 함수를 추가한다:

파일명: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

이제 바이너리 크레이트인 adder가 라이브러리 크레이트인 add_one에 의존하도록 설정할 수 있다. 먼저 _adder/Cargo.toml_에 add_one에 대한 경로 의존성을 추가한다.

파일명: adder/Cargo.toml

[dependencies]
add_one = { path = "../add_one" }

Cargo는 워크스페이스 내의 크레이트들이 서로 의존할 것이라고 가정하지 않으므로, 의존 관계를 명시적으로 설정해야 한다.

다음으로, adder 크레이트에서 add_one 크레이트의 add_one 함수를 사용해보자. adder/src/main.rs 파일을 열고 main 함수를 수정하여 add_one 함수를 호출한다. 이 내용은 Listing 14-7에 나와 있다.

Filename: adder/src/main.rs
fn main() {
    let num = 10;
    println!("Hello, world! {num} plus one is {}!", add_one::add_one(num));
}
Listing 14-7: adder 크레이트에서 add_one 라이브러리 크레이트 사용하기

이제 최상위 add 디렉토리에서 cargo build를 실행하여 워크스페이스를 빌드한다!

$ cargo build
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s

add 디렉토리에서 바이너리 크레이트를 실행하려면, cargo run 명령어에 -p 인자와 패키지 이름을 지정하여 실행할 패키지를 선택한다:

$ cargo run -p adder
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/adder`
Hello, world! 10 plus one is 11!

이 명령어는 _adder/src/main.rs_의 코드를 실행하며, 이 코드는 add_one 크레이트에 의존한다.

워크스페이스에서 외부 패키지 사용하기

워크스페이스는 각 크레이트 디렉토리마다 Cargo.lock 파일을 두지 않고, 최상위 레벨에 단 하나의 Cargo.lock 파일만을 갖는다. 이렇게 하면 모든 크레이트가 동일한 버전의 의존성을 사용할 수 있다. 만약 _adder/Cargo.toml_과 add_one/Cargo.toml 파일에 rand 패키지를 추가하면, Cargo는 두 파일 모두에서 동일한 버전의 rand를 사용하도록 해결하고, 이를 단일 Cargo.lock 파일에 기록한다. 워크스페이스 내 모든 크레이트가 동일한 의존성을 사용하면, 크레이트 간 호환성이 항상 보장된다. 이제 add_one 크레이트에서 rand 크레이트를 사용할 수 있도록 add_one/Cargo.toml 파일의 [dependencies] 섹션에 rand 크레이트를 추가해 보자:

파일명: add_one/Cargo.toml

[dependencies]
rand = "0.8.5"

이제 add_one/src/lib.rs 파일에 use rand;를 추가하고, add 디렉토리에서 cargo build를 실행하면 전체 워크스페이스를 빌드할 때 rand 크레이트를 가져와 컴파일한다. 하지만 rand를 사용하지 않았기 때문에 다음과 같은 경고가 발생한다:

$ cargo build
    Updating crates.io index
  Downloaded rand v0.8.5
   --snip--
   Compiling rand v0.8.5
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
warning: unused import: `rand`
 --> add_one/src/lib.rs:1:5
  |
1 | use rand;
  |     ^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `add_one` (lib) generated 1 warning (run `cargo fix --lib -p add_one` to apply 1 suggestion)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.95s

이제 최상위 Cargo.lock 파일에는 add_onerand에 의존한다는 정보가 포함된다. 하지만 rand가 워크스페이스 내 어딘가에서 사용된다고 해도, 다른 크레이트에서 rand를 사용하려면 해당 크레이트의 Cargo.toml 파일에도 rand를 추가해야 한다. 예를 들어, adder 패키지의 adder/src/main.rs 파일에 use rand;를 추가하면 다음과 같은 에러가 발생한다:

$ cargo build
  --snip--
   Compiling adder v0.1.0 (file:///projects/add/adder)
error[E0432]: unresolved import `rand`
 --> adder/src/main.rs:2:5
  |
2 | use rand;
  |     ^^^^ no external crate `rand`

이 문제를 해결하려면 adder 패키지의 Cargo.toml 파일을 수정하고, rand가 의존성임을 명시해야 한다. adder 패키지를 빌드하면 Cargo.lock 파일에 adder의 의존성 목록에 rand가 추가되지만, rand의 추가 복사본은 다운로드되지 않는다. Cargo는 워크스페이스 내 모든 패키지의 크레이트가 rand 패키지를 사용할 때 동일한 버전을 사용하도록 보장한다. 이를 통해 공간을 절약하고, 워크스페이스 내 크레이트 간 호환성을 유지한다.

만약 워크스페이스 내 크레이트가 동일한 의존성의 호환되지 않는 버전을 지정하면, Cargo는 각각을 해결하되 가능한 한 적은 버전을 사용하도록 노력한다.

워크스페이스에 테스트 추가하기

다음으로, add_one 크레이트 내부의 add_one::add_one 함수에 대한 테스트를 추가해 보자.

파일명: add_one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}

이제 최상위 add 디렉토리에서 cargo test를 실행해 보자. 이렇게 구성된 워크스페이스에서 cargo test를 실행하면 워크스페이스 내 모든 크레이트의 테스트가 실행된다.

$ cargo test
   Compiling add_one v0.1.0 (file:///projects/add/add_one)
   Compiling adder v0.1.0 (file:///projects/add/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests src/main.rs (target/debug/deps/adder-3a47283c568d2b6a)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

출력의 첫 번째 부분은 add_one 크레이트의 it_works 테스트가 통과했음을 보여준다. 다음 부분은 adder 크레이트에서 테스트가 발견되지 않았음을 나타내고, 마지막 부분은 add_one 크레이트에서 문서 테스트가 발견되지 않았음을 보여준다.

또한 최상위 디렉토리에서 -p 플래그를 사용해 특정 크레이트의 테스트만 실행할 수도 있다. 이때 테스트할 크레이트의 이름을 지정하면 된다.

$ cargo test -p add_one
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.00s
     Running unittests src/lib.rs (target/debug/deps/add_one-93c49ee75dc46543)

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests add_one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

이 출력은 cargo testadd_one 크레이트의 테스트만 실행했고, adder 크레이트의 테스트는 실행하지 않았음을 보여준다.

만약 워크스페이스 내의 크레이트를 crates.io에 공개하려면, 각 크레이트를 개별적으로 공개해야 한다. cargo test와 마찬가지로, -p 플래그를 사용해 특정 크레이트를 공개할 수 있다.

추가 연습으로, add_one 크레이트와 유사한 방식으로 add_two 크레이트를 이 워크스페이스에 추가해 보자!

프로젝트가 커지면 워크스페이스를 사용하는 것을 고려해 보자. 워크스페이스는 하나의 큰 코드 덩어리보다 작고 이해하기 쉬운 컴포넌트로 작업할 수 있게 해준다. 또한, 크레이트를 워크스페이스에 유지하면 동시에 변경되는 경우 크레이트 간의 조정이 더 쉬워진다.

cargo install로 바이너리 설치하기

cargo install 명령어를 사용하면 로컬에서 바이너리 크레이트를 설치하고 실행할 수 있다. 이 명령어는 시스템 패키지를 대체하기 위한 것이 아니라, Rust 개발자들이 crates.io에 공유된 도구를 편리하게 설치할 수 있도록 제공된다. 단, 이 명령어로는 바이너리 타겟을 가진 패키지만 설치할 수 있다. 바이너리 타겟은 크레이트가 src/main.rs 파일이나 다른 바이너리로 지정된 파일을 포함할 때 생성되는 실행 가능한 프로그램을 의미한다. 이는 독립적으로 실행할 수 없고 다른 프로그램에 포함되도록 설계된 라이브러리 타겟과는 다르다. 일반적으로 크레이트의 README 파일에는 해당 크레이트가 라이브러리인지, 바이너리 타겟을 가지고 있는지, 혹은 둘 다인지에 대한 정보가 포함되어 있다.

cargo install로 설치된 모든 바이너리는 설치 루트의 bin 폴더에 저장된다. _rustup.rs_를 사용해 Rust를 설치했고 커스텀 설정을 하지 않았다면, 이 디렉토리는 $HOME/.cargo/bin이 된다. cargo install로 설치한 프로그램을 실행하려면 이 디렉토리가 $PATH에 포함되어 있는지 확인해야 한다.

예를 들어, 12장에서는 파일 검색을 위한 grep 도구의 Rust 구현체인 ripgrep을 소개했다. ripgrep을 설치하려면 아래 명령어를 실행하면 된다:

$ cargo install ripgrep
    Updating crates.io index
  Downloaded ripgrep v14.1.1
  Downloaded 1 crate (213.6 KB) in 0.40s
  Installing ripgrep v14.1.1
--snip--
   Compiling grep v0.3.2
    Finished `release` profile [optimized + debuginfo] target(s) in 6.73s
  Installing ~/.cargo/bin/rg
   Installed package `ripgrep v14.1.1` (executable `rg`)

출력 결과에서 마지막에서 두 번째 줄은 설치된 바이너리의 위치와 이름을 보여준다. ripgrep의 경우 이는 rg이다. 앞서 언급한 대로 설치 디렉토리가 $PATH에 포함되어 있다면, rg --help를 실행해 파일 검색을 위한 더 빠르고 Rust 스타일의 도구를 사용할 수 있다!

Cargo를 커스텀 커맨드로 확장하기

Cargo는 수정 없이도 새로운 서브커맨드로 확장할 수 있도록 설계되었다. $PATH에 있는 바이너리 파일의 이름이 cargo-something이라면, cargo something을 실행하여 Cargo 서브커맨드처럼 사용할 수 있다. 이러한 커스텀 커맨드는 cargo --list를 실행할 때도 함께 표시된다. cargo install을 사용해 확장 기능을 설치하고, 내장된 Cargo 도구처럼 실행할 수 있다는 점은 Cargo 설계의 큰 장점이다!

요약

Cargo와 crates.io를 통해 코드를 공유하는 것은 Rust 생태계가 다양한 작업에 유용하게 활용될 수 있는 중요한 부분이다. Rust의 표준 라이브러리는 작고 안정적이지만, crates는 언어와는 다른 속도로 쉽게 공유하고 사용하며 개선할 수 있다. 여러분에게 유용한 코드를 crates.io에 공유하는 것을 주저하지 말자. 그 코드가 다른 누군가에게도 유용할 가능성이 크다!

스마트 포인터

_포인터_는 메모리 주소를 담고 있는 변수를 통칭하는 개념이다. 이 주소는 특정 데이터를 가리키거나 참조한다. Rust에서 가장 흔히 사용하는 포인터는 4장에서 배운 참조(reference)다. 참조는 & 기호로 표시되며, 가리키는 값을 빌려온다. 참조는 데이터를 참조하는 기능 외에는 특별한 능력이 없고, 오버헤드도 없다.

반면 _스마트 포인터_는 포인터처럼 동작하면서 추가 메타데이터와 기능을 제공하는 데이터 구조다. 스마트 포인터는 Rust에만 있는 개념이 아니다. C++에서 처음 등장했으며 다른 언어에서도 존재한다. Rust의 표준 라이브러리에는 참조보다 더 다양한 기능을 제공하는 여러 종류의 스마트 포인터가 정의되어 있다. 이번 장에서는 스마트 포인터의 일반적인 개념을 이해하기 위해 몇 가지 예를 살펴볼 것이다. 그 중 하나는 참조 카운팅 스마트 포인터 타입이다. 이 포인터는 데이터의 소유자 수를 추적하고, 소유자가 없어지면 데이터를 정리함으로써 데이터가 여러 소유자를 가질 수 있게 한다.

Rust의 소유권과 빌림 개념은 참조와 스마트 포인터 사이에 또 다른 차이를 만든다. 참조는 데이터를 빌려오지만, 스마트 포인터는 대부분의 경우 가리키는 데이터를 _소유_한다.

이 책에서 이미 몇 가지 스마트 포인터를 접했지만, 당시에는 그렇게 부르지 않았다. 8장에서 다룬 StringVec<T>가 그 예시다. 이 두 타입은 메모리를 소유하고 이를 조작할 수 있기 때문에 스마트 포인터로 분류된다. 또한 메타데이터와 추가 기능 또는 보장을 제공한다. 예를 들어, String은 용량을 메타데이터로 저장하며, 데이터가 항상 유효한 UTF-8임을 보장하는 추가 기능을 갖는다.

스마트 포인터는 보통 구조체(struct)로 구현된다. 일반 구조체와 달리, 스마트 포인터는 DerefDrop 트레이트를 구현한다. Deref 트레이트는 스마트 포인터 구조체의 인스턴스가 참조처럼 동작하게 해, 코드가 참조나 스마트 포인터 둘 다와 호환되도록 한다. Drop 트레이트는 스마트 포인터 인스턴스가 스코프를 벗어날 때 실행될 코드를 커스텀할 수 있게 한다. 이번 장에서는 이 두 트레이트를 자세히 살펴보고, 스마트 포인터에서 왜 중요한지 설명할 것이다.

스마트 포인터 패턴은 Rust에서 자주 사용되는 일반적인 디자인 패턴이므로, 이번 장에서는 모든 스마트 포인터를 다루지 않는다. 많은 라이브러리가 자체 스마트 포인터를 제공하며, 직접 스마트 포인터를 작성할 수도 있다. 이번 장에서는 표준 라이브러리의 가장 흔히 사용되는 스마트 포인터를 다룰 것이다:

  • Box<T>: 힙에 값을 할당하기 위한 포인터
  • Rc<T>: 다중 소유권을 가능하게 하는 참조 카운팅 타입
  • Ref<T>RefMut<T>: RefCell<T>를 통해 접근하며, 컴파일 타임이 아닌 런타임에 빌림 규칙을 강제하는 타입

또한, 불변 타입이 내부 값을 변경할 수 있는 API를 제공하는 내부 가변성 패턴도 살펴볼 것이다. 그리고 참조 순환 문제가 어떻게 메모리 누수를 일으키는지, 그리고 이를 방지하는 방법에 대해서도 논의할 것이다.

이제 시작해 보자!

힙에 데이터를 저장하기 위해 Box<T> 사용하기

가장 기본적인 스마트 포인터는 _박스_이며, 타입은 Box<T>로 표기한다. 박스를 사용하면 데이터를 스택 대신 힙에 저장할 수 있다. 스택에 남는 것은 힙 데이터를 가리키는 포인터뿐이다. 스택과 힙의 차이에 대해 다시 확인하려면 4장을 참고한다.

박스는 데이터를 힙에 저장한다는 점 외에는 성능 오버헤드가 없다. 하지만 추가 기능도 많지 않다. 주로 다음과 같은 상황에서 사용한다:

  • 컴파일 시점에 크기를 알 수 없는 타입이 있고, 정확한 크기가 필요한 컨텍스트에서 해당 타입의 값을 사용하려는 경우
  • 대량의 데이터가 있고, 소유권을 이전하되 데이터가 복사되지 않도록 보장하고 싶은 경우
  • 특정 타입이 아닌 특정 트레잇을 구현하는 타입의 값을 소유하고 싶은 경우

첫 번째 상황은 “박스를 사용한 재귀 타입 구현”에서 설명한다. 두 번째 경우, 대량의 데이터를 스택에서 복사하면 소유권 이전에 시간이 오래 걸린다. 이때 박스를 사용해 데이터를 힙에 저장하면 성능을 개선할 수 있다. 그러면 스택에서는 작은 포인터 데이터만 복사되고, 힙에 있는 데이터는 그대로 유지된다. 세 번째 경우는 _트레잇 객체_라고 하며, 18장의 “다양한 타입의 값을 허용하는 트레잇 객체 사용”에서 자세히 다룬다. 여기서 배운 내용을 해당 섹션에서 다시 활용하게 될 것이다!

Box<T>를 사용해 힙에 데이터 저장하기

Box<T>가 힙 저장소를 사용하는 경우에 대해 논의하기 전에, 먼저 문법과 Box<T>에 저장된 값과 상호작용하는 방법을 살펴보자.

리스트 15-1은 i32 값을 힙에 저장하기 위해 박스를 사용하는 방법을 보여준다.

Filename: src/main.rs
fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}
Listing 15-1: 박스를 사용해 i32 값을 힙에 저장하기

변수 b를 정의하고, 이 변수는 힙에 할당된 값 5를 가리키는 Box의 값을 갖는다. 이 프로그램은 b = 5를 출력한다. 이 경우, 박스 안의 데이터에 접근하는 방식은 스택에 데이터가 있을 때와 유사하다. 소유된 값과 마찬가지로, 박스가 스코프를 벗어나면 (예: main 함수의 끝에서 b가 스코프를 벗어나는 경우) 박스와 그 박스가 가리키는 데이터 모두 메모리에서 해제된다. 이때 박스는 스택에 저장되고, 데이터는 힙에 저장된다.

단일 값을 힙에 저장하는 것은 그리 유용하지 않기 때문에, 이런 방식으로 박스를 단독으로 사용하는 경우는 드물다. 기본적으로 스택에 저장되는 i32와 같은 값은 대부분의 상황에서 더 적합하다. 박스가 없으면 정의할 수 없는 타입을 박스를 통해 정의할 수 있는 경우를 살펴보자.

박스를 사용한 재귀 타입 활성화

_재귀 타입_은 자신의 일부로 동일한 타입의 값을 가질 수 있다. Rust는 컴파일 타임에 타입이 차지하는 공간을 알아야 하는데, 재귀 타입의 값이 이론적으로 무한히 중첩될 수 있기 때문에 Rust는 값이 필요한 공간을 알 수 없다. 박스는 크기가 정해져 있으므로, 재귀 타입 정의에 박스를 삽입함으로써 재귀 타입을 활성화할 수 있다.

재귀 타입의 예로 _cons 리스트_를 살펴보자. 이는 함수형 프로그래밍 언어에서 흔히 볼 수 있는 데이터 타입이다. 우리가 정의할 cons 리스트 타입은 재귀를 제외하면 간단하므로, 이 예제에서 다루는 개념은 재귀 타입과 관련된 더 복잡한 상황을 다룰 때 유용할 것이다.

콘스 리스트에 대한 추가 정보

_콘스 리스트_는 Lisp 프로그래밍 언어와 그 방언에서 유래한 데이터 구조다. 중첩된 쌍으로 구성되며, Lisp 버전의 연결 리스트라고 볼 수 있다. 이름은 Lisp의 cons 함수(_construct function_의 약어)에서 비롯됐다. 이 함수는 두 인자를 받아 새로운 쌍을 생성한다. 값과 다른 쌍으로 이루어진 쌍에 cons를 호출하면 재귀적인 쌍으로 구성된 콘스 리스트를 만들 수 있다.

예를 들어, 리스트 1, 2, 3을 포함하는 콘스 리스트를 의사 코드로 표현하면 다음과 같다. 각 쌍은 괄호로 묶여 있다:

(1, (2, (3, Nil)))

콘스 리스트의 각 항목은 두 요소를 포함한다: 현재 항목의 값과 다음 항목. 리스트의 마지막 항목은 Nil이라는 값만 포함하며, 다음 항목은 없다. 콘스 리스트는 cons 함수를 재귀적으로 호출해 생성한다. 재귀의 기본 사례를 나타내는 표준 이름은 Nil이다. 이는 6장에서 다룬 “null“이나 “nil” 개념과는 다르다. 그 개념은 유효하지 않거나 존재하지 않는 값을 의미한다.

콘스 리스트는 Rust에서 자주 사용되는 데이터 구조가 아니다. Rust에서 항목 리스트가 필요할 때는 대부분 Vec<T>를 사용하는 것이 더 나은 선택이다. 다른 복잡한 재귀 데이터 타입은 다양한 상황에서 유용하지만, 이 장에서는 콘스 리스트를 시작점으로 삼아 박스가 어떻게 재귀 데이터 타입을 정의할 수 있게 하는지 탐구한다.

Listing 15-2는 콘스 리스트를 표현하기 위한 열거형 정의를 보여준다. 이 코드는 아직 컴파일되지 않는다. List 타입의 크기를 알 수 없기 때문이다. 이 문제를 다음에 설명한다.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}
Listing 15-2: i32 값을 담는 콘스 리스트 데이터 구조를 표현하기 위한 열거형 정의 시도

참고: 이 예제에서는 i32 값만 담는 콘스 리스트를 구현한다. 10장에서 논의한 제네릭을 사용해 어떤 타입의 값이라도 저장할 수 있는 콘스 리스트 타입을 정의할 수도 있다.

List 타입을 사용해 리스트 1, 2, 3을 저장하면 Listing 15-3과 같은 코드가 된다.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}
Listing 15-3: List 열거형을 사용해 리스트 1, 2, 3 저장하기

첫 번째 Cons 값은 1과 또 다른 List 값을 담고 있다. 이 List 값은 2와 또 다른 List 값을 담고 있는 또 다른 Cons 값이다. 이 List 값은 3List 값을 담고 있는 또 하나의 Cons 값이며, 마지막으로 리스트의 끝을 나타내는 비재귀적인 변형인 Nil이 된다.

Listing 15-3의 코드를 컴파일하려고 하면 Listing 15-4와 같은 오류가 발생한다.

Filename: output.txt
$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing when `List` needs drop
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing when `List` needs drop again
  = note: cycle used when computing whether `List` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
Listing 15-4: 재귀 열거형을 정의하려고 할 때 발생하는 오류

오류는 이 타입이 “무한한 크기를 가진다“고 알려준다. 그 이유는 List를 재귀적인 변형으로 정의했기 때문이다: 이 변형은 자신과 동일한 타입의 값을 직접 담고 있다. 결과적으로 Rust는 List 값을 저장하기 위해 얼마나 많은 공간이 필요한지 계산할 수 없다. 이 오류가 발생하는 이유를 자세히 살펴보자. 먼저 Rust가 비재귀 타입의 값을 저장하기 위해 필요한 공간을 어떻게 결정하는지 알아본다.

비재귀 타입의 크기 계산

6장에서 열거형 정의를 다룰 때 Listing 6-2에 정의한 Message 열거형을 떠올려 보자:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

Message 값에 얼마나 많은 공간을 할당해야 하는지 결정하기 위해, Rust는 각 변형을 살펴보며 가장 많은 공간이 필요한 변형을 확인한다. Rust는 Message::Quit가 공간을 전혀 필요로 하지 않고, Message::Move는 두 개의 i32 값을 저장할 수 있는 충분한 공간이 필요하다는 것을 파악한다. 하나의 변형만 사용되기 때문에, Message 값이 필요로 하는 최대 공간은 가장 큰 변형을 저장하는 데 필요한 공간이 된다.

이를 Rust가 Listing 15-2의 List 열거형과 같은 재귀 타입의 크기를 결정하려 할 때의 동작과 비교해 보자. 컴파일러는 i32 타입의 값과 List 타입의 값을 포함하는 Cons 변형부터 살펴본다. 따라서 Consi32의 크기와 List의 크기를 합한 만큼의 공간이 필요하다. List 타입이 얼마나 많은 메모리를 필요로 하는지 알아내기 위해, 컴파일러는 Cons 변형부터 시작해 변형들을 살펴본다. Cons 변형은 i32 타입의 값과 List 타입의 값을 포함하며, 이 과정은 무한히 반복된다. 이는 그림 15-1에서 보여주는 것과 같다.

An infinite Cons list

그림 15-1: 무한한 Cons 변형으로 구성된 무한한 List

Box<T>를 사용해 크기가 정해진 재귀 타입 만들기

Rust는 재귀적으로 정의된 타입에 얼마나 많은 공간을 할당해야 하는지 계산할 수 없기 때문에, 컴파일러는 다음과 같은 유용한 제안과 함께 오류를 발생시킨다.

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

여기서 **간접 참조(indirection)**란 값을 직접 저장하는 대신, 값에 대한 포인터를 저장해 데이터 구조를 변경하는 것을 의미한다.

Box<T>는 포인터이기 때문에 Rust는 항상 Box<T>가 얼마나 많은 공간을 필요로 하는지 알고 있다. 포인터의 크기는 가리키는 데이터의 양에 따라 변하지 않는다. 이는 Cons 변형 안에 다른 List 값을 직접 넣는 대신 Box<T>를 넣을 수 있음을 의미한다. Box<T>는 힙에 위치한 다음 List 값을 가리킬 것이며, Cons 변형 안에 직접 들어가지 않는다. 개념적으로는 여전히 리스트가 리스트를 포함하는 방식으로 리스트를 생성하지만, 이 구현은 이제 아이템들이 서로 안에 들어가는 대신 나란히 배치되는 것과 더 유사하다.

Listing 15-2의 List 열거형 정의와 Listing 15-3의 List 사용을 Listing 15-5의 코드로 변경하면 컴파일이 가능해진다.

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Listing 15-5: Box<T>를 사용해 크기가 정해진 List 정의

Cons 변형은 i32의 크기에 더해 박스의 포인터 데이터를 저장할 공간이 필요하다. Nil 변형은 값을 저장하지 않으므로 Cons 변형보다 더 적은 공간이 필요하다. 이제 모든 List 값이 i32의 크기에 박스의 포인터 데이터 크기를 더한 만큼의 공간을 차지한다는 것을 알 수 있다. 박스를 사용함으로써 무한한 재귀 체인을 끊었기 때문에, 컴파일러는 List 값을 저장하는 데 필요한 크기를 계산할 수 있다. 그림 15-2는 이제 Cons 변형이 어떻게 보이는지를 보여준다.

A finite Cons list

그림 15-2: ConsBox를 포함함으로써 무한한 크기가 아닌 List

박스는 간접 참조와 힙 할당만 제공하며, 다른 스마트 포인터 타입에서 볼 수 있는 특별한 기능은 없다. 또한 이러한 특별한 기능으로 인한 성능 오버헤드도 없기 때문에, 간접 참조만 필요한 경우(예: cons 리스트)에 유용하다. 박스의 더 많은 사용 사례는 18장에서 살펴볼 것이다.

Box<T> 타입은 스마트 포인터이다. Deref 트레잇을 구현하기 때문에 Box<T> 값을 참조처럼 다룰 수 있다. Box<T> 값이 스코프를 벗어나면 박스가 가리키는 힙 데이터도 Drop 트레잇 구현 덕분에 정리된다. 이 두 트레잇은 이 장에서 다룰 다른 스마트 포인터 타입의 기능에서 더 중요하게 작용할 것이다. 이 두 트레잇을 더 자세히 살펴보자.

Deref를 사용해 스마트 포인터를 일반 참조처럼 다루기

Deref 트레잇을 구현하면 역참조 연산자 *의 동작을 커스터마이징할 수 있다. (이 연산자는 곱셈이나 전역 연산자와 혼동하지 말아야 한다.) 스마트 포인터가 일반 참조처럼 동작하도록 Deref를 구현하면, 참조를 다루는 코드를 작성하고 그 코드를 스마트 포인터와 함께 사용할 수 있다.

먼저 역참조 연산자가 일반 참조와 어떻게 동작하는지 살펴보자. 그런 다음 Box<T>처럼 동작하는 커스텀 타입을 정의하고, 역참조 연산자가 새로 정의한 타입에서 참조처럼 동작하지 않는 이유를 알아볼 것이다. Deref 트레잇을 구현함으로써 스마트 포인터가 참조와 유사한 방식으로 동작할 수 있게 되는 과정을 탐구할 것이다. 마지막으로 Rust의 역참조 강제 변환(deref coercion) 기능과 이를 통해 참조나 스마트 포인터를 다루는 방법을 살펴볼 것이다.

참고: 우리가 만들 MyBox<T> 타입과 실제 Box<T> 사이에는 한 가지 큰 차이가 있다. 우리 버전은 데이터를 힙에 저장하지 않는다. 이 예제는 Deref에 초점을 맞추고 있으므로, 데이터가 실제로 어디에 저장되는지는 포인터와 같은 동작보다 덜 중요하다.

포인터를 따라가서 값에 접근하기

일반적인 참조는 일종의 포인터로, 포인터를 값이 저장된 위치를 가리키는 화살표로 생각할 수 있다. 리스트 15-6에서는 i32 값에 대한 참조를 생성한 후, 역참조 연산자를 사용해 참조를 따라가서 값에 접근한다.

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-6: 역참조 연산자를 사용해 i32 값에 대한 참조를 따라가기

변수 xi325를 가지고 있다. yx에 대한 참조로 설정한다. x5와 같다는 것을 확인할 수 있다. 하지만 y에 있는 값을 확인하려면 *y를 사용해 참조가 가리키는 값을 따라가야 한다(이를 _역참조_라고 한다). 이렇게 해야 컴파일러가 실제 값을 비교할 수 있다. y를 역참조하면 y가 가리키는 정수 값에 접근할 수 있고, 이를 5와 비교할 수 있다.

만약 assert_eq!(5, y);와 같이 작성하려고 했다면, 다음과 같은 컴파일 오류가 발생한다:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

숫자와 숫자에 대한 참조를 비교하는 것은 허용되지 않는다. 두 값은 서로 다른 타입이기 때문이다. 참조가 가리키는 값을 따라가기 위해 역참조 연산자를 사용해야 한다.

Box<T>를 참조처럼 사용하기

Listing 15-6의 코드를 참조 대신 Box<T>를 사용하도록 다시 작성할 수 있다. Listing 15-7에서 Box<T>에 사용한 역참조 연산자는 Listing 15-6에서 참조에 사용한 역참조 연산자와 동일하게 작동한다.

Filename: src/main.rs
fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-7: Box<i32>에 역참조 연산자 사용하기

Listing 15-7과 Listing 15-6의 주요 차이점은 여기서 yx의 값을 가리키는 참조 대신 x의 복사된 값을 가리키는 박스의 인스턴스로 설정한다는 것이다. 마지막 단언문에서 박스의 포인터를 따라가기 위해 역참조 연산자를 사용할 수 있으며, 이는 y가 참조였을 때와 동일한 방식이다. 다음으로, 역참조 연산자를 사용할 수 있게 해주는 Box<T>의 특별한 점을 우리만의 타입을 정의하며 알아볼 것이다.

커스텀 스마트 포인터 정의하기

표준 라이브러리에서 제공하는 Box<T> 타입과 유사한 스마트 포인터를 직접 만들어 보자. 이를 통해 스마트 포인터가 기본적으로 참조와 어떻게 다른 동작을 하는지 경험할 수 있다. 이후에는 역참조 연산자를 사용할 수 있도록 기능을 추가하는 방법을 알아볼 것이다.

Box<T> 타입은 결국 하나의 요소를 가진 튜플 구조체로 정의된다. 따라서 Listing 15-8에서도 동일한 방식으로 MyBox<T> 타입을 정의한다. 또한 Box<T>에 정의된 new 함수와 일치하도록 new 함수를 정의한다.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}
Listing 15-8: MyBox<T> 타입 정의

MyBox라는 구조체를 정의하고, 어떤 타입의 값도 담을 수 있도록 제네릭 매개변수 T를 선언한다. MyBox 타입은 T 타입의 요소 하나를 가진 튜플 구조체다. MyBox::new 함수는 T 타입의 매개변수 하나를 받아, 전달된 값을 담은 MyBox 인스턴스를 반환한다.

Listing 15-7의 main 함수를 Listing 15-8에 추가하고, Box<T> 대신 우리가 정의한 MyBox<T> 타입을 사용하도록 수정해 보자. Listing 15-9의 코드는 Rust가 MyBox를 어떻게 역참조해야 할지 모르기 때문에 컴파일되지 않는다.

Filename: src/main.rs
struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-9: 참조와 Box<T>를 사용한 것과 동일한 방식으로 MyBox<T> 사용 시도

컴파일 결과는 다음과 같다:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

MyBox<T> 타입은 역참조 기능을 구현하지 않았기 때문에 역참조할 수 없다. * 연산자를 사용해 역참조를 가능하게 하려면 Deref 트레잇을 구현해야 한다.

Deref 트레잇 구현하기

10장 “타입에 트레잇 구현하기”에서 다뤘듯이, 트레잇을 구현하려면 트레잇의 필수 메서드에 대한 구현을 제공해야 한다. 표준 라이브러리에서 제공하는 Deref 트레잇은 self를 빌려서 내부 데이터에 대한 참조를 반환하는 deref 메서드 하나를 구현하도록 요구한다. 리스트 15-10은 MyBox<T> 정의에 추가할 Deref 구현을 보여준다.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}
Listing 15-10: MyBox<T>Deref 구현하기

type Target = T; 구문은 Deref 트레잇이 사용할 연관 타입을 정의한다. 연관 타입은 제네릭 매개변수를 선언하는 약간 다른 방식이지만, 지금은 걱정하지 않아도 된다. 20장에서 더 자세히 다룰 예정이다.

deref 메서드의 본문을 &self.0으로 채워서 deref* 연산자로 접근하려는 값에 대한 참조를 반환하도록 한다. 5장 “이름 없는 필드로 튜플 구조체를 사용해 다른 타입 만들기”에서 .0이 튜플 구조체의 첫 번째 값에 접근한다는 것을 떠올려보자. 리스트 15-9의 main 함수에서 MyBox<T> 값에 *를 호출하면 이제 컴파일이 되고, 단언문도 통과한다!

Deref 트레잇이 없으면 컴파일러는 & 참조만 역참조할 수 있다. deref 메서드는 컴파일러에게 Deref를 구현한 어떤 타입의 값을 가져와서 deref 메서드를 호출해 역참조 방법을 알고 있는 & 참조를 얻을 수 있는 능력을 제공한다.

리스트 15-9에서 *y를 입력했을 때, 러스트는 실제로 다음과 같은 코드를 실행한다:

*(y.deref())

러스트는 * 연산자를 deref 메서드 호출로 대체한 다음 일반 역참조를 수행한다. 따라서 우리는 deref 메서드를 호출해야 하는지 여부를 고민할 필요가 없다. 이 러스트 기능 덕분에 일반 참조를 사용하든 Deref를 구현한 타입을 사용하든 동일하게 작동하는 코드를 작성할 수 있다.

deref 메서드가 값에 대한 참조를 반환하고, *(y.deref())에서 괄호 밖의 일반 역참조가 여전히 필요한 이유는 소유권 시스템과 관련이 있다. deref 메서드가 값에 대한 참조 대신 값을 직접 반환하면 값이 self에서 이동된다. 이 경우나 대부분의 역참조 연산자 사용 사례에서 MyBox<T> 내부 값의 소유권을 가져오고 싶지는 않다.

* 연산자는 deref 메서드 호출로 대체된 다음 * 연산자 호출이 한 번만 이루어진다는 점에 유의하자. * 연산자의 대체가 무한히 재귀하지 않기 때문에 i32 타입의 데이터로 끝나며, 이는 리스트 15-9의 assert_eq!에서 5와 일치한다.

함수와 메서드에서의 암시적 Deref 강제 변환

_Deref 강제 변환_은 Deref 트레잇을 구현한 타입의 참조를 다른 타입의 참조로 변환한다. 예를 들어, Deref 강제 변환은 &String&str로 변환할 수 있다. 이는 StringDeref 트레잇을 구현해 &str을 반환하기 때문이다. Deref 강제 변환은 함수와 메서드의 인자에 대해 Rust가 편의를 위해 수행하는 기능이며, Deref 트레잇을 구현한 타입에서만 동작한다. 이 변환은 함수나 메서드 정의에서 매개변수 타입과 일치하지 않는 특정 타입의 값에 대한 참조를 인자로 전달할 때 자동으로 발생한다. deref 메서드를 연속적으로 호출해 제공한 타입을 매개변수가 필요한 타입으로 변환한다.

Rust에 Deref 강제 변환이 추가된 이유는 함수와 메서드 호출을 작성할 때 프로그래머가 &*를 사용해 명시적으로 참조와 역참조를 추가하는 작업을 줄이기 위해서다. 또한 Deref 강제 변환 기능은 참조나 스마트 포인터 모두에 동작하는 코드를 더 많이 작성할 수 있게 해준다.

Deref 강제 변환이 실제로 어떻게 동작하는지 확인하기 위해, Listing 15-8에서 정의한 MyBox<T> 타입과 Listing 15-10에서 추가한 Deref 구현을 사용해 보자. Listing 15-11은 문자열 슬라이스 타입의 매개변수를 가진 함수의 정의를 보여준다.

Filename: src/main.rs
fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}
Listing 15-11: &str 타입의 name 매개변수를 가진 hello 함수 정의

예를 들어, hello("Rust");와 같이 문자열 슬라이스를 인자로 hello 함수를 호출할 수 있다. Deref 강제 변환 덕분에 MyBox<String> 타입의 값에 대한 참조를 사용해 hello를 호출할 수도 있다. 이는 Listing 15-12에서 확인할 수 있다.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}
Listing 15-12: Deref 강제 변환 덕분에 MyBox<String> 값에 대한 참조로 hello 호출하기

여기서는 &m을 인자로 hello 함수를 호출한다. &mMyBox<String> 값에 대한 참조다. Listing 15-10에서 MyBox<T>Deref 트레잇을 구현했기 때문에, Rust는 deref를 호출해 &MyBox<String>&String으로 변환할 수 있다. 표준 라이브러리는 String에 대한 Deref 구현을 제공하며, 이는 문자열 슬라이스를 반환한다. 이 내용은 Deref의 API 문서에 나와 있다. Rust는 deref를 다시 호출해 &String&str로 변환하며, 이는 hello 함수의 정의와 일치한다.

만약 Rust가 Deref 강제 변환을 구현하지 않았다면, &MyBox<String> 타입의 값으로 hello를 호출하기 위해 Listing 15-13의 코드를 작성해야 했을 것이다.

Filename: src/main.rs
use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}
Listing 15-13: Rust에 Deref 강제 변환이 없었다면 작성해야 했던 코드

(*m)MyBox<String>String으로 역참조한다. 그런 다음 &[..]는 전체 문자열과 동일한 String의 문자열 슬라이스를 가져와 hello의 시그니처와 일치시킨다. Deref 강제 변환이 없다면 이 코드는 읽기, 작성하기, 이해하기 모두 더 어려워진다. Deref 강제 변환은 Rust가 이러한 변환을 자동으로 처리할 수 있게 해준다.

관련 타입에 대해 Deref 트레잇이 정의되어 있다면, Rust는 타입을 분석하고 Deref::deref를 필요한 만큼 호출해 매개변수의 타입과 일치하는 참조를 얻는다. Deref::deref를 삽입해야 하는 횟수는 컴파일 타임에 결정되므로, Deref 강제 변환을 활용해도 런타임 성능에 영향을 미치지 않는다!

Deref 강제 변환과 가변성의 상호작용

불변 참조에서 * 연산자를 오버라이드하기 위해 Deref 트레잇을 사용하는 것과 유사하게, 가변 참조에서 * 연산자를 오버라이드하기 위해 DerefMut 트레잇을 사용할 수 있다.

Rust는 세 가지 경우에 타입과 트레잇 구현을 발견할 때 Deref 강제 변환을 수행한다:

  1. &T에서 &U로 변환. 이때 T: Deref<Target=U> 조건을 만족한다.
  2. &mut T에서 &mut U로 변환. 이때 T: DerefMut<Target=U> 조건을 만족한다.
  3. &mut T에서 &U로 변환. 이때 T: Deref<Target=U> 조건을 만족한다.

첫 번째와 두 번째 경우는 가변성 구현 여부만 다르고 나머지는 동일하다. 첫 번째 경우는 &T가 있고 TDeref를 구현하여 어떤 타입 U로 변환될 수 있다면, &U를 투명하게 얻을 수 있다는 것을 의미한다. 두 번째 경우는 가변 참조에 대해 동일한 Deref 강제 변환이 일어난다는 것을 나타낸다.

세 번째 경우는 더 복잡하다: Rust는 가변 참조를 불변 참조로도 강제 변환한다. 하지만 그 반대는 불가능하다: 불변 참조는 절대 가변 참조로 강제 변환되지 않는다. 빌림 규칙에 따라, 가변 참조가 있다면 그 가변 참조는 해당 데이터에 대한 유일한 참조여야 한다(그렇지 않으면 프로그램이 컴파일되지 않는다). 하나의 가변 참조를 하나의 불변 참조로 변환하는 것은 빌림 규칙을 절대 위반하지 않는다. 그러나 불변 참조를 가변 참조로 변환하려면 초기 불변 참조가 해당 데이터에 대한 유일한 불변 참조여야 하는데, 빌림 규칙이 이를 보장하지 않는다. 따라서 Rust는 불변 참조를 가변 참조로 변환하는 것이 가능하다고 가정할 수 없다.

클린업 코드 실행을 위한 Drop 트레이트

스마트 포인터 패턴에서 중요한 두 번째 트레이트는 Drop이다. 이 트레이트를 통해 값이 스코프를 벗어날 때 실행될 코드를 커스터마이즈할 수 있다. 어떤 타입이든 Drop 트레이트를 구현할 수 있으며, 이 코드를 통해 파일이나 네트워크 연결과 같은 리소스를 해제할 수 있다.

Drop 트레이트의 기능은 스마트 포인터를 구현할 때 거의 항상 사용되기 때문에, 스마트 포인터의 맥락에서 Drop을 소개한다. 예를 들어, Box<T>가 드롭되면 힙에 할당된 공간을 해제한다.

일부 언어에서는 특정 타입의 인스턴스를 사용한 후에 프로그래머가 직접 메모리나 리소스를 해제하는 코드를 호출해야 한다. 파일 핸들, 소켓, 락 등이 그 예시다. 만약 이를 잊어버리면 시스템이 과부하를 겪고 충돌할 수 있다. Rust에서는 값이 스코프를 벗어날 때 특정 코드가 실행되도록 지정할 수 있으며, 컴파일러가 이 코드를 자동으로 삽입한다. 결과적으로 특정 타입의 인스턴스 사용이 끝난 후 클린업 코드를 프로그램 전체에 신경 쓸 필요가 없다. 그래도 리소스가 누수되지 않는다!

Drop 트레이트를 구현하면 값이 스코프를 벗어날 때 실행될 코드를 지정할 수 있다. Drop 트레이트는 self에 대한 가변 참조를 받는 drop이라는 메서드를 하나 구현해야 한다. Rust가 drop을 호출하는 시점을 확인하기 위해, 일단 println! 문을 사용해 drop을 구현해 보자.

리스트 15-14는 CustomSmartPointer라는 구조체를 보여준다. 이 구조체의 유일한 커스텀 기능은 인스턴스가 스코프를 벗어날 때 Dropping CustomSmartPointer!를 출력하는 것이다. 이를 통해 Rust가 drop 메서드를 실행하는 시점을 확인할 수 있다.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("my stuff"),
    };
    let d = CustomSmartPointer {
        data: String::from("other stuff"),
    };
    println!("CustomSmartPointers created.");
}
Listing 15-14: 클린업 코드를 넣을 Drop 트레이트를 구현한 CustomSmartPointer 구조체

Drop 트레이트는 프렐루드에 포함되어 있으므로 스코프로 가져올 필요가 없다. CustomSmartPointerDrop 트레이트를 구현하고 println!을 호출하는 drop 메서드를 제공한다. drop 메서드의 본문은 타입의 인스턴스가 스코프를 벗어날 때 실행하고 싶은 로직을 넣는 곳이다. 여기서는 Rust가 drop을 호출하는 시점을 시각적으로 확인하기 위해 텍스트를 출력한다.

main 함수에서 CustomSmartPointer의 두 인스턴스를 생성한 후 CustomSmartPointers created를 출력한다. main 함수가 끝나면 CustomSmartPointer의 인스턴스가 스코프를 벗어나고, Rust는 drop 메서드에 넣은 코드를 호출해 최종 메시지를 출력한다. 여기서 drop 메서드를 명시적으로 호출할 필요는 없다.

이 프로그램을 실행하면 다음과 같은 출력을 볼 수 있다:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/drop-example`
CustomSmartPointers created.
Dropping CustomSmartPointer with data `other stuff`!
Dropping CustomSmartPointer with data `my stuff`!

Rust는 인스턴스가 스코프를 벗어날 때 자동으로 drop을 호출해 우리가 지정한 코드를 실행했다. 변수는 생성된 순서의 역순으로 드롭되므로 dc보다 먼저 드롭된다. 이 예제의 목적은 drop 메서드가 어떻게 동작하는지 시각적으로 보여주는 것이다. 보통은 출력 메시지 대신 타입이 필요로 하는 클린업 코드를 지정한다.

안타깝게도 자동 drop 기능을 비활성화하는 것은 간단하지 않다. drop을 비활성화할 필요는 거의 없다. Drop 트레이트의 핵심은 자동으로 처리된다는 점이다. 하지만 가끔은 값을 조기에 클린업하고 싶을 때가 있다. 예를 들어, 락을 관리하는 스마트 포인터를 사용할 때 락을 해제하는 drop 메서드를 강제로 호출해 동일한 스코프의 다른 코드가 락을 획득할 수 있도록 하고 싶을 수 있다. Rust는 Drop 트레이트의 drop 메서드를 수동으로 호출할 수 없게 한다. 대신, 스코프가 끝나기 전에 값을 강제로 드롭하고 싶다면 표준 라이브러리에서 제공하는 std::mem::drop 함수를 호출해야 한다.

리스트 15-14의 main 함수를 수정해 Drop 트레이트의 drop 메서드를 수동으로 호출하려고 하면, 리스트 15-15와 같이 컴파일러 에러가 발생한다.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    c.drop();
    println!("CustomSmartPointer dropped before the end of main.");
}
Listing 15-15: 조기에 클린업하기 위해 Drop 트레이트의 drop 메서드를 수동으로 호출하려는 시도

이 코드를 컴파일하려고 하면 다음과 같은 에러가 발생한다:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
error[E0040]: explicit use of destructor method
  --> src/main.rs:16:7
   |
16 |     c.drop();
   |       ^^^^ explicit destructor calls not allowed
   |
help: consider using `drop` function
   |
16 |     drop(c);
   |     +++++ ~

For more information about this error, try `rustc --explain E0040`.
error: could not compile `drop-example` (bin "drop-example") due to 1 previous error

이 에러 메시지는 drop을 명시적으로 호출할 수 없다고 알려준다. 에러 메시지는 _destructor_라는 용어를 사용하는데, 이는 인스턴스를 클린업하는 함수를 일반적으로 지칭하는 프로그래밍 용어다. _destructor_는 인스턴스를 생성하는 _constructor_와 유사하다. Rust의 drop 함수는 특정한 destructor이다.

Rust는 drop을 명시적으로 호출할 수 없게 한다. 왜냐하면 Rust는 여전히 main 함수가 끝날 때 값에 대해 자동으로 drop을 호출하기 때문이다. 이로 인해 Rust가 동일한 값을 두 번 클린업하려고 시도하면서 double free 에러가 발생할 수 있다.

값이 스코프를 벗어날 때 drop이 자동으로 삽입되는 것을 비활성화할 수 없고, drop 메서드를 명시적으로 호출할 수도 없다. 따라서 값을 조기에 클린업해야 한다면 std::mem::drop 함수를 사용해야 한다.

std::mem::drop 함수는 Drop 트레이트의 drop 메서드와 다르다. 강제로 드롭할 값을 인자로 전달해 호출한다. 이 함수는 프렐루드에 포함되어 있으므로, 리스트 15-15의 main 함수를 수정해 drop 함수를 호출할 수 있다. 리스트 15-16에서 이를 확인할 수 있다.

Filename: src/main.rs
struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer with data `{}`!", self.data);
    }
}

fn main() {
    let c = CustomSmartPointer {
        data: String::from("some data"),
    };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main.");
}
Listing 15-16: 스코프가 끝나기 전에 값을 명시적으로 드롭하기 위해 std::mem::drop 호출

이 코드를 실행하면 다음과 같은 출력이 나타난다:

$ cargo run
   Compiling drop-example v0.1.0 (file:///projects/drop-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
     Running `target/debug/drop-example`
CustomSmartPointer created.
Dropping CustomSmartPointer with data `some data`!
CustomSmartPointer dropped before the end of main.

CustomSmartPointer created.CustomSmartPointer dropped before the end of main. 텍스트 사이에 Dropping CustomSmartPointer with data `some data`!가 출력되어, drop 메서드 코드가 그 시점에 c를 드롭하기 위해 호출되었음을 보여준다.

Drop 트레이트 구현에서 지정한 코드는 클린업을 편리하고 안전하게 만드는 다양한 방법으로 사용할 수 있다. 예를 들어, 자신만의 메모리 할당자를 만드는 데 사용할 수도 있다! Drop 트레이트와 Rust의 소유권 시스템 덕분에 클린업을 기억할 필요가 없다. Rust가 자동으로 처리해준다.

또한 여전히 사용 중인 값을 실수로 클린업해 발생하는 문제에 대해 걱정할 필요도 없다. 참조가 항상 유효하도록 보장하는 소유권 시스템은 값이 더 이상 사용되지 않을 때 drop이 한 번만 호출되도록 보장한다.

이제 Box<T>와 스마트 포인터의 몇 가지 특성을 살펴봤으니, 표준 라이브러리에 정의된 다른 스마트 포인터들을 살펴보자.

Rc<T>, 참조 카운트 스마트 포인터

대부분의 경우, 소유권은 명확하다. 특정 값이 어떤 변수에 의해 소유되는지 정확히 알 수 있다. 하지만 어떤 경우에는 하나의 값이 여러 소유자를 가질 수 있다. 예를 들어, 그래프 데이터 구조에서 여러 간선이 동일한 노드를 가리킬 수 있으며, 이 노드는 개념적으로 그 노드를 가리키는 모든 간선에 의해 소유된다. 노드는 더 이상 간선이 없어 소유자가 없는 경우에만 정리되어야 한다.

Rust에서는 Rc<T> 타입을 사용해 명시적으로 다중 소유권을 활성화해야 한다. Rc<T>참조 카운팅(reference counting) 의 약자로, 이 타입은 값에 대한 참조 수를 추적해 값이 여전히 사용 중인지 여부를 결정한다. 값에 대한 참조가 0개가 되면, 그 값은 더 이상 사용되지 않으므로 정리할 수 있다.

Rc<T>를 가족 방에 있는 TV라고 상상해 보자. 한 사람이 방에 들어와 TV를 켜면, 다른 사람들도 방에 들어와 TV를 볼 수 있다. 마지막 사람이 방을 나갈 때, 더 이상 TV가 사용되지 않으므로 TV를 끈다. 만약 누군가가 다른 사람들이 아직 TV를 보고 있는데 TV를 꺼버리면, 남아 있는 시청자들이 불만을 가질 것이다!

Rc<T> 타입은 프로그램의 여러 부분이 읽을 수 있도록 힙에 데이터를 할당하고, 컴파일 타임에 어떤 부분이 데이터를 마지막으로 사용할지 결정할 수 없을 때 사용한다. 만약 어떤 부분이 마지막으로 데이터를 사용할지 알고 있다면, 그 부분을 데이터의 소유자로 만들고, 컴파일 타임에 적용되는 일반적인 소유권 규칙을 따를 수 있다.

Rc<T>는 단일 스레드 시나리오에서만 사용된다는 점에 유의해야 한다. 16장에서 동시성을 다룰 때, 멀티스레드 프로그램에서 참조 카운팅을 어떻게 수행하는지 설명할 것이다.

Rc<T>를 사용해 데이터 공유하기

리스트 15-5의 cons 리스트 예제로 돌아가 보자. 이전에는 Box<T>를 사용해 리스트를 정의했다. 이번에는 두 개의 리스트가 세 번째 리스트를 공유하는 상황을 만들어 볼 것이다. 개념적으로는 그림 15-3과 비슷한 구조다.

세 번째 리스트를 공유하는 두 개의 리스트

그림 15-3: 리스트 bc가 리스트 a를 공유하는 구조

먼저 5와 10을 포함하는 리스트 a를 만든다. 그런 다음 두 개의 리스트 bc를 생성한다. b는 3으로 시작하고, c는 4로 시작한다. 그리고 두 리스트 모두 5와 10을 포함하는 리스트 a를 이어받는다. 즉, 두 리스트가 5와 10을 포함하는 리스트를 공유하게 된다.

이 시나리오를 Box<T>를 사용해 구현하려고 하면 리스트 15-17과 같이 동작하지 않는다.

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}
Listing 15-17: Box<T>를 사용해 두 리스트가 세 번째 리스트를 공유하려 할 때 발생하는 문제

이 코드를 컴파일하면 다음과 같은 에러가 발생한다.

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
9  |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

Cons 변형은 자신이 포함한 데이터의 소유권을 갖는다. 따라서 리스트 b를 생성할 때 ab로 이동하고, ba를 소유하게 된다. 그런 다음 리스트 c를 생성할 때 다시 a를 사용하려고 하면, a가 이미 이동되었기 때문에 허용되지 않는다.

Cons의 정의를 변경해 참조를 포함하도록 할 수도 있지만, 그렇게 하면 라이프타임 매개변수를 명시해야 한다. 라이프타임 매개변수를 명시하면 리스트의 모든 요소가 적어도 리스트 전체만큼 오래 살아있음을 보장한다. 리스트 15-17의 요소와 리스트는 이 조건을 만족하지만, 모든 상황에서 그렇지는 않다.

대신 Box<T> 대신 Rc<T>를 사용해 List를 정의한다. 리스트 15-18에서 볼 수 있듯이, 각 Cons 변형은 값과 List를 가리키는 Rc<T>를 포함한다. b를 생성할 때 a의 소유권을 가져가는 대신, a가 포함하는 Rc<List>를 복제한다. 이렇게 하면 참조 수가 1에서 2로 증가하고, abRc<List>의 데이터를 공유하게 된다. c를 생성할 때도 a를 복제해 참조 수를 2에서 3으로 증가시킨다. Rc::clone을 호출할 때마다 Rc<List> 내부 데이터의 참조 수가 증가하며, 참조 수가 0이 되기 전까지 데이터는 정리되지 않는다.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}
Listing 15-18: Rc<T>를 사용한 List 정의

Rc<T>를 스코프로 가져오기 위해 use 문을 추가해야 한다. main 함수에서는 5와 10을 포함하는 리스트를 생성하고, 이를 a라는 새로운 Rc<List>에 저장한다. 그런 다음 bc를 생성할 때 Rc::clone 함수를 호출하고, a에 있는 Rc<List>의 참조를 인자로 전달한다.

a.clone() 대신 Rc::clone(&a)를 호출할 수도 있지만, 러스트에서는 이 경우 Rc::clone을 사용하는 것이 관례다. Rc::clone의 구현은 대부분의 타입의 clone 구현과 달리 모든 데이터를 깊은 복사하지 않는다. Rc::clone 호출은 참조 수만 증가시키며, 이는 시간이 거의 걸리지 않는다. 데이터의 깊은 복사는 시간이 많이 걸릴 수 있다. Rc::clone을 사용해 참조 수를 증가시키면, 깊은 복사와 참조 수 증가를 시각적으로 구분할 수 있다. 코드에서 성능 문제를 찾을 때는 깊은 복사만 고려하면 되고, Rc::clone 호출은 무시해도 된다.

Rc<T>를 복제하면 참조 카운트가 증가한다

리스트 15-18의 예제를 수정해 a에 있는 Rc<List>에 대한 참조를 생성하고 제거할 때 참조 카운트가 어떻게 변하는지 살펴보자.

리스트 15-19에서는 main 함수를 수정해 리스트 c 주위에 내부 스코프를 추가한다. 이렇게 하면 c가 스코프를 벗어날 때 참조 카운트가 어떻게 변하는지 확인할 수 있다.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
Listing 15-19: 참조 카운트 출력

프로그램에서 참조 카운트가 변하는 각 지점에서 Rc::strong_count 함수를 호출해 참조 카운트를 출력한다. 이 함수는 count가 아닌 strong_count라는 이름을 사용하는데, 그 이유는 Rc<T> 타입이 weak_count도 가지고 있기 때문이다. weak_count의 용도는 Weak<T>를 사용해 참조 순환 방지하기”에서 살펴볼 것이다.

이 코드는 다음과 같은 결과를 출력한다:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

a에 있는 Rc<List>의 초기 참조 카운트는 1이다. 이후 clone을 호출할 때마다 카운트가 1씩 증가한다. c가 스코프를 벗어나면 카운트가 1 감소한다. 참조 카운트를 증가시키기 위해 Rc::clone을 호출해야 하는 것과 달리, 참조 카운트를 감소시키기 위해 별도의 함수를 호출할 필요는 없다. Rc<T> 값이 스코프를 벗어나면 Drop 트레이트의 구현이 자동으로 참조 카운트를 감소시킨다.

이 예제에서는 볼 수 없지만, main 함수의 끝에서 ba가 스코프를 벗어나면 카운트가 0이 되고, Rc<List>는 완전히 정리된다. Rc<T>를 사용하면 단일 값이 여러 소유자를 가질 수 있으며, 카운트는 소유자 중 하나라도 존재하는 한 값이 유효하도록 보장한다.

Rc<T>는 불변 참조를 통해 프로그램의 여러 부분에서 데이터를 읽기 전용으로 공유할 수 있게 해준다. 만약 Rc<T>가 여러 가변 참조도 허용한다면, 4장에서 다룬 빌림 규칙 중 하나를 위반할 수 있다. 동일한 위치에 대한 여러 가변 빌림은 데이터 경쟁과 불일치를 초래할 수 있다. 하지만 데이터를 변경할 수 있는 기능은 매우 유용하다! 다음 섹션에서는 내부 가변성 패턴과 Rc<T>와 함께 사용할 수 있는 RefCell<T> 타입에 대해 논의할 것이다.

RefCell<T>와 내부 가변성 패턴

**내부 가변성(Interior Mutability)**은 러스트의 디자인 패턴 중 하나로, 데이터에 대한 불변 참조가 존재할 때도 데이터를 변경할 수 있게 해준다. 일반적으로 이러한 동작은 러스트의 빌림 규칙에 의해 금지된다. 이 패턴은 데이터 구조 내부에 unsafe 코드를 사용해 러스트의 일반적인 뮤테이션과 빌림 규칙을 유연하게 적용한다. unsafe 코드는 컴파일러에게 우리가 직접 규칙을 확인하고 있음을 알려주며, 컴파일러가 이를 대신 확인하지 않도록 한다. unsafe 코드에 대해서는 20장에서 더 자세히 다룰 것이다.

내부 가변성 패턴을 사용하는 타입은 컴파일러가 보장할 수 없더라도 런타임에 빌림 규칙이 지켜질 것임을 확신할 수 있을 때만 사용할 수 있다. 이때 관련된 unsafe 코드는 안전한 API로 감싸지며, 외부 타입은 여전히 불변성을 유지한다.

이 개념을 이해하기 위해 내부 가변성 패턴을 따르는 RefCell<T> 타입을 살펴보자.

RefCell<T>를 사용해 런타임에 빌림 규칙 적용하기

Rc<T>와 달리, RefCell<T> 타입은 자신이 가지고 있는 데이터에 대해 단일 소유권을 나타낸다. 그렇다면 RefCell<T>Box<T> 같은 타입과 어떻게 다른가? 4장에서 배운 빌림 규칙을 다시 떠올려보자:

  • 특정 시점에 하나의 가변 참조 또는 여러 개의 불변 참조 중 하나만 가질 수 있다(둘 다 동시에 가질 수 없다).
  • 참조는 항상 유효해야 한다.

참조와 Box<T>의 경우, 빌림 규칙의 불변성은 컴파일 타임에 강제된다. 반면 RefCell<T>의 경우, 이 불변성은 런타임에 강제된다. 참조를 사용할 때 이 규칙을 어기면 컴파일 오류가 발생하지만, RefCell<T>를 사용할 때 규칙을 어기면 프로그램이 패닉 상태에 빠지고 종료된다.

컴파일 타임에 빌림 규칙을 검사하는 장점은 개발 과정에서 오류를 더 빨리 발견할 수 있고, 모든 분석이 사전에 완료되기 때문에 런타임 성능에 영향을 미치지 않는다는 점이다. 이러한 이유로 대부분의 경우 컴파일 타임에 빌림 규칙을 검사하는 것이 최선의 선택이며, 이것이 Rust의 기본 동작 방식이다.

반면, 런타임에 빌림 규칙을 검사하는 장점은 컴파일 타임 검사에서는 허용되지 않았던 특정 메모리 안전 시나리오를 허용할 수 있다는 점이다. Rust 컴파일러와 같은 정적 분석은 본질적으로 보수적이다. 코드의 일부 특성은 코드를 분석하는 것만으로는 감지할 수 없다. 가장 유명한 예는 정지 문제(Halting Problem)로, 이 책의 범위를 벗어나지만 연구해볼 만한 흥미로운 주제다.

어떤 분석은 불가능하기 때문에, Rust 컴파일러가 코드가 소유권 규칙을 준수하는지 확신할 수 없으면 올바른 프로그램도 거부할 수 있다. 이런 의미에서 컴파일러는 보수적이다. 만약 Rust가 잘못된 프로그램을 허용한다면, 사용자는 Rust가 제공하는 보장을 신뢰할 수 없게 될 것이다. 반면, Rust가 올바른 프로그램을 거부한다면 프로그래머에게 불편을 줄 수는 있지만, 치명적인 문제는 발생하지 않는다. RefCell<T> 타입은 코드가 빌림 규칙을 준수한다고 확신하지만 컴파일러가 이를 이해하고 보장할 수 없는 경우에 유용하다.

Rc<T>와 마찬가지로, RefCell<T>는 단일 스레드 시나리오에서만 사용할 수 있으며, 멀티스레드 환경에서 사용하려고 하면 컴파일 타임 오류가 발생한다. 16장에서 멀티스레드 프로그램에서 RefCell<T>의 기능을 어떻게 사용할 수 있는지 알아볼 것이다.

Box<T>, Rc<T>, RefCell<T>를 선택하는 이유를 정리하면 다음과 같다:

  • Rc<T>는 동일한 데이터에 대해 여러 소유자를 허용한다. Box<T>RefCell<T>는 단일 소유자만 허용한다.
  • Box<T>는 컴파일 타임에 검사되는 불변 또는 가변 빌림을 허용한다. Rc<T>는 컴파일 타임에 검사되는 불변 빌림만 허용한다. RefCell<T>는 런타임에 검사되는 불변 또는 가변 빌림을 허용한다.
  • RefCell<T>는 런타임에 가변 빌림을 허용하기 때문에, RefCell<T>가 불변일 때도 내부 값을 변경할 수 있다.

불변 값 내부의 값을 변경하는 것을 내부 가변성(interior mutability) 패턴이라고 한다. 내부 가변성이 유용한 상황을 살펴보고, 어떻게 가능한지 알아보자.

내부 가변성: 불변 값에 대한 가변 참조

빌림 규칙의 한 가지 결과는 불변 값을 가변적으로 빌릴 수 없다는 점이다. 예를 들어, 다음 코드는 컴파일되지 않는다:

fn main() {
    let x = 5;
    let y = &mut x;
}

이 코드를 컴파일하려고 하면 다음과 같은 에러가 발생한다:

$ cargo run
   Compiling borrowing v0.1.0 (file:///projects/borrowing)
error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable
 --> src/main.rs:3:13
  |
3 |     let y = &mut x;
  |             ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut x = 5;
  |         +++

For more information about this error, try `rustc --explain E0596`.
error: could not compile `borrowing` (bin "borrowing") due to 1 previous error

그러나 특정 상황에서는 값이 자신의 메서드 내에서 변이할 수 있으면서도 외부 코드에서는 불변으로 보이는 것이 유용할 때가 있다. 값의 메서드 외부에서는 해당 값을 변이할 수 없다. RefCell<T>를 사용하면 내부 가변성을 얻을 수 있지만, RefCell<T>가 빌림 규칙을 완전히 우회하는 것은 아니다: 컴파일러의 빌림 검사기는 이 내부 가변성을 허용하며, 빌림 규칙은 런타임에 검사된다. 규칙을 위반하면 컴파일러 에러 대신 panic!이 발생한다.

RefCell<T>를 사용해 불변 값을 변이할 수 있는 실제 예제를 통해 왜 이것이 유용한지 살펴보자.

내부 가변성 사용 사례: Mock 객체

테스트를 진행할 때 프로그래머는 특정 동작을 관찰하고 구현이 올바른지 확인하기 위해 한 타입을 다른 타입으로 대체할 수 있다. 이 대체 타입을 **테스트 더블(test double)**이라고 한다. 영화 촬영에서 스턴트 더블이 배우를 대신해 어려운 장면을 수행하는 것과 비슷하다고 생각하면 된다. 테스트 더블은 테스트를 실행할 때 다른 타입을 대신한다. Mock 객체는 테스트 중에 발생한 일을 기록해 올바른 동작이 수행되었는지 확인할 수 있도록 하는 특수한 테스트 더블이다.

Rust는 다른 언어와 같은 의미의 객체를 가지고 있지 않으며, 표준 라이브러리에 Mock 객체 기능이 내장되어 있지 않다. 하지만 Mock 객체와 동일한 목적을 수행할 수 있는 구조체를 직접 만들 수 있다.

테스트할 시나리오는 다음과 같다: 최댓값과 현재 값을 비교해 얼마나 가까운지 추적하고, 그에 따라 메시지를 보내는 라이브러리를 만든다. 이 라이브러리는 사용자가 허용된 API 호출 횟수를 추적하는 등 다양한 용도로 활용할 수 있다.

우리가 만드는 라이브러리는 최댓값에 얼마나 가까운지 추적하고, 특정 시점에 어떤 메시지를 보내야 하는지 결정하는 기능만 제공한다. 이 라이브러리를 사용하는 애플리케이션은 메시지를 보내는 방식을 직접 제공해야 한다. 애플리케이션은 메시지를 화면에 표시하거나, 이메일을 보내거나, 문자 메시지를 보내는 등 다양한 방식으로 메시지를 전달할 수 있다. 라이브러리는 이러한 세부 사항을 알 필요가 없다. 단지 우리가 제공할 Messenger라는 트레이트를 구현한 무언가만 필요하다. 다음은 라이브러리 코드이다.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}
Listing 15-20: 최댓값에 얼마나 가까운지 추적하고 특정 수준에서 경고를 보내는 라이브러리

이 코드에서 중요한 부분은 Messenger 트레이트가 self에 대한 불변 참조와 메시지 텍스트를 인자로 받는 send 메서드를 가지고 있다는 점이다. 이 트레이트는 Mock 객체가 실제 객체와 동일한 방식으로 사용될 수 있도록 구현해야 하는 인터페이스이다. 또 다른 중요한 부분은 LimitTrackerset_value 메서드의 동작을 테스트하려 한다는 점이다. value 매개변수에 전달하는 값을 변경할 수 있지만, set_value는 우리가 확인할 수 있는 값을 반환하지 않는다. 우리는 Messenger 트레이트를 구현한 객체와 특정 max 값을 가진 LimitTracker를 생성한 후, value에 다른 숫자를 전달했을 때 메신저가 적절한 메시지를 보내도록 하는지 확인하고 싶다.

우리는 send를 호출할 때 이메일이나 문자 메시지를 보내는 대신, 전달받은 메시지를 기록만 하는 Mock 객체가 필요하다. Mock 객체의 새 인스턴스를 생성하고, 이 Mock 객체를 사용하는 LimitTracker를 만든 후, LimitTrackerset_value 메서드를 호출하고, Mock 객체가 예상한 메시지를 가지고 있는지 확인할 수 있다. 다음은 이를 구현하려는 시도이지만, 빌림 검사기가 이를 허용하지 않는다.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct MockMessenger {
        sent_messages: Vec<String>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: vec![],
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.len(), 1);
    }
}
Listing 15-21: 빌림 검사기가 허용하지 않는 MockMessenger 구현 시도

이 테스트 코드는 MockMessenger 구조체를 정의한다. 이 구조체는 전달받은 메시지를 추적하기 위해 String 값의 Vec을 가진 sent_messages 필드를 가지고 있다. 또한, 빈 메시지 목록으로 시작하는 MockMessenger 값을 쉽게 생성할 수 있도록 new 연관 함수를 정의한다. 그리고 MockMessengerMessenger 트레이트를 구현해 LimitTrackerMockMessenger를 전달할 수 있게 한다. send 메서드의 정의에서는 매개변수로 전달된 메시지를 MockMessengersent_messages 목록에 저장한다.

테스트에서는 LimitTrackermax 값의 75% 이상인 value를 설정하도록 요청했을 때 어떤 일이 발생하는지 확인한다. 먼저, 빈 메시지 목록으로 시작하는 새로운 MockMessenger를 생성한다. 그런 다음, 새로운 LimitTracker를 만들고, 새로운 MockMessenger에 대한 참조와 max 값으로 100을 전달한다. LimitTrackerset_value 메서드를 80이라는 값으로 호출한다. 이 값은 100의 75%를 초과한다. 그런 다음, MockMessenger가 추적 중인 메시지 목록에 하나의 메시지가 있어야 한다고 확인한다.

그러나 이 테스트에는 한 가지 문제가 있다:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
error[E0596]: cannot borrow `self.sent_messages` as mutable, as it is behind a `&` reference
  --> src/lib.rs:58:13
   |
58 |             self.sent_messages.push(String::from(message));
   |             ^^^^^^^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable
   |
help: consider changing this to be a mutable reference in the `impl` method and the `trait` definition
   |
2  ~     fn send(&mut self, msg: &str);
3  | }
...
56 |     impl Messenger for MockMessenger {
57 ~         fn send(&mut self, message: &str) {
   |

For more information about this error, try `rustc --explain E0596`.
error: could not compile `limit-tracker` (lib test) due to 1 previous error

send 메서드가 self에 대한 불변 참조를 받기 때문에 MockMessenger를 수정해 메시지를 추적할 수 없다. 또한, 오류 메시지에서 제안한 대로 impl 메서드와 trait 정의 모두에서 &mut self를 사용할 수도 없다. 테스트를 위해서만 Messenger 트레이트를 변경하고 싶지 않다. 대신, 기존 설계와 함께 테스트 코드가 올바르게 작동하도록 하는 방법을 찾아야 한다.

이런 상황에서 **내부 가변성(interior mutability)**이 도움이 될 수 있다! sent_messagesRefCell<T> 안에 저장하면, send 메서드가 sent_messages를 수정해 우리가 본 메시지를 저장할 수 있다. 다음은 이를 구현한 코드이다.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            self.sent_messages.borrow_mut().push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        // --snip--
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}
Listing 15-22: 외부 값이 불변으로 간주되는 동안 내부 값을 변경하기 위해 RefCell<T> 사용

sent_messages 필드는 이제 Vec<String> 대신 RefCell<Vec<String>> 타입이다. new 함수에서는 빈 벡터를 감싸는 새로운 RefCell<Vec<String>> 인스턴스를 생성한다.

send 메서드의 구현에서 첫 번째 매개변수는 여전히 self에 대한 불변 참조로, 트레이트 정의와 일치한다. self.sent_messages에 있는 RefCell<Vec<String>>borrow_mut를 호출해 RefCell<Vec<String>> 내부의 벡터에 대한 가변 참조를 얻는다. 그런 다음, 벡터에 대한 가변 참조에 push를 호출해 테스트 중에 전송된 메시지를 추적할 수 있다.

마지막으로 변경해야 할 부분은 assertion이다. 내부 벡터에 몇 개의 항목이 있는지 확인하기 위해 RefCell<Vec<String>>borrow를 호출해 벡터에 대한 불변 참조를 얻는다.

이제 RefCell<T>를 사용하는 방법을 살펴봤으니, 이제 RefCell<T>가 어떻게 동작하는지 자세히 알아보자!

RefCell<T>를 사용해 런타임에 대여 상태 추적하기

불변 참조와 가변 참조를 만들 때 각각 &&mut 구문을 사용한다. RefCell<T>를 사용할 때는 borrowborrow_mut 메서드를 사용한다. 이 메서드들은 RefCell<T>의 안전한 API에 속한다. borrow 메서드는 스마트 포인터 타입인 Ref<T>를 반환하고, borrow_mutRefMut<T>를 반환한다. 두 타입 모두 Deref를 구현하므로 일반 참조처럼 사용할 수 있다.

RefCell<T>는 현재 활성화된 Ref<T>RefMut<T> 스마트 포인터의 수를 추적한다. borrow를 호출할 때마다 RefCell<T>는 불변 대여의 수를 증가시킨다. Ref<T> 값이 스코프를 벗어나면 불변 대여의 수가 1 감소한다. 컴파일 타임의 대여 규칙과 마찬가지로, RefCell<T>는 여러 불변 대여 또는 하나의 가변 대여를 허용한다.

이 규칙을 위반하려고 하면, 일반 참조에서와 같이 컴파일러 에러가 발생하는 대신 RefCell<T>의 구현이 런타임에 패닉을 일으킨다. 리스트 15-23은 리스트 15-22의 send 구현을 수정한 예시다. 같은 스코프에서 두 개의 가변 대여를 의도적으로 생성해 RefCell<T>가 런타임에 이를 방지하는 것을 보여준다.

Filename: src/lib.rs
pub trait Messenger {
    fn send(&self, msg: &str);
}

pub struct LimitTracker<'a, T: Messenger> {
    messenger: &'a T,
    value: usize,
    max: usize,
}

impl<'a, T> LimitTracker<'a, T>
where
    T: Messenger,
{
    pub fn new(messenger: &'a T, max: usize) -> LimitTracker<'a, T> {
        LimitTracker {
            messenger,
            value: 0,
            max,
        }
    }

    pub fn set_value(&mut self, value: usize) {
        self.value = value;

        let percentage_of_max = self.value as f64 / self.max as f64;

        if percentage_of_max >= 1.0 {
            self.messenger.send("Error: You are over your quota!");
        } else if percentage_of_max >= 0.9 {
            self.messenger
                .send("Urgent warning: You've used up over 90% of your quota!");
        } else if percentage_of_max >= 0.75 {
            self.messenger
                .send("Warning: You've used up over 75% of your quota!");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct MockMessenger {
        sent_messages: RefCell<Vec<String>>,
    }

    impl MockMessenger {
        fn new() -> MockMessenger {
            MockMessenger {
                sent_messages: RefCell::new(vec![]),
            }
        }
    }

    impl Messenger for MockMessenger {
        fn send(&self, message: &str) {
            let mut one_borrow = self.sent_messages.borrow_mut();
            let mut two_borrow = self.sent_messages.borrow_mut();

            one_borrow.push(String::from(message));
            two_borrow.push(String::from(message));
        }
    }

    #[test]
    fn it_sends_an_over_75_percent_warning_message() {
        let mock_messenger = MockMessenger::new();
        let mut limit_tracker = LimitTracker::new(&mock_messenger, 100);

        limit_tracker.set_value(80);

        assert_eq!(mock_messenger.sent_messages.borrow().len(), 1);
    }
}
Listing 15-23: 같은 스코프에서 두 개의 가변 참조를 생성해 RefCell<T>가 런타임에 패닉을 일으키는 것을 확인

borrow_mut에서 반환된 RefMut<T> 스마트 포인터를 one_borrow 변수에 저장한다. 그런 다음 같은 방식으로 two_borrow 변수에 또 다른 가변 대여를 생성한다. 이렇게 하면 같은 스코프에서 두 개의 가변 참조가 생기는데, 이는 허용되지 않는다. 라이브러리의 테스트를 실행하면 리스트 15-23의 코드는 컴파일 에러 없이 빌드되지만, 테스트는 실패한다:

$ cargo test
   Compiling limit-tracker v0.1.0 (file:///projects/limit-tracker)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.91s
     Running unittests src/lib.rs (target/debug/deps/limit_tracker-e599811fa246dbde)

running 1 test
test tests::it_sends_an_over_75_percent_warning_message ... FAILED

failures:

---- tests::it_sends_an_over_75_percent_warning_message stdout ----

thread 'tests::it_sends_an_over_75_percent_warning_message' panicked at src/lib.rs:60:53:
already borrowed: BorrowMutError
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::it_sends_an_over_75_percent_warning_message

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass `--lib`

코드가 already borrowed: BorrowMutError 메시지와 함께 패닉을 일으킨 것을 확인할 수 있다. 이는 RefCell<T>가 런타임에 대여 규칙 위반을 처리하는 방식이다.

컴파일 타임이 아닌 런타임에 대여 에러를 잡는 방식을 선택하면, 개발 과정에서 코드의 실수를 더 늦게 발견할 가능성이 있다. 심지어 프로덕션 환경에 배포된 후에야 발견될 수도 있다. 또한, 컴파일 타임이 아닌 런타임에 대여 상태를 추적하기 때문에 코드에 약간의 런타임 성능 저하가 발생한다. 하지만 RefCell<T>를 사용하면 불변 값만 허용되는 컨텍스트에서도 자신을 수정해 메시지를 추적할 수 있는 목 객체를 작성할 수 있다. RefCell<T>는 이러한 트레이드오프가 있음에도 불구하고 일반 참조보다 더 많은 기능을 제공한다.

Rc<T>RefCell<T>를 사용해 가변 데이터에 여러 소유자 허용하기

RefCell<T>를 사용하는 일반적인 방법은 Rc<T>와 결합하는 것이다. Rc<T>는 데이터에 여러 소유자를 허용하지만, 데이터에 대해 불변 접근만 제공한다. 만약 Rc<T>RefCell<T>를 가지고 있다면, 여러 소유자를 가질 수 있고 동시에 데이터를 변경할 수 있는 값을 얻을 수 있다.

예를 들어, 15장 18번 예제에서 Rc<T>를 사용해 여러 리스트가 다른 리스트의 소유권을 공유할 수 있게 한 cons 리스트 예제를 떠올려보자. Rc<T>는 불변 값만 보유하므로, 리스트를 생성한 후에는 리스트 내의 값을 변경할 수 없다. 이제 RefCell<T>를 추가해 리스트 내의 값을 변경할 수 있게 해보자. 15장 24번 예제는 Cons 정의에서 RefCell<T>를 사용해 모든 리스트에 저장된 값을 수정할 수 있음을 보여준다.

Filename: src/main.rs
#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Rc::new(Cons(Rc::clone(&value), Rc::new(Nil)));

    let b = Cons(Rc::new(RefCell::new(3)), Rc::clone(&a));
    let c = Cons(Rc::new(RefCell::new(4)), Rc::clone(&a));

    *value.borrow_mut() += 10;

    println!("a after = {a:?}");
    println!("b after = {b:?}");
    println!("c after = {c:?}");
}
Listing 15-24: Rc<RefCell<i32>>를 사용해 수정 가능한 List 생성

Rc<RefCell<i32>>의 인스턴스인 값을 생성하고, 이를 value라는 변수에 저장해 나중에 직접 접근할 수 있게 한다. 그런 다음 Cons 변형을 사용해 value를 보유하는 Lista에 생성한다. value를 복제해 avalue가 내부의 5 값을 공유하도록 해야 한다. 이렇게 하면 value에서 a로 소유권이 이전되거나 avalue에서 빌리는 일이 없어진다.

리스트 aRc<T>로 감싸서 리스트 bc를 생성할 때 둘 다 a를 참조할 수 있게 한다. 이는 15장 18번 예제에서 했던 것과 동일하다.

리스트 a, b, c를 생성한 후, value의 값에 10을 더하고 싶다. 이를 위해 valueborrow_mut를 호출한다. 이 메서드는 5장에서 논의한 자동 역참조 기능을 사용해 Rc<T>를 내부의 RefCell<T> 값으로 역참조한다. borrow_mut 메서드는 RefMut<T> 스마트 포인터를 반환하고, 이를 역참조 연산자와 함께 사용해 내부 값을 변경한다.

a, b, c를 출력하면 모두 5가 아닌 15로 수정된 값을 가지고 있음을 확인할 수 있다:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
     Running `target/debug/cons-list`
a after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 3 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 4 }, Cons(RefCell { value: 15 }, Nil))

이 기법은 상당히 유용하다! RefCell<T>를 사용해 외부적으로는 불변인 List 값을 가지면서도, 내부 가변성을 제공하는 RefCell<T>의 메서드를 사용해 필요할 때 데이터를 수정할 수 있다. 빌림 규칙에 대한 런타임 검사는 데이터 경쟁으로부터 보호해주며, 데이터 구조에서 이 유연성을 얻기 위해 약간의 속도를 희생하는 것도 가치가 있다. 다만 RefCell<T>는 멀티스레드 코드에서는 작동하지 않는다는 점을 주의해야 한다. Mutex<T>RefCell<T>의 스레드 안전 버전이며, 16장에서 Mutex<T>에 대해 논의할 것이다.

참조 순환으로 인한 메모리 누수

Rust의 메모리 안전성 보장은 실수로 정리되지 않는 메모리(일명 메모리 누수)를 생성하는 것을 어렵게 만들지만, 불가능하게 만들지는 않는다. Rust는 메모리 누수를 완전히 방지하는 것을 보장하지 않는다. 즉, 메모리 누수는 Rust에서 메모리 안전하다. Rc<T>RefCell<T>를 사용하면 Rust가 메모리 누수를 허용한다는 것을 확인할 수 있다. 아이템들이 서로 순환 참조를 하는 참조를 생성할 수 있다. 이렇게 되면 순환에 있는 각 아이템의 참조 카운트가 0에 도달하지 않아 값이 삭제되지 않으므로 메모리 누수가 발생한다.

참조 순환 생성

참조 순환이 어떻게 발생하는지, 그리고 이를 방지하는 방법을 살펴보자. 먼저 List 열거형과 tail 메서드를 정의한 코드를 Listing 15-25에서 확인할 수 있다.

Filename: src/main.rs
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {}
Listing 15-25: Cons 변형이 참조하는 대상을 수정할 수 있도록 RefCell<T>를 포함한 cons 리스트 정의

Listing 15-5에서 정의한 List의 변형을 사용한다. Cons 변형의 두 번째 요소는 이제 RefCell<Rc<List>>로, Listing 15-24에서 i32 값을 수정할 수 있었던 것과 달리 Cons 변형이 가리키는 List 값을 수정할 수 있다. 또한 Cons 변형이 있을 때 두 번째 항목에 쉽게 접근할 수 있도록 tail 메서드를 추가했다.

Listing 15-26에서는 Listing 15-25의 정의를 사용하는 main 함수를 추가한다. 이 코드는 a에 리스트를 생성하고, a의 리스트를 가리키는 b 리스트를 생성한다. 그런 다음 a의 리스트가 b를 가리키도록 수정하여 참조 순환을 만든다. 이 과정에서 참조 카운트를 확인할 수 있도록 println! 문을 추가했다.

Filename: src/main.rs
use crate::List::{Cons, Nil};
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match self {
            Cons(_, item) => Some(item),
            Nil => None,
        }
    }
}

fn main() {
    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(Rc::clone(&a))));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(link) = a.tail() {
        *link.borrow_mut() = Rc::clone(&b);
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle;
    // it will overflow the stack.
    // println!("a next item = {:?}", a.tail());
}
Listing 15-26: 두 List 값이 서로를 가리키는 참조 순환 생성

a 변수에 5, Nil로 초기화된 List 값을 담은 Rc<List> 인스턴스를 생성한다. 그런 다음 10을 포함하고 a의 리스트를 가리키는 List 값을 담은 Rc<List> 인스턴스를 b 변수에 생성한다.

aNil 대신 b를 가리키도록 수정하여 순환을 만든다. 이를 위해 tail 메서드를 사용해 aRefCell<Rc<List>>에 대한 참조를 얻고, 이를 link 변수에 저장한다. 그런 다음 RefCell<Rc<List>>borrow_mut 메서드를 사용해 Nil 값을 담은 Rc<List>에서 bRc<List>로 값을 변경한다.

이 코드를 실행하면 (마지막 println!을 주석 처리한 상태에서) 다음과 같은 출력을 얻는다:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.53s
     Running `target/debug/cons-list`
a initial rc count = 1
a next item = Some(RefCell { value: Nil })
a rc count after b creation = 2
b initial rc count = 1
b next item = Some(RefCell { value: Cons(5, RefCell { value: Nil }) })
b rc count after changing a = 2
a rc count after changing a = 2

a의 리스트가 b를 가리키도록 변경한 후, abRc<List> 인스턴스의 참조 카운트는 모두 2가 된다. main 함수의 끝에서 Rust는 b 변수를 드롭하며, bRc<List> 인스턴스의 참조 카운트를 2에서 1로 감소시킨다. 이 시점에서 Rc<List>가 힙에 가진 메모리는 드롭되지 않는다. 참조 카운트가 1이기 때문이다. 그런 다음 Rust는 a를 드롭하며, aRc<List> 인스턴스의 참조 카운트도 2에서 1로 감소시킨다. 이 인스턴스의 메모리도 드롭되지 않는다. 다른 Rc<List> 인스턴스가 여전히 이를 참조하고 있기 때문이다. 리스트에 할당된 메모리는 영원히 회수되지 않는다. 이 참조 순환을 시각적으로 표현하기 위해 Figure 15-4의 다이어그램을 만들었다.

Reference cycle of lists

Figure 15-4: 서로를 가리키는 리스트 ab의 참조 순환

마지막 println!의 주석을 해제하고 프로그램을 실행하면, Rust는 ab를 가리키고 ba를 가리키는 순환을 출력하려고 시도하다가 스택 오버플로가 발생한다.

실제 프로그램과 비교했을 때, 이 예제에서 참조 순환을 생성한 결과는 그리 심각하지 않다: 참조 순환을 생성한 직후 프로그램이 종료되기 때문이다. 하지만 더 복잡한 프로그램에서 순환을 통해 많은 메모리를 할당하고 오랫동안 유지한다면, 프로그램은 필요 이상의 메모리를 사용하게 되고 시스템에 과부하를 줄 수 있다. 이로 인해 사용 가능한 메모리가 부족해질 수 있다.

참조 순환을 만드는 것은 쉽지 않지만 불가능한 것도 아니다. Rc<T> 값을 포함하는 RefCell<T> 값이나 내부 가변성과 참조 카운팅을 가진 유사한 중첩 타입 조합을 사용한다면, 순환을 생성하지 않도록 주의해야 한다. Rust가 이를 알아채주길 기대할 수 없다. 참조 순환을 생성하는 것은 프로그램의 논리적 버그로, 자동화된 테스트, 코드 리뷰, 기타 소프트웨어 개발 관행을 통해 최소화해야 한다.

참조 순환을 피하는 또 다른 해결책은 데이터 구조를 재구성하여 일부 참조는 소유권을 표현하고 일부 참조는 소유권을 표현하지 않도록 하는 것이다. 그 결과, 소유 관계와 비소유 관계로 이루어진 순환을 만들 수 있으며, 오직 소유 관계만이 값이 드롭될 수 있는지 여부에 영향을 미친다. Listing 15-25에서는 항상 Cons 변형이 자신의 리스트를 소유하길 원하므로 데이터 구조를 재구성할 수 없다. 부모 노드와 자식 노드로 구성된 그래프를 사용해 비소유 관계가 참조 순환을 방지하는 적절한 방법이 되는 예제를 살펴보자.

Weak<T>를 사용해 참조 순환 방지하기

지금까지 Rc::clone을 호출하면 Rc<T> 인스턴스의 strong_count가 증가하고, strong_count가 0이 되어야만 Rc<T> 인스턴스가 정리된다는 것을 확인했다. 또한 Rc<T> 인스턴스에 대한 참조를 Rc::downgrade에 전달하여 값에 대한 약한 참조를 만들 수 있다. 강한 참조는 Rc<T> 인스턴스의 소유권을 공유하는 방법이다. 약한 참조는 소유권 관계를 표현하지 않으며, 그 수가 Rc<T> 인스턴스의 정리 시점에 영향을 미치지 않는다. 약한 참조가 포함된 순환 구조는 강한 참조 카운트가 0이 되면 깨지기 때문에 참조 순환을 일으키지 않는다.

Rc::downgrade를 호출하면 Weak<T> 타입의 스마트 포인터를 얻는다. Rc::downgrade를 호출하면 Rc<T> 인스턴스의 strong_count가 1 증가하는 대신 weak_count가 1 증가한다. Rc<T> 타입은 strong_count와 유사하게 weak_count를 사용해 Weak<T> 참조의 수를 추적한다. 차이점은 Rc<T> 인스턴스가 정리되기 위해 weak_count가 0이 될 필요가 없다는 것이다.

Weak<T>가 참조하는 값은 이미 제거되었을 수 있기 때문에, Weak<T>가 가리키는 값으로 무언가를 하려면 값이 여전히 존재하는지 확인해야 한다. 이를 위해 Weak<T> 인스턴스에서 upgrade 메서드를 호출하면 Option<Rc<T>>를 반환한다. Rc<T> 값이 아직 제거되지 않았다면 Some 결과를 얻고, Rc<T> 값이 제거되었다면 None 결과를 얻는다. upgradeOption<Rc<T>>를 반환하기 때문에 Rust는 SomeNone 경우를 모두 처리하도록 보장하며, 유효하지 않은 포인터가 발생하지 않는다.

예를 들어, 각 항목이 다음 항목만 알고 있는 리스트를 사용하는 대신, 각 항목이 자식 항목과 부모 항목을 모두 알고 있는 트리를 만들어 보자.

트리 데이터 구조 만들기: 자식 노드를 가진 Node

먼저, 자식 노드에 대한 정보를 알고 있는 노드로 트리를 만들어 보자. Node라는 구조체를 생성하고, 이 구조체는 i32 타입의 값을 가지며 자식 Node에 대한 참조도 포함한다.

파일명: src/main.rs

use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}

Node가 자식 노드를 소유하도록 하고, 이 소유권을 변수와 공유해 트리의 각 Node에 직접 접근할 수 있게 하려 한다. 이를 위해 Vec<T>의 항목을 Rc<Node> 타입의 값으로 정의한다. 또한 어떤 노드가 다른 노드의 자식인지 수정할 수 있도록 childrenVec<Rc<Node>>를 감싸는 RefCell<T>를 사용한다.

다음으로, 이 구조체 정의를 사용해 leaf라는 이름의 Node 인스턴스를 생성한다. 이 인스턴스는 값 3을 가지며 자식 노드가 없다. 또 다른 인스턴스인 branch는 값 5를 가지고 leaf를 자식 노드 중 하나로 포함한다. 이는 Listing 15-27에 나와 있다.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::Rc;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });
}
Listing 15-27: 자식 노드가 없는 leaf 노드와 leaf를 자식 노드로 가지는 branch 노드 생성

leafRc<Node>를 복제해 branch에 저장한다. 이제 leafNodeleafbranch 두 소유자를 가지게 된다. branch에서 branch.children을 통해 leaf로 접근할 수 있지만, leaf에서 branch로 접근할 방법은 없다. 그 이유는 leafbranch에 대한 참조를 가지고 있지 않으며, 둘이 관련되어 있다는 것을 모르기 때문이다. 다음으로 leafbranch가 부모 노드임을 알 수 있도록 해 보자.

자식 노드에서 부모 노드로의 참조 추가하기

자식 노드가 부모 노드를 인식할 수 있도록 하려면 Node 구조체 정의에 parent 필드를 추가해야 한다. 여기서 문제는 parent의 타입을 어떻게 정할지 결정하는 것이다. Rc<T>를 사용할 수는 없다. 왜냐하면 leaf.parentbranch를 가리키고 branch.childrenleaf를 가리키는 참조 순환을 만들기 때문이다. 이렇게 되면 strong_count 값이 절대 0이 되지 않는다.

관계를 다른 방식으로 생각해보면, 부모 노드는 자식 노드를 소유해야 한다. 즉, 부모 노드가 제거되면 자식 노드도 함께 제거되어야 한다. 그러나 자식 노드는 부모 노드를 소유해서는 안 된다. 자식 노드를 제거하더라도 부모 노드는 여전히 존재해야 한다. 이 경우 약한 참조(weak reference)를 사용해야 한다!

따라서 Rc<T> 대신 parent의 타입으로 Weak<T>, 구체적으로는 RefCell<Weak<Node>>를 사용한다. 이제 Node 구조체 정의는 다음과 같다:

파일명: src/main.rs

use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

이제 노드는 부모 노드를 참조할 수 있지만, 부모 노드를 소유하지는 않는다. Listing 15-28에서 main 함수를 업데이트하여 leaf 노드가 부모 노드인 branch를 참조할 수 있도록 한다.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![Rc::clone(&leaf)]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}
Listing 15-28: 부모 노드 branch에 대한 약한 참조를 가진 leaf 노드

leaf 노드를 생성하는 부분은 Listing 15-27과 비슷하지만, parent 필드가 추가되었다. leaf는 처음에 부모가 없으므로 새로운 빈 Weak<Node> 참조 인스턴스를 생성한다.

이 시점에서 leaf의 부모에 대한 참조를 얻기 위해 upgrade 메서드를 사용하면 None 값을 반환한다. 첫 번째 println! 문의 출력에서 이를 확인할 수 있다:

leaf parent = None

branch 노드를 생성할 때도 parent 필드에 새로운 Weak<Node> 참조가 추가된다. 왜냐하면 branch는 부모 노드가 없기 때문이다. 여전히 branch의 자식 중 하나로 leaf가 있다. branchNode 인스턴스를 생성한 후, leaf를 수정하여 부모에 대한 Weak<Node> 참조를 추가한다. leafparent 필드에 있는 RefCell<Weak<Node>>borrow_mut 메서드를 사용하고, branchRc<Node>에서 Rc::downgrade 함수를 사용해 branch에 대한 Weak<Node> 참조를 생성한다.

이제 leaf의 부모를 다시 출력하면, 이번에는 branch를 담고 있는 Some 변형이 반환된다. 이제 leaf는 부모에 접근할 수 있다! leaf를 출력할 때, Listing 15-26에서 발생했던 스택 오버플로우로 이어지는 순환을 피할 수 있다. Weak<Node> 참조는 (Weak)로 출력된다:

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

무한 출력이 발생하지 않는다는 것은 이 코드가 참조 순환을 만들지 않았음을 의미한다. 또한 Rc::strong_countRc::weak_count를 호출해 반환된 값을 확인함으로써 이를 확인할 수 있다.

strong_countweak_count 변화 시각화

Rc<Node> 인스턴스의 strong_countweak_count 값이 어떻게 변하는지 확인하기 위해, 새로운 내부 스코프를 만들고 branch 생성 부분을 그 안으로 옮겨보자. 이렇게 하면 branch가 생성된 후 스코프를 벗어나면서 삭제될 때 어떤 일이 발생하는지 관찰할 수 있다. 이 변경 사항은 Listing 15-29에 나와 있다.

Filename: src/main.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![Rc::clone(&leaf)]),
        });

        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}
Listing 15-29: 내부 스코프에서 branch를 생성하고 강한 참조와 약한 참조 카운트 확인

leaf가 생성된 후, Rc<Node>strong_count가 1이고 weak_count가 0이다. 내부 스코프에서 branch를 생성하고 leaf와 연결하면, 카운트를 출력할 때 branchRc<Node>strong_count가 1이고 weak_count가 1이 된다(leaf.parentWeak<Node>를 사용해 branch를 가리키기 때문). leaf의 카운트를 출력하면 strong_count가 2인 것을 확인할 수 있다. 이는 branchbranch.childrenleafRc<Node> 복사본을 저장했기 때문이다. 하지만 weak_count는 여전히 0이다.

내부 스코프가 끝나면 branch가 스코프를 벗어나고, Rc<Node>strong_count가 0으로 감소한다. 이로 인해 Node가 삭제된다. leaf.parentweak_count가 1이더라도 Node가 삭제되는 데는 영향을 미치지 않으므로 메모리 누수가 발생하지 않는다.

스코프가 끝난 후 leaf의 부모에 접근하려고 하면 다시 None을 얻게 된다. 프로그램이 끝날 때 leafRc<Node>strong_count가 1이고 weak_count가 0이다. 이는 leaf 변수가 다시 Rc<Node>의 유일한 참조이기 때문이다.

카운트 관리와 값 삭제를 위한 모든 로직은 Rc<T>Weak<T> 그리고 Drop 트레이트의 구현에 내장되어 있다. Node 정의에서 자식에서 부모로의 관계를 Weak<T> 참조로 지정함으로써, 부모 노드가 자식 노드를 가리키고 그 반대도 가능하게 하면서도 참조 순환과 메모리 누수를 방지할 수 있다.

요약

이 장에서는 스마트 포인터를 사용해 일반 참조와는 다른 보장과 트레이드오프를 만드는 방법을 다뤘다. Box<T> 타입은 크기가 정해져 있으며 힙에 할당된 데이터를 가리킨다. Rc<T> 타입은 힙에 있는 데이터에 대한 참조 개수를 추적해 데이터가 여러 소유자를 가질 수 있게 한다. RefCell<T> 타입은 내부 가변성을 제공해 불변 타입이 필요하지만 그 타입의 내부 값을 변경해야 할 때 사용할 수 있다. 또한 빌림 규칙을 컴파일 타임이 아닌 런타임에 강제한다.

스마트 포인터의 많은 기능을 가능하게 하는 DerefDrop 트레이트도 살펴봤다. 메모리 누수를 일으킬 수 있는 참조 순환과 이를 Weak<T>를 사용해 방지하는 방법도 탐구했다.

이 장이 흥미를 끌어 직접 스마트 포인터를 구현해 보고 싶다면 “The Rustonomicon”을 참고해 더 유용한 정보를 얻을 수 있다.

다음 장에서는 Rust의 동시성에 대해 이야기할 것이다. 몇 가지 새로운 스마트 포인터도 배울 예정이다.

두려움 없는 동시성

동시성 프로그래밍을 안전하고 효율적으로 처리하는 것은 Rust의 주요 목표 중 하나다. 동시성 프로그래밍은 프로그램의 여러 부분이 독립적으로 실행되는 것을 의미하며, 병렬 프로그래밍은 프로그램의 여러 부분이 동시에 실행되는 것을 의미한다. 이 두 개념은 컴퓨터가 멀티 프로세서의 이점을 활용하면서 점점 더 중요해지고 있다. 역사적으로, 이러한 프로그래밍 방식은 어렵고 오류가 발생하기 쉬웠다. Rust는 이를 바꾸고자 한다.

처음에 Rust 팀은 메모리 안전성을 보장하는 것과 동시성 문제를 해결하는 것이 서로 다른 방법으로 해결해야 하는 두 가지 과제라고 생각했다. 그러나 시간이 지나면서, 팀은 소유권과 타입 시스템이 메모리 안전성과 동시성 문제를 모두 관리하는 강력한 도구라는 사실을 발견했다. 소유권과 타입 검사를 활용함으로써, 많은 동시성 오류가 런타임 오류가 아니라 컴파일 타임 오류로 발생한다. 따라서 런타임 동시성 버그가 발생하는 정확한 상황을 재현하기 위해 많은 시간을 들이지 않고도, 잘못된 코드는 컴파일을 거부하고 문제를 설명하는 오류를 표시한다. 결과적으로, 코드를 프로덕션에 배포하기 전에 작업 중에 문제를 해결할 수 있다. Rust의 이러한 측면을 두려움 없는 동시성이라고 부른다. 두려움 없는 동시성은 미묘한 버그 없이 코드를 작성할 수 있게 해주며, 새로운 버그를 도입하지 않고도 리팩토링하기 쉽게 만든다.

참고: 간단히 설명하기 위해, 많은 문제를 동시성이라고만 언급할 것이다. 하지만 이 장에서는 동시성 및/또는 병렬성이라는 표현을 염두에 두고 읽어주길 바란다. 다음 장에서는 이 둘의 차이를 더 구체적으로 다룰 것이다.

많은 언어들은 동시성 문제를 해결하기 위해 특정한 해결책만 제공한다. 예를 들어, Erlang은 메시지 전달 동시성을 위한 우아한 기능을 제공하지만, 스레드 간 상태를 공유하는 방법은 명확하지 않다. 가능한 해결책의 일부만 지원하는 것은 고수준 언어에게는 합리적인 전략이다. 왜냐하면 고수준 언어는 일부 제어를 포기함으로써 추상화의 이점을 얻기 때문이다. 그러나 저수준 언어는 주어진 상황에서 최고의 성능을 제공하는 해결책을 제공해야 하며, 하드웨어에 대한 추상화가 적다. 따라서 Rust는 상황과 요구 사항에 맞는 다양한 도구를 제공한다.

이 장에서 다룰 주제는 다음과 같다:

  • 여러 코드 조각을 동시에 실행하기 위해 스레드를 생성하는 방법
  • 메시지 전달 동시성: 채널을 통해 스레드 간 메시지를 전송하는 방법
  • 공유 상태 동시성: 여러 스레드가 어떤 데이터에 접근할 수 있는 방법
  • SyncSend 트레이트: Rust의 동시성 보장을 사용자 정의 타입과 표준 라이브러리 제공 타입에까지 확장하는 방법

스레드를 사용해 코드를 동시에 실행하기

현대 운영체제에서 실행되는 프로그램의 코드는 프로세스 내에서 동작하며, 운영체제는 여러 프로세스를 동시에 관리한다. 프로그램 내부에서도 독립적으로 동시에 실행되는 부분을 만들 수 있다. 이런 독립적인 부분을 실행하는 기능을 스레드라고 한다. 예를 들어, 웹 서버는 여러 스레드를 사용해 동시에 여러 요청에 응답할 수 있다.

프로그램의 계산 작업을 여러 스레드로 나누어 동시에 실행하면 성능을 향상시킬 수 있지만, 복잡성도 증가한다. 스레드가 동시에 실행되기 때문에, 서로 다른 스레드에서 코드가 어떤 순서로 실행될지 보장할 수 없다. 이로 인해 다음과 같은 문제가 발생할 수 있다:

  • 경쟁 상태(Race conditions): 스레드가 데이터나 리소스에 일관성 없는 순서로 접근하는 경우
  • 데드락(Deadlocks): 두 스레드가 서로를 기다리며 더 이상 진행되지 못하는 경우
  • 특정 상황에서만 발생하는 버그: 재현하기 어렵고 안정적으로 수정하기 힘든 버그

Rust는 스레드 사용으로 인한 부정적인 영향을 줄이려고 노력하지만, 멀티스레드 환경에서 프로그래밍하려면 신중하게 생각해야 하며, 단일 스레드 프로그램과는 다른 코드 구조가 필요하다.

프로그래밍 언어는 다양한 방식으로 스레드를 구현하며, 많은 운영체제는 새로운 스레드를 생성하기 위한 API를 제공한다. Rust 표준 라이브러리는 1:1 스레드 구현 모델을 사용한다. 이 모델에서는 하나의 언어 스레드가 하나의 운영체제 스레드를 사용한다. 1:1 모델과는 다른 트레이드오프를 가지는 스레딩 모델을 구현하는 크레이트도 존재한다. (Rust의 비동기 시스템은 다음 장에서 다룰 예정이며, 이는 동시성을 다루는 또 다른 접근 방식을 제공한다.)

spawn을 사용해 새 스레드 생성하기

새 스레드를 생성하려면 thread::spawn 함수를 호출하고, 새 스레드에서 실행할 코드를 담은 클로저를 인자로 전달한다. (클로저는 13장에서 다뤘다.) 리스트 16-1의 예제는 메인 스레드와 새로 생성된 스레드에서 각각 다른 텍스트를 출력한다:

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}
Listing 16-1: 메인 스레드와 새 스레드에서 각각 다른 내용을 출력하는 예제

Rust 프로그램의 메인 스레드가 종료되면, 모든 생성된 스레드는 실행이 완료되었는지 여부와 상관없이 강제로 종료된다. 이 프로그램의 출력은 실행할 때마다 조금씩 다를 수 있지만, 대체로 다음과 비슷한 형태가 된다:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

thread::sleep 호출은 스레드의 실행을 잠시 멈추게 해서 다른 스레드가 실행될 기회를 제공한다. 스레드가 번갈아가며 실행될 가능성이 높지만, 이는 보장된 동작이 아니다. 스레드의 실행 순서는 운영체제의 스케줄링 방식에 따라 달라진다. 이번 실행에서는 코드상에서 생성된 스레드의 출력문이 먼저 위치했음에도 불구하고, 메인 스레드가 먼저 출력을 시작했다. 또한 생성된 스레드는 i9가 될 때까지 출력하도록 설정했지만, 메인 스레드가 종료되기 전에 5까지만 출력했다.

이 코드를 실행했을 때 메인 스레드의 출력만 보이거나, 두 스레드의 출력이 겹치지 않는다면, 범위의 숫자를 늘려서 운영체제가 스레드를 전환할 기회를 더 많이 만들어 보자.

join 핸들을 사용해 모든 스레드가 완료될 때까지 기다리기

리스트 16-1의 코드는 대부분의 경우 메인 스레드가 종료되면서 생성된 스레드가 중간에 멈추는 문제가 있다. 또한 스레드가 실행되는 순서가 보장되지 않기 때문에, 생성된 스레드가 실행될지조차 확신할 수 없다.

이 문제를 해결하기 위해 thread::spawn의 반환 값을 변수에 저장할 수 있다. thread::spawn의 반환 타입은 JoinHandle<T>다. JoinHandle<T>는 소유된 값으로, 여기에 join 메서드를 호출하면 해당 스레드가 완료될 때까지 기다린다. 리스트 16-2는 리스트 16-1에서 생성한 스레드의 JoinHandle<T>를 사용하는 방법과 join을 호출해 생성된 스레드가 main이 종료되기 전에 완료되도록 보장하는 방법을 보여준다.

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}
Listing 16-2: thread::spawn에서 반환된 JoinHandle<T>를 저장해 스레드가 완료될 때까지 기다리기

핸들에 join을 호출하면 현재 실행 중인 스레드는 해당 핸들이 나타내는 스레드가 종료될 때까지 블로킹된다. 스레드를 블로킹한다는 것은 해당 스레드가 작업을 수행하거나 종료하지 못하게 하는 것을 의미한다. join 호출을 메인 스레드의 for 루프 뒤에 배치했기 때문에, 리스트 16-2를 실행하면 다음과 비슷한 출력을 볼 수 있다:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

두 스레드는 계속 번갈아가며 실행되지만, handle.join() 호출로 인해 메인 스레드는 생성된 스레드가 완료될 때까지 기다린 후 종료된다.

이제 handle.join()mainfor 루프 앞으로 옮기면 어떤 일이 발생하는지 살펴보자:

Filename: src/main.rs
use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {i} from the spawned thread!");
            thread::sleep(Duration::from_millis(1));
        }
    });

    handle.join().unwrap();

    for i in 1..5 {
        println!("hi number {i} from the main thread!");
        thread::sleep(Duration::from_millis(1));
    }
}

메인 스레드는 생성된 스레드가 완료될 때까지 기다린 후 for 루프를 실행하므로, 출력은 더 이상 섞이지 않는다:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

join을 어디서 호출하는지와 같은 작은 세부 사항도 스레드가 동시에 실행되는지 여부에 영향을 미칠 수 있다.

move 클로저와 스레드 함께 사용하기

thread::spawn에 전달하는 클로저와 함께 move 키워드를 자주 사용한다. 이는 클로저가 환경에서 사용하는 값의 소유권을 가져가기 때문이다. 따라서 해당 값의 소유권이 한 스레드에서 다른 스레드로 이전된다. 13장의 “클로저로 환경 캡처하기”에서 클로저의 맥락에서 move에 대해 논의했다. 이제는 movethread::spawn 간의 상호작용에 더 집중할 것이다.

목록 16-1에서 thread::spawn에 전달하는 클로저는 인수를 받지 않는다. 생성된 스레드의 코드에서 메인 스레드의 데이터를 사용하지 않기 때문이다. 생성된 스레드에서 메인 스레드의 데이터를 사용하려면, 해당 스레드의 클로저가 필요한 값을 캡처해야 한다. 목록 16-3은 메인 스레드에서 벡터를 생성하고 이를 생성된 스레드에서 사용하려는 시도를 보여준다. 그러나 이 코드는 아직 작동하지 않는다.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-3: 메인 스레드에서 생성한 벡터를 다른 스레드에서 사용하려는 시도

클로저는 v를 사용하므로 v를 캡처해 클로저 환경의 일부로 만든다. thread::spawn은 이 클로저를 새로운 스레드에서 실행하므로, 새로운 스레드 내부에서 v에 접근할 수 있어야 한다. 그러나 이 예제를 컴파일하면 다음과 같은 에러가 발생한다.

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0373]: closure may outlive the current function, but it borrows `v`, which is owned by the current function
 --> src/main.rs:6:32
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {v:?}");
  |                                     - `v` is borrowed here
  |
note: function requires argument type to outlive `'static`
 --> src/main.rs:6:18
  |
6 |       let handle = thread::spawn(|| {
  |  __________________^
7 | |         println!("Here's a vector: {v:?}");
8 | |     });
  | |______^
help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

For more information about this error, try `rustc --explain E0373`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust는 v를 어떻게 캡처할지 추론하며, println!v에 대한 참조만 필요로 하기 때문에 클로저는 v를 빌리려고 시도한다. 그러나 문제가 있다. Rust는 생성된 스레드가 얼마나 오래 실행될지 알 수 없기 때문에 v에 대한 참조가 항상 유효한지 알 수 없다.

목록 16-4는 v에 대한 참조가 유효하지 않을 가능성이 더 높은 시나리오를 보여준다.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {v:?}");
    });

    drop(v); // oh no!

    handle.join().unwrap();
}
Listing 16-4: 메인 스레드에서 v를 드롭한 후 v에 대한 참조를 캡처하려는 클로저가 있는 스레드

Rust가 이 코드를 실행하도록 허용한다면, 생성된 스레드가 백그라운드로 즉시 이동되어 전혀 실행되지 않을 가능성이 있다. 생성된 스레드는 내부에 v에 대한 참조를 가지고 있지만, 메인 스레드는 15장에서 논의한 drop 함수를 사용해 즉시 v를 드롭한다. 그러면 생성된 스레드가 실행을 시작할 때 v는 더 이상 유효하지 않으므로, 그 참조도 무효가 된다. 큰 문제다!

목록 16-3의 컴파일 에러를 수정하기 위해 에러 메시지의 조언을 따를 수 있다.

help: to force the closure to take ownership of `v` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = thread::spawn(move || {
  |                                ++++

클로저 앞에 move 키워드를 추가함으로써, 클로저가 사용하는 값의 소유권을 가져가도록 강제한다. Rust가 값을 빌리도록 추론하는 대신, 값을 소유하도록 만든다. 목록 16-5는 목록 16-3을 수정한 것으로, 의도한 대로 컴파일되고 실행된다.

Filename: src/main.rs
use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {v:?}");
    });

    handle.join().unwrap();
}
Listing 16-5: move 키워드를 사용해 클로저가 사용하는 값의 소유권을 강제로 가져가도록 함

목록 16-4의 코드를 수정하기 위해 move 클로저를 사용해 같은 방법을 시도하고 싶을 수 있다. 그러나 이 수정은 작동하지 않는다. 목록 16-4가 시도하는 작업은 다른 이유로 허용되지 않기 때문이다. 클로저에 move를 추가하면 v를 클로저의 환경으로 이동시키고, 메인 스레드에서 더 이상 drop을 호출할 수 없게 된다. 대신 다음과 같은 컴파일 에러가 발생한다.

$ cargo run
   Compiling threads v0.1.0 (file:///projects/threads)
error[E0382]: use of moved value: `v`
  --> src/main.rs:10:10
   |
4  |     let v = vec![1, 2, 3];
   |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
5  |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved into closure here
7  |         println!("Here's a vector: {v:?}");
   |                                     - variable moved due to use in closure
...
10 |     drop(v); // oh no!
   |          ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `threads` (bin "threads") due to 1 previous error

Rust의 소유권 규칙이 다시 한번 우리를 구했다! 목록 16-3의 코드에서 에러가 발생한 이유는 Rust가 보수적으로 접근해 스레드에 대해 v를 빌려주기만 했기 때문이다. 이는 메인 스레드가 이론적으로 생성된 스레드의 참조를 무효화할 수 있다는 의미였다. Rust에게 v의 소유권을 생성된 스레드로 이동하라고 알림으로써, 메인 스레드가 더 이상 v를 사용하지 않을 것임을 보장한다. 목록 16-4를 같은 방식으로 변경하면, 메인 스레드에서 v를 사용하려고 할 때 소유권 규칙을 위반하게 된다. move 키워드는 Rust의 보수적인 기본값인 빌리기를 재정의한다. 하지만 소유권 규칙을 위반하도록 허용하지는 않는다.

이제 스레드가 무엇인지와 스레드 API가 제공하는 메서드를 살펴봤으니, 스레드를 사용할 수 있는 몇 가지 상황을 알아보자.

스레드 간 데이터 전송을 위한 메시지 패싱 활용

스레드 간 안전한 동시성을 보장하는 점점 더 인기 있는 접근 방식은 _메시지 패싱_이다. 이 방식에서는 스레드나 액터가 데이터를 포함한 메시지를 서로 주고받으며 통신한다. Go 언어 문서에 나온 슬로건으로 이 개념을 설명할 수 있다: “메모리를 공유하여 통신하지 말고, 통신을 통해 메모리를 공유하라.”

메시지 전송을 통해 동시성을 구현하기 위해 Rust의 표준 라이브러리는 채널(channel)을 제공한다. _채널_은 데이터를 한 스레드에서 다른 스레드로 전송하는 일반적인 프로그래밍 개념이다.

프로그래밍에서 채널은 강이나 하천과 같은 방향성이 있는 물줄기로 비유할 수 있다. 강에 고무 오리를 떨어뜨리면 물줄기를 따라 하류로 이동하는 것처럼, 채널도 데이터를 한쪽에서 다른쪽으로 전달한다.

채널은 두 부분으로 구성된다: 송신자(transmitter)와 수신자(receiver). 송신자는 강의 상류에 고무 오리를 떨어뜨리는 위치에 해당하고, 수신자는 고무 오리가 도착하는 하류에 해당한다. 코드의 한 부분은 전송할 데이터와 함께 송신자의 메서드를 호출하고, 다른 부분은 수신자에서 도착한 메시지를 확인한다. 송신자나 수신자 중 하나가 제거되면 채널은 닫힌 상태가 된다.

여기서는 한 스레드가 값을 생성해 채널로 보내고, 다른 스레드가 그 값을 받아 출력하는 프로그램을 만들어 볼 것이다. 채널을 사용해 스레드 간에 간단한 값을 전송하는 예제를 통해 이 기능을 설명한다. 이 기술에 익숙해지면, 채팅 시스템이나 여러 스레드가 계산의 일부를 수행한 후 결과를 하나의 스레드로 집계하는 시스템과 같이 서로 통신해야 하는 스레드에 채널을 활용할 수 있다.

먼저, 리스트 16-6에서 채널을 생성하지만 아무 작업도 하지 않는다. Rust가 채널을 통해 어떤 타입의 값을 전송할지 알 수 없기 때문에 이 코드는 아직 컴파일되지 않는다.

Filename: src/main.rs
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
}
Listing 16-6: 채널 생성 및 txrx에 두 부분 할당

mpsc::channel 함수를 사용해 새로운 채널을 생성한다. mpsc는 _multiple producer, single consumer_의 약자이다. Rust의 표준 라이브러리가 채널을 구현하는 방식은 여러 개의 송신 끝이 값을 생성할 수 있지만, 수신 끝은 하나만 있어 값을 소비한다는 것을 의미한다. 여러 개의 물줄기가 하나의 큰 강으로 합쳐지는 것을 상상해보라. 어떤 물줄기에든 떨어뜨린 것은 결국 하나의 강에 도달한다. 지금은 단일 송신자로 시작하지만, 이 예제가 동작하면 여러 송신자를 추가할 것이다.

mpsc::channel 함수는 튜플을 반환한다. 첫 번째 요소는 송신 끝인 송신자(transmitter)이고, 두 번째 요소는 수신 끝인 수신자(receiver)이다. txrx는 각각 _transmitter_와 _receiver_를 나타내는 전통적인 약어이므로, 각 끝을 나타내기 위해 변수명을 이렇게 짓는다. 튜플을 분해하는 패턴과 함께 let 문을 사용한다. let 문에서 패턴을 사용하는 방법과 튜플 분해에 대해서는 19장에서 다룰 것이다. 지금은 mpsc::channel이 반환한 튜플의 조각을 추출하는 편리한 방법으로 let 문을 사용한다는 것만 알아두자.

이제 송신자를 새로운 스레드로 옮겨 문자열 하나를 보내도록 하여, 생성된 스레드가 메인 스레드와 통신하도록 해보자. 리스트 16-7과 같다. 이는 강의 상류에 고무 오리를 떨어뜨리거나 한 스레드에서 다른 스레드로 채팅 메시지를 보내는 것과 같다.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}
Listing 16-7: tx를 생성된 스레드로 옮기고 "hi" 보내기

다시 thread::spawn을 사용해 새로운 스레드를 생성하고, move를 사용해 tx를 클로저로 이동시켜 생성된 스레드가 tx를 소유하도록 한다. 생성된 스레드는 채널을 통해 메시지를 보내기 위해 송신자를 소유해야 한다.

송신자는 전송할 값을 인수로 받는 send 메서드를 갖는다. send 메서드는 Result<T, E> 타입을 반환하므로, 수신자가 이미 제거되어 값을 보낼 곳이 없다면 전송 작업은 에러를 반환한다. 이 예제에서는 에러가 발생하면 패닉을 일으키기 위해 unwrap을 호출한다. 하지만 실제 애플리케이션에서는 적절히 처리할 것이다: 적절한 에러 처리 전략을 복습하려면 9장으로 돌아가보자.

리스트 16-8에서는 메인 스레드에서 수신자로부터 값을 가져온다. 이는 강의 끝에서 고무 오리를 꺼내거나 채팅 메시지를 받는 것과 같다.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}
Listing 16-8: 메인 스레드에서 "hi" 값을 받아 출력하기

수신자는 recvtry_recv라는 두 가지 유용한 메서드를 제공한다. 여기서는 _receive_의 약자인 recv를 사용한다. 이 메서드는 메인 스레드의 실행을 블로킹하고 채널로 값이 전송될 때까지 기다린다. 값이 전송되면 recvResult<T, E>로 값을 반환한다. 송신자가 닫히면 recv는 더 이상 값이 오지 않음을 알리는 에러를 반환한다.

try_recv 메서드는 블로킹하지 않고 즉시 Result<T, E>를 반환한다: 메시지가 있으면 Ok 값을, 이번에 메시지가 없으면 Err 값을 반환한다. 이 스레드가 메시지를 기다리는 동안 다른 작업을 해야 한다면 try_recv를 사용하는 것이 유용하다: 주기적으로 try_recv를 호출하는 루프를 작성하고, 메시지가 있으면 처리하고, 그렇지 않으면 잠시 다른 작업을 수행한 후 다시 확인할 수 있다.

이 예제에서는 간단히 recv를 사용했다. 메인 스레드가 메시지를 기다리는 것 외에 다른 작업을 할 필요가 없으므로 메인 스레드를 블로킹하는 것이 적절하다.

리스트 16-8의 코드를 실행하면 메인 스레드에서 값이 출력되는 것을 볼 수 있다:

Got: hi

완벽하다!

채널과 소유권 이전

소유권 규칙은 메시지 전송에서 중요한 역할을 한다. 이 규칙은 안전하고 동시성 있는 코드를 작성하는 데 도움을 준다. 동시성 프로그래밍에서 오류를 방지하는 것은 Rust 프로그램 전반에서 소유권을 고려하는 것의 장점이다. 채널과 소유권이 어떻게 함께 작동해 문제를 방지하는지 실험을 통해 확인해 보자. 채널로 값을 보낸 후, 스폰된 스레드에서 val 값을 사용하려고 시도할 것이다. 이 코드가 왜 허용되지 않는지 확인하기 위해 Listing 16-9의 코드를 컴파일해 보자.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {val}");
    });

    let received = rx.recv().unwrap();
    println!("Got: {received}");
}
Listing 16-9: 채널로 값을 보낸 후 val을 사용하려는 시도

여기서는 tx.send를 통해 채널로 값을 보낸 후 val을 출력하려고 한다. 이를 허용하는 것은 좋지 않은 아이디어다. 값이 다른 스레드로 전송된 후, 그 스레드가 값을 수정하거나 삭제할 수 있기 때문이다. 이로 인해 데이터가 일관되지 않거나 존재하지 않아 오류나 예상치 못한 결과가 발생할 수 있다. 그러나 Listing 16-9의 코드를 컴파일하려고 하면 Rust가 오류를 발생시킨다:

$ cargo run
   Compiling message-passing v0.1.0 (file:///projects/message-passing)
error[E0382]: borrow of moved value: `val`
  --> src/main.rs:10:26
   |
8  |         let val = String::from("hi");
   |             --- move occurs because `val` has type `String`, which does not implement the `Copy` trait
9  |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {val}");
   |                          ^^^^^ value borrowed here after move
   |
   = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `message-passing` (bin "message-passing") due to 1 previous error

동시성 실수로 인해 컴파일 타임 오류가 발생했다. send 함수는 파라미터의 소유권을 가져가고, 값이 이동되면 수신자가 그 소유권을 가진다. 이는 값을 보낸 후 실수로 다시 사용하는 것을 막아준다. 소유권 시스템이 모든 것이 올바른지 확인한다.

여러 값을 보내고 수신자가 기다리는 모습 관찰하기

리스트 16-8의 코드는 컴파일되고 실행되었지만, 두 개의 스레드가 채널을 통해 서로 통신하는 모습을 명확히 보여주지 못했다. 리스트 16-10에서는 리스트 16-8의 코드가 동시에 실행되고 있음을 증명하기 위해 몇 가지 수정을 가했다. 이제 생성된 스레드는 여러 메시지를 보내고 각 메시지 사이에 1초간 일시 정지한다.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }
}
Listing 16-10: 여러 메시지를 보내고 각 메시지 사이에 일시 정지하기

이번에는 생성된 스레드가 메인 스레드로 보낼 문자열 벡터를 가지고 있다. 이를 순회하며 각 문자열을 개별적으로 보내고, thread::sleep 함수를 호출해 1초간 일시 정지한다.

메인 스레드에서는 더 이상 recv 함수를 명시적으로 호출하지 않는다. 대신 rx를 이터레이터로 취급한다. 받은 각 값을 출력하고, 채널이 닫히면 이터레이션이 종료된다.

리스트 16-10의 코드를 실행하면 각 줄 사이에 1초의 간격을 두고 다음과 같은 출력을 확인할 수 있다:

Got: hi
Got: from
Got: the
Got: thread

메인 스레드의 for 루프에는 일시 정지나 지연을 발생시키는 코드가 없기 때문에, 메인 스레드가 생성된 스레드로부터 값을 받기 위해 기다리고 있음을 알 수 있다.

여러 생산자 생성하기: 송신자 복제하기

이전에 mpscmultiple producer, single consumer(다중 생산자, 단일 소비자)의 약자라고 언급했다. 이제 mpsc를 활용해 Listing 16-10의 코드를 확장하여 여러 스레드가 동일한 수신자에게 값을 보내는 예제를 만들어보자. Listing 16-11에서 보여주는 것처럼 송신자를 복제하여 이를 구현할 수 있다.

Filename: src/main.rs
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    // --snip--

    let (tx, rx) = mpsc::channel();

    let tx1 = tx.clone();
    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec![
            String::from("more"),
            String::from("messages"),
            String::from("for"),
            String::from("you"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    for received in rx {
        println!("Got: {received}");
    }

    // --snip--
}
Listing 16-11: 여러 생산자로부터 여러 메시지 보내기

이번에는 첫 번째 스레드를 생성하기 전에 송신자에 clone 메서드를 호출한다. 이렇게 하면 첫 번째 스레드에 전달할 새로운 송신자를 얻을 수 있다. 원본 송신자는 두 번째 스레드에 전달한다. 이렇게 하면 두 개의 스레드가 각기 다른 메시지를 동일한 수신자에게 보내게 된다.

이 코드를 실행하면 다음과 같은 출력이 나타날 것이다:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

시스템에 따라 값의 순서가 달라질 수 있다. 이는 동시성의 흥미로운 점이자 어려운 점이다. thread::sleep을 사용해 각 스레드에 다양한 값을 주면서 실험해보면, 실행마다 더 비결정적이 되어 매번 다른 출력을 생성할 것이다.

이제 채널이 어떻게 동작하는지 살펴봤으니, 다른 동시성 방법을 알아보자.

공유 상태 동시성

메시지 전달은 동시성을 처리하는 좋은 방법이지만, 유일한 방법은 아니다. 또 다른 방법은 여러 스레드가 동일한 공유 데이터에 접근하는 것이다. Go 언어 문서의 슬로건 일부를 다시 살펴보자: “메모리를 공유함으로써 통신하지 말라.”

메모리를 공유함으로써 통신하는 것은 어떤 모습일까? 또한, 메시지 전달을 선호하는 사람들이 왜 메모리 공유를 사용하지 말라고 조언할까?

어떤 면에서, 프로그래밍 언어의 채널은 단일 소유권과 유사하다. 값을 채널로 전송한 후에는 그 값을 더 이상 사용해서는 안 되기 때문이다. 반면, 공유 메모리 동시성은 다중 소유권과 같다. 여러 스레드가 동시에 동일한 메모리 위치에 접근할 수 있다. 15장에서 스마트 포인터가 다중 소유권을 가능하게 하는 것을 보았듯이, 다중 소유권은 이러한 다양한 소유자를 관리해야 하기 때문에 복잡성을 증가시킬 수 있다. Rust의 타입 시스템과 소유권 규칙은 이러한 관리를 올바르게 수행하는 데 큰 도움을 준다. 예를 들어, 공유 메모리를 위한 가장 일반적인 동시성 기본 요소 중 하나인 뮤텍스를 살펴보자.

뮤텍스를 사용해 한 번에 하나의 스레드만 데이터에 접근하도록 허용하기

_뮤텍스(Mutex)_는 _상호 배제(mutual exclusion)_의 약자로, 뮤텍스를 사용하면 한 번에 하나의 스레드만 특정 데이터에 접근할 수 있다. 뮤텍스 내부의 데이터에 접근하려면 스레드가 먼저 뮤텍스의 _락(lock)_을 획득하도록 요청해야 한다. 락은 뮤텍스의 일부로, 현재 누가 데이터에 배타적으로 접근 중인지 추적하는 데이터 구조다. 따라서 뮤텍스는 락 시스템을 통해 자신이 보유한 데이터를 _보호(guarding)_한다고 표현한다.

뮤텍스는 사용하기 까다롭다는 평판이 있는데, 그 이유는 두 가지 규칙을 반드시 기억해야 하기 때문이다:

  1. 데이터를 사용하기 전에 반드시 락을 획득하려고 시도해야 한다.
  2. 뮤텍스가 보호하는 데이터 사용을 마치면, 다른 스레드가 락을 획득할 수 있도록 데이터를 잠금 해제해야 한다.

뮤텍스를 현실 세계에 비유하자면, 컨퍼런스에서 단 하나의 마이크만 사용할 수 있는 패널 토론을 생각해볼 수 있다. 패널리스트가 발언하기 전에 마이크 사용을 요청하거나 신호를 보내야 한다. 마이크를 받으면 원하는 만큼 발언할 수 있고, 발언이 끝나면 다음 발언을 요청한 패널리스트에게 마이크를 넘겨야 한다. 만약 패널리스트가 발언을 마친 후 마이크를 넘기는 것을 잊어버리면, 다른 누구도 발언할 수 없다. 공유 마이크 관리가 잘못되면 패널 토론은 계획대로 진행되지 않을 것이다!

뮤텍스 관리는 매우 까다로울 수 있어서 많은 사람들이 채널을 선호한다. 하지만 Rust의 타입 시스템과 소유권 규칙 덕분에 락 획득과 해제를 잘못 사용할 일은 없다.

Mutex<T>의 API

Mutex를 사용하는 방법을 설명하기 위해, 먼저 단일 스레드 환경에서 Mutex를 사용하는 예제를 살펴보자. 이는 Listing 16-12에 나와 있다.

Filename: src/main.rs
use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {m:?}");
}
Listing 16-12: 단순함을 위해 단일 스레드 환경에서 Mutex<T>의 API를 탐색

다른 타입들과 마찬가지로, Mutex<T>는 연관 함수 new를 사용해 생성한다. 뮤텍스 내부의 데이터에 접근하기 위해 lock 메서드를 사용해 락을 획득한다. 이 호출은 현재 스레드를 블로킹하여 락을 획득할 때까지 다른 작업을 수행할 수 없게 한다.

만약 락을 가지고 있는 다른 스레드가 패닉 상태에 빠진다면, lock 호출은 실패할 것이다. 이 경우, 아무도 락을 얻을 수 없기 때문에, 우리는 unwrap을 선택해 이 스레드가 패닉 상태에 빠지도록 했다.

락을 획득한 후에는, 반환값(이 예제에서는 num이라는 이름)을 뮤텍스 내부 데이터에 대한 가변 참조로 취급할 수 있다. 타입 시스템은 m 내부의 값을 사용하기 전에 반드시 락을 획득하도록 보장한다. m의 타입은 i32가 아니라 Mutex<i32>이기 때문에, i32 값을 사용하려면 반드시 lock을 호출해야 한다. 이를 잊을 수는 없다. 타입 시스템이 내부의 i32에 접근하지 못하도록 막기 때문이다.

예상할 수 있듯이, Mutex<T>는 스마트 포인터다. 더 정확히 말하면, lock 호출은 MutexGuard라는 스마트 포인터를 반환하며, 이는 unwrap 호출로 처리된 LockResult로 감싸져 있다. MutexGuard 스마트 포인터는 내부 데이터를 가리키기 위해 Deref를 구현한다. 또한 이 스마트 포인터는 Drop 구현을 가지고 있어, MutexGuard가 스코프를 벗어날 때(내부 스코프의 끝에서) 자동으로 락을 해제한다. 결과적으로, 락을 해제하는 것을 잊어버려 다른 스레드가 뮤텍스를 사용하지 못하게 되는 위험은 없다. 락 해제가 자동으로 이루어지기 때문이다.

락을 해제한 후에는 뮤텍스 값을 출력해 볼 수 있으며, 내부의 i32 값을 6으로 변경할 수 있었다는 것을 확인할 수 있다.

여러 스레드 간 Mutex<T> 공유하기

이제 Mutex<T>를 사용해 여러 스레드 간에 값을 공유해 보자. 10개의 스레드를 생성하고 각 스레드가 카운터 값을 1씩 증가시켜, 카운터가 0에서 10으로 증가하도록 할 것이다. 리스트 16-13의 예제는 컴파일 오류가 발생하며, 이 오류를 통해 Mutex<T>의 사용법과 Rust가 어떻게 올바른 사용을 도와주는지 더 깊이 이해할 수 있다.

Filename: src/main.rs
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-13: Mutex<T>로 보호된 카운터를 각각 증가시키는 10개의 스레드

리스트 16-12와 마찬가지로 Mutex<T> 내부에 i32 값을 저장할 counter 변수를 생성한다. 다음으로, 숫자 범위를 순회하며 10개의 스레드를 생성한다. thread::spawn을 사용하고 모든 스레드에 동일한 클로저를 전달한다. 이 클로저는 counter를 스레드로 이동시키고, lock 메서드를 호출해 Mutex<T>의 락을 획득한 후, 뮤텍스 내부의 값을 1 증가시킨다. 스레드가 클로저 실행을 마치면 num이 스코프를 벗어나 락이 해제되어 다른 스레드가 락을 획득할 수 있다.

메인 스레드에서는 모든 조인 핸들을 수집한다. 그리고 리스트 16-2에서 했던 것처럼 각 핸들에 대해 join을 호출해 모든 스레드가 종료될 때까지 기다린다. 그 시점에서 메인 스레드가 락을 획득하고 프로그램의 결과를 출력한다.

이 예제가 컴파일되지 않을 것이라고 암시했다. 이제 그 이유를 알아보자!

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0382]: borrow of moved value: `counter`
  --> src/main.rs:21:29
   |
5  |     let counter = Mutex::new(0);
   |         ------- move occurs because `counter` has type `Mutex<i32>`, which does not implement the `Copy` trait
...
8  |     for _ in 0..10 {
   |     -------------- inside of this loop
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved into closure here, in previous iteration of loop
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value borrowed here after move
   |
help: consider moving the expression out of the loop so it is only moved once
   |
8  ~     let mut value = counter.lock();
9  ~     for _ in 0..10 {
10 |         let handle = thread::spawn(move || {
11 ~             let mut num = value.unwrap();
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

오류 메시지는 루프의 이전 반복에서 counter 값이 이동되었다고 알려준다. Rust는 락 counter의 소유권을 여러 스레드로 이동할 수 없다고 설명하고 있다. 15장에서 논의한 다중 소유권 방법을 사용해 이 컴파일 오류를 해결해 보자.

다중 스레드에서의 다중 소유권

15장에서는 스마트 포인터 Rc<T>를 사용해 참조 카운트가 적용된 값을 생성함으로써 다중 소유권을 구현했다. 이번에도 같은 방식을 적용해 보자. Mutex<T>Rc<T>로 감싸고, 스레드로 소유권을 이동하기 전에 Rc<T>를 복제해 보겠다.

<리스트 번호=“16-14” 파일명=“src/main.rs” 캡션=“Rc<T>를 사용해 다중 스레드가 Mutex<T>를 소유하도록 시도”>

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Rc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

</리스트>

다시 컴파일을 해 보면… 또 다른 에러가 발생한다! 컴파일러가 우리에게 많은 것을 가르쳐 주고 있다.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<Mutex<i32>>` cannot be sent between threads safely
  --> src/main.rs:11:36
   |
11 |           let handle = thread::spawn(move || {
   |                        ------------- ^------
   |                        |             |
   |  ______________________|_____________within this `{closure@src/main.rs:11:36: 11:43}`
   | |                      |
   | |                      required by a bound introduced by this call
12 | |             let mut num = counter.lock().unwrap();
13 | |
14 | |             *num += 1;
15 | |         });
   | |_________^ `Rc<Mutex<i32>>` cannot be sent between threads safely
   |
   = help: within `{closure@src/main.rs:11:36: 11:43}`, the trait `Send` is not implemented for `Rc<Mutex<i32>>`
note: required because it's used within this closure
  --> src/main.rs:11:36
   |
11 |         let handle = thread::spawn(move || {
   |                                    ^^^^^^^
note: required by a bound in `spawn`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:728:1

For more information about this error, try `rustc --explain E0277`.
error: could not compile `shared-state` (bin "shared-state") due to 1 previous error

와우, 에러 메시지가 매우 길다! 여기서 중요한 부분은 `Rc<Mutex<i32>>`는 스레드 간에 안전하게 전송될 수 없다는 것이다. 컴파일러는 또한 그 이유를 알려주고 있다: `Rc<Mutex<i32>>`에 대해 `Send` 트레잇이 구현되지 않았다. Send 트레잇에 대해서는 다음 섹션에서 다룰 것이다. 이 트레잇은 스레드에서 사용하는 타입이 동시성 상황에서 사용하기 적합한지 보장하는 역할을 한다.

안타깝게도 Rc<T>는 스레드 간에 공유하기에 안전하지 않다. Rc<T>가 참조 카운트를 관리할 때, clone이 호출될 때마다 카운트를 증가시키고, 각 복제본이 삭제될 때마다 카운트를 감소시킨다. 하지만 이 과정에서 동시성 기본 요소를 사용하지 않기 때문에 다른 스레드에 의해 카운트 변경이 중단될 수 있다. 이는 잘못된 카운트로 이어질 수 있으며, 이는 메모리 누수나 값이 사용 중이지 않을 때 삭제되는 미묘한 버그를 초래할 수 있다. 우리에게 필요한 것은 Rc<T>와 정확히 같지만 참조 카운트 변경을 스레드 안전하게 처리하는 타입이다.

Arc<T>를 활용한 원자적 참조 카운팅

다행히 Arc<T>Rc<T>와 유사한 타입으로, 동시성 상황에서도 안전하게 사용할 수 있다. 여기서 ’a’는 ’atomic’을 의미하며, 이는 원자적 참조 카운팅(atomically reference-counted) 타입이라는 뜻이다. 원자적 연산은 또 다른 종류의 동시성 기본 요소인데, 여기서는 자세히 다루지 않는다. 더 알고 싶다면 표준 라이브러리의 [std::sync::atomic][atomic] 문서를 참고하면 된다. 여기서는 원자적 연산이 기본 타입처럼 동작하지만, 스레드 간에 안전하게 공유할 수 있다는 점만 이해하면 충분하다.

그렇다면 모든 기본 타입이 원자적이지 않은 이유와 표준 라이브러리 타입이 기본적으로 Arc<T>를 사용하지 않는 이유가 궁금할 것이다. 그 이유는 스레드 안전성을 보장하려면 성능상의 비용이 발생하기 때문이다. 이 비용은 정말 필요할 때만 지불하는 것이 좋다. 단일 스레드 내에서만 값을 다룬다면, 원자적 연산이 제공하는 보장을 강제할 필요가 없으므로 코드가 더 빠르게 실행될 수 있다.

예제로 돌아가 보자. Arc<T>Rc<T>는 동일한 API를 제공하므로, use 문과 new 호출, 그리고 clone 호출만 변경하면 프로그램을 수정할 수 있다. 리스트 16-15의 코드는 드디어 컴파일되고 실행된다.

Filename: src/main.rs
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}
Listing 16-15: Mutex<T>를 감싸기 위해 Arc<T>를 사용하여 여러 스레드 간에 소유권을 공유

이 코드는 다음과 같은 결과를 출력한다:

Result: 10

성공했다! 0부터 10까지 세는 것은 별로 대단해 보이지 않을 수 있지만, Mutex<T>와 스레드 안전성에 대해 많은 것을 배울 수 있었다. 이 프로그램의 구조를 활용해 단순히 카운터를 증가시키는 것보다 더 복잡한 작업도 수행할 수 있다. 이 전략을 사용하면 계산을 독립적인 부분으로 나누고, 각 부분을 여러 스레드에 분배한 다음, Mutex<T>를 사용해 각 스레드가 최종 결과를 업데이트하도록 할 수 있다.

단순한 숫자 연산을 수행하는 경우, 표준 라이브러리의 [std::sync::atomic 모듈][atomic]에서 제공하는 타입이 Mutex<T>보다 더 간단한 경우도 있다. 이 타입들은 기본 타입에 대한 안전하고 동시적인 원자적 접근을 제공한다. 이 예제에서는 Mutex<T>의 동작 방식을 집중적으로 설명하기 위해 기본 타입과 함께 Mutex<T>를 사용했다.

RefCell<T>/Rc<T>Mutex<T>/Arc<T>의 유사점

counter가 불변임에도 불구하고 내부 값에 대한 가변 참조를 얻을 수 있다는 점을 눈치챘을 것이다. 이는 Mutex<T>Cell 패밀리와 마찬가지로 내부 가변성을 제공한다는 것을 의미한다. 15장에서 Rc<T> 내부의 내용을 변경하기 위해 RefCell<T>를 사용한 것과 마찬가지로, Arc<T> 내부의 내용을 변경하기 위해 Mutex<T>를 사용한다.

또한, Mutex<T>를 사용할 때 Rust가 모든 종류의 논리 오류로부터 보호해주지는 않는다는 점을 주목해야 한다. 15장에서 Rc<T>를 사용할 때 참조 순환이 발생할 위험이 있다는 것을 기억할 것이다. 두 Rc<T> 값이 서로를 참조하면 메모리 누수가 발생할 수 있다. 마찬가지로, Mutex<T>는 데드락을 발생시킬 위험이 있다. 데드락은 두 리소스를 잠그는 작업이 필요할 때, 두 스레드가 각각 하나의 잠금을 획득하여 서로를 영원히 기다리게 되는 상황에서 발생한다. 데드락에 관심이 있다면, 데드락이 발생하는 Rust 프로그램을 만들어보고, 다양한 언어에서 뮤텍스의 데드락 완화 전략을 연구한 후 Rust에서 이를 구현해보는 것도 좋은 방법이다. Mutex<T>MutexGuard의 표준 라이브러리 API 문서는 유용한 정보를 제공한다.

이 장의 마지막으로, SendSync 트레이트에 대해 이야기하고 커스텀 타입에서 이를 어떻게 사용할 수 있는지 알아볼 것이다.

SendSync 트레이트를 통한 확장 가능한 동시성

흥미롭게도, 이 장에서 지금까지 다룬 대부분의 동시성 기능은 언어 자체가 아닌 표준 라이브러리의 일부였다. 동시성을 처리하는 방법은 언어나 표준 라이브러리에 국한되지 않는다. 여러분은 직접 동시성 기능을 작성하거나 다른 사람이 작성한 기능을 사용할 수 있다.

그러나 언어 자체에 내장된 주요 동시성 개념 중 하나는 std::marker 트레이트인 SendSync이다.

스레드 간 소유권 이전을 허용하는 Send

Send 마커 트레이트는 Send를 구현한 타입의 값에 대한 소유권이 스레드 간에 전달될 수 있음을 나타낸다. 거의 모든 Rust 타입은 Send를 구현하지만, 몇 가지 예외가 있다. 예를 들어 Rc<T>Send를 구현할 수 없다. Rc<T> 값을 복제한 후 복제본의 소유권을 다른 스레드로 전달하려고 하면, 두 스레드가 동시에 참조 카운트를 업데이트할 가능성이 있기 때문이다. 따라서 Rc<T>는 스레드 안전성을 위한 성능 손실을 감수하고 싶지 않은 단일 스레드 상황에서 사용하도록 구현되었다.

따라서 Rust의 타입 시스템과 트레이트 바운드는 실수로 Rc<T> 값을 스레드 간에 안전하지 않게 전달하는 일이 없도록 보장한다. 리스트 16-14에서 이를 시도했을 때, the trait Send is not implemented for Rc<Mutex<i32>>라는 오류가 발생했다. Send를 구현한 Arc<T>로 전환하자 코드가 정상적으로 컴파일되었다.

Send 타입으로만 구성된 모든 타입은 자동으로 Send로 표시된다. 원시 포인터를 제외한 거의 모든 기본 타입은 Send를 구현하며, 원시 포인터에 대해서는 20장에서 자세히 다룰 예정이다.

Sync를 사용한 다중 스레드 접근 허용

Sync 마커 트레이트는 이 트레이트를 구현한 타입이 여러 스레드에서 참조해도 안전함을 나타낸다. 즉, &T(T에 대한 불변 참조)가 Send를 구현한다면, 해당 타입 TSync를 구현한다고 볼 수 있다. 이는 참조를 다른 스레드로 안전하게 보낼 수 있음을 의미한다. Send와 마찬가지로, 모든 기본 타입은 Sync를 구현하며, Sync를 구현한 타입으로만 구성된 타입 역시 Sync를 구현한다.

스마트 포인터 Rc<T>Send를 구현하지 않는 것과 같은 이유로 Sync도 구현하지 않는다. RefCell<T> 타입(15장에서 다룬 내용)과 관련된 Cell<T> 타입들도 Sync를 구현하지 않는다. RefCell<T>가 런타임에 수행하는 차용 검사는 스레드 안전하지 않다. 반면, 스마트 포인터 Mutex<T>Sync를 구현하며, “다중 스레드 간 Mutex<T> 공유”에서 본 것처럼 다중 스레드 간 접근을 공유하는 데 사용할 수 있다.

SendSync를 수동으로 구현하는 것은 안전하지 않다

SendSync 트레이트를 구현한 타입들로만 구성된 타입은 자동으로 SendSync를 구현한다. 따라서 이 트레이트를 수동으로 구현할 필요가 없다. 마커 트레이트로서, 이들은 구현할 메서드도 없다. 이들은 단지 동시성과 관련된 불변 조건을 강제하는 데 유용하다.

이 트레이트를 수동으로 구현하려면 안전하지 않은 Rust 코드를 구현해야 한다. 안전하지 않은 Rust 코드 사용에 대해서는 20장에서 다룰 예정이다. 지금 중요한 점은 SendSync로 구성되지 않은 새로운 동시성 타입을 만들 때, 안전성 보장을 유지하기 위해 신중하게 고민해야 한다는 것이다. “The Rustonomicon”에는 이러한 보장과 이를 유지하는 방법에 대한 더 많은 정보가 있다.

요약

이 책에서 동시성에 대해 다루는 내용은 여기서 끝나지 않는다. 다음 장에서는 비동기 프로그래밍에 초점을 맞추고, 21장의 프로젝트에서는 이 장에서 배운 개념을 더 현실적인 상황에서 활용한다.

앞서 언급했듯이, Rust가 동시성을 처리하는 방식은 언어 자체에 포함된 부분이 거의 없다. 따라서 많은 동시성 솔루션이 크레이트로 구현된다. 이러한 크레이트는 표준 라이브러리보다 더 빠르게 발전하므로, 멀티스레드 상황에서 사용할 최신 크레이트를 온라인에서 검색해보는 것이 좋다.

Rust 표준 라이브러리는 메시지 전달을 위한 채널과 Mutex<T>, Arc<T>와 같은 스마트 포인터 타입을 제공한다. 이들은 동시성 컨텍스트에서 안전하게 사용할 수 있다. 타입 시스템과 빌림 검사기는 이러한 솔루션을 사용하는 코드가 데이터 경쟁이나 잘못된 참조로 이어지지 않도록 보장한다. 코드가 컴파일되면, 여러 스레드에서 안정적으로 실행될 것이라는 확신을 가질 수 있다. 다른 언어에서 흔히 발생하는 추적하기 어려운 버그를 걱정할 필요가 없다. 동시성 프로그래밍은 더 이상 두려워할 개념이 아니다. 두려움 없이 프로그램에 동시성을 도입해보자!

비동기 프로그래밍의 기초: Async, Await, Futures, 그리고 Streams

컴퓨터가 수행하는 많은 작업은 완료까지 시간이 걸릴 수 있다. 이러한 오래 걸리는 프로세스가 완료될 때까지 기다리는 동안 다른 작업을 할 수 있다면 좋을 것이다. 현대 컴퓨터는 한 번에 여러 작업을 처리하기 위해 두 가지 기술을 제공한다: 병렬성(parallelism)과 동시성(concurrency). 그러나 병렬 또는 동시 작업을 포함하는 프로그램을 작성하기 시작하면, 작업이 시작된 순서대로 완료되지 않을 수 있는 비동기 프로그래밍의 고유한 문제에 직면하게 된다. 이 장에서는 16장에서 다룬 스레드를 통한 병렬성과 동시성에 이어, 비동기 프로그래밍을 위한 대안적인 접근 방식을 소개한다. Rust의 Futures, Streams, 그리고 이를 지원하는 asyncawait 문법, 그리고 비동기 작업을 관리하고 조율하는 도구들에 대해 알아볼 것이다.

예를 들어, 가족 행사를 담은 동영상을 내보내는 작업을 한다고 가정해 보자. 이 작업은 몇 분에서 몇 시간까지 걸릴 수 있다. 동영상 내보내기는 가능한 한 많은 CPU와 GPU 자원을 사용할 것이다. 만약 CPU 코어가 하나뿐이고 운영체제가 내보내기 작업이 완료될 때까지 이를 일시 중지하지 않는다면, 즉 동기적으로(synchronously) 실행한다면, 그 작업이 실행되는 동안 컴퓨터에서 다른 작업을 할 수 없을 것이다. 이는 매우 불편한 경험이 될 것이다. 다행히, 운영체제는 내보내기 작업을 자주 눈에 띄지 않게 중단시켜 다른 작업을 동시에 수행할 수 있게 해준다.

이제 다른 사람이 공유한 동영상을 다운로드하는 경우를 생각해 보자. 이 작업도 시간이 걸리지만 CPU 시간을 많이 차지하지는 않는다. 이 경우, CPU는 네트워크에서 데이터가 도착하기를 기다려야 한다. 데이터가 도착하기 시작하면 읽을 수는 있지만, 모든 데이터가 도착하려면 시간이 걸릴 수 있다. 데이터가 모두 도착하더라도, 동영상이 매우 크다면 이를 모두 로드하는 데 최소 1~2초가 걸릴 수 있다. 이는 짧은 시간처럼 들릴 수 있지만, 현대 프로세서가 1초에 수십억 번의 연산을 수행할 수 있다는 점을 고려하면 매우 긴 시간이다. 다시 말해, 운영체제는 네트워크 호출이 완료될 때까지 기다리는 동안 프로그램을 눈에 띄지 않게 중단시켜 CPU가 다른 작업을 수행할 수 있게 해준다.

동영상 내보내기는 CPU-bound 또는 compute-bound 작업의 예시이다. 이는 CPU 또는 GPU 내에서 컴퓨터의 잠재적 데이터 처리 속도와 그 작업에 할당할 수 있는 속도에 의해 제한된다. 반면, 동영상 다운로드는 IO-bound 작업의 예시이다. 이는 컴퓨터의 입출력(input and output) 속도에 의해 제한되며, 네트워크를 통해 데이터를 보낼 수 있는 속도만큼 빠르게 진행된다.

이 두 예시에서 운영체제의 눈에 띄지 않는 중단은 동시성의 한 형태를 제공한다. 그러나 이 동시성은 전체 프로그램 수준에서만 발생한다: 운영체제는 한 프로그램을 중단시켜 다른 프로그램이 작업을 수행할 수 있게 한다. 많은 경우, 우리는 운영체제보다 더 세부적인 수준에서 프로그램을 이해하기 때문에, 운영체제가 볼 수 없는 동시성의 기회를 발견할 수 있다.

예를 들어, 파일 다운로드를 관리하는 도구를 만든다면, 하나의 다운로드를 시작해도 UI가 멈추지 않도록 프로그램을 작성할 수 있어야 하며, 사용자가 동시에 여러 다운로드를 시작할 수 있어야 한다. 그러나 네트워크와 상호작용하는 많은 운영체제 API는 블로킹(blocking) 방식이다. 즉, 처리 중인 데이터가 완전히 준비될 때까지 프로그램의 진행을 막는다.

참고: 대부분의 함수 호출이 이렇게 동작한다고 생각할 수 있다. 그러나 블로킹이라는 용어는 일반적으로 파일, 네트워크, 또는 컴퓨터의 다른 리소스와 상호작용하는 함수 호출에 사용된다. 왜냐하면 이러한 경우에 개별 프로그램이 작업이 **논블로킹(non-blocking)**으로 수행되는 것에서 이점을 얻을 수 있기 때문이다.

우리는 각 파일을 다운로드하기 위해 전용 스레드를 생성함으로써 메인 스레드가 블로킹되는 것을 피할 수 있다. 그러나 이러한 스레드의 오버헤드는 결국 문제가 될 수 있다. 처음부터 호출이 블로킹되지 않는 것이 더 바람직하다. 또한 블로킹 코드에서 사용하는 것과 같은 직접적인 스타일로 작성할 수 있다면 더 좋을 것이다. 예를 들어 다음과 같다:

let data = fetch_data_from(url).await;
println!("{data}");

이것이 바로 Rust의 async(비동기의 줄임말) 추상화가 제공하는 것이다. 이 장에서는 다음과 같은 주제를 다루며 async에 대해 자세히 알아볼 것이다:

  • Rust의 asyncawait 문법을 사용하는 방법
  • 16장에서 다룬 동일한 문제를 해결하기 위해 async 모델을 사용하는 방법
  • 멀티스레딩과 async가 상호 보완적인 솔루션을 제공하며, 많은 경우에 이를 결합할 수 있는 방법

그러나 async가 실제로 어떻게 동작하는지 살펴보기 전에, 병렬성과 동시성의 차이에 대해 짧게 논의할 필요가 있다.

병렬성과 동시성

지금까지 병렬성(parallelism)과 동시성(concurrency)을 거의 같은 개념으로 다뤘다. 하지만 이제는 이 둘을 더 정확히 구분해야 한다. 차이점을 명확히 이해해야 실제 작업을 시작할 때 혼란을 피할 수 있다.

소프트웨어 프로젝트에서 팀이 작업을 나누는 다양한 방식을 생각해 보자. 한 멤버에게 여러 작업을 할당하거나, 각 멤버에게 하나의 작업을 할당하거나, 두 방식을 혼합할 수 있다.

한 사람이 여러 작업을 동시에 진행하되, 아직 완료되지 않은 상태에서 작업을 전환하는 것을 동시성이라고 한다. 예를 들어, 컴퓨터에서 두 개의 프로젝트를 동시에 진행하다가 한 프로젝트에서 막히거나 지루해지면 다른 프로젝트로 전환하는 경우를 생각해 보자. 한 사람이기 때문에 두 작업을 정확히 동시에 진행할 수는 없지만, 작업을 번갈아 가며 진행하면서 각각의 작업에 조금씩 진전을 이룰 수 있다(그림 17-1 참조).

Task A와 Task B로 표시된 상자와 그 안에 하위 작업을 나타내는 다이아몬드가 있는 다이어그램. A1에서 B1, B1에서 A2, A2에서 B2, B2에서 A3, A3에서 A4, A4에서 B3으로 가는 화살표가 있다. 하위 작업 사이의 화살표는 Task A와 Task B 사이의 상자를 가로지른다.
그림 17-1: Task A와 Task B 사이를 전환하는 동시성 워크플로우

반면, 팀이 작업을 나누어 각 멤버가 하나의 작업을 맡아 독립적으로 진행하는 것을 병렬성이라고 한다. 이 경우 팀의 각 멤버는 정확히 동시에 작업을 진행할 수 있다(그림 17-2 참조).

Task A와 Task B로 표시된 상자와 그 안에 하위 작업을 나타내는 다이아몬드가 있는 다이어그램. A1에서 A2, A2에서 A3, A3에서 A4, B1에서 B2, B2에서 B3으로 가는 화살표가 있다. Task A와 Task B 사이의 상자를 가로지르는 화살표는 없다.
그림 17-2: Task A와 Task B에서 독립적으로 작업이 진행되는 병렬 워크플로우

이 두 워크플로우에서도 서로 다른 작업 간의 조정이 필요할 수 있다. 예를 들어, 한 사람에게 할당된 작업이 다른 사람의 작업과 완전히 독립적이라고 생각했지만, 실제로는 팀의 다른 사람이 먼저 작업을 완료해야 할 수도 있다. 일부 작업은 병렬로 수행할 수 있지만, 일부는 순차적으로만 수행할 수 있다. 즉, 한 작업이 끝난 후에야 다음 작업을 시작할 수 있다(그림 17-3 참조).

Task A와 Task B로 표시된 상자와 그 안에 하위 작업을 나타내는 다이아몬드가 있는 다이어그램. A1에서 A2, A2에서 '일시정지' 기호 같은 두꺼운 수직선, 그 기호에서 A3, B1에서 B2, B2에서 B3, B3에서 A3, B3에서 B4로 가는 화살표가 있다.
그림 17-3: 부분적으로 병렬적인 워크플로우. Task A와 Task B는 독립적으로 진행되다가 Task A3이 Task B3의 결과를 기다리며 차단된다.

마찬가지로, 자신의 작업 중 하나가 다른 작업에 의존한다는 사실을 깨달을 수도 있다. 이제 동시에 진행하던 작업이 순차적으로 바뀌게 된다.

병렬성과 동시성은 서로 교차할 수도 있다. 동료가 자신의 작업 중 하나를 완료할 때까지 막혀 있다는 사실을 알게 되면, 아마도 그 작업에 모든 노력을 집중해 동료를 ’차단 해제’하려 할 것이다. 이제는 더 이상 병렬로 작업할 수 없고, 자신의 작업을 동시에 진행할 수도 없다.

이러한 기본적인 역학은 소프트웨어와 하드웨어에서도 동일하게 적용된다. 단일 CPU 코어를 가진 머신에서는 CPU가 한 번에 하나의 작업만 수행할 수 있지만, 여전히 동시성을 활용할 수 있다. 스레드, 프로세스, 비동기(async)와 같은 도구를 사용하면 컴퓨터는 하나의 작업을 일시 중지하고 다른 작업으로 전환한 후 다시 원래 작업으로 돌아올 수 있다. 반면, 여러 CPU 코어를 가진 머신에서는 병렬로 작업을 수행할 수 있다. 하나의 코어가 한 작업을 수행하는 동안 다른 코어는 완전히 무관한 작업을 동시에 수행할 수 있다.

Rust에서 비동기(async) 작업을 다룰 때는 항상 동시성을 다룬다. 사용하는 하드웨어, 운영체제, 비동기 런타임(나중에 자세히 설명)에 따라, 이 동시성은 내부적으로 병렬성을 활용할 수도 있다.

이제 Rust에서 비동기 프로그래밍이 실제로 어떻게 동작하는지 자세히 알아보자.

비동기 프로그래밍과 Async 구문

Rust에서 비동기 프로그래밍의 핵심 요소는 _퓨처(future)_와 async, await 키워드이다.

_퓨처_는 현재는 준비되지 않았지만 미래의 어느 시점에 준비될 값이다. (이 개념은 다른 언어에서도 나타나며, 때로는 _태스크(task)_나 _Promise_와 같은 이름으로 불린다.) Rust는 Future 트레이트를 제공하여 다양한 데이터 구조로 비동기 연산을 구현할 수 있도록 한다. Rust에서 퓨처는 Future 트레이트를 구현한 타입이다. 각 퓨처는 현재까지의 진행 상황과 “준비됨“이 무엇을 의미하는지에 대한 정보를 담고 있다.

async 키워드를 블록이나 함수에 적용하면 해당 블록이나 함수가 중단되고 재개될 수 있음을 지정한다. async 블록이나 함수 내에서 await 키워드를 사용해 퓨처를 기다릴 수 있다(즉, 퓨처가 준비될 때까지 기다린다). async 블록이나 함수 내에서 퓨처를 기다리는 지점은 해당 블록이나 함수가 일시 중단되고 재개될 수 있는 잠재적 지점이다. 퓨처의 값이 준비되었는지 확인하는 과정을 _폴링(polling)_이라고 한다.

C#이나 JavaScript와 같은 다른 언어들도 비동기 프로그래밍을 위해 asyncawait 키워드를 사용한다. 이러한 언어에 익숙하다면 Rust가 이를 처리하는 방식, 특히 구문 처리 방식에서 몇 가지 중요한 차이점을 발견할 수 있다. 이는 합당한 이유가 있으며, 이에 대해 곧 알아볼 것이다.

Rust에서 비동기 코드를 작성할 때는 주로 asyncawait 키워드를 사용한다. Rust는 이 키워드들을 Future 트레이트를 사용한 코드로 컴파일한다. 이는 for 루프를 Iterator 트레이트를 사용한 코드로 컴파일하는 방식과 유사하다. Rust가 Future 트레이트를 제공하기 때문에, 필요할 때 자신만의 데이터 타입에 이를 구현할 수도 있다. 이 장에서 살펴볼 많은 함수들은 각자의 Future 구현을 가진 타입을 반환한다. 장의 마지막에서 이 트레이트의 정의로 돌아가 더 깊이 파고들어보겠지만, 지금은 이 정도로 충분히 진행할 수 있다.

이 모든 것이 다소 추상적으로 느껴질 수 있으므로, 첫 번째 비동기 프로그램을 작성해보자: 간단한 웹 스크래퍼이다. 커맨드라인에서 두 개의 URL을 입력받아 동시에 두 URL을 가져오고, 먼저 완료된 결과를 반환한다. 이 예제에는 새로운 구문이 꽤 많이 등장하지만, 걱정하지 말자. 진행하면서 필요한 모든 것을 설명할 것이다.

첫 번째 비동기 프로그램

이번 장에서는 생태계의 다양한 부분을 다루기보다 비동기 프로그래밍 학습에 집중할 수 있도록 trpl 크레이트를 만들었다. (trpl은 “The Rust Programming Language“의 약어이다.) 이 크레이트는 주로 futurestokio 크레이트에서 필요한 타입, 트레이트, 함수를 다시 내보낸다. futures 크레이트는 Rust에서 비동기 코드를 실험하기 위한 공식적인 장소이며, 실제로 Future 트레이트가 처음 설계된 곳이다. Tokio는 현재 Rust에서 가장 널리 사용되는 비동기 런타임으로, 특히 웹 애플리케이션에서 많이 사용된다. 다른 훌륭한 런타임도 있지만, trpl에서는 tokio 크레이트를 사용한다. 이는 잘 테스트되었고 널리 사용되기 때문이다.

경우에 따라 trpl은 원래 API의 이름을 바꾸거나 감싸서 이번 장과 관련된 세부 사항에 집중할 수 있도록 했다. 크레이트가 어떤 역할을 하는지 이해하고 싶다면 소스 코드를 확인해 보길 권한다. 각각의 다시 내보낸 항목이 어떤 크레이트에서 왔는지 확인할 수 있으며, 크레이트의 기능을 설명하는 상세한 주석도 남겨두었다.

hello-async라는 이름의 새로운 바이너리 프로젝트를 생성하고 trpl 크레이트를 의존성으로 추가한다:

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

이제 trpl이 제공하는 다양한 기능을 사용해 첫 번째 비동기 프로그램을 작성할 수 있다. 두 개의 웹 페이지를 가져와 각각의 <title> 엘리먼트를 추출한 후, 전체 과정을 먼저 완료한 페이지의 제목을 출력하는 간단한 커맨드라인 도구를 만들어 볼 것이다.

page_title 함수 정의하기

먼저, 페이지 URL을 인자로 받아 해당 페이지에 요청을 보내고, <title> 엘리먼트의 텍스트를 반환하는 함수를 작성해 보자 (Listing 17-1 참조).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    // TODO: we'll add this next!
}

use trpl::Html;

async fn page_title(url: &str) -> Option<String> {
    let response = trpl::get(url).await;
    let response_text = response.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}
Listing 17-1: HTML 페이지에서 title 엘리먼트를 가져오는 비동기 함수 정의

먼저, page_title이라는 함수를 정의하고 async 키워드를 붙인다. 그런 다음 trpl::get 함수를 사용해 전달된 URL을 가져오고, await 키워드를 사용해 응답을 기다린다. 응답의 텍스트를 얻기 위해 text 메서드를 호출하고, 다시 await 키워드를 사용해 기다린다. 이 두 단계 모두 비동기적으로 동작한다. get 함수의 경우, 서버가 응답의 첫 부분(HTTP 헤더, 쿠키 등)을 보내는 것을 기다려야 한다. 특히 응답 본문이 매우 큰 경우, 전체가 도착하는 데 시간이 걸릴 수 있다. 응답 전체가 도착할 때까지 기다려야 하므로 text 메서드도 비동기적으로 동작한다.

이 두 가지 퓨처(future)를 명시적으로 await 해야 하는 이유는, Rust의 퓨처가 게으르기(lazy) 때문이다. 즉, await 키워드로 명시적으로 요청하기 전까지는 아무 작업도 수행하지 않는다. (사실, 퓨처를 사용하지 않으면 Rust 컴파일러가 경고를 보낸다.) 이는 이터레이터를 사용한 일련의 항목 처리에서 설명한 이터레이터의 동작과 유사하다. 이터레이터는 next 메서드를 직접 호출하거나 for 루프, map 같은 메서드를 사용해 내부적으로 next를 호출하기 전까지는 아무 작업도 하지 않는다. 마찬가지로, 퓨처도 명시적으로 요청하기 전까지는 아무 작업도 하지 않는다. 이러한 게으름 덕분에 Rust는 비동기 코드가 실제로 필요할 때까지 실행하지 않을 수 있다.

참고: 이는 스레드 생성에서 thread::spawn을 사용할 때의 동작과 다르다. thread::spawn에서는 다른 스레드에 전달한 클로저가 즉시 실행된다. 또한, 많은 다른 언어의 비동기 처리 방식과도 다르다. 하지만 Rust가 성능 보장을 제공하기 위해서는 이 방식이 중요하다. 이는 이터레이터와 마찬가지이다.

response_text를 얻은 후, Html::parse를 사용해 Html 타입의 인스턴스로 파싱한다. 이제 원시 문자열 대신 HTML을 더 풍부한 데이터 구조로 다룰 수 있다. 특히, select_first 메서드를 사용해 주어진 CSS 선택자에 해당하는 첫 번째 인스턴스를 찾을 수 있다. "title" 문자열을 전달하면 문서 내 첫 번째 <title> 엘리먼트를 얻을 수 있다. 일치하는 엘리먼트가 없을 수도 있으므로, select_firstOption<ElementRef>를 반환한다. 마지막으로, Option::map 메서드를 사용해 Option 내부의 항목이 있을 때만 작업을 수행하고, 없으면 아무 작업도 하지 않는다. (여기서 match 표현식을 사용할 수도 있지만, map이 더 관용적이다.) map에 전달한 함수 본문에서는 title_elementinner_html을 호출해 내용을 가져오며, 이는 String 타입이다. 최종적으로 Option<String>을 얻게 된다.

Rust의 await 키워드는 기다리는 표현식 뒤에 위치한다는 점에 주목하자. 즉, 후위(postfix) 키워드이다. 이는 다른 언어에서 async를 사용해 본 경험이 있다면 익숙하지 않을 수 있지만, Rust에서는 메서드 체이닝을 더 편리하게 할 수 있다. 결과적으로, page_title 함수의 본문을 trpl::gettext 함수 호출을 await 키워드로 연결하도록 변경할 수 있다 (Listing 17-2 참조).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    // TODO: we'll add this next!
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}
Listing 17-2: await 키워드를 사용한 체이닝

이제 첫 번째 비동기 함수를 성공적으로 작성했다! main 함수에서 이 함수를 호출하는 코드를 추가하기 전에, 작성한 내용과 그 의미에 대해 조금 더 이야기해 보자.

Rust는 async 키워드가 붙은 블록을 보면, Future 트레잇을 구현하는 고유한 익명 데이터 타입으로 컴파일한다. async 키워드가 붙은 함수를 보면, 그 본문이 비동기 블록인 비동기 함수로 컴파일한다. 비동기 함수의 반환 타입은 컴파일러가 해당 비동기 블록을 위해 생성한 익명 데이터 타입의 타입이다.

따라서 async fn을 작성하는 것은 반환 타입의 _퓨처_를 반환하는 함수를 작성하는 것과 동일하다. 컴파일러에게는 Listing 17-1의 async fn page_title과 같은 함수 정의가 다음과 같이 정의된 비동기 함수와 동일하다:

#![allow(unused)]
fn main() {
extern crate trpl; // mdbook 테스트를 위해 필요
use std::future::Future;
use trpl::Html;

fn page_title(url: &str) -> impl Future<Output = Option<String>> {
    async move {
        let text = trpl::get(url).await.text().await;
        Html::parse(&text)
            .select_first("title")
            .map(|title| title.inner_html())
    }
}
}

변환된 버전의 각 부분을 살펴보자:

  • “트레잇을 파라미터로 사용하기”에서 논의한 impl Trait 구문을 사용한다.
  • 반환된 트레잇은 Output이라는 연관 타입을 가진 Future이다. Output 타입은 Option<String>으로, async fn 버전의 page_title과 동일한 반환 타입이다.
  • 원래 함수 본문에서 호출된 모든 코드는 async move 블록으로 감싸져 있다. 블록은 표현식임을 기억하자. 이 전체 블록이 함수에서 반환되는 표현식이다.
  • 이 비동기 블록은 Option<String> 타입의 값을 생성한다. 이 값은 반환 타입의 Output 타입과 일치한다. 이는 다른 블록과 동일하다.
  • 새로운 함수 본문은 url 파라미터를 사용하는 방식 때문에 async move 블록이다. (asyncasync move의 차이에 대해서는 이 장 후반에 더 자세히 논의할 것이다.)

이제 main 함수에서 page_title을 호출할 수 있다.

단일 페이지의 제목 추출하기

먼저 단일 페이지의 제목을 가져오는 방법부터 시작한다. 리스팅 17-3에서는 커맨드라인 인자 처리하기 섹션에서 다룬 패턴을 그대로 사용한다. 첫 번째 URL을 page_title에 전달하고 결과를 기다린다. 이때 반환되는 값은 Option<String> 타입이므로, match 표현식을 사용해 페이지에 <title> 태그가 있는지 여부에 따라 다른 메시지를 출력한다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let url = &args[1];
    match page_title(url).await {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}
Listing 17-3: 사용자가 입력한 인자를 사용해 main 함수에서 page_title 함수 호출

하지만 이 코드는 컴파일되지 않는다. await 키워드는 async 함수나 블록 내에서만 사용할 수 있는데, Rust에서는 특수한 main 함수를 async로 표시할 수 없기 때문이다.

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:6:1
  |
6 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

main 함수를 async로 표시할 수 없는 이유는 async 코드가 실행되려면 _런타임_이 필요하기 때문이다. 런타임은 비동기 코드의 실행을 관리하는 Rust 크레이트이다. 프로그램의 main 함수는 런타임을 _초기화_할 수 있지만, 런타임 _자체_는 아니다. (이에 대한 자세한 내용은 잠시 후에 살펴본다.) 비동기 코드를 실행하는 모든 Rust 프로그램은 최소 한 군데에서 런타임을 설정하고 퓨처를 실행한다.

대부분의 언어는 async를 지원하면서 런타임을 함께 제공하지만, Rust는 그렇지 않다. 대신 다양한 async 런타임이 존재하며, 각각은 특정 사용 사례에 적합한 방식으로 설계되었다. 예를 들어, 많은 CPU 코어와 대용량 메모리를 가진 고성능 웹 서버의 요구사항은 단일 코어, 작은 메모리, 그리고 힙 할당 기능이 없는 마이크로컨트롤러와는 완전히 다르다. 이러한 런타임을 제공하는 크레이트는 종종 파일이나 네트워크 I/O와 같은 일반적인 기능의 비동기 버전도 함께 제공한다.

이 장에서는 trpl 크레이트의 run 함수를 사용한다. 이 함수는 퓨처를 인자로 받아 완료될 때까지 실행한다. 내부적으로 run을 호출하면 전달된 퓨처를 실행하기 위한 런타임이 설정된다. 퓨처가 완료되면 run은 퓨처가 생성한 값을 반환한다.

page_title이 반환한 퓨처를 직접 run에 전달하고, 완료된 후 Option<String>에 대해 match를 수행할 수도 있다. 하지만 이 장의 대부분의 예제(그리고 실제 세계의 대부분의 async 코드)에서는 단일 async 함수 호출 이상의 작업을 수행할 것이므로, 리스팅 17-4와 같이 async 블록을 전달하고 page_title 호출 결과를 명시적으로 기다리는 방식을 사용한다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::Html;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let url = &args[1];
        match page_title(url).await {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
    })
}

async fn page_title(url: &str) -> Option<String> {
    let response_text = trpl::get(url).await.text().await;
    Html::parse(&response_text)
        .select_first("title")
        .map(|title_element| title_element.inner_html())
}
Listing 17-4: trpl::run을 사용해 async 블록 실행

이 코드를 실행하면 처음 기대한 동작을 확인할 수 있다:

$ cargo run -- https://www.rust-lang.org
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running `target/debug/async_await 'https://www.rust-lang.org'`
The title for https://www.rust-lang.org was
            Rust Programming Language

드디어 동작하는 비동기 코드를 완성했다! 하지만 두 사이트를 경쟁시키는 코드를 추가하기 전에, 잠시 퓨처(Future)가 어떻게 동작하는지 다시 살펴보자.

await 포인트—즉, 코드에서 await 키워드를 사용하는 모든 지점—는 런타임에 제어권을 반환하는 위치를 나타낸다. 이를 동작하게 하려면 Rust는 비동기 블록과 관련된 상태를 추적해야 한다. 그래야 런타임이 다른 작업을 시작하고, 준비가 되면 다시 돌아와 첫 번째 작업을 계속 진행할 수 있다. 이는 마치 여러분이 각 await 포인트에서 현재 상태를 저장하기 위해 다음과 같은 enum을 작성한 것과 같은 보이지 않는 상태 머신이다:

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test

enum PageTitleFuture<'a> {
    Initial { url: &'a str },
    GetAwaitPoint { url: &'a str },
    TextAwaitPoint { response: trpl::Response },
}
}

각 상태 사이를 전환하는 코드를 직접 작성하는 것은 지루하고 오류가 발생하기 쉽다. 특히 나중에 더 많은 기능과 상태를 추가해야 할 때 더욱 그렇다. 다행히 Rust 컴파일러는 비동기 코드를 위한 상태 머신 데이터 구조를 자동으로 생성하고 관리한다. 데이터 구조 주변의 일반적인 빌림과 소유권 규칙은 여전히 적용되며, 컴파일러는 이를 검사하고 유용한 오류 메시지를 제공한다. 이 장의 후반부에서 몇 가지 예를 살펴볼 것이다.

궁극적으로, 이 상태 머신을 실행해야 하는 무언가가 필요하며, 그 무언가는 런타임이다. (이것이 런타임을 조사할 때 _실행자(executor)_에 대한 참조를 보게 되는 이유다: 실행자는 런타임의 일부로, 비동기 코드를 실행하는 역할을 담당한다.)

이제 Listing 17-3에서 왜 컴파일러가 main 함수 자체를 비동기 함수로 만들지 못하게 했는지 이해할 수 있다. 만약 main이 비동기 함수라면, main이 반환한 퓨처의 상태 머신을 관리할 무언가가 필요하지만, main은 프로그램의 시작점이다! 대신, main에서 trpl::run 함수를 호출하여 런타임을 설정하고, 비동기 블록이 반환한 퓨처가 완료될 때까지 실행했다.

참고: 일부 런타임은 async main 함수를 작성할 수 있도록 매크로를 제공한다. 이러한 매크로는 async fn main() { ... }를 일반 fn main으로 재작성하며, 이는 Listing 17-4에서 수동으로 한 것과 동일한 작업을 수행한다: trpl::run이 하는 것처럼 퓨처를 완료할 때까지 실행하는 함수를 호출한다.

이제 이 조각들을 모아 동시성 코드를 어떻게 작성할 수 있는지 알아보자.

두 URL 간의 경쟁

리스트 17-5에서는 커맨드라인에서 전달된 두 개의 다른 URL을 page_title 함수에 전달하고, 이 둘을 경쟁시킨다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::{Either, Html};

fn main() {
    let args: Vec<String> = std::env::args().collect();

    trpl::run(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::race(title_fut_1, title_fut_2).await {
                Either::Left(left) => left,
                Either::Right(right) => right,
            };

        println!("{url} returned first");
        match maybe_title {
            Some(title) => println!("Its page title is: '{title}'"),
            None => println!("Its title could not be parsed."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let text = trpl::get(url).await.text().await;
    let title = Html::parse(&text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}
Listing 17-5:

먼저 사용자가 제공한 각 URL에 대해 page_title 함수를 호출한다. 그 결과로 반환된 퓨처를 title_fut_1title_fut_2에 저장한다. 이 퓨처들은 아직 아무 작업도 하지 않는다는 점을 기억하자. 퓨처는 지연 평가되며, 아직 await로 기다리지 않았기 때문이다. 이후 이 퓨처들을 trpl::race 함수에 전달한다. 이 함수는 전달된 퓨처 중 어떤 것이 먼저 완료되었는지를 나타내는 값을 반환한다.

참고: 내부적으로 race 함수는 더 일반적인 함수인 select를 기반으로 동작한다. 실제 러스트 코드에서는 select 함수를 더 자주 접하게 될 것이다. select 함수는 trpl::race 함수가 할 수 없는 많은 작업을 수행할 수 있지만, 지금은 넘어갈 수 있는 추가적인 복잡성도 있다.

어느 퓨처가 먼저 완료될지 예측할 수 없기 때문에 Result를 반환하는 것은 적절하지 않다. 대신 race 함수는 이전에 본 적 없는 trpl::Either 타입을 반환한다. Either 타입은 Result와 유사하게 두 가지 경우를 가진다. 하지만 Result와 달리 Either에는 성공이나 실패라는 개념이 없다. 대신 LeftRight를 사용해 “둘 중 하나“를 나타낸다:

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

race 함수는 첫 번째 퓨처 인자가 먼저 완료되면 Left와 함께 그 결과를 반환하고, 두 번째 퓨처 인자가 먼저 완료되면 Right와 함께 그 결과를 반환한다. 이는 함수를 호출할 때 인자가 나타나는 순서와 일치한다: 첫 번째 인자는 두 번째 인자의 왼쪽에 위치한다.

또한 page_title 함수를 수정해 전달된 URL을 그대로 반환하도록 했다. 이렇게 하면 먼저 반환된 페이지에 <title>이 없더라도 의미 있는 메시지를 출력할 수 있다. 이 정보를 활용해 println! 출력을 업데이트하여 어떤 URL이 먼저 완료되었는지와 해당 URL의 웹 페이지 <title>이 무엇인지(있는 경우)를 표시한다.

이제 간단한 웹 스크래퍼를 만들었다! 몇 개의 URL을 선택하고 커맨드라인 도구를 실행해 보자. 어떤 사이트는 항상 더 빠르게 응답하는 반면, 다른 경우에는 실행마다 더 빠른 사이트가 달라질 수 있다. 더 중요한 것은, 여러분이 퓨처를 다루는 기본을 배웠다는 점이다. 이제 비동기 작업을 통해 무엇을 할 수 있는지 더 깊이 파고들 준비가 되었다.

동시성 처리와 Async의 활용

이번 섹션에서는 16장에서 스레드를 사용해 해결했던 동시성 문제를 async를 활용해 다시 접근한다. 이미 핵심 개념은 충분히 다뤘으므로, 이번에는 스레드와 future의 차이점에 집중한다.

대부분의 경우, async를 활용한 동시성 처리 API는 스레드를 사용할 때와 매우 유사하다. 하지만 다른 경우에는 상당히 다르게 동작한다. 스레드와 async의 API가 비슷해 보이더라도 실제 동작은 다르며, 성능 특성도 거의 항상 차이가 있다.

spawn_task를 사용해 새 작업 생성하기

새 스레드 생성하기에서 다룬 첫 번째 작업은 두 개의 별도 스레드에서 카운트를 세는 것이었다. 이번에는 async를 사용해 동일한 작업을 해보자. trpl 크레이트는 thread::spawn API와 매우 유사한 spawn_task 함수와 thread::sleep API의 async 버전인 sleep 함수를 제공한다. 이 두 함수를 함께 사용해 카운팅 예제를 구현할 수 있으며, 그 예는 리스트 17-6에서 확인할 수 있다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }
    });
}
Listing 17-6: 주 작업이 다른 내용을 출력하는 동안 새 작업을 생성해 한 가지를 출력하기

시작점으로, main 함수를 trpl::run으로 설정해 최상위 함수가 async가 되도록 한다.

참고: 이 장의 나머지 부분에서 모든 예제는 main 함수에 trpl::run을 포함한 동일한 래핑 코드를 사용할 것이다. 따라서 main 함수를 생략할 때도 이 코드를 포함해야 한다는 점을 잊지 말자!

그런 다음 해당 블록 안에 두 개의 루프를 작성한다. 각 루프에는 trpl::sleep 호출이 포함되어 있으며, 이는 다음 메시지를 보내기 전에 0.5초(500밀리초) 동안 기다린다. 하나의 루프는 trpl::spawn_task의 본문에 넣고, 다른 하나는 최상위 for 루프에 넣는다. 또한 sleep 호출 뒤에 await를 추가한다.

이 코드는 스레드 기반 구현과 유사하게 동작한다. 단, 실행 시 터미널에서 메시지가 다른 순서로 나타날 수 있다는 점도 포함된다:

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!

이 버전은 주 async 블록의 for 루프가 끝나자마자 종료된다. spawn_task로 생성된 작업은 main 함수가 끝나면 종료되기 때문이다. 작업이 완료될 때까지 실행되게 하려면, 첫 번째 작업이 완료될 때까지 기다리기 위해 조인 핸들을 사용해야 한다. 스레드에서는 join 메서드를 사용해 스레드가 실행을 마칠 때까지 “블로킹“했다. 리스트 17-7에서는 await를 사용해 동일한 작업을 수행할 수 있다. 작업 핸들 자체가 future이기 때문이다. Output 타입은 Result이므로, await 후에 이를 언래핑한다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let handle = trpl::spawn_task(async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        });

        for i in 1..5 {
            println!("hi number {i} from the second task!");
            trpl::sleep(Duration::from_millis(500)).await;
        }

        handle.await.unwrap();
    });
}
Listing 17-7: 조인 핸들에 await를 사용해 작업을 완료까지 실행하기

이 업데이트된 버전은 루프가 모두 끝날 때까지 실행된다.

hi number 1 from the second task!
hi number 1 from the first task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

지금까지 async와 스레드는 기본적으로 동일한 결과를 제공하지만, 구문만 다르다는 것을 알 수 있다: 조인 핸들에 join을 호출하는 대신 await를 사용하고, sleep 호출에 await를 사용한다.

더 큰 차이점은 이 작업을 위해 또 다른 운영체제 스레드를 생성할 필요가 없다는 점이다. 사실 여기서는 작업을 생성할 필요조차 없다. async 블록은 익명 future로 컴파일되기 때문에, 각 루프를 async 블록에 넣고 trpl::join 함수를 사용해 런타임이 두 루프를 모두 완료할 때까지 실행하도록 할 수 있다.

모든 스레드가 완료될 때까지 기다리기 섹션에서는 std::thread::spawn을 호출할 때 반환되는 JoinHandle 타입에 join 메서드를 사용하는 방법을 보여줬다. trpl::join 함수는 이와 유사하지만 future를 대상으로 한다. 두 future를 주면, 이들이 모두 완료된 후 각 future의 출력을 포함하는 튜플을 출력하는 단일 새로운 future를 생성한다. 따라서 리스트 17-8에서는 trpl::join을 사용해 fut1fut2가 모두 끝날 때까지 기다린다. fut1fut2await하는 대신, trpl::join이 생성한 새로운 future를 await한다. 출력은 두 단위 값을 포함하는 튜플이므로 이를 무시한다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let fut1 = async {
            for i in 1..10 {
                println!("hi number {i} from the first task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let fut2 = async {
            for i in 1..5 {
                println!("hi number {i} from the second task!");
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        trpl::join(fut1, fut2).await;
    });
}
Listing 17-8: trpl::join을 사용해 두 익명 future를 await하기

이 코드를 실행하면 두 future가 모두 완료될 때까지 실행되는 것을 볼 수 있다:

hi number 1 from the first task!
hi number 1 from the second task!
hi number 2 from the first task!
hi number 2 from the second task!
hi number 3 from the first task!
hi number 3 from the second task!
hi number 4 from the first task!
hi number 4 from the second task!
hi number 5 from the first task!
hi number 6 from the first task!
hi number 7 from the first task!
hi number 8 from the first task!
hi number 9 from the first task!

이제 매번 동일한 순서로 출력되는 것을 볼 수 있다. 이는 스레드에서 본 것과 매우 다르다. trpl::join 함수는 _공정_하기 때문이다. 즉, 각 future를 동일한 빈도로 확인하고 번갈아가며 실행하며, 한 future가 준비되어 있으면 다른 future가 앞서 나가지 않도록 한다. 스레드의 경우 운영체제가 어떤 스레드를 확인할지와 얼마나 오래 실행할지를 결정한다. async Rust에서는 런타임이 어떤 작업을 확인할지 결정한다. (실제로는 async 런타임이 내부적으로 운영체제 스레드를 사용해 동시성을 관리할 수 있으므로 공정성을 보장하는 것이 더 많은 작업이 될 수 있지만, 여전히 가능하다!) 런타임은 특정 작업에 대해 공정성을 보장할 필요는 없으며, 종종 공정성을 원하는지 여부를 선택할 수 있도록 다양한 API를 제공한다.

future를 await하는 이러한 변형을 시도해보고 어떤 결과가 나오는지 확인해보자:

  • 하나 또는 두 루프 주변의 async 블록을 제거한다.
  • async 블록을 정의한 직후에 바로 await한다.
  • 첫 번째 루프만 async 블록으로 감싸고, 두 번째 루프의 본문 이후에 결과 future를 await한다.

추가 도전으로, 코드를 실행하기 전에 각 경우의 출력이 어떻게 될지 예측해보자!

두 작업에서 메시지 전달을 사용해 카운팅하기

퓨처 간에 데이터를 공유하는 방법은 이미 익숙할 것이다. 이번에도 메시지 전달 방식을 사용하지만, 비동기 버전의 타입과 함수를 활용한다. 스레드 간 데이터 전달을 위한 메시지 전달에서 다룬 방식과는 약간 다른 접근법을 통해 스레드 기반 동시성과 퓨처 기반 동시성의 주요 차이점을 살펴본다. 리스트 17-9에서는 단일 async 블록으로 시작한다. 별도의 스레드를 생성하지 않고, 별도의 작업도 생성하지 않는다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let val = String::from("hi");
        tx.send(val).unwrap();

        let received = rx.recv().await.unwrap();
        println!("Got: {received}");
    });
}
Listing 17-9: 비동기 채널을 생성하고 txrx에 할당하기

여기서는 trpl::channel을 사용한다. 이는 16장에서 스레드와 함께 사용한 다중 생산자, 단일 소비자 채널 API의 비동기 버전이다. 비동기 버전 API는 스레드 기반 버전과 크게 다르지 않다. 불변 수신자 rx 대신 가변 수신자를 사용하며, recv 메서드는 값을 직접 반환하는 대신 퓨처를 생성해 await해야 한다. 이제 송신자에서 수신자로 메시지를 보낼 수 있다. 별도의 스레드나 작업을 생성할 필요 없이, 단순히 rx.recv 호출을 await하면 된다.

std::mpsc::channel의 동기식 Receiver::recv 메서드는 메시지를 받을 때까지 블로킹된다. 반면 trpl::Receiver::recv 메서드는 비동기식이므로 블로킹되지 않는다. 대신, 메시지를 받거나 채널의 송신 측이 닫힐 때까지 런타임에 제어권을 넘긴다. 반면 send 호출은 블로킹되지 않으므로 await하지 않는다. 우리가 사용하는 채널은 무제한이므로 블로킹할 필요가 없다.

참고: 이 모든 비동기 코드는 trpl::run 호출 내의 async 블록에서 실행되므로, 내부에서는 블로킹을 피할 수 있다. 그러나 외부 코드는 run 함수가 반환될 때까지 블로킹된다. 이것이 trpl::run 함수의 핵심이다. 이 함수를 사용하면 비동기 코드 세트에서 블로킹할 위치를 선택할 수 있으며, 동기 코드와 비동기 코드 간 전환 지점을 결정할 수 있다. 대부분의 비동기 런타임에서 run은 정확히 이 이유로 block_on이라고 불린다.

이 예제에서 두 가지를 주목하자. 첫째, 메시지는 즉시 도착한다. 둘째, 여기서 퓨처를 사용하지만 아직 동시성이 없다. 리스트의 모든 작업은 퓨처가 없을 때와 마찬가지로 순차적으로 실행된다.

첫 번째 부분을 해결하기 위해 일련의 메시지를 보내고 그 사이에 잠시 대기하는 방법을 알아보자. 리스트 17-10에서 이를 확인할 수 있다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("future"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            trpl::sleep(Duration::from_millis(500)).await;
        }

        while let Some(value) = rx.recv().await {
            println!("received '{value}'");
        }
    });
}
Listing 17-10: 비동기 채널을 통해 여러 메시지를 보내고 받으며 각 메시지 사이에 await로 대기하기

메시지를 보내는 것 외에도, 이를 받아야 한다. 이 경우에는 몇 개의 메시지가 오는지 알고 있으므로 rx.recv().await를 네 번 호출해 수동으로 처리할 수 있다. 그러나 실제 상황에서는 대개 알 수 없는 수의 메시지를 기다리므로, 더 이상 메시지가 없을 때까지 계속 대기해야 한다.

리스트 16-10에서는 동기 채널에서 받은 모든 항목을 처리하기 위해 for 루프를 사용했다. 그러나 Rust는 아직 비동기 항목 시퀀스에 대해 for 루프를 작성할 수 있는 방법을 제공하지 않으므로, 새로운 루프인 while let 조건 루프를 사용해야 한다. 이는 간결한 제어 흐름: if letlet else에서 본 if let 구문의 루프 버전이다. 이 루프는 지정된 패턴이 값과 계속 일치하는 한 실행을 계속한다.

rx.recv 호출은 퓨처를 생성하며, 이를 await한다. 런타임은 퓨처가 준비될 때까지 일시 중지한다. 메시지가 도착하면 퓨처는 Some(message)로 해결된다. 채널이 닫히면, 메시지가 도착했는지 여부와 상관없이 퓨처는 None으로 해결되어 더 이상 값이 없으므로 폴링(즉, await)을 중지해야 함을 나타낸다.

while let 루프는 이 모든 것을 하나로 묶는다. rx.recv().await 호출 결과가 Some(message)라면, 루프 본문에서 메시지에 접근해 사용할 수 있다. if let과 마찬가지로, 결과가 None이면 루프가 종료된다. 루프가 완료될 때마다 다시 await 지점에 도달하므로, 런타임은 다른 메시지가 도착할 때까지 다시 일시 중지한다.

이제 코드는 모든 메시지를 성공적으로 보내고 받는다. 그러나 아직 몇 가지 문제가 남아 있다. 첫째, 메시지가 0.5초 간격으로 도착하지 않는다. 프로그램 시작 후 2초(2,000밀리초)가 지나면 한꺼번에 도착한다. 둘째, 이 프로그램은 종료되지 않는다! 대신, 새로운 메시지를 영원히 기다린다. ctrl-c를 눌러 강제로 종료해야 한다.

먼저, 메시지가 각각의 지연 시간 사이에 도착하는 대신 전체 지연 시간 후에 한꺼번에 도착하는 이유를 살펴보자. 주어진 async 블록 내에서 await 키워드가 코드에 나타나는 순서는 프로그램 실행 시 실행 순서와 동일하다.

리스트 17-10에는 하나의 async 블록만 있으므로, 내부의 모든 작업은 선형적으로 실행된다. 여전히 동시성이 없다. 모든 tx.send 호출이 발생하고, 그 사이에 trpl::sleep 호출과 관련된 await 지점이 있다. 그런 후에야 while let 루프가 recv 호출의 await 지점을 처리할 수 있다.

각 메시지 사이에 지연 시간이 발생하는 동작을 얻으려면, txrx 작업을 각각의 async 블록에 배치해야 한다. 리스트 17-11에서 이를 확인할 수 있다. 이렇게 하면 런타임이 trpl::join을 사용해 각 블록을 별도로 실행할 수 있다. 다시 한번, trpl::join 호출 결과를 await하며, 개별 퓨처를 await하지 않는다. 개별 퓨처를 순차적으로 await하면, 우리가 피하려고 했던 순차적 흐름으로 돌아가게 된다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listing 17-11: sendrecv를 각각의 async 블록으로 분리하고 이 블록의 퓨처를 await하기

리스트 17-11의 업데이트된 코드를 통해 메시지는 2초 후에 한꺼번에 도착하는 대신 500밀리초 간격으로 출력된다.

그러나 프로그램은 여전히 종료되지 않는다. 이는 while let 루프와 trpl::join의 상호작용 방식 때문이다:

  • trpl::join에서 반환된 퓨처는 전달된 두 퓨처가 모두 완료된 후에야 완료된다.
  • tx 퓨처는 vals의 마지막 메시지를 보낸 후 잠시 대기한 후 완료된다.
  • rx 퓨처는 while let 루프가 종료될 때까지 완료되지 않는다.
  • while let 루프는 rx.recv를 await한 결과가 None이 될 때까지 종료되지 않는다.
  • rx.recv를 await한 결과가 None을 반환하려면 채널의 다른 쪽이 닫혀야 한다.
  • 채널은 rx.close를 호출하거나 송신 측인 tx가 삭제될 때만 닫힌다.
  • 우리는 어디에서도 rx.close를 호출하지 않으며, txtrpl::run에 전달된 가장 바깥쪽 async 블록이 종료될 때까지 삭제되지 않는다.
  • 이 블록은 trpl::join이 완료될 때까지 블로킹되므로 종료할 수 없다.

어딘가에서 rx.close를 호출해 수동으로 닫을 수 있지만, 이는 큰 의미가 없다. 임의의 수의 메시지를 처리한 후 프로그램을 종료하면 메시지를 놓칠 수 있다. 함수가 종료되기 전에 tx가 삭제되도록 하는 다른 방법이 필요하다.

현재, 메시지를 보내는 async 블록은 tx를 빌려 사용한다. 메시지를 보내는 데 소유권이 필요하지 않기 때문이다. 그러나 tx를 이 async 블록으로 이동시킬 수 있다면, 블록이 종료될 때 삭제될 것이다. 13장의 참조 캡처 또는 소유권 이동에서 클로저와 함께 move 키워드를 사용하는 방법을 배웠다. 16장의 스레드와 함께 move 클로저 사용하기에서도 스레드 작업 시 데이터를 클로저로 이동해야 하는 경우가 많다는 것을 확인했다. async 블록에도 동일한 기본 원칙이 적용되므로, move 키워드는 클로저와 마찬가지로 async 블록에서도 작동한다.

리스트 17-12에서는 메시지를 보내는 블록을 async에서 async move로 변경한다. 이 버전의 코드를 실행하면 마지막 메시지가 보내지고 받은 후에 정상적으로 종료된다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        trpl::join(tx_fut, rx_fut).await;
    });
}
Listing 17-12: 리스트 17-11의 코드를 수정해 완료 시 정상적으로 종료되도록 함

이 비동기 채널은 다중 생산자 채널이기도 하므로, 여러 퓨처에서 메시지를 보내려면 tx를 복제할 수 있다. 리스트 17-13에서 이를 확인할 수 있다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_millis(500)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_millis(1500)).await;
            }
        };

        trpl::join3(tx1_fut, tx_fut, rx_fut).await;
    });
}
Listing 17-13: 여러 생산자와 async 블록 사용하기

먼저, tx를 복제해 첫 번째 async 블록 외부에서 tx1을 생성한다. 이전에 tx를 이동시켰던 것처럼 tx1을 이 블록으로 이동시킨다. 그런 다음, 원래의 tx를 새로운 async 블록으로 이동시켜 약간 더 느린 지연 시간으로 더 많은 메시지를 보낸다. 이 새로운 async 블록은 메시지를 받는 async 블록 뒤에 배치했지만, 앞에 배치해도 상관없다. 핵심은 퓨처가 생성된 순서가 아니라 await된 순서다.

메시지를 보내는 두 async 블록 모두 async move 블록이어야 하므로, 두 블록이 완료될 때 txtx1이 삭제된다. 그렇지 않으면, 처음에 시작했던 무한 루프로 돌아가게 된다. 마지막으로, 추가 퓨처를 처리하기 위해 trpl::join 대신 trpl::join3으로 전환한다.

이제 두 송신 퓨처의 모든 메시지가 출력되며, 송신 퓨처가 약간 다른 지연 시간을 사용하므로 메시지도 다른 간격으로 받는다.

received 'hi'
received 'more'
received 'from'
received 'the'
received 'messages'
received 'future'
received 'for'
received 'you'

이는 좋은 시작이지만, join으로 두 개, join3으로 세 개의 퓨처로 제한된다. 더 많은 퓨처를 다루는 방법을 살펴보자.

여러 개의 Future 다루기

이전 섹션에서 두 개의 Future에서 세 개로 바꾸면서 join 대신 join3을 사용해야 했다. Future의 개수가 바뀔 때마다 다른 함수를 호출해야 한다면 번거로울 것이다. 다행히 join의 매크로 형태가 있어서 임의의 개수의 인자를 전달할 수 있다. 또한 Future를 직접 대기할 수도 있다. 따라서 리스트 17-13의 코드를 join3 대신 join!을 사용하도록 리스트 17-14와 같이 다시 작성할 수 있다.

<리스트 번호=“17-14” 캡션=“join!을 사용해 여러 Future를 대기하기” 파일명=“src/main.rs”>

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        trpl::join!(tx1_fut, tx_fut, rx_fut);
    });
}

</리스트>

이 방법은 join, join3, join4 등을 번갈아 사용하는 것보다 확실히 나아졌다! 하지만 이 매크로 형태도 Future의 개수를 미리 알고 있을 때만 작동한다. 실제 Rust에서는 Future를 컬렉션에 넣고 그 중 일부 또는 전체가 완료될 때까지 기다리는 패턴이 흔히 사용된다.

컬렉션에 있는 모든 Future를 확인하려면 모든 Future를 순회하며 대기해야 한다. trpl::join_all 함수는 Iterator 트레잇을 구현한 모든 타입을 받는다. 이 트레잇은 The Iterator Trait and the next Method 챕터 13에서 다뤘다. 따라서 이 함수가 적합해 보인다. Future를 벡터에 넣고 join! 대신 join_all을 사용해보자. 리스트 17-15와 같이 작성할 수 있다.

<리스트 번호=“17-15” 캡션=“익명 Future를 벡터에 저장하고 join_all 호출하기”>

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures = vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}

</리스트>

안타깝게도 이 코드는 컴파일되지 않는다. 대신 다음과 같은 에러가 발생한다:

error[E0308]: mismatched types
  --> src/main.rs:45:37
   |
10 |         let tx1_fut = async move {
   |                       ---------- the expected `async` block
...
24 |         let rx_fut = async {
   |                      ----- the found `async` block
...
45 |         let futures = vec![tx1_fut, rx_fut, tx_fut];
   |                                     ^^^^^^ expected `async` block, found a different `async` block
   |
   = note: expected `async` block `{async block@src/main.rs:10:23: 10:33}`
              found `async` block `{async block@src/main.rs:24:22: 24:27}`
   = note: no two async blocks, even if identical, have the same type
   = help: consider pinning your async block and casting it to a trait object

이 결과는 놀라울 수 있다. 결국 모든 async 블록은 아무것도 반환하지 않으므로 각각 Future<Output = ()>를 생성한다. 하지만 Future는 트레잇이며, 컴파일러는 각 async 블록에 대해 고유한 enum을 생성한다. 두 개의 서로 다른 구조체를 Vec에 넣을 수 없는 것처럼, 컴파일러가 생성한 서로 다른 enum도 같은 규칙이 적용된다.

이 문제를 해결하려면 _트레잇 객체_를 사용해야 한다. “Returning Errors from the run function” 챕터 12에서와 마찬가지로 트레잇 객체를 사용하면 각 익명 Future를 같은 타입으로 취급할 수 있다. 이는 모두 Future 트레잇을 구현하기 때문이다.

참고: Using an Enum to Store Multiple Values 챕터 8에서 Vec에 여러 타입을 포함하는 또 다른 방법을 논의했다. enum을 사용해 벡터에 나타날 수 있는 각 타입을 표현하는 방법이다. 하지만 여기서는 이 방법을 사용할 수 없다. 첫째, 익명 타입이므로 서로 다른 타입에 이름을 붙일 방법이 없다. 둘째, 벡터와 join_all을 사용한 이유는 동적으로 Future 컬렉션을 다루기 위함이었고, 출력 타입이 같기만 하면 된다.

먼저 vec! 안의 각 Future를 Box::new로 감싼다. 리스트 17-16과 같이 작성할 수 있다.

<리스트 번호=“17-16” 캡션=“Box::new를 사용해 Vec 안의 Future 타입을 맞추기” 파일명=“src/main.rs”>

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}

</리스트>

안타깝게도 이 코드도 여전히 컴파일되지 않는다. 사실 두 번째와 세 번째 Box::new 호출에서 이전과 같은 기본 에러가 발생하며, Unpin 트레잇과 관련된 새로운 에러도 나타난다. 잠시 후 Unpin 에러로 돌아올 것이다. 먼저 Box::new 호출에서 발생한 타입 에러를 해결하기 위해 futures 변수의 타입을 명시적으로 지정한다. 리스트 17-17과 같이 작성할 수 있다.

<리스트 번호=“17-17” 캡션=“명시적 타입 선언을 사용해 나머지 타입 불일치 에러 해결하기” 파일명=“src/main.rs”>

extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = async move {
            let vals = vec![
                String::from("hi"),
                String::from("from"),
                String::from("the"),
                String::from("future"),
            ];

            for val in vals {
                tx1.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let rx_fut = async {
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        };

        let tx_fut = async move {
            let vals = vec![
                String::from("more"),
                String::from("messages"),
                String::from("for"),
                String::from("you"),
            ];

            for val in vals {
                tx.send(val).unwrap();
                trpl::sleep(Duration::from_secs(1)).await;
            }
        };

        let futures: Vec<Box<dyn Future<Output = ()>>> =
            vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];

        trpl::join_all(futures).await;
    });
}

</리스트>

이 타입 선언은 조금 복잡하므로 단계별로 살펴보자:

  1. 가장 안쪽 타입은 Future 자체다. Future의 출력이 단위 타입 ()임을 Future<Output = ()>로 명시한다.
  2. 그런 다음 트레잇을 dyn으로 표시해 동적임을 나타낸다.
  3. 전체 트레잇 참조를 Box로 감싼다.
  4. 마지막으로 futures가 이러한 항목을 포함하는 Vec임을 명시한다.

이렇게 하면 큰 차이가 생긴다. 이제 컴파일러를 실행하면 Unpin과 관련된 에러만 나타난다. 세 개의 에러가 있지만 내용은 매우 비슷하다.

오류만 복사

경로 수정

–>

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
   --> src/main.rs:49:24
    |
49  |         trpl::join_all(futures).await;
    |         -------------- ^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
    |         |
    |         required by a bound introduced by this call
    |
    = note: consider using the `pin!` macro
            consider using `Box::pin` if you need to access the pinned value outside of the current scope
    = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `join_all`
   --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:105:14
    |
102 | pub fn join_all<I>(iter: I) -> JoinAll<I::Item>
    |        -------- required by a bound in this function
...
105 |     I::Item: Future,
    |              ^^^^^^ required by this bound in `join_all`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:9
   |
49 |         trpl::join_all(futures).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:49:33
   |
49 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `async_await` (bin "async_await") due to 3 previous errors

퓨처 경주

join 계열의 함수와 매크로를 사용해 퓨처를 “조인“할 때는 모든 퓨처가 완료될 때까지 기다린다. 하지만 때로는 여러 퓨처 중 하나만 완료되면 진행할 수 있는 상황이 있다. 이는 마치 퓨처끼리 경주를 시키는 것과 비슷하다.

리스트 17-21에서는 다시 trpl::race를 사용해 slowfast 두 퓨처를 서로 경주시킨다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            println!("'slow' started.");
            trpl::sleep(Duration::from_millis(100)).await;
            println!("'slow' finished.");
        };

        let fast = async {
            println!("'fast' started.");
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'fast' finished.");
        };

        trpl::race(slow, fast).await;
    });
}
Listing 17-21: race를 사용해 먼저 완료된 퓨처의 결과를 얻기

각 퓨처는 실행을 시작할 때 메시지를 출력하고, sleep을 호출하고 기다린 후, 완료되면 또 다른 메시지를 출력한다. 그런 다음 slowfasttrpl::race에 전달하고 둘 중 하나가 완료될 때까지 기다린다. (결과는 당연히 fast가 이긴다.) 첫 번째 비동기 프로그램에서 race를 사용했을 때와 달리, 여기서는 반환된 Either 인스턴스를 무시한다. 왜냐하면 모든 흥미로운 동작이 async 블록 내부에서 일어나기 때문이다.

race의 인자 순서를 바꾸면 “시작됨” 메시지의 순서가 바뀌지만, fast 퓨처는 항상 먼저 완료된다. 이는 이 특정 race 함수의 구현이 공정하지 않기 때문이다. 이 구현은 항상 전달된 순서대로 퓨처를 실행한다. 다른 구현들은 공정하며, 어떤 퓨처를 먼저 폴링할지 무작위로 선택한다. 하지만 우리가 사용하는 race 구현이 공정하든 아니든, 한 퓨처는 다른 태스크가 시작되기 전에 본문의 첫 번째 await까지 실행된다.

첫 번째 비동기 프로그램에서 기억할 수 있듯이, 각 await 지점에서 러스트는 런타임에게 태스크를 일시 중지하고, 기다리는 퓨처가 준비되지 않았다면 다른 태스크로 전환할 기회를 준다. 반대의 경우도 마찬가지다: 러스트는 async 블록을 일시 중지하고 await 지점에서만 런타임에 제어권을 넘긴다. await 지점 사이의 모든 작업은 동기적으로 실행된다.

즉, async 블록 내에서 await 지점 없이 많은 작업을 수행하면, 그 퓨처는 다른 퓨처가 진행하는 것을 막을 수 있다. 이를 종종 한 퓨처가 다른 퓨처를 굶주리게 만든다고 표현한다. 어떤 경우에는 이게 큰 문제가 되지 않을 수도 있다. 하지만 비용이 많이 드는 초기 설정이나 오래 실행되는 작업을 수행하거나, 특정 작업을 무한히 반복하는 퓨처가 있다면, 언제 어디서 런타임에 제어권을 넘길지 고민해야 한다.

마찬가지로, 오래 실행되는 블로킹 작업이 있다면, async는 프로그램의 다른 부분이 서로 관련을 맺을 수 있는 유용한 도구가 될 수 있다.

그렇다면 이런 경우에 어떻게 런타임에 제어권을 넘길 수 있을까?

런타임에 제어권 양보하기

긴 작업을 시뮬레이션해 보자. Listing 17-22는 slow 함수를 소개한다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        // We will call `slow` here later
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-22: thread::sleep을 사용해 느린 작업 시뮬레이션하기

이 코드는 trpl::sleep 대신 std::thread::sleep을 사용해 slow 함수를 호출하면 현재 스레드를 몇 밀리초 동안 블로킹한다. slow 함수는 실제로 오래 걸리고 블로킹되는 작업을 대신하는 역할을 한다.

Listing 17-23에서는 slow 함수를 사용해 두 개의 Future에서 CPU 집약적인 작업을 에뮬레이트한다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            slow("a", 10);
            slow("a", 20);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            slow("b", 10);
            slow("b", 15);
            slow("b", 350);
            trpl::sleep(Duration::from_millis(50)).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-23: thread::sleep을 사용해 느린 작업 시뮬레이션하기

처음에 각 Future는 여러 번의 느린 작업을 수행한 후에야 런타임에 제어권을 넘긴다. 이 코드를 실행하면 다음과 같은 출력을 볼 수 있다:

'a' started.
'a' ran for 30ms
'a' ran for 10ms
'a' ran for 20ms
'b' started.
'b' ran for 75ms
'b' ran for 10ms
'b' ran for 15ms
'b' ran for 350ms
'a' finished.

이전 예제와 마찬가지로 a가 완료되면 race도 즉시 종료된다. 하지만 두 Future 간에 작업이 교차하지는 않는다. a Future는 trpl::sleep 호출을 기다릴 때까지 모든 작업을 수행한 후, b Future가 자신의 trpl::sleep 호출을 기다릴 때까지 모든 작업을 수행하고, 마지막으로 a Future가 완료된다. 두 Future가 느린 작업 사이에 진행 상태를 유지하려면 런타임에 제어권을 넘길 수 있는 await 포인트가 필요하다. 즉, await할 수 있는 무언가가 필요하다!

Listing 17-23에서도 이런 제어권 전환이 일어나는 것을 볼 수 있다. a Future의 마지막에 있는 trpl::sleep을 제거하면 b Future가 전혀 실행되지 않고 a Future가 완료된다. Listing 17-24와 같이 sleep 함수를 사용해 작업이 번갈아가며 진행되도록 해보자.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let one_ms = Duration::from_millis(1);

        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::sleep(one_ms).await;
            slow("a", 10);
            trpl::sleep(one_ms).await;
            slow("a", 20);
            trpl::sleep(one_ms).await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::sleep(one_ms).await;
            slow("b", 10);
            trpl::sleep(one_ms).await;
            slow("b", 15);
            trpl::sleep(one_ms).await;
            slow("b", 350);
            trpl::sleep(one_ms).await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-24: sleep을 사용해 작업이 번갈아가며 진행되도록 하기

Listing 17-24에서는 각 slow 호출 사이에 await 포인트와 함께 trpl::sleep 호출을 추가한다. 이제 두 Future의 작업이 교차해서 진행된다:

'a' started.
'a' ran for 30ms
'b' started.
'b' ran for 75ms
'a' ran for 10ms
'b' ran for 10ms
'a' ran for 20ms
'b' ran for 15ms
'a' finished.

a Future는 trpl::sleep을 호출하기 전에 slow를 호출하므로 b에 제어권을 넘기기 전에 조금 실행되지만, 그 이후에는 각 Future가 await 포인트에 도달할 때마다 번갈아가며 실행된다. 이 경우에는 모든 slow 호출 후에 await 포인트를 추가했지만, 작업을 어떻게 나누는지는 상황에 따라 달라질 수 있다.

하지만 여기서 우리가 원하는 것은 _sleep_이 아니라 가능한 한 빨리 작업을 진행하는 것이다. 단지 런타임에 제어권을 넘기기만 하면 된다. 이를 직접적으로 수행하기 위해 yield_now 함수를 사용할 수 있다. Listing 17-25에서는 모든 sleep 호출을 yield_now로 대체한다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::run(async {
        let a = async {
            println!("'a' started.");
            slow("a", 30);
            trpl::yield_now().await;
            slow("a", 10);
            trpl::yield_now().await;
            slow("a", 20);
            trpl::yield_now().await;
            println!("'a' finished.");
        };

        let b = async {
            println!("'b' started.");
            slow("b", 75);
            trpl::yield_now().await;
            slow("b", 10);
            trpl::yield_now().await;
            slow("b", 15);
            trpl::yield_now().await;
            slow("b", 350);
            trpl::yield_now().await;
            println!("'b' finished.");
        };

        trpl::race(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-25: yield_now를 사용해 작업이 번갈아가며 진행되도록 하기

이 코드는 실제 의도를 더 명확히 표현할 뿐만 아니라 sleep을 사용하는 것보다 훨씬 빠를 수 있다. 왜냐하면 sleep이 사용하는 타이머는 종종 최소 단위에 제한이 있기 때문이다. 예를 들어, 우리가 사용하는 sleep 버전은 1나노초의 Duration을 전달하더라도 항상 최소 1밀리초 동안 sleep한다. 다시 말하지만, 현대 컴퓨터는 매우 빠르다: 1밀리초 동안 많은 작업을 수행할 수 있다!

Listing 17-26과 같은 간단한 벤치마크를 설정해 이를 직접 확인할 수 있다. (이 방법은 성능 테스트를 수행하는 데 특히 엄격한 방법은 아니지만, 여기서 차이를 보여주기에는 충분하다.)

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::{Duration, Instant};

fn main() {
    trpl::run(async {
        let one_ns = Duration::from_nanos(1);
        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::sleep(one_ns).await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'sleep' version finished after {} seconds.",
            time.as_secs_f32()
        );

        let start = Instant::now();
        async {
            for _ in 1..1000 {
                trpl::yield_now().await;
            }
        }
        .await;
        let time = Instant::now() - start;
        println!(
            "'yield' version finished after {} seconds.",
            time.as_secs_f32()
        );
    });
}
Listing 17-26: sleepyield_now의 성능 비교

여기서는 모든 상태 출력을 생략하고, trpl::sleep에 1나노초의 Duration을 전달한 후, 각 Future를 별도로 실행한다. 그리고 1,000번 반복 실행해 trpl::sleep을 사용한 Future와 trpl::yield_now를 사용한 Future의 실행 시간을 비교한다.

yield_now를 사용한 버전이 훨씬 빠르다!

이는 async가 컴퓨팅 집약적인 작업에서도 유용할 수 있음을 보여준다. 프로그램의 다른 부분과의 관계를 구조화하는 데 유용한 도구를 제공하기 때문이다. 이는 _협력적 멀티태스킹(cooperative multitasking)_의 한 형태로, 각 Future가 await 포인트를 통해 제어권을 넘길 시점을 결정할 수 있다. 따라서 각 Future는 너무 오래 블로킹하지 않도록 주의해야 한다. 일부 Rust 기반 임베디드 운영체제에서는 이 방식이 유일한 멀티태스킹 방식이다!

실제 코드에서는 보통 모든 줄마다 함수 호출과 await 포인트를 번갈아가며 사용하지는 않는다. 이렇게 제어권을 양보하는 것은 상대적으로 비용이 적지만, 무료는 아니다. 많은 경우, 컴퓨팅 집약적인 작업을 나누려고 하면 오히려 전체 성능이 크게 저하될 수 있으므로, 때로는 작업이 잠시 블로킹되는 것이 전체 성능에 더 나을 수 있다. 항상 실제 성능 병목 현상을 측정해 보아야 한다. 하지만 만약 예상한 동시성이 아닌 직렬로 많은 작업이 수행되고 있다면, 이 기본 동작을 염두에 두는 것이 중요하다!

우리만의 비동기 추상화 만들기

우리는 이미 갖고 있는 비동기 빌딩 블록을 조합해 새로운 패턴을 만들 수 있다. 예를 들어, timeout 함수를 만들 수 있다. 이 작업이 끝나면, 결과적으로 우리가 사용할 수 있는 또 다른 빌딩 블록이 생긴다. 이를 통해 더 많은 비동기 추상화를 만들 수 있다.

Listing 17-27은 우리가 상상한 timeout 함수가 느린 future와 함께 어떻게 동작할지 보여준다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_millis(100)).await;
            "I finished!"
        };

        match timeout(slow, Duration::from_millis(10)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}
Listing 17-27: 상상한 timeout을 사용해 시간 제한 내에 느린 작업 실행하기

이제 이를 구현해보자. 먼저 timeout의 API를 생각해보자:

  • 비동기 함수여야 한다. 그래서 await를 사용할 수 있다.
  • 첫 번째 매개변수로 실행할 future를 받아야 한다. 모든 future와 호환되도록 제네릭으로 만들 수 있다.
  • 두 번째 매개변수는 최대 대기 시간이다. Duration을 사용하면 trpl::sleep에 쉽게 전달할 수 있다.
  • Result를 반환해야 한다. future가 성공적으로 완료되면 ResultOk와 함께 future가 생성한 값을 반환한다. 만약 타임아웃이 먼저 발생하면 ResultErr와 함께 타임아웃이 대기한 시간을 반환한다.

Listing 17-28은 이 선언을 보여준다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_millis(10)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    // Here is where our implementation will go!
}
Listing 17-28: timeout의 시그니처 정의

이렇게 하면 타입에 대한 목표를 충족한다. 이제 우리가 필요한 _동작_을 생각해보자: 전달된 future와 duration을 경쟁시키고 싶다. trpl::sleep을 사용해 duration으로부터 타이머 future를 만들고, trpl::race를 사용해 이 타이머와 호출자가 전달한 future를 함께 실행할 수 있다.

또한 race는 공정하지 않으며, 전달된 순서대로 인수를 폴링한다는 것을 알고 있다. 따라서 future_to_try를 먼저 race에 전달해 max_time이 매우 짧은 duration일 때도 완료할 기회를 준다. future_to_try가 먼저 완료되면 raceLeft와 함께 future_to_try의 출력을 반환한다. timer가 먼저 완료되면 raceRight와 함께 타이머의 출력인 ()를 반환한다.

Listing 17-29에서는 trpl::raceawait한 결과를 매칭한다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

use trpl::Either;

// --snip--

fn main() {
    trpl::run(async {
        let slow = async {
            trpl::sleep(Duration::from_secs(5)).await;
            "Finally finished"
        };

        match timeout(slow, Duration::from_secs(2)).await {
            Ok(message) => println!("Succeeded with '{message}'"),
            Err(duration) => {
                println!("Failed after {} seconds", duration.as_secs())
            }
        }
    });
}

async fn timeout<F: Future>(
    future_to_try: F,
    max_time: Duration,
) -> Result<F::Output, Duration> {
    match trpl::race(future_to_try, trpl::sleep(max_time)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(max_time),
    }
}
Listing 17-29: racesleep을 사용해 timeout 정의하기

future_to_try가 성공하고 Left(output)를 얻으면 Ok(output)를 반환한다. 대신 sleep 타이머가 만료되고 Right(())를 얻으면 ()_로 무시하고 Err(max_time)를 반환한다.

이렇게 하면 두 개의 다른 비동기 헬퍼를 사용해 동작하는 timeout을 만들었다. 코드를 실행하면 타임아웃 후 실패 모드를 출력할 것이다:

Failed after 2 seconds

future는 다른 future와 조합될 수 있으므로, 작은 비동기 빌딩 블록을 사용해 매우 강력한 도구를 만들 수 있다. 예를 들어, 이와 같은 접근 방식을 사용해 타임아웃과 재시도를 결합하고, 이를 네트워크 호출과 같은 작업에 사용할 수 있다(이 장의 시작 부분에서 다룬 예제 중 하나).

실제로는 주로 asyncawait를 직접 사용하고, join, join_all, race와 같은 함수와 매크로를 보조적으로 사용할 것이다. pin은 가끔 이러한 API와 함께 future를 사용할 때만 필요하다.

이제 여러 future를 동시에 작업하는 여러 방법을 살펴보았다. 다음으로는 _스트림_을 사용해 시간에 따라 여러 future를 순차적으로 작업하는 방법을 살펴볼 것이다. 하지만 먼저 고려해볼 몇 가지 사항이 있다:

  • join_all과 함께 Vec를 사용해 특정 그룹의 모든 future가 완료되기를 기다렸다. Vec를 사용해 그룹의 future를 순차적으로 처리하려면 어떻게 해야 할까? 그렇게 했을 때의 장단점은 무엇인가?

  • futures 크레이트의 futures::stream::FuturesUnordered 타입을 살펴보자. Vec를 사용하는 것과 어떻게 다를까? (이 타입이 크레이트의 stream 부분에 있다는 사실은 걱정하지 않아도 된다. future의 어떤 컬렉션과도 잘 동작한다.)

스트림: 순차적 Future

이번 장에서는 주로 개별 Future에 대해 다뤘다. 한 가지 큰 예외는 이전에 사용했던 비동기 채널이다. “메시지 전달” 섹션에서 비동기 채널의 리시버를 어떻게 사용했는지 기억할 것이다. 비동기 recv 메서드는 시간에 따라 일련의 아이템을 생성한다. 이는 스트림 이라는 더 일반적인 패턴의 한 예시다.

13장에서 Iterator 트레이트를 다룰 때 일련의 아이템을 본 적이 있다. 하지만 이터레이터와 비동기 채널 리시버 사이에는 두 가지 차이점이 있다. 첫 번째 차이점은 시간이다. 이터레이터는 동기적이지만, 채널 리시버는 비동기적이다. 두 번째 차이점은 API다. Iterator를 직접 사용할 때는 동기적인 next 메서드를 호출한다. 반면 trpl::Receiver 스트림에서는 비동기적인 recv 메서드를 대신 호출한다. 그 외에는 이 두 API가 매우 유사하며, 이 유사성은 우연이 아니다. 스트림은 비동기적인 형태의 이터레이션과 같다. trpl::Receiver가 특정 메시지를 수신하기 위해 대기하는 반면, 일반적인 스트림 API는 훨씬 더 넓은 범위를 제공한다. 이 API는 Iterator와 같은 방식으로 다음 아이템을 제공하지만, 비동기적으로 작동한다.

Rust에서 이터레이터와 스트림의 유사성은 실제로 어떤 이터레이터든 스트림으로 변환할 수 있다는 것을 의미한다. 이터레이터와 마찬가지로, 스트림의 next 메서드를 호출하고 출력을 기다리는 방식으로 작업할 수 있다. 이는 아래 예제 17-30에서 확인할 수 있다.

<예제 번호=“17-30” 설명=“이터레이터로부터 스트림을 생성하고 값을 출력하는 예제” 파일 이름=“src/main.rs”>

extern crate trpl; // required for mdbook test

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}

</예제>

숫자 배열로 시작해 이터레이터로 변환한 후, map을 호출해 모든 값을 두 배로 만든다. 그런 다음 trpl::stream_from_iter 함수를 사용해 이터레이터를 스트림으로 변환한다. 다음으로, while let 루프를 사용해 스트림의 아이템이 도착할 때마다 반복한다.

안타깝게도 이 코드를 실행하려고 하면 컴파일되지 않고, next 메서드가 없다는 오류가 발생한다.

error[E0599]: no method named `next` found for struct `Iter` in the current scope
  --> src/main.rs:10:40
   |
10 |         while let Some(value) = stream.next().await {
   |                                        ^^^^
   |
   = note: the full type name has been written to 'file:///projects/async-await/target/debug/deps/async_await-575db3dd3197d257.long-type-14490787947592691573.txt'
   = note: consider using `--verbose` to print the full type name to the console
   = help: items from traits can only be used if the trait is in scope
help: the following traits which provide `next` are implemented but not in scope; perhaps you want to import one of them
   |
1  + use crate::trpl::StreamExt;
   |
1  + use futures_util::stream::stream::StreamExt;
   |
1  + use std::iter::Iterator;
   |
1  + use std::str::pattern::Searcher;
   |
help: there is a method `try_next` with a similar name
   |
10 |         while let Some(value) = stream.try_next().await {
   |                                        ~~~~~~~~

이 출력에서 설명하듯, 컴파일러 오류의 원인은 next 메서드를 사용하려면 적절한 트레이트가 스코프 내에 있어야 한다는 것이다. 지금까지의 논의를 고려하면, 이 트레이트가 Stream일 것이라고 예상할 수 있지만, 실제로는 StreamExt다. extension 의 약자인 Ext는 Rust 커뮤니티에서 한 트레이트를 다른 트레이트로 확장하는 일반적인 패턴이다.

StreamStreamExt 트레이트에 대해서는 이 장의 끝에서 좀 더 자세히 설명할 것이다. 지금은 Stream 트레이트가 IteratorFuture 트레이트를 효과적으로 결합한 저수준 인터페이스를 정의한다는 것만 알면 된다. StreamExtStream 위에 next 메서드와 Iterator 트레이트에서 제공하는 유틸리티 메서드와 유사한 고수준 API 세트를 제공한다. StreamStreamExt는 아직 Rust의 표준 라이브러리에 포함되지 않았지만, 대부분의 생태계 크레이트가 동일한 정의를 사용한다.

컴파일러 오류를 수정하려면 trpl::StreamExt에 대한 use 문을 추가해야 한다. 이는 아래 예제 17-31에서 확인할 수 있다.

<예제 번호=“17-31” 설명=“이터레이터를 스트림의 기반으로 성공적으로 사용하는 예제” 파일 이름=“src/main.rs”>

extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        let iter = values.iter().map(|n| n * 2);
        let mut stream = trpl::stream_from_iter(iter);

        while let Some(value) = stream.next().await {
            println!("The value was: {value}");
        }
    });
}

</예제>

이 모든 조각을 합치면, 이 코드는 원하는 대로 작동한다! 더 나아가, 이제 StreamExt가 스코프 내에 있으므로, 이터레이터와 마찬가지로 모든 유틸리티 메서드를 사용할 수 있다. 예를 들어, 예제 17-32에서는 filter 메서드를 사용해 3과 5의 배수만 남기도록 필터링한다.

<예제 번호=“17-32” 설명=“StreamExt::filter 메서드로 스트림 필터링하기” 파일 이름=“src/main.rs”>

extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::run(async {
        let values = 1..101;
        let iter = values.map(|n| n * 2);
        let stream = trpl::stream_from_iter(iter);

        let mut filtered =
            stream.filter(|value| value % 3 == 0 || value % 5 == 0);

        while let Some(value) = filtered.next().await {
            println!("The value was: {value}");
        }
    });
}

</예제>

물론, 이는 일반 이터레이터로도 할 수 있고, 비동기와는 전혀 무관하므로 그다지 흥미롭지 않다. 이제 스트림만의 고유한 기능을 살펴보자.

스트림 합성하기

많은 개념은 자연스럽게 스트림으로 표현할 수 있다. 큐에서 아이템이 사용 가능해지는 경우, 전체 데이터가 컴퓨터 메모리보다 클 때 파일 시스템에서 데이터를 점진적으로 가져오는 경우, 네트워크를 통해 시간이 지남에 따라 데이터가 도착하는 경우 등이 그 예이다. 스트림은 Future이기 때문에 다른 종류의 Future와 함께 사용할 수 있고, 흥미로운 방식으로 조합할 수 있다. 예를 들어, 너무 많은 네트워크 호출을 방지하기 위해 이벤트를 일괄 처리하거나, 장기 실행 작업 시퀀스에 타임아웃을 설정하거나, 불필요한 작업을 피하기 위해 사용자 인터페이스 이벤트를 조절할 수 있다.

먼저 WebSocket이나 다른 실시간 통신 프로토콜에서 볼 수 있는 데이터 스트림을 대체할 메시지 스트림을 만들어 보자. 이는 리스팅 17-33에 나와 있다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages = get_messages();

        while let Some(message) = messages.next().await {
            println!("{message}");
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}
Listing 17-33: rx 수신기를 ReceiverStream으로 사용하기

먼저 get_messages라는 함수를 만들고, 이 함수는 impl Stream<Item = String>을 반환한다. 구현에서는 비동기 채널을 생성하고, 영어 알파벳의 첫 10글자를 반복하며 채널을 통해 전송한다.

또한 ReceiverStream이라는 새로운 타입을 사용한다. 이 타입은 trpl::channelrx 수신기를 next 메서드가 있는 Stream으로 변환한다. main 함수에서는 while let 루프를 사용해 스트림에서 모든 메시지를 출력한다.

이 코드를 실행하면 예상한 결과를 얻을 수 있다:

Message: 'a'
Message: 'b'
Message: 'c'
Message: 'd'
Message: 'e'
Message: 'f'
Message: 'g'
Message: 'h'
Message: 'i'
Message: 'j'

하지만 이 작업은 일반 Receiver API나 심지어 일반 Iterator API로도 할 수 있다. 따라서 스트림이 필요한 기능을 추가해 보자: 스트림의 각 아이템에 타임아웃을 적용하고, 전송하는 아이템에 지연을 추가한다. 이는 리스팅 17-34에 나와 있다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};
use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
    for message in messages {
        tx.send(format!("Message: '{message}'")).unwrap();
    }

    ReceiverStream::new(rx)
}
Listing 17-34: 스트림의 아이템에 시간 제한을 설정하기 위해 StreamExt::timeout 메서드 사용하기

먼저 StreamExt 트레이트의 timeout 메서드를 사용해 스트림에 타임아웃을 추가한다. 그런 다음 while let 루프의 본문을 업데이트한다. 이제 스트림은 Result를 반환한다. Ok 변형은 메시지가 시간 내에 도착했음을 나타내고, Err 변형은 타임아웃이 지나기 전에 메시지가 도착하지 않았음을 나타낸다. 이 결과를 match로 처리하고, 메시지를 성공적으로 받으면 출력하고, 타임아웃이 발생하면 알림을 출력한다. 마지막으로, 타임아웃을 적용한 후 메시지를 고정한다. 타임아웃 헬퍼는 폴링되기 위해 고정이 필요한 스트림을 생성하기 때문이다.

하지만 메시지 사이에 지연이 없기 때문에 이 타임아웃은 프로그램의 동작을 변경하지 않는다. 이제 전송하는 메시지에 변수 지연을 추가해 보자. 이는 리스팅 17-35에 나와 있다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-35: get_messages를 비동기 함수로 만들지 않고 tx를 통해 메시지를 비동기 지연과 함께 전송하기

get_messages 함수에서 messages 배열과 함께 enumerate 이터레이터 메서드를 사용해 전송하는 각 아이템의 인덱스와 아이템 자체를 얻는다. 그런 다음 짝수 인덱스 아이템에는 100밀리초 지연을, 홀수 인덱스 아이템에는 300밀리초 지연을 적용해 실제 세계의 메시지 스트림에서 볼 수 있는 다양한 지연을 시뮬레이트한다. 타임아웃이 200밀리초이기 때문에 이는 메시지의 절반에 영향을 미칠 것이다.

get_messages 함수에서 메시지 사이에 블로킹 없이 지연을 추가하려면 비동기를 사용해야 한다. 하지만 get_messages 자체를 비동기 함수로 만들 수는 없다. 그렇게 하면 Future<Output = Stream<Item = String>>을 반환하게 되고, 스트림 자체가 아니라 스트림에 접근하려면 get_messages를 기다려야 하기 때문이다. 하지만 기억하자: 주어진 Future 내의 모든 작업은 선형적으로 발생한다. 동시성은 Future _사이_에서 발생한다. get_messages를 기다리면 모든 메시지와 각 메시지 사이의 지연을 전송한 후에야 수신 스트림을 반환하게 된다. 결과적으로 타임아웃은 쓸모 없게 된다. 스트림 자체에는 지연이 없을 것이다. 모든 지연은 스트림이 사용 가능해지기 전에 발생할 것이다.

대신, get_messages를 스트림을 반환하는 일반 함수로 두고, 비동기 sleep 호출을 처리하는 작업을 생성한다.

참고: 이 방식으로 spawn_task를 호출하는 것은 런타임을 이미 설정했기 때문에 가능하다. 그렇지 않으면 패닉이 발생할 것이다. 다른 구현은 다른 트레이드오프를 선택한다: 새로운 런타임을 생성하고 패닉을 피하지만 약간의 추가 오버헤드를 발생시키거나, 런타임에 대한 참조 없이 작업을 생성할 수 있는 독립적인 방법을 제공하지 않을 수도 있다. 사용하는 런타임이 어떤 트레이드오프를 선택했는지 알고 그에 맞게 코드를 작성해야 한다!

이제 코드의 결과가 훨씬 더 흥미로워진다. 매번 다른 메시지 쌍 사이에 Problem: Elapsed(()) 오류가 발생한다.

Message: 'a'
Problem: Elapsed(())
Message: 'b'
Message: 'c'
Problem: Elapsed(())
Message: 'd'
Message: 'e'
Problem: Elapsed(())
Message: 'f'
Message: 'g'
Problem: Elapsed(())
Message: 'h'
Message: 'i'
Problem: Elapsed(())
Message: 'j'

타임아웃은 메시지가 결국 도착하는 것을 막지 않는다. 여전히 모든 원래 메시지를 받는다. 왜냐하면 채널이 _무제한_이기 때문이다: 메모리에 맞는 한 많은 메시지를 보유할 수 있다. 메시지가 타임아웃 전에 도착하지 않으면 스트림 핸들러는 이를 처리하지만, 스트림을 다시 폴링할 때 메시지가 도착했을 수 있다.

필요한 경우 다른 종류의 채널이나 일반적으로 다른 종류의 스트림을 사용해 다른 동작을 얻을 수 있다. 이제 시간 간격 스트림과 이 메시지 스트림을 결합해 실제로 하나를 살펴보자.

스트림 병합하기

먼저, 직접 실행할 경우 매 밀리초마다 아이템을 방출하는 스트림을 생성한다. 간단하게 sleep 함수를 사용해 메시지를 지연 전송하고, get_messages에서 사용한 채널 기반 스트림 생성 방식을 활용한다. 이번에는 경과한 간격의 카운트를 반환하므로, 반환 타입은 impl Stream<Item = u32>가 되고, 이 함수를 get_intervals라고 부른다(리스트 17-36 참조).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let mut messages =
            pin!(get_messages().timeout(Duration::from_millis(200)));

        while let Some(result) = messages.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-36: 매 밀리초마다 카운트를 방출하는 스트림 생성

먼저 태스크 내부에 count를 정의한다. (태스크 외부에서 정의할 수도 있지만, 변수의 범위를 제한하는 것이 더 명확하다.) 그런 다음 무한 루프를 생성한다. 루프의 각 반복은 비동기적으로 1밀리초 동안 대기하고, 카운트를 증가시킨 후 채널을 통해 전송한다. 이 모든 것이 spawn_task로 생성된 태스크에 포함되므로, 무한 루프를 포함한 모든 것이 런타임과 함께 정리된다.

이런 종류의 무한 루프는 전체 런타임이 종료될 때까지 계속 실행되며, 비동기 Rust에서 꽤 흔히 볼 수 있다. 많은 프로그램이 무한히 실행되어야 하기 때문이다. 비동기에서는 루프의 각 반복에 최소 하나의 await 포인트가 있으면 다른 작업을 블로킹하지 않는다.

이제 메인 함수의 async 블록으로 돌아가 messagesintervals 스트림을 병합해 본다(리스트 17-37 참조).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals();
        let merged = messages.merge(intervals);

        while let Some(result) = merged.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-37: messagesintervals 스트림 병합 시도

먼저 get_intervals를 호출한다. 그런 다음 messagesintervals 스트림을 merge 메서드로 병합한다. 이 메서드는 여러 스트림을 하나로 합치며, 소스 스트림에서 아이템이 사용 가능해지면 순서에 관계없이 즉시 방출한다. 마지막으로 messages 대신 병합된 스트림을 루프로 처리한다.

이 시점에서 messagesintervals는 고정되거나 변경 가능할 필요가 없다. 두 스트림이 단일 merged 스트림으로 결합되기 때문이다. 그러나 이 merge 호출은 컴파일되지 않는다! (while let 루프의 next 호출도 마찬가지지만, 이 문제는 나중에 다시 다룬다.) 이는 두 스트림의 타입이 다르기 때문이다. messages 스트림은 Timeout<impl Stream<Item = String>> 타입을 가지며, Timeouttimeout 호출에 대해 Stream을 구현한 타입이다. intervals 스트림은 impl Stream<Item = u32> 타입을 가진다. 이 두 스트림을 병합하려면 하나의 타입을 다른 타입과 맞춰야 한다. messages는 이미 원하는 기본 형식이며 타임아웃 오류를 처리해야 하므로, intervals 스트림을 수정한다(리스트 17-38 참조).

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-38: intervals 스트림의 타입을 messages 스트림과 맞추기

먼저 map 헬퍼 메서드를 사용해 intervals를 문자열로 변환한다. 두 번째로, messagesTimeout과 일치시켜야 한다. 그러나 intervals에 실제로 타임아웃을 원하지 않으므로, 사용 중인 다른 지속 시간보다 긴 타임아웃을 생성한다. 여기서는 Duration::from_secs(10)로 10초 타임아웃을 생성한다. 마지막으로 while let 루프의 next 호출이 스트림을 반복할 수 있도록 stream을 변경 가능하게 만들고, 안전하게 처리할 수 있도록 고정한다. 이렇게 하면 거의 필요한 지점에 도달한다. 모든 것이 타입 검사를 통과한다. 그러나 이 코드를 실행하면 두 가지 문제가 발생한다. 첫째, 절대 멈추지 않는다! ctrl-c로 중지해야 한다. 둘째, 영어 알파벳 메시지가 모든 간격 카운터 메시지 사이에 묻힌다.

--snip--
Interval: 38
Interval: 39
Interval: 40
Message: 'a'
Interval: 41
Interval: 42
Interval: 43
--snip--

리스트 17-39는 이 두 문제를 해결하는 한 가지 방법을 보여준다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval: {count}"))
            .throttle(Duration::from_millis(100))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(message) => println!("{message}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    })
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            tx.send(format!("Message: '{message}'")).unwrap();
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;
            tx.send(count).unwrap();
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-39: throttletake를 사용해 병합된 스트림 관리

먼저 intervals 스트림에 throttle 메서드를 적용해 messages 스트림을 압도하지 않도록 한다. _쓰로틀링_은 함수 호출 빈도를 제한하는 방법이다. 이 경우 스트림이 폴링되는 빈도를 제한한다. 메시지가 대략 100밀리초마다 도착하므로, 100밀리초마다 한 번씩 처리하도록 설정한다.

스트림에서 받을 아이템의 수를 제한하기 위해 merged 스트림에 take 메서드를 적용한다. 최종 출력만 제한하려는 것이지, 하나의 스트림만 제한하려는 것이 아니기 때문이다.

이제 프로그램을 실행하면 스트림에서 20개의 아이템을 가져온 후 멈추고, 간격 메시지가 메시지를 압도하지 않는다. 또한 Interval: 100이나 Interval: 200 같은 메시지 대신 Interval: 1, Interval: 2 등이 출력된다. 원본 스트림이 매 밀리초마다 이벤트를 생성할 수 있음에도 불구하고 말이다. 이는 throttle 호출이 원본 스트림을 감싸는 새로운 스트림을 생성하기 때문이다. 원본 스트림은 자체 “기본” 속도가 아니라 쓰로틀 속도로만 폴링된다. 처리되지 않은 간격 메시지를 무시하는 것이 아니라, 처음부터 그런 메시지를 생성하지 않는다. 이는 Rust 퓨처의 고유한 “게으름“이 다시 작동하는 것으로, 성능 특성을 선택할 수 있게 해준다.

Interval: 1
Message: 'a'
Interval: 2
Interval: 3
Problem: Elapsed(())
Interval: 4
Message: 'b'
Interval: 5
Message: 'c'
Interval: 6
Interval: 7
Problem: Elapsed(())
Interval: 8
Message: 'd'
Interval: 9
Message: 'e'
Interval: 10
Interval: 11
Problem: Elapsed(())
Interval: 12

마지막으로 처리해야 할 것은 오류다! 이 두 채널 기반 스트림에서 send 호출은 채널의 반대쪽이 닫히면 실패할 수 있다. 이는 스트림을 구성하는 퓨처가 런타임에서 어떻게 실행되는지에 달려 있다. 지금까지는 unwrap을 호출해 이 가능성을 무시했지만, 잘 동작하는 앱에서는 최소한 루프를 종료해 더 이상 메시지를 보내지 않도록 명시적으로 오류를 처리해야 한다. 리스트 17-40은 간단한 오류 처리 전략을 보여준다. 문제를 출력한 후 루프에서 break한다.

extern crate trpl; // required for mdbook test

use std::{pin::pin, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let mut count = 0;
        loop {
            trpl::sleep(Duration::from_millis(1)).await;
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-40: 오류 처리 및 루프 종료

일반적으로 메시지 전송 오류를 처리하는 올바른 방법은 다양하므로, 반드시 전략을 세워야 한다.

이제 실제로 비동기 코드를 많이 살펴봤으니, 한 걸음 물러나 Future, Stream, 그리고 Rust가 비동기를 가능하게 하는 다른 주요 트레이트의 세부 사항을 깊이 있게 알아본다.

Async 트레이트 심층 분석

이번 장에서는 Future, Pin, Unpin, Stream, StreamExt 트레이트를 다양한 방식으로 사용했다. 지금까지는 이들이 어떻게 작동하고 서로 어떻게 연결되는지에 대한 세부 사항을 깊이 다루지 않았는데, 대부분의 경우 일상적인 Rust 작업에는 이 정도로 충분하다. 하지만 때로는 이러한 세부 사항을 더 깊이 이해해야 하는 상황이 발생한다. 이번 섹션에서는 그러한 시나리오에 도움이 될 만큼만 자세히 살펴보고, 진짜 깊이 있는 내용은 다른 문서에 맡긴다.

Future 트레이트

먼저 Future 트레이트가 어떻게 동작하는지 자세히 살펴보자. Rust에서 Future는 다음과 같이 정의한다:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

이 트레이트 정의에는 새로운 타입과 이전에 보지 못한 구문이 포함되어 있다. 따라서 정의를 하나씩 살펴보자.

먼저, Future의 연관 타입인 Output은 future가 최종적으로 어떤 값을 반환할지 지정한다. 이는 Iterator 트레이트의 Item 연관 타입과 유사하다. 두 번째로, Futurepoll 메서드를 가지고 있다. 이 메서드는 self 매개변수로 특별한 Pin 참조를 받고, Context 타입의 가변 참조를 받으며, Poll<Self::Output>을 반환한다. PinContext에 대해서는 잠시 후에 더 자세히 알아볼 것이다. 지금은 메서드가 반환하는 Poll 타입에 집중해 보자:

#![allow(unused)]
fn main() {
enum Poll<T> {
    Ready(T),
    Pending,
}
}

Poll 타입은 Option과 비슷하다. 값을 가지는 Ready(T)와 값을 가지지 않는 Pending이라는 두 가지 변형이 있다. 하지만 PollOption과는 상당히 다른 의미를 가진다! Pending 변형은 future가 아직 작업을 완료하지 않았음을 나타내므로, 호출자는 나중에 다시 확인해야 한다. Ready 변형은 future가 작업을 완료했고 T 값이 사용 가능함을 나타낸다.

참고: 대부분의 future는 Ready를 반환한 후에 다시 poll을 호출하면 안 된다. 많은 future는 준비 상태가 된 후에 다시 poll을 호출하면 패닉을 일으킨다. 다시 poll을 호출해도 안전한 future는 해당 문서에 명시적으로 설명한다. 이는 Iterator::next의 동작과 유사하다.

await를 사용하는 코드를 보면, Rust는 내부적으로 poll을 호출하는 코드로 컴파일한다. 이전에 단일 URL의 페이지 제목을 출력했던 코드를 다시 살펴보면, Rust는 이를 다음과 비슷한 코드로 컴파일한다:

match page_title(url).poll() {
    Ready(page_title) => match page_title {
        Some(title) => println!("The title for {url} was {title}"),
        None => println!("{url} had no title"),
    }
    Pending => {
        // 여기서는 어떻게 해야 할까?
    }
}

future가 여전히 Pending 상태라면 어떻게 해야 할까? future가 준비될 때까지 계속 시도할 방법이 필요하다. 다시 말해, 루프가 필요하다:

let mut page_title_fut = page_title(url);
loop {
    match page_title_fut.poll() {
        Ready(value) => match page_title {
            Some(title) => println!("The title for {url} was {title}"),
            None => println!("{url} had no title"),
        }
        Pending => {
            // 계속 진행
        }
    }
}

하지만 Rust가 정확히 이 코드로 컴파일한다면, 모든 await가 블로킹될 것이다. 이는 우리가 원하는 것과 정반대다! 대신 Rust는 이 루프가 다른 future를 처리하고 나중에 다시 확인할 수 있도록 제어를 넘길 수 있게 한다. 앞서 본 것처럼, 이는 async 런타임이 담당하며, 이 스케줄링과 조정 작업이 런타임의 주요 역할 중 하나다.

이 장의 앞부분에서 rx.recv를 기다리는 것에 대해 설명했다. recv 호출은 future를 반환하고, future를 기다리면 poll을 호출한다. 런타임은 future가 Some(message) 또는 채널이 닫힌 경우 None을 반환할 때까지 future를 일시 중지한다. Future 트레이트, 특히 Future::poll에 대한 깊은 이해를 바탕으로 이를 어떻게 작동하는지 알 수 있다. 런타임은 future가 Poll::Pending을 반환하면 아직 준비되지 않았다는 것을 안다. 반대로, 런타임은 pollPoll::Ready(Some(message)) 또는 Poll::Ready(None)를 반환하면 future가 준비되었음을 알고 이를 진행시킨다.

런타임이 이를 어떻게 수행하는지에 대한 정확한 세부 사항은 이 책의 범위를 벗어나지만, 중요한 점은 future의 기본 메커니즘을 이해하는 것이다: 런타임은 자신이 담당하는 각 future를 _폴링_하고, future가 아직 준비되지 않았다면 다시 잠재운다.

PinUnpin 트레이트

리스트 17-16에서 고정(pinning) 개념을 소개하며 복잡한 에러 메시지를 마주했다. 여기서 그 에러 메시지의 관련 부분을 다시 살펴보자:

error[E0277]: `{async block@src/main.rs:10:23: 10:33}` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         trpl::join_all(futures).await;
   |                                 ^^^^^ the trait `Unpin` is not implemented for `{async block@src/main.rs:10:23: 10:33}`
   |
   = note: consider using the `pin!` macro
           consider using `Box::pin` if you need to access the pinned value outside of the current scope
   = note: required for `Box<{async block@src/main.rs:10:23: 10:33}>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
  --> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
   |
27 | pub struct JoinAll<F>
   |            ------- required by a bound in this struct
28 | where
29 |     F: Future,
   |        ^^^^^^ required by this bound in `JoinAll`

이 에러 메시지는 값들을 고정해야 한다는 것뿐만 아니라 왜 고정이 필요한지도 알려준다. trpl::join_all 함수는 JoinAll이라는 구조체를 반환한다. 이 구조체는 F 타입에 대해 제네릭하며, FFuture 트레이트를 구현해야 한다. await를 사용해 퓨처를 직접 기다리면 암묵적으로 퓨처가 고정된다. 그래서 퓨처를 기다릴 때마다 pin!을 사용할 필요가 없다.

하지만 여기서는 퓨처를 직접 기다리지 않는다. 대신 join_all 함수에 퓨처 컬렉션을 전달해 새로운 퓨처인 JoinAll을 생성한다. join_all의 시그니처는 컬렉션의 아이템 타입이 모두 Future 트레이트를 구현해야 한다는 것을 요구하며, Box<T>TUnpin 트레이트를 구현한 퓨처일 때만 Future를 구현한다.

이 내용을 완전히 이해하려면 _고정_과 관련해 Future 트레이트가 실제로 어떻게 동작하는지 더 깊이 파헤쳐야 한다.

Future 트레이트의 정의를 다시 살펴보자:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

pub trait Future {
    type Output;

    // Required method
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

cx 매개변수와 그 Context 타입은 런타임이 퓨처를 언제 확인해야 하는지 알 수 있게 하는 핵심이다. 이 동작의 세부 사항은 이 장의 범위를 벗어나며, 일반적으로 커스텀 Future 구현을 작성할 때만 이 부분을 고려하면 된다. 대신 self의 타입에 집중하자. 이는 self에 타입 주석이 붙은 메서드를 처음 보는 것이다. self의 타입 주석은 다른 함수 매개변수의 타입 주석과 비슷하지만 두 가지 중요한 차이점이 있다:

  • 이 주석은 메서드가 호출되기 위해 self가 어떤 타입이어야 하는지 Rust에게 알려준다.

  • 이 타입은 아무 타입이나 될 수 없다. 메서드가 구현된 타입, 그 타입에 대한 참조 또는 스마트 포인터, 또는 그 타입에 대한 참조를 감싼 Pin으로 제한된다.

이 문법에 대해선 18장에서 더 자세히 다룬다. 지금은 퓨처가 Pending인지 Ready(Output)인지 확인하기 위해 퓨처를 폴링하려면 해당 타입에 대한 Pin으로 감싼 가변 참조가 필요하다는 것만 알면 된다.

Pin&, &mut, Box, Rc 같은 포인터형 타입을 위한 래퍼다. (기술적으로 PinDeref 또는 DerefMut 트레이트를 구현한 타입과 함께 동작하지만, 이는 실질적으로 포인터와만 동작하는 것과 같다.) Pin은 포인터 자체가 아니며 RcArc처럼 참조 카운팅과 같은 자체 동작을 하지 않는다. 이는 컴파일러가 포인터 사용에 대한 제약을 강제하는 순수한 도구다.

awaitpoll 호출로 구현된다는 것을 떠올리면 앞서 본 에러 메시지를 설명할 수 있지만, 그 메시지는 Unpin이 아니라 Pin에 관한 것이었다. 그렇다면 PinUnpin과 어떻게 관련되며, 왜 Futurepoll을 호출하기 위해 selfPin 타입이어야 할까?

이 장 앞부분에서 퓨처 내의 일련의 await 지점이 상태 머신으로 컴파일되며, 컴파일러는 이 상태 머신이 Rust의 일반적인 안전 규칙(소유권 및 참조 규칙 포함)을 모두 따르도록 보장한다는 것을 기억하자. 이를 위해 Rust는 하나의 await 지점과 다음 await 지점 또는 async 블록의 끝 사이에 어떤 데이터가 필요한지 확인한다. 그런 다음 컴파일된 상태 머신에 해당하는 변형을 생성한다. 각 변형은 소스 코드의 해당 섹션에서 사용될 데이터에 필요한 접근 권한을 얻는다. 이는 해당 데이터의 소유권을 가져오거나, 가변 또는 불변 참조를 얻는 방식으로 이루어진다.

지금까지는 문제가 없다: 주어진 async 블록에서 소유권이나 참조에 대해 잘못된 부분이 있으면 빌림 검사기가 알려준다. 하지만 해당 블록에 해당하는 퓨처를 이동하려 할 때—예를 들어 join_all에 전달하기 위해 Vec에 넣으려 할 때—문제가 복잡해진다.

퓨처를 이동할 때—join_all과 함께 이터레이터로 사용하기 위해 데이터 구조에 푸시하거나 함수에서 반환할 때—이는 실제로 Rust가 생성한 상태 머신을 이동하는 것을 의미한다. 그리고 Rust의 대부분의 다른 타입과 달리, async 블록을 위해 Rust가 생성한 퓨처는 특정 변형의 필드에 자기 자신에 대한 참조를 가질 수 있다. 이는 그림 17-4의 단순화된 예시에서 볼 수 있다.

A single-column, three-row table representing a future, fut1, which has data values 0 and 1 in the first two rows and an arrow pointing from the third row back to the second row, representing an internal reference within the future.
그림 17-4: 자기 참조 데이터 타입.

하지만 기본적으로 자기 자신에 대한 참조를 가진 객체는 이동하기에 안전하지 않다. 참조는 항상 참조 대상의 실제 메모리 주소를 가리키기 때문이다(그림 17-5 참조). 데이터 구조 자체를 이동하면 그 내부 참조는 이전 위치를 가리키게 된다. 그러나 이 메모리 위치는 이제 유효하지 않다. 한 가지는 데이터 구조를 변경할 때 그 값이 업데이트되지 않는다는 점이다. 더 중요한 점은 컴퓨터가 이제 그 메모리를 다른 목적으로 재사용할 수 있다는 것이다! 나중에 완전히 관련 없는 데이터를 읽게 될 수 있다.

Two tables, depicting two futures, fut1 and fut2, each of which has one column and three rows, representing the result of having moved a future out of fut1 into fut2. The first, fut1, is grayed out, with a question mark in each index, representing unknown memory. The second, fut2, has 0 and 1 in the first and second rows and an arrow pointing from its third row back to the second row of fut1, representing a pointer that is referencing the old location in memory of the future before it was moved.
그림 17-5: 자기 참조 데이터 타입을 이동한 결과의 안전하지 않은 상황

이론적으로 Rust 컴파일러는 객체가 이동할 때마다 모든 참조를 업데이트하려고 시도할 수 있지만, 이는 특히 참조의 전체 네트워크를 업데이트해야 할 때 많은 성능 오버헤드를 추가할 수 있다. 대신 해당 데이터 구조가 메모리에서 _이동하지 않는다_는 것을 보장할 수 있다면, 어떤 참조도 업데이트할 필요가 없다. 이는 Rust의 빌림 검사기가 요구하는 것과 정확히 일치한다: 안전한 코드에서는 활성 참조가 있는 항목을 이동하는 것을 방지한다.

Pin은 이를 기반으로 우리가 필요한 정확한 보장을 제공한다. Pin으로 값을 감싸면 그 값은 더 이상 이동할 수 없다. 따라서 Pin<Box<SomeType>>이 있다면 실제로 SomeType 값을 고정하는 것이지 Box 포인터를 고정하는 것이 아니다. 그림 17-6은 이 과정을 보여준다.

Three boxes laid out side by side. The first is labeled “Pin”, the second “b1”, and the third “pinned”. Within “pinned” is a table labeled “fut”, with a single column; it represents a future with cells for each part of the data structure. Its first cell has the value “0”, its second cell has an arrow coming out of it and pointing to the fourth and final cell, which has the value “1” in it, and the third cell has dashed lines and an ellipsis to indicate there may be other parts to the data structure. All together, the “fut” table represents a future which is self-referential. An arrow leaves the box labeled “Pin”, goes through the box labeled “b1” and has terminates inside the “pinned” box at the “fut” table.
그림 17-6: 자기 참조 퓨처 타입을 가리키는 `Box`를 고정하기.

사실 Box 포인터는 여전히 자유롭게 이동할 수 있다. 기억하자: 우리가 신경 쓰는 것은 궁극적으로 참조되는 데이터가 제자리에 있는지 확인하는 것이다. 포인터가 이동하더라도 그 포인터가 가리키는 데이터가 같은 위치에 있다면 (그림 17-7에서와 같이) 잠재적인 문제가 없다. 독립적인 연습으로, 타입과 std::pin 모듈의 문서를 살펴보고 Box를 감싼 Pin으로 이를 어떻게 할지 생각해보자.) 핵심은 자기 참조 타입 자체가 이동할 수 없다는 것이다. 왜냐하면 여전히 고정되어 있기 때문이다.

Four boxes laid out in three rough columns, identical to the previous diagram with a change to the second column. Now there are two boxes in the second column, labeled “b1” and “b2”, “b1” is grayed out, and the arrow from “Pin” goes through “b2” instead of “b1”, indicating that the pointer has moved from “b1” to “b2”, but the data in “pinned” has not moved.
그림 17-7: 자기 참조 퓨처 타입을 가리키는 `Box`를 이동하기.

하지만 대부분의 타입은 Pin 래퍼 뒤에 있더라도 완벽하게 안전하게 이동할 수 있다. 내부 참조가 있는 항목에 대해서만 고정을 고려하면 된다. 숫자나 불리언 같은 기본 값은 내부 참조가 없으므로 안전하다. Rust에서 일반적으로 작업하는 대부분의 타입도 마찬가지다. 예를 들어 Vec을 이동할 때 걱정할 필요가 없다. 지금까지 본 내용만으로는 Pin<Vec<String>>이 있다면 Pin이 제공하는 안전하지만 제한적인 API를 통해 모든 작업을 해야 하지만, Vec<String>은 다른 참조가 없다면 항상 안전하게 이동할 수 있다. 이와 같은 경우에 항목을 이동해도 괜찮다는 것을 컴파일러에게 알려줄 방법이 필요하다—그리고 바로 여기서 Unpin이 등장한다.

Unpin은 16장에서 본 SendSync 트레이트와 유사한 마커 트레이트이며, 따라서 자체 기능은 없다. 마커 트레이트는 특정 컨텍스트에서 주어진 트레이트를 구현한 타입을 안전하게 사용할 수 있다는 것을 컴파일러에게 알리는 역할만 한다. Unpin은 주어진 타입이 값이 안전하게 이동할 수 있는지에 대한 보장을 유지할 필요가 없다는 것을 컴파일러에게 알린다.

SendSync와 마찬가지로, 컴파일러는 안전하다고 판단될 경우 모든 타입에 대해 Unpin을 자동으로 구현한다. 특수한 경우로, SendSync와 유사하게, Unpin이 타입에 대해 구현되지 않는 경우가 있다. 이를 나타내는 표기법은 impl !Unpin for SomeType이며, 여기서 SomeTypePin으로 감싼 포인터를 사용할 때 안전을 위해 해당 보장을 유지해야 하는 타입의 이름이다.

다시 말해, PinUnpin의 관계에 대해 두 가지를 기억해야 한다. 첫째, Unpin은 “일반적인” 경우이고 !Unpin은 특수한 경우다. 둘째, 타입이 Unpin을 구현하는지 !Unpin을 구현하는지는 Pin<&mut SomeType>과 같은 고정된 포인터를 사용할 때만 중요하다.

이를 구체적으로 이해하기 위해 String을 생각해보자: 길이와 이를 구성하는 유니코드 문자를 가지고 있다. 그림 17-8에서 볼 수 있듯이 StringPin으로 감쌀 수 있다. 그러나 String은 Rust의 대부분의 다른 타입과 마찬가지로 자동으로 Unpin을 구현한다.

Concurrent work flow
그림 17-8: `String` 고정하기; 점선은 `String`이 `Unpin` 트레이트를 구현하므로 고정되지 않음을 나타낸다.

결과적으로 String!Unpin을 구현했다면 불법이었을 행동을 할 수 있다. 예를 들어 그림 17-9에서와 같이 메모리의 정확히 같은 위치에서 한 문자열을 다른 문자열로 교체할 수 있다. 이는 Pin 계약을 위반하지 않는다. 왜냐하면 String은 이동해도 안전하지 않게 만드는 내부 참조가 없기 때문이다! 이것이 바로 String!Unpin이 아니라 Unpin을 구현하는 이유다.

Concurrent work flow
그림 17-9: 메모리에서 `String`을 완전히 다른 `String`으로 교체하기.

이제 리스트 17-17의 join_all 호출에서 보고된 에러를 이해할 수 있다. 원래 async 블록에 의해 생성된 퓨처를 Vec<Box<dyn Future<Output = ()>>>로 이동하려고 시도했지만, 앞서 본 것처럼 이 퓨처는 내부 참조를 가질 수 있으므로 Unpin을 구현하지 않는다. 이들은 고정되어야 하며, 그런 다음 Pin 타입을 Vec에 전달할 수 있다. 이렇게 하면 퓨처의 기본 데이터가 이동되지 않는다는 확신을 가질 수 있다.

PinUnpin은 주로 하위 수준 라이브러리를 구축하거나 런타임 자체를 구축할 때 중요하며, 일상적인 Rust 코드에서는 그다지 중요하지 않다. 하지만 이 트레이트가 에러 메시지에 나타날 때, 이제는 코드를 어떻게 수정해야 할지 더 잘 이해할 수 있을 것이다!

참고: PinUnpin의 조합은 Rust에서 자기 참조 때문에 구현하기 어려운 복잡한 타입의 전체 클래스를 안전하게 구현할 수 있게 한다. Pin이 필요한 타입은 오늘날 async Rust에서 가장 흔하게 나타나지만, 가끔 다른 컨텍스트에서도 볼 수 있다.

PinUnpin이 어떻게 동작하며, 이들이 유지해야 할 규칙에 대한 세부 사항은 std::pin의 API 문서에서 광범위하게 다루므로, 더 알고 싶다면 이 문서가 좋은 시작점이다.

내부적으로 어떻게 동작하는지 더 자세히 알고 싶다면 Asynchronous Programming in Rust2장4장을 참고하라.

Stream 트레이트

이제 Future, Pin, 그리고 Unpin 트레이트에 대해 깊이 이해했으니, Stream 트레이트에 대해 알아보자. 앞서 배웠듯이, 스트림은 비동기 이터레이터와 유사하다. 하지만 IteratorFuture와 달리, Stream은 현재 표준 라이브러리에 정의되어 있지 않다. 대신, futures 크레이트에서 제공하는 정의가 생태계 전반에서 널리 사용되고 있다.

Stream 트레이트를 살펴보기 전에, IteratorFuture 트레이트의 정의를 다시 짚어보자. Iterator는 시퀀스의 개념을 제공한다. next 메서드는 Option<Self::Item>을 반환한다. Future는 시간에 따른 준비 상태의 개념을 제공한다. poll 메서드는 Poll<Self::Output>을 반환한다. 시간에 따라 준비되는 아이템의 시퀀스를 표현하기 위해, 이 두 기능을 결합한 Stream 트레이트를 정의할 수 있다:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;

    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>
    ) -> Poll<Option<Self::Item>>;
}
}

Stream 트레이트는 스트림이 생성하는 아이템의 타입을 나타내는 Item이라는 연관 타입을 정의한다. 이는 Iterator와 유사하며, 아이템이 없거나 여러 개일 수 있다. 반면 Future는 항상 단일 Output을 가지며, 심지어 단위 타입 ()일 수도 있다.

Stream은 또한 이러한 아이템을 가져오는 메서드를 정의한다. 이 메서드를 poll_next라고 부르는데, 이는 Future::poll과 같은 방식으로 동작하며, Iterator::next와 같은 방식으로 아이템 시퀀스를 생성한다. 반환 타입은 PollOption을 결합한 형태다. 외부 타입은 Poll인데, 이는 Future와 마찬가지로 준비 상태를 확인해야 하기 때문이다. 내부 타입은 Option인데, 이는 Iterator와 마찬가지로 더 이상 메시지가 있는지 여부를 알려주기 때문이다.

이와 매우 유사한 정의가 Rust의 표준 라이브러리에 추가될 가능성이 크다. 그때까지는 대부분의 런타임에서 이 트레이트를 사용할 수 있으므로, 안심하고 사용할 수 있다. 또한 이어지는 내용은 일반적으로 적용 가능하다.

스트리밍 섹션에서 본 예제에서는 poll_nextStream을 직접 사용하지 않고, nextStreamExt를 사용했다. 물론, poll_next API를 직접 사용하여 Stream 상태 머신을 직접 작성할 수도 있다. 이는 poll 메서드를 통해 Future를 직접 다루는 것과 유사하다. 하지만 await를 사용하는 것이 훨씬 편리하며, StreamExt 트레이트는 next 메서드를 제공하여 이를 가능하게 한다:

#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};

trait Stream {
    type Item;
    fn poll_next(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Self::Item>>;
}

trait StreamExt: Stream {
    async fn next(&mut self) -> Option<Self::Item>
    where
        Self: Unpin;

    // other methods...
}
}

참고: 이 장의 앞부분에서 사용한 실제 정의는 이와 조금 다르다. 이는 Rust가 트레이트 내에서 async 함수를 아직 지원하지 않았던 버전을 지원하기 때문이다. 따라서 다음과 같이 보인다:

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

Next 타입은 Future를 구현한 struct이며, Next<'_, Self>를 통해 self에 대한 참조의 라이프타임을 명시할 수 있다. 이를 통해 await가 이 메서드와 함께 동작할 수 있다.

StreamExt 트레이트는 스트림과 함께 사용할 수 있는 모든 흥미로운 메서드의 집합이기도 하다. StreamExtStream을 구현하는 모든 타입에 대해 자동으로 구현되지만, 이 트레이트는 기본 트레이트에 영향을 주지 않고 편의 API를 반복적으로 개선할 수 있도록 별도로 정의된다.

trpl 크레이트에서 사용된 StreamExt 버전에서는, next 메서드를 정의할 뿐만 아니라 Stream::poll_next를 호출하는 세부 사항을 올바르게 처리하는 next 메서드의 기본 구현도 제공한다. 이는 스트리밍 데이터 타입을 직접 작성해야 할 때도 Stream만 구현하면 되며, 이를 사용하는 모든 사람이 StreamExt와 그 메서드를 자동으로 사용할 수 있음을 의미한다.

이 트레이트에 대한 저수준의 세부 사항은 여기까지 다룬다. 마지막으로, Future(스트림 포함), Task, 그리고 Thread가 어떻게 함께 작동하는지 살펴보자!

모든 것을 종합해 보자: Future, Task, 그리고 Thread

16장에서 살펴보았듯이, 스레드는 병행성(concurrency)을 구현하는 한 가지 방법이다. 이번 장에서는 또 다른 접근 방식인 async와 future, stream을 사용하는 방법을 배웠다. 어떤 방법을 선택해야 할지 고민된다면, 정답은 “상황에 따라 다르다“이다. 그리고 많은 경우, 스레드와 async 중 하나를 선택하는 것이 아니라 둘 다 함께 사용하는 것이 더 적합하다.

수십 년 동안 많은 운영체제가 스레드 기반의 병행성 모델을 제공해 왔고, 그 결과 많은 프로그래밍 언어가 이를 지원한다. 하지만 이 모델에는 단점도 있다. 많은 운영체제에서 각 스레드는 상당한 메모리를 사용하며, 시작과 종료 시 오버헤드가 발생한다. 또한 스레드는 운영체제와 하드웨어가 이를 지원할 때만 사용할 수 있다. 일반적인 데스크톱과 모바일 컴퓨터와 달리, 일부 임베디드 시스템에는 운영체제가 없기 때문에 스레드도 없다.

async 모델은 다른 방식의 장단점을 제공하며, 궁극적으로 스레드와 상호 보완적이다. async 모델에서는 병행 작업이 별도의 스레드를 필요로 하지 않는다. 대신, 스트림 섹션에서 trpl::spawn_task를 사용해 동기 함수에서 작업을 시작한 것처럼, 작업(task)에서 실행될 수 있다. Task는 스레드와 유사하지만, 운영체제가 아닌 런타임이라는 라이브러리 수준의 코드에 의해 관리된다.

이전 섹션에서는 async 채널을 사용하고 동기 코드에서 호출할 수 있는 async 작업을 생성해 스트림을 구축하는 방법을 살펴보았다. 스레드를 사용해도 동일한 작업을 수행할 수 있다. 리스트 17-40에서는 trpl::spawn_tasktrpl::sleep을 사용했지만, 리스트 17-41에서는 get_intervals 함수에서 이를 표준 라이브러리의 thread::spawnthread::sleep API로 대체했다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{pin::pin, thread, time::Duration};

use trpl::{ReceiverStream, Stream, StreamExt};

fn main() {
    trpl::run(async {
        let messages = get_messages().timeout(Duration::from_millis(200));
        let intervals = get_intervals()
            .map(|count| format!("Interval #{count}"))
            .throttle(Duration::from_millis(500))
            .timeout(Duration::from_secs(10));
        let merged = messages.merge(intervals).take(20);
        let mut stream = pin!(merged);

        while let Some(result) = stream.next().await {
            match result {
                Ok(item) => println!("{item}"),
                Err(reason) => eprintln!("Problem: {reason:?}"),
            }
        }
    });
}

fn get_messages() -> impl Stream<Item = String> {
    let (tx, rx) = trpl::channel();

    trpl::spawn_task(async move {
        let messages = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];

        for (index, message) in messages.into_iter().enumerate() {
            let time_to_sleep = if index % 2 == 0 { 100 } else { 300 };
            trpl::sleep(Duration::from_millis(time_to_sleep)).await;

            if let Err(send_error) = tx.send(format!("Message: '{message}'")) {
                eprintln!("Cannot send message '{message}': {send_error}");
                break;
            }
        }
    });

    ReceiverStream::new(rx)
}

fn get_intervals() -> impl Stream<Item = u32> {
    let (tx, rx) = trpl::channel();

    // This is *not* `trpl::spawn` but `std::thread::spawn`!
    thread::spawn(move || {
        let mut count = 0;
        loop {
            // Likewise, this is *not* `trpl::sleep` but `std::thread::sleep`!
            thread::sleep(Duration::from_millis(1));
            count += 1;

            if let Err(send_error) = tx.send(count) {
                eprintln!("Could not send interval {count}: {send_error}");
                break;
            };
        }
    });

    ReceiverStream::new(rx)
}
Listing 17-41: get_intervals 함수에서 async trpl API 대신 std::thread API 사용

이 코드를 실행하면 리스트 17-40과 동일한 결과가 출력된다. 호출 코드의 관점에서 얼마나 적은 변경이 필요한지 주목하라. 또한 한 함수는 런타임에 async 작업을 생성하고, 다른 함수는 OS 스레드를 생성했지만, 결과적으로 생성된 스트림은 이러한 차이에 영향을 받지 않았다.

두 접근 방식은 유사해 보이지만 실제로는 매우 다르게 동작한다. 단순한 예제에서는 이를 측정하기 어려울 수 있지만, 현대의 개인용 컴퓨터에서는 수백만 개의 async 작업을 생성할 수 있다. 반면 스레드로 동일한 작업을 시도하면 메모리가 부족해질 것이다.

그럼에도 불구하고 이 API들이 유사한 데는 이유가 있다. 스레드는 동기 작업의 경계 역할을 하며, 스레드 간에 병행성이 가능하다. Task는 비동기 작업의 경계 역할을 하며, task 내부와 task 간에 병행성이 가능하다. 이는 task가 본문에서 future 간에 전환할 수 있기 때문이다. Future는 Rust에서 가장 세분화된 병행성 단위이며, 각 future는 다른 future의 트리를 나타낼 수 있다. 런타임(특히 executor)은 task를 관리하고, task는 future를 관리한다. 이 점에서 task는 런타임에 의해 관리된다는 점에서 추가 기능을 가진 경량의 스레드와 유사하다.

이것이 async task가 항상 스레드보다 우수하다는 것을 의미하지는 않는다. 스레드를 사용한 병행성은 어떤 면에서는 async를 사용한 병행성보다 더 단순한 프로그래밍 모델이다. 이는 장점이 될 수도 있고 단점이 될 수도 있다. 스레드는 “발사 후 잊어버리는” 방식으로 동작하며, future와 같은 네이티브 동등물이 없기 때문에 운영체제 자체에 의해 중단되지 않는 한 완료될 때까지 실행된다. 즉, future와 달리 스레드에는 _task 내부의 병행성_을 위한 내장 지원이 없다. Rust의 스레드는 또한 취소 메커니즘이 없는데, 이는 이 장에서 명시적으로 다루지는 않았지만 future가 종료될 때마다 그 상태가 올바르게 정리된다는 사실에서 암시되었다.

이러한 제약 사항은 스레드를 future보다 합성하기 어렵게 만든다. 예를 들어, 이 장의 앞부분에서 만든 timeoutthrottle 같은 헬퍼를 스레드로 구현하는 것은 훨씬 더 어렵다. Future가 더 풍부한 데이터 구조라는 사실은 이를 더 자연스럽게 합성할 수 있게 한다.

Task는 future에 대한 추가적인 제어를 제공하여, 이를 그룹화할 위치와 방법을 선택할 수 있게 한다. 그리고 스레드와 task는 종종 매우 잘 함께 작동하는데, task는 (적어도 일부 런타임에서는) 스레드 간에 이동할 수 있기 때문이다. 실제로, 우리가 사용해 온 런타임(예: spawn_blockingspawn_task 함수를 포함)은 기본적으로 멀티스레드이다! 많은 런타임은 _작업 도용(work stealing)_이라는 접근 방식을 사용하여 스레드 간에 task를 투명하게 이동시켜 시스템의 전반적인 성능을 향상시킨다. 이 접근 방식은 실제로 스레드와 task, 그리고 future가 모두 필요하다.

어떤 방법을 사용할지 고민할 때는 다음의 경험 법칙을 고려하라:

  • 작업이 _매우 병렬화 가능_하다면, 예를 들어 각 부분을 별도로 처리할 수 있는 데이터를 처리하는 경우, 스레드가 더 나은 선택이다.
  • 작업이 _매우 병행적_이라면, 예를 들어 다양한 간격이나 속도로 들어오는 여러 소스의 메시지를 처리하는 경우, async가 더 나은 선택이다.

그리고 병렬성과 병행성이 모두 필요한 경우, 스레드와 async 중 하나를 선택할 필요는 없다. 둘을 자유롭게 함께 사용하여 각각의 강점을 활용할 수 있다. 예를 들어, 리스트 17-42는 실제 Rust 코드에서 이러한 혼합을 보여주는 일반적인 예시이다.

Filename: src/main.rs
extern crate trpl; // for mdbook test

use std::{thread, time::Duration};

fn main() {
    let (tx, mut rx) = trpl::channel();

    thread::spawn(move || {
        for i in 1..11 {
            tx.send(i).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    trpl::run(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}
Listing 17-42: 스레드에서 블로킹 코드로 메시지를 보내고 async 블록에서 메시지를 기다리는 예제

먼저 async 채널을 생성한 후, 채널의 송신 측을 소유하는 스레드를 생성한다. 스레드 내부에서는 1부터 10까지의 숫자를 보내며 각각 사이에 1초씩 대기한다. 마지막으로, 이 장에서 계속 사용해 온 것처럼 trpl::run에 전달된 async 블록으로 생성된 future를 실행한다. 이 future에서는 이전에 본 메시지 전달 예제와 마찬가지로 이러한 메시지를 기다린다.

이 장의 시작 부분에서 언급한 시나리오로 돌아가면, 비디오 인코딩 작업을 전용 스레드에서 실행하고(비디오 인코딩은 계산 집약적이기 때문에) 작업이 완료되면 async 채널을 통해 UI에 알리는 것을 상상해 보라. 실제 사용 사례에서 이러한 조합은 무수히 많다.

요약

이 책에서 동시성에 대한 이야기는 여기서 끝나지 않는다. 21장의 프로젝트에서는 여기서 다룬 간단한 예제보다 더 현실적인 상황에서 이러한 개념을 적용하고, 스레딩과 태스크를 사용한 문제 해결 방식을 직접 비교해 볼 것이다.

여러분이 어떤 접근 방식을 선택하든, Rust는 고성능 웹 서버부터 임베디드 운영체제까지 안전하고 빠른 동시성 코드를 작성하는 데 필요한 도구를 제공한다.

다음 장에서는 Rust 프로그램이 커짐에 따라 문제를 모델링하고 해결책을 구조화하는 관용적인 방법에 대해 이야기할 것이다. 또한 Rust의 관용구가 객체 지향 프로그래밍에서 익숙할 수 있는 관용구와 어떻게 관련되는지 논의할 것이다.

객체 지향 프로그래밍의 특징

객체 지향 프로그래밍(OOP)은 프로그램을 모델링하는 하나의 방식이다. 프로그래밍 개념으로서의 객체는 1960년대 Simula 프로그래밍 언어에서 처음 소개되었다. 이러한 객체 개념은 Alan Kay의 프로그래밍 아키텍처에 영향을 미쳤는데, 그의 아키텍처에서는 객체들이 서로 메시지를 주고받는다. 이 아키텍처를 설명하기 위해 그는 1967년에 ’객체 지향 프로그래밍’이라는 용어를 만들었다. OOP를 정의하는 많은 경쟁적인 설명이 존재하며, 어떤 정의에 따르면 Rust는 객체 지향적이라고 할 수 있지만, 다른 정의에서는 그렇지 않다. 이 장에서는 일반적으로 객체 지향적이라고 여겨지는 특성들이 무엇인지 살펴보고, 이러한 특성들이 Rust의 관용적인 코드로 어떻게 표현되는지 알아본다. 그리고 Rust에서 객체 지향 디자인 패턴을 구현하는 방법을 보여주고, Rust의 강점을 활용한 다른 해결책과 비교했을 때의 장단점에 대해 논의한다.

객체지향 언어의 특징

프로그래밍 커뮤니티에서는 어떤 언어가 객체지향이라고 불리기 위해 반드시 갖춰야 할 기능에 대해 명확한 합의가 없다. Rust는 OOP를 포함한 다양한 프로그래밍 패러다임의 영향을 받았다. 예를 들어, 13장에서는 함수형 프로그래밍에서 유래한 기능들을 살펴봤다. 일반적으로 객체지향 언어는 객체, 캡슐화, 상속이라는 공통적인 특징을 공유한다고 볼 수 있다. 이제 각 특징이 무엇을 의미하는지, 그리고 Rust가 이를 지원하는지 알아보자.

객체는 데이터와 동작을 포함한다

에리히 감마, 리처드 헬름, 랄프 존슨, 존 블리사이드가 공동 집필한 《Design Patterns: Elements of Reusable Object-Oriented Software》(Addison-Wesley, 1994)는 객체 지향 디자인 패턴을 정리한 책이다. 이 책은 흔히 갱 오브 포(Gang of Four) 라고 불리며, 객체 지향 프로그래밍(OOP)을 다음과 같이 정의한다:

객체 지향 프로그램은 객체로 구성된다. 객체는 데이터와 그 데이터를 처리하는 절차를 함께 포함한다. 이 절차는 일반적으로 메서드 또는 연산이라고 불린다.

이 정의에 따르면, Rust는 객체 지향적이다. 구조체(struct)와 열거형(enum)은 데이터를 포함하며, impl 블록을 통해 구조체와 열거형에 메서드를 추가한다. 메서드를 가진 구조체와 열거형이 _객체_라고 불리지는 않지만, 갱 오브 포가 정의한 객체의 기능을 동일하게 제공한다.

구현 세부사항을 숨기는 캡슐화

객체지향 프로그래밍(OOP)에서 흔히 언급되는 또 다른 개념은 캡슐화다. 캡슐화는 객체의 구현 세부사항을 외부 코드에서 접근할 수 없도록 숨기는 것을 의미한다. 따라서 객체와 상호작용하려면 해당 객체의 공개 API를 통해서만 가능하다. 객체를 사용하는 코드는 객체의 내부에 직접 접근해 데이터를 변경하거나 동작을 수정할 수 없다. 이를 통해 프로그래머는 객체의 내부를 변경하거나 리팩토링할 때, 해당 객체를 사용하는 코드를 수정할 필요가 없어진다.

7장에서 캡슐화를 어떻게 제어하는지 다뤘다. pub 키워드를 사용해 코드의 모듈, 타입, 함수, 메서드 중 어떤 것을 공개할지 결정할 수 있다. 기본적으로 나머지는 모두 비공개로 설정된다. 예를 들어, i32 값의 벡터를 포함하는 필드가 있는 AveragedCollection 구조체를 정의할 수 있다. 이 구조체는 벡터 내 값들의 평균을 포함하는 필드도 가질 수 있다. 즉, 누군가가 평균을 필요로 할 때마다 계산하지 않고도 평균값을 제공할 수 있다. 다시 말해, AveragedCollection은 계산된 평균을 캐싱한다. 아래는 AveragedCollection 구조체의 정의다:

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
Listing 18-1: 정수 목록과 컬렉션 내 항목의 평균을 유지하는 AveragedCollection 구조체

이 구조체는 pub으로 표시되어 있어 외부 코드에서 사용할 수 있지만, 구조체 내부의 필드는 비공개로 유지된다. 이는 목록에 값이 추가되거나 제거될 때마다 평균이 업데이트되도록 보장하기 위해 중요하다. 이를 위해 구조체에 add, remove, average 메서드를 구현한다. 아래는 이 메서드들의 구현이다:

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            }
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}
Listing 18-2: AveragedCollection의 공개 메서드 add, remove, average 구현

add, remove, average 공개 메서드는 AveragedCollection 인스턴스의 데이터에 접근하거나 수정할 수 있는 유일한 방법이다. add 메서드를 사용해 list에 항목을 추가하거나 remove 메서드를 사용해 항목을 제거할 때, 각 구현은 average 필드를 업데이트하는 비공개 update_average 메서드를 호출한다.

listaverage 필드를 비공개로 유지함으로써 외부 코드가 list 필드에 직접 항목을 추가하거나 제거할 수 없도록 한다. 그렇지 않으면 list가 변경될 때 average 필드가 동기화되지 않을 수 있다. average 메서드는 average 필드의 값을 반환하므로, 외부 코드는 평균을 읽을 수는 있지만 수정할 수는 없다.

AveragedCollection 구조체의 구현 세부사항을 캡슐화했기 때문에, 앞으로 데이터 구조와 같은 측면을 쉽게 변경할 수 있다. 예를 들어, list 필드에 Vec<i32> 대신 HashSet<i32>를 사용할 수 있다. add, remove, average 공개 메서드의 시그니처가 동일하게 유지된다면, AveragedCollection을 사용하는 코드는 변경할 필요가 없다. 만약 list를 공개로 했다면 이는 반드시 그렇지 않을 것이다. HashSet<i32>Vec<i32>는 항목을 추가하고 제거하는 메서드가 다르므로, 외부 코드가 list를 직접 수정한다면 코드를 변경해야 할 가능성이 높다.

캡슐화가 객체지향 언어의 필수 요소라면, Rust는 이 요구사항을 충족한다. 코드의 각 부분에 pub을 사용할지 여부를 선택할 수 있기 때문에 구현 세부사항을 캡슐화할 수 있다.

타입 시스템과 코드 공유로서의 상속

상속은 한 객체가 다른 객체의 정의에서 요소를 물려받을 수 있는 메커니즘이다. 이를 통해 부모 객체의 데이터와 동작을 다시 정의하지 않고도 사용할 수 있다.

만약 어떤 언어가 객체 지향적이기 위해 상속이 필수적이라면, Rust는 그런 언어가 아니다. Rust에서는 매크로를 사용하지 않고는 부모 구조체의 필드와 메서드 구현을 상속받는 구조체를 정의할 방법이 없다.

하지만 프로그래밍 도구 상자에서 상속을 사용하는 데 익숙하다면, Rust에서도 상속을 사용하려는 이유에 따라 다른 해결책을 찾을 수 있다.

상속을 선택하는 주된 이유는 두 가지이다. 첫째는 코드 재사용이다. 특정 타입에 대해 특정 동작을 구현하고, 상속을 통해 다른 타입에서도 그 구현을 재사용할 수 있다. Rust에서는 기본 트레이트 메서드 구현을 통해 이를 제한적으로 구현할 수 있다. 예를 들어, Summary 트레이트에 summarize 메서드의 기본 구현을 추가했던 Listing 10-14에서 이를 확인할 수 있다. Summary 트레이트를 구현하는 모든 타입은 추가 코드 없이 summarize 메서드를 사용할 수 있다. 이는 부모 클래스가 메서드를 구현하고, 상속받는 자식 클래스도 그 구현을 갖는 것과 유사하다. 또한 Summary 트레이트를 구현할 때 summarize 메서드의 기본 구현을 재정의할 수 있는데, 이는 자식 클래스가 부모 클래스로부터 상속받은 메서드의 구현을 재정의하는 것과 비슷하다.

상속을 사용하는 또 다른 이유는 타입 시스템과 관련이 있다. 자식 타입이 부모 타입과 같은 위치에서 사용될 수 있도록 하는 것이다. 이를 **다형성(polymorphism)**이라고도 부르며, 이는 런타임에 특정 특성을 공유하는 여러 객체를 서로 대체할 수 있음을 의미한다.

다형성

많은 사람들에게 다형성은 상속과 동의어로 여겨진다. 하지만 다형성은 실제로 여러 타입의 데이터와 함께 작동할 수 있는 코드를 가리키는 더 일반적인 개념이다. 상속의 경우, 그 타입들은 일반적으로 하위 클래스이다.

Rust는 대신 제네릭을 사용해 다양한 가능한 타입을 추상화하고, 트레이트 바운드를 통해 그 타입들이 제공해야 할 제약을 부과한다. 이를 **경계가 있는 매개변수 다형성(bounded parametric polymorphism)**이라고도 부른다.

최근에는 많은 프로그래밍 언어에서 상속이 프로그래밍 설계 솔루션으로서 인기를 잃고 있다. 상속은 종종 필요 이상으로 많은 코드를 공유할 위험이 있기 때문이다. 하위 클래스가 항상 부모 클래스의 모든 특성을 공유해서는 안 되지만, 상속을 사용하면 그렇게 된다. 이는 프로그램의 설계를 덜 유연하게 만들 수 있다. 또한 하위 클래스에 적용되지 않거나 오류를 일으킬 수 있는 메서드를 호출할 가능성도 있다. 게다가 어떤 언어들은 단일 상속만 허용하기도 하는데(즉, 하위 클래스가 하나의 클래스만 상속받을 수 있음), 이는 프로그램 설계의 유연성을 더욱 제한한다.

이러한 이유로 Rust는 상속 대신 트레이트 객체를 사용하는 다른 접근 방식을 취한다. 이제 Rust에서 트레이트 객체가 어떻게 다형성을 가능하게 하는지 살펴보자.

다양한 타입의 값을 허용하는 트레이트 객체 사용하기

8장에서 벡터의 한계점 중 하나는 단일 타입의 요소만 저장할 수 있다는 것을 언급했다. 리스트 8-9에서 SpreadsheetCell 열거형을 정의해 정수, 부동소수점, 텍스트를 저장할 수 있는 방법을 소개했다. 이렇게 하면 각 셀에 다양한 타입의 데이터를 저장할 수 있으면서도, 여전히 셀 행을 나타내는 벡터를 유지할 수 있다. 이 방법은 코드를 컴파일할 때 교체 가능한 항목이 고정된 타입 집합으로 정해져 있는 경우에 완벽한 해결책이다.

그러나 때로는 라이브러리 사용자가 특정 상황에서 유효한 타입 집합을 확장할 수 있도록 하고 싶을 때가 있다. 이를 어떻게 구현할 수 있는지 보여주기 위해, GUI 도구 예제를 만들어본다. 이 도구는 항목 목록을 순회하며 각 항목의 draw 메서드를 호출해 화면에 그리는 일반적인 GUI 기법을 사용한다. gui라는 이름의 라이브러리 크레이트를 만들어 GUI 라이브러리의 구조를 담을 것이다. 이 크레이트는 Button이나 TextField와 같은 사용자 정의 타입을 포함할 수 있다. 또한 gui 사용자는 화면에 그릴 수 있는 자신만의 타입을 만들고 싶어할 것이다. 예를 들어, 한 프로그래머는 Image를 추가하고, 다른 프로그래머는 SelectBox를 추가할 수 있다.

이 예제에서는 완전한 GUI 라이브러리를 구현하지는 않지만, 각 부분이 어떻게 조합되는지 보여줄 것이다. 라이브러리를 작성하는 시점에서는 다른 프로그래머가 만들고 싶어할 모든 타입을 알거나 정의할 수 없다. 하지만 gui가 다양한 타입의 값을 추적해야 하고, 이렇게 타입이 다른 각 값에 대해 draw 메서드를 호출해야 한다는 것은 알고 있다. draw 메서드를 호출했을 때 정확히 어떤 일이 일어날지는 알 필요가 없으며, 단지 해당 값이 호출할 수 있는 draw 메서드를 가지고 있다는 것만 알면 된다.

상속이 있는 언어라면 draw 메서드를 가진 Component라는 클래스를 정의할 수 있다. Button, Image, SelectBox와 같은 다른 클래스들은 Component를 상속받아 draw 메서드를 물려받을 것이다. 각 클래스는 draw 메서드를 오버라이드해 자신만의 동작을 정의할 수 있지만, 프레임워크는 모든 타입을 Component 인스턴스처럼 취급하고 draw 메서드를 호출할 수 있다. 하지만 Rust에는 상속이 없기 때문에, 사용자가 새로운 타입으로 라이브러리를 확장할 수 있도록 gui 라이브러리를 구조화하는 다른 방법이 필요하다.

공통 동작을 위한 트레이트 정의

gui가 가져야 하는 동작을 구현하기 위해 Draw라는 이름의 트레이트를 정의한다. 이 트레이트는 draw라는 하나의 메서드를 가진다. 그런 다음 트레이트 객체를 받는 벡터를 정의할 수 있다. _트레이트 객체_는 지정한 트레이트를 구현한 타입의 인스턴스와 런타임에 해당 타입의 트레이트 메서드를 조회하기 위한 테이블을 모두 가리킨다. 트레이트 객체를 생성하려면 & 참조나 Box<T> 스마트 포인터 같은 포인터를 지정한 다음 dyn 키워드를 사용하고 관련 트레이트를 지정한다. (트레이트 객체가 반드시 포인터를 사용해야 하는 이유는 20장의 “동적 크기 타입과 Sized 트레이트”에서 다룬다.) 트레이트 객체는 제네릭이나 구체적인 타입 대신 사용할 수 있다. 트레이트 객체를 사용하는 곳에서는 Rust의 타입 시스템이 컴파일 시점에 해당 컨텍스트에서 사용되는 모든 값이 트레이트 객체의 트레이트를 구현하도록 보장한다. 따라서 컴파일 시점에 모든 가능한 타입을 알 필요가 없다.

Rust에서는 구조체와 열거형을 다른 언어의 객체와 구분하기 위해 “객체“라고 부르지 않는다. 구조체나 열거형에서는 구조체 필드의 데이터와 impl 블록의 동작이 분리되어 있지만, 다른 언어에서는 데이터와 동작이 하나의 개념으로 결합된 것을 종종 객체라고 부른다. 그러나 트레이트 객체는 데이터와 동작을 결합한다는 점에서 다른 언어의 객체와 더 비슷하다. 하지만 트레이트 객체는 데이터를 추가할 수 없다는 점에서 전통적인 객체와 다르다. 트레이트 객체는 다른 언어의 객체만큼 일반적으로 유용하지는 않다. 트레이트 객체의 특정 목적은 공통 동작을 통해 추상화를 가능하게 하는 것이다.

리스트 18-3은 draw라는 하나의 메서드를 가진 Draw 트레이트를 정의하는 방법을 보여준다.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}
Listing 18-3: Draw 트레이트 정의

이 구문은 10장에서 트레이트를 정의하는 방법에 대해 논의할 때 익숙해졌을 것이다. 다음은 새로운 구문이다: 리스트 18-4는 components라는 벡터를 가진 Screen 구조체를 정의한다. 이 벡터는 Box<dyn Draw> 타입으로, 트레이트 객체이다. 이는 Box 내부에 Draw 트레이트를 구현한 모든 타입을 대표한다.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}
Listing 18-4: Draw 트레이트를 구현한 트레이트 객체의 벡터를 가진 components 필드를 가진 Screen 구조체 정의

Screen 구조체에는 run이라는 메서드를 정의한다. 이 메서드는 components의 각 요소에 대해 draw 메서드를 호출한다. 리스트 18-5에서 이를 확인할 수 있다.

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-5: Screenrun 메서드: 각 컴포넌트에 대해 draw 메서드 호출

이 방식은 트레이트 바운드를 사용한 제네릭 타입 매개변수를 가진 구조체를 정의하는 것과 다르게 동작한다. 제네릭 타입 매개변수는 한 번에 하나의 구체적인 타입으로만 대체될 수 있지만, 트레이트 객체는 런타임에 여러 구체적인 타입이 트레이트 객체를 대체할 수 있도록 허용한다. 예를 들어, 리스트 18-6과 같이 제네릭 타입과 트레이트 바운드를 사용해 Screen 구조체를 정의할 수도 있다:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
where
    T: Draw,
{
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}
Listing 18-6: 제네릭과 트레이트 바운드를 사용한 Screen 구조체 및 run 메서드의 대체 구현

이 방식은 Screen 인스턴스가 모든 컴포넌트가 Button 타입이거나 모두 TextField 타입인 리스트를 가지도록 제한한다. 동일한 타입의 컬렉션만 사용한다면, 제네릭과 트레이트 바운드를 사용하는 것이 더 나은데, 정의가 컴파일 시점에 구체적인 타입을 사용하도록 단일화되기 때문이다.

반면, 트레이트 객체를 사용하는 방법에서는 하나의 Screen 인스턴스가 Box<Button>Box<TextField>를 모두 포함하는 Vec<T>를 가질 수 있다. 이 방식이 어떻게 동작하는지 살펴보고, 런타임 성능에 미치는 영향에 대해 논의해 보자.

트레이트 구현하기

이제 Draw 트레이트를 구현하는 타입들을 추가해 보자. Button 타입을 구현할 것이다. 실제 GUI 라이브러리를 구현하는 것은 이 책의 범위를 벗어나므로, draw 메서드의 본문에는 유용한 구현이 포함되지 않을 것이다. 구현이 어떻게 보일지 상상해 보자면, Button 구조체는 width, height, label 필드를 가질 수 있다. 다음은 리스트 18-7에 나온 예제다:

Filename: src/lib.rs
pub trait Draw {
    fn draw(&self);
}

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // code to actually draw a button
    }
}
Listing 18-7: Draw 트레이트를 구현한 Button 구조체

Buttonwidth, height, label 필드는 다른 컴포넌트와 다를 수 있다. 예를 들어, TextField 타입은 동일한 필드에 더해 placeholder 필드를 가질 수 있다. 화면에 그릴 각 타입은 Draw 트레이트를 구현하지만, draw 메서드에서 해당 타입을 어떻게 그릴지 정의하는 코드는 다를 것이다. Button의 경우, 사용자가 버튼을 클릭할 때 발생하는 동작과 관련된 메서드를 포함하는 추가 impl 블록을 가질 수 있다. 이러한 메서드는 TextField와 같은 타입에는 적용되지 않는다.

우리 라이브러리를 사용하는 누군가가 width, height, options 필드를 가진 SelectBox 구조체를 구현하기로 결정했다면, SelectBox 타입에서도 Draw 트레이트를 구현할 수 있다. 다음은 리스트 18-8에 나온 예제다:

Filename: src/main.rs
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

fn main() {}
Listing 18-8: gui를 사용하고 SelectBox 구조체에 Draw 트레이트를 구현한 다른 크레이트

이제 라이브러리 사용자는 main 함수를 작성해 Screen 인스턴스를 생성할 수 있다. Screen 인스턴스에 SelectBoxButton을 추가할 수 있는데, 각각을 Box<T>에 넣어 트레이트 객체로 만든다. 그런 다음 Screen 인스턴스에서 run 메서드를 호출할 수 있으며, 이 메서드는 각 컴포넌트에서 draw 메서드를 호출한다. 다음은 리스트 18-9에 나온 구현 예제다:

Filename: src/main.rs
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Button, Screen};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No"),
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}
Listing 18-9: 동일한 트레이트를 구현하는 다양한 타입의 값을 저장하기 위해 트레이트 객체 사용

우리가 라이브러리를 작성할 때는 누군가가 SelectBox 타입을 추가할지 알지 못했지만, Screen 구현은 새로운 타입에서 동작하고 이를 그릴 수 있었다. 이는 SelectBoxDraw 트레이트를 구현했기 때문이다. 즉, draw 메서드를 구현했다는 의미다.

이 개념은 값의 구체적인 타입보다는 값이 응답하는 메시지에만 관심을 갖는 것으로, 동적 타입 언어에서의 덕 타이핑(duck typing) 개념과 유사하다: 만약 어떤 것이 오리처럼 걷고 오리처럼 꽥꽥거린다면, 그것은 오리일 것이다! 리스트 18-5에서 Screenrun 구현에서 run은 각 컴포넌트의 구체적인 타입이 무엇인지 알 필요가 없다. 컴포넌트가 Button의 인스턴스인지 SelectBox의 인스턴스인지 확인하지 않고, 단순히 컴포넌트에서 draw 메서드를 호출한다. components 벡터의 값 타입으로 Box<dyn Draw>를 지정함으로써, Screendraw 메서드를 호출할 수 있는 값이 필요하다고 정의했다.

트레이트 객체와 Rust의 타입 시스템을 사용해 덕 타이핑과 유사한 코드를 작성할 때의 장점은, 런타임에 값이 특정 메서드를 구현했는지 확인하거나, 값이 메서드를 구현하지 않았는데도 호출할 때 발생할 수 있는 오류를 걱정할 필요가 없다는 점이다. Rust는 값이 트레이트 객체가 필요로 하는 트레이트를 구현하지 않았다면 코드를 컴파일하지 않는다.

예를 들어, 리스트 18-10은 String을 컴포넌트로 사용해 Screen을 생성하려고 할 때 발생하는 상황을 보여준다.

Filename: src/main.rs
use gui::Screen;

fn main() {
    let screen = Screen {
        components: vec![Box::new(String::from("Hi"))],
    };

    screen.run();
}
Listing 18-10: 트레이트 객체의 트레이트를 구현하지 않은 타입을 사용하려는 시도

StringDraw 트레이트를 구현하지 않았기 때문에 다음과 같은 오류가 발생한다:

$ cargo run
   Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
 --> src/main.rs:5:26
  |
5 |         components: vec![Box::new(String::from("Hi"))],
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
  |
  = help: the trait `Draw` is implemented for `Button`
  = note: required for the cast from `Box<String>` to `Box<dyn Draw>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error

이 오류는 우리가 Screen에 의도하지 않은 타입을 전달했음을 알려주므로, 다른 타입을 전달하거나 String에서 Draw를 구현해 Screendraw를 호출할 수 있도록 해야 한다.

트레이트 객체와 동적 디스패치

10장 “제네릭 코드의 성능”에서 컴파일러가 제네릭에 대해 수행하는 단일화(monomorphization) 과정에 대해 논의했다. 컴파일러는 제네릭 타입 파라미터 대신 사용하는 구체적인 타입마다 함수와 메서드의 비제네릭 구현을 생성한다. 단일화를 통해 생성된 코드는 _정적 디스패치(static dispatch)_를 수행한다. 정적 디스패치는 컴파일 시점에 어떤 메서드를 호출할지 컴파일러가 알 수 있는 경우를 말한다. 이와 반대로 _동적 디스패치(dynamic dispatch)_는 컴파일 시점에 어떤 메서드를 호출할지 컴파일러가 알 수 없는 경우를 의미한다. 동적 디스패치에서는 컴파일러가 런타임에 어떤 메서드를 호출할지 결정하는 코드를 생성한다.

트레이트 객체를 사용할 때 Rust는 동적 디스패치를 사용해야 한다. 컴파일러는 트레이트 객체를 사용하는 코드에서 어떤 타입이 사용될지 알 수 없기 때문에, 어떤 타입의 어떤 메서드를 호출해야 할지 결정할 수 없다. 대신 런타임에 Rust는 트레이트 객체 내부의 포인터를 사용해 호출할 메서드를 결정한다. 이 과정에서 런타임 오버헤드가 발생하며, 이는 정적 디스패치에서는 발생하지 않는다. 또한 동적 디스패치는 컴파일러가 메서드의 코드를 인라인화하는 것을 방해해 일부 최적화를 막는다. Rust는 동적 디스패치를 어디서 사용할 수 있고 없는지에 대한 규칙을 가지고 있으며, 이를 _dyn 호환성(dyn compatibility)_이라고 한다. 이 규칙은 이 논의의 범위를 벗어나지만, 레퍼런스에서 더 자세히 알아볼 수 있다. 그러나 리스트 18-5리스트 18-9에서 작성한 코드는 추가적인 유연성을 얻을 수 있었으므로, 이는 고려해야 할 트레이드오프다.

객체 지향 디자인 패턴 구현

상태 패턴은 객체 지향 디자인 패턴 중 하나다. 이 패턴의 핵심은 값이 내부적으로 가질 수 있는 상태 집합을 정의하는 것이다. 상태는 상태 객체 집합으로 표현되며, 값의 동작은 현재 상태에 따라 달라진다. 이 예제에서는 블로그 포스트를 다루며, 포스트는 내부 상태를 나타내는 필드를 가진다. 상태는 “초안”, “검토 중”, “게시됨” 중 하나가 된다.

상태 객체는 기능을 공유한다. Rust에서는 객체와 상속 대신 구조체와 트레이트를 사용한다. 각 상태 객체는 자신의 동작과 상태 전환 시기를 관리한다. 상태 객체를 포함하는 값은 상태 간의 다른 동작이나 상태 전환 시기에 대해 알지 못한다.

상태 패턴을 사용하면 프로그램의 비즈니스 요구사항이 변경되었을 때, 상태를 포함하는 값이나 값을 사용하는 코드를 변경할 필요가 없다. 단지 상태 객체 중 하나의 코드를 업데이트하여 규칙을 변경하거나 새로운 상태 객체를 추가하면 된다.

먼저 전통적인 객체 지향 방식으로 상태 패턴을 구현한 다음, Rust에 더 적합한 방식으로 접근한다. 상태 패턴을 사용해 블로그 포스트 워크플로우를 점진적으로 구현해보자.

최종 기능은 다음과 같다:

  1. 블로그 포스트는 빈 초안 상태로 시작한다.
  2. 초안이 완료되면 포스트 검토를 요청한다.
  3. 포스트가 승인되면 게시된다.
  4. 게시된 블로그 포스트만 콘텐츠를 반환하므로, 승인되지 않은 포스트가 실수로 게시되는 것을 방지한다.

그 외의 변경 시도는 아무런 효과가 없어야 한다. 예를 들어, 검토를 요청하기 전에 초안 블로그 포스트를 승인하려고 하면 포스트는 게시되지 않은 초안 상태로 유지된다.

리스트 18-11은 이 워크플로우를 코드로 보여준다. 이 코드는 blog라는 라이브러리 크레이트에서 구현할 API의 사용 예시다. 아직 blog 크레이트를 구현하지 않았기 때문에 이 코드는 컴파일되지 않는다.

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}
Listing 18-11: 우리가 blog 크레이트에 구현하고자 하는 동작을 보여주는 코드

사용자가 Post::new를 통해 새로운 초안 블로그 포스트를 생성할 수 있도록 한다. 블로그 포스트에 텍스트를 추가할 수 있어야 한다. 승인 전에 포스트의 콘텐츠를 바로 가져오려고 하면, 포스트가 여전히 초안 상태이므로 텍스트를 얻지 못해야 한다. 코드에는 설명을 위해 assert_eq!를 추가했다. 이에 대한 훌륭한 단위 테스트는 초안 블로그 포스트가 content 메서드에서 빈 문자열을 반환하는지 확인하는 것이다. 하지만 이 예제에서는 테스트를 작성하지 않는다.

다음으로, 포스트 검토를 요청할 수 있도록 하고, 검토를 기다리는 동안 content가 빈 문자열을 반환하도록 한다. 포스트가 승인되면 게시되어, content가 호출될 때 포스트의 텍스트가 반환된다.

크레이트에서 상호작용하는 유일한 타입은 Post 타입이다. 이 타입은 상태 패턴을 사용하며, 포스트가 가질 수 있는 다양한 상태(초안, 검토 중, 게시됨)를 나타내는 세 가지 상태 객체 중 하나를 값으로 가진다. 한 상태에서 다른 상태로의 전환은 Post 타입 내부에서 관리된다. 상태는 Post 인스턴스에 대해 라이브러리 사용자가 호출한 메서드에 따라 변경되지만, 사용자가 직접 상태 변경을 관리할 필요는 없다. 또한 사용자는 검토 전에 포스트를 게시하는 등의 실수를 할 수 없다.

Post 정의와 초기 상태로 인스턴스 생성하기

라이브러리 구현을 시작해 보자. 우선 Post 구조체가 필요하며, 이 구조체는 일부 콘텐츠를 보관한다. 따라서 Post 구조체를 정의하고, Post 인스턴스를 생성하는 new 함수를 함께 구현한다. 또한 Post의 모든 상태 객체가 갖춰야 할 동작을 정의하는 State 트레이트를 비공개로 만든다.

Poststate라는 비공개 필드에 Box<dyn State> 타입의 트레이트 객체를 Option<T>로 보관한다. 이렇게 Option<T>를 사용하는 이유는 조금 뒤에 설명한다.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-12: Post 구조체와 new 함수 정의, State 트레이트, Draft 구조체

State 트레이트는 다양한 포스트 상태가 공유하는 동작을 정의한다. 상태 객체는 Draft, PendingReview, Published이며, 이들은 모두 State 트레이트를 구현한다. 현재는 트레이트에 메서드가 없으며, 포스트가 시작하는 상태인 Draft 상태부터 정의한다.

새로운 Post를 생성할 때, state 필드를 Box를 담은 Some 값으로 설정한다. 이 BoxDraft 구조체의 새 인스턴스를 가리킨다. 이렇게 하면 Post의 새 인스턴스를 생성할 때마다 초기 상태가 Draft가 된다. Poststate 필드는 비공개이므로, 다른 상태로 Post를 생성할 방법은 없다! Post::new 함수에서 content 필드는 새로운 빈 String으로 설정한다.

게시물 콘텐츠 텍스트 저장하기

리스트 18-11에서 add_text라는 메서드를 호출하고, &str 타입의 값을 전달하여 블로그 게시물의 텍스트 콘텐츠로 추가할 수 있도록 하려는 것을 확인했다. 이 기능을 content 필드를 pub으로 공개하는 대신 메서드로 구현한 이유는 나중에 content 필드의 데이터를 읽는 방식을 제어할 수 있는 메서드를 구현하기 위함이다. add_text 메서드는 매우 직관적이므로, 리스트 18-13의 구현을 impl Post 블록에 추가해 보자.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-13: 게시물의 content에 텍스트를 추가하는 add_text 메서드 구현

add_text 메서드는 self에 대한 가변 참조를 받는다. 이는 add_text를 호출하는 Post 인스턴스를 변경하기 때문이다. 그런 다음 content에 있는 Stringpush_str을 호출하고, text 인자를 전달하여 저장된 content에 추가한다. 이 동작은 게시물의 상태에 의존하지 않으므로 상태 패턴의 일부가 아니다. add_text 메서드는 state 필드와 전혀 상호작용하지 않지만, 우리가 지원하고자 하는 동작의 일부이다.

초안 게시물의 내용이 비어 있는지 확인하기

add_text를 호출하고 게시물에 일부 내용을 추가한 후에도, 게시물이 아직 초안 상태이기 때문에 content 메서드는 빈 문자열 슬라이스를 반환해야 한다. 이는 목록 18-11의 7번째 줄에서 확인할 수 있다. 현재로서는 이 요구 사항을 충족시키기 위해 가장 간단한 방법으로 content 메서드를 구현한다. 즉, 항상 빈 문자열 슬라이스를 반환하는 것이다. 나중에 게시물 상태를 변경하여 게시할 수 있는 기능을 구현하면 이 부분을 수정할 예정이다. 지금은 게시물이 초안 상태에만 있을 수 있으므로, 게시물 내용은 항상 비어 있어야 한다. 목록 18-14는 이 플레이스홀더 구현을 보여준다.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Listing 18-14: Post에 대한 content 메서드의 플레이스홀더 구현 추가. 항상 빈 문자열 슬라이스를 반환한다.

이렇게 추가된 content 메서드로 인해, 목록 18-11의 7번째 줄까지 모든 내용이 의도한 대로 작동한다.

리뷰 요청으로 게시물 상태 변경하기

다음으로 게시물의 리뷰를 요청하는 기능을 추가해야 한다. 이 기능은 게시물의 상태를 Draft에서 PendingReview로 변경한다. 리스트 18-15는 이 코드를 보여준다.

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-15: PostState 트레잇에 request_review 메서드 구현

Postrequest_review라는 공개 메서드를 추가한다. 이 메서드는 self에 대한 가변 참조를 받는다. 그런 다음 Post의 현재 상태에 대해 내부 request_review 메서드를 호출한다. 이 두 번째 request_review 메서드는 현재 상태를 소비하고 새로운 상태를 반환한다.

State 트레잇에 request_review 메서드를 추가한다. 이제 이 트레잇을 구현하는 모든 타입은 request_review 메서드를 구현해야 한다. 메서드의 첫 번째 매개변수로 self, &self, 또는 &mut self 대신 self: Box<Self>를 사용한다. 이 구문은 해당 메서드가 Box에 담긴 타입에서만 호출될 수 있음을 의미한다. 이 구문은 Box<Self>의 소유권을 가져가므로, 이전 상태가 무효화되고 Post의 상태 값이 새로운 상태로 변환될 수 있다.

이전 상태를 소비하기 위해 request_review 메서드는 상태 값의 소유권을 가져와야 한다. 이때 Poststate 필드에 있는 Option이 사용된다. take 메서드를 호출해 state 필드에서 Some 값을 꺼내고 그 자리에 None을 남긴다. Rust는 구조체에서 비어 있는 필드를 허용하지 않기 때문이다. 이를 통해 state 값을 빌리는 대신 Post에서 이동시킬 수 있다. 그런 다음 이 작업의 결과를 poststate 값으로 설정한다.

state 값을 직접 self.state = self.state.request_review();와 같은 코드로 설정하는 대신, 일시적으로 None으로 설정해야 한다. 이렇게 하면 state 값의 소유권을 얻을 수 있다. 이는 Post가 상태를 새로운 상태로 변환한 후 이전 state 값을 사용할 수 없도록 보장한다.

Draftrequest_review 메서드는 새로운 PendingReview 구조체의 박스 인스턴스를 반환한다. 이 구조체는 게시물이 리뷰를 기다리는 상태를 나타낸다. PendingReview 구조체도 request_review 메서드를 구현하지만 아무런 변환을 수행하지 않는다. 대신, 이미 PendingReview 상태에 있는 게시물에 대해 리뷰를 요청하면 PendingReview 상태를 유지해야 하므로 자기 자신을 반환한다.

이제 상태 패턴의 장점을 확인할 수 있다. Postrequest_review 메서드는 state 값에 관계없이 동일하다. 각 상태는 자신의 규칙을 책임진다.

Postcontent 메서드는 그대로 두고 빈 문자열 슬라이스를 반환한다. 이제 PostPendingReview 상태와 Draft 상태로 둘 수 있다. 하지만 PendingReview 상태에서도 동일한 동작을 원한다. 리스트 18-11은 이제 10번째 줄까지 작동한다!

content 동작을 변경하기 위해 approve 추가하기

approve 메서드는 request_review 메서드와 유사하게 동작한다. 현재 상태가 승인되었을 때 설정해야 하는 값으로 state를 변경한다. 이 내용은 리스트 18-16에서 확인할 수 있다:

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        ""
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-16: PostState 트레잇에 approve 메서드 구현

State 트레잇에 approve 메서드를 추가하고, State를 구현하는 새로운 구조체인 Published 상태를 정의한다.

PendingReview에서 request_review가 동작하는 방식과 마찬가지로, Draft에서 approve 메서드를 호출하면 아무런 효과가 없다. approveself를 반환하기 때문이다. PendingReview에서 approve를 호출하면 Published 구조체의 새로운 박스 인스턴스를 반환한다. Published 구조체는 State 트레잇을 구현하며, request_review 메서드와 approve 메서드 모두에서 self를 반환한다. 이 경우 포스트는 Published 상태로 유지되어야 하기 때문이다.

이제 Postcontent 메서드를 업데이트해야 한다. content에서 반환되는 값이 Post의 현재 상태에 따라 달라지도록 하기 위해, Poststate에 정의된 content 메서드에 위임하도록 한다. 이 내용은 리스트 18-17에서 확인할 수 있다:

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Listing 18-17: Postcontent 메서드를 Statecontent 메서드에 위임하도록 업데이트

이 규칙들을 State를 구현하는 구조체 내부에 유지하기 위해, state의 값에 content 메서드를 호출하고 post 인스턴스(즉, self)를 인자로 전달한다. 그런 다음 state 값의 content 메서드를 사용해 반환된 값을 반환한다.

Optionas_ref 메서드를 호출하는 이유는 Option 내부 값의 소유권이 아닌 참조를 얻기 위해서다. stateOption<Box<dyn State>>이기 때문에, as_ref를 호출하면 Option<&Box<dyn State>>가 반환된다. as_ref를 호출하지 않으면 함수 매개변수의 &self에서 state를 이동시킬 수 없기 때문에 에러가 발생한다.

그런 다음 unwrap 메서드를 호출하는데, 이 메서드는 절대 패닉을 일으키지 않는다. Post의 메서드들이 완료될 때 state가 항상 Some 값을 포함하도록 보장하기 때문이다. 이는 9장의 “컴파일러보다 더 많은 정보를 가진 경우”에서 다룬 경우 중 하나로, 컴파일러가 이해할 수는 없지만 None 값이 절대 발생하지 않음을 알고 있는 상황이다.

이 시점에서 &Box<dyn State>content를 호출하면, &Box에 대한 역참조 강제가 적용되어 State 트레잇을 구현하는 타입의 content 메서드가 최종적으로 호출된다. 이는 State 트레잇 정의에 content를 추가해야 함을 의미하며, 여기에 현재 상태에 따라 반환할 콘텐츠에 대한 로직을 넣는다. 이 내용은 리스트 18-18에서 확인할 수 있다:

Filename: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }

    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}
Listing 18-18: State 트레잇에 content 메서드 추가

content 메서드에 기본 구현을 추가해 빈 문자열 슬라이스를 반환하도록 한다. 이는 DraftPendingReview 구조체에서 content를 구현할 필요가 없음을 의미한다. Published 구조체는 content 메서드를 오버라이드하고 post.content의 값을 반환한다.

10장에서 논의한 것처럼 이 메서드에 라이프타임 주석이 필요하다. post에 대한 참조를 인자로 받고 그 post의 일부에 대한 참조를 반환하기 때문에, 반환된 참조의 라이프타임은 post 인자의 라이프타임과 관련이 있다.

이제 리스트 18-11의 모든 내용이 작동한다! 블로그 포스트 워크플로우의 규칙을 따라 상태 패턴을 구현했다. 규칙과 관련된 로직은 Post 전체에 흩어져 있지 않고 상태 객체 내부에 존재한다.

왜 열거형을 사용하지 않았나요?

포스트 상태를 나타내는 열거형을 사용하지 않은 이유가 궁금할 수 있다. 이 방법도 가능한 해결책이다. 직접 시도해보고 최종 결과를 비교해보자! 열거형을 사용할 때의 단점은 열거형의 값을 확인하는 모든 곳에서 가능한 모든 변형을 처리하기 위해 match 표현식이나 유사한 것이 필요하다는 점이다. 이는 트레잇 객체를 사용한 해결책보다 더 반복적일 수 있다.

상태 패턴의 장단점

Rust가 객체 지향의 상태 패턴을 구현할 수 있음을 확인했다. 이 패턴은 각 상태에 따라 포스트가 가져야 하는 다양한 동작을 캡슐화한다. Post의 메서드는 다양한 동작에 대해 전혀 알지 못한다. 코드를 이렇게 구성하면, 포스트가 발행 상태일 때 어떤 동작을 하는지 알고 싶을 때 단 한 곳만 확인하면 된다: Published 구조체에서 State 트레이트를 구현한 부분이다.

만약 상태 패턴을 사용하지 않는 대안적인 구현을 한다면, Post의 메서드나 main 코드에서 match 표현식을 사용해 포스트의 상태를 확인하고 그에 따라 동작을 변경할 수도 있다. 하지만 이렇게 하면 포스트가 발행 상태일 때의 모든 의미를 이해하기 위해 여러 곳을 살펴봐야 한다! 상태가 더 많아질수록 이 문제는 더 심각해진다: 각 match 표현식에 새로운 분기(arm)를 추가해야 하기 때문이다.

상태 패턴을 사용하면 Post 메서드와 Post를 사용하는 곳에서 match 표현식이 필요 없으며, 새로운 상태를 추가할 때는 단순히 새로운 구조체를 추가하고 해당 구조체에 트레이트 메서드를 구현하기만 하면 된다.

상태 패턴을 사용한 구현은 기능을 추가하기 쉽다. 상태 패턴을 사용한 코드를 유지보수하는 것이 얼마나 간단한지 확인하기 위해, 다음 제안을 시도해 보자:

  • PendingReview 상태에서 Draft 상태로 변경하는 reject 메서드를 추가한다.
  • Published 상태로 변경하기 전에 approve 메서드를 두 번 호출하도록 요구한다.
  • 포스트가 Draft 상태일 때만 사용자가 텍스트 콘텐츠를 추가할 수 있도록 허용한다. 힌트: 상태 객체가 콘텐츠의 변경 가능성을 책임지되, Post를 수정하는 책임은 지지 않도록 한다.

상태 패턴의 단점 중 하나는, 상태 간 전환을 상태 자체가 구현하기 때문에 일부 상태가 서로 결합된다는 점이다. 예를 들어 PendingReviewPublished 사이에 Scheduled와 같은 새로운 상태를 추가한다면, PendingReview의 코드를 수정해 Scheduled로 전환하도록 변경해야 한다. PendingReview가 새로운 상태의 추가에 따라 변경될 필요가 없다면 작업량이 줄어들겠지만, 그렇게 하려면 다른 디자인 패턴으로 전환해야 한다.

또 다른 단점은 일부 로직이 중복된다는 점이다. 이 중복을 줄이기 위해 State 트레이트에 request_reviewapprove 메서드의 기본 구현을 추가해 self를 반환하도록 할 수도 있다. 하지만 이 방법은 동작하지 않는다: State를 트레이트 객체로 사용할 때, 트레이트는 구체적인 self가 무엇인지 정확히 알지 못하기 때문에 반환 타입을 컴파일 시점에 알 수 없다. (이는 앞서 언급한 dyn 호환성 규칙 중 하나다.)

다른 중복 사례로는 Postrequest_reviewapprove 메서드의 유사한 구현이 있다. 두 메서드 모두 Poststate 필드에 Option::take를 사용하며, stateSome이면 래핑된 값의 동일한 메서드 구현에 위임하고, state 필드의 새 값을 결과로 설정한다. 만약 Post에 이 패턴을 따르는 메서드가 많다면, 반복을 줄이기 위해 매크로를 정의하는 것을 고려할 수 있다. (20장의 “매크로” 참조)

객체 지향 언어를 위해 정의된 상태 패턴을 그대로 구현하면, Rust의 강점을 최대한 활용하지 못하게 된다. blog 크레이트를 변경해 유효하지 않은 상태와 전환을 컴파일 타임 오류로 만들 수 있는 몇 가지 방법을 살펴보자.

상태와 동작을 타입으로 인코딩하기

이번에는 상태 패턴을 재고하여 다른 방식의 트레이드오프를 얻는 방법을 알아본다. 상태와 전환을 완전히 캡슐화해 외부 코드가 알 수 없게 하는 대신, 상태를 서로 다른 타입으로 인코딩한다. 이를 통해 Rust의 타입 검사 시스템이 컴파일러 오류를 발생시켜, 게시된 게시물만 허용되는 곳에서 초안 게시물을 사용하려는 시도를 방지한다.

Listing 18-11의 main 함수 첫 부분을 살펴보자:

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

Post::new를 사용해 초안 상태의 새 게시물을 생성하고, 게시물 내용에 텍스트를 추가하는 기능은 여전히 유지한다. 하지만 초안 게시물에서 빈 문자열을 반환하는 content 메서드를 제공하는 대신, 초안 게시물에는 content 메서드 자체를 없앤다. 이렇게 하면 초안 게시물의 내용을 가져오려고 할 때 메서드가 존재하지 않는다는 컴파일러 오류가 발생한다. 결과적으로, 초안 게시물의 내용을 실수로 프로덕션 환경에서 표시하는 일은 발생하지 않는다. 해당 코드는 컴파일조차 되지 않기 때문이다. Listing 18-19는 Post 구조체와 DraftPost 구조체의 정의, 그리고 각 구조체의 메서드를 보여준다.

Filename: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}
Listing 18-19: content 메서드가 있는 Postcontent 메서드가 없는 DraftPost

PostDraftPost 구조체 모두 블로그 게시물 텍스트를 저장하는 content 필드를 가지고 있다. 이제 구조체에는 state 필드가 없다. 상태를 인코딩하는 방식을 구조체의 타입으로 옮겼기 때문이다. Post 구조체는 게시된 게시물을 나타내며, content 메서드를 통해 content를 반환한다.

Post::new 함수는 여전히 존재하지만, Post 인스턴스 대신 DraftPost 인스턴스를 반환한다. content 필드는 비공개이며, Post를 반환하는 함수가 없기 때문에 현재로서는 Post 인스턴스를 생성할 수 없다.

DraftPost 구조체에는 add_text 메서드가 있어 이전과 마찬가지로 content에 텍스트를 추가할 수 있다. 하지만 DraftPost에는 content 메서드가 정의되어 있지 않다는 점에 주목하자! 이제 프로그램은 모든 게시물이 초안 상태로 시작하고, 초안 게시물의 내용은 표시할 수 없도록 보장한다. 이러한 제약을 우회하려는 모든 시도는 컴파일러 오류를 발생시킬 것이다.

트랜지션을 다른 타입으로 변환하여 구현하기

그렇다면 어떻게 게시물을 발행할 수 있을까? 우리는 초안 상태의 게시물이 반드시 검토와 승인을 거쳐야만 발행될 수 있도록 규칙을 강제하고 싶다. 검토 대기 중인 게시물은 여전히 어떤 내용도 표시하지 않아야 한다. 이 제약 조건을 구현하기 위해 PendingReviewPost라는 새로운 구조체를 추가하고, DraftPostrequest_review 메서드를 정의하여 PendingReviewPost를 반환하도록 한다. 또한 PendingReviewPostapprove 메서드를 정의하여 Post를 반환하도록 한다. 이는 리스팅 18-20에서 확인할 수 있다.

Filename: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
        &self.content
    }
}

impl DraftPost {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}
Listing 18-20: DraftPost에서 request_review를 호출해 생성되는 PendingReviewPostPendingReviewPost를 발행된 Post로 변환하는 approve 메서드

request_reviewapprove 메서드는 self의 소유권을 가져가므로, DraftPostPendingReviewPost 인스턴스를 소비하고 각각 PendingReviewPost와 발행된 Post로 변환한다. 이렇게 하면 request_review를 호출한 후에도 DraftPost 인스턴스가 남아 있지 않게 된다. PendingReviewPost 구조체는 content 메서드가 정의되어 있지 않으므로, DraftPost와 마찬가지로 내용을 읽으려고 하면 컴파일러 오류가 발생한다. content 메서드가 정의된 발행된 Post 인스턴스를 얻는 유일한 방법은 PendingReviewPost에서 approve 메서드를 호출하는 것이고, PendingReviewPost를 얻는 유일한 방법은 DraftPost에서 request_review 메서드를 호출하는 것이다. 이렇게 하여 블로그 게시물의 워크플로우를 타입 시스템에 인코딩했다.

그러나 main에도 약간의 변경이 필요하다. request_reviewapprove 메서드는 호출된 구조체를 수정하는 대신 새로운 인스턴스를 반환하므로, 반환된 인스턴스를 저장하기 위해 let post = 섀도잉 할당을 추가해야 한다. 또한 초안 및 검토 대기 중인 게시물의 내용이 빈 문자열이라는 단언은 더 이상 필요하지 않다. 이 상태의 게시물 내용을 사용하려는 코드는 컴파일할 수 없기 때문이다. 업데이트된 main 코드는 리스팅 18-21에서 확인할 수 있다.

Filename: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}
Listing 18-21: 새로운 블로그 게시물 워크플로우 구현을 사용하도록 수정된 main

post를 재할당하기 위해 main에 필요한 변경 사항은 이 구현이 더 이상 객체 지향적인 상태 패턴을 따르지 않음을 의미한다. 상태 간의 변환이 Post 구현 내에 완전히 캡슐화되지 않았기 때문이다. 그러나 우리가 얻은 이점은 타입 시스템과 컴파일 타임에 발생하는 타입 검사 덕분에 이제 잘못된 상태가 불가능해졌다는 점이다. 이는 발행되지 않은 게시물의 내용이 표시되는 등의 특정 버그가 프로덕션 환경에 도달하기 전에 발견되도록 보장한다.

리스팅 18-21 이후의 blog 크레이트에서 이 섹션의 시작 부분에서 제안된 작업을 시도해보고 이 버전의 코드 설계에 대해 어떻게 생각하는지 확인해보자. 이 설계에서 일부 작업은 이미 완료되었을 수도 있다.

우리는 Rust가 객체 지향적인 디자인 패턴을 구현할 수 있지만, 타입 시스템에 상태를 인코딩하는 것과 같은 다른 패턴도 Rust에서 사용 가능하다는 것을 확인했다. 이러한 패턴은 서로 다른 장단점을 가진다. 객체 지향 패턴에 익숙할지라도, Rust의 기능을 활용하기 위해 문제를 재고하는 것은 컴파일 타임에 일부 버그를 방지하는 등의 이점을 제공할 수 있다. 소유권과 같은 객체 지향 언어에는 없는 특정 기능 때문에 Rust에서 객체 지향 패턴이 항상 최선의 해결책은 아니다.

요약

이 장을 읽고 나면 Rust가 객체 지향 언어인지 여부에 대한 의견은 각자 다를 수 있다. 그러나 이제 트레이트 객체를 사용해 Rust에서 객체 지향적 기능을 활용할 수 있다는 점은 분명히 알게 됐다. 동적 디스패치는 런타임 성능을 약간 희생하는 대신 코드에 유연성을 제공한다. 이 유연성을 활용해 코드의 유지보수성을 높이는 객체 지향 패턴을 구현할 수 있다. 또한 Rust는 소유권과 같은 객체 지향 언어에는 없는 고유한 기능도 제공한다. Rust의 강점을 최대한 활용하기 위해 객체 지향 패턴이 항상 최선의 방법은 아니지만, 선택 가능한 옵션 중 하나임은 분명하다.

다음 장에서는 Rust의 또 다른 유연한 기능인 패턴에 대해 알아본다. 이 책 전반에서 간략히 살펴봤지만, 아직 그 모든 잠재력을 확인하진 못했다. 이제 본격적으로 파헤쳐보자!

패턴과 매칭

_패턴_은 Rust에서 타입의 구조를 매칭하기 위한 특별한 문법이다. 단순한 타입부터 복잡한 타입까지 다양한 구조를 처리할 수 있다. match 표현식과 함께 패턴을 사용하면 프로그램의 흐름을 더 세밀하게 제어할 수 있다. 패턴은 다음과 같은 요소들의 조합으로 구성된다:

  • 리터럴
  • 구조 분해된 배열, 열거형, 구조체, 튜플
  • 변수
  • 와일드카드
  • 플레이스홀더

패턴의 예로는 x, (a, 3), Some(Color::Red) 등이 있다. 패턴이 유효한 컨텍스트에서 이러한 요소들은 데이터의 형태를 설명한다. 프로그램은 값을 패턴과 비교하여 특정 코드를 실행하기 위한 올바른 데이터 형태인지 판단한다.

패턴을 사용하려면 특정 값과 비교한다. 패턴이 값과 일치하면, 코드에서 해당 값의 부분을 활용할 수 있다. 6장에서 다룬 동전 정렬기 예제와 같이 패턴을 사용한 match 표현식을 떠올려보자. 값이 패턴의 형태와 일치하면, 명명된 부분을 사용할 수 있다. 일치하지 않으면 해당 패턴과 연결된 코드는 실행되지 않는다.

이 장은 패턴과 관련된 모든 것을 다루는 참고 자료이다. 패턴을 사용할 수 있는 유효한 위치, 반박 가능한 패턴과 반박 불가능한 패턴의 차이, 그리고 다양한 패턴 문법을 살펴본다. 이 장을 마치면 패턴을 사용해 많은 개념을 명확하게 표현하는 방법을 이해할 수 있다.

패턴이 사용될 수 있는 모든 곳

러스트에서 패턴은 다양한 곳에서 등장한다. 여러분은 이미 패턴을 많이 사용하고 있었지만, 이를 깨닫지 못했을 수 있다. 이 섹션에서는 패턴이 유효하게 사용될 수 있는 모든 경우를 살펴본다.

match Arm

6장에서 다룬 것처럼, match 표현식의 각 Arm에는 패턴을 사용한다. 공식적으로 match 표현식은 match 키워드, 매칭할 값, 그리고 패턴과 그 패턴에 해당하는 경우 실행할 표현식으로 구성된 하나 이상의 Arm으로 정의된다. 아래와 같은 구조를 가진다.

match VALUE {
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
}

예를 들어, 리스팅 6-5의 match 표현식은 변수 x에 저장된 Option<i32> 타입의 값을 매칭한다.

match x {
    None => None,
    Some(i) => Some(i + 1),
}

match 표현식에서 각 화살표 왼쪽에 있는 NoneSome(i)가 패턴이다.

match 표현식은 반드시 모든 가능성을 다뤄야 한다는 점에서 **완전성(exhaustive)**이 요구된다. 모든 가능성을 다루기 위한 한 가지 방법은 마지막 Arm에 모든 경우를 포괄하는 패턴을 추가하는 것이다. 예를 들어, 어떤 값이든 매칭할 수 있는 변수 이름을 사용하면 실패할 일이 없으므로 나머지 모든 경우를 커버할 수 있다.

특히 _ 패턴은 어떤 값과도 매칭되지만, 변수에 바인딩되지 않는다. 따라서 주로 마지막 match Arm에서 사용된다. _ 패턴은 지정되지 않은 값을 무시하고 싶을 때 유용하다. 이 패턴에 대한 자세한 내용은 이 장의 뒷부분인 “패턴에서 값 무시하기”에서 다룰 것이다.

조건부 if let 표현식

6장에서는 if let 표현식을 주로 한 가지 경우만 매칭하는 match의 짧은 형태로 사용하는 방법을 다뤘다. 추가적으로, if let은 패턴이 매칭되지 않을 때 실행할 코드를 포함하는 else를 함께 사용할 수 있다.

Listing 19-1은 if let, else if, else if let 표현식을 혼합하여 사용할 수 있음을 보여준다. 이렇게 하면 하나의 값만 패턴과 비교할 수 있는 match 표현식보다 더 많은 유연성을 얻을 수 있다. 또한 Rust는 if let, else if, else if let의 조건들이 서로 관련이 있을 것을 요구하지 않는다.

Listing 19-1의 코드는 여러 조건을 검사하여 배경색을 결정한다. 이 예제에서는 실제 프로그램이 사용자 입력으로 받을 수 있는 값을 하드코딩한 변수를 사용했다.

Filename: src/main.rs
fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();

    if let Some(color) = favorite_color {
        println!("Using your favorite color, {color}, as the background");
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color");
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color");
    }
}
Listing 19-1: if let, else if, else if let, else 혼합 사용

사용자가 선호하는 색상을 지정하면 그 색상이 배경색으로 사용된다. 선호하는 색상이 지정되지 않고 오늘이 화요일이라면 배경색은 초록색이 된다. 그렇지 않고 사용자가 나이를 문자열로 지정했고 이를 숫자로 성공적으로 파싱할 수 있다면, 숫자의 값에 따라 보라색 또는 주황색이 배경색이 된다. 이 모든 조건에 해당하지 않으면 배경색은 파란색이 된다.

이 조건부 구조는 복잡한 요구사항을 지원할 수 있게 해준다. 여기서 사용한 하드코딩된 값으로 인해, 이 예제는 Using purple as the background color를 출력할 것이다.

if letmatch의 경우와 마찬가지로 기존 변수를 가리는 새로운 변수를 도입할 수 있다. if let Ok(age) = age 라인은 Ok 변형 내부의 값을 포함하는 새로운 age 변수를 도입하며, 이는 기존의 age 변수를 가린다. 이는 if age > 30 조건을 해당 블록 내에 위치시켜야 함을 의미한다. 즉, if let Ok(age) = age && age > 30과 같이 두 조건을 결합할 수 없다. 새로운 age가 30과 비교되기 위해서는 중괄호로 시작하는 새로운 스코프가 시작되어야 한다.

if let 표현식을 사용할 때의 단점은 컴파일러가 모든 경우를 검사하지 않는다는 점이다. 반면 match 표현식은 모든 경우를 검사한다. 만약 마지막 else 블록을 생략하고 일부 경우를 처리하지 않았다면, 컴파일러는 잠재적인 논리 오류에 대해 경고하지 않을 것이다.

while let 조건부 루프

if let과 유사한 구조를 가진 while let 조건부 루프는 패턴이 계속 일치하는 동안 while 루프를 실행한다. 예제 19-2에서는 스레드 간에 전송된 메시지를 기다리는 while let 루프를 보여주지만, 이 경우 Option 대신 Result를 확인한다.

fn main() {
    let (tx, rx) = std::sync::mpsc::channel();
    std::thread::spawn(move || {
        for val in [1, 2, 3] {
            tx.send(val).unwrap();
        }
    });

    while let Ok(value) = rx.recv() {
        println!("{value}");
    }
}
Listing 19-2: rx.recv()Ok를 반환하는 동안 값을 출력하는 while let 루프 사용

이 예제는 1, 2, 그리고 3을 출력한다. recv 메서드는 채널의 수신 측에서 첫 번째 메시지를 가져와 Ok(value)를 반환한다. 16장에서 recv를 처음 봤을 때, 에러를 직접 언래핑하거나 for 루프를 사용해 반복자로 다뤘다. 그러나 예제 19-2에서 보듯이, while let을 사용할 수도 있다. recv 메서드는 메시지가 도착할 때마다 Ok를 반환하며, 송신자가 존재하는 한 계속해서 메시지를 받다가 송신자 측이 연결을 끊으면 Err를 생성한다.

for 반복문

for 반복문에서 for 키워드 바로 뒤에 오는 값은 패턴이다. 예를 들어, for x in y에서 x는 패턴이다. 리스트 19-3은 for 반복문에서 튜플을 분해하거나 나누기 위해 패턴을 사용하는 방법을 보여준다.

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (index, value) in v.iter().enumerate() {
        println!("{value} is at index {index}");
    }
}
Listing 19-3: for 반복문에서 튜플을 분해하기 위해 패턴 사용

리스트 19-3의 코드는 다음과 같은 결과를 출력한다:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.52s
     Running `target/debug/patterns`
a is at index 0
b is at index 1
c is at index 2

enumerate 메서드를 사용해 이터레이터를 조정하면 해당 값과 그 값의 인덱스를 튜플로 생성한다. 첫 번째로 생성된 값은 튜플 (0, 'a')이다. 이 값을 패턴 (index, value)와 매칭하면 index0이 되고 value'a'가 되어 출력의 첫 번째 줄을 출력한다.

let

이 장을 시작하기 전에, 우리는 matchif let에서 패턴을 사용하는 것만 명시적으로 다뤘다. 하지만 사실, 우리는 let 문을 포함한 다른 곳에서도 패턴을 사용해 왔다. 예를 들어, 다음과 같이 let을 사용한 간단한 변수 할당을 생각해 보자:

#![allow(unused)]
fn main() {
let x = 5;
}

이렇게 let 문을 사용할 때마다 패턴을 사용하고 있다. 물론 그것을 깨닫지 못했을 수도 있지만! 좀 더 공식적으로, let 문은 다음과 같이 구성된다:

let PATTERN = EXPRESSION;

let x = 5;와 같은 문장에서 PATTERN 자리에 변수 이름이 오면, 이 변수 이름은 패턴의 매우 간단한 형태이다. Rust는 표현식을 패턴과 비교하고, 패턴에서 발견된 이름에 값을 할당한다. 따라서 let x = 5; 예제에서 x는 “여기에 매칭되는 값을 변수 x에 바인딩하라“는 의미의 패턴이다. x라는 이름이 전체 패턴이기 때문에, 이 패턴은 “어떤 값이든 상관없이 모든 것을 변수 x에 바인딩하라“는 의미가 된다.

let의 패턴 매칭 측면을 더 명확히 이해하기 위해, Listing 19-4를 살펴보자. 이 예제는 let과 함께 패턴을 사용해 튜플을 분해한다.

fn main() {
    let (x, y, z) = (1, 2, 3);
}
Listing 19-4: 패턴을 사용해 튜플을 분해하고 한 번에 세 개의 변수를 생성

여기서는 튜플을 패턴과 매칭시킨다. Rust는 값 (1, 2, 3)을 패턴 (x, y, z)와 비교하고, 두 요소의 개수가 동일하므로 값이 패턴과 일치한다고 판단한다. 따라서 Rust는 1x에, 2y에, 3z에 바인딩한다. 이 튜플 패턴은 세 개의 개별 변수 패턴을 중첩한 것으로 생각할 수 있다.

만약 패턴의 요소 개수가 튜플의 요소 개수와 일치하지 않으면, 전체 타입이 일치하지 않아 컴파일러 에러가 발생한다. 예를 들어, Listing 19-5는 세 개의 요소를 가진 튜플을 두 개의 변수로 분해하려는 시도를 보여준다. 이 코드는 동작하지 않는다.

fn main() {
    let (x, y) = (1, 2, 3);
}
Listing 19-5: 튜플의 요소 개수와 변수 개수가 일치하지 않는 패턴을 잘못 구성한 예

이 코드를 컴파일하려고 하면 다음과 같은 타입 에러가 발생한다:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0308]: mismatched types
 --> src/main.rs:2:9
  |
2 |     let (x, y) = (1, 2, 3);
  |         ^^^^^^   --------- this expression has type `({integer}, {integer}, {integer})`
  |         |
  |         expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected tuple `({integer}, {integer}, {integer})`
             found tuple `(_, _)`

For more information about this error, try `rustc --explain E0308`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

이 에러를 해결하기 위해, 튜플의 값 중 하나 이상을 _..를 사용해 무시할 수 있다. 이 방법은 “패턴에서 값 무시하기” 섹션에서 자세히 다룬다. 만약 패턴에 변수가 너무 많아 문제가 된다면, 변수를 제거해 패턴의 변수 개수와 튜플의 요소 개수가 일치하도록 수정하면 된다.

함수 파라미터

함수의 파라미터도 패턴으로 사용할 수 있다. i32 타입의 x라는 하나의 파라미터를 받는 foo 함수를 선언한 Listing 19-6의 코드는 이제 익숙할 것이다.

fn foo(x: i32) {
    // code goes here
}

fn main() {}
Listing 19-6: 함수 시그니처에서 파라미터로 패턴 사용

여기서 x 부분이 바로 패턴이다! let과 마찬가지로, 함수의 인자로 튜플을 패턴에 매칭시킬 수 있다. Listing 19-7은 튜플의 값을 함수에 전달하면서 분해하는 예제를 보여준다.

Filename: src/main.rs
fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({x}, {y})");
}

fn main() {
    let point = (3, 5);
    print_coordinates(&point);
}
Listing 19-7: 튜플을 분해하는 파라미터를 가진 함수

이 코드는 Current location: (3, 5)를 출력한다. &(3, 5) 값이 &(x, y) 패턴과 매칭되므로, x3이고 y5가 된다.

클로저 파라미터 목록에서도 함수 파라미터 목록과 같은 방식으로 패턴을 사용할 수 있다. 클로저는 함수와 유사하기 때문이다. 이에 대해서는 13장에서 이미 설명했다.

지금까지 패턴을 사용하는 여러 방법을 살펴봤지만, 패턴을 사용할 수 있는 모든 곳에서 동일하게 작동하지는 않는다. 어떤 곳에서는 패턴이 반드시 무조건적이어야 하고, 다른 상황에서는 조건적일 수 있다. 이 두 개념에 대해 다음에 자세히 설명할 것이다.

패턴의 실패 가능성: 패턴이 매칭되지 않을 수 있는 경우

패턴은 두 가지 형태로 나뉜다: 실패할 수 없는 패턴(irrefutable)과 실패할 수 있는 패턴(refutable). 어떤 값이든 매칭되는 패턴은 _실패할 수 없는 패턴_이다. 예를 들어 let x = 5; 문에서 x는 어떤 값이든 매칭되므로 실패할 수 없다. 반면, 특정 값에 대해 매칭에 실패할 수 있는 패턴은 _실패할 수 있는 패턴_이다. 예를 들어 if let Some(x) = a_value 표현식에서 Some(x)a_value 변수의 값이 Some이 아닌 None일 경우 매칭에 실패한다.

함수 매개변수, let 문, for 루프는 오직 실패할 수 없는 패턴만 허용한다. 이는 값이 매칭되지 않을 경우 프로그램이 의미 있는 동작을 할 수 없기 때문이다. if letwhile let 표현식, 그리고 let...else 문은 실패할 수 있는 패턴과 실패할 수 없는 패턴을 모두 허용하지만, 컴파일러는 실패할 수 없는 패턴에 대해 경고를 발생시킨다. 이는 조건문의 기능이 성공 또는 실패에 따라 다르게 동작할 수 있도록 설계되었기 때문이다.

일반적으로 실패할 수 있는 패턴과 실패할 수 없는 패턴의 구분에 대해 크게 걱정할 필요는 없지만, 오류 메시지에서 이 개념을 접했을 때 대응할 수 있도록 기본적인 이해는 필요하다. 이 경우, 코드의 의도에 따라 패턴을 변경하거나 패턴을 사용하는 구문을 수정해야 한다.

Rust에서 실패할 수 없는 패턴이 필요한 곳에 실패할 수 있는 패턴을 사용하거나 그 반대의 경우 어떤 일이 발생하는지 예제를 통해 살펴보자. 리스팅 19-8은 let 문을 보여주지만, 패턴으로 실패할 수 있는 패턴인 Some(x)를 지정했다. 예상대로 이 코드는 컴파일되지 않는다.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value;
}
Listing 19-8: let 문에서 실패할 수 있는 패턴 사용 시도

만약 some_option_valueNone 값이라면 Some(x) 패턴과 매칭되지 않아 패턴이 실패할 수 있음을 의미한다. 그러나 let 문은 실패할 수 없는 패턴만 허용한다. 왜냐하면 None 값에 대해 코드가 유효한 동작을 할 수 없기 때문이다. 컴파일 시 Rust는 실패할 수 없는 패턴이 필요한 곳에 실패할 수 있는 패턴을 사용하려 했다는 오류를 발생시킨다:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error[E0005]: refutable pattern in local binding
 --> src/main.rs:3:9
  |
3 |     let Some(x) = some_option_value;
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let else` to handle the variant that isn't matched
  |
3 |     let Some(x) = some_option_value else { todo!() };
  |                                     ++++++++++++++++

For more information about this error, try `rustc --explain E0005`.
error: could not compile `patterns` (bin "patterns") due to 1 previous error

Some(x) 패턴이 모든 유효한 값을 커버하지 못했기 때문에 Rust는 정당하게 컴파일 오류를 발생시킨다.

실패할 수 없는 패턴이 필요한 곳에 실패할 수 있는 패턴을 사용했다면, 패턴을 사용하는 코드를 변경해 문제를 해결할 수 있다. let 대신 if let을 사용하면 된다. 이렇게 하면 패턴이 매칭되지 않을 경우 중괄호 안의 코드를 건너뛰어 유효하게 동작할 수 있다. 리스팅 19-9는 리스팅 19-8의 코드를 어떻게 수정하는지 보여준다.

fn main() {
    let some_option_value: Option<i32> = None;
    let Some(x) = some_option_value else {
        return;
    };
}
Listing 19-9: let 대신 let...else와 실패할 수 있는 패턴 사용

이제 코드는 완벽하게 유효하다. 그러나 if let에 실패할 수 없는 패턴(항상 매칭되는 패턴)을 사용하면, 리스팅 19-10과 같이 x를 사용할 경우 컴파일러는 경고를 발생시킨다.

fn main() {
    let x = 5 else {
        return;
    };
}
Listing 19-10: if let에 실패할 수 없는 패턴 사용 시도

Rust는 if let에 실패할 수 없는 패턴을 사용하는 것이 의미가 없다고 경고한다:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
warning: irrefutable `let...else` pattern
 --> src/main.rs:2:5
  |
2 |     let x = 5 else {
  |     ^^^^^^^^^
  |
  = note: this pattern will always match, so the `else` clause is useless
  = help: consider removing the `else` clause
  = note: `#[warn(irrefutable_let_patterns)]` on by default

warning: `patterns` (bin "patterns") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.39s
     Running `target/debug/patterns`

이러한 이유로, match의 각 분기(arm)는 실패할 수 있는 패턴을 사용해야 한다. 단, 마지막 분기는 남은 모든 값을 매칭할 수 있는 실패할 수 없는 패턴을 사용해야 한다. Rust는 match에 단일 분기만 있을 경우 실패할 수 없는 패턴을 허용하지만, 이 구문은 특별히 유용하지 않으며 더 간단한 let 문으로 대체할 수 있다.

이제 패턴을 사용할 위치와 실패할 수 있는 패턴과 실패할 수 없는 패턴의 차이를 알았으니, 패턴을 생성하는 데 사용할 수 있는 모든 구문을 살펴보자.

패턴 문법

이 섹션에서는 패턴에서 유효한 모든 문법을 모아 각각을 왜, 언제 사용해야 하는지 설명한다.

리터럴 매칭

6장에서 살펴보았듯이, 패턴을 리터럴에 직접 매칭할 수 있다. 다음 코드는 몇 가지 예제를 보여준다:

fn main() {
    let x = 1;

    match x {
        1 => println!("one"),
        2 => println!("two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

이 코드는 x의 값이 1이기 때문에 one을 출력한다. 이 구문은 코드가 특정한 구체적인 값을 받았을 때 동작을 수행하도록 할 때 유용하다.

명명된 변수 매칭

명명된 변수는 어떤 값이든 매칭할 수 있는 무조건적인 패턴이며, 이 책에서 여러 번 사용했다. 그러나 match, if let, 또는 while let 표현식에서 명명된 변수를 사용할 때 주의할 점이 있다. 이러한 표현식은 각각 새로운 스코프를 시작하기 때문에, 표현식 내부의 패턴에 선언된 변수는 외부에 있는 동일한 이름의 변수를 가린다. 이는 모든 변수에 적용되는 일반적인 규칙이다. 리스트 19-11에서 x라는 변수를 Some(5)로, y라는 변수를 10으로 선언했다. 그리고 x 값에 대해 match 표현식을 생성했다. match의 각 패턴과 마지막의 println!을 살펴보고, 코드를 실행하거나 더 읽기 전에 이 코드가 무엇을 출력할지 예측해 보자.

Filename: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(y) => println!("Matched, y = {y}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}
Listing 19-11: 기존 변수 y를 가리는 새로운 변수를 도입하는 match 표현식

match 표현식이 실행될 때 어떤 일이 일어나는지 살펴보자. 첫 번째 match 패턴은 x의 정의된 값과 일치하지 않으므로 코드는 계속 진행된다.

두 번째 match 패턴은 Some 값 내부의 어떤 값이든 매칭할 새로운 변수 y를 도입한다. match 표현식 내부의 새로운 스코프에 있기 때문에, 이 y는 처음에 10으로 선언한 y와는 다른 새로운 변수다. 이 새로운 y 바인딩은 Some 내부의 어떤 값이든 매칭하며, x에 있는 값이 바로 그렇다. 따라서 이 새로운 yxSome 내부 값에 바인딩된다. 그 값은 5이므로, 해당 패턴의 표현식이 실행되어 Matched, y = 5를 출력한다.

만약 xSome(5) 대신 None 값이었다면, 처음 두 패턴은 일치하지 않았을 것이므로 값은 언더스코어 패턴과 일치하게 된다. 언더스코어 패턴에서는 x 변수를 도입하지 않았으므로, 표현식 내의 x는 여전히 가려지지 않은 외부의 x다. 이 가상의 경우, matchDefault case, x = None을 출력할 것이다.

match 표현식이 끝나면, 그 스코프도 끝나고 내부의 y 스코프도 함께 끝난다. 마지막 println!at the end: x = Some(5), y = 10을 출력한다.

외부의 xy 값을 비교하는 match 표현식을 만들려면, 기존의 y 변수를 가리는 새로운 변수를 도입하는 대신 매치 가드 조건을 사용해야 한다. 매치 가드에 대해서는 나중에 “매치 가드를 사용한 추가 조건”에서 다룰 것이다.

여러 패턴 매칭

| 문법을 사용하면 여러 패턴을 매칭할 수 있다. 이는 패턴 or 연산자로 동작한다. 예를 들어, 다음 코드에서 x 값을 매치 암과 비교한다. 첫 번째 매치 암은 or 옵션을 가지고 있어, x 값이 해당 암의 값 중 하나와 일치하면 해당 암의 코드가 실행된다:

fn main() {
    let x = 1;

    match x {
        1 | 2 => println!("one or two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

이 코드는 one or two를 출력한다.

..= 구문으로 값의 범위 매칭하기

..= 구문은 특정 범위 내의 값을 매칭할 때 사용한다. 다음 코드에서 패턴이 주어진 범위 내의 어떤 값과도 일치하면, 해당 분기가 실행된다:

fn main() {
    let x = 5;

    match x {
        1..=5 => println!("one through five"),
        _ => println!("something else"),
    }
}

x1, 2, 3, 4, 또는 5 중 하나라면 첫 번째 분기가 매칭된다. 이 구문은 여러 매칭 값을 표현할 때 | 연산자를 사용하는 것보다 편리하다. |를 사용하려면 1 | 2 | 3 | 4 | 5와 같이 각 값을 일일이 지정해야 한다. 범위를 지정하면 훨씬 간결해지며, 특히 1부터 1,000까지의 숫자를 매칭하고 싶을 때 유용하다.

컴파일러는 컴파일 시점에 범위가 비어 있지 않은지 확인한다. Rust가 범위가 비어 있는지 확인할 수 있는 타입은 char와 숫자 값뿐이므로, 범위는 숫자나 char 값에만 허용된다.

다음은 char 값의 범위를 사용한 예제이다:

fn main() {
    let x = 'c';

    match x {
        'a'..='j' => println!("early ASCII letter"),
        'k'..='z' => println!("late ASCII letter"),
        _ => println!("something else"),
    }
}

Rust는 'c'가 첫 번째 패턴의 범위 안에 있음을 확인하고 early ASCII letter를 출력한다.

값 분해를 위한 구조 분해

구조체, 열거형, 튜플의 값을 분해하여 각 부분을 활용할 수 있다. 각 값 유형에 대해 하나씩 살펴보자.

구조체 분해

리스트 19-12는 xy 두 개의 필드를 가진 Point 구조체를 보여준다. 이 구조체의 필드를 let 구문과 패턴을 사용해 분해할 수 있다.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}
Listing 19-12: 구조체의 필드를 별도의 변수로 분해하기

이 코드는 p 구조체의 xy 필드의 값과 일치하는 ab 변수를 생성한다. 이 예제는 패턴에서 사용한 변수 이름이 구조체의 필드 이름과 일치할 필요가 없음을 보여준다. 그러나 일반적으로 변수 이름을 필드 이름과 일치시켜 어떤 변수가 어떤 필드에서 왔는지 쉽게 기억할 수 있도록 한다. 이런 일반적인 사용법과 let Point { x: x, y: y } = p;와 같은 코드가 중복을 많이 포함하기 때문에, Rust는 구조체 필드와 일치하는 패턴에 대한 단축 문법을 제공한다. 구조체 필드 이름만 나열하면 패턴에서 생성된 변수가 같은 이름을 갖게 된다. 리스트 19-13은 리스트 19-12의 코드와 동일하게 동작하지만, let 패턴에서 생성된 변수가 ab 대신 xy가 된다.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    let Point { x, y } = p;
    assert_eq!(0, x);
    assert_eq!(7, y);
}
Listing 19-13: 구조체 필드 단축 문법을 사용해 구조체 필드 분해하기

이 코드는 p 변수의 xy 필드와 일치하는 xy 변수를 생성한다. 결과적으로 xy 변수는 p 구조체의 값을 갖게 된다.

또한 모든 필드에 대해 변수를 생성하는 대신, 구조체 패턴의 일부로 리터럴 값을 사용해 분해할 수 있다. 이를 통해 특정 필드의 값을 테스트하면서 다른 필드를 분해하는 변수를 생성할 수 있다.

리스트 19-14에서는 match 표현식을 사용해 Point 값을 세 가지 경우로 나눈다: x 축 위에 있는 점(y = 0일 때), y 축 위에 있는 점(x = 0일 때), 그리고 어느 축에도 있지 않은 점.

Filename: src/main.rs
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("On the x axis at {x}"),
        Point { x: 0, y } => println!("On the y axis at {y}"),
        Point { x, y } => {
            println!("On neither axis: ({x}, {y})");
        }
    }
}
Listing 19-14: 한 패턴에서 분해와 리터럴 값 매칭하기

첫 번째 패턴은 y 필드의 값이 리터럴 0과 일치할 때 x 축 위에 있는 점과 일치한다. 이 패턴은 여전히 이 패턴의 코드에서 사용할 수 있는 x 변수를 생성한다.

마찬가지로, 두 번째 패턴은 x 필드의 값이 0일 때 y 축 위에 있는 점과 일치하며, y 필드의 값을 위한 y 변수를 생성한다. 세 번째 패턴은 어떤 리터럴도 지정하지 않으므로 다른 모든 Point와 일치하며 xy 필드 모두에 대한 변수를 생성한다.

이 예제에서 p 값은 x0을 포함하므로 두 번째 패턴과 일치한다. 따라서 이 코드는 On the y axis at 7을 출력한다.

match 표현식은 첫 번째로 일치하는 패턴을 찾으면 더 이상 패턴을 확인하지 않는다는 점을 기억하자. 따라서 Point { x: 0, y: 0}x 축과 y 축 모두에 있더라도 이 코드는 On the x axis at 0만 출력한다.

열거형 구조 분해

이 책에서 여러 번 열거형을 구조 분해했지만, 아직 열거형 내부에 저장된 데이터를 정의하는 방식과 구조 분해 패턴이 어떻게 대응하는지 명시적으로 다루지 않았다. 예를 들어, 리스트 19-15에서는 리스트 6-2의 Message 열거형을 사용하고, 각 내부 값을 구조 분해하는 패턴을 가진 match를 작성한다.

Filename: src/main.rs
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {
    let msg = Message::ChangeColor(0, 160, 255);

    match msg {
        Message::Quit => {
            println!("The Quit variant has no data to destructure.");
        }
        Message::Move { x, y } => {
            println!("Move in the x direction {x} and in the y direction {y}");
        }
        Message::Write(text) => {
            println!("Text message: {text}");
        }
        Message::ChangeColor(r, g, b) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
    }
}
Listing 19-15: 다양한 종류의 값을 보유한 열거형 변형을 구조 분해하기

이 코드는 Change color to red 0, green 160, and blue 255를 출력한다. msg의 값을 변경해 다른 분기의 코드가 실행되는지 확인해 보자.

Message::Quit처럼 데이터가 없는 열거형 변형은 더 이상 구조 분해할 수 없다. 오직 Message::Quit 리터럴 값과만 매치할 수 있으며, 그 패턴에는 변수가 포함되지 않는다.

Message::Move처럼 구조체 형태의 열거형 변형은 구조체를 매치할 때 지정하는 패턴과 유사한 패턴을 사용할 수 있다. 변형 이름 뒤에 중괄호를 넣고, 필드와 변수를 나열하여 코드에서 사용할 수 있도록 조각을 분리한다. 여기서는 리스트 19-13에서 했던 것처럼 단축 형태를 사용한다.

Message::Write처럼 하나의 요소를 가진 튜플을 보유하거나 Message::ChangeColor처럼 세 개의 요소를 가진 튜플을 보유하는 튜플 형태의 열거형 변형은 튜플을 매치할 때 지정하는 패턴과 유사하다. 패턴의 변수 수는 매치하려는 변형의 요소 수와 일치해야 한다.

중첩된 구조체와 열거형의 구조 분해

지금까지 다룬 예제들은 모두 한 단계 깊이의 구조체나 열거형을 매칭하는 것이었다. 하지만 매칭은 중첩된 항목에서도 동작한다! 예를 들어, ChangeColor 메시지에서 RGB와 HSV 색상을 지원하도록 Listing 19-15의 코드를 Listing 19-16과 같이 리팩토링할 수 있다.

enum Color {
    Rgb(i32, i32, i32),
    Hsv(i32, i32, i32),
}

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(Color),
}

fn main() {
    let msg = Message::ChangeColor(Color::Hsv(0, 160, 255));

    match msg {
        Message::ChangeColor(Color::Rgb(r, g, b)) => {
            println!("Change color to red {r}, green {g}, and blue {b}");
        }
        Message::ChangeColor(Color::Hsv(h, s, v)) => {
            println!("Change color to hue {h}, saturation {s}, value {v}");
        }
        _ => (),
    }
}
Listing 19-16: 중첩된 열거형 매칭

match 표현식의 첫 번째 갈래 패턴은 Color::Rgb 변형을 포함하는 Message::ChangeColor 열거형 변형과 매칭된다. 그리고 이 패턴은 내부의 세 개의 i32 값에 바인딩된다. 두 번째 갈래 패턴도 Message::ChangeColor 열거형 변형과 매칭되지만, 내부 열거형은 대신 Color::Hsv와 매칭된다. 두 개의 열거형이 관여되더라도 하나의 match 표현식에서 이러한 복잡한 조건을 지정할 수 있다.

구조체와 튜플의 구조 분해

구조 분해 패턴을 더 복잡한 방식으로 혼합하고 중첩할 수 있다. 다음 예제는 튜플 안에 구조체와 튜플을 중첩한 후, 모든 기본 값을 구조 분해하는 복잡한 경우를 보여준다:

fn main() {
    struct Point {
        x: i32,
        y: i32,
    }

    let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
}

이 코드를 통해 복잡한 타입을 구성 요소로 분해하고, 필요한 값만 따로 사용할 수 있다.

패턴을 사용한 구조 분해는 구조체의 각 필드 값과 같은 값의 일부를 개별적으로 사용할 수 있는 편리한 방법이다.

패턴에서 값 무시하기

패턴에서 값을 무시하는 것이 유용한 경우가 있다. 예를 들어, match의 마지막 부분에서 나머지 모든 가능한 값을 처리하기 위해 실제로 아무 작업도 하지 않는 catchall 패턴을 사용할 때가 있다. 패턴에서 전체 값이나 값의 일부를 무시하는 몇 가지 방법이 있다: _ 패턴을 사용하거나, 다른 패턴 내에서 _ 패턴을 사용하거나, 밑줄로 시작하는 이름을 사용하거나, ..를 사용해 값의 나머지 부분을 무시하는 방법이 있다. 각 패턴을 어떻게 사용하고 왜 사용하는지 살펴보자.

_를 사용한 전체 값 무시

언더스코어(_)는 모든 값과 일치하지만 해당 값에 바인딩하지 않는 와일드카드 패턴으로 사용한다. 이는 특히 match 표현식의 마지막 분기에서 유용하지만, 함수 매개변수를 포함한 모든 패턴에서도 활용할 수 있다. 아래 Listing 19-17에서 이를 확인할 수 있다.

Filename: src/main.rs
fn foo(_: i32, y: i32) {
    println!("This code only uses the y parameter: {y}");
}

fn main() {
    foo(3, 4);
}
Listing 19-17: 함수 시그니처에서 _ 사용

이 코드는 첫 번째 인자로 전달된 3을 완전히 무시하고, This code only uses the y parameter: 4를 출력한다.

대부분의 경우, 특정 함수 매개변수가 더 이상 필요하지 않다면 시그니처를 수정해 사용하지 않는 매개변수를 제거한다. 하지만 함수 매개변수를 무시하는 방식은 특정 타입 시그니처가 필요하지만 구현체에서 해당 매개변수를 사용하지 않는 경우에 특히 유용하다. 예를 들어, 트레이트를 구현할 때 이런 상황이 발생할 수 있다. 이름을 사용하는 대신 _를 활용하면 사용하지 않는 함수 매개변수에 대한 컴파일러 경고를 피할 수 있다.

중첩된 _를 사용하여 값의 일부 무시하기

값의 일부만 테스트하고 나머지 부분은 사용하지 않을 때, 패턴 내부에 _를 사용하여 특정 부분을 무시할 수 있다. 예를 들어, 특정 설정 값을 관리하는 코드에서 사용자가 기존의 커스텀 설정을 덮어쓰지 못하게 하지만, 설정이 현재 해제된 상태라면 값을 부여할 수 있도록 하는 비즈니스 요구사항이 있다고 가정해 보자. 아래 코드는 이러한 시나리오를 보여준다.

fn main() {
    let mut setting_value = Some(5);
    let new_setting_value = Some(10);

    match (setting_value, new_setting_value) {
        (Some(_), Some(_)) => {
            println!("Can't overwrite an existing customized value");
        }
        _ => {
            setting_value = new_setting_value;
        }
    }

    println!("setting is {setting_value:?}");
}
Listing 19-18: Some 변형과 매칭할 때 Some 내부의 값을 사용하지 않으려면 패턴 내부에 _ 사용하기

이 코드는 Can't overwrite an existing customized value를 출력한 후 setting is Some(5)를 출력한다. 첫 번째 매치 암에서는 Some 변형 내부의 값을 매칭하거나 사용할 필요는 없지만, setting_valuenew_setting_value가 모두 Some 변형인 경우를 테스트해야 한다. 이 경우, setting_value를 변경하지 않는 이유를 출력하고, 값은 변경되지 않는다.

두 번째 암에서 _ 패턴으로 표현된 다른 모든 경우(즉, setting_value 또는 new_setting_valueNone인 경우)에는 new_setting_valuesetting_value가 되도록 허용한다.

또한 하나의 패턴 내에서 여러 위치에 _를 사용하여 특정 값을 무시할 수도 있다. 아래 코드는 다섯 개의 아이템으로 이루어진 튜플에서 두 번째와 네 번째 값을 무시하는 예제를 보여준다.

fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, _, third, _, fifth) => {
            println!("Some numbers: {first}, {third}, {fifth}");
        }
    }
}
Listing 19-19: 튜플의 여러 부분 무시하기

이 코드는 Some numbers: 2, 8, 32를 출력하며, 값 416은 무시된다.

변수명 앞에 _를 붙여 사용하지 않는 변수 표시하기

변수를 생성했지만 어디에서도 사용하지 않으면, Rust는 일반적으로 경고를 발생시킨다. 사용하지 않는 변수는 버그일 가능성이 있기 때문이다. 하지만 프로토타이핑이나 프로젝트 초기 단계에서 아직 사용하지 않을 변수를 생성해야 하는 경우가 있다. 이때 변수명 앞에 밑줄(_)을 붙이면 Rust가 해당 변수에 대해 경고하지 않도록 할 수 있다. 리스트 19-20에서는 사용하지 않는 변수 두 개를 생성했지만, 코드를 컴파일하면 그중 하나에 대해서만 경고가 발생한다.

Filename: src/main.rs
fn main() {
    let _x = 5;
    let y = 10;
}
Listing 19-20: 변수명 앞에 밑줄을 붙여 사용하지 않는 변수 경고를 피하는 예제

여기서는 변수 y를 사용하지 않았다는 경고가 발생하지만, _x에 대해서는 경고가 발생하지 않는다.

단순히 _를 사용하는 것과 변수명 앞에 밑줄을 붙이는 것 사이에는 미묘한 차이가 있다. _x와 같은 구문은 여전히 값을 변수에 바인딩하지만, _는 전혀 바인딩하지 않는다. 이 차이가 중요한 경우를 보여주기 위해 리스트 19-21에서는 오류가 발생한다.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_s) = s {
        println!("found a string");
    }

    println!("{s:?}");
}
Listing 19-21: 변수명 앞에 밑줄을 붙이면 여전히 값이 바인딩되며, 이는 값의 소유권을 가져갈 수 있다

s 값이 여전히 _s로 이동되기 때문에 오류가 발생한다. 이로 인해 s를 다시 사용할 수 없게 된다. 하지만 단순히 밑줄(_)을 사용하면 값이 바인딩되지 않는다. 리스트 19-22에서는 s_로 이동하지 않기 때문에 오류 없이 컴파일된다.

fn main() {
    let s = Some(String::from("Hello!"));

    if let Some(_) = s {
        println!("found a string");
    }

    println!("{s:?}");
}
Listing 19-22: 밑줄만 사용하면 값이 바인딩되지 않는다

이 코드는 s를 아무것에도 바인딩하지 않았기 때문에 정상적으로 작동한다. 즉, s가 이동하지 않는다.

..를 사용하여 나머지 값 무시하기

여러 부분으로 구성된 값에서 특정 부분만 사용하고 나머지는 무시할 때 .. 구문을 사용한다. 이를 통해 무시할 값마다 언더스코어(_)를 일일이 나열하는 번거로움을 피할 수 있다. .. 패턴은 패턴의 나머지 부분에서 명시적으로 일치시키지 않은 값은 모두 무시한다. Listing 19-23에서는 3차원 공간의 좌표를 담는 Point 구조체를 사용한다. match 표현식에서 x 좌표만 사용하고 yz 필드의 값은 무시한다.

fn main() {
    struct Point {
        x: i32,
        y: i32,
        z: i32,
    }

    let origin = Point { x: 0, y: 0, z: 0 };

    match origin {
        Point { x, .. } => println!("x is {x}"),
    }
}
Listing 19-23: ..를 사용해 Pointx 필드만 사용하고 나머지 필드 무시하기

x 값을 명시한 뒤 .. 패턴을 추가한다. 이 방법은 y: _z: _를 일일이 나열하는 것보다 훨씬 간단하며, 특히 필드가 많은 구조체에서 일부 필드만 사용할 때 유용하다.

.. 구문은 필요한 만큼의 값을 무시한다. Listing 19-24에서는 튜플에서 ..를 사용하는 방법을 보여준다.

Filename: src/main.rs
fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (first, .., last) => {
            println!("Some numbers: {first}, {last}");
        }
    }
}
Listing 19-24: 튜플의 첫 번째와 마지막 값만 일치시키고 나머지 값 무시하기

이 코드에서는 첫 번째와 마지막 값이 firstlast로 일치한다. ..는 중간에 있는 모든 값을 무시한다.

하지만 ..를 사용할 때는 모호하지 않아야 한다. 어떤 값을 일치시키고 어떤 값을 무시할지 명확하지 않으면 Rust는 오류를 발생시킨다. Listing 19-25에서는 ..를 모호하게 사용했기 때문에 컴파일되지 않는다.

Filename: src/main.rs
fn main() {
    let numbers = (2, 4, 8, 16, 32);

    match numbers {
        (.., second, ..) => {
            println!("Some numbers: {second}")
        },
    }
}
Listing 19-25: ..를 모호하게 사용하려는 시도

이 예제를 컴파일하면 다음과 같은 오류가 발생한다:

$ cargo run
   Compiling patterns v0.1.0 (file:///projects/patterns)
error: `..` can only be used once per tuple pattern
 --> src/main.rs:5:22
  |
5 |         (.., second, ..) => {
  |          --          ^^ can only be used once per tuple pattern
  |          |
  |          previously used here

error: could not compile `patterns` (bin "patterns") due to 1 previous error

Rust는 second와 일치시킬 값 이전에 몇 개의 값을 무시해야 하는지, 그리고 그 이후에 몇 개의 값을 더 무시해야 하는지 결정할 수 없다. 이 코드는 2를 무시하고 second4에 바인딩한 뒤 8, 16, 32를 무시하라는 뜻일 수도 있고, 24를 무시하고 second8에 바인딩한 뒤 1632를 무시하라는 뜻일 수도 있다. 변수명 second가 Rust에게 특별한 의미를 가지지 않기 때문에, 이렇게 두 곳에서 ..를 사용하는 것은 모호하다는 컴파일러 오류가 발생한다.

매치 가드와 추가 조건

매치 가드는 match 구문의 패턴 뒤에 추가로 지정하는 if 조건이다. 이 조건도 만족해야 해당 매치 분기가 선택된다. 매치 가드는 단순한 패턴만으로는 표현하기 어려운 복잡한 조건을 처리할 때 유용하다. 다만, if let이나 while let 표현식에서는 사용할 수 없다는 점에 주의해야 한다.

조건은 패턴에서 생성된 변수를 사용할 수 있다. 리스트 19-26은 첫 번째 매치 분기에 Some(x) 패턴과 함께 if x % 2 == 0이라는 매치 가드를 추가한 예제를 보여준다. 이 조건은 숫자가 짝수일 때 true가 된다.

fn main() {
    let num = Some(4);

    match num {
        Some(x) if x % 2 == 0 => println!("The number {x} is even"),
        Some(x) => println!("The number {x} is odd"),
        None => (),
    }
}
Listing 19-26: 패턴에 매치 가드 추가

이 예제는 The number 4 is even을 출력한다. num이 첫 번째 매치 분기의 패턴과 비교될 때, Some(4)Some(x)와 일치하므로 매치가 성립한다. 이후 매치 가드에서 x를 2로 나눈 나머지가 0인지 확인하고, 조건이 참이므로 첫 번째 분기가 선택된다.

만약 numSome(5)였다면, 첫 번째 매치 분기의 조건은 false가 된다. 5를 2로 나눈 나머지는 1이기 때문이다. 이 경우 러스트는 두 번째 매치 분기로 이동하고, 두 번째 분기는 매치 가드가 없으므로 모든 Some 변형과 일치한다.

if x % 2 == 0과 같은 조건은 패턴 내에서 표현할 수 없으므로, 매치 가드는 이러한 로직을 표현할 수 있는 기능을 제공한다. 다만, 이 추가적인 표현력의 단점은 매치 가드가 포함된 경우 컴파일러가 완전성(exhaustiveness)을 검사하지 않는다는 점이다.

리스트 19-11에서 언급했듯이, 매치 가드를 사용하면 패턴 섀도잉 문제를 해결할 수 있다. match 표현식 내부에서 새로운 변수를 생성하는 대신, 외부 변수를 사용할 수 있게 된다. 리스트 19-27은 매치 가드를 사용해 이 문제를 해결하는 방법을 보여준다.

Filename: src/main.rs
fn main() {
    let x = Some(5);
    let y = 10;

    match x {
        Some(50) => println!("Got 50"),
        Some(n) if n == y => println!("Matched, n = {n}"),
        _ => println!("Default case, x = {x:?}"),
    }

    println!("at the end: x = {x:?}, y = {y}");
}
Listing 19-27: 매치 가드를 사용해 외부 변수와 동일한지 확인

이제 이 코드는 Default case, x = Some(5)를 출력한다. 두 번째 매치 분기의 패턴은 외부 변수 y를 섀도잉하지 않는 새로운 변수 n을 생성한다. 따라서 매치 가드에서 외부 변수 y를 사용할 수 있다. Some(y)로 패턴을 지정하면 외부 변수 y를 섀도잉하지만, Some(n)으로 지정하면 새로운 변수 n이 생성되고 외부 변수 y를 사용할 수 있다.

매치 가드 if n == y는 패턴이 아니므로 새로운 변수를 생성하지 않는다. 여기서 y는 외부 변수 y를 가리키며, ny를 비교해 같은 값을 찾는다.

또한, 매치 가드에서 | 연산자를 사용해 여러 패턴을 지정할 수도 있다. 이 경우 매치 가드 조건은 모든 패턴에 적용된다. 리스트 19-28은 |를 사용한 패턴과 매치 가드를 결합할 때의 우선순위를 보여준다. 이 예제에서 중요한 점은 if y 매치 가드가 4, 5, 6 모두에 적용된다는 것이다.

fn main() {
    let x = 4;
    let y = false;

    match x {
        4 | 5 | 6 if y => println!("yes"),
        _ => println!("no"),
    }
}
Listing 19-28: 여러 패턴과 매치 가드 결합

이 매치 조건은 x4, 5, 6 중 하나이고 ytrue일 때만 매치 분기가 선택된다. 이 코드를 실행하면 첫 번째 매치 분기의 패턴은 x4이므로 일치하지만, 매치 가드 if yfalse이므로 선택되지 않는다. 코드는 두 번째 매치 분기로 이동하고, 이 분기는 일치하므로 no를 출력한다. 이는 if 조건이 패턴 4 | 5 | 6 전체에 적용되기 때문이다. 즉, 매치 가드의 우선순위는 다음과 같이 동작한다:

(4 | 5 | 6) if y => ...

다음과 같이 동작하지 않는다:

4 | 5 | (6 if y) => ...

코드를 실행한 후, 매치 가드가 | 연산자로 지정된 값 목록의 마지막 값에만 적용된다면, 매치 분기가 선택되고 yes가 출력되었을 것이다.

@ 바인딩

@ 연산자는 패턴 매칭을 테스트하는 동시에 해당 값을 변수에 저장할 수 있게 해준다. Listing 19-29에서는 Message::Helloid 필드가 3..=7 범위에 있는지 테스트하면서, 동시에 그 값을 id_variable 변수에 바인딩한다. 이 변수를 id 필드와 같은 이름으로 지을 수도 있지만, 이 예제에서는 다른 이름을 사용한다.

fn main() {
    enum Message {
        Hello { id: i32 },
    }

    let msg = Message::Hello { id: 5 };

    match msg {
        Message::Hello {
            id: id_variable @ 3..=7,
        } => println!("Found an id in range: {id_variable}"),
        Message::Hello { id: 10..=12 } => {
            println!("Found an id in another range")
        }
        Message::Hello { id } => println!("Found some other id: {id}"),
    }
}
Listing 19-29: 패턴에서 값을 테스트하면서 동시에 바인딩하기 위해 @ 사용

이 예제는 Found an id in range: 5를 출력한다. 3..=7 범위 앞에 id_variable @를 지정함으로써, 범위 패턴과 일치하는 값을 캡처하면서 동시에 그 값을 테스트한다.

두 번째 패턴에서는 범위만 지정했기 때문에, 해당 패턴과 연결된 코드에서는 id 필드의 실제 값을 담는 변수가 없다. id 필드의 값이 10, 11, 또는 12일 수 있지만, 이 패턴과 연결된 코드는 그 값이 무엇인지 알 수 없다. id 필드의 값을 변수에 저장하지 않았기 때문에, 패턴 코드는 id 필드의 값을 사용할 수 없다.

마지막 패턴에서는 범위 없이 변수만 지정했기 때문에, id라는 변수로 값을 사용할 수 있다. 이는 구조체 필드 단축 구문을 사용했기 때문이다. 하지만 이 패턴에서는 id 필드의 값에 대해 어떤 테스트도 적용하지 않는다. 첫 두 패턴과 달리, 모든 값이 이 패턴과 일치한다.

@를 사용하면 하나의 패턴 내에서 값을 테스트하고 동시에 변수에 저장할 수 있다.

요약

Rust의 패턴은 다양한 종류의 데이터를 구분하는 데 매우 유용하다. match 표현식에서 패턴을 사용할 때, Rust는 모든 가능한 값을 다루는지 확인한다. 만약 그렇지 않으면 프로그램이 컴파일되지 않는다. let 문과 함수 매개변수에서 패턴을 사용하면, 값을 더 작은 부분으로 분해하면서 동시에 그 부분들을 변수에 할당할 수 있어 더 유용하게 활용할 수 있다. 우리는 필요에 따라 단순하거나 복잡한 패턴을 만들 수 있다.

다음 장에서는 Rust의 다양한 기능 중 몇 가지 고급 주제를 살펴본다.

고급 기능

지금까지 여러분은 Rust 프로그래밍 언어에서 가장 일반적으로 사용되는 부분을 배웠다. 21장에서 한 프로젝트를 더 진행하기 전에, 가끔 마주칠 수 있지만 매일 사용하지는 않을 언어의 몇 가지 측면을 살펴볼 것이다. 이 장은 알려지지 않은 기능을 마주쳤을 때 참고할 수 있는 자료로 활용할 수 있다. 여기서 다루는 기능들은 매우 특정한 상황에서 유용하다. 비록 자주 사용하지는 않더라도, Rust가 제공하는 모든 기능을 이해하고 있는지 확인하고자 한다.

이 장에서 다룰 내용은 다음과 같다:

  • 안전하지 않은 Rust: Rust의 일부 보장을 포기하고 수동으로 그 보장을 유지하는 방법
  • 고급 트레이트: 연관 타입, 기본 타입 매개변수, 완전 정규화된 구문, 슈퍼트레이트, 그리고 트레이트와 관련된 뉴타입 패턴
  • 고급 타입: 뉴타입 패턴에 대한 추가 정보, 타입 별칭, 절대 타입, 그리고 동적 크기 타입
  • 고급 함수와 클로저: 함수 포인터와 클로저 반환
  • 매크로: 컴파일 시간에 더 많은 코드를 정의하는 코드를 작성하는 방법

이 장은 Rust의 다양한 기능을 폭넓게 다루며, 모두에게 유용한 정보를 제공한다. 함께 알아보자!

안전하지 않은 Rust

지금까지 다룬 모든 코드는 컴파일 시점에 Rust의 메모리 안전성을 보장받았다. 하지만 Rust 내부에는 이러한 메모리 안전성을 보장하지 않는 또 다른 언어가 숨어 있다. 이를 _안전하지 않은 Rust(unsafe Rust)_라고 부르며, 일반적인 Rust와 동일하게 작동하지만 추가적인 강력한 기능을 제공한다.

안전하지 않은 Rust가 존재하는 이유는 정적 분석이 본질적으로 보수적이기 때문이다. 컴파일러가 코드가 보장 사항을 준수하는지 판단할 때, 잘못된 프로그램을 허용하는 것보다 일부 유효한 프로그램을 거부하는 것이 더 나은 선택이다. 코드가 문제 없을 수도 있지만, Rust 컴파일러가 확신할 만한 충분한 정보가 없으면 코드를 거부한다. 이런 경우, 안전하지 않은 코드를 사용해 컴파일러에게 “내가 무엇을 하는지 알고 있으니 믿어 달라“고 말할 수 있다. 하지만 주의할 점은, 안전하지 않은 Rust를 사용할 때는 본인의 책임 하에 사용해야 한다는 것이다. 안전하지 않은 코드를 잘못 사용하면 널 포인터 역참조와 같은 메모리 안전성 문제가 발생할 수 있다.

Rust가 안전하지 않은 기능을 제공하는 또 다른 이유는, 기본 컴퓨터 하드웨어가 본질적으로 안전하지 않기 때문이다. Rust가 안전하지 않은 작업을 허용하지 않는다면, 특정 작업을 수행할 수 없게 된다. Rust는 운영체제와 직접 상호작용하거나 심지어 자신만의 운영체제를 작성하는 것과 같은 저수준 시스템 프로그래밍을 가능하게 해야 한다. 저수준 시스템 프로그래밍 작업은 Rust 언어의 목표 중 하나이다. 이제 안전하지 않은 Rust로 무엇을 할 수 있는지, 그리고 어떻게 사용하는지 알아보자.

안전하지 않은 슈퍼파워

안전하지 않은 Rust 코드를 사용하려면 unsafe 키워드를 사용한 후 안전하지 않은 코드를 담을 새로운 블록을 시작한다. 안전하지 않은 Rust에서는 안전한 Rust에서는 할 수 없는 다섯 가지 작업을 수행할 수 있으며, 이를 _안전하지 않은 슈퍼파워_라고 부른다. 이 슈퍼파워는 다음과 같은 기능을 포함한다:

  • 원시 포인터 역참조
  • 안전하지 않은 함수나 메서드 호출
  • 가변 정적 변수 접근 또는 수정
  • 안전하지 않은 트레잇 구현
  • union의 필드 접근

여기서 중요한 점은 unsafe가 빌림 검사기(borrow checker)를 비활성화하거나 Rust의 다른 안전 검사를 끄지 않는다는 것이다. 안전하지 않은 코드에서 참조를 사용하면 여전히 검사가 이루어진다. unsafe 키워드는 단순히 이 다섯 가지 기능에 접근할 수 있게 해줄 뿐이며, 이 기능들은 컴파일러가 메모리 안전성을 검사하지 않는다. 하지만 안전하지 않은 블록 내부에서도 어느 정도의 안전성은 보장된다.

또한 unsafe는 블록 내부의 코드가 반드시 위험하거나 메모리 안전성 문제가 발생한다는 의미가 아니다. 프로그래머로서 안전하지 않은 블록 내부의 코드가 유효한 방식으로 메모리에 접근하도록 보장해야 한다.

사람은 실수할 수 있고, 실수는 발생하기 마련이다. 하지만 이 다섯 가지 안전하지 않은 작업을 unsafe로 주석이 달린 블록 내부에 포함시킴으로써, 메모리 안전성과 관련된 오류가 반드시 unsafe 블록 내부에서 발생한다는 것을 알 수 있다. unsafe 블록을 가능한 한 작게 유지하라. 나중에 메모리 버그를 조사할 때 이 점에 감사할 것이다.

안전하지 않은 코드를 최대한 격리하기 위해서는 이러한 코드를 안전한 추상화로 감싸고 안전한 API를 제공하는 것이 가장 좋다. 이에 대해서는 나중에 안전하지 않은 함수와 메서드를 살펴볼 때 자세히 다룰 것이다. 표준 라이브러리의 일부는 검토된 안전하지 않은 코드를 기반으로 한 안전한 추상화로 구현되어 있다. 안전하지 않은 코드를 안전한 추상화로 감싸면 unsafe 사용이 여러분이나 사용자가 안전하지 않은 코드로 구현된 기능을 사용하려는 모든 곳으로 퍼지는 것을 방지할 수 있다. 안전한 추상화를 사용하는 것은 안전하기 때문이다.

이제 다섯 가지 안전하지 않은 슈퍼파워를 하나씩 살펴보자. 또한 안전하지 않은 코드에 안전한 인터페이스를 제공하는 몇 가지 추상화도 함께 살펴볼 것이다.

Raw Pointer 역참조하기

4장의 “Dangling References”에서 언급했듯이, 컴파일러는 참조가 항상 유효하도록 보장한다. 하지만 안전하지 않은 Rust에서는 참조와 유사한 _raw pointer_라는 두 가지 새로운 타입을 제공한다. 참조와 마찬가지로 raw pointer는 불변(*const T) 또는 가변(*mut T)으로 선언할 수 있다. 여기서 별표(*)는 역참조 연산자가 아니라 타입 이름의 일부다. raw pointer의 맥락에서 _불변_은 포인터가 역참조된 후 직접 할당할 수 없음을 의미한다.

참조와 스마트 포인터와 달리, raw pointer는 다음과 같은 특징을 가진다:

  • 빌림 규칙을 무시하고 동일한 위치에 대해 불변 및 가변 포인터 또는 여러 가변 포인터를 가질 수 있다.
  • 유효한 메모리를 가리킨다는 보장이 없다.
  • null 값을 허용한다.
  • 자동 정리 기능을 구현하지 않는다.

Rust가 제공하는 이러한 보장을 포기함으로써, 더 높은 성능이나 Rust의 보장이 적용되지 않는 다른 언어 또는 하드웨어와의 인터페이스를 위해 안전성을 희생할 수 있다.

Listing 20-1은 불변 및 가변 raw pointer를 생성하는 방법을 보여준다.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}
Listing 20-1: Raw borrow 연산자를 사용해 raw pointer 생성하기

이 코드에서는 unsafe 키워드를 사용하지 않았다. 안전한 코드에서도 raw pointer를 생성할 수 있지만, 잠시 후에 살펴보겠지만, unsafe 블록 외부에서는 raw pointer를 역참조할 수 없다.

Raw borrow 연산자를 사용해 raw pointer를 생성했다: &raw const num*const i32 불변 raw pointer를 생성하고, &raw mut num*mut i32 가변 raw pointer를 생성한다. 이 예제에서는 로컬 변수에서 직접 생성했기 때문에 이 raw pointer가 유효하다는 것을 알 수 있지만, 모든 raw pointer에 대해 이런 가정을 할 수는 없다.

이를 확인하기 위해, Raw borrow 연산자 대신 as를 사용해 값을 캐스팅하여 유효성을 확신할 수 없는 raw pointer를 생성해보자. Listing 20-2는 메모리의 임의 위치를 가리키는 raw pointer를 생성하는 방법을 보여준다. 임의의 메모리를 사용하려고 시도하는 것은 정의되지 않은 동작이다: 해당 주소에 데이터가 있을 수도 있고 없을 수도 있으며, 컴파일러가 메모리 접근을 최적화하거나 프로그램이 세그먼테이션 오류로 종료될 수도 있다. 일반적으로 이런 코드를 작성할 이유는 없지만, 특히 Raw borrow 연산자를 대신 사용할 수 있는 경우에도 가능은 하다.

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}
Listing 20-2: 임의의 메모리 주소를 가리키는 raw pointer 생성하기

안전한 코드에서 raw pointer를 생성할 수 있지만, raw pointer를 _역참조_하고 포인터가 가리키는 데이터를 읽을 수는 없다. Listing 20-3에서는 unsafe 블록이 필요한 raw pointer에 역참조 연산자 *를 사용한다.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;

    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
    }
}
Listing 20-3: unsafe 블록 내에서 raw pointer 역참조하기

포인터를 생성하는 것은 아무런 해가 없지만, 포인터가 가리키는 값에 접근하려고 할 때 유효하지 않은 값을 다룰 수 있다.

또한 Listing 20-1과 20-3에서 num이 저장된 동일한 메모리 위치를 가리키는 *const i32*mut i32 raw pointer를 생성했다. 만약 num에 대해 불변 및 가변 참조를 생성하려고 했다면, Rust의 소유권 규칙으로 인해 코드가 컴파일되지 않았을 것이다. Raw pointer를 사용하면 동일한 위치에 대해 가변 포인터와 불변 포인터를 생성하고, 가변 포인터를 통해 데이터를 변경할 수 있지만, 이는 데이터 경쟁을 유발할 가능성이 있다. 주의해야 한다!

이렇게 위험한데도 왜 raw pointer를 사용할까? 주요 사용 사례 중 하나는 C 코드와의 인터페이스다. 이는 다음 섹션 “안전하지 않은 함수 또는 메서드 호출하기”에서 살펴볼 것이다. 또 다른 사례는 빌림 검사기가 이해하지 못하는 안전한 추상화를 구축할 때다. 안전하지 않은 함수를 소개한 후, 안전하지 않은 코드를 사용하는 안전한 추상화 예제를 살펴볼 것이다.

안전하지 않은 함수 또는 메서드 호출

unsafe 블록 내에서 수행할 수 있는 두 번째 작업은 안전하지 않은 함수를 호출하는 것이다. 안전하지 않은 함수와 메서드는 일반 함수 및 메서드와 동일하게 보이지만, 정의 앞에 unsafe 키워드가 추가된다. 이 컨텍스트에서 unsafe 키워드는 함수를 호출할 때 지켜야 할 요구사항이 있음을 나타내며, Rust는 이러한 요구사항이 충족되었는지 보장할 수 없다. unsafe 블록 내에서 안전하지 않은 함수를 호출함으로써, 해당 함수의 문서를 읽었고 함수의 계약을 준수할 책임이 있음을 명시하는 것이다.

다음은 본문에서 아무 작업도 수행하지 않는 dangerous라는 안전하지 않은 함수의 예시다:

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

dangerous 함수를 호출하려면 별도의 unsafe 블록 안에서 호출해야 한다. unsafe 블록 없이 dangerous를 호출하려고 하면 오류가 발생한다:

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0133]: call to unsafe function `dangerous` is unsafe and requires unsafe block
 --> src/main.rs:4:5
  |
4 |     dangerous();
  |     ^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to avoid undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

unsafe 블록을 사용함으로써, Rust에게 해당 함수의 문서를 읽었고, 올바르게 사용하는 방법을 이해했으며, 함수의 계약을 충족하고 있음을 확인했다고 선언하는 것이다.

안전하지 않은 함수의 본문에서 안전하지 않은 작업을 수행하려면, 일반 함수 내에서와 마찬가지로 여전히 unsafe 블록을 사용해야 한다. 이를 잊어버리면 컴파일러가 경고를 표시한다. 이는 unsafe 블록을 가능한 한 작게 유지하는 데 도움이 되며, 함수 전체 본문에 걸쳐 안전하지 않은 작업이 필요하지 않을 수도 있음을 의미한다.

안전하지 않은 코드 위에 안전한 추상화 만들기

함수 안에 안전하지 않은 코드가 있다고 해서 전체 함수를 안전하지 않다고 표시할 필요는 없다. 실제로 안전하지 않은 코드를 안전한 함수로 감싸는 것은 흔히 사용하는 추상화 방법이다. 예를 들어, 표준 라이브러리의 split_at_mut 함수를 살펴보자. 이 함수는 안전하지 않은 코드가 필요하다. 이 함수를 어떻게 구현할 수 있는지 알아보자. 이 안전한 메서드는 가변 슬라이스에 정의되어 있다: 하나의 슬라이스를 받아서 주어진 인덱스에서 두 개의 슬라이스로 나눈다. Listing 20-4는 split_at_mut를 사용하는 방법을 보여준다.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];

    let r = &mut v[..];

    let (a, b) = r.split_at_mut(3);

    assert_eq!(a, &mut [1, 2, 3]);
    assert_eq!(b, &mut [4, 5, 6]);
}
Listing 20-4: 안전한 split_at_mut 함수 사용하기

이 함수는 안전한 Rust만으로는 구현할 수 없다. Listing 20-5와 같은 시도는 컴파일되지 않는다. 단순화를 위해 split_at_mut를 메서드가 아닌 함수로 구현하고, 제네릭 타입 T 대신 i32 값의 슬라이스에 대해서만 구현할 것이다.

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();

    assert!(mid <= len);

    (&mut values[..mid], &mut values[mid..])
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-5: 안전한 Rust만으로 split_at_mut 구현 시도

이 함수는 먼저 슬라이스의 전체 길이를 가져온다. 그런 다음 주어진 인덱스가 슬라이스 내에 있는지 확인하기 위해 길이보다 작거나 같은지 검사한다. 이 검사는 슬라이스를 나누기 위해 길이보다 큰 인덱스를 전달하면 함수가 해당 인덱스를 사용하기 전에 패닉을 일으킨다는 것을 의미한다.

그런 다음 튜플로 두 개의 가변 슬라이스를 반환한다: 하나는 원래 슬라이스의 시작부터 mid 인덱스까지, 다른 하나는 mid부터 슬라이스의 끝까지이다.

Listing 20-5의 코드를 컴파일하려고 하면 오류가 발생한다.

$ cargo run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
error[E0499]: cannot borrow `*values` as mutable more than once at a time
 --> src/main.rs:6:31
  |
1 | fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
  |                         - let's call the lifetime of this reference `'1`
...
6 |     (&mut values[..mid], &mut values[mid..])
  |     --------------------------^^^^^^--------
  |     |     |                   |
  |     |     |                   second mutable borrow occurs here
  |     |     first mutable borrow occurs here
  |     returning this value requires that `*values` is borrowed for `'1`
  |
  = help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices

For more information about this error, try `rustc --explain E0499`.
error: could not compile `unsafe-example` (bin "unsafe-example") due to 1 previous error

Rust의 빌림 검사기는 슬라이스의 다른 부분을 빌리고 있다는 것을 이해하지 못한다. 같은 슬라이스에서 두 번 빌리고 있다는 것만 알고 있다. 슬라이스의 다른 부분을 빌리는 것은 근본적으로 괜찮은데, 두 슬라이스가 겹치지 않기 때문이다. 하지만 Rust는 이를 알아채지 못한다. 우리는 코드가 괜찮다는 것을 알고 있지만 Rust는 모르는 상황에서, 이때가 바로 안전하지 않은 코드를 사용할 때이다.

Listing 20-6은 unsafe 블록, raw 포인터, 그리고 몇 가지 안전하지 않은 함수 호출을 사용해 split_at_mut를 구현하는 방법을 보여준다.

use std::slice;

fn split_at_mut(values: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = values.len();
    let ptr = values.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.add(mid), len - mid),
        )
    }
}

fn main() {
    let mut vector = vec![1, 2, 3, 4, 5, 6];
    let (left, right) = split_at_mut(&mut vector, 3);
}
Listing 20-6: split_at_mut 함수 구현에 안전하지 않은 코드 사용하기

4장의 “The Slice Type”에서 슬라이스는 데이터에 대한 포인터와 슬라이스의 길이로 구성된다는 것을 기억할 것이다. len 메서드를 사용해 슬라이스의 길이를 가져오고, as_mut_ptr 메서드를 사용해 슬라이스의 raw 포인터에 접근한다. 이 경우 i32 값의 가변 슬라이스가 있으므로 as_mut_ptr*mut i32 타입의 raw 포인터를 반환하며, 이를 ptr 변수에 저장한다.

mid 인덱스가 슬라이스 내에 있다는 검사는 그대로 유지한다. 그런 다음 안전하지 않은 코드에 도달한다: slice::from_raw_parts_mut 함수는 raw 포인터와 길이를 받아 슬라이스를 생성한다. 이 함수를 사용해 ptr에서 시작하고 mid 길이만큼의 슬라이스를 생성한다. 그런 다음 mid를 인자로 ptradd 메서드를 호출해 mid에서 시작하는 raw 포인터를 얻고, 그 포인터와 mid 이후의 남은 길이를 사용해 슬라이스를 생성한다.

slice::from_raw_parts_mut 함수는 raw 포인터를 받기 때문에 안전하지 않으며, 이 포인터가 유효하다고 믿어야 한다. raw 포인터의 add 메서드도 안전하지 않은데, 오프셋 위치가 유효한 포인터라고 믿어야 하기 때문이다. 따라서 slice::from_raw_parts_mutadd 호출 주위에 unsafe 블록을 추가해 이들을 호출할 수 있도록 했다. 코드를 살펴보고 midlen보다 작거나 같아야 한다는 검사를 추가함으로써, unsafe 블록 내에서 사용된 모든 raw 포인터가 슬라이스 내의 데이터에 대한 유효한 포인터임을 알 수 있다. 이는 unsafe를 적절하고 허용 가능한 방식으로 사용한 것이다.

결과적으로 split_at_mut 함수를 unsafe로 표시할 필요가 없으며, 이 함수를 안전한 Rust에서 호출할 수 있다. 우리는 안전하지 않은 코드에 대한 안전한 추상화를 만들었는데, 이 함수가 접근할 수 있는 데이터로부터 유효한 포인터만 생성하기 때문이다.

반대로, Listing 20-7에서 slice::from_raw_parts_mut를 사용하면 슬라이스를 사용할 때 충돌이 발생할 가능성이 높다. 이 코드는 임의의 메모리 위치를 가져와 길이가 10,000인 슬라이스를 생성한다.

fn main() {
    use std::slice;

    let address = 0x01234usize;
    let r = address as *mut i32;

    let values: &[i32] = unsafe { slice::from_raw_parts_mut(r, 10000) };
}
Listing 20-7: 임의의 메모리 위치에서 슬라이스 생성하기

우리는 이 임의의 메모리 위치를 소유하지 않으며, 이 코드가 생성한 슬라이스에 유효한 i32 값이 포함되어 있다는 보장도 없다. values를 유효한 슬라이스인 것처럼 사용하려고 하면 정의되지 않은 동작이 발생한다.

extern 함수를 사용해 외부 코드 호출하기

때로는 Rust 코드가 다른 언어로 작성된 코드와 상호작용해야 할 때가 있다. 이를 위해 Rust는 extern 키워드를 제공하며, 이를 통해 외부 함수 인터페이스(Foreign Function Interface, FFI) 를 생성하고 사용할 수 있다. FFI는 프로그래밍 언어가 함수를 정의하고, 다른 (외부) 프로그래밍 언어가 그 함수를 호출할 수 있도록 하는 방법이다.

리스트 20-8은 C 표준 라이브러리의 abs 함수와 통합을 설정하는 방법을 보여준다. extern 블록 내에서 선언된 함수는 일반적으로 Rust 코드에서 호출할 때 안전하지 않기 때문에, extern 블록은 unsafe로 표시해야 한다. 이는 다른 언어가 Rust의 규칙과 보장을 강제하지 않으며, Rust가 이를 확인할 수 없기 때문이다. 따라서 프로그래머가 안전을 보장할 책임이 있다.

Filename: src/main.rs
unsafe extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}
Listing 20-8: 다른 언어로 정의된 extern 함수 선언 및 호출

unsafe extern "C" 블록 내에서 호출하려는 다른 언어의 외부 함수 이름과 시그니처를 나열한다. "C" 부분은 외부 함수가 사용하는 애플리케이션 바이너리 인터페이스(Application Binary Interface, ABI) 를 정의한다. ABI는 어셈블리 수준에서 함수를 호출하는 방법을 정의한다. "C" ABI는 가장 일반적이며, C 프로그래밍 언어의 ABI를 따른다. Rust가 지원하는 모든 ABI에 대한 정보는 Rust Reference에서 확인할 수 있다.

unsafe extern 블록 내에서 선언된 모든 항목은 암묵적으로 unsafe이다. 그러나 일부 FFI 함수는 안전하게 호출할 수 있다. 예를 들어, C 표준 라이브러리의 abs 함수는 메모리 안전성 문제가 없으며, 어떤 i32 값으로도 호출할 수 있다는 것을 알고 있다. 이런 경우에는 safe 키워드를 사용해 이 특정 함수가 unsafe extern 블록 안에 있더라도 안전하게 호출할 수 있다고 명시할 수 있다. 이렇게 변경하면, 리스트 20-9에서 보여주는 것처럼 호출 시 unsafe 블록이 필요하지 않다.

Filename: src/main.rs
unsafe extern "C" {
    safe fn abs(input: i32) -> i32;
}

fn main() {
    println!("Absolute value of -3 according to C: {}", abs(-3));
}
Listing 20-9: unsafe extern 블록 내에서 함수를 safe로 명시적으로 표시하고 안전하게 호출하기

함수를 safe로 표시한다고 해서 자동으로 안전해지는 것은 아니다! 대신, 이는 Rust에게 해당 함수가 안전하다고 약속하는 것과 같다. 여전히 프로그래머가 그 약속을 지킬 책임이 있다.

다른 언어에서 Rust 함수 호출하기

extern을 사용해 다른 언어가 Rust 함수를 호출할 수 있는 인터페이스를 생성할 수도 있다. 전체 extern 블록을 생성하는 대신, 해당 함수의 fn 키워드 앞에 extern 키워드와 사용할 ABI를 지정한다. 또한 #[unsafe(no_mangle)] 어노테이션을 추가해 Rust 컴파일러가 이 함수의 이름을 변경하지 않도록 해야 한다. _Mangling_은 컴파일러가 함수에 부여한 이름을 컴파일 과정의 다른 부분에서 사용하기 위해 더 많은 정보를 포함하지만 인간이 읽기 어려운 다른 이름으로 변경하는 것이다. 모든 프로그래밍 언어 컴파일러는 이름을 약간씩 다르게 변경하므로, Rust 함수가 다른 언어에서 호출 가능하려면 Rust 컴파일러의 이름 변경 기능을 비활성화해야 한다. 이는 내장된 이름 변경 기능이 없을 때 라이브러리 간 이름 충돌이 발생할 수 있기 때문에 안전하지 않으며, 프로그래머가 선택한 이름이 변경 없이 안전하게 내보낼 수 있는지 확인할 책임이 있다.

다음 예제에서는 call_from_c 함수를 C 코드에서 접근 가능하도록 만들고, 공유 라이브러리로 컴파일한 후 C에서 링크하는 방법을 보여준다:

#![allow(unused)]
fn main() {
#[unsafe(no_mangle)]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

extern 사용법은 어노테이션에서만 unsafe가 필요하며, extern 블록에서는 필요하지 않다.

변경 가능한 정적 변수에 접근하거나 수정하기

이 책에서 아직 다루지 않은 전역 변수는 Rust에서 지원하지만, Rust의 소유권 규칙과 충돌할 수 있다. 두 스레드가 동일한 변경 가능한 전역 변수에 접근하면 데이터 경쟁이 발생할 수 있다.

Rust에서 전역 변수는 정적 변수라고 부른다. Listing 20-10은 문자열 슬라이스를 값으로 가지는 정적 변수의 선언과 사용 예제를 보여준다.

Filename: src/main.rs
static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}
Listing 20-10: 변경 불가능한 정적 변수 정의 및 사용

정적 변수는 3장의 “상수”에서 다룬 상수와 유사하다. 정적 변수의 이름은 관례적으로 SCREAMING_SNAKE_CASE로 작성한다. 정적 변수는 'static 수명을 가진 참조만 저장할 수 있으며, 이는 Rust 컴파일러가 수명을 추론할 수 있기 때문에 명시적으로 주석을 달 필요가 없다는 것을 의미한다. 변경 불가능한 정적 변수에 접근하는 것은 안전하다.

상수와 변경 불가능한 정적 변수의 미묘한 차이점은 정적 변수의 값이 메모리에서 고정된 주소를 가진다는 것이다. 값을 사용할 때 항상 동일한 데이터에 접근한다. 반면, 상수는 사용될 때마다 데이터를 복제할 수 있다. 또 다른 차이점은 정적 변수가 변경 가능할 수 있다는 것이다. 변경 가능한 정적 변수에 접근하거나 수정하는 것은 안전하지 않다. Listing 20-11은 COUNTER라는 변경 가능한 정적 변수를 선언하고, 접근하며, 수정하는 방법을 보여준다.

Filename: src/main.rs
static mut COUNTER: u32 = 0;

/// SAFETY: Calling this from more than a single thread at a time is undefined
/// behavior, so you *must* guarantee you only call it from a single thread at
/// a time.
unsafe fn add_to_count(inc: u32) {
    unsafe {
        COUNTER += inc;
    }
}

fn main() {
    unsafe {
        // SAFETY: This is only called from a single thread in `main`.
        add_to_count(3);
        println!("COUNTER: {}", *(&raw const COUNTER));
    }
}
Listing 20-11: 변경 가능한 정적 변수에서 읽거나 쓰는 것은 안전하지 않음

일반 변수와 마찬가지로 mut 키워드를 사용해 변경 가능성을 지정한다. COUNTER에서 읽거나 쓰는 모든 코드는 unsafe 블록 내에 있어야 한다. 이 코드는 단일 스레드에서 실행되기 때문에 컴파일되고 COUNTER: 3을 출력한다. 여러 스레드가 COUNTER에 접근하면 데이터 경쟁이 발생할 가능성이 높기 때문에 이는 정의되지 않은 동작이다. 따라서 전체 함수를 unsafe로 표시하고, 안전성 제한 사항을 문서화하여 함수를 호출하는 사람이 무엇을 안전하게 할 수 있고 없는지 알 수 있도록 해야 한다.

안전하지 않은 함수를 작성할 때는 SAFETY로 시작하는 주석을 추가하고, 호출자가 함수를 안전하게 호출하기 위해 해야 할 일을 설명하는 것이 관례적이다. 마찬가지로, 안전하지 않은 작업을 수행할 때는 SAFETY로 시작하는 주석을 추가해 안전성 규칙이 어떻게 유지되는지 설명하는 것이 일반적이다.

또한 컴파일러는 변경 가능한 정적 변수에 대한 참조를 생성하는 것을 허용하지 않는다. 오직 원시 포인터를 통해 접근할 수 있으며, 이는 원시 차용 연산자 중 하나를 사용해 생성된다. 이는 println!과 같이 참조가 암묵적으로 생성되는 경우에도 해당된다. 정적 변경 가능 변수에 대한 참조가 오직 원시 포인터를 통해 생성되어야 한다는 요구 사항은 이를 사용할 때의 안전성 요구 사항을 더 명확히 한다.

전역적으로 접근 가능한 변경 가능한 데이터의 경우 데이터 경쟁이 없음을 보장하기 어렵기 때문에, Rust는 변경 가능한 정적 변수를 안전하지 않다고 간주한다. 가능한 경우 16장에서 다룬 동시성 기법과 스레드 안전한 스마트 포인터를 사용해 컴파일러가 다른 스레드에서의 데이터 접근이 안전하게 이루어지는지 확인하도록 하는 것이 바람직하다.

안전하지 않은 트레이트 구현하기

unsafe 키워드를 사용해 안전하지 않은 트레이트를 구현할 수 있다. 트레이트가 안전하지 않은 경우는, 해당 트레이트의 메서드 중 적어도 하나가 컴파일러가 검증할 수 없는 불변 조건을 가지고 있을 때이다. 트레이트를 unsafe로 선언하려면 trait 앞에 unsafe 키워드를 추가하고, 트레이트의 구현도 unsafe로 표시해야 한다. 이는 리스트 20-12에서 확인할 수 있다.

unsafe trait Foo {
    // methods go here
}

unsafe impl Foo for i32 {
    // method implementations go here
}

fn main() {}
Listing 20-12: 안전하지 않은 트레이트 정의 및 구현

unsafe impl을 사용함으로써, 우리는 컴파일러가 검증할 수 없는 불변 조건을 지킬 것을 약속한다.

예를 들어, 16장에서 논의한 SyncSend 마커 트레이트를 떠올려 보자. 이 트레이트는 우리의 타입이 SendSync를 구현한 다른 타입들로만 구성된 경우, 컴파일러가 자동으로 구현한다. 하지만 SendSync를 구현하지 않은 타입(예: raw 포인터)을 포함하는 타입을 구현하고, 그 타입을 SendSync로 표시하려면 unsafe를 사용해야 한다. Rust는 우리의 타입이 스레드 간에 안전하게 전송되거나 여러 스레드에서 안전하게 접근될 수 있는지 검증할 수 없다. 따라서 우리가 직접 이러한 검사를 수행하고, unsafe로 이를 표시해야 한다.

유니언(Union)의 필드 접근

unsafe 키워드와 함께만 사용할 수 있는 마지막 동작은 유니언(union)의 필드에 접근하는 것이다. 유니언은 구조체(struct)와 유사하지만, 특정 인스턴스에서 한 번에 하나의 선언된 필드만 사용된다. 유니언은 주로 C 코드의 유니언과 인터페이스하기 위해 사용된다. 유니언 필드에 접근하는 것은 안전하지 않은(unsafe) 작업으로 간주된다. 왜냐하면 Rust는 현재 유니언 인스턴스에 저장된 데이터의 타입을 보장할 수 없기 때문이다. 유니언에 대한 더 자세한 내용은 Rust Reference에서 확인할 수 있다.

Miri를 사용해 안전하지 않은 코드 검사하기

안전하지 않은 코드를 작성할 때, 실제로 안전하고 올바른지 확인하고 싶을 수 있다. 이를 확인하는 가장 좋은 방법 중 하나는 Miri를 사용하는 것이다. Miri는 정의되지 않은 동작을 감지하는 공식 Rust 도구다. 빌드 시점에 작동하는 정적 도구인 borrow checker와 달리, Miri는 런타임에 작동하는 동적 도구다. 프로그램이나 테스트 스위트를 실행하면서 Rust가 작동해야 하는 방식에 대한 규칙을 위반하는지 확인한다.

Miri를 사용하려면 Rust의 nightly 버전이 필요하다(부록 G: Rust의 제작 과정과 “Nightly Rust”에서 더 자세히 다룬다). rustup +nightly component add miri 명령어를 입력하면 Rust의 nightly 버전과 Miri 도구를 설치할 수 있다. 이 명령어는 프로젝트에서 사용하는 Rust 버전을 변경하지 않는다. 단지 시스템에 도구를 추가해 필요할 때 사용할 수 있게 해준다. 프로젝트에서 Miri를 실행하려면 cargo +nightly miri run 또는 cargo +nightly miri test를 입력하면 된다.

이 도구가 얼마나 유용한지 예를 들어보자. 리스트 20-11에 Miri를 실행하면 어떤 일이 발생하는지 살펴보자.

$ cargo +nightly miri run
   Compiling unsafe-example v0.1.0 (file:///projects/unsafe-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `file:///home/.rustup/toolchains/nightly/bin/cargo-miri runner target/miri/debug/unsafe-example`
COUNTER: 3

Miri는 변경 가능한 데이터에 대한 공유 참조가 있다고 정확히 경고한다. 이 경우 Miri는 경고만 발행하는데, 이는 반드시 정의되지 않은 동작이 발생한다고 보장할 수 없기 때문이다. 또한 문제를 해결하는 방법을 알려주지 않는다. 하지만 최소한 정의되지 않은 동작의 위험이 있다는 것을 알 수 있고, 코드를 안전하게 만드는 방법을 고민할 수 있다. 어떤 경우에는 Miri가 확실히 잘못된 코드 패턴을 감지하고, 이를 수정하는 방법에 대한 권장 사항을 제시하기도 한다.

Miri는 안전하지 않은 코드를 작성할 때 발생할 수 있는 모든 문제를 잡아내지는 못한다. Miri는 동적 분석 도구이기 때문에 실제로 실행되는 코드의 문제만 감지한다. 따라서 작성한 안전하지 않은 코드에 대한 신뢰를 높이려면 좋은 테스트 기법과 함께 사용해야 한다. 또한 Miri는 코드가 불안정해질 수 있는 모든 가능한 경우를 다루지 않는다.

다시 말하면, Miri가 문제를 감지하면 버그가 있다는 것을 알 수 있지만, Miri가 버그를 감지하지 못했다고 해서 문제가 없다는 의미는 아니다. 그럼에도 Miri는 많은 문제를 잡아낼 수 있다. 이 장의 다른 안전하지 않은 코드 예제에 Miri를 실행해보고 어떤 결과가 나오는지 확인해보자!

Miri에 대해 더 자세히 알고 싶다면 GitHub 저장소를 참고하자.

언제 unsafe 코드를 사용할까

앞서 언급한 다섯 가지 강력한 기능을 사용하기 위해 unsafe를 사용하는 것은 잘못된 일도 아니고, 나쁜 것도 아니다. 하지만 unsafe 코드를 올바르게 작성하는 것은 더 까다롭다. 왜냐하면 컴파일러가 메모리 안전성을 보장해주지 못하기 때문이다. unsafe 코드를 사용할 만한 이유가 있다면, 사용해도 좋다. 명시적인 unsafe 어노테이션을 사용하면 문제가 발생했을 때 그 원인을 쉽게 추적할 수 있다. unsafe 코드를 작성할 때는 Miri를 사용해 코드가 Rust의 규칙을 준수하는지 더 확신할 수 있다.

unsafe Rust를 효과적으로 사용하는 방법에 대해 더 깊이 알고 싶다면, Rust의 공식 가이드인 Rustonomicon을 읽어보자.

고급 트레이트

트레이트에 대해 처음 다룬 것은 10장 “트레이트: 공유 동작 정의하기”에서였다. 하지만 더 깊이 있는 세부 사항은 논의하지 않았다. 이제 여러분이 러스트에 대해 더 많이 알게 되었으니, 본격적으로 자세히 살펴볼 차례다.

연관 타입

_연관 타입_은 타입 플레이스홀더를 트레이트와 연결하여 트레이트 메서드 정의에서 이 플레이스홀더 타입을 시그니처에 사용할 수 있게 한다. 트레이트를 구현하는 쪽에서 플레이스홀더 타입 대신 구체적인 타입을 지정한다. 이렇게 하면, 트레이트를 구현할 때까지 정확히 어떤 타입인지 알 필요 없이, 트레이트에서 사용할 타입을 정의할 수 있다.

이 장에서 다룬 대부분의 고급 기능은 거의 필요하지 않다고 설명했다. 연관 타입은 중간 정도의 위치를 차지한다: 이 책의 다른 부분에서 설명한 기능보다는 덜 사용되지만, 이 장에서 다룬 다른 기능들보다는 더 자주 사용된다.

연관 타입이 있는 트레이트의 예로는 표준 라이브러리가 제공하는 Iterator 트레이트가 있다. 이 트레이트의 연관 타입은 Item이라는 이름을 가지며, Iterator 트레이트를 구현하는 타입이 순회하는 값의 타입을 나타낸다. Iterator 트레이트의 정의는 아래 목록 20-13과 같다.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
Listing 20-13: 연관 타입 Item을 가진 Iterator 트레이트의 정의

Item 타입은 플레이스홀더이며, next 메서드의 정의는 Option<Self::Item> 타입의 값을 반환할 것임을 보여준다. Iterator 트레이트를 구현하는 쪽에서 Item에 대한 구체적인 타입을 지정하면, next 메서드는 그 타입의 값을 포함한 Option을 반환한다.

연관 타입은 제네릭과 비슷한 개념으로 보일 수 있다. 제네릭은 함수를 정의할 때 처리할 타입을 지정하지 않아도 되게 한다. 두 개념의 차이를 이해하기 위해, Item 타입을 u32로 지정한 Counter 타입에 Iterator 트레이트를 구현한 예제를 살펴보자.

Filename: src/lib.rs
struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // --snip--
        if self.count < 5 {
            self.count += 1;
            Some(self.count)
        } else {
            None
        }
    }
}

이 문법은 제네릭과 유사해 보인다. 그렇다면 왜 Iterator 트레이트를 제네릭으로 정의하지 않았을까? 아래 목록 20-14는 제네릭을 사용한 Iterator 트레이트의 가상 정의를 보여준다.

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
Listing 20-14: 제네릭을 사용한 Iterator 트레이트의 가상 정의

차이점은 제네릭을 사용할 때, 목록 20-14와 같이 각 구현에서 타입을 명시해야 한다는 것이다. Iterator<String> for Counter를 구현하거나 다른 타입을 구현할 수 있기 때문에, Counter에 대해 Iterator를 여러 번 구현할 수 있다. 즉, 트레이트에 제네릭 파라미터가 있으면, 한 타입에 대해 여러 번 구현할 수 있고, 매번 제네릭 타입 파라미터의 구체적인 타입을 바꿀 수 있다. Counter에서 next 메서드를 사용할 때, 어떤 Iterator 구현을 사용할지 타입 어노테이션을 제공해야 한다.

연관 타입을 사용하면, 타입을 명시할 필요가 없다. 왜냐하면 한 타입에 대해 트레이트를 여러 번 구현할 수 없기 때문이다. 목록 20-13에서 연관 타입을 사용한 정의를 보면, Item 타입을 한 번만 선택할 수 있다. 왜냐하면 impl Iterator for Counter는 하나만 존재할 수 있기 때문이다. Counter에서 next를 호출할 때마다 u32 값의 반복자를 원한다고 명시할 필요가 없다.

연관 타입은 또한 트레이트의 계약의 일부가 된다: 트레이트를 구현하는 쪽은 연관 타입 플레이스홀더를 대체할 타입을 제공해야 한다. 연관 타입은 종종 그 타입이 어떻게 사용될지 설명하는 이름을 가지며, API 문서에서 연관 타입을 문서화하는 것이 좋은 관행이다.

기본 제네릭 타입 매개변수와 연산자 오버로딩

제네릭 타입 매개변수를 사용할 때, 제네릭 타입에 대한 기본 구체 타입을 지정할 수 있다. 이렇게 하면 기본 타입이 적합한 경우, 트레이트를 구현하는 개발자가 구체 타입을 지정할 필요가 없어진다. 기본 타입은 <PlaceholderType=ConcreteType> 구문을 사용해 제네릭 타입을 선언할 때 지정한다.

이 기법이 유용한 대표적인 예는 연산자 오버로딩이다. 연산자 오버로딩은 특정 상황에서 연산자(예: +)의 동작을 커스텀하는 것을 의미한다.

Rust에서는 새로운 연산자를 만들거나 임의의 연산자를 오버로드할 수 없다. 하지만 std::ops에 나열된 연산과 해당 트레이트를 구현함으로써 연산자를 오버로드할 수 있다. 예를 들어, 리스트 20-15에서는 Point 인스턴스 두 개를 더하기 위해 + 연산자를 오버로드한다. 이를 위해 Point 구조체에 Add 트레이트를 구현한다.

Filename: src/main.rs
use std::ops::Add;

#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

fn main() {
    assert_eq!(
        Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
        Point { x: 3, y: 3 }
    );
}
Listing 20-15: Point 인스턴스에 + 연산자를 오버로드하기 위해 Add 트레이트 구현

add 메서드는 두 Point 인스턴스의 x 값과 y 값을 더해 새로운 Point를 생성한다. Add 트레이트에는 Output이라는 연관 타입이 있으며, 이 타입은 add 메서드가 반환하는 타입을 결정한다.

이 코드에서 기본 제네릭 타입은 Add 트레이트 내에 있다. 다음은 그 정의이다:

#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
    type Output;

    fn add(self, rhs: Rhs) -> Self::Output;
}
}

이 코드는 일반적으로 익숙할 것이다: 하나의 메서드와 연관 타입을 가진 트레이트이다. 새로운 부분은 Rhs=Self이다: 이 구문을 기본 타입 매개변수라고 한다. Rhs 제네릭 타입 매개변수(“right-hand side“의 약어)는 add 메서드의 rhs 매개변수 타입을 정의한다. Add 트레이트를 구현할 때 Rhs에 대한 구체 타입을 지정하지 않으면, Rhs의 타입은 기본적으로 Self가 되며, 이는 Add를 구현 중인 타입이 된다.

Point에 대해 Add를 구현할 때, 두 Point 인스턴스를 더하고 싶었기 때문에 Rhs의 기본값을 사용했다. 이제 기본값을 사용하지 않고 Rhs 타입을 커스텀하는 Add 트레이트 구현 예제를 살펴보자.

MillimetersMeters라는 두 구조체가 있으며, 각각 다른 단위로 값을 보관한다. 기존 타입을 다른 구조체로 감싸는 이 방식을 뉴타입 패턴이라고 하며, 이에 대해서는 “뉴타입 패턴을 사용해 외부 타입에 외부 트레이트 구현하기” 섹션에서 자세히 설명한다. 밀리미터 단위의 값과 미터 단위의 값을 더하고, Add 구현이 올바르게 변환하도록 하고 싶다. 리스트 20-16과 같이 Millimeters에 대해 MetersRhs로 지정해 Add를 구현할 수 있다.

Filename: src/lib.rs
use std::ops::Add;

struct Millimeters(u32);
struct Meters(u32);

impl Add<Meters> for Millimeters {
    type Output = Millimeters;

    fn add(self, other: Meters) -> Millimeters {
        Millimeters(self.0 + (other.0 * 1000))
    }
}
Listing 20-16: MillimetersAdd 트레이트를 구현해 MillimetersMeters를 더하기

MillimetersMeters를 더하기 위해, impl Add<Meters>를 지정해 Rhs 타입 매개변수의 값을 설정한다. 이렇게 하면 Self의 기본값을 사용하지 않는다.

기본 타입 매개변수는 주로 두 가지 방식으로 사용된다:

  1. 기존 코드를 손상시키지 않고 타입을 확장하기 위해
  2. 대부분의 사용자가 필요로 하지 않는 특정 경우에 커스텀을 허용하기 위해

표준 라이브러리의 Add 트레이트는 두 번째 목적의 예이다: 일반적으로 동일한 타입 두 개를 더하지만, Add 트레이트는 그 이상의 커스텀을 허용한다. Add 트레이트 정의에서 기본 타입 매개변수를 사용하면 대부분의 경우 추가 매개변수를 지정할 필요가 없다. 즉, 구현 상의 보일러플레이트가 필요 없어져 트레이트를 더 쉽게 사용할 수 있다.

첫 번째 목적은 두 번째와 비슷하지만 반대 방향이다: 기존 트레이트에 타입 매개변수를 추가하려면, 기본값을 지정해 트레이트의 기능을 확장할 수 있으며, 기존 구현 코드를 손상시키지 않는다.

동일한 이름의 메서드 구분하기

Rust에서는 서로 다른 트레이트가 동일한 이름의 메서드를 가질 수 있다. 또한 하나의 타입에 여러 트레이트를 구현할 수도 있다. 심지어 타입 자체에 트레이트의 메서드와 동일한 이름의 메서드를 직접 구현할 수도 있다.

동일한 이름의 메서드를 호출할 때는 Rust에게 어떤 메서드를 사용할지 명확히 알려줘야 한다. 예를 들어, PilotWizard라는 두 트레이트가 있고, 둘 다 fly라는 메서드를 가지고 있다고 가정하자. 이 두 트레이트를 Human 타입에 구현하고, Human 타입 자체에도 fly 메서드를 직접 구현했다. 각 fly 메서드는 서로 다른 동작을 수행한다.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {}
Listing 20-17: 두 트레이트가 fly 메서드를 가지고 있으며, Human 타입에 구현되었고, Human 타입에 직접 fly 메서드가 구현됨

Human 인스턴스에서 fly를 호출하면, 컴파일러는 타입에 직접 구현된 메서드를 기본적으로 호출한다. 이는 Listing 20-18에서 확인할 수 있다.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}
Listing 20-18: Human 인스턴스에서 fly 호출

이 코드를 실행하면 *waving arms furiously*가 출력된다. 이는 Rust가 Human에 직접 구현된 fly 메서드를 호출했음을 보여준다.

Pilot 트레이트나 Wizard 트레이트의 fly 메서드를 호출하려면, 더 명시적인 문법을 사용해 어떤 fly 메서드를 호출할지 지정해야 한다. Listing 20-19는 이 문법을 보여준다.

Filename: src/main.rs
trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}
Listing 20-19: 호출할 트레이트의 fly 메서드 지정

메서드 이름 앞에 트레이트 이름을 지정하면 Rust가 어떤 fly 구현을 호출할지 명확히 알 수 있다. Human::fly(&person)과 같이 작성할 수도 있지만, 이는 person.fly()와 동일하며, 구분이 필요하지 않다면 더 길게 작성할 필요는 없다.

이 코드를 실행하면 다음과 같이 출력된다:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
     Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*

fly 메서드가 self 파라미터를 가지고 있기 때문에, 두 타입이 하나의 트레이트를 구현하고 있다면 Rust는 self의 타입을 기반으로 어떤 트레이트 구현을 사용할지 결정할 수 있다.

그러나 메서드가 아닌 연관 함수는 self 파라미터를 가지고 있지 않다. 동일한 함수 이름을 가진 여러 타입이나 트레이트가 있을 때, Rust는 _완전한 정규화 문법(fully qualified syntax)_을 사용하지 않으면 어떤 타입을 의미하는지 알 수 없다. 예를 들어, Listing 20-20에서는 모든 강아지의 이름을 _Spot_으로 짓는 동물 보호소를 위한 트레이트를 만든다. Animal 트레이트에는 연관 함수 baby_name이 있다. Animal 트레이트는 Dog 구조체에 구현되며, Dog에도 직접 baby_name 연관 함수가 제공된다.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}
Listing 20-20: 연관 함수를 가진 트레이트와 동일한 이름의 연관 함수를 가진 타입이 트레이트를 구현함

Dog에 정의된 baby_name 연관 함수에서 모든 강아지의 이름을 Spot으로 짓는 코드를 구현했다. Dog 타입은 Animal 트레이트도 구현하며, 이 트레이트는 모든 동물이 가진 특성을 설명한다. 강아지는 puppy라고 불리며, 이는 Animal 트레이트의 baby_name 함수에서 표현된다.

main에서 Dog::baby_name 함수를 호출하면, Dog에 직접 정의된 연관 함수가 호출된다. 이 코드는 다음과 같이 출력된다:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
     Running `target/debug/traits-example`
A baby dog is called a Spot

이 출력은 우리가 원하는 결과가 아니다. 우리는 Dog에 구현된 Animal 트레이트의 baby_name 함수를 호출해 A baby dog is called a puppy가 출력되길 원한다. Listing 20-19에서 사용한 트레이트 이름 지정 기법은 여기서 도움이 되지 않는다. main을 Listing 20-21의 코드로 변경하면 컴파일 오류가 발생한다.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}
Listing 20-21: Animal 트레이트의 baby_name 함수를 호출하려고 시도했지만, Rust가 어떤 구현을 사용할지 알 수 없음

Animal::baby_nameself 파라미터를 가지고 있지 않으며, Animal 트레이트를 구현하는 다른 타입이 있을 수 있기 때문에 Rust는 어떤 Animal::baby_name 구현을 사용할지 결정할 수 없다. 이 경우 다음과 같은 컴파일 오류가 발생한다:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
  --> src/main.rs:20:43
   |
2  |     fn baby_name() -> String;
   |     ------------------------- `Animal::baby_name` defined here
...
20 |     println!("A baby dog is called a {}", Animal::baby_name());
   |                                           ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
   |
help: use the fully-qualified path to the only available implementation
   |
20 |     println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
   |                                           +++++++       +

For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error

Rust에게 다른 타입이 아닌 Dog에 구현된 Animal 트레이트의 구현을 사용하라고 명확히 알려주기 위해 완전한 정규화 문법을 사용해야 한다. Listing 20-22는 완전한 정규화 문법을 사용하는 방법을 보여준다.

Filename: src/main.rs
trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
Listing 20-22: 완전한 정규화 문법을 사용해 Dog에 구현된 Animal 트레이트의 baby_name 함수를 호출함

각괄호 안에 타입 어노테이션을 제공해 Rust에게 Dog 타입을 Animal로 취급해 baby_name 메서드를 호출하라고 알려준다. 이제 이 코드는 우리가 원하는 대로 출력한다:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
     Running `target/debug/traits-example`
A baby dog is called a puppy

일반적으로 완전한 정규화 문법은 다음과 같이 정의된다:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

메서드가 아닌 연관 함수의 경우 receiver가 없으며, 다른 인수 목록만 존재한다. 함수나 메서드를 호출할 때 완전한 정규화 문법을 어디서든 사용할 수 있다. 그러나 Rust가 프로그램의 다른 정보를 통해 추론할 수 있는 부분은 생략할 수 있다. 동일한 이름을 가진 여러 구현이 있고 Rust가 어떤 구현을 호출할지 도움이 필요한 경우에만 이 더 장황한 문법을 사용하면 된다.

슈퍼트레이트 사용하기

때로는 한 트레이트 정의가 다른 트레이트에 의존하도록 작성할 수 있다. 첫 번째 트레이트를 구현하려면 해당 타입이 두 번째 트레이트도 구현해야 한다. 이렇게 하면 트레이트 정의에서 두 번째 트레이트의 연관 아이템을 활용할 수 있다. 이때 의존하는 트레이트를 _슈퍼트레이트_라고 부른다.

예를 들어, OutlinePrint 트레이트를 만들고, outline_print 메서드를 통해 주어진 값을 별표로 둘러싼 형태로 출력하려 한다고 가정해 보자. 즉, Point 구조체가 표준 라이브러리의 Display 트레이트를 구현하여 (x, y) 형태로 출력된다면, x1이고 y3Point 인스턴스에서 outline_print를 호출하면 다음과 같은 결과가 출력되어야 한다:

**********
*        *
* (1, 3) *
*        *
**********

outline_print 메서드를 구현할 때 Display 트레이트의 기능을 사용하고자 한다. 따라서 OutlinePrint 트레이트는 Display를 구현한 타입에 대해서만 동작하도록 지정해야 한다. 이를 위해 트레이트 정의에서 OutlinePrint: Display를 지정할 수 있다. 이 기법은 트레이트에 트레이트 바운드를 추가하는 것과 유사하다. 아래 예제는 OutlinePrint 트레이트의 구현을 보여준다.

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

fn main() {}
Listing 20-23: Display의 기능을 필요로 하는 OutlinePrint 트레이트 구현

OutlinePrintDisplay 트레이트를 필요로 한다고 명시했기 때문에, Display를 구현한 모든 타입에 대해 자동으로 구현되는 to_string 함수를 사용할 수 있다. 만약 Display 트레이트를 지정하지 않고 to_string을 사용하려고 하면, 현재 스코프에서 &Self 타입에 대해 to_string 메서드를 찾을 수 없다는 오류가 발생한다.

이제 Display를 구현하지 않은 타입(예: Point 구조체)에 OutlinePrint를 구현하려고 할 때 어떤 일이 발생하는지 살펴보자:

Filename: src/main.rs
use std::fmt;

trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

이 경우 Display가 필요하지만 구현되지 않았다는 오류가 발생한다:

$ cargo run
   Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:20:23
   |
20 | impl OutlinePrint for Point {}
   |                       ^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint`

error[E0277]: `Point` doesn't implement `std::fmt::Display`
  --> src/main.rs:24:7
   |
24 |     p.outline_print();
   |       ^^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter
   |
   = help: the trait `std::fmt::Display` is not implemented for `Point`
   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
note: required by a bound in `OutlinePrint::outline_print`
  --> src/main.rs:3:21
   |
3  | trait OutlinePrint: fmt::Display {
   |                     ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
4  |     fn outline_print(&self) {
   |        ------------- required by a bound in this associated function

For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors

이 문제를 해결하려면 PointDisplay를 구현하여 OutlinePrint가 요구하는 제약 조건을 충족시켜야 한다. 아래와 같이 작성할 수 있다:

Filename: src/main.rs
trait OutlinePrint: fmt::Display {
    fn outline_print(&self) {
        let output = self.to_string();
        let len = output.len();
        println!("{}", "*".repeat(len + 4));
        println!("*{}*", " ".repeat(len + 2));
        println!("* {output} *");
        println!("*{}*", " ".repeat(len + 2));
        println!("{}", "*".repeat(len + 4));
    }
}

struct Point {
    x: i32,
    y: i32,
}

impl OutlinePrint for Point {}

use std::fmt;

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    let p = Point { x: 1, y: 3 };
    p.outline_print();
}

이제 PointOutlinePrint 트레이트를 구현하면 성공적으로 컴파일되며, Point 인스턴스에서 outline_print를 호출해 별표로 둘러싼 형태로 출력할 수 있다.

뉴타입 패턴을 사용해 외부 타입에 외부 트레잇 구현하기

10장의 “타입에 트레잇 구현하기”에서, 오펀 규칙(orphan rule)에 대해 언급했다. 이 규칙에 따르면, 트레잇이나 타입 중 하나 또는 둘 다 크레이트 내부에 정의되어 있을 때만 해당 타입에 트레잇을 구현할 수 있다. 이 제약을 우회하기 위해 **뉴타입 패턴(newtype pattern)**을 사용할 수 있다. 이 패턴은 튜플 구조체(tuple struct) 내에 새로운 타입을 만드는 방식이다. (튜플 구조체에 대해서는 5장의 “이름 없는 필드를 가진 튜플 구조체로 서로 다른 타입 만들기”에서 다뤘다.) 튜플 구조체는 하나의 필드를 가지며, 트레잇을 구현하려는 타입을 감싸는 얇은 래퍼 역할을 한다. 이렇게 하면 래퍼 타입이 크레이트 내부에 속하게 되고, 래퍼에 트레잇을 구현할 수 있다. 뉴타입이라는 용어는 Haskell 프로그래밍 언어에서 유래했다. 이 패턴을 사용해도 런타임 성능에 영향을 미치지 않으며, 컴파일 시 래퍼 타입은 제거된다.

예를 들어, Vec<T>Display 트레잇을 구현하고 싶다고 가정해보자. 오펀 규칙 때문에 Display 트레잇과 Vec<T> 타입이 모두 크레이트 외부에 정의되어 있으므로 직접 구현할 수 없다. 이 경우 Vec<T> 인스턴스를 갖는 Wrapper 구조체를 만들 수 있다. 그런 다음 WrapperDisplay를 구현하고 Vec<T> 값을 사용할 수 있다. 이 내용은 리스트 20-24에 나와 있다.

Filename: src/main.rs
use std::fmt;

struct Wrapper(Vec<String>);

impl fmt::Display for Wrapper {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "[{}]", self.0.join(", "))
    }
}

fn main() {
    let w = Wrapper(vec![String::from("hello"), String::from("world")]);
    println!("w = {w}");
}
Listing 20-24: Vec<String>을 감싸는 Wrapper 타입을 만들어 Display 구현하기

Display 구현에서 self.0을 사용해 내부의 Vec<T>에 접근한다. Wrapper는 튜플 구조체이고, Vec<T>는 튜플의 0번 인덱스에 위치하기 때문이다. 이제 Wrapper에서 Display 트레잇의 기능을 사용할 수 있다.

이 기법의 단점은 Wrapper가 새로운 타입이기 때문에, 내부에 있는 값의 메서드를 그대로 사용할 수 없다는 점이다. Vec<T>의 모든 메서드를 Wrapper에 직접 구현해야 한다. 이때 메서드들은 self.0에 위임해야 하며, 이렇게 하면 WrapperVec<T>처럼 다룰 수 있다. 만약 새로운 타입이 내부 타입의 모든 메서드를 갖게 하려면, WrapperDeref 트레잇을 구현해 내부 타입을 반환하는 방식으로 해결할 수 있다. (15장의 Deref 트레잇으로 스마트 포인터를 일반 참조처럼 다루기”에서 Deref 트레잇 구현에 대해 다뤘다.) 만약 Wrapper 타입이 내부 타입의 모든 메서드를 갖지 않게 하려면, 예를 들어 Wrapper 타입의 동작을 제한하려면, 원하는 메서드만 수동으로 구현해야 한다.

이 뉴타입 패턴은 트레잇과 관련이 없을 때도 유용하다. 이제 관점을 바꿔 러스트의 타입 시스템과 상호작용하는 몇 가지 고급 방법을 살펴보자.

고급 타입

Rust의 타입 시스템에는 지금까지 언급만 하고 자세히 다루지 않은 몇 가지 기능이 있다. 먼저 뉴타입(newtype)의 일반적인 개념을 살펴보고, 왜 뉴타입이 유용한 타입인지 알아본다. 그런 다음 뉴타입과 유사하지만 약간 다른 의미를 가진 타입 별칭(type alias)에 대해 설명한다. 마지막으로 ! 타입과 동적 크기 타입(dynamically sized types)에 대해 논의한다.

타입 안전성과 추상화를 위한 뉴타입 패턴 사용

이 섹션을 읽기 전에 이전 섹션인 “외부 타입에 외부 트레이트를 구현하기 위해 뉴타입 패턴 사용”을 먼저 읽었다고 가정한다. 뉴타입 패턴은 지금까지 논의한 내용 외에도, 값이 혼동되지 않도록 정적으로 강제하거나 값의 단위를 나타내는 데 유용하다. 리스트 20-16에서 뉴타입을 사용해 단위를 나타내는 예제를 살펴보았다: MillimetersMeters 구조체가 u32 값을 뉴타입으로 감싸는 것을 기억할 것이다. 만약 Millimeters 타입의 매개변수를 받는 함수를 작성했다면, 실수로 Meters 타입이나 일반 u32 값으로 이 함수를 호출하려는 프로그램은 컴파일되지 않는다.

또한 뉴타입 패턴을 사용해 타입의 구현 세부 사항을 추상화할 수도 있다: 새로운 타입은 내부 타입의 API와 다른 공개 API를 노출할 수 있다.

뉴타입은 내부 구현을 숨기는 데에도 사용할 수 있다. 예를 들어, People 타입을 제공해 HashMap<i32, String>을 감싸고, 여기서 사람의 ID와 이름을 연결하여 저장할 수 있다. People을 사용하는 코드는 우리가 제공하는 공개 API와만 상호작용할 것이다. 예를 들어, People 컬렉션에 이름 문자열을 추가하는 메서드가 있을 수 있다. 이 코드는 내부적으로 이름에 i32 ID를 할당한다는 사실을 알 필요가 없다. 뉴타입 패턴은 구현 세부 사항을 숨기기 위한 가벼운 캡슐화 방법이다. 이는 18장의 “구현 세부 사항을 숨기는 캡슐화”에서 논의한 내용과 일맥상통한다.

타입 별칭을 사용해 타입 동의어 만들기

Rust는 기존 타입에 다른 이름을 붙일 수 있는 _타입 별칭_을 선언할 수 있는 기능을 제공한다. 이를 위해 type 키워드를 사용한다. 예를 들어, i32 타입에 Kilometers라는 별칭을 다음과 같이 만들 수 있다:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

이제 Kilometersi32의 _동의어_가 된다. Listing 20-16에서 만든 MillimetersMeters 타입과 달리, Kilometers는 별도의 새로운 타입이 아니다. Kilometers 타입의 값은 i32 타입의 값과 동일하게 처리된다:

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

    println!("x + y = {}", x + y);
}

Kilometersi32는 동일한 타입이므로, 두 타입의 값을 더할 수 있고, i32 타입의 매개변수를 받는 함수에 Kilometers 값을 전달할 수도 있다. 그러나 이 방법을 사용하면 앞서 설명한 newtype 패턴에서 얻을 수 있는 타입 검사 이점을 얻을 수 없다. 즉, Kilometersi32 값을 혼용하더라도 컴파일러는 에러를 발생시키지 않는다.

타입 별칭의 주요 사용 사례는 반복을 줄이는 것이다. 예를 들어, 다음과 같이 길고 복잡한 타입이 있을 수 있다:

Box<dyn Fn() + Send + 'static>

이렇게 긴 타입을 함수 시그니처나 타입 어노테이션에 반복적으로 작성하는 것은 지루하고 오류가 발생하기 쉽다. Listing 20-25와 같은 코드가 프로젝트 전체에 걸쳐 있다고 상상해 보자.

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));

    fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // --snip--
    }

    fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-25: 긴 타입을 여러 곳에서 사용하는 예

타입 별칭을 사용하면 반복을 줄여 코드를 더 관리하기 쉽게 만들 수 있다. Listing 20-26에서는 Thunk라는 별칭을 도입하여 긴 타입을 더 짧은 별칭 Thunk로 대체했다.

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("hi"));

    fn takes_long_type(f: Thunk) {
        // --snip--
    }

    fn returns_long_type() -> Thunk {
        // --snip--
        Box::new(|| ())
    }
}
Listing 20-26: 타입 별칭 Thunk를 도입해 반복을 줄이는 예

이 코드는 훨씬 읽고 쓰기 쉬워졌다! 타입 별칭에 의미 있는 이름을 선택하면 의도를 더 명확히 전달할 수도 있다 (_thunk_는 나중에 평가될 코드를 의미하는 단어이므로, 저장된 클로저에 적합한 이름이다).

타입 별칭은 Result<T, E> 타입과 함께 사용되어 반복을 줄이는 데에도 흔히 사용된다. 표준 라이브러리의 std::io 모듈을 생각해 보자. I/O 작업은 종종 작업이 실패할 경우를 처리하기 위해 Result<T, E>를 반환한다. 이 라이브러리에는 모든 가능한 I/O 오류를 나타내는 std::io::Error 구조체가 있다. std::io의 많은 함수들은 Estd::io::ErrorResult<T, E>를 반환한다. 예를 들어, Write 트레이트의 함수들은 다음과 같다:

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Result<..., Error>가 반복적으로 사용된다. 따라서 std::io는 다음과 같은 타입 별칭을 선언한다:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

이 선언이 std::io 모듈에 있으므로, std::io::Result<T>라는 완전한 별칭을 사용할 수 있다. 즉, Estd::io::Error로 채워진 Result<T, E>이다. Write 트레이트의 함수 시그니처는 다음과 같이 된다:

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

타입 별칭은 두 가지 방식으로 도움을 준다: 코드를 더 쉽게 작성할 수 있게 하고, std::io 전체에 걸쳐 일관된 인터페이스를 제공한다. 별칭이기 때문에 여전히 Result<T, E>이며, 따라서 Result<T, E>에서 동작하는 모든 메서드와 ? 연산자와 같은 특별한 문법을 사용할 수 있다.

값을 반환하지 않는 Never 타입

Rust에는 !라는 특별한 타입이 있다. 타입 이론에서는 이를 _빈 타입(empty type)_이라고 부르는데, 이 타입은 어떤 값도 가질 수 없기 때문이다. 하지만 우리는 이 타입을 _never 타입_이라고 부르는 것을 선호한다. 이 타입은 함수가 값을 반환하지 않을 때 반환 타입 자리에 위치하기 때문이다. 다음은 그 예시이다:

fn bar() -> ! {
    // --snip--
    panic!();
}

이 코드는 “함수 bar는 never를 반환한다“고 읽을 수 있다. never를 반환하는 함수는 _발산 함수(diverging functions)_라고 불린다. ! 타입의 값을 생성할 수 없기 때문에 bar는 절대 값을 반환할 수 없다.

그렇다면 값을 생성할 수 없는 타입은 왜 필요할까? 2장에서 다룬 숫자 맞추기 게임의 코드를 떠올려보자. 여기서는 그 중 일부를 다시 살펴본다.

use std::cmp::Ordering;
use std::io;

use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    println!("The secret number is: {secret_number}");

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        // --snip--

        io::stdin()
            .read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {guess}");

        // --snip--

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}
Listing 20-27: continue로 끝나는 match 구문

당시 이 코드의 몇 가지 세부 사항을 건너뛰었다. 6장의 match 제어 흐름 연산자”에서 match 구문의 모든 패턴은 동일한 타입을 반환해야 한다고 설명했다. 예를 들어, 다음 코드는 동작하지 않는다:

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

이 코드에서 guess의 타입은 정수 이면서 문자열이어야 하지만, Rust는 guess가 단일 타입을 가져야 한다고 요구한다. 그렇다면 continue는 무엇을 반환할까? Listing 20-27에서 한 패턴은 u32를 반환하고 다른 패턴은 continue로 끝나는데, 어떻게 이 코드가 유효할까?

추측할 수 있듯이, continue! 값을 가진다. 즉, Rust가 guess의 타입을 계산할 때, 두 패턴을 모두 살펴보는데, 하나는 u32 값을 가지고 다른 하나는 ! 값을 가진다. !는 값을 가질 수 없기 때문에 Rust는 guess의 타입을 u32로 결정한다.

이 동작을 공식적으로 설명하면, ! 타입의 표현식은 다른 어떤 타입으로도 강제 변환될 수 있다. match 패턴을 continue로 끝낼 수 있는 이유는 continue가 값을 반환하지 않고, 대신 루프의 시작으로 제어를 이동시키기 때문이다. 따라서 Err 경우에는 guess에 값을 할당하지 않는다.

never 타입은 panic! 매크로와도 유용하게 사용된다. Option<T> 값에 대해 unwrap 함수를 호출하면 값을 생성하거나 패닉을 일으키는데, 그 정의는 다음과 같다:

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

이 코드에서 Listing 20-27의 match 구문과 동일한 일이 발생한다: Rust는 valT 타입이고 panic!! 타입임을 확인한 후, 전체 match 표현식의 결과를 T로 결정한다. 이 코드는 panic!이 값을 생성하지 않고 프로그램을 종료하기 때문에 유효하다. None 경우에는 unwrap에서 값을 반환하지 않으므로 이 코드는 정상적으로 동작한다.

마지막으로 ! 타입을 가지는 표현식은 loop이다:

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

여기서 루프는 절대 끝나지 않으므로 !가 표현식의 값이다. 하지만 break를 포함한다면 이는 성립하지 않는다. break에 도달하면 루프가 종료되기 때문이다.

동적 크기 타입과 Sized 트레잇

Rust는 특정 타입의 값에 얼마나 많은 공간을 할당해야 하는지와 같은 세부 사항을 알아야 한다. 이 때문에 타입 시스템의 한 부분이 처음에는 조금 혼란스러울 수 있다: 바로 _동적 크기 타입(Dynamically Sized Types, DST)_의 개념이다. 이 타입은 DSTs 또는 _unsized types_라고도 불리며, 런타임에만 크기를 알 수 있는 값을 사용해 코드를 작성할 수 있게 해준다.

이 책 전반에서 사용해 온 str 타입을 예로 들어 동적 크기 타입의 세부 사항을 살펴보자. &str이 아니라 str 자체가 DST라는 점에 주목하자. 런타임에만 문자열의 길이를 알 수 있기 때문에, str 타입의 변수를 만들거나 str 타입의 인자를 받을 수 없다. 다음 코드는 동작하지 않는다:

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

Rust는 특정 타입의 값에 얼마나 많은 메모리를 할당해야 하는지 알아야 하며, 동일한 타입의 모든 값은 동일한 양의 메모리를 사용해야 한다. 만약 Rust가 이 코드를 허용한다면, 두 str 값은 동일한 크기의 공간을 차지해야 한다. 하지만 이들의 길이는 다르다: s1은 12바이트를 필요로 하고, s2는 15바이트를 필요로 한다. 이 때문에 동적 크기 타입을 담는 변수를 만들 수 없다.

그렇다면 어떻게 해야 할까? 이 경우 이미 답을 알고 있을 것이다: s1s2의 타입을 str 대신 &str로 만드는 것이다. 4장의 “문자열 슬라이스”에서 슬라이스 데이터 구조는 단지 시작 위치와 슬라이스의 길이만 저장한다는 것을 기억할 것이다. 따라서 &TT가 위치한 메모리 주소를 저장하는 단일 값이지만, &str 값을 저장한다: str의 주소와 그 길이. 이 때문에 &str 값의 크기를 컴파일 타임에 알 수 있다: usize 길이의 두 배다. 즉, &str이 참조하는 문자열의 길이가 얼마나 길든 상관없이 &str의 크기는 항상 알 수 있다. 일반적으로 Rust에서 동적 크기 타입을 사용하는 방식은 이렇다: 동적 정보의 크기를 저장하는 추가 메타데이터를 가지고 있다. 동적 크기 타입의 황금 법칙은 동적 크기 타입의 값을 항상 어떤 종류의 포인터 뒤에 두어야 한다는 것이다.

str을 다양한 포인터와 함께 사용할 수 있다: 예를 들어, Box<str>이나 Rc<str> 등이 있다. 사실, 이전에 다른 동적 크기 타입으로 이를 본 적이 있다: 트레잇이다. 모든 트레잇은 동적 크기 타입이며, 트레잇의 이름을 사용해 참조할 수 있다. 18장의 “서로 다른 타입의 값을 허용하는 트레잇 객체 사용”에서 트레잇을 트레잇 객체로 사용하려면 &dyn Trait이나 Box<dyn Trait> (Rc<dyn Trait>도 가능)과 같은 포인터 뒤에 두어야 한다고 언급했다.

DST를 다루기 위해 Rust는 Sized 트레잇을 제공한다. 이 트레잇은 타입의 크기가 컴파일 타임에 알려져 있는지 여부를 결정한다. 이 트레잇은 크기가 컴파일 타임에 알려진 모든 타입에 대해 자동으로 구현된다. 또한 Rust는 모든 제네릭 함수에 Sized 바운드를 암묵적으로 추가한다. 즉, 다음과 같은 제네릭 함수 정의는:

fn generic<T>(t: T) {
    // --snip--
}

실제로는 다음과 같이 작성된 것처럼 처리된다:

fn generic<T: Sized>(t: T) {
    // --snip--
}

기본적으로 제네릭 함수는 컴파일 타임에 크기가 알려진 타입에 대해서만 동작한다. 하지만 다음과 같은 특수 문법을 사용해 이 제한을 완화할 수 있다:

fn generic<T: ?Sized>(t: &T) {
    // --snip--
}

?Sized 트레잇 바운드는 “TSized일 수도 있고 아닐 수도 있다“는 의미이며, 이 표기법은 제네릭 타입이 컴파일 타임에 크기가 알려져야 한다는 기본 설정을 재정의한다. 이 의미의 ?Trait 문법은 Sized에만 사용할 수 있으며, 다른 트레잇에는 사용할 수 없다.

또한 t 매개변수의 타입을 T에서 &T로 변경했다. 타입이 Sized가 아닐 수 있기 때문에, 어떤 종류의 포인터 뒤에 두어야 한다. 이 경우 참조를 선택했다.

다음으로 함수와 클로저에 대해 이야기할 것이다!

고급 함수와 클로저

이 섹션에서는 함수 포인터와 클로저 반환을 포함해 함수와 클로저와 관련된 몇 가지 고급 기능을 살펴본다.

함수 포인터

클로저를 함수에 전달하는 방법에 대해 알아봤다면, 일반 함수도 함수에 전달할 수 있다! 이 기법은 새로운 클로저를 정의하지 않고 이미 정의된 함수를 전달하고 싶을 때 유용하다. 함수는 Fn 클로저 트레잇과 혼동하지 않도록 fn (소문자 f) 타입으로 강제 변환된다. 이 fn 타입을 함수 포인터 라고 부른다. 함수 포인터를 사용해 함수를 전달하면, 함수를 다른 함수의 인자로 사용할 수 있다.

함수 포인터를 매개변수로 지정하는 문법은 클로저와 유사하다. 아래 예제 20-28에서 add_one 함수는 매개변수에 1을 더한다. do_twice 함수는 두 개의 매개변수를 받는다: 하나는 i32 타입의 매개변수를 받고 i32를 반환하는 함수 포인터, 다른 하나는 i32 값이다. do_twice 함수는 함수 f를 두 번 호출하며 arg 값을 전달한 후, 두 함수 호출 결과를 더한다. main 함수는 add_one5를 인자로 do_twice를 호출한다.

Filename: src/main.rs
fn add_one(x: i32) -> i32 {
    x + 1
}

fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
    f(arg) + f(arg)
}

fn main() {
    let answer = do_twice(add_one, 5);

    println!("The answer is: {answer}");
}
Listing 20-28: 함수 포인터를 인자로 받기 위해 fn 타입 사용

이 코드는 The answer is: 12를 출력한다. do_twice의 매개변수 fi32 타입의 매개변수를 하나 받고 i32를 반환하는 fn 타입으로 지정한다. 그런 다음 do_twice 본문에서 f를 호출할 수 있다. main에서는 add_one 함수 이름을 do_twice의 첫 번째 인자로 전달한다.

클로저와 달리 fn은 트레잇이 아닌 타입이므로, Fn 트레잇 중 하나를 트레잇 바운드로 사용해 제네릭 타입 매개변수를 선언하는 대신 fn을 직접 매개변수 타입으로 지정한다.

함수 포인터는 세 가지 클로저 트레잇(Fn, FnMut, FnOnce)을 모두 구현한다. 즉, 클로저를 기대하는 함수에 항상 함수 포인터를 인자로 전달할 수 있다. 함수나 클로저를 모두 받을 수 있도록 제네릭 타입과 클로저 트레잇 중 하나를 사용해 함수를 작성하는 것이 가장 좋다.

그러나 클로저가 없는 외부 코드와 인터페이스할 때는 fn만 받고 싶을 수 있다. C 함수는 함수를 인자로 받을 수 있지만, C에는 클로저가 없다.

인라인으로 정의된 클로저나 이름이 있는 함수를 사용할 수 있는 예시로, 표준 라이브러리의 Iterator 트레잇이 제공하는 map 메서드를 살펴보자. map 메서드를 사용해 숫자 벡터를 문자열 벡터로 변환하려면, 아래 예제 20-29처럼 클로저를 사용할 수 있다.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(|i| i.to_string()).collect();
}
Listing 20-29: map 메서드와 클로저를 사용해 숫자를 문자열로 변환

또는 클로저 대신 함수 이름을 map의 인자로 사용할 수 있다. 예제 20-30은 이를 보여준다.

fn main() {
    let list_of_numbers = vec![1, 2, 3];
    let list_of_strings: Vec<String> =
        list_of_numbers.iter().map(ToString::to_string).collect();
}
Listing 20-30: String::to_string 메서드를 사용해 숫자를 문자열로 변환

여기서는 “고급 트레잇”에서 설명한 완전한 문법을 사용해야 한다. 왜냐하면 to_string이라는 이름의 함수가 여러 개 있기 때문이다.

여기서는 표준 라이브러리가 Display를 구현한 모든 타입에 대해 구현한 ToString 트레잇에 정의된 to_string 함수를 사용한다.

6장의 “열거형 값”에서 정의한 각 열거형 변형의 이름이 초기화 함수가 된다는 것을 기억할 것이다. 이 초기화 함수를 클로저 트레잇을 구현한 함수 포인터로 사용할 수 있다. 즉, 클로저를 받는 메서드에 초기화 함수를 인자로 지정할 수 있다. 예제 20-31에서 이를 확인할 수 있다.

fn main() {
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}
Listing 20-31: map 메서드와 열거형 초기화 함수를 사용해 숫자로부터 Status 인스턴스 생성

여기서 map이 호출된 범위의 각 u32 값을 사용해 Status::Value 인스턴스를 생성한다. 이때 Status::Value의 초기화 함수를 사용한다. 어떤 사람은 이 스타일을 선호하고, 어떤 사람은 클로저를 선호한다. 둘 다 같은 코드로 컴파일되므로, 더 명확한 스타일을 사용하면 된다.

클로저 반환하기

클로저는 트레잇으로 표현되기 때문에 직접 반환할 수 없다. 대부분의 경우 트레잇을 반환하려면 해당 트레잇을 구현한 구체적인 타입을 함수의 반환 값으로 사용할 수 있다. 하지만 클로저는 반환 가능한 구체적인 타입이 없기 때문에 일반적으로 이 방법을 사용할 수 없다. 예를 들어, 클로저가 스코프에서 값을 캡처하면 함수 포인터 fn을 반환 타입으로 사용할 수 없다.

대신, 일반적으로 10장에서 배운 impl Trait 문법을 사용한다. Fn, FnOnce, FnMut를 사용해 어떤 함수 타입이든 반환할 수 있다. 예를 들어, 아래 예제 코드는 정상적으로 동작한다.

#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}
Listing 20-32: impl Trait 문법을 사용해 함수에서 클로저 반환하기

하지만 13장의 “클로저 타입 추론과 명시적 타입 지정”에서 언급했듯이, 각 클로저는 고유한 타입을 가진다. 동일한 시그니처를 가지지만 구현이 다른 여러 함수를 다뤄야 한다면 트레잇 객체를 사용해야 한다. 아래 예제 코드에서 어떤 일이 발생하는지 살펴보자.

Filename: src/main.rs
fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}

fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
    move |x| x + init
}
Listing 20-33: impl Fn을 반환하는 함수로 정의된 클로저의 Vec<T> 생성하기

여기서 returns_closurereturns_initialized_closure 두 함수는 모두 impl Fn(i32) -> i32를 반환한다. 두 함수가 반환하는 클로저는 동일한 타입을 구현하지만 서로 다르다. 이 코드를 컴파일하려고 하면 Rust는 다음과 같은 오류를 발생시킨다:

$ cargo build
   Compiling functions-example v0.1.0 (file:///projects/functions-example)
error[E0308]: mismatched types
  --> src/main.rs:2:44
   |
2  |     let handlers = vec![returns_closure(), returns_initialized_closure(123)];
   |                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected opaque type, found a different opaque type
...
9  | fn returns_closure() -> impl Fn(i32) -> i32 {
   |                         ------------------- the expected opaque type
...
13 | fn returns_initialized_closure(init: i32) -> impl Fn(i32) -> i32 {
   |                                              ------------------- the found opaque type
   |
   = note: expected opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:9:25>)
              found opaque type `impl Fn(i32) -> i32` (opaque type at <src/main.rs:13:46>)
   = note: distinct uses of `impl Trait` result in different opaque types

For more information about this error, try `rustc --explain E0308`.
error: could not compile `functions-example` (bin "functions-example") due to 1 previous error

오류 메시지는 impl Trait를 반환할 때마다 Rust가 고유한 불투명 타입(opaque type) 을 생성한다는 것을 알려준다. 이 타입은 Rust가 우리를 위해 생성한 세부 사항을 볼 수 없는 타입이다. 따라서 이 두 함수가 동일한 트레잇 Fn(i32) -> i32를 구현하는 클로저를 반환하더라도, Rust가 생성한 불투명 타입은 서로 다르다. (이는 17장의 “여러 Future 다루기”에서 본 것처럼, 동일한 출력 타입을 가지는 다른 async 블록에 대해 Rust가 서로 다른 구체적인 타입을 생성하는 것과 유사하다.) 이 문제에 대한 해결책은 이미 여러 번 살펴봤듯이, 트레잇 객체를 사용하는 것이다. 아래 예제 코드를 참고하자.

fn main() {
    let handlers = vec![returns_closure(), returns_initialized_closure(123)];
    for handler in handlers {
        let output = handler(5);
        println!("{output}");
    }
}

fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
    Box::new(|x| x + 1)
}

fn returns_initialized_closure(init: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + init)
}
Listing 20-34: Box<dyn Fn>을 반환하는 함수로 정의된 클로저의 Vec<T> 생성하기

이 코드는 정상적으로 컴파일된다. 트레잇 객체에 대한 더 자세한 내용은 18장의 “다양한 타입의 값을 허용하는 트레잇 객체 사용하기” 섹션을 참고하자.

다음으로, 매크로에 대해 알아보자!

매크로

이 책에서 println!과 같은 매크로를 사용해왔지만, 매크로가 무엇이고 어떻게 동작하는지 완전히 설명하지는 않았다. _매크로_라는 용어는 러스트의 여러 기능을 가리킨다. macro_rules!를 사용한 선언적 매크로와 세 가지 종류의 프로시저 매크로가 있다:

  • 구조체와 열거형에 derive 속성을 사용할 때 추가되는 코드를 지정하는 커스텀 #[derive] 매크로
  • 모든 아이템에 사용 가능한 커스텀 속성을 정의하는 속성 형태의 매크로
  • 함수 호출처럼 보이지만 인자로 지정된 토큰을 처리하는 함수 형태의 매크로

이러한 각 매크로에 대해 차례로 설명할 것이다. 하지만 먼저, 함수가 이미 있는데 왜 매크로가 필요한지 살펴보자.

매크로와 함수의 차이점

기본적으로 매크로는 코드를 생성하는 코드를 작성하는 방식이다. 이를 _메타프로그래밍_이라고 한다. 부록 C에서는 derive 속성을 다루는데, 이는 다양한 트레이트의 구현을 자동으로 생성한다. 또한 이 책 전반에서 println!vec! 매크로를 사용했다. 이 모든 매크로는 직접 작성한 코드보다 더 많은 코드를 생성하도록 _확장_된다.

메타프로그래밍은 작성하고 유지해야 하는 코드의 양을 줄이는 데 유용하며, 이는 함수의 역할 중 하나이기도 하다. 그러나 매크로는 함수가 할 수 없는 몇 가지 추가적인 기능을 제공한다.

함수 시그니처는 함수가 갖는 매개변수의 수와 타입을 명시해야 한다. 반면 매크로는 가변적인 수의 매개변수를 받을 수 있다. 예를 들어 println!("hello")처럼 하나의 인자로 호출할 수도 있고, println!("hello {}", name)처럼 두 개의 인자로 호출할 수도 있다. 또한 매크로는 컴파일러가 코드의 의미를 해석하기 전에 확장되므로, 특정 타입에 대해 트레이트를 구현하는 등의 작업을 할 수 있다. 함수는 런타임에 호출되기 때문에 컴파일 타임에 구현해야 하는 트레이트를 구현할 수 없다.

함수 대신 매크로를 구현할 때의 단점은 매크로 정의가 함수 정의보다 복잡하다는 점이다. 왜냐하면 Rust 코드를 생성하는 Rust 코드를 작성해야 하기 때문이다. 이러한 간접적인 구조 때문에 매크로 정의는 일반적으로 함수 정의보다 읽기, 이해하기, 유지하기가 더 어렵다.

매크로와 함수의 또 다른 중요한 차이점은 매크로를 파일에서 호출하기 전에 정의하거나 스코프로 가져와야 한다는 점이다. 반면 함수는 어디에서든 정의하고 호출할 수 있다.

macro_rules!를 사용한 선언적 매크로와 일반 메타프로그래밍

러스트에서 가장 널리 사용되는 매크로 형태는 _선언적 매크로_다. 이는 때때로 “예제 매크로”, “macro_rules! 매크로”, 또는 단순히 “매크로“라고도 불린다. 선언적 매크로의 핵심은 러스트의 match 표현식과 유사한 것을 작성할 수 있게 해준다는 점이다. 6장에서 논의했듯이, match 표현식은 특정 표현식을 평가한 결과를 패턴과 비교한 후, 매칭되는 패턴과 관련된 코드를 실행하는 제어 구조다. 매크로도 마찬가지로 값을 패턴과 비교하는데, 여기서 값은 매크로에 전달된 러스트 소스 코드의 리터럴이다. 패턴은 해당 소스 코드의 구조와 비교되며, 매칭된 패턴과 관련된 코드는 매크로에 전달된 코드를 대체한다. 이 모든 과정은 컴파일 중에 일어난다.

매크로를 정의하려면 macro_rules! 구문을 사용한다. vec! 매크로가 어떻게 정의되어 있는지 살펴보면서 macro_rules!의 사용법을 알아보자. 8장에서는 vec! 매크로를 사용해 특정 값을 가진 새로운 벡터를 생성하는 방법을 다뤘다. 예를 들어, 다음 매크로는 세 개의 정수를 포함하는 새로운 벡터를 생성한다:

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

또한 vec! 매크로를 사용해 두 개의 정수로 이루어진 벡터나 다섯 개의 문자열 슬라이스로 이루어진 벡터를 만들 수도 있다. 함수를 사용해 동일한 작업을 수행할 수는 없는 이유는, 함수에서는 사전에 값의 개수나 타입을 알 수 없기 때문이다.

리스트 20-35는 vec! 매크로의 정의를 약간 단순화한 버전을 보여준다.

Filename: src/lib.rs
#[macro_export]
macro_rules! vec {
    ( $( $x:expr ),* ) => {
        {
            let mut temp_vec = Vec::new();
            $(
                temp_vec.push($x);
            )*
            temp_vec
        }
    };
}
Listing 20-35: vec! 매크로 정의의 단순화된 버전

참고: 표준 라이브러리에서 vec! 매크로의 실제 정의에는 사전에 정확한 양의 메모리를 할당하는 코드가 포함되어 있다. 이 코드는 최적화를 위한 것이며, 예제를 단순화하기 위해 여기서는 포함하지 않았다.

#[macro_export] 어노테이션은 이 매크로가 정의된 크레이트가 스코프에 포함될 때마다 매크로를 사용할 수 있도록 해준다. 이 어노테이션이 없으면 매크로를 스코프로 가져올 수 없다.

그런 다음 macro_rules!와 함께 매크로 정의를 시작하고, 매크로의 이름을 정의한다. 이때 이름 뒤에는 느낌표(!)를 붙이지 않는다. 여기서는 vec이라는 이름을 사용했으며, 이어서 중괄호를 사용해 매크로 정의의 본문을 표시한다.

vec! 본문의 구조는 match 표현식의 구조와 유사하다. 여기서는 ( $( $x:expr ),* )라는 패턴과 => 뒤에 이 패턴과 관련된 코드 블록이 있다. 패턴이 매칭되면, 관련된 코드 블록이 생성된다. 이 매크로에서는 패턴이 하나뿐이므로, 매칭되는 유일한 방법이 있다. 다른 패턴은 오류를 발생시킨다. 더 복잡한 매크로는 여러 개의 패턴을 가질 수 있다.

매크로 정의에서 유효한 패턴 문법은 19장에서 다룬 패턴 문법과 다르다. 매크로 패턴은 값이 아닌 러스트 코드의 구조와 매칭되기 때문이다. 리스트 20-29의 패턴 조각이 무엇을 의미하는지 살펴보자. 전체 매크로 패턴 문법은 러스트 레퍼런스를 참조하라.

먼저, 전체 패턴을 감싸기 위해 괄호를 사용한다. 달러 기호($)를 사용해 매크로 시스템에서 패턴과 매칭되는 러스트 코드를 포함할 변수를 선언한다. 달러 기호는 이 변수가 일반 러스트 변수가 아니라 매크로 변수임을 명확히 한다. 다음으로 괄호를 사용해 패턴과 매칭되는 값을 캡처하여 대체 코드에서 사용할 수 있도록 한다. $() 내부에는 $x:expr이 있는데, 이는 모든 러스트 표현식과 매칭되며 해당 표현식에 $x라는 이름을 부여한다.

$() 뒤에 오는 쉼표는 $() 내부의 코드와 매칭되는 코드 사이에 리터럴 쉼표 구분자가 있어야 함을 나타낸다. ** 앞에 오는 패턴이 0회 이상 반복될 수 있음을 지정한다.

이 매크로를 vec![1, 2, 3];으로 호출하면, $x 패턴은 1, 2, 3 세 개의 표현식과 각각 매칭된다.

이제 이 패턴과 관련된 코드 본문의 패턴을 살펴보자: temp_vec.push()$()* 내부에서 패턴이 매칭될 때마다 생성된다. $x는 매칭된 각 표현식으로 대체된다. 이 매크로를 vec![1, 2, 3];으로 호출하면, 이 매크로 호출을 대체하는 코드는 다음과 같이 생성된다:

{
    let mut temp_vec = Vec::new();
    temp_vec.push(1);
    temp_vec.push(2);
    temp_vec.push(3);
    temp_vec
}

이렇게 정의한 매크로는 임의의 타입의 인수를 임의의 개수만큼 받아, 지정된 요소를 포함하는 벡터를 생성하는 코드를 만들 수 있다.

매크로를 작성하는 방법에 대해 더 알아보려면 온라인 문서나 Daniel Keep이 시작하고 Lukas Wirth가 이어간 “The Little Book of Rust Macros”와 같은 리소스를 참조하라.

속성을 활용한 코드 생성 프로시저 매크로

두 번째 형태의 매크로는 프로시저 매크로로, 함수와 유사하게 동작한다. _프로시저 매크로_는 코드를 입력으로 받아 처리한 후 새로운 코드를 출력한다. 선언적 매크로와 달리 패턴 매칭을 통해 코드를 대체하는 방식이 아니라, 코드를 직접 조작한다. 프로시저 매크로는 크게 세 가지 종류로 나뉜다: 커스텀 derive, 속성 기반, 함수형 매크로. 이들은 모두 유사한 방식으로 동작한다.

프로시저 매크로를 생성할 때는 정의를 특별한 크레이트 타입의 독립된 크레이트에 위치시켜야 한다. 이는 복잡한 기술적 이유로, 향후 개선될 예정이다. 아래 예제는 프로시저 매크로를 정의하는 방법을 보여준다. 여기서 some_attribute는 특정 매크로 종류를 사용하기 위한 자리 표시자다.

Filename: src/lib.rs
use proc_macro;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Listing 20-36: 프로시저 매크로 정의 예제

프로시저 매크로를 정의하는 함수는 TokenStream을 입력으로 받고 TokenStream을 출력으로 반환한다. TokenStream 타입은 Rust에 포함된 proc_macro 크레이트에서 정의되며, 토큰의 시퀀스를 나타낸다. 이 매크로의 핵심은 입력 TokenStream으로 전달된 소스 코드를 처리하고, 출력 TokenStream으로 새로운 코드를 생성하는 것이다. 또한 함수에는 생성할 프로시저 매크로의 종류를 지정하는 속성이 부착된다. 동일한 크레이트 내에서 여러 종류의 프로시저 매크로를 정의할 수 있다.

이제 각각의 프로시저 매크로 종류를 살펴보자. 먼저 커스텀 derive 매크로를 설명한 후, 다른 형태의 매크로와의 차이점을 알아볼 것이다.

커스텀 derive 매크로 작성 방법

hello_macro라는 크레이트를 만들어 보자. 이 크레이트는 HelloMacro라는 트레이트와 hello_macro라는 하나의 연관 함수를 정의한다. 사용자가 각 타입에 대해 HelloMacro 트레이트를 직접 구현하도록 하는 대신, 프로시저 매크로를 제공하여 사용자가 타입에 #[derive(HelloMacro)]를 주석으로 추가하면 hello_macro 함수의 기본 구현을 얻을 수 있도록 한다. 기본 구현은 Hello, Macro! My name is TypeName!을 출력한다. 여기서 TypeName은 이 트레이트가 정의된 타입의 이름이다. 즉, 다른 프로그래머가 우리 크레이트를 사용해 리스트 20-37과 같은 코드를 작성할 수 있도록 하는 크레이트를 만드는 것이다.

<리스트 번호=“20-37” 파일명=“src/main.rs” 설명=“우리 크레이트를 사용하는 프로그래머가 작성할 수 있는 코드”>

use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}

</리스트>

이 코드는 완성되면 Hello, Macro! My name is Pancakes!를 출력한다. 첫 번째 단계는 다음과 같이 새로운 라이브러리 크레이트를 만드는 것이다:

$ cargo new hello_macro --lib

다음으로 HelloMacro 트레이트와 그 연관 함수를 정의한다:

<리스트 파일명=“src/lib.rs” 번호=“20-38” 설명=“derive 매크로와 함께 사용할 간단한 트레이트”>

pub trait HelloMacro {
    fn hello_macro();
}

</리스트>

이제 트레이트와 함수가 있다. 이 시점에서 크레이트 사용자는 리스트 20-39와 같이 트레이트를 구현해 원하는 기능을 달성할 수 있다.

<리스트 번호=“20-39” 파일명=“src/main.rs” 설명=“사용자가 HelloMacro 트레이트를 수동으로 구현한 모습”>

use hello_macro::HelloMacro;

struct Pancakes;

impl HelloMacro for Pancakes {
    fn hello_macro() {
        println!("Hello, Macro! My name is Pancakes!");
    }
}

fn main() {
    Pancakes::hello_macro();
}

</리스트>

하지만 사용자는 hello_macro와 함께 사용하려는 각 타입에 대해 구현 블록을 작성해야 한다. 우리는 이 작업을 없애고 싶다.

또한 러스트는 리플렉션 기능이 없기 때문에 런타임에 타입의 이름을 조회할 수 없다. 따라서 트레이트가 구현된 타입의 이름을 출력하는 hello_macro 함수의 기본 구현을 제공할 수 없다. 컴파일 타임에 코드를 생성하는 매크로가 필요하다.

다음 단계는 프로시저 매크로를 정의하는 것이다. 이 글을 쓰는 시점에서 프로시저 매크로는 자체 크레이트에 있어야 한다. 이 제한은 나중에 해제될 수도 있다. 크레이트와 매크로 크레이트를 구조화하는 규칙은 다음과 같다: foo라는 크레이트의 경우, 커스텀 derive 프로시저 매크로 크레이트는 foo_derive라고 한다. hello_macro 프로젝트 내부에 hello_macro_derive라는 새 크레이트를 시작해 보자:

$ cargo new hello_macro_derive --lib

두 크레이트는 밀접하게 연관되어 있으므로 프로시저 매크로 크레이트를 hello_macro 크레이트의 디렉토리 내부에 만든다. hello_macro에서 트레이트 정의를 변경하면 hello_macro_derive에서 프로시저 매크로의 구현도 변경해야 한다. 두 크레이트는 별도로 게시해야 하며, 이 크레이트를 사용하는 프로그래머는 둘 모두를 의존성으로 추가하고 스코프로 가져와야 한다. 대신 hello_macro 크레이트가 hello_macro_derive를 의존성으로 사용하고 프로시저 매크로 코드를 다시 내보낼 수도 있다. 그러나 우리가 프로젝트를 구조화한 방식은 프로그래머가 derive 기능을 원하지 않더라도 hello_macro를 사용할 수 있게 한다.

hello_macro_derive 크레이트를 프로시저 매크로 크레이트로 선언해야 한다. 또한 synquote 크레이트의 기능이 필요하므로 이를 의존성으로 추가해야 한다. hello_macro_deriveCargo.toml 파일에 다음을 추가한다:

<리스트 파일명=“hello_macro_derive/Cargo.toml”>

[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

</리스트>

프로시저 매크로를 정의하기 위해 리스트 20-40의 코드를 hello_macro_derive 크레이트의 src/lib.rs 파일에 넣는다. impl_hello_macro 함수에 대한 정의를 추가할 때까지 이 코드는 컴파일되지 않는다는 점에 유의하라.

<리스트 번호=“20-40” 파일명=“hello_macro_derive/src/lib.rs” 설명=“러스트 코드를 처리하기 위해 대부분의 프로시저 매크로 크레이트에 필요한 코드”>

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate.
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation.
    impl_hello_macro(&ast)
}

</리스트>

코드를 TokenStream을 파싱하는 역할을 하는 hello_macro_derive 함수와 구문 트리를 변환하는 역할을 하는 impl_hello_macro 함수로 나눈 것을 확인할 수 있다. 이렇게 하면 프로시저 매크로를 작성하는 것이 더 편리해진다. 외부 함수(hello_macro_derive)의 코드는 거의 모든 프로시저 매크로 크레이트에서 동일하다. 내부 함수(impl_hello_macro)의 본문에 지정하는 코드는 프로시저 매크로의 목적에 따라 달라진다.

세 가지 새로운 크레이트를 소개했다: proc_macro, syn, 그리고 quote. proc_macro 크레이트는 러스트와 함께 제공되므로 _Cargo.toml_에 의존성으로 추가할 필요가 없다. proc_macro 크레이트는 코드에서 러스트 코드를 읽고 조작할 수 있도록 하는 컴파일러의 API이다.

syn 크레이트는 문자열에서 러스트 코드를 파싱해 조작할 수 있는 데이터 구조로 변환한다. quote 크레이트는 syn 데이터 구조를 다시 러스트 코드로 변환한다. 이 크레이트들은 처리하려는 모든 종류의 러스트 코드를 파싱하는 것을 훨씬 간단하게 만든다. 러스트 코드에 대한 완전한 파서를 작성하는 것은 간단한 작업이 아니다.

hello_macro_derive 함수는 라이브러리 사용자가 타입에 #[derive(HelloMacro)]를 지정할 때 호출된다. 이는 hello_macro_derive 함수에 proc_macro_derive를 주석으로 추가하고 트레이트 이름과 일치하는 HelloMacro를 지정했기 때문에 가능하다. 이는 대부분의 프로시저 매크로가 따르는 규칙이다.

hello_macro_derive 함수는 먼저 inputTokenStream에서 해석하고 조작할 수 있는 데이터 구조로 변환한다. 여기서 syn이 작동한다. synparse 함수는 TokenStream을 받아 파싱된 러스트 코드를 나타내는 DeriveInput 구조체를 반환한다. 리스트 20-41은 struct Pancakes; 문자열을 파싱할 때 얻는 DeriveInput 구조체의 관련 부분을 보여준다.

<리스트 번호=“20-41” 설명=“리스트 20-37의 매크로 속성이 있는 코드를 파싱할 때 얻는 DeriveInput 인스턴스”>

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}

</리스트>

이 구조체의 필드는 파싱한 러스트 코드가 Pancakes라는 ident(식별자, 즉 이름)를 가진 유닛 구조체임을 보여준다. 이 구조체에는 모든 종류의 러스트 코드를 설명하기 위한 더 많은 필드가 있다. 자세한 정보는 syn 문서의 DeriveInput를 참조하라.

impl_hello_macro 함수를 정의할 것이다. 이 함수는 포함하려는 새로운 러스트 코드를 작성하는 곳이다. 하지만 그 전에 derive 매크로의 출력도 TokenStream이라는 점을 알아두자. 반환된 TokenStream은 크레이트 사용자가 작성한 코드에 추가되므로, 사용자가 크레이트를 컴파일할 때 우리가 수정한 TokenStream에서 제공하는 추가 기능을 얻게 된다.

syn::parse 함수 호출이 실패하면 hello_macro_derive 함수가 패닉을 일으키도록 unwrap을 호출하는 것을 눈치챘을 것이다. 프로시저 매크로는 오류 시 패닉을 일으켜야 한다. 왜냐하면 proc_macro_derive 함수는 프로시저 매크로 API에 맞게 Result가 아니라 TokenStream을 반환해야 하기 때문이다. 이 예제에서는 unwrap을 사용해 단순화했다. 프로덕션 코드에서는 panic!이나 expect를 사용해 무엇이 잘못되었는지에 대한 더 구체적인 오류 메시지를 제공해야 한다.

이제 주석이 달린 러스트 코드를 TokenStream에서 DeriveInput 인스턴스로 변환하는 코드가 있으므로, 리스트 20-42와 같이 주석이 달린 타입에 HelloMacro 트레이트를 구현하는 코드를 생성해 보자.

<리스트 번호=“20-42” 파일명=“hello_macro_derive/src/lib.rs” 설명=“파싱된 러스트 코드를 사용해 HelloMacro 트레이트 구현”>

use proc_macro::TokenStream;
use quote::quote;

#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
    // Construct a representation of Rust code as a syntax tree
    // that we can manipulate
    let ast = syn::parse(input).unwrap();

    // Build the trait implementation
    impl_hello_macro(&ast)
}

fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
    let name = &ast.ident;
    let generated = quote! {
        impl HelloMacro for #name {
            fn hello_macro() {
                println!("Hello, Macro! My name is {}!", stringify!(#name));
            }
        }
    };
    generated.into()
}

</리스트>

ast.ident를 사용해 주석이 달린 타입의 이름(식별자)을 포함하는 Ident 구조체 인스턴스를 얻는다. 리스트 20-33의 구조체는 리스트 20-31의 코드에서 impl_hello_macro 함수를 실행할 때 얻는 ident"Pancakes" 값을 가진 ident 필드를 가짐을 보여준다. 따라서 리스트 20-34의 name 변수는 Ident 구조체 인스턴스를 포함하며, 이는 출력될 때 리스트 20-37의 구조체 이름인 "Pancakes" 문자열이 된다.

quote! 매크로는 반환하려는 러스트 코드를 정의할 수 있게 해준다. 컴파일러는 quote! 매크로 실행의 직접적인 결과와는 다른 것을 기대하므로 이를 TokenStream으로 변환해야 한다. into 메서드를 호출해 이를 수행한다. 이 메서드는 이 중간 표현을 소비하고 필요한 TokenStream 타입의 값을 반환한다.

quote! 매크로는 또한 매우 멋진 템플릿 메커니즘을 제공한다: #name을 입력하면 quote!는 이를 name 변수의 값으로 대체한다. 일반 매크로와 유사한 방식으로 반복 작업도 할 수 있다. 자세한 소개는 the quote crate’s docs를 참조하라.

우리의 프로시저 매크로는 사용자가 주석을 단 타입에 대해 HelloMacro 트레이트의 구현을 생성하도록 한다. 이는 #name을 사용해 얻을 수 있다. 트레이트 구현에는 hello_macro라는 하나의 함수가 있으며, 이 함수의 본문에는 제공하려는 기능이 있다: Hello, Macro! My name is을 출력한 다음 주석이 달린 타입의 이름을 출력한다.

여기서 사용한 stringify! 매크로는 러스트에 내장되어 있다. 이 매크로는 1 + 2와 같은 러스트 표현식을 컴파일 타임에 "1 + 2"와 같은 문자열 리터럴로 변환한다. 이는 표현식을 평가한 다음 결과를 String으로 변환하는 format!이나 println! 매크로와 다르다. #name 입력이 문자 그대로 출력할 표현식일 가능성이 있으므로 stringify!를 사용한다. stringify!를 사용하면 컴파일 타임에 #name을 문자열 리터럴로 변환해 할당을 절약할 수 있다.

이 시점에서 cargo buildhello_macrohello_macro_derive 모두에서 성공적으로 완료되어야 한다. 이 크레이트를 리스트 20-31의 코드에 연결해 프로시저 매크로가 작동하는 것을 확인해 보자! projects 디렉토리에서 cargo new pancakes를 사용해 새로운 바이너리 프로젝트를 만든다. pancakes 크레이트의 _Cargo.toml_에 hello_macrohello_macro_derive를 의존성으로 추가해야 한다. hello_macrohello_macro_derive의 버전을 crates.io에 게시한다면 일반 의존성이 될 것이다. 그렇지 않다면 다음과 같이 path 의존성으로 지정할 수 있다:

hello_macro = { path = "../hello_macro" }
hello_macro_derive = { path = "../hello_macro/hello_macro_derive" }

리스트 20-37의 코드를 _src/main.rs_에 넣고 cargo run을 실행하면 Hello, Macro! My name is Pancakes!가 출력되어야 한다. 프로시저 매크로의 HelloMacro 트레이트 구현은 pancakes 크레이트가 이를 구현하지 않아도 포함되었다. #[derive(HelloMacro)]가 트레이트 구현을 추가한 것이다.

다음으로, 다른 종류의 프로시저 매크로가 커스텀 derive 매크로와 어떻게 다른지 살펴보자.

속성(Attribute)과 유사한 매크로

속성과 유사한 매크로는 커스텀 derive 매크로와 비슷하지만, derive 속성을 위한 코드를 생성하는 대신 새로운 속성을 만들 수 있다. 또한 더 유연하다. derive는 구조체와 열거형에만 적용할 수 있지만, 속성은 함수와 같은 다른 항목에도 적용할 수 있다. 웹 애플리케이션 프레임워크에서 함수를 주석 처리하는 route라는 속성을 사용하는 예제를 살펴보자:

#[route(GET, "/")]
fn index() {

#[route] 속성은 프레임워크에서 프로시저 매크로로 정의된다. 매크로 정의 함수의 시그니처는 다음과 같다:

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

여기서 두 개의 TokenStream 타입 매개변수가 있다. 첫 번째 매개변수는 속성의 내용, 즉 GET, "/" 부분을 담는다. 두 번째 매개변수는 속성이 적용된 항목의 본문, 이 경우 fn index() {}와 함수의 나머지 부분을 담는다.

이 외에도, 속성과 유사한 매크로는 커스텀 derive 매크로와 동일한 방식으로 작동한다. proc-macro 크레이트 타입으로 크레이트를 만들고, 원하는 코드를 생성하는 함수를 구현하면 된다!

함수형 매크로

함수형 매크로는 함수 호출처럼 보이는 매크로를 정의한다. macro_rules! 매크로와 마찬가지로, 이 매크로는 함수보다 더 유연하다. 예를 들어, 함수형 매크로는 알려지지 않은 수의 인자를 받을 수 있다. 하지만 macro_rules! 매크로는 이전에 macro_rules!를 사용한 선언적 매크로와 일반 메타프로그래밍”에서 논의한 것처럼 매치(match)와 유사한 구문을 사용해서만 정의할 수 있다. 함수형 매크로는 TokenStream 파라미터를 받고, 다른 두 가지 타입의 프로시저 매크로와 마찬가지로 Rust 코드를 사용해 이 TokenStream을 조작한다. 함수형 매크로의 예로는 다음과 같이 호출될 수 있는 sql! 매크로가 있다:

let sql = sql!(SELECT * FROM posts WHERE id=1);

이 매크로는 내부에 있는 SQL 문을 파싱하고 문법적으로 올바른지 확인한다. 이는 macro_rules! 매크로가 할 수 있는 것보다 훨씬 더 복잡한 처리 과정이다. sql! 매크로는 다음과 같이 정의된다:

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

이 정의는 커스텀 derive 매크로의 시그니처와 유사하다. 괄호 안에 있는 토큰을 받고, 생성하려는 코드를 반환한다.

요약

드디어 여러분의 Rust 도구 상자에 자주 사용하지는 않지만 특정 상황에서 유용하게 활용할 수 있는 기능들이 추가되었다. 이번 장에서는 복잡한 주제들을 소개했는데, 이를 통해 앞으로 에러 메시지 제안이나 다른 사람의 코드에서 이 개념들과 문법을 마주쳤을 때 쉽게 이해할 수 있을 것이다. 이 장을 참고 자료로 활용해 문제를 해결하는 데 도움을 받길 바란다.

다음으로는 지금까지 책에서 다룬 모든 내용을 종합해 하나의 프로젝트를 진행해볼 예정이다!

최종 프로젝트: 멀티스레드 웹 서버 구축

긴 여정이었지만, 이제 책의 마지막에 도달했다. 이번 장에서는 앞서 다룬 개념들을 다시 정리하고, 마지막 몇 장에서 배운 내용을 활용해 하나의 프로젝트를 함께 만들어 볼 것이다.

최종 프로젝트로, 웹 브라우저에서 “hello“라고 말하는 웹 서버를 만들 것이다. 이 서버는 그림 21-1과 같은 모습을 보여줄 것이다.

hello from rust

그림 21-1: 우리의 최종 공유 프로젝트

웹 서버를 구축하기 위한 계획은 다음과 같다:

  1. TCP와 HTTP에 대해 간단히 알아본다.
  2. 소켓에서 TCP 연결을 기다린다.
  3. 소수의 HTTP 요청을 파싱한다.
  4. 적절한 HTTP 응답을 생성한다.
  5. 스레드 풀을 사용해 서버의 처리량을 개선한다.

시작하기 전에 두 가지 사항을 언급하고자 한다. 첫째, 우리가 사용할 방법은 Rust로 웹 서버를 구축하는 최선의 방법은 아니다. 커뮨리티에서 이미 crates.io에 프로덕션 준비가 된 여러 크레이트를 공개했으며, 이들은 우리가 구축할 것보다 더 완전한 웹 서버와 스레드 풀 구현을 제공한다. 그러나 이번 장의 목적은 배우는 것이지, 쉬운 길을 택하는 것이 아니다. Rust는 시스템 프로그래밍 언어이기 때문에, 우리는 원하는 추상화 수준을 선택할 수 있으며, 다른 언어에서는 불가능하거나 실용적이지 않은 낮은 수준으로 내려갈 수 있다.

둘째, 여기서는 async와 await를 사용하지 않을 것이다. 스레드 풀을 구축하는 것만으로도 충분히 큰 도전이기 때문에, async 런타임까지 추가로 구축하지는 않을 것이다! 그러나 이번 장에서 마주칠 몇 가지 문제에 async와 await가 어떻게 적용될 수 있는지 언급할 것이다. 궁극적으로, 17장에서 언급했듯이, 많은 async 런타임이 스레드 풀을 사용해 작업을 관리한다.

따라서 우리는 기본적인 HTTP 서버와 스레드 풀을 직접 작성할 것이다. 이를 통해 여러분이 앞으로 사용할 크레이트들 뒤에 숨은 일반적인 아이디어와 기술을 배울 수 있도록 하기 위함이다.

싱글스레드 웹 서버 구축

먼저 싱글스레드 웹 서버를 구동하는 방법부터 알아보자. 시작하기 전에, 웹 서버 구축에 사용되는 주요 프로토콜을 간략히 살펴보자. 이 프로토콜들의 세부 사항은 이 책의 범위를 벗어나지만, 간단한 개요를 통해 필요한 기본 지식을 얻을 수 있다.

웹 서버와 관련된 두 가지 주요 프로토콜은 **HTTP(Hypertext Transfer Protocol)**와 **TCP(Transmission Control Protocol)**다. 두 프로토콜 모두 요청-응답 방식으로 동작한다. 즉, 클라이언트가 요청을 시작하면, 서버는 해당 요청을 듣고 클라이언트에게 응답을 제공한다. 요청과 응답의 내용은 프로토콜에 의해 정의된다.

TCP는 정보가 한 서버에서 다른 서버로 어떻게 전달되는지에 대한 세부 사항을 설명하는 저수준 프로토콜이지만, 정보의 내용은 정의하지 않는다. HTTP는 TCP 위에 구축되어 요청과 응답의 내용을 정의한다. 기술적으로 HTTP를 다른 프로토콜과 함께 사용할 수 있지만, 대부분의 경우 HTTP는 TCP를 통해 데이터를 전송한다. 우리는 TCP와 HTTP 요청 및 응답의 원시 바이트를 직접 다룰 것이다.

TCP 연결 수신하기

웹 서버는 TCP 연결을 수신해야 한다. 이 부분부터 시작해보자. 표준 라이브러리는 std::net 모듈을 제공하며, 이를 통해 TCP 연결을 처리할 수 있다. 일반적인 방식으로 새로운 프로젝트를 생성해보자:

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

이제 src/main.rs 파일에 목록 21-1의 코드를 입력한다. 이 코드는 로컬 주소 127.0.0.1:7878에서 들어오는 TCP 스트림을 수신한다. 스트림이 들어오면 Connection established! 메시지를 출력한다.

Filename: src/main.rs
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        println!("Connection established!");
    }
}
Listing 21-1: 들어오는 스트림을 수신하고 스트림을 받으면 메시지를 출력

TcpListener를 사용하면 127.0.0.1:7878 주소에서 TCP 연결을 수신할 수 있다. 이 주소에서 콜론 앞 부분은 컴퓨터의 IP 주소를 나타내며, 7878은 포트 번호다. 이 포트를 선택한 이유는 두 가지다: HTTP는 일반적으로 이 포트에서 허용되지 않기 때문에 다른 웹 서버와 충돌할 가능성이 적고, 7878은 전화기에서 _rust_를 입력한 결과다.

이 시나리오에서 bind 함수는 new 함수와 유사하게 새로운 TcpListener 인스턴스를 반환한다. 네트워크에서 포트에 연결해 수신하는 것을 “포트에 바인딩한다“고 하기 때문에 이 함수는 bind라고 불린다.

bind 함수는 Result<T, E>를 반환하며, 이는 바인딩이 실패할 수 있음을 나타낸다. 예를 들어, 포트 80에 연결하려면 관리자 권한이 필요하다(비관리자는 1023보다 높은 포트만 수신할 수 있음). 따라서 관리자 권한 없이 포트 80에 연결하려고 하면 바인딩이 실패한다. 또한 프로그램을 두 번 실행해 같은 포트를 수신하려고 해도 바인딩이 실패한다. 학습 목적으로 기본적인 서버를 작성 중이므로 이러한 오류를 처리하지 않고, 대신 unwrap을 사용해 오류가 발생하면 프로그램을 중단한다.

TcpListenerincoming 메서드는 스트림 시퀀스(더 정확히는 TcpStream 타입의 스트림)를 제공하는 이터레이터를 반환한다. 단일 _스트림_은 클라이언트와 서버 간의 열린 연결을 나타낸다. _연결_은 클라이언트가 서버에 연결하고, 서버가 응답을 생성한 후 연결을 닫는 전체 요청 및 응답 프로세스를 의미한다. 따라서 TcpStream에서 클라이언트가 보낸 데이터를 읽고, 스트림에 응답을 작성해 클라이언트에게 데이터를 보낸다. 이 for 루프는 각 연결을 순차적으로 처리하고 처리할 스트림 시퀀스를 생성한다.

현재는 스트림 처리 시 unwrap을 호출해 스트림에 오류가 있으면 프로그램을 종료한다. 오류가 없으면 프로그램이 메시지를 출력한다. 다음 목록에서 성공 사례에 대한 기능을 추가할 것이다. 클라이언트가 서버에 연결할 때 incoming 메서드에서 오류를 받을 수 있는 이유는 연결을 반복하는 것이 아니라 _연결 시도_를 반복하기 때문이다. 여러 가지 이유로 연결이 성공하지 못할 수 있으며, 그 중 많은 이유가 운영체제에 따라 다르다. 예를 들어, 많은 운영체제는 동시에 열 수 있는 연결 수에 제한이 있다. 그 수를 초과하는 새로운 연결 시도는 일부 열린 연결이 닫힐 때까지 오류를 발생시킨다.

이 코드를 실행해보자! 터미널에서 cargo run을 실행한 후 웹 브라우저에서 _127.0.0.1:7878_을 로드한다. 서버가 현재 데이터를 보내고 있지 않기 때문에 브라우저는 “Connection reset“과 같은 오류 메시지를 표시한다. 하지만 터미널을 보면 브라우저가 서버에 연결할 때 출력된 여러 메시지를 볼 수 있다!

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

때로는 하나의 브라우저 요청에 대해 여러 메시지가 출력될 수 있다. 그 이유는 브라우저가 페이지 요청뿐만 아니라 브라우저 탭에 나타나는 favicon.ico 아이콘과 같은 다른 리소스에 대한 요청도 보내기 때문일 수 있다.

또한 서버가 데이터를 응답하지 않기 때문에 브라우저가 여러 번 서버에 연결을 시도할 수도 있다. 루프 끝에서 stream이 범위를 벗어나면 drop 구현의 일부로 연결이 닫힌다. 브라우저는 때때로 임시적인 문제일 수 있기 때문에 닫힌 연결을 재시도한다.

브라우저는 때때로 요청을 보내지 않고도 서버에 여러 연결을 열기도 한다. 나중에 요청을 보낼 때 더 빠르게 처리할 수 있도록 하기 위해서다. 이 경우 서버는 각 연결을 확인하지만, 해당 연결을 통해 요청이 있는지는 상관없다. 예를 들어, Chrome 기반 브라우저의 많은 버전이 이를 수행한다. 이 최적화를 비활성화하려면 시크릿 모드를 사용하거나 다른 브라우저를 사용하면 된다.

중요한 점은 TCP 연결에 대한 핸들을 성공적으로 얻었다는 것이다!

특정 버전의 코드 실행을 마쳤으면 ctrl-c를 눌러 프로그램을 중지한다. 코드를 변경한 후에는 cargo run 명령을 다시 실행해 최신 코드가 실행되도록 한다.

요청 읽기

브라우저로부터 요청을 읽는 기능을 구현해 보자! 연결을 먼저 얻고, 그 연결을 통해 어떤 작업을 수행하는 두 가지 관심사를 분리하기 위해, 새로운 함수를 만들어 연결을 처리한다. 이 새로운 handle_connection 함수에서 TCP 스트림으로부터 데이터를 읽고, 브라우저가 보낸 데이터를 확인할 수 있도록 출력한다. 코드를 리스트 21-2와 같이 변경한다.

Filename: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}
Listing 21-2: TcpStream에서 데이터를 읽고 출력하기

std::io::preludestd::io::BufReader를 스코프로 가져와 스트림에서 데이터를 읽고 쓰는 데 필요한 트레잇과 타입에 접근한다. main 함수의 for 루프에서, 연결이 성공했다는 메시지를 출력하는 대신, 새로운 handle_connection 함수를 호출하고 stream을 인자로 전달한다.

handle_connection 함수에서는 stream에 대한 참조를 감싸는 새로운 BufReader 인스턴스를 생성한다. BufReaderstd::io::Read 트레잇 메서드 호출을 관리하며 버퍼링을 추가한다.

http_request라는 변수를 만들어 브라우저가 서버로 보낸 요청의 라인들을 수집한다. Vec<_> 타입 어노테이션을 추가하여 이 라인들을 벡터로 수집할 것임을 명시한다.

BufReaderstd::io::BufRead 트레잇을 구현하며, 이 트레잇은 lines 메서드를 제공한다. lines 메서드는 데이터 스트림에서 개행 바이트를 만날 때마다 분할하여 Result<String, std::io::Error>의 이터레이터를 반환한다. 각 String을 얻기 위해, 각 Resultmap하고 unwrap한다. 데이터가 유효한 UTF-8이 아니거나 스트림에서 읽는 데 문제가 발생하면 Result는 에러가 될 수 있다. 프로덕션 프로그램에서는 이러한 에러를 더 우아하게 처리해야 하지만, 간단함을 위해 에러 발생 시 프로그램을 중단하도록 선택한다.

브라우저는 두 개의 연속된 개행 문자를 보내어 HTTP 요청의 끝을 알린다. 따라서 스트림에서 하나의 요청을 얻기 위해, 빈 문자열 라인을 만날 때까지 라인을 수집한다. 라인들을 벡터로 수집한 후, 웹 브라우저가 서버로 보낸 명령어를 확인할 수 있도록 디버그 포맷으로 출력한다.

이 코드를 실행해 보자! 프로그램을 시작하고 웹 브라우저에서 다시 요청을 보낸다. 브라우저에서는 여전히 에러 페이지가 표시되지만, 터미널에서 프로그램의 출력은 다음과 비슷하게 나타난다:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished dev [unoptimized + debuginfo] target(s) in 0.42s
     Running `target/debug/hello`
Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
    "Accept-Language: en-US,en;q=0.5",
    "Accept-Encoding: gzip, deflate, br",
    "DNT: 1",
    "Connection: keep-alive",
    "Upgrade-Insecure-Requests: 1",
    "Sec-Fetch-Dest: document",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-User: ?1",
    "Cache-Control: max-age=0",
]

브라우저에 따라 약간 다른 출력이 나타날 수 있다. 이제 요청 데이터를 출력하고 있으므로, 요청의 첫 번째 줄에서 GET 뒤의 경로를 확인하여 하나의 브라우저 요청에서 여러 연결이 발생하는 이유를 알 수 있다. 반복되는 연결이 모두 _/_를 요청한다면, 프로그램이 응답을 보내지 않아 브라우저가 _/_를 반복적으로 가져오려고 한다는 것을 알 수 있다.

이 요청 데이터를 분석하여 브라우저가 프로그램에 무엇을 요청하는지 이해해 보자.

HTTP 요청 자세히 살펴보기

HTTP는 텍스트 기반 프로토콜이며, 요청은 다음과 같은 형식을 따른다:

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

첫 번째 줄은 클라이언트가 요청하는 내용을 담고 있는 _요청 라인_이다. 요청 라인의 첫 부분은 사용되는 _메서드_를 나타내며, GET이나 POST와 같은 값이 들어간다. 이는 클라이언트가 어떻게 요청을 보내는지 설명한다. 여기서는 GET 요청을 사용했는데, 이는 클라이언트가 정보를 요청하고 있다는 의미이다.

요청 라인의 다음 부분은 _/_로, 클라이언트가 요청하는 _통합 자원 식별자(URI)_를 나타낸다. URI는 _통합 자원 위치(URL)_와 거의 동일하지만 완전히 같지는 않다. URI와 URL의 차이는 이 장에서 중요하지 않지만, HTTP 스펙에서는 URI라는 용어를 사용하므로 여기서는 _URI_를 _URL_로 이해하면 된다.

마지막 부분은 클라이언트가 사용하는 HTTP 버전이며, 요청 라인은 CRLF 시퀀스로 끝난다. (CRLF는 _캐리지 리턴_과 _라인 피드_를 의미하며, 타자기 시대의 용어이다!) CRLF 시퀀스는 \r\n으로도 표현할 수 있는데, 여기서 \r은 캐리지 리턴이고 \n은 라인 피드이다. _CRLF 시퀀스_는 요청 라인과 나머지 요청 데이터를 구분한다. CRLF가 출력될 때는 \r\n 대신 새로운 줄이 시작되는 것을 볼 수 있다.

지금까지 실행한 프로그램에서 받은 요청 라인 데이터를 보면, GET이 메서드이고 _/_가 요청 URI, HTTP/1.1이 버전임을 확인할 수 있다.

요청 라인 이후에는 Host:로 시작하는 헤더들이 나온다. GET 요청에는 본문(body)이 없다.

다른 브라우저에서 요청을 보내거나 _127.0.0.1:7878/test_와 같은 다른 주소를 요청해 보면 요청 데이터가 어떻게 바뀌는지 확인할 수 있다.

이제 브라우저가 무엇을 요청하는지 알았으니, 데이터를 보내보자!

응답 작성하기

클라이언트 요청에 대한 응답으로 데이터를 보내는 기능을 구현해 보자. HTTP 응답은 다음과 같은 형식을 따른다:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

첫 번째 줄은 _상태 줄_로, 응답에 사용된 HTTP 버전, 요청 결과를 요약한 숫자 상태 코드, 그리고 상태 코드에 대한 설명인 텍스트 문구로 구성된다. CRLF 시퀀스 이후에는 헤더, 또 다른 CRLF 시퀀스, 그리고 응답 본문이 이어진다.

다음은 HTTP 버전 1.1을 사용하고, 상태 코드 200, OK 문구, 헤더와 본문이 없는 간단한 응답 예제다:

HTTP/1.1 200 OK\r\n\r\n

상태 코드 200은 표준 성공 응답이다. 이 텍스트는 매우 간단한 성공적인 HTTP 응답이다. 이제 이 응답을 스트림에 작성해 요청에 대한 응답으로 보내보자. handle_connection 함수에서 요청 데이터를 출력하던 println!을 지우고, 아래 코드로 대체한다.

Filename: src/main.rs
use std::{
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let response = "HTTP/1.1 200 OK\r\n\r\n";

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-3: 스트림에 간단한 성공 HTTP 응답 작성하기

첫 번째 줄은 성공 메시지 데이터를 담는 response 변수를 정의한다. 그런 다음 responseas_bytes를 호출해 문자열 데이터를 바이트로 변환한다. streamwrite_all 메서드는 &[u8] 타입을 받아 연결을 통해 해당 바이트를 직접 전송한다. write_all 작업이 실패할 수 있으므로, 이전과 마찬가지로 에러 결과에 unwrap을 사용한다. 실제 애플리케이션에서는 여기에 에러 처리를 추가해야 한다.

이제 코드를 실행하고 요청을 보내보자. 더 이상 터미널에 데이터를 출력하지 않으므로, Cargo의 출력 외에는 아무것도 보이지 않을 것이다. 웹 브라우저에서 _127.0.0.1:7878_을 로드하면, 에러 대신 빈 페이지가 표시될 것이다. 이제 HTTP 요청을 수신하고 응답을 보내는 기능을 직접 구현했다!

실제 HTML 반환하기

이제 빈 페이지 대신 실제 HTML을 반환하는 기능을 구현해보자. 프로젝트 루트 디렉토리(소스 디렉토리가 아님)에 hello.html 파일을 생성한다. 원하는 HTML을 입력할 수 있으며, 목록 21-4는 하나의 예시를 보여준다.

Filename: hello.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Hello!</h1>
    <p>Hi from Rust</p>
  </body>
</html>
Listing 21-4: 응답으로 반환할 샘플 HTML 파일

이 HTML은 제목과 텍스트가 포함된 간단한 HTML5 문서다. 요청을 받으면 이 HTML을 서버에서 반환하기 위해 handle_connection 함수를 수정한다. 목록 21-5는 HTML 파일을 읽고, 이를 응답 본문에 추가한 후 전송하는 과정을 보여준다.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<_> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    let status_line = "HTTP/1.1 200 OK";
    let contents = fs::read_to_string("hello.html").unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-5: hello.html의 내용을 응답 본문으로 전송하기

fsuse 문에 추가해 표준 라이브러리의 파일 시스템 모듈을 범위로 가져왔다. 파일 내용을 문자열로 읽는 코드는 익숙할 것이다. 목록 12-4에서 I/O 프로젝트를 위해 파일 내용을 읽을 때 사용했던 것과 동일하다.

다음으로, format!을 사용해 파일 내용을 성공 응답의 본문으로 추가한다. 유효한 HTTP 응답을 보장하기 위해 Content-Length 헤더를 추가한다. 이 헤더는 응답 본문의 크기로 설정되며, 여기서는 hello.html 파일의 크기가 된다.

이 코드를 cargo run으로 실행하고 브라우저에서 _127.0.0.1:7878_을 로드하면 HTML이 렌더링되는 것을 확인할 수 있다.

현재는 http_request의 요청 데이터를 무시하고 무조건 HTML 파일의 내용을 반환한다. 따라서 브라우저에서 _127.0.0.1:7878/something-else_를 요청해도 동일한 HTML 응답을 받게 된다. 지금은 서버가 매우 제한적이며 대부분의 웹 서버가 하는 일을 하지 못한다. 요청에 따라 응답을 다르게 하고, _/_에 대한 올바른 요청일 때만 HTML 파일을 반환하도록 수정할 것이다.

요청 검증과 선택적 응답

현재 웹 서버는 클라이언트가 어떤 요청을 하든 상관없이 파일에 있는 HTML을 반환한다. 이제 브라우저가 / 경로를 요청할 때만 HTML 파일을 반환하고, 그 외의 요청에는 오류를 반환하는 기능을 추가해 보자. 이를 위해 handle_connection 함수를 수정해야 한다. Listing 21-6에서 보듯이, 새로운 코드는 수신된 요청의 내용을 검사하고 / 요청과 일치하는지 확인한 후, ifelse 블록을 추가해 요청에 따라 다르게 처리한다.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    } else {
        // some other request
    }
}
Listing 21-6: / 요청과 다른 요청을 다르게 처리하기

우리는 HTTP 요청의 첫 번째 줄만 확인할 것이므로, 전체 요청을 벡터로 읽어들이는 대신 next를 호출해 이터레이터의 첫 번째 항목을 가져온다. 첫 번째 unwrapOption을 처리하고, 이터레이터에 항목이 없을 경우 프로그램을 중단한다. 두 번째 unwrapResult를 처리하며, Listing 21-2에서 추가한 map 내의 unwrap과 동일한 효과를 가진다.

다음으로, request_line/ 경로에 대한 GET 요청의 요청 라인과 동일한지 확인한다. 만약 일치한다면, if 블록이 HTML 파일의 내용을 반환한다.

만약 request_line/ 경로에 대한 GET 요청과 일치하지 않는다면, 다른 요청이 들어온 것이다. 잠시 후 else 블록에 코드를 추가해 모든 다른 요청에 응답할 것이다.

이제 이 코드를 실행하고 _127.0.0.1:7878_을 요청해 보자. _hello.html_의 HTML이 반환될 것이다. 만약 _127.0.0.1:7878/something-else_와 같은 다른 요청을 하면, Listing 21-1과 Listing 21-2에서 본 것과 같은 연결 오류가 발생할 것이다.

이제 Listing 21-7의 코드를 else 블록에 추가해, 요청한 콘텐츠를 찾을 수 없음을 나타내는 상태 코드 404와 함께 응답을 반환하자. 또한 브라우저에서 사용자에게 표시할 오류 페이지를 위한 HTML도 함께 반환할 것이다.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    if request_line == "GET / HTTP/1.1" {
        let status_line = "HTTP/1.1 200 OK";
        let contents = fs::read_to_string("hello.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    // --snip--
    } else {
        let status_line = "HTTP/1.1 404 NOT FOUND";
        let contents = fs::read_to_string("404.html").unwrap();
        let length = contents.len();

        let response = format!(
            "{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
        );

        stream.write_all(response.as_bytes()).unwrap();
    }
}
Listing 21-7: / 이외의 요청에 대해 상태 코드 404와 오류 페이지 반환

여기서 응답은 상태 코드 404와 이유 구문 NOT FOUND가 포함된 상태 라인을 가진다. 응답 본문은 404.html 파일의 HTML이 될 것이다. 오류 페이지를 위해 hello.html 파일 옆에 404.html 파일을 생성해야 한다. 이 파일에는 원하는 HTML을 사용하거나 Listing 21-8의 예제 HTML을 사용해도 된다.

Filename: 404.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello!</title>
  </head>
  <body>
    <h1>Oops!</h1>
    <p>Sorry, I don't know what you're asking for.</p>
  </body>
</html>
Listing 21-8: 404 응답과 함께 반환할 페이지의 예제 콘텐츠

이 변경 사항을 적용한 후 서버를 다시 실행해 보자. _127.0.0.1:7878_을 요청하면 _hello.html_의 내용이 반환되고, _127.0.0.1:7878/foo_와 같은 다른 요청을 하면 _404.html_의 오류 HTML이 반환될 것이다.

리팩토링 적용하기

현재 ifelse 블록에는 많은 중복 코드가 있다. 둘 다 파일을 읽고 파일의 내용을 스트림에 쓰는 동일한 작업을 수행한다. 유일한 차이는 상태 라인과 파일명뿐이다. 이 차이를 별도의 ifelse 라인으로 분리하여 상태 라인과 파일명을 변수에 할당하고, 이후에는 이 변수를 무조건적으로 사용해 파일을 읽고 응답을 작성하는 코드를 더 간결하게 만들어보자. 리스팅 21-9는 ifelse 블록을 리팩토링한 결과를 보여준다.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}
// --snip--

fn handle_connection(mut stream: TcpStream) {
    // --snip--
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-9: 두 경우에 따라 달라지는 코드만 포함하도록 ifelse 블록 리팩토링

이제 ifelse 블록은 상태 라인과 파일명을 튜플로 반환한다. 그리고 19장에서 다룬 패턴을 사용해 let 문에서 이 두 값을 status_linefilename 변수에 구조 분해 할당한다.

이전에 중복되었던 코드는 이제 ifelse 블록 밖에 위치하며, status_linefilename 변수를 사용한다. 이를 통해 두 경우의 차이를 더 쉽게 파악할 수 있고, 파일 읽기와 응답 쓰기 방식을 변경하고 싶을 때 한 곳만 수정하면 된다. 리스팅 21-9의 코드 동작은 리스팅 21-7과 동일하다.

훌륭하다! 이제 약 40줄의 Rust 코드로 간단한 웹 서버를 만들었다. 이 서버는 하나의 요청에 대해 콘텐츠 페이지를 반환하고, 다른 모든 요청에 대해 404 응답을 반환한다.

현재 우리의 서버는 단일 스레드에서 실행되기 때문에 한 번에 하나의 요청만 처리할 수 있다. 몇 가지 느린 요청을 시뮬레이션해 이 문제를 살펴보고, 이후에 서버가 여러 요청을 동시에 처리할 수 있도록 수정해보자.

단일 스레드 서버를 멀티스레드 서버로 전환하기

현재 서버는 요청을 순차적으로 처리한다. 즉, 첫 번째 요청이 완전히 처리될 때까지 두 번째 연결을 처리하지 않는다. 서버가 점점 더 많은 요청을 받게 되면, 이와 같은 직렬 처리 방식은 점점 더 비효율적이 된다. 서버가 처리하는 데 오랜 시간이 걸리는 요청을 받게 되면, 그 요청이 끝날 때까지 새로운 요청들은 기다려야 한다. 새로운 요청이 빠르게 처리될 수 있는 경우에도 말이다. 이 문제를 해결해야 하지만, 우선 이 문제가 어떻게 발생하는지 살펴보자.

현재 서버 구현에서 느린 요청 시뮬레이션

현재 서버 구현에서 느리게 처리되는 요청이 다른 요청에 어떤 영향을 미치는지 살펴본다. 리스트 21-10은 /sleep 요청을 처리하는 코드를 보여준다. 이 코드는 서버가 응답하기 전에 5초간 대기하도록 해서 느린 응답을 시뮬레이션한다.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};
// --snip--

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    // --snip--

    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // --snip--

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-10: 5초간 대기하여 느린 요청 시뮬레이션

이제 세 가지 경우를 처리하기 위해 if 대신 match를 사용한다. match는 자동으로 참조 및 역참조를 수행하지 않기 때문에, 문자열 리터럴 값과 비교하기 위해 request_line의 슬라이스를 명시적으로 매칭해야 한다. 이는 == 연산자와는 다르게 동작한다.

첫 번째 매칭은 리스트 21-9의 if 블록과 동일하다. 두 번째 매칭은 /sleep 요청을 처리한다. 이 요청을 받으면, 서버는 5초간 대기한 후 성공 HTML 페이지를 렌더링한다. 세 번째 매칭은 리스트 21-9의 else 블록과 동일하다.

우리 서버가 얼마나 기본적인지 확인할 수 있다. 실제 라이브러리는 여러 요청을 훨씬 간결하게 처리할 것이다!

cargo run으로 서버를 시작한다. 그런 다음 두 개의 브라우저 창을 연다. 하나는 http://127.0.0.1:7878/, 다른 하나는 _http://127.0.0.1:7878/sleep_로 접속한다. / URI를 여러 번 입력하면 이전처럼 빠르게 응답하는 것을 볼 수 있다. 하지만 _/sleep_을 입력한 후 _/_를 로드하면, _/_는 sleep이 5초 동안 대기한 후에야 로드된다.

느린 요청 뒤에 다른 요청이 밀리는 현상을 방지하기 위해 여러 기술을 사용할 수 있다. 17장에서 다룬 async를 사용하는 방법도 있고, 이번에는 스레드 풀을 구현해 볼 것이다.

스레드 풀을 통한 처리량 향상

_스레드 풀_은 태스크를 처리하기 위해 대기 중인 스레드 그룹이다. 프로그램이 새로운 태스크를 받으면, 풀에 있는 스레드 중 하나를 해당 태스크에 할당한다. 이 스레드는 태스크를 처리하며, 나머지 스레드는 첫 번째 스레드가 작업을 처리하는 동안 들어오는 다른 태스크를 처리할 수 있다. 첫 번째 스레드가 태스크를 완료하면, 다시 풀에 돌아가 새로운 태스크를 처리할 준비를 한다. 스레드 풀을 사용하면 연결을 동시에 처리할 수 있어 서버의 처리량을 높일 수 있다.

우리는 DoS 공격을 방지하기 위해 풀에 있는 스레드 수를 적은 수로 제한할 것이다. 만약 프로그램이 들어오는 요청마다 새로운 스레드를 생성한다면, 누군가가 서버에 1천만 개의 요청을 보내면 서버의 리소스를 모두 사용해 요청 처리를 중단시킬 수 있다.

따라서 무제한으로 스레드를 생성하는 대신, 풀에 고정된 수의 스레드를 대기시킨다. 들어오는 요청은 풀로 전달되어 처리된다. 풀은 들어오는 요청의 큐를 유지한다. 풀에 있는 각 스레드는 이 큐에서 요청을 꺼내 처리한 후, 큐에서 다음 요청을 요청한다. 이 설계를 통해 *N*개의 요청을 동시에 처리할 수 있다. 여기서 *N*은 스레드의 수이다. 각 스레드가 오래 실행되는 요청에 응답하고 있다면, 후속 요청은 큐에 백업될 수 있지만, 그 지점에 도달하기 전에 처리할 수 있는 오래 실행되는 요청의 수를 늘릴 수 있다.

이 기법은 웹 서버의 처리량을 향상시키는 여러 방법 중 하나일 뿐이다. 다른 옵션으로는 fork/join 모델, 단일 스레드 비동기 I/O 모델, 다중 스레드 비동기 I/O 모델 등을 탐구할 수 있다. 이 주제에 관심이 있다면, 다른 솔루션에 대해 더 읽어보고 구현해 볼 수 있다. Rust와 같은 저수준 언어를 사용하면 이러한 모든 옵션을 구현할 수 있다.

스레드 풀을 구현하기 전에, 풀을 사용하는 것이 어떻게 보여야 하는지 이야기해 보자. 코드를 설계할 때 클라이언트 인터페이스를 먼저 작성하면 설계를 안내하는 데 도움이 될 수 있다. 코드의 API를 원하는 방식으로 호출할 수 있도록 구조화한 후, 그 구조 내에서 기능을 구현하는 것이 좋다. 기능을 먼저 구현하고 공개 API를 설계하는 것보다 더 나은 접근 방식이다.

12장 프로젝트에서 테스트 주도 개발을 사용한 것과 유사하게, 여기서는 컴파일러 주도 개발을 사용할 것이다. 우리가 원하는 함수를 호출하는 코드를 작성한 후, 컴파일러의 오류를 보고 코드가 작동하도록 다음에 무엇을 변경해야 하는지 결정할 것이다. 그러나 그 전에, 시작점으로 사용하지 않을 기법에 대해 먼저 알아보자.

각 요청마다 스레드 생성하기

먼저, 모든 연결에 대해 새로운 스레드를 생성하는 코드가 어떻게 동작하는지 살펴보자. 앞서 언급했듯이, 이 방식은 무제한으로 스레드를 생성할 가능성이 있어 최종적인 해결책은 아니다. 하지만 동작하는 멀티스레드 서버를 만들기 위한 시작점으로 적합하다. 이후에 스레드 풀을 추가해 개선할 것이며, 두 해결책을 비교하는 것도 더 쉬워질 것이다. 아래 코드는 for 루프 내에서 각 스트림을 처리하기 위해 새로운 스레드를 생성하도록 main 함수를 수정한 예제이다.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        thread::spawn(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-11: 각 스트림에 대해 새로운 스레드 생성

16장에서 배웠듯이, thread::spawn은 새로운 스레드를 생성하고, 그 스레드에서 클로저 내부의 코드를 실행한다. 이 코드를 실행한 후 브라우저에서 _/sleep_을 로드하고, 두 개의 추가 탭에서 _/_를 로드하면, _/_에 대한 요청이 _/sleep_이 끝날 때까지 기다리지 않는다는 것을 확인할 수 있다. 하지만 앞서 언급했듯이, 이 방식은 스레드를 무제한으로 생성하기 때문에 결국 시스템에 부하를 줄 것이다.

17장에서 배운 내용을 떠올려보면, 이 상황은 바로 async와 await가 빛을 발휘하는 곳이다! 스레드 풀을 구축하면서 이 점을 염두에 두고, async를 사용했을 때 어떤 점이 달라지거나 동일할지 생각해보자.

제한된 수의 스레드 생성하기

우리는 스레드 풀이 익숙한 방식으로 동작하도록 만들어, 스레드에서 스레드 풀로 전환할 때 API를 사용하는 코드에 큰 변경이 필요 없도록 하고 싶다. 리스트 21-12는 thread::spawn 대신 사용할 ThreadPool 구조체의 가상 인터페이스를 보여준다.

Filename: src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-12: 우리가 원하는 ThreadPool 인터페이스

ThreadPool::new를 사용해 구성 가능한 수의 스레드를 가진 새로운 스레드 풀을 생성한다. 이 예제에서는 4개의 스레드를 사용한다. 그런 다음 for 루프에서 pool.executethread::spawn과 유사한 인터페이스를 가지며, 풀이 각 스트림에 대해 실행할 클로저를 받는다. pool.execute를 구현해야 하는데, 이는 클로저를 받아 풀 내의 스레드에게 실행하도록 전달하는 역할을 한다. 이 코드는 아직 컴파일되지 않지만, 컴파일러가 문제를 해결하는 방법을 안내할 수 있도록 시도해 볼 것이다.

컴파일러 주도 개발로 ThreadPool 구현하기

src/main.rs 파일에 목록 21-12의 변경 사항을 적용한 후, cargo check의 컴파일러 오류를 활용해 개발을 진행한다. 첫 번째로 발생한 오류는 다음과 같다:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0433]: failed to resolve: use of undeclared type `ThreadPool`
  --> src/main.rs:11:16
   |
11 |     let pool = ThreadPool::new(4);
   |                ^^^^^^^^^^ use of undeclared type `ThreadPool`

For more information about this error, try `rustc --explain E0433`.
error: could not compile `hello` (bin "hello") due to 1 previous error

이 오류는 ThreadPool 타입이나 모듈이 필요하다는 것을 알려준다. 따라서 이제 ThreadPool을 구현할 것이다. 이 ThreadPool 구현은 웹 서버의 작업 종류와는 독립적이다. 따라서 hello 크레이트를 바이너리 크레이트에서 라이브러리 크레이트로 전환해 ThreadPool 구현을 포함시킨다. 라이브러리 크레이트로 변경한 후에는 웹 요청 처리뿐만 아니라 스레드 풀을 사용해 수행할 다른 작업에도 별도의 스레드 풀 라이브러리를 사용할 수 있다.

src/lib.rs 파일을 생성하고 다음 코드를 추가한다. 이 코드는 현재로서는 가장 간단한 ThreadPool 구조체 정의이다:

Filename: src/lib.rs
pub struct ThreadPool;

그런 다음 src/main.rs 파일을 편집해 라이브러리 크레이트에서 ThreadPool을 스코프로 가져온다. 이를 위해 src/main.rs 파일 상단에 다음 코드를 추가한다:

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

이 코드는 아직 동작하지 않지만, 다음 오류를 확인하기 위해 다시 검사한다:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no function or associated item named `new` found for struct `ThreadPool` in the current scope
  --> src/main.rs:12:28
   |
12 |     let pool = ThreadPool::new(4);
   |                            ^^^ function or associated item not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

이 오류는 ThreadPoolnew라는 연관 함수가 필요하다는 것을 알려준다. 또한 new 함수는 4를 인자로 받을 수 있는 하나의 매개변수를 가져야 하며, ThreadPool 인스턴스를 반환해야 한다. 이러한 특성을 가진 가장 간단한 new 함수를 구현한다:

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }
}

size 매개변수의 타입으로 usize를 선택한 이유는 음수의 스레드 수는 의미가 없기 때문이다. 또한 이 4를 스레드 컬렉션의 요소 수로 사용할 것이며, 이는 usize 타입이 적합하다. 이에 대한 자세한 내용은 3장의 “정수 타입”에서 다룬다.

다시 코드를 검사한다:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0599]: no method named `execute` found for struct `ThreadPool` in the current scope
  --> src/main.rs:17:14
   |
17 |         pool.execute(|| {
   |         -----^^^^^^^ method not found in `ThreadPool`

For more information about this error, try `rustc --explain E0599`.
error: could not compile `hello` (bin "hello") due to 1 previous error

이제 오류는 ThreadPoolexecute 메서드가 없기 때문에 발생한다. “유한한 수의 스레드 생성”에서 스레드 풀의 인터페이스가 thread::spawn과 유사해야 한다고 결정한 것을 기억할 것이다. 추가적으로 execute 함수를 구현해 주어진 클로저를 풀의 유휴 스레드에 전달해 실행하도록 할 것이다.

ThreadPoolexecute 메서드를 정의해 클로저를 매개변수로 받도록 한다. 13장의 “클로저에서 캡처된 값을 이동시키기와 Fn 트레이트”에서 클로저를 매개변수로 받는 세 가지 트레이트(Fn, FnMut, FnOnce)를 사용할 수 있다는 것을 기억할 것이다. 여기서 어떤 종류의 클로저를 사용할지 결정해야 한다. 표준 라이브러리의 thread::spawn 구현과 유사한 작업을 수행할 것이므로, thread::spawn의 매개변수에 어떤 제약이 있는지 확인할 수 있다. 문서는 다음과 같이 보여준다:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

여기서 관심 있는 타입 매개변수는 F이다. T 타입 매개변수는 반환 값과 관련이 있으며, 여기서는 다루지 않는다. spawnFFnOnce 트레이트를 사용하는 것을 볼 수 있다. 이는 execute에서 받은 인자를 spawn에 전달할 것이므로, 아마도 우리가 원하는 것과 일치한다. 또한 요청을 실행할 스레드는 해당 요청의 클로저를 한 번만 실행할 것이므로, FnOnceOnce와 일치한다는 점에서 더 확신할 수 있다.

F 타입 매개변수는 Send 트레이트와 'static 라이프타임 제약을 가진다. 이는 우리의 상황에서 유용하다: 클로저를 한 스레드에서 다른 스레드로 전달하기 위해 Send가 필요하며, 스레드가 실행될 때까지 얼마나 걸릴지 모르기 때문에 'static이 필요하다. 이제 ThreadPoolexecute 메서드를 생성해 이러한 제약을 가진 F 타입의 제네릭 매개변수를 받도록 한다:

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    // --snip--
    pub fn new(size: usize) -> ThreadPool {
        ThreadPool
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

FnOnce 뒤에 ()를 사용하는 이유는 이 FnOnce가 매개변수를 받지 않고 유닛 타입 ()을 반환하는 클로저를 나타내기 때문이다. 함수 정의와 마찬가지로 반환 타입은 시그니처에서 생략할 수 있지만, 매개변수가 없더라도 여전히 괄호가 필요하다.

다시 한 번, 이 execute 메서드의 가장 간단한 구현이다: 아무것도 하지 않지만, 코드가 컴파일되도록 하는 데 목적이 있다. 다시 검사한다:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s

컴파일이 성공한다! 하지만 cargo run을 실행하고 브라우저에서 요청을 보내면, 장 초반에 본 오류가 브라우저에 표시된다. 우리의 라이브러리는 아직 execute에 전달된 클로저를 호출하지 않는다!

참고: Haskell이나 Rust와 같이 엄격한 컴파일러를 가진 언어에 대해 “코드가 컴파일되면 동작한다“는 말을 들을 수 있다. 하지만 이 말은 항상 사실이 아니다. 우리의 프로젝트는 컴파일되지만, 아무것도 하지 않는다! 실제 완전한 프로젝트를 구축한다면, 이 시점에서 단위 테스트를 작성해 코드가 컴파일되고 원하는 동작을 하는지 확인하는 것이 좋다.

고려해볼 점: 클로저 대신 _future_를 실행한다면 여기서 무엇이 달라질까?

new 함수에서 스레드 수 검증하기

현재 newexecute 함수의 인자를 활용하지 않고 있다. 이제 이 함수들의 동작을 구현해 보자. 먼저 new 함수부터 생각해 보자. 이전에 size 매개변수의 타입으로 부호 없는 정수(unsigned type)를 선택했는데, 음수 개수의 스레드를 가진 스레드 풀은 말이 되지 않기 때문이다. 하지만 스레드가 0개인 풀도 말이 되지 않는데, 0은 usize 타입에서 유효한 값이다. 따라서 size가 0보다 큰지 확인하는 코드를 추가하고, 0이 들어오면 프로그램이 패닉 상태에 빠지도록 assert! 매크로를 사용할 것이다. 이는 리스트 21-13에 나와 있다.

Filename: src/lib.rs
pub struct ThreadPool;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        ThreadPool
    }

    // --snip--
    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listing 21-13: size가 0일 경우 패닉을 발생시키는 ThreadPool::new 구현

또한 ThreadPool에 대한 문서 주석도 추가했다. 14장에서 논의한 것처럼, 함수가 패닉을 일으킬 수 있는 상황을 명시하는 섹션을 추가해 좋은 문서 작성 관례를 따랐다. cargo doc --open을 실행하고 ThreadPool 구조체를 클릭해 new 함수에 대해 생성된 문서가 어떻게 보이는지 확인해 보자.

여기서 assert! 매크로를 추가하는 대신, newbuild로 변경하고 I/O 프로젝트의 리스트 12-9에서 Config::build와 같이 Result를 반환하도록 할 수도 있다. 하지만 이 경우 스레드가 없는 스레드 풀을 생성하려는 시도는 복구할 수 없는 오류로 간주하기로 결정했다. 만약 도전하고 싶다면, 다음 시그니처를 가진 build 함수를 작성해 new 함수와 비교해 보자:

pub fn build(size: usize) -> Result<ThreadPool, PoolCreationError> {

스레드를 저장할 공간 만들기

이제 스레드 풀에 저장할 유효한 스레드 수를 확인할 수 있으므로, 해당 스레드를 생성하고 ThreadPool 구조체에 저장한 후 구조체를 반환할 수 있다. 그런데 스레드를 어떻게 “저장“할까? thread::spawn 시그니처를 다시 살펴보자:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
    where
        F: FnOnce() -> T,
        F: Send + 'static,
        T: Send + 'static,

spawn 함수는 JoinHandle<T>를 반환한다. 여기서 T는 클로저가 반환하는 타입이다. 우리도 JoinHandle을 사용해 보자. 이 경우에는 스레드 풀에 전달한 클로저가 연결을 처리하고 아무것도 반환하지 않으므로, T는 유닛 타입 ()이 된다.

리스트 21-14의 코드는 컴파일되지만 아직 스레드를 생성하지는 않는다. ThreadPool의 정의를 변경해 thread::JoinHandle<()> 인스턴스의 벡터를 보유하도록 했다. 벡터를 size 크기로 초기화하고, 스레드를 생성하기 위해 일부 코드를 실행할 for 루프를 설정한 후, 이를 포함한 ThreadPool 인스턴스를 반환한다.

Filename: src/lib.rs
use std::thread;

pub struct ThreadPool {
    threads: Vec<thread::JoinHandle<()>>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut threads = Vec::with_capacity(size);

        for _ in 0..size {
            // create some threads and store them in the vector
        }

        ThreadPool { threads }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}
Listing 21-14: ThreadPool이 스레드를 보유할 벡터 생성

std::thread를 라이브러리 크레이트의 스코프로 가져왔다. ThreadPool의 벡터 항목 타입으로 thread::JoinHandle을 사용하기 때문이다.

유효한 크기를 받으면, ThreadPoolsize 항목을 보유할 수 있는 새 벡터를 생성한다. with_capacity 함수는 Vec::new와 동일한 작업을 수행하지만 중요한 차이가 있다. 벡터에 공간을 미리 할당한다. 벡터에 size 요소를 저장해야 한다는 것을 알고 있기 때문에, 이렇게 미리 할당하는 것이 Vec::new를 사용하는 것보다 약간 더 효율적이다. Vec::new는 요소가 삽입될 때마다 크기를 조정한다.

cargo check를 다시 실행하면 성공할 것이다.

ThreadPool에서 스레드로 코드를 전달하는 Worker 구조체

이전에 21-14 목록의 for 루프에서 스레드를 생성하는 부분에 대해 주석을 남겼다. 이제 실제로 스레드를 어떻게 생성하는지 알아보자. 표준 라이브러리는 thread::spawn을 통해 스레드를 생성하는 방법을 제공하며, thread::spawn은 스레드가 생성되자마자 실행할 코드를 받기를 기대한다. 하지만 우리의 경우, 스레드를 생성하고 나중에 보낼 코드를 기다리도록 하고 싶다. 표준 라이브러리의 스레드 구현에는 이를 위한 방법이 포함되어 있지 않으므로, 직접 구현해야 한다.

이 동작을 구현하기 위해 ThreadPool과 스레드 사이에 새로운 데이터 구조를 도입한다. 이 데이터 구조를 _Worker_라고 부르며, 풀링 구현에서 흔히 사용되는 용어다. Worker는 실행해야 할 코드를 받아서 해당 Worker의 스레드에서 코드를 실행한다.

레스토랑 주방에서 일하는 사람들을 생각해보자. 직원들은 고객으로부터 주문이 들어올 때까지 기다렸다가, 그 주문을 받아서 처리하는 역할을 한다.

ThreadPool에서 JoinHandle<()> 인스턴스의 벡터를 저장하는 대신, Worker 구조체의 인스턴스를 저장한다. 각 Worker는 단일 JoinHandle<()> 인스턴스를 저장한다. 그리고 Worker에 실행할 코드 클로저를 받아서 이미 실행 중인 스레드로 보내는 메서드를 구현한다. 또한 각 Workerid를 부여해 로깅이나 디버깅 시 풀 안의 다른 Worker 인스턴스와 구별할 수 있도록 한다.

ThreadPool을 생성할 때 발생하는 새로운 프로세스는 다음과 같다. Worker를 이렇게 설정한 후 클로저를 스레드로 보내는 코드를 구현할 것이다:

  1. idJoinHandle<()>을 포함하는 Worker 구조체를 정의한다.
  2. ThreadPoolWorker 인스턴스의 벡터를 보유하도록 변경한다.
  3. id 번호를 받아서 id와 빈 클로저로 생성된 스레드를 포함하는 Worker 인스턴스를 반환하는 Worker::new 함수를 정의한다.
  4. ThreadPool::new에서 for 루프의 카운터를 사용해 id를 생성하고, 해당 id로 새로운 Worker를 생성한 후 벡터에 저장한다.

도전해보고 싶다면, 21-15 목록의 코드를 보기 전에 이 변경사항을 직접 구현해보자.

준비가 되었다면, 앞서 설명한 수정사항을 반영한 21-15 목록을 확인해보자.

Filename: src/lib.rs
use std::thread;

pub struct ThreadPool {
    workers: Vec<Worker>,
}

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listing 21-15: ThreadPool이 스레드를 직접 보유하는 대신 Worker 인스턴스를 보유하도록 수정

ThreadPool의 필드 이름을 threads에서 workers로 변경했다. 이제 JoinHandle<()> 인스턴스 대신 Worker 인스턴스를 보유하기 때문이다. for 루프의 카운터를 Worker::new의 인자로 사용하고, 각 새로운 Workerworkers라는 벡터에 저장한다.

외부 코드(예: _src/main.rs_의 서버)는 ThreadPool 내에서 Worker 구조체를 사용하는 구현 세부사항을 알 필요가 없으므로, Worker 구조체와 그 new 함수를 비공개로 만든다. Worker::new 함수는 우리가 제공한 id를 사용하고, 빈 클로저를 사용해 생성된 새로운 스레드의 JoinHandle<()> 인스턴스를 저장한다.

참고: 운영체제가 시스템 리소스가 부족해 스레드를 생성할 수 없는 경우, thread::spawn이 패닉을 일으킬 수 있다. 이로 인해 일부 스레드 생성은 성공했더라도 전체 서버가 패닉 상태에 빠질 수 있다. 간단함을 위해 이 동작은 괜찮지만, 실제 프로덕션 스레드 풀 구현에서는 std::thread::Builder와 그 spawn 메서드를 사용해 Result를 반환하도록 하는 것이 좋다.

이 코드는 컴파일되며, ThreadPool::new에 인자로 지정한 수만큼의 Worker 인스턴스를 저장한다. 하지만 여전히 execute에서 받은 클로저를 처리하지는 않는다. 다음으로 이를 어떻게 처리하는지 알아보자.

채널을 통해 스레드에 요청 보내기

다음으로 해결해야 할 문제는 thread::spawn에 전달된 클로저가 아무런 동작을 하지 않는다는 점이다. 현재 execute 메서드에서 실행할 클로저를 가져오지만, ThreadPool 생성 시 각 Worker를 만들 때 thread::spawn에 실행할 클로저를 전달해야 한다.

방금 생성한 Worker 구조체가 ThreadPool에 있는 큐에서 실행할 코드를 가져와 해당 스레드로 전달하도록 하고 싶다.

16장에서 배운 채널은 두 스레드 간 통신을 위한 간단한 방법으로, 이 사용 사례에 완벽하게 적합하다. 채널을 작업 큐로 사용하고, executeThreadPool에서 Worker 인스턴스로 작업을 보낸다. 그러면 Worker는 해당 작업을 스레드로 전달한다. 계획은 다음과 같다:

  1. ThreadPool은 채널을 생성하고 송신자를 보유한다.
  2. Worker는 수신자를 보유한다.
  3. 채널을 통해 보낼 클로저를 담을 새로운 Job 구조체를 생성한다.
  4. execute 메서드는 실행할 작업을 송신자를 통해 보낸다.
  5. Worker는 스레드 내에서 수신자를 반복적으로 확인하고 받은 작업의 클로저를 실행한다.

먼저 ThreadPool::new에서 채널을 생성하고 ThreadPool 인스턴스에 송신자를 보관한다. 현재 Job 구조체는 아무것도 포함하지 않지만, 채널을 통해 보낼 항목의 타입이 될 것이다.

Filename: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize) -> Worker {
        let thread = thread::spawn(|| {});

        Worker { id, thread }
    }
}
Listing 21-16: Job 인스턴스를 전송하는 채널의 송신자를 저장하도록 ThreadPool 수정

ThreadPool::new에서 새로운 채널을 생성하고 풀이 송신자를 보유하도록 한다. 이 코드는 성공적으로 컴파일된다.

이제 스레드 풀이 채널을 생성할 때 각 Worker에 채널의 수신자를 전달해 보자. Worker 인스턴스가 생성하는 스레드에서 수신자를 사용하고 싶으므로, 클로저 내에서 receiver 매개변수를 참조한다. Listing 21-17의 코드는 아직 컴파일되지 않는다.

Filename: src/lib.rs
use std::{sync::mpsc, thread};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, receiver));
        }

        ThreadPool { workers, sender }
    }
    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--


struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-17: 각 Worker에 수신자 전달

몇 가지 간단한 변경을 했다: 수신자를 Worker::new에 전달하고, 클로저 내부에서 사용한다.

이 코드를 확인하려고 하면 다음과 같은 오류가 발생한다:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0382]: use of moved value: `receiver`
  --> src/lib.rs:26:42
   |
21 |         let (sender, receiver) = mpsc::channel();
   |                      -------- move occurs because `receiver` has type `std::sync::mpsc::Receiver<Job>`, which does not implement the `Copy` trait
...
25 |         for id in 0..size {
   |         ----------------- inside of this loop
26 |             workers.push(Worker::new(id, receiver));
   |                                          ^^^^^^^^ value moved here, in previous iteration of loop
   |
note: consider changing this parameter type in method `new` to borrow instead if owning the value isn't necessary
  --> src/lib.rs:47:33
   |
47 |     fn new(id: usize, receiver: mpsc::Receiver<Job>) -> Worker {
   |        --- in this method       ^^^^^^^^^^^^^^^^^^^ this parameter takes ownership of the value
help: consider moving the expression out of the loop so it is only moved once
   |
25 ~         let mut value = Worker::new(id, receiver);
26 ~         for id in 0..size {
27 ~             workers.push(value);
   |

For more information about this error, try `rustc --explain E0382`.
error: could not compile `hello` (lib) due to 1 previous error

코드는 receiver를 여러 Worker 인스턴스에 전달하려고 한다. 16장에서 배웠듯이, 이는 작동하지 않는다. Rust가 제공하는 채널 구현은 다중 생산자, 단일 소비자 방식이다. 즉, 채널의 소비 측을 복제해서 이 코드를 고칠 수 없다. 또한 여러 소비자에게 메시지를 여러 번 보내고 싶지 않다. 여러 Worker 인스턴스가 하나의 메시지 목록을 공유하고 각 메시지가 한 번만 처리되도록 하고 싶다.

또한, 채널 큐에서 작업을 가져오는 것은 receiver를 변경하는 작업이므로, 스레드가 receiver를 안전하게 공유하고 수정할 방법이 필요하다. 그렇지 않으면 경쟁 조건이 발생할 수 있다(16장에서 다룬 내용).

16장에서 논의한 스레드 안전한 스마트 포인터를 떠올려 보자: 여러 스레드 간에 소유권을 공유하고 스레드가 값을 변경할 수 있도록 하려면 Arc<Mutex<T>>를 사용해야 한다. Arc 타입은 여러 Worker 인스턴스가 수신자를 소유할 수 있게 하고, Mutex는 한 번에 하나의 Worker만 수신자에서 작업을 가져올 수 있도록 보장한다. Listing 21-18은 필요한 변경 사항을 보여준다.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};
// --snip--

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

struct Job;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    // --snip--

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        // --snip--
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-18: ArcMutex를 사용해 Worker 인스턴스 간에 수신자 공유

ThreadPool::new에서 수신자를 ArcMutex에 넣는다. 새로운 Worker를 생성할 때마다 Arc를 복제해 참조 카운트를 증가시키고, Worker 인스턴스가 수신자의 소유권을 공유할 수 있도록 한다.

이러한 변경을 통해 코드가 컴파일된다! 거의 다 왔다!

execute 메서드 구현

이제 ThreadPoolexecute 메서드를 구현해 보자. 또한 Job을 구조체에서 execute가 받는 클로저 타입을 담는 트레이트 객체의 타입 별칭으로 변경한다. 20장의 “타입 별칭으로 타입 동의어 만들기”에서 논의한 대로, 타입 별칭을 사용하면 긴 타입을 짧게 만들어 사용하기 편리하다. 목록 21-19를 살펴보자.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    // --snip--
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

// --snip--

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(|| {
            receiver;
        });

        Worker { id, thread }
    }
}
Listing 21-19: 각 클로저를 담는 Box에 대한 Job 타입 별칭을 생성하고, 채널을 통해 작업을 전송

execute에서 받은 클로저를 사용해 새로운 Job 인스턴스를 생성한 후, 해당 작업을 채널의 송신 측으로 보낸다. send에 대해 unwrap을 호출하는데, 이는 전송이 실패할 경우를 대비한 것이다. 예를 들어, 모든 스레드의 실행을 중지하면 수신 측이 새로운 메시지를 받는 것을 중지할 수 있다. 현재로서는 스레드 실행을 중지할 수 없는데, 풀이 존재하는 한 스레드는 계속 실행된다. unwrap을 사용하는 이유는 실패 사례가 발생하지 않을 것임을 알지만, 컴파일러는 이를 알지 못하기 때문이다.

하지만 아직 완전히 끝난 것은 아니다! Worker에서 thread::spawn에 전달된 클로저는 여전히 채널의 수신 측만 참조하고 있다. 대신, 클로저가 영원히 반복하면서 채널의 수신 측에서 작업을 요청하고, 작업을 받으면 실행하도록 해야 한다. 목록 21-20에서 보이는 변경을 Worker::new에 적용해 보자.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-20: Worker 인스턴스의 스레드에서 작업을 수신하고 실행

여기서는 먼저 receiver에 대해 lock을 호출해 뮤텍스를 획득하고, 그런 다음 unwrap을 호출해 오류가 발생하면 패닉을 일으킨다. 뮤텍스가 poisoned 상태일 경우 락 획득이 실패할 수 있는데, 이는 다른 스레드가 락을 해제하지 않고 패닉을 일으킨 경우에 발생할 수 있다. 이 상황에서 unwrap을 호출해 이 스레드가 패닉을 일으키는 것은 올바른 조치이다. 이 unwrap을 의미 있는 오류 메시지와 함께 expect로 변경해도 좋다.

뮤텍스에 대한 락을 획득하면 recv를 호출해 채널에서 Job을 받는다. 여기서도 마지막 unwrap은 오류를 넘어가는데, 이는 송신 측을 담당하는 스레드가 종료된 경우 발생할 수 있으며, 이는 send 메서드가 수신 측이 종료된 경우 Err를 반환하는 것과 유사하다.

recv 호출은 블로킹되므로, 아직 작업이 없으면 현재 스레드는 작업이 사용 가능해질 때까지 대기한다. Mutex<T>는 한 번에 하나의 Worker 스레드만 작업을 요청하도록 보장한다.

이제 스레드 풀이 작동 상태이다! cargo run을 실행하고 몇 가지 요청을 보내 보자.

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
warning: field `workers` is never read
 --> src/lib.rs:7:5
  |
6 | pub struct ThreadPool {
  |            ---------- field in this struct
7 |     workers: Vec<Worker>,
  |     ^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: fields `id` and `thread` are never read
  --> src/lib.rs:48:5
   |
47 | struct Worker {
   |        ------ fields in this struct
48 |     id: usize,
   |     ^^
49 |     thread: thread::JoinHandle<()>,
   |     ^^^^^^

warning: `hello` (lib) generated 2 warnings
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.91s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.
Worker 1 got a job; executing.
Worker 3 got a job; executing.
Worker 0 got a job; executing.
Worker 2 got a job; executing.

성공! 이제 비동기적으로 연결을 실행하는 스레드 풀이 준비되었다. 생성되는 스레드는 네 개를 넘지 않으므로, 서버가 많은 요청을 받더라도 시스템이 과부하되지 않는다. _/sleep_에 요청을 보내면, 서버는 다른 스레드가 요청을 실행하도록 하여 다른 요청을 처리할 수 있다.

참고: 여러 브라우저 창에서 동시에 _/sleep_을 열면, 5초 간격으로 하나씩 로드될 수 있다. 일부 웹 브라우저는 캐싱 이유로 동일한 요청의 여러 인스턴스를 순차적으로 실행한다. 이 제한은 우리 웹 서버로 인한 것이 아니다.

이제 잠시 멈추고 목록 21-18, 21-19, 21-20의 코드가 클로저 대신 futures를 사용한다면 어떻게 달라질지 생각해 볼 좋은 시기이다. 어떤 타입이 변경될까? 메서드 시그니처는 어떻게 달라질까? 코드의 어떤 부분이 동일하게 유지될까?

17장과 18장에서 while let 루프에 대해 배운 후, 왜 목록 21-21과 같이 작업자 스레드 코드를 작성하지 않았는지 궁금할 수 있다.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}
// --snip--

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            while let Ok(job) = receiver.lock().unwrap().recv() {
                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-21: while let을 사용한 Worker::new의 대체 구현

이 코드는 컴파일되고 실행되지만, 원하는 스레딩 동작을 얻지 못한다: 느린 요청이 여전히 다른 요청이 처리되기를 기다리게 할 수 있다. 그 이유는 다소 미묘한데, Mutex 구조체에는 공개된 unlock 메서드가 없기 때문이다. 락의 소유권은 lock 메서드가 반환하는 LockResult<MutexGuard<T>> 내의 MutexGuard<T>의 수명에 기반한다. 컴파일 시, 빌림 검사기는 Mutex로 보호된 리소스는 락을 보유하지 않으면 접근할 수 없다는 규칙을 강제할 수 있다. 그러나 이 구현은 MutexGuard<T>의 수명을 주의하지 않으면 락이 의도보다 오래 보유될 수 있다.

목록 21-20의 코드는 let job = receiver.lock().unwrap().recv().unwrap();을 사용하는데, 이는 let을 사용하면 등호 오른쪽의 표현식에 사용된 임시 값이 let 문이 끝나면 즉시 삭제되기 때문이다. 그러나 while let (그리고 if letmatch)은 관련 블록이 끝날 때까지 임시 값을 삭제하지 않는다. 목록 21-21에서는 job() 호출 동안 락이 계속 보유되므로, 다른 Worker 인스턴스가 작업을 받을 수 없다.

그레이스풀 셧다운과 정리

리스트 21-20의 코드는 의도한 대로 스레드 풀을 사용해 요청에 비동기적으로 응답한다. 하지만 workers, id, thread 필드를 직접 사용하지 않아 경고가 발생한다. 이는 정리 작업을 하지 않았다는 것을 알려주는 신호다. ctrl-c와 같은 간단한 방법으로 메인 스레드를 중단하면, 다른 모든 스레드도 즉시 중단된다. 심지어 요청을 처리 중인 스레드도 예외는 아니다.

다음으로, 스레드 풀의 각 스레드에서 join을 호출해 진행 중인 요청을 마무리한 후 종료할 수 있도록 Drop 트레이트를 구현한다. 그리고 스레드가 새로운 요청을 받지 않고 종료하도록 지시하는 방법도 추가한다. 이 코드를 실제로 확인하기 위해, 서버를 수정해 두 개의 요청만 처리한 후 스레드 풀을 그레이스풀하게 종료하도록 한다.

한 가지 주목할 점은, 이 모든 작업이 클로저를 실행하는 코드 부분에는 영향을 미치지 않는다는 것이다. 따라서 비동기 런타임을 위해 스레드 풀을 사용하더라도 여기서 다루는 내용은 동일하게 적용된다.

ThreadPoolDrop 트레이트 구현하기

먼저 스레드 풀에 Drop을 구현해 보자. 풀이 드롭될 때, 모든 스레드가 작업을 마칠 수 있도록 join을 호출해야 한다. 아래 예제는 Drop 구현을 위한 첫 번째 시도다. 이 코드는 아직 완벽하게 동작하지 않는다.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-22: 스레드 풀이 스코프를 벗어날 때 각 스레드에 join 호출하기

먼저 스레드 풀의 각 worker를 순회한다. self가 가변 참조이기 때문에 &mut를 사용하며, worker도 변경할 수 있어야 한다. 각 worker에 대해 해당 Worker 인스턴스가 종료된다는 메시지를 출력한 후, Worker 인스턴스의 스레드에 join을 호출한다. join 호출이 실패하면 unwrap을 사용해 Rust가 패닉에 빠지도록 하고, 비정상 종료를 유도한다.

이 코드를 컴파일하면 다음과 같은 에러가 발생한다:

$ cargo check
    Checking hello v0.1.0 (file:///projects/hello)
error[E0507]: cannot move out of `worker.thread` which is behind a mutable reference
  --> src/lib.rs:52:13
   |
52 |             worker.thread.join().unwrap();
   |             ^^^^^^^^^^^^^ ------ `worker.thread` moved due to this method call
   |             |
   |             move occurs because `worker.thread` has type `JoinHandle<()>`, which does not implement the `Copy` trait
   |
note: `JoinHandle::<T>::join` takes ownership of the receiver `self`, which moves `worker.thread`
  --> /rustc/4eb161250e340c8f48f66e2b929ef4a5bed7c181/library/std/src/thread/mod.rs:1876:17

For more information about this error, try `rustc --explain E0507`.
error: could not compile `hello` (lib) due to 1 previous error

에러 메시지는 각 worker에 대해 가변 참조만 가지고 있기 때문에 join을 호출할 수 없다고 알려준다. join은 인수의 소유권을 가져가기 때문이다. 이 문제를 해결하려면 thread를 소유한 Worker 인스턴스에서 스레드를 이동시켜 join이 스레드를 소비할 수 있도록 해야 한다. 한 가지 방법은 예제 18-15에서 사용한 접근 방식을 따르는 것이다. 만약 WorkerOption<thread::JoinHandle<()>>를 가지고 있다면, Optiontake 메서드를 호출해 Some 변형에서 값을 꺼내고 그 자리에 None을 남길 수 있다. 즉, 실행 중인 WorkerthreadSome 변형을 가지고 있고, Worker를 정리할 때 SomeNone으로 대체해 Worker가 더 이상 실행할 스레드를 가지지 않도록 할 수 있다.

그러나 이 방법은 Worker를 드롭할 때만 필요하다. 대신 worker.thread에 접근할 때마다 Option<thread::JoinHandle<()>>를 처리해야 한다. Rust에서는 Option을 자주 사용하지만, 항상 존재할 것이 확실한 값을 Option으로 감싸는 것은 코드를 더 깔끔하고 오류가 적게 만드는 대안을 찾는 것이 좋다.

이 경우 더 나은 대안이 있다: Vec::drain 메서드다. 이 메서드는 Vec에서 제거할 항목을 지정하는 범위 매개변수를 받고, 해당 항목의 이터레이터를 반환한다. .. 범위 구문을 전달하면 Vec의 모든 값을 제거한다.

따라서 ThreadPooldrop 구현을 다음과 같이 업데이트해야 한다:

Filename: src/lib.rs
#![allow(unused)]
fn main() {
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: mpsc::Sender<Job>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool { workers, sender }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
}

이렇게 하면 컴파일러 에러가 해결되며, 코드의 다른 부분을 변경할 필요가 없다.

스레드에게 작업 수신을 중단하도록 신호 보내기

지금까지 수정한 내용을 통해 코드는 경고 없이 컴파일된다. 그러나 아쉽게도 이 코드는 아직 원하는 대로 동작하지 않는다. 문제의 핵심은 Worker 인스턴스의 스레드가 실행하는 클로저의 로직에 있다. 현재는 join을 호출하지만, 이는 스레드가 작업을 계속해서 찾는 무한 루프 때문에 스레드를 종료하지 못한다. 만약 현재 구현된 drop을 통해 ThreadPool을 삭제하려고 하면, 메인 스레드는 첫 번째 스레드가 종료되기를 기다리며 영원히 블록될 것이다.

이 문제를 해결하기 위해 ThreadPooldrop 구현을 변경하고, Worker의 루프도 수정해야 한다.

먼저 ThreadPooldrop 구현을 변경하여 스레드가 종료되기를 기다리기 전에 sender를 명시적으로 삭제한다. 리스트 21-23은 sender를 명시적으로 삭제하기 위해 ThreadPool에 적용한 변경 사항을 보여준다. 스레드와 달리 여기서는 Option::take를 사용해 senderThreadPool에서 이동시키기 위해 Option을 사용해야 한다.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}
// --snip--

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        // --snip--

        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let job = receiver.lock().unwrap().recv().unwrap();

                println!("Worker {id} got a job; executing.");

                job();
            }
        });

        Worker { id, thread }
    }
}
Listing 21-23: Worker 스레드를 join하기 전에 sender를 명시적으로 삭제

sender를 삭제하면 채널이 닫히고, 더 이상 메시지가 전송되지 않음을 나타낸다. 이 경우 Worker 인스턴스가 무한 루프에서 수행하는 recv 호출은 모두 에러를 반환한다. 리스트 21-24에서는 이 경우에 루프를 정상적으로 종료하도록 Worker 루프를 변경한다. 이는 ThreadPooldrop 구현이 join을 호출할 때 스레드가 종료됨을 의미한다.

Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            worker.thread.join().unwrap();
        }
    }
}

struct Worker {
    id: usize,
    thread: thread::JoinHandle<()>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker { id, thread }
    }
}
Listing 21-24: recv가 에러를 반환할 때 루프를 명시적으로 종료

이 코드가 동작하는 모습을 보기 위해, 리스트 21-25와 같이 main을 수정하여 서버가 두 개의 요청만 처리한 후 정상적으로 종료하도록 한다.

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Listing 21-25: 루프를 종료하여 두 개의 요청을 처리한 후 서버 종료

실제 웹 서버는 단 두 개의 요청만 처리하고 종료하도록 만들지는 않을 것이다. 이 코드는 단순히 정상적인 종료와 정리 작업이 제대로 동작하는지 보여주기 위한 예제이다.

take 메서드는 Iterator 트레이트에 정의되어 있으며, 반복을 최대 두 개의 항목으로 제한한다. ThreadPoolmain의 끝에서 스코프를 벗어나며, drop 구현이 실행된다.

cargo run으로 서버를 시작하고, 세 번의 요청을 보낸다. 세 번째 요청은 에러를 반환해야 하며, 터미널에서 다음과 유사한 출력을 확인할 수 있다:

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/hello`
Worker 0 got a job; executing.
Shutting down.
Shutting down worker 0
Worker 3 got a job; executing.
Worker 1 disconnected; shutting down.
Worker 2 disconnected; shutting down.
Worker 3 disconnected; shutting down.
Worker 0 disconnected; shutting down.
Shutting down worker 1
Shutting down worker 2
Shutting down worker 3

Worker ID와 메시지의 순서는 다를 수 있다. 메시지를 통해 이 코드가 어떻게 동작하는지 확인할 수 있다: Worker 인스턴스 0과 3이 처음 두 요청을 받았다. 서버는 두 번째 연결 이후에 연결을 수락하지 않았고, ThreadPoolDrop 구현은 Worker 3이 작업을 시작하기 전에 실행되기 시작했다. sender를 삭제하면 모든 Worker 인스턴스의 연결이 끊어지고 종료하도록 지시한다. 각 Worker 인스턴스는 연결이 끊어질 때 메시지를 출력하고, 스레드 풀은 각 Worker 스레드가 종료되기를 기다리기 위해 join을 호출한다.

이 실행에서 흥미로운 점은 ThreadPoolsender를 삭제한 후, 어떤 Worker도 에러를 받기 전에 Worker 0을 join하려고 시도했다는 것이다. Worker 0은 아직 recv에서 에러를 받지 않았기 때문에, 메인 스레드는 Worker 0이 종료되기를 기다리며 블록되었다. 그 동안 Worker 3은 작업을 받고, 이후 모든 스레드가 에러를 받았다. Worker 0이 종료되면, 메인 스레드는 나머지 Worker 인스턴스가 종료되기를 기다렸다. 이 시점에서 모든 스레드는 루프를 종료하고 정지했다.

축하한다! 이제 프로젝트를 완성했다. 스레드 풀을 사용해 비동기적으로 응답하는 기본적인 웹 서버를 만들었다. 또한 서버를 정상적으로 종료하고 풀의 모든 스레드를 정리할 수 있게 되었다.

참고를 위해 전체 코드는 다음과 같다:

Filename: src/main.rs
use hello::ThreadPool;
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
    thread,
    time::Duration,
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
Filename: src/lib.rs
use std::{
    sync::{Arc, Mutex, mpsc},
    thread,
};

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();

        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);

        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

impl Drop for ThreadPool {
    fn drop(&mut self) {
        drop(self.sender.take());

        for worker in &mut self.workers {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || {
            loop {
                let message = receiver.lock().unwrap().recv();

                match message {
                    Ok(job) => {
                        println!("Worker {id} got a job; executing.");

                        job();
                    }
                    Err(_) => {
                        println!("Worker {id} disconnected; shutting down.");
                        break;
                    }
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

여기서 더 많은 작업을 할 수 있다! 이 프로젝트를 계속 개선하고 싶다면 다음 아이디어를 고려해보자:

  • ThreadPool과 그 공개 메서드에 대한 문서를 추가한다.
  • 라이브러리의 기능을 테스트한다.
  • unwrap 호출을 더 강력한 에러 처리로 변경한다.
  • 웹 요청 처리 외에 다른 작업을 수행하기 위해 ThreadPool을 사용한다.
  • crates.io에서 스레드 풀 크레이트를 찾아 이를 사용해 비슷한 웹 서버를 구현한다. 그리고 그 크레이트의 API와 견고성을 우리가 구현한 스레드 풀과 비교한다.

요약

축하한다! 지금까지 Rust 여정을 함께 해줘서 고맙다. 이제 여러분은 자신만의 Rust 프로젝트를 구현하고 다른 사람들의 프로젝트에도 기여할 준비가 되었다. Rust 여정에서 마주칠 수 있는 도전 과제를 해결하는 데 도움을 줄 Rust 커뮤니티가 항상 열려 있다는 것을 기억하길 바란다.

부록

아래 섹션들은 여러분의 Rust 학습 여정에서 유용할 수 있는 참고 자료를 담고 있다.

부록 A: 예약어

아래 목록은 Rust 언어가 현재 또는 미래에 사용하기 위해 예약해 둔 키워드들이다. 따라서 이 키워드들은 일반적인 식별자로 사용할 수 없다(단, “Raw Identifiers” 섹션에서 설명할 raw 식별자로는 사용 가능). 식별자란 함수, 변수, 매개변수, 구조체 필드, 모듈, 크레이트, 상수, 매크로, 정적 값, 속성, 타입, 트레잇, 라이프타임 등의 이름을 의미한다.

현재 사용 중인 키워드

다음은 현재 사용 중인 키워드 목록과 각각의 기능 설명이다.

  • as - 기본 타입 캐스팅, 특정 트레이트 내 항목의 모호성 제거, use 문에서 항목 이름 변경
  • async - 현재 스레드를 블로킹하지 않고 Future를 반환
  • await - Future의 결과가 준비될 때까지 실행을 일시 중지
  • break - 루프를 즉시 종료
  • const - 상수 항목 또는 상수 원시 포인터 정의
  • continue - 다음 루프 반복으로 이동
  • crate - 모듈 경로에서 크레이트 루트를 참조
  • dyn - 트레이트 객체에 대한 동적 디스패치
  • else - ifif let 제어 구조에 대한 대체 처리
  • enum - 열거형 정의
  • extern - 외부 함수 또는 변수 연결
  • false - 불리언 거짓 리터럴
  • fn - 함수 또는 함수 포인터 타입 정의
  • for - 이터레이터에서 항목을 순회하거나, 트레이트를 구현하거나, 고차 수명을 지정
  • if - 조건식의 결과에 따라 분기
  • impl - 고유 또는 트레이트 기능 구현
  • in - for 루프 문법의 일부
  • let - 변수 바인딩
  • loop - 무조건 루프 실행
  • match - 값을 패턴과 매칭
  • mod - 모듈 정의
  • move - 클로저가 모든 캡처를 소유하도록 만듦
  • mut - 참조, 원시 포인터 또는 패턴 바인딩에서 변경 가능성 표시
  • pub - 구조체 필드, impl 블록 또는 모듈에서 공개 가시성 표시
  • ref - 참조로 바인딩
  • return - 함수에서 반환
  • Self - 정의하거나 구현 중인 타입의 별칭
  • self - 메서드 주체 또는 현재 모듈
  • static - 전역 변수 또는 프로그램 실행 전체에 걸친 수명
  • struct - 구조체 정의
  • super - 현재 모듈의 상위 모듈
  • trait - 트레이트 정의
  • true - 불리언 참 리터럴
  • type - 타입 별칭 또는 연관 타입 정의
  • union - 유니온 정의; 유니온 선언에서만 키워드로 사용됨
  • unsafe - 안전하지 않은 코드, 함수, 트레이트 또는 구현 표시
  • use - 심볼을 범위로 가져오기; 제네릭 및 수명 경계에 대한 정확한 캡처 지정
  • where - 타입을 제약하는 절 표시
  • while - 조건식의 결과에 따라 루프 실행

미래 사용을 위해 예약된 키워드

다음 키워드들은 현재는 아무런 기능이 없지만, Rust에서 미래에 사용할 가능성을 위해 예약되어 있다.

  • abstract
  • become
  • box
  • do
  • final
  • gen
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

Raw Identifiers (원시 식별자)

_원시 식별자_는 일반적으로 허용되지 않는 곳에서 키워드를 사용할 수 있게 해주는 문법이다. 키워드 앞에 r#를 붙여 원시 식별자를 사용한다.

예를 들어, match는 키워드다. match를 함수 이름으로 사용하려고 다음과 같이 코드를 작성하면:

파일명: src/main.rs

fn match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

다음과 같은 오류가 발생한다:

error: expected identifier, found keyword `match`
 --> src/main.rs:4:4
  |
4 | fn match(needle: &str, haystack: &str) -> bool {
  |    ^^^^^ expected identifier, found keyword

오류 메시지는 match 키워드를 함수 식별자로 사용할 수 없음을 보여준다. match를 함수 이름으로 사용하려면 원시 식별자 문법을 사용해야 한다. 다음과 같이 작성하면 된다:

파일명: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

이 코드는 오류 없이 컴파일된다. 함수 정의와 main 함수에서 함수를 호출할 때 모두 r# 접두사가 붙어 있음을 주목하라.

원시 식별자를 사용하면 예약어로 지정된 단어도 식별자로 사용할 수 있다. 이를 통해 식별자 이름을 더 자유롭게 선택할 수 있으며, 다른 언어로 작성된 프로그램과 통합할 때도 유용하다. 또한 원시 식별자를 사용하면 현재 크레이트와 다른 Rust 버전으로 작성된 라이브러리를 사용할 수 있다. 예를 들어, try는 2015 버전에서는 키워드가 아니지만 2018, 2021, 2024 버전에서는 키워드다. 2015 버전으로 작성된 라이브러리를 사용하고 그 안에 try 함수가 있다면, 이후 버전에서 이 함수를 호출할 때 원시 식별자 문법인 r#try를 사용해야 한다. 버전에 대한 더 자세한 정보는 부록 E를 참고하라.

부록 B: 연산자와 기호

이 부록은 Rust 문법의 용어집을 담고 있다. 단독으로 사용되거나 경로, 제네릭, 트레이트 바운드, 매크로, 속성, 주석, 튜플, 괄호 등의 문맥에서 사용되는 연산자와 기호를 설명한다.

연산자

표 B-1은 Rust의 연산자, 연산자가 사용되는 예제, 간단한 설명, 그리고 해당 연산자가 오버로드 가능한지 여부를 보여준다. 연산자가 오버로드 가능한 경우, 해당 연산자를 오버로드하기 위해 사용할 수 있는 트레이트를 함께 표시했다.

표 B-1: 연산자

연산자예제설명오버로드 가능?
!ident!(...), ident!{...}, ident![...]매크로 확장
!!expr비트 단위 또는 논리적 보수Not
!=expr != expr비동등 비교PartialEq
%expr % expr산술 나머지Rem
%=var %= expr산술 나머지 및 할당RemAssign
&&expr, &mut expr참조
&&type, &mut type, &'a type, &'a mut type참조된 포인터 타입
&expr & expr비트 단위 ANDBitAnd
&=var &= expr비트 단위 AND 및 할당BitAndAssign
&&expr && expr단락 논리 AND
*expr * expr산술 곱셈Mul
*=var *= expr산술 곱셈 및 할당MulAssign
**expr역참조Deref
**const type, *mut type원시 포인터
+trait + trait, 'a + trait복합 타입 제약
+expr + expr산술 덧셈Add
+=var += expr산술 덧셈 및 할당AddAssign
,expr, expr인수 및 요소 구분자
-- expr산술 부정Neg
-expr - expr산술 뺄셈Sub
-=var -= expr산술 뺄셈 및 할당SubAssign
->fn(...) -> type, |…| -> type함수 및 클로저 반환 타입
.expr.ident필드 접근
.expr.ident(expr, ...)메서드 호출
.expr.0, expr.1, etc.튜플 인덱싱
...., expr.., ..expr, expr..expr오른쪽 제외 범위 리터럴PartialOrd
..=..=expr, expr..=expr오른쪽 포함 범위 리터럴PartialOrd
....expr구조체 리터럴 업데이트 구문
..variant(x, ..), struct_type { x, .. }“그 외” 패턴 바인딩
...expr...expr(더 이상 사용되지 않음, 대신 ..= 사용) 패턴 내에서 포함 범위 패턴
/expr / expr산술 나눗셈Div
/=var /= expr산술 나눗셈 및 할당DivAssign
:pat: type, ident: type제약 조건
:ident: expr구조체 필드 초기화
:'a: loop {...}루프 레이블
;expr;문장 및 아이템 종결자
;[...; len]고정 크기 배열 구문의 일부
<<expr << expr왼쪽 시프트Shl
<<=var <<= expr왼쪽 시프트 및 할당ShlAssign
<expr < expr작음 비교PartialOrd
<=expr <= expr작거나 같음 비교PartialOrd
=var = expr, ident = type할당/동등성
==expr == expr동등 비교PartialEq
=>pat => expr매치 암 구문의 일부
>expr > expr큼 비교PartialOrd
>=expr >= expr크거나 같음 비교PartialOrd
>>expr >> expr오른쪽 시프트Shr
>>=var >>= expr오른쪽 시프트 및 할당ShrAssign
@ident @ pat패턴 바인딩
^expr ^ expr비트 단위 배타적 ORBitXor
^=var ^= expr비트 단위 배타적 OR 및 할당BitXorAssign
|pat | pat패턴 대안
|expr | expr비트 단위 ORBitOr
|=var |= expr비트 단위 OR 및 할당BitOrAssign
||expr || expr단락 논리 OR
?expr?오류 전파

연산자가 아닌 심볼들

다음은 연산자로 동작하지 않는 심볼들의 목록이다. 즉, 이들은 함수나 메서드 호출처럼 동작하지 않는다.

표 B-2는 다양한 위치에서 유효한 독립적인 심볼들을 보여준다.

표 B-2: 독립적인 문법

심볼설명
'ident명명된 라이프타임 또는 루프 레이블
...u8, ...i32, ...f64, ...usize, etc.특정 타입의 숫자 리터럴
"..."문자열 리터럴
r"...", r#"..."#, r##"..."##, etc.원시 문자열 리터럴, 이스케이프 문자 처리 안 됨
b"..."바이트 문자열 리터럴; 문자열 대신 바이트 배열 생성
br"...", br#"..."#, br##"..."##, etc.원시 바이트 문자열 리터럴, 원시와 바이트 문자열 리터럴의 조합
'...'문자 리터럴
b'...'ASCII 바이트 리터럴
|…| expr클로저
!발산 함수를 위한 항상 비어 있는 바닥 타입
_“무시된” 패턴 바인딩; 정수 리터럴을 읽기 쉽게 만드는 데도 사용

표 B-3은 모듈 계층 구조를 통해 아이템에 이르는 경로와 관련된 심볼들을 보여준다.

표 B-3: 경로 관련 문법

심볼설명
ident::ident네임스페이스 경로
::path외부 프렐루드에 상대적인 경로, 모든 크레이트가 루트됨 (즉, 크레이트 이름을 포함한 명시적 절대 경로)
self::path현재 모듈에 상대적인 경로 (즉, 명시적 상대 경로).
super::path현재 모듈의 부모에 상대적인 경로
type::ident, <type as trait>::ident연관 상수, 함수, 타입
<type>::...직접 명명할 수 없는 타입의 연관 아이템 (예: <&T>::..., <[T]>::..., 등)
trait::method(...)메서드 호출을 정의한 트레이트를 명시하여 모호함 제거
type::method(...)메서드 호출을 정의한 타입을 명시하여 모호함 제거
<type as trait>::method(...)메서드 호출을 정의한 트레이트와 타입을 명시하여 모호함 제거

표 B-4는 제네릭 타입 매개변수를 사용하는 맥락에서 나타나는 심볼들을 보여준다.

표 B-4: 제네릭

심볼설명
path<...>타입에서 제네릭 타입에 매개변수 지정 (예: Vec<u8>)
path::<...>, method::<...>표현식에서 제네릭 타입, 함수, 또는 메서드에 매개변수 지정; 종종 터보피시라고 함 (예: "42".parse::<i32>())
fn ident<...> ...제네릭 함수 정의
struct ident<...> ...제네릭 구조체 정의
enum ident<...> ...제네릭 열거형 정의
impl<...> ...제네릭 구현 정의
for<...> type고차 라이프타임 바운드
type<ident=type>하나 이상의 연관 타입이 특정 값으로 할당된 제네릭 타입 (예: Iterator<Item=T>)

표 B-5는 트레이트 바운드로 제네릭 타입 매개변수를 제한하는 맥락에서 나타나는 심볼들을 보여준다.

표 B-5: 트레이트 바운드 제약

심볼설명
T: U제네릭 매개변수 TU를 구현하는 타입으로 제한됨
T: 'a제네릭 타입 T가 라이프타임 'a보다 오래 살아야 함 (즉, 타입이 'a보다 짧은 라이프타임을 가진 참조를 포함할 수 없음)
T: 'static제네릭 타입 T'static 이외의 빌린 참조를 포함하지 않음
'b: 'a제네릭 라이프타임 'b가 라이프타임 'a보다 오래 살아야 함
T: ?Sized제네릭 타입 매개변수가 동적 크기 타입일 수 있도록 허용
'a + trait, trait + trait복합 타입 제약

표 B-6은 매크로를 호출하거나 정의하는 맥락, 그리고 아이템에 속성을 지정하는 맥락에서 나타나는 심볼들을 보여준다.

표 B-6: 매크로와 속성

심볼설명
#[meta]외부 속성
#![meta]내부 속성
$ident매크로 치환
$ident:kind매크로 캡처
$(…)…매크로 반복
ident!(...), ident!{...}, ident![...]매크로 호출

표 B-7은 주석을 생성하는 심볼들을 보여준다.

표 B-7: 주석

심볼설명
//라인 주석
//!내부 라인 문서 주석
///외부 라인 문서 주석
/*...*/블록 주석
/*!...*/내부 블록 문서 주석
/**...*/외부 블록 문서 주석

표 B-8은 괄호가 사용되는 맥락을 보여준다.

표 B-8: 괄호

심볼설명
()빈 튜플 (즉, 유닛), 리터럴 및 타입
(expr)괄호로 묶인 표현식
(expr,)단일 요소 튜플 표현식
(type,)단일 요소 튜플 타입
(expr, ...)튜플 표현식
(type, ...)튜플 타입
expr(expr, ...)함수 호출 표현식; 튜플 struct 및 튜플 enum 변형 초기화에도 사용

표 B-9는 중괄호가 사용되는 맥락을 보여준다.

표 B-9: 중괄호

맥락설명
{...}블록 표현식
Type {...}struct 리터럴

표 B-10은 대괄호가 사용되는 맥락을 보여준다.

표 B-10: 대괄호

맥락설명
[...]배열 리터럴
[expr; len]exprlen번 복사한 배열 리터럴
[type; len]type의 인스턴스를 len개 포함한 배열 타입
expr[expr]컬렉션 인덱싱. 오버로드 가능 (Index, IndexMut)
expr[..], expr[a..], expr[..b], expr[a..b]Range, RangeFrom, RangeTo, 또는 RangeFull을 “인덱스”로 사용해 컬렉션 슬라이싱처럼 동작하는 컬렉션 인덱싱

부록 C: 파생 가능한 트레이트

이 책의 여러 부분에서 derive 속성에 대해 다뤘다. 이 속성은 구조체나 열거형 정의에 적용할 수 있으며, derive 문법으로 주석을 단 타입에 대해 기본 구현을 포함한 트레이트를 자동으로 구현하는 코드를 생성한다.

이 부록에서는 표준 라이브러리에서 derive와 함께 사용할 수 있는 모든 트레이트를 참조로 제공한다. 각 섹션에서는 다음 내용을 다룬다:

  • 해당 트레이트를 파생할 때 활성화되는 연산자와 메서드
  • derive가 제공하는 트레이트 구현의 동작 방식
  • 트레이트를 구현하는 것이 타입에 대해 의미하는 바
  • 트레이트를 구현할 수 있는 조건과 불가능한 조건
  • 해당 트레이트가 필요한 연산의 예시

derive 속성이 제공하는 동작과 다른 동작을 원한다면, 각 트레이트에 대한 표준 라이브러리 문서를 참고해 수동으로 구현하는 방법을 확인할 수 있다.

여기에 나열된 트레이트는 표준 라이브러리에서 정의된 것 중 derive를 사용해 타입에 구현할 수 있는 유일한 트레이트들이다. 표준 라이브러리에 정의된 다른 트레이트들은 기본 동작이 명확하지 않으므로, 목적에 맞게 직접 구현해야 한다.

파생할 수 없는 트레이트의 예로는 최종 사용자를 위한 포맷팅을 처리하는 Display가 있다. 타입을 최종 사용자에게 어떻게 표시할지 항상 고려해야 한다. 최종 사용자가 볼 수 있는 부분은 어디인가? 어떤 부분이 관련성이 있는가? 데이터의 어떤 포맷이 가장 적합한가? Rust 컴파일러는 이러한 통찰력을 가지고 있지 않으므로 적절한 기본 동작을 제공할 수 없다.

이 부록에서 제공하는 파생 가능한 트레이트 목록은 완전하지 않다. 라이브러리들은 자체 트레이트에 대해 derive를 구현할 수 있으므로, derive와 함께 사용할 수 있는 트레이트 목록은 사실상 무한하다. derive를 구현하려면 프로시저 매크로를 사용해야 하며, 이는 20장의 “매크로” 섹션에서 다룬다.

프로그래머를 위한 Debug 출력

Debug 트레이트는 포맷 문자열에서 디버그 포맷팅을 가능하게 한다. {} 플레이스홀더 안에 :?를 추가하면 이를 사용할 수 있다.

Debug 트레이트는 디버깅 목적으로 타입의 인스턴스를 출력할 수 있게 해준다. 이를 통해 여러분과 다른 프로그래머들은 프로그램 실행 중 특정 지점에서 해당 인스턴스를 확인할 수 있다.

예를 들어, Debug 트레이트는 assert_eq! 매크로를 사용할 때 필수적이다. 이 매크로는 동등성 검증이 실패할 경우 인자로 주어진 인스턴스의 값을 출력한다. 이를 통해 프로그래머는 두 인스턴스가 왜 동일하지 않은지 확인할 수 있다.

PartialEqEq를 통한 동등성 비교

PartialEq 트레이트는 타입의 인스턴스 간 동등성을 비교할 수 있게 해주며, ==!= 연산자를 사용할 수 있도록 한다.

PartialEq를 파생(derive)하면 eq 메서드가 구현된다. 구조체에 PartialEq를 파생할 경우, 두 인스턴스는 모든 필드가 동일할 때만 동등하다고 판단된다. 필드 중 하나라도 다르면 두 인스턴스는 동등하지 않다. 열거형에 PartialEq를 파생할 경우, 각 변형은 자기 자신과 동등하며 다른 변형과는 동등하지 않다.

PartialEq 트레이트는 예를 들어 assert_eq! 매크로를 사용할 때 필요하다. 이 매크로는 타입의 두 인스턴스를 비교할 수 있어야 하기 때문이다.

Eq 트레이트는 메서드가 없다. 이 트레이트의 목적은 주석이 달린 타입의 모든 값이 자기 자신과 동등함을 나타내는 것이다. Eq 트레이트는 PartialEq를 구현한 타입에만 적용할 수 있지만, PartialEq를 구현한 모든 타입이 Eq를 구현할 수 있는 것은 아니다. 예를 들어 부동소수점 숫자 타입은 NaN 값이 서로 동등하지 않다고 정의되어 있기 때문에 Eq를 구현할 수 없다.

Eq가 필요한 경우의 예로는 HashMap<K, V>의 키가 있다. HashMap<K, V>는 두 키가 동일한지 여부를 판단할 수 있어야 하기 때문이다.

정렬 비교를 위한 PartialOrdOrd

PartialOrd 트레이트는 타입의 인스턴스를 정렬 목적으로 비교할 수 있게 해준다. PartialOrd를 구현한 타입은 <, >, <=, >= 연산자를 사용할 수 있다. PartialOrd 트레이트는 PartialEq를 구현한 타입에만 적용할 수 있다.

PartialOrd를 파생시키면 partial_cmp 메서드가 구현된다. 이 메서드는 Option<Ordering>을 반환하며, 주어진 값들이 정렬 순서를 만들 수 없을 때 None을 반환한다. 예를 들어, 대부분의 값이 비교 가능한 타입이라도, NaN(Not-a-Number) 부동소수점 값은 정렬 순서를 만들 수 없다. 어떤 부동소수점 숫자와 NaN 값을 partial_cmp로 비교하면 None이 반환된다.

구조체에 PartialOrd를 파생시키면, 두 인스턴스를 비교할 때 구조체 정의에서 필드가 나타나는 순서대로 각 필드의 값을 비교한다. 열거형에 PartialOrd를 파생시키면, 열거형 정의에서 먼저 선언된 변형이 나중에 선언된 변형보다 작은 것으로 간주된다.

PartialOrd 트레이트는 예를 들어 rand 크레이트의 gen_range 메서드에서 필요하다. 이 메서드는 범위 표현식으로 지정된 범위 내에서 랜덤한 값을 생성한다.

Ord 트레이트는 주석이 달린 타입의 어떤 두 값에 대해서도 유효한 정렬 순서가 존재함을 보장한다. Ord 트레이트는 cmp 메서드를 구현하며, 이 메서드는 항상 유효한 정렬 순서가 가능하기 때문에 Option<Ordering>이 아닌 Ordering을 반환한다. Ord 트레이트는 PartialOrdEq를 구현한 타입에만 적용할 수 있다. (EqPartialEq를 필요로 한다.) 구조체와 열거형에 Ord를 파생시키면, cmpPartialOrd에서 파생된 partial_cmp 구현과 동일하게 동작한다.

Ord가 필요한 예시 중 하나는 BTreeSet<T>에 값을 저장할 때다. BTreeSet<T>은 값의 정렬 순서를 기준으로 데이터를 저장하는 데이터 구조다.

값 복제를 위한 CloneCopy

Clone 트레이트는 값을 명시적으로 깊은 복사(deep copy)할 수 있게 해준다. 이 복제 과정은 임의의 코드를 실행하거나 힙 데이터를 복사하는 작업을 포함할 수 있다. Clone에 대한 더 자세한 내용은 4장의 “Variables and Data Interacting with Clone”을 참고한다.

Clone을 파생(derive)하면 clone 메서드가 구현된다. 이 메서드는 전체 타입에 대해 구현될 때, 타입의 각 부분에 대해 clone을 호출한다. 따라서 Clone을 파생하려면 타입의 모든 필드나 값도 Clone을 구현해야 한다.

Clone이 필요한 예시 중 하나는 슬라이스에 to_vec 메서드를 호출할 때다. 슬라이스는 자신이 포함하는 타입 인스턴스를 소유하지 않지만, to_vec이 반환하는 벡터는 해당 인스턴스를 소유해야 한다. 그래서 to_vec은 각 항목에 대해 clone을 호출한다. 따라서 슬라이스에 저장된 타입은 Clone을 구현해야 한다.

Copy 트레이트는 스택에 저장된 비트만 복사함으로써 값을 복제할 수 있게 해준다. 여기서는 임의의 코드가 필요하지 않다. Copy에 대한 더 자세한 내용은 4장의 “Stack-Only Data: Copy”을 참고한다.

Copy 트레이트는 프로그래머가 메서드를 오버로드하거나 임의의 코드가 실행된다는 가정을 위반하지 않도록 아무런 메서드를 정의하지 않는다. 이렇게 함으로써 모든 프로그래머는 값 복사가 매우 빠르게 이루어진다는 가정을 할 수 있다.

Copy를 파생하려면 타입의 모든 부분이 Copy를 구현해야 한다. Copy를 구현하는 타입은 반드시 Clone도 구현해야 하는데, 이는 Copy를 구현하는 타입이 Copy와 동일한 작업을 수행하는 간단한 Clone 구현을 가지기 때문이다.

Copy 트레이트는 거의 필요하지 않다. Copy를 구현하는 타입은 최적화가 가능하며, 이는 clone을 호출할 필요가 없음을 의미한다. 따라서 코드가 더 간결해진다.

Copy로 할 수 있는 모든 작업은 Clone으로도 수행할 수 있지만, 코드가 더 느려지거나 일부 위치에서 clone을 사용해야 할 수 있다.

고정 크기 값으로 매핑하기 위한 Hash 트레이트

Hash 트레이트는 임의의 크기를 가진 타입의 인스턴스를 해시 함수를 사용해 고정된 크기의 값으로 매핑할 수 있게 해준다. Hash를 파생(derive)하면 hash 메서드가 구현된다. 파생된 hash 메서드의 구현은 타입의 각 부분에 대해 hash를 호출한 결과를 결합한다. 즉, 모든 필드나 값도 Hash를 구현해야 Hash를 파생할 수 있다.

Hash가 필요한 예시 중 하나는 HashMap<K, V>에서 키를 저장할 때다. 이를 통해 데이터를 효율적으로 저장할 수 있다.

기본값을 위한 Default 트레이트

Default 트레이트는 타입에 대한 기본값을 생성할 수 있게 해준다. Default를 파생(derive)하면 default 함수가 구현된다. 파생된 default 함수는 타입의 각 부분에 대해 default 함수를 호출한다. 즉, 타입의 모든 필드나 값도 Default를 구현해야 Default를 파생할 수 있다.

Default::default 함수는 5장에서 다룬 구조체 업데이트 문법과 함께 자주 사용된다. 구조체의 일부 필드를 커스터마이징한 후, 나머지 필드에 대해 기본값을 설정하고 사용하려면 ..Default::default()를 사용하면 된다.

예를 들어, Option<T> 인스턴스에서 unwrap_or_default 메서드를 사용할 때 Default 트레이트가 필요하다. Option<T>None인 경우, unwrap_or_default 메서드는 Option<T>에 저장된 타입 T에 대한 Default::default의 결과를 반환한다.

부록 D - 유용한 개발 도구

이 부록에서는 Rust 프로젝트에서 제공하는 유용한 개발 도구 몇 가지를 소개한다. 자동 포매팅, 경고 수정을 빠르게 적용하는 방법, 린터, 그리고 IDE와의 통합에 대해 살펴본다.

rustfmt로 자동 코드 포맷팅하기

rustfmt는 커뮤니티에서 정한 코드 스타일에 맞게 코드를 재포맷팅하는 도구이다. 많은 협업 프로젝트에서 rustfmt를 사용해 Rust 코드 작성 시 스타일 논쟁을 방지한다. 모든 개발자가 이 도구를 사용해 코드를 일관된 스타일로 포맷한다.

Rust 설치 시 기본적으로 rustfmt가 포함되어 있으므로, 시스템에 rustfmtcargo-fmt 명령어가 이미 존재할 것이다. 이 두 명령어는 rustccargo와 유사하게 동작한다. rustfmt는 세밀한 제어가 가능하고, cargo-fmt는 Cargo를 사용하는 프로젝트의 규칙을 이해한다. Cargo 프로젝트를 포맷하려면 다음 명령어를 실행한다:

$ cargo fmt

이 명령어를 실행하면 현재 크레이트의 모든 Rust 코드가 재포맷팅된다. 코드 스타일만 변경되고, 코드의 의미는 변하지 않는다.

이 명령어는 rustfmtcargo-fmt를 제공한다. 이는 Rust가 rustccargo를 제공하는 방식과 유사하다. Cargo 프로젝트를 포맷하려면 다음 명령어를 실행한다:

$ cargo fmt

이 명령어를 실행하면 현재 크레이트의 모든 Rust 코드가 재포맷팅된다. 코드 스타일만 변경되고, 코드의 의미는 변하지 않는다. rustfmt에 대한 더 자세한 정보는 공식 문서를 참고한다.

rustfix로 코드 수정하기

rustfix 도구는 Rust 설치 시 함께 포함되며, 문제를 해결할 수 있는 명확한 방법이 있는 컴파일러 경고를 자동으로 수정한다. 이전에 컴파일러 경고를 본 적이 있을 것이다. 예를 들어, 다음 코드를 살펴보자:

파일명: src/main.rs

fn main() {
    let mut x = 42;
    println!("{x}");
}

여기서 변수 x를 가변(mutable)으로 정의했지만, 실제로는 값을 변경하지 않는다. Rust는 이에 대해 다음과 같이 경고를 표시한다:

$ cargo build
   Compiling myprogram v0.1.0 (file:///projects/myprogram)
warning: variable does not need to be mutable
 --> src/main.rs:2:9
  |
2 |     let mut x = 0;
  |         ----^
  |         |
  |         help: remove this `mut`
  |
  = note: `#[warn(unused_mut)]` on by default

경고는 mut 키워드를 제거하라고 제안한다. 이 제안을 자동으로 적용하려면 cargo fix 명령어를 사용하면 된다:

$ cargo fix
    Checking myprogram v0.1.0 (file:///projects/myprogram)
      Fixing src/main.rs (1 fix)
    Finished dev [unoptimized + debuginfo] target(s) in 0.59s

다시 src/main.rs 파일을 확인해보면, cargo fix가 코드를 다음과 같이 변경한 것을 볼 수 있다:

파일명: src/main.rs

fn main() {
    let x = 42;
    println!("{x}");
}

이제 x 변수는 불변(immutable)이 되었고, 경고 메시지도 더 이상 나타나지 않는다.

cargo fix 명령어는 Rust의 다른 에디션(edition) 간에 코드를 전환할 때도 사용할 수 있다. 에디션에 대한 자세한 내용은 부록 E에서 다룬다.

Clippy로 더 많은 린트 적용하기

Clippy는 코드를 분석해 흔히 발생하는 실수를 잡아내고 Rust 코드를 개선할 수 있도록 도와주는 린트 도구 모음이다. Clippy는 표준 Rust 설치에 포함되어 있다.

Cargo 프로젝트에서 Clippy의 린트를 실행하려면 다음 명령어를 입력한다:

$ cargo clippy

예를 들어, 수학 상수인 파이(π)의 근사값을 사용하는 프로그램을 작성했다고 가정해 보자:

Filename: src/main.rs
fn main() {
    let x = 3.1415;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

이 프로젝트에서 cargo clippy를 실행하면 다음과 같은 에러가 발생한다:

error: approximate value of `f{32, 64}::consts::PI` found
 --> src/main.rs:2:13
  |
2 |     let x = 3.1415;
  |             ^^^^^^
  |
  = note: `#[deny(clippy::approx_constant)]` on by default
  = help: consider using the constant directly
  = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#approx_constant

이 에러는 Rust에서 이미 더 정확한 PI 상수가 정의되어 있음을 알려준다. 따라서 상수를 직접 사용하는 것이 더 정확하다. 이제 코드를 수정해 PI 상수를 사용하도록 변경한다. 다음 코드는 Clippy에서 에러나 경고를 발생시키지 않는다:

Filename: src/main.rs
fn main() {
    let x = std::f64::consts::PI;
    let r = 8.0;
    println!("the area of the circle is {}", x * r * r);
}

Clippy에 대한 더 많은 정보는 공식 문서를 참고한다.

rust-analyzer를 활용한 IDE 통합

Rust 커뮤니티는 IDE 통합을 위해 rust-analyzer 사용을 권장한다. 이 도구는 컴파일러 중심의 유틸리티 세트로, Language Server Protocol을 지원한다. 이 프로토콜은 IDE와 프로그래밍 언어가 서로 통신할 수 있도록 설계된 표준이다. 다양한 클라이언트가 rust-analyzer를 사용할 수 있으며, 예를 들어 Visual Studio Code용 Rust 분석기 플러그인이 있다.

rust-analyzer 프로젝트의 홈페이지를 방문해 설치 방법을 확인한 후, 사용 중인 IDE에 언어 서버 지원을 설치한다. 이를 통해 IDE는 자동 완성, 정의로 이동, 인라인 오류 표시 등의 기능을 활용할 수 있게 된다.

부록 E - 에디션

1장에서 cargo newCargo.toml 파일에 에디션에 대한 메타데이터를 추가하는 것을 살펴봤다. 이 부록에서는 그 의미에 대해 설명한다.

Rust 언어와 컴파일러는 6주 주기로 릴리스된다. 사용자는 지속적으로 새로운 기능을 사용할 수 있다. 다른 프로그래밍 언어는 덜 자주 더 큰 변경 사항을 릴리스하지만, Rust는 더 자주 작은 업데이트를 릴리스한다. 시간이 지나면 이러한 작은 변경 사항들이 누적된다. 하지만 릴리스마다 “Rust 1.10과 Rust 1.31 사이에 Rust가 많이 변했다!“라고 말하기는 어렵다.

약 3년마다 Rust 팀은 새로운 Rust _에디션_을 출시한다. 각 에디션은 도입된 기능들을 명확한 패키지로 묶고, 최신 문서와 도구를 제공한다. 새로운 에디션은 일반적인 6주 릴리스 프로세스의 일부로 출시된다.

에디션은 사람들마다 다른 목적으로 사용된다:

  • 활발한 Rust 사용자에게는 새로운 에디션이 점진적인 변경 사항을 이해하기 쉬운 패키지로 제공한다.
  • 비사용자에게는 새로운 에디션이 주요 발전이 이루어졌음을 알려주며, Rust를 다시 살펴볼 가치가 있음을 시사한다.
  • Rust 개발자에게는 새로운 에디션이 프로젝트 전체를 위한 집결점 역할을 한다.

이 글을 쓰는 시점에서 네 가지 Rust 에디션이 있다: Rust 2015, Rust 2018, Rust 2021, 그리고 Rust 2024. 이 책은 Rust 2024 에디션의 관용구를 사용해 작성되었다.

Cargo.toml 파일의 edition 키는 컴파일러가 코드를 컴파일할 때 사용할 에디션을 나타낸다. 키가 존재하지 않으면 Rust는 하위 호환성을 위해 2015를 기본값으로 사용한다.

각 프로젝트는 기본 2015 에디션 이외의 에디션을 선택할 수 있다. 에디션은 새로운 키워드 추가와 같이 코드와 충돌할 수 있는 호환되지 않는 변경 사항을 포함할 수 있다. 하지만 해당 변경 사항을 선택하지 않으면 Rust 컴파일러 버전을 업그레이드해도 코드는 계속 컴파일된다.

모든 Rust 컴파일러 버전은 해당 컴파일러가 릴리스되기 전에 존재했던 모든 에디션을 지원하며, 지원되는 모든 에디션의 크레이트를 함께 링크할 수 있다. 에디션 변경은 컴파일러가 코드를 처음 파싱하는 방식에만 영향을 미친다. 따라서 Rust 2015를 사용하고 있고 의존성 중 하나가 Rust 2018을 사용한다면, 프로젝트는 컴파일되고 해당 의존성을 사용할 수 있다. 반대로 프로젝트가 Rust 2018을 사용하고 의존성이 Rust 2015를 사용하는 경우도 마찬가지로 작동한다.

명확히 하자면: 대부분의 기능은 모든 에디션에서 사용할 수 있다. 모든 Rust 에디션을 사용하는 개발자들은 새로운 안정 버전이 릴리스될 때마다 계속해서 개선 사항을 확인할 수 있다. 하지만 주로 새로운 키워드가 추가되는 경우와 같이 일부 새로운 기능은 이후 에디션에서만 사용할 수 있다. 이러한 기능을 활용하려면 에디션을 전환해야 한다.

더 자세한 내용은 에디션 가이드를 참조하라. 이 가이드는 에디션 간 차이점을 열거하고 cargo fix를 통해 코드를 새로운 에디션으로 자동 업그레이드하는 방법을 설명한다.

부록 F: 책의 번역본

영어 이외의 언어로 된 리소스를 찾는다면 다음 목록을 참고한다. 대부분의 번역 작업은 아직 진행 중이다. Translations 라벨을 확인해 도움을 주거나 새로운 번역을 알려줄 수 있다!

부록 G - Rust의 개발 과정과 “Nightly Rust”

이 부록은 Rust가 어떻게 만들어지는지, 그리고 그 과정이 Rust 개발자에게 어떤 영향을 미치는지 설명한다.

안정성과 발전의 조화

Rust는 언어로서 코드의 안정성을 매우 중요하게 여긴다. Rust는 여러분이 의지할 수 있는 튼튼한 기반이 되길 원하며, 지속적으로 변화한다면 이를 달성할 수 없다. 동시에, 새로운 기능을 실험하지 못한다면 중요한 결함을 출시 후에야 발견하게 될 수 있고, 그때는 이미 변경할 수 없는 상황이 된다.

이 문제에 대한 Rust의 해결책은 “정체 없이 안정적으로” 유지하는 것이다. 여기서 핵심 원칙은 다음과 같다: 안정적인 Rust의 새 버전으로 업그레이드할 때 두려워할 필요가 없어야 한다. 각 업그레이드는 번거로움 없이 이루어져야 하며, 동시에 새로운 기능, 더 적은 버그, 그리고 더 빠른 컴파일 시간을 제공해야 한다.

출발 준비 완료! 릴리스 채널과 트레인 모델

Rust 개발은 트레인 스케줄 방식으로 운영된다. 모든 개발은 Rust 저장소의 master 브랜치에서 진행되며, 이는 Cisco IOS와 같은 소프트웨어 프로젝트에서 사용하는 소프트웨어 릴리스 트레인 모델을 따른다. Rust에는 세 가지 _릴리스 채널_이 있다:

  • Nightly
  • Beta
  • Stable

대부분의 Rust 개발자는 주로 안정적인 Stable 채널을 사용하지만, 실험적인 새로운 기능을 시도해보고 싶은 개발자는 Nightly나 Beta 채널을 사용할 수 있다.

개발 및 릴리스 프로세스가 어떻게 동작하는지 예를 들어보자. Rust 팀이 Rust 1.5 버전을 준비하고 있다고 가정하자. 이 릴리스는 2015년 12월에 출시되었지만, 여기서는 실제 버전 번호를 사용해 설명한다. Rust에 새로운 기능이 추가되면, 그 변경 사항은 master 브랜치에 커밋된다. 매일 밤, 새로운 Nightly 버전이 자동으로 생성된다. 즉, 매일이 릴리스 날이며, 이는 릴리스 인프라에 의해 자동으로 처리된다. 시간이 지나면 다음과 같이 매일 Nightly 버전이 출시된다:

nightly: * - - * - - *

6주마다 새로운 릴리스를 준비한다! 이때 Rust 저장소의 beta 브랜치가 Nightly에서 사용하는 master 브랜치에서 분기된다. 이제 두 가지 릴리스가 존재한다:

nightly: * - - * - - *
                     |
beta:                *

대부분의 Rust 사용자는 Beta 릴리스를 적극적으로 사용하지는 않지만, CI 시스템에서 Beta 버전을 테스트하여 잠재적인 회귀(regression) 문제를 발견하는 데 도움을 준다. 이 과정에서도 여전히 매일 Nightly 릴리스가 생성된다:

nightly: * - - * - - * - - * - - *
                     |
beta:                *

회귀 문제가 발견되었다고 가정해보자. 안정적인 Stable 릴리스에 문제가 포함되기 전에 Beta 릴리스를 테스트할 시간이 있었으니 다행이다! 문제는 master 브랜치에 수정이 적용되어 Nightly 버전이 수정되고, 이 수정 사항은 beta 브랜치에 백포트되어 새로운 Beta 버전이 출시된다:

nightly: * - - * - - * - - * - - * - - *
                     |
beta:                * - - - - - - - - *

첫 Beta 버전이 생성된 지 6주가 지나면 Stable 릴리스가 준비된다! 이때 stable 브랜치는 beta 브랜치에서 분기된다:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |
beta:                * - - - - - - - - *
                                       |
stable:                                *

이제 Rust 1.5 버전이 완성되었다! 하지만 한 가지 잊은 것이 있다. 6주가 지났으므로 다음 버전인 Rust 1.6의 Beta 버전도 준비해야 한다. 따라서 stable 브랜치가 beta 브랜치에서 분기된 후, 다음 beta 브랜치는 다시 nightly 브랜치에서 분기된다:

nightly: * - - * - - * - - * - - * - - * - * - *
                     |                         |
beta:                * - - - - - - - - *       *
                                       |
stable:                                *

이를 “트레인 모델“이라고 부르는 이유는 6주마다 릴리스가 “역을 떠나기” 때문이다. 하지만 Stable 릴리스로 도착하기 전에 Beta 채널을 거쳐야 한다.

Rust는 6주 주기로 정기적으로 릴리스된다. 한 Rust 릴리스의 날짜를 알면, 다음 릴리스 날짜도 예측할 수 있다. 6주 후에 출시되기 때문이다. 6주마다 정기적으로 릴리스가 이루어지는 장점은 다음 릴리스가 곧 출시된다는 점이다. 특정 릴리스에 기능이 포함되지 못하더라도 걱정할 필요가 없다. 곧 다음 릴리스가 출시되기 때문이다! 이는 릴리스 마감일에 미완성 기능을 급하게 추가하려는 압력을 줄이는 데 도움이 된다.

이 프로세스 덕분에 항상 다음 Rust 빌드를 확인하고 업그레이드가 얼마나 쉬운지 직접 검증할 수 있다. Beta 릴리스가 예상대로 동작하지 않으면 팀에 보고하여 다음 Stable 릴리스 전에 수정을 요청할 수 있다. Beta 릴리스에서 문제가 발생하는 경우는 비교적 드물지만, rustc도 소프트웨어이기 때문에 버그가 존재할 수 있다.

유지보수 기간

Rust 프로젝트는 가장 최신의 안정화 버전을 지원한다. 새로운 안정화 버전이 출시되면, 이전 버전은 지원 종료(EOL) 상태가 된다. 즉, 각 버전은 6주 동안 지원된다.

불안정한 기능

이 릴리스 모델에는 한 가지 더 고려해야 할 점이 있다: 바로 불안정한 기능이다. Rust는 “기능 플래그(feature flags)“라는 기법을 사용해 특정 릴리스에서 어떤 기능을 활성화할지 결정한다. 새로운 기능이 활발히 개발 중이라면, 해당 기능은 master 브랜치에 먼저 적용되며, 따라서 nightly 버전에서 사용할 수 있지만, 기능 플래그 뒤에 숨겨져 있다. 만약 사용자가 개발 중인 기능을 시험해보고 싶다면, Rust의 nightly 릴리스를 사용하면서 소스 코드에 해당 플래그를 명시적으로 추가해야 한다.

반면, Rust의 베타나 안정판 릴리스를 사용 중이라면 기능 플래그를 전혀 사용할 수 없다. 이는 새로운 기능을 영구적으로 안정화하기 전에 실제로 사용해볼 수 있는 핵심 메커니즘이다. 최신 기술을 가장 먼저 시도하고 싶은 사용자는 이를 선택할 수 있고, 안정적인 환경을 원하는 사용자는 안정판을 고수하며 코드가 깨지지 않을 것이라는 확신을 가질 수 있다. 이렇게 해서 Rust는 정체 없이 안정성을 유지한다.

이 책에서는 안정화된 기능에 대한 정보만 다룬다. 개발 중인 기능은 계속 변경되며, 책이 작성된 시점과 안정판에 적용된 시점 사이에 차이가 발생할 수 있기 때문이다. nightly 전용 기능에 대한 문서는 온라인에서 찾아볼 수 있다.

Rustup과 Rust Nightly의 역할

Rustup은 전역 또는 프로젝트 단위로 Rust의 다양한 릴리스 채널 간 전환을 쉽게 해준다. 기본적으로 안정적인 Rust 버전이 설치된다. 예를 들어, Nightly 버전을 설치하려면 다음 명령어를 실행한다:

$ rustup toolchain install nightly

rustup을 사용하면 설치된 모든 툴체인(Rust 릴리스 및 관련 컴포넌트)을 확인할 수도 있다. 다음은 작성자의 Windows 컴퓨터에서의 예제다:

> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc

여기서 볼 수 있듯이, 안정적인 툴체인이 기본값이다. 대부분의 Rust 사용자는 주로 안정적인 버전을 사용한다. 하지만 특정 프로젝트에서는 최신 기능이 필요할 수 있다. 이 경우 rustup override를 사용해 해당 프로젝트 디렉토리에서 Nightly 툴체인을 설정할 수 있다:

$ cd ~/projects/needs-nightly
$ rustup override set nightly

이제 ~/projects/needs-nightly 디렉토리 내에서 rustccargo를 호출할 때마다 rustup이 기본 안정적인 버전 대신 Nightly Rust를 사용하도록 보장한다. 여러 Rust 프로젝트를 관리할 때 유용한 기능이다!

RFC 프로세스와 팀 구조

그렇다면 이러한 새로운 기능에 대해 어떻게 알 수 있을까? Rust의 개발 모델은 _RFC(Request For Comments) 프로세스_를 따른다. Rust를 개선하고 싶다면 RFC라는 제안서를 작성할 수 있다.

누구나 Rust를 개선하기 위해 RFC를 작성할 수 있으며, 이 제안은 다양한 주제별 하위 팀으로 구성된 Rust 팀에 의해 검토되고 논의된다. Rust 웹사이트에서 팀 목록을 확인할 수 있으며, 이 목록에는 언어 설계, 컴파일러 구현, 인프라, 문서화 등 프로젝트의 각 영역을 담당하는 팀이 포함되어 있다. 적절한 팀이 제안서와 의견을 검토한 후, 자신들의 의견을 작성하고, 결국 기능을 수락할지 거부할지에 대한 합의를 이룬다.

기능이 수락되면 Rust 저장소에 이슈가 열리고, 누군가가 이를 구현할 수 있다. 기능을 구현하는 사람은 처음에 제안한 사람과 다를 가능성이 크다! 구현이 준비되면, “불안정한 기능” 섹션에서 설명한 것처럼 master 브랜치에 기능 게이트 뒤에 추가된다.

일정 기간 동안, nightly 버전을 사용하는 Rust 개발자들이 새로운 기능을 시도해 볼 기회를 가진 후, 팀 멤버들은 이 기능에 대해 논의하고, nightly 버전에서 어떻게 작동했는지 평가한 후, 이를 안정적인 Rust에 포함시킬지 여부를 결정한다. 만약 앞으로 나아가기로 결정되면, 기능 게이트가 제거되고, 이제 이 기능은 안정적인 것으로 간주된다! 이 기능은 새로운 안정적인 Rust 릴리스에 포함된다.