둘리 & 도우너 어서오고 짤 생성기 회고

그간 해보고 싶었던 사이드 프로젝트 중 짤(Meme) 생성기를 만들었다. 짤 생성기는 적은 시간 투자 대비 만족도와 활용도가 높은 편이라 꼭 해보고 싶었던 사이드 프로젝트 중 하나다.

웹툰 중 ‘도우너 어서오고’ 컷이 많은 베리에이션으로 화제가 되고 있는 와중에, 어떤 온라인 커뮤니티에서 한 유저가 문구 수정을 아이폰 단축키를 이용하여 번거롭게 그리고 예쁘지 않게 만드는 것을 보고 주제를 정하게 되었다.



둘리 & 도우너 어서오고 짤 생성기


결과 부터 공유하면 https://baek.dev/doolys-welcome를 통해 운영 중이다! 현재는 총 4종류이고, 템플릿화했기 때문에 데이터 프리셋에 값만 추가 해주면 반복적인 코드 작업없이 생성 되도록 만들었다. 모든 코드는 github@baek.dev/doolys-welcome에 공개되어 있다.

그럼 이 과정에서 어떤 고민이 있었고 어떻게 해결했는지 회고를 통해 공유하려고 한다 :)


( 동접수 실화? +_+ )

시작은 미미하지만, 일단 시작해보자!

프론트 초보인 나는(!) 소위 프론트엔드 프레임워크나 라이브러리를 써서 만들기에는 아는 것보다 모르는게 더 많아서 그 자체가 프로젝트 병목이 될 것이 자명했다. 그래서 조금 할 줄 아는 Vanilla.js로 일단 만들어보는 것이 1차 목표였다.

  • 짤은 캔버스를 이용하는 것 같아 캔버스 사용법을 찾아보고
  • input에서 타이핑하는 메시지가 캔버스에 바로바로 그려지는지 먼저 확인했다.
  • 이때 화자가 2명이므로 input을 2개로 설정했다.
  • 이어서 캔버스를 이미지 다운로드 하도록 기능을 추가했다.

이렇게 가장 메인이 되는 첫번째 어서오고 짤 생성기를 만들었다.

이후 React를 공부하면서 바로 적용해볼 수 있는 것들은 점차 적용하기 시작했다. 그래서 현재는 CRA를 이용해 React로 포팅했고, 운영에도 반영했다. 만약 내가 처음부터 React를 이용해서 만들려고 했다면, 분명 React를 잘 알지 못해 작동하지 않는 부분에서 나는 수없이 좌절했을 것이고 작동하는 early work1를 만들지 못하고 끝냈을 수도 있을 것이다.





주요 개발 포인트

다양한 화자들을 고려한 데이터셋 정의하기

애기 공룡 만화 컷에는 둘리와 도우너가 같이 나오는 2명의 화자가 있는 컷, 혹은 혼자 나오는 1인 화자 컷이 있다. 화자가 몇명인지, 각각의 화자는 어떤 기본 데이터를 갖게 하고 싶은지 JSON으로 정의한다. 이렇게 하는 이유는 컴포넌트를 재사용 가능하도록 분리하고, 가변적일 수 있는 데이터는 로직에서 분리하도록 하기 위함이다.

아래 코드를 보면 4개의 짤 유형별로 Actors.js에서 오브젝트를 불러오고 BuildMeme에 데이터를 던지기만 하면 자동 반복 생성된다.

import {Welcome, Hoi, Smoke, Over} from '../data/Actors.js';
import BuildMeme from './BuildMeme';

const DoolysWelcome = () => {

  const actors = [Welcome, Hoi, Smoke, Over];

  return (
    <div>
      {actors.map((actor, index) => {
        return <BuildMeme key={`actor-${index}`} actorConst={actor} />;
      })}
    </div>
  );
};

그럼 첫번째 Welcome 데이터가 ‘도우너 어서오고’의 프리셋인데, 이것으로 프리셋 구조를 소개해보려 한다.


// Actors.js  

