우테코

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

wldud8297 2025. 11. 3. 23:35

0. 과제 설명

8기의 3주차 문제는 로또였다.

사용자에게 구입 금액을 입력 받아, 개수만큼 랜덤 로또를 생성하고,
당첨 로또 번호, 보너스 번호를 입력 받아, 로또 당첨 여부와 수익률 계산해야한다.

변경사항

  1. 에러문을 터미널로 출력해야한다.
  2. 사용자가 잘못된 입력을 하면, 다시 입력받는다.
  3. "입력 → 응답 → 입력 → 응답" 과 같이 입력과 응답이 번갈아서 발생한다.

 

이 세가지 변경 사항이 지난주와 다르게 느껴진 가장 큰 부분이었다.

 


지난주 피드백

우테코 공통 피드백
1. README를 상세히 작성
→ 어떤 프로젝트인지, 주요 기능이 무엇인지
2. 기능 목록을 재검토
→ 클래스 설계나 구현, 메서드 설계같은 상세한 내용 포함 ❌
→ 정상적인 경우뿐 아니라, 예외 상황도 함께 정리
→ 개발 과정에서 꾸준히 업데이트
3. 하드 코딩된 값은 상수(static final)를 정의하고, 의미 있는 이름을 부여
4. 작은 단위의 테스트

 

 

코드 리뷰 피드백
1. 매직 넘버는 의미있은 이름으로 상수화 (반영 ⭕️)
2. 불변 변수와 객체에 final 사용 (반영 ⭕️)
→ 클래스 내부 필드에서 불변 객체
→ 생성자에서 주입 받은 객체
3. 테스트 코드에서 메세지 상수 사용 (반영 ⭕️)
4. 접근 제어자 private 적절하게 사용 (반영 ⭕️)
5. 유틸 클래스 private 생성자와 final 클래스 선언 (반영 ⭕️)
6. dto에서 record 선언 (→ dto 구조에 대해 더 익숙해진 후 반영 예정)

1. 나에게 적용해보기

지난주의 키워드를 "기능별 테스트"로 했지만, 기능별 보다는 계층별 테스트에 가까웠다. 😅

 

계층간의 경계를 더 명확하게 인지하고, 계층간의 의존성에 대해 생각했던 유익한 시간이었으나, 기능이란 무엇인가를 놓쳤던 것 같다.

 

이번주는 작은 단위의 테스트 부터 수행하라는 공통 피드백에 따라서, 좀 더 제대로 작은 단위의 기능 테스트에 집중해보려한다.

오늘의 키워드는 "작은 단위의 기능 테스트"이다 ⭐️

 

1주차의 키워드 "기능"과 2주차의 키워드 "기능별 테스트"를 합친 키워드라고 보면 된다.


2. 개발 시작

2-0.리드미 작성

우선 문제에서 주어진대로 리드미를 작성하였다.

 

공통 피드백 대로 이전 주차보다 상세하게 기능에 대해  작성해 보았다.

 

거의 대부분 기능명으로 커밋을 진행하였다.


파일 구조

파일 구조는 지난주와 유사하다.

 

다만, 많은 분들이 코드에서 외부 라이브러리 호출하는 클래스 분리하둔것이 인상적이어서,

나도 util 클래스를 추가해 외부라이브러리를 호출했다.

Controller (Controller, Message, View)
Service (Service, Dto)
Domain
Exception (ErrorMessage, Validator)
Util (RandomGenerator) (추가)


2-1. Message 작성

1) 출력 메세지 폼 작성
2) 에러 메세지 폼 작성

 

이번주도 Message는 생략하도록 하겠다. (자세한 사항은 1주차 계산기에서 확인)


2-2. Controller 작성

1) 구입 금액 입출력 기능 추가
2) 당첨번호 입출력 기능 추가
3) 보너스 번호 입출력 기능 추가

 

이번에는 3번의 입력을 받는다.

 

지난주와 다른 점은 "입력 → 출력 → 입력" 과 같이 입력과 출력이 번갈아서 발생한다는 것이다.

 


당첨번호 입출력 기능 추가

 

1) view에서 입출력 구현

//View.py
public String requestTargetLotto(){
    printMessage(Message.REQUEST_TARGET_LOTTO);
    String message = getNotNullMessage();
    return message;
}

 

2) Controller에서 view 호출

