노무현 대통령 배너

Command Pattern 커맨드 패턴을 이용한 history undo 작업취소 기능 구현

by on 10.31, 2009, under Design Pattern

command_z자바 코드로 된 디자인 패턴 관련 예제를 액션스크립트 코드로 번역하고, 컴파일을 해 보면 “이것은 마치.. 마법같잖아.” 하고 느끼는 때가 많습니다.
최하위에 위치한 구상 클래스로부터 시작해서 대략 한 두번 추상층을 거치고 호스트코드[01] 까지 거슬러 올라가 코드가 어떻게 실행되는지 살펴보면 (물리적으로) 마땅히 그런 결과가 나오는 것이 맞긴한데, 전체 클래스들이 관계한 모양새와 그것들이 보여주는 결과만을 보면 신기하기 이를데 없습니다. 이런 것이 디자인 패턴의 매력이겠죠?

오늘 포스팅하게 될 내용도 그런 신기한 것 중 하나로, 커맨드 패턴을 이용한 작업취소(언두: undo) 또는 작업내역(history) 기능 구현 입니다.
헤드퍼스트 디자인 패턴에 있는 디자인 패턴 부분의 내용을 기본으로 이해하기 좋도록 시각적인 구성을 추가했고, 중요한 기능 중 하나를 추가해 보았습니다.

undo 나 history 기능 자체는 디자인 패턴이 아닙니다. 이 기능은 커맨드 패턴을 구현하다 보면 서비스로 딸려오는 (자연스럽게 구성할 수 있는) 기능 정도로 이해하는 것이 좋습니다.

먼저 최종 결과 파일부터 볼까요?

위의 컴파일 결과물에서 왼쪽은 리모컨 입니다. 각 숫자는 선풍기의 회전수를 나타내는 회전 강도가 되겠고요, 아래쪽에 undo, redo 버튼, 그리고 현재 history 내역이 무엇인지 볼 수 있는 view history 버튼도 만들어져 있습니다.
각각의 버튼을 눌러보세요.

패턴과는 관계 없지만, 화면에서 보이는 선풍기는 정확하게 표현하자면 실링팬 입니다. 더운 지방이 나오는 영화를 보면 천정에서 천천히 돌아가는 선풍기가 바로 이것이죠. 영어 단어로는 ceiling fan 이라 하는데, 직역하자면 천정 선풍기라는 의미가 되겠습니다.
한편 ceil 이라는 단어는 Math 클래스 에서도 볼 수 있죠. Math.ceil( 1.3 ) 의 결과는 소수점 올림이 적용되어 2 가 나오게 되겠죠? 이때 ceil 이라는 의미가 ‘천정’ 또는 ‘올림’ 이라면, floor 는 ‘바닥’ 또는 ‘내림’ 이라는 상대적인 의미로 Math.floor() 에서도 사용됩니다.

ICommand 인터페이스는 이전 커맨드 패턴을 다룬 예제와 전혀 다르지 않습니다.

0
1
2
3
4
5
6
package 
{
	public interface ICommand
	{
		function execute():void
	}
}

아래는 선풍기를 off 한 상태의 커맨드 클래스 입니다. 이렇게 선풍기의 속도를 커맨드 클래스로 만든 클래스가 속도별로 3개가 더 있습니다. (전체 코드는 하단에 다운로드 링크가 있습니다)

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package 
{
	public class CeilingFanOffCommand implements ICommand
	{
		private var ceilingFan:CeilingFan;
 
		public function CeilingFanOffCommand( $ceilingFan:CeilingFan ) 
		{
			this.ceilingFan = $ceilingFan
		}
 
		public function execute():void
		{
			ceilingFan.off();
		}
	}
}

