OOP와 책임주도설계

SangHyeon Lee·2024년 12월 21일
0

시작하며

친구 '정'모씨와 9월부터 10월까지 OOP 스터디를 진행했다.

누군가는 취준할 때 SOLID원칙을 외웠었다 그러고,

막연하게 중요한 개념인 것 같으면서 선뜻 손이 안 갔던 개념이라 시작을 망설였다.

정모씨의 '비즈니스-UI 로직이 컴포넌트 안에서 뭉쳐있던 경험을 해보지 않았냐',

'추상적인 것일수록 한번쯤은 접해봐야 한다'는 말에 넘어가버렸다.

시간이 지난 지금와서 돌이켜보자면,

좋은 코드에 대해 고민할 수 있게 해준 선택이었다.

OOP 공부는 '객체지향의 사실과 오해' 라는 책으로 진행했다.

여기에는 그 내용보다는, 이를 직접 적용해본 경험을 서술하려 한다.

실습

상황

숫자 아구 게임을 제작하기로 했다.

생각해보면, 우테코 프리코스에서도 TDD에 대한 영상이 올라오는 등

OOP에 대한 고민이 있었어야 했던 시간이었음을 깨닫게 되었다.

아주 대표적인 프리코스 문제로 이전 코드와 비교해보기도 좋아 선정했다.

책임 주도 설계

책에 나온대로 유스케이스를 먼저 작성했다.
이후 협력을 생각하고, 도메인 모델과 책임을 다음과 같이 정했다.

각 시도별 자잘한 이름은 다를 수 있으나 큰 틀은 다음과 같다.

  • 경호원: validity를 체크한다.
  • 출제자: 게임의 정답을 출제한다.
  • 심판: 사용자의 입력을 정답과 비교한다.

첫 번째 시도

설계

이 때, 위에 추가해서 안내자를 객체로 설계했다.

인터페이스 제작 자체는 크게 막히는게 없었다.

당시의 주석은 다음과 같다.


// interface QuestionerInterface<T> {
//   // 자료구조를 마음대로 정해도 되는가?
//   getAnswer: () => T[];
// }

// interface AnnouncerInterface<S, T> {
//   getErrorComment: (result: S) => string;
//   getGameComment: (result: T) => string;
//   getInitComment: () => string;
// }

// interface GuardInterface<U> {
//   getValidity: () => U;
// }
// interface HeadGuardInterface extends GuardInterface<ValidityResultType> {
//   getValidity: () => ValidityResultType;
// }

// interface ExchangerInterface<S, T> {
//   getArrangedInput: (input: S) => T;
// }

// interface HeadRefreeInterface<T> {
//   getResult: () => T;
// }
// interface SubRefreeInterface<T> {
//   getCount: (input: T) => number;
// }
// ////////////// 애매한게, 서로 주고 받는 메시지 형식을 정해야 하는데, interface로 똑같이 두기에는 애매해서 일단 type로 둠.
// type AnswerType = number[];
// type ValidityResultType = {
//   inputLengthValidity: boolean;
//   inputTypeValidity: boolean;
// };
// type RoundResultType = {
//   ballCount: number;
//   strikeCount: number;
// };
// type SubValidityResultType = {
//   result: boolean;
// };
// type UserInputType = string;
// type InputArrangedType = number[];

당시 class 이외의 구현체가 사용되는 경우를 생각해 설계했으나,

구현하지 못하고 스터디 시간이 되었다.

정모씨와의 작은 토론이후 class를 사용할 수 밖에 없다는 결론을 내렸다.

object를 구현체로 사용하기에는 재사용성에서 불편했기 때문이다.

이후 class로 구현하고 동작을 우선시하여 코드를 작성했다.

구현

결론적으로, 책에서 하지 말라는 것은 다 했던 것 같다..

  • 로직에 사용될 자료구조가 변할 수 있다 생각했기에 제네릭을 남발했다.
  • 클래스 내에 사용될 객체들을 내부에서 직접 생성했다.
  • 로직을 한 곳에서 관리하고자 생각에 로직을 따로 모아두어 import해왔다.

