디스코드 메시지 100만개 순차 탐색

을 위한 빠른 솔루션 같은 건 없고..

배경

Discord는 채널당 고정 메시지를 최대 50개로 제한한다.

고정 메시지를 그저 간혹 올라오는 공지사항 정도로만 쓰면 모르겠으나, 매일 올라오는 주옥같은 유-머들을 보관하기에는 턱없이 부족하다.

그렇다면 고정 메시지 아카이브를 만들면 좋지 않을까? 하는 생각이 들었다. 일반 텍스트 채널의 메시지 제한은 없다. 따라서 고정된 메시지를 다른 채널에 옮기는 것은 가장 가깝고도 납득할만한 선택이다.

이 작업을 대신 해주는 봇 pinee를 만들기로 했다.

origin-of-name-of-pinee.png

pinee가 하는 일

가장 기본적으로 메시지의 고정 이벤트를 감지해서 아카이브 채널에 복사해야 한다.

그리고 pinee 도입 전 쌓여있던 수많은 고정 메시지를 한번에 모두 복사하기 위한 기능도 있어야 한다.

막 고정된 메시지 아카이브

메시지의 고정을 감지해서 아카이브를 생성하는 것은 두 작업으로 나눌 수 있다.

  • 메시지가 업데이트되었을 때, 이것이 사용자가 메시지를 고정한 것인지 판단하기.
  • 적절한 채널을 찾아서 아카이브를 만들어 포스트하기.

다행히 둘 다 어렵지 않았다. 메시지 업데이트 이벤트가 감지되었을 때, 아래 조건을 만족하면 그것은 메시지 고정 이벤트로 간주된다:

  • (메시지가) pinee가 작성한 것이 아님.
  • (메시지가) DM 채널에서 업데이트된 것이 아님.
  • 메시지의 내용이 변경되지 않음.
  • 메시지가 이제 막 고정됨.

고정된 메시지를 확보했을 때, 이를 아카이브하기 위해서는 적절한 채널이 있어야 한다. 길드에 존재하는 모든 채널 중에서, 토픽에 아카이브라는 단어가 있는 채널을 아카이브 채널로 사용하기로 했다. 만약 없으면 새로 생성할지 사용자에게 물어보도록 했다.

기존 메시지 아카이브

가장 어려웠던 부분이 기존의 메시지를 확보하는 것이었다.

Discord API는 고정된 메시지를 바로 가져오는 endpoint를 제공한다. 이를 사용하면 현재 특정 채널에 존재하는 고정 메시지를 모두 가져올 수 있다.

그런데 이렇게 하면 좀 아쉬운 점이 있었다.

고정 메시지 50개 제한은 항상 부족했고, 새 메시지를 고정하기 위해 기존의 메시지들은 고정 해제되었다. 그 메시지들은 위에서 언급한 endpoint로 가져올 수 없었다.

옛 고정 메시지 가져오기

다행히 방법이 있었다.

pin-add-message.png

메시지가 고정되면, 시스템이 이를 알려주는 메시지를 보낸다. 해당 메시지에는 고정된 메시지에 대한 레퍼런스가 있다. 아래와 같은 형태이다.

{
  guildID: 786482076836298752,
  channelID: 787282220933971998,
  messageID: 788200235515248660
}

레퍼런스의 3개 필드의 조합은 Discord상에서 유일한 메시지를 식별하는 데에 사용할 수 있다. 이것만 있으면 고정되었다가 해제된 메시지도 가져올 수 있다.

Discord의 메시지 URL은 https://discord.com/channels/길드ID/채널ID/메시지ID 형태를 가진다.

이렇게 구현하기로 결정했다:

  1. 먼저 모든 채널에 대해 모든 메시지를 가져온다.
  2. 가져온 메시지 중 typePIN_ADD인 시스템 메시지만 걸러낸다.
  3. 해당 시스템 메시지가 가리키는 원본 메시지(reference)를 가져온다.

성능 이슈

heroku-high-ram.png

메모리 사용량 920MB 초과…? 🤨

메시지를 가져오는데, 16만개째에서 Node가 메모리 사용량 255MB를 기록하고는 뻗어버렸다.

16만개째에서 램을 255MB를 잡아먹으니, 1GB라면 64만개 정도는 감당할 수 있겠지 싶어 Heroku dyno를 Standard-2X로 업그레이드 했다.

