main

우테코 프리코스 1주차(숫자야구)

Log
53

처음으로 우아한테크코스 프리코스 1주차에 참여하게 되었습니다.😊
설레는 마음과 함께 마지막주차까지 열심히 참여해보려합니다!

1주차 주제는 숫자야구였는데요, 익숙한 주제이기도해서 어떤 식으로 게임이 이뤄지는 생략하겠습니다.
아래는 우테코에서 공유해준 깃허브링크입니다.
미션의 기능요구사항, 프로그래밍 요구사항, 과제 진행 요구 사항은 깃허브에서 확인하실 수 있습니다.
1주차 미션 - 숫자야구


먼저 저는 미션의 기능요구사항, 프로그래밍 요구사항, 과제 진행 요구 사항을 모두 읽고 중요한 키워드를 정리했습니다.
정리한 바는 아래와 같습니다.

기능요구사항

  • 1부터 9까지 서로 다른 세 자리 숫자
  • 3개를 모두 맞히거나, 잘못된 값을 입력하면 즉시 게임 종료
  • 3개를 맞히면 재개할 것인지, 종료할 것인지 선택

프로그래밍 요구사항

  • Node.js 18.17.1에서 정상적으로 동작하지 않을 경우 0점 처리
  • 자바스크립트 코드 컨벤션을 지킨다. (함수 및 상수명)
  • 테스트가 실패할 경우 0점
  • @woowacourse/mission-utils 라이브러리를 활용

과제 진행 요구 사항

  • 기능을 구현하기 전 docs/README.md에 구현할 기능 목록을 정리

구현에 앞서기 이전에, 어떻게 구현할 것인지에 대한 기능목록을 작성하였습니다.

작성한 기능목록

1. 게임 시작 기능
2. 랜덤으로 숫자를 3개 뽑는 기능 (1-9까지의 모두 다른 세 자리 수)
3. 사용자의 입력을 받는 기능
4. 사용자의 입력을 검증하는 기능
5. 랜덤으로 뽑은 숫자와 사용자 입력을 비교해 결과(스트라이크, 볼)를 내는 기능
6. 게임 결과를 출력하는 기능
7. 게임을 맞췄다면 재시작할지, 종료할지 판단하는 기능

추가적으로 작성한 과제 진행 요구 사항은 아래와 같습니다.
요구사항은 게임이 진행되는 순서에 따라 순차적으로 작성하려고 하였습니다.

과제 진행 요구 사항

  • 1. 게임 시작 기능
    • 게임이 시작되면 숫자 야구 게임을 시작합니다.를 출력한다.
  • 2. 랜덤으로 숫자 3개를 뽑는 기능
    • 1부터 9까지 숫자를 겹치지 않는 3개 뽑아 3자리 숫자를 만든다.
    • 뽑은 숫자는 배열로 저장한다.
  • 3. 사용자의 입력을 받는 기능
    • 사용자를 입력 받기 위해 숫자를 입력해주세요 : 를 출력하면서 입력을 받는다.
    • 사용자의 입력을 받아 변수에 저장한다.
  • 4. 사용자의 입력을 검증하는 기능
    • 입력한 값이 3자리가 맞는지 확인한다.
    • 입력한 값이 숫자가 맞는지 확인한다.
    • 입력한 값이 유효하지 않다면 [ERROR ~]를 출력하고 에러를 띄워 게임을 종료한다.
  • 5. 랜덤으로 뽑은 숫자와 사용자 입력을 비교해 결과(스트라이크, 볼)를 내는 기능
    • 랜덤으로 뽑은 숫자와 사용자 입력을 비교해 스트라이크 수와 볼의 수를 센다.
    • 자리가 같고 같은 숫자라면 스트라이크이고, 자리가 다른데 숫자가 있다면 볼이다.
    • 아무것도 없다면 스트라이크 볼 모두 0이다.
  • 6. 게임 결과를 나타내는 기능
    • 스트라이크가 몇개고 볼이 몇개인지 출력한다.
    • 스트라이크, 볼 모두 없다면 낫싱을 출력한다.
    • 만약 3스트라이크가 아니라면 3부터 재개한다.
  • 7. 게임을 맞췄다면 재시작할지, 종료할지 판단하는 기능
    • 3스트라이크라면 3개의 숫자를 모두 맞히셨습니다! 게임 종료을 출력한다.
    • 사용자에게 게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.을 출력하고 1이나 2의 입력값을 받는다.
    • 이외의 값을 받았다면 [ERROR ~]를 출력하고 에러를 띄운다.
    • 1을 받았다면 실행 1부터 재개한다.
    • 2라면 게임이 종료됩니다.을 출력하고 게임을 종료한다.
  • etc
    • 추가적으로 출력 문구들은 모두 상수로 저장하여 관리한다.


먼저 구현하기 이전에, 저는 공유주셨던 코드 컨벤션과 우테코 라이브러리를 살펴보았습니다.
라이브러리는 아래 링크를 통해 살펴보았습니다.
@woowacourse/mission-utils

@woowacourse/mission-utils 라이브러리 살펴보기

Console

  • print(message)
const MissionUtils = require('@woowacourse/mission-utils');
const { Console } = MissionUtils;
 
Console.print('안녕하세요') // '안녕하세요'

라이브러리를 살펴보니 Console.log와 같은 기능을 구현하는 Console.print를 찾을 수 있었습니다. 따라서 이 피쳐를 사용해서 숫자 야구 게임을 시작합니다.와 같은 문구들을 출력해야겠다고 생각했습니다.

  • readLineAsync(query)
const MissionUtils = require('@woowacourse/mission-utils');
const { Console } = MissionUtils;
 
async function getUserInput() {
  const input = await Console.readLineAsync("값을 입력해주세요.");
  Console.print(input); // 입력값이 출력됨
}

Promise를 반환하는 비동기 함수이기 때문에 async를 사용하는 함수 아래에서 사용해야한다는 것을 알게 되었습니다. readLine 피쳐도 있었지만, asyncawait를 사용하는 것이 더 코드 가독성이 좋을 것 같아 readLineAsync로 사용자의 입력값을 받고 그에 따른 처리를 해야겠다고 생각했습니다.

Random

  • pickNumberInRange(startInclusive, endInclusive)
