10/30 wooteco: 리팩토링!

그른손·2023년 11월 1일
0
post-thumbnail

이전 포스팅에서 초기 구상과 테스트 통과까지를 다뤘고, 이번에는 목표한 바에 맞춰 리팩토링을 해보려고 한다! 미션에 들어가면서 세웠던 목표는 다음과 같다.

  • 의존성 주입 원칙을 적용해보자 : 외부의 모듈이 필요한 경우 직접 생성하거나 불러오는 대신, 외부에서 생성한 후 주입하게끔 만들자.
  • 과하게 분리하지 말자 : 재사용되지 않는 로직을 분리해서 밖으로 빼지 말자. 분리는 또 다른 의존을 만들어낼 수 있다.
  • 한 함수가 하나의 기능만 하게 만들자 : SRP를 준수하자!

구조 변경

유효성 검사 책임을 inputView로 이동

  • 기존에는 RacingGameController에서 inputView.read~~input 메서드로 입력값을 받아온 후에 이를 validateUtils.validateCarNames로 넘기는 방식이었다.
  • '입력의 유효성을 검증하는 것'은 '입력을 받는 부분'에서 책임져야 할 일이라고 생각해서, inputView의 메서드 내에서 readLineAsync로 받아온 입력값을 validate 메서드로 넘겨 유효성을 검사한 후에 반환하는 식으로 바꿨다.
  async readNamesInput() {
    const input = await Console.readLineAsync(GAME_MESSAGES.carNameQuery);
    const names = input.split(SYMBOLS.comma);
    names.forEach((item) => validateCarName(item));
    return names;
  },
  • 입력값을 사용 가능한 형태(배열, 숫자)로 바꾸는 걸 어 디서 할까 고민했는데, validate에서 배열/숫자로 바꾸고 컨트롤러에서 입력값을 반환받아 또 배열/숫자로 바꾸면 두 번 일하는 거니까, 아예 inputView에서 입력값을 받아올 때 변환(split, parseInt)해서 검사하고/반환하는 식으로 바꿨다. 이러면 한 번만 하면 되니까!

inputView, outputView를 클래스로 만들어 주입하기

  • 단순 객체로 만들었던 inputView, outputView를 클래스화하고, App의 play 메서드에서 인스턴스를 생성해 RacingGameController에 주입하는 식으로 바꿨다.
//App.js
async play() {
  const inputView = new InputView();
  const outputView = new OutputView();
  const racingGame = new RacingGame();
  const racingGameController = new RacingGameController(inputView, outputView, racingGame);
  racingGame.initiate();
  
  //RacingGameController.js
  constructor(inputView, outputView, racingGame) {
    this.#inputView = inputView;
    this.#outputView = outputView;
    this.#racingGame = racingGame
  }
  • 이런 식으로! 이제 inputView와 outputView는 전역에 정의된 객체가 아니라 클래스가 되었고, 컨트롤러의 외부에서 주입되므로 좀 더 유연한 구조를 갖게 되었다.

validateUtils => InputValidator 클래스로

  • validateUtils는 몇 개의 유효성 검사 함수를 포함하는 단순 객체다. InputView에서는 이 객체를 직접 import해서 내부 메서드를 사용하는데, 이러한 의존성을 해결하기 위해 InputValidator라는 별도의 클래스로 정의하기로 했다.
class InputValidator {
  validateCarNames(names) {
    for (const name of names) {
      this.#validateNameLength(name);
      this.#validateBlankName(name);
    }
    this.#validateNoDuplicateNames(names);
  }

  #validateNameLength(name) {
...
  }

  #validateBlankName(name) {
...
  }

  #validateNoDuplicateNames(names) {
...
  }

  validateRoundsNumber(roundsNumber) {
    this.#validateIsNumber(roundsNumber);
    this.#validateNonNegativeValue(roundsNumber);
  }

  #validateIsNumber(value) {
...
  }

  #validateNonNegativeValue(value) {
...
}
  • 그리고 InputView에서 사용할 때는, InputValidator를 인스턴스화하여 주입해줘야 한다.
//InputView.js
class InputView {
  #validator;
  
