본문 바로가기

Canvas API 기초

canvas의 2D 컨텍스트와 좌표계를 준비하고 선·사각형·원·텍스트·경로 상태를 픽셀 화면에 그립니다.

9장의 앞 글들에서는 Fetch API로 서버와 통신하고, 웹 스토리지로 데이터를 저장하며, History API로 SPA 라우팅을 만드는 방법을 익혔습니다. 이번에는 웹 페이지를 더 시각적이고 인터랙티브하게 만드는 Canvas API를 다룹니다.

Canvas API는 <canvas> 요소 위에 자바스크립트로 2D 그래픽을 직접 그리게 해 주는 API입니다. 도형과 글자를 그리고, 픽셀 단위로 이미지를 다루고, 프레임마다 다시 그려 애니메이션을 만들 수 있어 정적인 이미지로는 표현하기 어려운 화면을 만들 때 유용합니다. 간단한 게임, 이미지 편집기, 차트 같은 데이터 시각화가 대표적인 쓰임입니다. 이 글에서는 Canvas를 준비하고 그리기 도구를 얻은 뒤, 선과 사각형, 원, 글자를 그리는 기본 흐름을 차례로 익힙니다.


canvas 요소와 렌더링 컨텍스트

Canvas는 말 그대로 도화지입니다. HTML 문서에는 빈 도화지인 <canvas> 요소만 두고, 실제 그림은 자바스크립트 코드로 그 위에 그립니다.

canvas 요소 준비

HTML에 <canvas> 태그를 넣고 width와 height 속성으로 크기를 정합니다. 이 두 속성은 Canvas가 실제로 가진 픽셀 수를 정합니다. CSS로도 크기를 바꿀 수 있지만, CSS는 픽셀 수는 그대로 둔 채 보이는 크기만 늘리거나 줄이므로 그림이 흐려지거나 찌그러질 수 있습니다. 그래서 처음에는 HTML 속성으로 크기를 정해 둡니다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas API 기초</title>
    <style>
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background-color: #f0f0f0;
            margin: 0;
        }
        canvas {
            border: 2px solid #333; /* 캔버스 영역을 눈으로 확인하기 위한 테두리 */
            background-color: #fff;
        }
    </style>
</head>
<body>
    <canvas id="myCanvas" width="500" height="300">
        이 브라우저는 Canvas를 지원하지 않습니다.
    </canvas>

    <script src="canvas-script.js"></script>
</body>
</html>

<canvas> 태그 안의 글자는 Canvas를 지원하지 않는 브라우저에서만 보이는 대체 내용입니다. Canvas에 그린 그림은 픽셀일 뿐이라 스크린 리더가 읽을 수 없으므로, 그림의 내용을 설명하는 문장을 이 자리나 Canvas 옆의 다른 요소에 적어 두는 것이 좋습니다.

렌더링 컨텍스트 가져오기

<canvas> 요소 자체에는 그림을 그리는 메서드가 없습니다. 그림을 그리려면 getContext()로 렌더링 컨텍스트(rendering context)를 얻어야 하며, 가장 많이 쓰는 것은 2D 그래픽을 위한 '2d' 컨텍스트입니다.

// canvas-script.js
const canvas = document.getElementById('myCanvas');

// getContext() 메서드를 사용하여 렌더링 컨텍스트를 가져옵니다.
const ctx = canvas.getContext('2d');

// 캔버스가 정상적으로 초기화되었는지 확인
if (ctx) {
    console.log("2D 렌더링 컨텍스트를 성공적으로 가져왔습니다.");
    // 이제 ctx 객체를 사용하여 그림을 그릴 수 있습니다.
} else {
    console.error("이 브라우저는 Canvas 2D 컨텍스트를 지원하지 않습니다.");
}

이렇게 얻은 ctx 객체가 도형, 글자, 이미지를 그리는 모든 메서드와 색상, 선 두께 같은 그리기 속성을 갖고 있습니다. 이후 예제는 모두 이 canvas와 ctx를 이어서 씁니다.

Canvas의 좌표계는 왼쪽 위가 원점 (0, 0)이고, x는 오른쪽으로, y는 아래쪽으로 커집니다. 수학 시간의 좌표계와 달리 y가 아래로 커진다는 점에 주의하세요.


도형 그리기

Canvas에서 선이나 원 같은 도형은 경로(path)로 그립니다. 먼저 ctx.beginPath()로 새 경로를 시작하고, moveTo(), lineTo(), arc(), rect() 같은 메서드로 경로의 모양을 정합니다. 윤곽선을 닫아야 한다면 closePath()로 시작점까지 경로를 닫고, 마지막으로 stroke()로 선을 긋거나 fill()로 안쪽을 채웁니다. fill()은 닫지 않은 경로도 닫힌 것으로 보고 채웁니다.

선 그리기

// 선의 색상 설정
ctx.strokeStyle = 'blue';
// 선의 두께 설정
ctx.lineWidth = 5;

// 첫 번째 선: (50, 50)에서 (200, 50)까지
ctx.beginPath();       // 새로운 경로 시작
ctx.moveTo(50, 50);    // 시작점 이동
ctx.lineTo(200, 50);   // (200, 50)까지 선 그리기
ctx.stroke();          // 경로를 따라 선 그리기 (stroke)

