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

dooboocookie·2022년 11월 16일
4

우테코-프리코스

목록 보기
3/4
post-thumbnail

과제

기능 요구 사항

  1. 로또 게임 기능을 구현
  2. 로또 번호
    • 1~45의 숫자
    • 중복되지 않는 6개의 숫자로 1세트의 로또
  3. 당첨 번호
    • 1세트의 로또 번호와
    • 1개의 보너스 번호
  4. 당첨
    • 1등: 6개 번호 일치 / 2,000,000,000원
    • 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원
    • 3등: 5개 번호 일치 / 1,500,000원
    • 4등: 4개 번호 일치 / 50,000원
    • 5등: 3개 번호 일치 / 5,000원
  5. 게임 진행
    1. 사용자로또를 구매하고 싶은 만큼 금액을 입력
    2. 1000원 당 1장씩 발행 (나누어 떨어지지 않으면 예외)
    3. 발급한 로또 번호를 출력
    4. 당첨번호와 보너스번호를 입력 받는다. (잘못 입력 시 예외)
    5. 당첨번호와 로또번호를 비교하여 당첨 내역과 수익률을 출력
    6. 게임 종료
    7. 예외 발생 시, IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 종료

프로그래밍 요구 사항

사실 우테코 프리코스 기능 요구사항은 어떤 코드를 짜기 위한 최소한의 목표에 가까운 것 같고 주로 해결할 문제는 프로그래밍 요구사항에 있는 것 같다.

  1. 지난 주 과제(링크)에 추가된 요구 사항이 있다.
  2. 함수의 길이가 15라인 이상 넘어가지 않도록 구 (한 가지 일만 하도록)
  3. else 예약어, switch문을 사용하지 않는다
    • 이는 1주차부터 지켜온 클린코드 조건이었다.
  4. Java Enum을 사용
    • 저번 주에도 볼, 스트라이크, 낫싱에 대해서 Enum을 사용하였다.
    • 상수 값에 가깝게 사용하였었는데, 이번엔 좀 더 잘 사용할 수 있도록 해보자..
  5. 도메인 로직에 단위 테스트를 구현

클래스 나누기

MVC 패턴

  • 웹 프로젝트를 하다보면 MVC패턴에 대해 많이 익숙할 수 밖에 없다.
  • MVC 패턴이란
    • 출력을 담당하는 와 비즈니스 로직을 담당하는 모델의 관심사를 분리 시키고
    • 그를 컨트롤러가 적절하게 모델의 처리결과를 뷰에 전달하여 클라이언트에게 내용을 응답하는 패턴이다.

웹프로젝트와의 차이점

  • 웹프로젝트를 진행할 때
    • 뷰페이지를 JSP, Thymeleaf와 같이 SSR을 통해서 HTML 태그들로 렌더링하여 보여줬고
    • 모델로서는 핸들러서비스레파지토리(DAO)를 통하여 DB에 접근하며 필요한 로직을 처리
    • 데이터의 전달은 파라미터, 서블릿 리퀘스트, 세션의 어트리뷰트로 이동 시킬 수 있었다.
    • 프론트 컨트롤러에서 적절한 핸들러를 호출해서 결과를 적절한 뷰로 데이터를 넘겨준다.
    • 이 과정이 요청과 응답이라는 단위로 나눌 수 있었다.
  • 해당 과제를 진행할 때
    • 모델 영역은 비교적 명확했다.
      • 위에서 기능 요구 분석을 하면서, 특별한 기능을하는 대상이나 비즈니에서 관리가 필요한 값을 갖는 대상을 모델 클래스로 만들기로 했다.
    • 를 어떻게 구현해야되는지 헷갈렸다.
      • 일단 콘솔로 출력을 하는 것이므로
      • 특정 데이터(로직의 결과)를 받아다서 System.out.print()하는 과정을 메소드로 묶어서 뷰로 정했다.
    • 컨트롤러가 어떤 형태로 존재해야되는지가 제일 와닿지가 않았다.
      • 웹 프로젝트에서는 특정 URL에 맞는 특정 핸들러를 프론트 컨트롤러가 호출을 하게된다.
      • 현재 과제에서는 특정 핸들러나 모델을 호출하게 되는 이벤트가 어떻게 되어야할지 모르겠어서,
      • LottoHadler라는 클래스를 만들어서 그안에 입력모델의 로직 처리View 호출 단위로 메소드를 나눴다.

