코드 카타 - 숫자 야구 게임

코드 카타 - 숫자 야구 게임

TDD와 헥사고날 아키텍처 훈련


코드카타(Code Kata)


(주의… 의역이 심합니다 😅)

어떻게 하면 훌륭한 음악가가 될 수 있을까요?

우선, 일단 이론을 알면 악기의 역학을 이해하는데 분명 도움이 됩니다.

하지만 궁극적으로 훌륭한 실력은 이렇게 이론만 알아서 되는것이 아닌, 지속적이고 반복적인 훈련에서 나온다는 것을 깨달아야 합니다.

반복적인 훈련에 이론을 계속 적용하며 결과를 얻고, 결과에 대한 피드백도 얻는다면 결과는 계속해서 좋아질 것입니다.


위대한 스포츠 선수가 되려면 어떻게 해야 할까요?

체력과 같은 선천적인 재능들은 분명히 큰 도움이 됩니다.

그러나 이러한 재능들과 별개로 모든 위대한 스포츠 선수들은 보이지 않는 곳에서 매일 긴 시간 동안의 피나는 훈련을 했음을 알아야 합니다.


일반적으로 소프트웨어 산업현장에서 우리는 이론만 훈련받은 개발자들을 데려와 프로젝트에 투입시키고, 프로젝트가 잘 돌아가길 원합니다.

이것은 건강한 아이들을 몇 모으면 워싱턴 커맨더스를 이길 수 있다고 말하는 것과 다르지 않습니다.


즉, 우리는 직장에서 훈련을 하기 때문에 실수를 하는 것입니다.

따라서, 우리는 일과 훈련을 분리하는 방법을 찾고 지속적으로 훈련해 성장해야만 합니다.


코드 카타, 어떻게 해야 하는가?


  • 누구에게도 방해받지 않는 시간이 필요하며, 가볍게 시도해보고 싶은 단순한 주제가 필요합니다.

  • 필요하거나 만족스러울 만큼 여러 번 쉽게 시도하고, 무엇보다 실수를 하는 것이 두렵지 않고 편안해야만 합니다.

  • 개선을 위해 노력할 수 있도록 매번 피드백을 찾아야 합니다.

  • 외부의 압력이 없어야 합니다. 심신이 편안해야 합니다. 그리하여 오로지 훈련에만 몰입 할 수 있어야 합니다.


이것이 프로젝트 환경에서 성장하기 어렵고, 실수가 잦아지는 이유입니다.


카타는 한 형태를 여러 번 반복하면서 조금씩 개선되는 가라데의 훈련입니다.

코드 카타의 의도는 가라데의 카타와 비슷합니다.

둘 모두 단순히 짧은 반복 훈련입니다.


카타를 처음 하면 지루할수도 있지만, 계속해서 반복 할 수록 코드가 점점 더 좋아지는 것을 보고 느끼게 될 것이기 때문에 하면 할 수록 이 훈련이 아주 좋다는 것을 스스로 인식하게 될 것입니다.

결과적으로, 스스로가 성장한다는 것을 직접적으로 느낄 수 있기 때문에 카타는 개발에 재미를 느끼는 데에도 큰 도움이 됩니다.


누군가는 카타를 하며 단순히 코딩하는 것을 떠나 설계까지 고려 할 겁니다.

또 누군가는 카타를 진행하며 프로그래밍 이면의 문제에 대한 고민을 할 겁니다.

즉, 카타에는 정답이 없습니다.


원하는 훈련을 하고 원하는 공예에 충분한 시간을 투자하세요.

카타의 요점은 정답이 없다는 것이며, 설령 정답이 있다고 하더라도 그 정답이 중요하지 않을 것이라는 점입니다.

중요한 것은 결과가 아닌 카타를 진행하며 고민하는 과정 그 자체입니다.


소프트웨어 개발 3대 원칙


나는 최대한 소프트웨어 개발 3대 원칙과 객체지향 5대 원칙(SOLID)을 고민하면서 훈련에 임할 것이다.

  • KISS(Keep It Simple Stupid): 최대한 단순하게 해라
  • YAGNI(You Ain’t Gonna Need It): 필요한 것만 해라
  • DRY(Do not Repeat Yourself): 같은 것을 반복하지 마라

숫자 야구 게임

Java_17



기능 요구 사항


