[우아한테크코스] 2주차 회고 - 로또

Nayoung·2025년 3월 3일
1

1. 👆 1단계 : 로또 - 페어 프로그래밍

기간: 2/18~2/25

1.1 제출한 코드

https://github.com/woowacourse/javascript-lotto/pull/352

1) 이번 단계에서 가장 많이 고민했던 문제와 해결 과정에서 배운 점

크게 두 가지를 중점으로 신경써 작업하였습니다.

  • InputHandler로 반복되는 UI 로직 및 검증을 템플릿화 하고 유연한 에러핸들링 작성
  • 중간 레이어 객체 LottoMachine의 설계
  • 중복 코드 제거 및 적절한 의존성 주입/조합으로 결합도를 낮추고 응집도를 높여 확장성과 유지보수성 높이기

2) 이번 리뷰를 통해 논의하고 싶은 부분

  • LottoMachine 이 현재 중간 레이어 객체의 역할을 잘 하고 있는지?
    LottoMachine은 Lotto, Winnings 클래스와 Main(Controller) 클래스 사이에서 중간 계층 역할을 수행하도록 설계되었습니다. 저는 컨트롤러를 "사람", 모델을 "로또머신과 로또 머신에 필요한 부품들", 뷰를 로또머신의 스크린으로 생각하며 리팩토링을 해보았습니다.

여기서 컨트롤러는 로또 판매점 사장입니다. 사장은 로또가 어떤 로직으로 발행되고 관리되고 계산되는지 알 필요 없이 구매자가 구매를 원하는 금액을 입력하면 발행된 복권을 확인할 수 있고 당첨번호를 넣으면 당첨 통계 및 수익률을 스크린으로 확인할 수 있으면 된다고 생각했습니다.
그래서 저희의 컨트롤러인 Main은 딱 저 정도의 역할 수행을 합니다.

로또 머신은 여러 기능들을 캡슐화되어 갖고 있는 중간 계층 모델입니다. 예를 들어 사장이 로또 머신에 구입금액만 입력해도 로또 머신은 알아서 순차적으로 메소드들을 실행해 계산된 반환값을 사장에게 보여줍니다.

그 외 Lotto, Winnings 등의 모델은, 로또 머신이 로직을 수행하기 위해 필요한 로또 발행 규칙과 우승자 판별 규칙을 갖고 있는 객체입니다.

여기서 Lotto, Winnings, LottoMachine을 작성하면서 신경쓴 점은,

  • Lotto는 나중에 AmericanLotto등, 다른 구성을 가진 로또가 왔을 때도 대응할 수 있도록 로또 머신의 로또를 조합으로 생성하였습니다.
  • winnings와 같은 당첨 로직은 추후 변경사항이 생길 수도 있는 부분이라고 생각해서 컨트롤러에서 winnings 인스턴스를 생성해 로또 머신에 인스턴스를 주입하는 식으로 작성하였습니다.
  • 재귀함수에 대한 처리를 이렇게 해도 되는지?
// 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에 대한 케빈의 생각도 궁금합니다🥹

+3) 설계 설명

우선, 가장 난해하게 작성된 부분이 InputHandler와 Validator라 생각해서 두 부분에 대해서 설명을 드리려고합니다...ㅠㅠ

a. InputHandler와 Validator의 존재 이유

'꼭 Validator와 input 함수가 에러로 소통해야 할까?'라는 생각이 들었습니다!
잘못된 입력을 알려주는 것도 View 영역이라면, 판단은 Validator가 하고, 어떤 문자열을 보내줄지는 input 함수에서 책임지는 방법은 어떤가요?

이게 제가 지난 미션에서 받았던 코드 리뷰입니다. 이 부분에 대해서 확실히, 검증은 검증만 하고 view는 입출력만 하면되지 않나? 그렇다면, 에러 메세지에 따른 결정은 view가 해야하지 않을까? 하는 생각이 들었습니다.

그리고, 이번 미션에서 추가된 요구사항이 throw Error를 발생시켜야한다는 것이었는데 이 에러를 발생시키는 일을 Validator나 View가 하지 말고 유틸함수를 호출해 컨트롤러에서 발생해야하지 않을까 생각했습니다.

그래서 throwError라는 유틸함수가 추가로 만들어졌습니다.

b. InputHandler와 Validator의 로직 설명

