Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

러스트 프로그래밍 언어

Steve Klabnik, Carol Nichols, Chris Krycho 공저, 그리고 러스트 커뮤니티의 기여

Note: 이 사이트는 비공식 한국어 번역본입니다. 원문은 rust-lang/book 저장소의 The Rust Programming Language 이며, 이 번역본은 OpenAI Codex 를 사용해 번역하고 정적 사이트 형태로 구성했습니다. 공식 Rust 프로젝트와는 별개이며, Rust 프로젝트의 승인이나 보증을 받지 않았습니다.

이 판본은 모든 프로젝트의 Cargo.toml 파일에 edition = "2024" 를 설정하여 Rust 2024 에디션 관용구를 사용하고, Rust 1.90.0(2025-09-18 출시) 이상을 사용한다고 가정합니다. 설치하거나 업데이트하는 방법은 1장 “설치” 절을 참고하고, 에디션 정보는 부록 E를 참고하세요.

HTML 형식은 https://doc.rust-lang.org/stable/book/ 에서 온라인으로 읽을 수 있고, rustup 으로 설치한 Rust에는 오프라인 문서도 포함되어 있습니다. rustup doc --book 명령으로 열 수 있습니다.

커뮤니티에서 만든 여러 번역본도 제공됩니다.

이 책은 No Starch Press에서 종이책과 전자책으로도 구매할 수 있습니다.

🚨 더 상호작용적인 학습 경험을 원하나요? 퀴즈, 하이라이트, 시각화 등 여러 기능이 포함된 다른 버전의 러스트 책도 있습니다: https://rust-book.cs.brown.edu

머리말

러스트 프로그래밍 언어는 짧은 몇 년 사이에 아주 먼 길을 걸어왔습니다. 소수의 초기 열성 팬들이 만들고 키워 낸 작은 공동체에서 출발해, 이제는 전 세계에서 가장 사랑받고 가장 수요가 높은 프로그래밍 언어 중 하나가 되었습니다. 돌이켜 보면, 러스트의 힘과 가능성이 사람들의 시선을 끌고 시스템 프로그래밍 분야에 자리를 잡으리라는 점은 어느 정도 필연적이었습니다. 그러나 오픈 소스 커뮤니티 전반에 퍼져 나간 전 세계적 관심의 성장과 혁신, 그리고 그것이 산업 전반의 대규모 도입을 촉진하리라는 점은 결코 당연한 일이 아니었습니다.

오늘날 러스트에 대한 관심과 채택이 폭발적으로 늘어난 이유를 설명하는 것은 어렵지 않습니다. 메모리 안전성에, 빠른 성능에, 친절한 컴파일러에, 뛰어난 툴링까지 갖춘 언어를 누가 마다하겠습니까? 지금 여러분이 보고 있는 러스트는 시스템 프로그래밍에 대한 수년간의 연구와 활기차고 열정적인 커뮤니티의 실용적 지혜가 결합된 결과입니다. 이 언어는 분명한 목적 아래 세심하게 설계되었고, 개발자가 안전하고 빠르며 신뢰할 수 있는 코드를 더 쉽게 작성할 수 있도록 도와주는 도구를 제공합니다.

하지만 러스트를 진정 특별하게 만드는 것은, 사용자 여러분이 목표를 이룰 수 있도록 힘을 실어 주겠다는 뿌리 깊은 철학입니다. 이 언어는 여러분의 성공을 바라며, 그런 역량 부여의 원칙은 러스트를 만들고 유지하고 알리는 커뮤니티의 중심부를 관통하고 있습니다. 이 결정판 교재의 이전 판 이후로도 러스트는 더욱 발전하여, 진정으로 세계적이고 신뢰받는 언어가 되었습니다. 러스트 프로젝트는 이제 러스트 재단의 탄탄한 지원을 받고 있으며, 재단은 러스트가 안전하고 안정적이며 지속 가능하도록 보장하는 핵심 이니셔티브에도 투자하고 있습니다.

이번 러스트 프로그래밍 언어 판은 언어가 세월에 따라 발전해 온 내용을 반영하고, 새롭고 가치 있는 정보를 담은 포괄적인 개정판입니다. 하지만 이것은 단순히 문법과 라이브러리를 설명하는 안내서가 아닙니다. 품질, 성능, 그리고 사려 깊은 설계를 중시하는 커뮤니티로 여러분을 초대하는 책이기도 합니다. 처음으로 러스트를 탐험해 보려는 숙련 개발자든, 이미 러스트를 익혀 더 실력을 다듬고 싶은 경험 많은 러스타시안이든, 이번 판은 누구에게나 건질 것을 제공합니다.

러스트의 여정은 협업과 학습, 그리고 반복적인 개선의 역사였습니다. 언어와 생태계의 성장은 그 뒤에 있는 활기차고 다양한 커뮤니티를 있는 그대로 반영합니다. 핵심 언어 설계자부터 가볍게 기여한 참여자들까지, 수천 명의 개발자가 보탠 기여가 러스트를 이렇게 독특하고 강력한 도구로 만들었습니다. 여러분이 이 책을 집어 드는 순간, 단지 새로운 프로그래밍 언어를 배우는 것이 아닙니다. 소프트웨어를 더 좋고, 더 안전하고, 더 즐겁게 만드는 흐름에 동참하게 되는 것입니다.

러스트 커뮤니티에 오신 것을 환영합니다!

  • 러스트 재단 사무총장, Bec Rumbul

들어가며

Note: 이 책의 이번 판은 No Starch Press에서 종이책과 전자책으로 출간된 The Rust Programming Language와 같은 내용입니다.

러스트 프로그래밍 언어 에 오신 것을 환영합니다. 이 책은 러스트를 소개하는 입문서입니다. 러스트 프로그래밍 언어는 더 빠르고 더 신뢰할 수 있는 소프트웨어를 작성하도록 도와줍니다. 보통 프로그래밍 언어를 설계할 때는 고수준의 사용 편의성과 저수준의 세밀한 제어가 서로 충돌합니다. 러스트는 이 충돌에 정면으로 도전합니다. 강력한 기술적 역량과 훌륭한 개발 경험의 균형을 통해, 러스트는 메모리 사용량 같은 저수준 세부 사항을 제어하면서도 그에 따라붙기 마련이던 번거로움은 크게 줄여 줍니다.

러스트는 누구를 위한 언어인가

러스트는 여러 이유로 다양한 사람들에게 잘 맞는 언어입니다. 그중에서도 특히 중요한 몇 부류를 살펴보겠습니다.

개발자 팀

러스트는 시스템 프로그래밍 지식 수준이 서로 다른 대규모 개발자 팀이 협업할 때 생산적인 도구임이 입증되고 있습니다. 저수준 코드는 다양한 미묘한 버그에 취약한데, 대부분의 다른 언어에서는 이런 버그를 광범위한 테스트와 숙련 개발자의 세심한 코드 리뷰를 통해서만 잡아낼 수 있습니다. 러스트에서는 컴파일러가 문지기 역할을 수행하여, 동시성 버그를 포함한 이런 잡기 어려운 버그가 있는 코드는 아예 컴파일하지 않습니다. 팀은 컴파일러와 함께 일함으로써 버그를 쫓는 데 시간을 쓰기 보다 프로그램의 논리에 집중할 수 있습니다.

러스트는 시스템 프로그래밍 세계에 현대적인 개발 도구도 함께 가져왔습니다.

  • 함께 제공되는 의존성 관리자이자 빌드 도구인 Cargo는 의존성 추가, 컴파일, 관리 작업을 러스트 생태계 전반에서 일관되고 수월하게 만들어 줍니다.
  • rustfmt 포매터는 여러 개발자 사이에서 일관된 코딩 스타일을 보장합니다.
  • Rust Language Server는 코드 자동 완성과 인라인 오류 메시지 같은 통합 개발 환경 (IDE) 연동을 가능하게 합니다.

이런 도구들과 러스트 생태계의 다른 도구들을 활용하면, 개발자는 시스템 수준의 코드를 작성하면서도 높은 생산성을 유지할 수 있습니다.

학생

러스트는 학생과 시스템 개념을 배우고 싶은 사람들에게도 잘 맞습니다. 많은 사람이 러스트를 사용하면서 운영체제 개발 같은 주제를 배웠습니다. 커뮤니티는 매우 친절하며 학생들의 질문에 기꺼이 답해 줍니다. 러스트 팀은 이 책과 같은 노력을 통해, 특히 프로그래밍을 처음 접하는 사람들에게 시스템 개념을 더 쉽게 접할 수 있게 만들고자 합니다.

기업

대기업과 소기업을 포함한 수백 개의 회사가 커맨드라인 도구, 웹 서비스, DevOps 도구, 임베디드 장치, 오디오·비디오 분석 및 트랜스코딩, 암호화폐, 생물정보학, 검색 엔진, 사물 인터넷 애플리케이션, 머신 러닝, 심지어 Firefox 웹 브라우저의 핵심 일부에 이르기까지 다양한 분야에서 러스트를 실제 운영 환경에 사용하고 있습니다.

오픈 소스 개발자

러스트는 러스트 언어 자체와 커뮤니티, 개발 도구, 라이브러리를 함께 만들어 가고 싶은 사람들을 위한 언어이기도 합니다. 러스트 언어에 기여해 주신다면 언제든 환영합니다.

속도와 안정성을 중시하는 사람

러스트는 언어에서 속도와 안정성을 모두 원하는 사람을 위한 언어입니다. 여기서 말하는 속도는 러스트 코드가 얼마나 빠르게 실행되는가뿐 아니라, 러스트가 얼마나 빠르게 프로그램을 작성하게 해 주는가도 포함합니다. 러스트 컴파일러의 검사는 기능 추가와 리팩터링 과정에서도 안정성을 보장합니다. 이런 검사가 없는 언어의 낡고 깨지기 쉬운 레거시 코드와는 대조적이며, 그런 코드들은 개발자가 수정 자체를 두려워하는 경우가 많습니다. 러스트는 수동으로 작성한 저수준 코드만큼 빠르게 컴파일되는 고수준 기능, 즉 zero-cost abstraction을 지향함으로써 안전한 코드가 곧 빠른 코드가 되도록 노력합니다.

러스트는 여기서 언급한 사람들 외에도 훨씬 더 많은 사용자를 지원하고자 합니다. 지금 소개한 부류는 그중 일부의 대표적인 이해관계자일 뿐입니다. 전체적으로 보면, 러스트의 가장 큰 야망은 프로그래머가 수십 년 동안 당연하게 받아들여 온 절충을 없애는 데 있습니다. 안전성 그리고 생산성, 속도 그리고 사용 편의성을 함께 제공하겠다는 것입니다. 러스트를 직접 써 보고, 이런 선택이 여러분에게도 잘 맞는지 확인해 보세요.

이 책은 누구를 위한 책인가

이 책은 여러분이 다른 프로그래밍 언어로 코드를 작성해 본 적은 있다고 가정하지만, 그 언어가 무엇인지는 가정하지 않습니다. 다양한 배경을 가진 독자가 폭넓게 접근할 수 있도록 내용을 구성하려 했습니다. 프로그래밍이 무엇인지, 혹은 프로그래밍을 어떻게 사고해야 하는지에 대해서는 많은 시간을 쓰지 않습니다. 만약 여러분이 프로그래밍을 완전히 처음 접한다면, 프로그래밍 자체를 소개하는 입문서를 먼저 읽는 편이 더 나을 수 있습니다.

이 책을 사용하는 방법

대체로 이 책은 앞에서 뒤로 순서대로 읽는 것을 전제로 합니다. 뒤의 장들은 앞선 장들의 개념을 바탕으로 하며, 앞 장에서 어떤 주제를 자세히 다루지 않더라도 뒤 장에 가서 다시 그 주제를 깊게 다루는 경우가 있습니다.

이 책에는 두 종류의 장이 있습니다. 개념 장과 프로젝트 장입니다. 개념 장에서는 러스트의 특정 측면을 배우고, 프로젝트 장에서는 지금까지 배운 내용을 적용해 작은 프로그램을 함께 만들어 봅니다. 2장, 12장, 21장은 프로젝트 장이고, 나머지는 개념 장입니다.

1장에서는 러스트 설치 방법, “Hello, world!” 프로그램 작성 방법, 그리고 러스트의 패키지 관리자이자 빌드 도구인 Cargo 사용법을 설명합니다. 2장은 러스트로 프로그램을 작성하는 실습형 입문 장으로, 숫자 맞히기 게임을 만들어 봅니다. 여기서는 개념을 높은 수준에서 다루고, 자세한 내용은 뒤 장들에서 보강합니다. 손을 바로 움직여 보고 싶다면 2장이 좋은 출발점입니다. 반대로 모든 세부를 익힌 뒤 다음으로 넘어가고 싶은 꼼꼼한 학습자라면, 다른 프로그래밍 언어와 비슷한 러스트 기능을 설명하는 3장으로 먼저 가도 됩니다. 그런 다음 배운 내용을 프로젝트에 적용해 보고 싶을 때 2장으로 돌아오면 됩니다.

4장에서는 러스트의 소유권 시스템을 배웁니다. 5장에서는 구조체와 메서드를 다루고, 6장에서는 열거형, match 식, if letlet...else 제어 흐름 구문을 다룹니다. 여러분은 구조체와 열거형을 사용해 자신만의 타입을 만들게 됩니다.

7장에서는 러스트의 모듈 시스템과, 코드와 공개 애플리케이션 프로그래밍 인터페이스(API)를 구성하기 위한 공개 범위 규칙을 배웁니다. 8장에서는 표준 라이브러리가 제공하는 대표적인 컬렉션 자료구조인 벡터, 문자열, 해시 맵을 다룹니다. 9장에서는 러스트의 에러 처리 철학과 기법을 살펴봅니다.

10장에서는 여러 타입에 적용되는 코드를 정의할 수 있게 해 주는 제네릭, 트레이트, 라이프타임을 깊이 있게 다룹니다. 11장은 테스트에 관한 내용으로, 러스트의 안전성 보장이 있더라도 프로그램의 논리가 올바른지 확인하려면 테스트가 필요하다는 점을 보여 줍니다. 12장에서는 파일 안의 텍스트를 검색하는 grep 명령줄 도구 기능의 일부를 직접 구현해 봅니다. 여기서는 앞 장에서 다룬 많은 개념을 활용합니다.

13장에서는 함수형 프로그래밍 언어에서 온 러스트 기능인 클로저와 반복자를 살펴봅니다. 14장에서는 Cargo를 더 깊이 들여다보고, 라이브러리를 다른 사람과 공유할 때의 모범 사례를 이야기합니다. 15장에서는 표준 라이브러리가 제공하는 스마트 포인터와, 그 기능을 가능하게 하는 트레이트를 다룹니다.

16장에서는 다양한 동시성 프로그래밍 모델을 둘러보고, 러스트가 여러 스레드를 겁 없이 다룰 수 있게 어떻게 도와주는지 설명합니다. 17장에서는 그 내용을 바탕으로 러스트의 async, await 문법과 태스크, 퓨처, 스트림, 그리고 그것들이 가능하게 하는 경량 동시성 모델을 탐구합니다.

18장에서는 여러분이 익숙할 수 있는 객체 지향 프로그래밍 원칙과 러스트식 관용 표현을 비교해 봅니다. 19장은 패턴과 패턴 매칭에 대한 참고서 같은 장으로, 러스트 프로그램 전반에서 아이디어를 강력하게 표현하는 방법을 다룹니다. 20장에는 unsafe Rust, 매크로, 그리고 라이프타임, 트레이트, 타입, 함수, 클로저에 대한 추가 논의를 포함해 다양한 고급 주제가 담겨 있습니다.

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

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

이 책을 읽는 데 정답은 없습니다. 앞부분을 건너뛰고 싶다면 그렇게 해도 됩니다. 다만 헷갈리는 부분이 생기면 앞 장으로 다시 돌아와야 할 수도 있습니다. 결국 여러분에게 가장 잘 맞는 방식으로 읽으면 됩니다.

러스트를 배우는 과정에서 매우 중요한 부분 중 하나는 컴파일러가 보여 주는 오류 메시지를 읽는 법을 익히는 것입니다. 이 메시지들은 여러분을 올바르게 동작하는 코드로 이끌어 줍니다. 그래서 이 책에는 컴파일되지 않는 예제와, 그 상황에서 컴파일러가 실제로 보여 줄 오류 메시지를 함께 많이 실었습니다. 눈에 띄는 예제를 아무거나 입력해서 실행하면 컴파일되지 않을 수도 있다는 점을 기억하세요! 실행하려는 예제가 원래 오류를 내도록 의도된 것인지 주변 설명을 꼭 읽어 보아야 합니다. 대부분의 경우 컴파일되지 않는 코드에 대해서는 올바른 버전까지 함께 안내할 것입니다. Ferris는 원래 동작하지 않도록 의도된 코드를 구분하는 데도 도움을 줍니다.

Ferris의미
물음표와 함께 있는 Ferris이 코드는 컴파일되지 않습니다!
두 손을 들어 올린 Ferris이 코드는 패닉이 발생합니다!
한쪽 집게발을 들고 어깨를 으쓱하는 Ferris이 코드는 원하는 동작을 만들어 내지 못합니다.

대부분의 경우, 컴파일되지 않는 코드는 올바른 버전까지 함께 안내할 것입니다.

소스 코드

이 책을 생성하는 데 사용된 원본 파일은 GitHub에서 찾을 수 있습니다.

시작하기

이제 러스트 여정을 시작해 봅시다! 배울 것은 많지만, 모든 여정은 어디선가 시작합니다. 이 장에서는 다음 내용을 다룹니다.

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

설치

설치

첫 번째 단계는 Rust를 설치하는 것입니다. Rust 버전과 관련 도구를 관리하는 명령줄 도구인 rustup 을 통해 Rust를 내려받겠습니다. 다운로드하려면 인터넷 연결이 필요합니다.

Note: 어떤 이유로든 rustup 을 쓰고 싶지 않다면, 다른 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!

또한 linker 도 필요합니다. 이것은 Rust가 컴파일 결과물을 하나의 파일로 연결할 때 사용하는 프로그램입니다. 아마 이미 설치되어 있을 가능성이 큽니다. linker 관련 오류가 발생한다면 보통 linker를 포함하는 C 컴파일러를 설치해야 합니다. C 컴파일러는 일부 널리 쓰이는 Rust 패키지가 C 코드에 의존하기 때문에 그런 경우에도 유용합니다.

macOS에서는 다음 명령으로 C 컴파일러를 설치할 수 있습니다.

$ xcode-select --install

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

Windows에 rustup 설치하기

Windows에서는 https://www.rust-lang.org/tools/install 으로 가서 Rust 설치 안내를 따르면 됩니다. 설치 과정 중 어느 시점에 Visual Studio를 설치하라는 안내가 나옵니다. 이것은 프로그램을 컴파일할 때 필요한 linker와 네이티브 라이브러리를 제공합니다. 이 단계에서 더 도움이 필요하다면 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가 동작하지 않는다면 도움을 받을 수 있는 곳이 여럿 있습니다. 다른 Rustacean(우리가 스스로를 부르는 다소 장난스러운 별명)과 어떻게 연락할 수 있는지는 커뮤니티 페이지에서 확인할 수 있습니다.

업데이트와 제거

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!

Hello, World!

이제 Rust를 설치했으니 첫 번째 Rust 프로그램을 작성해 볼 차례입니다. 새로운 언어를 배울 때는 화면에 Hello, world! 라는 문구를 출력하는 작은 프로그램을 작성하는 것이 전통이므로, 여기서도 같은 방식으로 시작하겠습니다!

Note: 이 책은 독자가 명령줄에 기본적으로 익숙하다고 가정합니다. 러스트는 어떤 편집기나 도구를 써야 하는지, 코드가 어디에 있어야 하는지에 대해 특별한 요구를 하지 않습니다. 따라서 명령줄 대신 IDE를 쓰고 싶다면, 좋아하는 IDE를 자유롭게 사용해도 됩니다. 요즘 많은 IDE가 어느 정도 Rust를 지원하므로, 자세한 내용은 해당 IDE 문서를 확인하세요. Rust 팀은 rust-analyzer 를 통해 훌륭한 IDE 지원을 제공하는 데 집중해 왔습니다. 자세한 내용은 부록 D 를 참고하세요.

프로젝트 디렉터리 준비

먼저 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 파일을 열고, 목록 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 명령을 입력합니다.

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

운영체제에 상관없이 터미널에 Hello, world! 문자열이 출력되어야 합니다. 이 출력이 보이지 않는다면 설치 절의 “문제 해결” 부분으로 돌아가 도움을 받는 방법을 확인하세요.

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

Rust 프로그램의 구조

이 “Hello, world!” 프로그램을 자세히 살펴봅시다. 먼저 첫 번째 조각은 다음과 같습니다.

fn main() {

}

이 줄들은 main 이라는 이름의 함수를 정의합니다. main 함수는 특별합니다. 모든 실행 가능한 Rust 프로그램에서 가장 먼저 실행되는 코드이기 때문입니다. 여기서 첫 줄은 매개변수가 없고 아무것도 반환하지 않는 main 함수를 선언합니다. 매개변수가 있다면 괄호(()) 안에 들어갑니다.

함수 본문은 {} 로 감싸여 있습니다. 러스트는 모든 함수 본문을 중괄호로 감싸도록 요구합니다. 여는 중괄호를 함수 선언과 같은 줄에 두고, 그 앞에 공백 하나를 두는 것이 좋은 스타일입니다.

Note: 여러 Rust 프로젝트에서 표준 스타일을 유지하고 싶다면 rustfmt 라는 자동 포매터 도구를 사용해 코드를 특정 스타일로 맞출 수 있습니다(rustfmt 에 대해서는 부록 D에서 더 다룹니다). Rust 팀은 이 도구를 rustc 와 마찬가지로 표준 Rust 배포판에 포함시켰으므로, 이미 컴퓨터에 설치되어 있을 것입니다.

main 함수 본문에는 다음 코드가 들어 있습니다.

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

이 한 줄이 이 작은 프로그램의 모든 일을 합니다. 화면에 텍스트를 출력하는 것입니다. 여기에는 주목할 중요한 세 가지가 있습니다.

첫째, println! 은 Rust 매크로를 호출합니다. 만약 함수였다면 println (! 없음) 형태로 적었을 것입니다. Rust 매크로는 코드를 생성하는 코드를 작성하여 Rust 문법을 확장하는 방법이며, 이에 대해서는 20장 에서 더 자세히 다룹니다. 지금은 ! 가 붙으면 일반 함수가 아니라 매크로를 호출한다는 점, 그리고 매크로는 함수와 같은 규칙만 따르지는 않는다는 점만 알면 충분합니다.

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

셋째, 줄 끝에 세미콜론(;)이 있습니다. 이것은 현재 식이 끝났고 다음 식이 시작될 준비가 되었음을 뜻합니다. Rust 코드의 대부분의 줄은 세미콜론으로 끝납니다.

컴파일과 실행

방금 새로 만든 프로그램을 실행했으니, 이 과정의 각 단계를 살펴봅시다.

Rust 프로그램을 실행하기 전에, rustc 명령에 소스 파일 이름을 넘겨 Rust 컴파일러로 먼저 컴파일해야 합니다.

$ 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 %= the /B option says to only show the file names =%
main.exe
main.pdb
main.rs

여기에는 .rs 확장자를 가진 소스 코드 파일, 실행 파일(Windows에서는 main.exe, 그 외 플랫폼에서는 main), 그리고 Windows를 사용할 경우 .pdb 확장자를 가진 디버깅 정보 파일이 표시됩니다. 이제 main 또는 main.exe 파일을 다음과 같이 실행합니다.

$ ./main # or .\main on Windows

여러분의 main.rs 가 “Hello, world!” 프로그램이라면, 이 줄은 터미널에 Hello, world! 를 출력합니다.

Ruby, Python, JavaScript 같은 동적 언어에 더 익숙하다면, 프로그램을 컴파일하는 단계와 실행하는 단계를 따로 나누는 것이 낯설 수 있습니다. 러스트는 ahead-of-time compiled 언어입니다. 즉, 프로그램을 컴파일한 뒤 생성된 실행 파일을 다른 사람에게 전달하면, 그 사람은 Rust가 설치되어 있지 않아도 실행할 수 있습니다. 반면 .rb, .py, .js 파일을 누군가에게 주면, 그 사람은 각각 Ruby, Python, JavaScript 구현체를 설치하고 있어야 합니다. 대신 그런 언어에서는 프로그램을 컴파일하고 실행하는 데 명령 하나면 충분합니다. 언어 설계에는 언제나 절충이 따릅니다.

단순한 프로그램은 rustc 로 컴파일하는 것만으로도 충분하지만, 프로젝트가 커질수록 여러 옵션을 관리하고 코드를 쉽게 공유할 방법이 필요해집니다. 다음 절에서는 실제 현실의 Rust 프로그램을 작성할 때 큰 도움이 되는 Cargo 도구를 소개하겠습니다.

Hello, Cargo!

Hello, Cargo!

Cargo는 러스트의 빌드 시스템이자 패키지 관리자입니다. 대부분의 Rustacean은 Rust 프로젝트를 관리할 때 이 도구를 사용합니다. Cargo가 코드 빌드, 코드가 의존하는 라이브러리 다운로드, 그리고 그 라이브러리들의 빌드 같은 일을 많이 대신 처리해 주기 때문입니다. (코드에 필요한 라이브러리를 의존성(dependencies) 이라고 부릅니다.)

우리가 지금까지 작성한 것 같은 가장 단순한 Rust 프로그램은 의존성이 없습니다. “Hello, world!” 프로젝트를 Cargo로 만들었다면, Cargo의 기능 중 코드 빌드를 담당하는 부분만 사용했을 것입니다. 하지만 더 복잡한 Rust 프로그램을 작성하게 되면 의존성이 추가되기 시작하고, 프로젝트를 Cargo로 시작해 두면 그런 의존성을 훨씬 쉽게 추가할 수 있습니다.

대부분의 Rust 프로젝트가 Cargo를 사용하기 때문에, 이 책의 나머지 부분도 여러분이 Cargo를 사용한다고 가정합니다. Cargo는 “설치” 절에서 설명한 공식 설치 방법으로 Rust를 설치했다면 함께 설치됩니다. 다른 방식으로 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 파일과, 그 안에 main.rs 파일이 들어 있는 src 디렉터리입니다.

또한 .gitignore 파일과 함께 새 Git 저장소도 초기화합니다. 이미 존재하는 Git 저장소 안에서 cargo new 를 실행하면 Git 관련 파일은 생성되지 않습니다. 이 동작을 바꾸고 싶다면 cargo new --vcs=git 를 사용할 수 있습니다.

Note: Git은 널리 쓰이는 버전 관리 시스템입니다. --vcs 플래그를 사용하면 cargo new 가 다른 버전 관리 시스템을 사용하도록 하거나, 아예 버전 관리 시스템을 사용하지 않도록 바꿀 수 있습니다. 가능한 선택지는 cargo new --help 로 확인하세요.

원하는 텍스트 에디터로 Cargo.toml 을 열어 보세요. 목록 1-2와 비슷한 모습일 것입니다.

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

[dependencies]
Listing 1-2: cargo new 가 생성한 Cargo.toml 내용

이 파일은 Cargo의 설정 형식인 TOML(Tom’s Obvious, Minimal Language) 형식으로 작성되어 있습니다.

첫 줄의 [package] 는 뒤따르는 항목들이 패키지 설정임을 나타내는 섹션 헤딩입니다. 앞으로 이 파일에 더 많은 정보를 추가하면서 다른 섹션도 늘어나게 됩니다.

다음 세 줄은 Cargo가 프로그램을 컴파일하는 데 필요한 설정, 즉 이름, 버전, 사용할 Rust 에디션을 지정합니다. edition 키는 부록 E에서 다룹니다.

마지막 줄인 [dependencies] 는 프로젝트의 의존성을 나열하는 섹션의 시작입니다. Rust에서는 코드 패키지를 crate 라고 부릅니다. 이 프로젝트에서는 다른 crate가 필요 없지만, 2장의 첫 번째 프로젝트에서는 필요하므로 그때 이 의존성 섹션을 사용하게 됩니다.

이제 src/main.rs 를 열어 봅시다.

Filename: src/main.rs

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

Cargo는 목록 1-1에서 우리가 직접 쓴 것과 똑같은 “Hello, world!” 프로그램을 자동으로 만들어 주었습니다! 지금까지 우리가 만든 프로젝트와 Cargo가 생성한 프로젝트의 차이는, Cargo는 코드를 src 디렉터리에 넣고 최상위 디렉터리에 Cargo.toml 설정 파일을 만든다는 점입니다.

Cargo는 소스 파일이 src 디렉터리 안에 있기를 기대합니다. 프로젝트 최상위 디렉터리는 README 파일, 라이선스 정보, 설정 파일, 그리고 코드와 직접 관련 없는 다른 것들을 두는 곳입니다. Cargo를 사용하면 프로젝트를 체계적으로 정리할 수 있습니다. 모든 것에는 자리가 있고, 모든 것이 제자리에 있게 됩니다.

“Hello, world!” 프로젝트처럼 Cargo를 사용하지 않고 시작한 프로젝트라도, 나중에 Cargo를 사용하는 프로젝트로 바꿀 수 있습니다. 프로젝트 코드를 src 디렉터리 안으로 옮기고 적절한 Cargo.toml 파일을 만들면 됩니다. 그 파일을 쉽게 만드는 방법 중 하나는 cargo init 을 실행하는 것입니다. 그러면 자동으로 생성해 줍니다.

Cargo 프로젝트 빌드하고 실행하기

이제 “Hello, world!” 프로그램을 Cargo로 빌드하고 실행할 때 무엇이 다른지 살펴봅시다! 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 (Windows에서는 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 run 은 먼저 cargo build 를 실행하고 다시 바이너리 전체 경로를 입력해야 하는 방식보다 훨씬 편리합니다. 그래서 대부분의 개발자는 cargo run 을 사용합니다.

이번에는 Cargo가 hello_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 는 프로젝트가 아직 잘 컴파일되는지를 더 빨리 알려 줍니다. 그래서 많은 Rustacean이 프로그램을 작성하는 동안 주기적으로 cargo check 를 실행해 컴파일 상태를 확인하고, 실행 파일이 필요해졌을 때 cargo build 를 실행합니다.

지금까지 Cargo에 대해 배운 내용을 정리해 봅시다.

  • cargo new 로 프로젝트를 만들 수 있습니다.
  • cargo build 로 프로젝트를 빌드할 수 있습니다.
  • cargo run 으로 빌드와 실행을 한 번에 할 수 있습니다.
  • cargo check 로 바이너리를 만들지 않고 오류만 확인할 수 있습니다.
  • Cargo는 빌드 결과를 코드와 같은 디렉터리가 아니라 target/debug 디렉터리에 저장합니다.

Cargo를 사용할 때의 또 다른 장점은, 어떤 운영체제에서 작업하든 명령이 같다는 것입니다. 따라서 이 시점부터는 Linux·macOS와 Windows를 따로 나눠 설명하지 않겠습니다.

릴리스용 빌드

프로젝트가 마침내 배포할 준비가 되었다면, cargo build --release 로 최적화를 켜고 컴파일할 수 있습니다. 이 명령은 실행 파일을 target/debug 대신 target/release 에 만듭니다. 최적화를 켜면 Rust 코드 실행 속도는 빨라지지만, 컴파일 시간은 더 길어집니다. 그래서 프로필이 두 가지 존재합니다. 자주 빠르게 다시 빌드하고 싶은 개발용 프로필 하나, 사용자에게 전달할 최종 프로그램을 가능한 빠르게 실행되도록 빌드하는 용도의 프로필 하나입니다. 코드 실행 시간을 벤치마크할 때는 반드시 cargo build --release 로 빌드하고 target/release 안의 실행 파일로 측정하세요.

Cargo의 관례 활용하기

단순한 프로젝트에서는 Cargo가 rustc 를 직접 쓰는 것에 비해 큰 차이를 주지 않을 수 있습니다. 하지만 프로그램이 복잡해질수록 Cargo의 진가가 드러납니다. 프로그램이 여러 파일로 커지거나 의존성이 필요해지면, 빌드 조율을 Cargo에 맡기는 편이 훨씬 쉽습니다.

hello_cargo 프로젝트는 단순하지만, 앞으로 러스트를 사용하는 동안 계속 쓰게 될 실제 도구의 상당 부분을 이미 사용하고 있습니다. 실제로 이미 존재하는 프로젝트에서 작업할 때도, 다음처럼 Git으로 코드를 받아오고 해당 프로젝트 디렉터리로 이동한 뒤 빌드하면 됩니다.

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

Cargo에 대해 더 알고 싶다면 공식 문서를 확인하세요.

정리

여러분은 이미 러스트 여정을 훌륭하게 시작했습니다! 이 장에서는 다음 내용을 배웠습니다.

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

이제 좀 더 실질적인 프로그램을 만들어 보면서 Rust 코드를 읽고 쓰는 감각을 익히기 좋은 시점입니다. 그래서 2장에서는 숫자 맞히기 게임 프로그램을 만들어 봅니다. 만약 일반적인 프로그래밍 개념이 Rust에서 어떻게 동작하는지부터 배우고 싶다면 3장을 먼저 읽은 뒤 다시 2장으로 돌아오면 됩니다.

숫자 맞히기 게임 프로그래밍하기

직접 손을 움직여 보는 프로젝트를 함께 진행하면서 러스트를 시작해 봅시다! 이 장에서는 실제 프로그램 안에서 몇 가지 흔한 러스트 개념을 사용하는 방법을 보여 주며, 여러분을 러스트에 입문시킵니다. let, match, 메서드, 연관 함수, 외부 크레이트 등 여러 가지를 배우게 됩니다! 이어지는 장들에서는 이 아이디어들을 더 자세히 살펴볼 것입니다. 이 장에서는 우선 기초를 직접 연습해 보겠습니다.

우리는 초보자용 고전 프로그래밍 문제인 숫자 맞히기 게임을 구현할 것입니다. 동작 방식은 이렇습니다. 프로그램은 1부터 100 사이의 임의의 정수를 하나 생성합니다. 그런 다음 플레이어에게 추측값을 입력하라고 요청합니다. 추측값이 입력되면, 프로그램은 그 값이 너무 작은지 너무 큰지 알려 줍니다. 정답을 맞히면 게임은 축하 메시지를 출력하고 종료합니다.

새 프로젝트 준비하기

새 프로젝트를 만들려면 1장에서 만든 projects 디렉터리로 가서, 다음과 같이 Cargo를 사용해 새 프로젝트를 만드세요.

$ cargo new guessing_game
$ cd guessing_game

첫 번째 명령인 cargo new 는 프로젝트 이름(guessing_game)을 첫 번째 인수로 받습니다. 두 번째 명령은 새 프로젝트 디렉터리로 이동합니다.

생성된 Cargo.toml 파일을 살펴보세요.

파일명: Cargo.toml

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

[dependencies]

1장에서 보았듯이 cargo new 는 “Hello, world!” 프로그램도 함께 생성합니다. src/main.rs 파일도 확인해 봅시다.

파일명: src/main.rs

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

이제 cargo run 명령을 사용해 이 “Hello, world!” 프로그램을 컴파일하고 같은 단계에서 실행해 봅시다.

$ 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 파일을 다시 여세요. 앞으로 작성할 코드는 전부 이 파일 안에 넣게 됩니다.

추측값 처리하기

숫자 맞히기 게임 프로그램의 첫 번째 부분은 사용자 입력을 요청하고, 그 입력을 처리하며, 입력이 우리가 기대한 형식인지 확인하는 것입니다. 우선은 플레이어가 추측값을 입력할 수 있게만 해 봅시다. 목록 2-1의 코드를 src/main.rs 에 입력하세요.

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}");
}

기본적으로 러스트는 표준 라이브러리 안의 몇몇 항목을 모든 프로그램의 스코프로 가져옵니다. 이 집합을 prelude 라고 하며, 그 전체 내용은 표준 라이브러리 문서에서 볼 수 있습니다.

사용하고 싶은 타입이 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 에 바인딩합니다. 러스트에서 변수는 기본적으로 불변입니다. 즉 한 번 값을 넣으면 그 값은 바뀌지 않습니다. 이 개념은 3장의 “변수와 가변성” 절에서 자세히 다룹니다. 변수를 가변으로 만들려면 변수 이름 앞에 mut 를 붙입니다.

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

Note: // 문법은 해당 줄 끝까지 이어지는 주석을 시작합니다. 러스트는 주석 안의 모든 내용을 무시합니다. 주석은 3장에서 더 자세히 설명합니다.

숫자 맞히기 게임 프로그램으로 돌아오면, 이제 let mut guessguess 라는 이름의 가변 변수를 도입한다는 것을 알 수 있습니다. 등호(=)는 지금 어떤 값을 변수에 바인딩하겠다는 뜻입니다. 등호 오른쪽에 있는 값은 String::new 호출 결과이며, 이 함수는 새로운 String 인스턴스를 반환합니다. String 은 표준 라이브러리가 제공하는 문자열 타입으로, 크기를 늘릴 수 있는 UTF-8 인코딩 텍스트입니다.

::new 에서 :: 문법은 newString 타입의 연관 함수라는 뜻입니다. 연관 함수(associated function) 는 어떤 타입에 구현된 함수입니다. 여기서는 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 의 전체 역할은 사용자가 표준 입력에 입력한 내용을 우리가 넘긴 문자열의 끝에 덧붙이는 것(기존 내용을 덮어쓰지 않음)이므로, 그 문자열을 인수로 전달합니다. 또한 메서드가 문자열 내용을 바꿔야 하므로 문자열 인수는 가변이어야 합니다.

& 는 이 인수가 참조(reference) 임을 나타냅니다. 참조는 데이터를 메모리에 여러 번 복사하지 않고도 코드의 여러 부분이 하나의 데이터를 접근하게 해 주는 방법입니다. 참조는 복잡한 기능이지만, 러스트의 큰 장점 중 하나는 참조를 안전하고 쉽게 사용할 수 있다는 점입니다. 이 프로그램을 완성하는 데 지금 당장 그 세부를 많이 알 필요는 없습니다. 지금은 변수와 마찬가지로 참조도 기본적으로 불변이라는 점만 알면 충분합니다. 그래서 가변으로 만들기 위해 &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열거형 enumeration, 흔히 enum 이라고 부르는 타입입니다. enum은 여러 가능한 상태 중 하나가 될 수 있는 타입입니다. 우리는 각 가능한 상태를 variant 라고 부릅니다.

6장에서 enum을 더 자세히 다루겠습니다. 이런 Result 타입의 목적은 에러 처리 정보를 표현하는 것입니다.

Result 의 variant는 OkErr 입니다. Ok variant는 작업이 성공했음을 뜻하고 성공적으로 만들어진 값을 담고 있습니다. Err variant는 작업이 실패했음을 뜻하고, 어떻게 혹은 왜 실패했는지에 대한 정보를 담습니다.

다른 모든 타입의 값처럼 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

러스트는 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 사이의 임의의 수를 사용하겠습니다. 러스트는 아직 표준 라이브러리에 난수 생성 기능을 포함하고 있지 않습니다. 하지만 러스트 팀은 그 기능을 제공하는 rand crate를 제공합니다.

크레이트로 기능 늘리기

crate는 러스트 소스 코드 파일들의 모음이라는 점을 기억하세요. 지금까지 우리가 만들고 있던 프로젝트는 실행 가능한 바이너리 크레이트(binary crate) 입니다. 반면 rand crate는 라이브러리 크레이트(library crate) 로, 다른 프로그램에서 사용하도록 만든 코드를 담고 있으며 그 자체로 실행되지는 않습니다.

외부 크레이트를 다룰 때야말로 Cargo의 진가가 드러납니다. rand 를 사용하는 코드를 쓰기 전에, Cargo.toml 파일을 수정해 rand crate를 의존성으로 추가해야 합니다. 지금 파일을 열고, Cargo가 만들어 둔 [dependencies] 섹션 헤더 아래 맨 아래에 다음 줄을 추가하세요. 여기 적힌 것처럼 rand 와 이 버전 번호를 정확히 사용해야 합니다. 그렇지 않으면 이 튜토리얼의 코드 예제가 동작하지 않을 수 있습니다.

파일명: Cargo.toml

[dependencies]
rand = "0.8.5"

Cargo.toml 파일에서 어떤 헤더 뒤에 나오는 내용은 다음 섹션이 시작될 때까지 모두 그 섹션에 속합니다. [dependencies] 에서는 프로젝트가 어떤 외부 크레이트에 의존하는지, 그리고 그 크레이트의 어떤 버전을 요구하는지를 Cargo에게 알려 줍니다. 여기서는 rand crate를 시맨틱 버전 지정자 0.8.5 로 지정합니다. Cargo는 시맨틱 버저닝(줄여서 SemVer 라고도 부름)을 이해하는데, 이것은 버전 번호를 표기하는 표준입니다. 0.8.5 라는 지정자는 사실 ^0.8.5 의 축약형이며, 이는 0.8.5 이상이면서 0.9.0 미만인 모든 버전을 뜻합니다.

Cargo는 이런 버전들이 0.8.5와 공개 API 호환성을 가진다고 간주합니다. 이 지정은 이 장의 코드와 여전히 컴파일되는 최신 패치 릴리스를 받게 해 줍니다. 반면 0.9.0 이상 버전은 아래 예제와 같은 API를 유지한다고 보장되지 않습니다.

이제 코드는 바꾸지 말고, 목록 2-2처럼 프로젝트를 빌드해 봅시다.

$ 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 crate를 의존성으로 추가한 뒤 cargo build 를 실행했을 때의 출력

여러분의 화면에는 다른 버전 번호가 보일 수도 있습니다(하지만 SemVer 덕분에 모두 이 코드와 호환됩니다). 또 운영체제에 따라 줄 구성이 조금 다를 수 있고, 줄 순서도 달라질 수 있습니다.

외부 의존성을 포함하면 Cargo는 그 의존성이 필요로 하는 것들의 최신 버전을 registry 에서 가져옵니다. registry는 Crates.io의 데이터 사본입니다. Crates.io는 러스트 생태계 사람들이 자신의 오픈 소스 러스트 프로젝트를 올려 다른 사람이 사용할 수 있게 하는 곳입니다.

registry를 갱신한 뒤 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에는 여러분이나 다른 사람이 코드를 빌드할 때마다 같은 결과물을 다시 만들 수 있도록 보장하는 메커니즘이 있습니다. Cargo는 여러분이 달리 지시하지 않는 한, 현재 지정된 의존성 버전만 사용합니다. 예를 들어 다음 주에 rand crate의 0.8.6 버전이 나왔다고 해 봅시다. 그 버전에는 중요한 버그 수정이 들어 있지만, 동시에 여러분의 코드를 깨뜨리는 회귀도 포함되어 있다고 합시다. 이런 상황을 다루기 위해 러스트는 여러분이 처음 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 crate가 0.8.6과 0.999.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.999.0)

Cargo는 0.999.0 릴리스를 무시합니다. 이 시점에서는 여러분의 Cargo.lock 파일도 바뀌어, 현재 사용 중인 rand crate 버전이 0.8.6이라는 내용이 기록될 것입니다. 만약 rand 0.999.0이나 0.999.x 계열을 쓰고 싶다면, Cargo.toml 파일을 다음과 같이 바꿔야 합니다(하지만 아래 예제는 rand 0.8을 전제로 하므로 실제로는 바꾸지 마세요).

[dependencies]
rand = "0.999.0"

그다음 cargo build 를 실행하면 Cargo는 사용 가능한 크레이트 registry를 갱신하고, 새로 지정한 버전에 따라 rand 요구 사항을 다시 계산합니다.

Cargo그 생태계에 관해서는 할 말이 훨씬 더 많고, 14장에서 다시 다룰 것입니다. 하지만 지금은 이 정도면 충분합니다. Cargo 덕분에 라이브러리 재사용이 매우 쉬워지므로, Rustacean은 여러 패키지를 조합해 더 작은 프로젝트들을 만들어 낼 수 있습니다.

임의의 숫자 생성하기

이제 rand 를 사용해 맞혀야 할 숫자를 생성해 봅시다. 다음 단계는 목록 2-3처럼 src/main.rs 를 수정하는 것입니다.

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 을 써야 합니다.

Note: 어떤 트레이트를 사용해야 하고, 어떤 메서드와 함수를 호출해야 하는지를 그냥 저절로 알게 되는 것은 아닙니다. 그래서 각 크레이트는 사용 방법을 설명하는 문서를 제공합니다. Cargo의 또 다른 멋진 기능은 cargo doc --open 명령을 실행하면 모든 의존성이 제공하는 문서를 로컬에서 빌드해 브라우저로 열어 준다는 점입니다. 예를 들어 rand crate의 다른 기능도 궁금하다면 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 타입 역시 enum이며, Less, Greater, Equal variant를 가집니다. 이 셋은 두 값을 비교했을 때 가능한 세 가지 결과입니다.

그다음 아래쪽에 Ordering 타입을 사용하는 다섯 줄을 추가합니다. cmp 메서드는 두 값을 비교하며, 비교 가능한 어떤 것에도 호출할 수 있습니다. 비교 대상에 대한 참조를 인수로 받는데, 여기서는 guesssecret_number 를 비교합니다. 그리고 use 문으로 스코프에 가져온 Ordering enum의 variant 중 하나를 반환합니다. 우리는 match 식을 사용해, guesssecret_numbercmp 로 비교한 결과 어떤 Ordering variant가 반환되었는지에 따라 다음에 무엇을 할지 결정합니다.

match 식은 여러 arm 으로 이루어집니다. 각 arm은 맞춰 볼 패턴 과, 그 값이 그 패턴에 맞을 때 실행할 코드로 구성됩니다. 러스트는 match 에 주어진 값을 가져와 각 arm의 패턴과 차례대로 비교합니다. 패턴과 match 구문은 아주 강력한 러스트 기능입니다. 코드가 마주칠 수 있는 다양한 상황을 표현할 수 있고, 그 모든 상황을 처리하도록 보장해 주기 때문입니다. 이 기능은 각각 6장과 19장에서 자세히 다룹니다.

여기서 사용한 match 식을 예로 들어봅시다. 사용자가 50을 추측했고, 이번에 무작위로 생성된 비밀 숫자가 38이라고 해 봅시다.

코드가 50과 38을 비교하면, 50이 38보다 크기 때문에 cmp 메서드는 Ordering::Greater 를 반환합니다. match 식은 Ordering::Greater 값을 받고, 각 arm의 패턴을 검사하기 시작합니다. 첫 번째 arm의 패턴인 Ordering::Less 를 보면 Ordering::Greater 와 맞지 않기 때문에 그 arm의 코드는 무시하고 다음 arm으로 이동합니다. 다음 arm의 패턴은 Ordering::Greater 이고, 이것은 Ordering::Greater 와 정확히 일치합니다! 따라서 이 arm에 연결된 코드가 실행되어 화면에 Too big! 를 출력합니다. match 식은 첫 번째로 성공한 매치 이후에 끝나므로, 이 경우 마지막 arm은 검사하지 않습니다.

하지만 목록 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/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/cmp.rs:979: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

오류의 핵심은 타입이 맞지 않는다(mismatched types) 는 것입니다. 러스트는 강력한 정적 타입 시스템을 가집니다. 하지만 타입 추론도 지원합니다. 예를 들어 우리가 let mut guess = String::new() 라고 썼을 때, 러스트는 guessString 이어야 한다는 것을 추론했고 타입을 직접 적으라고 요구하지 않았습니다. 반면 secret_number 는 숫자 타입입니다. 1부터 100 사이 값을 가질 수 있는 러스트의 숫자 타입은 여럿 있습니다. 32비트 정수 i32, 부호 없는 32비트 정수 u32, 64비트 정수 i64 등입니다. 별도의 정보가 없으면 러스트는 기본적으로 i32 를 사용하며, 다른 곳의 타입 정보가 영향을 주지 않는 한 secret_number 의 타입도 i32 입니다. 오류가 나는 이유는 러스트가 문자열과 숫자 타입을 서로 비교할 수 없기 때문입니다.

결국 우리가 원하는 것은 프로그램이 입력으로 읽어들인 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 라는 변수가 있지 않았나요? 그렇습니다. 하지만 다행히 러스트는 이전 guess 값을 새로운 값으로 가릴 수 있게 해 줍니다. 이것을 shadowing 이라고 하며, 예를 들어 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 라고 씁니다. guess 뒤의 콜론(:)은 변수의 타입을 명시하겠다는 뜻입니다. 러스트에는 여러 기본 숫자 타입이 있는데, 여기 나오는 u32 는 부호 없는 32비트 정수입니다. 작은 양의 정수를 다룰 때 좋은 기본 선택입니다. 다른 숫자 타입은 3장에서 배웁니다.

또한 이 예제 프로그램에서는 u32 타입 주석과 secret_number 와의 비교가 함께 있기 때문에, 러스트는 secret_number 역시 u32 여야 한다고 추론합니다. 이렇게 하면 비교가 같은 타입 두 값 사이에서 이루어집니다.

parse 메서드는 논리적으로 숫자로 바꿀 수 있는 문자에 대해서만 동작하므로 쉽게 오류가 날 수 있습니다. 예를 들어 문자열이 A👍% 라면 이를 숫자로 바꿀 방법이 없습니다. 실패할 가능성이 있으므로 parse 역시 read_line 과 마찬가지로 Result 타입을 반환합니다([앞의 “Result 타입으로 발생 가능한 실패 처리하기”] (#handling-potential-failure-with-result) 참조). 따라서 여기서도 같은 방식으로 다시 expect 메서드를 사용합니다. parse 가 문자열에서 숫자를 만들지 못해 Err variant를 반환하면, expect 호출은 게임을 중단시키고 우리가 준 메시지를 출력합니다. 반대로 parse 가 문자열을 숫자로 성공적으로 변환하면 ResultOk variant를 반환하고, expect 는 우리가 원하는 숫자를 그 Ok 값에서 꺼내 반환합니다.

이제 프로그램을 실행해 봅시다.

$ 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!"),
        }
    }
}

보시다시피 추측 입력 프롬프트부터 그 뒤의 모든 코드를 루프 안으로 옮겼습니다. 루프 안의 줄들은 각각 공백 네 칸씩 더 들여쓰는 것을 잊지 말고, 프로그램을 다시 실행해 보세요. 이제 프로그램은 영원히 다음 추측을 계속 요청합니다. 그런데 이로 인해 새로운 문제가 생깁니다. 사용자가 게임을 끝낼 수 없게 보입니다!

물론 사용자는 키보드 단축키 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 의 마지막 부분이기 때문입니다.

잘못된 입력 처리하기

이제 게임 동작을 한 단계 더 다듬어 봅시다. 사용자가 숫자가 아닌 값을 입력했을 때 프로그램을 크래시시키는 대신, 그런 입력은 무시하고 계속 추측하게 만들겠습니다. 이를 위해 guessString 에서 u32 로 변환하는 줄을 목록 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 값은 첫 번째 arm의 패턴과 일치하고, match 식은 parse 가 만든 값을 Ok 안에서 꺼내 그 num 값 자체를 돌려줍니다. 이 숫자는 우리가 새로 만드는 guess 변수에 바로 들어갑니다.

반대로 parse 가 문자열을 숫자로 바꾸지 못하면, 에러에 대한 자세한 정보를 담은 Err 값을 반환합니다. 이 Err 값은 첫 번째 arm의 Ok(num) 패턴과는 맞지 않지만, 두 번째 arm의 Err(_) 패턴과는 맞습니다. 밑줄 _ 은 모든 값을 받아들이는 패턴입니다. 여기서는 안에 어떤 정보가 들어 있든 모든 Err 값을 다 받아들이겠다는 뜻입니다. 따라서 프로그램은 두 번째 arm의 코드인 continue 를 실행합니다. 이것은 프로그램에게 loop 의 다음 반복으로 가서 다시 추측값을 물어보라고 지시합니다. 즉, 결과적으로 프로그램은 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! 을 삭제합시다. 최종 코드는 목록 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: 완성된 숫자 맞히기 게임 코드

이제 여러분은 숫자 맞히기 게임을 성공적으로 만들었습니다. 축하합니다!

정리

이 프로젝트는 많은 새로운 러스트 개념을 직접 손으로 익히는 방식으로 소개해 주었습니다. let, match, 함수, 외부 크레이트 사용법 등 여러 가지를 다뤘습니다. 다음 몇 장에서 이 개념들을 더 자세히 배우게 됩니다. 3장에서는 대부분의 프로그래밍 언어에 공통으로 있는 변수, 데이터 타입, 함수 같은 개념을 다루고, 그것들을 러스트에서 어떻게 사용하는지 보여 줍니다. 4장에서는 러스트를 다른 언어와 구별해 주는 소유권을 탐구합니다. 5장에서는 구조체와 메서드 문법을 다루고, 6장에서는 enum이 어떻게 동작하는지 설명합니다.

일반적인 프로그래밍 개념

이 장에서는 거의 모든 프로그래밍 언어에 등장하는 개념들과, 그것들이 러스트에서 어떻게 동작하는지를 다룹니다. 많은 프로그래밍 언어는 핵심 부분에서 상당히 많은 공통점을 가지고 있습니다. 이 장에서 소개하는 개념들 중 러스트만의 고유한 것은 없지만, 러스트의 맥락 속에서 그것들을 설명하고 사용하는 관례도 함께 살펴보겠습니다.

구체적으로는 변수, 기본 타입, 함수, 주석, 제어 흐름을 배우게 됩니다. 이런 기초는 모든 러스트 프로그램에 등장하며, 초반에 익혀 두면 앞으로의 학습을 지탱해 줄 단단한 기반이 됩니다.

키워드

러스트 언어에는 다른 언어와 마찬가지로, 언어 자체에서만 사용할 수 있도록 예약된 키워드(keyword) 집합이 있습니다. 따라서 이런 단어들은 변수나 함수 이름으로 사용할 수 없다는 점을 기억하세요. 대부분의 키워드는 특별한 의미를 가지며, 러스트 프로그램에서 여러 작업을 수행할 때 직접 사용하게 됩니다. 일부는 현재 특별한 기능이 연결되어 있지 않지만, 미래에 추가될 수 있는 기능을 위해 미리 예약되어 있습니다. 키워드 목록은 부록 A에서 볼 수 있습니다.

변수와 가변성

변수와 가변성

“변수로 값 저장하기” 절에서 언급했듯이, 변수는 기본적으로 불변입니다. 이것은 러스트가 제공하는 안전성과 쉬운 동시성을 최대한 활용하는 방식으로 코드를 작성하도록 살짝 밀어 주는 여러 장치 중 하나입니다. 물론 여전히 변수를 가변으로 만들 수도 있습니다. 러스트가 왜 불변성을 선호하도록 유도하는지, 그리고 어떤 경우에는 왜 그 기본 선택에서 벗어나고 싶을 수 있는지를 살펴봅시다.

변수가 불변이라면, 어떤 값이 이름에 한 번 바인딩되고 나면 그 값을 바꿀 수 없습니다. 이를 확인해 보기 위해 projects 디렉터리 안에 cargo new variables 를 사용해 variables 라는 새 프로젝트를 만들어 봅시다.

그런 다음 새 variables 디렉터리에서 src/main.rs 를 열고, 코드를 다음과 같이 바꾸세요. 아직은 이 코드는 컴파일되지 않습니다.

Filename: 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

이 예제는 컴파일러가 프로그램의 오류를 어떻게 찾아주는지를 보여 줍니다. 컴파일러 오류는 답답할 수 있지만, 사실 그것은 아직 프로그램이 여러분이 원하는 일을 안전하게 하지 못하고 있다는 뜻일 뿐입니다. 여러분이 형편없는 프로그래머라는 뜻은 절대로 아닙니다! 경험 많은 Rustacean도 여전히 컴파일러 오류를 만납니다.

여러분이 cannot assign twice to immutable variable `x` 라는 오류 메시지를 받은 이유는, 불변 변수 x 에 두 번째 값을 대입하려 했기 때문입니다.

불변으로 지정된 값을 바꾸려 할 때 컴파일 시점 오류가 나는 것은 중요합니다. 바로 이 상황이 버그로 이어질 수 있기 때문입니다. 코드의 한 부분은 어떤 값이 절대 바뀌지 않는다고 가정하고 동작하는데, 다른 부분이 그 값을 바꿔 버리면 첫 번째 부분의 코드는 설계한 대로 동작하지 않을 수 있습니다. 이런 종류의 버그는 나중에 원인을 추적하기가 어렵고, 특히 두 번째 코드 조각이 값을 가끔만 바꿀 때 더 그렇습니다. 러스트 컴파일러는 여러분이 어떤 값이 바뀌지 않는다고 선언하면 정말로 바뀌지 않도록 보장해 주므로, 그 상태를 머릿속으로 따로 추적할 필요가 없습니다. 덕분에 코드에 대해 추론하기가 더 쉬워집니다.

하지만 가변성은 매우 유용할 수 있고, 코드를 더 편하게 작성하게 해 주기도 합니다. 변수는 기본적으로 불변이지만, 2장 에서 했듯이 변수 이름 앞에 mut 를 붙이면 가변으로 만들 수 있습니다. mut 를 추가하는 것은 이 변수의 값이 코드의 다른 부분에서 바뀔 것이라는 의도를 미래의 독자에게 전달하는 역할도 합니다.

예를 들어 src/main.rs 를 다음처럼 바꿔 봅시다.

Filename: 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 으로 바꿀 수 있습니다. 결국 가변성을 사용할지 말지는 여러분의 선택이며, 그 상황에서 무엇이 가장 명확한지에 따라 달라집니다.

상수 선언하기

불변 변수와 마찬가지로 상수(constants) 도 이름에 바인딩된 뒤 바뀌지 않는 값입니다. 하지만 상수와 변수 사이에는 몇 가지 차이가 있습니다.

첫째, 상수에는 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(이 프로그램에서 계산하고 싶은 시간 수)의 결과로 설정됩니다. 러스트에서 상수 이름은 모두 대문자로 쓰고 단어 사이는 밑줄로 구분하는 것이 관례입니다. 컴파일러는 컴파일 시점에 제한된 연산 집합을 평가할 수 있으므로, 이 상수를 그냥 10,800 으로 쓰는 대신 더 이해하고 검증하기 쉬운 형태로 적을 수 있습니다. 상수를 선언할 때 어떤 연산을 사용할 수 있는지는 Rust Reference의 상수 평가 절을 참고하세요.

상수는 선언된 스코프 안에서 프로그램이 실행되는 전체 시간 동안 유효합니다. 이런 성질 때문에 상수는 애플리케이션 도메인에서 프로그램의 여러 부분이 알아야 하는 값, 예를 들면 게임 플레이어가 얻을 수 있는 최대 점수나 빛의 속도 같은 값을 표현할 때 유용합니다.

프로그램 전반에서 쓰이는 하드코딩된 값을 상수로 이름 붙여 두면, 미래의 유지보수자가 그 값의 의미를 이해하는 데 도움이 됩니다. 또한 나중에 그 값을 바꿔야 할 때 코드의 단 한 곳만 수정하면 된다는 점도 장점입니다.

섀도잉

2장의 숫자 맞히기 게임 튜토리얼에서 본 것처럼, 이전 변수와 같은 이름으로 새 변수를 선언할 수 있습니다. Rustacean은 이때 첫 번째 변수가 두 번째 변수에 의해 shadowed 되었다고 말합니다. 즉, 변수 이름을 사용할 때 컴파일러가 보게 되는 것은 두 번째 변수라는 뜻입니다. 결과적으로 두 번째 변수는 첫 번째 변수를 가려 버리고, 다시 다른 변수에 가려지거나 스코프가 끝날 때까지 그 이름에 대한 모든 사용을 자신이 차지합니다. 섀도잉은 다음처럼 같은 변수 이름을 다시 쓰고 let 키워드를 반복해서 사용하면 됩니다.

Filename: 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}");
}

이 프로그램은 먼저 x 를 값 5 에 바인딩합니다. 그런 다음 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_strspaces_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

이제 변수가 어떻게 동작하는지 살펴봤으니, 변수가 가질 수 있는 더 많은 데이터 타입을 살펴봅시다.

데이터 타입

데이터 타입

러스트의 모든 값은 특정한 데이터 타입(data type) 을 가집니다. 데이터 타입은 어떤 종류의 데이터가 지정되었는지를 러스트에게 알려 주며, 러스트는 이를 바탕으로 그 데이터를 어떻게 다뤄야 할지 알 수 있습니다. 여기서는 데이터 타입의 두 가지 하위 집합인 스칼라 타입과 복합 타입을 살펴보겠습니다.

러스트는 정적 타입(statically typed) 언어라는 점을 기억하세요. 즉, 컴파일 시점에 모든 변수의 타입을 알고 있어야 합니다. 컴파일러는 보통 값과 사용 방식을 보고 어떤 타입을 써야 하는지 추론할 수 있습니다. 하지만 2장의 “추측값을 비밀 숫자와 비교하기” 절에서 parse 를 사용해 String 을 숫자 타입으로 바꾼 경우처럼, 여러 타입이 가능한 상황에서는 다음처럼 타입 주석을 추가해야 합니다.

#![allow(unused)]
fn main() {
let guess: u32 = "42".parse().expect("Not a number!");
}

위 코드에 있는 : u32 타입 주석을 추가하지 않으면, 러스트는 아래와 같은 오류를 표시합니다. 이 오류는 컴파일러가 우리가 어떤 타입을 쓰고 싶은지 알기 위해 더 많은 정보를 필요로 한다는 뜻입니다.

$ 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

다른 데이터 타입에서도 여러 형태의 타입 주석을 보게 될 것입니다.

스칼라 타입

스칼라(scalar) 타입은 하나의 단일 값을 나타냅니다. 러스트에는 네 가지 기본 스칼라 타입이 있습니다. 정수, 부동소수점 수, 불리언, 문자입니다. 다른 프로그래밍 언어에서 이미 익숙할 수도 있습니다. 러스트에서 각각이 어떻게 동작하는지 바로 살펴봅시다.

정수 타입

정수(integer) 는 소수 부분이 없는 숫자입니다. 우리는 2장에서 이미 u32 라는 정수 타입을 사용했습니다. 이 타입 선언은 그 값이 부호 없는 정수(부호 있는 정수 타입은 u 대신 i 로 시작합니다)이며, 32비트 공간을 차지한다는 뜻입니다. 표 3-1에는 러스트의 내장 정수 타입이 나와 있습니다. 이들 중 어느 것을 사용해도 정수 값의 타입을 선언할 수 있습니다.

표 3-1: 러스트의 정수 타입

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

각 변형은 부호 있음 또는 부호 없음일 수 있으며, 크기가 명시되어 있습니다. 부호 있음(signed)부호 없음(unsigned) 은 숫자가 음수가 될 수 있는지 여부를 뜻합니다. 즉 숫자와 함께 부호를 저장해야 하는지(부호 있음), 아니면 항상 양수이므로 부호 없이 표현할 수 있는지(부호 없음)를 말합니다. 종이에 숫자를 적는 것과 비슷합니다. 부호가 중요할 때는 더하기나 빼기 기호를 함께 적지만, 숫자가 양수라고 가정해도 안전할 때는 부호를 생략합니다. 부호 있는 숫자는 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: 러스트의 정수 리터럴

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

그렇다면 어떤 정수 타입을 써야 할까요? 잘 모르겠다면 러스트의 기본값이 대체로 좋은 출발점입니다. 정수 타입의 기본값은 i32 입니다. isizeusize 를 주로 쓰는 상황은 어떤 종류의 컬렉션을 인덱싱할 때입니다.

정수 오버플로

값이 0 에서 255 사이인 u8 타입 변수가 있다고 해 봅시다. 이 변수를 256처럼 그 범위를 벗어난 값으로 바꾸려 하면 정수 오버플로(integer overflow) 가 발생하며, 그 결과 두 가지 동작 중 하나가 일어날 수 있습니다. 디버그 모드로 컴파일할 때 러스트는 정수 오버플로 검사를 포함하며, 오버플로가 발생하면 프로그램이 런타임에 패닉 을 일으킵니다. 러스트에서 패닉 이란 프로그램이 오류와 함께 종료하는 것을 말합니다. 패닉은 9장의 [“panic! 으로 복구 불가능한 에러 다루기”] unrecoverable-errors-with-panic 절에서 더 자세히 다룹니다.

반면 --release 플래그를 써 릴리스 모드로 컴파일할 때는, 오버플로로 패닉을 발생시키는 검사가 포함되지 않습니다. 대신 오버플로가 발생하면 러스트는 2의 보수 래핑(two’s complement wrapping) 을 수행합니다. 간단히 말해, 타입이 담을 수 있는 최대값을 넘는 값은 해당 타입이 담을 수 있는 최소값 쪽으로 “되감기” 됩니다. u8 의 경우 256은 0이 되고, 257은 1이 되는 식입니다. 프로그램이 패닉을 일으키지는 않지만, 변수에는 여러분이 기대한 값이 아닐 가능성이 높은 값이 들어갑니다. 정수 오버플로의 래핑 동작에 의존하는 것은 보통 버그로 간주됩니다.

오버플로 가능성을 명시적으로 다루려면, 표준 라이브러리가 기본 숫자 타입에 제공하는 다음 메서드 계열을 사용할 수 있습니다.

  • wrapping_add 같은 wrapping_* 메서드: 모든 모드에서 래핑합니다.
  • checked_* 메서드: 오버플로가 있으면 None 을 반환합니다.
  • overflowing_* 메서드: 값과 함께 오버플로 여부를 나타내는 불리언을 반환합니다.
  • saturating_* 메서드: 값의 최솟값 또는 최댓값에서 포화시킵니다.

부동소수점 타입

러스트에는 소수점을 가진 숫자인 부동소수점 수(floating-point numbers) 를 위한 기본 타입도 두 가지 있습니다. 러스트의 부동소수점 타입은 각각 32비트와 64비트 크기의 f32, f64 입니다. 기본 타입은 f64 인데, 현대 CPU에서는 대체로 f32 와 속도가 비슷하면서도 더 높은 정밀도를 제공하기 때문입니다. 모든 부동소수점 타입은 부호가 있습니다.

다음은 부동소수점 수가 실제로 동작하는 예시입니다.

파일명: src/main.rs

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

    let y: f32 = 3.0; // f32
}

부동소수점 수는 IEEE-754 표준에 따라 표현됩니다.

숫자 연산

러스트는 모든 숫자 타입에 대해, 여러분이 기대할 만한 기본 수학 연산인 덧셈, 뺄셈, 곱셈, 나눗셈, 나머지 연산을 지원합니다. 정수 나눗셈은 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 에서 볼 수 있습니다.

불리언 타입

대부분의 다른 프로그래밍 언어와 마찬가지로, 러스트의 불리언 타입은 두 가지 가능한 값만 가집니다. truefalse 입니다. 불리언은 크기가 1바이트입니다. 러스트에서 불리언 타입은 bool 로 표기합니다. 예를 들면 다음과 같습니다.

파일명: src/main.rs

fn main() {
    let t = true;

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

불리언 값은 주로 if 식 같은 조건문에서 사용합니다. 러스트에서 if 식이 어떻게 동작하는지는 “제어 흐름” 절에서 다룹니다.

문자 타입

러스트의 char 타입은 언어에서 가장 기본적인 문자 타입입니다. 다음은 char 값을 선언하는 몇 가지 예입니다.

파일명: src/main.rs

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

char 리터럴은 큰따옴표를 사용하는 문자열 리터럴과 달리, 작은따옴표로 감싼다는 점에 주목하세요. 러스트의 char 타입은 크기가 4바이트이며 유니코드 스칼라 값을 나타냅니다. 즉, 단순히 ASCII 문자만이 아니라 훨씬 더 많은 것을 표현할 수 있습니다. 악센트가 있는 문자, 중국어·일본어·한국어 문자, 이모지, zero-width space 모두 러스트에서 유효한 char 값입니다. 유니코드 스칼라 값 범위는 U+0000 부터 U+D7FFU+E000 부터 U+10FFFF 까지입니다. 다만 유니코드에서 “문자”라는 개념은 생각보다 단순하지 않기 때문에, 인간이 직관적으로 생각하는 “한 글자”와 러스트의 char 가 반드시 일치하지는 않습니다. 이 주제는 8장의 “문자열에 UTF-8 텍스트 저장하기” 절에서 자세히 다룹니다.

복합 타입

복합 타입(compound types) 은 여러 값을 하나의 타입으로 묶을 수 있습니다. 러스트에는 두 가지 기본 복합 타입이 있습니다. 튜플과 배열입니다.

튜플 타입

튜플(tuple) 은 서로 다른 타입의 여러 값을 하나의 복합 타입으로 묶는 일반적인 방법입니다. 튜플은 길이가 고정되어 있어서, 한 번 선언하면 크기를 늘리거나 줄일 수 없습니다.

튜플은 괄호 안에 쉼표로 구분된 값 목록을 써서 만듭니다. 튜플 안의 각 위치는 타입을 가지며, 서로 다른 값들이 같은 타입일 필요는 없습니다. 아래 예시에서는 선택적인 타입 주석을 추가해 두었습니다.

파일명: 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 로 나눕니다. 이것을 구조분해(destructuring) 라고 하며, 하나의 튜플을 여러 부분으로 풀어내는 것입니다. 마지막으로 프로그램은 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입니다.

아무 값도 가지지 않는 튜플에는 특별한 이름이 있는데, 바로 유닛(unit) 입니다. 이 값과 그에 대응하는 타입은 모두 () 로 쓰며, 비어 있는 값 또는 비어 있는 반환 타입을 나타냅니다. 어떤 식이 다른 값을 반환하지 않으면 암묵적으로 유닛 값을 반환합니다.

배열 타입

여러 값을 모아 두는 또 다른 방법은 배열(array) 입니다. 튜플과 달리, 배열의 모든 요소는 반드시 같은 타입이어야 합니다. 또 다른 언어의 배열과 달리, 러스트의 배열은 길이가 고정되어 있습니다.

배열 값은 대괄호 안에 쉼표로 구분된 목록으로 씁니다.

파일명: 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 는 배열이 다섯 개 요소를 포함한다는 뜻입니다.

또한 초기값 하나와 세미콜론, 길이를 지정해 모든 요소를 같은 값으로 초기화할 수도 있습니다. 다음과 같습니다.

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

이 배열 a 는 다섯 개 요소를 가지며, 처음에는 모두 값 3 으로 설정됩니다. 이것은 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 이므로 값 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! 문은 실행되지 않았습니다. 인덱싱을 사용해 요소에 접근하려 할 때 러스트는 지정한 인덱스가 배열 길이보다 작은지 검사합니다. 인덱스가 길이보다 크거나 같으면 러스트는 패닉을 일으킵니다. 특히 이 예제처럼 사용자가 나중에 어떤 값을 입력할지 컴파일러가 알 수 없는 경우에는, 이런 검사를 런타임에 할 수밖에 없습니다.

이것은 러스트의 메모리 안전 원칙이 실제로 동작하는 예입니다. 많은 저수준 언어에서는 이런 검사가 이루어지지 않으며, 잘못된 인덱스를 주면 유효하지 않은 메모리에 접근할 수 있습니다. 러스트는 그런 잘못된 메모리 접근을 허용하고 계속 진행하는 대신, 즉시 종료함으로써 여러분을 이런 종류의 오류로부터 보호합니다. 9장에서는 러스트의 에러 처리를 더 살펴보고, 패닉을 일으키지도 않고 잘못된 메모리 접근도 허용하지 않는 읽기 쉽고 안전한 코드를 어떻게 작성할 수 있는지 설명합니다.

함수

함수

함수는 러스트 코드 곳곳에서 매우 흔하게 등장합니다. 여러분은 이미 이 언어에서 가장 중요한 함수 중 하나인 main 함수를 보았습니다. main 은 많은 프로그램의 진입점입니다. 또한 새 함수를 선언할 수 있게 해 주는 fn 키워드도 보았습니다.

러스트 코드는 함수와 변수 이름에 snake case 를 관례적으로 사용합니다. 즉 모든 문자를 소문자로 쓰고, 단어 사이는 밑줄로 구분합니다. 다음은 함수 정의 예제를 담은 프로그램입니다.

파일명: src/main.rs

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

    another_function();
}

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

러스트에서 함수는 fn 뒤에 함수 이름과 괄호를 써서 정의합니다. 중괄호는 함수 본문이 어디서 시작하고 끝나는지를 컴파일러에게 알려 줍니다.

우리가 정의한 함수는 이름 뒤에 괄호를 붙여 호출할 수 있습니다. another_function 이 프로그램 안에 정의되어 있으므로, main 함수 안에서도 호출할 수 있습니다. 소스 코드에서는 another_functionmain 함수 뒤에 정의했지만, 앞에 정의해도 상관없습니다. 러스트는 함수를 어디에 정의했는지는 신경 쓰지 않고, 호출자가 볼 수 있는 스코프 어딘가에 정의되어 있기만 하면 됩니다.

함수를 좀 더 살펴보기 위해 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 이 호출되어 해당 메시지가 출력됩니다.

매개변수

함수에는 매개변수(parameters) 를 정의할 수 있습니다. 매개변수는 함수 시그니처의 일부인 특별한 변수입니다. 함수에 매개변수가 있으면, 그 매개변수들에 실제 값을 전달할 수 있습니다. 엄밀히 말하면 실제 전달되는 값은 인수(arguments) 라고 부르지만, 일상적인 대화에서는 함수 정의 안의 변수와 함수를 호출할 때 넘기는 실제 값 둘 모두를 가리켜 parameterargument 를 섞어 쓰는 경우가 많습니다.

이번 버전의 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 를 넣습니다.

함수 시그니처에서는 각 매개변수의 타입을 반드시 선언해야 합니다. 이것은 러스트 설계에서 의도적으로 선택한 점입니다. 함수 정의에 타입 주석을 요구하면, 컴파일러는 코드의 다른 위치에서 여러분이 의미한 타입을 추론하기 위해 추가 정보를 거의 요구하지 않게 됩니다. 또한 함수가 어떤 타입을 기대하는지 알고 있으므로 더 유용한 오류 메시지를 제공할 수 있습니다.

매개변수가 여러 개라면 다음처럼 쉼표로 구분합니다.

파일명: 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

value 에는 5, unit_label 에는 'h' 를 넘겨 함수 호출을 했으므로, 프로그램 출력에도 그 값들이 포함됩니다.

문장과 식

함수 본문은 여러 문장들로 이루어지며, 선택적으로 마지막에 식 하나로 끝날 수 있습니다. 지금까지 우리가 본 함수들은 마지막 식을 포함하지 않았지만, 여러분은 이미 문장 안의 일부로 식을 본 적이 있습니다. 러스트는 식 중심 언어이기 때문에 이 차이를 이해하는 것이 중요합니다. 다른 언어에서는 이런 구분이 같지 않은 경우도 있으니, 문장과 식이 무엇이고 그 차이가 함수 본문에 어떤 영향을 주는지 살펴봅시다.

  • 문장(statements) 은 어떤 동작을 수행하지만 값을 반환하지 않는 명령입니다.
  • 식(expressions) 은 어떤 결과값으로 평가됩니다.

몇 가지 예를 살펴봅시다.

사실 우리는 이미 문장과 식을 사용해 왔습니다. let 키워드로 변수를 만들고 값을 대입하는 것은 문장입니다. 목록 3-1에서 let y = 6; 은 문장입니다.

Filename: src/main.rs
fn main() {
    let y = 6;
}
Listing 3-1: 문장 하나를 담고 있는 main 함수 선언

함수 정의 자체도 문장입니다. 앞의 예제 전체가 그 자체로 하나의 문장입니다. (곧 보겠지만, 함수 호출 은 문장이 아닙니다.)

문장은 값을 반환하지 않습니다. 따라서 다음 코드처럼 let 문을 다른 변수에 대입할 수 없으며, 오류가 발생합니다.

파일명: 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 을 갖게 할 수 있지만, 러스트에서는 그렇게 되지 않습니다.

식은 값으로 평가되며, 여러분이 러스트에서 작성하게 될 코드의 대부분을 이룹니다. 예를 들어 5 + 6 같은 수학 연산은 11 이라는 값으로 평가되는 식입니다. 식은 문장의 일부가 될 수도 있습니다. 목록 3-1에서 let y = 6;6 은 값 6 으로 평가되는 식입니다. 함수 호출도 식입니다. 매크로 호출도 식입니다. 중괄호로 만든 새 스코프 블록도 식입니다. 예를 들면 다음과 같습니다.

파일명: 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 줄 끝에는 세미콜론이 없다는 점에 주목하세요. 식에는 세미콜론이 붙지 않습니다. 식 끝에 세미콜론을 붙이면 그 식은 문장이 되고, 더 이상 값을 반환하지 않게 됩니다. 다음으로 함수 반환값과 식을 살펴볼 때 이 점을 기억하세요.

반환값을 가지는 함수

함수는 자신을 호출한 코드에 값을 돌려줄 수 있습니다. 반환값에 이름을 붙이지는 않지만, 화살표(->) 뒤에 그 타입은 반드시 선언해야 합니다. 러스트에서 함수의 반환값은 함수 본문 블록의 마지막 식의 값과 같습니다. return 키워드와 값을 사용해 함수에서 일찍 빠져나올 수도 있지만, 대부분의 함수는 마지막 식을 암묵적으로 반환합니다. 다음은 값을 반환하는 함수의 예입니다.

파일명: src/main.rs

fn five() -> i32 {
    5
}

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

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

five 함수 안에는 함수 호출도, 매크로도, 심지어 let 문도 없습니다. 숫자 5 하나만 있을 뿐입니다. 하지만 이것은 러스트에서 완전히 유효한 함수입니다. 함수의 반환 타입도 -> 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(); 라는 줄은 함수 반환값을 사용해 변수를 초기화하고 있음을 보여 줍니다. 함수 five5 를 반환하므로, 이 줄은 사실 다음과 같습니다.

#![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 를 반환하겠다고 말하지만, 문장은 값으로 평가되지 않으며, 이는 unit 타입 () 로 표현됩니다. 따라서 아무 값도 반환되지 않고, 이것이 함수 정의와 모순되어 오류가 발생하는 것입니다. 이 출력에서 러스트는 문제를 고칠 수 있도록 도움말도 제공합니다. 세미콜론을 제거하라고 제안하는데, 그렇게 하면 오류가 해결됩니다.

주석

주석

모든 프로그래머는 자신의 코드를 이해하기 쉽게 만들려고 노력하지만, 때로는 추가 설명이 필요합니다. 이런 경우 프로그래머는 소스 코드 안에 주석(comments) 을 남깁니다. 컴파일러는 이 주석을 무시하지만, 소스 코드를 읽는 사람에게는 유용할 수 있습니다.

다음은 간단한 주석입니다.

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

러스트에서 관용적인 주석 스타일은 슬래시 두 개로 주석을 시작하고, 그 주석은 줄 끝까지 이어집니다. 한 줄을 넘어가는 주석은 다음처럼 각 줄마다 // 를 넣어야 합니다.

#![allow(unused)]
fn main() {
// So we're doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what's going on.
}

주석은 코드가 있는 줄의 끝에 둘 수도 있습니다.

파일명: src/main.rs

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

하지만 보통은 주석을 설명하려는 코드 위의 별도 줄에 두는 형식을 더 자주 보게 됩니다.

파일명: src/main.rs

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

러스트에는 또 다른 종류의 주석인 문서화 주석도 있습니다. 이것은 14장의 “Crates.io에 크레이트 배포하기” 절에서 다룹니다.

제어 흐름

제어 흐름

조건이 true 인지에 따라 어떤 코드를 실행할 수 있는 능력과, 조건이 true 인 동안 어떤 코드를 반복해서 실행할 수 있는 능력은 대부분의 프로그래밍 언어에서 기본이 되는 구성 요소입니다. 러스트 코드의 실행 흐름을 제어하게 해 주는 가장 흔한 구문은 if 식과 루프입니다.

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 식에서 조건과 연결된 코드 블록은 2장의 “추측값을 비밀 숫자와 비교하기” 절에서 다룬 match 식의 arm과 비슷하게, 때때로 arm 이라고 부르기도 합니다.

필요하다면 여기서처럼 else 식을 포함해, 조건이 false 로 평가될 때 실행할 대체 코드 블록을 줄 수도 있습니다. 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 으로 평가되므로, 러스트는 오류를 냅니다.

$ 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

오류는 러스트가 bool 을 기대했지만 정수를 받았다고 알려 줍니다. Ruby나 JavaScript 같은 언어와 달리, 러스트는 불리언이 아닌 타입을 자동으로 불리언으로 변환하지 않습니다. if 의 조건으로는 항상 명시적으로 불리언을 제공해야 합니다. 예를 들어 숫자가 0 이 아닐 때만 if 코드 블록을 실행하고 싶다면, 다음처럼 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 도 보이지 않습니다. 러스트는 첫 번째 true 조건에 해당하는 블록만 실행하고, 그 하나를 찾고 나면 나머지는 검사하지 않기 때문입니다.

else if 식이 너무 많아지면 코드가 복잡해 보일 수 있으므로, 하나보다 많아질 것 같다면 리팩터링을 고려해 보는 것이 좋습니다. 이런 경우를 위해 6장에서는 match 라는 강력한 러스트 분기 구문을 설명합니다.

let 문 안에서 if 사용하기

if 는 식이기 때문에, 목록 3-2처럼 let 문 오른쪽에서 사용해 결과를 변수에 대입할 수 있습니다.

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 의 각 arm에서 결과가 될 수 있는 값들은 모두 같은 타입이어야 합니다. 목록 3-2에서는 if arm과 else arm 모두 i32 정수를 결과로 내놓았습니다. 만약 아래 예시처럼 타입이 맞지 않으면 오류가 납니다.

파일명: src/main.rs

fn main() {
    let condition = true;

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

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

이 코드를 컴파일하면 오류가 발생합니다. ifelse arm이 서로 호환되지 않는 타입의 값을 가지며, 러스트는 프로그램 어디에서 문제가 생겼는지를 정확히 알려 줍니다.

$ 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 블록 안의 식은 문자열로 평가됩니다. 이것은 동작할 수 없습니다. 변수는 하나의 단일 타입만 가져야 하고, 러스트는 컴파일 시점에 number 변수의 타입이 무엇인지 확실히 알아야 하기 때문입니다. number 의 타입을 알고 있어야 컴파일러가 number 를 사용하는 모든 곳에서 그 타입이 유효한지 검사할 수 있습니다. 만약 number 의 타입이 런타임에 가서야 결정된다면 컴파일러는 훨씬 더 복잡해지고, 어떤 변수든 여러 가상의 타입을 추적해야 하므로 코드에 대해 보장할 수 있는 것도 줄어들게 됩니다.

루프로 반복하기

어떤 코드 블록을 한 번 이상 실행해야 하는 경우는 자주 있습니다. 이를 위해 러스트는 여러 가지 루프(loop) 를 제공합니다. 루프는 루프 본문 안의 코드를 끝까지 실행한 뒤 즉시 처음으로 다시 돌아갑니다. 루프를 실험해 보기 위해 loops 라는 새 프로젝트를 만들어 봅시다.

러스트에는 세 종류의 루프가 있습니다. 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! 이 보일 수도 있고 보이지 않을 수도 있습니다.

다행히 러스트는 코드 안에서 루프를 빠져나오는 방법도 제공합니다. 루프 안에 break 키워드를 두면 언제 루프 실행을 멈출지를 프로그램에 알려 줄 수 있습니다. 2장의 “정답을 맞히면 종료하기” 절에서 사용자가 정답을 맞혔을 때 프로그램을 종료하기 위해 이것을 이미 써 본 적이 있습니다.

숫자 맞히기 게임에서는 continue 도 사용했었는데, 루프 안에서 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 라는 변수를 선언합니다. 루프의 각 반복마다 counter1 을 더하고, 그 뒤 counter10 과 같은지 확인합니다. 같다면 break 키워드와 함께 counter * 2 라는 값을 사용합니다. 루프 뒤에는 세미콜론을 붙여, 이 값이 result 에 대입되는 문장을 끝냅니다. 마지막으로 result 의 값을 출력하는데, 이 경우 값은 20 입니다.

루프 안에서 return 을 사용할 수도 있습니다. break 가 현재 루프만 빠져나가는 반면, return 은 항상 현재 함수 전체를 종료합니다.

루프 레이블로 구분하기

루프 안에 또 다른 루프가 있다면, breakcontinue 는 그 지점에서 가장 안쪽 루프에 적용됩니다. 필요하다면 루프에 레이블(loop label) 을 지정하고, 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 를 조합해서 구현할 수도 있습니다. 원한다면 직접 그렇게 한 번 만들어 봐도 좋습니다. 하지만 이 패턴은 너무 흔해서, 러스트는 이를 위한 내장 언어 구성 요소인 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 구문을 선택할 수도 있습니다. 예를 들어 목록 3-4의 루프는 배열 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 < 5 가 더 이상 true 가 아닐 때까지). 이 코드를 실행하면 배열의 모든 요소가 출력됩니다.

$ 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 루프는 목록 3-5와 같습니다.

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 루프로 컬렉션의 각 요소를 순회하기

이 코드를 실행하면 목록 3-4와 같은 출력을 보게 됩니다. 더 중요한 것은, 이제 코드가 더 안전해졌고 배열 끝을 넘어가거나 일부 요소를 빠뜨리는 버그 가능성도 사라졌다는 점입니다. for 루프에서 생성되는 머신 코드는 매 반복마다 인덱스를 배열 길이와 비교할 필요가 없기 때문에 더 효율적일 수도 있습니다.

for 루프를 사용하면 배열 값 개수를 바꿀 때도 목록 3-4 방식처럼 다른 코드까지 함께 바꾸는 것을 기억할 필요가 없습니다.

안전성과 간결성 덕분에 for 루프는 러스트에서 가장 흔히 쓰이는 반복 구문입니다. 심지어 목록 3-3의 카운트다운 예시처럼 특정 횟수만큼 코드를 실행하고 싶을 때도, 대부분의 Rustacean은 while 대신 for 루프를 사용할 것입니다. 그 방법은 표준 라이브러리가 제공하는 Range 를 사용하는 것입니다. Range 는 한 숫자에서 시작해 다른 숫자 직전까지의 수를 순서대로 생성합니다.

카운트다운을 for 루프와, 아직 설명하지 않은 또 다른 메서드인 rev 를 사용해 범위를 뒤집는 방식으로 작성하면 다음과 같습니다.

파일명: src/main.rs

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

이 코드가 조금 더 보기 좋지 않나요?

정리

여기까지 왔다면 잘해낸 것입니다! 꽤 큰 장이었습니다. 여러분은 변수, 스칼라 및 복합 데이터 타입, 함수, 주석, if 식, 루프를 배웠습니다! 이 장에서 다룬 개념을 연습해 보고 싶다면, 다음과 같은 프로그램을 만들어 보세요.

  • 화씨와 섭씨 사이 온도 변환하기
  • n 번째 피보나치 수 구하기
  • 노래의 반복 구조를 활용해 크리스마스 캐럴 “The Twelve Days of Christmas” 가사 출력하기

이제 다음으로 넘어갈 준비가 되었다면, 다른 대부분의 프로그래밍 언어에는 흔히 존재하지 않는 러스트의 개념인 소유권에 대해 이야기하겠습니다.

소유권 이해하기

소유권은 러스트에서 가장 독특한 기능이며, 언어의 나머지 부분 전반에 깊은 영향을 미칩니다. 소유권 덕분에 러스트는 가비지 컬렉터 없이도 메모리 안전성을 보장할 수 있으므로, 소유권이 어떻게 동작하는지 이해하는 것이 중요합니다. 이 장에서는 소유권과 더불어 그와 관련된 몇 가지 기능, 즉 대여, 슬라이스, 그리고 러스트가 메모리에 데이터를 어떻게 배치하는지도 함께 다룹니다.

소유권이란 무엇인가?

소유권이란 무엇인가?

소유권(ownership) 은 러스트 프로그램이 메모리를 관리하는 방식을 지배하는 규칙 집합입니다. 모든 프로그램은 실행 중 컴퓨터 메모리를 어떻게 사용할지 관리해야 합니다. 어떤 언어는 프로그램이 실행되는 동안 더 이상 쓰이지 않는 메모리를 정기적으로 찾아 정리하는 가비지 컬렉션을 사용합니다. 또 다른 언어에서는 프로그래머가 메모리를 명시적으로 할당하고 해제해야 합니다. 러스트는 세 번째 접근법을 사용합니다. 컴파일러가 검사하는 규칙 집합을 가진 소유권 시스템을 통해 메모리를 관리합니다. 이 규칙 가운데 하나라도 어기면 프로그램은 컴파일되지 않습니다. 소유권의 어떤 기능도 프로그램이 실행되는 동안 성능을 느리게 만들지 않습니다.

소유권은 많은 프로그래머에게 새로운 개념이기 때문에 익숙해지는 데 시간이 조금 걸립니다. 좋은 소식은, 러스트와 소유권 규칙에 익숙해질수록 안전하고 효율적인 코드를 자연스럽게 작성하는 일이 점점 쉬워진다는 점입니다. 계속 연습해 보세요!

소유권을 이해하게 되면, 러스트를 독특하게 만드는 기능들을 이해할 수 있는 단단한 기초를 얻게 됩니다. 이 장에서는 아주 흔한 자료구조인 문자열을 중심으로 몇 가지 예를 직접 따라가며 소유권을 배워 보겠습니다.

스택과 힙

많은 프로그래밍 언어에서는 스택과 힙을 자주 의식할 필요가 없습니다. 하지만 러스트 같은 시스템 프로그래밍 언어에서는 어떤 값이 스택에 있는지 힙에 있는지가 언어의 동작 방식과 왜 특정 선택을 해야 하는지에 영향을 줍니다. 이 장의 뒤에서 소유권의 일부를 스택과 힙에 연결해 설명할 것이므로, 먼저 짧게 정리해 두겠습니다.

스택과 힙은 둘 다 런타임에 코드가 사용할 수 있는 메모리의 일부이지만, 구조가 다릅니다. 스택은 값을 들어온 순서대로 저장하고, 반대 순서로 제거합니다. 이것을 후입선출(last in, first out; LIFO) 이라고 합니다. 접시 더미를 떠올려 보세요. 접시를 더 얹을 때는 맨 위에 올리고, 접시 하나가 필요할 때는 맨 위에서 꺼냅니다. 가운데나 맨 아래에서 넣거나 빼는 방식은 잘 맞지 않겠지요. 데이터를 추가하는 것을 스택에 푸시한다(push onto the stack) 고 하고, 데이터를 제거하는 것을 스택에서 팝한다(pop off the stack) 고 합니다. 스택에 저장되는 모든 데이터는 크기가 알려져 있고 고정되어 있어야 합니다. 컴파일 시점에 크기를 알 수 없거나, 크기가 바뀔 수 있는 데이터는 대신 힙에 저장해야 합니다.

힙은 덜 정돈되어 있습니다. 힙에 데이터를 넣을 때는 일정한 양의 공간을 요청합니다. 메모리 할당자는 힙 안에서 그 크기를 수용할 수 있는 빈 자리를 찾고, 그 자리를 사용 중으로 표시한 뒤, 그 위치의 주소인 포인터(pointer) 를 반환합니다. 이 과정을 힙에 할당한다(allocating on the heap) 고 하며, 때로는 줄여서 그냥 할당한다 고도 합니다(스택에 값을 푸시하는 것은 보통 할당이라고 하지 않습니다). 힙 데이터에 대한 포인터는 크기가 알려져 있고 고정되어 있으므로 포인터 자체는 스택에 저장할 수 있지만, 실제 데이터에 접근하려면 포인터를 따라가야 합니다. 식당에 들어가 자리를 안내받는 상황을 떠올려 보세요. 여러분이 인원 수를 말하면, 안내 직원이 모두가 앉을 수 있는 빈 테이블을 찾아 거기로 데려다줍니다. 일행 중 누군가 늦게 오면, 이미 어디 앉았는지 물어본 뒤 여러분을 찾아갈 수 있습니다.

스택에 푸시하는 작업은 힙에 할당하는 것보다 빠릅니다. 할당자가 새 데이터를 넣을 위치를 탐색할 필요가 없기 때문입니다. 그 위치는 언제나 스택 맨 위에 있으니까요. 반대로 힙에 공간을 할당하려면 더 많은 작업이 필요합니다. 할당자가 데이터를 담을 만큼 충분히 큰 공간을 먼저 찾아야 하고, 다음 할당을 준비하기 위한 bookkeeping도 해야 하기 때문입니다.

힙의 데이터에 접근하는 것은 보통 스택의 데이터에 접근하는 것보다 느립니다. 그곳까지 가려면 포인터를 따라가야 하기 때문입니다. 현대 프로세서는 메모리 안을 덜 이리저리 뛰어다닐수록 더 빠릅니다. 다시 식당 비유로 설명하면, 여러 테이블의 주문을 받는 서버는 한 테이블의 주문을 전부 받은 뒤 다음 테이블로 이동하는 편이 가장 효율적입니다. A 테이블에서 하나 받고, B 테이블에서 하나 받고, 다시 A, 다시 B로 오가는 것은 훨씬 느립니다. 같은 이유로 프로세서도 다른 데이터와 가까이 있는 데이터(스택의 경우)로 일할 때, 멀리 떨어진 데이터(힙의 경우)보다 더 효율적으로 동작할 수 있습니다.

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

코드의 어떤 부분이 힙의 어떤 데이터를 사용하고 있는지 추적하기, 힙에 중복 데이터를 최소화하기, 더 이상 사용하지 않는 힙 데이터를 정리해 공간을 낭비하지 않기 같은 문제들은 모두 소유권이 해결하고자 하는 대상입니다. 소유권을 이해하고 나면 스택과 힙을 자주 의식할 필요는 없어집니다. 하지만 소유권의 주된 목적이 힙 데이터를 관리하는 데 있다는 사실을 알고 있으면, 왜 소유권이 그런 방식으로 동작하는지를 이해하는 데 도움이 됩니다.

소유권 규칙

먼저 소유권 규칙을 살펴봅시다. 뒤이어 나오는 예제를 따라가는 동안 다음 규칙을 계속 머릿속에 두세요.

  • 러스트의 각 값은 소유자(owner) 를 가진다.
  • 어떤 값에는 한 시점에 오직 하나의 소유자만 있을 수 있다.
  • 소유자가 스코프를 벗어나면 값은 버려진다(drop 된다).

변수 스코프

이제 기본적인 러스트 문법을 지나왔으므로, 예제마다 fn main() { 전체를 계속 포함하지는 않겠습니다. 직접 따라 하신다면 아래 예제들을 수동으로 main 함수 안에 넣어 주세요. 이렇게 하면 보일러플레이트 코드보다 실제 핵심에 집중할 수 있어 예제가 좀 더 간결해집니다.

소유권의 첫 번째 예로, 몇 가지 변수의 스코프를 살펴보겠습니다. 스코프(scope) 는 어떤 항목이 유효한 프로그램 구간을 뜻합니다. 다음 변수를 보세요.

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

변수 s 는 문자열 리터럴을 가리킵니다. 이 문자열 값은 프로그램 텍스트 안에 하드코딩되어 있습니다. 이 변수는 선언된 시점부터 현재 스코프가 끝날 때까지 유효합니다. 목록 4-1은 변수 s 가 어디서 유효한지를 주석으로 표시한 프로그램입니다.

fn main() {
    {                      // s is not valid here, since 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 가 스코프 안으로 들어오면 유효해집니다.
  • s 는 스코프 밖으로 나갈 때까지 유효합니다.

이 시점에서 스코프와 변수의 유효 기간 사이 관계는 다른 프로그래밍 언어와 비슷합니다. 이제 여기에 String 타입을 도입해 이해를 확장해 보겠습니다.

String 타입

소유권 규칙을 설명하려면 3장의 [“데이터 타입”] 절에서 다룬 타입들보다 좀 더 복잡한 데이터 타입이 필요합니다. 앞서 다룬 타입들은 크기가 알려져 있고, 스택에 저장할 수 있으며, 스코프가 끝나면 스택에서 제거할 수 있고, 다른 코드가 같은 값을 다른 스코프에서 사용해야 할 때도 빠르고 단순하게 복사해 독립적인 새 인스턴스를 만들 수 있습니다. 하지만 우리는 힙에 저장되는 데이터를 살펴보고, 러스트가 언제 그 데이터를 정리해야 하는지 어떻게 아는지 알아보고 싶습니다. String 타입은 이 목적에 아주 좋은 예입니다.

여기서는 String 의 여러 측면 중 소유권과 관련된 부분에 집중하겠습니다. 이런 특징은 표준 라이브러리가 제공하는 다른 복잡한 데이터 타입이나 여러분이 직접 만든 타입에도 똑같이 적용됩니다. 소유권과 직접 관련 없는 String 의 성질은 8장 에서 다룹니다.

우리는 이미 문자열 리터럴을 보았습니다. 문자열 리터럴은 문자열 값이 프로그램 코드 안에 하드코딩되어 있습니다. 문자열 리터럴은 편리하지만, 우리가 텍스트를 다루고 싶어 하는 모든 상황에 적합하지는 않습니다. 한 가지 이유는 불변이라는 점입니다. 또 다른 이유는, 모든 문자열 값을 우리가 코드를 작성하는 시점에 미리 알 수는 없기 때문입니다. 예를 들어 사용자 입력을 받아 저장하고 싶다면 어떨까요? 이런 상황을 위해 러스트에는 String 타입이 있습니다. 이 타입은 힙에 할당된 데이터를 관리하므로, 컴파일 시점에 길이를 알 수 없는 텍스트 양도 저장할 수 있습니다. 문자열 리터럴에서 String 을 만들려면 다음처럼 from 함수를 사용합니다.

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

이중 콜론 :: 연산자는 from 함수를 string_from 같은 별도 이름으로 쓰는 대신 String 타입 아래에 네임스페이스된 함수로 사용하게 해 줍니다. 이 문법은 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 를 짝지어야 합니다.

러스트는 다른 길을 택합니다. 메모리는 그것을 소유한 변수가 스코프를 벗어날 때 자동으로 반환됩니다. 목록 4-1의 스코프 예제를 문자열 리터럴 대신 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 가 스코프를 벗어나는 순간입니다. 변수가 스코프를 벗어나면, 러스트는 우리를 대신해 특별한 함수를 호출합니다. 이 함수의 이름은 drop 이며, String 작성자는 이 함수 안에 메모리를 반환하는 코드를 넣을 수 있습니다. 러스트는 닫는 중괄호를 만났을 때 drop 을 자동으로 호출합니다.

Note: C++ 에서는 항목의 생애주기 끝에서 자원을 해제하는 이 패턴을 RAII(Resource Acquisition Is Initialization) 라고 부르기도 합니다. RAII 패턴을 사용해 본 적이 있다면, 러스트의 drop 함수도 익숙하게 느껴질 것입니다.

이 패턴은 러스트 코드가 작성되는 방식에 아주 큰 영향을 미칩니다. 지금은 단순해 보이지만, 힙에 할당한 같은 데이터를 여러 변수가 사용하려는 더 복잡한 상황에서는 코드 동작이 예상과 다를 수 있습니다. 이제 그런 상황을 몇 가지 살펴보겠습니다.

이동(move)으로 변수와 데이터가 상호작용하는 방식

러스트에서는 여러 변수가 같은 데이터와 다양한 방식으로 상호작용할 수 있습니다. 목록 4-2는 정수를 사용하는 예를 보여 줍니다.

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

이 코드가 무엇을 하는지는 대략 짐작할 수 있습니다. “값 5x 에 바인딩하고, 그다음 x 의 값을 복사해 y 에 바인딩한다”는 의미입니다. 실제로도 그렇습니다. 정수는 크기가 알려져 있고 고정된 단순한 값이므로, 두 개의 5 값 모두 스택에 푸시됩니다.

이제 String 버전을 봅시다.

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

겉보기에는 매우 비슷하므로, 같은 방식으로 동작할 것이라고 생각할 수도 있습니다. 즉 두 번째 줄이 s1 의 값을 복사해 s2 에 바인딩한다고 생각하기 쉽습니다. 하지만 실제로는 그렇지 않습니다.

String 이 내부적으로 어떻게 생겼는지 그림 4-1을 봅시다. String 은 왼쪽에 보이는 세 부분으로 이루어져 있습니다. 문자열 내용을 담는 메모리를 가리키는 포인터, 길이, 용량입니다. 이 데이터 묶음은 스택에 저장됩니다. 오른쪽에는 실제 문자열 내용을 담고 있는 힙 메모리가 있습니다.

Two tables: the first table contains the representation of s1 on the
stack, consisting of its length (5), capacity (5), and a pointer to the first
value in the second table. The second table contains the representation of the
string data on the heap, byte by byte.

그림 4-1: 값 "hello" 를 가진 Strings1 에 바인딩되었을 때의 메모리 표현

길이는 현재 String 내용이 사용하는 메모리 양(바이트 수)입니다. 용량은 String 이 할당자로부터 받은 총 메모리 양(바이트 수)입니다. 길이와 용량의 차이는 중요하지만, 여기서는 무시해도 괜찮습니다.

s1s2 에 대입하면, String 데이터가 복사됩니다. 즉 스택에 있는 포인터, 길이, 용량이 복사됩니다. 포인터가 가리키는 힙 데이터 자체는 복사하지 않습니다. 다시 말해 메모리 표현은 그림 4-2처럼 됩니다.

Three tables: tables s1 and s2 representing those strings on the
stack, respectively, and both pointing to the same string data on the heap.

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

메모리 표현은 그림 4-3처럼 되지 않습니다. 만약 러스트가 힙 데이터까지 함께 복사했다면 메모리는 그 그림처럼 보였을 것입니다. 그렇게 되면 힙 데이터가 클 때 s2 = s1 연산은 런타임 성능 측면에서 매우 비싸질 수 있습니다.

Four tables: two tables representing the stack data for s1 and s2,
and each points to its own copy of string data on the heap.

그림 4-3: 러스트가 힙 데이터까지 복사했다면 s2 = s1 이 보였을 또 다른 가능성

앞에서 변수가 스코프를 벗어나면 러스트가 자동으로 drop 함수를 호출해 그 변수의 힙 메모리를 정리한다고 말했습니다. 그런데 그림 4-2에서는 두 데이터 포인터가 같은 위치를 가리키고 있습니다. 이것은 문제가 됩니다. s2s1 이 스코프를 벗어나면 둘 다 같은 메모리를 해제하려 할 것이기 때문입니다. 이것을 이중 해제(double free) 오류라고 하며, 앞에서 언급한 메모리 안전성 버그 중 하나입니다. 메모리를 두 번 해제하면 메모리 손상이 발생할 수 있고, 이는 보안 취약점으로 이어질 수도 있습니다.

메모리 안전성을 보장하기 위해, 러스트는 let s2 = s1; 이후 s1 을 더 이상 유효하지 않다고 간주합니다. 그러면 s1 이 스코프를 벗어날 때 러스트는 아무 것도 해제할 필요가 없어집니다. s2 가 만들어진 뒤 s1 을 사용하려 하면 어떤 일이 일어나는지 보세요. 동작하지 않습니다.

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

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

러스트는 무효화된 참조를 사용하는 것을 막기 때문에 다음과 같은 오류를 받게 됩니다.

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
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) 라는 용어를 들어 본 적이 있다면, 데이터 자체를 복사하지 않고 포인터, 길이, 용량만 복사하는 이 개념은 얕은 복사와 비슷하게 들릴 것입니다. 하지만 러스트는 동시에 첫 번째 변수도 무효화하기 때문에, 이것을 얕은 복사라고 부르지 않고 이동(move) 이라고 부릅니다. 이 예에서는 s1s2이동되었다 고 말합니다. 실제 상태는 그림 4-4와 같습니다.

Three tables: tables s1 and s2 representing those strings on the
stack, respectively, and both pointing to the same string data on the heap.
Table s1 is grayed out because s1 is no longer valid; only s2 can be used to
access the heap data.

그림 4-4: s1 이 무효화된 뒤의 메모리 표현

이것으로 문제가 해결됩니다. 이제 유효한 것은 s2 하나뿐이므로, s2 가 스코프를 벗어날 때 그 하나만 메모리를 해제하면 됩니다.

더 나아가, 여기에는 하나의 설계 선택이 암시되어 있습니다. 러스트는 여러분의 데이터에 대해 “깊은” 복사를 자동으로 만들지 않습니다. 따라서 러스트에서의 자동 복사는 런타임 성능 측면에서 비용이 낮다고 가정할 수 있습니다.

스코프와 대입

이와 반대되는 방향의 관계도 있습니다. 스코프, 소유권, 그리고 drop 함수를 통한 메모리 해제 사이의 관계입니다. 기존 변수에 완전히 새로운 값을 대입하면, 러스트는 즉시 drop 을 호출해 원래 값의 메모리를 해제합니다. 예를 들어 다음 코드를 보세요.

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

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

우리는 먼저 변수 s 를 선언하고 값 "hello"String 에 바인딩합니다. 그리고 곧바로 "ahoy" 값을 가진 새로운 String 을 만들어 s 에 대입합니다. 이 시점에는 원래 힙 값 "hello" 를 가리키는 것은 아무 것도 없습니다. 그림 4-5는 지금의 스택과 힙 데이터를 보여 줍니다.

One table representing the string value on the stack, pointing to
the second piece of string data (ahoy) on the heap, with the original string
data (hello) grayed out because it cannot be accessed anymore.

그림 4-5: 초기 값이 완전히 교체된 뒤의 메모리 표현

따라서 원래 문자열은 즉시 스코프를 벗어난 것으로 처리됩니다. 러스트는 그 값에 대해 drop 함수를 실행하고, 메모리는 곧바로 해제됩니다. 마지막에 값을 출력하면 결과는 "ahoy, world!" 가 됩니다.

clone 으로 변수와 데이터가 상호작용하는 방식

만약 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 을 호출해도 일반 복사와 다를 바가 없고 그냥 생략할 수 있습니다.

러스트에는 Copy 트레이트라는 특별한 표시가 있습니다. 정수처럼 스택에 저장되는 타입에 이 트레이트를 붙일 수 있습니다(10장에서 트레이트를 더 자세히 다룹니다). 어떤 타입이 Copy 트레이트를 구현하면, 그 타입을 사용하는 변수는 이동되지 않고 단순히 복사됩니다. 따라서 다른 변수에 대입한 뒤에도 계속 유효합니다.

러스트는 타입 자체나 그 일부라도 Drop 트레이트를 구현했다면, 그 타입에 Copy 주석을 붙이게 하지 않습니다. 값이 스코프를 벗어날 때 특별한 일이 일어나야 하는데, 그 타입에 Copy 를 추가하면 컴파일 시점 오류가 납니다. 여러분의 타입에 Copy 주석을 추가해 트레이트를 구현하는 방법은 부록 C의 [“파생 가능한 트레이트”] derivable-traits를 참고하세요.

그렇다면 어떤 타입이 Copy 트레이트를 구현할까요? 정확한 것은 해당 타입의 문서를 확인하면 되지만, 일반적으로 단순한 스칼라 값들의 묶음은 Copy 를 구현할 수 있고, 할당이 필요하거나 어떤 형태의 자원인 것은 구현할 수 없습니다. Copy 를 구현하는 타입에는 다음과 같은 것들이 있습니다.

  • u32 같은 모든 정수 타입
  • true, false 값을 가지는 불리언 타입 bool
  • f64 같은 모든 부동소수점 타입
  • 문자 타입 char
  • 내부 요소들도 모두 Copy 를 구현하는 경우의 튜플. 예를 들어 (i32, i32)Copy 이지만 (i32, String) 은 아닙니다.

소유권과 함수

값을 함수에 넘기는 동작은 값을 변수에 대입하는 동작과 비슷합니다. 변수를 함수에 넘기면 대입과 마찬가지로 이동되거나 복사됩니다. 목록 4-3은 변수들이 어디서 스코프 안으로 들어오고 어디서 나가는지 주석으로 표시한 예시입니다.

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,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, 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 를 다시 사용하려 하면, 러스트는 컴파일 시점 오류를 냅니다. 이런 정적 검사가 실수를 막아 줍니다. 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 에 의해 정리됩니다.

이 방식은 동작하긴 하지만, 함수가 값을 받을 때마다 소유권을 가져가고 다시 돌려주는 것은 다소 번거롭습니다. 함수가 값을 사용만 하고 소유권은 가져가지 않게 하고 싶다면 어떻게 해야 할까요? 다시 사용하려면 우리가 넘긴 것은 결과와 별도로 그대로 다시 돌려받아야 한다는 점도 꽤 성가십니다.

러스트는 목록 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: 매개변수의 소유권 반환하기

하지만 이것은 너무 장황하고, 원래 흔해야 할 개념치고는 일이 많습니다. 다행히 러스트는 소유권을 옮기지 않고도 값을 사용할 수 있게 해 주는 기능을 제공합니다. 바로 참조입니다.

참조와 대여

참조와 대여

목록 4-5의 튜플 코드가 가진 문제는, Stringcalculate_length 로 이동되었기 때문에 함수 호출 뒤에도 그 String 을 계속 사용하려면 호출한 함수 쪽으로 다시 String 을 반환해야 한다는 점입니다. 대신 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()
}

먼저 변수 선언과 함수 반환값에 있던 튜플 코드가 모두 사라졌다는 점에 주목하세요. 둘째, calculate_length&s1 을 넘기고 있고, 함수 정의에서도 String 대신 &String 을 받고 있습니다. 이 앰퍼샌드들은 참조를 나타내며, 값의 소유권을 가져가지 않고도 그 값을 가리킬 수 있게 해 줍니다. 그림 4-6은 이 개념을 보여 줍니다.

Three tables: the table for s contains only a pointer to the table
for s1. The table for s1 contains the stack data for s1 and points to the
string data on the heap.

그림 4-6: String s1 을 가리키는 &String s 도식

Note: & 를 사용해 참조하는 것의 반대는 역참조(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 String 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 라는 또 다른 가변 참조를 만들어 r1 과 같은 데이터를 빌리려 했습니다.

같은 데이터에 대한 여러 가변 참조를 동시에 막는 이 제약은, 변경 자체는 허용하되 매우 통제된 방식으로만 허용한다는 뜻입니다. 대부분의 언어는 원하면 언제든 값을 바꿀 수 있게 해 주기 때문에, 이것은 새로운 Rustacean들이 종종 어려워하는 부분입니다. 하지만 이 제약의 장점은 러스트가 컴파일 시점에 데이터 경쟁을 막을 수 있다는 점입니다. 데이터 경쟁(data race) 은 레이스 컨디션과 비슷한 문제로, 다음 세 가지 조건이 동시에 성립할 때 발생합니다.

  • 두 개 이상의 포인터가 같은 데이터에 동시에 접근한다.
  • 그 포인터 중 적어도 하나는 데이터를 쓰는 데 사용된다.
  • 데이터 접근을 동기화하는 메커니즘이 없다.

데이터 경쟁은 정의되지 않은 동작을 일으키며, 런타임에 추적하려고 하면 진단과 수정이 매우 어렵습니다. 러스트는 데이터 경쟁이 있는 코드를 아예 컴파일하지 않음으로써 이 문제를 막습니다.

언제나 그렇듯, 중괄호로 새 스코프를 만들면 여러 가변 참조를 사용할 수 있습니다. 단, 동시에 존재하지만 않으면 됩니다.

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;
}

러스트는 가변 참조와 불변 참조를 섞어 쓸 때도 비슷한 규칙을 강제합니다. 다음 코드는 오류를 발생시킵니다.

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!("{r1}, {r2}, and {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!("{r1}, {r2}, and {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}");
}

불변 참조 r1, r2 의 스코프는 그것들이 마지막으로 사용되는 println! 이후에 끝납니다. 이는 가변 참조 r3 가 만들어지기 이전 입니다. 따라서 스코프가 겹치지 않기 때문에 이 코드는 허용됩니다. 컴파일러는 스코프 끝보다 더 앞선 시점에서 참조가 이미 더 이상 사용되지 않는다는 점을 알 수 있습니다.

대여 관련 오류가 때로는 답답할 수 있지만, 이것은 러스트 컴파일러가 잠재적인 버그를 런타임이 아니라 컴파일 시점에 미리 알려 주고, 정확히 어디가 문제인지 보여 주고 있다는 뜻이라는 점을 기억하세요. 그러면 데이터가 왜 생각한 것과 다르게 되었는지 나중에 추적할 필요가 줄어듭니다.

댕글링 참조

포인터를 사용하는 언어에서는, 어떤 메모리를 해제한 뒤 그 메모리를 가리키는 포인터를 그대로 남겨 두는 실수를 통해 댕글링 포인터(dangling pointer) 를 만들기 쉽습니다. 댕글링 포인터는 이미 다른 것에 재할당되었을 수도 있는 메모리 위치를 참조합니다. 반면 러스트에서는 컴파일러가 참조가 절대 댕글링 참조가 되지 않도록 보장합니다. 어떤 데이터에 대한 참조가 있다면, 컴파일러는 그 참조가 살아 있는 동안 데이터가 먼저 스코프를 벗어나지 않도록 확인합니다.

러스트가 컴파일 시점 오류로 이를 어떻게 막는지 보기 위해, 댕글링 참조를 일부러 만들어 보겠습니다.

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 {
  |

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

이 오류 메시지에는 아직 다루지 않은 기능인 라이프타임이 언급됩니다. 라이프타임은 10장에서 자세히 다룹니다. 하지만 라이프타임 부분을 잠시 무시하고 보면, 이 메시지는 왜 이 코드가 문제인지에 대한 핵심을 담고 있습니다.

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from

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 는 할당 해제됩니다. 그런데 우리는 그 값에 대한 참조를 반환하려 했습니다. 그러면 이 참조는 무효한 String 을 가리키게 됩니다. 좋지 않지요! 러스트는 이런 코드를 허용하지 않습니다.

여기서 해결책은 String 을 직접 반환하는 것입니다.

fn main() {
    let string = no_dangle();
}

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

이 코드는 아무 문제 없이 동작합니다. 소유권이 함수 밖으로 이동하므로, 아무 것도 미리 해제되지 않습니다.

참조 규칙

지금까지 참조에 대해 다룬 내용을 정리해 봅시다.

  • 어떤 시점이든, 가변 참조는 하나만 있거나, 아니면 불변 참조를 몇 개든 가질 수 있습니다.
  • 참조는 언제나 유효해야 합니다.

다음으로는 또 다른 종류의 참조인 슬라이스를 살펴보겠습니다.

슬라이스 타입

슬라이스 타입

슬라이스(slices)컬렉션 안에서 연속된 요소 시퀀스를 참조할 수 있게 해 줍니다. 슬라이스는 참조의 한 종류이므로 소유권을 갖지 않습니다.

작은 프로그래밍 문제를 하나 생각해 봅시다. 공백으로 구분된 여러 단어를 담고 있는 문자열을 받아서, 그 문자열에서 첫 번째 단어를 반환하는 함수를 작성한다고 합시다. 함수가 문자열 안에서 공백을 찾지 못하면, 문자열 전체가 하나의 단어라는 뜻이므로 문자열 전체를 반환해야 합니다.

Note: 슬라이스를 소개하는 목적상, 이 절에서는 ASCII만 다룬다고 가정합니다. UTF-8 처리에 대한 더 자세한 내용은 8장의 [“문자열에 UTF-8 텍스트 저장하기”] strings 절에서 설명합니다.

먼저 슬라이스를 사용하지 않고 이 함수의 시그니처를 어떻게 작성할지를 살펴보며, 슬라이스가 해결해 줄 문제를 이해해 봅시다.

fn first_word(s: &String) -> ?

first_word 함수는 &String 타입의 매개변수를 가집니다. 소유권은 필요 없으므로 이 점은 괜찮습니다. (관용적인 러스트에서는 함수가 정말 필요하지 않은 한 인수의 소유권을 가져가지 않으며, 왜 그런지는 계속 진행하면서 분명해질 것입니다.) 하지만 무엇을 반환해야 할까요? 우리는 문자열의 일부 에 대해 적절히 말할 방법이 없습니다. 다만 단어 끝 위치를 나타내는 인덱스, 즉 공백의 위치를 반환할 수는 있습니다. 목록 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-8의 프로그램은 목록 4-7의 first_word 함수를 사용하는 예입니다.

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 를 사용해 첫 번째 단어를 꺼내려고 시도할 수 있겠지만, 5word 에 저장한 이후 s 의 내용이 바뀌었으므로 이것은 버그가 됩니다.

word 안의 인덱스가 s 안의 데이터와 어긋나지 않도록 계속 신경 써야 하는 것은 성가시고 오류를 만들기 쉽습니다. second_word 함수를 작성하면 이런 인덱스 관리는 더 취약해집니다. 시그니처는 다음처럼 되어야 할 것입니다.

fn second_word(s: &String) -> (usize, usize) {

이제 시작 인덱스 끝 인덱스를 함께 추적해야 하고, 모두 특정 시점의 데이터에서 계산된 값이지만 그 상태와는 전혀 연결되지 않은 값들입니다. 서로 관련 없는 변수 셋이 떠다니며 서로 동기화되어야 하는 상황이 된 것입니다.

다행히 러스트에는 이 문제를 해결하는 방법이 있습니다. 바로 문자열 슬라이스입니다.

문자열 슬라이스

문자열 슬라이스(string slice)String 의 연속된 일부 요소에 대한 참조이며, 다음처럼 생겼습니다.

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

    let hello = &s[0..5];
    let world = &s[6..11];
}

hello 는 문자열 전체 String 에 대한 참조가 아니라, 추가된 [0..5] 부분으로 지정된 String 의 일부에 대한 참조입니다. 슬라이스는 대괄호 안에 범위를 넣어 만듭니다. 즉 [starting_index..ending_index] 형태로 쓰며, starting_index 는 슬라이스의 첫 번째 위치이고, ending_index 는 슬라이스의 마지막 위치보다 하나 큰 값입니다. 내부적으로 슬라이스 데이터 구조는 시작 위치와 슬라이스 길이를 저장하며, 이 길이는 ending_index 에서 starting_index 를 뺀 값에 해당합니다. 따라서 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 represents 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 의 일부를 가리키는 문자열 슬라이스

러스트의 .. 범위 문법에서는 인덱스 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[..];
}

Note: 문자열 슬라이스 범위 인덱스는 반드시 유효한 UTF-8 문자 경계에 있어야 합니다. 멀티바이트 문자의 한가운데에서 문자열 슬라이스를 만들려고 하면 프로그램은 오류와 함께 종료됩니다.

이제 이 정보를 바탕으로 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() {}

단어 끝 인덱스를 찾는 방식은 목록 4-7과 동일합니다. 즉, 첫 번째 공백을 찾습니다. 공백을 찾으면 문자열의 시작부터 공백 인덱스까지를 시작과 끝 인덱스로 하는 문자열 슬라이스를 반환합니다.

이제 first_word 를 호출하면, 우리는 기반 데이터와 연결된 하나의 값을 돌려받습니다. 이 값은 슬라이스 시작 위치에 대한 참조와 슬라이스에 포함된 요소 수로 이루어져 있습니다.

슬라이스를 반환하는 방식은 second_word 함수에도 잘 맞습니다.

fn second_word(s: &String) -> &str {

이제는 훨씬 단순하고 실수하기 어려운 API를 갖게 되었습니다. 컴파일러가 String 안으로 들어가는 참조들이 유효하게 유지되는지 보장해 주기 때문입니다. 목록 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 안의 참조를 사용하므로, 그 시점까지는 불변 참조가 여전히 살아 있어야 합니다. 러스트는 clear 안의 가변 참조와 word 안의 불변 참조가 동시에 존재하는 것을 허용하지 않고, 컴파일에 실패합니다. 러스트는 API를 더 쓰기 쉽게 만들었을 뿐 아니라, 컴파일 시점에 하나의 버그 범주 전체를 제거해 준 것입니다.

문자열 리터럴은 슬라이스이다

앞에서 문자열 리터럴이 바이너리 안에 저장된다고 이야기했습니다. 이제 슬라이스를 알게 되었으니 문자열 리터럴도 정확히 이해할 수 있습니다.

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

여기서 s 의 타입은 &str 입니다. 즉 바이너리 안 특정 지점을 가리키는 슬라이스입니다. 이것이 문자열 리터럴이 불변인 이유이기도 합니다. &str 은 불변 참조이기 때문입니다.

문자열 슬라이스를 매개변수로 받기

문자열 리터럴과 String 값 모두에서 슬라이스를 취할 수 있다는 사실을 알게 되면, first_word 에 대해 한 가지 더 개선할 수 있습니다. 바로 시그니처입니다.

fn first_word(s: &String) -> &str {

경험 많은 Rustacean이라면 목록 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: 매개변수 s 의 타입으로 문자열 슬라이스를 사용해 first_word 함수 개선하기

이미 문자열 슬라이스가 있다면 그대로 전달하면 됩니다. String 이 있다면 그 String 의 슬라이스를 넘기거나, String 에 대한 참조를 넘길 수 있습니다. 이런 유연성은 역참조 강제(deref coercion)를 활용한 것으로, 이는 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장에서 이런 컬렉션을 자세히 설명합니다.

정리

소유권, 대여, 슬라이스 개념은 컴파일 시점에 러스트 프로그램의 메모리 안전성을 보장해 줍니다. 러스트는 다른 시스템 프로그래밍 언어와 같은 수준의 메모리 사용 제어권을 제공하면서도, 데이터의 소유자가 스코프를 벗어날 때 그 데이터를 자동으로 정리하게 함으로써 그 제어를 얻기 위해 추가 코드를 쓰고 디버깅할 필요를 줄여 줍니다.

소유권은 러스트의 다른 많은 부분이 작동하는 방식에 영향을 주므로, 책의 나머지 부분에서도 이 개념들을 계속 다루게 됩니다. 이제 5장으로 넘어가 struct 로 여러 데이터 조각을 한데 묶는 방법을 살펴봅시다.

관련된 데이터를 구조체로 표현하기

struct 또는 structure 는 의미 있는 하나의 묶음을 이루는 여러 관련 값을 한데 묶고 이름 붙일 수 있게 해 주는 사용자 정의 데이터 타입입니다. 객체 지향 언어에 익숙하다면, 구조체는 객체의 데이터 속성과 비슷하다고 생각할 수 있습니다. 이 장에서는 튜플과 구조체를 비교하고 대조하면서, 여러분이 이미 알고 있는 내용을 바탕으로 언제 구조체가 데이터를 묶는 더 나은 방법이 되는지 보여 주겠습니다.

구조체를 정의하고 인스턴스를 만드는 방법을 살펴보고, 구조체 타입에 연결된 동작을 표현하기 위해 연관 함수를 어떻게 정의하는지도 설명합니다. 특히 연관 함수 중에서도 메서드 라고 부르는 종류를 다룹니다. 구조체와 6장에서 다룰 enum은, 러스트의 컴파일 시 타입 검사를 최대한 활용할 수 있도록 프로그램 도메인에 맞는 새로운 타입을 만들어 내는 기본 재료입니다.

구조체 정의와 인스턴스 생성

구조체 정의와 인스턴스 생성

구조체는 “튜플 타입” 절에서 다룬 튜플과 비슷합니다. 둘 다 여러 관련 값을 함께 담는다는 점이 그렇습니다. 튜플처럼 구조체 안의 각 요소도 서로 다른 타입일 수 있습니다. 하지만 튜플과 달리 구조체에서는 각 데이터 조각에 이름을 붙이므로, 그 값이 무엇을 뜻하는지 더 명확해집니다. 이런 이름 덕분에 구조체는 튜플보다 더 유연합니다. 인스턴스의 값을 지정하거나 접근할 때 데이터의 순서에 의존할 필요가 없기 때문입니다.

구조체를 정의하려면 struct 키워드를 쓰고 구조체 전체의 이름을 붙입니다. 구조체 이름은 함께 묶인 데이터 조각들이 무엇을 의미하는지 설명할 수 있어야 합니다. 그런 다음 중괄호 안에 데이터 조각의 이름과 타입을 정의하는데, 이것들을 필드(fields) 라고 부릅니다. 예를 들어 목록 5-1은 사용자 계정 정보를 저장하는 구조체를 보여 줍니다.

Filename: src/main.rs
struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

fn main() {}
Listing 5-1: User 구조체 정의

구조체를 정의한 뒤에는 각 필드에 구체적인 값을 지정해 그 구조체의 인스턴스(instance) 를 만들 수 있습니다. 인스턴스를 만들려면 구조체 이름 뒤에 중괄호를 쓰고 그 안에 key: value 쌍을 넣습니다. 여기서 키는 필드 이름이고 값은 그 필드에 저장할 데이터입니다. 필드를 구조체 정의와 같은 순서로 적을 필요는 없습니다. 다시 말해 구조체 정의는 타입을 위한 일반적인 템플릿 같은 것이고, 인스턴스는 그 템플릿에 구체적인 데이터를 채워 넣어 해당 타입의 값을 만들어 냅니다. 예를 들어 특정 사용자를 목록 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 을 사용합니다. 인스턴스가 가변이라면, 점 표기법으로 특정 필드에 대입하여 값을 바꿀 수도 있습니다. 목록 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 필드 값 바꾸기

인스턴스 전체가 가변이어야 한다는 점에 주의하세요. 러스트는 일부 필드만 따로 가변으로 표시하는 것을 허용하지 않습니다. 또한 다른 식들과 마찬가지로, 함수 본문의 마지막 식으로 구조체의 새 인스턴스를 만들어 암묵적으로 반환할 수도 있습니다.

목록 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에서는 매개변수 이름과 구조체 필드 이름이 정확히 같기 때문에, 필드 초기화 축약(field init shorthand) 문법을 사용하여 build_user 를 목록 5-5처럼 다시 쓸 수 있습니다. 동작은 완전히 같지만 usernameemail 을 반복해서 적지 않아도 됩니다.

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 함수

여기서 우리는 email 이라는 필드를 가진 새 User 구조체 인스턴스를 만들고 있습니다. 그리고 build_user 함수의 email 매개변수에 들어 있는 값을 구조체 필드 email 의 값으로 넣고 싶습니다. 필드 이름과 매개변수 이름이 같기 때문에 email: email 대신 그냥 email 이라고만 써도 됩니다.

구조체 갱신 문법으로 인스턴스 만들기

같은 타입의 다른 인스턴스에 있는 값 대부분을 그대로 사용하되, 일부만 바꾼 새 인스턴스를 만들고 싶을 때가 자주 있습니다. 이때 구조체 갱신 문법(struct update syntax)을 사용할 수 있습니다.

먼저 목록 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: 새 email 값을 갖되 나머지 값은 user1 에서 가져오도록 구조체 갱신 문법 사용하기

목록 5-7의 코드는 email 값만 다른 새 인스턴스 user2 를 만들고, username, active, sign_in_count 필드는 모두 user1 의 값을 사용합니다. ..user1 은 남은 필드들이 user1 의 대응하는 필드에서 값을 가져오도록 지정하는 문법이므로 반드시 마지막에 와야 합니다. 다만 구조체 정의에서 필드가 선언된 순서와 상관없이, 원하는 만큼의 필드에 대해 어떤 순서로든 값을 직접 지정할 수 있습니다.

구조체 갱신 문법이 대입문처럼 = 를 사용하는 데 주목하세요. 이것은 “이동(move)으로 변수와 데이터가 상호작용하는 방식” 절에서 보았듯이 데이터가 이동되기 때문입니다. 이 예제에서는 user1username 필드 안에 있던 Stringuser2 로 이동했으므로, user2 를 만든 뒤에는 user1 을 더 이상 사용할 수 없습니다. 만약 user2email 뿐 아니라 username 도 새 String 값을 넣고, user1 에서 activesign_in_count 값만 사용했다면, user2 생성 이후에도 user1 은 여전히 유효했을 것입니다. activesign_in_count 는 둘 다 Copy 트레이트를 구현하는 타입이기 때문에, “스택에만 있는 데이터: Copy 절에서 설명한 동작이 적용되기 때문입니다. 이 예제에서는 user1.email 도 계속 사용할 수 있습니다. 그 값은 user1 밖으로 이동되지 않았기 때문입니다.

튜플 구조체로 서로 다른 타입 만들기

러스트는 튜플과 비슷하게 생긴 구조체도 지원하는데, 이것을 튜플 구조체(tuple struct) 라고 부릅니다. 튜플 구조체는 구조체 이름이 부여하는 의미는 가지지만, 각 필드에 이름이 붙지는 않습니다. 대신 필드의 타입만 가집니다. 튜플 구조체는 튜플 전체에 이름을 붙여 다른 튜플과 구별되는 타입으로 만들고 싶을 때, 그리고 일반 구조체처럼 각 필드에 이름을 붙이는 것이 장황하거나 중복일 때 유용합니다.

튜플 구조체를 정의하려면 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 타입을 받는 함수는, 둘 다 세 개의 i32 값으로 이루어져 있더라도 Point 를 인수로 받을 수 없습니다. 이 점을 제외하면 튜플 구조체 인스턴스는 일반 튜플과 비슷해서, 각 부분으로 구조분해할 수도 있고 . 와 인덱스로 개별 값에 접근할 수도 있습니다. 다만 구조분해할 때는 일반 튜플과 달리 튜플 구조체 이름을 명시해야 합니다. 예를 들어 origin 안의 값을 x, y, z 변수로 구조분해하려면 let Point(x, y, z) = origin; 처럼 씁니다.

유닛 유사 구조체 정의하기

필드가 전혀 없는 구조체도 정의할 수 있습니다! 이런 구조체는 “튜플 타입” 절에서 언급한 유닛 타입 () 와 비슷하게 동작하기 때문에 유닛 유사 구조체(unit-like structs) 라고 부릅니다. 유닛 유사 구조체는 어떤 타입에 트레이트를 구현해야 하지만, 그 타입 자체 안에 저장하고 싶은 데이터는 없을 때 유용합니다. 트레이트는 10장에서 다룹니다. 다음은 AlwaysEqual 이라는 유닛 구조체를 선언하고 인스턴스를 만드는 예입니다.

Filename: src/main.rs
struct AlwaysEqual;

fn main() {
    let subject = AlwaysEqual;
}

AlwaysEqual 을 정의할 때는 struct 키워드와 원하는 이름 뒤에 세미콜론만 적습니다. 중괄호도 괄호도 필요 없습니다! 그런 다음 subject 변수 안에 AlwaysEqual 인스턴스를 만들 수 있는데, 이 역시 중괄호나 괄호 없이 이름만 사용하는 비슷한 방식입니다. 나중에 이 타입에, 어떤 AlwaysEqual 인스턴스든 다른 어떤 인스턴스와도 항상 같다고 판단하는 동작을 구현할 수도 있다고 상상해 봅시다. 예를 들어 테스트할 때 항상 예측 가능한 결과를 얻고 싶을 수 있습니다. 그런 동작을 구현하는 데는 별도의 데이터가 필요하지 않습니다! 10장에서는 유닛 유사 구조체를 포함해 어떤 타입이든 트레이트를 정의하고 구현하는 방법을 배우게 됩니다.

구조체 데이터의 소유권

목록 5-1의 User 구조체 정의에서는 문자열 슬라이스 타입 &str 대신, 소유권을 가지는 String 타입을 사용했습니다. 이것은 의도적인 선택입니다. 구조체의 각 인스턴스가 자신의 데이터를 전부 소유하고, 구조체 전체가 유효한 동안 그 데이터도 유효하길 원하기 때문입니다.

구조체가 다른 무언가가 소유한 데이터에 대한 참조를 저장하도록 만드는 것도 가능합니다. 하지만 그렇게 하려면 10장에서 다룰 러스트 기능인 라이프타임 을 사용해야 합니다. 라이프타임은 구조체가 참조하는 데이터가 구조체가 살아 있는 동안 유효함을 보장합니다. 예를 들어 src/main.rs 에서 다음처럼 라이프타임 없이 구조체 안에 참조를 저장하려 하면, 이 코드는 동작하지 않습니다.

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 함수는 원래 하나의 직사각형 넓이를 계산해야 하는데, 지금 작성한 함수는 매개변수가 둘입니다. 그리고 이 매개변수 둘이 서로 관련 있다는 사실이 프로그램 어디에도 드러나지 않습니다. 너비와 높이를 함께 묶는 편이 더 읽기 쉽고 관리하기 쉬울 것입니다. 이를 위한 한 가지 방법은 이미 3장의 “튜플 타입” 절에서 다뤘습니다. 튜플을 사용하는 방식입니다.

튜플로 리팩터링하기

목록 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 안에서는 너비가 30, 높이가 50 인 특정 Rectangle 인스턴스를 만들었습니다.

이제 area 함수는 rectangle 이라는 이름의 하나의 매개변수만 받는데, 그 타입은 Rectangle 구조체 인스턴스에 대한 불변 대여입니다. 4장에서 설명했듯이, 구조체의 소유권을 가져가는 대신 빌려 쓰고 싶기 때문에 참조를 사용합니다. 그렇게 하면 main 이 소유권을 계속 유지할 수 있고, 그래서 함수 시그니처와 호출 부분에 & 를 붙입니다.

area 함수는 Rectangle 인스턴스의 width, height 필드에 접근합니다 (빌린 구조체 인스턴스의 필드에 접근하는 것은 필드 값을 이동시키지 않는다는 점에 주의하세요. 그래서 러스트에서는 구조체를 빌리는 코드를 자주 보게 됩니다). 이제 area 의 함수 시그니처는 우리가 의도한 바를 정확히 말해 줍니다. Rectangle 의 넓이를 widthheight 필드를 사용해 계산하라는 뜻입니다. 이렇게 하면 너비와 높이가 서로 관련된 값이라는 사실이 드러나고, 튜플 인덱스 0, 1 대신 설명적인 이름을 붙일 수 있습니다. 명확성 면에서 훨씬 낫습니다.

파생 트레이트로 유용한 기능 추가하기

프로그램을 디버깅할 때 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! 매크로는 여러 가지 출력 형식을 지원하며, 기본적으로 중괄호는 Display 라는 형식을 사용하라는 뜻입니다. 이것은 최종 사용자가 바로 소비할 출력을 의도한 형식입니다. 지금까지 본 기본 타입들은 사용자에게 1 이나 다른 기본값을 보여 주는 방식이 사실상 하나뿐이므로 기본적으로 Display 를 구현합니다. 하지만 구조체는 상황이 다릅니다. println! 이 어떤 형식으로 출력해야 할지가 명확하지 않기 때문입니다. 쉼표를 넣을까요? 중괄호를 출력할까요? 모든 필드를 보여 줄까요? 이런 모호함 때문에, 러스트는 우리가 무엇을 원하는지 추측하지 않습니다. 그래서 구조체에는 println!{} 플레이스홀더에 사용할 수 있는 Display 구현이 기본 제공되지 않습니다.

오류 메시지를 더 읽어 내려가면 이런 도움말을 찾을 수 있습니다.

   |                        |`Rectangle` cannot be formatted with the default formatter
   |                        required by this formatting parameter

한번 해 봅시다! 이제 println! 호출은 println!("rect1 is {rect1:?}"); 처럼 생기게 됩니다. 중괄호 안에 :? 지정자를 넣으면, println! 에게 Debug 라는 출력 형식을 사용하고 싶다고 알려 주는 것입니다. Debug 트레이트는 개발자에게 유용한 형식으로 구조체를 출력하게 해 주어, 디버깅 중에 그 값을 확인할 수 있게 합니다.

이 변경을 적용한 뒤 코드를 컴파일해 보세요. 아쉽게도 여전히 오류가 납니다.

error[E0277]: `Rectangle` doesn't implement `Debug`

하지만 이번에도 컴파일러는 유용한 힌트를 줍니다.

   |                        required by this formatting parameter
   |

러스트는 실제로 디버깅 정보를 출력하는 기능을 제공하지만, 우리 구조체에서 그 기능을 쓰겠다고 명시적으로 선택해야 합니다. 그렇게 하려면 목록 5-12처럼 구조체 정의 바로 앞에 #[derive(Debug)] 라는 바깥 속성을 추가합니다.

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,
}

또 다른 방법으로는 dbg! 매크로를 사용할 수 있습니다. dbg! 는 참조를 받는 println! 과 달리, 식의 소유권을 가져와서 그 식이 있는 파일과 줄 번호, 그리고 그 식의 결과값을 함께 출력한 뒤, 다시 그 값의 소유권을 반환합니다.

Note: dbg! 매크로는 println! 이 사용하는 표준 출력 스트림(stdout)이 아니라, 표준 에러 스트림(stderr)에 출력합니다. 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);
}

30 * scale 식을 dbg! 로 감싸면, dbg! 는 식의 값 소유권을 돌려주기 때문에 width 필드는 dbg! 가 없을 때와 같은 값을 그대로 갖게 됩니다. 반면 rect1 의 소유권은 dbg! 에 빼앗기고 싶지 않으므로, 다음 호출에서는 &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 타입의 예쁜 Debug 형식을 사용합니다. dbg! 매크로는 코드가 실제로 무엇을 하고 있는지 파악할 때 아주 도움이 됩니다.

Debug 트레이트 외에도, 러스트는 derive 속성과 함께 사용할 수 있는 여러 트레이트를 제공하며, 이것들은 사용자 정의 타입에 유용한 동작을 추가해 줍니다. 그런 트레이트와 그 동작 목록은 부록 C에 정리되어 있습니다. 10장에서는 이런 트레이트를 직접 구현해 원하는 동작을 정의하는 방법과, 여러분 자신의 트레이트를 만드는 방법도 다룹니다. 또한 derive 외에도 다양한 속성이 있는데, 자세한 내용은 Rust Reference의 “Attributes” 절을 참고하세요.

현재 area 함수는 매우 특수합니다. 사각형 넓이만 계산할 수 있습니다. 이 동작을 Rectangle 구조체와 더 강하게 연결할 수 있다면 좋겠습니다. 다른 타입에서는 동작하지 않기 때문입니다. 다음으로는 area 함수를 Rectangle 타입 위에 정의된 area 메서드로 바꾸면서 이 코드를 계속 리팩터링해 보겠습니다.

메서드

메서드

메서드는 함수와 비슷합니다. fn 키워드와 이름으로 선언하고, 매개변수와 반환값을 가질 수 있으며, 다른 곳에서 메서드가 호출되었을 때 실행할 코드를 포함합니다. 하지만 함수와 달리 메서드는 구조체(또는 6장과 18장에서 각각 다룰 enum, trait object)의 맥락 안에서 정의되며, 첫 번째 매개변수는 언제나 self 입니다. self 는 메서드가 호출되는 구조체 인스턴스를 나타냅니다.

메서드 문법

Rectangle 인스턴스를 매개변수로 받는 area 함수를 바꿔, 목록 5-13처럼 Rectangle 구조체 위에 정의된 area 메서드로 만들어 봅시다.

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 (implementation) 블록을 시작합니다. 이 impl 블록 안에 있는 모든 것은 Rectangle 타입과 연관됩니다. 그런 다음 area 함수를 impl 중괄호 안으로 옮기고, 첫 번째 매개변수(이 경우 유일한 매개변수)를 시그니처와 본문 모두에서 self 로 바꿉니다. main 에서는 원래 area 함수에 rect1 을 인수로 넘겨 호출했지만, 이제는 메서드 문법 을 사용해 Rectangle 인스턴스 위에서 직접 area 메서드를 호출할 수 있습니다. 메서드 문법은 인스턴스 뒤에 점과 메서드 이름, 괄호, 그리고 필요한 인수를 붙이는 형태입니다.

area 의 시그니처에서는 rectangle: &Rectangle 대신 &self 를 사용합니다. 사실 &selfself: &Self 의 축약형입니다. impl 블록 안에서 Self 타입은 이 impl 블록이 구현하는 타입의 별칭이며, 여기서는 Rectangle 입니다. 메서드는 첫 번째 매개변수로 Self 타입의 self 라는 이름을 가져야 하므로, 러스트는 첫 번째 매개변수 자리에서 이 표현을 단순히 self 로 줄여 쓰도록 허용합니다. 또한 이 메서드가 Self 인스턴스를 빌려 쓴다는 사실을 나타내려면, rectangle: &Rectangle 에서 했던 것처럼 self 앞에 여전히 & 를 붙여야 한다는 점에 주의하세요. 메서드는 다른 매개변수와 마찬가지로 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 필드 값이 0보다 크면 true, 0이면 false 를 반환하는 width 메서드를 만들기로 했습니다. 이렇게 같은 이름의 필드를 메서드 안에서 다른 목적으로 얼마든지 사용할 수 있습니다. main 에서 rect1.width 뒤에 괄호를 붙이면 러스트는 우리가 width 메서드를 의미한다고 압니다. 괄호가 없으면 width 필드를 의미한다고 압니다.

언제나 그런 것은 아니지만, 메서드 이름을 필드 이름과 같게 지을 때는 그 메서드가 대개 필드 값을 그대로 반환하고 다른 일은 하지 않도록 만들고 싶어 하는 경우가 많습니다. 이런 메서드를 getter 라고 합니다. 러스트는 다른 일부 언어와 달리 구조체 필드에 대한 getter를 자동으로 만들어 주지 않습니다. getter는 필드는 비공개로 두되 메서드는 공개로 만들어, 타입의 공개 API 일부로 읽기 전용 접근을 제공하고 싶을 때 유용합니다. 공개와 비공개가 무엇인지, 필드와 메서드를 어떻게 공개/비공개로 표시하는지는 7장에서 설명합니다.

-> 연산자는 어디에 있나요?

C와 C++에서는 메서드를 호출할 때 두 종류의 연산자를 사용합니다. 객체 자체에 메서드를 호출할 때는 ., 객체를 가리키는 포인터에 대해 메서드를 호출하고 먼저 포인터를 역참조해야 할 때는 -> 를 씁니다. 즉 object 가 포인터라면, object->something()(*object).something() 과 비슷합니다.

러스트에는 -> 연산자와 같은 것이 없습니다. 대신 러스트에는 자동 참조 및 역참조(automatic referencing and dereferencing) 라는 기능이 있습니다. 메서드 호출은 러스트에서 이 동작이 일어나는 몇 안 되는 곳 중 하나입니다.

동작 방식은 이렇습니다. object.something() 처럼 메서드를 호출하면, 러스트는 object 가 메서드 시그니처와 맞도록 자동으로 &, &mut, 또는 * 를 추가합니다. 다시 말해 다음 둘은 같은 의미입니다.

#![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);
}

첫 번째 형태가 훨씬 깔끔해 보이지요. 이런 자동 참조 동작이 가능한 이유는, 메서드에는 명확한 수신자(receiver), 즉 self 의 타입이 있기 때문입니다. 러스트는 수신자와 메서드 이름을 바탕으로, 그 메서드가 읽기(&self), 변경(&mut self), 소모(self) 중 무엇을 하는지 확실히 알 수 있습니다. 러스트가 메서드 수신자에 대해서는 대여를 암묵적으로 처리해 주는 사실이, 실제 사용에서 소유권을 훨씬 다루기 쉽게 만들어 주는 중요한 이유 중 하나입니다.

추가 매개변수를 가지는 메서드

메서드를 직접 하나 더 구현해 보면서 연습해 봅시다. 이번에는 Rectangle 인스턴스가 또 다른 Rectangle 인스턴스를 받아, 두 번째 Rectangleself(첫 번째 Rectangle) 안에 완전히 들어갈 수 있으면 true, 그렇지 않으면 false 를 반환하게 하고 싶습니다. 즉, can_hold 메서드를 정의한 뒤에는 목록 5-14와 같은 프로그램을 작성할 수 있어야 합니다.

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 를 읽기만 하면 되고(쓰기 위해서는 가변 대여가 필요합니다), 메서드 호출 후에도 mainrect2 의 소유권을 유지해 다시 사용할 수 있게 하고 싶기 때문입니다. can_hold 의 반환값은 불리언이고, 구현은 self 의 너비와 높이가 각각 다른 Rectangle 의 너비와 높이보다 큰지 확인하면 됩니다. 목록 5-15처럼 목록 5-13의 impl 블록 안에 새 can_hold 메서드를 추가해 봅시다.

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: 다른 Rectangle 인스턴스를 매개변수로 받는 can_hold 메서드를 Rectangle 에 구현하기

이제 목록 5-14의 main 함수와 함께 이 코드를 실행하면 원하는 출력이 나옵니다. 메서드는 self 매개변수 뒤에 원하는 만큼의 추가 매개변수를 받을 수 있고, 그 매개변수들은 함수의 매개변수와 똑같이 동작합니다.

연관 함수

impl 블록 안에 정의된 모든 함수는 연관 함수(associated functions) 라고 부릅니다. impl 뒤에 적힌 타입과 연관되어 있기 때문입니다. 이들 중에는 첫 번째 매개변수로 self 를 갖지 않는 함수도 정의할 수 있습니다(따라서 메서드는 아닙니다). 그런 함수들은 작업하는 데 타입의 인스턴스가 필요하지 않기 때문입니다. 우리는 이미 이런 함수 하나를 사용해 봤습니다. String 타입에 정의된 String::from 함수입니다.

메서드가 아닌 연관 함수는 보통 새 구조체 인스턴스를 반환하는 생성자 역할에 자주 사용합니다. 이런 함수 이름은 흔히 new 이지만, new 는 특별한 이름도 아니고 언어에 내장된 것도 아닙니다. 예를 들어 한 개의 길이만 받아 그 값을 너비와 높이 둘 다로 사용함으로써 정사각형 Rectangle 을 더 쉽게 만들 수 있도록, square 라는 연관 함수를 제공한다고 해 봅시다. 이렇게 하면 같은 값을 두 번 적지 않아도 됩니다.

파일명: 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: 목록 5-15를 여러 개의 impl 블록으로 다시 쓰기

여기서는 메서드를 굳이 여러 impl 블록으로 나눌 이유가 없지만, 문법적으로는 유효합니다. 10장에서 제네릭 타입과 트레이트를 다룰 때 여러 impl 블록이 유용한 경우를 보게 됩니다.

정리

구조체는 도메인에 의미가 있는 사용자 정의 타입을 만들 수 있게 해 줍니다. 구조체를 사용하면 서로 관련된 데이터 조각을 한데 묶어 두고, 각 조각에 이름을 붙여 코드가 무엇을 의미하는지 더 분명하게 만들 수 있습니다. impl 블록 안에서는 타입과 연관된 함수를 정의할 수 있고, 메서드는 그 연관 함수의 한 종류로서 구조체 인스턴스가 어떤 동작을 하는지를 표현하게 해 줍니다.

하지만 사용자 정의 타입을 만드는 방법이 구조체뿐인 것은 아닙니다. 이제 러스트의 enum 기능으로 넘어가, 도구 상자에 또 하나의 중요한 도구를 추가해 봅시다.

열거형과 패턴 매칭

이 장에서는 enum 이라고도 부르는 열거형(enumeration)을 살펴봅니다. enum은 가능한 variant들을 열거함으로써 타입을 정의하게 해 줍니다. 먼저 enum을 정의하고 사용하는 방법을 보면서, enum이 데이터를 담는 동시에 의미도 표현할 수 있음을 보여 주겠습니다. 다음으로는 값이 어떤 것이거나 아무 것도 아닐 수 있음을 표현하는, 특히 유용한 Option 이라는 enum을 살펴봅니다. 그런 뒤 match 식 안의 패턴 매칭이 enum의 서로 다른 값에 따라 다른 코드를 실행하는 일을 얼마나 쉽게 만드는지 봅니다. 마지막으로, 코드에서 enum을 다룰 때 사용할 수 있는 또 다른 편리하고 간결한 관용구인 if let 구문을 다룹니다.

열거형 정의하기

열거형 정의하기

구조체가 widthheight 를 가진 Rectangle 처럼 관련된 필드와 데이터를 함께 묶는 방법을 제공한다면, enum은 어떤 값이 “가능한 값들의 집합 중 하나”라고 말할 수 있는 방법을 제공합니다. 예를 들어 RectangleCircle, Triangle 도 포함하는 여러 가능한 도형 중 하나라고 표현하고 싶을 수 있습니다. 이를 위해 러스트는 이런 가능성들을 enum으로 인코딩하게 해 줍니다.

코드에서 표현하고 싶은 한 가지 상황을 살펴보면서, 왜 이 경우 enum이 유용하고 구조체보다 더 적절한지 알아봅시다. IP 주소를 다뤄야 한다고 해 보겠습니다. 현재 IP 주소에는 두 가지 주요 표준이 있습니다. 버전 4와 버전 6입니다. 프로그램이 만날 수 있는 IP 주소의 가능성은 이 둘뿐이므로, 우리는 가능한 모든 variant를 열거(enumerate) 할 수 있습니다. 열거형(enumeration)이라는 이름도 여기서 나옵니다.

어떤 IP 주소든 버전 4이거나 버전 6일 수는 있지만, 동시에 둘 다일 수는 없습니다. 이 특성 때문에 enum 데이터 구조가 적합합니다. enum 값은 그 variant 중 하나만 될 수 있기 때문입니다. 버전 4 주소와 버전 6 주소는 둘 다 본질적으로 IP 주소이므로, 코드가 “모든 종류의 IP 주소에 공통으로 적용되는 상황”을 다룰 때는 같은 타입으로 취급되어야 합니다.

이 개념은 IpAddrKind 라는 enum을 정의하고, IP 주소가 될 수 있는 종류인 V4, V6 를 나열함으로써 코드로 표현할 수 있습니다. 이것들이 enum의 variant입니다.

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 는 코드의 다른 곳에서도 사용할 수 있는 사용자 정의 데이터 타입이 되었습니다.

enum 값

IpAddrKind 의 두 variant 각각의 인스턴스는 다음처럼 만들 수 있습니다.

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의 variant는 그 enum 식별자 아래에 네임스페이스가 나뉘어 있으며, 둘을 구분할 때 이중 콜론을 사용한다는 점에 주목하세요. 이는 유용합니다. 왜냐하면 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) {}

그리고 이 함수는 어느 variant로도 호출할 수 있습니다.

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을 사용하는 데는 더 많은 장점이 있습니다. 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 variant를 struct 로 저장하기

여기서는 IpAddr 라는 구조체를 정의했고, 두 개의 필드를 가집니다. 하나는 앞에서 정의한 enum 타입 IpAddrKind 를 가지는 kind 필드이고, 다른 하나는 String 타입의 address 필드입니다. 그리고 이 구조체의 인스턴스를 두 개 만들었습니다. 첫 번째 인스턴스 homekind 값으로 IpAddrKind::V4 를 가지고, 그에 대응하는 주소 데이터는 127.0.0.1 입니다. 두 번째 인스턴스 loopbackkind 값으로 다른 variant인 V6 를 가지며, 주소 ::1 을 함께 가집니다. 즉, 구조체를 사용해 kindaddress 값을 하나로 묶어 둔 것입니다.

하지만 같은 개념은 enum만으로도 더 간결하게 표현할 수 있습니다. 구조체 안에 enum을 넣는 대신, enum variant 각각에 직접 데이터를 담을 수 있습니다. 다음 IpAddr enum 정의는 V4V6 variant가 모두 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"));
}

이제 각 enum variant에 데이터를 직접 붙였습니다. 그래서 별도의 구조체가 더 이상 필요하지 않습니다. 여기서는 enum이 어떻게 동작하는지에 대한 또 다른 중요한 세부도 더 쉽게 보입니다. 우리가 정의한 각 enum variant 이름은 그 enum 인스턴스를 만드는 함수 역할도 한다는 점입니다. 즉 IpAddr::V4()String 인수를 받아 IpAddr 타입 인스턴스를 반환하는 함수 호출입니다. enum을 정의하는 것만으로 이런 생성자 함수를 자동으로 얻게 됩니다.

구조체 대신 enum을 사용하는 또 다른 장점은, 각 variant가 서로 다른 타입과 개수의 데이터를 가질 수 있다는 점입니다. 버전 4 IP 주소는 항상 0에서 255 사이의 값을 가진 숫자 네 개로 이루어집니다. 만약 V4 주소는 네 개의 u8 값으로 저장하되, V6 주소는 하나의 String 값으로 표현하고 싶다면 구조체만으로는 어렵습니다. 하지만 enum은 이를 쉽게 처리할 수 있습니다.

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 를 어떻게 정의하는지 봅시다. 방금 우리가 직접 정의해 사용한 것과 정확히 같은 enum과 variant를 갖고 있지만, 주소 데이터는 각 variant마다 서로 다르게 정의된 두 구조체 형태로 안에 넣고 있습니다.

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --snip--
}

struct Ipv6Addr {
    // --snip--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

이 코드는 enum variant 안에 문자열, 숫자 타입, 구조체 등 어떤 종류의 데이터든 넣을 수 있음을 보여 줍니다. 심지어 다른 enum을 넣는 것도 가능합니다! 또한 표준 라이브러리의 타입들은 여러분이 직접 생각해 낼 수 있는 것보다 훨씬 더 복잡하지만은 않은 경우가 많습니다.

표준 라이브러리에 IpAddr 정의가 있더라도, 우리는 그 정의를 스코프로 가져오지 않았기 때문에 충돌 없이 우리 자신의 정의를 여전히 만들고 사용할 수 있다는 점에 주목하세요. 타입을 스코프로 가져오는 방법은 7장에서 더 이야기합니다.

이제 표준 라이브러리에서 또 하나 흔하고 유용한 enum을 살펴봅시다. 목록 6-2는 여러 variant 안에 다양한 타입이 들어 있는 enum의 예입니다.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}
Listing 6-2: 각 variant가 서로 다른 양과 타입의 값을 저장하는 Message enum

이 enum은 서로 다른 타입을 가진 네 개의 variant를 가집니다.

  • Quit: 어떤 데이터도 전혀 가지지 않습니다
  • Move: 구조체처럼 이름 붙은 필드를 가집니다
  • Write: 하나의 String 을 가집니다
  • ChangeColor: 세 개의 i32 값을 가집니다

목록 6-2처럼 variant가 서로 다른 형태를 가지는 enum을 정의하는 것은, 여러 종류의 구조체를 각각 따로 정의하는 것과 비슷합니다. 다만 enum은 struct 키워드를 사용하지 않고, 모든 variant가 하나의 Message 타입 아래에 함께 묶입니다. 다음 구조체들은 앞의 enum variant들이 담고 있던 것과 같은 데이터를 저장할 수 있습니다.

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 enum은 하나의 타입이므로 훨씬 간단합니다.

enum과 구조체 사이에는 또 다른 공통점이 있습니다. 구조체에 대해 impl 을 사용해 메서드를 정의할 수 있듯이, enum에도 메서드를 정의할 수 있습니다. 다음은 Message enum 위에 정의할 수 있는 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 를 사용해 메서드가 호출된 값을 가져옵니다. 이 예제에서는 Message::Write(String::from("hello")) 값을 가지는 변수 m 을 만들었고, m.call() 이 실행될 때 call 메서드 본문 안에서 self 는 바로 이 값이 됩니다.

이제 표준 라이브러리의 또 다른, 아주 흔하고 유용한 enum인 Option 을 살펴봅시다.

Option enum

이 절에서는 표준 라이브러리가 정의한 또 다른 enum인 Option 을 사례 연구처럼 살펴봅니다. Option 타입은 값이 어떤 것일 수도 있고, 아무 것도 아닐 수도 있는 아주 흔한 상황을 표현합니다.

예를 들어 비어 있지 않은 리스트에서 첫 번째 항목을 요청하면 값을 얻게 됩니다. 하지만 빈 리스트에서 첫 번째 항목을 요청하면 아무 것도 얻지 못합니다. 이런 개념을 타입 시스템으로 표현하면, 컴파일러는 여러분이 처리해야 할 모든 경우를 다루었는지 검사할 수 있습니다. 이런 기능은 다른 프로그래밍 언어에서 매우 흔한 버그를 막는 데 도움이 됩니다.

프로그래밍 언어 설계는 흔히 어떤 기능을 포함하느냐의 관점에서 이야기되지만, 어떤 기능을 제외하느냐 도 똑같이 중요합니다. 러스트에는 많은 다른 언어가 가진 null 기능이 없습니다. Null 은 거기에 값이 없다는 뜻의 값입니다. null이 있는 언어에서는 변수가 늘 두 가지 상태 중 하나가 될 수 있습니다. null 이거나, null 이 아니거나.

2009년 “Null References: The Billion Dollar Mistake” 발표에서, null의 창시자 Tony Hoare는 이렇게 말했습니다.

나는 이것을 내 10억 달러짜리 실수라고 부른다. 그 당시 나는 객체 지향 언어에서 참조를 위한 최초의 포괄적인 타입 시스템을 설계하고 있었다. 내 목표는 참조의 모든 사용이 절대적으로 안전하도록 만들고, 그 검사를 컴파일러가 자동으로 수행하게 하는 것이었다. 그러나 null 참조를 넣고 싶은 유혹을 이기지 못했다. 구현이 너무 쉬웠기 때문이다. 그 결과 수많은 오류와 취약점, 시스템 충돌이 발생했고, 지난 40년 동안 아마 10억 달러에 해당하는 고통과 피해를 만들어 냈을 것이다.

Null 값의 문제는, null 값을 null 이 아닌 값처럼 사용하려 하면 어떤 형태로든 오류가 발생한다는 점입니다. 그리고 “null 이거나 아니거나” 라는 성질은 너무 널리 퍼져 있어, 이런 종류의 실수를 저지르기가 매우 쉽습니다.

하지만 null이 표현하려던 개념 자체는 여전히 유용합니다. 어떤 이유로든 현재 값이 유효하지 않거나 존재하지 않는 상태를 나타내는 것이니까요.

문제는 사실 개념 자체가 아니라 구체적인 구현 방식에 있습니다. 그래서 러스트는 null은 없지만, 값의 존재 여부를 표현할 수 있는 enum을 제공합니다. 이 enum이 바로 Option<T> 이며, 표준 라이브러리에서 다음과 같이 정의되어 있습니다.

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

Option<T> enum은 너무 유용해서 prelude에 포함되어 있습니다. 즉 명시적으로 스코프로 가져올 필요가 없습니다. 그 variant 역시 prelude에 포함되어 있으므로 Option:: 접두사 없이 Some, None 을 바로 쓸 수 있습니다. 물론 Option<T> 는 여전히 그저 평범한 enum이고, Some(T)None 역시 Option<T> 타입의 variant입니다.

<T> 문법은 아직 이야기하지 않은 러스트의 기능입니다. 이것은 제네릭 타입 매개변수이며, 10장에서 더 자세히 다룹니다. 지금은 <T>Option enum의 Some variant가 아무 타입의 데이터 하나를 담을 수 있다는 뜻이고, 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> 이고, 이는 서로 다른 타입입니다. Some variant 안에 값을 넣었기 때문에 러스트는 이 타입들을 추론할 수 있습니다. 반면 absent_number 에 대해서는 전체 Option 타입을 주석으로 달아 주어야 합니다. None 값만 보고는, 여기에 대응하는 Some variant가 어떤 타입을 가질지 컴파일러가 알 수 없기 때문입니다. 그래서 여기서는 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

꽤 강렬하지요! 이 오류 메시지가 뜻하는 바는, 러스트가 i8Option<i8> 를 어떻게 더해야 할지 모르겠다는 것입니다. 왜냐하면 둘은 서로 다른 타입이기 때문입니다. 러스트에서 i8 같은 타입의 값을 갖는다는 것은, 컴파일러가 우리가 언제나 유효한 값을 가지고 있음을 보장해 준다는 뜻입니다. 따라서 그 값을 사용하기 전에 null인지 검사할 필요 없이 자신 있게 사용할 수 있습니다. 오직 Option<i8>(혹은 현재 다루는 다른 Option<T> 값)을 가질 때만, 값이 없을 가능성을 걱정해야 합니다. 그리고 컴파일러는 그 값을 사용하기 전에 그 경우를 반드시 처리하도록 강제합니다.

다시 말해, T 에 대한 연산을 수행하기 전에 Option<T>T 로 변환해야 합니다. 이것은 일반적으로 null과 관련된 가장 흔한 문제 중 하나, 즉 실제로는 값이 없는데 값이 있을 것이라고 가정하는 실수를 잡는 데 도움이 됩니다.

값이 null 이 아니라고 잘못 가정할 위험을 없애면 코드에 대해 훨씬 더 확신을 가질 수 있습니다. null 일 수도 있는 값을 가지려면, 그 값의 타입을 Option<T> 로 만들어 명시적으로 선택해야 합니다. 그리고 그 값을 사용할 때는 값이 없을 수도 있는 경우를 명시적으로 처리해야 합니다. 반대로 어떤 값의 타입이 Option<T> 가 아니라면, 그 값이 null이 아니라고 안전하게 가정할 수 있습니다. 이것은 null 이 코드 전체로 퍼지는 것을 제한하고 러스트 코드의 안전성을 높이기 위한, 러스트의 의도적인 설계 선택입니다.

그렇다면 Option<T> 값을 가지고 있을 때, 그 안의 Some variant에서 T 값을 어떻게 꺼내어 사용할까요? Option<T> enum에는 다양한 상황에서 유용한 메서드가 많이 정의되어 있으며, 문서에서 확인할 수 있습니다. 러스트를 배워 나가면서 Option<T> 의 메서드들에 익숙해지는 것은 매우 큰 도움이 됩니다.

일반적으로 Option<T> 값을 사용하려면, 각 variant를 모두 처리하는 코드를 작성해야 합니다. Some(T) 값을 가진 경우에만 실행되는 코드가 필요하며, 그 코드 안에서는 내부의 T 값을 사용할 수 있어야 합니다. 그리고 None 값을 가진 경우에만 실행되는 다른 코드도 필요합니다. 그 경우에는 사용할 T 값이 없습니다. match 식은 enum과 함께 쓸 때 바로 이 역할을 하는 제어 흐름 구문입니다. 어떤 enum variant를 가졌는지에 따라 서로 다른 코드를 실행하며, 그 코드 안에서는 매칭된 값 내부의 데이터를 사용할 수 있습니다.

match 제어 흐름 구문

match 제어 흐름 구문

러스트에는 match 라는 매우 강력한 제어 흐름 구문이 있습니다. 이 구문은 어떤 값을 일련의 패턴과 비교한 뒤, 어떤 패턴이 맞는지에 따라 코드를 실행하게 해 줍니다. 패턴은 리터럴 값, 변수 이름, 와일드카드 등 다양한 것으로 이루어질 수 있으며, 19장에서 패턴 종류와 역할을 자세히 다룹니다. match 의 강력함은 패턴이 매우 표현력이 높다는 점과, 컴파일러가 가능한 모든 경우가 처리되었는지 확인해 준다는 점에서 나옵니다.

match 식을 동전 분류 기계라고 생각해도 좋습니다. 동전이 여러 크기의 구멍이 뚫린 레일을 따라 내려오다가, 들어갈 수 있는 첫 번째 구멍을 만나면 그곳으로 떨어집니다. 마찬가지로 값도 match 의 각 패턴을 차례로 통과하고, 값이 “맞는” 첫 번째 패턴을 만나면 그와 연결된 코드 블록으로 떨어져 실행됩니다.

마침 동전 이야기가 나왔으니, match 예제로 동전을 사용해 봅시다! 정체를 모르는 미국 동전을 받아서, 분류 기계처럼 그것이 어떤 동전인지 판별하고 몇 센트인지 반환하는 함수를 목록 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: enum의 variant를 패턴으로 가지는 enum과 match

value_in_cents 함수 안의 match 를 하나씩 뜯어 봅시다. 먼저 match 키워드를 쓰고, 그 뒤에 식을 놓습니다. 여기서는 값 coin 입니다. 겉보기에는 if 와 함께 사용하는 조건식과 비슷해 보이지만, 큰 차이가 있습니다. if 는 조건이 반드시 불리언으로 평가되어야 하지만, 여기서는 어떤 타입이든 올 수 있습니다. 이 예제에서 coin 의 타입은 첫 줄에서 정의한 Coin enum 입니다.

그다음은 match arm 들입니다. 각 arm은 패턴과 코드, 두 부분으로 이루어집니다. 첫 번째 arm에는 Coin::Penny 라는 패턴이 있고, 그 뒤에 패턴과 실행할 코드를 구분하는 => 연산자가 있습니다. 여기서 코드는 단순히 값 1 입니다. 각 arm은 쉼표로 구분됩니다.

match 식이 실행되면, 주어진 값은 각 arm의 패턴과 순서대로 비교됩니다. 어떤 패턴이 값과 맞으면 그 패턴에 연결된 코드가 실행됩니다. 맞지 않으면 다음 arm으로 넘어갑니다. 동전 분류 기계와 같은 방식입니다. 필요한 만큼 arm을 둘 수 있으며, 목록 6-3에는 네 개의 arm이 있습니다.

각 arm에 연결된 코드는 하나의 식이며, 매칭된 arm 식의 결과값이 전체 match 식의 결과가 됩니다.

보통 목록 6-3처럼 각 arm이 단순히 값 하나를 반환하는 짧은 코드라면 중괄호를 사용하지 않습니다. 하지만 하나의 match arm 안에서 여러 줄의 코드를 실행하고 싶다면 반드시 중괄호를 사용해야 하고, 그 경우 arm 뒤의 쉼표는 선택 사항이 됩니다. 예를 들어 다음 코드는 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() {}

값을 바인딩하는 패턴

match arm의 또 다른 유용한 기능은, 패턴이 매칭된 값 안의 일부를 변수에 바인딩할 수 있다는 점입니다. 이 방식으로 enum variant 안의 값을 꺼낼 수 있습니다.

예를 들어, enum variant 하나가 내부에 데이터를 갖도록 바꿔 봅시다. 1999년부터 2008년까지 미국은 50개 주 각각에 대해 다른 디자인을 한쪽 면에 새긴 quarter를 발행했습니다. 다른 동전에는 주 디자인이 없으므로, quarter만 이 추가 정보를 갖게 하면 됩니다. 목록 6-4처럼 Quarter variant 안에 UsState 값을 저장하도록 enum 을 변경할 수 있습니다.

#[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 variant 안에 UsState 값을 저장하는 Coin enum

친구가 50개 주 quarter를 전부 모으고 있다고 상상해 봅시다. 우리는 동전 종류별로 잔돈을 분류하면서, quarter가 나올 때마다 어떤 주의 것인지 이름도 함께 말해 줄 수 있습니다. 그러면 친구가 아직 없는 quarter라면 수집품에 추가할 수 있겠지요.

이 코드의 match 식에서는 Coin::Quarter variant에 매칭되는 패턴에 state 라는 변수를 추가합니다. 어떤 Coin::Quarter 가 매칭되면, state 변수는 그 quarter에 들어 있는 주(state) 값에 바인딩됩니다. 그러면 그 arm 안의 코드에서 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) 가 됩니다. 이 값을 각 match arm과 비교해 보면, Coin::Quarter(state) 에 도달하기 전까지는 어느 것도 맞지 않습니다. 그 지점에서 stateUsState::Alaska 값에 바인딩됩니다. 그 다음 println! 식 안에서 이 바인딩을 사용해 Quarter 안의 실제 주 값만 꺼내 쓸 수 있습니다.

Option<T>match 패턴

앞 절에서는 Option<T> 를 사용할 때 Some 안쪽의 T 값을 꺼내고 싶다고 이야기했습니다. 이것 역시 Coin enum을 다룰 때처럼 match 로 처리할 수 있습니다! 동전을 비교하는 대신 Option<T> 의 variant를 비교하는 것이고, match 식의 동작 방식은 그대로 같습니다.

예를 들어 Option<i32> 를 받아서 값이 있으면 그 값에 1을 더하고, 값이 없으면 None 을 그대로 반환하는 함수를 작성하고 싶다고 합시다.

이 함수는 match 덕분에 아주 쉽게 쓸 수 있으며, 목록 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 arm과 비교됩니다.

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 패턴과 맞지 않으므로 다음 arm으로 넘어갑니다.

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) 와 맞을까요? 맞습니다! 같은 variant이기 때문입니다. iSome 안의 값에 바인딩되고, 따라서 i 는 값 5 를 갖습니다. 그러면 이 arm의 코드가 실행되어 i 에 1을 더하고, 합계 6 을 담은 새 Some 값이 만들어집니다.

이제 목록 6-5에서 xNone 인 두 번째 호출을 생각해 봅시다. match 에 진입해 첫 번째 arm과 비교합니다.

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 값을 반환합니다. 첫 번째 arm이 맞았으므로 다른 arm들은 비교하지 않습니다.

match 와 enum을 조합하는 방식은 여러 상황에서 유용합니다. 러스트 코드에서는 이 패턴을 아주 자주 보게 될 것입니다. enum에 대해 match 를 하고, 안의 데이터를 변수에 바인딩한 뒤, 그것을 바탕으로 코드를 실행하는 방식입니다. 처음에는 약간 헷갈릴 수 있지만, 익숙해지고 나면 모든 언어에 이런 기능이 있기를 바라게 될지도 모릅니다. 실제로 많은 사용자가 아주 좋아하는 기능입니다.

match 는 모든 경우를 다루어야 한다

match 에 대해 반드시 이야기해야 할 또 한 가지가 있습니다. 각 arm의 패턴은 가능한 모든 경우를 덮어야 한다는 점입니다. 아래의 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/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/option.rs:593:1
 ::: /rustc/1159e78c4747b02ef996e55082b704c09b970588/library/core/src/option.rs:597: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 경우를 명시적으로 처리하는 것을 잊지 못하게 함으로써, 값이 없을지도 모르는데 있다고 가정하는 실수를 막아 줍니다. 앞에서 이야기한 “10억 달러짜리 실수”가 여기서는 일어날 수 없게 되는 것입니다.

catch-all 패턴과 _ 플레이스홀더

enum을 사용할 때, 몇몇 특정 값에 대해서만 특별한 동작을 하고 나머지 모든 값에 대해서는 기본 동작을 하고 싶을 수도 있습니다. 예를 들어 어떤 게임을 구현하는데, 주사위를 굴려 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의 패턴은 리터럴 값 3, 7 입니다. 나머지 모든 가능한 값을 처리하는 마지막 arm의 패턴은 other 라는 이름의 변수입니다. 이 other arm에서 실행되는 코드는 그 변수를 move_player 함수에 넘겨 사용합니다.

이 코드는, 우리가 u8 이 가질 수 있는 모든 값을 하나하나 나열하지 않았더라도 컴파일됩니다. 마지막 패턴이 명시적으로 나열되지 않은 모든 값과 맞기 때문입니다. 이 catch-all 패턴 덕분에 match 가 완전해야 한다는 요구를 만족합니다. 단, 패턴은 순서대로 검사되므로 catch-all arm은 반드시 마지막에 두어야 합니다. 만약 catch-all을 앞에 두면 나머지 arm은 절대로 실행되지 않을 것이므로, 러스트는 catch-all 뒤에 arm을 추가하면 경고합니다.

러스트에는 catch-all을 원하되, 그 값 자체를 사용하고 싶지 않을 때 쓰는 특별한 패턴도 있습니다. _ 는 어떤 값과도 매칭되지만, 그 값에 이름을 바인딩하지 않습니다. 이것은 “우리는 이 값을 사용하지 않을 것이다”라고 러스트에게 알려 주는 것이며, 따라서 러스트는 사용하지 않는 변수에 대한 경고도 하지 않습니다.

게임 규칙을 조금 바꿔 봅시다. 이제 3이나 7이 아닌 값을 굴리면 그냥 다시 굴려야 한다고 합시다. 더 이상 catch-all 값 자체를 사용할 필요가 없으므로, 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과 함께 유닛 값(3장의 “튜플 타입” 절에서 언급한 빈 튜플 타입)을 코드로 쓰면 표현할 수 있습니다.

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의 패턴과 맞지 않는 다른 모든 값은 사용하지 않겠고, 그 경우에는 아무 코드도 실행하고 싶지 않다고 러스트에게 명시적으로 말하고 있는 것입니다.

패턴과 매칭에 대해서는 19장에서 더 많은 내용을 다룹니다. 지금은 조금 장황하게 느껴질 수 있는 상황에서 유용한 if let 문법으로 이제 넘어가 보겠습니다.

if let과 let...else로 더 간결하게 쓰기

if letlet...else 로 더 간결하게 제어 흐름 쓰기

if let 문법은 iflet 을 합쳐, 하나의 패턴과 맞는 값만 처리하고 나머지는 무시하는 일을 덜 장황하게 표현할 수 있게 해 줍니다. 예를 들어 목록 6-6의 프로그램은 config_max 변수에 들어 있는 Option<u8> 값을 match 로 처리하지만, 실제로 코드를 실행하고 싶은 경우는 값이 Some variant일 때뿐입니다.

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 식을 완전하게 만들려면, 하나의 variant만 처리하고도 _ => () 같은 코드를 덧붙여야 합니다. 이건 성가신 보일러플레이트죠.

대신 if let 을 사용하면 훨씬 더 짧게 같은 일을 쓸 수 있습니다. 다음 코드는 목록 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 에 주어지고 패턴이 첫 번째 arm 역할을 합니다. 이 경우 패턴은 Some(max) 이며, maxSome 안쪽 값에 바인딩됩니다. 그 뒤 if let 블록 본문에서, 대응하는 match arm에서 했던 것과 똑같이 max 를 사용할 수 있습니다. if let 블록 안의 코드는 값이 해당 패턴과 매칭될 때만 실행됩니다.

if let 을 사용하면 타이핑도 줄고, 들여쓰기도 줄며, 보일러플레이트 코드도 줄어듭니다. 하지만 그 대가로 match 가 강제하던 완전성 검사를 잃게 됩니다. 즉, 어떤 경우를 빠뜨렸는지 컴파일러가 확인해 주지 않습니다. 따라서 matchif let 중 어느 것을 선택할지는, 지금 상황에서 더 간결한 코드를 얻는 것이 완전성 검사를 포기할 만한 가치가 있는지에 따라 달라집니다.

다르게 말하면, if let 은 하나의 패턴과 매칭될 때만 코드를 실행하고, 나머지 모든 값은 무시하는 match 의 문법적 설탕이라고 생각할 수 있습니다.

if let 에는 else 도 붙일 수 있습니다. else 와 연결된 코드 블록은, 같은 일을 하는 match 식에서 _ 케이스와 함께 둘 코드와 동일한 역할을 합니다. 목록 6-4의 Coin enum 정의를 떠올려 봅시다. 거기서 Quarter variant는 UsState 값을 함께 가지고 있었습니다. 만약 quarter가 아닌 동전을 셀 필요도 있고, quarter라면 어떤 주의 동전인지도 출력하고 싶다면 다음처럼 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 로 “행복한 경로” 유지하기

흔한 패턴 하나는 값이 존재할 때 어떤 계산을 수행하고, 그렇지 않으면 기본값을 반환하는 것입니다. 주 값이 들어 있는 동전 예제를 계속 사용해 보면, 만약 어떤 quarter가 새겨진 주가 얼마나 오래된 주인지에 따라 재미있는 말을 하고 싶다면, 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}");
    }
}

그런 다음 목록 6-7처럼 if let 을 사용해 동전 종류를 매칭하고, 조건 본문 안에서 state 변수를 도입할 수 있습니다.

#[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 를 만들어 내거나 조기에 반환할 수도 있습니다. 목록 6-8처럼 말이죠. (match 로도 비슷하게 만들 수 있습니다.)

#[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 의 한 분기는 값을 만들고, 다른 분기는 함수에서 바로 반환해 버리기 때문입니다.

이런 흔한 패턴을 더 보기 좋게 표현하기 위해, 러스트는 let...else 를 제공합니다. let...else 문법은 왼쪽에 패턴, 오른쪽에 식을 받는다는 점에서 if let 과 매우 비슷하지만, if 분기는 없고 else 분기만 있습니다. 패턴이 맞으면, 패턴 안의 값은 바깥 스코프에 바인딩됩니다. 패턴이 맞지 않으면, 프로그램 흐름은 else arm으로 들어가며, 그 arm은 반드시 함수에서 반환해야 합니다.

목록 6-9는 목록 6-8을 if let 대신 let...else 로 썼을 때 어떤 모습인지 보여 줍니다.

#[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 사용하기

이 방식은 if let 처럼 두 분기의 제어 흐름이 크게 갈라지지 않으면서도, 함수 본문이 주로 “행복한 경로(happy path)” 에 머무르게 해 줍니다.

어떤 상황에서 프로그램 로직이 match 로 표현하기엔 너무 장황하다면, if letlet...else 역시 러스트 도구 상자 안에 있다는 점을 기억해 두세요.

정리

이제 우리는 enum을 사용해, 열거된 값 집합 중 하나가 될 수 있는 사용자 정의 타입을 만드는 방법을 배웠습니다. 또한 표준 라이브러리의 Option<T> 타입이 타입 시스템을 사용해 오류를 막는 데 어떻게 도움을 주는지도 보았습니다. enum 값 안에 데이터가 있다면, 처리해야 할 경우 수에 따라 matchif let 을 사용해 그 값을 꺼내 쓰면 됩니다.

이제 여러분의 러스트 프로그램은 구조체와 enum을 사용해 도메인 개념을 더 정확히 표현할 수 있게 되었습니다. API에 사용할 사용자 정의 타입을 만들면 타입 안전성도 함께 얻습니다. 컴파일러가 함수가 기대하는 타입의 값만 함수에 들어오도록 보장해 주기 때문입니다.

이제 사용자가 쉽게 쓸 수 있고, 동시에 정말 필요한 것만 정확히 드러내는 잘 정리된 API를 제공하기 위해, 러스트의 모듈로 넘어가 보겠습니다.

패키지, 크레이트, 모듈

프로그램이 커질수록 코드를 잘 정리하는 일은 점점 더 중요해집니다. 관련 기능을 함께 묶고, 서로 다른 기능을 분리해 두면, 어떤 기능을 구현한 코드를 어디서 찾아야 하는지, 그리고 그 기능의 동작 방식을 바꾸려면 어디로 가야 하는지가 훨씬 분명해집니다.

지금까지 우리가 작성한 프로그램은 하나의 파일 안, 하나의 모듈 안에 있었습니다. 프로젝트가 커지면 코드를 여러 모듈로 나누고, 나중에는 여러 파일로 나누어 정리해야 합니다. 하나의 패키지는 여러 개의 바이너리 크레이트를 포함할 수 있고, 선택적으로 하나의 라이브러리 크레이트도 포함할 수 있습니다. 패키지가 더 커지면 일부를 별도 크레이트로 분리해 외부 의존성으로 만들 수도 있습니다. 이 장에서는 이런 기법을 모두 다룹니다. 함께 진화하는 서로 관련된 패키지들의 집합으로 이루어진 아주 큰 프로젝트를 위해서는 Cargo가 워크스페이스도 제공하며, 이는 14장의 [“Cargo 워크스페이스”] workspaces 절에서 설명합니다.

또한 구현 세부를 캡슐화하는 방법도 설명합니다. 이를 통해 더 높은 수준에서 코드를 재사용할 수 있습니다. 어떤 연산을 구현하고 나면, 다른 코드는 그 구현이 내부에서 어떻게 동작하는지 몰라도 공개 인터페이스를 통해 그 코드를 호출할 수 있습니다. 코드를 작성하는 방식에 따라, 다른 코드가 사용할 수 있도록 공개할 부분과 여러분이 자유롭게 바꿀 수 있도록 비공개 구현 세부로 둘 부분이 정해집니다. 이것 역시 머릿속에 유지해야 하는 세부 사항의 양을 줄이는 방법입니다.

관련된 개념으로 스코프(scope)가 있습니다. 코드를 작성하는 중첩된 맥락에는 “현재 스코프 안에 있다”고 정의된 이름들의 집합이 있습니다. 코드를 읽고 쓰고 컴파일할 때, 프로그래머와 컴파일러는 특정 위치의 어떤 이름이 변수, 함수, 구조체, enum, 모듈, 상수, 혹은 다른 항목을 가리키는지, 그리고 그것이 무슨 의미인지 알아야 합니다. 스코프를 만들고, 어떤 이름이 스코프 안에 있고 밖에 있는지를 바꿀 수 있습니다. 같은 스코프 안에 같은 이름의 항목 두 개를 둘 수는 없으며, 이름 충돌을 해결하는 도구도 존재합니다.

러스트에는 어떤 세부를 노출하고 어떤 세부를 비공개로 둘지, 그리고 프로그램의 각 스코프 안에 어떤 이름이 있는지를 포함해 코드 조직을 관리할 수 있게 해 주는 여러 기능이 있습니다. 이러한 기능들을 묶어 흔히 모듈 시스템(module system) 이라고 부르며, 다음이 포함됩니다.

  • 패키지(Packages): 크레이트를 빌드, 테스트, 공유하게 해 주는 Cargo 기능
  • 크레이트(Crates): 라이브러리 또는 실행 파일을 만들어 내는 모듈 트리
  • 모듈과 use: 경로의 조직, 스코프, 공개 범위를 제어하게 해 줌
  • 경로(Paths): 구조체, 함수, 모듈 같은 항목에 이름을 붙이는 방법

이 장에서는 이 기능들을 모두 다루고, 이들이 어떻게 상호작용하는지 설명하며, 스코프를 관리하는 데 어떻게 사용하는지도 보여 줄 것입니다. 이 장을 마치면 모듈 시스템을 탄탄하게 이해하고, 스코프를 능숙하게 다룰 수 있게 될 것입니다.

패키지와 크레이트

패키지와 크레이트

모듈 시스템에서 가장 먼저 다룰 부분은 패키지와 크레이트입니다.

크레이트(crate) 는 러스트 컴파일러가 한 번에 고려하는 코드의 최소 단위입니다. cargo 대신 rustc 를 실행하고, 소스 코드 파일 하나만 넘긴다고 해도(1장의 “Rust 프로그램의 기초” 절에서 실제로 그랬습니다), 컴파일러는 그 파일을 하나의 크레이트로 취급합니다. 크레이트는 모듈을 포함할 수 있고, 앞으로 보겠지만 그 모듈들은 크레이트와 함께 컴파일되는 다른 파일에 정의될 수도 있습니다.

크레이트는 두 형태 중 하나일 수 있습니다. 바이너리 크레이트 또는 라이브러리 크레이트입니다. 바이너리 크레이트(binary crates) 는 컴파일해 실행 가능한 파일로 만들 수 있는 프로그램입니다. 예를 들어 커맨드라인 프로그램이나 서버가 여기에 해당합니다. 각각은 실행 파일이 돌아갈 때 어떤 일이 일어나는지 정의하는 main 함수를 반드시 가져야 합니다. 지금까지 우리가 만든 크레이트는 모두 바이너리 크레이트였습니다.

라이브러리 크레이트(library crates)main 함수가 없고, 실행 파일로 컴파일되지도 않습니다. 대신 여러 프로젝트가 공유해서 사용할 기능을 정의합니다. 예를 들어 2장에서 사용한 rand 크레이트는 난수를 생성하는 기능을 제공합니다. Rustacean이 “crate” 라고 말할 때는 대부분 라이브러리 크레이트를 의미하며, 일반적인 프로그래밍 개념의 “라이브러리” 와 사실상 같은 뜻으로 쓰기도 합니다.

크레이트 루트(crate root) 는 러스트 컴파일러가 출발점으로 삼는 소스 파일이며, 해당 크레이트의 루트 모듈이 됩니다(모듈은 [“모듈로 스코프와 공개 범위 제어하기”] modules 절에서 자세히 설명합니다).

패키지(package) 는 하나 이상의 크레이트를 묶어 특정 기능 집합을 제공하는 단위입니다. 패키지는 그 크레이트들을 어떻게 빌드할지 설명하는 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가 무엇을 만들었는지 확인합니다. my-project 디렉터리 안에는 Cargo.toml 파일이 있는데, 이것이 패키지를 정의합니다. 또한 main.rs 를 담고 있는 src 디렉터리도 있습니다. Cargo.toml 을 에디터로 열어 보면, 그 안에 src/main.rs 에 대한 언급은 없습니다. Cargo는 src/main.rs 가 패키지와 같은 이름의 바이너리 크레이트 루트라는 관례를 따릅니다. 마찬가지로 패키지 디렉터리에 src/lib.rs 가 있으면, 패키지는 패키지와 같은 이름의 라이브러리 크레이트를 포함하고 있고, src/lib.rs 가 그 크레이트의 루트라고 Cargo는 압니다. Cargo는 이런 크레이트 루트 파일들을 rustc 에 넘겨 라이브러리나 바이너리를 빌드합니다.

여기서는 패키지 안에 src/main.rs 만 있으므로, my-project 라는 이름의 바이너리 크레이트 하나만 포함합니다. 만약 패키지에 src/main.rssrc/lib.rs 가 모두 있다면, 같은 이름을 가진 바이너리와 라이브러리, 두 개의 크레이트를 포함하게 됩니다. 또한 src/bin 디렉터리에 파일을 넣으면 패키지는 여러 개의 바이너리 크레이트를 가질 수 있으며, 각 파일은 각각 별도의 바이너리 크레이트가 됩니다.

모듈로 스코프와 공개 범위 제어하기

Control Scope and Privacy with Modules

이 절에서는 모듈과 모듈 시스템의 다른 구성 요소들을 다룹니다. 항목에 이름을 붙이는 경로(paths), 경로를 스코프로 가져오는 use 키워드, 그리고 항목을 공개로 만드는 pub 키워드가 그것입니다. 또한 as 키워드, 외부 패키지, 글롭 연산자도 함께 설명합니다.

모듈 치트 시트

모듈과 경로의 세부로 들어가기 전에, 여기서는 모듈, 경로, 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 에 있습니다.
  • 비공개와 공개: 모듈 안의 코드는 기본적으로 부모 모듈에서 보기에 비공개입니다. 모듈을 공개로 만들려면 mod 대신 pub mod 로 선언합니다. 공개 모듈 안의 항목도 함께 공개하려면 각 선언 앞에 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 에서 찾은 코드를 포함하라고 알려 줍니다. 그 파일의 내용은 다음과 같습니다.

Filename: src/garden.rs
pub mod vegetables;

여기서 pub mod vegetables;src/garden/vegetables.rs 의 코드도 포함하라는 뜻입니다. 그 파일의 코드는 다음과 같습니다.

#[derive(Debug)]
pub struct Asparagus {}

이제 이 규칙들의 세부로 들어가 실제로 어떻게 동작하는지 살펴봅시다!

관련 코드를 모듈로 묶기

모듈(modules) 은 크레이트 안의 코드를 읽기 쉽고 재사용하기 쉽게 정리하게 해 줍니다. 또한 모듈은 항목의 공개 범위(privacy) 를 제어할 수 있게 해 줍니다. 모듈 안의 코드는 기본적으로 비공개이기 때문입니다. 비공개 항목은 외부에서 쓸 수 없는 내부 구현 세부입니다. 우리는 모듈과 그 안의 항목들을 공개로 만들 수도 있는데, 그러면 외부 코드가 그것들을 사용하고 의존할 수 있게 됩니다.

예제로, 레스토랑 기능을 제공하는 라이브러리 크레이트를 하나 작성해 봅시다. 여기서는 레스토랑 구현 자체보다 코드 조직에 집중하기 위해, 함수 시그니처만 정의하고 본문은 비워 두겠습니다.

레스토랑 업계에서는 보통 레스토랑의 일부를 프런트 오브 하우스(front of house), 다른 일부를 백 오브 하우스(back of house)라고 부릅니다. 프런트 오브 하우스 는 손님이 있는 공간입니다. 호스트가 손님을 자리로 안내하고, 서버가 주문과 계산을 받고, 바텐더가 음료를 만드는 곳이 여기에 포함됩니다. 백 오브 하우스 는 셰프와 요리사가 주방에서 일하고, 식기 세척 담당이 정리하고, 매니저가 행정 업무를 보는 공간입니다.

우리 크레이트도 이런 식으로 구성하려면, 기능을 중첩된 모듈들로 조직하면 됩니다. cargo new restaurant --lib 를 실행해 restaurant 라는 새 라이브러리를 만드세요. 그다음 src/lib.rs 에 목록 7-1의 코드를 넣어 몇 개의 모듈과 함수 시그니처를 정의합니다. 이 코드는 프런트 오브 하우스 부분에 해당합니다.

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: A front_of_house module containing other modules that then contain functions

모듈은 mod 키워드와 모듈 이름(여기서는 front_of_house)을 써서 정의합니다. 그 뒤 중괄호 안에 모듈 본문이 들어갑니다. 모듈 안에는 hosting, serving 처럼 다른 모듈도 넣을 수 있습니다. 또한 구조체, enum, 상수, 트레이트, 그리고 목록 7-1의 함수처럼 다른 항목 정의도 담을 수 있습니다.

모듈을 사용하면 서로 관련된 정의를 함께 묶고, 왜 서로 관련되어 있는지도 이름으로 표현할 수 있습니다. 이 코드를 사용하는 프로그래머는 모든 정의를 다 읽어야 하는 대신 그룹을 따라가며 코드를 탐색할 수 있어서, 자신에게 필요한 정의를 더 쉽게 찾을 수 있습니다. 또한 이 코드에 새 기능을 추가하는 프로그래머도 프로그램을 정리된 상태로 유지하려면 코드를 어디에 넣어야 하는지 쉽게 알 수 있습니다.

앞에서 src/main.rssrc/lib.rs크레이트 루트 라고 부른다고 했습니다. 이런 이름이 붙은 이유는, 이 두 파일 중 어느 하나의 내용이든 크레이트의 모듈 구조 맨 위에 있는 crate 라는 이름의 모듈을 이루기 때문입니다. 이 구조를 모듈 트리 라고 합니다.

목록 7-2는 목록 7-1 구조에 대한 모듈 트리를 보여 줍니다.

crate
 └── front_of_house
     ├── hosting
     │   ├── add_to_waitlist
     │   └── seat_at_table
     └── serving
         ├── take_order
         ├── serve_order
         └── take_payment
Listing 7-2: The module tree for the code in Listing 7-1

이 트리는 어떤 모듈이 다른 모듈 안에 중첩되는지 보여 줍니다. 예를 들어 hostingfront_of_house 안에 들어 있습니다. 또한 어떤 모듈은 서로 형제(siblings) 인데, 이는 같은 모듈 안에 정의되었다는 뜻입니다. hostingservingfront_of_house 안에 함께 정의된 형제 모듈입니다. 모듈 A가 모듈 B 안에 들어 있다면, A를 B의 자식(child) 이라고 하고 B를 A의 부모(parent) 라고 합니다. 그리고 전체 모듈 트리는 암묵적인 crate 모듈 아래에 뿌리를 두고 있다는 점에 주목하세요.

모듈 트리는 컴퓨터의 파일시스템 디렉터리 트리를 떠올리게 할 수도 있습니다. 그리고 그 비유는 아주 적절합니다! 파일시스템에서 디렉터리로 파일을 정리하듯, 우리는 모듈을 사용해 코드를 정리합니다. 또한 디렉터리 안 파일을 찾아야 하듯, 모듈도 찾아낼 수 있는 방법이 필요합니다.

모듈 트리의 항목을 경로로 가리키기

모듈 트리의 항목을 경로로 가리키기

모듈 트리 안에서 어떤 항목을 어디서 찾을지 러스트에게 알려 주려면, 파일시스템을 탐색할 때 경로를 쓰는 것과 같은 방식으로 경로(path)를 사용합니다. 함수를 호출하려면 그 함수의 경로를 알아야 합니다.

경로는 두 가지 형태를 가질 수 있습니다.

  • 절대 경로(absolute path) 는 크레이트 루트에서 시작하는 전체 경로입니다. 외부 크레이트의 코드에 대해서는 절대 경로가 크레이트 이름으로 시작하고, 현재 크레이트의 코드에 대해서는 리터럴 crate 로 시작합니다.
  • 상대 경로(relative path) 는 현재 모듈에서 시작하며, self, super, 또는 현재 모듈 안의 식별자를 사용합니다.

절대 경로든 상대 경로든, 그 뒤에는 이중 콜론(::)으로 구분된 하나 이상의 식별자가 이어집니다.

목록 7-1로 돌아가서, add_to_waitlist 함수를 호출하고 싶다고 해 봅시다. 이것은 결국 “add_to_waitlist 함수의 경로가 무엇인가?”라고 묻는 것과 같습니다. 목록 7-3은 일부 모듈과 함수를 제거한 목록 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 에 도달할 때까지 각 모듈 이름을 차례로 이어 붙입니다. 이를 파일시스템으로 비유하면, /front_of_house/hosting/add_to_waitlist 경로로 add_to_waitlist 프로그램을 실행하는 것과 비슷합니다. crate 로 시작하는 것은 셸에서 파일시스템 루트 / 로 시작하는 것과 같은 느낌입니다.

두 번째 호출에서는 상대 경로를 사용합니다. 경로는 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 모듈로 옮긴다면, 절대 경로는 그대로 유지되지만 상대 경로는 수정해야 합니다. 일반적으로는, 코드 정의와 항목 호출을 독립적으로 옮기고 싶어질 가능성이 더 크기 때문에 절대 경로를 선호합니다.

이제 목록 7-3을 실제로 컴파일해 보고 왜 아직 컴파일되지 않는지 확인해 봅시다. 얻게 되는 오류는 목록 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: 목록 7-3의 코드를 빌드할 때 나오는 컴파일러 오류

오류 메시지는 hosting 모듈이 private 하다고 말합니다. 다시 말해 hosting 모듈과 add_to_waitlist 함수로 가는 경로 자체는 맞지만, 러스트는 private 한 부분에 접근할 수 없기 때문에 그것을 사용하게 해 주지 않습니다. 러스트에서는 모든 항목(함수, 메서드, 구조체, enum, 모듈, 상수)이 기본적으로 부모 모듈 기준으로 private 입니다. 어떤 항목을 private 로 만들고 싶다면 그냥 모듈 안에 두면 됩니다.

부모 모듈 안의 코드는 자식 모듈 안의 private 항목을 사용할 수 없습니다. 하지만 자식 모듈 안의 코드는 조상 모듈 안의 항목을 사용할 수 있습니다. 자식 모듈은 구현 세부를 감싸고 숨기지만, 자식 모듈 자체는 자신이 정의된 바깥 맥락을 볼 수 있기 때문입니다. 이 비유를 계속 사용하자면, privacy 규칙은 레스토랑의 백오피스와 비슷합니다. 그 안에서 무슨 일이 벌어지는지는 손님에게는 비공개이지만, 매니저는 자신이 운영하는 레스토랑 전체를 보고 필요한 일을 할 수 있습니다.

러스트가 모듈 시스템을 이렇게 동작하게 만든 것은, 내부 구현 세부를 숨기는 것이 기본이 되게 하기 위해서입니다. 그래야 내부 코드 중 어떤 부분은 바꿔도 외부 코드를 깨뜨리지 않는지 쉽게 알 수 있습니다. 물론 러스트는 pub 키워드를 사용해 자식 모듈 안의 코드를 바깥 조상 모듈에 노출할 수 있는 선택지도 제공합니다.

pub 키워드로 경로 노출하기

hosting 모듈이 private 하다고 알려 준 목록 7-4의 오류로 다시 돌아갑시다. 우리는 부모 모듈 안의 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 안의 내용물 은 여전히 private 입니다. 모듈을 공개로 만든다고 해서 내용물까지 자동으로 공개되지는 않습니다. 모듈에 붙은 pub 키워드는 조상 모듈의 코드가 그 모듈을 참조 할 수 있게 할 뿐, 그 안의 코드를 바로 사용할 수 있게 하지는 않습니다. 모듈은 컨테이너이기 때문에, 모듈만 공개로 만들어서는 할 수 있는 일이 많지 않습니다. 그 안의 항목 중 하나 이상도 명시적으로 공개해야 합니다.

목록 7-6의 오류는 add_to_waitlist 함수가 private 하다고 말합니다. privacy 규칙은 모듈뿐 아니라 구조체, enum, 함수, 메서드에도 모두 적용됩니다.

그렇다면 목록 7-7처럼 add_to_waitlist 함수 정의 앞에도 pub 키워드를 붙여 이 함수도 공개로 만들어 봅시다.

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 에서 함수를 호출할 수 있다

이제 코드는 컴파일됩니다! privacy 규칙과 관련해서 왜 pub 를 추가하면 eat_at_restaurant 안에서 이 경로들을 사용할 수 있게 되는지, 절대 경로와 상대 경로를 기준으로 다시 봅시다.

절대 경로에서는 먼저 크레이트 모듈 트리의 루트인 crate 에서 시작합니다. front_of_house 모듈은 크레이트 루트에 정의되어 있습니다. front_of_house 자체는 public 이 아니지만, eat_at_restaurant 함수가 front_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_waitlist 가 둘 다 pub 이므로 나머지 경로도 유효합니다.

만약 여러분이 라이브러리 크레이트를 공유해서 다른 프로젝트에서도 그 코드를 사용하게 하려면, 공개 API는 사용자와의 계약이 됩니다. 사용자들이 여러분의 코드와 어떻게 상호작용할 수 있는지를 결정하기 때문입니다. 사람들이 여러분의 크레이트에 쉽게 의존할 수 있도록 공개 API 변경을 관리하는 데는 여러 고려사항이 있습니다. 이 책의 범위를 벗어나는 주제이므로, 관심이 있다면 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 를 사용했습니다.

구조체와 enum을 공개로 만들기

구조체와 enum도 pub 를 사용해 공개로 지정할 수 있지만, 이 경우에는 몇 가지 추가 세부가 있습니다. 구조체 정의 앞에 pub 를 붙이면 구조체 자체는 공개되지만, 구조체의 필드들은 여전히 private 입니다. 각 필드는 공개할지 말지를 개별적으로 정할 수 있습니다. 목록 7-9에서는 public 한 back_of_house::Breakfast 구조체를 정의했는데, toast 필드는 public 이고 seasonal_fruit 필드는 private 입니다. 이는 손님은 식사에 어떤 빵이 나올지 선택할 수 있지만, 어떤 과일이 곁들여질지는 계절과 재고 상황에 따라 셰프가 결정하는 레스토랑 상황을 모델링한 것입니다. 과일 종류는 자주 바뀌므로, 손님은 과일을 고를 수도 없고 어떤 과일이 나올지도 볼 수 없습니다.

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: 일부 필드는 public 이고 일부는 private 인 구조체

back_of_house::Breakfast 구조체의 toast 필드는 public 이므로, eat_at_restaurant 안에서 점 표기법으로 toast 필드를 읽고 쓸 수 있습니다. 하지만 seasonal_fruit 는 private 이기 때문에 eat_at_restaurant 에서 사용할 수 없습니다. 직접 seasonal_fruit 값을 바꾸려는 줄의 주석을 풀어 보면 어떤 오류가 나는지 확인할 수 있습니다!

또한 back_of_house::Breakfast 는 private 필드를 하나 가지고 있으므로, Breakfast 인스턴스를 만들어 주는 public 연관 함수가 필요합니다(여기서는 summer 라는 이름을 붙였습니다). 만약 Breakfast 에 이런 함수가 없다면, eat_at_restaurant 안에서 Breakfast 인스턴스를 만들 수 없습니다. private 필드 seasonal_fruit 값을 설정할 방법이 없기 때문입니다.

반면 enum을 public 으로 만들면, 그 variant 들은 자동으로 모두 public 이 됩니다. 즉 enum 키워드 앞에만 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: enum을 public 으로 지정하면 모든 variant도 public 이 된다

Appetizer enum을 public 으로 만들었기 때문에, eat_at_restaurant 안에서 SoupSalad variant를 사용할 수 있습니다.

Enum은 variant 가 public 이 아니면 사실상 별로 쓸모가 없습니다. 그래서 매번 variant 마다 전부 pub 를 달아야 한다면 꽤 성가실 것입니다. 이런 이유로 enum variant는 기본적으로 public 입니다. 반면 구조체는 필드가 공개되지 않아도 유용한 경우가 많기 때문에, 구조체 필드는 pub 로 표시하지 않는 한 기본적으로 private 라는 일반 규칙을 따릅니다.

이제 pub 과 관련해 아직 다루지 않은 마지막 모듈 시스템 기능이 하나 남아 있습니다. 바로 use 키워드입니다. 먼저 use 자체를 살펴보고, 그다음 pubuse 를 함께 쓰는 방법을 보겠습니다.

use 키워드로 경로를 스코프로 가져오기

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 로 스코프 안에 가져온 경로도 다른 경로와 마찬가지로 privacy 검사를 받습니다.

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 unresolved module or unlinked crate `hosting`
  --> src/lib.rs:11:9
   |
11 |         hosting::add_to_waitlist();
   |         ^^^^^^^ use of unresolved module or unlinked crate `hosting`
   |
   = help: if you wanted to use a crate named `hosting`, use `cargo add hosting` to add it to your `Cargo.toml`
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 를 호출했을까요? 같은 효과를 얻기 위해 add_to_waitlist 함수까지 포함한 경로를 use 로 가져와도 될 것 같은데 말입니다. 목록 7-13처럼요.

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은 둘 다 같은 일을 하지만, use 로 함수를 스코프로 가져오는 관용적인 방식은 목록 7-11 쪽입니다. 함수 자체가 아니라 부모 모듈을 use 로 가져오면, 함수 호출 시 부모 모듈 이름을 붙여야 합니다. 이 방식은 함수가 로컬에 정의된 것이 아니라는 사실을 드러내면서도, 전체 경로를 계속 반복하는 일은 줄여 줍니다. 반대로 목록 7-13의 코드는 add_to_waitlist 가 어디서 정의된 것인지 한눈에 분명하지 않습니다.

반면 구조체, enum, 기타 항목을 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 을 스코프로 가져오기

이 관용구 뒤에 특별히 강한 이유가 있는 것은 아닙니다. 그냥 이런 방식이 널리 퍼졌고, 사람들이 이런 형태로 러스트 코드를 읽고 쓰는 데 익숙해졌기 때문입니다.

이 관용구의 예외는, use 문으로 같은 이름의 항목 두 개를 동시에 스코프로 가져오려는 경우입니다. 러스트는 같은 이름 두 개를 허용하지 않기 때문입니다. 목록 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 타입이 두 개 있게 되어, 우리가 Result 라고 썼을 때 러스트는 어느 것을 뜻하는지 알 수 없게 됩니다.

as 키워드로 새 이름 붙이기

같은 이름의 타입 두 개를 같은 스코프로 가져오는 문제를 해결하는 또 다른 방법도 있습니다. 경로 뒤에 as 와 새 로컬 이름, 즉 별칭(alias) 을 붙이는 것입니다. 목록 7-16은 as 를 사용해 두 Result 타입 중 하나의 이름을 바꿈으로써, 목록 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::fmt 에서 가져온 Result 와 충돌하지 않게 됩니다. 목록 7-15와 7-16은 둘 다 관용적인 방식으로 여겨지므로, 어느 쪽을 선택할지는 여러분의 취향에 달려 있습니다.

pub use 로 이름 재수출하기

use 키워드로 어떤 이름을 스코프로 가져오면, 그 이름은 가져온 그 스코프 안에서만 private 합니다. 그 스코프 밖의 코드가 그 이름을 마치 그 스코프 안에서 정의된 것처럼 참조할 수 있게 하려면, pubuse 를 결합할 수 있습니다. 이 기법을 재수출(re-exporting) 이라고 합니다. 어떤 항목을 스코프로 가져오면서 동시에, 다른 사람도 자기 스코프로 가져올 수 있게 공개하기 때문입니다.

목록 7-17은 목록 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 로 새로운 스코프에서 누구나 사용할 수 있게 이름을 공개하기

이 변경 이전에는, 외부 코드는 restaurant::front_of_house::hosting::add_to_waitlist() 경로를 사용해 add_to_waitlist 함수를 호출해야 했고, 그러려면 front_of_house 모듈 자체도 pub 로 표시되어 있어야 했습니다. 하지만 pub use 로 루트 모듈에서 hosting 모듈을 재수출했기 때문에, 이제 외부 코드는 restaurant::hosting::add_to_waitlist() 경로를 사용할 수 있습니다.

재수출은 내부 코드 구조와, 그 코드를 호출하는 프로그래머가 그 도메인을 생각하는 방식이 다를 때 유용합니다. 이 레스토랑 비유에서 레스토랑을 운영하는 사람은 “프런트 오브 하우스” 와 “백 오브 하우스” 로 생각하겠지만, 손님은 아마 그런 식으로 생각하지 않을 것입니다. pub use 를 사용하면 우리는 코드는 한 구조로 짜되, 외부에는 다른 구조를 노출할 수 있습니다. 이렇게 하면 라이브러리를 작성하는 사람에게도, 라이브러리를 호출하는 사람에게도 더 잘 정리된 라이브러리가 됩니다. pub use 의 또 다른 예와, 이것이 크레이트 문서에 어떤 영향을 미치는지는 14장의 “편리한 공개 API 내보내기” 절에서 다시 보게 됩니다.

외부 패키지 사용하기

2장에서는 난수를 얻기 위해 rand 라는 외부 패키지를 사용한 숫자 맞히기 게임을 만들었습니다. 프로젝트에서 rand 를 사용하기 위해 Cargo.toml 에 다음 줄을 추가했었습니다.

Filename: Cargo.toml
rand = "0.8.5"

Cargo.tomlrand 를 의존성으로 추가하면, 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}");
}

러스트 커뮤니티 구성원들은 crates.io 에 수많은 패키지를 공개해 두었고, 그중 어떤 것이든 여러분의 패키지에 가져오는 과정은 똑같습니다. 즉, Cargo.toml 에 의존성을 적고, use 로 그 크레이트의 항목을 스코프로 가져오면 됩니다.

표준 라이브러리 std 도 사실은 우리 패키지 밖에 있는 외부 크레이트라는 점도 기억하세요. 다만 표준 라이브러리는 러스트 언어와 함께 제공되므로, Cargo.toml 을 바꿔서 std 를 따로 추가할 필요는 없습니다. 그러나 그 안의 항목을 패키지 스코프로 가져오려면 여전히 use 를 통해 참조해야 합니다. 예를 들어 HashMap 을 쓰려면 다음 줄을 사용합니다.

#![allow(unused)]
fn main() {
use std::collections::HashMap;
}

이것은 표준 라이브러리 크레이트 이름인 std 로 시작하는 절대 경로입니다.

중첩 경로로 use 목록 정리하기

같은 크레이트나 같은 모듈 안에 정의된 여러 항목을 사용한다면, 각 항목을 별도 use 줄로 적는 것은 파일에서 세로 공간을 많이 차지할 수 있습니다. 예를 들어 목록 2-4의 숫자 맞히기 게임에서는 다음 두 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 문으로 합치려면, 목록 7-20처럼 중첩 경로 안에서 self 를 사용할 수 있습니다.

Filename: src/lib.rs
use std::io::{self, Write};
Listing 7-20: 목록 7-19의 경로를 하나의 use 문으로 합치기

이 한 줄은 std::iostd::io::Write 를 둘 다 스코프로 가져옵니다.

글롭 연산자로 항목 가져오기

어떤 경로 안에 정의된 모든 public 항목을 스코프로 가져오고 싶다면, 경로 뒤에 * 글롭 연산자를 붙일 수 있습니다.

#![allow(unused)]
fn main() {
use std::collections::*;
}

use 문은 std::collections 안에 정의된 모든 public 항목을 현재 스코프로 가져옵니다. 다만 글롭 연산자는 사용할 때 주의가 필요합니다! 글롭은 어떤 이름들이 현재 스코프에 들어와 있는지, 그리고 프로그램에서 사용한 이름이 원래 어디서 정의된 것인지를 파악하기 어렵게 만들 수 있습니다. 또한 의존성이 정의를 바꾸면, 여러분이 가져온 내용도 같이 바뀝니다. 예를 들어 그 의존성이 같은 스코프 안에서 여러분의 정의와 같은 이름의 항목을 새로 추가하면, 의존성을 업그레이드했을 때 컴파일 오류로 이어질 수 있습니다.

글롭 연산자는 테스트할 때 테스트 대상의 모든 것을 tests 모듈 안으로 가져오는 데 자주 사용되며, 이는 11장의 “테스트 작성 방법” 절에서 설명합니다. 또한 prelude 패턴의 일부로 사용되기도 합니다. 그 패턴에 대한 더 자세한 내용은 표준 라이브러리 문서 를 참고하세요.

모듈을 여러 파일로 분리하기

모듈을 여러 파일로 분리하기

지금까지 이 장의 예제들은 여러 모듈을 한 파일 안에 정의했습니다. 하지만 모듈이 커지면, 코드를 더 쉽게 탐색할 수 있도록 정의를 별도 파일로 옮기고 싶어질 수 있습니다.

예를 들어 여러 레스토랑 모듈이 있던 목록 7-17의 코드부터 시작해 봅시다. 이번에는 모든 모듈을 크레이트 루트 파일 안에 두지 않고, 각 모듈을 파일로 분리해 보겠습니다. 이 경우 크레이트 루트 파일은 src/lib.rs 이지만, 크레이트 루트가 src/main.rs 인 바이너리 크레이트에도 같은 절차를 적용할 수 있습니다.

먼저 front_of_house 모듈을 자체 파일로 분리합니다. front_of_house 모듈의 중괄호 안 코드를 제거하고 mod front_of_house; 선언만 남겨, src/lib.rs 가 목록 7-21의 코드만 담도록 만듭니다. 물론 목록 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: 본문이 src/front_of_house.rs 에 있게 될 front_of_house 모듈 선언

그다음 중괄호 안에 있던 코드를 목록 7-22처럼 src/front_of_house.rs 라는 새 파일로 옮깁니다. 크레이트 루트에서 front_of_house 라는 이름의 모듈 선언을 봤기 때문에, 컴파일러는 이 파일에서 모듈 코드를 찾습니다.

Filename: src/front_of_house.rs
pub mod hosting {
    pub fn add_to_waitlist() {}
}
Listing 7-22: src/front_of_house.rs 안의 front_of_house 모듈 정의

주의할 점은, 모듈 트리 안에서 어떤 파일을 mod 선언으로 한 번만 불러오면 충분하다는 것입니다. 컴파일러가 그 파일이 프로젝트 일부라는 것을 알고(그리고 mod 문이 어디에 있는지 덕분에 모듈 트리 안의 어느 위치에 속하는지도 알고) 나면, 프로젝트의 다른 파일에서는 “모듈 트리의 항목을 경로로 가리키기” 절에서 설명한 것처럼 선언된 위치를 가리키는 경로로 그 코드에 접근하면 됩니다. 다시 말해 mod 는 다른 언어에서 보았을 수도 있는 “include” 연산이 아닙니다.

이제 hosting 모듈도 별도 파일로 분리해 봅시다. 다만 이번 과정은 조금 다릅니다. hosting 은 루트 모듈의 자식이 아니라 front_of_house 의 자식 모듈이기 때문입니다. 따라서 hosting 파일은 모듈 트리에서 그 조상 이름을 딴 새 디렉터리, 여기서는 src/front_of_house 안에 두게 됩니다.

hosting 을 옮기기 시작하려면, src/front_of_house.rshosting 모듈 선언만 남도록 바꿉니다.

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.rssrc 디렉터리에 그냥 두었다면, 컴파일러는 그 코드를 크레이트 루트에서 선언된 hosting 모듈의 코드라고 기대했을 것입니다. front_of_house 자식으로 선언된 hosting 모듈의 코드라고는 보지 않습니다. 즉 어떤 모듈의 코드를 찾을 때 어떤 파일을 살펴볼지에 대한 컴파일러 규칙 덕분에, 디렉터리와 파일 구조가 모듈 트리와 더 가깝게 맞춰지게 됩니다.

대체 파일 경로

지금까지는 러스트 컴파일러가 사용하는 가장 관용적인 파일 경로를 설명했지만, 러스트는 예전 스타일의 파일 경로도 여전히 지원합니다. 크레이트 루트에서 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 키워드이며, 러스트는 모듈과 같은 이름의 파일을 찾아 그 코드를 해당 모듈에 넣습니다.

정리

러스트는 패키지를 여러 크레이트로 나누고, 하나의 크레이트도 여러 모듈로 나눌 수 있게 해 줍니다. 그래서 한 모듈에 정의된 항목을 다른 모듈에서 참조할 수 있습니다. 이때 절대 경로나 상대 경로를 사용할 수 있고, use 문을 사용하면 같은 스코프 안에서 더 짧은 경로를 반복해서 사용할 수 있습니다. 모듈 코드는 기본적으로 private 이지만, pub 키워드를 붙여 정의를 public 하게 만들 수 있습니다.

다음 장에서는, 이렇게 잘 정리한 코드 안에서 사용할 수 있는 표준 라이브러리의 몇 가지 컬렉션 자료구조를 살펴보겠습니다.

자주 쓰는 컬렉션

러스트의 표준 라이브러리에는 컬렉션(collections) 이라고 부르는 아주 유용한 자료구조가 여럿 들어 있습니다. 대부분의 다른 데이터 타입은 하나의 특정 값만 표현하지만, 컬렉션은 여러 값을 담을 수 있습니다. 내장된 배열이나 튜플과 달리, 이 컬렉션들이 가리키는 데이터는 힙에 저장됩니다. 따라서 데이터 양을 컴파일 시점에 미리 알 필요가 없고, 프로그램이 실행되는 동안 크기가 커지거나 줄어들 수 있습니다. 컬렉션 종류마다 제공하는 기능과 비용이 다르며, 지금 상황에 맞는 것을 고르는 능력은 시간을 들여 익히게 됩니다. 이 장에서는 러스트 프로그램에서 아주 자주 쓰이는 세 가지 컬렉션을 다룹니다.

  • 벡터(vector) 는 개수가 가변적인 여러 값을 메모리에서 서로 붙여 저장할 수 있게 합니다.
  • 문자열(string) 은 문자의 컬렉션입니다. 앞에서 String 타입을 간단히 언급했지만, 이 장에서는 그것을 더 깊이 다룹니다.
  • 해시 맵(hash map) 은 특정 키와 값을 연결할 수 있게 합니다. 이는 더 일반적인 자료 구조인 맵(map) 의 한 가지 구현입니다.

표준 라이브러리가 제공하는 다른 컬렉션 종류를 알고 싶다면 문서를 참고하세요.

이 장에서는 벡터, 문자열, 해시 맵을 어떻게 만들고 갱신하는지, 그리고 각각이 어떤 점에서 특별한지를 설명합니다.

벡터로 값 목록 저장하기

벡터로 값 목록 저장하기

우리가 처음 살펴볼 컬렉션 타입은 벡터라고도 부르는 Vec<T> 입니다. 벡터는 여러 값을 하나의 자료구조 안에 저장하며, 그 값들은 메모리 안에서 서로 붙어 있습니다. 벡터는 같은 타입의 값만 저장할 수 있습니다. 파일의 텍스트 줄들이나 장바구니 안 상품 가격처럼, 항목들의 목록을 가지고 있을 때 유용합니다.

새 벡터 만들기

새로운 빈 벡터를 만들려면 목록 8-1처럼 Vec::new 함수를 호출합니다.

fn main() {
    let v: Vec<i32> = Vec::new();
}
Listing 8-1: i32 타입 값을 담을 새 빈 벡터 만들기

여기서는 타입 주석을 추가했다는 점에 주목하세요. 아직 이 벡터에 아무 값도 넣지 않았기 때문에, 러스트는 우리가 어떤 종류의 요소를 저장하려는지 알 수 없습니다. 이것은 중요한 점입니다. 벡터는 제네릭으로 구현되어 있으며, 여러분 자신의 타입에 제네릭을 적용하는 방법은 10장에서 다룹니다. 지금은 표준 라이브러리의 Vec<T> 가 어떤 타입이든 담을 수 있다는 것만 알면 충분합니다. 특정 타입을 저장하는 벡터를 만들 때는 꺾쇠 괄호 안에 그 타입을 명시할 수 있습니다. 목록 8-1에서는 v 안의 Vec<T>i32 타입의 요소를 담을 것이라고 러스트에게 알려 준 것입니다.

하지만 대부분은 초기값을 가진 Vec<T> 를 만들고, 러스트가 저장하려는 값의 타입을 추론하게 할 것입니다. 그래서 이런 타입 주석은 드물게만 필요합니다. 러스트는 vec! 매크로를 제공하는데, 이것은 여러분이 넘긴 값들을 담은 새 벡터를 만들어 줍니다. 목록 8-2는 값 1, 2, 3 을 담은 새 Vec<i32> 를 만듭니다. 이 정수 타입은, 3장의 “데이터 타입” 절에서 다뤘듯이 기본 정수 타입인 i32 가 됩니다.

fn main() {
    let v = vec![1, 2, 3];
}
Listing 8-2: 값을 담은 새 벡터 만들기

초기값으로 i32 값을 넣었기 때문에, 러스트는 v 의 타입이 Vec<i32> 라고 추론할 수 있고 타입 주석도 필요 없습니다. 다음으로는 벡터를 어떻게 수정하는지 살펴봅시다.

벡터 갱신하기

벡터를 만든 뒤 요소를 추가하려면 목록 8-3처럼 push 메서드를 사용할 수 있습니다.

fn main() {
    let mut v = Vec::new();

    v.push(5);
    v.push(6);
    v.push(7);
    v.push(8);
}
Listing 8-3: push 메서드로 벡터에 값 추가하기

모든 변수와 마찬가지로 값을 바꾸려면, 3장에서 이야기했듯이 mut 키워드로 변수를 가변으로 만들어야 합니다. 벡터 안에 넣는 숫자는 모두 i32 타입이고, 러스트는 이 사실을 데이터로부터 추론하므로 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 메서드로 벡터 항목 접근하기

여기서 몇 가지 세부를 눈여겨봅시다. 인덱스 값 2 로 세 번째 요소를 가져오는데, 이는 벡터 인덱스가 0부터 시작하기 때문입니다. &[] 를 사용하면 해당 인덱스 위치의 요소에 대한 참조를 얻게 됩니다. 반면 get 메서드에 인덱스를 인수로 넘기면, match 와 함께 사용할 수 있는 Option<&T> 를 얻게 됩니다.

러스트가 벡터 요소를 참조하는 두 가지 방법을 제공하는 이유는, 기존 요소 범위를 벗어난 인덱스를 사용했을 때 프로그램이 어떻게 동작하길 원하는지를 여러분이 선택할 수 있게 하기 위해서입니다. 예를 들어 요소가 다섯 개인 벡터가 있을 때, 인덱스 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 을 반환합니다. 정상적인 상황에서도 가끔 범위를 벗어난 접근이 일어날 수 있다면 이 방식을 쓰는 것이 좋습니다. 그러면 여러분의 코드는 6장에서 다룬 것처럼 Some(&element) 또는 None 둘 다를 처리하는 로직을 갖게 됩니다. 예를 들어 인덱스가 사용자의 입력에서 온 값일 수 있습니다. 사용자가 실수로 너무 큰 숫자를 입력해 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의 코드는 얼핏 보기에는 동작해야 할 것처럼 보일 수 있습니다. 벡터 맨 끝에 변화가 생기는 것이 왜 첫 번째 요소에 대한 참조와 상관있을까요? 이 오류는 벡터가 동작하는 방식에서 비롯됩니다. 벡터는 값을 메모리 안에 서로 붙여 두기 때문에, 벡터 끝에 새 요소를 추가하는 일이 현재 저장된 위치에 모든 요소를 계속 붙여 놓을 공간이 없다면, 새 메모리를 할당하고 기존 요소를 통째로 복사하는 작업을 필요로 할 수도 있습니다. 그렇게 되면 첫 번째 요소를 가리키던 참조는 이미 해제된 메모리를 가리키게 됩니다. 대여 규칙은 프로그램이 그런 상황에 빠지는 것을 막아 줍니다.

Note: Vec<T> 타입의 구현 세부에 대해 더 알고 싶다면 [“The Rustonomicon”] nomicon을 참고하세요.

벡터 값 순회하기

벡터 안의 각 요소에 차례로 접근하려면, 하나씩 인덱스로 가져오기보다 모든 요소를 반복(iterate)하면 됩니다. 목록 8-7은 for 루프를 사용해 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 루프가 가지고 있는 벡터에 대한 참조가, 벡터 전체를 동시에 수정하는 것을 막기 때문입니다.

enum으로 여러 타입 저장하기

벡터는 같은 타입의 값만 저장할 수 있습니다. 이는 때때로 불편할 수 있습니다. 실제로는 서로 다른 타입의 항목 목록을 저장해야 하는 경우도 분명 있기 때문입니다. 다행히 enum의 variant들은 모두 같은 enum 타입 아래에 정의되므로, 서로 다른 타입의 요소를 표현할 하나의 타입이 필요할 때 enum을 정의해 사용할 수 있습니다!

예를 들어 어떤 스프레드시트의 한 행에서 값을 가져오고 싶은데, 그 행의 어떤 열은 정수, 어떤 열은 부동소수점 수, 어떤 열은 문자열을 담고 있다고 해 봅시다. 이때 다양한 값 타입을 담을 variant를 가지는 enum을 정의할 수 있고, 모든 variant는 동일한 타입, 즉 그 enum 타입으로 취급됩니다. 그러면 그 enum을 담는 벡터를 만들 수 있고, 결과적으로 서로 다른 타입을 하나의 벡터 안에 담을 수 있습니다. 목록 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 정의하기

러스트는 각 요소를 저장하는 데 힙 메모리가 정확히 얼마나 필요한지 알아야 하므로, 벡터 안에 어떤 타입들이 들어갈지 컴파일 시점에 알고 있어야 합니다. 또한 이 벡터에 어떤 타입이 허용되는지도 명시적이어야 합니다. 만약 러스트가 벡터가 아무 타입이나 담을 수 있게 허용했다면, 벡터 요소에 수행하는 연산과 맞지 않는 타입이 들어가 오류를 일으킬 가능성이 생깁니다. enum과 match 식을 함께 사용하면, 6장에서 보았듯이 러스트는 가능한 모든 경우가 컴파일 시점에 처리되었는지를 확인해 줍니다.

런타임에 벡터에 저장될 타입의 전체 집합을 미리 알 수 없다면, enum 기법은 통하지 않습니다. 그런 경우에는 18장에서 다룰 trait object를 사용할 수 있습니다.

지금까지 벡터를 사용하는 가장 흔한 방법들을 살펴보았으니, 표준 라이브러리가 Vec<T> 에 정의해 둔 유용한 메서드들을 API 문서에서 꼭 확인해 보세요. 예를 들어 push 외에도 마지막 요소를 제거해 반환하는 pop 메서드가 있습니다.

벡터가 drop 되면 요소도 함께 drop 된다

다른 struct 와 마찬가지로, 벡터도 스코프를 벗어나면 해제됩니다. 목록 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: 벡터와 그 요소들이 어디서 drop 되는지 보여 주기

벡터가 drop 되면 그 안의 모든 내용도 함께 drop 됩니다. 즉 벡터가 들고 있던 정수들도 정리됩니다. 대여 검사기는 벡터 내용에 대한 참조가 벡터 자체가 유효한 동안에만 사용되도록 보장합니다.

이제 다음 컬렉션 타입인 String 으로 넘어가 봅시다!

문자열로 UTF-8 텍스트 저장하기

문자열에 UTF-8 텍스트 저장하기

우리는 4장에서 문자열을 이미 이야기했지만, 여기서는 더 깊이 들여다보겠습니다. 새로운 Rustacean이 문자열에서 자주 막히는 이유는 대개 세 가지가 겹치기 때문입니다. 러스트는 잠재적 오류를 잘 드러내는 언어이고, 문자열은 많은 프로그래머가 생각하는 것보다 복잡한 자료구조이며, 거기에 UTF-8 까지 얽혀 있습니다. 다른 프로그래밍 언어에서 넘어올 때 이 요소들이 합쳐져 꽤 어렵게 느껴질 수 있습니다.

문자열은 바이트들의 컬렉션에, 그 바이트를 텍스트로 해석할 때 유용한 기능을 제공하는 메서드가 덧붙은 형태로 구현되기 때문에, 여기서는 컬렉션의 맥락에서 문자열을 다룹니다. 이 절에서는 String 에 대해, 생성·갱신·읽기처럼 모든 컬렉션 타입이 공통으로 갖는 연산을 설명합니다. 또한 사람이 String 데이터를 이해하는 방식과 컴퓨터가 그것을 해석하는 방식의 차이 때문에, String 이 다른 컬렉션과 어떻게 다른지도 살펴봅니다.

문자열 정의하기

먼저 러스트에서 문자열 이라는 말을 정확히 무엇으로 뜻하는지 정의해 봅시다. 러스트 코어 언어 자체에는 문자열 타입이 하나뿐인데, 그것은 문자열 슬라이스 str 이고 대개는 빌린 형태인 &str 로 보게 됩니다. 4장에서 설명했듯, 문자열 슬라이스는 어딘가에 저장된 UTF-8 인코딩 문자열 데이터에 대한 참조입니다. 예를 들어 문자열 리터럴은 프로그램 바이너리 안에 저장되므로 문자열 슬라이스입니다.

반면 String 타입은 코어 언어에 내장된 것이 아니라 표준 라이브러리가 제공하는 타입으로, 크기를 키울 수 있고, 가변이며, 소유권을 가지는 UTF-8 인코딩 문자열 타입입니다. Rustacean이 러스트에서 “문자열”이라고 말할 때는 String 을 뜻할 수도 있고 문자열 슬라이스 &str 을 뜻할 수도 있습니다. 이 절은 주로 String 에 초점을 맞추지만, 러스트 표준 라이브러리에서는 String 과 문자열 슬라이스 둘 다 매우 자주 사용되며, 둘 다 UTF-8 인코딩이라는 점을 기억하세요.

새 문자열 만들기

String 은 추가적인 보장과 제약, 기능이 붙은 바이트 벡터를 감싼 래퍼(wrapper) 형태로 구현되어 있기 때문에, Vec<T> 에서 가능한 많은 연산이 String 에도 가능합니다. Vec<T>String 에서 똑같이 동작하는 함수의 한 예가 인스턴스를 만드는 new 함수입니다. 목록 8-11을 보세요.

fn main() {
    let mut s = String::new();
}
Listing 8-11: 새 빈 String 만들기

이 한 줄은 s 라는 새 빈 문자열을 만듭니다. 그다음 우리는 이 안에 데이터를 넣을 수 있습니다. 흔히는 문자열을 만들 때 이미 초기 데이터가 있는 경우가 많습니다. 그럴 때는 문자열 리터럴처럼 Display 트레이트를 구현하는 모든 타입에서 사용할 수 있는 to_string 메서드를 사용합니다. 목록 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 을 만들 때는 String::from 함수도 사용할 수 있습니다. 목록 8-13의 코드는 to_string 을 쓴 목록 8-12와 같은 의미입니다.

fn main() {
    let s = String::from("initial contents");
}
Listing 8-13: 문자열 리터럴에서 String::from 으로 String 만들기

문자열은 정말 많은 곳에서 쓰이기 때문에, 러스트는 문자열과 함께 사용할 수 있는 다양한 범용 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 로 덧붙이기

목록 8-15처럼 push_str 메서드를 사용해 문자열 슬라이스를 덧붙이면 String 을 키울 수 있습니다.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
Listing 8-15: push_str 로 문자열 슬라이스를 String 에 덧붙이기

이 두 줄 뒤에 sfoobar 를 담게 됩니다. push_str 메서드가 문자열 슬라이스를 받는 이유는, 매개변수의 소유권을 꼭 가져오고 싶지는 않기 때문입니다. 예를 들어 목록 8-16의 코드에서는 s1s2 의 내용을 붙인 뒤에도 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_strs2 의 소유권을 가져갔다면 마지막 줄에서 s2 를 출력할 수 없었을 것입니다. 하지만 이 코드는 우리가 기대한 대로 잘 동작합니다!

push 메서드는 문자 하나를 매개변수로 받아 String 에 추가합니다. 목록 8-17은 pushString 에 문자 l 을 추가하는 예입니다.

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 에는 문자열 슬라이스를 더할 수는 있지만, 두 개의 String 값 자체를 그대로 더할 수는 없습니다. 그런데 잠깐, &s2 의 타입은 &String 이지 &str 이 아닙니다. 그런데도 목록 8-18이 컴파일되는 이유는 무엇일까요?

이는 컴파일러가 &String 인수를 &str 로 강제(coerce)할 수 있기 때문입니다. add 메서드를 호출할 때 러스트는 역참조 강제를 사용해서 &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! 버전의 코드는 훨씬 읽기 쉽고, 매크로가 생성한 코드는 참조를 사용하므로 호출 과정에서 어떤 인수의 소유권도 가져가지 않습니다.

문자열 인덱싱

많은 다른 프로그래밍 언어에서는 문자열 안의 개별 문자를 인덱스로 접근하는 것이 흔하고 유효한 연산입니다. 하지만 러스트에서 인덱싱 문법으로 String 의 일부에 접근하려 하면 오류를 받습니다. 목록 8-19의 잘못된 코드를 보세요.

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`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = 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 following other types implement trait `SliceIndex<T>`:
            `usize` implements `SliceIndex<ByteStr>`
            `usize` implements `SliceIndex<[T]>`
  = 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

이 오류는 핵심을 잘 보여 줍니다. 러스트 문자열은 인덱싱을 지원하지 않습니다. 왜 그럴까요? 이를 이해하려면 러스트가 문자열을 메모리에 어떻게 저장하는지부터 이야기해야 합니다.

내부 표현

String 은 사실 Vec<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라고 대답하고 싶을지도 모릅니다. 실제로는 러스트의 답은 24입니다. “Здравствуйте” 를 UTF-8 로 인코딩하는 데 필요한 바이트 수가 24이기 때문입니다. 이 문자열의 각 유니코드 스칼라 값은 2바이트를 차지합니다. 따라서 문자열 바이트 인덱스는 항상 유효한 유니코드 스칼라 값 하나와 대응되지 않습니다. 이를 보여 주기 위해 다음 잘못된 러스트 코드를 생각해 봅시다.

let hello = "Здравствуйте";
let answer = &hello[0];

여러분은 이미 answer 가 첫 글자 З 가 되지 않을 것이라는 점을 알고 있습니다. UTF-8 로 인코딩하면 З 의 첫 번째 바이트는 208, 두 번째는 151 이기 때문에, answer 는 겉보기에 208 이어야 할 것처럼 보일 수 있습니다. 하지만 208 은 그 자체로 유효한 문자가 아닙니다. 어떤 사용자가 이 문자열의 첫 글자를 요구했을 때 208 을 돌려주는 것은 거의 분명히 원하는 동작이 아닙니다. 그런데 러스트가 바이트 인덱스 0에서 실제로 볼 수 있는 것은 그 208 하나뿐입니다. 문자열에 라틴 문자만 들어 있더라도 사람은 보통 바이트 값을 원하지 않습니다. 만약 &"hi"[0] 가 유효한 코드라서 바이트 값을 반환한다면, 그것은 h 가 아니라 104 를 반환했을 것입니다.

그래서 러스트는 예기치 않은 값을 반환해 나중에야 드러날 버그를 만드는 대신, 이 코드를 아예 컴파일하지 않음으로써 개발 초기 단계에서 오해를 예방합니다.

바이트, 스칼라 값, 그리고 그래핌 클러스터

UTF-8 과 관련해 또 한 가지 중요한 점은, 문자열을 러스트의 관점에서 바라보는 방식이 실제로는 세 가지나 있다는 것입니다. 바이트, 스칼라 값, 그래핌 클러스터입니다 (그래핌 클러스터는 사람이 보통 글자 라고 부르는 것과 가장 가깝습니다).

데바나가리 문자로 쓰인 힌디어 단어 “नमस्ते” 를 보면, 이것은 다음과 같은 u8 값 벡터로 저장됩니다.

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

즉 18바이트이고, 컴퓨터는 최종적으로 이런 식으로 데이터를 저장합니다. 이것을 유니코드 스칼라 값, 곧 러스트의 char 타입 관점에서 보면 다음과 같이 보입니다.

['न', 'म', 'स', '्', 'त', 'े']

여기에는 char 값이 여섯 개 있지만, 네 번째와 여섯 번째는 글자가 아닙니다. 그 자체만으로는 의미가 없는 발음 부호입니다. 마지막으로 그래핌 클러스터로 보면, 사람이 힌디어 단어를 네 글자라고 보는 것과 비슷한 결과를 얻습니다.

["न", "म", "स्", "ते"]

러스트는 컴퓨터가 저장한 원시 문자열 데이터를 이렇게 여러 방식으로 해석할 수 있도록 해 줍니다. 그래서 데이터가 어떤 인간 언어인지와 상관없이, 각 프로그램이 자신에게 필요한 해석 방식을 선택할 수 있습니다.

러스트가 String 에 대해 문자 인덱싱을 허용하지 않는 마지막 이유는, 인덱싱 연산은 항상 상수 시간(O(1))에 끝날 것이라고 기대되기 때문입니다. 하지만 String 에 대해서는 그 성능을 보장할 수 없습니다. 러스트는 해당 인덱스까지 유효한 문자 수가 몇 개인지 판단하기 위해 문자열 시작부터 끝까지 걸어가야 할 수도 있기 때문입니다.

문자열 슬라이싱

문자열에 인덱싱하는 것은 반환 타입이 무엇이어야 하는지가 불명확하기 때문에 대체로 좋은 생각이 아닙니다. 바이트 값이어야 할까요, 문자여야 할까요, 그래핌 클러스터여야 할까요, 아니면 문자열 슬라이스여야 할까요? 그래서 정말 인덱스를 사용해 문자열 슬라이스를 만들고 싶다면, 러스트는 여러분에게 더 구체적으로 지정하라고 요구합니다.

숫자 하나로 [] 인덱싱을 하는 대신, 범위를 넣어 특정 바이트를 담는 문자열 슬라이스를 만들 수 있습니다.

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

여기서 s 는 문자열의 앞 4바이트를 담은 &str 이 됩니다. 앞에서 각 글자가 2바이트씩이라고 했으므로, sЗд 가 됩니다.

만약 &hello[0..1] 처럼 글자의 바이트 일부만 잘라내려 한다면, 벡터에서 잘못된 인덱스를 접근했을 때처럼 러스트는 런타임에 패닉을 일으킵니다.

$ 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

따라서 범위를 사용해 문자열 슬라이스를 만들 때는 프로그램이 크래시할 수 있으므로 주의해야 합니다.

문자열 순회하기

문자열의 일부를 다루는 가장 좋은 방법은, 원하는 것이 문자인지 바이트인지 명시하는 것입니다. 개별 유니코드 스칼라 값을 원하면 chars 메서드를 사용합니다. "Зд"chars 를 호출하면 char 타입 값 두 개가 분리되어 반환되고, 각 요소를 반복하며 접근할 수 있습니다.

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

이 코드는 다음을 출력합니다.

З
д

반대로 여러분의 도메인에서는 raw byte가 더 적절할 수도 있습니다. 그럴 때는 bytes 메서드를 사용할 수 있습니다.

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

이 코드는 이 문자열을 구성하는 4개의 바이트를 출력합니다.

208
151
208
180

다만 유효한 유니코드 스칼라 값 하나가 1바이트보다 큰 경우도 많다는 점을 반드시 기억하세요.

데바나가리 문자 예처럼 그래핌 클러스터를 문자열에서 꺼내는 일은 복잡하기 때문에, 표준 라이브러리는 이 기능을 제공하지 않습니다. 만약 꼭 필요하다면 crates.io에 이 기능을 제공하는 크레이트들이 있습니다.

문자열의 복잡성 다루기

정리하면, 문자열은 복잡합니다. 프로그래밍 언어마다 이 복잡성을 프로그래머에게 어떻게 보여 줄지에 대해 서로 다른 선택을 합니다. 러스트는 String 데이터를 정확하게 다루는 것을 모든 프로그램의 기본 동작으로 삼기로 선택했습니다. 그래서 프로그래머는 UTF-8 데이터를 처리할 때 처음부터 조금 더 많은 생각을 해야 합니다. 이 선택은 다른 언어보다 문자열의 복잡성을 더 많이 드러내지만, 나중에 개발 과정에서 비 ASCII 문자와 관련된 오류를 처리하느라 고생할 일을 줄여 줍니다.

좋은 소식은 표준 라이브러리가 String&str 위에 다양한 기능을 제공하여, 이런 복잡한 상황을 올바르게 처리하는 데 도움을 준다는 점입니다. 문자열 검색을 위한 contains, 문자열 일부를 다른 문자열로 바꾸는 replace 같은 유용한 메서드가 있으니 문서를 꼭 확인해 보세요.

이제 조금 덜 복잡한 주제로 넘어가 봅시다. 해시 맵입니다!

해시 맵으로 키와 값을 연결하기

해시 맵으로 키와 값을 연결하기

우리가 자주 쓰는 컬렉션의 마지막은 해시 맵입니다. HashMap<K, V> 타입은 해싱 함수(hashing function) 를 사용해, 타입 K 의 키를 타입 V 의 값에 대응시켜 저장합니다. 이 해싱 함수는 키와 값을 메모리 안에 어떻게 배치할지 결정합니다. 많은 프로그래밍 언어가 이런 자료구조를 지원하지만, hash, map, object, hash table, dictionary, associative array 등 서로 다른 이름을 쓰기도 합니다.

해시 맵은 벡터처럼 인덱스로 데이터를 찾는 대신, 어떤 타입이든 될 수 있는 키를 통해 데이터를 찾고 싶을 때 유용합니다. 예를 들어 게임에서 각 팀의 점수를 추적할 때, 해시 맵의 키를 팀 이름으로, 값은 그 팀의 점수로 둘 수 있습니다. 특정 팀 이름을 주면 점수를 얻을 수 있습니다.

이 절에서는 해시 맵의 기본 API를 살펴봅니다. 하지만 표준 라이브러리의 HashMap<K, V> 에는 더 많은 유용한 기능이 숨겨져 있으므로, 늘 그렇듯 더 자세한 내용은 표준 라이브러리 문서를 확인하세요.

새 해시 맵 만들기

빈 해시 맵을 만드는 방법 중 하나는 new 를 사용하고, insert 로 요소를 추가하는 것입니다. 목록 8-20에서는 BlueYellow 라는 두 팀의 점수를 저장합니다. 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 해야 한다는 점에 주목하세요. 세 가지 자주 쓰는 컬렉션 중에서 해시 맵은 가장 덜 흔하게 사용되기 때문에, prelude로 자동 스코프에 들어오지 않습니다. 또한 해시 맵은 표준 라이브러리에서 다른 두 컬렉션보다 직접적인 지원이 적어서, 예를 들어 벡터처럼 전용 매크로가 제공되지는 않습니다.

벡터와 마찬가지로 해시 맵도 데이터를 힙에 저장합니다. 이 HashMap 은 키 타입이 String, 값 타입이 i32 입니다. 벡터처럼 해시 맵도 동질적입니다. 즉 모든 키는 같은 타입이어야 하고, 모든 값도 같은 타입이어야 합니다.

해시 맵 값에 접근하기

해시 맵에서 값을 꺼내려면 목록 8-21처럼 get 메서드에 키를 넘기면 됩니다.

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 을 반환합니다. 이 프로그램은 copied 를 호출해 Option<&i32> 대신 Option<i32> 를 얻고, 이어서 unwrap_or 로 키에 해당하는 항목이 없을 경우 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: 키와 값은 해시 맵에 삽입되는 순간 해시 맵이 소유하게 됨을 보여 주기

field_namefield_value 변수는 insert 호출로 해시 맵 안으로 이동되었기 때문에, 그 뒤에는 더 이상 사용할 수 없습니다.

만약 해시 맵에 값 자체가 아니라 참조를 넣는다면, 값은 해시 맵 안으로 이동되지 않습니다. 다만 그 참조가 가리키는 값은 적어도 해시 맵이 유효한 동안 계속 유효해야 합니다. 이 문제는 10장의 [“라이프타임으로 참조의 유효성 검증하기”] validating-references-with-lifetimes 절에서 더 이야기합니다.

해시 맵 갱신하기

키-값 쌍의 수는 늘어나거나 줄어들 수 있지만, 하나의 고유한 키에는 어떤 순간에도 오직 하나의 값만 연결될 수 있습니다(그 반대는 아닙니다. 예를 들어 Blue 팀과 Yellow 팀이 둘 다 값 10 을 가질 수는 있습니다).

해시 맵 안의 데이터를 바꾸고 싶을 때는, 그 키에 이미 값이 있을 때 어떻게 처리할지를 먼저 정해야 합니다. 새 값으로 기존 값을 덮어쓸 수도 있고, 기존 값을 유지하고 새 값을 무시할 수도 있으며, 이전 값과 새 값을 결합할 수도 있습니다. 각각을 어떻게 하는지 살펴봅시다.

값을 덮어쓰기

해시 맵에 키와 값을 넣은 뒤, 같은 키에 다른 값을 다시 넣으면 그 키에 연결된 값은 교체됩니다. 목록 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 라는 enum 값을 반환하는데, 이것은 그 위치에 값이 있을 수도 있고 없을 수도 있음을 나타냅니다. 예를 들어 Yellow 팀 키에 값이 있는지 확인하고, 없다면 값 50 을 넣고 싶다고 해 봅시다. Blue 팀도 마찬가지입니다. entry API를 사용하면 코드는 목록 8-24처럼 됩니다.

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 메서드 사용하기

Entry 에 정의된 or_insert 메서드는, 해당 키가 이미 있다면 그 값에 대한 가변 참조를 반환하고, 없다면 인수로 받은 값을 새 값으로 삽입한 뒤 그 새 값에 대한 가변 참조를 반환하도록 정의되어 있습니다. 이 방식은 우리가 직접 조건 로직을 쓰는 것보다 훨씬 깔끔하며, 대여 검사기와도 더 잘 맞습니다.

목록 8-24의 코드를 실행하면 {"Yellow": 50, "Blue": 10} 이 출력됩니다. 첫 번째 entry 호출은 Yellow 팀이 아직 값을 갖고 있지 않기 때문에 키와 값 50 을 넣습니다. 두 번째 호출은 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 루프 한 반복이 끝나면 스코프를 벗어나므로, 이런 변경은 모두 대여 규칙상 안전하고 허용됩니다.

해싱 함수

기본적으로 HashMapSipHash 라는 해싱 함수를 사용합니다. 이 함수는 해시 테이블을 이용한 서비스 거부(DoS) 공격에 대한 저항성을 제공합니다1. 이것이 사용 가능한 가장 빠른 해싱 알고리즘은 아니지만, 성능이 조금 떨어지는 대신 보안을 얻는다는 점에서 대체로 합리적인 선택입니다. 만약 코드를 프로파일링해 보았더니 기본 해시 함수가 너무 느리다면, 다른 hasher를 지정해 다른 함수로 바꿀 수 있습니다. HasherBuildHasher 트레이트를 구현한 타입입니다. 트레이트와 그 구현 방법은 10장에서 다룹니다. 직접 처음부터 hasher를 구현할 필요는 없고, crates.io에는 다양한 흔한 해싱 알고리즘을 구현한 hasher를 제공하는 라이브러리들이 공유되어 있습니다.

정리

벡터, 문자열, 해시 맵은 데이터를 저장하고, 접근하고, 수정해야 할 때 프로그램에서 필요한 기능의 큰 부분을 담당합니다. 이제 여러분은 다음과 같은 문제를 충분히 풀 수 있어야 합니다.

  1. 정수 리스트가 주어졌을 때, 벡터를 사용해 리스트의 median(정렬했을 때 가운데 값)과 mode(가장 자주 나타나는 값. 해시 맵이 도움이 됩니다)를 구해 보세요.
  2. 문자열을 Pig Latin 으로 변환해 보세요. 각 단어의 첫 자음은 단어 끝으로 옮기고 ay 를 붙입니다. 그래서 firstirst-fay 가 됩니다. 모음으로 시작하는 단어에는 대신 끝에 hay 를 붙입니다(appleapple-hay 가 됩니다). UTF-8 인코딩의 세부도 꼭 염두에 두세요!
  3. 해시 맵과 벡터를 사용해, 회사의 부서에 직원 이름을 추가할 수 있는 텍스트 인터페이스를 만들어 보세요. 예를 들어 “Add Sally to Engineering” 또는 “Add Amir to Sales” 같은 식입니다. 그런 다음 사용자가 특정 부서의 모든 사람 목록이나, 회사 전체 사람들을 부서별로 알파벳 순서대로 정렬해 조회할 수 있게 해 보세요.

표준 라이브러리 API 문서에는 벡터, 문자열, 해시 맵이 가진 메서드들이 잘 정리되어 있고, 이런 연습문제를 풀 때 큰 도움이 될 것입니다!

이제 점점 더 복잡한 프로그램으로 들어가고 있고, 연산이 실패할 수도 있는 상황도 등장하기 시작합니다. 그래서 지금이 바로 에러 처리를 이야기하기 좋은 시점입니다. 다음 장에서 그 내용을 다루겠습니다!


  1. https://en.wikipedia.org/wiki/SipHash

에러 처리

에러는 소프트웨어에서 피할 수 없는 현실이므로, 러스트는 무언가 잘못되었을 때를 다루기 위한 여러 기능을 제공합니다. 많은 경우 러스트는 코드가 컴파일되기 전에, 에러 가능성을 인정하고 어떤 조치를 취하라고 요구합니다. 이런 요구는 여러분의 프로그램이 실제 운영 환경에 배포되기 전에 에러를 발견하고 적절히 처리하도록 만들어 더 견고하게 해 줍니다.

러스트는 에러를 크게 두 가지 범주로 나눕니다. 복구 가능한 에러와 복구 불가능한 에러입니다. 복구 가능한 에러(recoverable error) 는 예를 들어 파일을 찾을 수 없다 같은 경우로, 보통은 사용자에게 문제를 알리고 작업을 다시 시도하고 싶을 가능성이 큽니다. 반면 복구 불가능한 에러(unrecoverable error) 는 배열 끝을 넘어선 위치에 접근하려는 경우처럼 항상 버그의 증상이며, 따라서 프로그램을 즉시 멈추고 싶습니다.

대부분의 언어는 이 두 종류를 구분하지 않고, 예외 같은 메커니즘으로 같은 방식으로 처리합니다. 러스트에는 예외가 없습니다. 대신 복구 가능한 에러를 위한 Result<T, E> 타입과, 복구 불가능한 에러를 만났을 때 실행을 멈추는 panic! 매크로가 있습니다. 이 장에서는 먼저 panic! 호출을 다루고, 이어서 Result<T, E> 값을 반환하는 방법을 설명합니다. 또한 어떤 상황에서 에러 복구를 시도할지, 아니면 실행을 멈출지를 결정할 때 고려할 점도 함께 살펴봅니다.

panic!으로 복구 불가능한 에러 다루기

panic! 으로 복구 불가능한 에러 다루기

가끔은 코드 안에서 좋지 않은 일이 벌어지고, 그것에 대해 할 수 있는 일이 전혀 없을 때가 있습니다. 이런 경우 러스트는 panic! 매크로를 제공합니다. 실제로 패닉을 일으키는 방법은 두 가지입니다. 코드가 패닉을 일으키는 동작을 하게 만드는 것(예를 들어 배열 끝을 넘어서 접근하기), 혹은 panic! 매크로를 직접 호출하는 것입니다. 두 경우 모두 프로그램에서 패닉이 발생합니다. 기본적으로 이런 패닉은 실패 메시지를 출력하고, 스택을 언와인드하며 정리한 뒤 종료합니다. 또한 환경 변수를 통해 패닉이 발생했을 때 호출 스택까지 함께 표시하도록 할 수 있어, 패닉 원인을 추적하기가 더 쉬워집니다.

패닉 발생 시 스택 언와인드와 중단(abort)

기본적으로 패닉이 발생하면 프로그램은 언와인드(unwinding) 를 시작합니다. 즉, 러스트가 스택을 거슬러 올라가며 만나는 각 함수의 데이터를 정리한다는 뜻입니다. 하지만 이렇게 되돌아가며 정리하는 작업에는 비용이 많이 듭니다. 그래서 러스트는 즉시 중단(aborting) 하는 대안도 제공합니다. 중단은 아무 정리도 하지 않고 프로그램을 곧바로 끝냅니다.

그때 프로그램이 사용하던 메모리는 운영체제가 정리하게 됩니다. 만약 프로젝트에서 최종 바이너리 크기를 최대한 줄여야 한다면, 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! 이 호출되는 예를 보겠습니다. 목록 9-1은 벡터에서 유효한 범위를 벗어난 인덱스에 접근하려는 코드입니다.

Filename: src/main.rs
fn main() {
    let v = vec![1, 2, 3];

    v[99];
}
Listing 9-1: 벡터 끝을 넘는 요소에 접근하려고 하여 panic! 호출을 일으키는 코드

여기서는 벡터의 100번째 요소에 접근하려고 합니다(인덱스는 0부터 시작하므로 실제 인덱스 값은 99입니다). 하지만 이 벡터에는 요소가 세 개뿐입니다. 이런 상황에서 러스트는 패닉을 일으킵니다. [] 는 어떤 요소를 반환해야 하는 문법인데, 유효하지 않은 인덱스를 넘기면 러스트가 돌려줄 수 있는 올바른 요소가 존재하지 않기 때문입니다.

C에서는 자료구조 끝을 넘어 읽는 것이 정의되지 않은 동작입니다. 때에 따라서는 그 자료구조에 속하지 않는 메모리이더라도, 마치 그 요소 자리에 해당하는 위치의 값을 그냥 읽어 올 수도 있습니다. 이를 버퍼 오버리드(buffer overread) 라고 하며, 공격자가 인덱스를 조작해 읽어서는 안 되는 데이터를 읽을 수 있게 되면 보안 취약점으로 이어질 수 있습니다.

러스트는 이런 취약점으로부터 프로그램을 보호하기 위해, 존재하지 않는 인덱스의 요소를 읽으려 하면 실행을 중단하고 더 이상 진행하지 않습니다. 실제로 실행해 봅시다.

$ 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

이 에러는 우리가 벡터 v 의 인덱스 99에 접근하려는 main.rs 4번째 줄을 가리킵니다.

note: 줄은 에러를 일으킨 정확한 경로를 백트레이스로 보고 싶다면 RUST_BACKTRACE 환경 변수를 설정할 수 있다고 알려 줍니다. 백트레이스(backtrace) 는 이 지점에 도달하기까지 호출된 모든 함수의 목록입니다. 러스트의 백트레이스도 다른 언어와 마찬가지로 읽습니다. 맨 위에서부터 내려가다가 여러분이 작성한 파일이 나오는 지점을 찾으면 됩니다. 그 지점이 문제의 출발점입니다. 그 위의 줄들은 여러분 코드가 호출한 코드들이고, 그 아래 줄들은 여러분 코드를 호출한 코드입니다. 여기에는 핵심 러스트 코드, 표준 라이브러리 코드, 또는 사용하는 외부 크레이트 코드가 포함될 수도 있습니다. 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! 호출이 생성하는 백트레이스

출력이 꽤 길지요! 실제로 보게 되는 정확한 출력은 운영체제와 러스트 버전에 따라 다를 수 있습니다. 이 정도 정보가 담긴 백트레이스를 얻으려면 디버그 심벌이 켜져 있어야 합니다. 우리가 지금처럼 cargo buildcargo run--release 없이 실행할 때는 디버그 심벌이 기본적으로 활성화됩니다.

목록 9-2 출력에서 백트레이스의 6번째 줄이 문제를 일으키는 우리 프로젝트 코드, 즉 src/main.rs 4번째 줄을 가리킵니다. 프로그램이 패닉하지 않게 하고 싶다면, 우리가 작성한 파일을 처음 언급하는 줄의 위치부터 조사를 시작하면 됩니다. 목록 9-1처럼 의도적으로 패닉을 일으키는 코드를 쓴 경우, 해결책은 벡터 인덱스 범위를 벗어난 요소를 요청하지 않는 것입니다. 앞으로 코드가 패닉을 일으킬 때는, 어떤 연산이 어떤 값과 함께 실행되어 패닉이 발생했는지, 그리고 그 대신 코드가 무엇을 해야 하는지를 판단해야 합니다.

이 장의 뒤쪽 [“panic! 을 써야 할 때와 쓰지 말아야 할 때”] to-panic-or-not-to-panic 절에서, 언제 panic! 을 사용해야 하고 언제 사용하지 말아야 하는지 다시 돌아와 이야기할 것입니다. 이제는 Result 로 에러에서 어떻게 복구하는지 살펴봅시다.

Result로 복구 가능한 에러 다루기

Result 로 복구 가능한 에러 다루기

대부분의 에러는 프로그램 전체를 멈출 정도로 심각하지는 않습니다. 함수가 실패하더라도, 그 이유를 해석하고 대응하기 쉬운 경우가 많기 때문입니다. 예를 들어 파일을 열려고 했는데 파일이 존재하지 않아 실패했다면, 프로세스를 끝내는 대신 파일을 새로 만들고 싶을 수도 있습니다.

2장의 Result 타입으로 발생 가능한 실패 처리하기” 절에서 본 것처럼, Result enum은 OkErr 두 variant를 가진다고 정의되어 있습니다.

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

TE 는 제네릭 타입 매개변수입니다. 제네릭은 10장에서 더 자세히 다룹니다. 지금은 T 가 성공했을 때 Ok variant 안에 들어가는 값의 타입을 뜻하고, E 는 실패했을 때 Err variant 안에 들어가는 에러 값의 타입을 뜻한다는 것만 알면 됩니다. Result 가 이런 제네릭 매개변수를 가지기 때문에, 우리는 성공값과 에러값이 서로 다른 여러 상황에서 Result 타입과 그 위의 함수들을 유연하게 사용할 수 있습니다.

실패할 수 있는 함수가 Result 값을 반환하는 경우를 호출해 봅시다. 목록 9-3에서는 파일을 열려고 시도합니다.

use std::fs::File;

fn main() {
    let greeting_file_result = File::open("hello.txt");
}

File::open 의 반환 타입은 Result 입니다. 여기서 제네릭 매개변수 T 는 성공값 타입인 std::fs::File 로 채워지고, 에러값에 쓰이는 Estd::io::Error 로 채워집니다.

이 반환 타입은 File::open 호출이 성공하면 읽기나 쓰기에 사용할 수 있는 파일 핸들을 반환할 수 있다는 뜻입니다. 동시에 호출이 실패할 수도 있습니다. 예를 들어 파일이 존재하지 않거나, 우리가 접근 권한을 갖고 있지 않을 수 있습니다. 따라서 File::open 은 성공/실패 여부를 알려 주면서, 동시에 파일 핸들이나 에러 정보 중 하나를 함께 전달할 방법이 필요합니다. Result enum 이 바로 그 정보를 표현합니다.

File::open 이 성공하면 변수 greeting_file_result 의 값은 파일 핸들을 담은 Ok 인스턴스가 됩니다. 실패하면 greeting_file_result 는 어떤 종류의 에러가 발생했는지 더 많은 정보를 담은 Err 인스턴스가 됩니다. 이제 목록 9-3의 코드에, File::open 이 어떤 값을 반환했는지에 따라 다른 행동을 하도록 코드를 추가해야 합니다.

목록 9-4는 6장에서 배운 기본 도구인 match 식을 사용해 Result 를 처리하는 한 가지 방법을 보여 줍니다.

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:?}"),
    };
}

Option enum과 마찬가지로, Result enum과 그 variant 역시 prelude 덕분에 이미 스코프에 들어와 있으므로, match arm 안에서 Ok, Err 앞에 Result:: 를 붙일 필요가 없습니다.

결과가 Ok 이면 이 코드는 Ok 안의 file 값을 꺼내어, 그 파일 핸들을 greeting_file 변수에 대입합니다. match 뒤에서는 이 파일 핸들을 읽기나 쓰기에 사용할 수 있습니다. 다른 arm은 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 식을 추가합니다.

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:?}");
            }
        },
    };
}

File::openErr variant 안에 담아 반환하는 값의 타입은 io::Error 인데, 이것은 표준 라이브러리가 제공하는 구조체입니다. 이 구조체에는 kind 라는 메서드가 있고, 이를 호출하면 io::ErrorKind 값을 얻을 수 있습니다. io::ErrorKind 역시 표준 라이브러리가 제공하는 enum으로, io 연산에서 발생할 수 있는 서로 다른 종류의 에러를 표현하는 variant들을 가집니다.

우리가 여기서 쓰고 싶은 variant는 ErrorKind::NotFound 입니다. 이 값은 열려고 했던 파일이 아직 존재하지 않는다는 뜻입니다. 따라서 우리는 먼저 greeting_file_result 에 대해 match 를 하고, 그 안에서 error.kind() 결과에 대해 또 match 를 합니다. 안쪽 match 에서 확인하고 싶은 조건은 error.kind()ErrorKind enum의 NotFound variant 인지 여부입니다. 만약 그렇다면 File::create 로 파일을 만들려고 시도합니다.

하지만 File::create 도 실패할 수 있으므로, 안쪽 match 식에도 두 번째 arm이 필요합니다. 파일을 만들 수 없으면 다른 에러 메시지를 출력합니다. 바깥쪽 match 의 두 번째 arm은 그대로 유지되므로, “파일이 없음” 이외의 에러는 여전히 모두 패닉을 일으킵니다.

Resultmatch 를 쓰는 것의 대안

match 는 아주 유용하지만, 동시에 꽤 원시적인 도구이기도 합니다.

13장에서는 클로저를 배우게 되는데, 클로저는 Result 에 정의된 많은 메서드와 함께 사용됩니다. 이런 메서드들은 Result 값을 처리할 때, 직접 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 식을 치워 주는 메서드가 훨씬 더 많이 있습니다.

에러 시 패닉하기 위한 지름길

match 도 충분히 잘 동작하지만, 장황하게 느껴질 수 있고 의도를 항상 잘 드러내는 것도 아닙니다. Result 타입에는 더 구체적인 작업을 수행하기 위한 여러 헬퍼 메서드가 정의되어 있습니다.

unwrap 메서드는 목록 9-4에서 우리가 직접 쓴 match 식을 축약한 메서드입니다. Result 값이 Ok variant면 unwrap 은 그 안의 값을 반환합니다. 반면 Err variant 면 unwrap 이 대신 panic! 매크로를 호출합니다.

다음은 unwrap 을 사용하는 예입니다.

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! 메시지도 직접 선택할 수 있습니다.

다음이 expect 의 문법입니다.

use std::fs::File;

fn main() {
    let greeting_file = File::open("hello.txt")
        .expect("hello.txt should be included in this project");
}

우리는 expect 역시 unwrap 과 같은 방식으로 사용합니다. 파일 핸들을 반환받거나, 아니면 panic! 매크로를 호출하도록 하는 것이지요. 다만 expectpanic! 에 전달하는 에러 메시지는 unwrap 이 쓰는 기본 메시지가 아니라, 우리가 직접 expect 에 넘긴 인수입니다.

결과는 다음과 같습니다.

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" }

실전 품질의 코드에서는 대부분의 Rustacean이 unwrap 보다는 expect 를 선호하고, 해당 연산이 왜 성공해야 하는지에 대한 맥락을 메시지에 넣어 둡니다. 그렇게 하면 나중에 그 가정이 틀렸을 때 디버깅에 더 많은 정보를 얻을 수 있습니다.

에러 전파하기

어떤 함수 구현이 실패할 수 있는 다른 함수를 호출할 때, 그 함수 안에서 직접 에러를 처리하는 대신 호출한 쪽 코드로 에러를 반환하여 거기서 무엇을 할지 결정하게 할 수 있습니다. 이를 에러를 전파(propagating) 한다고 하며, 더 많은 문맥과 로직을 가진 호출하는 쪽 코드에게 더 큰 제어권을 주게 됩니다.

예를 들어 목록 9-6은 파일에서 사용자 이름을 읽어 오는 함수를 보여 줍니다. 파일이 없거나 읽을 수 없다면, 이 함수는 그 에러를 함수를 호출한 쪽 코드에 그대로 돌려줍니다.

#![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),
    }
}
}

이 함수는 훨씬 더 짧게 쓸 수도 있지만, 에러 처리를 살펴보기 위해 일부를 수동으로 길게 적어 보겠습니다. 마지막에 더 짧은 버전도 보여 줄 것입니다.

먼저 함수의 반환 타입부터 봅시다. Result<String, io::Error> 입니다. 이는 이 함수가 Result<T, E> 타입의 값을 반환한다는 뜻이고, 여기서 제네릭 매개변수 T 는 구체 타입 String 으로, 제네릭 타입 E 는 구체 타입 io::Error 로 채워졌다는 의미입니다.

이 함수가 문제 없이 성공하면, 이 함수를 호출한 코드는 파일에서 읽은 사용자 이름을 담고 있는 String 을 포함한 Ok 값을 받습니다. 반대로 함수가 문제를 만나면, 호출한 쪽 코드는 어떤 문제가 있었는지 더 많은 정보를 담은 io::Error 인스턴스를 포함한 Err 값을 받게 됩니다. 우리가 이 함수의 반환 타입으로 io::Error 를 선택한 이유는, 함수 본문 안에서 실패할 수 있는 두 연산인 File::open 함수와 read_to_string 메서드가 둘 다 io::Error 타입의 에러를 반환하기 때문입니다.

함수 본문은 먼저 File::open 을 호출하는 것으로 시작합니다. 그런 다음 목록 9-4의 match 와 비슷한 방식으로 이 Result 값을 처리합니다. File::open 이 성공하면 패턴 변수 file 안의 파일 핸들이 가변 변수 username_file 안으로 들어가고 함수는 계속 진행합니다. Err 인 경우에는 panic! 을 호출하는 대신 return 키워드로 함수 전체에서 조기에 빠져나오며, File::open 의 에러 값을(이제 패턴 변수 e 안에 있습니다) 이 함수의 에러 값으로 그대로 호출한 쪽에 돌려줍니다.

그래서 username_file 안에 파일 핸들이 있게 되면, 함수는 username 이라는 새 String 을 만들고, username_file 의 파일 핸들에 대해 read_to_string 메서드를 호출해 파일 내용을 username 안으로 읽어들입니다. read_to_string 역시 실패할 수 있기 때문에 또 다른 Result 를 반환합니다. File::open 이 성공했다고 해서 read_to_string 도 반드시 성공하는 것은 아니므로, 이 Result 도 다시 match 로 처리해야 합니다. read_to_string 이 성공하면 함수는 성공한 것이므로, username 안에 들어 있는 사용자 이름을 Ok 로 감싸 반환합니다. 실패하면 File::open 의 반환값을 처리했던 match 에서와 같은 방식으로 그 에러 값을 반환합니다. 다만 여기서는 함수의 마지막 식이므로 return 을 명시적으로 쓸 필요가 없습니다.

그러면 이 함수를 호출한 코드는 String 을 담은 Ok 값이나, io::Error 를 담은 Err 값을 받게 되고, 그중 무엇을 받았는지에 따라 처리할 수 있습니다. 예를 들어 호출한 코드는 Err 값을 받으면 panic! 을 호출해 프로그램을 중단시킬 수도 있고, 기본 사용자 이름을 사용하거나, 파일이 아닌 다른 곳에서 사용자 이름을 읽어 올 수도 있습니다. 현재 함수 안에서는 호출한 쪽 코드가 실제로 무엇을 하려는지 알 수 없으므로, 성공과 에러 정보를 그대로 위로 전파해 적절히 처리하도록 맡기는 것입니다.

이런 식의 에러 전파 패턴은 러스트에서 너무 흔하기 때문에, 이를 더 쉽게 쓰도록 러스트는 물음표 연산자 ? 를 제공합니다.

? 연산자 지름길

목록 9-7은 목록 9-6과 같은 기능을 하는 read_username_from_file 구현을 보여 줍니다. 이번 구현은 ? 연산자를 사용합니다.

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에서 우리가 직접 쓴 match 식과 거의 같은 방식으로 동작하도록 정의되어 있습니다. Result 값이 Ok 라면, 그 안의 값이 이 식의 결과로 반환되고 프로그램은 계속 진행합니다. 값이 Err 라면, 마치 우리가 직접 return 키워드를 쓴 것처럼 함수 전체에서 그 Err 가 반환되어 에러 값이 호출하는 쪽 코드로 전파됩니다.

하지만 목록 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: ? 연산자 뒤에 메서드 호출을 체이닝하기

이 버전에서는 함수 처음에 usernameString 을 만드는 부분은 그대로 두고, 중간의 username_file 변수를 없앴습니다. 대신 File::open("hello.txt")? 의 결과에 직접 read_to_string 호출을 연결했습니다. 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 을 만들고, 파일 내용을 읽어 그 안에 넣은 뒤, 그 문자열을 반환하는 편리한 fs::read_to_string 함수를 제공합니다. 물론 여기서는 에러 처리 과정을 설명하기 위해 더 긴 방식을 먼저 살펴본 것입니다.

? 연산자를 사용할 수 있는 곳

? 연산자는 그것이 적용되는 값과 호환되는 반환 타입을 가진 함수 안에서만 사용할 수 있습니다. 이는 ? 연산자가 목록 9-6의 match 식에서 우리가 했던 것처럼, 함수 바깥으로 값을 조기 반환하도록 정의되어 있기 때문입니다. 목록 9-6의 matchResult 값에 대해 동작했고, 조기 반환 arm은 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: 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 을 구현한 다른 타입을 반환하는 함수에서만 쓸 수 있다고 지적합니다.

이 오류를 고치는 방법은 두 가지입니다. 하나는 특별한 제약이 없다면, 함수의 반환 타입을 ? 를 사용하는 값과 호환되도록 바꾸는 것입니다. 다른 하나는 matchResult<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> 를 반환합니다. 이 코드는 문자열 슬라이스 인수 textlines 메서드를 호출하는데, 이는 문자열 안의 줄들에 대한 반복자를 반환합니다. 함수는 첫 번째 줄만 확인하고 싶기 때문에, 반복자에 next 를 호출해 첫 값을 가져옵니다. 만약 text 가 빈 문자열이면, 이 next 호출은 None 을 반환하고, 이때 우리는 ? 를 사용해 함수를 멈추고 last_char_of_first_line 에서 None 을 반환합니다. 반대로 text 가 빈 문자열이 아니라면, next 는 첫 줄의 문자열 슬라이스를 담은 Some 값을 반환합니다.

? 는 그 문자열 슬라이스를 꺼내 주고, 우리는 그 슬라이스에 chars 를 호출해 문자 반복자를 얻을 수 있습니다. 우리는 첫 줄의 마지막 문자에 관심이 있으므로, 반복자에 last 를 호출해 마지막 항목을 반환합니다. 이 역시 Option 인데, 첫 줄이 빈 문자열일 가능성이 있기 때문입니다. 예를 들어 text"\nhi" 라면, 첫 줄은 비어 있지만 뒤 줄에는 문자가 있습니다. 그러나 첫 줄에 마지막 문자가 있다면 그 값은 Some variant로 반환됩니다. 중간의 ? 연산자는 이 전체 로직을 매우 간결하게 표현하게 해 줍니다. 만약 Option 에 대해 ? 를 쓸 수 없었다면, 이 로직은 훨씬 더 많은 메서드 호출이나 match 식으로 구현해야 했을 것입니다.

중요한 점은, Result 를 반환하는 함수 안에서는 Result 에 대해 ? 를 사용할 수 있고, Option 을 반환하는 함수 안에서는 Option 에 대해 ? 를 사용할 수 있지만, 이 둘을 마음대로 섞어 쓸 수는 없다는 것입니다. ? 연산자는 ResultOption 으로, 혹은 그 반대로 자동 변환해 주지 않습니다. 그런 경우에는 Resultok 메서드나 Optionok_or 메서드처럼, 명시적으로 변환하는 메서드를 사용해야 합니다.

지금까지 우리가 사용한 모든 main 함수는 () 를 반환했습니다. main 함수는 실행 가능한 프로그램의 시작점이자 끝점이기 때문에, 프로그램이 기대한 대로 동작하려면 반환 타입에 몇 가지 제약이 있습니다.

다행히 mainResult<(), 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> 타입은 trait object이며, 이는 18장의 “공통 동작을 추상화하기 위해 트레이트 객체 사용하기” 절에서 다룹니다. 지금은 Box<dyn Error> 를 “어떤 종류의 에러든 담을 수 있는 것” 정도로 읽으면 됩니다. Box<dyn Error> 가 허용되기 때문에, Result<(), Box<dyn Error>> 를 반환하는 main 안에서는 어떤 Err 값이든 조기 반환될 수 있습니다. 이 main 함수 본문이 현재는 std::io::Error 타입 에러만 반환하더라도, 나중에 다른 종류의 에러를 반환하는 코드를 추가해도 이 시그니처는 여전히 올바르게 유지됩니다.

main 함수가 Result<(), E> 를 반환하면, mainOk(()) 를 반환할 때 실행 파일은 종료 값 0 으로 끝나고, Err 를 반환하면 0이 아닌 값으로 끝납니다. C로 작성된 실행 파일도 종료 시 정수를 반환합니다. 성공적으로 끝난 프로그램은 0, 오류로 끝난 프로그램은 0 이 아닌 정수를 반환합니다. 러스트도 이 관례와 호환되기 위해 실행 파일에서 정수를 반환합니다.

main 함수는 [표준 라이브러리의 std::process::Termination 트레이트] termination를 구현한 어떤 타입이든 반환할 수 있습니다. 이 트레이트는 ExitCode 를 반환하는 report 함수를 포함합니다. 여러분 자신의 타입에 Termination 트레이트를 구현하는 방법은 표준 라이브러리 문서를 참고하세요.

이제 panic! 을 호출하는 경우와 Result 를 반환하는 경우의 세부를 모두 살펴봤으니, 어떤 상황에서 어느 쪽을 사용하는 것이 적절한지라는 주제로 다시 돌아가 보겠습니다.

panic!을 써야 할 때와 쓰지 말아야 할 때

panic! 을 써야 할 때와 쓰지 말아야 할 때

그렇다면 언제 panic! 을 호출해야 하고, 언제 Result 를 반환해야 할까요? 코드가 패닉하면 복구할 방법이 없습니다. 복구 가능성이 있든 없든 모든 에러 상황에 대해 panic! 을 호출할 수는 있지만, 그렇게 하면 “이 상황은 복구 불가능하다”는 결정을 호출하는 코드 대신 여러분이 내려 버리는 것입니다. 반대로 Result 값을 반환하면 호출하는 코드에 선택지를 남겨 둡니다. 호출하는 코드는 자기 상황에 맞는 방식으로 복구를 시도할 수도 있고, 혹은 이 경우 Err 값이 사실상 복구 불가능하다고 판단해 panic! 을 호출하고, 여러분의 “복구 가능한 에러”를 자기 쪽에서는 “복구 불가능한 에러”로 바꿀 수도 있습니다. 그래서 실패할 수 있는 함수를 정의할 때는 Result 를 반환하는 것이 좋은 기본 선택입니다.

예제 코드, 프로토타입 코드, 테스트 같은 상황에서는 Result 대신 패닉하는 코드를 작성하는 편이 더 적절할 수 있습니다. 왜 그런지 먼저 살펴보고, 그다음에는 컴파일러는 실패 가능성을 알 수 없지만 인간인 여러분은 “실패할 수 없다”고 판단할 수 있는 상황을 논의하겠습니다. 마지막에는 라이브러리 코드에서 패닉할지 말지를 결정할 때 도움이 되는 일반적인 가이드라인으로 장을 마무리합니다.

예제, 프로토타입 코드, 테스트

어떤 개념을 설명하기 위한 예제를 작성할 때, 그 안에 견고한 에러 처리 코드까지 다 넣어 버리면 오히려 예제가 덜 명확해질 수 있습니다. 예제에서 unwrap 같은 패닉 가능 메서드를 호출하는 것은, “여기에는 실제 애플리케이션이 상황에 맞게 에러를 처리하는 방식이 들어갈 자리표시자” 정도로 이해하면 됩니다.

마찬가지로, 에러를 어떻게 처리할지 아직 결정하지 않은 프로토타입 단계에서는 unwrapexpect 가 매우 편리합니다. 나중에 프로그램을 더 견고하게 만들 준비가 되었을 때 “여기를 다시 봐야 한다”는 표시를 코드에 명확하게 남겨 두기 때문입니다.

테스트에서 어떤 메서드 호출이 실패한다면, 그 메서드 자체가 테스트 대상이 아니더라도 테스트 전체가 실패해야 합니다. 러스트에서 테스트 실패는 panic! 으로 표시되므로, unwrap 이나 expect 를 호출했을 때 그대로 패닉하는 동작은 오히려 우리가 원하는 정확한 행동입니다.

여러분이 컴파일러보다 더 많은 정보를 알고 있을 때

또 다른 적절한 경우는, 어떤 로직 덕분에 Result 가 반드시 Ok 값을 가질 것이라는 사실을 여러분은 알고 있지만 컴파일러는 알지 못하는 경우입니다. 일반적인 의미에서 실패할 가능성이 여전히 있는 연산을 호출하고 있으므로, 코드 안에는 여전히 Result 값이 생깁니다. 그러나 코드 전체를 사람이 직접 살펴보았을 때 특정 상황에서는 Err variant가 절대로 나올 수 없다고 확신할 수 있다면, expect 를 호출하고 왜 그런 확신을 갖는지를 expect 의 메시지 안에 적어 두는 것은 충분히 괜찮습니다. 다음은 예시입니다.

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 를 더 적절한 에러 처리 코드로 바꿔야 한다는 사실도 쉽게 드러납니다.

에러 처리 가이드라인

코드가 나쁜 상태(bad state)에 빠질 가능성이 있다면, 그럴 때는 패닉하게 만드는 것이 바람직합니다. 여기서 나쁜 상태 란 어떤 가정, 보장, 계약, 혹은 불변 조건이 깨진 상태를 말합니다. 예를 들어 잘못된 값, 서로 모순되는 값, 필요한 값의 부재 같은 것이 여기에 해당하고, 보통 다음 중 하나 이상이 함께 성립합니다.

  • 그 나쁜 상태는 사용자가 가끔 잘못된 형식으로 데이터를 입력하는 것처럼 “가끔 일어날 수 있는 일” 이 아니라, “예상 밖의 일” 이다.
  • 그 이후의 코드가 매 단계마다 문제를 검사하는 대신, “지금 나쁜 상태가 아니다”는 사실에 의존해야 한다.
  • 사용 중인 타입으로 이 정보를 깔끔하게 인코딩할 좋은 방법이 없다. 18장의 “상태와 동작을 타입으로 인코딩하기” 절에서 이 의미가 무엇인지 예제를 통해 살펴봅니다.

누군가가 여러분의 코드를 호출할 때 말이 안 되는 값을 넘겼다면, 가능하다면 에러를 반환해 라이브러리 사용자가 자기 상황에 맞는 결정을 내리게 하는 것이 좋습니다. 그러나 계속 진행하는 것이 위험하거나 해로울 수 있는 경우라면, panic! 을 호출해 라이브러리를 사용하는 사람에게 “여기에는 버그가 있다”는 사실을 알려 주고 개발 중에 고치게 하는 편이 더 좋을 수도 있습니다. 마찬가지로, 여러분이 제어할 수 없는 외부 코드를 호출했는데 그것이 고칠 방법도 없는 잘못된 상태를 반환한다면, panic! 이 적절한 선택인 경우가 많습니다.

반대로 실패가 기대되는 상황이라면, panic! 보다는 Result 를 반환하는 편이 적절합니다. 예를 들어 파서에 잘못된 형식의 데이터가 들어오거나, HTTP 요청이 속도 제한에 걸렸음을 나타내는 상태 코드를 반환하는 경우가 그렇습니다. 이런 경우 Result 를 반환하는 것은 “실패가 예상 가능한 가능성” 이며, 호출하는 코드가 이를 어떻게 처리할지 결정해야 함을 의미합니다.

코드가 잘못된 값으로 호출되었을 때 사용자를 위험하게 만들 수 있는 연산을 수행한다면, 코드 안에서 먼저 값이 유효한지 검사하고, 유효하지 않다면 패닉해야 합니다. 이것은 주로 안전 때문입니다. 잘못된 데이터로 연산하려고 하면 코드가 취약점에 노출될 수 있습니다. 이것이 표준 라이브러리가 배열 범위를 벗어난 메모리 접근 시 panic! 을 호출하는 주된 이유입니다. 현재 자료구조에 속하지 않는 메모리에 접근하려는 시도는 아주 흔한 보안 문제이기 때문입니다. 함수는 종종 계약(contract) 을 가집니다. 입력이 특정 요구사항을 만족할 때만 그 동작이 보장됩니다. 계약이 깨졌을 때 패닉하는 것은 자연스러운 일입니다. 계약 위반은 항상 호출자 쪽의 버그 를 의미하기 때문이며, 호출하는 코드가 일일이 처리해야 할 종류의 에러가 아니기 때문입니다. 사실 호출하는 코드가 “복구”할 합리적인 방법도 없습니다. 고쳐야 하는 것은 호출하는 프로그래머 의 코드입니다. 함수의 계약은, 특히 계약 위반이 패닉을 일으킨다면 API 문서에 설명되어 있어야 합니다.

그렇다고 모든 함수에 이런 검사를 전부 손으로 넣는다면 장황하고 귀찮아집니다. 다행히 러스트의 타입 시스템(그리고 컴파일러의 타입 검사)을 이용해 많은 검사를 대신 시킬 수 있습니다. 함수가 어떤 타입을 매개변수로 받는다면, 컴파일러가 이미 유효한 값만 들어올 수 있도록 보장했다는 사실을 믿고 이후 로직을 진행할 수 있습니다. 예를 들어 Option 대신 단일 타입을 받는다면, 이 프로그램은 없음 이 아니라 무언가 가 반드시 있다는 것을 기대하고 있습니다. 그러면 코드는 SomeNone 두 경우를 모두 다룰 필요 없이, 값이 확실히 있다는 경우 하나만 처리하면 됩니다. 함수에 “없음” 을 넘기려는 코드는 아예 컴파일조차 되지 않으므로, 함수는 런타임에서 그 경우를 검사할 필요가 없습니다. 또 다른 예는 u32 같은 부호 없는 정수 타입을 사용하는 것입니다. 그러면 매개변수가 절대 음수가 아님이 보장됩니다.

검증을 위한 사용자 정의 타입

러스트 타입 시스템을 사용해 “유효한 값만 가진다”는 사실을 보장하는 아이디어를 한 걸음 더 밀어붙여, 검증용 사용자 정의 타입을 만드는 방법을 살펴봅시다. 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 사이임을 알고 있으므로, 비밀 숫자와의 비교를 안심하고 진행할 수 있습니다.

하지만 이 방법도 이상적이지는 않습니다. 프로그램이 정말로 1부터 100 사이의 값에서만 동작해야 하고, 이런 요구를 가진 함수가 여러 개 있다면, 모든 함수 안에 이런 검사를 반복해서 적는 것은 지루하고(심지어 성능에도 영향을 줄 수 있습니다).

대신 별도 모듈 안에 새 타입을 만들고, 인스턴스를 생성하는 함수 안에 검증을 넣을 수 있습니다. 그러면 검증을 여러 곳에 반복하지 않아도 되고, 함수들은 그 새 타입을 시그니처에 사용함으로써 “전달받은 값은 이미 유효하다”는 사실을 믿고 코드를 쓸 수 있습니다. 목록 9-13은 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 타입

참고로 이 코드는 src/guessing_game.rs 안에 있지만, 여기에는 보여 주지 않은 mod guessing_game; 모듈 선언이 src/lib.rs 안에 추가되어 있어야 합니다. 새 모듈 파일 안에서 우리는 value 라는 i32 필드를 가진 Guess 구조체를 정의합니다. 이 필드에 실제 숫자가 저장됩니다.

그리고 나서 Guess 위에 new 라는 연관 함수를 구현합니다. new 함수는 value 라는 이름의 i32 매개변수 하나를 받고 Guess 를 반환합니다. new 본문 안의 코드는 value 가 1과 100 사이인지 검사합니다. 만약 이 검사를 통과하지 못하면 panic! 을 호출합니다. 이것은 이 함수를 호출하는 코드를 작성한 프로그래머에게 “고쳐야 할 버그가 있다”는 사실을 알려 주기 위함입니다. 범위 밖 값으로 Guess 를 만드는 것은 Guess::new 가 기대하는 계약을 어기는 것이기 때문입니다. Guess::new 가 패닉할 수 있는 조건은 공개 API 문서에도 설명되어 있어야 합니다. 14장에서는 API 문서 안에 panic! 가능성을 어떻게 표시하는지도 다룹니다. 반대로 value 가 검사를 통과하면, value 필드가 그 값으로 설정된 새 Guess 를 만들어 반환합니다.

그 다음에는 self 를 빌리고 다른 매개변수는 받지 않으며 i32 를 반환하는 value 라는 메서드를 구현합니다. 이런 종류의 메서드는 보통 필드 데이터를 꺼내 반환하는 역할을 하므로 getter 라고 부릅니다. 이 public 메서드가 필요한 이유는, Guess 구조체의 value 필드가 private 이기 때문입니다. value 필드를 private 로 두는 것은 중요합니다. Guess 를 사용하는 코드가 직접 value 를 설정하지 못하게 하기 위해서 입니다. 모듈 바깥 코드가 Guess 인스턴스를 만들려면 반드시 Guess::new 를 사용해야 하고, 그러면 Guess 안의 값은 항상 Guess::new 의 검사를 통과한 값이라는 사실이 보장됩니다.

1부터 100 사이 숫자만 받아야 하는 함수라면, 이제 시그니처에서 i32 대신 Guess 를 받거나 반환한다고 선언할 수 있습니다. 그러면 함수 본문 안에서 추가 검사 코드를 또 작성할 필요가 없습니다.

정리

러스트의 에러 처리 기능은 여러분이 더 견고한 코드를 작성하도록 설계되어 있습니다. panic! 매크로는 프로그램이 더 이상 감당할 수 없는 상태에 있다는 신호를 보내고, 잘못된 값으로 계속 진행하는 대신 프로세스를 멈추게 합니다. Result enum은 타입 시스템을 사용해, 어떤 연산이 실패할 수 있지만 여러분의 코드가 그 실패로부터 복구할 수 있다는 사실을 표현합니다. Result 는 여러분의 코드를 호출하는 코드에게도, 성공과 실패 가능성을 모두 처리해야 한다는 사실을 알릴 수 있습니다. 적절한 상황에서 panic!Result 를 사용하는 것은, 피할 수 없는 문제들 앞에서도 코드를 더 신뢰할 수 있게 만들어 줍니다.

이제 여러분은 표준 라이브러리가 OptionResult enum에서 제네릭을 어떻게 유용하게 사용하는지도 보았습니다. 다음으로는 제네릭이 어떻게 동작하는지, 그리고 여러분 자신의 코드에서 제네릭을 어떻게 사용할 수 있는지를 이야기하겠습니다.

제네릭 타입, 트레이트, 라이프타임

모든 프로그래밍 언어에는 개념 중복을 효과적으로 다루기 위한 도구가 있습니다. 러스트에서는 그중 하나가 제네릭(generics) 입니다. 제네릭은 구체적인 타입이나 다른 속성을 대신하는 추상적인 자리표시자입니다. 우리는 코드를 컴파일하고 실행할 때 그 자리에 무엇이 올지 몰라도, 제네릭의 동작 방식이나 제네릭들 사이 관계를 표현할 수 있습니다.

함수는 i32String 같은 구체 타입 대신 어떤 제네릭 타입의 매개변수를 받을 수 있습니다. 이는 함수가 여러 구체 값에 같은 코드를 적용하기 위해 “값은 아직 모르는” 매개변수를 받는 것과 비슷합니다. 사실 우리는 이미 6장에서 Option<T>, 8장에서 Vec<T>HashMap<K, V>, 9장에서 Result<T, E> 를 통해 제네릭을 사용해 보았습니다. 이 장에서는 여러분 자신의 타입, 함수, 메서드에 제네릭을 정의하는 방법을 살펴보게 됩니다!

먼저, 코드 중복을 줄이기 위해 함수를 추출하는 방법을 다시 훑어보겠습니다. 그런 다음 매개변수 타입만 다른 두 함수를 같은 기법으로 하나의 제네릭 함수로 바꾸겠습니다. 또한 구조체와 enum 정의 안에서 제네릭 타입을 사용하는 방법도 설명합니다.

그다음에는 트레이트를 사용해 동작을 제네릭한 방식으로 정의하는 방법을 배웁니다. 트레이트와 제네릭 타입을 결합하면, “아무 타입이나” 가 아니라 “특정 동작을 가진 타입만” 받아들이도록 제네릭 타입을 제한할 수 있습니다.

마지막으로 라이프타임(lifetimes) 을 다룹니다. 라이프타임은 제네릭의 한 종류로, 참조들이 서로 어떻게 관계되는지를 컴파일러에게 알려 줍니다. 라이프타임을 사용하면 빌린 값에 대해 컴파일러가 더 많은 정보를 알 수 있게 되어, 우리 도움 없이도 보장할 수 있는 것보다 더 많은 상황에서 참조가 유효함을 보장할 수 있습니다.

함수 추출로 중복 제거하기

제네릭은 여러 타입을 나타내는 자리표시자로 특정 타입을 대체함으로써 코드 중복을 없애게 해 줍니다. 제네릭 문법으로 바로 들어가기 전에, 먼저 제네릭 타입을 사용하지 않고도 어떻게 중복을 제거할 수 있는지 살펴봅시다. 구체적인 값을 여러 값을 나타내는 자리표시자로 바꾸는 함수를 추출하는 방식입니다. 그런 다음 같은 기법을 적용해 제네릭 함수를 추출해 보겠습니다. 함수를 추출할 수 있는 중복 코드를 어떻게 찾아내는지 익히면, 제네릭을 사용할 수 있는 중복 코드도 자연스럽게 보이기 시작할 것입니다.

먼저 목록 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}");
    assert_eq!(*largest, 100);
}
Listing 10-1: 숫자 목록에서 가장 큰 수 찾기

우리는 정수 목록을 number_list 변수에 저장하고, 목록의 첫 번째 숫자에 대한 참조를 largest 라는 변수에 넣습니다. 그런 다음 목록의 모든 숫자를 순회하면서, 현재 숫자가 largest 안에 저장된 숫자보다 크면 그 변수 안의 참조를 새 숫자로 바꿉니다. 반대로 현재 숫자가 지금까지 본 가장 큰 수보다 작거나 같으면 변수는 바뀌지 않고, 코드는 다음 숫자로 넘어갑니다. 목록의 모든 숫자를 검사하고 나면, largest 는 가장 큰 수를 가리키게 되는데, 이 경우 그 값은 100입니다.

이제 우리는 숫자 목록 두 개에서 각각 가장 큰 수를 찾아야 하는 상황을 맞았다고 해 봅시다. 그렇게 하려면 목록 10-1의 코드를 그냥 복사해 프로그램의 두 곳에서 같은 논리를 사용할 수 있습니다. 목록 10-2가 그런 모습입니다.

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: 숫자 목록 두 개 에서 가장 큰 수를 찾는 코드

이 코드는 동작하지만, 코드를 복붙하는 것은 지루하고 실수하기 쉽습니다. 나중에 코드를 수정하고 싶을 때 여러 곳을 함께 바꿔야 한다는 점도 문제입니다.

이 중복을 제거하기 위해, 정수 목록을 매개변수로 받아 그 목록에 대해 동작하는 함수를 정의함으로써 추상화를 만들어 봅시다. 이렇게 하면 코드가 더 명확해지고, “목록에서 가장 큰 수를 찾는다”는 개념도 더 추상적으로 표현할 수 있습니다.

목록 10-3에서는 가장 큰 수를 찾는 코드를 largest 라는 함수로 추출합니다. 그런 다음 목록 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 슬라이스라도 나타냅니다. 따라서 함수를 호출하면, 우리가 실제로 넘긴 구체 값에 대해 그 코드가 실행됩니다.

정리하면, 목록 10-2의 코드를 목록 10-3처럼 바꾸기 위해 다음 단계를 거쳤습니다.

  1. 중복 코드를 찾는다.
  2. 중복 코드를 함수 본문으로 추출하고, 그 코드의 입력과 반환값을 함수 시그니처로 지정한다.
  3. 중복되던 두 코드 위치를 함수 호출로 바꾼다.

이제 같은 단계를 제네릭에도 적용해 코드 중복을 줄여 보겠습니다. 함수 본문이 구체적인 값 대신 추상적인 list 에 대해 동작할 수 있었던 것처럼, 제네릭도 코드가 추상적인 타입에 대해 동작하게 해 줍니다.

예를 들어 i32 슬라이스에서 가장 큰 값을 찾는 함수 하나와, char 슬라이스에서 가장 큰 값을 찾는 함수 하나가 있다고 해 봅시다. 이 중복을 어떻게 제거할 수 있을까요? 이제 알아봅시다!

제네릭 데이터 타입

제네릭 데이터 타입

제네릭은 함수 시그니처나 구조체 같은 항목 정의를 만들 때 사용하며, 이후 다양한 구체 데이터 타입과 함께 쓸 수 있게 해 줍니다. 먼저 함수, 구조체, enum, 메서드를 제네릭으로 정의하는 방법을 살펴보겠습니다. 그런 다음 제네릭이 코드 성능에 어떤 영향을 미치는지도 이야기하겠습니다.

함수 정의에서의 제네릭

제네릭을 사용하는 함수를 정의할 때는, 보통 매개변수와 반환값의 데이터 타입을 적는 자리인 함수 시그니처에 제네릭을 둡니다. 이렇게 하면 코드는 더 유연해지고, 함수 호출자에게 더 많은 기능을 제공하면서도 코드 중복은 막을 수 있습니다.

앞의 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 를 찾습니다. 함수 본문은 완전히 같으니, 하나의 함수에 제네릭 타입 매개변수를 도입하여 중복을 제거합시다.

하나의 새 함수 안에서 타입들을 매개변수화하려면, 함수의 값 매개변수에 이름을 붙일 때와 마찬가지로 타입 매개변수에도 이름을 붙여야 합니다. 타입 매개변수 이름에는 어떤 식별자를 써도 되지만, 관례적으로 T 를 많이 사용합니다. 러스트에서 타입 매개변수 이름은 대개 짧고, 종종 한 글자이며, 러스트 타입 이름 관례인 UpperCamelCase 를 따릅니다. 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 가 언급되어 있는데, 이것은 트레이트이고 바로 다음 절에서 자세히 다룰 것입니다. 지금은 이 오류가, largest 본문이 T 가 될 수 있는 모든 타입에 대해 동작하지 않는다고 말하고 있다는 점만 알면 충분합니다. 함수 본문 안에서 T 타입 값들을 비교하려 하기 때문에, 값들을 순서 비교할 수 있는 타입만 쓸 수 있습니다. 이를 가능하게 하기 위해 표준 라이브러리에는 타입이 구현할 수 있는 std::cmp::PartialOrd 트레이트가 있습니다(이 트레이트에 대한 더 자세한 내용은 부록 C를 참고하세요). 목록 10-5를 고치려면 도움말의 제안처럼, T 에 들어올 수 있는 타입을 PartialOrd 를 구현한 타입으로 제한하면 됩니다. 그러면 표준 라이브러리가 i32char 모두에 PartialOrd 를 구현하고 있으므로 이 목록은 컴파일됩니다.

구조체 정의에서의 제네릭

구조체의 하나 이상의 필드에 제네릭 타입 매개변수를 사용하도록 구조체를 정의할 수도 있습니다. 목록 10-6은 x, y 좌표값을 어떤 타입이든 담을 수 있도록 Point<T> 구조체를 정의한 예입니다.

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: 타입 Tx, y 값을 담는 Point<T> 구조체

구조체 정의에서 제네릭을 사용하는 문법은 함수 정의에서 사용한 것과 비슷합니다. 먼저 구조체 이름 바로 뒤의 꺾쇠 괄호 안에 타입 매개변수 이름을 선언합니다. 그다음 구조체 정의 안에서 원래 구체 타입을 적을 위치에 제네릭 타입을 사용합니다.

여기서 Point<T> 를 정의할 때 제네릭 타입을 하나만 사용했기 때문에, 이 정의는 Point<T> 구조체가 어떤 타입 T 에 대해 제네릭이며, 필드 xy 둘 다 같은 타입이라는 뜻입니다. 목록 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 을 넣으면, yx 와 같은 타입이어야 하므로 다음과 같은 타입 불일치 오류가 발생합니다.

$ 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 구조체를 정의하려면, 여러 개의 제네릭 타입 매개변수를 사용할 수 있습니다. 예를 들어 목록 10-8에서는 Point 정의를 T, U 두 타입에 대해 제네릭하게 바꾸고, xT, yU 타입으로 만듭니다.

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 인스턴스가 허용됩니다! 정의 안에는 필요한 만큼 많은 제네릭 타입 매개변수를 둘 수 있습니다. 다만 몇 개 이상으로 많아지면 코드를 읽기 어려워집니다. 코드 안에 제네릭 타입이 너무 많이 필요하다고 느껴진다면, 코드 구조를 더 작은 조각들로 다시 나눠야 한다는 신호일 수도 있습니다.

enum 정의에서의 제네릭

구조체와 마찬가지로, enum도 variant 안에 제네릭 데이터 타입을 담도록 정의할 수 있습니다. 6장에서 사용했던 표준 라이브러리의 Option<T> enum을 다시 봅시다.

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

이제 이 정의가 좀 더 잘 이해될 것입니다. 보시다시피 Option<T> enum은 타입 T 에 대해 제네릭이고, 두 개의 variant를 가집니다. Some 은 타입 T 값 하나를 담고, None 은 아무 값도 담지 않습니다. Option<T> 를 사용하면 “선택적인 값”이라는 추상 개념을 표현할 수 있고, Option<T> 가 제네릭이기 때문에 그 값의 타입이 무엇이든 같은 추상화를 사용할 수 있습니다.

Enum도 여러 개의 제네릭 타입을 사용할 수 있습니다. 9장에서 사용했던 Result enum이 그 예입니다.

#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Result enum은 두 타입 T, E 에 대해 제네릭이며, OkErr 두 variant를 가집니다. OkT 타입 값 하나를 담고, ErrE 타입 값 하나를 담습니다. 이 정의 덕분에, 어떤 연산이 성공할 수도(T 타입 값 반환), 실패할 수도(E 타입의 에러 반환) 있는 상황 어디에서든 Result enum을 편리하게 사용할 수 있습니다. 실제로 이것이 우리가 목록 9-3에서 파일을 열 때 사용했던 방식입니다. 파일이 성공적으로 열리면 Tstd::fs::File 로 채워지고, 열기에 문제가 생기면 Estd::io::Error 로 채워졌습니다.

여러 구조체나 enum 정의가 내부에 담은 값의 타입만 다르고 나머지는 비슷하다면, 제네릭 타입을 사용해 중복을 피할 수 있습니다.

메서드 정의에서의 제네릭

5장에서 했던 것처럼, 구조체와 enum 위에 메서드를 구현하면서 그 정의 안에서 제네릭 타입을 사용할 수도 있습니다. 목록 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: x 필드의 T 타입 데이터에 대한 참조를 반환하는 x 메서드를 Point<T> 에 구현하기

여기서는 Point<T> 위에 x 라는 메서드를 정의했고, 이 메서드는 필드 x 안의 데이터에 대한 참조를 반환합니다.

여기서도 Timpl 뒤에 다시 선언해야 한다는 점에 주의하세요. 그래야 Point<T> 타입에 메서드를 구현하고 있다는 사실을 T 를 사용해 표현할 수 있습니다. impl 뒤에 T 를 제네릭 타입으로 선언함으로써, 러스트는 Point 의 꺾쇠 괄호 안에 있는 T 가 구체 타입이 아니라 제네릭 타입임을 알 수 있습니다. 물론 구조체 정의에서 사용한 것과 다른 이름을 써도 되지만, 같은 이름을 쓰는 것이 관례입니다. impl 안에 제네릭 타입을 선언한 메서드는, 그 제네릭 타입 자리에 어떤 구체 타입이 들어오든 그 타입의 모든 인스턴스에 대해 정의됩니다.

타입 위에 메서드를 정의할 때 제네릭 타입에 제한을 둘 수도 있습니다. 예를 들어 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 에는 X1, Y1 을, mixup 메서드 시그니처에는 X2, Y2 를 사용합니다. 이 메서드는 selfPoint(타입 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 안에서는 xi325, yf6410.4 를 가진 Point 하나를 정의했습니다. p2 변수는 x 에 문자열 슬라이스 "Hello", y 에 문자 c 를 가진 Point 구조체입니다. p1 에 대해 p2 를 인수로 mixup 을 호출하면 p3 를 얻는데, xp1 에서 왔기 때문에 i32, yp2 에서 왔기 때문에 char 입니다. 그래서 println!p3.x = 5, p3.y = c 를 출력합니다.

이 예제의 목적은, 어떤 제네릭 매개변수는 impl 뒤에 선언되고 어떤 것은 메서드 정의 뒤에 선언된다는 상황을 보여 주는 데 있습니다. 여기서 X1Y1 은 구조체 정의와 함께 가기 때문에 impl 뒤에 선언되고, X2Y2 는 그 메서드에서만 의미가 있기 때문에 fn mixup 뒤에 선언됩니다.

제네릭을 사용하는 코드의 성능

제네릭 타입 매개변수를 사용하면 런타임 비용이 생기는지 궁금할 수도 있습니다. 좋은 소식은, 제네릭 타입을 사용해도 프로그램은 구체 타입을 직접 썼을 때보다 느려지지 않는다는 점입니다.

러스트는 컴파일 시점에 제네릭 코드를 단형화(monomorphization) 하여 이를 해결합니다. 단형화 는 컴파일 시 사용된 구체 타입을 채워 넣어, 제네릭 코드를 구체 코드로 바꾸는 과정입니다. 이 과정에서 컴파일러는 우리가 목록 10-5의 제네릭 함수를 만들기 위해 수행했던 추상화의 반대 작업을 합니다. 즉, 제네릭 코드가 호출되는 모든 위치를 살펴보고, 그 호출에서 사용된 구체 타입에 대한 코드를 생성합니다.

표준 라이브러리의 제네릭 Option<T> enum을 예로 들어 이 동작을 살펴봅시다.

#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}

러스트가 이 코드를 컴파일할 때 단형화를 수행합니다. 그 과정에서 컴파일러는 Option<T> 인스턴스에서 사용된 값을 읽고, 두 종류의 Option<T> 가 쓰였다는 사실을 알아냅니다. 하나는 i32, 다른 하나는 f64 입니다. 그래서 Option<T> 의 제네릭 정의를 i32 용과 f64 용 두 정의로 확장하여, 제네릭 정의를 구체적인 정의로 대체합니다.

단형화된 버전의 코드는 대략 다음과 비슷한 모습이 됩니다(여기서는 설명을 위해 컴파일러가 실제로 쓰는 이름과는 다른 이름을 사용합니다).

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> 는 컴파일러가 만든 구체적인 정의들로 치환됩니다. 러스트는 제네릭 코드를 이렇게 각 인스턴스의 구체 타입을 명시한 코드로 컴파일하므로, 제네릭을 써도 런타임 비용을 치르지 않습니다. 실행 시에는 우리가 각 정의를 손으로 복붙해 쓴 것과 완전히 같은 방식으로 동작합니다. 단형화 과정 덕분에 러스트의 제네릭은 런타임에서 매우 효율적입니다.

트레이트로 공통 동작 정의하기

트레이트로 공통 동작 정의하기

트레이트(trait) 는 특정 타입이 가지는 기능을 정의하며, 그 기능을 다른 타입과 공유할 수 있게 해 줍니다. 트레이트를 사용하면 공통 동작을 추상적인 방식으로 정의할 수 있습니다. 또한 트레이트 바운드(trait bounds) 를 사용하면, 제네릭 타입이 “아무 타입이나” 가 아니라 어떤 특정 동작을 가진 타입만 받아들이도록 제한할 수 있습니다.

Note: 트레이트는 다른 언어에서 흔히 인터페이스(interface) 라고 부르는 기능과 비슷하지만, 몇 가지 차이가 있습니다.

트레이트 정의하기

타입의 동작은 그 타입에 대해 호출할 수 있는 메서드들로 이루어집니다. 서로 다른 여러 타입에서 같은 메서드를 호출할 수 있다면, 그 타입들은 같은 동작을 공유한다고 할 수 있습니다. 트레이트 정의는 메서드 시그니처들을 한데 모아, 어떤 목적을 달성하기 위해 필요한 동작 집합을 정의하는 방법입니다.

예를 들어 서로 다른 종류와 길이의 텍스트를 담는 여러 구조체가 있다고 합시다. 하나는 특정 장소에서 작성된 뉴스 기사를 담는 NewsArticle 구조체이고, 다른 하나는 최대 280자까지의 글과 함께 새 글인지, 재게시인지, 답글인지 같은 메타데이터를 담는 SocialPost 구조체입니다.

우리는 이런 데이터들을 요약해서 보여 줄 수 있는 aggregator 라는 미디어 집계 라이브러리 크레이트를 만들고 싶습니다. 그렇게 하려면 각 타입에서 요약을 얻을 수 있어야 하고, 인스턴스에서 summarize 메서드를 호출해 그 요약을 받아오고 싶습니다. 목록 10-12는 이런 동작을 표현하는 public 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 구조체의 경우에는, 게시물 본문이 이미 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 를 구현했으므로, 크레이트 사용자는 이 타입 인스턴스에서 일반 메서드처럼 트레이트 메서드를 호출할 수 있습니다. 차이가 있다면 사용자 쪽 코드도 타입과 함께 그 트레이트를 스코프로 가져와야 한다는 점입니다. 다음은 바이너리 크레이트가 우리 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 post: {}", post.summarize());
}

이 코드는 1 new post: horse_ebooks: of course, as you probably already know, people 를 출력합니다.

aggregator 크레이트에 의존하는 다른 크레이트도 Summary 트레이트를 스코프로 가져와 자신만의 타입에 Summary 를 구현할 수 있습니다. 한 가지 제한이 있는데, 어떤 타입에 트레이트를 구현하려면 그 트레이트나 타입, 혹은 둘 다가 현재 크레이트 로컬이어야 합니다. 예를 들어 SocialPost 타입은 우리 aggregator 크레이트 안에 정의되어 있으므로, 표준 라이브러리 트레이트인 DisplaySocialPost 에 구현할 수 있습니다. 반대로 Summary 트레이트는 우리 크레이트 안에 정의되었으므로, Vec<T> 같은 표준 라이브러리 타입에 대해서도 Summary 를 구현할 수 있습니다.

하지만 외부 트레이트를 외부 타입에 구현할 수는 없습니다. 예를 들어 DisplayVec<T> 는 둘 다 표준 라이브러리 안에 정의되어 있으므로, 우리 aggregator 크레이트 안에서 Vec<T>Display 를 구현하는 것은 불가능합니다. 이 제약은 일관성(coherence) 이라고 하는 속성의 일부이며, 더 구체적으로는 고아 규칙(orphan rule) 이라고 부릅니다. 부모 타입이 현재 크레이트에 없기 때문에 붙은 이름입니다. 이 규칙 덕분에 다른 사람의 코드가 여러분의 코드를 망가뜨리거나, 그 반대가 되는 일을 막을 수 있습니다. 이 규칙이 없다면 두 크레이트가 같은 타입에 같은 트레이트를 구현할 수 있고, 그때 러스트는 어떤 구현을 사용해야 할지 알 수 없게 됩니다.

기본 구현 사용하기

때로는 트레이트의 모든 메서드에 대해 각 타입이 반드시 구현을 제공하게 하는 대신, 일부 또는 전부에 기본 동작을 제공하는 편이 유용합니다. 그러면 특정 타입에 트레이트를 구현할 때, 각 메서드의 기본 동작을 그대로 쓰거나 필요에 따라 덮어쓸 수 있습니다.

목록 10-14에서는, 목록 10-12처럼 summarize 메서드 시그니처만 두는 대신, Summary 트레이트의 summarize 메서드에 기본 문자열 구현을 제공합니다.

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 트레이트에 구현이 필수인 summarize_author 메서드를 두고, 그 메서드를 호출하는 기본 구현을 가진 summarize 메서드를 다음처럼 정의할 수 있습니다.

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 를 호출할 수 있습니다. Summary 트레이트의 기본 구현이 우리가 제공한 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 post: {}", post.summarize());
}

이 코드는 1 new post: (Read more from @horse_ebooks...) 를 출력합니다.

한 가지 주의할 점은, 같은 메서드를 덮어쓴 구현 안에서 그 메서드의 기본 구현을 직접 호출하는 것은 불가능하다는 것입니다.

트레이트를 매개변수로 사용하기

이제 트레이트를 정의하고 구현하는 법을 알게 되었으니, 트레이트를 사용해 여러 타입을 받는 함수를 어떻게 정의하는지 살펴봅시다. 목록 10-13에서 NewsArticleSocialPost 위에 구현한 Summary 트레이트를 이용해, item 매개변수에 대해 summarize 메서드를 호출하는 notify 함수를 정의해 보겠습니다. 이를 위해 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 본문 안에서는 Summary 트레이트에서 온 메서드들, 예를 들어 summarizeitem 에 대해 호출할 수 있습니다. 따라서 notifyNewsArticle 이나 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) {

이 방식은 item1item2 가 서로 다른 타입이어도 둘 다 Summary 만 구현하고 있으면 허용하고 싶을 때 적절합니다. 하지만 두 매개변수가 같은 타입이길 강제하고 싶다면, 다음처럼 트레이트 바운드를 사용해야 합니다.

pub fn notify<T: Summary>(item1: &T, item2: &T) {

item1item2 의 타입으로 지정된 제네릭 타입 T 가 하나뿐이라는 사실이, 두 인수에 들어오는 실제 타입이 반드시 같아야 한다는 제약을 만들어 냅니다.

+ 문법으로 여러 트레이트 바운드 지정하기

하나 이상의 트레이트 바운드도 지정할 수 있습니다. 예를 들어 notifyitem 에 대해 summarize 를 호출하는 동시에 출력 형식화도 하고 싶다고 해 봅시다. 그러면 itemDisplaySummary 를 둘 다 구현해야 한다고 notify 정의에 적어 주면 됩니다. 이때 + 문법을 사용합니다.

pub fn notify(item: &(impl Summary + Display)) {

+ 문법은 제네릭 타입에 대한 트레이트 바운드와 함께 쓸 때도 유효합니다.

pub fn notify<T: Summary + Display>(item: &T) {

이 두 바운드가 지정되면, notify 본문 안에서는 summarize 를 호출할 수 있고, {} 형식화를 사용해 item 을 출력할 수도 있습니다.

where 절로 트레이트 바운드 더 명확하게 쓰기

트레이트 바운드가 많아지면 단점도 생깁니다. 각 제네릭마다 자기 바운드가 있고, 여러 제네릭 타입 매개변수를 가진 함수는 함수 이름과 매개변수 목록 사이에 바운드 정보가 잔뜩 들어가서 시그니처를 읽기 어려워질 수 있습니다. 이를 위해 러스트는 함수 시그니처 뒤의 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 는 오직 하나의 구체 타입만 반환할 때만 사용할 수 있습니다. 예를 들어 반환 타입을 impl Summary 로 지정해 놓고, 상황에 따라 NewsArticle 이나 SocialPost 를 반환하는 코드는 동작하지 않습니다.

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> 타입은 항상 새 Pair<T> 인스턴스를 반환하는 new 함수를 구현합니다 (5장의 “메서드 문법” 절에서 보았듯, Selfimpl 블록의 타입, 여기서는 Pair<T> 의 별칭입니다). 하지만 다음 impl 블록에서 Pair<T> 는 내부 타입 T 가 비교를 가능하게 하는 PartialOrd 와 출력을 가능하게 하는 Display 를 모두 구현한 경우에만 cmp_display 메서드를 구현합니다.

Filename: 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);
        }
    }
}
Listing 10-15: 트레이트 바운드에 따라 제네릭 타입에 조건부로 메서드 구현하기

또한 어떤 다른 트레이트를 구현하는 모든 타입에 대해, 특정 트레이트를 조건부로 구현할 수도 있습니다. 트레이트 바운드를 만족하는 모든 타입에 대한 이런 구현을 블랭킷 구현(blanket implementation) 이라고 하며, 러스트 표준 라이브러리에서 광범위하게 사용됩니다. 예를 들어 표준 라이브러리는 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” 섹션에서 확인할 수 있습니다.

트레이트와 트레이트 바운드를 사용하면, 제네릭 타입 매개변수를 활용해 중복을 줄이면서도 그 제네릭 타입이 특정 동작을 가져야 한다고 컴파일러에게 명시할 수 있습니다. 그러면 컴파일러는 이 트레이트 바운드 정보를 사용해, 실제로 우리 코드와 함께 쓰이는 모든 구체 타입이 올바른 동작을 제공하는지 검사할 수 있습니다. 동적 타입 언어에서는, 어떤 메서드를 구현하지 않은 타입에 대해 그 메서드를 호출하면 런타임에서야 오류를 보게 됩니다. 반면 러스트는 그런 오류를 컴파일 시점으로 끌어올려, 코드가 실행되기도 전에 반드시 고치게 만듭니다. 게다가 런타임에 동작 여부를 검사하는 코드를 우리가 직접 작성할 필요도 없습니다. 컴파일 시점에 이미 검사가 끝났기 때문입니다. 이런 방식은 제네릭의 유연성을 유지하면서도 성능을 떨어뜨리지 않습니다.

라이프타임으로 참조의 유효성 검증하기

라이프타임으로 참조의 유효성 검증하기

라이프타임은 우리가 이미 써 오고 있던 또 다른 종류의 제네릭입니다. 타입이 원하는 동작을 갖는지 보장하는 대신, 라이프타임은 참조가 필요한 만큼 오래 유효한지를 보장합니다.

4장의 “참조와 대여” 절에서는 설명하지 않았던 세부가 하나 있는데, 러스트의 모든 참조는 저마다의 라이프타임을 가진다는 점입니다. 라이프타임은 그 참조가 유효한 스코프를 뜻합니다. 대부분의 경우 라이프타임은 암묵적이고 추론됩니다. 타입이 대개 추론되는 것과 비슷합니다. 여러 타입이 가능할 때만 우리가 타입을 주석으로 적어 주어야 합니다. 마찬가지로 참조의 라이프타임이 여러 방식으로 관련될 수 있는 경우에만 라이프타임을 주석으로 명시해야 합니다. 러스트는 런타임에 실제로 사용되는 참조들이 확실히 유효하도록, 제네릭 라이프타임 매개변수로 참조들 사이 관계를 적어 주길 요구합니다.

라이프타임 주석은 대부분의 다른 프로그래밍 언어에는 없는 개념이므로 낯설게 느껴질 수 있습니다. 이 장에서 라이프타임 전체를 완전히 다루지는 않겠지만, 라이프타임 문법을 만날 수 있는 흔한 상황들을 설명해 개념에 익숙해지게 하겠습니다.

댕글링 참조

라이프타임의 주된 목적은 댕글링 참조를 막는 것입니다. 만약 허용된다면, 프로그램은 원래 의도한 데이터가 아닌 다른 데이터를 참조하게 될 수도 있습니다. 목록 10-16의 프로그램은 바깥 스코프와 안쪽 스코프를 함께 가지고 있습니다.

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}
Listing 10-16: 이미 스코프를 벗어난 값을 참조하는 참조를 사용하려는 시도

Note: 목록 10-16, 10-17, 10-23의 예제에서는 변수에 초기값을 주지 않고 선언합니다. 그래서 변수 이름은 바깥 스코프 안에 존재합니다. 언뜻 보면 이는 러스트에 null 값이 없다는 사실과 충돌하는 것처럼 보일 수도 있습니다. 하지만 값을 주기 전에 그 변수를 사용하려고 하면 컴파일 시점 오류가 발생합니다. 즉, 러스트는 실제로 null 값을 허용하지 않는다는 점을 보여 줍니다.

바깥 스코프에는 초기값이 없는 r 이라는 변수가 선언되어 있고, 안쪽 스코프에는 초기값 5 를 가진 x 가 선언되어 있습니다. 안쪽 스코프에서 우리는 r 의 값을 x 에 대한 참조로 설정하려고 합니다. 그 뒤 안쪽 스코프는 끝나고, 바깥 스코프에서 r 의 값을 출력하려 합니다. 이 코드는 컴파일되지 않습니다. 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 이 “더 오래 산다”고 말합니다. 만약 러스트가 이 코드를 허용했다면, rx 가 스코프를 벗어날 때 이미 해제된 메모리를 가리키게 되었을 것이고, 그 뒤 r 에 대해 무엇을 하든 올바르게 동작하지 않았을 것입니다. 그렇다면 러스트는 어떻게 이 코드가 잘못되었다는 것을 알까요? 대여 검사기를 사용합니다.

대여 검사기

러스트 컴파일러에는 모든 대여가 유효한지 확인하기 위해 스코프를 비교하는 대여 검사기(borrow checker) 가 있습니다. 목록 10-17은 목록 10-16과 같은 코드에, 변수 라이프타임을 주석으로 표시한 버전입니다.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+
Listing 10-17: 각각 'a, 'b 로 이름 붙인 rx 의 라이프타임 주석

여기서는 r 의 라이프타임을 'a, x 의 라이프타임을 'b 라고 표시했습니다. 보시다시피 안쪽 'b 블록은 바깥쪽 'a 라이프타임 블록보다 훨씬 작습니다. 컴파일 시점에 러스트는 두 라이프타임 크기를 비교하고, 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 를 참조해도 문제가 없습니다. 러스트는 x 가 유효한 동안 r 안의 참조도 항상 유효하다는 사실을 알고 있습니다.

이제 참조의 라이프타임이 무엇이고, 러스트가 라이프타임을 분석해 참조가 언제나 유효하도록 어떻게 보장하는지 알게 되었으니, 함수 매개변수와 반환값에서 제네릭 라이프타임을 어떻게 사용하는지 살펴보겠습니다.

함수에서의 제네릭 라이프타임

문자열 슬라이스 두 개 중 더 긴 쪽을 반환하는 함수를 작성해 보겠습니다. 이 함수는 두 개의 문자열 슬라이스를 받아 하나의 문자열 슬라이스를 반환할 것입니다. 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 가 인수의 소유권을 가져가길 원하지 않기 때문입니다. 왜 이런 매개변수를 원하는지에 대해서는 4장의 [“문자열 슬라이스를 매개변수로 받기”] string-slices-as-parameters 절을 떠올려 보세요.

만약 목록 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

도움말은 반환 타입에 제네릭 라이프타임 매개변수가 필요하다고 알려 줍니다. 러스트는 반환되는 참조가 x 를 가리키는지, 아니면 y 를 가리키는지 알 수 없기 때문입니다. 사실 우리도 함수 정의만 보고는 알 수 없습니다. 이 함수 본문에서 if 블록은 x 를, else 블록은 y 를 반환하기 때문입니다.

이 함수를 정의하는 시점에서는 실제로 어떤 값이 이 함수에 들어올지 모르므로, if 분기가 실행될지 else 분기가 실행될지도 알 수 없습니다. 또한 들어올 참조들의 구체적인 라이프타임도 모르기 때문에, 목록 10-17과 10-18에서처럼 단순히 스코프를 보고 반환될 참조가 언제나 유효한지 판단할 수 없습니다. 대여 검사기도 마찬가지로 이를 알 수 없습니다. x, y 의 라이프타임이 반환값 라이프타임과 어떤 관계인지 모르기 때문입니다. 이 오류를 고치기 위해, 참조들 사이 관계를 정의하는 제네릭 라이프타임 매개변수를 추가해 대여 검사기가 분석할 수 있게 해야 합니다.

라이프타임 주석 문법

라이프타임 주석은 참조가 실제로 얼마나 오래 사는지를 바꾸지 않습니다. 대신 여러 참조의 라이프타임이 서로 어떤 관계를 갖는지를 설명할 뿐입니다. 이는 라이프타임 자체에는 영향을 주지 않습니다. 함수 시그니처가 제네릭 타입 매개변수 덕분에 어떤 타입이든 받을 수 있는 것처럼, 제네릭 라이프타임 매개변수를 지정하면 어떤 라이프타임을 가진 참조든 받아들일 수 있습니다.

라이프타임 주석은 약간 특이한 문법을 가집니다. 라이프타임 매개변수 이름은 작은따옴표 (') 로 시작해야 하고, 보통 모두 소문자이며 매우 짧습니다. 제네릭 타입 이름처럼, 대부분의 사람은 첫 번째 라이프타임 주석 이름으로 'a 를 사용합니다. 라이프타임 매개변수 주석은 참조의 & 뒤에 두고, 참조 타입과는 공백으로 구분합니다.

다음은 예시입니다. 라이프타임 매개변수가 없는 i32 참조, 'a 라는 라이프타임을 가진 i32 참조, 그리고 같은 'a 라이프타임을 가진 가변 i32 참조입니다.

&i32        // a reference
&'a i32     // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime

라이프타임 주석 하나만 떼어 놓고 보면 큰 의미는 없습니다. 주석의 목적은 여러 참조의 제네릭 라이프타임 매개변수들 사이 관계를 러스트에게 알려 주는 데 있기 때문입니다. 이제 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 함수와 함께 사용하면 정상적으로 컴파일되고, 원하는 결과를 출력합니다.

이제 함수 시그니처는 러스트에게 “어떤 라이프타임 'a 에 대해, 함수는 그 라이프타임 이상 살아 있는 문자열 슬라이스 두 개를 받고, 반환값 역시 최소한 'a 동안 살아 있는 문자열 슬라이스다”라고 말하고 있습니다. 실제로는, longest 함수가 반환하는 참조의 라이프타임은 함수 인수들이 가리키는 두 값의 라이프타임 중 더 짧은 쪽과 같다는 뜻입니다. 이것이 바로 우리가 러스트가 이 코드를 분석할 때 사용하길 원하는 관계입니다.

기억해야 할 점은, 우리가 이 함수 시그니처에 라이프타임 매개변수를 적을 때 실제로 들어오고 나가는 값들의 라이프타임을 바꾸는 것이 아니라는 것입니다. 단지 대여 검사기가 이 제약을 지키지 않는 값을 거부할 수 있게 규칙을 명시하는 것뿐입니다. 또한 longest 함수는 xy 가 정확히 얼마 동안 살아 있을지를 알 필요도 없습니다. 단지 이 시그니처를 만족시키는 어떤 스코프가 'a 로 대입될 수 있기만 하면 됩니다.

함수에 라이프타임을 주석으로 적을 때는 함수 본문이 아니라 시그니처에 적습니다. 라이프타임 주석은 시그니처의 타입처럼 함수의 계약 일부가 됩니다. 함수 시그니처가 이런 라이프타임 계약을 담고 있으면, 러스트 컴파일러의 분석도 더 단순해질 수 있습니다. 함수 주석 방식이나 호출 방식에 문제가 있으면, 컴파일러는 코드의 어느 부분과 어떤 제약이 문제인지 더 정확하게 가리킬 수 있습니다. 만약 러스트 컴파일러가 우리가 의도한 라이프타임 관계를 더 많이 추측해야 했다면, 오류가 생겼을 때 원인에서 훨씬 떨어진 다른 지점을 겨우 지목할 수도 있었을 것입니다.

구체적인 참조를 longest 에 넘길 때, 'a 에 실제로 대입되는 구체 라이프타임은 xy 의 스코프가 겹치는 부분입니다. 다시 말해 제네릭 라이프타임 '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 는 안쪽 스코프 끝까지 유효한 무언가를 참조합니다. 이 코드를 실행하면 대여 검사기는 이를 허용하고, 프로그램은 컴파일되어 The longest string is long string is long 을 출력합니다.

이제 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

이 오류는 println! 문에서 result 가 유효하려면, string2 역시 바깥 스코프 끝까지 유효해야 한다고 알려 줍니다. 러스트는 함수 매개변수와 반환값 라이프타임을 같은 라이프타임 매개변수 'a 로 주석 처리했기 때문에 이 사실을 알고 있습니다.

사람 눈으로 보면, 이 경우 string1string2 보다 길고 따라서 result 가 항상 string1 을 참조할 것이라는 점을 알 수 있습니다. string1 은 아직 스코프를 벗어나지 않았으므로 string1 에 대한 참조는 println! 에서도 유효합니다. 하지만 컴파일러는 이 경우 참조가 유효하다는 사실을 볼 수 없습니다. 우리는 longest 함수가 반환하는 참조의 라이프타임이 들어온 참조들 중 더 짧은 쪽과 같다고 러스트에게 알려 주었기 때문입니다. 그래서 대여 검사기는 목록 10-23의 코드를 무효한 참조 가능성이 있는 것으로 보고 거부합니다.

이제 longest 함수에 넘기는 참조 값과 라이프타임, 그리고 반환 참조 사용 위치를 직접 바꿔 가며 더 많은 실험을 해 보세요. 컴파일하기 전에 대여 검사기를 통과할지 못할지 먼저 가설을 세운 뒤, 실제 결과를 확인해 보면 많은 도움이 됩니다.

관계

어떤 방식으로 라이프타임 매개변수를 적어야 하는지는 함수가 실제로 무엇을 하는지에 달려 있습니다. 예를 들어 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 나 반환값의 라이프타임과 아무 관계가 없기 때문입니다.

함수에서 참조를 반환할 때는, 반환 타입의 라이프타임 매개변수가 반드시 어떤 매개변수의 라이프타임 매개변수와 연결되어 있어야 합니다. 반환되는 참조가 매개변수 중 하나를 가리키지 않는다면, 함수 안에서 만들어진 값을 가리켜야 합니다. 하지만 이 경우 그 값은 함수 끝에서 스코프를 벗어나므로 댕글링 참조가 됩니다. 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 에 대한 참조를 반환하려고 했습니다. 라이프타임 매개변수를 어떻게 적더라도 이 댕글링 참조 문제 자체를 바꿀 수는 없습니다. 러스트는 댕글링 참조를 허용하지 않기 때문입니다. 이런 경우 가장 좋은 해결책은 참조 대신 소유권을 가진 데이터 타입을 반환하여, 그 값을 정리하는 책임을 호출하는 함수 쪽으로 옮기는 것입니다.

결국 라이프타임 문법의 핵심은 함수의 여러 매개변수와 반환값 라이프타임을 서로 연결하는 데 있습니다. 일단 이런 연결이 명시되면, 러스트는 메모리 안전한 연산은 허용하고, 댕글링 포인터를 만들거나 메모리 안전성을 깨뜨릴 연산은 막을 수 있는 충분한 정보를 얻게 됩니다.

구조체 정의에서의 라이프타임

지금까지 우리가 정의한 구조체는 모두 소유 타입만 담고 있었습니다. 구조체 안에 참조를 담도록 정의할 수도 있지만, 그 경우 구조체 정의 안의 모든 참조에 라이프타임 주석을 추가해야 합니다. 목록 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 인스턴스가 만들어지기 전에 이미 존재하며, ImportantExcerpt 가 스코프를 벗어난 뒤에야 novel 도 스코프를 벗어나기 때문에, 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의 함수

이 함수가 라이프타임 주석 없이 컴파일되는 이유는 역사적인 배경이 있습니다. 초기 버전의 러스트(1.0 이전)에서는 이런 코드가 컴파일되지 않았습니다. 그 당시에는 모든 참조에 대해 라이프타임을 명시해야 했습니다. 당시 이 함수 시그니처는 다음처럼 적었을 것입니다.

fn first_word<'a>(s: &'a str) -> &'a str {

러스트 코드를 많이 쓰다 보니, 러스트 팀은 프로그래머들이 특정 상황에서 같은 라이프타임 주석을 반복해서 계속 적고 있다는 사실을 발견했습니다. 이 상황들은 예측 가능했고, 몇 가지 결정적인 패턴을 따르고 있었습니다. 그래서 개발자들은 그런 패턴을 컴파일러에 직접 집어넣어, 대여 검사기가 이런 경우에는 라이프타임을 추론하게 하고 더 이상 명시적 주석이 필요 없게 만들었습니다.

이 역사 이야기가 중요한 이유는, 앞으로도 이런 결정 가능한 패턴이 더 발견되면 컴파일러에 추가될 수 있기 때문입니다. 미래에는 라이프타임 주석이 지금보다 훨씬 덜 필요해질 수도 있습니다.

러스트의 참조 분석 안에 내장된 이런 패턴들을 라이프타임 생략 규칙(lifetime elision rules) 이라고 합니다. 이것은 프로그래머가 따라야 할 규칙이 아니라, 컴파일러가 확인하는 특정 케이스들의 집합입니다. 코드가 이 케이스에 맞으면 라이프타임을 직접 쓰지 않아도 됩니다.

하지만 생략 규칙이 완전한 추론을 제공하는 것은 아닙니다. 러스트가 규칙을 모두 적용한 뒤에도 참조 라이프타임에 대한 모호함이 남아 있으면, 컴파일러는 남은 참조의 라이프타임을 추측하지 않습니다. 대신 라이프타임 주석을 추가해서 해결하라는 오류를 냅니다.

함수나 메서드 매개변수에 붙는 라이프타임은 입력 라이프타임(input lifetimes) 이라고 부르고, 반환값에 붙는 라이프타임은 출력 라이프타임(output lifetimes) 이라고 부릅니다.

컴파일러는 명시적 주석이 없을 때 참조 라이프타임을 알아내기 위해 세 가지 규칙을 사용합니다. 첫 번째 규칙은 입력 라이프타임에 적용되고, 두 번째와 세 번째는 출력 라이프타임에 적용됩니다. 세 규칙을 모두 적용한 뒤에도 여전히 라이프타임을 알 수 없는 참조가 남아 있다면, 컴파일러는 오류를 냅니다. 이 규칙들은 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을 컴파일하려 할 때 오류가 났던 것입니다. 컴파일러는 생략 규칙을 다 적용해 보았지만, 시그니처 안 모든 참조의 라이프타임을 알아내지 못했습니다.

세 번째 규칙은 사실 메서드 시그니처에서 특히 중요하게 작용합니다. 그래서 다음에는 메서드 맥락에서 라이프타임을 살펴보며, 왜 세 번째 규칙 덕분에 메서드 시그니처에는 라이프타임 주석을 자주 적지 않아도 되는지 보겠습니다.

메서드 정의에서의 라이프타임

라이프타임을 가진 구조체에 메서드를 구현할 때는, 목록 10-11의 제네릭 타입 매개변수 문법과 같은 방식을 사용합니다. 라이프타임 매개변수를 어디에 선언하고 어디서 사용해야 하는지는, 그것이 구조체 필드와 관련되는지, 아니면 메서드의 매개변수와 반환값과 관련되는지에 따라 달라집니다.

구조체 필드에 대한 라이프타임 이름은 언제나 impl 키워드 뒤에 선언하고, 구조체 이름 뒤에도 다시 사용해야 합니다. 그 라이프타임이 구조체 타입의 일부이기 때문입니다.

impl 블록 안의 메서드 시그니처에서는, 참조가 구조체 필드 참조의 라이프타임과 연결될 수도 있고 아닐 수도 있습니다. 게다가 라이프타임 생략 규칙 덕분에 메서드 시그니처에서는 라이프타임 주석이 필요 없는 경우가 많습니다. 목록 10-24에서 정의한 ImportantExcerpt 구조체를 예로 들어 봅시다.

먼저 self 에 대한 참조만을 유일한 매개변수로 받고, 참조가 아닌 i32 를 반환하는 level 메서드를 보겠습니다.

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,
    };
}

입력 라이프타임이 두 개 있기 때문에, 러스트는 먼저 첫 번째 생략 규칙을 적용해 &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 를 읽는 것이 좋습니다. 하지만 그 전에, 러스트에서 테스트를 작성해 코드가 기대한 대로 동작하는지 확인하는 방법을 배우겠습니다.

자동화 테스트 작성하기

Edsger W. Dijkstra는 1972년 에세이 “The Humble Programmer” 에서 “프로그램 테스트는 버그의 존재 를 보여 주는 데는 매우 효과적일 수 있지만, 버그의 부재 를 보여 주기에는 절망적으로 불충분하다”고 말했습니다. 그렇다고 가능한 한 많이 테스트하려는 시도를 포기해야 한다는 뜻은 아닙니다!

프로그램의 정확성(correctness) 이란, 우리의 코드가 우리가 의도한 일을 실제로 수행하는 정도를 뜻합니다. 러스트는 프로그램의 정확성에 큰 비중을 두고 설계되었지만, 정확성은 복잡하고 증명하기 쉽지 않습니다. 러스트의 타입 시스템이 이 부담의 큰 부분을 떠안고 있지만, 타입 시스템이 모든 것을 잡아낼 수는 없습니다. 그래서 러스트는 자동화된 소프트웨어 테스트를 작성하기 위한 지원도 포함합니다.

예를 들어 add_two 라는 함수를 작성해, 인수로 받은 숫자에 2를 더한다고 합시다. 이 함수의 시그니처는 정수를 매개변수로 받고 정수를 반환합니다. 우리가 그 함수를 구현하고 컴파일할 때, 러스트는 지금까지 배운 타입 검사와 대여 검사를 전부 수행해 String 값이나 잘못된 참조가 이 함수에 넘어가지 않도록 막아 줍니다. 하지만 러스트는 이 함수가 “정확히 우리가 의도한 대로”, 즉 “매개변수에 2를 더한 값을 반환하는지”까지는 검사할 수 없습니다. 예를 들어 10을 더하거나 50을 빼는 구현이어도 타입상으로는 문제가 없기 때문입니다. 바로 이런 지점에서 테스트가 필요합니다.

예를 들어 add_two 함수에 3 을 넘겼을 때 반환값이 5 여야 한다고 테스트를 작성할 수 있습니다. 그리고 코드를 수정할 때마다 테스트를 실행해, 기존의 올바른 동작이 바뀌지 않았는지 확인할 수 있습니다.

테스트는 복잡한 기술입니다. 한 장만으로 좋은 테스트 작성법의 모든 세부를 다룰 수는 없지만, 이 장에서는 러스트 테스트 도구들의 작동 방식을 설명합니다. 테스트를 쓸 때 사용할 수 있는 주석과 매크로, 테스트 실행 시의 기본 동작과 각종 옵션, 그리고 테스트를 단위 테스트와 통합 테스트로 나누어 구성하는 방법을 이야기합니다.

테스트 작성 방법

테스트 작성 방법

테스트(tests) 는 테스트 대상이 아닌 코드가 기대한 방식으로 동작하는지 검증하는 러스트 함수입니다. 테스트 함수 본문은 보통 다음 세 가지 일을 수행합니다.

  • 필요한 데이터나 상태를 준비한다.
  • 테스트하려는 코드를 실행한다.
  • 결과가 기대한 것과 같은지 단언(assert)한다.

이제 이런 작업을 위해 러스트가 특별히 제공하는 기능들을 살펴봅시다. test 속성, 몇 가지 매크로, 그리고 should_panic 속성이 여기에 포함됩니다.

테스트 함수의 구조

가장 단순한 형태에서, 러스트의 테스트는 test 속성으로 주석이 붙은 함수입니다. 속성(attribute)은 러스트 코드 조각에 대한 메타데이터입니다. 한 가지 예로 5장에서 구조체와 함께 사용했던 derive 속성이 있습니다. 함수를 테스트 함수로 바꾸려면, fn 앞 줄에 #[test] 를 추가하면 됩니다. cargo test 명령으로 테스트를 실행하면, 러스트는 이런 주석이 붙은 함수를 실행하는 테스트 러너 바이너리를 빌드하고, 각 테스트 함수가 통과했는지 실패했는지를 보고합니다.

Cargo로 새 라이브러리 프로젝트를 만들면, 테스트 함수 하나를 포함한 테스트 모듈이 자동으로 생성됩니다. 이 모듈은 새 프로젝트를 시작할 때마다 테스트의 정확한 구조와 문법을 외울 필요가 없도록 기본 템플릿을 제공합니다. 물론 여기에 원하는 만큼의 추가 테스트 함수와 테스트 모듈을 넣을 수 있습니다!

먼저 실제 코드를 테스트하기 전에, 이 자동 생성된 템플릿 테스트를 가지고 러스트 테스트가 어떻게 동작하는지 몇 가지 측면을 살펴보겠습니다. 그런 다음 우리가 직접 작성한 코드를 호출하고 그 동작이 올바른지 단언하는 현실적인 테스트를 작성해 보겠습니다.

두 숫자를 더하는 adder 라는 새 라이브러리 프로젝트를 만들어 봅시다.

$ cargo new adder --lib
     Created library `adder` project
$ cd adder

adder 라이브러리의 src/lib.rs 파일 내용은 목록 11-1과 비슷할 것입니다.

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] 주석에 주목하세요. 이 속성은 이 함수가 테스트 함수라는 뜻이므로, 테스트 러너가 이 함수를 테스트로 취급합니다. 또한 공통 시나리오를 준비하거나 공통 작업을 수행하기 위한 비테스트 함수도 tests 모듈 안에 둘 수 있으므로, 어떤 함수가 테스트인지 항상 명시해 주어야 합니다.

예제 함수 본문은 assert_eq! 매크로를 사용해, add(2, 2) 의 결과를 담은 result4 와 같다고 단언합니다. 이것은 전형적인 테스트 형식을 보여 주는 예입니다. 직접 실행해 이 테스트가 통과하는지 봅시다.

cargo test 명령은 프로젝트 안의 모든 테스트를 실행합니다. 목록 11-2를 보세요.

$ 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) 처리해 특정 실행에서는 돌지 않게 만들 수도 있는데, 이는 이 장 뒤의 [“특별히 요청한 경우가 아니면 테스트 무시하기”][ignoring] 절에서 다룹니다. 여기서는 그런 처리를 하지 않았으므로 요약에 0 ignored 가 표시됩니다. 또한 cargo test 에 인수를 주어 이름이 특정 문자열과 일치하는 테스트만 실행할 수도 있는데, 이를 필터링(filtering) 이라고 하며 [“이름으로 테스트 일부만 실행하기”][subset] 절에서 다룹니다. 지금은 필터링하지 않았기 때문에 요약 끝에 0 filtered out 이라고 나옵니다.

0 measured 라는 통계는 성능을 측정하는 벤치마크 테스트를 뜻합니다. 이 글을 쓰는 시점에서 벤치마크 테스트는 nightly Rust 에서만 가능합니다. 자세한 내용은 [벤치마크 테스트 문서][bench]를 참고하세요.

테스트 출력에서 Doc-tests adder 부터 시작하는 다음 부분은 문서 테스트 결과입니다. 지금은 문서 테스트가 없지만, 러스트는 API 문서 안에 있는 코드 예제도 컴파일할 수 있습니다. 이 기능은 문서와 코드가 계속 맞물려 있도록 유지하는 데 도움이 됩니다! 문서 테스트 작성법은 14장의 [“문서 주석을 테스트로 사용하기”][doc-comments] 절에서 설명합니다. 지금은 Doc-tests 출력은 무시해도 됩니다.

이제 이 테스트를 우리 필요에 맞게 조금 바꿔 봅시다. 먼저 it_works 함수 이름을 예를 들어 exploration 같은 다른 이름으로 바꿉니다.

파일명: 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

이제 테스트 하나를 더 추가해 보겠습니다. 이번에는 실패하는 테스트를 만들 것입니다! 테스트 함수 안에서 무언가가 패닉하면 테스트는 실패합니다. 각 테스트는 새 스레드에서 실행되며, 메인 스레드는 테스트 스레드가 죽었다는 사실을 감지하면 그 테스트를 실패로 표시합니다. 9장에서 가장 단순하게 패닉하는 방법은 panic! 매크로를 호출하는 것이라고 배웠습니다. another 라는 이름의 새 테스트 함수를 추가해 src/lib.rs 를 목록 11-3처럼 바꿔 보세요.

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 로 테스트를 실행해 봅시다. 출력은 목록 11-4와 비슷할 것입니다. 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 가 표시된다는 점에 주목하세요. 개별 결과와 최종 요약 사이에는 두 개의 새 섹션이 생깁니다. 첫 번째는 각 테스트 실패 이유를 자세히 보여 줍니다. 이 경우 tests::anothersrc/lib.rs 17번째 줄에서 Make this test fail 메시지와 함께 패닉했기 때문에 실패했다는 정보를 얻습니다. 그 다음 섹션은 실패한 테스트 이름만 나열해 주는데, 테스트와 실패 출력이 많을 때 유용합니다. 실패한 테스트 이름을 이용하면 그 테스트만 따로 실행해 더 쉽게 디버깅할 수 있습니다. 테스트 실행 방법은 [“테스트 실행 방식 제어하기”] [controlling-how-tests-are-run] 절에서 더 이야기합니다.

끝 줄의 요약은 전체적으로 테스트 결과가 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에서는 너비 8, 높이 7인 Rectangle 인스턴스를 만들고, 그것이 너비 5, 높이 1인 다른 Rectangle 을 실제로 담을 수 있는지 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
    }
}

#[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장의 [“모듈 트리의 항목을 경로로 가리키기”][paths-for-referring-to-an-item-in-the-module-tree] 절에서 설명한 일반 공개 범위 규칙을 그대로 따릅니다. tests 는 내부 모듈이기 때문에, 테스트 대상이 되는 바깥 모듈의 코드를 그 안으로 가져와야 합니다. 여기서는 글롭을 사용해, 바깥 모듈 안의 모든 항목이 tests 모듈 안에서 보이도록 했습니다.

우리는 이 테스트 이름을 larger_can_hold_smaller 로 지었고, 필요한 두 Rectangle 인스턴스를 만들었습니다. 그런 다음 larger.can_hold(&smaller) 호출 결과를 assert! 에 전달했습니다. 이 식은 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

정상적으로 통과합니다! 이번에는 더 작은 직사각형이 더 큰 직사각형을 담을 수는 없다는 사실을 확인하는 테스트를 하나 더 추가해 봅시다.

파일명: 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: u64) -> u64 {
    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

우리는 add_two(2) 호출 결과를 result 라는 변수에 저장한 다음, result4assert_eq! 에 넘깁니다. 이 테스트의 출력 줄은 test tests::it_adds_two ... ok 형태이며, ok 는 테스트가 통과했다는 뜻입니다.

이번에는 버그를 넣어 assert_eq! 가 실패할 때 어떻게 보이는지 확인해 봅시다. add_two 구현이 2 대신 3을 더하도록 바꿉니다.

pub fn add_two(a: u64) -> u64 {
    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`

테스트가 버그를 잡아냈습니다! tests::it_adds_two 테스트가 실패했고, 메시지는 실패한 단언이 left == right 였는지, 그리고 각각의 left, right 값이 무엇이었는지를 알려 줍니다. leftadd_two(2) 호출 결과인 5 였고, right4 였습니다. 테스트가 많아지면 이런 정보가 특히 유용합니다.

일부 언어와 테스트 프레임워크에서는 동등성 단언 함수의 매개변수를 expectedactual 로 부르고, 인수 순서가 중요하기도 합니다. 하지만 러스트에서는 left, right 라고 부르며, 기대값과 실제값을 어느 순서로 적든 상관없습니다. 예를 들어 이 테스트에서 assert_eq!(4, result) 라고 써도, assertion `left == right` failed 를 포함한 같은 실패 메시지가 나옵니다.

assert_ne! 매크로는 두 값이 같지 않으면 통과하고, 같으면 실패합니다. 이 매크로는 값이 무엇일지는 모르지만 적어도 무엇이 아니어야 하는지 는 아는 경우에 특히 유용합니다. 예를 들어 어떤 함수가 입력값을 반드시 변화시키긴 하지만, 어떻게 변하는지는 테스트 실행 날짜 같은 외부 조건에 따라 달라진다면, “함수 출력이 입력과 같지 않다”는 것을 단언하는 것이 최선일 수 있습니다.

내부적으로 assert_eq!assert_ne! 는 각각 ==, != 연산자를 사용합니다. 단언이 실패하면 인수들을 디버그 형식으로 출력하므로, 비교 대상 값들은 PartialEqDebug 트레이트를 구현하고 있어야 합니다. 모든 기본 타입과 대부분의 표준 라이브러리 타입은 이 트레이트들을 이미 구현합니다. 여러분이 직접 정의한 구조체와 enum에 대해서는, 그런 타입의 동등성 단언을 하려면 PartialEq 를 구현해야 하고, 실패 시 값을 출력하려면 Debug 도 구현해야 합니다. 둘 다 5장의 목록 5-12에서 언급한 파생 가능한 트레이트이므로, 보통은 구조체나 enum 정의 앞에 #[derive(PartialEq, Debug)] 를 추가하는 정도로 충분합니다. 자세한 내용은 부록 C의 [“파생 가능한 트레이트”][derivable-traits]를 참고하세요.

사용자 정의 실패 메시지 추가하기

assert!, assert_eq!, assert_ne! 매크로에는 실패 메시지와 함께 출력할 사용자 정의 메시지를 선택적 인수로 추가할 수도 있습니다. 필수 인수 뒤에 오는 모든 추가 인수는 format! 매크로로 전달됩니다([8장의 “+ 또는 format! 으로 이어 붙이기” 절][concatenating] 참고). 따라서 {} 플레이스홀더가 들어 있는 포맷 문자열과 그 자리에 채울 값들을 넘길 수 있습니다. 사용자 정의 메시지는 단언의 의미를 문서화하는 데 유용합니다. 테스트가 실패했을 때, 코드에서 무엇이 문제인지 더 잘 알 수 있기 때문입니다.

예를 들어 어떤 사람의 이름으로 인사하는 함수가 있고, 함수 출력 안에 우리가 넣은 이름이 실제로 포함되어 있는지 테스트하고 싶다고 합시다.

파일명: 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 함수가 반환한 값과 완전히 같은지를 검사하는 대신, 출력이 입력 매개변수 텍스트를 포함하는지만 검사하기로 합니다.

이제 이 코드에 버그를 넣어, greetingname 을 제외하도록 바꿔 보고, 기본 실패 출력이 어떤 모양인지 확인해 봅시다.

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! 을 일으키는지 테스트하기

#[test] 뒤, 해당 테스트 함수 앞에 #[should_panic] 속성을 붙입니다. 이 테스트가 통과할 때 결과가 어떤지 봅시다.

$ 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 at src/lib.rs:21:8

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 속성에 선택적 expected 매개변수를 넣을 수 있습니다. 그러면 테스트 하네스가 실패 메시지 안에 제공된 문자열이 포함되어 있는지도 확인합니다. 예를 들어 목록 11-9의 Guess 코드는, 값이 너무 작은지 너무 큰지에 따라 new 함수가 서로 다른 메시지로 패닉하도록 바꾼 버전입니다.

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 이었습니다. 이제 버그가 어디에 있는지 추적하기 시작할 수 있습니다.

테스트 실행 방식 제어하기

테스트 실행 방식 제어하기

cargo run 이 코드를 컴파일한 뒤 실행 파일을 실행하듯이, cargo test 는 코드를 테스트 모드로 컴파일한 뒤 생성된 테스트 바이너리를 실행합니다. cargo test 가 만든 바이너리의 기본 동작은 모든 테스트를 병렬로 실행하고, 테스트 실행 중 발생한 출력을 캡처하는 것입니다. 이렇게 하면 출력이 화면에 그대로 섞여 나오지 않아 테스트 결과 관련 출력이 더 읽기 쉬워집니다. 물론 이 기본 동작은 명령줄 옵션으로 바꿀 수 있습니다.

어떤 옵션은 cargo test 자체에 전달되고, 어떤 옵션은 최종적으로 실행되는 테스트 바이너리에 전달됩니다. 이 둘을 구분하려면 먼저 cargo test 용 인수를 적고, 구분자 -- 를 쓴 뒤 테스트 바이너리용 인수를 적습니다. cargo test --help 를 실행하면 cargo test 에 사용할 수 있는 옵션이 나오고, cargo test -- --help 를 실행하면 구분자 뒤에 사용할 수 있는 옵션이 나옵니다. 이 옵션들은 The rustc Book_ 의 “Tests” 절에도 문서화되어 있습니다.

테스트를 병렬 또는 순차적으로 실행하기

여러 테스트를 실행할 때, 기본적으로는 스레드를 사용해 병렬로 실행됩니다. 그래서 더 빨리 끝나고 피드백도 더 빨리 받을 수 있습니다. 다만 테스트가 동시에 실행되므로, 테스트끼리 서로에게 의존하지 않고, 현재 작업 디렉터리나 환경 변수처럼 공유된 상태에도 의존하지 않도록 주의해야 합니다.

예를 들어 모든 테스트가 디스크에 test-output.txt 라는 파일을 만들고 그 안에 데이터를 기록한다고 해 봅시다. 그리고 각 테스트가 그 파일 내용을 다시 읽어 특정 값을 가지고 있는지 검사한다고 합시다. 이때 각 테스트가 기대하는 값은 서로 다릅니다. 테스트가 동시에 실행되면, 한 테스트가 파일을 쓰고 읽는 사이에 다른 테스트가 그 파일을 덮어써 버릴 수 있습니다. 그러면 두 번째 테스트는 코드가 잘못되어서가 아니라, 병렬 실행 중 테스트끼리 서로 간섭했기 때문에 실패합니다. 한 가지 해결책은 각 테스트가 서로 다른 파일을 쓰게 만드는 것이고, 또 다른 해결책은 테스트를 한 번에 하나씩 실행하는 것입니다.

테스트를 병렬로 실행하고 싶지 않거나, 사용할 스레드 수를 더 세밀하게 제어하고 싶다면 --test-threads 플래그와 원하는 스레드 수를 테스트 바이너리에 전달하면 됩니다. 예를 들면 다음과 같습니다.

$ cargo test -- --test-threads=1

여기서는 테스트 스레드 수를 1 로 설정했기 때문에, 프로그램에게 병렬성을 사용하지 말라고 지시한 것입니다. 이렇게 하면 병렬 실행보다 시간이 더 걸릴 수 있지만, 공유 상태가 있는 테스트들이 서로 간섭하지는 않습니다.

함수 출력 보기

기본적으로 테스트가 통과하면, 러스트 테스트 라이브러리는 표준 출력에 찍힌 내용을 캡처합니다. 예를 들어 테스트 안에서 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 을 사용해 러스트에게 성공한 테스트의 출력도 보여 달라고 할 수 있습니다.

$ cargo test -- --show-output

목록 11-10의 테스트를 다시 --show-output 과 함께 실행하면, 다음과 같은 출력이 보입니다.

$ 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 에 실행하고 싶은 테스트 이름을 인수로 넘기면 어떤 테스트를 실행할지 선택할 수 있습니다.

테스트 일부만 실행하는 방법을 보여 주기 위해, 먼저 목록 11-11처럼 add_two 함수에 대한 테스트 세 개를 만들고, 그중 일부만 선택해 실행해 보겠습니다.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    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 tests::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 결과를 더 빨리 받아 볼 수 있습니다. 무시된 테스트 결과까지 볼 시간이 있을 때는 cargo test -- --ignored 를 실행하면 되고, 무시 여부와 상관없이 전부 실행하고 싶다면 cargo test -- --include-ignored 를 사용하면 됩니다.

테스트 구성하기

테스트 구성하기

이 장의 시작에서 이야기했듯, 테스트는 복잡한 분야이고 사람마다 용어나 구성 방식이 조금씩 다릅니다. 러스트 커뮤니티는 테스트를 크게 두 범주로 생각합니다. 단위 테스트와 통합 테스트입니다. 단위 테스트(unit tests) 는 작고 더 집중되어 있으며, 한 번에 한 모듈만 격리해 테스트하고, private 인터페이스도 검증할 수 있습니다. 통합 테스트 (integration tests) 는 라이브러리 바깥에 완전히 독립적으로 존재하며, 공개 인터페이스만 사용해 외부 코드가 쓰는 방식 그대로 라이브러리를 사용합니다. 그리고 한 테스트 안에서 여러 모듈을 함께 건드릴 수도 있습니다.

라이브러리의 각 부분이 개별적으로, 그리고 함께 동작할 때 모두 기대대로 작동하는지 확인하려면 두 종류의 테스트를 모두 작성하는 것이 중요합니다.

단위 테스트

단위 테스트의 목적은 코드의 각 단위를 나머지 코드와 격리해 테스트함으로써, 어디서 코드가 기대대로 작동하는지, 어디서 그렇지 않은지를 빠르게 짚어내는 데 있습니다. 단위 테스트는 src 디렉터리 안에서, 테스트 대상 코드와 같은 파일에 둡니다. 관례적으로 각 파일 안에 tests 라는 모듈을 만들고, 그 안에 테스트 함수를 넣으며, 모듈 자체에는 cfg(test) 를 붙입니다.

tests 모듈과 #[cfg(test)]

tests 모듈에 붙은 #[cfg(test)] 주석은, cargo build 가 아니라 cargo 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 의 약자이며, 어떤 설정 옵션이 주어졌을 때만 바로 다음 항목을 포함하라고 러스트에게 알려 줍니다. 여기서 설정 옵션은 러스트가 테스트를 컴파일하고 실행할 때 제공하는 test 입니다. 따라서 cfg 속성을 사용하면 Cargo는 우리가 명시적으로 cargo test 를 실행했을 때만 테스트 코드를 컴파일합니다. 이는 #[test] 가 붙은 함수뿐 아니라, 이 모듈 안에 있을 수 있는 각종 헬퍼 함수에도 마찬가지로 적용됩니다.

private 함수 테스트하기

테스트 커뮤니티 안에서는 private 함수를 직접 테스트해야 하는지에 대해 논쟁이 있고, 어떤 언어는 private 함수를 테스트하기 어렵거나 아예 불가능하게 만들기도 합니다. 여러분이 어떤 테스트 철학을 따르든 상관없이, 러스트의 privacy 규칙은 private 함수 테스트를 허용합니다. 목록 11-12의 internal_adder private 함수를 보세요.

Filename: src/lib.rs
pub fn add_two(a: u64) -> u64 {
    internal_adder(a, 2)
}

fn internal_adder(left: u64, right: u64) -> u64 {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        let result = internal_adder(2, 2);
        assert_eq!(result, 4);
    }
}
Listing 11-12: private 함수 테스트하기

internal_adder 함수에는 pub 가 붙어 있지 않다는 점에 주목하세요. 하지만 테스트는 그냥 러스트 코드이고, tests 모듈도 그냥 또 하나의 모듈입니다. [“모듈 트리의 항목을 경로로 가리키기”][paths] 절에서 설명했듯, 자식 모듈 안의 항목은 조상 모듈 안의 항목을 사용할 수 있습니다. 이 테스트에서는 use super::*; 를 사용해 tests 모듈의 부모 안에 있는 모든 항목을 스코프로 가져오고, 그 덕분에 테스트가 internal_adder 를 호출할 수 있습니다. 만약 private 함수는 테스트하지 않아야 한다고 생각한다면, 러스트가 여러분에게 억지로 그렇게 하게 만들지는 않습니다.

통합 테스트

러스트에서 통합 테스트는 라이브러리 밖에 완전히 독립적으로 존재합니다. 그것들은 외부 코드가 라이브러리를 사용하는 방식과 똑같이 라이브러리를 사용하므로, 라이브러리의 public API 안에 있는 함수만 호출할 수 있습니다. 목적은 라이브러리의 여러 부분이 함께 올바르게 동작하는지 검증하는 것입니다. 개별 단위들은 혼자서는 잘 동작하더라도, 통합되었을 때 문제가 생길 수 있으므로 통합된 코드에 대한 테스트 커버리지도 중요합니다. 통합 테스트를 만들려면 먼저 tests 디렉터리가 필요합니다.

tests 디렉터리

프로젝트 최상위 디렉터리에서 src 와 나란한 위치에 tests 디렉터리를 만듭니다. Cargo는 통합 테스트 파일을 이 디렉터리에서 찾는다는 사실을 알고 있습니다. 그리고 그 안에 원하는 만큼의 테스트 파일을 만들 수 있으며, Cargo는 각 파일을 개별 크레이트처럼 컴파일합니다.

이제 하나의 통합 테스트를 만들어 봅시다. 목록 11-12의 코드가 아직 src/lib.rs 안에 있다고 가정하고, tests 디렉터리를 만든 뒤 tests/integration_test.rs 라는 새 파일을 생성합니다. 디렉터리 구조는 다음처럼 됩니다.

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

tests/integration_test.rs 파일 안에는 목록 11-13의 코드를 넣습니다.

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

출력은 세 부분으로 이루어져 있습니다. 단위 테스트, 통합 테스트, 문서 테스트입니다. 한 섹션 안에서 테스트 하나라도 실패하면, 뒤의 섹션들은 실행되지 않는다는 점에 주의하세요. 예를 들어 단위 테스트가 실패하면 통합 테스트와 문서 테스트는 실행되지 않습니다. 이 둘은 단위 테스트가 모두 통과했을 때만 실행됩니다.

첫 번째 단위 테스트 섹션은 앞에서 보던 것과 같습니다. 각 단위 테스트마다 한 줄씩 있고(여기서는 목록 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 안 파일들은, 7장에서 코드와 파일을 모듈로 나누는 법을 배울 때 봤던 src 안 파일들과는 다른 동작을 보입니다.

이 차이는 여러 통합 테스트 파일에서 공통으로 쓸 헬퍼 함수 집합이 있을 때 특히 눈에 띕니다. 그리고 7장의 [“모듈을 여러 파일로 분리하기”] [separating-modules-into-files] 절에서 했던 것처럼, 그 공통 함수를 공용 모듈로 빼내려 할 때 드러납니다. 예를 들어 tests/common.rs 를 만들고 그 안에 setup 이라는 함수를 두면, 여러 테스트 파일의 여러 테스트 함수에서 공통 호출하고 싶은 코드를 거기에 넣을 수 있을 것 같습니다.

파일명: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

이제 테스트를 다시 실행하면, common.rs 파일 안에는 테스트 함수가 하나도 없고 우리가 setup 함수도 어디서도 호출하지 않았는데도, 테스트 출력에 common.rs 에 대한 새 섹션이 생깁니다.

$ 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 라고 나오는 것은 우리가 원하는 결과가 아닙니다. 우리는 단지 다른 통합 테스트 파일과 공유할 코드가 필요했을 뿐입니다. 이 문제를 피하려면 tests/common.rs 를 만드는 대신, tests/common/mod.rs 를 만들면 됩니다. 그러면 프로젝트 디렉터리는 이제 다음처럼 보입니다.

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

이것은 7장의 [“대체 파일 경로”][alt-paths] 절에서 언급했던, 러스트가 여전히 이해하는 예전 스타일 파일 명명 규칙입니다. 파일 이름을 이렇게 두면 러스트는 common 모듈을 통합 테스트 파일로 취급하지 않습니다. setup 함수 코드를 tests/common/mod.rs 로 옮기고 tests/common.rs 파일을 삭제하면, 테스트 출력에 그 섹션은 더 이상 나타나지 않습니다. tests 디렉터리 안의 하위 디렉터리 파일은 별도 크레이트로 컴파일되지 않고, 테스트 결과에 독립 섹션도 생기지 않습니다.

tests/common/mod.rs 를 만든 뒤에는, 어떤 통합 테스트 파일에서도 그것을 모듈처럼 사용할 수 있습니다. 다음은 tests/integration_test.rsit_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; 선언은 목록 7-21에서 보였던 일반 모듈 선언과 똑같습니다. 그 다음 테스트 함수 안에서 common::setup() 을 호출할 수 있습니다.

바이너리 크레이트에 대한 통합 테스트

만약 프로젝트가 오직 src/main.rs 만 가지고 있고 src/lib.rs 가 없는 바이너리 크레이트라면, tests 디렉터리에 통합 테스트를 만들어도 src/main.rs 에 정의된 함수를 use 로 스코프로 가져올 수는 없습니다. 다른 크레이트가 사용할 수 있는 함수를 노출하는 것은 라이브러리 크레이트뿐이고, 바이너리 크레이트는 그 자체로 실행되는 것이 목적이기 때문입니다.

이것이, 바이너리를 제공하는 러스트 프로젝트가 흔히 src/main.rs 는 단순하게 두고 핵심 로직은 src/lib.rs 안에 두는 이유 중 하나입니다. 그런 구조를 사용하면, 통합 테스트는 use 로 라이브러리 크레이트를 가져와 중요한 기능을 테스트할 수 있습니다. 핵심 로직이 올바르게 동작한다면, src/main.rs 안의 적은 양의 코드는 대체로 자연스럽게 올바르게 동작하므로 그 부분은 별도로 테스트하지 않아도 됩니다.

정리

러스트의 테스트 기능은 코드가 어떻게 동작해야 하는지를 명시하고, 이후 코드를 수정해도 여전히 기대한 대로 동작하는지를 확인하게 해 줍니다. 단위 테스트는 라이브러리의 여러 부분을 각각 독립적으로 검증하며, private 구현 세부도 테스트할 수 있습니다. 통합 테스트는 라이브러리의 여러 부분이 함께 올바르게 동작하는지를 검사하며, 외부 코드가 사용하는 방식 그대로 public API 를 통해 코드를 테스트합니다. 러스트의 타입 시스템과 소유권 규칙이 일부 버그를 예방해 주긴 하지만, 코드가 어떤 동작을 해야 하는지에 관한 로직 버그를 줄이기 위해 테스트는 여전히 중요합니다.

I/O 프로젝트: 명령줄 프로그램 만들기

이 장은 지금까지 배운 여러 기술을 다시 활용하면서, 표준 라이브러리의 몇 가지 기능을 추가로 탐색하는 장입니다. 파일과 명령줄 입출력을 다루는 커맨드라인 도구를 직접 만들어 보며, 여러분이 이제 익숙해진 러스트 개념들을 실제로 연습해 보겠습니다.

러스트는 빠르고 안전하며, 하나의 단일 바이너리로 배포할 수 있고, 여러 플랫폼을 지원하기 때문에 커맨드라인 도구를 만들기에 아주 적합한 언어입니다. 그래서 이번 프로젝트에서는 고전적인 명령줄 검색 도구 grep (globally search a regular expression and print) 의 축소판을 직접 만들어 보겠습니다. 가장 단순한 사용 사례에서 grep 은 특정 파일에서 특정 문자열을 찾습니다. 이를 위해 grep 은 파일 경로와 문자열을 인수로 받고, 파일을 읽은 뒤 그 문자열을 포함하는 줄을 찾아 출력합니다.

이 과정에서 많은 다른 커맨드라인 도구들이 사용하는 터미널 기능도 함께 살펴봅니다. 환경 변수 값을 읽어 사용자가 도구의 동작을 설정하게 하고, 성공적인 출력은 파일로 리다이렉트하더라도 에러 메시지는 화면에 보이도록, 표준 출력(stdout) 대신 표준 에러(stderr) 에 에러 메시지를 출력하는 방법도 다룹니다.

러스트 커뮤니티의 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 이 명령줄 인수 값을 읽게 하려면, 러스트 표준 라이브러리의 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: Collecting the command line arguments into a vector and printing them

먼저 std::env 모듈을 use 문으로 스코프로 가져와 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 의 타입을 명시적으로 주석 처리합니다. 러스트에서는 타입을 직접 적어야 하는 경우가 드물지만, collect 는 원하는 컬렉션 종류를 러스트가 추론하기 어려워서 자주 타입 주석이 필요한 함수 중 하나입니다.

마지막으로 디버그 형식으로 벡터를 출력합니다. 먼저 인수 없이, 그 다음 두 개의 인수를 넣고 이 코드를 실행해 봅시다.

$ 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: Creating variables to hold the query argument and file path argument

벡터를 출력했을 때 봤듯이, 프로그램 이름은 벡터의 첫 번째 값 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의 Emily Dickinson 시는 좋은 테스트 데이터가 됩니다! 프로젝트 루트에 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: A poem by Emily Dickinson makes a good test case.

텍스트를 준비했다면 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: Reading the contents of the file specified by the second argument

먼저 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 를 사용했기 때문에, 사용자가 인수를 충분히 주지 않고 프로그램을 실행하면 러스트로부터 index out of bounds 같은 에러를 보게 되는데, 이것은 무슨 문제가 있는지 명확하게 설명하지 않습니다. 모든 에러 처리 코드를 한 곳에 모아 두면, 이후 유지보수자는 에러 처리 로직을 바꿔야 할 때 한 곳만 보면 됩니다. 또한 에러 처리 코드가 한곳에 모여 있으면, 최종 사용자에게 의미 있는 메시지만 일관되게 출력하도록 만들 수도 있습니다.

이제 이 네 가지 문제를 리팩터링으로 해결해 보겠습니다.

바이너리 프로젝트에서 관심사 분리하기

여러 작업의 책임을 main 함수 하나에 몰아주는 문제는 많은 바이너리 프로젝트에서 흔하게 나타납니다. 그래서 많은 Rust 프로그래머는 main 함수가 커지기 시작하면 바이너리 프로그램의 서로 다른 관심사를 나누는 패턴을 사용합니다. 그 과정은 보통 다음 단계를 따릅니다.

  • 프로그램을 main.rslib.rs 로 나누고, 프로그램의 실제 로직을 lib.rs 로 옮긴다.
  • 명령줄 인수 파싱 로직이 아직 작다면 main 함수 안에 그대로 둘 수 있다.
  • 명령줄 파싱이 복잡해지기 시작하면, 그것도 main 에서 떼어내 별도 함수나 타입으로 분리한다.

이 과정을 거친 뒤 main 함수에 남아야 하는 책임은 대체로 다음 정도로 제한됩니다.

  • 인수 값을 이용해 명령줄 파싱 로직 호출하기
  • 나머지 설정 구성하기
  • lib.rs 안의 run 함수 호출하기
  • run 이 에러를 반환할 경우 그 에러를 처리하기

이 패턴의 핵심은 관심사 분리입니다. main.rs 는 프로그램을 실행하는 일을 맡고, lib.rs 는 실제 작업 로직을 맡습니다. main 함수는 직접 테스트할 수 없으므로, 이 구조를 사용하면 main 바깥으로 옮긴 나머지 프로그램 로직을 모두 테스트할 수 있습니다. main 안에 남는 코드는 육안으로도 쉽게 검증할 수 있을 정도로 작아질 것입니다. 이제 이 과정을 따라 프로그램을 다시 정리해 봅시다.

인수 파서 추출하기

먼저 인수 파싱 기능을 main 이 호출하는 별도 함수로 추출합니다. 목록 12-5는 main 초반이 어떻게 바뀌는지를 보여 줍니다. 이 함수는 src/main.rs 안에 정의할 새 parse_config 함수를 호출합니다.

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 으로 돌려줍니다. queryfile_path 변수 자체는 여전히 main 안에서 만들어지지만, 이제 main 은 명령줄 인수와 변수의 대응 관계를 결정하는 책임을 지지 않습니다.

이 리팩터링은 지금처럼 작은 프로그램에서는 과해 보일 수 있습니다. 하지만 우리는 작고 점진적인 단계로 리팩터링하고 있습니다. 이 변경을 한 뒤 프로그램을 다시 실행해, 인수 파싱이 여전히 제대로 동작하는지 확인하세요. 이렇게 중간중간 자주 확인하는 것은, 문제가 생겼을 때 원인을 빠르게 좁히는 데 큰 도움이 됩니다.

설정값 묶기

parse_config 를 한 단계 더 개선할 수 있습니다. 현재는 튜플을 반환한 뒤, 곧바로 그 튜플을 다시 개별 값으로 분해하고 있습니다. 이것은 아직 적절한 추상화에 도달하지 못했다는 신호입니다.

또 다른 신호는 parse_config 의 이름에 있는 config 입니다. 이 이름은 우리가 반환하는 두 값이 서로 관련되어 있고 하나의 설정값 일부라는 뜻을 암시합니다. 하지만 현재는 그 의미를 단지 튜플로 묶는 것 외에는 데이터 구조에서 드러내지 못하고 있습니다. 대신 두 값을 하나의 구조체에 넣고, 각 필드에 의미 있는 이름을 붙이겠습니다. 그러면 나중에 코드를 보는 사람도 값들이 서로 어떤 관계인지, 각 값의 목적이 무엇인지 더 쉽게 이해할 수 있습니다.

목록 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_configConfig 구조체 인스턴스를 반환하도록 리팩터링하기

여기서는 queryfile_path 라는 필드를 가진 Config 구조체를 추가했습니다. 이제 parse_config 시그니처는 Config 값을 반환한다고 말합니다. parse_config 본문에서는 원래 args 안의 String 값을 가리키는 문자열 슬라이스를 반환했지만, 이제 Config 구조체는 소유권을 가진 String 값을 담습니다. main 안의 args 변수가 인수 값들의 소유자이고, parse_config 는 그 값을 잠시 빌려 쓸 뿐이기 때문에, 만약 Config 가 그 값을 그대로 가져가려 하면 러스트의 대여 규칙을 위반하게 됩니다.

String 데이터를 다루는 방법은 여러 가지가 있지만, 가장 쉬우면서 다소 비효율적인 방법은 값에 clone 을 호출하는 것입니다. 이렇게 하면 Config 인스턴스가 소유할 완전한 복사본이 만들어지므로 시간과 메모리를 더 씁니다. 하지만 데이터를 복사하면 참조의 라이프타임을 관리할 필요가 없어지므로, 코드가 훨씬 단순해집니다. 이 상황에서는 약간의 성능을 포기하고 단순성을 얻는 것이 합리적인 절충입니다.

clone 사용의 트레이드오프

많은 Rustacean은 소유권 문제를 해결하기 위해 clone 을 쓰는 것을, 런타임 비용이 든다는 이유로 피하고 싶어 합니다. [13장][ch13]에서는 이런 상황에서 더 효율적인 방법을 사용하는 법을 배우게 됩니다. 하지만 지금은 계속 진도를 나가기 위해 문자열 몇 개를 복사하는 정도는 괜찮습니다. 이런 복사는 한 번만 일어나고, 파일 경로와 검색 문자열도 매우 작기 때문입니다. 처음 시도부터 지나치게 최적화하려 하기보다, 약간 비효율적이더라도 동작하는 프로그램을 만드는 편이 낫습니다. 러스트에 더 익숙해질수록 처음부터 더 효율적인 해결책을 택하기도 쉬워지겠지만, 지금은 clone 을 호출하는 것이 충분히 괜찮습니다.

우리는 main 도 업데이트해서, parse_config 가 반환한 Config 인스턴스를 config 라는 변수에 넣고, 이전에 queryfile_path 개별 변수를 쓰던 코드도 Config 구조체의 필드를 사용하도록 바꿨습니다.

이제 코드는 queryfile_path 가 서로 관련 있으며, 프로그램 동작 방식을 설정하는 값이라는 사실을 더 분명하게 전달합니다. 이 값들을 사용하는 코드는 이제 목적이 드러나는 필드 이름과 함께 config 인스턴스 안에서 그것들을 찾으면 됩니다.

Config 생성자 만들기

지금까지 우리는 명령줄 인수 파싱 책임을 main 에서 떼어내 parse_config 함수로 옮겼습니다. 이 과정을 통해 queryfile_path 가 관련된 값이라는 점을 확인했고, 그 관계를 코드 구조에도 반영해야 한다는 것을 알게 되었습니다. 그래서 Config 구조체를 추가해, 이 둘의 공통 목적에 이름을 붙이고, 구조체 필드 이름으로 함께 반환할 수 있게 했습니다.

이제 parse_config 함수의 목적이 Config 인스턴스를 만드는 일이라는 것이 분명해졌으니, parse_config 를 일반 함수 대신 Config 구조체와 연관된 new 함수로 바꿔도 좋습니다. 이렇게 하면 코드도 더 관용적인 러스트 스타일이 됩니다. 표준 라이브러리의 타입인 String 인스턴스를 String::new 로 만들듯이, Config 와 연관된 new 함수로 바꾸면 Config::newConfig 인스턴스를 만들 수 있게 됩니다. 목록 12-7이 필요한 변경을 보여 줍니다.

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 블록 안으로 옮겨 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에서 썼던 방식이 여기에는 최선이 아닌 것 같습니다. [9장에서 논의했듯이] [ch9-error-guidelines], panic! 은 사용법 실수보다는 프로그래밍 오류에 더 어울립니다. 그래서 이제는 9장에서 배운 다른 기법, 즉 [성공 또는 실패를 나타내는 Result 반환하기][ch9-result]를 사용해 보겠습니다.

panic! 대신 Result 반환하기

이제는 성공한 경우 Config 인스턴스를 담고, 에러인 경우에는 문제를 설명하는 Result 값을 반환하도록 바꿀 수 있습니다. 또한 많은 프로그래머는 new 함수가 실패하지 않는다고 기대하므로, 함수 이름도 new 에서 build 로 바꾸겠습니다. Config::buildmain 과 통신할 때 Result 타입을 사용하면, main 에서 Err variant를 감지해 사용자에게 더 친절한 에러를 출력할 수 있고, panic! 이 출력하는 thread 'main' 이나 RUST_BACKTRACE 같은 부가 정보는 숨길 수 있습니다.

목록 12-9는 이제 Config::build 라고 부르는 함수의 반환값과 본문을 어떻게 바꿔야 하는지 보여 줍니다. 물론 다음 목록에서 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 를, 실패하면 문자열 리터럴을 담은 Result 를 반환합니다. 이 에러 값은 언제나 'static 라이프타임을 가진 문자열 리터럴입니다.

함수 본문에는 두 가지 변화가 있습니다. 사용자가 인수를 충분히 주지 않았을 때 더 이상 panic! 을 호출하지 않고 Err 값을 반환합니다. 그리고 성공 시에는 Config 반환값을 Ok 로 감싸서 반환합니다. 이 두 변화 덕분에 함수는 새 타입 시그니처와 맞게 됩니다.

Config::build 에서 Err 를 반환하게 되면, 이제 main 함수는 build 함수가 반환한 Result 값을 직접 처리할 수 있고, 에러일 경우 더 깔끔한 방식으로 프로세스를 종료시킬 수 있습니다.

Config::build 호출하고 에러 처리하기

에러를 처리하고 사용자 친화적인 메시지를 출력하려면, main 을 목록 12-10처럼 업데이트해야 합니다. 또한 명령줄 도구가 0이 아닌 종료 코드로 끝나는 책임을 panic! 에 맡기지 않고, 우리가 직접 구현할 것입니다. 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 생성에 실패하면 에러 코드로 종료하기

여기서는 아직 자세히 다루지 않은 메서드 하나를 사용했습니다. 바로 표준 라이브러리의 Result<T, E> 에 정의된 unwrap_or_else 입니다. unwrap_or_else 를 사용하면, panic! 이 아닌 사용자 정의 에러 처리 로직을 만들 수 있습니다. ResultOk 이면 이 메서드는 unwrap 과 비슷하게 내부 값을 반환합니다. 반대로 값이 Err 이면, 인수로 넘긴 클로저(익명 함수) 안의 코드를 실행합니다. 클로저는 [13장][ch13] 에서 자세히 다루겠지만, 지금은 unwrap_or_elseErr 안의 값, 이 경우 목록 12-9에서 넣은 "not enough arguments" 라는 정적 문자열을, 세로줄 사이에 나타난 인자 err 로 클로저에 넘긴다는 점만 알면 충분합니다. 클로저 안 코드는 실행될 때 이 err 값을 사용할 수 있습니다.

표준 라이브러리의 process 를 스코프로 가져오기 위해 use 문도 하나 추가했습니다. 에러일 때 실행되는 클로저 안 코드는 두 줄뿐입니다. 먼저 err 를 출력하고, 그다음 process::exit 를 호출합니다. process::exit 함수는 프로그램을 즉시 중단시키고, 전달받은 숫자를 종료 상태 코드로 반환합니다. 이것은 목록 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 함수를 추출하는 작은 단계의 리팩터링을 보여 줍니다.

Filename: src/main.rs
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 })
    }
}
Listing 12-11: 나머지 프로그램 로직을 담는 run 함수 추출하기

이제 run 함수는 파일 읽기부터 시작하는, main 에 있던 나머지 로직을 전부 담고 있습니다. run 함수는 Config 인스턴스를 인수로 받습니다.

run 에서 에러 반환하기

이제 나머지 로직이 run 함수로 분리되었으니, 목록 12-9에서 Config::build 를 리팩터링했던 것처럼 run 의 에러 처리도 개선할 수 있습니다. expect 로 프로그램이 패닉하게 두는 대신, 문제가 생기면 runResult<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 안에 감쌉니다.

에러 타입으로는 trait object인 Box<dyn Error> 를 사용했습니다. 이를 위해 파일 위쪽에서 std::error::Erroruse 로 가져왔습니다. trait object는 [18장][ch18]에서 더 자세히 다루지만, 지금은 Box<dyn Error> 가 “Error 트레이트를 구현하는 어떤 에러 타입이든 반환할 수 있다” 정도로 이해하면 됩니다. 이 방식은 함수가 서로 다른 에러 상황에서 서로 다른 타입의 에러 값을 반환해야 할 수도 있을 때 유연성을 제공합니다.

둘째, [9장][ch9-question-mark]에서 이야기한 것처럼 expect 호출을 ? 연산자로 바꿨습니다. 이제 에러가 나면 panic! 하는 대신, ? 가 현재 함수에서 에러 값을 반환해 호출한 쪽이 처리하도록 합니다.

셋째, 성공한 경우 run 함수는 이제 Ok 값을 반환합니다. run 함수 시그니처에서 성공 타입을 () 로 선언했으므로, 단위 타입 값을 Ok 안에 감싸야 합니다. Ok(()) 라는 문법은 처음에는 조금 낯설 수 있지만, “이 함수는 부작용을 위해 호출하며, 별도의 값은 반환하지 않는다”는 것을 표현하는 러스트 관용구입니다.

이 코드를 실행하면 컴파일은 되지만 경고가 하나 뜹니다.

$ 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 값을 무시했고, 그 값이 에러를 나타낼 수도 있다고 알려 줍니다. 즉, 실제로 에러가 있었는지 아직 확인하지 않고 있다는 뜻입니다. 이제 그 문제도 바로 고쳐 봅시다.

main 에서 run 이 반환한 에러 처리하기

이제 목록 12-10에서 Config::build 를 다룰 때 썼던 것과 비슷한 방식으로, run 이 반환한 에러도 검사하고 처리하겠습니다. 다만 약간의 차이는 있습니다.

파일명: 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 을 사용해 runErr 값을 반환했는지 확인하고, 그렇다면 process::exit(1) 을 호출합니다. run 함수는 성공 시 우리가 unwrap 할 만한 의미 있는 값을 반환하지 않고 () 를 반환하므로, Config::build 와는 달리 굳이 unwrap_or_else 를 쓸 필요가 없습니다.

두 경우 모두 if let 본문과 unwrap_or_else 안의 본문은 똑같습니다. 에러를 출력하고, 프로세스를 종료합니다.

코드를 라이브러리 크레이트로 분리하기

우리 minigrep 프로젝트는 지금도 꽤 잘 정리되어 있습니다. 이제 src/main.rs 를 더 작게 만들기 위해, 일부 코드를 src/lib.rs 로 옮겨 보겠습니다. 이렇게 하면 그 코드에 대해 테스트를 작성하기도 쉬워집니다.

검색 텍스트를 처리하는 코드는 src/main.rs 가 아니라 src/lib.rs 안에 두겠습니다. 그러면 우리 minigrep 바이너리뿐 아니라, 그 라이브러리를 사용하는 누구든 그 검색 함수를 활용할 수 있게 됩니다.

먼저, 목록 12-13처럼 src/lib.rs 안에 search 함수 시그니처를 정의해 둡니다. 본문은 지금은 unimplemented! 매크로로 두겠습니다. 구현은 곧 채워 넣고, 그 전에 시그니처를 조금 더 설명하겠습니다.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}
Listing 12-13: src/lib.rssearch 함수 정의하기

함수 정의 앞에 pub 키워드를 붙여 search 를 라이브러리 크레이트의 공개 API 일부로 지정했습니다. 이제 우리는 바이너리 크레이트에서도 사용할 수 있고, 테스트도 할 수 있는 라이브러리 크레이트를 하나 가지게 된 것입니다.

다음으로는 src/lib.rs 에 정의한 코드를 src/main.rs 안의 바이너리 크레이트 스코프로 가져오고, 그것을 호출해야 합니다. 목록 12-14를 보세요.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

// --snip--
use minigrep::search;

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);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

// --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 })
    }
}

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(())
}
Listing 12-14: src/main.rs 에서 minigrep 라이브러리 크레이트의 search 함수 사용하기

우리는 use minigrep::search 라는 줄을 추가해 라이브러리 크레이트의 search 함수를 바이너리 크레이트 스코프로 가져왔습니다. 그다음 run 함수 안에서 더 이상 파일 내용을 그대로 출력하는 대신, search 함수를 호출하고 config.querycontents 를 인수로 넘깁니다. 그런 뒤 runsearch 가 반환한, 질의와 매칭된 각 줄을 for 루프로 출력합니다. 이 시점에서 main 함수 안에서 질의와 파일 경로를 출력하던 println! 도 지워, 에러가 없는 경우에는 오직 검색 결과만 출력하게 만드는 것도 좋습니다.

참고로 이 search 함수는 출력하기 전에 모든 결과를 벡터에 모아 반환합니다. 따라서 큰 파일을 검색할 때는 결과를 찾자마자 바로 출력하지 못하므로 느릴 수 있습니다. 13장에서 반복자를 배우면서 이를 개선할 수 있는 방법도 이야기할 것입니다.

후우, 여기까지도 꽤 많은 작업이었습니다. 하지만 덕분에 우리는 앞으로 더 쉽게 작업할 수 있는 구조를 마련했습니다. 에러 처리도 훨씬 다루기 쉬워졌고, 코드도 더 모듈화 되었습니다. 이제부터는 거의 모든 작업이 src/lib.rs 안에서 이루어질 것입니다.

그럼 이 새롭게 얻은 모듈성을 활용해, 이전 구조에서는 하기 어려웠지만 지금은 쉬워진 일을 해 봅시다. 바로 테스트 작성입니다!

테스트 주도 개발로 기능 추가하기

테스트 주도 개발로 기능 추가하기

이제 검색 로직이 main 함수와 분리되어 src/lib.rs 안에 있으므로, 코드의 핵심 기능에 대한 테스트를 쓰기가 훨씬 쉬워졌습니다. 명령줄에서 바이너리를 실행하지 않고도, 여러 인수를 주며 함수를 직접 호출하고 반환값을 검사할 수 있기 때문입니다.

이 절에서는 다음과 같은 테스트 주도 개발(TDD) 과정을 따라 minigrep 프로그램의 검색 로직을 추가합니다.

  1. 실패하는 테스트를 작성하고, 기대한 이유로 실제로 실패하는지 확인한다.
  2. 새 테스트를 통과시키는 데 필요한 최소한의 코드만 작성하거나 수정한다.
  3. 방금 추가하거나 바꾼 코드를 리팩터링하고, 테스트가 계속 통과하는지 확인한다.
  4. 다시 1단계로 돌아간다.

TDD가 소프트웨어를 작성하는 유일한 방법은 아니지만, 코드 설계를 이끌어 주는 데 매우 유용합니다. 테스트가 통과하게 만드는 코드를 쓰기 전에 먼저 테스트를 작성하면, 개발 과정 전체에서 높은 테스트 커버리지를 유지하는 데 도움이 됩니다.

우리는 질의 문자열에 맞는 줄들을 실제로 검색하고, 그 줄들의 목록을 반환하는 기능을 테스트 주도로 구현할 것입니다. 이 기능은 search 라는 함수 안에 추가합니다.

실패하는 테스트 작성하기

src/lib.rs11장에서 했던 것처럼 tests 모듈과 테스트 함수를 추가합니다. 이 테스트 함수는 우리가 search 함수에 기대하는 동작을 명시합니다. 즉, 질의 문자열과 검색 대상 텍스트를 받아, 질의를 포함하는 줄만 반환해야 합니다. 목록 12-15가 그 테스트입니다.

Filename: src/lib.rs
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    unimplemented!();
}

// --snip--

#[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" 를 포함한 줄은 하나뿐입니다(열린 큰따옴표 뒤의 역슬래시는, 문자열 리터럴 내용 앞에 줄바꿈 문자가 들어가지 않게 하기 위한 것입니다). 우리는 search 함수가 반환한 값이 오직 "safe, fast, productive." 줄 하나만 담고 있는지 단언합니다.

지금 이 테스트를 실행하면 실패합니다. unimplemented! 매크로가 “not implemented” 메시지와 함께 패닉하기 때문입니다. TDD 원칙에 따라, 함수 호출 시 패닉만은 하지 않도록 딱 필요한 만큼의 코드만 먼저 추가하겠습니다. 이를 위해 search 함수가 항상 빈 벡터를 반환하도록 정의해 둡니다. 목록 12-16을 보세요. 그러면 테스트는 일단 컴파일되고, 이제는 “빈 벡터” 가 우리가 기대한 줄 하나와 다르기 때문에 실패하게 됩니다.

Filename: src/lib.rs
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장] ch10-lifetimes에서 보았듯이, 라이프타임 매개변수는 반환값 라이프타임이 어느 인수의 라이프타임과 연결되는지를 지정합니다. 이 경우 우리는 반환되는 벡터가 query 가 아니라 contents 의 일부를 가리키는 문자열 슬라이스들을 담아야 한다고 표현하고 있습니다.

즉, 우리는 search 함수가 반환하는 데이터가, contents 인수로 전달된 데이터만큼은 살아 있어야 한다고 러스트에게 알려 주는 것입니다. 이것은 중요합니다! 슬라이스가 가리키는 대상 데이터가 살아 있어야만 그 참조도 유효하기 때문입니다. 만약 컴파일러가 우리가 contents 가 아니라 query 를 기준으로 슬라이스를 만든다고 잘못 추측한다면, 안전성 검사를 엉뚱한 방식으로 하게 될 것입니다.

이 라이프타임 주석을 빼고 이 함수를 컴파일하려 하면, 다음과 같은 오류를 얻게 됩니다.

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
 --> src/lib.rs:1:51
  |
1 | 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
  |
1 | 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

러스트는 두 매개변수 중 어느 쪽과 출력이 연결되는지 알 수 없기 때문에, 우리가 직접 명시해 주어야 합니다. 도움말은 모든 매개변수와 출력 타입에 같은 라이프타임 매개변수를 붙이라고 제안하지만, 그건 이 경우 틀립니다. 우리가 반환하고 싶은 것은 query 가 아니라 contents 일부이기 때문에, 반환값과 연결되어야 하는 매개변수는 contents 뿐입니다.

다른 프로그래밍 언어들은 보통 함수 시그니처에서 인수와 반환값을 이렇게 연결하라고 요구하지 않지만, 시간이 지나면 익숙해질 것입니다. 10장의 “라이프타임으로 참조의 유효성 검증하기” 절의 예들과 이 예를 비교해 보는 것도 도움이 됩니다.

테스트를 통과시키는 코드 작성하기

현재 테스트는 늘 빈 벡터를 반환하므로 실패합니다. 이를 고쳐 search 를 구현하려면, 프로그램은 다음 단계를 수행해야 합니다.

  1. 텍스트의 각 줄을 순회한다.
  2. 현재 줄이 질의 문자열을 포함하는지 검사한다.
  3. 포함한다면, 반환할 값 목록에 그 줄을 추가한다.
  4. 포함하지 않으면 아무 것도 하지 않는다.
  5. 매칭된 결과 목록을 반환한다.

이제 각 단계를 차례대로 구현해 봅시다. 먼저 줄 단위 반복부터 시작합니다.

lines 메서드로 줄 순회하기

러스트는 문자열을 줄 단위로 순회하는 데 아주 유용한 lines 메서드를 제공합니다. 목록 12-17이 이를 보여 줍니다. 다만 아직은 컴파일되지 않습니다.

Filename: src/lib.rs
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 메서드는 반복자를 반환합니다. 반복자는 13장 에서 자세히 다루겠지만, 목록 3-5에서 이미 비슷한 사용법을 본 적이 있습니다. 컬렉션 각 항목에 대해 코드를 실행하기 위해 for 루프와 반복자를 함께 썼었죠.

각 줄에서 질의 문자열 찾기

다음으로 현재 줄이 질의 문자열을 포함하는지 확인해야 합니다. 다행히 문자열에는 이를 위한 contains 라는 편리한 메서드가 이미 있습니다! 목록 12-18처럼 search 함수 안에 contains 호출을 추가해 보세요. 물론 이 상태에서는 아직도 컴파일되지 않습니다.

Filename: src/lib.rs
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 루프 전에 가변 벡터를 하나 만들고, 벡터에 line 을 넣기 위해 push 메서드를 호출하면 됩니다. for 루프가 끝나면 그 벡터를 반환합니다. 목록 12-19를 보세요.

Filename: src/lib.rs
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장] ch13-iterators에서 반복자를 자세히 다룰 때 다시 돌아와, 어떻게 개선할 수 있는지 살펴보겠습니다.

이제 프로그램 전체가 동작해야 합니다! 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 바이너리에 기능 하나를 더 추가하겠습니다. 사용자가 환경 변수를 통해 켜고 끌 수 있는 대소문자 무시 검색 옵션입니다. 이 기능을 명령줄 옵션으로 만들어 매번 입력하게 할 수도 있지만, 대신 환경 변수로 만들면 사용자는 터미널 세션 동안 환경 변수를 한 번만 설정해 두고 모든 검색을 대소문자 무시 모드로 실행할 수 있습니다.

대소문자 무시 검색 함수에 대한 실패하는 테스트 작성하기

먼저 minigrep 라이브러리에 search_case_insensitive 라는 새 함수를 추가하겠습니다. 이 함수는 환경 변수에 값이 설정되어 있을 때 호출됩니다. 여기서도 계속 TDD 과정을 따르므로, 첫 단계는 역시 실패하는 테스트를 작성하는 것입니다. 새 search_case_insensitive 함수를 위한 테스트를 추가하고, 기존 테스트 이름도 one_result 에서 case_sensitive 로 바꾸어 두 테스트의 차이를 더 분명히 하겠습니다. 목록 12-20이 그 모습을 보여 줍니다.

Filename: src/lib.rs
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 도 바꾸었다는 점에 주목하세요. "Duct tape." 라는 새 줄을 대문자 D 로 추가했습니다. 대소문자를 구분하는 검색에서는 이것이 질의 "duct" 와 매칭되면 안 됩니다. 기존 테스트를 이렇게 바꾸는 것은, 앞으로 대소문자 무시 검색 기능을 추가하는 동안 이미 구현한 대소문자 구분 검색 기능을 실수로 망가뜨리지 않게 해 줍니다. 이 테스트는 지금 통과해야 하고, 이후 작업 중에도 계속 통과해야 합니다.

새 대소문자 무시 검색 테스트는 "rUsT" 를 질의로 사용합니다. 우리가 곧 만들 search_case_insensitive 함수에서는 이 "rUsT" 질의가 대문자 R 로 시작하는 "Rust:" 줄과, 또 다른 대소문자를 가진 "Trust me." 줄 모두와 매칭되어야 합니다. 이것이 우리가 원하는 실패 테스트이며, 아직 search_case_insensitive 함수를 정의하지 않았기 때문에 현재는 컴파일에도 실패합니다. 필요하다면 목록 12-16에서 search 함수에 대해 했던 것처럼, 항상 빈 벡터를 반환하는 골격 구현을 먼저 넣어 테스트가 “컴파일은 되지만 실패하게” 바꿔 보는 것도 좋습니다.

search_case_insensitive 함수 구현하기

목록 12-21에 나오는 search_case_insensitive 함수는 search 함수와 거의 같습니다. 유일한 차이는 질의 문자열 query 와 각 줄 line 을 모두 소문자로 바꾼 뒤 비교한다는 점입니다. 이렇게 하면 입력 인수의 대소문자 형태가 어떻든, 비교할 때는 동일한 대소문자가 됩니다.

Filename: src/lib.rs
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 함수 정의하기

먼저 query 문자열을 소문자로 바꾸고, 원래 이름과 같은 새 변수에 저장해 이전 query 를 가립니다. to_lowercase 를 호출해야 하는 이유는, 사용자가 "rust", "RUST", "Rust", "rUsT" 중 무엇을 입력하든 그것을 모두 "rust" 처럼 다루고 대소문자를 무시하기 위해서입니다. to_lowercase 는 기본적인 유니코드를 처리하지만 100퍼센트 완전한 해법은 아닙니다. 만약 실제 애플리케이션을 만든다면 이 부분을 더 신경 써야 하겠지만, 여기서는 환경 변수가 주제이지 유니코드가 주제는 아니므로 이 정도로 두겠습니다.

이제 query 는 문자열 슬라이스가 아니라 String 입니다. to_lowercase 는 기존 데이터를 참조하는 대신 새로운 데이터를 만들기 때문입니다. 예를 들어 질의가 "rUsT" 라면, 그 문자열 슬라이스 안에는 우리가 그대로 사용할 수 있는 소문자 u, t 가 들어 있지 않습니다. 따라서 "rust" 를 담은 새 String 을 할당해야 합니다. 이제 contains 메서드에 query 를 인수로 넘길 때는, contains 시그니처가 문자열 슬라이스를 받도록 정의되어 있으므로 & 를 붙여야 합니다.

다음으로 각 line 에 대해서도 to_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

좋습니다. 테스트가 통과했습니다! 이제 새 search_case_insensitive 함수를 run 함수에서 실제로 호출해 보겠습니다. 먼저 Config 구조체에 대소문자를 구분할지 무시할지 전환하는 설정 필드를 하나 추가합니다. 아직 이 필드를 어디에서도 초기화하지 않았으므로, 이 변경은 곧바로 컴파일 오류를 일으킬 것입니다.

파일명: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --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);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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 })
    }
}

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(())
}

우리는 불리언 값을 담는 ignore_case 필드를 추가했습니다. 다음으로 run 함수가 config.ignore_case 값을 검사해, search 함수를 호출할지 search_case_insensitive 함수를 호출할지 결정하도록 만들어야 합니다. 목록 12-22가 그 코드입니다. 물론 아직은 이것만으로는 컴파일되지 않습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

// --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);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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 })
    }
}

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(())
}
Listing 12-22: config.ignore_case 값에 따라 search 또는 search_case_insensitive 호출하기

마지막으로 환경 변수를 실제로 검사해야 합니다. 환경 변수를 다루는 함수들은 표준 라이브러리의 env 모듈에 있고, 이것은 이미 src/main.rs 맨 위에서 스코프로 가져와져 있습니다. 목록 12-23처럼 env 모듈의 var 함수를 사용해 IGNORE_CASE 라는 이름의 환경 변수에 값이 설정되어 있는지 확인하겠습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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(())
}
Listing 12-23: IGNORE_CASE 라는 환경 변수에 어떤 값이든 들어 있는지 확인하기

여기서는 ignore_case 라는 새 변수를 만들고 값을 설정합니다. 이를 위해 env::var 함수를 호출하고 IGNORE_CASE 환경 변수 이름을 넘깁니다. env::varResult 를 반환하는데, 환경 변수가 어떤 값으로든 설정되어 있다면 그 값을 담은 성공적인 Ok variant를 반환하고, 설정되어 있지 않다면 Err variant를 반환합니다.

우리는 Result 에서 is_ok 메서드를 사용해 환경 변수가 설정되어 있는지만 확인합니다. 즉, 값이 있으면 프로그램은 대소문자를 구분하지 않는 검색을 하게 됩니다. IGNORE_CASE 환경 변수에 아무 값도 없으면 is_okfalse 를 반환하고, 프로그램은 대소문자를 구분하는 검색을 수행합니다. 환경 변수의 구체적인 값 자체는 관심 대상이 아니고, 설정되어 있는지 여부만 중요하므로 unwrap, expect 같은 메서드 대신 is_ok 를 쓰는 것입니다.

우리는 이 ignore_case 변수 값을 Config 인스턴스로 넘겨서, run 함수가 이 값을 읽고 목록 12-22에서 구현한 대로 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::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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(())
}
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! 매크로를 사용함으로써, 이제 여러분은 커맨드라인 애플리케이션을 작성할 준비가 되었습니다. 앞 장의 개념들과 합쳐 보면, 여러분의 코드는 잘 조직되어 있고, 적절한 자료구조에 데이터를 효과적으로 저장하며, 에러도 깔끔하게 처리하고, 테스트도 잘 갖춘 상태가 됩니다.

다음 장에서는 함수형 언어에서 영향을 받은 러스트 기능인 클로저와 반복자를 살펴보겠습니다.

함수형 언어 기능: 반복자와 클로저

러스트 설계는 많은 기존 언어와 기법에서 영감을 받았고, 그중 하나의 중요한 영향이 함수형 프로그래밍(functional programming) 입니다. 함수형 스타일의 프로그래밍은 함수를 값처럼 다루는 경우가 많습니다. 예를 들어 함수를 인수로 넘기거나, 다른 함수에서 반환하거나, 나중에 실행하기 위해 변수에 저장하는 식입니다.

이 장에서는 “무엇이 함수형 프로그래밍인가”라는 철학적 논쟁보다는, 여러 언어에서 흔히 함수형 기능이라고 부르는 것과 비슷한 러스트 기능들을 살펴보겠습니다.

구체적으로는 다음을 다룹니다.

  • 클로저(closures): 변수에 저장할 수 있는 함수 비슷한 구성 요소
  • 반복자(iterators): 요소들의 연속을 처리하는 방법
  • 12장의 I/O 프로젝트를 클로저와 반복자로 개선하는 방법
  • 클로저와 반복자의 성능(결론부터 말하면, 생각보다 훨씬 빠릅니다!)

패턴 매칭이나 enum 같은 다른 러스트 기능들도 함수형 스타일의 영향을 받았다는 점은 이미 이야기했습니다. 빠르고 관용적인 러스트 코드를 작성하는 데 클로저와 반복자를 잘 다루는 것은 아주 중요하므로, 이 장 전체를 이 두 주제에 할애하겠습니다.

클로저

클로저

러스트의 클로저는 변수에 저장하거나 다른 함수의 인수로 넘길 수 있는 익명 함수입니다. 한곳에서 클로저를 만든 뒤, 나중에 다른 맥락에서 호출해 실행할 수 있습니다. 함수와 달리, 클로저는 자신이 정의된 스코프의 값을 캡처할 수 있습니다. 이 장에서는 이런 클로저의 성질이 코드 재사용과 동작 맞춤화를 어떻게 가능하게 하는지 보여 줍니다.

환경 캡처하기

먼저, 클로저가 정의된 환경의 값을 캡처해 나중에 사용할 수 있다는 사실을 살펴보겠습니다. 상황은 이렇습니다. 우리 티셔츠 회사는 가끔 홍보 이벤트로, 메일링 리스트에 있는 사람 가운데 한 명에게 한정판 티셔츠를 무료로 나눠 줍니다. 메일링 리스트 사용자는 자신의 프로필에 선호 색상을 선택적으로 적어 둘 수 있습니다. 무료 티셔츠 당첨자가 선호 색상을 설정해 두었다면 그 색상의 셔츠를 받고, 그렇지 않다면 회사에 현재 가장 많이 남아 있는 색상의 셔츠를 받습니다.

이 상황을 구현하는 방법은 여러 가지가 있습니다. 여기서는 클로저에 집중하기 위해, 클로저를 사용하는 giveaway 메서드 본문 외에는 여러분이 이미 배운 개념만 사용하겠습니다. 예제에서는 색상을 단순화하기 위해 RedBlue 두 variant를 가진 ShirtColor enum을 사용하고, 회사 재고는 현재 보유 중인 셔츠 색상을 담은 Vec<ShirtColor> 필드를 가진 Inventory 구조체로 표현합니다. 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> 타입 매개변수로 받고, 그 값에 unwrap_or_else 메서드를 호출합니다. 표준 라이브러리에 정의된 [Option<T>unwrap_or_else 메서드][unwrap-or-else]는 인수 하나를 받는데, 그 인수는 매개변수가 없는 클로저이며 값 T 를 반환합니다 (여기서는 Option<T>Some 안에 저장되는 타입이 ShirtColor 입니다). Option<T>Some variant 이면 unwrap_or_else 는 그 안의 값을 그대로 반환합니다. 반대로 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)처럼 매개변수 타입이나 반환값 타입을 주석으로 적을 필요가 없습니다. 함수는 사용자에게 노출되는 명시적인 인터페이스의 일부이기 때문에 타입 주석이 필요합니다. 인터페이스를 엄격히 정의해 두어야 모두가 함수가 어떤 타입을 받고 어떤 타입을 반환하는지 동의할 수 있기 때문입니다. 반면 클로저는 이런 식으로 외부에 노출되는 인터페이스에 사용되지 않습니다. 클로저는 변수에 저장되고, 이름을 통해 외부 사용자에게 드러나는 것이 아니라 내부적으로만 쓰입니다.

클로저는 보통 짧고, 어떤 임의의 상황 전반이 아니라 좁은 맥락 안에서만 의미가 있습니다. 이런 제한된 맥락에서는, 컴파일러가 대부분의 변수 타입을 추론하듯 매개변수 타입과 반환 타입도 추론할 수 있습니다(드물게는 클로저에도 타입 주석이 필요한 경우가 있습니다).

변수와 마찬가지로, 조금 더 명확하게 표현하고 싶다면 타입 주석을 달 수도 있습니다. 물론 그만큼 장황해지긴 합니다. 목록 13-2는 클로저에 타입 주석을 붙이면 어떤 모양인지 보여 줍니다. 여기서는 목록 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(); 가 벡터 안에 어떤 타입의 값이 들어가거나, 타입 주석이 추가되기 전까지는 타입을 알 수 없는 것과 비슷합니다.

클로저 정의에 대해 컴파일러는 각 매개변수와 반환값에 대해 하나의 구체적인 타입만 추론합니다. 예를 들어 목록 13-3에는 인수로 받은 값을 그대로 반환하는 짧은 클로저를 정의합니다. 이 클로저는 예제 외에는 별 쓸모가 없지만, 타입 추론 방식을 설명하기에는 좋습니다. 정의 자체에는 타입 주석이 없다는 점에 주의하세요. 타입 주석이 없기 때문에 처음에는 어떤 타입으로도 호출할 수 있을 것처럼 보이지만, 실제로는 첫 호출에서 타입이 고정됩니다. 여기서는 첫 번째 호출에서 String 을 사용했고, 그 다음에 정수로 호출하려 하면 오류가 납니다.

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);
  |             --------------- ^ 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;
  |                            ^
help: try using a conversion method
  |
5 |     let n = example_closure(5.to_string());
  |                              ++++++++++++

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 라는 벡터 값에 대한 불변 참조만 있으면 출력을 수행할 수 있기 때문에, 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]

여기서는 클로저 정의와 호출 사이에 더 이상 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 를 불변 참조로 캡처했습니다. 하지만 여기서는 클로저 본문이 여전히 불변 참조만 필요하더라도, move 키워드를 붙여 list 가 클로저 안으로 이동되도록 명시해야 합니다. 만약 메인 스레드가 새 스레드에 join 하기 전에 더 많은 작업을 수행한다면, 새 스레드가 먼저 끝나고 그 뒤 메인 스레드 쪽의 list 가 더 이상 유효하지 않을 가능성도 있기 때문입니다. move 로 소유권을 넘기면 이런 문제를 피할 수 있습니다.

반복자로 항목 시리즈 처리하기

반복자로 항목 시리즈 처리하기

반복자 패턴은 어떤 항목들의 시퀀스에 대해 순서대로 작업을 수행하게 해 줍니다. 반복자는 각 항목을 어떻게 순회할지, 그리고 언제 시퀀스가 끝나는지를 결정하는 로직을 담당합니다. 반복자를 사용하면 그런 로직을 직접 다시 구현할 필요가 없습니다.

러스트에서 반복자는 게으르다(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-5에서는 for 루프로 배열을 순회하며 각 항목에 어떤 코드를 실행하는 예를 보았습니다. 그때 내부적으로는 반복자가 암묵적으로 만들어지고 소비되었지만, 정확히 어떻게 동작하는지는 지금까지 자세히 보지 않았습니다.

목록 13-11의 예에서는 반복자 생성과 반복자 사용을 for 루프에서 분리해서 보여 줍니다. for 루프가 v1_iter 안의 반복자를 사용하면, 반복자의 각 요소가 루프의 한 번의 반복에서 사용되어 각 값을 출력합니다.

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>;

    // methods with default implementations elided
}
}

이 정의에는 새로운 문법인 type ItemSelf::Item 이 등장한다는 점에 주목하세요. 이것은 이 트레이트에 대한 연관 타입(associated type)을 정의하는 문법입니다. 연관 타입은 20장에서 자세히 다룹니다. 지금은 이 코드가, Iterator 트레이트를 구현하려면 Item 타입도 함께 정의해야 하며, 이 Item 타입이 next 메서드의 반환 타입에 사용된다고 말하고 있다는 점만 이해하면 충분합니다. 다시 말해 Item 타입은 반복자가 반환하는 값의 타입이 됩니다.

Iterator 트레이트는 구현자에게 단 하나의 메서드만 요구합니다. 바로 next 메서드입니다. 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 메서드를 호출하면 반복자가 현재 시퀀스에서 어느 위치에 있는지를 추적하는 내부 상태가 변하기 때문입니다. 즉, 이 코드는 반복자를 소모(consume) 합니다. next 를 호출할 때마다 반복자 안의 항목 하나가 사용됩니다. for 루프를 사용할 때는, 루프가 내부에서 v1_iter 소유권을 가져가고 가변으로 처리해 주었기 때문에 직접 mut 를 붙일 필요가 없었습니다.

또한 next 호출로 얻는 값이 벡터 안 값에 대한 불변 참조라는 점에도 주목하세요. iter 메서드는 불변 참조에 대한 반복자를 만듭니다. 만약 v1 의 소유권을 가져가고 소유된 값을 반환하는 반복자를 만들고 싶다면 iter 대신 into_iter 를 호출할 수 있습니다. 마찬가지로, 가변 참조를 순회하고 싶다면 iter 대신 iter_mut 를 사용할 수 있습니다.

반복자를 소비하는 메서드

Iterator 트레이트에는 표준 라이브러리가 기본 구현까지 제공하는 다양한 메서드가 정의되어 있습니다. 이 메서드들이 어떤 것인지는 Iterator 트레이트에 대한 표준 라이브러리 API 문서를 보면 확인할 수 있습니다. 이 메서드들 중 일부는 내부 구현에서 next 를 호출하기 때문에, Iterator 트레이트를 구현할 때 next 메서드를 반드시 정의해야 하는 것입니다.

이처럼 next 를 호출하는 메서드는 반복자를 사용해 버리기 때문에 소비 어댑터(consuming adapters) 라고 부릅니다. 한 예가 sum 메서드입니다. 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 adapters)Iterator 트레이트에 정의된 메서드로, 반복자를 소비하지는 않습니다. 대신 원래 반복자의 어떤 성질을 바꾼 새로운 반복자를 만들어 냅니다.

목록 13-14는 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의 코드는 실제로 아무 일도 하지 않습니다. 우리가 지정한 클로저는 한 번도 호출되지 않습니다. 왜냐하면 반복자 어댑터는 게으르기 때문입니다. 이 경고는 그 사실을 상기시켜 줍니다. 즉, 여기서는 반복자를 실제로 소비해야만 의미 있는 일이 일어납니다.

이 경고를 없애고 반복자를 소비하기 위해, 목록 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 메서드를 사용하겠습니다. filter 는 클로저를 받는데, 그 클로저는 반복자에서 항목 하나를 받아 bool 값을 반환합니다. 클로저가 true 를 반환하면 그 값은 filter 가 만들어 내는 반복자 안에 포함됩니다. false 를 반환하면 그 값은 제외됩니다.

목록 13-16에서는 shoe_size 변수를 환경에서 캡처하는 클로저와 함께 filter 를 사용해 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 함수는 신발 벡터의 소유권과 원하는 신발 사이즈를 매개변수로 받습니다. 그리고 지정한 사이즈의 신발만 담은 벡터를 반환합니다.

함수 본문에서는 먼저 into_iter 를 호출해, 신발 벡터의 소유권을 가져가는 반복자를 만듭니다. 그 다음 filter 를 호출해, 클로저가 true 를 반환하는 요소만 포함하는 새 반복자로 바꿉니다.

이 클로저는 환경에서 shoe_size 매개변수를 캡처하고, 각 신발의 size 필드와 비교합니다. 그래서 지정한 사이즈와 같은 신발만 남게 됩니다. 마지막으로 collect 를 호출해 어댑터 반복자가 반환하는 값들을 벡터로 모으고, 그 벡터를 함수에서 반환합니다.

이 테스트는 shoes_in_size 를 호출했을 때, 우리가 지정한 크기와 같은 신발만 돌아오는지 확인합니다.

I/O 프로젝트 개선하기

I/O 프로젝트 개선하기

이제 반복자에 대한 새로운 지식을 얻었으니, 12장의 I/O 프로젝트도 반복자를 사용해 더 명확하고 더 간결하게 개선할 수 있습니다. 여기서는 반복자가 Config::build 함수와 search 함수 구현을 어떻게 개선하는지 살펴보겠습니다.

반복자로 clone 제거하기

목록 12-6에서는 String 값 슬라이스를 받아, 특정 인덱스에 접근한 뒤 값을 cloneConfig 구조체 인스턴스를 만드는 코드를 작성했습니다. 목록 13-17에는, 목록 12-23 시점의 Config::build 구현을 그대로 다시 적어 두었습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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);
    });

    if let Err(e) = run(config) {
        println!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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(())
}
Listing 13-17: 목록 12-23의 Config::build 함수 다시 보기

그때는 비효율적인 clone 호출은 지금 걱정하지 말고, 나중에 제거하겠다고 했습니다. 이제 바로 그 “나중” 입니다!

여기서 clone 이 필요했던 이유는, args 매개변수가 String 값들의 슬라이스였고, build 함수는 그 슬라이스 자체의 소유권을 갖지 않았기 때문입니다. 그래서 Config 인스턴스의 소유권으로 값을 넘겨 주려면 queryfile_path 값을 복사해 새 할당을 해야 했습니다.

하지만 이제 반복자를 알게 되었으므로, build 함수가 슬라이스를 빌리는 대신 반복자의 소유권을 매개변수로 받게 만들 수 있습니다. 그러면 슬라이스 길이를 검사하고 특정 위치를 인덱싱하던 코드를 반복자 기능으로 바꿀 수 있고, Config::build 가 실제로 무엇을 하는지 더 잘 드러납니다.

한 번 Config::build 가 반복자의 소유권을 가져가고 더 이상 인덱싱으로 값을 빌리지 않게 되면, clone 으로 새 할당을 만들지 않고 반복자 안의 String 값 자체를 Config 안으로 이동시킬 수 있습니다.

반환된 반복자를 직접 사용하기

먼저 I/O 프로젝트의 src/main.rs 파일을 열면 현재 내용은 대략 이렇습니다.

파일명: src/main.rs

use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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(())
}

우선 목록 12-24의 main 함수 앞부분을 목록 13-18의 코드처럼 바꾸겠습니다. 이번에는 env::args 가 반환한 반복자를 그대로 Config::build 에 넘깁니다. 물론 Config::build 도 아직 바꾸지 않았으므로, 이 상태에서는 컴파일되지 않습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

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) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

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();

        let ignore_case = env::var("IGNORE_CASE").is_ok();

        Ok(Config {
            query,
            file_path,
            ignore_case,
        })
    }
}

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(())
}
Listing 13-18: env::args 의 반환값을 그대로 Config::build 에 넘기기

env::args 함수는 반복자를 반환합니다! 이제는 그 반복자를 벡터로 모은 뒤 슬라이스를 Config::build 로 넘기지 않고, env::args 가 반환한 반복자 소유권을 바로 Config::build 에 넘깁니다.

이제 Config::build 정의도 바꿔야 합니다. 시그니처를 목록 13-19처럼 수정합시다. 물론 함수 본문도 함께 바꿔야 하므로 아직은 컴파일되지 않습니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    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,
        })
    }
}

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(())
}
Listing 13-19: Config::build 시그니처를 반복자를 받도록 바꾸기

표준 라이브러리의 env::args 문서를 보면, 이 함수가 반환하는 반복자 타입은 std::env::Args 이고, 이 타입은 Iterator 트레이트를 구현하며 String 값을 반환합니다.

우리는 Config::build 시그니처를 바꾸어, args 매개변수가 &[String] 대신 impl Iterator<Item = String> 라는 트레이트 바운드를 가진 제네릭 타입이 되도록 했습니다. 이는 10장의 “트레이트를 매개변수로 사용하기” 절에서 설명한 impl Trait 문법입니다. 즉, argsIterator 트레이트를 구현하고 String 항목을 반환하는 어떤 타입이든 될 수 있다는 뜻입니다.

우리는 args 의 소유권을 가져오고, 실제로 순회하면서 그 내부 상태를 바꿀 것이므로, 매개변수 선언에 mut 키워드를 붙여 args 를 가변으로 만들었습니다.

인덱싱 대신 Iterator 트레이트 메서드 사용하기

이제 Config::build 본문을 고쳐 봅시다. argsIterator 를 구현하므로, 우리는 next 메서드를 호출할 수 있다는 것을 알고 있습니다. 목록 13-20은 목록 12-23의 코드를 next 메서드를 사용하도록 바꾼 예입니다.

Filename: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;

use minigrep::{search, search_case_insensitive};

fn main() {
    let config = Config::build(env::args()).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {err}");
        process::exit(1);
    });

    if let Err(e) = run(config) {
        eprintln!("Application error: {e}");
        process::exit(1);
    }
}

pub struct Config {
    pub query: String,
    pub file_path: String,
    pub ignore_case: bool,
}

impl Config {
    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,
        })
    }
}

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(())
}
Listing 13-20: Config::build 본문을 반복자 메서드 사용 방식으로 바꾸기

env::args 의 첫 번째 값은 프로그램 이름이라는 점을 기억하세요. 우리는 이것은 무시하고 그 다음 값부터 쓰고 싶습니다. 그래서 먼저 next 를 한 번 호출하되 반환값은 버리고, 그 다음 query 필드에 넣을 값을 얻기 위해 다시 next 를 호출합니다. nextSome 을 반환하면 match 로 안의 값을 꺼내 쓰고, None 이면 인수가 부족하다는 뜻이므로 Err 값을 조기 반환합니다. file_path 값에 대해서도 같은 방식을 반복합니다.

반복자 어댑터로 코드 더 명확하게 만들기

우리 I/O 프로젝트 안의 search 함수도 반복자를 사용해 개선할 수 있습니다. 목록 13-21은 목록 12-19 시점 그대로의 search 구현을 다시 보여 줍니다.

Filename: src/lib.rs
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
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 함수에도 같은 변화를 적용해 보세요.

한 단계 더 나아가, search 함수에서 collect 호출을 제거하고 반환 타입을 impl Iterator<Item = &'a str> 로 바꾸면 반복자 자체를 반환할 수도 있습니다. 그러면 테스트도 함께 수정해야 합니다! 이 변경 전과 후에 큰 파일을 minigrep 로 검색해 보며 동작 차이를 관찰해 보세요. 변경 전에는 모든 결과를 다 모을 때까지 아무 것도 출력하지 않지만, 변경 후에는 run 함수의 for 루프가 반복자의 게으른 특성을 활용해, 매칭 줄을 찾자마자 바로 출력할 수 있습니다.

루프와 반복자 중 무엇을 선택할까

그렇다면 자연스럽게 드는 질문은, 여러분 자신의 코드에서는 어떤 스타일을 택해야 하고 왜 그런가 하는 점입니다. 목록 13-21의 원래 구현처럼 명시적인 for 루프를 쓸까요, 아니면 목록 13-22의 반복자 스타일을 쓸까요(여기서는 반복자를 반환하는 대신, 모든 결과를 모아 반환한다고 가정합니다)?

대부분의 Rust 프로그래머는 반복자 스타일을 선호합니다. 처음에는 조금 익숙해지는 데 시간이 걸리지만, 다양한 반복자 어댑터가 무엇을 하고 어떻게 조합되는지 감이 잡히면, 반복자가 오히려 이해하기 더 쉬워집니다. 루프 인덱스와 중간 벡터를 이리저리 다루는 대신, 코드는 “무엇을 하고 싶은가” 라는 더 높은 수준의 목표에 집중하게 됩니다. 이렇게 하면 흔한 반복 코드는 추상화 뒤로 숨겨지고, 대신 이 코드만의 핵심 개념 예를 들어 “각 요소가 어떤 조건을 통과해야 하는가” 가 더 잘 드러납니다.

하지만 두 구현이 성능까지도 정말 비슷할까요? 직관적으로는 “더 저수준인 루프가 더 빠르지 않을까?” 라는 생각이 들 수 있습니다. 이제 성능 이야기를 해 봅시다.

루프와 반복자의 성능 비교

루프와 반복자의 성능 비교

루프를 쓸지 반복자를 쓸지 결정하려면, 둘 중 어느 구현이 더 빠른지도 알아야 합니다. 즉, 명시적인 for 루프를 사용한 search 함수 버전과, 반복자를 사용한 버전 중 어느 쪽이 더 성능이 좋은지 확인해야 합니다.

우리는 Sir Arthur Conan Doyle의 The Adventures of Sherlock Holmes 전체 내용을 하나의 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 로 사용하고, 길이도 다른 여러 질의 문자열을 써 보고, 그 밖의 여러 변형도 실험해 봐야 합니다. 하지만 핵심은 이것입니다. 반복자는 고수준 추상화이지만, 결국은 여러분이 직접 저수준 코드를 썼을 때와 거의 같은 코드로 컴파일됩니다. 반복자는 러스트의 zero-cost abstractions 중 하나입니다. 즉, 그 추상화를 사용한다고 해서 추가적인 런타임 오버헤드가 붙지 않는다는 뜻입니다. 이는 C++의 원 설계자이자 구현자인 Bjarne Stroustrup 이 2012년 ETAPS 기조연설 “Foundations of C++” 에서 zero-overhead 를 정의한 방식과 비슷합니다.

일반적으로 C++ 구현은 zero-overhead 원칙을 따른다. 쓰지 않는 기능에는 비용을 지불하지 않는다. 그리고 더 나아가, 쓰는 기능도 손으로 더 잘 짤 수는 없다.

많은 경우 러스트의 반복자 코드는 사람이 손으로 직접 작성했을 법한 어셈블리와 같은 형태로 컴파일됩니다. 루프 전개(loop unrolling)나 배열 접근의 경계 검사 제거 같은 최적화도 적용되어, 결과 코드는 매우 효율적입니다. 이제 이 사실을 알았으니 클로저와 반복자를 안심하고 사용할 수 있습니다! 코드는 더 높은 수준의 추상화처럼 보이지만, 그 때문에 런타임 성능을 희생하지는 않습니다.

정리

클로저와 반복자는 함수형 프로그래밍 언어 아이디어에서 영감을 받은 러스트 기능입니다. 이들은 러스트가 저수준 성능을 유지하면서도 고수준 개념을 명확하게 표현할 수 있게 해 줍니다. 그리고 클로저와 반복자의 구현 방식은 런타임 성능에 영향을 주지 않습니다. 이것 역시 러스트가 zero-cost abstraction을 제공하려는 목표의 일부입니다.

이제 I/O 프로젝트의 표현력을 한층 끌어올렸으니, 다음으로는 cargo 의 몇 가지 기능을 더 살펴보면서 이 프로젝트를 세상과 공유하는 방법을 알아보겠습니다.

Cargo와 Crates.io 더 알아보기

지금까지 우리는 Cargo의 가장 기본적인 기능만 사용해 코드를 빌드하고 실행하고 테스트해 보았습니다. 하지만 Cargo는 훨씬 더 많은 일을 할 수 있습니다. 이 장에서는 비교적 고급 기능 몇 가지를 설명하면서 다음을 어떻게 하는지 보여 줍니다.

  • 릴리스 프로필을 사용해 빌드 방식을 커스터마이징하기
  • crates.io 에 라이브러리 배포하기
  • 워크스페이스로 큰 프로젝트 조직하기
  • crates.io 에서 바이너리 설치하기
  • 커스텀 명령으로 Cargo 확장하기

Cargo는 이 장에서 다루는 기능보다 훨씬 더 많은 일을 할 수 있습니다. 전체 기능에 대한 자세한 설명은 Cargo 문서를 참고하세요.

릴리스 프로필로 빌드 커스터마이징하기

릴리스 프로필을 통한 빌드 커스터마이징하기

러스트에서 릴리스 프로필(release profiles) 은 서로 다른 설정을 가진, 미리 정의된 커스터마이징 가능한 프로필입니다. 이를 통해 프로그래머는 코드 컴파일 옵션을 더 세밀하게 제어할 수 있습니다. 각 프로필은 다른 프로필과 독립적으로 설정됩니다.

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.toml 안에 [profile.*] 섹션을 명시적으로 추가하지 않았다면, Cargo는 각 프로필의 기본 설정을 사용합니다. 원하는 프로필에 대해 [profile.*] 섹션을 추가하면, 기본 설정 중 필요한 것만 골라 덮어쓸 수 있습니다. 예를 들어 devrelease 프로필에서 opt-level 설정의 기본값은 다음과 같습니다.

Filename: Cargo.toml

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

opt-level 설정은 러스트가 여러분의 코드에 얼마나 많은 최적화를 적용할지를 제어하며, 값 범위는 0부터 3까지입니다. 더 많은 최적화를 적용하면 컴파일 시간은 길어집니다. 따라서 개발 중에는 코드를 자주 컴파일하므로, 결과 코드가 조금 느리더라도 더 적은 최적화를 적용해 더 빨리 컴파일하는 편이 좋습니다. 그래서 dev 의 기본 opt-level0 입니다. 반대로 코드를 배포할 준비가 되었을 때는 컴파일에 조금 더 시간을 쓰는 것이 좋습니다. 릴리스 모드로는 한 번만 컴파일하지만, 그 결과물은 여러 번 실행될 것이기 때문입니다. 그래서 release 모드는 컴파일 시간이 더 긴 대신, 더 빠르게 실행되는 코드를 만들어 냅니다. 이것이 release 프로필의 기본 opt-level3 인 이유입니다.

기본 설정을 바꾸고 싶다면 Cargo.toml 안에 다른 값을 적어 덮어쓸 수 있습니다. 예를 들어 개발 프로필에서 최적화 수준 1을 쓰고 싶다면, 프로젝트의 Cargo.toml 에 다음 두 줄을 추가하면 됩니다.

Filename: Cargo.toml

[profile.dev]
opt-level = 1

이 코드는 기본값 0 을 덮어씁니다. 이제 cargo build 를 실행하면 Cargo는 dev 프로필의 기본 설정에 더해 우리가 지정한 opt-level 값을 사용합니다. opt-level1 로 설정했으므로, 기본값보다는 더 많은 최적화를 적용하지만 릴리스 빌드만큼 많이 하지는 않습니다.

각 프로필의 전체 설정 목록과 기본값은 Cargo 문서를 참고하세요.

Crates.io에 크레이트 배포하기

Crates.io에 크레이트 배포하기

지금까지 우리는 crates.io의 패키지를 프로젝트 의존성으로 사용해 왔습니다. 하지만 여러분 자신의 패키지를 배포해 다른 사람과 코드를 공유할 수도 있습니다. crates.io 에 있는 크레이트 레지스트리는 패키지의 소스 코드를 배포하므로, 주로 오픈 소스 코드를 호스팅합니다.

러스트와 Cargo는 여러분이 배포한 패키지를 다른 사람들이 더 쉽게 찾고 사용할 수 있도록 해 주는 기능을 갖추고 있습니다. 이제 그런 기능들을 몇 가지 살펴본 뒤, 실제로 패키지를 배포하는 방법을 설명하겠습니다.

유용한 문서 주석 달기

패키지에 대해 정확히 문서화해 두면 다른 사용자가 언제 어떻게 써야 하는지 이해하는 데 큰 도움이 되므로, 문서를 작성하는 데 시간을 투자할 가치가 있습니다. 3장에서는 // 를 사용해 러스트 코드에 주석을 다는 방법을 다뤘습니다. 러스트에는 문서를 위한 특별한 종류의 주석도 있는데, 이름 그대로 문서 주석(documentation comment) 입니다. 이 주석은 HTML 문서를 생성합니다. 그 HTML 은 크레이트가 어떻게 구현되었는지 보다는 크레이트를 어떻게 사용해야 하는지 를 알고 싶은 프로그래머를 위한 공개 API 항목의 문서 내용을 보여 줍니다.

문서 주석은 슬래시 두 개가 아니라 세 개, /// 를 사용하고, 텍스트 형식을 위해 Markdown 문법을 지원합니다. 문서화하려는 항목 바로 위에 문서 주석을 둡니다. 목록 14-1은 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 을 실행하면 됩니다. 이 명령은 러스트와 함께 배포되는 rustdoc 도구를 실행하고, 생성한 HTML 문서를 target/doc 디렉터리에 넣습니다.

편의를 위해 cargo doc --open 을 실행하면 현재 크레이트 문서(그리고 의존성 문서도 함께)를 HTML 로 빌드한 뒤, 결과를 웹 브라우저로 열어 줍니다. add_one 함수로 이동해 보면, 문서 주석의 텍스트가 그림 14-1처럼 렌더링된 것을 확인할 수 있습니다.

Rendered HTML documentation for the `add_one` function of `my_crate`

그림 14-1: add_one 함수에 대한 HTML 문서

자주 쓰는 섹션들

목록 14-1에서는 # Examples 라는 Markdown 제목을 사용해 HTML 안에 “Examples” 라는 섹션을 만들었습니다. 크레이트 작성자들이 문서에 자주 넣는 다른 섹션은 다음과 같습니다.

  • Panics: 문서화하는 함수가 어떤 상황에서 패닉할 수 있는지를 설명합니다. 자신의 프로그램이 패닉하지 않기를 원하는 호출자는 이런 상황에서 그 함수를 호출하지 않도록 주의해야 합니다.
  • Errors: 함수가 Result 를 반환한다면, 어떤 종류의 에러가 일어날 수 있고 어떤 조건에서 그런 에러가 반환되는지 설명해 주면 호출자가 상황별로 다르게 대응하는 코드를 작성하는 데 도움이 됩니다.
  • Safety: 함수가 unsafe 하다면(이는 20장에서 다룹니다), 왜 unsafe 한지와, 호출자가 반드시 지켜야 하는 불변 조건을 설명하는 섹션이 있어야 합니다.

대부분의 문서 주석이 이 모든 섹션을 필요로 하지는 않지만, 사용자 입장에서 어떤 정보를 궁금해할지 떠올리는 체크리스트로는 아주 좋습니다.

문서 주석을 테스트로 사용하기

문서 주석 안에 예제 코드 블록을 넣으면 라이브러리 사용법을 보여 주는 데 도움이 될 뿐 아니라, 추가 보너스도 있습니다. 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 크레이트의 목적을 설명하는 문서를 추가하고 싶다면, 목록 14-2처럼 src/lib.rs 파일 맨 앞에 //! 로 시작하는 문서 주석을 적습니다.

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 을 실행하면, 이 주석들은 그림 14-2처럼 크레이트 공개 항목 목록 위쪽의 문서 첫 화면에 표시됩니다.

크레이트나 모듈을 설명할 때는 이런 “포함한 항목 문서 주석” 이 특히 유용합니다. 이 주석은 컨테이너 전체의 목적을 설명하는 데 쓰면, 사용자가 크레이트의 구조를 더 쉽게 이해할 수 있습니다.

Rendered HTML documentation with a comment for the crate as a whole

그림 14-2: 크레이트 전체를 설명하는 주석을 포함한 my_crate 문서

편리한 공개 API 내보내기

크레이트를 배포할 때 공개 API 구조는 매우 중요한 고려사항입니다. 여러분은 크레이트 구조를 잘 알고 있겠지만, 사용하는 사람은 그렇지 않습니다. 특히 크레이트 안에 큰 모듈 계층이 있을 경우, 사용자는 자신이 쓰고 싶은 타입이 어디 있는지 찾기 어려울 수 있습니다.

7장에서는 pub 키워드로 항목을 공개하는 방법과, use 키워드로 항목을 스코프 안으로 가져오는 방법을 설명했습니다. 하지만 여러분이 개발하는 동안 편하게 느껴지는 구조가, 사용자에게도 편한 구조라는 보장은 없습니다. 예를 들어 구조체를 여러 단계의 계층 안에 정리해 두고 싶을 수 있지만, 사용자는 그 깊은 계층 안에 있는 타입이 존재하는지 알아내기조차 어려울 수 있습니다. 또 use my_crate::some_module::another_module::UsefulType; 같은 긴 경로를 써야 하는 것에도 짜증을 느낄 수 있습니다. use my_crate::UsefulType; 가 훨씬 더 편하기 때문입니다.

좋은 소식은, 외부에서 사용하기에 구조가 불편하다면 내부 구조를 반드시 뜯어고칠 필요는 없다는 점입니다. 대신 pub use 를 사용해 내부 구조와는 다른 공개 구조를 만들 수 있습니다. 재수출(re-exporting) 이란 어떤 위치의 public 항목을 다른 위치에서도 public 하게 만들어, 마치 원래 그 다른 위치에서 정의된 것처럼 보이게 하는 것입니다.

예를 들어 예술 개념을 모델링하는 art 라는 라이브러리를 만들었다고 해 봅시다. 이 라이브러리 안에는 PrimaryColorSecondaryColor 라는 두 enum을 담은 kinds 모듈과, mix 함수를 담은 utils 모듈이 있다고 합시다. 목록 14-3이 그 구조를 보여 줍니다.

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 으로 생성한 이 크레이트의 문서 첫 화면이 어떤 모습일지를 보여 줍니다.

Rendered documentation for the `art` crate that lists the `kinds` and `utils` modules

그림 14-3: kindsutils 모듈만 보이는 art 크레이트 문서 첫 화면

여기서는 PrimaryColor, SecondaryColor, mix 가 첫 화면에 보이지 않습니다. 사용자는 그것들을 보려면 kindsutils 를 직접 클릭해 들어가야 합니다.

이 라이브러리에 의존하는 다른 크레이트는, 현재 정의된 모듈 구조를 그대로 따라 use 문을 써야만 art 의 항목을 스코프로 가져올 수 있습니다. 목록 14-4는 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 크레이트 항목 사용하기

목록 14-4의 코드를 작성한 사람은 PrimaryColorkinds 모듈 안에 있고, mixutils 모듈 안에 있다는 사실을 알아내야 했습니다. 그런데 art 크레이트의 모듈 구조는, art 를 사용하는 사람보다 art 를 개발하는 사람에게 더 중요한 정보일 수 있습니다. 이 내부 구조는 사용자가 art 를 어떻게 써야 하는지 이해하는 데 큰 도움은 되지 않지만, 대신 어디를 찾아야 하는지 먼저 파악해야 하고 use 문에도 모듈 이름을 모두 적어야 하므로 오히려 혼란을 줍니다.

이 내부 조직을 공개 API에서 제거하려면, 목록 14-3의 art 크레이트 코드에 pub use 문을 추가해 항목을 최상위 레벨로 재수출할 수 있습니다. 목록 14-5를 보세요.

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처럼 문서 첫 화면에 재수출된 항목들을 직접 보여 주고 링크도 걸어 줍니다. 덕분에 PrimaryColor, SecondaryColor, mix 를 훨씬 쉽게 찾을 수 있습니다.

Rendered documentation for the `art` crate with the re-exports on the front page

그림 14-4: 첫 화면에 재수출 항목이 나타나는 art 크레이트 문서

art 크레이트 사용자는 여전히 목록 14-3의 내부 구조를 따라 목록 14-4 같은 방식으로 코드를 쓸 수도 있고, 목록 14-5에서 만든 더 편한 구조를 사용해 목록 14-6처럼 쓸 수도 있습니다.

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.toml 에 저장합니다. 이 토큰은 비밀 값이라는 점에 주의하세요. 절대 다른 사람과 공유하지 마세요. 어떤 이유로든 토큰을 공유했다면, crates.io 에서 그 토큰을 폐기(revoke)하고 새 토큰을 발급받아야 합니다.

새 크레이트에 메타데이터 추가하기

배포하고 싶은 크레이트가 있다고 합시다. 배포하기 전에, 그 크레이트의 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

에러가 나는 이유는 중요한 정보 몇 가지가 빠져 있기 때문입니다. 설명(description)과 라이선스는 필수입니다. 그래야 다른 사람이 여러분의 크레이트가 무엇을 하는지, 어떤 조건으로 사용할 수 있는지 알 수 있습니다. Cargo.toml 에는 검색 결과에 표시될 한두 문장 정도의 설명을 넣어야 합니다. license 필드에는 라이선스 식별자 값 을 넣어야 합니다. 이 값으로 어떤 식별자를 사용할 수 있는지는 [Linux Foundation의 SPDX] spdx 목록에서 확인할 수 있습니다. 예를 들어 MIT 라이선스로 크레이트를 배포한다면 MIT 식별자를 추가하면 됩니다.

파일명: Cargo.toml

[package]
name = "guessing_game"
license = "MIT"

SPDX 목록에 없는 라이선스를 쓰고 싶다면, 그 라이선스 전문을 프로젝트 안 파일로 넣고, license 대신 license-file 키로 그 파일 이름을 지정해야 합니다.

어떤 라이선스가 여러분 프로젝트에 적절한지는 이 책의 범위를 벗어납니다. 다만 러스트 커뮤니티의 많은 사람들은 러스트 자체와 같은 방식으로 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)
    Packaged 6 files, 1.2KiB (895.0B compressed)
   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)
    Uploaded guessing_game v0.1.0 to registry `crates-io`
note: waiting for `guessing_game v0.1.0` to be available at registry
`crates-io`.
You may press ctrl-c to skip waiting; the crate should be available shortly.
   Published guessing_game v0.1.0 at registry `crates-io`

축하합니다! 이제 여러분의 코드는 러스트 커뮤니티와 공유되었고, 누구든 자신의 프로젝트 의존성에 이 크레이트를 쉽게 추가할 수 있게 되었습니다.

기존 크레이트의 새 버전 배포하기

크레이트를 수정한 뒤 새 버전을 공개할 준비가 되면, Cargo.toml 안의 version 값을 바꾸고 다시 배포하면 됩니다. 어떤 종류의 변경을 했는지에 따라 다음 버전 번호를 어떻게 정할지는 시맨틱 버저닝 규칙을 따르세요. 그다음 cargo publish 를 실행해 새 버전을 업로드하면 됩니다.

Crates.io 버전 폐기하기

크레이트의 이전 버전을 삭제할 수는 없지만, 앞으로 새 프로젝트가 그 버전을 새 의존성으로 추가하지 못하게 막을 수는 있습니다. 어떤 이유로든 특정 버전이 깨져 버렸을 때 유용한 기능입니다. 이런 상황에서 Cargo는 크레이트 버전을 yank 하는 기능을 제공합니다.

버전을 yank 하면, 새로운 프로젝트가 그 버전에 의존하지 못하게 되지만, 이미 그 버전에 의존하고 있는 기존 프로젝트는 계속 그대로 동작합니다. 요컨대 yank는 Cargo.lock 을 가진 기존 프로젝트를 깨뜨리지 않으면서, 앞으로 생성될 새로운 Cargo.lock 이 그 버전을 더 이상 사용하지 않게 만드는 동작입니다.

크레이트 버전을 yank 하려면, 예전에 배포했던 그 크레이트 디렉터리에서 cargo yank 를 실행하고 어떤 버전을 yank 할지 지정하면 됩니다. 예를 들어 guessing_game 크레이트의 1.0.1 버전을 배포했는데, 그것을 yank 하고 싶다면 프로젝트 디렉터리에서 다음처럼 실행합니다.

$ cargo yank --vers 1.0.1
    Updating crates.io index
        Yank guessing_game@1.0.1

명령에 --undo 를 붙이면 yank를 되돌려, 그 버전에 다시 의존할 수 있게 만들 수도 있습니다.

$ cargo yank --vers 1.0.1 --undo
    Updating crates.io index
      Unyank guessing_game@1.0.1

yank는 코드를 삭제하는 것이 아닙니다. 예를 들어 실수로 비밀 값을 업로드했다면, yank 로는 그 비밀 값을 지울 수 없습니다. 그런 경우에는 즉시 그 비밀 값을 교체해야 합니다.

Cargo 워크스페이스

Cargo 워크스페이스

12장에서는 하나의 바이너리 크레이트와 하나의 라이브러리 크레이트를 포함한 패키지를 만들었습니다. 프로젝트가 발전하다 보면 라이브러리 크레이트가 점점 커지고, 패키지를 여러 개의 라이브러리 크레이트로 더 쪼개고 싶어질 수 있습니다. Cargo는 함께 개발되는 여러 관련 패키지를 관리하는 데 도움이 되는 워크스페이스(workspaces) 기능을 제공합니다.

워크스페이스 만들기

워크스페이스(workspace) 는 같은 Cargo.lock 과 출력 디렉터리를 공유하는 패키지 집합입니다. 워크스페이스를 사용하는 프로젝트를 하나 만들어 봅시다. 코드 자체는 아주 단순하게 유지해서, 워크스페이스 구조에만 집중하겠습니다. 워크스페이스를 구성하는 방법은 여러 가지가 있지만, 여기서는 흔한 방식 하나만 보여 줍니다. 바이너리 하나와 라이브러리 둘을 포함한 워크스페이스를 만들 것입니다. 메인 기능을 제공하는 바이너리는 두 라이브러리에 의존합니다. 한 라이브러리는 add_one 함수를, 다른 라이브러리는 add_two 함수를 제공합니다. 이 세 크레이트는 같은 워크스페이스 안에 속하게 됩니다. 먼저 워크스페이스용 새 디렉터리를 만듭니다.

$ mkdir add
$ cd add

다음으로 add 디렉터리 안에 워크스페이스 전체를 설정할 Cargo.toml 파일을 만듭니다. 이 파일에는 [package] 섹션이 없습니다. 대신 [workspace] 섹션으로 시작하고, 여기에 워크스페이스 구성원을 추가합니다. 또한 Cargo의 resolver 알고리즘은 최신 버전을 쓰도록 resolver 값을 "3" 으로 지정합니다.

파일명: Cargo.toml

[workspace]
resolver = "3"

이제 add 디렉터리 안에서 cargo new 를 실행해 adder 바이너리 크레이트를 만들겠습니다.

$ cargo new adder
     Created binary (application) `adder` package
      Adding `adder` as member of workspace at `file:///projects/add`

워크스페이스 안에서 cargo new 를 실행하면, Cargo는 새로 만든 패키지를 자동으로 워크스페이스 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
     Created library `add_one` package
      Adding `add_one` as member of workspace at `file:///projects/add`

이제 최상위 Cargo.tomlmembers 목록에는 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.tomladd_one 에 대한 path 의존성을 추가해야 합니다.

파일명: adder/Cargo.toml

[dependencies]
add_one = { path = "../add_one" }

Cargo는 워크스페이스 안의 크레이트들이 자동으로 서로 의존할 것이라고 가정하지 않기 때문에, 이런 의존성 관계는 직접 명시해야 합니다.

그 다음 adder/src/main.rs 파일을 열고, 목록 14-7처럼 add_one 크레이트의 add_one 함수를 호출하도록 main 을 바꿉니다.

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.tomladd_one/Cargo.toml 모두에 rand 패키지를 추가하면, Cargo는 둘 모두를 하나의 rand 버전으로 해결하고, 그 버전을 단 하나의 Cargo.lock 에 기록합니다. 워크스페이스 안의 모든 크레이트가 같은 의존성 버전을 사용하면, 서로 항상 호환 가능하다는 장점도 있습니다. 이제 add_one/Cargo.toml[dependencies] 섹션에 rand 를 추가해, add_one 크레이트 안에서 rand 를 사용할 수 있게 해 봅시다.

파일명: add_one/Cargo.toml

[dependencies]
rand = "0.8.5"

이제 add_one/src/lib.rsuse 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 가 워크스페이스 어딘가에서 쓰이고 있다고 해서, 다른 크레이트에서 자동으로 사용할 수 있는 것은 아닙니다. 예를 들어 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.lockadder 에 대한 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로 바이너리 설치하기

cargo install 명령은 바이너리 크레이트를 로컬에 설치하고 사용할 수 있게 해 줍니다. 이것은 시스템 패키지를 대체하려는 목적이 아니라, 다른 사람들이 crates.io에 공유한 도구를 러스트 개발자가 편리하게 설치할 수 있게 하려는 기능입니다. 다만 설치할 수 있는 것은 바이너리 타깃을 가진 패키지뿐입니다. 바이너리 타깃(binary target) 은 크레이트에 src/main.rs 파일이나 바이너리로 지정된 다른 파일이 있을 때 만들어지는 실행 가능한 프로그램을 뜻합니다. 반면 라이브러리 타깃은 그 자체로 실행되지는 않지만 다른 프로그램 안에 포함하기에 적합합니다. 보통 크레이트 README 파일에는 그 크레이트가 라이브러리인지, 바이너리 타깃이 있는지, 혹은 둘 다인지에 대한 정보가 들어 있습니다.

cargo install 로 설치한 모든 바이너리는 설치 루트의 bin 폴더에 저장됩니다. 만약 rustup.rs 로 러스트를 설치했고 별도 설정을 바꾸지 않았다면, 이 디렉터리는 $HOME/.cargo/bin 입니다. cargo install 로 설치한 프로그램을 실행할 수 있도록 이 디렉터리가 반드시 $PATH 안에 들어 있어야 합니다.

예를 들어 12장에서 파일 검색용 grep 도구의 러스트 구현체로 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 를 실행하고 더 빠르고 더 러스트다운 파일 검색 도구를 바로 사용할 수 있습니다!

커스텀 명령으로 Cargo 확장하기

커스텀 명령으로 Cargo 확장하기

Cargo는 본체를 수정하지 않고도 새로운 하위 명령으로 확장할 수 있게 설계되어 있습니다. 만약 여러분의 $PATH 안에 cargo-something 이라는 이름의 바이너리가 있다면, cargo something 이라고 실행해서 그것을 마치 Cargo의 하위 명령처럼 사용할 수 있습니다. 이런 커스텀 명령은 cargo --list 를 실행했을 때 목록에도 함께 나타납니다. cargo install 로 확장 명령을 설치하고, 그것을 내장 Cargo 도구와 거의 같은 방식으로 바로 실행할 수 있다는 점은 Cargo 설계의 아주 편리한 장점입니다.

정리

Cargo와 crates.io를 통한 코드 공유는, 러스트 생태계가 다양한 작업에 유용한 이유의 중요한 일부입니다. 러스트 표준 라이브러리는 작고 안정적이지만, 크레이트는 언어 자체와는 다른 속도로 쉽게 공유되고 사용되고 개선될 수 있습니다. 여러분에게 유용한 코드를 crates.io에 올리는 것을 주저하지 마세요. 아마 다른 사람에게도 분명 유용할 것입니다!

스마트 포인터

포인터는 메모리 안의 주소를 담는 변수에 대한 일반적인 개념입니다. 이 주소는 다른 어떤 데이터를 가리킵니다. 러스트에서 가장 흔한 포인터 종류는 4장에서 배운 참조입니다. 참조는 & 기호로 표시하며, 자신이 가리키는 값을 빌려옵니다. 데이터를 가리킨다는 것 외에는 특별한 기능이 없고, 추가 비용도 거의 없습니다.

반면 스마트 포인터(smart pointers) 는 포인터처럼 동작하지만, 추가 메타데이터와 기능까지 갖춘 자료구조입니다. 스마트 포인터라는 개념은 러스트만의 것이 아닙니다. 스마트 포인터는 원래 C++ 에서 시작되었고 다른 언어에도 존재합니다. 러스트는 표준 라이브러리에 여러 종류의 스마트 포인터를 제공하며, 이들은 참조보다 더 많은 기능을 가집니다. 이 장에서는 스마트 포인터의 일반 개념을 이해하기 위해, 참조 카운팅 스마트 포인터를 포함한 몇 가지 예를 살펴봅니다. 이 포인터는 소유자 수를 추적하고, 소유자가 하나도 남지 않으면 데이터를 정리함으로써, 하나의 데이터에 여러 소유자가 있을 수 있게 해 줍니다.

러스트에서는 소유권과 대여 개념 때문에, 참조와 스마트 포인터 사이에 추가적인 차이도 있습니다. 참조는 단지 데이터를 빌릴 뿐이지만, 스마트 포인터는 많은 경우 자신이 가리키는 데이터를 소유 합니다.

스마트 포인터는 보통 구조체를 사용해 구현됩니다. 일반 구조체와 달리, 스마트 포인터는 DerefDrop 트레이트를 구현합니다. Deref 트레이트는 스마트 포인터 구조체의 인스턴스를 참조처럼 동작하게 만들어, 코드가 참조와 스마트 포인터 모두에 대해 동작할 수 있게 해 줍니다. Drop 트레이트는 스마트 포인터 인스턴스가 스코프를 벗어날 때 실행되는 코드를 원하는 대로 지정할 수 있게 해 줍니다. 이 장에서는 이 두 트레이트를 모두 다루고, 왜 스마트 포인터에 중요한지를 설명합니다.

스마트 포인터 패턴은 러스트에서 자주 쓰이는 일반적인 설계 패턴이기 때문에, 이 장에서 존재하는 모든 스마트 포인터를 다루지는 않습니다. 많은 라이브러리가 자기만의 스마트 포인터를 가지고 있고, 여러분이 직접 만들 수도 있습니다. 여기서는 표준 라이브러리의 가장 흔한 스마트 포인터를 다룹니다.

  • Box<T>: 힙에 값을 할당할 때 사용
  • Rc<T>: 여러 소유권을 가능하게 하는 참조 카운팅 타입
  • RefCell<T> 를 통해 접근하는 Ref<T>RefMut<T>: 컴파일 시가 아니라 런타임에 대여 규칙을 검사하는 타입

추가로, 불변 타입이 내부 값 변경용 API를 노출하는 내부 가변성(interior mutability) 패턴도 다루고, 참조 사이클이 어떻게 메모리 누수를 만들 수 있는지, 그리고 이를 어떻게 방지하는지도 살펴봅니다.

이제 시작해 봅시다!

Box<T>로 힙의 데이터를 가리키기

Box<T>를 사용하여 힙에 있는 데이터 가리키기

가장 단순한 스마트 포인터는 박스(box)이며, 타입 표기는 Box<T> 입니다. 박스 는 스택이 아니라 힙에 데이터를 저장하게 해 줍니다. 스택에는 힙 데이터를 가리키는 포인터만 남습니다. 스택과 힙의 차이는 4장을 떠올려 보세요.

박스는 데이터를 스택이 아니라 힙에 저장한다는 점 외에는 특별한 성능 오버헤드가 없습니다. 그렇다고 특별한 기능이 많은 것도 아닙니다. 보통 다음과 같은 상황에서 가장 자주 사용합니다.

  • 컴파일 시점에 크기를 알 수 없는 타입이 있고, 그 타입의 값을 정확한 크기를 요구하는 문맥 안에서 사용하고 싶을 때
  • 큰 데이터를 가지고 있는데 소유권은 이전하고 싶되, 그 과정에서 데이터 복사는 피하고 싶을 때
  • 어떤 값을 소유하고 싶지만, 구체적인 타입보다 특정 트레이트를 구현한 타입이라는 사실에만 관심이 있을 때

첫 번째 상황은 “박스로 재귀 타입 가능하게 만들기” 에서 보여 줍니다. 두 번째 경우에는 큰 데이터를 스택 위에서 복사하며 소유권을 옮기는 일이 오래 걸릴 수 있습니다. 이런 상황에서는 큰 데이터를 박스 안에 넣어 힙에 저장하면 성능이 좋아집니다. 그러면 스택에서는 작은 포인터 데이터만 복사되고, 실제 데이터는 힙의 같은 위치에 그대로 남습니다. 세 번째 경우는 트레이트 객체 라고 부르며, 18장의 “공통 동작을 추상화하기 위해 트레이트 객체 사용하기” 절에서 자세히 다룹니다. 그러니 여기서 배우는 내용은 나중에 다시 그대로 활용하게 됩니다!

힙에 데이터 저장하기

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 를 출력합니다. 여기서는 박스 안의 데이터에 접근하는 방식이, 그 데이터가 스택에 있을 때와 거의 비슷합니다. 다른 어떤 소유 값과 마찬가지로, bmain 끝에서 스코프를 벗어나면 박스도 해제됩니다. 이때 스택에 저장된 박스 자체와, 박스가 가리키는 힙 데이터 모두가 함께 정리됩니다.

값 하나를 힙에 두는 것만으로는 그다지 유용하지 않아서, 실제로 이런 식으로 박스만 단독으로 쓰는 경우는 많지 않습니다. 단일 i32 같은 값은 기본적으로 스택에 저장되며, 대부분의 상황에서는 그것이 더 적절합니다. 이제 박스가 아니었다면 정의할 수 없었을 타입을 어떻게 가능하게 하는지 살펴보겠습니다.

박스로 재귀 타입 가능하게 만들기

재귀 타입(recursive type) 의 값은 자기 자신의 타입 값을 다시 일부로 포함할 수 있습니다. 재귀 타입은 러스트에 문제를 일으키는데, 러스트는 컴파일 시점에 어떤 타입이 차지하는 메모리 크기를 알아야 하기 때문입니다. 그러나 재귀 타입의 값은 이론상 무한히 중첩될 수 있으므로, 러스트는 그 값이 얼마만큼의 공간을 필요로 하는지 알 수 없습니다. 박스는 크기가 알려져 있으므로, 재귀 타입 정의 안에 박스를 넣어 주면 재귀 타입도 표현할 수 있게 됩니다.

재귀 타입의 예로 cons list를 살펴보겠습니다. cons list는 Lisp 계열 함수형 언어에서 자주 등장하는 자료구조입니다. 우리가 여기서 정의할 cons list 타입은 재귀 부분을 빼면 단순하므로, 이번 예제에서 배우는 개념은 더 복잡한 재귀 타입을 다룰 때도 유용하게 적용됩니다.

cons list 이해하기

Cons list 는 Lisp 프로그래밍 언어와 그 방언에서 온 자료구조로, 중첩된 쌍들로 이루어져 있고, Lisp에서 연결 리스트에 해당하는 구조입니다. 이름은 Lisp의 cons 함수(construct function의 줄임말)에서 왔습니다. 이 함수는 두 인수로부터 새로운 쌍을 만듭니다. 값 하나와 또 다른 쌍으로 이루어진 쌍에 대해 cons 를 반복 호출하면, 재귀적인 쌍들로 구성된 cons list를 만들 수 있습니다.

예를 들어 리스트 1, 2, 3 을 가진 cons list를 괄호로 표현하면 다음과 비슷합니다.

(1, (2, (3, Nil)))

cons list의 각 항목은 두 요소를 담습니다. 현재 항목의 값과, 다음 항목의 값입니다. 리스트 마지막 항목은 다음 항목 없이 Nil 이라는 값만 가집니다. cons list는 cons 함수를 재귀적으로 호출해 만들어집니다. 재귀의 바닥 조건을 나타내는 표준적인 이름이 Nil 입니다. 이것은 6장에서 다룬 “null” 혹은 “nil” 개념과는 다릅니다. 여기의 Nil 은 잘못된 값이나 부재를 뜻하는 것이 아닙니다.

cons list는 러스트에서 아주 흔한 자료구조는 아닙니다. 러스트에서 항목 목록을 다룰 때는 대부분 Vec<T> 가 더 적합합니다. 하지만 더 복잡한 재귀 자료구조는 여러 상황에서 유용합니다. 이 장에서는 cons list부터 시작하면, 박스가 재귀 자료구조를 어떻게 가능하게 하는지를 큰 방해 요소 없이 살펴볼 수 있습니다.

목록 15-2는 cons list를 표현하기 위한 enum 정의입니다. 이 코드는 아직 컴파일되지 않습니다. List 타입의 크기를 알 수 없기 때문이며, 곧 그 이유를 확인하겠습니다.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}
Listing 15-2: i32 값을 담는 cons list 자료구조를 표현하려는 첫 번째 enum 정의 시도

Note: 이 예제에서는 단순화를 위해 i32 값만 담는 cons list를 구현합니다. 10장에서 논의한 제네릭을 사용했다면 어떤 타입이든 저장할 수 있는 cons list 타입으로 만들 수도 있었습니다.

1, 2, 3 리스트를 List 타입으로 저장하면 목록 15-3처럼 보이게 됩니다.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

// --snip--

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}
Listing 15-3: 리스트 1, 2, 3 을 저장하기 위해 List enum 사용하기

첫 번째 Cons 값은 1 과 또 다른 List 값을 담습니다. 그 List 값은 2 와 또 다른 List 를 담는 또 하나의 Cons 입니다. 그리고 그 List 값은 다시 3 과 마지막 List 값 하나를 담는 Cons 인데, 마지막 List 값은 재귀가 아닌 variant인 Nil 로, 리스트 끝을 표시합니다.

목록 15-3의 코드를 컴파일하려 하면 목록 15-4의 오류를 보게 됩니다.

$ 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: 재귀 enum을 정의하려 할 때 얻는 오류

오류는 이 타입이 “무한한 크기”를 가진다고 말합니다. 그 이유는 List 를 재귀적인 variant를 가진 타입으로 정의했기 때문입니다. 즉, 자기 자신 타입의 값을 직접 다시 안에 담고 있습니다. 그 결과 러스트는 List 값을 저장하는 데 얼마만큼의 공간이 필요한지 계산할 수 없습니다. 왜 이런 오류가 나는지 하나씩 뜯어 봅시다. 먼저는 비재귀 타입의 크기를 러스트가 어떻게 계산하는지 살펴보겠습니다.

비재귀 타입의 크기 계산하기

6장에서 enum을 설명할 때 목록 6-2에서 정의했던 Message enum을 떠올려 보세요.

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

러스트가 Message 값에 얼마만큼의 공간을 할당할지 결정하려면, 먼저 각 variant를 살펴 가장 많은 공간이 필요한 variant가 무엇인지 확인합니다. Message::Quit 는 공간이 거의 필요 없고, Message::Move 는 두 개의 i32 를 담을 만큼의 공간이 필요합니다. 이런 식으로 각 variant를 비교합니다. 결국 어떤 순간에도 실제로 쓰이는 variant는 하나뿐이므로, Message 값 전체에 필요한 공간은 그중 가장 큰 variant를 저장하는 데 필요한 공간이면 됩니다.

이것을 목록 15-2의 List 같은 재귀 타입과 비교해 봅시다. 컴파일러는 먼저 Cons variant를 보는데, 이것은 i32 값 하나와 List 값 하나를 담습니다. 따라서 Consi32 크기 + List 크기만큼의 공간이 필요합니다. 그런데 List 타입이 얼마나 큰지 알아내려면, 컴파일러는 다시 List 의 variant를 들여다봐야 하고, 다시 Cons variant를 보게 됩니다. 그런데 Cons 는 또 i32List 를 포함하고 있으므로, 이 과정은 그림 15-1처럼 무한히 반복됩니다.

An infinite Cons list: a rectangle labeled 'Cons' split into two smaller rectangles. The first smaller rectangle holds the label 'i32', and the second smaller rectangle holds the label 'Cons' and a smaller version of the outer 'Cons' rectangle. The 'Cons' rectangles continue to hold smaller and smaller versions of themselves until the smallest comfortably sized rectangle holds an infinity symbol, indicating that this repetition goes on forever.

그림 15-1: 무한한 Cons variant로 이루어진 무한한 List

알려진 크기를 가진 재귀 타입 만들기

러스트가 재귀적으로 정의된 타입에 필요한 공간을 계산할 수 없기 때문에, 컴파일러는 도움이 되는 다음 제안을 담은 오류를 냅니다.

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

여기서 간접화(indirection) 란, 값을 직접 저장하는 대신 그 값을 가리키는 포인터를 저장하라는 뜻입니다.

Box<T> 는 포인터이므로, 러스트는 Box<T> 가 차지하는 공간이 얼마인지 항상 알 수 있습니다. 포인터 크기는 포인터가 가리키는 데이터 양에 따라 달라지지 않기 때문입니다. 따라서 Cons variant 안에 또 다른 List 값을 직접 넣는 대신, Box<T> 를 넣을 수 있습니다. 그러면 Box<T>Cons variant 안이 아니라 힙에 있는 다음 List 값을 가리키게 됩니다. 개념적으로는 여전히 “리스트가 다른 리스트를 담고 있는” 구조이지만, 구현 차원에서는 이제 값을 서로 안에 넣어 중첩하는 대신, 포인터로 연결 하게 된 것입니다.

목록 15-2의 List 정의와 목록 15-3의 사용 코드를 목록 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 variant는 i32 크기와 박스 포인터 데이터 크기만큼의 공간이 필요합니다. Nil variant는 아무 값도 저장하지 않으므로 Cons 보다 적은 스택 공간만 필요합니다. 이제 우리는 어떤 List 값이든 i32 하나와 박스 포인터 하나의 크기를 차지한다는 사실을 알 수 있습니다. 박스를 사용함으로써 무한 재귀 사슬을 끊었기 때문에, 컴파일러는 마침내 List 값에 필요한 크기를 계산할 수 있게 됩니다. 그림 15-2는 Cons variant가 이제 어떤 형태로 보이는지 보여 줍니다.

A rectangle labeled 'Cons' split into two smaller rectangles. The first smaller rectangle holds the label 'i32', and the second smaller rectangle holds the label 'Box' with one inner rectangle that contains the label 'usize', representing the finite size of the box's pointer.

그림 15-2: ConsBox 를 들고 있으므로 더 이상 무한 크기가 아닌 List

박스는 오직 간접 참조와 힙 할당만 제공합니다. 뒤에서 볼 다른 스마트 포인터들처럼 추가적인 특별 기능은 없습니다. 따라서 그런 특별 기능에 따르는 성능 오버헤드도 없습니다. 그래서 cons list처럼 “간접 참조” 자체만 필요할 때 유용합니다. 18장에서는 박스의 다른 활용 사례도 보게 됩니다.

Box<T> 타입은 Deref 트레이트를 구현하기 때문에 스마트 포인터입니다. Deref 덕분에 Box<T> 값은 참조처럼 취급될 수 있습니다. 또한 Box<T> 값이 스코프를 벗어나면, Drop 트레이트 구현 덕분에 박스가 가리키던 힙 데이터도 함께 정리됩니다. 이 두 트레이트는 이 장의 나머지 부분에서 다룰 다른 스마트 포인터들에게는 더욱 중요합니다. 이제 이 두 트레이트를 자세히 살펴보겠습니다.

스마트 포인터를 일반 참조처럼 다루기

스마트 포인터를 일반 참조처럼 다루기

Deref 트레이트를 구현하면 역참조 연산자 * 의 동작을 원하는 대로 바꿀 수 있습니다(* 는 곱셈 연산자나 글롭 연산자와는 다른 의미라는 점을 기억하세요). 스마트 포인터가 일반 참조처럼 취급되도록 Deref 를 구현하면, 참조를 대상으로 쓴 코드를 스마트 포인터와도 함께 쓸 수 있게 됩니다.

먼저 역참조 연산자가 일반 참조와 함께 어떻게 동작하는지 보겠습니다. 그런 다음 Box<T> 처럼 동작하는 사용자 정의 타입을 만들어 보고, 왜 새 타입에 대해서는 역참조 연산자가 참조처럼 바로 동작하지 않는지 확인할 것입니다. 이어서 Deref 트레이트를 구현하면 왜 스마트 포인터도 참조와 비슷한 방식으로 동작할 수 있는지 살펴봅니다. 마지막으로 러스트의 역참조 강제(deref coercion) 기능이 참조와 스마트 포인터를 둘 다 자연스럽게 다루게 해 주는 방식도 보겠습니다.

참조가 가리키는 값 따라가기

일반 참조는 포인터의 한 종류이며, 포인터를 “어딘가 다른 곳에 저장된 값을 가리키는 화살표”라고 생각할 수 있습니다. 목록 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 에 대한 참조로 설정합니다. 따라서 x == 5 라는 비교는 바로 할 수 있습니다. 하지만 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> 를 참조처럼 사용하기

목록 15-6의 코드를 참조 대신 Box<T> 를 사용하도록 다시 써도, 목록 15-7처럼 역참조 연산자는 똑같이 동작합니다.

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> 에 역참조 연산자 사용하기

목록 15-7과 15-6의 주요 차이는, 여기서는 yx 값을 가리키는 참조가 아니라 x 값을 복사해서 힙에 저장한 박스로 만들었다는 점입니다. 마지막 단언에서도 마찬가지로, 박스 포인터를 역참조해 실제 값을 따라갈 수 있습니다. 이제 우리만의 박스 타입을 정의하면서, 왜 Box<T> 에서 이런 역참조 동작이 가능한지 살펴봅시다.

우리만의 스마트 포인터 정의하기

표준 라이브러리의 Box<T> 와 비슷한 래퍼 타입을 직접 만들어 보면, 스마트 포인터 타입이 기본적으로 참조와 어떻게 다르게 동작하는지 체감할 수 있습니다. 그 뒤에 역참조 연산자를 사용할 수 있게 하는 방법도 보겠습니다.

Note: 우리가 곧 만들 MyBox<T> 는 실제 Box<T> 와 큰 차이가 하나 있습니다. 우리의 버전은 데이터를 힙에 저장하지 않습니다. 이 예제는 Deref 에 초점을 맞추기 때문에, 데이터가 실제로 어디 저장되는지는 “포인터처럼 동작한다”는 성질만큼 중요하지 않습니다.

Box<T> 는 결국 요소 하나를 담는 튜플 구조체로 정의되므로, 목록 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 인스턴스를 반환합니다.

이제 목록 15-7의 main 함수를 목록 15-8에 추가해, Box<T> 대신 우리가 정의한 MyBox<T> 타입을 사용하도록 바꿔 보겠습니다. 목록 15-9의 코드는 컴파일되지 않는데, 그 이유는 러스트가 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);
   |                   ^^ can't be dereferenced

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처럼 MyBox<T> 값에 * 를 적용하는 main 함수도 컴파일되고, 단언도 통과합니다!

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 coercion)Deref 트레이트를 구현한 타입에 대한 참조를, 또 다른 타입에 대한 참조로 변환하는 기능입니다. 예를 들어 StringDeref 트레이트를 구현해 &str 를 반환하므로, 역참조 강제는 &String&str 로 바꿀 수 있습니다. 역참조 강제는 함수와 메서드 인수에 대해 러스트가 자동으로 수행하는 편의 기능이며, 오직 Deref 트레이트를 구현한 타입에서만 작동합니다. 함수나 메서드 정의의 매개변수 타입과 우리가 넘긴 참조 타입이 다를 때, 러스트는 deref 메서드를 연쇄 호출하여 필요한 타입으로 바꿔 줍니다.

역참조 강제가 러스트에 들어간 이유는, 프로그래머가 함수와 메서드를 호출할 때 &* 를 일일이 많이 적지 않아도 되게 하기 위해서입니다. 또한 이 기능 덕분에 참조로도, 스마트 포인터로도 동작하는 더 많은 코드를 작성할 수 있습니다.

이 기능이 실제로 어떻게 동작하는지 보기 위해, 목록 15-8에서 정의한 MyBox<T> 와 목록 15-10의 Deref 구현을 사용해 보겠습니다. 목록 15-11은 문자열 슬라이스 매개변수를 받는 함수 정의를 보여 줍니다.

Filename: src/main.rs
fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}
Listing 15-11: 타입이 &str 인 매개변수 name 을 받는 hello 함수

예를 들어 hello("Rust"); 처럼 문자열 슬라이스를 인수로 넘겨 hello 함수를 호출할 수 있습니다. 그리고 역참조 강제 덕분에, 목록 15-12처럼 MyBox<String> 값에 대한 참조를 넘겨도 hello 를 호출할 수 있습니다.

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: 역참조 강제 덕분에 MyBox<String> 참조로 hello 호출하기

여기서 우리는 &mhello 함수에 넘기는데, 이 값은 MyBox<String> 에 대한 참조입니다. 목록 15-10에서 MyBox<T>Deref 트레이트를 구현했기 때문에, 러스트는 deref 를 호출하여 &MyBox<String>&String 으로 바꿀 수 있습니다. 그리고 표준 라이브러리는 String 에 대해서도 Deref 를 구현해 문자열 슬라이스를 반환하도록 해 두었습니다. 이 구현은 Deref 문서에서도 볼 수 있습니다. 그래서 러스트는 다시 한 번 deref 를 호출해 &String&str 로 바꾸고, 그 결과가 hello 함수의 시그니처와 맞아떨어집니다.

만약 러스트에 역참조 강제가 없었다면, 타입이 &MyBox<String> 인 값을 이용해 hello 를 호출하려면 목록 15-12 대신 목록 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: 러스트에 역참조 강제가 없었다면 직접 써야 했을 코드

(*m)MyBox<String> 을 역참조해 String 으로 바꾸고, 그다음 &[..] 로 그 전체 String 에 대한 슬라이스를 만들어 hello 시그니처와 맞춥니다. 역참조 강제가 없는 이 코드는 기호가 많아져서 읽기 어렵고, 쓰기도 어렵고, 이해하기도 어렵습니다. 역참조 강제 덕분에 러스트가 이런 변환을 자동으로 처리해 줍니다.

관련 타입들에 대해 Deref 트레이트가 정의되어 있으면, 러스트는 타입을 분석해 매개변수 타입과 맞는 참조를 얻을 때까지 필요한 횟수만큼 Deref::deref 를 호출합니다. 이 Deref::deref 삽입 횟수는 컴파일 시점에 결정되므로, 역참조 강제를 사용해도 런타임 비용은 없습니다.

가변 참조에서의 역참조 강제

불변 참조에서 Deref 트레이트로 * 동작을 바꿀 수 있는 것과 비슷하게, 가변 참조에서는 DerefMut 트레이트를 사용해 * 동작을 바꿀 수 있습니다.

러스트는 세 가지 경우에 타입과 트레이트 구현을 보고 역참조 강제를 수행합니다.

  1. T: Deref<Target = U> 일 때 &T 에서 &U
  2. T: DerefMut<Target = U> 일 때 &mut T 에서 &mut U
  3. T: Deref<Target = U> 일 때 &mut T 에서 &U

첫 두 경우는 두 번째가 가변성까지 포함한다는 점만 빼면 거의 같습니다. 첫 번째 규칙은 &T 가 있고 T 가 어떤 타입 UDeref 된다면, &U 를 투명하게 얻을 수 있다는 뜻입니다. 두 번째 규칙은 가변 참조에서도 같은 일이 일어난다고 말합니다.

세 번째 규칙은 조금 더 미묘합니다. 러스트는 가변 참조를 불변 참조로도 강제할 수 있습니다. 하지만 그 반대는 절대 허용되지 않습니다. 대여 규칙 때문에, 가변 참조가 있다면 그 가변 참조는 그 데이터에 대한 유일한 참조여야 합니다(그렇지 않으면 프로그램은 애초에 컴파일되지 않습니다). 따라서 가변 참조 하나를 불변 참조 하나로 바꾸는 것은 대여 규칙을 깨지 않습니다. 하지만 불변 참조를 가변 참조로 바꾸려면, 그 불변 참조가 사실상 유일한 참조여야 한다는 추가 가정이 필요합니다. 대여 규칙은 그런 보장을 하지 않기 때문에, 러스트는 그런 강제를 허용할 수 없습니다.

Drop 트레이트로 정리 시 코드 실행하기

Drop 트레이트로 메모리 정리 코드 실행하기

스마트 포인터 패턴에서 두 번째로 중요한 트레이트는 Drop 입니다. Drop 은 어떤 값이 스코프를 벗어나기 직전에 무엇이 일어나야 하는지 여러분이 정의할 수 있게 해 줍니다. 어떤 타입이든 Drop 트레이트를 구현할 수 있고, 그 안의 코드는 파일이나 네트워크 연결 같은 자원을 해제하는 데 사용할 수 있습니다.

우리가 Drop 을 스마트 포인터 맥락에서 소개하는 이유는, Drop 의 기능이 거의 항상 스마트 포인터 구현에 사용되기 때문입니다. 예를 들어 Box<T> 가 drop 될 때, 박스가 가리키던 힙 공간을 해제합니다.

어떤 언어에서는, 특정 타입에 대해 프로그래머가 그 타입 인스턴스를 다 쓸 때마다 메모리나 자원을 해제하는 코드를 직접 호출해야 합니다. 파일 핸들, 소켓, 락 같은 것이 대표적입니다. 프로그래머가 이를 잊으면 시스템 자원이 점점 쌓여 과부하가 걸리고, 심하면 프로그램이 망가질 수도 있습니다. 러스트에서는 특정 값이 스코프를 벗어날 때 실행되어야 하는 코드를 지정할 수 있고, 컴파일러가 그 코드를 자동으로 삽입해 줍니다. 덕분에 특정 타입의 인스턴스 사용이 끝나는 지점마다 정리 코드를 빠짐없이 직접 넣을 필요가 없습니다. 그런데도 자원 누수는 막을 수 있습니다!

값이 스코프를 벗어날 때 실행할 코드는 Drop 트레이트를 구현함으로써 지정합니다. Drop 트레이트는 self 에 대한 가변 참조를 받는 drop 메서드 하나를 구현하라고 요구합니다. 러스트가 언제 drop 을 호출하는지 확인하기 위해, 지금은 println! 을 사용해 drop 안에 메시지를 넣어 보겠습니다.

목록 15-14는 CustomSmartPointer 라는 구조체를 보여 줍니다. 이 구조체의 유일한 특별한 기능은, 인스턴스가 스코프를 벗어날 때 Dropping CustomSmartPointer! 라는 메시지를 출력한다는 점입니다. 이를 통해 러스트가 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 트레이트는 prelude 에 포함되어 있으므로 따로 스코프로 가져올 필요가 없습니다. 우리는 CustomSmartPointerDrop 을 구현하고, 그 안의 drop 메서드에서 println! 을 호출하는 구현을 제공합니다. drop 메서드 본문이 바로, 해당 타입 인스턴스가 스코프를 벗어날 때 실행하고 싶은 로직을 넣는 자리입니다. 여기서는 러스트가 언제 drop 을 호출하는지 시각적으로 보이게 하려고 문자열을 출력하는 것입니다.

main 에서는 CustomSmartPointer 인스턴스 둘을 만들고, 이어서 CustomSmartPointers created 라는 문자열을 출력합니다. main 끝에 도달하면 CustomSmartPointer 인스턴스는 스코프를 벗어나고, 러스트는 우리가 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`!

러스트는 인스턴스가 스코프를 벗어날 때 자동으로 drop 을 호출했고, 우리가 지정한 코드를 실행해 주었습니다. 변수는 생성된 역순으로 drop 되므로, dc 보다 먼저 drop 됩니다. 이 예제의 목적은 drop 메서드가 어떻게 동작하는지 눈으로 이해하게 하는 데 있습니다. 실제로는 이렇게 메시지를 출력하기보다는, 타입이 필요로 하는 정리 코드를 거기에 넣게 될 것입니다.

안타깝게도 자동 drop 동작을 끄는 것은 간단하지 않습니다. 하지만 보통은 그럴 필요도 없습니다. Drop 트레이트의 핵심 장점이 바로 “자동으로 처리된다”는 점이기 때문입니다. 다만 가끔은 어떤 값을 조금 더 일찍 정리하고 싶을 때도 있습니다. 예를 들어 락을 관리하는 스마트 포인터를 사용할 때, 같은 스코프 안의 다른 코드가 락을 잡을 수 있게 지금 당장 락을 풀고 싶을 수 있습니다. 러스트는 Drop 트레이트의 drop 메서드를 직접 호출하게 허용하지 않습니다. 대신 표준 라이브러리의 std::mem::drop 함수를 사용해 값을 스코프 끝보다 먼저 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 -     c.drop();
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

오류 메시지에는 destructor 라는 표현이 나오는데, 이것은 인스턴스를 정리하는 함수에 대한 일반적인 프로그래밍 용어입니다. Destructor 는 인스턴스를 만드는 constructor 와 짝이 되는 개념입니다. 러스트의 drop 함수는 하나의 구체적인 destructor입니다.

러스트가 drop 을 직접 호출하지 못하게 하는 이유는, 프로그램 끝에서 러스트가 어차피 그 값에 대해 drop 을 자동으로 다시 호출할 것이기 때문입니다. 그렇게 되면 같은 값을 두 번 정리하려 하므로 double free 에러가 됩니다.

우리는 값이 스코프를 벗어날 때 자동 삽입되는 drop 을 비활성화할 수도 없고, drop 메서드를 직접 호출할 수도 없습니다. 따라서 값을 더 일찍 정리하고 싶다면 std::mem::drop 함수를 사용합니다.

std::mem::drop 함수는 Drop 트레이트의 drop 메서드와는 다릅니다. 우리는 미리 drop 시키고 싶은 값을 인수로 넘겨 호출합니다. 이 함수는 prelude에 있으므로, 목록 15-15의 main 을 목록 15-16처럼 바꿔 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("some data"),
    };
    println!("CustomSmartPointer created");
    drop(c);
    println!("CustomSmartPointer dropped before the end of main");
}
Listing 15-16: 값이 스코프를 벗어나기 전에 std::mem::drop 으로 명시적으로 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

Dropping CustomSmartPointer with data `some data`! 라는 텍스트가 CustomSmartPointer createdCustomSmartPointer dropped before the end of main 사이에 출력된다는 점을 보면, 그 시점에서 c 에 대해 drop 메서드가 실제로 호출되었음을 알 수 있습니다.

Drop 트레이트 구현 안에 넣은 코드는 정리를 편리하고 안전하게 만드는 여러 방식으로 활용할 수 있습니다. 예를 들어 여러분만의 메모리 할당자를 만들 때도 사용할 수 있습니다! Drop 트레이트와 러스트의 소유권 시스템 덕분에, 따로 정리 코드를 잊지 않고 넣을 필요가 없습니다. 러스트가 자동으로 처리해 주기 때문입니다.

또한 아직 사용 중인 값을 실수로 정리해서 생기는 문제도 걱정할 필요가 없습니다. 참조가 언제나 유효하도록 보장하는 그 소유권 시스템이, 값이 더 이상 사용되지 않을 때 딱 한 번만 drop 이 호출되도록도 함께 보장해 주기 때문입니다.

이제 Box<T> 와 스마트 포인터의 몇 가지 특성을 살펴보았으니, 표준 라이브러리에 정의된 다른 스마트 포인터들도 보겠습니다.

Rc<T>, 참조 카운팅 스마트 포인터

Rc<T>, 참조 카운트 스마트 포인터

대부분의 경우 소유권은 분명합니다. 어떤 변수가 특정 값을 소유하는지 정확히 알 수 있습니다. 하지만 하나의 값이 여러 소유자를 가져야 하는 경우도 있습니다. 예를 들어 그래프 자료구조에서는 여러 간선이 같은 노드를 가리킬 수 있고, 개념적으로 그 노드는 그 노드를 가리키는 모든 간선의 소유물이 됩니다. 따라서 그 노드를 가리키는 간선이 하나도 남지 않아 소유자가 없어지기 전에는 그 노드를 정리하면 안 됩니다.

러스트의 Rc<T> 타입은 reference counting 의 약자로, 이런 다중 소유권을 명시적으로 가능하게 해 줍니다. Rc<T> 는 어떤 값에 대한 참조 수를 추적해서, 그 값이 여전히 사용 중인지 아닌지를 판단합니다. 어떤 값에 대한 참조가 0개가 되면, 더 이상 유효하지 않은 참조 없이 그 값을 정리할 수 있습니다.

Rc<T> 를 가족 거실의 TV 라고 생각해도 좋습니다. 한 사람이 들어와 TV를 보기 시작하면 TV를 켭니다. 다른 사람들도 들어와 같이 볼 수 있습니다. 마지막 사람이 거실을 나갈 때 그들은 TV를 끕니다. 더 이상 사용되지 않으니까요. 만약 아직 다른 사람이 보고 있는데 누군가 TV를 꺼 버린다면, 남은 시청자들의 불만이 터져 나오겠지요!

우리는 프로그램의 여러 부분이 힙에 있는 어떤 데이터를 읽어야 하지만, 컴파일 시점에는 그 중 어느 부분이 마지막까지 그 데이터를 쓸지 알 수 없는 경우 Rc<T> 타입을 사용합니다. 만약 어떤 부분이 마지막인지 알고 있다면, 그냥 그 부분을 데이터의 소유자로 삼고 러스트의 일반 소유권 규칙을 그대로 적용할 수 있었을 것입니다.

Rc<T> 는 오직 단일 스레드 상황에서만 사용할 수 있다는 점에 주의하세요. 16장에서 동시성을 다룰 때, 멀티스레드 프로그램에서 참조 카운팅을 어떻게 하는지도 살펴봅니다.

데이터 공유하기

이제 목록 15-5의 cons list 예제로 다시 돌아갑시다. 그때는 Box<T> 를 사용해 정의했습니다. 이번에는 세 번째 리스트 하나를 두 개의 다른 리스트가 함께 소유하는 구조를 만들어 보겠습니다. 개념적으로는 그림 15-3과 비슷합니다.

A linked list with the label 'a' pointing to three elements. The first element contains the integer 5 and points to the second element. Th
e second element contains the integer 10 and points to the third element. The third element contains the value 'Nil' that signifies the end of the l
ist; it does not point anywhere. A linked list with the label 'b' points to an element that contains the integer 3 and points to the first element o
f list 'a'. A linked list with the label 'c' points to an element that contains the integer 4 and also points to the first element of list 'a' so th
at the tails of lists 'b' and 'c' are both list 'a'.

그림 15-3: 세 번째 리스트 a 를 함께 소유하는 두 리스트 b, c

우리는 값 5, 10 을 담은 리스트 a 를 만들고, 그 다음 3 으로 시작하는 리스트 b, 4 로 시작하는 리스트 c 를 만들 것입니다. bc 는 이후 둘 다 값 5, 10 이 들어 있는 첫 번째 리스트 a 로 이어집니다. 즉, 두 리스트가 같은 꼬리 부분을 공유하는 것입니다.

이 상황을 Box<T> 를 사용하는 List 정의로 구현하려 하면, 목록 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
   |
note: if `List` implemented `Clone`, you could clone the value
  --> src/main.rs:1:1
   |
 1 | enum List {
   | ^^^^^^^^^ consider implementing `Clone` for this type
...
10 |     let b = Cons(3, Box::new(a));
   |                              - you could clone this value

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 variant는 자신이 담는 데이터를 직접 소유합니다. 따라서 리스트 b 를 만들 때 ab 안으로 이동하고, 이제 ba 를 소유합니다. 그런 뒤 리스트 c 를 만들 때 다시 a 를 쓰려고 하면, 이미 a 가 이동되었기 때문에 사용할 수 없습니다.

Cons 정의를 참조를 들도록 바꿀 수도 있지만, 그렇게 하면 라이프타임 매개변수까지 지정해야 합니다. 라이프타임을 적는다는 것은 리스트의 모든 요소가 그 리스트 전체만큼 오래 살아야 함을 뜻하는데, 목록 15-17에서는 그렇지만 모든 상황에서 항상 그렇지는 않습니다.

그래서 이번에는 List 정의에서 Box<T> 대신 Rc<T> 를 사용하겠습니다. 목록 15-18을 보세요. 이제 각 Cons variant는 값 하나와 List 를 가리키는 Rc<T> 를 담게 됩니다. b 를 만들 때는 a 의 소유권을 가져가는 대신, a 가 가지고 있던 Rc<List>clone 해 참조 수를 1에서 2로 늘리고, ab 가 그 Rc<List> 안의 데이터를 함께 소유하게 만듭니다. c 를 만들 때도 a 를 다시 clone 하면 참조 수가 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> 는 prelude에 없기 때문에 먼저 use 문으로 스코프로 가져와야 합니다. main 에서 우리는 5, 10 을 담은 리스트를 만들고, 그 값을 Rc<List> 로 감싼 뒤 a 에 저장합니다. 그 다음 b, c 를 만들 때는 Rc::clone 함수를 호출해, a 안의 Rc<List> 에 대한 참조를 인수로 넘깁니다.

여기서는 Rc::clone(&a) 대신 a.clone() 라고 쓸 수도 있지만, 러스트 관례상 이 경우에는 Rc::clone 형식을 선호합니다. 대부분의 타입에 대한 clone 구현은 데이터 전체를 깊게 복사하지만, Rc::clone 의 구현은 참조 수만 올립니다. 이것은 거의 비용이 들지 않습니다. 데이터의 깊은 복사는 비용이 클 수 있기 때문에, 우리는 Rc::clone 표기 덕분에 “깊은 복사”와 “참조 수 증가”를 시각적으로 구분할 수 있습니다. 성능 문제를 볼 때도 깊은 복사는 신경 써야 하지만, Rc::clone 호출은 무시해도 된다는 뜻입니다.

클론해서 참조 수 늘리기

이제 목록 15-18의 예제를 약간 바꿔, a 안의 Rc<List> 에 대한 참조를 만들고 없앨 때 참조 수가 어떻게 바뀌는지 눈으로 보겠습니다.

목록 15-19에서는 main 안에 내부 스코프를 하나 추가하고, branch 생성 부분을 그 스코프 안으로 옮깁니다. 그러면 branch 가 만들어졌다가 스코프를 벗어날 때 어떤 일이 일어나는지 확인할 수 있습니다.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

// --snip--

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: branch 를 내부 스코프에서 만들고 strong/weak 참조 수 확인하기

leaf 가 만들어진 직후, 그 Rc<Node> 의 strong count 는 1이고 weak count 는 0입니다. 내부 스코프에서 branch 를 만들고 leaf 와 연결하면, branch 안의 Rc<Node> 는 strong count 1, weak count 1을 갖게 됩니다(leaf.parentWeak<Node>branch 를 가리키기 때문입니다). 그리고 leaf 의 count를 출력하면, branch.children 안에 저장된 leafRc<Node> clone 때문에 strong count 가 2가 되었고, weak count 는 여전히 0인 것을 보게 됩니다.

내부 스코프가 끝나면 branch 는 스코프를 벗어나고, 그 Rc<Node> 의 strong count 는 0으로 줄어듭니다. 따라서 그 Node 는 drop 됩니다. leaf.parent 안의 weak count 1은 Node 가 drop 되는지 여부에는 영향을 주지 않으므로, 메모리 누수는 발생하지 않습니다!

스코프가 끝난 뒤 leaf 의 부모에 접근하려 하면 다시 None 을 얻게 됩니다. 프로그램 끝 시점에는 leaf 변수만이 그 Rc<Node> 를 가리키므로, leaf 안의 Rc<Node> 는 strong count 1, weak count 0을 갖게 됩니다.

참조 수 관리와 값 drop 로직은 모두 Rc<T>Weak<T>, 그리고 그것들의 Drop 구현 안에 들어 있습니다. Node 정의에서 “자식에서 부모로 가는 관계는 Weak<T> 참조다” 라고 지정함으로써, 부모 노드가 자식 노드를 가리키고 자식도 부모를 가리키면서도 참조 사이클과 메모리 누수는 만들지 않을 수 있는 것입니다.

정리

이 장에서는 일반 참조에 비해 서로 다른 보장과 트레이드오프를 제공하는 스마트 포인터를 어떻게 사용하는지 살펴보았습니다. Box<T> 는 크기가 알려져 있고, 힙에 할당된 데이터를 가리킵니다. Rc<T> 는 힙 데이터에 대한 참조 수를 추적해, 하나의 데이터를 여러 소유자가 함께 가질 수 있게 해 줍니다. RefCell<T> 는 내부 가변성을 통해, 겉보기엔 불변인 타입 안의 값을 바꿀 수 있게 하고, 대여 규칙을 컴파일 시점이 아니라 런타임에서 검사합니다.

또한 스마트 포인터 기능의 많은 부분을 가능하게 하는 Deref, Drop 트레이트도 다루었습니다. 메모리 누수를 일으킬 수 있는 참조 사이클을 살펴보고, Weak<T> 를 사용해 이를 방지하는 방법도 배웠습니다.

이 장이 흥미를 자극했고 여러분만의 스마트 포인터를 직접 구현해 보고 싶다면, “The Rustonomicon” 을 읽어 보세요.

다음 장에서는 러스트의 동시성을 다룹니다. 그 과정에서 새로운 스마트 포인터도 몇 가지 더 만나게 됩니다.

RefCell<T>와 내부 가변성 패턴

RefCell<T>와 내부 가변성 패턴

내부 가변성(interior mutability) 은 러스트의 설계 패턴으로, 어떤 데이터에 대해 불변 참조만 있어도 그 데이터 내부를 변경할 수 있게 해 줍니다. 보통 이런 동작은 대여 규칙에 의해 금지됩니다. 이 패턴은 러스트가 원래의 대여·가변성 규칙을 “조금 비틀기” 위해 자료구조 안쪽에서 unsafe 코드를 사용합니다. unsafe 코드는 “이 규칙 검사는 컴파일러가 아니라 우리가 직접 책임지고 확인하겠다”는 뜻입니다. unsafe 코드는 20장에서 더 자세히 다룹니다.

내부 가변성 패턴을 사용하는 타입은, 컴파일러가 보장해 주지는 못하더라도 런타임에는 대여 규칙이 지켜질 것이라는 확신이 있을 때만 사용해야 합니다. 이런 unsafe 코드는 안전한 API 안에 감춰져 있으므로, 바깥에서 보이는 타입 자체는 여전히 불변입니다.

이제 내부 가변성 패턴을 따르는 RefCell<T> 타입을 통해 이 개념을 살펴보겠습니다.

런타임에 대여 규칙 강제하기

Rc<T> 와 달리 RefCell<T> 는 자신이 담고 있는 데이터에 대해 단일 소유권만을 가집니다. 그렇다면 RefCell<T>Box<T> 같은 타입과 무엇이 다를까요? 4장에서 배운 대여 규칙을 떠올려 보세요.

  • 어떤 시점이든, 가변 참조는 하나만 있거나, 불변 참조는 여러 개 있을 수 있다 (둘은 동시에 안 된다).
  • 참조는 항상 유효해야 한다.

일반 참조나 Box<T> 에서는 이 규칙이 컴파일 시점 에 강제됩니다. 반면 RefCell<T> 에서는 이 규칙이 런타임 에 강제됩니다. 일반 참조에서 이 규칙을 어기면 컴파일 오류가 나고, RefCell<T> 에서는 런타임에 패닉이 일어나며 프로그램이 종료됩니다.

대여 규칙을 컴파일 시점에 검사하는 장점은, 오류를 개발 초기에 훨씬 빨리 잡을 수 있고, 분석이 모두 미리 끝나므로 런타임 성능에 영향이 없다는 점입니다. 이런 이유로, 대여 규칙은 대부분의 경우 컴파일 시점에 검사하는 편이 최선이고, 그래서 이것이 러스트의 기본 방식입니다.

반대로 런타임에 검사할 때의 장점은, 컴파일 시점 검사에서는 거부되었을 어떤 메모리 안전한 시나리오들이 허용된다는 점입니다. 러스트 컴파일러 같은 정적 분석기는 본질적으로 보수적입니다. 어떤 코드의 성질은 분석만으로는 판별할 수 없기 때문입니다. 가장 유명한 예가 정지 문제(Halting Problem)인데, 이 책의 범위를 벗어나지만 흥미로운 주제입니다.

어떤 분석은 원천적으로 불가능하기 때문에, 러스트 컴파일러는 코드가 소유권 규칙을 지키는지 확신할 수 없으면 사실은 올바른 프로그램조차 거부할 수 있습니다. 이런 의미에서 컴파일러는 보수적입니다. 하지만 러스트가 잘못된 프로그램을 받아들인다면, 사용자는 러스트가 보장하는 안전성을 신뢰할 수 없게 됩니다. 반대로 러스트가 올바른 프로그램을 거부하더라도 프로그래머가 조금 불편할 뿐, 치명적인 문제는 일어나지 않습니다. 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> 가 불변이어도 그 안의 값을 바꿀 수 있습니다.

불변 값의 내부를 바꾸는 것이 바로 내부 가변성 패턴입니다. 이제 이것이 왜 유용한지, 어떤 상황에서 사용할 수 있는지 살펴봅시다.

내부 가변성 사용하기

대여 규칙의 결과로, 불변 값을 가지고 있다면 그것을 가변으로 빌릴 수 없습니다. 예를 들어 다음 코드는 컴파일되지 않습니다.

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> 는 이런 내부 가변성을 얻는 한 가지 방법이지만, 대여 규칙 전체를 없애는 것은 아닙니다. 컴파일러의 대여 검사기가 이 내부 가변성 패턴을 허용하는 대신, 규칙 검사는 런타임에 이루어집니다. 규칙을 어기면 컴파일 오류가 아니라 panic! 을 얻게 됩니다.

이제 RefCell<T> 를 사용해 불변 값을 변경할 수 있는 실용적인 예를 하나 따라가며, 왜 이것이 유용한지 살펴보겠습니다.

모의 객체(mock objects)로 테스트하기

테스트를 할 때 프로그래머는 어떤 타입 대신 다른 타입을 사용하는 경우가 있습니다. 그 목적은 특정 동작을 관찰하고 그것이 제대로 구현되었는지 단언하는 데 있습니다. 이런 대체 타입을 테스트 더블(test double) 이라고 부릅니다. 영화에서 배우 대신 위험한 장면을 촬영하는 스턴트 더블과 비슷하게 생각하면 됩니다. 테스트 더블은 테스트 동안 다른 타입을 대신합니다. 그중에서도 모의 객체(mock object) 는 테스트 중 어떤 일이 일어났는지를 기록해 두고, 예상한 동작이 실제로 일어났는지를 단언할 수 있게 해 주는 구체적인 종류의 테스트 더블입니다.

러스트는 다른 언어들이 말하는 의미 그대로의 객체를 갖고 있지는 않고, 어떤 언어들처럼 표준 라이브러리에 모의 객체 기능이 내장되어 있지도 않습니다. 하지만 모의 객체와 같은 목적을 수행하는 구조체를 직접 만드는 것은 충분히 가능합니다.

우리가 테스트할 시나리오는 이렇습니다. 어떤 값이 최대값에 얼마나 가까운지를 추적하고, 현재 값이 최대에 얼마나 가까운지에 따라 메시지를 보내는 라이브러리를 하나 만들겠습니다. 예를 들어 사용자가 몇 번의 API 호출을 할 수 있는지 quota 를 추적하는 데 사용할 수도 있습니다.

우리 라이브러리는 “값이 최대에 얼마나 가까운지” 와, “어떤 시점에 어떤 메시지를 보내야 하는지” 만 제공합니다. 메시지를 실제로 보내는 수단은 라이브러리를 사용하는 애플리케이션이 제공해야 합니다. 예를 들어 애플리케이션은 메시지를 화면에 보여 줄 수도 있고, 이메일로 보낼 수도 있고, 문자로 보낼 수도 있습니다. 라이브러리는 그런 세부를 알 필요가 없습니다. 그저 Messenger 라는 이름의 트레이트를 구현한 무언가만 필요합니다. 목록 15-20이 그 라이브러리 코드를 보여 줍니다.

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 메서드 하나를 가진다는 점입니다. 바로 이 트레이트가 모의 객체가 구현해야 하는 인터페이스가 되며, 덕분에 모의 객체를 실제 객체와 같은 방식으로 사용할 수 있습니다. 다른 중요한 부분은, 우리가 LimitTrackerset_value 메서드 동작을 테스트하고 싶다는 점입니다. value 인수로 무엇을 넘길지는 바꿀 수 있지만, set_value 는 우리가 직접 단언할 만한 값을 아무것도 반환하지 않습니다. 우리가 원하는 것은, Messenger 트레이트를 구현한 어떤 것과 특정 max 값을 사용해 LimitTracker 를 만들었을 때, value 에 따라 적절한 메시지가 전송되었는지를 확인할 수 있는 것입니다.

따라서 우리는, 이메일이나 문자를 실제로 보내는 대신 send 호출 시 “보내라고 지시받은 메시지들”만 기록하는 모의 객체가 필요합니다. 새 모의 객체 인스턴스를 만들고, 그 모의 객체를 쓰는 LimitTracker 를 만든 뒤, LimitTracker 에 대해 set_value 를 호출하고, 마지막으로 모의 객체에 우리가 기대하는 메시지가 기록되었는지 확인할 수 있어야 합니다. 목록 15-21은 이를 구현하려는 시도를 보여 주지만, 대여 검사기가 허용하지 않습니다.

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 구현 시도

이 테스트 코드는 sent_messages 라는 필드 안에 String 벡터를 가진 MockMessenger 구조체를 정의합니다. 이 벡터는 “전송하라고 지시받은 메시지들”을 기록합니다. 또한 빈 메시지 목록으로 시작하는 새 MockMessenger 값을 쉽게 만들기 위해 new 연관 함수도 정의합니다. 그런 뒤 MockMessengerMessenger 트레이트를 구현하여, LimitTrackerMockMessenger 를 넘길 수 있게 합니다. send 메서드 정의에서는 인수로 받은 메시지를 sent_messages 목록에 저장합니다.

테스트에서는 LimitTrackervaluemax 의 75퍼센트를 넘는 값으로 설정했을 때 어떤 일이 일어나는지 테스트합니다. 먼저 빈 메시지 목록을 가진 새 MockMessenger 를 만들고, 그것에 대한 참조와 max 값 100을 가진 LimitTracker 를 생성합니다. 그런 다음 set_value 를 80으로 호출합니다. 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 안의 메시지 목록을 수정할 수 없습니다. 또한 오류 메시지가 제안하듯 &mut self 로 바꾸는 것도 원하지 않습니다. 테스트만을 위해 Messenger 트레이트 자체를 바꾸고 싶지는 않기 때문입니다. 따라서 현재 설계를 유지하면서도 테스트 코드가 제대로 동작하게 만들 다른 방법이 필요합니다.

여기서 바로 내부 가변성이 도움이 됩니다! sent_messagesRefCell<T> 안에 저장하면, send 메서드가 sent_messages 를 수정해 본 메시지를 기록할 수 있게 됩니다. 목록 15-22가 그 모습입니다.

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 메서드를 구현할 때, 첫 번째 매개변수는 여전히 trait 정의와 같은 불변 self 참조입니다. 우리는 self.sent_messages 안의 RefCell<Vec<String>> 에 대해 borrow_mut 를 호출해 내부 벡터에 대한 가변 참조를 얻습니다. 그런 다음 그 가변 참조에 대해 push 를 호출해 테스트 도중 전송된 메시지를 기록할 수 있습니다.

마지막으로 단언 부분도 바꿔야 합니다. 안쪽 벡터에 항목이 몇 개 있는지 확인하려면, RefCell<Vec<String>> 에 대해 borrow 를 호출해 벡터에 대한 불변 참조를 얻습니다.

이제 RefCell<T> 를 어떻게 쓰는지 보았으니, 내부적으로 어떻게 동작하는지도 살펴보겠습니다.

런타임에 대여 추적하기

일반 불변 참조와 가변 참조를 만들 때는 각각 &&mut 문법을 사용합니다. RefCell<T> 에 대해서는 borrowborrow_mut 메서드를 사용하는데, 이것들은 RefCell<T> 가 제공하는 안전한 API 일부입니다. borrowRef<T> 라는 스마트 포인터를 반환하고, borrow_mutRefMut<T> 라는 스마트 포인터를 반환합니다. 두 타입 모두 Deref 를 구현하므로, 일반 참조처럼 취급할 수 있습니다.

RefCell<T> 는 현재 활성화된 Ref<T>RefMut<T> 스마트 포인터 수를 추적합니다. borrow 를 호출할 때마다 RefCell<T> 는 활성 불변 대여 수를 1 늘립니다. 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 라는 두 번째 가변 대여를 만들려고 합니다. 이것은 같은 스코프 안에 두 개의 가변 참조를 만드는 것이므로 허용되지 않습니다. 하지만 이 코드는 컴파일은 되며, 테스트를 실행했을 때 실패하게 됩니다.

$ 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:
RefCell already borrowed
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> 는 데이터에 여러 소유자가 있도록 해 주지만, 그 데이터에 대해서는 오직 불변 접근만 허용한다는 점을 기억하세요. 그런데 RefCell<T> 를 담고 있는 Rc<T> 를 사용하면, 여러 소유자를 가지면서도 값을 변경할 수 있는 구조를 만들 수 있습니다!

예를 들어 목록 15-18의 cons list 예제를 떠올려 봅시다. 그때는 Rc<T> 로 여러 리스트가 하나의 다른 리스트를 함께 소유하게 만들었습니다. 하지만 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: 수정 가능한 List 를 만들기 위해 Rc<RefCell<i32>> 사용하기

우리는 Rc<RefCell<i32>> 인스턴스를 하나 만들고, 나중에 직접 접근할 수 있도록 value 라는 변수에 저장합니다. 그런 다음 a 라는 List 를 만들고, 그 안의 Cons variant 가 value 를 담도록 합니다. 이때 avalue 둘 다 내부의 5 값에 대한 소유권을 가져야 하므로, 소유권을 value 에서 a 로 옮기거나 avalue 를 빌리게 하는 대신 valueclone 합니다.

이후 리스트 aRc<T> 로 감싸 두었기 때문에, bc 를 만들 때 둘 다 a 를 참조할 수 있습니다. 이는 목록 15-18에서 했던 것과 같습니다.

리스트 a, b, c 를 만든 뒤에는 value 안의 값에 10을 더하고 싶습니다. 이를 위해 value 에 대해 borrow_mut 를 호출합니다. 이 과정에서는 5장의 -> 연산자는 어디에 있나요?” 절에서 다뤘던 자동 역참조 기능이 사용되어, Rc<T> 가 안쪽 RefCell<T> 값으로 자동 역참조됩니다. borrow_mutRefMut<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> 이고, 이는 16장에서 다룹니다.

참조 순환은 메모리 누수를 일으킬 수 있다

참조 순환은 메모리 누수를 일으킬 수 있다

러스트의 메모리 안전성 보장은, 절대 정리되지 않는 메모리(즉 메모리 누수)를 실수로 만드는 일을 어렵게 하기는 하지만, 완전히 불가능하게 만들지는 않습니다. 메모리 누수를 완전히 막는 것은 러스트의 보장 범위에 포함되지 않습니다. 즉, 메모리 누수는 러스트에서 “메모리 안전한” 문제입니다. Rc<T>RefCell<T> 를 사용하면 러스트가 메모리 누수를 허용한다는 사실을 볼 수 있습니다. 항목들이 서로를 원형으로 참조하는 구조를 만들 수 있기 때문입니다. 그러면 순환 안의 각 항목 참조 수가 절대 0에 도달하지 못해, 값이 영원히 drop 되지 않습니다.

참조 순환 만들기

어떻게 참조 순환이 생길 수 있는지, 그리고 그것을 어떻게 막을 수 있는지 살펴봅시다. 먼저 목록 15-25의 List enum 정의와 tail 메서드부터 시작합니다.

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 variant가 가리키는 대상을 바꿀 수 있도록 RefCell<T> 를 담은 cons list 정의

우리는 목록 15-5의 List 정의를 또 다른 방식으로 변형해 사용합니다. 이제 Cons variant의 두 번째 요소는 RefCell<Rc<List>> 입니다. 목록 15-24에서 했던 것처럼 i32 값을 바꾸는 대신, 이번에는 Cons variant가 가리키는 List 값을 바꾸고 싶기 때문입니다. 또한 Cons variant가 있을 때 두 번째 항목에 쉽게 접근할 수 있도록 tail 메서드도 추가했습니다.

목록 15-26에서는 목록 15-25 정의를 사용하는 main 함수를 추가합니다. 이 코드는 리스트 a 와, a 를 가리키는 리스트 b 를 만든 뒤, 다시 ab 를 가리키게 바꿔서 참조 순환을 만듭니다. 중간중간 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 값의 참조 순환 만들기

우리는 먼저 5, Nil 을 담은 List 값을 a 라는 변수 안의 Rc<List> 인스턴스로 만듭니다. 그런 다음 값 10 을 담고 a 를 가리키는 또 다른 Rc<List> 인스턴스를 b 에 만듭니다.

그 다음 a 가 더 이상 Nil 을 가리키지 않고 b 를 가리키게 바꾸어, 순환을 만듭니다. 이를 위해 tail 메서드를 사용해 a 안의 RefCell<Rc<List>> 참조를 얻고, 그것을 link 변수에 저장합니다. 그런 뒤 그 RefCell<Rc<List>> 에 대해 borrow_mut 를 호출해, 안의 값을 Nil 을 담은 Rc<List> 에서 b 안의 Rc<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

리스트 ab 안의 Rc<List> 인스턴스 참조 수는, ab 를 가리키도록 바꾼 뒤 모두 2가 됩니다. main 끝에서 러스트는 변수 b 를 drop 하고, b 안의 Rc<List> 참조 수는 2에서 1로 줄어듭니다. 하지만 참조 수가 0이 아니라 1이기 때문에, 힙 안의 그 메모리는 아직 drop 되지 않습니다. 이어서 러스트는 a 도 drop 하며, a 안의 Rc<List> 참조 수도 2에서 1로 줄어듭니다. 하지만 이 경우도 다른 Rc<List> 인스턴스가 여전히 그것을 가리키고 있으므로 메모리를 정리할 수 없습니다. 따라서 이 리스트에 할당된 메모리는 영원히 수거되지 않은 채 남게 됩니다. 이를 그림으로 나타낸 것이 그림 15-4입니다.

A rectangle labeled 'a' that points to a rectangle containing the integer 5. A rectangle labeled 'b' that points to a rectangle containing the integer 10. The rectangle containing 5 points to the rectangle containing 10, and the rectangle containing 10 points back to the rectangle containing 5, creating a cycle.

그림 15-4: 서로를 가리키는 리스트 ab 의 참조 순환

마지막 println! 의 주석을 풀고 프로그램을 실행하면, 러스트는 ab 를 가리키고 b 가 다시 a 를 가리키는 이 순환 구조를 끝없이 출력하려다가 결국 스택 오버플로를 일으키게 됩니다.

이 예제에서는 참조 순환의 결과가 아주 심각하지는 않습니다. 순환을 만들고 나자마자 프로그램이 종료되기 때문입니다. 하지만 더 복잡한 프로그램이 이런 순환 안에 많은 메모리를 할당하고, 그 상태로 오래 유지한다면 프로그램은 필요 이상의 메모리를 사용하게 되고, 심한 경우 시스템의 여유 메모리를 다 써 버릴 수도 있습니다.

참조 순환은 아주 쉽게 만들어지는 것은 아니지만, 불가능한 것도 아닙니다. Rc<T> 를 담은 RefCell<T> 값이나, 내부 가변성과 참조 카운팅을 결합한 비슷한 중첩 타입을 사용할 때는, 순환을 만들지 않도록 여러분이 직접 주의해야 합니다. 러스트가 컴파일러 차원에서 이를 잡아 주지는 않습니다. 참조 순환은 프로그램 안의 논리 버그이며, 이를 줄이기 위해 자동화 테스트, 코드 리뷰, 그 밖의 소프트웨어 개발 기법을 활용해야 합니다.

참조 순환을 피하는 또 다른 방법은, 어떤 참조는 소유권을 표현하고 어떤 참조는 소유권을 표현하지 않도록 데이터 구조를 다시 설계하는 것입니다. 그러면 순환 구조 자체는 가지더라도, 실제로 값이 drop 될지 여부에는 오직 “소유권 관계”만 영향을 주게 할 수 있습니다. 목록 15-25처럼 Cons variant가 리스트를 반드시 소유해야 하는 상황에서는 그런 재구성이 어렵습니다. 이제는 부모 노드와 자식 노드로 이루어진 그래프를 예로 들어, 비소유 참조가 참조 순환을 방지하는 적절한 방법이 되는 경우를 살펴봅시다.

Weak<T> 로 참조 순환 방지하기

지금까지 우리는 Rc::clone 호출이 Rc<T> 인스턴스의 strong_count 를 증가시키고, strong_count 가 0일 때만 Rc<T> 인스턴스가 정리된다는 사실을 보았습니다. 그런데 Rc::downgrade 를 호출해 Rc<T> 에 대한 참조를 넘기면, 그 안의 값에 대한 약한 참조도 만들 수 있습니다. 강한 참조(strong references)Rc<T> 인스턴스의 소유권을 공유하는 방식입니다. 약한 참조(weak references) 는 소유권 관계를 표현하지 않으며, 참조 수 역시 그 값이 언제 정리될지에 영향을 주지 않습니다. 따라서 약한 참조가 포함된 순환은, 그 값들에 대한 강한 참조 수가 0이 되는 순간 자연스럽게 끊어지므로 참조 순환을 만들지 않습니다.

Rc::downgrade 를 호출하면 Weak<T> 타입의 스마트 포인터를 얻게 됩니다. Rc::clonestrong_count 를 1 증가시키는 것과 달리, Rc::downgradeweak_count 를 1 증가시킵니다. Rc<T>strong_count 처럼 weak_count 도 추적하지만, 차이는 Rc<T> 를 정리하는 데 weak_count 가 0일 필요는 없다는 점입니다.

Weak<T> 가 가리키는 값은 이미 drop 되었을 수도 있으므로, Weak<T> 가 가리키는 값을 실제로 사용하려면 그 값이 여전히 존재하는지 먼저 확인해야 합니다. 이를 위해 Weak<T> 인스턴스의 upgrade 메서드를 호출합니다. 그러면 Option<Rc<T>> 가 반환됩니다. Rc<T> 값이 아직 drop 되지 않았다면 Some 을 받고, 이미 drop 되었다면 None 을 받습니다. upgradeOption<Rc<T>> 를 반환하기 때문에, 러스트는 SomeNone 두 경우를 모두 처리하도록 강제하고, 그 덕분에 잘못된 포인터를 쓰는 일이 생기지 않습니다.

예제로는, 다음 항목만 아는 리스트 대신, 자식 노드도 알고 부모 노드도 아는 트리 구조를 만들어 보겠습니다.

트리 자료구조 만들기

먼저 자식 노드를 알고 있는 노드들로 트리를 구성하겠습니다. 우리는 자신만의 i32 값과, 자식 Node 값에 대한 참조들을 함께 담는 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> 값으로 정의합니다. 또한 어떤 노드가 다른 노드의 자식인지 변경할 수도 있어야 하므로, children 안의 Vec<Rc<Node>> 주위에 RefCell<T> 를 둡니다.

다음으로 이 구조체 정의를 사용해, 값이 3 이고 자식이 없는 leaf 노드 하나와, 값이 5 이고 leaf 를 자식으로 하나 가진 branch 노드 하나를 목록 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 노드 만들기

우리는 leaf 안의 Rc<Node> 를 clone 해서 branch 안에 저장합니다. 따라서 leaf 안의 Node 는 이제 leafbranch 라는 두 소유자를 갖게 됩니다. 우리는 branch.children 을 통해 branch 에서 leaf 로 갈 수는 있지만, leaf 에서 branch 로 가는 방법은 없습니다. leaf 안에는 branch 에 대한 참조가 없어, 서로 관련 있다는 사실을 모르기 때문입니다. 이제 leafbranch 를 자신의 부모로 알게 만들겠습니다.

자식에서 부모로 가는 참조 추가하기

자식 노드가 부모를 알게 하려면, Node 구조체 정의에 parent 필드를 추가해야 합니다. 문제는 parent 의 타입을 무엇으로 해야 하느냐입니다. Rc<T> 가 될 수는 없다는 점은 분명합니다. 그렇게 하면 leaf.parentbranch 를 가리키고, branch.childrenleaf 를 가리키는 참조 순환이 생기며, 두 값의 strong_count 는 절대 0이 되지 않을 것이기 때문입니다.

관계를 다르게 생각해 보면, 부모 노드는 자식을 소유해야 합니다. 부모 노드가 drop 되면 자식 노드도 함께 drop 되어야 하기 때문입니다. 하지만 자식이 부모를 소유해서는 안 됩니다. 자식 노드를 drop 하더라도 부모는 계속 존재해야 합니다. 이런 경우가 바로 약한 참조를 써야 하는 상황입니다!

따라서 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());
}

이제 어떤 노드는 부모를 가리킬 수는 있지만 부모를 소유하지는 않습니다. 목록 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 노드를 만드는 부분은 parent 필드만 빼면 목록 15-27과 비슷합니다. leaf 는 처음에는 부모가 없으므로, 비어 있는 Weak<Node> 참조를 새로 만듭니다.

이 시점에서 leaf 의 부모를 얻기 위해 upgrade 메서드를 사용하면 None 을 얻게 됩니다. 첫 번째 println! 출력에서 이를 확인할 수 있습니다.

leaf parent = None

이제 branch 노드를 만들면, branch 역시 부모가 없으므로 parent 필드 안에 새 Weak<Node> 참조를 갖게 됩니다. 그리고 동시에 leaf 를 자식 중 하나로 넣습니다. branch 안에 Node 인스턴스를 만든 뒤에는, leafparent 필드를 바꿔 branch 를 가리키는 Weak<Node> 참조를 넣을 수 있습니다. 이를 위해 leaf.parent 안의 RefCell<Weak<Node>> 에 대해 borrow_mut 를 호출하고, 그다음 branch 안의 Rc<Node> 에 대해 Rc::downgrade 를 사용해 Weak<Node> 를 만듭니다.

이제 다시 leaf 의 부모를 출력하면, 이번에는 branch 를 담은 Some variant를 얻게 됩니다. 즉 leaf 는 이제 부모에 접근할 수 있습니다! 또한 leaf 자체를 출력할 때도, 목록 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 변화 시각화하기

새 내부 스코프를 만들고 branch 생성을 그 안으로 옮기면, Rc<Node> 인스턴스의 strong_count, weak_count 값이 어떻게 바뀌는지 쉽게 볼 수 있습니다. 그렇게 하면 branch 가 만들어졌다가 스코프를 벗어날 때 어떤 일이 일어나는지도 확인할 수 있습니다. 수정된 코드는 목록 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 를 만들고 strong/weak 참조 수를 살펴보기

leaf 가 생성된 직후, 그 Rc<Node> 는 strong count 1, weak count 0을 가집니다. 내부 스코프 안에서 branch 를 만들고 그것을 leaf 와 연결하면, 그 시점에 branch 안의 Rc<Node> 는 strong count 1, weak count 1을 갖게 됩니다 (leaf.parentWeak<Node>branch 를 가리키기 때문입니다). 그리고 leaf 쪽 count를 출력해 보면, branch.children 안에 leafRc<Node> clone 이 있기 때문에 strong count 는 2가 되고, weak count 는 여전히 0임을 볼 수 있습니다.

내부 스코프가 끝나면 branch 는 스코프를 벗어나고, 그 Rc<Node> 의 strong count 는 0이 되므로 해당 Node 는 drop 됩니다. leaf.parent 에서 온 weak count 1은 Node 가 drop 되는지 여부에 아무 영향도 주지 않으므로, 메모리 누수가 발생하지 않습니다!

스코프가 끝난 뒤 leaf 의 부모에 접근하려 하면 다시 None 을 얻습니다. 프로그램 끝 시점에는 leaf 변수 하나만 그 Rc<Node> 를 가리키고 있으므로, leaf 안의 Rc<Node> 는 다시 strong count 1, weak count 0이 됩니다.

참조 수 관리와 값 drop 로직은 모두 Rc<T>Weak<T>, 그리고 이들의 Drop 구현 안에 들어 있습니다. Node 정의에서 자식에서 부모로 향하는 관계를 Weak<T> 참조로 지정했기 때문에, 부모는 자식을 가리키고 자식도 부모를 가리킬 수 있으면서도 참조 순환과 메모리 누수를 만들지 않는 구조를 얻을 수 있는 것입니다.

정리

이 장에서는 스마트 포인터를 사용해, 러스트가 일반 참조에서 기본으로 제공하는 것과는 다른 보장과 트레이드오프를 만드는 방법을 살펴보았습니다. Box<T> 는 크기가 알려져 있고 힙에 할당된 데이터를 가리킵니다. Rc<T> 는 힙 데이터에 대한 참조 수를 추적해, 하나의 데이터에 여러 소유자가 있을 수 있게 해 줍니다. 내부 가변성을 가진 RefCell<T> 는, 바깥에서 보면 불변 타입이지만 내부 값을 바꿔야 할 때 사용할 수 있는 타입을 제공하며, 대여 규칙을 컴파일 시점이 아니라 런타임에 강제합니다.

또한 스마트 포인터 기능 대부분을 가능하게 하는 Deref, Drop 트레이트도 함께 다루었습니다. 메모리 누수를 일으킬 수 있는 참조 순환과, 그것을 Weak<T> 로 방지하는 방법도 살펴보았습니다.

이 장이 흥미를 자극했고 여러분만의 스마트 포인터를 직접 구현해 보고 싶다면, “The Rustonomicon”을 참고하세요.

다음 장에서는 러스트의 동시성을 다룹니다. 그 과정에서 새로운 스마트 포인터도 몇 가지 더 배우게 됩니다.

겁 없는 동시성

동시성 프로그래밍을 안전하고 효율적으로 다루는 것은 러스트의 또 다른 중요한 목표입니다. 동시성 프로그래밍(concurrent programming) 은 프로그램의 서로 다른 부분이 독립적으로 실행되는 것을 뜻하고, 병렬 프로그래밍(parallel programming) 은 프로그램의 서로 다른 부분이 실제로 동시에 실행되는 것을 뜻합니다. 여러 프로세서를 활용하는 컴퓨터가 점점 늘어남에 따라, 이런 주제는 점점 더 중요해지고 있습니다. 역사적으로 이런 맥락의 프로그래밍은 어렵고 오류가 많았습니다. 러스트는 그것을 바꾸고자 합니다.

처음에 러스트 팀은 메모리 안전성과 동시성 문제 예방을 서로 다른 방법으로 풀어야 하는 별개의 과제라고 생각했습니다. 하지만 시간이 지나며, 소유권 시스템과 타입 시스템이 메모리 안전성 동시성 문제를 함께 다루는 강력한 도구라는 사실을 알게 되었습니다! 러스트는 소유권과 타입 검사를 활용해 많은 동시성 오류를 런타임 에러가 아니라 컴파일 타임 에러로 만들어 줍니다. 덕분에 런타임 동시성 버그가 정확히 어떤 조건에서 재현되는지 오랜 시간 추적하는 대신, 잘못된 코드는 아예 컴파일되지 않고 문제를 설명하는 오류 메시지를 보여 줍니다. 그 결과, 코드를 작성하는 도중에 문제를 고칠 수 있고, 배포한 뒤에야 버그를 잡게 되는 일을 줄일 수 있습니다. 우리는 러스트의 이런 면을 겁 없는 동시성(fearless concurrency) 이라고 부릅니다. 겁 없는 동시성 덕분에 미묘한 버그가 없고, 리팩터링해도 새 버그를 만들 가능성이 적은 코드를 작성할 수 있습니다.

Note: 편의를 위해 이 장에서는 많은 문제를 그냥 동시성 문제 라고 부르겠습니다. 더 정확히 말하면 동시성 및/또는 병렬성 문제라고 해야 하지만, 이 장에서는 “동시성” 이라는 말을 볼 때마다 머릿속으로 “동시성 및/또는 병렬성”이라고 치환해 생각하면 됩니다. 다음 장에서는 이 구분이 더 중요해지므로, 그때는 좀 더 엄밀하게 구분해 말하겠습니다.

많은 언어는 동시성 문제를 해결하는 방식에 대해 어느 정도 교조적인 태도를 가집니다. 예를 들어 Erlang은 메시지 전달 기반 동시성을 매우 우아하게 지원하지만, 스레드 간 상태를 공유하는 방법은 훨씬 덜 직관적입니다. 가능한 해결책 일부만 지원하는 것은 고수준 언어에서는 꽤 합리적인 전략입니다. 더 많은 추상화를 얻는 대신 제어권 일부를 포기하겠다고 약속하는 언어이기 때문입니다. 하지만 저수준 언어는 상황마다 가장 좋은 성능을 낼 수 있는 해결책을 제공해야 하고, 하드웨어 위의 추상화도 더 적어야 한다고 기대됩니다. 그래서 러스트는 여러분의 상황과 요구에 맞게 문제를 모델링할 수 있도록 여러 가지 도구를 제공합니다.

이 장에서는 다음 주제를 다룹니다.

  • 여러 코드 조각을 동시에 실행하기 위해 스레드 만들기
  • 채널로 스레드 사이에 메시지를 보내는 메시지 전달 동시성
  • 여러 스레드가 같은 데이터에 접근하는 공유 상태 동시성
  • 표준 라이브러리 타입뿐 아니라 사용자 정의 타입에도 러스트의 동시성 보장을 확장해 주는 SyncSend 트레이트

스레드로 코드를 동시에 실행하기

스레드로 코드를 동시에 실행하기

대부분의 현대 운영체제에서, 실행 중인 프로그램 코드는 하나의 프로세스(process) 안에서 돌고, 운영체제가 여러 프로세스를 동시에 관리합니다. 또한 프로그램 내부에도 동시에 독립적으로 실행되는 부분들을 둘 수 있습니다. 이런 독립된 부분을 실행하는 기능을 스레드(threads) 라고 부릅니다. 예를 들어 웹 서버는 여러 스레드를 두어 동시에 여러 요청에 응답할 수 있습니다.

프로그램의 계산을 여러 스레드로 나누어 여러 작업을 동시에 실행하면 성능을 높일 수 있지만, 그만큼 복잡성도 커집니다. 스레드들은 동시에 실행될 수 있기 때문에, 서로 다른 스레드에서 코드가 어떤 순서로 실행될지에 대한 보장은 기본적으로 없습니다. 그래서 다음과 같은 문제가 생길 수 있습니다.

  • 스레드가 데이터나 자원에 일관성 없는 순서로 접근하는 경쟁 상태(race conditions)
  • 두 스레드가 서로를 기다리기만 하며 둘 다 더 이상 진행하지 못하는 교착 상태(deadlocks)
  • 특정 상황에서만 발생하고, 재현과 수정이 어려운 버그

러스트는 스레드 사용의 부정적인 영향을 줄이려 하지만, 멀티스레드 문맥의 프로그래밍이 여전히 신중한 사고를 요구하고 단일 스레드 프로그램과는 다른 코드 구조를 요구한다는 사실은 변하지 않습니다.

프로그래밍 언어는 스레드를 여러 방식으로 구현합니다. 또한 많은 운영체제는 새 스레드를 만들기 위한 API를 제공합니다. 러스트 표준 라이브러리는 1:1 모델을 사용합니다. 즉, 프로그램의 언어 수준 스레드 하나당 운영체제 스레드 하나를 사용하는 방식입니다. 다른 스레딩 모델을 구현한 크레이트도 있고, 각각 1:1 모델과는 다른 트레이드오프를 가집니다. (다음 장에서 볼 러스트의 async 시스템 역시 동시성을 다루는 또 다른 방법을 제공합니다.)

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: 메인 스레드와 새 스레드가 서로 다른 것을 출력하기

러스트 프로그램의 메인 스레드가 끝나면, 생성된 모든 스레드는 작업이 끝났는지와 상관없이 함께 종료된다는 점에 주의하세요. 이 프로그램의 출력은 실행할 때마다 조금씩 다를 수 있지만, 대략 다음과 비슷할 것입니다.

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 호출은 현재 스레드 실행을 잠시 멈춰, 다른 스레드가 실행될 기회를 줍니다. 스레드들이 번갈아 실행될 가능성이 크지만, 그 순서는 보장되지 않습니다. 이 실행에서는 메인 스레드 코드보다 생성된 스레드 코드가 앞에 적혀 있음에도 메인 스레드가 먼저 출력했습니다. 그리고 생성된 스레드는 i 가 9가 될 때까지 출력하도록 했지만, 메인 스레드가 먼저 종료되었기 때문에 실제로는 5까지만 출력하고 끝났습니다.

이 코드를 실행했는데 메인 스레드 출력만 보이거나, 두 스레드 출력이 전혀 섞이지 않는다면 반복 범위 숫자를 키워 운영체제가 스레드를 바꿔 탈 기회를 더 많이 만들어 보세요.

모든 스레드가 끝날 때까지 기다리기

목록 16-1의 코드는 생성된 스레드가 대개 끝나기도 전에 메인 스레드가 먼저 종료해 버리는 문제가 있을 뿐만 아니라, 스레드 실행 순서도 보장되지 않기 때문에 생성된 스레드가 실행될 기회 자체가 없을 수도 있습니다.

생성된 스레드가 아예 실행되지 않거나, 끝나기 전에 잘리는 문제는 thread::spawn 의 반환값을 변수에 저장함으로써 해결할 수 있습니다. thread::spawn 의 반환 타입은 JoinHandle<T> 입니다. JoinHandle<T> 는 소유권을 가진 값이며, 여기에 대해 join 메서드를 호출하면 해당 스레드가 끝날 때까지 기다립니다. 목록 16-2는 목록 16-1에서 만든 스레드의 JoinHandle<T> 를 저장하고, main 이 종료되기 전에 생성된 스레드가 끝나도록 join 을 호출하는 방법을 보여 줍니다.

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::spawnJoinHandle<T> 를 저장해 스레드가 끝까지 실행되도록 보장하기

핸들에 대해 join 을 호출하면 현재 실행 중인 스레드는, 그 핸들이 나타내는 스레드가 끝날 때까지 블록됩니다. 스레드를 블록한다 는 것은, 해당 스레드가 더 이상 일을 하거나 종료하지 못하고 기다리게 된다는 뜻입니다. 우리는 메인 스레드의 for 루프 뒤에 join 을 두었으므로, 목록 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장의 [“참조를 캡처하거나 소유권을 이동하기”][capture] 절에서 클로저의 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

러스트는 v 를 어떻게 캡처할지 추론 하는데, 여기서는 println!v 에 대한 참조만 필요하므로 클로저는 v 를 빌리려 합니다. 하지만 문제가 있습니다. 러스트는 생성된 스레드가 얼마나 오래 실행될지 알 수 없으므로, v 에 대한 참조가 언제나 유효할지 판단할 수 없습니다.

목록 16-4는 이 참조가 실제로 유효하지 않을 가능성이 높은 상황을 보여 줍니다.

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 를 drop 한 뒤, 생성된 스레드 클로저가 그 참조를 쓰려는 상황

만약 러스트가 이 코드를 허용한다면, 생성된 스레드는 전혀 실행되지 못한 채 뒤로 밀릴 수도 있습니다. 하지만 생성된 스레드 안에는 v 에 대한 참조가 있습니다. 그런데 메인 스레드는 곧바로 15장에서 다뤘던 drop 함수를 사용해 v 를 drop 해 버립니다. 그렇게 되면 생성된 스레드가 실제로 실행되기 시작할 때는 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 키워드를 추가하면, 러스트가 참조를 빌리는 대신 클로저가 사용하는 값의 소유권을 가져오게 강제할 수 있습니다. 목록 16-3을 이렇게 수정한 버전이 목록 16-5이고, 이번에는 우리가 원한 대로 컴파일되고 실행됩니다.

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 사용하기

메시지 전달로 스레드 사이에 데이터 보내기

메시지 전달로 스레드 사이에 데이터 보내기

안전한 동시성을 보장하기 위한 점점 더 인기 있는 접근법 중 하나는 메시지 전달(message passing) 입니다. 이 방식에서는 스레드나 액터가 데이터를 담은 메시지를 서로 주고받으며 통신합니다. Go 언어 문서의 유명한 구호를 떠올려 보세요. “공유 메모리로 통신하지 말고, 통신함으로써 메모리를 공유하라.”

그렇다면 “메모리를 공유하면서 통신한다”는 것은 어떤 모습일까요? 또 왜 메시지 전달을 선호하는 사람들은 메모리 공유를 피하라고 이야기할까요?

어떤 의미에서 모든 언어의 채널은 단일 소유권과 비슷합니다. 값을 채널을 통해 보내면 그 값을 더 이상 쓰지 않아야 하기 때문입니다. 반면 공유 메모리 기반 동시성은 다중 소유권과 비슷합니다. 여러 스레드가 동시에 같은 메모리 위치에 접근할 수 있기 때문입니다. 15장에서 여러 소유권을 가능하게 해 주는 스마트 포인터가 얼마나 복잡성을 더하는지 보았습니다. 서로 다른 소유자들을 관리해야 했기 때문입니다. 러스트의 타입 시스템과 소유권 규칙은 이 관리를 올바르게 하는 데 큰 도움을 줍니다. 이제 공유 메모리의 흔한 동시성 원시 도구 중 하나인 mutex를 예로 들어 이를 살펴보겠습니다.

뮤텍스로 접근 제어하기

Mutexmutual exclusion 의 줄임말로, 어떤 순간에도 오직 하나의 스레드만 특정 데이터에 접근할 수 있게 해 줍니다. 뮤텍스 안의 데이터에 접근하려면, 먼저 스레드가 접근 권한을 원한다고 알려 락(lock)을 획득해야 합니다. 은 현재 누가 그 데이터에 대한 배타적 접근권을 가지고 있는지를 추적하는, 뮤텍스의 일부인 데이터 구조입니다. 그래서 흔히 뮤텍스가 자신이 담고 있는 데이터를 “락으로 지킨다”고 표현합니다.

뮤텍스는 두 가지 규칙을 기억해야 해서 사용하기 어렵다는 평판이 있습니다.

  1. 데이터를 사용하기 전에 반드시 락을 획득하려고 시도해야 한다.
  2. 뮤텍스가 지키는 데이터를 다 쓴 뒤에는, 다른 스레드가 락을 잡을 수 있도록 반드시 락을 해제해야 한다.

실생활 비유로는, 하나의 마이크만 있는 컨퍼런스 패널 토론을 떠올려 보면 됩니다. 패널 한 명이 말하고 싶으면 먼저 마이크를 쓰고 싶다고 요청해야 하고, 마이크를 받으면 원하는 만큼 말할 수 있습니다. 그리고 나면 마이크를 다음으로 말하고 싶은 사람에게 넘겨줘야 합니다. 만약 누군가가 다 말한 뒤에도 마이크를 넘기지 않으면, 아무도 더 이상 말을 할 수 없게 됩니다. 공유 마이크 관리가 꼬이면 패널 전체가 엉망이 되는 것이지요.

뮤텍스 관리는 올바르게 구현하기가 정말 까다로울 수 있기 때문에, 많은 사람들이 채널에 열광합니다. 하지만 러스트에서는 타입 시스템과 소유권 규칙 덕분에 락 획득과 해제를 잘못하는 일이 훨씬 어렵습니다.

Mutex<T> API

뮤텍스를 어떻게 쓰는지 보기 위해, 먼저 단일 스레드 환경에서 단순하게 사용해 보겠습니다. 목록 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 의 타입은 Mutex<i32> 이지 i32 가 아니기 때문에, 우리는 반드시 lock 을 호출해야만 안의 i32 값에 접근할 수 있습니다. 이 과정을 잊어버릴 수 없습니다. 타입 시스템이 그걸 허용하지 않기 때문입니다.

lock 호출은 LockResult 로 감싼 MutexGuard 타입을 반환하며, 우리는 unwrap 으로 이를 처리했습니다. MutexGuard 타입은 안쪽 데이터를 가리키도록 Deref 를 구현하고 있고, 또한 Drop 구현을 가지고 있어 MutexGuard 가 스코프를 벗어나는 순간 락을 자동으로 해제합니다. 이 예제에서는 내부 스코프가 끝날 때 그렇게 됩니다. 덕분에 락 해제를 잊어 다른 스레드가 뮤텍스를 사용하지 못하게 막아 버릴 위험이 줄어듭니다.

락이 해제된 뒤에는 뮤텍스 값을 출력하고, 안의 i32 값을 6 으로 바꿀 수 있었다는 사실을 확인할 수 있습니다.

공유 상태 동시성

공유 상태 동시성

메시지 전달은 동시성을 다루는 훌륭한 방법이지만, 유일한 방법은 아닙니다. 또 다른 방식은 여러 스레드가 같은 공유 데이터에 접근하는 것입니다. Go 언어 문서의 구호 일부를 다시 떠올려 보세요. “공유 메모리로 통신하지 말라.”

그렇다면 공유 메모리로 통신하는 것은 실제로 어떤 모습일까요? 그리고 메시지 전달을 선호하는 사람들은 왜 메모리 공유를 피하라고 할까요?

어떤 의미에서, 어떤 언어의 채널이든 단일 소유권과 비슷합니다. 값을 채널로 보낸 뒤에는 그 값을 더 이상 사용하지 않아야 하기 때문입니다. 반면 공유 메모리 동시성은 다중 소유권과 비슷합니다. 여러 스레드가 같은 메모리 위치에 동시에 접근할 수 있기 때문입니다. 15장에서 스마트 포인터로 다중 소유권을 가능하게 만들었을 때처럼, 여러 소유자는 관리해야 할 복잡성을 늘립니다. 러스트의 타입 시스템과 소유권 규칙은 이 관리를 올바르게 하는 데 큰 도움을 줍니다. 이제 공유 메모리에서 자주 쓰이는 동시성 원시 도구 중 하나인 뮤텍스를 예로 들어 보겠습니다.

뮤텍스로 접근 제어하기

Mutexmutual exclusion 의 줄임말로, 어느 한 시점에는 오직 하나의 스레드만 특정 데이터에 접근하도록 허용한다는 뜻입니다. 뮤텍스 안 데이터에 접근하려면, 스레드는 먼저 접근하겠다고 신호를 보내 락을 획득해야 합니다. 락(lock) 은 현재 누가 데이터에 대한 배타적 접근권을 갖고 있는지를 추적하는, 뮤텍스 내부의 자료구조입니다. 그래서 뮤텍스는 자신이 담고 있는 데이터를 락으로 보호(guard) 한다고 말합니다.

뮤텍스는 올바르게 사용하기가 어렵다는 평판이 있습니다. 두 가지 규칙을 반드시 기억해야 하기 때문입니다.

  1. 데이터를 사용하기 전에 반드시 락을 획득해야 한다.
  2. 뮤텍스가 보호하는 데이터를 다 사용한 뒤에는, 다른 스레드가 락을 획득할 수 있도록 반드시 잠금을 해제해야 한다.

실생활 비유로는, 하나의 마이크만 있는 컨퍼런스 패널 토론을 떠올려 볼 수 있습니다. 패널 한 명이 말하려면 먼저 마이크를 쓰고 싶다고 요청해야 하고, 마이크를 받으면 원하는 만큼 말한 뒤 다음 사람에게 넘겨줘야 합니다. 만약 누군가가 다 말한 뒤에도 마이크를 넘기지 않으면, 아무도 더 이상 발언할 수 없습니다. 공유 마이크 관리가 틀어지면 토론이 제대로 진행되지 않는 것과 같습니다.

뮤텍스 관리는 정말 까다로울 수 있기 때문에, 많은 사람이 채널을 더 선호합니다. 하지만 러스트에서는 타입 시스템과 소유권 규칙 덕분에 락을 잘못 다루는 실수를 훨씬 줄일 수 있습니다.

Mutex<T> API

뮤텍스를 어떻게 사용하는지 보기 위해, 먼저 단일 스레드 상황에서 단순하게 살펴봅시다. 목록 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 의 타입은 Mutex<i32> 이고 i32 가 아니므로, 안의 i32 값에 접근하려면 반드시 lock 을 호출해야 합니다.

lock 호출은 LockResult 로 감싼 MutexGuard 타입을 반환하며, 우리는 unwrap 으로 이를 꺼냈습니다. MutexGuardDeref 를 구현해 안쪽 데이터를 가리키고, 또 Drop 구현도 있어서 MutexGuard 가 스코프를 벗어나면 자동으로 락이 해제됩니다. 덕분에 락을 놓치는 실수로 다른 스레드가 영영 기다리게 될 위험이 줄어듭니다.

락이 해제된 뒤에는 뮤텍스 값을 출력할 수 있고, 그 안의 i326 으로 바꿀 수 있었음을 확인할 수 있습니다.

여러 스레드와 Mutex<T> 공유하기

이제 Mutex<T> 를 여러 스레드 사이에서 공유해 봅시다. 스레드 열 개를 만들고, 각각이 카운터 값을 1씩 늘리게 해서, 카운터가 0에서 10까지 올라가도록 하겠습니다. 목록 16-13의 예제는 처음에는 컴파일 오류가 날 것이고, 이 오류를 통해 Mutex<T> 의 사용법과 러스트가 이를 어떻게 올바르게 쓰게 도와주는지 더 배워 볼 것입니다.

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: 각각 카운터를 1씩 증가시키는 열 개 스레드와 Mutex<T>

우리는 목록 16-12에서 했던 것처럼 i32 값을 담은 Mutex<T>counter 변수에 만듭니다. 그 다음 숫자 범위를 순회하며 스레드 열 개를 생성합니다. 우리는 thread::spawn 에 같은 클로저를 넘깁니다. 이 클로저는 카운터를 스레드 안으로 이동시키고, lock 메서드로 Mutex<T> 의 락을 획득한 뒤, 뮤텍스 안 값에 1을 더합니다. 스레드가 클로저 실행을 마치면, num 이 스코프를 벗어나며 락이 해제되어 다른 스레드가 락을 얻을 수 있게 됩니다.

메인 스레드에서는 모든 JoinHandle 을 모읍니다. 그리고 목록 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 `std::sync::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 값이 이전 반복에서 이미 이동되었다고 말합니다. 다시 말해 러스트는 같은 락 counter 의 소유권을 여러 스레드로 여러 번 이동시키는 것을 허용하지 않는다고 알려 주는 것입니다. 이제 15장에서 다룬 다중 소유권 방식으로 이 컴파일 오류를 해결해 보겠습니다.

여러 스레드에서 다중 소유권 사용하기

15장에서는 스마트 포인터 Rc<T> 를 사용해 하나의 값을 여러 소유자가 가지게 할 수 있다는 것을 보았습니다. 여기서도 같은 접근을 해 보고 어떤 일이 벌어지는지 확인해 봅시다. 목록 16-14에서는 Mutex<T>Rc<T> 로 감싸고, 스레드로 소유권을 넘기기 전에 Rc<T> 를 clone 합니다.

Filename: src/main.rs
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());
}
Listing 16-14: 여러 스레드가 Mutex<T> 를 소유하게 하려고 Rc<T> 사용 시도하기

다시 컴파일하면… 이번에는 다른 오류를 얻게 됩니다. 컴파일러가 계속 많이 가르쳐 주고 있죠.

$ cargo run
   Compiling shared-state v0.1.0 (file:///projects/shared-state)
error[E0277]: `Rc<std::sync::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<std::sync::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<std::sync::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/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:723: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>>` cannot be sent between threads safely. 그리고 그 이유로 the trait `Send` is not implemented for `Rc<Mutex<i32>>` 라고 알려 줍니다. 다음 절에서 Send 에 대해 이야기하겠지만, 지금은 스레드와 함께 쓰기에 적합한 타입인지 보장하는 트레이트 중 하나라고만 알면 됩니다.

안타깝게도 Rc<T> 는 스레드 사이에서 공유해도 안전하지 않습니다. Rc<T> 는 각 clone 호출 때 참조 수를 늘리고, 각 clone 이 drop 될 때 그 수를 줄입니다. 하지만 이 참조 수 변경이 다른 스레드에 의해 중간에 끼어들지 않도록 보장하는 동시성 원시 도구를 사용하지 않습니다. 그 결과 참조 수가 잘못 계산될 수 있고, 이는 메모리 누수나 아직 사용 중인 값이 너무 일찍 drop 되는 문제로 이어질 수 있습니다. 우리가 필요한 것은 Rc<T> 와 똑같이 생겼지만, 참조 수 변경을 스레드 안전하게 처리하는 타입입니다.

Arc<T> 로 원자적 참조 카운팅하기

다행히 동시성 상황에서 안전하게 쓸 수 있는 Rc<T> 의 대안으로 Arc<T> 가 있습니다. 여기서 aatomic 의 약자로, 즉 원자적 참조 카운팅(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> 를 사용한다고 해서 러스트가 모든 논리 오류까지 막아 주는 것은 아니라는 점입니다. 15장에서 Rc<T> 를 쓸 때 참조 순환으로 메모리 누수를 만들 수 있었던 것을 떠올려 보세요. 비슷하게 Mutex<T> 에는 교착 상태(deadlocks) 를 만들 위험이 있습니다. 이는 어떤 연산이 두 자원 모두의 락을 필요로 하는데, 두 스레드가 각각 하나의 락씩을 이미 쥐고 있어서 서로를 영원히 기다리게 되는 상황입니다. 교착 상태에 관심이 있다면, 일부러 교착 상태가 일어나는 러스트 프로그램을 하나 만들어 보고, 어떤 언어에서든 뮤텍스 교착 상태를 줄이는 전략을 조사한 뒤 러스트에 적용해 보는 것도 좋은 연습입니다. 표준 라이브러리의 Mutex<T>MutexGuard 문서는 이와 관련된 유용한 정보를 제공합니다.

이제 이 장을 마무리하면서 SendSync 트레이트, 그리고 이들을 사용자 정의 타입과 함께 어떻게 사용하는지 이야기해 보겠습니다.

Send와 Sync로 확장 가능한 동시성 만들기

SendSync로 확장 가능한 동시성 만들기

흥미롭게도, 지금까지 이 장에서 다룬 거의 모든 동시성 기능은 언어 자체보다는 표준 라이브러리의 일부였습니다. 즉 동시성을 다루는 방법이 언어나 표준 라이브러리에만 제한되는 것은 아닙니다. 여러분이 직접 동시성 도구를 만들 수도 있고, 다른 사람이 만든 것을 사용할 수도 있습니다.

하지만 표준 라이브러리가 아니라 언어 차원 에 박혀 있는 중요한 동시성 개념으로는 std::markerSendSync 트레이트가 있습니다.

스레드 사이에서 소유권 이전하기

Send 마커 트레이트는, 이 트레이트를 구현한 타입의 값 소유권을 스레드 사이에서 옮기는 것이 안전하다는 뜻입니다. 거의 모든 러스트 타입은 Send 를 구현하지만, 예외도 있습니다. 대표적인 것이 Rc<T> 입니다. Rc<T> 값을 clone 한 뒤 그 clone 의 소유권을 다른 스레드로 넘기면, 두 스레드가 동시에 참조 수를 갱신하려 할 수 있기 때문입니다. 그래서 Rc<T> 는 스레드 안전성 비용을 지불하고 싶지 않은 단일 스레드 환경 전용으로만 구현되어 있습니다.

이 때문에 러스트의 타입 시스템과 트레이트 바운드는, Rc<T> 값을 스레드 사이로 실수로 안전하지 않게 보내는 일을 원천적으로 막아 줍니다. 목록 16-14에서 우리가 실제로 그런 시도를 했을 때, the trait `Send` is not implemented for `Rc<Mutex<i32>>` 라는 오류를 받았습니다. 반대로 Send 를 구현한 Arc<T> 로 바꾸자 코드는 정상적으로 컴파일되었습니다.

다른 모든 구성 요소가 Send 인 타입으로만 이루어진 타입은 자동으로 Send 로 표시됩니다. 거의 모든 기본 타입도 Send 인데, 20장에서 다룰 raw 포인터만은 예외입니다.

여러 스레드에서 접근 허용하기

Sync 마커 트레이트는, 이 트레이트를 구현한 타입에 대해 여러 스레드가 참조하는 것이 안전하다는 뜻입니다. 다시 말해 어떤 타입 TSync 라는 것은, &T (즉 T 에 대한 불변 참조)가 Send 를 구현한다는 말과 같습니다. 그래서 그 참조를 다른 스레드로 안전하게 보낼 수 있습니다. Send 와 비슷하게 기본 타입들은 모두 Sync 를 구현하고, Sync 타입들로만 이루어진 타입 역시 자동으로 Sync 를 구현합니다.

스마트 포인터 Rc<T>Send 가 아니었던 것과 같은 이유로 Sync 도 구현하지 않습니다. 15장에서 다룬 RefCell<T> 와 그 친척 타입들인 Cell<T>Sync 를 구현하지 않습니다. RefCell<T> 가 런타임에 하는 대여 검사 구현은 스레드 안전하지 않기 때문입니다. 반면 Mutex<T> 스마트 포인터는 Sync 를 구현하며, 앞의 “뮤텍스로 접근 제어하기” 절에서 보았듯 여러 스레드가 공유해 사용할 수 있습니다.

SendSync 를 수동 구현하는 것은 unsafe 하다

SendSync 를 구현하는 타입들로만 이루어진 타입은 자동으로 Send, Sync 도 구현하므로, 보통은 우리가 이 트레이트들을 직접 구현할 필요가 없습니다. 또한 이들은 마커 트레이트라서 구현해야 할 메서드조차 없습니다. 동시성과 관련된 어떤 불변 조건을 강제하는 데 쓰이는 표시일 뿐입니다.

이 트레이트들을 수동으로 구현하는 일은 unsafe 러스트 코드를 수반합니다. unsafe 러스트는 20장에서 다루겠지만, 지금 중요한 점은 SendSync 가 아닌 부품들로 새로운 동시성 타입을 만들려면, 안전성 보장을 유지하기 위해 아주 신중한 설계가 필요하다는 것입니다. “The Rustonomicon”에는 이런 보장이 무엇이고 어떻게 지켜야 하는지에 대한 더 많은 정보가 있습니다.

정리

이 책에서 동시성을 보는 것은 아직 여기서 끝이 아닙니다. 다음 장에서는 async 프로그래밍에 집중하고, 21장의 프로젝트에서도 이번 장의 개념을 더 현실적인 상황에서 다시 사용하게 됩니다.

앞에서도 말했듯이, 러스트가 동시성을 다루는 방식 중 언어 자체에 속한 부분은 많지 않습니다. 그래서 많은 동시성 해결책은 크레이트로 구현되어 있습니다. 이런 크레이트는 표준 라이브러리보다 더 빠르게 발전하므로, 멀티스레드 상황에서 최신 기법을 찾고 싶다면 온라인에서 현재 잘 유지되는 크레이트들을 찾아보는 것이 좋습니다.

러스트 표준 라이브러리는 메시지 전달을 위한 채널과, Mutex<T>, Arc<T> 같은 동시성 문맥에서 안전한 스마트 포인터 타입을 제공합니다. 타입 시스템과 대여 검사기는 이런 도구를 사용하는 코드가 데이터 경쟁이나 무효한 참조로 끝나지 않도록 보장합니다. 일단 코드가 컴파일만 된다면, 여러 스레드에서 실행될 때도 다른 언어에서 흔히 보이는 추적하기 어려운 버그 없이 잘 돌아갈 것이라고 믿을 수 있습니다. 이제 동시성은 더 이상 무서워해야 할 개념이 아닙니다. 마음껏, 겁 없이 프로그램을 동시적으로 만들어 보세요!

비동기 프로그래밍의 기초: Async, Await, Futures, Streams

우리가 컴퓨터에게 시키는 작업 중에는 끝나기까지 시간이 꽤 걸리는 것이 많습니다. 오래 걸리는 작업이 끝나기를 기다리는 동안 다른 일도 할 수 있다면 좋겠지요. 현대의 컴퓨터는 한 번에 둘 이상의 작업을 다루기 위한 두 가지 기법, 즉 병렬성(parallelism)과 동시성(concurrency)을 제공합니다. 하지만 우리 프로그램의 논리는 대체로 선형적으로 작성됩니다. 우리는 프로그램이 수행해야 할 작업과, 함수가 잠시 멈추고 다른 부분이 대신 실행될 수 있는 지점을 표현하고 싶어 합니다. 그 과정에서 각 코드 조각이 정확히 어떤 순서와 방식으로 실행될지를 미리 전부 지정할 필요는 없습니다. 비동기 프로그래밍(asynchronous programming) 은 잠재적인 일시 중지 지점과 나중에 도착할 결과를 기준으로 코드를 표현하게 해 주는 추상화이며, 조율에 필요한 세부 사항은 그 추상화가 대신 처리합니다.

이 장은 16장에서 스레드를 이용해 병렬성과 동시성을 다뤘던 내용을 바탕으로, 코드를 작성하는 또 다른 접근법을 소개합니다. 바로 Rust의 퓨처(future), 스트림(stream), 그리고 연산이 비동기적으로 수행될 수 있음을 표현하는 async, await 문법, 그리고 비동기 런타임을 구현하는 서드파티 크레이트입니다. 비동기 런타임은 비동기 연산의 실행을 관리하고 조율하는 코드입니다.

예를 하나 생각해 봅시다. 가족 행사를 찍은 영상을 내보내기(export)한다고 해보죠. 이 작업은 몇 분이 걸릴 수도 있고 몇 시간이 걸릴 수도 있습니다. 영상 내보내기는 가능한 한 많은 CPU와 GPU 성능을 사용하려 할 것입니다. 만약 CPU 코어가 하나뿐이고 운영체제가 그 작업이 끝날 때까지 중간에 멈추지 않는다면, 즉 내보내기를 동기적으로(synchronously) 실행한다면, 그 작업이 돌아가는 동안 컴퓨터에서 다른 일을 전혀 할 수 없을 것입니다. 꽤 답답한 경험이 되겠지요. 다행히도 실제 운영체제는 내보내기 작업을 보이지 않게 자주 끊어 주기 때문에, 그동안에도 다른 작업을 동시에 할 수 있습니다.

이번에는 다른 사람이 공유한 영상을 다운로드한다고 해봅시다. 이것도 시간이 꽤 걸릴 수 있지만 CPU 시간을 그렇게 많이 쓰지는 않습니다. 이 경우 CPU는 네트워크로 부터 데이터가 도착하기를 기다려야 합니다. 데이터가 도착하기 시작하면 바로 읽기 시작할 수는 있지만, 전부 도착하기까지는 시간이 걸릴 수 있습니다. 데이터가 다 도착한 뒤에도 영상이 크다면 전부 읽어 들이는 데 1~2초는 족히 걸릴 수 있습니다. 짧게 들릴지 모르지만, 초당 수십억 번의 연산을 수행하는 현대 프로세서에게는 아주 긴 시간입니다. 이 경우에도 운영체제는 네트워크 호출이 끝나기를 기다리는 동안 프로그램을 보이지 않게 중단시켜 CPU가 다른 일을 할 수 있도록 합니다.

영상 내보내기는 CPU-bound 또는 compute-bound 연산의 예입니다. 이 작업은 CPU나 GPU 내부에서 데이터를 처리할 수 있는 잠재적인 속도, 그리고 그 속도 중 얼마나 많은 부분을 해당 작업에 할당할 수 있는지에 의해 제한됩니다. 반면 영상 다운로드는 I/O-bound 연산의 예입니다. 이 작업은 컴퓨터의 입출력(input and output) 속도에 의해 제한되며, 네트워크를 통해 데이터가 전달되는 속도 이상으로 빨라질 수 없습니다.

이 두 예 모두에서 운영체제가 보이지 않게 수행하는 인터럽트는 동시성의 한 형태를 제공합니다. 다만 이 동시성은 전체 프로그램 수준에서만 일어납니다. 운영체제가 한 프로그램을 멈추고 다른 프로그램이 일을 하게 만드는 방식이지요. 하지만 많은 경우에 우리는 운영체제보다 훨씬 더 세밀한 수준에서 우리 프로그램을 이해하고 있기 때문에, 운영체제가 볼 수 없는 동시성의 기회를 직접 발견할 수 있습니다.

예를 들어 파일 다운로드를 관리하는 도구를 만든다고 하면, 하나의 다운로드를 시작했다고 해서 UI가 멈추면 안 되고, 사용자는 여러 다운로드를 동시에 시작할 수 있어야 합니다. 그런데 네트워크와 상호작용하는 많은 운영체제 API는 blocking 입니다. 즉, 처리 중인 데이터가 완전히 준비될 때까지 프로그램의 진행을 가로막는 방식입니다.

Note: 생각해 보면 대부분의 함수 호출이 원래 이런 식으로 동작합니다. 다만 blocking 이라는 용어는 보통 파일, 네트워크, 혹은 컴퓨터의 다른 자원과 상호작용하는 함수 호출에 주로 사용됩니다. 그런 경우에야말로 개별 프로그램이 non-blocking 방식에서 실제 이점을 얻기 때문입니다.

메인 스레드가 막히는 것을 피하려고 각 파일 다운로드마다 전용 스레드를 하나씩 만들 수도 있습니다. 하지만 그렇게 하면 결국 그 스레드들이 사용하는 시스템 자원의 오버헤드가 문제가 됩니다. 더 바람직한 방법은 호출 자체가 처음부터 블로킹되지 않고, 대신 프로그램이 완료하고 싶은 작업들의 집합을 정의해 두면 런타임이 그것을 어떤 순서와 방식으로 실행하는 것이 최선인지 선택하게 하는 것입니다.

바로 이것이 Rust의 async(asynchronous 의 줄임말) 추상화가 제공하는 것입니다. 이 장에서는 다음 내용을 통해 async를 전반적으로 배워 보겠습니다.

  • Rust의 async, await 문법을 사용하는 방법과, 런타임으로 비동기 함수를 실행하는 방법
  • 16장에서 살펴본 몇 가지 문제를 async 모델로 해결하는 방법
  • 멀티스레딩과 async가 서로 보완적인 해결책을 제공하며, 많은 경우 함께 조합할 수 있다는 점

하지만 async가 실제로 어떻게 동작하는지 보기 전에, 먼저 병렬성과 동시성의 차이를 짚고 넘어가야 합니다.

병렬성과 동시성

지금까지는 병렬성과 동시성을 거의 같은 의미로 다뤄 왔습니다. 하지만 이제부터는 두 개념을 더 정확하게 구분해야 합니다. 실제로 작업을 진행해 나가다 보면 그 차이가 드러나기 때문입니다.

소프트웨어 프로젝트에서 팀이 일을 나누는 여러 방식을 생각해 봅시다. 한 사람에게 여러 작업을 맡길 수도 있고, 각 사람에게 하나의 작업만 맡길 수도 있으며, 두 방식을 섞어 쓸 수도 있습니다.

어떤 개인이 여러 작업을 오가며, 그중 어느 것도 끝내지 않은 상태에서 계속 진행한다면 이것은 동시성(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 사이를 오가며 진행하는 동시적 워크플로

반대로 팀이 작업 묶음을 나누어 각 구성원이 하나의 작업만 맡아 독립적으로 수행한다면, 이것은 병렬성(parallelism) 입니다. 팀의 각 사람은 정확히 같은 시간에 진척을 만들 수 있습니다(그림 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에서 독립적으로 일이 진행되는 병렬 워크플로

이 두 워크플로 모두에서 서로 다른 작업 사이 조율이 필요할 수 있습니다. 어떤 사람에게 맡긴 작업이 완전히 독립적이라고 생각했는데, 실제로는 팀의 다른 사람이 자기 작업을 먼저 끝내야만 진행될 수도 있습니다. 어떤 일은 병렬로 처리할 수 있지만, 또 어떤 일은 실제로 직렬적(serial) 입니다. 즉 그림 17-3처럼 하나의 작업이 끝나야 다음 작업이 이어지는 순차적인 흐름에서만 진행될 수 있습니다.

Task A와 Task B라고 적힌 두 상자가 층층이 쌓여 있고, 그 안에 하위 작업을 나타내는 마름모가 있는 도식. Task A에서는 화살표가 A1에서 A2로, A2에서 ‘일시 정지’ 기호처럼 보이는 두꺼운 세로선 한 쌍으로, 그리고 그 기호에서 A3으로 이어진다. Task B에서는 화살표가 B1에서 B2, B2에서 B3, B3에서 A3, 그리고 B3에서 B4로 이어진다.
그림 17-3: Task A3가 Task B3의 결과를 기다리기 전까지는 Task A와 Task B가 독립적으로 진행되는 부분 병렬 워크플로

마찬가지로, 자신이 맡은 작업 중 하나가 다른 작업에 의존한다는 사실을 깨달을 수도 있습니다. 그러면 원래는 동시적으로 처리하던 일도 직렬적으로 바뀝니다.

병렬성과 동시성은 서로 교차할 수도 있습니다. 동료가 당신의 작업 하나가 끝나기를 기다리며 막혀 있다는 사실을 알게 되면, 아마 그 동료의 막힘을 풀어 주기 위해 그 작업에 집중하게 될 것입니다. 그러면 당신과 동료는 더 이상 병렬로 일할 수 없고, 당신 자신도 여러 작업을 동시적으로 오가며 처리하기 어려워집니다.

소프트웨어와 하드웨어에서도 같은 기본 원리가 적용됩니다. CPU 코어가 하나뿐인 머신에서는 CPU가 한 번에 하나의 연산만 수행할 수 있지만, 여전히 동시적으로 작업할 수는 있습니다. 스레드, 프로세스, async 같은 도구를 사용하면 컴퓨터는 하나의 작업을 잠시 멈추고 다른 작업으로 전환한 뒤, 나중에 다시 처음 작업으로 돌아올 수 있습니다. CPU 코어가 여러 개인 머신이라면 병렬 작업도 가능합니다. 한 코어가 한 작업을 수행하는 동안 다른 코어는 전혀 관계없는 다른 작업을 수행할 수 있으며, 이 연산들은 실제로 같은 시간에 일어납니다.

Rust에서 async 코드를 실행할 때는 보통 동시성이 발생합니다. 그리고 사용하는 하드웨어, 운영체제, 비동기 런타임(곧 자세히 살펴봅니다)에 따라, 그 동시성은 내부 구현에서 병렬성을 활용할 수도 있습니다.

이제 Rust의 비동기 프로그래밍이 실제로 어떻게 동작하는지 살펴봅시다.

퓨처와 async 문법

퓨처와 async 문법

러스트에서 비동기 프로그래밍의 핵심 요소는 퓨처(futures)async, await 키워드입니다.

퓨처 는 지금 당장은 준비되지 않았지만, 미래의 어떤 시점에는 준비될 값을 뜻합니다. (이 개념은 다른 언어에도 있으며, taskpromise 같은 이름으로 불리기도 합니다.) 러스트는 Future 트레이트를 제공해, 서로 다른 비동기 연산이 각자 다른 자료구조로 구현되더라도 공통 인터페이스를 가지게 해 줍니다. 러스트에서 퓨처는 Future 트레이트를 구현하는 타입입니다. 각 퓨처는 자기 자신의 진행 상태와 “준비됨(ready)”이 무엇을 의미하는지에 대한 정보를 직접 가지고 있습니다.

async 키워드는 블록과 함수에 적용할 수 있으며, 그 코드가 중간에 멈췄다가 다시 이어질 수 있음을 뜻합니다. async 블록이나 async 함수 안에서는 await 키워드로 퓨처를 기다릴 수 있습니다. 즉, 준비될 때까지 기다립니다. await 를 사용하는 지점은 언제든 그 블록이나 함수가 잠시 멈췄다가 다시 이어질 수 있는 위치입니다. 퓨처의 값이 아직 준비되었는지 확인하는 과정을 polling 이라고 부릅니다.

C# 이나 JavaScript 같은 다른 언어도 asyncawait 키워드를 사용합니다. 그 언어들을 써 본 적이 있다면, 러스트가 이 문법을 다루는 방식이 꽤 다르다는 사실을 금방 눈치챌 수도 있습니다. 곧 그 이유를 보게 될 것입니다!

러스트 async 코드를 작성할 때는 대부분 asyncawait 키워드를 사용합니다. 러스트는 이를 13장에서 for 루프를 Iterator 트레이트 기반의 코드로 컴파일했던 것과 비슷한 방식으로, Future 트레이트를 사용하는 코드로 컴파일합니다. 하지만 러스트는 Future 트레이트 자체를 제공하므로, 필요하다면 여러분 자신의 타입에도 직접 이를 구현할 수 있습니다. 이 장에서 볼 많은 함수는 각자 자기만의 Future 구현을 가진 타입을 반환합니다. 장 마지막에 다시 트레이트 정의로 돌아와, 그것이 어떻게 작동하는지도 더 깊게 파헤칠 것입니다. 하지만 지금은 계속 진행하기에 충분한 정도만 알고 있으면 됩니다.

여기까지는 조금 추상적으로 느껴질 수 있으니, 첫 번째 async 프로그램을 하나 만들어 봅시다. 간단한 웹 스크레이퍼입니다. 명령줄에서 두 URL을 받아 둘 다 동시에 요청하고, 더 빨리 끝난 쪽의 <title> 요소를 출력하는 프로그램을 만들겠습니다. 새로운 문법이 조금 많이 등장하겠지만, 진행하면서 필요한 것은 전부 설명할 것입니다.

첫 번째 async 프로그램

이 장의 초점을 생태계 도구 조합이 아니라 async 개념 자체에 두기 위해, 우리는 trpl 이라는 크레이트를 미리 준비해 두었습니다(trpl 은 “The Rust Programming Language” 의 약자입니다). 이 크레이트는 주로 futurestokio 크레이트에서 필요한 타입, 트레이트, 함수를 재수출합니다. futures 크레이트는 async 러스트 실험을 위한 공식적인 공간이며, Future 트레이트가 원래 설계된 곳이기도 합니다. Tokio는 오늘날 러스트에서 가장 널리 쓰이는 async 런타임이며, 특히 웹 애플리케이션 분야에서 많이 사용됩니다. 물론 다른 훌륭한 런타임도 많고, 목적에 따라 그것들이 더 적합할 수도 있습니다. 여기서는 Tokio가 널리 쓰이고 잘 검증된 런타임이기 때문에 trpl 내부에서 그것을 사용합니다.

어떤 경우에는 trpl 이 원래 API를 그대로 재수출하지 않고 이름을 바꾸거나 얇게 감싸서, 이 장에서 중요한 세부에만 집중할 수 있게 해 줍니다. 이 크레이트가 실제로 무엇을 하는지 궁금하다면 소스 코드를 직접 보세요. 각 재수출이 어떤 크레이트에서 왔는지도 확인할 수 있고, 무엇을 위해 그렇게 감쌌는지에 대한 설명도 충분히 달아 두었습니다.

hello-async 라는 새 바이너리 프로젝트를 만들고, trpl 크레이트를 의존성으로 추가합시다.

$ cargo new hello-async
$ cd hello-async
$ cargo add trpl

이제 trpl 이 제공하는 여러 구성 요소를 사용해 첫 번째 async 프로그램을 작성할 수 있습니다. 두 웹 페이지를 가져오고, 각 페이지의 <title> 요소를 뽑아낸 뒤, 그 전체 과정이 더 빨리 끝난 페이지의 제목을 출력하는 작은 커맨드라인 도구를 만들 것입니다.

page_title 함수 정의하기

먼저 페이지 URL 하나를 받아 요청을 보내고, 그 HTML 안의 <title> 요소 텍스트를 반환하는 함수를 작성해 봅시다. 목록 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| title.inner_html())
}
Listing 17-1: HTML 페이지에서 제목 요소를 가져오는 async 함수 정의하기

먼저 page_title 이라는 함수를 정의하고 async 키워드를 붙입니다. 그런 다음 trpl::get 함수를 사용해 전달된 URL을 요청하고, 응답을 await 로 기다립니다. 응답 본문 텍스트를 얻기 위해 responsetext 메서드를 호출하고, 이것도 다시 await 합니다. 이 두 단계는 모두 비동기입니다. get 은 서버가 응답의 첫 부분 (HTTP 헤더, 쿠키 등)를 보내 줄 때까지 기다려야 하고, 응답 본문과는 따로 올 수도 있습니다. 특히 본문이 크다면 전체가 도착하기까지 시간이 걸릴 수 있습니다. 그리고 본문 전체 가 도착해야만 읽을 수 있으므로, text 메서드도 비동기입니다.

이 두 퓨처를 모두 명시적으로 await 해야 하는 이유는, 러스트의 퓨처가 게으르기 때문입니다. await 로 “지금 실행해”라고 요청하기 전까지는 아무 것도 하지 않습니다. (실제로 퓨처를 사용하지 않으면 러스트는 컴파일 경고도 보여 줍니다.) 이것은 13장의 “반복자로 항목 시리즈 처리하기” 절에서 했던 반복자 이야기와 비슷합니다. 반복자도 next 를 직접 호출하거나, for 루프나 map 같은 메서드가 내부적으로 next 를 호출하기 전까지는 아무 일도 하지 않습니다. 마찬가지로 퓨처도 명시적으로 await 하기 전까지는 아무 일도 하지 않습니다. 이런 게으름은 러스트가 정말 필요할 때까지 async 코드를 실행하지 않게 해 줍니다.

Note: 이것은 16장의 spawn 으로 새 스레드 만들기” 절에서 봤던 thread::spawn 과는 다릅니다. 그때는 다른 스레드에 넘긴 클로저가 즉시 실행되기 시작했습니다. 다른 많은 언어의 async와도 다릅니다. 하지만 반복자와 마찬가지로, 러스트가 성능 보장을 제공하려면 이런 동작 방식이 중요합니다.

response_text 를 얻은 뒤에는, 이를 Html::parse 를 사용해 Html 타입 인스턴스로 파싱할 수 있습니다. 이제 우리는 단순 문자열이 아니라, HTML을 더 풍부한 자료구조로 다룰 수 있는 타입을 가지게 됩니다. 특히 select_first 메서드를 사용해 주어진 CSS 선택자와 맞는 첫 번째 요소를 찾을 수 있습니다. 문자열 "title" 을 넘기면 문서 안의 첫 <title> 요소를 얻게 됩니다. 매칭되는 요소가 없을 수도 있으므로 select_firstOption<ElementRef> 를 반환합니다. 마지막으로 Option::map 메서드를 사용해, 값이 있을 때만 그 안의 값을 다루고 없으면 아무 것도 하지 않게 할 수 있습니다. (여기서 match 식을 써도 되지만 map 이 더 관용적입니다.) map 에 넘기는 함수 본문에서는 title 에 대해 inner_html 을 호출해 내용을 String 으로 꺼냅니다. 결과적으로 우리는 Option<String> 을 얻게 됩니다.

여기서 러스트의 await 키워드는 여러분이 기다리는 식 이 아니라 에 붙는다는 점에 주목하세요. 즉, 후위(postfix) 키워드입니다. 다른 언어의 async에 익숙하다면 조금 다르게 느껴질 수 있지만, 러스트에서는 이 덕분에 메서드 체인이 훨씬 읽기 좋습니다. 그래서 목록 17-2처럼 trpl::gettext 호출을 체인으로 연결하고, 사이사이에 await 를 끼워 넣는 식으로 page_title 본문을 쓸 수도 있습니다.

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| title.inner_html())
}
Listing 17-2: await 키워드와 메서드 체이닝하기

이제 첫 번째 async 함수를 성공적으로 작성했습니다! main 에서 이 함수를 호출하는 코드를 추가하기 전에, 방금 작성한 코드가 정확히 무엇을 의미하는지 잠시 더 살펴봅시다.

러스트가 async 키워드가 붙은 블록 을 만나면, 컴파일러는 그것을 Future 트레이트를 구현하는 고유한 익명 데이터 타입으로 컴파일합니다. 그리고 async 가 붙은 함수 를 만나면, 컴파일러는 그 함수를 “본문이 async 블록인 일반 함수”로 컴파일합니다. async 함수의 반환 타입은 바로 그 async 블록을 위해 컴파일러가 만든 익명 데이터 타입이 됩니다.

따라서 async fn 을 쓴다는 것은, 결국 “어떤 반환 타입에 대한 퓨처 를 반환하는 함수”를 쓰는 것과 같습니다. 컴파일러 입장에서는 목록 17-1의 async fn page_title 정의가 대략 다음과 같은 비동기 아님 함수와 같습니다.

#![allow(unused)]
fn main() {
extern crate trpl; // required for mdbook test
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())
    }
}
}

이 변환된 버전을 하나씩 살펴봅시다.

  • 이는 10장의 “트레이트를 매개변수로 사용하기” 절에서 본 impl Trait 문법을 사용합니다.
  • 반환값은 Future 트레이트를 구현하며, 그 연관 타입 OutputOption<String> 입니다. 이는 원래 async fn 버전의 page_title 반환 타입과 같습니다.
  • 원래 함수 본문 안에서 호출하던 코드는 모두 async move 블록 안에 감싸져 있습니다. 앞에서 본 것처럼 블록은 하나의 식입니다. 이 블록 전체가 함수가 반환하는 식입니다.
  • 이 async 블록은 방금 설명한 것처럼 Option<String> 타입의 값을 만들어 냅니다. 이는 반환 타입의 Output 과 딱 맞습니다.
  • 새 함수 본문이 async move 블록인 이유는, 그 안에서 url 매개변수를 사용하기 때문입니다. (asyncasync move 의 차이는 이 장 뒤쪽에서 더 자세히 다룹니다.)

이제 main 에서 page_title 을 호출할 수 있습니다.

런타임으로 async 함수 실행하기

우선은 페이지 하나의 제목만 가져와 보겠습니다. 목록 17-3이 그 코드입니다. 안타깝게도 이 코드는 아직 컴파일되지 않습니다.

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| title.inner_html())
}
Listing 17-3: 사용자가 전달한 인수로 main 에서 page_title 함수를 호출하기

우리는 12장의 “명령줄 인수 받기” 절에서 했던 방식대로 명령줄 인수를 얻고, URL 인수를 page_title 에 넘긴 뒤 결과를 await 하려 합니다. 퓨처가 만들어 내는 값은 Option<String> 이므로, 페이지에 <title> 이 있는지 없는지에 따라 다른 메시지를 출력하려고 match 식도 사용하고 있습니다.

문제는 await 키워드를 async 함수나 async 블록 안에서만 쓸 수 있다는 점입니다. 그리고 러스트는 특별한 함수인 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`

mainasync 를 붙일 수 없는 이유는, async 코드를 실행하려면 런타임(runtime) 이 필요하기 때문입니다. 런타임은 비동기 코드 실행 세부를 관리하는 러스트 크레이트입니다. 프로그램의 main 함수는 런타임을 초기화 할 수는 있지만, 그 자체가 런타임은 아닙니다. (왜 그런지는 조금 뒤에 더 설명하겠습니다.) async 코드를 실행하는 모든 러스트 프로그램에는, 퓨처를 실제로 실행하는 런타임을 설정하는 지점이 반드시 하나 이상 존재합니다.

대부분의 async 지원 언어는 런타임을 언어와 함께 내장해 두지만, 러스트는 그러지 않습니다. 대신 서로 다른 트레이드오프를 가진 다양한 async 런타임이 존재합니다. 예를 들어 CPU 코어가 많고 메모리도 충분한 고성능 웹 서버는, 단일 코어와 소량의 RAM만 가진 마이크로컨트롤러와 전혀 다른 요구를 가집니다. 이런 런타임을 제공하는 크레이트들은 대개 파일 I/O 나 네트워크 I/O 같은 흔한 기능의 async 버전도 함께 제공합니다.

이 장과 나머지 부분에서는 trpl 크레이트의 block_on 함수를 사용합니다. block_on 은 퓨처 하나를 인수로 받아, 그 퓨처가 끝날 때까지 현재 스레드를 막습니다. 내부적으로 block_on 을 호출하면, 넘겨진 퓨처를 실행하기 위해 tokio 를 사용하는 런타임이 설정됩니다(trplblock_on 동작은 다른 런타임 크레이트의 block_on 과 비슷합니다). 퓨처가 완료되면 block_on 은 그 퓨처가 만든 값을 반환합니다.

기술적으로는 page_title 이 반환한 퓨처를 바로 block_on 에 넘긴 다음, 그 결과로 나온 Option<String> 에 대해 바로 match 를 써도 됩니다. 하지만 이 장의 대부분 예제(그리고 현실의 많은 async 코드)에서는 단지 async 함수 하나만 호출하는 것보다 더 많은 일을 하게 될 것이므로, 대신 목록 17-4처럼 async 블록 자체를 block_on 에 넘기고, 그 안에서 page_title 호출 결과를 명시적으로 await 하겠습니다.

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::block_on(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| title.inner_html())
}
Listing 17-4: trpl::block_on 에 넘긴 async 블록 안에서 await 하기

이 코드를 실행하면, 우리가 처음 기대했던 바로 그 동작을 얻게 됩니다.

$ 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

후우. 드디어 동작하는 async 코드를 얻었습니다! 이제 두 사이트를 서로 경주시키는 코드를 추가하기 전에, 퓨처가 실제로 어떻게 동작하는지 잠시만 더 봅시다.

await 지점—즉, 코드가 await 키워드를 사용하는 모든 위치—은 런타임에 제어권을 돌려주는 지점입니다. 이 동작을 가능하게 하려면, 러스트는 async 블록이 가진 상태를 추적해야 합니다. 그래야 런타임이 다른 일을 하러 갔다가 나중에 다시 돌아와 그 작업을 이어갈 수 있기 때문입니다. 이는 보이지 않는 상태 머신과 같습니다. 마치 각 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 },
}
}

물론 각 상태 사이 전환 코드를 손으로 쓰는 일은 지루하고 오류를 만들기 쉽습니다. 특히 나중에 기능이 더 늘어나 상태가 많아질수록 그렇습니다. 다행히 러스트 컴파일러는 이런 async 코드에 필요한 상태 머신 자료구조를 자동으로 만들고 관리해 줍니다. 그리고 일반적인 대여 및 소유권 규칙도 그대로 적용되며, 기쁘게도 컴파일러는 그 검사까지 함께 해 주고 유용한 오류 메시지도 제공합니다. 이 장 뒤에서 그런 예도 몇 가지 보게 될 것입니다.

결국 이 상태 머신을 실제로 실행하는 무언가는 반드시 필요하고, 그것이 바로 런타임입니다. (런타임을 찾아보다 보면 executor 라는 표현을 볼 수도 있는데, 이것은 비동기 코드를 실행하는 런타임의 구성 요소를 뜻합니다.)

이제 목록 17-3에서 왜 컴파일러가 main 자체를 async 함수로 만드는 것을 막았는지도 보일 것입니다. 만약 main 이 async 함수라면, main 이 반환하는 퓨처를 실행하는 또 다른 무언가가 필요하게 됩니다. 하지만 main 은 프로그램의 시작점입니다! 그래서 대신 main 안에서 trpl::block_on 을 호출해 런타임을 만들고, async 블록이 반환한 퓨처를 끝날 때까지 실행한 것입니다.

Note: 어떤 런타임은 async fn main() 을 쓸 수 있게 해 주는 매크로를 제공합니다. 하지만 그 매크로는 내부적으로 async fn main() { ... } 를 일반 fn main 으로 바꾸고, 목록 17-4에서 우리가 손으로 했던 것처럼 퓨처를 끝까지 실행하는 함수를 호출하게 만들어 줍니다.

이제 이 조각들을 합쳐서, 어떻게 동시적인 코드를 작성할 수 있는지 보겠습니다.

두 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::block_on(async {
        let title_fut_1 = page_title(&args[1]);
        let title_fut_2 = page_title(&args[2]);

        let (url, maybe_title) =
            match trpl::select(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 was: '{title}'"),
            None => println!("It had no title."),
        }
    })
}

async fn page_title(url: &str) -> (&str, Option<String>) {
    let response_text = trpl::get(url).await.text().await;
    let title = Html::parse(&response_text)
        .select_first("title")
        .map(|title| title.inner_html());
    (url, title)
}
Listing 17-5: 두 URL에 대해 page_title 을 호출해 어느 쪽이 먼저 끝나는지 보기

우리는 먼저 사용자가 입력한 두 URL에 대해 각각 page_title 을 호출하고, 결과 퓨처를 title_fut_1, title_fut_2 에 저장합니다. 기억하세요. 아직 이 퓨처들은 실제로는 아무 것도 하지 않습니다. 퓨처는 게으르고, 아직 await 하지 않았기 때문입니다. 그 다음 우리는 이 두 퓨처를 trpl::select 에 넘기는데, 이 함수는 전달한 퓨처 중 어느 쪽이 먼저 끝났는지를 알려 주는 값을 반환합니다.

Note: 내부적으로 trpl::selectfutures 크레이트의 더 일반적인 select 함수 위에 만들어져 있습니다. futuresselecttrpl::select 보다 훨씬 더 많은 일을 할 수 있지만, 지금은 넘어가도 되는 추가 복잡성도 함께 가집니다.

어느 퓨처가 먼저 끝나든 모두 정상적인 결과이므로, Result 를 반환할 이유는 없습니다. 대신 trpl::select 는 아직 보지 못한 trpl::Either 타입을 반환합니다. Either 는 두 경우를 가진다는 점에서는 Result 와 약간 비슷하지만, 성공과 실패의 개념이 들어 있지는 않습니다. 대신 단순히 “둘 중 하나”를 Left, Right 로 표현합니다.

#![allow(unused)]
fn main() {
enum Either<A, B> {
    Left(A),
    Right(B),
}
}

select 함수는 첫 번째 인수 퓨처가 먼저 끝나면 그 출력값을 담은 Left 를 반환하고, 두 번째 인수 퓨처가 먼저 끝나면 그 출력값을 담은 Right 를 반환합니다. 즉, 호출 시 인수 순서와 왼쪽/오른쪽 구분이 그대로 대응됩니다.

또한 page_title 이 원래 받았던 URL 도 함께 반환하도록 바꿨습니다. 그래야 먼저 끝난 페이지에 <title> 이 없어도, 어떤 URL 이 먼저 끝났는지는 여전히 의미 있게 출력할 수 있기 때문입니다. 이 정보까지 활용해, println! 출력도 “어느 URL 이 먼저 끝났는지” 와 “그 URL의 <title> 이 무엇인지(있다면)” 를 함께 보여 주도록 바꿉니다.

이제 여러분은 작지만 실제로 동작하는 웹 스크레이퍼를 하나 만들었습니다! 서로 다른 URL 몇 개를 넣고 실행해 보세요. 어떤 사이트는 항상 더 빠르고, 어떤 사이트는 실행할 때마다 먼저 끝나는 쪽이 달라질 수도 있습니다. 더 중요한 것은, 이제 여러분이 퓨처를 어떻게 다루는지의 기초를 배웠다는 점입니다. 그러니 이제 async 에 대해 더 깊이 들어갈 준비가 된 것입니다.

async로 동시성 적용하기

async로 동시성 적용하기

이 절에서는 16장에서 스레드로 풀어 보았던 몇몇 동시성 문제를 async 로 다시 다뤄봅니다. 이미 거기서 핵심 아이디어 대부분을 설명했기 때문에, 여기서는 스레드와 퓨처 사이의 차이에 더 집중하겠습니다.

많은 경우, async 로 동시성을 다루는 API는 스레드를 사용할 때의 API와 매우 비슷합니다. 하지만 어떤 경우에는 꽤 다르기도 합니다. 심지어 스레드와 async 사이 API가 겉보기에는 비슷해 보이더라도, 실제 동작 방식은 다를 수 있고, 성능 특성은 거의 항상 다릅니다.

spawn_task 로 새 작업 만들기

16장의 spawn 으로 새 스레드 만들기” 절에서 처음 했던 작업은, 두 개의 별도 스레드에서 숫자를 세는 것이었습니다. 이번에는 같은 일을 async 로 해 봅시다. trpl 크레이트는 thread::spawn 과 아주 비슷한 spawn_task 함수를 제공하고, thread::sleep 의 async 버전인 sleep 함수도 제공합니다. 이 둘을 사용해 목록 17-6처럼 카운팅 예제를 구현할 수 있습니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(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: 메인 작업이 다른 내용을 출력하는 동안 새 작업을 만들어 또 다른 내용을 출력하기

우리는 먼저 최상위 함수가 async 가 될 수 있도록, maintrpl::block_on 으로 감쌉니다.

Note: 이 장의 나머지 예제도 거의 모두 main 에서 같은 trpl::block_on 래핑 코드를 사용합니다. 앞으로는 main 을 자주 생략해 설명하겠지만, 실제 코드에는 여러분이 직접 포함해야 한다는 점을 기억하세요!

그다음 그 블록 안에 두 개의 루프를 씁니다. 각 루프 안에는 다음 메시지를 보내기 전에 0.5초(500밀리초)를 기다리는 trpl::sleep 호출이 있습니다. 한 루프는 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 handle이 필요합니다. 스레드에서는 join 메서드로 스레드 종료를 기다렸습니다. 목록 17-7에서는 await 로 같은 일을 합니다. 작업 핸들 자체도 하나의 퓨처이기 때문입니다. 또한 그 Output 타입은 Result 이므로, await 한 뒤 unwrap 도 호출합니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(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: join handle 에 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 블록은 익명 퓨처로 컴파일되므로, 각 루프를 각자의 async 블록 안에 넣고 trpl::join 함수를 사용해 둘 다 끝까지 실행하게 할 수 있습니다.

16장의 “모든 스레드가 끝날 때까지 기다리기” 절에서 std::thread::spawn 이 반환한 JoinHandle 타입에 대해 join 을 사용하는 방법을 보았습니다. trpl::join 함수는 비슷하지만 퓨처를 위한 것입니다. 퓨처 두 개를 주면, 둘 다 완료된 뒤 각 퓨처의 출력값을 튜플로 담은 하나의 새 퓨처를 만들어 냅니다. 그래서 목록 17-8에서는 fut1, fut2 각각을 await 하는 대신, trpl::join 이 만든 새 퓨처 하나를 await 합니다. 그리고 출력은 그저 unit 값 둘을 담은 튜플일 뿐이므로 무시합니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(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 으로 두 익명 퓨처를 함께 await 하기

이 코드를 실행하면 두 퓨처가 모두 끝까지 실행됩니다.

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!

이제는 항상 같은 순서 를 보게 됩니다. 이는 스레드나 목록 17-7의 trpl::spawn_task 와는 상당히 다릅니다. 그 이유는 trpl::join 함수가 공정(fair) 하기 때문입니다. 즉, 각 퓨처를 번갈아가며 동일하게 자주 확인하고, 둘 중 하나가 준비되었다고 해서 다른 하나를 굶기지 않습니다. 반면 스레드에서는 운영체제가 어느 스레드를 언제 얼마나 오래 실행시킬지를 결정합니다. async 러스트에서는 어떤 태스크를 언제 확인할지 런타임이 결정합니다. (물론 실제로는 런타임이 내부적으로 운영체제 스레드를 쓰기도 하므로 세부는 더 복잡하지만, 개념적으로는 그렇습니다.) 모든 런타임이 어떤 연산에 대해서도 공정성을 보장해야 하는 것은 아니며, 공정성을 원하는지 여부를 선택할 수 있게 여러 API를 제공하는 경우도 많습니다.

이제 다음과 같은 변형들을 직접 시도해 보며 어떤 결과가 나오는지 살펴보세요.

  • 두 루프 중 하나, 혹은 둘 모두에서 async 블록을 제거하기
  • 각 async 블록을 정의하자마자 곧바로 await 하기
  • 첫 번째 루프만 async 블록으로 감싸고, 두 번째 루프 본문이 끝난 뒤에 그 퓨처를 await 하기

추가 도전 과제로, 코드를 실행해 보기 에 각 경우의 출력이 어떻게 될지 먼저 예상해 보세요!

메시지 전달로 두 태스크 사이 데이터 보내기

퓨처들 사이 데이터 공유 역시 익숙한 방식으로 할 수 있습니다. 이번에도 메시지 전달을 사용하되, 이번에는 타입과 함수의 async 버전을 사용합니다. 16장의 “메시지 전달로 스레드 사이에 데이터 보내기” 절과는 약간 다른 길을 택해, 스레드 기반 동시성과 퓨처 기반 동시성의 핵심 차이를 더 잘 드러내 보겠습니다. 목록 17-9에서는 별도 태스크를 만들지 않고, 하나의 async 블록부터 시작합니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let val = String::from("hi");
        tx.send(val).unwrap();

        let received = rx.recv().await.unwrap();
        println!("received '{received}'");
    });
}
Listing 17-9: async 채널을 만들고 양쪽 절반을 tx, rx 에 대입하기

여기서 우리는 16장에서 사용했던 다중 생산자, 단일 소비자 채널 API의 async 버전인 trpl::channel 을 사용합니다. 이 API는 스레드 기반 버전과 아주 비슷하지만, 수신자 rx 는 불변이 아니라 가변이고, recv 메서드는 값을 바로 주는 대신 우리가 await 해야 하는 퓨처를 만든다는 점이 다릅니다. 이제 송신자에서 수신자로 메시지를 보낼 수 있습니다. 별도 스레드나 태스크를 만들 필요가 없다는 점에도 주목하세요. rx.recv 호출만 await 하면 됩니다.

std::mpsc::channel 의 동기식 Receiver::recv 메서드는 메시지를 받을 때까지 현재 스레드를 블록합니다. 반면 trpl::Receiver::recv 메서드는 async 이기 때문에 블록하지 않습니다. 메시지가 도착하거나 채널의 송신 쪽이 닫힐 때까지 런타임에 제어권을 돌려주는 것입니다. 반대로 send 호출은 await 하지 않습니다. 이 채널은 unbounded 채널이라 send 가 블록할 필요가 없기 때문입니다.

Note: 이 async 코드 전체가 trpl::block_on 안의 async 블록에서 실행되기 때문에, 그 안에서는 블록을 피할 수 있습니다. 하지만 그 바깥 코드는 block_on 함수가 끝날 때까지 블록됩니다. 이것이 바로 trpl::block_on 의 역할입니다. async 코드를 어디서부터 어디까지 블록할지, 즉 sync 코드와 async 코드의 경계를 어디에 둘지를 여러분이 정하게 해 줍니다.

이 예제에서 두 가지 점에 주목하세요. 첫째, 메시지는 즉시 도착합니다. 둘째, 비록 퓨처를 사용하고는 있지만 아직은 아무 동시성도 없습니다. 이 목록 안의 모든 것은 퓨처가 없었어도 그랬을 것처럼 그냥 순서대로 실행됩니다.

이제 첫 번째 문제부터 해결하기 위해, 메시지를 여러 개 보내고 그 사이사이에 잠깐 쉬는 코드를 목록 17-10처럼 써 봅시다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(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: async 채널로 여러 메시지를 보내고, 메시지 사이마다 await 와 함께 잠시 쉬기

메시지를 보내는 것만이 아니라, 그것들을 받아야도 합니다. 지금은 몇 개 메시지가 올지 알고 있으므로 rx.recv().await 를 네 번 수동으로 호출해도 됩니다. 하지만 현실에서는 메시지 수가 몇 개인지 모르는 경우가 대부분이므로, 더 이상 메시지가 오지 않는다는 사실이 확인될 때까지 계속 기다릴 필요가 있습니다.

목록 16-10에서는 동기 채널에서 받은 모든 항목을 처리하기 위해 for 루프를 사용했습니다. 하지만 러스트에는 아직 비동기적으로 생성되는 항목 시퀀스를 for 로 순회하는 방법이 없으므로, 대신 while let 조건 루프를 사용합니다. 이는 6장의 if letlet...else 로 더 간결하게 제어 흐름 쓰기” 절에서 본 if let 의 루프 버전입니다. 루프는 지정한 패턴이 계속 맞는 한 반복을 이어 갑니다.

rx.recv 호출은 퓨처를 만들고, 우리는 그것을 await 합니다. 런타임은 이 퓨처가 준비될 때까지 실행을 잠시 멈춥니다. 메시지가 도착하면 퓨처는 도착한 횟수만큼 Some(message) 로 해결됩니다. 채널이 닫히면, 메시지가 아예 오지 않았더라도 퓨처는 None 으로 해결되어 더 이상 기다릴 값이 없다는 뜻이 됩니다.

while let 루프는 이 모든 것을 한데 묶습니다. rx.recv().await 의 결과가 Some(message) 이면 메시지에 접근할 수 있고, 그것을 루프 본문 안에서 사용할 수 있습니다. 이는 if let 과 같은 방식입니다. 결과가 None 이면 루프는 끝납니다. 루프 본문이 한 번 끝날 때마다 다시 await 지점에 도달하므로, 런타임은 다른 메시지가 도착할 때까지 이 루프를 다시 멈춥니다.

이제 코드는 모든 메시지를 성공적으로 보내고 받습니다. 하지만 아직 문제가 둘 남아 있습니다. 첫째, 메시지가 0.5초 간격으로 오지 않습니다. 프로그램 시작 후 2초 (2000밀리초) 가 지나서야 한꺼번에 도착합니다. 둘째, 이 프로그램은 끝나지 않습니다. 새 메시지를 영원히 기다리며 멈춰 있기 때문입니다. 사용자가 ctrl-C 로 강제로 종료해야 합니다.

하나의 async 블록 안 코드는 선형적으로 실행된다

먼저, 왜 메시지가 기대한 것처럼 중간중간 지연되며 오지 않고 전체 지연이 끝난 뒤 한꺼번에 도착하는지 살펴봅시다. 어떤 async 블록 안에서든, 코드에 await 키워드가 등장하는 순서가 실제 프로그램 실행 시 그 코드가 처리되는 순서입니다.

목록 17-10에는 async 블록이 단 하나뿐이므로, 그 안의 모든 코드는 선형적으로 실행됩니다. 즉, 여전히 동시성은 없습니다. tx.send 호출이 모두 실행되고, 그 사이에 trpl::sleep 과 그에 대응하는 await 지점도 모두 실행됩니다. 그 뒤에야 while let 루프가 recv 호출의 await 지점들에 도달할 수 있습니다.

우리가 원하는, 즉 각 메시지 사이에 실제로 지연이 일어나면서 두 쪽이 동시에 진행되는 동작을 얻으려면, txrx 로직을 각각 자기만의 async 블록 안에 두어야 합니다. 목록 17-11이 그 예입니다. 그리고 목록 17-8에서 했던 것처럼, 둘을 trpl::join 으로 함께 실행하게 합니다. 다시 말해 fut1fut2 를 개별적으로 await 하지 않고, trpl::join 이 만들어 주는 하나의 퓨처를 await 하는 것입니다. 그렇지 않으면 다시 순차 실행으로 돌아가, 바로 지금 피하려는 상황이 되어 버립니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(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밀리초 간격으로 출력됩니다.

async 블록 안으로 소유권 이동하기

그래도 프로그램은 여전히 끝나지 않습니다. 이유는 while let 루프와 trpl::join 사이의 상호작용 때문입니다.

  • trpl::join 이 반환한 퓨처는, 전달된 퓨처가 모두 끝나야만 완료됩니다.
  • tx_fut 퓨처는 vals 의 마지막 메시지를 보낸 뒤 마지막 sleep 이 끝나면 완료됩니다.
  • rx_fut 퓨처는 while let 루프가 끝나야 완료됩니다.
  • while let 루프는 rx.recv().awaitNone 을 반환해야만 끝납니다.
  • rx.recv().awaitNone 을 반환하려면 채널의 반대편, 즉 송신 쪽이 닫혀야 합니다.
  • 채널은 우리가 rx.close 를 호출하거나, 송신 쪽 tx 가 drop 될 때만 닫힙니다.
  • 하지만 우리는 rx.close 를 어디서도 호출하지 않으며, txtrpl::block_on 에 넘긴 가장 바깥 async 블록이 끝날 때까지 drop 되지 않습니다.
  • 그런데 그 블록은 trpl::join 이 끝날 때까지 기다리고 있으므로, 다시 처음으로 돌아가게 됩니다.

지금 상태에서는 메시지를 보내는 async 블록이 tx빌려만 쓰고 있습니다. 메시지를 보내는 데 소유권 자체는 필요하지 않기 때문입니다. 하지만 만약 tx 를 그 async 블록 안으로 이동 시킬 수 있다면, 그 블록이 끝날 때 tx 도 함께 drop 될 것입니다. 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::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx_fut = async move {
            // --snip--
            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을 수정한 버전

이 버전의 코드를 실행하면 마지막 메시지를 모두 보내고 받은 뒤 깔끔하게 종료됩니다. 다음으로는 여러 퓨처에서 데이터를 보내려면 무엇이 달라져야 하는지 보겠습니다.

join! 매크로로 여러 퓨처 묶기

이 async 채널도 다중 생산자 채널이므로, 여러 퓨처에서 메시지를 보내고 싶다면 tx 에 대해 clone 을 호출하면 됩니다. 목록 17-13을 보세요.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(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::join!(tx1_fut, tx_fut, rx_fut);
    });
}
Listing 17-13: 여러 생산자가 async 블록에서 메시지를 보내기

우리는 첫 번째 spawned task를 만들기 전에 먼저 tx 를 clone 해 tx1 을 얻습니다. 그리고 앞에서 했던 것처럼 tx1 을 그 블록 안으로 이동시킵니다. 그런 뒤 조금 더 느린 간격으로 메시지를 보내는 async 블록 안으로 원래의 tx 도 이동시킵니다. 이 새 블록은 수신 블록 뒤에 두었지만, 앞에 두어도 상관없습니다. 중요한 것은 생성 순서가 아니라, 퓨처들을 어떻게 await 하느냐입니다.

메시지를 보내는 두 async 블록 모두 async move 여야 합니다. 그래야 그 블록들이 끝날 때 txtx1 이 함께 drop 됩니다. 그렇지 않으면 방금 해결했던 무한 대기 문제가 다시 생깁니다.

마지막으로, 추가된 퓨처를 처리하기 위해 trpl::join 대신 trpl::join! 매크로로 바꿉니다. join! 매크로는, 컴파일 시점에 개수가 정해져 있는 여러 퓨처를 함께 기다립니다. 나중에 이 장에서 개수를 미리 모르는 퓨처 집합을 기다리는 방법도 다루겠습니다.

이제 두 송신 퓨처가 보내는 메시지가 모두 출력되고, 각각의 지연 시간이 조금 다르기 때문에 메시지 수신도 그 다른 간격을 반영하게 됩니다.

received 'hi'
received 'more'
received 'from'
received 'the'
received 'messages'
received 'future'
received 'for'
received 'you'

지금까지 우리는 메시지 전달을 이용해 퓨처 사이로 데이터를 보내는 법, 하나의 async 블록 안 코드가 순차적으로 실행된다는 사실, async 블록 안으로 소유권을 이동시키는 법, 그리고 여러 퓨처를 함께 묶는 법을 살펴보았습니다. 다음으로는 런타임에게 “이제 다른 작업을 해도 된다”고 알려 줘야 하는 이유와 방법을 이야기해 보겠습니다.

여러 개의 퓨처 다루기

런타임에 제어권 넘기기

“첫 번째 async 프로그램” 절에서 보았듯, 각 await 지점마다 러스트는 런타임에게 “이 퓨처가 아직 준비되지 않았다면 이 태스크를 잠시 멈추고 다른 태스크로 전환할 기회”를 줍니다. 반대로 말하면, 러스트는 오직 await 지점에서만 async 블록을 멈추고 런타임에 제어권을 돌려줍니다. await 지점들 사이 의 코드는 모두 동기적으로 실행됩니다.

이 말은, async 블록 안에서 await 지점 없이 많은 일을 하면 그 퓨처가 다른 퓨처들의 진행을 모두 막아 버릴 수 있다는 뜻입니다. 이런 상황은 때때로 한 퓨처가 다른 퓨처들을 굶긴다(starving) 고도 표현합니다. 어떤 경우에는 그다지 문제가 아닐 수도 있습니다. 하지만 비용이 큰 초기화 작업이나 오래 걸리는 작업을 하거나, 어떤 퓨처가 특정 작업을 무기한 계속 수행해야 하는 경우에는 언제 어디서 런타임에게 제어권을 돌려줄지 신중하게 생각해야 합니다.

이런 starvation 문제를 보여 주기 위해 오래 걸리는 연산을 하나 흉내 내고, 이를 어떻게 해결할 수 있는지도 함께 보겠습니다. 목록 17-14는 slow 함수를 소개합니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(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-14: 느린 연산을 흉내 내기 위해 thread::sleep 사용하기

이 코드는 trpl::sleep 대신 std::thread::sleep 을 사용하므로, slow 를 호출하면 현재 스레드를 지정한 밀리초 수만큼 블록하게 됩니다. 이렇게 하면 실제로는 오래 걸리면서도 블로킹되는 현실의 작업을 흉내 낼 수 있습니다.

목록 17-15에서는 두 개의 퓨처 안에서 이런 CPU-bound 작업을 흉내 내기 위해 slow 를 사용합니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(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::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-15: 느린 작업을 흉내 내기 위해 slow 함수 호출하기

각 퓨처는 여러 개의 느린 연산을 수행한 뒤에야 런타임에 제어권을 돌려줍니다. 이 코드를 실행하면 다음과 같은 출력이 보일 것입니다.

'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.

목록 17-5에서 trpl::select 로 두 URL 요청 퓨처를 경주시켰을 때와 마찬가지로, 여기서도 selecta 가 끝나자마자 바로 완료됩니다. 하지만 두 퓨처 안의 slow 호출들은 서로 전혀 섞여 실행되지 않습니다. a 퓨처가 trpl::sleepawait 하기 전까지 자기 일을 전부 하고, 그 다음에야 b 퓨처가 자기 trpl::sleepawait 할 때까지 일을 하고, 마지막으로 다시 a 가 끝납니다. 두 퓨처가 느린 작업 사이사이에 조금씩 진행하게 하려면, 중간중간 await 지점을 넣어 런타임에 제어권을 돌려줘야 합니다. 즉, 우리가 await 할 무언가가 필요합니다!

목록 17-15에서도 이런 제어권 넘김의 흔적은 이미 볼 수 있습니다. 만약 a 퓨처 끝의 trpl::sleep 을 제거하면, b 퓨처는 전혀 실행되지 않은 채로 a 가 끝나게 됩니다. 그렇다면 제어권을 넘기는 출발점으로 trpl::sleep 함수를 사용해 보겠습니다. 목록 17-16을 보세요.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(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::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-16: 진행을 번갈아 하도록 trpl::sleep 사용하기

이제 우리는 각 slow 호출 사이에 trpl::sleep 과 await 지점을 넣었습니다. 그 결과 두 퓨처의 작업이 서로 섞여 실행됩니다.

'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 퓨처는 처음 trpl::sleep 을 만나기 전까지는 여전히 혼자 조금 더 실행되지만, 그 뒤부터는 두 퓨처가 await 지점을 만날 때마다 번갈아 제어권을 주고받습니다. 이 예제에서는 slow 호출마다 한 번씩 제어권을 넘겼지만, 실제로는 여러분이 적절하다고 생각하는 단위로 작업을 쪼개면 됩니다.

하지만 여기서 우리가 진짜 원하는 것은 잠드는 것 이 아니라, 가능한 한 빨리 진행하되 잠시 런타임에게 제어권만 넘기는 것입니다. 그래서 직접 trpl::yield_now 함수를 사용할 수 있습니다. 목록 17-17은 모든 trpl::sleep 호출을 trpl::yield_now 로 바꾼 예입니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::{thread, time::Duration};

fn main() {
    trpl::block_on(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::select(a, b).await;
    });
}

fn slow(name: &str, ms: u64) {
    thread::sleep(Duration::from_millis(ms));
    println!("'{name}' ran for {ms}ms");
}
Listing 17-17: 진행을 번갈아 하도록 yield_now 사용하기

이 코드는 의도를 더 분명하게 드러낼 뿐 아니라, sleep 을 사용하는 것보다 훨씬 빠를 수도 있습니다. sleep 이 사용하는 타이머는 보통 최소 해상도 제한이 있기 때문입니다. 예를 들어 우리가 쓰는 sleep 구현은 Duration 으로 1나노초를 넘겨도 적어도 1밀리초는 잠듭니다. 다시 말하지만 현대 컴퓨터는 아주 빠르기 때문에, 1밀리초 동안도 상당히 많은 일을 할 수 있습니다!

이 말은, async가 프로그램이 하는 다른 일에 따라서는 CPU-bound 작업에도 유용할 수 있다는 뜻입니다. async 상태 머신이라는 오버헤드가 있긴 하지만, 프로그램 안의 여러 부분이 서로 어떤 관계를 갖는지를 구조화하는 데 유용한 도구가 되기 때문입니다. 이것은 각 퓨처가 await 지점을 통해 언제 제어권을 넘길지 직접 결정하는 형태의 협력적 멀티태스킹(cooperative multitasking) 입니다. 따라서 각 퓨처는 너무 오래 블로킹하지 않도록 스스로 책임도 져야 합니다. 어떤 러스트 기반 임베디드 운영체제에서는, 이 방식이 유일한 멀티태스킹 방법이기도 합니다!

물론 현실의 코드에서는 매 한 줄마다 함수 호출과 await 지점을 교대로 넣는 식으로 작성하지는 않습니다. 이렇게 제어권을 넘기는 비용이 비교적 작긴 해도 0 은 아니기 때문입니다. 어떤 경우에는 CPU-bound 작업을 억지로 잘게 쪼개는 것이 오히려 성능을 크게 떨어뜨릴 수도 있습니다. 그래서 전체 성능을 위해서는 잠시 블로킹하게 두는 편이 낫기도 합니다. 언제나 실제 코드의 병목이 무엇인지 측정 해야 합니다. 하지만 “분명 동시적으로 돌 거라고 생각한 코드가 줄줄이 직렬로 돌아간다”는 징후가 보일 때는, 지금의 이 동작 원리를 꼭 떠올려야 합니다.

우리만의 async 추상화 만들기

퓨처끼리 조합해 새로운 패턴을 만들 수도 있습니다. 예를 들어 지금까지 배운 async 구성 요소만으로 timeout 함수를 직접 만들 수 있습니다. 완성된 결과는 또 하나의 작은 빌딩 블록이 되고, 그 위에 더 많은 async 추상화를 쌓아 올릴 수 있습니다.

목록 17-18은 느린 퓨처와 함께 사용할 때 이 timeout 이 어떻게 동작하길 기대하는지를 보여 줍니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(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())
            }
        }
    });
}
Listing 17-18: 시간 제한과 함께 느린 작업을 실행하기 위해 우리가 상상한 timeout 사용하기

이제 실제로 구현해 봅시다! 먼저 timeout 의 API를 생각해 보면 다음과 같습니다.

  • 우리가 await 할 수 있어야 하므로, timeout 자체도 async 함수여야 한다.
  • 첫 번째 매개변수는 실행할 퓨처여야 한다. 어떤 퓨처와도 동작하게 하려면 제네릭으로 만들 수 있다.
  • 두 번째 매개변수는 최대 대기 시간이어야 한다. Duration 을 사용하면 trpl::sleep 에 바로 넘기기 쉽다.
  • 반환 타입은 Result 여야 한다. 퓨처가 성공적으로 끝나면 Ok 안에 퓨처의 결과를 넣고, 타임아웃이 먼저 끝나면 기다린 시간 Duration 을 담은 Err 를 반환한다.

목록 17-19는 이런 요구를 만족하는 선언을 보여 줍니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(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> {
    // Here is where our implementation will go!
}
Listing 17-19: timeout 의 시그니처 정의하기

이제 타입은 원하는 대로 맞췄습니다. 다음으로는 동작 을 생각해야 합니다. 우리가 원하는 것은 “전달받은 퓨처”와 “지연 시간”을 서로 경주시키는 것입니다. trpl::sleep 을 사용해 Duration 으로부터 타이머 퓨처를 만들고, trpl::select 를 사용해 이 타이머와 호출자가 넘겨 준 퓨처를 함께 실행하면 됩니다.

목록 17-20에서는 trpl::select 의 결과를 await 하고, 그 결과를 match 하여 timeout 을 구현합니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

use trpl::Either;

// --snip--

fn main() {
    trpl::block_on(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::select(future_to_try, trpl::sleep(max_time)).await {
        Either::Left(output) => Ok(output),
        Either::Right(_) => Err(max_time),
    }
}
Listing 17-20: selectsleep 으로 timeout 구현하기

trpl::select 의 구현은 공정하지 않습니다. 인수를 전달된 순서대로 항상 폴링합니다 (다른 select 구현은 첫 번째로 폴링할 인수를 무작위로 고르기도 합니다). 따라서 max_time 이 아주 짧더라도 future_to_try 가 먼저 끝날 기회를 갖게 하려면, future_to_tryselect 의 첫 번째 인수로 넘겨야 합니다. future_to_try 가 먼저 끝나면 select 는 그 출력값을 담은 Left 를 반환합니다. 반대로 타이머가 먼저 끝나면 타이머 출력인 () 를 담은 Right 를 반환합니다.

future_to_try 가 성공해 Left(output) 를 얻으면 우리는 Ok(output) 을 반환합니다. 반대로 타이머가 먼저 끝나 Right(()) 를 얻으면, ()_ 로 무시하고 Err(max_time) 을 반환합니다.

이제 우리는 이미 갖고 있던 async 도우미 두 개를 조합해 완전히 동작하는 timeout 을 만들었습니다. 코드를 실행하면, 타임아웃이 끝난 뒤 실패 모드를 출력하게 됩니다.

Failed after 2 seconds

퓨처는 다른 퓨처들과 자유롭게 조합될 수 있기 때문에, 작은 async 구성 요소들을 이용해 정말 강력한 도구를 만들 수 있습니다. 예를 들어 같은 방식으로 timeout과 retry를 결합하고, 다시 그것을 네트워크 호출(목록 17-5 같은)과 함께 쓸 수도 있습니다.

실무에서는 대부분 asyncawait 를 직접 사용하고, 그 다음 단계에서 select 함수나 join! 매크로처럼 “가장 바깥 퓨처를 어떻게 실행할지”를 제어하는 도구들을 사용하게 됩니다.

지금까지 우리는 여러 퓨처를 동시에 다루는 여러 방법을 살펴보았습니다. 다음으로는 스트림 을 사용해 여러 퓨처를 시간의 흐름 속 시퀀스로 다루는 방법을 보겠습니다.

스트림: 순차적으로 이어지는 퓨처

스트림: 순차적으로 이어지는 퓨처

이 장 앞쪽 “메시지 전달” 절에서 async 채널의 수신자를 사용했던 것을 떠올려 보세요. async recv 메서드는 시간에 따라 아이템의 시퀀스를 만들어 냅니다. 이것은 스트림(stream) 이라고 불리는 훨씬 더 일반적인 패턴의 한 예입니다. 큐에 항목이 차례로 들어오는 것, 한 번에 메모리에 다 올리기엔 너무 큰 데이터를 파일시스템에서 조금씩 읽어 오는 것, 네트워크를 통해 시간이 지나며 데이터가 도착하는 것 등, 많은 개념이 자연스럽게 스트림으로 표현됩니다. 스트림은 퓨처이기 때문에, 다른 어떤 퓨처와도 함께 사용할 수 있고 흥미로운 방식으로 조합할 수도 있습니다. 예를 들어 너무 많은 네트워크 호출을 피하려고 이벤트를 묶거나, 오래 걸리는 작업 시퀀스에 타임아웃을 걸거나, 불필요한 작업을 줄이기 위해 사용자 인터페이스 이벤트를 조절(throttle)할 수도 있습니다.

13장의 Iterator 트레이트와 next 메서드” 절에서 우리는 이미 아이템의 시퀀스를 보았습니다. 그러나 반복자와 async 채널 수신자 사이에는 두 가지 차이가 있습니다. 첫 번째 차이는 시간 입니다. 반복자는 동기적이고, 채널 수신자는 비동기적입니다. 두 번째 차이는 API입니다. Iterator 를 직접 다룰 때는 동기식 next 메서드를 호출합니다. 하지만 trpl::Receiver 스트림에서는 비동기 recv 메서드를 호출했습니다. 이 차이를 빼면 API 느낌은 매우 비슷한데, 이는 우연이 아닙니다. 스트림은 반복자의 비동기 버전 같은 존재이기 때문입니다. 다만 trpl::Receiver 는 구체적으로 메시지를 받는 역할을 하고, 일반적인 스트림 API는 그보다 더 넓은 개념입니다. 즉, Iterator 가 다음 항목을 제공하는 방식과 비슷하게, 하지만 비동기적으로 다음 항목을 제공합니다.

러스트에서 반복자와 스트림이 이렇게 비슷하다는 사실은, 사실 어떤 반복자든 스트림으로 바꿀 수 있다는 뜻이기도 합니다. 반복자와 마찬가지로, 스트림도 next 메서드를 호출하고 그 출력을 await 하면서 다룰 수 있습니다. 목록 17-21을 보세요. 이 코드는 아직 컴파일되지 않습니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

fn main() {
    trpl::block_on(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}");
        }
    });
}
Listing 17-21: 반복자에서 스트림을 만들고 값을 출력하기

우리는 숫자 배열 하나로 시작하고, 이를 반복자로 바꾼 뒤 map 을 호출해 모든 값에 2를 곱합니다. 그 다음 trpl::stream_from_iter 함수를 사용해 이 반복자를 스트림으로 변환합니다. 그리고 while let 루프로, 도착하는 스트림의 각 항목을 순서대로 처리합니다.

하지만 이 코드를 실제로 실행하려 하면, 컴파일되지 않고 next 메서드가 없다는 오류를 보게 됩니다.

error[E0599]: no method named `next` found for struct `tokio_stream::iter::Iter` in the current scope
  --> src/main.rs:10:40
   |
10 |         while let Some(value) = stream.next().await {
   |                                        ^^^^
   |
   = 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 {
   |                                        ~~~~~~~~

이 오류가 설명하듯, 문제는 올바른 트레이트를 스코프로 가져오지 않았다는 데 있습니다. 지금까지 이야기한 내용을 떠올리면 그 트레이트가 Stream 이라고 생각할 법하지만, 실제 필요한 것은 StreamExt 입니다. Extextension 의 줄임말로, 러스트 커뮤니티에서 어떤 트레이트를 다른 트레이트로 확장할 때 자주 붙이는 이름입니다.

Stream 트레이트는 사실상 IteratorFuture 트레이트를 결합한 저수준 인터페이스를 정의합니다. StreamExt 는 그 위에 더 고수준 API를 얹어 제공하며, next 메서드와 그 밖의 반복자 스타일 유틸리티 메서드들이 여기에 들어 있습니다. StreamStreamExt 는 아직 러스트 표준 라이브러리 일부는 아니지만, 생태계의 대부분의 크레이트가 비슷한 정의를 사용합니다.

이 컴파일 오류를 고치려면 목록 17-22처럼 trpl::StreamExt 에 대한 use 문을 추가하면 됩니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use trpl::StreamExt;

fn main() {
    trpl::block_on(async {
        let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
        // --snip--
        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}");
        }
    });
}
Listing 17-22: 반복자를 스트림의 기반으로 성공적으로 사용하기

이제 모든 조각이 맞춰졌으니, 이 코드는 우리가 원한 방식으로 동작합니다! 더 나아가, 이제 StreamExt 가 스코프에 있으므로, 반복자에서 했던 것처럼 그 안의 모든 유틸리티 메서드를 함께 사용할 수도 있습니다.

async 관련 트레이트를 더 자세히 살펴보기

async 관련 트레이트를 더 자세히 살펴보기

이 장 전체에서 우리는 Future, Stream, StreamExt 트레이트를 여러 방식으로 사용해 왔습니다. 하지만 지금까지는 그것들이 정확히 어떻게 동작하고, 서로 어떻게 맞물리는지에 대해 너무 깊게 들어가지는 않았습니다. 일상적인 러스트 작업에서는 대부분 이 정도로도 충분합니다. 그러나 가끔은 Pin 타입과 Unpin 트레이트를 포함해, 이 트레이트들의 세부를 좀 더 이해해야 하는 상황을 만나게 됩니다. 이 절에서는 그런 상황에 도움이 되도록 필요한 만큼만 더 깊게 파고들고, 정말 깊은 세부는 다른 문서에 남겨 두겠습니다.

Future 트레이트

먼저 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 은 이 퓨처가 최종적으로 무엇으로 resolve 되는지를 말합니다. 이는 Iterator 트레이트의 연관 타입 Item 과 비슷합니다. 둘째, Future 에는 poll 이라는 메서드가 있는데, self 매개변수로 특별한 Pin 참조를 받고, Context 타입의 가변 참조를 받으며, Poll<Self::Output> 를 반환합니다. PinContext 는 잠시 뒤 더 자세히 다룹니다. 지금은 우선 이 메서드가 반환하는 Poll 타입에 집중해 봅시다.

#![allow(unused)]
fn main() {
pub enum Poll<T> {
    Ready(T),
    Pending,
}
}

Poll 타입은 Option 과 비슷합니다. 값이 있는 variant인 Ready(T) 와, 값이 없는 Pending 이 있습니다. 하지만 Poll 이 뜻하는 바는 Option 과 꽤 다릅니다. Pending variant는 퓨처가 아직 더 할 일이 남아 있다는 뜻이며, 따라서 호출하는 쪽이 나중에 다시 확인해야 한다는 의미입니다. Ready variant는 퓨처의 작업이 끝났고 값 T 를 이제 사용할 수 있다는 뜻입니다.

Note: 직접 poll 을 호출해야 하는 경우는 드물지만, 정말 그럴 일이 있다면 대부분의 퓨처는 한 번 Ready 를 반환한 뒤 다시 poll 하면 안 된다는 점을 기억하세요. 많은 퓨처가, 준비 상태가 된 뒤 다시 poll 하면 패닉합니다. 다시 poll 해도 안전한 퓨처라면 문서에서 그 사실을 명시해 둘 것입니다. 이는 Iterator::next 의 동작과 비슷합니다.

여러분이 await 를 사용하는 코드를 보면, 러스트는 그것을 내부적으로 poll 을 호출하는 코드로 컴파일합니다. 예를 들어 목록 17-4에서 단일 URL에 대한 페이지 제목을 출력했던 코드는, 러프하게 말하면(정확히 일치하지는 않지만) 다음과 비슷한 코드로 컴파일됩니다.

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 => {
        // But what goes here?
    }
}

그렇다면 퓨처가 아직 Pending 인 경우에는 무엇을 해야 할까요? 다시 확인하고, 또 다시 확인하고, 결국 준비될 때까지 반복해야 합니다. 즉, 루프가 필요합니다.

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 => {
            // continue
        }
    }
}

하지만 러스트가 정말로 이 코드 그대로 컴파일한다면, 모든 await 는 블로킹이 됩니다. 그것은 우리가 원하던 것과 정반대죠! 대신 러스트는 이 루프가 어떤 “다른 무언가”에게 제어권을 넘겨, 지금 퓨처 작업을 잠시 멈추고 다른 퓨처를 실행한 뒤 나중에 다시 이 퓨처를 확인하게 만듭니다. 지금까지 보았듯 그 “다른 무언가”가 바로 async 런타임이며, 이런 스케줄링과 조율이 런타임의 핵심 역할 중 하나입니다.

“메시지 전달로 두 태스크 사이 데이터 보내기” 절에서 우리는 rx.recv 를 기다렸습니다. recv 호출은 하나의 퓨처를 반환하고, 그 퓨처를 await 했습니다. 수신 메시지가 있으면 Some(message), 송신 쪽이 닫히면 None 으로 resolve 될 때까지 런타임이 그것을 멈춰 둔다고 설명했죠. 이제 Future 트레이트, 특히 Future::poll 을 더 잘 알게 되었으니 그 이유를 이해할 수 있습니다. pollPoll::Pending 을 반환하면, 런타임은 이 퓨처가 아직 준비되지 않았음을 압니다. 반대로 Poll::Ready(Some(message)) 또는 Poll::Ready(None) 을 반환하면, 런타임은 이 퓨처가 준비되었고 이제 다음 단계로 진행할 수 있음을 압니다.

런타임이 구체적으로 이를 어떻게 하는지는 이 책 범위를 벗어납니다. 중요한 것은 퓨처의 기본 동작 원리입니다. 런타임은 자신이 담당하는 각 퓨처를 poll 하며, 아직 준비되지 않은 퓨처는 다시 잠재워 둡니다.

Pin 타입과 Unpin 트레이트

목록 17-13에서는 trpl::join! 매크로로 세 개의 퓨처를 함께 기다렸습니다. 하지만 현실에서는 벡터 같은 컬렉션에, 런타임이 시작되기 전에는 몇 개가 될지 모르는 퓨처들이 들어 있는 경우도 흔합니다. 목록 17-23은 세 개의 퓨처를 벡터에 넣고 trpl::join_all 을 호출하는 코드입니다. 그런데 이 코드는 아직 컴파일되지 않습니다.

Filename: src/main.rs
extern crate trpl; // required for mdbook test

use std::time::Duration;

fn main() {
    trpl::block_on(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 {
            // --snip--
            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;
    });
}
Listing 17-23: 컬렉션 안의 퓨처들 기다리기

우리는 각 퓨처를 Box 안에 넣어 트레이트 객체(trait objects) 로 만들었습니다. 12장의 [“run 에서 에러 반환하기”] 절에서 trait object를 잠깐 사용했었죠 (트레이트 객체는 18장에서 자세히 다룹니다). 트레이트 객체를 쓰면, async 블록 각각이 만드는 익명 퓨처 타입이 모두 달라도, 셋 다 Future 트레이트를 구현한다는 사실만을 이용해 하나의 동일한 타입처럼 다룰 수 있습니다.

놀라울 수도 있습니다. 사실 이 async 블록들은 아무 값도 반환하지 않으므로, 모두 Future<Output = ()> 를 만들어 냅니다. 하지만 Future 는 어디까지나 트레이트입니다. 그리고 컴파일러는 각 async 블록에 대해, 출력 타입이 같더라도 서로 다른 익명 enum 을 실제로 생성합니다. 우리가 손으로 서로 다른 구조체 두 개를 만들어서는 하나의 Vec 에 섞어 넣을 수 없는 것처럼, 컴파일러가 만든 서로 다른 enum들도 그대로는 섞을 수 없습니다.

그리고 이 퓨처 컬렉션을 trpl::join_all 함수에 넘기고 그 결과를 await 하려 하면, 다음과 같은 오류가 납니다.

error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
  --> src/main.rs:48:33
   |
48 |         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`

이 오류 메시지의 도움말은 값을 핀(pin) 해야 한다고 알려 줍니다. 즉 Pin 타입 안에 넣어 “메모리에서 움직이지 않는다”는 보장을 줘야 한다는 뜻입니다. 오류는 dyn Future<Output = ()>Unpin 트레이트를 구현하지 않았기 때문에 이 작업이 필요하다고 말합니다.

trpl::join_all 함수는 JoinAll 이라는 구조체를 반환하는데, 이 구조체는 타입 F 에 대해 제네릭이며 F: Future 라는 제약을 가집니다. 퓨처를 await 할 때는 러스트가 암묵적으로 그 퓨처를 pin 합니다. 그래서 보통은 우리가 pin! 을 직접 쓸 일이 없습니다.

하지만 여기서는 퓨처를 직접 await 하는 것이 아닙니다. 대신 퓨처들의 컬렉션을 join_all 함수에 넘겨, JoinAll 이라는 새 퓨처를 만들고 있습니다. 그리고 join_all 시그니처는 컬렉션 안의 항목 타입이 모두 Future 를 구현해야 한다고 요구하는데, Box<T>Future 를 구현하는 것은 오직 그 안의 TFuture 이면서 동시에 Unpin 도 구현할 때뿐입니다.

생각할 게 많아졌습니다. 이를 제대로 이해하려면, 퓨처 트레이트가 실제로 어떻게 동작하는지, 특히 pinning 과 관련해 조금 더 깊이 들어가야 합니다. 다시 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 가 어떤 타입이어야 하는지를 러스트에게 알려 준다.
  • 하지만 완전히 임의의 타입일 수는 없다. 메서드가 구현되는 타입 그 자체, 그 타입에 대한 참조 또는 스마트 포인터, 혹은 그 참조를 Pin 으로 감싼 것만 가능하다.

이 문법은 18장에서 더 다룹니다. 지금은 어떤 퓨처가 Pending 인지 Ready(Output) 인지 직접 poll 해 보려면, 그 타입에 대한 Pin<&mut Self> 가 필요하다는 사실만 알면 됩니다.

Pin&, &mut, Box, Rc 같은 포인터 비슷한 타입을 감싸는 래퍼입니다. (정확히는 Deref 또는 DerefMut 를 구현한 타입과 함께 동작하지만, 사실상 참조와 스마트 포인터를 감싼다고 생각하면 됩니다.) Pin 자체가 포인터인 것은 아니며, RcArc 처럼 자체 동작을 가지지도 않습니다. 이것은 순전히 컴파일러가 포인터 사용에 대한 특정 제약을 강제하도록 도와주는 도구입니다.

await 가 결국 poll 호출로 구현된다는 사실을 떠올리면, 앞서 보았던 오류 메시지도 조금 더 이해됩니다. 그런데 오류는 Pin 이 아니라 Unpin 을 이야기하고 있었죠. 그렇다면 PinUnpin 은 정확히 어떤 관계이고, 왜 Futurepoll 을 호출할 때 selfPin 안에 감싼 형태로 요구할까요?

이 장 앞에서 설명했듯, async 코드 안의 여러 await 지점은 결국 상태 머신으로 컴파일됩니다. 그리고 컴파일러는 그 상태 머신도 러스트의 일반적인 소유권, 대여, 안전성 규칙을 지키도록 만들어 줍니다. 이를 위해 러스트는 한 await 지점과 다음 await 지점(또는 async 블록 끝) 사이에서 어떤 데이터가 필요한지를 추적하고, 각 상태를 표현하는 variant를 생성합니다. 각 variant는 해당 시점 코드가 필요로 하는 데이터에 대해, 소유권이든, 가변 참조든, 불변 참조든 필요한 접근 방식을 가집니다.

지금까지는 괜찮습니다. 어떤 async 블록 안의 소유권이나 참조 관계를 잘못 쓰면, 대여 검사기가 알려 줍니다. 하지만 그 블록에 해당하는 퓨처를 움직이고 싶어질 때 상황이 복잡해집니다. 예를 들어 join_all 에 넘기기 위해 Vec 안으로 퓨처를 넣거나, 함수에서 퓨처를 반환하는 경우처럼 말이죠.

퓨처를 움직인다는 것은 사실 컴파일러가 생성한 상태 머신 자체를 움직인다는 뜻입니다. 그런데 일반적인 대부분의 러스트 타입과 달리, async 블록에서 생성된 퓨처는 어떤 variant의 필드 안에 자기 자신을 참조하는 참조를 담게 될 수도 있습니다. 그림 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: 자기 자신을 참조하는 데이터 타입을 움직였을 때의 위험한 결과

이론적으로는 러스트 컴파일러가 어떤 객체가 움직일 때마다 그 객체를 참조하는 모든 참조를 다시 고쳐 줄 수도 있겠지만, 그렇게 하면 성능 비용이 상당히 커질 수 있습니다. 특히 참조의 연결 구조가 복잡할수록 그렇겠죠. 대신, 해당 데이터 구조가 메모리에서 움직이지 않도록 만들 수 있다면, 참조를 하나도 갱신하지 않아도 됩니다. 이것이 바로 러스트의 대여 검사기가 하는 역할입니다. 안전한 코드 안에서는, 어떤 값에 활성 참조가 있을 때 그 값을 움직이지 못하게 막아 줍니다.

Pin 은 여기에 우리가 필요한 정확한 보장을 더해 줍니다. 어떤 값을 Pin 으로 감싸 pin 하면, 그 값은 더 이상 메모리에서 움직일 수 없습니다. 따라서 Pin<Box<SomeType>> 가 있다면, 실제로 pin 되는 것은 Box 포인터가 아니라 그 안의 SomeType 값입니다. 그림 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 terminates inside the “pinned” box at the “fut” table.
그림 17-6: 자기 자신을 참조하는 퓨처 타입을 가리키는 `Box` 를 pin 하기

사실 Box 포인터 자체는 여전히 자유롭게 움직일 수 있습니다. 중요한 것은 “최종적으로 참조되는 데이터”가 제자리에 남아 있는지입니다. 포인터가 움직여도 그 포인터가 가리키는 데이터가 같은 위치에 그대로 있다면, 그림 17-7처럼 아무 문제도 없습니다. (추가 연습으로, 타입 문서와 std::pin 모듈 문서를 보며, PinBox 를 감쌀 때 이것이 정확히 어떻게 가능한지 스스로 따져 보면 좋습니다.) 핵심은 자기 자신을 참조하는 타입 자체 가 움직이지 않아야 한다는 것입니다. 그것이 지금 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 뒤에 있더라도 그냥 움직여도 완전히 안전합니다. Pin을 신경 써야 하는 경우는, 내부 참조를 가진 타입일 때뿐입니다. 숫자나 불리언 같은 기본 값은 당연히 내부 참조를 가질 수 없습니다. 여러분이 보통 러스트에서 다루는 대부분의 타입도 마찬가지입니다. 예를 들어 Vec 는 자유롭게 움직여도 괜찮습니다. 지금까지 본 내용을 바탕으로 생각해 보면, 만약 Pin<Vec<String>> 가 있다면 Vec<String> 이 실제로는 움직여도 전혀 문제 없는 타입임에도 불구하고, Pin 이 제공하는 더 제한적인 API를 통해서만 다뤄야 할 것입니다. 이런 경우에 “이 타입은 움직여도 괜찮다”고 컴파일러에게 알려 줄 방법이 필요하고, 그게 바로 Unpin 입니다.

Unpin 은 16장에서 본 Send, Sync 와 비슷한 마커 트레이트이며, 자기만의 기능은 없습니다. 어떤 타입이 특정 문맥에서 안전하게 쓰여도 된다는 사실을 컴파일러에게 알려 주는 용도로만 존재합니다. Unpin 은 “이 타입은 값이 움직여도 괜찮다”고 컴파일러에게 알려 줍니다.

SendSync 처럼, 컴파일러는 안전하다고 증명할 수 있는 모든 타입에 대해 자동으로 Unpin 을 구현합니다. 그리고 여기서도 비슷한 특수 경우가 있는데, 어떤 타입에 대해서는 Unpin 이 아니라 !Unpin 으로 표기합니다. 문법은 impl !Unpin for SomeType 처럼 보이며, 이는 그 타입이 Pin 안에서 안전하게 사용되려면 “움직이지 않는다”는 보장을 지켜야 한다는 뜻입니다.

다시 말해 PinUnpin 관계에서 기억해야 할 점은 두 가지입니다. 첫째, Unpin 이 “보통의 기본 경우”이고, !Unpin 이 특수한 경우입니다. 둘째, 어떤 타입이 Unpin 인지 !Unpin 인지는 오직 Pin<&mut SomeType> 같은 형태의 pin된 포인터와 함께 그 타입을 사용할 때만 의미가 있습니다.

구체적으로 생각해 보기 위해 String 을 떠올려 보세요. String 은 길이와, 그 안의 유니코드 문자들을 가집니다. 그림 17-8처럼 StringPin 으로 감쌀 수는 있습니다. 하지만 String 은 러스트의 대부분 타입처럼 자동으로 Unpin 을 구현합니다.

A box labeled “Pin” on the left with an arrow going from it to a box labeled “String” on the right. The “String” box contains the data 5usize, representing the length of the string, and the letters “h”, “e”, “l”, “l”, and “o” representing the characters of the string “hello” stored in this String instance. A dotted rectangle surrounds the “String” box and its label, but not the “Pin” box.
그림 17-8: `String` 을 pin 하기. 점선은 `String` 이 `Unpin` 을 구현하므로 실제로는 pin 되지 않는다는 뜻이다

그 결과, 그림 17-9처럼 같은 메모리 위치에 있던 문자열 하나를 완전히 다른 문자열로 교체하는 일도 가능합니다. 이것은 Pin 계약을 깨지 않습니다. String 에는 값을 움직이면 위험해지는 내부 자기 참조가 없기 때문입니다. 그래서 String!Unpin 이 아니라 Unpin 을 구현하는 것입니다.

The same “hello” string data from the previous example, now labeled “s1” and grayed out. The “Pin” box from the previous example now points to a different String instance, one that is labeled “s2”, is valid, has a length of 7usize, and contains the characters of the string “goodbye”. s2 is surrounded by a dotted rectangle because it, too, implements the Unpin trait.
그림 17-9: 메모리 안의 `String` 을 완전히 다른 `String` 으로 교체하기

이제 우리는 목록 17-23의 join_all 오류를 이해할 만큼 충분히 배웠습니다. 우리는 async 블록이 만든 퓨처들을 Vec<Box<dyn Future<Output = ()>>> 안으로 움직이려 했는데, 방금 보았듯 그런 퓨처에는 내부 자기 참조가 있을 수 있으므로 자동으로 Unpin 을 구현하지 않습니다. 따라서 이 값들을 pin 한 뒤에야 Vec 안으로 넣을 수 있고, 그렇게 하면 그 퓨처 안의 데이터가 더 이상 움직이지 않는다는 사실을 믿을 수 있습니다. 목록 17-24는 각 퓨처를 정의할 때 pin! 매크로를 호출하고, trait object 타입도 조정해서 이 코드를 고치는 방법을 보여 줍니다.

extern crate trpl; // required for mdbook test

use std::pin::{Pin, pin};

// --snip--

use std::time::Duration;

fn main() {
    trpl::block_on(async {
        let (tx, mut rx) = trpl::channel();

        let tx1 = tx.clone();
        let tx1_fut = pin!(async move {
            // --snip--
            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 = pin!(async {
            // --snip--
            while let Some(value) = rx.recv().await {
                println!("received '{value}'");
            }
        });

        let tx_fut = pin!(async move {
            // --snip--
            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<Pin<&mut dyn Future<Output = ()>>> =
            vec![tx1_fut, rx_fut, tx_fut];

        trpl::join_all(futures).await;
    });
}
Listing 17-24: 벡터 안으로 움직일 수 있도록 퓨처를 pin 하기

이제 이 예제는 컴파일되고 실행되며, 런타임에 벡터 안 퓨처를 추가하거나 제거하고, 그 모두를 함께 join_all 로 기다릴 수 있습니다.

PinUnpin 은 일반적인 러스트 코드보다는, 저수준 라이브러리를 만들거나 런타임 자체를 구현할 때 훨씬 더 중요합니다. 하지만 오류 메시지에서 이 둘을 보게 되면, 이제는 어떻게 고쳐야 할지 훨씬 감을 잡을 수 있을 것입니다.

Note: PinUnpin 의 조합 덕분에, 그렇지 않았다면 자기 참조 때문에 다루기 어려웠을 복잡한 타입들의 전체 부류를 러스트에서 안전하게 구현할 수 있습니다. Pin 이 필요한 타입은 오늘날 async 러스트에서 가장 흔하지만, 가끔 다른 문맥에서도 등장할 수 있습니다.

PinUnpin 이 어떻게 동작하고 어떤 규칙을 지켜야 하는지는 std::pin API 문서에 매우 자세히 설명되어 있으므로, 더 알고 싶다면 거기서 시작하는 것이 좋습니다.

내부 동작을 더 깊이 이해하고 싶다면, [Asynchronous Programming in Rust] async-book2장4장 을 읽어 보세요.

Stream 트레이트

이제 Future, Pin, Unpin 을 더 잘 이해하게 되었으니, 이제 Stream 트레이트로 시선을 돌려 봅시다. 이 장 앞에서 배웠듯, 스트림은 비동기적인 반복자와 비슷합니다. 하지만 IteratorFuture 와 달리, 적어도 이 글을 쓰는 시점에는 Stream 이 아직 표준 라이브러리에 정의되어 있지 않습니다. 대신 생태계 전반에서 널리 쓰이는 사실상의 표준 정의가 futures 크레이트에 존재합니다.

StreamIteratorFuture 를 어떻게 합치는지 보기 전에, 먼저 두 트레이트 정의를 다시 떠올려 봅시다. 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 이라는 연관 타입을 정의하는데, 이는 스트림이 만들어 내는 항목 타입입니다. 이 점은 0개에서 여러 개까지 항목이 있을 수 있다는 점에서 Iterator 와 비슷하고, unit 타입 () 이더라도 언제나 출력값이 하나뿐인 Future 와는 다릅니다.

또한 Streampoll_next 라는 메서드를 정의합니다. 이름에서 보이듯, 이 메서드는 Future::poll 처럼 “폴링” 되며, Iterator::next 처럼 “다음 항목”을 만들어 냅니다. 그래서 반환 타입이 Poll<Option<Self::Item>> 으로 두 개를 결합한 형태가 됩니다. 바깥 타입이 Poll 인 이유는, 퓨처처럼 아직 준비되지 않았을 수도 있기 때문입니다. 안쪽 타입이 Option 인 이유는, 반복자처럼 더 이상 항목이 없는 경우를 표현해야 하기 때문입니다.

이와 매우 비슷한 정의가 언젠가는 표준 라이브러리 안으로 들어갈 가능성이 큽니다. 그 전까지는 이것이 대부분 런타임이 사용하는 사실상의 도구이며, 우리가 여기서 설명하는 내용도 대체로 그대로 적용됩니다.

하지만 “스트림: 순차적으로 이어지는 퓨처” 절의 예제에서는 직접 poll_nextStream 을 사용하지 않았고, 대신 nextStreamExt 를 사용했습니다. 물론 우리가 직접 상태 머신을 써서 poll_next 기반으로 코드를 짜는 것도 가능합니다. 퓨처에 대해 직접 poll 을 쓰는 것이 가능했던 것과 같은 이치입니다. 하지만 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...
}
}

Note: 우리가 이 장 앞에서 사용한 실제 StreamExt 정의는 이 코드와 조금 다릅니다. 그 이유는 아직 “트레이트 안 async 함수”를 지원하지 않던 옛 버전의 러스트도 지원해야 했기 때문입니다. 그래서 실제 정의는 다음과 같이 생겼습니다.

fn next(&mut self) -> Next<'_, Self> where Self: Unpin;

여기의 Next 타입은 Future 를 구현하는 구조체이며, Next<'_, Self> 형태로 self 참조의 라이프타임을 이름 붙일 수 있게 해 줍니다. 그래야 await 와 함께 자연스럽게 사용할 수 있습니다.

StreamExt 트레이트는 또한 스트림과 함께 사용할 수 있는 흥미로운 메서드들의 본거지이기도 합니다. StreamExtStream 을 구현하는 모든 타입에 대해 자동 구현되지만, 이 둘이 별도 트레이트로 분리되어 있는 이유는, 기초 트레이트를 바꾸지 않고도 생태계가 편의 API를 계속 확장·발전시킬 수 있게 하기 위해서입니다.

우리가 trpl 에서 사용한 StreamExt 버전은 next 메서드를 정의할 뿐 아니라, Stream::poll_next 를 올바르게 호출하는 기본 구현까지 제공합니다. 즉, 여러분이 직접 스트리밍 데이터 타입을 만들어야 하는 경우에도, 구현해야 하는 것은 Stream 하나뿐이고, 그 타입을 사용하는 사람은 StreamExt 와 그 메서드들을 자동으로 함께 쓸 수 있습니다.

이 트레이트들의 더 저수준 세부는 여기까지만 다루겠습니다. 이제 마무리로, 퓨처 (스트림 포함), 태스크, 스레드가 서로 어떻게 들어맞는지 함께 생각해 봅시다!

퓨처, 태스크, 스레드

모두 함께 보기: 퓨처, 태스크, 스레드

16장에서 보았듯, 스레드는 동시성을 구현하는 한 가지 접근법입니다. 그리고 이 장에서는 퓨처와 스트림을 사용하는 async 라는 또 다른 접근을 살펴보았습니다. 언제 어느 쪽을 골라야 할지 궁금할 수 있는데, 대답은 “상황에 따라 다르다” 입니다. 그리고 많은 경우, 선택지는 스레드 혹은 async 가 아니라 스레드 그리고 async 입니다.

많은 운영체제는 수십 년 전부터 스레드 기반 동시성 모델을 제공해 왔고, 그 결과 많은 프로그래밍 언어도 이를 지원합니다. 하지만 이런 모델에도 분명한 트레이드오프가 있습니다. 많은 운영체제에서는 스레드 하나마다 적지 않은 메모리를 사용합니다. 또한 스레드는 운영체제와 하드웨어가 이를 지원할 때만 선택할 수 있습니다. 일반적인 데스크톱이나 모바일 컴퓨터와 달리, 일부 임베디드 시스템은 아예 운영체제가 없으므로 스레드도 없습니다.

async 모델은 이와 다른, 그리고 궁극적으로는 상호 보완적인 트레이드오프를 제공합니다. async 모델에서는 동시성 작업이 각자 전용 스레드를 가질 필요가 없습니다. 대신 스트림 절에서 trpl::spawn_task 로 작업을 시작했듯이, 태스크 위에서 실행될 수 있습니다. 태스크는 스레드와 비슷하지만 운영체제가 아니라 라이브러리 수준의 코드, 즉 런타임이 관리합니다.

스레드를 생성하는 API와 태스크를 생성하는 API가 비슷해 보이는 데는 이유가 있습니다. 스레드는 동기 연산 묶음에 대한 경계 역할을 하고, 동시성은 스레드 사이 에서 발생합니다. 태스크는 비동기 연산 묶음에 대한 경계 역할을 하고, 동시성은 태스크 사이 는 물론 태스크 내부 에서도 발생할 수 있습니다. 왜냐하면 하나의 태스크는 본문 안의 여러 퓨처 사이를 전환할 수 있기 때문입니다. 마지막으로, 퓨처는 러스트에서 가장 세밀한 동시성 단위이며, 하나의 퓨처는 다시 다른 퓨처들의 트리를 표현할 수 있습니다. 런타임, 정확히 말하면 executor 가 태스크를 관리하고, 태스크는 퓨처를 관리합니다. 이런 의미에서 태스크는 운영체제가 아니라 런타임이 관리하는, 더 가벼운 스레드와 비슷하되, 런타임 관리 덕분에 추가 기능을 가진다고 볼 수 있습니다.

그렇다고 async 태스크가 항상 스레드보다 낫다는 뜻은 아닙니다(혹은 그 반대도 아닙니다). 스레드 기반 동시성은 어떤 면에서는 async 동시성보다 더 단순한 프로그래밍 모델입니다. 이것은 장점일 수도 있고 단점일 수도 있습니다. 스레드는 어느 정도 “fire and forget” 성격이 있습니다. 퓨처에 해당하는 내장 개념이 없기 때문에, 운영체제에 의해 끊기지 않는 한 그냥 끝까지 실행됩니다.

흥미롭게도 스레드와 태스크는 종종 아주 잘 어울립니다. 어떤 런타임에서는 태스크를 스레드 사이로 옮길 수도 있기 때문입니다. 실제로 우리가 사용한 런타임의 내부, 즉 spawn_blockingspawn_task 를 포함한 런타임은 기본적으로 멀티스레드입니다! 많은 런타임은 work stealing 이라는 접근법을 사용해, 현재 스레드 사용 상태에 따라 태스크를 투명하게 스레드 사이로 이동시켜 전체 시스템 성능을 개선합니다. 이런 접근은 실제로 스레드 태스크, 그리고 퓨처를 모두 요구합니다.

어떤 상황에 어떤 방식을 써야 할지 고민할 때는, 다음과 같은 경험칙을 참고하면 좋습니다.

  • 작업이 매우 병렬화 가능 하다면(즉 CPU-bound 작업, 예를 들어 각 부분을 독립적으로 처리할 수 있는 대량의 데이터 처리) 스레드가 더 나은 선택입니다.
  • 작업이 매우 동시적 이라면(즉 I/O-bound 작업, 예를 들어 서로 다른 시점과 속도로 들어오는 여러 메시지를 다루는 경우) async 가 더 나은 선택입니다.

그리고 병렬성과 동시성이 둘 다 필요하다면, 스레드와 async 중 하나만 선택할 필요는 없습니다. 둘을 자유롭게 함께 써서, 각자가 가장 잘하는 역할을 맡기면 됩니다. 목록 17-25는 실제 러스트 코드에서 흔히 볼 수 있는 이런 조합의 간단한 예를 보여 줍니다.

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::block_on(async {
        while let Some(message) = rx.recv().await {
            println!("{message}");
        }
    });
}
Listing 17-25: 블로킹 코드는 스레드에서 메시지를 보내고, async 블록은 그 메시지를 기다리기

우리는 먼저 async 채널을 만들고, 채널의 송신자 쪽 소유권을 move 키워드로 가져가는 스레드를 생성합니다. 그 스레드 안에서는 1부터 10까지 숫자를 보내고, 각 숫자 사이에 1초씩 쉽니다. 마지막으로, 이 장 내내 해 왔듯이 trpl::block_on 에 async 블록을 넘겨 그 안에서 메시지를 await 합니다.

이 장의 도입부에서 이야기했던 영상 인코딩 작업을 다시 떠올려 보면, 예를 들어 계산량이 큰 비디오 인코딩 작업은 전용 스레드에서 수행하고(비디오 인코딩은 CPU-bound 이기 때문), 그 작업이 끝났다는 사실은 async 채널을 통해 UI에 알려 주는 식으로 생각할 수 있습니다. 현실의 프로그램에는 이런 조합 예가 정말 많습니다.

정리

이 책에서 동시성을 보는 것은 아직 끝이 아닙니다. 21장 프로젝트에서는, 여기서 본 간단한 예제보다 훨씬 더 현실적인 상황에서 이 개념들을 적용하고, 스레드 기반 문제 해결과 태스크/퓨처 기반 문제 해결을 더 직접적으로 비교하게 됩니다.

여러분이 어느 접근을 택하든, 러스트는 안전하고 빠른 동시성 코드를 작성하는 데 필요한 도구를 제공합니다. 고성능 웹 서버를 만들든, 임베디드 운영체제를 만들든 마찬가지입니다.

다음 장에서는 러스트 프로그램이 더 커졌을 때 문제를 모델링하고 해결책을 구조화하는 관용적인 방법들을 살펴보겠습니다. 또한 러스트의 이런 관용구가 여러분이 객체 지향 프로그래밍에서 익숙할 수 있는 방식과는 어떻게 관계되는지도 이야기해 보겠습니다.

객체 지향 프로그래밍 기능

객체 지향 프로그래밍(OOP)은 프로그램을 모델링하는 한 가지 방식입니다. 프로그램적 개념으로서의 객체는 1960년대 Simula 언어에서 처음 도입되었습니다. 그리고 그 객체 개념은 “객체들이 서로 메시지를 주고받는다”는 Alan Kay의 프로그래밍 아키텍처에도 영향을 주었습니다. 그는 이 아키텍처를 설명하기 위해 1967년에 객체 지향 프로그래밍 이라는 용어를 만들었습니다. OOP가 무엇인지에 대해서는 서로 다른 정의가 많고, 어떤 정의에 따르면 러스트는 객체 지향이고, 다른 정의에 따르면 그렇지 않기도 합니다. 이 장에서는 일반적으로 객체 지향의 특징으로 여겨지는 몇 가지 성질을 살펴보고, 그것들이 관용적인 러스트 코드에서는 어떻게 번역되는지 알아보겠습니다. 그런 다음 러스트에서 하나의 객체 지향 디자인 패턴을 구현하는 방법을 보여 주고, 그렇게 하는 것과 러스트 자체의 강점을 살린 해결책을 쓰는 것 사이의 트레이드오프를 논의하겠습니다.

객체 지향 언어의 특징

객체 지향 언어의 특징

어떤 기능을 가져야 언어를 객체 지향이라고 부를 수 있는지에 대해서는 프로그래밍 커뮤니티 안에 합의가 없습니다. 러스트는 OOP를 포함한 여러 프로그래밍 패러다임의 영향을 받았습니다. 예를 들어 13장에서는 함수형 프로그래밍에서 온 기능들을 살펴보았습니다. 보통 OOP 언어는 몇 가지 공통 특징을 공유한다고 볼 수 있는데, 대표적으로 객체, 캡슐화, 상속이 있습니다. 이제 각각이 무엇을 의미하는지, 그리고 러스트가 이를 지원하는지 보겠습니다.

객체는 데이터와 동작을 함께 가진다

Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides 의 책 Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994), 흔히 Gang of Four 책이라고 불리는 책은 객체 지향 디자인 패턴의 대표적인 카탈로그입니다. 이 책은 OOP를 다음과 같이 정의합니다.

객체 지향 프로그램은 객체들로 이루어진다. 객체 는 데이터와 그 데이터에 대해 작동하는 절차를 함께 묶는다. 이 절차들은 보통 메서드 또는 연산 이라고 부른다.

이 정의에 따르면 러스트는 객체 지향입니다. 구조체와 enum은 데이터를 가지고 있고, impl 블록은 구조체와 enum에 메서드를 제공합니다. 비록 구조체와 enum에 메서드가 있다고 해서 그것을 꼭 객체 라고 부르지는 않지만, Gang of Four가 말한 의미에서의 객체 기능은 분명 제공합니다.

구현 세부를 숨기는 캡슐화

객체 지향과 함께 자주 언급되는 또 다른 측면은 캡슐화(encapsulation) 입니다. 캡슐화란 객체의 구현 세부가 그 객체를 사용하는 코드에서 직접 접근되지 않는다는 뜻입니다. 따라서 객체와 상호작용하는 유일한 방법은 그 공개 API를 통하는 것이며, 사용하는 코드는 객체 안으로 파고들어 데이터를 직접 바꾸거나 동작을 직접 바꾸면 안 됩니다. 이런 구조 덕분에 프로그래머는 객체를 사용하는 코드를 바꾸지 않고도, 객체 내부 구현을 변경하고 리팩터링할 수 있습니다.

캡슐화를 어떻게 제어하는지는 7장에서 설명했습니다. 우리는 pub 키워드를 사용해 코드 안 어떤 모듈, 타입, 함수, 메서드를 공개할지 정할 수 있고, 기본적으로 다른 모든 것은 private 입니다. 예를 들어 정수 벡터를 필드로 가진 AveragedCollection 구조체를 정의할 수 있습니다. 또한 이 구조체는 그 벡터 안 값들의 평균도 필드로 가지게 만들 수 있습니다. 이렇게 하면 누군가가 평균을 필요로 할 때마다 매번 계산하지 않아도 됩니다. 즉, AveragedCollection 이 평균값을 캐시해 두는 것입니다. 목록 18-1이 이 구조체 정의를 보여 줍니다.

Filename: src/lib.rs
pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}
Listing 18-1: 정수 목록과 그 평균을 유지하는 AveragedCollection 구조체

구조체 자체는 pub 이라 다른 코드도 사용할 수 있지만, 구조체 내부 필드는 여전히 private 입니다. 이 예에서는 이것이 중요합니다. 리스트에 값을 추가하거나 제거할 때마다 평균도 함께 갱신되게 만들고 싶기 때문입니다. 그래서 목록 18-2처럼 add, remove, average 메서드를 구현하고, 각 메서드 안에서 private 한 update_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 에 대해 public 메서드 add, remove, average 구현하기

AveragedCollection 인스턴스 안의 데이터에 접근하거나 수정할 수 있는 방법은 이 public 메서드뿐입니다. add 메서드로 list 에 항목을 추가하거나 remove 로 제거할 때, 각 구현은 내부에서 private 한 update_average 를 호출해 average 필드도 함께 갱신합니다.

우리는 listaverage 필드를 private 으로 유지하기 때문에, 외부 코드는 list 필드 안에 직접 항목을 추가하거나 제거할 수 없습니다. 만약 그렇게 되면 list 가 바뀔 때 average 필드가 서로 어긋날 수 있기 때문입니다. average 메서드는 average 필드 값을 반환해서 외부 코드가 평균을 읽을 수 있게 하되, 직접 수정하지는 못하게 합니다.

이처럼 AveragedCollection 구조체의 구현 세부를 캡슐화해 두었기 때문에, 나중에 예를 들어 내부 자료구조를 Vec<i32> 에서 HashSet<i32> 로 바꾸는 일도 쉽습니다. add, remove, average public 메서드 시그니처만 유지된다면, AveragedCollection 을 사용하는 코드는 전혀 바꿀 필요가 없습니다. 반대로 list 를 public 으로 노출했다면 이렇게 하기 어려웠을 것입니다. HashSet<i32>Vec<i32> 는 항목 추가/제거 메서드가 다르므로, 외부 코드도 함께 바꿔야 했을 가능성이 큽니다.

만약 언어가 객체 지향으로 간주되려면 캡슐화가 필수라고 본다면, 러스트는 그 요건을 충족합니다. 코드의 각 부분에 대해 pub 를 붙일지 말지를 선택할 수 있기 때문에, 구현 세부를 충분히 캡슐화할 수 있습니다.

타입 시스템과 코드 공유 측면의 상속

상속(inheritance) 은 한 객체가 다른 객체 정의의 요소를 물려받아, 부모 객체의 데이터와 동작을 다시 정의하지 않고도 그대로 얻게 하는 메커니즘입니다.

만약 어떤 언어가 객체 지향으로 인정받으려면 상속도 반드시 가져야 한다면, 러스트는 그런 언어는 아닙니다. 매크로를 사용하지 않는 한, 부모 구조체의 필드와 메서드 구현을 그대로 물려받는 구조체를 정의하는 방법은 없습니다.

하지만 여러분이 프로그래밍 도구 상자 안에 “상속”을 갖고 있는 것에 익숙하다면, 상속을 왜 쓰고 싶었는지에 따라 러스트에서는 다른 해결책을 사용할 수 있습니다.

상속을 고르는 이유는 크게 두 가지입니다. 하나는 코드 재사용 입니다. 어떤 타입에 특정 동작을 구현해 두고, 상속을 통해 다른 타입에서도 그 구현을 재사용하고 싶은 것입니다. 러스트에서는 이것을 제한적인 방식으로, 기본 트레이트 메서드 구현으로 할 수 있습니다. 목록 10-14에서 Summary 트레이트의 summarize 메서드에 기본 구현을 추가했던 것을 떠올려 보세요. Summary 를 구현하는 어떤 타입이든, 추가 코드 없이 summarize 메서드를 바로 사용할 수 있습니다. 이는 부모 클래스가 메서드 구현을 제공하고, 그 메서드를 상속받은 자식 클래스가 그대로 갖게 되는 것과 비슷합니다. 또한 Summary 트레이트를 구현할 때 기본 summarize 구현을 덮어쓸 수도 있는데, 이는 자식 클래스가 부모에게서 물려받은 메서드를 재정의하는 것과 비슷합니다.

상속을 사용하는 다른 이유는 타입 시스템 과 관련이 있습니다. 즉, 자식 타입을 부모 타입이 쓰이는 자리 어디에든 쓸 수 있게 하려는 것입니다. 이것은 다형성(polymorphism) 이라고도 하며, 특정 특징을 공유하는 여러 객체를 런타임에 서로 대체할 수 있다는 뜻입니다.

다형성

많은 사람은 다형성을 곧 상속과 같은 뜻으로 사용합니다. 하지만 실제로는 더 일반적인 개념으로, 여러 타입의 데이터에 대해 동작할 수 있는 코드를 뜻합니다. 상속 문맥에서는 그 타입들이 보통 하위 클래스들입니다.

러스트는 대신, 여러 가능한 타입을 추상화하기 위해 제네릭을 사용하고, 그 타입들이 반드시 어떤 기능을 제공해야 하는지를 제한하기 위해 트레이트 바운드를 사용합니다. 이런 방식을 제한된 매개변수적 다형성(bounded parametric polymorphism) 이라고 부르기도 합니다.

러스트는 상속을 제공하지 않는 대신 다른 트레이드오프를 선택했습니다. 상속은 실제보다 더 많은 코드를 공유하게 만들 위험이 있습니다. 하위 클래스는 언제나 부모 클래스의 모든 특성을 공유해야 하는 것이 아니지만, 상속을 사용하면 그렇게 되어 버리기 쉽습니다. 이 때문에 프로그램 설계 유연성이 떨어질 수 있습니다. 또한 어떤 메서드는 하위 클래스에 의미가 없거나 오히려 오류를 일으킬 수 있음에도, 상속 때문에 메서드 호출 가능성이 열려 있다는 문제도 생깁니다. 게다가 어떤 언어는 단일 상속 만 허용하여(즉 자식 클래스가 부모 클래스 하나만 상속할 수 있어) 설계 유연성을 더 제한하기도 합니다.

이런 이유로, 러스트는 런타임 다형성을 위해 상속 대신 트레이트 객체(trait objects)를 사용하는 다른 접근을 취합니다. 이제 트레이트 객체가 어떻게 동작하는지 보겠습니다.

트레이트 객체로 공통 동작 추상화하기

트레이트 객체를 사용하여 공통 동작을 추상화하기

8장에서 벡터의 한계 중 하나로 “한 가지 타입의 요소만 담을 수 있다”는 점을 이야기했습니다. 우리는 목록 8-9에서 SpreadsheetCell enum을 정의해 정수, 실수, 텍스트를 담는 variant를 만들고, 그 덕분에 서로 다른 타입의 데이터를 각 셀에 담으면서도 하나의 벡터로 스프레드시트 행을 표현할 수 있었습니다. 이것은 컴파일 시점에 이미 서로 바꿔 넣을 수 있는 타입 집합을 알고 있을 때는 충분히 좋은 해결책입니다.

하지만 어떤 상황에서는 라이브러리 사용자가 “그 상황에서 허용되는 타입 집합” 자체를 확장할 수 있게 하고 싶을 수도 있습니다. 이를 보여 주기 위해, 항목 목록을 순회하며 각 항목의 draw 메서드를 호출해 화면에 그리는 간단한 GUI 도구 예제를 만들어 보겠습니다. 이는 GUI 도구에서 아주 흔한 기법입니다. 우리는 gui 라는 라이브러리 크레이트를 만들고, 그 안에 GUI 라이브러리의 뼈대를 둘 것입니다. 이 크레이트는 Button 이나 TextField 같은 타입을 제공할 수 있습니다. 하지만 gui 사용자들은 그 외에도 자기만의 그릴 수 있는 타입을 만들고 싶어질 것입니다. 어떤 사람은 Image 를, 어떤 사람은 SelectBox 를 추가하고 싶을 수 있습니다.

이 라이브러리를 작성하는 시점에는, 다른 프로그래머가 만들고 싶어 할 모든 타입을 우리가 미리 알 수도 없고 정의할 수도 없습니다. 하지만 gui 가 여러 서로 다른 타입 값을 추적해야 하고, 각 값에 대해 draw 메서드를 호출해야 한다는 사실은 알고 있습니다. 라이브러리는 draw 메서드를 호출했을 때 구체적으로 무슨 일이 일어나는지 알 필요는 없습니다. 중요한 것은 그 값에 draw 메서드가 존재한다 는 점뿐입니다.

상속이 있는 언어라면, draw 메서드를 가진 Component 클래스 하나를 정의하고, Button, Image, SelectBox 같은 다른 클래스들이 그것을 상속받는 식으로 만들 수 있었을 것입니다. 그런 클래스들은 각자 draw 를 재정의할 수 있지만, 프레임워크 입장에서는 이 모든 타입을 Component 인스턴스처럼 다루며 draw 를 호출할 수 있습니다. 하지만 러스트에는 상속이 없기 때문에, gui 라이브러리 사용자가 라이브러리와 호환되는 새 타입을 만들 수 있도록 다른 구조를 잡아야 합니다.

공통 동작을 위한 트레이트 정의하기

우리가 gui 에서 원하는 동작을 구현하려면, draw 라는 메서드 하나를 가지는 Draw 트레이트를 정의하면 됩니다. 그리고 나서 트레이트 객체를 담는 벡터를 정의할 수 있습니다. 트레이트 객체(trait object) 는, 지정한 트레이트를 구현하는 어떤 타입의 인스턴스와 그 타입의 트레이트 메서드를 런타임에 찾아 호출하기 위한 테이블을 함께 가리키는 값입니다. 트레이트 객체를 만들려면 참조나 Box<T> 같은 포인터 종류 뒤에 dyn 키워드와 트레이트 이름을 적습니다. (트레이트 객체가 왜 반드시 포인터를 통해 써야 하는지는 20장의 “동적 크기 타입과 Sized 트레이트” 절에서 설명합니다.) 제네릭 타입이나 구체 타입을 쓸 수 있는 많은 위치에 트레이트 객체도 사용할 수 있습니다. 트레이트 객체를 사용하면, 러스트의 타입 시스템은 그 문맥에 들어가는 값이 반드시 해당 트레이트를 구현한다는 것을 컴파일 시점에 보장합니다. 따라서 가능한 모든 타입을 미리 알 필요가 없습니다.

우리는 구조체와 enum을 다른 언어의 객체와 구분하기 위해 “객체”라고 부르지 않는다고 앞에서 언급했습니다. 구조체나 enum에서는 데이터는 필드 안에, 동작은 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> 인데, 이것이 바로 트레이트 객체입니다. 즉 Draw 트레이트를 구현하는 어떤 타입이든 Box 로 감싼 값을 넣을 수 있습니다.

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: 각 컴포넌트에 대해 draw 를 호출하는 Screenrun 메서드

이 방식은 트레이트 바운드를 가진 제네릭 타입 매개변수로 구조체를 정의하는 것과는 다르게 동작합니다. 예를 들어 목록 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> 를 함께 담을 수 있습니다. 이제 이것이 어떻게 가능해지는지 보고, 그에 따른 런타임 성능 특성도 함께 이야기해 봅시다.

트레이트 구현하기

이제 Draw 트레이트를 구현하는 타입 몇 개를 추가해 봅시다. 우선 Button 타입을 제공하겠습니다. 물론 실제 GUI 라이브러리를 구현하는 것은 이 책의 범위를 훨씬 벗어나므로, draw 메서드 본문은 실제로는 아무 의미 있는 GUI 코드를 가지지 않습니다. 대충 어떤 모양일지 상상해 보면, Button 구조체는 목록 18-7처럼 width, height, label 같은 필드를 가질 수 있습니다.

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 안에 가지게 될 것입니다. 여기서는 실제 GUI 코드는 생략했지만, Button 도 마찬가지입니다. 또한 Button 타입은 별도의 impl 블록 안에 “버튼을 클릭했을 때 일어나는 일” 같은 메서드를 더 가질 수도 있습니다. 이런 메서드는 TextField 같은 타입에는 적용되지 않겠지요.

만약 우리 라이브러리 사용자가 width, height, options 필드를 가진 SelectBox 구조체를 직접 만들기로 했다면, 그 타입에도 목록 18-8처럼 Draw 트레이트를 구현할 수 있습니다.

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 인스턴스를 만들 수 있습니다. 그리고 SelectBox, Button 을 각각 Box<T> 로 감싸 트레이트 객체로 만든 뒤, Screen 안에 넣을 수 있습니다. 이후 Screen 인스턴스의 run 메서드를 호출하면, components 안의 각 항목에 대해 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 타입을 만들 것이라는 사실을 몰랐습니다. 하지만 SelectBoxDraw 트레이트를 구현했기 때문에, Screen 구현은 이 새로운 타입도 문제없이 다루고 그릴 수 있습니다.

이 개념은 동적 타입 언어에서 흔히 덕 타이핑(duck typing) 이라고 부르는 개념과 비슷합니다. “오리처럼 걷고 오리처럼 꽥꽥거리면, 그것은 오리다” 같은 사고방식입니다. 목록 18-5의 Screen::run 구현은 각 컴포넌트의 구체 타입이 무엇인지 알 필요가 없습니다. 그것이 Button 인지 SelectBox 인지를 검사하지 않고, 그냥 draw 메서드를 호출합니다. 우리는 components 벡터의 타입을 Box<dyn Draw> 로 지정함으로써, Screen 이 “draw 메서드를 호출할 수 있는 값들”만 필요로 한다고 정의한 것입니다.

트레이트 객체와 러스트 타입 시스템을 사용하는 장점은, 런타임에서 어떤 값이 특정 메서드를 구현했는지 검사하거나, 메서드가 없는 값에 대해 잘못 호출할까 봐 걱정할 필요가 없다는 것입니다. 트레이트 객체가 요구하는 트레이트를 값이 구현하지 않으면, 러스트는 그 코드를 아예 컴파일하지 않습니다.

예를 들어 목록 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 에 넘기고 있으므로 다른 타입을 넘겨야 하거나, 혹은 StringDraw 를 구현해 Screen 이 그것에 대해 draw 를 호출할 수 있게 해야 한다는 점을 알려 줍니다.

동적 디스패치 수행하기

10장의 “제네릭을 사용하는 코드의 성능” 절에서, 컴파일러가 제네릭 코드에 대해 단형화를 수행한다고 이야기했습니다. 즉, 제네릭 타입 매개변수 자리에 우리가 실제로 사용하는 구체 타입을 넣은 함수와 메서드를 컴파일러가 각각 생성해 준다는 뜻입니다. 이렇게 컴파일 결과물이 호출할 메서드를 컴파일 시점에 이미 알고 있는 형태를 정적 디스패치(static dispatch) 라고 합니다. 반대로 호출할 메서드를 컴파일 시점에 알 수 없는 경우를 동적 디스패치(dynamic dispatch) 라고 부릅니다. 동적 디스패치에서는 런타임에 어떤 메서드를 호출해야 할지 결정하는 코드가 추가로 실행됩니다.

트레이트 객체를 사용할 때는 러스트가 반드시 동적 디스패치를 사용해야 합니다. 컴파일러는 트레이트 객체를 사용하는 코드와 함께 실제로 어떤 타입이 들어올지 모두 알 수 없기 때문에, “어떤 타입에 구현된 어느 메서드”를 호출해야 하는지 컴파일 시점에 결정할 수 없습니다. 그래서 러스트는 런타임에 트레이트 객체 안의 포인터를 사용해 호출할 메서드를 찾아냅니다. 이 조회에는 런타임 비용이 들며, 정적 디스패치에서 가능한 메서드 인라이닝과 그에 따른 최적화도 막습니다. 또한 동적 디스패치에는 사용 가능한 위치에 대한 몇 가지 규칙, 즉 dyn compatibility 규칙도 있습니다. 그 규칙은 여기서 다루지는 않지만 reference 문서 에서 더 읽어볼 수 있습니다. 대신 이 비용을 치르는 대가로, 우리는 목록 18-5에서 작성한 코드와 목록 18-9에서 보여 준 사용 방식을 통해 훨씬 더 큰 유연성을 얻게 되었습니다. 이것이 바로 고려해야 할 트레이드오프입니다.

객체 지향 디자인 패턴 구현하기

객체 지향 디자인 패턴 구현하기

상태 패턴(state pattern) 은 객체 지향 디자인 패턴 중 하나입니다. 이 패턴의 핵심은, 어떤 값이 내부적으로 가질 수 있는 여러 상태를 집합으로 정의하는 것입니다. 그 상태들은 상태 객체(state objects) 집합으로 표현되고, 값의 동작은 현재 상태에 따라 바뀝니다. 우리는 블로그 게시물 구조체를 예로 이 패턴을 따라가 보겠습니다. 게시물 구조체는 자기 상태를 담는 필드를 하나 가지는데, 그 값은 “초안(draft)”, “검토 중(review)”, “게시됨(published)” 중 하나에 해당하는 상태 객체가 됩니다.

이 상태 객체들은 공통 기능을 공유합니다. 러스트에서는 당연히 객체와 상속 대신 구조체와 트레이트를 사용합니다. 각 상태 객체는 자기 행동을 스스로 책임지고, 언제 다른 상태로 바뀌어야 하는지도 스스로 결정합니다. 반면 상태 객체를 담고 있는 값은, 각 상태의 구체적 동작이나 전이 시점에 대해 아무 것도 알지 못합니다.

이 패턴의 장점은 프로그램의 비즈니스 요구사항이 바뀌더라도, 상태를 담는 값의 코드나 그 값을 사용하는 코드를 거의 바꿀 필요가 없다는 점입니다. 특정 상태 객체 안의 규칙만 수정하거나, 혹은 상태 객체를 더 추가하는 정도면 충분합니다.

먼저는 더 전통적인 객체 지향 방식으로 상태 패턴을 구현해 보겠습니다. 그런 다음, 러스트에는 조금 더 자연스러운 접근법도 살펴볼 것입니다. 이제 상태 패턴을 사용해 블로그 게시글 워크플로를 차근차근 구현해 봅시다.

최종적으로 원하는 동작은 다음과 같습니다.

  1. 블로그 게시글은 빈 초안 상태로 시작한다.
  2. 초안이 끝나면 검토를 요청한다.
  3. 게시글이 승인되면 게시된다.
  4. 게시된 글만 content 호출 시 실제 내용을 반환한다. 승인되지 않은 글은 우발적으로 게시되면 안 된다.

그 외의 모든 상태 변경 시도는 아무 효과도 없어야 합니다. 예를 들어 아직 검토 요청도 하지 않은 초안 글을 승인하려고 해도, 그것은 여전히 게시되지 않은 초안으로 남아야 합니다.

전통적인 객체 지향 스타일 시도하기

같은 문제를 해결하는 방법은 사실 무한히 많고, 각각의 방법은 서로 다른 트레이드오프를 가집니다. 이 절의 구현은 좀 더 전통적인 객체 지향 스타일에 가깝습니다. 러스트에서도 충분히 쓸 수 있는 방식이지만, 러스트의 강점을 최대한 활용하는 방식은 아닙니다. 나중에는 여전히 객체 지향 디자인 패턴을 사용하면서도, 객체 지향 경험이 있는 사람에게는 조금 덜 익숙해 보일 수 있는 다른 해결책도 보여 드리겠습니다. 그 두 접근을 비교하면서, 다른 언어와는 다르게 러스트 코드를 설계할 때 생기는 트레이드오프를 느껴 볼 수 있을 것입니다.

목록 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 정의하고 초안 상태로 새 인스턴스 만들기

라이브러리 구현을 시작해 봅시다. 우리는 내용물을 담는 public 한 Post 구조체가 필요하다는 것을 알고 있으므로, 우선 구조체 정의와 새 Post 인스턴스를 만드는 public 연관 함수 new 를 작성하겠습니다. 목록 18-12처럼, 각 상태 객체가 반드시 가져야 할 동작을 정의하는 private State 트레이트도 함께 만듭니다.

그다음 Post 는 상태 객체를 담기 위해 state 라는 private 필드 안에 Option<Box<dyn State>> 형태의 트레이트 객체를 보관할 것입니다. 왜 굳이 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 구조체와, 새 Post 인스턴스를 만드는 new 함수, State 트레이트, Draft 구조체 정의

State 트레이트는 게시글 상태들이 공유하는 동작을 정의합니다. 상태 객체는 Draft, PendingReview, Published 이고, 모두 State 트레이트를 구현합니다. 일단은 게시글이 처음 시작하는 상태가 초안이므로, Draft 상태만 정의해 둡니다.

Post 를 만들 때는 state 필드를 Some 으로 두고, 그 안에 Draft 구조체의 새 인스턴스를 가리키는 Box 를 넣습니다. 이렇게 하면 새로 만든 Post 는 언제나 초안 상태에서 시작합니다. 그리고 Poststate 필드는 private 이므로, 외부에서 다른 상태의 Post 를 직접 만드는 방법은 없습니다!

게시글 내용 텍스트 저장하기

목록 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 를 기존 내용 뒤에 덧붙입니다. 이 동작은 게시글 상태와는 상관이 없기 때문에 상태 패턴의 일부는 아닙니다. add_text 메서드는 state 필드와 아예 상호작용하지 않지만, 우리가 지원하고 싶은 게시글 동작의 일부이긴 합니다.

초안 게시글의 내용은 비어 있어야 한다는 점 보장하기

add_text 를 호출해 게시글에 실제 내용을 넣은 뒤에도, 초안 상태에서는 content 메서드가 빈 문자열 슬라이스를 반환하길 원합니다. 목록 18-11의 첫 번째 assert_eq! 가 바로 그 사실을 보여 주었죠. 우선 가장 단순한 방식으로 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: 항상 빈 문자열 슬라이스를 반환하는 content 메서드의 임시 구현 추가하기

content 메서드를 추가하면, 목록 18-11의 처음부터 첫 번째 assert_eq! 까지의 코드는 의도대로 동작합니다.

검토 요청하기: 게시글 상태 바꾸기

이제 게시글에 대해 검토를 요청하는 기능을 추가해야 합니다. 이 동작은 상태를 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 라는 public 메서드를 추가하고, self 에 대한 가변 참조를 받게 합니다. 그다음 이 메서드는 현재 Post 상태 값에 대해 내부 request_review 메서드를 호출합니다. 이 두 번째 request_review 는 현재 상태를 소비하고, 새 상태를 반환합니다.

또한 State 트레이트에도 request_review 메서드를 추가합니다. 이제 이 트레이트를 구현하는 모든 타입은 request_review 를 구현해야 합니다. 주의할 점은, 이 메서드의 첫 번째 매개변수가 self, &self, &mut self 가 아니라 self: Box<Self> 라는 점입니다. 이 문법은 “이 메서드는 이 타입을 담은 Box 위에서만 호출할 수 있다”는 뜻입니다. 즉, Box<Self> 의 소유권을 가져와 기존 상태를 무효화하고, 그 게시글의 상태 값이 새로운 상태로 변형 되게 하겠다는 뜻입니다.

이전 상태를 소비하려면 request_review 메서드가 상태 값의 소유권을 가져와야 합니다. 여기서 바로 Poststate 필드가 Option<T> 인 이유가 드러납니다. 우리는 take 메서드를 호출해 state 필드 안의 Some 값을 꺼내고, 그 자리에 None 을 남겨 둡니다. 러스트는 구조체 안에 “비어 있는 필드”를 허용하지 않기 때문에, 이렇게 잠시 None 으로 만들어 두는 것입니다. 이렇게 하면 statePost 바깥으로 이동시킬 수 있고, 단순히 빌리는 것이 아니라 실제로 소유권을 가져올 수 있습니다. 그런 다음, 그 연산 결과로 나온 새 상태를 다시 state 필드에 집어넣습니다.

우리가 self.state = self.state.request_review(); 처럼 바로 쓰지 않고 take 를 사용하는 이유도 여기에 있습니다. 새 상태로 변환한 뒤에는 Post 가 더 이상 이전 상태값을 사용할 수 없어야 하기 때문입니다.

Draft 에 구현한 request_review 메서드는 새 PendingReview 구조체 인스턴스를 박스에 담아 반환합니다. 이것이 게시글이 검토 대기 상태일 때의 상태 객체입니다. PendingReview 구조체도 request_review 메서드를 구현하지만, 이 경우에는 아무 전환도 일어나지 않습니다. 이미 PendingReview 상태인 게시글에 다시 검토 요청을 해도 계속 PendingReview 로 남아야 하므로, 단순히 자기 자신을 반환합니다.

이제 상태 패턴의 장점이 조금씩 보이기 시작합니다. Postrequest_review 메서드는 현재 state 값이 무엇인지와 상관없이 동일합니다. 각 상태가 자기 규칙을 스스로 책임지기 때문입니다.

우리는 지금 Postcontent 메서드를 여전히 그대로 두고, 빈 문자열 슬라이스를 반환하게 둘 것입니다. 이제 게시글은 Draft 뿐 아니라 PendingReview 상태도 가질 수 있지만, PendingReview 상태에서도 여전히 같은 동작, 즉 빈 문자열을 반환하길 원합니다. 따라서 목록 18-11은 두 번째 assert_eq! 까지도 이제 동작하게 됩니다!

approve 추가해서 content 동작 바꾸기

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 도 추가했습니다.

PendingReviewrequest_review 가 그랬던 것처럼, 초안 상태에서 approve 를 호출해도 아무 효과가 없습니다. approve 는 단순히 self 를 반환하기 때문입니다. 반면 PendingReview 상태에서 approve 를 호출하면, 새 Published 구조체 인스턴스를 박스로 감싸 반환합니다. 그리고 Published 구조체는 State 트레이트를 구현하며, request_reviewapprove 둘 모두에 대해 자기 자신을 반환합니다. 이미 게시된 글은 그 상태 그대로 남아야 하기 때문입니다.

이제 Postcontent 메서드도 갱신해야 합니다. 우리는 content 의 반환값이 현재 Post 의 상태에 따라 달라지길 원하므로, Post 가 목록 18-17처럼 자신의 상태 객체 위에 정의된 content 메서드에 일을 위임하도록 만들겠습니다.

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 메서드를 호출하면서 게시글 인스턴스 자체(self)도 인수로 넘깁니다. 그리고 상태 객체가 반환한 값을 다시 최종 content 결과로 반환합니다.

여기서 Option 에 대해 as_ref 메서드를 호출하는 이유는, 그 안의 값을 소유권과 함께 꺼내는 것이 아니라 그 값에 대한 참조를 원하기 때문입니다. state 의 타입은 Option<Box<dyn State>> 이므로, as_ref 를 호출하면 Option<&Box<dyn State>> 를 얻게 됩니다. 만약 as_ref 를 호출하지 않았다면, 함수 매개변수의 &self 에서 빌린 값을 밖으로 이동 시키려 하기 때문에 오류가 났을 것입니다.

이후에는 unwrap 을 호출하는데, 이 호출은 패닉하지 않을 것임을 우리가 알고 있습니다. Post 의 메서드들이 끝날 때마다 state 에는 항상 Some 값이 들어 있도록 보장하기 때문입니다. 이것은 9장의 [“여러분이 컴파일러보다 더 많은 정보를 알고 있을 때”] more-info-than-rustc 절에서 다뤘던 경우와 같습니다. 컴파일러는 몰라도, 사람인 우리는 여기서 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 값을 반환하면 됩니다. 다만 이렇게 StatePost 의 내용을 결정하게 만들면, State 의 책임과 Post 의 책임 경계가 조금 흐려진다는 점은 기억할 만합니다.

이 메서드에는 10장에서 다뤘던 라이프타임 주석도 필요합니다. post 에 대한 참조를 인수로 받아 그 post 의 일부에 대한 참조를 반환하므로, 반환 참조의 라이프타임이 post 인수의 라이프타임과 연결되어야 하기 때문입니다.

이로써 목록 18-11의 모든 코드가 동작합니다! 우리는 상태 패턴을 사용해 블로그 게시글 워크플로 규칙을 구현했습니다. 규칙과 관련된 로직은 Post 여러 곳에 흩어져 있는 대신, 각 상태 객체 안에 모여 있습니다.

왜 enum을 쓰지 않았을까?

이쯤에서 “그냥 가능한 게시 상태를 enum variant로 두면 안 되나?”라고 생각했을지도 모릅니다. 물론 그것도 가능한 해결책입니다. 직접 구현해 보고, 최종 결과가 현재 접근과 비교해 어떤지 살펴보세요! enum을 쓰는 방식의 한 가지 단점은, enum 값을 검사하는 모든 위치에서 가능한 variant들을 처리하기 위한 match 나 비슷한 코드가 필요해질 수 있다는 점입니다. 이런 반복은 현재의 트레이트 객체 기반 해결책보다 더 장황해질 수 있습니다.

상태 패턴 평가하기

우리는 지금까지 러스트가 객체 지향 상태 패턴을 구현할 수 있다는 것을 보여 주었습니다. 이 방식은 게시글이 각 상태에서 어떻게 행동해야 하는지를 상태 객체 안에 캡슐화합니다. Post 의 메서드는 각 상태의 구체 동작을 알 필요가 없습니다. 코드가 이렇게 조직되어 있기 때문에, “게시된 글이 어떤 식으로 동작할 수 있는가”를 이해하려면 Published 구조체에 구현된 State 트레이트 코드만 보면 됩니다.

만약 상태 패턴을 사용하지 않는 다른 구현을 선택했다면, 대신 Post 메서드 안이나 심지어 main 안에서 게시글 상태를 검사하는 match 식을 여러 곳에 써서, 그 자리에서 행동을 바꾸었을 수도 있습니다. 그렇게 되면 게시 상태의 의미를 이해하려고 코드 여러 곳을 다 확인해야 했을 것입니다.

상태 패턴에서는 Post 메서드와 Post 를 사용하는 코드에 match 식이 필요 없고, 새 상태를 추가할 때도 그 구조체 하나와 그 트레이트 구현만 추가하면 됩니다.

이 구현은 확장하기도 쉽습니다. 상태 패턴을 사용한 코드 유지보수가 얼마나 단순한지 체감해 보려면, 다음과 같은 변경을 직접 시도해 보세요.

  • 게시글 상태를 PendingReview 에서 Draft 로 되돌리는 reject 메서드 추가하기
  • Published 로 가기 전에 approve 를 두 번 받아야만 하도록 바꾸기
  • 게시글이 Draft 상태일 때만 텍스트를 추가할 수 있도록 하기 힌트: 무엇이 content 에 대해 바뀔 수 있는지는 상태 객체가 결정하게 하되, 실제로 Post 를 수정하는 책임은 상태 객체가 아닌 Post 가 갖게 해 보세요.

상태 패턴의 한 가지 단점은, 상태 전이를 각 상태가 직접 구현하기 때문에 상태들끼리 서로 결합된다는 점입니다. 예를 들어 PendingReviewPublished 사이에 Scheduled 같은 새 상태를 넣고 싶다면, PendingReview 의 코드를 바꿔야 Scheduled 로 전이할 수 있습니다. 새로운 상태가 추가될 때 PendingReview 가 전혀 바뀌지 않아도 되면 더 좋겠지만, 그러려면 또 다른 디자인 패턴으로 바꿔야 할 것입니다.

또 다른 단점은 일부 로직이 중복된다는 것입니다. 예를 들어 Postrequest_reviewapprove 구현은 꽤 비슷합니다. 둘 다 Poststate 필드에 대해 Option::take 를 사용하고, 만약 stateSome 이면 감싸고 있던 값의 같은 이름 메서드 구현에 위임한 뒤, 그 결과를 다시 state 필드에 넣습니다. 만약 Post 에서 이런 패턴을 따르는 메서드가 많아진다면, 이런 반복을 줄이기 위해 매크로를 고려할 수도 있을 것입니다(20장의 “매크로” 절 참고).

하지만 여기까지의 상태 패턴 구현은, 객체 지향 언어에서 정의된 방식을 거의 그대로 옮긴 것이기 때문에 러스트의 강점을 충분히 살리고 있다고는 보기 어렵습니다. 이제 blog 크레이트를 조금 다른 방식으로 바꾸어, 일부 잘못된 상태나 전이를 컴파일 타임 에러로 만들 수 있는 방법을 보겠습니다.

상태와 동작을 타입으로 인코딩하기

이제 상태 패턴을 다시 생각해 보면서, 조금 다른 트레이드오프를 가지는 구현으로 바꿔 보겠습니다. 상태와 전이를 완전히 캡슐화해 외부 코드가 전혀 모르도록 만드는 대신, 각 상태를 서로 다른 타입으로 인코딩 하겠습니다. 그러면 러스트의 타입 검사는, 예를 들어 게시된 글만 허용되는 곳에 초안 글을 사용하려 하면 컴파일러 오류를 내며 그것을 막아 줍니다.

목록 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 메서드 자체를 없앨 것입니다. 그러면 초안의 내용을 읽으려 하면 “그런 메서드는 없다”는 컴파일 오류를 얻게 됩니다. 결과적으로 미게시 글의 내용을 실수로 운영 환경에 출력하는 코드는 컴파일조차 되지 않기 때문에 원천적으로 막을 수 있습니다. 목록 18-19는 content 메서드를 가진 Post 구조체와, content 메서드가 없는 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 메서드가 있는 Post 와, 없는 DraftPost

PostDraftPost 둘 다 블로그 글 텍스트를 저장하는 private content 필드를 가집니다. 상태 인코딩을 타입으로 옮겼기 때문에, 더 이상 구조체 안에 state 필드는 없습니다. Post 구조체는 “게시된 글”을 의미하고, 이 타입에만 content 메서드가 있어 그 content 를 반환합니다.

여전히 Post::new 함수는 있지만, 이제는 Post 인스턴스가 아니라 DraftPost 인스턴스를 반환합니다. content 가 private 이고 Post 를 직접 반환하는 함수도 없기 때문에, 지금 이 시점에서 Post 인스턴스를 직접 만드는 것은 불가능합니다.

DraftPostadd_text 메서드를 가지므로, 이전처럼 글 내용을 추가할 수 있습니다. 하지만 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: DraftPostrequest_review 로 생기는 PendingReviewPost 와, 그것을 게시된 Post 로 바꾸는 approve 메서드

request_reviewapprove 는 둘 다 self 의 소유권을 가져갑니다. 즉, DraftPostPendingReviewPost 인스턴스를 소비하고 각각 PendingReviewPost 와 게시된 Post 로 변환합니다. 이 방식 덕분에, 예를 들어 DraftPost 에 대해 request_review 를 호출한 뒤에도 옛 DraftPost 인스턴스가 계속 남아 있지 않습니다. PendingReviewPost 구조체에는 여전히 content 메서드가 없으므로, 그 상태에서 내용을 읽으려 하면 DraftPost 때와 마찬가지로 컴파일 에러가 납니다. content 메서드를 가진 게시된 Post 를 얻는 유일한 방법은 PendingReviewPost 에 대해 approve 를 호출하는 것입니다. 그리고 PendingReviewPost 를 얻는 유일한 방법은 DraftPost 에 대해 request_review 를 호출하는 것이므로, 이제 블로그 게시글 워크플로가 타입 시스템 안에 그대로 인코딩되었습니다.

하지만 이 구조에 맞추려면 main 코드도 조금 바꿔야 합니다. request_reviewapprove 는 원래 값을 수정하는 대신 새 인스턴스 를 반환하므로, 반환된 인스턴스를 저장하기 위한 let post = 형태의 섀도잉 대입을 몇 번 더 써야 합니다. 또한 초안과 검토 중 게시글의 내용을 “빈 문자열과 같다”고 단언하던 코드는 더 이상 둘 필요도 없고 쓸 수도 없습니다. 그런 상태의 게시글에서 content 를 사용하려는 코드는 아예 컴파일되지 않기 때문입니다. 수정된 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 를 계속 다시 대입해야 하도록 바뀌었다는 사실은, 이 구현이 더 이상 전통적인 객체 지향 상태 패턴과는 완전히 같지 않다는 뜻입니다. 상태 전이가 Post 구현 안에 완전히 숨겨져 있지는 않기 때문입니다. 하지만 그 대신 얻는 것은 큽니다. 타입 시스템과 컴파일 시 타입 검사 덕분에 “나올 수 없는 상태” 자체가 불가능해진다는 점입니다! 예를 들어 미게시 글 내용을 실수로 출력하는 버그는 운영 환경에 가기 전에 컴파일 단계에서 바로 걸립니다.

이 절 시작에서 제안했던 작업들을, 목록 18-21 이후 상태의 blog 크레이트에 대해 직접 시도해 보고 이 설계가 어떻게 느껴지는지 생각해 보세요. 주어진 작업 일부는 이 새 설계에서는 이미 해결된 상태일 수도 있습니다.

지금까지 본 것처럼, 러스트는 객체 지향 디자인 패턴을 구현할 수 있습니다. 하지만 상태를 타입 시스템 안에 인코딩하는 것처럼, 다른 패턴도 충분히 사용 가능합니다. 각 접근은 서로 다른 트레이드오프를 가집니다. 여러분이 객체 지향 패턴에 아주 익숙하다 하더라도, 러스트의 특성을 적극적으로 활용하도록 문제를 다시 생각해 보는 것이 컴파일 시점에 버그를 막는 등 추가 이점을 줄 수 있습니다. 러스트에는 소유권 같은, 일반적인 객체 지향 언어에는 없는 기능이 있기 때문에, 객체 지향 패턴이 언제나 최선의 해법은 아닙니다. 하지만 분명 사용할 수 있는 선택지 중 하나이긴 합니다.

정리

이 장을 읽고도 러스트가 객체 지향 언어인지 확신이 서지 않을 수는 있지만, 적어도 이제 러스트에서 일부 객체 지향 기능을 얻기 위해 트레이트 객체를 사용할 수 있다는 사실은 알게 되었습니다. 동적 디스패치는 약간의 런타임 성능 비용을 치르는 대신 코드에 유연성을 줍니다. 그리고 그 유연성을 사용해 유지보수에 도움이 되는 객체 지향 패턴도 구현할 수 있습니다. 동시에, 러스트는 객체 지향 언어에는 없는 소유권 같은 기능도 제공합니다. 객체 지향 패턴이 러스트의 강점을 활용하는 최선의 방식이 아닐 때도 많지만, 사용할 수 있는 도구임은 분명합니다.

다음 장에서는 또 하나의 강력한 러스트 기능인 패턴을 살펴보겠습니다. 책 전반에서 짧게 계속 사용해 왔지만, 아직 그 전체 능력을 제대로 본 적은 없습니다. 이제 그것을 본격적으로 파헤쳐 봅시다!

패턴과 매칭

패턴은 러스트에서 타입의 구조에 맞춰 값을 검사하는 특별한 문법입니다. 단순한 타입과 복잡한 타입 모두에 사용할 수 있습니다. 패턴을 match 식과 다른 구문들에 함께 사용하면, 프로그램의 제어 흐름을 훨씬 더 정교하게 다룰 수 있습니다. 패턴은 대체로 다음 요소들의 조합으로 이루어집니다.

  • 리터럴
  • 구조분해된 배열, enum, 구조체, 튜플
  • 변수
  • 와일드카드
  • 자리표시자

패턴의 예로는 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장의 목록 6-5에서 변수 xOption<i32> 값에 대해 매칭하던 match 식입니다.

match x {
    None => None,
    Some(i) => Some(i + 1),
}

match 식에서 패턴은 각 화살표 왼쪽의 NoneSome(i) 입니다.

match 식에는 한 가지 요구사항이 있습니다. 반드시 완전해야 한다는 점입니다. 즉 match 안에서 다루는 값이 가질 수 있는 모든 가능성이 반드시 어떤 arm 에서든 처리되어야 합니다. 이를 만족하는 한 가지 방법은 마지막 arm에 “모든 것을 받는” catch-all 패턴을 두는 것입니다. 예를 들어 어떤 값과도 매칭될 수 있는 변수 이름은 실패할 수 없기 때문에 나머지 모든 경우를 덮을 수 있습니다.

특히 _ 패턴은 무엇이든 매칭되지만, 그 값을 변수에 바인딩하지는 않습니다. 따라서 어떤 값을 무시하고 싶을 때 마지막 match arm에서 자주 사용합니다. 이 _ 패턴은 뒤의 [“패턴에서 값 무시하기”][ignoring-values-in-a-pattern] 절에서 더 자세히 다룹니다.

let

이 장 전까지 우리는 패턴을 matchif let 에서만 명시적으로 이야기했지만, 사실은 let 문에서도 계속 패턴을 써 왔습니다. 예를 들어 다음처럼 단순한 변수 대입을 보세요.

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

이런 let 문을 쓸 때마다 사실 패턴을 사용하고 있었던 것입니다! 형식적으로 let 문은 다음과 같은 모양입니다.

let PATTERN = EXPRESSION;

let x = 5; 같은 문장에서 PATTERN 자리에 오는 변수 이름은, 사실 매우 단순한 형태의 패턴입니다. 러스트는 오른쪽 식을 왼쪽 패턴과 비교하고, 패턴 안에서 발견한 이름에 값을 바인딩합니다. 따라서 let x = 5; 에서 x 는 “여기에 맞는 값을 변수 x 에 묶어라”는 뜻의 패턴입니다. x 가 패턴 전체이므로, 사실상 “무슨 값이든 전부 x 에 바인딩하라”와 같습니다.

패턴 매칭이라는 측면을 좀 더 분명히 보기 위해, 목록 19-1처럼 let 안 패턴으로 튜플을 구조분해해 보겠습니다.

fn main() {
    let (x, y, z) = (1, 2, 3);
}
Listing 19-1: 패턴으로 튜플을 구조분해하여 한 번에 세 변수를 만들기

여기서 러스트는 값 (1, 2, 3) 을 패턴 (x, y, z) 와 비교하고, 양쪽 요소 개수가 같다는 것을 확인한 뒤 1x, 2y, 3z 에 바인딩합니다. 이 튜플 패턴은, 그 안에 세 개의 개별 변수 패턴이 중첩되어 있는 것으로 생각해도 됩니다.

패턴 안 요소 수가 튜플 안 요소 수와 맞지 않으면, 전체 타입이 맞지 않으므로 컴파일 오류가 납니다. 예를 들어 목록 19-2는 요소가 세 개인 튜플을 두 변수에만 구조분해하려는 잘못된 시도입니다.

fn main() {
    let (x, y) = (1, 2, 3);
}
Listing 19-2: 튜플 요소 개수와 맞지 않는 변수 개수를 가진 패턴을 잘못 작성하기

이 코드를 컴파일하려 하면 다음과 같은 타입 오류가 납니다.

$ 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

이 오류를 고치려면 _.. 를 사용해 일부 값을 무시하면 됩니다. 이것은 뒤의 [“패턴에서 값 무시하기”][ignoring-values-in-a-pattern] 절에서 보게 될 것입니다. 반대로 패턴에 변수가 너무 많아서 문제라면, 변수 개수를 줄여 양쪽 타입을 맞추면 됩니다.

조건부 if let

6장에서 if let 을 주로 “한 경우만 매칭하는 match 의 짧은 형태”로 설명했습니다. 선택적으로 if let 뒤에 else 를 붙여, 패턴이 맞지 않을 때 실행할 코드를 줄 수도 있습니다.

목록 19-3은 if let, else if, else if let, else 를 서로 섞어서 쓰는 것도 가능하다는 점을 보여 줍니다. 이렇게 하면 match 보다 더 유연하게 표현할 수 있습니다. match 에서는 하나의 값만 여러 패턴과 비교할 수 있지만, if let 계열은 서로 관련 없는 조건들을 함께 표현할 수 있기 때문입니다.

이 목록은 여러 조건을 검사해 배경색을 어떤 색으로 정할지 결정하는 코드입니다. 예제에서는 실제 프로그램이라면 사용자 입력 등으로 받을 법한 값을 단순하게 하드코딩해 넣었습니다.

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-3: if let, else if, else if let, else 를 섞어 쓰기

사용자가 선호 색상을 지정했다면 그 색을 배경색으로 사용합니다. 선호 색이 없고 오늘이 화요일이면 배경색은 초록색입니다. 그렇지 않고 사용자가 나이를 문자열로 주었으며 그 값을 숫자로 성공적으로 파싱할 수 있다면, 그 숫자에 따라 보라색이나 주황색을 사용합니다. 이 중 아무 조건도 맞지 않으면 배경색은 파란색입니다.

이런 조건 구조를 통해 꽤 복잡한 요구도 표현할 수 있습니다. 여기 넣어 둔 하드코딩된 값으로 이 예제를 실행하면 Using purple as the background color 를 출력합니다.

if let 역시 match arm과 마찬가지로 기존 변수를 가리는 새 변수를 도입할 수 있다는 점도 볼 수 있습니다. if let Ok(age) = age 라는 줄은, Ok 안 값으로 새 age 변수를 만들며 바깥의 age 변수를 가립니다. 따라서 if age > 30 조건은 반드시 그 블록 안에 있어야 합니다. if let Ok(age) = age && age > 30 처럼 하나로 합칠 수는 없습니다. 비교하고 싶은 새 age 는 중괄호 블록이 시작되어야 비로소 유효해지기 때문입니다.

if let 식의 단점은, match 식과 달리 컴파일러가 완전성을 검사해 주지 않는다는 점입니다. 만약 마지막 else 블록을 생략해 어떤 경우를 빼먹더라도, 컴파일러는 그 가능한 논리 버그를 알려 주지 않습니다.

while let 조건 루프

구조가 if let 과 비슷한 while let 조건 루프는, 어떤 패턴이 계속 매칭되는 동안 반복 실행되는 while 루프입니다. 목록 19-4는 스레드 사이로 보내진 메시지를 기다리는 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-4: rx.recv() 가 계속 Ok 를 반환하는 동안 값을 출력하는 while let 루프 사용하기

이 예제는 1, 2, 3 을 출력합니다. recv 메서드는 채널 수신 쪽에서 첫 번째 메시지를 꺼내고 Ok(value) 를 반환합니다. 16장에서 recv 를 처음 다룰 때는 오류를 그냥 unwrap 하거나, 혹은 for 루프로 반복자처럼 다뤘습니다. 하지만 목록 19-4처럼 while let 로 처리할 수도 있습니다. 송신자가 살아 있는 동안은 recv 가 메시지가 도착할 때마다 Ok 를 반환하고, 송신 쪽이 끊어지면 Err 를 반환하기 때문입니다.

for 루프

for 루프에서는 for 키워드 바로 뒤에 오는 값이 패턴입니다. 예를 들어 for x in y 에서 x 가 패턴입니다. 목록 19-5는 for 루프 안의 패턴이 튜플을 구조분해하는 데도 사용될 수 있다는 점을 보여 줍니다.

fn main() {
    let v = vec!['a', 'b', 'c'];

    for (index, value) in v.iter().enumerate() {
        println!("{value} is at index {index}");
    }
}
Listing 19-5: for 루프 안 패턴으로 튜플 구조분해하기

이 코드는 다음을 출력합니다.

$ 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' 가 되어 출력의 첫 줄이 만들어집니다.

함수 매개변수

함수 매개변수도 패턴이 될 수 있습니다. 목록 19-6의 foo 함수는 i32 타입 매개변수 하나를 받는데, 이제는 이 코드가 꽤 익숙하게 보여야 합니다.

fn foo(x: i32) {
    // code goes here
}

fn main() {}
Listing 19-6: 매개변수 위치에 패턴을 사용하는 함수 시그니처

여기서 x 부분이 바로 패턴입니다! let 때와 마찬가지로, 함수 인수 위치에서도 튜플을 패턴으로 매칭할 수 있습니다. 목록 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 가 됩니다.

클로저도 함수와 비슷하기 때문에, 함수 매개변수 목록에서 하듯 클로저 매개변수 목록에도 같은 방식으로 패턴을 사용할 수 있습니다.

반박 가능성: 패턴이 매칭에 실패할 수 있는가

반박 가능성: 패턴이 매칭에 실패할 수 있는가

패턴은 두 종류로 나뉩니다. 반박 가능한 패턴과 반박 불가능한 패턴입니다. 어떤 값이 들어오든 항상 매칭되는 패턴을 반박 불가능한(irrefutable) 패턴이라고 합니다. 예를 들어 let x = 5; 에서 x 는 어떤 값과도 항상 매칭되므로 실패할 수 없습니다. 반대로 어떤 값에 대해서는 매칭이 실패할 수 있는 패턴은 반박 가능한(refutable) 패턴입니다. 예를 들어 if let Some(x) = a_value 에서 Some(x) 는 반박 가능한 패턴입니다. a_valueSome 이 아니라 None 이면 이 패턴은 매칭되지 않기 때문입니다.

함수 매개변수, let 문, for 루프는 모두 반박 불가능한 패턴만 받을 수 있습니다. 값이 패턴과 맞지 않았을 때 프로그램이 할 수 있는 의미 있는 일이 없기 때문입니다. 반면 if let, while let, let...else 는 반박 가능한 패턴과 반박 불가능한 패턴 둘 다 받을 수 있습니다. 다만 컴파일러는 반박 불가능한 패턴에 대해서는 경고를 냅니다. 이런 구문은 원래 “실패할 수도 있음” 을 처리하려는 것이 목적이기 때문입니다. 조건 구문의 핵심은 성공과 실패에 따라 다르게 행동할 수 있다는 점이니까요.

일반적으로는 반박 가능성과 반박 불가능성의 차이를 너무 자주 의식할 필요는 없습니다. 하지만 오류 메시지에서 이 개념이 등장했을 때 대응할 수 있으려면, 적어도 그 뜻 정도는 알고 있어야 합니다. 그런 경우에는 코드가 의도한 동작에 따라, 패턴을 바꾸거나 패턴을 쓰는 구문 자체를 바꿔야 합니다.

이제 러스트가 반박 불가능한 패턴만 요구하는 위치에 반박 가능한 패턴을 쓰거나, 그 반대의 경우에 어떤 일이 일어나는지 예를 들어 봅시다. 목록 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_valueSome 이 아니라 None 이라면, Some(x) 패턴은 매칭되지 못합니다. 즉 이 패턴은 반박 가능합니다. 하지만 let 문은 반박 불가능한 패턴만 허용합니다. None 이 들어왔을 때 그 상황을 처리할 방법이 없기 때문입니다. 그래서 러스트는 “반박 불가능한 패턴이 필요한 위치에 반박 가능한 패턴을 썼다”고 컴파일 시점에 바로 알려 줍니다.

$ 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) 패턴으로 모든 유효한 값을 포괄하지 못했기 때문에, 러스트는 정당하게 컴파일 오류를 냅니다.

반박 불가능한 패턴이 필요한 곳에 반박 가능한 패턴이 있다면, 보통 그 패턴을 사용하는 코드를 바꿔서 해결할 수 있습니다. 예를 들어 let 대신 let...else 를 사용할 수 있습니다. 그러면 패턴이 맞지 않을 때는 else 블록 안 코드가 그 값을 처리하게 됩니다. 목록 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 와 반박 가능한 패턴용 블록 사용하기

이제 우리는 코드에 “빠져나갈 길”을 제공한 셈입니다! 이 코드는 완전히 유효합니다. 다만 let...else 에 반박 불가능한 패턴을 주면 경고가 난다는 점은 여전히 남아 있습니다. 예를 들어 목록 19-10처럼 언제나 매칭되는 x 패턴을 넣으면 컴파일러가 경고합니다.

fn main() {
    let x = 5 else {
        return;
    };
}
Listing 19-10: let...else 에 반박 불가능한 패턴을 사용하려 시도하기

러스트는 반박 불가능한 패턴과 함께 let...else 를 쓰는 것이 의미 없다고 알려 줍니다.

$ 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은 반박 가능한 패턴을 써야 하고, 오직 마지막 arm만 반박 불가능한 패턴으로 남은 모든 값을 받는 식으로 써야 합니다. 러스트는 arm이 하나뿐인 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에서는 값이 Some(5)x 와, 값이 10y 를 선언한 뒤, x 에 대해 match 를 실행합니다. 실행하기 전에 각 arm의 패턴과 마지막 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 를 가리는 새 변수 하나를 도입하는 arm을 가진 match

match 식이 실행될 때 어떤 일이 일어나는지 하나씩 봅시다. 첫 번째 arm의 패턴은 x 의 실제 값과 맞지 않으므로, 코드는 다음 arm으로 넘어갑니다.

두 번째 arm의 패턴은 Some 안 어떤 값이든 받아들이는 새 변수 y 를 도입합니다. 이 변수는 match 식 안의 새로운 스코프에 있기 때문에, 처음에 값 10 으로 선언했던 바깥의 y 와는 다른 변수입니다. 이 새 y 바인딩은 Some 안의 어떤 값과도 매칭되며, 우리가 가진 x 값이 바로 그렇습니다. 따라서 이 새 yxSome 의 내부 값인 5 에 바인딩됩니다. 그래서 이 arm의 식이 실행되어 Matched, y = 5 를 출력합니다.

만약 xSome(5) 가 아니라 None 이었다면, 앞의 두 arm 모두 매칭되지 않고 마지막 _ arm으로 갔을 것입니다. _ arm 안의 표현에서 x 를 사용할 때는, 그 패턴에서 새 변수를 만들지 않았으므로 여전히 바깥쪽 x 를 의미합니다. 이런 가상의 상황이라면 matchDefault case, x = None 을 출력했을 것입니다.

match 식이 끝나면, 그 스코프도 끝나고 안쪽 y 도 사라집니다. 마지막 println! 은 따라서 at the end: x = Some(5), y = 10 을 출력합니다.

바깥쪽 xy 값을 서로 비교하는 match 식을 만들고 싶다면, 기존 y 를 가리는 새 변수를 만들면 안 됩니다. 대신 매치 가드(match guard) 같은 조건을 써야 합니다. 이는 뒤의 “매치 가드로 조건 추가하기” 절에서 다룹니다.

여러 패턴 매칭하기

match 식에서는 | 문법을 사용해 여러 패턴을 한 arm 안에 쓸 수 있습니다. 이 | 는 패턴의 또는(or) 연산자입니다. 예를 들어 다음 코드에서는 x 값을 각 arm과 비교하는데, 첫 번째 arm은 | 를 사용해 값이 둘 중 어느 것이든 맞으면 해당 arm의 코드가 실행되도록 합니다.

fn main() {
    let x = 1;

    match x {
        1 | 2 => println!("one or two"),
        3 => println!("three"),
        _ => println!("anything"),
    }
}

이 코드는 one or two 를 출력합니다.

..= 로 값 범위 매칭하기

..= 문법은 값을 포함하는 범위와 매칭할 수 있게 해 줍니다. 다음 코드에서는 패턴이 주어진 범위 안의 어떤 값과 맞기만 하면, 그 arm이 실행됩니다.

fn main() {
    let x = 5;

    match x {
        1..=5 => println!("one through five"),
        _ => println!("something else"),
    }
}

x1, 2, 3, 4, 5 중 하나라면 첫 번째 arm이 매칭됩니다. 같은 의미를 | 로 쓰려면 1 | 2 | 3 | 4 | 5 처럼 적어야 하므로, 범위 문법이 훨씬 더 편리합니다. 특히 1부터 1000 사이 아무 수든 매칭하고 싶을 때처럼 범위가 클수록 더 그렇습니다.

컴파일러는 컴파일 시점에 이 범위가 비어 있지 않은지도 검사합니다. 다만 러스트가 “이 범위가 비었는지 아닌지”를 판별할 수 있는 타입은 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"),
    }
}

러스트는 'c' 가 첫 번째 패턴의 범위 안에 있다는 것을 알아낼 수 있고, early ASCII letter 를 출력합니다.

구조분해로 값 쪼개기

패턴은 구조체, enum, 튜플을 구조분해해서 그 안의 서로 다른 부분을 꺼내 쓸 때도 사용할 수 있습니다. 하나씩 살펴봅시다.

구조체

목록 19-12는 x, y 라는 두 필드를 가진 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: 구조체의 필드를 따로 변수로 구조분해하기

이 코드는 구조체 pxy 필드 값을 각각 a, b 변수에 바인딩합니다. 여기서 중요한 점은, 패턴 안의 변수 이름이 구조체 필드 이름과 꼭 같을 필요는 없다는 것입니다. 하지만 어떤 변수가 어떤 필드에서 왔는지 기억하기 쉽게 하기 위해, 실제로는 필드 이름과 같은 이름을 쓰는 경우가 더 흔합니다. 이 패턴이 흔하기 때문에, 그리고 let Point { x: x, y: y } = p; 라고 쓰면 반복이 너무 많기 때문에, 러스트는 구조체 필드를 매칭하는 패턴에 대한 축약 문법도 제공합니다. 구조체 필드 이름만 적으면, 그 패턴으로부터 만들어지는 변수도 같은 이름을 갖게 됩니다. 목록 19-13은 목록 19-12와 같은 동작을 하지만, a, b 대신 x, y 라는 변수 이름을 사용합니다.

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: 구조체 필드 이름 축약으로 구조분해하기

이 코드는 px, y 필드 값을 각각 같은 이름의 변수 x, y 에 바인딩합니다. 결과적으로 변수 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: 하나의 패턴 안에서 구조분해와 리터럴 매칭 함께 사용하기

첫 번째 arm은 y 필드 값이 리터럴 0 과 맞는 모든 점을 매칭합니다. 이 경우에도 x 값은 변수로 바인딩되므로, 그 arm의 코드 안에서 사용할 수 있습니다.

마찬가지로 두 번째 arm은 x 필드 값이 0 인 모든 점과 매칭되며, y 필드 값은 변수 y 로 바인딩합니다. 세 번째 arm은 아무 리터럴도 지정하지 않으므로 다른 모든 Point 와 매칭되고, x, y 둘 다 변수로 바인딩합니다.

이 예제에서 값 px0 이기 때문에 두 번째 arm과 매칭되므로, On the y axis at 7 을 출력합니다.

match 식은 가장 먼저 맞는 패턴을 찾으면 그 즉시 멈춘다는 점을 기억하세요. 따라서 Point { x: 0, y: 0 } 은 사실 x 축 위이기도 하고 y 축 위이기도 하지만, 이 코드는 오직 On the x axis at 0 만 출력할 것입니다.

enum

이 책 곳곳에서 enum을 구조분해했지만(예를 들어 6장의 목록 6-5), enum을 구조분해하는 패턴이 실제로는 enum 내부 데이터 정의 방식과 정확히 대응한다는 사실을 명시적으로는 아직 이야기하지 않았습니다. 예시로, 목록 6-2의 Message enum을 사용해, 각 내부 값을 구조분해하는 패턴을 가진 match 를 목록 19-15에 작성해 봅시다.

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: 서로 다른 종류의 값을 담는 enum variant를 구조분해하기

이 코드는 Change color to red 0, green 160, and blue 255 를 출력합니다. msg 값을 바꿔가며 다른 arm 코드가 어떻게 실행되는지도 직접 확인해 보세요.

Message::Quit 처럼 데이터를 전혀 가지지 않는 enum variant는 더 이상 구조분해할 것이 없습니다. 이런 경우 우리는 단지 리터럴 값 Message::Quit 과 매칭할 수 있을 뿐이며, 그 패턴 안에 바인딩할 변수는 없습니다.

Message::Move 처럼 구조체와 비슷한 enum variant라면, 구조체와 매칭할 때 썼던 것과 비슷한 패턴을 사용할 수 있습니다. variant 이름 뒤에 중괄호를 쓰고, 그 안에 필드 이름과 변수를 써서 값을 쪼개어 arm 안 코드에서 사용할 수 있습니다. 여기서는 목록 19-13과 같은 축약 형태를 사용했습니다.

Message::Write 처럼 요소 하나를 가진 튜플 형태 variant나, Message::ChangeColor 처럼 요소 셋을 가진 variant라면, 튜플 패턴과 비슷한 문법을 사용합니다. 이 경우 패턴 안 변수 수는 매칭하려는 variant 안 요소 수와 같아야 합니다.

중첩된 구조체와 enum

지금까지 예시는 구조체나 enum을 한 단계만 구조분해했지만, 패턴은 중첩된 항목에도 당연히 사용할 수 있습니다! 예를 들어 목록 19-16은 목록 19-15 코드를 리팩터링해, ChangeColor 메시지가 RGB와 HSV 두 가지 색 표현을 모두 지원하도록 바꾼 예입니다.

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: 중첩된 enum에 대해 매칭하기

match 식의 첫 번째 arm 패턴은 Color::Rgb variant를 담고 있는 Message::ChangeColor 를 매칭하고, 그 안쪽의 세 i32 값을 변수로 바인딩합니다. 두 번째 arm 역시 Message::ChangeColor 와 매칭하지만, 이번에는 내부 enum이 Color::Hsv 와 맞습니다. 이렇게 enum 두 개가 얽혀 있어도, 하나의 match 식 안에서 이런 복잡한 조건을 자연스럽게 표현할 수 있습니다.

구조체와 튜플 함께 구조분해하기

구조분해 패턴은 더 복잡하게 섞어 쓸 수도 있습니다. 다음 예제는 튜플 안에 구조체와 튜플이 다시 들어 있고, 그 안의 원시 값들까지 전부 꺼내는 복잡한 구조분해를 보여 줍니다.

fn main() {
    struct Point {
        x: i32,
        y: i32,
    }

    let ((feet, inches), Point { x, y }) = ((3, 10), Point { x: 3, y: -10 });
}

이 코드는 복잡한 타입을 구성 요소들로 분해해, 우리가 관심 있는 값만 따로 사용할 수 있게 해 줍니다.

이처럼 패턴을 사용한 구조분해는, 예를 들어 구조체 각 필드 값을 서로 독립적으로 사용하고 싶을 때 아주 편리합니다.

패턴에서 값 무시하기

패턴 안에서 값을 무시하고 싶을 때가 종종 있습니다. 예를 들어 match 마지막 arm처럼, 실제로는 아무 일도 하지 않지만 “남은 가능한 모든 경우를 다 처리했다”는 의미를 위해 필요한 경우가 그렇습니다. 패턴에서 값 전체나 값 일부를 무시하는 방법은 여러 가지입니다. 이미 보았던 _ 패턴을 쓰는 방법, 다른 패턴 안에서 _ 를 쓰는 방법, 밑줄로 시작하는 이름을 쓰는 방법, 그리고 .. 을 사용해 값의 나머지 부분을 무시하는 방법이 있습니다. 이제 각각을 왜, 어떻게 쓰는지 살펴보겠습니다.

_ 로 값 전체 무시하기

우리는 _ 를 어떤 값과도 매칭되지만 그 값을 변수에 바인딩하지 않는 와일드카드 패턴으로 이미 써 보았습니다. 이 패턴은 특히 match 식의 마지막 arm에서 자주 유용하지만, 함수 매개변수처럼 패턴을 쓸 수 있는 다른 곳에서도 쓸 수 있습니다. 목록 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 를 출력합니다.

대부분의 경우, 더 이상 쓰지 않는 함수 매개변수가 있다면 시그니처 자체에서 그 매개변수를 없애는 편이 낫습니다. 하지만 예를 들어 어떤 트레이트를 구현하는데, 요구되는 시그니처 때문에 매개변수는 있어야 하나 실제 메서드 본문에서는 필요 없는 경우가 있습니다. 이럴 때 _ 를 사용하면, 사용하지 않는 매개변수에 대해 컴파일러가 경고를 내지 않게 할 수 있습니다.

중첩된 _ 로 값 일부 무시하기

어떤 값의 일부만 검사하고 나머지는 코드에서 쓰지 않을 때는, 다른 패턴 안에 _ 를 넣어 그 부분만 무시할 수도 있습니다. 목록 19-18은 설정값을 관리하는 코드입니다. 비즈니스 요구사항은 이렇습니다. 사용자가 이미 커스터마이즈한 설정을 덮어쓰면 안 되지만, 현재 설정이 비어 있다면 새 값을 넣을 수는 있어야 합니다.

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 variant 안의 실제 값은 쓰지 않을 때, 패턴 안에서 _ 를 사용하는 예

이 코드는 Can't overwrite an existing customized value 를 출력한 뒤, setting is Some(5) 를 출력합니다. 첫 번째 match arm에서는 Some 안의 값들에 대해 매칭하거나 직접 사용할 필요는 없지만, setting_valuenew_setting_value 둘 다가 Some variant인 경우를 검사할 필요는 있습니다. 그 경우에는 setting_value 를 바꾸지 말아야 하는 이유를 출력하고, 실제 값은 그대로 둡니다.

나머지 모든 경우, 즉 setting_value 또는 new_setting_value 둘 중 하나가 None 인 경우는 두 번째 arm의 _ 패턴이 처리합니다. 이 arm에서는 new_setting_valuesetting_value 가 되도록 허용합니다.

하나의 패턴 안에서도 _ 를 여러 번 사용해 특정 값 여러 개를 동시에 무시할 수 있습니다. 목록 19-19는 다섯 요소짜리 튜플에서 두 번째와 네 번째 값만 무시하는 예입니다.

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 은 무시합니다.

이름을 _ 로 시작해 미사용 변수 무시하기

변수를 만들었지만 어디에서도 사용하지 않으면, 러스트는 대개 “이 변수를 안 쓰는데, 버그 아니냐?”는 경고를 냅니다. 하지만 프로토타이핑을 하거나 프로젝트를 막 시작한 상태에서는, 일단 변수를 만들어 두고 아직 쓰지 않는 경우가 있습니다. 이런 상황에서는 변수 이름을 밑줄로 시작해, 사용하지 않는 변수에 대해 경고하지 말라고 러스트에게 알릴 수 있습니다. 목록 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 를 어디에도 바인딩하지 않았기 때문에, 소유권 이동도 일어나지 않았습니다.

.. 로 값의 나머지 부분 무시하기

값에 많은 부분이 있을 때는 .. 문법을 사용해 특정 부분만 쓰고 나머지는 무시할 수 있습니다. 그러면 무시할 값마다 _ 를 일일이 나열하지 않아도 됩니다. .. 패턴은 패턴에서 명시적으로 매칭하지 않은 “나머지 부분 전체”를 무시합니다. 목록 19-23에는 3차원 공간 좌표를 담는 Point 구조체가 있고, match 식에서 x 좌표만 사용하고 y, z 는 무시하려 합니다.

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: _ 를 일일이 적는 것보다 훨씬 간단합니다.

.. 문법은 필요한 만큼 값들을 자동으로 확장해 줍니다. 목록 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: 튜플의 첫 값과 마지막 값만 매칭하고 가운데 값들은 모두 무시하기

이 코드에서 첫 번째와 마지막 값은 각각 first, last 와 매칭되고, .. 는 가운데의 다른 값들을 전부 무시합니다.

하지만 .. 사용은 모호해서는 안 됩니다. 어떤 값이 매칭용이고 어떤 값이 무시용인지 불분명하다면, 러스트는 컴파일 오류를 냅니다. 목록 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

러스트는 이 튜플에서 second 와 매칭하기 전에 몇 개를 무시해야 하는지, 그리고 그 다음 또 몇 개를 더 무시해야 하는지 판단할 수 없습니다. 예를 들어 2 를 무시하고 second4 와 매칭한 뒤 8, 16, 32 를 무시하자는 뜻일 수도 있고, 2, 4 를 무시한 뒤 second8 과 매칭하고 16, 32 를 무시하자는 뜻일 수도 있습니다. second 라는 변수 이름 자체에는 러스트가 특별한 의미를 두지 않으므로, 이렇게 두 곳에 .. 를 사용하는 것은 모호하다는 컴파일 오류가 납니다.

매치 가드로 조건 추가하기

매치 가드(match guard)match arm 안 패턴 뒤에 붙는 추가적인 if 조건입니다. 해당 arm이 선택되려면 이 조건까지 함께 만족해야 합니다. 매치 가드는 패턴만으로는 표현하기 어려운 더 복잡한 조건을 나타낼 때 유용합니다. 다만 이것은 match 식에서만 사용 가능하고, if let 이나 while let 에는 붙일 수 없습니다.

이 조건 안에서는 패턴으로 만든 변수도 사용할 수 있습니다. 목록 19-26은 첫 번째 arm에 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 이 첫 번째 arm 패턴과 비교될 때, Some(4)Some(x) 와 매칭됩니다. 그 다음 매치 가드는 x % 2 == 0 인지를 검사하고, 이 조건도 참이므로 첫 번째 arm이 선택됩니다.

만약 numSome(5) 였다면, 첫 번째 arm의 매치 가드는 false 가 되었을 것입니다. 5를 2로 나눈 나머지는 1이고, 이는 0이 아니기 때문입니다. 그러면 러스트는 다음 arm으로 이동하고, 그 arm에는 매치 가드가 없으므로 Some variant와 매칭되었을 것입니다.

if x % 2 == 0 같은 조건은 패턴 안에 직접 표현할 방법이 없습니다. 바로 이 지점에서 매치 가드가 유용합니다.

고급 기능

이제 여러분은 러스트 프로그래밍 언어에서 가장 자주 쓰는 부분들을 배웠습니다. 21장에서 마지막 프로젝트를 하기 전에, 매일 쓰지는 않지만 가끔 마주칠 수 있는 언어의 몇 가지 측면을 살펴보겠습니다. 이 장은 그런 기능을 만났을 때 참고서처럼 다시 찾아볼 수 있는 장이라고 생각하면 됩니다. 여기서 다루는 기능들은 특정한 상황에서 특히 유용합니다. 자주 손이 가지는 않겠지만, 러스트가 제공하는 기능들을 전체적으로 이해하도록 해 두고 싶습니다.

이 장에서는 다음을 다룹니다.

  • Unsafe Rust: 러스트의 일부 보장을 스스로 포기하고, 그 보장을 수동으로 지키는 책임을 직접 지는 방법
  • 고급 트레이트: 연관 타입, 기본 제네릭 타입 매개변수, 완전 수식 문법, 슈퍼트레이트, 그리고 트레이트와 관련한 newtype 패턴
  • 고급 타입: newtype 패턴을 더 깊이, 타입 별칭, never 타입, 동적 크기 타입
  • 고급 함수와 클로저: 함수 포인터와 클로저 반환하기
  • 매크로: 컴파일 시점에 “코드를 만드는 코드”를 정의하는 방법

러스트의 여러 기능이 한데 모여 있는 장입니다. 각자 필요한 것을 찾을 수 있을 겁니다. 이제 들어가 봅시다!

안전하지 않은 러스트

안전하지 않은 러스트

지금까지 이 책에서 다뤄 온 모든 코드는 컴파일 시점에 러스트의 메모리 안전성 보장을 받았습니다. 하지만 러스트 안에는 이런 메모리 안전성 보장을 강제하지 않는, 또 다른 언어가 숨어 있습니다. 그것이 바로 unsafe Rust 입니다. unsafe Rust는 일반 러스트와 똑같이 생겼지만, 몇 가지 “추가 능력”을 제공합니다.

Unsafe Rust가 존재하는 이유는, 정적 분석이 본질적으로 보수적이기 때문입니다. 컴파일러가 코드가 안전성 보장을 지키는지 판단하려 할 때, 어떤 잘못된 프로그램을 받아들이는 것보다는 어떤 올바른 프로그램을 거부하는 편이 더 낫습니다. 코드가 실제로는 괜찮을 수도 있지만, 컴파일러가 확신할 만큼 충분한 정보를 갖고 있지 않다면 거부합니다. 이런 경우 unsafe 코드를 사용하면 컴파일러에게 “나를 믿어라, 내가 뭘 하는지 안다”라고 말할 수 있습니다. 하지만 분명히 해두자면, unsafe Rust는 여러분 책임 하에 사용하는 것입니다. 잘못 사용하면 null 포인터 역참조 같은 메모리 안전성 문제가 실제로 발생할 수 있습니다.

Unsafe Rust가 존재하는 또 다른 이유는, 컴퓨터 하드웨어 자체가 본질적으로 안전하지 않기 때문입니다. 만약 러스트가 unsafe 작업을 전혀 허용하지 않았다면, 저수준 시스템 프로그래밍, 예를 들어 운영체제와 직접 상호작용하거나 심지어 운영체제 자체를 작성하는 일을 할 수 없었을 것입니다. 이런 저수준 시스템 프로그래밍은 러스트가 목표로 하는 용도 중 하나입니다. 이제 unsafe Rust로 무엇을 할 수 있는지, 그리고 어떻게 하는지 살펴봅시다.

unsafe 초능력 사용하기

Unsafe Rust로 전환하려면 unsafe 키워드를 쓴 뒤, 그 안에 unsafe 코드를 담은 새 블록을 시작하면 됩니다. Unsafe Rust 안에서는 안전한 러스트에서는 할 수 없는 다섯 가지 동작을 할 수 있는데, 우리는 이것을 unsafe 초능력 이라고 부르겠습니다.

  1. raw 포인터를 역참조하기
  2. unsafe 함수나 메서드 호출하기
  3. 가변 static 변수에 접근하거나 수정하기
  4. unsafe 트레이트 구현하기
  5. union 의 필드에 접근하기

중요한 점은, unsafe 가 대여 검사기를 끄거나 러스트의 다른 안전성 검사를 전부 비활성화하는 것이 아니라는 사실입니다. unsafe 코드 안에서 참조를 사용하면, 그 참조는 여전히 정상적으로 검사됩니다. unsafe 키워드는 컴파일러가 메모리 안전성까지 검사해 주지 않는 이 다섯 가지 기능에만 접근 권한을 주는 것입니다. 즉 unsafe 블록 안에도 여전히 꽤 많은 안전성이 남아 있습니다.

또한 unsafe 라고 해서 그 블록 안 코드가 반드시 위험하거나 메모리 안전성 문제가 있다는 뜻도 아닙니다. 의미는 오히려 반대입니다. 그 블록 안의 코드가 메모리를 유효한 방식으로 접근하도록 프로그래머가 직접 책임지겠다 는 것입니다.

사람은 실수할 수 있고, 실제로 실수는 일어납니다. 하지만 이런 다섯 가지 unsafe 연산이 반드시 unsafe 라고 표시된 블록 안에 들어가게 만들면, 메모리 안전성과 관련된 오류가 발생했을 때 그 원인을 unsafe 블록 안에서 찾으면 된다는 큰 힌트를 얻게 됩니다. unsafe 블록은 가능한 한 작게 유지하세요. 나중에 메모리 버그를 추적할 때 분명 고맙게 느끼게 될 것입니다.

unsafe 코드를 최대한 격리하기 위해, 보통은 unsafe 코드를 안전한 추상화 안에 감싸고 안전한 API를 제공하는 것이 좋습니다. 이 장 뒤에서 unsafe 함수와 메서드를 다룰 때 그 예를 보겠습니다. 표준 라이브러리의 일부도 이미 이런 방식으로 구현되어 있습니다. 검증된 unsafe 코드를 안전한 추상화 안에 감싸 두면, 여러분이나 라이브러리 사용자가 그 기능을 쓰는 코드 곳곳으로 unsafe 가 새어 나오지 않게 할 수 있습니다. 안전한 추상화 자체는 그대로 안전하니까요.

이제 다섯 가지 unsafe 초능력을 하나씩 살펴보겠습니다. 또한 unsafe 코드에 대해 안전한 인터페이스를 제공하는 추상화도 함께 보겠습니다.

raw 포인터 역참조하기

4장의 [“댕글링 참조”][dangling-references] 절에서, 컴파일러가 참조가 언제나 유효함을 보장한다고 했습니다. Unsafe Rust에는 참조와 비슷하지만 새로운 타입 둘이 있습니다. 바로 raw 포인터 입니다. 참조와 마찬가지로 raw 포인터도 불변과 가변이 있으며, 각각 *const T, *mut T 로 씁니다. 여기서 별표는 역참조 연산자가 아니라 타입 이름의 일부입니다. raw 포인터에서 불변 이라는 것은 “포인터가 가리키는 값을 직접 바꿀 수 없다”는 뜻입니다.

일반 참조나 스마트 포인터와 달리, raw 포인터는 다음과 같은 특징을 가집니다.

  • 같은 위치에 대해 불변 포인터와 가변 포인터를 동시에 두거나, 가변 포인터 여러 개를 둘 수 있어 대여 규칙을 무시할 수 있다
  • 유효한 메모리를 가리킨다는 보장이 없다
  • null 일 수 있다
  • 자동 정리 같은 기능을 전혀 제공하지 않는다

이런 보장을 포기하는 대신, 여러분은 더 높은 성능이나 외부 언어/하드웨어와의 상호작용 능력을 얻습니다. 그런 영역에서는 러스트의 일반 보장이 그대로 적용되지 않기 때문입니다.

목록 20-1은 raw borrow 연산자를 사용해 불변 raw 포인터와 가변 raw 포인터를 만드는 예를 보여 줍니다.

fn main() {
    let mut num = 5;

    let r1 = &raw const num;
    let r2 = &raw mut num;
}
Listing 20-1: raw borrow 연산자로 raw 포인터 만들기

이 코드 자체에는 unsafe 키워드가 없다는 점에 주목하세요. raw 포인터를 만드는 것 자체는 안전한 코드에서도 할 수 있고, 진짜 위험한 일은 그것을 역참조해 값을 읽는 것입니다. 그때는 곧 보게 될 것처럼 unsafe 블록이 필요합니다.

또한 raw borrow 연산자 대신 as 캐스팅을 사용해, 유효할지 확신할 수 없는 raw 포인터도 만들 수 있습니다. 목록 20-2는 임의의 메모리 위치를 가리키는 raw 포인터를 만드는 예입니다. 이런 임의 메모리 사용은 정의되지 않은 동작이며, 그 주소에 데이터가 있을 수도, 없을 수도 있고, 컴파일러가 아예 메모리 접근을 최적화로 없애 버릴 수도 있으며, 프로그램이 segmentation fault 로 끝날 수도 있습니다. 대개 raw borrow 연산자로 충분하므로 이런 코드를 직접 작성할 좋은 이유는 거의 없지만, 기술적으로는 가능합니다.

fn main() {
    let address = 0x012345usize;
    let r = address as *const i32;
}
Listing 20-2: 임의 메모리 주소를 가리키는 raw 포인터 만들기

앞서 말했듯 raw 포인터를 만드는 것 은 안전한 코드에서도 가능합니다. 하지만 그 포인터를 역참조해 가리키는 데이터를 읽는 것은 unsafe 입니다. 목록 20-3에서는 raw 포인터에 * 역참조 연산자를 적용하는 코드가 반드시 unsafe 블록 안에 있어야 함을 보여 줍니다.

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 포인터 역참조하기

포인터를 만드는 것 자체는 아무 해도 끼치지 않습니다. 위험은 그 포인터가 가리키는 값을 읽거나 쓸 때, 그 값이 유효한지 아닌지를 우리가 보장해야 한다는 데 있습니다.

또한 목록 20-1과 20-3에서는 *const i32, *mut i32 raw 포인터를 둘 다 같은 메모리 위치 num 에 대해 만들었다는 점도 주목하세요. 만약 이것을 불변 참조와 가변 참조로 만들려 했다면, 러스트의 소유권 규칙 때문에 컴파일되지 않았을 것입니다. 러스트는 가변 참조가 있는 동안 불변 참조를 허용하지 않기 때문입니다. 하지만 raw 포인터는 같은 위치에 대해 불변과 가변 포인터를 동시에 만들 수도 있고, 가변 포인터를 통해 데이터를 바꿀 수도 있습니다. 따라서 데이터 경쟁을 직접 만들어 낼 가능성도 생깁니다. 조심해야 합니다!

그렇다면 왜 이런 위험한 raw 포인터를 굳이 쓰고 싶을까요? 대표적인 이유는 외부 언어와 상호작용할 때입니다. 다음 절에서 C 코드와 연동하는 예를 보겠습니다. 또 다른 이유는 대여 검사기가 이해하지 못하는 안전한 추상화를 직접 만들어야 할 때입니다. 이제 unsafe 함수와 메서드를 소개하고, unsafe 코드를 감싼 안전한 추상화의 예도 함께 보겠습니다.

unsafe 함수 또는 메서드 호출하기

unsafe 블록 안에서 할 수 있는 두 번째 일은 unsafe 함수 호출입니다. unsafe 함수와 메서드는 겉보기에는 일반 함수와 똑같지만, 정의 앞에 unsafe 키워드가 하나 더 붙어 있습니다. 이 문맥에서 unsafe 키워드는, 이 함수를 호출할 때 지켜야 할 어떤 전제 조건이 있고, 러스트는 우리가 그 조건을 지켰는지 보장할 수 없다는 뜻입니다. 따라서 unsafe 함수를 unsafe 블록 안에서 호출한다는 것은, “이 함수 문서를 읽었고, 올바른 사용법을 이해했으며, 그 계약을 우리가 직접 만족시킨다”고 선언하는 것입니다.

본문이 텅 비어 있는 dangerous 라는 unsafe 함수를 예로 들어 봅시다.

fn main() {
    unsafe fn dangerous() {}

    unsafe {
        dangerous();
    }
}

우리는 이 dangerous 함수를 반드시 별도의 unsafe 블록 안에서 호출해야 합니다. 그렇지 않으면 오류가 납니다.

$ 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 블록을 씀으로써 우리는 “이 함수 문서를 읽었고, 안전하게 사용하는 방법을 이해했으며, 함수가 요구하는 계약을 직접 지키고 있다”고 러스트에게 알려 주는 것입니다.

중요한 점은, unsafe 함수 본문 안에서 unsafe 연산을 하려 해도 여전히 unsafe 블록이 또 필요하다는 것입니다. 일반 함수 안에서와 마찬가지입니다. 따라서 컴파일러는 함수 전체에 무분별하게 unsafe 가 퍼지지 않도록 도와 줍니다.

unsafe 코드에 대한 안전한 추상화 만들기

함수 안에 unsafe 코드가 들어 있다고 해서, 함수 전체 를 unsafe 로 만들 필요는 없습니다. 실제로 unsafe 코드를 안전한 함수 안에 감싸는 것은 매우 흔한 추상화 기법입니다. 예시로, 표준 라이브러리의 split_at_mut 함수를 직접 어떻게 구현할 수 있을지 살펴보겠습니다. 이 메서드는 일부 unsafe 코드가 필요하지만, 외부에는 안전한 함수로 노출됩니다. split_at_mut 은 가변 슬라이스 위에 정의된 안전한 메서드로, 슬라이스와 인덱스 하나를 받아 슬라이스를 두 개로 나눕니다. 목록 20-4는 이 안전한 메서드를 사용하는 예입니다.

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 함수 사용하기

이 함수를 오직 안전한 러스트만으로 구현하려 하면, 목록 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: 오직 안전한 러스트만으로 split_at_mut 을 구현하려는 시도

이 함수는 먼저 슬라이스 전체 길이를 구합니다. 그런 뒤 mid 인덱스가 슬라이스 길이 이하인지 assert! 로 검사합니다. 따라서 너무 큰 인덱스를 넘기면 잘못된 슬라이스를 만들기 전에 바로 패닉하게 됩니다.

그다음 하나는 원래 슬라이스 시작부터 mid 까지, 다른 하나는 mid 부터 끝까지인 두 개의 가변 슬라이스를 튜플로 반환하려 합니다.

하지만 이 코드를 컴파일하면 다음과 같은 오류가 납니다.

$ 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

러스트의 대여 검사기는 우리가 “슬라이스의 서로 다른 두 부분”을 빌리려는 것임을 이해하지 못합니다. 그저 하나의 같은 슬라이스를 두 번 가변으로 빌린 것으로만 보기 때문입니다. 실제로는 두 슬라이스가 겹치지 않으므로 이런 빌림은 안전하지만, 컴파일러는 그 정도까지 똑똑하지는 않습니다. 이럴 때가 바로 unsafe 코드를 사용할 시점입니다.

목록 20-6은 unsafe 블록, raw 포인터, 그리고 몇몇 unsafe 함수 호출을 사용해 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 구현 안에서 unsafe 코드 사용하기

고급 트레이트

고급 트레이트

우리는 10장의 “트레이트로 공통 동작 정의하기” 절에서 트레이트를 처음 다뤘지만, 그때는 더 깊은 세부는 건드리지 않았습니다. 이제 여러분이 러스트를 더 많이 알게 되었으니, 그 세부 사항까지 파고들 수 있습니다.

연관 타입을 사용해 트레이트 정의하기

연관 타입(associated types) 은 트레이트 안에 타입 자리표시자를 연결해 두고, 그 트레이트 메서드 시그니처 안에서 그 자리표시자를 사용할 수 있게 합니다. 트레이트를 구현하는 쪽은, 자기 구현에서 그 자리표시자에 들어갈 구체 타입을 지정합니다. 그 덕분에 트레이트를 정의하는 시점에는 아직 정확히 어떤 타입이 들어올지 모르더라도, 그 타입을 사용하는 트레이트를 정의할 수 있습니다.

이 장에서 다루는 고급 기능들 가운데, 연관 타입은 가장 흔한 축에 속합니다. 이 책 나머지의 기능들보다야 덜 자주 쓰이지만, 이 장의 다른 기능들보다는 훨씬 더 많이 보게 됩니다.

연관 타입을 가진 대표적인 예가 표준 라이브러리의 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 을 반환하게 됩니다.

연관 타입은, 어떤 함수가 다룰 수 있는 타입을 아직 구체적으로 적지 않는다는 점에서 제네릭과 비슷해 보일 수 있습니다. 이 둘의 차이를 보기 위해, Counter 라는 타입에 Iterator 를 구현하면서 Itemu32 로 지정한 예를 생각해 봅시다.

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처럼 정의하지 않을까요?

pub trait Iterator<T> {
    fn next(&mut self) -> Option<T>;
}
Listing 20-14: 제네릭을 사용한 Iterator 트레이트의 가상 정의

차이는, 목록 20-14 같은 제네릭 정의를 사용하면 구현할 때마다 타입을 다시 명시해야 한다는 점입니다. 더 나아가 Counter 에 대해 Iterator<String> 을 구현할 수도 있고, 또 다른 타입으로도 얼마든지 여러 번 구현할 수 있습니다. 즉, 어떤 트레이트가 제네릭 매개변수를 가지면, 하나의 타입에 대해 그 트레이트를 여러 번 구현하면서 제네릭 자리에 들어가는 구체 타입을 매번 바꿀 수 있습니다. 그러면 Counter 에 대해 next 를 쓸 때마다 “지금은 어떤 Iterator 구현을 쓰는지” 타입 주석으로 다시 알려 주어야 합니다.

반면 연관 타입을 쓰면 그런 주석이 필요 없습니다. 연관 타입 방식에서는 하나의 타입에 대해 같은 트레이트를 여러 번 구현할 수 없기 때문입니다. 목록 20-13의 정의에서는 Counter 에 대해 impl Iterator for Counter 가 딱 하나뿐이므로, Item 타입도 단 한 번만 정하면 됩니다. 그래서 Counternext 를 쓸 때마다 “지금은 u32 반복자를 원한다”는 식의 추가 타입 주석이 전혀 필요 없습니다.

연관 타입은 트레이트 계약의 일부가 됩니다. 트레이트를 구현하는 쪽은 연관 타입 자리표시자에 어떤 구체 타입을 넣을지 반드시 정해야 합니다. 그리고 그 연관 타입이 어떻게 사용될 것인지를 설명하는 이름을 잘 붙이고, API 문서에도 설명해 두는 것이 좋은 습관입니다.

기본 제네릭 타입 매개변수와 연산자 오버로딩

제네릭 타입 매개변수를 사용할 때, 그 제네릭 타입에 대한 기본 구체 타입도 지정할 수 있습니다. 이렇게 하면 기본 타입이 충분한 경우, 트레이트 구현자가 굳이 구체 타입을 다시 지정할 필요가 없어집니다. 이 문법은 <PlaceholderType = ConcreteType> 형태로 씁니다.

이 기법이 특히 유용한 대표 사례가 연산자 오버로딩(operator overloading) 입니다. 즉, + 같은 연산자가 특정 상황에서 어떻게 동작할지 사용자 정의하는 것입니다.

러스트는 여러분이 완전히 새로운 연산자를 만들거나 임의의 연산자를 마음대로 오버로드 하도록 허용하지는 않습니다. 하지만 std::ops 에 나열된 연산과 그에 대응하는 트레이트에 대해서는, 관련 트레이트를 구현함으로써 동작을 바꿀 수 있습니다. 예를 들어 목록 20-15에서는 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 메서드는 두 Pointx 값을 더하고, 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 의 구체 타입을 직접 지정하지 않으면, 기본값으로 Self, 즉 지금 Add 를 구현하고 있는 타입이 들어갑니다.

우리가 Point 에 대해 Add 를 구현할 때는 Rhs 기본값을 그대로 사용했습니다. 왜냐하면 Point 두 개를 더하고 싶었기 때문입니다. 그렇다면 이번에는 Rhs 를 기본값 대신 다른 타입으로 지정하는 구현을 보겠습니다.

예를 들어 MillimetersMeters 라는 두 구조체가 서로 다른 단위의 값을 담고 있다고 합시다. 기존 타입을 새 구조체 하나로 감싸는 이런 얇은 포장 방식을 newtype 패턴 이라고 부르는데, 이는 뒤의 [“외부 트레이트를 newtype 패턴으로 구현하기”] newtype 절에서 더 자세히 설명합니다. 우리가 원하는 것은 밀리미터 값과 미터 값을 더했을 때, 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: MillimetersMeters 를 더하기 위해 Add 트레이트 구현하기

여기서는 Add<Meters> 라고 적어 Rhs 타입 매개변수를 기본값 대신 Meters 로 지정했습니다.

기본 타입 매개변수는 주로 두 가지 방식으로 사용됩니다.

  1. 기존 코드를 깨뜨리지 않고 타입을 확장할 때
  2. 대부분의 사용자는 필요 없지만, 특정 경우에는 세밀한 커스터마이징을 허용하고 싶을 때

표준 라이브러리의 Add 트레이트는 두 번째 목적의 좋은 예입니다. 보통은 같은 타입 끼리 더하지만, Add 는 그보다 더 많은 경우를 표현할 수 있는 여지를 제공합니다. Add 정의에 기본 타입 매개변수를 두었기 때문에, 대부분의 경우 추가 타입 인수를 매번 적을 필요가 없습니다. 즉, 약간의 구현 보일러플레이트를 없애면서 트레이트 사용성을 높인 셈입니다.

첫 번째 목적은 그 반대 방향에서 비슷합니다. 이미 존재하는 트레이트에 타입 매개변수를 추가하고 싶다면, 기본값을 제공함으로써 기존 구현 코드를 깨지 않고도 기능을 확장할 수 있습니다.

이름이 같은 메서드 구분하기

러스트는 어떤 트레이트 메서드 이름이 다른 트레이트의 메서드 이름과 같다고 해서 막지 않습니다. 또한 하나의 타입에 두 트레이트를 모두 구현하는 것도 가능하고, 심지어 그 타입 자체에 같은 이름의 메서드를 직접 구현하는 것도 가능합니다.

이처럼 이름이 같은 메서드가 여러 개 있을 때는, 러스트에게 “정확히 어떤 메서드를 원하는지”를 더 분명히 알려 줘야 합니다. 목록 20-17에는 fly 라는 메서드를 가진 두 트레이트 Pilot, Wizard 와, fly 메서드를 직접 구현한 Human 타입이 등장합니다. 각 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 를 호출하면, 러스트는 기본적으로 타입에 직접 구현된 메서드를 호출합니다. 목록 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* 가 출력됩니다. 즉 러스트는 Human 자체에 구현된 fly 메서드를 호출한 것입니다.

Pilot 이나 Wizard 트레이트에서 온 fly 메서드를 호출하려면, 더 명시적인 문법이 필요합니다. 목록 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 메서드를 호출할지 명시하기

메서드 이름 앞에 트레이트 이름을 붙이면, 러스트는 우리가 어떤 fly 구현을 원하는지 명확히 알 수 있습니다. 물론 person.fly() 대신 Human::fly(&person) 처럼 쓸 수도 있지만, 특별히 구분할 필요가 없다면 전자가 더 짧고 읽기 쉽습니다.

이 코드를 실행하면 다음과 같은 출력이 나옵니다.

$ 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 매개변수를 받기 때문에, 만약 두 타입 이 같은 트레이트 를 구현하고 있다면 러스트는 self 의 타입을 보고 어떤 구현을 써야 할지 결정할 수 있습니다.

하지만 메서드가 아닌 연관 함수는 self 매개변수가 없습니다. 이 경우에는, 같은 함수 이름을 가진 타입이나 트레이트가 여러 개 있을 때, 완전 수식 문법(fully qualified syntax)을 사용하지 않으면 러스트가 어느 쪽을 뜻하는지 알 수 없습니다. 예를 들어 목록 20-20에서는 동물 보호소를 위한 예를 만듭니다. 보호소는 모든 강아지 이름을 Spot 으로 부르고 싶어 한다고 합시다. 우리는 Animal 트레이트를 만들고, 여기에 메서드가 아닌 연관 함수 baby_name 을 둡니다. 그리고 Dog 구조체에도 baby_name 을 직접 구현하면서, 동시에 Animal 트레이트도 구현합니다.

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 이라고 부르게 했습니다. 동시에 Animal 트레이트도 Dog 에 대해 구현했는데, 이 트레이트에서 정의한 baby_name 은 “아기 개는 puppy라고 부른다”는 사실을 표현합니다.

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 여야 합니다. 그런데 목록 20-19에서 썼던 것처럼 단순히 트레이트 이름을 붙이는 방식만으로는 해결되지 않습니다. 목록 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 을 호출하려 하지만, 러스트는 어떤 구현을 써야 할지 모른다

Animal::baby_nameself 를 받지 않기 때문에, 러스트는 Animal 을 구현하는 여러 타입 중 어느 타입의 구현 을 쓰려는 것인지 알 수 없습니다. 실제 컴파일러 오류는 다음과 같습니다.

$ 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

이 모호성을 풀고, 우리가 원하는 것이 “Dog 에 구현된 Animalbaby_name” 이라는 사실을 알려 주려면, 목록 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 에 구현된 Animalbaby_name 을 호출하기

우리는 꺾쇠 괄호 안에 타입 주석을 제공함으로써, 이번 함수 호출에서는 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_if_method 자리는 없고, 다른 인수들만 있게 됩니다. 사실 함수나 메서드 호출 어디에서든 이런 완전 수식 문법을 사용할 수 있습니다. 하지만 러스트가 다른 정보만으로도 어느 구현을 뜻하는지 알 수 있는 경우에는, 이 문법의 일부를 생략할 수 있습니다. 결국 이 더 장황한 문법이 필요한 경우는, 같은 이름을 가진 여러 구현이 있어서 러스트가 우리의 의도를 구분할 추가 정보가 필요한 상황뿐입니다.

슈퍼트레이트 사용하기

어떤 트레이트 정의는 다른 트레이트에 의존하도록 만들고 싶을 때가 있습니다. 즉 첫 번째 트레이트를 구현하려면, 두 번째 트레이트도 함께 구현해야 한다고 요구하고 싶은 상황입니다. 그렇게 하면 첫 번째 트레이트 정의 안에서 두 번째 트레이트의 연관 항목을 사용할 수 있기 때문입니다. 이때 앞서 요구되는 트레이트를 해당 트레이트의 슈퍼트레이트(supertrait) 라고 부릅니다.

예를 들어 어떤 값을 별표 테두리로 감싸 출력하는 outline_print 메서드를 가진 OutlinePrint 트레이트를 만들고 싶다고 해 봅시다. 표준 라이브러리의 Display 트레이트를 구현한 Point 구조체가 (x, y) 형태로 출력된다고 할 때, Point { x: 1, y: 3 } 에 대해 outline_print 를 호출하면 다음과 같이 출력되면 좋겠습니다.

**********
*        *
* (1, 3) *
*        *
**********

outline_print 메서드 구현 안에서는 Display 의 기능을 사용하고 싶으므로, OutlinePrint 트레이트는 Display 를 구현한 타입에 대해서만 동작해야 한다고 명시해야 합니다. 이를 위해 트레이트 정의에서 OutlinePrint: Display 라고 쓰면 됩니다. 이것은 트레이트에 트레이트 바운드를 추가하는 것과 같은 개념입니다. 목록 20-23이 그 구현을 보여 줍니다.

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 를 적지 않았다면, 현재 스코프에서 &Self 타입에 to_string 이 없다는 오류를 받았을 것입니다.

이제 Point 처럼 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));
    }
}

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 {}
   |                       ^^^^^ the trait `std::fmt::Display` is not implemented for `Point`
   |
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();
   |       ^^^^^^^^^^^^^ the trait `std::fmt::Display` is not implemented for `Point`
   |
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 를 호출해 별표 테두리 안에 표시할 수 있게 됩니다.

외부 트레이트를 newtype 패턴으로 구현하기

10장의 “타입에 트레이트 구현하기” 절에서 우리는 orphan rule 을 언급했습니다. 이 규칙에 따르면, 어떤 트레이트와 타입 쌍에 대해 둘 중 하나 이상이 현재 크레이트 로컬일 때만 그 트레이트를 구현할 수 있습니다. 이 제한은 newtype 패턴을 사용하면 우회할 수 있습니다. newtype 패턴은 튜플 구조체 안에 한 필드만 두고, 우리가 트레이트를 구현하고 싶은 타입을 그 안에 감싸는 방식입니다. (튜플 구조체는 5장의 [“튜플 구조체로 서로 다른 타입 만들기”] tuple-structs 절에서 다뤘습니다.) 그러면 이 래퍼 타입은 우리 크레이트 안의 로컬 타입이 되므로, 여기에 대해 원하는 트레이트를 구현할 수 있게 됩니다. Newtype 이라는 용어는 Haskell 언어에서 왔습니다. 이 패턴에는 런타임 성능 비용이 없습니다. 래퍼 타입은 컴파일 시점에 제거되기 때문입니다.

예를 들어 DisplayVec<T> 에 직접 구현하고 싶다고 해 봅시다. 하지만 Display 트레이트와 Vec<T> 타입 둘 다 표준 라이브러리 안에 정의되어 있으므로, orphan rule 때문에 직접 구현할 수 없습니다. 대신 Vec<T> 인스턴스를 감싸는 Wrapper 구조체를 만들고, DisplayWrapper 에 구현하면 됩니다. 목록 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 구현 안에서는, Wrapper 가 튜플 구조체이고 Vec<T> 가 그 안의 첫 번째 항목이므로 self.0 을 사용해 내부 Vec<T> 값에 접근합니다. 이렇게 하면 Wrapper 에 대해 Display 기능을 사용할 수 있습니다.

하지만 이 기법의 단점은 Wrapper 가 완전히 새 타입 이라는 점입니다. 따라서 내부에 들어 있는 Vec<T> 의 메서드를 자동으로 함께 가지지는 않습니다. 만약 Wrapper 를 정말로 Vec<T> 처럼 다루고 싶다면, Vec<T> 의 메서드들을 Wrapper 에 직접 하나씩 구현해 self.0 에 위임해야 합니다. 또는 내부 타입의 모든 메서드를 그대로 노출하고 싶다면, 15장의 “스마트 포인터를 일반 참조처럼 다루기” 절에서 다룬 Deref 트레이트를 Wrapper 에 구현해도 됩니다. 반대로 Wrapper 타입의 동작을 일부로 제한하고 싶다면, 원하는 메서드만 골라 직접 구현해야 합니다.

이 newtype 패턴은 트레이트와 관련이 없는 경우에도 유용합니다. 이제는 시선을 바꿔, 러스트 타입 시스템과 상호작용하는 또 다른 고급 방법들을 살펴보겠습니다.

고급 타입

고급 타입

러스트의 타입 시스템에는 지금까지 이름만 언급하고 아직 자세히 설명하지 않은 기능이 몇 가지 있습니다. 먼저 newtype을 일반적인 개념으로 다시 보면서 왜 타입으로서 유용한지 이야기하겠습니다. 그다음 newtype 과 비슷해 보이지만 의미는 조금 다른 타입 별칭(type aliases)으로 넘어갑니다. 그리고 ! 타입, 동적 크기 타입도 함께 다룹니다.

newtype 패턴으로 타입 안전성과 추상화 얻기

이 절은 이미 앞의 “외부 트레이트를 newtype 패턴으로 구현하기” 절을 읽었다고 가정합니다. newtype 패턴은 거기서 다룬 것 외에도 다양한 작업에 유용합니다. 예를 들어 값이 절대로 서로 혼동되지 않도록 정적 으로 강제하거나, 값이 어떤 단위를 뜻하는지 명확히 표시하는 데 쓸 수 있습니다. 목록 20-16에서 MillimetersMeters 구조체가 u32 를 감싼 newtype 으로 단위를 표현했던 것을 떠올려 보세요. 만약 어떤 함수 매개변수 타입이 Millimeters 라면, 실수로 Meters 나 그냥 u32 값을 넘기는 코드는 컴파일되지 않을 것입니다.

또한 newtype 패턴은 어떤 타입 구현의 일부 세부를 감추는 추상화 용도로도 쓸 수 있습니다. 새 타입은 private 한 내부 타입과는 다른 public API를 노출할 수 있기 때문입니다.

newtype 은 내부 구현을 숨기는 데도 유용합니다. 예를 들어 사람의 ID와 이름을 연결하는 HashMap<i32, String> 을 감싸는 People 타입을 제공할 수 있습니다. People 를 사용하는 코드는, 이름 문자열을 추가하는 메서드 같은 우리가 제공하는 public API로만 상호작용하면 됩니다. 내부적으로 이름에 i32 ID를 할당해 저장한다는 사실까지는 알 필요가 없습니다. 이것은 18장의 [“구현 세부를 숨기는 캡슐화”] encapsulation-that-hides-implementation-details 절에서 이야기한, 구현 세부를 감추는 캡슐화를 가볍게 구현하는 방법입니다.

타입 별칭과 타입 동의어

러스트는 기존 타입에 다른 이름을 붙이는 타입 별칭(type alias) 기능도 제공합니다. 이를 위해 type 키워드를 사용합니다. 예를 들어 i32 에 대한 별칭으로 Kilometers 를 이렇게 만들 수 있습니다.

fn main() {
    type Kilometers = i32;

    let x: i32 = 5;
    let y: Kilometers = 5;

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

이제 Kilometersi32동의어(synonym) 가 됩니다. 즉, 목록 20-16에서 만든 MillimetersMeters 처럼 완전히 별개의 새 타입은 아닙니다. 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>

이 긴 타입을 함수 시그니처와 타입 주석 곳곳에 반복해서 적는 것은 지루하고 실수하기도 쉽습니다. 프로젝트 전체가 그런 코드로 가득 차 있다면 더욱 그렇겠죠. 목록 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: 긴 타입을 여러 곳에서 사용하기

타입 별칭을 쓰면 이런 코드는 훨씬 다루기 쉬워집니다. 목록 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 안의 많은 함수는 다음 같은 형태의 Result<T, E> 를 반환하게 됩니다.

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 타입

러스트에는 타입 이론에서 empty type 이라고 부르는, 값이 하나도 없는 특별한 타입 ! 가 있습니다. 하지만 우리는 이것을 never 타입 이라고 부르는 편을 선호합니다. 어떤 함수가 절대 반환하지 않을 때 그 반환 타입 자리에 등장하기 때문입니다. 예를 들면 다음과 같습니다.

fn bar() -> ! {
    // --snip--
    panic!();
}

이 코드는 “함수 bar 는 절대 반환하지 않는다”라고 읽습니다. 이런 함수는 diverging functions 라고 부릅니다. ! 타입 값은 만들 수 없으므로, bar 는 실제로 어떤 값도 반환할 수 없습니다.

그렇다면 만들 수도 없는 타입이 무슨 쓸모가 있을까요? 2장의 숫자 맞히기 게임 코드 일부를 다시 떠올려 보겠습니다. 목록 20-27에 다시 실었습니다.

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 로 끝나는 arm을 가진 match

당시에는 여기서 일부 세부를 그냥 지나갔습니다. 6장의 [“match 제어 흐름 구문”] the-match-control-flow-construct 절에서, match 의 각 arm은 모두 같은 타입을 반환해야 한다고 설명했습니다. 예를 들어 다음 코드는 동작하지 않습니다.

fn main() {
    let guess = "3";
    let guess = match guess.trim().parse() {
        Ok(_) => 5,
        Err(_) => "hello",
    };
}

이 코드에서 guess 의 타입은 정수 이면서 문자열일 수는 없고, 러스트는 하나의 변수가 하나의 타입만 갖기를 요구합니다. 그렇다면 continue 는 무엇을 반환하기에 목록 20-27의 match 안에서 한 arm은 u32, 다른 arm은 continue 로 끝나도 허용되었을까요?

눈치챘겠지만, continue 의 타입은 ! 입니다. 즉, 러스트가 guess 의 타입을 계산할 때 한 arm은 u32 값을 만들고 다른 arm은 ! 값을 가진다고 봅니다. ! 는 실제 값을 가질 수 없으므로, 러스트는 guess 의 타입을 u32 로 결정합니다.

이 동작을 형식적으로 말하면, ! 타입 식은 어떤 타입으로도 강제될 수 있습니다. 목록 20-27에서 continue 로 arm을 끝낼 수 있었던 이유는, 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"),
        }
    }
}

여기서도 목록 20-27의 match 와 같은 일이 일어납니다. val 은 타입 T 를 가지고, panic! 은 타입 ! 를 가지므로, 전체 match 식 결과 타입은 T 가 됩니다. 이 코드가 동작하는 이유는 panic! 이 값을 만들어 내는 것이 아니라 프로그램을 끝내기 때문입니다. None 경우에는 unwrap 이 어떤 값도 반환하지 않으므로, 이 코드는 유효합니다.

Never 타입을 가지는 마지막 식의 예는 루프입니다.

fn main() {
    print!("forever ");

    loop {
        print!("and ever ");
    }
}

여기서 루프는 끝나지 않으므로, 이 식의 값은 ! 입니다. 물론 루프 안에 break 가 있다면 이 말은 성립하지 않습니다. 루프가 결국 종료될 수 있기 때문입니다.

동적 크기 타입과 Sized 트레이트

러스트는 각 타입에 대해, 예를 들어 “이 타입 값을 저장하려면 얼마나 많은 공간이 필요한가” 같은 몇 가지 세부를 알아야 합니다. 이 때문에 러스트 타입 시스템에는 처음엔 조금 헷갈리는 구석이 하나 생깁니다. 바로 동적 크기 타입(dynamically sized types), 줄여서 DST 혹은 unsized type 입니다. 이런 타입은 값의 크기를 런타임에 가서야 알 수 있게 해 줍니다.

책 전반에서 계속 사용해 온 str 을 예로 들어 살펴보겠습니다. 맞습니다. &str 이 아니라 맨몸의 str 자체가 DST 입니다. 사용자 입력 문자열처럼, 문자열 길이는 런타임 전에는 알 수 없는 경우가 많습니다. 따라서 str 타입 변수 자체를 직접 만들 수도 없고, str 타입 인수를 직접 받는 함수도 쓸 수 없습니다. 다음 코드는 동작하지 않습니다.

fn main() {
    let s1: str = "Hello there!";
    let s2: str = "How's it going?";
}

러스트는 어떤 타입의 값이든 메모리를 얼마나 할당해야 하는지 알아야 하고, 같은 타입의 값은 같은 양의 메모리를 차지해야 합니다. 만약 이런 코드를 허용한다면, 두 str 값은 같은 타입이면서도 서로 다른 크기를 가져야 합니다. 예를 들어 s1 은 12바이트, s2 는 15바이트가 필요할 수 있죠. 그래서 동적 크기 타입을 직접 담는 변수를 만드는 것은 불가능합니다.

그렇다면 어떻게 해야 할까요? 이 경우 정답은 이미 알고 있습니다. s1, s2 의 타입을 str 이 아니라 문자열 슬라이스 &str 로 만드는 것입니다. 4장의 [“문자열 슬라이스”] string-slices 절에서 설명했듯, 슬라이스는 시작 위치와 길이만을 저장합니다. 따라서 &T 가 단일 값(메모리 주소 하나)이라면, 문자열 슬라이스는 str 의 주소와 길이라는 두 값 을 담습니다. 그래서 러스트는 문자열이 아무리 길어도 문자열 슬라이스 자체의 크기는 컴파일 시점에 항상 알 수 있습니다. 즉, 문자열 슬라이스의 크기는 언제나 usize 두 개분입니다. 일반적으로 동적 크기 타입은 이런 식으로 러스트에서 사용됩니다. 동적 정보를 설명하는 추가 메타데이터가 함께 붙는 것이지요. 동적 크기 타입에 대한 황금률은, 언제나 그것을 어떤 포인터 뒤에 두어야 한다는 것입니다.

str 은 다양한 포인터 타입과 함께 사용할 수 있습니다. 예를 들어 Box<str> 이나 Rc<str> 같은 식입니다. 사실 여러분은 이미 이런 패턴을 다른 동적 크기 타입과 함께 본 적이 있습니다. 바로 트레이트입니다. 모든 트레이트 역시 동적 크기 타입이며, 우리는 트레이트 이름 자체를 사용해 그것을 참조할 수 있습니다. 18장의 “트레이트 객체를 사용하여 공통 동작을 추상화하기” 절에서 이야기했듯, 트레이트를 트레이트 객체로 사용하려면 &dyn Trait, Box<dyn Trait> (혹은 Rc<dyn Trait>) 같은 포인터 뒤에 두어야 합니다.

이런 DST 를 다루기 위해 러스트는 타입 크기를 컴파일 시점에 알 수 있는지 판단하는 Sized 트레이트를 제공합니다. 이 트레이트는 컴파일 시점에 크기를 알 수 있는 모든 타입에 자동으로 구현됩니다. 또한 러스트는 모든 제네릭 함수에 암묵적으로 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 클로저 트레이트가 아니라 소문자 f 를 쓰는 fn 타입으로 강제(coerce)됩니다. 이 fn 타입을 함수 포인터(function pointer) 라고 부릅니다. 함수 포인터를 사용하면 함수도 다른 함수의 인수로 넘길 수 있습니다.

어떤 매개변수가 함수 포인터임을 나타내는 문법은 클로저 문법과 비슷합니다. 목록 20-28은 매개변수에 1을 더하는 add_one 함수와, “하나의 i32 를 받아 i32 를 반환하는 함수 포인터”와 하나의 i32 값을 매개변수로 받는 do_twice 함수를 보여 줍니다. do_twice 는 함수 farg 값과 함께 두 번 호출하고, 그 두 결과를 다시 더해 반환합니다. 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_twicef 매개변수가 “i32 를 받아 i32 를 반환하는 함수 포인터”라고 명시했습니다. 그러면 do_twice 본문 안에서 f 를 일반 함수처럼 호출할 수 있습니다. main 에서는 함수 이름 add_one 자체를 첫 번째 인수로 넘깁니다.

클로저와 달리 fn 은 트레이트가 아니라 타입 이기 때문에, 매개변수 타입으로 그냥 직접 fn 을 적어 줍니다. 제네릭 타입 매개변수에 Fn 계열 트레이트를 바운드로 거는 방식과는 다릅니다.

함수 포인터는 세 개의 클로저 트레이트(Fn, FnMut, FnOnce)를 모두 구현합니다. 그래서 어떤 함수가 클로저를 기대할 때는 일반 함수 포인터를 언제나 대신 넘길 수 있습니다. 그렇기 때문에 보통은 함수 시그니처를 “제네릭 + 클로저 트레이트 바운드” 방식으로 작성하는 것이 좋습니다. 그러면 일반 함수도, 클로저도 둘 다 받아들일 수 있기 때문입니다.

다만 함수만 받고 클로저는 받지 않기를 원하는 대표적 상황이 하나 있습니다. 바로 외부 코드와 연동할 때입니다. 예를 들어 C 함수는 함수를 인수로 받을 수 있지만, C 자체에는 클로저라는 개념이 없습니다.

클로저를 직접 적든, 이미 이름 붙은 함수를 넘기든 둘 다 가능한 예로, 표준 라이브러리의 Iterator 트레이트가 제공하는 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 메서드에 클로저를 넘겨 숫자를 문자열로 바꾸기

또는 목록 20-30처럼 클로저 대신 함수 이름을 map 의 인수로 넘길 수도 있습니다.

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: map 메서드와 함께 String::to_string 함수 사용해 숫자를 문자열로 바꾸기

여기서는 같은 이름의 함수가 여러 곳에 존재하기 때문에, [“고급 트레이트”] advanced-traits 절에서 봤던 완전 수식 문법을 써야 한다는 점에 주목하세요.

여기서 우리가 사용하는 것은 ToString 트레이트에 정의된 to_string 함수입니다. 표준 라이브러리는 Display 를 구현하는 모든 타입에 대해 ToString 을 자동으로 구현해 둡니다.

또한 6장의 “enum 값” 절에서 보았듯, 우리가 정의한 각 enum variant 이름은 동시에 그 variant를 만드는 생성자 함수 역할도 합니다. 이런 생성자 함수도 클로저 트레이트를 구현하는 함수 포인터처럼 쓸 수 있으므로, 클로저를 받는 메서드에 인수로 넘길 수 있습니다. 목록 20-31을 보세요.

fn main() {
    enum Status {
        Value(u32),
        Stop,
    }

    let list_of_statuses: Vec<Status> = (0u32..20).map(Status::Value).collect();
}
Listing 20-31: enum 생성자를 map 메서드와 함께 사용해 숫자로부터 Status 인스턴스 만들기

여기서는 map 이 순회하는 범위 안 각 u32 값에 대해, Status::Value 생성자 함수를 사용해 Status::Value 인스턴스를 만듭니다. 어떤 사람은 이 스타일을 더 좋아하고, 또 어떤 사람은 클로저가 더 읽기 쉽다고 느낍니다. 결국 둘은 같은 코드로 컴파일되므로, 여러분에게 더 명확한 스타일을 선택하면 됩니다.

클로저 반환하기

클로저는 트레이트로 표현되기 때문에, 클로저를 함수 반환 타입으로 직접 적을 수는 없습니다. 보통 “어떤 트레이트를 구현하는 것을 반환하고 싶다”는 상황에서는, 그 트레이트를 구현하는 구체 타입 을 반환 타입으로 직접 적으면 됩니다. 하지만 클로저는 대개 “직접 적을 수 있는 구체 타입 이름”이 없기 때문에, 예를 들어 클로저가 환경 값을 캡처하는 경우 함수 포인터 fn 도 반환 타입으로 쓸 수 없습니다.

대신 보통은 10장에서 배운 impl Trait 문법을 사용합니다. Fn, FnOnce, FnMut 중 적절한 것을 사용해 어떤 함수형 타입이든 반환할 수 있습니다. 예를 들어 목록 20-32의 코드는 문제없이 컴파일됩니다.

#![allow(unused)]
fn main() {
fn returns_closure() -> impl Fn(i32) -> i32 {
    |x| x + 1
}
}
Listing 20-32: impl Trait 문법으로 함수에서 클로저 반환하기

하지만 13장의 “클로저 타입 추론과 주석” 절에서 언급했듯, 각 클로저는 모두 자기만의 별도 타입입니다. 따라서 시그니처는 같지만 구현이 다른 여러 함수를 하나로 모아 다루고 싶다면, 결국 트레이트 객체를 사용해야 합니다. 목록 20-33이 그런 상황을 보여 줍니다.

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 를 반환합니다. 하지만 이 함수들이 실제로 반환하는 클로저는 서로 다릅니다. 비록 같은 트레이트를 구현하더라도, 이 코드를 컴파일하려 하면 러스트는 다음과 같이 알려 줍니다.

$ 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`
              found opaque type `impl Fn(i32) -> i32`
   = 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 를 반환할 때마다 러스트가 그 함수에 대해 하나의 고유한 불투명 타입(opaque type) 을 만든다고 말합니다. 즉, 러스트가 우리 대신 만든 그 타입 내부가 정확히 무엇인지 우리는 들여다볼 수도 없고, 이름을 추측해 직접 쓸 수도 없습니다. 따라서 이 함수들이 모두 같은 Fn(i32) -> i32 트레이트를 구현하는 클로저를 반환한다 하더라도, 러스트가 각 함수마다 생성한 불투명 타입은 서로 다릅니다. (이는 17장에서 본 것처럼, 출력 타입이 같더라도 각 async 블록이 서로 다른 구체 타입을 만들어 내는 것과 비슷합니다.) 이 문제를 해결하는 방법은 이미 여러 번 봤습니다. 바로 트레이트 객체입니다. 목록 20-34가 그 해결책을 보여 줍니다.

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) 라는 말은 하나의 단일 기능이 아니라, 여러 기능군을 가리킵니다. 구체적으로는 macro_rules! 를 사용한 선언적 매크로와, 세 종류의 프로시저럴 매크로가 있습니다.

  • 구조체와 enum에 붙는 derive 속성과 함께 코드를 생성하는 사용자 정의 #[derive] 매크로
  • 어떤 항목이든 붙일 수 있는 커스텀 속성을 정의하는 attribute-like 매크로
  • 함수 호출처럼 보이지만 인수로 전달된 토큰 자체를 다루는 function-like 매크로

이제 이들을 차례로 살펴보겠습니다. 하지만 먼저, 함수가 이미 있는데도 왜 매크로가 필요한지 부터 봅시다.

매크로와 함수의 차이

근본적으로 매크로는 “다른 코드를 생성하는 코드”를 작성하는 방법입니다. 이를 메타프로그래밍(metaprogramming) 이라고 부릅니다. 부록 C에서는 derive 속성이 여러 트레이트 구현을 자동으로 만들어 준다는 이야기를 했고, 이 책 전체에서 println!vec! 매크로도 사용해 왔습니다. 이런 매크로들은 여러분이 손으로 쓴 코드보다 더 많은 코드를 확장(expand) 하여 생성합니다.

메타프로그래밍은 작성하고 유지해야 할 코드 양을 줄이는 데 유용합니다. 이 점은 함수도 마찬가지입니다. 하지만 매크로에는 함수가 가지지 못한 몇 가지 추가 능력이 있습니다.

함수 시그니처는 함수가 받는 매개변수의 개수와 타입을 미리 선언해야 합니다. 반면 매크로는 가변 개수의 매개변수를 받을 수 있습니다. 예를 들어 println!("hello") 는 인수 하나로 호출할 수 있고, println!("hello {}", name) 은 두 인수로 호출할 수 있습니다. 또한 매크로는 컴파일러가 코드 의미를 해석하기 에 확장되므로, 예를 들어 특정 타입에 트레이트를 구현하는 코드까지도 만들어 낼 수 있습니다. 함수는 런타임에 호출되는 것이므로, 컴파일 시점에 필요한 트레이트 구현을 대신할 수 없습니다.

하지만 함수 대신 매크로를 구현하는 데는 단점도 있습니다. 매크로 정의는 “러스트 코드를 만드는 러스트 코드”를 써야 하므로, 일반 함수 정의보다 훨씬 복잡합니다. 이런 한 단계의 간접성 때문에, 매크로 정의는 보통 함수 정의보다 읽고 이해하고 유지보수하기 어렵습니다.

또 하나 중요한 차이는, 함수는 파일 어디에 정의하든 어디에서든 호출할 수 있지만, 매크로는 그 파일 안에서 호출하기 전에 먼저 정의되어 있거나 스코프로 들어와 있어야 한다는 점입니다.

일반적인 메타프로그래밍을 위한 선언적 매크로

러스트에서 가장 널리 쓰이는 매크로 형태는 선언적 매크로(declarative macro) 입니다. 이것들은 “매크로 바이 예제”, “macro_rules! 매크로”, 혹은 그냥 “매크로”라고도 불립니다. 핵심적으로 선언적 매크로는, 러스트의 match 식과 비슷한 것을 작성하게 해 줍니다. 6장에서 이야기했듯 match 식은 어떤 식의 결과 값을 여러 패턴과 비교하고, 매칭된 패턴에 연결된 코드를 실행하는 제어 구조입니다. 매크로도 값과 패턴을 비교한 뒤, 맞는 패턴에 연결된 코드를 실행합니다. 다만 여기서 “값”은 매크로에 전달된 러스트 소스 코드 그 자체이고, 패턴은 그 소스 코드의 구조와 매칭됩니다. 그리고 패턴과 맞는 코드가 기존 코드를 대체합니다. 이 모든 일은 컴파일 중에 일어납니다.

매크로를 정의하려면 macro_rules! 구문을 사용합니다. 이를 실제로 보기 위해, vec! 매크로가 어떻게 정의되는지 살펴봅시다. 8장에서 우리는 vec! 매크로로 특정 값들을 담은 새 벡터를 만들 수 있다고 설명했습니다. 예를 들어 다음 매크로 호출은 정수 세 개를 담은 새 벡터를 만듭니다.

#![allow(unused)]
fn main() {
let v: Vec<u32> = vec![1, 2, 3];
}

같은 매크로는 정수 두 개를 담은 벡터도, 문자열 슬라이스 다섯 개를 담은 벡터도 만들 수 있습니다. 함수로는 같은 일을 할 수 없습니다. 매개변수 개수도 타입도 미리 알 수 없기 때문입니다.

목록 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! 매크로 정의의 단순화된 버전

Note: 실제 표준 라이브러리의 vec! 정의에는 필요한 메모리를 미리 정확히 할당하는 코드도 들어 있습니다. 하지만 이 예제를 단순하게 유지하기 위해 그 최적화 코드는 생략했습니다.

#[macro_export] 주석은 이 매크로가 정의된 크레이트가 스코프로 들어올 때마다 함께 사용 가능해야 한다는 뜻입니다. 이 속성이 없으면 매크로를 바깥에서 가져다 쓸 수 없습니다.

그 다음 macro_rules! 와 느낌표 뒤에, 느낌표를 뺀 매크로 이름을 적으며 정의를 시작합니다. 여기서는 이름이 vec 이고, 그 뒤의 중괄호가 매크로 정의 본문입니다.

vec! 본문 구조는 match 식과 비슷합니다. 여기에는 ( $( $x:expr ),* ) 라는 패턴을 가진 arm 하나가 있고, 그 뒤에 => 와 그 패턴에 매칭되었을 때 내보낼 코드 블록이 옵니다. 이 매크로에는 arm이 하나뿐이므로, 패턴에 맞는 경우도 하나뿐이고 그 외는 모두 오류가 납니다. 더 복잡한 매크로는 arm이 여러 개일 수 있습니다.

매크로 정의 안에서 쓸 수 있는 패턴 문법은 19장에서 다룬 패턴 문법과는 다릅니다. 매크로 패턴은 값이 아니라 “러스트 코드의 구조”와 매칭되기 때문입니다. 목록 20-35의 패턴이 각각 무슨 뜻인지 하나씩 봅시다. 전체 패턴 문법은 Rust Reference 에서 볼 수 있습니다.

먼저 괄호 한 쌍으로 전체 패턴을 감쌉니다. 그리고 달러 기호($)를 사용해, 패턴과 매칭된 러스트 코드를 저장할 매크로 시스템 안의 변수를 선언합니다. 달러 기호는 이것이 일반 러스트 변수가 아니라 “매크로 변수”라는 점을 분명히 해 줍니다. 그 다음 $() 로 둘러싼 부분은, 괄호 안 패턴과 맞는 값들을 캡처해서 나중에 치환 코드에서 사용할 수 있게 합니다. 이 $() 안의 $x:expr 은 어떤 러스트 식과도 매칭되고, 그 식에 $x 라는 이름을 붙입니다.

$() 뒤의 쉼표는 $() 안 패턴과 매칭되는 코드 조각 각각 사이에 리터럴 쉼표가 있어야 함을 뜻합니다. 그리고 * 는 그 앞의 패턴이 0번 이상 반복될 수 있다는 뜻입니다.

우리가 vec![1, 2, 3]; 처럼 매크로를 호출하면, $x 패턴은 1, 2, 3 세 번과 매칭됩니다.

이제 arm의 본문 안 코드 패턴을 봅시다. 여기서는 $()* 안에 들어 있는 temp_vec.push() 가, 앞 패턴의 $() 와 매칭된 각 부분에 대해 0번 이상 생성됩니다. 그리고 $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” 같은 자료를 참고하세요.

속성으로부터 코드를 생성하는 프로시저럴 매크로

매크로의 두 번째 형태는 프로시저럴 매크로(procedural macro) 입니다. 선언적 매크로와 달리 함수처럼 동작합니다. 즉, 패턴과 비교한 뒤 코드를 바꾸는 대신, 입력으로 코드를 받고 그 코드를 가공한 뒤 다시 코드를 만들어 냅니다. 프로시저럴 매크로에는 custom derive, attribute-like, function-like 세 종류가 있고, 작동 방식은 모두 비슷합니다.

프로시저럴 매크로를 만들 때는, 특별한 크레이트 타입을 가진 별도의 크레이트에 정의를 두어야 합니다. 기술적인 이유가 있지만, 앞으로는 이 제약이 사라질 수도 있습니다. 목록 20-36은 프로시저럴 매크로를 정의하는 일반적인 형태를 보여 줍니다. 여기서 some_attribute 는 구체적인 매크로 종류를 대신하는 자리표시자입니다.

Filename: src/lib.rs
use proc_macro::TokenStream;

#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
Listing 20-36: 프로시저럴 매크로 정의 예제

프로시저럴 매크로를 정의하는 함수는 TokenStream 을 입력으로 받고, TokenStream 을 출력으로 반환합니다. TokenStream 타입은 러스트와 함께 제공되는 proc_macro 크레이트가 정의하며, 토큰들의 시퀀스를 나타냅니다. 즉, 매크로가 다루는 소스 코드 자체입니다. 매크로가 입력 코드에 대해 수행한 결과가 다시 출력 TokenStream 이 됩니다. 또한 함수 앞에 붙는 속성은 우리가 어떤 종류의 프로시저럴 매크로를 만들고 있는지 지정합니다. 하나의 크레이트 안에 여러 종류의 프로시저럴 매크로를 둘 수도 있습니다.

이제 각각의 종류가 어떻게 다른지 보겠습니다. 먼저 사용자 정의 derive 매크로부터 시작한 뒤, 나머지 두 종류는 무엇이 조금 다른지 설명하겠습니다.

사용자 정의 derive 매크로

hello_macro 라는 크레이트를 하나 만들고, 그 안에 hello_macro 라는 연관 함수 하나를 가진 HelloMacro 트레이트를 정의한다고 해 봅시다. 사용자가 각자의 타입에 대해 HelloMacro 를 직접 구현하게 만들 수도 있겠지만, 대신 #[derive(HelloMacro)] 를 붙이기만 하면 기본 구현을 얻을 수 있는 프로시저럴 매크로를 제공하겠습니다. 그 기본 구현은 Hello, Macro! My name is TypeName! 을 출력하는데, 여기서 TypeName 은 트레이트가 구현된 타입 이름입니다. 다시 말해, 우리는 우리 크레이트 사용자가 목록 20-37 같은 코드를 쓸 수 있게 하고자 합니다.

Filename: src/main.rs
use hello_macro::HelloMacro;
use hello_macro_derive::HelloMacro;

#[derive(HelloMacro)]
struct Pancakes;

fn main() {
    Pancakes::hello_macro();
}
Listing 20-37: 우리 프로시저럴 매크로를 사용하는 사용자가 작성하게 될 코드

모든 구현을 마치면, 이 코드는 Hello, Macro! My name is Pancakes! 를 출력하게 됩니다. 첫 단계는 다음처럼 새 라이브러리 크레이트를 만드는 것입니다.

$ cargo new hello_macro --lib

그 다음 목록 20-38처럼 HelloMacro 트레이트와 그 연관 함수를 정의합니다.

Filename: src/lib.rs
pub trait HelloMacro {
    fn hello_macro();
}
Listing 20-38: derive 매크로와 함께 사용할 단순한 트레이트

이제 트레이트와 함수는 준비되었습니다. 물론 이 시점에서도 목록 20-39처럼 라이브러리 사용자가 직접 트레이트를 구현해서 원하는 기능을 얻을 수는 있습니다.

Filename: src/main.rs
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();
}
Listing 20-39: 사용자가 HelloMacro 트레이트를 수동으로 구현해야 한다면 어떻게 생길지

하지만 우리는 사용자가 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_macrohello_macro_derive 를 내부 의존성으로 삼고 그것을 다시 재수출하게 만들 수도 있습니다. 하지만 지금처럼 프로젝트를 구성해 두면, 사용자는 derive 기능이 필요 없을 때 hello_macro 만 쓸 수도 있습니다.

이제 hello_macro_derive 크레이트를 “프로시저럴 매크로 크레이트”라고 선언해야 합니다. 또한 곧 사용하게 될 syn, quote 크레이트도 의존성으로 추가해야 합니다. Cargo.toml 에 다음 내용을 넣습니다.

Filename: hello_macro_derive/Cargo.toml
[lib]
proc-macro = true

[dependencies]
syn = "2.0"
quote = "1.0"

이제 프로시저럴 매크로 정의를 시작할 준비가 되었습니다. hello_macro_derive 크레이트의 src/lib.rs 에 목록 20-40의 코드를 넣어 보세요. 아직 impl_hello_macro 함수를 정의하지 않았으므로 이 코드는 지금은 컴파일되지 않습니다.

Filename: 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)
}
Listing 20-40: 대부분의 프로시저럴 매크로 크레이트가 필요로 하는 기본 구조

여기서는 코드를 hello_macro_derive 함수와 impl_hello_macro 함수로 나눴다는 점에 주목하세요. hello_macro_derive 는 입력 TokenStream 을 파싱하는 역할을 하고, impl_hello_macro 는 파싱된 구문 트리를 변형하는 역할을 합니다. 이런 식으로 나누면 프로시저럴 매크로를 작성하기가 훨씬 편합니다. 바깥 함수(hello_macro_derive)의 코드는 거의 모든 프로시저럴 매크로 크레이트에서 비슷하게 생기고, 안쪽 함수 (impl_hello_macro) 안의 코드는 여러분 매크로의 구체적인 목적에 따라 달라집니다.

여기서는 proc_macro, syn, quote 세 새 크레이트가 등장했습니다. proc_macro 는 러스트와 함께 제공되므로 Cargo.toml 에 따로 추가할 필요가 없었습니다. proc_macro 크레이트는 우리 코드 안에서 러스트 코드를 읽고 조작할 수 있게 해 주는 컴파일러 API입니다.

syn 크레이트는 문자열 형태의 러스트 코드를, 우리가 연산을 수행할 수 있는 자료구조로 파싱합니다. quote 크레이트는 syn 자료구조를 다시 러스트 코드로 바꿔 줍니다. 이 두 크레이트 덕분에 우리가 처리하고 싶은 거의 모든 러스트 코드를 훨씬 쉽게 다룰 수 있습니다. 러스트 전체 문법 파서를 직접 작성하는 것은 매우 어려운 일이기 때문입니다.

우리 라이브러리 사용자가 어떤 타입에 #[derive(HelloMacro)] 를 붙이면, hello_macro_derive 함수가 호출됩니다. 이것이 가능한 이유는, 함수 앞에 proc_macro_derive 속성을 붙이고 이름도 트레이트와 같은 HelloMacro 로 지정했기 때문입니다. 이것이 대부분의 프로시저럴 매크로가 따르는 관례입니다.

hello_macro_derive 함수는 먼저 input 으로 들어온 TokenStream 을 우리가 해석하고 조작할 수 있는 자료구조로 바꿉니다. 여기서 syn 이 사용됩니다. syn::parse 함수는 TokenStream 을 받아, 파싱된 러스트 코드를 표현하는 DeriveInput 구조체를 반환합니다. 목록 20-41은 struct Pancakes; 문자열을 파싱했을 때 얻게 되는 DeriveInput 구조체의 관련 부분을 보여 줍니다.

DeriveInput {
    // --snip--

    ident: Ident {
        ident: "Pancakes",
        span: #0 bytes(95..103)
    },
    data: Struct(
        DataStruct {
            struct_token: Struct,
            fields: Unit,
            semi_token: Some(
                Semi
            )
        }
    )
}
Listing 20-41: 목록 20-37에서 매크로 속성이 붙은 코드를 파싱해 얻은 DeriveInput 인스턴스

이 구조체 필드들은 우리가 파싱한 러스트 코드가 Pancakes 라는 식별자(ident)를 가진 유닛 구조체라는 사실을 보여 줍니다. 물론 실제 구조체에는 훨씬 더 많은 필드가 있고, 더 다양한 러스트 코드를 설명합니다. 자세한 내용은 synDeriveInput 문서를 참고하세요.

impl_hello_macro 함수를 정의해 우리가 삽입하고 싶은 새 러스트 코드를 만들게 되겠지만, 그 전에 한 가지를 더 짚고 넘어갑시다. derive 매크로의 출력 역시 TokenStream 입니다. 이 반환 TokenStream 은 우리 크레이트 사용자가 쓴 코드에 추가로 더해집니다. 즉, 그 사용자의 크레이트를 컴파일할 때 이 추가 기능까지 함께 얻게 됩니다.

여기서 syn::parse 호출 실패 시 unwrap 으로 패닉하도록 한 것도 보였을 것입니다. 프로시저럴 매크로는 API 시그니처상 Result 가 아니라 TokenStream 을 반환해야 하므로, 이런 실패 상황에서는 패닉할 수밖에 없습니다. 여기서는 단순하게 unwrap 을 사용했지만, 실제 코드에서는 panic! 이나 expect 로 좀 더 구체적인 에러 메시지를 제공하는 것이 좋습니다.

이제 매크로 속성이 붙은 러스트 코드를 TokenStream 에서 DeriveInput 으로 바꾸는 과정은 준비되었습니다. 다음으로는, 이 정보를 바탕으로 “주석이 붙은 타입에 HelloMacro 를 구현하는 코드”를 실제로 만들어 보겠습니다. 목록 20-42를 보세요.

Filename: 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)
}

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()
}
Listing 20-42: 파싱된 러스트 코드를 사용해 HelloMacro 트레이트 구현 생성하기

우리는 ast.ident 를 사용해, 매크로 주석이 붙은 타입 이름을 담은 Ident 구조체 인스턴스를 얻습니다. 목록 20-41을 떠올려 보면, 목록 20-37 코드에 대해 impl_hello_macro 를 실행하면 이 ident 필드는 "Pancakes" 값을 가집니다. 따라서 목록 20-42의 name 변수는, 출력하면 "Pancakes" 라는 문자열로 보이는 Ident 구조체를 담게 됩니다.

quote! 매크로는 우리가 반환하고 싶은 러스트 코드를 정의하게 해 줍니다. 다만 컴파일러는 quote! 실행 결과 그대로가 아니라 TokenStream 을 원하므로, into 메서드를 호출해 중간 표현을 소비하고 필요한 TokenStream 타입으로 바꿉니다.

quote! 매크로에는 아주 멋진 템플릿 기능도 있습니다. 예를 들어 #name 이라고 쓰면, quote! 가 그것을 변수 name 의 값으로 치환해 줍니다. 반복도 어느 정도 가능합니다. 자세한 내용은 quote 크레이트 문서를 참고하세요.

우리가 원하는 것은, 사용자가 주석을 붙인 타입 위에 HelloMacro 트레이트 구현을 자동으로 생성하는 것입니다. 그러려면 #name 을 사용해 그 타입 이름을 코드 안에 넣으면 됩니다. 트레이트 구현 안에는 hello_macro 함수 하나가 있고, 이 함수의 본문은 Hello, Macro! My name is 다음에 “주석이 붙은 타입의 이름”을 출력하는 코드를 담습니다.

여기서 쓰는 stringify! 매크로는 러스트에 내장되어 있습니다. 1 + 2 같은 러스트 식 하나를 받아, 컴파일 시점에 "1 + 2" 같은 문자열 리터럴로 바꿔 줍니다. 이는 식을 먼저 평가한 뒤 결과를 String 으로 바꾸는 format! 이나 println! 와는 다릅니다. #name 입력은 “그대로 출력하고 싶은 식”에 해당할 수 있으므로 stringify! 를 사용하는 것입니다. 또한 stringify! 를 쓰면 #name 을 컴파일 시점에 문자열 리터럴로 만들어 주므로, 런타임 할당도 줄일 수 있습니다.

이 시점이면 hello_macrohello_macro_derive 둘 모두에서 cargo build 가 성공해야 합니다. 이제 이 두 크레이트를 목록 20-37의 코드와 연결해, 실제로 프로시저럴 매크로가 동작하는 것을 확인해 봅시다. projects 디렉터리에 cargo new pancakes 로 새 바이너리 프로젝트를 하나 만드세요. pancakes 크레이트의 Cargo.toml 에는 hello_macrohello_macro_derive 를 의존성으로 추가해야 합니다. 만약 여러분이 두 크레이트를 crates.io에 배포했다면 일반 의존성으로 적으면 되고, 아직 배포하지 않았다면 다음처럼 path 의존성으로 지정할 수 있습니다.

[dependencies]
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! 가 출력됩니다. pancakes 크레이트는 직접 HelloMacro 를 구현하지 않았지만, #[derive(HelloMacro)] 덕분에 프로시저럴 매크로가 그 구현을 자동으로 만들어 넣은 것입니다.

다음으로는 다른 종류의 프로시저럴 매크로가 사용자 정의 derive 와 어떻게 다른지 살펴봅시다.

attribute-like 매크로

Attribute-like 매크로는 custom derive 매크로와 비슷하지만, derive 속성용 코드를 생성하는 대신 새 속성 자체를 정의할 수 있게 해 줍니다. 또한 더 유연합니다. derive 는 구조체와 enum에만 붙일 수 있지만, attribute-like 매크로는 함수 같은 다른 항목에도 붙일 수 있습니다. 예를 들어 웹 프레임워크에서 함수에 붙는 route 속성이 있다고 해 봅시다.

#[route(GET, "/")]
fn index() {

#[route] 속성은 프레임워크가 제공하는 프로시저럴 매크로이며, 정의 시그니처는 대략 다음과 같습니다.

#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {

여기에는 TokenStream 타입 매개변수 두 개가 있습니다. 첫 번째는 속성 내용, 즉 GET, "/" 부분입니다. 두 번째는 이 속성이 붙어 있는 항목 자체, 여기서는 fn index() {} 와 함수 본문 전체입니다.

이를 제외한 나머지는 custom derive 매크로와 같습니다. 즉, proc-macro 크레이트 타입을 가진 별도 크레이트를 만들고, 원하는 코드를 생성하는 함수를 구현하면 됩니다.

function-like 매크로

Function-like 매크로는 함수 호출처럼 보이는 매크로를 정의합니다. macro_rules! 매크로와 비슷하게 함수처럼 생긴 문법을 가지지만, 일반 함수보다 훨씬 유연합니다. 예를 들어 인수 개수를 미리 알 필요가 없습니다. 하지만 macro_rules! 매크로는 앞서 “일반적인 메타프로그래밍을 위한 선언적 매크로” 절에서 살펴본 “패턴 매칭 같은 문법”으로만 정의할 수 있습니다. 반면 function-like 매크로는 다른 프로시저럴 매크로들과 마찬가지로 TokenStream 을 인수로 받고, 러스트 코드로 그 TokenStream 을 조작합니다. 예를 들어 SQL 문장을 파싱하는 sql! 매크로는 다음처럼 호출될 수 있습니다.

let sql = sql!(SELECT * FROM posts WHERE id=1);

이 매크로는 내부 SQL 문장을 파싱하고, 문법적으로 올바른 SQL 인지를 검사할 수 있습니다. 이런 처리는 macro_rules! 만으로는 하기 어려울 정도로 복잡합니다. 이 sql! 매크로 정의는 다음처럼 생깁니다.

#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {

이 정의는 custom derive 매크로 시그니처와 비슷합니다. 괄호 안 토큰들을 입력으로 받아, 우리가 원하는 코드를 출력으로 돌려주기 때문입니다.

정리

후우! 이제 여러분의 러스트 도구 상자에는 아마 자주 쓰지는 않겠지만, 아주 특정한 상황에서 유용한 기능들도 들어가게 되었습니다. 우리는 여러 복잡한 주제를 소개했기 때문에, 앞으로 오류 메시지나 다른 사람 코드에서 이런 개념을 만나더라도 적어도 그것이 무엇인지 알아볼 수 있을 것입니다. 이 장은 나중에 해결책을 찾을 때 참고서로 활용하세요.

최종 프로젝트: 멀티스레드 웹 서버 만들기

긴 여정이었지만, 이제 책의 마지막에 도달했습니다. 이 장에서는 마지막 프로젝트 하나를 함께 만들면서, 최근 장들에서 다룬 몇 가지 개념을 실제로 써 보고, 앞 장에서 배운 내용도 다시 복습하겠습니다.

최종 프로젝트로는 웹 브라우저에서 그림 21-1처럼 “Hello!” 라고 말하는 웹 서버를 만들겠습니다.

웹 서버를 만드는 계획은 다음과 같습니다.

  1. TCP와 HTTP 에 대해 조금 배운다.
  2. 소켓에서 TCP 연결을 기다린다.
  3. 소수의 HTTP 요청을 파싱한다.
  4. 올바른 HTTP 응답을 만든다.
  5. 스레드 풀로 서버 처리량을 높인다.
Screenshot of a web browser visiting the address 127.0.0.1:8080 displaying a webpage with the text content “Hello! Hi from Rust”

Figure 21-1: Our final shared project

시작하기 전에 두 가지는 미리 짚고 가야 합니다. 첫째, 우리가 사용할 방식은 러스트로 웹 서버를 만드는 “최선의 방법” 은 아닙니다. 커뮤니티에는 우리가 만들 것보다 더 완전한 웹 서버와 스레드 풀 구현을 제공하는 실전용 크레이트들이 이미 crates.io 에 많이 공개되어 있습니다. 하지만 이 장의 목적은 “쉬운 길”을 택하는 것이 아니라, 개념을 배우는 데 있습니다. 러스트는 시스템 프로그래밍 언어이기 때문에, 다른 언어에서는 어렵거나 비실용적일 만큼 낮은 수준까지도 원하는 추상화 수준을 직접 선택해 내려갈 수 있습니다.

둘째, 여기서는 async 와 await 를 사용하지 않습니다. 스레드 풀 자체만으로도 충분히 큰 주제이기 때문입니다. 다만 이 장에서 다룰 문제들 중 일부에 async/await 가 어떻게 적용될 수 있는지는 짚고 넘어갈 것입니다. 실제로 17장에서 이야기했듯, 많은 async 런타임도 내부적으로는 스레드 풀을 사용해 작업을 관리합니다.

따라서 우리는 기본적인 HTTP 서버와 스레드 풀을 손으로 직접 구현하면서, 앞으로 여러분이 사용할 수도 있는 각종 크레이트가 어떤 일반적인 아이디어와 기법 위에 만들어졌는지 배우게 될 것입니다.

단일 스레드 웹 서버 만들기

단일 스레드 웹 서버 만들기

우선 단일 스레드 웹 서버가 동작하게 만드는 것부터 시작합시다. 본격적으로 들어가기 전에, 웹 서버를 만드는 데 관련된 프로토콜을 아주 짧게 훑어보겠습니다. 이 프로토콜들의 세부는 이 책 범위를 벗어나지만, 간단한 개요만으로도 필요한 배경은 충분히 얻을 수 있습니다.

웹 서버와 관련된 두 가지 주요 프로토콜은 HTTP(Hypertext Transfer Protocol)TCP(Transmission Control Protocol) 입니다. 둘 다 요청-응답(request-response) 프로토콜입니다. 즉 클라이언트 가 요청을 시작하고, 서버 가 요청을 받아 응답을 돌려줍니다. 요청과 응답의 내용은 각각 이 프로토콜이 정의합니다.

TCP 는 더 저수준의 프로토콜로, 정보가 한 서버에서 다른 서버로 어떻게 전달되는지에 대한 세부를 설명하지만, 그 정보가 무엇인지 까지는 정의하지 않습니다. HTTP 는 그 위에 쌓여, 요청과 응답의 내용을 정의합니다. 기술적으로 HTTP 를 다른 프로토콜 위에 올릴 수도 있지만, 현실에서는 압도적인 대부분의 경우 HTTP 가 TCP 위에서 데이터를 주고받습니다. 이 장에서는 TCP와 HTTP 요청/응답의 원시 바이트 수준을 직접 다루게 됩니다.

TCP 연결 기다리기

우리 웹 서버는 TCP 연결을 기다릴 수 있어야 하므로, 그것부터 구현해 보겠습니다. 표준 라이브러리는 이를 위한 std::net 모듈을 제공합니다. 늘 하던 방식으로 새 프로젝트를 만듭니다.

$ 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> 를 반환하는데, 이는 바인딩이 실패할 수도 있음을 뜻합니다. 예를 들어 프로그램 두 개를 동시에 실행해 둘 다 같은 포트를 듣게 만들면 실패할 수 있습니다. 우리는 지금 학습용 기본 서버를 작성하는 중이므로, 이런 에러를 세밀하게 처리하지 않고 unwrap 으로 문제가 나면 프로그램을 중단시키기로 합니다.

TcpListenerincoming 메서드는 스트림들의 시퀀스를 만들어 주는 반복자를 반환합니다(좀 더 정확히는 TcpStream 타입 스트림입니다). 하나의 스트림 은 클라이언트와 서버 사이의 열린 연결 하나를 나타냅니다. 연결(connection) 이란, 클라이언트가 서버에 연결하고, 서버가 응답을 만들고, 다시 연결을 닫는 전체 요청-응답 과정을 뜻합니다. 따라서 우리는 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 되면, 그 연결은 Drop 구현에 의해 닫힙니다. 브라우저는 이런 닫힌 연결을 보고 일시적인 문제라고 생각해 다시 시도하기도 합니다.

또한 어떤 브라우저는, 이후 요청이 생길 경우 더 빨리 처리할 수 있도록 서버와의 연결을 여러 개 미리 열어 두고는 아무 요청도 보내지 않기도 합니다. 이런 경우 우리의 서버는 실제 요청이 없더라도 각 연결을 모두 보게 됩니다. 예를 들어 많은 Chromium 계열 브라우저가 그렇습니다. 이 최적화는 사설(프라이빗) 브라우징 모드나 다른 브라우저를 사용하면 비활성화될 수 있습니다.

어쨌든 중요한 사실은, 우리가 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::BufReaderstd::io::prelude 를 스코프로 가져옵니다. 이들은 스트림에서 읽고 쓰는 데 필요한 트레이트와 타입을 제공합니다. main 함수의 for 루프에서는 이제 단순히 연결되었다는 메시지를 출력하는 대신, 새로 만든 handle_connection 함수를 호출하고 stream 을 넘깁니다.

handle_connection 함수 안에서는 먼저 BufReader 인스턴스를 하나 만들어 stream 에 대한 참조를 감쌉니다. BufReaderstd::io::Read 트레이트의 메서드를 우리가 직접 반복 호출하는 대신, 내부에서 버퍼링을 관리해 줍니다.

우리는 http_request 라는 변수를 만들고, 브라우저가 서버로 보내는 요청의 각 줄을 거기에 모읍니다. 그 줄들을 벡터에 모으고 싶다는 뜻으로 Vec<_> 타입 주석을 붙였습니다.

BufReaderstd::io::BufRead 트레이트를 구현하는데, 이 트레이트는 lines 메서드를 제공합니다. lines 는 스트림 안에서 줄바꿈 바이트를 만날 때마다 끊어서 Result<String, std::io::Error> 반복자를 만듭니다. 각 String 값을 얻기 위해 우리는 각 Result 에 대해 mapunwrap 을 사용합니다. 데이터가 유효한 UTF-8이 아니거나 스트림 읽기 자체에 문제가 있으면 Result 는 에러가 될 수 있습니다. 실제 프로덕션 프로그램이라면 이런 경우도 더 우아하게 처리해야 하겠지만, 여기서는 단순화를 위해 오류 시 프로그램을 멈추기로 합니다.

브라우저는 HTTP 요청의 끝을 “연속된 두 줄바꿈”으로 나타냅니다. 따라서 스트림으로부터 하나의 요청만 얻으려면, 빈 문자열이 나오는 줄까지 읽어들이면 됩니다. 이렇게 줄들을 벡터로 모은 뒤에는, 그 요청을 들여다볼 수 있도록 pretty debug 형식으로 출력합니다.

이제 이 코드를 실행해 봅시다! 프로그램을 시작하고 다시 브라우저에서 요청을 보내 보세요. 브라우저에는 여전히 에러 페이지가 뜨겠지만, 터미널 출력은 이제 다음과 비슷할 것입니다.

$ cargo run
   Compiling hello v0.1.0 (file:///projects/hello)
    Finished `dev` profile [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

첫 줄은 요청 줄(request line) 이고, 클라이언트가 무엇을 요청하고 있는지에 대한 정보를 담습니다. 요청 줄 첫 부분은 메서드이며, GET 이나 POST 같은 값이 올 수 있습니다. 이는 클라이언트가 어떤 방식으로 요청하고 있는지를 설명합니다. 우리 클라이언트는 GET 요청을 사용했는데, 이는 “정보를 달라”는 의미입니다.

요청 줄의 다음 부분은 / 인데, 이것은 클라이언트가 요청하는 URI(Uniform Resource Identifier) 입니다. URI 는 URL 과 아주 비슷하지만 완전히 같은 개념은 아닙니다. 하지만 이 장의 목적에서는 그 차이를 엄밀하게 알 필요가 없고, HTTP 명세는 URI 라는 말을 쓰므로 여기서는 그냥 URI 를 URL 이라고 생각해도 충분합니다.

마지막 부분은 클라이언트가 사용하는 HTTP 버전입니다. 그 뒤 요청 줄은 CRLF 시퀀스로 끝납니다. (CRLFcarriage returnline feed 를 뜻하는데, 타자기 시절의 용어입니다.) CRLF 시퀀스는 \r\n 이라고 쓸 수도 있습니다. 여기서 \r 은 carriage return, \n 은 line feed 입니다. 이 CRLF 시퀀스 가 요청 줄과 나머지 요청 데이터를 구분합니다. 출력할 때는 \r\n 이 문자 그대로 보이는 대신 실제 줄바꿈 으로 나타납니다.

지금까지 실행한 프로그램에서 받은 요청 줄 데이터를 보면, GET 이 메서드이고, / 가 요청 URI이며, HTTP/1.1 이 버전이라는 것을 알 수 있습니다.

요청 줄 뒤에 Host: 부터 시작하는 나머지 줄들은 헤더입니다. GET 요청에는 본문이 없습니다.

다른 브라우저를 사용해 보거나, 127.0.0.1:7878/test 같은 다른 주소를 요청해 보면서 요청 데이터가 어떻게 바뀌는지도 확인해 보세요.

이제 브라우저가 무엇을 요청하는지 알게 되었으니, 그에 응답하는 데이터를 보내 봅시다!

단일 스레드 서버를 멀티스레드 서버로 바꾸기

단일 스레드 서버를 멀티스레드 서버로 바꾸기

지금 서버는 각 요청을 차례대로 처리합니다. 즉, 첫 번째 연결 처리가 끝나기 전까지는 두 번째 연결을 처리하지 않습니다. 요청이 점점 많아질수록 이런 직렬 실행 방식은 점점 비효율적이 됩니다. 처리 시간이 오래 걸리는 요청이 하나 들어오면, 뒤따르는 요청이 빠르게 처리할 수 있는 요청이라 해도 그 긴 요청이 끝날 때까지 기다려야 합니다. 이를 고쳐야 합니다. 하지만 먼저, 이 문제가 실제로 어떻게 드러나는지 살펴보겠습니다.

느린 요청 시뮬레이션하기

느리게 처리되는 요청이 현재 서버 구현에서 다른 요청들에 어떤 영향을 주는지 살펴보겠습니다. 목록 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 로 바꿨습니다. 문자열 리터럴 값에 대해 패턴 매칭하려면 request_line 의 슬라이스에 대해 명시적으로 매칭해야 합니다. match 는 동등성 비교 메서드와 달리 자동으로 참조/역참조를 해주지 않기 때문입니다.

첫 번째 팔은 목록 21-9의 if 블록과 같습니다. 두 번째 팔은 /sleep 요청과 매칭됩니다. 이 요청을 받으면 서버는 성공 HTML 페이지를 렌더링하기 전에 5초 동안 잠듭니다. 세 번째 팔은 목록 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 모델, 단일 스레드 async I/O 모델, 멀티스레드 async I/O 모델 같은 다른 방법도 있습니다. 이 주제가 흥미롭다면 다른 해법을 더 읽고 직접 구현해 볼 수 있습니다. 러스트처럼 저수준 언어라면 이런 선택지가 모두 가능합니다.

스레드 풀 구현을 시작하기 전에, 이 풀을 사용하는 모습이 어떠해야 하는지 먼저 이야기해 봅시다. 코드를 설계할 때는 클라이언트 인터페이스를 먼저 써 보는 것이 설계를 이끄는 데 도움이 됩니다. 즉, 코드를 어떤 방식으로 호출하고 싶은지에 맞춰 API를 먼저 작성하고, 그 구조 안에 기능을 구현하는 편이 기능을 먼저 만든 뒤 공개 API를 설계하는 것보다 낫습니다.

12장의 프로젝트에서 테스트 주도 개발을 사용했던 것과 비슷하게, 여기서는 컴파일러 주도 개발을 사용하겠습니다. 우리가 원하는 함수를 호출하는 코드를 먼저 쓰고, 그런 다음 컴파일러 오류를 보면서 무엇을 바꿔야 코드가 동작하게 되는지 결정할 것입니다. 다만 그 전에, 출발점으로 삼되 საბოლო적인 해법으로는 쓰지 않을 기법부터 살펴보겠습니다.

요청마다 스레드 하나씩 생성하기

먼저, 연결마다 새 스레드를 만든다면 코드가 어떤 모습일지 살펴보겠습니다. 앞에서 말했듯, 무제한으로 스레드를 만들 가능성이 있다는 문제가 있으므로 이것은 최종 계획이 아닙니다. 하지만 우선 동작하는 멀티스레드 서버를 만든 뒤, 그다음 개선책으로 스레드 풀을 추가하면 두 해법을 비교하기 쉬워집니다.

목록 21-11은 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 를 사용해 새 스레드 풀을 만듭니다. 이 예제에서는 스레드 수를 네 개로 설정합니다. 그런 다음 for 루프 안에서 pool.execute 는 각 스트림마다 풀에서 실행해야 할 클로저를 받는다는 점에서 thread::spawn 과 비슷한 인터페이스를 가집니다. 이제 pool.execute 가 그 클로저를 받아 풀 안의 스레드 하나에 전달해 실행하게 구현해야 합니다. 아직은 이 코드가 컴파일되지 않지만, 컴파일러가 고치는 방향을 안내해 주도록 일단 시도해 보겠습니다.

컴파일러 주도 개발로 ThreadPool 만들기

목록 21-12의 변경을 src/main.rs 에 반영한 다음, 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 구현은 웹 서버가 무슨 작업을 하는지와는 독립적일 것입니다. 따라서 hello 크레이트를 바이너리 크레이트에서 라이브러리 크레이트로 바꾸어 ThreadPool 구현을 담아 보겠습니다. 라이브러리 크레이트로 바꾸면, 웹 요청 처리뿐 아니라 스레드 풀을 이용하고 싶은 다른 작업에도 이 별도 라이브러리를 사용할 수 있습니다.

우선 src/lib.rs 파일을 만들고, 지금 당장 가질 수 있는 가장 단순한 ThreadPool 구조체 정의를 다음과 같이 넣습니다.

Filename: src/lib.rs
pub struct ThreadPool;

그다음 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 를 스레드 컬렉션의 원소 개수로 쓸 것이라는 점도 알고 있습니다. 이는 3장의 “정수 타입” 절에서 논의했듯 usize 가 적합한 용도입니다.

코드를 다시 확인해 봅시다.

$ 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, 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 타입 매개변수는 반환값과 관련 있으므로 지금은 중요하지 않습니다. spawnF 의 트레이트 경계로 FnOnce 를 사용한다는 것을 볼 수 있습니다. 이것이 우리에게도 아마 적절할 것입니다. 결국 execute 가 받은 인수를 spawn 에 넘길 것이기 때문입니다. 또한 요청을 처리할 스레드는 그 요청의 클로저를 딱 한 번만 실행하므로, FnOnceOnce 와도 잘 맞습니다.

F 타입 매개변수에는 Send 트레이트 경계와 'static 라이프타임 경계도 붙어 있습니다. 이것도 우리 상황에 유용합니다. 클로저를 한 스레드에서 다른 스레드로 옮기려면 Send 가 필요하고, 스레드가 얼마나 오래 실행될지 모르므로 'static 이 필요합니다. 이제 이런 제약을 가진 제네릭 타입 F 를 받는 ThreadPoolexecute 메서드를 만들어 봅시다.

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 에 전달된 클로저를 실제로 호출하지는 않기 때문입니다.

Note: 하스켈이나 러스트처럼 컴파일러가 엄격한 언어에 대해 “코드가 컴파일되면 동작한다”는 말을 들을 수 있습니다. 하지만 이 말은 언제나 참은 아닙니다. 지금 프로젝트는 컴파일되지만 아무 일도 하지 않습니다! 실제 완성형 프로젝트를 만들고 있었다면, 지금쯤 코드를 컴파일할 뿐 아니라 원하는 동작도 하는지 확인하는 단위 테스트를 쓰기 시작해야 할 좋은 시점입니다.

생각해 봅시다. 여기서 클로저 대신 future 를 실행하려 했다면 무엇이 달라졌을까요?

new 에서 스레드 수 검증하기

아직 newexecute 의 매개변수로 아무것도 하고 있지 않습니다. 이제 우리가 원하는 동작을 하도록 이 함수들의 본문을 구현해 봅시다. 먼저 new 를 생각해 보면, 앞에서는 음수 개수의 스레드 풀이 말이 되지 않기 때문에 size 에 부호 없는 타입을 선택했습니다. 하지만 스레드가 0개인 풀도 역시 말이 되지 않는데, 0은 usize 로서는 완전히 유효한 값입니다. 그래서 ThreadPool 인스턴스를 반환하기 전에 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 로 바꾸고 12-9의 I/O 프로젝트에서 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 보다 미리 할당하는 편이 약간 더 효율적입니다.

이제 cargo check 를 다시 실행하면 성공할 것입니다.

Sending Code from the ThreadPool to a Thread

목록 21-14의 for 루프에는 스레드 생성과 관련된 주석을 남겨 두었습니다. 이제 실제로 스레드를 어떻게 만드는지 살펴보겠습니다. 표준 라이브러리는 스레드를 만드는 방법으로 thread::spawn 을 제공하며, thread::spawn 은 스레드가 생성되자마자 실행할 코드를 받기를 기대합니다. 하지만 우리 경우에는 스레드를 먼저 만들고, 나중에 보낼 코드를 기다리게 하고 싶습니다. 표준 라이브러리의 스레드 구현은 그런 기능을 제공하지 않기 때문에, 우리가 직접 구현해야 합니다.

이 동작을 구현하기 위해 ThreadPool 과 스레드 사이에 새 데이터 구조를 하나 두고, 그 구조가 이 새로운 동작을 관리하게 하겠습니다. 이 데이터 구조를 Worker 라고 부르겠습니다. 풀 구현에서 흔히 쓰는 용어입니다. Worker 는 실행해야 할 코드를 가져와 자신의 스레드에서 실행합니다.

식당 주방에서 일하는 사람들을 떠올려 보세요. 일꾼들은 손님 주문이 들어올 때까지 기다렸다가, 주문을 받아 처리하는 책임을 집니다.

이제 스레드 풀 안에 JoinHandle<()> 벡터를 저장하는 대신 Worker 구조체 인스턴스들을 저장하겠습니다. 각 WorkerJoinHandle<()> 인스턴스 하나를 보관합니다. 그리고 Worker 에 메서드를 구현해서, 실행할 코드 클로저를 받아 이미 돌고 있는 스레드로 보내 실행하게 할 것입니다. 또한 로깅이나 디버깅 시 풀 안의 서로 다른 Worker 인스턴스를 구분할 수 있도록 각 Workerid 를 부여하겠습니다.

ThreadPool 을 만들 때 새로 일어날 과정은 다음과 같습니다. 먼저 이런 구조로 Worker 를 설정하고, 그 다음에 클로저를 스레드로 보내는 코드를 구현하겠습니다.

  1. Define a Worker struct that holds an id and a JoinHandle<()>.
  2. Change ThreadPool to hold a vector of Worker instances.
  3. Define a Worker::new function that takes an id number and returns a Worker instance that holds the id and a thread spawned with an empty closure.
  4. In ThreadPool::new, use the for loop counter to generate an id, create a new Worker with that id, and store the Worker in the vector.

도전해 보고 싶다면 목록 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: 스레드를 직접 들고 있는 대신 Worker 인스턴스를 들도록 ThreadPool 수정하기

ThreadPool 의 필드 이름을 threads 에서 workers 로 바꿨습니다. 이제는 JoinHandle<()> 인스턴스가 아니라 Worker 인스턴스를 들고 있기 때문입니다. for 루프의 카운터를 Worker::new 의 인수로 사용하고, 새로 만든 Workerworkers 라는 벡터에 저장합니다.

외부 코드(예를 들어 src/main.rs 의 서버)는 ThreadPool 안에서 Worker 구조체를 사용한다는 구현 세부사항을 알 필요가 없습니다. 그래서 Worker 구조체와 그 new 함수는 비공개로 둡니다. Worker::new 함수는 우리가 넘긴 id 를 사용하고, 빈 클로저로 새 스레드를 생성해 얻은 JoinHandle<()> 인스턴스를 저장합니다.

Note: 운영체제가 시스템 자원이 부족해 스레드를 만들 수 없으면 thread::spawn 은 패닉합니다. 그러면 일부 스레드 생성은 성공했더라도 서버 전체가 패닉하게 됩니다. 단순한 예제로서는 이 동작도 괜찮지만, 실제 서비스용 스레드 풀 구현이라면 아마 std::thread::BuilderResult 를 반환하는 spawn 메서드를 쓰고 싶을 것입니다.

이 코드는 컴파일되고, ThreadPool::new 에 인수로 지정한 수만큼의 Worker 인스턴스를 저장합니다. 하지만 여전히 execute 에서 받은 클로저를 처리하지는 않습니다. 이제 그 방법을 살펴보겠습니다.

채널을 통해 스레드로 요청 보내기

다음으로 해결할 문제는 thread::spawn 에 넘긴 클로저들이 지금은 아무 일도 하지 않는다는 점입니다. 현재는 실행하고 싶은 클로저를 execute 메서드에서 받고 있지만, ThreadPool 생성 중 각 Worker 를 만들 때 thread::spawn 에도 실행할 클로저를 줘야 합니다.

우리는 방금 만든 Worker 구조체들이 ThreadPool 이 들고 있는 큐에서 실행할 코드를 가져와, 자신의 스레드로 보내 실행하게 만들고 싶습니다.

16장에서 배운 채널은 두 스레드 사이를 통신하는 단순한 방법으로, 이 용도에 딱 맞습니다. 채널을 작업 큐처럼 사용하고, executeThreadPool 에서 Worker 인스턴스로 작업을 보내며, Worker 는 그 작업을 자신의 스레드로 넘기게 할 것입니다. 계획은 다음과 같습니다.

  1. The ThreadPool will create a channel and hold on to the sender.
  2. Each Worker will hold on to the receiver.
  3. We’ll create a new Job struct that will hold the closures we want to send down the channel.
  4. The execute method will send the job it wants to execute through the sender.
  5. In its thread, the Worker will loop over its receiver and execute the closures of any jobs it receives.

먼저 목록 21-16처럼 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 매개변수를 참조할 것입니다. 목록 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장에서 기억하겠지만, 러스트가 제공하는 채널 구현은 다중 생산자, 단일 소비자 입니다. 즉, 이 코드를 고치기 위해 채널의 소비 쪽 끝을 단순히 복제할 수는 없습니다. 또한 우리는 여러 소비자에게 메시지를 여러 번 보내고 싶은 것도 아닙니다. 여러 Worker 인스턴스가 하나의 메시지 목록을 공유하되, 각 메시지는 한 번만 처리되길 원합니다.

추가로, 채널 큐에서 작업을 꺼내는 과정은 receiver 를 변경하는 일이므로 스레드들은 receiver 를 안전하게 공유하고 수정할 수 있는 방법이 필요합니다. 그렇지 않으면 데이터 경쟁이 생길 수 있습니다(16장에서 다뤘습니다).

16장에서 본 스레드 안전 스마트 포인터를 떠올려 보세요. 여러 스레드가 소유권을 공유하고 값을 수정할 수 있게 하려면 Arc<Mutex<T>> 를 사용해야 합니다. Arc 타입은 여러 Worker 인스턴스가 수신자를 소유하게 해주고, Mutex 는 한 번에 단 하나의 Worker 만 수신자에게서 작업을 가져가도록 보장합니다. 목록 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 도 여기서 발생할 수 있는 오류를 넘기기 위한 것인데, 이는 수신자가 종료되면 sendErr 를 반환하듯, 송신자를 들고 있던 스레드가 종료되면 일어날 수 있습니다.

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 요청이 들어와도 다른 스레드가 나머지 요청을 처리할 수 있습니다.

Note: /sleep 을 여러 브라우저 창에서 동시에 열면 5초 간격으로 하나씩 로드될 수도 있습니다. 일부 브라우저는 캐싱 때문에 같은 요청을 여러 번 보낼 때 순차적으로 실행하기도 합니다. 이 제한은 우리의 웹 서버 때문이 아닙니다.

여기서 잠시 멈추고, 21-18, 21-19, 21-20의 코드가 해야 할 일을 클로저가 아니라 future 로 표현했다면 어떻게 달라졌을지 생각해 보기 좋습니다. 어떤 타입이 바뀔까요? 메서드 시그니처는 달라질까요, 아니면 그대로일까요? 코드의 어떤 부분은 그대로 남을까요?

17장과 19장에서 while let 루프를 배운 뒤라면, 왜 Worker 스레드 코드를 목록 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> 라이프타임에 기반하기 때문입니다. 그러면 컴파일 시점에 대여 검사기가 “뮤텍스로 보호되는 자원은 락을 쥐고 있지 않으면 접근할 수 없다”는 규칙을 강제할 수 있습니다. 하지만 우리가 MutexGuard<T> 의 라이프타임을 주의하지 않으면, 이 구현은 의도보다 오래 락을 잡고 있게 만들 수도 있습니다.

목록 21-20의 let job = receiver.lock().unwrap().recv().unwrap(); 코드는 동작합니다. let 을 사용할 때는 등호 오른쪽 표현식에서 쓰인 임시 값들이 let 문이 끝나는 즉시 drop 되기 때문입니다. 하지만 while let 은(if let, match 도 마찬가지로) 연관된 블록이 끝날 때까지 임시 값을 drop 하지 않습니다. 목록 21-21에서는 job() 호출이 끝날 때까지 락이 계속 유지되므로, 다른 Worker 인스턴스들이 작업을 받을 수 없게 됩니다.

우아한 종료와 정리

우아한 종료와 정리

목록 21-20의 코드는 의도한 대로 스레드 풀을 사용해 요청에 비동기적으로 응답하고 있습니다. 다만 workers, id, thread 필드에 대해 직접 사용하지 않는다는 경고가 나오는데, 이는 우리가 아직 아무 정리 작업도 하지 않고 있다는 신호이기도 합니다. 그다지 우아하지 않은 ctrl-C 방식으로 메인 스레드를 멈추면, 다른 스레드들도 요청을 처리하는 도중이라 해도 즉시 함께 멈춥니다.

다음으로는 Drop 트레이트를 구현해서 풀 안의 각 스레드에 join 을 호출하고, 각 스레드가 작업 중인 요청을 마친 뒤 종료하게 만들겠습니다. 그다음에는 스레드들에게 새 요청 수락을 멈추고 종료하라고 알리는 방법도 구현하겠습니다. 이 코드를 실제로 보려면, 서버가 요청 두 개만 처리한 뒤 스레드 풀을 우아하게 종료하도록 바꿔 보겠습니다.

진행하면서 주목할 점이 하나 있습니다. 여기서 하는 일은 클로저 실행을 담당하는 코드에는 아무 영향도 주지 않습니다. 따라서 async 런타임용 스레드 풀을 쓰고 있었다 해도 이 부분은 모두 동일했을 것입니다.

ThreadPoolDrop 트레이트 구현하기

먼저 스레드 풀에 Drop 을 구현하는 것부터 시작합시다. 풀이 drop 될 때는 모든 스레드가 작업을 끝낼 수 있도록 join 해야 합니다. 목록 21-22는 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 가 가변 참조이고 worker 도 변경할 수 있어야 하므로 &mut 를 사용합니다. 각 worker 에 대해, 해당 Worker 인스턴스가 종료 중이라는 메시지를 출력한 다음 그 Worker 의 스레드에 join 을 호출합니다. join 호출이 실패하면 unwrap 으로 러스트가 패닉하게 만들어, 우아하지 않은 종료로 들어가게 합니다.

이 코드를 컴파일하면 다음 오류가 납니다.

$ 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/1159e78c4747b02ef996e55082b704c09b970588/library/std/src/thread/mod.rs:1921: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 을 갖고 있다가, 정리하고 싶을 때 SomeNone 으로 바꿔 더 이상 실행할 스레드가 없게 만드는 방식입니다.

하지만 이 상황은 Worker 가 drop 될 때에만 필요합니다. 그 대가로, worker.thread 에 접근하는 곳마다 Option<thread::JoinHandle<()>> 를 다뤄야 합니다. 관용적인 러스트는 Option 을 자주 쓰지만, 이렇게 우회책으로 “항상 값이 있다고 알고 있는 것”을 Option 으로 감싸고 있다면 코드를 더 깔끔하고 오류가 덜 나게 만드는 다른 접근을 찾아보는 편이 좋습니다.

이 경우에는 더 나은 대안이 있습니다. Vec::drain 메서드입니다. 이는 벡터에서 제거할 항목 범위를 나타내는 매개변수를 받고, 제거된 항목들의 반복자를 반환합니다. .. 범위 문법을 넘기면 벡터 안의 모든 값을 제거합니다.

따라서 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 }
    }
}
}

이렇게 하면 컴파일 오류가 해결되고, 다른 코드 변경도 필요 없습니다. 다만 drop 은 패닉 중에도 호출될 수 있으므로, 여기의 unwrap 이 다시 패닉해서 이중 패닉을 만들 수 있고, 그러면 프로그램이 즉시 크래시하면서 진행 중인 정리가 모두 중단됩니다. 예제 프로그램으로는 괜찮지만, 실제 서비스 코드에서는 권장되지 않습니다.

스레드에게 더 이상 작업을 기다리지 말라고 알리기

지금까지의 변경으로 코드는 경고 없이 컴파일됩니다. 하지만 안 좋은 소식은, 아직 우리가 원하는 방식으로 동작하지는 않는다는 점입니다. 핵심은 Worker 인스턴스의 스레드가 실행하는 클로저 로직에 있습니다. 지금은 join 을 호출하지만, 스레드들은 작업을 찾으려고 영원히 loop 를 돌기 때문에 이걸로는 종료되지 않습니다. 현재의 drop 구현으로 ThreadPool 을 drop 하려고 하면, 메인 스레드는 첫 번째 스레드가 끝나기를 영원히 기다리며 막히게 됩니다.

이 문제를 해결하려면 ThreadPooldrop 구현을 먼저 바꾸고, 그 다음 Worker 루프도 바꿔야 합니다.

먼저 ThreadPooldrop 구현에서 스레드들이 끝나기를 기다리기 전에 sender 를 명시적으로 drop 하도록 바꾸겠습니다. 목록 21-23은 sender 를 명시적으로 drop 하도록 바꾼 ThreadPool 코드를 보여 줍니다. 스레드와 달리 이 경우에는 Option::takesenderThreadPool 밖으로 꺼내려면 정말로 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 를 명시적으로 drop 하기

sender 를 drop 하면 채널이 닫히고, 더 이상 메시지가 오지 않을 것이라는 뜻이 됩니다. 그러면 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 트레이트에 정의되어 있으며, 반복을 최대 처음 두 항목으로 제한합니다. main 끝에서 ThreadPool 은 스코프를 벗어나고, 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이 처음 두 요청을 받았습니다. 서버는 두 번째 연결 이후 더 이상 연결을 받지 않았고, Worker 3 이 자기 작업을 시작하기도 전에 ThreadPoolDrop 구현이 실행되기 시작합니다. sender 를 drop 하면 모든 Worker 인스턴스와의 연결이 끊기고, 종료하라는 신호가 전달됩니다. 각 Worker 는 연결이 끊겼다는 메시지를 출력하고, 그 뒤 스레드 풀이 각 Worker 스레드가 끝날 때까지 join 으로 기다립니다.

이 실행에서 흥미로운 점이 하나 있습니다. ThreadPoolsender 를 drop 한 뒤, 아직 어떤 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와 견고함을 우리가 구현한 스레드 풀과 비교하기

마무리

잘 해냈습니다! 드디어 책의 끝에 도달했습니다. 러스트 여행을 함께해 주셔서 감사합니다. 이제 여러분은 자신만의 러스트 프로젝트를 구현하고, 다른 사람들의 프로젝트를 돕기 시작할 준비가 되었습니다. 러스트 여정에서 마주칠 어떤 어려움이든 기꺼이 도와주려는 친근한 러스타시안 공동체가 있다는 점도 기억해 두세요.

부록

다음 절들에는 러스트를 배우고 사용하는 여정에서 유용할 수 있는 참고 자료가 들어 있습니다.

A - 키워드

부록 A: 키워드

다음 목록에는 러스트 언어가 현재 또는 미래에 사용할 목적으로 예약해 둔 키워드가 들어 있습니다. 따라서 이 단어들은 식별자로 사용할 수 없습니다(“로우 식별자” 절에서 다루는 로우 식별자는 예외입니다). 식별자 는 함수, 변수, 매개변수, 구조체 필드, 모듈, 크레이트, 상수, 매크로, 정적 값, 애트리뷰트, 타입, 트레이트, 라이프타임의 이름을 말합니다.

현재 사용 중인 키워드

다음은 현재 사용 중인 키워드 목록과 그 기능 설명입니다.

  • as: 기본 타입 캐스팅을 수행하거나, 특정 항목을 담고 있는 정확한 트레이트를 구분하거나, use 문에서 항목 이름을 바꿉니다.
  • async: 현재 스레드를 막지 않고 Future 를 반환합니다.
  • await: Future 의 결과가 준비될 때까지 실행을 중단합니다.
  • break: 루프를 즉시 빠져나옵니다.
  • const: 상수 항목이나 상수 raw 포인터를 정의합니다.
  • continue: 다음 루프 반복으로 넘어갑니다.
  • crate: 모듈 경로 안에서 크레이트 루트를 가리킵니다.
  • dyn: 트레이트 객체에 대해 동적 디스패치를 수행합니다.
  • else: if, if let 제어 흐름 구문의 대체 경로를 나타냅니다.
  • enum: 열거형을 정의합니다.
  • extern: 외부 함수나 변수를 연결합니다.
  • false: 불리언 거짓 리터럴입니다.
  • fn: 함수를 정의하거나 함수 포인터 타입을 정의합니다.
  • for: 반복자에서 항목을 순회하거나, 트레이트를 구현하거나, higher ranked lifetime 을 지정합니다.
  • if: 조건식 결과에 따라 분기합니다.
  • impl: 고유 메서드나 트레이트 기능을 구현합니다.
  • in: for 루프 문법의 일부입니다.
  • let: 변수를 바인딩합니다.
  • loop: 무조건 반복합니다.
  • match: 값을 패턴과 매칭합니다.
  • mod: 모듈을 정의합니다.
  • move: 클로저가 캡처한 모든 값을 소유하도록 만듭니다.
  • mut: 참조, raw 포인터, 패턴 바인딩의 가변성을 나타냅니다.
  • pub: 구조체 필드, impl 블록, 모듈의 공개 가시성을 나타냅니다.
  • ref: 참조로 바인딩합니다.
  • return: 함수에서 반환합니다.
  • Self: 현재 정의하거나 구현 중인 타입을 가리키는 타입 별칭입니다.
  • self: 메서드의 대상이거나 현재 모듈을 가리킵니다.
  • static: 전역 변수이거나 프로그램 전체 실행 동안 지속되는 라이프타임을 나타냅니다.
  • struct: 구조체를 정의합니다.
  • super: 현재 모듈의 부모 모듈을 가리킵니다.
  • trait: 트레이트를 정의합니다.
  • true: 불리언 참 리터럴입니다.
  • type: 타입 별칭이나 연관 타입을 정의합니다.
  • union: 유니언 을 정의합니다. 유니언 선언에서 사용할 때만 키워드입니다.
  • unsafe: unsafe 코드, 함수, 트레이트, 구현을 나타냅니다.
  • use: 심벌을 현재 스코프로 가져옵니다.
  • where: 타입을 제한하는 절을 나타냅니다.
  • while: 표현식 결과에 따라 조건부로 반복합니다.

미래 사용을 위해 예약된 키워드

다음 키워드들은 아직 기능은 없지만, 러스트가 미래에 사용할 가능성에 대비해 예약해 두었습니다.

  • abstract
  • become
  • box
  • do
  • final
  • gen
  • macro
  • override
  • priv
  • try
  • typeof
  • unsized
  • virtual
  • yield

로우 식별자

로우 식별자 는 원래는 허용되지 않는 위치에서 키워드를 사용할 수 있게 해 주는 문법입니다. 키워드 앞에 r# 를 붙여 사용합니다.

예를 들어 match 는 키워드입니다. 다음처럼 match 를 함수 이름으로 사용하는 코드를 컴파일하려 하면,

Filename: 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 를 함수 이름으로 쓰려면 다음처럼 로우 식별자 문법을 사용해야 합니다.

Filename: src/main.rs

fn r#match(needle: &str, haystack: &str) -> bool {
    haystack.contains(needle)
}

fn main() {
    assert!(r#match("foo", "foobar"));
}

이 코드는 오류 없이 컴파일됩니다. 함수 정의에서도, main 에서 함수를 호출하는 곳에서도 함수 이름 앞에 r# 접두사가 붙는다는 점에 주목하세요.

로우 식별자를 사용하면, 그 단어가 예약 키워드라 해도 원하는 어떤 단어든 식별자로 쓸 수 있습니다. 덕분에 식별자 이름을 더 자유롭게 지을 수 있고, 해당 단어들이 키워드가 아닌 언어로 작성된 프로그램과도 더 쉽게 통합할 수 있습니다. 또한 로우 식별자는 현재 크레이트와 다른 러스트 에디션으로 작성된 라이브러리를 사용할 수 있게 도와줍니다. 예를 들어 try 는 2015 에디션에서는 키워드가 아니지만 2018, 2021, 2024 에디션에서는 키워드입니다. 만약 2015 에디션으로 작성된 라이브러리에 try 함수가 있고 그 라이브러리에 의존한다면, 이후 에디션의 코드에서는 그 함수를 호출할 때 r#try 처럼 로우 식별자 문법을 써야 합니다. 에디션에 대한 자세한 내용은 부록 E 를 참고하세요.

B - 연산자와 기호

부록 B: 연산자와 기호

이 부록에는 러스트 문법에 대한 용어집이 들어 있습니다. 연산자뿐 아니라 경로, 제네릭, 트레이트 바운드, 매크로, 애트리뷰트, 주석, 튜플, 괄호 문맥에서 사용되는 다른 기호들도 포함합니다.

연산자

표 B-1에는 러스트의 연산자와, 그 연산자가 문맥 속에서 어떻게 나타나는지에 대한 예, 짧은 설명, 그리고 오버로딩 가능 여부가 들어 있습니다. 어떤 연산자가 오버로딩 가능하다면, 그 연산자를 오버로딩할 때 사용하는 관련 트레이트도 함께 적었습니다.

표 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 typeraw 포인터
+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, and so on튜플 인덱싱
...., 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 => exprmatch 팔 문법의 일부
>expr > expr크다 비교PartialOrd
>=expr >= expr크거나 같다 비교PartialOrd
>>expr >> expr오른쪽 시프트Shr
>>=var >>= expr오른쪽 시프트 후 대입ShrAssign
@ident @ pat패턴 바인딩
^expr ^ expr비트 XORBitXor
^=var ^= expr비트 XOR 후 대입BitXorAssign
|pat | pat패턴 대안
|expr | expr비트 ORBitOr
|=var |= expr비트 OR 후 대입BitOrAssign
||expr || expr단락 평가되는 논리 OR
?expr?오류 전파

비연산자 기호

다음 표들에는 연산자로 기능하지 않는 모든 기호가 들어 있습니다. 즉, 함수 호출이나 메서드 호출처럼 동작하지 않는 기호들입니다.

표 B-2는 단독으로 나타나며 여러 위치에서 유효한 기호를 보여 줍니다.

표 B-2: 단독 문법

기호설명
'ident이름 있는 라이프타임 또는 루프 레이블
u8, i32, f64, usize 등이 바로 뒤따르는 숫자특정 타입의 숫자 리터럴
"..."문자열 리터럴
r"...", r#"..."#, r##"..."##로우 문자열 리터럴, 이스케이프 문자를 처리하지 않음
b"..."바이트 문자열 리터럴, 문자열 대신 바이트 배열을 생성
br"...", br#"..."#, br##"..."##로우 바이트 문자열 리터럴, 로우 문자열과 바이트 문자열의 결합
'...'문자 리터럴
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::<...>표현식 안에서 제네릭 타입, 함수, 메서드의 매개변수를 지정함. 흔히 turbofish 라고 부름(예: "42".parse::<i32>())
fn ident<...> ...제네릭 함수 정의
struct ident<...> ...제네릭 구조체 정의
enum ident<...> ...제네릭 열거형 정의
impl<...> ...제네릭 구현 정의
for<...> typehigher ranked lifetime 바운드
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: 소괄호

기호설명
()빈 튜플(즉 unit), 리터럴이자 타입
(expr)괄호로 감싼 표현식
(expr,)원소가 하나인 튜플 표현식
(type,)원소가 하나인 튜플 타입
(expr, ...)튜플 표현식
(type, ...)튜플 타입
expr(expr, ...)함수 호출 표현식, 튜플 구조체와 튜플 열거형 variant 초기화에도 사용됨

표 B-9는 중괄호가 사용되는 문맥을 보여 줍니다.

표 B-9: 중괄호

문맥설명
{...}블록 표현식
Type {...}구조체 리터럴

표 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 - 파생 가능한 트레이트

부록 C: 파생 가능한 트레이트

책의 여러 곳에서 구조체나 열거형 정의에 적용할 수 있는 derive 애트리뷰트를 다뤘습니다. derive 애트리뷰트는 derive 문법으로 표시된 타입에 대해, 기본 구현을 가진 트레이트 구현 코드를 자동으로 생성합니다.

이 부록에서는 derive 와 함께 사용할 수 있는 표준 라이브러리의 모든 트레이트를 참고용으로 정리합니다. 각 절에서는 다음 내용을 다룹니다.

  • 이 트레이트를 파생하면 어떤 연산자와 메서드를 쓸 수 있게 되는지
  • derive 가 제공하는 트레이트 구현이 실제로 무엇을 하는지
  • 그 트레이트를 구현한다는 것이 타입에 대해 무엇을 의미하는지
  • 그 트레이트를 구현해도 되는 조건과 구현하면 안 되는 조건
  • 그 트레이트가 필요한 연산의 예

derive 가 제공하는 기본 동작과 다른 동작이 필요하다면, 각 트레이트를 수동으로 구현하는 방법에 대한 자세한 내용은 표준 라이브러리 문서 를 참고하세요.

여기에 나열된 트레이트는 표준 라이브러리에서 정의된 것 중, derive 를 사용해 여러분의 타입에 구현할 수 있는 유일한 트레이트들입니다. 표준 라이브러리의 다른 트레이트들은 그럴듯한 기본 동작을 갖기 어렵기 때문에, 여러분이 만들고자 하는 것에 맞게 직접 구현해야 합니다.

파생할 수 없는 트레이트의 예로는 최종 사용자용 포매팅을 담당하는 Display 가 있습니다. 타입을 최종 사용자에게 어떻게 보여 줄지 늘 고민해야 합니다. 타입의 어떤 부분을 최종 사용자에게 보여 줄 수 있을까요? 그들이 관련 있다고 느낄 부분은 무엇일까요? 어떤 데이터 형식이 가장 적절할까요? 러스트 컴파일러는 이런 맥락을 알지 못하므로, 적절한 기본 동작을 대신 제공할 수 없습니다.

이 부록의 파생 가능 트레이트 목록이 전부를 뜻하는 것은 아닙니다. 라이브러리는 자신만의 트레이트에 대해서도 derive 를 구현할 수 있으므로, derive 와 함께 쓸 수 있는 트레이트 목록은 사실상 열려 있습니다. derive 구현에는 프로시저 매크로가 사용되며, 이는 20장의 “커스텀 derive 매크로” 절에서 다룹니다.

프로그래머 출력을 위한 Debug

Debug 트레이트는 포맷 문자열 안에서 디버그 포매팅을 가능하게 하며, {} 자리표시자 안에 :? 를 넣어 사용합니다.

Debug 트레이트는 타입 인스턴스를 디버깅 목적으로 출력할 수 있게 해 주므로, 여러분과 여러분의 타입을 사용하는 다른 프로그래머가 프로그램 실행 중 특정 시점의 인스턴스를 살펴볼 수 있습니다.

예를 들어 assert_eq! 매크로를 사용하려면 Debug 트레이트가 필요합니다. 이 매크로는 같음 비교가 실패할 경우 인수로 전달된 인스턴스들의 값을 출력해, 두 인스턴스가 왜 같지 않았는지 프로그래머가 알 수 있게 해 줍니다.

동등성 비교를 위한 PartialEqEq

PartialEq 트레이트는 타입 인스턴스끼리의 동등성을 비교할 수 있게 해 주며, ==!= 연산자를 사용할 수 있게 합니다.

PartialEq 를 파생하면 eq 메서드가 구현됩니다. 구조체에 PartialEq 를 파생하면, 모든 필드가 같을 때만 두 인스턴스가 같고, 어느 하나라도 필드가 다르면 두 인스턴스는 같지 않다고 판단합니다. 열거형에 파생하면 각 variant 는 자기 자신과만 같고 다른 variant 와는 같지 않습니다.

예를 들어 assert_eq! 매크로를 쓰려면 PartialEq 트레이트가 필요합니다. 이 매크로는 두 타입 인스턴스가 같은지 비교할 수 있어야 하기 때문입니다.

Eq 트레이트는 메서드를 갖지 않습니다. 목적은 “표시된 타입의 모든 값은 자기 자신과 같다”는 사실을 나타내는 것입니다. EqPartialEq 를 구현한 타입에만 적용할 수 있지만, PartialEq 를 구현한 모든 타입이 Eq 를 구현할 수 있는 것은 아닙니다. 대표적인 예가 부동소수점 타입입니다. 부동소수점의 정의에 따르면, 숫자가 아님을 나타내는 NaN 값 두 개는 서로 같지 않습니다.

Eq 가 필요한 경우의 예로는 HashMap<K, V> 의 키가 있습니다. HashMap<K, V> 가 두 키가 같은지 판단할 수 있어야 하기 때문입니다.

순서 비교를 위한 PartialOrdOrd

PartialOrd 트레이트는 타입 인스턴스를 정렬 목적에 맞게 비교할 수 있게 합니다. PartialOrd 를 구현한 타입은 <, >, <=, >= 연산자와 함께 사용할 수 있습니다. PartialOrdPartialEq 도 구현한 타입에만 적용할 수 있습니다.

PartialOrd 를 파생하면 partial_cmp 메서드가 구현되며, 이는 Option<Ordering> 을 반환합니다. 주어진 두 값이 순서를 만들 수 없으면 None 이 됩니다. 대부분의 값은 비교 가능하지만 순서를 만들 수 없는 값의 예로는 부동소수점 NaN 이 있습니다. 어떤 부동소수점 수와 NaN 에 대해 partial_cmp 를 호출하면 None 이 반환됩니다.

구조체에 파생한 경우 PartialOrd 는 구조체 정의에 필드가 나타나는 순서대로 각 필드의 값을 비교합니다. 열거형에 파생한 경우에는, 열거형 정의에서 더 먼저 선언된 variant 가 더 뒤에 선언된 variant 보다 작다고 간주됩니다.

예를 들어 rand 크레이트의 gen_range 메서드는 범위 표현식으로 지정한 범위 안의 임의 값을 만들기 때문에 PartialOrd 트레이트가 필요합니다.

Ord 트레이트는 표시된 타입의 임의의 두 값에 대해 언제나 유효한 순서가 존재함을 알려 줍니다. Ord 트레이트는 cmp 메서드를 구현하는데, 유효한 순서가 항상 존재하므로 Option<Ordering> 이 아니라 Ordering 을 반환합니다. OrdPartialOrdEq 를 모두 구현한 타입에만 적용할 수 있습니다(EqPartialEq 를 요구합니다). 구조체와 열거형에 파생한 경우, cmpPartialOrd 의 파생 구현에서 partial_cmp 가 하는 것과 같은 방식으로 동작합니다.

Ord 가 필요한 예로는 값의 정렬 순서에 기반해 데이터를 저장하는 자료구조인 BTreeSet<T> 에 값을 저장할 때가 있습니다.

값 복제를 위한 CloneCopy

Clone 트레이트는 값의 깊은 복사를 명시적으로 만들 수 있게 하며, 이 복제 과정에는 임의의 코드 실행이나 힙 데이터 복사가 포함될 수 있습니다. Clone 에 대한 자세한 내용은 4장의 “변수와 데이터가 Clone 과 상호작용하는 방식” 절을 참고하세요.

Clone 을 파생하면 clone 메서드가 구현되는데, 타입 전체에 대해 구현할 때 타입의 각 부분에 대해 clone 을 호출합니다. 즉, 타입의 모든 필드 또는 값이 Clone 을 구현하고 있어야 Clone 을 파생할 수 있습니다.

Clone 이 필요한 예로는 슬라이스에서 to_vec 메서드를 호출할 때가 있습니다. 슬라이스는 내부의 타입 인스턴스를 소유하지 않지만, to_vec 가 반환하는 벡터는 그 인스턴스들을 소유해야 하므로 각 항목에 대해 clone 을 호출합니다. 따라서 슬라이스에 저장된 타입은 Clone 을 구현해야 합니다.

Copy 트레이트는 스택에 저장된 비트만 복사해서 값을 복제할 수 있게 합니다. 임의의 코드를 실행할 필요가 없습니다. Copy 에 대한 더 자세한 설명은 4장의 “스택에만 있는 데이터: Copy 절을 참고하세요.

Copy 트레이트는 어떤 메서드도 정의하지 않습니다. 프로그래머가 메서드를 오버로드해 “임의의 코드가 실행되지 않는다”는 가정을 깨뜨리지 못하게 하기 위함입니다. 덕분에 모든 프로그래머는 값 복사가 매우 빠르다고 가정할 수 있습니다.

모든 부분이 Copy 를 구현하는 타입이라면 어떤 타입에도 Copy 를 파생할 수 있습니다. Copy 를 구현하는 타입은 반드시 Clone 도 구현해야 합니다. Copy 를 구현한 타입의 Clone 구현은 Copy 와 동일한 작업을 하는 아주 단순한 구현이기 때문입니다.

Copy 트레이트가 반드시 필요한 경우는 드뭅니다. Copy 를 구현하는 타입은 최적화 이점을 얻기 때문에 clone 을 호출하지 않아도 되어 코드가 더 간결해집니다.

Copy 로 가능한 일은 모두 Clone 으로도 할 수 있지만, 코드는 더 느려질 수 있고 어떤 곳에서는 clone 을 직접 써야 할 수도 있습니다.

값을 고정 크기의 값으로 매핑하기 위한 Hash

Hash 트레이트는 크기가 임의인 타입의 인스턴스를 받아, 해시 함수를 사용해 고정된 크기의 값으로 매핑할 수 있게 합니다. Hash 를 파생하면 hash 메서드가 구현됩니다. 파생된 hash 구현은 타입의 각 부분에 대해 hash 를 호출한 결과를 결합하므로, 모든 필드나 값도 Hash 를 구현하고 있어야 Hash 를 파생할 수 있습니다.

Hash 가 필요한 예로는 데이터를 효율적으로 저장하기 위해 HashMap<K, V> 의 키를 저장할 때가 있습니다.

기본값을 위한 Default

Default 트레이트는 타입의 기본값을 만들 수 있게 합니다. Default 를 파생하면 default 함수가 구현됩니다. 파생된 default 구현은 타입의 각 부분에 대해 default 를 호출하므로, 타입의 모든 필드나 값도 Default 를 구현해야 Default 를 파생할 수 있습니다.

Default::default 함수는 5장의 “구조체 업데이트 문법으로 다른 인스턴스에서 인스턴스 만들기” 절에서 본 구조체 업데이트 문법과 함께 자주 사용됩니다. 구조체 필드 몇 개만 커스터마이즈한 뒤, ..Default::default() 를 사용해 나머지 필드에는 기본값을 넣을 수 있습니다.

예를 들어 Option<T> 인스턴스에 unwrap_or_default 메서드를 사용할 때 Default 트레이트가 필요합니다. Option<T>None 이라면, unwrap_or_defaultOption<T> 안에 들어 있던 타입 T 에 대해 Default::default 의 결과를 반환합니다.

D - 유용한 개발 도구

부록 D: 유용한 개발 도구

이 부록에서는 러스트 프로젝트가 제공하는 유용한 개발 도구 몇 가지를 살펴봅니다. 자동 포매팅, 경고 수정 사항을 빠르게 적용하는 방법, 린터, IDE 통합을 다룰 것입니다.

rustfmt 로 자동 포매팅하기

rustfmt 도구는 커뮤니티 코드 스타일에 맞게 코드를 다시 포맷합니다. 많은 협업 프로젝트가 러스트 코드를 작성할 때 어떤 스타일을 쓸지 논쟁하지 않기 위해 rustfmt 를 사용합니다. 모두가 이 도구로 코드를 포맷하면 되기 때문입니다.

러스트 설치에는 기본적으로 rustfmt 가 포함되어 있으므로, 시스템에는 이미 rustfmtcargo-fmt 프로그램이 있어야 합니다. 이 둘의 관계는 rustccargo 에 비슷합니다. rustfmt 는 더 세밀한 제어를 제공하고, cargo-fmt 는 Cargo를 사용하는 프로젝트의 관례를 이해합니다. 어떤 Cargo 프로젝트든 포맷하려면 다음을 입력하세요.

$ cargo fmt

이 명령을 실행하면 현재 크레이트의 모든 러스트 코드가 다시 포맷됩니다. 코드는 의미적으로는 바뀌지 않고 스타일만 바뀌어야 합니다. rustfmt 에 대한 자세한 내용은 공식 문서 를 참고하세요.

rustfix 로 코드 고치기

rustfix 도구도 러스트 설치에 포함되어 있으며, 문제를 고치는 방법이 분명한 컴파일러 경고를 자동으로 수정해 줍니다. 이미 컴파일러 경고를 본 적이 있을 것입니다. 예를 들어 다음 코드를 생각해 봅시다.

Filename: src/main.rs

fn main() {
    let mut x = 42;
    println!("{x}");
}

여기서는 변수 x 를 가변으로 정의했지만, 실제로는 한 번도 변경하지 않습니다. 러스트는 이에 대해 경고를 보여 줍니다.

$ 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 키워드를 제거하라고 제안합니다. rustfix 도구를 사용하면 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 가 코드를 바꾼 것을 확인할 수 있습니다.

Filename: src/main.rs

fn main() {
    let x = 42;
    println!("{x}");
}

이제 변수 x 는 불변이고, 경고도 더 이상 나타나지 않습니다.

cargo fix 명령은 서로 다른 러스트 에디션 사이로 코드를 전환할 때도 사용할 수 있습니다. 에디션은 부록 E 에서 다룹니다.

Clippy로 더 많은 린트 보기

Clippy 도구는 코드를 분석하는 린트 모음입니다. 흔한 실수를 잡고 러스트 코드를 개선하는 데 도움을 줍니다. Clippy 역시 표준 러스트 설치에 포함되어 있습니다.

어떤 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

이 오류는 러스트가 이미 더 정확한 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 통합

IDE 통합을 위해 러스트 커뮤니티는 rust-analyzer 사용을 권장합니다. 이 도구는 Language Server Protocol 을 사용하는 컴파일러 중심 유틸리티 모음입니다. Language Server Protocol 은 IDE 와 프로그래밍 언어가 서로 통신하기 위한 명세입니다. 예를 들어 Visual Studio Code용 Rust analyzer 플러그인 같은 다양한 클라이언트가 rust-analyzer 를 사용할 수 있습니다.

설치 방법은 rust-analyzer 프로젝트의 홈페이지 를 참고하고, 사용하는 IDE에 맞는 언어 서버 지원도 함께 설치하세요. 그러면 IDE에서 자동 완성, 정의로 이동, 인라인 오류 표시 같은 기능을 사용할 수 있게 됩니다.

E - 에디션

부록 E: 에디션

1장에서 cargo newCargo.toml 파일에 에디션 관련 메타데이터를 조금 추가하는 것을 보았습니다. 이 부록에서는 그 의미를 설명합니다.

러스트 언어와 컴파일러는 6주 릴리스 주기를 가집니다. 즉, 사용자는 새 기능을 꾸준히 받게 됩니다. 다른 프로그래밍 언어는 더 큰 변화를 더 드물게 릴리스하는 경우가 많지만, 러스트는 더 작은 업데이트를 더 자주 릴리스합니다. 시간이 지나면 이런 작은 변화들이 쌓입니다. 그러나 릴리스와 릴리스 사이만 비교하면 “Rust 1.10에서 1.31 사이에 러스트가 정말 많이 변했구나!”라고 실감하기는 어렵습니다.

러스트 팀은 대략 3년마다 새로운 러스트 에디션 을 만듭니다. 각 에디션은 그동안 도입된 기능들을, 완전히 업데이트된 문서와 도구를 함께 갖춘 명확한 패키지로 묶어 제공합니다. 새 에디션 역시 평소의 6주 릴리스 과정의 일부로 배포됩니다.

에디션은 사람마다 다른 목적을 가집니다.

  • 활발히 러스트를 쓰는 사람에게는 점진적 변화들을 이해하기 쉬운 패키지로 묶어 줍니다.
  • 아직 러스트를 쓰지 않는 사람에게는 큰 진전이 있었다는 신호가 되어, 다시 한번 러스트를 살펴볼 이유가 될 수 있습니다.
  • 러스트를 개발하는 사람들에게는 프로젝트 전체의 결집점을 제공합니다.

이 글을 쓰는 시점에는 네 가지 러스트 에디션이 있습니다. Rust 2015, Rust 2018, Rust 2021, Rust 2024 입니다. 이 책은 Rust 2024 에디션 스타일을 기준으로 작성되었습니다.

Cargo.tomledition 키는 컴파일러가 여러분의 코드에 어떤 에디션을 적용해야 하는지를 나타냅니다. 이 키가 없으면 하위 호환성 때문에 러스트는 2015 를 에디션 값으로 사용합니다.

각 프로젝트는 기본값인 2015 에디션이 아닌 다른 에디션을 선택해 사용할 수 있습니다. 에디션에는 코드 안의 식별자와 충돌하는 새 키워드 추가처럼 호환되지 않는 변화가 들어갈 수 있습니다. 하지만 그런 변화를 직접 opt-in 하지 않는 한, 사용하는 러스트 컴파일러 버전을 올려도 여러분의 코드는 계속 컴파일됩니다.

모든 러스트 컴파일러 버전은 자신이 릴리스되기 이전에 존재하던 모든 에디션을 지원하며, 지원되는 어떤 에디션의 크레이트끼리도 함께 링크할 수 있습니다. 에디션 변화는 컴파일러가 코드를 처음 파싱하는 방식에만 영향을 줍니다. 따라서 여러분이 Rust 2015 를 쓰고 있고 의존성 하나가 Rust 2018 을 쓴다 해도 프로젝트는 컴파일되고 그 의존성을 사용할 수 있습니다. 반대로 프로젝트가 Rust 2018 을 쓰고 의존성이 Rust 2015 를 쓰는 경우도 마찬가지입니다.

분명히 해 두자면, 대부분의 기능은 모든 에디션에서 사용할 수 있습니다. 어떤 러스트 에디션을 사용하든 개발자는 새로운 안정 릴리스가 나올 때마다 계속 개선 사항을 누릴 수 있습니다. 다만 경우에 따라, 특히 새 키워드가 추가될 때는 일부 새 기능이 더 나중 에 나온 에디션에서만 제공될 수 있습니다. 그런 기능을 활용하고 싶다면 에디션을 전환해야 합니다.

더 자세한 내용은 The Rust Edition Guide 를 참고하세요. 이 문서는 에디션 사이의 차이를 정리하고, cargo fix 로 코드를 새 에디션으로 자동 업그레이드하는 방법까지 설명하는 완전한 책입니다.

F - 책의 번역본

부록 F: 책의 번역본

영어 외 언어로 제공되는 자료들입니다. 대부분은 아직 진행 중입니다. 도움을 주거나 새 번역본을 알려 주고 싶다면 Translations 레이블 을 참고하세요.

G - 러스트가 만들어지는 방식과 “Nightly Rust”

부록 G - 러스트가 만들어지는 방식과 “Nightly Rust”

이 부록은 러스트가 어떻게 만들어지며, 그 과정이 러스트 개발자인 여러분에게 어떤 영향을 주는지에 관한 내용입니다.

정체 없는 안정성

러스트는 언어로서 여러분 코드의 안정성을 매우 중요하게 생각합니다. 러스트가 튼튼한 기반이 되기를 바라며, 모든 것이 계속 바뀐다면 그런 기반이 될 수 없습니다. 동시에, 새 기능을 실험할 수 없다면 중요한 결함을 릴리스 뒤에야 발견하게 될 수 있고, 그때는 이미 바꿀 수 없을지도 모릅니다.

이 문제에 대한 러스트의 해법이 바로 “정체 없는 안정성(stability without stagnation)” 입니다. 그리고 우리의 기본 원칙은 다음과 같습니다. 안정 버전의 새 러스트로 업그레이드하는 일을 절대로 두려워할 필요가 없어야 한다. 각 업그레이드는 고통 없이 이루어져야 하며, 동시에 새 기능, 더 적은 버그, 더 빠른 컴파일 시간을 가져와야 한다.

칙칙폭폭! 릴리스 채널과 기차 타기

러스트 개발은 기차 시간표 에 맞춰 이루어집니다. 즉, 모든 개발은 러스트 저장소의 메인 브랜치에서 이뤄집니다. 릴리스는 Cisco IOS 와 다른 소프트웨어 프로젝트에서도 사용된 소프트웨어 릴리스 트레인 모델을 따릅니다. 러스트에는 세 가지 릴리스 채널 이 있습니다.

  • Nightly
  • Beta
  • Stable

대부분의 러스트 개발자는 주로 안정 채널을 사용하지만, 실험적인 새 기능을 써 보고 싶은 사람은 nightly 나 beta 를 사용할 수 있습니다.

개발과 릴리스 과정이 어떻게 돌아가는지 예를 들어 봅시다. 러스트 팀이 Rust 1.5 릴리스를 작업 중이라고 가정합시다. Rust 1.5 는 2015년 12월에 나왔지만, 여기서는 현실적인 버전 번호를 제공하는 예시로 쓰겠습니다. 러스트에 새 기능 하나가 추가됩니다. 새 커밋이 메인 브랜치에 들어갑니다. 매일 밤 새로운 nightly 러스트 버전이 만들어집니다. 매일이 릴리스 날이고, 이 릴리스들은 릴리스 인프라가 자동으로 만듭니다. 시간이 지나면 릴리스는 밤마다 대략 이렇게 보입니다.

nightly: * - - * - - *

6주가 지나면 새 릴리스를 준비할 때가 됩니다! 러스트 저장소의 beta 브랜치는 nightly 가 사용하는 메인 브랜치에서 갈라집니다. 이제 릴리스는 두 개가 됩니다.

nightly: * - - * - - *
                     |
beta:                *

대부분의 러스트 사용자는 beta 릴리스를 적극적으로 쓰지는 않지만, CI 시스템에서 beta 를 대상으로 테스트해 러스트가 회귀 버그를 미리 발견하도록 돕습니다. 그 사이에도 nightly 릴리스는 매일 밤 계속 나옵니다.

nightly: * - - * - - * - - * - - *
                     |
beta:                *

회귀 버그가 하나 발견되었다고 합시다. 다행히 그 버그가 안정 릴리스에 숨어들기 전에 beta 를 시험해 볼 시간이 있었습니다! 수정은 메인 브랜치에 먼저 적용되어 nightly 가 고쳐지고, 그다음 beta 브랜치로 백포트되어 새 beta 릴리스가 만들어집니다.

nightly: * - - * - - * - - * - - * - - *
                     |
beta:                * - - - - - - - - *

첫 beta 가 만들어지고 6주가 지나면 안정 릴리스 시점입니다! stable 브랜치는 beta 브랜치에서 만들어집니다.

nightly: * - - * - - * - - * - - * - - * - * - *
                     |
beta:                * - - - - - - - - *
                                       |
stable:                                *

좋습니다! Rust 1.5 가 완성되었습니다! 하지만 잊은 것이 하나 있습니다. 6주가 지났으니 이제 다음 버전인 Rust 1.6의 새 beta 도 필요합니다. 따라서 stablebeta 에서 갈라져 나온 뒤, 다음 버전의 beta 는 다시 nightly 에서 갈라집니다.

nightly: * - - * - - * - - * - - * - - * - * - *
                     |                         |
beta:                * - - - - - - - - *       *
                                       |
stable:                                *

이것을 “기차 모델”이라고 부르는 이유는, 6주마다 릴리스 하나가 “역을 떠나지만”, 안정 릴리스로 도착하기 전에는 반드시 beta 채널을 거쳐 가야 하기 때문입니다.

러스트는 시계처럼 정확하게 6주마다 릴리스됩니다. 러스트 릴리스 하나의 날짜를 알고 있다면 다음 릴리스 날짜도 알 수 있습니다. 정확히 6주 뒤입니다. 6주마다 릴리스가 예약되어 있다는 점의 좋은 점은 다음 기차가 곧 온다는 것입니다. 어떤 기능이 특정 릴리스를 놓치더라도 걱정할 필요가 없습니다. 곧 다음 릴리스가 오기 때문입니다. 덕분에 릴리스 직전 미완성 상태의 기능을 억지로 밀어 넣으려는 압박도 줄어듭니다.

이 과정 덕분에 언제나 러스트의 다음 빌드를 써 보고 업그레이드가 쉬운지 직접 확인할 수 있습니다. beta 릴리스가 기대한 대로 동작하지 않으면 팀에 보고해, 다음 안정 릴리스 전에 고치게 할 수 있습니다! beta 릴리스에서 깨지는 일은 비교적 드물지만, rustc 도 결국 소프트웨어이므로 버그는 존재합니다.

유지보수 기간

러스트 프로젝트는 가장 최신의 안정 버전만 지원합니다. 새 안정 버전이 릴리스되면, 이전 버전은 수명 종료(EOL)에 도달합니다. 즉, 각 버전은 6주 동안 지원됩니다.

불안정 기능

이 릴리스 모델에는 한 가지 요소가 더 있습니다. 바로 불안정 기능입니다. 러스트는 주어진 릴리스에서 어떤 기능이 활성화되는지 결정하기 위해 “기능 플래그(feature flag)” 라는 기법을 사용합니다. 새 기능이 활발히 개발 중이라면 메인 브랜치, 즉 nightly 에 들어가지만 기능 플래그 뒤에 가려집니다. 여러분이 사용자로서 작업 중인 기능을 직접 시험해 보고 싶다면 그렇게 할 수는 있습니다. 다만 nightly 러스트를 사용해야 하고, 소스 코드에 적절한 플래그를 달아 명시적으로 opt-in 해야 합니다.

beta 나 stable 러스트를 사용하는 경우에는 어떤 기능 플래그도 사용할 수 없습니다. 이 점이 새 기능을 영구적으로 안정화하기 전에 실전 환경에서 써 볼 수 있게 해 줍니다. 최신 실험 기능을 쓰고 싶은 사람은 그렇게 할 수 있고, 단단한 경험을 원하는 사람은 stable 에 머물면서 코드가 깨지지 않음을 알 수 있습니다. 이것이 바로 정체 없는 안정성입니다.

이 책에는 안정 기능에 대한 정보만 담겨 있습니다. 진행 중인 기능은 계속 변하고 있고, 책이 쓰인 시점과 그 기능이 안정 빌드에 들어가는 시점 사이에서도 분명 달라질 것이기 때문입니다. nightly 전용 기능 문서는 온라인에서 찾을 수 있습니다.

Rustup 과 Rust Nightly 의 역할

Rustup 은 전역 단위든 프로젝트 단위든, 러스트의 서로 다른 릴리스 채널 사이를 쉽게 바꿀 수 있게 해 줍니다. 기본적으로는 stable 러스트가 설치됩니다. 예를 들어 nightly 를 설치하려면 다음과 같이 합니다.

$ rustup toolchain install nightly

설치한 모든 툴체인 (러스트 릴리스와 관련 구성요소들)도 rustup 으로 확인할 수 있습니다. 다음은 이 책 저자 중 한 명의 Windows 컴퓨터에서 본 예시입니다.

> rustup toolchain list
stable-x86_64-pc-windows-msvc (default)
beta-x86_64-pc-windows-msvc
nightly-x86_64-pc-windows-msvc

보다시피 stable 툴체인이 기본값입니다. 대부분의 러스트 사용자는 대부분의 시간 동안 stable 을 씁니다. 여러분도 대체로 stable 을 쓰다가, 어떤 최첨단 기능이 필요한 특정 프로젝트에서는 nightly 를 쓰고 싶을 수 있습니다. 그러려면 그 프로젝트 디렉터리에서 rustup override 를 사용해, 그 디렉터리에 있을 때 rustup 이 nightly 툴체인을 사용하도록 설정하면 됩니다.

$ cd ~/projects/needs-nightly
$ rustup override set nightly

이제 ~/projects/needs-nightly 안에서 rustccargo 를 호출할 때마다 rustup 은 기본값인 stable 러스트가 아니라 nightly 러스트를 사용하고 있는지 확인해 줍니다. 러스트 프로젝트가 많을 때 아주 편리합니다.

RFC 절차와 팀

그렇다면 이런 새 기능에 대해 어떻게 알게 될까요? 러스트의 개발 모델은 Request For Comments (RFC) 절차 를 따릅니다. 러스트를 개선하고 싶다면 RFC 라는 제안 문서를 작성할 수 있습니다.

누구나 러스트 개선을 위한 RFC 를 쓸 수 있고, 이 제안은 여러 주제별 하위 팀으로 구성된 러스트 팀에 의해 검토되고 논의됩니다. 모든 팀 목록은 러스트 웹사이트 에서 볼 수 있습니다. 언어 설계, 컴파일러 구현, 인프라, 문서 등 프로젝트 각 분야의 팀이 포함되어 있습니다. 적절한 팀이 제안과 댓글을 읽고, 자기들의 의견도 남긴 뒤, 결국 그 기능을 받아들일지 거부할지에 대한 합의가 형성됩니다.

기능이 받아들여지면 러스트 저장소에 이슈가 열리고, 누군가 그 기능을 구현할 수 있습니다. 아주 잘 구현한 사람이 처음 제안한 사람과 동일하지 않을 수도 있습니다! 구현이 준비되면, “불안정 기능” 절에서 설명한 것처럼 기능 게이트 뒤에 둔 상태로 메인 브랜치에 들어갑니다.

시간이 지나 nightly 릴리스를 사용하는 러스트 개발자들이 새 기능을 충분히 시험해 보면, 팀 구성원들은 그 기능과 nightly 에서의 동작 결과를 논의하고, stable 러스트에 넣을지 말지를 결정합니다. 앞으로 진행하기로 결정되면 기능 게이트가 제거되고, 그 기능은 이제 안정적이라고 간주됩니다! 그리고 기차를 타고 다음 안정 러스트 릴리스로 들어갑니다.