기본적으로 1부터 9까지 서로 다른 수로 이루어진 3자리의 수를 맞추는 게임이다.

  • 같은 수가 같은 자리에 있으면 스트라이크, 다른 자리에 있으면 볼, 같은 수가 전혀 없으면 포볼 또는 낫싱이란 힌트를 얻고, 그 힌트를 이용해서 먼저 상대방(컴퓨터)의 수를 맞추면 승리한다.
    • e.g. 상대방(컴퓨터)의 수가 425일 때, 123을 제시한 경우 : 1스트라이크, 456을 제시한 경우 : 1볼 1스트라이크, 789를 제시한 경우 : 낫싱
  • 위 숫자 야구 게임에서 상대방의 역할을 컴퓨터가 한다. 컴퓨터는 1에서 9까지 서로 다른 임의의 수 3개를 선택한다. 게 임 플레이어는 컴퓨터가 생각하고 있는 3개의 숫자를 입력하고, 컴퓨터는 입력한 숫자에 대한 결과를 출력한다.
  • 이 같은 과정을 반복해 컴퓨터가 선택한 3개의 숫자를 모두 맞히면 게임이 종료된다.
  • 게임을 종료한 후 게임을 다시 시작하거나 완전히 종료할 수 있다.


실행 결과


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


프로그래밍 요구사항


  • 자바 코드 컨벤션을 지키면서 프로그래밍한다.
    • 기본적으로 Google Java Style Guide을 원칙으로 한다.
    • 단, 들여쓰기는 ‘2 spaces’가 아닌 ‘4 spaces’로 한다.
  • indent(인덴트, 들여쓰기) depth를 2가 넘지 않도록 구현한다. 1까지만 허용한다.
    • 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다.
  • else 예약어를 쓰지 않는다.
  • 모든 로직에 단위 테스트를 구현한다. 단, UI(System.out, System.in) 로직은 제외
  • 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 구분한다.
  • 3항 연산자를 쓰지 않는다.
  • 함수(또는 메소드)가 한 가지 일만 하도록 최대한 작게 만들어라.


기능 정의 및 책임 할당

우선 꼭 필요하다고 생각되는 기능들을 정의해야한다.

구현 중간에 세세하게 바뀔수는 있겠지만 거시적으로 보면 크게 변하지는 않을 것이다.

  • 콘솔로 숫자를 입력받을 수 있어야 한다 (ConsoleInput)
  • 콘솔로 재시작 여부를 입력받을 수 있어야 한다(ConsoleInput)
  • 콘솔에 정보를 출력할 수 있어야 한다(ConsoleOutput)
  • 1~9사이의 중복되지 않는 랜덤한 수 3개를 생성할 수 있어야 한다(NumbersGenerativeStrategy)
  • 생성된 랜덤수와 사용자가 입력한 수를 비교하여 볼, 스트라이크 카운트를 판별할 수 있어야 한다(Referee)


프로젝트 구성


일단 다음과 같이 패키지 구조를 잡았다


image


의존성 설정을 할 것인데, 자바 17을 사용할 것이며, 빌드툴은 Gradle을 사용한다.

테스트를 원활하게 하기 위해 AssertJJUnit5를 추가했다.


// file: 'build.gradle'
plugins {
    id 'java'
}

group 'io.github.shirohoo'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.assertj:assertj-core:3.22.0'
    testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
}

test {
    useJUnitPlatform()
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}


숫자 야구 게임 구현



우선 이 게임에서 가장 중요한 객체인 숫자를 구현해야겠다.

게임에서 사용하는 숫자를 표현 할 클래스 이름은 Numbers라고 명명하였고, 구현의 유연함을 위해 외부에서 생성 전략을 주입받아 생성되도록 구성할 것이다.


class NumbersTests {
    @Test
    void from() {
        assertThatCode(() -> {
            Numbers randomNumbers = Numbers.from(() -> "123");
        }).doesNotThrowAnyException();
    }
}


먼저 테스트 케이스를 정의해주고 스터빙을 한다.


public interface NumbersGenerativeStrategy {}

public class RandomNumbersGenerativeStrategy implements NumbersGenerativeStrategy {}

public class Numbers {
    public static Numbers from(NumbersGenerativeStrategy randomStrategy) {
        return null;
    }
}


다시 테스트를 실행해본다.


image


