[레벨1] 요청, 도메인 로직, 실행 구조를 분리해보기

yoondgu·2023년 3월 25일
0

우테코 백엔드 5기 과정 중 학습하며 작성한 내용입니다.
주관적인 생각을 정리한 글이며, 의견 및 부족한 부분에 대한 피드백 환영합니다!


레벨1 체스 미션 1, 2단계 에서는 Java로 콘솔 체스 게임을 구현하는데, 이를 위해 여러 개의 명령어에 따라 정해진 로직을 실행하도록 한다.

이에 대하여

  1. 요청: 명령어와 실행 로직 매핑
  2. 실행 구조: 명령어 입력에 따라 알맞은 로직 요청
  3. 도메인 로직: 실제 실행 로직 정의

를 분리하는 작업을 해보았다.

하나의 컨트롤러에 클래스에 위 세 가지를 위한 코드가 모두 함께 있으니 가독성이 떨어지고, 실행 구조를 파악하기 어렵다고 느껴졌기 때문이다.

결과적으로 프론트 컨트롤러 패턴과 유사한 점이 있었다. 그리고 오버 엔지니어링인 이유와 그럼에도 얻을 수 있는 장점에 대해 생각해볼 수 있었다.

이를 정리해보려고 한다.

요구사항

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;
    }

조건문을 삭제함으로써 가독성은 조금 개선되었지만, 사실 큰 차이가 없어보인다.

여전히

  1. Command에 새로운 값이 추가될 때마다, 직접 actionMapper 에 새 엔트리를 추가(put())해주어야 한다. 조건문을 사용했을 때와 같은 문제가 발생하는 것이다.
  2. actionMapper 에 새 엔트리를 추가할 때마다, 이 클래스에도 새로운 메서드를 정의해야 한다.
  3. 명령어 입력을 받는 로직, 명령어와 기능을 매핑하는 로직, 이를 실제로 실행하는 로직이 모두 한 클래스에 있어 쉽게 파악하기 어렵다.

요청, 도메인 로직, 실행 구조를 분리해보자

위와 같이 해결되지 않는 문제점을 해결하고자

  • 체스 게임(도메인) 로직을 직접 실행하는 클래스 ChessController
  • 명령어와, ChessController로부터 호출할 메서드를 매핑하는 클래스 CommandActionMapper
  • 명령어를 입력받은 뒤, CommandActionMapper 를 통해 알맞은 메서드를 호출하는 클래스 FrontController

세 가지로 역할을 분리해보았다.

⚠️ 리팩터링 과정에서 ChessBoard - ChessController 사이에 ChessGame 계층을 추가하고, 새로운 명령어에 대한 기능을 추가하는 등의 변경사항이 있었다. 위 코드와 아래 코드 간 구현 내용에 조금의 차이가 있다. 하지만 구조를 비교하는 데는 문제가 없어보여 그대로 게시한다.

FrontController

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

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

CommandActionMapperChessController 에서 실행할 수 있는 로직들과 명령어를 매핑해준다.

  • 명령어를 추가할 때마다 해당 값에 대한 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;
    }
}

장단점

위와 같이 컨트롤러 단의 구조를 변경해보니, 장점과 단점이 명확했다.

장점

  1. 명령어 입력 ↔ 실제 실행 로직 에 대하여, 관심사를 분리할 수 있다.
  2. 명령어와 그에 따른 기능을 새로 추가해야 할 때, 어느 클래스의 어느 부분을 수정하면 될지 명확하다.
  3. FrontController에는 공통 로직인 명령어 입력, 오류 시 재입력 처리만 남는다.
    • 예시로 올린 코드는 1, 2단계의 코드이다. 하지만 3단계를 진행하면서도 status 조회 기능을 추가해야 했는데, 각 클래스가 역할을 나눠 가지고 있어서 관련 코드를 추가하더라도 한 클래스에만 라인 수가 과도하게 증가할 일이 없어서 좋았다.

단점

  • 한 마디로 정리하자면, 지금 프로젝트(1, 2단계 미션)에서는 오버 엔지니어링이다.
  • 구현하다보니 프론트 컨트롤러 패턴과 유사한 방식이 되었고, 그래서 클래스 이름도 FrontController 로 지었다. 하지만 Spring MVC의 프론트 컨트롤러를 생각해보면 해당 패턴은 다수의 컨트롤러가 존재할 때 효과적이기 때문이다.
    • Spring MVC에서는 FrontController가 공통 코드를 처리하고 요청에 맞는 컨트롤러를 매핑해준다.
    • 이 때, 프론트 컨트롤러의 핵심은 다수의 컨트롤러에 대한 공통 로직의 분리인 것으로 보인다.
    • 하지만 현재 내 코드에서는, 컨트롤러가 하나다. 하나의 컨트롤러에 대해서만 요청을 매핑해주고 있다. 따라서 규모가 큰 구조에 비해 얻는 이점이 아주 크지 않다.

그래도 체스 게임 방 입장 기능, 사용자 별 기록 관리 기능이 4단계 선택 요구사항에 있는데,

이에 대해 다른 컨트롤러를 추가로 만들어야 한다면 충분히 활용성이 있겠다는 생각이 든다.

아직 그 부분을 구현하진 않았지만…ㅎㅎ 그 전에 이렇게 여러 가지 방식으로 실행 로직의 구조를 바꾸어보는 작업이 재미있었고 기억에 남겨두고 싶어서 정리해보았다.

레벨2 때는 Spring을 배우게 될 텐데, 이렇게 서로 다른 요청에 대해 dispatch하는 방식을 만들어본 것도 의미 있다고 생각한다.

이제 다시 3, 4단계 미션도 마저 잘 마무리해봐야지 ~~

참고 링크

[MVC] 프론트 컨트롤러 패턴

0개의 댓글