본문 바로가기

강의/TDD, Clean Code with Java 12기

자동차 게임 프로젝트 - 강의 정리

자동차 게임 프로젝트

구현시 주의할 점

domain은 view에 대한 의존관계를 가지지 않게 코드를 작성하라.

 

자동차 경주 프로젝트

RacingGame 클래스가 n개의 자동차를 가지고 있는 형태로 구현해보자.

Random 값을 생성하는 클래스를 분리하여 테스트가 쉽도록 구현.

요구사항 분석 후, 가장 작은 단위로 기능을 쪼개서 기능 목록을 만드는 것을 추천한다.

ToDo list를 기반으로 commit 로그를 작성하자.

ToDo list를 기반으로 test case를 만들자.

객체 설계만큼 기능 목록을 만드는 것이 중요하다.

 

기능 목록 분리가 어렵다면 아래의 순서로 프로젝트를 진행해보자

일단 구현 (프로그램에 대한 전반적인 지식을 쌓기) -> 구현한 모든 코드를 버린다. -> 기능 목록 작성 -> 만만한 것 부터 TDD 구현 -> 복잡도가 높아지면 다시 버리고 재도전

설계가 엉망인 코드는 과감하게 버리고 도전하는 것이 좋다. (레거시 코드가 있는 상태에서 리팩토링하는 것은 몇 배 더 어렵다.)

반복적인 연습은 설계 역량을 향상시키는 좋은 방법이 될 수 있다.

 

하나의 과정을 반복적으로 연습하면서 난이도를 높이는게 좋은 방법이다.

하나의 미션을 깊이 있고 다양하게 연습해 보는 것을 추천한다.

 

테스트를 위한 메서드를 추가하는 것은 좋지 않은 방법이지만

테스트를 위한 생성자를 추가하는 것은 괜찮다고 생각한다. (자바지기님)

 

TDD로 자동차 게임 구현

참여자의 이름  split하고 자동차 생성

1자 이상, 5자 이하의 정상적인 이름인지 확인

자동차 이동 유무

자동차 이동 거리에 따라 "-" 생성하기

경주에 참여한 자동차 중에서 우승자 찾기

우승자 이름 출력하기

 

1단계 - Util 성격의 기능이 TDD로 도전하기 좋음

1자 이상, 5자 이하의 정상적인 이름인지 확인

자동차 이동 거리에 따라 "-" 생성하기

 

2단계 - 테스트 가능한 부분에 대해 TDD로 도전

참여자의 이름  split하고 자동차 생성

경주에 참여한 자동차 중에서 우승자 찾기

 

3단계 - 테스트하기 어려운 부분을 찾아 가능한 구조로 개선

자동차 이동 유무 (랜덤 값에 의존하기 때문에, 랜덤값을 결정할 수 있도록 설정을 해야함)

우승자 이름 출력하기 (콘솔 ui와 의존하기 때문에, 또는 데이터 베이스에 접근할 때 테스트 하기 어려움)

 

테스트 하기 어려운 부분과 테스트 하기 쉬운 부분을 분리해서 테스트 하기 쉬운 부분만 테스트 할 수 있는 역량이 필요

 

테스트하기 어려운 부분을 찾아 가능한 구조로 개선

Object Graph (클래스에 의존하는 클래스)에서 다른 Object와 의존관계를 가지지 않는 마지막 노드(Node)를 먼저 찾는다.

예를 들어 RacingMain -> RacingGame -> Car와 같이 의존 관계를 가진다면 Car가 테스트 가능한지 확인한다.

 

public class Car {
	private static final int FORWARD_NUM = 4;

	private int position = 0;

	public void move() {
        Random random = new Random();
        return random.nextInt(MAX_BOUND);
    	if (getRandomNo() >= FORWARD_NUM)
        	this.position++;
		}
	}
}

 

위의 move 메서드는 테스트하기 어렵다.

class CarTest {
    @Test
    void 이동() {
        Car car = new Car(0);
        car.move();
        assertThat(car.getPosition()).isEqualTo(1);
    }

    @Test
    void 정지() {
        Car car = new Car(0);
        car.move();
        assertThat(car.getPosition()).isEqualTo(0);
    }
}

테스트 코드는 항상 패스를 해야하는데, 현재는 랜덤 값에 따라 테스트가 성공할 수도 실패할 수도 있다.

(좋은 테스트 코드는 언제 어디서든 항상 패스해야 한다.)

 

현재 상황을 살펴보자.

 

 

랜덤 값을 테스트 코드에서 결정할 수 없기 때문에 이 메서드는 테스트가 불가하다. ( 설계를 개선해야 한다.)

Random이 테스트 하기 어렵기 때문에, Car의 move메서드도 테스트 하기 어렵고

