Backend/Java
[숫자야구게임 Step1] 도메인 구현
Seyun(Marco)
2024. 4. 12. 02:15
728x90
서론
- 이전 장에서는 숫자야구게임의 정책문서를 작성하며, 테크스펙을 작성해 개발시 문제가 될 영역들을 체크해봤다.
- 이번 장에서는 도메인을 실제 구현해볼 예정이다.
Number
- BaseBall Game에서 사용할 숫자를 Value Object로 정의내린 클래스입니다.
- 일단 Validate가 필요합니다.
- 이전 장에서 정책 문서에서 보면 실제 숫자는 1~9의 값만 가능합니다.
- 실제 그 숫자가 아닌 다른 값이 들어온다면 예외가 발생하여 버그를 방지하는 코드를 작성하였습니다.
- 또한 Value Object이기 때문에 실제 value가 같으면 해당 객체는 같은것으로 보는것도 중요합니다.
- 해당 게임에선 주소값이 같은게 같은 Number가 아니라 실제 숫자가 같은지를 보고 판단합니다.
- 그러기 때문에 eqauls & hashCode를 오버라이딩을 하여 처리해줘야 합니다.
- 그럼 아래 코드를 보면, 위에서 말한것이 모두 적용된걸 확인할수 있습니다.
import java.util.Objects;
public class Number {
public static final int MIN_VALUE = 1;
public static final int MAX_VALUE = 9;
private final int value;
public Number(int value) {
validateRange(value);
this.value = value;
}
public void validateRange(int value) {
if (value < MIN_VALUE || value > MAX_VALUE) {
throw new IllegalArgumentException("1부터 9까지의 숫자만 입력 가능합니다.");
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Number number = (Number) o;
return value == number.value;
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
- 실제 잘 적용되었는지 Validate에 대한 테스트 코드를 작성해보겠습니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class NumberTest {
@DisplayName("1부터 9까지의 숫자만 입력 가능하다.")
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})
void validateRangeTrueTest(int number) {
assertDoesNotThrow(() -> new Number(number));
}
@DisplayName("0또는 10이상인 값인 경우엔 에러가 발생한다.")
@ParameterizedTest
@ValueSource(ints = {0, 10})
void validateRangeFalseTest(int number) {
assertThatThrownBy(() -> new Number(number))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("1부터 9까지의 숫자만 입력 가능합니다.");
}
}
- 1~9까지의 숫자는 정상적으로 생성되는지를 체크하기 위해 생성 시, 예외가 발생하지 않는다면 정상적인것으로 판단하였습니다.
- 아울러 다른 숫자들을 모두다 검증할 수 없으니, 경계선인 0과 10을 가지고 테스트 코드를 작성하여 실제 예외가 발생하는지에 대한 테스트 코드를 넣어두었습니다..
BaseBallNumbers
- 이제 실제 BaseBall에서 사용할 숫자의 리스트인 일급컬렉션을 구현해봅니다.
- 여기서 중요한 validate 정책들이 있습니다.
- 중복값이 존재하면 안된다.
- Stream의 distinct를 이용해 중복값을 제거한 갯수와 실제 List의 갯수와 같은지를 체크함으로써 중복값이 존재하면 예외를 발생하도록 하였습니다.
- 3개의 숫자들만 존재해야 한다.
- 실제 numbers의 갯수가 3 인지를 체크하는 코드를 작성하였습니다.
- 중복값이 존재하면 안된다.
- 이제 이 일급컬렉션이 하는 행동들을 생각해보겠습니다.
- Ball인 경우엔 해당 숫자가 존재하는지에 대해서가 중요합니다.
- 따라서 그부분에 대한 상태를 말해줄수 있게, isContains라는 메서드를 정의내립니다.
- 실제 이 메서드는 인자로 들어온 number가 해당 리스트에 존재하는지를 체크해줍니다.
- Strike는 해당 숫자가 같은 위치에 있는지가 중요합니다.
- 따라서 그부분의 상태를 말해줄수 있게, isSameIndexOf 라는 메서드를 정의내립니다.
- 실제 이메서드는 인자로 number와 위치를 뜻하는 index를 받아 실제 위치가 같은지를 체크해줍니다.
- Ball인 경우엔 해당 숫자가 존재하는지에 대해서가 중요합니다.
- 실제 해당 일급컬렉션도 주소값이 아니라, 실제 List의 숫자가 위치까지 아예 모두 동일하면 동일한 객체로 보는게 좋으니, eqauls & hashcode를 구현합니다.
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public class BaseBallNumbers {
public static final int TOTAL_COUNT = 3;
private final List<Number> numbers;
public BaseBallNumbers(List<Number> numbers) {
this.numbers = numbers;
validateDuplicateNumbers();
validateSize();
}
public boolean isContains(Number number) {
return numbers.contains(number);
}
public boolean isSameIndexOf(Number number, int index) {
return numbers.get(index).equals(number);
}
public int indexOf(Number number) {
return numbers.indexOf(number);
}
public List<Number> getNumbers() {
return Collections.unmodifiableList(numbers);
}
private boolean isTotalSize() {
return this.numbers.size() == TOTAL_COUNT;
}
private boolean isDuplication() {
return numbers.stream()
.distinct()
.count() != numbers.size();
}
private void validateDuplicateNumbers() {
if (isDuplication()) {
throw new IllegalArgumentException("중복된 숫자가 있습니다.");
}
}
private void validateSize() {
if (!isTotalSize()) {
throw new IllegalArgumentException("3개의 숫자를 입력해주세요.");
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BaseBallNumbers that = (BaseBallNumbers) o;
return Objects.equals(numbers, that.numbers);
}
@Override
public int hashCode() {
return Objects.hash(numbers);
}
}
- 이 부분들을 체크하기 위해서 테스트 코드를 작성합니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
class BaseBallNumbersTest {
private static final BaseBallNumbers INCORRECT_BASE_BALL_NUMBERS = new BaseBallNumbers(List.of(
new Number(1),
new Number(2),
new Number(3))
);
@DisplayName("해당 Number가 포함되어 있다면 True다.")
@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void isContainsTrueTest(int number) {
assertThat(INCORRECT_BASE_BALL_NUMBERS.isContains(new Number(number))).isTrue();
}
@DisplayName("해당 Number가 포함되어 있지 않다면 False다.")
@ParameterizedTest
@ValueSource(ints = {4, 5, 6})
void isContainsFalseTest(int number) {
assertThat(INCORRECT_BASE_BALL_NUMBERS.isContains(new Number(number))).isFalse();
}
@DisplayName("같은 위치에 같은 숫자가 있으면 True다.")
@Test
void isSameIndexOfTrueTest() {
assertThat(INCORRECT_BASE_BALL_NUMBERS.isSameIndexOf(new Number(1), 0)).isTrue();
}
@DisplayName("위치에 다른 숫자가 있으면 True다.")
@Test
void isSameIndexOfFalseTEst() {
assertThat(INCORRECT_BASE_BALL_NUMBERS.isSameIndexOf(new Number(1), 1)).isFalse();
}
@DisplayName("해당 숫자의 인덱스를 조회할수 있다.")
@ParameterizedTest
@CsvSource(value = {"1, 0", "2, 1", "3, 2"}, delimiter = ',')
void indexOfTest(int number, int index) {
assertThat(INCORRECT_BASE_BALL_NUMBERS.indexOf(new Number(number))).isEqualTo(index);
}
@DisplayName("중복된 값이 있으면 예외가 발생한다.")
@Test
void validateDuplicateNumbersExceptionTest() {
List<Number> numbers = List.of(new Number(1),
new Number(2),
new Number(2));
assertThatThrownBy(() -> new BaseBallNumbers(numbers)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("중복된 숫자가 있습니다.");
}
@DisplayName("중복값이 없으면 예외가 발생하지 않는다.")
@Test
void validateDuplicateNumbersNoExceptionTest() {
List<Number> numbers = List.of(new Number(1),
new Number(2),
new Number(3));
assertDoesNotThrow(() -> new BaseBallNumbers(numbers));
}
@DisplayName("number가 2개 이하가 들어가는 경우 에러가 발생한다.")
@Test
void validateSizeLessThanTwoExceptionTest() {
List<Number> numbers = List.of(new Number(1),
new Number(2));
assertThatThrownBy(() -> new BaseBallNumbers(numbers)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("3개의 숫자를 입력해주세요.");
}
@DisplayName("number가 4개 이상이 들어가는 경우 에러가 발생한다.")
@Test
void validateSizeMoreThanFourExceptionTest() {
List<Number> numbers = List.of(new Number(1),
new Number(2),
new Number(3),
new Number(4));
assertThatThrownBy(() -> new BaseBallNumbers(numbers)).isInstanceOf(IllegalArgumentException.class)
.hasMessage("3개의 숫자를 입력해주세요.");
}
}
- 테스트 코드에서 중요한것은 성공하는 테스트와 실패하는 테스트 둘다 있어야 한다는 점입니다.
- 해당 부분을 참고해서 True, False와 예외가 발생하는지 예외가 발생하지 않는지에 따라서 처리하도록 하였습니다.
- 또한 중복된 텍스트 픽스쳐가 있기 때문에 맨위에다가 상수로 정의해두어서 처리하도록 하였습니다.
Computer
- 이제 Domain인 Computer를 구현해보겠습니다.
- 컴퓨터는 자신이 뽑은 임의의 숫자 3개를 가진 BaseBallNumbers를 상태로 가지고 있습니다.
- 실질적으로 Ball인지, Strike인지는 컴퓨터만 알것입니다. 왜냐하면 자기 자신의 상태는 객체 스스로만 가지도록 설계헤야 하기 때문입니다.
- 따라서 그에 맞춰 Ball, Strike인지를 체크하는 메서드를 구현해두었습니다.
- 아울러 마지막으로 실제 Computer가 가지고 있는 numbers와 위치, 숫자 모두다 같은지를 체크하는 isSameNumbers까지 구현하였습니다.
- 이 코드는 이후 편에서 어떻게 사용할지 나올겁니다.
import java.util.List;
public class Computer {
private final BaseBallNumbers numbers;
public Computer(List<Number> numbers) {
this.numbers = new BaseBallNumbers(numbers);
}
public boolean isBall(Number number) {
return numbers.isContains(number);
}
public boolean isStrike(Number number, int index) {
return numbers.isSameIndexOf(number, index);
}
public boolean isSameNumbers(BaseBallNumbers numbers) {
return this.numbers.equals(numbers);
}
}
Commend
- 애플리케이션에는 명령어가 있습니다.
- 지금은 게임을 시작하고, 끝내는 명령어만 있는데요. 해당 명령어를 Enum을 통해 상수로 정의내려두어 명령어를 한곳에 모아 처리할수 있도록 구현합니다.
- COMMEND_VALUES는 캐싱을 위해 구현한 상수입니다.
- 기본적으로 Enum.values() 메서드는 새로운 배열을 계속 리턴하게 됩니다.
- 해당 배열을 가지고 Stream으로 FindFirst로 해당 명령어를 찾으면 메모리단에서 비효율적일수도 있습니다.
- 해당 부분을 Map을 통해 처리합니다.
- 참고링크 : https://dzone.com/articles/memory-hogging-enumvalues-method
import java.util.Arrays;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
public enum Commend {
START("1"),
END("9");
private static final Map<Integer, Commend> COMMEND_VALUES = Arrays.stream(values())
.collect(Collectors.toMap(commend -> Integer.parseInt(commend.value), Function.identity()));
private final String value;
Commend(String value) {
this.value = value;
}
public static Commend of(int value) {
return Optional.ofNullable(COMMEND_VALUES.get(value))
.orElseThrow(() -> new IllegalArgumentException("1 또는 9만 입력 가능합니다."));
}
}
- 여기서는 of에 대한 테스트코드만 작성하면 끝입니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CommendTest {
private static Stream<Arguments> provideOfTestFixtures() {
return Stream.of(
Arguments.of(1, Commend.START),
Arguments.of(9, Commend.END)
);
}
@DisplayName("명령어 찾기 테스트")
@ParameterizedTest
@MethodSource("provideOfTestFixtures")
void ofTest(int value, Commend expected) {
Commend actual = Commend.of(value);
assertThat(actual).isEqualTo(expected);
}
@DisplayName("존재하지 않는 명령어를 입력하면 예외가 발생한다.")
@Test
void ofExceptionTest() {
assertThatThrownBy(() -> Commend.of(2))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("1 또는 9만 입력 가능합니다.");
}
}
클린코드&객체지향을 지향하자.
- 하드코딩된 값들을 상수화를 진행합니다.
- 실제 해당 내부 메서드에서만 사용하는 메서드 및 상태인 경우엔 private의 접근제어자로 외부에서 사용하지 못하도록 합니다
- getter를 지양합니다.
- Collection을 부득히 하게 getter를 써야 하는 경우엔 unmodifiableList 를 이용해 불변을 유지하여 외부에서 해당 데이터를 변경하지 못하도록 합니다.
- 어떤 행동을 해야 한다면, 객체에게 메시지를 던져 응답을 받을수 있도록 합니다.
결론
- 모델들을 구현해 나가면서, 실제 메시지를 던져, 메시지를 받는 객체지향의 중요한 개념을 중점적으로 생각해서 구현해보았습니다.
- 아울러 테스트 코드를 통해 안정적인 애플리케이션을 집중해서 작성하였습니다.
- 다음편에서는 실제 비지니스 로직을 작성할 Controller를 구현해보겠습니다.
728x90
728x90