[우아한테크코스] 프리코스 3주차 후기
3주차 프리코스 과제는 로또를 구현하는 문제였다. 1 ~ 45 사이의 중복되지 않는 숫자 6개를 갖는 각 로또 번호 조합에 대해 당첨 결과와 수익률을 출력해야 하는 문제로 기존 자동차 게임 상위 버전의 느낌이 있었다. 요구사항이 까다롭고 디테일해서 큰 그림을 잘 그리는 것이 중요했다.
1. 애플리케이션 아키텍처 설계
크게 Domain, View, Controller로 구조를 나누어서 최대한 책임 분리를 하는 것으로 구조를 잡았다. Domain의 경우 로또와 관련된 모든 역할과 책임을 맡게, Controller는 서비스 플로우를 담당하게, View는 입출력에 대한 책임을 맡도록 구성했다.
Domain의 경우 먼저 로또 하나의 상태를 담당하는 Lotto 클래스와 사용자가 구매한 로또들을 관리하는 일급 컬렉션인 Lottos를 두었고, 로또 번호 생성을 맡는 LottoGenerator 클래스를 두었다. 로또의 당첨 결과는 요구사항의 열거 타입을 활용한 LottoRank 열거 타입을 활용했고 이를 이용해 로또 당첨 결과를 책임지는 LottoResult 클래스를 두었다.
Controller의 경우 View와 LottoGenerator, LottoResult를 받아서 비즈니스 로직을 수행하도록 했는데 LottoGenerator를 주입 받음으로써 로또 번호 생성 과정에 대한 유연함과 LottoResult를 주입 받음으로써 결과 관리의 유연성을 챙기고자 했다. 생각보다 결합이 강하게 느껴져서 좋은 구조가 아니었을 수도 있겠다는 생각은 들었다.
2. Domain - Lotto, Lottos 클래스
Lotto 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public final class Lotto {
private static final int SIZE = 6;
private final List<Integer> numbers;
/**
* 로또 번호 정렬 및 방어적 복사
*
* @param numbers
*/
public Lotto(List<Integer> numbers) {
validate(numbers);
List<Integer> copy = new ArrayList<>(numbers);
copy.sort(Comparator.naturalOrder());
this.numbers = List.copyOf(copy);
}
private void validate(List<Integer> numbers) {
if (numbers == null) {
throw new IllegalArgumentException(LOTTO_NULL_ERROR.getMessage());
}
if (numbers.size() != SIZE) {
throw new IllegalArgumentException(LOTTO_NUMS_INAPPROPRIATE_ERROR.getMessage());
}
if (new HashSet<>(numbers).size() != SIZE) {
throw new IllegalArgumentException(LOTTO_NUMS_DUPLICATED_ERROR.getMessage());
}
}
/**
* 당첨 로또에 포함된 번호의 수 반환
*
* @param winningLotto
* @return
*/
public int retainAll(Lotto winningLotto) {
return (int) numbers.stream()
.filter(winningLotto::contains)
.count();
}
/**
* 해당 번호 포함 여부 반환
*
* @param number
* @return
*/
public boolean contains(int number) {
return numbers.contains(number);
}
@Override
public boolean equals(Object object) {
if (object == null || getClass() != object.getClass()) return false;
Lotto lotto = (Lotto) object;
return Objects.equals(numbers, lotto.numbers);
}
@Override
public int hashCode() {
return Objects.hashCode(numbers);
}
@Override
public String toString() {
return numbers.toString();
}
}
Lotto 클래스 역시 로또 번호 리스트를 갖는 일종의 일급 컬렉션으로 생각했다. 따라서 방어적 복사를 통해 외부에서 주입한 로또 번호 리스트와의 참조를 끊게 생성자를 구성했다. 로또의 경우 retainAll 메서드를 통해 당첨 번호의 수를 반환하는 기능과 contains 메서드를 통해 특정 번호가 포함됐는지 여부를 반환하는 기능을 추가했는데 이를 통해 당첨 여부에 대한 판단까지는 로또에 맡겼다. 추가로 가변 필드가 없다는 점에서 불변 객체로 설계했고 equals랑 hashCode의 재정의를 통해 의도를 드러냈다.
Lottos 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* Lotto 일급 컬렉션
*/
public final class Lottos implements Iterable<Lotto> {
private final List<Lotto> lottos;
/**
* 방어적 복사 생성자
*
* @param lottos
*/
public Lottos(List<Lotto> lottos) {
validate(lottos);
this.lottos = List.copyOf(lottos);
}
private void validate(List<Lotto> lottos) {
if (lottos == null) {
throw new IllegalArgumentException(LOTTOS_NULL_ERROR.getMessage());
}
}
public int size() {
return lottos.size();
}
@Override
public Iterator<Lotto> iterator() {
return lottos.iterator();
}
}
사실상 별 기능은 없지만 로또들을 관리하는 역할을 맡겼고 로또 개수와 순회 정도만 제공했다. Iterator를 공개 API로 제공해 본 적이 없어서 괜찮을까 고민이 됐는데 로또 자체가 불변 객체라서 괜찮다는 판단을 했다.(사실 이거 때문에 불변 객체로 설계했다…)
3. Domain - LottoGenerator 클래스
LottoGenerator 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 로또 번호 생성기 클래스
*/
public final class LottoGenerator {
private static final int MIN = 1;
private static final int MAX = 45;
private static final int COUNT = 6;
private Lotto getLotto() {
return new Lotto(generateLotto());
}
/**
* count만큼 로또 번호를 생성하여 Lottos로 반환
*
* @param count
* @return
*/
public Lottos getLottos(int count) {
return new Lottos(generateLottos(count));
}
private List<Integer> generateLotto() {
return Randoms.pickUniqueNumbersInRange(MIN, MAX, COUNT);
}
private List<Lotto> generateLottos(int count) {
return IntStream.range(0, count)
.mapToObj(i -> getLotto())
.toList();
}
}
요구사항의 메서드로 로또 번호를 생성해주는 간단한 클래스로 로또 번호를 생성하는 더 좋은 클래스로의 전환을 염두하여 일반 클래스로 설계하고 변경에 용이하도록 했다. 다만 개발 편의상 인터페이스가 아닌 해당 클래스에 의존하도록 컨트롤러가 구성되어 있어서 아주 완벽하지는 않은 것 같다.
4. Domain - LottoRank, LottoResult 클래스
LottoRank 열거 타입은 아래와 같이 정의했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 로또 당첨 정보를 관리하는 열거 타입
*/
public enum LottoRank {
MISS(0, false, 0),
THREE(3, false, 5_000),
FOUR(4, false, 50_000),
FIVE(5, false, 1_500_000),
FIVE_AND_BONUS(5, true, 30_000_000),
SIX(6, false, 2_000_000_000);
private final int matchCount;
private final boolean matchBonus;
private final int winnings;
LottoRank(int matchCount, boolean matchBonus, int winnings) {
this.matchCount = matchCount;
this.matchBonus = matchBonus;
this.winnings = winnings;
}
public int getMatchCount() {
return matchCount;
}
public boolean isMatchBonus() {
return matchBonus;
}
public int getWinnings() {
return winnings;
}
public static LottoRank valueOf(int matchCount, boolean matchBonus) {
if (matchCount == 6) return SIX;
if (matchCount == 5 && matchBonus) return FIVE_AND_BONUS;
if (matchCount == 5) return FIVE;
if (matchCount == 4) return FOUR;
if (matchCount == 3) return THREE;
return MISS;
}
}
열거 타입을 활용하라는 요구사항을 보자마자 여기가 적합하겠다고 생각했다. 각 당첨에 대해 열거 타입으로 관리하여 직관성을 높였다.
LottoResult 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 로또 구매 결과를 관리하는 일급 컬렉션
*/
public final class LottoResult {
// 로또 당첨 결과를 저장하는 EnumMap
private final EnumMap<LottoRank, Integer> lottoResults;
public LottoResult() {
this.lottoResults = new EnumMap<>(LottoRank.class);
for (LottoRank rank : LottoRank.values()) {
this.lottoResults.put(rank, 0);
}
}
public void add(LottoRank rank) {
lottoResults.put(rank, lottoResults.get(rank) + 1);
}
public void forEach(BiConsumer<LottoRank, Integer> action) {
lottoResults.forEach(action);
}
/**
* 총 당첨금을 반환하는 메서드
*
* @return
*/
private long calculateTotalWinnings() {
return lottoResults.entrySet().stream()
.mapToLong(e -> (long) e.getKey().getWinnings() * e.getValue())
.sum();
}
/**
* 수익률(%)을 계산하여 반환하는 메서드
*
* @param amount
* @return
*/
public double yield(int amount) {
return (double) calculateTotalWinnings() / amount * 100;
}
}
LottoRank 열거 타입을 활용하는 클래스로 당첨 결과를 관리하는 사실상 일급 컬렉션으로 생각된다. 열거 타입을 key로 당첨 횟수를 value로 갖으므로 EnumMap을 활용하여 성능 향상을 도모했다. OutView에서 출력에 활용하므로 이에 대한 편의 메서드까지 책임졌다.
5. View - InputView, OutputView 클래스
InputView 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
public class InputView {
private static final String READ_PURCHASE_AMOUNT = "구입 금액을 입력해 주세요.";
private static final String READ_WINNING_NUMBERS = "당첨 번호를 입력해 주세요.";
private static final String READ_BONUS_NUMBER = "보너스 번호를 입력해 주세요.";
private static final String SHOW_PURCHASED_LOTTOS = "개를 구매했습니다.\n";
private static final int UNIT = 1_000;
private static final String DELIMITER = ",";
private static final int LOTTO_NUMS = 6;
private static final int MIN = 1;
private static final int MAX = 45;
public int readPurchaseAmount() {
while (true) {
try {
System.out.println(READ_PURCHASE_AMOUNT);
String input = Console.readLine();
System.out.println();
readPurchaseAmountValidate(input);
return Integer.parseInt(input);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
private void readPurchaseAmountValidate(String input) {
try {
int amount = Integer.parseInt(input);
if (amount < UNIT || amount % UNIT != 0) {
throw new IllegalArgumentException(PURCHASE_AMOUNT_ERROR.getMessage());
}
} catch (NumberFormatException e) {
throw new IllegalArgumentException(PURCHASE_AMOUNT_ERROR.getMessage());
}
}
public void showPurchasedLottos(Lottos lottos) {
StringBuilder sb = new StringBuilder();
sb.append(lottos.size()).append(SHOW_PURCHASED_LOTTOS);
for (Lotto lotto : lottos) {
sb.append(lotto).append("\n");
}
System.out.println(sb);
}
public Lotto readWinningLotto() {
while (true) {
try {
System.out.println(READ_WINNING_NUMBERS);
String input = Console.readLine();
System.out.println();
readWinningLottoValidate(input);
return new Lotto(Arrays.stream(input.split(DELIMITER))
.map(Integer::parseInt)
.toList());
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
private void readWinningLottoValidate(String input) {
String[] split = input.split(DELIMITER);
if (split.length != LOTTO_NUMS) {
throw new IllegalArgumentException(LOTTO_NUMS_INAPPROPRIATE_ERROR.getMessage());
}
}
public int readBonusNumber() {
while (true) {
try {
System.out.println(READ_BONUS_NUMBER);
String input = Console.readLine();
System.out.println();
readBonusNumberValidate(input);
return Integer.parseInt(input);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
}
}
private void readBonusNumberValidate(String input) {
try {
int bonusNumber = Integer.parseInt(input);
if (bonusNumber < MIN || bonusNumber > MAX) {
throw new IllegalArgumentException(BONUS_NUM_INAPPROPRIATE_ERROR.getMessage());
}
} catch (NumberFormatException e) {
throw new IllegalArgumentException(BONUS_NUM_INAPPROPRIATE_ERROR.getMessage());
}
}
}
이번에는 입력과 입력값에 대한 검증을 같이 책임지도록 InputView를 구현했다. 기존의 검증기를 분리하는 방식에 비해 코드가 약간 어수선한 감이 있는데 과제의 기본 로또 구현체가 검증 메서드를 같이 포함하고 있었다는 점에서 같이 넣는 방식을 도입했다.
OutputView 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public final class OutputView {
private static final String WINNING_STATISTICS_INTRO = "당첨 통계\n---\n";
public void render(LottoResult result, int amount) {
StringBuilder sb = new StringBuilder(WINNING_STATISTICS_INTRO);
result.forEach((rank, count) -> {
if (rank != MISS) {
sb.append(getTemplate(rank, count));
}
});
sb.append(getTotalYieldTemplate(result, amount));
System.out.println(sb);
}
private String getTemplate(LottoRank rank, int count) {
if (rank.isMatchBonus()) {
return rank.getMatchCount() + "개 일치, 보너스 볼 일치 (" + String.format("%,d", rank.getWinnings()) + "원) - " + count + "개\n";
}
return rank.getMatchCount() + "개 일치 (" + String.format("%,d", rank.getWinnings()) + "원) - " + count + "개\n";
}
private String getTotalYieldTemplate(LottoResult result, int amount) {
return "총 수익률은 " + String.format("%.1f", result.yield(amount)) + "%입니다.";
}
}
출력 포맷이 약간 지저분해서 개인적으로 만족스럽지는 않은 코드다. 매직 스트링을 상수를 활용한 완전한 제거는 불가능해 보이는데 이를 별도의 메서드로 빼는 게 과연 클린 코드인가에 대한 의문도 있어서 좋은 구현 예시가 궁금하다.
6. Controller - LottoController
LottoController 클래스는 아래와 같이 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class LottoController {
private static final int UNIT = 1_000;
private final InputView inputView;
private final OutputView outputView;
private final LottoGenerator lottoGenerator;
private final LottoResult lottoResult;
public LottoController(InputView inputView,
OutputView outputView,
LottoGenerator lottoGenerator,
LottoResult lottoResult
) {
this.inputView = inputView;
this.outputView = outputView;
this.lottoGenerator = lottoGenerator;
this.lottoResult = lottoResult;
}
public void run() {
int amount = inputView.readPurchaseAmount();
Lottos lottos = lottoGenerator.getLottos(amount / UNIT);
inputView.showPurchasedLottos(lottos);
Lotto winningLotto = inputView.readWinningLotto();
int bonusNumber = inputView.readBonusNumber();
calculate(lottoResult, lottos, winningLotto, bonusNumber);
outputView.render(lottoResult, amount);
}
private void calculate(LottoResult lottoResult, Lottos lottos, Lotto winningLotto, int bonusNumber) {
for (Lotto lotto : lottos) {
int matchCount = getMatchCount(lotto, winningLotto);
boolean matchBonus = isMatchBonus(lotto, bonusNumber);
LottoRank lottoRank = LottoRank.valueOf(matchCount, matchBonus);
lottoResult.add(lottoRank);
}
}
private int getMatchCount(Lotto lotto, Lotto winningLotto) {
return lotto.retainAll(winningLotto);
}
private boolean isMatchBonus(Lotto lotto, int bonusNumber) {
return lotto.contains(bonusNumber);
}
}
run 메서드는 사실상 main 메서드의 플로우를 옮겨 놓은 정도이며 calculate 메서드가 각 번호별 당첨 여부를 LottoResult에 전달하는 역할을 수행한다. 해당 기능에 대한 책임도 LottoResult가 맡는게 맞는지에 대한 고민이 있었는데 좀 더 순수하게 두고 싶은 마음에 컨트롤러에 위임했다. 로또 당첨이라는건 변하지 않는 로직이어서 LottoResult에 두는게 더 나았을 것 같다.
7. 과제 후기
기능 구현만 본다면 그렇게 어렵지는 않았지만 객체 지향적 설계에서는 제법 어렵게 느껴졌던 과제였다. 오버 엔지니어링은 지양하는 입장이라 필요에 따라 기능을 분리해나가는 것을 좋아하는데 강한 결합력을 끊어내기가 까다롭게 느껴졌다. 객체가 자신의 상태를 통해 동작을 제공하는 캡슐화된 설계의 중요성을 느낄 수 있었고 공식 피드백 역시 getter를 통한 필드 제공보다는 해당 기능까지 객체가 갖길 원하는 것 같아서 이 부분은 잘 설계하려고 노력했던 것 같다.
