🎴
『곽철용 짤 생성기』, 이렇게 만들어졌습니다

HTML Canvas API를 이용한 짤 생성기 개발 과정을 소개합니다.

Posted by 재그지그 on October 13, 2019

FrontEnd Vue


곽철용 짤 생성기는 여기, 코드는 Github에서 확인할 수 있습니다.

마포대교

올 하반기에 갑작스럽게 뜬 재밌는 인터넷 밈(meme)이 하나 있죠. 바로 영화 『타짜』에 나온 악역 캐릭터인 곽철용입니다.

지금 와서 말하는 거지만, 저는 사실 곽철용 밈이 처음에는 뭔지 몰랐습니다. 왜냐하면 영화가 개봉한 2006년에는 제가 초등학생이어서 애초에 볼 수가 없었고(…), 유명한 장면만 클립 영상으로 알고 있었거든요. 그래서 대략적인 스토리는 알고 있었는데 사람들이 올리는 곽철용 짤들이 어떤 문맥에서 어떤 의미로 쓰이는지는 사실 잘 몰랐습니다. 그래서 궁금해진 김에 이번 기회에 영화를 대여해서 보았지요. 역시 문맥을 알고 나니까 훨씬 재밌었습니다. 일상 생활에서의 응용력(?)도 좋아서 드립도 착착 감기더군요.

그러다가 문득 곽철용 짤 생성기는 없나?라는 생각이 들었습니다. 개비스콘 짤 생성기도 있는데, 이것도 혹시나 있을까 싶어서 구글링을 해봤는데… 아직 없는 것 같더군요. 어, 없으면 이거 내가 한 번 만들어보면 재밌겠는데? 라는 생각이 들었습니다. 마침 지난 주에 개천절이라는 쉬는 날이 있어서 하루동안 해커톤 하는 느낌으로 뚝딱뚝딱 만들어 보았습니다.

그래서 이번 포스트에서는 곽철용 짤 생성기를 개발하게 된 스토리에 대해 소개하고자 합니다. 사실 보잘 것 없는 기술로 간단하게 만든 거라 이걸 가지고 포스트를 쓴다는 게 좀 부끄럽기도 합니다. 하지만 사소한 경험이더라도 이 경험을 공유하는 것이 누군가에게는 가치있게 느껴질 수도 있다고 생각하면서 포스트를 써 봅니다. 재미로 봐 주셔도 좋아요.

이번 포스트를 통해, HTML 캔버스를 공부해 보고 싶은 분이나 나만의 짤 생성기를 만들어 보고 싶은 분이나들에게 도움이 되었으면 좋겠습니다.

묻고 캔버스로 가

마포대교 라이브러리로 쉽게 가시겠어요? 아니면 묻고 캔버스로 가시겠어요?

일반적으로 짤을 만든다고 하면 보통 이미지에다가 패러디한 자막을 써놓은 게 일반적이죠. 이 기능을 웹에서 구현을 하려면 보통 라이브러리를 쓰거나 캔버스(Canvas)를 이용해 직접 구현하는 두 가지 방법이 있는 것 같습니다.

라이브러리 중 대표적인 것은 dom-to-image가 있습니다. 이 라이브러리는 눈에 보이는 DOM을 그대로 캡쳐하듯이 이미지를 딸 수 있어서 시간이나 생산성 면에서는 훨씬 유용합니다. 하지만 저는 평소에 캔버스에 대해 공부해보고 싶었기 때문에, 일부러 캔버스를 이용해 구현하게 되었습니다.

그럼 이 글을 읽고 계시는 여러분들은 혹시 캔버스에 대해서 잘 알고 계신가요? MDN 문서에서는 캔버스를 아래와 같이 설명하고 있습니다.

캔버스(Canvas)는 HTML 요소 중 하나로서, 스크립트(보통은 자바스크립트)를 사용하여 그림을 그리는 데에 사용됩니다. 예를 들면, 그래프를 그리거나 사진을 합성하거나, 간단한(혹은 복잡할 수도 있는) 애니메이션을 만드는 데에 사용될 수 있습니다.