테스트 케이스를 추가한다.

null이 입력되면 NPE가 발생하는지 검증하도록 한다.


class NumbersTests {
    @Test
    void from() {
        assertThatCode(() -> {
            Numbers randomNumbers = Numbers.from(() -> "123");
        }).doesNotThrowAnyException();
    }

    @Test
    void createExceptionWhenInNull() {
        assertThatThrownBy(() -> {
            Numbers randomNumbers = Numbers.from(null);
        }).isInstanceOf(NullPointerException.class);
    }
}


Expecting code to raise a throwable.
	at io.github.shirohoo.baseball.app.domain.NumbersTests.createExceptionWhenInNull(NumbersTests.java:18)


테스트가 실패하였다.

구현을 해야 하는데, 일단 Numbers는 여기서 손 떼고 Numbers 생성을 책임지는 전략쪽으로 선회하는게 조금 더 편할 것 같다.

NumbersGenerativeStrategy는 숫자를 생성하는 전략이다.

RandomNumbersGenerativeStrategy는 1~9사이의 중복되지 않는 숫자 세개를 생성해야만 한다.

검증은 어떻게 해야 할까?

  1. 우선 생성된 세 숫자의 중복을 제거하고도 숫자의 수가 3개여야 할 것이다.
  2. 1~9사이의 중복되지 않는 숫자 3개가 나오므로 최소로 나올 수 있는 수는 123이며, 최대로 나올 수 있는 수는 789이다. 따라서 세 숫자의 합 x는 6 <= x <= 24 여야 한다.
  3. 1,2번 테스트 케이스로도 충분히 검증은 될 것 같지만, 아무래도 랜덤수이다 보니 조금 더 신뢰성을 확보하기 위해 테스트를 100번 반복해야겠다.


class RandomNumbersGenerativeStrategyTests {
    @RepeatedTest(100)
    void generate() {
        RandomNumbersGenerativeStrategy strategy = new RandomNumbersGenerativeStrategy();
        String randomNumbers = strategy.generate();

        assertThat(distinctCount(randomNumbers)).isEqualTo(3L);
        assertThat(sum(randomNumbers)).isGreaterThanOrEqualTo(6).isLessThanOrEqualTo(24);
    }

    private long distinctCount(String randomNumbers) {
        return stream(randomNumbers.split(""))
            .distinct()
            .count();
    }

    private int sum(String randomNumbers) {
        return stream(randomNumbers.split(""))
            .mapToInt(Integer::valueOf)
            .sum();
    }
}


스터빙을 하고 테스트를 실행하면 구현은 아예 없기 때문에 당연히 테스트는 실패한다.

바로 구현에 들어간다.

위 테스트만 만족하면 RandomNumbersGenerativeStrategy는 유효하다고 볼 수 있을 것 같다.

구현 할 때 자바 17에 포함되어 릴리즈된 새로운 의사난수 생성기(RandomGenerator)를 사용해봤다.


public class RandomNumbersGenerativeStrategy implements NumbersGenerativeStrategy {
    private static final RandomGenerator RANDOM_GENERATOR = RandomGenerator.getDefault();

    public String generate() {
        return RANDOM_GENERATOR.ints(1, 10)
            .distinct()
            .limit(3)
            .mapToObj(String::valueOf)
            .collect(joining());
    }
}


다시 테스트를 실행한다.


image


100번의 반복동안 모든 케이스가 한번도 실패하지 않았다.

이정도면 충분히 검증이 되었다고 믿어도 될 것 같다.

이제 NumbersNumbersGenerativeStrategy를 받아 인스턴스를 생성하는 부분을 구현할건데, 코드를 보니 Numbers는 내부적으로 문자열을 포함해야 할 것 같다.

객체지향 프로그래밍을 할 때 중요한 것은 항상 데이터 -> 행동이 아닌, 행동 -> 데이터 순으로 생각해야만 한다는 것이다.

데이터 중심적인 사고를 버리고 책임,역할과 행동 중심적인 사고를 길러야 한다 !


public class Numbers {
    private final String numbers;

    private Numbers(String numbers) {
        this.numbers = numbers;
    }

    public static Numbers from(NumbersGenerativeStrategy strategy) {
        return new Numbers(strategy.generate());
    }
}


구현하고 다시 Numbers 테스트를 실행했다.


image


아주 잘 된다.

