0319

FE

테스트보다 구현이 먼저,,? - 로또 발매기 프로젝트에서 배운 TDD의 전환점

| 도입

로또 발매기 미션은 제게 단순한 과제가 아니라, “테스트를 진짜로 써야 하는 이유”를 깨닫게 한 계기였습니다.

그전까진 테스트를 코드가 완성된 뒤 “돌려보는 확인용” 정도로만 생각했습니다.

하지만 이번 프로젝트에서는 Jest를 활용해 테스트를 먼저 작성하고, 그에 맞춰 구현을 완성하는 TDD(Test-Driven Development) 방식을 처음으로 진지하게 시도했습니다.

“테스트보다 구현이 더 익숙한 나”에게 이 방식은 꽤 낯설었지만, 점점 테스트가 ‘제약’이 아니라 ‘설계 도구’가 되어가는 경험을 했습니다.

| 시행착오: 실패의 시작은 ‘테스트하기 어려운 코드’

TDD를 시도하기 전까지는 늘 이렇게 생각했습니다.

“일단 기능부터 완성하고, 나중에 테스트를 붙이자.”

그게 더 효율적인 방법이라고 믿었지만, 실제로는 가장 비효율적인 길이었습니다.

예를 들어, 로또 발매기의 핵심 로직을 담당하던 LottoMachine 클래스가 있었습니다.

로또 발행, 당첨 비교, 수익률 계산 등 모든 책임을 하나의 클래스에 몰아넣은 구조였죠.

덕분에 구현은 빠르게 끝났지만, 테스트를 작성하는 순간부터 지옥이 시작되었습니다.

수익률 계산 로직만 테스트하려고 했는데, 내부에서 Random.pickUniqueNumbersInRange()를 호출하는 탓에 매번 다른 번호가 생성되어 결과를 예측할 수 없었습니다.

code
// 수정 전 LottoMachine에 대한 테스트 (가상 예시)
test('수익률을 정확히 계산해야 한다', () => {
  const machine = new LottoMachine(1000); // 내부에서 랜덤 번호로 로또 생성
  machine.applyWinningNumbers([1, 2, 3, 4, 5, 6]);
  machine.applyBonusNumber(7);

  const profit = machine.calculateProfitPercentage();

  // 생성된 로또 번호를 모르니 결과를 검증할 수 없음
  expect(profit).toBe('???'); // Flaky Test (성공/실패가 랜덤)
});

이 문제를 해결하려고 Random API를 mock 처리해보았지만, LottoMachine이 로또 생성부터 수익률 계산까지 전부 담당하고 있었기 때문에 테스트 설정이 복잡하게 꼬이고 의존성 지옥에 빠졌습니다.

결국 “테스트를 수정해야 하는 테스트”가 되어버렸죠.

이 경험을 통해 확실히 깨달았습니다.

“테스트하기 어려운 구조는 대부분 설계가 잘못된 구조다.”

테스트는 단순히 동작 여부를 검증하는 도구가 아니라, “코드를 독립적으로 동작하게 만드는 구조의 리트머스 시험지”라는 사실을 처음 체감한 순간이었습니다.

| 깨달음: 테스트는 코드의 거울이다

그때부터 테스트를 “검증용”이 아니라 “설계용”으로 바라보기 시작했습니다.

LottoMachine이 테스트하기 어려웠던 이유는, ‘로또 생성’과 ‘결과 분석’이라는 서로 다른 책임이 한 클래스 안에 섞여 있었기 때문이었습니다.

그래서 이 책임들을 명확히 분리했습니다.

  • LottoGenerator → 로또 번호 생성 전담

  • LottoMatcher → 당첨 번호 매칭 계산

  • LottoStatistics → 통계 및 수익률 계산

이후 테스트는 훨씬 단순해졌습니다.

LottoMachine은 더 이상 직접 번호를 생성하지 않고, LottoGenerator의 결과를 조합해 로직을 실행하도록 구조를 바꿨습니다.

code
// tests/LottoMachineTest.js (실제 수정된 테스트 코드)
test('결과 계산: 당첨 통계와 수익률을 올바르게 계산한다.', () => {
  // given: Random API를 제어하여 예측 가능한 번호 반환
  const purchasePrice = 1000;
  const mockTicketNumbers = [1, 2, 3, 10, 11, 12];
  Random.pickUniqueNumbersInRange = jest.fn().mockReturnValue(mockTicketNumbers);

  const lottoMachine = new LottoMachine(purchasePrice);
  const winningNumbers = [1, 2, 3, 20, 21, 22]; // 5등 조건
  const bonusNumber = 40;

  // when
  lottoMachine.applyWinningNumbers(winningNumbers);
  lottoMachine.applyBonusNumber(bonusNumber);
  lottoMachine.makeAllMatchCounts();
  const summary = lottoMachine.getDetailMatchResult();
  const profit = lottoMachine.calculateProfitPercentage(summary);

  // then
  expect(summary.FIFTH).toBe(1);
  expect(profit).toBe(5); // 수익률 500%
});

이 테스트를 먼저 작성했다면, 애초에 Random 의존성을 직접 포함하지 않는 구조를 설계했을 겁니다.

즉, 테스트가 더 나은 설계를 이끌어준 셈이었죠.

이 변화가 바로 SRP(단일 책임 원칙) TDD의 핵심이 만나는 지점이었습니다.

| 실천 계획: 테스트로 시작하는 사고 훈련

이후부터는 기능을 구현할 때마다 다음의 순서를 스스로 훈련했습니다.

1. 예상 시나리오를 테스트 코드로 먼저 작성한다.

“사용자가 잘못된 값을 입력하면 어떤 에러가 발생해야 할까?”

2. 그 테스트를 통과할 최소한의 코드를 작성한다.

‘정답’을 바로 구현하기보다, ‘테스트가 실패하지 않는 최소한의 조건’을 만족시킨다.

3. 테스트가 초록불이 되면 리팩토링한다.

중복 제거, 메서드 분리, 네이밍 정리 등 구조 개선에 집중한다.

이 루틴을 반복하다 보니, 미약하더라도 조금씩은 코드보다 테스트가 설계를 이끄는 경험을 하게 됐습니다.

예전에는 “이 함수는 왜 이렇게 복잡하지?” 였다면, 지금은 “이 테스트를 통과하려면 어떤 구조가 필요할까?” 로 사고가 바뀌었습니다.

| 처음 TDD를 배우는 사람에게 해주고 싶은 조언

  • 테스트는 나중에 붙이는 게 아니다.테스트가 어렵다면, 이미 코드가 얽혀 있다는 신호다.

  • 실패하는 테스트를 두려워하지 말자.빨간불은 실패가 아니라, “방향을 알려주는 지도”다.

  • 단순한 유효성 검사부터 시작하자.모든 걸 한 번에 검증하려 하지 말고,입력 검증 → 비즈니스 로직 → 출력 순으로 확장하자.

  • 테스트는 문서이자 커뮤니케이션 도구다.코드보다 테스트를 읽는 사람이 설계를 이해할 수 있도록 작성하자.

| 마무리: 테스트는 나를 통제하는 도구가 아니라, 나를 돕는 도구다

TDD를 처음 접했을 때는 “왜 이렇게 느리지?”라는 생각이 들었습니다.

하지만 프로젝트가 커질수록 테스트는 저를 더 빠르게, 더 안정적으로 만들었습니다.

테스트가 있으면, 코드 수정이 더 이상 두려운 일이 아니게 되니까요.

테스트는 나를 제한하는 족쇄가 아니라, “이 코드가 어떻게 동작해야 하는지를 가장 정확히 보여주는 거울”이라고 생각합니다!