NEXT-STEP 1주차 미션(자동차 경주 - step1)

jiny·2023년 7월 29일
0
post-thumbnail

이번 포스팅은 1주차 미션인 자동차 경주에 대한 구현을 마친 후 어떤 문제점 들과 고민을 갖고 있었고, 그 문제들과 고민들을 어떻게 해결하려고 했는지에 대한 회고글을 작성하고자 한다.

InputView에 대한 고민

InputView.js

const InputView = {
  async createUserInputByQuestion(message) {},
  async getUserInput (message) {},
  processUserInput (userInput, message) {},
  async input (message) {
    const userInput = await getUserInput(message);
    return processUserInput(userInput, message);
  }
}

mvc 중 view에 해당되는 InputView의 경우 입력에 대한 rendering을 담당하는 로직들에 대한 모듈이다.

InputView에 대한 나의 고민은 다음과 같았다.

  1. input 함수를 처리하는 로직을 다른 모듈에서 사용하지 못하게 할 수 없을까?
  2. 1번을 만족하면서 "InputView" 라는 관심사를 유지할 수 있는 방법은 없을까?

1번째 고민에 대한 해답

const createUserInputByQuestion = async (message) => {};

const getUserInput = async (message) => {};

const processUserInput = (userInput, message) => {};

export const async input = (message) => {
  const userInput = await getUserInput(message);
  return processUserInput(userInput, message);
}

다른 모듈에서 사용하지 못하게 할 수 있는 방법은 "객체"가 아닌 개별 함수로 분리하는 방법이 있었다.

GameController.js

static async #getRacingCarNames() {
  const racingCarNames = await input(INPUT_MESSAGE.RACING_CAR);
  return racingCarNames;
}

하지만 이렇게 개별로 분리할 때 InputView.input이 아닌 input으로 호출하기 때문에 이 코드를 읽는 사람은 "이 input이 어느 모듈의 input 함수 일까?"라고 한번 더 생각하게 할 거 같았기에, input 함수에 대한 명확한 관심사를 표현하고 싶었다.

2번째 고민에 대한 해답

모듈 패턴

클래스에 비공개 및 공용 캡슐화를 모두 제공하는 방법을 제공하는 패턴

const InputView = (function InputView() {
  const createUserInputByQuestion = async (message) => {};

  const getUserInput = async (message) => {};

  const processUserInput = (userInput, message) => {};

  return {
    async input(message) {
      const userInput = await getUserInput(message);
      return processUserInput(userInput, message);
    },
  };
})();

IIFE을 이용한 모듈 패턴을 통해 문제를 해결할 수 있었다.

InputView는 즉시 실행함수(IIFE)를 통해 얻어진 객체만 받게 되며 input 함수 이외 다른 함수들은 함수 InputView의 스코프 내에만 존재하게 된다.

static async #getRacingCarNames() {
  const racingCarNames = await InputView.input(INPUT_MESSAGE.RACING_CAR); // O
  const racingCarNames = await InputView.getUserInput(INPUT_MESSAGE.RACING_CAR); // X
  return racingCarNames; 
}

그렇기 때문에 외부에선 input 함수만 호출 가능하며 다른 함수를 호출 시 TypeError가 발생하게 된다.

정리하자면 모듈 패턴이 주는 이점은 다음과 같다.

  1. InputView 관심사 내 private & public 변수, 메서드 들을 캡슐화 할 수 있다.
  2. 캡슐화가 잘 이뤄졌기 때문에 외부에서 사용할 때 사용하는 값들이 어떠한 도메인인지 명확하게 파악할 수 있다.

하나의 함수에는 하나의 책임을 부여하기

"대부분의 함수가 각각의 역할만 수행하는 것이 아니라 자신과 연관된 앞뒤 맥락을 같이 알고 있어 강결합을 유지하고 있는 것으로 보입니다. 함수가 하나의 역할만 수행한다는 것이 무엇인지 좀 더 고민해보시길 바랍니다."

리뷰어 님께 들었던 1차 피드백 중 일부였다. 단일 책임을 생각하지 않은건 아니었지만 하나의 책임을 부여하는 것에 대한 나만의 기준을 만들 필요가 있다고 생각했다.

그렇게 내가 생각한 단일 책임을 갖는 함수는 다음과 같았다.

  1. 이 함수는 하나의 목적으로 작업을 수행하고 있는가?
  2. 다른 모듈에 변경이 발생했을 때 이 함수는 영향을 받지 않을 함수인가?
  3. 함수의 네이밍이 하나의 역할을 수행하는 함수로 지어졌나?

예시 : 단일 책임을 갖지 않는 함수

// 옳지 않은 예시
function emailClients(clients) {
 clients.forEach(client => {
   const clientRecord = database.lookup(client);
   if (clientRecord.isActive()) {
     email(client);
   }
 });
}

emailClients의 함수의 역할을 살펴보면 다음과 같은 역할을 가진다.

  1. clients를 순회하며 유저 정보를 DB에서 가져온다.
  2. 유저 정보가 active한지 확인 후 email을 전송한다.

