기간: 2/18~2/25
크게 두 가지를 중점으로 신경써 작업하였습니다.
여기서 컨트롤러는 로또 판매점 사장입니다. 사장은 로또가 어떤 로직으로 발행되고 관리되고 계산되는지 알 필요 없이 구매자가 구매를 원하는 금액을 입력하면 발행된 복권을 확인할 수 있고 당첨번호를 넣으면 당첨 통계 및 수익률을 스크린으로 확인할 수 있으면 된다고 생각했습니다.
그래서 저희의 컨트롤러인 Main은 딱 저 정도의 역할 수행을 합니다.
로또 머신은 여러 기능들을 캡슐화되어 갖고 있는 중간 계층 모델입니다. 예를 들어 사장이 로또 머신에 구입금액만 입력해도 로또 머신은 알아서 순차적으로 메소드들을 실행해 계산된 반환값을 사장에게 보여줍니다.
그 외 Lotto, Winnings 등의 모델은, 로또 머신이 로직을 수행하기 위해 필요한 로또 발행 규칙과 우승자 판별 규칙을 갖고 있는 객체입니다.
여기서 Lotto, Winnings, LottoMachine을 작성하면서 신경쓴 점은,
// Main.js - play()
const isRestart = await Input.restartLotto();
if (isRestart) await this.play();
저는 원래 while문으로 반복에 대한 처리를 해왔었는데, 이번에 let 사용이 금지되면서 이러한 방법을 써보았습니다.
이런 방식에 문제가 있을까요? 재귀함수라는 게 while이나 try..,catch로 처리해야만하는 로직인지 궁금합니다!
TDD가 제대로 되었는가?
TDD를 적용하기 위해, 의존성이 가장 낮은 Lotto 클래스부터 단위 테스트를 작성하며 진행했습니다. TDD를 처음 접하는 입장에서 이러한 접근 방식이 맞는지 계속 고민하며 구현을 이어나갔습니다. 단위 테스트가 가능한 모든 로직에 대해 TDD를 적용하려고 했는데, 마지막에 로또 머신에 컨트롤러의 역할이 분담되면서 로또 머신에 대한 테스트는 작성하지 못했습니다 ㅠㅠ 아쉽지만 그 외의 테스트는 단위 테스트가 잘 진행되었는지 궁금합니다!
추가로 아래 설명드리는 InputHandler와 Validator에 대한 케빈의 생각도 궁금합니다🥹
우선, 가장 난해하게 작성된 부분이 InputHandler와 Validator라 생각해서 두 부분에 대해서 설명을 드리려고합니다...ㅠㅠ
'꼭 Validator와 input 함수가 에러로 소통해야 할까?'라는 생각이 들었습니다!
잘못된 입력을 알려주는 것도 View 영역이라면, 판단은 Validator가 하고, 어떤 문자열을 보내줄지는 input 함수에서 책임지는 방법은 어떤가요?
이게 제가 지난 미션에서 받았던 코드 리뷰입니다. 이 부분에 대해서 확실히, 검증은 검증만 하고 view는 입출력만 하면되지 않나? 그렇다면, 에러 메세지에 따른 결정은 view가 해야하지 않을까? 하는 생각이 들었습니다.
그리고, 이번 미션에서 추가된 요구사항이 throw Error를 발생시켜야한다는 것이었는데 이 에러를 발생시키는 일을 Validator나 View가 하지 말고 유틸함수를 호출해 컨트롤러에서 발생해야하지 않을까 생각했습니다.
그래서 throwError라는 유틸함수가 추가로 만들어졌습니다.
먼저, 인풋핸들러와 벨리데이터는 객체 에러 코드로 소통합니다. 검증하는 로직은 계속 반복되기 때문에 각 검증 로직 키에 boolean 값을 받아, 어떤 키에 true값이 켜졌는지에 따라서 해당 인풋에 따라 미리 정의되어있는 해당 인풋메서드 전용 상수 메세지에 접근해 해당 에러메세지를 결정하는 식입니다. 이를 위해 키값의 조회가 잦게 일어났습니다.
bonusNumber: (number) => {
const errorResults = {
IS_NUMBER_RANGE_OVER: ValidationUtils.isNumberRangeOver(number, 1, 45),
IS_NOT_NATURAL_NUMBER: ValidationUtils.isNotNaturalNumber(number),
};
return errorResults;
},
Validator 안의 메서드는 이렇게 errorResult를 반환하는데, 이는 에러 코드를 객체로 반환하는 방식입니다. 해당 값에 true가 하나라도 켜지면 에러가 있는 것으로 간주하고 에러를 발생시키는 식으로 에러 핸들링을 하였고,
저 에러리절트의 키와 동일한 키를 갖는 메세지 객체를 상수파일에 저장해, 해당하는 메세지를 출력하도록 하였습니다.
아래는 ERROR 메세지들을 저장한 객체입니다.
const ERROR = {
USER_INPUT: {
IS_EMPTY: "[ERROR] 빈 값은 입력할 수 없습니다.",
},
WINNING_NUMBERS: {
IS_WRONG_ARRAY_LENGTH: "[ERROR] 로또는 6개의 숫자로 이루어져야합니다.",
IS_DUPLICATED_NUMBER: "[ERROR] 중복된 숫자는 입력하실 수 없습니다.",
IS_ARRAY_NUMBER_RANGE_OVER: "[ERROR] 1~45 사이의 숫자를 입력해야합니다.",
IS_NOT_NATURAL_NUMBER_IN_ARRAY: "[ERROR] 숫자는 자연수여야 합니다.",
},
BONUS_NUMBER: {
IS_NUMBER_RANGE_OVER: "[ERROR] 1~45 사이의 숫자를 입력해야합니다.",
...
export default ERROR;
그리고, Output이 Validator에서 에러 객체를 받아, ERROR 메세지에 접근해 출력 처리하는 로직 부분입니다.
printErrorResults(errorResults, errorName) {
Object.entries(errorResults).forEach(([key, value]) => {
if (value) this.print(`${ERROR[errorName][key]}`);
});
},
export async function inputHandler({
promptMessage,
parser,
validatorMethod,
errorName,
}) {
try {
const userInput = await userInputEmptyHandler(promptMessage);
const parsedUserInput = parser ? Parser[parser](userInput) : userInput;
const parsedUserInputError = Validator[validatorMethod](parsedUserInput);
Output.printErrorResults(parsedUserInputError, errorName);
throwError(parsedUserInputError);
return parsedUserInput;
} catch (error) {
return inputHandler({
promptMessage,
parser,
validatorMethod,
errorName,
});
}
}
인풋핸들러의 로직은 이러합니다.
인자로 실행할 input의 promptMessage와, 해당 인풋의 파서를 받고 어떤 유효성 검증을 해야하는지 validatorMethod 명을 받습니다. 그리고 ERROR 메세지를 출력할 때, 해당 인풋을 위한 에러메세지를 모아둔 상수에 키로 접근하기 위해 errorName을 받습니다.
그러면 순차적으로 빈값 검증->파싱->유효성 검증->return 이 실행됩니다.
이렇게 하면 장점이
1. 유효성 검증이 어긋난 모든 에러의 메세지를 출력할 수 있습니다.
2. 같은 검증 오류(보너스 넘버 입력과 당첨번호 입력을 할 때 둘다 1~45 사이의 숫자인지를 검증하는 등)를 반환해도 다른 메세지 처리를 할 수 있습니다.
3. 반복되는 input 로직을 템플릿화 할 수 있었습니다.
이러한 장점을 노리고 작성하였으나... 아직은 리팩토링이 부족한 것 같습니다.
제가 생각한 이 방식의 단점은
1. 확장성과 유연성을 위해 이렇게 유틸화를 하였으나 정작 이 핸들러를 사용하기 위해 개발자가 사용법과 구조를 익혀야하는 문제
2. 도메인과 UI 로직 간 분리가 애매해진 것 같다.
3. 높은 결합도
4. 과연 인풋과 에러 핸들링을 위해 이렇게까지 작성되어야할 가치가 있을까?
5. 호출해야할 메서드를 문자열로 감싸 적기 때문에 오타가 날 가능성이 높고, 오타로 인한 오류를 디버깅하기 힘들다
이 부분은 좀 더 고민해보고 있습니다. 리뷰어님의 의견이 궁금합니다!🥹
긴 글 읽어주셔서 감사합니다🥹 좋은 하루되세요 케빈!
LottoMachine의 역할이 모호하여 중간 계층 역할을 부여하는 방향으로 리팩토링 필요LottoMachine이 단순 로또 생성기에 불과하여 비즈니스 로직 추가 검토 필요TDD 적용 방식 점검 → LottoMachine 관련 테스트 부족 (당첨 번호 비교 및 수익률 계산 테스트 필요)InputHandler와 Validator 설계 논리적이지만 사용성이 어려움 → 재사용성 및 가독성 개선 필요Validator에서 boolean 객체 대신 배열 반환 방식 검토LottoMachine의 역할이 중간 계층으로 설계되었으나, Main(Controller) 클래스가 일부 역할을 수행하여 모호한 상태LottoMachine을 로또 구매 및 당첨 결과까지 처리하는 방향으로 리팩토링 검토Lotto → LottoMachine → Main 순으로 단위 테스트 작성된 점은 긍정적LottoMachine 관련 테스트가 부족하여 추가 필요 (당첨 번호 비교 및 수익률 계산 테스트 등)Main.js의 play() 흐름을 테스트할 수 있는 코드 추가 검토Validator에서 boolean 객체 대신 배열 반환으로 가독성 및 유지보수성 개선 검토MVC 패턴에서 컨트롤러는 사용자의 입력을 처리하고 모델과 뷰를 연결하는 역할Main.js가 LottoMachine의 버튼을 누르는 역할을 수행하도록 설계됨LottoMachine이 너무 많은 책임을 가지지 않도록 분리 필요 (예: 당첨 계산을 별도 클래스로 분리)let 사용 금지로 인해 while 문을 활용한 루프 구조 적용이 어려웠음while 문 내부에서 값을 갱신하는 방식 고려export async function inputHandler(inputMethod, parser, validator) {
return (async function askUserInput() {
const userInput = await inputMethod();
const parsedUserInput = parser ? parser(userInput) : userInput;
const errors = validator(parsedUserInput);
if (Object.values(errors).some(Boolean)) {
Output.printErrorResults(errors);
return askUserInput();
}
return parsedUserInput;
})();
}
// ❌ getter 사용 예시
if (lotto.numbers.includes(bonusNumber)) {
throw new Error("보너스 번호는 당첨 번호와 중복될 수 없습니다.");
}
// ✅ 메시지를 보내는 방식
if (lotto.has(bonusNumber)) {
throw new Error("보너스 번호는 당첨 번호와 중복될 수 없습니다.");
}
부모 클래스의 기능을 그대로 가져와 확장하는 방식
단점: 부모 클래스의 변경이 하위 클래스에 영향을 줄 수 있음 (강한 결합)
class WinningLotto extends Lotto {
constructor(numbers, bonusNumber) {
super(numbers);
if (numbers.includes(bonusNumber)) {
throw new Error("보너스 번호는 당첨 번호와 중복될 수 없습니다.");
}
this.bonusNumber = bonusNumber;
}
}
기존 클래스를 객체로 포함하여 재사용하는 방식
장점: 각 객체가 독립적으로 존재하며, 유연한 구조를 가짐 (느슨한 결합)
class WinningLotto {
constructor(winningLotto, bonusNumber) {
if (winningLotto.has(bonusNumber)) {
throw new Error("보너스 번호는 당첨 번호와 중복될 수 없습니다.");
}
this.winningLotto = winningLotto;
this.bonusNumber = bonusNumber;
}
}
단순한 코드 재사용을 위해 상속을 남용하지 않는다.
상속보다는 조합(Composition)을 우선적으로 고려.
계층 구조가 필요하거나, 상속이 유리한 경우에는 상속을 사용.
객체의 역할을 명확히 정의하고 하나의 책임만 부여
관련된 기능을 한 곳에 모아 변경 범위를 최소화
내부 동작을 숨기고, 필요한 기능만 노출하여 유지보수성 향상
상속보다는 조합을 우선적으로 고려하여 유연한 구조 설계
프론트엔드 개발에서도 객체 지향 개념을 배울 필요가 있음!
하지만 목적은 개념 자체를 배우는 것이 아니라 유지보수성과 확장성이 좋은 코드를 작성하는 것! 🚀
변경점 브리핑
class LottoMachine {
#lottos;
constructor(LottoClass) {
this.LottoClass = LottoClass;
this.winnings;
}
publishLottos(money) {
const count = Math.floor(money / PRICE.LOTTO);
this.#lottos = Array.from({ length: count }).map(
() => new this.LottoClass()
);
...
export default class Main {
async play() {
const lottoMachine = new LottoMachine(Lotto);
const purchasePrice = await Input.purchasePrice();
const publishedLottos = lottoMachine.publishLottos(purchasePrice);
Output.printLottos(publishedLottos);
await this.defineWinningRules(lottoMachine);
await this.printLottoResult(lottoMachine, purchasePrice);
const isRestart = await Input.restartLotto();
if (isRestart) await this.play();
}
async defineWinningRules(lottoMachine) {
const winningNumbers = await Input.winningNumbers();
const bonusNumber = await Input.bonusNumber(winningNumbers);
const winnings = new Winnings(winningNumbers, bonusNumber);
lottoMachine.defineRule(winnings);
return winnings;
}
async printLottoResult(lottoMachine, purchasePrice) {
const { countStatistics, winningRate } =
lottoMachine.drawWinning(purchasePrice);
Object.entries(countStatistics).forEach(([rank, amount]) =>
Output.matchResult(rank, amount)
);
Output.winningRate(winningRate);
Output.newLine();
}
}
메인 컨트롤러는 전체적인 흐름을 관리하는 역할을 하고 로또 머신이 좀 더 도메인 로직 역할을 분담받도록 리팩토링 진행하였습니다.
여기서 컨트롤러는 로또 판매점 사장입니다. 사장은 로또가 어떤 로직으로 발행되고 관리되고 계산되는지 알 필요 없이 구매자가 구매를 원하는 금액을 입력하면 발행된 복권을 확인할 수 있고 당첨번호를 넣으면 당첨 통계 및 수익률을 스크린으로 확인할 수 있으면 된다고 생각했습니다.
라고 했던 저의 의도대로 메인컨트롤러는 단순히 로또 머신의 버튼을 흐름대로 누른다, 머신 내부의 일을 사장이 알 필요는 없다는 관점에 집중해서 리팩토링하였습니다.
(위의 코멘트는 케빈이 못 보셨을 수도 있습니다! 리뷰 작성해주시고 계신 줄 모르고 PR 본문 메세지를 수정했어서요...!🥹)
또한, Lotto가 추후 AmericanLotto같은 게 생길 수도 있으니 Lotto 객체 자체를 컨트롤러에서 LottoMachine에 주입하는 식으로 조합을 의도해서 작성하였습니다.
마지막으로, Winnings.js 같이 당첨룰을 정의하는 방식도 추후 달라질 수 있다고 생각해, 메인컨트롤러에서 Winnings 인스턴스를 만들고 로또 머신에 defineRules(winning)으로 주입하여 주입받은 로직에 맞게 당첨자를 가려내는 식으로 리팩토링하였습니다!
리뷰어 답변
-> 우선 지금 컨트롤러를 로또 판매점 사장에 비유하면서, 로직의 세부 사항을 몰라도 흐름을 관리하는 역할로 정리한 점이 매우 직관적이고 좋은 접근 방식이라고 생각해요 특히, 컨트롤러가 LottoMachine의 버튼을 누르는 역할에 집중하고, 내부 로직에 관여하지 않도록 한 점이 설계적으로 적절해 보입니다.
몇가지만 더 고려해보면 LottoMachine이 너무 많은 책임을 가지게 되지는 않았을까?를 고민해보면 좋을거 같아요 LottoMachine이 로또 발행뿐만 아니라, 당첨 확인까지 담당하게 되면서 "로또 발행기"라기보다는 "로또 게임 관리자"에 가까운 역할이 된거 같아요. 만약 역할을 더 명확히 분리하려면 당첨 계산을 별도의 클래스로 분리할 수도 있을거 같아요