[우테코-프리코스] 2주차 회고

dooboocookie·2022년 11월 10일
3

우테코-프리코스

목록 보기
2/4
post-thumbnail

2주차 과제

  • 2주차 과제는 야구게임이었다.
    1. 컴퓨터가 임의 3개의 숫자를 고르면 사용자가 그 숫자를 추측하는 게임
    2. 자리수와 숫자가 같으면 스트라이크, 자리수가 틀리고 숫자가 같으면 볼, 아무 숫자도 못마추면 낫싱을 출력
    3. 3스트라이크가 나오면 게임을 종료하고, 게임을 재시작 여부를 확인하는 방식
  • 추가된 요구사항
    1. 들여쓰기는 2 level까지만 허용
    2. 삼항 연산자를 쓰지 말 것
    3. 메소드가 한가지 일만 하도록 할 것
    4. 기능 목록에 따라 Junit5로 테스트 코드 작성

클래스 구조

자세한 내용은 Pull Request(링크) 에서 참고 부탁드립니다.

Number 클래스

  • 1~9 까지의 게임에 사용되는 int를 관리하는 클래스
  • 생성할 때, 1~9 범위에 대한 검증 처리
  • equals()를 재정의해서 Number의 객체끼리 비교하도록 하였다.
    • 근데 이과정에서 getter()를 만들었는데, 아주 좋은 방법은 아니였던 것 같다. 다른 방법이 있는지 조금 더 고민해봐야 할듯하다.
  • 이 Number 클래스는 2가지 상황에 따라 다르게 생성된다.
    1. 특정 숫자를 지정하여 생성
    2. 랜덤 숫자로 생성
      • 이 2가지 케이스는 정적 메소드로 구현하였다.
public class Number {

    private int number;
	//생성자 private
    private Number(int number) {
        validateSize(number);
        this.number = number;
    }
	//1. 지정한 숫자 생성
    public static Number createNumber(int number) {
        return new Number(number);
    }
	//2. 랜덤 숫자 생성
    public static Number createRandomNumber() {
        int randomNumber = Randoms.pickNumberInRange(RANDOM_NUMBER_MIN, RANDOM_NUMBER_MAX);
        return new Number(randomNumber);
    }
	//검증
    private void validateSize(int number) {
        if ((number < 1) || (number > 9)) {
            throw new IllegalArgumentException(Errors.NUMBER_RANGE.getValue());
        }
    }
	//getter
    public int getNumber() {
        return this.number;
    }
	//equals 재정의
    @Override
    public boolean equals(Object obj) {
        return this.number == ((Number)obj).getNumber();
    }

}

Numbers 클래스

  • 게임하는데 필요한 3개의 List<Number>를 관리하는 일급 컬렉션
    • int numberNumber라는 클래스로 감싸는 것과
    • List<Number>를 클래스로 감씨는 것은
    • 1주차에서 클린코드를 공부하며 배운 내용이다.
  • 검증하는 내용 포함
    • 숫자가 3자리 인지
    • 중복된 숫자가 없는지
  • 이 Numbers 객체 또한 2가지 방식으로 생성
    • 지정된 입력값으로 Number 3개를 저장하는 생성 메소드
    • 랜덤한 Number 3개로 저장하는 생성 메소드
public class Numbers {
	//게임에 필요한 숫자 갯수 상수로 명시
    private static final int NUMBER_COUNT = 3;

    private final List<Number> numberList = new ArrayList<>();
    
	//기본생성자 접근 막음
    private Numbers() {}
    
	//지정 숫자 3개로 생성
    public static Numbers createNumbers(int input) {
        Numbers numbers = new Numbers();

        numbers.validateThreeDigits(input);
        numbers.validateDuplicateNumber(input);

        numbers.createNumberList(input);

        return numbers;
    }
    
	//랜덤한 숫자 3개로 생성
    public static Numbers createRandomNumbers() {
        Numbers numbers = new Numbers();
        numbers.pickNewRandomNumbers();
        return numbers;
    }

	/*검증 로직 시작*/
    private void validateThreeDigits(int input) {
        if ((input < 111) || (input > 999)) {
            throw new IllegalArgumentException(Errors.NUMBERS_THREE_DIGITS.getValue());
        }
    }

    private void validateDuplicateNumber(int input) {
        if (checkDuplicateNumber(input)) {
            throw new IllegalArgumentException(Errors.NUMBERS_DUPLICATE_NUMBER.getValue());
        }
    }

