우테코 백엔드 5기 과정 중 학습하며 작성한 내용입니다.
주관적인 생각을 정리한 글이며, 의견 및 부족한 부분에 대한 피드백 환영합니다!
레벨1 체스 미션 1, 2단계 에서는 Java로 콘솔 체스 게임을 구현하는데, 이를 위해 여러 개의 명령어에 따라 정해진 로직을 실행하도록 한다.
이에 대하여
를 분리하는 작업을 해보았다.
하나의 컨트롤러에 클래스에 위 세 가지를 위한 코드가 모두 함께 있으니 가독성이 떨어지고, 실행 구조를 파악하기 어렵다고 느껴졌기 때문이다.
결과적으로 프론트 컨트롤러 패턴
과 유사한 점이 있었다. 그리고 오버 엔지니어링인 이유와 그럼에도 얻을 수 있는 장점에 대해 생각해볼 수 있었다.
이를 정리해보려고 한다.
1, 2단계에서 주어진 요구사항에 따르면 명령어에 따라 실행해야 할 로직은 아래와 같다.
start
입력move
입력end
입력(end를 입력하면 어플리케이션이 종료되는 것이 아니라, 해당 게임을 종료하고 다시 대기 상태로 돌아가도록 하였다.)
처음에는 ‘상태 패턴’을 적용해보고자 했다.
아래와 같이 Ready
, Running
이라는 상태 객체가 각자의 상태에 맞게 로직을 수행하도록 했다.
public class Running implements GameState {
private static final String WRONG_CALLING_ERROR_MESSAGE = "이미 시작된 게임입니다.";
private final ChessBoard chessBoard;
public Running(final ChessBoard chessBoard) {
this.chessBoard = chessBoard;
}
@Override
public GameState start(final ChessBoard chessBoard) {
throw new IllegalStateException(WRONG_CALLING_ERROR_MESSAGE);
}
@Override
public GameState move(final CommandRequest commandRequest) {
// ChessBoard의 도메인 로직 실행
return new Running(chessBoard);
}
@Override
public GameState end() {
return new Ready();
}
@Override
public Map<Position, Piece> read() {
return chessBoard.piecesByPosition();
}
}
하지만 start
, move
, end
는 서로 독립적인 핵심 도메인 로직이 아니라 서로 연관된 흐름에 있는 분기이다.
move
메서드에서 chessBoard
의 상태가 변경되는 것 외에는, 다음 상태를 반환해주는 로직만이 존재한다.
단순히 “상태 변경”만 해주고 있는 것이다.
따라서, 상태 패턴을 효과적으로 사용할 수 있는 상황이 아니고
오히려 코드를 보았을 때, 구조를 바로 파악하기 어려워졌다고 판단했다.
리뷰어님이 남겨주신 코멘트 덕분에 이와 같이 생각할 수 있게 되었다!
패턴에 사로잡히지 말고 내용에만 집중해보자.
가장 단순하게 구현하면, 조건문으로 분기를 표현할 수 있다.
// ChessController 클래스
private final InputExceptionHandler inputExceptionHandler;
private GameStatus gameStatus;
private ChessBoard chessBoard;
public ChessController() {
this.inputExceptionHandler = new InputExceptionHandler(OutputView::printInputErrorMessage);
this.gameStatus = READY;
}
// 최종 실행 로직, 재입력 처리 로직 생략
private GameStatus executeInputCommand(CommandRequest commandRequest) {
Command command = commandRequest.getCommand();
gameStatus.validateCommand(command);
if (command == Command.START) {
return start();
}
if (command == Command.MOVE) {
return move(commandRequest);
}
return end();
}
// 조건문에 따라 실행할 로직들
private GameStatus start(CommandRequest commandRequest) {
// 체스판을 초기화하고, 체스판 상태를 출력한다.
return RUNNING;
}
private GameStatus move(CommandRequest commandRequest) {
// 요청에 따라 말을 이동시키고, 체스판 상태를 출력한다.
return RUNNING;
}
private GameStatus end(CommandRequest commandRequest) {
// 체스판을 초기화하고, 체스 게임 안내 메시지를 출력한다.
return READY;
}
하지만 조건문의 가독성이 좋지 않다고 느껴져,
실행할 로직들의 함수를 Map에 매핑해두고, 꺼내 쓸 수 있도록 바꿔보았다.
// ChessController 클래스
private final InputExceptionHandler inputExceptionHandler;
private final Map<Command, CommandAction> actionMapper = new EnumMap<>(Command.class);
private GameStatus gameStatus;
private ChessBoard chessBoard;
public ChessController() {
this.inputExceptionHandler = new InputExceptionHandler(OutputView::printInputErrorMessage);
this.gameStatus = READY;
actionMapper.put(Command.START, this::start);
actionMapper.put(Command.MOVE, this::move);
actionMapper.put(Command.END, this::end);
}
// 최종 실행 로직, 재입력 처리 로직 생략
private GameStatus execute(CommandRequest commandRequest) {
gameStatus.validateCommand(commandRequest);
CommandAction action = actionMapper.getOrDefault(commandRequest.getCommand(),
request -> {
throw new IllegalArgumentException("해당 요청으로 실행할 수 있는 기능이 없습니다.");
});
return action.apply(commandRequest);
}
// 조건문에 따라 실행할 로직들
private GameStatus start(CommandRequest commandRequest) {
// 체스판을 초기화하고, 체스판 상태를 출력한다.
return RUNNING;
}
private GameStatus move(CommandRequest commandRequest) {
// 요청에 따라 말을 이동시키고, 체스판 상태를 출력한다.
return RUNNING;
}
private GameStatus end(CommandRequest commandRequest) {
// 체스판을 초기화하고, 체스 게임 안내 메시지를 출력한다.
return READY;
}
조건문을 삭제함으로써 가독성은 조금 개선되었지만, 사실 큰 차이가 없어보인다.
여전히
Command
에 새로운 값이 추가될 때마다, 직접 actionMapper
에 새 엔트리를 추가(put()
)해주어야 한다. 조건문을 사용했을 때와 같은 문제가 발생하는 것이다.actionMapper
에 새 엔트리를 추가할 때마다, 이 클래스에도 새로운 메서드를 정의해야 한다.명령어 입력을 받는 로직
, 명령어와 기능을 매핑하는 로직
, 이를 실제로 실행하는 로직
이 모두 한 클래스에 있어 쉽게 파악하기 어렵다.위와 같이 해결되지 않는 문제점을 해결하고자
ChessController
ChessController
로부터 호출할 메서드를 매핑하는 클래스 CommandActionMapper
CommandActionMapper
를 통해 알맞은 메서드를 호출하는 클래스 FrontController
세 가지로 역할을 분리해보았다.
⚠️ 리팩터링 과정에서
ChessBoard
-ChessController
사이에ChessGame
계층을 추가하고, 새로운 명령어에 대한 기능을 추가하는 등의 변경사항이 있었다. 위 코드와 아래 코드 간 구현 내용에 조금의 차이가 있다. 하지만 구조를 비교하는 데는 문제가 없어보여 그대로 게시한다.
FrontController
는 공통 로직으로 명령어를 입력받고, 이에 따른 로직을 실행하는 역할을 한다.
ChessController
를 가지고 있다.public class FrontController {
private static final ChessController chessController = new ChessController();
private static final InputExceptionHandler inputExceptionHandler = new InputExceptionHandler(
OutputView::printInputErrorMessage);
private static AppStatus appStatus = AppStatus.RUNNING;
private FrontController() {
}
public static void run() {
OutputView.printGuideMessage();
while (appStatus == AppStatus.RUNNING) {
appStatus = inputExceptionHandler.retryExecuteIfInputIllegal(
InputView::requestGameCommand, chessController, CommandActionMapper::execute
);
}
}
}
ChessController
는 ChessGame 이라는 도메인에 대한 세부 로직을 실행한다.
public class ChessController {
private ChessGame chessGame;
public ChessController() {
this.chessGame = new ChessGame();
}
public AppStatus start(CommandRequest commandRequest) {
// 체스 게임의 상태를 실행중으로 변경하고, 체스판을 출력한다.
return AppStatus.RUNNING;
}
public AppStatus move(CommandRequest commandRequest) {
// 요청에 따라 체스 게임의 말을 움직이고, 체스판을 출력한다.
return AppStatus.RUNNING;
}
public AppStatus end(CommandRequest commandRequest) {
// 체스 게임의 상태를 대기중으로 변경하고, 안내메시지를 출력한다.
return AppStatus.RUNNING;
}
public AppStatus forceQuit(CommandRequest commandRequest) {
// 어플리케이션을 종료한다.
return AppStatus.TO_EXIT;
}
}
CommandActionMapper
는 ChessController
에서 실행할 수 있는 로직들과 명령어를 매핑해준다.
CommandAction
도 강제로 정의해야 하므로 조건문 분기에서의 문제점이 해결된다.ChessController
인스턴스는 외부에서 전달받으므로 책임을 분리할 수 있다.public enum CommandActionMapper {
START("start", ChessController::start),
MOVE("move", ChessController::move),
END("end", ChessController::end),
EXIT("exit", ChessController::forceQuit);
private static final Map<String, CommandAction> actionByCommand = Arrays.stream(values())
.collect(Collectors.toMap(value -> value.command, value -> value.commandAction));
private final String command;
private final CommandAction commandAction;
CommandActionMapper(final String command, final CommandAction commandAction) {
this.command = command;
this.commandAction = commandAction;
}
public static AppStatus execute(ChessController chessController, CommandRequest commandRequest) {
CommandAction action = actionByCommand.getOrDefault(commandRequest.getCommand(),
(controller, request) -> {
throw new IllegalArgumentException("해당 요청으로 실행할 수 있는 기능이 없습니다.");
});
return action.execute(chessController, commandRequest);
}
public String getCommand() {
return command;
}
}
위와 같이 컨트롤러 단의 구조를 변경해보니, 장점과 단점이 명확했다.
status
조회 기능을 추가해야 했는데, 각 클래스가 역할을 나눠 가지고 있어서 관련 코드를 추가하더라도 한 클래스에만 라인 수가 과도하게 증가할 일이 없어서 좋았다.FrontController
로 지었다. 하지만 Spring MVC의 프론트 컨트롤러를 생각해보면 해당 패턴은 다수의 컨트롤러가 존재할 때 효과적이기 때문이다.다수의 컨트롤러에 대한 공통 로직의 분리
인 것으로 보인다.그래도 체스 게임 방 입장 기능, 사용자 별 기록 관리 기능이 4단계 선택 요구사항에 있는데,
이에 대해 다른 컨트롤러를 추가로 만들어야 한다면 충분히 활용성이 있겠다는 생각이 든다.
아직 그 부분을 구현하진 않았지만…ㅎㅎ 그 전에 이렇게 여러 가지 방식으로 실행 로직의 구조를 바꾸어보는 작업이 재미있었고 기억에 남겨두고 싶어서 정리해보았다.
레벨2 때는 Spring을 배우게 될 텐데, 이렇게 서로 다른 요청에 대해 dispatch하는 방식을 만들어본 것도 의미 있다고 생각한다.
이제 다시 3, 4단계 미션도 마저 잘 마무리해봐야지 ~~
참고 링크