진격의 거인 너무 재밌다
그래서 벽 어쩌라고
요즘 웹 개발에 질려서 웹보다 express와 SwiftUI 개발을 더 많이 한 것 같다....
뭔가 신선한게 없을까 하다가 우리 학교 공인 프로젝트인 나르샤 프로젝트에서 블록코딩 서비스를 만들게 되었는데....
맨날 CRUD+a만 하던 나는
블록코딩 처럼 인터렉티브한 서비스를 개발해본 적이 없어서 어떻게 시작해야할지 감이 안잡혔다.
사실 원래는 내 담당 아니었는데
그렇다 원래 내 담당 아니었다. 하지만 굉장히 재밌어 보였기에 내가 뺐어왔다
합의된거니 오해 ㄴㄴ
엄청난 여정
이걸 구현하는데 까지 많은 여정이 있었다.
react-flow나 blockly, entryjs, scratch-gui 이런 오픈소스, 라이브러리들이 있어서 써보려고 했지만
도저히 나의 자존심이 허락하지 않았다 사실 디자인대로 절대 안나올 것 같아서 포기함
그래서 처음부터 만들기로 했다.
시도 1 - 리액트적 관점
“”리액트적 관점이 뭔데;;;
라고 묻는다면,
상태 === UI 라고 말할 것 같다.
리액트에서는 상태의 변경이 리렌더링을 일으키고 결국 UI의 변경을 만든다.
그래서 블록코딩에 적용하면, 블록들을 마우스로 잡아끌때마다 useState에 x, y를 저장하고,이 x, y 값에 따라 블록을 다시 그린다.그런데 이 방법은 인터렉티브한 웹서비스를 만들기 위한 방법과는 어울리지 않는다.결과적으로는 실패!
왜 실패인가?
리액트에는 초당 최대 리렌더링 횟수가 정해져있다.
그런데 만약 내가 블록을 마우스로 드래깅을 할 때, 1px마다 state가 업데이트되고, 초당 100px씩 10초간 한번도 멈추지 않고 움직인다 가정해보자.
x, y를 저장하는 state는 초당 100번씩 업데이트되고 총 1000번의 리렌더링을 수행한다.
이건 말도 안되는 수치이다. 사용자가 서비스를 사용하면서 10초 드래깅은 허다할 것이며, 100px도 화면상에서는 손톱만한 수치이다. 10초에 1000번 얼마 안된다고 생각할 수 있지만, 이는 단위수치이며, 실제 구현 및 테스트에서는 몇배에서 십몇배는 더 많이 된다고 생각하면 된다.
그렇기에 이 방법은 실패라고 할 수있다.
시도 2 - DOM을 직접 조작하기
이건 리액트보다는 기존의 HTML, JS를 사용하는 것과 다름없다. 서버에다 넘겨줄 블록 데이터와 UI를 분리하고, 상태 업데이트 -> UI 업데이트가 아닌, UI 업데이트 -> 상태 업데이트로 워크플로우를 변경했다.
이 방법은 반 성공이다. useState의 손아귀에서 벗어나게 되면서 무자비한 드래깅을 커버할 수 있게 되었으니까 말이다.
그럼 문제 해결한거 아님? 왜 반만 성공????
그 이유는 블록이 조립됐을 때를 가정하면 알 수 있다.
우리 프로젝트에서는 블록 데이터를 플랫 리스트에 저장한다.
그렇기 때문에 블록의 부모-자식 관계를 데이터상에서 직접 만들어주는 것이 아닌, parentId, childId등으로 명시만 해주게 된다.
이때 useState방식을 사용하면, 단순하게 부모 블록이 움직일 때, childId와 같은 id를 가진 노드를 플랫리스트에서 find()를 통해 찾아 x,y값을 변경하면, 그대로 리렌더링되어 UI에 반영이 되지만,
DOM을 이용한 방식에서는 UI와 데이터가 분리되었음으로 이 방법을 사용할 수 없다.
그리고 나는 플랫리스트에서 find를 통해 블록들이 움직일 때 마다 배열을 순회하여 x,y를 바꿔준다는게 마음에 안들었다. 아예 안 쓸 수는 없지만, 적어도 블록이 움직일때마다 그짓거리를 하는 건 용납할 수 없었다.
그렇기에 반은 성공, 반은 실패인 것이다.
시도 3 - 재귀함수, 재귀적 컴포넌트 사용을 통한 자식 블록 조작
이 방법이 사실상 답과 가장 가깝다. 답 이라기보단 내가 정착한 방식과 가깝다.
암튼 이 방법은 앞서 말한 자식노드를 변경하는 로직까지 DOM을 직접 조작하는 방식을 사용한다.
그리고 모든 블록을 root계층에서 UI에 그리는 것이 아닌, childId를 가진 블록들이 자신의 자식 블록을 컴포넌트 내에서 호출하는 것으로 변경하였다.

