프리코스를 시작하며
프리코스를 시작하고 일주일이라는 시간이 금방 지나갔다.
커뮤니티에서는 해당 주차 미션에 관한 이야기를 지양하기 때문에 그동안 말하고 싶었던 것을 꾹 참았다.
처음 미션 관련 메일을 받았을 때 얼마나 설렜는지 모른다.
다만 저번 기수처럼 1주차엔 알고리즘 관련 온보딩 미션이 나올 줄 알았는데 숫자 야구 미션이 나왔다.
그래도 하나의 프로그램을 완성해야하기 때문에 꽤나 재미있었다.
첫주 차이기 때문에 나는 처음부터 완벽한 코드를 짜기 보다는, 프리코스에 익숙해지기 위해 노력했다.
가령 자바 컨벤션을 습관화하기 위해 신경썼고, 의미있는 커밋 메시지를 작성하기 위해 연습했다.
또한 변수나 메서드 이름을 명확하게 짓는데에 오랜 시간을 투자하는 것처럼 클린 코드를 작성하는 것을 목표로 했다.
1주차 미션 - 숫자 야구 (내 코드)
https://github.com/junseoparkk/java-baseball-6/tree/junseoparkk
미션은 기능 요구 사항', '프로그래밍 요구 사항', '과제 진행 요구 사항' 세 가지의 요구 사항이 주어진다.
먼저 기능 요구 사항을 살펴보자.
[1] 요구사항
나는 숫자 야구 게임 규칙을 알고 있었기 때문에 이해하는데엔 어려움이 없었다.
하지만 MVC 패턴을 적용하기 위해 노력하던 중 큰 고민에 빠지게 되었다.
가장 처음 구현한 코드는 'model', 'view', 'controller' 세 개의 패키지로 나눴다. 이전 기수의 프리코스 후기를 봤을 때 MVC패턴을 적용했다는 후기가 압도적으로 많았기 때문이다. 그래서 나도 아무 생각 없이 해당 패턴으로 구현해야하나 싶었다.
그래서 'RandomNumberGenerator'클래스와 'Referee'클래스를 통해 각각 세 자리 숫자를 생성하고, 볼, 스트라이크를 판단하는 기능을 넣어주었다. 그런데 요구 사항을 다시 한번 자세히 읽어보니 '컴퓨터'가 1~9사이의 서로 다른 임의의 수를 선택하고, 사용자가 입력한 숫자에 대한 결과를 출력하라고 돼있었다.
이를 본 뒤로 기존에 하려던 방향대로 코드를 작성해야할지, 요구 사항을 따를지 생각해봤다. 결론은 후자였다. 분명 1주차이고, 특정 패턴으로 유도하는 내용이 없다면 요구 사항을 따르는게 맞다고 판단했기 때문이다. 또한 MVC패턴을 완벽하게 이해한 것도 아니었기 때문에, 현재 나의 실력을 객관적으로 판단하고자 실력 그대로 코드를 작성했다.
실제 게임의 흐름대로 작은 단위부터 생각하니 'game', 'view', 'utils' 세 패키지가 나왔다. 결론적으로 MVC패턴과 흡사한데, 이때 디자인 패턴을 정하고 설계하는 것보다 객체 지향적으로 설계하고, 좋은 구조를 만들기 위해 노력하다보니 왜 디자인 패턴이 필요한지 알게 되었다. 각 클래스의 역할과 책임을 분리할 때 유용했지만 아직 MVC 패턴에 대한 이해가 부족하여 결국 적용하지는 않았다.
[2] 커밋 컨벤션
미션 요구 사항에 커밋 컨벤션에 관한 구체적인 언급은 없었지만, 이전에 커밋 메시지에 관한 내용을 본 적이 있어 공부하기로 결정했다.
기본적인 메시지 구조는 아래처럼 제목, 본문, 꼬리말을 빈줄로 구분한다. 자세한 내용은 이후에 다른 글에서 다뤄보겠다.
type : subject //제목
body //본문
footer //꼬리말
하지만 실제 커밋시 태그: 내용 의 형태로 작성했다. 자주 사용한 태그는 아래와 같다.
- feat : 새로운 기능 추가
- fix : 버그 수정
- docs : 문서 수정
- style : 코드 변경 없이 코드 포맷팅, 단어 문법 수정 등
- refactor : 리팩토링
- test : 테스트 코드 작성 및 수정
처음엔 커밋 메시지를 깜빡하고 커밋한 적이 많았다. 그래서 메시지를 수정하기 위해 다양한 방법을 찾아봤고, 결론적으로 git 명령어를 조금 더 이해할 수 있었다. 그 중 'git rebase -i HEAD~n' 명령어를 자주 사용했는데, 이는 가장 최근 커밋부터 n번째 커밋까지 내역을 확인하는 명령어다. 여기서 에디터 모드로 바꾸고 수정을 원하는 커밋을 'reword'로 변경 후 메시지를 수정하면 된다.
아무튼 의식적으로 컨벤션을 지키려고 노력하니 점점 익숙해졌다. 독학할 땐 전혀 고려하지 않았을 법 한데, 프리코스 1주차임에도 불구하고 벌써 배워가는 것이 생긴다. 앞으로 얼마나 성장할 수 있을지 기대가 많이 된다.
[3] 일급 컬렉션
미션을 진행하다가 '일급 컬렉션'에 대해 알게 되었다. 일급 컬렉션은 간단하게 멤버 변수로 하나의 컬렉션만 가져야 하고, 이외 다른 멤버 변수는 없어야 한다. 즉, 하나의 컬렉션만을 Wrapping한 클래스가 되겠다. 아래는 내가 적용한 코드 중 일부이다.
//리팩토링 전
public class GameNumbers {
private List<Integer> numbers;
public GameNumbers(List<Integer> numbers) {
validate(numbers);
this.numbers = numbers;
}
public int getNumberByIndex(int index) {
return numbers.get(index);
}
public List<Integer> getNumbers() {
return Collections.unmodifiableList(numbers);
}
public int size() {
return numbers.size();
}
private static void validate(List<Integer> numbers) {
Validator.validateNumbersSize(numbers);
Validator.validateNumberRange(numbers);
Validator.validateIsContainDuplicateNumber(numbers);
}
}
3개의 숫자를 다루는 숫자 야구 게임에서 필요한 숫자를 담는 클래스이다. 여기서 특이한 점은 생성자에서 검증을 진행한다는 점이다. 실제로 값을 받아와 검증 후에 올바른 값일 때만 멤버 변수에 할당하는 방식이다. 처음엔 이런 방법도 있구나 생각하며 놀랐고, 흥미로웠다.
실제로 'validate'함수에는 숫자를 검증하는 로직이 들어있다. 3개의 숫자로 이루어져 있는지, 각각 1~9사이의 숫자인지, 중복된 숫자가 없는지 검증한다. 만약 이에 위배된다면 'IllegalArgumentException'을 발생하며, 따로 예외 처리를 하지 않는다면 프로그램이 종료된다.
하지만 리팩토링을 거치면서 몇몇 부족한 부분을 발견했다. 일급 컬렉션이 가지는 이점을 제대로 살리지 못한 것이다.
- 컬렉션의 불변성을 보장하지 못했다.
- getter 형식의 메소드만 가지므로, 상태와 행위를 함께 관리하지 못했다.
- 비즈니스 구조에 종속적이지 못했다.
이후 아래의 코드처럼 리팩토링했다.
//리팩토링 후
public record BaseballNumber(int number) {
private static final int MINIMUM_NUMBER_RANGE = 1;
private static final int MAXIMUM_NUMBER_RANGE = 9;
public BaseballNumber {
validateNumberRange(number);
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o instanceof BaseballNumber) {
return ((BaseballNumber) o).isSame(number);
}
return false;
}
@Override
public int hashCode() {
return Objects.hashCode(number);
}
public boolean isSame(final int target) {
return number == target;
}
...
}
//리팩토링 후
public class BaseballNumbers {
private static final int NUMBERS_SIZE = 3;
private final List<BaseballNumber> numbers;
public BaseballNumbers(final List<Integer> numbers) {
validateNumbers(numbers);
this.numbers = convertToBaseballNumbers(numbers);
}
public int size() {
return numbers.size();
}
public boolean contains(final int number) {
return numbers.stream()
.anyMatch(baseballNumber -> baseballNumber.isSame(number));
}
public int findNumberByIndex(final int index) {
return numbers.get(index).number();
}
...
}
1. 우선 멤버 변수와 인자에 'final' 키워드를 통해 불변값으로 다루도록 했다. 리팩토링 전 코드에서 'getNumbers()' 메소드에서 사용한 'unmodifiableList'는 불변 리스트를 반환한다. 그럼에도 원본값의 불변성을 완벽하게 보장하지 못하기 때문에 조금 더 생각해야될 필요가 있다. 가령 'new ArrayList(Collections.unmodifiableList(numbers))' 처럼 새로운 리스트를 할당하는 방법처럼 말이다.
2. 하지만 최대한 getter사용을 지양하고 메시지 전달을 통해 구현하고 싶었다. 어느 정도 선까지 getter를 허용해야 하는지는 더 공부해야겠지만, 결국 'contains()' 메소드를 통해 인자로 전달된 값이 포함되어있는지 여부를 반환하도록 구현했다. 이를 이해하기 위해 숫자 값을 Wrapping한 'BaseballNumber' 클래스를 살펴보자.
해당 클래스에선 숫자 하나에 대한 검증을 책임진다. 또한 'isSame'를 통해 getter의 사용을 줄일 수 있었다. 클래스가 아닌 'record'로 구현하였기 때문에 자연스럽게 불변성도 보장할 수 있다. 이 때 많은 고민을 하게 된 부분은 '모든 원시값을 포장해야하는가?' 이다. 내가 내린 결론은 비즈니스 로직과 관련된, 어떠한 책임을 가질 수 있다면 포장해도 된다는 것이다.
3. 위의 1, 2번을 지키기 위해 코드를 작성하다보니 자연스럽게 3번도 만족하게 되었다. 단순히 값을 가져오는 기능만 가진 객체가 아닌, 해당 객체에서 필요한 상태와 행위를 관리하여 비즈니스 구조에 종속적으로 바뀌게 되었다.
[4] 함수의 분리
1주 차 이후부터 공통 피드백에 등장한 내용이다. 함수를 최대한 작게 만들어 하나의 기능만 갖도록 하면, 자연스럽게 가독성은 좋아질 것이며 함수의 길이가 줄어들 것이다. 아래는 리팩토링 전 코드이며, 피어 리뷰를 통해 함수를 분리하면 좋겠다는 의견을 받았다.
//리팩토링 전
public class Computer {
private static final int GAME_NUMBER_SIZE = 3;
private static final int MIN_GAME_NUMBER_RANGE = 1;
private static final int MAX_GAME_NUMBER_RANGE = 9;
private List<Integer> answer;
public Hint getHint(GameNumbers user) {
int ball = 0;
int strike = 0;
for (int i = 0; i < GAME_NUMBER_SIZE; i++) {
int userNumber = user.getNumberByIndex(i);
int computerNumber = answer.get(i);
if (answer.contains(userNumber) && userNumber != computerNumber) {
ball++;
} else if (answer.contains(userNumber) && userNumber == computerNumber) {
strike++;
}
}
return new Hint(List.of(ball, strike));
}
'getHint()' 는 사용자의 입력으로부터 볼, 스트라이크 개수를 계산하는 함수이다. 그런데 자세히 보면 if 문을 통해 볼과 스트라이크의 개수를 계산하는 로직이 함께 들어있다. 이를 더 작은 함수로 분리하면 아래처럼 바꿀 수 있을 것이다.
//리팩토링 후
public class Referee {
public BallCount judge(final BaseballNumbers computer, final BaseballNumbers user) {
int ball = calculateBallCount(computer, user);
int strike = calculateStrikeCount(computer, user);
return new BallCount(ball, strike);
}
private int calculateBallCount(BaseballNumbers computer, BaseballNumbers user) {
int result = 0;
for (int i = 0; i < computer.size(); i++) {
int computerNumber = computer.findNumberByIndex(i);
int userNumber = user.findNumberByIndex(i);
if (isContain(computer, userNumber) && isNotSame(computerNumber, userNumber)) {
result++;
}
}
return result;
}
private boolean isContain(BaseballNumbers computer, int userNumber) {
return computer.contains(userNumber);
}
private boolean isNotSame(int computerNumber, int userNumber) {
return computerNumber != userNumber;
}
private int calculateStrikeCount(BaseballNumbers computer, BaseballNumbers user) {
int result = 0;
for (int i = 0; i < computer.size(); i++) {
int computerNumber = computer.findNumberByIndex(i);
int userNumber = user.findNumberByIndex(i);
if (computerNumber == userNumber) {
result++;
}
}
return result;
}
}
'Referee' 클래스는 컴퓨터가 하던 역할인 볼, 스트라이크 개수를 구하는 심판 기능을 가진 클래스이다. 'judge()' 의 인자로 컴퓨터와 사용자의 숫자를 전달하면, 'calculateBallCount()', 'calculateStrikeCount()' 를 통해 각각 볼과 스트라이크 개수를 계산한다. 또한 if 문 내에 조건을 넣지 않고 'isContain()' 과 'isNotSame()' 함수로 분리하였다. 글자 그대로 잘 읽히지 않는가?
다음으로 반환 타입을 보면 리팩토링 전엔 'Hint', 리팩토링 후엔 'BallCount'인 것을 알 수 있다. 네이밍에서 굉장히 많은 고민을 했지만, 후자가 더 명확하다고 생각하여 해당 이름을 사용하게 되었다. 또 하나 다른 점은 역시 클래스가 아닌 record로 구현했다는 점이다.
//리팩토링 전
public class Hint {
private static final int BALL_INDEX = 0;
private static final int STRIKE_INDEX = 1;
private List<Integer> hint;
public Hint(List<Integer> hint) {
this.hint = hint;
}
public int getBall() {
return hint.get(BALL_INDEX);
}
public int getStrike() {
return hint.get(STRIKE_INDEX);
}
}
단순히 getter만 가진 클래스이다. 이는 과연 객체스럽게 사용할 수 있는 객체일까? 오히려 DTO의 역할이라고 보는 것이 낫겠다. 또한 이러한 기능을 위해 record로 구현하는 것이 낫다. 다음은 같은 기능이지만 매우 간단하게 표현된 결과이다.
//리팩토링 후
public record BallCount(int ball, int strike) {
}
이제 DTO의 역할을 하는 객체는 record를 사용하는 것이 좋겠다.
[5] 마무리
사실 회고를 미션이 끝날 때 바로 작성하려고 했다. 하지만 짧다고 하면 짧고 길다고 하면 긴 일주일 동안 꽤나 많은 내용을 학습했다. 실제로 소감문을 작성하는데에 하루는 꼭 투자했기 때문에 이렇게 자세히 블로그에 회고를 작성할 수 없었다. 처음엔 당시 학습한 내용만 작성하려고 했지만, 이후 수정을 통해 리팩토링 전과 후를 비교하는 내용을 포함하기로 결정했다. 실제로 내가 어떤 부분이 부족하여 무엇을 공부했고, 얼마나 발전했는지 눈에 보이기 때문이다. 독학할 때 전혀 생각하지 못했던 부분들이 많았다. 프리코스는 미션과 요구 사항을 던져주기만 했는데 이렇게 많은 것을 배울 수 있다니 정말 놀랍다. 우테코가 바라던 성장 방법이 이런게 아닐까 싶다. 스스로 학습할 내용을 판단하여 익히는 것 말이다. 아무튼 1주 차는 프리코스 미션 방식에 적응하는 것이 최우선 목표였다. 또한 git 메시지와 Java 컨벤션을 의식적으로 익히려는 연습을 했다. 나에겐 고통이 아닌 즐거움과 성장의 시간이었다.