3차 강의 - 클래스 분리 & Immutable

객체 설계 (클래스 분리)

메서드 분리만 잘해도 클린코드를 어느정도 만들 수 있다.

특히, 함수를 한가지 일만 하도록 분리하는걸 최우선으로 하는게 좋다!

이게 익숙해진다면 그 다음으로 클래스 분리로 넘어간다.

근데 클래스 분리( = 객체설계)가 메서드 분리보다 훨씬 어렵다.

객체 설계와 관련한 조언들

  • 객체의 역할, 책임, 협력을 고려하면서 객체 설계
    • 역할, 책임, 협력이란 무엇인가?
  • 객체 설계 5원칙 SOLID를 지키면서 객체 설계
  • 유지보수 하기 좋도록 객체 설계
  • "응집도는 높이고, 결합도는 낮춘다!" 원칙으로 객체 설계

이런 여러가지 조언들이 있지만 실제 현장에서 해당 원칙을 기반으로 객체 설계하기란 쉽지 않다. (물론 깊이 있게 고민하고 의식한다면 조금씩 나을 수 있지만, 조금이라도 의식하지 못한다면 즉흥적으로 개발하기 쉽다. 물론 즉흥적으로 개발하면서 좋은 설계를 할 수 있지만, 이 경지에 도달하기 까지 많은 연습이 필요한데, 위의 조언들만 가지고는 그 경지에 빠르게 도달하기 힘들 수 있다. ⇒ 객체 설계 역량은 더디게 성장한다.)

즉, 객체 설계 경험이 부족한 초보 개발자에게, 객체 설계는 어렵고, 도전하기 힘들고, 두려움이 가득한 존재로 인식되는 경향이 있다.

이러한 객체 설계에 대한 두려움은 클래스 분리에 대한 거부감으로 작용하게 된다.

객체 설계와 클래스 분리에 대한 두려움과 거부감을 줄이려면?

정성적인 객체 설계의 어려움을 정량적인 원칙을 지키는 방식으로 도전하자!

→ 클래스를 분리할 수밖에 없다. 클래스를 분리하다 보면 자연스럽게, 그 클래스와 관련된 메서드들이 이동하게 되어있음. 그렇게 정량적인 원칙으로 클래스를 분리하다보면 OOP가 이런것이구나! 라는 감이 오게된다.

그리고 이게 반복되어 어느정도 수준이 되면 나중에 즉흥적으로 객체를 설계하는데 이전보다 훨씬 나은 설계가 나올 수 있게 된다.

클랙스 분리를 위한 정량적인 원칙 찾기

  • 소트웍스 앤솔리지 책 중에서 찾을 수 있다. - 객체지향 생활 체조 원칙
  • 엘레강트 오브젝트 23가지 조언중에서 찾을 수 있다.
  • 클린코드 중에서 찾을 수 있다.
  • 이러한 책을 읽으면서 정량적인 원칙을 찾기 위한 노력을 해본다.

소트웍스 앤솔리지 책 - 객체지향 생활 체조 원칙

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

인스턴스 변수로 원시값과 문자열 하나만을 가지는 객체

지난 시간에 자동차 경주 게임을 하면서 int position을 포장했었다.

public class Position {
    private final int position;

    public Position(String position) {
        this(Integer.parseInt(position); 
    }

    public Position(int position) {
        if (position < 0) { // 생성자에서 유효성 처리!
            throw new IllegalArgumentException("이동거리는 음수가 될 수 없습니다.");
        }
        this.position = position; 
    }

    public int getPosition() {
        return this.position;
    }

    public void move(int randomNo) {
        if(randomNo >= FORWARD_NUM) 
                this.position++;
        }
    }

    public boolean isMaxPosition(int maxPosition) {
        return this.position == maxPosition; 
    }

    public int maxPosition(int maxPosition) {
        if (maxPosition < this.posititon) { 
                return this.position;
        } 
        return maxPosition;
    }

    ...
}

이때 Postiton 객체는 인스턴스 변수(위 예시의 경우 private final int position)를 반드시 하나만 가져야 한다.

지난 시간에도 언급했지만, 이렇게 Position 객체로 분리하게 된다면 CarTest에서 수행했던 isWinner, maxPosition 테스트를 PostionTest에서 수행하고, CarTest에서는 더이상 해당 테스트를 수행하지 않아도 된다.

public class CarTest {
    @Test <-- PositionTest로 옮기는게 맞다.
    void 최대이동거리_구하기() {
        Car car = new Car("pobi", 3);
        assertThat(car.maxPosition(2)).isEqualTo(3);
        assertThat(car.maxPosition(4)).isEqualTo(4);
    } 

    @Test <-- PositionTest로 옮기는게 맞다.
    void 최대이동거리_유무() {
        int maxPosition = 3;
        assertThat(new Car("pobi", 3).isWinner(maxPosition)).isTrue();
        assertThat(new Car("pobi", 2).isWinner(maxPosition)).isFalse();
    }

