🏴 우테코 프리코스 과제 1 - 숫자야구
오늘부로 우테코 프리코스가 시작이 되었다. 워낙 할게 많고 내년에 예정 계획도 있던지라 지원을 할까말까 고민해봤지만, 밑져야 본전이고 어차피 지식도 쌓을겸 해보면 많이 배워나갈 수 있을것 같아서 일단 과제라도 도전을 해보기로 결심했다 😉 사실 오늘 하루종일 이런저런 생각에 마음이 싱숭생숭해서 기분이 저기압이다😢 이럴때일수록 그냥 우직하게 하는거지! 화이팅 나 자신🪜
🚀 기능 요구 사항 분석
기능을 구현하기 전, 기능 목록을 만들어야한다. 일단 기능 요구 사항을 꼼꼼히 분석하고 정리해 보기로 했다. 🧐
◼️게임 시작 시 메시지 출력
게임 시작 시 "숫자 야구 게임을 시작합니다." 를 출력해야 한다.
◼️컴퓨터의 숫자 생성
컴퓨터는 1부터 9까지 서로 다른 3개의 임의 숫자를 생성해야 한다.
◼️사용자 입력
- 사용자는 3자리의 서로 다른 숫자를 입력해야 한다.
- 이때, 사용자가 잘못된 값을 입력한 경우 IllegalArgumentException을 발생시켜 애플리케이션을 종료해야 한다.
◼️게임 진행
- 사용자가 컴퓨터가 생각한 숫자를 맞출 때까지 게임이 진행되어야 한다.
- 사용자가 숫자를 입력하면 컴퓨터는 답과 사용자가 제출한 답을 비교하여 결과를 출력한다.
- 입력한 수에 대한 결과를 볼, 스트라이크 개수로 표시"볼," "스트라이크," "낫싱"을 출력한다.
◼️게임 종료 조건
- 사용자가 3스트라이크를 달성하면 게임을 종료할 수 있어야 한다.
◼️게임 재시작 또는 종료
- 3스트라이크 달성시, 사용자가 게임을 종료할지 계속할지 선택할 수 있어야 한다.
- 게임 종료가 되면 아래 메시지를 출력한다.
- 3개의 숫자를 모두 맞히셨습니다! 게임 종료
게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.
🎯 프로그래밍 요구 사항 분석
특히 눈여겨본 요구 사항은 다음과 같다.
◼️build.gradle 파일을 변경할 수 없고, 외부 라이브러리를 사용하지 않아야한다는 점.
◼️Java 코드 컨벤션 가이드를 준수하며 프로그래밍한다는 점.
▪️블럭 들여쓰기: 새 블록이 열릴 때마다 들여쓰기를 네 칸씩( +4 스페이스 ) 증가
public void exampleMethod() {
if (condition) {
// 네 칸 들여쓰기
doSomething();
}
}
▪️열 제한: Java 코드의 한 줄은 최대 120자로 제한
▪️ 줄 바꿈 후 다음 줄은 원래 줄에서 +8칸 이상 들여쓰기
▪️ 수직 빈 줄: 가독성을 향상시키기 위해서 어디든(예를 들면 논리적으로 코드를 구분하기 위해 문장 사이) 사용
◼️제한된 라이브러리: ...missionutils.Randoms와 ...missionutils.Console에서 제공하는 API를 사용하여 구현
▪️랜덤한 값을 추출할 때 ...Randoms의 pickNumberInRange() 메서드를 활용
▪️사용자의 입력을 받을 때...Console의 readLine() 메서드를 사용
✏️ 과제 진행 요구 사항 분석
여기서 눈여겨본 요구 사항은 다음과 같다.
◼️기능을 구현하기 전 docs/README.md에 구현할 기능 목록을 정리해 추가한다
📜 실제 구현기
🔘 기초 작업
1. 자바 코드 컨벤션 등록
먼저 프로그래밍 요구사항에 따라, Java 코드 컨벤션을 신경쓰는 시간을 줄이기 위해 우테코 코드 포매터를 등록해 활용하였다. 자세한 정보글은 아래를 참고하였다. (정보글에 감사를 올립니다 ...)
[IntelliJ] 코드 스타일을 설정해보자 (feat.우테코)
오늘부터 시작한 우아한 테크코스 6기의 프리코스를 수행하면서기능 구현에 집중하느라 코드 포맷을 정리하지 못해, 기능 구현 이후 Commit 직전일일히 객체를 찾아다니며 Command + Option + L (코드
velog.io
2. 깃 커밋 태그 형식 통일
프로그래밍을 할 때 이제까지 커밋명은 그때그때 생각나는 설명구를 붙여왔는데, 깃 커밋 태그의 스타일 규정을 정해놓는 방법이 있다는 것을 알게 되었다. 따라서 아래 규칙에 따라 기능별 커밋을 했다.
Feat | 새로운 기능을 추가할 경우 |
Fix | 버그를 고친 경우 |
Design | CSS 등 사용자 UI 디자인 변경 |
!BREAKING CHANGE | 커다란 API 변경의 경우 |
!HOTFIX | 급하게 치명적인 버그를 고쳐야하는 경우 |
Style | 코드 포맷 변경, 세미 콜론 누락, 코드 수정이 없는 경우 |
Refactor | 프로덕션 코드 리팩토링 |
Comment | 필요한 주석 추가 및 변경 Docs 문서를 수정한 경우 |
Test | 테스트 추가, 테스트 리팩토링(프로덕션 코드 변경 X) |
Chore | 빌드 태스트 업데이트, 패키지 매니저를 설정하는 경우(프로덕션 코드 변경 X) |
Rename | 파일 혹은 폴더명을 수정하거나 옮기는 작업만인 경우 |
Remove | 파일을 삭제하는 작업만 수행한 경우 |
🔘 코드 구현
처음부터 좋은 코드를 작성하려 하니, 고려해야할 점이 많은것 같아서 막막했다 😐...그래서 일단 큼직한 클래스 단위로 무엇을 작성해야할지부터 생각해보았다. 내가 생각한 가장 큼직한 줄기는 다음과 같다.
GameController | - 게임의 전체 흐름과 동작을 관리한다. - 게임을 시작, 종료한다. - 게임 상태값을 관리하고 결과를 출력하는 메서드를 포함한다. - 결과를 판별한다. |
Computer | - 게임 시작 시 3자리 서로 다른 숫자를 생성한다. - 플레이어의 입력값을 받아 스트라이크/볼/낫싱을 계산한다. |
Application | - 게임을 실행하는 진입점 (프로그램 요구사항에 따름.) - GameController 클래스를 생성하여 게임을 시작한다. |
위에서 더욱 세분화하여 Player,유효성 검사등의 클래스를 생성할 수도 있지만, Player라 해봤자 input값을 입력 받는 것 빼고는 아무 기능이 없기에 굳이 클래스생성의 이유를 느끼지 못하여, 일단 바로 생각난 위의 세 클래스를 큰 틀로 먼저 구현해보기로 했다.
📑 폴더 구조
📑 domain/Computer.java
package baseball.domain;
import camp.nextstep.edu.missionutils.Randoms;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
public class Computer {
private List<Integer> secretNumbers;
public void setSecretNumbers(List<Integer> secretNumbers) {
this.secretNumbers = secretNumbers;
}
public List<Integer> generateRandomNumbers(int startInclusive, int endInclusive, int count) {
List<Integer> computer = new ArrayList<>();
while (computer.size() < 3) {
int randomNumber = Randoms.pickNumberInRange(startInclusive, endInclusive);
if (!computer.contains(randomNumber)) {
computer.add(randomNumber);
}
}
setSecretNumbers(computer);
return secretNumbers;
}
public Game computeResult(List<Integer> userInputNumbers) {
// 사용자 입력과 비교하여 스트라이크와 볼 계산
int strikes = 0;
int balls = 0;
strikes = (int) IntStream.range(0, 3)
.filter(index -> userInputNumbers.get(index).equals(secretNumbers.get(index)))
.count();
int matchingNumberCount = (int) userInputNumbers.stream()
.filter(secretNumbers::contains)
.count();
balls = matchingNumberCount - strikes;
return new Game(strikes, balls);
}
}
- Computer는 1에서 9까지 서로 다른 임의의 수 3개를 변수 secretNumbers로 갖는다.
- generateRandomNumbers() : secretNumbers를 생성한다.
- computeResult() : 사용자 입력과 비교하여 스트라이크와 볼을 계산한다.
- 0~3 인덱스위치 userInputNumbers의 각 숫자와 secretInputNumbers의 각 숫자가 같은지 비교해 strikes를 계산
- userInputNumbers의 각 숫자가 secretNumbers에 포함되는 갯수를 계산
- 위의 두 결과를 빼서 balls를 계산함
📑 domain/Game.java
public class Game {
private int strikes;
private int balls;
private int targetCount=3;
public Game(int strikes, int balls) {
this.strikes = strikes;
this.balls = balls;
}
public boolean isWin() {
return strikes == targetCount;
}
public String getResultString() {
if (isWin()) {
return "3스트라이크";
}
String result = "";
if (balls > 0) {
result += balls + "볼 ";
}
if (strikes > 0) {
result += strikes + "스트라이크 ";
}
if (result.isEmpty()) {
return "낫싱";
}
return result.trim();
}
}
- Game은 Computer에서 게산된 strikes 와 balls의 게임 결과값을 반환한다.
- getResultString()은 아주 직관적으로 작성함...ㅎ
📑 Application.java
package baseball;
public class Application {
public static void main(String[] args) {
GameController gameController = new GameController();
gameController.run();
}
}
- gameController를 실행하는 메인 클래스
📑 GameController.java
package baseball;
import baseball.domain.Computer;
import baseball.domain.Game;
import camp.nextstep.edu.missionutils.Console;
import java.util.ArrayList;
import java.util.List;
public class GameController {
private static final String START_MSG = "숫자 야구 게임을 시작합니다.";
private static final String INPUT_MSG = "숫자를 입력해주세요 : ";
private static final String ENDGAME_MSG = "3개의 숫자를 모두 맞히셨습니다! 게임 종료\n게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.\n";
private static final int START_INCLUSIVE = 1;
private static final int END_INCLUSIVE = 9;
private static final int COUNT = 3;
private Computer computer;
Boolean isWin = false;
final int REPLAY = 1;
public GameController() {
computer = new Computer();
}
public void run() throws IllegalArgumentException {
startGame();
endGame();
}
public void startGame() throws IllegalArgumentException {
System.out.println(START_MSG);
// 컴퓨터 랜덤 숫자 생성
List<Integer> randomNumbers = computer.generateRandomNumbers(START_INCLUSIVE, END_INCLUSIVE, COUNT);
do {
// 유저 숫자 추측값 받기
String userInput = readUserInput(INPUT_MSG);
System.out.println(userInput);
// 유저 입력 String -> List<Integer>로 변환
List<Integer> userInputNumbers = parseStringNumbers(userInput);
// count 에러 처리
if (userInputNumbers.size() != COUNT) {
throw new IllegalArgumentException();
}
// 게임 결과 계산
Game game = computer.computeResult(userInputNumbers);
if (game.isWin()) {
isWin = true;
}
// 게임 결과 출력
System.out.println(game.getResultString());
} while (isWin == false); // 답 맞출때까지 반복
}
public void endGame() {
System.out.print(ENDGAME_MSG);
// replay 선택시 게임 재실행
if (getInputNum() == REPLAY) {
isWin = false;
startGame();
endGame();
}
}
// 유저 숫자 추측값 받는 함수
public static String readUserInput(String prompt) {
System.out.print(INPUT_MSG);
return Console.readLine();
}
// 유저가 입력한 숫자값 받는 함수
private int getInputNum() {
int inputNum = Integer.parseInt(Console.readLine());
return inputNum;
}
// 유저 입력 String -> List<Integer>로 변환
public static List<Integer> parseStringNumbers(String userInput) {
List<Integer> userInputNumbers = new ArrayList<>();
for (char digit : userInput.toCharArray()) {
if (Character.isDigit(digit)) {
userInputNumbers.add(Integer.parseInt(String.valueOf(digit)));
}
// 유효성 검사 코드 추가 필요
}
return userInputNumbers;
}
}
- isWin을 flag로 두어 do-while문을 사용해 답을 맞출때까지 답을 맞추는 과정을 반복한다.
- 그 외 입력값을 받고 이를 형변환하는 보조함수를 두었다.
과연 이게 최선인가...?
뭔가 아주 너저분하다🐣;
코드 작성후 다시보니 고칠점들이 보였다. 처음부턴 완벽할 수 없다. 하나씩 고쳐가보자 🧐!
🛠️ 코드 리팩토링
🔧 constants 폴더 생성 &상수 분리
package baseball.constants;
public class GameConstants {
public static final String START_MSG = "숫자 야구 게임을 시작합니다.";
public static final String INPUT_MSG = "숫자를 입력해주세요 : ";
public static final String ENDGAME_MSG = "3개의 숫자를 모두 맞히셨습니다! 게임 종료\n게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.\n";
public static final int START_INCLUSIVE = 1;
public static final int END_INCLUSIVE = 9;
public static final int COUNT = 3;
public static final int REPLAY = 1;
}
상수 클래스 분리는 상수 데이터를 별도 클래스로 모듈화하여 코드의 가독성을 개선하고 유지 보수성을 향상시키며, 중복 정의를 방지하는 이점이 있다. 따라서 변하지않는 값을 모두 Constants에 분리하였다.이후 에러처리 코드도 이 폴더에 넣을 것을 염두하였다.
그리고 상수들과 관련된 코드 부분들을 모두 알맞게 수정해주었다.
🔧 UserInputHandler 클래스 생성 및 기능 분리
package baseball;
import camp.nextstep.edu.missionutils.Console;
import java.util.ArrayList;
import java.util.List;
public class UserInputHandler {
public String readUserInput() {
return Console.readLine();
}
public int getInputNum() {...}
public List<Integer> parseStringNumbers(String userInput) {...}
}
얼마전 읽은 책에서 '코드를 재사용 가능하고 일반화할 수 있게 작성하라'는 코드 품질의 핵심 요소가 떠올랐다.
이에따라 User의 Input값 처리를 담당하는 UserInputHandler를 기존 컨트롤러 코드에서 분리했다.
🔧 GameController에 UserInputHandler 클래스 생성자 주입
public class GameController {
private Computer computer;
private UserInputHandler userInputHandler;
public GameController() {
computer = new Computer();
userInputHandler = new UserInputHandler();
}
Computer와 UserInputHandler 클래스의 인스턴스를 GameController 클래스 내에서 생성자 주입(Constructor Injection)을 했다. 이것은 GameController가 Computer와 UserInputHandler 클래스에 의존한다는 것을 명시한다.
더불어 이는 의존성을 외부에서 주입하므로 클래스 간의 결합도(의존성)를 낮추고, 테스트 용이성을 향상시킨다는 장점이 있다. 또한,이렇게 하면 코드가 더 모듈화되고, 유연성과 테스트 용이성이 향상된다.
🔧 ( GameController) Do-while => while로 수정 & isWin=>ContinueGame 변수명 변경
public class GameController {
private Computer computer;
private UserInputHandler userInputHandler;
Boolean continueGame = true;
public GameController() {
computer = new Computer();
userInputHandler = new UserInputHandler();
}
public void run() throws IllegalArgumentException {
startGame();
endGame();
}
public void startGame() throws IllegalArgumentException {
System.out.println(START_MSG);
List<Integer> randomNumbers = computer.generateRandomNumbers(START_INCLUSIVE, END_INCLUSIVE, COUNT);
System.out.println("###randomNumbers = " + randomNumbers);
System.out.println(INPUT_MSG);
while (continueGame) {
String userInput = userInputHandler.readUserInput();
System.out.println("###userInput = " + userInput);
List<Integer> userInputNumbers = userInputHandler.parseStringNumbers(userInput);
if (userInputNumbers.size() != COUNT) {
throw new IllegalArgumentException("사용자 입력값의 크기가 같지 않습니다.");
}
Game game = computer.computeResult(userInputNumbers);
System.out.println(game.getResultString());
if (game.isWin()) {
continueGame = false;
}
}
}
public void endGame() {
System.out.print(ENDGAME_MSG);
if (userInputHandler.getInputNum() == REPLAY) {
continueGame = true;
run();
}
}
}
기존의 코드가 헷갈려서 가만보니 변수명때문이었던것 같다 -_-; 로직을 보니 do-while로 쓸필요가 굳이없어서(왜이렇게작성했지;) 이부분을 while로 수정했고, isWin이던 애매한 변수명도 continueGame으로 수정! 이렇게만 바꿨을뿐인데 코드가 훨 보기 편해짐.
🔧 유효성 검사 클래스 Validator 클래스 추가 & 에러 코드 분리
package baseball;
import static baseball.constants.ErrorMessages.EMPTY_INPUT_MSG;
import static baseball.constants.ErrorMessages.INVALID_NUMBER_MSG;
import static baseball.constants.ErrorMessages.INVALID_SIZE_MSG;
public class Validator {
public void checkValidNumber(String input) {
if (!input.matches("\\d+")) {
throw new IllegalArgumentException(INVALID_NUMBER_MSG);
}
}
public void checkInputSize(String input, int requiredSize) {
checkNotEmpty(input);
if (input.length() != requiredSize) {
throw new IllegalArgumentException(INVALID_SIZE_MSG);
}
}
public void checkNotEmpty(String input) {
if (input == null || input.trim().isEmpty()) {
throw new IllegalArgumentException(EMPTY_INPUT_MSG);
}
}
}
package baseball.constants;
public class ErrorMessages {
public static final String INVALID_NUMBER_MSG = "숫자만 입력이 가능합니다.";
public static final String INVALID_SIZE_MSG = "사용자 입력값의 크기가 올바르지 않습니다.";
public static final String EMPTY_INPUT_MSG = "값을 입력해주세요.";
}
향후 유지보수를 생각해보았을때 아예 유효검사 클래스를 따로 만드는게 맞다고 생각되었다.따라서 에러 코드를 정리하고 Validator 클래스를 별도 분리해서 유효성,에러 관련 작업을 이 클래스에서 처리하도록 하였다.
🔧 테스트 코드 작성
// 작성한 테스트 코드 일부 예시...
@Test
public void testComputeResult() {
Computer computer = new Computer();
// 시크릿 넘버 [1, 2, 3]
List<Integer> secretNumbers = new ArrayList<>();
secretNumbers.add(1);
secretNumbers.add(2);
secretNumbers.add(3);
computer.setSecretNumbers(secretNumbers);
// 입력 숫자 [3, 2, 1]
List<Integer> userInputNumbers = new ArrayList<>();
userInputNumbers.add(3);
userInputNumbers.add(2);
userInputNumbers.add(1);
Game game = computer.computeResult(userInputNumbers);
assertNotNull(game);
assertEquals(1, game.getStrikes());
assertEquals(2, game.getBalls());
}
위와같이 확인할 모듈 별 테스트 코드를 작성했다. 입출력,Validator,Computer 클래스의 각 모듈들에 대해 테스트 코드를 작성했다.
원래 더 상세히,그리고 각 기능별로 나누어 모두 점검 및 작성해야하는게 맞지만...지금 이번주차 마감 과제가 8~9개라 도무지 시간이 없어 이로 마무리하기로했다.
+ 더불어 짜잘한 에러들도 모두 수정해주었다.
🖥️ 최종 코드 & 기능 명세
Class | Module name | description |
Application | Main() | 프로그램 시작 지점 |
GameController | Run() | 전체 프로그램 start(), end() 실행 |
startGame() | 프로그램 시작, 답을 맞출 때까지 게임 반복 | |
endGame() | 프로그램 종료, 재시작 여부를 출력 | |
Computer | generateRandomNumbers(int startInclusive, int endInclusive, int count) | 중복되지않은 범위 내 랜덤 숫자를 count 수만큼 선정 |
computeResult(List<Integer> userInputNumbers) | 컴퓨터의 숫자들을 사용자 입력과 비교하여 스트라이크와 볼 숫자 카운트 | |
Game | isWin() | 사용자가 이겼는지 결과를 Boolean으로 반환 |
getResultString() | 결과에 따른 출력 문구를 반환 | |
UserInputHandler | readUserInput() | 유저의 입력 값을 받는 메소드 |
parseStringNumbers(String userInput) | 유저의 입력 값을 List<Integer>숫자배열로 변환 ex)”123”=>[1,2,3] | |
Validator | checkValidNumber (String input) | 숫자만 입력했는지 검증 후 아니면 에러 |
checkInputSize(String input, int requiredSize) | 입력 사이즈가 요구된 사이즈와 같은 지 검증 후 아니면 에러 | |
checkNotEmpty(String input) | 빈 값을 입력 했는 지 검증 |
이렇게 완성된 최종 폴더는 위와같은 구조를 가진다. 사실 과제나 주어진 업무에 따라 코드를 짜는 일은 많았지만 기초 뼈대부터 설계하는 작업은 별로 해본 적이 없다. 본 프로젝트를 통해 간단한 게임을 만드는 것이더라도 생각보다 클래스의 기능을 직접 정의하고 이의 코드를 작성하는 것이 어렵다는 것을 알게되었다. 더불어 객체지향에 대해 고민하고 수정해나가는 과정을 통해 많은 것을 느낄 수 있었다. 더 나은 코드를 짜는 개발자가 되고싶다는 의지도 샘솟았다. 😃
완성된 최종 코드는 아래 링크를 참고.
https://github.com/hyeonjeong-ko/java-baseball-6
GitHub - hyeonjeong-ko/java-baseball-6: 우테코 프리코스 첫번째 과제
우테코 프리코스 첫번째 과제. Contribute to hyeonjeong-ko/java-baseball-6 development by creating an account on GitHub.
github.com
👀 구현 과정에서의 실수 및 성찰
// 컴퓨터 랜덤 숫자 생성
List<Integer> randomNumbers = computer.generateRandomNumbers(START_INCLUSIVE, END_INCLUSIVE, COUNT);
//List<Integer> randomNumbers = pickUniqueNumbersInRange(START_INCLUSIVE, END_INCLUSIVE, COUNT);
분명 실행은 올바르게 되는 것 같은데 테스트코드가 실행이 되지않았다. 당황해서 헤매다가,프로그래밍의 요구사항 중 'Random 값 추출은 camp.nextstep.edu.missionutils.Randoms의 pickNumberInRange()를 활용한다.' 는 조건을 어겼음을 깨달았다.
위 코드를 보면 알 수 있듯이, 나는 요구사항을 정확히 읽지않고 Randoms모듈중 내 맘대로 다른 함수를 활용하려 했다😅
이 부분을 해결하니 다행히 테스트 코드 또한 잘 작동되었고, 이를 계기로 구현 요구사항을 더욱 꼼꼼히 점검하고 검토해야겠다는 깨달음과 다짐을 얻을 수 있었다. 🐤
그리고 테스트를 위해 JUnit을 따로 공부하고 찾아보아야겠다. 아쉽게도 이번에는 시간이 부족해서 이에 시간을 많이 투자할 수 없었지만, 꼭 공부하고 테스트를 잘 작성해보겠다!
'개발프로젝트' 카테고리의 다른 글
[개발프로젝트] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 chapter 02- 테스트 (0) | 2023.12.20 |
---|---|
[개발프로젝트] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 chapter 01 (0) | 2023.12.19 |
utc4 (2) | 2023.11.15 |
utc3 (0) | 2023.11.07 |
utc2 (0) | 2023.10.31 |