본문 바로가기
Project

[메이플 사진관] 메이플 포토샵을 만들며

by SpiralMoon 2025. 5. 6.
반응형

들어가며

안녕하세요. 메이플 사진관을 1인 개발한 7년차 서버 개발자 SpiralMoon 입니다.

오늘은 메이플스토리 관련 서비스인 메이플 사진관의 새로운 주력 컨텐츠인 메이플 포토샵의 개발 과정과 성과에 대해 기록을 하려 합니다.


시리즈

2024.09.06 - [Project] - [메이플 사진관] 프로젝트 기획부터 서비스 공개까지

2024.11.25 - [Project] - [메이플 사진관] 기능 업데이트와 유저 이벤트 진행 후기

2025.02.04 - [Project] - [메이플 사진관] 디자인 시스템 도입과 리뉴얼 적용기


또 다시 찾아온 서비스 확장에 대한 고민

메이플 사진관의 프로필 템플릿

기존 메이플 사진관은 프로필 제작과 시뮬레이션 서비스를 주요 컨텐츠로 제공하고 있었습니다만... 문득 이런 고민이 생겼습니다.

 

정작 사진과 관련된 메인 컨텐츠가 없지 않나?

 

지금까지 개별 캐릭터에 대한 서비스만 제공하고 있었는데 이 부분에서 큰 아쉬움을 느꼈고 해결책을 찾아야 했습니다.


사진의 의미를 다시 생각해보다

"사진"이라는 단어를 곱씹어 봤습니다.

단순히 한 장의 이미지를 만드는 것을 넘어서, 사진은 여러 사람이 함께한 순간을 기록하는 수단이 아닐까요?

지금까지의 메이플 사진관은 캐릭터 한 명을 위한 컨텐츠에 집중해왔습니다. 하지만 유저들의 실제 플레이 경험은 분명 다릅니다.

 

메이플스토리는 늘 여러 캐릭터가 함께하는 이야기입니다.

길드원들과 추억, 친구화 함께한 보스 격파, 파티 사냥의 한 장면까지.

이 모든 순간이 "사진"이 되어야 하지 않을까요?

 

이런 문제의식을 바탕으로, 저도 자연스레 지난 시간을 되돌아보게 되었습니다.

16년 동안 메이플스토리를 플레이하면서, 저 역시 혼자가 아닌 "함께"였던 순간들이 가장 선명하게 기억에 남아 있습니다.

 

길드컨텐츠 입장을 위해 다 같이 모인 길드원들 (21.02)

 

물컹물컹한 신발을 얻기 위해 커닝시티에서 처음 파티 퀘스트에 도전하던 날,

모든 길드원을 소집해놓고 마스터가 노쇼해서 길드컨텐츠 입구에서 다 같이 폭동을 일으키던 사건,

60일간의 도전 끝에 파티원들과 메이플 월드의 최종보스 검은마법사를 격파한 순간...

 

메이플은 저에게 단순한 게임이 아닌, 사람과의 추억이 차곡차곡 쌓여가는 공간이었습니다.


메이플 포토샵 기획

메이플 포토샵으로 홈을 꾸민 모습 (메이플 홈 시뮬레이션)

"여러 캐릭터 정보를 한 장의 사진으로 남긴다"는 아이디어에서 출발해, 메이플 사진관의 신규 기능으로 메이플 포토샵을 기획하게 되었습니다.

  • 메이플스토리 게임 내의 맵을 배경으로 선택할 수 있어야 할 것 (직접 업로드한 이미지를 배경으로 사용할 수도 있음)
  • 서버가 달라도 플레이어의 캐릭터를 불러올 수 있을 것
  • NPC, 몬스터, 가구 등의 다양한 이미지 리소스를 불러올 수 있을 것
  • 각 요소를 편집할 수 있는 기능을 제공할 것

사용자는 여러 요소들을 직접 배치하고 다양한 편집 기능을 통해 자유롭게 구성할 수 있으며, 단순한 단체사진을 넘어 테마와 상황이 있는 장면 연출을 가능토록 하는 것을 목표로 삼았습니다.

 

