이번 주차 목표는 "클래스 분리"와 "도메인 로직에 대한 단위 테스트 작성" 이다. 이전 주차에서 TDD를 통해 함수별 기능을 분리하고 그 결과로 클래스에 책임을 할당하여 클래스가 분리되었던 경험이 있다. 따라서 이번 주차에는 도메인 별로 테스트 코드를 먼저 작성함으로써 클래스 분리를 이뤄내려 했다.
예로 LottoOwner 클래스가 있다.
public class LottoOwner {
private List<Lotto> lottos;
public LottoOwner(int lottoNumber) {
lottos = generateLottos(lottoNumber);
}
private List<Lotto> generateLottos(int lottoNumber) {
List<Lotto> lottos = new ArrayList<>();
for (int i = 0; i < lottoNumber; i++) {
lottos.add(new Lotto(Utils.generateLottoNumber));
}
return lottos;
}
}
클래스를 분리 하기 전의 LottoOwner
클래스다. 이전에 테스트 코드를 작성하지 않은 이유가 있는데 그 이유는 LottoOwner
클래스가 로또들을 생성할 책임이 있다고 생각했고 private로 설정했기 때문에 테스트가 불가하다고 생각했기 때문이다.
하지만 목표대로 여기서 "구입한 로또 수만큼 로또를 생성하는 기능 테스트"라는 도메인 로직에 대한 단위 테스트를 작성하면 이런 식의 테스트를 해야한다.
@Test
void test() {
LottoOwner owner = new LottoOwner(3);
assertThat(owner.lottos.size()).isEquals(3);
}
만약 강제로 테스트하겠다면 lottos
의 접근자를 public으로 바꾸고 테스트 코드에 위와 같이 쓰겠지만 테스트 때문에 굳이 접근자를 바꾸는 건 위험한 일이라고 생각한다. (다른 클래스가 lottos
에 접근해서 객체가 객체스럽지 못할 수 있으니.)
따라서 로또 생성 책임을 LottoOwner
클래스에서 LottoTicket
클래스로 옮겼다.
public class LottoOwner {
List<Lotto> lottos;
public int purchaseLotto(int money) {
LottoTicket lottoTicket = new LottoTicket(money);
lottos = lottoTicket.generateLottos();
return lottoTicket.getTicketNumber();
}
...
}
public class LottoTicket {
...
public List<Lotto> generateLottos() {
List<Lotto> lottos = new ArrayList<>();
for (int ticket = 0; ticket < ticketNumber; ticket++) {
lottos.add(new Lotto(Utils.generateRandomLottoNumbers()));
}
return lottos;
}
...
}
publi class LottoTicketTest {
@Test
void test() {
LottoTicket lottoTicket = new LottoTicket("3000");
List<Lotto> lottos = lottoTicket.generateLottos();
assertThat(lottos.size()).isEqualTo(3);
}
}
LottoOwner
가 했던 로또를 생성하는 작업은 LottoTicket
에게 할당되었다. 이 둘은 협력 관계에 있다. LottoOwner
가 LottoTicket
에 로또 생성을 요청하는 것처럼 우리 테스트 코드도 LottoTicket
에 로또 생성을 요청할 수 있고 테스트도 할 수 있다.
전체적으로 도메인 로직에 대한 단위 테스트를 작성함으로써 클래스가 분리되어야함을 인지하게 되었고 결과적으로 리팩토링을 통해 클래스가 분리하게 된다.
단위적인 테스트 코드는 클래스가 옳은 방향으로 분리되도록 만든다.
살면서 로또를 해본 경험이 없기 때문에 이번 주차 개발을 하면서 보너스 번호의 존재가 왜 있는지 궁금했다. 보너스 번호의 존재 자체가 궁금한 이유는 도메인에서 차이가 발생하고 그 결과로 설계에 영향을 끼치기 때문이다.
좀 더 자세히 설명하면 보너스 번호가 6개의 로또 번호 중에 겹쳐도 되는지 아닌지 차이가 코드를 작성할 때 있어서 중요했기 때문이다. 따라서 위키 사전에 검색을 해보았다.
보다시피 1등은 보너스 번호를 제쳐두고 6개의 숫자를 전부 일치시켜야 1등이다. 2등은 5개의 번호가 일치하고 보너스 번호 1개가 일치시켜야 2등이다.
그말은 즉슨 2등의 나머지 1개의 숫자는 보너스 번호와 다른 숫자이고 -> 보너스 번호는 6개의 숫자와 겹치면 안된다는 사실이다.
아마 로또 미션을 받고 나와 달리 개발하는데 문제가 없는 분들도 많을 것이다. 어떤 서비스의 도메인에 대해 익숙한 개발자일 수록 개발하기 유리할 것이다.
여름 방학에 들은 말로는 라인은 다른 나라에 론칭하기 전에 개발자들을 그 나라로 몇 개월 동안 보낸다고 한다. 외국에 도착한 개발자들은 그 나라의 인프라, 문화를 경험하고 경험한 대로 개발에 착수한다. 문화와 같이 기술 외적인 것도 중요하다는 것이다.
이번 주차의 기능 목록 작성은 4시간 정도 걸렸다. 시간이 여유롭다면 괜찮은 일이지만 만약 최종 코딩 테스트까지 가게 된다면 5시간을 시험 볼텐데 걱정이다.
플로우 차트를 작성할 때 시간을 많이 잡아먹었다. 처음 플로우 차트를 작성할 때 클래스 구조를 생각하면서 차트를 작성하니까 좀처럼 운이 잘 안 때졌다.
다음 미션의 플로우 차트를 쓸 때는 클래스 구조, 객체지향 전부 배제하고 작성해야겠다는 생각이 들었다. 기능은 기능으로 보자. 아무리 좋은 클래스 구조를 따진다고 해도 요구사항에 따른 기능은 바뀌지 않는다.
다음으로 기능별로 클래스를 나누는 과정도 시간이 오래 걸렸다.
객체지향적으로 클래스를 나누고 싶었던게 시간이 오래 걸린 이유라고 생각한다. "getter를 사용하지 않는다. / 원시 타입 을 포장하자. / 일급 콜렉션을 사용하자."와 같은 원칙을 염두해두면서 클래스들의 구조를 설계하니까 어떻게 해야할 지 몰랐다.
적절한 클래스에게 책임을 부여하는 것은 경험의 영역인 것 같다. 처음부터 디테일하게 클래스를 나누는 것은 좋지만 나와 같은 초짜는 기능마다 적당하게 클래스에게 책임을 부여하고 코딩을 하면서 고쳐나가는게 좋은 것 같다.
처음부터 잘 짤 수 있었더라면 리펙토링이라는 단어는 존재하지 않을 것이다.
지난 주차의 코드이다.
public class Winners {
private final List<Car> winners;
...
public void print() {
List<String> winnerNameList = new ArrayList<>();
for (Car winner : winners) {
winnerNameList.add(winner.getCarName());
}
OutputView.printWinners(winnerNameList);
}
우승자들의 이름을 출력하기 위한 Winners
의 print()
메서드이다.
Winners
객체는 레이싱의 우승자를 판별하는 도메인 로직을 가진 객체이다. 이전 주차에는 "getter 사용을 지양해라"라는 코드 컨벤션을 보고 getter를 사용하지 않으려 도메인 로직에 UI (OutputVIew)를 직접 호출을 했다. (Winners
객체가 아닌 다른 객체에서도 이런 식으로 호출했다.)
하지만 잘못되었다고 생각한다. 그 이유는 도메인 로직이 UI 에 의존하며 종속적이 되기 때문이다.
만약 출력할 곳이 콘솔이 아니라 HTML 파일이라면 어떻게 될까. Winners
뿐만 아니라 OutputView
를 사용하는 모든 객체에서 코드를 변경해야할 것이다. 그래서 이번 주차에서는 UI와 도메인을 분리하는 작업을 해야겠다고 결심했다.
다음은 이번 주차에서 UI와 도메인을 분리한 코드이다.
public class Lotto {
List<Integer> numbers;
...
@Override
public String toString() {
return numbers.stream()
.map(number -> number.toString())
.collect(Collectors.joining(", "));
}
}
public class LottoGameController {
private void purchaseLottoByOwner(LottoOwner lottoOwner) {
...
OutputView.printLottoNumbers(lottoOwner.getLottoNumbers());
}
public class OutputView {
public static void printLottoNumbers(List<String> lottos) {
lottos.forEach((lotto) -> System.out.printf(LOTTO_NUMBERS_TEXT, lotto));
}
Lotto
에서 출력할 문자열을 리턴하고 Lotto
와 OutputView
중간에 LottoGameController
가 문자열을 받아서 OutputView
에 전달한다. 이로써 Lotto
라는 도메인 로직이 UI로부터 분리가 되었다.
하지만 OuputView
에서 항상 "쉼표로 분리된 로또 넘버"를 출력하는 것이 아니라면 Lotto
에서 가공된 문자열을 리턴하는 것은 유연하지 못한 설계라고 생각했다.
Lotto 객체는 OutputView에서 쉼표로 분리된 로또 넘버를 사용한다는 것을 인지하고 있고 OutputView는 Lotto 객체에서 쉽표로 분리된 로또 넘버 문자열을 받는다는 것을 인지하고 있다. 즉 도메인과 UI가 완전히 분리되지 않았다.
앞서 언급한 문제를 해결하기 위해 적용한 패턴으로 MVC 패턴을 사용했다. MVC 패턴의 존재에 대해서는 익히 알고 있었지만 제대로 이해하고 있지 못했다.
MVC 패턴은 UI을 담당하는 영역과 도메인을 담당하는 영역을 분리시킨다는 이점이 있고 내가 겪고 있는 문제에 해결할 수 있는 패턴이라고 생각해 학습하고 정리했다.
(MVC 패턴이란?)
MVC 패턴을 적용하기 위해 지켜야하는 규칙을 중심으로 내 과제에 적용했다.
(https://github.com/minjun7410/java-lotto-6/commit/09d67bd43b53e4b2fad7c603b7c70361802ba180)
public class Lotto {
List<Integer> numbers;
...
public List<Integer> getNumbers() {
return numbers;
}
}
public class OutputView {
public static void printLottoNumbers(List<Lotto> lottos) {
lottos.forEach((lotto) -> {
String lottoNumbers = lotto.getNumbers().stream()
.map(Object::toString)
.collect(Collectors.joining(", "));
System.out.printf(LOTTO_NUMBERS_TEXT, lottoNumbers);
});
}
}
Lotto (Model)은 로또 넘버를 넘겨주기만한다. Lotto 객체는 OutputView (View) 에 대한 정보를 모른다.
그리고 OutputView는 Lotto 리스트를 받아서 Lotto의 데이터들을 통해 출력할 형식에 따라 문자열을 콘솔에 출력한다. 중요한 점은 OuputView는 Lotto가 수행하는 일을 몰라도 된다는 점이다. 단지 Lotto에서 필요한 데이터를 getter로 가져온다.
즉 도메인과 UI를 분리시킬 수 있었다.
https://futurecreator.github.io/2018/08/26/java-8-streams/
코드 리뷰 당시에 for 문을 사용하는 것보다 가독성이 좋아지는 것을 목격하고 Stream에 대해 배워보고 싶었다. 그래서 Stream에 대한 내용을 서칭하고 블로그 글도 남겼다.
(Java Stream)
Stream을 이번주차 과제에 적용시켰는데 for 문을 사용했을 때보다 가독성이 좋아지는 결과를 확인했다. 보통 코드를 읽는 사람은 위에서 아래로 읽는데 for문을 읽을 때는 이 코드 박스가 반복된다는 것을 인지한 채 읽어야하는 반면 Stream을 이용하면 list -> "b"를 포함하는 것들만 있는 list -> list를 조인한 문자열
과 같이 순차적으로 코드를 해석할 수 있기 때문이었다.
List<Integer> array = List.of(10, 5, 4, 2, 6);
// for 문
List<Integer> result = new ArrayList<Integer>();
for (int i : array) {
if (i > 4) {
result.add(i);
}
}
...
// Stream
List<Integer> result = array.stream()
.filter(i -> i > 4)
.toList();
다음은 4보다 큰 숫자만 남기는 코드이다. for 문을 사용했을 때와 Stream을 사용했을 때 비교하면 확연히 다름을 알 수 있었다.
Stream도 자바 기본 API 이다. 학습하면 할 수록 내가 몰랐던 자바의 꿀기능을 찾고 있다.
@Override
public String toString() {
return numbers.stream()
.map((number) -> toString())
.collect(Collectors.joining(", "));
}
스택 오버 플로우가 일어났다. 어이없게도 재귀적으로 toString
을 호출하는 것을 몰랐다. toString 호출하고, toString이 실행하면 다시 호출하고.. 반복이다. 이러다가 스택 용량에 벗어나면 스택 오버 플로우 에러가 나온다.
@Override
public String toString() {
return numbers.stream()
.map(number -> number.toString())
.collect(Collectors.joining(", "));
}
에러가 나지 않는 코드다.
stream을 배우는 중이라 정신이 없었던 것 같다. 저렇게 (number) ->
라고 쓰면 뒤에 나오는 문장은 number에 관련되어있다고 생각했다.
이를 경험하고 람다식에 대해 학습할 필요가 있다고 느꼈다.
Enum에 대해서는 문외한이었고 솔직히 Enum 클래스에 대해 사용 안해도 크게 무리없는 클래스라고 생각했었다. 하지만 3주차 미션 중 "Java Enum을 적용한다." 라는 프로그래밍 요구사항이 있어 자바를 시작한 뒤로 처음 학습했고 생각보다 객체지향을 지킬 수 있는 방법임을 알게 되었다.
(Java Enum을 사용하는 이유)
public enum Rank {
FIRST(6, false, 0, 2000000000, "6개 일치 (2,000,000,000원) - %d개\n"),
...;
...
public static Rank findByMatchCount(int lottoMatchCount, boolean bonusMatched) {
for (Rank rank : Rank.values()) {
if (rank.lottoMatchCount == lottoMatchCount && (!rank.bonusRequired || bonusMatched)) {
return rank;
}
}
return Rank.FAIL;
}
public void addCount() {
this.winningCount += 1;
}
public int getCount() {
return this.winningCount;
}
...
}
Enum 클래스를 미션에 적용하면서 느낀 강점 중 몇 개를 꼽자면
첫 번째로 상수들과 그에 대한 처리를 한 곳에 모아둘 수 있다는 점이다.
순위를 의미하는 상수들이 있는데 순위에 따라 번 돈을 계산하거나, 당첨 통계를 출력할 때 얼마나 몇등이 나왔는지에 대한 처리가 뿔뿔히 흩어져 있는 것 보다 Rank라는 Enum 클래스를 만들어서 순위에 대한 처리를 할당시켰다.
두번째로 Enum 클래스는 싱글톤이라는 점이다.
순위마다 몇번 나왔는 지를 계산할 때 Map을 사용해서 순위마다 얼마나 당첨되었는 지 카운트하는 구현도 있을 것이다. 하지만 Enum 클래스는 싱글톤 클래스라는 특징을 가지고 있기 때문에 이 점을 이용해서 순위마다 카운팅하는 기능을 만들었다.