이제 찬바람을 맞으며 출근할 수 있는 계절이 왔다. 가을 냄새가 물씬 풍길 때, 여름이 다 간줄 알았는데 계속 더워서 너무 설래발을 쳤다고 생각했다. 하지만 이제 출근 후에도 미니 선풍기를 켤 필요가 없고 에어컨 바람이 점점 불편해지기 시작했다. 출근 전에 환기를 위해 창문을 열어 놓으면 그래도 기분 좋게 선선한 바람이 불어온다.

월급을 받으면서 일한지 9개월이 다 되어간다. 사람들은 내가 너무 익숙한 나머지 2-3년차 프론트엔드 개발자인줄 안다. 하지만 아직 회사 도메인의 전체적인 흐름보다 부분 부분 아는 정도이고 더듬거리면서 코드를 읽는다. 조금 웃픈건 입사 초기에 만들었던 (3월쯤) 기능이 밀리다 밀리다 이제서야 배포되었다는 것과 그 제품 덕분에 PWA를 해볼 수 있었다는 것 그리고 너무 오래전 작성했던 코드라 배포를 하기 위해 코드를 열어봤을 때 너무 썩은내가 진동해서 놀랬다는 것이다. 아직 코드를 작성한지 6개월 밖에 안됐는데 벌써 레거시가 된 것이다. 하지만 다른 일들이 너무 많아서 코드를 손볼 수가 없었고 다른 분이 '이거 왜 이렇게 짠거지...?'라고 생각 할 것을 떠올리면 미안하다. 미리 사과의 말씀을 드린다.(정말 미안합니다.)

6월 말부터 사용자가 포스에 테이블 배치를 가상으로 하는 테이블 등록 기능 개발에 참여했다. 이전에 있었던 기능은 KonvaJS로 되어 있었는데 유지보수 난의도가 극악이었다. 새로 들어오신 분은 코드 3줄 넣는데 하루 종일 봤다고 킹받는다고 표현을 할 정도였다. 사용자 인터렉션도 그다지 훌륭하지 않고 이 기능에서 테이블이나 층을 삭제할 때 다른 기능과 엮여있는 것이 많은 것에 비해 방어로직이 전무해서 문제도 많은 부분이었다. 게다가 대표님이 연초부터 점주들이 요구하는 기능을 추가했으면 한다고 계속 어필(?)해오셨고 덕분인지 기획, 포스, 백엔드 그리고 내가 모인 회의를 시작으로 기능을 리뉴얼 하기 시작했다.

처음에는 자신 만만하게 시작했다. 팀장님에게 '아이폰 같은 인터렉션이 구현된 테이블 등록' 기능을 만들것이라고 매일 스크럼 때마다 호언 장담을 했다. 하지만 이게 웬걸... 캔버스는 css flex와 grid를 사용할 수 없네? 일정한 간격으로 등록되는 테이블을 만들기 위해 하루를 썼다. 그만큼 캔버스의 세계는 너무 낯선 곳이었다. React KonvaJS를 사용해서 테이블 등록 기능을 만들면서 겪은 낯설은 이야기를 적어보려고 한다.

KonvaJS

캔버스 라이브러리의 종류는 생각보다 많다. 대표적으로 KonvaJS, p5JS, threeJS와 게임 제작 툴인 PhaserJS 등이 있다. 각 라이브러리마다 장단점이 있다.

React KonvaJS는 캔버스를 감싼 KonvaJS를 React에서 사용하기 쉽게 한번 더 감쌓은 라이브러리다.

공식문서
KonvaJS

컴퓨터 그래픽스

