2023 우테코 프리코스 1주차 후기
개요
2023년 첫 번째 주 프리코스 미션이 마무리되었다.
문제 자체의 구현은 단순한 알고리즘 풀이에 불과했지만, “어떻게 하면 좋은 코드를 작성할 수 있을까?”라는 질문을 던지며 코드를 개선하는 데에 더욱 많은 시간을 할애했다.
1주차 동안 나에게 가장 큰 영향을 준 핵심 가치는 “객체지향 프로그래밍”이었다고 생각한다.
문제 요구 사항
1주차에서 구현해야하는 기능요구사항은 다음과 같다.
기본적으로 1부터 9까지 서로 다른 수로 이루어진 3자리의 수를 맞추는 게임이다.
같은 수가 같은 자리에 있으면 스트라이크, 다른 자리에 있으면 볼, 같은 수가 전혀 없으면 낫싱이란 힌트를 얻고, 그 힌트를 이용해서 먼저 상대방(컴퓨터)의 수를 맞추면 승리한다.
예) 상대방(컴퓨터)의 수가 425일 때
123을 제시한 경우 : 1스트라이크
456을 제시한 경우 : 1볼 1스트라이크
789를 제시한 경우 : 낫싱
위 숫자 야구 게임에서 상대방의 역할을 컴퓨터가 한다. 컴퓨터는 1에서 9까지 서로 다른 임의의 수 3개를 선택한다. 게임 플레이어는 컴퓨터가 생각하고 있는 서로 다른 3개의 숫자를 입력하고, 컴퓨터는 입력한 숫자에 대한 결과를 출력한다.
이 같은 과정을 반복해 컴퓨터가 선택한 3개의 숫자를 모두 맞히면 게임이 종료된다.
게임을 종료한 후 게임을 다시 시작하거나 완전히 종료할 수 있다.
사용자가 잘못된 값을 입력한 경우 throw문을 사용해 예외를 발생시킨후 애플리케이션은 종료되어야 한다.
즉 간단히 말해, 숫자 야구 게임 시나리오를 구성하고 입력을 받아 게임을 진행하도록 처리하는 문제였다.
기능 설계와 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 구현할 기능 목록
## 시작
- [x] 게임 시작 문구 출력
## 초기 설정
- [x] 임의의 다른 3자리의 정수 계산
## 게임 진행 (정답을 맞출 때 까지)
- [x] 사용자의 입력 처리
- [x] 예외처리 (정수 , 자릿수 , 잘못된 값)
- [x] 사용자의 입력에 대한 결과 출력
- [x] 스트라이크, 볼, 낫싱에 대한 결과 계산
## 게임 종료
- [x] 사용자 입력 처리 (1 : 재시작 , 2: 종료)
- [x] 입력에 대한 예외처리
- [x] 사용자 입력에 대한 로직 처리
- [x] "1" 입력 시 초기설정부터 플로우 반복
초기에는 위와 같이 플로우의 절차를 머리로 생각하고 각 흐름에 따라 필요한 기능을 정의하였다.
그리고는 위 절차에 맞춰 기능을 하나하나 구현하기 시작했고 결과적으로 아래의 코드를 완성시킬 수 있었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import { MissionUtils, Console } from "@woowacourse/mission-utils";
class App {
async play() {
Console.print("숫자 야구 게임을 시작합니다.");
let isGameEnded = false;
while (!isGameEnded) {
const answer = this.createThreeRandomNumbers();
console.log(answer); // debug용 로그
while (true) {
const userResponse = await this.promptUserForNumbers();
const { strike, ball } = this.compareNumbers(answer, userResponse);
this.displayGameStatus(strike, ball);
if (strike === 3) {
Console.print("3개의 숫자를 모두 맞히셨습니다! 게임 종료");
break;
}
}
isGameEnded = await this.promptNewGameOrExit();
}
}
createThreeRandomNumbers() {
const threeRandomInteger = [];
while (threeRandomInteger.length < 3) {
const number = MissionUtils.Random.pickNumberInRange(1, 9);
if (!threeRandomInteger.includes(number)) {
threeRandomInteger.push(number);
}
}
return threeRandomInteger;
}
compareNumbers(answer, userResponse) {
let strike = 0;
let ball = 0;
for (let i = 0; i < 3; i++) {
if (userResponse.charAt(i) - "0" === answer[i]) {
strike++;
} else if (answer.includes(userResponse.charAt(i) - "0")) {
ball++;
}
}
return { strike, ball };
}
displayGameStatus(strike, ball) {
let message = "";
if (ball !== 0) {
message += `${ball}볼 `;
}
if (strike !== 0) {
message += `${strike}스트라이크`;
}
if (!message) {
message = "낫싱";
}
Console.print(message.trim());
}
async promptUserForNumbers() {
let userResponse = await Console.readLineAsync("숫자를 입력해주세요: ");
userResponse = userResponse.trim();
if (userResponse.length !== 3) {
throw new Error("[ERROR]");
}
if (new Set(userResponse).size !== 3) {
throw new Error("[ERROR]");
}
if (isNaN(userResponse)) {
throw new Error("[ERROR]");
}
return userResponse;
}
async promptNewGameOrExit() {
let userResponse = await Console.readLineAsync(
"게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.\n"
);
userResponse = userResponse.trim();
if (userResponse.length !== 1) {
throw new Error("[ERROR]");
}
if (isNaN(userResponse)) {
throw new Error("[ERROR]");
}
if (userResponse === "1") {
return false;
}
if (userResponse === "2") {
Console.print("게임을 종료합니다.");
return true;
}
return false;
}
}
export default App;
코드 개선시키기
“완성!!!” 이라며 바로 마무리 지을 수도 있었지만, 나는 더 나은 코드를 위해 추가작업을 진행하기로 결정했다.
기본적으로 상수를 분리하고, 함수를 더 세분화하거나 변수명을 더 직관적으로 수정하는 작업을 진행하였다.
그러던 중, 많은 개발자들이 객체지향적으로 코드를 구조화하거나 MVC 모델을 사용하는 것을 보았다.
근데 굳이 객체로 분리해야하나?? 프론트엔드에서는 대체로 함수형으로 코드를 짜지 않나??
실제로 프론트엔드 개발을 하면서 클래스 기반의 객체지향적인 코드를 작성할 기회는 많지 않았다. 대부분의 코드는 함수형으로 작성했고, ‘객체지향’이라는 용어는 주로 백엔드에서 사용되는 개념으로 여겨왔기 때문이다.
디스코드의 채널에 글을 올렸고, 많은 분들이 적극적으로 답변을 주셨다.
결과적으로, 나는 다음과 같은 부분을 배우고 생각할 수 있었다.
-
상황에 따라 프론트엔드에서도 객체지향적인 코드를 작성할 필요가 있다는 점.
-
단순히 사용을 지양하기보다는 객체지향적인 코드 방식의 장단점을 명확히 이해하는게 중요하다는것. (패러다임이 언제 바뀔 지 모르니까)
-
적어도 절차지향 프로그래밍과 함수형 프로그래밍 역시 그 장점을 바탕으로 확실히 이해하고 사용할 필요가 있다고 생각했다.
객체?
자, 그래서 객체지향을 썼을때 어떤이점이 있을까?
객체지향 프로그래밍을 사용하면 코드의 재사용성, 유지보수성, 확장성, 가독성을 향상시킬수 있다
예를 들어, 쇼핑몰에서 상품을 관리하기 위한 서비스가 있고, 상품의 가격을 계산하는 기능이 있다고 가정해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 객체지향의 Class를 사용한 사례
class Product {
constructor(name, price, discountRate) {
this.name = name;
this.price = price;
this.discountRate = discountRate;
}
calculateDiscountPrice() {
return this.price - this.price * this.discountRate;
}
}
const shirt = new Product("셔츠", 30000, 0.2);
console.log(shirt.calculateDiscountPrice()); // 24000
위 예시에서 Product 클래스는 상품의 이름, 가격, 할인율을 속성으로 가지고 있고, calculateDiscountPrice 메서드를 통해 할인된 가격을 계산한다.
상품에 대한 정보(상태)와 기능(메서드)을 하나의 클래스로 묶어서 관리하기 때문에 구조가 명확하고,
또한 상품에 따라 별도의 카테고리가 필요하다면 상속을 통해 “의류”, “전자기기”와 같은 하위 클래스를 만들어 관리할 수 있다. 이는 확장성에 있어서도 유리한 점이다.
1
2
3
4
5
6
7
8
// 함수형 프로그래밍
function calculateDiscountPrice(price, discountRate) {
return price - price * discountRate;
}
const shirtPrice = 30000;
const shirtDiscountRate = 0.2;
console.log(calculateDiscountPrice(shirtPrice, shirtDiscountRate)); // 24000
반면, 함수형 프로그래밍에서는 상태와 로직이 분리되어 있어서 상태의 변화에 따른 부작용이 없다. 하지만 상품에 대한 정보를 계속해서 함수에 전달해야 하므로, 코드가 번거로울 수 있다는 단점이 있다.
사실, “숫자 야구 게임”과 같은 과제에서는 굳이 객체지향적인 접근이 필요하지 않다고 생각했다.
시스템이 복잡하지 않으며, 객체로 분리함으로써 얻을 수 있는 이점이 크게 두드러지지 않다고 생각했기 때문이다.
그러나 앞으로 다가올 과제는 훨씬 더 복잡할 것이고, 나는 결국 학습을 목표로 참여하고 있기에 “이 숫자 야구 게임은 미래에 확장될 가능성이 있다”라는 가정 하에 객체지향적인 방식으로 코드를 분리하기로 결정했다.
클래스 분리
기존에는 App클래스 내부에서 모든 기능이 처리되고있었다면, 객체지향 프로그래밍의 원칙에 따라 하나의 클래스는 하나의 책임만을 가지도록 여러 기능을 분리하고 클래스로 나누었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
## GameLifecycleManager
- [x] 초기 메세지 출력
- [x] 게임 재시작, 종료 전체의 라이프 사이클 관리
## Game
- [x] 사용자가 정답을 맞출 때 까지 게임을 반복하도록 수행
## Computer
- [x] 사용자가 맞춰야 하는 3가지의 랜덤 정수를 생성
## GameResult
- [x] 사용자의 입력값과 맞춰야하는 값을 비교해 결과 값을 생성
- [x] 정답 여부 판단
- [x] 게임 결과를 사용자 화면에 표현하기 위한 문자열 연산
## IOManager
- [x] 사용자로부터 숫자야구 정수를 입력받도록 처리
- [x] 사용자로부터 게임 재시작 커맨드를 입력받도록 처리
## Validation
- [x] 사용자로부터 숫자 야구 정수 입력 예외처리
- [x] 정수인지 확인
- [x] 중복된 정수가 없는지 확인
- [x] 요구하는 정답의 길이와 입력받은 문자열의 길이가 일치하는지 확인
- [x] 사용자로부터 게임 재시작 커맨드 입력 예외처리
- [x] 정수인지 확인
- [x] 1자리의 문자열이 맞는지 확인
- [x] "1" 또는 "2" 의 정수가 입력되었는지 확인
중간중간에 하나의 클래스에서 너무 많은 기능을 수행하는 것 같다는 피드백도 들었고 최대한 역할을 분리할 수 있도록 처리하는데 초점을 두었다.
Utils
추가로 서비스 플로우와 관계없이 공통적으로 사용될 수 있는 요소는 별도의 Utils로 분리하였다.
MissionUtils
을 통해 Random메서드와 Console메서드를 가져왔던 것 처럼 순수 유틸의 기능을 수행하는 요소는 재활용성을 고려해 따로 처리하는게 확장성면에서 좋을 것 같다고 생각했기때문이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { ERROR_MESSAGES } from "../constants";
class ValidatorUtil {
static validateLength(input, length) {
if (input.length !== length) {
throw new Error(ERROR_MESSAGES.INVALID_LENGTH(length));
}
}
static validateNotDuplicate(input) {
if (new Set(input).size !== input.length) {
throw new Error(ERROR_MESSAGES.DUPLICATE_VALUE);
}
}
static validateIsNumber(input) {
if (isNaN(input)) {
throw new Error(ERROR_MESSAGES.NOT_NUMBER);
}
}
}
export default ValidatorUtil;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Console } from "@woowacourse/mission-utils";
class IOManagerUtil {
static async getUserInput(message, validateFunction) {
let userResponse = await Console.readLineAsync(message);
validateFunction(userResponse.trim());
return userResponse;
}
static printMessage(message) {
Console.print(message.trim());
}
}
export default IOManagerUtil;
상수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export const GAME_MESSAGES = {
START: "숫자 야구 게임을 시작합니다.",
END: "3개의 숫자를 모두 맞히셨습니다! 게임 종료",
ENTER_RESTART_OR_QUIT:
"게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.\n",
ENTER_NUMBERS: "숫자를 입력해주세요: ",
EXIT: "게임을 종료합니다.",
};
export const ERROR_MESSAGES = {
INVALID_END_COMMAND: "[ERROR] 입력값이 1 또는 2가 아닙니다.",
};
export const COMMANDS = {
RESTART: "1",
EXIT: "2",
};
export const ANSWER_LENGTH = 3;
변하지 않은 요소나 가독성을 높이기 위해 특정 커맨드나 표현 메세지는 별도의 상수로 분리하여 처리하였다.
느낀점
이번 주차에서는 어떤식으로 관심사를 분리하고 객체로 정의하는지 이해하는데 크게 도움이 되었다.
특히 코드의 가독성이 얼마나 중요한지를 실감할 수 있었다.
명확하지 않은 변수명으로 인해 다른 사람의 코드를 이해하는 데 어려움을 겪기도 했기 때문에 명확하게 의미를 전달할 수 있는 네이밍에 크게 신경쓰는게 중요하다고 생각했다.
뿐만 아니라, 커밋도 적절한 단위로 나누어 명시했을 때 리뷰나 코드의 변경내용을 직관적으로 확인할 수 있기 때문에 중요한 요점이라고 느꼈다
(특히 나는 기능 구현을 다 하고 한꺼번에 docs를 업데이트했는데 그때 그때 해야했다ㅜㅜ..)
1주차 공통 리뷰의 일부 내용
이후의 2주차에서는 “테스트코드” 와 “MVC” 패턴을 위주로 의식해서 코드를 짜고 싶다고 생각했다.
특히 리펙토링을 했을 때 테스트 코드를 실행하여 빠르고 정확하게 검증할 수 있음을 느꼈는데 1주차에서 주어진 두 개의 테스트케이스에서 잡아내지 못한 플로우가 있지는 않을까 걱정도 많이 되었기 떄문이다..
이러한 경험을 통해 테스트 코드의 중요성을 더욱 인식하게 되었고, 앞으로의 프로젝트에서도 이를 적극 활용하고자 한다
기타 피드백
리뷰 남겨주신분들 너무 감사드립니다
https://github.com/woowacourse-precourse/javascript-baseball-6/pull/136
-
isNaN()
보다는Number.isNaN()
을 사용하자-
{} , undefined의 경우 예상치 못한 값이 표현될 수 있으므로
Number.isNaN()
이 보다 안정적인 방법 -
https://github.com/airbnb/javascript#standard-library–isnan
-
-
상수형 객체의 경우 Key값은 카멜케이스로 사용하기
- https://github.com/ParkSB/javascript-style-guide#%EB%AA%85%EB%AA%85%EA%B7%9C%EC%B9%99-naming-conventions
-
for문의 반복문 보다
map
,filter
,reduce
등의 고차함수 사용하기-
코드의 가독성과 유지보수성을 향상시킨다
-
그러나, 경우에 따라 for문이 효율적인 부분도 있다고 하기때문에 “무조건” 이라고 생각하지는 말기
-
-
if-else문 줄이고 depth를 최소화 하기
-
else문을 사용하면 가독성이 떨어진다.
-
depth가 깊으면 가독성이 어렵고, 하나의 함수에서 너무 많은 역할을 처리하고 있는건 아닌지 의심하고 분리하기
-
댓글남기기