티스토리 뷰

8월 중순부터 지금까지 약 2달간 계속 만들고 있는 프로젝트가 있다. 바로 롤보챗(롤 보이스 채팅)이다. 

롤보챗은 롤에서 만난 팀원들과 음성채팅을 할 수 있는 데스크탑 애플리케이션 서비스다. 이미 롤에도 인게임 보이스가 있지만, 사전에 같이 게임을 돌린 팀원들만 연결이 된다. 그래서 나와 프론트를 하는 친구 둘이서 사전에 같이 돌리지 않아도 자동으로 보이스 채팅을 이용할 수 있는 서비스를 만들어보면 좋겠다! 해서 시작하게 되었다. 

 

핵심로직을 간단하게 말하자면, 매칭이 잡히고 챔피언 선택창으로 넘어가는 순간 롤보챗 앱을 이용하고 있는 팀원들끼리 자동으로 보이스방이 연결된다. 기본적으로 최근에 했던 게임 전적을 볼 수 있고, 최근 전적의 승률도 볼 수 있다. 또한 챔피언을 선택할 때마다 해당 챔피언의 평균 KDA, 피해량, CS, 플레이 횟수를 볼 수 있다. 

 

 

이게 핵심로직 끝인데, 여기서 재밌는 기능을 추가하기로 했다. 바로 챔피언 선택 후 게임로딩창으로 넘어가면 전체보이스방이 연결돼서 팀원과 적팀이 서로 음성채팅을 할 수 있게 하는 것이다. 전체보이스방은 미니언이 나오는 시간 (1분 5초)에 자동으로 끊기고, 다시 팀원보이스를 할 수 있다. 

 

보이스 기능 말고도 전체채팅, 소환사 정보 표시, 최근 함께한 소환사, 단축키, 기타 설정 등 다양한 기능이 있다. 자세한 설명은 Github에서 확인이 가능하다. 그럼 2달동안 롤보챗 서비스를 구현하면서 어려웠던 점, 즐거웠던 점, 성능개선한 점 등을 이야기하려고 한다.

 

구현 가능성

일단 이 서비스가 구현이 가능한지부터 알아볼 필요가 있었다. 처음 해보는 것들이 너무 많았기 때문이다.

 

  • JavaScript & Nodejs
  • 보이스 연결
  • 롤 API
  • 소켓
  • Electron

1년동안 SpringBoot와 JPA 공부만 했었던 SpringBoot원툴이었지만, 서비스 자체가 기존에 했었던 웹서비스 기반과 완전히 다르기 때문에 SpringBoot로 구현하면 무리가 있다고 판단했다. 그래서 한번도 사용해보지 않았던 Nodejs를 사용했다. 자바스크립트도 문법만 약간 아는 정도였는데, 문법부터 공부할 시간이 부족해서 바로 롤보챗을 만들면서 모르겠거나 헷갈리는 부분은 검색해가면서 개발했다. 

 

가장 어려울 것 같던 기능은 보이스 연결과 롤 API였다. 라이엇에서 제공하는 API가 있는지 한번 검색해봤는데, 있긴 있었다. 하지만 이 API로 우리 서비스를 구현하기에는 불가능 했다. 왜냐하면 실시간으로 롤 클라이언트의 정보를 얻어야 하기 때문에, 단순한 정보 조회용 API로는 원하는 데이터를 얻지 못할 뿐더러, 실시간을 대체하려고 폴링(Polling) 기법을 사용하려고 하면 API가 초당 보낼 수 있는 횟수가 제한돼있기 때문이다. 따라서 실시간으로 데이터를 얻는 방법을 찾아야했다. 

 

일단 찾는게 불가능하진 않을거라고 생각했다. OPGG 데스크탑 앱이나 블리츠 앱은 롤을 키자마자 내 정보가 뜨기 때문이다. 그래서 폭풍검색을 하는 도중 LCU API(League Client Update API)라는 API를 찾았다. LCU API는 롤 클라이언트와 상호작용하기 위한 API다. 어떻게 실시간으로 데이터를 얻는지에 대한 방법은 따로 포스팅을 했다.

 

