Spring 기반 디스코드봇 "망고봇" 개발기

· Backend, Spring, GitOps

망고봇 개발을 시작하며

망고봇은 내가 운영하고 있는 디스코드 커뮤니티에서 사용하기 위해 기획한 서비스다. 한창 게임을 즐길 때 만든 디스코드 커뮤니티였는데, 친구가 또 다른 친구를 데려오기 시작하면서 현재는 봇과 중복 계정을 제외하고 100명에 근접한 규모가 되었다. 요즘 나는 게임을 자주 하지 않는 편인데도 알아서 디스코드에 모여서 다같이 게임을 하고 있는 모습을 보니 커뮤니티가 꽤 안정화되었다는 생각이 들곤했다.

부쩍 성장한 커뮤니티 규모

규모가 커진만큼 커뮤니티의 관리를 수동으로 하지 않고 시스템적으로 커뮤니티를 운영할 수 있는 방법이 있지 않을까?라는 생각이 들었다. 그러다 기존에 초대되어 커뮤니티에 존재하던 봇들이 눈에 들어왔다. 로스크아크 정보를 보여주는 로아봇, TTS를 지원하는 티토커…

봇을 만들어 유틸리티적인 기능을 제공하고, 여기에 간단한 미니게임도 제공하면 커뮤니티 구성원들의 만족도에 큰 도움이 될 것 같았다. 기존의 다른 봇들을 활용하는 방안도 있었지만, 내 기획 방향과는 맞지 않았다. 나는 유저들에게 포인트나 랭크 제도를 도입해서 커뮤니티 활동이나 미니게임 등을 이용해서 포인트를 모으고 랭크를 올리는 시스템을 만들고 싶었다. 게임을 좋아하는 사람들이 모인 커뮤니티인만큼 커뮤니티에도 게이미피케이션을 적용해보고자 했다.

이제 어떻게 봇을 구현할 것인지 생각해보았다. 그동안 슬랙봇은 만들어본 적이 었어서 봇 개발에는 조금 익숙했다. 디스코드 봇 서버는 스프링을 이용하기로 했다. 요즘 들어 스프링을 열심히 공부하기도 했고, 스프링의 매력에 푹 빠져 있던 상황이라 묻지도 따지지도 않고 스프링으로 결정했다. 사실 굳이 프레임워크를 쓰지 않아도 JDA 라이브러리만을 이용해 디스코드 봇을 만들 수는 있겠지만 나중에 서비스가 커진 뒤에 봇 이외의 다른 서비스도 함께 제공할 수 있을거란 생각이 들어 스프링을 활용하기로 했다. 서비스가 많이 커지면 MSA 구조로 분리하는 것도 고려해볼만 할 것 같다.

망고봇을 개발할 때 고심한 부분은 구조였던 것 같다. 스프링으로 실제로 운영할 프로덕트를 만드는 것은 처음이라 구조 설계에 시간을 많이 쏟았고, 실제로 운영할 프로덕트이다보니 배포 프로세스와 개발 환경과 운영 환경의 분리에도 시간을 꽤 쏟았다.

구조 고민

핵심은 “어떻게 확장가능한 구조를 가져가면서, 반복되는 코드를 줄일 수 있을 것인가”였다.

디스코드 서버와 봇은 소켓 통신을 기반으로 연결을 맺고 유지한다. JDA 라이브러리는 봇이 이벤트를 수신하면 이벤트 종류에 따라 해당하는 ListenerAdapter의 메소드가 실행되는 구조다.

봇 기능의 확장성을 고려하면 ListenerAdapter에 모든 코드를 다 집어넣을 수 없는 것이 당연했고, 기능 별로 핸들러를 분리하고, 이벤트 내용에 따라 필요한 핸들러를 호출하는 구조를 만들기로 했다.

