세 번째 주차가 되니 이제 우테코의 학습 방식에도 조금씩 익숙해지고 있는 것 같습니다.
3주차 미션에서는 2주차와 다르게 요구사항에서 변경점이 있었습니다.
첫번째는 사용자가 잘못된 값을 입력할 경우 throw문을 사용해 예외를 발생시키면서 [ERROR]로 시작하는 에러메시지 까지는 똑같지만 이후에 해당 부분부터 입력을 다시 받는다. 라는 요구가 추가되었습니다.
추가적으로
Lotto클래스가 추가적으로 제공되었고 필드와 private는 변경할수 없었습니다.
처음 이 클래스를 보고 '이부분은 도메인에서 로또번호를 보고 전반적인 번호들을 관리하는 곳 이구나' 라고 생각이 들었습니다.
이번에도 MVC패턴을 최대한 이용해보려고 노력했었습니다.
폴더구조
+ src
+ constants
└ ErrorMessages.js ------ 에러 메시지에 사용되는 상수를 저장
└ GameSettings.js ------ 게임 설정에 관련된 상수를 저장
└ Prizes.js ------ 로또 등수와 금액에 관련된 상수를 저장
└ UiConstants.js ------ 입력 과 출력에 관련된 메시지들을 상수로 저장
+ controller
└ LottoGameController.js ---- 게임의 전체 흐름을 제어하는 컨트롤
+ model
└ Lotto.js ----- 로또의 데이터 구조와 로직, 유효성 검사
└ LottoMachine.js --- 로또 번호를 생성하는 로직
└ LottoResult.js ----- 게임 결과를 계산하고 저장
+ utils
└ InputValidator.js ------ 사용자 입력을 검증하는 클래스
└ GenerateRandomNumberUtils.js ----- 로또 번호를 랜덤으로 생성하는 유틸
+ view
└ InputView.js ------ 사용자로부터 입력을 처리하는 뷰
└ OutputView.js ------ 게임의 결과를 출력하는 뷰
import InputView from '../view/InputView';
import OutputView from '../view/OutputView';
import InputValidator from '../utils/InputValidator';
import LottoMachine from '../model/LottoMachine';
import LottoResult from '../model/LottoResult';
export default class LottoGameController {
#lottoMachine;
constructor() {
this.#lottoMachine = new LottoMachine();
}
async start() {
const purchaseAmount = await this.#getPurchaseAmount();
const tickets = this.#generateTickets(purchaseAmount);
const winningNumbers = await this.#getWinningNumbers();
const bonusNumber = await this.#getBonusNumber(winningNumbers);
const lottoResult = this.#evaluateResults(winningNumbers, bonusNumber, tickets);
this.#displayResults(lottoResult);
}
async #getInput(inputFunction) {
return inputFunction();
}
async #getValidatedInput(inputFunction, validationFunction) {
while (true) {
try {
const input = await this.#getInput(inputFunction);
validationFunction(input);
return input;
} catch (error) {
OutputView.displayError(error.message);
}
}
}
async #getPurchaseAmount() {
return this.#getValidatedInput(
InputView.getPurchaseAmount,
InputValidator.validatePurchaseAmount,
);
}
#generateTickets(purchaseAmount) {
const tickets = this.#lottoMachine.generateTickets(purchaseAmount);
OutputView.displayTickets(tickets);
return tickets;
}
async #getWinningNumbers() {
return this.#getValidatedInput(
InputView.getWinningNumbers,
InputValidator.validateWinningNumbers,
);
}
async #getBonusNumber(winningNumbers) {
return this.#getValidatedInput(
() => InputView.getBonusNumber(winningNumbers),
(bonusNumber) => InputValidator.validateBonusNumber(bonusNumber, winningNumbers),
);
}
#evaluateResults(winningNumbers, bonusNumber, tickets) {
return new LottoResult(winningNumbers, bonusNumber, tickets);
}
#displayResults(lottoResult) {
const resultStrings = lottoResult.getFormattedResultString();
OutputView.displayResults(resultStrings);
OutputView.displayProfitRate(lottoResult.calculateProfitRate());
}
}
컨트롤러에서는 로또 미션에서 전체적인 게임 흐름을 담당하고있습니다.
사용자 입력을 받아 검증하고 해당 금액에 해당하는 티켓을 생성해주고 당첨 번호와 보너스 번호를 검증하면서 당첨 결과를 평가하고 결과를 출력하는 흐름 로직을 담당하고있습니다.
초기 코드에서는 입력을 받는 각 시점에 try-catch 문을 사용하여 예외를 처리하고 있었습니다.하지만 이렇게 하면 코드가 중복되는것 이많고 , 같은 종류의 예외를 여러 곳에서 처리해야하는 문제가 있었습니다.
이러한 문제를 해결하기 위해 저는 입력 받기 와 유효성 검증 이라는 두 가지 주요 작업을 추상화하여 각각의 함수로 분리했습니다.
#getInput 함수는 입력을 받는 로직을 #getValidatedInput 함수는 입력의 유효성을 검증하는 로직을 담당합니다. 여기서 #getValidatedInput 에서는 try-catch문을 사용하여 유효한 입력이 들어올 때 까지 사용자에게 재입력을 요구합니다.
이러한 리팩토링을 통해서 중복되는 try-catch문을 하나의 메서드로 통합할 수 있었고 이는 코드의 가독성을 향상 시키고 후에 변경 사항이 발생했을 때 대응력을 높이는데 큰 도움이 되는 것 같습니다.
import { ERROR_MESSAGES } from '../constants/ErrorMessages';
import { GAME_SETTINGS } from '../constants/GameSettings';
export default class InputValidator {
static validatePurchaseAmount(amount) {
InputValidator.#ensureIsNumeric(amount);
InputValidator.#ensureValidPurchaseAmount(amount);
}
static validateWinningNumbers(winningNumbersString) {
return InputValidator.#parseAndValidateNumbers(winningNumbersString);
}
static validateBonusNumber(bonusNumberString, winningNumbersString) {
const bonusNumber = InputValidator.#parseNumber(bonusNumberString);
const winningNumbers = InputValidator.#parseNumbers(winningNumbersString);
InputValidator.#ensureBonusNumberNotDuplicate(bonusNumber, winningNumbers);
}
static #ensureIsNumeric(amount) {
if (!/^\d+$/.test(amount)) {
throw new Error(ERROR_MESSAGES.INVALID_FORMAT);
}
}
static #ensureValidPurchaseAmount(amount) {
const numericAmount = parseInt(amount, 10);
if (numericAmount <= 0 || numericAmount % GAME_SETTINGS.TICKET_PRICE !== 0) {
throw new Error(ERROR_MESSAGES.INVALID_AMOUNT);
}
}
static #parseAndValidateNumbers(numbersString) {
const numbers = InputValidator.#parseNumbers(numbersString);
InputValidator.#validateNumbers(numbers);
return numbers;
}
static #parseNumbers(numbersString) {
return numbersString.split(',').map(InputValidator.#parseNumber);
}
static #parseNumber(numString) {
const number = parseInt(numString.trim(), 10);
if (Number.isNaN(number) || !InputValidator.#isNumberInRange(number)) {
throw new Error(ERROR_MESSAGES.INVALID_NUMBER);
}
return number;
}
static #validateNumbers(numbers) {
if (
numbers.length !== GAME_SETTINGS.NUMBERS_PER_TICKET ||
new Set(numbers).size !== numbers.length
) {
throw new Error(ERROR_MESSAGES.INVALID_NUMBERS);
}
numbers.forEach(InputValidator.#ensureNumberInRange);
}
static #ensureNumberInRange(number) {
if (!InputValidator.#isNumberInRange(number)) {
throw new Error(ERROR_MESSAGES.NUMBER_OUT_OF_RANGE);
}
}
static #ensureBonusNumberNotDuplicate(bonusNumber, winningNumbers) {
if (winningNumbers.includes(bonusNumber)) {
throw new Error(ERROR_MESSAGES.BONUS_NUMBER_DUPLICATE);
}
}
static #isNumberInRange(number) {
return number >= GAME_SETTINGS.MIN_LOTTO_NUMBER && number <= GAME_SETTINGS.MAX_LOTTO_NUMBER;
}
}
위의 중복코드 처리를 바탕으로 입력값을 검증할 때 로또미션을 하면서 숫자검증에 대해 중복되는 코드가 너무 많았습니다. 예를들어 전체적으로 숫자로 통일해야되고 문자열이 들어가면 안되고 당첨번호와 보너스 번호를 입력할때에도 1부터 45까지의 숫자였었습니다. 이를 통해 중복되는 메서드들을 캡슐화를 통해 접근을 막고 필요한 메서드들만 노출하게 하였습니다.
또한 InputValidator 에서는 정적 메서드인 static을 사용하고 있습니다. 이러한 정적 메서드의 장점에는
1. 인스턴스 생성이 불필요
2. 메모리 효율성
3. 명확성
4. 재사용성
이러한 정적 static으로 정의햇던 것은 이 메서드들이 어떠한 로또 번호 입력에 대해서도 일관되게 동작해야 하며 , 이러한 유효성 검증 기능이 해당 미션에서 여러 부분에 재사용될 수 있다는 점을 생각하였습니다. 또한 유지보수성을 극대화 한다는 장점이 있습니다.
model ( 도메인 )
import { GAME_SETTINGS } from '../constants/GameSettings';
import { ERROR_MESSAGES } from '../constants/ErrorMessages';
export default class Lotto {
#numbers;
constructor(numbers) {
this.#validate(numbers);
this.#numbers = this.#sortNumbers(numbers);
}
#validate(numbers) {
this.#validateNumbersCount(numbers);
this.#validateUniqueness(numbers);
this.#validateNumberRange(numbers);
}
#validateNumbersCount(numbers) {
if (numbers.length !== GAME_SETTINGS.NUMBERS_PER_TICKET) {
throw new Error(ERROR_MESSAGES.INVALID_NUMBERS);
}
}
#validateUniqueness(numbers) {
if (new Set(numbers).size !== GAME_SETTINGS.NUMBERS_PER_TICKET) {
throw new Error(ERROR_MESSAGES.INVALID_NUMBERS);
}
}
#validateNumberRange(numbers) {
if (
numbers.some(
(number) =>
number < GAME_SETTINGS.MIN_LOTTO_NUMBER || number > GAME_SETTINGS.MAX_LOTTO_NUMBER,
)
) {
throw new Error(ERROR_MESSAGES.NUMBER_OUT_OF_RANGE);
}
}
#sortNumbers(numbers) {
return [...numbers].sort((a, b) => a - b);
}
includesBonusNumber(bonusNumber) {
return this.#numbers.includes(bonusNumber);
}
matchNumbers(winningNumbers) {
return this.#numbers.filter((number) => winningNumbers.includes(number)).length;
}
get numbers() {
return this.#numbers;
}
}
이번 미션에서는 모델 즉 도메인 Lotto.js가 요구사항에 있을 뿐더러 제한사항도 있었습니다. 이러한 의도로 봐서 해당 클래스는 로또 번호가 갖추어야할 조건들을내부적으로 검증해야 했던 것이였습니다.
Lotto클래스는 캡슐화를 통해 객체의 내부 표현을 숨기고, 외부 인터페이스를 통해서만 상호작용할 수 있도록 하여 이는 객체의 상태를 예측 가능하게 하면서 유지보수또한 용이하게 합니다.
import Lotto from './Lotto';
import { generateUniqueNumbers } from '../utils/GenerateRandomNumberUtils';
import { GAME_SETTINGS } from '../constants/GameSettings';
export default class LottoMachine {
generateTickets(purchaseAmount) {
const numberOfTickets = purchaseAmount / GAME_SETTINGS.TICKET_PRICE;
const tickets = Array.from({ length: numberOfTickets }, () => {
const numbers = generateUniqueNumbers(
GAME_SETTINGS.MIN_LOTTO_NUMBER,
GAME_SETTINGS.MAX_LOTTO_NUMBER,
GAME_SETTINGS.NUMBERS_PER_TICKET,
);
return new Lotto(numbers);
});
return tickets;
}
}
import { GAME_SETTINGS } from '../constants/GameSettings';
import { PRIZES } from '../constants/Prizes';
export default class LottoResult {
#winningNumbers;
#bonusNumber;
#tickets;
#result;
constructor(winningNumbers, bonusNumber, tickets) {
this.#winningNumbers = winningNumbers;
this.#bonusNumber = bonusNumber;
this.#tickets = tickets;
this.#result = this.#initializeResult();
this.#calculateResults();
}
#initializeResult() {
return Object.keys(PRIZES).reduce((acc, key) => {
acc[key] = { count: 0, prize: PRIZES[key] };
return acc;
}, {});
}
#calculateResults() {
this.#tickets.forEach((ticket) => {
const matchCount = ticket.matchNumbers(this.#winningNumbers);
const isBonusMatch = matchCount === 5 && ticket.includesBonusNumber(this.#bonusNumber);
const key = isBonusMatch ? '5+1' : matchCount;
if (this.#result[key]) {
this.#result[key].count += 1;
}
});
}
getFormattedResultString() {
const sortOrder = [3, 4, 5, '5+1', 6];
return sortOrder
.map((match) => {
const data = this.#result[match];
const matchText = GAME_SETTINGS.MATCH_TEXTS[match];
return `${matchText} (${data.prize.toLocaleString()}원) - ${data.count}개`;
})
.join('\n');
}
calculateProfitRate() {
const totalSpent = this.#tickets.length * GAME_SETTINGS.TICKET_PRICE;
const totalPrize = this.#calculateTotalPrize();
return Number(((totalPrize / totalSpent) * 100).toFixed(2));
}
#calculateTotalPrize() {
return Object.values(this.#result).reduce((acc, { count, prize }) => acc + count * prize, 0);
}
}
나머지 LottoMachine.js와 LottoResult.js도 비즈니스 모델이며 각자의 역할을 캡슐화를 통해 객체의 내부 표현을 숨기고 외부 인터페이스에서만 상호작용할 수 있도록 설계하였습니다.
마무리
이번 프리코스 미션을 통해 객체 지향 프로그래밍의 근본적인 개념에 대해 깊이 탐구할 수 있었습니다. 특히 Lotto클래스의 설계를 통해 캡슐화의 중요성과 코드의 안정성을 확보하는 방법을 실천적으로 이해하게 되었습니다.
저는 개선의 여지를 발견하고 반복되는 try-catch 블록을 고도화된 에러 처리 메커니즘으로 추상화 하였습니다. 또한 단순히 기능 구현에만 그치지 않고 왜 그런 구조를 선택했는지에 대해 깊이 있는 고민을 했습니다.
하지만 초기에는 유효성 검증 로직을 여러 곳에서 중복해서 사용했었습니다. 이는 코드의 중복을 증가시켰고, 유지보수의 복잡성을 불러일으켰습니다. 리팩토링 과정에서 중복을 제거하는 것에 집중했지만, 더욱 체계적인 접근을 할 수 있었으면 하는 아쉬움이 남습니다.
이번 학습 과정은 단순히 기술적 지식을 넘어서, 실제로 사용자의 입장에서 안전하고 신뢰할 수 있는 코드를 작성하는 방법에 대해 생각해보는 기회였습니다. 보안 대회에서도 인젝션 해킹과 입력값을 변조해서 해킹 당하는 실제 사례도 많기 때문에 입력값 검증에대해서도 더 신중하게 생각했던 것 같습니다.
4주차가 마지막 미션입니다. 이제 한주밖에 남지 않은 시간이지만 이번에 배운 교훈을 살려 더욱 견고하고 효율적인 코드를 작성하는데 초점을 맞출 계획입니다. 또한 이러한 설계 원칙이 어떻게 적용될 수 있는지에 대해 코드 리뷰를 통해 더 많은 대화를 나눠야 할 것 같습니다.
여러분들의 지난 2주차와 3주차의 코드를 보며 어떤 식으로 성장해 나아갔는지 살펴보는 것을 추천드립니다. 감사합니다.
'우아한테크코스' 카테고리의 다른 글
우아한테크코스 6기 프리코스 4주차 마지막 회고 (1) | 2023.11.16 |
---|---|
우아한테크코스 6기 프리코스 2주차 회고 (0) | 2023.11.02 |
JEST 테스트결과를 HTML로 보여주자! (0) | 2023.11.02 |
우아한테크코스 6기 프리코스 1주차 회고 (0) | 2023.10.23 |