로또 문제 풀이

이프·2025년 11월 3일

woowacourse

목록 보기
7/9

늘 똑같은 레파토리

5시간 내 기능 구현 - 1주 2주차와 같은 내용으로 생략


이번에는 TDD를 적용해보자!

1주차 과제에서는 오네로가 2주차 과제에서는 오션이 TDD로 과제를 진행했습니다.
스터디원이 TDD를 적용하는 모습을 보고 배우며, 저도 이번 로또 미션을 TDD로 도전을 해봤습니다.

TDD의 커밋 단위

우선 로또 미션에 TDD를 적용하는 과정에 앞서 2주차 때 오션이 했던 질문이 떠오르더군요 🤔

커밋 단위를 어떻게 잡아야할지 헷갈리네요 (레드 그린 블루를 각각 커밋하는건지…)

저는 과거 프로젝트에서

  • 구현하려는 기능에 대해 모두 테스트 단위를 작성하고 커밋(Red Step)
  • 모든 테스트를 구현하고 커밋(Blue Step)
  • 리팩토링 한 뒤 또 커밋(Refactoring Step)

했다고 소개했는데요..!!

TDD는 한 기능에 대해 Red-Green-Blue Cycle이 돌아간다.

이번 과제에서 TDD를 진행하려고 보니까 제가 완전히 잘못 알고 있었습니다!
프로젝트에서 진행하던 방식은 모든 기능에 단위를 예측했고… 이것은 Test-Last에 가까운 방식이었습니다.

이미 탑다운으로 시뮬레이션을 상상하고 테스트를 작성한 것이었죠… 🤣

그럼 커밋은 언제해야될까? 🤔

커밋은 한 기능에 대해 모든 TDD Cycle을 마치고 커밋을 해야 됐습니다.
하지만 한 번의 TDD Cycle에 완성도 높은 기능 구현은 힘들었습니다!

나중에 여러 객체들을 조합하는 과정에서 구조가 바뀔 수 있고 결국 추가적인 리팩토링이 필요하게 됩니다! 이 경우는 단순히 리팩토링 커밋을 한번 더 남겼습니다.

잘못된 로직으로 버그도 발생했는데요… 이 경우도 버그에 대응하는 테스트를 수정 및 추가 하고 TDD 사이클을 적용 후 커밋했습니다!
(TDD 커밋을 깨닫게해주신 오션에게 정말 감사합니다 🙏)


STEP 1. 요구사항 정리

TDD는 기능 단위 별로 테스트를 진행해야 되기 때문에 요구사항 정리가 필수입니다.

1. 구입 금액을 입력받는다.
2. 로또를 n장 구매한다. (로또 생성)
3. 당첨 번호와 보너스 번호를 입력받는다.
4. 당첨 확인을 한다.
5. 수익금을 계산한다.

제가 정리한 요구사항의 핵심 기능은 5가지로 이제 각 기능 별로 TDD를 진행하면 됩니다!

TDD 진행 사이클

[클린 코드 - 단위 테스트 챕터]에서 소개되는 TDD 사이클로 개발을 진행했습니다.

  1. 테스트 대상 식별 후 테스트 코드 작성
  2. 컴파일 되는 코드
  3. 테스트가 동작하도록 최소 기능 구현
  4. 리팩토링

STEP 2. 구입 금액

구입 금액은 입력이 포함되므로, 생성하는 검증이 필요하다고 딱 느낌이 옵니다!

자세한 요구사항에 따르면 로또 1장의 가격은 1,000원이다, 1000원으로 나누어 떨어지지 않으면 예외라고 명확히 명시되어있습니다.

이프 extends Person인 것처럼 구입 금액 또한 Money의 형태이기 때문에, Money라는 네이밍을 사용했습니다.

돌아가는 코드를 먼저 작성한 뒤, 최종적으로 리팩토링한 형태입니다.
Money는 상태 관리 없이 원시 값만을 가지고 있기 때문에 하나의 VO로 볼 수 있겠죠?

