이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.
객체지향 디자인 패턴 중에 상태 패턴이라는 것이 있다.
물론 이것 말고도 다양한 디자인 패턴이 존재하지만
대표로 이걸 Rust에서 적용해보도록 하겠다.
이것은 어떤 내부 상태를 가지며 그 내부 상태에 따라 값이 변경하는 상태 객체를 포함한다.
이 디자인 패턴을 사용하면 비즈니스 요구사항에 의해 프로그램을 변경해야 할 대
그 중 하나의 상태에 대한 것을 수정하거나 추가하는 것만으로 변경할 수 있다.
어떤 메서드가 상태를 변화시키며
상태 변화의 순서가 정해져 있을 경우 순서가 맞지 않게 호출되는 메서드는 무시된다.
이런 디자인 패턴을 활용하여
초고 → 리뷰 → 승인 과정을 거쳐야만 발행되는 블로그 포스팅을 가상으로 작성해보자.
이전 작업이 완료되지 않은 상태에서 다음 작업을 하려고 하면 무시된다.
라이브러리 크레이트의 코드를 작성하기 전에 그 동작을 먼저 알아보면 다음과 같다.
물론 아직 그 기능이 구현되지 않았으니 컴파일 되지 않는다.
peter@hp-laptop:~/rust-practice/chapter17/draw_gui$ cd ..
peter@hp-laptop:~/rust-practice/chapter17$ cargo new blog
Created binary (application) `blog` package
peter@hp-laptop:~/rust-practice/chapter17$ cd blog/
peter@hp-laptop:~/rust-practice/chapter17/blog$ vi src/main.rs
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
함수를 통해 포스트의 초고를 작성할 수 있다.
그리고 포스트의 상태가 초고일 때, 텍스트를 추가하는 add_text
메서드를 사용할 수 있다.
이 함수가 호출된 후에도 아직 승인되지 않았으므로
post.content
로 내용을 읽어와도 방금 추가한 텍스트는 존재하지 않는다.
초고를 작성한 후에는 request_review
메서드를 통해 리뷰를 요청할 수 있다.
이 때도 여전히 내용에는 반영되어 있지 않다.
approve
메서드를 통해 승인을 받은 후에야 비로소 우리가 추가한 텍스트가 반영된다.
오직 Post
라는 자료형 하나로 이 모든 작업이 이루어지는 것이다.
이 때, 상태 변화는 절대 외부에서 직접 관리하지 않고 메서드를 통해서 관리한다.
그리고 상태 변화는 반드시 정해진 순서대로만 이루어진다.
자 이제 본격적인 라이브러리 트레이트를 구현하는 코드를 조금씩 작성해보자.
상태는 여러 상태가 있을 수 있으므로 트레이트 객체로 선언한다.
각각의 상태를 따라 확장적으로 작성하도록 하겠다.
peter@hp-laptop:~/rust-practice/chapter17/blog$ vi src/lib.rs
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 { "" } } trait State {} struct Draft {} impl State for Draft {}
먼저 초고 상태에 대한 코드를 작성하였다.
새로 생성된 포스트는 항상 그 state
가 Draft
구조체의 인스턴스를 담고 있다.
그리고 state
와 content
는 모두 외부에서 접근할 수 없다.
state
를 변경하기 위해서는 다음 단계의 메서드를 호출해야 하고
content
를 변경하기 위해서는 add_text
메서드를 호출해야 한다.
state
는 외부에서는 따로 볼 수 없고
content
의 값을 보기 위해서는 content
메서드를 사용해야 하는데
초고 상태에서는 항상 빈 문자열이어야 하므로 일단 빈 문자열을 반환하게 작성한다.
물론 이 메서드는 이후에 수정될 것이다.
다음으로 리뷰 요청 상태에 대한 코드를 작성해보자.
peter@hp-laptop:~/rust-practice/chapter17/blog$ vi src/lib.rs
src/lib.rs
// snip impl Post { // snip 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
구조체에 공개 메서드 request_review
를 추가하고
그 안에서 구조체 인스턴스의 현재 상태의 request_review
를 호출하도록 한다.
그리고 State
트레이트에 request_review
메서드를 추가함으로써
State
를 구현하는 모든 자료형은 이 메서드를 구현하도록 하였다.
특이한 점은 self
옆에 : Box<Self>
가 붙었다는 건데,
Box<T>
에 저장된 경우에만 이 메서드가 유효하다는 것이며
현재 상태를 무효화하고 새로운 상태를 반환하는 것이 이 메서드의 역할이다.
Post
의 request_review
메서드에서 State
의 그것을 호출하기 전에 조건문이 있다.
이 조건문을 통해 이 메서드에서는
state
는 Option
으로 선언되어 있는데 이것이 None
이면 그대로 두고
Some
이면 그 소유권을 가져와 그 안에 들어있는 state
값으로 메서드를 호출하고
그 반환값을 state
에 저장한다.
Draft
의 request_reivew
는 새 PendingReview
를 반환하고
PendingReview
의 request_reivew
는 자기 자신을 반환한다.
이와 같이 구조체의 메서드는 현재 상태값과 무관하게 동일하며
상태 변환에 대한 코드 자체는 각각의 코드에 구현된다.
리뷰 요청 상태에서 승인 상태로 가는 approve
메서드는 request_review
와 유사하다.
peter@hp-laptop:~/rust-practice/chapter17/blog$ vi src/lib.rs
src/lib.rs
// snip impl Post { // snip 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<State>; fn approve(self: Box<Self>) -> Box<dyn State>; } // snip impl State for Draft { // snip fn approve(self: Box<Self>) -> Box<dyn State> { self } } // snip impl State for PendingReview { //snip 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 } }
이번에도 역시 유효하지 않은 상태 변화에서는 자기 자신을 반환하고
유효한 상태 변화에만 현재 상태의 소유권을 버린 채 다음 상태를 생성하여 반환한다.
이제 Post
의 content
메서드를 수정해보자.
이것은 Published
상태일 때만 content
를 반환하고
다른 상태에서는 빈 문자열을 반환해야 한다.
이 메서드도 다른 녀석들과 마찬가지로 State
트레이트에 추가하여
각각의 상태에 맞게 구현하면 된다.
다만, 이번에는 state
필드의 소유권이 필요하지 않으므로
Post
의 메서드 구현 방식에 차이가 있다.
여기서는 as_ref
메서드를 통해 값을 대여한 후 unwrap
으로 꺼내 메서드에 접근한다.
우리는 state
가 None
이 될 수 없음을 알고 있기에 None
에 대한 처리 없이
단지 unwrap
으로 Option
값을 처리할 수 있다.
빈 문자열을 반환하는 content
메서드를 State
의 default 메서드로 넣어두면
Draft
와 PendingReview
에서는 그것을 따로 구현할 필요가 없다.
그리고 content
메서드의 반환값의 수명은 post
를 따르도록 한다.
peter@hp-laptop:~/rust-practice/chapter17/blog$ vi src/lib.rs
src/lib.rs
// snip impl Post { // snip pub fn content(&self) -> &str { self.state.as_ref().unwrap().content(self) } // snip } trait State { // snip fn content<'a>(&self, post: &'a Post) -> &'a str { "" } } // snip impl State for Published { //snip fn content<'a>(&self, post: &'a Post) -> &'a str { &post.content } }
자, 이제 main.rs
에 작성한 것처럼 의도대로 작동할 것이다.
상태 패턴을 사용함으로써 각 상태의 동작을 캡슐화할 수 있었다.
만약 이런 식으로 구현하지 않았다면 Post
의 메서드에서 match
문을 사용한다거나,
main
에서 그 모든 처리를 해준다거나, 상당히 복잡한 코드가 되었을 수 있다.
물론 이 디자인 패턴이 무조건 좋기만 한 것은 아니다.
어떤 상태가 추가되면 그것과 관련된 상태 변화가 모두에게 추가되어야 한다거나
일부 로직에서 코드 중복이 발생하는 등의 단점이 존재한다.
앞서 우리가 작성해본 것은 객체지향적인 상태 패턴이다.
하지만 알다시피, Rust는 완전 객체지향 언어는 아니다.
조금 더 Rust의 특성을 살린 코드로 다시 작성해보자.
상태를 필드에 저장하는 게 아니라 상태 별로 자료형을 생성하도록 하겠다.
peter@hp-laptop:~/rust-practice/chapter17/blog$ cd ..
peter@hp-laptop:~/rust-practice/chapter17$ cargo new rust_blog --lib
Created library `rust_blog` package
peter@hp-laptop:~/rust-practice/chapter17$ cd rust_blog/
peter@hp-laptop:~/rust-practice/chapter17/rust_blog$ vi src/lib.rs
src/lib.rs
pub struct Post { content: String, } pub struct DraftPost { content: String, } pub struct PendingReviewPost { 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); } pub fn request_review(self) -> PendingReviewPost { PendingReviewPost { content: self.content, } } } impl PendingReviewPost { pub fn approve(self) -> Post { Post { content: self.content, } } }
Post
를 생성하면 DraftPost
가 생성되고
add_content
메서드는 DraftPost
에만 구현함으로써
리뷰 요청 전에만 내용을 추가할 수 있도록 하였다.
그리고 content
메서드는 Post
에만 구현함으로써
초고 - 리뷰 - 승인 과정이 끝나기 전에는 그것을 호출할 수 없도록 하였다.
상태를 변경하는 메서드도 request_review
는 DraftPost
에만,
approve
는 PendingReviewPost
에만 구현하는 식으로
상태를 잘못 파악하고 잘못 작성할 여지를 남기지 않도록 컴파일 시간에 처리하였다.
디자인 패턴에 변화를 줌으로써 main.rs
도 기존의 것에서 변형을 가해야 한다.
이전에는 하나의 Post
자료형으로 모든 것을 처리했지만
이제는 상태에 따라 다른 자료형을 사용하게 되었으니 말이다.
peter@hp-laptop:~/rust-practice/chapter17/rust_blog$ vi src/main.rs
src/main.rs
use rust_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()); }
peter@hp-laptop:~/rust-practice/chapter17/rust_blog$ cargo run
Compiling rust_blog v0.1.0 (/home/peter/rust-practice/chapter17/rust_blog)
Finished dev [unoptimized + debuginfo] target(s) in 0.38s
Running `target/debug/rust_blog`
peter@hp-laptop:~/rust-practice/chapter17/rust_blog$
let
키워드를 통해 shadowing을 해가며 코드를 작성하였다.
앞서 살펴본 두 가지 방식은 각자 장단점이 있다.
Rust 방식은 컴파일 시간의 자료형 검사를 통해
유효하지 않은 상태로의 전환을 사전 방지할 수 있다는 것과
이로써 버그 탐지가 수월하다는 장점이 있다.
이처럼 Rust는 객체지향 디자인 패턴을 따라 코드를 작성할 수도 있고
Rust 고유의 특성을 살려 코드를 작성할 수도 있다.
상황과 맥락에 따라 적절한 방식을 선택하여 개발하도록 하자.
객체지향 디자인 패턴은 항상 최선의 방법은 아닐 수 있지만
고려해볼 수 있는 선택지라는 점을 기억하자.
이 포스트의 내용은 공식문서의 17장 3절 Implementing an Object-Oriented Design Pattern에 해당합니다.