본문 바로가기

카테고리 없음

[우테코] 프리코스 2주차 회고

프리코스와 함께 보낸 시간이 2주가 다 되어간다.

좋은 코드를 고민하고 같은 분야의 많은 사람들과 함께 공통된 주제로 토의하는 시간을 가지다 보니 나도 같이 타오르는 것을 느꼈다. 특히 의미있는 고민과 생각을 공유하는 사람들이 정말 많았고, 서로 학습한 내용을 나누며 빠른 속도로 성장할 수 있도록 하는 우테코만의 분위기가 너무 즐거웠다. 이번 2주차에서는 “어떻게 해야 더 가독성이 좋을까?”, “어떻게 해야 더 객체지향적으로 설계할 수 있을까?” 이런 고민들을 중점적으로 하고, 코드에 녹여보는 시간을 집중적으로 가져보았다. 특히 책임을 바탕으로 역할을 분리하여 도메인을 설계하고, 클린 코드의 원칙을 최대한 지키려고 노력하여 최대한 높은 수준의 코드를 개발해나갔다.

이런 노력을 통해 코드의 가독성이 눈에 띄게 향상되어가는 것을 지켜보며 “왜 객체지향적으로 개발해야하는가?”, “객체지향 생활체조에서 강조하는 7가지가 왜 중요한가?” 에 대한 질문을 직접 몸으로 느껴 볼 수 있는 시간이었다. 이번 2주차 프로젝트를 진행하면서 생각했던 내용을 정리해보았다.

 

1. 일급 컬렉션?

 

"컬렉션 단 하나만을 필드로 가지고 있는", "컬렉션을 감싸 이름을 붙인" 그런 클래스를 일급 컬렉션 이라고합니다.

일급 컬렉션을 사용하면 어떤 장점이 있길래 객체지향 생활 체조 원칙에서는 일급 컬렉션을 강조하는 것인지 궁금하여 직접 사용해보면서 익혀보았습니다. 

 

변경 전 코드인 RacingGame이라는 클래스에는 아래와 같은 메서드가 존재하였습니다.

playGame 메서드에서 흐름이 시작되는데 이 메서드에서는 확인해보면 사이클마다 자동차가 이동을하고 (goEachcar), 각각의 사이클의 결과를 출력하고 (printEachCycleResult), 최종 결과를 출력하는 로직이 존재합니다.

 

변경 전 RacingGame 클래스를 살펴보면 2개의 필드 1개의 생성자 5개의 메서드를 가지고 있습니다. 이 정도의 역할과 책임을 가지고 있는 것은 적당해 보이는데? 라고 생각하였지만 일급 컬렉션을 사용하고 난 다음에는 생각이 변하였습니다.

 

package racingcar.domain;

import camp.nextstep.edu.missionutils.Randoms;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import racingcar.view.OutputManager;

public class RacingGame {
    private final Integer gameCycle;
    private final List<Car> cars;

    public RacingGame(Integer gameCycleNumber, List<Car> cars) {
        this.gameCycle = gameCycleNumber;
        this.cars = cars;
    }

    public void playGame() {
        for (int i = 0; i < this.gameCycle; i++) {
            for (Car car : cars) {
                goEachCar(car);
            }
            printEachCycleResult(cars);
        }
        printResult(cars);
    }
    private static void goEachCar(Car car) {
        int randNumber = Randoms.pickNumberInRange(0, 9);
        car.go(randNumber);
    }

    private void printEachCycleResult(List<Car> cars) {
        for (Car car : cars) {
            OutputManager.printEachCycleResult(car.getName(), car.getPosition());
        }
        System.out.println();
    }

    private void printResult(List<Car> cars) {
        List<String> winnerNames = findWinnerName(cars);
        OutputManager.printWinner(winnerNames.stream().collect(Collectors.joining(", ")));
    }

    private static List<String> findWinnerName(List<Car> cars) {
        List<String> winnerNames = new ArrayList<>();
        int maxPosition = 0;

        for (Car car : cars) {
            if (car.getPosition() == maxPosition) {
                winnerNames.add(car.getName());
                continue;
            }
            if (car.getPosition() > maxPosition) {
                maxPosition = car.getPosition();
                winnerNames = new ArrayList<>();
                winnerNames.add(car.getName());
            }
        }
        return winnerNames;
    }
}

 