    private boolean checkDuplicateNumber(int input){
        int firstNumberInt = input/100;
        int secondNumberInt = input%100/10;
        int thirdNumberInt = input%10;

        return (firstNumberInt == secondNumberInt)
                ||(secondNumberInt == thirdNumberInt)
                ||(thirdNumberInt == firstNumberInt);
    }
	/*검증 로직 끝*/
 
    private void createNumberList(int input) {
        for (int exponent = NUMBER_COUNT - 1; exponent >= 0; exponent--) {
            int decimalNumber = (int) Math.pow(10, exponent);
            int number = input / decimalNumber;

            addNumber(number);

            input = input % decimalNumber;
        }
    }

    private void addNumber(int numberInt) {
        Number number = Number.createNumber(numberInt);
        numberList.add(number);
    }


    private void pickNewRandomNumbers() {
        for (int index = 0; index < NUMBER_COUNT; index++) {
            numberList.add(newRandomNumber());
        }
    }
	
    private Number newRandomNumber() {
        Number newRandomNumber;
        //랜덤 숫자 중복 방지
        do {
            newRandomNumber = Number.createRandomNumber();
        } while (numberList.contains(newRandomNumber));
        return newRandomNumber;
    }

    public Number findNumber(int index) {
        return numberList.get(index);
    }
}

User, Computer 클래스

  • User와 Computer는 거의 같은 역할을 한다.
    • User 클래스 하나로 User user1 = new User(); User user2 = new User();이렇게 두 객체를 만들어서 사용해도되지만,
    • 프로그램의 확장될 때 컴퓨터와 유저의 역할이 점점 달라질 것 같아서 분리하게 되었다.
  • 주요 동작
    • 컴퓨터는 1 게임마다 새로운 랜덤 숫자를 가져야한다.
    • 유저는 1 라운드마다 새로운 숫자를 입력받아야 한다.
public class Computer {

    private Numbers computerNumbers;

    public Computer () {}
	
    //새로운 랜덤 숫자를 고르는 메소드 
    public void pickRandomNumbers() {
        this.computerNumbers = Numbers.createRandomNumbers();
    }
	
    public Number findComputerNumber(int index) {
        return this.computerNumbers.findNumber(index);
    }
}
public class User {

    private Numbers userNumbers;

    public User () {}
	
    //새로운 입력받은 숫자로 새로운 Numbers를 할당하는 메소드
    public void inputNewNumbers(int input) {
        this.userNumbers = Numbers.createNumbers(input);
    }

    public Number findUserNumber(int index) {
        return this.userNumbers.findNumber(index);
    }
}

Game 클래스

  • 게임의 시작과 끝을 관리하는 클래스
  • 주요기능
    • computeruser로 새로운 게임을 켜는 기능
    • 새 게임을 시작할 수 있는 기능
      • 정답을 맞추면 1게임 종료
    • 게임을 재시작할 지 묻고 재시작하거나 게임을 완전히 끄는 기능
public class Game {

    private Computer computer;
    private User user;
    private Round round;

    public Game() {}

    public void turnOnGame(Computer computer, User user) {
        this.computer = computer;
        this.user = user;
        this.round = new Round();
        Print.printGameStart();
    }

	// 컴퓨터가 새로운 랜덤 넘버를 고르며 게임 시작 후 정답 시 게임 종료 멘트 출력
    public void startNewGame() {
        this.computer.pickRandomNumbers();
        playGame();
        Print.printGameEnd();
    }

	//3스트라이크가 나올 때 까지 새로운 라운드를 호출하는 메소드
    private void playGame() {
        do {
            round.startNewRound(user, computer);
        } while (!round.isThreeStrike());
    }

    public boolean replayGame() {
        Print.printReplayGame();
        int inputInt = Input.readInt();
        ReplayNumber replayNumber = new ReplayNumber(inputInt);
        return replayNumber.isReplay();
    }
}

Round 클래스

  • 유저가 새로운 숫자를 입력하면 그에 대한 결과까지만 관리하는 클래스다.
  • 이 프로그램에서 제일 중요한 로직이 많이 들어잇는 클래스다.
  • 주요 기능
    • 새 라운드를 시작
      • 시작 메시지 출력
      • 숫자 입력 (이 기능은 어디 넣을지 고민이 아직도 됨)
      • 라운드에 대한 결과 판별
      • 결과 메시지 출력
public class Round {

    private final int COUNT_NUMBER = 3;

    private Hints hints;

    public Round() {}

