우테코 프리코스 3주차 후기

임현규·2022년 11월 17일
0

이번 3주차 미션의 주제는 객체 분리와 단위 테스트였다. 나는 그 두 개 중 객체 분리에 어려움을 겪었다. 그 이유는 어떤 기준으로 객체를 분리해야 할지 분리한다면 어떤 특징을 가져야 할지, 요구사항은 어떻게 작성하는 게 객체 분리에 유리할지 감이 잘 잡히지 않았기 때문이다. 그래서 에릭 에반스(Eric Evans)의 도메인 기반 디자인 관련 글을 보았고 이번 3주차 미션에 완전히 엄격하게 DDD 방식을 활용하지는 않았지만, 간단히 이해한 부분만 적용하려고 노력했다. 그래서 이번 글은 어떻게 도메인 기준으로 객체를 분리했는지 회고해 볼 생각이다.

에릭 에반스 관련 글 정리한 블로그

도메인을 쪼개보고 서로 간의 관계 생각하다

3주차 미션 - 로또
이번 3주차 미션은 기능 목록을 뽑아내기 위해 기존과 다르게 고민해봤다.

  1. 입출력 사항에서 큰 목록을 뽑아본다.
  2. 큰 목록에서 상태에 대한 예외사항을 기준으로 VO로 사용할 도메인 객체를 분리한다.
  3. 예외 상황이 중복되거나 연관이 있다면, 도메인간 관계를 생각해본다.

우선, 3주차 미션에서 요청하는 부분은 2가지로 분리해 볼 수 있었다.
1. 로또 구매 -> 구매 금액 기준으로 로또를 랜덤하게 생성.
2. 당첨번호와 보너스 번호 입력 -> 당첨 정보와 수익률 출력.

그리고 이 2가지에서 필요한 객체를 분리하면 다음과 같이 분리 할 수 있다.

금액, 로또, 당첨 번호, 보너스 번호, 당첨 정보

분리해서 끝이 아니다. 이들 간에 예외 사항이 중복되는 부분이 있었다.
당첨 번호, 로또, 당첨 번호, 보너스 번호 -> 1 ~ 45 사이의 숫자만 입력 가능
4개의 객체가 중복된 예외 사항을 가지고 있다. 그래서 이를 로또 번호(LottoNumber)로 뽑아냈다. 그리고 로또의 경우 로또 번호 객체를 포함한다면 1 ~ 45에 대한 예외 처리를 다시 할 필요가 없어진다. 그래서 최종 뽑아낸 객체는 다음과 같다.

금액, 로또 번호, 로또(로또 번호 포함), 당첨정보

굉장히 심플해졌다.

객체 간의 관계 특성에 따라 구현하다

이번 미션에는 Lotto 클래스가 일급 컬렉션으로 주어지고 일급 컬렉션이 훼손되지 않는 범위에서 수정이 가능하도록 제약사항이 주어졌다.

public class Lotto {
    private final List<Integer> numbers;

    public Lotto(List<Integer> numbers) {
        validate(numbers);
        this.numbers = numbers;
    }

    private void validate(List<Integer> numbers) {
        if (numbers.size() != 6) {
            throw new IllegalArgumentException();
        }
    }

    // TODO: 추가 기능 구현
}

나는 이 로또의 validate를 좀 더 객체지향적으로 처리하길 원했다. 그래서 로또 번호와 로또의 연관관계로부터 다음과 같이 구현했다.

public class Lotto implements Iterable<LottoNumber> {

    public static final int LOTTO_LENGTH = 6;
    private static final String ERROR_MATCH_LENGTH = "로또는 6개의 번호만 입력 가능합니다.";
    private static final String ERROR_DUPLICATE_VALUE = "로또에는 중복된 번호를 입력할 수 없습니다.";
    private final List<LottoNumber> numbers;

    public Lotto(List<Integer> numbers) {
        List<LottoNumber> lottoNumbers = generateLottoNumber(numbers);
        validateNumbersLength(lottoNumbers);
        validateDuplicateValue(lottoNumbers);
        this.numbers = lottoNumbers;
    }

    private List<LottoNumber> generateLottoNumber(List<Integer> numbers) {
        return numbers.stream()
                .map(LottoNumber::getInstance)
                .sorted()
                .collect(Collectors.toList());
    }

    private void validateNumbersLength(List<LottoNumber> numbers) {
    //....
    }

