팀 프로젝트로 보드게임 형식의 게임을 만들게 되었다. 게임을 진행하며 각각의 Game 상태 값에 따른 제한시간 로직을 넣어야 했다. 만약 제한시간 내에 어떤 동작을 수행하지 않았다면 강제로 화면을 전환시켜야 했다. 하지만 강제로 화면을 전환시키기 전에 처리해야 할 로직을 수행하고 넘겨야 했다.
예를 들어 게임 시작 전 공격 무기를 100초 이내에 선택해야 하는 경우가 있었는데, 제한 시간이 모두 지난 경우에는 공격 무기를 랜덤으로 지정해주고 다음 화면으로 넘어가야 했다. 다른 경우로는 공격자가 제한시간 내 공격을 수행하지 않으면 다음 턴의 공격자에게 기회를 넘겨주어야 했다.
따라서 제한시간 로직을 크게 쪼개면
각 게임 상태(Status) 별로 처리해야 할 로직은 달랐지만, 1~3번 로직은 모두 동일했다. 따라서 이러한 공통 부분과 Status 별로 처리하는 로직을 분리할 수 있는 방법을 찾아보았고, 마침 책에서 본 디자인 패턴이 떠올라 적용해보았다.
템플릿 메소드에 대한 설명은 아래의 글을 읽어보면 좋을 것 같다. 아래 글을 기반으로 간략한 설명만 하도록 한다.
https://niceman.tistory.com/142
이 패턴은 어떤 작업에 대한 전체적인 알고리즘은 상위 클래스에서 구현하고, 알고리즘 내의 특정 상태에 따라 달라지는 부분은 서브 클래스로 캡슐화하는 방식이다. 이러한 방식을 통해 전체적인 알고리즘을 하나의 상위 클래스에서 관리할 수 있으며, 상태에 따라 변경되는 로직은 서브 클래스에서 각각의 상태에 알맞은 로직을 구현하면 된다.
따라서 코드의 중복을 효과적으로 줄일 수 있고, 핵심 로직에 대한 관리와 확장이 용이하다.
객체 생성 부분을 Factory라는 클래스로 떼어냈다. 이 덕분에 상위 클래스는 인스턴스 생성 방식에 대해 전혀 알 필요가 없기 때문에 더 많은 유연성을 가질 수 있다. 또한, 객체 생성 로직이 따로 분리되어 있기 때문에 유지보수성이 증가한다. 사용 예시는 아래를 참고하자.
https://niceman.tistory.com/143
제한시간 로직의 Template 클래스이다. 전체적인 로직을 구현했으며 변경이 있는 부분만 추상 메소드로 선언해 놓은 추상 클래스이다.
@Slf4j
public abstract class TimeLimitServiceTemplate {
private GameRedisRepository redisRepository;
private GameLockUtil gameLockUtil;
public TimeLimitServiceTemplate(GameRedisRepository redisRepository, GameLockUtil gameLockUtil) {
this.redisRepository = redisRepository;
this.gameLockUtil = gameLockUtil;
}
public void executeTimeLimit(long gameId, SendMessageService sendMessageService, GameStatus preStatus) {
InGame preInGame = getInGame(gameId);
int preTurn = preInGame.getTurnNumber();
try {
//1. 제한시간만큼 대기
waitForTimeLimit(preStatus);
//2. 제한시간 전에 화면전환이 이뤄진 경우 return
InGame curInGame = getInGame(gameId);
if (curInGame.getTurnNumber() != preTurn || !preStatus.equals(curInGame.getGameStatus())) {
return;
}
try {
//3. Lock
gameLockUtil.dataLock(gameId, 1, 5);
log.info(" [Start Time Out] : status = {}", curInGame.getGameStatus().name());
//4. 화면 강제전환 후 제한시간 로직 수행
sendMessageService.convertCall(gameId);
runLimitLogic(gameId);
} finally {
//5. UnLock
gameLockUtil.dataUnLock(gameId);
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// Status에 따라 달라지는 제한시간 로직을 추상 메소드로 분리
public abstract void runLimitLogic(long gameId);
private void waitForTimeLimit(GameStatus preStatus) throws InterruptedException {
int time = (int) preStatus.getLimitMilliSeconds() / 200;
for (int i = 0; i < time; i++) {
Thread.sleep(190);
}
}
private InGame getInGame(long gameId) {
return redisRepository.getInGame(gameId)
.orElseThrow(() -> new CustomWebSocketException(INGAME_IS_NOT_EXIST));
}
}
추상 메소드로 선언한 부분을 오버라이드 하여 알맞게 구현한 하위 클래스이다.
@Service
public class DefenseTimeLimitService extends TimeLimitServiceTemplate {
private final GameRedisRepository redisRepository;
public DefenseTimeLimitService(GameRedisRepository redisRepository, GameLockUtil gameLockUtil) {
super(redisRepository, gameLockUtil);
this.redisRepository = redisRepository;
}
@Override
public void runLimitLogic(long gameId) {
InGame inGame = getInGame(gameId);
TurnData turnData = inGame.getTurnData();
DefendData defendData = turnData.getDefenseData();
defendData.defendPass();
redisRepository.saveInGame(gameId, inGame);
}
private InGame getInGame(long gameId) {
return redisRepository.getInGame(gameId)
.orElseThrow(() -> new CustomWebSocketException(INGAME_IS_NOT_EXIST));
}
}
GameStatus를 인자로 받아 적절한 인스턴스를 반환해준다.
@RequiredArgsConstructor
@Component
public class TimeLimitServiceFactory {
private final PrepareTimeLimitService prepareTimeLimitService;
private final DefenseTimeLimitService defenseTimeLimitService;
private final DefaultTimeLimitService defaultTimeLimitService;
public TimeLimitServiceTemplate getTimeLimitService(GameStatus gameStatus) {
switch (gameStatus) {
case PREPARE:
return prepareTimeLimitService;
case DEFENSE:
return defenseTimeLimitService;
default:
return defaultTimeLimitService;
}
}
}
@RequiredArgsConstructor
@Service
public class SendMessageService {
private final TimeLimitServiceFactory timeLimitServiceFactory;
// ...
public void proceedCall(long gameId, long delayMillis) {
try {
Thread.sleep(delayMillis);
GameStatus gameStatus = gameConvertUtil.getGameStatus(gameId);
sendData(gameId, "/proceed", ProceedResponse.toDto(gameStatus));
**TimeLimitServiceTemplate timeLimitService = timeLimitServiceFactory.getTimeLimitService(gameStatus);
timeLimitService.executeTimeLimit(gameId, this, gameStatus);**
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// ...
}
프로젝트를 진행하면서 요구 사항이 계속 바뀌면서 지속적으로 로직을 수정해야 했다. 하지만 디자인 패턴을 활용하여 코드를 적절하게 분리하고 각각의 책임에 맞게 클래스 구조를 구성 해 놓은 결과 여기저기 코드를 수정할 필요 없이 해당 로직을 담당하는 클래스 한 부분만 수정해도 모두 반영되었다.
객체지향과 디자인패턴을 통한 유지보수성 향상 효과를 확실히 체감하게 되었고 시간 날 때마다 틈틈히 공부하고 적용하려는 노력을 이어나가야 겠다고 생각하게 되었다.