
들어가며
오늘은 어제 작성한 코드를 다시 들여다보며 중복을 제거하고, 각 클래스가 맡아야 할 책임을 정의하며 리팩토링 작업을 진행했다.
어제는 디스코드 봇이라는 완전히 새로운 환경에서 처음부터 책임 분리·객체지향·깨끗한 구조까지 다 챙기려 했다가는,
아예 한 줄도 못 쓸 것 같아 과감히 동작 우선의 코드를 작성했기 때문이다. (돌아가는 쓰레기 코드...)
기능이 실제로 작동하는지 빠르게 확인하는 데 목적이 있었기 때문에 여러 책임이 한 클래스에 섞여 있거나, 중복된 메서드가 생기는 등 구조적으로 아쉬운 부분이 많았다.
따라서 리팩토링을 통해 각 환경에서 도메인 로직을 최대한 활용하고자 했다.
오늘 한 일
- gateway intent 제거
- Slash Command에서는 사용자로부터 메세지를 직접 읽어오지 않아 MESSAGE_CONTENT Intent가 불필요했다.
따라서 JDA 초기화 시, Message Content Intent 를 비활성화 시켜주었다.
더 자세한 내용은 https://jiihyunn.tistory.com/40에서 확인할 수 있다!
- Slash Command에서는 사용자로부터 메세지를 직접 읽어오지 않아 MESSAGE_CONTENT Intent가 불필요했다.
- RoboGame 도메인 책임 재정립
- 카드 제출 전략패턴 적용
- Controller, Listener에 있던 비지니스 코드 제거
- play 명령어 지연 응답 적용
- Command enum 이용하여 명령어 메서드 동적 처리 (BiConsumer)
- DiscordBotApplication 책임 분리
- 기존 DiscordBotApplication은 다음과 같은 역할을 한 번에 맡고 있었기에, 변경 사항이 생기면 관련 책임이 없을지라도 영향을 받을 가능성이 존재했다. (JDA 객체 생성, 토큰 주입, GatewayIntent 설정, 리스너 등록, 명령어 등록)
- 따라서 클래스마다 책임을 부여하여 분리해 주었고, 변경이 생겨도 바뀌어야 하는 영역이 명확하게 한 곳으로 정해졌다.
DiscordBotApplication은 봇을 실행하는 역할에만 집중하게 되었고, 초기화/명령어 등록/비즈니스 로직은 각각 자신의 책임을 가진 클래스로 따로 빠지게 되었다.
📌 RoboGame 도메인 로직 본래 자리로 되돌리기
▪️ 카드 제출 전략패턴 재도입
플레이어(사람, 봇)에 따라 카드 제출 방식이 달라지는 로직을 유연하게 처리하기 위해 전략 패턴을 도입했다.
이 글에서는 카드 제출 시 전략 패턴 안 쓰겠다고 했었는데?!
며칠 전까지만 해도 “카드 제출 로직에는 전략 패턴이 어울리지 않는다” 라는 결론을 내렸었다.
실제로 그때의 판단은 틀리지 않았다.
전략 패턴의 본질(동일한 문제를 다른 알고리즘으로 해결)이 카드 제출 상황과는 잘 맞지 않았기 때문이다.
(봇은 hand로부터 어떤 카드를 제출할지 결정하지만, 사람은 이미 ui에서 선택된 값을 전달하고 있기에)
// Before
playTurn(null); // Player가 Bot인 경우
playTurn(cardValue); // Player가 사용자인 경우
// After
playTurn(new BotSubmitStrategy()); // 아, 봇이 카드를 제출하는 전략이구나!
playTurn(new HumanSubmitStrategy(cardValue)); // 아, 사용자가 카드를 제출하는 전략이구나!
하지만 디스코드 봇에서도 RoboGame 도메인을 사용하려고 리팩토링을 하다보니,
playTurn() 메서드가 플레이어 타입에 따라 다른 입력을 받도록 되었다.
사람 플레이어의 경우 사용자가 선택한 cardValue를 넘기지만, 봇은 카드를 직접 선택하지 않기 때문에 null을 전달해야 했다.
유저는 손패 중 하나를 선택해 제출하는 반면, 봇은 손패 중 하나를 랜덤으로 제출한다는 구현 방식 때문이었다.
이로 인해, 봇의 카드 제출을 위해 들어가던 null이 무엇을 의미하는지 코드만 보고 즉시 알기 어렵다는 문제가 발생했다.
null이 봇의 자동 선택을 뜻한다는 사실은 메서드의 내부 구현을 열어보지 않으면 이해할 수 없었고, 이는 코드 가독성과 유지보수 측면에서 좋지 않은 구조였다.
전략 패턴을 쓰지 않겠다는 이론적인 이유보다,
가독성과 의도 전달 측면에서의 불편함이 현실적인 문제가 되어버린 상황이었다.
그래서 카드 제출 방식 자체를 전략으로 만들어 각 플레이어가 가진 ‘제출 전략’으로 위임하는 구조로 바꿨다.
사람 플레이어는 “HumanSubmitStrategy”, 봇 플레이어는 “BotSubmitStrategy”을 사용하도록 분리하고,
playTurn()은 단순히 플레이어가 가진 전략에게 카드 선택을 요청하는 형태로 변경했다.
이렇게 전략 패턴을 적용하면서
- null이라는 모호한 인자가 사라지고
- 어떤 플레이어인지 계속 물어봐야 했던 if문이 사라졌으며
- 플레이어별 행동 규칙이 명확히 분리되어 확장성도 좋아졌다.
public TurnResult playTurn(SubmitCardStrategy strategy) {
Player currentPlayer = getCurrentPlayer();
Card submittedCard = processSubmitCard(strategy, currentPlayer);
Card newCard = pickNewCard(currentPlayer);
boolean isGameOver = !isPlaying();
return new TurnResult(currentPlayer, submittedCard, newCard, isGameOver);
}
전략 패턴을 적용하면서 당장의 null 문제와 분기문 제거는 해결할 수 있었지만,
여전히 구조적으로 고민되는 지점이 남아 있다🥲
현재는 SubmitCardStrategy가 Player 전체를 넘겨받아 필요한 메서드를 호출하는 방식인데,
이렇게 되면 새로운 전략이 생길 때마다 Player에 새로운 메서드가 계속 추가되는 문제가 있다.
이는 장기적으로 Player의 책임이 비대해지고 전략 패턴을 쓴 의미가 점점 희미해질 것 같다.
또한 봇은 hand로부터 어떤 카드를 제출할지 결정하지만,
사람은 이미 ui에서 선택된 값을 전달하고 있기에 두 전략의 책임이 일치하지 않는다.
그렇다고 전략에 Player의 세부 데이터를 직접 넘기도록 만들면,
캡슐화를 깨뜨리게 되고, 모든 전략에서 필요한 매개변수를 들고 있지 않는다는 또 다른 문제가 발생한다.
즉, 전략 패턴으로 구조를 한 번 정리하긴 했지만, 여전히 더 나은 설계를 고민해야 하는 상황이다...
애초에 이 로직을 전략 패턴으로 묶는 선택이 최선이었는지조차 의문이 들었고,
어쩌면 Player 안에 인간과 봇을 모두 포함시키는 구조 자체가 문제의 근원일 수도 있겠다는 생각도 들었다.
코드를 읽을 때마다 어딘가 불편하다는 느낌은 확실히 있지만, 맘에 드는 대안이 떠오르지 않아 계속 고민하는 중이다.
일단,,,아직 가야할 길이 멀기에 일보 후퇴,,하고 시간이 될 때 고민을 더 해봐야겠다.
▪️ Controller·Listener 역할을 가볍게: 흐름 제어만 남기기
Controller와 Listener가 흐름을 제어하는 역할에만 집중하도록 만들었다.
기존에는 이 클래스들이 카드 제출, 턴 진행 등 도메인에서 해야 할 일을 직접 수행하고 있었다.
따라서 해당 로직들을 모두 RoboGame 도메인으로 이동시키고,
Controller와 Listener는 도메인 호출 → 응답 전달 역할만 담당하도록 정리했다.
⬇️ 코드 변경 내역
https://github.com/Jiihyun/robo77/commit/7a993c62d3a0218ca48f4038e8e54344c1721466
이 과정에서 가장 만족스러웠던 점은,
Controller와 Listener가 점점 본연의 역할만 수행하는 형태로 가벼워졌다는 것이다.
- Controller & Listener → 입력값 받고, 게임 시작·진행 요청 전달
- RoboGame 도메인 → 실제 게임 규칙 판단, 턴 진행, 카드 검증 등 핵심 로직 전담
이렇게 역할이 단순해지니 Controller & Listener에서 중복으로 존재했던 코드가 사라졌을 뿐만 아니라,
앞으로 비즈니스 규칙이 변경되거나 기능이 추가되더라도 Controller와 Listener 모두 수정하는 것이 아닌
도메인만 수정하면 된다는 점이 정말 편리하게 느껴졌다.
📌 play 명령어 3초 제한을 넘겨버렸다: 지연 응답 적용기
/play 명령어를 실행하는데 갑자기 아래와 같은 에러가 발생했다.


