티스토리 뷰

자동차 경주 게임

 어느덧 2주차 미션 제출을 진행하고 시간이 참 빠르게 흘러간 것 같습니다 :)

 이번 미션에서는 느낀바가 참 많았던 미션인 것 같아요. 안다고 생각했던 부분들에 대해서 실수가 잦았고 모르는 부분도 엄청 많이 있었구요 🤣 문제없이 3주동안 진행되는 것보다 하나라도 제 문제가 드러난다면 고칠 수 있는 좋은 기회가 될 것이라고 생각해서 기분은 오히려 좋습니다 :)

 프리코스의 2주차인 [자동차 경주 게임]에 대해서 회고를 시작하겠습니다🙆‍♂️

 

 이번에도 동일하게 메일과 함께 시작된 자동차 경주 게임이었어요.

 다만 조금 달랐던 건 지난주차에 미션에 대한 공통 피드백을 받고 제 문제를 빠르게 파악해서 진행할 수 있었다는게 참 좋았던 것 같습니다. 지난주 회고의 마지막에 피드백에 대한 이야기를 잠깐 해두었지만 자동차 미션을 진행하면서 제가 느끼는바도 컸기 때문에 1주차 피드백도 2주차 회고와 같이 포함해서 이야기하려고 해요.

 

1주차 피드백 반영하는 과정

1. 살아있는 문서 만들기

 지난주차에 피드백을 이야기하면서 가장 인상깊었다고 이야기했던 부분이에요. 하루종일 기능 목록을 만드느라 어질어질했던 기억이 생생한데 피드백을 받자마자 조금 깨달음을 얻었던 것 같아요.

 좋은 요구사항을 설계시작부터 완전하게 정리한다면 가장 이상적이지만 개발을 하다보면 처음 생각한 요구사항이 틀리거나, 더 추가할 부분이 생기는걸 당연하게 못 여겼다고 생각해요.

 그런 부분에 있어서 계속해서 개발을 해나가며 기능 목록을 업데이트하는, 살아있는 문서를 만들라라는 피드백은 제가 개발하는데 있어서 잘못된 방향을 수정할 수 있는 이정표가 되어준 것 같아요 :) 덕분에 최종적으로 제 기능 목록이 개인적으로는 1주차보다 만족할만큼 좋은 기능 목록을 나타내주었다고 생각하구요.

자동차 경주 게임에 제출한 기능 목록

 다음 미션에는 더 좋은 기능 리스트가 만들 수 있도록 노력해보겠습니다 :)

 

2. 이름을 통해 의도를 드러내라

 개인적으로 많이 지킨다고 생각했지만 이번 미션에서 다시 한번 생각해볼 수 있었던 피드백인 것 같아요.

 매번 많은 코드의 네이밍에서 다른 사람이 보더라도 한눈에 볼 수 있는 네이밍을 만들 수 있도록 시간 투자를 많이 하는 편이기는 했지만 그와 더불어서 일관성을 지키기 위해서 네이밍에 신경쓰지 못한 부분이 많았었거든요.

 정적 팩토리 메소드와 같은 경우에도 of, from과 같이 관습적으로 쓰기보다는 아래와 같이 보면 알 수 있게끔 의미있는 네이밍을 남기면 어떨까 하고 계속 고민했던 것 같아요.

public static Car createRandomMoveCar(String name) {
  return new Car(name, new RandomMoveStrategy());
}

 

 다른 메소드, 필드, 클래스들도 그런 부분에서 엄청 많이 신경썼습니다.

 특히 Exception과 같은 경우에도 Exception을 커스텀하게 많이 사용했는데 사용을 하는 이유가 메소드 네임만 보더라도 그 예외에 대한 의미를 파악하기 쉽기 때문에 사용을 했었거든요. 최근에 앞서 이야기한대로 일관성을 추구하다보니 네이밍에 대한 의미를 잃어버리는 것 같아 그 부분에서 의미를 조금 더 강화해보고자?했어요.

 

미션에 제출한 커스텀 exception들

 커밋 메시지도 더불어서 커밋 단위를 최소화하고, 커밋에서 그 의미를 바로 알 수 있게끔 진행했구요!

 클린코드에 대한 관심을 갖게 된 이유가 다른 사람이 짰던 코드에서 좋은 코드, 안좋은 코드를 보았을 때 내가 그 사람의 코드를 보는 느낌도 이런데 내가 짠 코드는 다른 사람이 보더라도, 사용하더라도 직관적으로 이해할 수 있게끔하자였었어요.

 이번 피드백에서는 제가 처음 클린코드에 대한 관심이 생겼을 때의 그 다짐을 한 번 더 리마인드 할 수 있는 계기가 된 것 같습니다.

 