먼저, 인풋핸들러와 벨리데이터는 객체 에러 코드로 소통합니다. 검증하는 로직은 계속 반복되기 때문에 각 검증 로직 키에 boolean 값을 받아, 어떤 키에 true값이 켜졌는지에 따라서 해당 인풋에 따라 미리 정의되어있는 해당 인풋메서드 전용 상수 메세지에 접근해 해당 에러메세지를 결정하는 식입니다. 이를 위해 키값의 조회가 잦게 일어났습니다.

  • *Validator
    먼저 Validator는 불리언을 반환하는 검사 로직의 모음집인 validationUtils 라는 객체가 정의되어있습니다.
    그리고, Validator는 이 유틸에서 로직을 재활용하며 각 인풋필드에 맞는 메서드를 갖고 있습니다.
    이렇게 한 이유는 입력필드마다 다른 유효성 검사를 진행해야하기 때문에, view나 controller에서 검증을 진행할 때 그에 맞는 메서드 하나만 호출하는 것이 좋을 것 같았기 때문입니다.
 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]}`);
    });
  },
  • *InputHandler
    inputHandler는 try...catch를 이용한 재귀함수 방식과, 사용자의 input 값을 빈값 검증->파싱->유효성 검증->return 하는 방식이 모든 input마다 반복되어 템플릿화하기 위해 만들어진 함수입니다.
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. 호출해야할 메서드를 문자열로 감싸 적기 때문에 오타가 날 가능성이 높고, 오타로 인한 오류를 디버깅하기 힘들다

이 부분은 좀 더 고민해보고 있습니다. 리뷰어님의 의견이 궁금합니다!🥹

4. 아쉬운 점

  • 리팩토링을 진행하며 기존 TDD방식으로 작성하던 로직과 컨트롤러 로직이 크게 달라진 부분이 있어서 단위테스트가 제대로 작성되지 못했습니다
  • 시간이 부족해 상수화가 완전히 진행되지 못했습니다

긴 글 읽어주셔서 감사합니다🥹 좋은 하루되세요 케빈!

1.2 피드백

1.2.1 리뷰어 피드백

코드 리뷰 요약

  • 네이밍 컨벤션 통일 필요 (CamelCase, snake_case, kebab-case 혼합 문제)
  • LottoMachine의 역할이 모호하여 중간 계층 역할을 부여하는 방향으로 리팩토링 필요
  • LottoMachine이 단순 로또 생성기에 불과하여 비즈니스 로직 추가 검토 필요
  • TDD 적용 방식 점검 → LottoMachine 관련 테스트 부족 (당첨 번호 비교 및 수익률 계산 테스트 필요)
  • InputHandlerValidator 설계 논리적이지만 사용성이 어려움 → 재사용성 및 가독성 개선 필요
  • 문자열을 통한 메서드 호출 방식 대신 객체 기반 직접 함수 전달 방식 고려
  • Validator에서 boolean 객체 대신 배열 반환 방식 검토

Q & A 요약

1. LottoMachine의 역할이 적절한가?

  • 현재 LottoMachine의 역할이 중간 계층으로 설계되었으나, Main(Controller) 클래스가 일부 역할을 수행하여 모호한 상태
  • LottoMachine을 로또 구매 및 당첨 결과까지 처리하는 방향으로 리팩토링 검토
  • 만약 필요하지 않다면 단순 로또 생성 함수로 대체 가능

2. TDD 적용 방식

  • 의존성이 낮은 Lotto → LottoMachine → Main 순으로 단위 테스트 작성된 점은 긍정적
  • LottoMachine 관련 테스트가 부족하여 추가 필요 (당첨 번호 비교 및 수익률 계산 테스트 등)
  • Main.jsplay() 흐름을 테스트할 수 있는 코드 추가 검토

3. InputHandler와 Validator의 역할 분리

  • 현재 구조가 재사용성을 높이려는 의도는 있으나 사용성이 어려움
  • 문자열을 통한 메서드 호출 방식은 오타 및 디버깅 어려움이 존재 → 객체 기반 직접 함수 전달 방식 고려
  • Validator에서 boolean 객체 대신 배열 반환으로 가독성 및 유지보수성 개선 검토

4. 컨트롤러의 역할 정의

  • MVC 패턴에서 컨트롤러는 사용자의 입력을 처리하고 모델과 뷰를 연결하는 역할
  • Main.jsLottoMachine의 버튼을 누르는 역할을 수행하도록 설계됨
  • LottoMachine이 너무 많은 책임을 가지지 않도록 분리 필요 (예: 당첨 계산을 별도 클래스로 분리)

5. 재귀 호출 vs while 문

  • 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;
  })();
}

1.2.2 수업 피드백

📝 프론트엔드 개발에서 객체 지향을 배우는 이유

🔹 객체 지향을 배우는 목적

  • 단순히 OOP 개념을 배우는 것이 아니라 유지보수하기 좋은 코드를 작성하는 것이 목표
  • 예측 가능하고 파악하기 쉬운 코드 작성
    • 객체를 사용할 때 어떤 기능을 써야 하는지 명확해야 함
  • 수정 및 확장이 쉬운 코드 유지
    • 새로운 기능 추가/변경이 용이해야 함

1️⃣ 객체 나누기 (모듈화)

🎯 역할, 책임, 협력 원칙

  • 자율적이고 협력적인 객체 설계
  • 객체의 자율성 = 내부 구현을 숨기고(캡슐화), 필요한 기능만 공개
  • 객체의 협력성 = 다른 객체와의 상호작용을 고려

📌 설계 원칙

  • 객체는 하나의 책임만 가지도록 한다.
  • 하나의 기능을 변경할 때 하나의 객체만 변경하도록 설계.
  • 데이터는 객체 내부에 캡슐화하고, 객체가 직접 처리하도록 한다.
  • getter 대신 객체에게 메시지를 보내서 데이터 조작을 유도.
// ❌ getter 사용 예시
if (lotto.numbers.includes(bonusNumber)) { 
    throw new Error("보너스 번호는 당첨 번호와 중복될 수 없습니다.");
}

// ✅ 메시지를 보내는 방식
if (lotto.has(bonusNumber)) { 
    throw new Error("보너스 번호는 당첨 번호와 중복될 수 없습니다.");
}

2️⃣ 코드 재사용 (중복 제거)

🎯 상속(Inheritance) vs 조합(Composition)

🔹 상속 (Inheritance)

부모 클래스의 기능을 그대로 가져와 확장하는 방식
단점: 부모 클래스의 변경이 하위 클래스에 영향을 줄 수 있음 (강한 결합)

class WinningLotto extends Lotto {
  constructor(numbers, bonusNumber) {
    super(numbers);

    if (numbers.includes(bonusNumber)) {
      throw new Error("보너스 번호는 당첨 번호와 중복될 수 없습니다.");
    }

    this.bonusNumber = bonusNumber;
  }
}

🔹 조합 (Composition)

기존 클래스를 객체로 포함하여 재사용하는 방식
장점: 각 객체가 독립적으로 존재하며, 유연한 구조를 가짐 (느슨한 결합)

class WinningLotto {
  constructor(winningLotto, bonusNumber) {
    if (winningLotto.has(bonusNumber)) {
      throw new Error("보너스 번호는 당첨 번호와 중복될 수 없습니다.");
    }

    this.winningLotto = winningLotto;
    this.bonusNumber = bonusNumber;
  }
}

📌 정리

단순한 코드 재사용을 위해 상속을 남용하지 않는다.
상속보다는 조합(Composition)을 우선적으로 고려.
계층 구조가 필요하거나, 상속이 유리한 경우에는 상속을 사용.

3️⃣ 객체 지향을 활용한 코드 유지보수성 향상

객체의 역할을 명확히 정의하고 하나의 책임만 부여
관련된 기능을 한 곳에 모아 변경 범위를 최소화
내부 동작을 숨기고, 필요한 기능만 노출하여 유지보수성 향상
상속보다는 조합을 우선적으로 고려하여 유연한 구조 설계

✅ 결론

프론트엔드 개발에서도 객체 지향 개념을 배울 필요가 있음!
하지만 목적은 개념 자체를 배우는 것이 아니라 유지보수성과 확장성이 좋은 코드를 작성하는 것! 🚀

1.3 리팩토링 진행할 점

  • 인풋 핸들러가 메서드를 넘길 때 문자열로 넘겨서 휴먼에러를 발생시킬 수 있는 점 리팩토링
  • 로또 머신에게 중간 객체 레이어 역할 부여 && 메인 컨트롤러의 역할 완화
  • 배운 조합의 응용으로 의존성을 주입하고 결합도를 낮추며 응집도를 높여보기

1.4 공부해볼 것들

  • TDD 문법
  • 객체의 조합

2. 👆 1단계 : 로또 - 리팩토링

2.1 주요 변경점


변경점 브리핑

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이 로또 발행뿐만 아니라, 당첨 확인까지 담당하게 되면서 "로또 발행기"라기보다는 "로또 게임 관리자"에 가까운 역할이 된거 같아요. 만약 역할을 더 명확히 분리하려면 당첨 계산을 별도의 클래스로 분리할 수도 있을거 같아요


profile
프론트엔드 개발자로 성장하고 싶은 그래픽 디자이너입니다!

0개의 댓글