우리는 이를 응용해서, <canvas>라는 HTML 태그 안에다가 배경으로는 타짜 영화에서 캡쳐한 이미지를 깔고, 그 위에 자막처럼 글자를 넣고 다운로드 할 수 있도록 짤 생성기를 구현할 예정입니다. 자, 그럼 본 게임 들어갑니다.

내가 Vue 생활을 열 일곱에 시작했다

프론트시작한놈 그 나이 때 프론트 시작한 놈들이 백 명이다 치면은… 지금 나만큼 사는 놈은 나 혼자 뿐이야.

저희가 만들 짤 생성기는 프론트엔드에서만 동작하는 정적(static)인 웹 사이트입니다. 요새 핫한 SPA를 쓰지 않고 HTML, JavaScript로도 충분히 구현이 가능합니다만, 상태 관리를 좀 더 편하게 하기 위해서 저는 Vue를 사용했습니다. React를 이용한 짤 생성기도 참고해 보시면 좋을 것 같아요.

우선 vue-cli를 이용해 프로젝트를 새로 생성했습니다. vue-cli의 자세한 사용법은 이 곳을 참고하세요.

vue create kwakcheolyong

프로젝트 설정은 취향껏 하시면 되는데, 저 같은 경우는 구버전 브라우저 호환성을 위한 Babel, 효율적인 CSS 작성을 위한 SASS, 그리고 코드 컨벤션을 일관되게 유지하기 위한 Linter(+ airbnb 룰) 정도를 설정해주었습니다.

그리고 짤의 배경이 될 사진들을 구해서 /assets 디렉토리 안에 정리해두었습니다. 이 때 이미지의 크기는 모두 동일하게 설정했습니다(1000px * 427px).

이미지

그리고 각각의 이미지들과 관련된 정보들을 담은 객체를 배열로 만들고, 프로젝트 안에서 import하여 사용할 수 있도록 별도의 파일(images.js)을 만들었습니다.

// images.js
import 내밑에서일할생각없냐 from '@/assets/내밑에서일할생각없냐.png';
import 늑대새끼가어떻게개밑으로들어갑니까 from '@/assets/늑대새끼가어떻게개밑으로들어갑니까.png';
...

export default [
  {
    id: 0, // 식별하기 위한 ID값
    src: 어이젊은친구, // 이미지 소스
    name: '어이 젊은 친구', // 셀렉트 박스에 표시할 내용
    original: '어이 젊은 친구, 신사답게 행동해.', // 원본 대사 칸에 표시할 내용
  },
  {
    id: 1,
    src: 신사답게행동해,
    name: '신사답게 행동해',
    original: '어이 젊은 친구, 신사답게 행동해.',
  },
  ...
]

저희가 만들 어플리케이션의 규모는 그렇게 크지 않아서, 필요한 곳에서 모든 기능들을 App.vue에 작성할 것입니다. 따라서 이 곳에서만 import해서 쓰기만 하면 됩니다.

그럼 이제 본격적으로 <canvas> 엘리먼트를 그려보도록 합시다. 우선 vue의 템플릿 코드에다가 아래와 같이 <canvas> 엘리먼트를 초기화시켜줍니다.

<canvas id="canvas" ref="canvas" :width="1000" :height="427">
</canvas>

아무것도없어요

<canvas> 엘리먼트를 작성했다 하더라도 아직 저희가 캔버스에 그림을 그리는 코드를 작성하지 않았기 때문에, 흰 도화지처럼 아무것도 보이지 않는 것이 정상입니다.

짤림