// 두 번째 선: (200, 50)에서 (200, 150)까지
// beginPath()를 다시 호출하면 이전 경로와 독립적으로 그림
ctx.beginPath();
ctx.moveTo(200, 50);
ctx.lineTo(200, 150);
ctx.stroke();

// 삼각형 그리기
ctx.strokeStyle = 'red';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(300, 100);
ctx.lineTo(350, 200);
ctx.lineTo(250, 200);
ctx.closePath(); // 시작점으로 경로를 닫아 삼각형 완성
ctx.stroke();

beginPath()를 다시 호출하면 이전 경로를 버리고 새 경로를 시작하므로, 두 번째 선은 첫 번째 선과 따로 그려집니다. beginPath()를 빠뜨리면 이전 경로가 남아 있어, 다음 stroke()가 이전 선까지 새 색으로 다시 그리는 실수가 생깁니다. 삼각형은 세 점을 이은 뒤 closePath()로 마지막 점과 시작점을 이어 완성합니다.

사각형 그리기

사각형에는 전용 메서드가 있습니다. ctx.rect(x, y, width, height)는 사각형 경로를 정의하고, ctx.strokeRect()는 윤곽선 사각형을, ctx.fillRect()는 채워진 사각형을 바로 그립니다. ctx.clearRect()는 지정한 영역의 픽셀을 투명하게 지웁니다.

// 채워진 사각형 (fillStyle 설정)
ctx.fillStyle = 'green'; // 채울 색상 설정
ctx.fillRect(50, 100, 100, 50); // (x, y, width, height)

// 윤곽선 사각형 (strokeStyle, lineWidth 설정)
ctx.strokeStyle = 'purple';
ctx.lineWidth = 3;
ctx.strokeRect(200, 100, 100, 50);

// 특정 영역 지우기
ctx.clearRect(70, 110, 60, 30); // 초록색 사각형의 일부를 지움

fillRect()와 strokeRect()는 경로를 거치지 않고 바로 그리므로 beginPath()가 필요 없습니다. 마지막 clearRect()는 초록색 사각형의 가운데 일부를 지워, 그 자리에 Canvas의 흰 배경이 비쳐 보입니다.

더 깊이: 1픽셀 선이 흐리게 보이는 이유

Canvas에 두께 1인 세로선을 x = 10에 그리면, 진한 1픽셀 선 대신 옅은 2픽셀 선이 그려지는 경우가 있습니다. Canvas 좌표의 정수 값은 픽셀의 한가운데가 아니라 두 픽셀이 맞닿는 선을 가리키기 때문입니다. 선은 경로를 중심으로 양쪽에 두께의 절반씩 칠해지므로, 맞닿는 선 위에 놓인 두께 1인 선은 왼쪽 픽셀의 절반과 오른쪽 픽셀의 절반을 덮습니다. 픽셀을 절반만 칠할 수는 없으니 브라우저는 두 픽셀을 모두 원래 색의 절반 진하기로 칠하고, 그 결과 선이 넓고 흐려 보입니다.

해결 방법은 선의 중심을 픽셀의 가운데로 옮기는 것입니다. 두께가 홀수인 선은 ctx.moveTo(10.5, 0)처럼 좌표에 0.5를 더하면 선이 한 픽셀 열을 정확히 채웁니다. 반면 fillRect()로 칠한 면은 맞닿는 선에서 다음 맞닿는 선까지 픽셀을 통째로 채우므로 정수 좌표에서도 가장자리가 선명합니다. 같은 이유로 strokeRect(50, 50, 50, 50)에 두께 1의 테두리를 그리면 테두리가 맞닿는 선을 사이에 두고 양쪽 픽셀에 걸치므로, 실제로는 가로세로 52픽셀을 덮고 가장자리가 2픽셀 너비의 옅은 선으로 보입니다. 선명한 1픽셀 테두리가 필요하면 strokeRect(50.5, 50.5, 49, 49)처럼 위치를 0.5 옮기고 크기를 두께만큼 줄입니다.

이 계산은 Canvas 좌표 1이 화면의 물리 픽셀 1개에 맞을 때의 이야기입니다. MDN의 도형 그리기 설명은 devicePixelRatio가 1.5처럼 정수가 아니어서 CSS 픽셀과 물리 픽셀이 맞아떨어지지 않으면 일부 픽셀이 다른 픽셀보다 크게 그려져 고르지 않게 보일 수 있고, 이것은 쉽게 풀리지 않는 문제라고 설명합니다.

원 그리기

원이나 호는 ctx.arc(x, y, radius, startAngle, endAngle, counterclockwise)로 그립니다. x, y는 원의 중심, radius는 반지름이고, startAngle과 endAngle은 호의 시작과 끝 각도입니다. 각도는 도가 아니라 라디안(radian)으로 적으며, 180도가 Math.PI, 한 바퀴가 2 * Math.PI입니다. 마지막 인수가 true면 반시계 방향, 생략하거나 false면 시계 방향으로 그립니다.

// 라디안 계산: Math.PI는 파이 (180도)
// 전체 원: 0부터 2 * Math.PI
// 반원: 0부터 Math.PI

// 채워진 원
ctx.fillStyle = 'orange';
ctx.beginPath();
ctx.arc(150, 230, 40, 0, 2 * Math.PI, false); // (x, y, 반지름, 시작각, 끝각, 반시계방향)
ctx.fill();

// 윤곽선 원
ctx.strokeStyle = 'darkblue';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(350, 230, 40, 0, 2 * Math.PI, false);
ctx.stroke();

