FE
테스트보다 구현이 먼저,,? - 로또 발매기 프로젝트에서 배운 TDD의 전환점
| 도입
로또 발매기 미션은 제게 단순한 과제가 아니라, “테스트를 진짜로 써야 하는 이유”를 깨닫게 한 계기였습니다.
그전까진 테스트를 코드가 완성된 뒤 “돌려보는 확인용” 정도로만 생각했습니다.
하지만 이번 프로젝트에서는 Jest를 활용해 테스트를 먼저 작성하고, 그에 맞춰 구현을 완성하는 TDD(Test-Driven Development) 방식을 처음으로 진지하게 시도했습니다.
“테스트보다 구현이 더 익숙한 나”에게 이 방식은 꽤 낯설었지만, 점점 테스트가 ‘제약’이 아니라 ‘설계 도구’가 되어가는 경험을 했습니다.
| 시행착오: 실패의 시작은 ‘테스트하기 어려운 코드’
TDD를 시도하기 전까지는 늘 이렇게 생각했습니다.
“일단 기능부터 완성하고, 나중에 테스트를 붙이자.”
그게 더 효율적인 방법이라고 믿었지만, 실제로는 가장 비효율적인 길이었습니다.
예를 들어, 로또 발매기의 핵심 로직을 담당하던 LottoMachine 클래스가 있었습니다.
로또 발행, 당첨 비교, 수익률 계산 등 모든 책임을 하나의 클래스에 몰아넣은 구조였죠.
덕분에 구현은 빠르게 끝났지만, 테스트를 작성하는 순간부터 지옥이 시작되었습니다.
수익률 계산 로직만 테스트하려고 했는데, 내부에서 Random.pickUniqueNumbersInRange()를 호출하는 탓에 매번 다른 번호가 생성되어 결과를 예측할 수 없었습니다.
// 수정 전 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의 결과를 조합해 로직을 실행하도록 구조를 바꿨습니다.
// 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를 처음 접했을 때는 “왜 이렇게 느리지?”라는 생각이 들었습니다.
하지만 프로젝트가 커질수록 테스트는 저를 더 빠르게, 더 안정적으로 만들었습니다.
테스트가 있으면, 코드 수정이 더 이상 두려운 일이 아니게 되니까요.
테스트는 나를 제한하는 족쇄가 아니라, “이 코드가 어떻게 동작해야 하는지를 가장 정확히 보여주는 거울”이라고 생각합니다!