이제보니 리팩토링 할 건덕지가 보인다.

Numbersrecord 클래스로 변경해도 좋을 것 같다.

변경하고 다시 테스트를 실행해보자.


public record Numbers(String numbers) {
    public static Numbers from(NumbersGenerativeStrategy strategy) {
        return new Numbers(strategy.generate());
    }
}


image


사용자가 3개의 숫자를 입력해올 것인데, 이 숫자도 1~9사이의 중복되지 않는 랜덤한 수인지를 판별할 수 있게 정적 팩토리 메서드를 하나 열어봐야겠다는 생각이 들었다.

우선 테스트 케이스를 추가한다.

사용자 입력이 null, empty, 4자리 이상, 중복되는 수 포함, 0이 포함된 경우 예외가 발생되도록 할 것이다.


class NumbersTests {

    ...
    
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {"012", "890", "111", "112", "1234", "1111"})
    void createExceptionWhenNot3NonOverlappingNumbersForUserInput(String userInput) {
        assertThatThrownBy(() -> {
            Numbers randomNumbers = Numbers.nonOverlapping3digits(userInput);
        }).isInstanceOf(IllegalArgumentException.class);
    }
}


새로 추가한 테스트를 실행하면 모든 케이스가 실패한다.


image


구현하자.


public record Numbers(String numbers) {
    public static Numbers from(NumbersGenerativeStrategy strategy) {
        return new Numbers(strategy.generate());
    }

    public static Numbers nonOverlapping3digits(String userInput) {
        if (userInput == null || userInput.isBlank()) {
            throw new IllegalArgumentException("'userInput' must not be null or empty");
        }

        if (userInput.contains("0")) {
            throw new IllegalArgumentException("'userInput' must not be contain '0'");
        }

        long count = stream(userInput.split("")).distinct().count();
        if (count != 3) {
            throw new IllegalArgumentException("'userInput' must be three non-overlapping numbers");
        }

        int sum = stream(userInput.split("")).mapToInt(Integer::valueOf).sum();
        if (sum < 6 || sum > 24) {
            throw new IllegalArgumentException("sum of 'userInput' must be 6 <= x <= 24");
        }
        return new Numbers(userInput);
    }
}


다시 테스트를 실행해봤다.


image


이제 랜덤하게 생성된 Numbers와 사용자의 입력을 받아 생성된 Numbers 를 비교하여 볼, 스트라이크 카운트를 반환해주는 심판(Referee) 객체를 구현 할 것이다.

심판은 오로지 두개의 숫자(Numbers)를 비교하여 판정(decision)하고 판정 결과(DecisionResult)를 반환 할 것이다.

그리고 심판이 반환한 판정 결과에는 볼, 스트라이크 카운트가 포함돼있을 것이다.

테스트 코드를 작성 할 때, 볼과 스트라이크가 나올 수 있는 경우의 수를 최대한 고려해 테스트 케이스에 추가했다.


class RefereeTests {
    @MethodSource
    @ParameterizedTest
    void decision(Numbers userNumbers, DecisionResult expected) {
        Referee referee = new Referee();
        Numbers computerNumbers = Numbers.nonOverlapping3digits("123");

        DecisionResult decisionResult = referee.decision(computerNumbers, userNumbers);

        assertThat(decisionResult).isEqualTo(expected);
    }

    static Stream<Arguments> decision() {
        return Stream.of(
            Arguments.of(Numbers.nonOverlapping3digits("123"), DecisionResult.of(0, 3)),
            Arguments.of(Numbers.nonOverlapping3digits("124"), DecisionResult.of(0, 2)),
            Arguments.of(Numbers.nonOverlapping3digits("145"), DecisionResult.of(0, 1)),
            Arguments.of(Numbers.nonOverlapping3digits("135"), DecisionResult.of(1, 1)),
            Arguments.of(Numbers.nonOverlapping3digits("132"), DecisionResult.of(2, 1)),
            Arguments.of(Numbers.nonOverlapping3digits("345"), DecisionResult.of(1, 0)),
            Arguments.of(Numbers.nonOverlapping3digits("234"), DecisionResult.of(2, 0)),
            Arguments.of(Numbers.nonOverlapping3digits("789"), DecisionResult.of(0, 0))
        );
    }
}


우선 DecisionResultrecord 클래스로 하나 생성했다.