만약 다음과 같은 변경 사항이 발생하면 emailClients에게 영향이 갈 것이다.

client의 active 여부를 다른 함수에서 필요할 경우

function emailActiveClients(clients) {
 clients.filter(isActiveClient).forEach(email);
}

function isActiveClient(client) {
 const clientRecord = database.lookup(client);
 return clientRecord.isActive();
}

DB에서 유저 정보를 가져와 active 한지 확인하는 isActiveClient와 active 한 client만 email을 발송하는 emailActiveClients로 분리 할 수 있다.

이렇게 분리 했을 때 이점은 다음과 같다.

  1. 함수가 어떤 작업을 수행하는지 쉽게 파악이 가능하다. (가독성)
  2. 재 사용성이 뛰어나며 유연한 설계가 가능하다.

코드에 적용

GameController.js

async #setRacingCar() {
    const racingCars = await InputView.input(INPUT_MESSAGE.RACING_CAR);
    this.racingTrack.setRacingCars(racingCars);
}

setRacingCar 함수의 역할은 다음과 같다.

  1. InputView로 부터 유저가 입력한 값을 받는다.
  2. racingTrack에게 racingCar 정보를 update 하도록 요청한다.

함수가 inputView에도, racingTrack에도 의존하게 되어 InputView나 RacingTrack 객체 내 input, setRacingCars에 서로 다른 시점에서 변경이 발생하면 setRacingCar도 변경이 발생할 것이다.

  static async #getRacingCarNames() {
    const racingCarNames = await InputView.input(INPUT_MESSAGE.RACING_CAR);
    return racingCarNames;
  }
  
  #setRacingCars(carNames) {
	this.racingTrack.setRacingCars(carNames);
  }

입력에 대한 책임을 분리하기 위해 getRacingCarNames로, racingCar를 update 하기 위해 setRacingCars로 분리할 수 있다.

이렇게 잘 분리했을 때 기능 요구사항이 추가되어도 잘 대응할 수 있다.

  • 입력한 차들이 어떤 이름을 갖고 있는지 확인할 수 있어야 한다.
static #printRacingCarNames(racingCarNames) {
	return OutputView.print(`${racingCarNames}들이 입장했습니다.`)
}

const carNames = await GameController.#getRacingCarNames()
GameController.#printRacingCarNames(carNames)

만약 다음과 같은 요구사항이 생기면 분리한 getRacingCarNames 함수를 재활용하여 더 빠르게 요구사항에 대응할 수 있을 것이다.

함수의 네이밍 신경쓰기

utils/RacingWinners.js

export const genRacingWinners = (racingResult) => {
  const result = genResultArray(racingResult);
  const maxDistance = genMaxDistance(result);
  return result
    .filter(([, distance]) => distance === maxDistance)
    .map(([racer]) => racer);
}; // x

export const createRacingWinners = (racingResult) => {
  const result = getResultArray(racingResult);
  const maxDistance = getMaxDistance(result);
  return result
    .filter(([, distance]) => distance === maxDistance)
    .map(([racer]) => racer);
}; // o

gen과 같이 축약형으로 네이밍을 하게 되면 읽는 사람으로 하여금 한번 더 생각하게 만드는 코드가 된다고 리뷰어님이 말씀해주셨다.

그래서 다소 네이밍이 길어지더라도 명확한 의미를 나타낼 수 있는 네이밍에 신경써야겠다는 생각을 했고 실제 코드에서 리팩터링 때 적용하려 했다.

static class vs object

NumberMaker.js

class NumberMaker {
  static genRandomNumber(maxValue) {
    return Math.floor(Math.random() * maxValue);
  }
}

export default NumberMaker;

기존 까지 static 함수들만 있는 모듈 들에 대해 위와 같은 class 문법으로 작성 후 static 메서드로 관리하고 있었다.

이렇게 관리했을 때 문제점은 다음과 같았다.

  1. class는 인스턴스를 생성하기 위한 문법이다.
  2. 즉, NumberMaker는 보는 사람으로 하여금 "왜 class인데 상태가 없지?" 와 같은 혼란을 야기할 수 있다.
const NumberMaker = {
  createRandomNumber() {
    return Math.floor(Math.random() * MAX_RANDOM_NUMBER_RANGE);
  },
};

이렇게 객체로만 사용해도 이전과 동일하게 사용할 수 있으며, 이제 더 이상 class 문법으로 인해 혼란을 야기하지 않을 것이다.

테스트 코드을 위한 코드

기존 테스트 코드의 문제점

NumberMaker.js

import { MAX_RANDOM_NUMBER_RANGE } from './constants/index.js';

const NumberMaker = {
  createRandomNumber() {
    return Math.floor(Math.random() * MAX_RANDOM_NUMBER_RANGE);
  },

  getRacingCarRandomNumbers(racingCars) {
    const racingCarNumbers = [...racingCars];
    return racingCarNumbers.map(() => this.createRandomNumber());
  },
};

export default NumberMaker;

Car.test.js

