우아한테크코스 6기 프리코스 - 숫자 야구 미션 회고(1)(with 객체 지향 패러다임 적용하기)

jiny·2023년 10월 25일
1
post-thumbnail

1주차 온보딩 미션으로 숫자 야구 미션이 나오게 되었고, 구현 및 테스트를 마친 상태에서 글을 작성하게 되었습니다.

이번 미션에서 스스로 어떤 목표를 설정해서 어떻게 그 목표를 달성할 수 있었는지를 기록해보며 회고를 진행해보려고 합니다.

그 첫 번째 목표로 미션 내 객체지향 패러다임을 적용하기 까지의 과정과 이를 통해 느끼고 배웠던 점들에 대해 서술해 보려고 합니다.

객체 지향 패러다임 적용 하기

해당 목표를 설정한 이유

첫 번째 목표를 듣고 많은 분들이 의아해 하실거라고 생각합니다.

왜 프론트엔드 개발자가 객체 지향 패러다임을 적용하려고 할까?

프론트엔드 개발자들이 많이 사용하는 자바스크립트 프레임워크인 React의 경우에도 React 16부터 클래스 컴포넌트가 아닌 함수형 컴포넌트를 사용할 정도로 프론트엔드 생태계에선 함수형 프로그래밍을 주목하고 있고 실제로 사용하고 있습니다.

하지만, JavaScript는 만들어질 당시 함수형 패러다임Java의 문법을 일부 차용해서 만들어졌을 만큼, 함수형 패러다임객체지향 패러다임이 어우러진 멀티 패러다임 언어 입니다.

