https://github.com/woowacourse-precourse/java-racingcar-6/pull/149
2주 차를 마치고 느낀 점
구현 난이도는 1주 차보다 높진 않았던 것 같다. 오히려 1주 차보다 낮은 느낌을 받았다.
그런데 객체지향적 설계 같은 여러 가지 조건을 고려하다 보면, 이 미션 생각보다 까다롭다.
TDD를 처음으로 적용해 봤는데, 확실히 좋더라.
클래스의 역할 분리가 명확하게 되고, 더욱 견고하게 만들어지는 느낌이다.
무엇보다도 오류가 훨씬 적어져서, 오류 색출에 걸리는 시간이 대폭 줄어들었다.
순조롭게 진행을 하다가, 마지막 날에 큰 리팩토링을 해버리는 바람에 애를 먹었다.
코드를 고치는 건 괜찮은데, 테스트 코드를 옮기고 하는 게 진짜 괴롭더라...
오히려 괴로워하다 보니 설계가 얼마나 중요한지에 대해 뼈저리게 느끼게 됐다.
앞으로는 더더욱 생각 오래 하고 코딩해야겠다.. 너무 힘들다..
집중했던 부분
1. TDD(테스트 주도 개발) 설계를 통한 개발 진행
평소에도 테스트 코드를 항상 짜긴 했지만, 테스트 코드를 중점을 두고 해 본 적은 없는 것 같다.
우아한 테크코스에서도 TDD를 강조하기도 하기도 해서, 이번에는 TDD의 장점을 느껴보자 적용해 봤다.
README를 작성할 때부터 예외사항부터 먼저 생각했던 것 같다.
도메인을 작성한 뒤, 최우선적으로 생각해 준 것이 예외사항이었다.
각 도메인에서 발생할 수 있는 예외사항을 예상해 보고 이를 적어봤다.
@Test
@DisplayName("List에 Car 객체가 하나도 없다면 예외 발생")
void 빈_Car_객체_예외_O() {
//given
List<Car> cars = List.of();
//when,then
assertThrows(IllegalArgumentException.class, () -> new Cars(cars));
}
@Test
@DisplayName("Car 이름이 없는게 있다면 예외 발생")
void 빈_Car_이름_예외_O() {
//given
List<Car> cars = List.of(create(""), create("가"), create("다"));
//when,then
assertThrows(IllegalArgumentException.class, () -> new Cars(cars));
}
@Test
@DisplayName("하나라도 이름의 길이가 5가 넘었으니 IllegalArgumentException 발생시킨다.")
void 이름_길이_예외_O() {
//given
List<Car> cars = List.of(create("가"), create("나나나나나나"), create("다"));
//when,then
}
@Test
@DisplayName("이름이 중복이 있으면 예외 발생")
void 이름_중복_예외_O() {
//given
List<Car> cars = List.of(create("가"), create("가"), create("다"));
//when,then
assertThrows(IllegalArgumentException.class, () -> new Cars(cars));
}
그리고 해당 도메인 작성 전에 테스트 코드를 먼저 만들어, 실패 테스트를 먼저 하게 했다.
위는 README에서 예상한 예외사항들에 대한 테스트코드들이다.
다른 도메인들도 이와 같은 단계를 거쳐서 설계를 해나갔다.
해당 방식으로 설계하는 것은 확실히 평소 방식보다는 느렸다.
그런데 클래스가 쌓이면 쌓일수록 왜 해당 방식이 선호되는지를 알겠더라.
1. 에러를 잡는데 소모되는 시간이 줄어든다.
기존 설계 방식에서는 그 에러가 어디서 났는지를 파악하는 데에 많은 시간이 걸렸다.
하지만 설계할 때 미리 예외 사항에 대해 고려하고, 테스트까지 해보면서 개발을 진행하니 에러가 적어진다.
2. 리팩토링에 걸리는 시간이 줄어든다.
생각해 보니 테스트하기 쉬운 코드란, 해당 클래스가 명확한 기능을 가지고 있다는 것을 의미한다.
이는 객체지향 원칙과도 일치하는 것이라 TDD로 설계하다 보면 자연스레 모듈화가 되어있다.
객체지향적으로 설계하는 데에는 진짜 TDD 만 한 게 없다고 느꼈다.
2. 협업 또는 유지보수를 고려하여 구현
단순 구현이 아닌 미션인 만큼, 다양한 부분을 고려해야 한다고 생각했다.
그중 한 부분이 좋은 코드이라고 생각했다.
내가 생각하는 좋은 코드란 '협업이나 유지보수하는 입장에서 얼마나 편하게 할 수 있느냐?'이다.
어떤 기능을 추가하거나 오류를 고칠 때, 클래스가 역할별로 잘 분리되어 있고,
네이밍이 알아보기 쉽게 잘 되어있다면 협업하는 사람 입장에서는 편할 것이다.
읽기 좋은 코드 만들기
private void printWinners(List<Car> cars) {
System.out.print("최종 우승자 : ");
// convertNameToString이라는 함수를 통해 기능을 유추할 수 있게끔 함
String winners = convertNameToString(cars);
System.out.println(winners);
}
private String convertNameToString(List<Car> cars) {
return cars.stream()
.map(Car::getName)
.collect(Collectors.joining(WINNER_DELIMITER));
}
이번 미션에서는 들여 쓰기(indent)가 2줄 제한이었지만, 최대한 1줄이 되도록 했다.
또한 복잡한 로직이 들어간 경우,
해당 로직을 함수로 만들어서 그 이름을 통해 기능을 유추할 수 있도록 했다.
이는 협업자가 코드의 세부 사항을 보는 수고로움을 덜 수 있고,
주요 코드(public)만 볼 수 있게 하는 효과를 줄 수 있으리라 생각한다.
Controller 분리
컨트롤러 또한 같은 목적으로 분리했다. '조금 과한 것 아닐까?'라고 생각할 수도 있다.
하지만 오히려 컨트롤러가 모델과 뷰를 이어주는 역할이기 때문에
여기저기서 클래스를 가져오고 하다 보면, 더욱 코드가 난잡해지기 쉽다고 생각한다.
그래서 컨트롤러의 역할을 정확히 분리해 주는 것이 협업과 더불어
나중에 코드 유지보수에 큰 도움이 된다고 생각하여 Controller 분리를 결정했다.
해당 그림은 실제 이번 미션에서 사용한 view와 controller 클래스들 간의 public 연관관계이다.
여기서 하고 싶은 말은 각 Controller 구현체마다 쌍이 되는 View와 매핑되어 있다.
즉, 나중에 기능이 확장되어 입력 사항이나, 출력 사항이 추가돼도 확장이 쉽다.
또한 에러가 발생하여 이를 고쳐야 하는 상황이 와도, 역할별로 분리해 놨기 때문에
어느 클래스를 손봐야 할지 바로 알 수 있을 것이다.
확장 가능성을 고려하면서도 보안성을 중요시하는 설계
정확히는 은닉성이라고 하는 것이 맞는 거 같긴 한데, 보안성이 좀 더 와닿는 것 같다.
비밀번호 같은 것이나 하는 개인 정보는 함부로 노출되어서는 안 된다.
또한, 협업을 생각해서도 public으로 함부로 열어놨다가는 오용할 가능성이 생기기 때문에
사용 자체를 막아두는 편이 되도록이면 좋다고 생각한다.
그래서 이번 미션에서도 꼭 필요한 클래스나 메서드의 교환을 제외하고서는 이를 제한하려고 노력했다.
package racingcar.controller;
import racingcar.view.inputview.SettingInputView;
import racingcar.view.outputview.PlayOutputView;
import racingcar.view.outputview.ResultOutputView;
import racingcar.view.outputview.SettingOutputView;
final class ControlConfig {
private ControlConfig() {
}
static Controller createSettingController() {
return new SettingController(new SettingInputView(), new SettingOutputView());
}
static Controller createPlayController() {
return new PlayController(new PlayOutputView());
}
static Controller createResultController() {
return new ResultController(new ResultOutputView());
}
}
ControlConfig는 그저 Controller의 구성을 담당해서 다른 곳에서는 알 필요가 전혀 없다.
그래서 제한자를 default로 둠으로써 다른 패키지에서는 접근조차 못하게 막았다.
고민했던 부분
발생할 수 있는 요구사항 변경은 무엇이 있을까?
1. 차가 움직이는 방법(Rule)
내가 실제 자동차 게임 개발 중이라고 가정을 한다면, 차를 움직이는 법이 가장 바뀔 확률이 높을 것 같다.
지금은 한 가지 방법밖에 없지만, 만약에 클라이언트가 요구사항을 변경한다면? 충분히 발생할 수 있다.
심지어 게임의 재미를 위해, 차마다 움직이는 방법을 다르게 만들어달라고 하면?
그래서 움직이는 것을 moveRule이라는 인터페이스를 통해 변화에 잘 대응할 수 있도록 구현했다.
public interface MoveRule {
int tryMove();
}
public final class ThresholdScoreMoveRule implements MoveRule {
private final NumberGenerator numberGenerator;
public ThresholdScoreMoveRule(NumberGenerator numberGenerator) {
this.numberGenerator = numberGenerator;
}
private static final int MIN_MOVE_NUMBER = 4;
private static final int FORWARD = 1;
private static final int STOP = 0;
@Override
public int tryMove() {
int number = numberGenerator.generate();
if (number >= MIN_MOVE_NUMBER) {
return FORWARD;
}
return STOP;
}
}
2. 차에 대한 정보
또한 지금은 차에 대한 정보가 이름밖에 없지만 게임이 확장되면, 차에 영향을 미칠 확률이 높다.
그래서 빌더 패턴을 통해 마치 차에 대한 정보를 조립하도록 구현했다.
public final class Car {
private final String name;
private final MoveRule moveRule;
private int distance;
// Builder로만 접근할 수 있도록 생성자를 막아둠
private Car(String name, MoveRule moveRule) {
this.name = name;
this.moveRule = moveRule;
distance = 0;
}
public void move() {
distance += moveRule.tryMove();
}
public String getName() {
return name;
}
public int getDistance() {
return distance;
}
// 차를 만드는 빌더 패턴
public static class CarBuilder {
private final String name;
// MoveRule에 대한 기본값 지정
private MoveRule moveRule = new ThresholdScoreMoveRule(new RandomNumberGenerator());
public CarBuilder(String name) {
this.name = name;
}
public CarBuilder setMoveRule(MoveRule moveRule) {
this.moveRule = moveRule;
return this;
}
public Car build() {
return new Car(name, moveRule);
}
}
}
이렇게 설계했을 때의 첫 번째 장점은 Car이 MoveRule를 지정하지 않았을 때,
기본값으로 ThresholdScoreMoveRule을 가진다는 것이다.
이는 MoveRule에 대한 의존성을 setMoveRule로 유지한다.
또한 새로운 MoveRule이 생겨도 setMoveRule로
각 차에 붙여주기만 하면 되는 것이기 때문에 확장에 아무런 문제가 없게 된다.
2. 적절한 확장 가능성의 범위는 어디까지인가?
확장 가능성이란 것이 생각하다 보면 한도 끝도 없다. 적절한 범위가 어디까지일까?
클래스를 설계하면서 자꾸만 들었던 생각이다.
'내가 너무 과한 추상화를 하고 있나? 해당 추상화가 정말 필요할까?'
인터넷과 책을 통해 여러 가지 정보를 알아보고, 내 코딩 스타일과 가치관을 섞어 생각해 봤다.
최종적으로 '발생할 가능성이 높은 것에 대해서는 대비하는 것이 옳다.'라는 생각을 했다.
왜냐하면 올바른 추상화란 무엇일까를 생각해 봤다.
올바른 추상화는 추상화가 효과적으로 쓰였을 때 쓰는 말이다.
이를 쉽게 말한다면, 자주 쓰인다면 그건 올바른 추상화라는 뜻이라고 생각했다.
때문에 자주 쓰이기 위해서는 발생 빈도가 높은, 즉 확률이 높은 곳에 대한 추상화를 해야 한다고 생각했다.
아쉬웠던 부분
입력에 대한 테스트 코드
이번에는 입력에 대해서 깔끔한 테스트 코드를 짜지 못한 점이 아쉽게 다가온다.
우아한 테크코스 측에서 제공하는 readLine 메서드를 보니,
Scanner를 사용하는 것이 이 서 이와 관련된 인터페이스를 테스트케이스에서 재정의해서 사용했다.
근데 단일 테스트 코드 실행을 하면 잘 동작하는데, 입력 테스트 여러 개를 동시에 돌리면 계속 오류가 뜬다..
인터넷에서 4~5시간 동안 여러 가지 방법을 찾아봐도 해답을 찾을 수 없어서
결국엔 한 테스트 코드 메서드에 몰아넣어 검증하는 방법을 사용했다.
깔끔하지 않은 방법이어서 사용하기 싫었지만, 그래도 검증하지 않는 것보다는 낫으니까..
웃긴 건 출력은 또 된다.. 왜 안되지... 아직도 미스터리다..
Car 객체에서 distance(거리)를 분리하지 못한 점
내 코드를 보면 Car 객체에 움직인 거리를 나타내는 distance 필드가 있다.
이는 Game이나 다른 클래스가 가지고 있는 것이 맞다고 판단했지만, 이를 알아차렸을 때는 마지막날이어서 너무 늦었다.
그래서 부랴부랴 리팩토링을 하고 했는데, 마음대로 되지 않아서 결국엔 해결하지 못한 과제이다.
어떤 식으로 해도 애매하고, 뭔가 과한 확장이 되는 것 같아서 일단은 Car에 붙여뒀다.
뭔가 더 좋은 방법이 있는 것 같은데, 떠오르지가 않아서 아쉽다.