겸손하기 꾸준하기 건강하기

우테코 6기 프리코스(back-end) 3주차 회고 본문

woowacourse/백엔드 6기 프리코스

우테코 6기 프리코스(back-end) 3주차 회고

seminss 2023. 11. 29. 13:51

밀린 일들과 4주 차 미션 회고, 리펙토링을 하다 보니 정신없이 2주가 갔다 :0

11/2~11/ 8에 진행되었던 3주차 미션, [로또] 회고 글을 이제야 써보려 한다. 😶
 
사실 [로또]는 프리코스 기간 동안 진행했던 미션 중, 가장 아픈 손가락이고, 너무너무 아쉬운 점이 많은 미션이다.
 
그래도 당시에는 나름의 철칙이 있었어서 ㅋㅋㅋ 예쁜 코드를 짜려고 나름대로의 노력을 했었는데,
당시 그런 선택을 했던 이유와, 지금 와서 생각 해보면, 어떤 방법이 나았을지 살펴보면서 글을 작성해 보겠다!
 
3주 차를 마친 뒤, 자극을 많이 받고, 뉘우침을 얻은 덕에^^ 4주 차에 더 열심히 할 수 있었다 생각한다. 😀

 


 

 

 

지난 2주 차 미션에서는 함수 분리와 함수별로 테스트를 작성하는 것을 목표로 했는데요.
3주 차 미션에서는 2주 차에서 학습한 것에 2가지 목표를 추가했어요.

  1. 클래스(객체)를 분리하는 연습
  2. 도메인 로직에 대한 단위 테스트를 작성하는 연습

도메인 로직과 단위 테스트와 같은 용어들이 낯설 수 있지만, 작은 기능부터 테스트를 작성하는 연습을 시작해 보는 것입니다.
2주 차 미션 진행 과정을 보니 새로운 방식으로 구현하느라 쉽지 않았을 텐데요. 다른 사람과의 비교보다는 어제의 나를 생각하며 본인의 속도에 맞추어서 마무리하는 걸 목표로 하면 좋을 것 같아요. 이 경험을 좋은 프로그래머가 되기 위한 중요한 역량을 키우는 과정으로 생각해 주세요.


미션 - 로또


2주 차 공통 피드백

  1. README.md를 상세히 작성한다.
  2. 기능 목록을 재검토한다.
  3. 기능 목록을 업데이트한다.
  4. 값을 하드 코딩하지 않는다.
  5. 구현 순서도 코딩 컨벤션이다.
  6. 변수 이름에 자료형은 사용하지 않는다.
  7. 한 함수가 한 가지 기능만 담당하게 한다.
  8. 함수가 한 가지 기능을 하는지 확인하는 기준을 세운다.
  9. 테스트를 작성하는 이유에 대해 본인의 경험을 토대로 정리해 본다.
  10. 처음부터 큰 단위의 테스트를 만들지 않는다.

 
2주 차에 처음으로 MVC 패턴을 적용해봤고, 많은 분들께 요청을 드렸다.
 
2주차 회고에서 작성한 리뷰 관련된 부분은 다른 분들 코드를 살펴보며 어깨 너머로 배운 것들이었고,
이후 내 코드에 대한 리뷰를 받아 추가적으로 리뷰 관련 부분을 작성해 본다.
 
리뷰를 바탕으로 [자동차 경주] 미션에서 느낀 내 코드의 문제를 다음과 같이 정의했다.

  1. 클래스, 또는 메서드가 한 번에 하나의 역할만 하고 있지 않은 것. 그 결과로, 객체 간 의존성이 높은 것.
  2. 저번 주차 목표가 '함수별로 테스트를 작성하는 것'을 목표로 하고 있었음에도, 테스트가 비교적 큰 단위부터 동작하고 있는 것.
  3. "이런 식으로 구현하면 MVC 패턴이 되는 거구나~" 하고 이것저것 가져다 썼던 것

 

1~2일 차 (Nov 03, 2023) (Nov 04, 2023)

docs: 기능 목록 및 애플리케이션 구조 구상

 

로또 미션은, 미션을 받고 머릿속에 그림을 그리기까지 너무 오래 걸렸다.

 