아래는 변경 후 코드입니다. 

기존 RacingGame 클래스와 비교해보면 코드가 엄청 간결해 진 것을 볼 수 있습니다. playGame 메서드를 살펴보면 각각의 사이클 마다 자동차가 이동하고 결과를 출력해주는 로직만 남게 됩니다. 기존에는 RacingGame에서 자동차 리스트를 관리 하였었지만 현재는 Cars라는 일급 컬렉션이 생성되었기 때문에 이 역할은 모두 Cars 안에서 하는 것을 확인할 수 있습니다.

 

public class RacingGame {
    private final Integer gameCycle;
    private final Cars cars;

    public RacingGame(Integer gameCycleNumber, Cars cars) {
        this.gameCycle = gameCycleNumber;
        this.cars = cars;
    }

    public void playGame() {
        for (int i = 0; i < this.gameCycle; i++) {
            cars.moveByCycle();
            printEachCycleResult(cars);
        }
        printWinner(cars);
    }

    private void printEachCycleResult(Cars cars) {
        for (Car car : cars.getCarList()) {
            OutputManager.printEachCycleResult(car.getName(), car.getPosition());
        }
        System.out.println();
    }

    private void printWinner(Cars cars) {
        List<String> winnerNames = cars.winner();
        OutputManager.printWinner(winnerNames.stream().collect(Collectors.joining(", ")));
    }
}

 

Cars 클래스입니다. 

public class Cars {
    private List<Car> carList = new ArrayList<>();


    public Cars(String carNames) {
        List<String> carNameList = RacingGameUtil.splitCarNames(carNames);
        for (String carName : carNameList) {
            Car car = new Car(carName);
            this.carList.add(car);
        }
    }

    public void moveByCycle() {
        for (Car car : carList) {
            goEachCar(car);
        }

    }
    private void goEachCar(Car car) {
        int randNumber = Randoms.pickNumberInRange(0, 9);
        car.go(randNumber);
    }

    public List<Car> getCarList() {
        return Collections.unmodifiableList(carList);
    }

    public List<String> winner() {
        List<String> winnerNames = new ArrayList<>();
        int maxPosition = 0;

        for (Car car :carList) {
            if (car.getPosition() == maxPosition) {
                winnerNames.add(car.getName());
                continue;
            }
            if (car.getPosition() > maxPosition) {
                maxPosition = car.getPosition();
                winnerNames = new ArrayList<>();
                winnerNames.add(car.getName());
            }
        }
        return winnerNames;
    }
}

 

그래서 일급 컬렉션을 사용하는 이유는?

상위 1%의 엔지니어의 7가지 간단한 습관이라는 글을 읽어보면 (https://engineercodex.substack.com/p/7-simple-habits-of-the-top-1-of-engineers) 컴퓨터가 아닌 인간을 위한 코드를 작성하라는 내용이 존재한다. 객체 지향 프로그래밍이 지향하는 방향도 이와 같다고 보며, 일급 컬렉션을 사용하는 목적도 이와 같다고 볼 수 있다. 결국에는 역할과 책임을 적절한 곳에 적절하게 분배해서 사람이 코드를 이해하기 편하게 만들기 위함이다. 일급 컬렉션을 사용함으로 인해 역할과 책임을 분리하고, 가독성이 높아지게 되기 때문에 사용하는 것이라고 볼 수 있다.

 

2. 테스트와 설계에 대한 고민

 

기존에는 자동차가 이동하는 메서드를 아래와 같이 구현을 하였다. 랜덤 숫자를 뽑고, 그 숫자가 기준치를 만족하면 position을 한 칸 앞으로 이동하는 형태였다. (아래 메서드 참고) 기능을 이렇게 구현하고 나니 테스트를 할 수 있는 방법이 보이지 않는 것이다. 랜덤한 숫자가 무엇이 나올 지 알 수가 없는데 어떻게 테스트를 구현할 수 있을까? Mock을 써야하나?

 

public void go() {
    int randNumber = Randoms.pickNumberInRange(0, 9);
    if (randNumber >= CAR_FORWARD_JUDGMENT_CRITERIA) {
        this.position++;
    }
}

 

발상을 전환해보니 간단하였다. 구조를 조금만 변경해주었더니 테스트를 하기 편한 구조가 된 것이다. 랜덤 숫자를 파라미터로 받고, 파라미터로 받은 랜덤 숫자를 이용하여 위치를 이동시키도록 메서드를 변경하였다.

 

public void go(int randNumber) {
    if (randNumber >= CAR_FORWARD_JUDGMENT_CRITERIA) {
        this.position++;
    }
}

 

구현 코드를 개선함으로써 MOCK을 사용하지 않았음에도 불구하고 더 간결하고 검증하기 쉬운 테스트 코드를 작성할 수 있었다. 결국, 테스트 코드를 적극적으로 반영하는 과정에서 자연스럽게 더 나은 구현 코드를 작성하는 선순환 구조를 형성하는 셈이 된 것이다. 테스트 코드를 편리하게 작성하기 위해 코드의 설계를 무조건 적으로 변경하는 것은 바람직 하지 않지만, 더 나은 구조가 없을까 고민하는 부분은 필요하다고 생각한다. 앞으로도 동일한 문제가 발생하면 위 케이스를 생각하면서 더 나은 구조가 없을까 고민할 것 같다.

 

3. 원시값 포장

 

원시값 포장은 원시유형의 값을 이용해 의미를 나타내지 않고, 의미 있는 객체로 포장한다는 개념이라고 볼 수 있다.

 

변수를 선언하는 방법에는 두 가지가 있다.

 

int age = 20;
Age age = new Age(20);

 

바로 원시타입의 변수를 선언하는 방법과, 원시 타입의 변수를 객체로 포장한 변수를 선언하는 방법이 있다.

 

그렇다면 원시값 포장을 하는 이유는 무엇인가 ?

 

원시값 포장은 Primitive Obsession Anti Pattern(도메인의 객체를 나타내기 위해 primitive 타입을 사용하는 나쁜 습관)을 피하기 위함이다. 원시값 포장을 함으로써 안티 패턴을 피함으로써 얻을 수 이점들이 있기 때문이다. 이번 RacingCar 프로젝트에서 사용한 코드를 살펴보자.

 

public class Car {
    public static final Integer NAME_VALIDATION_LENGTH = 5;
    public static final Integer CAR_FORWARD_JUDGMENT_CRITERIA = 4;
    private final String name;
    private Integer position;

    public Car(String name) {
        validateName(name);
        this.name = name;
        this.position = 0;
    }

    private void validateName(String name) {
        if (name.length() >= NAME_VALIDATION_LENGTH) {
            throw new IllegalArgumentException();
        }
    }

    public void go(int randNumber) {
        if (randNumber >= CAR_FORWARD_JUDGMENT_CRITERIA) {
            this.position++;
        }
    }

    public Integer getPosition() {
        return position;
    }

    public String getName() {
        return name;
    }
}

 

위 코드처럼 String으로 name을 가지고 있다면 어떻게 될까 ? 가장 먼저 생각 나는 것은 나이에 관한 유효성 검사를 Car 클래스에서 하게 된다는 점이다. 이러한 코드에 원시값을 포장하게 된다면 기존 코드를 아래와 같이 변경할 수 있다. 

(Car 클래스를 Car과 Name 클래스로 나눠주었다.)

 

package racingcar.domain;

public class Car {
    public static final Integer CAR_FORWARD_JUDGMENT_CRITERIA = 4;
    private final Name name;
    private Integer position;

    public Car(String name) {
        this.name = new Name(name);
        this.position = 0;
    }
    

    public void go(int randNumber) {
        if (randNumber >= CAR_FORWARD_JUDGMENT_CRITERIA) {
            this.position++;
        }
    }

    public Integer getPosition() {
        return position;
    }

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

 

Name 클래스

package racingcar.domain;

public class Name {
    public static final Integer NAME_VALIDATION_LENGTH = 5;
    private static final String NAME_LENGTH_EXCEPTION_MESSAGE = "4글자 이하의 이름을 입력하세요.";

    private final String name;

    public Name(String name) {
        validateName(name);
        this.name = name;
    }

    private void validateName(String name) {
        if (name.length() >= NAME_VALIDATION_LENGTH) {
            throw new IllegalArgumentException(NAME_LENGTH_EXCEPTION_MESSAGE);
        }
    }

    public String getName() {
        return name;
    }
}

 

기존에는 Car 클래스에서 유효성 검사를 하던 부분이 Name 클래스에서 유효성 검사를 하도록 변경되었다. 그리고 Name이라는 클래스로 독립적으로 분리하였기 떄문에 다른 클래스에서도 재사용할 수 있다는 장점이 있다. 

 

 

4. 변경 불가능하게 만들기

 

문제의 조건을 확인해보면 사이클 마다 자동차의 위치를 출력해야 한다. 이를 위해 어쩔 수 없이 자동차를 꺼내서 (cars.getCarList()) 각각의 이름과 위치를 출력해줘야 하는 상황이 와버린 것이다. carList를 받아오는 것은 맞지만 개발자가 실수로 carList안에 있는 값을 변경하여 문제가 발생하지 않도록 해주는 시스템이 필요하다. 

 

 

아래와 같이 carList를 반환하는 두가지 메서드를 만들어보았다. unmodifiableList와 그냥 리스트를 반환해주는 메서드이다. 두개의 메서드는 어떤 차이가 있는지 테스트 코드를 통해서 확인해보았다.

 

public class Cars {
    private List<Car> carList = new ArrayList<>();


    public Cars(String carNames) {
        List<String> carNameList = RacingGameUtil.splitCarNames(carNames);
        for (String carName : carNameList) {
            Car car = new Car(carName, 0);
            this.carList.add(car);
        }
    }

    public List<Car> getCarList() {
        return Collections.unmodifiableList(carList);
    }
    
    public List<Car> getModifiableCarList() {
        return carList;
    }
    
    ...
}

 

예상했던바와 같이 리스트는 값을 제거하는 것도 가능하고, unmodifiableList는 리스트에 값을 추가할 수도 제거할 수도 없다. 반면에 리스트같은 경우에는 값을 추가할 수도 제거할 수도 있다. 리스트를 조작하고, 이 조작으로 인해 개발자가 의도하지 않은 문제가 발생할 수 있다. 바로 아래 테스트처럼 말이다. 이러한 문제가 발생하지 않도록 컬렉션을 return할 때는 꼭 Collections.unmodifiableList 를 사용해야 된 다는 것을 알게되었다.

 

class RacingGameTest {

    @Test
    @DisplayName("unmodifiableList인 경우에는 리스트에 값을 추가할 수 없습니다.")
    public void unmodifiableListTest() {
        Cars cars = new Cars("a,b,c");
        List<Car> carList = cars.getCarList();
        assertThatThrownBy(() -> carList.add(new Car("d")))
                .isInstanceOf(UnsupportedOperationException.class);
    }

    @Test
    @DisplayName("unmodifiableList인 경우에는 리스트에 값을 제거할 수 없습니다.")
    public void unmodifiableListTest2() {
        Cars cars = new Cars("a,b,c");
        List<Car> carList = cars.getCarList();
        assertThatThrownBy(() -> carList.remove(0))
                .isInstanceOf(UnsupportedOperationException.class);
    }

    @Test
    @DisplayName("modifiableList인 경우에는 리스트에 값을 추가할 수 있습니다.")
    public void modifiableListTest() {
        Cars cars = new Cars("a,b,c");
        List<Car> carList = cars.getModifiableCarList();
        carList.add(new Car("d"));
        assertThat(carList.size()).isEqualTo(4);
    }

    @Test
    @DisplayName("modifiableList인 경우에는 리스트에 값을 제거할 수 있습니다.")
    public void modifiableListTest2() {
        Cars cars = new Cars("a,b,c");
        List<Car> carList = cars.getModifiableCarList();
        carList.remove(0);
        assertThat(carList.size()).isEqualTo(2);
    }
}