Backend/Java

[숫자야구게임 Step 1] 비지니스 로직을 작성하자.

Seyun(Marco) 2024. 4. 13. 23:30
728x90

서론

  • 지금까지 우리는 요구사항 분석과 테크스펙 작성을 통해 전체적인 흐름을 살펴보았으며, 도메인, VO, 일급컬렉션 등을 작성하여 객체지향설계에서 가장 중요한 객체를 직접 구현해보았습니다.
  • 그 과정에서 우리는 테스트 코드를 추가하여 안정적으로 우리가 설계한 대로 동작하는지, 또한 요구사항 정책에 따라 정상적으로 동작하는지를 확인하보았습니다.
  • 이제는 실제 숫자 야구게임이 동작하게 되는 큰 흐름을 살펴볼 예정입니다.

Factory

  • 메모리적인 성능을 위해 Factory를 만들어서 1~9까지의 Number를 재사용할 수 있게 하는게 좋다.
  • Factory내에는 캐싱할 값들의 Collections이 있습니다.
    • 해당 List에는 1~9까지의 Number 객체 9개가 있습니다.
    • 이후에 내부의 값이 변경되지 않도록 불변을 합니다.
    • 마지막으로 valueOf()라는 정적 메서드를 만듭니다.
      • int값의 number로 해당 value(Number 객체)를 찾을 수 있도록 합니다.
    • 마지막으로 getter를 만들어서 Generator에서 Shuffle을 위해 깊은 복사를 할수 있도록 처리합니다.
import baseball.domain.Number;

import java.util.List;
import java.util.stream.IntStream;

public class BaseBallNumberFactory {
    private static final List<Number> numbers = IntStream.rangeClosed(Number.MIN_VALUE, Number.MAX_VALUE)
            .mapToObj(Number::new)
            .toList();

    private BaseBallNumberFactory() {
    }

    public static Number valueOf(int value) {
        return numbers.stream()
                .filter(number -> number.equals(value))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("1부터 9까지의 숫자만 입력 가능합니다."));
    }

    public static List<Number> getNumbers() {
        return numbers;
    }
}

  • 이렇게 되면 위와 같이 로직이 돕니다.
  • 마지막으로 테스트 코드를 작성하여 원하는 바가 되었는지 체크해봅니다.
import baseball.domain.Number;
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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class BaseBallNumberFactoryTest {

    @DisplayName("숫자 값을 통해 해당 Number 객체를 조회할 수 있다.")
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9})
    void valueOfTest(int number) {
        assertThat(BaseBallNumberFactory.valueOf(number)).isEqualTo(new Number(number));
    }

    @DisplayName("숫자가 1부터 9까지의 숫자가 아닌 경우에 예외가 발생한다.")
    @ParameterizedTest
    @ValueSource(ints = {0, 10})
    void valueOfExceptionTest(int number) {
        assertThatThrownBy(() -> BaseBallNumberFactory.valueOf(number))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("1부터 9까지의 숫자만 입력 가능합니다.");
    }
}

Generator

  • 숫자야구게임은 컴퓨터가 1~9의 3개의 숫자를 랜덤으로 중복되지 않는 값으로 3개를 뽑아 그걸 사용자가 맞추게 됩니다.
  • 여기서 컴퓨터가 1~9의 3개의 숫자를 랜덤으로 중복되지 않는 값을 만들어야 합니다.
  • 이에 관련된 자세한 내용은 아래 해당 글에서 자세히 살펴볼 수 있습니다.
  • 위 글에서 실험한 대로, 우리는 Collections.shuffle을 사용해 List를 랜덤으로 하고, 3개의 값을 추출할겁니다.
    • 이유는 간단한 동작 시간의 벤치마킹을 하였을때 해당 방법이 더 빨랐습니다.
  • 아울러 랜덤이라는 정책은 실제 제어할 수 없는 영역입니다. 실제 응답이 어떤 값이 나올지 전혀 알수가 없죠. 그러기 떄문에 테스트 코드를 작성할때 제어할 수 없는 값때문에 실제 제어할 수 없는 값을 사용하는 부분들을 모두 테스트를 할수가 없습니다.
  • 그런 경우엔 Inteface를 도입해 갈아끼우기 쉽게 처리하는것이 좋습니다. 이제 전체 코드를 살펴보죠.