Standard-2X는 1GB의 메모리를 제공하고…비싸다. 한 달에 50$.

그리고 Node 런타임이 메모리를 충분히 쓰도록 --max_old_space_size=920 옵션도 추가해 보았다.

괜찮은가 싶더니 55만개 째에서 메모리 사용량 1GB에 근접하며 또 뻗었다.

스케일링으로 될 문제가 아니다 싶어 새롭게 접근하기로 했다.

모든 메시지를 한 번에 가져오는게 맞는 걸까?

먼저 모든 채널에 대해 모든 메시지를 가져온다 부터 의심하기 시작했다.

Discord API는 봇이 권한만 있다면 모든 채널, 모든 메시지를 가져올 수 있도록 허용한다. 대신 1회 요청에 100개까지만 허용한다.

100개씩 나누어서 처리하지 않고, 74만개에 달하는 메시지를 모두 한 인스턴스에 담아두고 처리하는게 옳은 걸까? 하는 물음이 들었다. 당연히 이런 짓은 하면 안 되는 거였다.

여러분은 정신 맑을 때에 코딩하세요!

에러 한번만 생기면 처음부터 다시 시작해야 하는게 맞는 걸까?

두 시간에 걸쳐 메시지를 수십만개 가져오던 도중 Discord 서버가 500 응답을 한 번이라도 준다든가, Heroku dyno가 하루에 한 번 강제로 재시작되었다든가(프로세스가 무슨 일을 하든 그냥 죽여버린다…) 하면 두 시간짜리 노력이 물거품이 되어버린다.

앱은 그냥 항상 죽는다고 가정하는게 마음 편하다.

물론 의도한 건 아니고 대용량 데이터와 마주할 상상을 하지 못 했기 때문이다. 그래서 fetch 세션 동안에 가져온 데이터는 Redis를 사용해 적당히 보관하기로 했다.

개선

제일 먼저, exception handling에서 벗어난 API 호출을 try-catch로 감쌌다. 그리고는 가져온 데이터를 캐싱하도록 뜯어고치기 시작했다.

수십만개의 메시지 중 실제로 건져야 하는 건 400개 내외이다. 수천번의 fetch는 모두 저 400개 내외의 메시지를 위한 것이다.

건져낸 메시지는 모두 Redis에게 맡겼다. 앱 메모리에 인스턴스로 담아두지 않고 Redis를 주 저장소로 사용했다. 이렇게 하면 이런 것들이 좋다:

  • 이 세션 이전에 다른 저장된 캐시가 있는지 확인할 필요가 없다. 그냥 새로운 것을 더하고 한꺼번에 꺼내오면 자동으로 세션 복구가 구현된다.
  • 분기가 적어져 코드가 깔끔해진다.

호출  호출  ...      DiscordAPI
 \    \                                   
-수신--수신----->----App memory------->---인출---->--다음 작업-->
   \    \                               /
    \   저장          Redis             /
    저장    \ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ/
      \ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ/

데이터의 흐름. 수신되자마자 Redis로 가고, 메모리 상에서는 GC된다.

Redis는 또한 현재까지 fetch한 메시지의 갯수나 마지막으로 처리된 메시지의 id와 같은 정보를 가지고 있다. 이들은 fetch 세션을 시작할 때에 초기 값으로 활용된다.

Discord API로 메시지를 가져올 때에, 특정 메시지 이전의 메시지들을 가져올 수 있다. 해당 API는 메시지를 최근 것부터 가져오므로, 마지막으로 fetch된 메시지를 기억해 두었다가 다음번 요청에서 그 메시지 이전 메시지만 가져오면 세션을 이어나가는 것처럼 구현할 수 있다.

결과

heroku-memory-stable.png

커서가 있는 부분이 개선 이후.

한 번에 다룰 메시지의 수가 많아야 수백 개 수준으로 줄어들어, 메모리 사용량이 100MB 선을 한참 밑돌게 되었다.

RedisToGo가 제공하는 Redis instance도 전체 무료 제공 용량 5MB 중 6%~8%만 차지하며 안정적으로 돌아갔다.

redis-stable.png

마치며

이제는 그냥 돌아가게만 하면 안되고… 퍼포먼스까지 잘 생각해야겠구나… 싶었다.

References

  • https://support.discord.com/hc/ko/articles/212889058-Discord의-공식-API

댓글