// 반원 (파이차트 조각처럼)
ctx.fillStyle = 'lightblue';
ctx.beginPath();
ctx.moveTo(400, 50); // 중심점에서 시작
ctx.arc(400, 50, 40, 0, Math.PI, false); // 아래쪽 반원 (기본 canvas 좌표계)
ctx.closePath(); // 경로를 명시적으로 닫음 (fill은 열린 경로도 암묵적으로 닫아 채움)
ctx.fill();

각도 0은 중심의 오른쪽에서 시작하고, y가 아래로 커지는 좌표계라 시계 방향으로 Math.PI까지 그리면 아래쪽 반원이 됩니다. 반원 예제는 중심점에서 경로를 시작한 뒤 호를 그리고 closePath()로 닫아, 부채꼴 모양의 조각을 채웁니다.


글자 그리기

ctx.fillText(text, x, y, maxWidth)는 채운 글자를, ctx.strokeText(text, x, y, maxWidth)는 윤곽선 글자를 그립니다. 마지막 인수 maxWidth를 주면 글자가 그 폭을 넘지 않도록 줄여서 그립니다.

글자의 모양은 세 속성으로 정합니다. ctx.font는 '48px serif'처럼 CSS의 font와 같은 형식으로 크기와 글꼴을 정합니다. ctx.textAlign은 x 좌표를 기준으로 한 가로 정렬로 'left', 'right', 'center', 'start', 'end' 가운데 하나이고, ctx.textBaseline은 y 좌표를 어느 기준선에 맞출지로 'alphabetic', 'top', 'hanging', 'middle', 'ideographic', 'bottom' 가운데 하나입니다.

ctx.fillStyle = 'black';
ctx.font = '30px Arial'; // 폰트 크기와 패밀리
ctx.textAlign = 'center'; // 수평 정렬
ctx.textBaseline = 'middle'; // 수직 정렬

// 캔버스 중앙에 텍스트 그리기
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;

ctx.fillText('Hello Canvas!', centerX, centerY);

ctx.strokeStyle = 'gray';
ctx.lineWidth = 1;
ctx.font = '20px Sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.strokeText('Web Graphics', 10, 10);

textAlign을 'center', textBaseline을 'middle'로 정하면 넘겨준 좌표가 글자의 가운데가 되므로, Canvas 중앙에 글자를 놓기 쉽습니다. 두 번째 글자는 'left'와 'top'으로 바꿔, (10, 10)이 글자의 왼쪽 위 모서리가 되게 했습니다.


그리기 상태 저장과 복원

컨텍스트는 지금의 색상, 선 두께, 글꼴, 좌표 변환(transform), 클리핑 영역 같은 그리기 상태를 기억합니다. ctx.save()는 지금의 그리기 상태를 스택에 쌓아 저장하고, ctx.restore()는 가장 최근에 저장한 상태를 꺼내 되돌립니다.

// 채우기 색상: 빨간색
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, 50, 50); // (0,0)에 빨간색 사각형

ctx.save(); // 현재 그리기 상태 저장 (채우기 색상: 빨간색)

ctx.fillStyle = 'blue'; // 새로운 상태: 파란색
ctx.fillRect(60, 0, 50, 50); // (60,0)에 파란색 사각형

ctx.save(); // 현재 그리기 상태 저장 (채우기 색상: 파란색)

ctx.fillStyle = 'green'; // 새로운 상태: 초록색
ctx.fillRect(120, 0, 50, 50); // (120,0)에 초록색 사각형

ctx.restore(); // 스택에서 가장 최근 상태 복원 (파란색)
ctx.fillRect(180, 0, 50, 50); // (180,0)에 파란색 사각형

ctx.restore(); // 스택에서 다음 상태 복원 (빨간색)
ctx.fillRect(240, 0, 50, 50); // (240,0)에 빨간색 사각형

다섯 사각형은 왼쪽부터 빨강, 파랑, 초록, 파랑, 빨강입니다. 첫 save()가 빨강 상태를, 두 번째 save()가 파랑 상태를 쌓아 두므로, 첫 restore()는 파랑으로, 두 번째 restore()는 빨강으로 되돌립니다. 작은 그림 단위마다 save()와 restore()로 감싸면, 그 안에서 바꾼 색이나 좌표 변환이 다음 그림으로 번지지 않습니다.

save()와 restore()가 되돌리는 것은 그리기 상태뿐입니다. 지금 만들고 있는 경로나 이미 Canvas에 그린 픽셀은 저장하지도 되돌리지도 않으므로, restore()를 호출해도 이미 그린 사각형은 그대로 남습니다.


보이는 크기와 픽셀 버퍼

Canvas 렌더 상태와 픽셀 파이프라인 — 상태 추적

DPR transform과 save/설정/draw/restore에서 context state와 bitmap/path의 변화가 다르다. restore 뒤 그린 픽셀이 남는 것을 동일 canvas/context/model로 추적한다. 의미 경로는 별도 병렬 관계로 위 관계도와 표에 유지한다.

Canvas 렌더 상태와 픽셀 파이프라인

FLOWCHART · WEB PLATFORM CONTRACT

Canvas 렌더 상태와 픽셀 파이프라인

하나의 장면 모델에서 픽셀 렌더와 접근성·좌표 판정을 나눠 관리한다. 픽셀 버퍼는 CSS 크기와 DPR에 맞추고 그리기 상태는 save/restore로 격리한다.

