
들어가며
오늘은 출력 로직의 책임 분리를 다시 살펴보며 개선 작업을 진행했다.
콘솔/디스코드 환경의 입출력 모델을 다시 이해하면서 기존 설계의 잘못된 가정을 분명히 인지할 수 있었고, 불필요한 추상화를 제거해 구조적 혼잡을 줄이는 리팩토링을 진행했다.
또한 앞으로 기능 추가나 리팩토링 시 기존 흐름이 깨지지 않는 확신을 갖기 위해 디스코드 봇 관련 테스트 코드도 작성했다. 디스코드 봇 특성상 실제 이벤트를 다루기 어렵기 때문에 외부 의존성을 모킹하기 위한 Mockito를 학습하여, EventListener의 흐름이 기대한 대로 동작하는지 검증할 수 있었다.
오늘 한 일
- 입출력 로직 책임 분리
- 불필요하게 사용되던 Reader, Writer 인터페이스를 제거하고, ConsoleInput, ConsoleOutput을 직접 사용하도록 단순화
- DiscordCommandListener 내에 있던 출력 관련 로직을 DiscordOutput 클래스로 분리
- 게임 규칙에 대해 간단히 설명해주는 /guide 명령어 추가
- Mockito 의존성 추가 및 DiscordCommandListener 관련 테스트 코드 작성
📌 콘솔 vs 디스코드, 완전히 다른 입출력 모델을 이해하며 구조 재정립
▪️ 콘솔과 디스코드를 억지로 통합하려다 깨달은 것들
처음에는 지금까지 프리코스 미션에서 활용했던 MVC 패턴 기반으로 설계하되,
입력과 출력을 각각 Reader와 Writer 인터페이스로 추상화해 콘솔과 디스코드 봇 환경을 분리하려고 했다.

두 Application이 동일한 Controller & Model을 사용하고,
Controller는 InputView/OutputView를 의존하며,
View는 다시 Reader/Writer 인터페이스를 콘솔용과 디스코드용으로 각각 구현해 다형성으로 문제를 해결하는 것이다.
이렇게 인터페이스로 분리해두면 필요한 환경에 맞춰 구현체만 교체하면 되기 때문에, 중복을 줄이면서도 유연하고 확장 가능한 구조가 될 것이라 생각했다.
하지만 디스코드 봇 입출력까지 구현하고 나니, 콘솔과 디스코드의 입출력 방식이 근본적으로 다르다는 점이 명확하게 드러났다.
- 콘솔: 프로그램이 입력을 요청하는 Pull 모델
- 디스코드: Listener 기반으로 사용자 이벤트가 들어오는 Push 모델
이로 인해 두 환경을 하나의 일관된 흐름으로 통합한다는 초기 계획이 현실적으로 어렵다는 사실을 깨달았다.
돌이켜보면, 디스코드가 이벤트 기반으로 동작하고 상호작용 흐름도 콘솔과 전혀 다르다는 점을 고려하지 못한 것이 가장 큰 원인이었다.
처음 설계 당시 나는 디스코드의 동작 방식에 대해 완전히 무지한 상태였고, 그렇기 때문에 “어떤 방식이든 결국 입출력은 존재하니 Reader와 Writer로 추상화해 분리할 수 있을 것”이라고 지나치게 단순하게 판단했던 것이다.
결국 이번 시행착오를 통해 얻은 교훈은 명확하다.
추상화는 목적이 아니라 결과로써 나타나는 것이며,
충분한 이해와 실제 반복되는 요구가 존재할 때 비로소 가치를 가진다는 점이다.
앞으로는 단순히 추상화가 가능할 것이라는 가정만으로 구조를 설계하기보다는, 추상화하려는 대상이 실제로 어떻게 동작하는지와 어떤 제약을 갖는지 먼저 파악하고 모델링하는 과정을 선행할 것이다. 그렇게 해야만 어떤 부분이 공통화할 수 있는지, 그리고 어떤 부분은 별도로 두어야 하는지를 올바르게 판단할 수 있을 것 같다.
또한 YAGNI 원칙처럼, 실제로 필요가 확인되는 순간에만 추상화를 도입하는 ‘늦은 추상화’ 접근을 통해 불필요한 인터페이스나 계층을 만드는 실수를 줄이고, 더 안정적이고 현실적인 구조를 설계하고자 한다.
▪️ Reader·Writer 제거, 그리고 더 단순하고 명확한 구조로 변경

