FE
하나의 클래스가 너무 많은 일을 할 때 — LottoMachine 리팩토링 이야기
| 도입
우테코 3주차 미션이었떤 로또 발매기 프로젝트의 리팩토링을 진행하면서 가장 크게 부딪힌 문제는 LottoMachine이 모든 일을 혼자 하고 있었다는 점이었습니다.
로또 번호 생성부터 당첨 매칭, 결과 통계 계산, 수익률 산출까지 — 모든 책임이 단일 클래스 안에 얽혀 있었죠.
처음엔 “한 곳에서 다 관리하면 편하지 않을까?” 싶었지만, 프로젝트가 커질수록 테스트가 불가능하고, 유지보수가 어려운 거대 클래스로 변해갔습니다.
이후 “진짜 객체지향적인 구조란 무엇일까?”라는 질문을 던지며, 리팩토링을 시작했습니다.
| 문제 상황: 거대해진 LottoMachine
리팩토링 이전의 LottoMachine은 요약해서 가져왔을때, 다음과 같은 형태였습니다.
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 → 이 세 클래스를 조합해 전체 흐름을 관리
그래서 먼저 각 기능이 무엇을 하는지 역할 단위로 나누는 작업부터 시작했습니다.
생성 로직 분리 (LottoGenerator)
기존에는 #createLottoTickets()가 Random.pickUniqueNumbersInRange()까지 직접 호출하고 있었습니다.
이 부분을 별도의 정적 클래스 LottoGenerator로 추출해, “로또 번호를 만드는 행위”만 전담하도록 변경했습니다.
이렇게 분리하니 LottoMachine이 더 이상 숫자 생성 로직에 관여하지 않아도 되어, 생성과 비즈니스 로직의 결합도를 낮출 수 있었습니다.
매칭 로직 분리 (LottoMatcher)
#getMatchCount() 메서드가 당첨 번호 비교, 보너스 번호 확인, 누적 결과 계산까지 모두 담당하고 있었습니다.
이를 LottoMatcher 클래스로 분리하여, “하나의 티켓이 당첨 번호와 몇 개 일치하는지”만 계산하도록 했습니다.
LottoMatcher를 도입한 후에는 테스트에서 매칭 로직만 독립적으로 검증할 수 있었고,입력 값 조작 없이 바로 시뮬레이션이 가능해졌습니다.
통계 및 수익률 계산 분리 (LottoStatistics)
원래는 calculateProfitPercentage()가 수익률 계산뿐 아니라, reduce()를 통해 당첨 통계까지 함께 계산했습니다.
이를 LottoStatistics로 이동시켜, “매칭 결과를 바탕으로 통계 데이터를 정리하고 수익률을 계산하는 역할”만 수행하도록 변경했습니다.
이로 인해 계산 로직이 명확히 드러났고, 이후 수익률 포맷을 수정하거나 새로운 당첨 규칙을 추가하기도 쉬워졌습니다.
이렇게 단계별로 역할을 쪼개는 과정에서, 처음엔 “굳이 이렇게까지 나눠야 할까?” 싶었지만, 각 클래스를 테스트할 수 있게 된 순간 리팩토링의 효과를 체감할 수 있었습니다.
이렇게 구조를 바꾼 이후의 코드는 다음과 같습니다.
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);
}
}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,
);
}
}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;
}
}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);
}
}| 이 경험을 통해 얻은 교훈
이 과정을 통해서 배운 점은 크게 세 가지입니다.
리팩토링의 핵심은 코드 분리가 아니라 책임 분리다.- 클래스의 수가 많아지는 것이 문제가 아니다.- 문제는 “하나의 클래스가 너무 많은 일을 하는 것”이다.
결합도를 낮추면 테스트가 자연스럽게 가능해진다.- 독립적인 책임 단위는 자연스럽게 테스트 단위가 된다.
설계는 나중이 아니라 처음부터 고민해야 한다.- 처음엔 빠르게 구현하는 것이 목표였지만, 결국 설계의 부족이 발목을 잡았다.
이번 리팩토링은 단순한 코드 정리가 아니라, “책임이 명확한 코드가 얼마나 유지보수에 강한가”를 깨닫게 해준 경험이었습니다.
앞으로의 프로젝트에서도 더 작고, 더 명확한 단위로 시스템을 설계해 나가고자 합니다.