아무튼 롤 API는 가능하다는 것을 알았고, 이제 보이스 연결을 어떻게 하는지 감을 잡은 후에 본격적으로 구현에 들어갔다. 보이스 연결은 WebRTC와 소켓을 사용한다는데 암만 글을 읽어봐도 직접 작동되는 코드를 보는게 더 좋다고 생각해서 간단하게 웹사이트에서 1대1로 보이스 연결이 되는 코드를 긁어와 실행해봤다. 생각보다 잘 작동되서 신기해하며 친구와 같이 코드를 분석했다. 소켓이 대부분인 코드여서 소켓과 보이스 연결 동작 원리를 같이 공부했다. 

 

이 서비스를 데스크탑 앱으로 개발하는 이유가 있다. 처음에는 웹서비스로 생각하고 있었다. 왜냐하면 웹사이트는 링크로 공유할 수 있기 때문에, 우리 서비스를 모르거나 이용하지 않는 사람들한테 쉽게 알릴 수 있다. 또한 로그인 없이 서비스를 이용할 수 있도록 할 계획이었기에 링크를 누르기만 하면 돼서 쉽게 접근할 수 있고 로그인이 없어 빠르게 우리 서비스를 이용가능하게 하는것이 시너지가 높을거라고 생각했다. 하지만 웹사이트를 배포하고나서 롤 API 감지가 안되길래 찾아봤더니 알고보니까 LCU API 웹소켓을 연결하기 위해선 롤 앱에서 port, password 등 정보를 얻어야 하는데, 웹사이트에서는 사용자 컴퓨터 내부에 접근할 수 없었다. 

 

결국 OPGG와 같이 데스크탑 앱으로밖에 할 수 없다는 것을 알고 어떻게 앱을 만들어야 할지 찾아봤다. 찾아보니 Electron이라고 있는데, js와 css, html로 데스크탑 앱을 만들 수 있게 하는 라이브러리가 있었다. Electron으로 만든 유명한 앱을 봤는데 갑자기 Electron에 대한 신뢰가 올라갔다 ㅋㅋ

디스코드, 피그마, 노션, 슬랙, Postman, vscode, 트위치 등등..

 

이렇게 처음에는 웹사이트로 기획했다가 데스크탑 앱으로 변경하고, 처음해보는 기술들이 대부분이어서 막막한 것도 없지 않았지만 이때만큼 개발하는 것이 가슴이 두근거리고 즐거웠던 일은 없었던 것 같다. 맨날 CRUD만 반복하다가 내가 좋아하는 게임에 아이디어를 얻어서 관련된 서비스를 개발하고, 친구집에서 밤을 새면서 둘이서 "이거 구현 가능하냐?ㅋㅋㅋ", "몰라 찾아보면 되겠지ㅋㅋ" 하면서 열심히 구현 가능성을 알아보는게 재밌었다. 이제 본격적으로 구현 시작!

 

롤 데이터 얻기

lcu api를 사용하면서 느낀 불편한점은 라이엇에서 공식문서나 지원을 전혀 하지 않는다는 점이다. 그래서 외국의 능력좋은 사람들이 API의 엔드포인트들을 다 찾아서 정리해놓은 문서를 보고 엔드포인트를 사용해야 한다. 하지만 정리된 문서에도 이 엔드포인트는 뭔지, 응답된 데이터들의 값이 뭐를 의미하는지에 대해 설명조차 없어 하나하나 확인해가면서 어떤 url인지, 응답값이 뭘 뜻하는지 하나하나 확인해야 했다.

 

이게 하나의 엔드포인트의 설명이다.

 

