문서 읽는 데 88분 · H2

H-2: Canvas & 시각화

목차 52
전체 34강 중 33강 · HTML·CSS·JS
난이도 · 입문

오늘 배울 것

지난 시간(H-1)에 우리는 사진에 흑백 필터를 거는 기능을 만들었어요. 그때 사진의 픽셀을 꺼내려고 보이지 않는 canvas(캔버스)를 하나 만들었던 거 기억나시죠? getContext("2d")로 붓을 꺼내고, drawImage로 사진을 그려 넣은 다음, 거기서 픽셀을 읽었어요. 그러니까 그때 canvas는 픽셀을 읽는 도구로만 썼던 거예요.

오늘이 바로 그 canvas를 직접 그리는 도구로 꺼내 쓰는 시간이에요. 사진에서 픽셀을 읽기만 하던 그 그림판에, 이번엔 우리가 도형도 그리고, 글자도 얹고, 마우스로 선도 긋고, 공도 튕겨봐요.

오늘 손에 넣을 것을 정리하면 이래요.

  • 직접 그리기(도형·텍스트·그래디언트) — 사각형·원·글자·색 번짐을 좌표로 그려요.
  • 마우스로 그리는 드로잉 보드 — 마우스를 따라 선이 그어지게요.
  • 초당 60장 애니메이션(requestAnimationFrame) — H-1 마지막에 예고한 그 기능이에요.
  • 스토리 편집기 — 사진 위에 글자를 얹고 끌어서 옮긴 뒤 PNG로 저장해요.
  • 데이터 시각화 — 좋아요 수를 막대 그래프로. 직접 손코딩 vs 라이브러리 두 방식으로요.
텍스트
 [ 지난 시간 (H-1) ]                  [ 오늘 (H-2) ]

  canvas 로 픽셀을 '읽었다'            canvas 에 직접 '그린다'
  (흑백 필터)                          ├ 도형 · 텍스트 · 그래디언트
                                       ├ 마우스 드로잉
  drawImage  getImageData             ├ requestAnimationFrame 애니메이션
  (읽기 전용)                          ├ 스토리 편집기 (drawImage + 글자)
                                       └ 막대 차트 (손코딩 vs Chart.js)

🎯 오늘의 목표: canvas 2D 붓으로 도형·글자·애니메이션·그래픽 편집기를 직접 만들고, 데이터를 막대 그래프로 시각화한다.

오늘은 새 화면을 여러 개 만들어요. 그동안 우리는 인스타 클론의 피드·프로필 페이지를 다듬어왔는데, canvas는 그 안에 끼워 넣기보단 별도 데모 페이지에서 하나씩 연습하는 게 깔끔해요. 그래서 canvas.html, drawing.html 같은 작은 실험 페이지를 만들어 가며 진행할게요.


Step 1: Canvas 첫 그림 — 좌표계와 도형

canvas는 빈 그림판이에요

<canvas>는 HTML 태그지만, 혼자선 아무것도 안 보여요. 그냥 투명한 빈 그림판 한 장이에요. 여기에 그림을 그리려면 JavaScript로 을 꺼내야 해요. 그 붓을 꺼내는 게 바로 getContext("2d")예요. H-1에서 픽셀을 읽으려고 썼던 그 메서드죠.

먼저 그림판을 HTML에 한 장 깔아요.

HTML
<!-- instagram-clone-frontend/canvas.html -->
<canvas id="basics" width="480" height="360" class="demo-canvas"></canvas>
<script type="module" src="js/canvas-basics.js"></script>

widthheight를 480×360으로 줬어요. 이 숫자가 곧 그림판의 실제 픽셀 크기예요. 그리고 JS 파일에서 붓을 꺼내요.

JavaScript
// instagram-clone-frontend/js/canvas-basics.js

// canvas 하나를 받아 2D 붓(context)을 꺼내고, 도형·텍스트·그래디언트를 그려요.
export function setupCanvasBasics(canvas) {
  const ctx = canvas.getContext("2d");
  drawShapes(ctx);
  drawText(ctx);
  drawGradientBar(ctx);
}

ctx라는 이름이 보이죠? context(컨텍스트)를 줄인 거예요. 앞으로 그림은 전부 이 ctx를 통해 그려요. "그림판의 붓"이라고 생각하면 돼요. 색을 정하고, 선을 긋고, 도형을 칠하는 모든 명령이 ctx.무언가() 모양이에요.

좌표계 — 왼쪽 위가 출발점, y는 아래로 자라요

캔버스에 그림을 그리기 전에 좌표(coordinate) 약속을 잡고 가야 해요. 이걸 헷갈리면 그림이 엉뚱한 데 그려지거든요.

우리가 수학 시간에 배운 그래프는 원점(0, 0)이 가운데 있고, y가 위로 갈수록 커졌죠? 그런데 canvas는 정반대예요. 왼쪽 위 모서리가 (0, 0)이고, 오른쪽으로 갈수록 x가 커지고, 아래로 갈수록 y가 커져요.

텍스트
 (0,0) ●──────────── x 가 커지는 방향 ──────────
       │
       │      canvas 의 좌표계
       │      왼쪽 위가 출발점 (0,0)
       │      오른쪽으로 x, 아래로 y
       
   y 가 커지는 방향 (아래로!)

처음엔 좀 낯설어요. "위로 갈수록 작아진다고?" 싶죠. 하지만 화면이라는 건 원래 글자가 위에서 아래로 흐르잖아요. 그래서 화면 좌표는 거의 다 이 방식이에요. (0, 0)은 왼쪽 위, 아래로 갈수록 y가 커진다 — 이것만 손에 익히면 나머지는 쉬워요.

사각형 그리기 — clearRect, fillRect, strokeRect

이제 첫 도형을 그려봐요. 사각형부터요.

JavaScript
// Step 1 — 도형: 칠한 사각형, 테두리 사각형, 원
function drawShapes(ctx) {
  // 그리기 전에 화면을 비워요(좌상단 0,0 부터 전체).
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

  // ① 칠한 사각형 — (x, y)는 좌상단 모서리, 그 뒤가 너비·높이
  ctx.fillStyle = "#0095f6";
  ctx.fillRect(40, 40, 120, 80);

  // ② 테두리만 있는 사각형
  ctx.strokeStyle = "#262626";
  ctx.lineWidth = 3;
  ctx.strokeRect(200, 40, 120, 80);

한 줄씩 뜯어볼게요.

ctx.clearRect(0, 0, ...)지우개예요. 그리기 전에 그림판을 깨끗이 비우는 거죠. 네 숫자는 "왼쪽 위 (0, 0)에서 시작해 그림판 전체 너비·높이만큼 지워라"라는 뜻이에요. 안 비우면 이전에 그린 게 남아 겹칠 수 있어요.

ctx.fillStyle = "#0095f6"칠할 색을 정하는 거예요. 인스타 파란색이죠. 이 줄 다음부터 칠하는 건 다 이 색이에요. CSS에서 쓰던 색 표현(hex, rgb)을 그대로 써요.

ctx.fillRect(40, 40, 120, 80)이 진짜 사각형을 칠해요. 네 숫자의 뜻이 중요해요.

텍스트
 ctx.fillRect( 40 ,  40 ,  120 ,  80 )
                │     │     │      │
          왼쪽위x 왼쪽위y  너비   높이

  (40,40) ┌──────────┐
          │  120×80  │    왼쪽 위 모서리가 (40, 40)
          │  파란 칠 │      거기서 오른쪽 120, 아래 80 만큼
          └──────────┘

앞 두 숫자가 왼쪽 위 모서리의 위치, 뒤 두 숫자가 너비와 높이예요. 중심이 아니라 모서리 기준이라는 점을 꼭 기억하세요.

ctx.strokeRect(...)는 칠하지 않고 테두리만 그리는 사각형이에요. fill(채우기)과 stroke(선 긋기)가 한 쌍으로 자주 나와요. strokeStyle로 선 색, lineWidth로 선 굵기를 정해요.

원 그리기 — arc

사각형은 모서리와 크기로 그렸어요. 원은 약속이 좀 달라요. 중심반지름, 그리고 각도로 그려요.

JavaScript
  // ③ 원 — arc(중심x, 중심y, 반지름, 시작각, 끝각). 한 바퀴 = 2π
  ctx.beginPath();
  ctx.arc(120, 250, 50, 0, Math.PI * 2);
  ctx.fillStyle = "#ed4956";
  ctx.fill();
}

ctx.arc(120, 250, 50, 0, Math.PI * 2)를 풀어볼게요.

텍스트
 ctx.arc( 120 ,  250 ,  50 ,   0  , Math.PI*2 )
          │      │      │      │        │
        중심x  중심y  반지름 시작각    끝각

  Math.PI * 2 = 한 바퀴 전체 (360도)

앞 두 숫자가 원의 중심, 세 번째가 반지름이에요. 사각형과 달리 중심 기준이죠. 마지막 두 숫자는 시작 각도와 끝 각도예요. 0에서 시작해서 Math.PI * 2까지 그리면 한 바퀴를 꽉 채운 동그라미가 돼요.

여기서 Math.PI는 원주율 파이(약 3.14)예요. 컴퓨터 그래픽에서 각도는 도(degree)가 아니라 라디안(radian)으로 재는데, 한 바퀴가 예요. 외우긴 좀 그렇고, "동그라미 한 바퀴 = Math.PI * 2" 이 한 줄만 기억하면 충분해요.

그리고 원에는 새로운 친구 셋이 보이죠.

  • ctx.beginPath() — "새 경로(path)를 시작할게" 선언이에요. 도형 하나를 시작할 때 깔끔하게 떼어주는 거예요.
  • ctx.arc(...) — 원의 모양을 계획만 세워요. 아직 안 보여요.
  • ctx.fill() — 그 계획대로 진짜 칠해요. 이 줄에서 비로소 빨간 원이 나타나요.

arc는 모양을 잡기만 하고, fill이나 stroke를 불러야 실제로 화면에 찍혀요. 이 "계획 따로, 그리기 따로" 패턴은 다음 Step 드로잉 보드에서 또 만나요.

직접 확인해보세요

canvas.html을 Live Server로 열어보세요. 480×360 그림판 안에 파란 사각형, 검은 테두리 사각형, 빨간 원이 떠 있어요. fillRect(40, 40, 120, 80)의 숫자를 (40, 40, 200, 50)처럼 바꿔보세요. 사각형이 더 넓고 납작해지죠? 원의 반지름 5080으로 바꾸면 더 큰 동그라미가 돼요. 숫자를 바꿔가며 좌표 감각을 손에 익혀보세요.

💡 여기까지 정리: canvas는 빈 그림판, getContext("2d")로 붓(ctx)을 꺼내요. 좌표는 왼쪽 위가 (0,0), 아래로 y가 커져요. 사각형은 모서리+너비·높이(fillRect), 원은 중심+반지름+각도(arc)로 그려요. fill/stroke를 불러야 진짜 그려져요.


Step 2: 텍스트와 그래디언트로 꾸미기

글자도 그림이에요 — fillText

캔버스에서는 글자도 도형처럼 그려요. HTML 텍스트와 달리 픽셀로 칠해지는 거라, 색·글꼴·위치를 붓으로 정해요. 인스타 게시물의 "좋아요 1,240개" 라벨을 canvas로 찍어볼게요.

