오늘의 실무형 코딩테스트 - 피그마 Undo-Redo 구현
오늘도 시대를 역행(?)하는 코딩테스트 공부를 해 봅니다. ^^
AI 친구들이 만들어 준 걸 아직은 분석&이해&수정 할 필요도 있고
애초에 백그라운드 지식이 많아야 AI와 더 친하게 놀 수 있습니다.
AI는 코드를 범람시키기는 하지만, 책임지지는 못합니다. 설계를 고민하며, AI를 팀 동료로 삼아 생산성을 극대화 하되,
책임의 방향키를 놓치지 않는 개발자가 되기 위해서는 여전히 공부해야하고, 코드 읽기를 해야 합니다.
문제:
간단한 그림판(선, 네모 그리기)에서 커맨드 패턴을 활용한 undo/redo 기능 구현
요구사항
UI
선(Line)과 네모(Rectangle) 중 하나를 선택해 캔버스에 그릴 수 있는 메뉴를 제공한다.
Undo, Redo 버튼을 제공한다.
캔버스는 HTML5 <canvas>를 사용한다.
기능
선 또는 네모를 마우스로 그릴 수 있다.
그리기 동작은 커맨드 패턴(Command Pattern)으로 구현한다.
Undo 버튼을 누르면 마지막에 그린 도형이 사라진다.
Redo 버튼을 누르면 Undo로 지운 도형이 다시 나타난다.
Undo/Redo는 커맨드 리스트와 포인터로 관리한다.
상태관리
전역 상태 관리는 jotai를 사용한다.
기술스택
Next.js, React, TailwindCSS, jotai
코드 구조
커맨드 패턴의 핵심(커맨드 객체, 리스트, 포인터, execute, undo/redo)이 명확히 드러나야 한다.
보너스(선택)
코드에 커맨드 패턴의 장점(확장성, 테스트 용이성 등)을 주석으로 설명하시오.
모범 답안
1. state/atoms.ts
import { atom } from 'jotai';
// 현재 선택된 툴 (선/네모)
export const currentToolAtom = atom<'line' | 'rect'>('line');
// 커맨드 리스트 (도형 그리기 명령들의 배열)
export const commandListAtom = atom<any[]>([]);
// 현재 커맨드 포인터 (undo/redo 위치)
export const commandPointerAtom = atom<number>(-1);
2. state/commands.ts
// 커맨드 객체는 execute(ctx) 메서드를 가진다.
// 커맨드 패턴의 장점: execute만 구현하면, 도형 추가/기능 확장이 쉽다.
export function drawLineCommand(canvasRef, x1, y1, x2, y2, setCommandList, setPointer) {
const command = {
type: 'line',
x1, y1, x2, y2,
execute(ctx) {
ctx.beginPath();
ctx.moveTo(this.x1, this.y1);
ctx.lineTo(this.x2, this.y2);
ctx.stroke();
}
};
setCommandList((prev) => [...prev.slice(0, prev.length), command]);
setPointer((prev) => prev + 1);
}
export function drawRectCommand(canvasRef, x1, y1, x2, y2, setCommandList, setPointer) {
const command = {
type: 'rect',
x1, y1, x2, y2,
execute(ctx) {
ctx.beginPath();
ctx.rect(this.x1, this.y1, this.x2 - this.x1, this.y2 - this.y1);
ctx.stroke();
}
};
setCommandList((prev) => [...prev.slice(0, prev.length), command]);
setPointer((prev) => prev + 1);
}
// undo/redo는 포인터만 이동 (실제 그리기는 index.tsx에서 useEffect로 처리)
export function undoCommand(canvasRef, setPointer) {
setPointer((prev) => Math.max(-1, prev - 1));
}
export function redoCommand(canvasRef, setPointer) {
setPointer((prev) => prev + 1);
}3. pages/index.tsx
import { useAtom, useAtomValue } from 'jotai';
import { useRef, useEffect } from 'react';
import React from 'react';
import { currentToolAtom, commandListAtom, commandPointerAtom } from '../state/atoms';
import { drawLineCommand, drawRectCommand, undoCommand, redoCommand } from '../state/commands';
const tools = [
{ name: '선', value: 'line' },
{ name: '네모', value: 'rect' },
];
export default function Home() {
const [currentTool, setCurrentTool] = useAtom(currentToolAtom);
const [_, setCommandList] = useAtom(commandListAtom);
const [pointer, setPointer] = useAtom(commandPointerAtom);
const commandList = useAtomValue(commandListAtom);
const pointerValue = useAtomValue(commandPointerAtom);
const canvasRef = useRef<HTMLCanvasElement>(null);
let drawing = false;
let startX = 0, startY = 0;
function handleMouseDown(e: React.MouseEvent) {
drawing = true;
const rect = (e.target as HTMLCanvasElement).getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
}
function handleMouseUp(e: React.MouseEvent) {
if (!drawing) return;
drawing = false;
const rect = (e.target as HTMLCanvasElement).getBoundingClientRect();
const endX = e.clientX - rect.left;
const endY = e.clientY - rect.top;
if (currentTool === 'line') {
drawLineCommand(canvasRef, startX, startY, endX, endY, setCommandList, setPointer);
} else if (currentTool === 'rect') {
drawRectCommand(canvasRef, startX, startY, endX, endY, setCommandList, setPointer);
}
}
// 커맨드 리스트와 포인터가 바뀔 때마다 캔버스를 다시 그림
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i <= pointerValue; i++) {
commandList[i]?.execute(ctx);
}
}, [commandList, pointerValue]);
return (
<div className="flex flex-col items-center min-h-screen bg-gray-100">
<div className="flex gap-2 my-4">
{tools.map((tool) => (
<button
key={tool.value}
className={`px-4 py-2 rounded ${currentTool === tool.value ? 'bg-blue-500 text-white' : 'bg-white border'}`}
onClick={() => setCurrentTool(tool.value as 'line' | 'rect')}
>
{tool.name}
</button>
))}
<button className="px-4 py-2 bg-gray-300 rounded" onClick={() => undoCommand(canvasRef, setPointer)}>
Undo
</button>
<button className="px-4 py-2 bg-gray-300 rounded" onClick={() => redoCommand(canvasRef, setPointer)}>
Redo
</button>
</div>
<canvas
ref={canvasRef}
width={600}
height={400}
className="bg-white border shadow"
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
/>
</div>
);
}화면에서 액션을 한다. (마우스를 버튼 업 등)
commands 모듈에서 해당 명령에 대한 객체 구성을 수행한다.
atom 전역 모듈에 상태가 변경된다. (명령은 무조건 적재되고, Undo,Redo 는 포인터가 변경된다)
전역 상태가 바뀌었으니, 화면이 재드로잉 된다. (useEffect 효과를 받고 atom에 적재된 명령을 포인터 위치 만큼 하나씩 수행해준다)
1. 단방향 데이터 플로우
Action → Command → State → UI2. 관심사의 분리
Commands: 비즈니스 로직
Atoms: 상태 관리
Component: UI 렌더링
커맨드 패턴의 장점
도형 추가/기능 확장 시 기존 코드 수정 없이 새로운 커맨드만 추가하면 됨
커맨드 객체 단위로 테스트가 쉬움
undo/redo, 매크로 등 다양한 기능에 유연하게 대응 가능
개선된 버전 - feat. Claude 4.0)
1. state/atoms.ts
// state/atoms.ts
import { atom } from 'jotai';
// 도구 타입 정의
export type Tool = 'line' | 'rect';
// 커맨드 인터페이스 - 타입 안정성 보장
export interface Command {
id: string;
type: Tool;
timestamp: number;
execute(ctx: CanvasRenderingContext2D): void;
}
// 선 그리기 커맨드
export interface LineCommand extends Command {
type: 'line';
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
lineWidth: number;
}
// 사각형 그리기 커맨드
export interface RectCommand extends Command {
type: 'rect';
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
lineWidth: number;
filled: boolean;
}
// 현재 선택된 도구
export const currentToolAtom = atom<Tool>('line');
// 커맨드 히스토리 관리
export const commandHistoryAtom = atom<Command[]>([]);
// 현재 위치 (undo/redo 포인터)
export const currentPositionAtom = atom<number>(-1);
// 그리기 설정
export const drawingSettingsAtom = atom({
color: '#000000',
lineWidth: 2,
filled: false
});
// 파생 atom들 - 계산된 상태
export const canUndoAtom = atom(
(get) => get(currentPositionAtom) >= 0
);
export const canRedoAtom = atom(
(get) => {
const position = get(currentPositionAtom);
const history = get(commandHistoryAtom);
return position < history.length - 1;
}
);
// 실행 가능한 커맨드들만 반환
export const visibleCommandsAtom = atom(
(get) => {
const commands = get(commandHistoryAtom);
const position = get(currentPositionAtom);
return commands.slice(0, position + 1);
}
);
2. state/commands.ts
// state/commands.ts
import { LineCommand, RectCommand, Command } from './atoms';
/**
* 커맨드 팩토리 - 커맨드 객체 생성의 일관성 보장
* 장점:
* - ID 자동 생성으로 추적 가능
* - 타임스탬프로 실행 순서 보장
* - 타입 안정성 확보
*/
// 고유 ID 생성기
let commandIdCounter = 0;
const generateCommandId = () => `cmd_${Date.now()}_${++commandIdCounter}`;
// 선 그리기 커맨드 생성
export function createLineCommand(
x1: number,
y1: number,
x2: number,
y2: number,
color: string = '#000000',
lineWidth: number = 2
): LineCommand {
return {
id: generateCommandId(),
type: 'line',
timestamp: Date.now(),
x1, y1, x2, y2, color, lineWidth,
execute(ctx: CanvasRenderingContext2D) {
ctx.save();
ctx.strokeStyle = this.color;
ctx.lineWidth = this.lineWidth;
ctx.lineCap = 'round'; // 선 끝을 둥글게
ctx.beginPath();
ctx.moveTo(this.x1, this.y1);
ctx.lineTo(this.x2, this.y2);
ctx.stroke();
ctx.restore();
}
};
}
// 사각형 그리기 커맨드 생성
export function createRectCommand(
x1: number,
y1: number,
x2: number,
y2: number,
color: string = '#000000',
lineWidth: number = 2,
filled: boolean = false
): RectCommand {
return {
id: generateCommandId(),
type: 'rect',
timestamp: Date.now(),
x1, y1, x2, y2, color, lineWidth, filled,
execute(ctx: CanvasRenderingContext2D) {
ctx.save();
ctx.strokeStyle = this.color;
ctx.fillStyle = this.color;
ctx.lineWidth = this.lineWidth;
const width = this.x2 - this.x1;
const height = this.y2 - this.y1;
ctx.beginPath();
ctx.rect(this.x1, this.y1, width, height);
if (this.filled) {
ctx.fill();
} else {
ctx.stroke();
}
ctx.restore();
}
};
}
/**
* 커맨드 매니저 클래스 - 히스토리 관리의 핵심
* 장점:
* - 명확한 인터페이스와 책임 분리
* - 브랜치 처리 (중간에서 새 커맨드 실행 시 이후 히스토리 삭제)
* - 메모리 관리 (최대 히스토리 제한)
* - 테스트 용이성
*/
export class CommandManager {
private static readonly MAX_HISTORY = 100;
/**
* 새 커맨드 추가 및 브랜치 관리
* 중간 위치에서 새 커맨드 실행 시 이후 히스토리는 삭제됨
*/
static addCommand(
commands: Command[],
currentPosition: number,
newCommand: Command
): { commands: Command[], position: number } {
// 현재 위치 이후의 커맨드들 제거 (브랜치 처리)
const newCommands = commands.slice(0, currentPosition + 1);
newCommands.push(newCommand);
// 최대 히스토리 제한
if (newCommands.length > this.MAX_HISTORY) {
newCommands.shift(); // 가장 오래된 커맨드 제거
return {
commands: newCommands,
position: newCommands.length - 1
};
}
return {
commands: newCommands,
position: newCommands.length - 1
};
}
/**
* Undo 실행 - 포인터만 이동
*/
static undo(currentPosition: number): number {
return Math.max(-1, currentPosition - 1);
}
/**
* Redo 실행 - 포인터만 이동
*/
static redo(currentPosition: number, maxPosition: number): number {
return Math.min(maxPosition, currentPosition + 1);
}
/**
* 캔버스 전체 다시 그리기
* 성능 최적화: 필요한 커맨드만 실행
*/
static redrawCanvas(
ctx: CanvasRenderingContext2D,
commands: Command[],
currentPosition: number
): void {
// 캔버스 클리어
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
// 현재 위치까지의 커맨드만 실행
for (let i = 0; i <= currentPosition; i++) {
const command = commands[i];
if (command) {
try {
command.execute(ctx);
} catch (error) {
console.error(`커맨드 실행 실패 [${command.id}]:`, error);
}
}
}
}
}3. pages/index.tsx
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import {
Tool,
currentToolAtom,
commandHistoryAtom,
currentPositionAtom,
drawingSettingsAtom,
canUndoAtom,
canRedoAtom,
visibleCommandsAtom
} from '../state/atoms';
import {
createLineCommand,
createRectCommand,
CommandManager
} from '../state/commands';
// 도구 설정
const TOOLS: Array<{ name: string; value: Tool; icon: string }> = [
{ name: '선', value: 'line', icon: '📏' },
{ name: '사각형', value: 'rect', icon: '⬜' }
];
// 색상 팔레트
const COLORS = ['#000000', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF'];
export default function ImprovedDrawingApp() {
// Atoms
const [currentTool, setCurrentTool] = useAtom(currentToolAtom);
const [commandHistory, setCommandHistory] = useAtom(commandHistoryAtom);
const [currentPosition, setCurrentPosition] = useAtom(currentPositionAtom);
const [settings, setSettings] = useAtom(drawingSettingsAtom);
// 파생 상태들
const canUndo = useAtomValue(canUndoAtom);
const canRedo = useAtomValue(canRedoAtom);
const visibleCommands = useAtomValue(visibleCommandsAtom);
// 로컬 상태
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 });
/**
* 마우스 좌표를 캔버스 좌표로 변환
*/
const getCanvasCoordinates = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
}, []);
/**
* 그리기 시작
*/
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
const coords = getCanvasCoordinates(e);
setIsDrawing(true);
setStartPos(coords);
setPreviewPos(coords);
}, [getCanvasCoordinates]);
/**
* 그리기 중 (프리뷰)
*/
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const coords = getCanvasCoordinates(e);
setPreviewPos(coords);
}, [isDrawing, getCanvasCoordinates]);
/**
* 그리기 완료
*/
const handleMouseUp = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const coords = getCanvasCoordinates(e);
setIsDrawing(false);
// 최소 크기 검증 (너무 작은 도형 방지)
const minSize = 3;
if (Math.abs(coords.x - startPos.x) < minSize && Math.abs(coords.y - startPos.y) < minSize) {
return;
}
// 커맨드 생성
let command;
if (currentTool === 'line') {
command = createLineCommand(
startPos.x, startPos.y, coords.x, coords.y,
settings.color, settings.lineWidth
);
} else if (currentTool === 'rect') {
command = createRectCommand(
startPos.x, startPos.y, coords.x, coords.y,
settings.color, settings.lineWidth, settings.filled
);
}
if (command) {
const result = CommandManager.addCommand(commandHistory, currentPosition, command);
setCommandHistory(result.commands);
setCurrentPosition(result.position);
}
}, [isDrawing, getCanvasCoordinates, startPos, currentTool, settings, commandHistory, currentPosition, setCommandHistory, setCurrentPosition]);
/**
* Undo 실행
*/
const handleUndo = useCallback(() => {
if (canUndo) {
setCurrentPosition(CommandManager.undo(currentPosition));
}
}, [canUndo, currentPosition, setCurrentPosition]);
/**
* Redo 실행
*/
const handleRedo = useCallback(() => {
if (canRedo) {
setCurrentPosition(CommandManager.redo(currentPosition, commandHistory.length - 1));
}
}, [canRedo, currentPosition, commandHistory.length, setCurrentPosition]);
/**
* 전체 지우기
*/
const handleClear = useCallback(() => {
setCommandHistory([]);
setCurrentPosition(-1);
}, [setCommandHistory, setCurrentPosition]);
/**
* 키보드 단축키 처리
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case 'z':
e.preventDefault();
if (e.shiftKey) {
handleRedo();
} else {
handleUndo();
}
break;
case 'y':
e.preventDefault();
handleRedo();
break;
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleUndo, handleRedo]);
/**
* 캔버스 다시 그리기 (커맨드 히스토리 변경 시)
*/
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
CommandManager.redrawCanvas(ctx, visibleCommands, visibleCommands.length - 1);
}, [visibleCommands]);
/**
* 실시간 프리뷰 그리기
*/
useEffect(() => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 전체 다시 그리기 + 프리뷰
CommandManager.redrawCanvas(ctx, visibleCommands, visibleCommands.length - 1);
// 프리뷰 그리기 (반투명)
ctx.save();
ctx.globalAlpha = 0.5;
ctx.strokeStyle = settings.color;
ctx.lineWidth = settings.lineWidth;
ctx.lineCap = 'round';
if (currentTool === 'line') {
ctx.beginPath();
ctx.moveTo(startPos.x, startPos.y);
ctx.lineTo(previewPos.x, previewPos.y);
ctx.stroke();
} else if (currentTool === 'rect') {
const width = previewPos.x - startPos.x;
const height = previewPos.y - startPos.y;
ctx.beginPath();
ctx.rect(startPos.x, startPos.y, width, height);
if (settings.filled) {
ctx.fillStyle = settings.color;
ctx.fill();
} else {
ctx.stroke();
}
}
ctx.restore();
}, [isDrawing, startPos, previewPos, currentTool, settings, visibleCommands]);
return (
<div className="flex flex-col items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white rounded-lg shadow-lg p-6 w-full max-w-4xl">
{/* 제목 */}
<h1 className="text-2xl font-bold text-center mb-6 text-gray-800">
🎨 개선된 그림판 (커맨드 패턴)
</h1>
{/* 도구 선택 */}
<div className="flex flex-wrap gap-4 mb-4 p-4 bg-gray-100 rounded-lg">
<div className="flex gap-2">
<span className="font-medium text-gray-700 self-center">도구:</span>
{TOOLS.map((tool) => (
<button
key={tool.value}
className={`px-4 py-2 rounded-lg font-medium transition-all ${
currentTool === tool.value
? 'bg-blue-500 text-white shadow-md'
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'
}`}
onClick={() => setCurrentTool(tool.value)}
>
{tool.icon} {tool.name}
</button>
))}
</div>
{/* 색상 선택 */}
<div className="flex gap-2 items-center">
<span className="font-medium text-gray-700">색상:</span>
{COLORS.map((color) => (
<button
key={color}
className={`w-8 h-8 rounded-full border-2 transition-all ${
settings.color === color ? 'border-gray-800 scale-110' : 'border-gray-300'
}`}
style={{ backgroundColor: color }}
onClick={() => setSettings(prev => ({ ...prev, color }))}
/>
))}
</div>
{/* 선 굵기 */}
<div className="flex gap-2 items-center">
<span className="font-medium text-gray-700">굵기:</span>
<input
type="range"
min="1"
max="10"
value={settings.lineWidth}
className="w-20"
onChange={(e) => setSettings(prev => ({ ...prev, lineWidth: Number(e.target.value) }))}
/>
<span className="text-sm text-gray-600 w-6">{settings.lineWidth}</span>
</div>
{/* 채우기 옵션 (사각형만) */}
{currentTool === 'rect' && (
<div className="flex gap-2 items-center">
<label className="flex items-center gap-2 font-medium text-gray-700">
<input
type="checkbox"
checked={settings.filled}
onChange={(e) => setSettings(prev => ({ ...prev, filled: e.target.checked }))}
className="rounded"
/>
채우기
</label>
</div>
)}
</div>
{/* 액션 버튼들 */}
<div className="flex gap-2 mb-4">
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${
canUndo
? 'bg-green-500 text-white hover:bg-green-600'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
onClick={handleUndo}
disabled={!canUndo}
title="Ctrl+Z"
>
↶ Undo
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${
canRedo
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
onClick={handleRedo}
disabled={!canRedo}
title="Ctrl+Y"
>
↷ Redo
</button>
<button
className="px-4 py-2 rounded-lg font-medium bg-red-500 text-white hover:bg-red-600 transition-all"
onClick={handleClear}
>
🗑️ 전체 지우기
</button>
</div>
{/* 상태 표시 */}
<div className="mb-4 text-sm text-gray-600">
커맨드: {currentPosition + 1} / {commandHistory.length}
{isDrawing && " (그리는 중...)"}
</div>
{/* 캔버스 */}
<canvas
ref={canvasRef}
width={800}
height={500}
className="border-2 border-gray-300 rounded-lg bg-white cursor-crosshair shadow-inner"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => setIsDrawing(false)}
/>
{/* 사용법 안내 */}
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<h3 className="font-medium text-blue-800 mb-2">💡 사용법</h3>
<ul className="text-sm text-blue-700 space-y-1">
<li>• 마우스로 드래그하여 도형을 그리세요</li>
<li>• <kbd className="bg-blue-100 px-1 rounded">Ctrl+Z</kbd>: Undo, <kbd className="bg-blue-100 px-1 rounded">Ctrl+Y</kbd>: Redo</li>
<li>• 실시간 프리뷰로 그리기 전 미리 확인 가능</li>
<li>• 중간에서 새로 그리면 이후 히스토리는 자동 삭제</li>
</ul>
</div>
{/* 기술적 특징 */}
<div className="mt-4 p-3 bg-green-50 rounded-lg">
<h3 className="font-medium text-green-800 mb-2">🔧 기술적 특징</h3>
<ul className="text-sm text-green-700 space-y-1">
<li>• <strong>커맨드 패턴</strong>: 각 그리기 작업을 독립적인 커맨드 객체로 관리</li>
<li>• <strong>Jotai 상태관리</strong>: 파생 atom으로 효율적인 리렌더링</li>
<li>• <strong>타입스크립트</strong>: 컴파일타임 타입 안정성 보장</li>
<li>• <strong>메모리 최적화</strong>: 최대 100개 커맨드 히스토리 관리</li>
<li>• <strong>실시간 프리뷰</strong>: Canvas 2D API 활용한 부드러운 UX</li>
</ul>
</div>
{/* 디버그 정보 (개발 환경에서만) */}
{typeof window !== 'undefined' && window.location.hostname === 'localhost' && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<h3 className="font-medium text-gray-800 mb-2">🐛 디버그 정보</h3>
<div className="text-sm text-gray-600 space-y-1">
<div>전체 커맨드: {commandHistory.length}개</div>
<div>현재 위치: {currentPosition + 1}</div>
<div>표시 중인 커맨드: {visibleCommands.length}개</div>
<div>Undo 가능: {canUndo ? 'Yes' : 'No'}</div>
<div>Redo 가능: {canRedo ? 'Yes' : 'No'}</div>
<div>현재 도구: {currentTool}</div>
<div>현재 색상: {settings.color}</div>
<div>선 굵기: {settings.lineWidth}px</div>
<div>채우기: {settings.filled ? 'On' : 'Off'}</div>
</div>
</div>
)}
{/* 커맨드 패턴 설명 */}
<div className="mt-4 p-3 bg-yellow-50 rounded-lg">
<h3 className="font-medium text-yellow-800 mb-2">📚 커맨드 패턴의 장점</h3>
<ul className="text-sm text-yellow-700 space-y-1">
<li>• <strong>확장성</strong>: 새로운 도형 추가 시 기존 코드 수정 불필요</li>
<li>• <strong>테스트 용이성</strong>: 각 커맨드를 독립적으로 테스트 가능</li>
<li>• <strong>실행 취소/재실행</strong>: 자연스러운 히스토리 관리</li>
<li>• <strong>매크로 기능</strong>: 여러 커맨드 조합으로 복합 작업 가능</li>
<li>• <strong>로깅/감사</strong>: 모든 작업을 추적하고 기록 가능</li>
</ul>
</div>
</div>
</div>
);
}
주요 개선사항
1. 타입 안정성 강화
인터페이스 정의:
Command,LineCommand,RectCommand로 명확한 타입 체계제네릭 활용: 타입스크립트의 장점을 최대한 활용
런타임 에러 방지: 컴파일 타임에 오류 검출
2. 커맨드 패턴 고도화
//개선된 커맨드 생성
const command = createLineCommand(x1, y1, x2, y2, color, lineWidth);
//커맨드 매니저로 히스토리 관리
const result = CommandManager.addCommand(history, position, command);3. 실시간 프리뷰 기능
그리기 중 반투명 프리뷰 표시
사용자 경험 대폭 향상
실제 그리기 전 미리 확인 가능
4. 고급 기능 추가
키보드 단축키:
Ctrl+Z,Ctrl+Y,Ctrl+Shift+Z색상 팔레트: 다양한 색상 선택
선 굵기 조절: 1~10px 범위
채우기 옵션: 사각형 내부 채우기
최소 크기 검증: 너무 작은 도형 방지
5. 메모리 및 성능 최적화
// 최대 히스토리 제한 (100개)
private static readonly MAX_HISTORY = 100;
// 필요한 부분만 리렌더링
const visibleCommandsAtom = atom((get) => {
const commands = get(commandHistoryAtom);
const position = get(currentPositionAtom);
return commands.slice(0, position + 1);
});6. 브랜치 관리
중간에서 새 커맨드 실행 시 이후 히스토리 자동 삭제:
// 포토샵, VS Code와 동일한 동작
const newCommands = commands.slice(0, currentPosition + 1);
newCommands.push(newCommand);7. 에러 처리 강화
커맨드 실행 실패 시 graceful handling
콘솔 로그로 디버깅 정보 제공
캔버스 좌표 변환 안전성 보장
8. 확장성 고려
// 새로운 도형 추가가 매우 쉬움
export interface CircleCommand extends Command {
type: 'circle';
centerX: number;
centerY: number;
radius: number;
}
// 새 도구 추가 시 기존 코드 수정 불필요커맨드 패턴의 장점이 잘 드러나는 부분
단일 책임 원칙: 각 커맨드가 하나의 그리기 작업만 담당
개방-폐쇄 원칙: 새 도형 추가 시 기존 코드 수정 없음
테스트 용이성: 각 커맨드를 독립적으로 테스트 가능
실행 취소/재실행: 자연스러운 히스토리 관리
매크로 기능: 여러 커맨드를 조합하여 복합 작업 가능