11/6 wooteco: 로또 게임, 처음부터 테스트 통과까지

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

처음부터 찬찬히

지난 번 경험을 교훈 삼아, 이번에는 앱을 작은 단위로 쪼개서 하나씩 구현하고, 직접 테스트해보며 진행하기로 했다.

오류 발생 시 다시 입력받기 적용

  • 오류 발생 시 다시 입력받는거 해결한듯
async #retryOnFailure(func) {
    try {
      return await func();
    } catch (error) {
      this.#outputView.print(error.message);
      return this.#retryOnFailure(func);
    }
  }

  async readMoneyAmount() {
    const moneyAmountInput = await this.#retryOnFailure(() =>
      this.#inputView.readInteger(MESSAGE.read.moneyAmount),
    );
    return Number(moneyAmountInput);
  }
  • 에러 발생하면 에러 메세지 표시 후에 콜백으로 받은 함수를 재귀호출하는 retryOnFailure,를 View에 구현했다.
  async start() {
    const moneyAmount = await this.#view.readMoneyAmount();
    Console.print(moneyAmount);
    const winningNumbers = await this.#view.readWinningNumbers();
    Console.print(winningNumbers);
    const bonusNumber = await this.#view.readBonusNumber();
    Console.print(bonusNumber);
  }
  • 컨트롤러에서 위와 같이 테스트해봤을 때, 중간에 오류가 발생하면 오류가 발생한 부분부터 다시 입력받는 걸 확인
  • 다만, catch가 어디에서 발생한 오류까지 잡아주는지가 문제임
  • 지금은 단순히 InputView에서 InputValidator로 인해 발생하는 오류만 잡아냄
  • Lotto나 moneyAmount, bonusNumber에서 비즈니스 로직에 대한 예외처리를 할 때 오류가 발생하면 그건 캐치가 안됨
  • => View가 아닌, 더 상위에서 try catch를 해야 하나?
  • => Controller에서 try catch 하자!

Controller에서 에러 캐치 및 재실행 적용 : 하위 에러 모두 캐치 가능

class LottoGameController {
  #view = new View();

  #lottoPublisher = new LottoPublisher();

  async start() {
    await this.setMoneyAmountFromInput();
    const winningNumbers = await this.#view.readWinningNumbers();
    Console.print(winningNumbers);
    const bonusNumber = await this.#view.readBonusNumber();
    Console.print(bonusNumber);
  }

  async #retryOnFailure(func) {
    try {
      return await func();
    } catch (error) {
      this.#view.printError(error);
      return this.#retryOnFailure(func);
    }
  }

  async setMoneyAmountFromInput() {
    await this.#retryOnFailure(async () => {
      const moneyAmount = await this.#view.readMoneyAmount();
      this.#lottoPublisher.setMoneyAmount(moneyAmount);
      Console.print('잘 됐음');
    });
  }
}

//LottoPublisher.js
class LottoPublisher {
  #moneyAmount;

  setMoneyAmount(moneyAmount) {
    this.validate(moneyAmount);
    this.#moneyAmount = moneyAmount;
  }

  validate(moneyAmount) {
    if (moneyAmount % 1000 !== 0) {
      throw new Error('안돼임마');
    }
  }
}
  • 이렇게 하면 금액 입력 시 InputView에서 발생하는 오류도, LottoPublisher에서 금액 유효성 검사 시 발생하는 오류도 모두 잡아내서 '오류가 발생하면 그 부분부터 다시 입력받는' 걸 수행할 수 있을 것 같다!
  • 다른 부분도 적용해보자
 async setMoneyAmountFromInput() {
    await this.#retryOnFailure(async () => {
      const moneyAmount = await this.#view.readMoneyAmount();
      this.#lottoPublisher.setMoneyAmount(moneyAmount);
      Console.print('잘 됐음');
    });
  }

  async setWinningNumbersFromInput() {
    await this.#retryOnFailure(async () => {
      const winningNumbers = await this.#view.readWinningNumbers();
      this.#lottoService.setWinningNumbers(winningNumbers);
      Console.print(this.#lottoService.getWinningNumbers());
    });
  }

  async setBonusNumbersFromInput() {
    await this.#retryOnFailure(async () => {
      const bonusNumber = await this.#view.readBonusNumber();
      this.#lottoService.setBonusNumber(bonusNumber);
      Console.print(this.#lottoService.getBonusNumber());
    });
  }
  • 실행 로그는 아래와 같았다

    penfreak@choegwanghuiui-MacBookAir src % node index.js
    구입금액을 입력해 주세요.
    a
    숫자만 입력할 수 있습니다.
    구입금액을 입력해 주세요.
    10
    안돼임마
    구입금액을 입력해 주세요.
    1000
    잘 됐음
    당첨 번호를 입력해 주세요.
    123
    길이오류
    당첨 번호를 입력해 주세요.
    1,2,3
    길이오류
    당첨 번호를 입력해 주세요.
    1,2,3,4,5,6
    [ 1, 2, 3, 4, 5, 6 ]
    보너스 번호를 입력해 주세요.
    6
    보너스번호가 당첨번호랑 중복임
    보너스 번호를 입력해 주세요.
    7
    7

  • 도중에 오류가 발생하면 오류가 난 곳부터 다시 입력받도록 잘 구현됐다!

