Backend/Java

[숫자야구게임 Step2] 도메인 구현

Seyun(Marco) 2024. 4. 24. 00:30
728x90

서론

  • 이전 장에서는 숫자야구게임의 정책문서를 작성하며, 테크스펙을 작성해 개발시 문제가 될 영역들을 체크해봤다.
  • Step2에서 필요한 도메인 로직을 추가하고, Game이라는 도메인을 만들어서 하나의 Game에 도메인 관리를 하는 것을 목표로 리팩토링 및 구현을 해보겠습니다.
  • 이번 미션부터는 인자, 멤버 변수, 지역 변수 등에 최대한 final을 붙여 불변을 유지하는 것이 목표입니다.
  • 또한 Value Object를 record로 변경합니다.
    • Java17부터는 record를 지원합니다. Value Object인 경우엔 record가 어울리는 형태이기 때문에 record로 변경해서 진행합니다.

Game

  • Game의 모든 정보를 가지고 있습니다.
  • 유니크한 값으로 구분할 PK, 게임에 할당된 Computer, Player가 시도한 기록인 PlayerRecords, 시간 및 종료 일자 및 시간을 상태로 가지고 있습니다.
  • 이번 요구사항에서의 핵심 메서드는 addPlayerRecord 입니다.
public void addPlayerRecord(final PlayerRecord playerRecord) {
    if (endAt != null) {
        throw new IllegalArgumentException("게임이 종료되었습니다.");
    }

    playerNumbers.add(playerRecord);

    if (playerRecord.isSuccess()) {
        endAt = LocalDateTime.now();
    }
}
  • 게임을 진행하면, Player는 정답을 맞출때 까지 계속 시도하게 됩니다.
  • 일단, 게임이 종료되었다면 Player는 시도할 수 없도록 해야 합니다. 그 부분에 대한 Validate를 추가합니다.
  • 아울러 PlayerRecord를 일급컬렉션에 추가합니다.
  • 마지막으로 이번 시도에서 Player가 정답을 맞춰 성공했다면 endAt의 시간을 현재 시간으로 넣어서 종료되도록 합니다.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class GameTest {
    private Game generalGame;

    @BeforeEach
    void setUp() {
        generalGame = new Game(new Computer(List.of(new Number(1), new Number(2), new Number(3))));
    }

    @DisplayName("player가 성공하지 못하면 게임이 종료되지 않는다.")
    @Test
    void AddPlayerRecordTest() {
        final PlayerRecord playerRecord = new PlayerRecord(new BaseBallNumbers(List.of(new Number(1), new Number(2), new Number(4))), 2, 0);
        assertThat(generalGame.getEndAt()).isNull();

        generalGame.addPlayerRecord(playerRecord);

        assertThat(generalGame.getEndAt()).isNull();
    }

    @DisplayName("player가 성공하면 게임이 종료된다.")
    @Test
    void endAfterAddPlayerRecordTest() {
        final PlayerRecord playerRecord = new PlayerRecord(new BaseBallNumbers(List.of(new Number(1), new Number(2), new Number(3))), 3, 0);
        assertThat(generalGame.getEndAt()).isNull();

        generalGame.addPlayerRecord(playerRecord);

        assertThat(generalGame.getEndAt()).isNotNull();
    }

    @DisplayName("게임이 이미 종료되었다면 playerRecord를 추가할 수 없다.")
    @Test
    void addPlayerRecordAfterEndExcpetionTest() {
        final PlayerRecord playerRecord = new PlayerRecord(new BaseBallNumbers(List.of(new Number(1), new Number(2), new Number(3))), 3, 0);

        generalGame.addPlayerRecord(playerRecord);

        assertThat(generalGame.getEndAt()).isNotNull();

        assertThatThrownBy(() -> generalGame.addPlayerRecord(playerRecord))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("게임이 종료되었습니다.");
    }
}

  • 위에서의 조건 및 상황들이 모두 잘 동작하는지 단위테스트를 꼼꼼히 넣어두고 정책을 확인해봅니다.
  • 전체적인 도메인 코드를 살펴보겠습니다.