궁극적으로 메이플 포토샵은 단순한 꾸미기 도구가 아닌, 유저들이 직접 자신만의 사진을 연출하고 남길 수 있는 캔버스가 되고자 했습니다.


개발 - 메이플 포토샵 UI 구현

기획이 결정된 이후, 실제 사용자가 직관적으로 조작할 수 있도록 UI 구현을 진행했습니다.

 

메이플 포토샵 UI 구상도

왼쪽에는 사진 연출을 위해 맵과 여러 요소들이 올라갈 canvas, 오른쪽에는 그 요소들을 편집할 수 있는 editor로 섹션을 구분 배치하였습니다.

 

canvas에서는 마우스로 방향키로 요소를 선택, 배치, 제거할 수 있고 editor가 선택된 요소의 타입을 감지하고 적절한 편집 기능을 우측에 표시하는 구조입니다.

 

플로팅 메뉴

canvas에 요소를 추가하는 기능은 플로팅 메뉴로 합쳐 브라우저 우측에 항상 표시되도록 했습니다.

여기서 메뉴 버튼을 누르면...

맵 검색 Modal

요소를 검색할 수 있는 창이 나타납니다. 이 곳에서 검색 결과를 선택하면 canvas에 자동으로 추가됩니다.

 

메이플 포토샵 UI

최종 구현된 UI입니다.

PC와 모바일의 UX 격차

그동안 메이플 사진관은 모바일 이용자를 위해 웹사이트를 반응형으로 개발했지만, 이번 포토샵 기능의 경우 예외적으로 모바일 또는 브라우저의 width가 좁은 환경에서는 부득이하게 서비스를 이용할 수 없도록 했습니다.

이미지 편집이라는 행위가 PC 환경에 최적화 되어있고, 터치 기반 기기에서는 별도의 컨트롤 기능과 UI 구현이 필요하기 때문입니다.

(게다가 모바일에서 얼마나 사용할지 예측도 안되는 상황...)

 

따라서, 유저에게 불편한 경험을 제공하는 것보다는 아예 명확히 제한하는 편이 낫다고 판단했습니다.


개발 - Asset Module

메이플 포토샵은 여러 이미지 에셋을 기반으로 연출합니다. 에셋이 여러 곳에 흝어져있다보니 하나의 Module로 통합하는 과정이 필요했습니다.

 

다음은 에셋 종류별 확보 위치입니다.

  • 맵, 가구 : 게임 클라이언트의 리소스 파일에서 추출
  • 캐릭터 : Nexon Open API에서 조회
  • NPC, 몬스터 : MapleStory IO API에서 조회

 

메이플 사진관 API 구조 (NestJS)

위 사진은 메이플 사진관 API 서버의 Asset Module의 구조입니다. 캐릭터 이미지는 기존 그대로 공식 Nexon API에서 가져오면 되지만 나머지 에셋들은 공식 API에서 지원해주지 않기 때문에 각자 다른 방법을 통해 가져오고 이를 다시 Asset Module로 통합 합니다.

첫 번째, MapleStory IO API에서 가져오기 - NPC, 몬스터

 

Maplestory: IO

The unofficial worldwide Maplestory developer platform, created by maplers, for maplers.

maplestory.io