그리고 상수로 표현했는데 … 지금 보니까 MINIMUM_UNIT 같은 것을 해야했네요.. AH😭
1단계 Money는 이것으로 끝입니다!


STEP 3. 로또 N장 구매

로또 구매는 구입 금액에 따라 n개의 로또를 발행이라는 명확한 요구사항이 있습니다. 앞서 구입 금액에서 로또 1장의 가격(1,000원)을 알고 있으니, n개의 로또부터 구해야 할 것 같습니다 😊

3-1. 구매할 로또 수량 구하기

구매 가격 정보는 Money 객체가 들고 있기 때문에 TDA(Tell Don’t Ask)를 지켜 수량 정보를 가져와야합니다!

그래서 추가된 테스트 케이스로 수량 계산, 수량 계산 시 단위 가격이 양수가 아니라면 Divide by Zero Exception or Negative Result가 나오기 때문에 방어적 테스트도 추가했습니다. (아… Paramterized인데 파라미터를 안받고 있네요 😱😱)

그리고 Money에 동작하는 코드를 추가합니다. 추가로 더 리팩토링 할 부분이 안보여서… 넘어갔습니다. (사실 0 이하 처리도 YAGNI를 위반하는 것 일수도…)

3-2. 로또 생성하기

로또는 금액 정보로 로또를 구매하므로 LottoMachine을 통해 발행(issue)하면 구매 로또들이 반환되는 하나의 단위입니다.

그럼 여기서 LottoGenerator, Lotto, Lottos 라는 객체들이 추가로 필요합니다. 저는 클린 코드의 TDD 사이클과 동일하게 우선 컴파일 되도록 각 객체부터 만들었습니다.

가장 작은 단위인 LottoGenerator부터 보겠습니다. (Lotto는 우테코에서 제공하기 때문에 넘어가겠습니다.)
랜덤한 숫자는 우테코에서 제공하는 외부 라이브러리를 사용하기 때문에, 테스트 대상도 아니고 기본 구현체를 만들어줬습니다.

그 다음 기본적으로 로또를 생성할 경우 여러장이므로 List<Lotto>형식의 컬렉션 객체가 필요합니다. 그래서 Lottos라는 일급 컬렉션으로 컬렉션을 방어적 복사로 Wrapping하는 컴파일 되는 코드를 만들었습니다.

이제 앞서 실패하던 코드를 동작하도록 작성할 차례입니다. (Green)

Money에서 계산한 로또 수량과, LottoMachine에서 Generator로 로또를 생성하며 여러장의 로또 발행하는 코드를 작성했습니다.

사실 이것도 지금 보니까 LottoGenerator를 파라미터로 받았으면 더 좋았을 것 같습니다… 현재는 자동 로또 발급기인데, 수동 로또 발급이 필요하다면 파라미터로 Generator를 전달하면 되니까요!

이렇게 로또 생성하기까지 TDD 사이클을 마쳤습니다.


STEP 4. 당첨 번호 추첨

당첨 번호도 입력이라 생성 시점에 검증이 필요합니다. 현재 2가지가 필요하겠네요!

  • 당첨 번호의 검증과 보너스 번호의 검증
  • 중복되지 않는 숫자 6개와 보너스 번호 1개를 뽑는다. **로또 번호의 숫자 범위는 1~45까지이다.

4-1. 로또 번호 검증

앞 과정들과 마찬가지로 로또 번호 입력에 대해 실패하는 테스트를 먼저 작성하고 구현합니다.

4-2 보너스 번호 검증

보너스 번호는 로또 당첨 번호의 규칙이 재사용됩니다. 로또 번호의 숫자 범위는 1~45까지이다.

이것도 테스트와 구현을 마쳤고, 여기에서 몇가지 변화가 발생합니다!

1. Lotto의 로직 변화

사실 Lotto의 번호 또한 LottoNumber라서 Vo로 분리 할 수 있습니다.

그럼 validate에서는 번호 범위에 대한 검증은 제외하고, 불필요한 상수도 제거됩니다!
→ 당연히 Lotto의 중복되는 불필요한 범위 검증 테스트도 제거해야 되겠죠? (DRY🔥)