작동 내용 구현

로또 발행하기

  • 이제 받아온 값을 그냥 출력하는 대신, 실제로 사용하는 부분들을 추가하자.
import { Random } from '@woowacourse/mission-utils';
import NUMBER from '../utils/constants/number.js';
import Lotto from '../Lotto.js';
import CustomError from '../errors/CustomError.js';

const { game } = NUMBER;
const { lotto } = game;

class LottoPublisher {
  #moneyAmount;
  //...기존의 코드
  #generateRandomLottoNumbers() {
    return Random.pickUniqueNumbersInRange(
      lotto.minNumber,
      lotto.maxNumber,
      lotto.length,
    );
  }

  #publishSingleLotto() {
    const lottoNumbers = this.#generateRandomLottoNumbers();
    return new Lotto(lottoNumbers);
  }

  publishLottos() {
    const count = this.#moneyAmount / game.money.unitAmount;
    return Array.from({ length: count }, () => this.#publishSingleLotto());
  }
}

export default LottoPublisher;
  • 로또퍼블리셔에서 금액을 받아와서 Lotto 인스턴스가 들어있는 배열을 리턴한다.
  • Controller에서는 받아온 로또 배열을 LottoService에 세팅한다.

당첨 번호 세팅하기

  • 다음은 당첨 번호와 보너스 번호도 LottoService에 세팅되게끔 구현해봤다.
import CustomError from '../errors/CustomError.js';
import LottoResultCalculator from './LottoResultCalculator.js';

class LottoService {
  static TICKET_PRICE = 1000;

  #winningNumbers;

  #bonusNumber;

  #lottoTickets;

  #resultCalculator = new LottoResultCalculator();

  setWinningNumbers(numbers) {
    this.validateWinningNumbers(numbers);
    this.#winningNumbers = numbers;
  }

  validateWinningNumbers(numbers) {
    if (numbers.length !== 6) {
      throw new CustomError('길이오류');
    }
    if (new Set(numbers).size !== numbers.length) {
      throw new CustomError('중복있음');
    }
  }

  setBonusNumber(number) {
    this.validateBonusNumber(number);
    this.#bonusNumber = number;
  }

  validateBonusNumber(number) {
    if (this.#winningNumbers.includes(number)) {
      throw new CustomError('보너스번호가 당첨번호랑 중복임');
    }
  }

  setLottoTickets(tickets) {
    this.#lottoTickets = tickets;
  }

  getWinningNumbers() {
    return this.#winningNumbers;
  }

  getBonusNumber() {
    return this.#bonusNumber;
  }

  getLottoTickets() {
    return this.#lottoTickets;
  }
}

export default LottoService;

