게시판에 그림판을 넣은 이야기: Canvas API와 기존 업로드 파이프라인의 결합


왜 만들었나
수업 중에 Q&A 게시판을 많이 씁니다. 학생이 질문하면 강사나 다른 학생이 답변을 달고, 코드 리뷰도 거기서 하고, 과제 피드백도 거기서 합니다.
그런데 프로그래밍 수업이다 보니, 말로는 도저히 안 되는 순간이 자주 옵니다.
"이 부분에서 화살표가 이렇게 가야 하는데..."
"여기서 흐름이 이쪽으로 꺾여야 해요..."
"화면에서 이 버튼이 여기 있어야 하는데..."
아키텍처 흐름이나 UI 레이아웃을 설명할 때가 특히 그렇습니다. 결국 "잠깐 그림 하나 그릴게요" 하면서 외부 그림판을 열고, 캡처하고, 저장하고, 업로드하고... 네 번의 동작을 거칩니다.
이걸 한 번으로 줄이고 싶었습니다.
게시판 에디터 안에 그림판 버튼이 있으면, 클릭 한 번으로 캔버스가 열리고, 그림 그리고 저장 누르면 마크다운에 바로 삽입. 그게 전부입니다.
어디에서 쓸 수 있나
FullStackFamily에서 글을 쓸 수 있는 곳은 꽤 많습니다.
Q&A 게시판 (수업 질문/답변) 자유게시판, 개발일지, AI 게시판 블로그 글쓰기 댓글 (게시판/블로그 모두) 과제 제출
이미지 업로드가 되는 곳이라면 전부 그림판을 쓸 수 있습니다. 이유는 간단한데, 그림판이 기존 이미지 업로드 흐름을 그대로 타기 때문입니다.
핵심 설계 결정: "새로 만들지 않고 끼워넣기"
처음부터 정한 원칙이 하나 있었습니다. 백엔드를 건드리지 않겠다.
이미 이미지 업로드 파이프라인이 잘 돌아가고 있거든요.
[기존 이미지 업로드 흐름] 파일 선택 or 드래그앤드롭 → File 객체 생성 → onImageUpload(file) 호출 → POST /api/posts/{id}/images → 서버에서 WebP 변환 + R2 저장 → URL 반환 → 마크다운에  삽입
그림판이 할 일은 딱 하나. Canvas에서 그린 걸 File 객체로 만들어서 이 흐름에 태우기.
[그림판 추가 흐름] 그림판 열기 → 캔버스에서 드로잉 → canvas.toBlob('image/png') → new File(blob, 'drawing-1709012345.png') → 여기서부터는 기존 흐름과 동일! → onImageUpload(file) 호출 → POST /api/posts/{id}/images → 서버에서 WebP 변환 + R2 저장 → URL 반환 → 마크다운에 삽입
백엔드 입장에서는 사용자가 파일 탐색기에서 PNG를 골랐는지, 캔버스에서 그려서 만들었는지 알 수 없습니다. 그냥 PNG 파일이 올라올 뿐이니까요.
서버 코드 변경 0줄. DB 변경도 없고, API 추가도 없습니다.
컴포넌트 구조
DrawingCanvas는 어디서든 붙여 쓸 수 있는 공용 컴포넌트입니다.
DrawingCanvas.tsx (shared/ui/) ├── Props: isOpen, onClose, onSave(file) ├── 풀스크린 오버레이 (fixed inset-0 z-50) │ ├── 헤더 (그림판 제목 + 닫기) │ ├── 툴바 (색상 10개 + 펜 두께 4단계 + 지우기/되돌리기) │ ├── 캔버스 영역 (1024×768, flex-1로 남은 공간 채움) │ └── 푸터 (취소 + 저장) └── 내부적으로 Canvas API + Pointer Events 사용
여기서 중요한 건 onSave 콜백입니다.
onSave: (file: File) => Promise<void>
DrawingCanvas는 File 객체만 만들어서 넘기고 끝입니다. 이 파일을 어디에 업로드하고, 결과 URL을 어디에 삽입할지는 부모가 알아서 합니다. 그래서 MarkdownEditor든 BlogWriteForm이든 같은 DrawingCanvas를 붙일 수 있는 거고요.
재미있었던 기술적 디테일
1. 왜 React 이벤트를 안 쓰고 addEventListener를 직접 쓰는가
보통 React에서는 onPointerDown, onPointerMove 같은 합성 이벤트를 쓰는데, 캔버스 드로잉에서는 addEventListener를 직접 걸었습니다.
첫 번째 이유는 { passive: false } 옵션 때문입니다. 모바일에서 터치하면 기본적으로 스크롤이 되는데, 이걸 막으려면 preventDefault()를 호출해야 합니다. React 합성 이벤트로는 passive 옵션을 제어할 수가 없거든요.
두 번째는 클로저 스테일(stale closure) 문제입니다. 색상이나 펜 두께가 바뀔 때마다 이벤트 핸들러를 재등록하면 쓸데없는 오버헤드가 생깁니다. 그래서 useRef로 최신 값을 참조하고, 이벤트 핸들러는 isOpen이 바뀔 때 딱 한 번만 등록합니다.
// state가 바뀌어도 이벤트 핸들러를 재등록하지 않는다 const colorRef = useRef('#000000') const sizeRef = useRef(5) // state → ref 동기화 useEffect(() => { colorRef.current = selectedColor }, [selectedColor]) // 이벤트 핸들러 안에서는 ref를 참조 function startDrawing(pos) { ctx.strokeStyle = colorRef.current // 항상 최신 값 ctx.lineWidth = sizeRef.current }
2. devicePixelRatio와 캔버스 해상도
레티나 디스플레이(dpr=2)에서 캔버스를 1024×768로 그리면 선이 뿌옇게 보입니다. 물리 픽셀의 절반만 쓰는 셈이니까요.
해결책은 캔버스 버퍼 해상도는 크게, CSS 표시 크기는 작게 가져가는 것입니다.
논리 해상도: 1024 × 768 (드로잉 좌표계) 물리 해상도: 2048 × 1536 (dpr=2일 때 실제 픽셀 버퍼) CSS 표시: 브라우저 창에 맞게 축소 (JS로 계산)
ctx.scale(dpr, dpr)를 한 번 걸어주면, 이후 모든 드로잉 명령이 자동으로 2배 해상도로 렌더링됩니다. 좌표 계산은 논리 해상도(1024×768) 기준으로 하면 되니까 복잡하지 않습니다.
3. CSS로 제어되지 않는 캔버스 크기
처음에는 캔버스에 max-width: 100%, max-height: 100%, aspect-ratio: 4/3을 줬습니다. 보통의 HTML 요소라면 이것만으로 부모 컨테이너를 가득 채웠을 겁니다.
그런데 캔버스는 달랐습니다. flex 컨테이너 안에서 max-height: 100%가 부모의 계산된 높이를 제대로 참조하지 못하더군요. 캔버스가 아주 작게 나왔습니다.
CSS로는 안 돼서, JavaScript로 직접 계산하는 방식으로 바꿨습니다.
const fitCanvas = () => { const availW = container.clientWidth - padding const availH = container.clientHeight - padding const ratio = 1024 / 768 // 4:3 if (availW / availH > ratio) { // 높이 제약 → 높이 기준으로 계산 displayH = availH displayW = displayH * ratio } else { // 너비 제약 → 너비 기준으로 계산 displayW = availW displayH = displayW / ratio } canvas.style.width = `${Math.floor(displayW)}px` canvas.style.height = `${Math.floor(displayH)}px` }
컨테이너의 실제 픽셀 크기를 읽어서, 4:3 비율을 유지하면서 최대한 크게 잡습니다. window.resize에도 반응하니까 창 크기가 바뀌면 따라갑니다.
4. Undo는 스냅샷 방식으로
Undo 구현 방식은 보통 두 가지입니다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 명령 기록 (Command Pattern) | 메모리 적음 | 재생 시간, 복잡한 구현 |
| 스냅샷 (ImageData) | 즉시 복원, 단순 구현 | 메모리 많이 사용 |
스냅샷 방식을 택했습니다. 1024×768 × dpr=2 기준으로 ImageData 하나가 약 6MB. 20개면 ~120MB인데, 요즘 브라우저에서 이 정도는 괜찮습니다.
한 가지 신경 쓴 부분은, 캔버스 초기화 직후의 흰 배경도 첫 스냅샷으로 넣는다는 점입니다. 이게 없으면 첫 선을 Undo했을 때 빈 캔버스로 돌아갈 방법이 없거든요. "전체 지우기" 전에도 현재 상태를 스택에 push해 놓으면, 지우기 후 Undo로 이전 그림을 살릴 수 있습니다.
연동 포인트: 두 종류의 에디터
FullStackFamily에는 에디터가 두 종류 있습니다.
1. MarkdownEditor (shared/ui/) → Q&A, 자유게시판, 댓글 등 대부분의 글쓰기에서 사용 → onImageUpload prop이 있으면 자동으로 그림판 버튼 표시 2. BlogWriteForm (features/blog/) → 블로그 전용 에디터, 자체 EditorToolbar 사용 → MarkdownEditor를 쓰지 않고 독자적 구성
처음에는 MarkdownEditor만 수정하면 블로그에도 자동으로 적용될 줄 알았습니다. 하지만 블로그 에디터는 별도의 EditorToolbar 컴포넌트를 쓰고 있었습니다. 그래서 EditorToolbar에도 onDrawingOpen prop을 추가하고, BlogWriteForm에서 DrawingCanvas를 직접 마운트하는 작업을 별도로 했습니다.
결과적으로 이런 구조가 됩니다.
[MarkdownEditor 계열] MarkdownEditor.tsx └── DrawingCanvas (내장) → Q&A, 게시판, 댓글, 과제 → 자동 적용 [BlogWriteForm 계열] BlogWriteForm.tsx ├── EditorToolbar (그림판 버튼 추가) └── DrawingCanvas (별도 마운트) → 블로그 글쓰기/수정 → 별도 연동
"이 컴포넌트를 쓰는 곳이 정확히 어딘지" 파악을 제대로 안 하면 이런 일이 생깁니다. "자동으로 적용되겠지"라고 넘기면 안 됩니다.
실제 사용 시나리오
수업 중 학생이 "스프링 빈 라이프사이클 흐름이 헷갈려요"라고 Q&A에 질문을 올렸다고 칩시다.
1. 답변 작성 중 → 에디터 툴바에서 연필 아이콘 클릭 2. 풀스크린 그림판 열림 3. 검정 펜으로 박스를 그리고, 빨간 화살표로 흐름을 표시 4. 파란색으로 "여기서 init() 호출됨" 텍스트 추가 (손글씨) 5. 저장 클릭 6. PNG → 서버 업로드 → WebP 변환 → 마크다운에 자동 삽입 7. 답변 완료, 학생은 그림과 함께 설명을 봄
외부 도구 없이, 탭 전환 없이, 게시판 안에서 모든 게 끝납니다.
기술 결정 정리
| 결정 | 선택 | 이유 |
|---|---|---|
| 드로잉 라이브러리 | 순수 Canvas API | 외부 의존성 없이 프리 드로잉만 필요 |
| 입력 이벤트 | Pointer Events | 마우스/터치/펜을 하나의 API로 통일 |
| 출력 형식 | PNG → 서버에서 WebP | 기존 이미지 파이프라인 그대로 재사용 |
| 캔버스 해상도 | 1024×768 (논리), dpr 반영 | PC에서 충분한 크기 + 레티나 선명도 |
| Undo | ImageData 스냅샷 | 즉시 복원, 구현 단순, 메모리 허용 범위 |
| 모달 | 풀스크린 오버레이 | 캔버스 영역 최대화 |
| 백엔드 | 변경 없음 | File 객체를 기존 업로드 흐름에 태움 |
마무리
잘 돌아가는 파이프라인이 이미 있으면, 새 기능은 거기에 끼워넣는 게 제일 깔끔합니다. canvas.toBlob() → File 변환 한 줄이면 기존 업로드 흐름에 그대로 탈 수 있었습니다. 백엔드 코드 0줄, DB 변경 0줄.
다만 "MarkdownEditor에 넣었으니 블로그에도 되겠지" 했다가 블로그 에디터가 별도 컴포넌트라서 따로 연동해야 했습니다. 코드베이스가 커지면 이런 분기점을 놓치기 쉬우니까, "어디서 쓰이는지"를 먼저 확인하는 습관이 필요합니다.
지우개 모드나 도형 그리기(직선, 사각형, 원) 같은 건 나중에 필요하면 붙이면 됩니다. 지금은 수업 중에 "잠깐, 이거 그림으로 보여줄게요" 할 때 쓸 수 있으면 그걸로 충분합니다.






댓글
댓글을 작성하려면 이 필요합니다.