Post

우테코 7기 프리코스 2주차 회고

🎬 Intro

우테코 프리코스 2주차 회고를 적어봅니다~

1️⃣ 1주차 공통 피드백 확인하기

1주차 공통 피드백에서 유용한 팁들을 알 수 있었습니다. 그 중 [10분 테코톡] 웨지의 인텔리제이 디버깅을 통해 Evaluate 라는 기능을 알게 되었습니다.


IntelliJ 24.01 버전 기준

✅ Evaluate

Evaluate는 현재 브레이킹 포인트에서 클래스의 필드, Stack에 저장된 데이터들을 사용하여 로직을 돌려보는 기능입니다. 이처럼 로직을 미리 돌려볼 수 있어서 버그를 잡는거 이외에도 다양한 메서드들을 테스트해볼 수 있습니다.

사용법

img_3.png

  • 디버깅 모드 실행 후 하단의 디버깅 창에서 가장 우측에 있는 More를 클릭합니다.

img_4.png

img_7.png

img_8.png

  • Evaluate Expreesion을 선택하면 Evaluate 창이 보이게 됩니다.
  • Code fragment 영역에서 현재 브레이킹 포인트에서 돌려볼 수 있는 로직들을 전부 실행할 수 있습니다.
  • Result 영역에는 로직의 결과가 출력이 됩니다.
  • 예를 들어 위와 같이 value라는 문자열의 isBlank() 결과가 무엇인지 확인해볼 수 있습니다.

2️⃣ 요구 사항 파악하기

2주차 요구 사항은 1주차와 유사했으나, 클린코드와 테스트 관련 내용이 추가되어 있었습니다.


✅ 클린코드를 위한 요구 사항

  • Indent depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다.
  • 3항 연산자를 쓰지 않는다.
  • 함수가 한가지 일만 하도록 최대한 작게 만들어라.

클린코드는 코딩하면서 항상 신경썼었기 때문에 해당 요구 사항들을 지키는건 어렵지 않았습니다.

✅ JUnit 5와 AssertJ를 이용하여 정리한 기능 목록이 정상적으로 작동하는지 테스트 코드로 확인한다.

JUnit의 경우 사이드 프로젝트나 혼자 공부할때, 테스트 코드를 매번 작성했었기 때문에 익숙해져있는 상태였습니다 ! 다만 1주차 피드백에 테스트들의 볼륨이 너무 크다는 의견을 받았어서, 2주차에는 테스트를 잘게 쪼개어 가독성을 높이는데 신경썼습니다.

3️⃣ 문제 분석 하기

사용자에게 자동차의 이름과 경주 시도횟수를 입력받고 그에 대한 결과를 출력하는 것이었습니다. 1주차와 마찬가지로 기능 요구 사항에 기재되지 않은 내용은 스스로 판단해보았습니다.


✅ 자동차의 이름은 빈문자열을 허용하지 않는다

  • 빈문자열을 허용할 경우 누가 우승을 했는지, 알 수가 없기 때문에 허용하지 않는게 맞겠다고 판단하였습니다.

✅ 자동차의 이름은 공백으로만 이루어질 수 없다

  • 공백의 길이에 따라 자동차를 구분할 수 있겠지만, 사용자가 터미널에서 이를 구분하기에는 어려울것이라 판단하여 해당 제한 사항을 두었습니다.

✅ 자동차의 이름은 중복이 될 수 없다

  • 이름이 중복될 경우 빈문자열과 비슷한 맥락으로 누가 이겼는지 정확히 구분이 안되기 때문에 중복을 허용하지 않는것으로 구현하였습니다.

✅ 자동차의 대수는 1,000 이하로 제한 한다

  • 과연 1,000대까지 입력하는 사용자가 있을까 하는 생각이 들기도 하였고, 오버헤드도 고려하는게 맞다고 생각하여 자동차의 대수에 제한을 두었습니다.

✅ 시도 횟수는 10,000,000 이하로 제한 한다

  • 시도 횟수 만큼 반복문이 돌기 때문에 10e8로 제한하는게 맞다고 판단하였습니다.

그리고 위 5가지 내용들은 언제든지 변경될 수 있는 사항이라고 생각하여 README에 업데이트 내용으로 따로 분리해두었습니다.