자동차 경주 게임 코드 분석

 이전 회고에서는 제가 개발을 했을 때 가졌던 생각을 위주로 했고, 코드에 대한 회고는 진행하지 않았던 것 같아요. 이번 미션을 진행하면서 요구사항에 대해서 코드나 로직적인 부분에 대해서 반성해야할 부분들이 많아 상세하게 회고해보려 해요.

 

1. MoveStrategy 전략패턴

 이전 숫자 야구게임에서 느꼈던 고충이 있었습니다.

 우테코에서 제공하는 Random API를 사용해서 Random값을 도출해내기 때문에 항상 랜덤한 값이 결정되어 테스트하기 어려웠습니다. 만약 자동차 경주에서도 Random을 사용했더라면 다음과 같은 코드를 만들어 냈겠죠.

public int move() {
  int command = Randoms.pickNumberInRange(1, 9);
  if (command >= 4) {
    position++;
  }
  return this.potision;
}

 생각해보면 이 Car라는 객체는 내가 어떤 방식을 사용하건 Randoms에 종속적인 move() 메소드를 동작하게 됩니다. 그렇게 된다면 move()의 동작 테스트는 외부 API에 종속되어 개별적으로 하기에는 무리가 있고, 또한 Car라는 객체의 재사용성이 하락합니다. Car는 랜덤하게 움직이는 객체일 수도 있지만 다르게 움직일 수도 있을 거거든요.

 그래서 여기서는 인터페이스로 추상화한 전략패턴을 사용하였습니다.

@FunctionalInterface
public interface MoveStrategy {

    boolean isMoveable();
}

 움직일 수 있는지 없는지에 대한 역할 자체를 추상화한 인터페이스입니다. 랜덤하게 움직이는 경우 다음과 같은 구현체를 만들어낼 수 있겠죠.

public class RandomMoveStrategy implements MoveStrategy {

    private static final int MIN_COMMAND = 1;
    private static final int MAX_COMMAND = 9;
    private static final int MIN_MOVEABLE_COMMAND = 4;

    @Override
    public boolean isMoveable() {
        int command = Randoms.pickNumberInRange(MIN_COMMAND, MAX_COMMAND);
        checkCommandRange(command);
        return command >= MIN_MOVEABLE_COMMAND;
    }

    private static void checkCommandRange(int command) {
        if (command < MIN_COMMAND || command > MAX_COMMAND) {
            throw new RandomNumberRangeException();
        }
    }
}

 마찬가지로 Randoms를 통해 값을 받아 4 이상인 경우 true를, 아닌 경우 false를 반환하는 클래스입니다. 그럼 이 랜덤하게 움직일 수 있다라는 전략을 Car객체에 한번 담아볼까요?

 

public class Car {
		...

    private final String name;
    private final MoveStrategy moveStrategy;
    private int position = START_POSITION;
  
  	...
    
    public Car(String name, MoveStrategy moveStrategy) {
				...
        this.name = name;
        this.moveStrategy = moveStrategy;
    }
  
    public int move() {
        if (moveStrategy.isMoveable()) {
            position++;
        }
        return position;
    }

 Car는 전진에 대한 전략을 가지고 있고 move()는 전진 전략이 이동 가능할때만 postion을 1 추가해주는 방식으로 변경되었습니다. 따라서 Car객체 자체는 이전과는 다르게 외부 API에 대한 의존성이 떨어졌죠.

 

 여기서 랜덤한 값에 의해 움직이는 Car객체를 생성하고 싶다면 팩토리 메소드 패턴을 사용하든, 생성자를 사용하든 해서 다음과 같이 RandomMoveStrategy 구현체를 가지고 생성하면 될 것입니다.

new Car("name", new RandomMoveStrategy);

 

 이제 Car라는 객체가 무조건 Random하게 움직인다에 대해서 벗어났기 때문에 Car로 다양한 테스트를 진행할 수 있겠죠. 이전에는 하지 못했던 move()에 대한 테스트까지도 말이죠.

 move()를 테스트하는데는 두 가지 테스트 케이스가 필요합니다. isMoveable()true이기 때문에 postion이 올라가거나, false이기 때문에 position이 내려가거나.

 isMoveable()은 하나의 인터페이스로 된 기능 API이기 때문에 그 케이스에 맞는 전략만 추가하면 됩니다. 다음과 같이요.

@Test
@DisplayName("Car는 움직일 수 있는 경우 한칸 이동한다.")
void moveTrueTest() {
    // given
    Car car = new Car("pobi", () -> true);

    // when
    int result = car.move();

    // then
    assertThat(result).isEqualTo(1);
}

@Test
@DisplayName("Car는 움직일 수 없는 경우 그대로 멈춰있는다.")
void moveNothingTest() {
    // given
    Car car = new Car("pobi", () -> false);

    // when
    int result = car.move();

    // then
    assertThat(result).isEqualTo(0);
}

