본문으로 건너뛰기

썸네일이 백지가 되는 세 가지 경로 — 원인은 하나였습니다

2026년 8월 26일
5 views
MathCanvas
Vue
테스트
주니어 개발
회고

들어가며

교육용 수학 학습 도구를 만들고 있습니다. 선생님이 수업 자료를 만들면 학생이 태블릿으로 풀고, 선생님은 나중에 활동 리포트에서 학생별로 무엇을 했는지 확인합니다. 이 리포트에는 슬라이드마다 작은 썸네일이 붙어요. 그 학생의 화면이 어떤 모습이었는지 보여주는 그림입니다.

어느 날 기획에서 세 건이 왔습니다. 썸네일을 못 그리는 경우가 없어야 한다, 페이지별 보기 카드가 캔버스를 제대로 담지 못한다, 활동이 없는 캔버스에 '활동 보기' 버튼이 활성화된다.

셋 다 "썸네일 근처"라는 것 말고는 공통점이 안 보였습니다. 하루를 다 쓰고 나서야 셋이 같은 곳에서 나온 문제라는 걸 알았고, 그 과정에서 제가 세운 가설은 세 번 다 틀렸습니다. 이 글은 그 기록입니다.

반쯤 잘린 카드

썸네일 박스 높이가 고정이었습니다.

<div class="relative h-[114px] w-full overflow-hidden ...">

카드 폭은 화면 크기에 따라 변하는데 높이만 114px 로 묶여 있었어요. 캔버스는 16:9 입니다. 노트북 2열 배치에서 썸네일 폭이 428px 이면 높이는 241px 이어야 하는데 실제로는 114px 이었습니다. 53%가 잘린 거예요. 와이드 3열은 249px 이 나와야 할 자리라 54%, 태블릿과 데스크탑도 각각 38%와 41%가 사라지고 있었습니다.

축소 로직이 max(폭 비율, 높이 비율) 이라 — CSS의 object-fit: cover 와 같은 동작입니다 — 폭을 채우려고 그림을 확대하고, 넘친 위아래를 overflow-hidden 이 잘라냈습니다.

aspect-video 로 바꿔 높이가 폭을 따라오게 했습니다. 여기까지는 간단했어요.

활동 판정 — 첫 번째 가설

'활동 보기' 버튼은 학생이 캔버스에 뭔가를 했는지 조작 기록으로 셉니다.

export function hasMathCanvasHistoryStackForReplay(projectState: unknown): boolean {
  const hs = (projectState as { historyStack?: unknown }).historyStack
  if (!Array.isArray(hs) || hs.length === 0) return false
  return hs.some((item: any) => String(item?.type).toLowerCase() !== 'init')
}

리포트 한 줄은 선생님이 만든 원본과 학생이 낸 답을 합쳐서 만듭니다.

const merged = { ...template, ...learner }

이걸 보고 짐작했습니다. 학생이 아무것도 안 해도 선생님 쪽 조작 기록이 물려받아지고, 그게 "활동 있음"으로 세어진다고요. 스프레드 순서를 보면 자연스러운 추론이었습니다.

로컬 서버에 붙어 실제 응답을 찍어봤습니다.

선생님 원본:     조작 기록 없음
학생 7명 전원:   1개, 타입은 undefined

원본에는 기록이 아예 없었습니다. 짐작이 틀렸어요. 그리고 학생 전원이 똑같이 "노드 1개, 타입은 undefined" 였습니다.

조작 기록은 용량을 아끼려고 압축해서 저장합니다. 압축하면 통째로 노드 하나가 되고, 그 노드엔 조작 종류가 없어요.

[{ __hv: 3, z: 'H4sIAAAAA...' }]              // 압축본 — type 이 없다
String(undefined).toLowerCase() !== 'init'    // "undefined" !== "init" → 항상 true

압축을 안 풀고 세는 바람에, 캔버스에 들어가기만 해도 전원이 "활동 있음"이 됐습니다.

그런데 학생 화면은 같은 함수를 쓰는데 왜 맞았을까요. 그쪽은 이미 풀린 기록을 넘기고 있었습니다. 그래서 "버튼은 눌리는데 들어가면 활동 내역이 없어요"가 됐던 겁니다.

같은 함수가 두 화면에서 반대 답을 내면, 의심할 곳은 함수가 아니라 넘기는 값입니다.

압축본이면 풀고 세도록 고쳤습니다. 못 풀면 판정을 포기하게 해서 옛 오판으로 돌아가지 않게 했고요.