머릿속에 전체 프로세스가 있어야 구현이 제대로 진행된다는 철칙.. 이 있어😤 전체적인 애플리케이션 구조까지 생각하느라 이틀 내내 고민만 했던 것 같다.

 

지금 와서 보니, 구조적인 부분까지 너무 디테일하게 고민할 필요는 없었겠다는 생각이 든다.

고민하는 시간이 길어지면서, 개발을 늦게 시작하고, 급하게 제출하게 되는 결과로 이어졌기 때문에

대충 애플리케이션이 요구하는 기능, 어떤 콘셉트인지만 파악했으면 넘어가서 코드를 짜면서 구조를 고민했어도 충분했겠다 생각한다. (4주 차에는 그렇게 했고,, 실제로 훨씬 만족스러웠다 :)

 

당시에 그렸던 그림

 

위 설계에는 가장 큰 문제가 있는데, 바로 서비스 계층이 너무 많은 책임을 안고 있다는 것이다.

2주 차 미션을 하고 리뷰를 받으면서, 모델을 좀 더 컴펙트 하게 가져가면 어떨까? 라는 조언을 받기도 했고, 한 함수가 한 가지 기능만 담당하게 한다. 라는 2주차 공통 피드백 내용이 있었기 때문에 모델의 부담을 최소화하고, 서비스 계층도 최대한 잘게 쪼개려다 보니 위와 같은 그림이 나왔다..

public record Lotto(List<Integer> numbers) {
    public Lotto {
        validateCounts(numbers);
        validateRange(numbers);
        validateDuplicate(numbers);
    }

    private void validateCounts(List<Integer> numbers) {
        ...
    }

    private void validateRange(List<Integer> numbers) {
        ...
    }

    private void validateDuplicate(List<Integer> numbers) {
        ...
    }
}

당시 코드를 보면 , 모든 모델이 이런 식으로 생겼다.

private final 변수, 기본 생성자, getter 등의 기본 적인 유틸만 가지고 있는 "값 전송"을 위한 객체(Record)로써 사용되었고, 비즈니스 로직은 전부 service 계층에 몰빵이 되어있었다.

 

3주차 피드백 중

 

다음 주차 피드백을 받고 바로 반성... 🥹🥹🥹

일단은 서비스 계층에 모든 로직을 몰빵 시킬 계획을 가지고~~~ 구현 시작^^;

 


3일 차 (Nov 04, 2023)

[로또] 미션을 진행하면서 가장 잘했다고 생각되는 점은, 기능 하나를 구현할 때마다 단위 테스트를 하나씩 잘 짜면서 넘어간 점,

커밋을 기능 단위로 잘 쪼개서 올린 점이다.

 

[자동차경주] 미션까지는 기능 명세서를 아래와 같이 작성했었다.

위 방법도 나쁘다고 생각하지는 않지만, 이렇게 작성을 했을 때 단점이 있다.

기능 단위로 커밋 메시지를 작성하기 애매해진다는 점이다.

하나의 기능을 구현하기 위해 여러 클래스들의 변경이 필요한 경우가 많으니까.

 

[로또] 미션에서는 이렇게 기능 단위로 명세서를 작성했고, 구조적 설계는 별도로 진행했다.

docs에 욕심내어 모든 클래스, 구조에 대한 설명을 담으려고 하지 않았다.

 


4일 차 (Nov 05, 2023)

[로또] 미션에서 또 하나 얻어간 것은 model의 검증과 input의 검증 분리하기, 그리고 EnumMap의 사용이다.

 

2주 차 미션까지만 해도 하나의 validator를 선언하여 input으로 들어온 String을 검증&파싱 하고 있었는데

일급 컬렉션 개념을 찾아보면서 "모델에서의 검증", "뷰에서의 검증" 이 있다는 것을 알게 되었다.

 

예를 들면 아래와 같은 요구사항이 있다.

로또 번호는 1부터 45 사이의 숫자여야 합니다.

이런 요구사항을 지키기 위해 사용자의 입력을 받아 정수로 변환하고, 바로 1~45 범위에 있는지 확인해도 된다.

그러나, 그렇게 되면 로또 객체가 항상 1부터 45 사이의 값을 가지고 있다는 것을 보장할 수 없다.

 

