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를 받아 실제 위치가 같은지를 체크해줍니다.
  • 실제 해당 일급컬렉션도 주소값이 아니라, 실제 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