열심히 달리다보니, 벌써 3주차까지 끝났습니다...! 2주차에선 함수를 최대한 쪼개고, 함수별로 테스트를 하여 검증하는 태스크를 연습해보았다면, 3주차는 더욱 업그레이드된 요구사항들이 즐비했습니다. 객체지향언어인 java를 제대로 활용하도록 이끌어주는 요구사항들이었는데요.
첫번째는 클래스(객체) 분리!
마냥 클래스를 분리하는게 아니라 MVC 로직에 따라 도메인을 나눈 다음, 도메인 안에서 클래스를 쪼개는 것입니다.
두번째는 도메인 로직에 대한 단위테스트 작성
도메인 별로 테스트도 나누어 따로 관리 하는 것으로 이해하였으며, MVC 구조에 대한 이해를 계속 하며 테스트를 작성했습니다.
이밖에도 함수길이 제한, else 예약어 사용 금지, javaEnum 적용과 같은 세부 요구사항들이 추가되었습니다! 이제 개발하며 봉착한 난관과 해결해나간 경험, 그리고 배운점들을 공유하겠습니다.
[구매 금액 입력]
- (e) 양의 정수 형태 이외 입력
- (e) 1000 단위 이하 입력
- 입력 후, 정수형 변환
[구매 개수 계산]
- 구매금액 / 1000
[로또 지갑 생성]
- 로또 생성
- 1~45 사이 정수
- pickUniqueNumbersInRange() 활용
- 중복 불가
[로또 번호 출력]
- 오름차순 정렬
[이번주 당첨 번호 입력]
- 이번주 당첨 로또 생성
- (e) 번호 6개 , 구분자 = ","
- (e) 문자 입력 불가
- (e) 1~45 사이 정수
- (e) 중복 불가
- readLine() 사용
- 보너스 번호 생성
- (e) 문자 입력 불가
- (e) 1~45 사이 정수
- (e) 중복 불가
- readLine() 사용
[당첨 내역 출력]
- 숫자 일치 개수
- 번호 1개씩 당첨로또에 포함되어있는지 확인
- 보너스는 따로 확인
- 당첨 로또 개수
- 경우 별 당첨로또 개수 저장
[수익률 출력]
- 총 당첨 금액 : 총합(경우별 당첨금액 * 경우별 당첨 로또 개수)
- 수익률 : 총 당첨 금액 / 구매 금액
2주차 회고 때 다짐한대로 초기 계획을 가장 큰 기능부터 논리 순서대로 쪼갠다음, 기능을 구현할 때 필요한 보조기능, 예외사항을 추가하여 작성하였습니다. 하지만 이 계획은 MVC로직이 가시적으로 드러나지 않아 추가적으로 그림을 그려가며 수정하였습니다.
main
/Domain
/Buyer (로또 구매 및 저장을 담당)
/Lotto (로또 생성을 담당)
/WinningLotto (당첨 순위 배정을 담당)
/WinnigRank (당첨 순위 값 추출을 담당)
/Calculator (당첨 내역 계산을 담당)
/View
/InputView (사용자의 입력을 담당)
/OutputView (결과 값 출력을 담당)
/Controller
/InputController (입력 로직을 담당)
/LottoController (로또 게임 전반 로직을 담당)
/Utils
/Validator
/BonusValidator (보너스 점수 입력 예외처리 담당)
/BuyerValidator (구매 금액 입력 예외처리 담당)
/WinningLottoValidator (당첨 로또 입력 예외처리 담당)
/Constant (공통 사용 상수 담당)
/Util (공통 사용 함수 담당)
test
/Domain
/BuyerTest
/CalculatorTest
/LottoTest
/WinningLottoTest
/WinningRanktest
/Utils.Validator
/BonusValidator
/BuyerValidator
/WinningLottoValidator
개발 순서는 Domain Model -> View -> Controller -> Validator로 정한 후 기능 개발이 완료될때마다 README 체크리스트에 업데이트를 한 후 commit하는 것으로 정하였습니다.
Buyer
public class Buyer {
private final int START_NUM = 1;
private final int END_NUM = 45;
private int purchaseAmount;
private int purchaseCount;
private List<Lotto> lottoWallet;
public Buyer(String purchaseAmount) {
new BuyerValidator(purchaseAmount);
this.purchaseAmount = Util.getInt(purchaseAmount);
this.purchaseCount = Calculator.divide1000(this.purchaseAmount);
buyLotto();
}
public int getPurchaseCount() {
return purchaseCount;
}
public List<Lotto> getLottoWallet() {
return lottoWallet;
}
private void buyLotto() {
this.lottoWallet = new ArrayList<>();
for (int i = ZERO; i < purchaseCount; i++) {
Lotto lotto = new Lotto(Util.generateRandomNum(START_NUM, END_NUM, NUMBERS_OF_LOTTO));
lottoWallet.add(lotto);
}
}
}
구매자를 담당하는 객체로, 로또를 구입하는 행동이 주요한 태스크입니다. Lotto Class를 리스트로 가지는 lottoWallet, 입력받은 값을 통해 구한 구매금액, 구매개수, 총 3개의 인스턴스를 가지게 구성하였습니다. 2주차에서 배운 태스크를 이용하여 인스턴스 초기화를 사용하여 생성자를 만들었습니다.
Lotto
public class Lotto {
private final List<Integer> numbers;
public Lotto(List<Integer> numbers) {
validate(numbers);
this.numbers = numbers;
}
private void validate(List<Integer> numbers) {
Set<Integer> set = new HashSet<>(numbers);
if (numbers.size() != 6 || set.size() != 6) {
throw new IllegalArgumentException();
}
}
// TODO: 추가 기능 구현
public List<Integer> getNumbers() {
return numbers;
}
}
주어진 Lotto 클래스를 일급 컬렉션으로 정하고, getter를 통해 로또 번호를 가져오는 행동만 구현하였습니다.
WinningRank
public enum WinningRank {
FIRST(6, 2_000_000_000),
SECOND(5, 30_000_000),
THIRD(5, 1_500_000),
FOURTH(4, 50_000),
FIFTH(3, 5_000),
SIXTH(0, 0);
private static final int MIN_MATCH_COUNT = 3;
public static final String ERROR = "[ERROR] ";
public static final String NOT_COUNT_STATE = "유효한 개수가 아닙니다.";
private final int matchCount;
private final int WinningAmount;
WinningRank(int matchCount, int WinningAmount) {
this.matchCount = matchCount;
this.WinningAmount = WinningAmount;
}
public int WinningAmount() {
return WinningAmount;
}
private boolean Count(int matchCount) {
return this.matchCount == matchCount;
}
public static WinningRank valueOf(int matchCount, boolean matchBonus) {
if (matchCount < MIN_MATCH_COUNT) {
return SIXTH;
}
if (SECOND.Count(matchCount) && matchBonus) {
return SECOND;
}
return Arrays.stream(WinningRank.values())
.filter(rank -> rank.Count(matchCount) && rank != SECOND)
.findAny()
.orElseThrow(() -> new IllegalArgumentException(ERROR + NOT_COUNT_STATE));
}
}
등수와 당첨 금액을 상수처리하기 위해 Enum으로 WinningRank를 구성하였습니다. 로또 번호 일치 개수와 보너스 일치 여부를 받아 WinningLotto의 rank를 계산하는 valueOf가 주요 태스크입니다.
Enum을 통해 상수인 '등수'와 '당첨금액'를 WinningRank 클래스에 감싸며 상태와 행위를 동시에 관리할 수 있으니 로직 이해도가 훨씬 올라갔습니다!
WinningLotto
public class WinningLotto {
private final List<Integer> winningNumbers;
private final int bonus;
public WinningLotto(String lotto, String bonus) {
new WinningLottoValidator(lotto);
List<Integer> WinningLotto = Util.getList(lotto);
new BonusValidator(WinningLotto, bonus);
this.winningNumbers = WinningLotto;
this.bonus = Util.getInt(bonus);
}
public int matchNumberCount(Lotto lotto) {
return Math.toIntExact(lotto.getNumbers().stream()
.filter(this.winningNumbers::contains)
.count());
}
public boolean isMatchBonusNumber(Lotto lotto) {
return lotto.getNumbers().contains(bonus);
}
public List<WinningRank> makeRankResult(Buyer buyer) {
List<WinningRank> rankResult = new ArrayList<>();
List<Lotto> LottoWallet = buyer.getLottoWallet();
for (int i = ZERO; i < LottoWallet.size(); i++) {
rankResult.add(WinningRank.valueOf(this.matchNumberCount(LottoWallet.get(i))
, this.isMatchBonusNumber(LottoWallet.get(i))));
}
return rankResult;
}
}
당첨 로또 객체로, 구매자의 로또를 비교하여 랭크결과를 생성하는 행동이 주요 태스크이다. '맞춘 로또 번호 개수', '보너스 번호 일치 여부'를 계산하고, 이 값을 WinningRank에 넘겨주어 받은 Rank들을 차곡차곡 쌓아 rankResult를 생성한다.
Caculator
public class Calculator {
private final int PERCENT = 100;
private final List<WinningRank> rankResult;
public Calculator(List<WinningRank> rankResult) {
this.rankResult = rankResult;
}
public double getSumOfWinningAmount() {
double sum = 0;
for (WinningRank rank : rankResult) {
sum += rank.WinningAmount();
}
return sum;
}
public int CountOfRank(WinningRank wantedRank) {
int count = (int) rankResult.stream()
.filter(rank -> rank.equals(wantedRank))
.count();
return count;
}
public double earnedRatio() {
double sum = getSumOfWinningAmount();
return (sum / (rankResult.size() * UNIT_OF_MONEY)) * PERCENT;
}
public static int divide1000(int num) {
return num / UNIT_OF_MONEY;
}
}
당첨 통계 계산이 주요 태스크인 클래스로, 여기서 Enum을 사용한 장점이 또 나왔습니다. 감싸진 랭크 클래스 자체를 사용하니 함수로직을 구성하기 굉장히 수월하였습니다! 우리는 랭크의 개수도 구하지만, 랭크의 당첨금액도 계산해야했습니다. 이 두 태스크를 한 클래스 내에서 또 이루어지게 할수 있었습니다.
BuyerValidator
public class BuyerValidator {
private static final String NOT_INTEGER_STATE = "정수만 입력해야 합니다.";
private static final String NOT_NATURAL_STATE = "양수만 입력해야 합니다.";
private static final String NOT_1000UNIT_STATE = "1000원 단위로 입력해야 합니다.";
protected final String money;
public BuyerValidator(String money) {
this.money = money;
isInteger();
isNatural();
is1000Unit();
}
;
private void isInteger() {
try {
Util.getInt(money);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(ERROR + NOT_INTEGER_STATE);
}
}
private void isNatural() {
if (Util.getInt(money) < 0) {
throw new IllegalArgumentException(ERROR + NOT_NATURAL_STATE);
}
}
private void is1000Unit() {
if ((Util.getInt(money) % UNIT_OF_MONEY) != ZERO) {
throw new IllegalArgumentException(ERROR + NOT_1000UNIT_STATE);
}
}
}
구매자 행동의 예외를 처리하는게 주요 태스크입니다. 구매 금액 예외는 총 세 경우로 '숫자가 아닐때', '자연수가 아닐때', '1000단위가 아닐때' 입니다.
WinningLottoValidator
public class WinningLottoValidator {
private static final String NOT_INTEGER_STATE = "로또 번호는 숫자로 입력되어야 합니다.";
private static final String NOT_SIZE_STATE = "로또 번호는 6개 숫자입니다.";
private static final String NOT_RANGE_STATE = "로또 번호는 1부터 45 사이의 숫자여야 합니다.";
private static final String NOT_OVERLAP_STATE = "로또 번호는 중복되지 않아야 합니다.";
protected final String WinningLotto;
public WinningLottoValidator(String WinningLotto) {
this.WinningLotto = WinningLotto;
isInteger();
isValidLottoSize();
isValidRange();
isOverlap();
}
private void isInteger() {
try {
Util.getList(WinningLotto);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(ERROR + NOT_INTEGER_STATE);
}
}
private void isValidLottoSize() {
if (Util.getList(WinningLotto).size() != NUMBERS_OF_LOTTO) {
throw new IllegalArgumentException(ERROR + NOT_SIZE_STATE);
}
}
private void isValidRange() {
if (!Util.checkRange(Util.getList(WinningLotto))) {
throw new IllegalArgumentException(ERROR + NOT_RANGE_STATE);
}
}
private void isOverlap() {
Set set = new HashSet<>(Util.getList(WinningLotto));
if (set.size() != 6) {
throw new IllegalArgumentException(ERROR + NOT_OVERLAP_STATE);
}
}
}
당첨 로또 생성의 예외를 처리하는게 주요 태스크입니다. 당첨 로또의 예외는 총 네 경우로 '숫자가 아닐때', '번호 6개가 아닐때', '1-45 사이의 번호가 아닐때', '번호가 중복될 때' 입니다.
BonusValidator
public class BonusValidator {
private static final String NOT_INTEGER_STATE = "보너스 번호는 숫자로 입력되어야 합니다.";
private static final String NOT_RANGE_STATE = "보너스 번호는 1부터 45 사이의 숫자여야 합니다.";
private static final String NOT_OVERLAP_STATE = "보너스 번호는 중복되지 않아야 합니다.";
protected final String BonusLotto;
public BonusValidator(List<Integer> WinningLotto, String BonusLotto) {
this.BonusLotto = BonusLotto;
isInteger();
isValidRange();
isOverlap(WinningLotto);
}
;
private void isInteger() {
try {
Util.getInt(BonusLotto);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(ERROR + NOT_INTEGER_STATE);
}
}
private void isValidRange() {
if (Util.getInt(BonusLotto) > 45 || Util.getInt(BonusLotto) < 1) {
throw new IllegalArgumentException(ERROR + NOT_RANGE_STATE);
}
}
private void isOverlap(List<Integer> WinningLotto) {
if (WinningLotto.contains(Util.getInt(BonusLotto))) {
throw new IllegalArgumentException(ERROR + NOT_OVERLAP_STATE);
}
}
}
보너스 번호 입력의 예외처리는 따로 담당하였습니다. WinningLotto에서 다른 인스턴스로 설정하였기 때문입니다. 또한 '1, 2'와 같이 번호가 여러개 입력되어도 보너스번호는 1개이기 때문에 IsInteger()에서 처리됩니다. 그래서 size에 관한 예외는 처리할 필요가 없게 되었습니다.
InputController
public class InputController {
public static Buyer inputPurchaseAmount() {
return new Buyer(InputView.purchaseAmountInput());
}
public static WinningLotto inputWinningLotto() {
return new WinningLotto(InputView.WinningLottoInput(), InputView.BonusLottoInput());
}
}
입력값 로직을 담당합니다. InputView를 통해 받아온 입력으로 Model을 생성하는 연결고리 역할을 합니다.
LottoController
public class LottoController {
public LottoController() {
activate();
}
private void playLotto() {
Buyer buyer = InputController.inputPurchaseAmount();
OutputView.printBuyerLotto(buyer);
WinningLotto winninglotto = InputController.inputWinningLotto();
Calculator calculator = LottoController.makeCalculator(buyer, winninglotto);
OutputView.printWinningStatistics(calculator);
}
private void activate() {
try {
playLotto();
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
로또 게임 전반 로직을 담당합니다. playLotto()로 로또게임 전체 로직을 담은 다음, 예외 발생시 저장 메시지를 출력하는 try catch로 감싸 activate()를 구성하였습니다.
Application.java
package lotto;
import lotto.Controller.LottoController;
public class Application {
public static void main(String[] args) {
// TODO: 프로그램 구현
new LottoController();
}
}
Domain Model은 주요 태스크 한가지만을 진행하게 하며, Validate를 따로 두어 생성자에서 바로 검증할 수 있게끔 한다.
View는 입력과 출력을 사용자에게 보여주는 시각적 표현을 한다.
Controller는 View와 Model을 이어주는 연결고리 역할을 한다.
이를 지키니 Test를 구성할 때 클래스간 인자 전달 걱정 없이, 예시를 생성해서 모든 함수들을 test할 수 있게 되었습니다. 예를 들어 view를 통해 입력받는 작업을 따로 두었기 때문에 model을 test할 때 input값은 변수로 지정하여 쓸수 있게되기 때문입니다. 3주차 요구사항을 착실히 따르다보니 저절로 2주차에 겪었던 어려움이 풀렸습니다...! 교육자의 어려움을 정확히 파악하고, 스스로 깨닫게 하는 우아한테크코스의 커리큘럼은 대단하다고 생각합니다.
객체 지향의 핵심은 Wrapping이라 생각합니다. 때문에 객체의 인스턴스를 private로 두어 외부로부터 수정 및 가져오기를 지양하는 것이죠. 하지만 이번 과제에서 "수정(setter)"은 지양하는데 성공했지만, 일급콜렉션인 Lotto에서 "가져오기(gettter)"를 수행하기 때문에 아쉬운점이 남았습니다. WinningLotto에서 Buyer의 Lotto를 비교하기 위해 의존적으로 불러오는 태스크가 수행되었기 때문이죠.
본 과제에서는 수행하지 못하였지만, 회고하면서 로직을 고민하였고 이를 풀어보겠습니다.
<행동과 상태를 모두 하나의 클래스에서 처리한다는 아이디어에서 도출>
1) List를 매개변수로 받는 생성자
2) String를 매개변수로 받는 생성자
Lotto BuyerLotto = Lotto(randomNumGenerator로 생성한 List);
Lotto WinningLotto = Lotto(InputView로 받은 String);
문제점 1)
matchNumberCount()에서 매개변수로 BuyerLotto와 WinningLotto 중 하나를 받아와 비교해야 하는데, 여기서도 필연적으로 numbers를 꺼내기 위해선 getter가 필요합니다.
public int matchNumberCount(Lotto BuyerLotto) {
return Math.toIntExact(BuyerLotto.getNumbers().stream()
.filter(this.numbers::contains)
.count());
}
문제점 2)
bonus 일치 여부를 비교하기 위해서 Bonus클래스를 따로 두어 관리하면 여기에서 필연적으로 getter를 쓰게 됩니다.
public boolean isMatchBonusNumber(Bonus bonus) {
return this.numbers.contains(bonus.getNum());
}
이를 해결하기 위해 Lotto 생성자에 보너스번호도 매개변수로 같이 받는 방법도 고려해 보았지만, 필드값을 추가하지 않는다는 요구사항이 있기에 불가능했습니다.
이는 피어리뷰를 통해 동료들로부터 힌트를 얻어, 다음 과제에선 getter를 안쓰고 객체지향을 더욱 고도화시킨 코딩을 해보도록 노력해보겠습니다!
잘 읽고 갑니다! 도움이 됐어요!