본문으로 건너뛰기

슬라이드를 넘길 때마다 2초씩 멈췄습니다 — 헛가설 4개를 계측 한 번으로 죽인 이야기

2026년 6월 19일
1 views
MathCanvas
성능 최적화
Vue
주니어 개발
회고

들어가며

제가 다니는 회사의 MathCanvas는 학생이 수학 교구를 화면에서 직접 만지며 공부하는 웹 서비스입니다. 선생님이 만든 활동은 여러 장의 슬라이드 묶음이고, 각 슬라이드 안에 캔버스가 들어갈 수 있어요. 학생은 슬라이드를 넘기며 도형을 옮기고, 3D 쌓기나무를 쌓고, 답을 씁니다.

선생님이 나중에 학생의 활동을 리포트로 볼 때 각 슬라이드의 작은 미리보기 이미지가 보입니다. 그래서 학생이 슬라이드를 떠날 때마다 그 순간의 캔버스를 PNG로 찍어 서버에 올려요.

문제는 이 "찍는" 과정이었습니다. 학생이 다음 버튼을 누르면 1~2초 화면이 얼어붙었습니다. 학습 흐름을 끊는, 사용자가 바로 체감하는 렉이었어요.

가장 먼저 든 생각은 "그럼 서버에서 만들면 되잖아?"였습니다. 맞는 말이고 실제로 그렇게 요청했어요. 슬라이드를 서버에서 렌더링해 이미지를 굽는 게 정석에 가깝습니다. 브라우저 자원을 안 먹고 학생 기기 성능 편차도 없으니까요.

거절됐습니다. 서버 용량과 비용 때문이었어요. 이미지 렌더링은 무거운 작업이고 동시 접속 학생 수만큼 부하와 비용이 늘어납니다. 서버 증설 전까지는 프론트엔드가 이 일을 온전히 감당해야 했습니다.

"프론트에서 어떻게든 가볍게 찍는다"는 건 제가 고른 취향이 아니라 주어진 제약이었습니다.

이 글의 모든 선택은 그 제약 위에서 이뤄집니다. 구조를 하나만 짚고 가면, 슬라이드 안의 캔버스는 같은 앱이 아니라 별도 번들로 로드되고 격리된 영역(Shadow DOM) 안에서 자기 저장소를 갖고 돕니다. 메인 앱과 캔버스는 본관과 옆 건물처럼 떨어져 있고 전역 변수나 창 이벤트로만 소통해요. 이 분리가 뒤에서 여러 번 발목을 잡습니다.

용의자 넷

처음엔 의심할 게 많았습니다. 3D 교구를 다시 그리는 게 무겁겠지, 별도 번들을 다시 평가하느라 그렇겠지, 부트스트랩이 무거워서겠지, 네트워크 요청이 막혀서겠지.

전부 틀렸습니다.

추측 대신 코드에 계측을 심었어요. 평소엔 비용이 0이 되도록 전역 플래그로 감쌌습니다.

if (globalThis.__PERF) {
  console.log('[PERF] leave-capture', performance.now() - t0)
}

이렇게 하면 플래그가 꺼져 있을 때 console.log 는 물론 시각 계산조차 실행되지 않습니다. 성능을 재려고 심은 코드가 성능을 먹으면 안 되니까요.

단계별 밀리초를 찍어보니 교구 마운트는 26개를 다 합쳐도 3ms 였습니다. 제일 의심했던 게 가장 안 무거웠어요. 번들 생명주기가 80ms, 네트워크는 전부 캐시라 0, 이미지 업로드가 129ms. 그리고 떠나는 슬라이드 캡처가 2,825ms 였습니다.

코드를 보니 순서가 이랬습니다. 떠나는 슬라이드를 캡처하고 그게 끝나야 슬라이드를 교체합니다. 캡처가 끝날 때까지 화면 전환을 막고 있었어요.

"들어오는 슬라이드가 느린 것"처럼 보였지만, 실제로는 떠나는 슬라이드를 찍느라 멈춘 거였습니다.

사진기가 아니라 받아쓰기