KonvaJS에서 제공하는 예제들만으로도 내가 구현하려는 기능을 충분히 다 구현할 수 있었다. 하지만 그 과정이 너무 힘들었다. 일단 Canvas는 그래픽스를 다루기 위한 툴이다. 많이 만드는 그림판 기능과 사진 편집 애니메이션 등을 구현할 수 있다. 컴퓨터 그래픽스 기본 지식이 없어도 어차피 KonvaJS에서 알아서 다 렌더링 해주기 때문에 쉐이더나 메테리얼 구현 지식은 그다지 필요하지 않다.(물론 그 기능을 상용으로 구현해야한다면 방법을 찾아봐야한다.) 하지만 간단한 도형을 일정한 간격으로 정렬하거나 도형을 이동하거나 도형과 도형이 부딪쳤을 때 무언가를 한다거나 하는 것은 간단한 수학 지식이 필요하다.

간단한 수학이란 반올림, 내림, 삼각함수, 덧샘, 뺄샘, 나머지, 나눗셈 등이다. 내가 구현한 기능에서 필요한 수학 지식은 이게 다였다. 하지만 '낫 놓고 기억자도 모른다'는 말이 있듯이 예제 코드를 찾아보면서 '이 수학 지식이 이렇게 쓰인다고?'를 연발했었다.

수학 지식들

Arrage - 어떻게 도형을 일정한 간격으로 놓을 것인가.

서두에 말했지만 캔버스에는 css flex나 grid를 사용할 수 없다. 그 말은 내가 직접 일정한 간격으로 도형이 정렬되는 코드를 작성해야한다는 것이다. 캔버스 위에서 도형의 좌표는 왼쪽 상단의 x, y 좌표를 사용한다. 그 좌표를 기준으로 width와 height 만큼 선과 분을 그리고 그 안에 내가 넣고 싶은 색상을 채우게 된다. 캔버스의 넓이와 높이를 정하면 캔버스 안에 사용자가 사용하기에 가장 편리한 크기의 Default Shape의 width와 height를 정할 수 있다. 이건 기획자가 만들어준 요구 조건에 따라 정하면 된다. 예를 들어 가로 세로로 100개의 도형이 들어가야한다면 다음과 같이 코드를 작성할 수 있다.

createShape.ts
1const MAXIMUM_COUNT_WIDTH = 10;
2 const MAXIMUM_COUNT_HEIGHT = 10;
3
4 const createShape = (canvasWidth:number, canvasHeight:number) => {
5 const shapeWidth = canvasWidth / MAXIMUM_COUNT_WIDTH;
6 const shapeHeight = canvasHeight / MAXIMUM_COUNT_HEIGHT;
7
8 return {width:shapeWidth, height:shapeHeight}
9 }

도형 한개의 넢이와 높이를 만들었다. 그럼 좌표를 넣어야한다. 먼저 x 좌표부터 구현해보자.

createShapes.ts
1const createShapes = (shapeCount: number) => {
2 const canvasWidth = 1000;
3 const canvasHeight = 1000;
4 const { width, height } = createShape(canvasWidth, canvasHeight);
5
6 const shapes = Array.from({ length: shapeCount }, (_, index) => {
7 return {
8 width,
9 height,
10 x: (index % 10) * width
11 };
12 });
13};

x 좌표는 개발자라면 누구나 풀어봤던 알고리즘을 기억한다면 쉽게 구현할 수 있다. index % 10은 index가 100개든 1000개든 0-9의 범위 안에서 반복된다. 여기에 width를 곱하면 0, 10, 20, 30...으로 x 좌표가 나열된다. 그런데 문제가 있다. 여기에 gap을 넣기 위해서는 어떻게 해야할까? 나는 여기서 한참 걸렸다. 만약 gap이 2만큼이라면 도형 오른쪽에 2만큼의 padding이 들어가야한다.

createShapes.ts
1// ...생략
2 x : (index % 10) * width + (index % 10) * (width + 2)

처음에는 간단하게 width에 padding 만큼 더하면 된다고 생각했다. 하지만 그렇게 하면 도형이 2만큼 평행으로 이동할 뿐이다. padding이 가상의 도형이라고 생각하고 그 도형이 width + 2만큼 있다고 생각해야한다. 그럼 왼쪽에 padding이 2만큼 생긴다.

y 좌표도 생각보다 구하기 쉽다. y좌표는 x가 10번 반복되면 원하는 height만큼 이동하면 된다.