또한, ES6 부터 생성자 함수가 아닌 Class 라는 문법이 추가되었으며 ES2019private class field(#)이 추가되어 기존과 달리 편하게 사용이 가능합니다.

따라서, 자바스크립트의 특성을 잘 사용하는 것이 프론트엔드 개발자로써 중요하다고 생각했기 때문에 이번 미션에서 객체지향 패러다임을 최대한 적용해보려 했습니다.

애플리케이션 설계 시 각 객체 간 역할, 협력, 책임을 고려하여 MVC 패턴으로 설계하기

설정한 목표에 여러가지 Action이 드러나다보니 하나씩 설명 드리고자 합니다.

역할, 책임, 협력

🥹 역할 & 책임 & 협력이 무엇일까?

  • 역할 - 각 객체 들이 협력 안에서 수행하는 책임 들의 집합
  • 책임 - 객체가 협력에 참여하기 위해 수행하는 작업(메서드)
  • 협력 - 애플리케이션의 기능을 구현하기 위해 각 객체 들이 수행하는 상호작용

객체지향 애플리케이션연극에 비유해보면 쉽게 이해할 수 있습니다.

연극 무대 위에서, 연기자 A가 대사 속 특정 행동을 하기 위해 연기자 B에게 도움을 요청하는 것협력에 해당합니다.

또한, 연기자 B는 그 요청에 응답하여 자신의 대사나 행동을 수행하는 것책임에 해당합니다.

마지막으로, 연기자 A와 B가 수행하는 특정한 대사나 행동들의 집합역할이라고 말할 수 있습니다.

이렇게 각 배우(객체)들은 자신의 역할과 책임에 따라 연극(애플리케이션) 속에서 서로 협력하게 되고, 이런 상호작용을 통해 멋진 연극의 이야기(애플리케이션의 기능)가 만들어집니다.

이렇게 연극 처럼 프로그래밍비슷한 방식으로 기능을 구현할 수 있습니다.

객체 지향 프로그래밍에서의 기능 구현 방식

  • 각 객체들이 특정 기능을 구현하기 위해 다른 객체에게 협력을 요청(메서드 호출)
  • 협력에 부합한 책임을 담당하는 객체는 그 협력에 응답하여 자신이 맡은 일을 수행(메서드 실행) 후 그 결과를 객체에게 전달
  • 이런 상호작용들을 통해 객체는 각자 맡은 일을 열심히 수행하는 객체지향적인 애플리케이션 탄생

영화 티켓 구매를 통해 살펴보는 절차지향적 코드

class Person {
  name;
  
  ticket;
  
  constructor(name) {
    this.name = name;
    this.ticket = null;
  }
  
  getName() {
    return this.name;
  }
  
  setName(name) {
    this.name = name;
  }
  
  setTicket(ticket) {
  	this.ticket = ticket;
  }
  
  getTicket() {
  	return this.ticket;
  }
}

class Movie {
  title;

  constructor(title) {
    this.title = title;
  }

  getTitle() {
    return this.title;
  }

  setTitle(title) {
    this.title = title;
  }
}
class MovieTicketSeller {
  movie;
  
  person;

  constructor() {}

  sellTicket(person, movie) {
    this.person = person;
    this.movie = movie;
    console.log(`${person.getName()}님이 ${movie.getTitle()} 영화 티켓을 구매했습니다.`);
    person.setTicket({name : person.getName(), movieTitle : movie.getTitle()})
  }
}

const jiny = new Person('jiny');
const avengers = new Movie('어벤져스: 엔드게임');
const seller = new MovieTicketSeller();

seller.sellTicket(jiny, avengers);
jiny.getTicket()

person, movie, movieTicketSeller(영화 티켓 판매자)을 통해 영화 티켓 구매 기능을 구현하는 예시를 만들었습니다.

personmoviegetter와 setter를 통해 이름과 티켓, 영화 제목을 자유롭게 설정하며 movieTicketSeller의 경우 personmovie를 받아 해당 기능 들을 구현하고 있습니다.

저는 현재 코드가 객체지향 적이지 않다고 생각하며 이유는 아래와 같습니다.

객체 지향적인 코드가 아닌 이유

  • person은 영화 티켓이 있긴 하지만, 스스로 구매한 것이 아닌 MovieTicketSeller에 의해 타의적으로 얻게 되었다.
  • person과 movie는 단지 데이터 제공자의 역할로 외부에서 쉽게 변경 시킬 수 있어 캡슐화를 위배한다.

person이 스스로 영화 티켓을 구매하지 않는 것은 보는 사람 입장에서 로직을 이해하기 어렵게 만들며 외부에서 쉽게 변경이 가능하기 때문side effect발생할 가능성이 있다는 문제점을 야기합니다.

이러한 문제점 들은 객체가 요구사항에 대해 스스로 책임을 수행하게 함으로써 개선할 수 있습니다.

class Person {
  #name;
  constructor(name) {
    this.#name = name;
  }
  buyTicket(seller) {
    return seller.sellTicket(this.#name);
  }
}

class MovieTicketSeller {
  #movie;

  constructor(movie) {
    this.#movie = movie;
  }
  
  sellTicket(name) {
    console.log(`${name}님이 ${this.#movie.getTitle()} 영화 티켓을 구매했습니다.`);
    return {
      movieTitle: this.#movie.getTitle(),
      date: new Date()
    };
  }
}

const jiny = new Person('jiny');
const avengers = new Movie('어벤져스: 엔드게임');
const seller = new MovieTicketSeller(avengers);

console.log(jiny.buyTicket(seller))

PersonMovieTicketSeller수정할 때 고려했던 것은 아래와 같습니다.

  1. Person을 데이터 제공자로써의 역할이 아닌 MovieTicketSeller - Person 간 영화 티켓 구매의 협력하기 위해 존재하는 객체로 변경하기
  2. MovieTicketSeller은 Person에게 부족한 영화 판매 정보를 제공하기 위한 '영화 티켓 판매'의 책임으로써 일을 수행하기

Person영화 구매의 역할로써, MovieTicketSeller영화 판매의 역할로써 각자의 해야할 단 하나의 책임을 스스로 수행하는 것을 알 수 있습니다.

또한, Person에는 getter와 setter 없이 캡슐화가 잘 되어 있는 것을 알 수 있으며, 더 이상 외부의 영향을 받지 않고 영화 구매 요청이 오면 스스로 구매한 티켓을 반환하기 위한 객체로 바뀐 것을 알 수 있습니다.

역할, 책임, 협력을 고려하여 숫자 야구 게임 설계하기

이번 미션을 구현하기 위해 대략적인 flow를 정리했고, 다음과 같이 전개되었습니다.

컴퓨터 야구공 설정 ➡️ 유저 야구공 입력 ➡️ 두 야구공을 비교하여 스트라이크 및 볼 판정 ➡️ 조건에 부합할 시 비교 종료 ➡️ 게임 종료 명령어 입력 ➡️ 명령어에 따라 재 시작 하거나 게임 종료

flow를 정리해보며 어떤 객체가 필요할지 고민해봤을 때 야구공 생성을 수행할 객체와 야구공 비교을 수행할 객체가 필요하다고 생각했습니다.

따라서, flow를 분석한 것을 기반으로 필요한 협력을 만든 후 그 책임을 메시지 형태로 전달하는 것을 그림으로 표현하여 시각화 했습니다.

Computer.js

class Computer {
  
  #baseball;

  constructor() {
    this.#initBaseball();
  }

  #initBaseball() {
    this.#baseball = BaseballMaker.create().createBaseball();
  }

  #isStrike(playerBaseballNumber, digit) {
    return playerBaseballNumber === this.#baseball[digit];
  }

  #isBall(playerBaseballNumber, digit) {
    return (
      !this.#isStrike(playerBaseballNumber, digit) && this.#baseball.includes(playerBaseballNumber)
    );
  }

  #calculateCompareResult({ prevCompareResult: { strike, ball }, playerBaseballNumber, digit }) {
    return {
      strike: strike + (this.#isStrike(playerBaseballNumber, digit) ? 1 : 0),
      ball: ball + (this.#isBall(playerBaseballNumber, digit) ? 1 : 0),
    };
  }

  comparePlayerBaseball(playerBaseball) {
    return playerBaseball.reduce(
      (prevCompareResult, playerBaseballNumber, digit) =>
        this.#calculateCompareResult({ prevCompareResult, playerBaseballNumber, digit }),
      { strike: 0, ball: 0 },
    );
  }
}

