Seren dev's blog
article thumbnail

프리코스 3주차 미션을 진행한 과정, 느낀 점, 개선할 점을 작성한 회고록이다.

 

프리코스 3주차 미션은 "로또" 미션으로, 로또 게임을 구현해야 한다.

2주차 공통 피드백 중 중요하다고 생각한 피드백을 아래에 적어두었다.

 

2주차 공통 피드백


1. 기능 목록을 재검토하고 업데이트한다

기능 목록을 클래스 설계와 구현, 함수(메서드) 설계와 구현과 같이 너무 상세하게 작성하지 않는다. 클래스 이름, 함수(메서드) 시그니처와 반환값은 언제든지 변경될 수 있기 때문이다. 너무 세세한 부분까지 정리하기보다 구현해야 할 기능 목록을 정리하는 데 집중한다. 정상적인 경우도 중요하지만, 예외적인 상황도 기능 목록에 정리한다. 특히 예외 상황은 시작 단계에서 모두 찾기 힘들기 때문에 기능을 구현하면서 계속해서 추가해 나간다.

죽은 문서가 아니라 살아있는 문서를 만들기 위해 노력한다.

 

2. 값을 하드 코딩하지 않는다

문자열, 숫자 등의 값을 하드 코딩하지 마라. 상수(static final)를 만들고 이름을 부여해 이 변수의 역할이 무엇인지 의도를 드러내라. 구글에서 "java 상수"와 같은 키워드로 검색해 상수 구현 방법을 학습하고 적용해 본다.

 

3. 구현 순서도 코딩 컨벤션이다

클래스는 상수, 멤버 변수, 생성자, 메서드 순으로 작성한다.

class A {
    상수(static final) 또는 클래스 변수

    인스턴스 변수

    생성자
    
    메서드
}

 

4. 변수 이름에 자료형은 사용하지 않는다

변수 이름에 자료형, 자료 구조 등을 사용하지 마라.

String carNameList = Console.readLine();
String[] arrayString = carNameList.split(",");

 

5. 한 함수가 한 가지 기능만 담당하게 한다. 함수가 한 가지 기능을 하는지 확인하는 기준을 세운다

함수 길이가 길어진다면 한 함수에서 여러 일을 하려고 하는 경우일 가능성이 높다. 아래와 같이 한 함수에서 안내 문구 출력, 사용자 입력, 유효값 검증 등 여러 일을 하고 있다면 이를 적절하게 분리한다.

private List<Integer> userInput() {
    System.out.println("숫자를 입력해 주세요: ");
    String userInput = Console.readLine().trim();
    List<Integer> user = new ArrayList<>();
    for (char c : userInput.toCharArray()) {
      user.add(Character.getNumericValue(c));
    }
    if (user.size() != 3) {
      throw new IllegalArgumentException("[ERROR] 숫자가 잘못된 형식입니다.");
    }
    return user;
}

만약 여러 함수에서 중복되어 사용되는 코드가 있다면 함수 분리를 고민해 본다. 또한, 함수의 길이를 15라인을 넘어가지 않도록 구현하며 함수를 분리하는 의식적인 연습을 할 수 있다.

 

프리코스 3주차 로또 미션 Github 링크

🎯 프로그래밍 요구 사항

추가된 요구 사항

  • 함수(또는 메서드)의 길이가 15라인을 넘어가지 않도록 구현한다.
    • 함수(또는 메서드)가 한 가지 일만 잘 하도록 구현한다.
  • else 예약어를 쓰지 않는다.
    • 힌트: if 조건절에서 값을 return하는 방식으로 구현하면 else를 사용하지 않아도 된다.
    • else를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다.
  • Java Enum을 적용한다.
  • 도메인 로직에 단위 테스트를 구현해야 한다. 단, UI(System.out, System.in, Scanner) 로직은 제외한다.
    • 핵심 로직을 구현하는 코드와 UI를 담당하는 로직을 분리해 구현한다.
    • 단위 테스트 작성이 익숙하지 않다면 test/java/lotto/LottoTest를 참고하여 학습한 후 테스트를 구현한다.

Lotto 클래스

  • 제공된 Lotto 클래스를 활용해 구현해야 한다.
  • Lotto에 매개 변수가 없는 생성자를 추가할 수 없다.
  • numbers의 접근 제어자인 private을 변경할 수 없다.
  • Lotto에 필드(인스턴스 변수)를 추가할 수 없다.
  • 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: 추가 기능 구현
}

 