// LottoController.java
private String getTargetLotto(){
    try {
        return view.requestTargetLotto();
    }catch(IllegalArgumentException e)
    {
        view.printMessage(e.getMessage());
        return null;
    }
}

 

3) Controller에서 반복 실행

 

에러가 있으면 에러문을 출력하고, 다시 입력을 받는다. 최대 입력 횟수는 5회로 제한한다.

(안그러면 Controller 예외 테스트할 때, 무한루프 돔)

// LottoController.java
public void inputTargetLotto(){
    String numbers = null;
    int count = 1;
    while (numbers == null && count <= 5){
        numbers = getTargetLotto();
        count++;
    }
    if (numbers != null){
    }
}

 

4) Controller 메인 함수에서 실행

 

 

5) Test (선택)

 

우선 작동하는지는 전체에서 방금 작성한 부분 주석을 해제해서 실행시켜본다.
(가장 먼저 작성했기 때문에, 아직 로직이 없어서 아래와 같은 테스트 밖에 안된다.)

 

 

6) README (선택)

 

우테코 공통 피드백 대로, 개발 과정에서 리드미를 업데이트한다.
기능 개발을 완료한 것은 체크 표시한다.

 

* 입력값 검증에 대해서는 [2-4. Validator 작성 참고]


2-3 Controller Test 작성

나는 Controller Test를 주로 입력값 검사가 잘 작동하는지 확인하는 용도로 사용하였다.

// LottoControllerTest.java
@ParameterizedTest
@ValueSource(strings = {"", " "})
void 구입금액_공백_예외_테스트(String text) {
    assertSimpleTest(() -> {
        runException(text);
        assertThat(output()).contains(ErrorMessage.IS_NULL_ERROR.getMessage());
    });
}

 

왜냐하면, 기능 테스트라고 하기에는 아직 로직이 없고, 단순 Message.java에 적어둔것이 잘 출력되는지 확인하는 정도이기 때문이다. (로직을 거친 출력문은 ApplicationTest에서 검증하니까, 기능 테스트에 대해서는 ControllerTest의 역할이 크지 않음)

 

Controller에 있는 유일한 기능 테스트 코드인데, Message.java에 있는 모든 메세지들을 포함하고 있다. 처음에는 주석처리 했다가, 출력문 구현 기능을 완성하면, 해당 부분을 주석 해제해서 출력이 잘 되는지 확인했다.

// LottoControllerTest.java
@Test
void 기능_테스트() {
    assertRandomUniqueNumbersInRangeTest(
            () -> {
                run("5000", "1,2,3,4,5,6", "7");
                assertThat(output()).contains(
                        "구입금액을 입력해 주세요.",
                        "5개를 구매했습니다.",
                        "[1, 8, 11, 31, 41, 42]",
                        "[13, 14, 16, 38, 42, 45]",
                        "[7, 11, 30, 40, 42, 43]",
                        "[2, 13, 22, 32, 38, 45]",
                        "[1, 3, 5, 14, 22, 45]",
                        "당첨 번호를 입력해 주세요.",
                        "보너스 번호를 입력해 주세요.",
                        "당첨 통계",
                        "---",
                        "3개 일치 (5,000원) - 1개",
                        "4개 일치 (50,000원) - 0개",
                        "5개 일치 (1,500,000원) - 0개",
                        "5개 일치, 보너스 볼 일치 (30,000,000원) - 0개",
                        "6개 일치 (2,000,000,000원) - 0개",
                        "총 수익률은 62.5%입니다."
                );
            },
            List.of(1, 8, 11, 31, 41, 42),
            List.of(13, 14, 16, 38, 42, 45),
            List.of(7, 11, 30, 40, 42, 43),
            List.of(2, 13, 22, 32, 38, 45),
            List.of(1, 3, 5, 14, 22, 45)
    );
}

2-4. Validator 작성

1) 구입 금액 입력값 검사 추가
2) 당첨 번호 입력값 검사
3) 보너스 번호 입력값 검사 

 

Validator에서는 입력값 검사에 관한 코드를 작성하였고, 검증하고 에러를 던지는 책임을 가지도록 하였다. 지난주와 다르게 이번에는 도메인 검사를 Validator가 아닌 도메인 내부에 구현했다.

 

Validator에서 던진 에러는 controller에서 try-cath로 잡아서, 다시 사용자 입력을 받도록 할 것이다. 사용자 입력을 다시 받기 위해서, 값에 대한 검사를 서비스나 도메인으로 미루지 않고, controller 계층에서 최대한 수행하도록 하였다.


