웹 기반 인테리어 설계 에디터를 구현하기 위해 필요한 것들 [1편]
사실 에디터 엔진을 처음 만들 때는 일단 화면에 뭔가 그려보기라도 하는 방식이 가장 좋은 듯합니다. 그러고나서야 전반적인 아키텍처 설계나 최적화에 관해서 머릿속에 그림이 그려지게 마련이죠. 처음부터 머릿속으로 '객체지향적으로' 설계부터 하다보면 오히려 객체 관계가 꼬이고 맙니다. 오버엔지니어링에 빠지기도 십상입니다. 결국 경험이 중요합니다. 좋은 아키텍처를 만들려면 프로토타이핑 - 리팩터링 과정을 빠르게 반복할 수 있어야 한다고 생각합니다.
실제로 제가 인테리어 설계 에디터를 구현하면서도 이러한 과정을 따랐습니다. 가장 기본적인 계획은 첫번째로 코드를 써서 화면에 즉각적으로 무언가 그릴 수 있는 환경을 구축하는 것이었습니다. 이 글에서는 초기 프로토타이핑 이후에 리팩터링 과정에서 어떻게 클래스들이 만들어지고 클래스 사이의 관계가 어떻게 구축되었는지 정리해보도록 하겠습니다.
React에서 HTML Canvas API로 그리기
HTML Canvas API와 React를 사용하면 화면에 뭔가 그리는 일은 쉽습니다. 세련된 클래스 설계 없이 투박하게 프로토타이핑을 해봤습니다.
export function App() {
return <canvas className="w-full h-full" />
}아마 맨 처음 썼던 코드는 이랬던 것 같습니다. 브라우저에서 무언가 그리기 위해서는 Canvas 요소가 필요하니까 말입니다. 다음으로 코드를 통해서 캔버스를 제어하기 위해서 Canvas 요소의 참조를 얻어야 했습니다. useRef() 함수를 이용했습니다.
export function App() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
// TODO
}, []);
return <canvas ref={ref} className="w-full h-full" />
}useRef()로 저장한 참조는 useEffect()에서 꺼내 썼습ㄴ디ㅏ. 참조를 얻었으니 여기서부터는 일반적인 Canvas 사용법을 그대로 따랐습니다.
export function App() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const ctx = element.getContext("2d");
if (!ctx) return;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(100, 100);
ctx.stroke();
}, []);
return <canvas ref={ref} className="w-full h-full" />
}예시로 캔버스 위 0,0 지점부터 100,100 지점까지 선을 하나 그었습니다. 다른 모양을 그린다면 이 부분을 바꾸면 됩니다. 다른 부분은 손대지 않을 것이므로 지금까지 프로토타이핑한 코드를 리팩터링할 수 있습니다. 방법은 다양하지만 나는 그림을 그리는 코드가 React UI를 의존하지 않도록 별도의 클래스 파일을 만들었습니다.
export class Scene {
render(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(100, 100);
ctx.stroke();
}
}import { Scene } from "./scene";
export function App() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const ctx = element.getContext("2d");
if (!ctx) return;
const scene = new Scene();
scene.render(ctx);
}, []);
return <canvas ref={ref} className="w-full h-full" />
}코드는 대개 이런 식으로 발전해 나갔습니다. 실제로는 이 시점에서 캔버스 좌표계를 우리에게 익숙한 데카르트 좌표계로 옮기는 작업을 진행했습니다. 이에 대해서는 2편에서 정리해 보도록 하겠습니다. 이 글에서는 대강 아키텍처가 어떤 식으로 구성되었는지를 조금 더 훑어보도록 합니다.
도면 객체 구분하기
지금 상태는 Scene 클래스의 render() 함수에서 모든 것을 그리고 있습니다. 하지만 조금 지나면 다양한 도면 객체를 각각 따로 그리는 편이 나을 겁니다. 그래서 도면 객체를 각각 클래스로 만들고 따로 render() 함수를 정의했습니다.
export class WallEntity {
ps: Point;
pe: Point;
constructor(ps: Point, pe: Point) {
this.ps = ps;
this.pe = pe;
}
render(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
ctx.moveTo(ps.x, ps.y);
ctx.lineTo(pe.x, pe.y);
ctx.strokeStyle = 'black';
ctx.stroke();
}
}export class DoorEntity {
ps: Point;
pe: Point;
constructor(ps: Point, pe: Point) {
this.ps = ps;
this.pe = pe;
}
render(ctx: CanvasRenderingContext2D) {
ctx.beginPath();
ctx.moveTo(ps.x, ps.y);
ctx.lineTo(pe.x, pe.y);
ctx.strokeStyle = 'green';
ctx.stroke();
}
}벽과 문은 인테리어 설계에서 가장 많이 사용하는 두 요소입니다. Scene 클래스는 자연스럽게 아래와 같이 바꾸었습니다.
import { WallEntity } from "./wall_entity";
import { DoorEntity } from "./door_entity";
export class Scene {
entities = [
new WallEntity(new Point(0, 0), new Point(0, 100)),
new DoorEntity(new Point(0, 25), new Point(0, 75)),
];
render(ctx: CanvasRenderingContext2D) {
for (const entity of entities) {
entity.render(ctx);
}
}
}누가 애니메이팅을 책임지는가
에디터 엔진이기 때문에 도면 요소는 상호작용에 따라 그려지는 모양이 바뀌어야 합니다. 예를 들어서 문의 위치는 마우스로 드래그한 만큼 이동해야 합니다.
지금 상태는 상호작용을 떠나서 캔버스에 그림은 딱 한 번 그려집니다. 하지만 움직임을 표현하기 위해서는 매 프레임마다 캔버스를 다시 그릴 수 있어야 합니다.
사실 애니메이팅을 구현하는 방법보다도 어디에 애니메이팅을 구현해야 하는가가 더 중요하다고 생각합니다. 지금까지 방식 그대로 무작정 프로토타이핑부터 해보면 애니메이팅에 관한 코드는 Scene 클래스의 render() 함수 내부로 들어갈 겁니다.
export class Scene {
entities = [
new WallEntity(new Point(0, 0), new Point(0, 100)),
new DoorEntity(new Point(0, 25), new Point(0, 75)),
];
render(ctx: CanvasRenderingContext2D) {
const tick = () => {
requestAnimationFrame(tick);
for (const entity of entities) {
entity.render(ctx);
}
}
requestAnimationFrame(tick);
}
}하지만, 이 또한 경험으로부터 얻은 것이기는 하지만, 애니메이팅 코드는 Scene과 분리하는 편이 좋습니다. 왜냐하면 어떤 씬은 애니메이팅이 필요하지 않을 수도 있기 때문입니다. 실제로 예를 들자면 도면 요소 라이브러리에서 객체의 프리뷰를 보여줄 때는 애니메이팅이 필요하지 않습니다. 오히려 성능 저하의 원인이 됩니다.
그렇기 때문에 애니메이팅 코드는 차라리 아래와 같은 코드처럼 바깥으로 빼는 방식을 채택했습니다.
import { Scene } from "./scene";
export function App() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const ctx = element.getContext("2d");
if (!ctx) return;
const scene = new Scene();
let frame = 0;
const tick = () => {
frame = requestAnimationFrame(tick);
scene.render(ctx);
}
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
}
}, []);
return <canvas ref={ref} className="w-full h-full" />
}이렇해서 Scene을 이용하는 클라이언트 입장에서 씬을 한 번만 그릴 것인지 애니메이팅 방식으로 그릴 것인지를 선택할 수 있었습니다. 개인적으로는 tick 같은 함수도 매번 작성하기 번거로웠기 때문에 별도로 담당하는 클래스를 만들어주었습니다.
import { Scene } from "./scene";
import { SceneAniamtor } from "./scene_animator";
export function App() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const element = ref.current;
if (!element) return;
const ctx = element.getContext("2d");
if (!ctx) return;
const scene = new Scene();
const sceneAnimator = new SceneAnimator(scene);
sceneAnimator.play(ctx);
return () => {
sceneAnimator.stop();
}
}, []);
return <canvas ref={ref} className="w-full h-full" />
}class SceneAnimator {
scene: Scene
frame = 0;
constructor(scene: Scene) {
this.scene = scene;
}
play(ctx: CanvasRenderingContext2D) {
const tick = () => {
this.frame = requestAnimationFrame(tick);
this.scene.render(ctx);
}
this.frame = requestAnimationFrame(tick);
}
stop() {
cancelAnimationFrame(this.frame);
}
}
오진수Frontend Developer


.png)