때문에 view 단에서는 입력이 없는 경우, 잘못된 문자열 등의 예외만 잡아내고, 나머지 기능 요구사항에 있는 검증은 모델 단에서 진행해야 하는 것이다. 그럼 각 객체를 validatable 한 상태로 유지할 수 있게 된다.

/**뷰에서의 검증*/
public class PurchaseAmountValidator {

    public void validate(String userInput) {
        validateNotEmpty(userInput);
        validateInteger(userInput);
    }

    private void validateNotEmpty(String userInput) {
        if (userInput.isEmpty()) {
            throw new EmptyInputException(INPUT_EMPTY.getMessage());
        }
    }

    private void validateInteger(String userInput) {
        try {
            parseInteger(userInput);
        } catch (NumberFormatException e) {
            throw new NumberFormatException(INPUT_NOT_INTEGER.getMessage());
        }
    }
}

입력을 받았다고 할 수 없는 것들만 걸러낸다.

/**모델에서의 검증*/
public record LottoPurchaseAmount(int amount) {
    private static final int MINIMUM_AMOUNT_THRESHOLD = 0;
    private static final int NO_REMAINDER = 0;

    public LottoPurchaseAmount {
        validatePositive(amount);
        validateMultipleOfThousand(amount);
    }

    private void validatePositive(int amount) {
        if (amount <= MINIMUM_AMOUNT_THRESHOLD) {
            throw new BusinessConditionException(PURCHASE_AMOUNT_NOT_POSITIVE.getMessage());
        }
    }

    private void validateMultipleOfThousand(int amount) {
        if ((amount % LottoConfig.TICKET_PRICE.getValue()) != NO_REMAINDER) {
            throw new BusinessConditionException(PURCHASE_AMOUNT_NOT_MULTIPLE_THOUSAND.getMessage());
        }
    }
}

항상 천의 단위여야 한다, 구입 금액은 양수여야 한다. 와 같이, 애플리케이션을 구동하기 위해 해당 객체가 가져야 하는 성질은 모델에서 검증을 해서 완전한 상태를 유지할 수 있게 했다.


Enum은 이렇게 아래와 같이 등수를 관리하는 데 사용했다.

public enum RankCategory {
    NONE(0, 0, false),
    FIFTH(3, 5000, false),
    FOURTH(4, 50000, false),
    THIRD(5, 1500000, false),
    SECOND(5, 30000000, true),
    FIRST(6, 2000000000, false);


    private final int matchingNumbers;
    private final int prize;
    private final boolean bonusStatus;

    RankCategory(int matchingNumbers, int prize, boolean bonusStatus) {
        this.matchingNumbers = matchingNumbers;
        this.prize = prize;
        this.bonusStatus = bonusStatus;
    }

// ... getter

    public static RankCategory of(int matchingNumbers, boolean bonusStatus) {
        if (matchingNumbers == RankCategory.SECOND.matchingNumbers) {
            return determineSecondOrThird(bonusStatus);
        }
        for (RankCategory rankCategory : RankCategory.values()) {
            if (rankCategory.matchingNumbers == matchingNumbers) {
                return rankCategory;
            }
        }
        return NONE;
    }

    private static RankCategory determineSecondOrThird(boolean bonusStatus) {
        if (bonusStatus) {
            return SECOND;
        }
        return THIRD;
    }
}

이렇게 선언한 Enum을 EnumMap으로 묶어서 

public LottoResult calculateResults
        (LottoBundle lottoBundle, WinningNumbers winningNumbers, BonusNumber bonusNumber) {
    EnumMap<RankCategory, Integer> results = getInitializedEnumMap();
    for (Lotto lotto : lottoBundle.lottoBundle()) {
        int matchingNumbers = countMatchingNumbers(winningNumbers, lotto);
        boolean bonusStatus = checkBonusStatus(bonusNumber, lotto);
        RankCategory rankCategory = RankCategory.of(matchingNumbers, bonusStatus);
        results.put(rankCategory, results.get(rankCategory) + INCREASE_NUM);
    }
    return new LottoResult(results);
}

결과를 낼 때 사용을 했는데, 당시에는 Map, EnumMap 자료구조의 사용 자체를 너무 어려워했다.

 

