이 글은 우아한테크코스 6기 프리코스 3주차 미션 종료 직후 작성된 회고 글입니다.

스스로 고민한 내용이 글에 많이 포함되어 있기 때문에 본인 코드에 대해 충분히 고민한 후 읽으시는 것을 추천합니다.


🎯 주제

이번 3주차 미션은 로또 게임이었다. 확실히 이전 미션들보다 점점 요구사항도 많아지고 로직이 복잡해져서 어려움을 많이 느꼈던 것 같다.

특히 2주차 미션 후 코드리뷰를 통해 채찍을 많이 받았는데, 이를 바탕으로 3주차에는 더 성장한 모습을 느끼고 싶었다.

🎯 회고를 통한 수정

👍🏻 Static을 기능별로 나눠보자

사실 이 부분은 다른 분들의 코드를 살펴보다 발견한 점이었는데, 상당히 인상깊어서 한번 사용해봐야겠다는 생각을 했다. 기존에는 Static.js 파일 안에 에러메세지, 매직 넘버, 출력 메세지를 다 넣고 사용했었다. 하지만 다른 객체에서 import할 때에도 코드가 길어지고 정확한 의미가 담기기에는 역부족이었다. 그래서 기능별로 파일을 만들어서 가독성을 높이는데 힘을 쏟았다.

const MESSAGE = Object.freeze({
  purchase: '구입금액을 입력해 주세요.\n',
  purchaseAmount: '개를 구매했습니다.',
  winningNumber: '\n당첨 번호를 입력해 주세요.\n',
  bonusNumber: '\n보너스 번호를 입력해 주세요.\n',
  revenuePrefix: '총 수익률은',
  revenueSuffix: '%입니다.\n',
  resultStatic: '\n당첨통계\n---',
  staticSuffix: '개',
});

이렇게 사용하니 객체를 생성할 때 의미 중복되는 문제도 해결할 수 있었다.

👍🏻 반복문 사용 기준을 정해보자

코딩테스트 문제를 풀 때 항상 습관적으로 for 반복문을 사용했다. 하지만 코드의 길이가 조금 길어진다는 단점이 있고, for 반복문 외에도 사용할 수 있는 다양한 고차 함수가 있어서 프리코스에서 사용을 최대한 지양하고자 하였다.

하지만 이번에 같이 프리코스를 준비하는 동생이 ‘입력값이 말도 안되게 1천, 1억이 들어오면 고차함수를 사용할 때 메모리 낭비가 너무 심하지 않을까?‘라고 했던 말을 듣고 아차! 싶었다. 듣고보니 고차 함수 사용이 가독성이 좋고 정해진 값만큼의 순환을 할 때에는 효율적이지만 수가 커진다면 상당히 비효율적이기 때문이다.

따라서 이미 정해진 값의 순환은 고차함수를 사용하지만, 값을 User로부터 입력받는 경우라면 for문을 사용하는 것이 이상적일 것이라 판단했다.

// 로또의 개수는 입력값 영향을 받기 때문에 for문 사용
printLottos(lottos) {
  for (let i = 0; i < lottos.length; i += 1) {
    Console.print(`[${lottos[i].join(", ")}]`);
  }
}

// 결과 통계 배열은 5개로 이미 정해져있기 때문에 고차함수 사용
printResultStatistic(winningStatistic) {
  Console.print(MESSAGE.resultStatic);
  STATISTIC_RESULT.map((result, index) =>
    Console.print(
      `${result}${winningStatistic[NUMBER.arraySize - index]}${
        MESSAGE.staticSuffix
      }`
    )
  );
}

👍🏻 기능의 흐름대로 코드 작성하기

이 부분도 사실 고민이 많았다. 특히 컨트롤러에서 inputinput 끼리 나열하고 다른 기능은 다른 기능끼리 묶어 놓는 것이 더 통일성 있다고 생각했다. 하지만 이 코드를 읽는 입장에서 생각해본다면 처음 보는 코드를 여기갔다, 저기갔다 반복하다보면 나같아도 읽기 싫어질 것 같았다.

이번 3주차 미션부터는 위에서 아래로 읽는 방향으로 쭉 코드를 작성하고자 하였다.

 async #inputPurchasePrice() {
    ...
    await this.#purchaseLottos(price);
  }

  async #purchaseLottos(input) {
    ...
    await this.#inputWinningNumber();
  }

  async #inputWinningNumber() {
    ...
    await this.#inputBonusNumber();
  }

🎯 문제 요구 사항

