[우아한테크코스 5기 프리코스] 3주차 후기

96프로지망생·2022년 11월 17일
0

로또 시스템을 구현해보자

https://github.com/ilgon0110/javascript-lotto

📢 3주차에 추가된 요구 사항

  • 함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현한다.
    • 함수(또는 메서드)가 한 가지 일만 잘 하도록 구현한다.
  • else를 지양한다.
    • 힌트: if 조건절에서 값을 return하는 방식으로 구현하면 else를 사용하지 않아도 된다.
    • 때로는 if/else, switch문을 사용하는 것이 더 깔끔해 보일 수 있다. 어느 경우에 쓰는 것이 적절할지 스스로 고민해 본다.
  • 도메인 로직에 단위 테스트를 구현해야 한다. 단, UI(Console.readLine, Console.print) 로직에 대한 단위 테스트는 제외한다.
    • 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다.
    • 단위 테스트 작성이 익숙하지 않다면 __tests__/LottoTest.js를 참고하여 학습한 후 테스트를 구현한다.

기능 구현 목록 작성

2주차와 크게 달라진 점은 없었다. 다만 2주차에는 도메인 로직에 대한 단위 테스트 구현이 필수가 아니였지만 3주차에선 필수 요구사항으로 바뀌었기 때문에, 아예 기능목록에서 어떤 기능을 테스트할 것인지를 명시해야겠다고 생각했다. 그 다음 요구사항인 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분하였다. 다음 이미지는 도메인 단위로 핵심 로직과 UI를 담당하는 로직을 구분한 도식도이다.

비즈니스 로직과 UI 로직을 구분하는 기준은 다음 블로그를 참고했다.
https://velog.io/@eddy_song/domain-logic

여기서 비즈니스 로직을 구분하는데 많은 도움을 준 문장이 있다.

이 코드가 현실 문제, 즉 비즈니스에 대한 의사결정을 하고 있는가?

이 과제에서 현실 문제는 로또이다. 즉 로또에 대한 의사결정이 있는 지 없는지를 기준으로 비즈니스 로직과 UI로직을 분리했다. 총 6가지의 비즈니즈 로직을 구분하였고, 6가지의 단위 테스트 목록을 작성했다.
다음은 내가 작성한 기능 구현 목록이다.

MVP 기능 흐름

  1. 로또 구입 금액 입력 받기
  2. 로또 구입 금액 만큼의 로또 번호 생성
  3. 당첨 번호 입력 받기
    • 당첨 번호 : 중복되지 않는 숫자 6개
    • 보너스 번호 : 숫자 1개
  4. 당첨 번호 추첨
  5. 당첨 결과 발표
    • 번호가 n개 일치하는 경우 나열해서 발표
    • 수익률 발표

기능 구현 목록

1. 로또 구입 금액 입력 받기

예외처리

  • 숫자 이외의 type을 입력한 경우
  • 1000원 미만의 값을 입력한 경우
  • NUMBER.MAX_SAFE_INTEGER 이상 값을 입력한 경우
  • 1000원으로 나누어 떨어지지 않는 경우

기능구현

  • MissionUtils 라이브러리를 이용해 금액 입력 받기

2. 로또 구입 금액 만큼의 로또 번호 생성

예외처리

  • 로또 번호가 숫자 이외의 type인 경우
  • 로또 번호가 1~45의 숫자 범위를 벗어난 경우
  • 로또 번호에 중복된 숫자가 있는 경우

기능구현

  • MissionUtils 라이브러리를 이용해 로또 번호 생성
  • 입력받은 금액 만큼의 로또 발행

3. 당첨 번호 입력 받기

3-1. 당첨 번호 입력 받기

예외처리

  • 숫자 이외의 type을 입력한 경우
  • 1부터 45의 범위를 벗어난 숫자를 입력한 경우
  • 숫자가 6개가 아닌 경우
  • 중복되는 숫자가 있는 경우
  • 쉼표로 구별하지 않은 경우

기능구현

  • MissionUtils 라이브러리를 이용해 당첨 번호 입력 받기
  • 공백이 있는 경우 에러 대신 메서드에서 공백 처리 해주기

3-2. 보너스 번호 입력 받기