캡처는 html-to-image 라이브러리의 toPng 를 쓰고 있었습니다. 화면의 모든 DOM 노드를 복제하고, 각 노드의 계산된 스타일을 하나하나 문자열로 뽑아 인라인으로 박고, 그렇게 만든 거대한 문서를 이미지로 굽는 방식이에요.

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

toPng 는 받아쓰기였습니다. 교구 하나가 수백 개 노드를 가지므로 교구가 늘어날수록 받아쓸 항목이 늘어나요.

이 가설을 확인하는 실험을 하나 했습니다. 해상도 배율을 2에서 1로 낮춰본 거예요. 비용이 그리는 일에 있다면 픽셀이 1/4이니 시간도 크게 줄어야 하고, 받아쓰기에 있다면 거의 안 줄어야 합니다.

결과는 2,825ms 에서 2,677ms. 5%만 줄었습니다.

어느 쪽이면 어떤 결과가 나와야 하는지를 먼저 정해놓고 재면, 결과가 나오는 순간 가설이 확정됩니다.

비용의 96%가 받아쓰기였고, 해상도를 낮추는 건 답이 아니었습니다.

통째로 되돌린 첫 시도

가장 먼저 떠오른 해법은 기다리지 않는 것이었습니다. 슬라이드는 즉시 넘기고 무거운 캡처는 나중에 백그라운드로 돌리면 되잖아, 하고요.

문제는 나중에 돌릴 때쯤이면 그 슬라이드의 화면이 이미 사라진다는 것이었습니다. 그래서 떠나기 직전에 화면을 화면 밖으로 복제해 두고, 그 복제본을 나중에 찍는 방식을 만들었어요.

빠르긴 했습니다. 그런데 동료가 찍힌 이미지들을 보더니 물었습니다.

"캡처 시점만 바꿨다며. 왜 캡처되는 이미지가 다른 거야?"

복제본을 다른 환경 — 크기가 없는 화면 밖 영역 — 에서 다시 렌더하니 이미지 자체가 깨졌습니다. 툴바가 가로 아이콘 바에서 세로 텍스트 목록으로 바뀌고 카드 내용은 쪼그라들었어요. 같은 슬라이드인데 들어올 때는 정상이고 나갈 때만 깨지는 일관성 붕괴까지 생겼습니다.

전체 되돌렸습니다.

연극 무대를 사진으로 남기려는데 무대가 곧 철거된다고 해서 세트를 창고로 옮겨 나중에 찍기로 한 셈이었어요. 창고에서 찍은 사진은 무대에서 찍은 것과 다릅니다. 조명이 없고 천장 높이가 다르고 배경이 없으니까요. 세트는 같은데 세트가 놓인 환경이 사진의 절반이었던 겁니다.

CSS도 그렇습니다. 캔버스의 스타일은 부모 크기·레이아웃·런타임 클래스에 의존해요. 복제본을 다른 곳에 놓으면 그 의존이 전부 끊깁니다.

복제본은 라이브가 아닙니다. 캡처의 대상을 바꾸면 "시점만" 바꾼 게 아니라 결과 이미지가 바뀝니다.

묻어뒀던 방법을 되살리기

이전에 또 다른 시도가 있었습니다. 받아쓰기 대신 SVG를 통째로 직렬화하는 방식이었어요. 속도는 환상적이었습니다. 약 30ms, 95배 빨랐고요.

그런데 결과물이 깨졌습니다. 숫자 카드가 파란 블록으로 나오고 숫자가 사라졌어요. 그래서 메모에 "이 방식은 충실도가 깨진다 → 재시도 금지"라고 적고 묻었습니다.

이번엔 그 결론을 의심했습니다. 왜 카드가 파란 블록이 될까?

브라우저 개발자도구로 직접 파봤어요. 외부 이미지 때문인가 했는데 <image> 요소가 0개였고, 폰트 때문도 아니었습니다. 카드 구조를 뜯어보니 카드는 파란 뒷면을 갖고 있고, 그 위에 .hidden 클래스로 뒷면을 숨긴 채 앞면을 보여주고 있었습니다.

범인은 .hidden 이었습니다. 라이브 화면에서는 CSS가 .hidden { display: none } 을 적용해 파란 뒷면을 숨깁니다. 그런데 직렬화된 결과물에서는 그 CSS가 안 먹어서 숨겨졌어야 할 뒷면이 드러난 거였어요.