추가된 요구 사항이 갑작스럽게 늘어나서 조금 당황했다. 이것 외에도 메일로 받았던 피드백 중에서 클래스를 분리해보는 연습 을 해보라고 써있었다.

이번 주차는 함수의 길이를 최대한 줄이면서 하나의 기능만 할 수 있게끔 만들어보는 것이 큰 과제라고 생각했다.(함수 관련 피드백이 무척 많기도…) 또한 단위 테스트 구현을 요구사항으로 준 것을 보니, 도메인 부분을 상세하게 나누는 것도 key pooint가 될 것 같았다.

🎯 기능 목록 작성

공통 피드백에서 이 두개의 요구사항이 사실 제일 지키기 어려웠다. 흠…보통 README에 기능 목록을 작성하는데…상세히 작성하되 설계와 구현과 같이 너무 상세하게 작성하지 않기라… 사실 2주차 미션 PR에서 PR comment로 본인이 작성한 기능들을 상세하게 적어놓으신 분들을 발견했다. 코드 리뷰를 할 때 확실히 눈에 띄는 부분은 있어서 인상깊었던 부분이었다.

내가 내린 결론은 기존에 작성했던 기능 목록을 조금 더 구체적인 기능별로 분리를 하되, README 파일 말고 PR comment로 조금 더 상세적인 프로젝트 설명을 붙이면서 마크다운 문법을 좀 익히기로 결정했다.

// README.md

## 기능 목록

- [x] 구매자와 관련된 로또를 생성

  - [x] 로또 구매 금액을 입력받는 기능
  - [x] 로또 수량만큼 랜덤으로 로또 배열 생성 기능
  - [x] 로또 수량과 로또 배열 출력하는 기능
  - [x] 구매 금액의 유효성을 검사하는 기능

- [x] 당첨 로또를 생성

  - [x] 당첨 번호 입력받는 기능
  - [x] 보너스 번호 입력받는 기능
  - [x] 당첨번호의 유효성을 검사하는 기능
  - [x] 보너스 번호의 유효성을 검사하는 기능

- [x] 구매자가 구매한 로또와 관련된 결과 반환

  - [x] 랜덤으로 생성된 로또 배열과 당첨번호, 보너스 번호를 비교하는 기능
  - [x] 당첨 내역을 출력하는 기능
  - [x] 수익률을 계산하는 기능

🎯 고난 그리고 배움

❌ 또 예외 처리에서 오류…?

이번 미션에서도 예상치 못한 에러 발생으로 해결하는데 오랜 시간이 소요됐다. 평소와 같이 에러처리를 하였는데 자꾸 에러처리 하는 부분에서 테스트 케이스가 정상적으로 돌아가지 않았다. 괜히 제가 작성했던 ValidatorInputView 코드만 몇시간을 붙잡았던 것 같다.

결국에는 ApplicationTest 코드의 runException 함수를 읽어봤다.

const runException = async (input) => {
  // given
  const logSpy = getLogSpy();

  const RANDOM_NUMBERS_TO_END = [1, 2, 3, 4, 5, 6];
  const INPUT_NUMBERS_TO_END = ['1000', '1,2,3,4,5,6', '7'];

  mockRandoms([RANDOM_NUMBERS_TO_END]);
  mockQuestions([input, ...INPUT_NUMBERS_TO_END]);

  // when
  const app = new App();
  await app.play();

  // then
  expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[ERROR]'));
};

그냥 쓰윽 읽어보면 뭐가 문제인 지 잘 모르겠다. 하지만 2주차 때의 예외를 던지는 부분을 살펴보면서 비교해보면 차이가 무엇인지 분명하게 알 수 있다.