public record DecisionResult(int ballCount, int strikeCount) {
    public static DecisionResult of(int ballCount, int strikeCount) {
        return new DecisionResult(ballCount, strikeCount);
    }
}


이제 심판이 판정 후 DecisionResult를 반환할 것인데, 두개의 Numbers를 비교하는 알고리즘을 구현해야 한다.

해보자.


public class Referee {
    public DecisionResult decision(Numbers computerNumbers, Numbers userNumbers) {
        int ballCount = 0;
        int strikeCount = 0;
        for (int i = 0; i < computerNumbers.numbers().length(); i++) {
            ballCount += ifBallIncreaseByOne(i, computerNumbers, userNumbers);
            strikeCount += ifStrikeIncreaseByOne(i, computerNumbers, userNumbers);
        }
        return DecisionResult.of(ballCount, strikeCount);
    }

    private int ifBallIncreaseByOne(int i, Numbers computerNumbers, Numbers userNumbers) {
        if (computerNumbers.numbers().charAt(i) != userNumbers.numbers().charAt(i) &&
            userNumbers.numbers().contains(Character.toString(computerNumbers.numbers().charAt(i)))
        ) {
            return 1;
        }
        return 0;
    }

    private int ifStrikeIncreaseByOne(int i, Numbers computerNumbers, Numbers userNumbers) {
        if (computerNumbers.numbers().charAt(i) == userNumbers.numbers().charAt(i)) {
            return 1;
        }
        return 0;
    }
}


알고리즘의 시간복잡도는 O(N)으로 썩 훌륭하진 않지만, 더 좋은 방법이 생각나지 않아 일단 이렇게 구현했다.

어차피 N이 최대 3이라 이정도만 해도 아무 문제 없을 것 같기도 하고…

이제 테스트 코드를 실행해보자.


image


정의한 모든 케이스가 통과되는 것을 확인했다.

이쯤에서 도메인 구현은 얼추 끝이 난 것 같다.


image


이제 유스케이스를 하나 구현할것인데, 게임이 시작되면 3스트라이크가 될 때까지 사용자의 입력이 도메인에 계속 들어와야 한다.

이를 유저가 숫자 야구 게임(BaseBall)을 한번 시도했음으로 표현해야겠다.

경험상 유스케이스는 이미 테스트 코드로 검증된 도메인 객체들을 갖다 사용하므로 바로 구현에 들어가도 무방하다.

이미 테스트 된 것은 다시 테스트하지 말라는 말이 있기 때문이다. (DRY 원칙)

유스케이스가 생성될 때 심판과 컴퓨터의 랜덤수를 포함하고, 사용자 입력을 받아 계속 결과를 반환하도록 작성해보자.


public interface BaseBall {
    DecisionResult action(String input);
}


public class BaseBallImpl implements BaseBall {
    private final Referee referee;
    private final Numbers computerNumbers;

    private BaseBallImpl(Referee referee, Numbers computerNumbers) {
        this.referee = referee;
        this.computerNumbers = computerNumbers;
    }

    public static BaseBall of(Referee referee, NumbersGenerativeStrategy strategy) {
        return new BaseBallImpl(referee, Numbers.create(strategy));
    }

    @Override
    public DecisionResult action(String input) {
        return referee.decision(computerNumbers, Numbers.nonOverlapping3digits(input));
    }
}


여기까지 하고 보니, 애플리케이션 외부에서 String을 직접적으로 받는게 마음에 들지 않는다.

이러면 유저가 입력하는 데이터의 타입을 외부에 직접적으로 노출하게 되어 외부에서는 항상 String으로만 입력할 수 밖에 없게 된다.

모든 구현이 이 정의에 종속되기 때문이다.


따라서, 이를 UserInput으로 추상화 하고, Numbers.nonOverlapping3digits()에 있던 유효성 검사 코드를 모두 UserInput으로 옮겨와야겠다.

굉장히 많은 구조 변경이 예상되지만, 걱정할 것은 없다.

여지껏 작성한 테스트 코드가 보호해줄 것이다.

과감하게 구조 변경에 들어간다.

우선 모든 테스트 코드를 적절하게 옮기고 더이상 필요하지 않은 코드들은 제거하는 작업들을 해 주었다.