4️⃣ 기능 구현 목록 작성

1주차 코드 리뷰를 하면서 업데이트 내용을 따로 작성되어 있는 README를 보았습니다. 저 또한 이번에는 살아있는 문서를 만들기 위해 업데이트 내용을 작성하였습니다.


✨ 업데이트 내용

  • 자동차 이름은 빈 값(공백, 빈문자열) 불가
  • 자동차 이름 중복 불가
  • 경주 시도 횟수는 최대 10,000,000회로 제한
  • 참가 가능한 자동차는 최대 1,000대로 제한

✅ 진행 과정

1. 경주할 자동차 이름을 , (쉼표) 기준으로 구분하여 입력한다

  • 자동차 이름은 5글자 이하만 가능하다
  • 자동차 이름은 중복되면 안된다
  • 자동차 이름 형식은 빈문자열 또는 공백이 될 수 없다
  • 자동차는 1000대 까지만 가능하다
1
pobi,woni,jun

2. 시도할 횟수를 입력한다

  • 횟수는 양의 정수만 가능하다
  • 횟수는 10_000_000로 제한한다
1
5

3. 자동차 별로 횟수 만큼 전진을 수행한다

  • 전진하는 조건은 0에서 9 사이에서 무작위 값을 구한 후 무작위 값이 4이상일 경우이다

4. 차수별 실행 결과를 출력한다

  • 전진 결과는 자동차 이름과 함께 - (하이픈)으로 표현된다
1
2
3
pobi : --
woni : ----
jun : ---

5. 최종 우승자를 출력한다

  • 단독 우승자 안내 문구
1
최종 우승자 : pobi
  • 공동 우승자 안내 문구
1
최종 우승자 : pobi, jun

✅ 실행 결과 예시

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
경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)
pobi,woni,jun
시도할 횟수는 몇 회인가요?
5

실행 결과
pobi : -
woni : 
jun : -

pobi : --
woni : -
jun : --

pobi : ---
woni : --
jun : ---

pobi : ----
woni : ---
jun : ----

pobi : -----
woni : ----
jun : -----

최종 우승자 : pobi, jun

✅ 클래스 및 기능

1. domain

Car
  • 자동차의 이름과 위치 정보를 갖고 있는다
  • 무작위 숫자에 따라 자동차의 전진을 결정한다
  • 해당 자동차가 우승인지 판단한다
CarName
  • 자동차의 이름 정보를 갖고 있는다
예외처리
  • 자동차의 이름이 5글자 초과인 경우
  • 자동차의 이름 형식이 빈문자열 또는 공백인 경우
Score
  • 자동차의 점수 정보를 갖고 있는다
Count
  • 자동의 횟수 정보를 갖고 있는다
예외처리
  • 횟수가 양의 정수가 아닌 경우
  • 횟수가 10,000,000를 초과할 경우
Racing
  • 모든 자동차를 움직이고, 진행 상황을 반환한다
  • 최종 우승자를 결정한다
RacingCarService
  • 자동차 이름으로 자동차를 생성한다
  • 경주 결과를 반환한다
  • 최종 우승자를 반환한다
예외처리
  • 자동차 이름이 중복된 경우
  • 자동차의 대수가 1,000대를 초과한 경우

2. util

RacingCarStringValidator
  • 자동차 이름 길이 검증
  • 자동차 이름 형식 검증
  • StringValidator의 구현체
RacingCarNumberValidator
  • 시도 횟수 형식 검증
  • 시도 횟수 범위 검증
  • NumberValidator의 구현체
RacingCarListValidator
  • 자동차 이름 중복 검증
  • 자동차 대수 검증
  • ListValidator의 구현체
RandomNumberGenerator
  • 0 ~ 9 사이 무작위 값을 생성한다
  • NumberGenerator의 구현체

3. controller

  • 사용자에게 자동차 이름을 요청한다
  • 사용자에게 시도 횟수를 요청한다
  • 사용자에게 실행 결과를 반환한다
  • 사용자에게 최종 우승자를 반환한다

4. view

InputView
  • 입력값을 읽는다
OutputView
  • 요청 메세지를 출력한다
  • 실행 결과를 출력한다
  • 최종 우승자를 출력한다

