미션 과제
우아한 테크코스 4기 1주차 미션과제는 '숫자 야구 게임'을 구현하는 것이었습니다.
미션 과제는 아래와 같으며, 구현 내용은 프리코스 1주차 미션 저장소에 업로드 하였습니다.
위 내용을 기반으로 하여 구현해야 하는 기능들을 정리하였습니다.
README.MD 작성
필요한 기능을 실행 순서에 따라 작성해 보았습니다.
최대한 자세하게 작성하려고 하였으나, 미숙한 부분이 많을 것으로 예상됩니다.
제가 작성한 README.MD는 아래와 같습니다.
## 🚀 기능 구현
---
1. 1에서 9까지 서로 다른 임의의 수 3개를 뽑아 세 자리 난수 생성.
- mission 에서 제공하는 Randoms 메서드 사용.
- 백의 자리는 1에서 9까지의 수 중 하나를 뽑음.
- 십의 자리는 1에서 9까지의 수 중 하나를 뽑되, 백의 자리와 다른 수이어야 함. (중복 확인)
- 일의 자리는 1에서 9까지의 수 중 하나를 뽑되, 백과 십의 자리와 다른 수이어야 함. (중복 확인)
2. 사용자로 부터 세자리 수를 입력 받음.
- 안내 메세지 출력.
- 세자리 수가 아닐 경우 exception 처리.
3. 1번에서 생성한 세자리 난수와 사용자로 부터 입력 받은 세자리 수를 비교.
- 같은 자리에 같은 숫자가 존재할 경우 스트라이크.
- 다른 자리에 같은 숫자가 존재할 경우 볼.
- 스트라이크와 볼의 개수를 저장한 후 출력.
- 볼과 스트라이크가 존재할 경우 x볼 y스트라이크.
- 볼만 존재할 경우 x볼.
- 스트라이크만 존재할 경우 x스트라이크.
- 둘다 존재하지 않을 경우 낫싱.
- 3strike가 될 때 까지 3번 반복.
4. 게임 진행 여부 확인.
- 안내 메세지 출력.
- 사용자가 "1"을 입력할 경우.
- 1번 부터 다시 진행.
- 사용자가 "2"를 입력할 경우.
- 안내 메세지 출력 후 프로그램 종료.
기능 구현
요구하는 기능을 살펴 보았을 때는 크게 어렵지 않아 보였습니다.
객체 중심으로 코드를 작성하려고 하였으며, 읽는 사람이 간결하다고 느낄 수 있게 작성하려고 노력하였습니다.
어떤 기능이 어떤 객체에 있을 때 가장 매끄러울까 생각을 하며 작성하였는데 다른 사람들이 보았을 때는 어떻게 느낄지 궁금하네요.
1. 세자리 난수 생성
RandomNumbers.java
세자리의 난수를 생성하는 클래스입니다.
매직넘버를 모두 추출하였으며, 세자리 난수를 생성할 때 이전 자리 수에서 나온 수가 있는지 중복 여부를 확인하는 부분이 존재합니다.
세자리 난수를 생성할 때 while 보다 더 적절한 방법이 없을까 고민을 하였지만, 현재 수준에서는 while문을 사용하는게 최선이라고 판단하여 사용하였습니다.
첫번째 프리코스가 종료 후, 남들은 어떻게 작성하였는지, 더 나은 방법이 없는지 확인할 필요가 있을 것 같습니다.
package baseball.domain;
import camp.nextstep.edu.missionutils.Randoms;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class RandomNumbers {
private static final int START_INCLUSIVE = 1;
private static final int END_INCLUSIVE = 9;
private static final int COUNT = 3;
private static List<BaseBallNumber> randomNumbers;
public RandomNumbers() {
this.randomNumbers = extractRandomNumbers();
}
private List<BaseBallNumber> extractRandomNumbers() {
List<BaseBallNumber> randomNumbers = new ArrayList<>();
while (randomNumbers.size() != COUNT) {
BaseBallNumber randomNumber = new BaseBallNumber(Randoms.pickNumberInRange(START_INCLUSIVE, END_INCLUSIVE));
if (!randomNumbers.contains(randomNumber)) {
randomNumbers.add(randomNumber);
}
}
return randomNumbers;
}
public List<BaseBallNumber> randomNumbers() {
return Collections.unmodifiableList(randomNumbers);
}
}
2-1. 사용자로 부터 세자리 수 입력 받음
InputView.java
입력을 받거나 출력을 하는 부분은 view 패키지 안에 존재하도록 설정을 하였습니다.
그 중 입력을 받는 부분은 InputView 클래스에 작성을 하였습니다.
값을 입력 받으면서, 바로 3자리 수를 입력하였는지 확인을 할 까 생각을 하였지만 이내 마음을 바꿔 먹었습니다.
3자리 수가 맞는지 확인하는 기능은 domain에 작성하는 것이 맞다고 생각하였으며, 값에 대한 검증은 중요한 부분이므로 객체가 생성 되는 시점에 검증이 이뤄져야 더 강한 구속력(?)을 갖을 수 있을 것이라고 판단하여 값을 사용하는 객체를 생성할 때 작성하기로 생각하였습니다.
public static String requireBaseBallNumber() {
System.out.println(REQUIRE_BASEBALL_NUMBER);
return Console.readLine();
}
2-2. 사용자로 부터 입력 받은 세자리 수 검증
BaseBallNumbers.java
위에서 말한 객체가 생성 되는 시점에 검증을 하는 부분입니다.
우선 validateUserNumbers 메서드에서 유효성 검증을 진행하고 있으며,
convertBaseBallNumberFormat을 통해 사용하기 편하도록 포맷을 변경하였습니다.
package baseball.domain;
import baseball.exception.InvalidIntegerLengthException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
public class BaseBallNumbers {
private static final int START_INCLUSIVE = 0;
private static final int END_INCLUSIVE = 2;
private static final int BASEBALL_NUMBERS_LENGTH = 3;
private static List<BaseBallNumber> userBaseballNumbers;
public BaseBallNumbers(String userNumbers) {
validateUserNumbers(userNumbers);
this.userBaseballNumbers = convertBaseBallNumberFormat(userNumbers);
}
private List<BaseBallNumber> convertBaseBallNumberFormat(String userNumbers) {
List<BaseBallNumber> userNumbersList = new ArrayList<>();
for (int i = 0; i < userNumbers.length(); i++) {
userNumbersList.add(new BaseBallNumber(userNumbers.charAt(i)));
}
return userNumbersList;
}
private void validateUserNumbers(String userNumbers) {
if (userNumbers.length() != BASEBALL_NUMBERS_LENGTH) {
throw new InvalidIntegerLengthException();
}
}
public int calculateBallCount(List<BaseBallNumber> randomNumbers) {
return (int) IntStream.rangeClosed(START_INCLUSIVE, END_INCLUSIVE).filter(i -> isBall(i, randomNumbers)).count();
}
private boolean isBall(int targetIndex, List<BaseBallNumber> randomNumbers) {
BaseBallNumber randomBaseBallNumber = randomNumbers.get(targetIndex);
BaseBallNumber userBaseBallNumber = userBaseballNumbers.get(targetIndex);
return !randomBaseBallNumber.equals(userBaseBallNumber) && contains(randomBaseBallNumber);
}
public int calculateStrikeCount(List<BaseBallNumber> randomNumbers) {
return (int) IntStream.rangeClosed(START_INCLUSIVE, END_INCLUSIVE).filter(i -> isStrike(i, randomNumbers)).count();
}
private boolean isStrike(int targetIndex, List<BaseBallNumber> randomNumbers) {
BaseBallNumber randomBaseBallNumber = randomNumbers.get(targetIndex);
BaseBallNumber userBaseBallNumber = userBaseballNumbers.get(targetIndex);
return randomBaseBallNumber.equals(userBaseBallNumber);
}
public boolean contains(BaseBallNumber compareBaseBallNumber) {
return userBaseballNumbers.stream()
.anyMatch(baseBallNumber -> baseBallNumber.equals(compareBaseBallNumber));
}
}
3. 1번에서 생성한 세자리 난수와 사용자로 부터 입력 받은 세자리 수를 비교.
BaseBallNumbers.java
위에서 살펴본 BaseBallNumbers 클래스입니다.
너무 많은 기능을 하나의 클래스로 둔 것은 아닌지 걱정이 되어 유틸 패키지를 생성하고 그 안에 ball과 strike를 확인하는 기능을 가진 클래스를 생성하는 것이 더 좋은 방법인지 고민이 되었습니다.
하지만 따로 유틸 패키지를 만들지 않은 이유는 유틸리티를 따로 생성하여 사용하는 것은 객체지향적 프로그래밍에 적합하지 않은 방법이라고 판단하였기 때문입니다.
유틸리티 클래스는 절차적 언어에서 계승된 방법이며 객체 지향적으로 프로그래밍을 하길 원한다면 지양하는 것이 현재는 더 좋다고 판단하였습니다.
추가적으로 최초 중첩 for문을 이용하여 ball 개수와 strike 개수를 확인하였지만, stream을 이용하여 조금 더 간결하게 수정을 하였습니다. stream을 이용하고 나니 코드가 한층 간결해진 것을 느낄 수 있습니다.
package baseball.domain;
import baseball.exception.InvalidIntegerLengthException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
public class BaseBallNumbers {
private static final int START_INCLUSIVE = 0;
private static final int END_INCLUSIVE = 2;
private static final int BASEBALL_NUMBERS_LENGTH = 3;
private static List<BaseBallNumber> userBaseballNumbers;
public BaseBallNumbers(String userNumbers) {
validateUserNumbers(userNumbers);
this.userBaseballNumbers = convertBaseBallNumberFormat(userNumbers);
}
private List<BaseBallNumber> convertBaseBallNumberFormat(String userNumbers) {
List<BaseBallNumber> userNumbersList = new ArrayList<>();
for (int i = 0; i < userNumbers.length(); i++) {
userNumbersList.add(new BaseBallNumber(userNumbers.charAt(i)));
}
return userNumbersList;
}
private void validateUserNumbers(String userNumbers) {
if (userNumbers.length() != BASEBALL_NUMBERS_LENGTH) {
throw new InvalidIntegerLengthException();
}
}
public int calculateBallCount(List<BaseBallNumber> randomNumbers) {
return (int) IntStream.rangeClosed(START_INCLUSIVE, END_INCLUSIVE).filter(i -> isBall(i, randomNumbers)).count();
}
private boolean isBall(int targetIndex, List<BaseBallNumber> randomNumbers) {
BaseBallNumber randomBaseBallNumber = randomNumbers.get(targetIndex);
BaseBallNumber userBaseBallNumber = userBaseballNumbers.get(targetIndex);
return !randomBaseBallNumber.equals(userBaseBallNumber) && contains(randomBaseBallNumber);
}
public int calculateStrikeCount(List<BaseBallNumber> randomNumbers) {
return (int) IntStream.rangeClosed(START_INCLUSIVE, END_INCLUSIVE).filter(i -> isStrike(i, randomNumbers)).count();
}
private boolean isStrike(int targetIndex, List<BaseBallNumber> randomNumbers) {
BaseBallNumber randomBaseBallNumber = randomNumbers.get(targetIndex);
BaseBallNumber userBaseBallNumber = userBaseballNumbers.get(targetIndex);
return randomBaseBallNumber.equals(userBaseBallNumber);
}
public boolean contains(BaseBallNumber compareBaseBallNumber) {
return userBaseballNumbers.stream()
.anyMatch(baseBallNumber -> baseBallNumber.equals(compareBaseBallNumber));
}
}
4. 게임 진행 여부 확인.
ResultView
모든 안내 메세지는 view 에서 실행할 수 있도록 작성을 하였습니다.
StringBuilder 메서드를 이용하면서 조금 더 최적화를 진행하였으며, 모든 메세지는 static final 접근 제한자를 갖도록 하였습니다.
package baseball.view;
public class ResultView {
private static final String GAME_END_MESSAGE = "게임 종료";
private static final String ALL_CLEAR_MESSAGE = "3개의 숫자를 모두 맞히셨습니다! 게임 종료";
private static final String STATE_FORMAT = "%d%s ";
private static final String BALL = "볼";
private static final String STRIKE = "스트라이크";
private static final String NOTHING = "낫싱";
private static final Integer ZERO_COUNT = 0;
public static void printResult(int ballCount, int strikeCount) {
printBall(ballCount);
printStrike(strikeCount);
printNothing(ballCount, strikeCount);
System.out.println();
}
public static void printResultInfoMessage() {
System.out.println(ALL_CLEAR_MESSAGE);
}
public static void printGameEnd() {
System.out.println(GAME_END_MESSAGE);
}
private static void printNothing(int ballCount, int strikeCount) {
if (ballCount == ZERO_COUNT && strikeCount == ZERO_COUNT) {
System.out.print(NOTHING);
}
}
private static void printStrike(int strikeCount) {
if (strikeCount != ZERO_COUNT) {
System.out.printf(STATE_FORMAT, strikeCount, STRIKE);
}
}
private static void printBall(int ballCount) {
if (ballCount != ZERO_COUNT) {
System.out.printf(STATE_FORMAT, ballCount, BALL);
}
}
}
어려웠던 점
사실 이번에는 기능구현보다는 외적인 부분이 더 어려웠던 것 같습니다.
1. 하나의 메소드는 하나의 일을 하게 작성.
가장 핵심적인 부분이라고 생각합니다.
코드가 간결해지고, 읽는 사람들이 이해하기 더 편하게 만들어 주는데 도움을 준다고 생각하여 이 부분에 많은 노력을 들였습니다.
프리코스 기간이 마무리 되고 다시 야구 게임을 살펴 보았을 때, '아 이정도 밖에 구현을 못했었구나' 라고 생각할 수 있을 정도로 더 많은 발전을 하도록 노력하겠습니다.
2. 변수와 메소드 이름을 짓는 방법.
남들이 보았을 때, 주석 없이도 변수와 메소드가 무슨 일을 하는지 잘 전달할 수 있도록 이름을 짓는 것이 생각보다 어려웠습니다.
많은 책을 살펴보고, 다른 사람들의 코드도 참조하면서 더 발전을 하도록 노력해봐야 할 것 같습니다.
이렇게 작명이 힘든 것이었다니.. 제 이름을 지어주신 할아버지께도 다시 한번 감사드려야 겠다는 생각이 새삼스레 드는 하루입니다.
3. 컨벤션 적용
기본적으로는 IDE에서 체크해주지만, 놓치는 부분도 있다고 생각합니다.
항상 생각하면서 습관을 들여야 하는데 코드를 다 짜고 살펴보면 놓치는 부분이 문득 보이는 것을 발견할 수 있었습니다.
컨벤션은 가독성을 더 높일 수 있는 좋은 방법이라고 생각합니다.
앞으로 더 습관화를 들이도록 노력하겠습니다.
4. 적절한 시점에, 적절한 내용의 커밋 메세지
마무리 하고 보니 가장 아쉬운 점이 많은 부분이었습니다.
구현하는 것에 심취하여 계속 구현만 하다 보니 기능 몇개가 다 완성을 되고 커밋을 하는 상황이 발생하기도 하였습니다.
이를 통해 처음 작성하는 README 파일이 중요하다는 것을 다시 느낄 수 있었습니다.
다음 프로젝트는 README를 처음부터 전략적으로 좀 더 세세하게 작성을 해보고, 적절한 시점에 적절한 내용의 커밋 메세지를 보내는 데 집중을 해보도록 하겠습니다.
5. 무분별한 사용자 예외처리
이번 프로젝트에서 너무 무분별한 사용자 예외처리를 진행한 것 같아 반성하였습니다.
IllegalArgumentException 으로 충분한 커버를 할 수 있었음에도 따로 클래스를 만든 것에 대해 반성을 합니다.
사용자 예외처리에 대한 좋은 글이 있어 아래 reference에 남겨놓습니다.
느낀점
README 파일의 중요성
첫 README 파일은 프로젝트의 청사진과 같은 역할을 하는 것 같습니다.
한번에 완벽하게 README를 만들 필요까지는 없지만, 구현해야 하는 기능 목록이 어떤 것이 있는지는 정확하게 작성을 해야 할 것 같습니다.
잘 작성한 README는 정확한 시점에 정확한 Commit message를 보낼 수 있는 기반을 마련해 주는 역할을 한다는 것을 느꼈습니다.
다음 프로젝트는 기능별, 정확한 commit message를 보내는 것에 조금 더 신경을 써 보도록 하려 합니다.
객체의 역할과 책임
객체들의 역할과 책임을 나누면서, ball과 strike 개수를 계산하는 행위를 어떤 객체의 몫으로 둘지에 대한 고민을 많이 하였습니다.
"여기서 클래스를 더 분리하는 것이 맞는 것인가"
하는 물음에 대해 정답은 아닐지라도 제 나름대로 분석하고 답하여 코드를 짜보았습니다.
사용자에게서 야구번호를 받는 부분에 대한 코드를 작성을 할 때도 많은 고민을 하였습니다.
View에서 입력 예외를 검사를 하는게 맞는 것일지, Domain 에서 검사하는게 맞는 것일지에 대한 생각을 많이 하였는데
입력 예외를 확인하는 것은 Domain의 역할이라고 판단을 하였으며, 객체가 생성되는 시점에 검사를 하는것이 더 강한 구속력을 갖을 수 있을 것이라는 판단에 이르렀습니다.
끝으로
프리코스 첫번째 주제인 야구게임을 구현하면서 시간 가는줄을 몰랐습니다.
시험을 즐겁게 치룰 수 있다는 것이 저에게는 이색적인 경험으로 다가왔습니다.
이번 프로젝트 한 주간 진행하면서 많은 것을 느낄 수 있는 좋은 기회였던 것 같습니다.
다음 프로젝트를 진행할 때는 이번 보다는 조금 더 발전한 모습을 보일 수 있도록 노력하겠습니다.
나 자신과 경쟁에서 이길 수 있도록 파이팅 넘치는 마인드를 잃지 않도록 노력하겠습니다.
테스트는 3주지만 프로그래머로 살아갈 날은 이 보다도 더 훨씬 긴 레이스가 될 것입니다.
좋은 마인드로 지속 가능한 발전을 해나갈 수 있는 것이 중요하다고 생각합니다.
일신 우일신, 날이 갈수록 더욱 발전하는 사람이 되는 사람이 되자.
Reference
https://tecoble.techcourse.co.kr/post/2020-08-17-custom-exception/