작성한 코드는 다음과 같다.


class Worker<T> implements WorkerI<LogicResultT> {
  private ingredients: T;
  private logic: (ingre: T) => LogicResultT;
  constructor(ingredients: T, logic: (ingre: T) => LogicResultT) {
    this.ingredients = ingredients;
    this.logic = logic;
  }
  get result() {
    // 로직과 요소를 합쳐 동작시킨다
    return this.logic(this.ingredients);
  }
}

class HeadGuard<T extends HeadGuardLogicIngredientsI> implements HeadGuardI {
  private logicGoalMap: {
    [goals in GuardGoalsT]: (ingre: T) => GuardResultT;
  };
  private guards: Worker<T>[];
  constructor({ ...props }: T) {
    this.logicGoalMap = {
      type: typeGuardLogic,
      len: lenGuradLogic,
      phase: phaseGuardLogic,
    };
    this.guards = Object.values(this.logicGoalMap).map(
      (logic) => new Worker(props, logic)
    );
  }

  validate() {
    // 외부 import로부터의 로직으로 만들어진 guard들을 실행시켜 결과를 반환한다.
  }
}

class HeadReferee implements WorkerI<RefereeResultT> {
  private logicsAndGoals: {
    logic: (ingre: LogicIngredientsI) => RefereeResultT;
    goal: RefereeGoalsT;
  }[];
  private referees;
  constructor(input, answer) {
    this.logicsAndGoals = [
      { logic: strikeLogic, goal: 'strike' },
      { logic: ballLogic, goal: 'ball' },
    ];

    this.referees = this.logicsAndGoals.map(
      ({ logic }) => new Worker({ input, answer }, logic)
    );
  }

  get result() {
    // 대충 외부에서 import해온 로직으로부터의 referee들을 실행시켜, 그 결과를 반환한다.
  }
}

class Game implements GameI {
  private _input: DataT;
  private answer: DataT;
  private phase: number;
  private isEnd: boolean;
  constructor(answer: DataT = new Questioner(3).answer) {
    this.answer = answer;
    this.phase = 0;
    this.isEnd = false;
  }
  _validate() {
    // new HeadGuard에 입력,정답,페이즈를 넣어 validate시킨다.
    headGuard.validate();
  }
  _judge() {
    // 대충 new HeadReferee에 입력과 정답을 넣어 결과를 반환한다.
    // 결과 중 phase정보를 통해 end여부를 판단한다.
  }
  process() {
    // validate 후 phase++. 이후 judge결과를 반환한다.
  }
  set input(newIinput: DataT) {
    // 입력할 수 있게 한다.
  }
}


class Questioner {
  private len;
  constructor(len: number) {
    this.len = len;
  }
  _makeRandomDigit() {
    return Math.floor(Math.random() * 9);
  }
  get answer() {
    // 대충 _makeRandomDigit으로 정답을 만든다
    return result;
  }
}

export default Game;

연결

node환경에서 동작하도록 연결하는 과정에서 오류를 찾기 어려워 헤맸다.

역설적으로 TDD의 중요성을 체감했다.

두 번째 시도 : Top-Down

정모씨의 코드를 보고 DTO, DI와 같은 개념의 부재로 기준 없는 코드를 작성했음을 느꼈다.

  • DTO를 통해 객체간 메시지를 객체로 만들어 익숙한 사용감을 주었다.

  • 생성자 주입을 통해 테스트에 용이하고, 책임이 확실하게 분리된 클래스를 제작할 수 있었다.

설계

무엇보다도, 이전의 시도와 다른 점은 설계였다.

아예 클래스 다이어그램을 그렸는데, 그림을 그리니 훨씬 설계에 용이했다.

최상위 클래스인 Controller와 view와의 연결이 어색하여,

가장 바깥 부분부터 편하게 생각하는 Top-Down방식으로 먼저 설계했다.

DTO들은 의도적으로 클래스만 제작했는데,

비즈니스 로직에 들어가는 객체들은 잘 변하지 않을 것이라는 점과,