    @Test
    public void 이동() {
        Car car = new Car("pobi");
        car.move(4);
        assertThat(car.getPosition()).isEqualTo(1);
    }

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

이제 maxPosition과 isWinner는 더이상 Car에서 책임을 가지고 있지 않기 때문에 PositionTest에서 수행하는게 맞다.

public class PositionTest {
    @Test
    void 최대값_구하기() {
        Position position = new Position(3); // 여기서부터는 Car가 필요없고 Position 객체만 생각하면 된다.
        assertThat(position.maxPosition(2)).isEqualTo(3);
        assertThat(position.maxPosition(4)).isEqualTo(4);
    } 

    @Test
    void 최대이동거리_유무() {
        assertThat(new Position(3).isMaxPosition(3)).isTrue();
        assertThat(new Position(3).isMaxPosition(2)).isFalse();
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Position(-1);
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void create() {
        Position position = new Position(1);
        assertThat(position).isEqualTo(new Position("1"));
    }
}

Car 테스트는 이동과 정지에 대한 테스트만 담당하고, Position 테스트가 최대값, 최대이동거리 판단에 대한 테스트를 하게 된다.

이런식으로 클래스를 분리하다보면 각 객체에 책임을 위임하는 메서드가 많아지게되고, 이게 정상이다. (거부감이 없어야 한다.)

참고 : 페이징 처리를 위한 Page 객체는 도메인 객체와는 거리가 있으므로 꼭 포장을하거나 감쌀 필요 없다. (도메인 객체를 위주로 지금까지 배운 규칙들을 적용 시키려고 노력하면 된다.)

참고 : setter 없이도 도메인 설계 개발이 가능하다. setter와 getter는 DTO에서만 사용하도록 한다.

참고 : view 단에 필요한 데이터 들만 getter를 만든다.

public class Car {

    private final Position position; <-- wrapping
    ...

    public void move(int randomNo) {
        if(randomNo >= FORWARD_NUM) 
                this.position++; <-- 컴파일 에러
        }
    }


}

Position를 wrapping 하면서 다음과 같은 move 메서드에 컴파일 에러가 발생하게 된다. (이런일은 굉장히 빈번하게 일어나고, 이때 잘 리팩토링 해야 한다.)

position에 대한 값이 증가하는 것이므로 다음과 같이 테스트 코드를 작성한다.

public class PositionTest {

    @Test
    void increate() {
        Position position = new Position(3);
        position.increase(); <-- 컴파일 에러
        assertThat(position).isEqualTo(new Position(4));
    }

    @Test
    void 최대값_구하기() {
        Position position = new Position(3);
        assertThat(position.maxPosition(2)).isEqualTo(3);
        assertThat(position.maxPosition(4)).isEqualTo(4);
    } 

    @Test
    void 최대이동거리_유무() {
        assertThat(new Position(3).isMaxPosition(3)).isTrue();
        assertThat(new Position(3).isMaxPosition(2)).isFalse();
    }

    @Test
    void invalid() {
        assertThatThrownBy(() -> {
            new Position(-1);
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void create() {
        Position position = new Position(1);
        assertThat(position).isEqualTo(new Position("1"));
    }
}

테스트 실패 → Position 객체에 increase() 메서드를 만든다.

public class Position {
    private int position; // increase() 메서드에서 값을 변경하므로 final을 쓸 수 없다.

    public Position(String position) {
        this(Integer.parseInt(position); 
    }

    public Position(int position) {
        if (position < 0) { // 생성자에서 유효성 처리!
            throw new IllegalArgumentException("이동거리는 음수가 될 수 없습니다.");
        }
        this.position = position; 
    }

    public int getPosition() {
        return this.position;
    }

    public void move(int randomNo) {
        if(randomNo >= FORWARD_NUM) 
                this.position++;
        }
    }

    public boolean isMaxPosition(int maxPosition) {
        return this.position == maxPosition; 
    }

    public int maxPosition(int maxPosition) {
        if (maxPosition < this.posititon) { 
                return this.position;
        } 
        return maxPosition;
    }

    public void increase() {
        this.position++; // increase() 메서드 구현
    }

    ...
}

이렇게 구현하고

public class Car {

    private final Position position;
    ...

    public void move(int randomNo) {
        if(randomNo >= FORWARD_NUM) 
                position.increase(); // 컴파일 에러 해결
        }
    }
}

Car의 컴파일 에러까지 해결하면 모든 리팩토링은 끝난다.

OOP 적인 사고로 getter를 사용하지 않으려고 하다보면 이런식의 메서드들이 계속 만들어지게 된다! (좋은 현상이다.)

가장 좋은 객체는 응집도가 높은 객체이다. 응집도가 높으려면 인스턴스 메서드 전체가 인스턴스 변수에 의존해야 한다.

근데 만약 객체에 인스턴스 변수가 많아지게 되면 메서드별로 의존하는 인스턴스 변수들이 파편화되게 된다. 그렇게되면 객체가 여러개의 책임을 가질 수 있다는 의심 포인트가 된다..!

객체 지향적 으로 개발하면 좋은점

  1. 테스트하기 쉬워진다. (클래스 분리로 인해 테스트가 쉬워짐)
  2. 비즈니스 로직의 중복을 제거한다. (객체로 비즈니스 로직을 끌어들여서 중복을 제거함)

이제 Car 의 name도 객체로 분리할 수 있다.

public class Name {
    private final String name;

    public Name(String name) {
        if (isBlank(name)) {
            throw new IllegalArgumentException("이름은 빈 값이 될 수 없습니다.");
        }
        if (name.length() > 5) {
            throw new IllegalArgumentException("이름은 5자를 초과할 수 없습니다.");
        }

        this.name = name;
    }

    private boolean isBlank(String name) {
        return name == null || name.isBlank();
    }
}

이렇게 되면 Car 객체는 Name객체와 Position 객체를 필드로 가지고, 메서드로는 이동에 대한 메서드만 책임지면 된다!

Car의 move메서드의 파라미터로 받는 int randomNo도 객체로 받도록 해보자.

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

    private final int randomNo;

    public RandomNo(int randomNo) {
        this.randomNo = randomNo;
    }

    public boolean movable() {
        return this.randomNo >= FORWARD_NUM;
    }
}
public class Car {
    private final Name name;
    private final Position position;
    ...

    public void move(RandomNo randomNo) {
        if(randomNo.movable()) { 
            position.increase();
        }
    }

    ...

}

이렇게 계속해서 클래스를 분리하다보면 Car객체에 로직이 다른 클래스로 위임이 되게 된다.

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

  • 인스턴스 변수로 컬렉션 하나만을 가지는 객체

예시를 보자

public class RacingGame {
    private static final int MAX_BOUND = 10;
    private final List<Car> cars; <-- 객체로 감쌀 예정
    private int tryNo; <-- 객체로 감쌀 예정

    public RacingGame(String carNames, int tryNo) {
        this.cars = initCars(carNames);
        this.tryNo = tryNo;
    }

    private static List<Car> initCars(String carNames) {
        if (StringUtils.isBlank(carNames)) {
                throw new IllegalArgumentException("자동차 이름은 값이 존재해야 합니다.");
        }
        String[] names = carNames.split(',');
        List<Car> cars = new ArrayList<>();
        for (String name : names) {
                cars.add(new Car(name));
        }
        return cars;
    }

    public void race() {
        this.tryNo--;
        moveCars();
    }

    private void moveCars() {
        for (Car car : cars) {
                car.move();
        }
    }

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

    public boolean racing() {
            return this.tryNo > 0;
    }

    public List<Car> getCars() {
        Collections.unmodifiableList(cars);
    }

    public List<Car> getWinners() {
        return null;
    }
}

RacingGame에서 Car 객체들을 초기화하는 과정이 있는데, 이를 Cars 객체를 만들고, 일급 컬렉션을 만든다. (그 과정에서 RacingGame에 있던 moveCars, getRandomNo 메서드가 Cars 객체로 이동하게 된다.)

public class Cars {
    private static final int MAX_BOUND = 10;
    private final List<Car> cars;

    public Cars(List<Car> cars) {
        this.cars = cars;
    }

    public Cars(String carNames) {
        this(initCars(carNames)); // 주 생성자 호출
    }

    private static List<Car> initCars(String carNames) {
        if (StringUtils.isBlank(carNames)) {
                throw new IllegalArgumentException("자동차 이름은 값이 존재해야 합니다.");
        }
        String[] names = carNames.split(',');
        List<Car> cars = new ArrayList<>();
        for (String name : names) {
                cars.add(new Car(name));
        }
        return cars;
    }

    public void moveCars() { // RacingGame에서 메서드 옮겨옴
        for (Car car : this.cars) {
                car.move(getRandomNo());
        }
    }

    private int getRandomNo() { // RacingGame에서 메서드 옮겨옴
        Random random = new Random();
        return random.nextInt(MAX_BOUND);
    }
}
public class RacingGame {
    private final Cars cars;
    private int tryNo;

    public RacingGame(String carNames, int tryNo) {
        this.cars = new Cars(carNames);
        this.tryNo = tryNo;
    }

    public void race() {
        this.tryNo--;
        this.cars.move();
    }

    public boolean racing() {
            return this.tryNo > 0;
    }

    public Cars getCars() {
        return this.cars;
    }

    public List<Car> getWinners() {
        return null;
    }
}

그러면 racingGame 객체에서 초기화하던 로직을 Cars 일급 컬렉션에서 해결할 수 있다.

tryNo도 객체로 만들 수 있다.

public class TryNo {
    private int tryNo;

    public TryNo(int tryNo) {
        if (tryNo > 0) {
            throw new IllegalArgumentException();
        }
    }

    public void decrease() { <-- RacingGame에 있는 메서드 옮겨옴
        this.tryNo--;
    }

    public boolean racing() { <-- RacingGame에 있는 메서드 옮겨옴
        return this.tryNo > 0;
    }
}    
public class RacingGame {
    private final Cars cars;
    private TryNo tryNo;

    public RacingGame(String carNames, int tryNo) {
        this.cars = new Cars(carNames);
        this.tryNo = tryNo;
    }

    public void race() {
        this.decrease();
        this.cars.move();
    }

    public boolean racing() {
            return this.tryNo.racing();
    }

    public Cars getCars() {
        return this.cars;
    }

    public List<Car> getWinners() {
        return null;
    }
}

이렇게 객체를 분리하게되면

public class RacingMain {
    public static void main(String[] args) {
        String carNames = InputView.getCarNames();
        int tryNo = InputView.getTryNo();

        RacingGame racingGame = new RacingGame(carNames, tryNo);
        while(racingGame.racing()) {
            racingGame.race();
        }
        ResultView.printWinners(racingGame.getWinners());
    }
}

최종적으로 RacingMain이 다음과 같은 형태로 데이터를 꺼내오는 형태가아닌, 모두 객체에게 메시지를 보내서 레이싱 게임 프로그램을 시작 시킬 수 있다.

참고 : 위의 main 메서드를 controller로 보고 바로 mvc 패턴을 사용하는게 좋다. (굳이 RacingController를 만들어서 RacingMain의 main 함수에 RacingController를 호출하는 방식은 굳이 필요하지 않다.)
Layerd architecture에 익숙해져서 반드시 Controller, Service 객체를 만드는 경향이 있는데, 사실 지금 우리가 하고 있는 과정에서는 크게 필요하지 않다. (습관처럼 만들려고 하지말고 필요한지 아닌지 생각을 해볼 필요가 있다.)

참고 : 면접 질문으로 물어볼 수 있다. Layerd-architecture에서 service의 역할은 무엇입니까? (우리가 지금껏 습관적으로 사용한 것에 대한 의문을 가질 필요가 있다.)
(service의 역할은 중요하고 반드시 필요하다. 그런데, 우리는 보통 service를 아무 생각없이 사용하거나, 너무 과한 역할을 부여하고 있다. service 레이어에서는 정말 작은 역할만 해야 한다.)
결국 정답이라고 하면, service layer의 역할 중 하나는 도메인 객체에 메시지를 보내는 역할(메시지를 보내서 해당 도메인 객체를 동작시킴)이 서비스의 역할중 하나이다.
또, 도메인 객체에 대한 퍼사드 역할도 수행한다.

참고 : 객체를 view단 데이터로 출력하기 위해 외부로 반환할 때, 그 객체가 immutable하다면 객체 그대로 반환해도 큰 문제는 없지만, mutable한 객체를 그대로 반환 한다면 버그가 발생할 수 있다. (우리가 구현한 걸로 보면 Car, Cars, Position, TryNo 모두 mutable한 객체이다)
여기서 우리가 고민해야 할 것은 mutable한 객체의 데이터를 view단에 반환하려면 어떻게 immutable하게 고쳐서 줄지를 고민해야 한다.

참고 : immutable하게 만드는 방법중 하나로, Collections.unmodifiable을 주로 사용하지만, 이는 리스트 컬렉션에 데이터를 추가, 삭제하는것이 불가능한 것이지 원래 존재하고있는 리스트 값들을 변경할 수 없는 것은 아니다. 그래서 이때는 아예 원본 객체를 복제하여 반환해주는 방법도 있다. (깊은 복사) 혹은 immutable한 객체로 다시 구현할 수도 있다.

참고 : 스태틱 메서드도 왠만하면 사용을 줄이는 게 좋다. 어디서든 불릴 수 있기 때문에 테스트도 힘들고 유지보수도 어렵다. 그리고 객체의 변경가능성이 높아진다.

3. 규칙 7 : 3개 이상의 인스턴스 변수를 가진 클래스를 구현하지 않는다.

  • 한 클래스 내에 2개의 인스턴스 변수까지만 허용한다.

이 규칙은 생각보다 지키기 힘들다. (나중에 볼링 게임 구현할때 특히 느낄 수 있다.)

도메인 객체와 테이블간의 관계가 1:1로 개발이 되고 있다면, 잘못 개발하고 있는 것이다. 보통은 테이블 하나당 도메인 객체는 N개가 되야 한다. 그 말은 도메인 객체의 인스턴스 수가 적다는 뜻이 되고, 위 규칙 7을 지키기 위해 도메인 객체를 분리하게 되면서 인스턴스 변수가 줄어들게 되어있다!

인스턴스 변수의 수를 줄이는 좋은 방법은?

  • 중복된 값 또는 불필요한 인스턴스 변수가 있는지 확인해서 제거한다.
  • 관련 있는 인스턴스 변수를 새로운 클래스(객체)로 묶어서 분리한다.

예를 들어 우승자를 구하는 다음 객체를 보자.

public class Winner {
    private List<Car> cars;
    private List<String> winnerList;
    private int maxDistance;
}

위 객체의 maxDistance와 winnerList는 자동차 목록(cars)만 있어도 모두 구할 수 있는 값이다. (굳이 인스턴스 변수로 들고 있을 필요가 없다!)

⇒ 인스턴스의 수가 많아지면 어딘가 중복된 데이터가 없는지 찾아봐라!

중복된 데이터로 인스턴스 변수가 존재한다면, 데이터 싱크를 맞춰야하고, 버그가 발생할 확률이 높아진다.

데이터베이스도 똑같다. 예를들어 역정규화를 통해 데이터를 증가시킬 때, QNA에서 질문의 목록을 보여주는 기능이 있다고 해보자. 질문의 목록에 답변이 몇개 달렸는지 보여줘야 한다.

답변이 몇개 달렸는지를 목록을 뿌릴때마다 매번 하위에 달려있는 answer를 count하는거는 성능이 떨어진다. 그래서 보통 역정규화를 많이 한다. 답변이 쌓일때마다 question 테이블에 필드하나를 +1씩 증가시킨다. (삭제할때는 -1) ← 이런식의 역정규화를 많이한다. 근데 이건 데이터의 중복이다. question 목록만 가지고도 답변의 갯수를 알수 있는건데 성능때문에 이 답변갯수를 question 테이블에다가 역 정규화해서 갖고 있는 거기 떄문에 answer가 바뀔 때마다 답변의수가 몇개인지 싱크를 맞춰줘야 한다. (안그럼 버그가 생긴다. 트랜잭션이 깨질때도 버그가 생김..)

위의 우승자 객체의 인스턴스 변수로 이 데이터베이스를 다루는 상황과 똑같다..!

따라서 위 객체는 다음과 같이 하나의 인스턴스 변수만으로 구현할 수 있다.

public class Winner {
    private List<Car> cars;

    private int getMaxDistance() {...}

    public List<String> getWinners() {...}
} 

클린코드 책 중에서 클래스 분리 원칙 찾기

함수 인수

  • 함수에서 이상적인 인수 개수는 0개(무항)이다. 다음은 1개이고, 다음은 2개이다.
  • 3개는 가능한 피하는 편이 좋다.
  • 4개 이상은 특별한 이유가 있어도 사용하면 안된다.

여기서 위에서 말한 인스턴스 변수를 2개 이하로 사용해보자는 것과 상충하는 부분이 있을 수 있다.

인스턴스 변수가 적어질 수록 함수의 인수가 많아질 수 있고, 함수 인수가 적을 수록 인스턴스변수가 많아 질 수 있다는 것이다. 서로 상충된다고 생각할 수 있다.

이때 고민해야 할 것은 그 객체가 너무 많은 책임을 떠맡고 있지는 않나? 를 고민해 봐야 한다.

로또 게임 예시를 보자.

public class LottoGame {
    public static int match(List<Integer> userLotto, List<Integer> winningLotto, int bonusNo) {
        int matchCount = match(userLotto, winningLotto);
        if (matchCount == 6) {
            return 1;
        }
        boolean matchBonus = userLotto.contains(bonusNo);
        if (matchCount == 5 && matchBonus) {
            return 2;
        }
        if (matchCount > 2) {
            return 6 - matchCount + 2;
        }
    }

    ...

}

match 메서드 인자가 3개인데, 이를 어떻게 줄일 수 있을까?

해당 파라미터를 객체로 묶어보자!

public class MyLottoGame {
    private List<Integer> userLotto; (1)
    private List<Integer> winningLotto; (2)
    private int bonusNo; (3)

    public int match() {
        return 0; // 나중에 구현
    }
}

이렇게 파라미터 인수를 객체로 분리하였는데, 어? 이렇게 되면 또 인스턴스 변수가 3개가 되어버린다..

이때는 인스턴스 변수들, 혹은 위의 파라미터 인수들 중에 서로 관련성이 있는 것들끼리 하나의 객체로 묶는다!

그럼 (1) - (2) , (1) - (3), (2) - (3) 중 어떤 걸 묶는게 가장 좋을까?

객체의 라이프사이클을 봤을 때, 객체가 생성되고 소멸되는 과정에서 당첨번호와 보너스 번호가 라이프사이클이 일치한다. 그래서 묶을때 생명주기가 같은 얘들을 묶도록 노력해보자!(2) - (3)을 묶는게 옳아 보인다!

그렇게 되면 아래와 같은 객체가 만들어 지게 된다.

public class WinningLotto {
    private final List<Integer> winningLotto;
    private final int bonusNo;

    public WinningLotto(List<Integer> winningLotto, int bonusNo) {
        this.winningLotto = winningLotto;
        this.bonusNo = bonusNo;
    }
}
public class MyLottoGame {
    private List<Integer> userLotto;
    private WinningLotto winningLotto;

    public int match() {
        return 0;
    }
}
public class LottoGame {
    public static int match(List<Integer> userLotto, WinningLotto winningLotto) {
        int matchCount = match(userLotto, winningLotto);
        if (matchCount == 6) {
            return 1;
        }
        boolean matchBonus = userLotto.contains(bonusNo);
        if (matchCount == 5 && matchBonus) {
            return 2;
        }
        if (matchCount > 2) {
            return 6 - matchCount + 2;
        }
    }

    ...

}

이렇게 되면 MyLottoGame의 인스턴스 변수가 3개에서 2개로 줄어들고, 파라미터 인수 3개에서 2개로 줄일 수 있다.

4. private method에 대한 테스트는 어떻게 할 것인가?

private method가 등장한 것은 public method를 리팩토링하는 과정에서 나온 것이기 때문에 테스트 하지 않는게 좋다고 생각한다.

근데 이 코드를 한번 보자.

public class LottoGame {
    public static int match(List<Integer> userLotto, List<Integer> winningLotto, int bonusNo) {
        return rank(match(userLotto, winningLotto), userLotto.contains(bonusNo));
    }

    private static int rank(int matchCount, boolean matchBonus) {
        if (matchCount == 6) {
            return 1;
        }
        boolean matchBonus = userLotto.contains(bonusNo);
        if (matchCount == 5 && matchBonus) {
            return 2;
        }
        if (matchCount > 2) {
            return 6 - matchCount + 2;
        }
    }

    ...

}

private 메서드인 rank를 테스트하려면?

public class LottoGameTest {
    private List<Integer> winningLotto;
    private int bonusNo;

    @BeforeEach
    void setUp() {
        winningLotto = Arrays.asList(1, 2, 3, 4, 5, 6);
        bonusNo = 7;
    }

    @Test
    public void match_1등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 4, 5, 6);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(1);
    }

    @Test
    public void match_2등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 4, 5, 7);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(2);
    }

    @Test
    public void match_3등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 4, 5, 8);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(3);
    }

    @Test
    public void match_4등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 4, 7, 8);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(4);
    }

    @Test
    public void match_5등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 7, 8, 9);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(5);
    }

    @Test
    public void match_꽝() {
        List<Integer> userLotto = Arrays.asList(1, 2, 7, 8, 9, 10);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(0);
    }
}

이렇게 public 메서드인 match를 통해서 테스트 해야하는데, 테스트를 위한 데이터 셋업 과정이 너무 길어지게 된다.. 근데 만약 rank 메서드를 public으로 바꾼다면?

public class LottoGameTest {
    private List<Integer> winningLotto;
    private int bonusNo;

    @BeforeEach
    void setUp() {
        winningLotto = Arrays.asList(1, 2, 3, 4, 5, 6);
        bonusNo = 7;
    }

        @Test <------ 훨씬 간단하다..
        public void rank() {
            assertThat(LottoGame.rank(6, false)).isEqualTo(1);
            assertThat(LottoGame.rank(5, true)).isEqualTo(2);
            assertThat(LottoGame.rank(5, false)).isEqualTo(3);
            assertThat(LottoGame.rank(4, false)).isEqualTo(4);
            assertThat(LottoGame.rank(3, false)).isEqualTo(5);
            assertThat(LottoGame.rank(2, false)).isEqualTo(6);
        }

    @Test
    public void match_1등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 4, 5, 6);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(1);
    }

    @Test
    public void match_2등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 4, 5, 7);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(2);
    }

    @Test
    public void match_3등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 4, 5, 8);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(3);
    }

    @Test
    public void match_4등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 4, 7, 8);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(4);
    }

    @Test
    public void match_5등() {
        List<Integer> userLotto = Arrays.asList(1, 2, 3, 7, 8, 9);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(5);
    }

    @Test
    public void match_꽝() {
        List<Integer> userLotto = Arrays.asList(1, 2, 7, 8, 9, 10);
        assertThat(LottoGame.match(userLotto, winningLotto, bonusNo)).isEqualTo(0);
    }
}

훨씬 간단하게 테스트를 진행할 수 있다..

이런식으로 메서드를 분리하다보면, 로직이 복잡한 부분을 public 메서드를 통해서 테스트 하다보니 테스트 코드도 명확하지 않고 어려워 지게 된다.

public class LottoGame {
    public static int match(List<Integer> userLotto, List<Integer> winningLotto, int bonusNo) {
        return rank(match(userLotto, winningLotto), userLotto.contains(bonusNo));
    }

    static int rank(int matchCount, boolean matchBonus) { <-- default 접근제어자 이용
        if (matchCount == 6) {
            return 1;
        }
        boolean matchBonus = userLotto.contains(bonusNo);
        if (matchCount == 5 && matchBonus) {
            return 2;
        }
        if (matchCount > 2) {
            return 6 - matchCount + 2;
        }
    }

    ...

}

이러한 테스트 로직이 복잡한 private 메서드를 테스트 하고 싶다면 default 접근제어자를 사용하여 같은 패키지에서만 사용할 수 있게 오픈하는건 괜찮다고 생각한다.

참고 : protected는 default보다 범위가 넓기 떄문에 default를 사용하는게 더 낫다.

근데 테스트를 위해 접근 제어자를 풀어 주는게 이해가 안갈 수 있다. 이때는 power mock이나 테스트 라이브러리르 사용하면 private 메서드도 호출할 수 있다. (자바의 reflection을 이용하면, 자바 인스턴스 변수에 접근할 수 도있고, 자바 메서드도 호출할 수 있다. 그리고 powermock와 같은 테스트 라이브러리를 사용하면 private 메서드를 호출할 수 있다.)

근데 powerMock을 사용하려면 사용방법을 또 익혀야하고, 이정도의 접근제어자를 오픈하는 게 큰 문제가 되지는 않겠다고 판단하여 보통은 풀어준다.

근데 이게 클래스 분리와 무슨 상관이있지? 라고 다시 생각할 수 있다. 이때 우리가 고민해야할 것은, 이 rank 메서드가 해당 LottoGame 객체에 있는게 맞는가? 를 고민해볼 필요가 있다.

private 메서드를 테스트해봐야 할것같은 생각이 든다면 접근제어자를 푼다거나 powerMock을 써봐야지 라고 생각하기 전에 이걸 새로운 클래스로 이동해야 하는게 아닌가? 생각해봐야 한다. (클래스 분리의 힌트가 된다!)

rank 로직을 보면 등수에 대한 숫자들이 많이 등장하는데, 그럼 이걸 Enum으로 만들 수없을까? 생각해야 한다.

(Enum은 클래스와 다를게 없다. JVM상에서 인스턴스를 하나만 가질 수 있도록 보장 해주는 것 외에는 다를게 없다!)

public enum Rank {
    FIRST(6, 2000000000),
    SECOND(5, 1500000),
    THIRD(5, 50000),
    FOURTH(4, 5000),
    FIFTH(3, 0),
    NO_MATCH(0, 0);

    private final int matchCount;
    private final int money;

    Rank(int matchCount, int money) {
        this.matchCount = matchCount;
        this.money = money;
    }

    public static Rank of(int matchCount, boolean hasBonusNumber) {
        return Arrays.stream(Rank.values())
                .filter(rank -> rank.isSameMatchCount(matchCount))
                .filter(rank -> !rank.equals(SECOND) || hasBonusNumber) // 2등 확인 로직
                .findFirst()
                .orElse(NO_MATCH);
    }

    private boolean isSameMatchCount(int matchCount) {
        return this.matchCount == matchCount;
    }

    public int sumMoney(int totalMoney) {
        return money + totalMoney;
    }

    public int getMatchCount() {
        return matchCount;
    }

    public int getMoney() {
        return money;
    }

        public boolean is꽝() {
                return this == NO_MATCH;
        }
}

이렇게 Enum을 만들게 되면서 Rank의 of 메서드가 등수를 판별하게 로직이 이동되고 public으로 공개가 된다.

public class RankTest {
    @Test
    public void 당첨_1등() {
        Rank rank = Rank.of(6, false);
        assertThat(rank).isEqualTo(Rank.FIRST);

        rank = Rank.of(6, true);
        assertThat(rank).isEqualTo(Rank.FIRST);
    }

    @Test
    public void 당첨_2등() {
        Rank rank = Rank.of(5, true);
        assertThat(rank).isEqualTo(Rank.SECOND);
    }

    @Test
    public void 당첨_3등() {
        Rank rank = Rank.of(5, false);
        assertThat(rank).isEqualTo(Rank.THIRD);
    }

    @Test
    public void 당첨_4_or_5등() {
        Rank rank = Rank.of(4, true);
        assertThat(rank).isEqualTo(Rank.FOURTH);
        rank = Rank.of(4, false);
        assertThat(rank).isEqualTo(Rank.FOURTH);

        rank = Rank.of(3, true);
        assertThat(rank).isEqualTo(Rank.FIFTH);
        rank = Rank.of(3, false);
        assertThat(rank).isEqualTo(Rank.FIFTH);
    }

        @Test
        public void 꽝() {
                Rank rank = Rank.NO_MATCH;
                assertThat(rank.is꽝()).isTrue();

    ...

}

테스트도 훨씬 간단해 진다.

지금까지 나온 클래스 분리를 위한 4가지 정량적인 규칙을 앞으로 로또 미션, 사다리 미션하면서 찾고 이 원칙을 지키면서 리팩토링하려고 노력하자.

※ Immutable (불변) vs mutable (가변)

안정적인 프로그래밍, 유지보수 측면에서 불변하도록 객체를 설계하는게 좋은 습관중 하나이다.

그림 우리가 만들었떤 position 객체를 immutable로 바꾸려면 어떻게 해야할까?

public class PositionTest {
    @Test
    void mutable_vs_immutable() {
        Position position = new Position(3);
        assertThat(position.getPosition()).isEqualTo(3);
        Position newP = position.increase();
        assertThat(position.getPosition()).isEqualTo(3); <-- immutable 하므로 값이 바뀔 수 없음이 보장되야함.
        assertThat(newP.getPosition()).isEqualTo(4);
    }

    ...

}
public class Position {
    private final int position; // immutable하게 final 키워드 사용

    public Position(String position) {
        this(Integer.parseInt(position); 
    }

    public Position(int position) {
        if (position < 0) { // 생성자에서 유효성 처리!
            throw new IllegalArgumentException("이동거리는 음수가 될 수 없습니다.");
        }
        this.position = position; 
    }

    public int getPosition() {
        return this.position;
    }

    public void move(int randomNo) {
        if(randomNo >= FORWARD_NUM) 
                this.position++;
        }
    }

    public boolean isMaxPosition(int maxPosition) {
        return this.position == maxPosition; 
    }

    public int maxPosition(int maxPosition) {
        if (maxPosition < this.posititon) { 
                return this.position;
        } 
        return maxPosition;
    }

    public Position increase() { // 새로운 인스턴스를 만들어서 반환해야한다!
        return new Position(position + 1);
    }

    ...
}

이렇게 만들면 Position 객체의 상태값이 바뀌지 않음이 보장되므로 다른사람이 객체의 상태를 바꿀 수 있는 side effect가 생김을 막아준다.

이렇게 되면 Car 객체에 변경해야될 부분이 생긴다.

public class Car {

    private final Position position;
    ...

    public void move(int randomNo) {
        if(randomNo >= FORWARD_NUM) 
                this.position = position.increase();
        }
    }
}

차를 움직일 때마다 position을 증가시키고, 반환되는 새로운 인스턴스를 따로 저장해줘야 기존 테스트에 성공한다!

근데 immutable은 이렇게 매번 인스턴스를 만들고 GC되므로 많은 비용이 발생한다.

그냥 primitive를 사용했을 때는 이미 메모리가 할당되어 있기 때문에 새로운 인스턴스를 반환할 걱정이 없었다. (이미 메모리에 캐싱하고 있다. 재사용되고 있음. 그래서 문제가 없었다.)

로또 프로그램을 생각해보면, 로또 프로그램이 대박나서 1만명이 동시에 접속하여 1명이 5장의 로또를 산다고 생각해보자.

LottoNumber 인스턴스를 6개 생성해야 로또 1장이므로, 한 사람당 LottoNumber인스턴스를 30개 생성해야 5장의 로또를 사게 되는 거다.

그럼 1만명이 동시에 로또를 5장 산다면 30 * 10000 = 30만개의 인스턴스가 동시에 만들어진다..

immutable하게 객체를 만들면 이런 이슈가 생긴다. 이런 이슈때문에 그냥 가변 객체로 만들게 되고 불변 객체 설계를 포기한다.

이런 이슈를 고치기 위해 고민해야한다. immutable객체를 미리 캐싱하는 것이다.

로또넘버는 1 ~ 45개까지 필요하므로 미리 객체를 생성하여 Map<Integer, LottoNumber>로 캐싱해둔다면 위처럼 30만개의 인스턴스가 생길 일이 없다..!

성능이 떨어질 것이라는 이유로 바로 mutable한 객체설계로 가기보다는 해결할 수있는 방법들을 고민하여 최대한 immutable하게 가면 좋다! 하다하다 안되면 다시 primitive하게 인스턴스 필드를 가져도 된다.

하지만 이렇게 인스턴스가 많아져서 이슈가 생기는 경우는 거의 극소수이다. (대부분 불변 객체로 만들어도 인스턴스가 생각보다 많이 안 만들어 진다..!) 대부분의 성능이슈는 외부 api, 네트워크 io, 데이터베이스 connection에서 발생한다!

그렇기 때문에 너무 성능에 집착하지말고 좋은 설계를 위해 고민하자!

 

느낀점

매 강의마다 생각지도 못한 포인트들을 하나씩 얻어갈 수 있어서 좋았다.

이번 강의에서는 클래스 분리에 대한 정량적인 방법들을 한번 구체적으로 잡고 넘어가서 지금까지 미션을 진행하면서 내가 수행 했던 내용들이 자연스럽게 녹아 들어있었구나! 싶었고 다음 번에는 의식해서 할 수 있을 것이라 생각하니 기대가 됐다.

 

또, immutable, mutable한 객체의 설계에 대해서 또 하나의 인사이트를 얻을 수 있었다!

 

강의와 별개로 로또 미션을 진행하면서 리뷰어분과 함께 굉장히 디테일한 클린코드 및 클래스 설계 과정을 경험할 수 있었다.

이번 기회에 엘레강트 오브젝트라는 책을 한번 읽어보면 좋겠다는 생각도 들었다!!

수강한지 절반이 지나가는 시점에서 남은 미션도 배운내용을 바탕으로 잘 설계할 수 있으리라는 자신감이 생겼다!

 

로또 - 미션 피드백 링크 :

step 1 : https://github.com/next-step/java-lotto/pull/2334

step 2 : https://github.com/next-step/java-lotto/pull/2382

step 3 : https://github.com/next-step/java-lotto/pull/2405

 

'Next-Step > TDD, Clean Code with Java 14기' 카테고리의 다른 글

2차 강의 - TDD로 자동차 경주게임 구현  (0) 2022.04.23
1차 강의 - TDD  (0) 2022.04.13

댓글

Designed by JB FACTORY