배너 생성기 리팩터링
개발 •

요즘 예전에 진행했다 GitHub에서 하루하루 썩어가는 프로젝트들 전반적으로 리팩터링 하는 중입니다.
웹 앱과 GitHub에서 결과물을 확인하실 수 있습니다.

3년 전에 커밋한 파일이란 점, "commit"이란 강렬한 커밋 메시지에서 짐작해볼 수 있듯 지금보다 많이 못 하던 시절에 제작한 웹 앱입니다.
명색이 블로깅하면서 가장 많이 사용하는 앱인데, 아무래도 그냥 내버려둬도 그저 그런대로 작동하다 보니 오랜 세월 방치하고 있었네요.
내부적으론 아주 많이 뜯어고쳤는데, 겉으로 볼 땐 큰 차이가 없는 걸 보면 역시 업데이트가 멈추는 시점은 앱이 완성되는 시점이 아니라 제작자의 마음이 떠나는 시점이 아닐까 다시 한 번 생각하게 되네요.
상태 관리
export default class CanvasStore extends EventTarget {
canvas?: HTMLCanvasElement;
width: number;
height: number;
text: string;
font: string;
fontSize: number;
fontColor: string;
backgroundColor: string;
backgroundImage?: HTMLImageElement;
backgroundOpacity: number;
constructor() {
const backgroundColor = getRandomColor();
const fontColor = getProperColor(backgroundColor);
super();
this.width = 700;
this.height = 700;
this.text = "Sample Text";
this.font = "sans-serif";
this.fontSize = 64;
this.fontColor = fontColor;
this.backgroundColor = backgroundColor;
this.backgroundOpacity = 0.5;
bindMethods(this);
}
#update() {
this.dispatchEvent(new CustomEvent("update"));
}
setCanvas(canvas: HTMLCanvasElement) {
this.canvas = canvas;
}
setWidth(width: number) {
this.width = width;
this.#update();
}
// ...
}
EventTarget
의 자식 클래스를 제작해 전역 상태를 관리했습니다.
dispatchEvent
를 통해 store의 상태에 의존하는 컴포넌트에 재렌더링해야 할 시점을 간편히 알려줄 수 있었습니다.
export default function Preview() {
const { setCanvas } = canvasStore;
const canvas = el("canvas", {
className: "canvas",
width: canvasStore.width,
height: canvasStore.height,
});
const ctx = canvas.getContext("2d");
const render = () => {
// ...
};
canvasStore.addEventListener("update", render);
render();
setCanvas(canvas);
return el("div", {}, canvas);
}
render
는 canvas에 결과물을 그리는 함수입니다.
작성한 텍스트, 폰트 설정 등이 변경되면 render
를 다시 호출해 결과물을 그리도록 작업했습니다.
const WIDTH = <HTMLInputElement>document.getElementById("width");
const HEIGHT = <HTMLInputElement>document.getElementById("height");
const TEXT = <HTMLInputElement>document.getElementById("text");
const BG = <HTMLInputElement>document.getElementById("bgColor");
const TEXTCOLOR = <HTMLInputElement>document.getElementById("textColor");
const SIZE = <HTMLInputElement>document.getElementById("fontSize");
const TRANS = <HTMLInputElement>document.getElementById("transparency");
const img = new Image();
let input = 0;
let font = "sans-serif";
예전엔 이렇게 input 요소 등과 상태 모두 전역에 두고 input 요소 모두에 일일이 change
등 이벤트 핸들러 추가해둔 다음에 값 변경될 때마다 canvas를 다시 그렸는데, 다시 보니 새삼 끔찍하네요.
DOM 생성 방식
import { canvasStore } from "../store";
import el from "../utils/el";
export default function Textarea() {
const { setText } = canvasStore;
const handleChange = ({ target }: Event) => {
if (!(target instanceof HTMLTextAreaElement)) {
return;
}
setText(target.value);
};
return el(
"div",
{ className: "text" },
el("textarea", {
className: "text__input",
placeholder: "Type Here!",
ariaLabel: "Type Here!",
rows: 2,
events: {
change: handleChange,
keydown: handleChange,
keyup: handleChange,
},
}),
el("div", { className: "text__line text__line--top" }),
el("div", { className: "text__line" })
);
}
상기 코드들에서도 볼 수 있듯 이번에도 함수 하나 만들어 활용했습니다.
style과 dataset도 다룰 수 있도록 업데이트하는 등 사용성을 많이 개선했고, eln
이라는 함수도 추가로 만들어 SVG도 다룰 수 있도록 했습니다.
여전히 click
이벤트에서 event
의 타입이 Event
라는 점, SVGElement를 생성하려면 eln
을 사용해야 한다는 점 등의 아쉬움이 남아있어 개선할 여지가 많긴 합니다.
상태가 변경됐을 때 DOM을 업데이트하는 부분도 너무 수동으로 해줘야 하는 작업이 많은 것 같아 아쉽네요.
천천히 개선해나가면 되지 않을까 합니다.
기타 개선점
- canvas에 그린 내용이 변경될 때마다 다운로드 버튼의 링크를 업데이트해 딜레이가 심하게 생길 때가 있었는데, 이젠 다운로드 버튼을 클릭하면 링크를 생성하도록 해 딜레이를 없앴습니다.
- 배경 이미지 추가를 위해 별도의 canvas를 사용하지 않고 메인 canvas 하나만 활용하도록 업데이트했습니다.
- 앱을 시작할 때 무작위로 지정되는 배경색의 대비를 확인하여, 밝은색일 땐 글자색이 검은색으로 설정되게 업데이트했습니다. Brian Suda 님의 Calculating Color Contrast란 글을 참고했습니다.
- 올바른 시멘틱 태그를 사용해 접근성을 높였습니다.
- className에서 카멜 케이스를 걷어내고 BEM을 사용했습니다.
- 자동으로 배포가 이뤄지도록 설정했습니다.
맺으며
3년이란 세월이 흘러 묵은 때가 많이 쌓인 코드를 고치는 데 성공했고, 결과물이 썩 마음에 들어 여러모로 만족스럽네요.
끔찍한 코드 때문에 꽤 고통도 많이 받았지만, 제가 성장했단 증거기도 하고 오랜 세월 작업물을 방치한 벌이기도 한 것 같아 최대한 인내하며 작업했습니다.
역시 언제나 그래 왔듯 결과물을 보면 도대체 언제 힘들었나 싶네요.
물론 제 실력이 모자란 탓이 크겠지만, 이렇게 바닐라로만 작업하니 결과물의 규모가 크게 한정된단 느낌이 강하게 듭니다.
사이드 프로젝트로 해보고 싶은 것들이 몇 개 생겼는데 규모가 좀 있는 것들이 있기도 하고, 새로운 도전도 해보고 싶어 아마 이렇게 바닐라로 진행하는 건 당분간 사이드에 사이드로 두지 않을까 싶네요.
물론 그렇다고 공부를 게을리하진 않을 예정이니, 여정을 끝내고 돌아왔을 때 다시금 제게 기분 좋은 고통을 선사해주지 않을까 기대하고 있습니다.