여기서 widthheight 값을 미리 설정하는 이유는, 값을 명시적으로 설정하지 않으면 자동으로 값이 채워지는데 이 때문에 이미지 파일이 잘려 나올 수가 있습니다. 물론 CSS를 이용해서도 widthheight 값을 줄 수 있지만, 이 경우는 CSS 값으로 설정한 비율에 따라 왜곡이 될 수도 있으니 초기 캔버스의 비율을 맞추기 위해서는 명시적으로 초기화를 해 주는 것이 좋습니다.

또한 캔버스에 그림을 그리기 위해서는 캔버스 DOM에 직접 접근을 해야 하기 때문에, ref 속성을 설정해줍니다.

우선은 빈 화면이 허전해 보이니까 캔버스에 이미지를 나타나게 해봅시다. 구현하는 방법은 여러가지가 있겠지만, 저는 아래와 같은 방법을 이용해 구현하였습니다.

// App.vue
import images from '@/images';

export default {
  data() {
    return {
      imageIndex: 0,
    };
  },

  computed: {
    images() {
      return images; // 1번
    },
  },

  mounted() { // 2번
    this.updateCanvas();
  },

  methods: {
    updateCanvas() { // 3번
      if (!this.$refs.canvas) return;
      this.updateCanvasImage();
    },

    updateCanvasImage() {
      const { canvas } = this.$refs;
      const ctx = canvas.getContext('2d'); // 4번
      const img = new Image(); // 5번
      img.src = this.images[this.imageIndex].src;
      img.onload = () => { // 6번
        ctx.drawImage(img, 0, 0); // 7번
      };
    },
  },
}
  1. 우선 이미지 정보가 담긴 배열을 import해서 로컬의 computed로 접근할 수 있게 변수 선언
  2. Vue의 라이프사이클 메소드 중, DOM이 생성된 이후에 실행되는 mounted 훅에 캔버스를 업데이트 하는 메소드 updateCanvas()를 실행
  3. updateCanvas()에서는 ref로 접근할 수 있는 canvas라는 이름의 DOM이 있는지 확인하고, 있다면 이미지를 그리는 메소드 updateCanvasImage()를 실행
  4. updateCanvasImage()에서는 canvas.getContext('2d') 메소드를 이용해 캔버스에 2D 그림을 그릴 수 있는 컨텍스트(ctx)를 획득
  5. 그 후 새 이미지를 생성(new Image())하는데 그 이미지의 src 속성을 imageIndex라는 해당 인덱스의 이미지로 설정
  6. 이미지를 비동기로 불러오기 때문에, 이미지가 로드 된 이후에 해야하는 작업들은 항상 onload에 콜백 함수로 작성
  7. 이미지가 로드되면 컨텍스트의 좌상단(0,0) 위치를 기준으로 이미지를 그림

위와 같은 코드를 실행하고 나면, 다음과 같은 결과가 나옵니다.

오우야

이미지가 좀 크긴 하지만, 정상적으로 캔버스에 잘 그려지는 모습입니다. 그럼 이제 이미지를 셀렉트 박스를 이용해 동적으로 바꿀 수 있도록 해 볼까요?

<select
  :value="imageIndex"
  @input="onImageChanged($event.target.value)"
>
  <option v-for="img in images" :value="img.id" :key="img.id">
     {{ img.name }} 
  </option>
</select>
export default {
  methods: {
    onImageChanged(value) {
      this.imageIndex = value;
      this.updateCanvas();
    },
  }
}

셀렉트 박스에서 이미지가 바뀌면 this.imageIndex를 바꾼 후 다시 캔버스를 업데이트 하는 방식입니다. 이를 추가한 결과는 아래와 같습니다.

이미지변경

텍스트는 무너졌냐 이 새끼야

여태까지는 캔버스에서 이미지를 변경하는 방법에 대해서만 알아보았습니다. 이제 배경은 깔렸으니, 사용자로부터 텍스트를 입력받아서 캔버스 위에 그려봅시다.

캔버스 위에 그려질 텍스트를 입력 받는 것도 중요하겠지만, 글꼴이나 폰트 크기, 색상 등 사용자를 위한 추가적인 선택지도 제공할 예정입니다. 따라서 이 값들을 데이터로 저장할 수 있게 미리 선언해줍니다.