test('전진하는 조건은 4 이상일 경우다.', () => {
  const createRandomNumberMock = jest.fn();
  NumberMaker.createRandomNumber = createRandomNumberMock;
  createRandomNumberMock.mockReturnValue(4);
  expect(isMove('jiny')).toBeTruthy();
  createRandomNumberMock.mockReturnValue(3);
  expect(isMove('pobi')).toBeFalsy();
});

tests/utils/car.js

export const isMove = (carName) => {
  const randomNumber = NumberMaker.createRandomNumber(carName);
  return randomNumber >= AVALIABLE_RANDOM_NUMBER;
};

다음과 같이 static method 내 isMove 와 동일한 로직을 utils 폴더 내 따로 관리하여 테스트 코드에 활용하고 있었으며 다음과 같은 이유로 좋지 않은 코드라고 생각되었다.

  1. RacingCars 인스턴스 내 isMove 함수가 변경되면 함께 변경되어야 하며, 변경 과정에서 버그가 발생 될 수 있다.
  2. NumberMaker에 종속된 코드이기 때문에 createRandomNumber가 변경되면 isMove와 test 코드 내 isMove 모두 변경해줘야 한다.

하지만 static method로 관리되고 있는 함수를 테스팅해야 했기 때문에, isMove를 테스트 하지 않는 선에서 해결할 수 있는 방법을 찾게 되었다.

DIP(의존 관계 역전 원칙)로 해결하기

DIP(의존 관계 역전 원칙)란?

  1. 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
  2. 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다. - 위키 백과 -
class RacingCars {
  #moveStatus;

  #numberMaker;

  constructor() {
    this.#moveStatus = {};
    this.#numberMaker = NumberMaker;
  }
}

지금 RacingCars 내 numberMaker는 NumberMaker라는 객체에 의존 하는 것을 알 수 있다.

즉, RacingCars와 NumberMaker의 관계는 다음과 같다.

  • 상위 모듈 - RacingCars
  • 하위 모듈 - NumberMaker

항상 NumberMaker에 의해서 랜덤 숫자를 생성하기 때문에 랜덤 숫자 제어에 대한 권한은 RacingCars에 있었고, 테스트 코드에서도 isMove라는 함수가 호출되면 NumberMaker의 createRandomNumbers가 호출되었다.

즉, NumberMaker를 RacingCars에서 제어하는 것이 아닌 외부에서 제어할 수 있다면 테스트 코드 짜기 용이해질 수 있다는 것이다.

DIP 적용하기

RacingCars.js

class RacingCars {
  #moveStatus;

  #numberMaker;

  constructor(numberMaker) {
    this.#moveStatus = {};
    this.#numberMaker = numberMaker;
  }

  initMoveStatus(carNames) {
    carNames.forEach((carName) => {
      this.#moveStatus[carName] = CAR_STATUS_SYMBOLS.EMPTY;
    });
  }

  #isMove(carName) {
    const randomNumber = this.#numberMaker.createRandomNumber(carName);
    return randomNumber >= AVALIABLE_RANDOM_NUMBER;
  }

  move(carNames) {
    carNames.forEach((carName) => {
      if (this.#isMove(carName))
        this.#moveStatus[carName] += CAR_STATUS_SYMBOLS.MOVE;
    });
    return this.#moveStatus;
  }
}

우선, RacingCars 내 numberMaker를 외부에서 제어할 수 있도록 의존성 주입을 진행했다.

tests/utils/car.js

export const MockNumberMaker = {
  createRandomNumber() {
    return AVALIABLE_RANDOM_NUMBER;
  },
};

테스팅을 위해 NumberMaker에 대한 mock 객체를 생성했다.

Car.test.js

test('모든 자동차들은 랜덤 숫자가 4 이상 일 경우에만 이동할 수 있다.', () => {
  const racingCars = new RacingCars(MockNumberMaker);
  const carNames = ['jiny', 'pobi', 'conan'];
  racingCars.initMoveStatus(carNames);
  const moveResult = racingCars.move(carNames);
  expect(moveResult).toStrictEqual({
    jiny: '-',
    pobi: '-',
    conan: '-',
  });

  const createRandomNumberForStop = () => AVALIABLE_RANDOM_NUMBER - 1;
  MockNumberMaker.createRandomNumber = createRandomNumberForStop;
  const stopResult = racingCars.move(carNames);
  expect(stopResult).toStrictEqual({
    jiny: '-',
    pobi: '-',
    conan: '-',
  });
});

MockNumberMaker를 인자로 추가한 RacingCars 인스턴스를 생성 후 createRandomNumber를 변경 해가며 테스팅을 진행할 수 있었다.

의존성 주입을 했을 때 장점은 다음과 같았다.

  1. 테스트에 용이한 코드가 될 수 있었다.
  2. 만약 외부에서 RacingCars를 사용할 때 NumberMaker에 대해 별도로 관리하면 되니 재 사용성이 높아질 수 있었다.

레퍼런스

의존성 주입

단일 책임 함수

모듈패턴

static class, object

1개의 댓글

comment-user-thumbnail
2023년 7월 29일

이런 유용한 정보를 나눠주셔서 감사합니다.

답글 달기