CeilingFan 클래스는 아래와 같습니다. 4가지 속도에 해당하는 메서드를 public 으로 제공하고 있고, 메서드가 실행되면 speed 라는 이름의 변수에 저장하고 있습니다.

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package 
{
	import flash.display.Sprite;
	import flash.events.*;
 
	public class CeilingFan extends Sprite
	{
		private var location:String = "";
		private var speed:int;
		public static const HIGH:int = 3;
		public static const MEDIUM:int = 2;
		public static const LOW:int = 1;
		public static const OFF:int = 0;
 
		public function CeilingFan( $location:String ):void
		{
			this.fan.cacheAsBitmap = true;
			this.location = $location;
			this.speed = OFF;
			this.addEventListener( Event.ENTER_FRAME, onEnterFrameHandler );
		}
 
		public function high():void
		{
			speed = HIGH;
			outputText();
		}
		public function medium():void
		{
			speed = MEDIUM;
			outputText();
		}
		public function low():void
		{
			speed = LOW;
			outputText();
		}
		public function off():void
		{
			speed = OFF;
			outputText();
		}
		public function getSpeed():int
		{
			return speed;
		}
		private function outputText():void
		{
			txtSpeed.text = location + " : Fan Speed - " + speed;
		}
		private function onEnterFrameHandler( e:Event ):void 
		{
			this.fan.rotation += speed;
		}
	}
}

화면에 있는 선풍기의 날개를 속도에 맞게 회전시킬 것이므로 Event.ENTER_FRAME 을 이용해 fla 의 스테이지에 정의 되어 있는 fan 무비클립을 계속 갱신시키고 있습니다. 가장 아래쪽에 있는 onEnterFrameHandler() 메서드에 있는 fan 의 회전속도는 변수 speed 의 숫자값에 따라 달라지겠죠. speed 가 바뀌는 즉시 fan 의 회전속도도 바뀌게 될 것입니다.