 () -> true() -> falseMoveStrategy를 FunctionalInterface로 만들어둔 것이기 때문에 가능한 것이며, 이는 넘어가기로하고 true를 반환하거나 false를 반환하는 구현체를 만들어도 상관없습니다.

 이렇게 전략패턴으로 구분지었을 경우 Randoms에 대한 Car객체의 종속성을 막을 수 있고, 이를 사용하는 메소드에 대해서 다양한 케이스의 전략을 사용해서 테스트할 수 있는 유연성까지 바꾸어낼 수 있었습니다.

 

2. 예외 발생 시 다시 입력받기

 이번 미션에서는 다른 요구사항이 추가되었었습니다.

사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.

 예외가 발생하면 바로 종료하지말고, 다시 입력값을 받도록 하라라는 것이었죠. 이는 각각의 객체가 꼼꼼한 케이스에 대해서 예외처리를 했던 코드를 만들었다면 편했던 요구사항인 것 같아요.

 

 이 내용을 구현할 때 계속 고민했던 것이 있었습니다.

InputView에서 while을 돌리고 InputView에서 validation까지 넘기는게 과연 맞는 걸까?

 코드로 예를 들면 다음과 같았을 것 같아요.

public static List<String> inputCarNames() {
  while(true) {
    List<String> carNames = Arrays.stream(Console.readLine().split(",", -1))
            .map(String::trim)
            .collect(Collectors.toList());
    if (validate(carNames)) {
      return carNames;
    }
    ...
  }
}

 완전히 구현했던 코드가 날아가서 예제로나마 간단하게 구현한 코드입니다. 단순히 carNames를 입력받고, validate하고, 통과하면 리턴 아니면 while문을 계속 도는 구조였어요.

 근데 여기서 생각했던 건 과연 validation을 굳이 따로 빼서 InputView에서 맡아야하게끔 개발했어야하나? 였어요. 분명 저는 CarCars에 예외처리를 꼼꼼히 진행했고 거기에서 다 exception이 발생하게끔 했는데 그걸 밖으로 빼내게 된다면 InputView객체가 가져야할 책임이 아니었던 것 같은거죠.

 그래서 생각을 했던 것이 CarCars객체를 생성하고 거기서 exception이 발생하면 catch로 잡아서 다시 입력을받게 해보자! 였어요. 다음과 같이 말이죠.

private static Cars getInputRandomCars() {
    try {
        return Cars.createRandomCars(inputCarNames());
    } catch (IllegalArgumentException e) {
        System.out.println(e.getMessage());
        return getInputRandomCars();
    }
}

public static List<String> inputCarNames() {
    System.out.println(INPUT_CAR_NAMES);
    return Arrays.stream(Console.readLine().split(",", -1))
        .map(String::trim)
        .collect(Collectors.toList());
}

 코드가 깔끔해보이는?건 둘째치고 메소드가 가지는 역할이 분명하게끔 설계할 수 있었던 것같아요.

 inputCarNames() Car name을 입력받는 메소드, getInputRandomCars() 입력받은 값으로 Cars를 만들지만 exception이 발생할 경우 에러문을 출력하고 다시 입력받도록 재귀를 동작하는 메소드

 물론 재귀를 사용했을 때와 while문을 사용했을 때의 장단점은 분명 존재한다라고 생각해요. 재귀를 사용하게 된다면 stack이 계속 쌓여서 너무 무수한 요청이 진행될 경우 stackoverflow가 발생할 수 있다라고 생각하구요. 다만 try-catch문을 사용하는데 있어서 while문을 추가로 사용하게 될 경우 미션의 제약조건인 2depth를 허용하지 않는다때문에 메소드를 하나를 더 생성해야했기 때문에 그것보다는 어짜피 많은 요청을 할 것 같지않은 간단한 미션인데 재귀로 하자 라고 구현했어요 :)

 

 개발을 진행했을 때 추가적이었던 고민은 과연 getInputRandomCars()Application이 가져야할지, InputView가 가져야할지였지만 InputView는 입력을 받는다라는 명확한 책임만 두고싶었기 때문에 Cars를 생성하게 되는 getInputRandomCars()Application에 두게 되었습니다.

System.out.println(e.getMessage());와 같은 출력문은 View로 넘겼어야 하지않나 하는 아쉬움이 있네요 🤣

 

3. String.split

 모든 구현을 끝마치고 테스트도 통과하고 예외케이스가 없을까 Application을 돌리던 때였어요. 근데 문제 케이스가 발생했죠.

