마림바의 스마트 가이드는 어떻게 만들었을까?


스마트 가이드란?

Introduction picture for smart guide

스마트 가이드는 객체들을 움직일 때 다른 객체와의 정렬이나 간격을 고려해서 가이드라인을 표시해주거나 고정해주는 기능을 말합니다. 이 기능을 지칭하는 보편적인 용어가 없어서 저희는 스마트 가이드 및 스냅 기능으로 불렀는데, 다른 서비스나 애플리케이션에서는 안내선, 정렬선, 가이드라인, 마그넷, 스냅 등등 다양한 이름으로 부르고 있습니다. 이 기능이 등장한 이후로 무언가 화면에 그리고 배치하는 툴들은 이 기능이 없으면 허전하고 쓰기 어렵다는 인상을 주기 쉽기 때문에 온라인 화이트보드를 만드는 저희에게도 이 스마트 가이드 기능을 구현하는 것이 꽤나 중요한 과제가 되었습니다. 하지만 이 기능을 구현할 때 편의성, 성능, 구현 방식, 다른 컴포넌트와의 영향 등등 고민할 것이 많았는데 여기에서는 그 고민들에 대해서 이야기 해보고자 합니다.

개발을 위해 사용할 수 있었던 것들

본격적인 기능 개발을 시작하기 전에 항상 확인해봐야 할 것이, 내가 활용할 수 있는 API와 가져올 수 있는 정보들이 어떤 것들이 있는지 파악하는 것입니다. 스마트 가이드의 디자인은 비교적 명확하고 이견의 여지가 없었기 때문에, 그 디자인을 구현하기 위해 필요한 정보들도 명확했습니다. 현재 움직이는 객체의 좌표를 실시간으로 알 수 있어야 했고, 화면에 배치된 객체들의 종류, 좌표, 크기를 알 수 있어야 했고, 화면에 선을 임시로 그리거나 지우고 일정한 마우스 움직임 이내에서는 객체 위치를 고정할 수 있어야 했습니다. 만약 사전에 찾아봤을 때 구현에 필요한 정보와 API가 모두 있는게 확인 되었다면 그 개발은 매우 행복한 개발이 될 것이고, 그게 아니더라도 어지간히만 있고 일부 정보만 조금 가공하고 API 몇 개 새로 만드는 정도면 평범한 예상 범위라고 할 수 있을겁니다. 그리고 만약 기본적인 정보, 그러니까 객체들의 좌표 정보 조차 구할 수 있는 방법이 없다면 스마트 가이드의 계산과 표시에 관한 복잡한 로직을 만들기도 전에 기본 API부터 작업해야 했기에 매우 불행한 출발이 될 것 같았습니다.

그래서 기존 코드와 API를 열심히 뒤져보고 물어본 결과 다음과 같은 정보와 기능들을 활용할 수 있을 것이라 기대할 수 있었습니다.

  • 특정 영역 내에 존재하는 객체들의 종류, 좌표, 크기
  • Mouse Drag 이벤트에서 현재 움직이는 도형의 좌표를 받아올 수 있었음
  • 캔버스 기반이기 때문에 선 그리는 API는 당연히 있을 것이라 생각하고 확인 안 함
  • 마우스가 움직여도 객체가 움직이지 않게 고정시켜주는 Object Snapping의 경우 우리가 사용하는 캔버스 라이브러리인 Konva 사이트에 예제 코드가 있었음
  • 심지어 그 예제 코드에 스마트 가이드 표시 예제도 같이 있었음