데이터 객체들을 다른 객체들과 구분짓고 싶었기 때문이다.

구현

이전보다는 편했지만

클래스를 구현하고 테스트 코드를 작성하며 수정할 부분이 생겼을 때,

상단부터 하단의 모델까지 많은 부분이 바뀌어야 했다.

사용하는 입장에서 설계하기에 생각하기 편하지만, 단점이 존재함을 느꼈다.

폴더 구조는 다음과 같다.

Controller의 index.ts는 정모씨의 코드를 보고 힌트를 얻었다.

결국 생성자 주입을 적용하면,

최상단 클래스 생성자에는 여러 재료가 되는 인스턴스들을 인자로 넣어줘야 했다.

따라서, 각 인스턴스들을 미리 생성해놓고, 조립해주는 함수들을 만들었고,

이렇게 만든 Controller 인스턴스로 사용자에게 기능을 제공하도록 했다.

코드는 다음과 같다.

// 여기에 Game을 GameController에 넣는 동작을 만들어두자.
// 외부에서 controller사용할 때는, 어떤 게임 넣을지 직접 안하고,
//  메서드만 사용해서 실행시키기 위해.

/*
엄청난 클래스들 import부분
*/

// 게임에 필요한 설정들

const _makeConfig = () => {
  const DEFAULT_PHASE = 10;
  const DEFAULT_LEN = 3;
  const defaultNumberBaseballGameConfig = new DefaultGameConfigDTO(
    DEFAULT_PHASE,
    DEFAULT_LEN
  );
  return defaultNumberBaseballGameConfig;
};
const _makeGuard = () => {
  const typeGuard = new TypeGuard();
  const lenGuard = new LenGuard();
  const phaseGuard = new PhaseGuard();
  return new Guard1(typeGuard, lenGuard, phaseGuard);
};
const _makeReferee = () => {
  const strikeReferee = new StrikeReferee();
  const ballReferee = new BallReferee();
  return new Referee1([strikeReferee, ballReferee]);
};
const _makeAnswer = (answerLen: number) => {
  const typePitchingDTOguard = new TypePitchingDTOGuard();
  const identityPitchingDTOguard = new IdentityNumberPitchingDTOGuard();
  const answerMaker = new RandomAnswerMaker(
    typePitchingDTOguard,
    identityPitchingDTOguard
  );
  return answerMaker.makeAnswer(answerLen);
};

// 게임 생성

const _makeGame = () => {
  const config = _makeConfig();
  const guard = _makeGuard();
  const referee = _makeReferee();
  const answer = _makeAnswer(config.dataLen);

  return new NumberBaseballGame(config, guard, referee, answer);
};
const numberBaseballGame = _makeGame();

// 컨트롤러 생성

const defaultGameController = new DefaultGameController(numberBaseballGame);

const CustomConfigGameController = (config: GameConfigDTO) => {
  const customConfig = new DefaultGameConfigDTO(config.phase, config.dataLen);
  const guard = _makeGuard();
  const referee = _makeReferee();
  const answer = _makeAnswer(config.dataLen);
  const customNumberBaseballGame = new NumberBaseballGame(
    customConfig,
    guard,
    referee,
    answer
  );

  return new DefaultGameController(customNumberBaseballGame);
};

export default defaultGameController;
export { CustomConfigGameController };

세 번째 시도 : Bottom-Up

이번엔 바텀 업으로 다시 설계를 해봤다.

Bottom의 장점으로는 변화에 유연하다는 것인데,

이는 상위 구조가 하위 구조들로 이뤄지도록 제작하기 때문이다.

또한, 가장 낮은 구조부터 제작하므로, TDD가 자연스럽다는 것도 장점이다.

설계

다이어그램은 다음과 같다.

이전보다 객체간 책임을 명료화 하는데 힘을 쓰니, 구현에 도움이 되었다.

객체가 가져야 할 동작들의 기준이 세워지게 되었다.

DTO들도 interface를 갖게 해서, 보다 유연하게 만들었다.