보이는 것과 같이, 블록 컴포넌트 안에서 또 블록 컴포넌트를 호출한다.
이 방식은 편향 이진 트리에서 영감을 받았다.
우리의 데이터 구조는 부모 블록이 가질 수 있는 자식 블록의 갯수가 하나이며, 이 자식 블록이 또 부모 블록이 되어 자식 블록에 영향을 주도록 설계하였다.
그렇기에 parentId가 없는 노드들만 가장 상위계층에서 렌더링시켜주면, childId가 있는 블록들은 알아서 자기 자식 블록을 재귀적으로 렌더링 한다.
왜 이렇게 함?
그 이유는 블록이 조립되었을 때 데이터상에서만 연결된 것이 아닌, UI상에서도 하나의 블록 뭉치가 될 수 있도록 하기 위함이다.
g태그를 사용해서 각 계층별 블록들을 묶어서 가장 최상위 부모를 움직이면 g태그가 모든 블록을 감싸게 구성하려고 했다. 결과적으로 이 방식은 매우매우매우 성공적이며, chatGPT의 말로는 엔트리나 스크래치도 재귀적으로 자식 블록들을 렌더링 한다고 한다.
시도 4 - 완성!
시도 3에서 작성한 내용을 바탕으로 블록을 조립하는 것과 분해하는 것을 구현하던 참이었다.
부모-자식 관계를 설정하고나서 조립되어있을 때 자식 블록의 위치를 정의해주는데, 투명 블록이 있는 것 처럼 블록간 연결부에 빈공간이 생기는 현상이 발생했다.
처음에는 재귀 로직을 잘못짰다고 생각해서 지정해주는 좌표값을 하나하나 console.log로 찍어보기도 하고, 재귀를 없애보기도 했지만, 해결되지 않았다.
그러던 순간!!!!!!
개발자 도구속 element탭을 우연히 확인하게 되었는데, 글쎄 자식 블록 g태그의 transform="translate(x, y)"의 좌표가 부모의 움직임에는 변함없이 고정인 것이다.
충격 그 자체였다.
g태그의 특성
g태그 안의 속성들은 svg태그의 좌표계가 아닌 자기들을 감싸고 있는 g태그의 좌표계를 따른다.
따라서 부모 태그의 위치가 아무리 변경되어도 g태그 속에서는 위치 변화가 없는 것이다.
빈공간이 생겼던 이유는 이 때문이었다. 실제 svg좌표계에서의 위치를 g태그 좌표계 속에 넣어두니 당연히 이상해질 수 밖에 없었다. 그래서 나는 자식으로 설정된 블록들의 위치를 (0, 48)로 설정해두었다. (y좌표는 부모 블록의 사이즈를 고려한 값이다.)
이렇게해서 모든 문제가 해결되었다.
블록의 위치값과 같은 데이터는 zustand스토어를 통해 업데이트하고, 데이터를 업데이트하는 시점은 드래그가 끝났을 때만 업데이트하는 것으로 업데이트 횟수를 반의 반의 반이나 줄였다.
처음에 고려했던 성능 저하 및 많은 횟수의 리렌더링이 발생하는 문제를 없애면서(!!!!) 요구사항을 모두 충족해낸 것이다.
그래서 왜 벽인데
엔트리, 스크래치 개발자님들한데 벽 느꼈다. 진심임. 존경합니다.
소감
이런 종류의 기능을 개발해본게 첨인지라 헷갈리는 것도 많고 어떻게 해야할 지 잘 몰라서 시간을 많이 쏟아 부은 것 같다.
그렇지만 사실 엄청나게 값진 경험을 했다.
1. 리액트에 상태 변경 횟수에 제한이 있는 것을 알게됐다.
2. zustand에는 .getState()를 통해 전역 상태에 구독하지 않고도 값을 가져올 수 있는 기능이 있다는 것을 알게 되었다. (언급 안함)
3. 개발하면서 처음으로 자료구조 개념을 사용해보았다.
(양방향 링크드 리스트 - 블록의 parentId, childId / 편향 이진 트리 - 블록 렌더링 구조)
이런 경험들은 사실 CRUD에서는 얻을 수 없는 경험들이라 생각한다.
학교에서 자료구조 할 때 솔직히 이해도 잘 안가고 직접 쓰질 않으니 안와닿았는데 이번 기회에 자료구조랑 조금 더 친해진 것 같다.
다시 한번 말하지만 엔트리랑 스크래치 개발자님들 진짜 존경해요..
이상 오랜만에 돌아온 나의 벨로그였습니다.
훈수는 언제나 환영