클래스 구조

디렉토리 구조

├── controller
│   └── LottoHandler
├── model
│   ├── GameMoney.java
│   ├── Judgement.java
│   ├── Lotto.java
│   ├── Rank.java
│   ├── RankCounter.java
│   ├── User.java
│   └── WinningNumbers.java
├── utils
│   ├── Errors.java
│   ├── Input.java
│   ├── LottoGenerator.java
│   └── Rules.java
├── view
│   └── Print.java
└── Application.java
  • 위와 같이 controller, model, view 역할을 하는 클래스들을 각각 모았고, 그 외에 모델은 아니고 독립적인 클래스를 util 패키지에 모았다.

모델

클래스 내용
Lotto 로또 번호를 가지고 있는 클래스
WinningNumbers 당첨번호(Lotto)와 보너스 번호를 가지고 있는 클래스
GameMoney 사용자가 투입할 금액을 가지고 있는 클래스
Rank 로또 순위에 대한 정보를 갖고 있는 enum
RankCounter 순위에 대한 집계를 하는 클래스
User 로또 게임을 진행하는 사용자 정보(GameMoney, List< Lotto>)를 가지고 있는 클래스
Judgement 사용자(User)와 당첨번호(WinningNumbers)를 가지고 결과를 판단하는 클래스
  • 이제 이 안에서 View와 관련된 영역을 분리하고 해당 클래스의 관련된 로직만 짜는 것이다.
  • 예를 들어 Lotto 클래스를 보겠다.
public class Lotto {
    private final List<Integer> numbers;

    public Lotto(List<Integer> numbers) throws IllegalArgumentException {
        validateLength(numbers);
        validatedLottoRange(numbers);
        validateDuplicate(numbers);
        this.numbers = numbers;
    }

    private void validateLength(List<Integer> numbers) throws IllegalArgumentException {
        if (numbers.size() != Rules.LOTTO_SIZE) {
            throw new IllegalArgumentException(Errors.ERROR_LOTTO_NUMBER_SIZE.getValue());
        }
    }

    private void validatedLottoRange(List<Integer> numbers) throws IllegalArgumentException {
        for (Integer number : numbers) {
            validateNumberRange(number);
        }
    }

    private void validateNumberRange(int number) throws IllegalArgumentException {
        if ((number < Rules.LOTTO_MIN_NUMBER) || (number > Rules.LOTTO_MAX_NUMBER)) {
            throw new IllegalArgumentException(Errors.ERROR_LOTTO_NUMBER_RANGE.getValue());
        }
    }

    private void validateDuplicate(List<Integer> numbers) throws IllegalArgumentException {
        HashSet<Integer> checkDuplicate = new HashSet<>(numbers);
        if (checkDuplicate.size() != Rules.LOTTO_SIZE) {
            throw new IllegalArgumentException(Errors.ERROR_LOTTO_NUMBER_DUPLICATE.getValue());
        }
    }

    public int findLottoNumber(int index) {
        return numbers.get(index);
    }

    public boolean containNumber(int number) {
        return numbers.contains(number);
    }
}

잘못 생각했던 내용

  • 처음에는 로또를 생성하는 방법을 정적 팩토리 메소드로 2가지로 구현하려했다.
    • 랜덤으로 생성될 때
      • 랜덤으로 생성될 때는 결과를 출력하는 과정까지 메소드에 추가하려 했다.
    • 숫자를 입력할 때
      • 숫자를 입력할 때는 입력을 받는 과정까지 생성 메소드에 포함시키려 했다.
  • 위와 같은 방법은 아주 잘못된 방법이었다.(실제로 2주차에는 이렇게 짰다...)
    • 이렇게 되면 만약 해당 프로그램이 뷰를 콘솔에 출력하는 게 아니라 웹 어플리케이션으로 바뀐다던지 하면 뷰에 대한 내용이 바뀌어서 Model에 대한 코드를 수정해야 된다.

MVC패턴에서 View와 Model은 독립적이어야 한다.
View를 바꾼다고, Model의 로직이 바뀌면 안된다.
그 반대도..

  • 모델은 특정 로직만 수행하고 적절한 데이터를 리턴해주는 역할 까지만 해야된다.