test.each([[["pobi,javaji"]], [["pobi,eastjun"]]])(
    "이름에 대한 예외 처리",
    async (inputs) => {
      // given
      mockQuestions(inputs);

      // when
      const app = new App();

      // then
      await expect(app.play()).rejects.toThrow("[ERROR]");
    }

ApplicationTest 코드의 rejects를 살펴보니 이전 테스트 케이스와는 사뭇 다른걸 확인할 수 있었다. rejects는 비동기 함수가 실행되었을 때 예외가 발생한 부분의 프라미스를 반환하고, 그 프라미스가 에러를 던져야 한다는 것을 테스트한다. 그리고 내가 사용했던 toThrow는 던져진 에러의 유형을 지정하는 데 사용되고, 예외를 던져졌을 때의 메시지의 [ERROR] 가 포함이 되어야한다. 하지만 이번 3주차 코드의 마지막 부분을 보면 expect.stringContaining("[ERROR]") 부분이 있었다.

async play() {
    try {
      await this.#lottoGame.playGame();
    } catch (error) {
      Console.print(error.message);
    }
  }

예외가 발생했을 때 에러를 던지기 전에 string[ERROR] 가 포함이 되어야하는 것을 의미하는 것이었다. 즉, 에러를 보내는 것에서 끝내는 것이 아닌 에러의 메시지를 Console 로 출력해야하는 것이었다. 지금까지 내가 에러를 보낸 방식은 잘못되었다는 것을 이번 주차에서 깨달았다. 우선 Input과 관련된 부분을 모두 Console로 출력할 수 없기 때문에 전체적으로 관리하는 App.js 에서 이 역할을 부여받아 try...catch 문을 통해 에러의 메시지를 출력하는 과정까지 마치니 모든 테스트 케이스를 통과할 수 있었다.

사실 이번 오류를 통해 try...catch 내에 error 객체는 namemessage 속성을 갖고있기 때문에 출력을 할 때 해당 에러의 message 만 골라서 출력할 수 있다는 것을 처음 알 수 있었다. 또한 기본적으로 나에게 주어진 코드가 있고, 그것을 이용해야 한다면 우선적으로 해당 코드에 대해 꼼꼼하게 어떤 의미를 담고 있는지에 대해 분석을 해야한다는 점을 깨달았다.

❌ Rank 오류

// before

getRank(lotto, winningNumber, bonusNumber) {
    const sameNumberCount = this.getSameNumberCount(lotto, winningNumber);
	...
    if (sameNumberCount === 5 && lotto.includes(bonusNumber)) return 2;
	...
  }

  getRankStatistic(winningNumber, bonusNumber) {
    this.#lottos.forEach((lotto) => {
      const rank = this.getRank(lotto, winningNumber, bonusNumber);
    ...
    });
    ...
  }

Rank 라는 순위를 계산하는 클래스 내의 코드 일부이다. 순위를 계산하기 위해서는 로또, 당첨 번호, 보너스 번호 가 필요하기 때문에 이를 인자로 받아야 했다. 하지만 클린코드 원칙 중에서 인자를 2개 이상 받는 코드는 비추천한다는 글을 읽었었기 때문에 이를 그대로 받는 것은 문제가 있어 보였다.

따라서 props 객체를 활용해 인자의 수를 줄이기로 마음먹었다.

하지만 이 부분부터 문제가 발생하였다. 기존에 존재하는 배열을 밖에서 이미 받게 되었는데 이 로또를 어떻게 사용할 수 있을까? 하는 고민이 있었다. private 필드에서 선언을 해두고 해야할까? 아니면 Rank라는 클래스에 Lotto 클래스를 상속받을까? 그러면 Lotto 클래스가 두 곳에서 상속을 받아야 하기 때문에 모든 로직이 꼬일 것만 같았다.

// after

getRank(props) {
    const sameNumberCount = this.getSameNumberCount(props.winningNumber);
  	...
    if (sameNumberCount === 5 && props.lotto.includes(bonusNumber)) return 2;
  	...
  }

  getRankStatistic(props) {
    props.lottos.forEach((lotto) => {
      const rank = lotto.getRank(props.winningNumber, bonusNumber);
      ...
    });
    ...
  }

하지만 이것을 모두 lotto 를 인자로 받으면 해결이 되는 문제였다. 함수에서 3개의 인자를 받는 것은 불필요할 것 같아서 3개의 인자보다는 이것을 props 로 받으니 코드가 깔끔해졌다.

🎯 새로 배운 내용

🔖 객체 지향적인 코드 작성

이번 3주차 미션에서 가장 신경을 많이 썼던 부분이 바로 객체 지향적인 코드를 작성하는 것이었다. 우선 이번 주차에서는 능동적인 객체 를 만드는 것과 캡슐화를 잘 적용해보는 것을 목표로 잡았다. 능동적인 객체를 만들기 위해서 로또 게임을 할 때 필요한 데이터들을 공통 피드백에 적혀있듯이 클래스를 기능별로 분리를 해봤다.

import Lotto from './Lotto.js';

class Lottos {
  #lottos = [];

  constructor(count) {
    for (let i = 0; i < count; i += 1) {
      this.#lottos.push(new Lotto(LottoNumberGenerator.generate()).getLotto());
    }
  }

  getLottos() {
    return this.#lottos;
  }
}