    private void validateDuplicateValue(List<LottoNumber> lottoNumbers) {
    //...
    }

    public boolean contains(LottoNumber lottoNumber) {
        return numbers.contains(lottoNumber);
    }

Integer는 너무 범위가 넓은 타입이기 때문에 비즈니스에 맞게 예외 사항을 적용해주어야 한다. 그러나 LottoNumber에서 범위를 처리해주고 이를 구성요소로 활용한다면 로또 숫자 범위에 대한 예외 처리를 로또 번호 객체에 위임해 줄 수 있다. 그래서 Lotto 객체에서는 Lotto만의 로직만 구성해도 충분하게 된다. 다만 유의점은 로또 번호 객체를 정렬하기 위해서 Comparable를 로또 번호 객체에서 구현해주어야 한다.

도메인 특성에 적합한 패턴 또는 enum을 적용해보다

당첨(Rank) 객체에 enum을 적용하다

당첨 객체는 여러 정보를 포함하고 정보가 목록 형식으로 되어 있다. 예를 들면 1등의 경우
1등 : 6개를 맞추고 보너스 번호는 상관없음, 당첨 금액은 2,000,000,000원으로 되어 있다. 그리고 이런 정보가 1등, 2등, 3등…. 이렇게 등수별로 나뉘어 있다. 이런 경우 Enum이 효과적이라 생각했다. 이 부분에 대한 관점은 우아한 형제 기술 블로그 를 참고 했다.

기술 블로그에서 제공하는 enum의 관점 중 내가 미션에 적용한 관점은 2가지다. 하나는 enum은 여러 정보를 모아두기 효과적이다. 두 번 째는 enum은 관리 주체를 DB에서 애플리케이션으로 옮길 수 있다. 첫 번째는 당연히 모두 공감하는 내용이고, 두 번째 관점의 경우 우리는 DB를 사용하지 않는데 미션과 무슨 상관이냐고 질문할 수 있다. 내가 초점을 맞춘 부분은 옮긴다…. 라는 부분이 아니라 상태와 행위를 한 곳에 관리함에 있다. 그리고 enum은 목록 형태로 객체를 구성할 수 있고 굳이 생성자를 통해 인스턴스를 생성하지 않아도 런타임시 알아서 생성된다. 마치 싱글턴 패턴처럼 사용된다. 의심스럽다면 enum 내부에 public 생성자를 선언해보면 된다. 안될 것이다.

매번 인스턴스를 생성하지 않으므로 메모리 측면에서도 효과적이라 할 수 있다. 그 뿐만 아니다. 마치 리플렉션처럼 열거형 객체에 대해 순회하고 매칭해서 가져 올 수 있다. values(), valueOf()이다. 이를 잘 활용하면 알아서 당첨 정보와 맞는 enum 객체를 가져올 수 있는 메서드를 짤 수 있다.


public enum Rank {
    PLACE_1(6, false, new Money(2_000_000_000)),
    PLACE_2(5, true, new Money(30_000_000)),
    PLACE_3(5, false, new Money(1_500_000)),
    PLACE_4(4, false, new Money(50_000)),
    PLACE_5(3, false, new Money(5_000));

    private final int commonMatch;
    private final boolean bonusMatch;
    private final Money money;

    Rank(int commonMatch, boolean bonusMatch, Money money) {
        this.commonMatch = commonMatch;
        this.bonusMatch = bonusMatch;
        this.money = money;
    }

    public static Rank matchOf(int commonMatch, boolean bonusMatch) {
        for (Rank rank : Rank.values()) {
            if (rank.isEqualElement(commonMatch, bonusMatch)) {
                return rank;
            }
        }
        return null;
    }

    private boolean isEqualElement(int commonMatch, boolean bonusMatch) {
        if (commonMatch == 5) {
            return this.commonMatch == commonMatch && this.bonusMatch == bonusMatch;
        }
        return this.commonMatch == commonMatch;
    }
	// .......
}

약간 그냥 좋다는 식으로만 쓴 느낌이 없잖아 있는데.. 결론은 enum을 잘 활용하면 좋다...

로또 번호 객체에 싱글턴 패턴을 적용하다.

로또 번호도 처음엔 enum으로 구성을 시도했다. 1 ~ 45가 그렇게 많은 수는 아닌데 타이핑해서 관리하기엔 또 많은 수이다. 이 부분도 굳이 인스턴스를 생성할 필요 없이 미리 캐싱해서 쓰고 싶었고, 고민한 끝에 for문 활용할 수 있는 싱글턴 패턴으로 클래스를 설계했다.

public class LottoNumber implements Comparable<LottoNumber> {