입력값 검사에 대한 변경 사항 📝 

 

기존에는 Validator에서 에러만 바로 던졌다면, 이번에는 변경 사항이 있다.

 

1) IlllegalArgumentException 던지기
2) 에러 메세지를 터미널에 출력하기 (추가)
3) 입력을 다시 받기 (추가)
+Error를 명확한 유형으로 처리하기

 

2,3번을 추가로 처리해줘야한다.

 

validator는 기존처럼 에러를 던지고, Controller에서는 그 에러를 잡고,
View는 Controller의 요청대로 에러문을 출력하도록 하겠다.

 


구입 금액 입력값 검사 추가

 

1) 테스트 케이스 작성

 

입력값 검사는 controller 계층에서 수행되므로, 위에서 작성한 controller test 내용과 유사하다.

 

// LottoControllerTest.java
@ParameterizedTest
@ValueSource(strings = {"ㄴ", "s","."})
void 구입금액_숫자아님_테스트(String text) {
    assertSimpleTest(() -> {
        runException(text);
        assertThat(output()).contains(ErrorMessage.IS_NULL_ERROR.getMessage());
    });
}

 

2) 에러 확인

 

어떤 에러를 던졌는지 꼭 확인해야한다. 그래야 어떤 에러를 처리할지를 알고, 테스트 케이스를 잘 썼는지도 확인이 가능하다.

 


3) 검증기 작성

 

잡은 에러를 모두 IllegalArgumentException로 던진다.

 

// Validator.java
public static void validateIntegerValue(String message){
    try{
        Integer.parseInt(message);
    } catch(NumberFormatException e){
        throw new IllegalArgumentException(ErrorMessage.NOT_NUMBER_ERROR.getMessage());
    }
}

 

IllegalArgumentException과 IllegalStateException은 뭐가 다른가요? 🙋🏻‍♀️

 

IllegalArgumentException : 메서드 “입력값 자체가” 유효하지 않음
IllegalStateException : 객체의 상태(state) 가 메서드 호출에 적합하지 않음


4) 검증기 호출

 

View와 Controller에서 검증기를 호출해 입력값을 검사한다.

 

[공백, 숫자, 문자]와 같이 바로 값을 검사할 수 있는건 View에서 검증기를 호출하고, 문자열로 들어와 구분자로 분리하는 것과 같이 파싱이 필요한 것은 controller에서 처리한 후 검증기를 호출했다.

 

(View는 파싱에 대한 책임을 지고 있지 않기 때문에, Controller에서 검증기 호출함)

 

*위에서는 Validator에서 값 변환의 책임을 주지 않으려고, 모든 검증기를 void로 선언했는데, 경우에 따라서는 검증의 결과로 바로 숫자를 반환하는 것도 좋을 듯.


5) Controller에서 에러 처리

 

던진 에러는 모두 controller에서 try-catch로 잡는다.

 

// LottoController.java
// 제대로 된 값이 반환될 때까지, 반복한다. (최대 입력 요청 횟수는 5회로 제한)
public void purchaseLotto(){
    Integer price=null;
    int count=1;  // 입력 요청 횟수
    while (price==null && count<=5){
        price = getPurchasePrice();
        count++;
    }

}

// Validator에서 던진 에러를 잡아서, View에게 에러문 출력 요청한다.
public Integer getPurchasePrice(){
    try {
        return Integer.parseInt(view.requestPurchasePrice());
    } catch(IllegalArgumentException e)
    {
        view.printMessage(e.getMessage());
        return null;
    }
}

참고로 Validator에서 검증 모음 메서드를 만들어서, 그걸로 View에서 호출하면, 나중에 검증이 추가해도 View 변경 없이 Validator 검증 모음 메서드에만 추가하면 된다.


당첨번호 입력값 검사

 

당첨 번호는 ","로 구분되는 문자열로 입력된다.

빈 값 검증은 위와 동일하게 가능하지만, 나머지 검증은 리스트를 쪼개야 가능한 검증이다.

 


1) View에서 검증기 호출

 

빈값 검증은 View에서 바로 호출해서 검증한다.

// View.java
private String getNotNullMessage() {
    try {
        String message = getInputMessage();
        Validator.validateNotEmptyValue(message);
        return message;
    } catch (NoSuchElementException e) {
        throw new IllegalArgumentException(ErrorMessage.IS_NULL_ERROR.getMessage());
    }
}