하지만 기능을 추가하기 위해 핸들러를 하나 추가할 때마다 매번 핸들러 코드를 수동으로 이벤트리스너에 추가해주는 코드를 작성하는 것은 스프링답지 않아 보였다. 스프링의 핵심인 DI를 이용하면 수동으로 코드를 추가해주지 않더라도 리스너에 핸들러를 연결시켜줄 수 있을 것 같았다.

ListenerAdapter를 상속 받는 EventListener라는 추상클래스를 하나 만들고, 들어오는 이벤트 종류에 따라 사용하는 핸들러의 종류도 제네릭으로 정의할 수 있게끔 만들었다. 예를 들어 디스코드 서버로부터 슬래시커맨드 타입의 이벤트가 들어왔을 때는 SlashCommandEventListener 클래스를 통해 처리된다. SlashCommandEventListener 클래스는 SlashCommandHandler List를 DI 받아 HashMap에 명령어-실행할 객체를 Key-Value로 저장한다. 그리고 명령어 이벤트가 들어오면 명령어에 맞는 객체를 찾아 execute() 메소드를 실행한다. 당연히 exec() 메소드는 인터페이스에 정의되어 있어 구현이 보장된다.

@Component
public abstract class EventListener<S extends Handler> extends ListenerAdapter {
    protected HashMap<String, S> handlerHashMap = new HashMap<>();

}

ListenerAdapter를 상속받는 EvenetListener 추상 클래스를 선언하고, Handler들을 담아둘 HashMap을 만들었다.

@Slf4j
@Component
public class SlashCommandEventListener extends EventListener<SlashCommandHandler> {

    private List<SlashCommandData> commandList = new ArrayList<>();

    public SlashCommandEventListener(List<SlashCommandHandler> handlerList) {
        handlerList.stream().forEach(commandHandler -> {
            handlerHashMap.put(commandHandler.getCommandData().getName(), commandHandler);
            commandList.add(commandHandler.getCommandData());
        });
    }

    @Override
    public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
        if(handlerHashMap.containsKey(event.getName())) {
            handlerHashMap.get(event.getName()).execute(event);
        }
    }

    public List<SlashCommandData> getCommandList() {
        return commandList;
    }
}
public abstract class SlashCommandHandler implements Handler<SlashCommandInteractionEvent> {

    public abstract SlashCommandData getCommandData();

    public String getName() {
        return this.getClass().getName();
    }
}

해쉬맵에 들어갈 수 있는 핸들러 인터페이스를 제네릭으로 받아왔다. 핸들러 인터페이스 구현체들의 리스트는 의존성 주입으로 받아온다. 스프링은 같은 클래스의 빈들을 List로 모아서 DI해주는 기능을 이미 지원하고 있었기 때문에 편리하게 DI 받을 수 있었다. 사실 처음에는 자바 리플렉션을 써보기도 했는데 생각해보니 스프링이 이미 기능을 지원해주고 있어서 다시 수정했다.

이렇게 기본 뼈대를 잡았고, 핸들러를 추가하고, 각각의 핸들러를 구현할 때, 각각의 이벤트별 핸들러 추상클래스를 상속받으면 자동으로 Listener에서 DI되어 명령어가 동작할 수 있도록 구조를 작성했다.

클래스 파일을 만들고 스프링 빈으로 등록해주기만 하면, 자동으로 핸들러가 추가되게끔 구조를 완성했다.

그렇게 만들어진 구조

stateless 지향하기

기능을 만들면서 많이 신경쓰는 부분 중 하나다. 스프링 빈이 state를 가지게 되는 것은 상당한 부담이 따른다. 예를 들어 주사위 게임을 만들 때 고려했던 몇 가지 부분이 있다.

레디스에 저장되는 게임 정보들

이러한 여러 고민들을 하다보니, 역시 정답은 state를 분리해서 따로 관리하는 것이라는 생각이 들었다. state는 모두 다른 곳에 저장하고 있으면 코드는 state를 신경쓰지 않고 로직에 집중할 수 있을 것 같았다.

