History 관리 (Undo, Redo)
회사에서 History 관리에 대한 요구사항을 받았고, 이를 수행하기 위해 디자인 패턴 중 Command 패턴을 이용하기로 하였다.
Command 패턴은 소프트웨어 디자인 패턴 중 하나로, 주로 실행 취소(Undo)와 다시 실행(Redo) 기능을 구현할 때 많이 사용된다. 이 패턴에서는 각 작업을 커맨드 객체로 캡슐화하여, 작업에 대한 정보를 독립적으로 관리하고 실행할 수 있게 한다.
예를 들어, 사용자가 텍스트를 입력하거나 객체를 추가하는 등의 작업을 할 때, 각각의 작업을 커맨드 객체로 만들어 저장한다. 이렇게 하면 언제든지 실행 취소(Undo)나 다시 실행(Redo)할 수 있어, 복잡한 작업도 쉽게 관리할 수 있다.
command-manager.ts
import { Deque } from './deque';
export class CommandManager {
public undoHistory: Deque;
public redoHistory: Deque;
private static instance: CommandManager | null = null;
private constructor() {
this.undoHistory = new Deque();
this.redoHistory = new Deque();
}
/**
* `CommandManager`의 인스턴스를 반환합니다. 이미 인스턴스가 존재하면 해당 인스턴스를 반환하고,
* 존재하지 않으면 새로 생성합니다.
* @returns {CommandManager} `CommandManager`의 싱글톤 인스턴스.
* @throws {Error} 인스턴스 생성 중 오류가 발생할 경우 오류를 출력합니다.
*/
static getInstance(): CommandManager {
try {
if (CommandManager.instance === null) {
CommandManager.instance = new CommandManager();
}
return CommandManager.instance;
} catch (e) {
console.error(e);
}
}
/**
* 주어진 Command를 실행하고, 실행된 Command를 `undoHistory`에 추가합니다.
* `undoHistory`의 크기가 100개를 초과하면 가장 오래된 Command를 제거합니다.
* @param {object} command - 실행할 Command 객체. 이 객체는 `execute()` 메서드를 가져야 합니다.
* @returns {*} Command 실행의 결과를 반환합니다.
* @throws {Error} Command 실행 중 오류가 발생할 경우 오류를 출력합니다.
*/
execute(command: any): any {
try {
let result;
// undoHistory 100개 갯수 제한
if (this.undoHistory.getSize() === 100) {
this.undoHistory.removeFront();
}
if (command) {
result = command.execute();
this.undoHistory.addBack(command);
}
if (this.redoHistory.getSize() > 0) {
this.redoHistory.clear();
}
return result;
} catch (e) {
if (command && command.constructor) {
console.error(`Error in command instance of type: ${command.constructor.name}`);
}
console.error(e);
}
}
/**
* 실행된 Command를 실행 취소(undo)합니다. 실행 취소된 Command는 `redoHistory`에 추가됩니다.
* @returns {Promise<void>} 실행 취소 작업이 완료되면 반환됩니다.
* @throws {Error} 실행 취소 중 오류가 발생할 경우 오류를 출력합니다.
*/
async undo(): Promise<void> {
let command: any;
try {
command = this.undoHistory.removeBack();
if (command) {
command.undo();
this.redoHistory.addBack(command);
}
} catch (e) {
if (command && command.constructor) {
console.error(`Error in command instance of type: ${command.constructor.name}`);
}
console.error(e);
}
}
/**
* 실행 취소된 Command를 다시 실행(redo)합니다. 다시 실행된 Command는 `undoHistory`에 추가됩니다.
* @returns {Promise<void>} 다시 실행 작업이 완료되면 반환됩니다.
* @throws {Error} 다시 실행 중 오류가 발생할 경우 오류를 출력합니다.
*/
async redo(): Promise<void> {
let command: any;
try {
const command = this.redoHistory.removeBack();
if (command) {
command.redo();
this.undoHistory.addBack(command);
}
} catch (e) {
if (command && command.constructor) {
console.error(`Error in command instance of type: ${command.constructor.name}`);
}
console.error(e);
}
}
}
Command 패턴에서 일반적으로 undoHistory와 redoHistory는 배열을 사용하지만, 여기서는 Deque(데크) 자료구조를 선택했다. 그 이유는 시간 복잡도 때문이다.
히스토리를 관리할 때, 무제한으로 저장할 수 없기 때문에 저장 개수에 제한을 두어야 한다. 이 경우, 가장 오래된 작업은 제거되고, 새로운 작업이 추가되는 방식으로 동작하게 된다.
배열을 사용할 경우, 맨 앞의 요소를 제거하려면 shift() 메서드를 사용해야 하는데, 이때 모든 요소의 인덱스를 재정렬해야 하므로 O(n)의 시간 복잡도를 가지게 된다. 요소가 많아질수록 비효율적이라는 단점이 있다.
반면, Deque는 헤드(head)와 테일(tail) 포인터를 가지고 있어, 맨 앞이나 맨 뒤에서 요소를 제거하고 추가하는 작업을 O(1)의 시간 복잡도로 수행할 수 있다. 이러한 이유로, Deque 자료구조를 사용하여 더 효율적으로 히스토리를 관리하였다.
Deque(데크)
Deque(데크, Double Ended Queue)는 큐와 스택의 기능을 모두 가진 자료구조로, 양쪽 끝(Front와 Back)에서 요소를 추가하거나 제거할 수 있다. Deque는 head와 tail을 통해 각 끝을 관리하며, 이로 인해 효율적인 요소 추가와 제거가 가능하다.
Deque의 구성 요소
- head: 가장 앞(front)에 있는 노드를 가리킴.
- tail: 가장 뒤(back)에 있는 노드를 가리킴.
- node: 실제 값을 가지고 있는 요소로, 다른 노드와 연결됨.
- prev: 이전 노드를 가리키는 포인터.
- next: 다음 노드를 가리키는 포인터.
deque.ts
export class Node {
public value; // 노드에 저장되는 실제 값(command)
public prev; // 이전 노드를 가리키는 링크 (초기값은 null)
public next; // 이후 노드를 가리키는 링크 (초기값은 null)
constructor(value: any) {
this.value = value;
this.prev = null;
this.next = null;
}
}
export class Deque {
private head; // Deque의 첫 번째 노드를 가리키는 포인터
public tail; // Deque의 마지막 노드를 가리키는 포인터
public size; // Deque 안에 있는 노드의 수
constructor() {
this.head = null;
this.tail = null;
this.size = 0;
}
/**
* Deque의 앞에 새로운 값을 추가합니다.
*
* @param {any} value - Deque에 추가할 값.
* @returns {void}
*/
addFront(value: any): void {
const newNode = new Node(value);
if (this.size === 0) {
// Deque가 비어있는 경우
this.head = this.tail = newNode;
} else {
// 기존의 Deque 앞에 새로운 노드를 추가함
// 새로운 노드의 next는 기존의 Deque의 head를 가리킨다.
newNode.next = this.head;
this.head.prev = newNode;
// Deque의 head는 새로운 노드를 가리킨다.
this.head = newNode;
}
this.size++;
}
/**
* Deque의 뒤에 새로운 값을 추가합니다.
*
* @param {any} value - Deque에 추가할 값.
* @returns {void}
*/
addBack(value: any): void {
const newNode = new Node(value);
if (this.size === 0) {
this.head = this.tail = newNode;
} else {
newNode.prev = this.tail;
this.tail.next = newNode;
this.tail = newNode;
}
this.size++;
}
/**
* Deque의 앞에서 값을 제거하고 반환합니다.
*
* @returns {any} - 제거된 값. Deque가 비어있으면 null을 반환합니다.
*/
removeFront(): any {
if (this.size === 0) return null;
// 현재 head의 value를 저장한다.
const value = this.head.value;
// 현재 head의 next를 새로운 head로 지정한다.
this.head = this.head.next;
// head가 변경 되었다면, 즉 새로운 head가 존재하면,
if (this.head) {
// 이 노드의 prev를 null로 지정한다. 새로운 head보다 더 앞에 있는 노드가 없다는 것을 의미한다.
this.head.prev = null;
} else {
// Deque가 비워지면(this.head가 null이면), tail도 null로 설정하여 Deque가 완전히 비어 있음을 표시한다.
this.tail = null;
}
this.size--;
return value;
}
/**
* Deque의 뒤에서 값을 제거하고 반환합니다.
*
* @returns {any} - 제거된 값. Deque가 비어있으면 null을 반환합니다.
*/
removeBack(): any {
if (this.size === 0) return null;
const value = this.tail.value;
this.tail = this.tail.prev;
if (this.tail) {
this.tail.next = null;
} else {
this.head = null;
}
this.size--;
return value;
}
/**
* 현재 Deque의 크기를 반환합니다.
*
* @returns {number} - Deque의 노드 수.
*/
getSize(): number {
return this.size;
}
/**
* Deque이 비어 있는지 확인합니다.
*
* @returns {boolean} - Deque이 비어 있으면 true, 그렇지 않으면 false.
*/
isEmpty(): boolean {
return this.size === 0;
}
/**
* Deque을 비웁니다.
*
* @returns {void}
*/
clear(): void {
this.head = null;
this.tail = null;
this.size = 0;
}
}
예시
addFront(0):
- Deque에 0을 추가하면 다음과 같은 상태가 된다.
- [0]
Head / Tail -> [0]: head와 tail이 모두 0을 가리킨다.
addBack(2)
- Deque의 끝에 2를 추가하면 다음과 같은 상태가 된다.
- [0, 2]
- 노드 구성
- 0의 next는 2를 가리킨다.
- 2의 prev는 0을 가리킨다.
- Deque 상태
- head는 0을 가리키고, tail은 2를 가리킨다.
Head -> [0] <-> [2] <- Tail
addFront(-1)
- Deque의 앞에 -1을 추가하면 다음과 같은 상태가 된다.
- [-1, 0, 2]
- 노드 구성
- -1의 next는 0을 가리킨다. (newNode.next = this.head)
- 0의 prev는 -1을 가리킨다. (this.head.prev = newNode)
- 0의 next는 2를 가리킨다.
- 2의 prev는 0을 가리킨다.
- Deque 상태
- head는 -1을 가리키고, tail은 2를 가리킨다.
Head -> [-1] <-> [0] <-> [2] <- Tail
Factory Method 패턴
팩토리 메소드 패턴은 객체 생성 로직을 캡슐화하여 데이터에 따라 적절한 객체를 인스턴스화하는 디자인 패턴이다.
이 패턴을 사용하면, 클라이언트 코드가 직접적으로 객체를 생성하지 않고, 팩토리 메소드를 통해 필요한 객체를 생성할 수 있다.
도입 배경
수많은 Command 클래스들을 클라이언트 코드에서 직접 인스턴스화하다 보면 코드가 난잡해지고, 객체 생성 로직이 흩어지면서 유지보수가 어려워지는 문제가 발생하였다.
이 패턴을 사용하면 객체 생성 로직을 하나의 중앙화된 위치로 모아서 관리를 할 수 있다. 즉, 클라이언트 코드가 Command 클래스에 직접 접근하지 않고, 팩토리 메소드를 통해 필요한 Command 객체를 생성하도록 구조화할 수 있다.
command-factory.ts
type CommandType = 'modify' | 'remove' | 'add' | 'erase' | 'colorChange' | 'clear'
class CommandFactory {
static createCommand(type: CommandType, data: any): CommandBase {
switch (type) {
case 'modify':
return new ObjectModifiedCommand(data);
case 'remove':
return new ObjectRemoveCommand(data);
case 'add':
return new ObjectAddCommand(data);
case 'erase':
return new ObjectEraseCommand(data);
case 'colorChange':
return new ObjectColorChangeCommand(data);
case 'clear':
return new ObjectClearCommand(data);
default:
throw new Error(`Unrecognized command type '${type}'`);
}
}
}
export { CommandFactory };
'Programming Language > JS & TS' 카테고리의 다른 글
[TS] Type Guard 와 as 키워드 (0) | 2024.10.31 |
---|---|
[JS] every 메소드로 객체 속 값 비교하기 (0) | 2024.10.22 |
[TS] interface와 type 비교 및 상속, 그리고 Omit과 Pick 활용 (0) | 2024.10.21 |
[JS] Prototype과 __proto__ (1) | 2024.10.17 |
event.preventDefault & event stopPropagation (0) | 2024.10.10 |