Canvas raster state와 semantic interaction의 병렬 경계같은 scene은 픽셀과 DOM·좌표 판정 경로에 병렬로 들어갑니다. 새 실습은 DPR transform을 기본 좌표로 먼저 정하고 save 뒤 색·선·폰트를 격리합니다. DOM 상태 설명은 구현하며 포커스 대안·별도 hit-test는 확장 설계입니다. save/restore 경계는 bitmap과 현재 path를 저장하는 경계가 아닙니다.픽셀 렌더 경로그리기 상태 경계같은 모델의 의미 경로장면 모델좌표·상태·ID버퍼 준비CSS 크기 × DPRDPR transform 지정크기 변경 뒤 재설정save / 설정색·선·폰트 지정save 뒤 draw·restore경로와 그리기beginPath·commandsfill / strokebitmap이미 그린 픽셀restore로 되돌림 아님DOM 설명동일 장면·상태 문장포커스 대안좌표 판정 모델같은 scene에서 계산추가 구현의 책임restore는 상태 복원path·bitmap 복원 아님
Canvas raster state와 semantic interaction의 병렬 경계같은 scene은 픽셀과 DOM·좌표 판정 경로에 병렬로 들어갑니다. 새 실습은 DPR transform을 기본 좌표로 먼저 정하고 save 뒤 색·선·폰트를 격리합니다. DOM 상태 설명은 구현하며 포커스 대안·별도 hit-test는 확장 설계입니다. save/restore 경계는 bitmap과 현재 path를 저장하는 경계가 아닙니다.픽셀 렌더 경로그리기 상태 경계같은 모델의 의미 경로장면 모델좌표·상태·ID버퍼 준비CSS 크기 × DPRDPR transform 지정크기 변경 뒤 재설정save / 설정색·선·폰트 지정save 뒤 draw·restore경로와 그리기beginPath·commandsfill / strokebitmap이미 그린 픽셀restore로 되돌림 아님DOM 설명동일 장면·상태 문장포커스 대안좌표 판정 모델같은 scene에서 계산추가 구현의 책임restore는 상태 복원path·bitmap 복원 아님

같은 scene은 픽셀과 DOM·좌표 판정 경로에 병렬로 들어갑니다. 새 실습은 DPR transform을 기본 좌표로 먼저 정하고 save 뒤 색·선·폰트를 격리합니다. DOM 상태 설명은 구현하며 포커스 대안·별도 hit-test는 확장 설계입니다. save/restore 경계는 bitmap과 현재 path를 저장하는 경계가 아닙니다.

Canvas bitmap은 시각 출력일 뿐 DOM 의미·focus·hit target을 만들지 않는다. 픽셀 렌더와 접근성 경로는 같은 모델을 병렬로 소비한다. save/restore는 현재 path와 이미 그린 bitmap을 저장하거나 되돌리지 않는다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
context 준비context: 저장된 state / bitmap: 기존 픽셀DPR buffer와 transform을 맞추고 save한다해상도와 논리 좌표를 구분한다
상태 변경과 drawcontext: 변경된 state / bitmap: 새 픽셀 있음스타일/transform을 바꿔 픽셀을 그린다context state와 실제 bitmap은 다른 자료다
restorecontext: 저장 state 복원 / bitmap/path: 그린 내용 자동 복원 안 됨저장한 context만 되돌린다restore를 undo 화면으로 오해하지 않게 한다

Canvas 렌더 상태와 픽셀 파이프라인 — 상태 추적

DPR transform과 save/설정/draw/restore에서 context state와 bitmap/path의 변화가 다르다. restore 뒤 그린 픽셀이 남는 것을 동일 canvas/context/model로 추적한다. 의미 경로는 별도 병렬 관계로 위 관계도와 표에 유지한다.

Canvas 렌더 상태와 픽셀 파이프라인

FLOWCHART · WEB PLATFORM CONTRACT

Canvas 렌더 상태와 픽셀 파이프라인

하나의 장면 모델에서 픽셀 렌더와 접근성·좌표 판정을 나눠 관리한다. 픽셀 버퍼는 CSS 크기와 DPR에 맞추고 그리기 상태는 save/restore로 격리한다.

Canvas raster state와 semantic interaction의 병렬 경계같은 scene은 픽셀과 DOM·좌표 판정 경로에 병렬로 들어갑니다. 새 실습은 DPR transform을 기본 좌표로 먼저 정하고 save 뒤 색·선·폰트를 격리합니다. DOM 상태 설명은 구현하며 포커스 대안·별도 hit-test는 확장 설계입니다. save/restore 경계는 bitmap과 현재 path를 저장하는 경계가 아닙니다.픽셀 렌더 경로그리기 상태 경계같은 모델의 의미 경로장면 모델좌표·상태·ID버퍼 준비CSS 크기 × DPRDPR transform 지정크기 변경 뒤 재설정save / 설정색·선·폰트 지정save 뒤 draw·restore경로와 그리기beginPath·commandsfill / strokebitmap이미 그린 픽셀restore로 되돌림 아님DOM 설명동일 장면·상태 문장포커스 대안좌표 판정 모델같은 scene에서 계산추가 구현의 책임restore는 상태 복원path·bitmap 복원 아님
Canvas raster state와 semantic interaction의 병렬 경계같은 scene은 픽셀과 DOM·좌표 판정 경로에 병렬로 들어갑니다. 새 실습은 DPR transform을 기본 좌표로 먼저 정하고 save 뒤 색·선·폰트를 격리합니다. DOM 상태 설명은 구현하며 포커스 대안·별도 hit-test는 확장 설계입니다. save/restore 경계는 bitmap과 현재 path를 저장하는 경계가 아닙니다.픽셀 렌더 경로그리기 상태 경계같은 모델의 의미 경로장면 모델좌표·상태·ID버퍼 준비CSS 크기 × DPRDPR transform 지정크기 변경 뒤 재설정save / 설정색·선·폰트 지정save 뒤 draw·restore경로와 그리기beginPath·commandsfill / strokebitmap이미 그린 픽셀restore로 되돌림 아님DOM 설명동일 장면·상태 문장포커스 대안좌표 판정 모델같은 scene에서 계산추가 구현의 책임restore는 상태 복원path·bitmap 복원 아님