3주차 미션 구현 내용

3주차 미션 목표 : 2주차 + 클래스(객체)를 분리하는 연습 & 도메인 로직에 대한 단위 테스트를 작성하는 연습

⭐어려웠던 점

클래스 분리에 따른 예외 처리 로직, 상수,  에러 메시지 관리 위치

기능 요구 사항을 보면 "사용자가 잘못된 값을 입력할 경우 IllegalArgumentException를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 종료한다."고 나와 있다. 즉, 사용자가 잘못된 값을 입력하거나, 로또를 생성할 때 잘못된 로또 번호를 생성하면 예외 처리 로직을 실행해야 한다.

 

예외처리 로직을 작성할 때, Validator 클래스를 따로 만들어서 그 클래스 안에 모든 validation 로직을 작성할지, 아니면 도메인 마다 그 도메인의 유효성 검사 로직을 작성할 지 고민했는데, 이 글을 참고하여 입력 뷰에서는 최소한의 사용자 유효성 입력을 보장, 즉 입력 형태가 숫자인지 검증하는 로직을 작성하고, 각 도메인 클래스 내에 도메인의 요구사항과 관련된 유효성 검사 로직을 작성하여 생성자에서 유효성 검사 로직을 호출하도록 했다.

package lotto.view;

import static lotto.domain.ErrorMessage.NUMBER_FORMAT_ERROR_MESSAGE;
...

public class InputView {

    ...

    private static int convertStringToInteger(String number) {
        try {
            return Integer.parseInt(number);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(NUMBER_FORMAT_ERROR_MESSAGE);
        }
    }

    private static List<Integer> convertStringToNumbers(String winningNumbers) {
        try {
            String[] lottoNumbers = winningNumbers.split(",");
            return Arrays.stream(lottoNumbers)
                    .map(Integer::parseInt)
                    .collect(Collectors.toList());
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(NUMBER_FORMAT_ERROR_MESSAGE);
        }
    }
}
package lotto.domain;

import static lotto.domain.ErrorMessage.LOTTO_PURCHASE_MONEY_ERROR_MESSAGE;

public class LottoPurchaseMoney {
    static final int LOTTO_PRICE = 1000;

    private final int money;

    public LottoPurchaseMoney(int money) {
        validate(money);
        this.money = money;
    }

    private void validate(int money) {
        if (money % LOTTO_PRICE != 0) {
            throw new IllegalArgumentException(LOTTO_PURCHASE_MONEY_ERROR_MESSAGE);
        }
    }

    ...
}

하지만 이렇게 작성하니 상수와 에러 메시지를 어느 클래스에서 관리해야 할지 고민이 많이 되었는데, 상수는 각 도메인에서, 에러 메시지는 ErrorMessage 추상 클래스를 생성하여 상수로 보관하도록 했다.

또한 상수를 각 도메인에서 관리하니, 다른 클래스에서 필요한 상수들도 있었는데 이러한 경우에는 상수의 접근 제어자를 default로 하여 다른 클래스에서도 접근 가능하도록 하였다.

ex) 1, 45, 6 등의 매직 넘버는 Lotto 클래스에서 상수로 정의하여 관리하고, 접근 제어자를 default로 하여 LottoNumberGenerator와 WinningLotto에서도 접근할 수 있도록 하였다.

 

위와 같이 작성한 이유

로또 번호 숫자 범위나 로또 번호 개수와 같은 상수는 도메인과 밀접하게 연결되어있으므로 도메인 내에 static final로 생성하여 각 도메인이 관리하고, 다른 도메인에서도 접근할 수 있도록 접근 제어자로 default로 지정하여 코드를 작성했다.

에러 문구는 하나의 에러 문구가 여러 도메인에서 사용하는 경우도 있고, 이후 에러 문구를 유지보수할 때 하나의 클래스에 있는 것이 더 관리하기 편할 것 같다고 생각하여 하나의 클래스에 관리하도록 코드를 작성했다.

 

ApplicatoinTest 파일의 예외_테스트 실패

    @Test
    void 예외_테스트() {
        assertSimpleTest(() -> {
            runException("1000j");
            assertThat(output()).contains(ERROR_MESSAGE);
        });
    }