개발에 필요한 대부분의 정보가 있었기에 무척 낙관적으로 개발을 시작할 수 있었고 어느 정도의 추정도 가능하게 되었습니다. 보통 Expert Judgement가 가능한 개발자들의 경우 이러한 사전 지식들, 그러니까 내가 활용할 수 있는 정보와 기능들이 어떤 것이 있는지 모두 파악하고 있기에 디자인이나 스토리를 확인하자마자 리스크, 일정, 심지어 개발 완료된 후 품질 수준까지도 예측이 가능한 경우가 많았는데 저는 이 스마트 가이드 기능을 개발할 당시에는 기존에 저희 서비스가 가지고 있는 소스코드를 모두 파악하고 있지 않은 상태에서 개발을 시작했기 때문에 이렇게 몇 가지 확인해보고 찾아보는 단계가 필요했습니다. 사실 결과적으로는 이 시기에 제가 했던 추정은 꽤 크게, 나쁜 방향으로 틀렸었는데 개발을 할 때 훨씬 더 많은 정보들이 필요했는데 그것을 미처 파악하지 못했기 때문이었습니다.

어떻게 개발할지 계획을 짜다

화면에 놓여있는 다른 객체들와 좌표와 내가 움직이는 객체의 좌표를 구할 수 있다면 객체간의 정렬 가이드선을 그리는 로직은 생각보다 간단합니다. 특히나 저희는 별이나 동그라미, 다이아몬드 같은 도형들도 있었지만 실제 정렬은 해당 도형에 외접하는 사각형을 기준으로 정렬하기로 했기 때문에, 그리고 기존 API에서 해당 사각형의 상하좌우 좌표를 모두 가져올 수 있었기에 생각보다 쉽게 끝날 수도 있겠다는 생각을 했습니다.

Simpel guideline calculation example

예를 들어서 정해진 위치에 가이드라인을 표시해주는 근접 거리를 플러스 마이너스 4픽셀이라고 가정한다면, 기존 화면에 놓여있는 파란색 사각형이 (10,20) - (30,50)으로 존재할 때 현재 움직이는 노란색 사각형의 x좌표가 6 ~ 14 사이이거나 26 ~ 34 사이일 때 해당 객체와의 세로 정렬선을 표시해줄 수 있습니다. 이 때 노란색 사각형 y 좌표는 고려하지 않아도 되며, 이 계산은 물론 상수 시간에 끝날 수 있습니다.

하지만 만약 화면에 객체가 N개 존재한다면 계산은 O(N) 시간이 걸릴 수 있습니다. 일반적인 케이스에서는 크게 성능에 지장을 주는 시간이 아니지만, 저희가 화면 내에 정말 많은 객체가 들어갈 수도 있고 이 계산 자체가 마우스 드래그 이벤트마다, 그러니까 밀리 세컨드 단위로 반복되는 계산임을 생각해보면 그렇게 쉽게 생각할 일은 아니라고 생각할 수 있습니다. 그리고 저희는 사용자가 움직이는 객체가 아닌 정렬의 대상이 되는 객체가 다른 사용자에 의해서 실시간으로 같이 움직일 수 있는 케이스도 충분히 발생할 수 있는데 이런 문제를 생각해보면 단순한 정렬선의 표시를 위해서도 꽤나 고민할 것이 많아집니다.

우선 문제를 조금 단순하게 생각하기 위해서 실시간으로 다른 객체가 움직이는 경우를 제외하고 생각해봤습니다. 실시간으로 다른 객체를 움직이는 경우 성능을 일정 수준 이상 희생하지 않으면 실시간으로 정렬을 유지하는 것이 다소 어려웠기에 저는 기본적으로 실시간으로 움직이는 객체는 가이드 표시 대상에서 제외하고 객체가 멈췄을때 다시 대상에 포함시켜주는 형태로 만들어야 겠다고 마음먹고 허락도 받았습니다. 그리고 실시간 객체를 제외한만큼, 보드에 얌전히 위치를 고정하고 있는 객체를 대상으로 가이드라인 표시를 빠르게 할 수 있는 방법에 대해서 고민해봤습니다.