class NumbersTests {
    @Test
    void from() {
        assertThatCode(() -> {
            Numbers randomNumbers = Numbers.from(() -> "123");
        }).doesNotThrowAnyException();
    }
}


class RefereeTests {
    @MethodSource
    @ParameterizedTest
    void decision(UserInput userInput, DecisionResult expected) {
        Referee referee = new Referee();
        Numbers computerNumbers = Numbers.from("123");

        DecisionResult decisionResult = referee.decision(computerNumbers, userInput.createNumbers());

        assertThat(decisionResult).isEqualTo(expected);
    }

    static Stream<Arguments> decision() {
        return Stream.of(
            Arguments.of(UserInput.nonOverlapping3digits("123"), DecisionResult.of(0, 3)),
            Arguments.of(UserInput.nonOverlapping3digits("124"), DecisionResult.of(0, 2)),
            Arguments.of(UserInput.nonOverlapping3digits("145"), DecisionResult.of(0, 1)),
            Arguments.of(UserInput.nonOverlapping3digits("135"), DecisionResult.of(1, 1)),
            Arguments.of(UserInput.nonOverlapping3digits("132"), DecisionResult.of(2, 1)),
            Arguments.of(UserInput.nonOverlapping3digits("345"), DecisionResult.of(1, 0)),
            Arguments.of(UserInput.nonOverlapping3digits("234"), DecisionResult.of(2, 0)),
            Arguments.of(UserInput.nonOverlapping3digits("789"), DecisionResult.of(0, 0))
        );
    }
}


그리고 UserInputTests 를 새로 만들어 Numbers의 테스트 케이스를 여기로 옮겼다.