실제로 느꼈던 불편한 경험으론, 로그인 없이 이용할 수 있게 만드려고 하기 때문에 사용자가 롤 앱을 켜서 들어가면 자동으로 사용자의 정보를 가져와야 했다. 처음에는 /lol-summoner/v1/current-summoner를 요청해봤는데, 이름이나 프로필등은 잘 오는데 응답에 티어가 포함되지 않았다. 그래서 티어는 다른 엔드포인트를 사용해야 하나? 싶어서 찾아봤는데, /lol-ranked/v1/current-ranked-stats 엔드포인트가 있었다. 그래서 아 같이 사용하면 되겠다 싶어서 2개의 엔드포인트를 사용해서 사용자의 정보를 가져왔다. 

 

그런데 나중에 여러 엔드포인트 응답을 받아오는데 /lol-chat/v1/me 엔드포인트에서 기본적인 사용자 정보(프로필, 이름 등)과 티어도 함께 반환하는 것을 알았다. 엔드포인트 설명에서는 아래와 같이 되어있어서, 티어가 반환되는지 몰랐다. 

실제로 응답해보면 lol 객체 안에서 티어가 반환된다.

 

이렇게 반환되는 타입의 자세한 설명이 없어 불편했다. 하지만 LCU API가 반환값이나 엔드포인트 자체가 매번 바뀐다고 한다. (롤도 계속 업데이트나 수정을 해야하기 때문) 그럴때마다 문서를 수정하기에는 귀찮을 것 같긴 하다. 

 

롤 데이터 관리하기

API로 가져온 데이터를 추가로 가공하는 일이 많은데, 가져온 JSON객체가 클래스의 인스턴스가 아닌 리터럴 객체였다. SpringBoot에서는 외부 API를 호출할 때 자동으로 직렬화가 되서 클래스 인스턴스로 받기 때문에 불편함을 느끼지 못했다. 그래서 처음에는 데이터를 받고, 비즈니스로직을 함수로 따로 빼서 만들었었다.

 

예를 들어서 소환사 데이터를 가져온다고 했을 때, 아래처럼 응답이 된다고 가정해보자.

{
  gameName: "";
  gameTag: "";
  icon: "";
  id: "";
  lol: {
      rankedLeagueDivision: "";
      rankedLeagueTier: "";
  };
  name: "";
  pid: "";
  puuid: "";
  summonerId: "";
}

 

일단 그대로 저장한다.

interface SummonerData {
  gameName: string;
  gameTag: string;
  icon: string;
  id: string;
  lol: {
    rankedLeagueDivision: string;
    rankedLeagueTier: string;
  };
  name: string;
  pid: string;
  puuid: string;
  summonerId: number;
}

const summoner: SummonerData = //api 호출

 

그리고 만약 티어를 알고 싶다면 가져온 데이터를 파라미터로 넘겨서 함수로 처리했다. 

function getTier(summoner: SummonerData) {
    const { rankedLeagueDivision, rankedLeagueTier } = summoner;
    
    if (!rankedLeagueDivision && !rankedLeagueTier) {
      return 'Unrank';
    }

    const displayTier: string = rankedLeagueTier[0];
    switch (rankedLeagueDivision) {
      case 'I':
        return displayTier + 1;
      case 'II':
        return displayTier + 2;
      case 'III':
        return displayTier + 3;
      case 'IV':
        return displayTier + 4;
      case 'V':
        return displayTier + 5;
      default:
        return displayTier;
    }
  }

 

이렇게 구현하니까 상태와 행위가 따로 논다는 말이 무슨말인지 알게되었다. 데이터를 리터럴 객체가 아닌 클래스 인스턴스로 변환시킨다면 비즈니스로직 또한 클래스 내부에 있기 때문에 응집력이 좋은 코드가 된다. 

 

리터럴 객체를 클래스 인스턴스로 쉽게 직렬화 시키는 class-transformer 라이브러리를 사용해서 리팩토링을 해보자. 

import { plainToInstance } from 'class-transformer';

interface LeagueRanked {
  rankedLeagueDivision: string;
  rankedLeagueTier: string;
}

export class Summoner {
  gameName: string;
  gameTag: string;
  icon: string;
  id: string;
  lol: LeagueRanked;
  name: string;
  pid: string;
  puuid: string;
  summonerId: number;