같은 scene은 픽셀과 DOM·좌표 판정 경로에 병렬로 들어갑니다. 새 실습은 DPR transform을 기본 좌표로 먼저 정하고 save 뒤 색·선·폰트를 격리합니다. DOM 상태 설명은 구현하며 포커스 대안·별도 hit-test는 확장 설계입니다. save/restore 경계는 bitmap과 현재 path를 저장하는 경계가 아닙니다.

Canvas bitmap은 시각 출력일 뿐 DOM 의미·focus·hit target을 만들지 않는다. 픽셀 렌더와 접근성 경로는 같은 모델을 병렬로 소비한다. save/restore는 현재 path와 이미 그린 bitmap을 저장하거나 되돌리지 않는다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
context 준비context: 저장된 state / bitmap: 기존 픽셀DPR buffer와 transform을 맞추고 save한다해상도와 논리 좌표를 구분한다
상태 변경과 drawcontext: 변경된 state / bitmap: 새 픽셀 있음스타일/transform을 바꿔 픽셀을 그린다context state와 실제 bitmap은 다른 자료다
restorecontext: 저장 state 복원 / bitmap/path: 그린 내용 자동 복원 안 됨저장한 context만 되돌린다restore를 undo 화면으로 오해하지 않게 한다

위 그림은 하나의 장면 모델(scene model)이 두 갈래로 나뉘어 쓰이는 모습을 보여 줍니다. 장면 모델은 그릴 대상의 좌표와 상태, ID를 담은 데이터입니다. 왼쪽의 픽셀 렌더 경로에서는 먼저 CSS 크기에 기기 픽셀 비율(device pixel ratio, DPR)을 곱해 픽셀 버퍼를 준비하고, DPR만큼 좌표 변환을 지정합니다. Canvas 크기를 바꾸면 이 변환이 초기화되므로 다시 지정해야 합니다. 그다음 save() 뒤에 색과 선, 글꼴을 정하고, beginPath()와 경로 명령으로 그린 뒤 fill()이나 stroke()를 호출하고 restore()합니다. 결과는 이미 그린 픽셀인 비트맵(bitmap)으로 남으며, restore()로 되돌릴 수 없습니다.

오른쪽의 의미 경로는 같은 장면 모델로 DOM에 상태를 설명하는 문장을 만들고, 필요하면 키보드 포커스로 쓸 대안 요소나, 클릭한 좌표가 어느 대상에 닿았는지 판정하는 모델을 만듭니다. 이 글의 실습은 DOM 설명 문장까지 구현하며, 포커스 대안과 좌표 판정은 더 확장할 때 만드는 부분입니다. 그림 아래의 설명처럼 Canvas의 비트맵은 눈에 보이는 출력일 뿐이라, DOM의 의미나 포커스, 클릭 대상을 스스로 만들지 않습니다.

고해상도 화면에서는 CSS 1픽셀에 실제 화면 픽셀이 2개나 3개씩 들어갑니다. Canvas의 픽셀 수를 CSS 크기와 같게 두면 브라우저가 그림을 늘려 보여 줘 선과 글자가 흐려집니다. 그래서 보이는 크기는 CSS로 정하고, 픽셀 버퍼는 그 크기에 devicePixelRatio를 곱한 만큼 따로 잡은 뒤, 좌표 변환으로 CSS 좌표 그대로 그리게 합니다.

좁은 화면에 맞춰 다시 배치하기

앞의 500×300 예제는 좌표를 익히기 위한 고정 크기입니다. 320px 화면에 그대로 두면 Canvas와 테두리가 화면 밖으로 넘칩니다. 다음 별도 실습은 320px 이상의 화면에서 그림을 작게 줄이는 대신, 쓸 수 있는 가로폭에 맞춰 선과 원, 글자의 위치를 다시 계산합니다. HTML에는 <canvas id="responsive-canvas">가운데 원과 가로선을 그리는 연습 그림입니다.</canvas><p id="canvas-status"></p>를 넣고, 앞의 canvas-script.js와 함께 연결하지 마세요.

새 Canvas 실습의 화면 여백
body { margin: 0; padding: 16px; }
#responsive-canvas { display: block; outline: 1px solid #777; }
#canvas-status { overflow-wrap: anywhere; }
CSS 좌표와 픽셀 버퍼를 분리하는 좁은 화면 Canvas
const canvas = document.getElementById('responsive-canvas');
const status = document.getElementById('canvas-status');
const ctx = canvas.getContext('2d');

