프로그래밍 · 

웹 기반 인테리어 설계 에디터를 구현하기 위해 필요한 것들 [3편]

에디터 엔진 아키텍처와 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 설계는 좋지만, 실제 구현을 만들어 나가면서 절충안을 만들어가는 과정이 중요하다고 생각합니다.

Composition over Inheritance

도면 요소를 추가하다 보면 개구부를 추가해야 하고, 개구부는 대표적으로 창문과 문으로 나뉩니다. 그리고 문도 한 가지 종류만 있는 게 아니죠. 일반적인 여닫이문, 미닫이문, 그리고 문 중에서도 타공이 되어 있는 문, 아니면 자동문이나 폴딩도어 같은 종류들을 클래스로 정의해야 했습니다.


처음에는 상속을 사용해서 다양한 문 클래스를 정의했습니다. 예를 들어 목문 클래스는 다음과 같은 상속 구조로 구현할 수 있었습니다.

Entity
^
SegmentEntity
^
OpeningEntity
^
DoorEntity
^
SwingDoorEntity
^
WoodDoorEntity

자그마치 5개의 부모 클래스를 가지고 있었습니다.

  • SegmentEntity는 좌표평면 위의 두 점을 통해서 정의되는 선분 요소를 정의했습니다. 시작점과 끝점이라는 두 속성을 내려주는 클래스입니다. SegmentEntity를 상속하는 자식 클래스로는 개구부를 나타내는 OpeningEntity와 함께 WallEntity도 있었습니다.

  • OpeningEntity는 깊이(depth) 속성과 함께 벽면을 따라 움직일 수 있게 해주는 함수를 내려주는 클래스입니다. 인테리어 설계에 있어서 개구부란 창문이나 문처럼 벽면에 뚫려 있는 부분을 가리킵니다.

  • DoorEntity는 다른 Window Entity와 구분해주는 클래스입니다. 클래스 이름을 통해서 씬그래프에 존재하는 객체를 불러올 때 유용합니다: this.scene.findChildren(DoorEntity)

  • SwingDoorEntity는 여닫이문으로서 힌지 위치와 열리는 방향 등의 속성을 가지고 있고, 여닫이문 도면 기호를 렌더링하는 함수도 내려주는 역할을 합니다.

  • 마지막으로 WoodDoorEntity는 여닫이문중에서도 특정한 문짝 두께 범위를 지정하고, 유리 여닫이문에서는 존재하지 않는 타공 위치에 대한 속성을 가집니다.


언뜻 봐도 상속 구조가 너무 깊어 보입니다. 저의 경우에는 이런 문제가 생겼습니다. 예를 들어 양개도어 같은 클래스를 만들어야 하는 때입니다. 기존에 SwingDoorEntity는 편개도어로서 힌지 위치 같은 속성이 필요했지만 양개도어는 그렇지 않습니다. 그렇다면 SwingDoorEntity와 같은 계층에서 DoorEntity를 상속하는 DoubleDoorEntity를 만들어야 합니다. 하지만 그러자마자 양개도어는 목문도 있고 유리문도 있기 때문에 WoodDoubleDoorEntity와 GlassDoubleDoorEntity를 만들어야 하는 수고로움이 생깁니다. 미닫이문이라도 생기면 만들어야 하는 클래스는 배가 됩니다.


더 큰 문제가 있습니다. 예를 들어 곡면 창을 위한 클래스를 추가해야 했을 때입니다. 곡면 창은 제가 분류한 체계 아래에서는 ArcWindowEntity > WindowEntity > OpenginEntity > SegmentEntity 순으로 상속해야 할 것처럼 보입니다. 하지만 실제로 ArcWindowEntity는 SegmentEntity가 제공하는 선분 속성과 함수로는 표현이 어렵습니다. 이러한 곡선 요소를 정의하려면 ArcEntity 같은 새로운 클래스를 만들어야 하는데, 그러면 또 새로운 요소를 추가할 때마다 클래스를 반복적으로 만들어 줘야 하는 번거로움이 생기게 되겠죠.


원인은 상속 구조가 깊기도 하지만 상속을 여러 가지 목적으로 썼다는 점에 있습니다.

  • Entity는 시스템이 작동하는데에 필요한 공통 함수들을 공유하는 목적을 갖습니다.

  • SegmentEntity는 도면 위에서 나타나는 기하학적인 속성을 공유하는 목적을 갖습니다.

  • OpeningEntity는 다른 벽체 요소를 타고 다니게끔하는 상호작용을 공유하는 목적을 갖습니다.

  • DoorEntity는 클래스를 다른 클래스와 구별하는 목적을 갖습니다.


그러다 보니 만약 최하위 클래스가 SegmentEntity가 제공하는 기하학적 속성과 맞지 않거나 OpeningEntity가 제공하는 상호작용 방식과 맞지 않는다면 아예 새로운 상속 트리가 필요하게 되는 것입니다.


그래서 제가 선택한 구조는 ArcWindowEntity, WoodDoorEntity 같은 최하위 클래스로 다른 클래스와 서로를 구별하게끔 하고, 최하위 클래스에서 시스템 작동을 위한 공통 함수를 내려주는 Entity 부모 클래스를 곧바로 상속하도록 하는 방식입니다. 기존 SegmentEntity나 OpengingEntity 같은 중간 단계 클래스는 모두 Composition 패턴을 통해 공통 속성 및 함수들을 공유해 주는 것입니다.


즉 다음과 같은 모습이 됩니다.

export class WoodDoorEntity extends Entity {
  geometry: SegmentGeometry;
  
  interaction: OpeningInteraction;
  
  constructor() {
    super();
    
    this.geometry = new SegmentGeometry();
    
    this.interaction = new OpeningInteraction(geometry);
  }
  
  ...
}

이렇게 해서 어떤 창문이든 문이든 기하학적 속성이 달라지거나 상호작용 방식이 달라져도 새로운 상속 트리를 추가할 필요 없이 자유롭게 구현할 수 있었습니다.

프로필 이미지오진수

Frontend Developer

댓글

    프로그래밍 카테고리 다른 글

    1. AWS EC2 요금 폭탄
    2. 자바스크립트 instanceof 안 쓰고 클래스 타입 판별하는 방법
    3. CAD 프로그램 곡면 벽 구현하기 Segment + Arc를 Edge로 가지는 Polygon 활용법
    4. 실시간 협업을 위한 마우스 트래킹 구현
    5. Flutter 상태관리 Provider면 충분하다
    6. Next.js Docker 빌드시 creating optimized production build 단계에서 멈출 때 해결법
    7. Next.js vs Remix 비교 둘다 운영 중인 후기
    8. [TS] import type으로 순환 참조 문제 해결하기