사이드 프로젝트 후기

2022-10-14


avartment.com

지금으로부터 한 8달 전 2월 쯤 회사에서 사내 사이드 프로젝트 팀을 만들었다. 좀 늦었지만 이제 배포를 완료하였다.

처음에 사이드 프로젝트를 하게 된 이유는 회사에 재미있는 컨텐츠 아이디어들이 많이 나오는데 일정과 컨셉의 불일치 등의 사유로 짬되어버리는 아이디어들이 생기게된다.

넘치는 재능을 주체할 수 있는 팀원들의 아이디어가 기각되고 실현되지 않는 것이 사회 초년생 입장에서 얼마나 기운 빠지는 일인지 잘 알고 있기에 시니어(?)가 초년생들을 도와주고 싶어서 나서게되었다. 여러 아이디어 중에 재밌어 보이는 것을 실현 시켜주고 싶었다.(아마도 내가 심심했던 걸 수도 있다.)

처음에 기획은 나 모음 집이고, 내가 만든 아파트에 다른 사람이 내 아바타를 만들어주는 컨셉이었는 데, 커뮤니케이션 기능을 제대로 하기엔 작업 기간이 길어질 것을 우려하여 커뮤니티성 기능이 제거하고 단순히 아바타를 만들고 이미지를 저장할 수 있는 서비스가 되었다.

아바타 조합

프로젝트는 NextJS를 활용해서 만들었다. 아바타를 빌드하는 것은 Canvas를 활용해서 동적으로 수정될 수 있게 만들었다.

아래가 아바타에 필요한 아이템을 선택한 화면이다.

image

고를 수 있는 것은 얼굴(표정), 헤어스타일, 상의 아이템, 창가이다. 각 고를 수 있는 것들이 다양하다 보니까 조합해서 나올 수 있는 값들이 굉장히 많아진다. 개발로 만든다면 단순히 레이어 쌓기로 만들 수 있다고 생각 했기에 단순하게 아래의 순서로 Canvas로 그림을 그리면 되겠지 라고 생각했다.

  1. 배경
  2. 몸통(상의)
  3. 얼굴
  4. 헤어스타일
  5. 아이템
  6. 창가

셀렉트로 아이템을 선택하면 아래처럼 데이터 구조가 만들어진다.

// avatarBuilderData
{
  "data": {
    "background": {
      "value": "Background"
    },
    "body": {
      "value": "Body"
    },
    "emotion": {
      "value": "Shout"
    },
    "face": {
      "value": "Face"
    },
    "hair": {
      "options": {
        "fillColor": "#653C68"
      },
      "value": "Beanie"
    },
    "item1": {
      "value": "Glasses"
    },
    "item2": {
      "value": ""
    },
    "item3": {
      "value": ""
    },
    "top": {
      "value": "Knit"
    },
    "window": {
      "value": "Window"
    },
    "windowItem": {
      "value": ""
    }
  },
 }

선택된 아이템들을 순회하면서 알맞는 svg 코드를 찾아서 XMLParer로 파싱하고 속성을 가져와서 그림을 그렸다. 이 프로젝트는 기획을 개발자들끼리 만드는 것이 아니라, 일러스트레이터도 함께 진행했다.

export const drawInCanvas = (
  drawables: Drawable[],
  canvas: HTMLCanvasElement
) => {
  const context = canvas.getContext('2d');
  if (!context) throw Error('can not find context');
  context.clearRect(0, 0, 9999, 9999);
  context.save();
  // 배경색 설정
  context.fillStyle = '#FECE00';
  context.fillRect(0, 0, 9999, 9999);
  context.restore();
  // 구조를 하나씩 순회하며 그림 그리기
  drawables.forEach(([drawable, option]) => {
    if (drawable) {
      try {
        const [tags, settings] = parseDrawable(drawable);
        // option 값이 있는 경우 override
        settings['.cls-1'] = {
          ...(settings['.cls-1'] || {}),
          ...option,
        };
        // canvas에 특정 아이템을 그리기
        drawLayer(canvas, tags, settings);
      } catch (e) {
        console.log(e, drawable);
        throw e;
      }
    }
  });
  return canvas;
};

순서대로 진행했을 때 한 가지 문제가 발생하였다. 선택된 아이템에 따라서 일러스트 그리기 순서가 달라지는 경우가 생겼다. 목걸이 같은 경우는 상의위에 그리고, 그 이후에 앞머리가 있는 머리를 그려야했고, 머리에 장착하는 아이템은 머리를 그린 후에 아이템을 그려야했다.

모든 경우의 수를 생각했을 때, 조합된 조건에 따라 다른 그리기 로직을 호출 할 수 없어서 다양한 상황을 커버할 수 있도록 그리기 객체를 만드는 과정을 추가했다.

const onChange = useCallback(() => {
  const canvas = canvasRef.current;
  // 1. 선택된 아이템에 맞게 빌드할 아이템을 레이어에서 찾아서 그리기 객체 생성
  const drawables = buildDrawables(avatarBuilderData);
  // 2. 순서대로 그리기
  drawInCanvas(canvas, drawables);
}, [avatarBuilderData]);
image image