Getter를 없애기 위한 노력

  • Lotto라는 클래스를 만들어서 로또번호를 관리를 하는 것 까지는 쉽게 납득이 된다.
  • 하지만, 실제로 어떤 계산을 할 때는 그 로또 객체를 가르키고 있는 참조변수가 필요한 것이 아니라, 그 객체가 갖고있는 실질적인 필드 값이 필요한 것이다.
  • 그래서 처음 드는 생각은 getter를 사용하여 값 자체를 불러오는 방식을 사용하는 방법이 있다.
  • 하지만 getter는 외부에서 객체의 정보에 쉽게 접근하게 한다.
  • 로또 번호와 관련된 로직은 Lotto라는 모델 클래스안에서 구현하고 그 메소드를 외부에서 호출도록 구현하였다.
  • 예를 들어, 로또는 당첨번호와 비교를 하여 몇개가 일치하는 지 확인해야 된다.
  • 그렇다면 그것을 계산하는 부분에서 로또 번호 값을 직접 비교하는 것이 아니라, 어떤 번호가 해당 로또 객체에 존재하는지 확인하는 메소드를 구현해놓으면 된다.
public boolean containNumber(int number) {
	return numbers.contains(number);
}
  • 로또가 몇개 맞는지 계산하는 로직에서 위 메소드를 호출하여, 계산을 하고,
  • 어떤 모델의 메소드에서는 뷰페이지의 필요한 정보를 리턴하기도 한다.
  • 이런식으로 모델에 대한 관심사 분리와 그 안에서 각 메소드와 클래스에 대한 기능 분리를 진행하였다.

  • 뷰는 Print 라는 클래스에 static 메소드를 통해 구현하였다.
public class Print {
	// ...
    private static String MESSAGE_LOTTO_COUNT = "개를 구매했습니다.";
    private static String FORM_LIST_START = "[";
    private static String FROM_LIST_END = "]";
    private static String FORM_LIST_MIDDLE = ", ";

    public static void printLottoList(List<Lotto> lottos) {
        System.out.println(lottos.size() + MESSAGE_LOTTO_COUNT);
        for (int lottoIndex = 0; lottoIndex < lottos.size(); lottoIndex++) {
            printLotto(lottos.get(lottoIndex));
        }
    }

    public static void printLotto(Lotto lotto) {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append(FORM_LIST_START);
        stringBuilder.append(lotto.findLottoNumber(0));
        for (int numberIndex = 1; numberIndex < Rules.LOTTO_SIZE; numberIndex++) {
            stringBuilder.append(FORM_LIST_MIDDLE);
            stringBuilder.append(lotto.findLottoNumber(numberIndex));
        }
        stringBuilder.append(FROM_LIST_END);
        System.out.println(stringBuilder.toString());
    }
    
    // ...
}
  • 이렇게 모델에서 반환된 List<Lotto> 반환 값을 받아 그를 출력하는 과정을 짜준 것이다.
  • 이 메소드는 이제 모델과는 연관이 최대한 없어야한다.
  • 컨트롤러가 모델에서 리턴 값을 받아 그를 통하여 뷰를 호출해주는 것이다.

고민한 것

  • 처음에는 Lotto라는 클래스의 toString()을 오버라이딩 하여 로또 자체를 출력할 수 있게 하였다.
    • 하지만 시간이 지날 수록 이건 뷰에서 해야될 일을 모델 클래스가 하고 있다는 생각이 들었다.
    • 모델은 뷰가 어떻게 출력할지를 모르는 상황인데 출력할 포맷에 맞춰 String으로 변환을 한다니... 말이 안된다.
public class Lotto {
    private final List<Integer> numbers;
    
    // ...
    
    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        sb.append(numbers.get(0));
        for (int lottoIndex = 1; lottoIndex < numbers.size(); lottoIndex++) {
            sb.append(", ");
            sb.append(numbers.get(lottoIndex));
        }
        sb.append("]");
        return sb.toString();
    }
}
  • LottoRankCounter 클래스에 위와 같이 toString()을 재정의하였는데,
  • 결국엔 재정의한 내용을 지우고 해당 내용을 뷰단에서 처리를 하였다.

    모델에서는 데이터만 반환해주고 그를 뷰에서 형식에 맞게 출력해줘야한다.

컨트롤러

public class LottoHandler {
    private final User user;
    private final WinningNumbers winningNumbers;
    private final Judgment judgment;

    public LottoHandler() throws IllegalArgumentException {
        this.user = new User();
        this.winningNumbers = new WinningNumbers();
        this.judgment = new Judgment(user, winningNumbers);
    }