const Welcome = {
	title: "어서오고",      // 짤 제목
	source: {
		imageSrc: "bg_1.png", // 배경 이미지 파일
		imageWidth: 519,      // 배경 이미지 가로 크기 
		imageHeight: 606,     // 배경 이미지 세로 크기 

		twoLineTextId: "inputWelcomeStranger", // input name
		threeLineTextId: "inputWelcomeDooly",  // input name

		filename: "doolys-welcome", // 이미지 저장 파일 명
	},
	presets: {
		twoLineConst: { // 최대 2줄을 갖는 화자의 프리셋
			placeholder: "어이 둘리.", // input placeholder
			maxMessageLength: 12, // input에 입력 가능한 max length
			lineLimitLength: { // 2줄 중 각 라인별 최대 글자 수
				first: 7,
				second: 5,
			},
			positions: { // 라인 별 x, y 좌표 값 
				init: {
					x: 100,
					y: 70,
				},
				firstRow: {
					x: 70,
					y: 55,
				},
				secondRow: {
					x: 70,
					y: 90,
				},
			},
			countForMove: 4, // 이 글자수 이상 입력되면 왼쪽으로 메시지 이동을 시작 
			lineHeight: 20,
		},
		threeLineConst: { // 최대 3줄을 갖는 화자의 프리셋
			// twoLineConst의 하위 프리셋과 구조가 동일하여 생략  
		},
	},
}

위 Welcome은 도우너 어서오고에 주입할 데이터이며, 이어서 초능력 맛 좀 볼래, 선넘네 등도 동일한 구조로 값을 각각에 맞게 추가 정의하면 된다.

글자수에 따른 x, y 좌표 포지션 계산하기

유저가 화자의 메시지를 입력할 때마다 바로바로 캔버스에 동적으로 그려주면 좀 더 만드는 재미가 생길 것 같아 프로젝트 처음부터 생각했던 부분이다. 단순히 20자 정도 되는 메시지를 말풍선에 한줄로 표현하진 않아야 한다. 만화를 보면 개행이 이뤄지기 때문에 그것을 반영하기로 했다. 그러기 위해선 글자가 입력될 때마다 동적 좌표가 필요했다.

앞서 소개한 위 Actors.js에서 presets > positions 이 말풍선 안에 메시지를 위치시키기 위한 좌표이다. 각 이미지마다 말풍선의 위치는 다르므로 각각의 화자가 다른 좌표를 갖는 것이다.



좌표가 변하는 예시



첫줄 안에서도 일정 개수 만큼 메시지가 입력되면 말풍선이 왼쪽으로 움직이도록 좌표를 업데이트 한다. 이후 2번째 줄에서도 계속 메시지는 움직이게 된다. 위 presets > positions 에서 정의된 좌표들은 메시지가 왼쪽으로 이동하는 한계를 뜻하기도 한다.

입력받은 메시지는 각 라인별 최대 길이 맞게 slice하고 좌표를 계산하는 정제를 수행한다. slice한 메시지와 그 메시지에 맞는 좌표는 오브젝트에 각각 프로퍼티 text와 x,y를 갖는 pos로 구성하여 배열로 리턴한다.

[
  { text: '메시지1', 
    pos: {
      x: 100, y: 100
  }}
]

로직은 일정 값 이상이 되면 왼쪽으로 이동하도록 좌표를 계산하는 포인트와 말풍선의 각 라인에 맞게 메시지를 자르는 것이다.

  // ThreeLineText.js 중 일부   
    
  // 첫번째 라인의 최대 개수로 메시지 처음을 자르고 
  const messageHead = inputText.slice(0, lineLimitLength.second);
  // 나머지 메시지는 두번째 라인에 넣을 것  
  const messageTail = inputText.slice(lineLimitLength.second);   

  // countForMove는 메시지가 이 숫자 이상 입력되기 시작하면 왼쪽으로 이동하도록 셋팅한 값이다.  
  // 왼쪽으로 이동한다는 것은 x좌표가 점점 줄어드는 것을 의미하므로 늘어나는 개수만큼 x 좌표를 줄여준다. 
  const secondPosX = positions.init.x - lineHeight * (messageTail.length - countForMove < 0 ? 0 : messageTail.length - countForMove);
  const positionMessageTail = {
    // 하지만 말풍선을 넘도록 좌표를 줄일 순 없으므로 셋팅된 x좌표 한계에 다다르면 더 이상 줄이지 않는다.  
    x: secondPosX < positions.thirdRow.x ? positions.thirdRow.x : secondPosX,
    y: positions.secondRow.y + 20,
  };

  // split된 메시지를 배열에 담아 리턴한다.
  let result = [];   
  result.push({
    text: messageHead,
    pos: { x: positions.secondRow.x, y: positions.secondRow.y - lineHeight },
  });
  result.push({ text: messageTail, pos: positionMessageTail });
  return result;

유저 입력 메시지를 실시간으로 캔버스에 업데이트 하기

