우아한테크코스 5기 프리코스 3주차 회고

yoondgu·2022년 11월 16일
0
post-thumbnail

미션 저장소 가기

🔎 기준 만들기

지난 주 미션에서 커밋 원칙을 먼저 정리한 뒤 진행한 것이 큰 도움이 되었습니다. 그래서 이번에는 기능 단위와 함수 분리에 대한 제 자신만의 기준을 세워보고자 했습니다. 기준을 좀 더 명확히 세워두면 이를 토대로 이번주 미션의 주안점인 클래스 분리, 단위 테스트 작성을 더 수월하게 진행할 수 있을 것이라 생각했기 때문입니다.

지난 주에는 프로그램의 실행 절차에 따라 기능 목록을 작성했더니, 독립적으로 구현하고 테스트를 진행하기가 어려웠던 기억이 있습니다. 그래서 이번에는 이를 통해 최대한 구현 절차보다는 그 자체로 독립적인 기능 목록을 짜고 구현하려고 노력했습니다.

기능 목록 뿐만 아니라 앞서 말한 기능 단위, 함수 분리에 대한 기준 또한 docs/README.md 파일에 정리했습니다. 이 문서를 미션 관련 사항들을 설명하는 명세서로 구성하였습니다.


구현 과정에서 쉽게 변경할 수 있도록, 기능 목록에는 설계적인 내용을 모두 배제하였습니다. 그 대신 설계 방식에 대한 파악을 위해 별도로 클래스 다이어그램을 작성해두고 구현을 시작했습니다. 이 다이어그램 역시 살아있는 문서의 일종으로, 이전과의 변경사항을 비교할 수 있는 도구로 사용하려고 했습니다.
실제로 처음과 PR 시점에 만든 두 다이어그램을 보면 비교해 보면 더 많은 클래스들이 각자의 역할을 하는 것을 확인할 수 있었습니다. 그러나 단순 문서에 비해 내용 수정에 드는 노력이 크기 때문에 더 간단한 방식을 사용하면 좋겠다는 생각이 듭니다.

초기 구현 시작 전 작성해본 다이어그램

PR 제출 시점의 클래스 구조를 반영한 다이어그램

🚀 구현 과정에서 느낀 점

1. 의인화시키기

공통 피드백으로 제공받은 제이슨님의 자바 기초 강의를 듣던 중 “객체를 의인화한다”는 표현이 인상깊었습니다. 기능을 가지고 있는 클래스를 객체화하고, 필요한 기능을 각 객체가 수행하게 한다는 것이었습니다. 그동안 미션을 하면서 어떤 기능을 “어디서” 수행하게 할지 정하는 데 어려움을 많이 겪던 것이 떠올랐습니다.
그래서 이번에는 “의인화”의 관점에서 각 클래스가 자기 자신에 대한 기능을 수행하도록 했습니다. 그렇게 했더니 “수행 주체”에 대한 어려움은 훨씬 줄일 수 있었던 것 같습니다.
그러나 여전히 어려웠던 부분 중 하나는 “구입 금액”을 발행 개수 계산 시에도 사용하고, 수익률 계산 시에도 사용하는 부분이었습니다. 로또 당첨 결과를 계산하는 클래스에서 수익률을 계산하기 위해 로또 구입 금액을 다시 계산하거나, 별도로 전달받는다면 불필요하게 기능이 중복되기 때문이었습니다. 그런데 이 역시 “구입 금액”을 이용한 계산을 수행하는 클래스를 별도로 정의하여 해결할 수 있었습니다.

public class PaymentCalculator {
    private final int payment;

    public PaymentCalculator(int payment) throws IllegalArgumentException {
        MoneyValidator.validatePayment(payment);
        this.payment = payment;
    }

    public double calculateEarningRatio(int totalPrize) throws IllegalArgumentException {
        MoneyValidator.validateMoneyValue(totalPrize);
        return (totalPrize * (100.0)) / payment;
    }

    public int calculateAmountByLottoPrice() {
        return payment / (LottoRule.PRICE.getValue());
    }
}

2. 도메인 로직의 분리와 예외 처리

