본문 바로가기
Concepts/Design Pattern

행동 패턴 - 상태(State)

by ocwokocw 2021. 6. 19.

- 참조: https://refactoring.guru/design-patterns/state
- 참조: GoF의 디자인 패턴

- 상태(State) 패턴

상태 표현 객체(Object for state) 라고도 불리우며, 객체 내부의 상태가 변할 때 자신의 행동을 대체할 수 있도록 하는 패턴이다. 마치 객체가 자신의 클래스를 바꾸는것처럼 동작한다.


- 문제점

상태 패턴은 대학교때 컴파일러 수업을 들었다면 자세한 내용이 기억나지는 않더라도 들어봤을법한 유한 상태 기계(Finite-State Machine)의 개념과 깊은 연관성이 있다.

 

메인 발상은 어떤 순간에 프로그램에서 존재하는 상태의 수가 유한하다는 것이다. 특정 상태 마다 프로그램은 모두 다르게 동작하며, 하나의 상태에서 다른 상태로 즉시 전환된다. 하지만 현재 상태에 따라서는 다른 상태로 전환이 일어날수도 혹은 일어나지 않을수도 있다. 이런 전환규칙들은 '전이(transition)' 라고 불리며, 유한하고 미리 정의되어 있다.

 

위와 같은 사상을 객체에도 적용할 수 있다. Document 라는 클래스가 있다고 가정해보자. 문서는 3 가지 상태중에 하나를 갖는다.(Draft, Moderation, Published) 문서의 publish 메소드는 각 상태에 따라 다르게 동작한다.

  • Draft: 해당 문서는 moderation 상태로 변한다.
  • Moderation: 해당 문서를 public 상태로 만드는데, 관리자에게만 해당 권한이 주어진다.
  • Published: 별다른 행동을 하지 않는다.

상태 기계는 객체의 현재 상태에 따라 적절한 행동을 취하기 위해서 대부분 분기문(if, switch)으로 구현한다. 대게 이런 "상태(state)"는 객체 필드들 값의 집합이라고 할 수 있다. 사실 유한 상태 기계 라는 단어를 들어본적이 없더라도, 한번쯤은 자신도 모르게 구현해보았을 가능성이 크다.

 

class Document is
    field state: string
    // ...
    method publish() is
        switch (state)
            "draft":
                state = "moderation"
                break
            "moderation":
                if (currentUser.role == 'admin')
                    state = "published"
                break
            "published":
                // Do nothing.
                break
    // ...

 

분기문을 기반으로 상태 기계를 구현할 때, Document 클래스에 상태들을 점차 추가하고, 상태 의존적인 행동들도 추가하기 시작하면 단점이 드러나기 시작한다. 대부분의 메소드들에서 현재 상태에 따라 적절한 메소드의 행동을 취하려고 하다보면 괴랄한 조건문들이 나타나기 시작한다. 이런 방식의 코드는 유지보수 하기가 매우 어려운데, 전이 로직이 변경되면 모든 메소드의 상태 조건들도 변경해야하기 때문이다.

 

프로젝트가 진행될수록 문제점도 점점 커져간다. 설계 단계에서 모든 전이들과 가능한 상태들을 예측하는것은 매우 어렵다.


- 해결책

상태 패턴은 객체가 가질 수 있는 상태들에 대해 클래스들을 생성하고, 특정 상태의 행동을 클래스들안으로 추출한다.

원본 객체("context")에서 자신의 모든 행동을 구현하지 않고, 상태 객체들 중 현재 상태를 표현하는 하나의 상태를 참조해서 관련된 동작들을 해당 객체로 위임한다.

 

Context 가 다른 상태로 전이할때에는, 활성화된 상태 객체를 새로운 상태를 나타내는 다른 객체로 교체한다. 이런 방식이 가능하려면 모든 상태 클래스들이 동일한 인터페이스를 구현해야하며, context 자신도 해당 인터페이스를 통해서 상태 객체들과 교류해야한다.

 

이런 구조는 전략패턴과 비슷해보이지만 중요한 차이점이 하나 있다. 상태 패턴에서는 특정 상태들이 서로를 인식하고, 어떤 상태에서 다른 상태로의 전이를 시작하는 반면 전략패턴에서는 서로에 대해 알 수 없다.


- 실생활 예제

스마트폰의 버튼과 스위치들은 기계의 현재 상태에따라 다르게 동작한다.

  • 폰이 잠금해제일때에는 버튼을 누르면 다양한 기능을 수행한다.
  • 폰이 잠금상태일때에는 버튼을 누르면 잠금이 풀린다.
  • 폰이 배터리가 없을 때에는 버튼을 눌러도 충전화면이 나타난다.

- 구조