    public static final int MIN_VALUE = 1;
    public static final int MAX_VALUE = 45;
    private static final String ERROR_NUMERIC_RANGE = String.format("로또 번호는 %d 부터 %d 숫자까지 가능합니다.",
            MIN_VALUE, MAX_VALUE);
    private static final List<LottoNumber> cache = new ArrayList<>();

    static {
        for (int i = MIN_VALUE; i <= MAX_VALUE; ++i) {
            cache.add(new LottoNumber(i));
        }
    }

    private final int value;

    private LottoNumber(int value) {
        validateValueNumericRange(value);
        this.value = value;
    }

    public static LottoNumber getInstance(int value) {
        validateValueNumericRange(value);
        int cacheIndex = value - 1;
        return cache.get(cacheIndex);
    }

    private static void validateValueNumericRange(int value) {
        if (value < MIN_VALUE || value > MAX_VALUE) {
            throw new IllegalArgumentException(ERROR_NUMERIC_RANGE);
        }
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        LottoNumber that = (LottoNumber) o;
        return value == that.value;
    }

    @Override
    public int hashCode() {
        return Objects.hash(value);
    }

    @Override
    public int compareTo(LottoNumber o) {
        return this.value - o.value;
    }

    @Override
    public String toString() {
        return String.valueOf(value);
    }
}

이번 미션에서 로또 번호는 필수로 쓰여 static 블록에서 Eager initialization을 했는데 만약 로또 번호를 안 쓸 수도 있지 않느냐... 메모리 낭비다.. 라는 느낌이 든다면 Lazy initialization을 고려해볼 수 있다.
Lazy Initialization에 대한 예시는 우테코 API의 Console 클래스를 참고하면 된다.

    public static String readLine() {
        return getInstance().nextLine();
    }

    private static Scanner getInstance() {
        if (Objects.isNull(scanner) || isClosed()) {
            scanner = new Scanner(System.in);
        }
        return scanner;
    }

객체의 책임을 명확히 분리한다

이번 주차의 미션에 다음과 같은 조건이 있었다. UI와 비즈니스 로직을 분리한다. 분리한다. 이 말 뜻은 무엇일까?? 그냥 클래스만 나누면 분리일까?? 나는 이 해법을 MVC 패턴에 대해서 찾아보고 공부하면서 어떤 의미인지 이해했다.
MVC 패턴의 특징은 Model, View, Controller로 나뉜다. 그리고 여기서 핵심은 각자 역할이 나뉘고 각 컴포넌트들은 서로가 하는 일을 몰라야 한다는 것이다.

예는 내가 처음 코드를 짯을 때 짠 코드이다.

public class OutputView extends View {

	// 생략 .....

    public void responseStatistic(Map<Rank, Integer> frequency, double benefitRate) {
        print(STATISTICS);
        print(THREE_DOT_LINE);
        for (Entry<Rank, Integer> rankEntry : frequency.entrySet()) {
            printRankInfo(rankEntry);
        }
        print(String.format(BENEFIT_RATE, benefitRate * 100));
    }
}

위 코드는 OutputView에서 분석 결과를 알려준다. 근데 처음에 service에서 백분율 처리를 안해줘서 view에다가 했다. View는 데이터를 받아서 보여주는 역할에 충실해야하는데 데이터를 받아서 변형시키고 출력했다. Service layer가 수행해야 할 일을 View가 무의식적으로 침범한 것이다. 이런 부분을 이번 주차 미션에서 최대한 안하도록 노력했다.
MVC가 가져야 하는 책임은 다음과 같다.
Model -> 비즈니스 로직을 처리
View -> 데이터 출력을 담당
Controller -> view와 model의 연결고리 역할
이 셋은 서로의 역할을 몰라야한다.

느낀점

이번 주차는 객체 분리와 메시지 전달을 효율적으로 할 방법에 대해서 많이 고민했던 것 같다. 머리털과 지식을 좀 바꾼 느낌이지만 객체 분리가 무엇인지 조금은 얻어가게 된 계기가 된 것 아닌가 싶다. 4주차 미션 너무 기대된다..

성장하자....!!

profile
엘 프사이 콩그루

1개의 댓글

comment-user-thumbnail
2022년 11월 20일

잘보고갑니다!

답글 달기