[nextstep] - TDD, 클린 코드 with JavaScript 5기 회고 (2023. 07 .19 ~ 2023. 08. 30)

jiny·2023년 10월 3일
0
post-thumbnail

자동차 경주 부터 로또 미션 까지 진행하며 배운 것들에 대해 나의 시선에서 다시 한번 정리하고자 글을 작성하게 되었다.

TDD

우리가 흔히 알고 있는 TDD는 Test Driven Development로써, 아래와 같은 메커니즘을 가지고 있다.

  1. 테스트 코드 작성 하기
  2. 테스트 코드에 대한 내용 구현하기
  3. 테스트 코드가 올바르게 동작하는지 확인하기

물론 이 내용이 틀린 얘기는 아니지만, 개발을 하다보면 예상치 못한 에러나, 설계 과정에서 발생하는 문제들로 인해 TDD를 못 지키는 경우가 많으며 실제로 이번 미션을 진행하면서 이 과정을 대부분 지키지 못했다. (특히 1번 -> 2번의 과정으로 진행하는데 어색함 + 어려움으로 인해 고민 끝에 포기하게 되었다.)

하지만 nextstep의 메이커준님 께서는 TDD는 위와 같은 과정들이 아닌 TDD 적인 사고방식을 갖는 것을 강조하셨다.

TDD적인 사고방식

  1. 코드가 의도한 대로 동작 하는지 빠르게 피드백을 받기
  2. known to unknown

TDD의 핵심은 피드백을 더 자주 더 빨리 받는 것이며 개발하는데 있어서 더 빨리, 즉각적으로 TDD 적으로 문제를 해결하는 경험이 중요한 것이다! TDD를 통해 나오는 부산물이 테스트 코드라고 이해하는 것이 좋다.

TDD로 인해, 테스트 코드 짜기 위해 시간이 너무 오래 걸리거나, 요구사항이 자주 바뀌게 된다면 바로 베타로 배포해서 유저에게 피드백을 받기 위한 인프라를 구축하는 것이 중요할 수도 있다. (ex) 뱅크 샐러드)

어떻게 하면 TDD 적으로 문제를 더 잘 해결 할 수 있을까? (테스트 코드가 나오지 않더라도 TDD 적으로 문제를 해결할 수 있는 방법은 무엇일까?)

동작 가능한 가장 작은 버전으로 만들기 (핵심을 포함한 MVP 제품)

example(계산기)

  • 2개의 숫자를 다루는 계산기를 만든다. (최소한의 기능)
  1. Ui 없이 사칙연산이 되는 것만 먼저 빠르게 만든다.
  2. input으로 사용자 입력 받고 이벤트를 처리한다. (최소한의 이벤트 처리)
  3. 숫자 UI 추가하고 복잡한 이벤트를 처리한다.
  4. UI 레이아웃을 잡는다.
  5. 스타일을 입힌다.

핵심은 모든 step 에서도 동작 가능해야 한다.

테스트 코드도 짤 수 있으면 퍼포먼스 적으로 좋지만, 테스트 라이브러리 까지 학습하는데 오래 걸리기 때문에, 피드백 받는 사이클이 길어질 것이다.

이렇게 step 별로 하다보면 재 사용 가능하고, 읽기 쉬운 코드가 되기 위해 리팩토링을 자동적으로 하게 될 것이다.

TDD 사이클 (7단계)

  1. 전체 문제가 해결되었을 때 어떤 상태일지 상상해본다. (결국 내가 뭐하려는 걸까?)
  2. 적당한 난이도로 문제를 쪼개거나 변경해본다. (단, 핵심을 포함하기)
  3. 핵심에 가까우면서, 지금 내 상태에서 비교적 쉽게 할 수 있는 적절한 것을 하나 선택한다.
  4. 결과의 모습이 뭔지 구체화하고 시뮬레이션 해보기
  5. 동작 가능한 가장 작은 버전의 솔루션을 만들고, 테스트가 통과하는지 확인한다.
  6. 리팩터링을 하면서 중복을 줄이거나 의도를 드러나게 한다.
  7. 다시 1번이나 2번으로 돌아간다. (결국 내가 무엇을 하려는건지 이해하면서 개발해 나간다.)