🤔 왜 이런 에러가 발생했을까?
나는 reply() API를 사용하여 Slash Command 응답을 보내고 있었다.
reply() API는, 3초 안에 응답이 도착하지 않으면 interaction이 자동으로 폐기되고,
디스코드는 “이 명령어는 이미 만료됨!” 하고 응답 자체를 거부해버린다.
하지만 /play 명령어는 다음과 같이 꽤 많은 로직을 수행해야 한다...
- 사용자 카드 유효성 검증
- 유저가 제출한 카드 기능 확인
- 새로운 카드 선택
- 점수 기록
- 봇의 카드 선택 로직 실행
- 결과 메시지 생성
이 과정은 항상 3초 이내에 끝난다고 장담할 수 없는 흐름이다.
이로인해 명령어 처리 과정에서 interaction이 폐기되어, Unknown interaction이 발생한 것이다.
문제를 파악한 뒤, 명령어 응답 방식을 reply()에서 deferReply()로 변경했다.
deferReply()는 3초 안에 “명령어 받음!”이라는 상태만 먼저 서버에 전달한다.
따라서 Discord UI에서는 “Thinking…” 상태로 표시되며, 이후 최대 15분까지 실제 메시지를 전송할 수 있다.
즉, “응답이 조금 길어질 예정이니 일단 기다려줘!” 라고 먼저 알려주는 방식이다.
이번 문제를 해결하면서, 응답 시간 초과처럼 시스템 레벨에서 발생하는 예외 상황도 미리 대비해야 한다는 점을 깨달았다.
지금까지 나는 사용자가 잘못된 입력을 했을 때의 예외 처리나
게임 규칙에 맞지 않는 행동을 막는 로직 같은 비즈니스 로직 중심의 오류 처리에 집중해왔다.
하지만 이번 /play 명령어의 응답 지연으로 인해 인터랙션이 폐기되는 문제를 겪으면서,
내 코드가 정상적으로 돌아가는지 여부만큼이나 ‘얼마나 빨리, 안정적으로 응답할 수 있는지’도 중요한 요소라는 걸 처음으로 체감했다.
작은 문제였지만,
내가 앞으로 고려할 수 있는 예외 처리의 범위가 더 넓어졌다는 느낌이 들어
오늘도 성장한 느낌이 든다. 귯
📌 행위를 값으로 넘기며 얻은 구조적 변화( feat. BiConsumer)
@Override
public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) {
String channelId = event.getChannel().getId();
if (event.getName().equals(Command.START_GAME.getCommand())) {
handleStartGame(event, channelId);
}
if (event.getName().equals(Command.QUIT.getCommand())) {
handleQuit(event, channelId);
}
if (event.getName().equals(Command.HAND.getCommand())) {
handleHand(event, channelId);
}
if (event.getName().equals(Command.PLAY.getCommand())) {
handlePlay(event, channelId);
}
}
이랬는데
@Override
public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) {
try {
Command.from(event.getName())
.execute(this, event);
} catch (IllegalArgumentException e) {
event.reply("⚠️ " + e.getMessage()).setEphemeral(true).queue();
}
}
이렇게 됐슴다
리팩토링 하기 전의 코드는 명령어가 늘어날수록 if문이 계속 증가하고,
각 명령어마다 처리 흐름을 따로 관리해야 해서 유지보수가 어려워질 조짐이 보였다.
“이 구조로 계속 추가하다가는 나중에 후회하겠다…” 라는 느낌이 딱 든 것이다.
그래서 if문 없이 Command enum이 스스로 판단하여 자기 명령어에 맞는 메서드를 호출하면 좋지 않을까? 라고 생각했다.
이 생각이 들자 다음 고민이 따라왔다.
“Command가 스스로 자신에 맞는 메서드를 실행하려면, 해당 메서드를 인자로 갖고있어야 하지 않을까?”
이애 방법을 탐색하다 선택한 것이
함수형 인터페이스(Function Interface), 그중에서도 BiConsumer였다.
함수형 인터페이스는 행위(behavior)를 값처럼 전달하기 위한 도구이기 때문에
내가 표현하고 싶은 바에 딱 맞았다.
또한 명령어를 실행하려면 명령어를 실행할 대상(listener)과 명령어 이벤트(SlashCommandInteractionEvent) 라는 2개의 정보가 필요했고, 실행하려는 메서드의 반환값이 없기 때문에 BiConsumer를 선택하였다.
그 결과, 자연스럽게 다음과 같이 각 명령어는 자신이 실행해야 할 핸들러를 이렇게 스스로 들고 있게 되었다.
private final BiConsumer<GameCommandListener, SlashCommandInteractionEvent> handler;
START_GAME("startgame", "로보77 게임을 새로 시작합니다.", GameCommandListener::handleStartGame),
HAND("hand", "현재 손에 들고 있는 카드를 확인합니다.", GameCommandListener::handleHand),
PLAY("play", "손에 들고 있는 카드 중에서 한 장을 제출합니다.", GameCommandListener::handlePlay),
QUIT("quit", "게임을 종료합니다.", GameCommandListener::handleQuit);
이제는 명령어가 추가된다해도, onSlashCommandInteraction 메서드를 건들 필요 없이
Command enum에 명령어 추가 & 핸들러 메서드 구현만 하면 되니, 훨씬 확장에 유연해졌다.
또 각 명령어가 “자신의 책임(핸들러)를 스스로 갖는다"는 점에서도 응집도가 높아진 것 같다.
이번 고민을 통해 함수형 인터페이스를 왜 쓰는지, 언제 활용할 수 있는지에 대한 감각이 조금은 생긴 것 같다.
행위(메서드)를 값으로 전달하고 싶을 때는 함수형 인터페이스를 활용하자!
마치며
오늘은 “행위를 값으로 다루는 것”, “응답 지연 같은 시스템 레벨 문제까지 고려하는 것” 등 지금까지 크게 다뤄보지 않았던 영역을 처음으로 시도해본 점이 의미 있었던 하루였다.
기능을 구현하는 것을 넘어, 앞으로는 더 안정적이고 확장 가능한 방향을 고민할 수 있게 된 것 같다.
돌아보면 이런저런 시행착오들이 전부 다음 단계로 넘어가기 위한 과정인 것 같다.
만약 운 좋게 그냥 지나갔다면, 배움 없이 넘어갔을 테니까 오히려 다행이라고 볼 수 있지 않을까?
한 번 넘어지면, 다음엔 넘어지고 싶지 않다는 갈망이 생기게 되니 성공할 확률이 올라가 더 잘 될 수 밖에 없는 것 같다.
그러니 빠르게 실패하고, 빠르게 일어나 다시 시도하는 흐름을 반복하다 보면
어느 순간 자연스럽게 실력도 따라오고, 나아가 다수의 유저가 사용하는 서비스를 만드는 개발자가 되어 있을 거라 믿는다.
앞으로도 더 깊이 고민하고, 더 과감하게 시도하는 태도를 갖추자!
'Project > Robo77' 카테고리의 다른 글
| 로보 77 구현기 최종 회고 (1) | 2025.12.02 |
|---|---|
| 로보 77 구현기 9일 차 - 불필요한 추상화를 걷어내고 Discord 리스너 테스트하기 (0) | 2025.11.15 |
| 로보 77 구현기 7일 차 - 빈 깡통이었던 디스코드 봇에 생명 불어넣기 (0) | 2025.11.13 |
| 로보 77 구현기 6일 차 - Phase 2를 향한 재정비와 첫 디스코드 봇 생성! (0) | 2025.11.12 |
| 로보 77 구현기 2일 차 - 전략 패턴과 씨름한 하루 (0) | 2025.11.08 |