도메인 로직에 대한 단위 테스트를 구현하는 요구사항을 보고, “도메인 로직”이란 무엇인가 검색도 해보고 고민해보았습니다. 그 결과 도메인 로직은 “현실 세계의 의사결정”을 다루는 영역의 로직이라고 판단했습니다. 그리고 UI 로직은 “지금 이 자바 프로그램을 통해 사용자와 상호작용을 하기 위한 로직”이라고 판단했습니다. 그렇기 때문에 처음부터 이 둘을 분리해서 기능 목록을 작성하고 구현, 단위테스트를 진행했습니다.
따라서 현실 세계와 무관한 입출력 시 예외(잘못된 형식, 잘못된 데이터타입)는 UI 로직에서 먼저 처리하고, 도메인 로직에서는 각 기능들에 대한 예외사항만을 테스트하고자 했습니다.
단위 테스트 대상으로는 메소드 공개(public) 여부를 기준으로 삼았는데, 어떤 종류의 인자값이 들어올지 완전히 예측할 수 없는 상태이기 때문입니다. 지난 주 미션에서는 공개 메소드에 대하여 이런 위험에 대한 예외를 다 고려해야 할까 고민하다가 넘어간 적이 있습니다. 그런데 이 의미는 현실 세계에서도 완전히 통한다는 걸 알게 되었습니다. 예를 들어 앞서 보여드린 PaymentCalculator가 구입금액으로 900원을 받을지 1200원을 받을지 모르기 때문입니다. (반면, 구입 금액으로 “가나다”를 받을 일은 없으니 이 예외는 완전히 UI 로직만의 책임일 것입니다.)

또 이러한 예외처리는 해당 값을 전달받는 주체에서 직접 검증을 수행하도록 하였습니다. 그러나 예외 발생 지점을 모아서 관리하기 위해서, 해당 주체가 특정 유형의 Validator 클래스가 가진 예외 발생 메소드를 호출하도록 만들었습니다.

package lotto.domain.validator;

import lotto.domain.constants.ErrorMessage;
import lotto.domain.constants.LottoRule;

public class MoneyValidator {
    private MoneyValidator() { }

    public static void validatePayment(int payment) {
        validateMoneyValue(payment);
        if (isLessThanLottoPrice(payment)) {
            throw new IllegalArgumentException(ErrorMessage.PAYMENT_INSUFFICIENT.getValue());
        }
        if (hasRemainderForLottoPrice(payment)) {
            throw new IllegalArgumentException(ErrorMessage.PAYMENT_HAS_REMAINDER.getValue());
        }
    }

    public static void validateMoneyValue(int money) {
        if (isNegative(money)) {
            throw new IllegalArgumentException(ErrorMessage.MONEY_NEGATIVE.getValue());
        }
    }

    private static boolean isNegative(int money) {
        return money < 0;
    }

    private static boolean isLessThanLottoPrice(int money) {
        return money < LottoRule.PRICE.getValue();
    }

    private static boolean hasRemainderForLottoPrice(int money) {
        return (money % (LottoRule.PRICE.getValue())) != 0;
    }
}
package lotto.view.validator;

import lotto.view.constants.ErrorMessage;

public class IOValidator {
    private static final String REGEX_INTEGER = "^[^0]\\d*";

    public IOValidator() { }

    public static void validateFormattedStringToInteger(String value, String delimiter) {
        String delimiterRemoved = value.replaceAll(delimiter, "");
        if (value.length() == delimiterRemoved.length()) {
            throw new IllegalArgumentException(ErrorMessage.INPUT_ILLEGAL_FORMAT.getValue());
        }
        if (!delimiterRemoved.matches(REGEX_INTEGER)) {
            throw new IllegalArgumentException(ErrorMessage.INPUT_CONTAINS_NOT_INTEGER.getValue());
        }
        if (value.contains(delimiter + "0")) {
            throw new IllegalArgumentException(ErrorMessage.INPUT_START_WITH_ZERO.getValue());
        }
    }

    public static void validateStringToInteger(String value) {
        if (!value.matches(REGEX_INTEGER)) {
            throw new IllegalArgumentException(ErrorMessage.INPUT_NOT_INTEGER.getValue());
        }
    }

    public static void validateMessage(Object message) {
        if (message == null) {
            throw new NullPointerException(ErrorMessage.OUTPUT_NULL_POINTER.getValue());
        }
    }
}

3. 테스트를 작성하는 이유