let stack = raw
if (isDeflatedHistoryStack(raw)) {
  try { stack = hydrateHistoryStack(raw) }
  catch { return false }
}
return stack.some((item: any) => {
  const t = item?.type
  return typeof t === 'string' && t.toLowerCase() !== 'init'
})

목록 카드를 흉내 내다가

"카드를 목록 화면의 카드처럼 바꿔라"를 받고, 그 카드들이 서버가 미리 구워 둔 이미지를 쓴다는 걸 확인했습니다. 그래서 캔버스가 아닌 타입은 선생님 원본 페이지를 썸네일에 넘기고, 서버 이미지가 있으면 그걸 우선 쓰게 했습니다.

화면을 열어보니 OX 퀴즈 카드가 텅 비었습니다. 문제 문구 자리에 "질문을 입력해 주세요."라는 안내가 뜨고, 학생이 고른 답에 붙던 표시가 사라졌어요.

썸네일의 목적을 잘못 읽은 겁니다. 이 화면의 썸네일은 그 학생의 최종 상태를 저장된 데이터로 다시 그리는 것이었습니다. 선생님 원본만 넘기면 학생 답이 없고, 서버 이미지로 갈아치우면 다시 그리는 일 자체가 사라집니다.

"A 처럼 만들어라"를 받으면 A 의 방식이 아니라 A 의 목적을 먼저 확인해야 합니다.

되돌리고 16:9 비율만 남겼습니다. 그게 원래 문제였으니까요.

빈 배열이 이긴 자리

되돌리고 나니 이번엔 일반 슬라이드가 비었습니다. 다시 찍어봤어요.

선생님 원본     내용 = 배열(2개)   ← 이미지 + 텍스트
학생 8명 전원   내용 = 배열(0개)

일반 슬라이드는 학생이 손댈 게 없어서 응답이 빈 배열로 옵니다. 그런데 합칠 때 학생 값을 우선하니 빈 배열이 선생님 내용을 덮어썼어요. 선생님이 그린 도화지 위에 학생의 백지를 얹은 셈입니다.

OX 퀴즈나 선지형은 내용이 객체라 필드별로 합쳐져 이 구멍에 안 걸렸습니다. 그래서 일반 슬라이드만 깨져 보였고요.

같은 상황을 막는 코드가 선지형에는 이미 있었습니다.

// 학습자 contents가 options: [] 로 템플릿을 덮은 경우 정답 메타 복구
if ((!Array.isArray(merged.options) || merged.options.length === 0) && ...) {
  merged.options = template.options.map((o: any) => ({ ...o }))
}

일반 슬라이드에도 같은 처방을 적용했습니다. 학생이 실제로 그린 게 있으면 여전히 학생 것이 이깁니다.

사라진 배경색

배경색을 지정한 페이지가 썸네일에서 흰 바탕으로 나왔습니다. 학생 화면에서는 제대로 나왔고요.

배경색은 내용 안이 아니라 페이지 자체에 붙어 옵니다.

pages[] = { id, data, order, type, backgroundColor, memo, ... }
                                   ↑ 여기

슬라이드 종류와 무관한 공통 속성이니 맞는 설계입니다. 일반 슬라이드의 내용은 객체 배열이라 배경색을 끼워 넣을 자리도 없고요.

합치는 함수는 양쪽의 내용만 읽고 있었습니다. 슬라이드 종류만 예외적으로 따로 챙기고 나머지 페이지 레벨 속성은 손도 안 댔어요. 그래서 배경색과 메모가 사라졌습니다.

학생 화면이 쓰는 변환은 처음부터 둘을 나눠 챙깁니다.

const spread = type === GENERAL ? { contents: raw } : { ...raw }   // 내용
return { ...spread, id, type, backgroundColor, memo, thumbnail }   // 페이지 레벨

리포트 쪽만 이 규칙을 안 따르고 있었습니다.

하나의 뿌리

고치고 나서 보니 원인이 하나였어요. 학생 빈 배열이 원본을 덮은 것, 배경색과 메모가 사라진 것, 압축 기록을 안 푼 것 — 셋 다 합치는 코드가 내용만 보고 페이지 레벨을 놓친다는 한 문장으로 설명됩니다.

증상이 셋으로 갈려 보였을 뿐, 뿌리는 하나였습니다.

기획에서 세 건으로 온 것도 당연했습니다. 사용자 눈에는 셋이니까요. 원인이 하나라는 건 코드를 다 뒤진 뒤에야 보였습니다.