package baseball.domain;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

public class Game {

    private int id;

    private final Computer computer;

    private final List<PlayerRecord> playerNumbers = new ArrayList<>();

    private final LocalDateTime startAt = LocalDateTime.now();

    private LocalDateTime endAt;

    public Game(final Computer computer) {
        this.computer = computer;
    }

    public void addPlayerRecord(final PlayerRecord playerRecord) {
        if (endAt != null) {
            throw new IllegalArgumentException("게임이 종료되었습니다.");
        }

        playerNumbers.add(playerRecord);

        if (playerRecord.isSuccess()) {
            endAt = LocalDateTime.now();
        }
    }

    public int getId() {
        return id;
    }

    public Computer getComputer() {
        return computer;
    }

    public LocalDateTime getStartAt() {
        return startAt;
    }

    public LocalDateTime getEndAt() {
        return endAt;
    }

    public int getPlayerTimes() {
        return playerNumbers.size();
    }

    public void setId(final int id) {
        this.id = id;
    }

    public List<PlayerRecord> getPlayerNumbers() {
        return playerNumbers;
    }
}

  • setId가 있는 경우는 이번 애플리케이션에 특이한 부분으로 저희는 현재 InMemory에 저장되는 Collection을 사용하고 있습니다. PK 격인 Id는 해당 Collection에 저장될때 알수 있는것이기 때문에 특이하게 id만 setter를 만들었습니다.

Count

  • 스트라이크, 볼의 갯수를 의미하는 Count Value Object를 이번 Step2에서는 만들어 보도록 하겠습니다.
  • Value Object로 하는 이유는 일단 기본적인 Validate를 추가하기 위함입니다.
  • 0개~3개 사이의 값이 되어야 하는 조건을 좀더 Value Object를 통해 처리할 예정입니다.
  • 아울러 record로 처리해서 getter, final과 같은 기본 메서드들을 Value Object가 기본적으로 가져야 하는 것들을 나타낼 예정이빈다.
public record Count(int value) {
    private static final int MIN_VALUE = 0;
    private static final int MAX_VALUE = 3;

    public Count {
        validateCount(value);
    }

    private void validateCount(int count) {
        if (count < MIN_VALUE || count > MAX_VALUE) {
            throw new IllegalArgumentException("0~3개만 가능합니다.");
        }
    }

    public boolean isMinValue() {
        return this.value == MIN_VALUE;
    }

    public boolean isMaxValue() {
        return this.value == MAX_VALUE;
    }
}
  • 최대, 최소값을 상수로 빼서 최대한 가독성과 의미 전달에 신경을 쓰는것이 좋습니다.
  • isMinValue와 isMaxValue는 낫싱 / 성공 여부를 의미하는 수들이기 때문에 메서드로 빼서 자기 자신 스스로 상태를 검증할 수 있도록 합니다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class CountTest {

    @DisplayName("최소 수인 경우에는 true를 반환한다.")
    @Test
    void isMinValueTest() {
        Count count = new Count(0);
        assertThat(count.isMinValue()).isTrue();
    }

    @DisplayName("최소 수가 아닌 경우에는 false를 반환한다.")
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3})
    void isNotMinValueTest(int value) {
        Count count = new Count(value);
        assertThat(count.isMinValue()).isFalse();
    }

    @DisplayName("최대 수인 경우에는 true를 반환한다.")
    @Test
    void isMaxValueTest() {
        Count count = new Count(3);
        assertThat(count.isMaxValue()).isTrue();
    }

    @DisplayName("최대 수가 아닌 경우에는 false를 반환한다.")
    @ParameterizedTest
    @ValueSource(ints = {0, 1, 2})
    void isNotMaxValueTest(int value) {
        Count count = new Count(value);
        assertThat(count.isMaxValue()).isFalse();
    }

    @DisplayName("0~3개만 가능합니다. 에러 메시지를 반환한다.")
    @ParameterizedTest
    @ValueSource(ints = {-1, 4})
    void validateCountTest(int value) {
        assertThatThrownBy(() -> new Count(value))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("0~3개만 가능합니다.");
    }
}