예외처리

  • 숫자 이외의 type을 입력한 경우
  • 1부터 45의 범위를 벗어난 숫자를 입력한 경우

기능구현

  • MissionUtils 라이브러리를 이용해 보너스 번호 입력 받기

4. 당첨 번호 추첨

기능구현

  • 1개, 2개, 3개, 4개, 5개, 6개 일치한 경우
  • 5개 일치+보너스 볼이 일치한 경우

5. 당첨 결과 발표

5-1. 번호가 n개 일치하는 경우 나열해서 발표

기능구현

  • n개 일치한 경우가 총 몇번인지 사용자에게 알려주기
  • MissionUtils 라이브러리를 이용해 결과창 출력

5-2. 수익률 발표

기능구현

  • (사용자가 획득한 돈/사용자가 구입한 금액) 계산하기
  • 소수점 둘째 자리에서 반올림하기
  • MissionUtils 라이브러리를 이용해 수익률 출력

Domain 도식도

비즈니스 로직에 해당되는 총 6가지 테스트 진행

  • 구매금액이 적절한 지 확인했다.
  • 구매금액만큼 로또 번호를 생성했다.
  • 로또 번호가 제대로 생성되었는지 확인했다.
  • 당첨번호와 보너스번호가 적절히 입력되었는지 확인했다.
  • 당첨번호와 로또 번호를 비교해 결과를 계산했다.
  • 계산된 결과를 바탕으로 수익률을 계산했다.

추가된 요구사항

  • MVC 패턴 적용

MVC 패턴

3주차 미션을 처음 본 후 처음 든 생각은 '2주차와 크게 다르지 않은데..?'였다. 2주차의 핵심은 메서드 분리였고 3주차의 핵심은 클래스 분리라고 하지만 이미 2주차때도 어느정도 클래스를 분리하여 과제를 진행했기 때문이다. 로직 또한 2주차와 비슷한 난이도로 느껴졌다. 하지만 안일한 마음가짐으로 과제를 시작하다보니 코드를 작성하면 작성할수록 불안해졌다.
5기부턴 우테코에서 자체적으로 프리코스 커뮤니티와 슬랙을 운영하면서 다양한 지원자들이 자신들의 코드와 회고록을 공유하고 있다. 그 중 잘하는 지원자들의 레포지토리와 다른 기수 합격자들의 후기를 과제를 진행하면서 틈틈이 참고하는데, 2주차에도 충분히 메서드와 클래스를 잘 구분했다고 생각하는 지원자들도 3주차때는 그보다 훨씬 많은 것을 시도하고 적용시키고 있었다. 부끄러워짐과 동시에 어떤 시도를 하고 있나 살펴보았고, 대부분의 지원자들이 MVC패턴을 적용하여 클래스를 관리하고 있었다. 추가된 요구사항 중 하나인 '핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다.' 가 MVC패턴을 사용하라는 힌트라고 생각해 이번 과제에서 적용시켜야겠다고 결심했다.
우테코 유튜브 채널에 있는 10분 테코톡 MVC 관련 영상을 보며 진행했다. 마침 똑같은 로또 미션을 예시로 설명하고 있어 더 잘 이해할 수 있었다. MVC패턴에 맞게 Components를 디렉토링하니 프레임워크에서 개발하는 듯한 기분이 들었다. 다만 기능이 추가될수록 컨트롤러가 비대해지기 때문에 컨트롤러의 비대함을 적절히 분리하는 것이 MVC 패턴의 주요한 숙제라고 느꼈다. 다음은 MVC패턴을 적용하면서 꼭 지키려고 노력한 규칙들이다.

  1. Model은 Controller와 View에 의존하지 않아야 한다.
    (Model 내부에 Controller와 View에 관련된 코드가 있으면 안 된다.)
  2. View는 Model에만 의존해야 하고, Controller에는 의존하면 안 된다.
    (View 내부에 Model의 코드만 있을 수 있고, Controller에 코드가 있으면 안 된다.)
  3. View가 Model로부터 데이터를 받을 때는, 사용자마다 다르게 보여주어야 하는 데이터에 대해서만 받아야 한다.
  4. Controller는 Model과 View에 의존해도 된다.
    (Controller 내부에는 Model과 View의 코드가 있을 수 있다.)
  5. View가 Model로부터 데이터를 받을 때, 반드시 Controller에서 받아야 한다
    출처 : https://www.youtube.com/watch?v=ogaXW6KPc8I&ab_channel=%EC%9A%B0%EC%95%84%ED%95%9CTech ([10분 테코톡] 제리의 MVC 패턴)