class UserInputTests {
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {"012", "890", "111", "112", "1234", "1111"})
    void createExceptionWhenNot3NonOverlappingNumbersForUserInput(String userInput) {
        assertThatThrownBy(() -> {
            UserInput userNumbers = UserInput.nonOverlapping3digits(userInput);
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void createNumbers() {
        UserInput userInput = UserInput.nonOverlapping3digits("123");
        assertThat(userInput.createNumbers()).isEqualTo(Numbers.from("123"));
    }
}


모든 테스트 케이스가 통과할 수 있게 NumbersUserInput간의 책임을 재분배해준다.


public record Numbers(String numbers) {
    public static Numbers from(NumbersGenerativeStrategy strategy) {
      return new Numbers(strategy.generate());
    }

    public static Numbers from(String input) {
      return new Numbers(input);
    }
}


public class UserInput {
    private final String userInput;

    private UserInput(String input) {
        if (input == null || input.isBlank()) {
            throw new IllegalArgumentException("'userInput' must not be null or empty");
        }

        if (input.contains("0")) {
            throw new IllegalArgumentException("'userInput' must not be contain '0'");
        }

        long count = stream(input.split("")).distinct().count();
        if (count != 3) {
            throw new IllegalArgumentException("'userInput' must be three non-overlapping numbers");
        }

        int sum = stream(input.split("")).mapToInt(Integer::valueOf).sum();
        if (sum < 6 || sum > 24) {
            throw new IllegalArgumentException("sum of 'userInput' must be 6 <= x <= 24");
        }
        this.userInput = input;
    }

    public static UserInput nonOverlapping3digits(String input) {
        return new UserInput(input);
    }

    public Numbers createNumbers(){
        return Numbers.from(userInput);
    }
}


변경된 코드에 맞춰 BaseBallImpl도 수정해주자.


public class BaseBallImpl implements BaseBall {
    private final Referee referee;
    private final Numbers computerNumbers;

    private BaseBallImpl(Referee referee, Numbers computerNumbers) {
        this.referee = referee;
        this.computerNumbers = computerNumbers;
    }

    public static BaseBall of(Referee referee, NumbersGenerativeStrategy strategy) {
        return new BaseBallImpl(referee, Numbers.from(strategy));
    }

    @Override
    public DecisionResult action(UserInput input) {
        return referee.decision(computerNumbers, input.createNumbers());
    }
}


다시 테스트를 실행하면…


image


image


여기까지의 패키지 구조는 다음과 같다.


image


이제 마지막으로 콘솔 입력콘솔 출력을 구현하고, 콘솔 애플리케이션을 구현 할 차례다.

이부분은 요구사항에 의거하여 테스트 코드를 작성하지 않아도 되므로 구현에 집중하도록 하자.

사실 굳이 요구사항이 아니더라도 외부의 입력은 도메인에서 신경 쓸 필요가 없다고 생각한다.

단지 도메인 스스로 유효성 검사만 잘 하면 될 뿐이다.


public class ConsoleInput {
    private final Scanner scanner;

    public ConsoleInput() {
        this.scanner = new Scanner(System.in);
    }

    public UserInput trys() {
        return UserInput.nonOverlapping3digits(scanner.nextLine());
    }

    public boolean restartIntentions() {
        int intentions = scanner.nextInt();
        if (intentions == 1) {
            return true;
        }
        if (intentions == 2) {
            return false;
        }
        throw new IllegalArgumentException(String.format("'%s' is unknown.", intentions));
    }
}


public class ConsoleOutput {
    private static final String ENTER_NUMBER_MESSAGE = "숫자를 입력해 주세요 : ";
    private static final String BALL_MESSAGE = "볼";
    private static final String STRIKE_MESSAGE = "스트라이크";
    private static final String NOTHING_MESSAGE = "낫싱";
    private static final String COMPLETE_MESSAGE = "3개의 숫자를 모두 맞히셨습니다! 게임 종료";
    private static final String RESTART_MESSAGE = "게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.";

    public void enterNumberMessage() {
        print(ENTER_NUMBER_MESSAGE);
    }

    public void resultMessage(DecisionResult result) {
        if (result.ballCount() == 0 && result.strikeCount() == 0) {
            println(NOTHING_MESSAGE);
        }
        if (result.ballCount() > 0 && result.strikeCount() == 0) {
            println(result.ballCount() + BALL_MESSAGE);
        }
        if (result.ballCount() == 0 && result.strikeCount() > 0) {
            println(result.strikeCount() + STRIKE_MESSAGE);
        }
        if (result.ballCount() > 0 && result.strikeCount() > 0) {
            println(String.format("%s%s %s%s",
                result.ballCount(), BALL_MESSAGE,
                result.strikeCount(), STRIKE_MESSAGE
            ));
        }
        if (result.strikeCount() == 3) {
            println(COMPLETE_MESSAGE);
        }
    }

    public void restartMessage() {
        println(RESTART_MESSAGE);
    }

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

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


public class ConsoleBaseBallApp {
    public static void main(String[] args) {
        Runner.init().run();
    }

    private static class Runner {
        private static Runner init() {
            return new Runner();
        }

        private void run() {
            ConsoleInput input = new ConsoleInput();
            ConsoleOutput output = new ConsoleOutput();
            BaseBall game = BaseBallImpl.of(new Referee(), new RandomNumbersGenerativeStrategy());

            DecisionResult result;
            do {
                output.enterNumberMessage();
                result = game.action(input.trys());
                output.resultMessage(result);
            } while (result.strikeCount() != 3);

            output.restartMessage();
            if (input.restartIntentions()) {
                run();
            }
            System.exit(0);
        }
    }
}


출력 메시지를 결정하는 코드가 별로 마음에 안든다.

DecisionResult 내부의 구현을 모조리 외부로 노출하고 있기 때문이다.

모든 경우의 수를 스스로 판별하도록 위임해야겠다.

항상 그랬듯이 우선 테스트 케이스를 작성한다.


class DecisionResultTests {
    @Test
    void isBallAndStrike() {
        DecisionResult result = DecisionResult.of(1, 1);
        assertThat(result.isBallAndStrike()).isTrue();
        assertThat(result.isNothing()).isFalse();
        assertThat(result.isOnlyBall()).isFalse();
        assertThat(result.isOnlyStrike()).isFalse();
        assertThat(result.isStrikeOut()).isFalse();
    }

    @Test
    void isNothing() {
        DecisionResult result = DecisionResult.of(0, 0);
        assertThat(result.isBallAndStrike()).isFalse();
        assertThat(result.isNothing()).isTrue();
        assertThat(result.isOnlyBall()).isFalse();
        assertThat(result.isOnlyStrike()).isFalse();
        assertThat(result.isStrikeOut()).isFalse();
    }

    @Test
    void isOnlyBall() {
       DecisionResult result = DecisionResult.of(1, 0);
        assertThat(result.isBallAndStrike()).isFalse();
        assertThat(result.isNothing()).isFalse();
        assertThat(result.isOnlyBall()).isTrue();
        assertThat(result.isOnlyStrike()).isFalse();
        assertThat(result.isStrikeOut()).isFalse();
    }

    @Test
    void isOnlyStrike() {
       DecisionResult result = DecisionResult.of(0, 1);
        assertThat(result.isBallAndStrike()).isFalse();
        assertThat(result.isNothing()).isFalse();
        assertThat(result.isOnlyBall()).isFalse();
        assertThat(result.isOnlyStrike()).isTrue();
        assertThat(result.isStrikeOut()).isFalse();
    }

    @Test
    void isStrikeOut() {
        DecisionResult result = DecisionResult.of(0, 3);
        assertThat(result.isBallAndStrike()).isFalse();
        assertThat(result.isNothing()).isFalse();
        assertThat(result.isOnlyBall()).isFalse();
        assertThat(result.isOnlyStrike()).isTrue();
        assertThat(result.isStrikeOut()).isTrue();
    }
}


image


이제 모든 테스트 케이스가 통과하도록 구현을 해주자.


public record DecisionResult(int ballCount, int strikeCount) {
    public static DecisionResult of(int ballCount, int strikeCount) {
        return new DecisionResult(ballCount, strikeCount);
    }

    public boolean isBallAndStrike() {
        return ballCount() > 0 && strikeCount() > 0;
    }

    public boolean isNothing() {
        return ballCount() == 0 && strikeCount() == 0;
    }

    public boolean isOnlyBall() {
        return ballCount() > 0 && strikeCount() == 0;
    }

    public boolean isOnlyStrike() {
        return ballCount() == 0 && strikeCount() > 0;
    }

    public boolean isStrikeOut() {
        return strikeCount == 3;
    }
}


이제 다시 테스트를 실행하면…


image


image


이제 새로 작성한 메서드들로 기존 코드들을 리팩토링 해준다.


public class ConsoleOutput {
    private static final String ENTER_NUMBER_MESSAGE = "숫자를 입력해 주세요 : ";
    private static final String BALL_MESSAGE = "볼";
    private static final String STRIKE_MESSAGE = "스트라이크";
    private static final String NOTHING_MESSAGE = "낫싱";
    private static final String COMPLETE_MESSAGE = "3개의 숫자를 모두 맞히셨습니다! 게임 종료";
    private static final String RESTART_MESSAGE = "게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.";

    public void enterNumberMessage() {
        print(ENTER_NUMBER_MESSAGE);
    }

    public void resultMessage(DecisionResult result) {
        if (result.isBallAndStrike()) {
            println(result.ballCount() + BALL_MESSAGE + " " + result.strikeCount() + STRIKE_MESSAGE);
            return;
        }

        if (result.isNothing()) {
            println(NOTHING_MESSAGE);
            return;
        }

        if (result.isOnlyBall()) {
            println(result.ballCount() + BALL_MESSAGE);
            return;
        }

        if (result.isOnlyStrike()) {
            println(result.strikeCount() + STRIKE_MESSAGE);
            if (result.isStrikeOut()) {
                println(COMPLETE_MESSAGE);
            }
        }
    }

    public void restartMessage() {
        println(RESTART_MESSAGE);
    }

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

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


빠른 리턴을 통해 앞의 조건문이 충족됐다면 후속 조건문은 런타임에 실행되지 않도록 약간의 최적화를 곁들여줬다.


ConsoleBaseBallApp도 리팩토링 해주자.


public class ConsoleBaseBallApp {
    public static void main(String[] args) {
        Runner.init().run();
    }

    private static class Runner {
        private static Runner init() {
            return new Runner();
        }

        private void run() {
            ConsoleInput input = new ConsoleInput();
            ConsoleOutput output = new ConsoleOutput();
            BaseBall game = BaseBall.create(new Referee(), new RandomNumbersGenerativeStrategy());

            DecisionResult result;
            do {
                output.enterNumberMessage();
                result = game.action(input.trys());
                output.resultMessage(result);
            } while (!result.isStrikeOut()); // <--- 요기 !

            output.restartMessage();
            if (input.restartIntentions()) {
                run();
            }
            System.exit(0);
        }
    }
}


여기까지 전체적인 패키지 구조는 다음과 같다.


image


이제 게임을 실행해보자.


image


잘 된다.



© 2022. All rights reserved.