const MissionUtils = require('@woowacourse/mission-utils');
const { Console, Random } = MissionUtils;
 
const random = Random.pickNumberInRange(1,9);
Console.print(random); // 1부터 9까지 랜덤한 숫자가 출력됨

숫자의 시작과 끝의 범위를 정하면 정해진 범위 내에서 한개의 숫자를 뽑아주는 함수입니다. 저는 이 함수만 보고 배열에 하나씩 뽑아서 넣어야겠다고 생각했습니다. 그리고 매번 중복된 숫자인지 확인해 랜덤한 숫자를 뽑는 기능을 구현해야겠다고 고안했습니다.

  • pickNumberInList(array)
const MissionUtils = require('@woowacourse/mission-utils');
const { Random } = MissionUtils;
 
Random.pickNumberInList([1, 3, 10]); // 1
Random.pickNumberInList([1, 3, 10]); // 10
Random.pickNumberInList([1, 3, 10]); // 3

배열에 있는 숫자들 중 한개만을 뽑아줍니다. 이 피쳐보다는 아까 위의 pickNumberInRange가 구현하기 더 편할 것 같다는 생각이 들었습니다.

  • pickUniqueNumbersInRange(startInclusive, endInclusive, count)
Random.pickUniqueNumbersInRange(1, 10, 2); // [1, 2]
Random.pickUniqueNumbersInRange(1, 10, 5); // [1, 10, 7, 8, 5]

숫자 범위 내에서 지정된 개수만큼 겹치지 않는 숫자를 반환합니다. 구현하고 난 이후에 라이브러리를 더 자세히 살펴보며 포스팅을 정리하고 있는데, 코드를 다시 수정해야겠습니다.🤣 라이브러리를 더 자세히 살펴봐야한다는 배움을.. 얻어갑니다.. 사실 저는 pickNumberInRange를 사용해 구현하였는데, 이 함수가 가장 구현하기 적합할 것 같다는 생각이 들었습니다. Random.pickUniqueNumbersInRange(1, 9, 3)로 구현하면 되지 않을까? 란 생각이 바로 드네요. 아래에 포스팅에서 코드 수정 및 리팩토링도 작성할 예정입니다.


코드 컨벤션 살펴보기

공유주셨던 코드 컨벤션은 내용이 짧아서 한눈에 파악할 수 있었습니다. 함수, 변수, 상수에 대한 이야기였는데, 상수를 사용해서 구현해야한다는 것처럼 들렸습니다. 그래서 먼저 저 자신만이 정한 코드 컨벤션이 있었는데요, 아래와 같습니다.

- 함수는 무조건 동사로 시작한다.
- 소스의 변수명, 클래스명 등에는 영문 이외의 언어를 사용하지 않는다.
- 클래스, 메서드 등의 이름에는 특수 문자를 사용하지 않는다.
- 상수는 `const MESSAGE = '안녕하세요'`와 같이 대문자로 `SNAKE_CASE`로 작성한다.

추가적으로 커밋컨벤션도 작성하였습니다.

- Feat: 새로운 기능 추가
- Fix: 버그/오타(typo)/로직 등 코드를 수정한 경우
- Refactor: 코드 리팩토링
- style   : 코드 포맷팅, 세미콜론 누락 수정 등 내부 로직 변경이 없이 코드를 수정한 경우
- Docs    : README 문서 수정
- Remove  : 코드/파일 삭제

기능 구현하기

사실 저는 파일을 열어보고 조금 당황했습니다. jest를 사용해본적도, 클래스를 많이 사용해 본적도 없었기 때문입니다.
아래는 src 폴더 안에 있던 App.js 파일입니다.

class App {
  async play() {
  }
}

매번 테스트를 실행해보기만 했지 테스트 코드를 짜본적도 없었고 클래스는 개념만 조금 숙지하고 있었습니다.
그래서 이번 기회에 시간이 남는다면 직접 테스트코드를 추가적으로 조금 짜보고, 클래스도 제대로 숙지하자는 마음과 함께 구현을 시작하였습니다.

일단 코드를 살펴보았을 때, play() 메서드를 통해서 게임을 시작하는 거구나. 라고 생각했습니다.
먼저 제가 위에서 적었던 과제 진행 요구 사항에 맞춰 코드 작성의 흐름을 적어보았습니다.


1. 게임 시작 기능

  • 게임이 시작되면 숫자 야구 게임을 시작합니다.를 출력한다.

먼저 문구는 모두 상수로 관리하였으며, Console.print를 사용하여 문구를 출력하였습니다.
상수는 SNAKE_CASEMESSAGE 객체로 저장하였습니다.

const MissionUtils = require('@woowacourse/mission-utils');
const { Console } = MissionUtils;
 
const MESSAGE = {
  START: '숫자 야구 게임을 시작합니다.',
}
 
class App {
  async play() {
    Console.print(MESSAGE.START);
  }
}

2. 랜덤으로 숫자 3개를 뽑는 기능

  • 1부터 9까지 숫자를 겹치지 않는 3개 뽑아 3자리 숫자를 만든다.
  • 뽑은 숫자는 배열로 저장한다.

다음 랜덤으로 숫자 3개를 뽑는 기능을 구현하였습니다.
play 메서드 내부에서가 아닌 메서드 사용을 구분해야지 코드가 더 깔끔해질 것 같아 generateRandomNumber메서드를 만들어 구현하였습니다. 메서드는 모두 동사로 시작하게끔 네이밍하였습니다.
랜덤으로 숫자를 뽑는 기능은 Random.pickNumberInRange를 사용하였습니다.
그리고 뽑은 숫자를 저장하기 위해, contructor를 만들어 randomNumber 네이밍으로 변수를 배열로 초기화였습니다.

const MissionUtils = require('@woowacourse/mission-utils');
const { Console, Random } = MissionUtils;
 
class App {
  ...
  constructor(){
    this.randomNumber = [];
  }
 
  generateRandomNumber(){
    const { randomNumber } = this;
    
    // 3개의 숫자가 뽑힐 때까지 숫자를 반복해 뽑는다.
    // 만약에 뽑힌 숫자라면 배열에 추가하지 않는다. 오로지 뽑히지 않은 숫자만 추가한다.
    while(randomNumber.length < 3){
      const number = Random.pickNumberInRange(1, 9);
      if(!randomNumber.includes(number)) randomNumber.push(number);
    }
  }
}