TDD를 통해 3step으로 개발을 진행하진 못했지만, 이번 과정을 통해 "TDD는 step 대로 개발 하는 것이 아닌 수 많은 요구사항을 기능 별로 잘게 쪼갠 후 조금씩 구현해보며 빠르게 피드백하는 경험이 중요하다." 라는 것을 알 수 있었다.

로또 미션 TDD CYCLE을 만들어 보며 동작 가능한 작은 모듈들을 쪼개어 개발하는 경험을 할 수 있었고, 피드백이 빠르게 되다보니 문제가 발생하게 되면 해결하기 위한 solution을 빠르게 찾을 수 있었던 경험을 할 수 있었다.

자동차 경주 미션

자동차 경주 미션에서는 미션 수행에 대한 올바른 방향성과 클린 코드에 대한 내용들에 대해 수업을 듣고 나만의 시선에서 정리해볼 수 있었다.

피드백을 대하는 자세

nextstep에서 배운 3가지 내용들에 대해 스스로 생각하고 실천했던 내용들을 남겨보게 되었다.

모든 피드백은 비판적으로 수용하기

비판적으로 얘기한 내용이 없어 따로 관련 링크가 없지만 비판적으로 수용하기 보단 리뷰어 님들이 남겨주신 코멘트를 읽어본 후 chat gpt나 구글링을 통해 관련 자료를 찾아보며 전적으로 수정하기 보단 "코멘트를 남겨주신 내용의 근거"에 대해 스스로 찾아보고 학습하려 노력했다.

또한 피드백 해주신 내용들에 대한 나의 생각들을 PR 리뷰에 따로 기록해 놓기도 했다.

동의하지 않는다면 토론하기

자동차 경주 step2에서 "규모가 있는 프로덕트 내의 view는 쉽게 몇 백개가 넘어갈 수 있을 텐데 현재와 같은 구조가 된다면 View의 크기는 금방 비대해지지 않을까요?"라는 질문을 주셨었고, 동의하지 않는 것은 아니었지만 질문에 대한 내 생각에 대해 구체적인 근거를 남겨 고민하고 적용해 보려 했던 과정들을 구체적으로 코멘트 남겼었다.

핵심은 1번과 마찬가지로 리뷰어 님들의 코멘트를 100% 수용하는 것이 아닌 스스로 찾아보고 검증하여 나만의 의견을 만들 수 있도록 하는 것이 중요하다고 생각했다.

제안받은 방법이 이해가 가지 않는다면 다시 질문하기

이해가 아예 안되는 경우도 있겠지만 제안 받은 방법을 구현하는 과정에서 헷갈리고, 100% 확신할 수 없는 내용들도 있었다. 구현하면서 관련 내용들에 대해 재 PR 요청을 하며 내 생각들을 따로 기록 후 궁금한 점들을 따로 여쭤보았다.

정리한 내용들에 대해서 리뷰어님들도 왠만하면 답변해주셨다. 앞으로도 궁금하거나 어려웠던 점들을 따로 정리 후 PR 요청 때 여쭤보는 방향이 빠르게 답장을 받을 수 있다는 점과 필요한 부분 들을 느꼈다.

요구사항 정리

TDD 사이클에서 중요하게 강조했던 건 동작 가능한 가장 작은 버전부터 조금씩 기능 개발을 하는 것이었다.

이렇게 순차적으로 개발을 하기 위해선 기능에 대한 요구사항을 스스로 정리하는 능력, 기존 요구사항을 확인 후 추가적인 요구사항을 캐치할 수 있는 능력을 강조하셨다.

미션을 진행하는 동안 기능 요구사항을 추가적으로 작성 해보려고 했고, 고려했던 점은 아래와 같다.

  1. 기능에 대한 예외 상황 까지 고려한다.
  2. 전체적인 동작 시나리오를 고려 하여 step-by-step으로 분리한다.
  3. 그 기능에 대해 추가적인 요구사항이 없는지 확인한다.

읽기 좋은 코드에 대해 생각해보기

