이어서...
오늘 한 것은 다음과 같다.
- 옵저버 패턴 적용
- 컨트롤러 작성
- 정답 로또 번호와 구매한 로또 번호 비교
- 로또 결과 출력
- 테스트 통과
사실상 구매한 로또 번호 검증 기능을 제외하고는 모든 기능을 구현했으며, 테스트도 통과했다.
구매한 로또 번호도 사실 검증이 딱히 필요는 없는게, 이미 RandomNumberGeneratorTest
에서 1부터 45까지의 숫자 6개를 리스트로 만들어 반환하는 것의 테스트를 완료했으며,
로또 번호를 구매하는 것에 사용자가 관여하는 것은 구매 금액 입력밖에 없는데 이 구매 금액 입력의 테스트는 이미 완료했기 때문이다.
그래도 확실한게 좋으니까, 후순위에 두고 작성을 고려해야겠다.
사진에서 보다시피 엄청 많은 클래스를 작성 했는데, 문제는 개별 구현이 쉬웠던 어제의 구현 사항(검증 기능 등)과 달리 한 기능 구현을 위해서 여러 객체가 필요한지라 TDD로 개발을 하지 못했다. 그리고 구현 도중에 예상외로 복잡하게 머리를 써야 하는게 좀 있어서 도저히 TDD로 진행할 엄두를 못 냈다.
인터넷의 많은 선배 개발자분들의 말씀과 내가 학습한 책에서 TDD가 익숙하지 않은 사람은 TDD로 개발하기 꽤 힘들거라고 했는데, TDD를 정석대로 사용하니 왜 그런지 이제 정말 알겠다. 보통 힘든 일이 아니다. 컨트롤러, 서비스, 핵심 로직을 구현하는 단계에서 TDD를 적용할려면 개발하고자 하는 것의 전체적이면서도 세부적인 흐름을 미리 예측하고 있어야 겠구나- 라고 깨달았다.
옵저버 패턴 적용
옵저버 패턴을 적용한 곳은 N:1의 관계를 가지는 구매한 로또 번호 객체들과 정답 로또 번호 객체였다. 컨트롤러에도 연쇄적으로 옵저버 패턴을 구현할까도 고민했었는데, 코드가 너무 복잡해져서 구현을 시작하기가 너무 힘들었다. 옵저버 패턴은 일단 이곳에만 쓰고, 기본적인 객체지향과 SRP에 우선적으로 집중하기로 결정했다. 이후 시간이 된다면 컨트롤러에도 옵저버 패턴을 결합할 예정이다.
WinningLotto
가 주제, PurchasedLotto
가 옵저버가 되었다. WinningLotto
객체가 생성되고 번호가 정해지면, 즉각적으로 이를 구독하는 PurchasedLotto
객체들에게 DTO를 통해 WinningLotto 의 정보가 전달되고, PurchasedLotto
는 자기 자신의 등수를 업데이트 한다.
이후 시간이 된다면, 이 PurchasedLotto
의 rank
의 상태가 변경되면 컨트롤러에 상태 변경을 통지하게 하면 좋을 것 같다.
Lotto 클래스
이번 주 요구사항에 특별한 요구사항이 있었다. 제공된 Lotto
클래스를 활용하라는 것이다. 왜 이런 요구사항을 줬을까 생각해봤는데, 아마 클래스 분리를 연습시키기 위해서인 것 같았다. 나는 이 Lotto
클래스를 상속받는 PurchasedLotto
, WinningLotto
클래스를 만들었다.
상속을 이용하면 부모와 자식 객체가 강하게 결합한다는 단점이 있지만, 요구사항 변경시 많은 변화가 예상되지 않고 확실한 IS-A 관계인데도 상속을 이용하지 않을 이유는 없다고 생각해서 상속을 사용했다.
정답 로또 번호와 구매한 로또 번호 비교
이 부분이 애를 참 많이 먹은 부분 중 하나다. 고민했던 건 이것이였다.
- 번호 비교를 어떤 객체에게 역할을 부여할 것인가?
- 번호 비교를 어떻게 할 것인가?
번호 비교를 어떤 객체에게 역할을 부여할 것인가?
여기엔 두 가지 방안이 있었다.
PurchasedLotto
객체 내부에서 진행한다.- 번호를 비교하는 새로운 클래스를 만든다.
나는 고민 끝에 2번을 택했다. PurchasedLotto
객체가 번호 비교까지 역할을 맡기에는 코드의 양이 너무 많아지고, PurchasedLotto
객체와 WinningLotto
객체간의 의존성이 커질 것 같았기 때문이다. 따라서 번호를 비교하는 새로운 LottoResultService
클래스를 만들어 번호를 비교하게 했다.
번호 비교를 어떻게 할 것인가?
여기에도 두 가지 방안이 있었다.
LottoResultService
에서PurchasedLotto
와WinningLotto
의 번호 값들을 받아와서 비교시킨다.- 1번과 enum을 함께 사용한다.
1번 방안은 직관적이지만, 비교한 결과를 어디에 어떻게 저장하느냐가 문제였다. 받아온 번호 값들을 반복문으로 비교하고 등수별 개수를 계속 카운트 시켜야 할 것 같은데, 가독성과 역할의 문제에서 좋지 않을 것 같았다.
그래서 추가적인 코드를 작성하더라도 2번을 택했다. LottoRank
라는 enum 클래스를 만들어 이곳에 등수별 필요한 번호 일치 개수, 상금을 저장하게 했고, 번호 일치 개수가 5개이고 보너스 번호를 가지고 있다면 2등 값을 가지게 한다.
public enum LottoRank {
FIRST(6, 2000000000),
SECOND(5, 30000000),
THIRD(5, 1500000),
FOURTH(4, 50000),
FIFTH(3, 5000),
NONE(0, 0);
private final int matchCount;
private final long prize;
LottoRank(int matchCount, long prize) {
this.matchCount = matchCount;
this.prize = prize;
}
public static LottoRank valueOf(int matchCount, boolean hasBonusMatched) {
if (matchCount == SECOND.matchCount && hasBonusMatched) {
return SECOND;
}
for (LottoRank rank : values()) {
if (rank.matchCount == matchCount) {
return rank;
}
}
return NONE;
}
public int getMatchCount() {
return matchCount;
}
public long getPrize() {
return prize;
}
}
그리고 LottoResultService
클래스에서 PurchasedLotto
객체들이 가지고 있는 LottoRank
값들을 key로 해 HashMap
에 저장한다. 이 때 value는 카운트이다.
이 로직들을 총 정리하면,
- 정답 로또 번호가 확정된다.
- 정답 로또 번호 객체는 자신의 옵저버인 구매한 로또 번호 객체들에게 변경을 통지한다.
- 변경을 통지받은 구매한 로또 번호 객체들은 DTO를 통해 전달받은 정답 로또 번호 객체의 로또 번호와 보너스 번호를 전달받아 자신의 번호와 비교한다. 만약 이 때 번호가** 5개 일치한다면 추가적으로 보너스 번호 일치 여부를 확인**한다.
- 비교 후 구매한 로또 번호 객체들은 *자신의 등수(LottoRank) 를 상태로 가진다. *
- 이후
LottoResultService
객체가 구매한 로또 번호 객체들의 등수를 DTO를 통해 전달받아 등수와 등수의 개수를 Map에 저장시킨다. - 등수의 개수가 저장된 Map의 값들을 꺼내와서 수익률을 계산하는
ProfitRateCalculator
에 전달한다. ProfitRateCalculator
객체는 등수와 등수에 따른 상금을 계산해 수익률을 반환한다.
사실 이게 오늘 한 것의 가장 핵심적인 내용이고, 이번 과제의 핵심적인 로직이라 생각한다. 단순히 로또 번호를 비교해서 결과를 출력한다
라는 요구사항만 보면 되게 간단할 것 같았는데, 의외로 좀 난이도가 있었다. 그래도 어제 회고에서 말한 것처럼, 생각한 대로 구현이 됐고 핵심 로직을 구현하는 시간이 많이 짧아졌다.
참고로 결과 출력은 이렇게 LottoResultDto
의 값들을 꺼내와 출력하게 한다. DTO를 사용한 이유는 메서드에서 값들을 한번에 전달해 주기 위해 사용했다. 더 좋은 방법이 있을 것 같기도 한데, 시간이 많이 없으니 제일 간단한 방법 같아서 사용했다.
++ 참고로 회고글을 쓰다가 안 사실인데, PurchasedLotto
클래스에 보너스 번호를 포함하고 있는지 여부를 체크하는 로직이 없었다... 이러면 보너스 번호와 관계없이 5개를 맞추면 무조건 2등이 된다. 에러가 발생하는 것도 아니니 못 알아챌 뻔 했다. 이게 회고글 작성의 장점일까?
| ⬆️ 5개 일치시 보너스 번호 포함 여부 체크 로직 추가
BigDecimal
어제부터 사용하기로 마음 먹은 BigDecimal
은 소수점 처리에 유용한 클래스이다. float
, double
타입은 정확한 소수점 값을 계산하지 못하니, 꼭 사용해야만 하는 클래스이다.
| ⬆️ BigDecimal
을 사용한 ProfitRateCalculator
클래스의 calculate()
메서드
사용법은 블로그 포스팅을 보고 배웠다. 일반 타입에 비해서는 조금 까다롭지만 그리 어려운 것도 아니었다. 그냥 계산할 값을 BigDecimal
의 생성자에 인수로 전달해 BigDecimal
객체를 생성하고, 제공된 메서드를 이용해 계산하면 된다.
특이했던 점은, 62.500(%)
처럼 필요 없는 나머지 0 값을 제거하기 위해 stripTrailingZeros()
메서드를 사용했는데, 이 경우 toString()
으로 문자열로 반환할 때 계산 결과가 100(%)
일 경우 1E+2(%)
의 형태로 반환이 되었다. 00이 사라지면서 생긴 효과 일수도 있고, 10의 제곱으로 표현이 가능하면 저렇게 표현하는 것일 수도 있다. 지수 표기 없이 숫자를 문자열로 반환해주는 toPlainString()
을 사용함으로서 해결됐다.
UnsupportedOperationException
전체 구현 후 테스트를 하는데 UnsupportedOperationException
에러가 발생했다.
디버깅을 해보니 로또 번호를 담은 리스트를 오름차순 정렬할 때 발생한 에러였다.
해당 에러에 관해 알아보니, 제공된 테스트 코드에서는 List.of()
로 구매한 로또 번호를 제공하고 있었는데, List.of()
로 생성된 리스트는 기본적으로 불변이라 이 리스트의 값을 오름차순으로 변경하려 해서 생긴 문제였다.
| ⬆️ 테스트 코드에서 제공되는 구매된 로또 번호 리스트
해당 에러가 발생할 경우 new ArrayList()
로 새로운 List를 생성해서 정렬 후 다시 불변 객체로 반환하게 했다.
잘못된 입력시 반복 입력
이번 주 요구사항에는 잘못된 입력값을 받을 시 계속 재입력을 받아야 한다는 요구사항이 있었다. 사실 이건 처음에는 그렇게 어려운 문제라고 생각하지 않았다. 자바를 공부하며 Scanner
로 입력을 계속 다시 받는 코드를 작성해본 적이 있으니까. 근데 다시 생각해보니 입력에서 Scanner
가 아닌 우테코에서 제공하는 Console.readLine()
메서드를 사용해야 됐다. Scanner
처럼while(hasNext())
를 사용할 수 없어 골치 아팠다. 해결 방법은 Optional
이었다.
Optional
로 에러를 래핑해서 에러가 없으면 반복문 종료, 에러가 있으면 계속 값을 입력하게 했다.Optional
을 예전에 잠깐 배웠었는데, 그 때는 스트림에서의 예외 발생 예방용으로만 배웠었는데, 이렇게 사용할 수 있다는 것을 처음 알았다. 예외 처리할 때 아주 편할 것 같다.
마무리
'일단' 테스트는 완료했다. 하지만 아직 할게 많다.
필수적으로 해야 할 것은,
1. 모든 입력값을 받는 부분에 잘못된 입력시 반복 입력하게 하기
아직 한 클래스에만 구현을 해놨다.
2. 컨트롤러, 서비스를 깔끔하게 리팩토링 하기
3. 헷갈릴 수 있는 네이밍 리팩토링 하기
4. 리드미 상세 작성하기
5. 기타 Todo 사항들 적용하기
6. SRP를 잘 지켰는지 점검하기
이고, 가능하다면 추가적으로 할 것은
- 구매한 로또 검증 기능 추가
- 일급 콜렉션 적용하기
- 원시값 포장 적용하기
이다.
이번 주에 시간이 조금만 더 있었다면 널널하게 과제를 다듬을 수 있었을 텐데, 시간에 쫓기느라 개선하고 싶은 것들을 다 하지 못할 것 같아서 아쉽다. 그래도 이틀만에 백지에서 거의 모든 기능들을 구현하고, 테스트도 통과 했으니 필수적으로 해야 할 것은 내일 다 할수 있지 않을까? 성장한 내 능력을 기대해 본다.
그리고 오늘 몇 시간동안 한번도 일어서지 않고 복잡한 로직들을 고민하니 능률이 좀 떨어지는 걸 느꼈다. 원래는 1시간마다 알람을 설정해놔서 잠시라도 스트레칭 하고 물이라도 마시며 리프레시하는 시간을 가졌는데, 오늘 꼭 테스트를 통과시키고 내일은 리팩토링에 전념해야겠다는 생각에 머리를 너무 많이 쓰니 나중에는 머리가 안돌아갔다. 바쁠수록 일의 능률을 위해 일부러라도 쉬는 시간이 필요하다는것을 다시 한번 깨달았다.
새롭게 알게된 점
1. TDD는 어렵다
특히 복잡한 로직과 의존성이 있는 로직들을 작성할 때... 그래도 오히려 연습하고자 하는 마음이 더 생긴다. 프리코스 이후 기획하고 있는 새로운 나만의 학습 방법이 있는데, 이 때 적용해 봐야겠다.
2. BigDecimal
매우매우 기본적인 사용법을 익혔다. 딱 필요한 만큼 익혔다고 생각한다.
3. UnsupportedOperationException 에러UnsupportedOperationException
는 지원되지 않는 작업을 요청할 때 발생하는 에러이다. 이번 경우처럼...
4. List.of() 로 생성된 리스트는 불변이다.
전혀 몰랐던 사실이다. 사실 불변 객체에 대해 이번에 거의 신경을 못 썻는데, 다른 컬렉션들도 불변으로 반환하는 방법도 고민해 봐야겠다.
5. Optional
Optional의 기본적인 사용법을 익혔다. 예외 처리에 있어서 이토록 강력하다니... 자주 써먹어야겠다.
좋았던 점
1. 짧은 시간에 많은 기능을 구현했던 것
순수 시간으로 약 14시간만에 구현을 거의 완료했다. 2주차의 30시간에 비하면 거의 절반의 단축이다. 최종 코테에서는 5시간이 주어지지만, 최종 코테 전까지는 나도 쉬고 있지는 않을 테니 5시간 구현도 충분히 가능하지 않을까?
일단 1차 합격부터
2. 배우고자 하는 마음이 더더욱 생긴 것
이번에 TDD의 어려움을 겪으며 든 마음이다.
더 잘하고 싶다. 더 능숙해지고 싶다. 더 배우고 싶다...
이러한 마음이 생기는게 기분이 참 좋다.
아쉬웠던 점
1. 시간의 부족
이전 회고글들에 적었듯이, 이번 주는 개인적인 일정으로 인해 시간이 매우 부족했다. 사실상 코드를 작성한 건 어제와 오늘이 끝이다. 좀 더 시간이 있었다면 적용해보고 싶은 걸 다 해볼 수 있었을 텐데... 다음 주 과제때는 정말 리팩토링과 개선의 끝을 봐야겠다.
도움이 된 자료들
| [Java] java.lang.UnsupportedOperationException
| Git) 실수로 삭제한 Branch 복구하기 -> 중간에 한번 테스트를 위해 다른 브랜치를 만들고 푸시했다가 그 브랜치를 지웠는데, 작성한 코드들이 다 날라가서 정말로 식겁했다....
'우아한테크코스' 카테고리의 다른 글
도메인 객체가 뚱뚱해진다면? (0) | 2023.12.20 |
---|---|
[우테코 6기 프리코스] 3주차 전체 회고 (0) | 2023.12.20 |
[우테코 6기 프리코스] 3주차 - 5일차 회고 (0) | 2023.12.20 |
[우테코 6기 프리코스] 3주차 - 3, 4일차 회고 (0) | 2023.12.20 |
[우테코 6기 프리코스] 3주차 - 2일차 회고 (0) | 2023.12.20 |