지금 보면, matchingNumbers를 가져오는 건 메서드로 선언하지 않고, lotto 값을 넘겨서 winningNumbers가 계산해서 던져주게 했을 것 같고, bonusStatus도 bonusNumber을 lotto에 넘겨서 lotto가 던져주게 해줬어야 한다 생각한다.

Rank 값을 가져오기 위해 for문을 돌리지 않고, 정적 팩토리 메서드로 RankCatey를 바로 가져온 부분은 잘했다고 생각한다.

public LottoResult calculateResults(LottoBundle lottoBundle, WinningNumbers winningNumbers, BonusNumber bonusNumber) {
    EnumMap<RankCategory, Integer> results = lottoBundle.lottoBundle().stream()
        .collect(Collectors.toMap(
            lotto -> of(winningNumbers.countMatchingNumbers(lotto), lotto.checkBonusStatus(bonusNumber)),
            lotto -> INCREASE_NUM, //해당하는 key에 INCRESE_NUM(1)을 value로 넣는다.
            Integer::sum, //같은 카테고리를 가졌으면 더한다.
            () -> getInitializedEnumMap() //각 랭크를 key값, 합계를 value로 해서 EnumMap을 생성
        ));

    return new LottoResult(results);
}

스트림으로 변환할 수도 있다.

개인적으로는 for문과 비교했을 때 특별히 가독성이 더 좋아진다고 생각이 안 들어, 그냥 for문을 사용했던 것 같다.

 


5일 차 (Nov 06, 2023)

package lotto.model;

public record ProfitRate(double rate) {
}

profitRate 객체는 폼인가요?

public ProfitRate calculateProfitRate(LottoResult lottoResult, LottoPurchaseAmount lottoPurchaseAmount) {
    int profitSum = getProfitSum(lottoResult);
    return new ProfitRate(((double) profitSum / lottoPurchaseAmount.amount()) * PERCENT_MULTIPLIER);
}

이건 ProfitRate에서 처리해서 반환해줬어야 하는 부분이었다.

 


6일 차 (Nov 07, 2023)

- 사용자가 잘못된 값을 입력할 경우 `IllegalArgumentException`를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다.

로또 미션의 요구사항에는 에러 핸들링에 대한 요구사항이 추가되었다.

 

처음에는 그냥 while(true)로 돌려줬다.

public WinningNumbers getValidWinningNumbers() {
    while (true) {
        try {
            OutputView.printWinningNumbersMessage();
            return winningNumberService.createWinningNumbers(InputView.read());
        } catch (IllegalArgumentException e) {
            OutputView.printErrorMessage(e.getMessage());
        }
    }
}

그냥 루프를 돌린다고 되는 게 아니고, 예외를 잡아줘야 했다. 그렇지 않으면 모델 내부에서 터지는 예외들 때문에 전체 애플리케이션이 터지기 때문이다. 이때까지는 예외 처리에 대한 부분을 정말 감을 못했어서 그런지.. 이날 에러 처리하는 데 하루종일 헤매었다. 


- `Exception`이 아닌 `IllegalArgumentException`, `IllegalStateException` 등과 같은 명확한 유형을 처리한다.

 

명확한 유형의 에러를 처리한다는 요구사항도 있었기 때문에, 커스텀 exception을 만들어봤다.

public class LottoException extends IllegalArgumentException {
    public LottoException(String message) {
        super(message);
    }
}

 

지금 다시 만든다면, IllegalArgumentException을 상속받는 LottoException을 만들고, 모든 메시지 앞에 [Error]을 붙여야 한단 요구사한을 여기서 처리해 줬을 것 같다. 그리고 애플리케이션에서 사용하는 모든 커스텀 Exception들을 LottoException을 상속받게 함으로써, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다. 이 부분을 더 깔끔하게 만족시켰을 것 같다.

 


7일 차 (Nov 08, 2023)

7일 차에는 급하게 구현하느라 엉망이었던 코드를 리펙토링 했다. 나름대로 1,2 주차 공통 피드백은 모두 지키긴 했다고 생각한다.

그렇지만 엉망이 되어버렸던... (당시에는 몰랐던) 구조의 문제를 큰 부분들만 언급해 보겠다. 

 

실제 리뷰/스터디하면서 받았던 질문.. 🥹

1. 이게 controller에요?

나: 네 ㅎㅎ (머리 꽃밭)

public class LottoController {
    private final LottoService lottoService;

