이번 포스팅은 이전기수 프리코스 2주차 문제였던 숫자야구게임을 구현하는 과정을 작성하고 회고해보는 시간을 가져보도록 하자!
이 외에도 프로그래밍 요구사항, 과제 진행 요구사항이 있었는데 어느정도 참고해가면서 해당 문제를 풀어보았다.
처음에 설계했던 기능명세서였다. 문제는 처음 설계보다 기능 단위의 함수나 예외처리 단위가 더욱 많아지게 되면서 기능 명세서가 핵심 기능의 청사진 같은 설계가 된 것 같다. 실제 프리코스에서는 노트에 직접 기능을 설계해가면서 더욱 세분화된 기능 명세서를 작성해야겠다고 생각했다.
대략적인 기능명세서를 작성하고 개발하게되면 개발을 하다가 추가되는 부분이 생기게 되면 작업에 대한 프로세스가 꼬이거나 순간순간 로직에 대한 방향성을 잃는 것 같았다.
물론 기능을 설계할때 구성될 모든 함수나 예외처리를 한번에 잡는 것은 어렵다고 생각한다. 대신 핵심이 되거나 중요하게 여겨질 수 있는 메서드 단위의 기능들은 제대로 된 설계를 하고 기능을 구현해야 작업 속도는 물론 프로세르를 이해하고 클린코드가 되는 것 같다.
MVC 패턴을 적용하여 각각 Model, View, Controller 단위의 패키지를 만들어 구분하였다.
public class Number {
public static final int MAX_DIGITS = 3;
public static final List<Integer> ALL_DIGITS = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9));
private final String num;
public Number(String num) {
this.num = num;
}
public String getNum() {
return num;
}
public static Number createRandNumber() {
List<Integer> numbers = new ArrayList<>(ALL_DIGITS);
Collections.shuffle(numbers);
StringBuilder computerNumber = new StringBuilder();
for (int i = 0; i < MAX_DIGITS; i++) {
computerNumber.append(numbers.get(i));
}
return new Number(computerNumber.toString());
}
public Integer countStrike(Number myNumber) {
String myNum = myNumber.getNum();
int strike = 0;
for (int i = 0; i < MAX_DIGITS; i++) {
if (num.charAt(i) == myNum.charAt(i)) {
strike++;
}
}
return strike;
}
public Integer countBoll(Number myNumber) {
String myNum = myNumber.getNum();
int boll = 0;
String[] split = myNum.split("");
for (int i = 0; i < MAX_DIGITS; i++) {
if (num.charAt(i) != myNum.charAt(i) && num.contains(split[i])) {
boll++;
}
}
return boll;
}
}
Model에 포함되는 Number 클래스이다. 숫자 야구 게임에서 사용되는 숫자를 나타내는 클래스이면서 무작위로 숫자를 생성하거나, 스트라이크와 볼을 계산하는 기능을 제공한다. 여기서 내가 한가지 느낀건 Number가 너무 많은 책임을 갖고있지 않을까? 였다.
사실 Model에 Number는 사용되는 숫자를 담고있는 객체인데 이 객체 안에서 스트라이크의 개수나 볼의 개수를 세는것이 타당한가? 라는 생각이 들었다.
해당 기능을 구현할때 MVC 패턴을 service 단위까지 계층화했어야지 싶다.
public class BaseBallView {
private final Scanner scanner;
public BaseBallView(InputStream in) {
this.scanner = new Scanner(in);
}
public void gameStart() {
System.out.println("숫자 야구 게임을 시작합니다.");
}
public boolean isContinue() {
System.out.println("게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.");
String toggleContinue = scanner.nextLine();
if (toggleContinue.equals(GAME_START)) {
return true;
} else if (toggleContinue.equals(GAME_STOP)) {
return false;
}
throw new IllegalArgumentException("1 또는 2만 입력해야 합니다.");
}
public boolean adviceResult(Integer strike, Integer boll) {
if (strike == ALL_STRIKE) {
return processAllStrike();
} else if (strike == NO_STRIKE && boll == NO_BOLL) {
return processNothing();
} else {
return processStrikeAndBoll(strike, boll);
}
}
private static boolean processStrikeAndBoll(Integer strike, Integer boll) {
String resultStrike = "";
String resultBoll = "";
if (boll != NO_BOLL) {
resultBoll = String.format("%s볼 ", boll);
}
if (strike != NO_STRIKE) {
resultStrike = String.format("%s스트라이크", strike);
}
System.out.printf("%s%s\n", resultBoll, resultStrike);
return false;
}
private static boolean processNothing() {
System.out.println("낫싱");
return false;
}
private static boolean processAllStrike() {
System.out.println("3스트라이크");
System.out.println("3개의 숫자를 모두 맞히셨습니다! 게임 종료");
return true;
}
public Number inputMyNumber() {
System.out.print("숫자를 입력해주세요 : ");
String number = scanner.nextLine();
validateThreeDigitNumericInput(number);
validateDifferentNumber(number);
return new Number(number);
}
public void validateThreeDigitNumericInput(String number) {
if (!number.matches("^\\d{3}$")) {
throw new IllegalArgumentException("입력은 숫자 및 3글자 여야 합니다.");
}
}
public void validateDifferentNumber(String number) {
Set<Character> digits = new HashSet<>();
for (char c : number.toCharArray()) {
digits.add(c);
}
// Set 을 통해 중복제거 된 길이와 기존 String 의 길이 비교
if (digits.size() != NUMBER_LENGTH) {
throw new IllegalArgumentException("숫자는 전부 다른 숫자여야 합니다.");
}
}
}
view는 BaseBallView 클래스로 구성되어있다. 사용자에게 입/출력을 담당하는 클래스인만큼 입력에 대한 검증 메서드들이 작성되었다. 처음에는 Scanner를 메서드 내부에서 생성하여 입력을 받았었지만 해당 방법은 계속해서 새로운 Scanner 객체를 생성한다고 생각해 의존성 주입을 통해 하나의 Scanner를 재사용하는 방식을 택했다.
validateThreeDigitNumericInput 메서드의 경우 사실 숫자 / 길이 단위별로 분리되어있는 메서드였지만 정규표현식을 통해 하나의 메서드로 검증이 가능하게끔 변경하였다. 근데 생각해보면 그냥 단위별로 분리되어있는게 더 좋았을가? 싶다.
가장 고민이 많았던건 adviceResult, processStrikeAndBoll 메서드이다. 게임 종료에 대한 결과값을 어떻게 View 에서 처리하고 각각 스트라이크와 볼을 어떻게 사용자에게 제공할지 고민하였다. 구현한 기능 자체가 스트라이크와 볼을 각각 처리하다보니 View 에서도 각각 매개변수로 받아 format을 이용해 출력하는 방식을 택했다.
public class BaseBallController {
private final BaseBallView baseBallView;
public BaseBallController(BaseBallView baseBallView) {
this.baseBallView = baseBallView;
}
public void play() {
baseBallView.gameStart();
do {
Number computerNumber = Number.createRandNumber();
playRound(computerNumber);
} while (baseBallView.isContinue() != false);
}
private void playRound(Number computerNumber) {
while (true) {
Number myNumber = baseBallView.inputMyNumber();
Integer strike = computerNumber.countStrike(myNumber);
Integer boll = computerNumber.countBoll(myNumber);
boolean isGameComplete = baseBallView.adviceResult(strike, boll);
if (isGameComplete) {
break;
}
}
}
}
Controller인 BaseBallController는 BaseBallView를 주입받아 게임의 전체적인 로직을 담당했다.
게임이 돌아가고 종료될때 재시작 여부를 결정하기 위해 두개의 while문과 doWhile문을 사용하였다. 여기서 do안에서 돌아가는 while문이 조금 더 기능 단위로 분리 되는 것이 좋을 것 같아 메서드로 각 라운드가 수행하는 로직을 분리하였다.
Controller는 구현된 View와 Model을 적절하게 조회하는 기능을 수행하게 로직을 작성하였다.
늘 그렇지만 어떠한 기능을 구현하는 것은 어렵지 않은 일 이지만 좋은 설계를 하고 더욱 객체지향적이고 클린코드이냐를 만들어 내는건 어렵다. 이번 2주차 문제를 풀어보면서 어디까지 기능을 분리하는 것이 좋을까 라는 생각도 하고 과연 내가 작성한 코드가 효율적일까? 라는 생각도 하게 되었다.
코드가 막 효율적이라고 생각이 들진 않지만 MVC 패턴을 적용해 어느정도 객체지향적인 코드를 구성했다고는 생각했다. 각 단위별로의 책임을 쥐게 해주었지만 아쉬운건 역시 Model인 Number 클래스가 너무 많은 책임을 갖고 있는 것 같다는 생각이 들었다.
또한 View와 Number 단위의 테스트 클래스들을 작성하였다. 이때 사용자에게 입력을 임의로 input 하여 테스트를 하는 방식을 사용했다. 원래 같았으면 mokito를 사용했을 것 같지만,,, 프리코스는 외부 라이브러리는 사용이 불가하기에 최대한 자바 내부의 기능을 활용하여 테스트 코드를 작성해보았다.
아무튼 2주차 문제를 풀면서 많이 깨달은 것도 있다. 이전기수의 프리코스를 문제를 풀어보는것은 확실히 큰 도움이 되는 것 같다 👍
아무래도 Number의 책임이 너무 많다고 생각해 Service 계층을 추가해야겠다고 생각했다..
public class Number {
private final String num;
public Number(String num) {
this.num = num;
}
public static Number createRandNumber() {
List<Integer> numbers = new ArrayList<>(ALL_DIGITS);
Collections.shuffle(numbers);
StringBuilder computerNumber = new StringBuilder();
for (int i = 0; i < MAX_DIGITS; i++) {
computerNumber.append(numbers.get(i));
}
return new Number(computerNumber.toString());
}
public String getNum() {
return num;
}
}
기존 Model인 Number에서 createRandNumber 메서드가 어디서 구현되어야 하는가에 대한 고민을 했는데, 해당 메서드는 Number의 객체를 생성하는 부분이기 때문에 Model 단위에 남겨두었다.
public class BaseBallService {
public Integer countStrike(Number computerNumber, Number myNumber) {
String computerNum = computerNumber.getNum();
String myNum = myNumber.getNum();
int strike = 0;
for (int i = 0; i < MAX_DIGITS; i++) {
if (computerNum.charAt(i) == myNum.charAt(i)) {
strike++;
}
}
return strike;
}
public Integer countBoll(Number computerNumber, Number myNumber) {
String computerNum = computerNumber.getNum();
String myNum = myNumber.getNum();
int boll = 0;
String[] split = myNum.split("");
for (int i = 0; i < MAX_DIGITS; i++) {
if (computerNum.charAt(i) != myNum.charAt(i) && computerNum.contains(split[i])) {
boll++;
}
}
return boll;
}
}
기존에 Number에서 수행되는 메인 로직을 Service 계층으로 분리시켰다. 그러다보니 각각 컴퓨터와 자신의 번호를 매개변수로 받아 로직을 처리하게 변경하였고 테스트도 수정되었다.
사실 숫자야구게임이 확장되거나 변경될 사항은 그닥 없기에 구조를 변경하는게 큰 의미가 있겠냐 싶겠지만, 중요한건 책임을 조금 더 옳은 방향으로 리팩터링 해준 것 같다. 약간 찝찝했었는데 아무튼 변경했다!
그럼 이 포스팅은 진짜 끝 🚀