본문으로 건너뛰기

캔버스 저장 10초 → 0.5초 — 새로 만들지 않고, 저장소 안에 있던 답을 찾았습니다

2026년 8월 18일
3 views
MathCanvas
성능 최적화
Vue
Playwright
주니어 개발

들어가며

제가 다니는 회사의 MathCanvas는 선생님이 수학 교구를 배치해 수업 자료를 만드는 웹 캔버스입니다. 도형·수직선·쌓기나무 같은 교구를 놓고, 저장하면 서버에 올라가요. 저장할 때는 캔버스를 한 장의 이미지로 찍어 미리보기용으로 함께 올립니다. 목록에서 어떤 캔버스인지 알아볼 수 있어야 하니까요.

사용자 보고가 이렇게 왔습니다.

"저장이 UX에 치명적일 만큼 느리다."

"느리다"는 티켓이 안 됩니다. 얼마나 느린지, 어디가 느린지 모르면 고칠 대상이 없어요. 게다가 의심할 곳이 셋이었습니다. 서버가 느린 걸까, 회선이 느린 걸까, 아니면 브라우저에서 뭔가 무거운 걸 하고 있는 걸까. 어디인지 모르면 서버 팀에 문의할지, 회선을 볼지, 코드를 볼지도 정할 수 없습니다.

시간의 95%가 있던 곳

Playwright 로 저장 버튼 클릭부터 네트워크 요청 완료까지를 구간별로 쪼개 측정했습니다. 교구 종류에 따라 다를 것 같아 두 가지 캔버스로 비교했고요.

단순한 2D 캔버스는 캡처 1,087ms, 업로드 465ms, 저장 API 126ms 로 총 1.7초였습니다. 3D 교구 9개짜리는 캡처만 9,608ms — 9.6초였고 업로드 366ms, API 116ms 로 총 10.1초였어요. 2D 1.1초에서 3D 9.6초로, 교구가 늘면 캡처가 그만큼 늘어납니다.

두 가지가 한 번에 확정됐습니다. 시간의 95%가 캡처 한 곳이라는 것, 그리고 교구 개수에 선형 비례한다는 것. 네트워크는 3D 에서도 0.5초뿐이었으니 서버·회선 문제가 아니었습니다.

재기 전에는 "서버에 물어봐야 하나" 싶었는데, 재고 나니 문의할 필요가 없어졌습니다.

측정의 첫 번째 값은 병목을 찾는 것이지만, 두 번째 값은 엉뚱한 곳에 시간을 안 쓰게 하는 것이었어요.

사진기가 아니라 받아쓰기

캡처는 html-to-imagetoPng 를 쓰고 있었습니다. 이 방식은 화면의 DOM 노드마다 계산된 스타일을 읽어 문자열로 뽑아 인라인으로 박고, 3D 영역은 한 번 더 다시 그립니다.

방을 사진으로 남기는 두 가지 방법에 비유할 수 있어요. 하나는 방 안 모든 물건의 재질·색·크기를 말로 적고 그 설명서로 다시 그리는 것. 다른 하나는 그냥 셔터를 누르는 것. 앞의 것은 물건 개수에 비례하고 뒤의 것은 해상도에 비례합니다.

toPng 는 받아쓰기였습니다. 교구 하나가 수백 개 노드를 가지므로 교구 수 × 노드 수 만큼 받아쓸 항목이 늘어나요. 선형 증가의 정체가 여기 있었습니다.

옆 팀이 이미 닦아둔 길

여기서 보통은 "더 빠른 캡처 라이브러리를 찾자"로 갑니다. 저도 그러려고 했어요.

그런데 같은 저장소의 학생 활동 미리보기가 이미 같은 문제를 겪고 해결해 둔 상태였습니다. 코드 주석에 근거까지 남아 있었고요.

과거 html-to-image(toPng)는 노드마다 계산된 스타일을 직렬화해 약 2.8초가 걸렸다. 대신 캔버스의 본질만 직접 직렬화·합성한다.

