Backend/Java

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

Seyun(Marco) 2024. 4. 24. 22:04
728x90

서론

  • 지금까지 우리는 요구사항 분석과 테크스펙 작성을 통해 전체적인 흐름을 살펴보았으며, 도메인, VO, 일급컬렉션 등을 작성하여 객체지향설계에서 가장 중요한 객체를 직접 구현해보았습니다.
  • 그 과정에서 우리는 테스트 코드를 추가하여 안정적으로 우리가 설계한 대로 동작하는지, 또한 요구사항 정책에 따라 정상적으로 동작하는지를 확인하보았습니다.
  • 이번 Step2의 목표는 기록 조회가 목표입니다.
  • 다른 것 보단, 기록을 조회를 중점으로 설명합니다.

getGames(전체 기록 조회) / getGame(단건 기록 조회)

  • 사용자 스토리상 플로우를 생각한다면, 아래와 같습니다
    • 기록을 전체 조회해서 각 기록에 대해서 간단한 정보를 확인합니다.
    [1] / 시작시간: / 종료시간: / 횟수: 
    [2] / 시작시간: / 종료시간: / 횟수:
    
    • 맨 앞에 있는 숫자를 통해 기존에 게임 했던 정보를 조회할 수 있습니다.
    1
    
    숫자를 입력해주세요 : 123
    1볼 1스트라이크
    숫자를 입력해주세요: 145
    1볼
    숫자를 입력해주세요: 671
    2볼
    숫자를 입력해주세요: 216
    1스트라이크
    숫자를 입력해주세요: 713
    3스트라이크
    
    3개의 숫자를 모두 맞히셨습니다.
    -------게임 종료-------
    -------기록 종료-------
    
  • 이렇게 되면 기존에 ComputerRepository를 제거하고, GameRepository를 만들어서 각 Game의 정보를 저장해야 합니다.
package baseball.repository;

import baseball.domain.Game;

import java.util.List;
import java.util.Optional;

public interface GameRepository {
    int insert(Game game);

    Optional<Game> findById(int gameId);

    List<Game> findAll();

    void clear();

}

import baseball.domain.Game;

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

public class GameRepositoryImpl implements GameRepository {
    private static final Map<Integer, Game> GAME_RECORDS = new HashMap<>();
    private static int id = 1;

    @Override
    public int insert(final Game game) {
        game.setId(id);
        GAME_RECORDS.put(id, game);
        id++;

        return game.getId();
    }

    @Override
    public Optional<Game> findById(final int gameId) {
        return Optional.ofNullable(GAME_RECORDS.get(gameId));
    }

    @Override
    public List<Game> findAll() {
        return GAME_RECORDS.values()
                .stream()
                .toList();
    }

    @Override
    public void clear() {
        GAME_RECORDS.clear();
    }
}

  • 기존의 ComputerRepository와 동일하게 Interface를 만들어 확장성을 가지게 합니다.
  • 아울러 이제 저장, ID로 조회, 전체 기록 조회 총 3개를 위와 같이 생성합니다.
  • 코드단에서는 큰 설명은 없어도 될듯해서 넘어가도록 하겠습니다.
  • 그럼 이제 실제 Controller 로직을 살펴보겠습니다.
public List<GameRecordsResponse> getGames() {
    final List<Game> games = gameRepository.findAll();
    return games.stream()
            .map(this::convertGameRecordsResponse)
            .toList();
}

public GameRecordResponse getGame(final int id) {
    final Game game = gameRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("게임 기록이 존재하지 않습니다."));

    return convertGameRecordResponse(game);
}

private GameRecordsResponse convertGameRecordsResponse(final Game game) {
    return new GameRecordsResponse(game.getId(),
            game.getStartAt(),
            game.getEndAt(),
            game.getPlayerTimes());
}