  public static fetch() {
     const summonerData = //api호출
     const summoner: Summoner = plainToInstance(Summoner, summonerData);
  }

  public getTier() {
    const { rankedLeagueDivision, rankedLeagueTier } = this.lol;

    if (!rankedLeagueDivision && !rankedLeagueTier) {
      return 'Unrank';
    }

    const displayTier: string = rankedLeagueTier[0];
    switch (rankedLeagueDivision) {
      case 'I':
        return displayTier + 1;
      case 'II':
        return displayTier + 2;
      case 'III':
        return displayTier + 3;
      case 'IV':
        return displayTier + 4;
      case 'V':
        return displayTier + 5;
      default:
        return displayTier;
    }
  }
}

 

이렇게 유지보수가 좋은 코드로 리팩토링되었다. 

 

Electron에서 데이터 전달

Electron은 main process와 renderer process가 있는데 백엔드, 프론트라고 봐도 무방하다. 이제 얻은 롤 데이터를 renderer processs한테 넘겨야 하는데, 넘길때는 다음과 같이 넘긴다. 

window.webContents.send('channel', 'data');

 

뭔가 socket.io를 사용해서 데이터를 주고받을 때와 비슷하지 않은가? 만약 renderer쪽에서 데이터를 전달하고 main이 응답하고 싶다면 

아래와 같이 다양한 방법이 있다. 

//ipcMain.on 방법
ipcMain.on('channel', (event, payload) =>  {
    event.reply('channel', 'message');
})

//ipcMain.handle 방법
ipcMain.handle('channel', (event, payload) => {
    return 'message';
})

 

소켓

소켓은 그렇게 어렵지 않았다. 다만 고민했던게 있다면 프로젝트 패키지 구조였다. SpringBoot로 구현했을 때는 패키지 구조가 딱 정해져있어서 선택해서 똑같이 적용하기만 하면 된다. 하지만 Nodejs를 처음해봤기 때문에 어떻게 구조를 가져가야할지 몰랐다. 더군다나 대부분이 소켓로직이라서 비슷한 예제를 찾는것도 쉽지 않았다. 일단 Github에 있는 여러가지 프로젝트를 탐방하고 공통적인 부분을 적용했다. 

 

 

velopert님의 velog구조를 많이 참고했다. socket.js에서 전체적인 소켓연결 로직들을 모아놓고 관리한다. 서비스 특성상 소켓의 namespace가 많은데, 변경사항이 생길 때 한 파일에서만 수정하면 되서 편리했다.

import { Server } from 'socket.io';
import {
  teamVoiceChatConnection,
  teamVoiceChatManagerConnection,
  leagueVoiceChatConnection,
  leagueVoiceChatManagerConnection,
} from './voice/index.js';
import generalChatConnection from './chat/generalChatConnection.js';
import manageConnection from './manage/manageConnection.js';

const socketCors = {
  cors: {
    origin: '*',
    methods: ['GET', 'POST'],
    credentials: true,
  },
};

export default (server) => {
  const io = new Server(server, socketCors);

  onVoiceConnections(io);
  onChatConnections(io);
  onManageConnection(io);
};

function onVoiceConnections(io) {
  const teamVoiceChatIo = io.of('/team-voice-chat');
  const teamVoiceChatManagerIo = io.of('/team-voice-chat/manage');
  const leagueVoiceChatIo = io.of('/league-voice-chat');
  const leagueVoiceChatManagerIo = io.of('/league-voice-chat/manage');

  teamVoiceChatIo.on('connection', (socket) => {
    teamVoiceChatConnection(teamVoiceChatIo, socket);
  });

  teamVoiceChatManagerIo.on('connection', (socket) => {
    teamVoiceChatManagerConnection(teamVoiceChatManagerIo, socket);
  });

  leagueVoiceChatIo.on('connection', (socket) => {
    leagueVoiceChatConnection(leagueVoiceChatIo, socket);
  });

  leagueVoiceChatManagerIo.on('connection', (socket) => {
    leagueVoiceChatManagerConnection(leagueVoiceChatManagerIo, socket);
  });
}