App에서는 최종적으로 스트라이크 수, 볼 갯수를 필요로 할 것이라고 생각했습니다.

그래서, Computer를 통해 유저의 야구공과 컴퓨터의 야구공을 비교하여 그 결과 값을 도출해내기 위해 플레이어 야구공 비교 라는 협력에서 Computer 두 야구공을 비교하는 책임을 가지도록 했습니다.

각 메서드가 수행하는 책임은 아래와 같습니다.

Computer가 수행하는 메서드

  • calculateCompareResult - 야구 게임의 스트라이크와 볼 결과를 계산하는 메서드
  • isBall - 주어진 자릿수의 숫자가 볼인지 판단하는 메서드
  • isStrike - 주어진 자릿수의 숫자가 스트라이크인지 판단하는 메서드
  • initBaseball - BaseballMaker를 통해 야구공을 생성하여 Computer의 baseball 필드를 초기화 하는 메서드

Computer야구공 비교에 대한 협력은 comparePlayerBaseball를 통해 수행하며 구체적인 로직은 내부 private method로 통해 캡슐화함으로써 정해진 협력만 외부와 상호 작용 할 수 있도록 설계했습니다.

BaseballMaker.js

class BaseballMaker {
  #minNumber;

  #maxNumber;

  constructor() {
    this.#minNumber = GAME_TERMS.baseball.minNumber;
    this.#maxNumber = GAME_TERMS.baseball.maxNumber;
  }

  static create() {
    return new BaseballMaker();
  }

  createBaseball() {
    const baseball = new Set();
    while (baseball.size < GAME_TERMS.baseball.digit) {
      const baseballDigit = pickRandomNumberInRange(this.#minNumber, this.#maxNumber);
      baseball.add(baseballDigit);
    }
    return [...baseball];
  }
}

export default BaseballMaker;

또한, 컴퓨터는 야구공을 비교하려면 자신의 야구공이 있어야 합니다.

하지만, 스스로 야구공을 생성하는 것까지 수행하면 SRP에 위배되기 때문에 야구공 생성 이라는 책임을 수행하는 BaseballMaker 에게 요청함으로써, 필요한 기능을 구현할 수 있도록 설계하였습니다.

최종적으로 다음과 같은 클래스 다이어그램을 도출하게 되었는데, 잘 보면 Controller - View - ModelMVC 구조인 것을 알 수 있습니다.

이제 왜 MVC 패턴을 사용하였는지 말씀드리겠습니다.

MVC 패턴으로 설계한 이유

우선, MVC 패턴을 설계한 이유를 알기 전에 MVC 패턴이 뭔지 이해하는 것이 필요합니다.

제가 생각하는 MVC 패턴은 아래와 같습니다.

사용자 인터페이스, 데이터 및 논리 제어를 구현하는데 널리 사용되는 소프트웨어 디자인 패턴 - mdn -

즉, 비즈니스 로직model을 통해, UI 로직view, 애플리케이션 제어controller를 통해 서로의 관심사를 명확히 분리할 수 있으며, 이런 관심사의 분리를 통해 각 Layer의 역할을 명확히 정의하여 유지보수 및 재 사용성을 향상 시킨다는 이점이 있습니다.

하지만 이런 MVC 패턴복잡한 대규모 애플리케이션의 경우 모델과 컨트롤러가 압도적으로 커져 관리가 어려워질 수 있다는 단점이 존재합니다.

하지만, Model로써 설계한 객체가 2개라는 점미션 내 기능이 많지 않아 그렇게 복잡하기 않기 때문에 MVC 패턴을 사용하여 각 Layer의 역할을 명확히 할 수 있는 좋은 환경이라는 생각이 들어 해당 패턴을 통해 설계를 진행하게 되었습니다.

MVC 패턴을 구성하는 요소는 다음과 같습니다.

MVC 패턴의 요소

Model (모델)

