이 글은 v3에서 v4로 넘어갈 때의 개발기입니다. v4에서 구현된 무중단 배포와 같은 새 기능에 대한 설명을 진행합니다.


이전 버전의 문제점

사실 제가 Fine (v3)을 설계하면서 사실 정말로 많은 문제점이 있었습니다.

  • 보기에 매우 안 좋은 코드 구조 (가독성의 고갈)
  • 과도하게 수많은 모듈로 쪼깨진 앱
  • 올바른 동작을 보장할 수 없는 파서 모듈
  • 변수나 기능 이름에 대한 특이한 이름

일단 보기 좋지 않은 코드 구조는 사실 v3의 이전 버전부터 이어져 왔던 문제점 중 하나였습니다. if 문을 줄이려 일부로 Array나 Object를 사용해 우회했었죠. 하지만 이러한 결과로 매우 복잡한 코드가 되어버렸습니다.

https://github.com/serium-departments/Serium/blob/v3/handles/message.js#L29

보시다시피 모듈로 너무나도 많이 쪼깨져 있던 덕분에 멀리서 보면 간단할지 몰라도 가까이서 직접 짤 때는 너무나도 복잡해졌습니다. 또한 이러한 최신 문법의 배제와 Promise 기반의 코드는 수많은 중괄호를 만들어냈습니다. 그래서 첫 번째로 v4에서는 이러한 모듈화를 줄이고 if 문을 적당히 잘 보이도록 배치하자는 것을 목표로 했습니다.

https://github.com/serium-departments/Serium/blob/v3/structures/MessageParser.js

두 번째 문제점도 매우 심각한 애플리케이션의 코어 파서인 메세지 파싱 모듈에서 드러났습니다. 메세지 파싱에서 접두사 등을 구분해내는 작업은 보통 처음 작성하고 완전히 버그가 발견되지 않았다면 변경할 필요가 거의 없습니다. 하지만 사용자가 직접 접두사를 변경할 수 있게 되면서 한 가지 문제가 생겼습니다. (위 사진은 업데이트가 적용된 v3 사진입니다)

먼저 접두사가 기호로 되어 있을 때와 문자열로 나뉘게 되었을 때는 기존 파서 상으로는 다른 규칙이 적용되었어야 했습니다.

  • !ping
  • [string] ping

보시다시피 문자열이 들어갔을 때는 공백이 포함되어야 자연스럽게 보입니다. 그러나 Discord는 웹 앱이며 명령어 파라미터 끝에 공백이 포함될 경우 지워지며 위와 같은 상황을 절대 연출할 수가 없었습니다. 그래서 v4에서는 이를 해결하기 위해 새로운 메세지 파서를 작성하기로 했습니다.

세 번째는 매우 뻔한 이유에서 입니다. 제가 그동안 Prompt라던지 Command라는 흔히 편한 인식이 가능한 변수 이름을 배제하고 취향(?)에 맞는 특이한 이름들을 사용한 점이 있습니다. 이 또한 고칠 예정입니다. 사실 이 애플리케이션의 문제점이란 정말 셀 수도 없이 많습니다. 그리고 해결할 수 없는 오류 또한 등장한 적이 있습니다. 정확히는 해결하려면 전체적인 구조에 수정이 필요했습니다. 그리고 결과적으로 v4를 릴리즈하기로 합니다.

*라이선스 이야기는 건너뛰도록 하겠습니다.

전체적인 애플리케이션 재설계와 새로운 기능 그리고 확장성

이번 애플리케이션은 정확히 웹 앱까지 통합을 하기 위해서 약간은 다른 구조를 가져보기로 했습니다.

스케일링과 무중단 배포

먼저 중점적으로 애플리케이션 스케일링을 위해 Sharding(Discord 애플리케이션에서의 멀리 스레딩, PM2에서의 클러스터 모드와 같음) 기능을 손봐야 했습니다. 기본적으로 Discord에서 Sharding은 클러스터링을 뜻하며 처리하는 Discord 서버를 나누는 것으로 이루어집니다. 또한 각 하나의 클러스터는 Shard라고 부릅니다. 이 때 Master 프로세스는 ShardingManager 객체를 기준으로 합니다.

Discord 플랫폼의 Sharding의 구조는 매우 복잡하며 보통의 상황에서는 권장되지도 않는 경우입니다. 그만큼 비효율적이죠. 또한 PM2의 클러스터 기능을 통해 따로 처리할 수 있는 경우도 아닙니다. 기본적으로 웹소켓을 기반으로한 채팅 애플리케이션이기 때문입니다. 물론 라이브러리 자체적으로도 문서화도 제대로 되어 있지 않고 예제도 거의 존재하지 않는 부분입니다.