사실 이런 종류의 개발에서 가장 쉽게 생각할 수 있는 큰 그림은, 전체적인 로직을 2개의 페이즈로 나누어서 수행하는 것입니다. 내가 움직이기 시작한 순간, 내가 움직이는 객체 외의 다른 객체들은 멈춰있다고 가정한다면, 그리고 다른 사용자들이 실시간으로 움직이는 객체들을 대상에서 제외함으로써 그 가정을 참으로 만들었다면 가이드라인의 표시는 가이드라인을 미리 계산하는 페이즈와, 움직이는 객체의 x,y 좌표에 따라 계산된 가이드라인을 표시해주는 페이즈로 나누어서 생각할 수 있습니다. 가이드라인을 미리 계산하는 과정은 드래그를 시작하는 순간 이루어지기 때문에 아주 약간 시간이 걸려도 괜찮았고, 표시될 가이드라인을 빠르게 선별하는 로직만 잘 구성한다면 성능상의 이득을 많이 가져올 수 있을 것이라 판단했습니다.

가장 쉽게 생각할 수 있는 방법은 맵(Map)을 사용하는 방법입니다. 예를 들어서 대상 객체가 (10,20) - (30,50)의 좌표에 위치한다면 Key가 10이고 값이 20~50인 가이드라인 객체를 가지는 원소와 Key가 30인 원소를 가지고 있는 해쉬 맵을 만들어서 객체의 x좌표가 10일 때 Map.get(10)과 같은 형태로 현재 x좌표에 맞는 가이드라인을 한 번에 불러올 수 있습니다. y 좌표에 대에서도 또 다른 맵을 만들어서 2개의 맵으로 가이드라인을 가져올 수 있었기에 이 방식은 마우스의 드래그 이벤트가 발생했을 때 매우 빠르게 표시해야 할 가이드라인이 있는지 파악할 수 있는 로직이었습니다. 실제로는 x좌표가 10일 때 표시해야 할 가이드라인이 다수일 수 있기 때문에 여러 개의 value를 가질 수 있는 멀티맵(Multi Map) 형태의 자료구조를 사용하는 것이 쉽게 구현 가능한 방식이었습니다.

하지만 문제를 이렇게 단순하게 해결할 수는 없었는데, 저희가 표시해야 할 가이드라인을 찾는 기준이 x = 10 과 같이 하나의 Key로 떨어지는 것이 아니라 x가 26 ~ 34 사이일 때와 같은 형태로 일정한 범위를 Key로 가지는 형태였기 때문입니다. 이는 사용자가 움직이는 객체가 정확히 가이드라인의 위치에 닿지 않고 근처에만 가더라도 가이드라인을 찾아서 표시해주고 그 가이드라인의 위치에 객체를 딱 붙여서 고정해주는 스냅기능을 만들기 위해서 매우 필수적으로 들어가야 하는 구현이었습니다. 따라서 성능을 고려하여 스마트 가이드를 표시해주는 기능의 구현은 다음과 같은 문제로 다시 정리할 수 있었습니다.

‘범위를 Key로 가져서 상수 시간이나 로그 시간으로 값을 가져올 수 있는 자료구조나 알고리즘이 존재하는가?’

당장 떠오르는 자료구조가 없었기에 열심히 자료를 찾아봤는데, 다행히도 목적에 맞는 자료구조를 찾을 수 있었습니다. 바로 Interval Tree라는 자료 구조인데, Interval Tree는 이진탐색트리(Binary Search Tree)의 일종으로 각 노드의 값이 범위를 가질 수 있으며, 탐색 역시 범위로 탐색하여 찾고자 하는 범위와 오버랩 되는 노드를 검색할 수 있는 자료구조 입니다. 이진탐색트리의 경우 노드의 검색에 O(log N)의 복잡도가 소요되기 때문에 저희 화면에 표시되는 일반적인 객체 수(보통 10~20개, 많으면 100~200개)를 고려해보면 거의 상수시간에 탐색을 완료한다고 생각해도 좋았습니다.

Code example for interval tree library