그럼 이제 리모컨 클래스를 살펴볼 차례군요.

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
package 
{
	import fl.controls.Button;
	import flash.display.Sprite;
	import flash.events.*;
	import flash.text.*;
 
	public class RemoteControlWithHistory extends Sprite 
	{
		private var arrCommands:Array = new Array();;
		private var commandHistory:Array = new Array();
		private var historyIndex:int;
		private var txtHistory:TextField = new TextField();
 
		public function RemoteControlWithHistory( $ceilingFan:CeilingFan, $numberOfbuttons:uint = 4 )
		{
			var noCommand:ICommand = new NoCommand();
			for ( var i:int = 0; i < $numberOfbuttons; i++ )
			{
				arrCommands[ i ] = noCommand;
			}
 
			var ceilingFanHigh:CeilingFanHighCommand = new CeilingFanHighCommand( $ceilingFan );
			var ceilingFanMedium:CeilingFanMediumCommand = new CeilingFanMediumCommand( $ceilingFan );
			var ceilingFanLow:CeilingFanLowCommand = new CeilingFanLowCommand( $ceilingFan );
			var ceilingFanOff:CeilingFanOffCommand = new CeilingFanOffCommand( $ceilingFan );
 
			setCommand( 0, ceilingFanOff );
			setCommand( 1, ceilingFanLow );
			setCommand( 2, ceilingFanMedium );
			setCommand( 3, ceilingFanHigh );
 
			init( $numberOfbuttons );
		}
 
		private function init( $numberOfbuttons:uint ):void
		{
			this.x = this.y = 10;
 
			for ( var i:uint = 0; i < $numberOfbuttons; i++ )
			{
				var button:Button = new Button();
				button.addEventListener( MouseEvent.CLICK, buttonWasPushed );
				button.label = i.toString();
				button.height = 30;
				button.y = i * ( button.height + 5 );
				addChild( button );
			}
 
			var undoButton:Button = new Button();
			var redoButton:Button = new Button();
			var historylistButton:Button = new Button()
			undoButton.y = 200;
			redoButton.y = 230;
			historylistButton.y = 260;
			undoButton.label = "undo";
			redoButton.label = "redo";
			historylistButton.label = "view history";
 
			undoButton.addEventListener( MouseEvent.CLICK, undoButtonWasPushed )
			redoButton.addEventListener( MouseEvent.CLICK, redoButtonWasPushed )
			historylistButton.addEventListener( MouseEvent.CLICK, historyView );
 
			addChild( undoButton );
			addChild( redoButton );
			addChild( historylistButton );
 
			txtHistory.width = 300;
			txtHistory.height = 300;
			txtHistory.x = historylistButton.width;
			txtHistory.y = historylistButton.y;
			addChild( txtHistory );
 
			//선풍기의 초기값은 정지상태
			this.buttonWasPushed( null );
		}
 
		public function setCommand( $slot:int, $command:ICommand ):void
		{
			arrCommands[ $slot ] = $command;
		}
 
		public function buttonWasPushed( $e:MouseEvent = null ):void
		{
			var slot:uint = ( $e == null ) ? 0 : uint( $e.target.label );
			arrCommands[ slot ].execute();
			stackProcess( arrCommands[ slot ] );
		}
 
		private function stackProcess( $command:ICommand ):void
		{
			//historyIndex가 undo 중일때 undo나 redo가 아닌 새로운 command가 들어올경우 redo 부분이 없어지도록 배열길이를 조정
			commandHistory.length = historyIndex;
			commandHistory.push( $command );
			historyIndex = commandHistory.length;
		}
 
		public function undoButtonWasPushed( $e:MouseEvent = null ):void
		{
			if ( historyIndex > 1 )
			{
				commandHistory[ ( --historyIndex - 1 ) ].execute();
			}
			else
			{
				trace( "No more Undo" )
			}
		}
 
		public function redoButtonWasPushed( $e:MouseEvent = null ):void
		{
			if ( historyIndex < commandHistory.length )
			{
				commandHistory[ historyIndex++ ].execute();
			}
			else
			{
				trace( "No more Redo" )
			}
		}
 
		public function getCommandList():String
		{
			var str:String = "\n------ Remote Control -------"
			for ( var i:int = 0; i < arrCommands.length; i++)
			{
				str += "\n[slot " + i + "] " + arrCommands[i] 
			}
			str += "\n[undo] " + commandHistory
			return str
		}
 
		private function historyView( e:MouseEvent ):void 
		{
			txtHistory.text = "******History list******"
			for each( var element:Object in commandHistory )
			{
				txtHistory.appendText( ( "\n" + element.toString() ) );
			}
		}
	}
}

클래스가 좀 길군요. 하지만 대부분 생성자 함수와 init() 메서드의 초기화 내용들이므로 지나쳐도 무방하고, 실제 커맨드 패턴을 구현한 내용은 setCommand() 메서드 부터 입니다. 이전 두 개의 커맨드 패턴 관련 포스팅을 읽으신 분들은 이 메서드가 리모컨의 각 버튼에[02] 커맨드 객체를 저장해 주는 역할을 한다는 것을 알고 계실겁니다.

setCommand() 메서드 아래에 있는 buttonWasPushed() 메서드 에서는 각 속도가 할당된 버튼을 클릭하면 커맨드 객체를 통해 CeilingFan 객체에 각 속도에 해당하는 메서드를 실행하게 됩니다. 추가로 stackProcess() 메서드를 실행시키고 있는데, stackProcess() 에서는 history undo 기능을 수행할때 필연적으로 만나게 되는 문제에 대한 해결 방법을 제시하고 있습니다.

history undo 실행 중에 만나는 새로운 커맨드에 대한 처리

stackProcess() 메서드가 실행되면 인자로 받은 커맨드 객체를 commandHistory 배열에 넣는 기본 기능을 수행하면서 마지막 라인에서 commandHistory 의 배열 길이를 historyIndex 라는 별도의 변수에 저장합니다. 배열 길이는 언제든 참조가 가능한데 왜 이렇게 하는 것일까요?
stackProcess() 가 그렇게 한번 실행된 후, 두 번째 실행되면 그 이유를 알 수 있습니다.