제출 후 피드백

3주차는 2주차까지 다르게 내 코드와 상반되는 피드백 내용이 많았다. 역시 MVC 패턴을 처음 적용해 본 탓인가...? 부족한 점을 하나하나 정리해보자.

  1. 발생할 수 있는 예외 사항에 대해 고민한다.
  • 당첨 번호와 중복된 보너스 번호를 입력
    이건 좀 억울하다. 몰랐다. 로또를 사봤어야 알지...
  1. 객체의 상태 접근을 제한한다.
    필드는 private class 필드로 구현한다.
    class WinningLotto {
      #lotto
      #bonusNumber
    
      constructor(lotto, bonusNumber) {
          this.#lotto = lotto
          this.#bonusNumber = bonusNumber
      }
    }

내 코드

class Model {
  constructor() {
    this.userMoney = '';
    this.userBonusNumber = '';
    this.userLottoNumber = '';
    this.lottoLists = [];
    this.lottoResults = {
      five_th: 0,
      four_th: 0,
      three_rd: 0,
      two_nd: 0,
      one_st: 0,
    };
  }
}

Model을 이렇게 사용하는 것이 아닌가?
잘하는 분의 Model을 보니 유효성 검사까지 Model에서 진행하여 return이 필요하면 return하고 있다. 또 UserMoney, UserBonusNumber...을 각 Class로 분리한 다음 Model에 import하여 사용하고 있다. 각 Class들은 private 필드를 사용하고 있다. 역시 잘하는 사람은 많다. 다 나가라 나는 유효성 검사까지 Controller에서 진행했는데 이게 아니였군. Controller에서 값을 바로 쓸 수 있게 하는 것 까지가 Model의 역할인 것 같다.

  1. 요구사항 꼼꼼히 읽기
  • 발생한 로또 수량 및 번호를 출력한다. 로또 번호는 오름차순으로 정렬하여 보여준다.
  • 프로그래밍 요구사항에서 명시하지 않는 한 파일, 패키지 이름을 수정하거나 이동하지 않는다.

아..두개나 놓쳤다. 오름차순은 문서를 20번을 넘게 봤는데 단 한번도 눈에 들어오지 않았다. 왜!!!!
두번째도 저런 문항이 있는지도 몰랐다. Lotto.js를 Model폴더에 이동시켰는데 그러면 안됐다. OMG

  1. 객체는 객체스럽게 사용한다.
  • Lotto클래스는 numbers를 상태 값으로 가진다. 그런데 이 객체는 로직에 대한 구현은 하나도 없고, numbers에 대한 getter 메서드만을 가진다.
class Lotto {
   #numbers
 
   constructor(numbers) {
       this.#numbers = numbers
   }
 
   getNumbers() {
       return this.#numbers
   }
}
 
class LottoGame {
   play() {
       const lotto = new Lotto(...)
 
       // 숫자가 포함되어 있는지 확인한다.
       lotto.getNumbers().contains(number)
 
       // 당첨 번호와 몇 개가 일치하는지 확인한다.
       lotto.getNumbers().stream()...
   }
}

Lotto에서 데이터를 꺼내지(get) 말고 메시지를 던지도록 데이터를 가지는 객체가 일하도록 한다.

class Lotto {
 #numbers

 constructor(numbers) {
     this.#numbers = numbers
 }

//결국 validation 과정이다.
 contains(number) {
     // 숫자가 포함되어 있는지 확인한다.
     return ...
 }

 matchCount(other) {
     // 당첨 번호와 몇 개가 일치하는지 확인한다.
     return ...
 }
}

class LottoGame {
 play() {
     const lotto = new Lotto(...)

     lotto.contains(number)
     lotto.matchCount(...)
 }
}

내 코드

const MissionUtils = require('@woowacourse/mission-utils');
const Validation = require('../Utilities/Validation');
const { LOTTO_SPEC } = require('../Constants');

const { Random } = MissionUtils;

class Lotto {
 #numbers;

