들어가며
이 글의 내용중 일부는 일반인이 이해하기 힘든 내용이 포함되어 있을 sudo 있습니다.
안녕하세요. 메이플 사진관을 1인 개발한 7년차 서버 개발자 SpiralMoon 입니다.
오늘은 메이플스토리 관련 서비스인 메이플 사진관의 기획부터 오픈까지의 과정과 이후에 마주한 상황에 대해 기록을 하려 합니다.
프로젝트 시작 전
본래 저는 Github에서 넥슨의 MapleStory Open API를 Wrapping하는 Client side library를 JS, Java, C#, Python 4가지 언어로 작성하여 오픈소스 개발 및 패키지 저장소에 배포하고 있었습니다. 서비스를 직접 만드는 것 보다는 남들이 서비스를 쉽게 만들 수 있도록 도와주고 싶었거든요.
특히 js/ts로 작성된 라이브러리는 NPM에서 다운로드 수도 꽤 집계되고 있으며, 현재까지도 넥슨 측의 변경사항에 따라 꾸준히 업데이트 버전을 제공중입니다.
저는 2024년 2월부터 현업에서 잠시 물러나 휴식을 취하고 있었습니다. 재취업 전까지 딱히 할 일도 없었고 개발도 몇개월간 쉬었기 때문에 혼자서 사이드 프로젝트를 진행해보기로 하였습니다만... 마땅한 주제가 떠오르지 않아서 아이디어를 떠올리기 전에 몇가지 규칙을 세웠습니다.
- 직접 배포중인 라이브러리(maplestory-openapi)를 사용해서 메이플스토리와 관련된 프로젝트를 만들자
- next.js 프레임워크를 학습해가며 프로젝트를 만들자
- 이미 공개된 서비스들과는 다른 특별한 서비스를 제공하자
저는 효율을 중요시 하는 개발자이기 때문에... 새롭고 특별한 서비스를 개발하면서 프레임워크에 대한 학습까지 진행하기로 하였습니다.
첫 번째 사전 조사 - 기존 서비스
프로젝트를 진행하기에 앞서 이미 개발된 메이플스토리 관련 외부 서비스들에 대해 조사 했습니다.
넥슨이 OPEN API를 공개한 이후로 개발자들은 주로 메이플스토리 게임 플레이를 돕거나 기록 조회가 중심이 되는 서비스를 만들었습니다.
우선 넥슨에 공식으로 등록된 메이플스토리 파트너스 서비스 목록을 플랫폼과 핵심 기능에 따라 분류하였습니다.
분류 결과
서비스의 대부분이 웹서비스로 제공되고 있으며, 공통적으로 캐릭터 정보 조회와 아이템 강화 시뮬레이션 기능을 제공한다는 점을 파악했습니다.
사용자 접근성을 고려했을 때 웹은 좋은 선택이지만, 서비스의 대부분이 기능까지 비슷했습니다.
분류한 결과를 바탕으로 현 시점에서 비슷한 서비스를 또 개발하는 것은 의미가 없다고 판단했습니다.
이유는 크게 2가지 입니다.
첫 번째, 비슷한 서비스를 개발한다면 먼저 개발된 서비스들에 가려져 경쟁력이 없습니다. 레드오션입니다.
두 번째, 베끼기만 해서는 영양가도 없고 동기부여도 안됩니다. (단, 입문 개발자에게 서비스 Copy는 추천할만함)
두 번째 사전 조사 - 게임과 유저
차별성이 있는 서비스를 만들어서 메이플스토리의 유저들에게 공개하고 싶었기 때문에 메이플스토리의 유저들은 무엇을 좋아하는지에 대해 추가적인 분석이 필요했습니다.
메이플스토리는 20년 넘게 장수하고 있는 온라인 횡스크롤 RPG 게임이며 귀여운 캐릭터와 각자 다른 매력을 가진 몬스터/지역/세계관, 그리고 완벽하지는 않지만 기본적인 스토리라인을 가지고 있는 훌륭한 IP 사업 아이템입니다.
게임 내 핵심 콘텐츠로는 몬스터 사냥, 보스레이드, 캐릭터 꾸미기 및 코스튬 수집, 아이템 강화(도박1), 아이템 가챠(도박2) 등이 있으며 RPG 게임이기 때문에 유저들은 각각 다른 컨텐츠를 즐기기 위하여 게임을 플레이합니다.
유저는 플레이어의 크게 두 그룹으로 나눌 수 있습니다. A 그룹은 보스레이드와 강화를 즐기는 인구가 많으며, B 그룹은 캐릭터를 꾸미는 것에 진심이고 각종 코디 아이템을 수집하며 A 그룹 유저보다는 스펙업에 대한 욕심이 적습니다. (+ 돈으로 코디템 열심히 뽑아놓고 결국엔 몇번 안입음)
(메이플을 10년 이상 해오면서 느낀 주관적 견해입니다.)
얼핏보면 두 그룹에서 공통점을 찾기란 쉽지 않아보이는데요, 간단하게 생각해보면 두 그룹은 “강해진 내 캐릭터”, “예쁜 내 캐릭터”를 주요 컨텐츠로 즐기고 있습니다. 즉, 유저들은 캐릭터에 대한 애착이 공통점으로 존재합니다. RPG 게임이니 어찌보면 당연한 얘기입니다.
아이디어 결정
아이디어를 떠올리기 위해 범위를 좀 더 좁혀보았습니다.
첫 번째, 모든 유저가 가볍게 이용할 수 있어야 한다. (진입장벽 X)
두 번째, 메이플스토리만의 감성을 살려야 한다.
세 번째, 캐릭터에 대한 애착 심리를 이용하여야 한다.
(사실 여기서 "돈이 벌려야 한다"를 추가하면 게임회사 기획자가 하는 일입니다.)
위 세가지 규칙을 엮는 것은 쉽지 않았는데 갑자기 떠오른 키워드는 "아이템"이었습니다.
메이플스토리에서 유저가 제일 오래동안 보는 화면중에 하나는 장비창과 인벤토리창입니다. 여기에서는 캐릭터가 보유한 아이템을 확인할 수 있습니다. 이러한 Window UI와 Tooltip 요소는 메이플스토리가 유저들과 수년간 쌓아온 익숙함과 감성이 들어있다는 점을 캐치했습니다.
플레이어의 캐릭터를 아이템처럼 보여주면 어떨까?
캐릭터 정보를 장비아이템 ToolTip 스타일로 보여주면 재밌을 것 같다는 생각이 들었고, 즉시 프로토타입을 만들어 직접 확인해보기로 했습니다.
개발 - 프로토타입 제작
캐릭터 정보를 표현하기에 앞서, 장비아이템 ToolTip을 완벽하게 구현해내는 것을 우선 목표로 설정하였습니다. 조금이라도 원작과 다르게 보인다면 유저들은 제 서비스에 만족하지 못 할 것이기 때문입니다.
이미지 리소스 수집
HTML과 CSS만으로는 완벽한 ToolTip을 구현할 수 없었습니다. 주요 요소들이 이미지로 구현되어 있기 때문인데요. 이미지 리소스를 수집하기 위해서 WzCompareR2(위컴알) 프로그램을 사용해 메이플스토리 리소스 파일(.wz)에서 이미지 파일을 추출 했습니다.
ToolTip Layout 구현
먼저 장비아이템 ToolTip을 인게임 화면과 동일하게 구현하기로 했습니다.
maplestory-openapi 라이브러리로 캐릭터의 데이터를 조회할 수 있는 개발용 페이지를 만들고 이 곳에서 ToolTip 개발과 테스트를 진행했습니다.
ToolTip의 요소를 역할 단위로 분리하여 별개의 Component로 정의하였습니다.
크게 TopSection, Section, BottomSection 순차적으로 배치되게 하였으며 dot line으로 분리된 영역은 전부 Section이 담당하도록 구성했습니다.
/**
* 메이플스토리 장비아이템 툴팁의 "중앙 섹션"
*/
export const MapleStoryEquipmentItemTooltipSection = ({
children,
divisionLine,
}: {
children: ReactNode;
divisionLine?: boolean;
}) => {
return (
<div
style={{
position: 'relative',
height: '100%',
}}
>
<img
src={'./item/frame_line.png'}
style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: '100%',
objectFit: 'fill',
}}
/>
{children}
{/* 구분선 */}
{divisionLine && <img src={'./item/frame_division.png'} />}
</div>
);
};
예시인 MapleStoryEquipmentItemTooltipSection의 구현 소스코드 입니다. 외부로부터 children node를 전달받아 그대로 내부에 표시하는 역할입니다.
위 사진은 결과물입니다. 감쪽같이 인게임과 거의 동일한 수준으로 구현되었습니다.
Character ToolTip Layout 구현
앞에서 먼저 개발한 컴포넌트를 재활용하여 캐릭터를 장비아이템 처럼 보여주는 새 컴포넌트를 개발했습니다.
제 친구의 캐릭터 데이터를 기반으로 만들어진 프로필 결과물입니다. 저는 사실 이 단계에서 결과물을 보자마자 만족했고 외부에 공개하면 좋은 반응이 있을 것이라고 단번에 확신했습니다. 메이플스토리의 감성을 잘 살려냈고, 왠지 모를 소유욕이 느껴졌거든요. 메이플 유저들은 그냥 이렇게 귀여운거 좋아합니다.
외형 Section은 메이플스토리의 안드로이드 ToolTip을 참고했으며, 스탯 Section에서는 큰 숫자에 comma, unit 두 형태의 서식을 적용하여 가독성을 높였습니다.
프로토타입 결과물을 지인들에게 공유했습니다. 다들 아이템이 되어버린... 자신의 캐릭터를 보고 귀엽다고 만족했으며, 수정 피드백도 반영했습니다.
이 과정에서 지인 한 분이 커뮤니티에 자랑글을 올리기도 했었는데, 조회수가 무려 6만에 달했고 댓글창에 "직접 써보고 싶다", "귀엽다", "갖고싶다" 등의 긍정적인 코멘트가 많이 달렸습니다. 앞에서 좋은 반응이 있을 것이라고 확신했었는데 예상 적중 했습니다.
개발 - 웹사이트 구현
일반 사용자들이 기능을 이용할 수 있도록 웹사이트 제작을 진행했습니다.
반응형 웹 적용
PC, Mobile 환경에서 모두 원활하게 이용 가능하도록 웹사이트의 레이아웃을 반응형으로 제작했습니다.
다만, 저는 프론트엔드가 아닌 백엔드 개발자이기 때문에 당장 실무 레벨 수준의 UI를 개발하는 것은 어려웠습니다. 그래서 웹사이트 구현은 심플하게 생성형 AI를 활용하여 제작하기로 하였고, 요즘 AI들은 대부분 TailwindCSS으로 코드를 보여주기 때문에 호환성을 위해 TailwindCSS 기반으로 웹사이트를 구현했습니다. (중요한 것은 웹디자인이 아니라 기능이다!)
특히 TailwindCSS 공식 사이트에서 템플릿과 컴포넌트를 가져다가 잘 사용했습니다.
AI 서비스는 makereal tldraw와 v0를 활용 했습니다.
캐릭터 프로필 생성 기능
쉽게 사용할 수 있도록 행동 단계를 3단계로 구성하였습니다.
- 닉네임으로 캐릭터를 조회
- 커스터마이징 (기본 프리셋 있음)
- 만들기 버튼 클릭
굳이 커스터마이징 기능을 넣은 이유는 유저마다 표시하고 싶은 데이터가 다를 수 있으며, 한 화면에 많은 정보가 담기는 것은 가독성을 저하시키는 문제가 있기 때문입니다.
커스터마이징은 최대한 여러 항목을 준비했고, 개선을 위해 어떤 설정을 사용해서 프로필을 만들었는지에 대한 로그 데이터를 수집하고 있습니다.
항목이 너무 많고 MapleStoryEquipmentItemTooltip 내부의 depth가 깊기 때문에 props를 깔끔하게 전달할 수 있는 방법을 모색중입니다.
로딩 연출
메이플 사진관에서 대기시간이 발생하는 시점은 두 곳인데요. 첫 번째는 캐릭터 정보 로딩 시점, 두 번째는 프로필 생성 시점입니다.두 상황에서 요청이 완료되기까지 Modal을 표시하도록 했습니다.
Modal에는 메이플스토리만의 귀여운 감성을 녹여 연출해보았습니다. 특히 텍스트는 일반 사용자들이 쉽게 인지할 수 있도록 작성했으며, 시스템적인 메시지는 포함하지 않도록 의도했습니다. 프로필 생성 Modal에서는 "내 캐릭터를 촬영하는 상황"을 시각적으로 보여지도록 "카메라를 들고 여기저기 뛰어다니는 사진작가"가 보이도록 했습니다. 정말 귀엽죠?
개발 - 개발 과정에서의 기술적 이슈
글씨체 불일치 문제
ToolTip에 사용되는 폰트는 돋움(.ttc)입니다. 이 폰트는 Windows OS에서만 유효하므로 Android, iOS, MacOS 등에서 메이플 사진관에 접속 했을 때는 글씨체가 다르게 보이는 문제가 있었습니다.
차선책으로 Windows가 아닌 OS에서 접속한 경우에는 넥슨 Lv.1 고딕 폰트가 적용되도록 설정 했습니다. 생각보다 어색하지 않아서 다행이었습니다. (메이플핸즈+ 공식 앱과 동일)
HTML 이미지 변환 라이브러리 문제
웹 화면에 보이는 캐릭터 프로필은 결국 사용자 디바이스에 저장되어야 합니다. 브라우저에 렌더링된 DOM 객체를 이미지 파일로 변환하고 이를 다운로드 처리하도록 구현하면 됩니다.
DOM 객체를 이미지로 변환할 수 있는 상위권 js 라이브러리를 몇 가지 적용해보았습니다.
- html-to-image : 인터페이스는 가장 간단했으나 Windows 에서 이미지를 렌더링하면 텍스트 태그가 깨지는 버그가 있었고, 렌더링 속도가 평균 1200~1300ms 으로 매우 느리며 CPU 사용률도 높아 브라우저가 버벅거리는 현상이 있었습니다. 게다가 확률적으로 검정색 오류 이미지가 렌더링 되었습니다.
- dom-to-image : 이미지 렌더링 자체가 정상적으로 수행되지 않았습니다.
- html2canvas : 이미지 렌더링 결과물에서 텍스트 태그의 여백 속성이 브라우저 렌더링 결과가 상이한 버그가 있었고, 패키지의 마지막 업데이트도 3년 전으로 유지보수와 버그 수정이 중단된 점을 확인했습니다.
정상적인 라이브러리가 하나도 없어서 이대로 멸망하나 싶었는데... 다행히 올해 초에 출시된 html2canvas-pro 라는 html2canvas의 fork 버전 라이브러리를 발견했습니다. 해당 라이브러리는 html2canvas에서 방치된 버그가 수정된 버전으로 제가 겪고 있던 모든 문제를 해결할 수 있었습니다.
이미지 리소스 CORS(Cross-Origin Resource Sharing) 문제
HTML 요소를 이미지 파일로 변환하는 과정은 HTML → Canvas → 이미지 3단계로 구성되었는데 Canvas 단계에서 캐릭터 이미지에 대한 CORS 오류가 발생해 이미지 렌더링 결과에서 빠져버리는 버그가 발생했습니다.
/**
* URL 이미지를 base64 문자열로 인코딩합니다.
* @param url HTTP URL 이미지
*/
export const urlToBase64 = async (url) => {
const response = await axios.get(url, {
responseType: 'arraybuffer',
});
const base64Image = Buffer.from(response.data, 'binary').toString('base64');
const mimeType = response.headers['content-type'];
return `data:${mimeType};base64,${base64Image}`;
};
Nexon OPEN API에서 HTTP URL로 제공받던 캐릭터 이미지를 base64로 변환 후 사용하도록 서버 코드를 수정했습니다. 이렇게 하면 이미지 리소스는 로컬화되어 브라우저의 CORS 보안 정책을 회피할 수 있습니다.
이미지 리소스 뭉게짐 현상
이미지를 원본보다 작은 공간에 넣다보니 저해상도 디스플레이 환경에서는 브라우저 내장 알고리즘으로 인해 down sampling이 발생할 수 밖에 없었고, 특히 캐릭터의 얼굴이 어색하게 표현되는 문제가 있었습니다.
이미지가 .png 확장자이므로 해당 부분만 pixelated 옵션을 off하면 해결되는 문제이지만 html2canvas-pro를 통해 이미지를 생성할 때 이미지 객체에 대한 pixelated 옵션을 개별로 적용하는 것이 불가능했기 때문에 다른 방식으로 해결해야 했습니다.
/**
* 이미지에 안티 앨리어싱을 적용합니다.
* @param imageSrc
*/
export const convertSmoothImage = (imageSrc: string): string => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = imageSrc;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 1024;
canvas.height = 1024;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
};
이미지를 1024X1024로 확대한 후 부드럽게 처리하는 함수를 정의했습니다.
<MapleStoryItemTooltipIconFrameBox grade={grade}>
{/* 캐릭터 */}
<img
src={devicePixelRatio < 2 ? convertSmoothImage(icon) : icon}
style={{
position: 'absolute',
top: '50%',
left: '50%',
maxWidth: '38px',
maxHeight: '38px',
transform: 'translate(-50%, -50%) scale(2)',
objectFit: 'cover',
imageRendering: 'pixelated',
}}
/>
</MapleStoryItemTooltipIconFrameBox>
devicePixelRatio는 현재 디스플레이 장치에 대한 physical pixel 해상도와 css pixel 해상도의 비율을 나타내는 전역 변수입니다.
고해상도의 디스플레이가 아닌 경우 일반적으로 값은 1이기 때문에 devicePixelRatio가 2 미만일 때 이미지에 convertSmoothImage()를 적용하도록 했습니다.
위 사진에서 가운데가 convertSmoothImage ()의 결과물입니다. 원본에 더 가까워지긴 했지만 적용 이전보다 약간 흐려졌습니다. 이 부분은 솔직히 마음에 들지 않아서 나중에 다시 개선에 도전해 볼 생각입니다.
서비스 런칭
웹사이트 호스팅 준비
서비스 런칭을 위해 https://maplestudio.app 도메인을 구매하고 DNS를 설정했습니다. 서비스 이름 짓기가 애매했는데 도메인을 구매하며서 자연스럽게 메이플 사진관 이라는 이름을 붙였습니다. 각자 프로필 하나씩 찍어가는게 사진관의 역할이라고 생각하니 꽤 어울이는 이름입니다.
프로젝트는 nextjs로 작성되어 vercel을 이용하면 github 연동 후 별도 설정 없이 바로 호스팅이 가능하다는 얘기를 듣고 호다닥 달려갔습니다. 게다가 무료 호스팅까지 지원된다니! 서버가 미국에 있어서 로딩 속도가 좀 느리지만 무료니까 일단 넘어가기로 했습니다.
서비스 공개와 유저 반응
MVP 기능이 완성되었다고 판단되어 2024년 8월 19일 월요일 새벽에 배포 작업을 완료하였고, 오전 10시에 메이플 인벤(커뮤니티)에 서비스를 공개했습니다!
기획부터 오픈까지 약 10일정도의 개발 기간이 소요되었습니다.
서비스 공개 게시글 :
이후 유저 반응을 살펴보았습니다.
감사하게도 많은 유저분들께서 긍정적인 반응을 보여주셨고 사용 인증도 해주셨습니다.
이 뿐만 아니라...
메이플 사진관은 사람들의 입소문을 타고 널리 퍼지게 되어 각종 커뮤니티까지 전파되었습니다. 여러 유저들이 하나가 되어 서비스를 즐겨주신 모습을 보니 개발자로서 정말 뿌듯함을 느낍니다.
게시물을 하나하나 읽어보며 유저들이 어떤 캐릭터에 애정을 갖고 플레이하는지 구경할 수 있는 시간이었습니다.
사용량 모니터링
서비스 공개 이후 얼마나 많은 유저들이 이용해주셨는지 사용량 통계를 확인 해보았습니다.
서비스 공개 시점으로부터 당일날 캐릭터 검색이 18,000회 발생하였고, S3에 저장한 로그 파일 기준으로 3시간만에 4,500회 / 24시간만에 13,000회 이상의 프로필 다운로드가 발생했습니다.
솔직히 많아봐야 몇천 수준으로 기록될 줄 알았는데 예상보다 많은 유저분들께서 이용해주셔서 놀랐습니다.
또한 3시간만에 무료 호스팅 사용량을 초과하여 결국 서버비를 추가 결제했습니다...
로그 파일을 저장하던 AWS S3도 프리티어 사용량을 초과했습니다...
(서버비 걱정 없이 개발하던시절로 돌아가고 싶다...)
앞으로의 계획과 회고
백엔드 개발인 본업에서 잠시 벗어나 프론트엔드 개발을 해보니 꽤나 재미있는 시간이었습니다.
짧은 기간안에 만드느라 nextjs 장점을 제대로 활용하지 못한 점, 검색 최적화를 하지 못한 점, UI를 대충 만든 점 등은 아쉬움이 큽니다.
메이플 사진관은 현재 프로필 생성 기능만 있는 MVP 프로젝트입니다. 프로젝트의 반응이 좋아 더 다양하고 즐거운 컨텐츠를 제공해드리고 앞서 아쉬웠다고 느낀 부분을 보완하기 위해 추가 개발에 착수했습니다.
완성도가 일정 수준으로 올라왔다고 판단되면 메이플스토리 파트너스에도 도전해보려고 합니다.
읽어주셔서 감사합니다.
7년차 서버 개발자 SpiralMoon
댓글