private GameRecordResponse convertGameRecordResponse(final Game game) {
    final List<PlayerRecordResponse> playerRecordResponses = game.getPlayerNumbers()
            .stream()
            .map(playerRecord -> new PlayerRecordResponse(playerRecord.getValueNumbers(),
                    playerRecord.getStrikeCount(),
                    playerRecord.getBallCount(),
                    playerRecord.isNotting(),
                    playerRecord.isSuccess()))
            .toList();
    return new GameRecordResponse(game.getId(), playerRecordResponses);
}
  • 실제 Controller에선 특별한 로직은 없고, Repository에서 데이터를 가져와 내려주는 역할만 합니다.
  • 아울러 DTO의 Response를 만들어서 데이터를 내려줍니다.
import java.time.LocalDateTime;

public record GameRecordsResponse(
        int id,
        LocalDateTime startAt,
        LocalDateTime endAt,
        int playerTimes
) {
}
  • 전체 목록의 데이터를 내려주는 DTO입니다.
import java.util.List;

public record GameRecordResponse(
        int id,
        List<PlayerRecordResponse> playerRecordResponses
) {
}

import java.util.List;

public record PlayerRecordResponse(
        List<Integer> numbers,
        int strikeCount,
        int ballCount,
        boolean isNotting,
        boolean isSuccess
) {
}

  • 각 게임의 정보를 내려주는 DTO입니다.
  • 마지막으로 실제 Repository의 테스트 코드와 Controller의 테스트 코드를 살펴보겠습니다.
import baseball.domain.Computer;
import baseball.domain.Game;
import baseball.domain.Number;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Arrays;

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

class GameRepositoryImplTest {
    private static final GameRepository gameRepository = new GameRepositoryImpl();

    @AfterEach
    void tearDown() {
        gameRepository.clear();
    }

    @DisplayName("해당 게임을 조회할 수 있다.")
    @Test
    void findByIdTest() {
        Computer computer = new Computer(Arrays.asList(new Number(1), new Number(2), new Number(3)));
        Game game = new Game(computer);

        int gameId = gameRepository.insert(game);

        Game findGame = gameRepository.findById(gameId).get();

        assertThat(findGame).isEqualTo(game);
    }

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

    @DisplayName("모든 게임을 조회할 수 있다.")
    @Test
    void findAllTest() {
        Computer computer = new Computer(Arrays.asList(new Number(1), new Number(2), new Number(3)));
        Game game = new Game(computer);
        gameRepository.insert(game);

        assertThat(gameRepository.findAll()).hasSize(1);
        assertThat(gameRepository.findAll()).contains(game);
    }
}

  • Repository의 테스트 코드에서 가장 중요한건 tearDown의 AfterEach입니다.
    Independent
    단위 테스트는 그 자체만으로도 충분히 실행되어야 함.
    즉, 독립적이고 테스트들 끼리 의존하고나 영향을 주면 안됨
    대표적인 독립적이지 않은 케이스는 아래와 같음
    A라는 테스트에서 객체의 상태를 변경함.
    B라는 테스트에서 A라는 테스트의 객체 상태를 변경을 모르고, 테스트 케이스를 작성함으로 테스트가 깨짐.
    
    • 독립적으로 실행되어야 합니다. 그러나 Repository를 매번 만드는것도 방법이겟지만, 실제로 가장 확실한건 AfterEach에 각 테스트 실행될때마다 Repository로 사용하고 있는 InMemory Colleciton의 데이터를 모두 제거하는 것입니다.
    • 이렇게 하면 차후 테스트 코드가 추가되어도 다른 테스트들이 의존이 생기지 않습니다.
  • Controller 테스트는 조회가 잘 되는지 보면 될거 같습니다. Controller 테스트에서도 AfterEach를 통해 clear를 진행합니다.
@AfterEach
void tearDown() {
    gameRepository.clear();
}

@DisplayName("전체 게임의 기록들을 조회한다.")
@Test
void getGamesTest() {
    final int gameId = baseBallGameController.gameStart(() -> List.of(
            new Number(1), new Number(2), new Number(3)));

    assertThat(baseBallGameController.getGames()).hasSize(1);
    assertThat(baseBallGameController.getGames().get(0).id()).isEqualTo(gameId);
}