상태 패턴의 다이어그램에 대해 알아보자.

  • Context: 사용자가 관심 있어하는 인터페이스를 정의하고 있다. 세부 상태 객체들중의 하나를 참조하고 있으며 모든 세부 동작을 해당 객체에 위임한다. Context 는 State 인터페이스를 통해서 상태 객체와 교류한다. 또한 새로운 상태 객체를 넘겨주기 위해 setter 메소드도 정의되어 있다.
  • State: 상태별로 필요한 메소드를 정의하고 있다.
  • ConcreteStates: 상태별로 필요한 메소드들에 대한 구현체이다. 여러 상태들에 걸쳐 나타나는 코드의 중복을 피하기 위해서 공통적인 행동을 캡슐화하고 있는 추상클래스를 정의하는 경우도 있다. 상태 객체에 Context 객체 참조기능을 제공하는 경우도 있다. 이렇게 하면 상태 전이를 할 수도 있고, 필요한 정보를 Context 객체로부터 가져오는것도 가능해진다.

Context 와 ConcreteStates 둘 다 Context 의 다음 상태를 설정할 수 있으며, Context 에 연결된 상태 객체를 대체하여 실제 상태 전이를 일으킬 수 있다.


- 시나리오

이번 예제에서는 State 패턴을 이용하여 미디어 플레이어의 제어 기능들이 현재 재생 상태에 따라 다른 행동을 수행하는 시나리오를 구현해본다.

Player의 메인 객체는 대부분의 동작을 수행하는 상태 객체에 연결되어 있다. Player의 현재 상태 객체가 다른 객체로 대체되면, Player 의 행동들도 변화한다.


- Java 예제

시나리오의 뮤직 플레이어를 구현해보자. 우선 Context 참여자에 해당하는 핵심 클래스 Player 를 정의해보자. 코드가 좀 길긴하지만 복잡하진 않다.

 

State 에 해당하는 인터페이스 State 와 현재 State를 변경할 수 있는 setter 인 changeState 를 제공한다. State 에 따라 행동이 변경되어야 하는 메소드들에서는 자신이 행동에 관여하지 않고, state 객체의 메소드들에게 행동을 위임한다.

 

그 외의 나머지는 Player 의 고유 동작들을 정의한 메소드들이다. 이전곡과 다음곡 재생을 위해 곡 추가시 ListIterator 를 이용하여 참조할 수 있도록 구성하였다. 이터레이터의 경우 UnModifiable 이기 때문에 곡의 구성이 변경되면 다시 이터레이터를 얻어와야 한다.

 

public class Player {

	public static final String UI_PAUSE = "PAUSE";
	public static final String UI_PLAYBACK = "PLAYBACK"; 
	
	private State state;
	private ListIterator<String> playItr;
	
	public String UI;
	public int volumn;
	public List<String> playlist;
	public String currentSong;
	
	public Player() {
		super();
		
		this.UI = UI_PAUSE;
		this.volumn = 100;
		this.playlist = new ArrayList<>();
		this.playItr = this.playlist.listIterator();
		this.currentSong = "";
	}
	
	public void addSong(List<String> songs) {
		songs.forEach(song -> playlist.add(song));
		this.playItr = playlist.listIterator();
	}
	
	public void changeState(State state) {
		this.state = state;
	}
	
	public void clickLock() {
		this.state.clickLock();
	}
	
	public void clickPlay() {
		this.state.clickPlay();
	}
	
	public void clickNext() {
		this.state.clickNext();
	}
	
	public void clickPrev() {
		this.state.clickPrev();
	}
	
	public void startPlayback() {
		this.UI = UI_PLAYBACK;
	}
	
	public void stopPlayback() {
		this.UI = UI_PAUSE;
	}
	
	public void nextSong() {
		if(playItr.hasNext()) {
			currentSong = playItr.next();
		}
		else {
			System.out.println("There is no next song.");
		}
	}
	
	public void prevSong() {
		if(playItr.hasPrevious()) {
			currentSong = playItr.previous();
		}
		else {
			System.out.println("There is no prev. song.");
		}
	}
	
	public void fastForward() {
		System.out.println("Doing fastForward");
	}
	
	public void rewind() {
		System.out.println("Doing rewind");
	}
	
	public boolean isPlayingState() {
		return UI_PLAYBACK.equals(this.UI);
	}
}

 

State에 해당하는 State는 추상클래스로 정의하였다. 구조 Section 다이어그램에서는 세부 클래스들이 인터페이스를 구현하고, 세부 State 클래스에서 Context를 참조한다.

 

이번 Java 예제에서는 Context 를 참조하는 행동은 세부 State 의 공통적인 특성으로 여길 수 있어서, 추상클래스에서 참조하도록 한다. 또 자식 클래스에서 해당 field 참조를 위해 접근 제어자는 protected 가 되어야 한다. 상태에 따라 행동이 달라져야 하는 click 메소드들은 추상 메소드로 선언한다.

 