    public LottoController(LottoService lottoService) {
        this.lottoService = lottoService;
    }

    public void run() {
        LottoPurchaseAmount validPurchaseAmount = lottoService.getValidPurchaseAmount();
        LottoTicketCount ticketCount = lottoService.getTicketCount(validPurchaseAmount);
        LottoBundle lottoBundle = lottoService.getLottoBundle(ticketCount);
        WinningNumbers validWinningNumbers = lottoService.getValidWinningNumbers();
        BonusNumber validBonusNumber = lottoService.getValidBonusNumber(validWinningNumbers);
        LottoResult lottoResult = lottoService.getLottoResult(lottoBundle, validWinningNumbers, validBonusNumber);
        lottoService.showProfitRate(lottoResult, validPurchaseAmount);
        InputView.readClose();
    }
}

컨트롤러는 stateless 해야 한다.

컨트롤러는 서비스와 뷰를 이어주는 역할, 그 이상 이하도 아니다. 직접 모델을 선언해서는 안된다.

 

2. Service 계층이 뭐라고 생각하세요?

나: 비즈니스 로직을 처리하는 데요..

 

나는 무려 서비스 계층이 4개였다. LottoResultService(로또 결과를 만든다), LottoTicketService(로또를 생성한다), WinningNumberService(정답번호를 생성한다), 그리고 이 3개를 관리하는 LottoService.

지금 생각해 보면 앞선 3개는 전부 모델에서 처리했으면 되는 부분이고, LottoService 하나만 있었어도 되었을 것 같다.

public class LottoService {

    private final LottoTicketService lottoTicketService = new LottoTicketService();
    private final WinningNumberService winningNumberService = new WinningNumberService();
    private final LottoResultService lottoResultService = new LottoResultService();

    public LottoService() {
    }

    public LottoPurchaseAmount getValidPurchaseAmount() {
        while (true) {
            try {
                OutputView.printPurchaseAmountMessage();
                return lottoTicketService.parsePurchaseAmount(InputView.read());
            } catch (IllegalArgumentException e) {
                OutputView.printErrorMessage(e.getMessage());
            }
        }
    }

    public WinningNumbers getValidWinningNumbers() {
        while (true) {
            try {
                OutputView.printWinningNumbersMessage();
                return winningNumberService.createWinningNumbers(InputView.read());
            } catch (IllegalArgumentException e) {
                OutputView.printErrorMessage(e.getMessage());
            }
        }
    }

    public BonusNumber getValidBonusNumber(WinningNumbers winningNumbers) {
        while (true) {
            try {
                OutputView.printBonusNumberMessage();
                return winningNumberService.createBonusNumber(InputView.read(), winningNumbers.numbers());
            } catch (IllegalArgumentException e) {
                OutputView.printErrorMessage(e.getMessage());
            }
        }
    }

    public LottoTicketCount getTicketCount(LottoPurchaseAmount purchaseAmount) {
        LottoTicketCount ticketCount = lottoTicketService.calculateTicketCount(purchaseAmount);
        OutputView.printTicketCountMessage(ticketCount.count());
        return ticketCount;
    }

    public LottoBundle getLottoBundle(LottoTicketCount ticketCount) {
        LottoBundle lottoBundle = lottoTicketService.generateLottoBundle(ticketCount.count());
        OutputView.printLottoBundle(lottoBundle);
        return lottoBundle;
    }

    public LottoResult getLottoResult(LottoBundle lottoBundle, WinningNumbers winningNumbers, BonusNumber bonusNumber) {
        OutputView.printResultMessage();
        LottoResult lottoResult = lottoResultService.calculateResults(lottoBundle, winningNumbers, bonusNumber);
        OutputView.printLottoResult(lottoResult);
        return lottoResult;
    }

    public void showProfitRate(LottoResult lottoResult, LottoPurchaseAmount purchaseAmount) {
        ProfitRate profitRate = lottoResultService.calculateProfitRate(lottoResult, purchaseAmount);
        OutputView.printProfitRate(profitRate);
    }
}

 

정말 거를 타선 없이 LottoService가 총체적 난국이라서 전체 코드를 가져와 봤다.

 

기껏 inputView, outputView를 정의해 놓고, Service까지 들어와서 view단를 처리하고 있는 것!!

인풋 핸들링을 Service에서 하고 있는 것!!!

객체를 만들어서 굳이 굳이 controller 단으로 넘기고 있는 것!!!!!!!!!

도대체 무슨 생각을 이렇게 했던 건지 알 수 없다!!!!!

 

아무래도 로또 미션이 너무 어려워서, 구현하기 급급해, 깊게 고민하지 못한 것 같다.. 🥹 

 

3. 왜 Service 계층을 이렇게 짰어요?

나: 모델이 무거워지는 게 싫어서요..

리뷰어: 서비스 계층이 무거워지는 건 생각 안 하세요?

public class LottoResultService {
    private final int DEFAULT_COUNT = 0;
    private final int INCREASE_NUM = 1;
    private final int PERCENT_MULTIPLIER = 100;


    public LottoResult calculateResults
            (LottoBundle lottoBundle, WinningNumbers winningNumbers, BonusNumber bonusNumber) {
        EnumMap<RankCategory, Integer> results = getInitializedEnumMap();
        for (Lotto lotto : lottoBundle.lottoBundle()) {
            int matchingNumbers = countMatchingNumbers(winningNumbers, lotto);
            boolean bonusStatus = checkBonusStatus(bonusNumber, lotto);
            RankCategory rankCategory = of(matchingNumbers, bonusStatus);
            results.put(rankCategory, results.get(rankCategory) + INCREASE_NUM);
        }
        return new LottoResult(results);
    }

    public ProfitRate calculateProfitRate(LottoResult lottoResult, LottoPurchaseAmount lottoPurchaseAmount) {
        int profitSum = getProfitSum(lottoResult);
        return new ProfitRate(((double) profitSum / lottoPurchaseAmount.amount()) * PERCENT_MULTIPLIER);
    }

    private EnumMap<RankCategory, Integer> getInitializedEnumMap() {
        EnumMap<RankCategory, Integer> results = new EnumMap<>(RankCategory.class);
        for (RankCategory rankCategory : values()) {
            results.put(rankCategory, DEFAULT_COUNT);
        }
        return results;
    }

    private int countMatchingNumbers(WinningNumbers winningNumbers, Lotto lotto) {
        List<Integer> lottoNumbers = lotto.numbers();
        return (int) winningNumbers.numbers().stream()
                .filter(lottoNumbers::contains)
                .count();
    }

    private boolean checkBonusStatus(BonusNumber bonusNumber, Lotto lotto) {
        return lotto.numbers().contains(bonusNumber.number());
    }

    private int getProfitSum(LottoResult lottoResult) {
        int profitSum = DEFAULT_COUNT;
        for (RankCategory rankCategory : values()) {
            int rankCount = lottoResult.getResults().get(rankCategory);
            profitSum += rankCount * rankCategory.getPrize();
        }
        return profitSum;
    }

}

지금 보면 전부 객체가 직접 처리할 수 있었을 것 같은 부분들.. 이 전부 서비스단으로 빠져있다.

calculateResults 부분 말고는 모델에서 모든 것을 해결할 수 있었을 것이다.

 


회고글을 작성했을 뿐인데.. 마음이 너무 아프다.. (아마도 나름 1주일 동안 열심히 짠 코드였어서 그랬겠지)

 

사실 3주 차 리뷰를 받기 전까지는 MVC 패턴에 대한 감을 전혀 잡지 못해서 이런 식으로 짰던 것 같다.

그래도 나름대로 메서드 분리, 서비스 분리, 모델 분리를 열심히 하려고 노력했기 때문에, 리펙토링이 엄청 어려울 것 같다고 생각하지는 않는다. (개인적으로는 계층 분리만 잘 안되어있다 뿐이지, 그래도 보기는 나쁘지 않은 코드라고 생각해서,,_아님 말고)

 

테스트 코드도 모듈 단위로 열심히 짰고, 기능 분리도 열심히 했고, 자바 문법도 최대한 적용시키려고 노력했고..

그래도 2주 차 보다 3주 차에 한 톨만큼이라도 성장했던 건 맞지 않을까?

 