2주차 미션을 했을 때처럼, 처음 코드를 작성했을 때 예외 상황 발생 시 IllegalArgumentException을 발생시켜서 프로그램이 바로 종료되도록 했는데, 위 테스트를 실행시키면 IllegalArgumentException 예외가 발생하면서 테스트가 실패했었다.

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

=> 즉, 예외 상항이 발생해도 프로그램은 에러 메시지 출력 후 정상 종료되어야 한다.

 

테스트를 성공하기 위해 LottoGameController에서 playGame() 메서드와 예외가 발생할 수 있는 메서드에 try-catch 문을 작성하여 IllegalArgumentException 예외가 생성하면 에러 메시지를 출력하고, 종료하도록 했다.

package lotto.controller;

import lotto.domain.LottoGameResult;
import lotto.domain.LottoPurchaseMoney;
import lotto.domain.PlayerLotto;
import lotto.domain.WinningLotto;
import lotto.view.InputView;
import lotto.view.OutputView;
import java.util.List;

public class LottoGameController {

    public void playGame() {
        try {
            LottoPurchaseMoney lottoPurchaseMoney = createLottoPurchaseMoney();

            PlayerLotto playerLotto = createPlayerLotto(lottoPurchaseMoney);
            printPlayerLotto(playerLotto);

            WinningLotto winningLotto = createWinningLotto();

            LottoGameResult lottoGameResult = createLottoGameResult(playerLotto, winningLotto);
            printLottoGameResult(lottoGameResult, lottoPurchaseMoney);
        } catch (IllegalArgumentException e) {
            OutputView.printErrorMessage(e.getMessage());
        }
    }

    private LottoPurchaseMoney createLottoPurchaseMoney() {
        try {
            int money = InputView.lottoPurchaseMoney();
            return new LottoPurchaseMoney(money);
        } catch (IllegalArgumentException e) {
            throw e;
        }
    }

    private PlayerLotto createPlayerLotto(LottoPurchaseMoney lottoPurchaseMoney) {
        try {
            return new PlayerLotto(lottoPurchaseMoney);
        } catch (IllegalArgumentException e) {
            throw e;
        }
    }

    private void printPlayerLotto(PlayerLotto playerLotto) {
        OutputView.printLottos(playerLotto.getLottos());
    }

    private WinningLotto createWinningLotto() {
        try {
            List<Integer> winningNumbers = InputView.winningNumbers();
            int bonusNumber = InputView.bonusNumber();

            return new WinningLotto(winningNumbers, bonusNumber);
        } catch (IllegalArgumentException e) {
            throw e;
        }
    }

    private LottoGameResult createLottoGameResult(PlayerLotto playerLotto, WinningLotto winningLotto) {
        return new LottoGameResult(playerLotto, winningLotto);
    }

    private void printLottoGameResult(LottoGameResult lottoGameResult, LottoPurchaseMoney lottoPurchaseMoney) {
        OutputView.printTotalResult(lottoGameResult, lottoPurchaseMoney);
    }
}
createLottoPurchaseMoney, createPlayerLotto, createWinningLotto 메서드에도 try-catch문을 작성했는데, 사실 이 메서드에 try-catch 문을 작성하지 않아도 맨 처음에 게임을 시작할 때 호출되는 playGame 메서드에서 예외를 잡아내기 때문에 playGame 메서드에서만 try-catch 문을 작성해도 된다.

 

위와 같이 수정하면 ApplicationTest의 예외_테스트도 성공하고, 직접 게임을 실행해도 다음과 같이 정상 종료되는 것을 확인할 수 있다.

 

ApplicatoinTest 파일의 기능_테스트 실패

애플리케이션 로직을 완성하고 ApplicationTest의 기능_테스트 메서드를 처음으로 실행했을 때 실패가 떴었다. 로그를 찍어보면서 어디서 오류가 났는지 찾아봤는데, LottoNumberGenerator에서 Collections.sort(numbers) 부분에서 오류가 났었다.

기능 요구사항 중 출력 요구사항을 보면 "로또 번호는 오름차순으로 정렬하여 보여준다."고 나와있으므로 LottoNumberGenerator에서 Collections.sort(numbers)를 하여 로또 번호를 정렬하도록 하였는데, 오류가 나서 Collections.sort(numbers) 코드를 삭제하였다.

이런 에러가 발생한 것을 뒤늦게 발견했어서 결국 끝까지 해결하지 못하고 제출하였는데, 이후 미션이 끝난 후 슬랙에서 다른 분들이 사용한 해결 방법을 찾을 수 있었다.