게임을 진행하기 위해서 크게는 구매 금액, 로또 번호, 당첨 번호, 보너스 번호가 필요했고 이를 이용해서 순위를 정하고 최종 수익률을 산출한다. 따라서 로또 번호, 당첨 번호, 보너스 번호를 통해 데이터를 얻고 이것을 RankMoney 라는 객체와 협력한다면 충분히 get ,set 만 남발하지 않는 능동적인 객체를 만들 수 있을 것 같았다. 추가적으로 이번 미션에서는 Lotto 라는 클래스를 이미 주었는데 이 클래스로 하나의 로또를 관리하고 이것을 상속받는 클래스를 하나 생성하여 구매금액에 따른 갯수만큼의 로또 배열을 반환하면 더 객체 지향적인 코드를 작성하였다.

  #lottos;
  #rankStatistic;

  constructor(lottos) {...}

  #getSameNumberCount(lotto, winningNumber) {...}

  #getRank(lotto, props) {
    const count = this.#getSameNumberCount(lotto, props.winningNumber);
    ...
  }

  getRankStatistic(props) {
    this.#lottos.forEach((lotto) => {
      const rank = this.#getRank(lotto, props);
      if (rank) this.#rankStatistic[rank - 1] += 1;
    });
    return this.#rankStatistic;
  }
}

캡슐화를 적용하기 위해서는 private field 를 적극 활용하였다. 기존에는 변수만 private로 선언했지만 다른 객체에서 사용되지 않을 메서드 같은 경우에는 private로 선언하여 외부 변동이 적어지고 에러가 발생할 가능성이 적어진다는 것을 배웠다. 그래서 한 객체 안에서만 협력하는 메서드는 모두 private로 선언하여 캡슐화를 지키려고 노력하였다.

🔖 비동기를 사용해 Input 받기

// before

async readCarsName(callback) {
  try {
    const input = await Console.readLineAsync(PRINT_MESSAGE.inputNames);
    InputValidator.validateCarName(input);
    callback(input);
  } catch (error) {
    throw new Error(error);
  }
}

프리코스 2주차에 작성했던 InputView 코드의 일부이다.입력값을 callback함수에 입력하여 callback 함수에서 입력값을 처리하는 기능을 할 수 있게끔 코드를 작성하였다. 내가 사용하는 이유는 모듈화를 하기 위함이었다. 이렇게 작성하면 코드의 깊이가 깊어져 캡슐화에 용이할 것이라고 판단했다.

// after
async readPurchasePrice() {
  const inputPrice = await Console.readLineAsync(MESSAGE.purchase);

  Validator.inputPurchaseAmount(inputPrice);

  return inputPrice;
}

하지만 이미 비동기 함수인 awaut 을 사용하고 있다는 점을 간과했다. await 을 사용하기 때문에 callback 까지 사용한다면 두번의 과정으로 입력값을 처리하는 것이었고, 괜히 함수를 의미없게 깊게 만드는 것 같았다. 이러한 이유로 callback 대신 입력받은 값을 return만 하는 것으로 함수를 수정하였다.

🔖 도메인 단위 테스트 하기

이번 3주차 미션에서 중요한 요구사항으로 도메인 단위로 테스트 케이스를 작성하는 것이었다. 예시로 LottoTest라는 테스트 케이스가 주어지고 이걸 이용해서 다른 도메인의 케이스도 작성하는 것이었는데, 우선 도메인 단위로 테스트하는 것의 중요성에 대해 알고싶었다. 내 개인적인 생각으로는 내가 직접 구현한 기능을 세부적으로 테스트 해야 많은 예외 사항을 체크해 최종적으로 오류 발생률을 줄일 수 있을 것 같았다.

더 조사해보니, 많은 테스트 케이스를 실행하면서 새로운 기능을 추가하거나 버그를 잡는 것에 있어서 유용하고, 협업을 하는 과정에서도 여러 테스트를 공유하며 의사소통이 활발해진다고 한다.

내가 작성한 도메인만 6개가 되었기 때문에 6개의 클래스를 테스트할 수 있는 테스트 파일을 만들고, 세부적인 메서드를 모두 체크할 수 있게끔 작성하였다. 결과적으로 총 21개의 테스트를 진행하였고, 디테일한 코드까지 실행해볼 수 있어서 코드를 관리하는데 있어서 유용했다. 협업을 할 때 정말 필요한 기술을 미리 작성해보면서, 능동적인 코드를 생성한 것 같아 뿌듯했다.

🎯 리팩토링

✏️ props 사용하기

보통 인자로 값을 받을 때 변수를 많이 사용한다. 하지만 Rank 와 같이 3개 이상의 값을 통해서 결과를 도출해야 하는 함수를 만들 때 인자의 수가 너무 많은 것이 가독성 부분에서 좋아보이지 않았기 때문에 객체 를 인자로 넘겨주었다. 그 객체가 바로 props 이다.