위의 깨달음을 얻은 결과, 구조는 다음과 같이 분리되었다.
Domain은 서로 공유하지만,
- 콘솔은 RoboApplication → RoboGameController → ConsoleInput/ConsoleOutput,
- 디스코드는 DiscordBotApplication → DiscordCommandListener → DiscordOutput 흐름을 갖게 되었다.
구조를 재설계하면서 하나의 구현체밖에 없어 불필요하게 사용되던 Reader, Writer 인터페이스를 과감히 제거하고,
콘솔은 Controller에서 ConsoleInput, ConsoleOutput을 직접 사용하도록 단순화했다.
⬇️ 코드 변경 내역
https://github.com/Jiihyun/robo77/commit/d258533a1533a44da626ae8261d7fb84da564ba5
또한 디스코드 쪽에서는 입력은 이미 Listener가 이벤트로 전달하고 있기 때문에 따로 입력 클래스를 만들지 않았고,
기존에 DiscordCommandListener 안에 섞여 있던 출력 책임만 DiscordOutput 클래스로 완전히 분리해 내며 출력 관련 책임을 명확히 재정립했다.
⬇️ 코드 변경 내역
https://github.com/Jiihyun/robo77/commit/2b5f5ac23a93098095c87690912ddce9dd3e2717
📌 게임 규칙을 바로 안내하기 위한 /guide 명령어 추가