좋은 프로그래머는 사람이 이해할 수 있는 코드를 짠다. - <리팩터링(Refactoring>

리팩터링 저자의 말 처럼 개발은 혼자가 아닌 함께 업무를 수행한다.

그렇기 때문에 모두가 이해할 수 있고, 읽기 쉬운 코드를 만드는 것이 중요하다.

고려했던 점은 아래와 같다.

변수 이름을 축약하지 않는다.

export const genResultArray = (racingResult) =>
  racingResult
    .at(-1)
    .split('\n')
    .map((s) => {
      const [racer, distance] = [s.split(' : ')[0], s.split(' : ')[1].length];
      return [racer, distance];
    });

export const genRacingWinners = (racingResult) => {
  const result = genResultArray(racingResult);
  const maxDistance = genMaxDistance(result);
  return result.filter(([_, distance]) => distance === maxDistance).map(([racer, _]) => racer);
};

이전 까지 변수명이 짧아야 보는 사람들이 쉽게 파악할 수 있을 것이라고 생각했었다.

하지만 변수명을 짧게 하기 위해 축약하는 코드는 읽는 사람으로 하여금 한번 더 생각하게 만드는 코드라는 피드백을 받게 되었다.

피드백을 받고나선 축약된 코드가 읽는 사람 입장에서 모호한 의미를 전달할 수 있다고 느꼈고 모호한 이름보단 네이밍이 길어져도 명확한 이름을 가지는 것이 중요하다는 것을 알 수 있었다.

피드백 후의 코드는 아래와 같다.

export const createResultArray = (racingResult) =>
  racingResult
    .at(-1)
    .split('\n')
    .map((s) => {
      const [racer, distance] = [s.split(' : ')[0], s.split(' : ')[1].length];
      return [racer, distance];
    });

export const createRacingWinners = (racingResult) => {
  const result = genResultArray(racingResult);
  const maxDistance = genMaxDistance(result);
  return result.filter(([_, distance]) => distance === maxDistance).map(([racer, _]) => racer);
};

gen이라는 네이밍 대신 create라는 prefix를 붙임으로써, 생성의 의미를 명확하게 전달할 수 있었다.

하드 코딩 하지 않는다.

isValidName(name) {
  return name.length >= 1 && name.length <= 5;
}

isValidName 함수의 경우 자동차 이름의 범위가 1 ~ 5 인지 확인하고 있다.

이는, 비즈니스 규칙이기 때문에 읽는 사람 입장에선 순간적으로 왜 1~5자 일까? 라는 고민을 할 수 있다.

function isValidCarNames(carNames) {
  // 쉼표를 기준으로 자동차 이름을 분리
  const names = carNames.split(",");

  // 모든 이름이 1자 이상 인지 확인
  return names.every(name => name.length >= 1);
}

또한, isValidName 이외 다른 함수에서 동일하게 name의 최소 길이 or 최대 길이를 확인하는 함수가 있다면 중복되는 코드가 발생하고 code small이 나게 된다.

isValidName(name) {
  return name.length >= MIN_CAR_NAME_LENGTH && name.length <= MAX_CAR_NAME_LENGTH;
}

위와 같이 상수를 만들고 이름을 부여함으로써, 단순한 숫자가 어떤 역할을 하는지 명확하게 파악이 가능하다.

또한, 최소 길이가 만약 변경되게 된다면 상수 값을 사용하는 곳에서 일일히 모두 변경하는 것이 아닌 상수 값만 변경하기 때문에 간편하다.

함수는 한 가지 기능만 담당하게 한다.

GameController.js

class GameController {
  constructor() {
    this.racingTrack = new RacingTrack();
    this.racingWinners = new RacingWinners();
  }

  async #settingRacingCar() {
    const carNames = await InputView.inputCarNames();
    this.racingTrack.setRacingCars(carNames);
  }

  async run() {
    await this.#settingRacingCar();
  }
}

settingRacingCar의 담당 역할을 분리하면 아래와 같다.

  1. InputView를 통해 유저가 입력한 carNames를 받아온다.
  2. racingTrack에 carNames를 update 한다.

settingRacingCar는 여러 역할을 수행하고 있는 것을 알 수 있으며 아래와 같은 이유로 역할을 분리할 필요가 있다.

  1. 재 사용성 - 특정 로직을 다른 함수에서 필요로 할 수 있다.
  2. 테스트 코드 - 알아보기 쉬운 코드는 테스트 항목을 빠르게 체크 할 수 있으며, 이로 인해 생각할 시간을 줄일 수 있다.
  3. 유지보수 - 복잡한 코드는 읽는 사람으로 하여금 코드를 읽기 어렵게 만든다.
async inputCarNames() {
  const carNames = await InputView.inputCarNames();
  return carNames;
}