3. 사용자의 입력을 받는 기능

  • 사용자를 입력 받기 위해 숫자를 입력해주세요 : 를 출력하면서 입력을 받는다.
  • 사용자의 입력을 받아 변수에 저장한다.

사용자의 입력을 받기 위해 Console.readLineAsync를 사용하였습니다. getUserInput 로직은 메서드 내부에서 구현하였으며, 입력을 받고 저장하기 위해 userInput 네이밍으로 변수를 배열로 초기화하였습니다.

const MESSAGE = {
  START: '숫자 야구 게임을 시작합니다.',
  INPUT: '숫자를 입력해주세요 : ',
}
 
class App {
  ...
  constructor(){
    this.randomNumber = [];
    this.userInput = [];
  }
 
  async getUserInput(){
    const input = await Console.readLineAsync(MESSAGE.INPUT);
    this.userInput = [...input];
  }
}

4. 사용자의 입력을 검증하는 기능

  • 입력한 값이 3자리가 맞는지 확인한다.
  • 입력한 값이 숫자가 맞는지 확인한다.
  • 입력한 값이 유효하지 않다면 [ERROR ~]를 출력하고 에러를 띄워 게임을 종료한다.

3까지 구현한 이후에, 사용자의 입력을 받고 검증 이후에 값을 저장해야겠다고 생각했습니다. 검증하기 위한 validateInput 메서드를 만들었고, 사용자의 입력의 길이가 3이상인지, 그리고 모두 숫자만 입력했는지 판단하게 하였습니다.
만약에 위의 조건에 해당하지 않는다면 에러를 띄우게 하였습니다. 에러 메시지는 상수로 저장해 활용하였습니다.

// 에러 메시지 추가
const ERROR_MESSAGE = {
  LENGTH: '[ERROR] 세 자리 숫자만 입력해야합니다.',
  INT: "[ERROR] 정수만 입력해야합니다.",
}
 
class App {
  ...
  constructor(){
    this.randomNumber = [];
    this.userInput = [];
  }
 
  async getUserInput(){
    const input = await Console.readLineAsync(MESSAGE.INPUT);
    // 입력을 저장하기 이전에 validation 진행
    this.validateInput(input)
    this.userInput = [...input];
  }
 
  validateInput(input){
    if(input.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    if(!Number.isInteger(+input)) throw new Error(ERROR_MESSAGE.INT);
  }
}

5. 랜덤으로 뽑은 숫자와 사용자 입력을 비교해 결과(스트라이크, 볼)를 내는 기능

  • 랜덤으로 뽑은 숫자와 사용자 입력을 비교해 스트라이크 수와 볼의 수를 센다.
  • 자리가 같고 같은 숫자라면 스트라이크이고, 자리가 다른데 숫자가 있다면 볼이다.
  • 아무것도 없다면 스트라이크 볼 모두 0이다.

다음으로 변수로 저장한 randomNumberuserInput 값을 비교하여 스트라이크인지 볼인지 판단하는 기능을 구현하였습니다.
getResult라는 메서드로 구현하였습니다.

class App {
  constructor(){
    this.randomNumber = [];
    this.userInput = [];
    this.gameResults = {};
  }
 
  async getUserInput(){
    ...
    this.userInput = [...input];
    getResult();
  }
 
  getResult(){
    const { userInput } = this;
    const { randomNumber } = this;
    const result = { strike: 0, ball: 0};
 
    userInput.forEach((num, idx)=> {
      if(randomNumber[idx] === num) result.strike += 1;
      if(randomNumber[idx] !== num && randomNumber.includes(num)) result.ball +=1 ;
    })
  
    this.gameResults = result;
  }
}

원래는 변수인 gameResults에 값을 직접적으로 저장하고, 가변하는 식으로 코드를 짰었는데, 생각해보니 다음 게임을 할 때에는 gameResult및 모든 변수가 매번 초기화되어야한다는 것을 알게 되었습니다. 따라서 추가적으로 초기화하는 코드를 추가하였으며, getResult에서도 const result = { strike: 0, ball: 0} 코드를 추가하여 초기화해주었습니다.

이외에도 변수인 randomNumber, userInput도 모두 초기화해주어야했는데, userInput같은 경우에는 validation을 판독한 이후에 바로 초기화되고 있었기때문에 randomNumber만 초기화설정을 해주었습니다.

class App {
  generateRandomNumber(){
    // 매번 게임을 할때마다 변수 초기화
    this.randomNumber = [];
    const { randomNumber } = this;
    
    while(randomNumber.length < 3){
      const number = Random.pickNumberInRange(1, 9);
      if(!randomNumber.includes(number)) randomNumber.push(number);
    }
  }
}

6. 게임 결과를 나타내는 기능

  • 스트라이크가 몇개고 볼이 몇개인지 출력한다.
  • 스트라이크, 볼 모두 없다면 낫싱을 출력한다.
  • 만약 3스트라이크가 아니라면 3부터 재개한다.

결과를 판별하였으니, 결과를 출력만 하는 메서드를 구현하였습니다. Console.print를 활용해 결과를 출력하였습니다.
조건은 모두 if, else if, else문으로 구현하였습니다.

class App {
  constructor(){
    ...
  }
 
  async getUserInput(){
    ...
    this.getResult();
    this.printResult();
  }
 
  printResult(){
    const { strike, ball } = this.gameResults;
    
    if(ball === 0 && strike ===0) Console.print(`낫싱`);
    else if(ball > 0 && strike > 0) Console.print(`${ball}볼 ${strike}스트라이크`);
    else if(ball > 0) Console.print(`${ball}볼`);
    else Console.print(`${strike}스트라이크`);
  }
}

7. 게임을 맞췄다면 재시작할지, 종료할지 판단하는 기능