이 정도 파악을 마치고 난 뒤, 자연스럽게 npm에서 관련 라이브러리들을 검색해봤고, 라이브러리가 제공하는 API가 제가 원하는 기능들, 그러니까 범위를 Key로 지정하여 데이터를 저장하고, 범위를 이용해서 값을 탐색하며, 같은 범위를 가진 여러 데이터가 중복해서 들어갈 때 모두 리턴이 가능한지 등등을 모두 지원하고 있음을 확인했습니다. 다만 후술할 객체간의 간격 가이드라인을 표시하기 위해서는 범위가 min ~ max를 가지는 선의 형태가 아닌, x,y좌표의 범위를 모두 가지는 2차원 형태로 지정될 수 있다면 정말 좋았을거라 생각했는데 안타깝게도 2차원 범위를 Key로 가지는 자료구조나 라이브러리를 찾는 것은 실패해서 일단은 Interval Tree만 가지고 스마트 가이드 기능을 구현하기로 결정했습니다.

순조로운 개발과 그렇지 못한 결과

차마 탄탄하다고는 말하지 못하겠지만 그럭저럭 고민을 많이 해보고 개발을 시작했기에 초기 개발은 꽤나 순조로운 편이었습니다. 드래그를 시작할 때 객체들의 좌표를 수집한 뒤 가이드라인을 미리 만들어서 Interval Tree에 저장하는 로직은 크게 어렵지 않게 만들 수 있었고, Interval Tree는 원하는 데이터를 기대한 계산시간 내에 가져다 주어서 실제로 객체를 드래그 할 때 성능에 거의 영향을 주지 않으면서도 다수의 객체에 대해서도 문제 없이 가이드라인을 표시해주도록 구현할 수 있었습니다.

하지만 실제로 가이드라인이 표시되기 시작했을 때, 사전에 크게 고민안하고 넘겨버렸거나 미처 생각하지 못한 부분들이 실질적인 문제로 나타나기 시작했는데 이 문제들은 크게 다음과 같았습니다.

  • 드래그를 시작하는 순간 계산량이 화면내 객체 숫자에 비례하여 늘어나는 점
  • 다른 사용자가 실시간으로 움직이는 객체를 제대로 처리하지 않으면 해당 객체가 움직이기 전의 원래 위치를 기준으로 한 유령 가이드라인이 표시되는 점
  • 객체 간의 간격을 계산해서 보여주는 등간격 가이드라인은 상하좌우 정렬 가이드라인과는 완전 다른 차원이 문제였던 점

특히나 이 문제들은 다시 복잡하게 얽히면서 더 큰 문제를 만들어냈는데, 사전 계산 페이즈에서 상하좌우 정렬 가이드라인은 O(N) 시간 내에 가이드라인을 모두 구해서 Interval Tree에 집어넣을 수 있었지만 등간격 가이드라인은 하나의 객체를 기준으로 또 다시 다른 객체들과의 거리를 측정해서 계산해야 했기 때문에 N제곱 시간이 소요되었습니다. 이는 객체 숫자가 늘어날 수록 계산량이 많아져서 끊김이 발생하는 원인이 되었고, 실시간 객체를 고려할 경우 객체간의 관계가 뒤틀리기 때문에 제외할 가이드라인을 찾는 것도 어려운 일이 되었습니다.

Comment of smart guide calculation

그래서 일단 이 문제를 하나하나 해결할 방법을 고민하기 시작했습니다.

객체량에 비례하여 연산량이 증가하는 문제

사실 이 문제는 예상하고 있었기 때문에 비교적 차분하게 대처할 수 있었습니다. 우선 연산량이 지나치게 많아지면 UI에 끊김 현상이 발생할 우려가 있었기 떄문에 만약 지연이 심하게 일어난다면 조금 심각한 문제가 될 수 있었는데, 다행히도 여러 방면의 계산 로직을 모두 반영한 이후에도 드래그를 시작하는 순간 계산 로직은 객체가 많아져도 심각한 지연을 발생시키지는 않았습니다. 다만 유즈 케이스에 따라서는 이 계산이 불편함을 유발할 수 있겠다는 생각이 들어서 다음과 같은 완화 수단을 미리 생각해두었습니다.

  • 화면 내에 객체가 일정 숫자 이상인 경우 가이드 기능을 해제하고 계산을 안 하도록 변경 : 화면 내에 객체가 계산이 밀릴 정도로 많아지는 경우 (예 : 1,000개 이상) 가이드의 표시 자체가 오히려 사용자 움직임에 방해를 줄 수 있으므로 가이드를 표시하지 않는 것도 하나의 방법이라고 생각했습니다
  • 가이드가 다소 늦게 표시되더라도 UI에 끊김을 유발하지 않도록 사전 계산을 Web Worker를 이용해 백그라운드로 돌리는 방안
  • 하나의 자료구조에 모든 가이드라인을 넣어서 통합하기 보다는 표시해야 하는 가이드라인의 종류에 따라 Interval Tree를 다수 만들어서 계산 시간을 단축하는 방안

