이번주 미션은 로또 게임이다.
내가 작성한 코드와 PR은 밑에 링크에서 확인할 수 있다.
메일에 보면 이번주 미션의 목표는 "클래스 분리"와 "단위 테스트 작성"이었다.
이전에도 로또 미션은 한번 구현해보았는데, 오히려 저번보다 이번에 머리가 더 아팠다.
이전 2번의 미션에 비해 이번 미션은 더 잘할 것이라고 생각했는데 코드를 짜면서 절망의 계곡에 와버렸다,,
객체지향은... 알면 알수록 어렵다.
각설하고 회고를 시작해보겠다.
클래스의 책임 분리를 더 잘해보기 위한 방법을 찾아보다가 "원시값 랩핑"에 대한 키워드가 많이 보였다.
그래서 로또 숫자 하나를 의미하는 Integer
를 랩핑해
우테코에서 제공한 Lotto
클래스의 List<Integer>
를 List<LottoNumber>
로 변경하였다.
원시값을 포장하기 전에는 Lotto
가 List
의 각 요소들이 1~45 사이의 숫자인지 검증하고, 길이가 6인지도 검증하고 등등 유효성 검증을 많이 해야 했다.
원시값을 포장하니 LottoNumber
가 일부 유효성 검증을 책임지게 되면서 확실히 책임이 분리되는 것을 느꼈다.
단점 아닌 단점(?)으로는 원시값이 포장되었다 보니 값을 출력하기 위해 toString()
을 OverRiding
해야 했다.
아니면 getter
를 사용하면 되는데 출력을 위한 메서드로는 getter
보다는 toString()
를 더 깔끔하다고 느껴졌다.
LottoNumber.class
public record LottoNumber(int value) {
public LottoNumber {
// 생략
}
@Override
public String toString() {
return String.valueOf(value);
}
}
또한 클래스 분리를 위해 일급 컬렉션을 활용했다.
원시값을 랩핑하고 일급 컬렉션을 활용하다보니 다음과 같은 구조가 되었다.
LottoNumber
: 1~45 사이의 숫자를 랩핑한 클래스(숫자 범위 검증 등 담당)Lotto
:List<LottoNumber>
를 랩핑한 클래스(중복 숫자 검증 등 담당)Lottos
:List<Lotto>
를 랩핑한 클래스
그런데 Lottos
가 갑자기 붕 떠버렸다.
Lottos
는 단순히 컬렉션을 랩핑하고, 내부에는 별다른 로직이 없다.
만약 고객이 구매할 수 있는 로또 장수에 제한이 있는 등 제약사항이 있다면 Lottos
도 도메인 로직이 생기겠지만,
현재 상태에서는 제약사항이 없기에 오버프로그래밍한 것은 아닌지 고민이 되었다.
계속 고민을 하다가 일급 컬렉션의 장점 중 하나는 “컬렉션에 이름을 붙일 수 있다는 것”이라고 생각이 들었고 오버프로그래밍이라면 이로부터 파생될 수 있는 문제점도 직접 느껴보는 것이 좋을 것 같아서
일단은 해당 클래스를 유지하는 것으로 결론을 내렸다.
처음에는 Service
없이 Controller
에서 도메인 호출만 해서 프로그램을 구현했다.
그런데 그러다보니 누구에게도 속하기 애매한 로직이 생겼다. 바로 수익률을 계산하는 로직이다.
특히 이 두개 클래스 중 어디에 속해야 하는 로직인지 고민을 엄청 많이 했다.
Money
: "구입 금액"을 책임지는 도메인LottoResult
: "당첨 결과"를 책임지는 도메인
Money
는 구입 금액을 알고 있고, LottoResult
는 당첨 금액에 대해 알고 있다.
수익률을 계산하려면 이 두 금액에 대해 모두 알고 있어야 하는데... getter
로 둘다 꺼내와서 외부에서 계산해야 하나 고민하다가 이 두 객체를 필드로 갖고 있는 Profit
이라는 객체를 만들었다.
두 개의 객체 어디에도 속하기 애매한 로직인것 같고,
그렇다고 굳이 객체를 만들어야 하나 싶기도 하고...
그렇다고 Controller
나 Service
에 getter
로 값을 꺼내서 직접 계산하기에는 도메인 로직이 외부에 있고! 🤯
내가 느끼기에는 어디에 위치하기가 참 애매한 로직이었다.
고민했던 방법 중에 그나마 제일 났다고 판단한 방법은 그나마 Profit
객체를 만드는 것이었다.
(다른 더 좋은 방법은 없는지 미션 제출 시점까지, 끝까지 고민했던 부분인데 프리코스 끝나고 다시 찬찬히 봐야겠다.)
로또 당첨 정보 관련 정보들을 모아 Enum
으로 구현했다.
Rank.class
public enum Rank {
FIFTH(3, false, 5_000),
FOURTH(4, false, 50_000),
THIRD(5, false, 1_500_000),
SECOND(5, true, 30_000_000),
FIRST(6, false, 2_000_000_000),
NO_RANK(0, false, 0);
public final int matchedCount;
public final boolean matchesBonusNumber;
public final int prize;
Rank(int matchedCount, boolean matchesBonusNumber, int prize) {
this.matchedCount = matchedCount;
this.matchesBonusNumber = matchesBonusNumber;
this.prize = prize;
}
// 이하 생략
}
해당 정보를 view
에서 다음과 같이 출력하기 위해 당첨 정보를 LottoResult
클래스에 EnumMap
을 활용하여 저장했다.
그리고 출력 시 사용하기 위해 Collections.unmodifiableMap()
에 감싸서 값을 넘겨주고 있었는데
순서 보장에 대한 의문이 들었다.
출력 예시
3개 일치 (5,000원) - 1개
4개 일치 (50,000원) - 0개
5개 일치 (1,500,000원) - 0개
5개 일치, 보너스 볼 일치 (30,000,000원) - 0개
6개 일치 (2,000,000,000원) - 0개
총 수익률은 62.5%입니다.
LottoResult.class
public class LottoResult {
private final Map<Rank, Long> results = new EnumMap<>(Rank.class);
public LottoResult(Map<Rank, Long> results) {
// 생략
}
public Map<Rank, Long> getResults() {
return Collections.unmodifiableMap(results);
}
// 생략
}
EnumMap
의 저장 순서EnumMap
은 Enum
에 정의된 순서로 저장된다.
그런데 Collections.unmodifiableMap()
으로 감싸도 순서가 보장이 될까?
출력할 때마다 순서가 바뀐다면 LinkedHashMap
과 같은 다른 대안을 생각해야 되니까
얼릉 내부 코드를 살펴보자.
두번째의 private 메서드를 살펴보면 UnmodifiableMap
이라는 구현체를 반환할 때 인자로 받는 Map
을 참조하고 있다.
그리고 스크린샷의 1504 라인쪽 메서드를 보면 삭제하거나 추가하는 등
기존 Map
을 수정하지 못하도록 막고 있는 것을 확인할 수 있다.
즉 원본 객체인 EnumMap을 그대로 참조하고 있기 때문에 순서가 보장된다.!
(이 말은 반대로 하면 원본 데이터가 바뀌면 참조하는 데이터도 순서가 바뀐다는 뜻이다.)
미션 구현하면서 엄청 많은 고민들을 했는데, 이렇게 또 회고하면서 정리해보니 별로 많게 안 느껴지도 하는 것이...
코딩 능력과 더불어 글 쓰기 능력도 키워야겠다는 생각이 든다.
어느새 날씨가 쌀쌀해졌고, 프리코스도 절반 이상을 지나 이제 1개의 미션만 남겨두고 있다.
마지막까지 후회없이 최선을 다하자.
글 유잼 ㅎ