2) Controller에서 검증기 호출

 

빈값 검증을 마친 문자열을 View에게 반환 받고, Controller에서는 그 값을 정수 리스트로 쪼갠 후 검증기를 호출한다.

// LottoController.java
private List<Integer> getTargetLotto() {
    try {
        List<Integer> numbers = changeToInt(List.of(view.requestTargetLotto().split(Constant.SPLIT_DELIMETER)));
        Validator.validateLottoLength(numbers);
        Validator.validateNumbersAllInRange(numbers);
        Validator.validateUniqueNumber(numbers);
        return numbers;
    } catch (IllegalArgumentException e) {
        view.printMessage(e.getMessage());
        return null;
    }
}

2-5. Validator Test 작성

validator는 Controller 테스트에서 시행된다.

README에 작성해둔 대로 테스트가 하나씩 만들어진다.

 

 

아래는 보너스 번호에 대한 입력값 검사 테스트(예외테스트) 들이다.

README에 작성한 대로 하나씩 검증이 가능하다. assertThat의 contains를 사용해 세부적으로 에러메세지가 맞는지 확인하였다.

 


2-6. Domain 작성

1) Lotto 도메인 객체 생성
2) Record 도메인 객체 생성 ( → [4. 리팩토링] 참고)
3) 당첨 등수 및 상금을 Enum으로 분리
4) 도메인 검사 기능 추가


Lotto 도메인 객체 생성

 

우테코에서 Lotto 객체를 제공해주었고, 필드를 변경하면 안된다 했기 때문에 그래도 두었다.

 

 

Lotto가 해야하는 행동은 다음과 같이 정의했다.

- 랜덤 로또와 당첨 로또 비교 (당첨 개수 계산)
- 보너스 번호 당첨 여부 파악
- 랜덤 넘버 값 반환
→ 리스트는 반드시 copy.of를 한 후 반환❗️

 

// domain/Lotto.java
public int compareLotto(List<Integer> num) {
    Set<Integer> targetLotto = new HashSet<>(num);
    Set<Integer> randomLotto = new HashSet<>(numbers);

    targetLotto.retainAll(randomLotto);
    return targetLotto.size();
}

public boolean checkBonusNumber(Integer bonusNum) {
    if (numbers.contains(bonusNum) == true) {
        return true;
    }
    return false;
}

public List<Integer> getNumbers() {
    return List.copyOf(numbers);
}

당첨 등수 및 상금을 Enum으로 분리

 

당첨 등수, 상금, 맞힌 개수, 관련 출력문 등의 값을 한꺼번에 매핑해두고 싶어, 여러 상태값을 가질 수 있는 Enum을 사용했다.

 

이때, 맞힌 개수를 Enum 값에 포함시키면, 변수처럼 호출할 수 있을 것 같아서 "LOTTERY+(맞힌개수)"로 Enum값을 정했다. 추가로 맞힌 개수가 5개일 때, 보너스 번호 여부를 표시하기 위해 "LOTTERY5"에 추가로 b를 붙여 "LOTTERY5b"를 사용하였다.

 

// domain/lottery.java
package lotto.domain;

public enum Lottery {
    NOT_PRIZE("2개 이하 일치(상금 없음", 0),
    LOTTERY3("3개 일치 (5,000원)", 5000),
    LOTTERY4("4개 일치 (50,000원)", 50000),
    LOTTERY5("5개 일치 (1,500,000원)", 1500000),
    LOTTERY5b("5개 일치, 보너스 볼 일치 (30,000,000원)", 30000000),
    LOTTERY6("6개 일치 (2,000,000,000원)", 200000000);

    private final String message;
    private final Integer prize;

    Lottery(String message, Integer prize) {
        this.message = message;
        this.prize = prize;
    }

    public String getMessage() {
        return message;
    }

    public Integer getPrize() {
        return prize;
    }
}

 

아래는 "LOTTERY + 맞힌개수" 문자열을 만드는 메서드이다.
Lottery enum 값에서 문자열과 매칭되는 값을 찾으면, 쉽게 enum의 상태값을 가져올 수 있었다.

// domain/Record.java
private Lottery getPrizeType(int count, boolean isbonus) {
    if (count < 3) {
        return Lottery.NOT_PRIZE;
    }

    String message = "LOTTERY" + count;
    if (isbonus && count == 5) {
        message += "b";
    }

    Lottery type = Lottery.valueOf(message);
    return type;
}

도메인 검사 기능 추가

 

이번에는 도메인 내에서 도메인 검사를 실행했다.

 

이전과 같이 Validator에 static final 메소드를 만들어두고 호출할까 했지만,
도메인 객체가 스스로의 상태에 대해 검증하고, 값을 보장하게 하기 위해서 내부에 선언하였다.

(Validator 호출한다면, 검증의 책임이 Validator로 넘어가기 때문)

 

대신 null이나 빈 값 검증의 경우는 모든 애플리케이션에서 공통으로 사용되는 검증이기 때문에, 이건 Validator를 호출해서 사용했다. (빈값이 들어오면 안된다는 도메인의 비즈니스 로직이 아님)

 

// domain/Lotto.java
package lotto.domain;
//(import 생략)

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

    private Lotto(List<Integer> numbers) {
        this.numbers = new ArrayList<>(numbers);
    }

    public static Lotto buildLotto(List<Integer> numbers) {
        validateLottoLength(numbers);
        validateUniqueNumber(numbers);
        validateNumberInRange(numbers);

        Lotto lotto = new Lotto(numbers);
        Collections.sort(lotto.numbers);
        return lotto;
    }

    private static void validateLottoLength(List<Integer> numbers) {
        if (numbers.size() != Constant.LOTTO_LENGTH) {
            throw new IllegalArgumentException(ErrorMessage.LOTTO_NUMBER_LENGTH_ERROR.getMessage());
        }
    }

    private static void validateUniqueNumber(List<Integer> numbers) {
        Set<Integer> nums = new HashSet<>(numbers);
        if (nums.size() != Constant.LOTTO_LENGTH) {
            throw new IllegalArgumentException(ErrorMessage.NOT_UNIQUE_LOTTO_NUMBER.getMessage());
        }
    }

    private static void validateNumberInRange(List<Integer> numbers) {
        for (int num : numbers) {
            // 빈값과 null값 검증은 Validator 호출
            Validator.validateNotEmptyValue(Integer.toString(num));
            if (num < Constant.MIN_VALUE || num > Constant.MAX_VALUE) {
                throw new IllegalArgumentException(ErrorMessage.LOTTO_NUMBER_NOT_IN_RANGE.getMessage());
            }
        }
    }

2-7 Domain Test 작성

도메인 테스트에서는 도메인의 메소드와 도메인 검증 테스트를 할 수 있다.

 

1) 도메인 객체 생성 테스트

  // LottoTest.java
@Test
void 로또_객체생성_테스트() {
    assertThatCode(() -> Lotto.buildLotto(List.of(1, 2, 3, 4, 5, 6)))
            .doesNotThrowAnyException();
}

 

2) 도메인 검증 테스트

// LottoTest.java
@Test
@DisplayName("로또_번호의_개수가_6개가_넘어가면_예외가_발생한다")
void 로또_번호_개수초과_예외_테스트() {
    assertThatThrownBy(() -> Lotto.buildLotto(List.of(1, 2, 3, 4, 5, 6, 7)))
            .isInstanceOf(IllegalArgumentException.class);
}

 

3) 도메인의 메소드 테스트

@DisplayName("로또 번호는 오름차순 정렬되야한다.")
@Test
void 로또_정렬_테스트() {
    Lotto lotto = Lotto.buildLotto(List.of(1, 2, 32, 4, 3, 5));
    List<Integer> expected = lotto.getNumbers();
    List<Integer> actual = List.of(1, 2, 3, 4, 5, 32);

    assertThat(expected).containsExactlyElementsOf(actual);
}

2-8. Service 작성

1) Randoms 라이브러리로 로또 생성 추가
2) 구입한 로또 개수 계산 및 개수만큼 로또 생성
3) 랜덤 로또 DTO 생성
4) 로또 당첨 결과와 수익률 DTO 생성


- Randoms 라이브러리로 로또 생성 추가
- 구입한 로또 개수 계산 및 개수만큼 로또 생성

 

1) Randoms 라이브러리 클래스 분리

 

외부 라이브러리이기 때문에 util로 따로 클래스를 분리해주었다.

  // util/RandomGenerator.java
package lotto.util;