package baseball.generator;

import baseball.domain.Number;

import java.util.List;

public interface BaseBallNumberGenerator {
    List<Number> generate();
}

package baseball.generator;

import baseball.domain.BaseBallNumbers;
import baseball.domain.Number;
import baseball.factory.BaseBallNumberFactory;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class BaseBallNumberShuffleGenerator implements BaseBallNumberGenerator {

    @Override
    public List<Number> generate() {
        List<Number> numbers = BaseBallNumberFactory.getNumbers();
        List<Number> pickNumbers = new ArrayList<>(numbers);

        Collections.shuffle(pickNumbers);

        return pickNumbers.subList(0, BaseBallNumbers.TOTAL_COUNT);
    }
}

  • Factory에서 불변 List를 뺴서, 깊은 복사를 한 뒤에 Shuffle을 한 뒤에 subList를 통해 3개의 값을 추출합니다.
  • 이렇게 하면 랜덤의 값 3개가 생성되는걸 알수 있습니다.
  • 마지막으로 테스트 코드를 추가해 안정성을 체크합니다.
import baseball.domain.Number;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

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

class BaseBallNumberShuffleGeneratorTest {
    @DisplayName("중복되지 않는 3개의 숫자를 추출한다.")
    @Test
    void generateTest() {
        BaseBallNumberShuffleGenerator baseBallNumberShuffleGenerator = new BaseBallNumberShuffleGenerator();

        List<Number> baseBallNumbers = baseBallNumberShuffleGenerator.generate();

        assertThat(baseBallNumberShuffleGenerator.generate()).hasSize(3);
        assertThat(baseBallNumbers).doesNotHaveDuplicates();
    }
}

  • 일단 무조건 3개의 값이 나와야 하는 건 필수 불가결 합니다.
  • 아울러 중복되지 않은 값이 나와야 하는데, 그걸 체크하기 위해 AssetJ에서 제공해주는 doesNotHaveDuplicates 해당 메서드를 사용해 체크합니다.
  • 랜덤값으로 나오기 때문에 우리가 제어할 수 없는 영역들입니다. 따라서 실제 내부의 값까지는 체크할수가 없습니다. 일단 해당 부분에서 체크할 수 있는 영역까지만 하는게 가장 베스트라고 생각합니다.
  • cf) 실제 도메인에 있는 상수들을 사용하였습니다. 그 이유는 상수는 재활용성을 위해서, 즉, 정책이 한번에 변경되면 해당 정책의 영역들을 한번에 변경하는 것이 효과적인 방법이라고 생각합니다. 따라서 도메인에 상수를 넣어두고, 해당 도메인의 정책으로 정의내려두고 그걸 public으로 하여 다른 곳에서 사용하게 하였습니다.

Repository

  • 일단 간단하게 각 게임의 컴퓨터를 저장하는 Repository를 구현해봅니다.
  • 이유는 Controller에서 게임을 진행하면, Computer의 숫자들이 생성되며, 실제 Computer를 객체로 보고 해당 컴퓨터를 저장하는 로직을 작성하였습니다.
  • Consloe 애플리케이션이며, 학습의 목적이니 쉽게 Collections의 Map을 사용해 진행해봅니다.
  • 여기서도 Interface를 사용합니다. 이유는 차후에 변경하기 쉽게 하기 위함입니다.
import baseball.domain.Computer;

import java.util.Optional;

public interface ComputerRepository {
    Optional<Computer> findById(Integer id);

    Integer insert(Computer computer);
}

package baseball.repository;