등이 고려되었고, 기능을 릴리즈 한 뒤 사용자 피드백에 따라서 일부 개선사항들을 적용하는 형태로 해결하고자 했습니다.

다른 사용자가 실시간으로 움직이는 객체와의 가이드라인 표시

제가 구현한 로직에 의하면 드래그를 시작하는 순간 화면에 존재하는 객체들의 좌표와 크기 정보를 가지고 가이드라인을 미리 만들어두기 때문에 내가 객체를 드래그 하는 도중 다른 사용자가 또 다른 객체를 드래그하면, 다른 사용자가 움직이는 객체는 기존 위치에서 가이드라인이 나타나는 문제가 발생할 수 있습니다. 이는 개발을 시작할 때도 예상했던 문제이고, 실제로 다른 실시간 협업 솔루션에서도 발견할 수 있었던 버그였기 때문에 애초에 개발을 할 때 살짝 해결방안을 고려해서 개발하기는 했습니다.

이는 가이드라인을 사전에 계산해서 만들어둘 때, 대상 객체의 ID를 Key로, 이 객체와 연관성 있는 가이드라인의 배열을 Value로 가지는 별도의 관계성 맵을 만들어서 유지하는 것인데, 다른 사용자가 객체를 움직이거나 삭제했다는 메시지를 받았을 경우 맵에서 해당 객체와 관련 있는 가이드라인을 상수시간에 식별해낸 뒤, Interval Tree에서 해당 가이드라인을 모두 삭제해버리는 방식이었습니다. 이 방식은 상대가 실시간으로 객체를 움직이거나 삭제할 경우 그 객체를 가이드라인 표시 대상에서 제외함으로써 유령 가이드라인이 남는 현상을 방지할 수 있었습니다.

객체 간의 간격 가이드라인을 계산하고 표시하기

사실 이 문제가 가장 해결하기 어려웠고, 최초의 개발 사이즈 추정을 실패하게 만든 원인이기도 했습니다. 앞에서 제가 2차원 형태의 좌표 영역을 Key로 가지는 자료구조를 찾다가 못 찾아서 그냥 넘겨버렸다고 말씀을 드렸는데, 실제로 객체의 간격을 표시하는 가이드라인은 기존 상하좌우 정렬 가이드라인에 비해서 차원이 한 단계 높은 검색이었기 때문에 기존의 로직을 확장하거나 변형하는 형태로는 해결하기 어려웠던 것입니다.

Determine area for distribution guideline

예를 들어서 객체 A와 객체 B, 객체 C 사이의 간격이 20이라고 가정한다면, 간격 가이드라인은 A와 B사이, B와 C 사이에 생기게 되고 사용자가 움직이는 객체 D가 위치했을 때 이 가이드 라인들을 표시해줘야 하는 좌표는 그림의 파란색 표시 지점에 해당합니다. 상하좌우 가이드라인은 X 좌표를 기준으로 할 때는 Y 좌표를 신경쓰지 않아도 되었고, 그 반대의 경우에도 X 좌표를 신경쓰지 않아도 되었기에 Interval Tree에서 특정 X나 Y 좌표가 범위 내에 들어가는지만 확인하면 되었지만, 간격 가이드라인의 경우 X좌표와 Y좌표가 동시에 범위내에 들어가는 가이드라인을 찾아내야 했기에 Interval Tree를 사용해서 로그 시간 내에 표시해야 할 가이드라인을 찾는 것이 불가능했습니다.