방을 받아쓸 때 "이 상자는 뚜껑을 덮어 두세요"라는 지시문이 있었는데, 설명서를 옮겨 적을 때 물건 목록만 베끼고 지시문은 빼먹은 셈입니다. 그러니 다시 그린 방에서는 상자 뚜껑이 열려 있죠.

결과물에서 CSS가 다시 적용되기를 기대하지 말고, 라이브 화면에서 "지금 안 보이는" 요소를 직렬화 전에 잘라냅니다.

라이브 화면에서 계산된 스타일을 읽어 display: none 이나 visibility: hidden 인 노드를 복제본에서 제거했습니다. 카드도 숫자도 정상으로 나왔어요.

투명 필름 세 장

캔버스는 사실 세 종류의 그림이 겹쳐 있습니다. 각각을 다르게 다뤘어요.

[배경]  연한 하늘색
[1층]  벡터 콘텐츠 (텍스트·도형·카드·수식)
   → SVG 통째 직렬화 (숨김 요소 제거 + 스타일 임베드)
[2층]  3D 교구 (쌓기나무·다면체)
   → 직렬화로는 픽셀이 안 따라옴 → 라이브 캔버스를 그대로 오려 붙이기
[3층]  학생 펜 그림
   → 별도 직렬화 후 같은 크기로 합성 (맨 위)

옛날 OHP 필름처럼 배경 지도 위에 도로 필름, 그 위에 건물 필름을 겹치면 한 장짜리 완성된 지도가 됩니다. 여기서도 세 층을 같은 크기로 각각 그린 뒤 겹쳐서 한 장으로 만들어요. 정렬을 따로 계산할 필요가 없습니다. 세 장을 같은 가로×세로로 그리면 저절로 맞으니까요.

몇 가지 디테일을 적어둡니다.

툴바는 공짜로 사라집니다. 벡터 콘텐츠 영역만 직렬화하면 좌·우·하단 툴바는 그 바깥의 형제 요소라서 자연히 빠져요. 예전에 "확대/축소 툴바가 미리보기에 찍힌다"던 문제가 덤으로 해결됐습니다.

3D는 직렬화로 안 따라옵니다. 직렬화는 <canvas> 태그만 복사하고 거기 그려진 픽셀은 못 가져와요. 그래서 라이브 캔버스를 그대로 복사해 옮겨 그립니다.

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