같은 산을 넘어야 하는데 저는 새 길을 뚫으려 했습니다. 그런데 옆 팀이 반년 전에 같은 산을 넘으면서 길을 닦아 놨고, 이유까지 표지판에 적어 뒀던 거예요.

제가 한 일은 그 길을 찾은 것과, 그 길을 두 팀이 함께 쓰도록 공용화한 것입니다. 새로 만든 게 아닙니다.

세 층을 각각 다르게

새 방식은 벡터 콘텐츠를 SVG 통째로 직렬화하고, 3D 교구는 라이브 화면 버퍼를 그대로 복사하고, 펜 필기는 별도 층으로 합성합니다.

3D 부분이 핵심이에요. SVG 직렬화로는 3D 픽셀이 안 따라옵니다. 직렬화는 <canvas> 태그만 복사하고, 거기 그려진 픽셀은 못 가져오거든요.

그래서 라이브 화면을 그대로 오려 붙입니다.

// 3D 렌더러가 preserveDrawingBuffer 를 켜 둬 마지막 프레임이 버퍼에 남아 있다
ctx.drawImage(liveCanvas, x, y, w, h)

3D 부분을 "다시 그린다"는 건 그림을 보고 옮겨 그리는 것입니다. 시간이 걸려요. "버퍼를 복사한다"는 건 이미 인쇄된 종이를 그대로 오려 붙이는 것이고요. 어차피 화면에 이미 그려져 있으니 다시 그릴 이유가 없습니다.

결과

캔버스캡처 (전 → 후)총 저장 (전 → 후)
단순 (2D)1,087ms → 110ms1.7초 → 0.77초
3D 교구 9개9,608ms → 141ms10.1초 → 0.56초

2D 는 약 10배, 3D 는 약 68배 빨라졌습니다. 다만 가장 중요한 건 배수가 아니라 이겁니다.

교구 개수에 대한 선형 의존이 사라졌습니다. 2D 110ms, 3D 141ms 로 거의 같아졌어요.

교구를 더 놓아도 저장이 느려지지 않습니다.

부수 효과로 코드도 줄었습니다. 저장 캡처가 351줄에서 115줄로, 학생 활동 캡처가 331줄에서 199줄로 — 중복 132줄이 사라졌고요. 실행되지 않던 자동 저장 코드도 209줄에서 140줄이 됐습니다. html-to-image 의존성은 통째로 제거했습니다.

성능이 풀어준 기능 제약

이 작업은 원래 다른 기능의 파생이었습니다. 선생님이 풀이 과정을 녹화하는 기능이었어요. 녹화에 들어가려면 캔버스를 먼저 저장해야 했는데, 저장이 10초면 두 가지가 불가능했습니다.

하나는 팝업 차단입니다. 브라우저는 사용자 클릭 직후 약 5초 안에만 새 창 열기를 허용해요. "사용자가 방금 클릭했으니 이 창은 사용자 의도다"라고 판단해 문을 열어주는데, 그 유효 시간이 5초입니다. 10초 걸리는 일을 먼저 하면 문이 이미 닫혀 있습니다.

0.56초가 되면서 "저장 → 새 창" 순서가 가능해졌습니다.

다른 하나는 백그라운드 탭 정지였어요. 차단을 피하려고 창을 먼저 열면 원래 탭이 백그라운드가 되고, 그러면 브라우저가 화면 갱신 루프를 멈춰서 캡처가 끝나지 않습니다. 순서를 바꿔 이 문제를 구조적으로 제거했습니다.

성능 개선이 단순한 체감 향상이 아니라 기능 설계의 선택지를 열었습니다.

"빠르게 만들기"가 목적이 아니라, 느려서 못 하던 걸 하게 만드는 게 목적이었어요.

배운 것

네 번 뒤집힌 가설

이 작업에서 눈과 추론으로 내린 진단이 네 번 틀렸습니다. 매번 측정이 바로잡았고요.