JavaScript
// instagram-clone-frontend/js/canvas-basics.js

// Step 2 — 텍스트: 글꼴·정렬을 정하고 글자를 찍어요.
function drawText(ctx) {
  ctx.fillStyle = "#262626";
  ctx.font = "bold 24px sans-serif";
  ctx.textAlign = "left";
  ctx.fillText("좋아요 1,240개", 200, 200);

  ctx.font = "14px sans-serif";
  ctx.fillStyle = "#6e6e6e";
  ctx.fillText("jaehoon 외 여러 명", 200, 228);
}

ctx.fillText("좋아요 1,240개", 200, 200)이 핵심이에요. 첫 번째는 찍을 글자, 뒤 두 숫자는 글자를 놓을 (x, y) 위치예요. 도형이랑 똑같이 좌표로 위치를 정하는 거죠.

글자를 찍기 전에 두 가지를 정했어요.

  • ctx.font = "bold 24px sans-serif" — 글꼴이에요. CSS의 font 속성과 같은 문법이에요. "굵게, 24픽셀, sans-serif" 라는 뜻이죠.
  • ctx.textAlign = "left" — 글자를 기준점의 어느 쪽에 정렬할지예요. left면 (x, y)가 글자의 왼쪽 끝이 돼요. center면 가운데, right면 오른쪽 끝이고요.

fillStyle은 글자 색이에요. 도형 칠할 때 쓰던 그 속성을 글자에도 그대로 써요. 첫 줄은 진한 색(#262626)으로 굵게, 둘째 줄은 회색(#6e6e6e)으로 작게 — 인스타 좋아요 라벨이랑 비슷하죠.

색이 번지는 그래디언트 — createLinearGradient

이번엔 한 가지 색이 아니라 여러 색이 번지는 효과를 만들어봐요. 인스타 로고나 스토리 테두리의 그 무지개빛 느낌이요. 이걸 그래디언트(gradient, 색 번짐)라고 해요.

JavaScript
// Step 2 — 선형 그래디언트로 칠한 막대 (스토리 하이라이트 느낌)
function drawGradientBar(ctx) {
  // (x0,y0) → (x1,y1) 방향으로 색이 흐르는 그래디언트
  const gradient = ctx.createLinearGradient(200, 0, 440, 0);
  gradient.addColorStop(0, "#feda75");
  gradient.addColorStop(0.5, "#d62976");
  gradient.addColorStop(1, "#4f5bd5");

  ctx.fillStyle = gradient;
  ctx.fillRect(200, 280, 240, 40);
}

ctx.createLinearGradient(200, 0, 440, 0)색이 흐르는 방향을 정해요. 네 숫자는 시작점 (200, 0)에서 끝점 (440, 0)까지예요. y가 둘 다 0이고 x만 200→440으로 변하니까, 왼쪽에서 오른쪽으로 색이 흐르는 거예요.

그 흐름 위에 색깔 정거장을 찍어요. 그게 addColorStop이에요.

텍스트
 createLinearGradient(200, 0, 440, 0)  오 방향

  0.0                0.5             1.0
   │                  │               │
 #feda75 ──── #d62976 ──── #4f5bd5
 (노랑)        (분홍)        (보라)
   └──── 인스타 그래디언트처럼 부드럽게 번져요 ────┘

addColorStop(0, ...)은 흐름의 시작(0)에 노랑, addColorStop(0.5, ...)은 한가운데(0.5)에 분홍, addColorStop(1, ...)은 끝(1)에 보라를 놓는 거예요. 0부터 1까지의 비율로 색 정거장을 찍으면, 그 사이는 알아서 부드럽게 섞여요.

마지막에 ctx.fillStyle = gradient가 재밌어요. 색을 정하는 곳에 그래디언트 객체를 통째로 넣었죠? 단색 대신 그래디언트로 칠하라는 뜻이에요. 그다음 fillRect로 막대를 칠하면, 그 막대가 노랑→분홍→보라로 번져요.

직접 확인해보세요

canvas.html을 다시 열면, 도형 옆에 "좋아요 1,240개" 글자와 무지개빛 막대가 같이 떠 있어요. addColorStop의 색을 "#ff0000", "#00ff00"처럼 바꿔보세요. 막대의 번짐 색이 바뀌어요. createLinearGradient(200, 0, 200, 320)처럼 끝점을 세로로 바꾸면, 색이 위아래로 흐르고요. 방향이 어떻게 바뀌는지 눈으로 확인해보세요.

💡 여기까지 정리: 글자는 fillText(글자, x, y)로 찍고, font·textAlign으로 모양을 정해요. 그래디언트는 createLinearGradient로 방향을 잡고 addColorStop(비율, 색)으로 색 정거장을 찍어요. fillStyle에 그래디언트를 넣으면 단색 대신 번지는 색으로 칠해져요.


Step 3: 마우스로 그리는 드로잉 보드

점이 아니라 선을 그어요 — Path API

지금까지는 미리 정한 도형을 그렸어요. 이번엔 사용자의 마우스를 따라 선이 그어지는 그림판을 만들어요. 마우스가 지나간 자리를 점으로 잇는 거죠.

선을 긋는 데는 H-1 캔버스에선 안 썼던 Path(경로) 명령 세 개가 필요해요.

텍스트
 ctx.beginPath()    "새 선을 시작할게" (붓을 새로 듦)
 ctx.moveTo(x, y)   붓을 들어 (x, y) 로 옮김 (안 그림)
 ctx.lineTo(x, y)   직전 점에서 (x, y) 까지 선을 계획
 ctx.stroke()       계획한 선을 실제로 그음

moveTo는 붓을 든 채로 옮기는 거예요(자국이 안 남아요). lineTo는 붓을 댄 채로 옮기는 거고요(선이 그어질 계획이 잡혀요). Step 1의 원에서 본 "계획 따로, 그리기 따로"가 여기서 제대로 쓰여요. lineTo로 계획만 잡고, stroke()를 불러야 진짜 선이 화면에 나타나요.

마우스 이벤트로 그리기를 켜고 꺼요

이제 D-2에서 배운 마우스 이벤트를 떠올려보세요. addEventListener로 클릭·움직임을 잡았던 거요. 드로잉 보드는 이 이벤트 세 종류로 굴러가요.

JavaScript
// instagram-clone-frontend/js/drawing-board.js

export function setupDrawingBoard(canvas, options = {}) {
  const ctx = canvas.getContext("2d");
  ctx.lineCap = "round";  // 선의 끝을 둥글게
  ctx.lineJoin = "round"; // 선이 꺾이는 모서리를 둥글게

  let drawing = false;

drawing이라는 변수가 핵심이에요. 지금 그리는 중인지 기억하는 스위치예요. 마우스 버튼을 누르면 true(그리기 켜짐), 떼면 false(꺼짐)로 바꿔요. lineCap·lineJoinround로 준 건 선 끝과 모서리를 둥글게 해 부드럽게 보이게 한 거고요.

먼저 마우스를 누르는 순간이에요.

JavaScript
  canvas.addEventListener("mousedown", (event) => {
    drawing = true;
    const { x, y } = getPos(event);
    ctx.beginPath();  // 새 경로 시작
    ctx.moveTo(x, y); // 붓을 들어 시작점으로 이동
  });

mousedown(버튼 누름)이 일어나면 drawingtrue로 켜고, 새 경로를 시작(beginPath)한 다음, 누른 자리로 붓을 옮겨요(moveTo). 여기가 선의 출발점이에요.

다음은 마우스를 움직이는 동안이에요.

JavaScript
  canvas.addEventListener("mousemove", (event) => {
    if (!drawing) return;
    const { x, y } = getPos(event);
    ctx.lineTo(x, y); // 직전 점에서 여기까지 선을 잇고
    ctx.stroke();     // 실제로 그어요
  });

mousemove(움직임)는 마우스가 1픽셀만 움직여도 계속 일어나요. 그런데 첫 줄 if (!drawing) return;을 보세요. 그리는 중이 아니면(버튼을 안 누른 상태면) 아무것도 안 하고 빠져나가요. 그냥 마우스를 휘젓는다고 선이 그어지면 안 되니까요. 버튼을 누른 상태에서만, 직전 점부터 지금 위치까지 lineTo로 잇고 stroke()로 그어요. 이게 빠르게 반복되면서 곡선이 따라 그려져요.

마지막으로 멈추는 순간이에요.

JavaScript
  // 버튼을 떼거나 영역 밖으로 나가면 멈춰요.
  function stop() {
    drawing = false;
  }
  canvas.addEventListener("mouseup", stop);
  canvas.addEventListener("mouseleave", stop);

mouseup(버튼 뗌)과 mouseleave(그림판 밖으로 나감) 둘 다 stop을 불러요. mouseleave도 챙긴 이유가 있어요. 그림판 안에서 누른 채로 바깥까지 끌고 나가면, 떼는 순간을 놓칠 수 있거든요. 영역을 벗어나면 그냥 멈추게 해서 깔끔하게 처리하는 거예요.

화면 좌표를 canvas 좌표로 보정해요 — getBoundingClientRect

여기 아주 중요한 함정이 하나 있어요. 마우스 이벤트가 주는 event.clientX브라우저 화면 전체 기준의 좌표예요. 그런데 우리가 그릴 곳은 canvas 내부 기준 좌표죠. 이 둘이 안 맞아요. canvas가 화면 한가운데 있으면, 화면 기준 (300, 200)이 canvas 안에서는 (100, 50)일 수도 있거든요.

텍스트
 [ 브라우저 화면 전체 ]
  (0,0)
   ┌────────────────────────────┐
   │   여백          여백       │
   │      ┌──────────────┐      │
   │      │ canvas       │      │
   │      │   ● 마우스   │      │
   │      │              │      │
   │      └──────────────┘      │
   └────────────────────────────┘

  event.clientX 는 화면 기준  canvas 기준으로 바꿔야 정확해요

이 차이를 보정하는 게 getBoundingClientRect()예요. canvas가 화면에서 어디 있는지(왼쪽 위 모서리 위치)를 알려줘요.

JavaScript
  // 마우스 화면 좌표를 canvas 내부 좌표로 바꿔요.
  function getPos(event) {
    const rect = canvas.getBoundingClientRect();
    return { x: event.clientX - rect.left, y: event.clientY - rect.top };
  }

canvas.getBoundingClientRect()가 주는 rect.left·rect.top이 canvas의 화면상 왼쪽 위 위치예요. 마우스의 화면 좌표(event.clientX)에서 그걸 빼면, canvas 내부 기준 좌표가 나와요. 이 보정을 안 하면 그린 선이 마우스보다 어긋난 곳에 찍혀요. canvas로 마우스를 다룰 때 거의 항상 하는 보정이라 꼭 기억하세요.

색·굵기·지우개 연결하기

마지막으로 펜 색, 굵기, 전체 지우개 버튼을 붙여요. drawing.html<input type="color">·<input type="range">·버튼을 미리 만들어 뒀어요(A-4에서 배운 입력 태그들이죠).

JavaScript
  // 색·굵기 컨트롤이 있으면 연결해요.
  if (options.colorInput) {
    ctx.strokeStyle = options.colorInput.value;
    options.colorInput.addEventListener("input", (e) => {
      ctx.strokeStyle = e.target.value;
    });
  }
  if (options.sizeInput) {
    ctx.lineWidth = Number(options.sizeInput.value);
    options.sizeInput.addEventListener("input", (e) => {
      ctx.lineWidth = Number(e.target.value);
    });
  }
  // 전체 지우개
  if (options.clearBtn) {
    options.clearBtn.addEventListener("click", () => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
    });
  }
}