3주차 리뷰받았던 사항

  1. 로또 금액의 데이터 타입은 int가 아니라 long으로 관리되어야 한다. >> 이를 알기 위해 테스트를 큰 값으로 진행해 보았어야 했다.
  2. mvc 패턴에서 controller, service, model의 역할
  3. 객체를 리턴 시, unmodifiable**을 추가해서 객체의 불변성을 유지할 것
  4. 자바 표준 라이브러리에 NPE(NumberFormatException)이 있는데, 동일한 이름의 커스텀 exception을 만들었던 것
더보기

2주 차에 처음으로 MVC 패턴을 적용해 보고, 많은 분께 리뷰를 요청드렸습니다. 기술적인 것부터, 마인드, 태도까지 정말 많은 것을 배웠습니다.

리뷰를 바탕으로 "자동차 경주" 미션에서 제 코드의 약점을 분석해 봤을 때 다음과 같았습니다.

  1. 클래스, 또는 메서드가 한 번에 하나의 역할만 하지 않고 있다. 그 결과로, 객체 간 의존성이 높다.
  2. 저번 주차 목표가 "함수별로 테스트를 작성하는 것"을 목표로 하고 있었음에도, 테스트가 비교적 큰 단위부터 동작하고 있다.

두 가지 문제 모두, 기능 명세서임에도 "기능 요구사항"의 흐름대로 기능이 작성된 것이 아니라, 각 클래스와 메서드가 제공하는 기능을 기능 명세서에 작성하고 있었기 때문에, "기능 간 분리가 불명확해져서"라는 생각이 들었습니다.

마침 2주 차 공통 피드백으로 README 문서의 상세 작성 및 기능 목록에 관한 언급이 있었기 때문에, 이번 주차에는 docs/README.md에 이전보다 더 신경을 쓰기로 했습니다.

작성할 내용을 구조적인 관점에서가 아니라 “기능 단위"로 작성하기로 했고, 테스트도 가장 작은 단위부터 최대한 세밀하게 작성하는 것을 목표로 잡았습니다.

그러나 디자인 패턴을 적용한다거나, 구조적으로 깔끔한 애플리케이션을 만들기 위해서는 아직 큰 노력이 필요한 단계기 때문에, 기능 설계만 하고 구조 설계를 아예 하지 않기에는, 로또를 어떤 식으로 구현할지 감이 잡히지 않았습니다. 따라서 개발에 들어가기 전, 기능 명세서도 작성하고 구조적 설계도 같이 진행하게 되었습니다.

기능 명세 및 구조 설계를 하기 위해 2주 차에 리뷰를 받은 뒤, MVC 패턴의 특징, Abstract와 Interface의 차이, 일급 컬렉션 및 검증의 분리에 대해 공부를 했고, 이를 설계에 녹여내려고 정말 많이 고민했습니다.

설계하고 난 뒤, 객체 단위로 개발을 진행했습니다.

사실은 일급 컬렉션에 대한 개념이 잘 이해가 가지 않았었는데, 우아한 테크코스 측에서 제공한 Lotto Class에 validate 메서드가 있는 것을 보고, "아 객체는, 자기 자신에 대해 검증을 해야 하는 거구나." "객체가 스스로가 완전한 상태를 유지하게 하는 거구나"라는 확신이 들면서 이해를 할 수 있었습니다.

이 힌트를 계기로, 로또에 필요한 모든 값을 객체로 관리하고, 각 값이 자기 자신에 대해 검증하는 그림을 그려보고자 노력했습니다!

덕분에, validator가 뷰에 대한 검증, 비즈니스 요구사항에 대한 검증, 파싱 등 다양한 책임을 갖고 있던 문제도 해결되었습니다. validator에서 입력에 대한 검증만 하고, 비즈니스 로직에 대한 검증은 각 객체가 진행하도록 함으로써, 1주 차와 2주 차에 공통적으로 받았던 피드백도 해결할 수 있었습니다.

연장선으로, 작은 단위부터 테스트하는 것이 더 수월해졌고, 이들을 조립해 더 큰 단위의 테스트를 진행하는 것 또한 2주 차보다 수월하게 진행할 수 있었습니다.

이번 주차에서 큰 고민을 했던 부분은 "Rank(로또 결과)를 어떤 식으로 표현할까"였습니다. 간단하게 생각하면 Switch Case 문이나, if 문을 여러 번 쓰면 수월하게 구현할 수 있었을 테지만, 뭔가 더 좋은 방법이 있을 것만 같았습니다.