  • 기능 구현에 필요한 데이터 들에 대해 내부 상태를 가진다.
  • 외부에서 값을 직접 변경하지 않고, 모델 내부 메서드를 호출하는 방식으로만 값을 변경할 수 있도록 한다.
  • 외부 로직에 관한 어떤 정보도 담지 않는다.

View (뷰)

  • 값을 저장하지 않는다.
  • 사용자에게 보여지는 모든 부분을 담당한다.
  • 외부 로직에 관한 어떤 정보도 담지 않는다.

Controller (컨트롤러)

  • 값을 저장하지 않는다.
  • model과 view와 상호작용하며 전체 애플리케이션을 제어한다.
  • 값의 변경이 필요할 때 M(모델)의 메서드를 호출하여 프로그램 내 값을 변경시킨다.
  • UI에 관련된 모든 로직은 V(뷰)의 메서드를 호출하여 실행한다.

controllermodel, view와 상호작용이 가능하지만, modelviewcontroller접근이 가능합니다.

또한, 전체적인 데이터 흐름Controller ➡️ Model ➡️ Controller ➡️ View의 순서로 전개되고 있는 것을 알 수 있습니다.

Controller

class GameController {
  #computer;
  #inputView;
  #outputView;

  constructor() {
    this.#inputView = InputView;
    this.#outputView = OutputView;
    this.#computer = null;
  }

  #restartGame() {
    this.run();
  }

  #requireInputExitGameCommand() {
    return this.#inputView.readExitGameCommand();
  }

  async #requireExitGameCommand() {
    const inputExitGameCommand = await this.#requireInputExitGameCommand();
    ExitGameCommandValidator.from(inputExitGameCommand).validateExitGameCommand();
    return Number(inputExitGameCommand);
  }

  async #processExitGameCommand() {
    const userCommand = await this.#requireExitGameCommand();
    if (userCommand === EXIT_COMMAND_TYPES.restart) {
      this.#restartGame();
    }
  }

  #requirePrintExitGame() {
    this.#outputView.printExitGame();
  }

  #requirePrintCompareResult({ strike, ball }) {
    this.#outputView.printCompareResult({ strike, ball });
  }

  #requireCompareResult(playerBaseball) {
    return this.#computer.comparePlayerBaseball(playerBaseball);
  }

  #requireInputPlayerBaseball() {
    return this.#inputView.readPlayerBaseball();
  }

  async #requirePlayerBaseball() {
    const inputPlayerBaseball = await this.#requireInputPlayerBaseball();
    BaseballValidator.from(inputPlayerBaseball).validateBaseball();
    return inputPlayerBaseball.split(SYMBOLS.emptyString).map(Number);
  }

  #requirePrintStartGame() {
    this.#outputView.printStartGame();
  }

  async #processGame() {
    this.#requirePrintStartGame();
    while (true) {
      const playerBaseball = await this.#requirePlayerBaseball();
      const { strike, ball } = this.#requireCompareResult(playerBaseball);
      this.#requirePrintCompareResult({ strike, ball });
      if (strike === BaseballMaker.BASEBALL_SHAPE.size) break;
    }
    this.#requirePrintExitGame();
  }

  #initGameSetting() {
    this.#computer = new Computer();
  }

  async run() {
    this.#initGameSetting();
    await this.#processGame();
    await this.#processExitGameCommand();
  }
}