Number

  • Number도 record로 변경하여 좀더 Value Object에 필요한 메서드들을 쉽게 지원하도록 합니다.
package baseball.domain;

public record Number(int value) {
    public static final int MIN_VALUE = 1;
    public static final int MAX_VALUE = 9;

    public Number {
        validateRange(value);
    }

    public void validateRange(final int value) {
        if (value < MIN_VALUE || value > MAX_VALUE) {
            throw new IllegalArgumentException("1부터 9까지의 숫자만 입력 가능합니다.");
        }
    }

    public boolean equals(final int value) {
        return this.value == value;
    }

}

Commend

  • 이번에 기록을 조회하는 명령어를 추가하게 됩니다.
  • Enum에 추가함으로써 이전에 COMMEND_VALUES를 통해 알아서 캐싱이 될것이기 때문에 우리는 Enum 값만 하나 추가하면 됩니다.
  • 그러나 이번에 추가하면서 of 메서드에 validate 메서드는 하드코딩이기때문에 또 변경해줘야 하는 귀찮음이 있습니다.
  • 이부분도 동적으로 할수 있게 해보겠습니다.
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),
    GAME_RECORD(2),
    END(9);

    private static final String DELIMITER = ", ";
    private static final Map<Integer, Commend> COMMEND_VALUES = Arrays.stream(values())
            .collect(Collectors.toMap(commend -> commend.value, Function.identity()));
    private static final String VALUE_STRING = COMMEND_VALUES.keySet()
            .stream()
            .map(String::valueOf)
            .collect(Collectors.joining(DELIMITER));

    private final int value;

    Commend(final int value) {
        this.value = value;
    }

    public static Commend of(final int value) {
        return Optional.ofNullable(COMMEND_VALUES.get(value))
                .orElseThrow(() -> new IllegalArgumentException(VALUE_STRING + "만 입력 가능합니다."));
    }
}

  • 이미 있는 COMMEND_VALUES를 통해 stream을 통해 String으로 value를 만들어서 joining을 통해 값을 만들면 차후에 추가되어도 자동으로 ExceptionMessage까지 만들어지기 때문에 더욱더 유동성 있게 되었습니다.
  • of의 테스트 코드도 provideOfTestFixtures에 값을 추가하면 하면 테스트 코드도 통과합니다.
  • Exception인 경우엔 하드코딩을 해두어야 차후 엣지케이스가 생기지 않아 테스트 코드는 예외 메시지를 하드코딩으로 처리합니다.
package baseball.domain;

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(2, Commend.GAME_RECORD),
                Arguments.of(9, Commend.END)
        );
    }

    @DisplayName("명령어 찾기 테스트")
    @ParameterizedTest
    @MethodSource("provideOfTestFixtures")
    void ofTest(final int value, final Commend expected) {
        final Commend actual = Commend.of(value);

        assertThat(actual).isEqualTo(expected);
    }

    @DisplayName("존재하지 않는 명령어를 입력하면 예외가 발생한다.")
    @Test
    void ofExceptionTest() {
        assertThatThrownBy(() -> Commend.of(3))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("1, 2, 9만 입력 가능합니다.");
    }
}

BaseBallNumbers

  • 이것도 역시 record로 변경하였습니다.
  • 아울러 getValueNumber라고 하는 메서드를 새롭게 만들었습니다.