	//새 라운드를 시작하는 로직
    public void startNewRound(User user, Computer computer)  {
        Print.printRoundStart();
        readNumbers(user);
        getHints(user, computer);
        Print.printRoundResult(hints);
    }

	//숫자를 읽어서 user에 새로운 Numbers를 지정하는 메소드 
    private void readNumbers(User user) {
        int inputInt = Input.readInt();
        user.inputNewNumbers(inputInt);
    }

	// 3개 숫자의 결과를 판단하는 메소드 
    private void getHints(User user, Computer computer) {
        hints = new Hints();
        for (int index = 0; index < COUNT_NUMBER; index++) {
            Hint hint = getHint(index, user, computer);
            hints.addHint(hint);
        }
    }
	
    // 현재 인덱스의 Strike, Ball 여부를 판단하는 메소드
    private Hint getHint(int index, User user, Computer computer) {
        if (isStrike(index, user, computer)) {
            return Hint.STRIKE;
        }
        if (isBall(index, user, computer)) {
            return Hint.BALL;
        }
        return Hint.NOTHING;
    }

	//이 라운드가 3스트라이크인지 판별
    public boolean isThreeStrike(){
        int countStrike = hints.findHintCount(Hint.STRIKE);
        return (countStrike == 3);
    }

	//현재 인덱스가 볼인지 판별
    private boolean isBall(int index, User user, Computer computer) {
        // 이전 인덱스 : 0 -> 2
        int prevIndex = (index + 2) % 3;
        // 이후 인덱스 : 2 -> 0
        int nextIndex = (index + 1) % 3;

        Number prevComputerNumber = computer.findComputerNumber(prevIndex);
        Number nextComputerNumber = computer.findComputerNumber(nextIndex);
        Number userNumber = user.findUserNumber(index);
        
        boolean isPrevBall = userNumber.equals(prevComputerNumber);
        boolean isNextBall = userNumber.equals(nextComputerNumber);

        return  isPrevBall || isNextBall;
    }
	
    //현재 인덱스가 스트라이크인지 판별
    private boolean isStrike(int index, User user, Computer computer) {
        return computer.findComputerNumber(index)
                .equals(user.findUserNumber(index));
    }
}

그 외 클래스

  1. Hint
  • 힌트 종류인 스트라이크, 볼, 낫싱이 있는 enum
  • 힌트 출력, 결과 판별할 때 사용
public enum Hint {
    NOTHING("낫싱"),
    BALL("볼"),
    STRIKE("스트라이크");

    private String value;

    Hint(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }
}
  1. Hints
  • private final HashMap<Hint, Integer> hints; 각 힌트가 몇개 있는지 저장하는 클래스
  1. Print
  • 출력 메세지와 출력하는 메소드가 있는 클래스
  1. Input
  • 입력받는 메소드가 있는 클래스
  1. ReplayNumber
  • 재시작 여부를 묻는 숫자를 저장하는 클래스
  • 1,2만 저장할 수 있는 검증 처리가 있음
  1. Errors
  • 에러메세지를 저장하는 enum

main method

public class Application {
    public static void main(String[] args) {

        Game game = new Game();
        Computer computer = new Computer();
        User user = new User();

        game.turnOnGame(computer, user);

        do {
            game.startNewGame();
        } while (game.replayGame());

    }
}

고민한 것들

싱글톤

  • 어플리케이션의 시작과 끝이 게임이 켜지고 게임이 종료되며 끝나니, Game은 싱글톤으로 만들어야될 것 같았다.
  • 그래서 처음에는 Game을 싱글톤으로 구현하였다.
  • 또한, Round 또한 매 라운드 마다 라운드 객체를 새로 생성하는 것이아니라 새 라운드를 시작하는 메소드를 실행하는 것이라 싱글톤으로 구현하였다.
  • 테스트할 때 문제가 생겼다.
  • 테스트 툴을 다루는데 너무 익숙하지 않은 나는 처음에 Round와 Game을 상속한 클래스를 만들어서 랜덤 숫자가 아니라 지정한 숫자가 List에 들어가도록 재정의해서 테스트하려하였다.(결국엔 방법을 바꿨지만)
  • 하지만 싱글톤의 경우 기본 생성자가 private이기 때문에 상속이 불가하다.
  • 이정도의 단점을 가지고 가기엔 싱글톤이 주는 이점이 너무 부족하다는 생각이 들어서 마지막 쯤에 싱글톤 패턴을 제거했다.