function drawResponsiveCanvas() {
    if (!ctx) { status.textContent = '2D 그리기를 사용할 수 없습니다.'; return; }
    const width = Math.min(500, document.documentElement.clientWidth - 32);
    const height = 300;
    const dpr = window.devicePixelRatio || 1;
    canvas.style.width = `${width}px`;
    canvas.style.height = `${height}px`;
    canvas.width = Math.round(width * dpr);
    canvas.height = Math.round(height * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, width, height);

    const scene = { x: width / 2, y: 200, radius: 40 };
    ctx.save();
    ctx.strokeStyle = '#166534';
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.moveTo(24, 100);
    ctx.lineTo(width - 24, 100);
    ctx.stroke();
    ctx.beginPath();
    ctx.arc(scene.x, scene.y, scene.radius, 0, Math.PI * 2);
    ctx.stroke();
    ctx.fillStyle = '#111827';
    ctx.font = '20px sans-serif';
    ctx.fillText('Canvas 연습', 24, 48);
    ctx.restore();
    status.textContent = `원 중심 (${scene.x}, ${scene.y}), 반지름 ${scene.radius}; CSS ${width}×${height}, 버퍼 ${canvas.width}×${canvas.height}`;
}

drawResponsiveCanvas();
window.addEventListener('resize', drawResponsiveCanvas);

320px 화면이고 DPR이 2라면 CSS 폭은 양쪽 여백 16px씩을 뺀 288, 버퍼는 576×600이고, 원의 CSS 중심은 (144, 200)입니다. 375px 화면이면 CSS 폭은 343, 같은 DPR 2에서 버퍼 폭은 686입니다. 그릴 때는 CSS 좌표를 쓰고, setTransform()으로 지정한 변환이 버퍼의 좌표로 바꿔 주므로 글자는 어느 화면에서나 CSS 기준 20px입니다. 원의 중심을 계산할 때 canvas.width를 쓰면 이미 DPR을 곱한 값이라 DPR이 두 번 적용되므로, CSS 폭인 width와 구별해야 합니다.

창 크기가 바뀌어 canvas.width와 canvas.height를 다시 정하면, Canvas의 비트맵과 그리기 상태가 모두 초기화됩니다. 그래서 drawResponsiveCanvas()는 크기를 바꿀 때마다 변환과 스타일을 다시 지정하고 처음부터 다시 그립니다. 그린 내용은 같은 장면 모델로 #canvas-status 문장에도 적어, 그림을 볼 수 없는 사용자도 내용을 알 수 있게 했습니다.


애니메이션의 프레임 순환

Canvas 애니메이션은 프레임마다 이전 그림을 지우고 지금 상태를 다시 그리는 일을 반복해 만듭니다.

Canvas 애니메이션 프레임 순환 — 상태 추적

rAF callback에서 timestamp 차이→model 갱신→clear/render→다음 예약 여부가 변한다. 원본이 확장 설계라고 명시한 장면 모델을 고정하고 자동 반복/메모리 회수 오해를 피한다.

Canvas 애니메이션 프레임 순환

LOOP · WEB PLATFORM CONTRACT

Canvas 애니메이션 프레임 순환

입력·경과 시간·상태 갱신 뒤 bitmap을 지우고 현재 모델을 그린다. 모델 쓰기와 읽기를 구분하고 다음 frame을 예약한다.

Canvas 애니메이션 프레임 순환이 순환은 정적 원본의 확장 설계입니다. 실선은 콜백 처리·다음 예약, 점선은 모델 쓰기 또는 읽기입니다. rAF는 requestAnimationFrame입니다. 다음 예약 없이 콜백이 자동 반복된다고 가정하지 않습니다. 입력 수집입력 event 반영경과 시간timestamp 차이모델 갱신속도 × 경과 초clear이전 bitmap 지우기render현재 모델 읽고 그리기측정한 프레임의 비용다음 예약rAF 재호출장면 모델입력·update가 쓰기render는 읽기
Canvas 애니메이션 프레임 순환이 순환은 정적 원본의 확장 설계입니다. 실선은 콜백 처리·다음 예약, 점선은 모델 쓰기 또는 읽기입니다. rAF는 requestAnimationFrame입니다. 다음 예약 없이 콜백이 자동 반복된다고 가정하지 않습니다. 입력 수집입력 event 반영경과 시간timestamp 차이모델 갱신속도 × 경과 초clear이전 bitmap 지우기render현재 모델 읽고 그리기측정한 프레임의 비용다음 예약rAF 재호출장면 모델입력·update가 쓰기render는 읽기

이 순환은 정적 원본의 확장 설계입니다. 실선은 콜백 처리·다음 예약, 점선은 모델 쓰기 또는 읽기입니다. rAF는 requestAnimationFrame입니다. 다음 예약 없이 콜백이 자동 반복된다고 가정하지 않습니다.

requestAnimationFrame 콜백마다 경과 시간으로 모델을 갱신하고 bitmap을 다시 그린다. 점선은 모델 접근이다. 입력·update가 모델을 바꾸고 render는 읽기만 한다. 측정 후 requestAnimationFrame을 다시 호출해야 다음 콜백이 예약된다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
frame 시작입력: timestamp 차이 / model: 현재 위치/상태rAF timestamp 차이로 model을 갱신한다확장 animation 모델로 시간을 계산한다
그리기bitmap: 새 frame / model: 현재 값canvas를 clear하고 현재 model을 렌더한다bitmap과 모델의 역할을 구분한다
다음 예약예약: 다음 rAF / 콜백: 한 번 실행되는 예약측정 뒤 다음 frame을 요청한다현재 rAF 콜백은 한 번만 실행되므로 다음 프레임을 받으려면 requestAnimationFrame을 다시 호출해 예약해야 한다