Car와 의존관계를 가지고 있는 RacingGame의 race 메서드도 테스트 하기 어렵고

RacingGame과 의존관계를 가지고 있는 main 메서드도 테스트 하기 어려워진다.

 

테스트하기 어려운 코드의 의존 관계를 Object Graph의 상위로 이동시킨다. (아래 그림 참고)

Random에 대한 의존관계를 Car클래스가 아닌 Main에서 가지게 변경하자.

 

[메서드 시그니쳐를 변경할 수 없을 때]

메서드 구조를 변경할 수 없을 때 진행하는 방법이다.

 

1. 메서드 분리 refactor 진행

// 분리 전
public class Car {
	private static final int FORWARD_NUM = 4;

	private int position = 0;

	public void move() {
        Random random = new Random();
        return random.nextInt(MAX_BOUND);
    	if (getRandomNo() >= FORWARD_NUM)
        	this.position++;
		}
	}
}

// 분리 후
public class Car {
	private static final int FORWARD_NUM = 4;

	private int position = 0;

	public void move() {
    	if (getRandomNo() >= FORWARD_NUM)
        	this.position++;
		}
	}
    
    private int getRandomNo() {
    	Random random = new Random();
        return random.nextInt(MAX_BOUND);
    }
}

 

getRandomNo에서 생성되는 값을 우리가 지정할 수 있도록 접근 제한자를 변경 해주자

getRandomNo 메서드의 접근 제한자를 변경 (private -> protected)

값을 테스트 할 수 있는 경계 부분을 만들어 주는 것이다.

public class Car {
	private static final int FORWARD_NUM = 4;

	private int position = 0;

	public void move() {
    	if (getRandomNo() >= FORWARD_NUM)
        	this.position++;
		}
	}
    
    protected int getRandomNo() {
    	Random random = new Random();
        return random.nextInt(MAX_BOUND);
    }
}

 

Test에서 해당 메서드를 overridde 진행하자

경계값만 테스트 하도록 한다.

class CarTest {
    @Test
    void 이동() {
        Car car = new Car(0) {
            @Override
            protected int getRandNum() {
                return 4;
            }
        };
        car.move();
        assertThat(car.getPosition()).isEqualTo(1);
    }

    @Test
    void 정지() {
        Car car = new Car(0) {
            @Override
            protected int getRandNum() {
                return 3;
            }
        };
        car.move();
        assertThat(car.getPosition()).isEqualTo(0);
    }
}

override를 하게 되어 테스트 코드의 복잡도가 높아지는 단점이있다.

[메서드 시그니쳐를 변경할 수 있을  때]

구조를 변경할 수 있는 경우에 대한 예시다.

1. 메서드 분리 refactor 진행 (메서드 시그니쳐를 변경할 수 없을 때와 동일)

// 분리 전
public class Car {
	private static final int FORWARD_NUM = 4;

	private int position = 0;

	public void move() {
        Random random = new Random();
        return random.nextInt(MAX_BOUND);
    	if (getRandomNo() >= FORWARD_NUM)
        	this.position++;
		}
	}
}

// 분리 후
public class Car {
	private static final int FORWARD_NUM = 4;

	private int position = 0;

	public void move() {
    	if (getRandomNo() >= FORWARD_NUM)
        	this.position++;
		}
	}
    
    private int getRandomNo() {
    	Random random = new Random();
        return random.nextInt(MAX_BOUND);
    }
}

 

2. 인자를 받는 형태의 메서드로 변경

public class Car {
	private static final int FORWARD_NUM = 4;

	private int position = 0;

	public void move(int randomNo) {
    	if (randomNo >= FORWARD_NUM)
        	this.position++;
		}
	}
    
    // 상위 클래스로 이동시켜 준다.
    private int getRandomNo() {
    	Random random = new Random();
        return random.nextInt(MAX_BOUND);
    }
}

 

3. move를 사용하는 메서드에 인자를 추가해준다.

public class RacingGame {
    private void moveCars() {
        List<Car> cars = new ArrayList<>();
        cars.add(new Car(1));
        cars.add(new Car(1));

        for (Car car : cars) {
            car.move(getRandomNo());
        }
    }

    private int getRandomNo() {
        Random random = new Random();
        return random.nextInt(10);
    }
}

 

4. 테스트 코드에도 move 메서드에 인자를 추가해준다.

package test;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

class CarTest {
    @Test
    void 이동() {
        Car car = new Car(0); 
        car.move(4);
        assertThat(car.getPosition()).isEqualTo(1);
    }

    @Test
    void 정지() {
        Car car = new Car(0);
        car.move(3);
        assertThat(car.getPosition()).isEqualTo(0);
    }
}

테스트 코드가 한결 깨끗해졌다.

 

[심화]