async #settingRacingCar(carNames) {
  this.racingTrack.setRacingCars(carNames);
}

유저로 부터 입력 받는 부분은 inputCarNames로 분리 후, 입력 받은 값을 그대로 인자로 받아 setRacingCars의 인자로 전달 했다.

이렇게 함으로써, settingRacingCar 함수는 이제 racingTrack에 carNames를 셋팅 하는 역할만 수행하며, 입력 받는 부분은 inputCarNames가 대신 수행한다. 즉, 각 함수가 하나의 역할을 수행하는 것을 알 수 있다.

async inputCarNames() {
  try {
    const carNames = await InputView.inputCarNames();
    Validator.validateCarNames(carNames)
    return carNames;
  } catch(error) {
  	OutputView.printErrorMessage(error.message);
    process.exit();
  }
}

만약 입력 받은 값이 잘못된 값이라면 프로그램을 종료한다. 라는 요구사항이 추가로 생기게 되면 입력 받는 로직에서 쉽게 확장도 가능한 것을 알 수 있다.

자바스크립트 내장 메서드 활용하기

const addValues = (arr) => {
  let sum = 0;
  const copy = [...arr]
  for(let i = 1; i <= copy.length; i++) {
      sum += copy[i];
  }
  return sum;
}

addValues의 경우 arr의 요소 들을 모두 더한 값을 반환하는 역할을 수행하고 있다.

const addValues = (numbers) => {
  return numbers.reduce((prevSum, number) => prevSum + number , 0)
}

이는, reduce로 더 가독성 있게 바꿔볼 수 있다.

reduce 이외에도 정말 다양한 내장 메서드 들이 존재하니 잘 활용할 필요가 있다는 것을 알게 되었다.

로또 미션

로또 미션에서는 모델링 & 설계에 대해 수업을 진행했고, 객체지향 및 함수형 프로그래밍에서 사용하고 있는 레퍼런스들에 대해 이해하고 고민하여 코드에 적용하려고 시도했었다.

자율적이고 협력적인 객체를 고민하기

미션의 중점 요소가 모델링 & 설계 였던 만큼 로또 미션에선 객체 지향적인 설계를 하기 위해 역할과 협력을 중점으로 클래스를 설계했다.

협력을 고려할 때 가장 중요하다고 생각하는 것은 객체에게 전달할 메시지를 메서드로 표현하는 것이다.

데이터 중점으로 객체를 설계하게되면, 무분별한 getter와 setter로 인해, 자율적이지 않으며 다른 객체의 데이터 제공자가 되어 객체의 캡슐화가 전혀 이루어지지 않게 된다.

하지만, 메서드를 중점으로 객체를 설계하게 되면 메시지는 public으로 주고 받으며, 세부 내용은 private으로 숨길 수 있기 때문에 캡슐화가 잘 이루어지며, 결합도와 응집도가 높은 클래스를 설계할 수 있다.

이런 이유에서 위 그림과 같이 클래스를 구성했고, 화살표는 의존 관계, 화살표에 있는 텍스트는 객체 - 객체 간 협력(메시지) 관계를 표현하여 각 객체가 주어진 역할을 자율적으로 처리하고 객체 내 부족한 정보를 다른 객체가 제공할 수 있는 협력 적인 관계를 설계하고자 했다.

물론, 마음처럼 쉽게 되지 못했었다.

LottoGame.ts

export default class LottoGame {
  createWinningLottoNumbers(winningNumbers: string) {
    return Lotto.fromByString(winningNumbers, SYMBOLS.COMMA).getLottoNumbers();
  }

  createLottoNumbers(amount: number) {
    const lottos = LottoMerchant.from(amount).sellLotto();
    return lottos.map((lotto) => lotto.getLottoNumbers());
  }

  createResults({ winningLottoNumber, bonusNumber, lottoNumbers, investmentAmount }: CreateResultsParams) {
    return Bank.from(winningLottoNumber, bonusNumber).calculateResults(lottoNumbers, investmentAmount);
  }
}

LottoGame의 경우 step1에서 controller - model 간 데이터를 주고 받기 위한 모델로 설계했었다.


export default class LottoGame {
  buyerInfo: BuyerInfo = {
    lottos: [],
    investmentAmount: 0,
  };

  winningLottoInfo: WinningLottoInfo = {
    winningLottoNumbers: [],
    bonusNumber: 0,
  };