이러한 간격 가이드라인을 표시하기 위해서 두 가지 정도의 아이디어를 생각할 수 있었습니다. 첫번째는 처음에 계획했던 사전에 가이드라인을 미리 계산하고 표시해주는 로직을 포기하고, 등간격 가이드라인은 그냥 움직이는 객체에 따라서 가로 방향 혹은 세로 방향으로 일치하는 객체들을 계속 실시간으로 찾아내서 그 간격을 계산한 뒤 보여주는 방식이었습니다. 이는 최초에는 객체들 전체를 순회해야 했기에 성능 문제가 심하게 나타날 수 있었지만, Interval Tree에 각 객체의 가로 세로 좌표 영역을 저장해두고 스캔하면 조금 더 빠르게 처리할 수 있을 것으로 기대할 수 있었습니다. 하지만 여전히 실시간으로 가이드라인을 계산하는 것이, 화면내에 존재하는 객체의 숫자에 선형적으로 비례하여 계산 시간이 증가할 위험을 가지고 있다는 점에서는 쉽게 선택하기 어려운 아이디어 였습니다.

다른 방식으로는 가이드라인을 자료구조에 저장하고 이를 검색하여 표시해주는 방식이 아니라, 간격 가이드라인에 한해서는 대상 객체들이 각각 자신과 다른 객체들간의 간격 정보를 저장하도록 마는 뒤 해당 객체를 가로 혹은 세로 범위를 기준으로 다시 Interval Tree에 집어 넣는 방식이었습니다. 이는 사용자가 객체를 드래그 해서 움직이면 x 좌표 혹은 y 좌표를 기준으로 자신과 겹치는 객체들을 검색해낸 뒤, 다시 각각의 객체와 사용자 객체간의 거리를 계산하고, 이 게산된 거리가 각각의 객체가 가지고 있는 다른 객체와의 간격 정보와 플러스 마이너스 4픽셀 이내에 위치하는 경우 해당 가이드라인을 표시해주는 방식이었습니다. 다만 해당 가이드라인과 같은 간격을 가지는 가이드라인을 모두 표시해줘야 되었기 때문에, 가이드라인들은 다시 간격을 기준으로 맵에 저장되었고, 해당 가이드라인과 간격 및 좌표 범위가 일치하는 가이드라인들을 검색하여 화면에 그려주는 방식을 사용할 수 있었습니다.

이러한 방식은 사실 첫번째 아이디어인 실시간으로 계속 대상 객체들을 검색해서 가이드라인을 그려주는 방식을 반쯤 차용한 방식이었는데, 대상 객체들을 Interval Tree를 이용하여 실시간으로 계속 검색하 되, 일단 대상 객체를 식별하고 난 뒤에는 사전에 계산해두었던 간격 정보를 이용하여 빠르게 표시해야 할 가이드라인을 표시해주는 방식으로 계산 속도를 절약할 수 있었습니다.

개발의 결과

이후 객체를 가이드라인에 스냅해주고 떨어뜨리는 기능, 좌표를 움직일 때가 아니라 사이즈를 조절할 때도 가이드라인을 맞추어주는 기능, 자기 자신을 복제할 때 자기 자신과 가이드라인을 표시해주는 기능 등등 다채롭고 어려운 문제들이 다수 있기는 했지만 최초 상하좌우 및 간격 가이드라인 표시 및 스냅을 기본으로 스마트 가이드 기능은 몇 차례 개선 작업을 통해 순차적으로 릴리즈 되어 현재의 모습을 완성하게 되었습니다.

이 스마트 가이드라인은 이후에도 사용성의 개선이 필요하거나 성능의 문제가 발생했을 때 다양한 형태로 개선하여 반영할 예정이니, 앞으로도 많이 지켜봐주셨으면 좋겠습니다.