손대지 않은 캔버스는 무엇을 보여줘야 하나

캔버스는 저장된 데이터로 다시 그릴 수 없어서 이미지가 필요합니다. 학생 화면은 조작이 있을 때만 사진을 찍어요.

/**
 * → 조작 없이 보기만 했거나 갓 진입한 슬라이드는 캡처를 건너뛰고 원본 썸네일을 유지.
 */
function isCanvasUntouched(root: CaptureRoot): boolean { ... }

의도된 설계입니다. 그런데 유지할 원본을 아무도 연결하지 않아서 백지가 됐습니다. 주석은 "원본 썸네일을 유지"라고 말하는데, 그 원본을 넘기는 코드가 없었어요.

서버는 이미지가 필요한 두 타입에만 대표 그림 주소를 채워 보냅니다. 썸네일 컴포넌트는 대체 그림을 이미 지원하고 있었고 리포트만 안 넘겼습니다. 연결했습니다.

학생 그림 → 없으면 선생님 원본 → 그것도 없으면 안내 문구

이제 화면이 세 상태로 갈립니다. 조작한 학생은 자기가 그린 그림과 '활동 보기' 배지를, 방문만 한 학생과 아예 안 온 학생은 선생님 원본과 '활동 없음' 배지를 보게 됩니다. 뒤의 둘은 썸네일이 같지만 배지와 머문 시간이 다릅니다.

썸네일은 "화면이 어떤 모습이었나", 배지는 "학생이 뭘 했나". 두 신호를 섞지 않기로 했습니다.

배운 것

코드가 알려주지 않는 것

이번에 세운 가설 세 개가 전부 틀렸습니다. 합치는 과정에서 선생님 기록이 물려받아진다고 봤는데 원본엔 기록이 아예 없었고, 활동 응답에 대표 그림 주소가 안 온다고 단정했는데 필요한 두 타입에는 오고 있었고, 일반 슬라이드의 체크 표시를 정답 오표시로 읽었는데 그건 열람 확인이었습니다. 내부 상태 이름이 'correct' 라서 오독한 거였어요.

셋 다 그럴듯했습니다. 특히 첫 번째는 스프레드 순서라는 근거까지 있었고요. 그런데 실제 응답 한 줄이 그걸 무너뜨렸습니다.

원인을 말하기 전에 그 데이터를 직접 찍어 봅니다.

통과하는 테스트의 한계

고칠 때마다 고친 걸 도로 빼서 테스트가 실패하는지 확인했습니다. 활동 판정은 3건, 배경색도 3건, 일반 썸네일과 정렬 제거는 각 1건이 빨갛게 떴어요.

아무것도 검사하지 않는 테스트도 초록으로 통과합니다. 실패하는 걸 봐야 그 테스트가 무엇을 잡는지 알 수 있습니다.

입력의 상태도 계약

리포트와 학생 화면이 같은 함수를 쓰면서 반대 결론을 냈습니다. 함수를 의심하기 쉬운 상황인데 문제는 넘기는 값이었어요 — 한쪽은 압축본을, 한쪽은 풀린 값을 줬습니다.

"이 함수는 압축된 기록도 받는다"를 어디에도 적어두지 않았던 게 문제였습니다. 시그니처는 unknown 이었고, 그러면 호출하는 쪽은 아무거나 넣어도 된다고 읽습니다.

남은 것

data 안에 타입별로 무엇이 들어가는지가 API 문서에 없습니다. 프론트가 방어적으로 짜여 있던 근본 이유가 이건데, 다음 사람도 같은 데서 헤맬 겁니다. 문서화를 요청해 뒀지만 아직 답은 못 받았어요.

썸네일이 렌더되기 전 첫 프레임이 잠깐 비는 것도 남았습니다. 자기 치유되긴 하는데 깜빡임은 여전합니다. 이번엔 손대지 않기로 했습니다.

어떻게 확인했나

로컬 서버에 붙여 실제 응답을 찍어 대조했습니다. 가설 세 개가 전부 여기서 뒤집혔어요. 고칠 때마다 되돌려서 테스트가 실패하는지 봤고, 공용 썸네일 컴포넌트를 쓰는 11곳을 전수 확인한 뒤에 변경했습니다.

유닛 테스트는 1,923건에서 1,948건으로 늘었고 전부 통과합니다. 타입 오류는 36건 그대로예요. 새로 붙인 테스트는 20건입니다.

그리고 화면을 직접 열어봤습니다. 제가 만든 회귀는 거기서만 잡혔습니다.