5. constant

Rule
  • 자동차 경주의 룰을 상수로 정의한다

ErrorType

  • 에러 타입을 정의 한다

5️⃣ 구현 하기

1주차 공통 피드백과 코드 리뷰를 하면서 받았던 의견들을 반영하기 위해 노력하였습니다~

전체 코드


✅ 클래스 다이어그램

1. Main

2주차_다이어그램_메인

2. util

2주차_다이어그램_유틸

3. constant

2주차_다이어그램_상수

✅ 노력한 점

  • 테스트를 유연하게 하기 위해 추상화와 DI를 활용하였습니다.
  • 의존성 주입을 한곳에서 관리하기 위해 AppConfig 클래스를 두었습니다.(코드 리뷰 피드백)
  • 원시값들을 객체로 감싸 데이터의 의미와 책임 분리를 명확하게 하였습니다.
  • 테스트의 가독성을 높이기 위해@ParameterizedTest 사용을 지양하고, 테스트를 잘게 쪼겠습니다.(코드 리뷰 피드백)
  • 각 메서드는 한가지일만 수행하도록 하였습니다.(1주차 공통 피드백)
  • indent 3이 넘지 않도록 메서드들을 잘 분리하였습니다.(1주차 공통 피드백)
  • 순수한 기능 테스트를 위해 모킹을 적극 활용하였습니다.(코드 리뷰 피드백)
  • 일급 컬렉션을 이용하여 객체를 객체답게 사용할려고 노력하였습니다.

5️⃣ 리팩토링

✅ 유효성 검증 객체 분리

Before

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
public class RacingCarValidator {

  private RacingCarValidator() {

  }

  public static void validateCarNameLength(final String name, final int maxLength) {
    if (isExceedCarNameLength(name, maxLength)) {
      throw new IllegalArgumentException();
    }
  }

  public static void validateCarDuplicate(final List<Car> cars) {
    if (isDuplicateCar(cars)) {
      throw new IllegalArgumentException();
    }
  }

  public static <T extends Number & Comparable<T>> void validateCountValueRange(final T value, final T maxRange) {
    if (isExceedCountValueRange(value, maxRange)) {
      throw new IllegalArgumentException();
    }
  }

  private static boolean isExceedCarNameLength(final String name, final int maxLength) {
    return name.length() > maxLength;
  }

  private static boolean isDuplicateCar(final List<Car> cars) {
    return cars.stream()
      .distinct()
      .count() != cars.size();
  }

  private static <T extends Comparable<T>> boolean isExceedCountValueRange(final T value, final T maxRange) {
    return value.compareTo(maxRange) > 0;
  }


}
  • 하나의 객체에서 모든 유효성 검증을 static 메서드로 구현하였습니다.
  • 하지만 이렇게 구현할 경우 해당 메서드들을 사용하는 도메인의 테스트가 어렵다는 단점이 존재하게 됩니다.
  • 테스트를 좀 더 유연하게 하기 위해 각 메서들을 3개의 클래스로 분리하였습니다.

After

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
public interface ListValidator<T> {

    void validateDuplicate(final List<T> values);

    void validateSize(final List<T> values, final int maxSize);
}

public class RacingCarListValidator implements ListValidator<String> {


  @Override
  public void validateDuplicate(final List<String> values) {
    if (isDuplicateCar(values)) {
      throw new IllegalArgumentException(ErrorType.DUPLICATE_CAR_NAME);
    }
  }

  @Override
  public void validateSize(final List<String> values, final int maxSize) {
    if (isExceedMaxSize(values, maxSize)) {
      throw new IllegalArgumentException(ErrorType.EXCEEDED_MAX_CARS_SIZE);
    }
  }

  private boolean isExceedMaxSize(final List<String> values, final int maxSize) {
    return values.size() > maxSize;
  }

  private boolean isDuplicateCar(final List<String> values) {
    return values.stream()
      .distinct()
      .count() != values.size();
  }
}


  • 리스트 자료형을 검증하는 벨리데이터 입니다.
    • 리스트의 중복 여부를 검증합니다.
    • 리스트의 사이즈를 검증합니다.
  • 제네릭을 사용하여 확장성을 높였습니다.
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
public interface StringValidator {

