2주차 과제는 자동차 경주였다.
요구사항
요구사항은 과제 진행 요구 사항, 기능 요구 사항, 프로그래밍 요구 사항 세 가지로 구성되어 있었다.
과제 진행 요구 사항
- 기능을 구현하기 전 README.md에 구현할 기능 목록을 정리해 추가한다.
- Git의 커밋 단위는 앞 단계에서 README.md에 정리한 기능 목록 단위로 추가한다.
- Angular JS Git Commit Message Conventons를 참고해 커밋 메시지를 작성한다.
기능 요구 사항
- 주어진 횟수 동안 n대의 자동차는 전진 또는 멈출 수 있다.
- 각 자동차에 이름을 부여할 수 있다. 전진하는 자동차를 출력할 때 자동차 이름을 같이 출력한다.
- 자동차 이름은 쉼표(,)를 기준으로 구분하며 이름은 5자 이하만 가능하다.
- 사용자는 몇 번의 이동을 할 것인지를 입력할 수 있어야 한다.
- 전진하는 조건은 0에서 9 사이에서 무작위 값을 구한 후 무작위 값이 4 이상일 경우이다.
- 자동차 경주 게임을 완료한 후 누가 우승했는지를 알려준다. 우승자는 한 명 이상일 수 있다.
- 우승자가 여러 명일 경우 쉼표(,)를 이용하여 구분한다.
- 사용자가 잘못된 값을 입력할 경우 `IllegalArgumentException`을 발생시킨 후 애플리케이션은 종료되어야 한다.
구현할 기능 목록 작성
- 기능
- 경주할 자동차 이름 입력(쉼표로 구분)
- 시도할 횟수 입력
- `camp.nextstep.edu.missionutils.Console`의 `readLine()` 활용
- 잘못된 값을 입력할 경우, `IllegalArgumentException` 발생시킨 후 애플리케이션 종료
- 유효성 검사
- 경주할 자동차 이름: 빈 값이나 공백이 아닌 5자 이하의 문자열
- 시도할 횟수: 양의 정수
- 0 ~ 9 사이에서 무작위 값을 구한 후 무작위 값이 4 이상인 경우에 전진
- `camp.nextstep.edu.missionutils.Randoms`의 `pickNumberInRange()` 활용
- 차수별 실행 결과
- 차수별 실행 결과 프린트
pobi : --
woni : ----
jun : ---
- 입력 처리
- 자동차 이름
- 시도할 횟수
- 로직
- 4 이상이 경우 전진
- 4 미만인 경우 멈춤
- 출력 처리
- 차수별 실행 결과 프린트
- 우승자 안내 문구 단독인 경우, 공동인 경우
구현하기
이번 2주차의 목표는 1주차 때의 코드 리뷰, 전체 피드백, 프리코스의 목표를 지키는 것이었다. 또한 봤던 다른 사람의 코드에서 뽑아먹을 수 있는 건 최대한 뽑아먹자고 생각했다. 특히 최대한 객체지향의 원칙에 맞는 코드를 작성하고자 했다.
전체 코드
폴더 구조
src
├── main
│ └── java
│ └── racingcar
│ ├── Application.java
│ ├── controller
│ │ └── RacingGameController.java
│ ├── domain
│ │ ├── Car.java
│ │ └── RacingGame.java
│ ├── exception
│ │ └── ExceptionMessage.java
│ ├── utils
│ │ ├── CarNameInputProcessor.java
│ │ ├── RandomNumberGenerator.java
│ │ └── TotalRoundsInputProcessor.java
│ └── view
│ ├── InputView.java
│ └── OutputView.java
└── test
└── java
└── racingcar
├── ApplicationTest.java
├── domain
│ ├── CarTest.java
│ └── RacingGameTest.java
└── utils
├── CarNameInputProcessorTest.java
└── TotalRoundsInputProcessorTest.java
이번에는 입력 → 메인 로직 → 출력 순서대로 코드를 구현해나가기 시작했다. 저번 주와 마찬가지로 이번 주도 TDD를 실천하기 위해 테스트 코드부터 작성했는데, 전진 횟수 이름과 자동차 이름 입력에 대해 테스트 코드는 잘 작성되었다. 하지만 전체 로직을 관리하는 `RacingGame` 클래스의 경우, 테스트 코드부터 바로 떠오르지 않아서 기본적인 코드부터 먼저 작성했다.
과제를 제출하면서 다시 한 번 생각해보니, `RacingGame`의 설계가 복잡하게 느껴져 TDD를 포기했던 것 같다. 런데 코드를 완성하고 보니 생각보다 그렇게 복잡하지 않아서, 좀 더 차근차근 필요한 메서드를 고민해봤다면 TDD를 끝까지 잘 수행할 수 있었을 것 같다는 아쉬움이 남았다.
클래스 다이어그램도 만들었다. starUML로 조금 미흡하게(?) 작업했는데, 다른 분들은 어떤 툴로 만드는지 궁금하다.
테스트 코드
저번 주차 미션을 마친 후, 공부한 내용을 블로그에 정리하면서 테스트 코드에 대한 글을 작성했는데 덕분에 `parameterized test`에 대해 더 깊이 이해할 수 있었다. 이를 바탕으로 이번 테스트 코드 작성에 적극적으로 활용할 수 있었다.
1주차에서 테스트 코드를 작성할 때는 `@MethodSource`와 `@ValueSource` 같은 애노테이션을 다른 사람들의 글만 보고 급하게 사용했었다. 하지만 이번엔 직접 블로그 글을 작성하면서 이 애노테이션 외에도 어떤 Argument Sources가 있는지 어떻게 displayname을 custom할 수 있는지 등에 대해서 알 수 있었다.
이번 미션에서 특히 유용하게 사용한 것은 `@NullAndEmptySource`였다. 이 애노테이션은 null값이거나 비어있는 경우를 input으로 넣어주는데, 여기에 `@ValueSource(strings = {" ", "\t", "\n"})`를 함께 사용하면 빈 문자열, 공백... 등 이런 경우를 다 테스트할 수 있어서 간편했다.
@DisplayName("입력 값이 빈 문자열 또는 공백인 경우 - IllegalArgumentException 반환")
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void testEmptyOrBlankInput(String input) {
assertThatThrownBy(() -> parseTotalRounds(input))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(EMPTY_ROUNDS_ERROR);
}
또한 이번에는 `assertThatThrownBy`, `isInstanceOf`, `hasMessage`를 사용해서 발생하는 예외의 메시지까지 정확히 일치하는지 검증했다. 이는 지난주 다른 친구의 코드를 보다가 배운 내용인데, 과제에서는 다 `IllegalArgumentException`을 던져주기 때문에 예외 메시지까지 확인하는 것이 제대로 처리되었는지 확실히 검증하는 데 좋을 것 같아서 추가하였다.
@DisplayName("입력 값이 0 또는 음수인 경우 - IllegalArgumentException 반환")
@ParameterizedTest
@ValueSource(strings = {"0", "-2", "-100"})
void testZeroOrNegativeInput(String input) {
assertThatThrownBy(() -> parseTotalRounds(input))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(NON_POSITIVE_ROUNDS_ERROR);
}
추가로, 지난 주 코드 리뷰에서 들었던 피드백 중 테스트 코드의 `DisplayName`에 결과도 나타내라는 조언도 반영했다. 예외가 발생하는 경우에는 `- IllegalArgumentException 반환`, 값을 반환하는 경우, ` - Integer 반환`과 같은 설명을 추가해 가독성을 높였다.
적용하기 애매했던 조언도 있었다. 바로 given/when/then 패턴을 적용하는 부분인데, `parameterized test`나 `assertThatThrownBy`을 사용할 때는 한두 줄로 테스트가 끝나서 이 패턴을 적용하기 애매했다. 하지만 한 줄로 끝나지 않는 경우에는 최대한 이 패턴을 적용하려고 노력했다.
고민한 내용
Car의 position을 출력하는 로직은 어디에 넣는 게 좋을까?
처음에는 자동차 정보를 출력하는 기능이니 당연히 `Car`클래스에 넣는 게 맞다고 생각해서 해당 기능을 `Car` 클래스에 작성했다. 자동차의 속성이나 상태를 출력하는 작업이니, 논리적으로도 `Car` 클래스에 포함되는 것이 적절해 보였다.
하지만 카테캠 팀원들과 이야기를 나누면서, 출력과 관련된 모두 작업은 로직 클래스가 아니라 view 페이지에 두는 것이 적합하다는 결론을 내렸다. 팀원들 의견을 들으니, 로직과 출력 부분을 분리해두면 코드 유지 보수나 테스트에도 더 유리하겠다는 생각이 들었고, 결국 `Car` 클래스에서 `OutputView` 클래스로 출력 기능을 옮겼다.
Car 이름에 대한 validation 코드는 어디에 넣는 게 맞을까?
처음에는 `CarNameInputProcessor`에서 자동차들의 이름을 입력으로 받을 때, 이름 검증까지 함께 처리하도록 했다. 이유는 단순했다. 이미 `List<String>` 형태로 자동차 이름을 split하여 처리하는 로직을 수행하고 있었기 때문에, 이때 이름 유효성 검증까지 한꺼번에 처리하면 되지 않을까 하는 생각이었다. 이 생각을 다시 곱씹어보면, 결국 코드의 명확성보다 편리함을 우선했기 때문에 이런 방식으로 구현하게 된 것이다.
하지만 이후 코드의 책임을 더 명확히 하기 위해 `CarNameInputProcessor` 클래스에는 입력 값이 비어있는지 확인하고 `,`를 기준으로 이름을 나누어 `List`로 반환하는 메서드만 남기기로 했다. 이렇게 변경하고 나니, 입력 값을 다루는 부분과 검증 로직이 분리되어 각 클래스가 더 명확한 역할을 가지게 되었다.
다만, 지금 보니 클래스 이름이 조금 어색하게 느껴졌다. 여러 개의 이름을 다루기 때문에 `CarNameInputProcessor`이 아니라 `CarNamesInputProcessor`처럼 복수형으로 작성하는 것이 더 적절했을 것 같다.
utils 폴더의 클래스는 변수나 생성자를 넣는 게 맞을까?
처음 `CarNameInputProcessor`와 `TotalRoundsInputProcessor`를 구현할 때는 각각 생성자와 인스턴스 변수를 포함했다. 이렇게 하면 객체지향적인 접근에 더 가까워 보인다고 생각했지만, 이 클래스들이 기본적으로 단순 유틸리티 역할을 수행해야 한다고 생각하면서 고민이 생겼다. 유틸리티 클래스는 일반적으로 상태를 가지지 않으므로, 생성자와 변수를 포함하는 것이 적절하지 않다고 판단하게 되었다. 결국, 이 클래스들이 `utils` 폴더에 잘 맞도록 생성자와 변수를 제거해 단순히 필요한 기능만 제공하도록 리팩토링하였다.
하지만 여기서 중요한 점을 놓쳤다. 원래는 다른 클래스에서 접근하기 위해 모든 메서드에 `static` 키워드를 붙였는데, 리팩토링 후에는 `static`이 필요없는 메서드가 대부분이었다. 리팩토링을 통해 하나의 메서드가 다른 메서드들을 활용하도록 변경하면서, 이제 `static`이 필요한 것은 하나뿐이었다. 하지만 그 부분을 수정하지 않고 그대로 두어 불필요한 `static`메서드들이 남게 되었다.
다음에는 리팩토링을 할 때는 변경한 부분뿐만 아니라 관련된 모든 요소를 검토해야겠다...
상수화는 언제 필요할까?
다른 사람들의 코드를 보면서 랜덤 숫자의 범위나 자동차 이름의 글자 수를 상수화하여 매직 넘버 대신 사용하는 방법을 새롭게 알게 되었다. 매직 넘버를 상수화하면 코드의 가독성과 유지 보수성이 높아지고, 코드 전체에서 일관성있게 값들을 관리하게 용이하다는 장점이 있다.
하지만 1차 피드백 영상에서 "반복적으로 사용하지 않는 값은 굳이 상수화하지 않아도 된다", "상수로 뺐을 때 실제로 코드에 차이를 만드는지 생각해보라"는 조언을 듣고, 모든 값을 상수화하기보다는 필요할 때만 상수화하는 것이 더 합리적일 수 있겠다는 생각이 들었다. 예를 들어, 예외 메시지는 테스트 과정에서 일관된 메시지를 관리하고 수정하기 위해 상수화가 유용하다고 판단했다. 반면, 자동차 이름의 글자 수 제한과 같은 단순하고 명확한 값은 코드에 직접 명시해도 충분히 이해할 수 있기 때문에, 상수화하지 않고 바로 명시했다.
이처럼 반복 사용 여부나 코드 가독성을 고려해 상수화를 선택하는 것이 오히려 코드의 가독성을 높이는 데 도움이 될 수 있다고 느꼈다.
테스트를 위한 코드 변경, 어디까지 허용할 수 있을까?
테스트 코드를 작성하면서 테스트를 위해 로직을 복잡하게 변경하거나 `getter` 메서드를 추가하는 것이 옳은지 고민하게 되었다. 더 깊이 들여다보면, 테스트의 필요성을 위해 원래 코드에 추가 메서드를 넣거나 구조를 변경하는 것이 설계의 적절성을 흐리지 않을지에 대한 의문이었다. 찾아보다 리플렉션에 대해서 알게되었고, 이를 사용해 비공개 필드에 접근할 수도 있었지만 가독성과 유지 보수 측면에서 불리하다고 판단해 `getter`을 추가하는 방법을 선택했다.
프리소스 커뮤니티에서 다른 지원자들과 토론해 본 결과, 다양한 의견을 통해 코드 설계에 대해 더 넓은 시각을 얻을 수 있었다. 많은 의견이 "테스트하기 쉬운 코드일수록 좋은 설계를 가질 가능성이 높다"는 점을 강조했고, 테스트를 위해 약간의 복잡도가 추가된다 하더라도 그 복잡도가 책임과 역할을 명확히 나누는 과정에서 발생하는 것이라면 긍정적으로 볼 수 있다는 의견이 많았다. 이 논의를 통해, 테스트 가능한 코드가 더 견고한 설계와 구조를 제공할 가능성이 높다는 사실을 새롭게 인식하게 되었다.
학습하고 사용한 것들
코드 리뷰 후기
'우아한테크코스' 카테고리의 다른 글
[우아한테크코스] 프리코스 3주차 회고 (0) | 2024.11.06 |
---|---|
[우아한테크코스] 프리코스 2주차 코드 리뷰 후기 (0) | 2024.10.31 |
[우아한테크코스] 프리코스 1주차 코드 리뷰 후기 (1) | 2024.10.30 |
[우아한테크코스] 프리코스 1주차 회고 (0) | 2024.10.30 |
[우아한테크코스] 사용한 라이브러리 (0) | 2024.10.23 |