  setWinningLottoInfo({ winningLottoNumbers, bonusNumber }: WinningLottoInfo) {
    this.winningLottoInfo = { winningLottoNumbers, bonusNumber };
  }

  setBuyerInfo({ lottos, investmentAmount }: BuyerInfo) {
    this.buyerInfo = { lottos, investmentAmount };
  }

  createWinningLottoNumbers(winningNumbers: string) {
    return Lotto.fromByString(winningNumbers, SYMBOLS.COMMA).getLottoNumbers();
  }

  createLottoNumbers(amount: number) {
    const lottos = LottoMerchant.from(amount).sellLotto();
    this.setBuyerInfo({ lottos, investmentAmount: amount });
    return lottos.map((lotto) => lotto.getLottoNumbers());
  }

  createResults({ winningLottoNumber, bonusNumber, lottoNumbers, investmentAmount }: CreateResultsParams) {
    this.setWinningLottoInfo({ winningLottoNumbers: winningLottoNumber, bonusNumber });
    return Bank.from(winningLottoNumber, bonusNumber).calculateResults(lottoNumbers, investmentAmount);
  }
}

하지만 cli 기반 애플리케이션이 아닌 브라우저 기반 애플리케이션에서 LottoGame을 재 사용하면서 다음과 같이 변경되었고 다음과 같은 문제가 생겼다.

  1. 한 클래스에서 하나의 역할만 수행하지 않고 로또를 구매하는 과정과 당첨 로또를 통해 당첨 결과를 확인하는 역할을 모두 수행한다.
  2. 1번으로 인해 응집도가 많이 낮아진 객체가 되었다.
  3. LottoGame에 상태가 추가되면서, 변경의 가능성이 있기 때문에 step1의 controller에 악영향을 미칠 수 있다.

하나의 역할만 수행할 수 있도록 객체를 분리했다.

LottoBuyer.ts

export default class LottoBuyer {
  private buyerInfo: BuyerInfo;

  constructor() {
    this.init();
  }

  private setBuyerInfo({ lottos, investmentAmount }: BuyerInfo) {
    this.buyerInfo = { lottos, investmentAmount };
  }

  public init() {
    this.buyerInfo = {
      lottos: [],
      investmentAmount: 0,
    };
  }

  public getBuyerInfo() {
    return this.buyerInfo;
  }

  public buyLottos(amount: number) {
    const lottos = LottoMerchant.from(amount).sellLotto();
    this.setBuyerInfo({ lottos, investmentAmount: amount });
    return this.buyerInfo.lottos.map((lotto) => lotto.getLottoNumbers());
  }
}

LottoResult.ts

export default class LottoResult {
  private lottoBuyer: LottoBuyer;

  constructor(lottoBuyer: LottoBuyer) {
    this.lottoBuyer = lottoBuyer;
  }

  private createWinningLottoNumbers(winningNumbers: string) {
    return Lotto.fromByString(winningNumbers, SYMBOLS.COMMA).getLottoNumbers();
  }

  private calculateResults({ winningLottoNumber, bonusNumber, lottoNumbers, investmentAmount }) {
    return Bank.from(winningLottoNumber, bonusNumber).calculateResults(lottoNumbers, investmentAmount);
  }

  public createResults({ winningLottoNumbers, bonusNumber }) {
    const winningLottoNumber = this.createWinningLottoNumbers(winningLottoNumbers);
    const { lottos, investmentAmount } = this.lottoBuyer.getBuyerInfo();
    const lottoNumbers = lottos.map((lotto) => lotto.getLottoNumbers());
    return this.calculateResults({ winningLottoNumber, bonusNumber, lottoNumbers, investmentAmount });
  }
}

로또 구매는 LottoBuyer가, 로또 결과 확인은 LottoResult에서 수행하는 것을 알 수 있다. 즉, 객체가 하나의 역할을 수행하게 된다.

이로 인해, 만약 '로또 구매'에 대한 추가적인 요구사항이 발생하면, LottoBuyer에서 '당첨 결과'에 대한 추가적인 요구사항이 발생하면 LottoResult에서 쉽게 변경이 가능한 설계가 된 것을 알 수 있다.

또한, 역할이 명확하기 때문에 특정 기능에 문제가 생긴다면 빠르게 확인이 가능하며, 외부에서 사용할 때도 쉽게 사용할 수 있다.