    void validateFormat(final String value);

    void validateLength(final String value, final int maxLength);
}

public class RacingCarStringValidator implements StringValidator {

  @Override
  public void validateLength(final String value, final int maxLength) {
    if (isExceedCarNameLength(value, maxLength)) {
      throw new IllegalArgumentException(ErrorType.EXCEEDED_MAX_CAR_NAME_LENGTH);
    }
  }

  @Override
  public void validateFormat(final String value) {
    if (value.isBlank() || value.isEmpty()) {
      throw new IllegalArgumentException(ErrorType.INVALID_CAR_NAME);
    }
  }

  private static boolean isExceedCarNameLength(final String name, final int maxLength) {
    return name.length() > maxLength;
  }
}
  • 문자열을 검증하는 벨리테이터 입니다.
    • 문자열의 길이를 검증합니다.
    • 문자열의 형식을 검증합니다.
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
public interface NumberValidator<T extends Number> {

    void validateFormat(final String value);

    void validateRange(final T value, final T maxValue);
}
public class RacingCarNumberValidator implements NumberValidator<Integer> {

  private static final Pattern NUMBER_PATTERN = Pattern.compile("\\d*");

  @Override
  public void validateFormat(final String value) {
    if (isNotNumber(value)) {
      throw new IllegalArgumentException(ErrorType.INVALID_COUNT);
    }
  }

  @Override
  public void validateRange(final Integer value, final Integer maxValue) {
    if (isExceedMaxValueRange(value, maxValue)) {
      throw new IllegalArgumentException(ErrorType.EXCEEDED_MAX_COUNT);
    }
  }

  private boolean isExceedMaxValueRange(final Integer value, final Integer maxValue) {
    return value > maxValue;
  }

  private boolean isNotNumber(final String value) {
    return !NUMBER_PATTERN.matcher(value).matches();
  }
}
  • 숫자를 검증하는 벨리데이터 입니다.
    • 숫자의 형식을 검증합니다.
    • 숫자의 범위를 검증합니다.

✅ 테스트를 위한 유효성 검증 Fake 객체

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
public class FakeListValidator implements ListValidator<String> {

  @Override
  public void validateDuplicate(final List<String> values) {
    // fake
  }

  @Override
  public void validateSize(final List<String> values, final int maxSize) {
    // fake
  }
}

public class FakeNumberValidator implements NumberValidator<Integer> {

  @Override
  public void validateFormat(final String value) {
    // fake
  }

  @Override
  public void validateRange(final Integer value, final Integer maxValue) {
    // fake
  }
}

public class FakeStringValidator implements StringValidator {

  @Override
  public void validateFormat(final String value) {
    // fake
  }

  @Override
  public void validateLength(final String value, final int maxLength) {
    // fake
  }
}

  • 리스트, 문자열, 숫자 검증 클래스의 fake 객체들 입니다.
  • 해당 fake 객체를 이용하면 테스트를 유연하게 할 수 있습니다.

✅ Car 도메인의 순수한 기능 테스트

CarName

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CarName {

    private final String value;

    private CarName(final String value) {
        this.value = value;
    }

    public static CarName of(final String value, final StringValidator stringValidator) {
        stringValidator.validateFormat(value);
        stringValidator.validateLength(value, Rule.CAR_NAME_LENGTH_MAX);
        return new CarName(value);
    }

    public String getValue() {
        return this.value;
    }
}

  • CarName은 프로덕션 레벨에서 value가 5글자 초과할 경우 예외가 발생하게 됩니다.

Car

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
public class Car {

    private final CarName name;
    private final Score score;
    private final NumberGenerator randomNumberGenerator;


    public Car(final CarName name, final Score score, final NumberGenerator randomNumberGenerator) {
        this.name = name;
        this.score = score;
        this.randomNumberGenerator = randomNumberGenerator;
    }

    public String getName() {
        return this.name.getValue();
    }

    public int getScore() {
        return this.score.getValue();
    }

    public void go(final int score) {
        final int number = randomNumberGenerator.generate();
        if (isExceedForwardCondition(number)) {
            this.score.addValue(score);
        }
    }

    public boolean isWinner(final int maxScore) {
        return getScore() == maxScore;
    }