public List<Integer> getValueNumbers() {
    return numbers.stream()
            .map(Number::value)
            .toList();
}
  • 해당 메서드는 실제 numbers에 있는 원시값의 List를 반환합니다.
  • 불변으로 반환하며 원시값을 반환하는 이유는 차후 원시값을 View를 내려줄때 써야 하는데, 굳이 외부에서 getter를 사용해서 추출하기 보단 내부에서 변환 이후에 하는 것이 훨씬 더 안전하다고 판단하였습니다.
  • 마지막으로 record로 변경하면서 실제 validate를 할때 인자를 받아서 처리하도록 리팩토링 하였습니다.
import java.util.Collections;
import java.util.List;

public record BaseBallNumbers(List<Number> numbers) {
    public static final int TOTAL_COUNT = 3;

    public BaseBallNumbers {
        validateDuplicateNumbers(numbers);
        validateSize(numbers);
    }

    public boolean isContains(final Number number) {
        return numbers.contains(number);
    }

    public boolean isSameIndexOf(final Number number, final int index) {
        return numbers.get(index).equals(number);
    }

    public int indexOf(final Number number) {
        return numbers.indexOf(number);
    }

    @Override
    public List<Number> numbers() {
        return Collections.unmodifiableList(numbers);
    }

    public List<Integer> getValueNumbers() {
        return numbers.stream()
                .map(Number::value)
                .toList();
    }

    private boolean isTotalSize(final List<Number> numbers) {
        return numbers.size() == TOTAL_COUNT;
    }

    private boolean isDuplication(final List<Number> numbers) {
        return numbers.stream()
                .distinct()
                .count() != numbers.size();
    }

    private void validateDuplicateNumbers(final List<Number> numbers) {
        if (isDuplication(numbers)) {
            throw new IllegalArgumentException("중복된 숫자가 있습니다.");
        }
    }

    private void validateSize(final List<Number> numbers) {
        if (!isTotalSize(numbers)) {
            throw new IllegalArgumentException("3개의 숫자를 입력해주세요.");
        }
    }
}

  • 기존 Step1과 테스트 코드나 정책은 변경된게 없으니 테스트 코드는 같이 첨부를 하지 않습니다.

PlayerRecord

  • 실제 플레이어의 각각의 시도에 대한 정보들이 들어있는 Domain입니다.
  • 각각의 시도에서 숫자, 스트라이크 갯수, 볼 갯수들을 상태로 가지고 있습니다.
package baseball.domain;

import java.util.List;

public class PlayerRecord {
    private final BaseBallNumbers numbers;

    private final Count strikeCount;

    private final Count ballCount;

    public PlayerRecord(BaseBallNumbers numbers, int strikeCount, int ballCount) {
        this.numbers = numbers;
        this.strikeCount = new Count(strikeCount);
        this.ballCount = new Count(ballCount);
    }

    public List<Integer> getValueNumbers() {
        return numbers.getValueNumbers();
    }

    public int getStrikeCount() {
        return strikeCount.value();
    }

    public int getBallCount() {
        return ballCount.value();
    }

    public boolean isNotting() {
        return ballCount.isMinValue() && strikeCount.isMinValue();
    }

    public boolean isSuccess() {
        return strikeCount.isMaxValue();
    }
}

  • 플레이어가 입력하는 숫자에 대해서는 BaseBallNumbers에서 validate로 처리하고 있으며, 스트라이크, 볼의 갯수에 대해서는 Count의 Value Object에서 처리하고 있으므로 해당 객체에선 실제 플레이어의 시도에 대한 정보들만 잘 리턴하도록 처리합니다.
