3주차 미션
https://github.com/woowacourse-precourse/java-lotto-7/pull/236
느낀 점
TDD를 제대로 적용하자
TDD에 대한 오해
처음에는 TDD를 단순히 테스트를 먼저 작성하고 구현하는 방식으로 이해했습니다. 그래서 전체 시스템의 입출력을 검증하는 통합 테스트를 작성하고 이를 통과하는 코드를 만들었습니다.
하지만 2주 차 피드백에서 "처음부터 큰 단위의 테스트를 만들지 않는다"라는 피드백을 받았습니다. 통합 테스트부터 작성할 경우 모든 구현이 완료되어야만 테스트를 실행할 수 있어서 문제를 발견하기까지 시간이 오래 걸린다는 것이었습니다. 이를 계기로 TDD 책을 읽고, 미션에 적용해 보기 위해 노력했습니다.
설계 중심의 TDD
TDD를 배우고 적용하면서 느낀 점은 TDD는 단순한 테스트 작성 기법이 아닌 설계 방법론이라는 것입니다. 테스트를 작성할 때는 구현이 아닌 설계(인터페이스)에 집중합니다.
설계 : "무엇을" 할 것인가 (What)
구현 : "어떻게" 할 것인가 (How)
예를 들어 두 로또의 일치 개수를 확인하는 기능을 만들 때, 먼저 이 기능이 필요로 하는 메서드를 설계했습니다.
@Test
@DisplayName("일치 개수를 센다.")
void 성공_일치개수() {
// Given
Lotto lotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
Lotto winningLotto = new Lotto(List.of(1, 2, 3, 10, 11, 12));
// When
int count = lotto.countMatchingNumber(winningLotto);
// Then
assertThat(count).isEqualTo(3);
}
이를 통해 자연스럽게 아래 원칙들을 지킬 수 있었습니다.
1. 높은 응집도와 낮은 결합도
구현을 생각하지 않고 테스트를 작성하다보니 자연스럽게 단일 책임 원칙(SRP)을 따르는 메서드가 만들어졌습니다.
@Test
@DisplayName("수익률을 계산한다.")
void 성공_계산() {
// Given
Lotto winningLotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
LottoNumber bonusNumber = LottoNumber.valueOf(10);
List<Lotto> lottos = List.of(new Lotto(List.of(1, 2, 3, 4, 5, 6)),
new Lotto(List.of(1, 2, 3, 4, 5, 10)));
Lottery lottery = new Lottery(winningLotto, bonusNumber, lottos);
// When
BigDecimal profitRate = lottery.calculateProfitRate();
// Then
assertThat(profitRate).isEqualTo(new BigDecimal("101500000.0"));
}
2. 인터페이스 중심 설계 (DIP)
테스트 가능한 코드를 만들기 위해 자연스럽게 의존성 역전 원칙(DIP)을 따르게 되었습니다.
// 구현 먼저 생각 -> 강한 결합
class BadCar {
public void move() {
// 직접 랜덤 값을 생성하여 강한 결합 발생
int number = (int) (Math.random() * 10);
if (number >= 4) {
position++;
}
}
}
// 낮은 결합
class GoodCar {
private final MovementStrategy movementStrategy;
public void move() {
if (movementStrategy.canMove()) {
position++;
}
}
}
@Test
void 성공_이동_이동전략에따라() {
Car car = new Car(() -> true);
car.move();
assertThat(car.getPosition()).isEqualTo(1);
Car immobileCar = new Car(() -> false);
immobileCar.move();
assertThat(immobileCar.getPosition()).isEqualTo(0);
}
이렇게 TDD를 공부하면서 1,2 주차에 했던 것은 TDD(테스트 주도 개발)이 아니라 테스트 주도 리팩토링이라는 것을 깨달았습니다..
점진적으로 구현하기
TDD의 또 다른 핵심은 점진적 구현입니다. 이전에는 테스트 작성 후에 한 번에 완벽히 구현하려고 노력했지만, 이번에는 단일 기능에 대해 단계별로 하나씩 구현했습니다. 테스트가 깨지는 것을 두려워하지 않고 점진적으로 개선하면서 결과를 예측할 수 있었고 부담감이 적었습니다.
더 적은 결함, 더 적은 스트레스, 더 빠른 개발
개발자 친화적인 방법론
TDD는 특히 주니어 개발자에게 큰 도움이 되는 개발 방법론이라는 생각을 했습니다. 작은 단계로 나누어 구현하다 보니 복잡한 기능도 차근차근 구현할 수 있었고 버그도 지난 미션보다 적었습니다. 또한 테스트 통과를 목표로 개발하면서 길을 잃지 않을 수 있었고, 리팩토링도 편하게 수행하면서 단순하면서 명확한 프로그램이 만들어졌습니다.
테스트를 작성하는 이유
일반적으로 테스트는 버그를 찾고 검증하며 명세 역할을 수행합니다. 하지만 이번에 TDD를 적용하면서 "자연스러운 추상화를 통한 코드 품질 향상"이라는 장점을 깨달았습니다. 또한, 테스트가 개발을 주도하면서 인터페이스 중심의 설계, 높은 응집도와 낮은 결합도를 얻게 되었습니다.
결론
앞으로도 TDD를 통해 단순하면서 확장 가능한 설계를 만들고 싶습니다. 이를 위해 설계 능력을 키우고 작은 단위로 테스트를 작성하는 연습을 꾸준히 해야겠다는 생각을 했습니다.
코드 리뷰 계속 진행하기
지난 주차에는 3명 정도 코드만 리뷰했었는데, 더 많은 코드를 보지 못해 아쉬움이 많이 남았습니다. 그래서 이번에는 코드 리뷰 방을 열어 리뷰를 진행했습니다.
리뷰를 진행하며 같은 미션인데 굉장히 다른 방식으로 구현을 해서 흥미로웠습니다. 또한 다른 분들로부터 예외 처리나 문서 작성 방법, 책임 분리 패턴 등을 보며 빠르게 배우고 이번 미션에 적용할 수 있었습니다.
이렇게 서로의 경험을 공유하면서 더 나은 코드를 작성할 수 있어서 좋았고, 앞으로도 코드 리뷰를 많이 해야겠다는 생각이 들었습니다.
고민한 점
검증(Validation) 책임을 적절한 곳에 두기
자율적인 객체의 검증의 범위
개발하면서 가장 많이 고민했던 부분은 검증 로직의 범위와 책임이었습니다. 특히 연관된 객체들 사이에 중복되는 검증이 필요한 경우 이를 어떻게 수행할지 고민이 되었습니다.
예를 들어, 로또 판매기에서 구매 금액이 1000원 단위(0원 불가)여야하는 제약이 있다면, 이로 인해 로또 수량도 0개가 될 수 없습니다. 이때에도 로또 수량 객체에서 0에 대한 검증을 수행해야 할까요?
@Test
@DisplayName("로또 수량을 계산한다")
void 성공_로또수량계산() {
// Given
PurchasePrice price = new PurchasePrice("10000");
// When
BigDecimal quantity = price.calculateQuantity();
// Then
assertThat(quantity.intValue()).isEqualTo(10);
}
고민한 후 결론은 각 객체는 자율적인 존재로서 자신의 상태에 대한 책임을 지어야 한다는 것입니다. 구입 금액과 로또 수량 모두 독립적인 객체이므로 각각 자신의 유효성을 검증하고, 자신의 상태에 책임질 수 있도록 구현했습니다.
객체지향의 사실과 오해 : 객체지향 공동체를 구성하는 기본 단위는 '자율적'인 객체다.
중복된 검증을 제거하자.
또 다른 고민은 여러 도메인 객체에서 반복되는 기본적인 검증 로직(null, empty 등등)을 어떻게 다룰 것인지였습니다. 처음에는 각 도메인 객체가 자신의 검증 로직을 포함하도록 구현해서 검증 중복이 많이 일어났습니다.
- Splitter -> 입력값 검증 로직 포함
public class Splitter {
public List<String> split(final String text) {
validate(text);
return Arrays.asList(text.split(delimiter, -1));
}
private void validate(final String text) {
if (text == null) {
throw new InvalidTextException("null일 수 없습니다");
}
if (text.isBlank()) {
throw new InvalidTextException("빈 문자열이거나 공백일 수 없습니다");
}
}
}
- IntegerConverter -> 입력값 검증 로직 포함
public class IntegerConverter {
public int convertFrom(final String input) {
try {
validateInput(input);
return Integer.parseInt(input.trim());
} catch (NumberFormatException exception) {
throw new InvalidInputException("Integer 타입의 정수가 아닙니다");
} catch (NullPointerException exception) {
throw new InvalidInputException("null일 수 없습니다");
}
}
private void validateInput(final String input) {
if (input == null) {
throw new InvalidInputException("입력값은 null일 수 없습니다");
}
if (input.isBlank()) {
throw new InvalidInputException("입력값은 빈 문자열이거나 공백일 수 없습니다");
}
}
}
- Price -> 입력값 검증 로직 포함
public class Price {
public static final BigDecimal LOTTO_UNIT_PRICE = BigDecimal.valueOf(1000);
private final BigDecimal price;
public Price(final String input) {
validateLength(input);
BigDecimal price = parse(input);
validateNumber(price);
this.price = price;
}
private void validateLength(final String input) {
if (input == null) {
throw new InvalidPurchasePriceException("구입 금액은 null이 될 수 없습니다");
}
if (input.isBlank()) {
throw new InvalidPurchasePriceException("구입 금액은 비어있거나 공백일 수 없습니다");
}
}
private BigDecimal parse(final String input) {
try {
return new BigDecimal(input.trim());
} catch (NumberFormatException e) {
throw new InvalidPurchasePriceException("구입 금액은 숫자로만 이루어져야 합니다");
}
}
검증 로직에는 두 종류가 있습니다.
- 공통 검증 : null, empty 등 기본적인 입력값 검증
- 도메인 검증 : 1000원 단위 체크, 로또 번호 범위(1~45) 확인, 도메인 규칙과 관련된 검증
Validator 도입
공통 검증을 InputValidator
클래스로 분리하여 중복을 제거하고 검증 책임을 분리했습니다. 처음에는 유틸리티 클래스가 객체지향 원칙에 위배되는 것은 아닌지 고민했지만, 상태를 가지지 않은 순수한 검증 로직이므로 오히려 이런 방식이 더 자연스럽다고 판단했습니다.
public class InputValidator {
public static void validateNotNullOrBlank(final String input, final String fieldName) {
if (input == null) {
throw new InvalidInputException(fieldName + "은(는) null일 수 없습니다.");
}
if (input.isBlank()) {
throw new InvalidInputException(fieldName + "은(는) 빈 문자열이거나 공백일 수 없습니다.");
}
}
public static void validateNotNullOrEmpty(final List<?> input, final String fieldName) {
if (input == null) {
throw new InvalidInputException(fieldName + "은(는) null일 수 없습니다.");
}
if (input.isEmpty()) {
throw new InvalidInputException(fieldName + "은(는) 빈 문자열일 수 없습니다.");
}
}
}
public Price(final BigDecimal price) {
validate(price);
this.price = price;
}
private void validate(final BigDecimal price) {
if (price.scale() > 0) {
throw new InvalidPurchasePriceException("구입 금액은 숫자로만 이루어져야 합니다.");
}
if (isNotDivisible(price)) {
throw new InvalidPurchasePriceException("구입 금액이 1000원 단위가 아닙니다.");
}
if (price.compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidPurchasePriceException("구입 금액은 자연수여야 합니다.");
}
}
효과
이를 통해 검증 로직의 중복이 제거되고, 각 도메인 객체는 핵심 도메인 검증만 가지게 되었습니다. 또한 에러가 발생했을 때 어느 부분에서 에러가 났는지 더 쉽게 파악할 수 있게 되었습니다. 또한, 검증 로직도 적절하게 분리하여 코드의 품질을 높일 수 있다는 것을 알게 되었습니다.
적절한 Java 타입과 컬렉션을 사용하자.
금액 계산 - BigDecimal 활용
금융 계산에서는 정확성이 매우 중요하기 때문에 로또 가격과 수량 계산에 BigDecimal을 사용했습니다. BigDecimal을 사용하면서 그와 관련된 모든 숫자 타입이 자연스럽게 BigDecimal이 되었습니다.
BigDecimal은 부동 소수점 연산의 오차를 방지하고 정확한 계산을 보장합니다.
public class Price {
public static final BigDecimal LOTTO_UNIT_PRICE = BigDecimal.valueOf(1000);
private final BigDecimal price;
public Price(final BigDecimal price) {
validate(price);
this.price = price;
}
public Quantity calculateQuantity() {
return new Quantity(price.divide(LOTTO_UNIT_PRICE));
}
}
public class Quantity {
private final BigDecimal quantity;
public Quantity(final BigDecimal quantity) {
validate(quantity);
this.quantity = quantity;
}
}
EnumMap을 통해 성능 최적화
Enum을 키로 사용하는 Map이 필요할 때는 일반 HashMap 대신 EnumMap을 선택했습니다.
effective java item 37 : ordinal 인덱싱 대신 EnumMap을 사용하라.
EnumMap
은 열거 타입을 키로 사용한다. 더 짧고, 안전하고 성능도 비슷하다.
private Map<LottoRank, BigDecimal> calculateLottoResult() {
Map<LottoRank, BigDecimal> result = new EnumMap<>(LottoRank.class);
for (LottoRank lottoRank : LottoRank.values()) {
result.put(lottoRank, BigDecimal.ZERO);
}
for (Lotto lotto : drawnLottos) {
getRank(lotto).ifPresent(lottoRank ->
result.put(lottoRank, result.get(lottoRank).add(BigDecimal.ONE)));
}
return Collections.unmodifiableMap(result);
}
또한 Map을 unmodifiableMap으로 만들어 외부에서 수정하는 것을 막았습니다.
캐싱 도입하여 재사용하기
반복 생성되는 객체 문제
로또 번호는 1부터 45 사이의 정수로 제한되어 있는데, 게임이 진행될 때마다 동일한 값을 가진 새로운 객체들이 계속 생성되고 있었습니다. 같은 번호에 대해 매번 새로운 객체를 생성하는 것은 비효율적이므로 캐싱하여 재사용했습니다.
이때 생성자를 private으로 두고 정적 팩토리 메서드를 이용해 객체 생성을 제어했습니다.
public class LottoNumber {
public static final int MIN_LOTTO_NUMBER = 1;
public static final int MAX_LOTTO_NUMBER = 45;
private static final List<LottoNumber> CACHE;
static {
CACHE = IntStream.range(MIN_LOTTO_NUMBER, MAX_LOTTO_NUMBER + 1)
.mapToObj(LottoNumber::new)
.toList();
}
private final int number;
private LottoNumber(final int number) {
this.number = number;
}
public static LottoNumber valueOf(final int number) {
validateNumber(number);
return CACHE.get(number - 1);
}
private static void validateNumber(final int number) {
if (number < MIN_LOTTO_NUMBER || number > MAX_LOTTO_NUMBER) {
throw new InvalidLottoNumberException("로또 번호는 1 이상 45 이하여야 합니다.");
}
}
public int getNumber() {
return number;
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
LottoNumber that = (LottoNumber) o;
return number == that.number;
}
@Override
public int hashCode() {
return Objects.hash(number);
}
}
'우테코' 카테고리의 다른 글
[우아한테크코스] 프리코스 4주차 - 편의점 미션 회고 (1) | 2024.11.17 |
---|---|
[우아한테크코스] 프리코스 중간 회고 (2) | 2024.11.06 |
[우아한테크코스] 프리코스 2주차 - 자동차 경주 미션 회고 (0) | 2024.10.28 |
값 객체(VO) : 일반 클래스 vs record (2) | 2024.10.26 |