2. SSOT(Single Source Of Truth)

아마 눈썰미가 좋으신 분들은 이미 위의 코드에서 변경사항을 캐치하셨을거예요!
상수들이 모두 public으로 바뀌었는데, SSOT로 로또와 연관된 유일한 출처를 지키기 위합니다!

그럼 기존 RandomLottoGenerator는 로또 규칙이 바뀌어도 전혀 영향이 없게 됩니다 👏👏

왜 상수 클래스로 분리하지 않았나요?라는 질문이 나온다면 당신은 뛰어난 아키텍처!
→ 저는 God 클래스를 부정하기 때문에, 응집도를 더 높이는 방식을 택했습니다!

4-3. 당첨 번호 검증

당첨 번호는 WinningNumbers라는 네이밍으로 로또 번호와 보너스 번호가 필요한 하나의 책임이 있는 객체로 정의했습니다.

왜냐하면 보너스 번호도 로또에 중복되었는지 확인되어야 하기 때문입니다!

Green Step까지 우선 동작하는 코드를 작성해보면 TDA를 지키지 않고 있음을 알 수 있습니다. Lotto에 기능이 추가되어야겠죠? 🤩

로또에 숫자가 포함되었는지 확인하는 기능(match)에 TDD Cycle을 적용합니다.
최종적으로 WinningNumbers는 TDA를 지키며, 리팩토링에 성공했습니다.


STEP 5. 당첨 통계

통계에서는 아래 기능들이 필요하게 됩니다.

  • 구매한 로또마다 추첨한 당첨 번호로 당첨 순위를 알 수 있다.
  • 전체 구매한 로또의 당첨 결과를 통계낸다.

정리된 각 기능 별로 TDD로 구현해야 되겠군요.

5-1. 당첨 순위 판별

당첨 순위의 명확한 기준이 있고 구매한 로또에서 같은 등수가 여러번 반복 될 수 있는 순위가 재사용되는 구조니까, Enum을 활용하면 좋다는 것을 알 수 있습니다.

이 정보로 “당첨 번호에서 다른 로또로 순위를 결정한다”는 비즈니스 규칙을 추가했습니다.

컴파일 되는 코드(Rank class만 생성) → 돌아가는 코드로 Green Step을 마친 결과입니다.
여기서 또 count를 계산 하는 과정이 TDA를 지키지 않고 있는 것을 볼 수 있습니다.

Lotto 내부에 countMatching이란 기능을 TDD로 추가하고, 성공적으로 리팩토링을 마쳤습니다.
(countMatching TDD는 앞서 같은 과정이 있었기 때문에 생략하겠습니다.)

5-2. 전체 당첨 결과

당첨 통계는 로또 기계에서 “구매한 로또와 당첨 번호로 통계를 낸다”는 비즈니스 룰을 추가했습니다.

통계는 {당첨, 당첨 갯수}와 같은 형태라서 엄연한 Map 형태(컬렉션)이므로 일급 컬렉션으로 표현했습니다 👍

마찬가지로 컴파일 → 돌아가는 코드의 GreenStep으로 기능이 완성된 모습입니다.
⚠️⚠️ 현재 2가지 TDA 위반과 낮은 가독성이 보이고 있습니다.

  • rank != Rank.NONE을 Rank 객체의 책임으로 위임
  • purchaseLottos.lottos()List<Rank>를 직접 조작하는 것을 Lottos 객체의 책임으로 위임
  • EnumMap Collection으로 변환하는 로직을 WINNING_RESULT_CONVERTER라는 상수로 가독성 향상

이렇게 최종적으로 당첨 통계에 대한 TDD도 마쳤습니다.
(마찬가지로 TDA를 개선하며 진행하는 TDD는 앞서 언급되었기 때문에 생략합니다!)

STEP 6. 수익율 계산

수익율 계산은 전체 당첨 결과와 로또를 구매한 금액으로 LottoMachine에서 처리합니다.

Green Cycle을 마쳤더니 또 TDA를 위반하고 있습니다.

  • Winning Result를 직접 참조
  • Money를 직접 참조

