올 하반기에 갑작스럽게 뜬 재밌는 인터넷 밈(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>
엘리먼트를 작성했다 하더라도 아직 저희가 캔버스에 그림을 그리는 코드를 작성하지 않았기 때문에, 흰 도화지처럼 아무것도 보이지 않는 것이 정상입니다.
여기서 width
와 height
값을 미리 설정하는 이유는, 값을 명시적으로 설정하지 않으면 자동으로 값이 채워지는데 이 때문에 이미지 파일이 잘려 나올 수가 있습니다. 물론 CSS를 이용해서도 width
와 height
값을 줄 수 있지만, 이 경우는 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번
};
},
},
};
- 우선 이미지 정보가 담긴 배열을
import
해서 로컬의computed
로 접근할 수 있게 변수 선언 - Vue의 라이프사이클 메소드 중, DOM이 생성된 이후에 실행되는
mounted
훅에 캔버스를 업데이트 하는 메소드updateCanvas()
를 실행 updateCanvas()
에서는ref
로 접근할 수 있는canvas
라는 이름의 DOM이 있는지 확인하고, 있다면 이미지를 그리는 메소드updateCanvasImage()
를 실행updateCanvasImage()
에서는canvas.getContext('2d')
메소드를 이용해 캔버스에 2D 그림을 그릴 수 있는 컨텍스트(ctx
)를 획득- 그 후 새 이미지를 생성(
new Image()
)하는데 그 이미지의src
속성을imageIndex
라는 해당 인덱스의 이미지로 설정 - 이미지를 비동기로 불러오기 때문에, 이미지가 로드 된 이후에 해야하는 작업들은 항상
onload
에 콜백 함수로 작성 - 이미지가 로드되면 컨텍스트의 좌상단(
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,
);
});
},
- 이미지를 그릴 때처럼 캔버스 컨텍스트를 얻어오고, 텍스트 옵션들을
this.option
에서 받아옵니다. ctx.textAlign
옵션으로 텍스트를 가운데 정렬합니다.ctx.textBaseline
옵션으로 글자의 기준선을 글자 높이의 중심으로 설정합니다.ctx.font
로 폰트를 꾸밉니다. CSS의font
프로퍼티와 동일한 구문으로 설정할 수 있습니다. CSS에서 외부의 웹폰트를 임포트했을 경우 캔버스에서도 사용할 수 있습니다.<textarea/>
로는 개행문자(\n
)도 받을 수 있기 때문에, 여러 줄의 입력도 받을 수 있도록 각 줄을 개행문자로 나눕니다.- 개행문자로 나눠진 개수만큼 반복문을 실행합니다.
ctx.fillStyle
로 폰트 색상을 설정합니다.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월 4일, 7일, 14일에 유의미한 상승폭이 있는 것을 확인할 수 있습니다. 저 상승폭이 무엇을 의미하는지 궁금하시다면 별도로 게시한 포스트인 특정 버튼의 클릭 이벤트를 통계로 보고 싶어!를 참조해주세요.
마무리
오랜만에 재밌는 프로젝트를 해 본 것 같아서 기분이 좋습니다. 재밌다고 많은 분들이 공감해주셔서 저 역시도 뿌듯한 마음이 듭니다. 그리고 지금보다도 더 많은 사용자가 생겼으면 좋겠다는 소망도 갖고 있습니다.
사실 이 프로젝트는 곽철용이라는 인터넷 밈의 인기에 편승(?)한 거라… 뭔가 숟가락만 얹은 느낌도 들어서 괜히 찔리네요. 앞으로는 좀 더 개발자스럽게, 이 프로젝트를 기술적으로 어떻게 더 개선할 수 있을지에 대해서 고민해보아야겠습니다.
코드는 Github에도 공개되어 있으니, 이 프로젝트가 마음에 드신다면 스타(⭐)를, 개선이 필요한 점이 있다면 풀 리퀘스트를 보내주시면 감사하겠습니다. 덧글도 언제든 환영입니다.