객체 지향 디자인 패턴
게시물의 초안·검토·공개 전이를 상태 객체와 타입 상태 방식으로 구현해 런타임·컴파일타임 보장의 차이를 비교합니다.
상태 패턴(state pattern)은 객체 지향 설계 패턴입니다. 이 패턴의 핵심은 어떤 값이 안에서 가질 수 있는 상태들의 집합을 정의하는 것입니다. 상태는 상태 객체(state object)들의 집합으로 나타내고, 값의 동작은 그 상태에 따라 바뀝니다. 여기서는 블로그 게시물 구조체의 예를 다룹니다. 이 구조체에는 상태를 담는 필드가 있으며, 그 상태는 초안(draft), 검토 대기(review), 게시됨(published)의 집합 가운데 하나의 상태 객체입니다.
상태 객체들은 기능을 공유합니다. 러스트에서는 물론 객체와 상속 대신 구조체와 트레이트를 씁니다. 각 상태 객체는 자기 동작을 맡고, 언제 다른 상태로 바뀌어야 하는지도 스스로 다룹니다. 상태 객체를 담은 값은 상태들의 서로 다른 동작이나 상태 사이를 언제 바꿀지에 대해 아무것도 모릅니다.
상태 패턴을 쓰면 프로그램의 업무 요구 사항이 바뀔 때, 상태를 담은 값의 코드나 그 값을 쓰는 코드를 바꾸지 않아도 된다는 장점이 있습니다. 상태 객체 가운데 하나 안의 코드만 고쳐 규칙을 바꾸거나 상태 객체를 더하면 됩니다.
먼저 더 전통적인 객체 지향 방식으로 상태 패턴을 구현하고, 그다음 러스트에서 조금 더 자연스러운 방식을 씁니다. 상태 패턴으로 블로그 게시물 작업 흐름을 단계적으로 구현해 봅니다. 최종 기능은 다음과 같습니다.
| 단계 | 기능 |
|---|---|
| 1 | 블로그 게시물은 빈 초안으로 시작합니다. |
| 2 | 초안이 끝나면 게시물의 검토를 요청합니다. |
| 3 | 게시물이 승인되면 게시됩니다. |
| 4 | 게시된 블로그 게시물만 출력할 내용을 돌려주므로, 승인되지 않은 게시물이 실수로 게시될 수 없습니다. |
이 밖에 게시물에 시도하는 다른 변경은 아무 효과가 없어야 합니다. 예를 들어 검토를 요청하기 전에 초안 블로그 게시물을 승인하려 하면, 게시물은 게시되지 않은 초안으로 남아 있어야 합니다.
전통적인 객체 지향 스타일 시도하기
같은 문제를 푸는 코드를 구조화하는 방법은 끝없이 많으며, 각각 다른 트레이드오프가 있습니다. 이 절의 구현은 더 전통적인 객체 지향 스타일로, 러스트에서 쓸 수는 있지만 러스트의 강점을 살리지는 않습니다. 나중에는 객체 지향 설계 패턴을 쓰면서도 경험 있는 러스타시안에게 더 익숙해 보일 다른 방식으로 구조화한 해결책을 보여 줍니다. 두 해결책을 비교해, 러스트 코드를 다른 언어의 코드와 다르게 설계하는 데 따르는 트레이드오프를 경험해 봅니다.
다음 코드는 이 작업 흐름을 코드로 나타낸 것입니다. 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 런타임 상태 객체 전이
같은 Post의 Draft→request review→Pending→approve→Published에서 content 숨김/노출과 no-op 전이가 달라지는 실제 상태 기계이다.
RUST · 런타임 상태
Post의 타입은 유지하고 내부 상태 객체를 런타임에 바꾼다
Draft→PendingReview→Published의 정상 전이는 진행하고, 그 외 request_review·approve 호출은 상태를 유지합니다.
Draft
Post::new()로 시작합니다. content()는 빈 문자열, request_review는 PendingReview로, approve는 Draft 그대로입니다.
PendingReview
content()는 빈 문자열, approve는 Published로, request_review는 검토 상태 그대로입니다.
Published
content()는 저장된 글을 반환합니다. request_review와 approve 모두 Published 그대로입니다.
상태 객체를 소비하고 교체 · public Post는 그대로
if let Some(s) = self.state.take() {
self.state = Some(
s.request_review()
);
}take는 Option 필드에 None을 남기고 Box 상태를 꺼냅니다. self: Box<Self> 메서드가 이전 상태 객체를 소비해 새 Box<dyn State>를 반환하면 Some으로 다시 채웁니다.
읽기와 편집은 다른 계약
content()는 상태에 위임하므로 승인된 글만 노출합니다. 이 구현의 add_text는 Post 메서드이며 Draft 외 상태에서도 호출할 수 있습니다. Draft 전용 편집은 본문의 추가 과제입니다.
공개 메서드 호출은 컴파일 가능하고 상태 정책이 런타임에 결과를 정합니다. 새 상태를 추가하면 기존 전이 구현도 변경될 수 있습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | Draft의 request_review는 PendingReview를, PendingReview의 approve는 Published를 반환한다. 다른 전이는 자신을 반환한다. Post 타입은 그대로다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Draft 본문 텍스트를 보관해도 content는 숨겨진다. |
|
|
| Pending request_review 뒤 검토 대기 상태가 된다. |
|
|
| Published approve 뒤 텍스트를 반환한다. |
|
|
Post 런타임 상태 객체 전이
같은 Post의 Draft→request review→Pending→approve→Published에서 content 숨김/노출과 no-op 전이가 달라지는 실제 상태 기계이다.
RUST · 런타임 상태
Post의 타입은 유지하고 내부 상태 객체를 런타임에 바꾼다
Draft→PendingReview→Published의 정상 전이는 진행하고, 그 외 request_review·approve 호출은 상태를 유지합니다.
Draft
Post::new()로 시작합니다. content()는 빈 문자열, request_review는 PendingReview로, approve는 Draft 그대로입니다.
PendingReview
content()는 빈 문자열, approve는 Published로, request_review는 검토 상태 그대로입니다.
Published
content()는 저장된 글을 반환합니다. request_review와 approve 모두 Published 그대로입니다.
상태 객체를 소비하고 교체 · public Post는 그대로
if let Some(s) = self.state.take() {
self.state = Some(
s.request_review()
);
}take는 Option 필드에 None을 남기고 Box 상태를 꺼냅니다. self: Box<Self> 메서드가 이전 상태 객체를 소비해 새 Box<dyn State>를 반환하면 Some으로 다시 채웁니다.
읽기와 편집은 다른 계약
content()는 상태에 위임하므로 승인된 글만 노출합니다. 이 구현의 add_text는 Post 메서드이며 Draft 외 상태에서도 호출할 수 있습니다. Draft 전용 편집은 본문의 추가 과제입니다.
공개 메서드 호출은 컴파일 가능하고 상태 정책이 런타임에 결과를 정합니다. 새 상태를 추가하면 기존 전이 구현도 변경될 수 있습니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | Draft의 request_review는 PendingReview를, PendingReview의 approve는 Published를 반환한다. 다른 전이는 자신을 반환한다. Post 타입은 그대로다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Draft 본문 텍스트를 보관해도 content는 숨겨진다. |
|
|
| Pending request_review 뒤 검토 대기 상태가 된다. |
|
|
| Published approve 뒤 텍스트를 반환한다. |
|
|
Post::new()는 Draft 상태로 시작하며, 이때 content()는 빈 문자열을 돌려줍니다. Draft에서 request_review를 부르면 PendingReview가 되고, approve를 불러도 Draft에 그대로 머뭅니다. PendingReview에서 approve를 부르면 Published가 되고, request_review는 그대로 머뭅니다. Published에서는 content()가 저장된 글을 돌려주며 두 메서드 모두 상태를 바꾸지 않습니다. 상태가 바뀌어도 바깥에 보이는 타입은 언제나 Post입니다. 공개 메서드는 어떤 상태에서 불러도 컴파일되고, 결과는 런타임에 상태가 정합니다. 그림의 아래쪽 코드는 곧 볼 take와 self: Box<Self>로 상태 객체를 바꾸는 방법입니다.
Post를 정의하고 초안 상태의 새 인스턴스 생성하기
라이브러리의 구현을 시작합니다. 어떤 내용을 담는 공개 Post 구조체가 필요하다는 것은 압니다. 그래서 다음 코드처럼 구조체의 정의와, 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으로 정합니다.
게시물 콘텐츠의 텍스트 저장하기
앞의 사용 예에서, 블로그 게시물의 텍스트 내용에 &str를 더하는 add_text라는 메서드를 부를 수 있기를 원한다는 것을 보았습니다. 나중에 content 필드의 데이터를 어떻게 읽을지 정할 수 있도록, content 필드를 pub으로 노출하는 대신 메서드로 구현합니다. add_text 메서드는 아주 단순하므로, 다음처럼 impl Post 블록에 구현을 더합니다.
impl Post {
// --생략--
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}add_text를 부르는 Post 인스턴스를 바꾸므로, add_text 메서드는 self의 가변 참조를 받습니다. 그다음 content의 String에 push_str을 불러 text 인수를 넘기고, 저장된 content에 더합니다. 이 동작은 게시물의 상태에 따라 달라지지 않으므로 상태 패턴의 일부가 아닙니다. add_text 메서드는 state 필드와 전혀 상호작용하지 않지만, 우리가 지원하고 싶은 동작의 일부입니다.
초안 게시물의 내용이 비어있음을 보장하기
add_text를 불러 게시물에 내용을 더한 뒤에도, 게시물이 아직 초안 상태이므로 content 메서드가 빈 문자열 슬라이스를 돌려주기를 원합니다. 지금은 이 요구 사항을 만족하는 가장 단순한 것으로 content 메서드를 구현합니다. 언제나 빈 문자열 슬라이스를 돌려주는 것입니다. 나중에 게시물의 상태를 바꿔 게시할 수 있게 되면 이것을 바꿉니다. 지금까지는 게시물이 초안 상태로만 있을 수 있으므로, 게시물의 내용은 언제나 비어 있어야 합니다.
impl Post {
// --생략--
pub fn content(&self) -> &str {
""
}
}이 content 메서드를 더하면, 앞의 사용 예의 첫 assert_eq!까지는 의도대로 동작합니다.
게시물에 대한 검토 요청이 게시물의 상태를 변경합니다
다음으로 게시물의 검토를 요청하는 기능을 더해야 하며, 그러면 상태가 Draft에서 PendingReview로 바뀌어야 합니다. 다음 코드가 그렇게 합니다.
impl Post {
// --생략--
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을 남깁니다. 러스트는 구조체에 채워지지 않은 필드를 두는 것을 허용하지 않기 때문입니다. 그러면 state 값을 빌리는 대신 Post에서 옮겨 낼 수 있습니다. 그다음 게시물의 state 값을 이 연산의 결과로 정합니다.
self.state = self.state.request_review(); 같은 코드로 state 값의 소유권을 얻는 대신, 잠시 state를 None으로 정해야 합니다. 그래야 Post가 새 상태로 바꾼 뒤 이전 state 값을 쓸 수 없게 됩니다.
Draft의 request_review 메서드는 새 PendingReview 구조체를 담은 새 박스 인스턴스를 돌려줍니다. 이것은 게시물이 검토를 기다리는 상태를 나타냅니다. PendingReview 구조체도 request_review 메서드를 구현하지만, 아무 변환도 하지 않습니다. 대신 자기 자신을 돌려줍니다. 이미 PendingReview 상태인 게시물에 검토를 요청하면, PendingReview 상태에 머물러야 하기 때문입니다.
이제 상태 패턴의 장점이 보이기 시작합니다. Post의 request_review 메서드는 state 값이 무엇이든 같습니다. 각 상태가 자기 규칙을 맡습니다.
Post의 content 메서드는 빈 문자열 슬라이스를 돌려주는 그대로 둡니다. 이제 Post는 Draft 상태뿐 아니라 PendingReview 상태에도 있을 수 있지만, PendingReview 상태에서도 같은 동작을 원합니다. 이제 앞의 사용 예는 두 번째 assert_eq!까지 동작합니다.
content의 동작을 변경하는 approve 메서드 추가하기
approve 메서드는 request_review 메서드와 비슷합니다. 현재 상태가 승인되었을 때 그 상태가 정한 값으로 state를 바꿉니다. 다음 코드를 보세요.
impl Post {
// --생략--
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 approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
// --생략--
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의 현재 상태에 따라 달라지기를 원하므로, 다음 코드처럼 Post가 자신의 state에 정의된 content 메서드에 위임하게 합니다.
impl Post {
// --생략--
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
// --생략--
}목표는 이 모든 규칙을 State를 구현하는 구조체 안에 두는 것이므로, state의 값에 content 메서드를 부르고 게시물 인스턴스, 곧 self를 인수로 넘깁니다. 그다음 state 값의 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! 사용 기준에서 이야기한, 컴파일러는 이해하지 못하지만 우리는 None 값이 결코 가능하지 않다는 것을 아는 경우 가운데 하나입니다.
이 시점에서 &Box<dyn State>에 content를 부르면, &와 Box에 역참조 강제 변환이 적용되어 결국 State 트레이트를 구현하는 타입에 content 메서드가 불립니다. 그러므로 State 트레이트의 정의에 content를 더해야 하며, 다음 코드처럼 어떤 상태인지에 따라 무슨 내용을 돌려줄지에 관한 로직을 그곳에 둡니다.
trait State {
// --생략--
fn content<'a>(&self, post: &'a Post) -> &'a str {
""
}
}
// --생략--
struct Published {}
impl State for Published {
// --생략--
fn content<'a>(&self, post: &'a Post) -> &'a str {
&post.content
}
}content 메서드에 빈 문자열 슬라이스를 돌려주는 기본 구현을 더합니다. 그러면 Draft와 PendingReview 구조체에는 content를 구현하지 않아도 됩니다. Published 구조체는 content 메서드를 덮어써 post.content의 값을 돌려줍니다. 기본 구현에서는 post 매개변수를 쓰지 않으므로, 쓰지 않는 변수라는 경고가 나옵니다. 경고를 없애려면 매개변수 이름을 _post로 바꾸면 됩니다. 편리하긴 하지만, State의 content 메서드가 content의 값을 정하게 하면 State와 Post가 각각 무엇을 맡는지가 덜 분명해집니다.
라이프타임과 참조 유효성에서 이야기했듯, 이 메서드에는 라이프타임 표시가 필요합니다. post의 참조를 인수로 받고 그 post의 일부의 참조를 돌려주므로, 돌려주는 참조의 라이프타임은 post 인수의 라이프타임과 이어집니다.
이제 끝났습니다. 앞의 사용 예가 모두 동작합니다. 블로그 게시물 작업 흐름의 규칙으로 상태 패턴을 구현했습니다. 규칙에 관한 로직은 Post 곳곳에 흩어져 있지 않고 상태 객체 안에 있습니다. 그림이 밝히듯 add_text는 Post의 메서드이므로 Draft가 아닌 상태에서도 부를 수 있으며, 초안에서만 고칠 수 있게 하는 것은 아래 상태 패턴의 장단점에서 제안하는 과제로 남깁니다.
열거형은 어떨까요? 서로 다른 가능한 게시물 상태를 배리언트로 가진 enum을 쓰지 않은 까닭이 궁금했을 수 있습니다. 그것도 분명히 가능한 해결책입니다. 직접 해 보고 결과를 비교해 보세요. 열거형을 쓰는 단점 하나는, 열거형의 값을 확인하는 모든 곳에 가능한 모든 배리언트를 다루는 match 표현식이나 그와 비슷한 것이 필요하다는 것입니다. 이 트레이트 객체 해결책보다 반복이 많아질 수 있습니다.
상태 패턴의 장단점
러스트가 객체 지향 상태 패턴을 구현해, 게시물이 각 상태에서 가져야 하는 서로 다른 종류의 동작을 캡슐화할 수 있다는 것을 보였습니다. Post의 메서드는 여러 동작에 대해 아무것도 모릅니다. 코드를 이렇게 구성했으므로, 게시된 게시물이 어떻게 동작할 수 있는지 알려면 한 곳만 보면 됩니다. Published 구조체에 대한 State 트레이트의 구현입니다.
상태 패턴을 쓰지 않는 다른 구현을 만들었다면, 대신 Post의 메서드나 심지어 main 코드에서 match 표현식으로 게시물의 상태를 확인하고 그곳에서 동작을 바꿨을 것입니다. 그러면 게시된 상태에 있는 게시물의 모든 의미를 이해하려고 여러 곳을 봐야 했을 것입니다.
상태 패턴에서는 Post의 메서드와 Post를 쓰는 곳에 match 표현식이 필요 없으며, 새 상태를 더하려면 새 구조체를 더하고 그 구조체에 트레이트 메서드를 한곳에서 구현하기만 하면 됩니다.
상태 패턴을 쓰는 구현은 기능을 더하도록 넓히기 쉽습니다. 상태 패턴을 쓰는 코드를 유지 보수하는 것이 얼마나 쉬운지 보려면, 다음 제안 몇 가지를 해 보세요.
| 제안 |
|---|
게시물의 상태를 PendingReview에서 다시 Draft로 바꾸는 reject 메서드를 더합니다. |
상태가 Published로 바뀌기 전에 approve를 두 번 불러야 하게 합니다. |
게시물이 Draft 상태일 때만 사용자가 텍스트 내용을 더할 수 있게 합니다. 힌트는 상태 객체가 내용에 무엇이 바뀔 수 있는지를 맡고, Post를 바꾸는 일은 맡지 않게 하는 것입니다. |
상태 패턴의 단점 하나는, 상태들이 상태 사이의 전환을 구현하므로 일부 상태가 서로 묶인다는 것입니다. PendingReview와 Published 사이에 Scheduled 같은 다른 상태를 더하면, PendingReview의 코드를 고쳐 대신 Scheduled로 바뀌게 해야 합니다. 새 상태를 더해도 PendingReview를 고칠 필요가 없다면 일이 줄겠지만, 그러려면 다른 설계 패턴으로 바꿔야 합니다.
또 다른 단점은 일부 로직이 중복되었다는 것입니다. 중복의 일부를 없애려면, State 트레이트의 request_review와 approve 메서드에 self를 돌려주는 기본 구현을 두려 할 수 있습니다. 하지만 이것은 동작하지 않습니다. State를 트레이트 객체로 쓸 때, 트레이트는 구체적인 self가 정확히 무엇일지 모르므로 반환 타입을 컴파일 시점에 알 수 없기 때문입니다. 이것은 앞에서 말한 dyn 호환 규칙 가운데 하나입니다.
다른 중복으로는 Post의 request_review와 approve 메서드의 비슷한 구현이 있습니다. 두 메서드 모두 Option의 state 필드의 값에 대해 같은 메서드의 구현에 위임하고, state 필드의 새 값을 결과로 정합니다. Post에 이런 패턴을 따르는 메서드가 많다면, 반복을 없애려고 매크로를 정의하는 것을 고려할 수 있습니다. 매크로는 매크로에서 다룹니다.
객체 지향 언어에서 정의된 그대로 상태 패턴을 구현하면, 러스트의 강점을 충분히 살리지 못합니다. 유효하지 않은 상태와 전이를 컴파일 시점 오류로 만들 수 있도록 blog 크레이트를 고쳐 봅니다.
상태와 동작을 타입으로 인코딩하기
상태 패턴을 다시 생각해 다른 트레이드오프를 얻는 방법을 보여 줍니다. 상태와 전이를 완전히 캡슐화해 바깥의 코드가 그것을 모르게 하는 대신, 상태를 서로 다른 타입으로 인코딩합니다. 그러면 러스트의 타입 검사 시스템이, 게시된 게시물만 허용되는 곳에서 초안 게시물을 쓰려는 시도를 컴파일러 오류로 막습니다.
앞의 main의 첫 부분을 생각해 봅니다.
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
}여전히 Post::new로 초안 상태의 새 게시물을 만들고, 게시물의 내용에 텍스트를 더할 수 있게 합니다. 하지만 초안 게시물에 빈 문자열을 돌려주는 content 메서드를 두는 대신, 초안 게시물에는 content 메서드가 아예 없게 합니다. 그러면 초안 게시물의 내용을 얻으려 할 때, 그 메서드가 없다는 컴파일러 오류가 납니다. 그 결과 초안 게시물의 내용을 실수로 프로덕션에 보여 주는 일이 불가능해집니다. 그 코드는 컴파일조차 되지 않기 때문입니다. 다음 코드는 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를 돌려주는 content 메서드를 가집니다.
여전히 Post::new 함수가 있지만, Post의 인스턴스 대신 DraftPost의 인스턴스를 돌려줍니다. content가 비공개이고 Post를 돌려주는 함수가 없으므로, 지금은 Post의 인스턴스를 만들 수 없습니다.
DraftPost 구조체에는 add_text 메서드가 있으므로 이전처럼 content에 텍스트를 더할 수 있지만, DraftPost에는 content 메서드가 정의되어 있지 않다는 점을 보세요. 그래서 이제 프로그램은 모든 게시물이 초안 게시물로 시작하고, 초안 게시물은 내용을 보여 줄 수 없도록 보장합니다. 이 제약을 우회하려는 시도는 모두 컴파일러 오류가 됩니다.
다른 타입으로 변환하는 것으로 전환 구현하기
그렇다면 게시된 게시물은 어떻게 얻을까요? 초안 게시물은 검토되고 승인되어야 게시될 수 있고, 검토를 기다리는 게시물은 여전히 아무 내용도 보여 주면 안 된다는 규칙을 지키고 싶습니다. 다음 코드처럼 또 다른 구조체 PendingReviewPost를 더하고, DraftPost에 PendingReviewPost를 돌려주는 request_review 메서드를, PendingReviewPost에 Post를 돌려주는 approve 메서드를 정의해 이 규칙을 구현합니다.
impl DraftPost {
// --생략--
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 메서드를 부르는 것이므로, 이제 블로그 게시물 작업 흐름을 타입 시스템으로 인코딩했습니다. 아래 그림은 이 전이를 보여 줍니다.
Post 타입 상태 전이
Post new DraftPost→request_review(self) 소비→PendingReviewPost→approve(self) 소비→Post의 소유 값과 허용 메서드가 바뀐다. 이전 값 재사용과 content 미제공은 컴파일 계약으로 표시한다.
RUST · 타입 상태
타입 상태 전이는 값을 소비하고 다음 타입을 반환한다
DraftPost·PendingReviewPost·Post의 메서드 집합을 다르게 만들어 잘못된 호출을 컴파일 때 막습니다.
DraftPost
Post::new()가 반환합니다. add_text와 request_review를 제공하며 content와 approve는 없습니다.
PendingReviewPost
request_review(self)가 DraftPost를 소비해 반환합니다. approve(self)만 제공하며 content는 없습니다.
Post
approve(self)가 검토 중 값을 소비해 반환합니다. 저장된 글을 읽는 content()를 제공합니다.
본문의 호출 순서 · 반환값을 다시 바인딩
let mut post = Post::new();
post.add_text("글");
let post = post.request_review();
let post = post.approve();
assert_eq!(post.content(), "글");본문의 긴 점심 문장을 “글”로 줄였습니다. 같은 변수 이름을 섀도잉하지만 값의 타입은 DraftPost→PendingReviewPost→Post로 바뀝니다. 전이는 self를 소비하므로 이전 상태 값은 다시 사용할 수 없습니다.
컴파일 오류 · 없는 메서드
draft.content(); // 메서드 없음
draft.approve(); // 메서드 없음DraftPost에는 이 두 메서드가 없습니다. 검토 전 승인·미게시 글 읽기는 이 API로 표현할 수 없습니다.
보장 범위 · API에 표현한 규칙
타입이 허용하는 순서와 메서드만 검사합니다. 승인자 신원·내용 품질처럼 API에 인코딩하지 않은 업무 규칙을 자동으로 증명하지 않습니다.
본문의 타입 상태 설계에서만 제공되는 메서드를 표시했습니다. 런타임 상태 객체나 enum 설계는 다른 사용성과 확장성을 가진 대안입니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| DraftPost 값 Post::new가 초안 타입의 소유 값을 준다. |
|
|
| 초안 타입 소비 request_review(self)가 초안을 소비한다. |
|
|
| Post 반환 approve(self)가 대기 값을 소비해 Post를 준다. |
|
|
Post 타입 상태 전이
Post new DraftPost→request_review(self) 소비→PendingReviewPost→approve(self) 소비→Post의 소유 값과 허용 메서드가 바뀐다. 이전 값 재사용과 content 미제공은 컴파일 계약으로 표시한다.
RUST · 타입 상태
타입 상태 전이는 값을 소비하고 다음 타입을 반환한다
DraftPost·PendingReviewPost·Post의 메서드 집합을 다르게 만들어 잘못된 호출을 컴파일 때 막습니다.
DraftPost
Post::new()가 반환합니다. add_text와 request_review를 제공하며 content와 approve는 없습니다.
PendingReviewPost
request_review(self)가 DraftPost를 소비해 반환합니다. approve(self)만 제공하며 content는 없습니다.
Post
approve(self)가 검토 중 값을 소비해 반환합니다. 저장된 글을 읽는 content()를 제공합니다.
본문의 호출 순서 · 반환값을 다시 바인딩
let mut post = Post::new();
post.add_text("글");
let post = post.request_review();
let post = post.approve();
assert_eq!(post.content(), "글");본문의 긴 점심 문장을 “글”로 줄였습니다. 같은 변수 이름을 섀도잉하지만 값의 타입은 DraftPost→PendingReviewPost→Post로 바뀝니다. 전이는 self를 소비하므로 이전 상태 값은 다시 사용할 수 없습니다.
컴파일 오류 · 없는 메서드
draft.content(); // 메서드 없음
draft.approve(); // 메서드 없음DraftPost에는 이 두 메서드가 없습니다. 검토 전 승인·미게시 글 읽기는 이 API로 표현할 수 없습니다.
보장 범위 · API에 표현한 규칙
타입이 허용하는 순서와 메서드만 검사합니다. 승인자 신원·내용 품질처럼 API에 인코딩하지 않은 업무 규칙을 자동으로 증명하지 않습니다.
본문의 타입 상태 설계에서만 제공되는 메서드를 표시했습니다. 런타임 상태 객체나 enum 설계는 다른 사용성과 확장성을 가진 대안입니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| DraftPost 값 Post::new가 초안 타입의 소유 값을 준다. |
|
|
| 초안 타입 소비 request_review(self)가 초안을 소비한다. |
|
|
| Post 반환 approve(self)가 대기 값을 소비해 Post를 준다. |
|
|
Post::new()는 DraftPost를 돌려주며, DraftPost에는 add_text와 request_review만 있고 content와 approve는 없습니다. request_review(self)는 DraftPost를 소비해 approve만 가진 PendingReviewPost를 돌려주고, approve(self)는 그것을 소비해 content()만 가진 Post를 돌려줍니다. 그림 아래쪽의 호출 순서는 이 글의 점심 문장을 "글"로 줄인 것으로, 같은 이름 post를 섀도잉하지만 값의 타입은 DraftPost에서 PendingReviewPost, Post로 바뀝니다. draft.content()나 draft.approve()처럼 없는 메서드를 부르면 컴파일되지 않으므로, 검토 전 승인이나 게시 전 읽기는 이 API로 표현할 수조차 없습니다. 다만 타입이 검사하는 것은 API에 담은 순서와 메서드뿐이며, 승인한 사람이 누구인지나 내용의 품질 같은 규칙까지 증명해 주지는 않습니다.
main도 조금 고쳐야 합니다. request_review와 approve 메서드는 호출된 구조체를 바꾸는 대신 새 인스턴스를 돌려주므로, 돌려받은 인스턴스를 저장하려고 let post = 섀도잉 대입을 더 해야 합니다. 초안과 검토 대기 게시물의 내용이 빈 문자열이라는 단언도 쓸 수 없고 필요도 없습니다. 그 상태에서 게시물의 내용을 쓰려는 코드는 더 이상 컴파일할 수 없기 때문입니다. 바뀐 main은 다음과 같습니다.
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의 구현 안에 완전히 캡슐화되어 있지 않습니다. 하지만 이제 타입 시스템과 컴파일 시점에 일어나는 타입 검사 덕분에 유효하지 않은 상태가 불가능해졌다는 이점을 얻었습니다. 게시되지 않은 게시물의 내용을 보여 주는 것 같은 특정 버그가 프로덕션에 가기 전에 발견되도록 보장됩니다.
이 절의 시작에서 제안한 작업을 이 blog 크레이트에도 해 보며, 이 버전의 코드에서 설계가 어떻게 느껴지는지 보세요. 몇몇 작업은 이 설계에서 이미 끝나 있을 수도 있습니다.
러스트에서 객체 지향 설계 패턴을 구현할 수 있다는 것을 보았지만, 상태를 타입 시스템으로 인코딩하는 것 같은 다른 패턴도 러스트에서 쓸 수 있습니다. 이 패턴들에는 서로 다른 트레이드오프가 있습니다. 객체 지향 패턴에 아주 익숙하더라도, 러스트의 기능을 활용하도록 문제를 다시 생각하면 컴파일 시점에 일부 버그를 막는 것 같은 이점을 얻을 수 있습니다. 소유권처럼 객체 지향 언어에는 없는 기능 때문에, 객체 지향 패턴이 러스트에서 언제나 가장 좋은 해결책은 아닐 것입니다.
정리
이번 장을 읽고 러스트가 객체 지향 언어라고 생각하든 아니든, 이제 러스트에서 트레이트 객체로 일부 객체 지향 기능을 얻을 수 있다는 것을 압니다. 동적 디스패치는 약간의 런타임 성능을 대가로 코드에 약간의 유연함을 줄 수 있습니다. 이 유연함으로 코드의 유지 보수를 돕는 객체 지향 패턴을 구현할 수 있습니다. 러스트에는 소유권처럼 객체 지향 언어에 없는 다른 기능도 있습니다. 객체 지향 패턴이 러스트의 강점을 살리는 가장 좋은 방법은 언제나 아니지만, 쓸 수 있는 선택지입니다.
- 상태 패턴은 값의 상태를 상태 객체로 나타내고, 각 상태 객체가 자기 동작과 다음 상태로의 전이를 맡게 합니다.
Option<Box<dyn State>>필드에서take로 상태를 꺼내고,self: Box<Self>를 받는 메서드로 이전 상태를 소비해 새 상태를 돌려받습니다.- 상태 패턴은 새 상태를 더하기 쉽지만, 상태끼리 전이로 묶이고 일부 로직이 중복되며, 잘못된 호출도 컴파일되고 결과는 런타임에 정해집니다.
- 상태를
DraftPost,PendingReviewPost,Post같은 타입으로 인코딩하고 전이 메서드가self를 소비해 다음 타입을 돌려주게 하면, 잘못된 호출이 컴파일 오류가 됩니다. - 타입 상태 방식은 바깥 코드가
let post = ...로 다시 대입해야 하지만, API에 담은 규칙을 컴파일 시점에 보장합니다.
연습 문제
1. 반려하기
상태 객체 방식의 blog에 PendingReview를 Draft로 되돌리는 reject 메서드를 더하려 합니다. 무엇을 더해야 하나요? 타입 상태 방식에서는 어떻게 하나요?
해설 보기
상태 객체 방식에서는 State 트레이트에 fn reject(self: Box<Self>) -> Box<dyn State>;를 더하고, PendingReview는 Box::new(Draft {})를, Draft와 Published는 self를 돌려주도록 구현합니다. 그다음 Post에 request_review와 같은 모양의 reject 메서드를 더해 take로 위임합니다. 타입 상태 방식에서는 PendingReviewPost에 pub fn reject(self) -> DraftPost { DraftPost { content: self.content } }를 더하면 되며, DraftPost와 Post에는 reject가 없으므로 다른 상태에서 반려하는 호출은 컴파일되지 않습니다.
2. 어느 쪽이 알맞을까
게시물의 상태를 데이터베이스에서 읽어 오며, 어떤 상태인지는 실행해 봐야 압니다. 상태 객체 방식과 타입 상태 방식 가운데 어느 쪽이 더 쓰기 쉬울까요?
해설 보기
상태를 런타임에 알게 되므로, 하나의 Post 타입 안에 상태를 담는 상태 객체 방식이나 열거형 방식이 더 쓰기 쉽습니다. 타입 상태 방식은 상태마다 타입이 달라서, 읽어 온 값에 따라 서로 다른 타입을 돌려주려면 결국 그 타입들을 감싼 열거형이 필요합니다. 반대로 한 프로그램 안에서 게시물을 만들어 정해진 순서로 처리한다면, 잘못된 순서를 컴파일 시점에 막아 주는 타입 상태 방식이 이점이 큽니다.
이것으로 16장을 마칩니다. 다음 장의 패턴이 사용될 수 있는 모든 곳부터는 러스트의 패턴을 쓸 수 있는 곳과 패턴 문법을 자세히 살펴봅니다.