테스트 코드

  • 이번 과제에서 제일 고민이었던 것은 테스트 코드를 짜는 것이었다.
  • 지금까지 한 프로젝트에서는 기능 구현만 급급했을 뿐 테스트 코드를 짠적은 없었다.
  • 최대한 기능별로 테스트 하려고 했는데, 잘 되진 않았다.

뭘 비교해야 되지?

  • 가장 기본적인 것부터 고민이었다.
  • 제일 기본적으로 Numbers에 숫자가 잘 담기는 지 테스틀 하고 싶었는데
  • 정적 팩토리 메소드나 생성자를 통하여 Numbers나 Number를 만들어 놓고 뭘 비교해야할지 몰라서 처음에는 .isInstanceOf() 메소드를 사용하여 Numbers나 Number가 맞는지 확인 하였다.
    • (이건 진짜 너무 바보같은 짓이었다...ㅎ)
    • 당연히 Numbers를 만들었으니까 Numbers 객체가 만들어질 건데, 너무 뻔한걸 테스트 한것이다.
  • 그래서 나중에는 Number에서 재정의한 .equals() 메소드도 테스트 할 겸, "123"을 입력하고 Numbers에 들어간 숫자가 1,2,3으로 들어갔는지 확인하였다.
@DisplayName(value = "Numbers, Number 입력 테스트")
@ParameterizedTest
@ValueSource(ints = {123})
void inputValueTest(int input) throws Exception {
    Numbers numbers = Numbers.createNumbers(input);
    int[] inputs = {1,2,3};

    for (int index = 0; index < 3; index++) {
        assertThat(numbers.findNumber(index))
                .isEqualTo(Number.createNumber(inputs[index]));
    }
}
  • 하지만 이 또한 좋은 테스트는 아닌 것 같다.
  • 테스트 코드를 짜는 내 의도가 2가지 이상이 들어갔다.
    • Numbers.createNumbers(input);로 숫자 리스트가 잘 담기는 지
    • Number.createNumber(inputs[index]);로 숫자가 잘 담기는 지
    • Number.equals()가 잘 작동하는 지
  • 하지만 더 좋은 상황이 생각이 나지 않아서 이정도로 마무리 하였으나, 다음 과제를 하면서 더 고민을 해봐야될 것 같다.

입, 출력 테스트

  • 제공해준 라이브러리에 있는 Console.readLine() 메소드를 사용하긴 했지만, 그 안에 구현되어있는 메소드 또한 아래와 같은 식이다.
Scanner scanner = new Scanner(System.in);
String input = scanner.next();
  • System.in이 콘솔에 입력된 값을 InputStream에 담아주는 역할 을 하여 그것을 Scanner가 받아드리는 것인데,
  • 테스트 환경에서는 그것이 불가능 하다.
  • 따라서 아래와 같이 InputStream을 명시적으로 지정해주고 그것으로 System에 set 해줘야 한다.
String input = "입력하고 싶은 값";
InputStream in = new ByteArrayInputStream(input.getBytes());
System.setIn(in);
  • 출력되는 값 또한 결과가 잘 출력되는지 확인하기 위해서는 OutputStream에 콘솔에서 출력되는 바이트스트림을 저장하여 그를 읽는 과정이 필요하다.
OutputStream out = new ByteArrayOutputStream();
System.setOut(new PrintStream(out));

String 출력값 = out.toString();

여러 변수에 대한 테스트

  • JUnit5부터 지원하는 @ParameterizedTest어노테이션을 활용하여 테스트 할 수 있다.
  • @ValueSource(), @MethodSource(), @CsvSource 어노테이션을 통해 어떠한 변수를 넣을 것인지 설정할 수 있다.
  • 아래 예는 게임 재시작하는지 메소드에 대한 테스트이다.
@DisplayName(value = "게임 재시작 테스트")
@ParameterizedTest
@CsvSource(value = {"1,true", "2,false"})
void restartTest(String input, boolean isReplay) {
    InputStream in = new ByteArrayInputStream(input.getBytes());
    System.setIn(in);

    Game game = new Game();
    boolean replayGame = game.replayGame();

    assertThat(replayGame).isEqualTo(isReplay);
}

