✏️ 코드
🎯 코드의 구조
1️⃣ 원시값 포장 LottoNumber
미션의 요구사항은 다음과 같았다.
2주 차 공통 피드백 영상을 보고 나서 기능 구현 목록 정리하는데에 많은 시간을 쏟는 게 생각보다 중요하다고 느꼈다.
프로젝트에서도 요구사항을 탄탄하게 다져놔야 튼튼하게 개발할 수 있는 것처럼
위의 미션도 마찬가지이다.
그리고 다음과 같이 요구사항의 핵심을 추려보았다.
- 로또 번호는 1~45 사이이다
- 로또 번호는 중복되어서는 안 된다
- 구입 금액에 해당하는 만큼 로또를 발행한다 -> 여러 개의 로또를 발행할 수 있다.
- 당첨 번호를 입력한다. -> 6개의 로또 번호와 1개의 보너스 번호
- 결과를 출력한다.
요구 조건을 위와 같이 추려놓고 보니 포함관계를 가진 두 개의 객체가 직관적으로 보였다
"여러 개의 로또" -> "로또"
그러면 이제 다음과 같은 객체를 생각해 볼 수 있다.
1. Lottos : 여러 개의 로또를 관리한다.
2. Lotto : 하나의 로또를 관리한다.
그리고 이러한 포함관계를 가지고 있다면 2주 차 racingCar의 미션 때와 비슷하게
일급컬렉션을 구현할 수 있을 것이다!
public class Lottos{
private final List<Lotto> ramdonLottos
}
Lotto를 일급객체로 두어 Lottos에서 일급 컬렉션으로 Lotto를 관리할 수 있을 것이다!
public class Lotto{
private final List<Integer> randomLotto
}
근데 이렇게 Lotto의 객체를 두고 보니 다음과 같은 생각이 들었다.
로또 번호는 1~45 사이이다. 그렇다면 각각의 숫자에 대해서는 검증을 필요로 한다.
💡각각의 번호에 대한 Integer를 포장하면 어떨까?
이렇게 원시값에 대해 포장을 하여 새로운 객체 LottoNumber를 만든다면,
1~45 사이라는 검증 조건을 해당 객체에 넘길 수 있으므로
Lotto라는 객체는 책임을 덜어내고 비즈니스 로직에 더 초점을 둘 수 있을 것이다!
따라서 각각의 번호인 LottoNumber에 대해서 포장하기로 결정하였다
또한 LottoNumber 객체를 만든다면 추후에 입력받는 보너스 번호에 대해서도 객체를 재사용함으로써 검증이 가능하다!!
그리고 이는 어떻게 보면 이번 미션의 가장 큰 틀이라고 생각할 수 있을 것 같다.
2️⃣ VO 객체, enum으로 구현하다
VO 객체란?
주로 데이터를 전달하거나 특정 값을 표현하기 위해 사용하는 객체이다.
즉 VO는 불변성을 유지하며, 객체의 상태보다는 값 자체를 중요시한다.
그리고 VO는 주로 데이터 간의 동등성을 판단하거나 데이터를 그룹화해서 의미 있는 하나의 객체로 표현하는 데 사용한다.
따라서 VO는 다음과 같은 특징을 지니고 있다.
- 불변성
- VO 객체는 생성된 이후로 값이 변경되지 않는다
- final로 설정, setter는 사용 X
- 동등성
- equals()와 hashCode() 메서드를 재정의해 값 비교를 구현
- 값이 같으면 같은 값!
- equals()와 hashCode() 메서드를 재정의해 값 비교를 구현
- 값 중심 설계
- 재사용성
그리고 이번 미션에서 불변성을 유지하는 직관적인 부분이 바로 당첨에 대한 결과 출력 부분이었다
이러한 당첨을 출력하는 부분을 VO 객체로 두면 어떨까?라는 생각이 들었다.
1등 : 6개 번호 일치 / 2,000,000,000원
2등 : 5개 번호 + 보너스 번호 일치 / 30,000,000원
3등 : 5개 번호 일치 / 1,500,000원
4등 : 4개 번호 일치 / 50,000원
5등 : 3개 번호 일치 / 5,000원
또한 이러한 값들은 고정적인 값들이다.
즉, 상수다.
따라서 상수 집합을 나타내기에 강력한 enum을 사용해서 VO 객체를 구현하면 어떨까라는 생각이 들었다.
enum을 통해 VO 객체를 나타내면 뭐가 좋을까?
☑️ 값의 고정성을 나타낼 수 있다.
☑️필드와 메서드를 가지므로 추가적인 동작이 가능하다.
즉, 해당 객체 내애서 비교를 진행할 수 있다.
☑️싱글톤처럼 작동하기에 equals와 hashcode 재정의가 필요 없다.
public enum Rank {
FIRST(6, false, 2_000_000_000),
SECOND(5, true, 30_000_000),
THIRD(5, false, 1_500_000),
FOURTH(4, false, 50_000),
FIFTH(3, false, 5_000),
NONE(0, false, 0);
private final int matchNumber;
private final boolean hasBonus;
private final int prize;
private Rank(int matchNumber, boolean hasBonus, int prize) {
this.matchNumber = matchNumber;
this.hasBonus = hasBonus;
this.prize = prize;
}
}
따라서 다음과 같이 enum을 사용하여 Rank 객체를 생성하였다.
그리고 추가적인 메서드를 통해 Rank 객체 내에 책임을 부여할 수 있으므로,
public static Rank matchLottoRank(int matchNumber, boolean hasBonus) {
if (matchNumber == 6) {
return Rank.FIRST;
}
if (matchNumber == 5 && hasBonus) {
return Rank.SECOND;
}
if (matchNumber == 5) {
return Rank.THIRD;
}
if (matchNumber == 4) {
return Rank.FOURTH;
}
if (matchNumber == 3) {
return Rank.FIFTH;
}
return Rank.NONE;
}
위와 같이 입력받은 matchNumber와 hasBonus를 통해 적절한 Rank를 반환해 준다.
<사용예시>
Rank rank1 = Rank.matchLottoRank(6, false); // FIRST
Rank rank2 = Rank.matchLottoRank(5, true); // SECOND
Rank rank3 = Rank.matchLottoRank(2, false); // NONE
이러한 특성을 이용하여 Rank에 대한 객체를 Map 컬렉션 클래스로 관리하고자 하였다.
private final EnumMap<Rank, Integer> rankStatistics;
Map 컬렉션 클래스 중에서도 Enum 객체를 Key로 사용하는 EnumMap으로 Rank를 관리하고자 했다.
[ EnumMap의 특징]
☑️enum 전용 키
EnumMap의 키는 반드시 enum 타입이어야 한다.
☑️배열 기반 구현
배열 기반으로 저장되기에 메모리 효율성이 좋다.
☑️정렬
enum의 정의 순서에 따라 키-값 쌍이 정렬된다.
(HashMap의 키 순서가 불확실한 것과 대조)
☑️타입 안정성
☑️Null 허용
private EnumMap<Rank, Integer> progressStatistics(Lottos randomLottos, WinningLotto winningLotto) {
EnumMap<Rank, Integer> rankStatistics = new EnumMap<>(Rank.class);
for (Rank rank : Rank.values()) {
rankStatistics.put(rank, INIT_VALUE);
}
randomLottos.getLottos().forEach(randomLotto -> {
int matchCount = getMatchNumber(randomLotto, winningLotto);
boolean hasBonus = hasSameNumber(randomLotto, winningLotto);
Rank rank = Rank.matchLottoRank(matchCount, hasBonus);
rankStatistics.put(rank, rankStatistics.get(rank) + MATCH_COUNT);
});
return rankStatistics;
}
이렇게 matchCount와 hasBonus의 값을 구한 후 rank의 객체를 생성하고
각각의 rank를 key값으로 넣고 value값으로 각각의 rank가 몇 번으로 count가 되는지에 대해
1씩 추가해 주는 방향으로 Map을 관리하는 로직으로 구현하였다.
3️⃣ 팩토리 패턴 / 정적 팩토리 메서드
💡 생성자 오버로딩
이번 미션에서는 Lotto를 생성하는 방식에 두 가지가 있었다.
1. 구입금액을 바탕으로 RandomLotto 생성
2. 입력받은 6개의 숫자를 바탕으로 WinningLotto 생성
이렇게 코드를 짜다 보니 다음과 같은 생각이 들었다
Lotto 객체를 재사용할 수 있지 않을까?
RandomLotto와 WinningLotto는 둘 다 숫자 6개에 대해 리스트로 관리하는 객체이다.
따라서 Lotto라는 객체를 재사용했을 때 다음과 같은 이점이 있다.
☑️ validate로직의 재사용
☑️ 코드 간결화
그런데 Lotto의 객체를 생성하려니 문제가 생겼다.
기존의 RandomLotto를 위한 생성자로 Lotto클래스를 선언해 버렸기 때문에
-- Lottos 클래스 --
public List<Lotto> createLottos() {
List<Lotto> lottosNumber = new ArrayList<>();
for (int i = START_LOTTO_INDEX; i < ticketCount; i++) {
lottosNumber.add(LottoFactory.createAutoLotto());
}
return lottosNumber;
}
Lottos에서 Lotto를 생성하는 메서드가 있기에 생성자에 리스트를 반환한다.
--- Lotto 생성자 ---
public Lotto(List<LottoNumber> lottoNumbers) {
validateLotto(lottoNumbers);
this.lottoLottoNumbers = sortLottoNumber(lottoNumbers);
}
따라서 위와 같이 인자값으로 완성된 배열인 리스트를 받는다는 게 문제였다.
왜냐하면 WinningLotto는 String의 값으로 받아야 하고,
String의 값을 생성자로 넘겨줘야 입력값에 대한 검증을 모델에서 처리할 수 있기 때문이다
그렇게 하여 생각할 수 있는 방식은 두 가지였다.
✅ 1. 생성자 오버로딩
✅ 2. 문자열 parsing validate로직을 util로 빼기
그러나 문자열 parsing 하는 로직을 util로 빼버리게 되면 validate 하는 로직 또한 util로 빠지기에 관리가 어렵다고 판단하였다.
그래서 Lotto에 String을 인자로 받는 생성자를 하나 더 둠으로써 Lotto 내에서 모든 validate 로직을 관리하고자 하였다,
public Lotto(List<Number> numbers) {
validateLotto(numbers);
this.numbers = sortLottoNumber(numbers);
}
public Lotto(String input) { // winningLotto 생성자
validateStringFormat(input);
List<Number> parsedNumbers = parseStringInput(input);
validateLotto(parsedNumbers);
this.numbers = sortLottoNumber(parsedNumbers);
}
그래서 위와 같이 randomLotto와 winningLotto의 모든 validate로직을 관리하는 Lotto 클래스를 둘 수 있었다.
근데 이렇게 구성하고 보니 Lotto의 클래스에 너무 많은 책임이 부여되었고,
가독성이 떨어지며 관리가 힘들다고 판단이 서게 되었다.
💡 팩토리 패턴
그래서 어떻게 더 코드를 응집성을 살리면서 핵심 비즈니스 로직만 관리할 수 있을지에 대해 고민하다가
"팩토리 패턴"을 활용하여 관리하면 어떨까?라는 생각이 들었다.
하나의 공통적인 Lotto 생성자 클래스를 두고, LottoFactory에서 로또를 생성할 때,
해당 생성자만 최종적으로 거치게 된다면 핵심적인 validate 로직을 공통적으로 적용할 수 있으면서도
가독성과 응집도가 더 높아질 것이라 판단했다.
또한 팩토리 패턴으로 관리하였을 때 다음과 같은 이점이 있다.
☑️ 생성하는 역할에 이름을 부여함으로써 가독성 증가
ex) createAutoLotto(), createManualLotto()
☑️ Lotto 클래스는 비즈니스 로직에 더 집중할 수 있다 + 책임의 분리
public class LottoFactory {
private LottoFactory() {
}
public static Lotto createAutoLotto() {
return new Lotto(createLottoNumber());
}
public static Lotto createManualLotto(String input) {
List<LottoNumber> parsedLottoNumbers = parseStringInput(input);
return new Lotto(parsedLottoNumbers);
}
...
따라서 위와 같이 LottoFactory를 구현하여
RandomLotto와 WinningLotto를 생성하는 메서드 둘 다 Lotto의 생성자를 최종적으로 거치도록 구현하였다!
--- RandomLotto 생성 ---
lottosNumber.add(LottoFactory.createAutoLotto());
--- WinningLotto 생성 ---
LottoFactory.createManualLotto(rawWinningLotto);
이렇게 하여 위와 같은 구조로 손쉽게 Lotto를 생성할 수 있게 구현하였다.
💡 정적 팩토리 메서드
생성자 대신 정적 팩토리 메서드를 고려하라
< 조슈아 블로크(Joshua J. Bloch) >
이펙티브 자바를 펼쳐봤으면 한 번쯤은 봤을만한 내용이다.
정적 팩토리 메서드에 대한 개념만 알았지 어떻게 활용할지에 대해서는 감이 잡히지 않았다.
그래서 이번 미션에서 활용해 보면서 감을 기르고자 하였다.
그러나 무조건 생성자들 대신에 정적팩토리 메서드를 사용하기에는 무리였기에,
정적 팩토리 메서드를 통해 객체를 생성했을 때 이점이 가장 큰 경우를 생각해야 했다.
☑️ 메서드 이름을 통한 생성 의도 표현
public class User {
private String name;
private int age;
private User(String name, int age) {
this.name = name;
this.age = age;
}
// 정적 팩토리 메서드
public static User createAdult(String name) {
return new User(name, 18); // 18세 이상으로 간주
}
public static User createChild(String name) {
return new User(name, 0); // 0세로 초기화
}
}
createAdult()와 createChild()와 같이 직관적인 메서드명으로
생성의도를 쉽게 표현하여 가독성을 끌어올릴 수 있다.
☑️ 생성 로직 캡슐화
public class DatabaseConnection {
private String url;
private DatabaseConnection(String url) {
this.url = url;
}
public static DatabaseConnection createWithDefaultConfig() {
String defaultUrl = "jdbc:mysql://localhost:3306/default";
return new DatabaseConnection(defaultUrl);
}
public static DatabaseConnection createWithCustomConfig(String customUrl) {
return new DatabaseConnection(customUrl);
}
}
defaultUrl을 인자로 넘겨주는 대신 정적팩토리 메서드를 활용하면
url을 감추면서 위와 같이 객체를 생성할 수 있다.
☑️ 객체 캐싱을 활용하여 성능 최적화
public class BooleanWrapper {
private static final BooleanWrapper TRUE_INSTANCE = new BooleanWrapper(true);
private static final BooleanWrapper FALSE_INSTANCE = new BooleanWrapper(false);
private boolean value;
private BooleanWrapper(boolean value) {
this.value = value;
}
public static BooleanWrapper valueOf(boolean value) {
return value ? TRUE_INSTANCE : FALSE_INSTANCE;
}
}
객체를 생성할 때 동일한 객체를 반환하거나,
객체 생성을 제어하여 불필요한 객체 생성을 방지할 수 있다
즉 정리하자면 정적팩토리 메서드는 복잡한 생성 로직, 다양한 입력 처리, 객체 캐싱이 필요한 경우라면
정적 팩토리 메서드가 유용하다!
그래서 이번 미션에서 dto에서 인자를 복잡하게 받는 과정을 정적팩토리 메서드로 단순화하고자 하였다.
--- LottosDto ---
public static LottosDto of(List<Lotto> lottos, int ticketCount) {
return new LottosDto(lottos, ticketCount);
}
--- StatisticsDto ---
public static StatisticsDto of(EnumMap<Rank, Integer> rankStatistics, float profitRate) {
return new StatisticsDto(rankStatistics, profitRate);
}
이렇게 두 개의 dto에 대해 정적팩토리 메서드를 선언해 놓고,
(여러 매개 변수를 받는 경우에는 of로 네이밍, 하나의 매개 변수로 받는 경우에는 from으로 네이밍 한다.)
LottosDto lottosDto = randomLottos.toDto();
StatisticsDto statisticsDto = stastistics.toDto();
각각의 객체에서 toDto() 메서드를 구성함으로써 복잡한 인자를 캡슐화하면서 쉽게 객체를 생성하도록 구성하였다!
4️⃣ dto를 활용하여 문자열 parsing
이번 미션에서 또 중요하게 생각해봐야 할 문제는 어떻게 결괏값을 넘겨줄 것인가였다.
<< 최종 결과값 예시 >>
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%입니다.
최종 결괏값은 위와 같이 복잡한 형식을 띠고 있다.
여기에서 model의 비즈니스 로직을 통해 나오는 return값은
몇 개가 일치하는지
그리고 총 몇 개가 당첨되었는지에 대한 로직을 모델에서 담당한다.
그렇다면 위의 문자열을 만드는 과정은 어디에서 진행하는 게 좋을까??
다음과 같은 세 가지 방법을 떠올릴 수 있을 것 같다.
☑️ 1. 모델에서 문자열을 만든 후 넘겨준다.
장점: 데이터 안정성, return 되는 값만 view에 넘겨주면 되므로 추가 로직이 필요 없다
단점: 모델의 역할이 커진다.
☑️ 2. view에서 모델의 필요한 값만 가져온 후 view에서 문자열을 만든다
장점: view와 model을 완전히 분리할 수 있다
단점: view에도 로직이 생겨날 수 있다, 데이터가 변조될 수 있다.
☑️ 3. 모델 -> view로 넘겨주는 가운데 계층에 dto를 두고 dto 내에서 수행한다.
장점 : 데이터 안정성, model의 부담을 줄여준다.
단점 : 애매한 위치에 걸쳐져 있으므로 view와 domain을 완벽하게 분리하지 못한다.
나는 여기서 3번의 방법을 택하였고
public record StatisticsDto(EnumMap<Rank, Integer> rankStatistics, float profitRate) {
public static StatisticsDto of(EnumMap<Rank, Integer> rankStatistics, float profitRate) {
return new StatisticsDto(rankStatistics, profitRate);
}
public String getStatisticsAsString() {
return Arrays.stream(Rank.values())
.filter(rank -> rank != Rank.NONE)
.sorted(Collections.reverseOrder())
.map(rank -> String.format("%s - %d개", rank, rankStatistics.getOrDefault(rank, 0)))
.collect(Collectors.joining("\n"));
}
}
StatisticsDto에서 모델의 데이터를 넘겨주는 과정에서 문자열 parsing도 같이 진행하며
view에게 넘겨주었다.
5️⃣ util 클래스의 활용
지난 2주 차를 회고하면서 받았던 피드백의 내용이다.
스트링 문자열을 단순히 정수로 바꿔주는 로직과
문자열을 콤마 기준으로 나누는 단순 파싱 로직은
비즈니스 로직과 관련이 없다.
따라서 해당 로직들을 util 클래스로 뺀다면 모델은 비즈니스 로직에 더 집중할 수 있고
쉽게 해당 로직들을 재사용할 수 있다.
package lotto.util;
import java.util.Arrays;
import java.util.List;
public class InputParser {
private static final String INVALID_PARSE_INT_ERROR_MESSAGE = "[ERROR] 입력값에 대해 숫자로 입력해야합니다.";
private static final String INVALID_CONVERT_STRING_ERROR_MESSAGE = "[ERROR] 입력 값은 쉼표(,)로 구분된 숫자여야 합니다.";
private static final String STRING_FORMAT_PATTERN = "\\d+";
private static final String DELIMITER = ",";
public static int parseInt(String input) {
try {
return Integer.parseInt(input);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(INVALID_PARSE_INT_ERROR_MESSAGE);
}
}
public static List<Integer> convertStringToList(String input) {
return Arrays.stream(input.split(DELIMITER))
.map(String::trim) // 각 요소의 양쪽 공백 제거
.filter(part -> {
if (part.isEmpty() || !part.matches(STRING_FORMAT_PATTERN)) {
throw new IllegalArgumentException(INVALID_CONVERT_STRING_ERROR_MESSAGE);
}
return true;
})
.map(Integer::parseInt)
.toList();
}
}
그래서 이번에는 이러한 로직을 위와 같이 따로 util 클래스로 분리하였다.
이렇게 함으로써 모델의 역할을 줄여주었다.
실제로 parseInt() 메서드 같은 경우에는 다양한 객체에서 손쉽게 활용될 수 있었다
👀 의도적인 강한 결합?
객체에서 생성자로 객체를 받아올 때에는 크게 두 가지 방식이 있다.
✅ 첫 번째로는 객체를 외부에서 주입받는 방식이다.
public WinningLotto(Lotto winningLottoNum, Number bonusNumber) {
this.winningLottoNum = winningLottoNum;
this.bonusNum = bonusNumber;
}
위와 같이 외부에서 객체를 주입받아서 생성하게 되어버리면
Lotto나 Number와 같은 객체를 미리 Controller나 Service의 레이어에서 생성한 후 WinningLotto로 넘겨줘야 한다.
즉 이는 "느슨한 결합"이며, 모델 간의 의존성(결합도)을 낮춰준다.
따라서 시스템 전체의 유연성이 증가하고, 특정 클래스의 변경이 다른 클래스에 미치는 영향을 최소화하기에 유지보수에도 용이하다.
✅ 두 번째로는 객체를 내부에서 생성하는 방식이다.
public WinningLotto(Lotto winningLottoNum, String rawBonusNumber) {
this.winningLottoNum = winningLottoNum;
this.bonusNum = new Number(rawBonusNumber);
}
위와 같이 생성자 내부에서 new 연산자를 통해 Number를 생성하게 된다면
컨트롤러에서 Number 객체를 미리 생성하지 않아도 WinningLotto를 생성할 때 자동으로 만들어지고 할당된다.
이는 "강한 결합"이며, 모델 간의 의존성(결합도)이 높아진다.
따라서 단순하게 구현이 될지라도, 모델 간의 의존성이 높기에 유지보수에 좋지 않고 확장성과 유연성에 있어서도 부족하다.
그렇다면, 이렇게 봤을 때는 OOP에서는 무조건 느슨한 설계인 의존성 주입이 유리해 보인다.
하지만, 이번에 강한 결합으로 WinningLotto를 구현하였다.
이유는"캡슐화"였다.
만약 느슨할 결합으로 WinningLotto를 구현하려면 Number에 대한 객체를 사전에 Controller나 Service에서 정의를 하고,
객체에 넘겨줘야 한다.
즉, 느슨한 결합으로 구현하게 되는 경우에는 Number 객체가 Controller에서 노출된다.
하지만 WinningLotto에서 인자로 가지고 있는 일급객체인 Number에 대해서 Controller로 노출시키기가 싫었다.
Number와 같은 하위 객체들은 어차피 WinningLotto와 강한 연관성을 띠고 있고,
Number 객체를 WinningLotto 내부에서 생성한다면 Number라는 객체에 대해 감출 수 있다고 생각하였기 때문이다.
따라서 위와 같이 강한 결합을 통해 LottoNumber 객체를 숨기면서 구현하였다.