지난번 포스팅에 이어서 작성합니다. 야구 게임 구현을 위한 밑준비를 해놓고 마쳤던 거 같은데요! 일단 할 얘기가 많으니까 뭘 어떻게 했는지는 간단하게만 설명하겠습니다!
우선 MVC 패턴으로 분리했습니다!
이렇게 구현했습니다! 전체적인 흐름은, App.play로 구동 시 new BaseballGameController가 선언되고, 컨트롤러는 play 메서드로 새 정답을 설정한 후 입력을 받습니다. 입력받은 값을 평가한 후 승리 조건이 충족되면 커맨드 단으로 넘어가지만, 아닐 경우 다시 입력값을 받습니다. 커맨드 단에서는 종료 키를 입력하면 프로세스가 종료되고, 재시작 키를 입력하면 새 정답을 설정한 후 다시 숫자 입력을 받습니다.
여기까지 구현하니, node 명령어로 직접 App을 구동시켜서 확인해봤을 때 조건에 맞게 문제 없이 작동하는 것을 확인했습니다!(예외 처리 O / 정답 설정 조건 O / 점수 계산 O / 0볼이거나 0스트라이크일 때 등 유연한 메세지 처리와 출력 O / 커맨드 단 넘어가기 O / 재시작, 종료 O / 재시작 시 새로운 정답 설정 O)
그러나!! 테스트는 실패했습니다!!!
2개 케이스에서 모두 실패하는 걸 확인할 수 있었습니다. ApplicationTest를 뜯어보니, 다음과 같은 두 개의 테스트가 실행되는 걸 알 수 있었습니다.
[ERROR]
문자열로 시작되는 에러가 발생하는지(rejects.toThrow("[ERROR]"), 프로미스의 상태가 실패이므로 rejects, 위에껀 성공할것으로 예상하니까 resolves)확인하는 테스트이전 포스트에도 밝혔듯이, 작년 미션에서는 MissionUtils를 통째로 불러와서 사용할 때마다 MissionUtils.Console.print처럼 길다랗게 썼던 게 너무 귀찮아서, 이번에는 print, readLineAsync, pickNumberInRange를 따로 불러와서 사용하는 식으로 하기로 했었죠! 결과적으로 뭘 출력할 때는 그냥 print('에베베')로 해주면 되서 코드가 콤팩트해지고 저도 기분이 좋았는데요, 테스트가 실패한 이유는 이거였습니다.
const getLogSpy = () => {
const logSpy = jest.spyOn(MissionUtils.Console, "print");
logSpy.mockClear();
return logSpy;
};
이건 테스트 코드에서 '어떤 메세지가 출력되는지'확인하기 위해, MissionUtils.Console.print 함수의 호출을 감시하는 스파이를 생성하고 반환하는 함수입니다. 실패한 테스트 코드를 확인해봤을 때, 로그스파이가 호출을 확인한 결과 호출 횟수가 0이라는 내용이 있었습니다. 즉, 저는 뭔가를 출력할 때 그냥 print로 호출했기 때문에, MissionUtils.Console.print를 호출한 게 아니라서 스파이가 제 앱의 메세지 출력을 감지하지 못한 거죠!!
문제를 해결해서 기분은 좋았지만! 테스트 통과를 위해 미션유틸스 라이브러리의 함수를 사용하는 부분을 전부 MissionUtils.Console(혹은 Random).어쩌구저쩌구로 바꿔야 했습니다. 리팩토링하면서 테스트 코드를 변경하지 않고 우회할 수 있는 방법이 있는지 찾아보려구요!
두 번째 테스트가 실패했다는 건, Promise가 throw하지 않았다는 뜻입니다! 이상한 점은, 직접 구동해서 확인했을 때에는 에러가 제대로 발생하는게 확인된다는 것이었습니다.
export const validateUtils = {
validateNumbers(numbers) {
if (numbers.length !== GAME_CONSTANTS.ANSWER_LENGTH) {
throw new Error(ERROR_MESSAGES.INVALID_NUMBERS);
}
if (!isValidIntegers(numbers) || hasDuplicateNumber(numbers)) {
throw new Error(ERROR_MESSAGES.INVALID_NUMBERS);
}
},
validateCommand(command) {
if (!isValidCommand(command)) {
throw new Error(ERROR_MESSAGES.INVALID_COMMAND);
}
},
};
제 앱에서는 입력값에 대해 위 validateUtils의 내장 함수를 적용해서 유효성을 검사하고, 유효하지 않은 값이면 에러를 throw합니다. 근데 왜 테스트에서는 에러가 발생하지 않았다고 하는 걸까요? 내가 두 눈으로 똑똑히 봤는디??
라는 생각이 들어서 시도해봤습니다. 최상위 App.play에서 에러를 캐치하도록 해보기도 했고, processNumbers와 processCommands에 적용해보기도 했고, 아예 에러가 발생하는 validate함수를 실행시키는 process부터 모든 상위의 체인에 적용해보기도 했습니다 (이게 맞는 접근법이라고는 생각 안했지만 뭐라도 해보려고...) 그래도 안되더라구요! 오히려, try catch문으로 에러를 catch해서 다시 throw하게 만드니(결국 throw는 해야 하니까) 테스트 케이스가 실패하는 게 아니라 아예 도중에 종료되어버렸습니다!
아니 사실 범인은 접니다...재귀는 잘못한 게 없어요... 하지만 원인은 재귀입니다!
위에 설명한 제 앱의 구조를 보면, 여러 비동기 메서드들이 재귀적으로 다른 비동기 메서드를 호출하고 있습니다.
사실 프로그램 구조를 짤 때 재귀적으로 짠 것은 무슨 생각이 있어서가 아니라, 전에도 이렇게 했으니까 일케 하면 되겠지라는 안일한 발상이었습니다. 지난 번에는 콜백 기반으로 비동기 작업을 처리하도록(맞나?)되어 있어서 프로그램 구조가 재귀적이어도 문제가 없었던 건데, Promise 기반 비동기 작업을 하도록 이루어진 프로그램에서 재귀적으로 구조를 짠 게 문제였던 겁니다
교훈: '합격하러 온'게 아니다! 배우러 온 거다! (물론 합격하고 싶지만!) 지난번에 해 봤던 과제라고 대충 복붙해서 찌끄리러 온 거면 때려치우고 취준하러 가라! 까지는 아니고... 과제가 이전에 해봤던 거든 아니든, 어떻게 해야 할 지 충분한 고민 없이 되겠지~ 하고 대충 찌끄리는 식으로 하면 여기서 얻어갈 수 있는 게 별로 없을 거다. 그러기엔 4주라는 프리코스 시간이 너무 아깝다. 이 과정을 작년처럼 값진 경험으로 만들려면 자세를 똑바로 고쳐 앉아야겠다.
아아...답은 재귀가 아닌 반복이다.
재귀 구조에서 반복 구조로 변경하니, 예외 처리도 잘 작동하고 테스트도 통과하더라구요!
먼저 BaseballGameController에서 #gamestate 필드를 추가했습니다. 컨스트럭터로 초기값은 PLAYING으로 놔주고요!
그리고 컨트롤러의 play 메서드에서 아래와 같이 설정해줍니다.
while (this.#gameState !== QUIT) {
switch (this.#gameState) {
case PLAYING:
await this.readAndProcessNumbers(baseballGame);
break;
case COMMAND:
await this.readAndProcessCommand(baseballGame);
break;
}
}
자, 이제 play 메서드는 gameState가 QUIT이 아닐 때 gameState를 확인하고, PLAYING이면 숫자 입력을 받아 게임을 계속 진행하며, COMMAND이면 명령어 입력을 받아 게임을 끝낼지 다시 시작할지 결정합니다! gameState가 QUIT이 되면 반복문이 종료되면서 프로세스도 종료되겠죠!
async readAndProcessNumbers(game) {
const input = await this.#inputView.readUserInputNumbers();
validateUtils.validateNumbers(input);
const matchResult = game.calculateBallStrikeScore(input);
const [ball, strike] = matchResult;
this.#outputView.printMatchResult(matchResult);
if (strike === GAME_CONSTANTS.STRIKE_OUT_COUNT) {
this.#gameState = COMMAND;
}
}
readAndProcessNumbers 메서드는 점수를 계산하고 결과를 출력한 후에, strike가 3이면 gameState를 COMMAND로 바꿉니다! 이렇게 되면 while문에서 switch의 두 번째 케이스로 넘어가 커맨드를 입력받게 되죠! 만약 3스트라이크가 아니라면 다음 반복으로 넘어가서 readAndProcessNumbers를 다시 실행하구요!
async readAndProcessCommand(game) {
const input = await this.#inputView.readUserInputCommand();
validateUtils.validateCommand(input);
switch (input) {
case USER_COMMANDS.RESTART:
game.setNewAnswer();
this.#gameState = PLAYING;
break;
case USER_COMMANDS.QUIT:
this.#gameState = QUIT;
break;
}
}
readAndProcessCommand 메서드에서는 input이 재시작 키면 새 정답을 설정하고, gameState를 COMMAND에서 PLAYING으로 바꿉니다! 다음 while반복에서는 숫자 입력이 실행되죠!
그리고 종료 키를 누르면, gameState가 QUIT으로 바뀌면서 while 반복이 종료되고 => 프로세스도 자동으로 종료됩니다! 이렇게 게임의 페이즈를 '숫자 입력 단계', '명령어 입력 단계', '종료' 세 가지로 나누고, 반복문을 이용해 게임 상태에 따라 다른 메서드를 실행시키도록 구조를 바꿈으로써 재귀 구조로 인해 생기는 문제를 해소할 수 있었습니다!
하지만! 급하게 만드느라 책임 분리가 엉망이니(게임 상태나, 게임 상태를 변경하는 로직은 컨트롤러보다는 게임 모델 안에 있는 게 더 자연스러우니까요), 그 점을 중점적으로 리팩토링을 해보려고 합니다🫠 역시 산 넘으면 또 산이야! 짜릿해! 늘 새로워!