data() {
  return {
    imageIndex: 0,
    option: {
      fontFamily: 'Gulim',
      fontSize: 30,
      fontColor: '#FFFFFF',
      fontWeight: 'normal',
      text: '',
    },
  };
},

그리고 템플릿에 각 옵션들을 자유롭게 선택할 수 있도록 <input><select> 엘리먼트를 배치해줍니다. (<option> 태그는 생략했습니다)

<input
  type="textarea"
  :value="option.text"
  @input="onValueChanged('text', $event.target.value)"
/>
<select
  :value="option.fontFamily"
  @input="onValueChanged('fontFamily', $event.target.value)"
>
  <option> ... </option>
</select>
<select
  :value="option.fontSize"
  @input="onValueChanged('fontSize', $event.target.value)"
>
  <option> ... </option>
</select>
...

이미지변경

좀 못생기긴 했지만(…) 뭐 어때요, 옵션들 선택할 수 있기만 하면 됐죠.

여기서 잘 보시면 v-model 대신 @input:value를 쓰고 있다는 걸 알 수 있습니다. 그 이유는 한글의 입력 방식이 IME 방식이기 때문에, 글자가 완성되지 않으면(즉, 현재 글씨가 입력 중이면) v-model로는 타이핑하고 있는 글자를 정확히 입력받을 수 없습니다.

이미지변경 @input:value를 사용한 경우, 자모음을 입력할 때마다 값이 바뀝니다

이미지변경 v-model을 사용한 경우, 타이핑 중인 글자에서 커서가 벗어나기 전까지는 값이 바뀌지 않습니다

아무튼, @input 이벤트가 발생했을 때 this.option의 값을 변경하고, 캔버스를 업데이트하는 메소드를 실행합니다.

onValueChanged(key, value) {
  this.option[key] = value;
  this.updateCanvas();
},

그리고 텍스트를 입력하는 메소드를 만들어봅시다.

updateCanvasText() {
  const { canvas } = this.$refs; // 1번
  const ctx = canvas.getContext('2d');
  const { text, fontFamily, fontSize, fontColor, fontWeight } = this.option;

  ctx.textAlign = 'center'; // 2번
  ctx.textBaseline = 'middle'; // 3번
  ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; // 4번

  const lines = text.split('\n'); // 5번
  lines.forEach((line, index) => { // 6번
    ctx.fillStyle = fontColor; // 7번
    ctx.fillText( // 8번
      line,
      canvas.width / 2,
      canvas.height - fontSize * (lines.length - index) * 1.5,
    );
  });
},
  1. 이미지를 그릴 때처럼 캔버스 컨텍스트를 얻어오고, 텍스트 옵션들을 this.option에서 받아옵니다.
  2. ctx.textAlign 옵션으로 텍스트를 가운데 정렬합니다.
  3. ctx.textBaseline 옵션으로 글자의 기준선을 글자 높이의 중심으로 설정합니다.
  4. ctx.font로 폰트를 꾸밉니다. CSS의 font 프로퍼티와 동일한 구문으로 설정할 수 있습니다. CSS에서 외부의 웹폰트를 임포트했을 경우 캔버스에서도 사용할 수 있습니다.
  5. <textarea/>로는 개행문자(\n)도 받을 수 있기 때문에, 여러 줄의 입력도 받을 수 있도록 각 줄을 개행문자로 나눕니다.
  6. 개행문자로 나눠진 갯수만큼 반복문을 실행합니다.
  7. ctx.fillStyle로 폰트 색상을 설정합니다.
  8. ctx.fillText로 글자를 입력합니다. 첫 번째 파라미터는 입력할 텍스트(line)를, 두 번째 파라미터는 x축 위치를, 세 번째 파라미터로는 y축 위치를 지정합니다.

실행 결과는 다음과 같습니다.

이미지변경 고니야! 글씨가 써진다!