  • 3스트라이크라면 3개의 숫자를 모두 맞히셨습니다! 게임 종료을 출력한다.
  • 사용자에게 게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.을 출력하고 1이나 2의 입력값을 받는다.
  • 이외의 값을 받았다면 [ERROR ~]를 출력하고 에러를 띄운다.
  • 1을 받았다면 실행 1부터 재개한다.
  • 2라면 게임이 종료됩니다.을 출력하고 게임을 종료한다.

만약에 스트라이크가 3개라면 3개의 숫자를 모두 맞히셨습니다! 게임 종료 문구를 출력하고, 사용자의 입력값을 받게 하였습니다.
이 기능은 restart 라는 메서드로 구현하였습니다.
사용자 입력값이 1 또는 2가 아니라면 에러를 띄웁니다. 에러문구는 상수로 저장하였습니다.
1이라면 다시 play메서드가 실행되게 하고, 아니라면 게임을 종료합니다.
스트라이크가 3개가 아니라면 다시 getUserInput 함수를 실행하게 합니다.

const MESSAGE = {
  START: '숫자 야구 게임을 시작합니다.',
  INPUT: '숫자를 입력해주세요 : ',
  FINISH: '3개의 숫자를 모두 맞히셨습니다! 게임 종료',
  RESTART: '게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.',
  END: '게임이 종료되었습니다.'
}
 
const ERROR_MESSAGE = {
  LENGTH: '[ERROR] 세 자리 숫자만 입력해야합니다.',
  INT: "[ERROR] 정수만 입력해야합니다.",
  RESTART: '[ERROR] 숫자가 잘못된 형식입니다.',
}
 
class App {
  constructor(){
    ...
  }
 
  async getUserInput(){
    ...
 
    const { strike } = this.gameResults;
    if(strike === 3) await this.restart();
    else this.getUserInput();
  }
 
  async restart(){
    Console.print(MESSAGE.FINISH);
    Console.print(MESSAGE.RESTART);
    const input = await Console.readLineAsync('');
    if(input === '1') await this.play();
    else if(input === '2') Console.print(MESSAGE.END);
    else throw new Error(ERROR_MESSAGE.RESTART);
  }
}


보충해야할 점

구현을 모두 마친 이후에, 제 스스로 코드에 어떤점이 부족한지 그리고 어떤걸 채워나가면 좋을 지에 대해서 생각해보았습니다.

구현을 한 이후에, 이전에 기수 분들이 어떤식으로 보완해나아가셨는지 조금 서칭해보았습니다. 많은 분들이 로직구분에 많이 신경을 쓰신 것 같았고, 좀 더 로직을 명확히 구분해 보면 어떨까? 라는 생각과 jest 사용법을 모르니 이번 기회에 jest를 공부하면서 테스트 코드를 추가해보는 것이 좋을 것 같다는 생각이 들었습니다. 또한 예외사항들에 대해서 더 추가적으로 생각해보아야한다고 판단했습니다.

아래는 제가 추가적으로 보완하면 좋을 것 같은 부분들을 정리하였습니다.

  • 정리
    • 메서드를 인스턴스에서 접근해 랜덤숫자를 변경하면 어떡하지?🧐
    • 변수로 저장하지 않고 매개변수로 넘겨주고 있는 것들 로직구분
    • 사용자 입력값 예외사항 추가로 고려하기
    • 추가적으로 가독성을 높일 수 있는 코드로 수정해보자!😎
    • jest 테스트 코드 추가


코드 보완하기

1. 메서드를 인스턴스에서 접근해 랜덤숫자를 변경하면 어떡하지?🧐

로직을 짜면서 여러 메서드들과 프로퍼티들이 모두 public이여서 직접적으로 접근해 랜덤으로 뽑은 수를 수정하면 어떻게 해야하나 라는 생각이 들었습니다.. 그래서 private 프로퍼티를 사용해야하나? 라는 생각이 들었는데요,
우테코에서 나눠주셨던 자바스크립트 컨벤션 가이드에 아래와 같은 문구가 있었습니다.

클래스, 메서드 등의 이름에는 특수 문자를 사용하지 않습니다.

private프로퍼티를 사용하려면 #특수 문자를 사용해야하는데, 그럼 사용하면 안되는건가.. 생각이 문득들었습니다.
아직 정식으로 채택이 되지 않은 문법이라 허용하지 않는걸까? 라고 생각하며, 섣불리 코드를 수정하기보다 추후 피드백을 받고 수정하면 좋겠다는 생각이 들었습니다.🥸


2. 변수로 저장하지 않고 매개변수로 넘겨주고 있는 것들 로직구분

제가 작성하였던 메서들 모두 매개변수를 받고 있지 않게 로직을 짜놨었는데, 한 개의 메서드만 매개변수를 받고 있었습니다.
validateInput였는데요. 매개변수를 받는 이유는 변수를 활용해 저장하지 않고 바로 메서드에 넘겨주었기 때문이었습니다.
따라서 Model과 로직처리의 구분을 명확히하기 위해 변수를 하나 만들어 매개변수를 받지 않게 로직을 수정하였습니다.

수정 이전 코드

class App {
  constructor(){
    this.randomNumber = [];
    this.userInput = [];
    this.gameResults = {};
  }
  
  async getUserInput(){
    const input = await Console.readLineAsync(MESSAGE.INPUT);
    // 매개변수를 받던 validateInput 메서드
    this.validateInput(input);
    this.userInput = [...input].map(Number);
    ...
   }
   
   validateInput(input){
    if(input.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    if(!Number.isInteger(+input)) throw new Error(ERROR_MESSAGE.INT);
    if(Math.sign(input) !== 1) throw new Error(ERROR_MESSAGE.NEGATIVE);
   }
}

수정 이후 코드

class App {
  constructor(){
    this.randomNumber = [];
    // userStringInput 변수 추가
    this.userStringInput = '';
    this.userInput = [];
    this.gameResults = {};
  }
  
  async getUserInput(){
    const input = await Console.readLineAsync(MESSAGE.INPUT);
    this.userStringInput = input;
    // 매개변수 제거
    this.validateInput();
    ...
  }
  validateInput(){
    // 변수를 꺼내와서 검증시작
    const { userStringInput } = this;
 
    if(userStringInput.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    if(!Number.isInteger(+userStringInput)) throw new Error(ERROR_MESSAGE.INT);
    if(Math.sign(userStringInput) !== 1) throw new Error(ERROR_MESSAGE.NEGATIVE);
  }
}

3. 사용자 입력값 예외사항 추가로 고려하기

사용자 입력값을 검증할 때, 저는 아래와 같은 상황들을 예외처리 하였습니다. (에러처리)

