Canvas Editor
Temply의 POP와 디자인 캔버스에 누적된 상태 변경과 렌더링 경로를 하나의 편집 엔진으로 재설계했습니다. 문서·작업·상호작용 상태를 분리하고 입력, 저장, 이미지·인쇄·PSD 출력까지 단방향 파이프라인으로 연결한 대규모 리팩터링입니다.
Architecture · Frontend · Editor · Migration / 2026.02 — 2026.05
편집기를 새로 만드는 일보다 어려운 것은 이미 저장된 문서와 사용자의 작업 방식을 유지하면서 내부 구조를 교체하는 일이었습니다. 화면을 한 번에 갈아엎지 않고 상태의 소유권, 입력 경로, 렌더링과 출력의 경계를 차례로 옮긴 뒤 기존 런타임을 제거했습니다.
Overview
Temply의 Canvas Editor는 오프라인 마트가 상품 POP와 행사 홍보 이미지를 직접 만드는 웹 기반 편집기입니다. POP에서는 상품명·가격·할인·이미지를 템플릿에 배치해 매장 인쇄물을 만들고, 디자인 캔버스에서는 텍스트·이미지·로고·스티커를 조합해 프로모션 이미지를 제작합니다. 완성한 문서는 이미지와 인쇄물로 사용하거나 PSD로 내려받아 후속 디자인 작업을 이어갈 수 있습니다.
기능이 축적되면서 기존 이미지 에디터에는 문서 데이터, 화면 상태, 드래그 중인 값과 도메인별 조건이 여러 컴포넌트에 섞였습니다. 같은 사용자 동작도 버튼, 단축키와 컨텍스트 메뉴가 서로 다른 변경 경로를 사용했고, 화면에 보이는 결과와 저장·출력 결과의 의미를 함께 맞추기 어려워졌습니다.
2026년 2월부터 5월까지 POP와 디자인 캔버스가 공유할 신규 편집 엔진을 설계하고 실제 서비스 경로를 전환했습니다. 기존 문서와 사용자 템플릿을 읽는 호환 계층을 유지하면서 canonical state, 명령 파이프라인, 파생 렌더링과 출력 구조를 도입했고, 전환이 끝난 뒤 기존 이미지 에디터 route와 runtime을 제거했습니다.
담당 범위
- 기존 편집기의 상태 변경·렌더링·저장·출력 경로 분석과 전환 순서 설계
document / workspace / interaction으로 분리한 canonical editor state- UI event를 action·reducer·effect로 연결하는 단방향 입력 파이프라인
- 페이지·섹션·요소와 viewport geometry를 계산하는 파생 렌더링 구조
- POP와 디자인 캔버스의 기능 차이를 선언하는 capability 정책
- 저장 포맷 버전, 레거시 문서와 내 템플릿 migration
- 이미지 다운로드, 인쇄와 PSD 내보내기 파이프라인
- reducer·selector·migration·출력 경계를 검증하는 계약 테스트
Problem
기존 편집기는 POP 제작 기능에서 시작해 텍스트, 이미지, 상품 가격표, 템플릿, 페이지 분할과 출력 기능이 계속 추가된 구조였습니다. 여기에 자유도가 높은 디자인 캔버스까지 같은 기반을 사용하면서 하나의 상태가 여러 의미를 갖기 시작했습니다.
- 저장해야 할 문서와 선택·줌·클립보드 같은 작업 상태가 같은 흐름에서 변경됐습니다.
- 드래그·리사이즈의 미리보기와 최종 문서 반영이 서로 다른 계산을 사용할 수 있었습니다.
- POP와 디자인 캔버스의 진입 컴포넌트와 조건 분기가 중복됐습니다.
- 버튼, 단축키, 컨텍스트 메뉴가 동일한 편집 동작을 각자 구현했습니다.
- 템플릿 원본, 사용자의 수정값과 실제 화면 결과 사이의 책임이 불분명했습니다.
- 기존 문서와 내 템플릿의 저장 구조를 유지해야 해 신규 모델로 즉시 교체할 수 없었습니다.
- 이미지, 인쇄와 PSD 출력이 화면과 별도의 변환 경로를 가져 결과가 달라질 수 있었습니다.
편집기는 CRUD 화면과 달리 사용자의 입력이 연속적으로 이어집니다. 포인터 이동 중의 임시 좌표, 선택된 요소, undo 이력과 저장할 문서를 구분하지 않으면 작은 기능 하나가 편집 전반의 회귀로 이어집니다. 동시에 운영 중인 제품이므로 기존 문서를 열지 못하게 하거나 제작 흐름을 장기간 중단하는 방식도 선택할 수 없었습니다.
따라서 목표는 코드를 정리하는 데 있지 않았습니다. 현재 기능과 저장 데이터를 유지하면서 모든 편집 동작이 하나의 상태와 입력 규칙을 사용하도록 전환하고, 화면·저장·출력 결과의 의미를 일치시키는 것이 핵심이었습니다.
Process
교체 범위보다 유지해야 할 동작을 먼저 정의했습니다
리팩터링을 시작하며 POP와 디자인 캔버스의 공통 기능과 도메인별 기능을 분류했습니다. 공통 범위에는 선택, 이동, 크기 조절, 텍스트·이미지 편집, 페이지, undo/redo, 저장과 이미지 출력이 포함됐습니다. 상품 테이블, 가격 표시, 배너·레이아웃 템플릿, PSD 출력은 POP의 capability로, 디자인 템플릿·로고·제작 이력은 디자인 캔버스의 capability로 정리했습니다.
한 번에 기존 구현을 삭제하는 대신 신규 host와 action pipeline을 기존 화면 옆에 두고 동작을 순서대로 옮겼습니다. selection과 shortcut, page·section projection, 공통 slot action을 단계별로 전환한 뒤 실제 /canvas/* 경로를 신규 엔진에 연결했습니다. 저장·템플릿·출력까지 호환성을 확인한 후에만 기존 image editor route와 runtime을 제거했습니다.
편집 상태의 소유권을 세 영역으로 나눴습니다
CanvasEditorState를 편집기의 단일 기준으로 두고 상태를 document, workspace, interaction으로 분리했습니다.
Document에는 페이지 크기와 분할, 섹션 흐름, 요소, 상품 테이블, 가격 표시처럼 저장 후 다시 열어야 하는 값만 둡니다. Workspace에는 selection, clipboard, zoom, 활성 도구처럼 현재 편집 세션에 필요한 상태를 둡니다. Interaction에는 drag·resize 과정의 preview patch처럼 아직 문서에 커밋되지 않은 값을 둡니다.
hover, marquee와 포인터의 client 좌표처럼 DOM에 강하게 묶인 값은 viewport local state로 남겼습니다. 모든 순간 값을 전역 상태로 올리는 대신, 저장 가능한 상태와 짧게 존재하는 화면 상태의 경계를 명확히 한 것입니다. undo/redo도 전체 상태를 매번 복제하는 방식에서 문서 변경 patch를 저장하는 방식으로 전환했습니다.
모든 입력을 하나의 명령 파이프라인으로 모았습니다
기존에는 UI 컴포넌트가 문서를 직접 바꾸거나 동일한 동작을 서로 다른 방식으로 구현할 수 있었습니다. 신규 구조에서는 사용자의 입력이 다음 경로를 거치게 했습니다.
UI event
→ planner / action bus
→ action preparation
→ document 또는 editor reducer
→ effect
→ derived selectors
→ viewport render
header, 좌측 패널, 단축키, 컨텍스트 메뉴와 viewport가 모두 같은 action contract를 사용합니다. 템플릿 조회나 현재 페이지 해석처럼 외부 정보가 필요한 동작은 preparation 단계에서 실행 가능한 payload로 만든 뒤 reducer에 전달합니다. reducer는 문서 또는 작업 상태만 변경하고, 저장·다운로드·인쇄와 알림처럼 문서 변경 밖의 일은 effect가 처리합니다.
이 구조를 통해 같은 ‘삭제’, ‘복제’, ‘앞으로 가져오기’ 동작이 어떤 UI에서 시작됐는지와 관계없이 같은 guard와 상태 전이를 사용하게 했습니다. capability 검사와 실패 기록도 action bus 경계에 두어 허용되지 않은 동작과 실행 오류를 한곳에서 추적할 수 있게 했습니다.
저장 상태와 화면 결과 사이에 파생 계층을 뒀습니다
화면에서 필요한 좌표와 최종 스타일을 문서에 모두 저장하면 페이지 크기나 템플릿을 바꿀 때 중복 데이터가 서로 어긋날 수 있습니다. 신규 엔진은 canonical document를 다음 selector chain으로 변환합니다.
section placement
→ resolved document
→ render document
→ viewport geometry
section placement는 페이지와 분할 구조에 섹션을 배치합니다. resolved document는 템플릿의 base layout과 사용자의 override를 합성합니다. render document는 화면에 그릴 요소와 순서를 만들고, viewport geometry는 zoom과 화면 위치를 반영해 hit-test와 overlay 좌표를 계산합니다.
페이지가 어떤 요소를 소유하는지와 어떤 순서로 그릴지도 분리했습니다. 페이지 이동·복제와 레이어 앞뒤 이동이 같은 배열을 서로 다른 의미로 수정하지 않게 하고, 화면 렌더와 출력이 동일한 paint order를 사용하도록 했습니다.
하나의 엔진에서 도메인 차이를 선언적으로 관리했습니다
POP와 디자인 캔버스를 완전히 별도 구현으로 나누면 공통 편집 기능의 수정이 두 코드베이스에 반복됩니다. 반대로 모든 조건을 컴포넌트 내부의 if로 처리하면 도메인이 늘수록 화면 전체에 분기가 퍼집니다.
그래서 domainPolicy에 기능 capability와 shell profile을 정의했습니다. header action, navigation, shortcut, 좌측 메뉴와 export 항목은 현재 도메인의 capability를 보고 조립됩니다. POP에서는 상품 표와 가격 설정, 레이아웃·배너·홍보 템플릿, PSD가 열리고, 디자인 캔버스에서는 디자인 템플릿, 제작 이력과 로고 기능이 열립니다.
도메인 차이를 엔진의 상태 변경 규칙과 UI 구성 정책으로 나누면서 공통 편집기는 같은 상태·입력·렌더링 계약을 유지할 수 있었습니다. 이후 지류전단 초안을 신규 capability로 추가해, 기존 POP 분기를 복제하지 않고도 다른 페이지·출력 정책을 수용할 수 있는지 확인했습니다.
기존 문서와 내 템플릿을 버리지 않고 이동시켰습니다
새 구조가 정확하더라도 사용자가 저장한 POP와 내 템플릿을 열지 못하면 전환할 수 없습니다. 저장 envelope에 날짜 기반 버전을 두고 route adapter와 persistence normalization이 각 버전의 데이터를 canonical document로 변환하게 했습니다.
신규 내 템플릿은 선택 페이지의 document slice를 저장해 일반 문서와 같은 resolve pipeline을 사용합니다. 기존 내 템플릿은 이미 계산된 resolved snapshot 구조이므로 읽기 호환을 유지하고, 적용 후 다시 저장할 때 신규 구조로 전환했습니다. 레거시 예외는 validation, migration과 binding 생성 경계에 두어 일반 reducer에 과거 포맷의 조건이 퍼지지 않도록 했습니다.
라우팅 전환 뒤에도 문서·템플릿 migration과 저장 결과를 보정했고, 실제 신규 엔진이 모든 진입을 담당하게 된 시점에 기존 route와 runtime을 제거했습니다. 일정 기간 두 구조를 함께 유지한 것은 중복을 남기기 위한 것이 아니라, 데이터 호환을 검증하며 교체 지점을 통제하기 위한 전환 장치였습니다.
편집 결과물이 실제 출력까지 같은 의미를 갖게 했습니다
POP는 화면에서 보기 좋게 편집되는 것만으로 완성되지 않습니다. 매장에서 인쇄하거나 이미지로 사용하고, 디자인팀이 PSD로 다시 작업할 수 있어야 합니다. 신규 엔진의 canonical document와 paint order를 기준으로 세 출력 경로를 다시 연결했습니다.
- 이미지 다운로드는 전체 문서의 페이지를 안전하게 캡처하고 DPI에 맞는 래스터 크기를 계산합니다.
- 인쇄는 사용할 이미지를 먼저 준비하고 페이지·분할 구조에서 실제 출력 단위를 만듭니다.
- PSD 내보내기는 문서의 텍스트, 이미지와 레이어 순서를 PSD 구조로 변환합니다.
브라우저 canvas가 처리할 수 있는 최대 변과 면적도 출력 전에 계산해, 실패할 크기는 작업을 시작하기 전에 조정 가능한 용지 크기로 안내하게 했습니다. 출력 실패를 막연한 재시도 메시지로 끝내지 않고, 어떤 설정을 바꿔야 하는지 설명하도록 한 것입니다.
화면 구현보다 계약 경계를 테스트했습니다
편집기의 모든 DOM 조합을 재현하는 테스트보다, 상태 전이와 경계의 의미를 고정하는 테스트에 집중했습니다. document reducer, UI planner, action routing, page·section projection, hit-test, 저장 normalization, 레거시 migration과 이미지·PSD 변환을 독립적으로 검증했습니다.
특히 같은 action이 reducer와 runtime에서 다른 결과를 내지 않는지, preview 상태가 history와 저장 데이터에 섞이지 않는지, 기존 포맷을 열고 다시 저장했을 때 현재 문서 구조가 보존되는지를 회귀 조건으로 두었습니다. 구조를 옮기는 동안 화면의 세부 구현이 바뀌어도 핵심 계약을 비교할 수 있도록 한 선택입니다.
새 구조를 동료가 탐색할 수 있는 문서로 남겼습니다
리팩터링을 마친 뒤 신입 개발자와 기존 동료가 구현 세부사항을 모두 따라가지 않아도 큰 흐름을 이해할 수 있도록 사람용 온보딩 문서를 작성했습니다. 문서에는 편집기가 해결하는 문제부터 document / workspace / interaction 상태 모델, 다섯 가지 입력 pipeline과 화면 계산 순서, viewport·overlay의 책임을 실제 타입과 다이어그램으로 설명했습니다.
문서를 함께 보며 화면 문제가 발생했을 때 document를 바로 수정하기보다 어느 계산 단계에서 의미가 달라졌는지 추적하는 방법과, 신규 기능을 document·session·preview·viewport·effect 중 어디에 연결해야 하는지를 동료들에게 설명했습니다. 구현자만 알고 있는 구조가 아니라 팀이 같은 기준으로 기능을 추가하고 오류를 찾을 수 있는 구조로 만드는 것이 목적이었습니다. 작성한 Canvas Onboarding 문서는 포트폴리오에서도 확인할 수 있습니다.
Solution
공통 Canvas Engine
Next.js와 TypeScript 기반으로 state, engine, controller, viewport와 UI shell의 책임을 분리했습니다. Jotai는 앱 전역 연결과 일부 UI 상태에 사용하고, 편집기의 canonical document는 명시적인 action과 reducer를 통해서만 변경합니다. POP와 디자인 캔버스는 같은 controller와 viewport를 사용하며 도메인 policy가 기능 구성을 결정합니다.
버전이 있는 문서·템플릿 저장
route adapter가 API 데이터를 현재 editor state로 변환하고, 저장 시에는 document + workspace로 구성된 versioned envelope을 만듭니다. persistence normalization은 유효하지 않은 selection과 clipboard를 제거하고 문서 기준의 배너·가격·상품 데이터를 정규화합니다. 기존 문서와 내 템플릿은 migration을 거쳐 같은 편집 파이프라인에 진입합니다.
화면과 연결된 출력 파이프라인
이미지, 인쇄와 PSD가 별도의 편집 상태를 만들지 않고 canonical document와 파생된 layout을 사용하게 했습니다. 캡처 전에 이미지 준비 상태와 출력 가능 크기를 확인하고, 출력 과정은 progress와 오류 수집을 통해 사용자와 운영자가 문제 원인을 확인할 수 있도록 구성했습니다.
Results
- POP와 디자인 캔버스의 실제 서비스 route를 공통 Canvas Editor로 전환했습니다.
- 저장되는 문서, 편집 세션과 커밋 전 상호작용의 소유권을 분리하고 모든 문서 변경을 공통 action pipeline에 연결했습니다.
- 페이지·섹션·요소의 최종 화면을 selector chain에서 계산해 템플릿 원본과 사용자 override, paint order를 같은 기준으로 합성했습니다.
- 기존 문서와 내 템플릿을 읽는 migration을 유지한 상태에서 신규 저장 구조로 전환하고, 이후 기존 image editor route와 runtime을 제거했습니다.
- POP와 디자인 캔버스의 기능 차이를 capability로 관리해 공통 편집 동작과 도메인 전용 기능의 경계를 명시했습니다.
- 이미지 다운로드, 인쇄와 PSD 내보내기를 신규 문서 구조에 연결해 편집 화면과 실제 결과물이 같은 레이어·좌표·스타일 의미를 사용하도록 했습니다.
- 신규 엔진 위에 지류전단 도메인 초안을 추가해 페이지 정책이 다른 제작물도 같은 상태·명령·렌더링 기반에서 확장할 수 있음을 확인했습니다.
- 상태 모델, 입력 파이프라인과 화면 계산 과정을 사람용 온보딩 문서와 다이어그램으로 정리하고 동료에게 공유해, 신규 기능과 오류를 같은 아키텍처 기준으로 탐색할 수 있게 했습니다.
Learnings
대규모 리팩터링에서는 최종 구조만큼 전환 순서가 중요했습니다
새 모델을 먼저 완성한 뒤 한 번에 교체하는 방식은 기존 데이터와 기능의 차이를 너무 늦게 발견하게 합니다. 입력, projection, 저장과 출력 경계를 단계적으로 옮기고 실제 route를 전환한 뒤 레거시를 제거해야 각 단계의 회귀 범위를 통제할 수 있었습니다.
편집기의 복잡도는 화면보다 상태의 소유권에서 생겼습니다
드래그 중인 좌표, 선택 상태와 저장 문서가 모두 같은 수준의 상태로 취급되면 미리보기와 커밋, undo와 저장이 서로 영향을 줍니다. 값이 얼마나 오래 존재하고 누가 수정할 수 있는지 먼저 정의하면서 렌더링과 입력 구조도 단순해졌습니다.
호환 코드는 제거 시점까지 설계해야 했습니다
레거시 adapter를 추가하는 것만으로는 migration이 끝나지 않습니다. 예외를 좁은 경계에 격리하고, 신규 경로가 실제 사용을 담당하며, 저장본이 현재 포맷으로 전환되는 조건을 정해야 안전하게 삭제할 수 있었습니다. 기존 runtime 제거를 완료 조건으로 둔 이유입니다.
다음 단계에서는 긴 문서와 대형 이미지에서 selector·캡처 비용을 측정하고, action telemetry와 출력 오류를 제품 운영 지표로 연결하려 합니다. 기능 회귀를 막는 계약 테스트에 더해 실제 편집 세션에서 병목과 실패가 발생하는 지점을 지속적으로 확인하기 위해서입니다.