0318

FE

하나의 클래스가 너무 많은 일을 할 때 — LottoMachine 리팩토링 이야기

| 도입

우테코 3주차 미션이었떤 로또 발매기 프로젝트의 리팩토링을 진행하면서 가장 크게 부딪힌 문제는 LottoMachine이 모든 일을 혼자 하고 있었다는 점이었습니다.

로또 번호 생성부터 당첨 매칭, 결과 통계 계산, 수익률 산출까지 — 모든 책임이 단일 클래스 안에 얽혀 있었죠.

처음엔 “한 곳에서 다 관리하면 편하지 않을까?” 싶었지만, 프로젝트가 커질수록 테스트가 불가능하고, 유지보수가 어려운 거대 클래스로 변해갔습니다.

이후 “진짜 객체지향적인 구조란 무엇일까?”라는 질문을 던지며, 리팩토링을 시작했습니다.

| 문제 상황: 거대해진 LottoMachine

리팩토링 이전의 LottoMachine은 요약해서 가져왔을때, 다음과 같은 형태였습니다.

code
class LottoMachine {
  #purchasePrice;
  #ticketCount;
  #tickets = [];
  #winningNumbers = [];
  #bonusNumber;
  #matchList = [];
  #profitPercentage;

  constructor(purchasePrice) {
    this.#purchasePrice = purchasePrice;
    this.#ticketCount = purchasePrice / LOTTO.PRICE_PER_TICKET;
    this.#createLottoTickets();
  }

  #createLottoTickets() {
    this.#tickets = Array.from({ length: this.#ticketCount }, () => {
      const numbers = this.#getUniqueNumbersOfSix();
      return new Lotto(this.#sortNumbers(numbers));
    });
  }

  #getMatchCount(ticket) {
    const matchCountWinningNumbers = this.#getMatchCountWinningNumbers(ticket);
    const matchCountBonusNumber = this.#getMatchCountBonusNumber(ticket);
    return { matchCountWinningNumbers, matchCountBonusNumber };
  }

  calculateProfitPercentage(matchCountList) {
    const totalPrize = Object.entries(matchCountList).reduce(
      (total, [rank, count]) => total + RANKS[rank].prize * count,
      0,
    );
    return totalPrize / this.#purchasePrice;
  }
}

언뜻 보기엔 깔끔해 보이지만, 안쪽엔 다음과 같은 문제가 숨어 있었습니다.

  • SRP(단일 책임 원칙) 위반→ 클래스가 “로또 생성기”인지 “결과 계산기”인지 명확하지 않음

  • 높은 결합도→ 내부 데이터(tickets, matchList)가 여러 메서드에 의해 동시에 변경

  • 테스트 불가능한 구조→ 단일 기능을 검증하려 해도 모든 로직이 함께 실행되어야 함

결국, 하나의 클래스로 관리하는 것이 아니라 각 역할에 맞게 분리해야 한다는 결론에 도달했습니다.

| 리팩토링 과정: LottoMachine을 분리하다

처음에는 LottoMachine 내부 메서드 몇 개만 분리하는 수준의 단순한 리팩토링을 시도했습니다.

하지만 금세 “생성, 매칭, 통계”라는 세 흐름이 명확히 다른 책임임을 깨닫게 되었죠. 바로, 리팩토링의 핵심은 역할 분리였습니다.

  • LottoGenerator → 로또 번호 생성 전담

  • LottoMatcher → 당첨 번호와 매칭 계산

  • LottoStatistics → 당첨 통계 및 수익률 계산

  • LottoMachine → 이 세 클래스를 조합해 전체 흐름을 관리

그래서 먼저 각 기능이 무엇을 하는지 역할 단위로 나누는 작업부터 시작했습니다.

  1. 생성 로직 분리 (LottoGenerator)

    • 기존에는 #createLottoTickets()가 Random.pickUniqueNumbersInRange()까지 직접 호출하고 있었습니다.

    • 이 부분을 별도의 정적 클래스 LottoGenerator로 추출해, “로또 번호를 만드는 행위”만 전담하도록 변경했습니다.

    • 이렇게 분리하니 LottoMachine이 더 이상 숫자 생성 로직에 관여하지 않아도 되어, 생성과 비즈니스 로직의 결합도를 낮출 수 있었습니다.

  2. 매칭 로직 분리 (LottoMatcher)

    • #getMatchCount() 메서드가 당첨 번호 비교, 보너스 번호 확인, 누적 결과 계산까지 모두 담당하고 있었습니다.

    • 이를 LottoMatcher 클래스로 분리하여, “하나의 티켓이 당첨 번호와 몇 개 일치하는지”만 계산하도록 했습니다.

    • LottoMatcher를 도입한 후에는 테스트에서 매칭 로직만 독립적으로 검증할 수 있었고,입력 값 조작 없이 바로 시뮬레이션이 가능해졌습니다.

  3. 통계 및 수익률 계산 분리 (LottoStatistics)

    • 원래는 calculateProfitPercentage()가 수익률 계산뿐 아니라, reduce()를 통해 당첨 통계까지 함께 계산했습니다.

    • 이를 LottoStatistics로 이동시켜, “매칭 결과를 바탕으로 통계 데이터를 정리하고 수익률을 계산하는 역할”만 수행하도록 변경했습니다.

    • 이로 인해 계산 로직이 명확히 드러났고, 이후 수익률 포맷을 수정하거나 새로운 당첨 규칙을 추가하기도 쉬워졌습니다.

