들어가며
이 글의 내용중 일부는 일반인이 이해하기 힘든 내용이 포함되어 있을 sudo 있습니다.
안녕하세요. 메이플 사진관을 1인 개발한 7년차 서버 개발자 SpiralMoon 입니다.
오늘은 메이플스토리 관련 서비스인 메이플 사진관의 첫 오픈 이후의 추가 업데이트 과정과 유저 이벤트를 진행한 상황에 대해 기록을 하려 합니다.
시리즈
2024.09.06 - [Project] - [메이플 사진관] 프로젝트 기획부터 서비스 공개까지
서비스 확장에 대한 고민
지난 8월 19일 서비스 오픈 직후 폭발적인 관심을 받았으나 당시엔 MVP 단계로 기능이 하나밖에 없어 서비스 이용량은 점차 줄어들었습니다. 유입 하락은 예상했던 결과였습니다.
새로운 기능을 도입하여 다시 한 번 유입을 늘리고, 서비스가 장기적으로 유지될 수 있는 방향으로 확장이 필요해 보였습니다.
개발 - 신규 기능
아이템 프로필과 몬스터 프로필 (9월 23일 업데이트 반영)
지난번 장비 아이템 프로필과 비슷한 개념으로 아이템 프로필과 몬스터 프로필 등 툴팁 형태의 템플릿을 두 종류 추가하기로 했습니다.
(작은 공간 안에 억지로 넣어 찌그러진 캐릭터가 생각보다 귀엽다!)
아이템 프로필은 장비 아이템 프로필과 다르게 진짜 아이템처럼 보일 수 있도록 캐릭터의 능력치를 표시하지 않고 좀 더 자유로운 텍스트 기반의 커스터마이징에 집중할 수 있도록 구현했습니다.
그리고 아이콘과 아이템 속성 등을 꾸밀 수 있도록 하여 실제 메이플스토리의 소비, 기타, 설치, 캐시 아이템처럼 보이도록 했습니다.
몬스터 프로필은 인게임에 등장하는 툴팁은 아니지만, 메이플 테스트서버가 업데이트되면 위컴알로 추출된 신규 몬스터 정보가 게시되는데 그 때 사용되는 스타일이라 플레이 경험이 긴 유저들에게는 익숙할 것으로 판단했습니다.
등장위치는 NPC 툴팁을 참고하여 유저가 커스터마이징 할 수 있도록 했습니다. 이렇게 보니 확실히 메이플 월드의 주민이 된 것 같습니다.
메이플 주민등록증과 해시태그 (11월 6일 업데이트 반영)
툴팁 스타일의 프로필 말고도 캐릭터를 잘 표현할 수 있는 메이플스토리의 요소가 무엇이 있을지 고민하던 중, 메이플스토리에서 인기 많은 의자인 주민등록증 시리즈와 해시태그를 만들어달라는 요청이 들어왔습니다.
저도 해당 의자들을 참 좋아하는데요. 이벤트 기간을 놓쳐 얻지 못한 의자가 있습니다... 이런 아쉬움은 저만 느끼는 감정은 아닐 것입니다. 메이플 사진관에서 내 캐릭터를 해당 의자들에 앉혀서 사진을 찍을 수 있도록 기능을 제공한다면 앞에서 말한 아쉬움을 달래줄 수 있다고 생각하여 리소스를 수집하고 개발에 착수했습니다.
특히 이번 작업은 PNG 뿐만 아니라 GIF 까지 다운로드 할 수 있도록 하여 생동감 있는 이미지를 전달하고자 했습니다.
우선 주민등록증 시리즈입니다.
메이플 주민등록증 의자는 캐릭터명, 직업, 생성일을 주민등록증 형태로 보여주는 예쁜 의자입니다. 인게임에 존재하는 모든 시리즈를 선택할 수 있도록 구현했습니다.
그리고 메이플 사진관만의 차별점을 두기 위해서 인게임에서는 없는 표현인 포즈를 취할 수 있도록 했습니다. 귀엽지 않나요? 여성 유저분들께서 정말 좋아하실 것 같아요.
다음은 해시태그 입니다.
메이플 해시태그 의자는 SNS 포스트처럼 꾸밀 수 있는 예쁜 의자입니다. 그러나 인게임에서는 캐릭터의 발자취를 기반으로 하는 정해진 텍스트만 선택할 수 있다는 제약사항과 의자 이벤트 기간이 끝나면 내용을 수정할 수 없다는 큰 단점이 존재합니다.
메이플 사진관에서는 유저가 원하는 텍스트를 입력할 수 있도록 구현했습니다.
개발 - 웹 페이지
메인 페이지 개편
메이플 사진관을 좀 더 장기적인 서비스로 만들기 위해서 조잡했던 메인 페이지를 새롭게 단장하고 여러 기능을 배치하기로 했습니다.
위 사진은 첫 출시 당시의 메인 페이지입니다. 당시에는 기능이 하나였으므로 메인 페이지에 직접 기능을 배치했었습니다.
메인 페이지를 수정했습니다. 상단은 서비스를 소개하는 글을 배치했고, 로고를 교체했습니다.
네비게이션에 기능 바로가기 버튼을 추가했습니다. 이 때 기능마다 네비게이션 버튼을 전부 만들어버리면 사이트가 지저분하게 보일 수 있으므로 비슷한 기능끼리 그룹화하여 버튼 클릭 시 목록이 보여지도록 했습니다.
메인 페이지의 중앙에는 메이플 사진관의 핵심 기능을 CardView를 통해 나열하고 간략한 소개를 작성했습니다.
각 CardView는 그림자 표현을 넣었고, 마우스를 올리면 진입할 수 있는 상태를 알려주기 위해 텍스트 색상이 변경되도록 했습니다. 그리고 New 뱃지를 넣어 유저에게 신규 기능임을 알리도록 했습니다.
단계별 페이지 구성
기존 기능인 장비 아이템 프로필은 모든 커스터마이징 옵션이 한 페이지에서 보이도록 구성했었는데 사용 후기와 유저 통계에서 다음과 같은 정보를 알아냈습니다.
- 모바일 이용량이 많다.
- 대부분 기본 세팅값으로 프로필을 제작했다.
한 화면에 모든 기능을 배치했을 때 많은 기능은 오히려 모바일 환경에서 커스터마이징 경험을 해친다는 사용성 이슈를 발견했습니다.
그래서 신규 추가되는 주민등록증, 해시태그 기능은 제작 과정을 단계적으로 나누어 step-by-step 형태로 진행되는 모바일 친화적인 페이지를 구성했습니다.
깔끔해졌습니다. 이제 유저는 각 단계별로 요구되는 커스터마이징에 집중할 수 있을 것입니다.
개발 - Open Graph
URL을 게시할 때 미리보기가 보여지지 않아 유입을 저해하는 문제가 있었습니다.
새 기능을 공개하기 전에 Open Graph를 적용하여 타인에게 URL을 전달할 때 미리보기가 보여지도록 했습니다.
개발 - 프로젝트 구성 변경
Server 도입
메이플 사진관은 next.js로 개발된 프로젝트입니다. 서버의 기능이 필요하면 next.js에 내장된 API route를 사용하도록 구현했었으나, API route의 다음과 같은 단점들 때문에 구현에 어려움을 겪고 있었습니다.
- API route의 기본 제공 인터페이스가 빈약하여 생산성이 저하되는 문제
- 디렉토리 경로 기반의 API path 설정으로 인한 불편함
- serverless 실행 환경이므로 Cold Start로 인한 초기 지연 (특히 사용량이 적을 때)
- serverless 실행 환경이므로 데이터베이스 등 지속적인 커넥션 연결이 필요한 상황에 적합하지 않음
결국에는 위 문제점과 공부할 거리를 늘릴 겸 nest.js 프레임워크를 기반으로 하는 별도의 API 서버를 구축하기로 했습니다.
데이터베이스 도입과 로그 마이그레이션
기존에는 서버를 별도로 두지 않아 서비스 이용 시 발생하는 프로필 다운로드 로그를 AWS S3에 json 파일로 저장하고 있었으나, 로그로 통계를 구하기 위해서 MongoDB를 새로 도입하고 스크립트를 작성해 AWS S3에 저장된 모든 로그 파일을 DB로 마이그레이션 했습니다.
통계를 구할 수 있게 된 덕분에 사용자들에게 새로운 정보를 제공할 수 있었습니다.
또한, MongoDB는 Atlas로 구축했는데 대시보드 기능이 내장되있어서 간단한 사용량 통계를 모니터링 할 수 있어서 좋았습니다.
개발 - 개발 과정에서의 기술적 이슈
<img> 사이에 미세한 간격이 생기는 문제
아이템 프로필, 몬스터 프로필의 프레임은 9-slice 형태로 개발했었습니다.
그러나 이미지를 격자로 배치할 때 렌더링 환경에 따라 여백이 생기는 문제가 발생했습니다. 해당 버그를 해결해보려 레이아웃 구조를 flex, grid로 적용도 해보고 css 초기화(normalize.css 등)도 해보았으나 해결이 불가능 했습니다.
(해결 방법을 아시는 분은 댓글 부탁드립니다.)
이 문제는 프레임 이미지를 사용하지 않고 HTML + CSS를 활용하여 <div>를 최대한 원본과 비슷하게 만들어 해결하기로 했습니다. 검은 테두리, 흰 테두리, 내용물 총 3겹으로 구성되어 있어 하나의 <div>로는 구현할 수 없었고 <div>를 중첩으로 두었습니다.
position 동기화 문제
주민등록증 시리즈의 gif 미리보기에서 발생한 문제입니다.
유저가 gif를 선택하면 캐릭터가 들어간 반짝이는 주민등록증 움짤을 바로 보여줄 필요가 있었으나, gif는 여러장의 frame으로 구성되어야했고 성능 문제로 gif를 실시간으로 인코딩해서 보여줄 수 없었습니다.
대책 방안으로 미리보기에서 gif를 보여주는 대신 웹 요소를 frame delay 주기로 교체하는 애니메이션 연출을 적용하여 인코딩을 수행하지 않고 움짤처럼 보여지는 효과를 구현했습니다.
문제는 각 frame에 사용된 cover 이미지 파일의 크기가 가변값이라는 것입니다. 위 사진처럼 반짝이는 효과가 적용된 부분만큼 크기가 추가되기 때문에 모든 frame의 cover 이미지마다 별도의 position offset 값을 적용해야 했습니다.
// MapleStoryAnimationIdCardOption 클래스의 일부
public static readonly COVER_OFFSET: DisplayPosition[] = [
{ x: 146, y: 90 },
{ x: 155, y: 90 },
{ x: 164, y: 96 },
{ x: 159, y: 101 },
{ x: 155, y: 114 },
{ x: 146, y: 108 },
{ x: 146, y: 105 },
{ x: 146, y: 90 },
{ x: 146, y: 90 },
{ x: 160, y: 104 },
{ x: 164, y: 104 },
{ x: 160, y: 104 },
{ x: 160, y: 114 },
{ x: 146, y: 108 },
{ x: 146, y: 105 },
{ x: 146, y: 90 },
];
private coverIndex = 0;
constructor() {
for (let i = 0; i < 16; i++) {
const coverSrc = this.getCoverSrc();
const offset = this.getOffsetPosition();
this.coverImages.push(
<img
src={coverSrc}
alt="Cover"
style={{
position: 'absolute',
top: `${offset.y}px`,
left: `${offset.x}px`,
zIndex: 3,
objectFit: 'cover',
imageRendering: 'pixelated',
}}
/>
);
...
this.updateAnimationFrame();
}
}
public override getOffsetPosition(): DisplayPosition {
const standard = MapleStoryAnimationIdCardOption.COVER_OFFSET[0];
const current = MapleStoryAnimationIdCardOption.COVER_OFFSET[this.coverIndex];
return {
x: standard.x - current.x,
y: standard.y - current.y,
};
}
public updateAnimationFrame(): void {
this.coverIndex = (this.coverIndex + 1) % MapleStoryAnimationIdCardOption.COVER_FRAME_COUNT;
...
}
위 코드는 생성자 단계에서 MapleStoryAnimationIdCardOption 객체가 할당될 때 모든 cover 이미지에 position offset을 적용하는 과정입니다.
position offset 적용 후 기이한 현상이 발생했습니다... 특정 상황에서 position에 offset이 제대로 동기화되지 않아 이미지가 튀는 버그가 발생했습니다. 모든 frame을 분해하여 디버깅을 진행했으나 offset 값은 모두 정상이었습니다.
당시 소스코드는 frame이 넘어갈 때 현재 frame index와 일치하는 cover <img>를 사용하는 구조였습니다.
짧은 주기로 HTML 요소(cover <img>)를 매번 동적으로 렌더링 할 때 React에서(혹은 브라우저) positon 동기화 시점이 일정하지 않다고 추측했습니다.
export const MapleStoryAnimationIdCard = ({
options
...
}: {
options: MapleStoryAnimationIdCardOption
...
}) => {
...
const [coverIndex, setCoverIndex] = useState<number>(options.getCoverIndex());
...
useEffect(() => {
if (isPlay) {
const interval = setInterval(() => {
options.updateAnimationFrame();
setCoverIndex(options.getCoverIndex());
}, MapleStoryAnimationIdCardOption.FRAME_DELAY);
return () => clearInterval(interval);
}
}, []);
...
return (
<div>
...
{/* 커버 레이어 */}
{options.coverImages.map((element, index) => {
return <div style={{ display: coverIndex === index ? 'block' : 'none' }}>{element}</div>;
})}
...
</div>
)
}
앞서 말한 문제를 해결하기 위해 모든 frame의 cover를 Pre-Load 방식으로 변경하였습니다. 모든 요소를 display: none 상태로 미리 선언해두고 현재 frame index와 일치하는 순서의 요소만 보여지도록 했더니 동기화 문제가 해결되었습니다.
GIF 라이브러리 문제
이미지를 인코딩하는 작업은 리소스를 많이 소요하는 작업입니다. 서버에서 처리할 수도 있지만 서버가 저스펙(AWS free tier ec2)이라 이미지 작업은 클라이언트에서 처리하는 것이 좋다고 생각했고, 브라우저 환경에서 동작하는 gif 라이브러리를 찾아야만 했습니다.
- gif.js : 마지막 업데이트가 8년 전인 gif 생성 라이브러리입니다. web worker를 지원하여 병렬로 렌더링이 가능한 장점이 있지만, 투명 배경을 지원하지 않으며, dithering 방식이 캐릭터 이미지의 색상을 변경시키는 문제가있었습니다.
- gif-encoder-2-browser : gif-encoder-2 라이브러리를 브라우저 환경에서 실행할 수 있도록 polyfill이 추가된 버전입니다만, 제 환경에서는 실행되지 않았습니다.
- modern-gif : 최근에 개발된 gif 생성 라이브러리입니다. 투명 배경을 지원하고 이미지의 색상이 유실되는 문제도 없었습니다.
생각보다 메이저한 gif 오픈소스가 없어서 고생했지만, 메이플 사진관에서 필요로 하는 조건을 modern-gif 라이브러리가 만족했기 때문에 사용하기로 결정했습니다.
브라우저 리소스 부족 문제 (ERR_INSUFFICIENT_RESOURCES)
gif 인코딩 과정에서 발생한 문제입니다.
import { concurrent, map, pipe, take, toArray, toAsync } from '@fxts/core';
import html2canvas from 'html2canvas-pro';
import { encode } from 'modern-gif';
/**
* HTML 요소를 Canvas로 변환합니다.
* @param element
*/
export const createImageCanvas = async (element: HTMLElement): Promise<HTMLCanvasElement> => {
const canvas = document.createElement('canvas');
canvas.width = element.offsetWidth * devicePixelRatio;
canvas.height = element.offsetHeight * devicePixelRatio;
const ctx = canvas.getContext('2d');
ctx!.imageSmoothingEnabled = false;
return html2canvas(element, {
canvas,
backgroundColor: null,
});
};
/**
* HTML 요소를 GIF 애니메이션 이미지 URL로 변환합니다.
* @param frames
* @param options
*/
export const createImageUrlGif = async (
frames: HTMLElement[],
options: { frameDelay: number; width: number; height: number }
): Promise<string> => {
const { frameDelay, width, height } = options;
if (frames.length === 0) {
throw Error('frames must be not empty.');
}
const canvasFrames: HTMLCanvasElement[] = await Promise.all(frames.map(createImageCanvas));
const output = await encode({
width,
height,
maxColors: 255,
frames: canvasFrames.map((frame) => {
return {
data: frame,
delay: frameDelay,
};
}),
});
return URL.createObjectURL(new Blob([output], { type: 'image/gif' }));
};
브라우저에 표시되는 HTML 요소를 frame으로 취급하고 gif로 인코딩하기 위해서는 frame이 될 부분을 canvas로 변환하는 과정이 필요했습니다.
모든 frame을 canvas로 변환할 때 브라우저 리소스가 부족해지는 문제가 발생했습니다.
html2canvas() 함수는 변환 대상에 <img>가 포함되어 있으면 내부적으로 변환 준비 과정에서 리소스를 한 번 더 요청하는데 해당 작업이 동시에 너무 많이 요청(요청량 = 이미지 수 X 전체 프레임 수)되어 리소스를 로딩할 수 없는 상태가 되었던 것입니다.
import { concurrent, map, pipe, take, toArray, toAsync } from '@fxts/core';
...
const canvasFrames: HTMLCanvasElement[] = await pipe(
toAsync(frames),
map(createImageCanvas),
take(frames.length),
concurrent(2),
toArray
);
...
FxTS 라이브러리를 활용해 작업을 분할 실행 및 지연 평가하여 부하를 분산시키도록 하였습니다. 이제 canvas 변환 작업을 동시에 모두 실행하지 않고 2개씩 나눠서 실행하여 브라우저 리소스가 고갈되지 않게 되었습니다.
유저 이벤트 진행 후기
메이플 사진관이 관심 받을 수 있었던 이유는 유저분들 저마다의 캐릭터에 대한 깊은 애정이라고 생각합니다. 많은 관심 덕분에 뿌듯함과 특별한 경험을 할 수 있었습니다.
기쁨을 나누기 위해 유저분들에게 감사하는 의미로 주민등록증, 해시태그 프로필을 이용한 키링 제작 응모 이벤트를 계획했고 이를 커뮤니티에 공개하였습니다. 유저 본인들만의 캐릭터를 이용한 개인화 굿즈를 계획했기 때문에 다시 한 번 좋은 반응을 확인할 수 있었습니다.
이벤트는 일주일간 진행되었는데요. 준비된 수량은 각 10개밖에 안되었지만 응모자 수는 주민등록증 키링 584명, 해시태그 키링 71명으로 많은 분께서 응모해주셨습니다.
이후 당첨자들에게 상품 수령을 위한 배송지 정보를 수집했고, 굿즈 플랫폼을 통해서 키링을 발주하여 이벤트는 마무리 되었습니다.
마무리하며
이벤트를 진행하면서 많은 유저분들의 캐릭터를 살펴보았는데요. 다들 저마다의 귀여운 캐릭터를 키우고 계시더군요. RPG 캐릭터는 추억과 애정이 담겨있는 소중한 존재입니다. 이번 개인화 굿즈 이벤트를 계획한 것이 틀리지 않은 선택이었음을 확신했습니다. (발주비용 만큼 제 지갑이 아팠지만요...)
프로필 제작 기능은 1개에서 5개로 확장되었지만 메이플 사진관은 아직 MVP를 단계를 벗어나지 못했다고 판단하고 있습니다.
해야할 일이 많이 남아있고 우선순위도 정하기 쉽지 않은 상황이며, 공부를 병행하며 제작중이기 때문에 신규 기능을 빠르게 추가할 수는 없는 점은 안타깝습니다. 그럼에도 개발은 계속될 것입니다.
읽어주셔서 감사합니다.
7년차 서버 개발자 SpiralMoon
'Project' 카테고리의 다른 글
[메이플 사진관] 프로젝트 기획부터 서비스 공개까지 (5) | 2024.09.06 |
---|
댓글