function onChatConnections(io) {
  const generalChatIo = io.of('/general-chat');
  generalChatIo.on('connection', (socket) => {
    generalChatConnection(generalChatIo, socket);
  });
}

function onManageConnection(io) {
  const manageIo = io.of('/manage');
  manageIo.on('connection', manageConnection);
}

 

보이스 연결

게임매칭이 되고 나서 챔피언선택창으로 넘어갈 때 어떻게 팀원들을 보이스방에 연결시켜야하는지 생각했다. 소환사데이터에 summonerId가 있어서 매칭된 5명의 summonerId를 이어붙여서 방 key로 사용했다. 이렇게 하면 중간에 우리 앱을 껐다 키거나, 게임이 매칭된 후 앱을 켜도 보이스 연결이 잘되었다. 

 

실시간 보이스를 구현하기 위해선 기본적으로 WebRTC를 사용한다. (Electron앱 화면도 브라우저기 때문이다.)

처음에는 가장 구현하기 쉬운 P2P 방식으로 구현해었다. 하지만 2명이상일 때는 렉이 심해져서 알아보니 1대1에 특화된 방식이라고 한다.  우리서비스는 최대 10명이 동시에 연결해도 부담되지 않아야 했다. 찾다가 SFU 방식이 서버와 클라이언트에 부담을 골고루 퍼지고 고도화된 방식이라고 한다. 즉 N:N 소통방식에 가장 적합한 WebRTC 아키텍쳐였다.

 

여러 WebRTC 아키텍쳐 비교

 

그래서 SFU방식으로 변경하면서 성능을 개선시켰다. 자세한 이야기는 이 포스팅을 보면 된다!

 

모듈은 mediasoup를 사용했는데, 아주아주 row해서 좋았다. 보이스 기능은 계속해서 최적화를 해야하기 때문에, row한걸 고르는게 좋다. 하지만 mediasoup를 사용하면서 WebRTC를 사용하면 쉽게 해결할 문제가 mediasoup때문에 까다로운일도 있었다고 한다. 예를 들어서 입력 볼륨값과 출력 볼륨값을 바꾸는 기능을 구현할 때, mediasoup 때문에 막히는 일이 있었다고 한다. (이건 클라이언트쪽이라 자세한 내용은 생략하겠다.)

 

보이스 연결을 구현하면서 아주 심각한 문제가 있었는데, 연결은 잘 되지만 롤 인게임 소리나 유튜브를 키고 있으면 해당사람의 스피커를 타고 들린다는 것이다. WebRTC 자체에서는 이 문제를 해결할 방법이 없었기에 누군가 만들어놓은 것을 사용해야 한다. 그래서 찾던 도중 krisp를 발견했는데 discord도 사용하고 있을 만큼 잡음 제거가 확실하게 되는 ai모델이었다.

 

discord 음성 및 비디오 설정화면

 

하지만 krisp sdk를 기업들한테만 돈을 받고 제공해주기 때문에 사용하지 못했다.

 

마무리

이렇게 롤보챗 v1.0이 완성이 되었다. 아직 앱 자동업데이트, 자잘한 버그를 고쳐야 하긴 한다.

v2.0에서는 더 다양한 기능을 추가하고, 웬만한 오류는 나지 않도록 할 것이다.

 

힘들었던 일도 많았지만, 계속 회원가입 로그인, CRUD 등 같은 기능만 반복해서 개발하다가 롤보챗을 개발하니까 개발이 2배는 재밌어졌다. (내가 좋아하는 게임과 관련이 있어서 그런걸지도)

 

원래는 기술 위주로 적으려고 하다가 다시 회고 느낌으로 바꾸려고 하다가 기술 위주도 아니고 회고 느낌도 아닌 어중간한 포스팅이 되어버려서 아쉽다. 나중에 정식으로 출시되거나 새로운 기능이 나올때마다 블로그를 적을 예정이다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday