[TDD, 클린 코드 with Java] 객체 설계(클래스 분리) : 일급 컬렉션, DI(Dependency Injection)
by Hi.Claire🐣 TDD, 클린 코드 with Java 19기 (박재성, 넥스트스텝)
객체 설계(클래스 분리) : 일급 컬렉션, DI(Dependency Injection)
✔️ 해도해도 어려운 것들(또는 익숙하지 않은 것) 목록
- TDD cycle 따르기 (자꾸 프로덕션 코드부터 구현한다. 정신 체리..!)
- 기능 목록 작성하기
- 객체 설계하기 (클래스 분리 실컷 했지만 자꾸 Getter를 쓰고 시포효.. 정신 체리!!)
- 클린코드
- 등등등
아주 그냥 쉬운게 하나도 없다.
그래도 계속 연습하다보면 언젠가는 익숙해질거라 믿는다..!
객체 설계 (클래스 분리)
2. 일급 콜렉션을 쓴다.
<소트웍스 앤솔로지> 객체지향 생활 체조 원칙
- 일급 컬렉션 : 인스턴스 변수로 콜렉션 하나만을 가지는 객체
RacingGame 객체의 컬렉션 List<Car> cars를 일급 컬렉션으로 만든다.
TDD cycle에 따라 실패하는 테스트 코드 작성 -> 테스트 성공 -> 리팩터링의 과정을 반복한다.
이번에도 Test Fixture를 만들 때 생성자를 잘 활용하는 것부터 시작한다.
자동차 경주 미션 4단계 기능 요구사항에 따르면 사용자에게 자동차 이름을 입력받고, ','로 구분해 각 이름에 해당하는 자동차 객체를 생성해야 한다.
따라서 Cars에 사용자가 입력한 자동차 이름을 String 타입의 인자로 받아 Cars 객체를 생성하는 생성자를 추가한다.
역시 테스트코드부터 작성한다.
CarsTest.java
public class CarsTest {
@Test
void create() {
Car pobi = new Car("pobi");
Car jason = new Car("jason");
Car brown = new Car("brown");
Cars cars = new Cars(Arrays.asList(pobi, jason, brown));
assertThat(cars).isEqualTo(new Cars("pobi,jason,brown"));
}
}
Cars.java
public class Cars {
public static final String NAME_DELIMITER = ",";
private final List<Car> cars;
public Cars(String carNames) {
this(initCars(carNames));
}
// 주생성자
public Cars(List<Car> cars) {
this.cars = cars;
}
private static List<Car> initCars(String carName) {
String[] names = carName.split(NAME_DELIMITER);
List<Car> cars = new ArrayList<>();
for (String name : names) {
cars.add(new Car(name));
}
return cars;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Cars cars1 = (Cars) o;
return Objects.equals(cars, cars1.cars);
}
@Override
public int hashCode() {
return Objects.hash(cars);
}
}
파라미터로 String carNames를 받는 생성자를 추가했는데, 주생성자를 호출하기 위해 initCars() 메서드를 사용한다.
initCars() 메서드는 RacingGame에 이미 존재하는 메서드이므로 그대로 이동시킨다.
만약 테스트케이스가 실패한다면 Cars에 equals() 메서드를 구현했는지 확인한다.
(참고)
public class Cars {
// 생략
public Cars(List<String> carNames) {
this(List.copyOf(carNames));
}
public Cars(List<Car> cars) {
this.cars = cars;
}
// 생략
}
참고로 위와 같이 List<String> 타입의 파라미터를 받는 생성자를 추가하면 다음과 같은 컴파일 오류가 발생한다.
Recursive constructor invocation
'Cars(List<String>)' clashes with 'Cars(List<Car>)'; both methods have same erasure
원인은 자바에서 같은 List 컬렉션을 쓸 경우 안의 Generic이 사라져버려서 두 생성자가 같은 매개변수 타입을 갖는 것으로 인식하기 때문이다.
이때에는 둘 중 하나만 선택해야 하므로 List<Car>를 선택하는 것이 하나의 방법이다.
또는 가변인자를 사용한다.
(참고) 가변인자를 사용한 생성자
https://moominie.tistory.com/98
[TDD, 클린 코드 with Java] 생성자 활용
🐣 TDD, 클린 코드 with Java 19기 (박재성, 넥스트스텝) 생성자 활용세 번째 라이브 강의의 마지막 내용이다.마지막 내용은 책의 '생성자 하나를 주 생성자로 만드세요'와 관련한 내용이다.그동안
moominie.tistory.com
이제는 이전에 원시값을 포장하는 클래스를 분리했을 때와 마찬가지의 순서로 진행한다.
RacingGame 객체에서 List<Car>를 사용하는 로직을 찾아 Cars 객체에 해당 로직을 추가한다.
RacingGame.java
private void moveCars() {
for (Car car : cars) {
car.move(new RandomNo(getRandomNo()));
}
}
private int getRandomNo() {
Random random = new Random();
int randomNo = random.nextInt(MAX_BOUND);
System.out.println("RandomNo: " + randomNo);
return randomNo;
}
랜덤값 때문에 테스트하기 어려운 코드이므로 일단은 테스트코드 생성없이 Cars 클래스로 메서드를 이동시킨다.
Cars.java
public void moveCars() {
for (Car car : cars) {
car.move(new RandomNo(getRandomNo()));
}
}
private int getRandomNo() {
Random random = new Random();
int randomNo = random.nextInt(MAX_BOUND);
System.out.println("RandomNo: " + randomNo);
return randomNo;
}
이제 RacingGame 객체에서 List<Car> 대신 Cars 객체를 사용하도록 바꾼다.
생성자 체이닝을 활용한다.
객체에게 메시지를 보낸다.
RacingGame.java
package javajigi;
import java.util.List;
import static java.util.Collections.unmodifiableList;
public class RacingGame {
private final Cars cars;
private RacingNumber tryNo;
public RacingGame(String carNames, int tryNo) {
this(carNames, new RacingNumber(tryNo));
}
public RacingGame(String carNames, RacingNumber tryNo) {
this(new Cars(carNames), tryNo);
}
//주생성자
public RacingGame(Cars cars, RacingNumber tryNo) {
this.cars = cars;
this.tryNo = tryNo;
}
public void race() {
System.out.println("TryNo: " + this.tryNo);
tryNo.decrease();
cars.moveCars();
}
public boolean racing() {
return !tryNo.isZero();
}
public List<Car> getCars() {
return unmodifiableList(cars.getCars());
}
public List<Car> getWinners() {
return null;
}
}
이제 RacingGame 객체에는 로직이 없다.
RacingGame 객체 내에 있던 원시값과 컬렉션을 포장함으로써 이 원시값과 컬렉션과 관련한 로직들이 각각의 객체 내로 모이게 된다.
RacingGame 객체는 이제 Cars와 RacingNumber라는 두 개의 객체에게 로직을 위임한다.
즉 Cars와 RacingNumber라는 두 개의 객체가 협력하여 RacingGame 객체를 완성한다.
이제 getWinners() 구현을 완성하자.
Winners 객체를 보니 List<Car> 컬렉션을 사용하여 로직을 구현하고 있다.
우승자를 구하는 로직들을 Cars 객체로 이동시킬 수 있을 것 같다.
기존 Winners.java
package javajigi;
import java.util.List;
import java.util.stream.Collectors;
public class Winners {
public static List<Car> findWinners(List<Car> cars) {
return findWinners(cars, getMaxPosition(cars));
}
private static List<Car> findWinners(List<Car> cars, int maxPosition) {
return cars.stream()
.filter(car -> car.isSame(maxPosition))
.collect(Collectors.toList());
}
private static int getMaxPosition(List<Car> cars) {
int maxPosition = 0;
for (Car car : cars) {
maxPosition = car.max(maxPosition);
}
return maxPosition;
}
}
Cars.java
public class Cars {
private final List<Car> cars;
// 생략
public List<Car> findWinners() {
int maxPosition = getMaxPosition();
return cars.stream()
.filter(car -> car.isSame(maxPosition))
.collect(Collectors.toList());
}
private int getMaxPosition() {
int maxPosition = 0;
for (Car car : cars) {
maxPosition = car.max(maxPosition);
}
return maxPosition;
}
// 생략
}
RacingGame.java
public class RacingGame {
private final Cars cars;
// 생략
public List<Car> getWinners() {
return unmodifiableList(cars.findWinners());
}
}
혹은 Winners 객체를 그대로 사용하고 Cars 객체가 Winners 객체에게 메시지를 보내서 위임하는 방식을 사용한다.
Cars.java
public List<Car> findWinners() {
return Winners.findWinners(cars);
}
Winners.java
public class Winners {
public static List<Car> findWinners(List<Car> cars) {
return findWinners(cars, getMaxPosition(cars));
}
private static List<Car> findWinners(List<Car> cars, int maxPosition) {
return cars.stream()
.filter(car -> car.isSame(maxPosition))
.collect(Collectors.toList());
}
private static int getMaxPosition(List<Car> cars) {
int maxPosition = 0;
for (Car car : cars) {
maxPosition = car.max(maxPosition);
}
return maxPosition;
}
}
RacingGame.java
public List<Car> getWinners() {
return unmodifiableList(cars.findWinners());
}
테스트하기 어려운 부분을 찾아 테스트 가능한 구조로 개선
지난번에 Car 객체 내에서 테스트하기 어려운 부분을 찾아 테스트 가능한 구조로 개선하는 연습을 해보았다.
Car 객체의 move() 메서드가 테스트하기 어려운 Random 객체와 의존관계를 맺고 있어서 해당 의존관계를 한단계 위의 object인 RacingGame까지 끌어올렸다.
관려 내용은 아래 글을 보면 된다.
https://moominie.tistory.com/93#head8
[TDD, 클린 코드 with Java] 241021 두 번째 라이브 강의1 : Test Fixture, 생성자 활용, Getter/Setter 지양, 객
🐣 TDD, 클린 코드 with Java 19기 (박재성, 넥스트스텝) 241021 두 번째 라이브 강의1 : Test Fixture, 생성자 활용, Getter/Setter 지양, 객체에게 메시지 보내기, 테스트 가능한 구조로 개선미션1. 자
moominie.tistory.com
이번에 RacingGame 객체의 List<Car>를 일급 컬렉션을 사용하여 Cars로 분리하면서 그 테스트하기 어려운 코드가 Cars 객체에 들어와버렸다.
그래서 이번에는 Cars의 moveCars() 메서드를 테스트 가능한 구조로 개선해보도록 하겠다.
1. DI (Dependency Injection)
Cars.java
public class Cars {
private final List<Car> cars;
//생략
public void moveCars() {
for (Car car : cars) {
car.move(new RandomNo(getRandomNo()));
}
}
private int getRandomNo() {
Random random = new Random();
int randomNo = random.nextInt(MAX_BOUND);
System.out.println("RandomNo: " + randomNo);
return randomNo;
}
//생략
}
getRandomNo() 메서드가 랜덤값을 생성하는데 이 부분 때문에 Cars의 moveCars() 메서드가 테스트하기 어렵다.
자동차 대수를 전달하면 그만큼의 랜덤값을 생성해서 List<Integer>로 반환하는 interface를 만든다.
테스트하기 어려운 코드(getRandomNo() 메서드)를 interface로 추출하고, 그 interface를 DI하면 테스트하기 좋은 코드가 만들어진다.
테스트하기 좋은 코드가 된다는 것은 다른 측면으로는 유연성이 좋아진다는 뜻이다.
DI(Dependency Injection)는 개발자(슨배륌)들이 Spring Framework이 없던 시절부터 프로그램의 유연성을 높이기 위해 많이 사용하던 패턴이다.
인터페이스 RandomNumbers.java
package javajigi;
import java.util.List;
public interface RandomNumbers {
List<Integer> numbers(int size);
}
Cars.java
public void moveCars(RandomNumbers randomNumbers) {
List<Integer> numbers = randomNumbers.numbers(cars.size());
for (int i = 0; i < numbers.size(); i++) {
cars.get(i).move(new RandomNo(numbers.get(i)));
}
}
리팩터링은 나중에,,,
이제 테스트가 어떻게 가능해지는지 보자.
CarsTest.java
@Test
void moveCars() {
Cars cars = new Cars("pobi,jason,brown");
cars.moveCars(new RandomNumbers() {
@Override
public List<Integer> numbers(int size) {
return Arrays.asList(4, 3, 5);
}
});
assertThat(cars).isEqualTo(new Cars(
Arrays.asList(
new Car("pobi", 1),
new Car("jason", 0),
new Car("brown", 1)
)));
}
참고로 위의 익명클래스는 아래와 같이 람다식으로 표현 가능하다.
@Test
void moveCars() {
Cars cars = new Cars("pobi,jason,brown");
cars.moveCars(size -> Arrays.asList(4, 3, 5));
assertThat(cars).isEqualTo(new Cars(
Arrays.asList(
new Car("pobi", 1),
new Car("jason", 0),
new Car("brown", 1)
)));
}
RacingGame.java
public void race() {
System.out.println("TryNo: " + this.tryNo);
tryNo.decrease();
cars.moveCars(new DefaultRandomNumbers());
}
DefaultRandomNumbers.java
package javajigi;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class DefaultRandomNumbers implements RandomNumbers {
private static final Random random = new Random();
private static final int MAX_BOUND = 10;
@Override
public List<Integer> numbers(int size) {
List<Integer> randomNumbers = new ArrayList<>();
for (int i = 0; i < size; i++) {
randomNumbers.add(createRandomNumber());
}
return randomNumbers;
}
private int createRandomNumber() {
int randomNumber = random.nextInt(MAX_BOUND);
System.out.println("RandomNo: " + randomNumber);
return randomNumber;
}
}
이런식으로 SpringFramework 없이도 테스트 가능한 구조로 만들기 위해 테스트하기 어려운 부분을 interface로 추출하고, 테스트코드를 만드는 연습을 해야 진짜 DI의 의미와 필요성을 느낄 수 있다.
'☕️ Java > TDD, 클린 코드 with Java' 카테고리의 다른 글
블로그의 정보
Claire's Study Note
Hi.Claire