두 방법 모두 우아한 테크코스에서는 권장하지 않기 때문입니다.

반복적인 검색을 통해, EnumMap에 대해 알게 되었습니다. 처음 보는 자료구조다 보니, 사용법에서부터 애를 많이 먹었습니다. 어떻게 값을 호출해야 하는지, 어떤 식으로 초기화해야 하는지도 낯설었지만, 애플리케이션에 적용하고 나니, 어떤 것보다 적절한 선택이었다고 생각합니다. "Java Enum을 적용해야 한다"는 프로그래밍 요구사항의 의도에도 부합하는 선택이었다고 생각합니다.

Rank를 정의할 때는 '완전한 기능을 갖는 클래스'로써 사용했기 때문에, Java Enum을 사용했다고 할 수 있을 것 같습니다. (제가 지금까지 상수를 정의하는 데 사용했던 건 단순 Enum입니다.)

그러면서 또, 제가 지금까지 미션을 하면서 Enum을 사용하고 있던 방식에 대해서도 고찰을 해봤습니다. 저는 1~2주 차 동안, Enum을 전역적으로 사용되는 상수값을 정의하는 데에만 사용하고 있었습니다.

코드 리뷰를 하면서 봤을 때, 클래스 내부에서 필드 상수로 정의하고 계시는 분들도 많이 계셨고 그 둘 간에 어떤 것이 더 좋은 방식인지 궁금했습니다.

이번 미션에 "값을 하드코딩 하지 않는다"라는 요구사항도 있었으므로, 어떤 식으로 정의를 하는 것이 더 좋은 방향일까를 결정하기 위한 고민을 했습니다. 결론은 Enum Class로 빼는 것은 애플리케이션 전체를 통틀어 사용되는 상수일 때 선언하면 되고, 아니라면 필드로 선언해 줘도 괜찮다로 결론을 내어, 이번 미션에 그렇게 사용했습니다.

그 밖에도 구체적인 Exception을 처리하기 위해 IllegalArgumentException을 상속받아 사용하기, 정규표현식을 이용해 입력 검증하기 등 다양한 시도를 해보면서, 자바라는 언어에 더 깊게 빠질 수 있었습니다.

이번 주차 미션에서, 핵심이 되는 기능들은 모듈별로 단위테스트를 꼼꼼하게 작성했다고 생각합니다. 다만, 아쉬운 부분은 테스트 단위가 조금 커졌을 때, 자동화해서 여러 번 돌리는 법을 아직 공부를 못 해봤습니다. 또한 [학습테스트를 통해 JUnit 학습하기. pdf]의 내용을 전부 적용해보지 못한 것도 아쉬움에 남습니다.

저번 주차에 아쉬웠던 테스트 부분을 이번 주차에 보완해서 꼼꼼히 하고자 노력했으니, 다음 주차에는 후회 없게 이번 주차에 아쉬웠던 테스트 관련 부분을 더 잘 작성해 볼 예정입니다.

저는 시간이 오래 걸리는 건 중요하지 않다고 생각합니다. 할 수 있는 게 중요한 거고, 언젠간 빨라지니까요. 또한, 오래 쏟은 시간에 비례해 성장을 한다고 생각합니다. 3주 차 미션을 끝난 이 시점에서, 코드 리뷰는 도움이 되는 조언도 많지만, 각 조언이 적절한 지 한 번 더 생각한 뒤, 제 것으로 만들어 적재적소 사용하는 것이 훨씬 중요하다는 것을 느낍니다.

결국 코드에 어떤 기술을 사용할지 결정하는 것, 설계는 온전한 제 몫이라고 느꼈으며, 그러고자 노력했습니다. 한 주간 몰입한 덕에 이번 주차 미션을 성공했음에 상당한 뿌듯함을 느끼지만, 그만큼 부족한 부분도 많다 생각합니다. 이번 주차에 대한 리뷰 및 회고를 발판 삼아, 4주 차 미션도 후회 없이 최선을 다해 성장하도록 하겠습니다.

https://github.com/seminss/java-lotto-6/tree/seminss

 

GitHub - seminss/java-lotto-6

Contribute to seminss/java-lotto-6 development by creating an account on GitHub.

github.com