로보 77 게임 규칙을 정확히 알지 못해 진행에 어려움을 겪는 유저들이 있을 수 있다는 생각이 들었다. 이를 해결하기 위해 /guide 명령어를 구현해 게임 규칙을 디스코드 환경 안에서 바로 확인할 수 있도록 했다.
깃허브 README에 상세한 규칙 설명이 작성되어 있기는 하지만,
실제 유저가 디스코드 봇과 게임을 진행하는 상황에서 README를 찾아볼 가능성은 거의 없다는 생각이 문득 들었다.
따라서 디스코드 환경 안에서 필요한 정보가 즉시 제공되어야 한다고 판단했고, 그 결과 /guide 명령어를 추가하게 되었다.
기능을 추가하니, QA를 부탁할 때도 친구들에게 일일이 게임 규칙을 설명할 필요가 없어 훨씬 편했고,
친구들도 짧고 명확한 안내를 통해 금방 규칙을 이해할 수 있어서 만족도가 높았다.
구현 자체는 매우 간단했지만, 실제 사용 경험을 개선하여 꽤 뿌듯함을 느꼈던 기능이다!
📌 디스코드 봇 테스트 코드 작성: Mockito로 이벤트 흐름 검증하기
▪️ 왜 Mockito를 사용했는가?
디스코드 봇 테스트 코드를 작성하려 할 때, 가장 먼저 마주한 문제는 실제 Discord 서버에 연결해서 테스트할 수는 없다는 것이었다.
Discord 서버와의 통신, 메시지 객체 생성, 이벤트 리스너 호출 흐름은 외부 시스템에 강하게 의존하기 때문에 테스트 환경에서 그대로 재현하는 것이 불가능했다.
따라서 Discord의 실제 이벤트를 대신해 가짜 이벤트 객체를 만들고,
리스너가 Domain 로직을 제대로 호출하는지 확인하기 위해 외부 의존성을 모킹(Mock)하는 방식을 사용했다.
처음에는 "가짜로 테스트하면 의미가 있나?"라는 의구심도 들었다.
하지만 곧 깨달은 것은, DiscordCommandListener 테스트의 목적은 "이 클래스가 주어진 입력에 대해 올바른 메서드를 호출하는가 / 에러는 잘 처리하는가"를 검증하는 것이지, "Discord API가 잘 작동하는가"를 검증하는 것이 아니라는 점이다.
또한 DiscordCommandListener가 호출하는 도메인 로직들은 이미 별도의 단위 테스트에서 모킹 없이 검증해두었기 때문에,
여기서는 이 리스너가 Discord 이벤트를 받아 도메인 로직까지 정확히 이어주는지만 확인하면 충분하겠다고 생각했다.
테스트 코드 강의를 통해 Mockito의 존재와 아주 기본적인 사용법 정도는 알고 있었지만, 실제 프로젝트에서 직접 활용해본 경험은 없었다.
그래서 이번 테스트를 작성하면서 Mockito를 적용해보고, 외부 의존성을 분리한 테스트가 어떤 의미를 가지는지 체감하며 익숙해지고자 했다.
▪️ JUnit과 Mockito 의 버전 충돌 문제 해결하기
의존성을 추가하려고 Mockito를 검색했을 때,
가장 최신 버전이 mockito-core:5.20.0이고, usages가 1000이 넘길래 해당 버전을 추가했다.
testImplementation("org.mockito:mockito-core:5.20.0")
하지만 테스트 실행과 동시에 바로 문제가 발생했다.
OutputDirectoryProvider not available;
probably due to unaligned versions of the junit-platform-engine and junit-platform-launcher jars
처음 보는 메시지였다.
테스트가 실패하는게 아니라 테스트 엔진 자체가 죽어버린 상황이라 원인을 파악하는 데도 꽤 애를 먹었다...
AI와 Stack Overflow의 도움을 얻은 결과, 결론적으로 원인은 다음 한 문장으로 요약된다.
Mockito 5.20.0이 내부적으로 사용하는 JUnit Platform 버전이
프로젝트에서 사용하는 JUnit BOM 5.10.0과 호환되지 않음
프로젝트에서는 JUnit 전체 버전을 BOM(5.10.0)으로 맞추고 있었지만, Mockito 내부가 사용하려는 Platform API는 또 다른 버전을 사용하고 있기 때문에 두 버전이 동시에 로딩되면서 테스트 엔진 초기화 실패한 거였다.
따라서 BOM(5.10.0)에 맞추어 Mockito의 버전을 5.3버전으로 바꾸었더니 에러가 해결되었다.
겉보기엔 단순한 테스트 의존성 추가였지만,,,이번 시행착오를 통해 배운 점은 다음과 같다.
라이브러리를 선택할 때 최신+lts 버전 여부만 보지 말고, 프로젝트 전체의 버전 맥락(JUnit BOM, 플랫폼 버전, 다른 테스트 라이브러리 간의 호환성)을 함께 고려해야 한다는 점을 깨달았다.
▪️ 내가 작성한 비동기 테스트에서 왜 mock이 호출되지 않았을까?
테스트 코드에서 가장 까다로웠던 부분은 비동기 처리를 이용한 아래 메서드였다.
public void handlePlay(SlashCommandInteractionEvent event) {
findAndExecuteGameAction(event, game -> {
String cardValue = event.getOption("card").getAsString();
event.deferReply().queue(hook -> {
try {
playTurns(hook, game, event.getChannel().getId(), cardValue);
} catch (IllegalArgumentException illegalArgumentException) {
discordOutput.showError(event, illegalArgumentException.getMessage());
}
});
});
}
해당 기능을 테스트하면서, 처음에는 단순히 mock만 채워 넣으면 테스트를 통과할 수 있을줄 알았다.
하지만 예상과 달리 테스트는 계속 실패했고, Mockito는 다음과 같은 메시지를 날렸다.