색을 고르면 ctx.strokeStyle(선 색)이, 굵기 슬라이더를 움직이면 ctx.lineWidth(선 굵기)가 바뀌어요. 굵기 값에 Number(...)를 씌운 건, <input>이 주는 값이 문자열("4")이라 숫자(4)로 바꿔주는 거예요. 지우개 버튼은 Step 1에서 본 clearRect로 그림판을 통째로 비워요.

🙋 학생 질문 — "튜터님, mousemove 때마다 stroke()를 부르면 너무 많이 불리는 거 아니에요?"

날카로운 질문이에요. 맞아요, mousemove는 마우스가 조금만 움직여도 초당 수십~수백 번 일어나요. 그래서 stroke()도 그만큼 불려요.

그런데 캔버스의 stroke()는 아주 가벼운 작업이라 이 정도로는 끄떡없어요. 직전 점부터 지금 점까지 짧은 선 하나만 긋는 거거든요. 그리고 우리는 if (!drawing) return;으로 버튼을 누른 동안만 그리게 막아뒀죠. 휘젓기만 하는 동안엔 아무 일도 안 해요.

진짜 무거운 그래픽 작업(픽셀 수백만 개 처리 같은)을 매 움직임마다 한다면 그땐 문제가 돼요. 그건 H-1에서 배운 워커에게 넘기거나, 다음 Step에서 배울 requestAnimationFrame으로 묶어서 처리해요. 지금처럼 선 하나 긋는 정도는 걱정 안 해도 괜찮아요.

직접 확인해보세요

drawing.html을 열고 그림판 위에서 마우스를 누른 채 움직여보세요. 선이 따라 그려져요. 색을 빨강으로 바꾸고, 굵기 슬라이더를 30까지 올려보세요. 굵은 빨간 선이 그어지죠. "전체 지우기"를 누르면 싹 지워지고요. 마우스가 가는 곳에 정확히 선이 따라오는지 보세요 — 그게 getBoundingClientRect 보정이 제대로 동작하는 증거예요.

💡 여기까지 정리: 드로잉 보드는 Path 명령(beginPath/moveTo/lineTo/stroke)과 마우스 이벤트(mousedown→시작, mousemove→잇기, mouseup/mouseleave→멈춤)로 동작해요. getBoundingClientRect로 화면 좌표를 canvas 좌표로 보정하는 게 핵심이에요.


Step 4: requestAnimationFrame 60프레임 애니메이션

H-1에서 약속한 그 기능이에요

H-1 마지막에 이런 예고를 했어요. "다음 시간엔 canvas로 실시간 그림을 그리고, requestAnimationFrame으로 초당 60프레임 애니메이션까지 나아간다"고요. 지금이 그 시간이에요.

애니메이션의 원리는 사실 단순해요. 만화 영화(플립북)랑 똑같아요. 조금씩 다른 그림을 빠르게 여러 장 넘기면, 우리 눈엔 움직이는 것처럼 보이죠. canvas 애니메이션도 똑같아요. 화면을 지우고 → 위치를 조금 옮기고 → 다시 그리고를 1초에 60번 반복하면, 움직이는 것처럼 보여요.

텍스트
 [ 애니메이션 한 프레임의 한 바퀴 ]

   clearRect 로 지우기
        
   위치를 속도만큼 옮기기
        
   새 위치에 다시 그리기
        
   requestAnimationFrame 으로 다음 프레임 예약
        
       (로 돌아가 반복 — 초당 약 60번)

다음 그림을 예약하는 requestAnimationFrame

이 반복을 만드는 게 requestAnimationFrame(줄여서 rAF)이에요. 이름이 길지만 뜻은 간단해요. "다음 화면 그릴 때(animation frame) 딱 한 번 이 함수를 불러줘(request)"라는 부탁이에요.

벽에 튕기는 공을 만들어볼게요. 먼저 공의 상태를 정해요.

JavaScript
// instagram-clone-frontend/js/canvas-animation.js