테스트가 실패한 이유는 Random 라이브러리 안에 있는 리스트 생성 메소드를 받아서 바로 sort 해버리면 Unmodifiable 리스트라 오류가 떴을 수도 있다고 하였고, 그 리스트를 받아서 새 리스트를 생성해서 집어넣고 새 리스트를 sort하였다고 한다.

List test = new ArrayList(Randoms.pickUniqueNumber())

 

3주차 소감


이번 3주차 미션의 목표는 클래스(객체)를 분리하는 연습, 도메인 로직에 대한 단위 테스트를 작성하는 연습이었다.
2주차 미션을 수행할 때는 하나의 클래스에 모든 로직을 작성했지만, 다른 분들의 코드를 보며 나도 클래스 분리를 많이 연습해야겠다고 생각했다.
클래스를 분리하는 기준에 대해 고민했었는데, 다른 분들의 코드를 보고 구글링 해본 결과 많은 분들이 MVC 패턴을 사용하여 클래스를 분리하고 핵심 로직과 UI로직을 분리하였다. 그래서 MVC 패턴을 사용하여 클래스를 분리하였고, 패키지를 controller, domain, view로 나누었습니다. view에는 입력과 출력, controller는 전체적인 게임의 실행 로직, domain에는 여러가지 도메인 클래스를 생성하고 각 도메인 클래스 내에 도메인의 핵심 로직을 작성했다.

 

클래스를 분리하면서 가장 고민했던 점은, validation 로직을 작성할 때 하나의 validation 클래스를 만들어 그 클래스 안에 모든 유효성 검사 로직을 작성할지, 아니면 도메인마다 그 도메인의 유효성 검사 로직을 작성할지 였다. 나는 후자를 선택하여 각 도메인 클래스 내에 도메인의 요구사항과 관련된 유효성 검사 로직을 작성했고, 입력 View에서는 최소한의 사용자 입력 유효성을 보장, 즉 입력 형태가 숫자인지 검증하는 로직을 작성했다.
또한 클래스를 분리하며 상수 관리와 에러 문구 관리를 어느 클래스에서 할 지 고민이 많이 되었는데, 상수 관리는 각 도메인에서 관리하도록 하고, 에러 문구는 ErrorMessage 추상 클래스를 생성하여 상수로 관리하도록 작성했다. 로또 번호 숫자 범위나 로또 번호 개수와 같은 상수는 도메인과 밀접하게 연결되어있으므로 도메인 내에 static final로 생성하여 각 도메인이 관리하고, 다른 도메인에서도 접근할 수 있도록 접근 제어자로 default로 지정하여 코드를 작성했다. 에러 문구는 하나의 에러 문구가 여러 도메인에서 사용하는 경우도 있고, 이후 에러 문구를 유지보수할 때 하나의 클래스에 있는 것이 더 관리하기 편할 것 같다고 생각하여 하나의 클래스에 관리하도록 코드를 작성했다.
다른 사람들은 어떻게 작성했을지 이후 피어 리뷰(코드 리뷰)를 통해 확인하고 싶다.

 

또한 각 도메인의 도메인 로직에 대한 단위 테스트 코드를 작성할 때,  각 테스트 메서드가 어떤 내용을 테스트하고 검증하는 건지 잘 이해할 수 있도록 @DisplayName을 사용하고 given-when-then 패턴에 맞추어 테스트 코드를 작성하도록 노력했다.

 

이번 미션에서는 domain 패키지 내 클래스들을 하나의 domain 패키지 내에서 관리했지만, 다음 미션에서는 domain 패키지 내에 패키지를 생성하여 클래스들을 패키지별로 분리하고, stream을 능숙하게 사용하도록 많이 연습하고 싶다.

 

🛠️리팩토링하기


이후 슬랙이나 깃헙 디스커션을 보면서 내가 생각지도 못했던 예외사항이 있었음을 알게 되었고, 3주차 공통 피드백을 통해 리팩토링을 해야할 부분도 많이 발견하였다.

refactoring 브랜치를 생성하고 아래의 사항들을 적용해서 리팩토링할 예정이다.

git checkout -b refactoring	//refactoring 브랜치 생성

 

 

1. 로또 구입 금액 예외 사항

로또 구입 금액이 0인 경우를 생각하지 못하고 단순히 금액이 1000원으로 나누어 떨어지지 않을 경우만 예외 상황으로 처리하였다.