메서드 로직상 아래 mock 객체들을 분명히 사용해야 하는데, Mockito는 “전혀 호출되지 않았다”고 말하고 있었다.
when(event.deferReply()).thenReturn(replyAction);
TurnResult playerResult = mock(TurnResult.class);
when(playerResult.isGameOver()).thenReturn(false);
when(game.playTurn(any())).thenReturn(playerResult);
🤔 왜 그럴까?
mock 객체들은 모두 callback 내부에서 호출된다.
event.deferReply().queue(hook -> {
// 여기서 game.playTurn(), isGameOver(), playBotTurns() 호출됨
});
즉, 콜백이 실행되지 않으면
callback 내부의 어떤 로직도 단 한 줄도 실행되지 않는다.
실제 환경이라면 JDA가 콜백을 실행하지만,
테스트 환경에서는 JDA가 동작하지 않으니 코드를 작성해서 콜백을 직접 실행해야 했던 것이다.
하지만 난 그 사실을 모르고 단순히 mock 객체로만 만들어두고는 콜백을 실행하지 않았다.
문제의 원인을 파악하고 나니 해결은 아주 간단했다.
doAnswer(invocation -> {
Consumer<InteractionHook> callback = invocation.getArgument(0);
callback.accept(hook); // callback 직접 실행
return null;
}).when(replyAction).queue(any());
이제 테스트에서 queue()가 호출되면,
실제 JDA처럼 callback이 실행되고 그안에서 playTurn(), playBotTurns(), isGameOver() 등이 자연스럽게 작동한다.
이 문제를 겪으면서 내가 비동기에 대해 얼마나 수박 겉핥기식으로 이해하고 있었는지 깨달았다.
나는 그동안 완전 피상적으로만 이해하고 있었다...
- "비동기 = 시간이 오래 걸리는 작업을 처리할 때 쓰는 것"
- "콜백 = 나중에 자동으로 실행되는 것"
내가 Mock을 만들어놓고도 왜 호출되지 않는지 몰랐던 것은, 비동기 실행 메커니즘을 이해하지 않고, 그저 라이브러리가 제공하는 API를 사용했기 때문이다.
비동기 공부하려 했는데,,구현에 쫓기다보니 얕게만 공부하고 넘어간게 테스트 코드 작성하면서 드러나 버렸다,,ㅋㅋㅋㅎ
“왜 mock이 호출이 안 되지?” 하다가 → “아, 콜백을 실행해주는 주체가 없구나?”로 이어지기까지
꽤 멀리 돌아갔지만, 덕분에 비동기의 본질을 조금 더 깊이 바라보게 된 것 같다.
이번 경험을 통해 겉으로 보이는 코드 흐름만 이해하는 것은 절대 충분하지 않다는 걸 느꼈다.
앞으로는 "이렇게 쓰면 된다"는 수준을 넘어서, 실제로 어떻게 동작하는지에 대해 파악하는 습관을 들여야겠다.
마치며
오늘은 코드를 바라보는 관점 자체가 한 단계 확장된 날이었다.
입출력 구조를 다시 설계하며 큰 고민없이 만들어 둔 추상화가 얼마나 쉽게 불필요해질 수 있는지 깨달았고, 실제 동작 방식에 대한 이해 없이 만든 코드는 결국 다시 깊게 살펴보면서 실제 동작 방식을 이해해야 한다는 사실을 몸소 체감했다.
돌이켜 보면, 오늘의 시행착오는 모두 얕게 이해하고 내부 동작은 충분히 살피지 않은 상태에서 발생한 문제였다.
그리고 바로 그 지점이 앞으로 더 성장해야 할 부분이라는 것도 분명히 느꼈다.
코드를 깊게 이해하는 태도, 그리고 동작 원리부터 바라보려는 관점이 결국 더 단단한 설계로 이어진다는 것이다.
앞으로는 기능 구현에만 쫓기기보다, 코드가 실제로 어떻게 흘러가는지를 충분히 이해하며 개발하는 습관을 더 키워가고 싶다.
오늘의 경험은 그런 방향으로 한 걸음 더 나아간, 꽤 의미 있는 하루였던 것 같다!
얼추 개발이 마무리 되었으니, 이제 기능 구현하면서 놓친 부분들을 자세히 공부해봐야겠다ㅎㅎㅎ
'Project > Robo77' 카테고리의 다른 글
| 로보 77 구현기 최종 회고 (1) | 2025.12.02 |
|---|---|
| 로보 77 구현기 8일 차 - 동작 우선 코드, 객체지향으로 CPR 하기 (0) | 2025.11.14 |
| 로보 77 구현기 7일 차 - 빈 깡통이었던 디스코드 봇에 생명 불어넣기 (0) | 2025.11.13 |
| 로보 77 구현기 6일 차 - Phase 2를 향한 재정비와 첫 디스코드 봇 생성! (0) | 2025.11.12 |
| 로보 77 구현기 2일 차 - 전략 패턴과 씨름한 하루 (0) | 2025.11.08 |