"미리보기에 3D 교구가 안 담긴다"고 봤는데 캡처는 정상이었고, 목록의 object-fit: cover 가 양옆을 자른 거였습니다. "그 교구는 3D다"라고 했는데 <canvas> 가 0개인 순수 SVG였고요. "학생 활동도 같은 캡처 코드를 쓴다"고 봤는데 호출부는 1곳뿐이고 학생 활동은 331줄짜리 별도 구현이었습니다. 마지막으로 "동료 브랜치가 깨져 있다"고 판단했는데, 제가 방금 지운 패키지가 의존성 폴더에서 빠져 생긴 오염이었어요.

세 번째가 특히 뼈아팠습니다. 그 "별도 구현"이 제가 찾아야 했던 정답지였거든요. 작업 전에 같은 일을 하는 코드를 grep 으로 열거했다면 처음부터 그것을 재사용했을 겁니다.

첫 번째도 창피합니다. 60×40 크기의 작은 미리보기를 눈으로만 보고 "캡처가 3D를 못 담는다"고 보고했어요. 실제로는 16:9 이미지를 3:2 칸에 넣으면서 양옆이 잘린 것이었고, 게다가 그 교구는 3D도 아니었습니다.

증상을 보면 원인을 말하기 전에, 같은 일을 하는 구현과 호출부를 먼저 셉니다. "하나뿐"이라는 가정이 가장 자주 틀립니다.

그리고 축소된 결과물로 판단하지 말고 원본 크기로 저장해서 볼 것.

타입 검사도 테스트도 못 보는 곳

안 쓰는 의존성을 제거했을 때 타입 검사는 기존 47건을 유지했고 테스트 1,742건도 전부 통과했습니다. 둘 다 초록이었어요.

빌드는 깨졌습니다.

번들러의 수동 청크 설정 3곳에 그 패키지가 진입점으로 박혀 있었거든요. 설정 파일은 타입 검사와 테스트 어느 쪽의 시야에도 없습니다.

기존 테스트가 잡아준 것

중복 제거로 코드를 옮기며 두 가지를 빠뜨렸는데 기존 회귀 테스트가 잡아냈습니다.

일반 문서 환경에서는 스타일 수집을 건너뛰어야 하는데 — 앱 CSS 전체를 인라인하면 오히려 느려집니다 — 그걸 놓쳤고, 격리 영역 스타일에서 @font-face 와 외부 url() 을 제거해야 하는 것도 빠뜨렸어요. 외부 자원을 물면 이미지 추출이 통째로 실패합니다.

둘 다 복원하자 오히려 더 빨라졌습니다. 0.79초에서 0.56초로요.

남은 것

이 방식은 3D 렌더러가 preserveDrawingBuffer 를 켜 둔 것에 기대고 있습니다. 그 옵션은 메모리를 더 쓰기 때문에 언젠가 끄자는 이야기가 나올 수 있고, 그러면 캡처가 통째로 깨집니다. 지금은 주석으로만 남겨뒀는데 테스트로 잠가야 할 자리예요.

어떻게 확인했나

Playwright 로 클릭부터 요청까지 구간을 쪼개 측정했습니다. 단순 캔버스와 3D 캔버스 두 가지로 전후를 대조했고요.

화질은 캡처 결과를 원본 크기 PNG 로 저장해 눈으로 대조했습니다. 축소본으로 판단해 오진한 전례가 있어서요.

회귀 테스트는 8건 붙였습니다. 옛 방식 재도입 차단, 3층 합성 유지, 가려진 탭에서도 완료되는지. 가드가 실제로 잡는지는 순서를 뒤집어 테스트가 빨개지는 걸 확인했습니다. 메인과 SDK 두 빌드도 모두 통과했고요.

마무리

이 작업에서 제가 한 일을 한 줄로 적으면 이렇습니다.

재보고, 이미 있던 답을 찾고, 두 곳이 함께 쓰도록 정리했다.

새 알고리즘을 만들지 않았고 새 라이브러리를 도입하지도 않았습니다. 오히려 의존성을 하나 지웠고 코드가 500줄 넘게 줄었어요.

성능 작업이 항상 똑똑한 기법을 요구하는 건 아니라고 배웠습니다. 대부분은 "지금 하고 있는 일이 꼭 필요한가"를 묻는 것에서 시작하더군요.

읽어주셔서 고맙습니다.