이 튜토리얼의 앞부분에서 우리는 canvas 그리드와 좌표 공간에 대해 배웠어요. 지금까지는 기본 그리드만 사용하고 필요에 따라 전체 canvas의 크기만 변경했어요. 변환을 사용하면 원점을 다른 위치로 이동하고, 그리드를 회전하고, 심지어 크기를 조절할 수 있는 더 강력한 방법들이 있어요.
안녕하세요! 캔버스 튜토리얼을 계속해서 이어 나가고 계시네요. 이번 챕터는 캔버스를 다루는 데 있어 진정한 "마법"이라고 할 수 있는 '변환(Transformations)'과 '상태 저장 및 복원(Saving and restoring state)'에 대한 내용입니다.
이 기능들을 제대로 이해하면, 복잡한 도형을 일일이 좌표 계산해서 그릴 필요 없이 캔버스 자체를 돌리고, 옮기고, 크기를 키워서 아주 쉽게 그림을 그릴 수 있게 됩니다. 코딩 테스트에서 배열을 회전시키는 문제를 풀 때의 그 느낌과도 비슷할 거예요! 실무 팁과 함께 자세히 번역해 드릴게요.
변형(transformation) 메서드들을 살펴보기 전에, 먼저 캔버스에 복잡한 그림을 생성하기 시작할 때 절대 없어서는 안 될 두 가지 필수 메서드부터 알아보겠습니다.
save()
캔버스의 전체 상태(state)를 그대로 저장합니다.
restore()
가장 최근에 저장되었던 캔버스의 상태를 다시 불러와 복원합니다.
캔버스의 상태들은 내부적으로 스택(stack) 구조에 저장됩니다. 자료구조 공부하실 때 보셨던 그 스택 맞습니다! save() 메서드가 호출될 때마다, 현재의 드로잉 상태가 스택의 맨 위에 푸시(push)됩니다. 여기서 말하는 하나의 드로잉 상태란 다음 요소들로 구성됩니다:
translate, rotate, scale – 아래에서 자세히 배웁니다).여러분은 save() 메서드를 원하는 만큼 여러 번 호출할 수 있습니다. 그리고 restore() 메서드가 호출될 때마다, 스택의 맨 위에 있던(가장 최근에 저장된) 상태가 팝(pop)되어 빠져나오고, 그 안에 저장되어 있던 모든 설정값들이 캔버스에 다시 복원됩니다.
💡 강사의 핵심 팁:
이save()와restore()는 실무에서 함수 단위로 캔버스 렌더링을 쪼갤 때 필수적으로 사용되는 패턴입니다!
예를 들어drawCharacter()라는 함수 안에서 펜 색깔을 '빨간색'으로 바꿨다고 해볼게요. 만약 함수가 끝나기 전에 원래 색깔로 돌려놓지 않으면, 그 이후에 그려지는 배경이나 다른 UI까지 전부 빨간색으로 물들어 버립니다.
그래서 독립적인 그리기 함수를 짤 때는 무조건 함수 첫 줄에ctx.save();를 적고, 맨 마지막 줄에ctx.restore();를 적어주는 것을 습관화하셔야 합니다. 그래야 다른 코드에 부작용(side-effect)을 일으키지 않아요!
save 및 restore 캔버스 상태 예제<canvas id="canvas" width="150" height="150"></canvas>
function draw() {
const ctx = document.getElementById("canvas").getContext("2d");
ctx.fillRect(0, 0, 150, 150); // 기본 설정(검은색)으로 큰 사각형 그리기
ctx.save(); // 원래의 기본 상태(검은색 펜)를 스택에 저장 (Save 1)
ctx.fillStyle = "#0099ff"; // 저장 후 설정을 변경 (파란색 펜)
ctx.fillRect(15, 15, 120, 120); // 새로운 설정으로 두 번째 파란 사각형 그리기
ctx.save(); // 현재의 상태(파란색 펜)를 스택에 저장 (Save 2)
ctx.fillStyle = "white"; // 다시 설정을 변경 (하얀색 펜)
ctx.globalAlpha = 0.5; // 반투명 설정 추가
ctx.fillRect(30, 30, 90, 90); // 가장 최근 설정으로 세 번째 반투명 하얀 사각형 그리기
ctx.restore(); // 이전 상태(파란색 펜)로 복원! (Restore 1)
ctx.fillRect(45, 45, 60, 60); // 복원된 파란색 설정으로 네 번째 사각형 그리기
ctx.restore(); // 맨 처음 원래 상태(검은색 펜)로 복원! (Restore 2)
ctx.fillRect(60, 60, 30, 30); // 복원된 검은색 설정으로 마지막 사각형 그리기
}
첫 번째 단계에서는 캔버스의 기본 설정(검은색)을 사용해서 큰 사각형을 그립니다. 그런 다음 save()를 호출해서 이 상태를 저장하고, fillStyle 색상을 파란색으로 변경합니다. 이어서 두 번째로 작은 파란색 사각형을 그리고 다시 한 번 save()를 호출해 상태를 저장하죠. 다시 그리기 설정을 변경하여 세 번째 반투명한 흰색 사각형을 그립니다.
지금까지는 우리가 이전 섹션들에서 해왔던 것과 크게 다르지 않습니다. 하지만 첫 번째 restore() 구문을 호출하는 순간 마법이 일어납니다! 스택의 맨 위에 있던 드로잉 상태(파란색 펜)가 꺼내어지고 설정이 복원됩니다. 만약 우리가 save()를 사용해 상태를 저장해 두지 않았다면, 이전 상태로 돌아가기 위해 펜 색상을 다시 파란색으로 바꾸고 투명도(globalAlpha)를 원래대로 되돌리는 코드를 수동으로 일일이 작성해야 했을 겁니다. 설정값이 두 개뿐이라면 쉽겠지만, 관리해야 할 속성이 많아질수록 코드는 순식간에 길어지고 복잡해지겠죠.
두 번째 restore() 구문이 호출되면, 스택에 남아있던 원래의 초기 상태(첫 번째 save를 호출하기 전 상태, 즉 검은색 펜)가 복원되며, 마지막 사각형은 다시 검은색으로 그려지게 됩니다.
우리가 살펴볼 첫 번째 변형 메서드는 translate()입니다. 이 메서드는 캔버스의 그리드(좌표계) 원점 자체를 다른 위치로 이동시키는 데 사용됩니다.
translate(x, y)
캔버스와 그 그리드의 원점을 이동시킵니다. x는 수평(좌우)으로 이동할 거리를, y는 수직(상하)으로 이동할 거리를 나타냅니다.
변형(transformations) 작업을 하기 전에는 항상 캔버스의 상태를 저장(save())해 두는 것이 좋은 습관입니다. 대부분의 경우, 변형된 좌표계를 일일이 역산해서 제자리로 돌려놓는 것보다 그냥 restore 메서드를 호출해서 한 번에 원래 상태로 되돌리는 것이 훨씬 더 쉽기 때문입니다. 또한 반복문 안에서 평행 이동을 하면서 캔버스 상태를 저장/복원하지 않으면, 원점이 계속 누적해서 이동하기 때문에 그림이 캔버스 모서리 밖으로 벗어나 보이지 않게 되는 참사가 발생할 수 있습니다.
translate 예제이 예제는 캔버스의 원점을 평행 이동시켰을 때 얻을 수 있는 장점을 보여줍니다. 만약 translate() 메서드를 쓰지 않았다면 모든 사각형들은 똑같이 원점 (0,0) 위치에 겹쳐서 그려졌을 겁니다. translate() 메서드를 사용하면 fillRect() 함수 안의 좌표 수치를 수동으로 일일이 계산해서 바꿀 필요 없이, 캔버스 위의 원하는 위치에 자유롭게 사각형을 툭툭 배치할 수 있는 자유를 얻게 됩니다. 코드를 이해하고 사용하기가 훨씬 수월해지죠.
draw() 함수 내에서, 우리는 두 개의 for 루프를 사용해 fillRect() 함수를 아홉 번 호출합니다. 각 루프가 돌 때마다 (1) 캔버스가 평행 이동되고, (2) 사각형이 그려진 뒤, (3) 캔버스가 다시 원래 상태로 복원됩니다. 매번 fillRect(0, 0, 25, 25)처럼 완전히 똑같은 좌표를 사용하지만, translate()가 드로잉 기준 위치 자체를 이동시켜 주기 때문에 각기 다른 위치에 사각형이 그려지는 것을 눈여겨보세요.
<canvas id="canvas" width="150" height="150"></canvas>
function draw() {
const ctx = document.getElementById("canvas").getContext("2d");
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
ctx.save(); // 루프 시작 시 원점 상태 저장
ctx.fillStyle = `rgb(${51 * i} ${255 - 51 * i} 255)`;
// x와 y를 이동시켜 새로운 원점을 설정합니다.
ctx.translate(10 + j * 50, 10 + i * 50);
// 항상 0, 0에서 그림을 그리지만, translate 덕분에 다른 위치에 그려집니다.
ctx.fillRect(0, 0, 25, 25);
ctx.restore(); // 사각형을 그린 후 원점을 다시 원래 위치로 복구!
}
}
}
두 번째 변형 메서드는 rotate()입니다. 이 메서드는 현재 원점을 기준으로 캔버스 전체를 핑그르르 회전시키는 데 사용됩니다.
rotate(angle)
현재 원점을 중심으로 캔버스를 시계 방향으로 angle 라디안(radians) 만큼 회전시킵니다.
회전의 중심점은 항상 캔버스의 현재 원점입니다. 만약 중심점을 바꾸고 싶다면(예: 도형 자체의 중심을 기준으로 돌리고 싶다면), 먼저 translate() 메서드를 사용하여 캔버스의 원점을 그 위치로 이동시켜야만 합니다.
rotate 예제이 예제에서는 rotate() 메서드를 사용해서 두 가지 회전 방식을 보여줍니다. 먼저 캔버스의 기본 원점(좌측 상단)을 기준으로 사각형을 회전시켜 보고, 그다음에는 translate()의 도움을 받아 사각형 자체의 중심점을 기준으로 회전시켜 보겠습니다.
참고:
각도는 도(degrees)가 아닌 라디안(radians)을 사용합니다. 변환하려면radians = (Math.PI/180)*degrees공식을 사용하세요.
<canvas id="canvas" width="300" height="200"></canvas>
function draw() {
const ctx = document.getElementById("canvas").getContext("2d");
// 왼쪽 사각형들: 캔버스의 원점(0,0)을 기준으로 회전
ctx.save();
// 파란색 사각형 (회전 전)
ctx.fillStyle = "#0095DD";
ctx.fillRect(30, 30, 100, 100);
// 캔버스를 25도 회전시킵니다.
ctx.rotate((Math.PI / 180) * 25);
// 회색 사각형 (회전 후)
ctx.fillStyle = "#4D4E53";
ctx.fillRect(30, 30, 100, 100);
ctx.restore(); // 회전 상태 초기화
// 오른쪽 사각형들: 사각형 자체의 중심을 기준으로 회전
// 파란색 사각형 (회전 전)
ctx.fillStyle = "#0095DD";
ctx.fillRect(150, 30, 100, 100);
// [강사의 핵심 팁] 도형 자체의 중심으로 회전하는 3단계 마법!
// 1. 캔버스 원점을 사각형의 정중앙(x + 폭의 절반, y + 높이의 절반)으로 옮깁니다.
ctx.translate(200, 80);
// 2. 그 상태에서 캔버스를 25도 회전시킵니다.
ctx.rotate((Math.PI / 180) * 25);
// 3. 다시 캔버스 원점을 원래 위치로 되돌려 놓습니다.
ctx.translate(-200, -80);
// 회색 사각형 (회전 후)
ctx.fillStyle = "#4D4E53";
ctx.fillRect(150, 30, 100, 100);
}
사각형을 자기 자신의 중앙을 기준으로 회전시키려면, 먼저 캔버스의 원점을 사각형의 중심으로 이동(translate)시킨 다음, 캔버스를 회전(rotate)시키고, 캔버스의 원점을 다시 0,0 위치로 되돌려놓은(translate) 뒤에 사각형을 그려야 합니다.
다음 변형 메서드는 크기 조절(scaling)입니다. 캔버스 그리드의 단위(unit) 크기 자체를 늘리거나 줄이는 데 사용됩니다. 도형이나 비트맵 이미지를 작게 그리거나 확대해서 그릴 때 활용할 수 있습니다.
scale(x, y)
캔버스의 단위를 수평(x축)으로 x배, 수직(y축)으로 y배 만큼 크기를 조절합니다. 두 매개변수 모두 실수입니다. 값이 1.0보다 작으면 단위 크기가 줄어들고(축소), 1.0보다 크면 단위 크기가 늘어납니다(확대). 값이 1.0이면 크기가 그대로 유지됩니다.
음수(-) 값을 사용하면 축의 반전(미러링, 대칭 이동)을 수행할 수도 있습니다. (예를 들어 translate(0, canvas.height); scale(1, -1);을 사용하면, 우리가 수학 시간에 배웠던 것처럼 원점이 좌측 하단에 위치하고 위로 올라갈수록 y값이 커지는 친숙한 데카르트 직교 좌표계를 만들 수 있습니다!)
기본적으로 캔버스의 1 유닛은 정확히 화면의 1픽셀과 같습니다. 하지만 우리가 scale 비율을 0.5로 적용한다면, 결과적으로 1 유닛은 0.5 픽셀이 되어 버립니다. 따라서 그려지는 모든 도형이 절반 크기로 줄어들어 그려지게 되죠. 반대로 비율을 2.0으로 설정하면 유닛 크기가 커져서 1 유닛이 2 픽셀을 차지하게 됩니다. 결과적으로 도형이 두 배 크게 그려집니다.
scale 예제마지막 예제에서는 서로 다른 크기 조절(scaling) 비율을 사용하여 도형을 그려보겠습니다.
<canvas id="canvas" width="150" height="150"></canvas>
function draw() {
const ctx = document.getElementById("canvas").getContext("2d");
// 아주 작게 10x10 사이즈로 사각형을 그리지만, 가로로 10배, 세로로 3배 확대합니다.
ctx.save();
ctx.scale(10, 3);
ctx.fillRect(1, 10, 10, 10);
ctx.restore();
// 수평(x축)으로 대칭(거울) 이동시킵니다.
ctx.scale(-1, 1);
ctx.font = "48px serif";
// 캔버스가 좌우 반전되었으므로, 화면에 보이게 하려면 x 좌표를 음수로 주어야 합니다!
ctx.fillText("MDN", -135, 120);
}
마지막으로, 다음 메서드들은 내부적인 '변환 행렬(transformation matrix)' 자체를 직접 수정할 수 있게 해줍니다.
transform(a, b, c, d, e, f)
현재 적용되어 있는 변환 행렬에 매개변수로 전달된 새로운 행렬을 '곱합니다(덧붙입니다)'. 변환 행렬은 다음과 같은 구조를 가집니다:
[ a c e ]
[ b d f ]
[ 0 0 1 ]
만약 인자 중 하나라도 Infinity(무한대)가 있다면, 메서드가 예외를 튕겨내는 대신 변환 행렬이 '무한'으로 표기됩니다.
이 함수의 각 매개변수가 뜻하는 바는 다음과 같습니다:
a (m11): 수평 확대/축소 (Horizontal scaling)b (m12): 수평 기울이기/비틀기 (Horizontal skewing)c (m21): 수직 기울이기/비틀기 (Vertical skewing)d (m22): 수직 확대/축소 (Vertical scaling)e (dx): 수평 평행 이동 (Horizontal moving)f (dy): 수직 평행 이동 (Vertical moving)setTransform(a, b, c, d, e, f)
현재의 변환 행렬을 '항등 행렬(identity matrix, 초기 상태)'로 완벽하게 리셋한 다음, 동일한 매개변수를 가지고 transform() 메서드를 호출합니다. 쉽게 말해 현재까지 누적된 모든 변환을 싹 다 지우고(undo), 방금 지정한 변환 상태로 새로 덮어쓰는 동작을 단 한 번의 단계로 처리합니다.
resetTransform()
현재의 변환을 항등 행렬로 리셋합니다. ctx.setTransform(1, 0, 0, 1, 0, 0);를 호출하는 것과 완전히 동일한 동작입니다.
transform과 setTransform 예제<canvas id="canvas" width="200" height="250"></canvas>
function draw() {
const ctx = document.getElementById("canvas").getContext("2d");
const sin = Math.sin(Math.PI / 6); // 30도의 사인 값
const cos = Math.cos(Math.PI / 6); // 30도의 코사인 값
ctx.translate(100, 100);
let c = 0;
// transform()을 사용해 30도씩 비틀고 돌리며 사각형을 12번 연속해서 그립니다.
for (let i = 0; i <= 12; i++) {
c = Math.floor((255 / 12) * i);
ctx.fillStyle = `rgb(${c} ${c} ${c})`;
ctx.fillRect(0, 0, 100, 10);
// 현재 행렬에 새로운 행렬을 계속 곱해서 누적 변환시킵니다.
ctx.transform(cos, sin, -sin, cos, 0, 0);
}
// 여태까지의 모든 회전/비틀기/이동을 싹 다 초기화하고, 완전히 새로운 행렬로 덮어씁니다!
// (x축 대칭 이동 + x/y 100씩 평행이동)
ctx.setTransform(-1, 0, 0, 1, 100, 100);
ctx.fillStyle = "rgb(255 128 255 / 50%)";
ctx.fillRect(0, 50, 100, 100);
}
이번 장에서는 캔버스를 요리조리 돌리고 비트는 기술을 배웠습니다! 선형대수학의 행렬 개념이 살짝 들어가서 어렵게 느껴질 수도 있지만, 실무에서는 주로 save, restore, translate, rotate 네 가지만 조합해서 사용하는 경우가 대부분이랍니다. 직접 좌표를 움직이며 실습해 보세요! 궁금한 점이 생기면 편하게 물어보시고요.