본문 바로가기
개발 기록

썸네일 생성기를 리뉴얼 했어요

by 유세지 2022. 12. 12.

 

 

지금으로부터 약 1년 전, 블로그 포스팅에 사용하기 위해 썸네일을 생성해주는 프로그램을 만들었습니다. 이때 만든 프로그램을 지금까지도 만족스럽게 쭉 써오고 있었는데, 몇 가지 불편한 점이 있었으나 기술적인 역량이 부족하여 손대지 못하고 있었습니다.

 

🚩 이전에 만들었던 썸네일 생성기 제작기

 

이번에 우아한테크코스를 진행하며 생각해오던 목표 중 하나가 기술적으로 성장해서 이전에 진행했던 프로젝트들을 리팩토링 하는 것이었습니다. 현재까지 가장 많이 사용했었고, 앞으로도 계속 사용할 예정이며 직접 사용하며 느꼈던 불편점들이 명확했던 썸네일 생성기가 첫 번째 대상이 되었습니다.

 

 

현재 프로젝트의 문제점

우선 기술스택부터 점검하였습니다. 기존의 썸네일 생성기는 Express 기반의 노드 애플리케이션입니다. 때문에 사용하기 위해선 로컬에서 빌드 후 직접 실행하거나 이러한 과정없이 사용하려면 AWS의 EC2와 같은 서버 호스팅이 필요하였습니다. 저 혼자 사용하는데엔 웹스톰을 켜서 npm run start 명령어를 쳐주는 약간의 불편함을 감수하면 됐지만 일반 사용자에게 IDE를 설치하라고 할 수는 없는 노릇이었습니다. 이 불편한 접근성이 가장 우선적으로 해결해야 할 문제였습니다.

 

두 번째는 프로그램의 작동 방식에서 원인을 찾을 수 있는 문제입니다.

 

 

기존의 프로그램 흐름도

 

 

기존의 프로그램은 사용자로부터 필요한 데이터를 입력받아 새로운 페이지(/img/req)에 해당 내용이 담긴 페이지를 출력합니다. 이 과정에서 이미지를 페이지에 표시하고, 이를 캡쳐하여 파일로 저장하고, 다시 이 파일을 사용자에게 제공해주는 비효율적인 과정으로 이루어져 있습니다. 또한 파일을 먼저 저장한 뒤에 제공하는 방식이다보니 자연스럽게 스토리지에도 이미지 파일들이 쌓이게 됩니다. 안정적으로 서비스를 운영하기 위해서는 이 썸네일 파일들을 주기적으로 지워줘야하는 번거로운 과정이 또 생겨버립니다.

 

세 번째는 만들어지는 이미지를 미리 확인할 수 없어 사용성이 나쁘다는 점입니다. 이미지를 생성해서 캡쳐하는 과정이 먼저 이루어져야하기 때문에 어떤식으로 썸네일이 만들어질지 확인하려면 매 동작마다 캡쳐를 진행하고 파일을 생성해서 보여주어야 합니다. 이렇게 되면 그 많은 이미지 파일들을 다 저장하는 것도 일이고, 매번 요청을 보내야하기 때문에 실제로 한 번의 요청에 2~3초 가량의 시간이 소요되는걸 생각해보면 사실상 불가능한 구현입니다. 디바운싱 처리를 통해 어느정도 부담을 줄여줄 수는 있겠지만 그 정도로는 여전히 부족합니다.

 

 

현재까지 언급한 문제점들을 해결하기에는 약간의 수정으로는 힘들다고 느꼈습니다. 기존의 작동방식에 근본적인 문제가 있다는 결론에 다다랐고, 결국 기술스택을 싹 바꿔서 처음부터 다시 만들기로 결정했습니다.

 

문제가 너무 많고 복잡하다면, 처음부터 다시 시작해야 합니다.

 

 

바뀐 프로젝트의 작동 방식

기존 프로젝트는 썸네일을 html 요소로 만들고, 이를 puppeteer라는 헤드리스 브라우저를 이용해 캡쳐해오는 방식이었습니다. 엄밀히 따져보면 파일 생성의 주체가 프로그램이 아닌 내부에서 돌아가는 브라우저입니다. 이때문에 쓸데없는 과정이 추가되고 다른 기능을 추가하기가 어려워졌습니다.

 

간접적으로 html 요소를 만들고 캡쳐하는게 아닌, 우리의 프로그램이 '직접' 썸네일을 생성해야합니다. 그 방법은 바로 Canvas API입니다. 이 API에 대해서는 지난 포스팅에서 설명한 글이 있으니 이쪽을 참고해주시면 감사하겠습니다.

 

