프리코스 3주차를 진행하며
들어가며
우아한테크코스 8기 프리코스 3주차 과제는 로또 게임 구현이었다. 1주차 문자열 계산기는 단순 로직, 2주차 자동차 경주는 객체 만들기에 집중했다면, 이번엔 객체들이 서로 협력하는 구조를 만들어야 했다. 그리고 생각보다 훨씬 복잡했다. “당첨 번호도 로또인가?”, “비교는 누가 하지?”, “일급 컬렉션은 어디까지 책임져야 하지?” 끝도 없이 쏟아지는 질문들 속에서 하나씩 답을 찾아가는 과정을 기록하고자 한다.
1. 로또 vs 당첨 번호: 같은 객체로 할까 다른 객체로 할까?
처음 요구사항을 읽으며 클래스 구조를 상상해봤다.
“로또가 있고, 당첨 번호가 있고, 비교하고… 음, 간단한데?”
근데 막상 설계하려니 막막했다.
Q. 당첨 번호도 Lotto 클래스를 쓸까?
처음엔 당연히 당첨 번호도 Lotto를 쓰면 되지 않나 싶었다. 어차피 둘 다 6개 숫자 집합이기 때문이다.
1
2
3
4
// 방법 1: Lotto 재사용
Lotto purchasedLotto = Lotto.from(Arrays.asList(1, 2, 3, 4, 5, 6));
Lotto winningLotto = Lotto.from(Arrays.asList(7, 8, 9, 10, 11, 12));
int bonusNumber = 13;
DDD 관점에서는 내용이 달라도 같은 값이면 같은 객체로 판단한다고 한다. 실제로 처음 설계에서는 Lotto winningLotto로 작성했다.
근데 코드를 짜다 보니 이상했다.
문제 1: 보너스 번호는 어디에?
1
2
3
4
5
6
7
8
9
// 보너스 번호를 어디에 둘까?
Lotto winningLotto = Lotto.from(Arrays.asList(1, 2, 3, 4, 5, 6));
int bonusNumber = 7; // 따로 관리?
// 아니면 Lotto에 보너스를 추가?
public class Lotto {
private final List<Integer> numbers;
private Integer bonusNumber; // 이건 이상하다...
}
보너스 번호는 당첨 번호에만 있는 개념인데 Lotto에 넣기엔 애매했다. 구매한 로또에는 보너스가 없으니까.
문제 2: 개념적으로 다르다
1
2
3
4
5
// 구매한 로또
Lotto purchasedLotto = Lotto.from(numbers);
// 당첨 번호
Lotto winningLotto = Lotto.from(numbers);
같은 클래스를 쓰니까 코드만 봐서는 구분이 안 된다. “이게 구매한 건가, 당첨 번호인가?” 헷갈린다.
그래서 LottoNumbers라는 공통 클래스를 만들어 둘 다 래핑하는 구조를 시도해봤다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class LottoNumbers {
private final List<Integer> numbers;
// 공통 검증 로직
}
public class Lotto {
private final LottoNumbers numbers;
}
public class LottoWinningNumbers {
private final LottoNumbers numbers;
private final int bonusNumber;
}
근데 이것도 너무 복잡했다. 계층이 하나 더 생기니까 코드가 오히려 더 복잡해지고 “이게 진짜 필요한가?” 싶었다.
해결방법: 독립된 LottoWinningNumbers 클래스
결국 돌고 돌아 LottoWinningNumbers라는 독립 클래스를 만들었다.
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
public class Lotto {
private final List<Integer> numbers;
private Lotto(List<Integer> numbers) {
this.numbers = List.copyOf(numbers);
}
public static Lotto from(List<Integer> numbers) {
LottoValidator.validate(numbers);
return new Lotto(numbers);
}
}
public class LottoWinningNumbers {
private final List<Integer> winningNumbers;
private final int bonusNumber;
private LottoWinningNumbers(List<Integer> winningNumbers, int bonusNumber) {
this.winningNumbers = List.copyOf(winningNumbers);
this.bonusNumber = bonusNumber;
}
public static LottoWinningNumbers of(List<Integer> winningNumbers, int bonusNumber) {
LottoValidator.validate(winningNumbers);
validateBonusNumber(winningNumbers, bonusNumber);
return new LottoWinningNumbers(winningNumbers, bonusNumber);
}
private static void validateBonusNumber(List<Integer> winningNumbers, int bonusNumber) {
if (winningNumbers.contains(bonusNumber)) {
throw new IllegalArgumentException(DUPLICATED_BONUS_NUMBER);
}
}
}
왜 이렇게 결정했나?
- 보너스 번호는 당첨 번호만의 개념 -
Lotto에 억지로 넣을 필요 없다 - 역할이 명확하다 - 구매한 로또 vs 당첨 번호
- 검증 로직은
LottoValidator로 공유 - 중복 제거
내부에서 Lotto를 재사용하려다가 아예 List<Integer>로 직접 관리하는 방식으로 바꿨다. 개념적으로 다른 걸 억지로 같은 클래스에 우겨넣을 필요는 없다는 걸 느꼈다.
2. Getter 메서드
2주차에서 “getter를 지양하라”는 피드백을 듣고 이번엔 정말 가능한 한 getter를 안 쓰려고 했다.
“객체에게 메시지를 보내자!” 를 진짜 철저히 지키고 싶었다.
Lotto가 정렬까지 책임지게
처음에는 Lotto.getSortedNumbers() 같은 메서드를 만들어서 Lotto가 스스로 정렬된 문자열을 반환하게 했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 초기 시도
public class Lotto {
public String toSortedString() {
return numbers.stream()
.sorted()
.map(String::valueOf)
.collect(Collectors.joining(", ", "[", "]"));
}
}
// View에서 사용
for (Lotto lotto : lottos.getLottos()) {
System.out.println(lotto.toSortedString());
}
View에서는 그냥 받기만 하면 되니까 깔끔하다고 생각했다.
고민: 정렬은 누구의 책임인가?
근데 이게 최선의 구조인가 싶었다.
질문 1: “정렬은 Domain의 책임인가?”
- 로또 번호를 정렬하는 행위는 “출력을 위한 것”
- 비즈니스 로직과는 관련 없음
질문 2: “출력 형식이 바뀌면?”
1
2
// [1, 2, 3] → 1-2-3 으로 바뀐다면?
// Domain 객체를 수정해야 하나?
출력 형식은 View의 관심사인데, Domain이 이걸 알아야 하나?
해결방법: View에서 정렬, 불변 리스트로 반환
고민 끝에 결국 getNumbers()를 만들고 View에서 정렬하기로 했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Lotto {
public List<Integer> getLottoNumbers() {
return Collections.unmodifiableList(numbers);
}
}
public class OutputView {
private static void printLotto(Lotto lotto) {
String sortedLottoNumbers = lotto.getLottoNumbers().stream()
.sorted()
.map(String::valueOf)
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(sortedLottoNumbers);
}
}
대신 불변 리스트로 반환해서 외부에서 변경은 못하게 막았다.
적절한 책임 분리가 중요하다!
완전히 실패한 건 아니지만, “무조건 getter 제거”가 정답은 아니라는 걸 배웠다.
중요한 건 “적절한 책임 분리” 였다.
- 출력 형식은 View가 담당
- 비즈니스 로직은 Domain이 담당
그럼에도 이 피드백은 내게 너무나도 큰 성장의 발판을 줬다. 새로운 시각으로 코드를 바라볼 수 있게 되어 피드백 하나하나가 참 소중하다.
3. 검증 로직: 테세우스의 배
재미있었던 부분은 검증 로직 작성이었다.
처음 작성한 코드를 더 좋게 조금씩 조금씩 바꾸다 보니, 사실상 원본 코드가 거의 없는 코드가 된 것이다!
문제 상황: 검증 로직 중복
Lotto 클래스랑 LottoWinningNumbers 클래스를 만들다 보니 검증 로직이 완전히 똑같은 부분이 생겼다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Lotto 클래스
public static Lotto from(List<Integer> numbers) {
validateSize(numbers);
validateNumberRange(numbers);
validateDuplicates(numbers);
return new Lotto(numbers);
}
// LottoWinningNumbers 클래스
public static LottoWinningNumbers of(List<Integer> winningNumbers, int bonusNumber) {
validateSize(winningNumbers); // 중복!
validateNumberRange(winningNumbers); // 중복!
validateDuplicates(winningNumbers); // 중복!
validateBonusNumber(winningNumbers, bonusNumber);
return new LottoWinningNumbers(winningNumbers, bonusNumber);
}
6개인지, 1~45 범위인지, 중복인지 등의 검증이 완전히 같다. 이걸 어떻게 개선할지 고민했다.
객체 스스로 검증 vs 검증 로직 재사용
처음엔 “검증은 객체가 스스로 해야 하는 거 아닌가?”라는 생각이 들었다.
2주차에서도 엄청난 검증 로직 고민 끝에 자동차가 자기 이름을 스스로 검증하게 만들었기 때문이다.
근데 이번엔 상황이 달랐다. 같은 검증 로직을 두 곳에서 쓰는데 이걸 두 번 작성하는 게 맞나? 라는 생각이 들었다.
해결방법: LottoValidator 분리
고민하다가 “검증 로직 재사용”과 “객체 스스로 검증” 사이에서 균형을 찾았다.
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 class LottoValidator {
private LottoValidator() {
throw new AssertionError("유틸리티 클래스는 인스턴스화할 수 없습니다.");
}
public static void validate(List<Integer> numbers) {
validateSize(numbers);
validateNumberRange(numbers);
validateDuplicates(numbers);
}
private static void validateSize(List<Integer> numbers) {
if (numbers.size() != LOTTO_SIZE) {
throw new IllegalArgumentException(INVALID_LOTTO_SIZE);
}
}
private static void validateNumberRange(List<Integer> numbers) {
if (hasNumberOutOfRange(numbers)) {
throw new IllegalArgumentException(INVALID_LOTTO_NUMBER_RANGE);
}
}
private static boolean hasNumberOutOfRange(List<Integer> numbers) {
return numbers.stream()
.anyMatch(LottoValidator::isOutOfRange);
}
private static boolean isOutOfRange(int number) {
return number < MINIMUM_LOTTO_NUMBER || number > MAXIMUM_LOTTO_NUMBER;
}
private static void validateDuplicates(List<Integer> numbers) {
if (hasDuplicatedNumber(numbers)) {
throw new IllegalArgumentException(DUPLICATED_LOTTO_NUMBER);
}
}
private static boolean hasDuplicatedNumber(List<Integer> numbers) {
return numbers.size() != new HashSet<>(numbers).size();
}
}
사용
1
2
3
4
5
6
7
8
9
10
11
12
// Lotto
public static Lotto from(List<Integer> numbers) {
LottoValidator.validate(numbers); // 공통 검증
return new Lotto(numbers);
}
// LottoWinningNumbers
public static LottoWinningNumbers of(List<Integer> winningNumbers, int bonusNumber) {
LottoValidator.validate(winningNumbers); // 공통 검증
validateBonusNumber(winningNumbers, bonusNumber); // 고유 검증
return new LottoWinningNumbers(winningNumbers, bonusNumber);
}
왜 이렇게 했나?
- 중복 제거 - 같은 검증 로직을 한 곳에서 관리
- 객체 스스로 검증 - 여전히 생성 시점에 검증함
- 책임 분리 - 보너스 번호 검증은
LottoWinningNumbers에 남김
이렇게 하니까 중복도 없어지고 각 클래스도 자기 책임을 다하는 것 같았다. 보너스 번호 중복 검증만 LottoWinningNumbers에 남겨뒀는데, 이건 로또엔 없는 당첨 번호만의 고유한 검증이니까 여기 있는 게 맞다고 판단했다.
코드가 완전히 바뀌다
꽤 깔끔하게 잘 바꾸었다고 느꼈다. 재밌었던 점은 코드를 조금씩 고쳐나갈 땐 몰랐는데 다 바꾸고 보니 처음 작성한 코드가 사실 하나도 없었다.
분명 같은 내용을 작성했었고 잘 동작하던 코드였는데 말이다. 마치 테세우스의 배처럼!
4. EnumMap과 타입 안전성
처음으로 EnumMap을 시도해봤다.
Q. 등수별 통계를 어떻게 저장할까?
당첨 통계를 저장하려면 등수별로 몇 개 당첨됐는지 세어야 한다.
1
2
3
4
5
// 일반 HashMap 사용?
Map<Rank, Integer> statistics = new HashMap<>();
statistics.put(Rank.FIRST, 0);
statistics.put(Rank.SECOND, 0);
// ...
근데 HashMap은 타입 안전하지 않다. key에 뭐든지 들어갈 수 있다.
해결방법: EnumMap 사용
이펙티브 자바에서 공부한 내용인데 이번 과제의 통계 부분을 보고 바로 적용가능하다 싶었다.
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
public class Lottos {
public LottoWinningStatistics calculateWinningStatistics(LottoWinningNumbers winningNumbers) {
Map<Rank, Integer> statistics = new EnumMap<>(Rank.class);
// 모든 Rank 값 초기화
for (Rank rank : Rank.values()) {
statistics.put(rank, 0);
}
// 각 로또별 등수 계산
for (Lotto lotto : lottos) {
Rank rank = LottoResult.calculateRank(lotto, winningNumbers);
statistics.merge(rank, 1, Integer::sum);
}
return new LottoWinningStatistics(statistics);
}
}
public class LottoWinningStatistics {
private final Map<Rank, Integer> statistics;
LottoWinningStatistics(Map<Rank, Integer> statistics) {
this.statistics = statistics;
}
public Map<Rank, Integer> getStatistics() {
return new EnumMap<>(statistics); // 방어적 복사
}
}
EnumMap의 장점
1. 타입 안전성
1
2
Map<Rank, Integer> statistics = new EnumMap<>(Rank.class);
// Rank가 아닌 다른 타입은 컴파일 에러
2. 성능 최적화
- 내부적으로 배열 기반
HashMap보다 빠름
3. 모든 Enum 값 자동 포함
1
2
3
4
for (Rank rank : Rank.values()) {
statistics.put(rank, 0);
}
// 모든 등수가 자동으로 포함됨
EnumMap으로 타입 안전성도 확보되고 성능도 더 좋다고 해서 공부한 내용을 잊지 않고 적절하게 적용했다는 사실이 맘에 들었다.
Enum을 key로 쓸 때는 무조건 EnumMap을 고려해보자!
5. 상수 관리와 매직 넘버
이번엔 처음부터 상수를 제대로 관리하려고 했다. 1, 2주차에서 매직 넘버 때문에 고생했던 기억이 있었기 때문이다.
각 클래스에 private static final 로 선언해야하나?
처음엔 1주차, 2주차와 마찬가지로 각 클래스에서 매직 넘버와 매직 스트링이 있다면 이를 private static final 상수로 맨 위에 작성해주었다.
1
2
3
4
5
6
7
8
9
10
11
public class Lotto {
private static final int LOTTO_SIZE = 6;
private static final int MIN_NUMBER = 1;
private static final int MAX_NUMBER = 45;
}
public class LottoWinningNumbers {
private static final int LOTTO_SIZE = 6; // 중복!
private static final int MIN_NUMBER = 1; // 중복!
private static final int MAX_NUMBER = 45; // 중복!
}
근데 아까 말했듯이 로또 클래스와 당첨 번호 클래스가 비슷한 점이 많아 이런 상수도 하나로 모으면 어떨까 싶어 바로 리팩터링을 시도해보았다.
해결방법: LottoConfig 상수 클래스
먼저 LottoConfig라는 상수를 모으는 클래스를 만들어 로또 관련 상수를 다 모았다.
1
2
3
4
5
6
7
8
9
10
public class LottoConfig {
public static final int LOTTO_SIZE = 6;
public static final int MINIMUM_LOTTO_NUMBER = 1;
public static final int MAXIMUM_LOTTO_NUMBER = 45;
public static final int LOTTO_PRICE = 1000;
private LottoConfig() {
throw new AssertionError("유틸리티 클래스는 인스턴스화할 수 없습니다.");
}
}
이와 마찬가지로 예외 처리 시 출력할 에러 메시지도 따로 클래스로 만들어서 관리했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class ErrorMessage {
private static final String PREFIX = "[ERROR] ";
public static final String INVALID_LOTTO_SIZE = PREFIX + "로또 번호는 6개여야 합니다.";
public static final String INVALID_LOTTO_NUMBER_RANGE = PREFIX + "로또 번호는 1부터 45사이의 숫자여야 합니다.";
public static final String DUPLICATED_LOTTO_NUMBER = PREFIX + "로또 번호는 중복될 수 없습니다.";
public static final String INVALID_PURCHASE_AMOUNT = PREFIX + "구입 금액은 1000원 단위여야 합니다.";
// ...
private ErrorMessage() {
throw new AssertionError("유틸리티 클래스는 인스턴스화할 수 없습니다.");
}
}
장점
1. 변경 지점 단일화
1
2
// 로또가 7개로 바뀐다면?
public static final int LOTTO_SIZE = 7; // 여기 하나만 바꾸면 됨!
2. 의미 명확
1
2
if (numbers.size() != 6) // 6이 뭐지?
if (numbers.size() != LOTTO_SIZE) // 로또 크기구나!
3. 유지보수 용이
1
2
// PREFIX만 바꾸면 모든 에러 메시지 변경
private static final String PREFIX = "[ERROR] ";
이렇게 하니까 유지보수가 편했다. 나중에 “로또가 7개로 바뀐다면?” 같은 생각을 할 때 상수 하나만 바꾸면 되기 때문이다.
그리고 코드 읽을 때도 if (numbers.size() != 6) 보다 if (numbers.size() != LOTTO_SIZE)가 훨씬 이해하기 쉬웠다.
6. Enum 전략 패턴
당첨 등수를 뜻하는 Rank 를 Enum으로 설계했다. Enum을 사용하는 것이 요구사항이기도 했다.
Rank Enum 설계
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 enum Rank {
FIRST(6, 2000000000, false, "6개 일치 (2,000,000,000원)"),
SECOND(5, 30000000, true, "5개 일치, 보너스 볼 일치 (30,000,000원)"),
THIRD(5, 1500000, false, "5개 일치 (1,500,000원)"),
FOURTH(4, 50000, false, "4개 일치 (50,000원)"),
FIFTH(3, 5000, false, "3개 일치 (5,000원)"),
NONE(0, 0, false, "꽝");
private static final int MINIMUM_MATCH_COUNT = 3;
private final int matchCount;
private final int prize;
private final boolean requiresBonus;
private final String description;
Rank(int matchCount, int prize, boolean requiresBonus, String description) {
this.matchCount = matchCount;
this.prize = prize;
this.requiresBonus = requiresBonus;
this.description = description;
}
public static Rank of(int matchCount, boolean hasBonusMatch) {
if (matchCount < MINIMUM_MATCH_COUNT) {
return NONE;
}
for (Rank rank : values()) {
if (rank == NONE) {
continue;
}
if (rank.matchesCondition(matchCount, hasBonusMatch)) {
return rank;
}
}
return NONE;
}
private boolean matchesCondition(int matchCount, boolean hasBonusMatch) {
return this.matchCount == matchCount && this.requiresBonus == hasBonusMatch;
}
public int getPrize() {
return prize;
}
public String getDescription() {
return description;
}
}
Enum의 장점
1. 등수 관련 로직 모으기
1
2
3
4
5
6
7
8
// 등수 판정
Rank rank = Rank.of(matchCount, hasBonusMatch);
// 상금 조회
int prize = rank.getPrize();
// 출력 문구
String desc = rank.getDescription();
모든 등수 관련 로직이 Rank 하나에 모여있다!
2. 타입 안전성
1
Rank rank = Rank.FIRST; // 컴파일 타임에 체크
3. 출력 간편함
1
2
3
4
// View에서
for (Rank rank : displayOrder) {
System.out.printf("%s - %d개%n", rank.getDescription(), count);
}
description 필드로 출력 메시지까지 관리!
Enum은 단순한 상수 집합이 아니라 행동을 가진 객체 다. 등수 관련 모든 로직을 Rank에 모으니까 코드가 엄청 깔끔해졌다.
7. Controller: 흐름 제어
우테코에 뜨거운 토론 주제로 MVC가 있다.
코드 리뷰를 하며 다른 분들의 코드도 많이 참고해보고 배웠기에 이번 3주차는 1, 2주차 때처럼 Application에 모든 로직을 다 넣지 않고 Controller로 분리 해봤다.
기존 - Application에 모든 로직
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Application {
public static void main(String[] args) {
// 구입 금액 입력
String input = InputView.readPurchaseMoney();
int money = Integer.parseInt(input);
// 로또 구매
Lottos lottos = buyLottos(money);
// 당첨 번호 입력
// ...
// 결과 계산
// ...
}
}
너무 길고 복잡하다.
개선 - Controller 분리
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
public class Application {
public static void main(String[] args) {
try {
LottoController controller = new LottoController();
controller.run();
} finally {
Console.close();
}
}
}
public class LottoController {
public void run() {
int purchaseMoney = readPurchaseMoney();
Lottos lottos = buyLottos(purchaseMoney);
OutputView.printLottos(lottos);
LottoWinningNumbers winningNumbers = readTotalWinningNumbers();
LottoWinningStatistics winningStatistics = lottos.calculateWinningStatistics(winningNumbers);
OutputView.printWinningResult(winningStatistics, purchaseMoney);
}
private int readPurchaseMoney() {
while (true) {
try {
String purchaseMoneyInput = InputView.readPurchaseMoney();
return parsePurchaseMoney(purchaseMoneyInput);
} catch (IllegalArgumentException e) {
OutputView.printError(e.getMessage());
}
}
}
// ...
}
Controller의 역할
1. 전체 게임 흐름 제어
1
2
3
4
5
6
7
8
9
public void run() {
int purchaseMoney = readPurchaseMoney(); // 1. 금액 입력
Lottos lottos = buyLottos(purchaseMoney); // 2. 로또 구매
OutputView.printLottos(lottos); // 3. 로또 출력
LottoWinningNumbers winningNumbers = readTotalWinningNumbers(); // 4. 당첨 번호 입력
LottoWinningStatistics statistics = lottos.calculateWinningStatistics(winningNumbers); // 5. 통계 계산
OutputView.printWinningResult(statistics, purchaseMoney); // 6. 결과 출력
}
이렇게 작성하니 흐름이 한눈에 보였다.
2. 입력 파싱 + 형식 검증
1
2
3
4
5
private int parsePurchaseMoney(String purchaseMoneyInput) {
int purchasedMoney = parseNumber(purchaseMoneyInput);
validatePurchaseMoney(purchasedMoney);
return purchasedMoney;
}
3. 재입력 처리
1
2
3
4
5
6
7
8
9
10
private int readPurchaseMoney() {
while (true) {
try {
String purchaseMoneyInput = InputView.readPurchaseMoney();
return parsePurchaseMoney(purchaseMoneyInput);
} catch (IllegalArgumentException e) {
OutputView.printError(e.getMessage());
}
}
}
예외 발생 시 메시지 출력하고 다시 입력받기도 이렇게 작성했다.
4. Domain과 View 연결
1
2
Lottos lottos = buyLottos(purchaseMoney); // Domain
OutputView.printLottos(lottos); // View
장점
1. Application이 간결해짐
1
2
3
4
public static void main(String[] args) {
LottoController controller = new LottoController();
controller.run();
}
딱 세 줄이다!
2. 책임 명확
Application: Controller 생성 및 실행만Controller: 실제 게임 로직 흐름Domain: 비즈니스 로직View: 입출력
Service 계층은 없는데?
Service 계층은 만들지 않았다. 이유는
- 미션 규모에 과도
- Domain이 이미 충분한 로직 제공
- 불필요한 계층 추가 방지
복잡한 비즈니스 로직이 많다면 Service가 필요하겠지만, 이번 과제에서는 Controller → Domain 직접 호출로 충분했다.
8. 방어적 복사: 불변 객체 만들기
이펙티브 자바 아이템 50 “방어적 복사”를 적용해봤다.
문제상황: 외부에서 내부 상태 변경 가능
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Lotto {
private List<Integer> numbers;
public Lotto(List<Integer> numbers) {
this.numbers = numbers; // 위험!
}
public List<Integer> getNumbers() {
return numbers; // 위험!
}
}
// 사용하는 쪽
List<Integer> nums = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6));
Lotto lotto = new Lotto(nums);
nums.add(7); // 외부에서 변경!
lotto.getNumbers().add(8); // 외부에서 변경!
외부에서 내부 상태를 마음대로 바꿀 수 있다! 반드시 막아야하기에 다음과 같이 해결했다.
해결방법 1: 생성자에서 방어적 복사
1
2
3
private Lotto(List<Integer> numbers) {
this.numbers = List.copyOf(numbers); // 불변 복사본 생성
}
List.copyOf()는 완전히 새로운 불변 리스트 를 만든다.
- 원본 변경 시에도 영향 없음
- 수정 불가능한 리스트
해결방법 2: Getter에서 불변 뷰 반환
1
2
3
public List<Integer> getLottoNumbers() {
return Collections.unmodifiableList(numbers);
}
Collections.unmodifiableList()는 읽기 전용 뷰 를 반환한다.
- 외부에서 수정 시도하면 예외 발생
- 원본은 안전하게 보호됨
List.copyOf() vs Collections.unmodifiableList()
| 메서드 | 복사 여부 | 원본 변경 영향 | 용도 |
|---|---|---|---|
List.copyOf() |
새로운 불변 리스트 생성 | 영향 없음 | 생성자에서 사용 |
Collections.unmodifiableList() |
원본의 읽기 전용 뷰 | 영향 있음 | Getter에서 사용 |
불변 객체를 만들면 얻는 효과는 다음과 같다.
- 스레드 안전
- 사이드 이펙트 방지
- 예측 가능한 동작
조금 귀찮더라도 방어적 복사로 안전성을 확보하는 게 중요하다!
9. 일급 컬렉션 Lottos: 컬렉션도 객체다
2주차에서 Cars 일급 컬렉션을 만들어봤기에 이번에도 Lottos를 만들었다.
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
33
34
35
36
37
public class Lottos {
private final List<Lotto> lottos;
private Lottos(List<Lotto> lottos) {
this.lottos = new ArrayList<>(lottos);
}
public static Lottos from(List<Lotto> lottos) {
return new Lottos(lottos);
}
public static int calculateLottoCount(int purchaseAmount) {
return purchaseAmount / LOTTO_PRICE;
}
public int size() {
return lottos.size();
}
public List<Lotto> getLottos() {
return Collections.unmodifiableList(lottos);
}
public LottoWinningStatistics calculateWinningStatistics(LottoWinningNumbers winningNumbers) {
Map<Rank, Integer> statistics = new EnumMap<>(Rank.class);
for (Rank rank : Rank.values()) {
statistics.put(rank, 0);
}
for (Lotto lotto : lottos) {
Rank rank = LottoResult.calculateRank(lotto, winningNumbers);
statistics.merge(rank, 1, Integer::sum);
}
return new LottoWinningStatistics(statistics);
}
}
역할
1. 컬렉션 관련 로직 응집
1
2
3
4
// 로또 개수 계산
public static int calculateLottoCount(int purchaseAmount) {
return purchaseAmount / LOTTO_PRICE;
}
2. 비즈니스 로직 캡슐화
1
2
3
4
// 당첨 통계 계산
public LottoWinningStatistics calculateWinningStatistics(LottoWinningNumbers winningNumbers) {
// 로또들 스스로가 통계를 계산
}
Controller가 아닌 Lottos가 통계를 계산한다!
3. 불변성 보장
1
2
3
public List<Lotto> getLottos() {
return Collections.unmodifiableList(lottos);
}
장점
1. Controller가 가벼워짐
1
2
3
4
5
6
7
8
// 일급 컬렉션 없으면
for (Lotto lotto : lottos) {
Rank rank = LottoResult.calculateRank(lotto, winningNumbers);
statistics.merge(rank, 1, Integer::sum);
}
// 일급 컬렉션 있으면
LottoWinningStatistics statistics = lottos.calculateWinningStatistics(winningNumbers);
2. 책임이 명확함
- “통계 계산” →
lottos.calculateWinningStatistics() - “로또 개수” →
lottos.size()
1
2
3
4
5
6
7
8
// Ask - 데이터 달라고 요청
List lottos = lottos.getLottos();
for (Lotto lotto : lottos) {
// 외부에서 처리
}
// Tell - 객체에게 시킴
LottoWinningStatistics stats = lottos.calculateWinningStatistics(winningNumbers);
객체에게 물어보지 말고 시켜라!
마치며
3주 차를 돌아보니 단순히 “돌아가는 코드”를 넘어 “이유를 설명할 수 있는 코드” 를 작성하려고 노력했던 것 같다.
“왜 이 클래스를 만들었나?”, “왜 이 메서드는 여기 있나?”, “왜 이렇게 설계했나?”에 대답할 수 있는 코드 말이다.
YAGNI(You Aren’t Gonna Need It) 원칙도 이해하게 됐다. “나중에 필요할 것 같아서”가 아니라 “지금 필요하니까” 만드는 것. LottoNumbers를 만들까 말까 고민할 때 “지금 당장 필요한가?”를 물어봤고, 답이 “아니다”였기에 만들지 않았다.
그리고 객체지향은 “클래스를 많이 만드는 것”이 아니라 “적절한 책임을 적절한 객체에게 주는 것” 이라는 걸 다시 한번 깨달았다.
References
- Effective Java - 3/E
- Java SE 8 Documentation
- 일급 컬렉션을 사용하는 이유
- Tell, Don’t Ask
- DDD - Value Object vs Entity
