본격적으로 TDD 기반으로 테스트 코드를 작성했다.
도메인 설계 기본기가 부족하니 객체에 대한 책임을 분리하는 게 어려웠다.
처음에는 프로덕션 코드보다 테스트 코드를 먼저 짜는 게 이상하고, 시간적으로 비효율적이라고 생각했는데
'이건 이렇게 동작해야 해!' 하는 테스트 코드가 있으니 과감하지만 안정적으로 리팩토링 할 수 있었다. 😮
1단계 - 문자열 계산기
"2 + 3 * 4 / 2"와 같은 문자열을 입력할 경우 이를 계산해 10을 출력하는 계산기를 구현해야 했다.
항상 메서드 재사용을 고려하자
나는 사칙연산 기호+기능을 enum 클래스로 구현했기 때문에 연산을 하려면 문자열 내의 기호와 일치하는 상수 값을 찾아야 했다.
처음에는 `values()` 메서드를 그대로 사용했다.
public enum Operator {
PLUS("+", (x, y) -> x + y),
MINUS("-", (x, y) -> x - y),
MULTIPLY("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final IntBinaryOperator op;
private final String symbol;
Operator(String symbol, IntBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
public int apply(int x, int y) {
return op.applyAsInt(x, y);
}
public static Operator findByOperator(String inputSymbol) {
return Arrays.stream(values()) //여기
.filter(operator -> operator.symbol.equals(inputSymbol))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 기호 입니다." + inputSymbol));
}
}
리뷰어님께 "`values()`를 매번 호출하지 않고 재사용할 수도 있지 않을까요?"라는 리뷰를 받아 정적 상수 필드로 빼냈다.
public enum Operator {
PLUS("+", (x, y) -> x + y),
MINUS("-", (x, y) -> x - y),
MULTIPLY("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private static final Operator[] VALUES = values();
private final IntBinaryOperator op;
private final String symbol;
Operator(String symbol, IntBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
public int calculate(int x, int y) {
return op.applyAsInt(x, y);
}
public static Operator findByOperator(String inputSymbol) {
return Arrays.stream(VALUES)
.filter(operator -> operator.symbol.equals(inputSymbol))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 기호 입니다." + inputSymbol));
}
}
왜? 🤔
정적 필드에 값을 저장함으로써 한 번만 계산하고 이후에는 저장된 값을 사용하여 인스턴스가 만들어질 때마다 새로운 메모리에 초기화하지 않고, 하나의 메모리 공간만을 사용할 수 있다.
@MethodSource를 사용한 반복 코드 제거
테스트 코드를 작성하다 보면 여러가지 케이스를 테스트해야 하다 보니 반복 코드가 발생한다.
@Test
@DisplayName("입력값의 순서에 따라 계산하여 결과를 반환한다.")
void calculateTest() {
assertThat(CalculatorExecutor.calculate(Splitter.splitNumbers("2 * 3 / 2"), Splitter.splitOperators("2 * 3 / 2"))).isEqualTo(3);
assertThat(CalculatorExecutor.calculate(Splitter.splitNumbers("5 + 3 - 3"), Splitter.splitOperators("5 + 3 - 3"))).isEqualTo(5);
assertThat(CalculatorExecutor.calculate(Splitter.splitNumbers("5 * 3 - 2 / 2"), Splitter.splitOperators("5 * 3 - 2 / 2"))).isEqualTo(6);
}
그래서 @ParameterizedTest의 `@MethodSource`를 사용해 보았다.
`@MethodSource`내에 static으로 선언한 메서드명을 작성하면, `Stream.of`로 선언한 Arguments의 순서대로 테스트 코드의 파라미터로 전달된다.
class CalculatorExecutorTest {
@ParameterizedTest(name = "연산식 : {0}, 결과 : {1}")
@MethodSource("splitExpression")
@DisplayName("입력값의 순서에 따라 계산하여 결과를 반환한다.")
void calculateTest(String expression, int result) {
int actual = CalculatorExecutor.calculate(Splitter.splitNumbers(expression), Splitter.splitOperators(expression));
assertThat(actual).isEqualTo(result);
}
static Stream<Arguments> splitExpression() {
return Stream.of(
Arguments.arguments("2 * 3 / 2", 3),
Arguments.arguments("5 + 3 - 3", 5),
Arguments.arguments("5 * 3 - 2 / 2", 6)
);
}
}
2단계 - 로또(자동)
본격적으로 로또 프로그램을 구현하는 미션이었다.
구입금액을 입력하면 로또 번호를 발급해 주고 당첨 번호를 입력한 후 당첨 통계를 출력하면 된다.
기능 요구사항 분리 및 도메인 설계
포비님의 말대로 처음에 도메인 설계하는 연습을 제대로 그리고 많이 하려고 했다.
객체와 그에 맞는 책임을 잘 분리하면 저절로 객체지향적인 코드가 되고, 리팩토링도 쉬워진다.
Enum을 Key로 Map을 사용할 경우, EnumMap을 사용하자
각 등수에 대해 Enum으로 구현했고 각 등수가 몇 장 당첨되었는지 당첨 결과를 저장해야 했다.
public enum Rank {
ZERO("0개 일치", 0, 0),
FIFTH("3개 일치", 3, 5_000),
FOURTH("4개 일치", 4, 50_000),
THIRD("5개 일치", 5, 1_500_000),
SECOND("5개 일치, 보너스 볼 일치", 5, 30_000_000),
FIRST("6개 일치", 6, 2_000_000_000);
}
처음에는 HashMap으로 구현했다가 EnumMap으로 변경해주었다.
EnumMap은 Enum 타입을 Key로 사용하는 데 특화되어 있고, 해싱 과정이 없어 HashMap보다 빠르다고 한다.
EnumMap, 니가 그렇게 빨라?? 에서 성능 실험을 확인할 수 있다. (지금은 성능상으로 거의 차이가 없겠지만)
private 생성자를 통해 불필요한 인스턴스 생성을 막자
생성자를 작성하지 않으면 컴파일러가 자동으로 기본 생성자를 만들기 때문에, private 생성자를 사용해 불필요한 객체 생성을 구조적으로 막는 것이 좋다.
public class LottoController {
private LottoController() {
}
public static void run() {
//로또 게임 코드 생략
}
}
public class LottoApp {
public static void main(String[] args) {
LottoController controller = new LottoController(); //에러!!
LottoController.run(); //이렇게만 가능
}
}
3단계 - 로또(2등)
보너스 볼을 추가로 입력받아 2등이라는 등수를 추가하는 미션이었다.
2단계에서 많이 고민한 탓인지 크게 어렵지 않았다.
유효성 검증은 누가?
당첨 번호를 입력받았을 때, 이 번호가 로또 번호가 맞는지(1~45의 정수)에 대한 유효성 검증을 Controller에서 했었다.
입력받은 직후 그 값으로 바로 객체를 생성하기보다는 한 번 검증하고 생성하는 게 좋지 않나? 싶어서..!
public class LottoController {
private static final String DELIMITER = ", ";
private LottoController() {
}
public static void run() {
/* 생략 */
List<Integer> winningNumbers = InputView.inputWinningNumbers(DELIMITER);
Validator.validateLottoNumbers(winningNumbers);
LottoWinningMachine winningMachine = new LottoWinningMachine(new Lotto(winningNumbers));
Map<Rank, Integer> rankCounts = winningMachine.getRankCounts(lottoMachine.getLottos());
}
}
객체 생성 전에 검증하는 게 맞는지, 아니면 유효성 검증의 책임 또한 객체가 갖는 게 맞는지 헷갈려서 물어본 결과 이런 피드백을 받았다.
또, 생각해 보니 컨트롤러에서 검증하게 되면 다른 개발자가 `LottoNumber`를 생성할 때 유효성 검증 코드를 작성해야 한다는 사실을 알고 있어야 한다는 것을 깨달았다.
이에 `LottoNumber`에게 유효성 검증을 직접 하도록 수정했다.
public class LottoNumber {
private static final int MIN_NUMBER = 1;
private static final int MAX_NUMBER = 45;
private final int number;
public LottoNumber(int number) {
validateNumber(number);
this.number = number;
}
private void validateNumber(int number) {
if (!(number >= MIN_NUMBER && number <= MAX_NUMBER)) {
throw new InvalidLottoNumberException(number);
}
}
}
지역변수로 충분할 수 있다.
이번 미션하면서 헷갈렸던 점은 '이 데이터는 상태(멤버변수)로 갖고 있어야 할까, 메서드 내에 지역변수로 충분할까'였다.
처음엔 당첨 통계를 저장하는 `Map<Rank, Integer> rankCounts`라는 값을 멤버변수로 갖고 있었다.
public class LottoWinningMachine {
private final Map<Rank, Integer> rankCounts;
private final Lotto winningLotto;
private final LottoNumber bonusNumber;
public LottoWinningMachine(int number, Integer... numbers) {
this(new Lotto(numbers), new LottoNumber(number));
}
public LottoWinningMachine(Lotto winningLotto, LottoNumber bonusNumber) {
validateWinningLottoAndBonusNumber(winningLotto, bonusNumber);
this.rankCounts = new EnumMap<>(Rank.class);
this.winningLotto = winningLotto;
this.bonusNumber = bonusNumber;
}
private void validateWinningLottoAndBonusNumber(Lotto winningLotto, LottoNumber bonusNumber) {
if (winningLotto.contains(bonusNumber)) {
throw new InvalidBonusNumberException(bonusNumber);
}
}
public Map<Rank, Integer> getRankCounts(List<Lotto> lottos) {
for (Lotto lotto : lottos) {
int matchCount = lotto.matchCount(winningLotto);
Rank rank = Rank.rankByCount(matchCount, lotto.contains(bonusNumber));
rankCounts.put(rank, rankCounts.getOrDefault(rank, 0) + 1);
}
return rankCounts;
}
}
근데 당첨 결과 통계를 내는 `getRankCounts()`는 딱 한 번만 호출되고, 통계 결과를 변수에 저장한 다음 반환하면 끝인데 굳이 멤버 변수로 지니고 있어야 할까라는 생각에 지역 변수로 변경했다.
public Map<Rank, Integer> getRankCounts(List<Lotto> lottos) {
Map<Rank, Integer> rankCounts = new EnumMap<>(Rank.class);
for (Lotto lotto : lottos) {
Rank rank = Rank.rankByCount(lotto.matchCount(winningLotto), lotto.contains(bonusNumber));
rankCounts.put(rank, rankCounts.getOrDefault(rank, 0) + 1);
}
return rankCounts;
}
4단계 - 로또(수동)
현재 로또 발급은 자동만 되는데, 사용자가 입력한 로또 번호로 수동 발급 기능을 추가해야 했다.
'로또 번호 발급기'라는 인터페이스가 있었기 때문에 '수동 로또 발급기'라는 구현체를 추가하는 식으로 구현했다.
2등 판별 조건 리팩토링
2등과 3등을 어떻게 구별해야 할까 고민하다가 때 보너스 번호 일치 여부로 삼항 연산자를 사용했다.
public enum Rank {
ZERO("0개 일치", 0, 0),
FIFTH("3개 일치", 3, 5_000),
FOURTH("4개 일치", 4, 50_000),
THIRD("5개 일치", 5, 1_500_000),
SECOND("5개 일치, 보너스 볼 일치", 5, 30_000_000),
FIRST("6개 일치", 6, 2_000_000_000);
private static final Rank[] VALUES = values();
private final String title;
private final int matchCount;
private final int prizeMoney;
Rank(String title, int matchCount, int prizeMoney) {
this.title = title;
this.matchCount = matchCount;
this.prizeMoney = prizeMoney;
}
public static Rank rankByCount(int count, boolean matchBonus) {
if (count == 5) {
return isMatchBonus ? Rank.SECOND : Rank.THIRD;
}
return Arrays.stream(VALUES)
.filter(rank -> rank.matchCount == count)
.findFirst()
.orElse(Rank.ZERO);
}
}
피드백을 받아 5라는 수를 하드 코딩하기보다는 2등만 걸러내고 나머지 등수는 밑에서 판별하도록 리팩토링했다.
public static Rank rankByCount(int count, boolean matchBonus) {
if (matchBonus && SECOND.matchCount == count) {
return SECOND;
}
return Arrays.stream(VALUES)
.filter(rank -> rank.matchCount == count)
.findFirst()
.orElse(Rank.ZERO);
}
로또 미션을 수행하면서 참고했던 링크
⬇
'📝 끄적끄적 > TDD, 클린 코드 with Java 17기' 카테고리의 다른 글
미션1. 자동차 경주 - 단위테스트 회고 (0) | 2023.11.15 |
---|---|
TDD, 클린 코드 with Java 17기를 시작하며 (0) | 2023.11.05 |