God Object를 해체하고 설계 개선하기
게임이 작동하고 버그도 대부분 고쳤습니다. 기능 구현은 어느 정도 완성되었고 이제 코드를 제대로 정리할 시간이었습니다.
먼저 GameService.java 파일을 열어보니, 예상했던 대로 500줄이 넘어가 있었습니다.
GameService의 문제
GameService가 하는 일을 나열해봤습니다.
- 플레이어 세션 생성 및 제거
- 닉네임 중복 검증
- 대기열에 플레이어 추가
- 4명이 모이면 게임룸 생성
- 게임 시작 및 라운드 진행
- 게임 종료 후 정리 스케줄링
하나의 클래스가 너무 많은 책임을 가지고 있었습니다. 세션 관리, 매칭, 게임룸 생성 등 변경 이유가 여러 개였고, 프리코스에서 익힌 단일 책임 원칙(SRP)을 적용할 시점이었습니다.
변경 이유를 기준으로 3개 서비스로 분리했습니다.
PlayerSessionService: 세션 생성 및 관리
public class PlayerSessionService {
private final SessionManager sessionManager;
public void createSession(String sessionId, Socket socket) {
// 세션 생성
}
public void removeSession(String sessionId) {
// 세션 제거
}
}
MatchingService: 대기열 및 매칭
public class MatchingService {
private final WaitingQueue waitingQueue;
public void joinQueue(Player player) {
// 대기열 추가
}
public boolean isReadyToStart() {
return waitingQueue.size() == 4;
}
}
GameRoomService: 게임룸 생성 및 스케줄링
public class GameRoomService {
private final Map<String, GameRoom> gameRooms;
public GameRoom createGameRoom(List<Player> players) {
// 게임룸 생성
}
public void scheduleCleanup(String roomId) {
// 10초 후 정리
}
}
Controller의 역할
3개 서비스로 나누고 나니 서비스들 사이의 협력을 누가 관리할지 결정해야 했습니다. Controller가 흐름을 제어하도록 설계했습니다.
public class GameController {
private final PlayerSessionService sessionService;
private final MatchingService matchingService;
private final GameRoomService gameRoomService;
public void handlePlayerJoin(String sessionId, String nickname, String mode) {
Player player = sessionService.createPlayer(sessionId, nickname);
matchingService.joinQueue(player);
if (matchingService.isReadyToStart()) {
List<Player> players = matchingService.getPlayers();
GameRoom room = gameRoomService.createGameRoom(players);
room.start();
}
}
}
Controller는 개별 서비스의 내부를 모릅니다. 각 서비스에게 "무엇을 해라"고 요청만 합니다.
도메인과 인프라 의존성
서비스를 분리하고 나니 또 다른 문제가 보였습니다.
GameRoom 도메인 클래스가 SessionManager를 직접 의존하고 있었습니다.
public class GameRoom {
private final SessionManager sessionManager; // 인프라 계층
private void broadcastToPlayers(String message) {
for (Player player : players.getPlayers()) {
sessionManager.sendTo(player.getNickname(), message);
}
}
}
도메인은 비즈니스 로직만 담당해야 하는데, "어떻게 메시지를 전송하는가"는 인프라의 관심사입니다. WebSocket 대신 다른 프로토콜을 사용하게 되면 도메인도 수정해야 하는 구조였습니다.
의존성 역전 원칙(DIP)을 적용해 인터페이스로 추상화했습니다.
GameEventPublisher 인터페이스를 만들었습니다.
public interface GameEventPublisher {
void publish(String nickname, String message);
void publishToAll(List<String> nicknames, String message);
boolean hasActiveSession(String nickname);
boolean hasSession(String nickname);
}
GameRoom은 이제 인터페이스만 의존합니다.
public class GameRoom {
private final GameEventPublisher eventPublisher;
private void broadcastToPlayers(String message) {
for (Player player : players.getPlayers()) {
String nickname = player.getNickname();
if (eventPublisher.hasActiveSession(nickname)) {
eventPublisher.publish(nickname, message);
}
}
}
}
인터페이스의 구현체는 인프라 계층에 둡니다.
public class WebSocketGameEventPublisher implements GameEventPublisher {
private final SessionManager sessionManager;
@Override
public void publish(String nickname, String message) {
sessionManager.sendTo(nickname, message);
}
@Override
public void publishToAll(List<String> nicknames, String message) {
for (String nickname : nicknames) {
sessionManager.sendTo(nickname, message);
}
}
@Override
public boolean hasActiveSession(String nickname) {
return sessionManager.hasActiveSession(nickname);
}
@Override
public boolean hasSession(String nickname) {
return sessionManager.exists(nickname);
}
}
이제 GameRoom은 WebSocket이나 SessionManager를 몰라도 됩니다. 그저 "메시지를 발행"할 뿐입니다.
Singleton 제거
서비스들을 분리하면서 또 하나 고민해야 할 부분이 있었습니다. "SessionManager를 어떻게 관리할 것인가?"
초기에는 SessionManager를 Singleton 패턴으로 만들었습니다.
public class SessionManager {
private static SessionManager instance;
private SessionManager() {}
public static SessionManager getInstance() {
if (instance == null) {
instance = new SessionManager();
}
return instance;
}
}
어디서든 SessionManager.getInstance()만 호출하면 사용할 수 있어서 편했습니다.
테스트의 어려움
Singleton의 진짜 문제는 테스트를 작성할 때 드러났습니다.
@Test
void testGameRoom() {
SessionManager manager = SessionManager.getInstance();
// 이전 테스트의 세션이 남아있음!
// manager에는 다른 테스트의 데이터가 섞여있음
}
Singleton은 전역 상태이기 때문에 한 번 생성되면 프로그램이 종료될 때까지 살아있습니다. 이는 테스트 격리를 불가능하게 만들었습니다.
예를 들어 테스트 A에서 세션을 추가하면 테스트 B에서도 그 세션이 그대로 남아있어, 테스트들이 서로 영향을 주게 됩니다. 더 심각한 문제는 GameRoom이 SessionManager에 의존한다는 사실이 코드 안에 숨어있어서, 생성자나 메서드 파라미터만 봐서는 이 의존 관계를 전혀 알 수 없다는 점이었습니다.
생성자 주입으로 변경
Singleton을 제거하고 생성자 주입 방식으로 바꿨습니다.
Before:
public class GameRoom {
public void broadcastMessage(String message) {
SessionManager.getInstance().broadcast(message);
}
}
After:
public class GameRoom {
private final GameEventPublisher eventPublisher; // 의존성 명시!
public GameRoom(GameEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void broadcastMessage(String message) {
eventPublisher.publishToAll(message);
}
}
이제 GameRoom을 만들 때 의존성을 넘겨줘야 합니다.
GameEventPublisher eventPublisher = new WebSocketGameEventPublisher(sessionManager);
GameRoom room = new GameRoom(eventPublisher);
생성자만 봐도 이 클래스가 어떤 것들을 필요로 하는지 알 수 있습니다. 테스트 시에는 Mock 객체를 주입할 수 있습니다.
스케줄링 책임 분리
게임 라운드를 1초마다 자동으로 진행하는 기능을 만들면서 새로운 고민이 생겼습니다. "ScheduledExecutorService를 도메인에 넣어야 할까, 서비스에 넣어야 할까?"
처음에는 게임룸이 게임을 진행하니까 당연히 GameRoom이 스케줄링도 담당해야 한다고 생각했습니다.
public class GameRoom {
private final ScheduledExecutorService scheduler;
public void start() {
scheduler.scheduleAtFixedRate(() -> playOneRound(), 1, 1, TimeUnit.SECONDS);
}
}
코드를 작성하고 실행해보니 문제없이 작동했습니다. 하지만 "ScheduledExecutorService가 정말 도메인의 일부일까?"라는 의문이 들었습니다.
"1초마다 실행"은 도메인이 아닌 인프라의 관심사였습니다. ScheduledExecutorService를 GameRoomService로 옮겼습니다.
GameRoom - 도메인:
public class GameRoom {
public boolean playNextRound() {
if (round.isLast()) {
endGame();
return false; // 게임 종료
}
round = round.next();
players.moveAll();
printResult();
return true; // 게임 계속
}
}
GameRoomService - 서비스:
public class GameRoomService {
private void startGameLoop(GameRoom room) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
executor.scheduleAtFixedRate(() -> {
boolean continueGame = room.playNextRound();
if (!continueGame) {
executor.shutdown();
}
}, 1, 1, TimeUnit.SECONDS);
}
}
GameRoom은 "다음 라운드를 진행한다"만 제공합니다. 서비스가 "1초마다 호출한다"를 결정합니다.
테스트 시 playNextRound()만 직접 호출하면 되어서 스레드 없이 동기적으로 테스트할 수 있게 되었습니다.