creatShapes.ts
1// 생략
2y : Math.floor(index / 10) * height + Math.floor(index / 10) * (height + 2)

여기서는 버림을 사용한다. 그럼 index가 0-9까지는 소수점 단위이기 때문에 y좌표는 0이된다. 그 다음 10-19까지는 1, 20-29까지는 2가된다. 여기에 height를 곱하면 y좌표가 height만큼 이동할 수 있다. 버림이 이렇게 유용하게 사용될줄 누가 알았겠나?

Collision Detection - 도형이 서로 충돌했는지 어떻게 알 수 있을까?

KonvaJS는 도형의 왼쪽 상단 꼭지점의 x, y 값을 기준으로 해서 배치된다고 했다. 그럼 나머지 꼭지점의 값을 구하는 방법은 간단하다. 아래 그림을 보면 확 감이 온다.

도형의 좌표 평면

그럼 두 도형이 겹친 것은 A 도형의 좌표값이 B 도형의 좌표값 안에 있으면 충돌했다고 판단할 수 있다. 이 함수는 KonvaJS 공식 Document에 Example에서 잘 구현되어있다.

Collision Detection Example
haveIntersection

haveIntersection.ts
1interface RectBoundary {
2 x:number;
3 y:number;
4 width:number;
5 height:number;
6 }
7
8 function haveIntersection(r1:RectBoundary, r2:RectBoundary) {
9 return !(
10 r2.x > r1.x + r1.width ||
11 r2.x + r2.width < r1.x ||
12 r2.y > r1.y + r1.height ||
13 r2.y + r2.height < r1.y
14 );
15 }

이 함수와 예제 코드를 사용해 두 도형이 겹쳤을 때, 색상을 변경해서 사용자에게 알려줄 수 있다. 또한 도형이 겹쳐있을 때는 저장을 못하게 할 수도 있으며 collision detection을 응용하면 도형이 캔버스 범위 밖으로 나가지 못하게 할 수 있다.

하지만 내가 소개한 collision detection은 빙산의 일각이다. 원이나 원과 사각형, 사각형과 사각형의 충돌 애니메이션을 구현하려면 삼각함수를 알아야한다. 삼각함수는 두 도형이 충돌했을 때, 어느 방향 그리고 어느 각도로 도형이 튕겨져 나갈 것인지를 결정할때 유용하게 쓰인다. 사실 이번 예제를 구현하기 전부터 삼각함수나 벡터의 개념은 공부를 해봤지만 여전히 익숙하지가 않다. 이 부분에 대해서 조금 더 자세하게 알고 싶다면 마지막 부록에 나와있는 예제 링크를 참조하길 바란다.

Matrix 행렬 - 도형을 어떤 방향에서 늘리고 줄이는지 어떻게 알 수 있을까?

이 문제는 내가 가장 오랫동안 고민하고 버그를 해결하기 위해 이곳 저곳을 뒤적거리면서 겨우 발견한 코드와 그 코드에 대한 GPT의 설명을 기록한다. 일단 도형의 크기를 사용자가 마음것 변형할 수 있어야 했기 때문에 (크기가 제한되어있기는 하지만) Transform 기능이 필요했다. Transform 기능에 대한 유용한 예제는 역시 KonvaJS의 예제로도 충분하다.

예제
Transform Basic Example

그런데 내가 구현한 기능은 도형 여러개를 조합한 그룹이었다. 그런데 오른쪽 하단을 늘리면 버그 없이 잘 늘어나는데 왼쪽 상단이나 왼쪽 모서리 왼쪽 하단을 늘리면 도형이 춤을 추었다.(농담이 아닌 정말 춤을 췄다.) 그러다가 group 상태에서 도형이 춤추지 않고 잘 변형되는 예제를 찾았다.

Transform multiple selected shapes

이 예제에서는 matrix를 활용한다. 컴퓨터 그래픽스에서 사용되는 행렬은 두 가지 종류가 있다. GPT에게 물어봤더니 다음과 같이 대답해주었다.