랜덤 값에 대한 테스트

  • 가장 길게 고민한 내용이다.
  • Computer가 새로운 랜덤 넘버를 픽하지 않는다면, 이미 선택한 랜덤넘버를 읽어서 그거에 따라 케이스를 나눠 테스트할 수 있다.
  • 하지만 게임이 반복되는 상황에서는 computer가 새로운 랜덤 넘버를 계속 만들어 냈고 테스트 코드를 짜는 상황에서 InputStream에 저장해두려면 랜덤넘버를 미리 알고 있어야 했다.
  • 그래서 처음에는 실제 클래스를 상속 후 랜덤넘버를 새로 초기화하는 과정을 빼고 오버라이딩 하려했다.
  • 이는 좋은 방법이 아니라는 것이 직감적으로 들었고, Mocking이라는 것을 알게됐다.
    • 모킹은 외부에 의존하는 부분을 가짜로 대체하는 것이다.
  • 나는 제공되는 라이브러리에서 Randoms.class 부분을 Mockto를 사용하여 모킹하였다.
    • pickNumberInRange()은 정적 함수였고, 정적 메소드를 모킹하기 위해서는 다음과 같은 과정이 필요하다.
static MockedStatic<Randoms> randomsMockedStatic;

@BeforeAll
static void beforeAll() {
    randomsMockedStatic = mockStatic(Randoms.class);
    when(Randoms.pickNumberInRange(1,9)).thenReturn(1,2,3);
}
  • randomsMockedStatic이 관여할 수 있는 범위 내에서는 Randoms.pickNumberInRange(1,9)의 리턴 값은 1, 2, 3 순서로 된다는 뜻이다.
  • 그래서 3스트라이크 값이 123이라고 예상하고 여러 케이스를 테스트할 수 있었다.

기능 나누기

기능을 나누는게 뭐지?

  • 사실 이건 아직도 제일 모르겠다.
  • 하나의 기능 즉, 메소드를 작성할 어떤 과정이 생기면 다른 메소드를 계속 빼서 메소드를 분리하였다.
  • 하지만 그 메소드를 어떤 한 메소드에서 자꾸 호출하게 되었다.
  • 예를 들면 새로운 라운드를 시작하는 메소드에서
public void startNewRound(User user, Computer computer)  {
    Print.printRoundStart();
    readNumbers(user);
    getHints(user, computer);
    Print.printRoundResult(hints);
}
  1. 시작 메시지를 출력하는 것
  2. 새로운 숫자를 입력 받는 것
  3. 그 숫자로 힌트(볼, 스트라이크)를 받아오는 것
  4. 그 결과 메시지를 출력하는 것
  • 이 기능 들을 각각 메소드로 구현했지만, 결국 startNewRound라는 메소드는 이 모든 기능을 품고 있는게 아닌가? 라는 의문이 들었지만... 아직까지는 어쩔 수 없다라는 스스로 결론을 갖고 있긴 하다... 더 괜찮은 방법이 있는지 좀 고민 해볼 예정이다.
  • 그리고 특정 메소드가 너무 그 메소드를 호출하는 메소드를 위해서만 존재하는 것 같다...

너무 클래스끼리 의존적인거 같다...

  • Game과 User, Computer는 각각 main메소드에서 만들어지지만, Round는 Game에 의해서만 생성되고 관리된다.
  • Round가 너무 Game에 의존적인 것 같다... 이는 아직 어떻게 해결해야 할지 잘 모르겠다...

후기

  • 여러모로 고민할 지점이 많았던 시간이었다.
  • 테스트코드를 짜면서 들었던 고민은 내가 테스트 코드를 짜는 방법을 몰라서 오는 고민도 있었지만, 아마 내가 기능 분리를 제대로 하지 못해서 오는 고민이 대부분이었던 것 같다.
  • TDD는 설계테스트코드 작성기능 구현 이 순서로 진행된 다는 것을 알게 되었는데, 사실 지금 당장 이 순서를 지키면서 프로그램을 짜는 건 나에게 좀 벅찰 것 같다.
  • 하지만 저 순서를 지키면 설계단계에서 내가 작성한 기능 목록을 토대로 테스트코드를 짜고 그 테스트 코드를 토대로 기능 구현을 할 수 있으니까, 최소한의 메소드와 요구사항에 딱 맞는 프로그램을 짤 수 있을 것 같았다.
  • 1주차에도 그렇지만, 주어진 기능 요구사항의 난이도와는 별개로 내 스스로 설계부터 고민해보고 프로그래밍을 하는 과정에 나에게 이렇게 큰 학습효과를 가져올 수 있다는 것에 놀라는 일주일이었다..
  • 아쉬운 점도 배운 점도 많았지만 이를 보강하여 다음 과제는 더욱 더 잘 해내고 싶다.
profile
1일 1산책 1커밋

1개의 댓글

comment-user-thumbnail
2022년 11월 14일

잘보고갑니다!

답글 달기