public abstract class State {

	protected Player player;

	public State(Player player) {
		super();
		this.player = player;
	}
	
	public abstract void clickLock();
	public abstract void clickPlay();
	public abstract void clickNext();
	public abstract void clickPrev();
}

 

세부 클래스 중 ReadyState를 살펴보자. clickLock과 clickPlay 메소드에서는 Context 클래스 player 의 상태를 변경하고 있다.  player 에서 참조하고 있던 state가 변경되어 행동도 변경된다. 반면 clickNext와 clickPrev 는 state 변경없이 player의 고유기능을 그대로 호출한다.

 

public class ReadyState extends State{

	public ReadyState(Player player) {
		super(player);
	}

	@Override
	public void clickLock() {
		System.out.println("ReadyState -> LockedState");
		
		player.changeState(new LockedState(player));
	}

	@Override
	public void clickPlay() {
		System.out.println("clickPlay in ReadyState");
		
		player.startPlayback();
		player.changeState(new PlayingState(player));
	}

	@Override
	public void clickNext() {
		System.out.println("Move next song");
		
		player.nextSong();
	}

	@Override
	public void clickPrev() {
		System.out.println("Move prev. song");
		
		player.prevSong();
	}
}

 

나머지 세부 클래스인 LockedState와 PlayingState 클래스는 아래와 같다. LockedState 에서는 clickLock 에서만 행동을 수행하며, 나머지 메소드에서는 행동을 수행하지 않는다.

 

State 패턴은 구현 방법도 중요하지만 세부 State 클래스에서 특정 행동에 따라 다음 상태가 무엇이 되어야 하는지, 즉 전이를 잘 표현해야 한다. 헷갈린다면 종이에다가 유한 상태 기계를 그려보면서 설계를 하는것이 좋겠다.

 

public class LockedState extends State {

	public LockedState(Player player) {
		super(player);
	}

	@Override
	public void clickLock() {
		if(player.isPlayingState()) {
			System.out.println("Player is playing state. LockedState -> PlayingState");
			
			player.changeState(new PlayingState(player));
		}
		else {
			System.out.println("Player is pause state. LockedState -> ReadyState");
			
			player.changeState(new ReadyState(player));
		}
	}

	@Override
	public void clickPlay() {
		System.out.println("Sceen is locked");
	}

	@Override
	public void clickNext() {
		System.out.println("Sceen is locked");
	}

	@Override
	public void clickPrev() {
		System.out.println("Sceen is locked");
	}
}

public class PlayingState extends State {

	public PlayingState(Player player) {
		super(player);
	}

	@Override
	public void clickLock() {
		System.out.println("PlayingState -> LockedState");
		
		player.changeState(new LockedState(player));
	}

	@Override
	public void clickPlay() {
		System.out.println("PlayingState -> ReadyState");
		
		player.stopPlayback();
		player.changeState(new ReadyState(player));
	}

	@Override
	public void clickNext() {
		System.out.println("Play next song");
		
		player.nextSong();
	}

	@Override
	public void clickPrev() {
		System.out.println("Play prev. song");
		
		player.prevSong();
	}
}

 

상태전이가 잘 일어나느지 테스트를 해보자. 테스트는 아래 시나리오로 구성하였다.

 

1. 처음 Ready 상태에서 다음곡을 2번 누르고 Play를 누른다.

2. 화면 잠금을 위해 Lock을 누르고, 다음곡 2번, 이전곡 1번을 누른다.

3. 다시 Lock을 눌러 화면 잠금을 해지하고, 다음곡이 없을때까지 다음곡 버튼을 계속 누르고 일시정지를 한다.

 

public static void main(String[] args) {

		Player musicPlayer = new Player();
		
		musicPlayer.changeState(new ReadyState(musicPlayer));
		musicPlayer.addSong(Arrays.asList(
				"Song#1", "Song#2", "Song#3", "Song#4"));
		
		musicPlayer.clickNext();
		musicPlayer.clickNext();
		musicPlayer.clickPlay();
		
		musicPlayer.clickLock();
		musicPlayer.clickNext();
		musicPlayer.clickNext();
		musicPlayer.clickPrev();
		musicPlayer.clickLock();
		
		musicPlayer.clickNext();
		musicPlayer.clickNext();
		musicPlayer.clickNext();
		musicPlayer.clickNext();
		musicPlayer.clickPlay();
	}

===================================
Move next song
Move next song
clickPlay in ReadyState
PlayingState -> LockedState
Sceen is locked
Sceen is locked
Sceen is locked
Player is playing state. LockedState -> PlayingState
Play next song
Play next song
Play next song
There is no next song.
Play next song
There is no next song.
PlayingState -> ReadyState

댓글