이렇게 단계별로 역할을 쪼개는 과정에서, 처음엔 “굳이 이렇게까지 나눠야 할까?” 싶었지만, 각 클래스를 테스트할 수 있게 된 순간 리팩토링의 효과를 체감할 수 있었습니다.

이렇게 구조를 바꾼 이후의 코드는 다음과 같습니다.

code
class LottoGenerator {
  static createTickets(ticketCount) {
    return Array.from({ length: ticketCount }, () => {
      const numbers = this.#generateNumbers();
      return new Lotto(this.#sortNumbers(numbers));
    });
  }

  static #generateNumbers() {
    return Random.pickUniqueNumbersInRange(
      LOTTO.MIN_NUMBER,
      LOTTO.MAX_NUMBER,
      LOTTO.NUMBER_COUNT,
    );
  }

  static #sortNumbers(numbers) {
    return numbers.sort((a, b) => a - b);
  }
}
code
class LottoMatcher {
  static getMatchList(tickets, winningNumbers, bonusNumber) {
    return tickets.map((ticket) =>
      this.#getMatchCount(ticket.numbers, winningNumbers, bonusNumber),
    );
  }

  static #getMatchCount(ticket, winningNumbers, bonusNumber) {
    const matchCountWinningNumbers = this.#countMatches(ticket, winningNumbers);
    const matchCountBonusNumber = ticket.includes(bonusNumber) ? 1 : 0;
    return { matchCountWinningNumbers, matchCountBonusNumber };
  }

  static #countMatches(ticket, winningNumbers) {
    return ticket.reduce(
      (count, number) => count + (winningNumbers.includes(number) ? 1 : 0),
      0,
    );
  }
}
TypeScript
class LottoStatistics {
  static summarize(matchList) {
    const result = Object.keys(RANKS).reduce(
      (acc, rank) => ({ ...acc, [rank]: 0 }),
      {},
    );
    return matchList.reduce((acc, match) => {
      const rank = this.#getRank(match);
      if (rank) acc[rank]++;
      return acc;
    }, result);
  }

  static #getRank({ matchCountWinningNumbers, matchCountBonusNumber }) {
    for (const rank in RANKS) {
      const { matchCount, needsBonus } = RANKS[rank];
      if (matchCountWinningNumbers !== matchCount) continue;
      if (needsBonus && !matchCountBonusNumber) continue;
      return rank;
    }
  }

  static calculateProfit(matchSummary, purchasePrice) {
    const totalPrize = Object.entries(matchSummary).reduce(
      (sum, [rank, count]) => sum + RANKS[rank].prize * count,
      0,
    );
    return totalPrize / purchasePrice;
  }
}
code
class LottoMachine {
  #purchasePrice;
  #ticketCount;
  #tickets = [];
  #winningNumbers = [];
  #bonusNumber;
  #matchList = [];

  constructor(purchasePrice) {
    this.#purchasePrice = purchasePrice;
    this.#ticketCount = purchasePrice / LOTTO.PRICE_PER_TICKET;
    this.#tickets = LottoGenerator.createTickets(this.#ticketCount);
  }

  makeAllMatchCounts() {
    this.#matchList = LottoMatcher.getMatchList(
      this.#tickets,
      this.#winningNumbers,
      this.#bonusNumber,
    );
  }

  getDetailMatchResult() {
    return LottoStatistics.summarize(this.#matchList);
  }

  calculateProfitPercentage(matchCountList) {
    return LottoStatistics.calculateProfit(matchCountList, this.#purchasePrice);
  }
}

| 이 경험을 통해 얻은 교훈

이 과정을 통해서 배운 점은 크게 세 가지입니다.

  1. 리팩토링의 핵심은 코드 분리가 아니라 책임 분리다.- 클래스의 수가 많아지는 것이 문제가 아니다.- 문제는 “하나의 클래스가 너무 많은 일을 하는 것”이다.

  2. 결합도를 낮추면 테스트가 자연스럽게 가능해진다.- 독립적인 책임 단위는 자연스럽게 테스트 단위가 된다.

  3. 설계는 나중이 아니라 처음부터 고민해야 한다.- 처음엔 빠르게 구현하는 것이 목표였지만, 결국 설계의 부족이 발목을 잡았다.

이번 리팩토링은 단순한 코드 정리가 아니라, “책임이 명확한 코드가 얼마나 유지보수에 강한가”를 깨닫게 해준 경험이었습니다.

앞으로의 프로젝트에서도 더 작고, 더 명확한 단위로 시스템을 설계해 나가고자 합니다.