public class RandomGenerator {
    private static final int START_NUMBER = 1;
    private static final int END_NUMBER = 45;
    private static final int COUNT = 6;

    public static List<Integer> getRandomLottoNumber() {
        return Randoms.pickUniqueNumbersInRange(START_NUMBER, END_NUMBER, COUNT);
    }

}

 

2) DTO 생성


랜덤 로또들의 결과를 Controller에 전송하기 위해 Dto를 사용하였다.

// LottoDto.java
package lotto.service;

public class LottoDto {
    private final List<List<Integer>> randomLottos;
    private final Integer amount;

    public LottoDto(List<List<Integer>> randomLottos, Integer amount) {
        this.randomLottos = randomLottos;
        this.amount = amount;
    }

    public List<List<Integer>> getRandomLottos() {
        return randomLottos;
    }

    public Integer getCount() {
        return amount;
    }
}

 

3) Service에서 RandomGenerator 호출 및 dto 생성

 

Service에서 RandomGenerator를 호출해 랜덤 로또를 만들고 DTO를 생성해 controller에게 반환한다.

  // LottoService.java
public LottoDto generateRandomLottos(int price) {
    int amount = calculateLottoCount(price);
    addRandomLottos(amount);

    List<List<Integer>> lottoNumbers = randomLottos.stream()
            .map(Lotto::getNumbers)
            .toList();
    return new LottoDto(lottoNumbers, amount);
}

private void addRandomLottos(int amount){
    for (int i = 1; i <= amount; i++) {
        Lotto lotto = makeLotto();
        randomLottos.add(lotto);
    }
}

private Lotto makeLotto(){
    return Lotto.buildLotto(RandomGenerator.getRandomLottoNumber());
}

private int calculateLottoCount(int price) {
    return price / Constant.MONEY_UNIT;
}

로또 당첨 결과와 수익률 DTO 생성

 

당청여부와 수익률은 모두 도메인이 책임지고 결과를 반환해줄 것이다.

당첨 여부를 계산하기위해서는 지금까지의 로또 번호, 사용자가 입력한 번호들을 모두 가지고 도메인에게 넣어줘야한다.

 

위의 메소드에서 Service의 필드값으로 저장해둔 randomLottos를 가지고 와서 도메인에게 넣어준다.

DTO를 생성해서 controller에서 결과를 반환한다. 여기에 필요한 행동들은 모두 도메인에서 책임을 갖기 떄문에 service는 흐름 조정과 DTO 생성의책임을 맡는다.

// LottoService.java
package lotto.service;

public class LottoService {
    private final List<Lotto> randomLottos; // 이전 메소드에서 필드값 저장함

    public LottoService() {
        this.randomLottos = new ArrayList<>();
    }

    public ResultDto generateResult(List<Integer> targetLotto, Integer bonusNumber) {
        Record record = Record.buildRecord();
        record.calculateWinning(targetLotto, bonusNumber, randomLottos);
        return new ResultDto(record.getPrizeRecord(), record.calculateWinningRate());
    }

2-9. Service 테스트 작성

Service 테스트는 dto 생성하는 메소드를 제외하고는 모두 private으로 설정해두었기 때문에, dto 메소드만 테스트했다.

// LottoServiceTest.java
package lotto.service;

class LottoServiceTest {

    @Test
    void 구입한_개수만큼_로또랜덤생성_테스트() {
        LottoService lottoService = new LottoService();
        assertRandomUniqueNumbersInRangeTest(
                () -> {
                List<List<Integer>> result = lottoService.generateRandomLottos(5000);
                List<List<Integer>> expected = new ArrayList<>();
                expected.add(List.of(8, 21, 23, 41, 42, 43));
                expected.add(List.of(3, 5, 11, 16, 32, 38));
                expected.add(List.of(7, 11, 16, 35, 36, 44));
                expected.add(List.of(1, 8, 11, 31, 41, 42));
                expected.add(List.of(13, 14, 16, 38, 42, 45));
                assertThat(result).containsExactlyElementsOf(expected);

                },
                List.of(8, 21, 23, 41, 42, 43),
                List.of(3, 5, 11, 16, 32, 38),
                List.of(7, 11, 16, 35, 36, 44),
                List.of(1, 8, 11, 31, 41, 42),
                List.of(13, 14, 16, 38, 42, 45)
        );
    }

}

 

[실행결과]

 

 

값이 틀리면 에러가 나는걸 보니 제대로 테스트가 되는것같다.


4. 리팩토링

1) 문제 상황