절대 변환 행렬과 변환 행렬은 그래픽 요소의 위치, 회전, 크기 등을 표현하기 위해 사용되는 개념입니다.

변환 행렬 (Transformation Matrix) : 변환 행렬은 2D 그래픽 요소의 위치, 회전 및 크기 변경과 같은 변형을 표현하는 행렬입니다. 2차원 상에서 x와 y 축에 대한 변환을 나타내며, 아핀 변환(이동, 회전, 크기 조절)과 선형 변환(스케일링, 세로 변형 등)을 적용하는 데 사용됩니다. 이러한 변환 행렬을 조합하면 다양한 변형을 단일 행렬로 나타낼 수 있습니다.

절대 변환 행렬 (Absolute Transformation Matrix): 절대 변환 행렬은 그래픽 요소가 현재 위치에서 상위 요소 또는 캔버스 전체에서의 위치로 어떻게 변환되는지를 표현합니다. 각 그래픽 요소는 상위 그래픽 요소의 변환을 상속받으며, 이를 통해 캔버스 전체에서의 절대적인 위치와 변형을 결정합니다.

요약하면, 변환 행렬은 개별 그래픽 요소의 로컬 변환을 표현하며, 절대 변환 행렬은 상위 요소의 변환을 반영하여 그래픽 요소의 캔버스 전체에서의 위치와 변형을 나타냅니다.
여기서 a, b, c, d는 변환 행렬의 2x2 부분 행렬의 요소들을 나타내며, 변환 행렬은 다음과 같이 표현됩니다

1// | a b e |
2// | c d f |
3// | 0 0 1 |
4
5const matrix = [a, b, e, c, d, f];

Transform multiple selected shapes 예제를 보면 delta를 볼 수 있다.

1var delta = a * d - b * c;

왜 delta를 사용해서 transform을 하는지 궁금했다.

변환 행렬의 2D 부분 행렬은 첫 번째 두 행과 열로 이루어져 있습니다. 따라서 a, b, c, d는 이 변환 행렬의 2D 변환 부분에 해당합니다.
delta는 행렬식을 계산한 결과로, a _ d - b _ c로 계산됩니다. 행렬식은 변환 행렬이 어떤 종류의 변형을 수행하는지에 대한 정보를 제공합니다.
delta > 0: 이 경우, 변환 행렬은 일반적인 확대(Scaling) 또는 축소를 나타냅니다. a와 d가 서로 다른 부호를 가지는 경우입니다.
delta = 0: 이 경우, 변환 행렬은 비율 유지를 포함한 선형 변환이며, 크기 변화 없이 기울임(스키팅)이나 회전을 나타낼 수 있습니다.
delta < 0: 이 경우, 변환 행렬은 뒤집힌 확대(Scaling)나 반전을 나타냅니다. a와 d가 같은 부호를 가지는 경우입니다.
따라서 delta 값은 변환 행렬의 유형과 효과를 파악하는 데 도움을 주는 중요한 정보를 제공합니다.

변환 행렬은 transform에서 어느 방향으로 크기를 변형하는지 크기 변형의 증, 감을 결정할 때 사용한다. 3D에서는 3차원 행렬을 사용한다고 하는데 아직은...

그럼 최종적으로 transform은 matrix를 사용해서 구현했을까? 그렇지 않다. 구현은 다른 예제를 통해 구했다. 하지만 행렬이 컴퓨터 그래픽스에서 크기와 기울임 회전 그리고 그 요소들의 증감을 결정하는데 쓰인다는 것을 알게 되었다. react konva로 multple selection을 하는 방법에 대해서는 아래 예제를 공유한다.

react-konva. multiple selection.

Sanp - 도형의 위치를 옮겼을 때 어떻게 하면 아이폰처럼 grid 안에서 제자리를 찾아가게 할까?

이 구현은 가장 어렵다고 생각했는데 찾아보니 KonvaJS 튜토리얼 페이지에 그냥 있었다. 찾는 것도 능력이다. 그런데 Snap은 너무 허무하게 쉽게 구현되어있었다. 어차피 x, y의 스냅을 구현하는 방법은 똑같으니까 x 좌표만 살펴보자.