외부 자원을 남기면 이미지 추출이 통째로 실패합니다. 직렬화 결과에 @font-face 나 외부 url(https://...) 이 남으면 캔버스가 오염되어 이미지로 뽑는 순간 예외가 나요. 그래서 직렬화 전에 외부 참조를 제거합니다.

결과는 약 60~75ms 였습니다. 픽셀 단위로 완벽하진 않지만 선생님이 학생 작업을 알아보는 데는 충분하고 라이브 화면과 거의 일치합니다.

빨라지니 드러난 것

여기서 동료가 날카로운 걸 짚었습니다. 찍힌 3D 미리보기 일부가 블록 없이 빈 좌표축만 있었어요.

원인이 역설적이었습니다. 빨라진 방식이 너무 빨라서 3D가 아직 안 그려졌는데 찍어버린 거였습니다. 느린 방식은 2.8초를 끄는 동안 우연히 3D 렌더가 끝나기를 기다려줬던 거고요.

옛날 필름 카메라는 셔터를 누르고 나서도 한참 걸렸습니다. 그 덕에 배우가 자리를 잡을 시간이 우연히 생겼죠. 카메라를 최신 기종으로 바꾸니 셔터가 즉시 떨어지고, 배우가 아직 무대 뒤에 있습니다.

카메라가 문제가 아닙니다. 원래 "배우가 자리 잡았는지" 확인하는 절차가 없었던 것이 문제였고, 느림이 그걸 가려주고 있었을 뿐입니다.

3D 렌더 타이밍을 재보니 200ms쯤에 캔버스가 등장해 부분 렌더가 되고, 342ms쯤 블록까지 완전히 그려진 뒤로는 더 안 변했습니다.

해법은 버퍼가 안정될 때까지 기다린 뒤 찍는 것이었어요. 작은 영역의 픽셀 체크섬을 짧은 간격으로 확인해서 연속으로 같아지면 렌더가 끝난 것으로 봅니다. 3D가 없는 슬라이드는 캔버스가 없으니 즉시 통과하고요.

여기에 더 미묘한 함정이 하나 더 있었습니다. 진입 직후 캡처는 슬라이드를 교체한 직후 불리는데, 그 순간엔 새 슬라이드의 캔버스가 아직 마운트조차 안 돼 있었어요. 그래서 이전 슬라이드의 화면이나 크기 0인 화면을 잡아 아무 일도 안 하고 끝나고 있었습니다.

새 캔버스가 준비되면 발사되는 이벤트를 기다린 뒤 찍도록 고쳤습니다. 이 진입 캡처는 화면 전환을 막지 않도록 비블로킹으로 두고, 캡처 도중 다른 슬라이드로 넘어가면 폐기하도록 가드를 달았고요.

의미 있는 순간만

여기서 동료가 정책 하나를 제안했습니다.

"본 슬라이드라도 어떤 조작이 없으면 원본 슬라이드 미리보기를 써도 돼."

학생이 실제로 뭔가 바꿨을 때만 찍고, 그냥 보기만 했으면 선생님이 만든 원본을 유지하자는 것이었어요. 불필요한 덮어쓰기도 줄고 의미도 더 맞습니다. 작업 안 한 슬라이드는 학생 작업물이 없으니까요.

마침 캔버스엔 이미 딱 맞는 신호가 있었습니다. "저장되지 않은 내용이 있습니다" 확인창을 띄우는 바로 그 미저장 변경 플래그예요. 그리기·쌓기·교구 이동·되돌리기 같은 내용 변경이 있을 때 켜지고 슬라이드를 새로 들어오면 꺼집니다.

캡처 직전에 이 플래그를 확인해서 조작이 없으면 캡처를 건너뜁니다. 캔버스가 별도 번들의 격리된 영역에 있어 직접 import 는 못 하지만 호스트 요소에 붙은 접근자로 저장소를 읽을 수 있었어요. 신호를 못 읽는 예외 상황엔 안전하게 캡처하는 쪽으로 폴백했습니다. 캡처가 통째로 사라지는 것보다는 나으니까요.

"조작"의 범위는 한 번 흔들렸습니다. 처음엔 모든 상호작용을 잡으려 했는데 — 카메라 회전까지요 — 그러려면 포인터 추적기를 새로 붙여야 하고 의미 없는 클릭까지 캡처를 부르게 됩니다. 그래서 내용 변경만으로 좁혔어요. 카메라만 돌려본 건 작업이 아니니까요.

모든 순간이 아니라 의미 있는 순간을 찍습니다. 그리고 그 "의미"의 신호는 보통 앱 어딘가에 이미 있습니다.

결과

슬라이드 이동이 2.8초 멈춤에서 0.3초로 줄었습니다. 캡처 방식은 받아쓰기에서 3층 합성으로, 캡처 시점은 렌더 무관에서 렌더 완료 후로, 캡처 조건은 매 방문에서 조작했을 때만으로 바뀌었고요.

이 캡처는 화면 구조·캔버스·직렬화·타이밍·번들 경계가 전부 얽혀 있어 런타임 단위 테스트가 사실상 불가능했습니다. 그래서 소스 패턴 검사로 핵심 불변식만 잠갔어요. 옛 방식을 안 쓴다는 것, 미저장 변경 게이트가 있다는 것, 3층 합성을 쓴다는 것.

한 가지 함정이 있었습니다. 코드 주석에 "예전엔 html-to-image(toPng)를 썼는데…"라는 설명이 남아 있어서 테스트가 자기 주석에 걸려 실패했어요. 소스를 검사하기 전에 주석을 먼저 제거하도록 고쳤습니다.

무엇을 포기했나

갈림길마다 던진 질문은 "이론적으로 가장 완벽한 게 뭔가?"가 아니라 "이 상황에서 충분하고 견고하고 유지보수 가능한 게 뭔가?"였습니다.

캡처 충실도는 픽셀 단위 완벽 대신 "거의 정확"한 합성을 골랐습니다. 미리보기의 목적은 선생님이 알아보는 것이니까요. 화질은 2배 해상도 대신 1.5배와 시스템 폰트로, 파일 크기·속도와 균형을 맞췄고요. 회귀 테스트는 런타임 전체 검증 대신 소스 패턴 검사로 갔습니다. 검증 비용이 이득보다 컸어요.

탭 닫기 보존은 아예 포기했습니다. 비동기 캡처는 동기 종료 안에 못 끝나거든요.

안 되는 걸 인정하는 것도 선택입니다.

특히 "조작 범위"가 상징적이었어요. 처음엔 최대 커버리지를 노렸지만 그러려면 새 추적기가 필요했고 더 약해졌습니다. 최대를 포기하고 내용 변경만으로 좁히니 오히려 기존 신호 하나로 견고하게 끝났어요. 덜 잡지만 더 맞습니다.

더 크게 보면, 이 작업이 "프론트에서 어떻게든 가볍게"였던 것 자체가 가장 큰 트레이드오프였습니다. 정석은 서버 렌더링이지만 용량과 비용으로 막혔죠. 가장 좋은 아키텍처를 못 쓰는 상황에서 지금 가능한 아키텍처 안에서 가장 나은 선택을 찾은 겁니다.

제약을 탓하며 멈추는 대신 제약을 전제로 두고 그 안에서 최선을 짜는 것. 그게 실무라고 배웠습니다.

AI 는 항상 답을 가져다주지 않았다

솔직히 적자면, 이 작업에서 AI 는 여러 번 틀린 길로 자신 있게 걸어갔습니다.

비블로킹과 복제본 방식을 자신 있게 다 만들었고, 이미지가 깨져서 통째로 버렸어요. 예전의 "이 방식은 충실도가 깨진다 → 재시도 금지"라는 단정도 AI 가 내린 결론이었습니다. 이번엔 그게 틀렸지만, 반대로 그때 그 시점엔 맞았을 수도 있고요.

방향을 바로잡은 건 매번 사람의 개입이었습니다. "캡처 시점만 바꿨다며, 왜 이미지가 다르냐?"가 복제본이 캡처 대상을 바꾼다는 핵심을 짚었고, "렌더 전 캡처는 무의미하다"가 타이밍 수정의 방향을 줬고, "조작이 없으면 원본을 써도 된다"가 최대가 아니라 상황에 맞는 범위를 정해줬습니다.

AI 는 빠르게 시도하고 계측하고 구현하는 강력한 도구입니다. 하지만 "무엇이 이 상황에 맞는 선택인가"를 판단하는 자리는 사람이었습니다.

배운 것

추측하지 말고 계측

그럴듯한 헛가설 넷을 계측 한 번이 다 정리했습니다. "느리다"는 체감 대신 단계별 숫자를 확보하면 엉뚱한 곳을 최적화하지 않아요.

"안 된다"를 한 단계 더 파기

깨지던 진짜 이유를 찾으니 95배 빠른 방법이 살아났습니다. 막연히 묻어둔 결론은 그때의 이해 수준을 반영할 뿐이에요.

속도는 타이밍 가정을 들춘다

느림이 가려주던 것을 명시적으로 해야 합니다. 빨라지면 "우연히 됐던 것"이 깨져요.

테스트하기 힘든 것은 핵심 불변식만

전부 검증하려다 아무것도 못 잠그는 것보다, 되살아나면 안 되는 것 셋만 잠그는 게 낫습니다.

남은 것

탭을 닫을 때의 캡처는 구조적으로 포기했습니다. 학생이 브라우저를 그냥 닫으면 마지막 슬라이드의 작업이 미리보기에 안 남아요. 서버 렌더링이 가능해지면 자연히 풀리는 문제라 그때까지 두기로 했습니다.

체크섬으로 렌더 완료를 판정하는 방식도 완벽하진 않습니다. 애니메이션이 계속 도는 교구가 생기면 영영 안정되지 않을 텐데, 지금은 그런 교구가 없어서 문제가 안 됩니다. 생기면 다시 봐야 할 자리예요.

읽어주셔서 고맙습니다.