이제 말풍선 메시지는 적절히 나누면서 좌표까지 가졌으니 캔버스에 그리기만 하면 된다. input에 keyup 이벤트를 추가해주고 input의 value를 setState로 업데이트 해준 뒤 캔버스에 이 메시지로 텍스트 좌표를 지정하면 끝!

처음에는 input 컴포넌트는 메인 컴포넌트에 함께 있었지만 점차 리팩토링하면서 분리를 했다. 이로인해 리액트에서 자식 컴포넌트가 부모 컴포넌트로 데이터를 전달하도록 부모 함수를 props로 전달받아 호출하도록 구성했다. 부모로 부터 전달 받은 함수 onKeyUp에 필요한 데이터를 담아 호출하면 onKeyUp의 구현부인 부모는 필요한 데이터를 setState하고 캔버스는 이 데이터를 갖고 rerendering 된다.

  // ActorsMessage.js 중 일부  
  
  const inputRef = useRef();
  useEffect( () => {
    inputRef.current.addEventListener('keyup', handleChange);
    return () => {
      inputRef.current.removeEventListener('keyup', handleChange);
    };
  });

  const handleChange = (e) => {
    // onKeyUp은 부모의 함수
    props.onKeyUp({'name': e.target.name, 'value': e.target.value});
  };

  return (

    <div>
      <input
        type="text"
        name={inputName}
        ref={inputRef}
        placeholder={placeholder}
        onKeyUp={handleChange}
        maxLength={maxLength}
      />

    </div>

  );
  // DrawingCanvas.js 
 
  const canvasRef = useRef();  
  const initCanvas = () => {
    const canvas = canvasRef.current;  
    const items = props.items; // 좌표를 갖는 split된 메시지 배열
    // 중략   

    const bgImage = new Image();
    bgImage.src = selectBackgroundImg(source.imageSrc);
    bgImage.onload = function () {
      // 이미지가 준비되면 캔버스에 업데이트 한다.
      drawPreviewImage(canvas, bgImage, items);  
    };
  };

  const drawPreviewImage = (canvas, bgImage, items) => {
    // 중략 
    items.forEach((v) => {   
      drawTextWithPostion(ctx, v.text, v.pos);
    });
  };

  const drawTextWithPostion = (ctx, text, position) => {
    // 중략
    ctx.fillText(text, position.x, position.y);
  };

  return (
      <div>
        <canvas ref={canvasRef} 
                width={source.imageWidth} height={source.imageHeight} />
      </div>
  );

컴포넌트 분리하기

초기에는 거의 단일 파일에 코드를 작성해서 일단 돌아가는(?)것이 목표였다. 기능 구현이 다 되고 난 이후는 컴포넌트 분리가 필요했다. 기능과 역할 별로 적절히 구성을 나눴고, 이에 코드도 변경이 필요했다. 예로 앞서 소개한 input과 canvas 분리가 가장 큰 분리였던 것 같다.

src
 ㄴ components
    ㄴ download
       ㄴ DownloadImage.js
    ㄴ draw
       ㄴ ActorMessage.js
       ㄴ DrawingCanvas.js
    ㄴ layout
       ㄴ Footer.js
       ㄴ Header.js
       ㄴ Menu.js
    ㄴ util
       ㄴ ThreeLineText.js
       ㄴ TwoLineText.js
    ㄴ BuildMeme.js
    ㄴ DoolysWelcome.js
 ㄴ data
    ㄴ Actors.js
 ㄴ images
    ㄴ bg_1.png
 ㄴ style
    ㄴ style.scss
App.js

처음부터 자동차를 만들지 마세요

나는 최소한 속도를 낼 수 있는 작은 바뀌 두개와 두개 발을 올릴 수 있는 나무 판 한개로 구성된 작은 보드 한개를 만들었다. 직접 바퀴를 깎았고, 직접 나무 판을 구해서 손수 조립했다.

이후 리액트를 공부하면서 공부한 내용을 바로바로 적용하면서 리액트 앱으로 포팅했다.
처음엔 리액트로 기능 그 자체를 만드는 것에 의미를 두었고, 이후에 모듈 분리를 하면서 필요한 코드 리팩토링을 점진적으로 적용했다.

물론 리액트 고수 분들이 보면 내 둘리짤 생성기는 한 없이 작고 귀여운 스파게티일지도 모른다.
하지만 이것은 베이비 스텝일 뿐이다.

이 짤 생성기를 고도화해서 좀 더 자동화하고 싶은 욕심이 있다.
그것은 좀 더 리액트를 익혀본 이후에 도전해봐야겠다 :)