객체 지향 설계 패턴 구현하기
객체 지향 설계 패턴 구현하기 (Implementing an Object-Oriented Design Pattern)
상태 패턴(state pattern)은 객체 지향 설계 패턴이에요. 이 패턴의 핵심은 값이 내부적으로 가질 수 있는 상태들의 집합을 정의하는 거예요. 상태들은 상태 객체(state objects)의 집합으로 표현되고, 값의 행동은 그 상태에 따라 바뀌죠. 여기서는 자신의 상태를 담는 필드를 가진 블로그 포스트 구조체 예시를 함께 만들어 볼 거예요. 그 상태는 "초안(draft)", "검토(review)", "게시됨(published)" 집합 중 하나인 상태 객체가 될 거예요.
상태 객체들은 기능을 공유해요. Rust에서는 당연히 객체와 상속 대신 구조체와 트레이트를 쓰죠. 각 상태 객체는 자신의 행동과 언제 다른 상태로 바뀌어야 하는지를 책임집니다. 상태 객체를 담고 있는 값은 각 상태의 서로 다른 행동이나 언제 상태 사이를 전환해야 하는지에 대해 아무것도 몰라요.
상태 패턴을 쓰는 장점은, 프로그램의 비즈니스 요구가 바뀌어도 상태를 담고 있는 값의 코드나 그 값을 사용하는 코드는 바꿀 필요가 없다는 점이에요. 상태 객체 하나의 규칙을 바꾸거나 상태 객체를 더 추가하기 위해 그 안의 코드만 갱신하면 돼요.
먼저 상태 패턴을 좀 더 전통적인 객체 지향 방식으로 구현해 볼 거예요. 그다음 Rust에 좀 더 자연스러운 접근을 써보죠. 상태 패턴으로 블로그 포스트 워크플로우를 점진적으로 구현하면서 살펴볼게요.
최종 기능은 이렇게 보일 거예요.
- 블로그 포스트는 빈 초안으로 시작한다.
- 초안이 완성되면 포스트에 대한 검토가 요청된다.
- 포스트가 승인되면 게시된다.
- 승인되지 않은 포스트가 실수로 게시되지 않도록, 게시된 포스트만 인쇄할 내용을 반환한다.
포스트에 시도하는 다른 어떤 변경도 효과가 없어야 해요. 예를 들어 검토를 요청하기도 전에 초안 블로그 포스트를 승인하려고 하면, 포스트는 게시되지 않은 초안 상태로 남아 있어야 하죠.
전통적인 객체 지향 스타일 시도하기 (Attempting Traditional Object-Oriented Style)
같은 문제를 풀기 위한 코드 구조는 무한히 많고, 각각 서로 다른 트레이드오프를 가져요. 이 섹션의 구현은 좀 더 전통적인 객체 지향 스타일인데, Rust로도 쓸 수는 있지만 Rust의 몇몇 강점을 활용하지 못해요. 나중에 여전히 객체 지향 설계 패턴을 쓰면서도, 객체 지향 경험이 있는 프로그래머에게는 다소 낯설어 보일 수 있는 방식으로 구성한 다른 해결책을 보여드릴게요. 두 해결책을 비교하면서 Rust 코드를 다른 언어의 코드와 다르게 설계할 때의 트레이드오프를 직접 느껴 보죠.
Listing 18-11은 이 워크플로우를 코드로 보여줘요. blog라는 라이브러리 크레이트에서 구현할 API의 예시 사용법이에요. 아직 blog 크레이트를 구현하지 않았으니 이건 컴파일되지 않을 거예요.
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로 새 초안 블로그 포스트를 만들 수 있게 하고 싶어요. 블로그 포스트에 텍스트를 추가할 수 있게 하고 싶고요. 승인 전에 포스트의 내용을 즉시 가져오려 하면 텍스트를 얻을 수 없어야 해요. 아직 포스트가 초안이기 때문이죠. 데모 목적으로 코드에 assert_eq!를 넣어뒀어요. 여기서 좋은 단위 테스트는 초안 블로그 포스트가 content 메서드에서 빈 문자열을 반환하는지 확인하는 것이겠지만, 이 예시에선 테스트를 작성하지 않을 거예요.
다음으로 포스트에 대한 검토 요청을 활성화하고, 검토를 기다리는 동안 content가 빈 문자열을 반환하게 하고 싶어요. 포스트가 승인을 받으면 게시되어야 하고, content를 호출했을 때 포스트의 텍스트가 반환된다는 뜻이죠.
크레이트에서 우리가 상호작용하는 유일한 타입은 Post 타입이라는 점에 주목하세요. 이 타입은 상태 패턴을 사용하고, 포스트가 있을 수 있는 다양한 상태(초안, 검토, 게시됨) 중 하나를 나타내는 세 개의 상태 객체 중 하나인 값을 담을 거예요. 한 상태에서 다른 상태로 바뀌는 것은 Post 타입 내부에서 관리돼요. 상태는 라이브러리 사용자가 Post 인스턴스에 호출하는 메서드에 응답해 바뀌지만, 사용자가 상태 변경을 직접 관리할 필요는 없죠. 또한 검토되기 전에 게시하는 것처럼 사용자가 상태에 실수를 저지르는 것도 불가능해요.
Post 정의하고 새 인스턴스 만들기
라이브러리 구현을 시작해 볼게요! 어떤 내용을 담는 공개 Post 구조체가 필요하다는 건 알 수 있죠. 그래서 Listing 18-12처럼 구조체 정의와 Post 인스턴스를 만드는 연관 공개 함수 new부터 시작해 볼게요. Post의 모든 상태 객체가 가져야 할 행동을 정의하는 비공개 State 트레이트도 만들 거예요.
그다음 Post는 state라는 비공개 필드의 Option<T> 안에 Box<dyn State>의 트레이트 객체를 담아 상태 객체를 보관할 거예요. Option<T>가 왜 필요한지는 조금 이따 알게 될 거예요.
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 {}
State 트레이트는 서로 다른 포스트 상태들이 공유하는 행동을 정의해요. 상태 객체는 Draft, PendingReview, Published이고, 이 셋 모두 State 트레이트를 구현할 거예요. 지금은 트레이트에 메서드가 없고, 포스트가 시작하는 상태가 되길 원하는 초안 Draft만 정의하는 것으로 시작할 거예요.
새 Post를 만들 때 state 필드를 Box를 담는 Some 값으로 설정해요. 이 Box는 Draft 구조체의 새 인스턴스를 가리키죠. 이렇게 하면 새 Post 인스턴스를 만들 때마다 초안으로 시작한다는 게 보장돼요. Post의 state 필드가 비공개이므로 다른 어떤 상태로도 Post를 만들 수 없어요! Post::new 함수에서는 content 필드를 비어 있는 새 String으로 설정하죠.
포스트 내용 텍스트 저장하기
Listing 18-11에서 add_text라는 메서드를 호출하고 그것에 &str을 넘겨 블로그 포스트의 텍스트 내용으로 추가하고 싶어 한다는 걸 봤어요. 이걸 content 필드를 pub으로 노출하는 대신 메서드로 구현하는 이유는, 나중에 content 필드의 데이터를 어떻게 읽을지 제어하는 메서드를 구현할 수 있게 하기 위해서예요. add_text 메서드는 꽤 간단하니 Listing 18-13의 구현을 impl Post 블록에 추가해 볼게요.
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 {}
add_text 메서드는 self에 대한 가변 참조를 받아요. add_text를 호출하고 있는 Post 인스턴스를 변경하고 있기 때문이죠. 그러고 나서 content에 있는 String의 push_str을 호출해 text 인자를 저장된 내용에 추가해요. 이 행동은 포스트가 어떤 상태인지에 의존하지 않으므로 상태 패턴의 일부가 아니에요. add_text 메서드는 state 필드와 전혀 상호작용하지 않지만, 지원하고 싶은 행동의 일부이긴 하죠.
초안 포스트의 내용이 비어 있음을 보장하기
add_text를 호출해 포스트에 어떤 내용을 추가한 뒤에도, Listing 18-11의 첫 번째 assert_eq!가 보여주듯 포스트가 여전히 초안 상태이므로 content 메서드가 빈 문자열 슬라이스를 반환하길 원해요. 지금은 이 요구를 충족하는 가장 단순한 것으로 content 메서드를 구현해 볼게요. 항상 빈 문자열 슬라이스를 반환하는 거예요. 포스트를 게시할 수 있도록 포스트의 상태를 바꾸는 기능을 구현한 다음에 이걸 바꿀 거예요. 지금까지 포스트는 초안 상태만 가능하므로 포스트 내용은 항상 비어 있어야 해요. Listing 18-14가 이 자리 표시자 구현을 보여줘요.
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 {}
이 content 메서드를 추가하면 Listing 18-11의 첫 번째 assert_eq!까지의 모든 것이 의도대로 동작해요.
검토 요청하기, 포스트의 상태 바꾸기
다음으로 포스트에 대한 검토를 요청하는 기능을 추가해야 해요. 이건 상태를 Draft에서 PendingReview로 바꿔야 하죠. Listing 18-15가 이 코드를 보여줍니다.
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
}
}
Post에 self에 대한 가변 참조를 받는 request_review라는 공개 메서드를 제공해요. 그러고 나서 Post의 현재 상태에 있는 내부 request_review 메서드를 호출하는데, 이 두 번째 request_review 메서드는 현재 상태를 소비하고 새 상태를 반환하죠.
State 트레이트에 request_review 메서드를 추가해요. 이제 트레이트를 구현하는 모든 타입은 request_review 메서드를 구현해야 해요. 메서드의 첫 번째 매개변수로 self, &self, &mut self가 아니라 self: Box<Self>를 쓴다는 점에 주목하세요. 이 문법은 타입을 담은 Box에서 메서드를 호출할 때만 유효하다는 뜻이에요. 이 문법은 Box<Self>의 소유권을 가져와서 기존 상태를 무효화하고, Post의 상태 값이 새 상태로 변형될 수 있게 하죠.
기존 상태를 소비하려면 request_review 메서드가 상태 값의 소유권을 가져와야 해요. 여기서 Post의 state 필드에 있는 Option이 등장합니다. take 메서드를 호출해 state 필드에서 Some 값을 꺼내고 그 자리에 None을 남겨요. Rust가 구조체에 값이 채워지지 않은 필드를 허용하지 않기 때문이죠. 이렇게 하면 state 값을 빌리는 대신 Post 밖으로 옮길 수 있어요. 그리고 나서 포스트의 state 값을 이 연산의 결과로 설정하죠.
self.state = self.state.request_review(); 같은 코드로 직접 설정하는 대신 일시적으로 state를 None으로 설정해야 하는 이유는 상태 값의 소유권을 얻기 위해서예요. 이렇게 해야 Post가 새 상태로 변형된 후에는 기존 상태 값을 쓸 수 없게 되죠.
Draft의 request_review 메서드는 포스트가 검토를 기다리는 상태를 나타내는 새 PendingReview 구조체의 상자에 담긴 새 인스턴스를 반환해요. PendingReview 구조체도 request_review 메서드를 구현하지만 어떤 변형도 하지 않아요. 대신 그 자체를 반환하죠. 이미 PendingReview 상태에 있는 포스트에 검토를 요청하면 PendingReview 상태에 머물러야 하기 때문이에요.
이제 상태 패턴의 장점이 보이기 시작해요. Post의 request_review 메서드는 상태 값이 무엇이든 동일해요. 각 상태가 자기 자신의 규칙을 책임지죠.
Post의 content 메서드는 빈 문자열 슬라이스를 반환하는 그대로 두겠어요. 이제 Post는 Draft 상태뿐 아니라 PendingReview 상태일 수도 있지만, PendingReview 상태에서도 같은 행동을 원해요. Listing 18-11은 이제 두 번째 assert_eq! 호출까지 동작해요!
approve 추가해서 content의 행동 바꾸기
approve 메서드는 request_review 메서드와 비슷할 거예요. Listing 18-16처럼 해당 상태가 승인되었을 때 현재 상태가 가져야 한다고 말하는 값으로 state를 설정할 거예요.
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
}
}
State 트레이트에 approve 메서드를 추가하고, State를 구현하는 새 구조체인 Published 상태도 추가해요.
PendingReview의 request_review가 동작하는 방식과 비슷하게, Draft에서 approve 메서드를 호출하면 효과가 없어요. approve가 self를 반환하니까요. PendingReview에서 approve를 호출하면 Published 구조체의 상자에 담긴 새 인스턴스를 반환하죠. Published 구조체는 State 트레이트를 구현하고, request_review 메서드와 approve 메서드 모두에 대해 그 자체를 반환하는데, 그 경우들에 포스트가 Published 상태에 머물러야 하기 때문이에요.
이제 Post의 content 메서드를 갱신해야 해요. content가 반환하는 값이 Post의 현재 상태에 의존하길 원하므로, Listing 18-17처럼 Post가 자신의 상태에 정의된 content 메서드에 위임하게 할 거예요.
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
}
}
이 모든 규칙을 State를 구현하는 구조체 안에 유지하는 게 목표이므로, state에 있는 값의 content 메서드를 호출하고 포스트 인스턴스(즉 self)를 인자로 넘겨요. 그러고 나서 상태 값의 content 메서드를 사용한 결과로 반환되는 값을 돌려주죠.
Option에 as_ref 메서드를 호출하는 이유는 Option 안의 값에 대한 소유권이 아니라 참조를 원하기 때문이에요. state가 Option<Box<dyn State>>이므로 as_ref를 호출하면 Option<&Box<dyn State>>가 반환돼요. as_ref를 호출하지 않으면 함수 매개변수의 빌려온 &self에서 state를 옮길 수 없으니 오류가 나죠.
그러고 나서 unwrap 메서드를 호출하는데, Post의 메서드들이 끝날 때 state가 항상 Some 값을 담는다는 걸 알기 때문에 이것이 절대 패닉(panic)하지 않을 거라는 것도 알아요. 이건 9장의 "컴파일러보다 사용자가 더 많은 정보를 알고 있을 때(When You Have More Information Than the Compiler)" 섹션에서 이야기한 경우 중 하나로, 컴파일러는 이해하지 못하지만 None 값이 결코 가능하지 않다는 걸 우리가 아는 상황이에요.
이 시점에서 &Box<dyn State>에 content를 호출하면 &와 Box에 역참조 강제(deref coercion)가 적용되어, 결국 State 트레이트를 구현하는 타입에서 content 메서드가 호출돼요. 즉 State 트레이트 정의에 content를 추가해야 하고, Listing 18-18처럼 어떤 상태냐에 따라 어떤 내용을 반환할지에 대한 로직을 그 자리에 넣을 거예요.
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
}
}
빈 문자열 슬라이스를 반환하는 content 메서드의 기본 구현을 추가해요. 그 말은 Draft와 PendingReview 구조체에 content를 구현할 필요가 없다는 뜻이에요. Published 구조체는 content 메서드를 오버라이드해서 post.content의 값을 반환할 거예요. 편리하긴 하지만, State에 있는 content 메서드가 Post의 내용을 결정하게 하는 것은 State의 책임과 Post의 책임 사이의 경계를 흐리게 해요.
이 메서드에는 10장에서 논의했듯 라이프타임 어노테이션이 필요하다는 점에 주목하세요. 포스트에 대한 참조를 인자로 받고 그 포스트의 일부에 대한 참조를 반환하므로, 반환된 참조의 라이프타임은 포스트 인자와 관련이 있어요.
이제 끝났어요. Listing 18-11 전체가 동작해요! 블로그 포스트 워크플로우의 규칙으로 상태 패턴을 구현한 거예요. 규칙과 관련된 로직은 Post 전체에 흩어져 있는 게 아니라 상태 객체들에 들어 있어요.
왜 Enum을 안 쓸까? (Why Not An Enum?)
왜 가능한 포스트 상태들을 변형(variant)으로 갖는 열거형(enum)을 안 썼는지 궁금했을 수도 있어요. 그건 확실히 가능한 해결책이에요. 직접 시도해 보고 최종 결과를 비교해서 어떤 게 더 좋은지 골라 봐요! 열거형의 단점 하나는 열거형의 값을 확인하는 모든 곳에서 가능한 모든 변형을 처리하기 위해 match 표현식이나 이와 유사한 것이 필요하다는 거예요. 이건 이 트레이트 객체 해결책보다 훨씬 반복적일 수 있죠.
상태 패턴 평가하기 (Evaluating the State Pattern)
Rust가 각 상태에서 포스트가 가져야 할 다양한 행동을 캡슐화하는 객체 지향 상태 패턴을 구현할 수 있다는 걸 확인했어요. Post의 메서드들은 다양한 행동에 대해 아무것도 알지 못해요. 코드를 이렇게 구성했기 때문에, 게시된 포스트가 가질 수 있는 다양한 행동을 알려면 한 곳만 보면 돼요. 바로 Published 구조체에 대한 State 트레이트 구현이죠.
상태 패턴을 쓰지 않는 대안 구현을 만든다면, Post의 메서드나 심지어 main 코드에서도 match 표현식을 써서 포스트의 상태를 확인하고 그 자리에서 행동을 바꿀 수도 있을 거예요. 그렇다면 포스트가 게시 상태에 있을 때의 모든 함의를 이해하려면 여러 곳을 봐야 한다는 뜻이 되죠.
상태 패턴을 쓰면 Post 메서드와 Post를 사용하는 곳에 match 표현식이 필요 없고, 새 상태를 추가하려면 새 구조체를 하나 추가하고 그 구조체 하나에 트레이트 메서드를 구현하기만 하면 돼요.
상태 패턴을 쓴 구현은 기능을 더 추가하기 쉽게 확장돼요. 상태 패턴을 쓰는 코드를 유지하는 단순함을 보려면 다음 제안 중 몇 가지를 직접 해 보세요.
- 포스트의 상태를
PendingReview에서Draft로 되돌리는reject메서드를 추가하세요. - 승인을 두 번 호출해야 상태가
Published로 바뀌도록 하세요. - 사용자가 포스트가
Draft상태일 때만 텍스트 내용을 추가할 수 있게 하세요. 힌트: 내용에 대해 무엇이 바뀔 수 있는지 책임지는 건 상태 객체가 하되,Post를 수정하는 책임은 안 지게 하세요.
상태 패턴의 단점 하나는, 상태들이 상태 사이의 전환을 구현하기 때문에 일부 상태들이 서로 결합된다는 점이에요. PendingReview와 Published 사이에 Scheduled 같은 다른 상태를 추가한다면, PendingReview의 코드를 바꿔 Scheduled로 전환되도록 해야 해요. 새 상태가 추가되어도 PendingReview가 바뀔 필요가 없다면 작업이 줄겠지만, 그럴려면 다른 설계 패턴으로 전환해야 하죠.
또 다른 단점은 어떤 로직을 중복했다는 점이에요. 중복의 일부를 없애기 위해 State 트레이트의 request_review와 approve 메서드에 self를 반환하는 기본 구현을 만들려고 할 수도 있어요. 하지만 그렇게는 안 돼요. State를 트레이트 객체로 쓸 때, 트레이트는 구체적인 self가 정확히 무엇인지 알지 못하므로 컴파일 시점에 반환 타입이 알려지지 않거든요. (이건 앞서 언급한 dyn 호환성 규칙 중 하나예요.)
다른 중복으로는 Post의 request_review와 approve 메서드가 서로 비슷하게 구현된다는 점이 있어요. 두 메서드 모두 Post의 state 필드로 Option::take를 사용하고, state가 Some이면 감싸인 값의 같은 이름의 메서드 구현에 위임하고 state 필드의 새 값을 그 결과로 설정하죠. 이 패턴을 따르는 메서드가 Post에 많다면, 반복을 없애기 위해 매크로를 정의하는 것을 고려해 볼 수 있어요(20장의 "매크로(Macros)" 섹션 참고).
상태와 행동을 타입으로 인코딩하기 (Encoding States and Behavior as Types)
상태 패턴을 객체 지향 언어에서 정의된 그대로 구현해서, Rust의 강점을 최대한 활용하고 있지 못해요. blog 크레이트에 할 수 있는 변경 몇 가지를 살펴보면서, 잘못된 상태와 전환이 컴파일 타임 오류가 되도록 만들어 볼게요.
다른 트레이드오프를 얻기 위해 상태 패턴을 다시 생각하는 방법을 보여드릴게요. 외부 코드가 전혀 모르도록 상태와 전환을 완전히 캡슐화하는 대신, 상태를 서로 다른 타입으로 인코딩할 거예요. 그러면 Rust의 타입 검사 시스템이, 게시된 포스트만 허용되는 곳에서 초안 포스트를 쓰려는 시도를 컴파일러 오류를 내며 막아줘요.
Listing 18-11의 main 첫 부분을 생각해 보죠.
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
post.request_review();
assert_eq!("", post.content());
post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
여전히 Post::new로 초안 상태의 새 포스트를 만들고 포스트 내용에 텍스트를 추가할 수 있게 해요. 하지만 빈 문자열을 반환하는 초안 포스트에 content 메서드를 두는 대신, 초안 포스트에는 content 메서드가 아예 없도록 만들 거예요. 그러면 초안 포스트의 내용을 얻으려 하면 메서드가 존재하지 않는다는 컴파일러 오류를 받을 수 있어요. 결과적으로 프로덕션에서 실수로 초안 포스트 내용을 표시하는 건 불가능해지는데, 그런 코드는 컴파일조차 되지 않으니까요. Listing 18-19는 Post 구조체와 DraftPost 구조체의 정의와 각각의 메서드를 보여줘요.
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);
}
}
Post와 DraftPost 구조체 모두 블로그 포스트 텍스트를 저장하는 비공개 content 필드를 가져요. 구조체들에 더 이상 state 필드가 없는 이유는 상태의 인코딩을 구조체의 타입으로 옮기고 있기 때문이에요. Post 구조체는 게시된 포스트를 나타내고, 내용을 반환하는 content 메서드를 가져요.
여전히 Post::new 함수가 있지만, Post 인스턴스를 반환하는 대신 DraftPost 인스턴스를 반환해요. content가 비공개이고 Post를 반환하는 함수가 없으므로 지금은 Post 인스턴스를 만들 수 없어요.
DraftPost 구조체에는 add_text 메서드가 있어서 이전처럼 content에 텍스트를 추가할 수 있지만, DraftPost에는 content 메서드가 정의돼 있지 않다는 점에 주목하세요! 이제 프로그램은 모든 포스트가 초안 포스트로 시작하고, 초안 포스트는 표시할 내용을 갖지 않는다는 것을 보장해요. 이 제약들을 우회하려는 어떤 시도든 컴파일러 오류를 초래하죠.
그럼 게시된 포스트는 어떻게 얻을까요? 초안 포스트는 게시되기 전에 검토·승인을 받아야 한다는 규칙을 강제하고 싶어요. 보류 중인 검토 상태의 포스트는 여전히 내용을 표시하지 않아야 하고요. PendingReviewPost라는 또 다른 구조체를 추가하고, DraftPost에 PendingReviewPost를 반환하는 request_review 메서드를 정의하고, PendingReviewPost에 Post를 반환하는 approve 메서드를 정의해서 Listing 18-20처럼 이 제약들을 구현해 볼게요.
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,
}
}
}
request_review와 approve 메서드는 self의 소유권을 가져와서 DraftPost와 PendingReviewPost 인스턴스를 소비하고, 각각 PendingReviewPost와 게시된 Post로 변형해요. 이렇게 하면 request_review를 호출한 후에 남아 있는 DraftPost 인스턴스 같은 것이 없어요. PendingReviewPost 구조체에는 content 메서드가 정의돼 있지 않아서, DraftPost와 마찬가지로 그것의 내용을 읽으려고 하면 컴파일러 오류가 나죠. content 메서드가 정의된 게시된 Post 인스턴스를 얻는 유일한 방법은 PendingReviewPost에서 approve 메서드를 호출하는 것이고, PendingReviewPost를 얻는 유일한 방법은 DraftPost에서 request_review 메서드를 호출하는 것이므로, 이제 블로그 포스트 워크플로우를 타입 시스템에 인코딩한 거예요.
하지만 main에도 약간의 작은 변경을 해야 해요. request_review와 approve 메서드는 호출되는 구조체를 수정하는 게 아니라 새 인스턴스를 반환하므로, 반환된 인스턴스를 저장할 let post = 섀도잉(shadowing) 할당을 더 추가해야 해요. 또한 초안과 검토 보류 포스트의 내용이 빈 문자열이라는 검증(assertion)은 할 수 없어요. 그럴 필요도 없죠. 이제는 그 상태의 포스트 내용을 쓰려는 코드를 컴파일할 수 없으니까요. 갱신된 main 코드는 Listing 18-21에 있어요.
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());
}
post를 다시 할당하기 위해 main에 해야 했던 변경은, 이 구현이 더 이상 객체 지향 상태 패턴을 정확히 따르지 않는다는 뜻이에요. 상태 사이의 변형이 더 이상 Post 구현 안에 완전히 캡슐화되지 않으니까요. 하지만 그 대신 얻은 것은, 타입 시스템과 컴파일 시점에 일어나는 타입 검사 덕분에 잘못된 상태가 이제는 불가능하다는 점이에요! 이렇게 하면 게시되지 않은 포스트의 내용 표시 같은 특정 버그가 프로덕션에 도달하기 전에 발견되는 게 보장돼요.
Listing 18-21 이후 상태의 blog 크레이트에 이 섹션 시작에서 제안한 작업들을 시도해서 이 버전 코드 설계에 대해 어떻게 생각하는지 확인해 보세요. 몇 가지 작업은 이 설계에서 이미 완료돼 있을 수도 있다는 점을 참고하세요.
Rust가 객체 지향 설계 패턴을 구현할 수는 있지만, 상태를 타입 시스템에 인코딩하는 것 같은 다른 패턴도 Rust에서 쓸 수 있다는 걸 확인했어요. 이 패턴들은 서로 다른 트레이드오프를 가져요. 객체 지향 패턴에 아주 익숙하더라도, 문제를 다시 생각해서 Rust의 기능을 활용하는 것은 컴파일 시점에 어떤 버그를 막는 것 같은 이점을 줄 수 있어요. 객체 지향 패턴이 Rust에서 항상 최선의 해결책인 건 아니에요. 소유권(ownership)처럼 객체 지향 언어에는 없는 특정 기능 때문이죠.
요약 (Summary)
이 장을 읽고 나서 Rust가 객체 지향 언어라고 생각하든 아니든, 이제 트레이트 객체로 Rust에서 객체 지향적인 기능 몇 가지를 얻을 수 있다는 걸 알게 됐어요. 동적 디스패치는 약간의 런타임 성능을 대가로 코드에 유연성을 줄 수 있죠. 이 유연성을 이용해 코드의 유지보수성을 높이는 객체 지향 패턴을 구현할 수 있어요. Rust에는 소유권처럼 객체 지향 언어에는 없는 다른 기능도 있어요. 객체 지향 패턴이 Rust의 강점을 활용하는 최선의 방법이 항상 되는 건 아니지만, 쓸 수 있는 선택지이긴 하죠.
다음으로는 Rust의 또 다른 유연성을 크게 높여 주는 기능인 패턴(pattern)을 살펴볼 거예요. 이 책을 통해 잠깐씩 보긴 했지만 아직 그 전체 능력을 보진 못했죠. 시작해 볼게요!
출처: The Rust Book