이번 과제는 뭐가 나올까 했는데 자동차 경주 게임이 나왔다. 해당 과제는 지난 겨울 방학 멘토링 자격시험으로 자바를 처음 접할때, 진짜 아무것도 모를때 시작했던 과제였는데 그때보다 나은 프로젝트를 만들겠다는 다짐으로 시작했다.
https://github.com/woowacourse-precourse/java-racingcar-6
GitHub - woowacourse-precourse/java-racingcar-6
Contribute to woowacourse-precourse/java-racingcar-6 development by creating an account on GitHub.
github.com
이 글에선 2주차 과제를 하며 내가 고민하고 느낀 것들, 피드백을 정리해보고자 한다.
프로그래밍 요구사항 변경
1. indent 2 이하: 반복문 + 조건문을 쓰게 되면 바로 2까지 가게 된다. 소프트웨어 공학때 배운 것 처럼 경우를 나눌게 많아지면 가독성이 떨어진다. 이를 해결하기 위한 메서드를 최대한 분리하자. 스트림을 쓰자 이런 생각이 들었다.
2. 3항 연산자 쓰지 않기: 이건 원래 잘 쓰지 않는다.
3. 메서드 최대한 작게 만들기: 한 함수가 한 가지 일만하게 하자.
4. 테스트코드 추가: AssertJ와 JUnit 5를 잘 사용해보지 않았기에,, 공부가 필요하다.
또 한가지가 추가되었는데 기능 구현 명세를 docs/README.md에 정의하고, 그 단위로 커밋하라는 내용이었다. 아, 초기에 기능을 상세하게 나누는 것이 중요하다는 생각이 들었다.
지난 주차를 마치곤 설계와 final을 더 적극적으로 활용하기로 했으니 유의해야겠다.
설계
우선, 필요한 객체 설계를 아래와 같이 했다.
Player - Game - Car의 구조를 생각해 Game이 중간에서 Player와 Car 사이의 중간자 역할을 한다고 생각했다.
따라서, 아래와 같이 클래스의 역할을 정의했다.
설계에 대한 리뷰
우선, Player 객체가 진짜 필요할까? 였다. 확장 가능성을 고려해, Console이 아닌 다른 객체나 서비스와 연동되어 참가자를 선정할 가능성을 생각하고 Player의 역할을 명시하는 객체를 만들었다.
하지만, 코드리뷰와 MVC 구조를 생각해봤을때 여기서 게임에 참가자는 명백하게 Console에 Input할 수 있는 사용자다. 혼자 생각한 확장 가능성은 불필요하다. 요구사항을 잘못 분석했다.
아무래도 MVC 패턴에 대한 이해가 부족했던 것 같다.
바뀔 수 있는 기능 목록
아무래도 1주차의 경험을 토대로 기능 명세와 도메인별 필요 기능을 확실 하는 것이 좋다고 생각해 설계를 다 끝내고
설계 -> 구현
절차를 따르고자 했다. 하지만, 구현을 하며 자동차의 정지기능을 만들어야할까? `아 이 기능은 적절하지 않구나`와 같은 점을 느껴가며 기능 명세가 바뀐다는 것을 느꼈다.
2주차 피드백을 보니 기능 명세에 대해 너무 하드하게 생각했다는 것을 깨달았다. 기능 명세를 하되, 바뀔 가능성을 살리자
그렇게 하면 아래와 같은 장점을 얻을 수 있다
- 기능 명세를 끝내고 수정하지 않고 계속 구현하게 되면 명세에 과하게 집중하게 되는데, 이를 방지할 수 있다.
- 죽지 않고 살아있는 문서를 만들 수 있다.
그렇다면, 기능 명세와 구현 시작의 구분점을 어떻게 잡아야할까에 대한 고민이 되는데 한 가지 기준을 정해야겠다.
2주차 피드백과 리뷰해주신 분들의 의견을 반영해야겠다.
`메서드 타입, 반환 값보단 구현해야할 진짜 기능 목록에만 집중하자`
또한, 이번주에 추가된 요구사항인 기능 별 테스트하기가 있었는데, 이때 아, 내가 기능을 잘못짰구나를 느꼈다. 조금 어려우면 무엇을 테스트해야하는가? 에 대해 집중해보고자한다.
구현
이번에 구현하면서 오히려 편하다는 느낌이 들었는데, 주어진 요구사항인 Indent를 줄이자에 집중하다 보니 최대한 반복문과 조건문의 사용을 줄이고 새로운 메서드를 분리하고자 했다.
그렇게 구현을 하려면 Stream이나 List의 forEach를 사용해야했고 그 결과 명시적으로 코드를 작성할 수 있었다.
랜덤 숫자를 어디에서 생성할거야?
요구사항을 보면, 자동차는 생성된 랜덤 숫자에 따라 전진 또는 정지 여부를 결정한다는 내용이 나왔다. 이 랜덤 숫자를 어디에서 생성하고 넘길지에 대한 고민을 했다. 특히 경주에 참가한 자동차 리스트를 외부 반복으로 꺼내 하나씩 반복시키고 싶지 않았다.
그 경우엔
1. indent가 길어진다.
2. 하나씩 꺼내어 전진시키는 것은 실제 경주를 추상화했을때와 맞지 않다고 느꼈고 코드가 명시적이지 않다고 생각했다. (동시에 전진하는 느낌이 아님)
그래서 우선, Stream을 고민했다.
1. 게임에서 넘겨준다.
처음엔 이렇게 작성할 경우 Stream의 map을 쓰는 경우를 생각해 아래와 같은 코드를 생각했다.
carList.stream()
.map(car -> car.move(RandomUtil.getRandomNumber()))
.toList();
하지만, 이 경우엔, map의 Car.move() 메서드가 Car라는 새로운 인스턴스를 생성해서 반환해야했고 새로운 인스턴스를 만들면 기존 인스턴스는 GC에 의해 사라지겠지만 아래와 같이 다른 인스턴스를 만든다는 것 자체가 명시적이지 않다고 생각했고 youth와 movedYouth가 같은 인스턴스임을 만들기 위해 equals등을 재정의 해줘야할 필요가 있었다.
Car youth = new Car("youth");
Car movedYouth = youth.move(4);
2. Car 내부에서 생성한다.
캡슐화에 집중해서 코드를 작성하고자 했고 아래와 같이 외부에선 tryMove()를 호출하고 이 메서드 내부에서 랜덤 값을 부르고 전진 여부를 결정하는 로직 handleMove를 private하게 아래와 같이 짰다.
public void tryMove() {
int randomNumber = RandomUtil.getRandomNumber();
handleMove(randomNumber);
}
private void handleMove(int randomNumber) {
if (randomNumber >= MOVE_DELIMITER) {
moveForward();
}
}
이렇게 작성하면 아래와 같이 스트림이 아닌 Iterable의 forEach를 사용할 수 있었고 메서드 참조를 통해 가독성을 높일 수 있었다.
private void raceOneTime() {
carList.forEach(Car::tryMove);
}
하지만! 역시 이렇게 작성하게 되면 Random값을 외부에서 전달할 수가 없어서 기능 테스트를 하기가 어려웠다...
메서드 참조에 집중하다보니 파라미터를 전달할 수 없기도 했고 캡슐화에 더 초점을 맞췄기에 파라미터를 전달하는 것 자체를 생각치 못했다.
해결책이 있었네?
조금 지나서, 리뷰를 보고 글을 작성하며 든 생각인데 아래와 같은 방법을 사용할 수도 있었을 것 같다. 랜덤값에 따라 전진 여부를 결정하는 move 메서드를 만들고 파라미터로 랜덤 값을 전달하면 아래와 같이 forEach를 사용할 수 있었다.
private void raceOneTime() {
carList.forEach(car -> car.move(RandomUtil.getRandomNumber()));
}
메서드 분리와 캡슐화
새로운 메서드 분리를 많이했는데, 나는 최대한 캡슐화에 집중하고자 했다. 내부적으로 처리를 위한 부분을 다 Private으로 만들고 외부에서 내부 구현 코드 로직을 알 수 없고 내부 변수 세팅을 할 수 있는 경로를 최대한 줄이고자했다.
위와 같은 틀로 public에선 private 메서드를 호출하고 중요한 구현은 다 숨기고자 노력했는데 그 목표는 잘 이룬 것 같다!
하지만,, 과제 요구사항을 따르다보니 다른 문제가 생겼는데 테스트 코드를 짜기 어려웠다.
어디까지를 외부에서 set할 수 있고 어디까지 숨겨야할까?에 대한 고민이 깊어졌다.
테스트
기능 목록에 따른 테스트 코드를 작성하는 것이 필요했다. 그래서 구현이후 최종 README.md를 보며 테스트하고자 하는 기능 목록을 봤다.
여기서 캡슐화에 집중해 코드를 짜버린 것에 대한 문제가 발생했는데 보통의 테스트는 given, when, then 패턴을 따른다.
given에 필요한 객체나 입력을 세팅, when에서는 구체적인 행동을 했을때, then은 예상되는 결과와 맞는지 확인하는 단계를 따르는데 값 세팅을 Console에서만 할 수 있게 하다보니 하나의 객체, 메서드만으로 값을 세팅하기 어려웠다.
어쩔 수 없이 아래와 같이 NsTest를 사용해 테스트 코드를 짰다.
public class RaceTest extends NsTest {
private static final int MOVING_FORWARD = 4;
private Game game = new Game();
@Test
void 자동차_경주_결과_출력() {
assertRandomNumberInRangeTest(() -> {
run("youth", "1");
assertThat(output()).contains("실행 결과", "youth : -");
},
MOVING_FORWARD, MOVING_FORWARD
);
}
@Override
protected void runMain() {
game.raceSetting();
game.race();
}
}
캡슐화에 집중한다는 점, Controller를 제대로 활용하지 않은 점에서 클래스간의 의존성이 높은 코드를 짜고 있었다는 것을 느꼈다.
다행히 프리코스 디스코드에는 좋은 정보를 공유해주는 분들이 많았는데 같은 고민을 한분의 글이 있었다.
정리하자면 public만 테스트를 해야한다. private을 테스트하면 그것은 잘못된 설계를 한 것이다는 내용이었다.
https://mangkyu.tistory.com/235
[Java] Private 메소드를 테스트하는 방법과 이를 지양해야 하는 이유
이번에는 private 메소드를 테스트하는 방법에 대해 알아보도록 하겠습니다. 미리 이 글의 결론을 말씀드리면 private 메소드를 테스트하면 안된다는 것입니다. private 메소드를 테스트하는 코드를
mangkyu.tistory.com
또한, 나와 같이 과하게 캡슐화를 해서 private을 테스트해야하는 경우가 발생한다면 이는 Client가 내부 구현을 역으로 알아야 테스트가 가능하다는 점, 테스트가 어렵기 때문에 유지보수 비용이 높아진다는 문제점이 있었다.
총 회고
프리코스 2주차를 진행하니 단순히 과제에서 주는 요구사항을 따르는 것에서 끝나는 것이 아니라 그에 따라서 공부해야할 것들이 줄줄 나온다는 것이었다.
이번 과제를 마치고 리뷰를 하면서도 일급 컬렉션이 무엇인지, 객체 당 상태변수를 2개 이하로 두는 것이 좋다는 점, MVC패턴에 대한 공부, JUnit5와 AsserrtJ, 테스트 코드에 대한 공부가 필요하다는 것을 느꼈고 자바17에서 사용하는 Record, Repeat, toList()와 같이 딸려서 공부해야할 것이 많다는 것을 느꼈다.
이게 프리코스의 진짜 목적같다. 한 번 잘 따라가서 더 성장해보자
참고
제출 시 작성한 과제 후기
과제를 진행하며 객체의 캡슐화에 대해 유의하며 과제를 진행했습니다. 외부에서 객체의 변수를 세팅하거나 주요 로직을 실행할 수 없도록 기능을 짰으나 막상 테스트 코드를 작성할 때 실제 Console.readLine()으로만 필요한 값을 세팅을 할 수 있어 원하는 테스트 Input과 Output을 확인하는데 어려움을 겪었습니다.
요구사항과 기능을 도메인 별로 정의하고 기능을 구현했습니다. 그러나, 한 기능이 객체별로 엮여있어 정의한 특정 객체의 기능을 구현하려면 다른 객체의 기능이 필요하다는 것을 알게 되었고 기능 명세를 보다 세분화하고 기능끼리 의존 관계가 떨어지도록 기능을 설계하는 것이 중요하다는 것을 느꼈습니다.
다음 과제에선 이 두 점에 더 유의해서 과제를 진행해야겠다는 다짐을 했습니다.
'BE > 우아한테크코스' 카테고리의 다른 글
[우아한테크코스 6기] 프리코스 - 4주차 (1) | 2023.12.06 |
---|---|
[우아한테크코스 6기] 프리코스 - 3주차 (0) | 2023.11.15 |
[우아한테크코스 6기] 프리코스 - 1주차 (0) | 2023.11.02 |
[우아한테크코스 6기] 프리코스 0주차 (1) | 2023.10.26 |