export function setupBouncingBall(canvas, options = {}) {
  const ctx = canvas.getContext("2d");
  const W = canvas.width;
  const H = canvas.height;

  // 공의 상태 — 위치(x, y)와 속도(vx, vy)
  const ball = { x: 60, y: 60, r: 24, vx: 3.2, vy: 2.4 };
  let rafId = null;

ball 객체에 위치(x, y), 반지름(r), 그리고 속도(vx, vy)를 담았어요. vx는 가로 속도, vy는 세로 속도예요. 매 프레임마다 위치에 이 속도를 더하면 공이 움직이는 거죠. rafId는 예약 번호를 기억하는 변수인데, 멈출 때 쓸 거예요.

이제 핵심인 그리기 함수예요.

JavaScript
  function draw() {
    // 1) 지난 프레임을 지워요(안 지우면 잔상이 길게 남아요).
    ctx.clearRect(0, 0, W, H);

    // 2) 위치를 속도만큼 옮기고, 벽에 닿으면 방향을 뒤집어요.
    ball.x += ball.vx;
    ball.y += ball.vy;
    if (ball.x - ball.r < 0 || ball.x + ball.r > W) ball.vx *= -1;
    if (ball.y - ball.r < 0 || ball.y + ball.r > H) ball.vy *= -1;

    // 3) 새 위치에 공을 그려요.
    ctx.beginPath();
    ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
    ctx.fillStyle = "#0095f6";
    ctx.fill();

    // 4) 다음 프레임을 예약해요 — 브라우저가 다음 새로고침 직전에 draw 를 또 불러요.
    rafId = requestAnimationFrame(draw);
  }

아까 그림으로 본 네 단계가 그대로 코드가 됐어요.

1번은 clearRect로 지난 그림을 지워요. 이걸 안 하면 공이 지나간 자리가 다 남아서 길게 번진 자국이 돼요(직접 빼보면 재밌어요).

2번에서 ball.x += ball.vx로 위치를 속도만큼 옮겨요. 그리고 벽 충돌 처리가 나와요. 공의 가장자리(ball.x - ball.r 또는 ball.x + ball.r)가 벽을 넘으면, 속도에 -1을 곱해 방향을 뒤집어요. 오른쪽으로 가던 게 왼쪽으로 바뀌는 거죠. 이 한 줄로 공이 벽에서 튕겨요.

3번은 Step 1에서 배운 arc로 새 위치에 공을 그려요.

4번 rafId = requestAnimationFrame(draw)가 마법이에요. 함수 안에서 자기 자신(draw)을 다음 프레임에 또 예약해요. 그럼 다음 화면 그릴 때 draw가 또 불리고, 그 안에서 또 예약하고... 이게 끝없이 반복되면서 공이 계속 움직여요.

멈출 땐 cancelAnimationFrame

시작이 있으면 멈춤도 있어야죠. 예약을 취소하는 게 cancelAnimationFrame이에요.

JavaScript
  function start() {
    if (rafId === null) draw();
  }

  function stop() {
    if (rafId !== null) {
      cancelAnimationFrame(rafId); // 예약을 취소해 멈춰요.
      rafId = null;
    }
  }

startdraw를 한 번 불러 반복을 시작해요. stopcancelAnimationFrame(rafId)예약된 다음 프레임을 취소해요. 예약이 끊기면 반복도 멈추죠. 아까 기억해 둔 rafId(예약 번호)를 여기서 쓰는 거예요.

if (rafId === null)·if (rafId !== null) 검사는 이미 돌고 있는데 또 시작하거나, 이미 멈췄는데 또 멈추는 걸 막는 안전장치예요.

setInterval 대신 rAF를 쓰는 이유

"1초에 60번 반복? 그럼 setInterval로 16밀리초마다 부르면 되잖아요?" 싶을 수 있어요. 맞는 생각이지만, requestAnimationFrame이 애니메이션엔 더 좋아요. 이유는 두 가지예요.

첫째, rAF는 브라우저의 화면 새로고침 주기에 정확히 맞춰 불려요. 화면은 보통 1초에 60번 새로 그려지는데, rAF는 딱 그 직전에 불려서 그림이 부드럽고 어긋남이 없어요. setInterval은 화면 주기와 무관하게 불려서 미세하게 끊겨 보일 수 있어요.

둘째, rAF는 탭이 안 보이면 자동으로 멈춰요. 다른 탭을 보고 있을 때는 굳이 그릴 필요가 없으니, 브라우저가 알아서 쉬게 해줘서 배터리·성능을 아껴요. setInterval은 안 보이는 탭에서도 계속 돌아 자원을 낭비하고요.

직접 확인해보세요

animation.html을 열면 파란 공이 그림판 안에서 벽에 튕기며 돌아다녀요. "멈춤"을 누르면 그 자리에 서고, "시작"을 누르면 다시 굴러가요. 재밌는 실험을 해보세요. 코드에서 ctx.clearRect(0, 0, W, H) 줄을 잠깐 지워보세요. 공이 지나간 자리가 전부 남아서, 파란 선으로 그림이 그려져요. 이걸 보면 "매 프레임 지우고 다시 그린다"가 왜 필요한지 한눈에 이해돼요. (확인했으면 다시 살려두세요.)

💡 여기까지 정리: 애니메이션은 "지우고 → 옮기고 → 그리고 → 예약" 반복이에요. requestAnimationFrame이 다음 프레임에 함수를 다시 예약하고, cancelAnimationFrame으로 멈춰요. rAF는 화면 주기에 맞춰 부드럽고, 안 보이는 탭에선 자동으로 쉬어서 setInterval보다 애니메이션에 좋아요.


Step 5: 스토리 편집기 프로토타입

오늘의 종합 실습 — 사진 위에 글자 얹기

지금까지 배운 걸 한데 모아 인스타 스토리 편집기를 만들어요. 사진을 깔고, 글자를 얹고, 끌어서 옮기고, PNG로 저장하는 거예요. Step 1의 도형·텍스트, Step 3의 마우스 드래그가 전부 여기 모여요. 오늘의 하이라이트예요.

핵심 아이디어는 하나예요. render()라는 함수 하나가 "사진 → 글자들" 전체를 처음부터 다시 그린다. 글자를 추가하든 옮기든, 무슨 일이 일어나면 그냥 render()를 다시 불러요. 그럼 항상 최신 상태로 새로 그려지죠. 애니메이션에서 "매 프레임 다시 그린다"와 같은 발상이에요.

JavaScript
// instagram-clone-frontend/js/story-editor.js

export function setupStoryEditor(canvas, options = {}) {
  const ctx = canvas.getContext("2d");

  const texts = [];    // 화면에 얹은 글자들 — 각자 위치(x, y)·내용·색을 가져요.
  let photo = null;    // 배경 사진(불러오면 채워져요)
  let dragging = null; // 지금 끌고 있는 글자

세 변수가 편집기의 전부예요. texts는 얹은 글자들의 목록(각 글자는 위치·내용·색을 가진 객체), photo는 배경 사진, dragging은 지금 끌고 있는 글자예요.

배경 사진 불러오기 — drawImage와 crossOrigin

먼저 배경 사진을 불러와요. H-1에서 다른 도메인 사진을 다룰 때 crossOrigin 허락을 받았던 거 기억나죠? 여기서도 똑같아요.

JavaScript
  // 배경 사진을 불러와요(다른 도메인이라 crossOrigin 허락을 받아요).
  function loadPhoto(src) {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => {
      photo = img;
      render();
    };
    img.src = src;
  }

new Image()로 사진을 새로 만들고, crossOrigin = "anonymous"로 픽셀 사용 허락을 받아요. 나중에 PNG로 저장하려면(이게 픽셀을 읽는 일이라) 이 허락이 꼭 필요해요. 사진이 다 로드되면(onload) photo에 담고 render()를 불러 화면에 그려요.

render() — 전체를 다시 그리는 한 함수

이게 편집기의 심장이에요.

JavaScript
  // 전체를 다시 그려요: 사진 → 글자들 순서(나중에 넣은 글자가 위에).
  function render() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    if (photo) {
      ctx.drawImage(photo, 0, 0, canvas.width, canvas.height);
    } else {
      ctx.fillStyle = "#efefef";
      ctx.fillRect(0, 0, canvas.width, canvas.height);
    }
    ctx.font = "bold 32px sans-serif";
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    for (const t of texts) {
      ctx.fillStyle = t.color;
      ctx.fillText(t.text, t.x, t.y);
    }
  }

순서가 중요해요. 먼저 clearRect로 싹 지우고, drawImage배경 사진을 깔고, 그 위에 글자들을 차례로 찍어요. 사진을 먼저 그려야 글자가 사진 위에 올라오죠. 그림 그릴 때 배경부터 칠하는 거랑 같아요.

ctx.drawImage(photo, 0, 0, canvas.width, canvas.height)는 사진을 (0, 0)부터 canvas 크기에 꽉 맞게 그려요. H-1에선 픽셀을 읽으려고 drawImage를 썼지만, 여기선 진짜 보여주려고 쓰는 거예요.

글자를 찍기 전 textAlign = "center"textBaseline = "middle"을 줬어요. textAlign: center는 글자를 가로 가운데 정렬, textBaseline: middle은 세로 가운데 정렬이에요. 둘을 합치면 (x, y)가 글자의 정중앙이 돼요. 글자를 끌어 옮길 때 중심을 기준으로 잡으니 자연스럽죠. 그리고 for...oftexts 목록을 돌며 각 글자를 제 위치·제 색으로 찍어요.

글자 추가하기

JavaScript
  // 글자 하나 추가 — 한가운데에서 시작해요.
  function addText(value, color) {
    texts.push({ text: value, x: canvas.width / 2, y: canvas.height / 2, color });
    render();
  }

글자를 추가하면 texts 목록에 새 객체를 push(C-4에서 배운 배열 메서드죠)하고 render()를 불러요. 새 글자는 화면 한가운데(canvas.width / 2, canvas.height / 2)에서 시작해요. 추가하자마자 render()가 다시 그리니까 바로 화면에 떠요.

드래그로 글자 옮기기

Step 3의 드로잉 보드와 같은 마우스 이벤트 패턴이에요. 단, 이번엔 선을 긋는 게 아니라 글자를 잡아서 끌어요.

JavaScript
  function getPos(event) {
    const rect = canvas.getBoundingClientRect();
    // 보이는 크기와 실제 canvas 해상도가 다를 수 있어 비율로 보정해요.
    const scaleX = canvas.width / rect.width;
    const scaleY = canvas.height / rect.height;
    return {
      x: (event.clientX - rect.left) * scaleX,
      y: (event.clientY - rect.top) * scaleY,
    };
  }

getPos가 Step 3보다 한 단계 더 정교해졌어요. Step 3에선 화면 위치만 뺐는데, 여기선 scaleX·scaleY비율 보정까지 해요. 왜냐면 우리 canvas는 실제 해상도가 480×600인데, CSS의 max-width: 100% 때문에 화면엔 더 작게 줄어 보일 수 있거든요. 보이는 크기와 실제 크기가 다르면, 그 비율만큼 곱해줘야 마우스가 정확한 글자를 잡아요.

JavaScript
  // 누른 지점 근처의 글자를 잡아요.
  canvas.addEventListener("mousedown", (event) => {
    const { x, y } = getPos(event);
    dragging = texts.find((t) => Math.abs(t.x - x) < 120 && Math.abs(t.y - y) < 28) || null;
  });
  canvas.addEventListener("mousemove", (event) => {
    if (!dragging) return;
    const { x, y } = getPos(event);
    dragging.x = x;
    dragging.y = y;
    render();
  });
  canvas.addEventListener("mouseup", () => {
    dragging = null;
  });

mousedown에서 texts.find(...)(C-4 배열 메서드)로 누른 자리 근처의 글자를 찾아 dragging에 담아요. Math.abs(t.x - x) < 120은 "글자 중심에서 가로 120px 안쪽이면 잡은 걸로 친다"는 뜻이에요. 글자 폭을 넉넉히 잡은 거죠.

mousemove에서 잡은 글자가 있으면(if (!dragging) return;으로 없으면 빠짐), 그 글자의 위치를 마우스 위치로 바꾸고 render()를 다시 불러요. 글자가 마우스를 따라오죠. mouseup에서 dragging을 비워 손을 놓고요. Step 3 드로잉과 완전히 같은 구조인데, 대상이 선에서 글자로 바뀐 거예요.

PNG로 저장하기 — toDataURL

마지막으로 만든 스토리를 이미지 파일로 저장해요. canvas를 PNG로 바꾸는 게 toDataURL이에요.

JavaScript
  // "저장" — canvas 그림을 PNG 데이터로 바꿔 내려받아요.
  if (options.saveBtn) {
    options.saveBtn.addEventListener("click", () => {
      const url = canvas.toDataURL("image/png");
      const link = document.createElement("a");
      link.href = url;
      link.download = "my-story.png";
      link.click();
    });
  }

canvas.toDataURL("image/png")이 canvas의 현재 그림을 통째로 PNG 이미지 데이터로 바꿔줘요. 그 데이터를 <a> 링크의 href에 넣고, download 속성으로 파일 이름을 정한 다음, link.click()으로 다운로드를 실행해요. 다운로드 링크를 JS로 만들어서 자동으로 클릭하는 거죠.

⚠️ crossOrigin 없으면 저장이 막혀요: toDataURL은 canvas의 픽셀을 통째로 읽는 일이에요. 그래서 배경 사진이 다른 도메인에서 왔는데 crossOrigin 허락을 안 받았다면, 브라우저가 "오염된 canvas"라며 저장을 막아요(H-1에서 본 그 보안 규칙이에요). 그래서 loadPhoto에서 crossOrigin = "anonymous"를 꼭 챙긴 거예요. 픽셀을 읽으려면(필터든 저장이든) 항상 이 허락이 필요해요.

직접 확인해보세요

story-editor.html을 열면 배경 사진이 깔린 편집기가 떠요. 입력칸에 글자를 넣고 "글자 추가"를 누르면 사진 한가운데에 글자가 얹혀요. 그 글자를 마우스로 끌어 원하는 자리로 옮겨보세요. 색을 바꿔 글자를 여러 개 추가해도 되고요. 다 꾸몄으면 "PNG 저장"을 눌러보세요. my-story.png 파일이 다운로드돼요. 우리가 canvas로 만든 스토리가 진짜 이미지 파일로 손에 들어오는 순간이에요.

💡 여기까지 정리: 스토리 편집기는 render() 한 함수가 "사진 → 글자들" 전체를 다시 그리는 패턴이에요. drawImage로 배경, fillText로 글자를 얹고, 드래그(Step 3 패턴)로 글자를 옮기고, toDataURL로 PNG를 내려받아요. 오늘 배운 모든 게 여기 모였어요.


Step 6: Canvas로 미니 차트 직접 그리기

canvas는 범용 도구예요

지금까지 canvas로 도형, 글자, 그림, 애니메이션을 그렸어요. 이번엔 데이터를 그림으로 그려봐요. "내 게시물의 주간 좋아요 추이"를 막대 그래프로 만드는 거예요.

여기서 중요한 깨달음 하나. 막대 그래프도 결국 사각형(막대) + 글자(라벨)예요. 우리가 Step 1·2에서 배운 fillRectfillText 두 개면 충분해요. 차트 라이브러리 없이, 손으로 직접 그릴 수 있다는 거죠. canvas가 "무엇이든 그릴 수 있는 범용 도구"라는 걸 여기서 실감하게 돼요.

먼저 데이터예요.

JavaScript
// instagram-clone-frontend/js/mini-chart.js

// 요일별 좋아요 수 — "내 게시물 주간 좋아요 추이"
const weeklyLikes = [
  { label: "월", value: 120 },
  { label: "화", value: 200 },
  { label: "수", value: 150 },
  { label: "목", value: 280 },
  { label: "금", value: 240 },
  { label: "토", value: 360 },
  { label: "일", value: 300 },
];

요일과 좋아요 수를 객체 배열로 담았어요(C-4에서 익힌 형태죠). 이 숫자들을 막대 높이로 바꿔 그릴 거예요.

값을 막대 높이로 — 비례 계산

좋아요 360개와 120개를 어떻게 막대로 그릴까요? 차트 영역 높이에 비례해서 그려요. 가장 큰 값(360)을 기준으로, 각 값이 그 몇 분의 몇인지 계산하는 거죠.

JavaScript
export function drawBarChart(ctx, data) {
  const W = ctx.canvas.width;
  const H = ctx.canvas.height;
  const padding = 40;             // 축 둘레 여백
  const chartH = H - padding * 2; // 막대가 자라는 높이
  const max = Math.max(...data.map((d) => d.value));
  const slot = (W - padding * 2) / data.length; // 막대 하나가 차지하는 폭
  const barW = slot * 0.6;

차근차근 볼게요.

  • padding = 40 — 그래프 둘레 여백이에요. 막대가 그림판 끝에 딱 붙으면 답답하니, 사방에 40px씩 여백을 둬요.
  • chartH — 막대가 자랄 수 있는 높이예요. 전체 높이에서 위아래 여백을 뺀 거죠.
  • max = Math.max(...data.map(...)) — 데이터 중 가장 큰 값을 찾아요(여기선 360). map으로 값만 뽑고(C-4!), Math.max로 최댓값을 구해요. 이 최댓값이 막대 높이의 기준이 돼요.
  • slot — 막대 하나가 차지하는 가로 폭이에요. 가로 공간을 막대 개수(7개)로 나눈 거죠.
  • barW = slot * 0.6 — 실제 막대 폭은 그 칸의 60%만 써요. 나머지 40%는 막대 사이 간격이 되고요.

바닥 축선과 막대 그리기

먼저 바닥에 축선 하나를 긋고(Path API!), 막대를 하나씩 그려요.

JavaScript
  ctx.clearRect(0, 0, W, H);

  // 바닥 축선
  ctx.strokeStyle = "#dbdbdb";
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(padding, H - padding);
  ctx.lineTo(W - padding, H - padding);
  ctx.stroke();

바닥 축선은 Step 3에서 배운 beginPath/moveTo/lineTo/stroke로 가로선 하나를 긋는 거예요. 왼쪽 여백에서 오른쪽 여백까지, 바닥(H - padding) 높이에요.

이제 막대인데, 여기 오늘의 핵심 좌표 함정이 나와요.

JavaScript
  data.forEach((d, i) => {
    const barH = (d.value / max) * chartH;  // 값에 비례한 높이
    const x = padding + slot * i + (slot - barW) / 2;
    const y = (H - padding) - barH;         // 좌상단 원점이라, 바닥에서 위로 자라요

    // 막대
    ctx.fillStyle = "#0095f6";
    ctx.fillRect(x, y, barW, barH);

barH = (d.value / max) * chartH가 비례 계산이에요. 값을 최댓값으로 나눈 비율(예: 120/360 = 0.33)에 차트 높이를 곱하면, 그 값에 맞는 막대 높이가 나와요. 360은 꽉 찬 높이, 120은 그 3분의 1 높이가 되죠.

문제는 y예요. canvas는 왼쪽 위가 (0, 0)이고 y는 아래로 커진다고 Step 1에서 배웠죠? 그런데 막대는 바닥에서 위로 자라야 해요. 이 둘이 충돌해요.

텍스트
 우리가 원하는 것:  막대가 바닥에서 위로 자람
 canvas 의 현실:    y 가 아래로 커짐 (위가 0)

  y = 0  ┬  화면 맨 위
         │   ┌──┐  막대 꼭대기 y = (바닥) - 막대높이
         │   │  │
         │ ┌─┤  ├─┐
  바닥   ┴─┴─┴──┴─┴──  y = H - padding
         (여기서 위로 barH 만큼 빼야 막대 꼭대기 y)

그래서 y = (H - padding) - barH예요. 바닥 위치에서 막대 높이만큼 빼야 막대 꼭대기의 y가 나와요. fillRect는 왼쪽 위 모서리부터 그리니까, 막대 꼭대기 좌표를 줘야 하거든요. "좌상단 원점이라 바닥에서 위로" — 이 좌표 뒤집기가 canvas로 차트를 그릴 때 늘 만나는 부분이에요.

라벨 얹기

막대 위에 값, 축 아래에 요일을 찍어요.

JavaScript
    // 값 라벨(막대 위)
    ctx.fillStyle = "#262626";
    ctx.font = "12px sans-serif";
    ctx.textAlign = "center";
    ctx.fillText(String(d.value), x + barW / 2, y - 6);

    // 요일 라벨(축 아래)
    ctx.fillStyle = "#6e6e6e";
    ctx.fillText(d.label, x + barW / 2, H - padding + 16);
  });
}

값 라벨은 막대 위(y - 6, 막대 꼭대기보다 6px 위)에, 요일 라벨은 축 아래(H - padding + 16)에 찍어요. 둘 다 x + barW / 2로 막대의 가로 중심에 놓고, textAlign = "center"로 가운데 정렬해서 막대 정중앙에 오게 했어요. Step 2의 fillText가 그대로 쓰였죠. String(d.value)는 숫자를 글자로 바꾼 거예요(fillText는 글자를 받으니까요).

직접 확인해보세요

stats.html을 열면 위쪽에 우리가 손으로 그린 막대 그래프가 떠요. 토요일(360)이 가장 높고, 월요일(120)이 가장 낮죠. weeklyLikes의 값을 바꿔보세요 — 예를 들어 토요일을 900으로 올리면, 다른 막대들이 상대적으로 작아져요. max가 900으로 바뀌니까 모든 막대가 그 기준으로 다시 비례 계산되거든요. 라이브러리 한 줄 없이, 우리가 배운 fillRect·fillText만으로 진짜 차트를 그렸어요.

💡 여기까지 정리: 막대 그래프는 fillRect(막대) + fillText(라벨)로 직접 그릴 수 있어요. 값을 최댓값에 비례시켜 막대 높이를 구하고, canvas가 위에서 아래로 커지니 y = 바닥 - 높이로 좌표를 뒤집어요. canvas는 데이터 시각화까지 되는 범용 도구예요.


Step 7: Chart.js CDN으로 한 줄에

같은 차트를 라이브러리로

Step 6에서 막대 그래프를 손으로 그렸어요. 비례 계산하고, 좌표 뒤집고, 라벨 얹고 — 꽤 손이 갔죠? 이번엔 같은 차트를 시각화 라이브러리로 그려봐요. 결과를 비교하면 "직접 그리기"와 "라이브러리"의 차이가 확 와닿아요.

우리가 쓸 건 Chart.js라는 차트 라이브러리예요. 그런데 한 가지 짚고 갈 게 있어요. 우리 과목은 그동안 외부 라이브러리를 설치하지 않았어요. 그런데 Chart.js는 어떻게 쓸까요? CDN 링크로 가져다 써요.

텍스트
 [ 설치(npm) ]                    [ CDN <script> ]

  내 컴퓨터에 라이브러리를          남의 서버에 올려둔 라이브러리를
  내려받아 둠                       <script> 한 줄로 불러옴
  (우리 과목은 이건 안 해요)         (이건 OK — 가져다 쓰기만)

CDN(Content Delivery Network)은 라이브러리 파일을 인터넷 어딘가에 올려둔 서버예요. <script> 태그로 그 주소를 적으면, 브라우저가 그 파일을 받아와 바로 쓸 수 있어요. 설치 없이 링크만으로 가져다 쓰는 방식이죠.

HTML
<!-- instagram-clone-frontend/stats.html (head 안) -->
<!-- Chart.js — npm 설치 없이 CDN으로 불러와 전역 Chart 를 써요 (4.5.1) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>

이 한 줄이면 Chart.js가 준비돼요. @4.5.1은 버전이에요. 버전을 콕 적어두면, 나중에 라이브러리가 바뀌어도 우리 차트가 안 깨져요.

전역 Chart를 쓰기 — new Chart

CDN으로 불러온 라이브러리는 전역(window)에 객체를 하나 올려둬요. Chart.js는 window.Chart를 올려둬요. 우리가 모듈에서 import하는 게 아니라, 이미 전역에 떡 하니 놓인 걸 꺼내 쓰는 거죠.

JavaScript
// instagram-clone-frontend/js/like-chart.js
// Chart 는 CDN <script> 가 전역(window)에 올려둔 객체예요(import 가 아니에요).

const weeklyLikes = [
  { label: "월", value: 120 },
  { label: "화", value: 200 },
  { label: "수", value: 150 },
  { label: "목", value: 280 },
  { label: "금", value: 240 },
  { label: "토", value: 360 },
  { label: "일", value: 300 },
];

const target = document.getElementById("like-chart");
const ChartLib = window.Chart; // CDN 이 올려둔 전역 Chart

Step 6과 똑같은 데이터(weeklyLikes)예요. 그리고 window.ChartChartLib에 담았어요. 이게 라이브러리 본체예요.

이제 차트를 그려요. Step 6에선 50줄 넘게 그렸던 걸, 여기선 객체 하나만 넘기면 끝나요.

JavaScript
if (target && ChartLib) {
  new ChartLib(target, {
    type: "bar",
    data: {
      labels: weeklyLikes.map((d) => d.label),
      datasets: [
        {
          label: "주간 좋아요",
          data: weeklyLikes.map((d) => d.value),
          backgroundColor: "#0095f6",
          borderRadius: 6,
        },
      ],
    },
    options: {
      responsive: true,
      plugins: { legend: { display: false } },
      scales: { y: { beginAtZero: true } },
    },
  });
}

new ChartLib(target, {...}) 한 번이 전부예요. 첫 번째는 차트를 그릴 canvas, 두 번째는 설정 객체예요. 설정을 읽어볼게요.

  • type: "bar" — 막대 차트로 그려라.
  • data.labels — x축 라벨(요일). map으로 라벨만 뽑았어요(C-4!).
  • data.datasets[0].data — 막대 값(좋아요 수). 이것도 map으로 값만 뽑고요.
  • backgroundColor / borderRadius — 막대 색과 모서리 둥글기.
  • options — 반응형으로(responsive), 범례는 숨기고(legend.display: false), y축은 0부터 시작(beginAtZero).

Step 6에서 우리가 손으로 했던 비례 계산, 좌표 뒤집기, 축 그리기, 라벨 정렬을 Chart.js가 전부 알아서 해줘요. 우리는 "이런 모양으로 그려줘"라고 설정만 넘긴 거죠.

직접 그리기 vs 라이브러리 — 트레이드오프

같은 차트를 두 방식으로 그려봤으니, 차이를 정리해봐요.

텍스트
 [ 직접 그리기 (Step 6) ]          [ Chart.js (Step 7) ]

  fillRect · fillText 손코딩         설정 객체 한 번 넘기면 끝
  비례 계산 · 좌표 뒤집기 직접        라이브러리가 알아서

  ✓ 완전한 자유 (원하는 모양 다)     ✓ 빠르고 간편
  ✓ 무엇이 어떻게 그려지는지 다 앎   ✓ 축·범례·툴팁·애니메이션 공짜
  ✗ 축·범례·애니메이션 다 직접       ✓ 마우스 올리면 값 표시 등 기능 풍부
  ✗ 손이 많이 감                    ✗ 라이브러리에 의존 (남이 정한 틀)

stats.html을 열면 위(직접 그린 차트)와 아래(Chart.js 차트)가 나란히 떠요. Chart.js 차트는 막대에 마우스를 올리면 값이 툭 뜨고, 처음 뜰 때 막대가 스르륵 자라는 애니메이션도 있어요. 우리가 손으로 그린 건 그런 기능을 일일이 더 만들어야 하지만, Chart.js는 공짜로 줘요.

그럼 라이브러리가 항상 정답일까요? 아니에요. 트레이드오프가 있어요. 직접 그리기는 손이 많이 가지만 원하는 모양을 완전히 자유롭게 만들 수 있어요. 라이브러리는 빠르고 기능이 풍부하지만, 남이 정해둔 틀 안에서 움직여야 해요. 아주 특이한 차트를 만들거나 픽셀 단위로 제어해야 하면 직접 그리는 게 낫고, 흔한 막대·선·원 그래프를 빨리 만들려면 라이브러리가 나아요.

중요한 건, 직접 그릴 줄 알아야 라이브러리도 제대로 쓴다는 거예요. Step 6에서 비례 계산과 좌표를 손으로 해봤으니, Chart.js의 beginAtZero나 축 설정이 무슨 뜻인지 바로 이해되죠. 원리를 알면 도구가 더 잘 보여요.

💡 여기까지 정리: Chart.js는 CDN <script>로 불러와 전역 window.Chart를 써요(설치 아님). new Chart(canvas, {type, data, options}) 한 번이면 차트가 완성돼요. 직접 그리기는 자유롭지만 손이 많이 가고, 라이브러리는 빠르고 기능이 풍부하지만 정해진 틀을 따라요. 원리를 알면 도구를 더 잘 써요.


마무리

오늘 손에 넣은 것

오늘은 H-1에서 픽셀을 읽기만 하던 canvas를, 직접 그리는 도구로 활짝 펼쳤어요. 정리하면 이래요.

텍스트
 도형 · 글자 · 색      fillRect · arc · fillText · gradient
 마우스로 그리기        Path API + 마우스 이벤트 + 좌표 보정
 애니메이션            requestAnimationFrame (지우고·옮기고·그리고·예약)
 스토리 편집기          drawImage + 글자 + 드래그 + toDataURL
 데이터 시각화          직접 막대 차트  vs  Chart.js (트레이드오프)
  • 직접 그리기 — canvas 좌표는 왼쪽 위가 (0,0), y는 아래로 커져요. 사각형은 모서리+크기, 원은 중심+반지름+각도로요. 글자는 fillText, 색 번짐은 그래디언트로 칠해요.
  • 드로잉 보드 — Path 명령과 마우스 이벤트(mousedown/move/up)로 선을 긋고, getBoundingClientRect로 좌표를 보정했어요.
  • requestAnimationFrame — "지우고 → 옮기고 → 그리고 → 예약" 반복으로 초당 60장 애니메이션을 만들었어요. setInterval보다 부드럽고 효율적이고요.
  • 스토리 편집기render() 한 함수가 전체를 다시 그리는 패턴으로, 사진·글자·드래그·PNG 저장을 한 화면에 모았어요.
  • 시각화 — 막대 차트를 손으로 직접 그려보고, Chart.js로도 그려 둘을 비교했어요. 자유 vs 편리의 트레이드오프를 몸으로 느꼈죠.

H-1에서 흑백 필터로 잠깐 빌려 썼던 canvas가, 오늘은 그림판·애니메이션 무대·편집기·차트까지 펼쳐졌어요. 같은 도구 하나가 이렇게 넓게 쓰인다는 게 canvas의 매력이에요.

다음 시간 예고

다음 시간(H-3)엔 Web Components(웹 컴포넌트)를 배워요. 오늘 마지막에 Chart.js를 가져다 썼죠? 그건 남이 만들어 둔 준비된 조각을 링크로 빌려 쓴 거예요. 다음 시간엔 거꾸로, 라이브러리 없이 내 전용 태그를 직접 만들어요.

예를 들어 <like-button>이나 <post-card> 같은 나만의 HTML 태그를 만들어, 한 번 만들면 페이지 어디서든 그 태그 하나로 불러 쓰는 거예요. 생김새와 동작을 한 덩어리로 캡슐화하는 거죠. 오늘 "남이 만든 조각을 가져다 쓰는" 경험을 했으니, 다음 시간엔 "내 조각을 직접 만드는" 쪽으로 넘어가요.

오늘은 그릴 것도 많고 좌표 계산도 여럿 나왔어요. 다 외우지 않아도 괜찮아요. "canvas는 붓(ctx)으로 도형·글자를 좌표에 그리는 그림판이고, 애니메이션은 매 프레임 다시 그리는 것" — 이 큰 그림만 잡고 가면, 나머지는 코드를 다시 볼 때마다 또렷해질 거예요. 오늘도 정말 고생 많으셨어요.


과제

[구현] 미니 차트를 선 그래프로 바꾸기

Step 6에서 막대 그래프(fillRect)를 그렸어요. 같은 데이터로 이번엔 선 그래프를 그려보세요. 막대 대신 각 데이터 점을 선으로 잇는 거예요.

  • Step 3에서 배운 Path API(beginPath·moveTo·lineTo·stroke)를 써요.
  • 각 요일의 값을 점 좌표로 바꿔요. x는 padding + slot * i, y는 (H - padding) - (value / max) * chartH (막대 높이 계산과 똑같아요).
  • 첫 점은 moveTo로 붓을 옮기고, 나머지는 lineTo로 이어 그린 뒤 마지막에 stroke() 한 번.
  • 힌트: 점도 같이 찍고 싶으면 각 좌표에 작은 arc로 원을 그려요.

막대든 선이든 "값 → 좌표"로 바꾸는 계산은 똑같아요. 그리는 명령만 fillRect에서 lineTo로 바뀐다는 걸 확인해보세요.

[구현] 튕기는 공 여러 개 띄우기

Step 4에선 공 하나가 튕겼어요. 이번엔 공 여러 개가 동시에 튕기게 만들어보세요.

  • 공 하나(ball) 대신, 공들을 배열에 담아요(C-4에서 배운 배열!). 각 공은 위치·속도·반지름·색을 가져요.
  • draw() 안에서 forEach로 배열을 돌며, 각 공의 위치를 옮기고 벽 충돌을 처리하고 그려요.
  • 색을 공마다 다르게 주면 더 보기 좋아요.
  • 힌트: 공 3~5개를 서로 다른 시작 위치·속도로 만들면 화면이 북적여요.

[탐구] requestAnimationFrame은 setInterval과 뭐가 다를까

Step 4에서 "setInterval보다 부드럽고 효율적"이라고만 했어요. 정말 그런지, 무엇이 다른지 조사해보세요.

  • setInterval(fn, 16)으로도 애니메이션을 만들 수 있어요. 그런데 왜 requestAnimationFrame을 권장할까요?
  • 키워드: "화면 새로고침 주기(refresh rate)", "탭이 안 보일 때(백그라운드 탭)", "프레임 건너뛰기".
  • 정답을 외우기보다, "둘 다 반복은 되는데, 왜 하나가 더 부드럽고 배터리를 덜 쓸까"를 기준으로 정리해보세요.

생각해볼 주제

1. canvas에 그린 글자는 스크린 리더가 못 읽어요

스토리 편집기에서 사진 위에 "오늘 날씨 최고!"라는 글자를 canvas로 그렸다고 해볼게요. 우리 눈엔 분명히 글자가 보여요. 그런데 화면을 못 보는 사용자가 쓰는 스크린 리더에게 canvas는 그냥 "그림 한 장"일 뿐이에요. 그 안에 무슨 글자가 있는지 읽어줄 방법이 없어요. G-1에서 배운 접근성 관점에서, canvas로 만든 화면이 가진 이 한계를 어떻게 보완할 수 있을지 생각해보세요. (<canvas> 안에 대체 텍스트를 넣거나, 중요한 정보는 canvas 밖에도 두는 방법이 있어요.)

2. 직접 그리기와 라이브러리, 언제 무엇을 고를까

Step 7에서 같은 차트를 직접 그려도 보고 Chart.js로도 그려봤어요. 둘 다 되는데, 실무에선 어느 쪽을 골라야 할까요? "흔한 막대·선 그래프를 빨리"라면 라이브러리가 편하지만, 세상에 없던 특이한 시각화를 만들거나 픽셀 단위로 제어해야 하면 직접 그리는 게 나아요. 여러분이 인스타 클론에 "좋아요 수가 실시간으로 물결치는 특이한 그래프"를 넣는다면 어느 쪽을 고를지, 그 판단 기준을 한번 정리해보세요.

3. 부드러운 애니메이션에는 비용이 따라요

requestAnimationFrame은 초당 60번 화면을 다시 그려요. 부드럽지만, 그만큼 컴퓨터가 일을 많이 한다는 뜻이기도 해요. 노트북 배터리나 느린 폰에서는 과한 애니메이션이 화면을 버벅이게 하거나 배터리를 빨리 닳게 할 수 있어요. 또 requestAnimationFrame은 탭이 안 보이면 자동으로 멈추는데, 왜 그렇게 설계됐을지 생각해보세요. "부드러움"과 "비용" 사이에서 어떻게 균형을 잡으면 좋을지도요.

✅ 예시 답안정답 보기

오늘 과제는 "canvas 붓으로 직접 그린 코드를 조금씩 바꿔 보며, 같은 도구로 다른 그림이 나오는 걸 눈으로 확인하는" 거예요. 정답을 외우기보다, 막대를 선으로 바꾸고 공 하나를 여러 개로 늘려 보면서 결과가 어떻게 달라지는지 직접 관찰하는 게 핵심이에요. npm run build로 빌드한 뒤 npm run preview로 결과물을 띄워 두고 진행하세요.


과제 예시답안

🎯 [과제 1 예시답안] 미니 차트를 선 그래프로 바꾸기

핵심 접근

막대 그래프와 선 그래프는 "값을 좌표로 바꾼다"는 같은 틀을 써요. 달라지는 건 그리는 명령뿐이에요. 막대는 fillRect로 칠하고, 선은 moveTo/lineTo로 점을 잇죠. 이 과제의 진짜 목적은 "차트 = 값→좌표 계산 + 그리기 명령"이라는 구조를 손으로 확인하는 거예요.

답안

mini-chart.jsforEach 안에서 막대를 칠하던 부분을, 점을 잇는 방식으로 바꿔요. 좌표 계산(x, y)은 막대 때와 완전히 똑같아요. 첫 점만 moveTo로 붓을 옮기고, 나머지는 lineTo로 이어 그린 뒤 마지막에 stroke()를 딱 한 번 불러요.

JavaScript
// instagram-clone-frontend/js/mini-chart.js (예시 — 선 그래프 버전)
export function drawLineChart(ctx, data) {
  const W = ctx.canvas.width;
  const H = ctx.canvas.height;
  const padding = 40;
  const chartH = H - padding * 2;
  const max = Math.max(...data.map((d) => d.value));
  const slot = (W - padding * 2) / data.length;

  ctx.clearRect(0, 0, W, H);

  // 선 하나로 모든 점을 이어요.
  ctx.strokeStyle = "#0095f6";
  ctx.lineWidth = 2;
  ctx.beginPath();
  data.forEach((d, i) => {
    const x = padding + slot * i + slot / 2; // 칸의 가로 중심
    const y = (H - padding) - (d.value / max) * chartH; // 막대 높이 계산과 동일
    if (i === 0) {
      ctx.moveTo(x, y); // 첫 점은 붓을 들어 옮기고
    } else {
      ctx.lineTo(x, y); // 나머지는 직전 점에서 선을 이어요
    }
  });
  ctx.stroke(); // 계획한 선을 한 번에 그어요
}

점을 같이 찍고 싶으면, 좌표를 구한 자리에 작은 arc로 원을 하나씩 그려요.

JavaScript
// 각 점을 강조하고 싶을 때 — forEach 안에 추가
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = "#0095f6";
ctx.fill();

값을 좌표로 바꾸는 계산은 막대와 한 글자도 안 달라요. 그리는 명령만 갈아 끼운 거죠.

텍스트
 같은 틀, 그리는 명령만 다름

 막대   fillRect(x, y, barW, barH)    칸마다 사각형을 칠함
 선    moveTo / lineTo + stroke      점들을 이어 한 줄로
          └ "값  좌표(x, y)" 계산은 똑같아요

채점 포인트

포인트 설명 배점 가중
좌표 계산 재사용 x·y 계산을 막대 버전 그대로(값→좌표 비례) 가져왔는가
Path 흐름 첫 점 moveTo, 나머지 lineTo, 마지막 stroke() 한 번 구조인가
beginPath 위치 선을 잇기 전에 beginPath()로 새 경로를 시작했는가
점 강조(선택) 각 좌표에 작은 arc로 점을 찍었는가

흔한 실수

  • lineTo마다 stroke()를 부름 — 점을 이을 때마다 stroke()를 불러도 선은 그려져요. 하지만 선을 다 이은 뒤 마지막에 한 번만 부르는 게 정석이에요. 매번 부르면 같은 구간을 겹쳐 그어 선이 진해지거나 굵기가 들쭉날쭉해질 수 있어요.
  • 첫 점도 lineTo로 그림 — 첫 점을 lineTo로 시작하면, 직전 경로의 끝(보통 0,0)에서 첫 점까지 엉뚱한 선이 그어져요. 첫 점은 반드시 moveTo로 "붓을 들어" 옮겨야 해요.
  • beginPath()를 빼먹음 — 앞에서 그린 바닥 축선 같은 경로가 그대로 이어져, 의도치 않은 선이 함께 그어질 수 있어요. 새 도형을 시작할 땐 beginPath()로 깔끔하게 떼어 주세요.

실무 개선 포인트 (심화)

  • 점과 점 사이를 곧은 직선이 아니라 부드러운 곡선으로 잇고 싶을 때가 있어요. canvas에는 quadraticCurveTo·bezierCurveTo 같은 곡선 명령이 있어, 직선 lineTo 자리에 끼우면 물 흐르듯 이어진 그래프가 돼요. 원리는 똑같아요 — "점을 잇는 명령"만 바뀌는 거죠.
  • 선 아래를 색으로 채우면 "영역 차트(area chart)"가 돼요. 마지막 점에서 바닥까지 lineTo로 내려갔다가 첫 점 바닥으로 돌아와 경로를 닫고(closePath) fill()을 부르면 끝이에요. 막대→선→영역으로, 같은 데이터를 같은 좌표 계산으로 여러 모양으로 보여 줄 수 있다는 게 직접 그리기의 힘이에요.

🎯 [과제 2 예시답안] 튕기는 공 여러 개 띄우기

핵심 접근

공 하나를 여러 개로 늘리는 핵심은 "공 하나(ball)를 공들의 배열(balls)로 바꾸고, draw() 안에서 forEach로 똑같은 일을 모든 공에 반복하는" 거예요. 움직이고·튕기고·그리는 로직은 그대로 두고, 그걸 공마다 한 번씩 돌리기만 하면 돼요. C-4에서 배운 배열과 forEach가 그대로 쓰이는 과제예요.

답안

canvas-animation.js에서 공 하나를 담던 ball 객체를, 여러 공을 담는 balls 배열로 바꿔요. 그리고 draw() 안의 "옮기고·튕기고·그리고"를 forEach로 감싸요.

JavaScript
// instagram-clone-frontend/js/canvas-animation.js (예시 — 공 여러 개 버전)
export function setupBouncingBalls(canvas, options = {}) {
  const ctx = canvas.getContext("2d");
  const W = canvas.width;
  const H = canvas.height;

  // 공 하나가 아니라, 공들을 배열에 담아요(각자 위치·속도·반지름·색).
  const balls = [
    { x: 60, y: 60, r: 24, vx: 3.2, vy: 2.4, color: "#0095f6" },
    { x: 200, y: 120, r: 16, vx: -2.6, vy: 3.0, color: "#ed4956" },
    { x: 320, y: 80, r: 30, vx: 2.0, vy: -2.2, color: "#feda75" },
  ];
  let rafId = null;

  function draw() {
    ctx.clearRect(0, 0, W, H); // 한 번만 싹 지워요

    // 모든 공에 같은 일을 반복해요.
    balls.forEach((ball) => {
      // 옮기고
      ball.x += ball.vx;
      ball.y += ball.vy;
      // 벽에 닿으면 방향을 뒤집고
      if (ball.x - ball.r < 0 || ball.x + ball.r > W) ball.vx *= -1;
      if (ball.y - ball.r < 0 || ball.y + ball.r > H) ball.vy *= -1;
      // 그려요
      ctx.beginPath();
      ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
      ctx.fillStyle = ball.color;
      ctx.fill();
    });

    rafId = requestAnimationFrame(draw); // 다음 프레임 예약
  }

  start();

  function start() {
    if (rafId === null) draw();
  }
}

원래 코드에서 공 하나에 하던 일을 balls.forEach(...)로 감싼 게 전부예요. 공이 3개든 10개든 배열에 넣기만 하면, forEach가 알아서 다 돌려 줘요.

텍스트
 공 하나                          공 여러 개
 ball = { ... }                  balls = [ {...}, {...}, {...} ]

 draw() 안:                       draw() 안:
   옮기고·튕기고·그리고              balls.forEach(ball => {
                                     옮기고·튕기고·그리고
                                   })
   └ 한 번 실행                      └ 공 개수만큼 반복

채점 포인트

포인트 설명 배점 가중
배열 전환 공 하나(ball)를 공들의 배열(balls)로 바꿨는가
forEach 반복 draw() 안에서 forEach로 모든 공을 돌며 처리했는가
clearRect 위치 화면 지우기는 반복 에서 한 번만 했는가
공별 속성 각 공이 자기 위치·속도·반지름·색을 가지는가

흔한 실수

  • clearRectforEach 안에 넣음 — 공마다 화면을 지우면, 마지막 공만 남고 앞 공들이 지워져요. 화면 지우기는 한 프레임에 딱 한 번, 반복 밖에서 해야 모든 공이 함께 보여요.
  • 모든 공이 같은 객체를 공유함 — 공 하나를 만들어 배열에 세 번 push하면, 셋이 같은 객체라 똑같이 움직여요. 공마다 { ... }로 새 객체를 따로 만들어야 따로 놀아요.
  • requestAnimationFrameforEach 안에 넣음 — 다음 프레임 예약은 한 프레임에 한 번이면 충분해요. 반복 안에 넣으면 공 개수만큼 예약돼서 애니메이션이 점점 빨라지고 무거워져요. forEach , draw() 끝에 한 번만 두세요.

실무 개선 포인트 (심화)

  • 공이 수백 개로 늘면, 공끼리 부딪치는 처리(충돌 감지)가 필요해질 수 있어요. 모든 공을 서로 다 비교하면 개수가 많아질수록 일이 급격히 늘어나요. 실무에선 화면을 격자로 나눠 "가까운 공끼리만 비교"하는 식으로 일을 줄여요. 지금은 벽 충돌만으로 충분하지만, "개수가 늘면 비용도 늘어난다"는 감각은 알아 두면 좋아요.
  • 지금은 모든 공을 balls 배열 하나로 관리하는데, 공을 추가·삭제하는 버튼을 붙이면 push·splice(C-4에서 배운 배열 메서드)로 실시간 조작할 수 있어요. "데이터는 배열에, 그리기는 그 배열을 도는 함수에" — 이 분리가 화면을 자유롭게 다루는 기본 틀이에요.

🎯 [과제 3 예시답안] requestAnimationFrame은 setInterval과 뭐가 다를까

핵심 접근

정답을 외우는 과제가 아니에요. "둘 다 반복은 되는데, 왜 하나가 더 부드럽고 배터리를 덜 쓸까"라는 한 가지 질문으로 둘의 차이를 정리하는 거예요. 핵심은 세 가지 — 화면 새로고침 주기와 맞느냐, 안 보이는 탭에서 멈추느냐, 프레임 관리를 누가 하느냐예요.

답안

두 방식의 차이를 표로 정리하면 이래요.

항목 requestAnimationFrame setInterval(fn, 16)
호출 시점 화면 새로고침 직전에 딱 맞춰 고정 간격(16ms)마다
화면 주기와 맞음 맞음 (부드러움) 어긋날 수 있음 (끊겨 보임)
안 보이는 탭 자동으로 멈춤 (배터리 절약) 계속 돎 (자원 낭비)
프레임 관리 브라우저가 알아서 개발자가 간격을 직접 지정

조금 더 풀어 쓰면 이래요.

텍스트
 requestAnimationFrame
   화면이 새로 그려지기 직전 ── draw 호출 ── (다음 새로고침 직전 또 호출)
   └ 화면 주기에 딱 맞아 부드럽고, 안 보이는 탭에선 브라우저가 알아서 쉼

 setInterval(fn, 16)
   16ms ── fn ── 16ms ── fn ── ... (화면 주기와 무관하게 계속)
   └ 화면 주기와 어긋나면 프레임이 밀리거나 끊기고, 탭이 숨어도 계속 돎

requestAnimationFrame은 "다음 화면 그릴 때 한 번 불러 줘"라고 브라우저에 맡기는 방식이에요. 그래서 화면 새로고침 주기(보통 1초에 60번)에 정확히 맞춰 불려서 그림이 부드럽고, 다른 탭을 보고 있으면 그릴 필요가 없으니 브라우저가 알아서 멈춰 배터리를 아껴요.

setInterval(fn, 16)은 "16밀리초마다 무조건 불러"라고 고정 간격을 직접 정하는 방식이에요. 화면 새로고침 주기와 딱 안 맞으면 프레임이 미세하게 밀리거나 끊겨 보일 수 있고, 탭이 숨어도 계속 돌아 자원과 배터리를 낭비해요.

그래서 애니메이션처럼 "화면을 부드럽게 다시 그리는 일"에는 requestAnimationFrame이 어울려요. 반대로 "3초마다 새 알림 확인" 같은 화면 그리기와 무관한 주기 작업엔 setInterval이 맞고요. 도구마다 어울리는 자리가 다른 거예요.

채점 포인트

포인트 설명 배점 가중
화면 주기 동기화 rAF가 화면 새로고침 주기에 맞춰 불린다는 점을 설명했는가
백그라운드 탭 안 보이는 탭에서 rAF는 멈추고 setInterval은 계속 돈다는 점을 짚었는가
비교 정리 표나 도식으로 둘을 나란히 비교했는가
어울리는 자리 각각 언제 쓰는 게 맞는지 짝지었는가

흔한 실수

  • "setInterval이 더 빠르다/느리다"로 결론 — 속도의 문제가 아니에요. 핵심은 화면 주기와 맞느냐예요. setInterval은 화면과 무관하게 돌아서 어긋나면 끊겨 보이는 거지, 느린 게 아니에요.
  • 백그라운드 탭 동작을 안 짚음 — rAF의 가장 큰 장점 하나가 "안 보이면 멈춤"이에요. 이걸 빼면 비교가 반쪽이 돼요. 보이지 않는 화면을 계속 그리는 건 순수한 낭비거든요.
  • "setInterval은 쓰면 안 된다"고 단정 — 애니메이션엔 rAF가 낫지만, setInterval이 틀린 도구는 아니에요. 주기적인 데이터 확인처럼 화면 그리기와 무관한 일엔 setInterval이 더 자연스러워요.

실무 개선 포인트 (심화)

  • 화면 새로고침 주기는 기기마다 달라요. 60Hz가 흔하지만 120Hz·144Hz 화면도 있어요. setInterval(fn, 16)은 60Hz를 가정해 박은 숫자라, 120Hz 화면에선 절반만 따라가 어긋나요. requestAnimationFrame은 그 기기의 실제 주기에 맞춰 불리니, 어떤 화면에서도 자연스럽게 동작해요. "숫자를 박지 말고 브라우저에 맡긴다"가 핵심이에요.
  • 움직임을 더 정확히 하려면, rAF가 콜백에 넘겨 주는 시간 값을 활용해요. 프레임이 살짝 밀려도 "지난 프레임에서 얼마나 시간이 흘렀나"를 보고 그만큼 움직이면, 빠른 기기든 느린 기기든 같은 속도로 보여요. 지금은 매 프레임 고정값(vx)을 더했지만, 실무 애니메이션은 시간을 곱해 속도를 일정하게 맞추는 경우가 많아요.

생각해볼 주제 예시답안

1. canvas에 그린 글자는 스크린 리더가 못 읽어요

[문제 상황 요약]

스토리 편집기에서 사진 위에 "오늘 날씨 최고!"라는 글자를 canvas로 그렸어요. 우리 눈엔 분명히 글자가 보여요. 그런데 화면을 못 보는 사용자가 쓰는 스크린 리더에게 canvas는 그냥 "그림 한 장"일 뿐이에요. 그 안에 무슨 글자가 있는지 읽어 줄 방법이 없어요. 이 한계를 어떻게 보완할 수 있을까요?

[튜터의 가이드 및 해설]

먼저 왜 이런 일이 생기는지 짚어 볼게요. HTML의 <p><button> 안에 든 글자는 진짜 글자예요. 그래서 스크린 리더가 읽고, 검색 엔진도 찾고, 복사도 돼요. 그런데 canvas로 fillText로 찍은 글자는 글자가 아니라 칠해진 픽셀이에요. 그림 속 글자를 사람이 눈으로만 알아보듯, 컴퓨터에겐 의미 없는 색 점들의 모음일 뿐이죠. G-1에서 배운 "기계도 이해할 수 있게 의미를 주자"는 접근성의 핵심이 canvas에선 통째로 빠지는 거예요.

해법은 "canvas 밖에도 같은 정보를 진짜 글자로 두자"예요. 세 가지 방법이 있어요.

  • Option A — <canvas> 안에 대체 텍스트(fallback content) 넣기: <canvas> 여는 태그와 닫는 태그 사이에 글자를 적으면, canvas를 못 그리거나 못 읽는 환경에서 그 글자가 대신 읽혀요. 가장 간단하지만, 글자가 동적으로 바뀌면 같이 갱신해 줘야 해요.
  • Option B — 중요한 정보·컨트롤은 canvas 밖 실제 DOM 요소로도 제공: 스토리에 얹은 글자 목록을 canvas 옆에 <ul>로도 두거나, "글자 추가" 같은 버튼을 진짜 <button>으로 두는 거예요. 보이는 그림은 canvas가, 의미와 조작은 실제 DOM이 맡는 분담이죠.
  • Option C — 순수 장식이면 aria-hidden으로 숨기기: 의미 없는 배경 애니메이션이나 꾸밈용 그림이라면, 굳이 읽어 줄 필요가 없어요. aria-hidden="true"로 스크린 리더에게 "여긴 건너뛰어도 돼"라고 알려 주면, 오히려 방해가 줄어요.

현업에서는 보통 이 셋을 섞어 써요. "이 canvas가 전하는 핵심 정보가 무엇인가"를 먼저 묻고, 그게 정보면 B(밖에도 진짜 요소로), 보조면 A(fallback 텍스트), 장식이면 C(숨김)로 가르는 거죠. canvas는 보여 주기엔 강력하지만 "의미를 전달하는 일"은 약하니, 그 약점을 실제 DOM으로 메우는 게 핵심이에요.

🎯 면접관을 홀리는 핵심 멘트

"canvas에 찍은 글자는 글자가 아니라 픽셀이에요. 그래서 스크린 리더가 못 읽죠. 저는 'canvas는 보여 주는 역할, 의미는 실제 DOM이 맡는다'로 나눠요. 핵심 정보와 버튼은 canvas 밖에 진짜 <button>·텍스트로 두고, canvas 안엔 fallback 텍스트를 넣고, 순수 장식이면 aria-hidden으로 숨겨요. 보이는 것과 읽히는 것을 분리하는 게 canvas 접근성의 출발점입니다."


2. 직접 그리기와 라이브러리, 언제 무엇을 고를까

[문제 상황 요약]

Step 7에서 같은 차트를 직접 그려도 보고 Chart.js로도 그려봤어요. 둘 다 되는데, 실무에선 어느 쪽을 골라야 할까요? 인스타 클론에 "좋아요 수가 실시간으로 물결치는 특이한 그래프"를 넣는다면 어느 쪽을 고를지, 그 판단 기준을 정리해 보세요.

[튜터의 가이드 및 해설]

먼저 분명히 할 게 있어요. "라이브러리가 항상 정답"도 아니고 "직접 그리기가 늘 멋진 것"도 아니에요. 둘은 트레이드오프예요. 무엇을 만드느냐로 갈려요.

  • Option A — 라이브러리(Chart.js 같은): 막대·선·원 그래프처럼 흔한 차트를 빠르게 만들 때 최고예요. 축·범례·툴팁·애니메이션을 공짜로 주고, 마우스를 올리면 값이 뜨는 기능도 이미 들어 있어요. 대신 남이 정해 둔 틀 안에서 움직여야 하고, 라이브러리 파일을 함께 불러와야 해요(번들 크기·의존성). 라이브러리가 안 만들어 둔 특이한 모양은 억지로 끼워 맞추기 어려워요.
  • Option B — 직접 그리기(canvas 손코딩): "세상에 없던 시각화"나 "픽셀 단위로 제어"가 필요할 때 빛나요. 좋아요 수가 물결치며 출렁이는 그래프처럼 라이브러리에 없는 모양은, 직접 그려야 자유롭게 만들 수 있어요. 대신 축·라벨·애니메이션을 전부 손으로 만들어야 해서 손이 많이 가요.

판단 기준을 한 줄로 잡으면 이래요. "이게 흔한 차트인가, 아니면 이 서비스만의 특이한 그림인가?" 흔하면 라이브러리, 특이하면 직접이에요. 문제에 나온 "실시간으로 물결치는 특이한 그래프"는 라이브러리의 기본 막대·선에 없는 모양이라, 직접 그리기(B)가 자연스러워요. rAF로 매 프레임 출렁임을 다시 그리는, 오늘 배운 애니메이션 그대로의 일이거든요.

현업에서는 보통 흔한 건 라이브러리로 빨리 끝내고, 특이한 핵심 화면만 직접 그려요. 모든 걸 직접 그리면 시간이 부족하고, 모든 걸 라이브러리에 맡기면 서비스만의 개성을 못 살리니까요. 그리고 한 가지 더 — 직접 그릴 줄 알아야 라이브러리도 잘 써요. 비례 계산과 좌표 뒤집기를 손으로 해봤으니, Chart.js의 beginAtZero나 축 설정이 무슨 뜻인지 바로 보이죠. 원리를 알면 도구가 더 잘 보여요.

🎯 면접관을 홀리는 핵심 멘트

"직접 그리기와 라이브러리는 '자유 vs 편리'의 트레이드오프예요. 흔한 막대·선 그래프는 Chart.js 같은 라이브러리로 빠르게 끝내고, 이 서비스만의 특이한 시각화나 픽셀 제어가 필요한 화면만 직접 그려요. 물결치는 그래프처럼 라이브러리에 없는 모양은 직접 그리기가 답이고요. 그리고 직접 그려 본 사람이 라이브러리도 잘 써요 — 비례 계산과 좌표 뒤집기를 손으로 겪어 봐야 라이브러리 설정이 무슨 뜻인지 보이거든요."


3. 부드러운 애니메이션에는 비용이 따라요

[문제 상황 요약]

requestAnimationFrame은 초당 60번 화면을 다시 그려요. 부드럽지만, 그만큼 컴퓨터가 일을 많이 한다는 뜻이기도 해요. 느린 폰이나 배터리가 적은 노트북에서는 과한 애니메이션이 화면을 버벅이게 하거나 배터리를 빨리 닳게 할 수 있어요. "부드러움"과 "비용" 사이에서 어떻게 균형을 잡으면 좋을까요?

[튜터의 가이드 및 해설]

먼저 "왜 비용이 드나"를 짚어 볼게요. 초당 60프레임은 1초에 60번, 화면 전체를 지우고 다시 그린다는 뜻이에요. 그릴 게 많을수록(공 수백 개, 큰 사진, 복잡한 도형) 매 프레임 그 일을 60번씩 하니까 CPU·GPU가 바빠지고, 그만큼 전기를 써서 배터리가 닳고 기기가 뜨거워져요. 느린 폰은 한 프레임을 16밀리초 안에 못 그려서 프레임을 건너뛰고, 그게 "버벅임"으로 보여요.

균형을 잡는 방법은 여러 갈래예요.

  • Option A — 안 보이면 멈추기: requestAnimationFrame이 탭이 안 보이면 자동으로 멈추도록 설계된 이유가 바로 이거예요. 보이지 않는 화면을 계속 그리는 건 순수한 낭비잖아요. 브라우저가 알아서 쉬게 해서 배터리를 아끼는 거죠. 우리가 따로 안 해도 rAF가 챙겨 줘요.
  • Option B — 사용자 설정 존중(prefers-reduced-motion): B-7에서 배운 그거예요. 멀미를 느끼거나 움직임을 줄이고 싶은 사용자가 OS에서 "동작 줄이기"를 켜면, 그 뜻을 읽어 애니메이션을 약하게 하거나 꺼요. 부드러움보다 사람이 먼저예요.
  • Option C — 과한 애니메이션 자제 + transform 활용: 꼭 필요한 곳에만 움직임을 두고, 움직일 땐 B-7에서 배운 transform을 써요. transform은 주변 요소를 다시 계산해 밀어내지 않고 GPU가 가볍게 처리해서, 같은 움직임도 훨씬 싸게 만들어요.

현업에서는 보통 이 셋을 함께 챙겨요. "이 애니메이션이 정말 필요한가?"를 먼저 묻고, 필요하면 가볍게(transform) 만들고, 안 보일 땐 멈추고, 사용자가 원치 않으면 끄는 거죠. 핵심은 "부드러움은 공짜가 아니다"라는 감각이에요. 화려한 움직임이 멋져 보여도, 그게 누군가의 배터리를 깎고 누군가의 폰을 버벅이게 한다면 다시 생각해 봐야 해요. 좋은 애니메이션은 화려한 게 아니라, 필요한 만큼만 부드럽고 그 비용을 아는 애니메이션이에요.

🎯 면접관을 홀리는 핵심 멘트

"초당 60프레임은 1초에 60번 화면을 다시 그린다는 뜻이라, 그릴 게 많을수록 CPU·배터리를 써요. 그래서 저는 부드러움과 비용을 같이 봐요. requestAnimationFrame은 안 보이는 탭에서 자동으로 멈춰 낭비를 막고, 저는 거기에 prefers-reduced-motion으로 사용자 설정을 존중하고, 움직임은 GPU가 가벼운 transform으로 만들어요. 부드러움은 공짜가 아니에요 — 필요한 만큼만 움직이고 그 비용을 아는 게 핵심입니다."

전체 목록 HTML·CSS·JS