 constructor(
   numbers = Random.pickUniqueNumbersInRange(
     LOTTO_SPEC.MIN_NUMBER,
     LOTTO_SPEC.MAX_NUMBER,
     LOTTO_SPEC.PROPER_LENGTH,
   ),
 ) {
   this.validation = new Validation();
   this.validation.isValidWinningNumbers(numbers);
   this.#numbers = numbers;
 }

 get genWinningNumbers() {
   return this.#numbers;
 }
}

module.exports = Lotto;

나름 validation까지 진행하긴 했다. 하지만 getter를 사용하고 있다. 피드백 내용을 보면 순수값을 바로 참고하는 경우 외에는 getter를 최대한 지양하는 것 같다. 그냥 생성자에서 바로 꺼내는 것이 맞는건가? 애초에 이게 되나? 책에서는 get 메서드를 사용하라고 되어있다. 첨부된 링크를 읽어보니 get을 사용해도 되지만, private값이 변경되지 않도록 특정 라이브러리를 사용하여 호출하고 있다. 흠..

일단 validation까지가 Model의 역할인 것은 확실한 것 같다. 데이터를 꺼낼(get)때는 순수값 그대로가 필요하거나 가공이 다 끝난 마지막 값이 필요할 때만 호출하도록 하자.

꺼내서(get).method 하는 방식은 하지 말도록 코드를 작성해야겠다.

  1. 테스트 코드도 코드다.
  • 테스트 코드도 코드이므로 리팩터링을 통해 개선해나가야 한다. 특히 반복적으로 하는 부분을 중복되지 않게 만들어야 한다. 예를 들어 단순히 파라미터의 값만 바뀌는 경우라면 아래와 같이 테스트할 수 있다.
test.each([["999"], ["0"], ["-123"]])("천원 미만의 금액에 대한 예외 처리", (input) => {
   expect((input) => {
     const app = new App(input);
     app.play();
   }).toThrow();
 }
);

내 코드

test('숫자 이외의 타입을 입력한 경우', () => {
    const model = new Model();
    const controller = new Controller(model);
    mockQuestions(['1000d ']);
    expect(() => controller.getUserMoneyAndGenWinningNumbers()).toThrow(
      ERROR.USER.MONEY.TYPE,
    );
  });

나는 딱 하나의 case만 테스트하고 있다. test.each 를 활용해 다양한 파라미터들을 테스트 해보자.

  1. 단위 테스트하기 어려운 코드를 단위 테스트하기
  • 아래 코드는 Random 때문에 Lotto에 대한 단위 테스트를 하기 힘들다. 단위 테스트가 가능하도록 리팩터링한다면 어떻게 하는 것이 좋을까?
const MissionUtils = require("@woowacourse/mission-utils");
 
class Lotto {
   #numbers
 
   constructor() {
       this.#numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6)
   }
}
---
class LottoMachine {
   execute() {
       const lotto = new Lotto()
   }
}

올바른 로또 번호가 생성되는 것을 테스트하기 어렵다. 테스트하기 어려운 것을 클래스 내부가 아닌 외부로 분리하는 시도를 해 본다.

const MissionUtils = require("@woowacourse/mission-utils");
 
class Lotto {
   #numbers
 
   constructor(numbers) {
       this.#numbers = numbers
   }
}
 
class LottoMachine {
   execute() {
       const numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6)
       const lotto = new Lotto(numbers)
   }
}

하지만 프리코스를 진행하면서 기본적으로 주어지는 mockRandom함수를 사용하면 다 테스트된다. mockRandom을 사용하지 말고 메서드 시그니처를 수정하여 외부에서 주입하는 방식으로 코드를 작성하라는 뜻일까. 설명 자체는 이해가 가지만 테스트하기 어렵다는 건 잘 와닿지 않는다.

느낀 점

이번 과제를 진행하면서 클래스 추상화, DI, SOLID 등 Java에서 많이 보던 원칙들을 적용시킨 지원자들을 여럿 봤다. 나도 시도해보고 싶었지만 MVC 패턴만이라도 제대로 적용보고자 시도해보지 않았다. 피드백 내용을 보니 시도했으면 오히려 역효과를 봤을 것 같다. 마지막 4주차 미션에서 적용시켜봐야겠다. 파이팅!

0개의 댓글