잠시 고민을 해보니 적절한 구상이 떠올랐습니다. 사용자가 입력한 데이터를 프로그램이 관리하고, 이 데이터를 토대로 Canvas에 썸네일을 그려서 이미지 파일로 내보내주는 동시에 현재 작업 내용을 보여줄 수 있도록 실시간으로 표시해주면 될 것 같았습니다. 또한 이렇게 하면 굳이 Express 서버를 돌릴 필요없이, 정적 페이지로도 충분히 구현할 수 있게 됩니다. 정적 페이지로 구현할 수 있다는 것은 곧 서버 호스팅 없이도 비교적 쉽게 배포할 수 있다는 의미입니다.

 

바뀔 프로그램의 흐름도

 

 

Canvas API 하나로 위에서 느꼈던 모든 문제점을 완벽하게 해결할 수 있게 되었습니다. 최종적으로 기술 스택을 아래와 같이 변경시켰습니다.

 

 

변경 전 기술 스택

  • Javascript
  • Express.js
  • puppeteer.js

변경 후 기술 스택

  • Typescript
  • React.js
  • Canvas API

 

기존 버전을 deprecate 브랜치로 옮기고, 그냥 지워버리긴 아까워 기념삼아 v0.0.1으로 릴리즈 해주었습니다.

 

이제는 볼 수 없는 구버전

 

 

드디어, 새로운 버전으로 이주할 모든 준비가 끝났습니다.

 

 

 

구현

본격적인 구현을 위해 이전의 프로젝트 내용들은 지워주고, 이럴때를 위해 만든 보일러 플레이트를 불러와서 적용하였습니다. 보일러 플레이트는 yarn-berry와 react, typescript 개발 환경이 적용되어 있습니다.

 

개발 준비 단계를 줄여주는 보일러 플레이트

 

기왕이면 디자인도 새로 하는게 좋아보여서 피그마를 이용해 빠르게 시안을 잡아줍니다.

 

새로운 썸네일 생성기 디자인 시안

 

왼쪽 영역에서 썸네일에 필요한 정보들을 입력하면, 오른쪽 큰 화면에 미리보기가 표시되는 형태입니다. 디자인 시안을 잡고 나니 썸네일을 만들때 어떤 데이터들이 필요할지 대략적으로 알 수 있을 것 같습니다. 데이터들을 관리할 객체가 필요하여 인터페이스로 선언해주었습니다.

 

types/thumbnail.ts

interface ThumbnailData {
  imageSize: '16:9' | '4:3' | '1:1';
  backgroundType: 'Color' | 'Gradient' | 'Image' | null;
  backgroundImageSrc: string;
  backgroundColor: string;
  backgroundGradint: { start: string; end: string };
  backgroundBlur: boolean;
  title: string;
  subtitle: string;
  fontSize: 'Small' | 'Normal' | 'Big';
  fontFamily: '나눔고딕체' | '도현체' | '원스토어모바일POP체';
  fontColor: string;
  hasFontShadow: boolean;
}

export default ThumbnailData;

 

이제 이 데이터 타입을 기반으로 썸네일 기반 코드들이 동작하게 될 것입니다.

 

위 시안을 기준으로 왼쪽의 데이터를 입력하는 부분을 Sidebar, 결과물을 미리 볼 수 있는 부분을 Preview로 나누어 컴포넌트를 구성하였고, 두 컴포넌트에서 공통된 데이터를 참조해야했기 때문에 Context API를 이용해 두 컴포넌트에서 같은 상태를 구독하도록 해주었습니다.

 

stores/thumbnailContext.ts

import { useThumbnailReturnValues } from 'hooks/useThumbnail';
import { createContext } from 'react';

export const ThumbnailContext = createContext<useThumbnailReturnValues | null>(
  null,
);

 

pages/Main/index.tsx

import useThumbnail from 'hooks/useThumbnail';
import Preview from './Preview';
import Sidebar from './Sidebar';
import { ThumbnailContext } from 'stores/thumbnailContext';
import styles from './index.scss';

function Main() {
  return (
    <div className={styles.container}>
      <ThumbnailContext.Provider value={useThumbnail()}>
        <Sidebar />
        <Preview />
      </ThumbnailContext.Provider>
    </div>
  );
}

export default Main;

 

Preview 부분에서는 구독하고 있는 상태가 변할 때마다 Canvas를 다시 그려줍니다. Canvas의 특성상 기존 캔버스에 덧칠하는 방식으로 그려지기 때문에 제대로 반영하려면 배경부터 덮어쓰는 작업이 필요합니다.

 

