맵 에디터에 undo/redo 붙이기 — 스냅샷 스택, 드래그 합치기, 그리고 이모지가 깨뜨린 접근성 테스트
가상 오피스에는 관리자가 게임 안에서 바로 가구·포털·조명을 배치하는 인게임 맵 에디터가 있다. 배치와 저장은 되는데, 정작 편집을 편하게 하는 장치가 없었다. 잘못 옮기면 되돌릴 방법이 없고, 정렬 기준선도 없고, 팔레트는 table_round 같은 텍스트 버튼뿐이었다.
그래서 세 가지를 붙였다 — undo/redo, 32px 참조 그리드 토글, 이모지 팔레트 썸네일. 전부 에디터 전용 UX라 데이터 모델도 렌더 파이프라인도 건드리지 않았다. 순수하게 스토어와 HUD, 그리고 씬의 오버레이만 손대는 작업. 그런데 이 “작은” 슬라이스에서 배운 게 제법 있었다.
undo/redo는 결국 스냅샷 두 스택
에디터 상태는 zustand 스토어 하나가 진실원이다. 오브젝트 목록을 항상 불변으로 다루기 때문에(이동·수정·삭제가 모두 새 배열/새 객체를 만든다), undo/redo는 “변경 직전의 목록을 통째로 스냅샷 떠서 쌓아두면” 끝난다. 과거(past)와 미래(future) 두 스택이면 된다.
const HISTORY_LIMIT = 50
function snapshot(objs) {
// 오브젝트별 얕은 복사 + props까지 한 겹 더 — 불변 갱신이라 이 정도면 격리된다
return objs.map(o => ({ ...o, props: o.props ? { ...o.props } : o.props }))
}
function pushPast(past, objs) {
const next = [...past, snapshot(objs)]
if (next.length > HISTORY_LIMIT) next.shift() // 상한 50, 오래된 것부터 버림
return next
}
변경 액션은 상태를 바꾸기 전에 현재 목록을 past에 밀어넣고 future를 비운다. undo는 현재를 future로 넘기고 past에서 하나 꺼내 되돌린다. redo는 그 반대. 순수 함수적 상태 전이라 브라우저 없이 단위테스트로 정확성을 전부 잡을 수 있다는 게 이 구조의 진짜 장점이었다.
문제: 드래그 한 번이 undo 수십 단계가 된다
여기서 함정이 있다. 오브젝트를 마우스로 드래그하면 moveSelected가 매 프레임 호출된다. 소박하게 매 호출마다 스냅샷을 쌓으면, 한 번의 드래그가 undo 수십 단계로 쪼개진다. 사용자는 “방금 옮긴 걸 되돌려”라고 생각하는데 Ctrl+Z를 스무 번 눌러야 하는 것이다.
해법은 연속 편집 합치기(coalesce)다. 각 변경에 태그를 붙이고 — 이동은 move:<id>, 속성 수정은 update:<id> — 새 변경의 태그가 직전과 같으면 스냅샷을 새로 쌓지 않는다. 드래그 시작 시 한 번만 쌓인 “드래그 이전” 스냅샷이 그대로 유지되므로, 드래그 전체가 undo 한 단계가 된다.
moveSelected: (x, y) => set((st) => {
if (!st.selectedId) return st
const tag = `move:${st.selectedId}`
const hist = tag === st.lastTag
? {} // 같은 드래그 — 스냅샷 안 쌓음
: { past: pushPast(st.past, st.objects), future: [], lastTag: tag }
return { objects: /* 스냅 이동한 새 목록 */, dirty: true, ...hist }
})
선택을 바꾸거나(selectObject), 추가·삭제·undo·redo를 하면 lastTag를 null로 끊어 다음 편집이 새 단계로 시작하게 한다. 추가와 삭제는 항상 개별 단계다.
이 규칙에는 놓치기 쉬운 구멍이 하나 더 있었다. 그리드 토글처럼 목록을 바꾸지 않는 액션이 lastTag를 그대로 두면, “폭 입력 → 그리드 토글 → 높이 입력”이 같은 update:<id> 태그로 묶여 undo 한 번에 둘 다 되돌아간다. 그래서 toggleGrid에도 lastTag: null을 넣어 편집 경계를 끊었다. 코드 리뷰에서 잡힌 이 엣지는 회귀 테스트 하나로 못을 박았다 — 수정을 빼면 그 테스트가 실제로 실패하는 걸 확인하고서.
이모지 하나가 접근성 테스트를 깨뜨리다
팔레트를 텍스트 버튼에서 “이모지 + 라벨” 타일로 바꾸는 건 순수 UI 작업처럼 보였다. 그런데 기존 컴포넌트 테스트가 이렇게 버튼을 찾고 있었다.
screen.getByRole('button', { name: 'sofa' })
버튼의 접근성 이름으로 정확히 매칭하는 방식이다. 이모지를 그냥 텍스트로 넣으면 접근성 이름이 "🛋 sofa"가 되어 이 매칭이 깨진다. 스크린 리더도 “소파 그림문자 sofa”처럼 군더더기를 읽는다. 해법은 장식용 이모지를 aria-hidden으로 접근성 트리에서 빼는 것.
// ❌ 접근성 이름이 "🛋 sofa" — 테스트도 깨지고 스크린 리더도 지저분
<button>🛋 sofa</button>
// ✅ 이모지는 aria-hidden — 접근성 이름은 "sofa"만 남는다
<button>
<span aria-hidden="true">🛋</span>sofa
</button>
장식은 눈에만 보이고 이름 계산에서는 빠진다. 덕분에 화면에는 아이콘+라벨이 함께 나오면서도 기존 테스트 여덟 개가 손 안 대고 그대로 통과했다. “그냥 이모지 넣기”가 접근성과 테스트를 동시에 건드리는 지점이라는 걸, 이 작은 변경이 알려줬다.
그리드는 시각 참조일 뿐, 스냅은 그대로
마지막은 32px 참조 그리드. Phaser 씬에서 editing && showGrid일 때만 맵 경계 안에 은은한 격자(흰색 alpha 0.12)를 그린다. 중요한 건 오브젝트 오버레이보다 뒤(더 낮은 depth)에 두는 것, 그리고 배치 스냅은 8px 그대로 유지하는 것. 그리드는 눈으로 정렬을 돕는 참조선일 뿐 스냅 격자가 아니다. 이 둘을 섞으면 “그리드를 켰더니 배치 간격이 달라졌다”는 혼란이 생긴다.
키보드도 붙였다 — Ctrl+Z / Ctrl+Shift+Z. 기존 F키 상호작용에서 쓰던 “입력창 포커스 중이면 가로채지 않기” 가드를 그대로 재사용해서, URL·라벨 입력 중에 실행 취소가 브라우저 기본 동작과 충돌하지 않게 했다.
남는 이야기
세 기능 중 정확성 위험이 몰린 건 undo/redo였다 — 드래그 합치기, redo 스택 소거, 되돌린 목록 기준의 선택 유효성, 상한 50의 일관 적용. 전부 순수 스토어 로직이라 단위테스트로 정면 검증했고, 리뷰에서 나온 엣지 하나(그리드 토글의 태그 끊기)까지 테스트로 닫았다. 데이터·렌더·DB를 건드리지 않는다는 경계를 처음부터 그어둔 덕에, “편의 기능”이 예상치 못한 곳으로 번지지 않았다.
가장 기억에 남는 교훈은 역시 이모지였다. UI에 그림문자 하나 얹는 일이, 접근성 이름과 기존 테스트라는 두 눈에 안 보이는 계약을 동시에 건드린다는 것. aria-hidden 한 줄이 그 둘을 모두 지켜줬다.