게임이 생성되면, 게임의 메인 실행 흐름은 Redis에 새 게임을 저장한다. 게임 참여 혹은 베팅 같은 요청은 별도의 스레드로 들어오게 되는데, 기존의 본 게임 흐름이 진행되는 스레드에 정보를 전달하기 위해 레디스를 이용하는 것이다. 게임이 진행되면서, 각 컴포넌트들은 레디스로부터 데이터를 받아와서 데이터를 처리한 이후 다시 레디스에 데이터를 넣는다. 그리고 다음 컴포넌트를 호출할 때는 GameId만 파라미터로 넘겨준다. 다음 컴포넌트는 GameId를 이용해 다시 레디스로부터 데이터를 받아와 데이터를 처리한다.

인프라 v1

망고봇은 MySQL과 Redis를 사용하는데, 당연히 운영이 들어가는 프로덕트이니 환경을 분리하여야 했다. 간단하게 데이터베이스는 하나로 두고 그 안에서 분리하는 방법도 있겠지만, 보안 상 데이터베이스가 외부로 노출되지 않도록 하기 위해 디비 자체를 분리했다. 로컬에 MySQL과 Redis를 올려두고 개발환경으로 사용하였고, application.properties를 환경에 따라 분리하여 프로덕션 환경에서는 서버 내의 도커에 DB 인스턴스들을 올려두고 사용했다.

GCP 인스턴스를 사용했다. AWS가 아니라 GCP를 쓴 이유는 GCP가 무료 크레딧을 줘서… 비용을 고려하여 최대한 가벼운 구조를 가져가고자 하나의 인스턴스 안에 다 넣는 방식을 채택했다. 원래는 쿠버네티스를 써서 올리고 여러 인프라 도구들을 함께 구성할까 고민했지만, 너무 무겁고 돈도 많이 들 것 같아서 도커 컴포즈로 간결하게 구성하게 되었다.

CI 구성

자바 프로젝트는 깃허브 main 브랜치에 커밋이 일어나면 Github Actions가 동작해 Gradle 빌드를 해서 Docker로 말아서 GCP Artifact Registry에 올리는 것까지 수행하도록 만들었다. 배포까지 자동화를할까 고민했지만, 배포가 그렇게 자주 일어나지 않을 것 같았고 Stateful하게 진행되는 봇의 기능도 있을 것 같아서 배포는 수동으로 작업하기로 결정했다.

이슈 관리

혼자하는 프로젝트지만 나름 체계를 갖추고자 이슈 베이스로 작업을 하고 있다. 만들 새로운 기능이 생각나면 이슈에 올려두고, 코드를 짜서 커밋하고 메인에 머지되면 이슈를 닫는 방식으로 작업하고 있다.

이 프로젝트에서 앞으로 해결해야 할 인프라 과제는 로그 컬렉션을 구성하는 일과 장애 탐지의 속도를 높이는 것이다. 현재 쌓여 있는 이슈를 처리하고 난 다음에 볼 생각이다.

(2024.04 추가) 인프라를 쿠버네티스로 재구성했다!

게이미피케이션

벌써 누적 1천판은 거뜬히 넘긴 주사위게임

초기 버전을 내놓을 때, 간단한 게임을 만들고자 주사위 게임을 만들었는데, 현재 진행된 주사위 게임 판수가 1000판은 거뜬히 넘겼으니 생각보다 많은 사람들이 재미있게 즐겼던 것 같다. 내 추측으로는 혼자하는 게임이 아닌 다같이 할 수 있는 게임이라는 점과 포인트 제도가 큰 역할을 했던 것 같다.

혼자하는 게임은 금방 질릴 것 같다는 생각이 들어서, 카운트 다운 기능을 만들고, 제한 시간 내에 주사위 숫자에 베팅할 수 있도록 만들어, 하나의 게임을 여러 사람이 함께 즐길 수 있도록 만들었다.

이후로도 유틸리티성 기능들도 꽤 많이 출시를 했고, 해나갈 예정이다.

유틸리티성

마인크래프트와 같이 다른 게임과 연동을 하기도 했다.