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

객체 지향 디자인 패턴 구현하기

상태 패턴(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 크레이트에 대해 직접 시도해 보고 이 설계가 어떻게 느껴지는지 생각해 보세요. 주어진 작업 일부는 이 새 설계에서는 이미 해결된 상태일 수도 있습니다.

지금까지 본 것처럼, 러스트는 객체 지향 디자인 패턴을 구현할 수 있습니다. 하지만 상태를 타입 시스템 안에 인코딩하는 것처럼, 다른 패턴도 충분히 사용 가능합니다. 각 접근은 서로 다른 트레이드오프를 가집니다. 여러분이 객체 지향 패턴에 아주 익숙하다 하더라도, 러스트의 특성을 적극적으로 활용하도록 문제를 다시 생각해 보는 것이 컴파일 시점에 버그를 막는 등 추가 이점을 줄 수 있습니다. 러스트에는 소유권 같은, 일반적인 객체 지향 언어에는 없는 기능이 있기 때문에, 객체 지향 패턴이 언제나 최선의 해법은 아닙니다. 하지만 분명 사용할 수 있는 선택지 중 하나이긴 합니다.

정리

이 장을 읽고도 러스트가 객체 지향 언어인지 확신이 서지 않을 수는 있지만, 적어도 이제 러스트에서 일부 객체 지향 기능을 얻기 위해 트레이트 객체를 사용할 수 있다는 사실은 알게 되었습니다. 동적 디스패치는 약간의 런타임 성능 비용을 치르는 대신 코드에 유연성을 줍니다. 그리고 그 유연성을 사용해 유지보수에 도움이 되는 객체 지향 패턴도 구현할 수 있습니다. 동시에, 러스트는 객체 지향 언어에는 없는 소유권 같은 기능도 제공합니다. 객체 지향 패턴이 러스트의 강점을 활용하는 최선의 방식이 아닐 때도 많지만, 사용할 수 있는 도구임은 분명합니다.

다음 장에서는 또 하나의 강력한 러스트 기능인 패턴을 살펴보겠습니다. 책 전반에서 짧게 계속 사용해 왔지만, 아직 그 전체 능력을 제대로 본 적은 없습니다. 이제 그것을 본격적으로 파헤쳐 봅시다!