GameController가 수행하는 메서드

  • Private 메서드
    • requirePrintStartGame - 게임 시작 메시지 출력 요청 메서드
    • requirePrintCompareResult - 컴퓨터와 플레이어의 숫자 비교 결과 출력 요청 메서드
    • requirePrintExitGame - 게임 종료 메시지 출력 요청 메서드
    • initGameSetting - 게임 시작 전 설정을 초기화하는 메서드
    • requireInputPlayerBaseball - 플레이어의 숫자 입력 값을 읽어오는 메서드
    • requireInputExitGameCommand - 플레이어의 게임 종료 명령어 값을 읽어오는 메서드
    • requirePlayerBaseball - 플레이어의 야구공 유효성 검사 후 값을 반환하는 메서드
    • requireExitGameCommand - 플레이어의 게임 종료 명령어에 대해 유효성 검사 후 값을 반환하는 메서드
    • requireCompareResult - 플레이어 야구공과 비교한 결과를 요청하는 메서드
    • processGame - 게임 시작부터 게임 종료까지의 로직을 수행하는 메서드
    • restartGame - 게임을 다시 시작하는 메서드
    • processExitGameCommand - 게임 종료 또는 재시작에 대한 요청을 처리하는 메서드
  • Public 메서드
    • run - 게임 설정, 게임 시작, 게임 종료 및 종료 명령어 처리를 실행하는 메서드

Controller로써 설계한 GameController크게 2가지 형태로 메서드 네이밍을 구성했습니다.

  • require - 다른 레이어와 상호작용하여 애플리케이션 흐름을 전개하는 메서드
  • process - require 관련 메서드들로 부터 데이터를 전달 받거나 전달하여 큰 기능을 수행(야구공 비교, 명령어에 따른 게임 재시작 및 종료)

해당 네이밍을 통해 controller의 본질적인 목적인 다른 모듈들과의 상호작용애플리케이션 흐름 제어역할을 명확히 하려고 노력했습니다.

또한, 다른 모듈들과 특정 일을 수행하는 것은 의존하는 것과 동일하게 위해 분리하지 않았을 때 발생하는 디버깅 문제을 최소화하려 했습니다.

각 메서드의 경우 단일 책임 원칙의존성 최소화를 위해 최대한 메서드를 잘게 분리하여 대응 하려했습니다.

View

InputView

const InputView = {
  async read(query) {
    const inputValue = await Console.readLineAsync(query);
    return inputValue;
  },

  async readPlayerBaseball() {
    const inputBaseball = await this.read(INPUT_MESSAGE.playerBaseball);
    return inputBaseball;
  },

  async readExitGameCommand() {
    const inputBaseball = await this.read(INPUT_MESSAGE.exitGameCommand);
    return inputBaseball;
  },
};

InputView가 수행하는 메서드

  • read - 주어진 쿼리를 사용하여 값을 비동기로 읽어오는 메서드
  • readPlayerBaseball - 플레이어의 야구공 입력 값을 비동기로 읽어오는 메서드
  • readExitGameCommand - 게임 종료 명령어 입력 값을 비동기로 읽어오는 메서드

InputView의 경우 입력 값 전달의 역할로써, readLineAsync를 통해 비동기적으로 유저의 값을 받아와 controller에게 전달하도록 설계하였습니다.

OutputView

const OutputView = {
  print(message) {
    Console.print(message);
  },

  printStartGame() {
    this.print(OUTPUT_MESSAGE_TEXT.gameStart);
  },

  printCompareResult({ strike, ball }) {
    this.print(OUTPUT_MESSAGE_METHOD.compareResult({ strike, ball }));
  },

  printExitGame() {
    this.print(OUTPUT_MESSAGE_TEXT.exitGame);
  },
};

OutputView가 수행하는 메서드

  • print - 주어진 메시지를 출력하는 메서드
  • printStartGame - 게임 시작 메시지를 출력하는 메서드
  • printCompareResult - 스트라이크와 볼 결과를 출력하는 메서드
  • printExitGame - 게임 종료 메시지를 출력하는 메서드

OutputView를 통해 전달 받은 데이터를 화면에 출력의 역할을 수행하도록 설계하였습니다.

View Layer를 잘 살펴보면 controller로 부터 받은 데이터에만 접근하며, 그 외에 MVC 중 어떠한 모듈에도 접근하지 않는 것을 살펴볼 수 있습니다.

MVC 패턴을 사용하면서 느낀점

이렇게 Controller - Model - View를 통해 각 Layer의 역할을 명확히 하여 관심사의 분리를 진행했고, 비즈니스 로직UI 로직이 깔끔하게 분리될 수 있었습니다.

Computer.test.js

describe('Computer 테스트', () => {
  beforeAll(() => {
    BaseballMaker.prototype.createBaseball = () => [3, 4, 5];
  });

  test.each([
    {
      input: [1, 2, 6],
      output: {
        strike: 0,
        ball: 0,
      },
    },
  ])(
    '플레이어가 선택한 야구공 $input과 비교한 결과는 $output.strike스트라이크 $output.ball볼 이다.',
    ({ input, output }) => {
      // given
      const computer = new Computer();
      // when
      const { strike, ball } = computer.comparePlayerBaseball(input);
      // then
      expect(strike).toBe(output.strike);
      expect(ball).toBe(output.ball);
    },
  );
});