pages/Main/Preview/index.tsx

  useEffect(() => {
    const asyncWrapper = async () => {
      const canvas = canvasRef.current;
      const canvasContainer = canvasWrapperRef.current;

      if (!canvas || !canvasContainer) return;

      const context = canvas.getContext('2d');

      if (!context) return;

      canvasContainer.style.aspectRatio = imageSize.replace(':', '/');

      if (backgroundType === 'Color') {
        context.fillStyle = backgroundColor;
        context.fillRect(0, 0, canvas.width, canvas.height);
      }

      if (backgroundType === 'Gradient') {
        const gradient = context.createLinearGradient(
          0,
          0,
          canvas.width,
          canvas.height,
        );
        gradient.addColorStop(0, backgroundGradint.start);
        gradient.addColorStop(1, backgroundGradint.end);
        context.fillStyle = gradient;
        context.fillRect(0, 0, canvas.width, canvas.height);
      }

      if (backgroundType === 'Image') {
        const img = await loadBackgroundImage();

        context.fillStyle = backgroundColor;
        context.fillRect(0, 0, canvas.width, canvas.height);
        context.drawImage(img, 0, 0, canvas.width, canvas.height);
      }

      context.font = fontStyleBuilder('title');
      context.textAlign = 'center';

      context.shadowColor = 'rgba(0, 0, 0, 0)';
      context.shadowOffsetX = 0;
      context.shadowOffsetY = 0;

      if (backgroundBlur) {
        context.fillStyle = 'rgba(255, 255, 255, 0.5)';
        context.fillRect(0, 0, canvas.width, canvas.height);
      }

      if (hasFontShadow) {
        context.shadowColor = 'black';
        context.shadowOffsetX = 1;
        context.shadowOffsetY = 1;
      }

      context.fillStyle = `${fontColor}`;
      context.fillText(title, canvas.width / 2, canvas.height / 2);

      context.font = fontStyleBuilder('subtitle');
      context.fillText(subtitle, canvas.width / 2, (canvas.height * 5) / 7);
    };

    asyncWrapper();
  }, [
    imageSize,
    backgroundType,
    backgroundImageSrc,
    backgroundColor,
    backgroundGradint,
    backgroundBlur,
    title,
    subtitle,
    fontSize,
    fontFamily,
    fontColor,
    hasFontShadow,
  ]);

 

썸네일 데이터의 상태를 useEffect의 디펜던시로 넣고, 외부 이미지를 배경으로 가져올 수도 있으니 미리 비동기 함수에 넣어 사용했습니다.

 

const loadBackgroundImage = () => {
  return new Promise<HTMLImageElement>((resolve, reject) => {
    const img = new Image();
    img.src = backgroundImageSrc;

    img.onload = () => {
      resolve(img);
    };

    img.onerror = reject;
  });
};

 

처음에는 useEffect가 실행될 때마다 외부 이미지를 로드하기 위해 추가적인 네트워크 요청이 발생하지 않을까 생각했었는데, 다행히 추가적인 요청을 보내지 않았고 딜레이도 발생하지 않았습니다. 만약 그랬다면 한 번 가져온 이미지를 base64 형식으로 변환하여 썸네일 데이터가 가지고 있어야 했을텐데 정말 다행입니다.

 

이렇게 만들어진 썸네일은 Canvas API가 지원하는 .toDataURL() 메서드를 이용하여 파일로 저장할 수 있도록 해주었습니다.

 

components/CreateThumbnail/index.tsx

const downloadRef = useRef<HTMLAnchorElement>(null);

...

const downloadButton = downloadRef.current;

...

downloadButton.download = 'Thumbnail.png';
downloadButton.href = canvas.toDataURL('image/png');

...

return (
  ...
  <a className={styles.downloadButton} ref={downloadRef} href="">
    다운로드
  </a>
)

 

 

이렇게 메인 기능 구현이 끝났습니다. 기존 프로젝트가 가지고 있던 기능을 완벽하게 대체하면서도, 바뀐 기술 스택의 장점까지 그대로 누릴 수 있는 상태로 탈바꿈 되었습니다. 모든게 끝났으니 Vercel을 이용하여 프로젝트를 웹 상에 배포하였고 성공적으로 작동하는 모습을 확인할 수 있었습니다.

 

배포 끝!

 

 

 

그러나 여기엔 한 가지 문제가 있었습니다. 이전 포스트에서 언급했던 오염된 캔버스 (Tainted Canvas) 문제입니다.

 

 

오염된 캔버스 (Tainted Canvas)