stackProcess() 메서드의 첫 번째 라인을 보면 commandHistory 의 배열 길이를 강제로 historyIndex 로 맞줘주는 내용이 보입니다. 커맨드 객체가 계속 새로운 커맨드로 진행되는 경우라면 commandHistory.length 와 historyIndex 의 길이는 같을 것이므로[03] 이 코드는 실행되는 듯 마는 듯 지나치게 됩니다만, 만약 현재 상태가 undo 상태라면 이야기는 달라집니다.

이 문제는 모든 history undo 기능이 가지고 있는 공통의 문제라 할 수 있겠는데요, 사용자가 A, B, C, D 의 커맨드를 순서대로 실행하다가 undo 를 두번 했다면 현재는 B가 실행되고 있겠죠? 여기서 사용자가 고맙게도 redo 를 다시 두번 실행해 준다면 좋겠지만, 사용자가 어디 그런가요? 현재 B가 실행되고 있는 상황에서 C을 건너뛰고 D를 선택하는 상황이 발생하게 됩니다.

만약 stackProcess() 에서 push() 만 하는 경우라면 어떻게 될까요? undo 상태의 B위치에서 누른 새로운 D는 commandHistory의 5번째 원소가 될테죠.
commandHistory 를 나열해보면 이렇게 되어있겠네요. [ A, B, C, D, D ].
이것은 우리가 바라는게 아니라는것을 알 수 있습니다. undo 상태에서 redo 가 아닌 새로운 커맨드가 들어올 때, history 를 저장하고 있는 배열에서 redo 가 될 부분은 모두 버리고 새로운 커맨드를 쌓아나가야 하는 것이죠.
그래서 우리가 의도한 [ A, B, D ] 가 되도록 하기 위해서 commandHistory.push( $command ) 를 하기 직전에, 별도로 저장한 historyIndex 로 배열의 길이를 조절해 주는 것입니다.

stackProcess() 메서드 아래에는 undoButtonWasPushed() 메서드와 redoButtonWasPushed() 메서드가 있어서 undo나 redo 버튼이 눌렸을때에는 새로운 커맨드를 실행하는 것이 아니라 commandHistory 배열에서 앞뒤로 인덱스를 움직이며 커맨드를 실행해 줍니다.

마지막으로 호스트코드를 살펴보겠습니다. 이쪽은 뭐 간단합니다.
선풍기와 리모컨 객체를 하나씩 만들어 addChild 했을 뿐이네요.

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package 
{
	import flash.display.Sprite;
 
	public class Main extends Sprite 
	{
		public function Main():void 
		{
			var ceilingFan:CeilingFan = new CeilingFan( "Living Room" )
			ceilingFan.x = 400;
			ceilingFan.y = 200;
			addChild( ceilingFan );
 
			var remoteControl:RemoteControlWithHistory = new RemoteControlWithHistory( ceilingFan, 4 );
			addChild( remoteControl );
 
		}
	}
}

이 예제는 fla 에 있는 이미지 자원을 사용하기 때문에 컴파일도 fla 에서 합니다. 화면에서 모든것을 표현하고 있기 때문에 trace() 대신 화면을 관찰하면 됩니다. 현재 히스토리 현황을 알기 위해서 view history 버튼을 누르세요.

  Command Pattern ( History Undo) 액션스크립트 코드 다운로드 - fla, as, swf 파일 포함. fla 파일은 CS4 에서 작성
이 글을 복사해서 퍼가시는건 허용하지 않습니다. 글의 주소를 다른곳에 알려주시는 것은 환영합니다.
  1. 액션스크립트로 말하자면 도큐먼트 클래스 이죠 []
  2. 정확하게는 커맨드 슬롯을 저장하는 배열에 []
  3. 아직 commandHistory.push( $command ) 하기 이전이므로 []

관련된 글

:, , , , , , , , , , , , , ,

4 Comments for this entry

Leave a Reply

Looking for something?

Use the form below to search the site:

Still not finding what you're looking for? Drop a comment on a post or contact us so we can take care of it!

Meta