  constructor(validator){
    this.#validator = validator;
  }

//App.js

async play() {
  const inputValidator = new InputValidator();
  const inputView = new InputView(inputValidator);
  ...
}

이렇게, 외부의 모듈을 직접 불러오는 대신 외부에서 주입받아 사용하도록 바꿔주었다!

RacingCar 인스턴스를 외부에서 주입하기

  • RacingGame에서 names를 받아 RacingCar 인스턴스를 생성하고, 이를 #cars 필드에 넣는 식으로 구현했다. 이렇게 하면 RacingGame이 객체를 직접 생성하는 것이므로, 컨트롤러에서 주입하는 게 더 자연스럽겠다고 생각했다.
//RacingGameController.js
async getCarNames() {
 const names = await this.#inputView.readCarNamesInput();
 const cars = names.map(item => new RacingCar(item));
  this.#racingGame.setCars(cars);
}

RacingCarFactory 클래스 만들기

  • 위의 방법대로 하면, RacingGameController가 RacingCar 인스턴스를 직접 생성하게 된다. 이 의존성을 해결할 방법은 없을지 고민해보았지만, RacingCar 인스턴스는 이름을 받아서 생성해야 하므로, App에서 미리 생성해서 주입하는 건 불가능하다!
  • 고민하다 생각해낸 해결법은, RacingCarFactory라는 이름의 클래스 인스턴스를 외부에서 주입하고, 컨트롤러에서 RacingCar인스턴스를 직접 생성하기보다는 RacingCarFactory의 createRacingCar 메서드로 생성하는 것이다.
//RacingCarFactory.js
class RacingCarFactory {
  createRacingCar(name) {
    return new RacingCar(name);
  }
}

//App.js
async play(){
const racingCarFactory = new RacingCarFactory();
    const racingGameController = new RacingGameController(
      inputView,
      outputView,
      racingGame,
      racingCarFactory
    );
}

//RacingGameController.js
const cars = names.map(item => this.#factory.createRacingCar(item));
this.#racingGame.setCars(cars);                       
  • 이렇게 해도 RacingCarFactory는 RacingCar에 의존하게 된다. 이러면 의존성 역전 아닌가?
  • RacingCarFactory의 목적 자체가 특정 클래스의 인스턴스를 생성하는 것이기 때문에, RacingCar에 의존하는 것은 사실상 불가피하다. 중요한 것은 RacingGameController가 RacingCar의 구체적인 구현에 직접 의존하지 않게 만드는 것이다.
  • 이름을 받아오는 건 컨트롤러에서 이루어지고, RacingCar는 이름을 받아야 생성할 수 있기 때문에, 완벽한 의존성 제거는 어려운 상황이다. 그러나 목적은 변화에 유연하게 대응하는 설계를 만드는 것이므로, RacingCarFactory의 의존성은 괜찮은 트레이드오프라고 생각했다.

CustomError 클래스 만들기

  • 모든 에러 메세지 앞에 [ERROR] 문자열을 일일히 붙이는 대신, CustomError 클래스를 구현하여 붙여주는 방식을 적용해보았다. (지난 주차 미션 때 다른 사람 코드를 보면서 배웠다!)
class CustomError extends Error {
  constructor(message) {
    super(`[ERROR] ${message}`);
    this.name = this.constructor.name;
  }
}

//InputValidator.js
    if (name.trim().length === 0) {
      throw new CustomError(ERROR_MESSAGES.carName.blank);
    }
  • 이제 에러가 발생할 때 Error 대신 CustomError 인스턴스를 던져주면, 모든 에러 메세지 앞에 자동으로 [ERROR]가 붙게 된다!

결과 문자열 줄바꿈 추가 로직 분리

  • 기존에는 RacingGame.moveAllCars에서 모든 차량의 이동 결과를 반환할 때, 맨 끝에 \n을 붙여 줄바꿈을 한번 더 하도록 해뒀다.
  moveAllCars() {
    this.#cars.forEach((car) => car.move());
    return this.#cars.map((car) => car.getMoveResult()).join(`\n`) + '\n';
  }
  • 이 로직을 outputView로 옮겨서, printResult로 결과를 출력할 때 \n을 붙이도록 수정했다.
  printResult(result) {
    Console.print(result + '\n');
  }
  • printResult로 한 라운드의 결과 메세지를 출력할 때마다 개행을 해주는 로직은 게임보단 출력 그 자체에 더 관련성이 있다고 생각해서 이렇게 바꿨다!

RacingCar의 maxPosition, moveResult 분리

  • 기존에는 차량의 결과 문자열만 갖고 있고, 이 길이를 이용해서 현재 위치를 구하는 방식이었는데, 이를 분리해서 maxPosition(숫자) / moveResult(${name} : ---) 이런 식으로 바꿔줬다. 이렇게 되면 게임에서 최대 위치값을 구할 때 length를 일일히 붙여서 확인하지 않아도 되고, 결과 문자열을 name과 합칠 필요 없이 그대로 return하면 된다.

함수 분리 +a

RacingGame: 모든 차량 이동과 이동 결과 반환 함수 분리

  • moveAllCars로 모든 차량을 이동시키고, 동시에 모든 차량의 이동 결과를 \n으로 합쳐서 반환하도록 되어있었던 부분을 두 개의 함수로 분리했다.

RacingGame: 모든 차량의 최대 위치값 구하기 / 우승자 설정하기 함수 분리

  • 기존엔 setWinners함수에서 최대 위치값을 구하고, 우승자를 설정하는 동작을 모두 갖고 있었다.
  • RacingGame에 #maxPosition 필드를 만들고, setMaxPosition과 setWinners로 함수를 분리했다. 그리고 concludeGame 메서드로 위의 두 함수를 호출해서 게임을 마무리하는동작을 하게끔 만들었다.

메세지 줄바꿈을 View에서 담당하도록 변경

-constants.js에서 쿼리 메세지(이름과 시도 횟수를 물어보는 메세지)에 줄바꿈을 포함시켜두었는데, 이게 어색하다고 생각해서 View에서 출력할 때 \n을 추가하도록 바꿨다.

InputValidator: 중복된 for문 삭제

  validateCarNames(names) {
    this.#validateNameLength(names);
    this.#validateBlankNames(names);
    this.#validateUniqueNames(names);
  }

  #validateNameLength(names) {
    for (let name of names) {
      if (name.length > GAME_NUMBERS.carNameMaxLength) {
        throw new CustomError(ERROR_MESSAGES.carName.invalidLength);
      }
    }
  }