오염된 캔버스는 CORS 와도 긴밀히 연결되어 있는데, 간단히 설명하면 신뢰할 수 없는 출처의 소스를 사용함으로써 누군가 사용자에게 할 수 있는 악의적인 행위를 미연에 차단하는 정책의 일부분입니다. 만약 캔버스를 그리는데 사용한 소스가 허용되지 않은 출처의 소스라면, 이를 화면에 보여줄수는 있어도 데이터로 뽑아내거나 파일로 저장하는 동작들은 할 수 없게 됩니다.

 

Tainted canvases may not be exported.

 

오염된 캔버스를 판단하는 기준은 '소스의 출처가 현재 도메인의 접근을 허용하고 있는지' 입니다. 즉, 상호간의 동의가 있는 컨텐츠인지를 확인하는 것과 같습니다.

 

이 문제를 해결하기 위해서는 결국 매 소스마다 서버 관리자에게 찾아가서 "우리 사이트에서 사용할 수 있게 해주세요" 라며 허가를 구해야 한다는 것인데... 이건 불가능합니다. 사용자가 어떤 출처의 소스를 사용할 지에 대해서는 개발자의 입장에서는 알 수 없기 때문입니다.

 

그렇다고 여기서 포기할 수는 없겠죠. 다시 한 번 오염된 캔버스를 판단하는 기준을 살펴보았습니다.

 

소스의 출처가 현재 도메인의 접근을 허용하고 있는지, 이 말은 곧 헤더의 Access-control-allow-origin 속성이 현재 도메인을 포함하고 있는지에 달려 있습니다. 실제 소스 출처의 서버가 갖고있는 Access-control-allow-origin을 수정할 수는 없으니, 중간에 프록시 서버를 하나 만들어서 헤더에 와일드 카드(*)를 넣어주면 되지 않을까요?

 

 

프록시 서버 구축

직접 익스프레스로 프록시 서버를 만들어 주어도 되지만, 이미 이런 용도로 사용할 수 있는 오픈소스가 있습니다. 바로 cors-anywhere입니다. 이 서비스는 쿼리 스트링으로 들어온 사용자의 요청에 대한 응답을 CORS 정책에 차단되지 않도록 Access-control-allow-origin 헤더에 와일드 카드를 넣어 보내주도록 동작합니다.

 

사용하지 않을 이유가 없으니 해당 레포지토리를 포크하여 개인 서버에 올려주고, 클라이언트가 되는 썸네일 제작기의 요청 경로에 Prefix처럼 넣어주겠습니다.

 

constants/constants.ts

export const corsPrefixUrl = 'cors-anywhere가 올라간 개인 서버 주소';

 

components/CreateThumbnail/index.tsx

const loadBackgroundImage = () => {
  return new Promise<HTMLImageElement>((resolve, reject) => {
    const img = new Image();
    img.src = `${corsPrefixUrl}${backgroundImageSrc}`;
    img.crossOrigin = 'Anonymous';

    img.onload = () => {
      resolve(img);
    };

    img.onerror = reject;
  });
};

 

 

드디어 이 지긋지긋한 CORS 정책으로부터 해방되었습니다.

 

 

혼합 콘텐츠 (Mixed Content)

항상 무엇인가 일을 끝내고, 끝났다고 생각해서 긴장을 풀면 또 다른 문제가 튀어나오곤 합니다. 함부로 "끝났다!" 라는 말을 쓰지 않도록 하는게 팀 규칙인 경우도 간혹가다 보이는 듯 합니다.

 

아니오.

 

저는  Vercel을 이용해 이 썸네일 프로젝트를 배포하였는데, 별다른 설정 없이도 배포된 결과물에 대해 자동으로 https 통신을 지원해주고 있는 좋은 서비스였습니다. 그러나 안타깝게도, 제가 사용한 개인 서버는 http 통신만 지원하고, https를 지원하고 싶다면 직접 설정해주어야 했습니다.

 

위에서 CORS니, 오염된 캔버스니 하며 각종 요청을 차단하는 현상에 대해 설명했었는데, 이 두 정책들의 근본적인 목적은 신뢰할 수 없는 출처의 소스를 사용하는 것을 막아 사용자를 보호하는 역할을 하기 위함입니다.

 

그렇다면, 보안 연결을 지원하는 https에서 보안이 적용되지 않은 http의 리소스를 그대로 사용하면 어떻게 될까요?

 

당연하게도 CORS 정책에 의해 차단당하게 됩니다.

 