이것 또한 TDA를 지키며 각 책임을 객체에게 위임하면서 깔끔하게 리팩토링에 성공했습니다.


TDD로 인한 깨달음

앞서 작성한 내용은 사실 잘 나온 결과물의 최종본입니다.
이 과정 속에서 수정도 정말 많았고, 여러가지 난관이 많았습니다.
그 중 깨닫게 된 여러 장・단점을 작성해보려고 합니다.

TDD ≠ 좋은 것

TDD는 정말 좋은 것인가?이라는 질문을 받으면 저는 경험을 통해 과감히 “좋지 않다”라고 답 할 자신이 있습니다.

TDD의 단점

  1. TDD는 도메인 로직에 집중하기 때문에 조합에 어려움이 있다.
    • 경험이 정말 많이 필요하다.
  2. TDD는 변경지점이 발생하면 연달아 변경되는 테스트가 제법 존재한다.
    • 가장 작은 단위부터 판단하고 나누는 습관이 없다면, 고생좀 할 것이다.
    • 리팩토링 내성이 약하고 유지보수 비용이 제법 든다.
  3. 초기 생산성이 느리다.
    • “테스트 하기 좋은 코드”에 집착하게 되면서 나타나는 현상이다.

‼️ 그렇다고 좋지 않은 것도 아닙니다.

TDD의 장점

  1. 도메인 응집도가 매우 뛰어나다.
    • 테스트 단위별로 책임을 나누다 보니, “이 객체가 진짜 이 일을 해야 하는가?” 를 자연스럽게 고민하게 됨.
    • 결과적으로 클래스 설계가 명확해지고, SRP(단일 책임 원칙) 을 어기기 어려워짐.
  2. 코드 변경 시 테스트로 인한 안전망을 보장받는다.
    • 새로운 요구사항을 반영하거나 리팩토링할 때, 기존 테스트가 회귀 오류(regression) 를 바로 잡아냄.
    • 테스트가 자동화된 회귀 검증 장치 역할을 하므로 “리팩토링이 두렵지 않은 코드”를 만들 수 있음.
      • 결국 디버깅보다 테스트로 사고하는 습관이 생김 → 개발 속도는 느려도, 수정 속도는 빨라짐
  3. 코드 품질이 자연스럽게 향상된다.
    • 테스트 가능한 코드를 작성해야 하므로 의존성 분리, 인터페이스 기반 설계, 순수 함수 형태의 메서드가 많아짐.
    • 즉, 테스트 가능성이 좋은 설계의 지표로 작동함.
      • 💡 테스트하기 어려운 코드 = 결합도가 높거나 책임이 불명확한 코드
  4. 요구사항 이해가 깊어진다.
    • “무엇을 테스트해야 하지?”를 고민하는 과정이 곧 “이 기능이 실제로 뭘 해야 하지?”를 고민하는 과정이 됨.
    • 결과적으로 요구사항을 행위 단위로 명확히 정리하게 되고, “기능 단위 명세서”처럼 테스트가 작동함.

TDD는 DDD와 매우 밀접하다

이번 로또 프로그램의 도메인 계층은 TDD로 자연스럽게 DDD의 구조적 형태를 띠게 되었습니다.

도메인 객체들은 모두 불변(record)으로 설계되었고, LottoMachine은 상태를 갖지 않는 순수한 서비스 객체로 동작합니다.

즉, 도메인 서비스(Domain Service) 로서 여러 VO(Value Object)들을 조합해 행위 중심의 도메인 로직을 수행합니다.

아래는 실제 서비스 구조를 단순화한 그림입니다.

  1. LottoMachine은 상태를 가지지 않고, 여러 VO들을 조합하여 결과(WinningResult)를 산출합니다.
  2. 도메인 객체(Lottos, WinningNumbers 등)는 자신이 가진 값과 행위에만 집중합니다.
  3. 외부에서는 오직 LottoMachine을 통해서만 도메인 행위를 수행합니다.
    • 즉, 응집된 도메인 진입점(Aggregate Root처럼 동작하는 Service) 역할을 하게 됩니다.

