2022-10-14
지금으로부터 한 8달 전 2월 쯤 회사에서 사내 사이드 프로젝트 팀을 만들었다. 좀 늦었지만 이제 배포를 완료하였다.
처음에 사이드 프로젝트를 하게 된 이유는 회사에 재미있는 컨텐츠 아이디어들이 많이 나오는데 일정과 컨셉의 불일치 등의 사유로 짬되어버리는 아이디어들이 생기게된다.
넘치는 재능을 주체할 수 있는 팀원들의 아이디어가 기각되고 실현되지 않는 것이 사회 초년생 입장에서 얼마나 기운 빠지는 일인지 잘 알고 있기에 시니어(?)가 초년생들을 도와주고 싶어서 나서게되었다. 여러 아이디어 중에 재밌어 보이는 것을 실현 시켜주고 싶었다.(아마도 내가 심심했던 걸 수도 있다.)
처음에 기획은 나 모음 집이고, 내가 만든 아파트에 다른 사람이 내 아바타를 만들어주는 컨셉이었는 데, 커뮤니케이션 기능을 제대로 하기엔 작업 기간이 길어질 것을 우려하여 커뮤니티성 기능이 제거하고 단순히 아바타를 만들고 이미지를 저장할 수 있는 서비스가 되었다.
프로젝트는 NextJS를 활용해서 만들었다. 아바타를 빌드하는 것은 Canvas를 활용해서 동적으로 수정될 수 있게 만들었다.
아래가 아바타에 필요한 아이템을 선택한 화면이다.
고를 수 있는 것은 얼굴(표정), 헤어스타일, 상의 아이템, 창가이다. 각 고를 수 있는 것들이 다양하다 보니까 조합해서 나올 수 있는 값들이 굉장히 많아진다. 개발로 만든다면 단순히 레이어 쌓기로 만들 수 있다고 생각 했기에 단순하게 아래의 순서로 Canvas로 그림을 그리면 되겠지 라고 생각했다.
셀렉트로 아이템을 선택하면 아래처럼 데이 터 구조가 만들어진다.
// 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]);
결과물은 순조롭게 모든 경우의 수를 레이어에서 찾아서 가져오는 것으로 해결할 수 있었다.
위의 과정을 통해서 작업을 만들었는데, 일러스트레이터가 준 이미지들을 테스트해보고 바로 변경해서 테스트해보는 과정이 가능해야 했는데, 사이드 프로젝트 수준에서 비개발자가 테스팅 해볼 수 있는 툴을 제공하기가 꽤 품이 많이 들고 테스팅을 위해서 오프라인으로 계속 붙어서 작업하는 것도 번거로웠다. 그래서 언제든지 작업하고 싶을 때 작업하면서 확인해볼 수 있도록 일러스트레이터의 컴퓨터에 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를 얼마든지 늘리고 줄여가면서 애셋을 조절하면서 테스트해볼 수 있었고, 개발자 없이도 프로젝트에 기여할 수 있게되었다.
생각보다 작업은 빠르게 진행되었지만 사내 사이드이다보니 우선순위에 밀리면서 배포가 아주 늦어졌다... ~~중간에 퇴사하는 사람도 생겼다.~~ 하지만 일러스트레이터와 함께 프로젝트를 진행하는 것이 가능하단 것을 깨닫으면서 꽤 재밌는 작업이었고, 이제는 사람들이 많이 써주면 좋겠다. 할로윈을 위해서 아이템을 추가하면 좋을 것 같은데, 추가해달라하면 화내려나..?