한 객체에서 하나의 기능만 수행하기 위한 메서드들과 변수들로 구성되어 응집도 또한 올라간 것을 알 수 있다.

불변성 유지하기

함수형 프로그래밍에서 중요한 요소 중 하나인 불변성을 유지함으로써, 의도치 않게 데이터가 변경되는 side effect를 방지할 수 있다.

이런 불변성을 유지하는 방법은 아래와 같다.

변수 선언 시 되도록 const를 사용한다.

let x = 10;
let y = 20;

const setValues = () => {
  x = 30;
  y = 40;
}

const calculateSum = () => {
  return x + y;
}

setValues();
console.log(calculateSum()); // 출력: 70, 예상: 30

x,y가 let으로 선언되어 값이 변경될 수 있는 것을 알 수 있다.

이로 인해, setValues를 호출하면서 x, y가 값이 바뀌게 되고 calculateSum()의 결과 값이 30이 아닌 70이 나오는 것을 알 수 있다.

let을 const로만 바꿔도 문제를 해결해 볼 수 있다.

const x = 10;
const y = 20;

const setValues = () => {
  x = 30;
  y = 40;
}

const calculateSum = () => {
  return x + y;
}

setValues();
console.log(calculateSum()); // 출력: 30, 예상: 30

x와 y를 const로 바꿔주어도 불변한 값이기 되기 때문에 예상한 값이 나오는 것을 알 수 있다.

즉, 정말 필요한 경우에만 let을 활용하고, 되도록 const를 활용해야 한다.

값을 변경할 때에는 복사본을 만들어 활용한다.

const a = [1,2,3];

const addValues = (arr, element) => arr.push(element)

addValues(a, 4);

console.log(a) // [1,2,3,4]

위 코드 처럼 addValues를 통해 값을 추가할 수 있지만 이는, 원본 데이터를 직접 조작하는 것이고, 의도하지 않은 버그를 만들어내기가 쉽다. 이런 경우 앱의 규모가 커질수록 디버깅도 쉽지 않을 수 있다.

let a = [1,2,3];

const addValues = (arr, element) => {
	return [...arr, element];
}

a = addValues(a, 4);

console.log(a) // [1,2,3,4]

addValues에서 원본 배열이 아닌 복사본을 반환함으로써, a는 불변한 데이터를 받을 수 있으며 이전 보다 예측 가능해진 것을 알 수 있다.

순수 함수(pure function)와 부수 효과(side effect)가 있는 함수를 구분한다.

const numbers = [1, 2, 3, 4, 5]; 

// 비 순수 함수인 shuffle
function shuffle() {
    return numbers.sort(() => Math.random() - 0.5);
}

shuffle 함수의 경우 함수 외부에 있는 numbers에 영향을 받고 있다.

const numbers = [1, 2, 3, 4, 5]; 

function addValues (array, element) {
	array.push(element)
}

// 비 순수 함수인 shuffle
function shuffle() {
    return numbers.sort(() => Math.random() - 0.5);
}

// ... 여러 로직
addValues(numbers, 6)
// ... 여러 로직
shuffle()

이렇게 여러 복잡한 로직들 사이에 numbers의 값을 변경하는 함수가 존재한다면, shuffle의 경우 예측된 값이 아닌 값을 반환하게 될 것이다.

const numbers = [1, 2, 3, 4, 5]; 

function addValues (array, element) {
	array.push(element)
}

// 비 순수 함수인 shuffle
function shuffle(numbers) {
    return numbers.sort(() => Math.random() - 0.5);
}

// ... 여러 로직
addValues(numbers, 6)
// ... 여러 로직
shuffle(numbers)

shuffle 함수에서 shuffle할 배열을 받아 shuffle 된 배열을 반환한다면, 항상 5개의 요소가 있는 배열을 받아 이전보다 예측 가능한 로직이 될 것이다.

미션 내에서 함수형 사고를 생각해보진 못했지만, 앞으로 구현할 로직들에 대해 외부의 영향을 받는 함수와 아닌 함수를 구분하여, 최대한 순수 함수로 변경할 수 있는 연습이 필요하다는 것을 느꼈다.

NEXT-STEP 미션 레포지토리

https://github.com/jinyoung234/nextstep-tdd-with-cleancode-js

0개의 댓글