우테코 프리코스 미션 #02 | Baseball Game

개요

미션 수행 github 링크

이번 프로젝트는 아래와 같이 콘솔에서 JS로 숫자 야구게임을 구현하는 것이었다.

숫자 야구 게임을 시작합니다.
숫자를 입력해주세요 : 123
1볼 1스트라이크
숫자를 입력해주세요 : 145
1볼
숫자를 입력해주세요 : 671
2볼
숫자를 입력해주세요 : 216
1스트라이크
숫자를 입력해주세요 : 713
3스트라이크
3개의 숫자를 모두 맞히셨습니다! 게임 종료
게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.
1
숫자를 입력해주세요 : 123
1볼
...

JS컨벤션, TDD, 네이밍 등 많은 것들을 고려했지만 객체중심 사고를 하는 프로그래밍을 하는 것에 우선적으로 프로젝트를 진행했다.

접근 방식

🔍 도메인 설계

야구 게임에서 협력하는 객체들은 무엇이 있을지 고민하는데 가장 많은 시간을 소비했다. 아무리 생각해도 BaseballGame이라는 객체 하나밖에 생각이 나지 않았다.

하지만 그렇게 되면 BaseballGame은 랜덤한 숫자를 만들어야 되는 책임과, 들어온 값과 비교해 결과를 알려줘야 되는 책임, 즉 2개의 책임이 생기게 된다고 판단했다.

img
설계한 도메인 모델

이를 위해서 입력된 값과 현재 게임에 할당된 랜덤한 숫자를 비교해 결과를 반환하는 책임을 가진 Referee(심판) 객체를 추가하게 되었고, 추후에 결과에 대해서 문자열로 변환해 주는 책임을 가진 Judgement 객체를 추가했다.

결국 클라이언트는 Referee에게 judge를 요청하고 Referee는 Game과 값을 비교해 최종적으로 Judgement 인스턴스를 반환하는 로직을 설계했다.


🔍 왜 객체지향인가

설계를 시작하기 앞서 가장 먼저 든 생각은 ’왜 객체지향인가‘였다. App.js가 class로 구현되어 있기 때문에 당연하다는 듯이 객체지향 적으로 설계를 하고 싶지는 않았고, 이것을 어떤 방식으로 구현할지 이유와 그에 맞는 설계를 하고 싶었다.

게임에는 “상태”가 있었다. command를 입력받는 방식에는 3자리 숫자만 받는 것이 아닌, 게임이 끝났을 때 이어서 할 건지에 대한 여부를 입력하는 방식도 있었다.

뿐만 아니라 현재 게임이 끝나고 새로운 게임이 시작되었을 때 정답 숫자가 달라진다는 상태도 있기 때문에 이 “상태”를 효율적으로 관리하기 위해서 객체지향으로 설계를 시작했다.


🔍 MVC 패턴

파일들이 많아지게 되면 나중에 코드 관리가 어려워질 것 같아서 파일들을 의미 있게 나눠야 된다고 생각했고, 이 이유로 MVC패턴을 적용하려 했다.

App.js에서는 Controller에서 제공해 주는 함수만 사용하고, Model과 View는 서로를 알 지 못한 채로 Controller가 이들을 잘 활용해서 커뮤니케이션을 할 수 있도록 코드를 작성했다.

img
MVC 패턴의 흐름도

그러다가 ‘유효성 검사는 어디서 해야 되는가’에 대한 고민이 생겼고, Model, View, Controller 모두 유효성 검사를 하는 게 안전할 거라는 결론을 내렸다.

하지만 지금 당장에는 Controller에서만 유효성 검사를 해줘도 놓치는 부분 없이 구현할 수 있을 거란 생각해 Controller에 유효성 검사를 하도록 설계를 했다.


그리고 Model과 View의 분리에 대해서 생각을 해봤다.

“숫자를 입력하세요 : “부분은 View에서 띄워줘야 한다고 생각하지만 입력을 받았을 때 처리되는 처리되는 로직은 Contoller에 작성되어야 한다고 생각했다.

그러한 이유로 입력을 받았을 때 처리되는 handler들은 Contoller에 작성해서 this는 바인딩 하게 했다.

Refactoring

🛠 클래스 전달

JS Doc을 통해 아래와 같이 작성했었다.

class Controller {
  /**
   * @param {View} view
   */
  constructor(view) {
    this._view = new view();
  }
}

const controller = new Contoller(View);