https 연결을 사용하는 클라이언트에서 http 연결을 사용하는 서버의 응답을 사용하는 경우를 혼합 콘텐츠 (Mixed Content) 라고 부릅니다. 간단하게 서로간의 연결 방식이 통일되지 않고, 혼합되어 있는 상태를 의미합니다. 이런 경우에는 연결상의 허점이 생겨 보안상의 문제로 발전할 수 있는 가능성이 있기 때문에 위험합니다. 다행히 해결 방법은 간단합니다. 서버에 인증서를 넣어서, https 연결을 지원하도록 만들면 되는 간단한 작업만 해주면 됩니다.

 

https는 보안 연결을 지원합니다.

 

Certbot을 이용해 https 인증하기

저는 여기서 이 문제가 금방 해결될 것이라고 생각했습니다. 또 실수를 해버린 것입니다.

 

이전까지는 흔히 https를 지원하도록 할 때 Let's Encrypt를 사용하여 무료로 인증서를 발급받아 사용해왔습니다. 이때 Certbot이라는 프로그램을 이용하면 더 쉽게 발급하고, 3개월마다 만료되는 인증서를 자동으로 갱신할 수 있기 때문에 많은 분들께서 이 프로그램을 이용하였습니다.

 

저 또한 큰 고민없이 Certbot을 사용하여 인증서를 발급받을 생각이었습니다. 서버에 Certbot을 설치하고, 몇 가지 과정을 거쳐 인증서를 발급받아 이를 웹 서버였던 nginx의 configuration에 적용해주었습니다.

 

문제는 여기서 발생하였습니다. 제가 개인적으로 사용한 서버는 재학중인 학교의 동아리에서 운영하는 공용 서버였고, 이미 많은 사람들이 개인 서버로 이용하며 서비스를 운영하고 있는 형태여서 내부 구조가 굉장히 복잡했습니다. 하물며 관리자도 제가 아니었기 때문에 부족한 지식으로 해보려고 하다가 기존에 존재하던 다른 서버의 설정 파일을 잘못 덮어 씌우는 대참사를 저질러버렸습니다.

 

대략적으로 이러한 구조에서, 도커 내부의 이미지로 향하는 설정을 덮어 씌웠습니다.

 

복구하느라 며칠을 삽질하며 인프라 공부를 하고, 결국 메인 서버 관리자의 도움으로 어떻게 복구에 성공해서 고생한 결과물을 확인해봤더니, 잘못된 인증서라는 표시가 떠있었습니다.

 

찾아보니 일부 기기에선 Let's Encrypt로 생성된 인증서 자체에 대한 문제가 있을 수 있다고도 하고, certbot이 기존 설정을 뒤집어 엎어가며 (이게 유력해 보입니다) nginx 설정에 어딘가 오류가 생겨 발생하는 경우도 있다고 하던데 아쉽게도 정확한 이유는 끝까지 찾지 못했습니다.

 

결국 certbot으로 Let's Encrypt 인증서를 사용하는 것은 현재 서버 구조에서 어렵다고 판단하여, 기존 메인 서버에서 사용하던 cloudflare의 무료 인증서를 썸네일 생성기의 프록시 서버와 함께 적용하기로 하였습니다.

 

 

진짜 끝

아무튼, Canvas API로 시작해 인프라 공부로 마무리 지었던 썸네일 생성기 리뉴얼이 이렇게 끝났습니다. 기술 스택을 뜯어 고치면서 사용성과 접근성이 훨씬 좋아지고, 앞으로 기능을 추가하기에도 더 쉬운 썸네일 생성기로 다시 태어났다는 점이 가장 뿌듯하네요. 더군다나 지금까지 제가 React로 만들었던 서비스 중 가장 SPA라는 방식에 어울리는 웹 서비스라고 생각해서 내심 기분이 좋았습니다.

 

새로운 지식들을 습득하는 과정은 늘 재미있어서 별로 부담이 되진 않았지만, 개인적으로 인프라는 정말 어려웠습니다. 잘 모르는 상태로 이미 구축되어 있는 환경을 만지려니 진입 장벽이 조금 느껴진 것 같아요. 그래도 전보다는 이해도가 많이 올라간 것 같아서 그건 그거대로 좋았습니다.

 

이번에 리뉴얼한 썸네일 생성기는 아래 링크에 배포하였으니 혹시 유튜브나 블로그에 간단한 썸네일이 필요하시다면 한 번 이용해보세요! 개선할 점이나 불편한 점등은 깃허브 이슈로 남겨주시면 최대한 빠르게 반영하도록 하겠습니다 😊

 

 

긴 글 읽어주셔서 감사합니다.

반응형

댓글