import baseball.domain.Computer;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class ComputerRepositoryImpl implements ComputerRepository {
    private static final Map<Integer, Computer> COMPUTERS = new HashMap<>();
    private static Integer id = 0;

    @Override
    public Optional<Computer> findById(Integer id) {
        return Optional.of(COMPUTERS.get(id));
    }

    @Override
    public Integer insert(Computer computer) {
        id++;
        COMPUTERS.put(id, computer);

        return id;
    }
}

  • 실제 두개의 메서드만 구현합니다. insert는 해당 Map에 Computer를 저장하며, findById는 Id를 활용해 찾게 합니다.
import baseball.domain.Computer;
import baseball.domain.Number;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Arrays;

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

class ComputerRepositoryImplTest {
    private static final ComputerRepository computerRepository = new ComputerRepositoryImpl();

    @DisplayName("해당 컴퓨터를 조회할 수 있다.")
    @Test
    void findByIdTest() {
        Computer computer = new Computer(Arrays.asList(new Number(1), new Number(2), new Number(3)));

        int computerId = computerRepository.insert(computer);

        Computer findComputer = computerRepository.findById(computerId).get();

        assertThat(findComputer).isEqualTo(computer);
    }

    @DisplayName("컴퓨터를 저장할수 있다.")
    @Test
    void insertTest() {
        Computer computer = new Computer(Arrays.asList(new Number(1), new Number(2), new Number(3)));

        Integer computerId = computerRepository.insert(computer);

        assertThat(computerId).isNotNull();
    }
}

  • 간단하게 검증하는 코드를 작성하였습니다.

Controller

  • 이제 실제 게임의 전체적인 흐름을 진행하는 로직을 작성한 영역을 확인해보겠습니다.
import baseball.domain.BaseBallNumbers;
import baseball.domain.Computer;
import baseball.domain.Number;
import baseball.dto.CheckBallResponse;
import baseball.dto.CheckBallsRequest;
import baseball.factory.BaseBallNumberFactory;
import baseball.generator.BaseBallNumberGenerator;
import baseball.repository.ComputerRepository;

public class BaseBallGameController {
    private static final int ALL_STRIKE_CONT = 3;

    private final ComputerRepository computerRepository;

    public BaseBallGameController(ComputerRepository computerRepository) {
        this.computerRepository = computerRepository;
    }

    public int computerStart(BaseBallNumberGenerator baseBallNumberGenerator) {
        Computer computer = new Computer(baseBallNumberGenerator.generate());

        return computerRepository.insert(computer);
    }

    public CheckBallResponse checkBalls(CheckBallsRequest checkBallsRequest) {
        Computer computer = computerRepository.findById(checkBallsRequest.computerId())
                .orElseThrow(() -> new IllegalArgumentException("컴퓨터가 존재하지 않습니다."));

        BaseBallNumbers playerNumbers = getPlayerNumbers(checkBallsRequest);

        if (computer.isSameNumbers(playerNumbers)) {
            return new CheckBallResponse(ALL_STRIKE_CONT, 0, false, true);
        }

        int ballNumber = 0;
        int strikeNumber = 0;

        for (Number number : playerNumbers.getNumbers()) {
            if (computer.isStrike(number, playerNumbers.indexOf(number))) {
                strikeNumber++;
            } else if (computer.isBall(number)) {
                ballNumber++;
            }
        }

        return new CheckBallResponse(strikeNumber, ballNumber, isNotting(strikeNumber, ballNumber), false);
    }

    private BaseBallNumbers getPlayerNumbers(CheckBallsRequest checkBallsRequest) {
        return new BaseBallNumbers(
                checkBallsRequest.userNumbers()
                        .stream()
                        .map(BaseBallNumberFactory::valueOf)
                        .toList()
        );
    }

    private boolean isNotting(int strikeCount, int ballCount) {
        return strikeCount == 0 && ballCount == 0;
    }
}

  • 일단 computerStart 메서드를 살펴보겠습니다.
    • 실제 Computer를 생성하고, 이때 Generator를 인자로 받아서, 생성 법을 선택합니다.
    • 이걸 토대로 Computer를 생성하며, insert까지 하고 Id를 응답합니다.
  • checkBalls에 대해서 살펴보겠습니다.
    • 사용자가 입력한 Balls의 숫자 3개를 실제 컴퓨터가 뽑은 값과 같은지를 체크합니다.
    • 정책에 따라 Ball과 Strike의 갯수를 셉니다.
    • 실제 여기서 도메인들간의 소통을 여기서 하게 됩니다.
    • 여기서 isSameNumbers를 통해 3번의 For문을 돌리는것을 Eqauls하나로 검증할수 잇도록 좀더 빠르게 처리할 수 있도록 하였습니다.