구매 결과 출력하기

  • 그리고, 구매 결과를 출력하는 메서드를 View에 추가했다.
  printLottoPurchaseResult(lottos) {
    const count = lottos.length;
    this.printNewLine();
    this.#outputView.print(MessageFormat.lottoPurchaseHeader(count));
    const lottoNumbers = lottos.map(item =>
      MessageFormat.lottoTicket(item.getNumbers()),
    );
    lottoNumbers.forEach(item => this.#outputView.print(item));
    this.printNewLine();
  }
  • 배열을 [1, 2, 3, 4, 5, 6] 형태의 문자열로 바꾸기 위해 MessageFormat이라는 객체와 하위 메서드를 만들었다. 요건 나중에 중요하게 다룰 것임
  • 사이에 줄 바꿈이 있어야 하는데, 이걸 printNewLine이라는 메서드로 구현했다. printNewLine은 outputView.print로 빈 문자열을 출력한다. 썩 자연스러운 방법은 아닌 것 같지만..
  • 이제 컨트롤러에서 publisher로부터 반환받은 로또들을 view로 넘겨서 출력한다.
//...
  async executeLottoGame() {
    await this.setMoneyAmountFromInput();
    const tickets = this.#lottoPublisher.publishLottos();
    this.#lottoService.setLottoTickets(tickets);
    this.#view.printLottoPurchaseResult(tickets);
//...
  • 여기까지 하니, 기대한대로 구매 결과가 출력되는 것이 확인돼었다!

구매한 로또와 당첨번호/보너스 번호 비교하기

  • 이제 로또를 구매하고, 번호를 설정했으니, 결과를 계산해야 한다.
  • LottoService에서 구매한 티켓, 당첨 번호, 보너스 번호를 갖고 있게끔 하고,
  • LottoService 내에서 LottoCalculator를 통해 결과를 계산하게끔 해야겠다.
import MessageFormat from '../utils/MessageFormat.js';
import NUMBER from '../utils/constants/number.js';

const { statistics } = NUMBER;
const { winningCriteria, prizes } = statistics;

class LottoResultCalculator {
  #result = {
    first: 0,
    second: 0,
    third: 0,
    fourth: 0,
    fifth: 0,
  };

  calculateResults(tickets, winningNumbers, bonusNumber) {
    tickets.forEach(ticket => {
      this.#updateResult(ticket, winningNumbers, bonusNumber);
    });

    return this.#result;
  }

  #updateResult(ticket, winningNumbers, bonusNumber) {
    const matchCount = this.#countMatchingNumbers(ticket, winningNumbers);
    const hasBonusMatch = ticket.getNumbers().includes(bonusNumber);
    this.#incrementPrizeCount(matchCount, hasBonusMatch);
  }

  #incrementPrizeCount(matchCount, hasBonusMatch) {
    const prizeCategory = this.#findPrizeCategory(matchCount, hasBonusMatch);
    if (prizeCategory) this.#result[prizeCategory] += 1;
  }

  #findPrizeCategory(matchCount, hasBonusMatch) {
    const winningCriterion = Object.values(winningCriteria).find(
      criterion =>
        matchCount === criterion.matchCount &&
        (!criterion.bonusMatch || hasBonusMatch),
    );
    return Object.keys(winningCriteria).find(
      key => winningCriteria[key] === winningCriterion,
    );
  }

  #countMatchingNumbers(ticket, winningNumbers) {
    return ticket.getNumbers().filter(number => winningNumbers.includes(number))
      .length;
  }

  calculateProfitRate(moneyAmount) {
    const totalPrizeMoney = this.#calculateTotalPrizeMoney();
    const profitRate = (totalPrizeMoney / moneyAmount) * 100;
    return profitRate.toFixed(1);
  }

  #calculateTotalPrizeMoney() {
    return Object.entries(this.#result).reduce(
      (total, [prizeCategory, count]) => {
        const prizeMoney = prizes[prizeCategory] || 0;
        return total + prizeMoney * count;
      },
      0,
    );
  }
}

export default LottoResultCalculator;
const firstPrizeCriteria = Object.freeze({ matchCount: 6, bonusMatch: false });
const secondPrizeCriteria = Object.freeze({ matchCount: 5, bonusMatch: true });
const thirdPrizeCriteria = Object.freeze({ matchCount: 5, bonusMatch: false });
const fourthPrizeCriteria = Object.freeze({ matchCount: 4, bonusMatch: false });
const fifthPrizeCriteria = Object.freeze({ matchCount: 3, bonusMatch: false });

const winningCriteria = Object.freeze({
  first: firstPrizeCriteria,
  second: secondPrizeCriteria,
  third: thirdPrizeCriteria,
  fourth: fourthPrizeCriteria,
  fifth: fifthPrizeCriteria,
});

const prizes = Object.freeze({
  first: 2000000000,
  second: 30000000,
  third: 1500000,
  fourth: 50000,
  fifth: 5000,
});
  • 나는 당첨 조건과 당첨 금액을 상수로 설정했다. 이렇게 하면 로또 서비스와 당첨 기준이 분리되고, 로또 서비스는 당첨 기준이 달라지더라도 별다른 수정 없이 사용 가능하게 된다!
  • 추상화가 조금 과하지 않나 고민했지만, 로또 서비스 내에 당첨 로직 (3개 일치면 얼마, 4개 일치면 얼마, 5개 일치에 보너스 일치면 얼마...)이 복잡하게 얽혀 있는 것 보다는 깔끔하게 분리하는 게 낫겠다고 생각했다.
  • 카큘레이터는 각 로또 티켓의 형태를 몇 개 일치인지 보너스 번호가 포함되는지로 변환하고,
  • 이에 따라 winningCriteria의 matchCountd와 bonusMatch를 확인하여 몇 등 당첨인지 계산한 후,
  • 카큘레이터의 필드인 result의 값을 업데이트한다.
  • 총 상금 계산으로 얼마를 땄는지 계산한 후, 수익률 계산으로 수익률을 소수점 첫째자리까지 계산하여 반환한다.
  • 이제 주어진 로또 티켓들과 당첨번호, 보너스 번호를 대조해서 결과를 계산하는 것 까지 구현했다!

계산 결과 출력하기

  • 먼저 카큘레이터의 메서드를 활용해서 LottoService에서 계산을 실행하고, 컨트롤러에서 받아와야 한다.