@DisplayName("단건 게임을 조회한다.")
@Test
void getGameTest() {
    final int gameId = baseBallGameController.gameStart(() -> List.of(
            new Number(1), new Number(2), new Number(3)));

    baseBallGameController.checkBalls(new CheckBallsRequest(List.of(1, 2, 3), gameId));

    assertThat(baseBallGameController.getGame(gameId).playerRecordResponses()).isNotEmpty();
}

다른 Method Refactoring

  • Game이 시작되면 Game이 저장되어야 하고, Game이 진행되는 중 Ball들을 체크할때 Game을 조회해 PlayerRecord를 저장해줘야 합니다.
  • 위와 같은 정책으로 기존에 있는 Method를 리팩토링 해줍니다.
public int gameStart(final BaseBallNumberGenerator baseBallNumberGenerator) {
    final Computer computer = new Computer(baseBallNumberGenerator.generate());
    final Game game = new Game(computer);

    return gameRepository.insert(game);
}

public CheckBallResponse checkBalls(final CheckBallsRequest checkBallsRequest) {
    final Game game = gameRepository.findById(checkBallsRequest.gameId())
            .orElseThrow(() -> new IllegalArgumentException("게임이 존재하지 않습니다."));

    final BaseBallNumbers playerNumbers = getPlayerNumbers(checkBallsRequest);
    final Computer computer = game.getComputer();
    final Count strikeCount = computer.getStrikeCount(playerNumbers);
    final Count ballCount = computer.getBallCount(playerNumbers, strikeCount);

    final PlayerRecord playerRecord = new PlayerRecord(playerNumbers, strikeCount, ballCount);
    game.addPlayerRecord(playerRecord);

    return new CheckBallResponse(playerRecord.getStrikeCount(), playerRecord.getBallCount(), playerRecord.isNotting(), playerRecord.isSuccess());
}

private BaseBallNumbers getPlayerNumbers(final CheckBallsRequest checkBallsRequest) {
    return new BaseBallNumbers(
            checkBallsRequest.userNumbers()
                    .stream()
                    .map(BaseBallNumberFactory::valueOf)
                    .toList()
    );
}
  • 테스트 코드까지 추가합니다.
import baseball.domain.Game;
import baseball.domain.Number;
import baseball.dto.CheckBallResponse;
import baseball.dto.CheckBallsRequest;
import baseball.repository.GameRepository;
import baseball.repository.GameRepositoryImpl;
import org.junit.jupiter.api.AfterEach;
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 GameRepository gameRepository = new GameRepositoryImpl();
    private static final BaseBallGameController baseBallGameController = new BaseBallGameController(gameRepository);

    @AfterEach
    void tearDown() {
        gameRepository.clear();
    }

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

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

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

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

        assertThat(checkBallDto.strikeCount()).isEqualTo(1);
        assertThat(checkBallDto.ballCount()).isEqualTo(1);
        assertThat(checkBallDto.isNotting()).isFalse();
        assertThat(checkBallDto.isSuccess()).isFalse();
    }

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

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

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

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

        final CheckBallResponse checkBallDto = baseBallGameController.checkBalls(new CheckBallsRequest(List.of(1, 2, 3), gameId));
        final Game game = gameRepository.findById(gameId).get();

        assertAll(() -> assertThat(checkBallDto.strikeCount()).isEqualTo(3),
                () -> assertThat(checkBallDto.ballCount()).isZero(),
                () -> assertThat(checkBallDto.isNotting()).isFalse(),
                () -> assertThat(checkBallDto.isSuccess()).isTrue(),
                () -> assertThat(game.getEndAt()).isNotNull()
        );
    }
}

  • 기존에 테스트 코드와 거의 비슷합니다. 테스트 코드가 있음으로써 기본적인 정책인 게임을 시작하고, 플레이어의 스트라이크, 볼 체크를 합니다.
  • 이와 같이 하면서 기존에 정책을 검증하면서 리팩토링을 진행하였습니다.

결론

  • 이번엔 기록을 조회하는 로직을 작성해보았습니다.
  • Repository에 대해서 추가하고, 전체 조회, 단건 조회를 공부해보았습니다.
  • 마지막으로 View와 연동해 실제 Application이 동작하는걸 다루도록 하겠습니다.
728x90
728x90