처음에는 Winning이라는 객체를 설정해 당첨에 관련된 모든걸 관리하려고 했었다. 그러다 보니 도메인에 너무 많은 역할이 들어갔다.

 

[리팩토링 전 Winning 도메인]

 

2) 리팩토링

도메인을 가볍게 만들 수 있는 가장 효과적인 방법은 필드 수를 줄이는 것이라고 생각한다. 불필요한 필드를 갖고 있지는 않은지 검토하고, 정적 메소드 팩토리를 사용해 도메인을 다시 리팩토링 하였다.

변경 사항
1. randomLottos는 당첨 여부를 비교하는 하나의 메소드에서만 사용된다. 
→ 도메인 필드로 두지 않고 메소드 파라미터로 한번만 전달
2. Winning 보다는 기록 저장을 위한 Record로 이름 변경
3. 객체 생성에 대한 책임을 도메인에게 주고, 캡슐화하기 위해 정적 메소드 팩토리 사용

 

 

아래와 같이 Record 도메인 객체가 깔끔해진 것을 확인할 수 있다.

 

 

 

3) Test 

 

도메인 내의 메소드 중 service가 호출하는 public 메소드들을 검증하였다.

// RecordTest.java
package lotto.domain;

class RecordTest {
    @Test
    void 기록_객체생성_테스트() {
        assertThatCode(() -> Record.buildRecord())
                .doesNotThrowAnyException();
    }

    @Test
    @DisplayName("NOT_PRIZE를 제외한 등수별 당첨 내역이 반환된다.")
    void 기록_등수별_당첨내역_반환_기능_테스트() {
        // given
        Record record = Record.buildRecord();
        record.updateRecord(3, false); // LOTTERY3
        record.updateRecord(4, false); // LOTTERY4
        record.updateRecord(2, false); // NOT_PRIZE

        // when
        Map<String, Integer> result = record.getPrizeRecord();

        // then
        assertThat(result)
                .containsEntry(Lottery.LOTTERY3.getMessage(), 1)
                .containsEntry(Lottery.LOTTERY4.getMessage(), 1)
                .doesNotContainKey(Lottery.NOT_PRIZE.getMessage());
    }

    @Test
    @DisplayName("수익률이 소수점 첫째 자리까지 계산된다.")
    void 수익률_계산_기능_테스트() {
        // given
        Record record = Record.buildRecord();
        record.updateRecord(3, false); // 5,000원
        record.updateRecord(4, false); // 50,000원
        record.updateRecord(4, false); // 50,000원
        record.updateRecord(2, false); // 제외

        // when
        String rate = record.calculateWinningRate();

        // then
        // total = 105,000, money = 4개 × 1000
        // total / money × 100 = 105000 / 4000 × 100 = 2625.0
        assertThat(rate).isEqualTo("2625.0");
    }

}

4. 테스트

"기능 테스트"라는 것이 무엇일까 생각해봤는데,
클래스 외부에서 호출하는 public 메서드 단위도 기능이라고 볼 수 있을 것 같다.

  ex. Service가 Record 도메인의 calculateWinning()을 호출 
  → Record 도메인 테스트에서 calculateWinning() 테스트 하기


1) Controller 테스트

 

 

2) Domain 테스트

 

 

3) Service 테스트

 

 

4) Application 테스트


5. 우테코 과제 제출

 

PR 링크 : https://github.com/woowacourse-precourse/java-lotto-8/pull/278

 

[로또] 박지영 미션 제출합니다. by ParkJiYeoung8297 · Pull Request #278 · woowacourse-precourse/java-lotto-8

java-lotto-precourse 요구사항 사용자에게 구입 금액을 입력 받아, 개수만큼 로또를 랜덤 생성하고, 당첨 번호, 보너스 번호를 입력 받아, 로또 당첨 여부와 수익률 계산 동작 사용자에게 구입 금액

github.com

 

소감문 : 

이번 주에는 "작은 단위의 기능 테스트"라는 키워드를 가지고 코드를 구현했다.

 

지난주에 계층별 테스트를 구현하면서, 너무 계층에 집중한 나머지 기능이라는 단위를 놓친 것이 아쉬웠다. 우아한 테크 코스에서도 단위 테스트를 수행하고, README 기능 단위를 상세히 작성하라는 공통 피드백이 있었다. 그래서 이번 주는 계층 내에서 최대한 작은 기능 단위로 테스트해 보려고, "작은 단위의 기능 테스트"라는 키워드를 선정했다.

 