    public void sellLotto() throws IllegalArgumentException {
        Print.printInsertMoney();
        long money = Input.readLong();

        user.payMoney(money);
        user.buyLotto();

        Print.printLottoList(user.getUserLottos());
    }

    public void pickWinningNumber() throws IllegalArgumentException {
        Print.printWinningNumber();
        List<Integer> newWinningNumbers = Input.readListInteger(Rules.SEPARATOR_WINNING_NUMBERS);
        winningNumbers.newWinningNumbers(newWinningNumbers);
    }

    public void pickBonusNumber() throws IllegalArgumentException {
        Print.printBonusNumber();
        int newBonusNumber = Input.readInteger();
        winningNumbers.newBonusNumber(newBonusNumber);
    }

    public void calculateResult() throws IllegalArgumentException {
        RankCounter rankCounter = judgment.calculateRank();
        Print.printRankCounter(rankCounter);

        double yield = judgment.calculateYield();
        Print.printYield(yield);
    }
}
  • 기본적으로 입력모델 로직 호출값을 받아 뷰 호출하는 과정을 하나로 메소드로 묶었다.
  • 아직 이과정이 그냥 단순히 순차적인 과정을 메소드르로 나눈 것 같아 만족스럽지가 않다...
  • 이 부분은 좀 고민을 해봐야될 것 같다.

테스트

단위 테스트

단위 테스트를 더 잘 짜게 될 수 있었던 이유

  • 저번 주차보다는 훨씬 디테일하게 테스트코드를 짰다.
  • 당연히 JUnit 사용에 대한 이해도가 올라간 것도 이유다.
  • 하지만 더 큰 이유는 MVC패턴으로 클래스와 메소드의 관심을 나누고 메소드를 세분화했기 때문이다.

테스트하려는 메소드가 어떤 input에 의해 어떤 output이 나오는지 더 명확하여 테스트 코드를 짜는데 훨씬 용이했다.

  • 2주차 같은 경우에는 테스트하려는 단위 메소드가 특정 로직과 그 결과를 출력하는 과정이나 입력을 받은 과정을 다 가지고 있다 보니까 테스트코드를 짜기가 매우 어려웠다.
    • 2주차의 경우 그래서 컨트롤할 수 없는 클래스를 모킹한다던지 하여 테스트를 해야했다.
  • 테스트 코드가 더 명확해지고 짜기 수월해질수록 클래스와 메소드를 잘 나누고 있고 좋은 코드를 짜고있다는 확신이 들었다.

통과가 되지 않았던 테스트

	@Test
    void 예외_테스트() {
        assertSimpleTest(() -> {
            runException("1000j");
            assertThat(output()).contains(ERROR_MESSAGE);
        });
    }
  • 과제 통과 기준이었던 테스트가 통과를 하지 않았다...
  • 위 테스트코드가 검증하고자 하는 요구사항은 다음과 같다.

사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 종료한다.

  • 처음에는 입력 값을 검증하는 단계에서 throw new IllegalArgumentException("[ERROR] 에러메세지"); 로 처리를 하였다.
  • 테스트 통과가 되지 않았다. 테스트 코드를 분석해보았다.
  • 첫 줄runException("1000j");은 타고 들어가보면 다음과 같다.
	protected final void runException(final String... args) {
        try {
            run(args);
        } catch (final NoSuchElementException ignore) {
        }
    }

    protected final void run(final String... args) {
        command(args);
        runMain();
    }   

    private void command(final String... args) {
        final byte[] buf = String.join("\n", args).getBytes();
        System.setIn(new ByteArrayInputStream(buf));
    }

    protected abstract void runMain();
  • 메인 메소드를 실행하고 매개변수로 넘겨준 "1000j"를 인풋스트림으로 System.setIn()을 해줘서 "1000j"을 입력 값으로 받겠다는 의미다.
  • 해당 어플리케이션에서 제일 처음 받게되는 값이 구매 금액이므로 구매 금액 입력 검증에 대한 에러가 나야한다.
  • 여기까지는 뭐가 문젠지 이해하기 힘들었다.
  • 2번째 줄assertThat(output()).contains(ERROR_MESSAGE);output()ERROR_MESSAGE = "[ERROR]";이 포함되어 있는지 확인해보는 테스트다.
  • output()을 들어가보면 다음과 같다.
    private PrintStream standardOut;
    private OutputStream captor;

    @BeforeEach
    protected final void init() {
        standardOut = System.out;
        captor = new ByteArrayOutputStream();
        System.setOut(new PrintStream(captor));
    }
    
    protected final String output() {
        return captor.toString().trim();
    }
  • 즉, System에 print(출력)된 문자열을 트림하여 가져오겠다는 것이다.