현재는 랜덤값이 4이상 일때만 자동차가 한 칸 앞으로 가는 형태이다.

시간이 지나서, 이동 유무 조건이 변경된다면?

변경 가능한 부분을 인터페이스로 분리 할 수 있다.

시간이 지나면서 정책이 달라지거나, 전략이 달라지는 부분에 적용하면 된다.

 

Car 클래스를 살펴보자.

package test;

import java.util.Random;

public class Car {
    private int position;

    public Car(int position) {
        this.position = position;
    }

    public void move(int randomNumber) {
        if (randomNumber > 4) {
            this.position++;
        }
    }

    public int getPosition() {
        return position;
    }

    protected int getRandNum() {
        Random rd = new Random();
        return rd.nextInt(10);
    }
}

 

1. interface 생성

변경 가능한 부분은 move 메서드 내에 존재하는 randomNumber > 4 부분이다.

이 부분을 interface로 대체하자

 

interface의 return 값은 boolean type일 것이다.

boolean 값을 return 해주는 interface 메서드를 생성하자.

public interface MovingStrategy {
    public boolean movable();
}

 

2. interface 적용

car 클래스에서 변경 가능한 부분을 만들어둔 interface로 대체한다.

package test;

import java.util.Random;

public class Car {
    private int position;

    public Car(int position) {
        this.position = position;
    }

    public void move(MovingStrategy movingStrategy) {
        if (movingStrategy.movable()) {
            this.position++;
        }
    }

    public int getPosition() {
        return position;
    }

    protected int getRandNum() {
        Random rd = new Random();
        return rd.nextInt(10);
    }
}

3. interface를 상속 받고, 실제 조건을 구현

package test;

import java.util.Random;

public class RandomValueMovingStrategy implements MovingStrategy{
    @Override
    public boolean movable() {
        return getRandomNo() >= 4;
    }

    private int getRandomNo() {
        Random random = new Random();
        return random.nextInt(10);
    }
}

4. 실행 코드 변경

public class RacingGame {
    private void moveCars() {
        List<Car> cars = new ArrayList<>();
        cars.add(new Car(1));
        cars.add(new Car(1));

        for (Car car : cars) {
            car.move(new RandomValueMovingStrategy());
        }
    }
}

5. 테스트 코드도 변경

class CarTest {
    @Test
    void 이동() {
        Car car = new Car(0);
        car.move(new MovingStrategy() {
            @Override
            public boolean movable() {
                return true;
            }
        });
        assertThat(car.getPosition()).isEqualTo(1);
    }

    @Test
    void 정지() {
        Car car = new Car(0);
        car.move(new MovingStrategy() {
            @Override
            public boolean movable() {
                return false;
            }
        });
        assertThat(car.getPosition()).isEqualTo(0);
    }

6. 테스트 코드를 람다로 변경

class CarTest {
    @Test
    void 이동() {
        Car car = new Car(0);
        car.move(() -> true);
        assertThat(car.getPosition()).isEqualTo(1);
    }

    @Test
    void 정지() {
        Car car = new Car(0);
        car.move(() -> false);
        assertThat(car.getPosition()).isEqualTo(0);
    }
}

 

대표적으로 테스트하기 어려운 코드

내부 API

 - Random, shuffle, 날짜

 

외부 세계

 - 외부 REST API

 - 데이터베이스 API

 

4단계 - 프로그래밍 요구사항을 힌트로 리팩토링

규칙 1: 한 메서드에 오직 한 단계의 들여쓰기만 한다.

규칙 2: else 예약어를 쓰지 않는다.

규칙 3: 모든 원시값과 문자열을 포장한다.

규칙 8: 일급 콜렉션을 쓴다.

[원시값을 포장해보자]

package test;

import java.util.Random;

public class Car {
    private int position;

    public Car(int position) {
        this.position = position;
    }

    public void move(MovingStrategy movingStrategy) {
        if (movingStrategy.movable()) {
            this.position++;
        }
    }

    public int getPosition() {
        return position;
    }

    protected int getRandNum() {
        Random rd = new Random();
        return rd.nextInt(10);
    }
}

position 을 포장해보자.

우선 TDD를 기반으로 만들어보는 연습을 해보자.

public class PositionTest {
	@Test
    void create() {
    	Position actual = new Position(5);
        assertThat(actual.getPosition()).isEquals(5);
    }
}

이에 맞는 Position 클래스를 구현해주자.

public class Position {
	private final int position;
    
    public Position(int position) {
    	this.position = position;
	}
    