사실 메이플스토리 내부의 데이터를 얻을 수 있는 비공식 API(https://maplestory.io/)가 존재합니다.

 

공식 API는 플레이어의 데이터를 얻을 수 있는 반면, 비공식 API는 게임 내 정적 데이터를 얻을 수 있고 클라이언트 버전과 리전별로도 조회가 가능하다는 장점이 있습니다.

마이크 (NPC)
주황버섯 (몬스터)

마침 이 곳에서 NPC와 몬스터 데이터를 제공중이고 Swagger로 안내까지 되어있어서 Asset Module로의 통합을 쉽게 진행할 수 있었습니다.

 

export class MapleStoryIO {
  public readonly region: Region;
  public readonly version: number;
  private readonly client: KyInstance;
  private static readonly BASE_URL: string = 'https://maplestory.io';
  private static readonly DEFAULT_TIMEOUT: number = 5000;
  
  public constructor(region: Region, version: number) {
    this.region = region;
    this.version = version;
    this.client = ky.create({
      prefixUrl: MapleStoryIO.BASE_URL,
      timeout: MapleStoryIO.DEFAULT_TIMEOUT,
      retry: 3,
    });
  }
  
  /**
   * Get npc by id.
   * @param id npc id
   */
  public async getNpc(id: number): Promise<Npc> {
    const path = `api/${this.region}/${this.version}/npc/${id}`;
    const response = await this.client.get<Npc>(path, {
      cache: 'no-store',
    });

    return response.json();
  }
  
  ...
}

 

위 코드는 MapleStory IO API에서 NPC 데이터를 호출하는 예시입니다.

 

통합 이후엔 MapleStory IO API 서버가 안정적이지 않은 상황이 발견되어 적절히 대처했습니다.

  • API 처리 속도가 일정하지 않고 매우 느림 : timeout을 5초로 넉넉히 설정 (5초를 넘어가는 경우도 있음...)
  • API 요청 실패가 자주 발생함 : retry 설정 + Asset Controller에 캐싱 적용

두 번째, 게임 클라이언트에서 직접 가져오기 - 맵, 가구

그림자 신전 입구 (맵)
오르카 토끼 인형 쿠션 (가구)

맵과 가구 데이터는 WzCompareR2(위컴알) 프로그램을 사용해 메이플스토리 리소스 파일(.wz)에서 직접 추출하고 S3에 업로드하여 Asset Module에 통합 하였습니다.

 

API를 사용하지 않고 리소스를 직접 추출한 이유는 다음과 같습니다.

  • 가구 : MapleStory IO API에서 제공되지 않음
  • 맵 : 맵 구성 요소가 별도로 조회되어 포토샵의 배경 이미지로 사용하려면 Renderder를 직접 구현해야하는 기술적 문제

아쉽게도 API를 사용하지 않아 모든 맵을 지원할 수는 없게 되었습니다. 수동으로 추출하는 방법을 선택했으니 수천개나 존재하는 맵 데이터를 모두 지원하는 것은 무리이므로 수요가 많을 것으로 예상되는 맵의 이미지를 우선적으로 추출했습니다.

 

구글링하면 나오는 메이플 스샷 명소들

 

다음 기준으로 맵을 선별 했습니다.

  • 스샷 명소 (유저들 사이에서 예로부터 명소라고 불리는 맵)
  • 기간 한정 맵 (이벤트 전용 맵)
  • 패치로 인해 이제는 갈 수 없게 된 맵
  • 게임 내에서 다수의 플레이어가 동시에 들어갈 수 없는 맵
  • 해외 서버에만 존재하는 전용 맵

이 외에도 디자인이 잘 뽑혔거나 특별한 장소라고 느껴지는 맵이 발견될 때마다 선별 대상에 추가하고 있습니다.

 

(맵과 관련된 상세한 내용은 뒷부분에서 이어집니다.)


개발 - 개발 과정에서의 기술적 이슈

레이어별 배경 이미지 크기 불일치 문제

메이플 포토샵에서 배경으로 사용할 맵 이미지를 추출하던 중, 일부 맵에서 이미지가 깨져보이는 문제가 발생했습니다.

 

도원경 : 빛을 되찾은 사계

 

메이플스토리는 횡스크롤 2D 환경에서 입체적인 공간처럼 느껴지도록 화면을 여러 개의 레이어로 분리해 렌더링합니다.

특히, 배경 이미지에는 원근감을 극대화하기 위해 가까운 곳은 빠르게, 먼 곳은 느리게 움직이도록 하는 시차 스크롤링(Parallax scrolling) 기술이 적용되어 있습니다.

 

도원경 : 빛을 되찾은 사계

 

문제는 이 시차 스크롤링이 적용된 맵에서 나타납니다. 시차 스크롤링은 현 시점의 viewport 기준으로만 정상적으로 보이면 되기 때문에, 각 레이어에 들어간 이미지의 크기가 일치하지 않아도 됩니다. 이는 곧, 맵 전체를 한 시야에 하나의 이미지로 보여주려 할 때 문제가 됩니다. 특정 레이어에 이미지가 없는 빈 영역이 그대로 드러나며, 원래 게임에서는 보이지 않던 깨진 배경이 나타나게 되는 것이죠.

 

이 문제에 대해서 어떻게 해결할 수 있을지 여러 방법을 고민 했습니다.

  • Image Inpainting 적용
    • 설명 : 손상되었거나 빈 이미지 영역을 주변 정보로 자연스럽게 채우는 기술입니다. (텍스처 합성 방식, 딥러닝 GAN 방식 등)
    • 장점 : 자동화 가능
    • 단점 : 별도의 구현 필요, 채워야 할 영역이 크거나 복잡한 배경에서 부자연스러움
  • Map Renderer 구현
    • 설명 : 맵의 각 레이어와 요소를 기준 위치에 맞게 배치하고, 시차 스크롤링 로직을 따라가며 전체 맵을 재렌더링하는 방식입니다.
    • 장점 : 맵을 실제 게임과 동일하게 보여줄 수 있음
    • 단점 : 개발 리소스가 너무 큼, 사실상 메이플스토리를 웹으로 다시 만드는 수준
  • 수동 편집
    • 설명 : 문제가 발생한 맵 이미지를 사람이 직접 편집해서 자연스럽게 수정하는 방식입니다.
    • 장점 : 맵 수가 제한적이면 효율이 좋음
    • 단점 : 자동화 불가능

기술적으로 해결하려면 난이도도 높고, 자칫하면 본래 목적을 잃고 주객전도의 상황이 될 수 있다고 판단했습니다. 그래서 가장 단순하지만 확실한 방법인 "수동 편집"을 선택했습니다.

 

세계의 경계 (편집 전)
세계의 경계 (편집 후)

추출한 맵 이미지에서 빈 영역이나 잘린 오브젝트가 발견되면, 해당 부분을 최대한 자연스럽게 보이도록 색을 채워 넣거나, 잘린 오브젝트의 모서리에 맞춰 맵 이미지를 다시 잘라내는 방식으로 수동 보정을 진행했습니다.

Nexon API 호출량 제한 문제

메이플 포토샵에서 길드의 캐릭터 목록을 불러올 때 Nexon API의 초당 처리량 제한을 넘어버리는 문제가 발생했습니다.

 

NEXON API (https://openapi.nexon.com/ko/support/faq/2354215/)

Nexon API는 request를 초당 500번까지 허용(RPS = 500)합니다. 하지만 길드 API는 캐릭터의 이름만 제공하고, 캐릭터 정보는 여러 종류의 API(장비 API, 스탯 API 등)에 분산되어 있기 때문에 원하는 정보를 모두 얻기 위해서는 캐릭터당 여러개의 API 호출이 필요합니다. 게다가 메이플스토리의 길드 인원은 최대 200명이므로 200 X n회의 request가 발생할 수 있습니다. 이는 초당 API 제한을 가볍게 뛰어넘어 호출 제한 오류를 발생시키고 메이플 사진관 서비스의 장애를 초래했습니다.

 

첫 번째 방법으로 지연 평가(Lazy evaluation)과 병렬 처리(concurrent)를 통한 부하 분산으로 짧은 시간에 여러개의 API가 요청되지 않도록 분산시켰습니다.

 

// guild.service.ts
// GuildService.get() 함수의 일부

import {
  concurrent,
  delay,
  filter,
  map,
  pipe,
  take,
  toArray,
  toAsync,
} from '@fxts/core';

// 길드 식별자 조회
const guild = await this.api.getGuild(guildName, worldName);
// 길드 요약 정보 조회
const guildBasic = await this.api.getGuildBasic(guild.oguildId);
// 이용 정지된 캐릭터명 목록
const bannedMembers: string[] = [];
// 길드원 목록
const characters = await pipe(
  toAsync(guildBasic.guildMember),
  map(async (name) => {
    try {
      return await this.characterService.get(name);
    } catch (e) {
      bannedMembers.push(name);
      return null;
    } finally {
      await delay(175);
    }
  }),
  filter(Boolean),
  take(guildBasic.guildMember.length),
  concurrent(7),
  toArray,
);

 

위 코드는 7 캐릭터의 정보를 병렬로 요청하고 175ms 만큼 기다리는 동작을 모든 길드원 정보가 조회될 때 까지 반복하는 코드입니다.

의도적으로 작업간 delay를 주어 짧은 시간 안에 요청이 여러번 발생하는 문제를 완화했습니다.

 

그러나 이 방법은 근본적인 문제 4가지를 해결하지는 못합니다.

  • guildService.get()가 다수의 사용자한테서 동시에 호출되면 여전히 호출 제한 오류가 발생합니다.
  • characterService.get()은 내부적으로 n개의 API를 호출하기 때문에 n이 변경될 경우 delay를 조절해야합니다. (CharacterService의 사양이 GuildService에 영향을 미치므로 Anti-pattern)
  • guildService.get()외에도 여러 곳에서 Nexon API를 사용하므로 여전히 장애 포인트가 존재합니다.
  • 병렬 처리 수량을 지정한다고 해도 길드원 수에서 나눈 횟수만큼 loop를 await 하기 때문에 작업 완료까지 엄청난 시간이 소요됩니다. (길드원 200명 기준 7개씩 처리하면 29회)

이 방법은 임시로 사용하다가 결국 폐기처분 했습니다.

 

두 번째 방법으로 대기열 기반의 처리량 제어기(Rate limiter)와 Proxy를 통해 API 호출 제한 오류가 발생하지 않도록 제어하는 시스템을 만들었습니다.

 

export interface RateLimiter {
  run<T>(task: () => Promise<T>): Promise<T>;
}

/**
 * 일정 주기 마다 작업을 균일하게 실행하는 처리량 제어기 입니다.
 */
export class LeakyBucketRateLimiter implements RateLimiter {

  /**
   * @param interval 작업간 처리 주기 (ms)
   * @param maxQueueSize 작업 대기열 최대 크기
   */
  constructor(interval: number, maxQueueSize: number) {
    ...
  }

  /**
   * 대기열에 작업을 예약 합니다. 먼저 예약된 작업이 모두 완료된 후 실행 됩니다.
   * @param task 실행할 작업
   */
  run<T>(task: () => Promise<T>): Promise<T> {
    ...
  }
}

 

Rate Limiter는 Leaky bucket 알고리즘 기반으로 구현했고, 자세한 코드 내용은 아래 링크에 별도로 작성해두었습니다.

 

[Algorithm] Leaky bucket (처리량 제어기)

Leaky bucket (처리량 제어기)Leaky bucket 알고리즘을 활용한 처리량 제어기를 만들어보자.Leaky bucket 알고리즘이란? Leaky bucket 알고리즘은 네트워크 트래픽 제어 및 처리량 제한(rate limiting)에 주로

blog.spiralmoon.dev

 

Leaky bucket rate limiting

Leaky bucket 알고리즘은 불시에 몰려오는 task를 대기열 queue에 저장하고 고정된 속도로 처리하는 처리량 제어 방식입니다. 이것을 도입해서 RPS를 조절하면 호출 제한 오류를 사전에 방지하고 기존보다 빠르게 요청을 처리할 수 있습니다.

(메이플 사진관은 단일 서버 인스턴스이므로 대기열 queue는 외부 시스템 대신 서버 프로세스에서 직접 관리합니다. 확장 고려 X)

 

const api = new MapleStoryApi(apiKey);
const rateLimiter = new LeakyBucketRateLimiter(2, 10000);

...

const result = await rateLimiter.run(() => api.getCharacterBasic(캐릭터식별자));

 

이렇게 rateLimiter.run()을 통해 처리량 제어가 적용된 task를 실행할 수 있습니다.

 

하지만 또 문제가 남아있습니다. Nexon API를 사용할 때 자동으로 처리량 제어가 적용되어야 합니다. 그러나 제어 대상인 MapleStoryApi 객체는 외부에서 제공되는 라이브러리라서 소스코드를 직접 수정할 수 없습니다.

 

이에 대한 해결책으로 Proxy 패턴을 사용하여 해결했습니다.

 

export const createMapleStoryApi = (
  apiKey: string,
  rateLimiter: RateLimiter,
) => {
  return new Proxy(new MapleStoryApi(apiKey), {
    get(target, prop, receiver) {
      const property = Reflect.get(target, prop, receiver);

      // 모든 MapleStoryApi의 함수 요청에 rateLimiter 적용
      if (typeof property === 'function') {
        return (...args) => {
          return rateLimiter.run(() => property.apply(target, args));
        };
      }

      return property;
    },
  });
};

export const api = createMapleStoryApi(
  process.env.NEXON_OPEN_API_KEY,
  // 2ms마다 작업 처리 (RPS = 500), 대기열 크기 10000
  new LeakyBucketRateLimiter(2, 10000),
),

 

MapleStoryApi의 Proxy 객체를 만들고 MapleStoryApi가 제공하는 모든 api 요청 함수의 동작을 rateLimiter를 통해 실행하도록 wrapping 했습니다.

 

이제 적용 전/후를 비교 해보겠습니다. 비교 조건은 "200 캐릭터, 캐릭터당 3개의 API 호출" 입니다.

첫 번째 방법
두 번째 방법
처리 시간 비교

처리 속도가 16배 빨라졌습니다. 굉장히 만족스러운 결과입니다.

 

이렇게 첫 번째 방법에서 해결하지 못한 문제를 두 번째 방법을 통해 모두 해결하였습니다.


성과 - 넥슨 프렌즈 개발자 승인

그동안 만들어온 메이플 사진관의 기능들, 그리고 지난 번 디자인 개선으로 메이플 사진관은 제법 서비스다운 웹사이트의 형태를 갖추게 되었습니다.

 

Nexon Open API 프렌즈 개발자 전용 메뉴

그리고 2025년 4월 14일, 메이플 사진관의 완성도와 퀄리티를 인정받아 넥슨 API 프렌즈 개발자로 승인되었습니다.

프렌즈 개발자는 넥슨 API를 이용해 일정 수준 이상의 서비스를 제작한 일부 개발자에게만 주어지는 자격으로, 전용 API와 별도의 문의 채널이 제공됩니다.

 

이를 통해, 앞으로 서비스를 한 단계 더 끌어올릴 수 있게 되었습니다.


누군가에게는 추억을 되돌아보는 경험이 되었기를

메이플 포토샵을 개발중이던 2024년 겨울, 우연히 게임 커뮤니티에서 어느 길드마스터분의 사연을 접하게 되었습니다.

 

 

길드사진을 찍으며..

안녕하세요 크로아 길드마스터 추연v이에요.1년 6개월동안 별 문제없이 지낼 수 있게 도와준 길드원들에게 고마워요.정확하게 7년전에 처음 서린비 길드를 만들었어요. 길드를 만들게 된 계기는

www.inven.co.kr

 

지난 달 갑작스럽게 먼 여행을 떠난 故○○○○님. 길드사진에 담지 못한건 한이에요. ■■■ 길드에서 수로안치면 강퇴당하는데 로그인까지 안하네요. 메이플에서 여행하지, 너무 멀리 여행갔어요.

 

당시 마침 메이플 포토샵의 기능 프로토타입이 막 완성된 시기었기에, 그분의 길드 사진에 고인이 된 캐릭터를 조심스럽게 담아넣어 전달드렸습니다.

 

누군가에게는 사소해 보일지 모르는 한 장의 이미지가,

어떤 이에게는 정리되지 않은 마음을 다독일 수 있는 작은 위로가 되었기를 바랐습니다.

 

 

하루 뒤, 고인과 함께 길드에서 같이 메이플을 즐기던 실제 친구분으로부터 따뜻한 답장이 도착했습니다.

 

단 한 장의 이미지였지만, 그 안에는 잊지 못할 추억과 누군가를 향한 마음이 담겨 있었습니다.

이때 깨달았습니다. 메이플 포토샵은 단순한 이미지 편집 도구를 넘어, 누군가의 기억을 이어주는 역할을 할 수 있는 무언가라는 것을.

 

제가 만들고 싶었던 건 기능이 아니라, 기억을 담을 수 있는 그릇이었는지도 모릅니다.

앞으로도 이 작은 이 서비스가, 누군가에겐 한 시절의 기억을 꺼내볼 수 있는 사진관이 되길 바랍니다.

 

읽어주셔서 감사합니다.

 

7년차 서버 개발자 SpiralMoon

반응형

댓글