  #validateBlankNames(names) {
    for (let name of names) {
      if (name.trim().length === 0) {
        throw new CustomError(ERROR_MESSAGES.carName.blank);
      }
    }
  }
  • validateNameLength와 validateBlankNames에서 각각 한 번씩 for문을 사용하고 있는데, 함수를 수정해서 validateCarNames에서만 한 번 for 문을 사용하도록 바꿔주었다.
  validateCarNames(names) {
    for (let name of names) {
      this.#validateNameLength(name);
      this.#validateBlankName(name);
    }
    this.#validateNoDuplicateNames(names);
  }

RacingGameController: 입력 함수와 입력값 핸들링 함수 분리

  • getNamesInput에서 이름을 입력받고, 입력받은 이름으로 레이싱카를 생성하는 두 개의 동작을 분리했다.
  • const carNames = await this.#inputView.readNamesInput()으로 InputView의 메서드를 직접 이용하여 입력값을 가져오도록 바꿨다.(별도의 함수를 만들어 분리할 필요가 없으므로)
  • 가져온 carNames를 handleCarNamesInput으로 넘겨 레이싱카를 생성하고 RacingGame으로 넘기도록 했다.
  • getRoundsNumber도 동일한 방식으로 분리했다. (handleRoundsNumberInput으로 입력값을 넘기도록)

RacingGameController: 입력 함수를 하나의 함수로

  • initiate 메서드에서 두 개의 입력값을 가져오는 부분을 분리해서 하나의 함수로 합쳐줬다.
 async initiate() {
    const { carNames, rounds } = await this.#getUserInputs();
    this.#setupCarsFromNames(carNames);
    this.#executeRacingRounds(rounds);
    this.#racingGame.concludeGame();
    this.#displayResult();
  }

  async #getUserInputs() {
    const carNames = await inputView.readNamesInput();
    const rounds = await inputView.readRoundsNumberInput();
    return { carNames, rounds };
  }