package baseball.domain;

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 java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class PlayerRecordTest {
    @DisplayName("스트라이트와 볼 갯수가 0개면 낫싱이다.")
    @Test
    void isNottingTest() {
        BaseBallNumbers plyerNumbers = new BaseBallNumbers(List.of(
                new Number(1),
                new Number(2),
                new Number(3)));
        PlayerRecord playerRecord = new PlayerRecord(plyerNumbers, 0, 0);
        assertThat(playerRecord.isNotting()).isTrue();
    }

    @DisplayName("스트라이트와 볼 갯수가 0개가 아니면 낫싱이 아니다.")
    @ParameterizedTest
    @CsvSource(value = {"1,0", "0,1", "1,1"})
    void isNottingTest2(int ballCount, int strikeCount) {
        BaseBallNumbers plyerNumbers = new BaseBallNumbers(List.of(
                new Number(1),
                new Number(2),
                new Number(3)));
        PlayerRecord playerRecord = new PlayerRecord(plyerNumbers, strikeCount, ballCount);
        assertThat(playerRecord.isNotting()).isFalse();
    }

    @Test
    @DisplayName("스트라이크 갯수가 3개면 성공이다.")
    void isSuccessTest() {
        BaseBallNumbers plyerNumbers = new BaseBallNumbers(List.of(
                new Number(1),
                new Number(2),
                new Number(3)));
        PlayerRecord playerRecord = new PlayerRecord(plyerNumbers, 3, 0);
        assertThat(playerRecord.isSuccess()).isTrue();
    }

    @DisplayName("스트라이크 갯수가 3개가 아니면 성공이 아니다.")
    @ParameterizedTest
    @CsvSource(value = {"1,1", "0,2"})
    void isSuccessTest2(int ballCount, int strikeCount) {
        BaseBallNumbers plyerNumbers = new BaseBallNumbers(List.of(
                new Number(1),
                new Number(2),
                new Number(3)));
        PlayerRecord playerRecord = new PlayerRecord(plyerNumbers, strikeCount, ballCount);
        assertThat(playerRecord.isSuccess()).isFalse();
    }
}

클린코드&객체지향을 지향하자.

  • 하드코딩된 값들을 상수화를 진행합니다.
  • 실제 해당 내부 메서드에서만 사용하는 메서드 및 상태인 경우엔 private의 접근제어자로 외부에서 사용하지 못하도록 합니다
  • getter를 지양합니다.
    • Collection을 부득히 하게 getter를 써야 하는 경우엔 unmodifiableList 를 이용해 불변을 유지하여 외부에서 해당 데이터를 변경하지 못하도록 합니다.
  • 어떤 행동을 해야 한다면, 객체에게 메시지를 던져 응답을 받을수 있도록 합니다.
  • final을 통해서 각 값들에 대해서 최대한 불변을 유지합니다.
    • 인스턴스 변수, 매개변수, 지역 변수 등에 final을 사용합니다.
  • valueObject를 사용 할 시, record를 사용해서 좀더 간편하게 사용하도록 합니다.

결론

  • Step1을 끝내고 Step2로 넘어와서 Step1에 대한 코드들을 한번 읽어보고, 정책 문서 및 테크스펙을 작성하면서 리팩토링을 해야 하는 부분들이 많이 보이게 된거 같습니다.
  • 리팩토링이란, 계속 해야 하는 것이라고 생각이 드는데 이번 Step2에서 아키텍쳐적인 리팩토링 및 객체지향에서의 메서드에 대한 구조를 리팩토링 한 부분에 대해서는 최대한 만족스러운거 같습니다.
  • 요구사항이 추가되고 분석하면서 해당 도메인 및 문제에 대해서 객체지향적으로 어떻게 아키텍처를 잡는것이 훨씬 좋을지도 조금씩 보이게 되는거 같습니다.
  • 아직 ValueObject나 일급컬렉션, 메서드를 작성하는 것이 조금 미흡하다고 생각이 들지만, 아직 Step3, 4가 남아 있기 때문에 계속해서 리팩토링을 하면서 구조를 변경해 나가면서 시도해보는것이 가장 중요하다고 생각이 듭니다.
  • 다음 편에선 비지니스 로직을 어떻게 리팩토링하였는지, 실제 기록을 조회하는 비지니스 로직, Repository 로직을 같이 살펴보겠습니다.

 

언제든 해당 글에 대해서 피드백은 환영입니다. 
728x90
728x90