그랬더니 'View' 형식에 구문 시그니처가 없습니다.ts(2351) 와 같은 에러가 발생했다.

에러를 검색해보니 Construct signature 생성(new 키워드를 가지고있는 함수, 즉 생성자함수를 정의해주는데, 생성되는 클래스가 필요한 변수를 받고 참조할 interface를 반환해준다.) 방식으로 해결한 방법이 있었는데, 일단은 constructor에 view 클래스를 전달하는 방식이 아닌 전역으로 require한 View를 생성하도록 작성했다.

아직은 class에 익숙하지도 않고, DI 이런것들도 몰라서 어떤 방법이 좋은 방법인지는 모르기 때문에 열심히 찾아봐야겠다.


♻️ isValid 변수 추출

원래 isValid메서드는 아래와 같이 작성했다.

//Controller.js
_isValid(num) {
  if (!!Number(num) && +num > 0 && +num < 1000 //숫자 & 범위 체크
      && !num.split("").some((item) => 
          num.indexOf(item) !== num.lastIndexOf(item)))
    return true;
  return false;
}

다른 분들의 코드리뷰를 통해 if문안의 조건들을 변수로 추출하고, 정규식을 사용하는 방식으로 refactoring을 했다. 최종적으로

const isCorrectNumber = /\d/.test(num) && +num > 0 && num.length === 3;
const isNotDuplicate = num.length === [...new Set(num)].length;
if (isCorrectNumber && isNotDuplicate)
  return true;
return false;

이렇게 작성했는데 그래도 뭔가 아쉽다. Validator를 나누는 건 내게 아직 너무 어려운 일이다.


♻️ binding

//View.js
this._console.readLine(View.#PROMPT_END, (command) => {
	this._endHandler(command);
});

현재 객체의 _endHandler를 호출해야 했기 때문에 별 생각없이 위처럼 작성했다. 하지만

this._console.readLine(View.#PROMPT_END, this._endHandler.bind(this));

이렇게 작성하니 가독성이 좋아졌다.


♻️ handler 분리

//Controller.js
constructor() {
  this._view = new View((command) => {
    if (!this._isValid(command)) throw new Error("입력을 잘못 하셨네요 1에서 9 중복되지 않게 3자리");
    const judgement = this._referee.judge(command.split("").map((item) => +item));
    this._next(judgement.isAllStrike() ? GAME_STATE.END : GAME_STATE.ING, judgement.toString());
  }, (command) => {
    if (command === "1") this.start(GAME_STATE.ING);
    else if (command === "2") require("@woowacourse/mission-utils").Console.close();
    else throw new Error("입력을 잘못 하셨네요 1 또는 2");
  });
}

원래 constructor안에 handler들을 전부 작성했다. 하지만 가독성이 현저하게 떨어져서 private 메서드로 분리했다.

constructor() {
  this._referee = new Referee();
  this._view = new View(this._ingHandler.bind(this), this._endHandler.bind(this));
}

_ingHandler(command) {
  if (!Controller.isValid(command))
    throw new Error("입력을 잘못 하셨네요 1에서 9 중복되지 않게 3자리");
  const balls = command.split("").map((item) => +item);
  const judgement = this._referee.judge(balls);
  this._next(judgement.isAllStrike() ? GAME_STATE.END : GAME_STATE.ING, judgement.toString());
}

_endHandler(command) {
  if (command === "1")
    this.start(GAME_STATE.RE);
  else if (command === "2")
    Console.close();
  else
    throw new Error("입력을 잘못 하셨네요 1 또는 2");
}

this를 binding 해주었기 때문에 메서드를 분리해도 문제가 없다.

마치며

모델이 설계되고 해당 모델에 대한 테스트코드를 작성해봤다. Referee는 Judgement와 Game클래스에 의존적이기 때문에 Game -> Judgement -> Referee 순으로 작성되었다.

이번에 알게 된 건 100% 상향식은 없다는 것이었다. 설계를 미리 해놨다고 하더라도 각 모듈이 제공해야 할 기능을 명확하게 알기는 어렵다. 하향식으로 진행을 하다가 전체적인 틀은 어느 정도 잡아지면 상향식으로 (테스트 코드를 작성 → 구현) 진행하는 게 효율적인 것을 경험할 수 있었다.

아쉬운 점은 아직 객체지향을 잘 활용하지 못해 if 분기 처리가 남았다는 점이다. 이 부분을 다형성을 통해 해결하도록 리팩터링을 진행할 예정이다.