이로 인해, Model LayerComputer정말 필요한 기능에 대해서만 집중할 수 있었고 이런 결과의 산출물은 테스트 코드에도 드러날 수 있었습니다.

UI 로직이 있었다면 ComputerConsole 모듈을 mocking 하여 UI가 제대로 출력되고 있는지의 여부도 테스트 해야 했지만, 분리가 되었기 때문에 기능 테스트에만 집중할 수 있었습니다.

즉, 야구공 비교에 대한 결과 동작을 확인해야 할 경우 Computer.test.js에서 확인해야 겠다는 결론을 빠르게 도출할 수 있었습니다.

또한, Model Layer의 경우 다른 Layer의 의존성이 존재하지 않기 때문에 재 사용이 쉬우며, 기능 확장을 해야할 경우에도 고민 없이 확장이 가능해지는 것을 알 수 있었습니다.

응집도와 결합도를 고려하여 모듈 설계하기

이번 미션을 통해 설계한 모듈 들이 높은 응집도낮은 결합도를 갖춰 변경 될 수 있는 근거를 최소화 하는 것을 하나의 목표로 설정했었습니다.

우선, 살펴보기 전에 응집도결합도에 대해 이해하는 것이 필요합니다.

응집도

모듈에 포함된 내부 요소들이 연관돼 있는 정도

즉, 응집도모듈이 담당하는 기능에 대해 얼마나 많은 연관성을 가지고 있는지를 나타냅니다.

높은 응집도의 경우 한 기능에 대한 필드와 로직들로 구성되어 있을 확률이 높아지기 때문하나의 이유로 그 모듈 전체를 변경 하도록 만들 수 있습니다.

반대로 낮은 응집도여러 기능에 대한 필드와 로직들로 구성되어 있을 확률이 높아져, 변경 될 수 있는 수 많은 원인을 제공하게 되고 계속해서 변경되다보면 언젠가 버그가 발생할 확률이 높아지게 됩니다.

결합도

의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도

즉, 결합도다른 모듈과의 의존성의 정도를 나타나게 됩니다.

낮은 결합도다른 모듈에 대해 정말 필요한 것만 알고 있기 때문에, 그 모듈의 퍼블릭 인터페이스가 변경되는 것이 아니라면 변경 될 가능성이 낮아집니다.

하지만, 높은 결합도다른 모듈의 특정 로직을 건들기만 해도 변경 될 수 있는 가능성이 커지기 때문애플리케이션이 불안정하며 버그 위험성에 노출될 수 있습니다.

따라서, 변경 가능성을 최소화 하기 위해선 기능을 구성하고 있는 객체들이 낮은 결합도와 높은 응집도를 유지할 수 있어야 합니다.

응집도와 결합도를 고려하지 못한 설계 (Validator)

초기의 Validator 설계

utils/validate.js

export const isEmptyInputValue = (value) => value === SYMBOLS.EMPTY_STRING;

export const isExistSpace = (value) => value.includes(SYMBOLS.SPACE);

export const isTypeOfNumber = (value) => /^\d+$/.test(value);

export const isValidDigit = (value) => value.length === GAME_TERMS.BALL.DIGIT;

export const isValidNumberRange = (value) =>
  value
    .split(SYMBOLS.EMPTY_STRING)
    .every((digit) => digit >= GAME_TERMS.BALL.MIN_VALUE && digit <= GAME_TERMS.BALL.MAX_VALUE);

export const isDuplicateNumbers = (value) =>
  new Set(value.split(SYMBOLS.EMPTY_STRING)).size < GAME_TERMS.BALL.DIGIT;

export const isValidUserCommand = (value) =>
  Number(value) === GAME_TERMS.USER_COMMANDS.RESTART ||
  Number(value) === GAME_TERMS.USER_COMMANDS.EXIT;

constants/message.js

