티스토리 뷰
3주차 미션은 로또입니다. 로또는 현실 세계에 존재하는 도메인이기에 제가 로또를 사본 경험이 떠올랐고 경험을 바탕으로 미션을 진행해볼 수 있었습니다.
요구 사항 분석
위의 요구사항을 바탕으로 아래의 결과를 만들어내야 하는 것이 이번 과제의 목표입니다.
2주차 미션에서 요구 사항 분석 후 바로 구현 기능에 대해 생각하고 개발을 진행했을 때 실행 흐름을 고려하지 않아 추가되거나 수정되는 상황이 많이 발생했습니다. 이번에는 실행 흐름도 고려하여 기능에 대해 생각해보고자 실행 흐름에 대해서도 생각해보았습니다.
실행 흐름도를 통해 성공과 실패 케이스가 존재할 때 어디서 분기를 정해야 하는지 알 수 있었고 사용되는 명사를 통해 어떤 객체가 필요할지 한 눈에 확인할 수 있었습니다. 화살표에 도착한 곳의 객체가 달라지는 경우 다른 객체에서 메시지를 전달하는 것으로 보고 객체가 가져야 할 메서드에 대해서도 생각할 수 있었습니다.
기능 구현
클래스 분리
실행 흐름도에서 사용되는 명사를 추출해 보았습니다. 로또
, 구매자
, 당첨 로또
, 로또 결과
, 당첨 통계
를 확인할 수 있었고 이를 바탕으로 객체를 구성해 보았습니다. 아래의 다이어그램은 최종적으로 완성되었을 때를 나타내는 다이어그램입니다. 입력을 적절한 값으로 바꿔주는 컨버터와 입력을 위한 서비스, 출력을 위한 서비스를 추가로 구성하였습니다.
Lotto
- 숫자가 로또에 포함되어 있는지 확인합니다.
WinningLotto
- 로또와 비교하여 로또 등수를 반환합니다.
Consumer
- 당첨 결과를 확인합니다.
Rank
- 맞은 개수와 보너스 일치 여부에 따른 로또 등수를 생성합니다.
Result
- 수익률을 계산합니다.
내부는 최대한 외부에 드러내지 않고 가지고 있는 필드를 사용한 기능은 해당 객체의 역할이라는 판단으로 메서드로 해결하기 위해 노력했습니다. 최종 결과가 처음 생각했던 기능 그대로 메서드에 잘 녹아든 것 같아 뿌듯했습니다.
상속보다는 조립
당첨 로또는 로또와 보너스 번호를 합쳐서 구성되는데 처음에는 상속받아 사용하려고 했습니다. 상속을 위해 공부하던 도중 상속이 가지는 단점에 대해 알 수 있었습니다.
상속은 명확한 IS-A
관계일 때 단순 재사용이 아닌 상위 클래스 기능을 하위 클래스가 확장하고자 할 때 사용합니다. 그렇다면 어떤 단점이 존재할까요?
상위 클래스 변경이 어렵습니다.
상속 관계로 구성되어 있으면 상위 클래스 변경의 영향이 계층을 따라 전파되게 됩니다. 상위 클래스의 작은 변경이 하위 클래스에 어떤 영향을 끼칠지 예측할 수 없습니다. 그렇기에 상위 클래스의 변경을 어렵게 만듭니다.
클래스가 무분별하게 증가할 수 있습니다.
상속을 사용하기 위해 클래스 간 관계를 억지로 맞추다 보면 클래스가 무분별하게 증가하게 됩니다. 유연한 확장을 위해 사용되는 상속이 확장을 더욱 어렵게 만들고 복잡도를 증가시키게 됩니다.
상속 오용 문제가 발생할 수 있습니다.
하위 클래스에서는 상위 클래스의 메서드에 접근할 수 있기에 상위 클래스 메서드의 잘못된 사용으로 상속 오용 문제가 발생합니다. 이는 예상하지 못한 결과를 발생시킬 수 있습니다.
위와 같은 단점들이 존재하였기에 필드로 객체를 참조하는 방식인 조립을 사용하기로 했습니다. 조립은 상속의 문제를 해결할 수 있고 객체를 필요 시점에 생성하고 구현한다는 장점을 가지고 있습니다. 상속을 하기 전 조립으로 해결할 수 있는지 검토해보고 진짜 하위 타입인 경우에만 상속을 사용하는 것이 좋을 것 같습니다.
검증
검증이 존재해야할 위치에 대해 고민해볼 수 있었습니다. 이전 미션에서는 검증된 값을 객체에 넘겨줘 객체는 별도의 검증 과정없이 해당 필드를 가지도록 생성했습니다. 하지만 생성에서 검증 과정이 존재하지 않으면 아래와 같은 문제가 발생할 수 있습니다.
public class Lotto {
private final List<Integer> numbers;
public Lotto(List<Integer> numbers) {
this.numbers = numbers;
}
...
}
생성자를 열어두었다는 것은 해당 생성자로 객체 생성을 허용한다는 의미인데 이를 사용하는 클라이언트로서는 필드 타입에 맞는 값을 던져주면 언제든 객체를 생성할 수 있습니다. 중복을 가지지 않고 정해진 범위에 속하는 6개를 만족해야만 로또가 생성돼야 하지만 Lotto를 생성하는 입장에서는 이를 모르기에 어떤 값이든 넣어줄 수 있습니다. [100, 100, 100, 1000, 100, 1000, 1]
을 주더라도 Lotto는 생성되기에 예상과 다른 결과를 초래할 수 있습니다.
public class Lotto {
private final List<Integer> numbers;
public Lotto(List<Integer> numbers) {
validate(numbers);
this.numbers = numbers;
}
private void validate(List<Integer> numbers) {
validateCount(numbers);
validateRange(numbers);
validateDuplication(numbers);
}
...
}
생성 전 검증 과정이 존재하고 검증을 통과하지 못하면 객체 생성이 이루어지지 않도록 해야 클라이언트에서 잘못된 값을 넣더라도 생성을 막을 수 있습니다. 코드는 다른 사람도 볼 수 있고 수정할 수 있기에 의도를 잘 드러내도록 코드를 작성하는 것이 중요하다고 생각합니다.
Enum
이번 미션에서 Java Enum
을 사용해야 한다는 요구 사항이 존재했기에 적극적으로 활용해보고자 하였습니다. 로또 등수인 Rank
는 맞은 개수, 보너스 번호 일치 여부, 상금의 특정 값을 가지기에 Enum으로 사용하기에 적합하다고 판단했습니다.
if (count == 6) {
return Rank.FIRST;
}
if (count == 5 && bonus) {
return Rank.SECOND;
}
if (count == 5 && !bonus) {
return Rank.THIRD;
}
if (count == 4) {
return Rank.FOURTH;
}
if (count == 3) {
return Rank.FIFTH;
}
하지만 결국 Rank를 결정하기 위한 분기는 위와 같이 일일이 작성해야 줬기에 깔끔한 코드를 작성할 수 없었습니다. 이를 Enum으로 해결할 수 없을까 고민하다 Enum에 BiFunction을 활용할 수 있음을 알게 되었습니다.
public interface BiFuncton<T, U, R> {
R apply(T t, U u);
}
BiFunction
은 2개의 인자를 받고 1개의 객체를 반환하는 함수형 인터페이스입니다. 저는 맞은 개수인 count
와 보너스 일치 여부인 bonus
를 받아 일치하는 결과인지 여부를 판단하는 함수를 Enum에 활용하고자 했고 결과물은 아래와 같습니다.
public enum Rank {
FIRST(6, false, 2_000_000_000, (count, bonus) -> count == 6),
SECOND(5, true, 30_000_000, (count, bonus) -> count == 5 && bonus),
THIRD(5, false, 1_500_000, (count, bonus) -> count == 5 && !bonus),
FOURTH(4, false, 50_000, (count, bonus) -> count == 4),
FIFTH(3, false, 5_000, (count, bonus) -> count == 3),
NONE(0, false, 0, (count, bonus) -> count <= 2);
private final int count;
private final boolean bonus;
private final long prize;
private final BiFunction<Integer, Boolean, Boolean> expression;
Rank(int count, boolean bonus, long prize, BiFunction<Integer, Boolean, Boolean> expression) {
this.count = count;
this.bonus = bonus;
this.prize = prize;
this.expression = expression;
}
...
public boolean win(int count, boolean bonus) {
return expression.apply(count, bonus);
}
...
}
return Arrays.stream(Rank.values())
.filter(rank -> rank.win(count, bonus))
.findFirst()
.orElse(Rank.NONE);
BiFunction을 적용함으로 각각을 if문으로 처리해야 했던 이전과 달리 Rank의 값들을 하나씩 돌며 조건 확인 후 count, bonus에 해당하는 Rank가 있으면 반환하게 됩니다. Rank의 로직을 추상화하면서 알맞은 Rank를 가져올 수 있게 되었습니다.
테스트
이번 미션에서 가장 많이 고민한 부분은 테스트 코드입니다. 테스트가 어려운 이유에 대한 글을 읽게 되었는데 테스트가 어려운 이유는 숙련의 문제가 아닌 테스트가 어렵게 구현되었기 때문이라고 설명하고 있습니다. 테스트를 구현과 같은 레벨로 보고 테스트가 어려운 경우 설계를 변경하는 것에 대해 고려해야 하는 이유에 관해서도 설명하고 있었습니다. 코드를 작성할 때 테스트가 어려움에도 불구하고 그냥 넘어가곤 했었는데 테스트 코드의 가치를 이해하지 못했던 저가 부끄러웠고, 이번 미션에서는 public
메서드들에 대해서는 모두 테스트할 수 있게 구현하도록 노력했습니다.
public class Consumer {
private final List<Lotto> lottos;
private int purchase;
public Consumer(List<Lotto> lottos, int purchase) {
validate(purchase);
this.lottos = generateLottos(purchase);
}
private void validate(int purchase) {
...
}
private List<Lotto> generateLottos(int purchase) {
int count = purchase / PRICE;
return IntStream.range(0, count)
.mapToObj(
i -> new Lotto(Randoms.pickUniqueNumbersInRange(RANGE_START, RANGE_END, COUNT)))
.collect(Collectors.toList());
}
...
}
위의 코드에서는 어느 부분이 테스트를 어렵게 만들까요? 바로 Randoms의 함수가 테스트를 어렵게 만듭니다. Randoms
의 pickUniqueNumbersInRange
는 정해진 시작 범위와 정해진 끝 범위의 수에서 랜덤으로 중복되지 않도록 설정한 개수만큼의 숫자 리스트를 반환하는 함수입니다. 객체의 생성마다 랜덤으로 값이 정해지기에 객체의 상태를 알 수 없게 되므로 테스트를 어렵게 만듭니다.
해결하기 위해서는 제어할 수 없는 코드를 객체의 바깥으로 밀어내도록 해야 합니다. 내부에서 랜덤으로 값을 만들어내는 것이 아닌 값의 생성은 외부로 밀어내고 Consumer
는 List<Lotto>
를 받아 생성하도록 합니다.
public class Consumer {
private final List<Lotto> lottos;
public Consumer(List<Lotto> lottos) {
this.lottos = lottos;
}
...
}
다음과 같이 코드를 리팩토링하면 Consumer는 List를 받아 생성되고 객체의 상태를 알 수 있기에 테스트하기 쉽습니다. 또한 List가 만들어지는 방식에 Consumer가 의존하지 않게 되므로 List가 만들어지는 방식이 변경되더라도 Consumer는 영향을 받지 않습니다.
테스트를 위해 리팩토링한 결과 테스트도 쉬워졌을 뿐 아니라 객체가 가져야 할 역할만 온전히 가지게 된 상태가 되었습니다. 테스트를 위한 리팩토링을 직접 진행하면 테스트 코드의 가치를 직접 느낄 수 있게 되었습니다.
마치며
3주차에서는 불변과 단위 테스트에 대해 많이 고민하고 고민에 관한 결과를 코드로 작성할 수 있었습니다. 처음 우아한테크코스를 시작할 때와 비교하여 3주간 짧은 시간이었지만 폭발적으로 성장한 것을 확실히 느낄 수 있었습니다. 지금까지 재밌게 진행했기에 마지막 주차도 기대가 되고 최선을 다해보고자 합니다.
자세한 코드는 아래에서 확인할 수 있습니다.
'우아한테크코스' 카테고리의 다른 글
[우아한테크코스] 사다리 타기 미션 회고(feat. TDD) (8) | 2023.03.04 |
---|---|
[우아한테크코스] 백엔드 5기 합격 후기 (21) | 2022.12.31 |
[우아한테크코스 5기] 프리코스 4주차 회고 (5) | 2022.11.24 |
[우아한테크코스 5기] 프리코스 2주차 회고 (1) | 2022.11.09 |
[우아한테크코스 5기] 프리코스 1주차 회고 (2) | 2022.10.31 |