Canvas 애니메이션 프레임 순환 — 상태 추적

rAF callback에서 timestamp 차이→model 갱신→clear/render→다음 예약 여부가 변한다. 원본이 확장 설계라고 명시한 장면 모델을 고정하고 자동 반복/메모리 회수 오해를 피한다.

Canvas 애니메이션 프레임 순환

LOOP · WEB PLATFORM CONTRACT

Canvas 애니메이션 프레임 순환

입력·경과 시간·상태 갱신 뒤 bitmap을 지우고 현재 모델을 그린다. 모델 쓰기와 읽기를 구분하고 다음 frame을 예약한다.

Canvas 애니메이션 프레임 순환이 순환은 정적 원본의 확장 설계입니다. 실선은 콜백 처리·다음 예약, 점선은 모델 쓰기 또는 읽기입니다. rAF는 requestAnimationFrame입니다. 다음 예약 없이 콜백이 자동 반복된다고 가정하지 않습니다. 입력 수집입력 event 반영경과 시간timestamp 차이모델 갱신속도 × 경과 초clear이전 bitmap 지우기render현재 모델 읽고 그리기측정한 프레임의 비용다음 예약rAF 재호출장면 모델입력·update가 쓰기render는 읽기
Canvas 애니메이션 프레임 순환이 순환은 정적 원본의 확장 설계입니다. 실선은 콜백 처리·다음 예약, 점선은 모델 쓰기 또는 읽기입니다. rAF는 requestAnimationFrame입니다. 다음 예약 없이 콜백이 자동 반복된다고 가정하지 않습니다. 입력 수집입력 event 반영경과 시간timestamp 차이모델 갱신속도 × 경과 초clear이전 bitmap 지우기render현재 모델 읽고 그리기측정한 프레임의 비용다음 예약rAF 재호출장면 모델입력·update가 쓰기render는 읽기

이 순환은 정적 원본의 확장 설계입니다. 실선은 콜백 처리·다음 예약, 점선은 모델 쓰기 또는 읽기입니다. rAF는 requestAnimationFrame입니다. 다음 예약 없이 콜백이 자동 반복된다고 가정하지 않습니다.

requestAnimationFrame 콜백마다 경과 시간으로 모델을 갱신하고 bitmap을 다시 그린다. 점선은 모델 접근이다. 입력·update가 모델을 바꾸고 render는 읽기만 한다. 측정 후 requestAnimationFrame을 다시 호출해야 다음 콜백이 예약된다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
frame 시작입력: timestamp 차이 / model: 현재 위치/상태rAF timestamp 차이로 model을 갱신한다확장 animation 모델로 시간을 계산한다
그리기bitmap: 새 frame / model: 현재 값canvas를 clear하고 현재 model을 렌더한다bitmap과 모델의 역할을 구분한다
다음 예약예약: 다음 rAF / 콜백: 한 번 실행되는 예약측정 뒤 다음 frame을 요청한다현재 rAF 콜백은 한 번만 실행되므로 다음 프레임을 받으려면 requestAnimationFrame을 다시 호출해 예약해야 한다

위 그림은 한 프레임에서 일어나는 일을 순서대로 보여 줍니다. 먼저 키보드나 마우스 같은 입력을 모아 반영하고, 이전 프레임과의 시간 차이로 경과 시간을 구합니다. 그다음 속도에 경과한 초를 곱해 장면 모델을 갱신하고, 이전 비트맵을 지운 뒤, 지금 모델을 읽어 그립니다. 마지막으로 한 프레임에 든 비용을 측정하고, requestAnimationFrame()을 다시 호출해 다음 프레임을 예약합니다.

그림의 점선은 장면 모델에 접근하는 방향입니다. 입력과 갱신 단계는 모델을 바꾸고, 그리는 단계는 모델을 읽기만 합니다. 이렇게 나눠 두면 그리는 코드가 상태를 몰래 바꾸는 일이 없어 문제를 찾기 쉽습니다. 또 그림 아래의 설명처럼 requestAnimationFrame()은 콜백을 한 번만 예약하므로, 다음 프레임을 원하면 콜백 안에서 다시 호출해야 합니다.

브라우저는 화면을 다시 그리기 직전에 requestAnimationFrame()의 콜백을 부르며, 콜백 사이의 간격은 화면의 주사율이나 기기 상태에 따라 달라집니다. 그래서 프레임마다 정해진 거리만큼 움직이면 기기마다 속도가 달라지므로, 경과한 시간으로 이동 거리를 계산합니다. 다음 함수는 픽셀을 그리지 않고 그 계산만 떼어 낸 것입니다.

경과 시간을 초로 바꿔 모델 위치를 갱신하기
function nextPosition(x, previousMs, nowMs, speed) {
    const seconds = Math.max(0, nowMs - previousMs) / 1000;
    return x + speed * seconds;
}
console.log(nextPosition(10, 1000, 1250, 40)); // 20

이전 시각 1000ms에서 지금 시각 1250ms까지 250ms, 즉 0.25초가 지났으므로 초당 40px로 움직이면 10 + 40 × 0.25 = 20이 됩니다. 이 함수를 앞의 500×300 Canvas에 연결하면 다음과 같은 애니메이션이 됩니다.