결과물은 순조롭게 모든 경우의 수를 레이어에서 찾아서 가져오는 것으로 해결할 수 있었다.

일러스트레이터에게 개발 가르치기

위의 과정을 통해서 작업을 만들었는데, 일러스트레이터가 준 이미지들을 테스트해보고 바로 변경해서 테스트해보는 과정이 가능해야 했는데, 사이드 프로젝트 수준에서 비개발자가 테스팅 해볼 수 있는 툴을 제공하기가 꽤 품이 많이 들고 테스팅을 위해서 오프라인으로 계속 붙어서 작업하는 것도 번거로웠다. 그래서 언제든지 작업하고 싶을 때 작업하면서 확인해볼 수 있도록 일러스트레이터의 컴퓨터에 nodejs를 설치하고 리액트 실행 환경을 만들어 주었다. 어떻게 하면 테스팅 할 수 있는 지 교육을 하고

그리고 npm run dev 해보시고 결과물은 슬랙으로 주세요 라고 했다.

일러스트레이터는 재밌어(?) 하면서 개벌서버를 올려서 테스팅할 수 있었고, 소통을 최소한으로 하면서 아주 빠르게 작업을 진행 할 수 있었다.

여기서 한 가지 문제가 더 발생했는데, 일러스트레이터가 레이어나 네이밍을 다르게 수정하면, 매번 개발자가 코드도 다시 수정해야했다. 결국 개발자가 일을 해야한다는 것은 소통을 더 많이 해야한다는 뜻이기 때문에 일러스트레이터에게 최대한 많은 자유도를 부여할 수 있으면서 개발자의 품을 줄일 수 있는 해결책을 생각해봤다. 결론은 코드를 생산하는 코드를 만드는 것이었다.

아래가 코드를 생산하는 로직이다. 이 코드로 만들어진 코드는 .gitignore에 등록하였다.(매번 바뀌는데, 개발자가 만든 것이 아니라 커밋에 포함시키기엔 부적절하다고 생각했다.)

function createDrawerBuilder() {
  const assembled = assembleAssets(ASSET_PATH);
  const layers = Object.keys(assembled)
    .map((key) => ({
      key,
      selectorKey: key.replace(/^Layer\d+/, ''),
      index: Number(key.replace(/^Layer(\d+)\w+/, '$1')),
    }))
    .filter((item) => !['root'].includes(item.selectorKey))
    .sort((a, b) => a.index - b.index);
  fs.writeFileSync(
    `${UTIL_PATH}/DrawableBuilder.ts`,
    `
    import { Drawable, DrawablePayload } from '@/@types/drawable';
    import * as SvgData from '@/avatar/constants/layers';
    import color from 'color';
    import metadata from '@/avatar/constants/layers/metadata';
    import { convert, extract } from './object';

    function darkenFillStyleByLayerId(options: any, layerId: string, value: string) {
      const { isDarken } = metadata[layerId as 'Layer1']?.[value as 'Background'] || { isDarken: false };
      if (isDarken) {
        return convert(options, 'fillStyle', () =>
          color(options.fillStyle || 'black').mix(
            color('black'),
            0.3
          )
        );
      }
      return options;
    }

    export function buildDrawables(payload: DrawablePayload): Drawable[] {
      const data = SvgData;
      const layerMap = SvgData.layerMap;
      return [
        ${layers.map(createDrawerItem).join(',\n')},
      ];
    }
    `
  );
}

일러스트레이터가 특정 디렉토리 하위에 Layer[layerNumber:number][assetSelectorName:string]라는 네이밍 규칙을 따르는 폴더를 만들고 그 아래에 필요한 애셋을 추가하면 되도록 만들었고 아래처럼 구조를 만들면 되었다.

ex)

avatar
  |-- Layer2Hair
  |       |--Curlyshort.svg
selector
  |-- Hair
  |     |--Curlyshort.svg
# Selector에서 Hair에서 Curlyshort라는 것을 골랐으면 순서대로 했을 때 이 애셋은 2번째로 그림을 그린다.
# 위와 같은 구조로 만들어 두면 npm run assemble 명령어 수행으로 HairSelector.tsx 파일과 DrawBuilder.ts 파일이 자동 생성되어 로직에 자동 반영된다.

일러스트레이터는 이제 Layer를 얼마든지 늘리고 줄여가면서 애셋을 조절하면서 테스트해볼 수 있었고, 개발자 없이도 프로젝트에 기여할 수 있게되었다.

후기

생각보다 작업은 빠르게 진행되었지만 사내 사이드이다보니 우선순위에 밀리면서 배포가 아주 늦어졌다... ~~중간에 퇴사하는 사람도 생겼다.~~ 하지만 일러스트레이터와 함께 프로젝트를 진행하는 것이 가능하단 것을 깨닫으면서 꽤 재밌는 작업이었고, 이제는 사람들이 많이 써주면 좋겠다. 할로윈을 위해서 아이템을 추가하면 좋을 것 같은데, 추가해달라하면 화내려나..?