예외 상황을 좀 더 꼼꼼히 생각해봐야겠다.

 

2. enum Rank의 불필요한 인스턴스 변수 삭제

enum Rank 클래스에서 isBonus 인스턴스 변수는 필요 없었던 것 같은데, 기능 구현에만 급급해서 리팩토링에 신경을 많이 못 쓴 것 같다.

 

3. controller 부분에 불필요한 try - catch 문 삭제

 

4. 객체는 객체스럽게 사용한다 (3주차 공통 피드백)

객체에서 데이터를 꺼내지(getter) 말고 메시지를 던지도록 구조를 바꿔 데이터를 가지는 객체가 일하도록 한다.

getter를 사용하는 대신 객체에 메시지를 보내자

상태를 가지는 객체를 추가했다면 객체가 제대로 된 역할을 하도록 구현해야 한다.
객체가 로직을 구현하도록 해야한다.
상태 데이터를 꺼내 로직을 처리하도록 구현하지 말고 객체에 메시지를 보내 일을 하도록 리팩토링한다.

 

아래 코드는 이번 미션을 하면서 작성한 Rank 클래스 내 compare() 메서드와 countSameNumbers() 메서드이다. 이 코드를 getter를 사용하는 대신 객체에 메시지를 보내도록 리팩토링하면, Lotto 클래스에 contains() 메서드와 matchCount() 메서드를 추가하여 getNumbers() 메서드를 사용하지 않고 객체에 메시지를 보내 객체가 로직을 수행하도록 할 수 있다.

    public Rank compare(Lotto lotto) {
        int count = countSameNumbers(lotto);
        boolean isBonus = lotto.getNumbers().contains(bonusNumber);

        Rank rank = Rank.findRank(count, isBonus);
        return rank;
    }

    private int countSameNumbers(Lotto lotto) {
        long count = winningLotto.getNumbers()
                .stream()
                .filter(winningNumber -> lotto.getNumbers().contains(winningNumber))
                .count();
        return (int)count;
    }

 

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

    public boolean contains(int number) {
        // 숫자가 포함되어 있는지 확인한다.
        ...
    }
    
    public int matchCount(Lotto other) {
        // 당첨 번호와 몇 개가 일치하는지 확인한다.
        ...
    }
}

public class LottoGame {
    public void play() {
        Lotto lotto = new Lotto(...);
        lotto.contains(number);
        lotto.matchCount(...); 
    }
}

상태 데이터를 가진다고 무조건 setter, getter 메소드를 만드는 습관을 버리자. setter, getter 메소드는 정말 필요한 순간까지 뒤로 미루는 습관을 만들고, 아예 추가하지 않는 연습을 하면 더 좋다.

 

getter를 무조건 사용하지 말라는 말은 아니다.
당연히 getter를 무조건 사용하지 않고는 기능을 구현하기 힘들것이다. 출력을 위한 값 등 순수 값 프로퍼티를 가져오기 위해서라면 어느정도 getter는 허용된다. 그러나, Collection 인터페이스를 사용하는 경우 외부에서 getter메서드로 얻은 값을 통해 상태값을 변경할 수 있다.
그래서 Collections.unmodifiableList(), Collections.unmodifiableMap()와 같은 Unmodifiable Collecion을 사용해 외부에서 변경하지 못하도록 하는 게 좋다.

 

 

5. 수익률을 구하는 부분을 OutputView가 아니라 LottoGameResult에 메시지를 보내서 구현한다.

public class LottoResult {
    private Map<Rank, Integer> result = new HashMap<>();

    public double calculateProfitRate() { ... }
    
    public int calculateTotalPrize() { ... }
}

 

6. 테스트 코드 리팩토링  (3주차 공통 피드백)

테스트 코드도 코드이므로 리팩터링을 통해 개선해나가야 한다. 특히 반복적으로 하는 부분을 중복되지 않게 만들어야 한다. 예를 들어 단순히 파라미터의 값만 바뀌는 경우라면 아래와 같이 테스트할 수 있다.

@DisplayName("천원 미만의 금액에 대한 예외 처리")
@ValueSource(strings = {"999", "0", "-123"})
@ParameterizedTest
void underLottoPrice(Integer input) {
    assertThatThrownBy(() -> new Money(input))
            .isInstanceOf(IllegalArgumentException.class);
}

 

 

Reference

728x90
profile

Seren dev's blog

@Seren dev

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!