[그리디] 이규형 자동차 경주 미션 3, 4단계 제출합니다. - #219
Leeguhyung wants to merge 50 commits into
Conversation
- Cars가 상태(자동차 목록)와 행동(이동, 우승자 찾기)을 함께 갖도록 변경 - 요청하신 리뷰대로 행동만 있던 CarMove, FindCarWinner 삭제
-Cars가 carPosition 값을 꺼내 직접 비교하던 방식에서 Car에게 samePosition으로 판단을 위임하도록 변경
-난수 기반 carRace 대신 movePoint로 위치를 고정하여 단독/공동 우승 각각 검증
-직접 구현한 compareMax 대신 표준 라이브러리 Math.max로 대체하고 인덱스 대신 for-each로 순회하도록 변경
- List.of()가 잘 동작하는지만 확인하는 테스트였고 Cars 자체의 로직을 검증하지 않아 제거
- 생성자에서 새 ArrayList로 복사해 저장하고, getCars()는 Collections.unmodifiableList로 감싸 반환하여 외부에서 내부 목록을 직접 수정할 수 없도록 변경
- main에서 라운드마다 출력할 수 있도록 carRace() 대신 carRaceOnce() 도입
- 5자 초과 시 IllegalArgumentException 발생, 테스트 함수에 있는 이름도 5자 이하로 수정
- 실제로 작동하는 랜덤 로직을 DefaultRandomNumber로 분리함. Cars, RacingGame은 인터페이스 타입을 그대로 참조하고 있어 변경 불필요했고, Main에서 구현체 생성 부분만 DefaultRandomNumber로 교체함.이후 테스트에서 고정값을 반환하는 가짜 구현체를 주입할 수 있게 됨
- 자동차 생성은 Cars에서 관리하는 것이 조금더 옳다고 생각했음
- CarMovingTest -> CarTest, FindCarWinnerTest -> CarsTest 로 파일명을 테스트 대상 클래스와 맞춤
- 항상 고정값을 반환하는 FixedRandomNumber(테스트 전용 RandomNumber 구현체)를 추가함. 처음 랜덤값을 이용하면서 어떻게 테스트 할지 고민하였는데 인터페이스 활용으로 해소하였음.
| } | ||
| System.out.println(); | ||
| } | ||
| //repeat()는 문자열 반복 메서드임. car.getCarPosition()만큼 "-"를 반복해서 출력하는거. |
There was a problem hiding this comment.
주석에 학습하신 내용을 정리해두셨네요. 찾아보고 기록하는 습관은 좋습니다.
다만 주석의 용도를 한번 생각해보시면 좋겠어요. repeat()나 String.join()이 무슨 메서드인지는 나중에 이 코드를 읽는 사람에게 필요한 정보일까요? 코드를 보면 알 수 있는 걸 다시 설명하는 주석은 시간이 지나면 잘 안 맞게 되기도 하고요.
보통 주석은 코드만으로는 알 수 없는 것을 적을 때 값어치가 있다고 봅니다. 왜 이렇게 했는지, 왜 이 방법을 쓰지 않았는지 같은 거요.
There was a problem hiding this comment.
말씀해주신 부분에 공감합니다. 자바를 처음 배우다 보니 새로 알게 된 메서드(repeat(), String.join() 등)를 바로 기록해두고 싶어서 주석으로 남겼는데, 돌아보니 코드를 읽는 분께 필요한 정보가 아니라 제 학습 메모였던 것 같습니다. 다만 코드를 보면서 바로 복습할 수 있다는 점 때문에 당분간은 학습 메모 주석을 유지하고 싶습니다.(주석에 //[학습] 붙여서 구분 시키겠습니다) 대신 "왜 이렇게 했는지, 왜 이 방법을 쓰지 않았는지"를 적은 주석을 추가하는 습관을 길러보도록 하겠습니다,
| } | ||
|
|
||
| public List<Car> getCars() { | ||
| return Collections.unmodifiableList(cars); //외부에서 수정 못하게 막는거 |
There was a problem hiding this comment.
이 부분 좋은 것 같습니다. get 메소드로 호출 후 겪을 수 있는 문제를 막아두셨네요.
There was a problem hiding this comment.
감사합니다! 더 좋은 코드 보여드릴 수 있도록 하겠습니다.
| //final이 자바스크립트의 const와 유사? | ||
| //private는 이제 직접 수정못하게하는? 외부에서 직접 접근 불가하게 하는것? | ||
|
|
||
| private int carPosition = 0; |
There was a problem hiding this comment.
위치를 항상 0에서 시작하도록 고정하셨는데, 그러면 특정 위치에 있는 자동차를 만들 방법이 없어집니다. CarsTest에서 우승자 판별을 테스트하실 때 자동차를 움직이는 코드가 꽤 들어가지 않으셨나요?
검증하려는 건 우승자를 고르는 규칙인데 거기까지 도달하는 과정이 테스트의 대부분을 차지하게 되거든요. 위치를 받는 생성자가 하나 더 있으면 어떨까요?
There was a problem hiding this comment.
반영하였습니다. 말씀해주신 대로 우승자 판별 테스트에서 moveIfPossible()로 위치를 옮기는 테스트로 구성했었고, 4, 5가 "전진 조건을 만족하는 값"이라는 걸 알아야 읽혀서 이동 조건이 바뀌면 우승자 테스트도 같이 깨질 수 있다는 점을 느꼈습니다. 그래서 Car가 위치를 생성자로 받도록 변경했고 게임에서는 항상 0에서 시작하도록 Cars.createCars()에서 new Car(name.trim(), 0)으로 시작 위치를 넘기도록 했습니다.
우승자 테스트는 new Car("A", 7), new Car("B", 2)처럼 위치를 직접 지정해서 전진 과정 없이 우승자 규칙만 검증하도록 수정했습니다. 이제 우승자 판별만을 위한 테스트 코드가 된 것 같습니다.
|
|
||
| private int carPosition = 0; | ||
|
|
||
| public Car(String carName) { |
There was a problem hiding this comment.
이름 검증을 생성자에서 하고 계시네요. 잘못된 상태의 객체가 아예 못 만들어지게 막은 것 좋습니다.
한 걸음 더 가보면, 지금 이름은 String이라 Car 밖으로 나가는 순간 그냥 문자열이 됩니다. getCarName()으로 꺼낸 값을 다른 곳에서 다루게 되면 검증은 따라가지 않죠. 이름에 관한 규칙이 이름 자신에게 붙어 있으면 어떨까요? 그러면 그 타입이 존재한다는 것만으로 검증을 통과했다는 게 보장되고, Car는 이름 규칙을 몰라도 됩니다.
"원시값 포장"으로 찾아보시면 방향이 잡힐 겁니다.
There was a problem hiding this comment.
읽어보았을때 별도로 carName 같은 클래스를 만들어서 그쪽에서 처리하자라는 뜻으로 이해했는데. 이렇게 되면 이름만 받아내는 것이라 객체를 흉내내기만 하는 경우가 발생할 것 같은데 어떻게 생각하시나요?
There was a problem hiding this comment.
좋은 지적입니다. 실제로 값만 감싸고 getValue() 하나만 있는 클래스는 말씀하신 대로 객체를 흉내만 내는 셈이 되고, 원시값 포장을 두고 자주 나오는 비판이기도 해요.
다만 저는 검증 자체가 이미 행동이라고 봅니다. CarName이 생성될 때 규칙을 확인한다면, 그 타입을 받은 쪽은 "이건 올바른 이름"이라는 걸 따로 확인할 필요가 없어지죠. 지금은 그 보장이 Car 생성자 안에서만 유효하고요.
그리고 행동은 앞으로 더 붙을 수 있습니다. 예를 들어 같은 이름의 자동차를 막아야 한다면 이름끼리 같은지 비교해야 하는데, 그 비교를 누가 하는 게 자연스러울까요?
사실 정답은 없고 기준이 다를 뿐입니다. 지금 구조에서 이름에 붙을 행동이 검증 하나뿐이라 과하다고 판단하신다면, 그 판단도 충분히 납득이 갑니다. 이유가 있는 선택이면 그대로 두셔도 좋아요.
There was a problem hiding this comment.
아직 제가 엄청난 판단을 할 수 있는 지식이 있진않지만 이 부분 같은 경우는 흉내낸다는 느낌이 먼저 왔기에 유지하도록 하겠습니다.
|
|
||
| @Test | ||
| @DisplayName("공동 우승자가 있을 수 있다") | ||
| void returnsMultipleWinnersWhenTied() { |
There was a problem hiding this comment.
공동 우승 케이스를 테스트하신 것 좋습니다. 구현하다 보면 우승자가 한 명이라고 생각하기 쉬운데, 여러 명일 수 있다는 걸 놓치지 않으셨네요. 이렇게 "보통은 이렇지만 가끔 저럴 수도 있는" 경우를 테스트로 잡아두면 나중에 코드를 고칠 때 안전망이 됩니다.
| } | ||
|
|
||
| //자동차 생성시키기(위치가 여기가 맞는것같음) | ||
| public static Cars createCars(String[] carNameArray) { |
There was a problem hiding this comment.
이런 걸 정적 팩토리 메서드라고 하는데, 이름 배열에서 Cars를 만드는 책임을 Cars자신이 가진 것 좋습니다.
시간이 남으시면 생성자와 어떤 차이가 있는지 한번 찾아보세요. 이름을 붙일 수 있다는 것 말고도 몇 가지가 더 있습니다.
생성자의 특징에 대해 알면 조금 좋을 것 같아요.
There was a problem hiding this comment.
네 알겠습니다! 정적 팩토리 메서드와 생성자 차이 확인후 코멘트 추가로 남기겠습니다.
There was a problem hiding this comment.
차이점에 대해서 말씀드리겠습니다.
첫 번째, 이름을 붙일 수 있습니다. 생성자는 클래스 이름만 쓸 수 있지만, 정적팩토리메서드는 createCars처럼 무엇을 만드는지 드러낼 수 있습니다.
두 번째, 정적팩토리 메소드에서는 new 처럼 새 객체를 계속해서 선언하지 않아도 됩니다.
| import java.util.Collections; | ||
| import java.util.List; | ||
|
|
||
| public class Cars { |
There was a problem hiding this comment.
지금 같은 이름의 자동차를 여러 대 만들 수 있는데, 경주에서 이게 허용되어야 할까요? 출력에서 누가 누군지 구분이 안 될 것 같은데요. 만약 막아야 한다면 그 판단은 누가 할 수 있을까요?
There was a problem hiding this comment.
듣고보니 맞는 말씀입니다. 제가 놓치고 있었던 예외사항중 하나였던 것 같습니다
private void validateNoDuplicateNames(List<Car> cars) {
Set<String> duplicateNames = new HashSet<>(); //[학습] Set은 중복을 허용하지 않는 자료구조
for (Car car : cars) {
if (!duplicateNames.add(car.getCarName())) { //false면 중복이니까
throw new IllegalArgumentException("자동차 이름은 중복될 수 없습니다.");
//중복 검증을 하여, 동일한 이름의 자동차가 존재하면 예외를 발생시킵니다.
}
}
}
이와 같은 메소드를 추가하여 중복일 경우 예외처리가 가능하도록 추가해놓았습니다.
There was a problem hiding this comment.
또한 예외검증 추가로 테스트코드도 추가할 필요가 있어보여 추가완료하였습니다.
| return new Cars(cars); | ||
| } | ||
|
|
||
| public List<Car> findWinner() { |
There was a problem hiding this comment.
우승자를 별도 메서드로 나눠서 판단하신 것 좋습니다.
다만 addWinnerIfMatch()가 파라미터로 받은 winners를 직접 수정하고 있는데, 이런 코드는 나중에 위험해질 수 있습니다. 메서드 이름만 보면 판단만 할 것 같은데 실제로는 리스트를 바꾸고 있어서, 호출하는 쪽에서는 언제 무엇이 변경됐는지 추적하기 어려워지거든요. 리스트가 안 바뀐 줄 알았는데 바뀌어 있으면 원인을 찾는 데 시간이 오래 걸립니다.
파라미터를 고치는 대신 값을 반환하도록 바꿔보시면 어떨까요? 그러면 이 메서드가 뭘 하는지가 시그니처만 봐도 드러납니다.
참고로 findWinner()도 이름이 단수인데 여러 명을 반환하고 있어서, findWinners()가 더 자연스러워 보입니다.
There was a problem hiding this comment.
아직 직접 리스트를 넘겼을때 추적하기 어려워진다는점이 아직까지 와닿는 말은 아니지만 반영하였습니다.
public List<Car> findWinners() {
//최대위치인 차만 고르면됨
int maxPosition = getMaxPosition();
List<Car> winners = new ArrayList<>();
for (Car car : cars) {
winners.addAll(winnerOf(car, maxPosition)); //[학습] addAll은 다른 리스트의 요소를 통째로 추가하는 메서드임
}
return winners;
}
//add로 하면 값을 car로 받아야하는데 이때 우승자가 없는경우 반환값을 정하기 어려워 addAll 사용
//winnerOf()는 우승자면 그 차 한 대짜리 리스트를, 아니면 빈 리스트를 반환만 합니다
private List<Car> winnerOf(Car car, int maxPosition) {
if (car.samePosition(maxPosition)) {
return List.of(car);
}
return List.of();
}
위와같은 형태로 바꿔보았습니다.
처음에는 Car를 반환하고 add로 넣는 방법도 생각했는데, 우승자가 아닌 경우에 반환할 값이 없어서(null이 or if사용) 우승자면 한 대짜리 리스트, 아니면 빈 리스트를 반환하고 addAll로 합치는 방식으로 했습니다. 규칙상 indent depth를 1까지만 허용해서 for 안에서 if를 쓰지 않으려는 이유도 있었습니다
이름도 findWinners()로 변경 완료하였습니다.
There was a problem hiding this comment.
솔직하게 말씀해주셔서 좋습니다. 『클린 코드』 3장에 이 얘기가 나오는데, 인수는 보통 "넣어주는 값"으로 읽히기 때문에 그걸로 결과를 받아오면 읽는 사람의 예상이 어긋난다는 겁니다. 지금은 코드가 짧아 괜찮지만, 그 리스트를 다른 곳에서도 쓰고 있었다면 "누가 값을 넣었지?"를 찾으러 호출한 곳을 다 뒤져야 하죠.
병합 커밋(53feef7)이 base의 1~2단계 코드를 되살려 domain/과 중복되어 있었음. 옛 테스트는 domain/CarTest, CarsTest에 모두 포함되어 있어 삭제해도 무방함.
526aa15 to
9d2d30a
Compare
|
1번 답변 : 처음 폴더를 나누게 된것은 4단계 리팩토링 힌트부분에 model view 나누라는 힌트가 있었기에 나누게 되었습니다. 나눈 기준점에 대해 말씀드리겠습니다. 저는 model view를 나눌때 객체라던가 돌아가는 코드에 대해서(아직 model 에 대한 감이 완벽하지 않습니다) Model로 분류를 하였고 입출력이 있는 것들은 view로 배치하였습니다.(6번질문과 이어지는 내용이기도 합니다) 2번 답변: 감사합니다. 3번 답변: 감사합니다. 조금 더 감을찾을때까지 열심히 공부해보겠습니다. 4번 답변: 1번 답을 아직 못들어서 공란으로 남겨두겠습니다. 5번 답변: 테스트는 일일이 사람이 체크하기 힘든 것들을 미리 작성하여. 코드작성의 효율을 높이는 것이라고 생각하고 있습니다. 6번 답변: 1번에서 정리한 것처럼 RacingGame이 출력까지 하면 domain이 view를 알게 되어 방향이 어긋난다고 생각합니다. 그래서 반복과 출력을 컨트롤러 역할인 Application에서 하는 지금 구조가 맞다고 이해하고 있는데 맞을까요? |
| @@ -0,0 +1,17 @@ | |||
| package domain; | |||
|
|
|||
| public class RacingGame { | |||
There was a problem hiding this comment.
지금 RacingGame은 cars.move()를 한 줄 전달하는 것 말고는 하는 일이 없습니다. 이런 클래스는 두 방향 중 하나를 고르면 좋을 것 같아요.
하나는 역할을 주는 겁니다. 지금 Cars가 우승자 판단까지 하고 있는데, "가장 멀리 간 차가 이긴다"는 자동차들의 성질일까요, 경주의 규칙일까요? 시도 횟수도 경주가 알 만한 정보고요.
다른 하나는 없애는 겁니다. 하는 일 없이 전달만 하는 층이라면 오히려 읽는 사람이 한 단계 더 따라가야 하니까요.
어느 쪽이든 괜찮습니다. 규형님이 고르신 방향과 그 이유가 궁금하네요.
There was a problem hiding this comment.
들어보니 말씀하신부분이 맞는 것 같습니다. 그래서 RacingGame에 역할을 주기로 하였습니다.
가장 멀리 간 차가 이긴다는 자동차 목록의 성질이 아니라 경주의 규칙이라고 생각해서, Cars에 있던 findWinners()와 그 내부에서 쓰던 winnerOf(), getMaxPosition()을 RacingGame으로 옮겼습니다. 즉, Cars는 자동차 관리에만 힘쓰고 RacingGame에 우승자 판별까지 넣어서 구분지어놓았습니다.
There was a problem hiding this comment.
시도횟수도 RacingGame이 알만한 부분이라서 그렇게 코드수정하였습니다.
1번그림을 직접 따라가서 "model은 view를 모른다" 까지 읽어내신 것 좋습니다. 두 가지를 덧붙이겠습니다. 1) 컨트롤러는 따로 있어야 합니다
그런데 컨트롤러가 하는 일은 그림에 적혀 있는 그대로예요.
이 게임으로 치면 이런 흐름입니다. 이걸 컨트롤러 클래스로 분리하면 2) view가 model을 알면 서로 붙잡게 됩니다"view는 model을 거쳐서 오기 때문에 의존해도 된다"고 해석하셨는데, 이 부분은 다르게 봅니다. 물론 view가 model을 안다고 프로그램이 안 돌아가는 건 아닙니다. 문제는 비용이에요.
model이 view를 몰라야 하는 이유(화면이 바뀌면 model도 바뀌어야 하니까)와 똑같은 문제가 방향만 반대로 생기는 겁니다. 둘이 서로를 붙잡고 있어서 한쪽을 움직이면 다른 쪽이 끌려오는 상태예요. 이걸 끊으려면 controller가 도메인 객체를 그대로 넘기지 않고, 화면에 필요한 값만 담은 별도의 객체로 바꿔서 넘겨주면 됩니다. 그러면 view는 그 객체만 알면 되고, 이런 객체를 흔히 DTO(Data Transfer Object) 라고 부르는데, 이번 차시 키워드에도 있으니 한번 찾아보시면 좋겠습니다.
5번맞습니다. 그리고 이미 한 번 경험하셨을 수도 있어요. 이번에 코드를 꽤 많이 고치셨는데, 고칠 때마다 테스트가 통과하는 걸 보고 "안 깨졌구나" 하고 안심하지 않으셨나요? 규모가 커지면 그 안심의 값어치가 훨씬 커집니다. 다만 테스트에도 비용이 있다는 걸 같이 말씀드리고 싶어요. 테스트는 많을수록 좋을 것 같지만, 꼼꼼하게 짠 테스트가 오히려 발목을 잡기도 합니다. 코드를 하나 고쳤는데 테스트 수십 개가 한꺼번에 깨지면, 기능은 멀쩡한데도 테스트를 고치느라 시간을 다 쓰게 되거든요. 특히 어떻게 동작하는지(구현) 를 테스트하면 이런 일이 잦습니다. 예를 들어 그래서 저는 이렇게 구분합니다.
테스트는 무엇을 하는지를 지켜주는 거고, 어떻게 하는지는 자유롭게 바꿀 수 있어야 좋은 테스트라고 생각합니다. |
|

안녕하세요! 이수현 리뷰어님
3,4단계 코드 리뷰 잘 부탁드립니다!
패키지 / 클래스 구조
1,2단계 미션 당시 마지막에 반영 요청이 있었던 테스트 코드의 이름과 메인에 있는 클래스 이름을 맞추는것이 좋다고 해서 반영하였습니다.
[refactor: RacingGame이 한 라운드씩 경주를 진행하도록 수정 ] 이 커밋부터 3,4 단계 미션 시작하는 부분임을 알려드립니다!
질문 및 고민사항
4단계 리팩토링 힌트를 보고선 폴더 구조를 나누어 보았습니다. 아직 생소한 구조라서 알아보았는데 Model , View, Controller가 각각 데이터 처리, 화면 출력, 제어를 담당하게끔 나눈다고 적혀있었습니다. 이와 같이 폴더구조를 나누었을떄 얻을 수 있는 이점이 뭔지 리뷰어님의 생각이 궁금합니다! 그리고 또한 제가 지금 코드를 mvc에 맞게 잘 나눈건지도 궁금합니다. 2,3,4,5,6번 질문은 mvc 구조로 나누기전 작성된 것임을 알려드립니다.
스터디 과정에서 인터페이스를 적용하면 메서드 약속만 정의하고 실제 동작은 여러 형태로 찍어낼 수 있어서, 난수값임에도 고정된 값으로 결정론적(예측 가능)하게 테스트가 가능하다고 배워서 반영하였는데, RandomNumber(인터페이스)와 DefaultRandomNumber(구현체)를 같은 패키지 안에 나란히 두는 게 맞는 건지 확신이 안 섰습니다. 혹시 interface만 따로 폴더를 만들어서 관리하는 형태는 어떤지 궁금합니다.
Application(구 Main) class를 구성할 때 안에 메소드들을 최대한 기능별로 쪼개서 구성을 했는데, 아직 클래스 즉 객체 또는 메소드를 쪼개는 기준점에 대해 확신이 없어서 명확한 기준점을 판단하는 방법이 있을까요?
처음에는 createCars를 Application(구 Main)에다가 위치해놨었는데, 의미가 자동차들을 모아두는 걸 생성하는 것이다 보니 Cars로 옮겼습니다. 적절하게 잘 옮긴 걸까요?
테스트를 어디까지 짜야 할지 감이 잘 오지 않습니다. 우선은 제가 필요하다고 생각한 부분에서는 대부분 짰는데, 놓치고 있는 부분이나 "이런 경우라면 테스트를 짜야 한다"는 기준점, 관점이 있을까요?
아래 코드를 보면 시도 횟수만큼 경주를 시키는 건데, 경주가 한 번 돌 때마다 출력을 해야 해서 출력을 중간에 넣어놨습니다.
경주와 출력이 같이 있는 느낌이라 분리를 하고 싶은데 잘 생각이 나지 않습니다. 이정도는 그대로 놔둬도 괜찮은 걸까요? 아니면 따로 수정이 더 필요한 부분일까요?