  • 처리했던 예외사항
    • 사용자가 3개의 이상의 문자를 입력했을 경우 에러처리
    • 정수가 아닌 경우 에러처리

하지만 예외사항을 더 생각해보니 아래와 같은 예외사항이 더 있었습니다.

  • 생각하지 못했던 예외사항들
    • 019를 입력했을 경우
    • -12를 입력했을 경우

그래서 위의 사항들을 고려하여 예외처리를 수정하였습니다.

// 정규표현식 추가
const REGEX = /^[1-9]+$/;
 
class App {
  ...
  validateInput(){
    // 수정 이전
    // if(userStringInput.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    // if(!Number.isInteger(+userStringInput)) throw new Error(ERROR_MESSAGE.INT);
 
    const { userStringInput } = this;
    // 입력의 길이가 3이 넘어가는지 판별
    if(userStringInput.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    // 1-9로만 이뤄진 숫자인지 판별
    if(!REGEX.test(userStringInput)) throw new Error(ERROR_MESSAGE.INT);
    // 음수인지 판별
    if(Math.sign(userStringInput) !== 1) throw new Error(ERROR_MESSAGE.NEGATIVE);
  }
}

코드를 수정하고 나니 또 다른 예외사항이 있었습니다.😅 생각해보니 1-9까지의 숫자가 모두 한번만 입력되어야한다는 것이었습니다.
따라서 그에 대한 코드도 수정하였습니다.

const REGEX = /^[1-9]+$/;
 
class App {
  ...
  validateInput(){
    // 수정 이전
    // if(userStringInput.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    // if(!Number.isInteger(+userStringInput)) throw new Error(ERROR_MESSAGE.INT);
 
    // 수정 이후
    const { userStringInput } = this;
    const uniqueArr = [...new Set([...input])];
 
    if(userStringInput.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    if(!REGEX.test(userStringInput)) throw new Error(ERROR_MESSAGE.INT);
    // 각 고유한 것들만 뽑은 길이와 비교하고, 새로운 에러를 던졌습니다.(상수로 저장)
    if(userStringInput.length !== uniqueArr.length) throw new Error(ERROR_MESSAGE.UNIQUE);
    if(Math.sign(userStringInput) !== 1) throw new Error(ERROR_MESSAGE.NEGATIVE);
  }
}

4. 추가적으로 가독성을 높일 수 있는 코드로 수정해보자!😎

if문을 사용할 때, else if문을 사용하면 가독성이 떨어진다는 이야기가 있어, else if문으로 처리하던 로직을 모두 if문 하나로 수정하였습니다.

class App {
  ...
 
  
  printResult(){
    const { strike, ball } = this.gameResults;
    
    // 수정 이전
    // if(ball === 0 && strike ===0) Console.print(`낫싱`);
    // else if(ball > 0 && strike > 0) Console.print(`${ball}볼 ${strike}스트라이크`);
    // else if(ball > 0) Console.print(`${ball}볼`);
    // else Console.print(`${strike}스트라이크`);
 
    if(ball === 0 && strike === 0) Console.print(`낫싱`);
    if(ball > 0 && strike > 0) Console.print(`${ball}볼 ${strike}스트라이크`);
    if(ball > 0 && strike === 0) Console.print(`${ball}볼`);
    if(strike > 0 && ball === 0) Console.print(`${strike}스트라이크`);
  }
}

위의 코드 제외하고 else if문이 쓰인 부분들 모두 if문으로 처리하였습니다.



Jest 테스트 코드 작성하기

테스트 코드를 작성하기 이전에, Jest에 대해서 간단하게 공부를 해보았습니다.
Jest 사용해보기

먼저 저는 제가 적었던 예외사항들이 테스트케이스를 통과하는지가 궁금했습니다.

아까 위에서 적었던바와 같이 제가 사용자 입력 시, 추가적으로 고려했던 예외사항들은 아래와 같습니다.

  • 고려했던 예외사항
    • 019를 입력했을 경우
    • -12를 입력했을 경우
    • 119를 입력했을 경우

따라서 먼저, 제가 고려했던 예외사항들이 제대로 테스트케이스에서 통과가 되는지 확인해보았습니다.


사용자 입력값 예외사항 테스트 케이스 작성하기

테스트코드를 적기 전, __test__폴더 안에 ValidationTest.js 파일을 만들어 주었습니다.

ValidationTest.js

import App from "../src/App.js";
 
describe("사용자 입력 예외 처리 테스트", () => {
  const app = new App();
  app.userStringInput = '019'; // 테스트할 가짜 입력 값 지정
 
  expect(() => app.validateInput()).toThrow("[ERROR] 1부터 9사이의 숫자가 하나씩 있어야합니다.");
});

진짜 될까? 너무 궁금했어요. npm test를 실행하니 아래과 같은 결과가 나오게 되었습니다.

231024-111049
🫢 너무 신기했습니다.. 그래서 추가적으로 -12, 119를 입력했을 때도 추가적으로 테스트 코드를 작성해주었습니다.

import App from "../src/App.js";
 
describe("사용자 입력 예외 처리 테스트", () => {
  
  test("숫자가 중복되면 오류가 발생합니다.", () => {
    const app = new App();
    app.userStringInput = '113';
    expect(() => app.validateInput()).toThrow("[ERROR] 1부터 9사이의 숫자가 하나씩 있어야합니다.");
  });
 
  test("0은 입력될 수 없습니다.", () => {
    const app = new App();
    app.userStringInput = '019';
    expect(() => app.validateInput()).toThrow("[ERROR] 1부터 9사이의 숫자만 입력해야합니다.");
  })
  
  test("음수는 입력될 수 없습니다.", () => {
    const app = new App();
    app.userStringInput = "-19";
    expect(() => app.validateInput()).toThrow("[ERROR] 양수만 입력해야합니다.");
  })
});

위와 같이 테스트 코드를 적었는데요, 적고 난뒤, 에러를 마주할 수 있었습니다.
231024-111456
왜 이런 에러를 마주한 걸까요?

validateInput메서드 로직을 다시 살펴봐니 코드는 아래와 같았습니다.

const ERROR_MESSAGE = {
  LENGTH: '[ERROR] 세 자리 숫자만 입력해야합니다.',
  INT: '[ERROR] 1부터 9사이의 숫자만 입력해야합니다.',
  UNIQUE: '[ERROR] 1부터 9사이의 숫자가 하나씩 있어야합니다.',
  NEGATIVE: '[ERROR] 양수만 입력해야합니다.',
  RESTART: '[ERROR] 숫자가 잘못된 형식입니다.',
}
 
const REGEX = /^[1-9]+$/;
 