그래도 스케일링은 오픈소스 애플리케이션으로서는 구현을 하고 넘어가야 나중에도 편할 것이라고 생각했습니다. 또한 무중단 배포를 위해 이 기능은 필요했습니다. 아래는 무중단 배포에 관한 진행도입니다.

https://github.com/serium-departments/Serium/blob/nightly/src/index.js

지금은 약간 변경된 구조로 적용되었습니다. 그래도 Discord.JS 클라이언트가 동작하기 시작하는 Client의 ready 이벤트(로그인 이후 준비가 된 시점)를 기준으로 무중단 배포를 구현할 수 있었다고 생각합니다. 실제로 이에 대한 정확한 스펙이 없어 100%라고 보장은 못합니다. 2개의 앱이 동시에 작동할 경우 명령어를 입력했을 때 2개의 메세지(결과)가 도착하기 때문이죠. (물론 JavaScript 자체의 Timer는 특정 시간에 실행을 보장하는 것이 아닌 특정 시간 이후에 실행을 보장하는 것을 특징으로 하는 문제도 있습니다.)

정리하면 아래와 같습니다.

  1. 먼저 ShardingManager가 생성되고 필요한 Shard 프로세스를 생성하고 이벤트 리스너를 설정합니다.
  2. 업데이트 시점에서 Update 명령을 받은 Shard가 전체 Shard, Manager(master)로 update 시그널을 브로드캐스팅합니다.
  3. Manager가 대체 Shard 프로세스(A)를 준비하고 새 프로세스(A)가 준비(ready 이벤트)되면 기존 프로세스를 kill합니다.
  4. 마지막으로 새 프로세스(A)에 이벤트 리스너를 설정합니다.

하지만 여기에서도 하나의 문제점이 있었습니다. 기존에는 Master 프로세스가 SIGINT(Ctrl + C) 신호를 받고 사라지면 당연스럽게도 하위 프로세스에 해당하는 Shard 프로세스들 또한 사라진다고 생각하였습니다. 절대로 아니었죠. 그래서 SIGINT 신호에 이벤트 리스너 또한 설정하였습니다. 한 가지 더 주의하자면 각 Shard는 다른 프로세스에 존재하기 때문에 콘솔에 메세지를 남기고 싶으시면 Master로 메세지를 전달하셔야 합니다.

이렇게 우여곡절 끝에 무중단 배포를 위한 ShardingMaster 코드가 짜여졌습니다.

다국어 지원과 문자열에 대한 변수 바인딩

다국어 지원은 사실 v3 이전에도 구현이 되었었던 기능입니다. 하지만 v3에서는 포함되지 않고 한국어 전용으로만 개발되었었습니다. 그리고 피드백에서 해외에서 번역 기능이 필요하다는 말이 나와서 다시 추가하게 되었습니다. 이 또한 기존 구조에서 쉽고 빠르게 추가할 수 있는 기능이 아니었기에 v4로 자연스럽게 미루어졌습니다.

다국어 지원을 고려할 때는 애플리케이션 설계 초기에 구조를 잡아주지 않으면 코드가 쉽게 혼란스러워질 수 있습니다. 예를 들어서 이전에는 제가 메세지가 올 때마다 Google Translate에 쿼리를 통해 언어를 알아냈던 적도 있습니다. 슬프지만 실제 상황을 체험했었고 그 코드를 작성한 본인으로서 꼭 설계 단계에서 고려하시길 바랍니다.