package baseball.controller;

import baseball.domain.Number;
import baseball.dto.CheckBallResponse;
import baseball.dto.CheckBallsRequest;
import baseball.repository.ComputerRepository;
import baseball.repository.ComputerRepositoryImpl;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;
import static org.junit.jupiter.api.Assertions.assertAll;

class BaseBallGameControllerTest {

    private static final ComputerRepository computerRepository = new ComputerRepositoryImpl();
    private static final BaseBallGameController baseBallGameController = new BaseBallGameController(computerRepository);

    @DisplayName("컴퓨터를 생성한다.")
    @Test
    void computerStartTest() {
        int computerId = baseBallGameController.computerStart(() -> List.of(
                new Number(1), new Number(2), new Number(3)));

        assertThat(computerRepository.findById(computerId)).isPresent();
    }

    @DisplayName("1스트라이크 1볼을 확인한다.")
    @Test
    void checkBallTest() {
        int computerId = baseBallGameController.computerStart(() -> List.of(
                new Number(1), new Number(2), new Number(3)));

        CheckBallResponse checkBallDto = baseBallGameController.checkBalls(new CheckBallsRequest(List.of(1, 3, 5), computerId));

        assertThat(checkBallDto.strikeCont()).isEqualTo(1);
        assertThat(checkBallDto.ballCont()).isEqualTo(1);
        assertThat(checkBallDto.isNotting()).isFalse();
        assertThat(checkBallDto.isSuccess()).isFalse();
    }

    @DisplayName("낫싱을 확인한다.")
    @Test
    void checkBallTest2() {
        int computerId = baseBallGameController.computerStart(() -> List.of(
                new Number(1), new Number(2), new Number(3)));

        CheckBallResponse checkBallDto = baseBallGameController.checkBalls(new CheckBallsRequest(List.of(4, 5, 6), computerId));

        assertAll(() -> assertThat(checkBallDto.strikeCont()).isZero(),
                () -> assertThat(checkBallDto.ballCont()).isZero(),
                () -> assertThat(checkBallDto.isNotting()).isTrue(),
                () -> assertThat(checkBallDto.isSuccess()).isFalse()
        );
    }

    @DisplayName("3스트라이크를 확인한다.")
    @Test
    void checkBallTest3() {
        int computerId = baseBallGameController.computerStart(() -> List.of(
                new Number(1), new Number(2), new Number(3)));

        CheckBallResponse checkBallDto = baseBallGameController.checkBalls(new CheckBallsRequest(List.of(1, 2, 3), computerId));

        assertAll(() -> assertThat(checkBallDto.strikeCont()).isEqualTo(3),
                () -> assertThat(checkBallDto.ballCont()).isZero(),
                () -> assertThat(checkBallDto.isNotting()).isFalse(),
                () -> assertThat(checkBallDto.isSuccess()).isTrue()
        );
    }
}

  • 마지막으로 테스트 코드를 작성하여 안정성과 실제 로직을 확인합니다.

DTO

  • 실제 View와 통신하기 위해서 DTO를 만들어 처리합니다.
  • Jaa17을 사용하기 떄문에 record를 사용해서 구현합니다.
import java.util.List;

public record CheckBallsRequest(
        List<Integer> userNumbers,
        int computerId
) {
}

public record CheckBallResponse(
        int strikeCont,
        int ballCont,
        boolean isNotting,
        boolean isSuccess
) {
}

결론

  • 실제 비지니스 로직인 스트라이크와 볼의 갯수를 세는거까지 완료하였습니다.
  • 다음편은 마지막으로 실제 View와 연동하여 실제 동작하는 부분까지 해볼 예정입니다.
728x90
728x90