RacingCar: 난수 생성과 이동 가능 여부 확인 함수 분리

  • canMove메서드에서 난수를 생성하고 이동 가능 여부를 반환하는 두 가지 동작을 두 개의 함수로 분리했다.
  #generateRandomRacingGameNumber() {
    return Random.pickNumberInRange(
      GAME_NUMBERS.rangeMin,
      GAME_NUMBERS.rangeMax,
    );
  }

  #canMove(randomNumber) {
    return randomNumber >= GAME_NUMBERS.movementThreshold;
  }

  move() {
    const randomNumber = this.#generateRandomRacingGameNumber();
    if (this.#canMove(randomNumber)) {
      this.#position += 1;
      this.#moveResult += SYMBOLS.moveIndicator;
    }

이름짓기 타임

동작, 의도를 직관적으로 나타내는 이름을 고민하며 여러 변수명/함수명을 수정했다.

RacingGameController

handleCarNamesInput => setUpCarsFromNames(names)

  • 입력받은 이름을 토대로 레이싱카를 생성하고 게임에 세팅하는 역할

handleRoundsNumberInput => executeRacingRounds

  • 입력받은 시도 횟수만큼 for 반복으로 모든 차량을 움직이고 결과를 출력하는 역할

displayResult => displayWinners

게임 결과를 출력한다기보단 게임 우승자를 출력하는 역할이 더 정확하니까

for 문 반복자 i => round

    for (let round = 0; round < roundsNumber; round += 1) {
      this.#racingGame.moveAllCars();
      const roundResult = this.#racingGame.getAllCarsMovementHistory();
      this.#outputView.printRoundResult(roundResult);
    }

반복자도 변수니까! 한 번의 반복이 한 라운드라는 걸 명시했다!

initiate => runRacingGame

'시작한다'는 포괄적인 의미보다, '레이싱 게임을 실행하는' 동작을 잘 드러내게 바꿨다!

RacingCar

movementResult => moveResult => movementHistory

'이동 결과'보다는 '이동 기록'이 더 정확한 표현이겠다 싶었다. 근데 movementHistory보다는 movementRecord가 더 나을 것 같기도...

RacingCarFactory

createCar => createRacingCar

레이싱카 인스턴스 만드는거니까...

OutputView

printResult => printRoundResult

printResult라고 하면 '전체 결과를 출력'하는 느낌이 더 강한 것 같아서, '한 라운드의 결과를 출력'하는 의미가 잘 드러나도록 바꿨다.

Constants

상수 객체의 구체화

const ERROR_MESSAGES = {
  carName: {
    invalidLength: `자동차 이름은 ${GAME_NUMBERS.carNameMaxLength} 이하만 가능합니다.`,
    duplicate: '자동차 이름은 중복될 수 없습니다.',
    blank: '자동차 이름이 공백입니다.',
  },
  roundsNumber: {
    negativeValue: '시도 횟수는 음수일 수 없습니다.',
    notNumber: '시도 횟수는 숫자만 허용됩니다.',
  },
};

에러 메세지 상수 객체를 구체화(carName, roundsNumber로 묶었다.

SYMBOL 상수 변수명 변경

comma => carNamesSeparator

dash => moveIndicator

divider => playerResultSeparator

commaWithSpace => winnerNameSeparator

const SYMBOLS = {
  comma: ',',
  winnerNameSeparator: ', ',
  dash: '-',
  playerResultSeparator: ' : ',
};

상수의 이름이 형태만 설명하고 있어서, 쓰임새를 명확하게 드러내도록 바꿨다

후기

🤔 이게 맞나? 너무 과하게 추상화(?)한 거 아닌가? 더 복잡해지지 않았나?

🧐 이 정도면 분리를 위한 분리 아닌가?

😠 그럼 대체 언제 어떻게 얼만큼 분리해야 하는거지?

😮‍💨 이름 짓기 너무 어렵다... 그래도 애썼다

profile
프론트엔드 개발자

0개의 댓글