//Lottoservice.js
  calculateResults() {
    const tickets = this.getLottoTickets();
    const winningNumbers = this.getWinningNumbers();
    const bonusNumber = this.getBonusNumber();
    return this.#resultCalculator.calculateResults(
      tickets,
      winningNumbers,
      bonusNumber,
    );
  }

  calculateProfitRate() {
    const totalSpentMoney =
      this.#lottoTickets.length * LottoService.TICKET_PRICE;
    const profitRate =
      this.#resultCalculator.calculateProfitRate(totalSpentMoney);
    return profitRate;
  }
  • 컨트롤러에서는 이렇게 받아온다
async executeLottoGame(){
    const { results, profitRate } = this.executeLottoMatch();
    this.displayLottoResult(results, profitRate);
}

  displayLottoResult(results, profitRate) {
    this.#view.printLottoStats(results);
    this.#view.printProfitRate(profitRate);
  }
  • 그리고 각각을 view의 출력 메서드로 넘겨준다.
  • View에도 출력 메서드를 구현해준다.
  printLottoStats(result) {
    this.#printLottoStatsHeader();
    Object.entries(result).forEach(([category, count]) => {
      this.#printLottoStatItem(category, count);
    });
    this.printNewLine();
  }

  #printLottoStatsHeader() {
    this.printNewLine();
    this.#outputView.print(MESSAGE.statsHeader);
  }

  #printLottoStatItem(category, count) {
    const prize = prizes[category];
    const resultMessage = MessageFormat.lottoResultMessage(
      category,
      prize,
      count,
    );
    this.#outputView.print(resultMessage);
  }

  printProfitRate(profitRate) {
    const profitRateMessage = MessageFormat.profitRateMessage(profitRate);
    this.#outputView.print(profitRateMessage);
  }
  • 결과값 {first : 0, second: 0 ...}을 돌면서 각 카테고리와 갯수를 토대로 MessageFormat의 lottoResultMessage함수를 통해 메세지로 변환해주고, 출력한다.
  • 수익률도 MessageFormat.profitRateMessage로 변환해서 출력한다.
  • 참고로 MessageFormat은 아래와 같이 구현했다!
const MessageFormat = {
  lottoPurchaseHeader: count => `${count}개를 구매했습니다.`,
  lottoTicket: numbers => `[${numbers.join(SYMBOL.lottoNumberSeparator)}]`,
  formatPrize: prize => new Intl.NumberFormat('ko-KR').format(prize),
  formatProfitRate: profitRate =>
    new Intl.NumberFormat('ko-KR', {
      minimumFractionDigits: 1,
      maximumFractionDigits: 1,
    }).format(profitRate),
  lottoResultMessage: (category, prize, ticketCount) => {
    const { matchCount, bonusMatch } = winningCriteria[category];
    return `${matchCount}개 일치${
      bonusMatch ? ', 보너스 볼 일치' : ''
    } (${MessageFormat.formatPrize(prize)}원) - ${ticketCount}`;
  },
  profitRateMessage: profitRate =>
    `총 수익률은 ${MessageFormat.formatProfitRate(profitRate)}%입니다.`,
};
  • lottoResultMessage는 category, prize, ticketCount를 받는다.

  • winningCriteria의 [category]값을 불러온다. (first, second 등의 당첨기준)

  • matchCount, bonusMatch로 쪼개고, 몇 개 일치인지 표시하고 해당 순위의 당첨기준에 보너스 번호 일치가 포함되어있다면 '보너스 볼 일치'를 추가한다.

  • prize를 formatPrize에 넣어 1000 단위로 쉼표로 구분된 문자열로 바꿔준다 (20,000 이렇게)

  • 바꾼 값을 (prize원)으로 표기한다.

  • ticketCount로 몇 개 당첨됐는지도 표기해준다.

  • profitRateMessage는 formatProfitRate로 1000단위로 쉼표 구분 + 소수점 첫째자리까지 표기되게끔 처리해준다.

테스트 통과

여기까지 구현하고 테스트가 통과하는 걸 확인했다! 중간에 몇 가지 문제가 있었지만 그건 별도의 포스팅에서 다루기로 하고...
일단 지금 시점에서 필요한 건 다음과 같다.

  • Validator 분리, 유효성 검사 구체화 (지금은 단순하게만 되어있음)
  • CustomError 구체화 (extend해서 오류가 발생하는 부분에 따라 다른 에러로)
  • 재사용되지 않는 상수는 static 필드로
  • executeLottoGame 함수 분리
  • 테스트 작성하기 (근데 이걸 먼저 했어야 되는 거 아닌가? 아닐수도?) (단위 테스트, 통합 테스트 작성하기)
  • 클래스 분리 다시 고민해보기
  • 함수명, 변수명 리팩토링
  • 함수 분리 잘 되어있는지 고려해보기

할 건 많다! 시간은 없는데!!!!!

profile
프론트엔드 개발자

0개의 댓글