웹 기반 인테리어 설계 에디터를 구현하기 위해 필요한 것들 [1편]
에디터 엔진 아키텍처와 DX 설계
인터페이스부터 설계하기
에디터 엔진을 개발하면서 가장 큰 숙제는 '나중에 벽체, 가구, 마감재 같은 수많은 도면 요소들을 추가할 때, 어떻게 하면 엔진 자체를 뜯어고치는 일 없이 쉽게 추가할 수 있을까?'였습니다. 좋은 개발자 경험(DX)를 위한 확장성 있는 아키텍처가 절실했죠.
그래서 무작정 코드를 짜는 대신, 맨 처음 시도한 건 실제 구현 없이 도면 요소를 정의하는 클래스를 시뮬레이션해 보는 것이었습니다. 새로운 요구사항에 의해 도면 요소를 추가해야 하는 개발자의 입장에서, 어떻게 코드를 작성하는 게 가장 편할지 먼저 상상하고 정의한 뒤 그 형태에 맞춰 구현을 끼워 맞추는 방식입니다.
물론 에디터에는 씬그래프, 직렬화, HTML Canvas 렌더링, React UI와 통합 등 굵직하게 설계해야 할 시스템이 많았습니다. 그런데도 제가 도면 요소 설계부터 파고든 이유는 명확했습니다. 앞으로 개발 과정에서 가장 빈번하게 추가되거나 수정되어야 하는 대상이 바로 이 도면 요소들이었기 때문입니다.
그래서 저는 일단 엔진에 '문(Door)'이라는 새로운 객체를 추가해야 한다고 가정하고, 백지상태에서 코드를 써내려가 봤습니다.
export class DoorEntity extends Entity {
start: Point = new Point(0, 0);
end: Point = new Point(0, 0);
depth: number = 100;
isDragging = false;
update() {
const walls = this.scene.findChildrenOf(WallEntity);
...
}
onMouseMove(event: ControlEvent): void {
if (isDragging) {
const vector = new Vector(event.deltaX, event.deltaY);
start.translate(vector);
end.translate(vector);
...
}
}
render(context: RenderingContext): void {
context.beginPath();
...
}
}DoorEntity의 뼈대를 잡은 모습입니다. Entity라는 부모 클래스를 상속받게 했습니다. 무조건적인 상속을 선호하지는 않지만, 잘 사용한다면 개인적으로는 "이 필수 함수들만 구현하면 작동하겠구나!" 하는 편안한 느낌을 받는 것 같습니다.
문이라는 도면 요소를 어떻게 정의할 수 있을까요? 일단 도면 위에서 문의 위치는 시작점(start)과 끝점(start)이라는 좌표평면 위의 두 점으로 표현될 수 있을 겁니다. 그리고 문이 가지는 두께는 깊이(depth) 변수에 의해 정의될 수 있습니다. 실제로는 프레임 두께, 손잡이 종류, 타공 여부, 열리는 방향 등 많은 속성이 더 필요하다는 점을 참고해 주세요. isDragging은 상호작용할 때 내부 상태를 기억해두기 위해 추가했습니다.
그 다음은 Entity로부터 상속받은 필수 함수들을 하나씩 채워봤습니다. 가장 먼저 update() 함수입니다. 이 함수는 사용자가 상호작용을 하지 않아도 매 틱마다 돌아가야 하는 상태 변화를 처리하기 위해 필요합니다. 예를 들어 "내가 지금 어느 벽(WallEntity)에 붙어있지?"를 파악하는 식이죠.
여기서 눈치가 빠른 분이라면 알아차리셨겠지만, DoorEntity는 같은 도면 위의 WallEntity를 불러오기 위해서는 씬그래프를 사용하고 있습니다. 씬그래프는 도면 객체들을 계층적으로 관리하는 트리 구조의 데이터입니다. 트리 구조는 피그마나 유니티 등 많은 에디터 엔진에서 이미 쓰이고 있는 방식이기도 하고, 경험적인 선호에 따라 선택한 것입니다. 트리 구조는 종속 관계 덕분에 검색이 편리하고, 일괄 삭제도 가능하며, 동일 계층 사이 순서를 이용해 에디터 엔진에서는 그려지는 순서와 상호작용하는 순서도 관리할 수 있는 등 장점이 많습니다.
다음 공통 함수 부분은 onMouseMove()입니다. 마우스 이동 상호작용을 위한 onMouseMove를 예로 들있지만 onMouseDown이나 onKeyDown 등 다른 상호작용을 위한 함수들도 있습니다. 여기서는 사용자 입력에 따른 Entity 내부 속성의 변경을 처리합니다. 예를 들어서 마우스가 이동한 만큼 DoorEntity의 start 값과 end 값을 조정해 문 요소를 이동시키는 겁니다.
마지막 공통 함수는 render() 함수입니다. render() 함수를 이용해 DoorEntity 클래스가 도면 위에 그려지는 모습을 구현할 수 있습니다. context 객체 안에는 직선 그리기, 곡선 그리기, 색 변경 등 기초적인 렌더링을 위한 함수들이 들어가야 할 겁니다.
실제로 제가 설계한 에디터의 모든 도면 요소는 4가지 부분으로 정리할 수 있었습니다.
1. 데이터로 요소를 정의하는 속성 부분
2. 스스로 상태를 갱신하는 update()
3. 유저의 입력에 반응하는 상호작용 (onMouseMove, onKeyDown...)
4. 화면에 보여주는 render()
구현은 저절로 나온다
이 설계 과정에서 재미있는 부분은 따로 있습니다. DoorEntity라는 껍데기를 먼저 정의하고 나면, 정작 뼈대가 되는 기반 시스템의 구현 방향은 자동으로 잡힌다는 점입니다.
"DoorEntity가 이렇게 생겼고 이런 기능들을 쓰니까, 부모인 Entity와 전체 씬을 관리하는 Scene은 이런 형태여야 되는구나" 하고 거꾸로 설계가 됩니다. 이렇게 하면 편리한 개발자 경험을 유지한 채로 시스템을 만들어나갈 수 있습니다.
import type { Scene } from "./scene";
export class Entity {
parent: Entity;
children: Entity[] = [];
get scene(): Scene {
return this.parent.scene;
}
update() {}
onMouseMove(event: ControlEvent) {}
render(context: RenderingContext) {}
findChildrenOf<T>(type: abstract new (...args: any[]) => T): T[] { ... }
}import { Entity } from "./entity";
export class Scene extends Entity {
override get scene(): Scene {
return this;
}
}Entity 자체가 트리 구조에서 하나의 노드로 작동할 수 있게끔 parent와 children 속성을 추가했습니다. 그러면 Scene은 Entity를 상속하기만 하면 별도의 Entity 검색 기능을 구현하지 않고도 부모 함수를 이어받아서 사용할 수 있습니다. 이를 통해서 앞서 update() 함수에서 사용한 this.scene.findChildren(WallEntity) 라인이 작동합니다.
ControlEvent나 RenderingContext 같은 매개변수도 마찬가지로 도면요소가 필요로 하는 기능에 맞춰서 구현을 추가해 나갔습니다.
편리함보다 중요한 객체 관계
실제로 인테리어 설계 에디터를 개발할 때는 Undo&Redo 기능을 위한 구현도 들어가야 합니다. 예를 들어 DoorEntity를 삭제했을 때 다시 복구할 수 있어야 하기 때문입니다. 그렇다면 이러한 기능은 어떻게 설계할 수 있을까요? 마찬가지로 코드 구현을 먼저 시뮬레이션해 보았습니다.
export class DoorEntity extends Entity {
onKeyDown(event: ControlEvent) {
this.startAction("delete door");
this.destroy();
this.commitAction();
}
}startAction() 함수로 새로운 스냅샷을 만들어 저장하고, Undo&Redo 요청이 있을 시 스냅샷에 저장된 데이터를 바탕으로 복원할 수 있습니다.
좌우지간 startAction() 함수를 this에서 호출하기 위해서는 Entity 부모 클래스가 해당 함수를 가지고 있어야 합니다. 하지만 그렇다고 정말 startAction() 함수와 commitAction()을 Entity 클래스에 추가하다간 마지막에는 Entity 클래스가 잡다한 함수들로 가득찰 것이라고 생각했습니다.
한편으로 ReversibleEntity 같은, Entity의 새로운 자식 클래스에 함수를 넣고 상속하게끔 만드는 것도 무리가 있다고 생각했습니다. 여러가지 기능을 넣다 보면 상속되는 족보가 엄청나게 길어질 것이기 때문이죠. 상속이 길어지는 것은 오히려 수정을 어렵게 만들기 때문에 선호하지 않습니다.
그래서 저는 composition을 선택했습니다. 그래서 이렇게 코드를 고쳐봤습니다.
export class DoorEntity extends Entity {
memory: Memory = new Memory();
onKeyDown(event: ControlEvent) {
this.memory.startAction("delete door");
this.destroy();
this.memory.commitAction();
}
}startAction()을 호출하기 위한 코드가 조금 더 길어졌지만 객체 간 상속 및 참조 관계는 코드 스타일보다 훨씬 중요하다고 생각합니다. 충분히 감수할 수 있습니다.
다만 Undo&Redo를 구현하기 위해서 매번 DoorEntity에 new Memory();를 할당하고 싶지는 않았습니다. 그렇다고 같은 이유로 Entity에 넣고 싶진 않았죠.
그렇다면 싱글톤을 이용하는 건 어떨까요? 하지만 저는 싱글톤 패턴 사용을 최대한 피하려고 했습니다. 왜냐하면 경우에 따라 애플리케이션 내부 상태에 씬그래프가 두 개 존재할 수 있기 때문입니다. 실제로 제가 개발한 에디터는 프린트 기능을 구현하기 위해 새로운 씬그래프를 복사해야 했습니다.
그렇다면 Memory 클래스는 씬그래프가 한 개씩 가지고 있어야 한다는 말이기도 합니다. 그래서 저는 모든 Entity가 가지고 있는 scene을 이용하기로 했습니다. scene을 통해서 memory에 접근하는 것입니다.
export class DoorEntity extends Entity {
onKeyDown(event: ControlEvent) {
this.scene.memory.startAction("delete door");
this.destroy();
this.scene.memory.commitAction();
}
}export class Scene extends Entity {
memory: Memory = new Memory();
}다시 구현은 조금 더 길어졌지만, 모든 제약조건과 객체 간 상속 및 참조 관계를 해치지 않으면서 원하는 기능을 구현할 수 있었습니다.
이처럼 편리한 DX 설계는 좋지만, 실제 구현을 만들어 나가면서 절충안을 만들어가는 과정이 중요하다고 생각합니다.


.png)


.png)
![[TS] import type으로 순환 참조 문제 해결하기](https://d113dev8twsy0m.cloudfront.net/users/3e7da289-b684-467a-b639-c3a2adf57b89/%E3%85%87%E3%85%87.png)