먼저 생각해볼까요? 어떤 형식이 번역에 적당할지 말이죠. 아래는 생각해본 간단한 ping 명령어에 대한 다국어 지원 시에 변수 바인딩 형식들입니다.

  • {variable}ms가 소요되었습니다.
  • %variable%ms가 소요되었습니다.
  • {#variable}ms가 소요되었습니다.
  • {{variable}}ms가 소요되었습니다.

일단 Discord라는 플랫폼에서 사용해야 하기 때문에 대괄호는 혹시 모르는 상황에 따라 피해주는 편이 좋다고 생각했습니다. 메세지가 제대로 표시되지 않으면 그것대로 많이 귀찮아지기 때문이죠.

실제로는 첫 번째 포맷이 선택되었는데 여기에서 한 가지 더 걱정해야 할 부분이 나옵니다. 이러한 다국어 지원이 얼마나 코드를 추가로 더럽게 만들 것인가에 대해서 걱정해야 합니다. 기존처럼 문자열로 바로 값을 넣는 것만큼이나 가독성이 좋으면 그것대로 좋습니다. 먼저 몇 가지 선택지가 있습니다. 아래에서 후자는 비교적 늦게 떠오르기는 했습니다만…

  • 함수 안에 담아 전달한다.
  • 프로토타입에 정의한다.

저와 같은 경우는 프로토타입에 bind라는 함수로 정의했습니다. 그런데 아직 프로토타입 함수 정의와 같은 부분은 어디에 두어야 할지 몰라서 src/index.js의 코드 가장 아랫부분에 남겨두었습니다. JavaScript Tick을 고려하더라도 명령어 처리가 그렇게 빨리 이루어지지는 않을 것이니 괜찮습니다. 또한 세션 로그인에 걸리는 시간도 고려한다면 충분히 가능한 일입니다.

https://github.com/serium-departments/Serium/blob/nightly/src/client.js#L24

나머지는 간단히 translations 폴더를 만들고 안에 언어별로 하나의 오브젝트를 할당했습니다. 여기에서 왜 JSON을 쓰지 않았냐고 물어보시는 분이 있는데 이 또한 간단합니다. JSON 대신 JS 모듈을 활용한 이유는 편리하기 때문입니다. Booblean 등의 형식도 집어넣을 수 있어 일반적인 설정 파일을 작성할 때도 편리합니다.

데이터베이스 지원과 최신 문법 사용

약간은 쓸 때 없이도 고민한 적이 많습니다. 사실은 그 때는 익숙하지 않고 사용이 어려웠기 때문임의 변명이었습니다. 저는 ES7 문법을 쓰더라도 require문 등등 새로 지원하는 기능에서만 한해서 그것도 극히 한해서 사용했었습니다. 실제로는 몰랐던 샘이죠. 그리고 async/await과 같은 구조도 별로 좋아하지 않았습니다만 지금은 적당히 활용하고 있습니다.

https://github.com/serium-departments/Serium/blob/nightly/src/structures/preferences.js#L43

문법적인 요소와 관련해서는 많이 할 말은 없는 것 같네요.

앞으로의 챗봇 설계와 애플리케이션 구조

이 부분에서는 앞으로도 어떻게 챗봇을 설계해나갈지 그리고 현재 대부분의 Discord 챗봇에 대해 다룰려고 합니다. 먼저 챗봇이 생긴 것은 거의 다 비슷비슷하게 생겼습니다. 사실 다 commands, structures 폴더에 모듈화를 시킵니다. 그런데 여기에서 코드가 단순해보이는 2가지 지점이 있습니다.

  • 이벤트 핸들러
  • 커맨드(명령어) 핸들러

이 부분에서 얼마나 명확하게 작성하느냐가 대부분의 코드 구조가 얼마나 명확하게 보일지를 결정한다고 봅니다. 특히 이벤트보다는 더 복잡한 커맨드 핸들링에서 많이 갈립니다. 커맨드 모듈은 크게 2가지 갈래로 갈립니다. 첫 번째는 class 생성자이고 두 번째는 함수 모듈입니다.

Class 생성자 기반 확장형 구조

현재 대부분의 챗봇 애플리케이션이 채택하고 있는 구조이기도 합니다. 수많은 봇이 이 구조를 채택합니다. 방법론 자체의 이해는 함수로 이루어진 명령어 모듈보다는 쉽습니다. 먼저 Commands와 같은 Class 생성자가 있다면 나머지 명령어는 이 생성자를 확장하는 방식으로 계속 명령어를 확장해나가는 방식입니다.

대표적으로 아래와 같은 봇들이 오픈소스 상에 있습니다.

함수형 모듈 집합 구조

이 구조는 사실 처음 쉽게 봇을 만들기 위해 튜토리얼 등에 자주 쓰인 구조로 기억합니다. 실제로 나중에가면 이것을 어떻게 활용할 것이며, Circular dependency와 같은 문제를 어떻게 수정하냐 등등의 문제와 마주칠 가능성이 높습니다. 그러나 전자의 구조에 비해서 확실히 단순합니다.

대표적으로는 아래와 같은 봇들이 오픈소스 상에 있습니다.


긴 글이었는데 끝까지 읽어주셔서 감사합니다. 앞으로 챗봇 설계에 꼭 도움이 되셨길 바랍니다!