결론적으로, 기능 단위로 테스트하라는 것은 세세한 모든 메서드를 테스트하는 것이 아니라, 외부에서 접근하고 결과를 반환하는 단위로 테스트하라는 뜻이라는 깨달았다. 가장 작은 단위 테스트를 위해서는 기능을 최대한 작은 단위로 쪼개는 과정이 필요했다. 모든 메서드를 public으로 두는 것은 캡슐화를 해친다고 생각했다. 그래서 쪼갠 메서드 외부에서 접근이 필요하지 않은 메서드들은 private으로 선언했다. 하지만, private 선언할 경우 Test 파일에서 접근할 없는 문제가 생겼다. 테스트를 위해서 원본 코드를 public으로 고친다는 것은 본질을 해치는 것이라고 생각했다. 테스트는 원본 코드를 보조해 주는 수단이지, 테스트가 목적이 되어서는 안되기 때문이다. 그래서 고민한 결과 외부에서 접근하는 public 메서드 단위로 테스트를 진행하면 된다는 것을 발견했다. 해당 메서드를 테스트하면, 자동으로 메서드 안에서 호출하는 private 메서드들의 값도 검증할 있었다. 모호했던 테스트 단위에 대하 나름의 구체적인 기준을 가질 있었다.

 

작은 단위의 테스트를 수행하니, 마치 테스트 코드에서 디버깅을 하는 듯한 효과를 얻었다. Domain Service 테스트가 모두 통과됐는데, ApplicationTest 실패한 것을 확인하고, Controller에서 dto controller 받으려는 값이 다르다는 문제점을 빠르게 확인할 있었다. 지난주 공통 피드백으로 디버깅을 배웠었는데, 하나씩 breakpoint 직접 지정하는 디버깅을 테스트 코드를 통해 자동화한 같았다. 덕분에 코드의 변경사항이 발생했을 때도, 테스트 코드를 실행하면 손쉽게 문제가 없다는 사실을 보장해 주니, 코드 리팩토링에 대한 두려움도 없어졌다.

 

이번 주에 가장 많은 시간을 고민했던 것은데이터를 어디에 저장할 것인가?”였다. 이번 과제에서는 지난주와 다르게 입력과 출력이 번갈아서 발생한다. Service에서 랜덤 로또를 생성해서 Controller 값을 반환하면, Controller에서 랜덤 로또를 출력한 사용자에게 당첨 번호 입력을 받는다. 그렇다면, 이전에 사용자에게 출력한 랜덤 로또 데이터는 어디에 저장해야 할까? 스프링의 경우 Service 빈이 싱글톤 패턴으로 구현되어, Service 상태를 갖지 않고 statless 하게 둬야 한다고 알고 있었다. Service 상태를 가지면, 멀티 스레드 환경에서 객체를 공유해 데이터가 섞이는 문제가 발생할 있기 때문이다. 스프링은 DB 데이터를 저장하지만, 지금은 DB 없어서 데이터를 어떻게 저장해야 할지 고민했다. Controller에서 다시 dto 보내주는 방식, controller에서 랜덤 로또를 생성하고 service 메서드에 파라미터로 매번 넣어주는 방식 고민했지만, 아직은 선뜻 납득이 되지 않는 방법들이라 조금 공부하고 방법을 찾아봐야 같다.

 

이번 주에는 가장 작은 단위로 테스트를 진행하며, 여러 작은 단위의 테스트들이 모여서 통합 테스트를 통과시키는 과정을 경험했다. “기능 단위로 테스트하라 말에 나의 나름대로외부에서 호출하는 단위가 기능의 단위이다라는 기준점을 세워보는 시간이었다. 물론 모든 경우에 적용되는 정답은 없지만, 스스로 기준을 세워가며 성장하고 있다는 점에서 의미가 있었다. 다음 주에는 데이터를 어떻게 저장할지에 대해 조금 공부해서, 하나의 기준을 세워보도록 하겠다.

 

⬇️ 리팩토링, 트러블 슈팅, 했던 고민까지 포함된 버전을 보고 싶다면 아래의 링크를 추천합니다.

(참고로 길이 글이 매우 깁니다 🫠)

 

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