1x: Math.round(rectangle.x() / blockSnapSize) * blockSnapSize;

어플리케이션에서 스냅은 보통 내가 옮기려는 오브젝트가 어떤 위치나 오브젝트에 자석처럼 '착!'하고 달라붙는 것처럼 보인다. 그럼 어느 위치로 옮길 때 일정 크기 이상을 옮기면 달라 붙으면 되고 아니면 다시 원래 자리로 돌아오면 된다. 그럼 x좌표를 구할 때 x 좌표를 스냅의 크기로 나누면 소수점이 된다. (위의 y좌표를 구하는 것처럼) 그럼 소수점을 반올림을 하면 어떻게 될까? 50% 이하로 움직이면 x는 움직이지 않는다. 하지만 그 이상 움직이면 60%만 이동해도 blockSnapSize만큼 이동하는게 된다. 그럼 화면에서 보면 자석이 철에 달라 붙는 것처럼 달라 붙게 된다.

예제 KonvaJS 예제 Blog Post Snap to grid with KonvaJS

마무리

캔버스를 다루기 쉽진 않았다. 일단 react의 상태를 konva에 전달해서 렌더링 해야했고, konva에서 렌더링된 후 변환된 값을 다시 react 상태로 전달해야 했기 때문에 상태 관리가 많이 복잡했다. 이것 말고도 memento도 구현을 해야했고, 키보드 이벤트 관리도 어려웠고 무엇보다 마우스 이벤트가 드레그, 클릭, 컨텍스트 메뉴 클릭 등 여러가지가 있었기 때문에 구현하면서 버그를 상당히 많이 잡았다. 이번 구현은 캔버스 위에 도형이 100개가 최대이기 때문에 렌더링 이슈가 클것같지 않지만 만약 figma와 같은 크기의 kanvas라면 렌더링 최적화 문제도 생각해야한다. 무엇보다 이 기능을 다시 리뉴얼한 이유는 사용자가 사용하기 너무 어려운 기능이었기 때문이다. 조금 더 부드럽게 움직이고 쉽게 오브젝트를 만들고 쉽게 이동하고 크기를 줄일 수 있는 기능을 만들고 싶었다. 완전히 아이폰처럼 물리 시스템이 구현 된 것은 아니다. 만약 기회가 된다면 MatterJS와 같은 물리 엔진 라이브러리를 사용해서 보다 더 부드러운 모션을 적용하고 싶다.

컴퓨터 그래픽스는 이전부터 관심만 많았지만 본격적으로 시작해본 것은 처음이다. 만약 아직 혼자 개발한면 '해야지 해야하는데'만 반복하고 있을지도 모른다. 컴퓨터 그래픽스는 내가 생각했던것과 다르게 기초 수학 지식과 활용 능력을 요구한다. 오브젝트를 다루기 위해서는 오브젝트의 크기와 위치를 결정하는 것 외에도 움직이는 방향, 충돌, 부서진 이후 파편들의 방향, 간격 등을 결정하는데 무작정 라이브러리에만 의존할 수 없기 때문이다. 코딩을 위한 수학을 공부 해봐야겠다.

나중에 구현한다면 KonvaJS도 좋지만 ThreeJS를 활용해서 구현해보고 싶다.

항상 느끼는 거지만 나는 왜 이렇게 하고 싶은게 많은걸까? 평범하게 살면서 하고싶은게 많은건 재앙인 것 같다.

부록 - 유용한 자료

컴퓨터 그래픽스에 입문하고 싶다면 다른 좋은 자료도 많지만 수학을 공부하고 싶다면 아래 자료를 추천한다. CodingMath - Trigonometry

복잡한 충돌에 대한 자료는 유툽에 많다. 그중 하나를 소개한다. How to Code: Collision Detection Part 2

그밖에 KonvaJS 공식 문서의 Tutorial을 뒤지다 보면 유용한 것들이 많다.