Top-Down과 비교하면 달라진 부분들이 몇 있을 것인데,

대부분 책임을 다시 생각하는 과정에서 달라졌다.

예를 들어, PitchingDTO에 대해 factory와 validator를 제작하지 않는 것이 있다.

Bottom-Up 설계를 하면서, answer 데이터를 answerMaker가 만들지 않는다면,

또 다른 사용자로부터 받게 될텐데, 이에 따른 객체를 그 때 따로 만드는게 책임을 적절히 분리하는 것이라 생각하여 간단하게 DTO만 남겼다.

또한, 말 그대로 class를 구현체로 삼다 보니, 네이밍 방식도 변화가 있었다.

구현 방법에 맞춰 구현체 이름을 짓게 되었다.

구현

폴더 구조는 다음과 같다.

서비스 내부에서만 사용되는 타입들도 전부 models안에 넣었다.

컨트롤러까지 올라가는 데이터들은 DTO로, 서비스에서만 사용되는 것들은 단순 타입으로 정의했다.

import JudgeTypes from '../../RuleTerms/Judgement/types/JudgeTypes';

type JudgeResult = {
  [key in JudgeTypes]?: number;
};
export { JudgeResult };

이번 컨트롤러 index.ts는 사용자 입장을 더 신경써서 제작했다.

게임 컨트롤러로 필요한 작업 중 컨트롤러 클래스를 직접 사용하는 메서드들과 아닌 메서드가 존재했기에,

클로저를 활용해서 사용자에게 메서드들을 제공했다.


function _makeDefaultDistinguishingRefereeMaster() {
  const strikeReferee = new DefaultStrikeReferee();
  const ballReferee = new DefaultBallReferee();
  return new DistinguisingRefereeMaster([strikeReferee], [ballReferee]);
}
function _makeInsensitiveGuardMaster() {
  const typeguard = new DefaultTypeGaurd();
  const lengaurd = new DefaultLenGuard();
  const phaseGuard = new DefaultPhaseGuard();

  return new InsensitiveGuardMaster(typeguard, lengaurd, phaseGuard);
}
function _makeConfig() {
  const DEFAULT_PHASE = 10;
  const DEFAULT_DATALEN = 3;
  return new DefaultGameConfigDTO(DEFAULT_PHASE, DEFAULT_DATALEN);
}
function _makeAnswer(datalen: number) {
  const answerMaker = new RandomAnswerMaker();
  return answerMaker.makeAnswer(datalen);
}
function _makeGameMaster(config: GameConfigDTO = _makeConfig()) {
  const defaultRefereeMaster: RefereeMasters =
    _makeDefaultDistinguishingRefereeMaster();
  const defaultGuardMaster: GuardMaster = _makeInsensitiveGuardMaster();
  const answer = _makeAnswer(config.dataLen);
  const gameMaster = new PitchingGameMaster(
    defaultRefereeMaster,
    defaultGuardMaster,
    answer,
    config
  );
  return gameMaster;
}
const initCustomGame = (gameMaster: GameMaster) => {
  const gameController = new DefaultGameController(gameMaster);
  return gameController;
};

const initPithicngGame = (config: GameConfigDTO = _makeConfig()) => {
  const gameMaster: GameMaster = _makeGameMaster(config);
  const gameController = new DefaultGameController(gameMaster);
  const restartGame = (config: GameConfigDTO) => {
    const newGM = _makeGameMaster(config);
    return new DefaultGameController(newGM);
  };
  /**
   * gameRun 설명
   * @param input number[]
   * @returns { isSuccess: boolean;  detailResult: {strike:number, ball:number};  remainPhase: number;}
   */
  const gameRun = (input: number[]) => {
    const pitchingInput = new ArrayPitchingDTO(input);
    return gameController.run(pitchingInput);
  };
  return {
    gameRun,
    restartGame,
    end: gameController.end,
    getLastestResult: gameController.getLastestResult,
  };
};

export { initPithicngGame };

끝내며

OOP의 최대 장점을 느낄 수 있었다.

바로, "유연한 설계"이다.

