로또 시스템을 구현해보자
📢 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 기능 흐름
- 로또 구입 금액 입력 받기
- 로또 구입 금액 만큼의 로또 번호 생성
- 당첨 번호 입력 받기
- 당첨 번호 : 중복되지 않는 숫자 6개
- 보너스 번호 : 숫자 1개
- 당첨 번호 추첨
- 당첨 결과 발표
- 번호가 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 패턴 적용
3주차 미션을 처음 본 후 처음 든 생각은 '2주차와 크게 다르지 않은데..?'였다. 2주차의 핵심은 메서드 분리였고 3주차의 핵심은 클래스 분리라고 하지만 이미 2주차때도 어느정도 클래스를 분리하여 과제를 진행했기 때문이다. 로직 또한 2주차와 비슷한 난이도로 느껴졌다. 하지만 안일한 마음가짐으로 과제를 시작하다보니 코드를 작성하면 작성할수록 불안해졌다.
5기부턴 우테코에서 자체적으로 프리코스 커뮤니티와 슬랙을 운영하면서 다양한 지원자들이 자신들의 코드와 회고록을 공유하고 있다. 그 중 잘하는 지원자들의 레포지토리와 다른 기수 합격자들의 후기를 과제를 진행하면서 틈틈이 참고하는데, 2주차에도 충분히 메서드와 클래스를 잘 구분했다고 생각하는 지원자들도 3주차때는 그보다 훨씬 많은 것을 시도하고 적용시키고 있었다. 부끄러워짐과 동시에 어떤 시도를 하고 있나 살펴보았고, 대부분의 지원자들이 MVC패턴을 적용하여 클래스를 관리하고 있었다. 추가된 요구사항 중 하나인 '핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다.' 가 MVC패턴을 사용하라는 힌트라고 생각해 이번 과제에서 적용시켜야겠다고 결심했다.
우테코 유튜브 채널에 있는 10분 테코톡 MVC 관련 영상을 보며 진행했다. 마침 똑같은 로또 미션을 예시로 설명하고 있어 더 잘 이해할 수 있었다. MVC패턴에 맞게 Components를 디렉토링하니 프레임워크에서 개발하는 듯한 기분이 들었다. 다만 기능이 추가될수록 컨트롤러가 비대해지기 때문에 컨트롤러의 비대함을 적절히 분리하는 것이 MVC 패턴의 주요한 숙제라고 느꼈다. 다음은 MVC패턴을 적용하면서 꼭 지키려고 노력한 규칙들이다.
- Model은 Controller와 View에 의존하지 않아야 한다.
(Model 내부에 Controller와 View에 관련된 코드가 있으면 안 된다.)- View는 Model에만 의존해야 하고, Controller에는 의존하면 안 된다.
(View 내부에 Model의 코드만 있을 수 있고, Controller에 코드가 있으면 안 된다.)- View가 Model로부터 데이터를 받을 때는, 사용자마다 다르게 보여주어야 하는 데이터에 대해서만 받아야 한다.
- Controller는 Model과 View에 의존해도 된다.
(Controller 내부에는 Model과 View의 코드가 있을 수 있다.)- 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 패턴을 처음 적용해 본 탓인가...? 부족한 점을 하나하나 정리해보자.
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의 역할인 것 같다.
아..두개나 놓쳤다. 오름차순은 문서를 20번을 넘게 봤는데 단 한번도 눈에 들어오지 않았다. 왜!!!!
두번째도 저런 문항이 있는지도 몰랐다. Lotto.js를 Model폴더에 이동시켰는데 그러면 안됐다. OMG
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 하는 방식은 하지 말도록 코드를 작성해야겠다.
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
를 활용해 다양한 파라미터들을 테스트 해보자.
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주차 미션에서 적용시켜봐야겠다. 파이팅!