배너 이미지

배너 생성기 리팩터링

요즘 예전에 진행했다 GitHub에서 하루하루 썩어가는 프로젝트들 전반적으로 리팩터링 하는 중입니다.

웹 앱GitHub에서 결과물을 확인하실 수 있습니다.

날 것 그대로의 커밋 메시지 - commit

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년이란 세월이 흘러 묵은 때가 많이 쌓인 코드를 고치는 데 성공했고, 결과물이 썩 마음에 들어 여러모로 만족스럽네요.
끔찍한 코드 때문에 꽤 고통도 많이 받았지만, 제가 성장했단 증거기도 하고 오랜 세월 작업물을 방치한 벌이기도 한 것 같아 최대한 인내하며 작업했습니다.
역시 언제나 그래 왔듯 결과물을 보면 도대체 언제 힘들었나 싶네요.

물론 제 실력이 모자란 탓이 크겠지만, 이렇게 바닐라로만 작업하니 결과물의 규모가 크게 한정된단 느낌이 강하게 듭니다.
사이드 프로젝트로 해보고 싶은 것들이 몇 개 생겼는데 규모가 좀 있는 것들이 있기도 하고, 새로운 도전도 해보고 싶어 아마 이렇게 바닐라로 진행하는 건 당분간 사이드에 사이드로 두지 않을까 싶네요.
물론 그렇다고 공부를 게을리하진 않을 예정이니, 여정을 끝내고 돌아왔을 때 다시금 제게 기분 좋은 고통을 선사해주지 않을까 기대하고 있습니다.


profile

이메일 주소는 공개되지 않습니다.

주의 : 비밀 댓글 사용 시 수정 기능을 이용할 수 있는 시간이 지나면 작성자도 내용 확인이 불가능합니다.
카카오페이 qr코드 모바일이시라면 클릭