애초에 프로그래밍을 하며 제대로 설계해본 경험이 없었는데,

이번 경험을 통해 변화에 유연하고 에러가 적은 구현을 할 수 있었다.

또한, 프론트엔드에서도 반드시 적용되어야 할 개념도 적용해 볼 수 있었는데,

"책임 분리", "적절한 추상화 레벨", "테스트 코드 작성"이다.

사실, 이번 실습이후에 고민이 커졌다.

이렇게 장점이 많은데, 프론트엔드에서 적용하기가 어려워보였기 때문이다.

다음 몇 포스트들은 이에 대한 고민을 담은 글일 것이다.

*보너스* 프리코스 시절 코드

import { MissionUtils } from '@woowacourse/mission-utils';

const getCom = function generateComputer(computer) {
  while (computer.length < 3) {
    // 함수 이름에 따라 Number만 선택됨.
    let number = MissionUtils.Random.pickNumberInRange(1, 9);
    if (!computer.includes(number)) {
      computer.push(number);
    }
  }
};

const getBbNum = async function inputTreating() {
  // 인풋 받기

  let rawBaseballNum = await MissionUtils.Console.readLineAsync(
    '숫자를 입력해주세요 : '
  );

  // valid 확인 - 문자열 길이
  if (rawBaseballNum.length != 3) {
    throw new Error('[ERROR] 숫자가 잘못된 형식입니다.');
  }

  // valid 확인 -  각 자리 값
  let parsedBaseballNum = rawBaseballNum.split('').map((raw) => {
    let parsed = Number(raw);
    if (parsed === 0 || Number.isNaN(parsed)) {
      throw new Error('[ERROR] 숫자가 잘못된 형식입니다.');
    }
    return parsed;
  });

  // valid한 인풋 값.
  return parsedBaseballNum;
};

const match = function matchingComputerNumWithBaseballNum(
  computer,
  baseballNum
) {
  // 비교하기
  let strikes = 0;
  let balls = 0;

  // 비교하기 - 숫자 포함 여부부터.
  baseballNum.forEach((bNum, index) => {
    if (computer.includes(bNum)) {
      if (bNum === computer[index]) {
        strikes += 1;
        return false;
      }
      balls += 1;
    }
  });

  // 비교하기 - 코멘트 정하기
  let ballComment = balls !== 0 ? `${balls}` : '';
  let strikesComment = strikes !== 0 ? `${strikes}스트라이크` : '';
  let beteween = balls !== 0 && strikes !== 0 ? ' ' : '';

  let commentFinal =
    balls === 0 && strikes === 0
      ? '낫싱'
      : `${ballComment}${beteween}${strikesComment}`;

  // 비교하기 = 출력하기
  MissionUtils.Console.print(commentFinal);

  if (strikes === 3) {
    return true;
  }
  return false;
};

class App {
  async play() {
    try {
      MissionUtils.Console.print('숫자 야구 게임을 시작합니다.');
      let start = 1;
      while (start === 1) {
        // 컴퓨터 값 생성.
        let computer = [];

        getCom(computer);

        let correct = false;
        // 비교
        while (!correct) {
          let baseballNum = await getBbNum();
          correct = match(computer, baseballNum);
        }

        // 게임 종료 시.
        MissionUtils.Console.print('3개의 숫자를 모두 맞히셨습니다! 게임 종료');

        // 계속 및 종료
        start = Number(
          await MissionUtils.Console.readLineAsync(
            '게임을 새로 시작하려면 1, 종료하려면 2를 입력하세요.\n'
          )
        );

        // 계속 및 종료의 에러 체크
        if (start !== 1 && start !== 2) {
          throw new Error('[ERROR] 숫자가 잘못된 형식입니다.');
        }
      }
    } catch (e) {
      throw e;
    }
  }
}

export default App;

책임과 객체에 대한 이해가 하나도 없는 모습이다...

그래도 이 때랑 비교하면 성장 체감은 확실히 되는 것 같다.

profile
회고할 가치가 있는 개발을 하자

0개의 댓글

관련 채용 정보