Design pattern - State pattern
::: tip 예제 코드 https://github.com/ryujt/design_pattern :::
강의 개요
이번 강의에서는 State 패턴이 무엇이고 언제 사용하는 지에 대해서 알아봅니다.
우선 위키백과의 정의를 살펴보면 다음과 같습니다.
::: tip 상태 패턴(state pattern) 상태 패턴(state pattern)은 객체 지향 방식으로 상태 기계를 구현하는 행위 소프트웨어 디자인 패턴이다. 상태 패턴을 이용하면 상태 패턴 인터페이스의 파생 클래스로서 각각의 상태를 구현함으로써, 또 패턴의 슈퍼클래스 에 의해 정의되는 메소드를 호출하여 상태 변화를 구현함으로써 상태 기계를 구현한다.
- 위키백과 :::
조금 단순하게 설명해보겠습니다. State 패턴은 상태 또는 조건에 따라 각기 다른 일을 처리하고 싶을 때 활용할 수 있는 패턴입니다. 가만히 들어보면 조건문만으로도 충분한 것 같습니다.
조건문대신 State 패턴이 유리한 경우는 아래와 같이 두 가지의 경우를 생각할 수 있습니다.
::: tip State 패턴 적용이 더 유리한 경우
- 상태에 영향을 받는 메소드가 여럿인 경우
- 과거의 상태가 현재에게 영향을 주거나 현재의 상태가 미래에서 결정이 나는 경우 :::
상태가 이전 상태에게 영향을 받고 이후 상태에게 영향을 줄 수 있다면, 서로의 연관 관계가 복잡해질 가능성이 있습니다. 단지 현재의 상태나 조건만으로 결정을 지을 수 없기 때문에 이전의 상태의 변화 내용을 참조해야합니다. 이런 경우라면 State 패턴을 적용할 것을 고려해 볼 필요가 있습니다.
아래는 상태도라고 부르며, 상태들의 관계를 다이어그램으로 나타낸 것입니다. 어떤 조건에서 상태가 다른 상태로 전이되어야 하는 지에 대한 정보를 제공합니다.
상태에 영향을 받는 메소드가 여럿인 경우
상태 인터페이스
interface State {
void insertCoin(); // 동전 투입
void refund(); // 환불 요청
void selectItem(); // 상품 선택
void takeItem(); // 상품 꺼내기
}
동전 없음 클래스의 예
class StateNoCoin implements State {
void insertCoin() {
context.setState(new StateHasCoin());
}
public void refund() {
System.out.println("반환 할 동전이 없습니다.");
}
public void selectItem() {
System.out.println("동전을 먼저 넣어 주세요.");
}
public void takeItem() {
System.out.println("꺼낼 상품이 없습니다.");
}
}
동전 있음 클래스의 예
class StateHasCoin implements State {
void insertCoin() {
System.out.println("이미 동전을 투입하셨습니다.");
}
public void refund() {
context.setState(new StateNoCoin());
}
public void selectItem() {
context.setState(new ItemReleased());
}
public void takeItem() {
System.out.println("꺼낼 상품이 없습니다.");
}
}
if 문을 사용했을 때와 비교
class Context {
void insertCoin() {
switch (state) {
case noCoin:
state = hasCoin;
break;
case hasCoint:
System.out.println("이미 동전을 투입하셨습니다.");
break;
case itemReleasing:
System.out.println("상품 배출 중입니다.");
break;
case itemReleased:
System.out.println("상품 배출 완료, 잠시만 기다려주세요.");
break;
}
}
void refund() {
switch (state) {
case noCoin:
System.out.println("반환 할 동전이 없습니다.");
break;
case hasCoint:
state = noCoin;
break;
case itemReleasing:
System.out.println("상품 배출 중입니다.");
break;
case itemReleased:
System.out.println("상품 배출 완료, 잠시만 기다려주세요.");
break;
}
}
void selectItem() {
...
}
void takeItem() {
...
}
}
실제 구현 코드
기본 코드 살펴보기
import java.util.Scanner;
class VendingMachine {
public void setState(State state) {
this.state = state;
state.context = this;
System.out.println("상태변경: " + state.toString());
}
public State getState() {
return state;
}
private State state;
}
class State {
VendingMachine context = null;
public void insertCoin() {}
public void refund() {}
public void selectItem() {}
public void takeItem() {}
}
class Main {
public static void main(String[] args) {
VendingMachine machine = new VendingMachine();
Scanner in = new Scanner(System.in);
while (true) {
String line = in.nextLine();
switch (line) {
case "i": machine.getState().insertCoin(); break;
case "r": machine.getState().refund(); break;
case "s": machine.getState().selectItem(); break;
case "t": machine.getState().takeItem(); break;
case "q": return;
}
}
}
}
완성된 코드
import java.util.Scanner;
class VendingMachine {
public void setState(State state) {
this.state = state;
state.context = this;
System.out.println("상태변경: " + state.toString());
}
public State getState() {
return state;
}
private State state;
}
class State {
VendingMachine context = null;
public void insertCoin() {}
public void refund() {}
public void selectItem() {}
public void takeItem() {}
}
class StateNoCoin extends State {
public void insertCoin() {
context.setState(new StateHasCoin());
}
public void refund() {
System.out.println("반환 할 동전이 없습니다.");
}
public void selectItem() {
System.out.println("동전을 먼저 넣어 주세요.");
}
public void takeItem() {
System.out.println("꺼낼 상품이 없습니다.");
}
}
class StateHasCoin extends State {
public void insertCoin() {
System.out.println("이미 동전을 투입하셨습니다.");
}
public void refund() {
System.out.println("동전을 반환합니다.");
context.setState(new StateNoCoin());
}
public void selectItem() {
context.setState(new ItemReleased());
}
public void takeItem() {
System.out.println("꺼낼 상품이 없습니다.");
}
}
class ItemReleased extends State {
public void insertCoin() {
System.out.println("상품을 먼저 꺼내주세요.");
}
public void refund() {
System.out.println("반환 할 동전이 없습니다.");
}
public void selectItem() {
System.out.println("상품을 먼저 꺼내주세요.");
}
public void takeItem() {
context.setState(new StateNoCoin());
}
}
class Main {
public static void main(String[] args) {
VendingMachine machine = new VendingMachine();
machine.setState(new StateNoCoin());
Scanner in = new Scanner(System.in);
while (true) {
String line = in.nextLine();
switch (line) {
case "i": machine.getState().insertCoin(); break;
case "r": machine.getState().refund(); break;
case "s": machine.getState().selectItem(); break;
case "t": machine.getState().takeItem(); break;
case "q": return;
}
}
}
}
과거 또는 미래의 상태가 현재에 영향을 주는 경우
State 패턴을 사용한 경우와 그렇지 않은 경우를 비교하기 위해서 비교적 단순한 예제를 통해서 설명해보도록 하겠습니다. 주어진 C 코드 문자열 속에서 나누기 기호를 찾는 것이 주어진 문제입니다.
문자열의 문자들을 하나씩 일어나가다가 현재의 문자가 슬러시(/)였다면 바로 나누기라고 판단할 수가 없다는 것에 유의해야 합니다. 이전 문자 또는 다음 문자가 슬러시라면 나누기 기호가 아니고 주석의 일부라고 판단해야 하기 때문입니다.
::: tip 문제를 단순하게 하기 위해서 /* */ 형태의 주석은 무시합니다. :::
조건문과 플래그를 사용하는 방법
가장 단순하게 접근할 수 있는 조건문을 사용하는 예제입니다. 이전과 이후 상태를 함께 판단해야 하기 때문에 flag 변수를 만들어서 이전 정보를 기억했다가 사용하는 방식을 사용하고 있습니다.
#include <stdio.h>
#include <string>
using namespace std;
int main()
{
string code = "printf(..., 2 / 4); // 나누기 결과 출력";
char ch_prev = 0;
for (int i=0; i<code.size(); i++) {
char ch = code.at(i);
char ch_next = 0;
if (i < (code.size() -1)) ch_next = code.at(i + 1);
if ((ch == '/') && (ch_prev != '/') && (ch_next != '/')) {
printf("%d: 나누기를 찾았습니다. \n", i);
}
ch_prev = ch;
}
}
- 11-22: 문자열의 크기만큼 반복하면서 한 문자씩 검사합니다.
- 14-15: 다음 문자를 가져옵니다. 현재 문자가 문자열의 마지막이 아닌 경우에만 가져옵니다.
- 17-19: 현재 문자가 슬러시인데, 이전과 이후 문자가 슬러시가 아니라면 현재 문자는 나누기 기호라고 판단합니다.
- 21: 다음 반복에서 사용하기 위해서 현재의 문자를 이전 문자를 담는 변수에 저장합니다.
클래스 없이 구현한 State 패턴
이번에는 상태도를 이용해서 같은 문제를 표현해보겠습니다.
- None
- 초기 상태입니다.
- 초기 상태에서 문자를 계속 입력받는 이벤트가 발생한다고 가정합니다.
- 초기 상태에서 탈출할 수 있는 조건은 슬러시(/)가 발견되는 화살표 하나입니다. 즉 슬러시가 발견되면 Slash 상태가 됩니다.
- Slash
- Slash 상태에서는 두 가지 탈출 조건이 가능합니다.
- 슬러시가 발견되었을 경우 → 상태를 Comment로 변경합니다.
- 슬러시 이외의 문자가 발견되었을 경우 → 자신은 나누기 기호라고 판단할 수 있고, 초기 상태로 돌아가서 처음부터 다시 시작합니다.
- Slash 상태에서는 두 가지 탈출 조건이 가능합니다.
- Comment
- 화살표가 하나이면 조건이 없습니다. 무조건 상태가 초기 상태로 변경됩니다.
아래 상태도를 그대로 코드로 표현한 것입니다. 클래스를 사용하지 않고 분기문만으로 상태를 표현한 예제입니다.
#include <stdio.h>
#include <string>
using namespace std;
enum State {stNone, stSlash, stComment};
State state;
void do_none(int i, char ch)
{
if (ch == '/') state = stSlash;
}
void do_slash(int i, char ch)
{
if (ch == '/') state = stComment;
else {
printf("%d: 나누기를 찾았습니다. \n", i-1);
state = stNone;
}
}
void do_comment(int i, char ch)
{
state = stNone;
}
int main()
{
string code = "printf(..., 2 / 4); // 나누기 결과 출력";
state = stNone;
for (int i=0; i<code.size(); i++) {
switch (state) {
case stNone: do_none(i, code.at(i)); break;
case stSlash: do_slash(i, code.at(i)); break;
case stComment: do_comment(i, code.at(i)); break;
}
}
}
- 33-39: 문자열의 크기만큼 반복하면서 한 문자씩 검사합니다.
- 34-38: 현재 상태에 따라 일대 일로 지정된 함수를 실행합니다. 현재 위치와 문자를 파라메터로 전달합니다.
- 9-12: None(초기) 상태의 경우만 고려해서 처리하는 함수입니다.
- 슬러시가 발견되면 상태를 stSlash로 변경합니다. 상태도와 비교해보세요.
- 14-21: Slash 상태의 경우만 고려해서 처리하는 함수입니다.
- 슬러시가 발견되면 상태를 stComment로 변경합니다. 상태도와 비교해보세요.
- 그 이외의 문자가 발견되면 초기 상태로 변경합니다.
- 23-26: Comment 상태의 경우만 고려해서 처리하는 함수입니다.
- 무조건 상태를 초기 상태로 변경합니다.
::: tip 이번 예제의 장점
- 각 상태가 독립적으로 구별되어서 문제를 보다 단순한 작은 문제로 나눠서 처리할 수 있다.
- 상태도를 통해서 복잡한 인과 관계를 보다 명확하게 설계 할 수 있다. :::
클래스를 이용한 State 패턴
이번에는 클래스를 이용하여 State 패턴을 구현해보겠습니다. 아래 클래스 다이어그램은 이전에 풀었던 문제를 해결하기 위해서 설계된 내용을 설명하고 있습니다.
::: tip 아래 클래스 다이어그램은 일반적으로 볼 수 있는 State 패턴의 다이어그램과 다른 부분이 있습니다. ScannerInterface 부분인데 요. Scanner는 State를 참조하게 되어 있습니다. 그런데 State도 Scanner의 상태를 변경하기 위해서 setState() 메소드를 참조해야합니다. 이때 순환 참조가 문제가 발생합니다.
C/C++ 언어에서는 순환 참조를 하기위해서 헤더 파일을 사용해야 하는데요, 코드를 분리하지 않고 하나로 통일하기 위해서 인테페이스 부분만 참조하도록 하고, Scanner가 이를 상속받는 구조를 만들었습니다. 덤으로 State 객체들이 Scanner에서 필요하지 않은 부분들은 감출 수 있다는 장점도 있습니다. :::
코드가 길어서 부분적으로 나눠서 설명하도록 하겠습니다. 코드의 양이 늘었지만, 이전의 방법보다 클래스를 사용하는 방법이 효과적인 경우가 있습니다. 해법은 언제나 다양하고 상황에 맞춰서 최선을 선택하는 것이 중요합니다. 어느 해법이 언제나 우월한 경우는 없습니다.
전반적인 소스 구조
class State;
class ScannerInterface {
public:
virtual void setState(State* state) {}
State* state_none_;
State* state_slash_;
State* state_comment_;
};
class State {
public:
virtual void scan(int index, char ch) = 0;
virtual void pre(State* prior) {}
virtual void post(State* next) {}
ScannerInterface* scanner_;
};
class StateNone : State {
};
class StateSlash : State {
};
class StateComment : State {
};
class Scanner : ScannerInterface {
public:
void scan(string code) {}
private:
State* current_state_;
};
int main()
{
Scanner scanner;
scanner.scan("printf(..., 2 / 4); // 나누기 결과 출력");
}
- 우선 클래스 다이어그램의 중요 인터페이스 부분들을 위주로 전반적인 형태만 구현한 상태입니다.