pobi,woni,,,

 위 예제 케이스를 돌렸을 때 분명 제가 원하는 결과의 Input List는 ["pobi", "woni", "", "", ""] 였어요. ","라는 구분자를 사용했을 때 무조건 오른쪽에 문자가 없다면 공백이 Input되는 것이었죠.

 그렇게 된다면 이름의 길이가 0인 input이 들어오므로 exception이 발생해야했지만 발생하지 않았어요. 여기서 어떤 부분이 문제가 되었나 확인하게 되는 시작점이 된거죠.

 

 문제는 자주 사용하던 String.split()메소드였습니다.

 그냥 단순히 구분자를 적용하면 항상 공백이있건없건 구분지어주는줄 알았던 API인 줄 알았는데 사실은 아니었던거죠. 그래서 검색도 해보고 내부 구조도 뜯어보기로했습니다.

일반적으로 사용했던 split메소드

 첫번째로 원래 사용하는 split이었어요. 근데 조금 특이한 걸 발견했었습니다. 구현체가 있는게 아닌 내부에 있는 split을 또 호출하는 모양이었어요. limit = 0과 함께 말이죠.

 

 그다음으로 해당 API를 확인해봤어요.

실제 limit을 받는 split 메소드

 결과적으로 split에 구분자만 사용할 경우 limit이 0으로 걸리게되어 길이가 0인 문자열은 반환되지 않았고 limit에 대한 제한을 음수, -1로 두어야 길이가 0인 문자열을 반환할 수 있었던 상황이 된 것이었습니다. 다음과 같이 말이죠.

public static List<String> inputCarNames() {
    System.out.println(INPUT_CAR_NAMES);
    return Arrays.stream(Console.readLine().split(",", -1))
        .map(String::trim)
        .collect(Collectors.toList());
}

 알고리즘 문제를 풀거나 프로젝트를 진행해볼 때 split 메소드를 많이 사용했었는데 이렇게 구체적으로 내부에서 어떤 동작을 하는지 알아보지 못했던게 조금 부끄러워지는 순간이었습니다 😢

 

4. 객체 조금 더 분리하기 - RacingGame, Round

 Cars와 횟수를 의미하는 파라미터 round를 가진 RacingGame 객체를 설계하다보니 한가지 의문점이 발생했었습니다.

 round가 가지는 책임이 너무나도 많아보였거든요. 그래서 계속 생각했었어요, RacingGame만이 가지는 책임이 너무 많은건 아닐까? 여기서 더 분리할 수 있지않을까? 하구요. 그래서 그 round가 가질 수 있는 책임들을 나열해보기로 했었어요.

  • 문자열이 아닌 숫자가 들어올 경우 Error
  • 1보다 작은 값이 들어올 경우 Error
  • round 감소 시 round가 0이하일 경우 Error
  • round가 0인 경우 종료 확인

 간단하게 정리했는데 이거만 봐도 따로 객체로 빼도 충분할만큼 책임이 많아보였죠. RacingGame에 그대로 두게 된다면 RacingGame은 단순히 레이싱하고, 우승자를 반환하는 역할만 두고 싶었던 것도 있어요.

 그래서 Round라는 위에 나와있는 책임들을 짊어지는? 객체로 분리하게 된 것이죠.

 

생각 정리

 이번 미션은 미션에 대한 새로운 연습도 진행함과 더불어 기존에 알고있던 지식에 대해서 꼼꼼하지 않았던 저를 반성하게 되는 계기가 되었던 것 같아요. 지난 미션에 비해서 Round를 분리한 것처럼 더 극단적으로 객체를 분리하는 좋은 경험을 했던 것에 대해서 굉장히 기뻤었는데 마지막에 찾았던 split을 발견했을 때는 엄청 부끄러웠거든요 🤣

 그래도 나름대로 프리코스 매 주 미션마다 하나씩 얻어가는 것이 있었으면 하는 바람으로 진행하고 있었는데 저 나름대로 성장하고 있다라는 느낌이 들어서 좋은 것 같아요. 최근에 있었던 시험중에서 이렇게 재미있게 성장하는 느낌으로 치뤘던 시험이 있었나 싶네요 ㅎㅎ

 비록 다음 미션은 하나밖에 남지 않고 바로 최종 미션이 남아있지만 다음 미션에서도 어떤 무언가를 깨닫게 될지 너무 기대가 됩니다 :) 계속 미션을 진행하면서 배우게되는 것들이 많아지니 우테코에 합격해서 더 많이 성장하고 싶다는 생각이 강해지는 것 같아요 😃 마지막 미션도 잘 정리하고 회고해서 좋은 결과 있었으면 좋겠습니다!

 
댓글
최근에 올라온 글
Total
Today
Yesterday