System.out.print("이 값을 가져오겠다는 뜻");
  • 근데 가만히 생각해보면 난 지금 그냥 예외를 발생시킨거고 그 예외에 대한 메세지로 "[ERROR] 어쩌구 저쩌구"를 지정해 뒀을 뿐이지 그것을 출력하지 않았다.
  • 요구사항을 다시 읽어보자.

사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 종료한다.

  1. 잘못 입력하면 예외를 발생시킴
  2. [ERROR]"로 시작하는 에러 메시지를 출력
  3. 종료한다.
  • 난 지금 1번까지 밖에 진행을 하지 않은 것이다.
  • 그리고 심지어 그냥 예외를 터트린 상태로 예외에 대한 처리를 하지 않은 것이다.
  • 그러므로 예외가 발생할수 잇는 과정에 다음과 같은 처리가 필요했다.
try {
	validate(...);
} catch (IllegalArgumentException e) {
	System.out.println(e.getMessage());
}
  • 이렇게 까지 하면 2번까지 해결한 것이다, 이렇게 해도 테스트 자체는 통과한다.
  • 이렇게 까지 하면 잘못 입력된 값으로 프로그램을 계속 진행한다거나, 입력 값을 넣어주지 않아, NullPointException등을 터트려 예측하지 못하게 프로그램이 종료 될 수 있다. 이는 옳지 않다.
  • 또한 프로그래밍 요구사항 프로그램 종료 시 System.exit()를 호출하지 않는다.에 의해 catch문 안에서 해당 메소드를 호출하여 프로그램을 종료할 수 없다.
해결
  • 예외를 발생하는 지점에서 main메소드까지 throws하여 메인 전체 내용을 감싸 그 중 한 군데에서 IllegalArgumentException이 발생하면, main메소드가 끝나도록 하였다.
public class Application {
    public static void main(String[] args) {
        try {
            LottoHandler lottoHandler = new LottoHandler();
            lottoHandler.sellLotto();
            lottoHandler.pickWinningNumber();
            lottoHandler.pickBonusNumber();
            lottoHandler.calculateResult();
        } catch(Exception e){
        	// 이 부분은 뷰에 에러메세지를 출력하는 기능 구현
            Print.printExceptionMessage(e);
        }
    }
}

결론

몇 번의 웹 프로젝트르 진행하고, MVC 패턴에 대한 이해도가 있다라고 생각한 자체가 오만한 생각이었다. 지금 생각을 해보니 단순히 Spring MVC에 대한 사용이나, 서블릿 MVC가 어떻게 동작하는 지 정도를 이해한 것이지 MVC로서 관심사를 분리하는 기본적인 설계법에 대해서는 이해를 제대로하지 못한 상태였던것 같다.
그래서 이번 과제를 진행하면서 관심사가 명확하게 나눠지니 특정 메소드에서 처리해야되는 기능이 명확해졌고, 메소드가 단 하나의 일을 할 수 있도록 하기 쉬워졌다.
그렇다 보면, 나중에 특정 기능을 고치거나, 리팩토링할 때도 고쳐야되는 부분이 명확하게 보였고, 단위 테스트 코드를 짜는 것도 쉬워졌다.
객체 지향적으로 코딩을 하는 것이 얼마나 중요한지 느낄 수 있던 일주일이었다.

profile
1일 1산책 1커밋

4개의 댓글

comment-user-thumbnail
2022년 11월 16일

도움 많이 받고 갑니다!! 로또 클래스 내부에서 정렬해서 출력하는 toString() 메소드를 구현했는데, 생각해보니까 정말 view에서 해야하는 일을 model에서 하고 있었던 것 같네요 자세하게 작성해주신 회고록 덕분에 견문을 넓혀가는 것 같습니다 ! !!

1개의 답글
comment-user-thumbnail
2022년 11월 16일

글 읽으면서 MVC에대한 고민을 같이하게되었네요😆
잘보고갑니다!

1개의 답글