와! 이제 위의 <textarea>에 입력한 그대로 글씨가 써집니다. 하지만 이대로 끝일까요?

테두리노 예의 바른 고니

밝은 배경에서는 흰색 글씨가 잘 보이지 않는 문제가 있습니다. 이 때문에 기본적으로 글자에 테두리를 씌울 수 있는 옵션을 제공해서, 글자가 잘 보일 수 있도록 합니다. 아까 있었던 this.option의 프로퍼티에 글자 테두리 색깔을 담당할 textBorder를 추가합시다.

lines.forEach((line, index) => {
  ctx.lineWidth = 5;
  ctx.strokeStyle = `${textBorder}`;
  ctx.strokeText(
    line,
    canvas.width / 2,
    canvas.height - fontSize * (lines.length - index) * 1.5,
  );

  ctx.fillStyle = fontColor;
  ctx.fillText(
    line,
    canvas.width / 2,
    canvas.height - fontSize * (lines.length - index) * 1.5,
  );
});

ctx.strokeText 메소드를 이용해서 두께 5, 색깔은 textBorder의 테두리를 그리는 코드를 추가했습니다. 그 결과는 아래와 같습니다.

테두리온

고니야, 다운로드 버튼도 하나 찔러 봐라

캔버스를 이미지로 만드는 방법은 아주 쉽습니다. 바로 캔버스에서 기본적으로 지원하는 toDataURL()이라는 메소드를 이용해 BASE64로 인코딩된 이미지 파일을 쉽게 만들 수 있습니다.

<button @click="downloadCanvas" id="download">다운로드</button>
downloadCanvas() {
  const url = this.$refs.canvas.toDataURL('image/png');
  const link = document.createElement('a');
  link.href = url;
  link.setAttribute('download', `${this.images[this.imageIndex].name}.png`);
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
},

이제 다운로드 기능까지 잘 동작하니, 예쁘게 꾸며주기만 하면 됩니다. 저는 element-ui, 그리고 element-theme-dark를 사용했습니다.

이쁘당

이렇게 짤 생성기를 완성시켰습니다!

어이 젊은 친구, 신사답게 통계도 붙여보자구

이쁘당 10월 3일부터 12일간의 통계를 공개합니다

여기에서 끝을 내도 상관 없지만, 부가적인 작업을 해 볼까 합니다. 바로 구글 태그 매니저를 통해, 다운로드 버튼이 몇 번 눌렸는지를 체크해서 몇 명의 인원이 몇 개의 짤을 만들었는지를 조사해보고자 합니다.

위의 통계를 보면 여러가지 의미있는 지표들을 확인할 수 있는데요. 10월 4일, 7일, 14일에 유의미한 상승폭이 있는 것을 확인할 수 있습니다. 저 상승폭이 무엇을 의미하는지 궁금하시다면 별도로 게시한 포스트인 특정 버튼의 클릭 이벤트를 통계로 보고 싶어!를 참조해주세요.

마무리

오랜만에 재밌는 프로젝트를 해 본 것 같아서 기분이 좋습니다. 재밌다고 많은 분들이 공감해주셔서 저 역시도 뿌듯한 마음이 듭니다. 그리고 지금보다도 더 많은 사용자가 생겼으면 좋겠다는 소망도 갖고 있습니다.

사실 이 프로젝트는 곽철용이라는 인터넷 밈의 인기에 편승(?)한 거라… 뭔가 숟가락만 얹은 느낌도 들어서 괜히 찔리네요. 앞으로는 좀 더 개발자스럽게, 이 프로젝트를 기술적으로 어떻게 더 개선할 수 있을지에 대해서 고민해보아야겠습니다.

코드는 Github에도 공개되어 있으니, 이 프로젝트가 마음에 드신다면 스타(⭐)를, 개선이 필요한 점이 있다면 풀 리퀘스트를 보내주시면 감사하겠습니다. 덧글도 언제든 환영입니다.