<education>/2024 우아한테크코스 프리코스

[우테코 프리코스 해체 분석] 해체 / 1주차 / 숫자야구게임 (#우아한테크코스 #2024)

Rizingblare 2023. 12. 13. 16:32

0. Intro

2024 우테코 프리코스 1주차

대망의 첫 번째 과제는 숫자 야구 게임이다.

 

숫자 야구 게임은 프로그래밍 기본기를 익힐 때 흔히 활용되는 예제이므로

학교 강의를 들으며 몇 차례 구현해 본 적은 있었다.

 

하지만 보편적인 숫자 야구 게임과 다르게

특별히 신경써야 하는 몇 가지 요구사항이 더 있다.

 

기능 요구 사항, 프로그래밍 요구 사항, 과제 진행 요구 사항

크게 세 가지로 나누어지는데

 

⚡  기능 요구사항
  • 게임을 종료한 후 게임을 다시 시작하거나 완전히 종료할 수 있다.
  • 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시킨 후 애플리케이션은 종료되어야 한다.
⚡  프로그래밍 요구사항
  • camp.nextstep.edu.missionutils에서 제공하는 RandomsConsole API를 사용하여 구현해야 한다.
    • Random 값 추출은 camp.nextstep.edu.missionutils.RandomspickNumberInRange()를 활용한다.
    • 사용자가 입력하는 값은 camp.nextstep.edu.missionutils.ConsolereadLine()을 활용한다.
⚡  과제 진행 요구사항
  • 기능을 구현하기 전 docs/README.md에 구현할 기능 목록을 정리해 추가한다.

 

1. 기능 목록

구현에 앞서, 기능 목록을 먼저 작성해야 한다.

 

처음에는 기능 목록이 뭔가 싶어서 의아했다.

README 파일로 구현할 기능 목록을 먼저 작성하라는데

예시나 딱히 정해진 템플릿도 없어서 어떤 식으로 작성해야 좋을지 난해했다.

 

처음에는 유저에게 숫자를 입력받는 기능,

비교 결과를 출력하는 기능과 같이

숫자 야구 게임에 필요한 로직을 추상적으로 나열해보려고 했다.

 

그러다 문득 예전에 클린 코드 & TDD 특강에서 들었던 내용을 떠올리며

'게임은 가상의 상대를 갖는다', '가상의 상대는 세 자리 숫자인 난수를 발생시킨다',
'유저에게 예측한 숫자를 입력받는다', '정답이면 게임이 종료된다' 처럼

객체들의 구체적인 행동의 측면에서 다시 생각해보았다.

 

 

그렇게 다시 나열한 다음 역할이 비슷한 동작들끼리 묶었더니

구현에 필요한 객체가 보다 명확하게 잘 드러나는 것 같다는 생각을 했다.

 

 

2. GameConsole

출력 예시를 뜯어보니 한 가지 객체가 떠오른다.

출력 화면에 대한 예시는 다음과 같다.

 

실행 결과 예시

숫자 야구 게임을 시작합니다.
숫자를 입력해주세요 : 123
1볼 1스트라이크
숫자를 입력해주세요 : 145
1볼
숫자를 입력해주세요 : 671
2볼
숫자를 입력해주세요 : 216
1스트라이크
숫자를 입력해주세요 : 713
3스트라이크
3개의 숫자를 모두 맞히셨습니다! 게임 종료
게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.
1
숫자를 입력해주세요 : 123
1볼
...

 

기능 요구사항에도 명시되어 있듯이 숫자 야구 게임의 한 사이클이 끝나면

게임을 새로 시작하거나 종료할 수 있는 프로세스가 실행되어야 한다.

 

단순히 하나의 메인 클래스에 모든 역할과 책임을 그냥 때려박을 수도 있겠지만

이번 기회에 최대한 객체 지향적인 시도를 해보기로 마음먹었기 때문에

 

숫자 야구 게임을 진행하는 역할게임을 반복 실행시키는 역할

서로 다른 객체의 협력 관계로 표현하는 게 좋겠다는 생각이 들었다.

 

그래서 실행할 게임과 지속 여부에 대한 상태를 관리하는 GameConsole

숫자 야구 게임에 대한 비즈니스 로직을 처리하는 numberBaseballGame로 분리하였다.

 

package baseball.console;

import baseball.console.game.GameList;
import baseball.console.game.numberbaseball.NumberBaseballGame;
import baseball.console.config.ConsoleMessage;
import baseball.console.config.ConsoleStatus;
import camp.nextstep.edu.missionutils.Console;

public class GameConsole {
    ConsoleStatus status;
    GameList target;
    NumberBaseballGame numberBaseballGame;

    public GameConsole() {
        init();
    }

    private void init() {
        status = ConsoleStatus.CONTINUE;
        target = GameList.NUMBER_BASEBALL;
    }

    public void start() {
        while (status == ConsoleStatus.CONTINUE) {
            if (target == GameList.NUMBER_BASEBALL) {
                launchNumberBaseball();
            }
            confirmStatus();
        }
    }

    private void launchNumberBaseball() {
        printConsoleMessage(ConsoleMessage.NUMBER_BASEBALL_GAME_START);
        numberBaseballGame = new NumberBaseballGame();
        numberBaseballGame.start();
    }

    private void confirmStatus() {
        printConsoleMessage(ConsoleMessage.CONTINUE_OR_EXIT);
        int choice = Integer.parseInt(Console.readLine());
        status = ConsoleStatus.values()[choice];
    }

    private void printConsoleMessage(String message) {
        System.out.println(message);
    }

}

 

GameConsole의 생성자가 호출되면 

상태는 Continue(계속)로, 타겟은 NumberBaseball(숫자 야구 게임)으로 설정하고

 

GameConsole가 시작되면 타겟으로 설정된 게임을 실행(launchNumberbaseBall)하고

게임이 종료되면 상태를 재확인(confirmStatus)받고

상태가 Continue일 동안 위 과정을 반복하는

마치 닌텐도 게임기에서 설치된 게임을 로드하고 실행하는 과정을 떠올리며 구현해보았다. 

 

사실 지금처럼 if문으로 실행시킬 게임마다 분기하는 방식이 아니라

원래는 초기화 과정에서 미리 실행될 게임들을 전부 생성시켜놓고

타겟으로 설정된 게임을 launch라는 메서드 하나로 실행하는 방법을 구상했었지만

 

그럴려면 게임(Game)에 대한 추상화 과정을 한 단계 더 거쳐야할 것 같은데

아직 자바의 철저한 객체 지향 패러다임(OOP)에 익숙하지 않아서 그런지

시간 안에 해결하지 못할 것 같아서 요 정도 수준으로 마무리했다.

 

지금 생각해보면 다형성(Polymorphism)에 대한 이해가 부족해서 그런 것 같기도 하다.

다음 주에는 이 부분을 더욱 보강해봐야겠다는 생각이 들었다.

 

 

3. Random

숫자 야구 게임에는 랜덤값이 필요하다.

플레이어는 매번 컴퓨터가 생성하는 숫자를 맞춰야되기 때문이다.

 

여느 언어들처럼 자바도 random 관련 라이브러리를 지원한다.

java.lang.Math 클래스의 정적 메소드인 random()를 사용하는 방법과

java.util.Random 클래스를 사용하는 두 가지 방식이 있다.

 

하지만 이곳에서는 해당 라이브러리를 사용할 수 없다.

프로그래밍 요구 사항을 보면 Random 값 추출은

camp.nextstep.edu.missionutils.Randoms pickNumberInRange()를 활용하라고 한다.

 

대체 이게 무엇인고 하니

package camp.nextstep.edu.missionutils;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;

public class Randoms {
    private static final Random defaultRandom = ThreadLocalRandom.current();

    ...

    public static int pickNumberInRange(final int startInclusive, final int endInclusive) {
        validateRange(startInclusive, endInclusive);
        return startInclusive + defaultRandom.nextInt(endInclusive - startInclusive + 1);
    }

    ...

    private static void validateRange(final int startInclusive, final int endInclusive) {
        if (startInclusive > endInclusive) {
            throw new IllegalArgumentException("startInclusive cannot be greater than endInclusive.");
        }
        if (endInclusive == Integer.MAX_VALUE) {
            throw new IllegalArgumentException("endInclusive cannot be greater than Integer.MAX_VALUE.");
        }
        if (endInclusive - startInclusive >= Integer.MAX_VALUE) {
            throw new IllegalArgumentException("the input range is too large.");
        }
    }

    ...
}

 

프리코스 레포지토리를 클론하면 wowacourse-projects:mission-utils:1.10 의존성이 추가되어있다

우테코에서 심혈을 기울여 만든 교육용 라이브러리인가 보다.

 

이것도 결국은 java.util 라이브러리를 활용해서 만들긴 했지만

기본적으로 ThreadLocalRandom 클래스를 기반으로 동작한다.

 

위의 메소드 말고도 여러 가지 다른 메서드들이 몇 가지 더 있는데

처음에는 대충 보고 문제에서 제시한 pickNumberInRange()말고

pickUniqueNumbersInRange() 메서드로 구현해서 코드를 짰더니

테스트 코드를 통과하지 못해서 추적해낸다고 상당히 애를 먹었다.

 

package baseball;

import camp.nextstep.edu.missionutils.test.NsTest;
import org.junit.jupiter.api.Test;

import static camp.nextstep.edu.missionutils.test.Assertions.assertRandomNumberInRangeTest;
import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ApplicationTest extends NsTest {
    @Test
    void 게임종료_후_재시작() {
        assertRandomNumberInRangeTest(
                () -> {
                    run("246", "135", "1", "597", "589", "2");
                    assertThat(output()).contains("낫싱", "3스트라이크", "1볼 1스트라이크", "3스트라이크", "게임 종료");
                },
                1, 3, 5, 5, 8, 9
        );
    }

    @Test
    void 예외_테스트() {
        assertSimpleTest(() ->
                assertThatThrownBy(() -> runException("1234"))
                        .isInstanceOf(IllegalArgumentException.class)
        );
    }

    @Override
    public void runMain() {
        Application.main(new String[]{});
    }
}

 

테스트 코드에도 우테코 커스텀 메서드들이 잔뜩 끼어있다.

아직 테스트 관련 라이브러리와 테스트 코드 작성에 익숙하지 않았기 때문에 낯선 메서드와 클래스들이 굉장히 두려웠다.

 

package camp.nextstep.edu.missionutils.test;

import camp.nextstep.edu.missionutils.Randoms;
import org.junit.jupiter.api.function.Executable;
import org.mockito.MockedStatic;

import java.time.Duration;
import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.MockedStatic.Verification;
import static org.mockito.Mockito.mockStatic;

public class Assertions {
    private static final Duration SIMPLE_TEST_TIMEOUT = Duration.ofSeconds(1L);
    private static final Duration RANDOM_TEST_TIMEOUT = Duration.ofSeconds(10L);

    private Assertions() {
    }

    public static void assertSimpleTest(final Executable executable) {
        assertTimeoutPreemptively(SIMPLE_TEST_TIMEOUT, executable);
    }

    ...
    
    public static void assertRandomNumberInRangeTest(
        final Executable executable,
        final Integer value,
        final Integer... values
    ) {
        assertRandomTest(
            () -> Randoms.pickNumberInRange(anyInt(), anyInt()),
            executable,
            value,
            values
        );
    }

   ...
   
   private static <T> void assertRandomTest(
        final Verification verification,
        final Executable executable,
        final T value,
        final T... values
    ) {
        assertTimeoutPreemptively(RANDOM_TEST_TIMEOUT, () -> {
            try (final MockedStatic<Randoms> mock = mockStatic(Randoms.class)) {
                mock.when(verification).thenReturn(value, Arrays.stream(values).toArray());
                executable.execute();
            }
        });
    }
}

테스트 코드의 메서드들을 더 타고 들어가보면

위와 같이 굉장히 낯선 메서드들이 나를 반겨주는 모습을 확인할 수가 있다.


  List<Integer> computer = new ArrayList<>();
  while (computer.size() < 3) {
      int randomNumber = Randoms.pickNumberInRange(1, 9);
      if (!computer.contains(randomNumber)) {
          computer.add(randomNumber);
      }
  }

정말 1주차부터 이대로 고꾸라지는 줄 알고 스스로의 실력에 굉장히 절망할 뻔했다.

 

 

알고보니까 사용 예시에 제시된 방식 그대로 활용해서 코드를 짜야했다.

이것까지 평가의 과정이었던 것인가?!? 문제에 충실하라..? 쳇

 

 

아무튼 해당 이슈 때문에 시간을 굉장히 허비했었다.