const ERROR_MESSAGE = Object.freeze({
  EMPTY_VALUES: '아무것도 입력하지 않았으므로 다시 입력해주세요.',
  EXIST_SPACE: '입력한 값에 공백이 존재합니다.',
  AVAILABLE_NUMBER: '숫자만 입력이 가능합니다',
  AVAILABLE_DIGIT: `숫자는 ${GAME_TERMS.BALL.DIGIT}자리만 가능합니다.`,
  INVALID_RANGE: `입력한 숫자는 ${GAME_TERMS.BALL.MIN_VALUE}~${GAME_TERMS.BALL.MAX_VALUE}의 범위를 가져야 합니다.`,
  EXIST_DUPLICATE_VALUE: '입력한 숫자에 중복된 값이 존재합니다.',
  INVALID_USER_COMMAND: `명령어는 ${GAME_TERMS.USER_COMMANDS.RESTART}번 또는 ${GAME_TERMS.USER_COMMANDS.EXIT}번만 가능합니다.`,
});

constants/errorInstance.js

const ERROR_INSTANCE = {
    EMPTY_VALUES: new BallError(ERROR_MESSAGE.EMPTY_VALUES),
    EXIST_SPACE: new BallError(ERROR_MESSAGE.EXIST_SPACE),
    AVAILABLE_NUMBER: new BallError(ERROR_MESSAGE.AVAILABLE_NUMBER),
    AVAILABLE_DIGIT: new BallError(ERROR_MESSAGE.AVAILABLE_DIGIT),
    INVALID_RANGE: new BallError(ERROR_MESSAGE.INVALID_RANGE),
    EXIST_DUPLICATE_VALUE: new BallError(ERROR_MESSAGE.EXIST_DUPLICATE_VALUE),
    INVALID_USER_COMMAND: new BallError(ERROR_MESSAGE.INVALID_USER_COMMAND),
};

validator/BallValidator.js

const BallValidator = {
  validateBall(ball) {
    if (isEmptyInputValue(ball)) throw ERROR_INSTANCE.EMPTY_VALUES;
    if (isExistSpace(ball)) throw ERROR_INSTANCE.EXIST_SPACE;
    if (!isTypeOfNumber(ball)) throw ERROR_INSTANCE.AVAILABLE_NUMBER;
    if (!isValidDigit(ball)) throw ERROR_INSTANCE.AVAILABLE_DIGIT;
    if (!isValidNumberRange(ball)) throw ERROR_INSTANCE.INVALID_RANGE;
    if (isDuplicateNumbers(ball)) throw ERROR_INSTANCE.EXIST_DUPLICATE_VALUE;
  },
};

export default BallValidator;

초기에 Validator를 설계할 때 검증 함수는 재사용성을 위해 유틸 함수로써 utils 디렉토리로 설정했습니다.

ERROR_MESSAGE의 경우 INPUT_MESSAGE 등과 'message 관련 상수'로 묶어 관리하며, ERROR_INSTANCE따로 에러 객체들을 관리하기 위해 constants 디렉토리로 설정했습니다.

마지막으로 에러 메시지와 에러 객체, 검증함수를 통해 에러를 throw하는 검증 메서드(validateBall)로 유효성 검사를 진행하도록 설계했습니다.

설계 후 발생했던 문제점

유효성 검사를 추가 해야 할 경우 다음과 같은 순서의 작업들이 필요합니다.

  1. 에러 메시지 추가
  2. 에러 객체 추가
  3. 검증 함수 추가
  4. 검증 메서드 추가

하지만, 에러 메시지와 에러 객체는 constants, 검증 함수는 utils 디렉토리에 존재하기 때문유효성 검사를 추가할 때마다 다른 디렉토리 들을 계속해서 이동하다보니 굉장히 불편했으며 만약 검증 메서드가 문제가 생겼을 때도 다른 디렉토리 들을 이동해가며 문제점 들을 확인해야 했기 때문에 디버깅에 어려움이 있었습니다.

또한, 에러 객체에러 메시지중복된 프로퍼티를 가지고 있어 다른 메시지 및 객체를 추가할 때 마다 불필요한 작업을 반복해서 작업하고 있다고 생각했습니다.

즉, 에러 메시지 & 에러 객체 & 검증 함수Validator 내부에서 관리하면 효과적으로 관리할 수 있을 것이라고 생각했습니다.

필요한 것들을 Validator로 이동

import { GAME_TERMS } from '../constants/gameTerms';
import { SYMBOLS } from '../constants/symbols';
import AppError from '../errors/AppError';
import CommonValidator from './CommonValidator';

class BaseballValidator {
  #baseball;
  #commonValidator;

  constructor(baseball) {
    this.#commonValidator = new CommonValidator(baseball);
    this.#baseball = baseball.split(SYMBOLS.emptyString).map(Number);
  }

