MoreDesignPatterns
- More Design Patterns
원본-영어
http://conferences.embarcadero.com/article/32129
번역진행
이화영 (Hwa Young Lee)
검수진행
없음
서론
본 논문에서는 GoF(Gang of Four)의 디자인 패턴 서적에서 좀 덜 알려진 디자인 패턴을 일부 소개하고자 한다. 독자들에게 패턴에 관한 기본 지식이 어느 정도 있는 것으로 가정한다. 객체 지향 기법들은 확실히 이해할 필요가 있을 할 것이다.
물론 완전하진 않지만 패턴을 이용해 예제 프로그램을 개발하는 방법을 살펴볼 것이다. 시스템의 설계 과정을 검토하면서 어떤 패턴을 사용할 것인지 결정하고, 델파이에서 그 구현은 어떠한지를 검토할 것이다.
패턴은 코드로 표현할 수 없음을 명심한다. 본문의 예제들은 패턴의 구현 방법에 지나지 않으며, 사실상 다른 상황에서는 완전히 다른 구현을 생각해내는 것이 가능할뿐더러 오히려 바람직하기까지 하다. 그러한 옵션 중 일부를 본문에서 논할 것이다.
패턴은 코드 템플릿(code template)이 아니므로 이 예제들을 그러한 방식으로 사용하지 말길 바란다!
예제
XML 파일과 콤마로 값이 구분된 (CSV) 파일을 읽고 표시하는 소프트웨어 일부를 예제로 개발하고자 한다. 프로그램은 어떤 타입의 파일이 읽히는지 자동으로 감지하고, 그에 적절하게 파일 내용을 파싱 및 표시할 것이다.
디자인 과정을 살펴보면서 어떤 패턴을 사용할 것인지 결정하겠다. (바라건대) 완성된 코드는 꽤 깔끔해 보이지만, 처음부터 그런 것은 아니다. 이것을 정리하기 위해 사용된 일부 리팩토링을 Refactorings 논문에서 예제로 사용한다.
CSV 파일 파싱하기
가장 먼저 결정해야 할 것은 CSV 파일을 어떻게 읽느냐이다. 좀 더 명확하게 설명하자면, CSV 파일은 아래와 같은 행을 포함하는 텍스트 파일이다:
Stuff, 123, "More stuff, but this can contain commas",,LasField
큰 따옴표는 콤마를 포함한 문자열을 감쌀 때 사용되고, 필드는 비어 있는 것이(empty) 가능하다.
이와 같이 문자열을 파싱하는 전형적인 방법 중 하나는 상태 기계(state machine)를 사용하는 방법이다. Julian Bucknall 의 훌륭한 저서 Tomes of Delphi: Algorithms and Data Structures 가 이러한 방법을 포함한다. 그의 특별한 예제는 복귀(carriage return)나 개행(line feed)을 다루지 않음을 주목하고, 행들이 이미 구분되어 있고 한 번에 하나의 루틴이 피드(feed)되어 필드를 추출(extract)하고 있다고 가정한다. Refactoring 논문은 이렇게 작은 프로시저가 어떻게 완전한 패턴 구현으로 성장하는지에 관한 흥미로운 관점을 보여준다. 본문에서는 완성된 제품만 살펴보겠다.
State 패턴
"객체의 내부 상태가 변하면 객체가 그 행위를 변경할 수 있도록 하라. 객체가 그 클래스를 변경하는 것처럼 보일 것이다" - GoF
State 패턴은 객체가 그 상태에 따라 런타임 시 그 행위를 변경할 때 사용된다. 패턴의 사용 가능성을 나타내는 지표로는 긴 case문 또는 조건문의 리스트가 있다 (Switch Statements “bad smell”, 리팩토링 용어를 사용하기 위함). 델파이에서는 (대부분 언어와 마찬가지로) 주어진 객체가 사실상 그 클래스를 변경할 수 없으므로, 우리는 그 행위를 흉내내기 위해 아래와 같이 다른 계획을 사용하고자 한다.
구현에 참가자들로는 컨텍스트context 와 상태states 가 있다. 컨텍스트는 상태 패턴이 모델화하는 하위시스템의 클라이언트에게 표시되는 인터페이스이다. 우리 예제에서는 TCsvParser가 컨텍스트에 해당할 것이다. 클라이언트는 상태를 절대 볼 수 없으므로 우리가 재량껏 변경할 수 있다. 클라이언트 하위시스템이 관심을 가지는 유일한 인터페이스는 텍스트 행으로부터 필드의 추출밖에 없다.
이를 위해 유한 상태 기계(FSM)를 사용할 것이다. 기본적으로 FSM는 상태 집합에 대한 모델이다. 각 상태에서는 특정 입력을 사용 시 다른 상태로 전환된다. 특수 상태로는 두 가지 유형이 있다. Start 상태는 작동을 시작하기 전에 FSM의 상태를 의미한다. End 상태는 처리가 끝나는 곳의 상태로, 주로 이중 원(double circle)으로 표기된다. 파서에 대한 FSM를 아래 표시하겠다:
State 패턴에서 각 상태는 기반 상태 클래스의 하위클래스가 된다. 각 하위클래스는 입력 문자를 처리하고 다음 상태를 결정하는 추상 클래스 ProcessChar 를 구현해야 한다.
구현
CSV 파일을 파싱하기 위한 상태 패턴 코드에 대한 인터페이스 부 소스 코드는 아래와 같다:
unit CsvParser;
interface
uses Classes;
type
TCsvParser = class; // 전방 선언
TParserStateClass = class of TCsvParserState;
TCsvParserState = class(TObject)
private
FParser : TCsvParser;
procedure ChangeState(NewState : TParserStateClass);
procedure AddCharToCurrField(Ch : Char);
procedure AddCurrFieldToList;
public
constructor Create(AParser : TCsvParser);
procedure ProcessChar(Ch : AnsiChar;Pos : Integer); virtual; abstract;
end;
TCsvParserFieldStartState = class(TCsvParserState)
private
public
procedure ProcessChar(Ch : AnsiChar;Pos : Integer); override;
end;
TCsvParserScanFieldState = class(TCsvParserState)
private
public
procedure ProcessChar(Ch : AnsiChar;Pos : Integer); override;
end;
TCsvParserScanQuotedState = class(TCsvParserState)
private
public
procedure ProcessChar(Ch : AnsiChar;Pos : Integer); override;
end;
TCsvParserEndQuotedState = class(TCsvParserState)
private
public
procedure ProcessChar(Ch : AnsiChar;Pos : Integer); override;
end;
TCsvParserGotErrorState = class(TCsvParserState)
private
public
procedure ProcessChar(Ch : AnsiChar;Pos : Integer); override;
end;
TCsvParser = class(TObject)
private
FState : TCsvParserState;
//더 나은 성능을 위해 상태 객체를 캐시.
FFieldStartState : TCsvParserFieldStartState;
FScanFieldState : TCsvParserScanFieldState;
FScanQuotedState : TCsvParserScanQuotedState;
FEndQuotedState : TCsvParserEndQuotedState;
FGotErrorState : TCsvParserGotErrorState;
//파싱 중에 사용된 필드
FCurrField : string;
FFieldList : TStrings;
function GetState : TParserStateClass;
procedure SetState(const Value : TParserStateClass);
protected
procedure AddCharToCurrField(Ch : Char);
procedure AddCurrFieldToList;
property State : TParserStateClass read GetState write SetState;
public
constructor Create;
destructor Destroy; override;
procedure ExtractFields(const s : string;AFieldList : TStrings);
published
end;
Parser 클래스를 먼저 살펴보면, 각 상태 하위클래스의 private 인스턴스가 있음을 알 수 있다. 우리 예제에서는 매우 긴 파일을 파싱할 수 있고 상태가 자주 변경되므로, 모든 객체를 한 번에 생성하여 현재 상태를 계속 파악하는 편이 바람직하다. 상태가 많은 경우 (이 패턴이 사실상 영향력을 발휘하기 시작하는), 특히 상태를 가끔씩 필요로 하는 경우에는 그때 그때 상태를 생성하여 해제하는 편이 낫다. 이는 인터페이스의 자동 쓰레기 수집(garbage collection) 프로퍼티를 사용할 기회가 될지 모르지만, State 객체로의 인터페이스 접근과 클래스를 혼합하지 않도록 주의한다. 이 때는 Flyweight 패턴의 사용을 생각해보는 것도 좋다 (관련 내용은 GoF를 참고하길 바란다).
우리는 현재 State 객체의 클래스를 이용해 상태를 계속 파악하고 있음을 주목해야 한다. 필드로 접근하기 위해 protected 프로퍼티를 사용할 수도 있다 (Self Encapsulated Field 리팩토링의 예). Parser 클래스는 extracted 필드 리스트와 현재 필드를 유지할 수도 있다. 상태는 이를 업데이트하기 위해 protected 메소드를 사용할 것이다.
State 가 이것을 관리할 수 있는 것은 Parser 가 생성자 내에서 파라미터로서 전달되기 때문이다. State 객체가 그것이 사용되고 있는 컨텍스트로 접근을 필요로 한다는 것은 꽤 일반적인 일이다. 기반 추상 클래스는 상태를 변경하고 Parser 를 업데이트하기 위한 메소드를 정의한다. 자손 클래스는 문자 처리 루틴만 구현하면 된다.
이러한 루틴 중 start 상태를 위한 루틴 하나를 살펴보자.
procedure TCsvParserFieldStartState.ProcessChar(Ch : AnsiChar;Pos : Integer);
begin
case Ch of
'"' : ChangeState(TCsvParserScanQuotedState);
',' : AddCurrFieldToList;
else
AddCharToCurrField(Ch);
ChangeState(TCsvParserScanFieldState);
end;
end;
큰 따옴표가 있다면 FSM은 Scan Quoted 상태로 진입함을 의미하고, 콤마는 우리가 필드 끝에 도달했으니 리스트 뒤에 추가해야 함을 의미하며, 그 외 부분은 우리가 새 필드를 시작하고 있음을 나타낸다.
하지만 아래 표시된 Scan Quoted 상태에서 큰 따옴표가 있을 때 전환(transition)이 달라진다. 이것이 바로 '상태에 따른 행위'가 의미하는 바이다.
procedure TCsvParserScanQuotedState.ProcessChar(Ch : AnsiChar;Pos : Integer);
begin
if (Ch = '"') then begin
ChangeState(TCsvParserEndQuotedState);
end else begin
AddCharToCurrField(Ch);
end;
end;
나머지 코드는 꽤 쉽다. 미비하지만 유일한 차이는 바로 우리가 예외를 발생시키는 Error 상태가된다. Parser 는 하나의 긴 메소드만 가지는데, 유효성 검사, 설치 등을 처리해야 하기 때문이다. ExtractFields 에 필요한 행은 다음과 같다:
//문자열 내 모든 문자를 읽는다
for i := 1 to Length(s) do begin
//다음 문자를 얻는다
Ch := s[i];
FState.ProcessChar(Ch,i);
end;
이는 입력 행 s를 읽어내고, 각 문자를 현태 상태로 전송한다. 이와 같은 유형의 처리 루프는 흔히 사용된다. 나머지 코드 부분은 여러분이 시간 날 때 완성하도록 남겨두겠다. 이는 모두 CsvParser.pas에 위치한다.
필자는 예제의 요소를 시험하기 위해 DUnit을 사용하는 테스트 베드(test bed)에 대한 소스 코드도 포함하였다. 이러한 유닛의 사용 방법을 학습하는 것이 얼마나 유익한지는 아무리 강조해도 지나치지 않다. 테스트를 작성 시 체험하게 될 이득은 직접적이므로, 아직 작성하지 않았다면 알아볼 것을 권한다.
XML 파일 파싱하기
다음으로는 XML 파일을 읽는 방법을 결정해야 한다. 우리는 완전한 XML 파서로 사용할건 아니기때문에 언어의 작은 하위집합으로 제한할 것이다. 또한 우리만의 파서를 작성할 것이기 때문에 예제에서 문법을 간단하게 유지할 수 있다. 하지만 규칙은 CSV 파일을 정의하는 규칙보단 복잡한데, 사실상 우리에겐 소규모의 언어가 있다.
프로그래밍 언어의 처리는 보통 여러 프로세스를 필요로 한다. 가장 낮은 수준은 소스의 스트림을 토큰Token으로 분리하는 어휘 분석기lexical analyser의 수준이다. 토큰은 매우 작은 텍스트 조각으로서 파서Parser</suup> 로 전달되며, 이후에 문법Grammer 언어의 컨텍스트에서 해석된다. 문법이란 언어의 구문을 정의하는 규칙 집합이다. 문법은 의미론semantics이라고도 알려진 행위는 정의하지 않는다. 파서는 구문 오류의 보고를 책임진다.
흔히 파서의 출력은 언어 요소들을 표시하는 노드들로 구성된 추상 구문 트리abstract syntax tree이다. 시스템이 컴파일러일 경우, 추상 구문 트리는 다른 오퍼레이션, 즉 타이핑 검사나 코드 생성에 사용된다.
이러한 전체적 주제는 꽤 복잡하지만 수 십 년간 잘 이해되어 오고 있다. 표준 참고 자료로 Dragon Book 이 있는데, 표지에 그려진 용(Dragon) 때문에 그리 불린다. Aho, Sethi, and Ullman이라 불리는 필자의 개정판은 1986년에 인쇄되었으며 책에 포함된 정보는 아직도 최신에 가깝다 (우리 분야에서 드물게). 예제의 재귀적 하향recursive descent 파서와 어휘 분석기에 관한 심도 있는 논의는 이 자료를 참고하길 바란다. 그러한 Parser 와 Intepreper 패턴의 유사점을 주목하면 좋을 것이다.
패턴 서적을 살펴보면 언어를 처리하는 데에 사용되는 패턴은 Interpreter 라는 것을 발견할 것이다. 용어의 사용에서 예상할 수 있듯이 프로그램을 평가하는 (또는 실행) 것 외에도 이용 가능성을 발견할 것이다.
Interpreter 패턴
"언어에 따라서 문법에 대한 표현을 정의하고, 그 언어로 문장을 해석하기 위해 정의한 표현에 기반하여 분석기를 정의한다." - GoF
"해석interpret" 이란 용어가 여기서는 꽤 광범위하다. BASIC 인터프리터 에게는 어떤 런타임 환경에서 명령어(instruction)의 실행을 의미할 수도 있다. 하지만 그것 말고도 언어의 구조에 대한 이해를 요하는 다른 곳에 Interpreter 를 사용할 수 있다.
이 패턴은 구조가 너무 복잡하지 않을 때 최상으로 작용한다. 문법의 요소마다 클래스를 정의할 것이므로, 클래스 계층구조가 매우 방대해질 수 있다 (보통은 깊이가 얕고 매우 넓음). 데이터를 표현하고 작업하기에는 꽤 비효율적인 방법이 될 수도 있다. 필자 생각에는 이것은 재귀 하향 컴파일러가 적절한 조건이므로 훌륭한 조건이 되겠다. 하지만 델파이 컴파일러를 이러한 방식으로 작성하지는 않을 것이다. Dragon Book은 이보다 효율적인 방법들을 논한다.
하지만 우리가 제시하는 작은 문법에는 Interpreter 패턴으로 충분하다.
문법
모든 XML 문서를 파싱할 수는 없을 것이다. 특히 DTDs, 속성, 프롤로그 내용의 구조, 확장문자 (예: <), 빈 요소 태그(예: <NothingHere/>)는 무시하겠지만 빈 파일은 처리할 수 있을 것이다.
문법을 정의하기 위해 Backus Naur Form (BNF)의 변형을 사용하였다. 여기 빈 문자열은 ε으로, 0개 또는 그 이상의 선행하는 항목(zero or more occurrence)은 *로 (Kleene closure라 불린다), 하나 또는 이상은 +(positive closure라 불린다), 0 또는 1은 0..1 (알려진 별칭이 없다)로 표기된다.
XmlDoc ->Prolog0..1 TagList0..1
Prolog -><?xml PrologData?>
TagList ->Node*
Node ->StartTag [Data | TagList] EndTag
StartTag -><TagName>
EndTag -></TagName>
PrologData ->[Any printable characters except <,>,/ and ? ]*
Data ->[Any printable characters except <,> and / ]*
TagName ->[Any printable characters except <,>,/,space]+
우리가 해석할 수 있는 파일 유형의 예제는 다음과 같다:
<?xml version="1.0"?>
<List>
<SomeStuff>Stuff 1 is here</SomeStuff>
<SomeStuff>Stuff 2 is here</SomeStuff>
<SomeStuff>Stuff 3 is here</SomeStuff>
<SomeStuff>Stuff 4 is here</SomeStuff>
</List>
구현
Interpreter 패턴은 주로 구문 트리를 빌드 시 클라이언트client 를 필요로 한다. 우리 예제에서는 XmlParser.pas에 있는 XML 파서가 해당할 것이다. 이를 상세히 설명하기엔 시간이 부족하지만, 간단히 설명하자면 우리는 토큰(token)이란 것을 가지는데, 이는 단일 문자이거나 문서 마커의 끝이 될 것이다. 어휘 분석기 클래스는 이것들을 압축하여 XML 파서로 전달한다. 모든 재귀적 하향 파서와 마찬가지로 이것은 문법의 각 요소마다 선언된 프로시저를 가진다. 이러한 프로시저들은 해당 토큰을 검사하고 필요 시 오류를 보고하며, 다른 문법적 구조가 소스 텍스트에 표시되어야 할 때 그에 해당하는 프로시저들을 호출한다. 이 파서는 필요 시 구문 트리에 노드를 추가한다.
구문 트리는 Interpreter 패턴의 본질이다. Astute 독자들이라면 이것이 컴포지트(Composite) 패턴의 특수 사례임을 눈치챌 것이다. 트리의 기본은 추상 표현abstract expression 클래스인데, 이는 해석 연산을 실행하기 위해 추상 메소드를 정의한다. 우리 예제에서 이것은 찾기 및 바꾸기(search and replace)가 해당하겠다 (정의가 꽤 광범위할 수 있다고 언급한 바 있다). 우리는 연산을 데이터에서만 허용하거나 태그와 데이터에서 모두 허용할 것이다. 여기서 바로 문서 구조의 이해가 필요하다.
이후 우리는 하위클래스를 정의하는 문법, 각 문법 요소를 살펴보고자 한다. 클래스 타입에는 두 가지가 있지만 그들 사이에 상속될 수 있는 차이점은 없기 때문에 코드에서 정의할 필요는 없는 것으로 사료된다. 첫 번째 타입은 terminal expressions 에 관한 것으로, 더 이상 축소될 수 없는 문법 요소들을 의미한다. 우리 문법에서는 PrologData, Data, TagName 요소가 해당된다. 사실상 이러한 클래스들은 너무나 평범해서 필자는 결국 그들을 리팩토링하게 되는데, 이제 그것들은 관련 non-terminal expression 클래스들의 문자열 프로퍼티에 불과하다.
다른 문법 요소들은 각각에 대해 하나의 클래스가 있다. SearchAndReplace 메소드의 구현 외에도 이러한 클래스들은 그들이 구성된 다른 표현(expression) 클래스들의 인스턴스를 프로퍼티로서 가진다. Interpreter 클래스의 선언은 아래와 같다 (현재 Accept 루틴은 무시).
//기반 방문자 클래스의 전방 선언
TXmlInterpreterVisitor = class;
// 추상적 기반 표현 클래스
TXmlExpression = class(TObject)
private
protected
function DoSearchAndReplace(const TargetStr,SearchStr,ReplaceStr : string) :
public
//이러한 메소드를 추상적으로 선언 시 자손 클래스들이 그것을 강제로 구현하게 만든다
procedure SearchAndReplace(const SearchStr,ReplaceStr : string;
DoTags : Boolean = False); virtual; abstract;
procedure Accept(Visitor : TxmlInterpreterVisitor); virtual; abstract;
end;
TXmlStartTag = class(TXmlExpression)
private
FTagName : string;
protected
public
procedure SearchAndReplace(const SearchStr,ReplaceStr : string;
DoTags : Boolean = False); override;
procedure Accept(Visitor : TxmlInterpreterVisitor); override;
property TagName : string read FTagName write FTagName;
end;
TXmlEndTag = class(TXmlExpression)
private
FTagName : string;
protected
public
procedure SearchAndReplace(const SearchStr,ReplaceStr : string;
DoTags : Boolean = False); override;
procedure Accept(Visitor : TxmlInterpreterVisitor); override;
property TagName : string read FTagName write FTagName;
end;
TXmlTagList = class;
TXmlNode = class(TXmlExpression)
private
FStartTag : TXmlStartTag;
FData : string;
FTagList : TXmlTagList;
FEndTag : TXmlEndTag;
Public
destructor Destroy; override;
procedure SearchAndReplace(const SearchStr,ReplaceStr : string;
DoTags : Boolean = False); override;
procedure Accept(Visitor : TxmlInterpreterVisitor); override;
property StartTag : TXmlStartTag read FStartTag write FStartTag;
property EndTag : TXmlEndTag read FEndTag write FEndTag;
property Data : string read FData write FData;
property TagList : TXmlTagList read FTagList write FTagList;
end;
TXmlTagList = class(TXmlExpression)
private
FList : TObjectList;
function GetItem(Index : Integer) : TXmlNode;
protected
public
constructor Create;
destructor Destroy; override;
function Add : TXmlNode;
procedure SearchAndReplace(const SearchStr,ReplaceStr : string;
DoTags : Boolean = False); override;
procedure Accept(Visitor : TxmlInterpreterVisitor); override;
property Items[Index : Integer] : TXmlNode read GetItem; default;
end;
TXmlProlog = class(TXmlExpression)
private
FData : string;
protected
public
procedure SearchAndReplace(const SearchStr,ReplaceStr : string;
DoTags : Boolean = False); override;
procedure Accept(Visitor : TxmlInterpreterVisitor); override;
property Data : string read FData write FData;
end;
TXmlDoc = class(TXmlExpression)
private
FProlog : TXmlProlog;
FTagList : TXmlTagList;
protected
public
destructor Destroy; override;
procedure Clear;
procedure SearchAndReplace(const SearchStr,ReplaceStr : string;
DoTags : Boolean = False); override;
procedure Accept(Visitor : TxmlInterpreterVisitor); override;
property Prolog : TXmlProlog read FProlog write FProlog;
property TagList : TXmlTagList read FTagList write FTagList;
end;
// Interpreter 패턴의 클라이언트와 동일시한다
TXmlInterpreter = class(TObject)
private
FXmlDoc : TXmlDoc;
protected
public
constructor Create;
destructor Destroy; override;
property XmlDoc : TXmlDoc read FXmlDoc write FXmlDoc;
end;
EXmlInterpreterError = class(Exception);
클래스 정의가 문법을 따르는 방식을 주목하라. 유일한 변형은 TXmlTagList인데, 이는 리스트에 새 노드를 추가하기 위한 함수를 포함한다. 그리고 TXmlDoc는 구문 트리를 제거(clear)해주는 메소드를 가진다. 우리가 정의하는 리스트라면 전부 TObjectList 타입이고, 그들 자체가 파괴될 때 항목을 리스트에서 해제시키도록 구성된다.
SearchAndReplace 메소드의 예제를 몇 가지 살펴보면 이 패턴의 위력을 알 수 있을 것이다. 아래는 TXmlDoc의 버전이다:
procedure TXmlDoc.SearchAndReplace(const SearchStr,ReplaceStr : string;
DoTags : Boolean);
begin
if Assigned(Prolog) then begin
Prolog.SearchAndReplace(SearchStr,ReplaceStr,DoTags);
end;
if Assigned(TagList) then begin
TagList.SearchAndReplace(SearchStr,ReplaceStr,DoTags);
end;
end;
우리가 할 일은 이 표현식을 구성하는 요소들에 동일한 메소드를 호출하는 것으로, Prog와 TagList 프로퍼티가 해당하겠다. 이런 경우, 이 프로퍼티들은 선택적이므로 먼저 할당되었는지 검사한다. 다른 클래스들에서 non-terminal 표현식이 있을 때마다 이를 실행한다. Terminal expression이 있을 때마다 우리는 사실상 오퍼레이션을 실행한다. 예를 들어, 아래는 end tag 메소드이다:
procedure TXmlEndTag.SearchAndReplace(const SearchStr,ReplaceStr : string;
DoTags : Boolean);
begin
if not DoTags then begin
Exit;
end;
TagName := DoSearchAndReplace(TagName,SearchStr,ReplaceStr);
end;
DoSearchAndReplace 메소드는 여러 장소에서 사용되기 때문에 기반 클래스에서 선언된다.
당신이 종종 필요로 하게 될 마지막 참가자가 있는데, 바로 컨텍스트context 이다. 이는 인터프리터가 필요로 하는 전역적 정보를 모두 포함한다. 우리에겐 없기 때문에 예제에선 나타내지 못했다. 여러분에게 컨텍스트가 하나 있다면, 이는 주로 인터프리터 연산 메소드에 파라미터로서 전달된다.
이것이 전부다. 문법에서 각 표현식에 대한 클래스들을 정의하되, 이 클래스들은 하위표현식에 상응하는 프로퍼티들을 갖고 있어야 한다. 해석 오퍼레이션을 구현하기 위해서는 기반 표현 클래스에 추상 메소드를 정의한다. 이는 자손 클래스들이 그것을 구현하도록 강요할 것이다. 각 구현부에서는 모든 하위표현식 프로퍼티에서 메소드를 호출하라. 우리 예제보다 복잡한 연산의 경우, 일부 메소드도 작업해야 할 것이다. 우리는 필요하지 않았지만 부모 노드로 참조를 가질 때 유용한 경우도 종종 있다.
문법의 확장은 보다시피 꽤 쉽다; 서로 매우 유사한 클래스들이 필요할 뿐이며, 구현도 꽤 쉬운 편이다. 이러한 현상은 적어도 클래스 수가 너무 많아지기 전까진 사실이다ㅡ예를 들어 델파이 인터프리터에는 수 백 개의 클래스가 있을 수도 있다. 자식 연산을 어떻게 처리하는지와 관련해서도 많은 문제가 있으며, Interpreter 패턴과 컴포지트 패턴에 공통점들이 있지만 여기선 다루지 않겠다.
구문 트리에 여러 해석 방법을 추가해야 할 경우, 방법들에 대한 코드가 사실상 동일함을 발견할 것이다. 그렇다면 이제 다른 패턴을 사용할 때이다.
Visitor 패턴
"객체 요소로부터 떨어진 클래스에서 객체 구조의 요소들에 수행할 오퍼레이션을 표현한 패턴이다. Visitor 패턴은 오퍼레이션이 처리할 요소의 클래스를 변경하지 않고도 새로운 오퍼레이션을 정의할 수 있게 한다." - GoF
Visitor 패턴이 하는 일은 트리 상의 (또는 다른 구조의) 연산을 트리의 노드로부터 다른 클래스로 이동하는 것이다. 이 클래스는 연산을 실행하기 위해 각 노드의 인터페이스에 충분한 정보를 필요로 하므로, 때로는 당신이 원하는 것 이상으로 public 프로퍼티 및 메소드가 필요할지도 모른다.
Visitor 들은 이전에 델파이에서 설명해왔기 때문에 여기서는 지나치게 집중하지 않겠다. 기본적으로 당신이 할 일은 Visitor 클래스, 즉 객체 구조에 각 노드 타입에 대한 Visit 오퍼레이션을 선언하는 Visitor 클래스를 선언하는 일이다. 아래는 XML 인터프리터에 대한 기반 Visitor 클래스이다:
TXmlInterpreterVisitor = class(TObject)
private
protected
procedure Visit(Exp : TXmlStartTag); overload; virtual;
procedure Visit(Exp : TXmlEndTag); overload; virtual;
procedure Visit(Exp : TXmlNode); overload; virtual;
procedure Visit(Exp : TXmlTagList); overload; virtual;
procedure Visit(Exp : TXmlProlog); overload; virtual;
procedure Visit(Exp : TXmlDoc); overload; virtual;
public
end;
Visit 메소드들은 가상적이므로 Visitor 의 자손들은 구현할 메소드를 선택할 수 있다ㅡ전혀 구현할 필요가 없을지도 모른다. 물론 서명(signature)은 각각 다르기 때문에 필자는 함수 다중 정의를 사용했지만 이는 꼭 필요하지는 않으며, VisitXmlStartTag와 같이 좀 더 명시적인 이름을 사용해도 좋다. 개인적인 생각으로 함수 다중 정의의 장점은, 코드를 따르기가 좀 더 수월하다는 점이다.
기반 표현 클래스에서 추상적 Accept 메소드를 정의할 필요가 있다. 찾기 및 바꾸기에서와 마찬가지로 델파이 컴파일러는 모든 표현 클래스에 메소드를 구현하도록 강요한다. TXmlDoc 구현에서 코드는 앞서 보여주었던 SearchAndReplace와 사실상 동일함을 눈치챌 것이다:
procedure TXmlDoc.Accept(Visitor : TxmlInterpreterVisitor);
begin
Visitor.Visit(Self);
if Assigned(Prolog) then begin
Prolog.Accept(Visitor);
end;
if Assigned(TagList) then begin
TagList.Accept(Visitor);
end;
end;
유일한 차이는 우리가 파라미터로서 전달되는 Visitor의 Visit 메소드를 호출한다는 점이다. 사실 필자는 인쇄 작업이 좀 더 수월하도록 다른 메소드에서 약간의 속임수를 썼지만, 만일 묵인했다면 독자들은 절대 눈치채지 못할 정도이다.
Visitor 코드가 구문 트리 코드에 있다면, 새 Visitor 의 추가 시 그 코드에 더 많은 변경이 필요하진 않으며, 새 Visitor 클래스의 선언에만 변경을 요한다.
concrete Visitor 클래스는 XmlInterpreterVisitor.pas 에 정의되는데, 예쁜 프린터(pretty printer)를 구현하는 방법을 보여준다. 파서는 매우 불량하게 포맷된 XML 파일을 취하여 구문 트리를 만들 수 있다. 우리는 앞서 보였던 XML 예제와 같이 문서를 매우 잘 배치되도록 재생성할 것이다.
클래스 정의는 아래와 같다:
TXmlPrettyPrinter = class(TXmlInterpreterVisitor)
private
FList : TStringList;
FIndent : Integer;
function GetText : string;
protected
procedure AddString(AStr : string);
public
constructor Create;
destructor Destroy; override;
procedure Visit(Exp : TXmlStartTag); override;
procedure Visit(Exp : TXmlEndTag); override;
procedure Visit(Exp : TXmlNode); override;
procedure Visit(Exp : TXmlProlog); override;
procedure Clear;
property Text : string read GetText;
end;
위의 코드에서 볼 수 있듯이, 모든 표현 클래스가 인쇄를 필요로 하는 것은 아니므로 표현 클래스마다 Visitor 를 구현할 필요가 없다. 사실 인쇄하게 되는 것은 terminal 하위표현식을 가진 것들이다. 즉, start와 end tag, 노드 안의 데이터, 프롤로그가 되겠다.
각 지점에서 들여 쓰기를 추적하고, 각 새로운 행에서 올바른 공백 수를 추가할 것이다. 필자는 새로운 행을 추적하게 될 TStringLIst에 새로 형성된 텍스트를 모으기로 결정했다. GetText 함수는 리스트의 Text 프로퍼티로 접근하는 데에 그친다.
몇 가지 Visit 메소드를 들자면 다음과 같다:
procedure TXmlPrettyPrinter.Visit(Exp : TXmlStartTag);
begin
AddString('<' + Exp.TagName + '>');
Inc(FIndent,IndentAmount);
end;
procedure TXmlPrettyPrinter.Visit(Exp : TXmlEndTag);
begin
Dec(FIndent,IndentAmount);
AddString('</' + Exp.TagName + '>');
AddString('');
end;
procedure TXmlPrettyPrinter.Visit(Exp : TXmlNode);
begin
if Exp.Data = '' then begin
//빈 태그를 인쇄한다
AddString('<' + Exp.StartTag.TagName + '/>');
end else begin
AddString(Format('<%s>%s</%s>',
[Exp.StartTag.TagName,
Exp.Data,
Exp.EndTag.TagName]));
end;
end;
Start tag를 찾으면서 우리는 태그를 리스트에 추가한 후 들여 쓰기를 증가시킨다. End tag에서 우리는 반대로 들여 쓰기를 먼저 줄여서 start tag와 보조를 맞춘다. 태그 뒤에는 공백 행도 추가한다. 이 일이 발생 시 XML 집합체를 감싸는 tag에만 적용되는데 필자가 속임수를 쓴 곳이 개별 데이터 노드이기 때문이다. 필자는 TXmlNode.Accept 루틴을 정리하여 TagList가 없을 시 start 와 end 태그를 방문하지 않지만, 위에서 표시한 바와 같이 노드 Visitor 메소드에서 처리된 채로 남겨진다. 이 속임수는 전적으로 하나의 행에 있는 데이터와 태그를 좀 더 쉽게 인쇄하도록 만들기 위한 것이다.
구문 트리에 새로운 오퍼레이션은 쉽게 추가 가능하며, 우리는 방금 새 Visitor 를 추가하였다 (예를 들어, 이러한 방식으로 찾기 및 바꾸기를 재구현할 수 있겠다). 이제 관련 오퍼레이션들이 모두 한 클래스에 있는데, 해석자 클래스에 각 클래스와 연관되지 않은 다수의 오퍼레이션이 포함된 것이 아니라 연관이 없는 연산들이 다른 클래스에 위치한 것이다.
Visitor는 매우 괜찮은 패턴으로, 꽤 자주 사용된다. 객체 구조를 순회하는 데 사용된다는 점에서 Iterator 패턴과 비슷하지만, Visitor 패턴은 구조 항목들에 대해 공통된 부모가 없을 때에도 작동한다. 이에 비해 단점도 몇 가지 있다. 캡슐화를 깨는 것은 앞에서 언급한 바가 있고, 그 외에도 자신의 구조에 새 요소를 가끔씩 추가하는 경우, 작업이 매우 고달파진다. 예를 들어, 새 문법 규칙을 추가했다면 우리는 기반 Visitor 에 새 메소드를 추가하고, 모든 concrete Visitor 가 변경내용을 반영할 필요가 있는지 검사해야 할 것이다.
따라서 이제 우리는 XML과 CSV 파일을 읽을 수 있다. 이제 파일에 문서를 피드할 차례이다.
문서
우리는 현재 살펴보고 있는 문서에 관한 정보를 어느 정도 보관할 필요가 있다. 다수의 패턴에서는 패턴에서 주요 참여자가 사용하는 정보를 보유하는 클래스를 context 라고 부른다 (Interpreter 패턴에서 사용한 바가 있는데, 기억하는가?). 우리 예제에서 컨텍스트 역할을 하는 문서 클래스는 꽤 간단하다:
TDocument = class(TObject)
private
FFileText : TStringList;
FStrategy : TDocumentStrategy;
function GetText : string;
procedure SetText(const Value : string);
function GetMemento : TDocumentMemento;
procedure SetMemento(const Value : TDocumentMemento);
protected
public
constructor Create;
destructor Destroy; override;
procedure OpenFile(const FileName : string);
procedure CloseFile;
procedure SearchAndReplace(const FindText,ReplaceText : string);
procedure PrettyPrint;
property Text : string read GetText write SetText;
property Memento : TDocumentMemento read GetMemento write SetMemento;
end;
OpenFile 메소드는 텍스트를 그 행으로 로딩하기 위해 문자열리스트 필드 FFileText의 메소드 LoadFromFile을 사용한다. CloseFile 메소드는 행을 제거한다. 행들은 Text 프로퍼티를 통해 문자열로서 접근 가능하다. 나머지 프로퍼티와 메소드는 후에 호출 모습을 살펴볼 때 논하겠다. 우리의 문서 클래스에 대한 코드는 Document.pas 파일에 위치한다.
Strategy 패턴
"알고리즘군을 정의하고 각각의 알고리즘을 별도의 클래스로 캡슐화한 후 각 클래스를 동일한 인터페이스로 정의하여 교환 가능하게 만든다. Strategy 패턴은 이를 사용하는 클라이언트로부터 독립적으로 알고리즘을 다양하게 변경할 수 있게 한다." - GoF
가장 먼저 살펴볼 것은 OpenFile 메소드이다:
procedure TDocument.OpenFile(const FileName : string);
begin
FFileText.LoadFromFile(FileName);
// 여기서 팩토리 메소드를 이용 가능하지만, 지금은 새로운 Strategy 객체를 생성하기 위해
//코드를 인라인한다.
FreeAndNil(FStrategy);
if ExtractFileExt(FileName) = '.csv' then begin
FStrategy := TCsvStrategy.Create(Self);
end else if ExtractFileExt(FileName) = '.xml' then begin
FStrategy := TXmlStrategy.Create(Self);
end;
end;
여기서 우리는 문자열의 파일 로딩 메소드를 이용해 명시된 파일을 로딩함을 볼 수 있다. 이후 파일 확장자에 따라 Strategy 객체를 생성한다. 간단한 예제를 통해 CSV와 XML 파일만 다룰 예정이다.
우리가 이렇게 실행한 이유를 이해하려면 Strategy 패턴을 살펴볼 필요가 있다. 이 패턴은 동일한 대상에 여러 알고리즘을 별도의 클래스에 정의하도록 허용하고, 관련 클래스의 객체를 이용하여 그 중에 선택할 수 있게 해준다. 우리 예제에서는 찾기 및 바꾸기와 예쁜 인쇄(pretty printing)에 관한 세부 내용을 우리 문서를 사용하는 사람들에게 숨기는 데에 중점을 둔다.
procedure TDocument.SearchAndReplace(const FindText,ReplaceText : string);
begin
if Assigned(FStrategy) then begin
FStrategy.SearchAndReplace(FindText,ReplaceText);
end;
end;
procedure TDocument.PrettyPrint;
begin
if Assigned(FStrategy) then begin
FStrategy.PrettyPrint;
end;
end;
여기서 볼 수 있듯이, 두 메소드의 구현은 Strategy 객체를 따른다. 기반 Strategy 클래스는 아래와 같이 정의된다:
TDocumentStrategy = class(TObject)
private
FDocument : TDocument;
protected
property Document : TDocument read FDocument write FDocument;
public
constructor Create(ADocument : TDocument); virtual;
procedure SearchAndReplace(const FindText,ReplaceText : string); virtual; abstract; ak
procedure PrettyPrint; virtual; abstract;
end;
이것은 추상 클래스인데, 자손 클래스들이 두 가지 메소드를 모두 구현하도록 강요하길 원하기 때문이다. Strategy 객체가 컨텍스트의 프로퍼티로 접근해야 하는 것은 꽤 흔한 경우로, 사실상 그것이 우리가 할 일이다. 이것이 용이하도록 생성자는 문서를 파라미터로 취급한다. Self Encapsulate Field 리팩토링의 사용 시 자손 Strategy 를 다른 유닛에서 선언할 수 있고 여전히 문서로 접근성을 가진다는 사실을 주목한다. 예를 들어 문서 프로퍼티가 protected section에서 선언된다는 점을 주목한다.
DocumentStrategy.pas 파일에서 두 개의 Strategy 클래스 구현을 볼 수 있을 것이다. 두 개의 SearchAndReplace 메소드를 보면 왜 이 패턴을 사용해야 하는지 짐작이 갈 것이다:
procedure TCsvStrategy.SearchAndReplace(const FindText,ReplaceText : string);
begin
Document.Text := StringReplace(Document.Text,FindText,ReplaceText,[rfReplaceAll,end;
procedure TXmlStrategy.SearchAndReplace(const FindText,ReplaceText : string);
begin
FParser.Parse(Document.Text,FInterpreter.XmlDoc);
FInterpreter.XmlDoc.SearchAndReplace(FindText,ReplaceText,True);
//예쁜 인쇄도 출력을 얻을 수 있도록 마찬가지로 적용한다.
FVisitor.Clear;
FInterpreter.XmlDoc.Accept(FVisitor);
Document.Text := FVisitor.Text;
end;
볼 수 있듯이 두 메소드는 꽤 상이하다. 문서의 사용자가 CSV 파일에서 델파이 StringReplace 프로시저를 호출하는 것은 그다지 귀찮은 일이 아닐지 모르지만, XML 파일에 대한 코드라면 상황은 달라진다. 하나 이상의 알고리즘에 관한 세부 내용을 숨기는 데에 예쁜 인쇄와 동일한 클래스를 사용할 수도 있다.
Strategy 패턴 대신 자주 사용하는 대안방법은 컨텍스트를 서브클래싱(subclass) 방법으로, 우리 예제에서는 문서 클래스가 되겠다. 예를 들자면 TCSVDocument와 TXMLDocument를 가질 수 있겠다. 하지만 이는 알고리즘의 구현과 문서를 혼합하여 문서 클래스의 유지가 힘들어질지도 모른다.
클래스 계층구조는 구성이 까다로울 수도 있는데, 특히 하나 이상의 알고리즘을 고려해야 하는 경우 그러하다. 계층구조에서 하나의 가지가 자신의 구현뿐 아니라 다른 가지의 구현까지 공유해야 한다면 상황은 좀 더 힘들어진다.
당신은 TDocument 클래스에 조건문 리스트나 (if…then…else if…) case 문을 이용함으로써 동일한 행위를 얻을 수도 있다. 간단한 우리 예제에서 이는 그다지 불편하지 않아 보이지만 이것이 순식간에 통제를 벗어나 Switch Statements 를 야기하기도 한다. 이러한 현상이 왜 비추인지, 이를 어떻게 고치는지에 관해서는 Refactoring 문서를 참고한다.
몇 가지 불리한 점도 있다. 먼저 시스템에 객체의 수가 증가한다는 점을 들 수 있다 (하지만 개인적으로는 항상 객체의 수가 적어서 유지하기 수월할 것으로 생각된다). 우리 예제에서와 마찬가지로 Strategy 와 컨텍스트는 밀접하게 결합될 수 있다. 필요한 파라미터를 Strategy 메소드에서 전달함으로써 단순히 해결하는 것도 가능하다. 결합의 정도와 관련해 큰 문제는 없지만 어찌되었건 일부 결합은 이 패턴으로도 피할 수 없다.
우리 예제에서는 필요한 만큼 컨텍스트가 (예: 문서) Strategy 를 생성하였지만 클라이언트가 관련 Strategy 를 생성하여 컨텍스트로 전달하는 경우도 흔하다. 따라서 가령 Strategy 를 생성하기 위해 사용자 인터페이스 코드를 얻을 수 있을 것이다. 필자는 이를 항상 리팩토링하여 컨텍스트가 자신이 필요로 하는 Strategy 를 생성할 수 있음을 발견했지만, 어쩌면 이것이 불가능하거나 바람직하지 못한 상황도 있을지 모른다. 상황이 어떻건 Strategy 중 선택하려면 그에 관한 자식이 필요하다는 사실은 눈치챌 것이다.
패턴의 변형도 존재한다. 가장 유용한 변형 중 하나는 알고리즘의 기본 구현을 가진 컨텍스트를 만들고, 특정 상황에서는 Strategy 객체를 생성하는 것에 그친다. 우리는 모든 문서에 대한 StringReplace 프로시저를 사용하는 문서도 있는데, 예를 들자면 XML 파일과 같은 경우에서는 다른 프로시저만 사용한다.
이 패턴은 하나의 클래스에 하나의 알고리즘을 캡슐화하고, 하위클래스가 그 알고리즘의 특정 부분을 달리하도록 허용한다는 점에서 Template 패턴과도 비슷하다. 기반 클래스가 퀵 정렬(quicksort)을 구현할 수 있는 정렬 알고리즘을 예로 들 수 있는데, 하위클래스는 비교 함수를 다르게 정의할 수 있다. 명령 패턴에서 또 다른 예제를 살펴보겠다.
Command 패턴
"요청 또는 오퍼레이션을 객체로 캡슐화함으로써 서로 다른 다른 오퍼레이션, 큐 또는 로그 요청으로 클라이언트를 파라미터화하고, 오퍼레이션의 취소도 가능하게 한다." - GoF
Command 패턴은 델파이에선 액션으로 구현된다 (델파이 4부터). 패턴에 중점을 둘 것이므로 이러한 액션은 언급하지 않을 예정이다. 액션은 복잡하고 위험하며, 이 복잡성으로 인해 필자가 강조하고자 하는 중점으로부터 주의를 분산시킬 수도 있기 때문이다. 액션이 적절하지 않은 경우도 있으므로, 독자들은 자신의 Command 구조를 어떻게 작동시킬 것인지 알고 싶을 것이다.
우리는 위의 옵션 중 마지막 옵션에 Command 패턴을 이용하여 undo/redo 리스트를 구현함과 동시, 사용자 인터페이스 코드가 문서나 그에 위치한 오퍼레이션에 관해 모두 알 필요가 없도록 만들고자 한다. 이를 구현하는 방법을 논한 후에 로깅(logging), 큐, 등을 추가하는 방법을 볼 수 있을 것이다.
가장 간단한 형태로 된 Command 패턴은 Execute 메소드가 있는 추상 기반 클래스로 구성된다. 구상 하위클래스는 가능한 액션마다 선언되며, 각각에 구현된 메소드는 그 액션을 실행하도록 되어 있다. 주로 이는 다른 클래스의 인터페이스로 호출을 필요로 한다. 우리 예제를 살펴보면, 하나의 명령은 파일을 열기 위함이다. 파일명의 프롬프트가 끝나면 Command 명령은 문서 객체의 OpenFile 메소드를 호출할 것이다. 이 두 번째 객체를 receiver(수신자) 라고 부른다. 명령에 요청을 실행하도록 요청하는 객체를 invoker(호출자) 라고 부른다. 이는 주로 메뉴 항목이나 버튼이 해당한다. 이후에 살펴보겠지만 약간 다른 것을 시도하고자 한다.
우리 Command 클래스의 (DocumentCommands.pas에 위치) 선언은 다음과 같다:
TDocumentCommand = class(TObject)
private
FDocument : TDocument;
protected
procedure DoExecute; virtual; abstract;
procedure DoRollback; virtual;
// Self Encapsulate Field 리팩토링을 사용한다. 이제 자식 Command 들은
// 다른 유닛에서 선언된다 하더라도 문서로 접근할 수 있다
property Document : TDocument read FDocument write FDocument;
public
constructor Create(ADocument : TDocument);
procedure Execute;
procedure Rollback; // Execute의 효과를 역전한다
end;
그리고 그 구현은 아래와 같다:
constructor TDocumentCommand.Create(ADocument : TDocument);
begin
inherited Create;
FDocument := ADocument;
end;
procedure TDocumentCommand.DoRollback;
begin
end;
procedure TDocumentCommand.Execute;
begin
if Assigned(FDocument) then begin
DoExecute;
end;
end;
procedure TDocumentCommand.Rollback;
begin
if Assigned(FDocument) then begin
DoRollback;
end;
end;
무슨 일이 일어나고 있는지 살펴보자. 우선 두 개의 public 메소드가 있는데, 하나는 Command 를 실행하기 위한 것이고, 나머지는 취소하기 위함이다. 후자가 필요한 이유는 실행취소(undo)를 지원하기 위함이다. 두 메소드 모두 다른 protected 메소드를 호출하기 전에 문서가 이용 가능한지 검사한다는 사실을 주목한다. 이것은 Template 패턴의 아주 간단한 예제이다. 새 명령을 구현하는 프로그래머가 이를 모두 기억하도록 만들고 싶진 않기 때문에 여기서 우리는 프로그래머들을 대신하여 작업하고, 프로그래머는 DoExecute와 DoRollback 메소드를 오버라이드하기만 하면 된다.
Command 가 실행 취소를 실행할 수 없을지도 모르기 때문에 (이것은 예쁜인쇄와 찾기 및 바꾸기에서만 지원할 예정) DoRollback 메소드 구현은 비어 있으므로, 메소드를 추상적으로 만들어 하위클래스가 그것을 구현하도록 강요하도록 만들고 싶지는 않다. 하지만 Command 가 구현되길 원하므로 DoExecute 메소드는 오버라이드를 강요하기에 추상적이다.
생성자는 파라미터로서 전달된 문서를 받고, 그것은 우리 예제에서 수신자(receiver)로서 사용될 것이다. 예를 들어, Open 명령은 아래와 같이 구현된다:
procedure TOpenCommand.DoExecute;
var
FileName : string;
begin
if PromptForFileName(FileName,'XML files (*.xml)|*.xml|’ +
‘CSV files (*.csv)|*.csv') then begin
FDocument.OpenFile(FileName);
end;
end;
여기서 우리는 델파이 함수 PromptForFileName을 호출하는데, 사용자가 연산을 취소하지 않으면 우리는 텍스트를 로딩하기 위해 문서의 OpenFile 메소드를 호출한다. 다른 명령을 사용할 경우 좀 더 복잡해질 수 있는데, 이것이 바로 Command 로직을 그것이 호출하는 객체로부터 분리 시 가장 먼저 체험할 장점이다.
다른 장점들로는, 기존 클래스들을 변경할 필요가 없으므로 새 명령의 추가가 쉽다는 점을 들 수 있겠다. 이로 인해 코드의 수정 시 위험성이 낮아진다. 컴포지트 패턴을 이용해 여러 다른 명령으로부터 큰 매크로 명령을 만들어낼 수도 있다.
우리 Command 구현부는 모두 궁극적으로 연산을 실행하기 위해 수신자를 사용하지만, 이것이 항상 필요한 것은 아니다. GoF 예제에서는 다른 애플리케이션을 실행하기 위한 명령을 다루었다. Command 는 작업을 다른 객체로 위임할 필요 없이 스스로 이를 실행할 만큼 충분한 정보를 포함할지도 모른다 (예: ShellExecute를 호출하여). 때로는 Command 가 수신자를 동적으로 검색해야 하는지도 모른다. 우리 예제 애플리케이션이 MDI를 사용했다면, 가령 우리는 현재 문서를 찾아야 할지도 모른다.
다음으로, 좀 더 복잡한 Command 가 어떻게 작동하는지 살펴볼 것인데, 이를 위해선 새로운 패턴에 관한 지식이 필요하다.
Memento 패턴
"캡슐화를 위반하지 않고 객체 내부 상태를 객체화하여, 후에 객체를 이 상태로 복구 가능하게 한다." - GoF
Memento 패턴을 이용해 상태를 저장하여 우리 명령들 중 일부에 실행취소(undo)를 구현할 수 있도록 할 것이다. 매우 간단한 패턴으로, 유일한 비법은 객체를 이전 상태로 복구시키려면 private 필드의 값을 설정하여 public으로 노출하지 않는다는 것인데, 그 이유는 다른 객체들이 그러한 필드로 접근성을 가지는 것을 원치 않기 때문이다.
이를 위해선 보통 세 가지 타입의 객체가 필요하다. 그 중 하나가 memento 자체다. 이는 우리가 후에 필요로 할 상태 정보를 보관한다. 다음은 caretaker 로, 기존 상태를 복구시킬 시간이 될 때까지 Memento를 (혹은 여러 Memento를) 저장할 것이다. 물론 후에 필요한 때가 오지 않을지도 모른다. 마지막 클래스는 originator 로, memento 를 생성하기도 하고, 이를 사용해 이전 상태로 되돌아가기도 할 것이다.
델파이에서 이것을 사용 시 어려움이 있다. Memento 클래스는 보통 originator에 대해 광범위한 인터페이스를 가져야만 필요한 값을 모두 설정할 수 있다. 하지만 이러한 인터페이스는 다른 클래스에선 이용할 수 없어야 하는데, 이를 어길 경우 애초에 이 정보를 캡슐화하는 목적에 어긋난다. 따라서 우리에겐 두 개의 선택권이 있다. 하나는 다수의 파라미터가 있는 생성자를 가지는 것이다 (Introduce Parameter Object 리팩토링을 사용한다면 생성자가 객체가 될 수도 있다). 이보다 더 바람직하다고 생각되는 두 번재 선택권은, originator 클래스와 동일한 유닛에 Memento를 정의하는 것이다 (C++에서 우리는 그것을 친구 클래스로 만들 수 있다).
우리 예제에서 originator는 문서이고, Document.pas는 우리가 사용할 Memento의 정의를 포함한다:
TDocumentMemento = class(TObject)
private
FText : string;
end;
이 얼마나 간단한가. 이보다 덜 꾸민 예제는 주로 더 복잡하다. 우리는 앞의 정의에서 보았듯이 문서의 Memento 프로퍼티를 이용해 상태를 얻고 설정할 수 있다. 그에 대한 조상 메소드들은 다음과 같이 정의된다:
function TDocument.GetMemento : TDocumentMemento;
begin
//새로운 Memento를 생성하고, 현재 문서 상태를 보관하여 리턴한다
Result := TDocumentMemento.Create;
Result.FText := Text;
end;
procedure TDocument.SetMemento(const Value : TDocumentMemento);
begin
// Memento로부터 문서 상태를 업데이트한다. 보통은 더 복잡하다.
Text := Value.FText;
end;
Text 프로퍼티에서와 똑같이 효율적으로 보이지만, 보통은 그렇지 않으며, 상태는 여러 필드 값을 저장하도록 요구할 것이다.
예쁜 인쇄 명령의 구현을 살펴보자:
procedure TPrettyPrintCommand.DoExecute;
begin
//만일의 경우를 대비해 현재 Memento가 확실히 해제되었는지 확인한다
FreeAndNil(FOriginalDoc);
FOriginalDoc := FDocument.Memento;
FDocument.PrettyPrint;
end;
procedure TPrettyPrintCommand.DoRollback;
begin
if Assigned(FOriginalDoc) then begin
FDocument.Memento := FOriginalDoc;
end;
end;
명령에서 문서의 현재 상태는 FOriginalDoc 필드에 보관함을 볼 수 있는데, 이는 예쁜 인쇄의 변경내용을 적용하기 전에 caretaker 역할을 함을 알 수 있다. 변경내용을 복귀시키는 것은 문서가 그 상태를 Memento에 저장된 상태로 설정하도록 하는 것을 의미한다.
이제 모두 준비해놨으니 명령 실행취소/재실행(undo/redo) 리스트를 구현할 수 있다.
Facade 패턴
"하위시스템에 있는 인터페이스 집합에 대해서 하나의 통합된 인터페이스를 제공한다. Facade 패턴은 하위시스템을 더 쉽게 사용할 수 있도록 높은 수준 인터페이스를 정의한다." - GoF
보통 Facade 패턴을 구현하려면 다른 클래스들에게 일부 하위시스템의 유일한 인터페이스 역할을하는 facade 클래스를 정의할 필요가 있다. 보통은 하위시스템subsystem 이 꽤 복잡하기 때문인데, 종종 리팩토링과 디자인 패턴의 적용 결과로 인한 것이다. 이러한 실습은 주로 더 작고 많은 클래스들을 야기하여, 클라이언트가 사용하기 어려워지곤 한다. 이러한 클래스들은 더 밀접하게 결합된 것인 보통이다. Facade를 이용 시 그것을 숨긴다.
Facade 를 이용해 하위시스템의 요소들과 그것을 사용하는 클라이언트 간 의존성을 클라이언트와 Facade 사이로 전환하여 전체적인 애플리케이션의 결합을 감소시킬 수 있다. 클라이언트에 영향을 미치지 않고 하위시스템을 변경할 수 있다.
어떤 쪽이건 하위시스템 객체는 Facade 로 참조를 유지해선 안 되며, 자신에게 할당된 작업만 실행해야 한다.
우리 Facade 는 CommandFacade.pas에서 다음과 같이 정의된다:
TCommandFacade = class(TObject)
private
FCommandList : TObjectList;
FCurrCommand : Integer;
//짐작컨대 여기서 생성되기보단 등록되어야 할 것이다.
FDocument : TDocument;
FMemo : TMemo;
procedure ClearOldCommands;
procedure PerformCommand(CommandClass : TDocumentCommandClass;
ClearCommandList : Boolean);
procedure UpdateMemo;
protected
public
constructor Create;
destructor Destroy; override;
procedure OpenDocument;
procedure CloseDocument;
procedure Undo;
procedure Redo;
procedure SearchAndReplace;
procedure PrettyPrint;
//문서 내용이 변경 시 메모 컨트롤이 업데이트되도록 해준다
//실제로는 Observer 여야 하는데, session 이 너무 길다!
procedure RegisterMemo(AMemo : TMemo);
end;
우리 Facade 객체는 FCommandList 필드에서 실행된 명령 리스트를 보관하고, FCurrCommand에서 마지막으로 실행된 명령을 추적한다. 또한 여기서 애플리케이션에 대한 문서 객체를 보관하여 필요 시 명령으로 전달할 수 있도록 할 것이다. 메소드 리스트도 갖고 있는데, 각 메소드는 이용 가능한 명령들 중 하나를 나타낸다. 우리에겐 두 개의 메소드가 있으며ㅡundo와 redoㅡ잠시 후 상세히 살펴보겠다.
마지막으로, 문서를 표시할 메모 컨트롤로의 참조가 있다. RegisterMemo 메소드를 이용해 이것을 추가한다. 이상적으로는 변경내용을 추적하기 위해 관찰자(Observer) 또는 중재자(Mediator) 패턴을 구현하겠지만, 본 논문에 싣기엔 양이 방대하다.
예쁜 인쇄 명령 메소드를 위한 코드를 살펴보면, 네 가지 주요 액션이 어떻게 구현되는지 볼 수 있다:
procedure TCommandFacade.PrettyPrint;
begin
PerformCommand(TPrettyPrintCommand,False);
UpdateMemo;
end;
PerformCommand 루틴은 아래와 같다:
procedure TCommandFacade.PerformCommand(CommandClass : TDocumentCommandClass;
ClearCommandList : Boolean);
var
NewCommand : TDocumentCommand;
begin
NewCommand := CommandClass.Create(FDocument);
try
NewCommand.Execute;
if ClearCommandList then begin
FCommandList.Clear;
end else begin
// 실행 취소 후 새로운 명령을 선택하면 아래의 기존 명령을 삭제한다.
ClearOldCommands;
FCurrCommand := FCommandList.Add(NewCommand);
end;
except
// 예외가 발생하지 않을 시 명령을 명령 리스트에 추가만 한다.
NewCommand.Free;
end;
end;
우리는 새 명령 객체를 생성하는데, 그 구성 클래스는 실행될 명령 타입에 따라 결정된다. 파일을 열거나 닫을 시 undo/redo 리스트가 제거되고, 예쁜 인쇄나 찾기 및 바꾸기를 실행 시 리스트의 끝에 명령이 추가될 것이다. 리스트를 복귀했다면 새 명령이 추가되기 전에 우리가 실행취소한 모든 명령이 리스트에서 제거될 것이다. 명령 액션을 실행하고 나면 메모는 새 문서 텍스트를 표시하도록 업데이트된다 (파일을 닫을 경우 비어 있을 것이다).
실행취소와 재실행을 구현하는 소스 코드를 보면 관련 명령을 실행하는 undo/redo 리스트를 왔다 갔다함을 알 수 있을 것이다.
본문에 실은 구현은 매우 간단한 편으로, 특정 상황에서 undo/redo 리스트를 수정하는 방식은 GoF 서적을 참고할 것을 권한다. 우리가 발견한 바와 같이 여러 패턴에 걸쳐 확장한 예제를 싣고 있지만 Flyweight 등 적용할 수 있는 다른 패턴들도 있다.
마지막으로 적용할 패턴은 Singleton 패턴이다. 이 패턴은 가장 간단한 패턴에 속하므로 이에 관한 내용은 이미 많은 서적에서 다루어왔다. 우리는 Facade 객체가 한 번에 하나씩 생성되는 인스턴스만 갖도록 확실히 할 것이다. 인스턴스를 세는 간단한 메소드를 사용할 것이지만, 이를 실행하는 좀 더 복잡한 방법들도 발견할 것이다. 이러한 복잡성이 항상 지나치다고 생각되지만 일일이 열거하진 않겠다. 전역적 Commands 변수는 CommandFacade.pas 유닛의 initialization 부와 finalization 부에서 생성 및 파괴된다.
Facade 패턴을 사용 시 차이점을 확인하려면 MainForm.pas에서 사용자 인터페이스 코드를 살펴봐야 하는데, 여기서 당신은 각 메뉴 항목 이벤트 핸들러에 대한 코드가 한 줄로 된 코드를 갖고 있음을 발견할 것이다. 문서, 명령 리스트, 파서, 해석자 등을 관리하면서 꽤 복잡해질 법한 것들이 이제 UI로부터 완전히 숨겨져 있다. 인터페이스의 변경은 식은죽 먹기다. 새 명령의 추가도 꽤 쉽다.
요약
어느 정도 복잡한 예제, 인정하건대 곳곳에선 억지로 꾸민 듯한 예제를 더 잘 구성하고 구현하기 위해 패턴들을 사용하는 방식을 살펴보았다. 때로는 굳이 필수적이지 않은 곳에서 주어진 패턴을 사용하였는데, 그 상황에서 최선의 선택이어서가 아니라 그 패턴을 어떻게 사용할 수 있는지를 살펴보기 위함이었다. 자주 언급되지는 않지만 그럼에도 불구하고 필자가 자주 사용하는 패턴들도 몇 가지 논하였다.
바라건대 독자들도 자신의 작업에 패턴을 이용하는 방법에 대해 느끼는 바가 있었으면 한다. 자신의 애플리케이션에서 특정 부분을 어떻게 구현 또는 설계할 것인지 전혀 아이디어가 떠오르지 않는다거나, 코드가 너무 길고 복잡해지는 것을 발견한다면, 패턴 서적을 살펴볼 차례다.
패턴은 코드가 아니며, 자신의 애플리케이션에서 본 논문에 실린 코드를 작동시키려면 어느 정도 변경을 필요로 한다는 사실은 명심하길 바란다.
참고내용
- Gamma, Erich et al. Design Patterns. Elements of Reusable Object-Oriented Software, 1995, Addison-Wesley ISBN 0-201-63361-2
- Larman, Craig. Applying UML and Patterns, 2002 Prentice-Hall PTR ISBN 0-13-092569-1
- Bucknall, Julian. The Tomes of Delphi: Algorithms and Data Structures, 2001, Wordware Publishing Inc ISBN 1-55622-736-1
- Aho, Sethi and Ullman. Compilers: Principles, Techniques and Tools, 1986, Addison-Wesley ISBN 0-201-10194-7