  validateInput(){
    const { userStringInput } = this;
    const uniqueArr = [...new Set([...userStringInput])];
 
    if(userStringInput.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    // 정규표현식 부분
    if(!REGEX.test(userStringInput)) throw new Error(ERROR_MESSAGE.INT);
    if(userStringInput.length !== uniqueArr.length) throw new Error(ERROR_MESSAGE.UNIQUE);
    // 음수인 숫자 거르는 부분
    if(Math.sign(userStringInput) !== 1) throw new Error(ERROR_MESSAGE.NEGATIVE);
  }

확인해보니 -19, -30 이런 음수의 숫자들은 정규표현식에서 먼저 걸러져 에러를 던지고 있던 것이었습니다. 테스트케이스를 짜기 이전에는 정규표현식쪽에서 걸러지지 않을거라고 잘못예상하고, 마지막 부분에 음수인 숫자를 거르는 예외사항을 추가하였습니다.
jest를 사용해보면서 이전에 결과값을 예상해보고, 정말 그대로 나오는지 직접적으로 확인할 수 있는부분이 너무 좋은 것 같다고 느꼈습니다. 예상한 바와 다르게 나오니 큰일날뻔했다 싶기도했고..😉 jest의 장점을 제대로 실감할 수 있었습니다.

따라서 jest를 통해서 불필요했던 예외사항 코드를 삭제하였습니다.
상수로 관리했던 에러메시지는 import로 가져와 중복을 제거해주었습니다.


게임결과 테스트코드

이미 테스트 코드로 짜여있긴 하지만, 그냥 연습삼아 게임 결과 출력값이 뽑히는 테스트 코드도 짜보았습니다.
printResultTest.js

import App from "../src/App.js";
import { MissionUtils } from "@woowacourse/mission-utils";
 
const mockGetResult = (randomNumber, userInput) => {
  const app = new App();
  app.randomNumber = randomNumber;
  app.userInput = userInput;
  app.getResult();
  app.printResult();
}
 
const getLogSpy = () => {
  const logSpy = jest.spyOn(MissionUtils.Console, "print");
  logSpy.mockClear();
  return logSpy;
}
 
describe("게임 결과 출력 테스트", () => {
  test("3스트라이크인 경우", () => {
    const logSpy = getLogSpy();
    mockGetResult([1,5,8], [1,5,8])    
    expect(logSpy).toHaveBeenCalledWith('3스트라이크');
  });
 
  test("1볼 1스트라이크인 경우", () => {
    const logSpy = getLogSpy();
    mockGetResult([1,5,8], [9,5,1])    
    expect(logSpy).toHaveBeenCalledWith('1볼 1스트라이크');
  });
 
  test("낫싱인 경우", () => {
    const logSpy = getLogSpy();
    mockGetResult([1,5,8], [2,4,9])    
    expect(logSpy).toHaveBeenCalledWith('낫싱');
  });
});

게임 종료 이후, 사용자 입력값 테스트코드

3스트라이크를 하고 난 이후에 사용자에게 게임을 재개할지 아니면 종료할지에 대한 입력값을 받아야했는데요,
1을 입력했을 때 게임이 재개되는 지, 2를 입력했을 때는 종료가 잘되는지 그리고 예외사항도 잘 걸러지는지 테스트코드를 짜보았습니다.

import App from "../src/App.js";
import { MissionUtils } from "@woowacourse/mission-utils";
import { MESSAGE, ERROR_MESSAGE } from '../src/App.js';
 
const mockConsoleFn = (input) => {
  MissionUtils.Console.readLineAsync = jest.fn();
  MissionUtils.Console.readLineAsync.mockImplementation(()=> input);
}
 
const getLogSpy = () => {
  const logSpy = jest.spyOn(MissionUtils.Console, "print");
  logSpy.mockClear();
  return logSpy;
}
 
describe("게임 종료 및 재개 테스트", () => {
  test("게임을 종료하는 경우", async () => {
    const app = new App();
    const logSpy = getLogSpy();
    mockConsoleFn('2');
    await app.restart();
    expect(logSpy).toHaveBeenCalledWith(MESSAGE.END);
  })
 
  test("1과 2가 아닌 다른 값을 입력했을 경우", async () => {
    const app = new App();
    mockConsoleFn('게임종료해줘.');
    await expect(app.restart()).rejects.toThrow(ERROR_MESSAGE.RESTART);
  })
})

추가적으로 어떤걸?

사실 저는 이번 1주차에서 우테코에서 나눠주신 문제를 구현하는 것과 테스트 코드를 이해하자가 목표였습니다.😭
그런데 생각보다 구현과 테스트 코드까지 빠르게 짜게 되어서 다행이라고 생각이 들었어요ㅠㅠ
그래도 예상했던 일정보다 빠르게 목표에 달성했다고 안주하지 말고, 어떤 것을 더 보완해나가면 좋을지에 대해서 생각하게 되었습니다.

디스코드 채널에서 프리코스에 참여하시는 많은 분들이 객체지향프로그래밍(OOP)에 대한 블로그를 자주 공유해주시길래, 나도 최대한 SOLID원칙에 근거하여 코드를 좀 더 리팩토링 해보면 어떨까? 란 생각이 들었습니다.


S (Single Responsibility Principle / 단일 책임 원칙)

아래는 SOLID원칙 중에 S, 단일 책임 원칙에 대한 예시인데요,

Functions should do one thing
함수는 한 가지 작업을 수행해야 합니다.
이것은 소프트웨어 엔지니어링에서 단연코 가장 중요한 규칙입니다. 함수가 한 가지 이상을 수행할 때 구성, 테스트 및 추론하기가 더 어렵습니다. 함수를 하나의 작업으로 분리할 수 있으면 쉽게 리팩토링할 수 있고 코드가 훨씬 더 깔끔하게 읽힙니다. 이 가이드에서 이것 외에 다른 것을 빼지 않는다면 당신은 많은 개발자들보다 앞서게 될 것입니다.

Bad

function emailClients(clients: Client[]) {
  clients.forEach((client) => {
    const clientRecord = database.lookup(client);
    if (clientRecord.isActive()) {
      email(client);
    }
  });
}
function emailClients(clients: Client[]) {
  clients.filter(isActiveClient).forEach(email);
}
 
function isActiveClient(client: Client) {
  const clientRecord = database.lookup(client);
  return clientRecord.isActive();
}

확실히 예제를 보니까 뭔가 개안하는 느낌이 들었습니다.. 가독성이 확실히 높아지는 것을 느꼈어요.
예제를 보니 제가 작성하였던 코드에도 개선하고 싶었던 점이 있었습니다. 바로 validation부분이었는데요,

validateInput(){
    const { userStringInput } = this;
    const uniqueArr = [...new Set([...userStringInput])];
 
    if(userStringInput.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    if(!REGEX.test(userStringInput)) throw new Error(ERROR_MESSAGE.INT);
    if(userStringInput.length !== uniqueArr.length) throw new Error(ERROR_MESSAGE.UNIQUE);
  }

제가 작성했던 코드를 보면 3가지 if문을 보실 수 있습니다. if이 길어지다보니 좀 가독성이 떨어지는 것 같다고 느꼈거든요. 그래서 뭔가 if문 안에 있는 로직을 함수로 각각빼어서 boolean값으로 리턴하게끔 하면 어떨까? 라는 생각이 들었습니다.
예제에도 isActiveClient함수문도 로직을 나누고 boolean값으로 리턴하고 있어서 적용해보면 좋을 것 같다고 느껴 적용해보기로 했습니다.

먼저 if문이 3개니까, boolean값을 리턴하는 3개의 메서드를 만들면 좋겠다고 생각이 들었습니다.
그래서 constructor에 메서들을 추가해주었어요.

class App {
  constructor(){
    ...
    this.validate = {
      isLenThree: () => {},
      isInt: () => {},
      isUnique: () => {},
    }
  }
 
  validateInput(){
    const { userStringInput } = this;
    const uniqueArr = [...new Set([...userStringInput])];
 
    // 길어서 보기 좋지 않은 if문 
    if(userStringInput.length !== 3) throw new Error(ERROR_MESSAGE.LENGTH);
    if(!REGEX.test(userStringInput)) throw new Error(ERROR_MESSAGE.INT);
    if(userStringInput.length !== uniqueArr.length) throw new Error(ERROR_MESSAGE.UNIQUE);
  }
}

그리고 if문에서 사용되고 있는 로직들을 this.validate의 메서드들로 추가해주었습니다.

class App {
  constructor(){
    ...
    this.validate = {
      isLenThree: (input) => input.length === 3,
      isInt: (input) => REGEX.test(input),
      isUnique: (input) => {
        const uniqueArr = [...new Set([...input])];
        return input.length === uniqueArr.length;
      },
    }
  }
 
  validateInput(){
    const { userStringInput } = this;
    const { isLenThree, isInt, isUnique } = this.validate;
 
    if(!isLenThree(userStringInput)) throw new Error(ERROR_MESSAGE.LENGTH);
    if(!isInt(userStringInput)) throw new Error(ERROR_MESSAGE.INT);
    if(!isUnique(userStringInput)) throw new Error(ERROR_MESSAGE.UNIQUE);
  }
}

위의 코드 이외에도 스트라이크인지, 볼인지 판별하는 결과를 계산하는 메서드가 있었는데, 이때도 if문 내에서의 로직이 있어서 위와 같이 메서드로 정리해 빼어 코드를 수정하였습니다.

수정 이전 코드

class App {
  ...
  getResult(){
    const { userInput } = this;
    const { randomNumber } = this;
    const result = { strike: 0, ball: 0};
 
    userInput.forEach((num, idx)=> {
      if(randomNumber[idx] === num) result.strike += 1;
      if(randomNumber[idx] !== num && randomNumber.includes(num)) result.ball += 1;
    })
  
    this.gameResults = result;
  }
}

수정 이후 코드

class App {
  constructor(){
    ...
    this.compare = {
      isStrike: (num, idx) => {
        const { randomNumber } = this;
        return randomNumber[idx] === num
      },
      isBall: (num, idx) => {
        const { randomNumber } = this;
        return randomNumber[idx] !== num && randomNumber.includes(num)
      },
    }
  }
 
  getResult(){
    const { userInput } = this;
    const { isStrike, isBall } = this.compare;
    const result = { strike: 0, ball: 0};
 
    userInput.forEach((num, idx)=> {
      if(isStrike(num, idx)) result.strike += 1;
      if(isBall(num, idx)) result.ball += 1;
    })
  
    this.gameResults = result;
  }
}

O (Open-Closed Principle / 개방-폐쇄 원칙)

OCP의 원칙의 의미는 새로운 기능의 추가가 일어 났을때에는 기존코드의 수정 없이 추가가 되어야 하고, 내부 매커니즘이 변경이 되어야 할때에는 외부의 코드 변화가 없어야 한다 라는 것입니다.

함수형 프로그래밍에서 이 OCP를 가장 잘 느낄 수 있는 것은 바로 map, filter, reduce와 같은 Higer order Function(or Method)와 webpack loader와 같은 플러그인 또는 middleware 개념입니다.

그래서 사실 변수를 변경하는 부분들을 모두 gettersetter함수를 직접 만들어 사용하려고 했는데, 변수들을 모두 public으로 사용하고 있어서 의미가 있을까? 란 생각이 조금 들긴 했습니다.🥲
만약에 특수기호들을 메서드에 사용할 수 있었다면 private이나 _기호들... 변수 지정을 private하게 만들고, gettersetter함수를 만들어서 코드에 적용했을거예요.


L (Liskov substitution principle / 리스코프 치환 원칙)

리스코프 치환원칙같은 경우는 상속을 받은 부분에서 사용되는 개념이기 때문에, 이번 문제에는 크게 적용될 부분이 없다고 생각하여 아래의 링크에서 개념만 이해하고 넘어갔습니다.
SOLID 원칙


I (Interface segregation principle / 인터페이스 분리 원칙)

사실 이 원칙을 보면서 제 코드에서도 조금 분리하면 좋을 것 같다고 생각했던 코드 부분이 있었습니다.
바로 사용자 입력을 받는 부분이었는데요.

class App {
  async getUserInput(){
    const input = await Console.readLineAsync(MESSAGE.INPUT);
    this.userStringInput = input;
    this.validateInput();
    this.userInput = [...input].map(Number);
 
    this.getResult();
    this.printResult();
 
    const { strike } = this.gameResults;
    if(strike === 3) await this.restart();
    else this.getUserInput();
  }
}

코드를 보면 입력을 받고, 결과를 구하고, 결과를 콘솔에 출력하고 결과에 따라 종료할지 재시작할지에 대한 로직이 모두 담겨있습니다. 물론 구체적인 로직은 모두 모듈화 하여 코드를 나눴지만, 이 코도들을 play메서드에 순서대로 넣는게 훨씬 메서드명이나 로직상 옳을 것 같다고 느꼈기 때문입니다.

그래서 아래와 같이 로직 play start 로직을 구분하여 getUserInput에 몰려있는 로직들을 구분하였습니다.

class App {
  async play() {
    Console.print(MESSAGE.START);
    this.generateRandomNumber();
    await this.start();
  }
 
  async start(){
    await this.getUserInput();
    this.validateInput();
    this.setUserInput();
    this.getResult();
    this.printResult();
 
    const { strike } = this.gameResults;
    if(strike === 3) await this.wantToReplay();
    else await this.start();
  }
 
  async getUserInput(){
    const input = await Console.readLineAsync(MESSAGE.INPUT);
    this.userStringInput = input;
  }
 
  setUserInput(){
    const { userStringInput } = this;
    this.userInput = [...userStringInput].map(Number);
  }
 
  async wantToReplay(){
    Console.print(MESSAGE.FINISH);
    Console.print(MESSAGE.RESTART);
    const input = await Console.readLineAsync('');
 
    if(input === INPUT.RESTART) await this.play();
    if(input === INPUT.END) Console.print(MESSAGE.END);
    if(input !== INPUT.RESTART && input !== INPUT.END) throw new Error(ERROR_MESSAGE.RESTART);
  }
}

메서드명에 맞게 완전히 로직 구분이 더 되었다고 확실히 느끼게 되었습니다.🙂 넘무넘무 신기했어요.


D (Dependency inversion principle / 의존관계 역전 원칙)

프로그래머는 “추상화에 의존해야지, 구체화에 의존하면 안된다.” 의존성 주입은 이 원칙을 따르는 방법 중 하나다.

이 역시 직관적으로 이해해봅시다. 우리가 전기기구를 사용하기 위해서는 콘센트에 플러그를 꽃는 방법만 알면됩니다. 실제로 전기의 배선을 붙여가며 전기기구를 사용하지 않죠. "전기를 이용하기 위해서는 플러그를 꽃으면 된다."(추상화) 라는 추상화된 방법만 전달을 하고 있다면 플러그에서 실제 전기 배선이 어떻게 되던간에(구체화) 사용자는 관여하지 않아도 됩니다. 우리가 필요한것은 전기이며 실제로 전기를 얻기 위한 구체적인 방법이 아니니까요.

저는 다른 원칙들에 맞게 코드를 수정하면서 제 코드에서 의존관계 역전 원칙도 저절로 적용된 부분을 확인할 수 있었습니다.

class App {
  constructor(){
    ...
    this.validate = {
      isLenThree: (input) => input.length === 3,
      isInt: (input) => REGEX.test(input),
      isUnique: (input) => {
        const uniqueArr = [...new Set([...input])];
        return input.length === uniqueArr.length;
      },
    }
  }
 
  validateInput(){
    const { userStringInput } = this;
    const { isLenThree, isInt, isUnique } = this.validate;
 
    if(!isLenThree(userStringInput)) throw new Error(ERROR_MESSAGE.LENGTH);
    if(!isInt(userStringInput)) throw new Error(ERROR_MESSAGE.INT);
    if(!isUnique(userStringInput)) throw new Error(ERROR_MESSAGE.UNIQUE);
  }  
}

바로 validateInput이었습니다. 그냥 validateInput() 메서드를 실행만해도, 검증이 진행되고 있기 때문입니다. 세부 로직은 this.validate로 빼놓았기 때문에, 하위 모듈이 어떻게 돌아가는지 판단하지 않고 true, false로 boolean값에 따라 에러만 내뱉도록 코드를 적어두었기때문입니다.

추상화하는 방향으로 의존하라. 상위 레벨 모듈이 하위 레벨 세부 사항에 의존해서는 안된다.
따라서 상위모듈은 하위레벨 세부 사항 로직에 관계 없이 작동하고 있기 때문에 의존관계 역전 원칙을 준수하고 있는 예시가 될 수 있겠다고 생각했습니다.


마무리

과제를 받는 당일은 일정이있어서 다음날부터 과제를 시작해야했다는 점이 조금 아쉽다는 생각도 들기도했고, 짧은 시간 동안 jest 라이브러리를 학습하고, MVC 패턴에 대해서 다시금 상기시킬 수 있어서 좋았습니다. 또한 SOLID 원칙은 알고는 있었지만 제대로 알지는 못했어서 이번 기회에 확실히 접해볼 수 있었습니다. SOLID원칙을 제 코드에 반영하면서 하나의 원칙만 준수되는게 아니라 하나만 준수해도 여러 원칙들이 병행적으로 준수된다는 사실도 경험할 수 있었습니다.

또한 과제 구현 이후에도, 안주하지않고 리팩토링할 부분을 찾아 스스로 찾아 나가는 과정이 꽤나 재밌었습니다.
개선점을 꾸준히 찾고 개념, 패턴, 원칙을 공부해 실제로 적용해보았을 때, 정말로 코드의 가독성이나 높아지고 제 자신 스스로 점점 더 모듈화를 할 수 있다는 것이 신기했습니다.
저에게 정말로 많은 것을 배울 수 있었던 경험이었다고 확신합니다. 다음 주차 과제에서도 학습할 거리를 찾으러 가야겠습니다!

Reference Doc

Javascript에서도 SOLID 원칙이 통할까?
[javascript]class 생성 , getter setter 적용하기
프론트엔드에서 비즈니스 로직과 뷰 로직 분리하기 (feat. MVI 아키텍쳐)

김다은 이모지
Daeun Kim
Junior Frontend Engineer