    private boolean isExceedForwardCondition(final int number) {
        return number >= Rule.CAR_FORWARD_CONDITION;
    }


    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Car)) {
            return false;
        }
        final Car car = (Car) o;
        return Objects.equals(name.getValue(), car.name.getValue());
    }

    @Override
    public int hashCode() {
        return Objects.hash(name.getValue());
    }
}
  • Car 객체는 CarName을 참조하고 있습니다.
  • 따라서 테스트를 위해서는 CarName 유효성 검증을 통과해야합니다.
  • 하지만 위에서 말씀드린 Fake 객체를 사용하면 CarName 유효성 검증 필요없이 Car의 순수한 기능만 테스트할 수 있습니다.

CarTest

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
class CarTest {

  private NumberGenerator forwardNumberGenerator;
  private NumberGenerator stopNumberGenerator;
  private StringValidator fakeStringValidator;

  @BeforeEach
  void setUp() {
    forwardNumberGenerator = new ForwardNumberGenerator();
    stopNumberGenerator = new StopNumberGenerator();
    fakeStringValidator = new FakeStringValidator();
  }

  @Test
  @DisplayName("무작위 값이 기준값 미만이므로 자동차가 전진 하지않는다.")
  void stopTest() throws Exception {
    //given
    final CarName carName = CarName.of("전진 하지 않는 자동차", fakeStringValidator);
    final Score score = new Score(0);
    final Car car = new Car(carName, score, stopNumberGenerator);

    //when
    car.go(1);

    //then
    assertThat(car.getScore()).isZero();

  }
}
  • 이런식으로 CarName에 fake 객체를 주입하면, Car 기능 테스트시 CarName의 유효성 검증을 신경쓰지 않아도 됩니다.
  • 즉, 순수하게 Car의 기능만을 테스트할 수 있습니다.

✨ 마무리 하며

1주차 코드 리뷰를 하면서 유효성 검증을 크게 2가지 방법으로 구현하시는 것을 보았습니다.

  1. 검증이 필요한 도메인 내부에 구현
  2. 별도 클래스를 만들어 정적 메서드로 구현

2가지 각각 응집도, 재사용 측면에서 장점이 있기 때문에 위와 같은 방법을 사용하신것 같습니다.

그래서 저도 2주차 미션 초기에 2번째 방법으로 유효성 검증 로직을 구현 했었습니다.

하지만 만들어 놓고 보니 이를 사용하는 도메인에 대한 테스트가 어렵다는 문제가 있었습니다.

예를 들어 Car 객체에 자동차 이름에 대한 검증이 있다면, 순수하게 자동차 전진 메서드를 테스트하고 싶을 경우에도 자동차 이름을 5글자 이하로 만들어야한다는 강제성이 있었습니다.

또한 자동차 이름 길이 정책에 따라 매번 테스트 코드도 수정해야 한다는 단점도 존재하였습니다. 결과적으로 이는 순수한 기능 테스트가 아닌 유효성 테스트도 포함된 형식이라고 생각이 들었습니다.

그래서 결국에는 하나의 유효성 검증 클래스를 총 3가지 클래스로 나누고, 추상화와 DI를 통해 테스트를 유연하게 하는 방향으로 리팩토링 하였습니다. 하지만 이 방법 역시도 구현하다 보니까 단점들이 존재했습니다.

  1. 단일 클래스에서 여러 클래스로 나누면 클래스 간 관계를 관리할 수밖에 없음
  2. DI 설정과 관련된 추가 코스트가 발생함
  3. 검증 로직이 단순한 경우, 오히려 오버엔지니어링을 수 있음

이렇게 3가지 정도의 문제점을 느꼈고, 동시에 코딩에는 정말 정답이 없구나라는 것을 다시 한번 깨닫게 되었습니다. 결과적으로 이 부분은 각 방식 마다 장단점이 있기에,

이는 혼자 고민할 게 아니라 다른 사람의 의견을 들어보는 게 좋겠다라고 판단을 하였습니다!

그래서 이번주 코드 리뷰에서 해당 내용에 대한 크루원들의 의견을 들어보고자 합니다~

This post is licensed under CC BY 4.0 by the author.