    public int getPosition() {
    	return this.position;
    }
}

getter를 사용하고 있다.

일반적으로 getter와 setter는 지양하는 습관을 가지도록하자.

getter 메서드 없이 잘 생성 되었는지 확인하려면 어떻게 해야할까?

 

값을 객체로 포장하였다면 아래와 같이 객체와 객체를 비교하는 습관을 가지도록 해야한다.

public class PositionTest {
	@Test
    void create() {
    	Position actual = new Position(5);
        assertThat(actual.getPosition()).isEquals(new Position(5));
    }
}

위 Test 코드는 equals 메서드를 구현하지 않았기 때문에 실패한다.

equals 메서드를 구현하면 getter를 사용하지 않더라도 비교할 수 있다.

객체와 객체를 비교하는 것이 객체지향 프로그래밍의 첫번 째 만들어야 하는 습관이라고 생각한다.

객체의 데이터를 자꾸 꺼내려고 하지 말라.

 

position은 음수값을 가지지 않기 때문에 유효성 메서드도 추가해주면 된다.

public class Position {
	private final int position;
    
    public Position(int position) {
    	if (position < 0) {
        	throw new IllegalArgumentException("음수는 위치 값이 될 수 없습니다.");
        }
    }
    
    public Position() {
    	this(0);
    }
    
    public Position(int position) {
    	this.position = position;
	}
    
    public int getPosition() {
    	return this.position;
    }
}

테스트 코드도 같이 구현해보자.

public class PositionTest {
    @Test
    void create() {
    	Position actual = new Position(5);
        assertThat(actual.getPosition()).isEquals(new Position(5));
    }
    
    @Test
    void invalid() {
    	assertThatThrownBy(() -> {
        	new Position(-1)
        }).instanceOf(IllegalArgumentException.class)
    }
}

position 값이 음수가 되는 것도 같이 방지할 수 있다.

 

원시값을 포장하다 보면 상태값을 변경하는 메서드도 같이 이동하게 된다.

최대한 원시값을 모두 포장해주는 습관을 가지도록 하자.

작은 단위의 클래스도 의미가 있다.

 

final을 같이 붙여주게 되면 immutable 객체가 된다.

불변 객체로 구현한다는 것이다.

값을 바꿀 수 없다는 것이다.

불변 객체로 만들게 되면, 가비지 콜렉션되고, 메모리상 손실이 있을 수 있지만

사이드 이펙트가 생기는 것을 방지할 수 있다는 큰 장점이있다.

 

[일급 콜렉션을 쓴다.]

콜렉션을 하나의 인스턴스 변수로 들고 있는 클래스를 일급 콜렉션이라고한다.

테스트를 위한 생성자를 만드는 것은 허용한다.

아래의 Car 클래스의 생성자 매개변수가 다른 것은 감안하고 보길 바란다.

public class CarsTest {
	@Test
    void findWinners() {
    	Car pobi = new Car("pobi", 3);
        Car crong = new Car("crong", 2);
        Car honux = new Car("honux", 3);
        
    	cars cars = new Cars(Arrays.asList(pobi, crong, honux));
        List<Car> winners = cars.findWinners();
        assertThat(winners).contains(pobi, honux);
	}
}

일급 콜렉션을 생성한다.

public class Cars {
	public List<Car> cars;
    
    public Cars(List<Car> cars) {
    	this.cars = cars;
    }
    
    public List<Car> findWinners() {
    	int maxPosition = 0;
        for (Car car : cars) {
        	if (maxPosition < car.getPosition()) {
            	maxPosition = car.getPosition();
            }
        }
        
        List<Car> winners = new ArrayList<>();
        for (Car car : cars) {
        	if (car.getPosition() == maxPosition) {
	            winners.add(car);
			}
        }
        
        return winners;
    }
}

코드 refactor을 진행한다.

public class Cars {
	public List<Car> cars;
    
    public Cars(List<Car> cars) {
    	this.cars = cars;
    }
    
    public List<Car> findWinners() {
    	return getWinners(getMaxPosition());
    }
    
    private List<Car> getWinners(int maxPosition) {
    	List<Car> winners = new ArrayList();
        for (Car car : cars) {
        	if (car.getPosition() == maxPosition) {
            	winners.add(car);
            }
        }
        
        return winners;
    }
    
    private int getMaxPosition() {
    	int maxPosition = 0;
        for (Car car : cars) {
        	if (maxPosition < car.getPosition() {
            	maxPosition = car.getPosition();
            }
        }
        return maxPosition;
    }
}

getPosition을 사용하지 말고, 객체에 message를 보내서 구현하도록 한다.

반환되는 콜렉션도 일급 콜렉션으로 만들 수 있다!

 

'강의 > TDD, Clean Code with Java 12기' 카테고리의 다른 글

로또 게임 - 강의 정리  (0) 2021.09.07
엘레강트 오브젝트  (0) 2021.09.07
옵셔널  (0) 2021.08.25
스트림 실습  (0) 2021.08.25
스트림(stream)  (0) 2021.08.25