테스트 실행을 통해 오류를 발견하고 수정하는 경험은 지난 주부터 여러 번 할 수 있었고, 테스트의 기본적인 중요성을 깨닫는 계기가 되었습니다. 반면 2주차 공통 피드백에서 알게 된, 학습 도구로의 활용 방법에 대해서는 사실 처음에는 와 닿지 않았습니다. 미션 진행 과정과는 별개로 학습의 시간을 가져야 하는 일이라고 생각했습니다.
그러나 미션을 진행하는 중에도 테스트 작성을 통해 학습할 수 있다는 점이 학습 도구로서의 아주 강력한 장점이라는 것을 깨닫게 된 계기가 있습니다.
막 공부하게 된 Enum 클래스를 사용해 당첨 등수 정보를 제공하는 LottoRank 클래스를 구현했습니다. 그런데 등수 별 당첨 개수를 Map에 저장하려고 하니, LottoRank 객체를 key값으로 저장할 때마다 같은 값으로 판정되는지 확실하게 알지 못했습니다. 만약 같은 값으로 판정된다면 key값에 굳이 Integer 타입의 1,2,3,… 과 같은 등수의 값을 꺼내서 저장해줄 필요가 없는 상황이었습니다.
그런데 이 때 “그냥 테스트로 한 번 확인해보자”는 생각이 들었고, 검색을 하는 대신 직접 아래와 같은 코드를 작성하고 실행해보았습니다. 검색하고 다른 사람이 정리한 정보를 찾는 일 또한 학습 과정이지만, 이런 경험으로 습득하게 된 지식은 더욱 쉽게 잊혀지지 않겠다는 생각이 들었습니다. 경험을 통한 학습은 보통 그만큼 시간이 많이 투자되는 일인 경우가 많습니다. 하지만 프로그래밍을 진행하고 있는 도중에 필요에 따라 빠르게 학습할 수 있다는 점에서 학습 도구로서의 강력한 장점을 가졌다고 생각합니다.

    @Test
    void winningRankEquals() {
        Map<WinningRank, Integer> test = new HashMap<>();
        test.put(WinningRank.RANK_1, 1);
        int count = test.getOrDefault(WinningRank.RANK_1, 0) + 1;
        test.put(WinningRank.RANK_1, count);
        assertThat(test.get(WinningRank.RANK_1)).isEqualTo(2);
    }

✅ 4주차 미션에서 보완할 점

  • 현실세계를 생각하며 예외사항을 충분히 고려할 것
    이번에 제출 후에야 금액의 크기에 대한 예외사항을 체크하지 못한 것이 생각나 너무 아쉽다. "다 했다"는 생각을 경계하자.
  • 처음부터 기능 목록을 완벽하게 작성하려고 하지 말 것
    살아 있는 문서를 작성한다고 생각하자. 기능 목록을 작성할 때 충분히 고민하는 것도 좋지만 한 번 써놓고 수정하지 않으려고 하는 버릇을 없애자.
  • 적절한 API를 활용할 것
    이번에는 처음으로 Stream을, 그리고 Enum 클래스도 지난 미션보다 많이 활용해보았다. 하지만 무작정 쓰기보다는 언제 쓰는 것이 좋을지 잘 판단해야겠다.
  • 정적 메소드를 가진 클래스, 싱글톤 패턴 중 무엇을 언제 써야 할지 계속 고민을 하는데 명확한 답을 못내리고 있다. 이 부분을 확실히 짚고 넘어가야겠다.
  • MVC 패턴을 정확히 공부해보기
    MVC 패턴을 비롯해서 "안다고 착각"하고 써온 것들이 너무 많은 것 같다. 지금은 MVC패턴에서 사용하는 용어만 클래스명에 붙여놓은 것이 아닌가 싶다. 미션에서 적용하는 것과 별개로 좀 더 정확히 공부해봐야겠다.
  • 다 각설하고 여전히... 요구사항을 지켰는지 꼼꼼하게 확인하자.
    방금 소수점 뒤의 숫자가 0이더라도 소수점 뒤 첫째자리가 표현되도록 구현하지 못했다는 사실을 깨닫고 와서 덧붙인다^_^ㅠㅠ
    요구사항의 내용은 알고 있었는데, 작성해둔 테스트케이스가 다 통과했으니 됐다는 생각으로 확인을 자세히 안한 것 같다. 반성 ... 완벽한 테스트는 불가능하다는 것 잊지말기

1개의 댓글

comment-user-thumbnail
2022년 11월 16일

잘보고갑니다!

답글 달기