Uuno 에디터 강지수
디지털 명함 에디터를 개발하면서 가장 깊이 고민했던 기능 중 하나는 바로 Undo/Redo 기능이었다. 단순해 보이지만 실제로 구현하려면 복잡한 상태 관리, 요소 추적, ID 문제, 그리고 사용자 경험까지 고려해야 하는 만만치 않은 문제였다. 이 글에서는 Undo/Redo 기능을 개발하며 겪은 시행착오, 고민의 흔적들, 그리고 최종적으로 도달한 해결책을 공유하고자 한다.
에디터에서 사용자가 작업 중에 실수했을 때 "되돌리고 싶다"는 요구는 필수적이다. 이는 단순히 상태를 관리하는 것을 넘어서, 변경 전의 상태를 저장하고, 그 상태로 복원하는 메커니즘이 필요하다는 것을 의미한다.
처음에는 가능한 한 단순하게 접근하고 싶었기에, 개별 요소의 변경 사항만 기록하는 방식을 선택했다. 이러한 선택에는 다음과 같은 이유가 있었다:
이러한 판단으로 초기 구현은 다음과 같은 구조로 시작했다:
export interface EditorState {
histories: TextElement[]; // 개별 요소들의 기록
historyIdx: number; // 현재 기록 위치
showElements: TextElement[]; // 화면에 표시되는 요소들
// ... 기타 상태 및 함수들
}
histories
배열에 새 요소 추가updateText(id, updates)
를 통해 ID에 맞는 요소를 찾아 업데이트historyIdx--
로 이전 상태로 이동historyIdx++
로 다음 상태로 이동이 접근 방식은 초기에는 괜찮아 보였지만, 실제로 구현하고 테스트하면서 여러 문제점이 드러났다.
const updatedElement = state.histories.map(el => el.id === id ? { ...el, ...updates } : el);
const updatedHistories = [...state.histories, updatedElement[0]];
이 코드에서는 map()
함수가 전체 배열을 반환하는데, 그 중 [0]
번 요소만 히스토리에 추가했다. 이로 인해 수정한 요소가 아닌, 항상 배열의 첫 번째 요소만 기록되는 문제가 발생했다. 실제로 디버깅해보니 내가 수정하고 있는 요소의 ID는 048c...
인데, 히스토리에 저장되는 것은 매번 0032...
와 같은 고정된 ID였다.
개별 요소 기반 저장 방식에서는 화면에 표시되는 요소들(showElements
)과 히스토리에 저장된 요소들(histories
) 간에 동기화 문제가 발생했다. 예를 들어, 요소의 위치를 변경했을 때 showElements
에는 반영되었지만, histories
에는 제대로 기록되지 않는 등의 불일치가 생겼다.
여러 요소가 추가, 수정, 삭제될 때 각 요소의 ID를 정확히 추적하는 것이 생각보다 복잡했다. 특히 Undo/Redo 과정에서 ID를 기준으로 요소를 찾아 업데이트해야 하는데, 이 과정에서 예상치 못한 버그가 자주 발생했다.
// 각 요소별로 ID를 추적하는 로직이 필요
undo: () => {
const state = get();
if (state.historyIdx > 0) {
const prevIdx = state.historyIdx - 1;
const prevState = state.histories[prevIdx];
const updatedShowElements = state.showElements.map((el) =>
el.id === prevState.id ? { ...el, ...prevState } : el
);
set({
historyIdx: prevIdx,
showElements: updatedShowElements,
});
}
}
이 코드에서는 히스토리에서 이전 상태를 가져와 현재 화면의 요소들과 ID로 매칭하여 업데이트한다. 하지만 요소가 삭제되거나 새로 추가된 경우에는 이 로직이 제대로 작동하지 않았다.
이러한 문제들을 해결하기 위해 다양한 시도를 했다:
updateText: (id, updates) => {
console.log('[updateText] id:', id);
console.log('[updateText] selectedElementId:', selectedElementId);
// ...
}
디버깅을 통해 실제 선택된 요소와 업데이트되는 요소 간의 불일치를 확인했다.
map()[0]
대신 find()
를 사용하여 정확한 요소를 찾도록 수정했다.
const updatedElement = state.histories.find(el => el.id === id);
if (updatedElement) {
const updatedHistories = [
...state.histories.slice(0, state.historyIdx + 1),
{ ...updatedElement, ...updates }
];
// ... 코드 계속
}
이렇게 수정하니 적어도 올바른 요소가 히스토리에 기록되기 시작했다.
Konva 캔버스에서의 요소 위치 정보를 정확히 가져오기 위해 getAbsolutePosition()
을 활용했다.
onDragEnd={(e) => {
const node = shapeRefs.current[el.id];
const absPos = node
? node.getAbsolutePosition()
: { x: el.x, y: el.y };
updateText(el.id, {
x: absPos.x,
y: absPos.y,
});
}}
여러 문제를 해결하려 노력했지만, 근본적인 해결책은 접근 방식 자체를 바꾸는 것이었다. 개별 요소 기반에서 전체 상태의 스냅샷 방식으로 변경하는 것이었다.
스냅샷 방식은 각 상태 변경 시점에 전체 요소 목록의 복사본을 저장하는 방식이다
interface HistoryEntry {
elements: TextElement[]; // 이 시점의 전체 요소 목록
action: 'add' | 'update' | 'remove'; // 작업 종류 (선택적)
elementId?: string; // 작업 대상 ID (선택적)
}
export interface EditorState {
histories: HistoryEntry[];
historyIdx: number;
showElements: TextElement[];
// ... 기타 상태 및 함수들
}
undo: () => {
const state = get();
if (state.historyIdx > 0) {
const prevIdx = state.historyIdx - 1;
const prevHistoryEntry = state.histories[prevIdx];
set({
historyIdx: prevIdx,
showElements: prevHistoryEntry.elements
});
}
},
redo: () => {
const state = get();
if (state.historyIdx < state.histories.length - 1) {
const nextIdx = state.historyIdx + 1;
const nextHistoryEntry = state.histories[nextIdx];
set({
historyIdx: nextIdx,
showElements: nextHistoryEntry.elements
});
}
}
하지만 현대 웹 환경에서 이 정도의 메모리 사용은 크게 문제가 되지 않으며, 코드의 안정성과 명확성을 위해 기꺼이 감수할 수 있는 수준이다.
최종적으로는 각 변경 사항마다 전체 상태의 스냅샷을 저장하는 방식으로 구현했다
addText: (element) => {
const state = get();
const newElements = [...state.showElements, element];
// 현재 historyIdx 이후의 기록을 버리고 새 히스토리 항목 추가
const updatedHistories = [
...state.histories.slice(0, state.historyIdx + 1),
{
elements: newElements,
action: 'add',
elementId: element.id
}
];
// 상태 업데이트
set({
histories: updatedHistories,
showElements: newElements,
historyIdx: updatedHistories.length - 1,
});
},
updateText: (id, updates) => {
const state = get();
// 요소 업데이트
const updatedElements = state.showElements.map((el) =>
el.id === id ? { ...el, ...updates } : el
);
// 히스토리 업데이트
const updatedHistories = [
...state.histories.slice(0, state.historyIdx + 1),
{
elements: updatedElements,
action: 'update',
elementId: id
}
];
set({
histories: updatedHistories,
showElements: updatedElements,
historyIdx: updatedHistories.length - 1,
});
}
이 방식은 코드가 간결해지고, 버그 발생 가능성이 크게 줄어들었다. 전체 상태를 스냅샷으로 저장하므로 ID 추적이나 개별 요소 매핑에 대한 걱정 없이 Undo/Redo가 정확하게 작동한다.
Undo/Redo 기능을 구현하면서 가장 큰 깨달음은 단순한 생각이 라는 것이었다. 처음에는 메모리 효율성을 위해 복잡한 개별 요소 추적 방식을 선택했지만, 그로 인한 버그와 혼란은 그 이점을 크게 상쇄했다. 처음부터 효율성을 갖추고 개발하면 좋겠지만 그럴 수 없는 경우도 있다는 것을 깨달았다.
결국, 전체 상태를 스냅샷으로 저장하는 단순한 접근 방식이 코드의 가독성, 유지보수성, 그리고 안정성을 모두 개선해주었다. 이는 "간결함이 복잡함보다 낫다"는 소프트웨어 개발의 기본 원칙을 다시 한번 확인시켜 준 경험이었다.