  static validationTypes = Object.freeze({
    availableNumber: Object.freeze({
      errorMessage: '숫자만 입력이 가능합니다.',
      isValid(baseball) {
        return baseball.every((ballNumber) => !Number.isNaN(ballNumber));
      },
    }),
	// ...
    }),
  });

  validateBaseball() {
    this.#commonValidator.validate();
    Object.values(BaseballValidator.validationTypes).forEach(({ errorMessage, isValid }) => {
      if (!isValid(this.#baseball)) throw new AppError(errorMessage);
    });
  }
}

export default BaseballValidator;

필요한 것은 에러 객체, 에러 메시지, 검증 함수 였습니다. 그래서, Validator에 필요한 것들을 모아 하나의 객체로 정리한 validationTypes(검사 유형) 라는 객체를 추가로 만들었습니다.

validationTypes는 각 유형에 대한 유효성을 검증하도록 하기 위한 isValid 프로퍼티와 유효성 검증 실패에 따른 errorMessage 프로퍼티로 묶어 표현했습니다.

검증 메서드인 validateBaseball은 이 validationTypesObject.values()를 통해 배열로 만든 후 검증 항목(key)에 따른 isValid 함수를 실행함으로써 유효성 검사를 실시하며 만약 실패할 경우 해당 항목의 에러 메시지를 에러 객체에 전달하도록 설계를 변경했습니다.

validationTypesstatic으로 설정한 이유는 테스트 코드에서 isValiderrorMessage 프로퍼티 항목으로 유효성 검사 테스트를 진행할 수 있도록 하기 위해서 입니다.

설계 변경 후 얻은 이점

static validationTypes = Object.freeze({
  availableNumber: Object.freeze({
    errorMessage: '숫자만 입력이 가능합니다.',
    isValid(baseball) {
      return baseball.every((ballNumber) => !Number.isNaN(ballNumber));
    },
  }),
  availableNumberRange: Object.freeze({
    errorMessage: `입력한 숫자는 ${GAME_TERMS.baseball.minNumber}~${GAME_TERMS.baseball.maxNumber}의 범위를 가져야 합니다.`,
    isValid(baseball) {
      return baseball.every(
        (ballNumber) =>
        ballNumber >= GAME_TERMS.baseball.minNumber &&
        ballNumber <= GAME_TERMS.baseball.maxNumber,
      );
    },
  }),
 // ...
});

에러 메시지, 검증 함수validationTypes관리되고 있기 때문에, 만약 새로운 유효성 항목이 필요하다면 validationTypes에서 쉽게 추가가 가능합니다.

또한, 유효성 검증이 예상과 다르게 동작했다면 Validator 내부에서 문제를 찾아 수정이 가능합니다.

즉, 높은 응집성을 위해 에러 메시지, 에러 객체, 검증 함수들을 Validator 내부에서 관리함으로써 기능 추가와 버그 수정이 하나의 모듈에서 관리 되어 유지보수가 용이할 수 있었습니다.

또한, 검증 항목이 추가되면 검증 메서드에서도 검증 함수와 에러 객체를 throw 하는 로직을 추가했었지만 validationTypes로 관리 하면서 forEach 메서드를 통해 검증 로직을 추가하지 않도록 하여 뛰어난 코드 가독성과 확장성을 유지할 수 있었습니다.

describe('예외 테스트', () => {
  test.each([
    {
      baseball: '12A',
      expectedErrorMessage: BaseballValidator.validationTypes.availableNumber.errorMessage,
    },
    // ... 
  ])(
    'baseball이 "$baseball"일 때 "$expectedErrorMessage" 메시지와 함께 에러가 발생해야 한다.',
    ({ baseball, expectedErrorMessage }) => {
      // given
      const validator = new BaseballValidator(baseball);
      // when
      const startValidation = () => validator.validateBaseball();
      // then
      expect(startValidation).toThrow(new AppError(expectedErrorMessage));
    },
  );
});

이는 테스트 코드에서도 편리할 수 있었는데, 검증 항목이 늘어나도 테스트 코드 자체는 변경점 없이, test case만 추가하여 쉽게 추가가 가능할 수 있었습니다.

2개의 댓글

comment-user-thumbnail
2023년 10월 25일

오호! 객체 내부에 팩토리 패턴을 이용해서 해시맵스럽게 key에 맞는 validation을 제공하는 구조군요!! :)
React-hook-form에서 쏟아지는 다양하고 복잡한 Input들을 관리하는데 애용하는데, 이렇게 응용할 수도 있는걸 보고 고정관념이 깨졌습니다! 엄청난 고민을 하신게 보이네요! :) 갓갓

1개의 답글