props 안에 로또, 당첨 번호, 보너스 번호를 객체 형태로 넘겨주니 인자는 한개로 줄어들어서 모듈화가 되고, 보기에 편해졌지만, 컨트롤러에서 초기화할 때 객체 안에 상당히 긴 코드가 작성되어 코드가 길어지는 단점이 생겼다. 그래도 3개의 인자를 받는 것보다는 더 나은 방법이라 생각이 든다.

✏️ if / switch 말고 다른 방법은?

if(sameNumberCount === 3) return 5;
if(sameNumberCount === 4) return 4;
if(sameNumberCount === 5 && /* 보너스 번호가 포함된 경우)**/ return 2;
if(sameNumberCount === 5) return 3;
if(sameNumberCount === 6) return 1;

당첨 번호와 로또 번호를 비교하면서 같은 숫자의 개수를 카운트할 때, if문을 사용했었다. 흠… 솔직히 직관적으로 이해하기에는 편리하나 굳이 sameNumberCount 를 5번까지 쓰면서 코드를 길게까지 하는건 좀 아닌 것 같았다.

const count = this.#getSameNumberCount(lotto, props.winningNumber);
switch (count) {
  case 3:
    return 5;
  case 4:
    return 4;
  case 5:
    if (lotto.includes(props.bonusNumber)) return 2;
    return 3;
  case 6:
    return 1;
}

음…그나마 변수가 계속 중복되는 것이 보기 안좋아서 switch 문으로 변경했지만 이 역시도 함수의 길이가 너무 길어지고 숫자를 하드코딩 하는 점이 상당히 마음에 들지 않았다.

const count = this.#getSameNumberCount(lotto, props.winningNumber);
const rankMapping = {
  3: NUMBER.fifthPlace,
  4: NUMBER.fourthPlace,
  5: lotto.includes(props.bonusNumber) ? NUMBER.secondPlace : NUMBER.thirdPlace,
  6: NUMBER.firstPlace,
};
return rankMapping[count];

함수를 사용해볼까…하다가 props를 사용했던 점을 활용해보고 싶었다. 객체를 만들어서 후에 필요한 값만 쏙쏙 빼온다면 뭔가 코드가 좀 간결해질 것 같았다. 엄청 마음에 들지는 않지만 if문으로 남발했던 코드보다는 나아진 것 같다.

🎯 개선해야할 점

사실 앞에 비동기 테스트 코드 관련해서 너무 많은 시간을 쏟았다. 결국 혼자 고민하고 해결하는 과정에서 얻은 부분이 많았지만, 주어진 코드를 꼼꼼하게 분석하지 못한 것도 어느 정도 영향이 컸던 것 같다. 이런 경우가 2번, 3번 생긴다면 시간적으로 많이 손해를 볼 것 같아서 앞으로 주어진 코드를 확인할 때, 요구사항 분석을 잘 해야할 것 같다.

또한 다른 분의 코드를 살펴보니 static을 자주 사용하는 것을 볼 수 있었다. 특히 InputViewOutputView 를 static으로 묶고 다른 파일에서 끌어오는 방법인 것 같은데, 4주차 미션 시작 전에는 이 부분에 대해서 조금 공부를 하고 필요하다면 나도 접목시켜보고 싶다.

🎯 마무리

이번 주차 한줄평을 다음과 같이 할 수 있을 것 같다.

부족한 부분을 채우면서 성장할 수 있었다.

2주차 미션을 진행할 때에는 끈임없는 의문을 갖고 새로운 기술에 대해 탐구하는 과정이었다면 이번 3주차 미션에서는 의문을 확신으로 바꾸고자 노력하였다. 지금까지 프리코스를 진행하면서 ‘다른 사람들은 이렇게 작성하는데 나는 이렇게 작성하는 것이 맞다고 생각하는데…이게 아닌가?’ 라는 생각을 정말 많이 했다.

코드 리뷰를 받으며 스스로 부족한 점은 채우려고 노력하였고, 누군가에게 자신있게 기술 사용 이유에 대해 설명할 수 있다면 기존에 내가 작성한 코드를 밀고 나갔다. 수동적으로 받아들이기만 한 것이 아닌 기존 나의 코드에 필요한 점만 채워나가니 전보다 더 발전된 코드를 작성할 수 있었던 것 같다.

🧑🏻‍💻 내가 작성한 코드

👉 우아한테크코스 3주차 미션_로또

코드 리뷰는 언제든 환영입니다! 🙂