const ball = { x: 0, speed: 120 }; // 초당 120px
let previousMs = null;

function frame(nowMs) {
    if (previousMs !== null) {
        ball.x = nextPosition(ball.x, previousMs, nowMs, ball.speed) % canvas.width; // 모델 갱신
    }
    previousMs = nowMs;

    ctx.clearRect(0, 0, canvas.width, canvas.height); // 이전 그림 지우기
    ctx.beginPath();
    ctx.arc(ball.x, canvas.height / 2, 20, 0, 2 * Math.PI); // 지금 모델 그리기
    ctx.fill();

    requestAnimationFrame(frame); // 다음 프레임 예약
}

requestAnimationFrame(frame);

콜백이 받는 nowMs는 브라우저가 넘겨주는 밀리초 단위의 시각입니다. 공이 오른쪽 끝을 넘어가면 % canvas.width로 왼쪽에서 다시 시작합니다. 탭이 백그라운드로 가면 브라우저가 콜백을 멈추거나 줄이는데, 경과 시간으로 계산했으므로 돌아왔을 때도 속도는 그대로 유지됩니다.


정리

  • Canvas는 <canvas> 요소에 getContext('2d')로 얻은 컨텍스트로 그리며, 좌표는 왼쪽 위가 원점이고 y가 아래로 커집니다.
  • 선과 원은 beginPath()로 경로를 시작해 모양을 정한 뒤 stroke()나 fill()로 그리고, 사각형은 fillRect()나 strokeRect()로 바로 그립니다.
  • 글자는 fillText()와 strokeText()로 그리며, font, textAlign, textBaseline으로 크기와 정렬을 정합니다.
  • save()와 restore()는 색, 선, 글꼴, 좌표 변환 같은 그리기 상태만 저장하고 되돌리며, 경로와 이미 그린 픽셀은 되돌리지 않습니다.
  • 선명하게 그리려면 보이는 크기는 CSS로, 픽셀 버퍼는 devicePixelRatio를 곱한 크기로 따로 잡고, 크기를 바꾸면 상태를 다시 지정해 다시 그립니다.
  • 애니메이션은 requestAnimationFrame() 콜백마다 경과 시간으로 모델을 갱신하고, 지우고, 다시 그린 뒤 다음 프레임을 예약해 만듭니다.

연습 문제

1. 크기와 시간 계산하기

뷰포트 폭이 375, 양쪽 여백이 16, 높이가 300, DPR이 3일 때 반응형 실습의 CSS 크기, 버퍼 크기, 원의 CSS 중심을 계산해 보세요. 또 다른 이동 모델이 x 10에서 시작해, 이전 시각 1000ms부터 1500ms까지 초당 40px로 움직였습니다. 다음 x는 얼마일까요? restore()를 호출하면 이 이동이나 이미 그린 픽셀이 이전으로 돌아갈까요?

해설 보기

CSS 크기는 343×300, 버퍼는 1029×900이고, 원의 CSS 중심은 (171.5, 200)입니다. 500ms는 0.5초이므로 x는 10 + 40 × 0.5 = 30입니다.

restore()는 좌표 변환이나 스타일 같은 그리기 상태만 되돌리며, 모델의 위치나 이미 그린 비트맵은 되돌리지 않습니다. 그래서 애니메이션은 프레임마다 픽셀을 지우고 지금 모델을 다시 그립니다.

2. save를 하나 빼면

상태 저장 예제에서 파란 사각형 다음의 두 번째 ctx.save();만 지웠습니다. (180, 0)과 (240, 0)에 그려지는 사각형은 각각 무슨 색일까요?

해설 보기

둘 다 빨강입니다. 이제 스택에는 첫 save()가 저장한 빨강 상태 하나만 있으므로, 첫 restore()가 빨강으로 되돌려 (180, 0)이 빨강이 됩니다.

두 번째 restore()는 꺼낼 상태가 없으므로 아무 일도 하지 않고, 지금 상태인 빨강이 유지되어 (240, 0)도 빨강입니다. save()와 restore()는 짝을 맞춰 써야 의도한 상태로 돌아갑니다.

3. 같은 크기로 다시 정하기

앞의 도형들을 그린 뒤 ctx.fillStyle = 'red';를 실행하고, 이어서 canvas.width = canvas.width;를 실행했습니다. Canvas의 그림과 ctx.fillStyle은 어떻게 될까요?

해설 보기

그림은 모두 지워져 투명해지고, ctx.fillStyle은 기본값인 '#000000'으로 돌아갑니다. width나 height에 값을 넣으면 같은 값이라도 Canvas의 비트맵과 컨텍스트의 그리기 상태가 모두 초기화됩니다.

그래서 크기를 바꾼 뒤에는 반응형 실습처럼 좌표 변환과 스타일을 다시 정하고 처음부터 다시 그려야 합니다.

크기를 바꿀 때의 초기화와 비트맵 규칙은 HTML 표준의 canvas 요소에서, 기본 사용법은 MDN의 Canvas 튜토리얼 문서에서 확인할 수 있습니다.

다음 글 웹 워커와 서비스 워커에서는 무거운 계산을 메인 스레드 밖에서 실행하는 웹 워커와, 네트워크 요청을 가로채 오프라인에서도 동작하게 하는 서비스 워커를 살펴봅니다.