결과적으로, TDD로 “가장 작은 단위의 테스트”를 쌓아가면서 자연스럽게 도메인 간 경계가 명확해지고, DDD의 핵심인 도메인 중심 설계가 구현 코드로 녹아들게 되었습니다.


ATDD로 좀 더 유연하게

TDD는 도메인 단위의 테스트에는 강력하지만, “조합된 행위의 흐름”을 검증하는 데는 꽤나 약점을 보였습니다.

예를 들어, LottoMachine, Money, WinningNumbers 등 여러 객체가 얽히는 흐름에서는
“도메인을 어떻게 조합해야 하는가”에 대한 경험이 많이 필요했습니다.

이런 점을 개선하기 위해 다음 과제에서는 ATDD(Acceptance Test-Driven Development) 방식을 시도해볼 생각입니다. ATDD는 사용자 시나리오(Use Case)를 기준으로 테스트를 작성하고, 그 시나리오를 만족시키는 단위 테스트를 하위에서 구현해나가는 방식입니다.

즉, TDD가 “도메인 내부의 단위 로직”을 먼저 보는 것이라면 ATDD는 “사용자 행위 → 시스템 응답”을 먼저 보는 접근입니다.

ATDD Example

// 예시: ATDD 스타일의 시나리오 테스트
@Test
void 로또_구매부터_당첨_통계까지_정상_흐름을_검증한다() {
    InputHandler input = new FakeInputHandler(10_000, List.of(1,2,3,4,5,6), 7);
    OutputPresenter output = new SpyOutputPresenter();
    LottoGenerator generator = new FixedLottoGenerator();
    LottoController controller = new LottoController(input, output, generator);

    controller.run();

    assertThat(output.getPrintedResults()).contains("총 수익률");
}

제가 1주차 과제(문자열 계산기)에서 컨트롤러 흐름을 테스트 하기 위해 작성했던 방식인데, 이런식으로 ATDD를 병행할 수 있습니다. 그럼

  • 사용자 플로우(시나리오) 기준으로 검증이 가능해지고,
  • 도메인 객체 조합의 방향성을 테스트가 명세서처럼 안내하게 됩니다.

그래서 다음 과제에서는 ATDD를 통해 “조합에 강한 TDD”를 도전해볼 계획입니다.
(아 물론 4주차 과제부터 어떻게 바뀔지 몰라서… 적용이 가능할 지는 모르겠네요 🤣😭)


마치며

이번 로또 미션은 단순히 기능 구현이 아니라 TDD, DDD, 그리고 테스트 중심 사고를 전부 체험한 시간이었네요 👏👏

TDD를 하면서 객체의 책임을 명확히 나누는 연습이 됐고, 그 과정에서 자연스럽게 DDD의 구조를 닮아갔던 것 같아요. 과정 속에서 로직이 명확해지고, 테스트가 API 명세서처럼 일종의 명세서(?)처럼 느껴졌습니다!

다만 느낀 건, TDD가 무조건 정답은 아니다는 점이라는 것입니다!
처음부터 모든 걸 테스트로 접근하니 오히려 속도나 유연성이 떨어질 때도 있었거든요.

결국 중요한 건 “테스트를 통해 더 나은 구조를 만드는 태도” 라고 생각해요.
그래서 TDD, DDD, Test-Last 어떤 방식이든 팀이 합의하고, 서비스를 유지할 수 있는 “지속 가능한 테스트 문화”가 더 중요하다고 느꼈습니다. 💭

어쨌든 다음번엔 ATDD처럼(가능하면) 시나리오 중심의 접근에 도전해보려고 합니다. 🔥🔥

profile
if (이런 시나리오는 어떨까?) then(테스트로 검증하고 해결) else(다음 시나리오 고민)

1개의 댓글

comment-user-thumbnail
2025년 11월 4일

3주차 미션도 고생하셨습니다!
TDD에 대한 이프님의 생각을 볼 수 있어서 좋았습니다.
덕분에 항상 많이 배우고 있습니다 ㅎㅎ

답글 달기